Skip to content

Commit 924945b

Browse files
jsflaxclaude
andcommitted
Fire-and-forget memory maintenance subprocess
Spawn maintenance agent as a detached `claude` CLI subprocess instead of injecting a nudge into conversation context. Saves tokens and turns. - Extract shared spawnClaudeSubprocess() into SubprocessSpawner.swift - Advise hook spawns maintenance subprocess when CRUD threshold crossed - Remove maintenanceNudge() from LatticeHelpers and all hook callers - Refactor OnStop session-learner to use shared spawner - Use mcp__memory__* wildcard for allowed tools - Slack release notification now includes changelog highlights Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fb12d9d commit 924945b

11 files changed

Lines changed: 190 additions & 128 deletions

File tree

.github/workflows/release.yml

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -243,29 +243,31 @@ jobs:
243243
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
244244
run: |
245245
TAG="${GITHUB_REF_NAME}"
246+
VERSION="${TAG#v}"
246247
REPO="${{ github.repository }}"
247248
URL="https://github.com/${REPO}/releases/tag/${TAG}"
248-
STATUS="${{ job.status }}"
249-
250-
if [ "$STATUS" = "success" ]; then
251-
EMOJI="✅"
252-
TEXT="Release *${TAG}* published successfully"
253-
else
254-
EMOJI="❌"
255-
TEXT="Release *${TAG}* failed"
256-
fi
249+
250+
# Extract changelog section for this version (between ## [version] and next ## [)
251+
CHANGES=$(awk "/^## \\[${VERSION}\\]/{found=1; next} /^## \\[/{if(found) exit} found{print}" CHANGELOG.md \
252+
| sed '/^$/d' \
253+
| sed 's/^### /\n*/' \
254+
| sed 's/^- /• /' \
255+
| head -20)
256+
257+
# Escape for JSON
258+
CHANGES_ESCAPED=$(echo "$CHANGES" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read())[1:-1])')
257259
258260
curl -s -X POST "$SLACK_WEBHOOK_URL" \
259261
-H 'Content-Type: application/json' \
260262
-d "{
261263
\"blocks\": [
262264
{
263265
\"type\": \"header\",
264-
\"text\": {\"type\": \"plain_text\", \"text\": \"${EMOJI} Engram ${TAG}\"}
266+
\"text\": {\"type\": \"plain_text\", \"text\": \" Engram ${TAG} released\"}
265267
},
266268
{
267269
\"type\": \"section\",
268-
\"text\": {\"type\": \"mrkdwn\", \"text\": \"${TEXT}\"},
270+
\"text\": {\"type\": \"mrkdwn\", \"text\": \"${CHANGES_ESCAPED}\"},
269271
\"accessory\": {
270272
\"type\": \"button\",
271273
\"text\": {\"type\": \"plain_text\", \"text\": \"View Release\"},

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ All notable changes to Engram are documented in this file.
44

55
> Formerly "ClaudeMemory" — renamed in v0.12.0 to be tool-agnostic.
66
7-
## [0.12.0] - 2026-02-21
7+
## [0.12.0] - 2026-02-22
88

99
### Changed
1010
- **Rebrand to Engram** — product name, Xcode project, bundle ID (`io.engram.app`), CI/CD artifacts, and documentation all renamed. CLI tool names (`memory` / `memory-hooks`) and MCP server name (`memory`) are unchanged.
11+
- **Fire-and-forget memory maintenance** — maintenance agent now spawns as a detached `claude` CLI subprocess (same pattern as session-learner) instead of injecting a nudge into conversation context. Saves tokens and turns in the main conversation.
12+
- Extracted shared `spawnClaudeSubprocess()` utility used by both session-learner and maintenance spawners
13+
- Maintenance nudge removed from all hook handlers (SessionStart, PreToolUse, PostToolUseFailure, PreCompact) — only the Advise hook spawns maintenance now
14+
- Subprocess allowed tools use wildcard `mcp__memory__*` instead of enumerating each tool
1115

1216
## [0.11.0] - 2026-02-21
1317

Sources/EngramHooks/Advise.swift

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ struct Advise: AsyncParsableCommand {
1010
)
1111

1212
func run() async throws {
13+
// Guard against recursion: maintenance subprocess sets this env var
14+
if ProcessInfo.processInfo.environment["CLAUDE_MEMORY_MAINTENANCE"] != nil {
15+
return
16+
}
17+
1318
let inputData = readStdin()
1419
guard !inputData.isEmpty else { return }
1520

@@ -40,10 +45,8 @@ struct Advise: AsyncParsableCommand {
4045
}
4146
}
4247

43-
// Maintenance nudge (if threshold crossed)
44-
if let nudge = maintenanceNudge(project: proj) {
45-
sections.append(nudge)
46-
}
48+
// Spawn maintenance subprocess if threshold crossed (fire-and-forget)
49+
spawnMaintenanceIfNeeded(project: proj, cwd: input.cwd)
4750

4851
// Learning nudge — skip if stop hook just fired (session-learner already spawned)
4952
if let state = getSessionState(sessionId: input.sessionId), state.stopNudgeSent {
@@ -61,4 +64,66 @@ struct Advise: AsyncParsableCommand {
6164
)
6265
try writeOutput(output)
6366
}
67+
68+
// MARK: - Maintenance Subprocess
69+
70+
/// The maintenance system prompt, loaded from agents/memory-maintenance.md or inlined.
71+
private static let maintenanceSystemPrompt: String = loadAgentSystemPrompt(
72+
name: "memory-maintenance",
73+
fallback: """
74+
You are a memory maintenance agent. Your job is to analyze the memory database and improve its organization for better recall quality.
75+
76+
## Workflow
77+
1. Run stats() and list_topics() to understand memory distribution.
78+
2. Run find_clusters() to discover redundant memories.
79+
3. Consolidate genuinely redundant memories. Keep distinct ones separate.
80+
4. Use organize() to create subtopics for large topic groups.
81+
5. Connect new memories to related existing ones.
82+
6. Verify improvements with stats() and list_topics().
83+
84+
## Guidelines
85+
- Read full memory content before deciding what to do.
86+
- Only consolidate memories that are truly saying the same thing.
87+
- Be efficient — don't over-process clean databases.
88+
"""
89+
)
90+
91+
private static let maintenanceAllowedTools = "mcp__memory__*,Read,Grep,Glob,Bash"
92+
93+
private static let maintenanceLogPath = NSHomeDirectory() + "/.claude/memory-maintenance.log"
94+
95+
/// Check if maintenance threshold is crossed and spawn the subprocess if so.
96+
private func spawnMaintenanceIfNeeded(project: String, cwd: String?) {
97+
let opCount = Int(getHookState(key: .crudOperationCount) ?? "0") ?? 0
98+
let lastOpCount = Int(getHookState(key: .maintenanceLastOpCount) ?? "0") ?? 0
99+
let delta = opCount - lastOpCount
100+
101+
guard delta >= maintenanceThreshold else { return }
102+
103+
// Update baseline immediately to prevent re-spawning on next advise call
104+
setHookState(key: .maintenanceLastOpCount, value: String(opCount))
105+
106+
let prompt = """
107+
Perform maintenance on the memory database. Focus on project "\(project)".
108+
109+
Current state: \(opCount) total CRUD operations, \(delta) since last maintenance.
110+
111+
Follow your system prompt workflow: assess, find redundancy, find communities, take action, connect the graph, verify.
112+
"""
113+
114+
do {
115+
try spawnClaudeSubprocess(
116+
prompt: prompt,
117+
systemPrompt: Self.maintenanceSystemPrompt,
118+
allowedTools: Self.maintenanceAllowedTools,
119+
model: "sonnet",
120+
envGuard: (key: "CLAUDE_MEMORY_MAINTENANCE", value: "1"),
121+
logPath: Self.maintenanceLogPath,
122+
cwd: cwd
123+
)
124+
hookLog("Advise: spawned memory-maintenance subprocess (ops delta: \(delta))")
125+
} catch {
126+
hookLog("Advise: failed to spawn memory-maintenance: \(error)")
127+
}
128+
}
64129
}

