peon-mem 1.0.0

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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +301 -0
  3. package/bin/peon-mem.mjs +273 -0
  4. package/dist/brain.d.ts +72 -0
  5. package/dist/brain.js +224 -0
  6. package/dist/compression.d.ts +9 -0
  7. package/dist/compression.js +37 -0
  8. package/dist/config.d.ts +22 -0
  9. package/dist/config.js +99 -0
  10. package/dist/daemon-cli.d.ts +2 -0
  11. package/dist/daemon-cli.js +54 -0
  12. package/dist/daemon.d.ts +23 -0
  13. package/dist/daemon.js +1078 -0
  14. package/dist/embedding-store.d.ts +43 -0
  15. package/dist/embedding-store.js +169 -0
  16. package/dist/embeddings.d.ts +93 -0
  17. package/dist/embeddings.js +345 -0
  18. package/dist/entities.d.ts +61 -0
  19. package/dist/entities.js +191 -0
  20. package/dist/entity-extraction.d.ts +33 -0
  21. package/dist/entity-extraction.js +75 -0
  22. package/dist/eval-metrics.d.ts +27 -0
  23. package/dist/eval-metrics.js +50 -0
  24. package/dist/evaluation.d.ts +58 -0
  25. package/dist/evaluation.js +244 -0
  26. package/dist/global-extraction.d.ts +15 -0
  27. package/dist/global-extraction.js +61 -0
  28. package/dist/global-memory.d.ts +43 -0
  29. package/dist/global-memory.js +306 -0
  30. package/dist/global-promotion.d.ts +25 -0
  31. package/dist/global-promotion.js +29 -0
  32. package/dist/hyde.d.ts +31 -0
  33. package/dist/hyde.js +46 -0
  34. package/dist/index.d.ts +2 -0
  35. package/dist/index.js +246 -0
  36. package/dist/injection.d.ts +38 -0
  37. package/dist/injection.js +133 -0
  38. package/dist/logger.d.ts +17 -0
  39. package/dist/logger.js +63 -0
  40. package/dist/memory-mutations.d.ts +24 -0
  41. package/dist/memory-mutations.js +57 -0
  42. package/dist/memory-store.d.ts +194 -0
  43. package/dist/memory-store.js +1205 -0
  44. package/dist/monitor.d.ts +13 -0
  45. package/dist/monitor.js +977 -0
  46. package/dist/overview.d.ts +73 -0
  47. package/dist/overview.js +104 -0
  48. package/dist/processor.d.ts +90 -0
  49. package/dist/processor.js +450 -0
  50. package/dist/quality.d.ts +86 -0
  51. package/dist/quality.js +338 -0
  52. package/dist/recuration.d.ts +13 -0
  53. package/dist/recuration.js +65 -0
  54. package/dist/reranker.d.ts +34 -0
  55. package/dist/reranker.js +89 -0
  56. package/dist/retrieval.d.ts +106 -0
  57. package/dist/retrieval.js +392 -0
  58. package/dist/session-index.d.ts +34 -0
  59. package/dist/session-index.js +87 -0
  60. package/dist/temporal.d.ts +20 -0
  61. package/dist/temporal.js +62 -0
  62. package/dist/token-ab-monitor.d.ts +1 -0
  63. package/dist/token-ab-monitor.js +7 -0
  64. package/dist/tools.d.ts +232 -0
  65. package/dist/tools.js +546 -0
  66. package/dist/types.d.ts +169 -0
  67. package/dist/types.js +1 -0
  68. package/docs/assets/neural-universe.png +0 -0
  69. package/package.json +57 -0
  70. package/scripts/claude-peon-hook.mjs +522 -0
  71. package/scripts/codex-peon-hook.mjs +4 -0
  72. package/scripts/eval-retrieval-labeled.mjs +135 -0
  73. package/scripts/eval-retrieval.mjs +96 -0
  74. package/scripts/evaluate-peon.mjs +47 -0
  75. package/scripts/install-peon-stl.mjs +82 -0
  76. package/scripts/install-peon.mjs +318 -0
  77. package/scripts/lib/eval-ledger.mjs +104 -0
  78. package/scripts/lib/stl-classify.mjs +44 -0
  79. package/scripts/longmemeval-eval.mjs +144 -0
  80. package/scripts/peon-report.mjs +155 -0
  81. package/scripts/peon-stl.mjs +506 -0
  82. package/scripts/token-ab-monitor.html +235 -0
