diff --git a/Sources/NotchFlowCore/MemoryObserverSessions.swift b/Sources/NotchFlowCore/MemoryObserverSessions.swift new file mode 100644 index 0000000..083d213 --- /dev/null +++ b/Sources/NotchFlowCore/MemoryObserverSessions.swift @@ -0,0 +1,38 @@ +import Foundation + +/// The claude-mem plugin compresses conversations into its memory store by +/// running headless "observer" Claude sessions in the background. They write +/// real transcripts under ~/.claude/projects and fire real hooks, so every +/// feed would surface them — but they are the memory system's plumbing, not +/// the user's work, and each one lights up the notch as a phantom agent that +/// wakes whenever a real session does. Observers all run from +/// ~/.claude-mem/observer-sessions, which stamps both the event's cwd and +/// the munged project directory its transcript lands in. +public enum MemoryObserverSessions { + public static func matches(cwd: String?, transcriptPath: String?) -> Bool { + if let cwd, cwd.contains("/.claude-mem/") { return true } + if let transcriptPath, transcriptPath.contains("-claude-mem-observer-sessions/") { + return true + } + return false + } + + /// Claude Code munges the observer cwd into one project directory name + /// (`…--claude-mem-observer-sessions`), so the transcript scan can skip + /// the whole directory instead of parsing files it would only discard. + public static func matchesProjectDirectory(named name: String) -> Bool { + name.hasSuffix("-claude-mem-observer-sessions") + } +} + +public extension AgentEvent { + var isMemoryObserver: Bool { + MemoryObserverSessions.matches(cwd: cwd, transcriptPath: transcriptPath) + } +} + +public extension AgentSession { + var isMemoryObserver: Bool { + MemoryObserverSessions.matches(cwd: cwd, transcriptPath: transcriptPath) + } +} diff --git a/Sources/NotchFlowHook/main.swift b/Sources/NotchFlowHook/main.swift index 3300341..7f459cb 100644 --- a/Sources/NotchFlowHook/main.swift +++ b/Sources/NotchFlowHook/main.swift @@ -47,6 +47,11 @@ if hook.envelope.interaction != nil, ) } +// claude-mem's headless observers inherit the same hooks as a real +// session; exiting here keeps the memory system from watching itself +// into the notch, and Claude Code proceeds as if no hook were installed. +if hook.envelope.event.isMemoryObserver { exit(0) } + guard let encoded = try? JSONEncoder().encode(hook.envelope) else { if options.strict { exit(2) } exit(0) diff --git a/Sources/NotchKit/AppModel.swift b/Sources/NotchKit/AppModel.swift index 1cc109e..2db47ee 100644 --- a/Sources/NotchKit/AppModel.swift +++ b/Sources/NotchKit/AppModel.swift @@ -351,6 +351,10 @@ public final class AppModel: ObservableObject { } private func applyOne(_ event: AgentEvent, announce: Bool) { + // The hook and the transcript scan both drop claude-mem observers at + // the source; this guard catches whatever slips past them — an old + // helper binary still installed, a replayed snapshot event. + guard !event.isMemoryObserver else { return } let previousStatus = reducer.sessions[event.sessionId]?.status reducer.apply(event) @@ -643,7 +647,9 @@ public final class AppModel: ObservableObject { guard let data = try? Data(contentsOf: snapshotURL), var snapshot = try? JSONDecoder().decode(AppSnapshot.self, from: data) else { return } let cutoff = Date().addingTimeInterval(-24 * 60 * 60) - snapshot.sessions = snapshot.sessions.filter { $0.value.updatedAt >= cutoff } + snapshot.sessions = snapshot.sessions.filter { + $0.value.updatedAt >= cutoff && !$0.value.isMemoryObserver + } for key in snapshot.sessions.keys { guard let status = snapshot.sessions[key]?.status else { continue } // The process is the truth across restarts, but only for root diff --git a/Sources/NotchKit/ClaudeTranscriptMonitor.swift b/Sources/NotchKit/ClaudeTranscriptMonitor.swift index 54b75f6..a2b3d3d 100644 --- a/Sources/NotchKit/ClaudeTranscriptMonitor.swift +++ b/Sources/NotchKit/ClaudeTranscriptMonitor.swift @@ -91,6 +91,12 @@ final class ClaudeTranscriptMonitor { includingPropertiesForKeys: [.isDirectoryKey] )) ?? [] for directory in projectDirs { + // claude-mem observer transcripts fill a whole project directory + // with sessions the notch must ignore; skipping it here is what + // keeps the scan from paying for the memory system's chatter. + if MemoryObserverSessions.matchesProjectDirectory(named: directory.lastPathComponent) { + continue + } let files = (try? FileManager.default.contentsOfDirectory( at: directory, includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey] diff --git a/Tests/NotchFlowCoreTests/MemoryObserverSessionsTests.swift b/Tests/NotchFlowCoreTests/MemoryObserverSessionsTests.swift new file mode 100644 index 0000000..1a35a64 --- /dev/null +++ b/Tests/NotchFlowCoreTests/MemoryObserverSessionsTests.swift @@ -0,0 +1,76 @@ +import XCTest +@testable import NotchFlowCore + +final class MemoryObserverSessionsTests: XCTestCase { + func testObserverCwdMatches() { + XCTAssertTrue(MemoryObserverSessions.matches( + cwd: "/Users/sergio/.claude-mem/observer-sessions", + transcriptPath: nil + )) + } + + func testObserverTranscriptPathMatches() { + XCTAssertTrue(MemoryObserverSessions.matches( + cwd: nil, + transcriptPath: "/Users/sergio/.claude/projects/" + + "-Users-sergio--claude-mem-observer-sessions/abc123.jsonl" + )) + } + + func testRegularProjectDoesNotMatch() { + XCTAssertFalse(MemoryObserverSessions.matches( + cwd: "/Users/sergio/PycharmProjects/NotchFlow", + transcriptPath: "/Users/sergio/.claude/projects/" + + "-Users-sergio-PycharmProjects-NotchFlow/abc123.jsonl" + )) + } + + /// Hacking on the plugin itself is real work: a checkout named after + /// claude-mem must not be swallowed by the filter. + func testClaudeMemCheckoutDoesNotMatch() { + XCTAssertFalse(MemoryObserverSessions.matches( + cwd: "/Users/sergio/PycharmProjects/claude-mem", + transcriptPath: "/Users/sergio/.claude/projects/" + + "-Users-sergio-PycharmProjects-claude-mem/abc123.jsonl" + )) + } + + func testProjectDirectoryNameMatchesOnlyObserverSessions() { + XCTAssertTrue(MemoryObserverSessions.matchesProjectDirectory( + named: "-Users-sergio--claude-mem-observer-sessions" + )) + XCTAssertFalse(MemoryObserverSessions.matchesProjectDirectory( + named: "-Users-sergio-PycharmProjects-NotchFlow" + )) + XCTAssertFalse(MemoryObserverSessions.matchesProjectDirectory( + named: "-Users-sergio-PycharmProjects-claude-mem" + )) + } + + func testEventPredicateReadsBothFields() { + let byCwd = AgentEvent( + type: .toolStarted, + agent: .claude, + sessionId: "s1", + cwd: "/Users/sergio/.claude-mem/observer-sessions" + ) + XCTAssertTrue(byCwd.isMemoryObserver) + + let byTranscript = AgentEvent( + type: .turnCompleted, + agent: .claude, + sessionId: "s2", + transcriptPath: "/Users/sergio/.claude/projects/" + + "-Users-sergio--claude-mem-observer-sessions/s2.jsonl" + ) + XCTAssertTrue(byTranscript.isMemoryObserver) + + let regular = AgentEvent( + type: .toolStarted, + agent: .claude, + sessionId: "s3", + cwd: "/Users/sergio/PycharmProjects/NotchFlow" + ) + XCTAssertFalse(regular.isMemoryObserver) + } +}