Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions Sources/NotchFlowCore/MemoryObserverSessions.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 5 additions & 0 deletions Sources/NotchFlowHook/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion Sources/NotchKit/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,10 @@
}

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)

Expand Down Expand Up @@ -643,7 +647,9 @@
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
Expand Down Expand Up @@ -700,4 +706,4 @@
let request: InteractionRequest
public let sessionId: String
let respond: (InteractionDecision) -> Void
}

Check warning on line 709 in Sources/NotchKit/AppModel.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

File should contain 600 lines or less: currently contains 709 (file_length)
6 changes: 6 additions & 0 deletions Sources/NotchKit/ClaudeTranscriptMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
76 changes: 76 additions & 0 deletions Tests/NotchFlowCoreTests/MemoryObserverSessionsTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading