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
73 changes: 73 additions & 0 deletions Sources/NotchFlowCore/MemoryObserverSessions.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
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
150 changes: 150 additions & 0 deletions Tests/NotchFlowCoreTests/MemoryObserverSessionsTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading