agent-working-memory 0.4.3 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,289 +1,289 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * Hook Sidecar — lightweight HTTP server that runs alongside the MCP process.
5
- *
6
- * Claude Code hooks (PreCompact, SessionEnd, etc.) send POST requests here.
7
- * Since we share the same process as the MCP server, there's zero SQLite
8
- * contention — we use the same store/engines directly.
9
- *
10
- * Endpoints:
11
- * POST /hooks/checkpoint — auto-checkpoint (called by PreCompact, SessionEnd hooks)
12
- * GET /health — health check
13
- *
14
- * Security:
15
- * - Binds to 127.0.0.1 only (localhost)
16
- * - Bearer token auth via AWM_HOOK_SECRET
17
- */
18
-
19
- import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
20
- import { readFileSync, existsSync } from 'node:fs';
21
- import type { EngramStore } from '../storage/sqlite.js';
22
- import type { ConsciousState } from '../types/checkpoint.js';
23
- import { log, getLogPath } from '../core/logger.js';
24
-
25
- export interface SidecarDeps {
26
- store: EngramStore;
27
- agentId: string;
28
- secret: string | null;
29
- port: number;
30
- onConsolidate?: (agentId: string, reason: string) => void;
31
- }
32
-
33
- interface HookInput {
34
- session_id?: string;
35
- transcript_path?: string;
36
- hook_event_name?: string;
37
- cwd?: string;
38
- tool_name?: string;
39
- tool_input?: Record<string, unknown>;
40
- }
41
-
42
- interface TranscriptContext {
43
- currentTask: string;
44
- activeFiles: string[];
45
- recentTools: string[];
46
- lastUserMessage: string;
47
- }
48
-
49
- /**
50
- * Parse the Claude Code transcript to extract context for auto-checkpointing.
51
- * Reads the JSONL transcript file and extracts recent tool calls, files, and user messages.
52
- */
53
- function parseTranscript(transcriptPath: string): TranscriptContext {
54
- const ctx: TranscriptContext = {
55
- currentTask: '',
56
- activeFiles: [],
57
- recentTools: [],
58
- lastUserMessage: '',
59
- };
60
-
61
- try {
62
- const raw = readFileSync(transcriptPath, 'utf-8');
63
- const lines = raw.trim().split('\n').filter(Boolean);
64
-
65
- const files = new Set<string>();
66
- const tools: string[] = [];
67
- let lastUserMsg = '';
68
-
69
- // Parse last 100 lines max to avoid huge transcripts
70
- const recent = lines.slice(-100);
71
-
72
- for (const line of recent) {
73
- try {
74
- const entry = JSON.parse(line);
75
-
76
- // Extract user messages
77
- if (entry.role === 'user' && typeof entry.content === 'string') {
78
- lastUserMsg = entry.content.slice(0, 200);
79
- }
80
- if (entry.role === 'user' && Array.isArray(entry.content)) {
81
- for (const part of entry.content) {
82
- if (part.type === 'text' && typeof part.text === 'string') {
83
- lastUserMsg = part.text.slice(0, 200);
84
- }
85
- }
86
- }
87
-
88
- // Extract tool uses
89
- if (entry.role === 'assistant' && Array.isArray(entry.content)) {
90
- for (const part of entry.content) {
91
- if (part.type === 'tool_use') {
92
- tools.push(part.name);
93
- // Extract file paths from tool inputs
94
- const input = part.input;
95
- if (input?.file_path) files.add(String(input.file_path));
96
- if (input?.path) files.add(String(input.path));
97
- if (input?.command && typeof input.command === 'string') {
98
- // Try to extract file paths from bash commands
99
- const pathMatch = input.command.match(/["']?([A-Z]:[/\\][^"'\s]+|\/[^\s"']+\.\w+)["']?/g);
100
- if (pathMatch) pathMatch.forEach((p: string) => files.add(p.replace(/["']/g, '')));
101
- }
102
- }
103
- }
104
- }
105
- } catch {
106
- // Skip malformed lines
107
- }
108
- }
109
-
110
- ctx.lastUserMessage = lastUserMsg;
111
- ctx.activeFiles = [...files].slice(-20); // Last 20 unique files
112
- ctx.recentTools = tools.slice(-30); // Last 30 tool calls
113
- ctx.currentTask = lastUserMsg || 'Unknown task (auto-checkpoint)';
114
- } catch {
115
- ctx.currentTask = 'Auto-checkpoint (transcript unavailable)';
116
- }
117
-
118
- return ctx;
119
- }
120
-
121
- function readBody(req: IncomingMessage): Promise<string> {
122
- return new Promise((resolve, reject) => {
123
- const chunks: Buffer[] = [];
124
- req.on('data', (chunk: Buffer) => chunks.push(chunk));
125
- req.on('end', () => resolve(Buffer.concat(chunks).toString()));
126
- req.on('error', reject);
127
- });
128
- }
129
-
130
- function json(res: ServerResponse, status: number, body: Record<string, unknown>) {
131
- res.writeHead(status, { 'Content-Type': 'application/json' });
132
- res.end(JSON.stringify(body));
133
- }
134
-
135
- const AUTO_CHECKPOINT_INTERVAL_MS = 15 * 60_000; // 15 minutes
136
-
137
- export function startSidecar(deps: SidecarDeps): { close: () => void } {
138
- const { store, agentId, secret, port, onConsolidate } = deps;
139
-
140
- const server = createServer(async (req, res) => {
141
- // CORS preflight
142
- if (req.method === 'OPTIONS') {
143
- res.writeHead(204);
144
- res.end();
145
- return;
146
- }
147
-
148
- // Health check — no auth required
149
- if (req.url === '/health' && req.method === 'GET') {
150
- json(res, 200, { status: 'ok', sidecar: true, agentId });
151
- return;
152
- }
153
-
154
- // Auth check
155
- if (secret) {
156
- const auth = req.headers.authorization;
157
- if (auth !== `Bearer ${secret}`) {
158
- json(res, 401, { error: 'Unauthorized' });
159
- return;
160
- }
161
- }
162
-
163
- // GET /stats — daily activity counts from the log (no auth required)
164
- if (req.url === '/stats' && req.method === 'GET') {
165
- try {
166
- const lp = getLogPath();
167
- if (!lp || !existsSync(lp)) {
168
- json(res, 200, { error: 'No log file', writes: 0, recalls: 0, restores: 0, hooks: 0, total: 0 });
169
- return;
170
- }
171
- const raw = readFileSync(lp, 'utf-8');
172
- const today = new Date().toISOString().slice(0, 10);
173
- const todayLines = raw.split('\n').filter(l => l.startsWith(today));
174
- let writes = 0, recalls = 0, restores = 0, hooks = 0, checkpoints = 0;
175
- for (const line of todayLines) {
176
- if (line.includes('| write:')) writes++;
177
- else if (line.includes('| recall')) recalls++;
178
- else if (line.includes('| restore')) restores++;
179
- else if (line.includes('| hook:')) hooks++;
180
- else if (line.includes('| checkpoint')) checkpoints++;
181
- }
182
- const total = writes + recalls + restores + hooks + checkpoints;
183
- json(res, 200, { date: today, agentId, writes, recalls, restores, hooks, checkpoints, total });
184
- } catch {
185
- json(res, 500, { error: 'Failed to read log' });
186
- }
187
- return;
188
- }
189
-
190
- // POST /hooks/checkpoint — auto-checkpoint from hook events
191
- if (req.url === '/hooks/checkpoint' && req.method === 'POST') {
192
- try {
193
- const body = await readBody(req);
194
- const hookInput: HookInput = body ? JSON.parse(body) : {};
195
- const event = hookInput.hook_event_name ?? 'unknown';
196
-
197
- // Parse transcript for rich context
198
- let ctx: TranscriptContext | null = null;
199
- if (hookInput.transcript_path) {
200
- ctx = parseTranscript(hookInput.transcript_path);
201
- }
202
-
203
- // Build checkpoint state
204
- const state: ConsciousState = {
205
- currentTask: ctx?.currentTask ?? `Auto-checkpoint (${event})`,
206
- decisions: [],
207
- activeFiles: ctx?.activeFiles ?? [],
208
- nextSteps: [],
209
- relatedMemoryIds: [],
210
- notes: `Auto-saved by ${event} hook.${ctx?.recentTools.length ? ` Recent tools: ${[...new Set(ctx.recentTools)].join(', ')}` : ''}`,
211
- episodeId: null,
212
- };
213
-
214
- store.saveCheckpoint(agentId, state);
215
- log(agentId, `hook:${event}`, `auto-checkpoint files=${state.activeFiles.length} task="${state.currentTask.slice(0, 80)}"`);
216
-
217
- // On SessionEnd: run full consolidation (sleep cycle) before process dies
218
- let consolidated = false;
219
- if (event === 'SessionEnd' && onConsolidate) {
220
- try {
221
- onConsolidate(agentId, `SessionEnd hook (graceful exit)`);
222
- consolidated = true;
223
- log(agentId, 'consolidation', 'full sleep cycle on graceful exit');
224
- } catch { /* consolidation failure is non-fatal */ }
225
- }
226
-
227
- // Return context for Claude (stdout from hooks is visible)
228
- json(res, 200, {
229
- status: 'checkpointed',
230
- event,
231
- task: state.currentTask,
232
- files: state.activeFiles.length,
233
- consolidated,
234
- });
235
- } catch (err) {
236
- json(res, 500, { error: 'Checkpoint failed', detail: String(err) });
237
- }
238
- return;
239
- }
240
-
241
- // 404
242
- json(res, 404, { error: 'Not found' });
243
- });
244
-
245
- server.listen(port, '127.0.0.1', () => {
246
- console.error(`AWM hook sidecar listening on 127.0.0.1:${port}`);
247
- });
248
-
249
- server.on('error', (err: NodeJS.ErrnoException) => {
250
- if (err.code === 'EADDRINUSE') {
251
- console.error(`AWM hook sidecar: port ${port} in use, hooks disabled`);
252
- } else {
253
- console.error('AWM hook sidecar error:', err.message);
254
- }
255
- });
256
-
257
- // --- Silent auto-checkpoint every 15 minutes ---
258
- const autoCheckpointTimer = setInterval(() => {
259
- try {
260
- const checkpoint = store.getCheckpoint(agentId);
261
- if (!checkpoint) return; // No state to save yet
262
-
263
- // Only checkpoint if there's been activity since last auto-checkpoint
264
- const lastActivity = checkpoint.auto.lastActivityAt?.getTime() ?? 0;
265
- const sinceActivity = Date.now() - lastActivity;
266
- if (sinceActivity > AUTO_CHECKPOINT_INTERVAL_MS) return; // Idle — skip
267
-
268
- const state: ConsciousState = {
269
- currentTask: checkpoint.executionState?.currentTask ?? 'Active session (auto-checkpoint)',
270
- decisions: checkpoint.executionState?.decisions ?? [],
271
- activeFiles: checkpoint.executionState?.activeFiles ?? [],
272
- nextSteps: checkpoint.executionState?.nextSteps ?? [],
273
- relatedMemoryIds: checkpoint.executionState?.relatedMemoryIds ?? [],
274
- notes: `Auto-checkpoint (15min timer). Last activity: ${Math.round(sinceActivity / 60_000)}min ago.`,
275
- episodeId: checkpoint.executionState?.episodeId ?? null,
276
- };
277
-
278
- store.saveCheckpoint(agentId, state);
279
- log(agentId, 'hook:timer', `auto-checkpoint (${Math.round(sinceActivity / 60_000)}min since activity)`);
280
- } catch { /* timer failure is non-fatal */ }
281
- }, AUTO_CHECKPOINT_INTERVAL_MS);
282
-
283
- return {
284
- close: () => {
285
- clearInterval(autoCheckpointTimer);
286
- server.close();
287
- },
288
- };
289
- }
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Hook Sidecar — lightweight HTTP server that runs alongside the MCP process.
5
+ *
6
+ * Claude Code hooks (PreCompact, SessionEnd, etc.) send POST requests here.
7
+ * Since we share the same process as the MCP server, there's zero SQLite
8
+ * contention — we use the same store/engines directly.
9
+ *
10
+ * Endpoints:
11
+ * POST /hooks/checkpoint — auto-checkpoint (called by PreCompact, SessionEnd hooks)
12
+ * GET /health — health check
13
+ *
14
+ * Security:
15
+ * - Binds to 127.0.0.1 only (localhost)
16
+ * - Bearer token auth via AWM_HOOK_SECRET
17
+ */
18
+
19
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
20
+ import { readFileSync, existsSync } from 'node:fs';
21
+ import type { EngramStore } from '../storage/sqlite.js';
22
+ import type { ConsciousState } from '../types/checkpoint.js';
23
+ import { log, getLogPath } from '../core/logger.js';
24
+
25
+ export interface SidecarDeps {
26
+ store: EngramStore;
27
+ agentId: string;
28
+ secret: string | null;
29
+ port: number;
30
+ onConsolidate?: (agentId: string, reason: string) => void;
31
+ }
32
+
33
+ interface HookInput {
34
+ session_id?: string;
35
+ transcript_path?: string;
36
+ hook_event_name?: string;
37
+ cwd?: string;
38
+ tool_name?: string;
39
+ tool_input?: Record<string, unknown>;
40
+ }
41
+
42
+ interface TranscriptContext {
43
+ currentTask: string;
44
+ activeFiles: string[];
45
+ recentTools: string[];
46
+ lastUserMessage: string;
47
+ }
48
+
49
+ /**
50
+ * Parse the Claude Code transcript to extract context for auto-checkpointing.
51
+ * Reads the JSONL transcript file and extracts recent tool calls, files, and user messages.
52
+ */
53
+ function parseTranscript(transcriptPath: string): TranscriptContext {
54
+ const ctx: TranscriptContext = {
55
+ currentTask: '',
56
+ activeFiles: [],
57
+ recentTools: [],
58
+ lastUserMessage: '',
59
+ };
60
+
61
+ try {
62
+ const raw = readFileSync(transcriptPath, 'utf-8');
63
+ const lines = raw.trim().split('\n').filter(Boolean);
64
+
65
+ const files = new Set<string>();
66
+ const tools: string[] = [];
67
+ let lastUserMsg = '';
68
+
69
+ // Parse last 100 lines max to avoid huge transcripts
70
+ const recent = lines.slice(-100);
71
+
72
+ for (const line of recent) {
73
+ try {
74
+ const entry = JSON.parse(line);
75
+
76
+ // Extract user messages
77
+ if (entry.role === 'user' && typeof entry.content === 'string') {
78
+ lastUserMsg = entry.content.slice(0, 200);
79
+ }
80
+ if (entry.role === 'user' && Array.isArray(entry.content)) {
81
+ for (const part of entry.content) {
82
+ if (part.type === 'text' && typeof part.text === 'string') {
83
+ lastUserMsg = part.text.slice(0, 200);
84
+ }
85
+ }
86
+ }
87
+
88
+ // Extract tool uses
89
+ if (entry.role === 'assistant' && Array.isArray(entry.content)) {
90
+ for (const part of entry.content) {
91
+ if (part.type === 'tool_use') {
92
+ tools.push(part.name);
93
+ // Extract file paths from tool inputs
94
+ const input = part.input;
95
+ if (input?.file_path) files.add(String(input.file_path));
96
+ if (input?.path) files.add(String(input.path));
97
+ if (input?.command && typeof input.command === 'string') {
98
+ // Try to extract file paths from bash commands
99
+ const pathMatch = input.command.match(/["']?([A-Z]:[/\\][^"'\s]+|\/[^\s"']+\.\w+)["']?/g);
100
+ if (pathMatch) pathMatch.forEach((p: string) => files.add(p.replace(/["']/g, '')));
101
+ }
102
+ }
103
+ }
104
+ }
105
+ } catch {
106
+ // Skip malformed lines
107
+ }
108
+ }
109
+
110
+ ctx.lastUserMessage = lastUserMsg;
111
+ ctx.activeFiles = [...files].slice(-20); // Last 20 unique files
112
+ ctx.recentTools = tools.slice(-30); // Last 30 tool calls
113
+ ctx.currentTask = lastUserMsg || 'Unknown task (auto-checkpoint)';
114
+ } catch {
115
+ ctx.currentTask = 'Auto-checkpoint (transcript unavailable)';
116
+ }
117
+
118
+ return ctx;
119
+ }
120
+
121
+ function readBody(req: IncomingMessage): Promise<string> {
122
+ return new Promise((resolve, reject) => {
123
+ const chunks: Buffer[] = [];
124
+ req.on('data', (chunk: Buffer) => chunks.push(chunk));
125
+ req.on('end', () => resolve(Buffer.concat(chunks).toString()));
126
+ req.on('error', reject);
127
+ });
128
+ }
129
+
130
+ function json(res: ServerResponse, status: number, body: Record<string, unknown>) {
131
+ res.writeHead(status, { 'Content-Type': 'application/json' });
132
+ res.end(JSON.stringify(body));
133
+ }
134
+
135
+ const AUTO_CHECKPOINT_INTERVAL_MS = 15 * 60_000; // 15 minutes
136
+
137
+ export function startSidecar(deps: SidecarDeps): { close: () => void } {
138
+ const { store, agentId, secret, port, onConsolidate } = deps;
139
+
140
+ const server = createServer(async (req, res) => {
141
+ // CORS preflight
142
+ if (req.method === 'OPTIONS') {
143
+ res.writeHead(204);
144
+ res.end();
145
+ return;
146
+ }
147
+
148
+ // Health check — no auth required
149
+ if (req.url === '/health' && req.method === 'GET') {
150
+ json(res, 200, { status: 'ok', sidecar: true, agentId });
151
+ return;
152
+ }
153
+
154
+ // Auth check
155
+ if (secret) {
156
+ const auth = req.headers.authorization;
157
+ if (auth !== `Bearer ${secret}`) {
158
+ json(res, 401, { error: 'Unauthorized' });
159
+ return;
160
+ }
161
+ }
162
+
163
+ // GET /stats — daily activity counts from the log (no auth required)
164
+ if (req.url === '/stats' && req.method === 'GET') {
165
+ try {
166
+ const lp = getLogPath();
167
+ if (!lp || !existsSync(lp)) {
168
+ json(res, 200, { error: 'No log file', writes: 0, recalls: 0, restores: 0, hooks: 0, total: 0 });
169
+ return;
170
+ }
171
+ const raw = readFileSync(lp, 'utf-8');
172
+ const today = new Date().toISOString().slice(0, 10);
173
+ const todayLines = raw.split('\n').filter(l => l.startsWith(today));
174
+ let writes = 0, recalls = 0, restores = 0, hooks = 0, checkpoints = 0;
175
+ for (const line of todayLines) {
176
+ if (line.includes('| write:')) writes++;
177
+ else if (line.includes('| recall')) recalls++;
178
+ else if (line.includes('| restore')) restores++;
179
+ else if (line.includes('| hook:')) hooks++;
180
+ else if (line.includes('| checkpoint')) checkpoints++;
181
+ }
182
+ const total = writes + recalls + restores + hooks + checkpoints;
183
+ json(res, 200, { date: today, agentId, writes, recalls, restores, hooks, checkpoints, total });
184
+ } catch {
185
+ json(res, 500, { error: 'Failed to read log' });
186
+ }
187
+ return;
188
+ }
189
+
190
+ // POST /hooks/checkpoint — auto-checkpoint from hook events
191
+ if (req.url === '/hooks/checkpoint' && req.method === 'POST') {
192
+ try {
193
+ const body = await readBody(req);
194
+ const hookInput: HookInput = body ? JSON.parse(body) : {};
195
+ const event = hookInput.hook_event_name ?? 'unknown';
196
+
197
+ // Parse transcript for rich context
198
+ let ctx: TranscriptContext | null = null;
199
+ if (hookInput.transcript_path) {
200
+ ctx = parseTranscript(hookInput.transcript_path);
201
+ }
202
+
203
+ // Build checkpoint state
204
+ const state: ConsciousState = {
205
+ currentTask: ctx?.currentTask ?? `Auto-checkpoint (${event})`,
206
+ decisions: [],
207
+ activeFiles: ctx?.activeFiles ?? [],
208
+ nextSteps: [],
209
+ relatedMemoryIds: [],
210
+ notes: `Auto-saved by ${event} hook.${ctx?.recentTools.length ? ` Recent tools: ${[...new Set(ctx.recentTools)].join(', ')}` : ''}`,
211
+ episodeId: null,
212
+ };
213
+
214
+ store.saveCheckpoint(agentId, state);
215
+ log(agentId, `hook:${event}`, `auto-checkpoint files=${state.activeFiles.length} task="${state.currentTask.slice(0, 80)}"`);
216
+
217
+ // On SessionEnd: run full consolidation (sleep cycle) before process dies
218
+ let consolidated = false;
219
+ if (event === 'SessionEnd' && onConsolidate) {
220
+ try {
221
+ onConsolidate(agentId, `SessionEnd hook (graceful exit)`);
222
+ consolidated = true;
223
+ log(agentId, 'consolidation', 'full sleep cycle on graceful exit');
224
+ } catch { /* consolidation failure is non-fatal */ }
225
+ }
226
+
227
+ // Return context for Claude (stdout from hooks is visible)
228
+ json(res, 200, {
229
+ status: 'checkpointed',
230
+ event,
231
+ task: state.currentTask,
232
+ files: state.activeFiles.length,
233
+ consolidated,
234
+ });
235
+ } catch (err) {
236
+ json(res, 500, { error: 'Checkpoint failed', detail: String(err) });
237
+ }
238
+ return;
239
+ }
240
+
241
+ // 404
242
+ json(res, 404, { error: 'Not found' });
243
+ });
244
+
245
+ server.listen(port, '127.0.0.1', () => {
246
+ console.error(`AWM hook sidecar listening on 127.0.0.1:${port}`);
247
+ });
248
+
249
+ server.on('error', (err: NodeJS.ErrnoException) => {
250
+ if (err.code === 'EADDRINUSE') {
251
+ console.error(`AWM hook sidecar: port ${port} in use, hooks disabled`);
252
+ } else {
253
+ console.error('AWM hook sidecar error:', err.message);
254
+ }
255
+ });
256
+
257
+ // --- Silent auto-checkpoint every 15 minutes ---
258
+ const autoCheckpointTimer = setInterval(() => {
259
+ try {
260
+ const checkpoint = store.getCheckpoint(agentId);
261
+ if (!checkpoint) return; // No state to save yet
262
+
263
+ // Only checkpoint if there's been activity since last auto-checkpoint
264
+ const lastActivity = checkpoint.auto.lastActivityAt?.getTime() ?? 0;
265
+ const sinceActivity = Date.now() - lastActivity;
266
+ if (sinceActivity > AUTO_CHECKPOINT_INTERVAL_MS) return; // Idle — skip
267
+
268
+ const state: ConsciousState = {
269
+ currentTask: checkpoint.executionState?.currentTask ?? 'Active session (auto-checkpoint)',
270
+ decisions: checkpoint.executionState?.decisions ?? [],
271
+ activeFiles: checkpoint.executionState?.activeFiles ?? [],
272
+ nextSteps: checkpoint.executionState?.nextSteps ?? [],
273
+ relatedMemoryIds: checkpoint.executionState?.relatedMemoryIds ?? [],
274
+ notes: `Auto-checkpoint (15min timer). Last activity: ${Math.round(sinceActivity / 60_000)}min ago.`,
275
+ episodeId: checkpoint.executionState?.episodeId ?? null,
276
+ };
277
+
278
+ store.saveCheckpoint(agentId, state);
279
+ log(agentId, 'hook:timer', `auto-checkpoint (${Math.round(sinceActivity / 60_000)}min since activity)`);
280
+ } catch { /* timer failure is non-fatal */ }
281
+ }, AUTO_CHECKPOINT_INTERVAL_MS);
282
+
283
+ return {
284
+ close: () => {
285
+ clearInterval(autoCheckpointTimer);
286
+ server.close();
287
+ },
288
+ };
289
+ }