agent-working-memory 0.3.2 → 0.4.1

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