diff --git a/Sources/NotchFlowCore/MemoryObserverSessions.swift b/Sources/NotchFlowCore/MemoryObserverSessions.swift new file mode 100644 index 0000000..dd4a254 --- /dev/null +++ b/Sources/NotchFlowCore/MemoryObserverSessions.swift @@ -0,0 +1,73 @@ +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?, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> Bool { + let observerDirectory = observerDirectory(in: homeDirectory) + if let cwd { + let candidate = URL(fileURLWithPath: cwd).standardizedFileURL.path + let root = observerDirectory.path + if candidate == root || candidate.hasPrefix(root + "/") { return true } + } + guard let transcriptPath else { return false } + let projectDirectory = URL(fileURLWithPath: transcriptPath) + .deletingLastPathComponent() + .lastPathComponent + return matchesProjectDirectory( + named: projectDirectory, + homeDirectory: homeDirectory + ) + } + + /// 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, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> Bool { + name == claudeProjectDirectoryName(for: observerDirectory(in: homeDirectory).path) + } + + private static func observerDirectory(in homeDirectory: URL) -> URL { + homeDirectory.standardizedFileURL + .appendingPathComponent(".claude-mem", isDirectory: true) + .appendingPathComponent("observer-sessions", isDirectory: true) + .standardizedFileURL + } + + /// Claude Code replaces path separators, dots, and other punctuation + /// with hyphens when naming a directory under ~/.claude/projects. + private static func claudeProjectDirectoryName(for path: String) -> String { + path.replacingOccurrences( + of: #"[^A-Za-z0-9_-]"#, + with: "-", + options: .regularExpression + ) + } +} + +public extension AgentEvent { + var isMemoryObserver: Bool { + guard agent == .claude else { return false } + return MemoryObserverSessions.matches(cwd: cwd, transcriptPath: transcriptPath) + } +} + +public extension AgentSession { + var isMemoryObserver: Bool { + guard agent == .claude else { return false } + return 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..c94fa4c --- /dev/null +++ b/Tests/NotchFlowCoreTests/MemoryObserverSessionsTests.swift @@ -0,0 +1,150 @@ +import Foundation +import XCTest +@testable import NotchFlowCore + +final class MemoryObserverSessionsTests: XCTestCase { + private let home = URL(fileURLWithPath: "/Users/sergio", isDirectory: true) + + func testObserverCwdMatches() { + XCTAssertTrue(MemoryObserverSessions.matches( + cwd: "/Users/sergio/.claude-mem/observer-sessions", + transcriptPath: nil, + homeDirectory: home + )) + XCTAssertTrue(MemoryObserverSessions.matches( + cwd: "/Users/sergio/.claude-mem/observer-sessions/nested", + transcriptPath: nil, + homeDirectory: home + )) + } + + func testObserverTranscriptPathMatches() { + XCTAssertTrue(MemoryObserverSessions.matches( + cwd: nil, + transcriptPath: "/Users/sergio/.claude/projects/" + + "-Users-sergio--claude-mem-observer-sessions/abc123.jsonl", + homeDirectory: home + )) + } + + func testOtherClaudeMemDirectoriesDoNotMatch() { + XCTAssertFalse(MemoryObserverSessions.matches( + cwd: "/Users/sergio/.claude-mem/cache", + transcriptPath: nil, + homeDirectory: home + )) + XCTAssertFalse(MemoryObserverSessions.matches( + cwd: "/tmp/.claude-mem/observer-sessions", + transcriptPath: nil, + homeDirectory: home + )) + XCTAssertFalse(MemoryObserverSessions.matches( + cwd: "/Users/sergio/.claude-mem/observer-sessions-old", + transcriptPath: nil, + homeDirectory: home + )) + XCTAssertFalse(MemoryObserverSessions.matches( + cwd: "/Users/sergio/.claude-mem/observer-sessions/../cache", + transcriptPath: nil, + homeDirectory: home + )) + } + + func testRegularProjectDoesNotMatch() { + XCTAssertFalse(MemoryObserverSessions.matches( + cwd: "/Users/sergio/PycharmProjects/NotchFlow", + transcriptPath: "/Users/sergio/.claude/projects/" + + "-Users-sergio-PycharmProjects-NotchFlow/abc123.jsonl", + homeDirectory: home + )) + } + + /// 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", + homeDirectory: home + )) + } + + func testProjectDirectoryNameMatchesOnlyObserverSessions() { + XCTAssertTrue(MemoryObserverSessions.matchesProjectDirectory( + named: "-Users-sergio--claude-mem-observer-sessions", + homeDirectory: home + )) + XCTAssertFalse(MemoryObserverSessions.matchesProjectDirectory( + named: "-Users-sergio-PycharmProjects-NotchFlow", + homeDirectory: home + )) + XCTAssertFalse(MemoryObserverSessions.matchesProjectDirectory( + named: "-Users-sergio-PycharmProjects-claude-mem", + homeDirectory: home + )) + XCTAssertFalse(MemoryObserverSessions.matchesProjectDirectory( + named: "-Users-sergio-PycharmProjects-claude-mem-observer-sessions", + homeDirectory: home + )) + } + + func testEventPredicateReadsBothFields() { + let actualHome = FileManager.default.homeDirectoryForCurrentUser + let observerCwd = actualHome + .appendingPathComponent(".claude-mem", isDirectory: true) + .appendingPathComponent("observer-sessions", isDirectory: true) + .path + let observerProjectDirectory = observerCwd.replacingOccurrences( + of: #"[^A-Za-z0-9_-]"#, + with: "-", + options: .regularExpression + ) + let byCwd = AgentEvent( + type: .toolStarted, + agent: .claude, + sessionId: "s1", + cwd: observerCwd + ) + XCTAssertTrue(byCwd.isMemoryObserver) + + let byTranscript = AgentEvent( + type: .turnCompleted, + agent: .claude, + sessionId: "s2", + transcriptPath: "/tmp/.claude/projects/" + + "\(observerProjectDirectory)/s2.jsonl" + ) + XCTAssertTrue(byTranscript.isMemoryObserver) + + let regular = AgentEvent( + type: .toolStarted, + agent: .claude, + sessionId: "s3", + cwd: "/Users/sergio/PycharmProjects/NotchFlow" + ) + XCTAssertFalse(regular.isMemoryObserver) + + let otherAgent = AgentEvent( + type: .toolStarted, + agent: .codex, + sessionId: "s4", + cwd: observerCwd + ) + XCTAssertFalse(otherAgent.isMemoryObserver) + + let otherAgentSession = AgentSession( + id: "s5", + agent: .codex, + cwd: observerCwd + ) + XCTAssertFalse(otherAgentSession.isMemoryObserver) + + let observerSession = AgentSession( + id: "s6", + agent: .claude, + cwd: observerCwd + ) + XCTAssertTrue(observerSession.isMemoryObserver) + } +}