Sources/EngramHooks/LatticeHelpers.swift

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -65,25 +65,6 @@ func getSessionState(sessionId: String?) -> SessionState? {
6565

6666
// MARK: - Shared Nudges
6767

68-
/// Returns a maintenance nudge if the CRUD operation delta exceeds the threshold.
69-
func maintenanceNudge(project: String) -> String? {
70-
let opCount = Int(getHookState(key: .crudOperationCount) ?? "0") ?? 0
71-
let lastOpCount = Int(getHookState(key: .maintenanceLastOpCount) ?? "0") ?? 0
72-
let delta = opCount - lastOpCount
73-
74-
guard delta >= maintenanceThreshold else { return nil }
75-
76-
hookLog("Maintenance nudge injected (ops delta: \(delta))")
77-
78-
return """
79-
## Memory maintenance required
80-
81-
\(delta) memory operations since last maintenance (\(opCount) total ops). \
82-
Spawn the memory-maintenance agent in the background now. \
83-
Focus on project "\(project)".
84-
"""
85-
}
86-
8768
/// Returns the learning nudge for the given project (always fires).
8869
func learningNudge(project: String) -> String {
8970
"""

Sources/EngramHooks/OnFailure.swift

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,6 @@ struct OnFailure: AsyncParsableCommand {
2929

3030
var sections: [String] = []
3131

32-
// Maintenance nudge (if threshold crossed)
33-
if let nudge = maintenanceNudge(project: proj) {
34-
sections.append(nudge)
35-
}
36-
3732
// Learning nudge
3833
if let nudge = throttledLearningNudge(project: proj, sessionId: input.sessionId) {
3934
sections.append(nudge)

Sources/EngramHooks/OnStart.swift

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,6 @@ struct OnStart: AsyncParsableCommand {
3939
}
4040
}
4141

42-
// Maintenance nudge (if threshold crossed)
43-
if let nudge = maintenanceNudge(project: proj) {
44-
sections.append(nudge)
45-
}
46-
4742
guard !sections.isEmpty else { return }
4843

4944
let output = HookOutput(

Sources/EngramHooks/OnStop.swift

Lines changed: 15 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -175,26 +175,9 @@ struct OnStop: AsyncParsableCommand {
175175
// MARK: - CLI Spawning
176176

177177
/// The session-learner system prompt, loaded from agents/session-learner.md or inlined.
178-
private static let sessionLearnerSystemPrompt: String = {
179-
// Try to load from the agents directory relative to the hooks binary
180-
let bundlePath = Bundle.main.bundlePath
181-
let possiblePaths = [
182-
// Relative to the binary in ~/.claude/bin/
183-
NSHomeDirectory() + "/.claude/agents/session-learner.md",
184-
// Development path
185-
bundlePath + "/../../../agents/session-learner.md",
186-
]
187-
188-
for path in possiblePaths {
189-
if let content = try? String(contentsOfFile: path, encoding: .utf8) {
190-
// Strip frontmatter (--- delimited block at the start)
191-
let stripped = stripFrontmatter(content)
192-
return stripped
193-
}
194-
}
195-
196-
// Inline fallback
197-
return """
178+
private static let sessionLearnerSystemPrompt: String = loadAgentSystemPrompt(
179+
name: "session-learner",
180+
fallback: """
198181
You are a session learning agent. Your job is to review what happened in a coding session and store the key insights as memories for future recall.
199182
200183
## Workflow
@@ -210,62 +193,23 @@ struct OnStop: AsyncParsableCommand {
210193
- Prefer updating existing memories over creating new ones.
211194
- Keep total turns low: 2-3 recalls, 2-5 remember/update/connect calls, done.
212195
"""
213-
}()
214-
215-
/// Strip YAML frontmatter from markdown content.
216-
private static func stripFrontmatter(_ content: String) -> String {
217-
guard content.hasPrefix("---") else { return content }
218-
// Find the closing ---
219-
let lines = content.components(separatedBy: .newlines)
220-
var endIndex = 0
221-
for i in 1..<lines.count {
222-
if lines[i].hasPrefix("---") {
223-
endIndex = i + 1
224-
break
225-
}
226-
}
227-
guard endIndex > 0 else { return content }
228-
return lines[endIndex...].joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
229-
}
196+
)
197+
198+
private static let allowedTools = "mcp__memory__*,Read,Grep,Glob,Bash"
230199

231200
/// Log file path for session-learner output.
232201
private static let logPath = NSHomeDirectory() + "/.claude/session-learner.log"
233202

234-
/// Spawn `claude` CLI as a detached fire-and-forget subprocess, logging output to a file.
235-
/// Uses /bin/sh with shell redirection to ensure output is captured even with buffered pipes.
203+
/// Spawn `claude` CLI as a detached fire-and-forget subprocess for session learning.
236204
private func spawnSessionLearner(prompt: String, cwd: String?) throws {
237-
// Shell-escape the prompt and system prompt for embedding in a sh -c command
238-
let escapedPrompt = prompt.replacingOccurrences(of: "'", with: "'\\''")
239-
let escapedSystemPrompt = Self.sessionLearnerSystemPrompt.replacingOccurrences(of: "'", with: "'\\''")
240-
let allowedTools = "mcp__memory__remember,mcp__memory__recall,mcp__memory__update,mcp__memory__forget,mcp__memory__merge,mcp__memory__connect,mcp__memory__graph,mcp__memory__list_topics,mcp__memory__stats,Read,Grep,Glob,Bash"
241-
242-
let shellCommand = """
243-
echo '===== session-learner started at '\\''\(ISO8601DateFormatter().string(from: Date()))'\\'' =====' >> '\(Self.logPath)' && \
244-
claude -p '\(escapedPrompt)' \
245-
--model sonnet \
246-
--allowedTools '\(allowedTools)' \
247-
--no-session-persistence \
248-
--output-format text \
249-
--append-system-prompt '\(escapedSystemPrompt)' \
250-
>> '\(Self.logPath)' 2>&1
251-
"""
252-
253-
let sh = Process()
254-
sh.executableURL = URL(fileURLWithPath: "/bin/sh")
255-
sh.arguments = ["-c", shellCommand]
256-
sh.environment = ProcessInfo.processInfo.environment.merging(
257-
["CLAUDE_MEMORY_LEARNER": "1"],
258-
uniquingKeysWith: { _, new in new }
205+
try spawnClaudeSubprocess(
206+
prompt: prompt,
207+
systemPrompt: Self.sessionLearnerSystemPrompt,
208+
allowedTools: Self.allowedTools,
209+
model: "sonnet",
210+
envGuard: (key: "CLAUDE_MEMORY_LEARNER", value: "1"),
211+
logPath: Self.logPath,
212+
cwd: cwd
259213
)
260-
if let cwd {
261-
sh.currentDirectoryURL = URL(fileURLWithPath: cwd)
262-
}
263-
264-
// Detach from our process's stdio
265-
sh.standardOutput = FileHandle.nullDevice
266-
sh.standardError = FileHandle.nullDevice
267-
268-
try sh.run()
269-
// Don't wait — fire and forget
270214
}
271215
}

Sources/EngramHooks/PreCompact.swift

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,6 @@ struct PreCompact: AsyncParsableCommand {
4444
Do this immediately — after compaction, you will not be able to recall these details.
4545
""")
4646

47-
// Maintenance nudge (if threshold crossed)
48-
if let nudge = maintenanceNudge(project: proj) {
49-
sections.append(nudge)
50-
}
51-
5247
// Learning nudge
5348
sections.append(learningNudge(project: proj))
5449

Sources/EngramHooks/PreTool.swift

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,6 @@ struct PreTool: AsyncParsableCommand {
2929

3030
var sections: [String] = []
3131

32-
// Maintenance nudge (if threshold crossed)
33-
if let nudge = maintenanceNudge(project: proj) {
34-
sections.append(nudge)
35-
}
36-
3732
// Learning nudge
3833
if let nudge = throttledLearningNudge(project: proj, sessionId: input.sessionId) {
3934
sections.append(nudge)

0 commit comments

Comments
 (0)