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.
- package/README.md +56 -7
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +3 -1
- package/dist/api/routes.js.map +1 -1
- package/dist/cli.js +94 -23
- package/dist/cli.js.map +1 -1
- package/dist/core/logger.d.ts +12 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +32 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/salience.d.ts +14 -0
- package/dist/core/salience.d.ts.map +1 -1
- package/dist/core/salience.js +51 -5
- package/dist/core/salience.js.map +1 -1
- package/dist/hooks/sidecar.d.ts +27 -0
- package/dist/hooks/sidecar.d.ts.map +1 -0
- package/dist/hooks/sidecar.js +220 -0
- package/dist/hooks/sidecar.js.map +1 -0
- package/dist/mcp.js +631 -417
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
- package/src/api/routes.ts +4 -1
- package/src/cli.ts +98 -23
- package/src/core/logger.ts +34 -0
- package/src/core/salience.ts +51 -5
- package/src/hooks/sidecar.ts +258 -0
- package/src/mcp.ts +282 -33
|
@@ -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
|
+
}
|