package/dist/daemon.js ADDED
@@ -0,0 +1,1078 @@
1
+ import { createServer } from "node:http";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { basename, dirname, join } from "node:path";
6
+ import { URL } from "node:url";
7
+ import { PeonLogger } from "./logger.js";
8
+ import { renderMonitorHtml } from "./monitor.js";
9
+ import { renderTokenAbMonitorHtml } from "./token-ab-monitor.js";
10
+ import { SessionIndex } from "./session-index.js";
11
+ import { createPeonTools } from "./tools.js";
12
+ import { summarizeBeliefs, detectDuplicates, computeTokenSavings, enrichInjection, filterStrayProjects } from "./overview.js";
13
+ function emptyTokenStats() {
14
+ return { total: 0, runs: 0, byModel: {}, byProject: {}, recent: [] };
15
+ }
16
+ /** Record one consolidation's token cost into the running totals. */
17
+ function addTokenRun(stats, run) {
18
+ const tokens = Number.isFinite(run.tokens) ? Math.max(0, Math.trunc(run.tokens)) : 0;
19
+ stats.total += tokens;
20
+ stats.runs += 1;
21
+ const model = run.model || "unknown";
22
+ stats.byModel[model] ??= { tokens: 0, runs: 0 };
23
+ stats.byModel[model].tokens += tokens;
24
+ stats.byModel[model].runs += 1;
25
+ stats.byProject[run.projectPath] = (stats.byProject[run.projectPath] ?? 0) + tokens;
26
+ stats.recent.unshift({ ...run, tokens });
27
+ if (stats.recent.length > 50)
28
+ stats.recent.length = 50;
29
+ }
30
+ /** Seed token totals from the on-disk log on boot, so history survives restarts. */
31
+ async function seedTokenStats(logger) {
32
+ const stats = emptyTokenStats();
33
+ const entries = await logger.recent(50000).catch(() => []);
34
+ // recent() returns newest-first; reverse so recent[] ends up newest-first after unshift.
35
+ for (const entry of [...entries].reverse()) {
36
+ if (entry.type !== "process_finish" && entry.type !== "auto_process_finish")
37
+ continue;
38
+ if (entry.status && entry.status !== "processed")
39
+ continue;
40
+ addTokenRun(stats, {
41
+ createdAt: String(entry.createdAt ?? ""),
42
+ projectPath: String(entry.projectPath ?? "unknown"),
43
+ model: String(entry.model ?? "unknown"),
44
+ tokens: Number(entry.estimatedTokens) || 0,
45
+ superseded: typeof entry.superseded === "number" ? entry.superseded : undefined,
46
+ merged: typeof entry.merged === "number" ? entry.merged : undefined,
47
+ recordsAdded: typeof entry.recordsAdded === "number" ? entry.recordsAdded : undefined
48
+ });
49
+ }
50
+ return stats;
51
+ }
52
+ function contextChars(context) {
53
+ return [
54
+ context.summary,
55
+ context.memories,
56
+ context.decisions,
57
+ context.preferences,
58
+ context.openQuestions,
59
+ context.artifacts,
60
+ context.timeline
61
+ ].reduce((sum, section) => sum + (section ? section.length : 0), 0);
62
+ }
63
+ const DEFAULT_HOST = "127.0.0.1";
64
+ const DEFAULT_PORT = 3737;
65
+ const DEFAULT_STATE_DIR = join(homedir(), "Library", "Application Support", "Peon");
66
+ // Sessions still "active" after this long are assumed orphaned by a crashed run.
67
+ const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
68
+ // High-frequency UI/health endpoints whose request/response logging is pure noise.
69
+ const NOISY_LOG_PATHS = new Set(["/monitor", "/monitor/state", "/token-ab-monitor", "/health", "/logs", "/favicon.ico"]);
70
+ // Temp roots — no real user project lives here. Throwaway test/hook projects do.
71
+ const TEMP_ROOTS = [tmpdir(), "/tmp", "/var/folders", "/private/var/folders", "/private/tmp"];
72
+ // Escape hatch (tests): treat every known project as real, including temp dirs.
73
+ const SHOW_ALL_PROJECTS = /^(1|true|yes|on)$/i.test(process.env.PEON_SHOW_ALL_PROJECTS ?? "");
74
+ /**
75
+ * Resolve any path to its ONE project brain: collapse git-worktree paths to the repo root, then
76
+ * climb ancestors (bounded by home). A `.peon/root` marker declares a brain BOUNDARY — the nearest
77
+ * one wins and the climb stops there, so a big sub-project (e.g. a thesis folder) keeps its OWN
78
+ * brain instead of being swallowed by the parent. With no marker anywhere the behaviour is
79
+ * unchanged: climb to the TOPMOST `.peon` (unify stray subfolders onto the root brain). Applied at
80
+ * the daemon boundary so EVERY caller (Claude hook, direct MCP, Codex) resolves a path identically
81
+ * — not just the hook. Mirrors resolveProjectPath() in scripts/claude-peon-hook.mjs.
82
+ */
83
+ export function canonicalProjectPath(projectPath, home = homedir()) {
84
+ const marker = "/.claude/worktrees/";
85
+ const idx = projectPath.indexOf(marker);
86
+ const base = idx !== -1 ? projectPath.slice(0, idx) : projectPath;
87
+ let dir = base;
88
+ let rootBrain = null;
89
+ while (dir && dir.startsWith(home) && dir !== home) {
90
+ if (existsSync(join(dir, ".peon"))) {
91
+ if (existsSync(join(dir, ".peon", "root")))
92
+ return dir; // boundary marker — this is its own brain
93
+ rootBrain = dir; // otherwise topmost-wins (unify subfolders onto the root brain)
94
+ }
95
+ const parent = dirname(dir);
96
+ if (!parent || parent === dir)
97
+ break;
98
+ dir = parent;
99
+ }
100
+ return rootBrain ?? base;
101
+ }
102
+ /**
103
+ * Reject requests that aren't from a loopback caller — defeats DNS-rebinding and drive-by-localhost
104
+ * attacks where a malicious web page POSTs to the daemon (which would otherwise write/read a brain
105
+ * at an attacker-controlled path). A bad Host header (rebinding) or a cross-origin Origin/Referer
106
+ * (browser drive-by) is refused. The node hook (no Origin) and the local monitor UI (loopback
107
+ * Origin) both pass.
108
+ */
109
+ function isLoopbackHostname(hostname) {
110
+ const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
111
+ return h === "127.0.0.1" || h === "localhost" || h === "::1" || h === "0:0:0:0:0:0:0:1";
112
+ }
113
+ /** Extract the hostname from a Host header, handling [ipv6]:port and host:port without the
114
+ * ":-split" bug that let a malformed "::evil" Host parse to an empty (loopback-looking) string. */
115
+ function hostnameFromHostHeader(raw) {
116
+ const value = raw.trim();
117
+ if (value.startsWith("[")) {
118
+ const end = value.indexOf("]");
119
+ return end > 0 ? value.slice(1, end) : value.slice(1);
120
+ }
121
+ const colons = (value.match(/:/g) ?? []).length;
122
+ if (colons === 1)
123
+ return value.slice(0, value.indexOf(":")); // host:port
124
+ return value; // bare host (0 colons) or bracketless ipv6 (ambiguous) → fails the loopback check
125
+ }
126
+ function isLocalRequest(request) {
127
+ const rawHost = request.headers.host;
128
+ // A present Host must be loopback (a missing Host — rare, HTTP/1.0 — is allowed; bind is 127.0.0.1).
129
+ if (rawHost !== undefined && !isLoopbackHostname(hostnameFromHostHeader(String(rawHost))))
130
+ return false;
131
+ for (const header of [request.headers.origin, request.headers.referer]) {
132
+ if (!header)
133
+ continue;
134
+ try {
135
+ if (!isLoopbackHostname(new URL(String(header)).hostname))
136
+ return false;
137
+ }
138
+ catch {
139
+ return false; // unparseable Origin/Referer → refuse
140
+ }
141
+ }
142
+ return true;
143
+ }
144
+ function isTempProjectPath(projectPath) {
145
+ if (SHOW_ALL_PROJECTS)
146
+ return false;
147
+ return TEMP_ROOTS.some((root) => projectPath === root || projectPath.startsWith(root + "/"));
148
+ }
149
+ /** A project worth tracking: its brain exists on disk and it is NOT a temp/test dir. */
150
+ function isRealProjectPath(projectPath) {
151
+ return existsSync(join(projectPath, ".peon")) && !isTempProjectPath(projectPath);
152
+ }
153
+ export async function startPeonDaemon(options = {}) {
154
+ const host = options.host ?? DEFAULT_HOST;
155
+ const port = options.port ?? DEFAULT_PORT;
156
+ const stateDir = options.logDir ?? DEFAULT_STATE_DIR;
157
+ const sessionIndexPath = join(stateDir, "sessions-index.json");
158
+ const tools = createPeonTools({ globalMemoryDir: options.globalMemoryDir, sessionIndexPath });
159
+ const logger = new PeonLogger({ logDir: options.logDir });
160
+ const projectRegistry = new ProjectRegistry(stateDir);
161
+ const sessionIndex = new SessionIndex(sessionIndexPath);
162
+ const activeSessions = new Map();
163
+ // Clear zombie sessions left behind by a crashed run before rehydrating, so the
164
+ // monitor never shows sessions that no client will ever end.
165
+ await sessionIndex.prune(Date.now(), SESSION_MAX_AGE_MS).catch(() => 0);
166
+ // Rehydrate in-flight sessions so a daemon restart doesn't lose the monitor view
167
+ // or orphan sessions that clients are still recording into.
168
+ for (const record of await sessionIndex.active()) {
169
+ activeSessions.set(record.sessionId, {
170
+ id: record.sessionId,
171
+ projectPath: record.projectPath,
172
+ client: record.client,
173
+ startedAt: record.startedAt
174
+ });
175
+ }
176
+ const monitorState = {
177
+ knownProjects: await projectRegistry.read(),
178
+ recentTraffic: [],
179
+ processingJobs: [],
180
+ tokens: await seedTokenStats(logger),
181
+ lastQueryByProject: new Map(),
182
+ recalledByProject: new Map(),
183
+ lastDreamByProject: new Map(),
184
+ lastRecuratedByProject: new Map()
185
+ };
186
+ // Permanently forget throwaway temp/test projects and brains deleted off disk,
187
+ // and collapse any stale worktree paths to their repo root. Keeps the UI clean.
188
+ const liveProjects = new Set([...monitorState.knownProjects].map((p) => canonicalProjectPath(p)).filter(isRealProjectPath));
189
+ if (liveProjects.size !== monitorState.knownProjects.size) {
190
+ monitorState.knownProjects = liveProjects;
191
+ await projectRegistry.write(liveProjects).catch(() => undefined);
192
+ }
193
+ const server = createServer(async (request, response) => {
194
+ const startedAt = Date.now();
195
+ const requestId = crypto.randomUUID();
196
+ const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
197
+ const requestMethod = request.method ?? "GET";
198
+ // The monitor polls /monitor/state every 2s; logging that floods the analysis
199
+ // log with thousands of meaningless entries. Skip the high-frequency UI/health
200
+ // endpoints — errors are still logged below regardless.
201
+ // Security: only serve loopback callers (blocks DNS-rebinding / drive-by-localhost).
202
+ if (!isLocalRequest(request)) {
203
+ sendJson(response, 403, { error: "forbidden: non-local request origin" });
204
+ return;
205
+ }
206
+ const noisy = NOISY_LOG_PATHS.has(requestUrl.pathname);
207
+ if (!noisy) {
208
+ await logger.log("request_in", {
209
+ requestId,
210
+ method: requestMethod,
211
+ path: requestUrl.pathname,
212
+ query: Object.fromEntries(requestUrl.searchParams.entries())
213
+ });
214
+ }
215
+ try {
216
+ const result = await routeRequest(request, tools, activeSessions, monitorState, logger, projectRegistry);
217
+ if ("html" in result) {
218
+ sendHtml(response, result.status ?? 200, result.html);
219
+ }
220
+ else {
221
+ sendJson(response, result.status ?? 200, result.body);
222
+ }
223
+ if (!noisy) {
224
+ await logger.log("response_out", {
225
+ requestId,
226
+ method: requestMethod,
227
+ path: requestUrl.pathname,
228
+ status: result.status ?? 200,
229
+ durationMs: Date.now() - startedAt
230
+ });
231
+ }
232
+ }
233
+ catch (error) {
234
+ // Only send an error response if the success path hasn't already responded
235
+ // (e.g. a post-response logging failure must not trigger a double-send).
236
+ if (!response.headersSent) {
237
+ sendJson(response, statusForError(error), {
238
+ error: error instanceof Error ? error.message : "Unknown Peon daemon error"
239
+ });
240
+ }
241
+ await logger.log("response_out", {
242
+ requestId,
243
+ method: requestMethod,
244
+ path: requestUrl.pathname,
245
+ status: statusForError(error),
246
+ durationMs: Date.now() - startedAt,
247
+ error: error instanceof Error ? error.message : "Unknown Peon daemon error"
248
+ });
249
+ }
250
+ });
251
+ await new Promise((resolve, reject) => {
252
+ server.once("error", reject);
253
+ server.listen(port, host, () => {
254
+ server.off("error", reject);
255
+ resolve();
256
+ });
257
+ });
258
+ // ─── The heartbeat: the brain stays ALIVE even with no active session ───
259
+ // Every pulse, sweep each real project and run a cost-free autonomous pass
260
+ // (reinforce / resolve conflicts / merge duplicates). LLM compression is left
261
+ // to the cost-gated consolidation path. Off-switch: PEON_BRAIN_ASLEEP.
262
+ const heartbeatMs = Number.parseInt(process.env.PEON_HEARTBEAT_MS ?? "", 10);
263
+ const pulseMs = Number.isFinite(heartbeatMs) && heartbeatMs >= 30000 ? heartbeatMs : 180000;
264
+ const asleep = /^(1|true|yes|on)$/i.test(process.env.PEON_BRAIN_ASLEEP ?? "");
265
+ let heartbeat;
266
+ if (!asleep) {
267
+ heartbeat = setInterval(() => {
268
+ void pulseBrain(tools, monitorState, logger);
269
+ }, pulseMs);
270
+ if (typeof heartbeat.unref === "function")
271
+ heartbeat.unref();
272
+ }
273
+ const address = server.address();
274
+ return {
275
+ host,
276
+ port: address.port,
277
+ url: `http://${host}:${address.port}`,
278
+ close: () => {
279
+ if (heartbeat)
280
+ clearInterval(heartbeat);
281
+ return closeServer(server);
282
+ }
283
+ };
284
+ }
285
+ /** One heartbeat pulse: run an autonomous brain pass over every real project. */
286
+ async function pulseBrain(tools, monitorState, logger) {
287
+ monitorState.lastHeartbeatAt = new Date().toISOString();
288
+ for (const projectPath of monitorState.knownProjects) {
289
+ if (!isRealProjectPath(projectPath))
290
+ continue;
291
+ try {
292
+ const recalledIds = monitorState.recalledByProject.get(projectPath);
293
+ const { actions } = await tools.brainPass({ projectPath, recalledIds });
294
+ monitorState.recalledByProject.delete(projectPath);
295
+ if (actions.length > 0) {
296
+ monitorState.lastDreamByProject.set(projectPath, new Date().toISOString());
297
+ await logger.log("brain_pulse", { projectPath, actions: actions.length, kinds: actions.map((a) => a.type) });
298
+ }
299
+ }
300
+ catch (error) {
301
+ await logger.log("brain_pulse_fail", { projectPath, error: error instanceof Error ? error.message : "unknown" });
302
+ }
303
+ }
304
+ // The global brain curates itself too (cost-free deterministic pass on the pulse).
305
+ try {
306
+ const { actions } = await tools.globalBrainPass({});
307
+ if (actions.length > 0)
308
+ await logger.log("global_brain_pulse", { actions: actions.length, kinds: actions.map((a) => a.type) });
309
+ }
310
+ catch (error) {
311
+ await logger.log("global_brain_pulse_fail", { error: error instanceof Error ? error.message : "unknown" });
312
+ }
313
+ // Costly LLM re-curation (trims ephemeral trivia) runs at most once per project
314
+ // per interval, ONE project per pulse — so the cost is spread thin and bounded.
315
+ // It's conservative + capped + recoverable (archives, never deletes).
316
+ await maybeRecurate(tools, monitorState, logger);
317
+ }
318
+ const RECURATE_INTERVAL_MS = 24 * 60 * 60 * 1000;
319
+ /** Pick the single most-overdue project and re-curate it (throttled, best-effort). */
320
+ async function maybeRecurate(tools, monitorState, logger) {
321
+ if (/^(1|true|yes|on)$/i.test(process.env.PEON_RECURATE_OFF ?? ""))
322
+ return;
323
+ const now = Date.now();
324
+ const due = [...monitorState.knownProjects]
325
+ .filter(isRealProjectPath)
326
+ .filter((p) => {
327
+ const last = monitorState.lastRecuratedByProject.get(p);
328
+ return !last || now - Date.parse(last) > RECURATE_INTERVAL_MS;
329
+ })
330
+ // Oldest (or never) first.
331
+ .sort((a, b) => Date.parse(monitorState.lastRecuratedByProject.get(a) ?? "0") - Date.parse(monitorState.lastRecuratedByProject.get(b) ?? "0"));
332
+ const projectPath = due[0];
333
+ if (!projectPath)
334
+ return;
335
+ // Stamp BEFORE running so a slow/failed pass doesn't get retried every pulse.
336
+ monitorState.lastRecuratedByProject.set(projectPath, new Date().toISOString());
337
+ try {
338
+ const result = await tools.recurateProject({ projectPath });
339
+ if (result.archived > 0 || result.capped) {
340
+ await logger.log("recurate", { projectPath, archived: result.archived, considered: result.considered, capped: result.capped ?? false });
341
+ }
342
+ }
343
+ catch (error) {
344
+ await logger.log("recurate_fail", { projectPath, error: error instanceof Error ? error.message : "unknown" });
345
+ }
346
+ }
347
+ async function routeRequest(request, tools, activeSessions, monitorState, logger, projectRegistry) {
348
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
349
+ const method = request.method ?? "GET";
350
+ if (method === "GET" && url.pathname === "/health") {
351
+ return { body: { ok: true, service: "peon-daemon" } };
352
+ }
353
+ if ((method === "GET" || method === "HEAD") && url.pathname === "/monitor") {
354
+ return { html: renderMonitorHtml() };
355
+ }
356
+ if ((method === "GET" || method === "HEAD") && url.pathname === "/token-ab-monitor") {
357
+ return { html: renderTokenAbMonitorHtml() };
358
+ }
359
+ if (method === "GET" && url.pathname === "/monitor/state") {
360
+ return { body: await buildMonitorState(tools, activeSessions, monitorState, logger) };
361
+ }
362
+ if (method === "GET" && url.pathname === "/logs") {
363
+ const limit = Number.parseInt(url.searchParams.get("limit") ?? "100", 10);
364
+ return { body: { entries: await logger.recent(Number.isFinite(limit) ? limit : 100) } };
365
+ }
366
+ if (method === "GET" && url.pathname === "/sessions") {
367
+ return { body: { active: Array.from(activeSessions.values()) } };
368
+ }
369
+ if (method === "POST" && url.pathname === "/sessions") {
370
+ const input = await readJson(request);
371
+ // Resolve the project the SAME way the hook does, so a raw cwd from a direct-MCP client (e.g.
372
+ // Codex) binds the session to the same brain Claude Code's hook would — not a divergent fork.
373
+ if (input.projectPath)
374
+ input.projectPath = canonicalProjectPath(input.projectPath);
375
+ if (input.cwd)
376
+ input.cwd = canonicalProjectPath(input.cwd);
377
+ const result = await tools.startSession(input);
378
+ await rememberProject(monitorState, projectRegistry, result.projectPath);
379
+ activeSessions.set(result.sessionId, {
380
+ id: result.sessionId,
381
+ projectPath: result.projectPath,
382
+ client: input.client,
383
+ startedAt: new Date().toISOString()
384
+ });
385
+ rememberTraffic(monitorState, {
386
+ id: crypto.randomUUID(),
387
+ projectPath: result.projectPath,
388
+ sessionId: result.sessionId,
389
+ type: "session_started",
390
+ content: `Session started for ${input.client}`,
391
+ createdAt: new Date().toISOString()
392
+ });
393
+ return { status: 201, body: result };
394
+ }
395
+ if (method === "POST" && url.pathname === "/messages") {
396
+ const input = await readJson(request);
397
+ const result = await tools.recordMessage(input);
398
+ rememberTraffic(monitorState, {
399
+ id: result.id,
400
+ projectPath: activeSessions.get(input.sessionId)?.projectPath,
401
+ sessionId: input.sessionId,
402
+ type: "message",
403
+ content: input.content,
404
+ createdAt: result.createdAt
405
+ });
406
+ return { status: 201, body: result };
407
+ }
408
+ if (method === "POST" && url.pathname === "/events") {
409
+ const input = await readJson(request);
410
+ const result = await tools.recordEvent(input);
411
+ rememberTraffic(monitorState, {
412
+ id: result.id,
413
+ projectPath: activeSessions.get(input.sessionId)?.projectPath,
414
+ sessionId: input.sessionId,
415
+ type: input.type,
416
+ content: input.content,
417
+ createdAt: result.createdAt
418
+ });
419
+ return { status: 201, body: result };
420
+ }
421
+ if (method === "POST" && url.pathname === "/process") {
422
+ const input = await readJson(request);
423
+ await rememberProject(monitorState, projectRegistry, input.projectPath);
424
+ const job = {
425
+ id: crypto.randomUUID(),
426
+ projectPath: input.projectPath,
427
+ reason: input.reason ?? "manual",
428
+ status: "processed",
429
+ createdAt: new Date().toISOString()
430
+ };
431
+ try {
432
+ await logger.log("process_start", {
433
+ projectPath: input.projectPath,
434
+ reason: input.reason ?? "manual"
435
+ });
436
+ const result = await tools.processMemory(input);
437
+ job.model = result.model;
438
+ job.estimatedTokens = result.estimatedTokens;
439
+ monitorState.processingJobs.unshift(job);
440
+ trim(monitorState.processingJobs, 30);
441
+ job.stats = result.stats;
442
+ addTokenRun(monitorState.tokens, {
443
+ createdAt: job.createdAt,
444
+ projectPath: input.projectPath,
445
+ model: result.model,
446
+ tokens: result.estimatedTokens,
447
+ superseded: result.stats?.superseded,
448
+ merged: result.stats?.merged,
449
+ recordsAdded: result.stats?.recordsAdded
450
+ });
451
+ await logger.log("process_finish", {
452
+ projectPath: input.projectPath,
453
+ reason: input.reason ?? "manual",
454
+ status: result.status,
455
+ model: result.model,
456
+ estimatedTokens: result.estimatedTokens,
457
+ operationsEmitted: result.stats?.operationsEmitted,
458
+ superseded: result.stats?.superseded,
459
+ obsoleted: result.stats?.obsoleted,
460
+ recordsAdded: result.stats?.recordsAdded,
461
+ merged: result.stats?.merged
462
+ });
463
+ // Auto-promote cross-cutting beliefs to global memory so any project can
464
+ // recall them — best-effort, never fails the consolidation.
465
+ await autoPromoteToGlobal(tools, logger, input.projectPath);
466
+ return { status: 201, body: result };
467
+ }
468
+ catch (error) {
469
+ job.status = "failed";
470
+ job.error = error instanceof Error ? error.message : "Unknown processing error";
471
+ monitorState.processingJobs.unshift(job);
472
+ trim(monitorState.processingJobs, 30);
473
+ await logger.log("process_fail", {
474
+ projectPath: input.projectPath,
475
+ reason: input.reason ?? "manual",
476
+ error: job.error
477
+ });
478
+ throw error;
479
+ }
480
+ }
481
+ if (method === "POST" && url.pathname === "/process/auto") {
482
+ const input = await readJson(request);
483
+ await rememberProject(monitorState, projectRegistry, input.projectPath);
484
+ return { status: 201, body: await runAutomaticProcessing(tools, monitorState, logger, input) };
485
+ }
486
+ const endSessionMatch = url.pathname.match(/^\/sessions\/([^/]+)\/end$/);
487
+ if (method === "POST" && endSessionMatch) {
488
+ const input = { sessionId: decodeURIComponent(endSessionMatch[1]) };
489
+ let result;
490
+ try {
491
+ result = await tools.endSession(input);
492
+ }
493
+ catch (error) {
494
+ // A SessionEnd hook must NEVER receive a 500 — that errors the user's Claude session.
495
+ // An unknown session (started before a daemon restart that didn't index it, or already
496
+ // ended) simply has nothing to consolidate; degrade to a benign no-op.
497
+ if (error instanceof Error && /Unknown Peon session/.test(error.message)) {
498
+ activeSessions.delete(input.sessionId);
499
+ return { body: { sessionId: input.sessionId, status: "unknown_session" } };
500
+ }
501
+ throw error;
502
+ }
503
+ activeSessions.delete(input.sessionId);
504
+ await rememberProject(monitorState, projectRegistry, result.projectPath);
505
+ rememberTraffic(monitorState, {
506
+ id: crypto.randomUUID(),
507
+ projectPath: result.projectPath,
508
+ sessionId: input.sessionId,
509
+ type: "session_ended",
510
+ content: "Session ended",
511
+ createdAt: result.endedAt ?? new Date().toISOString()
512
+ });
513
+ const autoProcessing = await runAutomaticProcessing(tools, monitorState, logger, {
514
+ projectPath: result.projectPath,
515
+ trigger: "session_end"
516
+ });
517
+ return { body: { ...result, autoProcessing } };
518
+ }
519
+ if (method === "GET" && url.pathname === "/context") {
520
+ const rawProjectPath = url.searchParams.get("projectPath");
521
+ if (!rawProjectPath)
522
+ throw new BadRequestError("projectPath is required");
523
+ // Canonicalize so a direct-MCP caller (Codex) reads the SAME brain the hook would inject —
524
+ // a raw cwd was hitting a divergent per-subfolder fork and missing the real history.
525
+ const projectPath = canonicalProjectPath(rawProjectPath);
526
+ const input = {
527
+ projectPath,
528
+ query: url.searchParams.get("query") ?? undefined,
529
+ maxChars: optionalNumber(url.searchParams.get("maxChars"))
530
+ };
531
+ const servedAt = Date.now();
532
+ const context = await tools.getContext(input);
533
+ const latencyMs = Date.now() - servedAt;
534
+ // Remember the live prompt so the Overview can replay "what Peon injected last".
535
+ if (input.query && input.query.trim())
536
+ monitorState.lastQueryByProject.set(projectPath, input.query.trim());
537
+ // Accumulate which beliefs were recalled so the next brain pass reinforces them.
538
+ if (context.recalledIds && context.recalledIds.length > 0) {
539
+ const prior = monitorState.recalledByProject.get(projectPath) ?? [];
540
+ monitorState.recalledByProject.set(projectPath, Array.from(new Set([...prior, ...context.recalledIds])).slice(-100));
541
+ }
542
+ // Log what the brain actually GAVE BACK for this prompt — the key signal for analysing how
543
+ // well retrieval served each request. Includes serve-time telemetry (latency + an est. token
544
+ // count) so the LOW-COST/latency constraint can be OBSERVED in production, not just asserted
545
+ // from offline harnesses — the STL monitor surfaces the averages.
546
+ const chars = contextChars(context);
547
+ await logger.log("context_served", {
548
+ projectPath,
549
+ query: input.query,
550
+ chars,
551
+ estTokens: Math.round(chars / 4), // ~4 chars/token heuristic — good enough for trend/telemetry
552
+ latencyMs,
553
+ compacted: context.meta?.compacted ?? false,
554
+ maxChars: context.meta?.maxChars
555
+ });
556
+ return { body: context };
557
+ }
558
+ if (method === "GET" && url.pathname === "/brain") {
559
+ const projectPath = url.searchParams.get("projectPath");
560
+ if (!projectPath)
561
+ throw new BadRequestError("projectPath is required");
562
+ return {
563
+ body: await tools.inspectBrain({
564
+ projectPath,
565
+ query: url.searchParams.get("query") ?? undefined,
566
+ maxChars: optionalNumber(url.searchParams.get("maxChars"))
567
+ })
568
+ };
569
+ }
570
+ if (method === "GET" && url.pathname === "/search") {
571
+ const projectPath = url.searchParams.get("projectPath");
572
+ const query = url.searchParams.get("query");
573
+ if (!projectPath)
574
+ throw new BadRequestError("projectPath is required");
575
+ if (!query)
576
+ throw new BadRequestError("query is required");
577
+ return {
578
+ body: await tools.searchMemory({
579
+ projectPath: canonicalProjectPath(projectPath),
580
+ query,
581
+ limit: optionalNumber(url.searchParams.get("limit")),
582
+ maxChars: optionalNumber(url.searchParams.get("maxChars"))
583
+ })
584
+ };
585
+ }
586
+ if (method === "GET" && url.pathname === "/quality") {
587
+ const projectPath = url.searchParams.get("projectPath");
588
+ if (!projectPath)
589
+ throw new BadRequestError("projectPath is required");
590
+ return {
591
+ body: await tools.qualityReport({
592
+ projectPath,
593
+ staleAfterDays: optionalNumber(url.searchParams.get("staleAfterDays"))
594
+ })
595
+ };
596
+ }
597
+ if (method === "GET" && url.pathname === "/cross-context") {
598
+ const query = url.searchParams.get("query");
599
+ if (!query)
600
+ throw new BadRequestError("query is required");
601
+ const target = url.searchParams.get("projectPath");
602
+ const exclude = url.searchParams.get("exclude") ?? undefined;
603
+ // One explicit target, or fan out across every project the daemon knows about.
604
+ const projectPaths = target ? [target] : Array.from(monitorState.knownProjects);
605
+ const result = await tools.crossProjectSearch({
606
+ query,
607
+ projectPaths,
608
+ excludeProjectPath: exclude,
609
+ limit: optionalNumber(url.searchParams.get("limit")),
610
+ maxProjects: optionalNumber(url.searchParams.get("maxProjects"))
611
+ });
612
+ await logger.log("cross_context_served", {
613
+ query,
614
+ exclude,
615
+ projectsSearched: result.projectsSearched.length,
616
+ hits: result.results.length
617
+ });
618
+ return { body: result };
619
+ }
620
+ if (method === "GET" && url.pathname === "/injection") {
621
+ const projectPath = url.searchParams.get("projectPath");
622
+ if (!projectPath)
623
+ throw new BadRequestError("projectPath is required");
624
+ return {
625
+ body: await tools.buildInjection({
626
+ projectPath,
627
+ query: url.searchParams.get("query") ?? undefined,
628
+ maxChars: optionalNumber(url.searchParams.get("maxChars")),
629
+ includeInactive: url.searchParams.get("includeInactive") === "true"
630
+ })
631
+ };
632
+ }
633
+ if (method === "GET" && url.pathname === "/global/memories") {
634
+ return {
635
+ body: await tools.searchGlobalMemory({
636
+ query: url.searchParams.get("query") ?? undefined,
637
+ type: optionalMemoryType(url.searchParams.get("type")),
638
+ status: optionalMemoryStatus(url.searchParams.get("status"))
639
+ })
640
+ };
641
+ }
642
+ if (method === "POST" && url.pathname === "/global/memories") {
643
+ return { status: 201, body: await tools.rememberGlobal(await readJson(request)) };
644
+ }
645
+ if (method === "POST" && url.pathname === "/global/import-project") {
646
+ return { status: 201, body: await tools.importGlobalMemory(await readJson(request)) };
647
+ }
648
+ if (method === "POST" && url.pathname === "/global/promote") {
649
+ return { status: 201, body: await tools.promoteToGlobal(await readJson(request)) };
650
+ }
651
+ if (method === "POST" && url.pathname === "/memory/update") {
652
+ return { body: await tools.updateMemory(await readJson(request)) };
653
+ }
654
+ if (method === "POST" && url.pathname === "/memory/delete") {
655
+ return { body: await tools.deleteMemory(await readJson(request)) };
656
+ }
657
+ if (method === "POST" && url.pathname === "/memory/merge") {
658
+ return { body: await tools.mergeMemory(await readJson(request)) };
659
+ }
660
+ if (method === "POST" && url.pathname === "/brain/pass") {
661
+ const input = await readJson(request);
662
+ return { body: await tools.brainPass(input) };
663
+ }
664
+ if (method === "POST" && url.pathname === "/global/brain-pass") {
665
+ const input = await readJson(request);
666
+ return { body: await tools.globalBrainPass(input) };
667
+ }
668
+ if (method === "POST" && url.pathname === "/global/extract") {
669
+ const input = await readJson(request);
670
+ return { status: 201, body: await tools.extractGlobal(input) };
671
+ }
672
+ if (method === "POST" && url.pathname === "/recurate") {
673
+ const input = await readJson(request);
674
+ return { body: await tools.recurateProject(input) };
675
+ }
676
+ if (method === "GET" && url.pathname === "/brain/activity") {
677
+ const projectPaths = [...monitorState.knownProjects].filter(isRealProjectPath);
678
+ return { body: await tools.brainActivity({ projectPaths, limit: optionalNumber(url.searchParams.get("limit")) }) };
679
+ }
680
+ if (method === "GET" && url.pathname === "/global/dashboard") {
681
+ return { body: await tools.globalDashboard() };
682
+ }
683
+ if (method === "GET" && url.pathname === "/brain/actions") {
684
+ const projectPath = url.searchParams.get("projectPath");
685
+ if (!projectPath)
686
+ throw new BadRequestError("projectPath is required");
687
+ return { body: await tools.brainActions({ projectPath, limit: optionalNumber(url.searchParams.get("limit")) }) };
688
+ }
689
+ if (method === "POST" && url.pathname === "/brain/restore") {
690
+ const input = await readJson(request);
691
+ return { body: await tools.restoreBackup(input) };
692
+ }
693
+ if (method === "POST" && url.pathname === "/evaluate") {
694
+ return { body: await tools.evaluateProject(await readJson(request)) };
695
+ }
696
+ if (method === "GET" && url.pathname === "/token-ab") {
697
+ return { body: { records: await readTokenAbLog() } };
698
+ }
699
+ if (method === "GET" && url.pathname === "/overview") {
700
+ const projectPath = url.searchParams.get("projectPath");
701
+ if (!projectPath)
702
+ throw new BadRequestError("projectPath is required");
703
+ return { body: await buildOverviewPayload(tools, monitorState, projectPath) };
704
+ }
705
+ if (method === "GET" && url.pathname === "/network") {
706
+ return { body: await buildNetworkPayload(tools, monitorState) };
707
+ }
708
+ return { status: 404, body: { error: `No Peon daemon route for ${method} ${url.pathname}` } };
709
+ }
710
+ async function readTokenAbLog() {
711
+ const logPath = join(homedir(), "Library", "Application Support", "Peon", "token-ab-log.jsonl");
712
+ const raw = await readFile(logPath, "utf8").catch(() => "");
713
+ return raw
714
+ .split("\n")
715
+ .filter(Boolean)
716
+ .flatMap((line) => {
717
+ try {
718
+ return [JSON.parse(line)];
719
+ }
720
+ catch {
721
+ return [];
722
+ }
723
+ });
724
+ }
725
+ /** Assemble the at-a-glance Overview for one project. */
726
+ async function buildOverviewPayload(tools, monitorState, projectPath) {
727
+ const brain = await tools.inspectBrain({ projectPath });
728
+ const globalRecords = await tools.searchGlobalMemory({ status: "active" }).catch(() => []);
729
+ const counts = summarizeBeliefs(brain.records);
730
+ const duplicates = detectDuplicates(brain.records);
731
+ const conflicts = brain.records
732
+ .filter((record) => record.status === "conflicted")
733
+ .map((record) => ({ id: record.id, content: record.content }));
734
+ const savings = computeTokenSavings(await readTokenAbLog(), projectPath);
735
+ const query = monitorState.lastQueryByProject.get(projectPath) ?? "recent project context decisions current work";
736
+ const injection = await tools.buildInjection({ projectPath, query, maxChars: 4000 }).catch(() => null);
737
+ const lastInjection = injection
738
+ ? { query, items: enrichInjection(injection.selected, [...brain.records, ...globalRecords]) }
739
+ : { query, items: [] };
740
+ const lastConsolidatedAt = brain.records
741
+ .map((record) => record.updatedAt)
742
+ .sort()
743
+ .at(-1);
744
+ const brainActions = await tools.brainActions({ projectPath, limit: 12 }).catch(() => []);
745
+ return {
746
+ projectPath,
747
+ projectName: basename(projectPath),
748
+ counts: { ...counts, project: counts.active, global: globalRecords.length },
749
+ lastConsolidatedAt: lastConsolidatedAt ?? null,
750
+ tokensSaved: savings,
751
+ lastInjection,
752
+ needsReview: { conflicts, duplicates },
753
+ vitals: {
754
+ alive: !/^(1|true|yes|on)$/i.test(process.env.PEON_BRAIN_ASLEEP ?? ""),
755
+ lastHeartbeatAt: monitorState.lastHeartbeatAt ?? null,
756
+ lastDreamAt: monitorState.lastDreamByProject.get(projectPath) ?? null
757
+ },
758
+ brainActions
759
+ };
760
+ }
761
+ /** Assemble the cross-project + global-memory Network view. */
762
+ async function buildNetworkPayload(tools, monitorState) {
763
+ const globalRecords = await tools.searchGlobalMemory({ status: "active" }).catch(() => []);
764
+ const projects = await Promise.all(Array.from(monitorState.knownProjects)
765
+ .filter((projectPath) => isRealProjectPath(projectPath))
766
+ .map(async (projectPath) => {
767
+ const brain = await tools.inspectBrain({ projectPath }).catch(() => null);
768
+ const records = brain?.records ?? [];
769
+ const counts = summarizeBeliefs(records);
770
+ return {
771
+ projectPath,
772
+ projectName: basename(projectPath),
773
+ active: counts.active,
774
+ total: counts.total,
775
+ pinned: counts.pinned
776
+ };
777
+ }));
778
+ // Hide stray subdirectory brains (empty .peon from a subdir session) — never merges memory.
779
+ const visible = filterStrayProjects(projects);
780
+ return {
781
+ global: globalRecords.map((record) => ({
782
+ id: record.id,
783
+ type: record.type,
784
+ content: record.content,
785
+ importance: record.score.importance,
786
+ entities: record.entities
787
+ })),
788
+ projects: visible.sort((left, right) => right.active - left.active)
789
+ };
790
+ }
791
+ function optionalNumber(value) {
792
+ if (value === null)
793
+ return undefined;
794
+ const parsed = Number.parseInt(value, 10);
795
+ return Number.isFinite(parsed) ? parsed : undefined;
796
+ }
797
+ function optionalMemoryType(value) {
798
+ if (value === "summary" ||
799
+ value === "decision" ||
800
+ value === "preference" ||
801
+ value === "open_question" ||
802
+ value === "artifact" ||
803
+ value === "timeline" ||
804
+ value === "fact") {
805
+ return value;
806
+ }
807
+ return undefined;
808
+ }
809
+ function optionalMemoryStatus(value) {
810
+ if (value === "active" || value === "stale" || value === "conflicted" || value === "superseded" || value === "archived")
811
+ return value;
812
+ return undefined;
813
+ }
814
+ async function readJson(request) {
815
+ const chunks = [];
816
+ for await (const chunk of request) {
817
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
818
+ }
819
+ const raw = Buffer.concat(chunks).toString("utf8");
820
+ if (!raw.trim())
821
+ return {};
822
+ try {
823
+ return JSON.parse(raw);
824
+ }
825
+ catch {
826
+ throw new BadRequestError("Request body must be valid JSON");
827
+ }
828
+ }
829
+ function sendJson(response, status, body) {
830
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
831
+ response.end(`${JSON.stringify(body)}\n`);
832
+ }
833
+ function sendHtml(response, status, html) {
834
+ response.writeHead(status, { "content-type": "text/html; charset=utf-8" });
835
+ response.end(html);
836
+ }
837
+ /** Latest STL daily self-check verdict (headline + timestamp), or null if none written yet. */
838
+ async function readStlSummary() {
839
+ try {
840
+ const raw = await readFile(join(homedir(), "Library", "Logs", "Peon", "stl", "latest.json"), "utf8");
841
+ const parsed = JSON.parse(raw);
842
+ if (!parsed.headline)
843
+ return null;
844
+ return { headline: parsed.headline, generatedAt: parsed.generatedAt ?? "" };
845
+ }
846
+ catch {
847
+ return null;
848
+ }
849
+ }
850
+ async function buildMonitorState(tools, activeSessions, monitorState, logger) {
851
+ // Only surface real projects (existing brain, not a temp/test dir).
852
+ const live = Array.from(monitorState.knownProjects).filter(isRealProjectPath);
853
+ const built = await Promise.all(live.map(async (projectPath) => {
854
+ // inspectBrain already computes getContext internally — reuse it instead of calling
855
+ // getContext a SECOND time per project (that ran ranking + spreading activation twice
856
+ // on every 2s poll for every project).
857
+ const brain = await readProjectBrain(tools, projectPath);
858
+ const context = brain && !("error" in brain) ? brain.context : null;
859
+ return { projectPath, context, brain };
860
+ }));
861
+ // Hide empty shells (a .peon dir with no remembered beliefs) — only show real brains.
862
+ const projects = SHOW_ALL_PROJECTS
863
+ ? built
864
+ : built.filter((p) => p.brain && "records" in p.brain && Array.isArray(p.brain.records) && p.brain.records.length > 0);
865
+ // Health strip: injection serve-cost over the last 24h (from the telemetry fields the
866
+ // /context handler logs) + the daily STL self-check verdict — so the Ops page answers
867
+ // "is Peon fast, and did its own daily check pass?" at a glance.
868
+ const recentEntries = (await logger.recent(2000));
869
+ const dayAgo = Date.now() - 24 * 3600 * 1000;
870
+ const serves = recentEntries.filter((e) => e.type === "context_served" && typeof e.latencyMs === "number" && new Date(String(e.createdAt)).getTime() > dayAgo);
871
+ const lat = serves.map((e) => Number(e.latencyMs)).sort((a, b) => a - b);
872
+ const health = {
873
+ serve: lat.length
874
+ ? {
875
+ count: lat.length,
876
+ avgMs: Math.round(lat.reduce((a, b) => a + b, 0) / lat.length),
877
+ p50Ms: lat[Math.floor(lat.length / 2)],
878
+ p95Ms: lat[Math.min(lat.length - 1, Math.floor(lat.length * 0.95))],
879
+ avgTokens: Math.round(serves.reduce((a, e) => a + (Number(e.estTokens) || 0), 0) / serves.length)
880
+ }
881
+ : null,
882
+ stl: await readStlSummary()
883
+ };
884
+ return {
885
+ service: "peon-daemon",
886
+ generatedAt: new Date().toISOString(),
887
+ activeSessions: Array.from(activeSessions.values()),
888
+ recentTraffic: monitorState.recentTraffic,
889
+ recentLogs: recentEntries.slice(-80),
890
+ processingJobs: monitorState.processingJobs,
891
+ tokens: monitorState.tokens,
892
+ health,
893
+ projects
894
+ };
895
+ }
896
+ async function runAutomaticProcessing(tools, monitorState, logger, input) {
897
+ const job = {
898
+ id: crypto.randomUUID(),
899
+ projectPath: input.projectPath,
900
+ reason: `auto:${input.trigger}`,
901
+ status: "skipped",
902
+ createdAt: new Date().toISOString()
903
+ };
904
+ try {
905
+ await logger.log("auto_process_start", {
906
+ projectPath: input.projectPath,
907
+ trigger: input.trigger,
908
+ force: input.force ?? false
909
+ });
910
+ const result = await tools.maybeProcessMemory(input);
911
+ job.status = result.status;
912
+ job.reason = `auto:${input.trigger}:${result.decision.reason}`;
913
+ if (result.status === "processed") {
914
+ job.model = result.result.model;
915
+ job.estimatedTokens = result.result.estimatedTokens;
916
+ job.stats = result.result.stats;
917
+ addTokenRun(monitorState.tokens, {
918
+ createdAt: job.createdAt,
919
+ projectPath: input.projectPath,
920
+ model: result.result.model,
921
+ tokens: result.result.estimatedTokens,
922
+ superseded: result.result.stats?.superseded,
923
+ merged: result.result.stats?.merged,
924
+ recordsAdded: result.result.stats?.recordsAdded
925
+ });
926
+ }
927
+ else {
928
+ job.estimatedTokens = result.decision.estimatedTokens;
929
+ }
930
+ monitorState.processingJobs.unshift(job);
931
+ trim(monitorState.processingJobs, 30);
932
+ await logger.log("auto_process_finish", {
933
+ projectPath: input.projectPath,
934
+ trigger: input.trigger,
935
+ status: result.status,
936
+ reason: result.decision.reason,
937
+ rawChars: result.decision.rawChars,
938
+ newChars: result.decision.newChars,
939
+ estimatedTokens: job.estimatedTokens,
940
+ model: job.model,
941
+ operationsEmitted: job.stats?.operationsEmitted,
942
+ superseded: job.stats?.superseded,
943
+ obsoleted: job.stats?.obsoleted,
944
+ recordsAdded: job.stats?.recordsAdded,
945
+ merged: job.stats?.merged
946
+ });
947
+ // After a real consolidation (already past the cost gate), lift cross-cutting
948
+ // beliefs into global AND run a full brain pass WITH LLM topic-compression.
949
+ if (result.status === "processed") {
950
+ await autoPromoteToGlobal(tools, logger, input.projectPath);
951
+ // Let the model lift this project's cross-cutting knowledge into global memory.
952
+ await tools
953
+ .extractGlobal({ projectPath: input.projectPath })
954
+ .then(({ promoted }) => promoted.length > 0 ? logger.log("global_extract", { projectPath: input.projectPath, promoted: promoted.length }) : undefined)
955
+ .catch((error) => logger.log("global_extract_fail", { projectPath: input.projectPath, error: error instanceof Error ? error.message : "unknown" }));
956
+ const recalledIds = monitorState.recalledByProject.get(input.projectPath);
957
+ await tools
958
+ .brainPass({ projectPath: input.projectPath, recalledIds, compress: true })
959
+ .then(({ actions }) => {
960
+ monitorState.recalledByProject.delete(input.projectPath);
961
+ if (actions.length > 0) {
962
+ monitorState.lastDreamByProject.set(input.projectPath, new Date().toISOString());
963
+ return logger.log("brain_consolidate", { projectPath: input.projectPath, actions: actions.length, kinds: actions.map((a) => a.type) });
964
+ }
965
+ return undefined;
966
+ })
967
+ .catch((error) => logger.log("brain_consolidate_fail", { projectPath: input.projectPath, error: error instanceof Error ? error.message : "unknown" }));
968
+ }
969
+ return result;
970
+ }
971
+ catch (error) {
972
+ job.status = "failed";
973
+ job.error = error instanceof Error ? error.message : "Unknown automatic processing error";
974
+ monitorState.processingJobs.unshift(job);
975
+ trim(monitorState.processingJobs, 30);
976
+ await logger.log("auto_process_fail", {
977
+ projectPath: input.projectPath,
978
+ trigger: input.trigger,
979
+ error: job.error
980
+ });
981
+ // Do NOT re-throw: a failed consolidation (e.g. unparseable model output) must not bubble a
982
+ // 500 out to the SessionEnd hook. It is already recorded as an auto_process_fail (so the STL
983
+ // monitor still counts it) and the delta cursor was never advanced (processMemory threw before
984
+ // writing processing-state), so the batch is retried on the next trigger.
985
+ return { status: "failed", error: job.error };
986
+ }
987
+ }
988
+ /**
989
+ * Best-effort: copy this project's cross-cutting beliefs into global memory.
990
+ * Never throws — a promotion failure must not break consolidation.
991
+ */
992
+ async function autoPromoteToGlobal(tools, logger, projectPath) {
993
+ if (/^(1|true|yes|on)$/i.test(process.env.PEON_AUTO_PROMOTE_GLOBAL_OFF ?? ""))
994
+ return;
995
+ try {
996
+ const { promoted } = await tools.promoteToGlobal({ projectPath });
997
+ if (promoted.length > 0) {
998
+ await logger.log("global_promote", {
999
+ projectPath,
1000
+ promoted: promoted.length,
1001
+ types: promoted.map((record) => record.type)
1002
+ });
1003
+ }
1004
+ }
1005
+ catch (error) {
1006
+ await logger.log("global_promote_fail", {
1007
+ projectPath,
1008
+ error: error instanceof Error ? error.message : "Unknown promotion error"
1009
+ });
1010
+ }
1011
+ }
1012
+ async function readProjectBrain(tools, projectPath) {
1013
+ try {
1014
+ return await tools.inspectBrain({ projectPath });
1015
+ }
1016
+ catch (error) {
1017
+ return { error: error instanceof Error ? error.message : "Unable to inspect project brain" };
1018
+ }
1019
+ }
1020
+ function rememberTraffic(monitorState, item) {
1021
+ monitorState.recentTraffic.unshift(item);
1022
+ trim(monitorState.recentTraffic, 80);
1023
+ }
1024
+ async function rememberProject(monitorState, projectRegistry, projectPath) {
1025
+ // Never register throwaway temp/test projects — they only pollute the registry.
1026
+ if (isTempProjectPath(projectPath))
1027
+ return;
1028
+ const canonical = canonicalProjectPath(projectPath);
1029
+ if (monitorState.knownProjects.has(canonical))
1030
+ return;
1031
+ monitorState.knownProjects.add(canonical);
1032
+ await projectRegistry.write(monitorState.knownProjects);
1033
+ }
1034
+ function trim(items, max) {
1035
+ if (items.length > max)
1036
+ items.splice(max);
1037
+ }
1038
+ class ProjectRegistry {
1039
+ path;
1040
+ constructor(stateDir) {
1041
+ this.path = join(stateDir, "projects.json");
1042
+ }
1043
+ async read() {
1044
+ const raw = await readFile(this.path, "utf8").catch(() => "");
1045
+ if (!raw.trim())
1046
+ return new Set();
1047
+ try {
1048
+ const parsed = JSON.parse(raw);
1049
+ if (!Array.isArray(parsed))
1050
+ return new Set();
1051
+ return new Set(parsed.filter((value) => typeof value === "string" && value.trim().length > 0));
1052
+ }
1053
+ catch {
1054
+ return new Set();
1055
+ }
1056
+ }
1057
+ async write(projects) {
1058
+ await mkdir(this.path.slice(0, this.path.lastIndexOf("/")), { recursive: true });
1059
+ await writeFile(this.path, `${JSON.stringify(Array.from(projects).sort(), null, 2)}\n`, "utf8");
1060
+ }
1061
+ }
1062
+ function statusForError(error) {
1063
+ if (error instanceof BadRequestError)
1064
+ return 400;
1065
+ return 500;
1066
+ }
1067
+ function closeServer(server) {
1068
+ return new Promise((resolve, reject) => {
1069
+ server.close((error) => {
1070
+ if (error)
1071
+ reject(error);
1072
+ else
1073
+ resolve();
1074
+ });
1075
+ });
1076
+ }
1077
+ class BadRequestError extends Error {
1078
+ }