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.
- package/LICENSE +21 -0
- package/README.md +301 -0
- package/bin/peon-mem.mjs +273 -0
- package/dist/brain.d.ts +72 -0
- package/dist/brain.js +224 -0
- package/dist/compression.d.ts +9 -0
- package/dist/compression.js +37 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.js +99 -0
- package/dist/daemon-cli.d.ts +2 -0
- package/dist/daemon-cli.js +54 -0
- package/dist/daemon.d.ts +23 -0
- package/dist/daemon.js +1078 -0
- package/dist/embedding-store.d.ts +43 -0
- package/dist/embedding-store.js +169 -0
- package/dist/embeddings.d.ts +93 -0
- package/dist/embeddings.js +345 -0
- package/dist/entities.d.ts +61 -0
- package/dist/entities.js +191 -0
- package/dist/entity-extraction.d.ts +33 -0
- package/dist/entity-extraction.js +75 -0
- package/dist/eval-metrics.d.ts +27 -0
- package/dist/eval-metrics.js +50 -0
- package/dist/evaluation.d.ts +58 -0
- package/dist/evaluation.js +244 -0
- package/dist/global-extraction.d.ts +15 -0
- package/dist/global-extraction.js +61 -0
- package/dist/global-memory.d.ts +43 -0
- package/dist/global-memory.js +306 -0
- package/dist/global-promotion.d.ts +25 -0
- package/dist/global-promotion.js +29 -0
- package/dist/hyde.d.ts +31 -0
- package/dist/hyde.js +46 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +246 -0
- package/dist/injection.d.ts +38 -0
- package/dist/injection.js +133 -0
- package/dist/logger.d.ts +17 -0
- package/dist/logger.js +63 -0
- package/dist/memory-mutations.d.ts +24 -0
- package/dist/memory-mutations.js +57 -0
- package/dist/memory-store.d.ts +194 -0
- package/dist/memory-store.js +1205 -0
- package/dist/monitor.d.ts +13 -0
- package/dist/monitor.js +977 -0
- package/dist/overview.d.ts +73 -0
- package/dist/overview.js +104 -0
- package/dist/processor.d.ts +90 -0
- package/dist/processor.js +450 -0
- package/dist/quality.d.ts +86 -0
- package/dist/quality.js +338 -0
- package/dist/recuration.d.ts +13 -0
- package/dist/recuration.js +65 -0
- package/dist/reranker.d.ts +34 -0
- package/dist/reranker.js +89 -0
- package/dist/retrieval.d.ts +106 -0
- package/dist/retrieval.js +392 -0
- package/dist/session-index.d.ts +34 -0
- package/dist/session-index.js +87 -0
- package/dist/temporal.d.ts +20 -0
- package/dist/temporal.js +62 -0
- package/dist/token-ab-monitor.d.ts +1 -0
- package/dist/token-ab-monitor.js +7 -0
- package/dist/tools.d.ts +232 -0
- package/dist/tools.js +546 -0
- package/dist/types.d.ts +169 -0
- package/dist/types.js +1 -0
- package/docs/assets/neural-universe.png +0 -0
- package/package.json +57 -0
- package/scripts/claude-peon-hook.mjs +522 -0
- package/scripts/codex-peon-hook.mjs +4 -0
- package/scripts/eval-retrieval-labeled.mjs +135 -0
- package/scripts/eval-retrieval.mjs +96 -0
- package/scripts/evaluate-peon.mjs +47 -0
- package/scripts/install-peon-stl.mjs +82 -0
- package/scripts/install-peon.mjs +318 -0
- package/scripts/lib/eval-ledger.mjs +104 -0
- package/scripts/lib/stl-classify.mjs +44 -0
- package/scripts/longmemeval-eval.mjs +144 -0
- package/scripts/peon-report.mjs +155 -0
- package/scripts/peon-stl.mjs +506 -0
- package/scripts/token-ab-monitor.html +235 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Peon STL cycle — Self-Tracking Loop.
|
|
4
|
+
*
|
|
5
|
+
* Runs once every 24h and keeps an eye on what Peon has been doing. It answers the
|
|
6
|
+
* four questions, for the trailing 24h window:
|
|
7
|
+
*
|
|
8
|
+
* CHECK 1 RECORDED — what Peon captured (raw messages/events/tool-calls) and
|
|
9
|
+
* what new beliefs it wrote, per project.
|
|
10
|
+
* CHECK 2 INJECTED — what context Peon handed back to Claude ("in the cloud"):
|
|
11
|
+
* how often, how big, for which queries, per project.
|
|
12
|
+
* CHECK 3 WENT WRONG — failures split into the INJECTION path (context/overview)
|
|
13
|
+
* and the RECORDING path (record/session-end/consolidation),
|
|
14
|
+
* de-duplicating the client-side hook mirror of a server 5xx.
|
|
15
|
+
* CHECK 4 PROCESSING — "process_last_24_hours": the consolidation runs in the
|
|
16
|
+
* window — processed / skipped / failed — with a recovery-aware
|
|
17
|
+
* correctness verdict (healthy / healthy-with-failures /
|
|
18
|
+
* FAILING / churning / gated-ok / idle) per project.
|
|
19
|
+
*
|
|
20
|
+
* It writes a dated report + latest.{md,json} + appends history.jsonl, and — if an
|
|
21
|
+
* OpenRouter key is present — adds a short LLM diagnosis. It is READ-ONLY on memory
|
|
22
|
+
* by default; pass --fix (or PEON_STL_FIX=1) to also run the safe, reversible
|
|
23
|
+
* recuration pass on projects with a conflict backlog (with an up-front daemon
|
|
24
|
+
* health check). It NEVER edits Peon's code or config, and it ALWAYS writes a report
|
|
25
|
+
* — even a degraded one if analysis throws.
|
|
26
|
+
*
|
|
27
|
+
* node scripts/peon-stl.mjs # diagnose + report (default, safe)
|
|
28
|
+
* node scripts/peon-stl.mjs --fix # also recurate high-conflict projects
|
|
29
|
+
* PEON_STL_WINDOW_HOURS=48 node scripts/peon-stl.mjs
|
|
30
|
+
*/
|
|
31
|
+
import { readFileSync, writeFileSync, mkdirSync, statSync, openSync, readSync, closeSync, appendFileSync, existsSync } from "node:fs";
|
|
32
|
+
import { homedir } from "node:os";
|
|
33
|
+
import { join, basename, dirname, parse as parsePath } from "node:path";
|
|
34
|
+
import { isServerFault } from "./lib/stl-classify.mjs";
|
|
35
|
+
|
|
36
|
+
// ---------- config ----------
|
|
37
|
+
const HOME = homedir();
|
|
38
|
+
const SUPPORT = join(HOME, "Library", "Application Support", "Peon");
|
|
39
|
+
const LOGS = join(HOME, "Library", "Logs", "Peon");
|
|
40
|
+
const DAEMON_LOG = process.env.PEON_LOG_PATH || join(LOGS, "daemon.jsonl");
|
|
41
|
+
const HOOK_ERRORS = join(SUPPORT, "claude-hooks", "errors.jsonl");
|
|
42
|
+
const ERR_LOG = join(SUPPORT, "daemon.err.log");
|
|
43
|
+
const PROJECTS_JSON = join(SUPPORT, "projects.json");
|
|
44
|
+
const REPORT_DIR = join(LOGS, "stl");
|
|
45
|
+
const DAEMON = process.env.PEON_DAEMON_URL || "http://127.0.0.1:3737";
|
|
46
|
+
|
|
47
|
+
const WINDOW_HOURS = Number(process.env.PEON_STL_WINDOW_HOURS) || 24;
|
|
48
|
+
const WINDOW_MS = WINDOW_HOURS * 3600 * 1000;
|
|
49
|
+
const NOW = Date.now();
|
|
50
|
+
const CUTOFF = NOW - WINDOW_MS;
|
|
51
|
+
const DO_FIX = process.argv.includes("--fix") || process.env.PEON_STL_FIX === "1";
|
|
52
|
+
|
|
53
|
+
const CONFLICT_THRESHOLD = 10; // recurate / flag once unresolved-conflict backlog crosses this
|
|
54
|
+
const HIGH_FAILURE_RATE = 0.34; // >1/3 of consolidation runs failing == genuinely FAILING
|
|
55
|
+
const LOG_TAIL_CAP_BYTES = 80 * 1024 * 1024; // bound memory on append-only logs as they grow
|
|
56
|
+
const RECURATE_TIMEOUT_MS = Number(process.env.PEON_STL_RECURATE_TIMEOUT_MS) || 600000; // LLM recuration is slow
|
|
57
|
+
|
|
58
|
+
// ---------- io helpers ----------
|
|
59
|
+
function parseLines(raw) {
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
62
|
+
const t = line.trim();
|
|
63
|
+
if (!t) continue;
|
|
64
|
+
try { out.push(JSON.parse(t)); } catch { /* skip bad line */ }
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/** Read only the tail of a (possibly huge) append-only log, then drop the first partial line. */
|
|
69
|
+
function readJsonlTail(path, capBytes = LOG_TAIL_CAP_BYTES) {
|
|
70
|
+
let size = 0;
|
|
71
|
+
try { size = statSync(path).size; } catch { return []; }
|
|
72
|
+
if (size <= capBytes) {
|
|
73
|
+
try { return parseLines(readFileSync(path, "utf8")); } catch { return []; }
|
|
74
|
+
}
|
|
75
|
+
const start = size - capBytes;
|
|
76
|
+
let fd;
|
|
77
|
+
try {
|
|
78
|
+
fd = openSync(path, "r");
|
|
79
|
+
const buf = Buffer.alloc(capBytes);
|
|
80
|
+
readSync(fd, buf, 0, capBytes, start);
|
|
81
|
+
const text = buf.toString("utf8");
|
|
82
|
+
const nl = text.indexOf("\n");
|
|
83
|
+
return parseLines(nl >= 0 ? text.slice(nl + 1) : text);
|
|
84
|
+
} catch { return []; }
|
|
85
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
86
|
+
}
|
|
87
|
+
const readJsonl = (p) => readJsonlTail(p); // all our jsonl reads are append-only → tail-capped
|
|
88
|
+
function readJson(path) {
|
|
89
|
+
try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
|
|
90
|
+
}
|
|
91
|
+
const ts = (e) => new Date(e?.createdAt || e?.at || e?.ts || e?.timestamp || 0).getTime();
|
|
92
|
+
const within = (e) => { const t = ts(e); return t >= CUTOFF && t <= NOW + 60000; };
|
|
93
|
+
const fmt = (n) => Number(n || 0).toLocaleString("en-US");
|
|
94
|
+
|
|
95
|
+
/** Count append-only jsonl lines whose timestamp falls in the window (tail-capped). */
|
|
96
|
+
function countWithin(path) {
|
|
97
|
+
let n = 0, total = 0;
|
|
98
|
+
for (const e of readJsonlTail(path)) { total++; if (within(e)) n++; }
|
|
99
|
+
return { window: n, total };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------- classification ----------
|
|
103
|
+
/** A git worktree under .claude/worktrees/<x> is the same project as its parent checkout. */
|
|
104
|
+
function canonicalProject(p) {
|
|
105
|
+
if (typeof p !== "string") return "";
|
|
106
|
+
const marker = "/.claude/worktrees/";
|
|
107
|
+
const idx = p.indexOf(marker);
|
|
108
|
+
return idx !== -1 ? p.slice(0, idx) : p;
|
|
109
|
+
}
|
|
110
|
+
/** Which Peon path a request belongs to: injection (serving memory) vs recording (capturing it). */
|
|
111
|
+
function classifyPath(path) {
|
|
112
|
+
const p = String(path || "");
|
|
113
|
+
if (/^\/(context|overview|build_injection|network|cross)/.test(p)) return "injection";
|
|
114
|
+
if (/^\/(sessions|messages|events|process|record|recurate)/.test(p)) return "recording";
|
|
115
|
+
return "other";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isRealProject(p) {
|
|
119
|
+
if (!p || typeof p !== "string") return false;
|
|
120
|
+
if (/\/peon-test-project|\/private\/var\/folders|\/tmp\/|node_modules/.test(p)) return false;
|
|
121
|
+
try { statSync(join(p, ".peon", "brain", "memories.jsonl")); return true; } catch { return false; }
|
|
122
|
+
}
|
|
123
|
+
function discoverProjects(logEntries) {
|
|
124
|
+
const set = new Set();
|
|
125
|
+
const list = readJson(PROJECTS_JSON);
|
|
126
|
+
for (const p of Array.isArray(list) ? list : []) { const c = canonicalProject(p); if (c) set.add(c); }
|
|
127
|
+
for (const e of logEntries) if (e.projectPath) { const c = canonicalProject(e.projectPath); if (c) set.add(c); }
|
|
128
|
+
return [...set].filter(isRealProject).sort();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Resolve the OpenRouter key the same way the daemon does: env first, then a climbed .env. */
|
|
132
|
+
function resolveOpenRouterKey() {
|
|
133
|
+
if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY;
|
|
134
|
+
let cur = process.cwd();
|
|
135
|
+
const root = parsePath(cur).root;
|
|
136
|
+
while (true) {
|
|
137
|
+
const f = join(cur, ".env");
|
|
138
|
+
try {
|
|
139
|
+
if (existsSync(f)) {
|
|
140
|
+
for (const line of readFileSync(f, "utf8").split(/\r?\n/)) {
|
|
141
|
+
const m = line.match(/^\s*OPENROUTER_API_KEY\s*=\s*(.+)\s*$/);
|
|
142
|
+
if (m) return m[1].trim().replace(/^['"]|['"]$/g, "");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch { /* unreadable .env → keep climbing */ }
|
|
146
|
+
if (cur === root) return undefined;
|
|
147
|
+
cur = dirname(cur);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------- daemon log ----------
|
|
152
|
+
const LOG = readJsonlTail(DAEMON_LOG).filter(within);
|
|
153
|
+
for (const e of LOG) if (e.projectPath) e.projectPath = canonicalProject(e.projectPath); // fold worktrees into parent
|
|
154
|
+
|
|
155
|
+
function analyzeDaemonLog() {
|
|
156
|
+
const types = {};
|
|
157
|
+
const served = [], crossServed = [], procFinish = [], procFail = [], recurates = [];
|
|
158
|
+
const http5xx = [];
|
|
159
|
+
const injByProject = {};
|
|
160
|
+
for (const e of LOG) {
|
|
161
|
+
types[e.type] = (types[e.type] || 0) + 1;
|
|
162
|
+
switch (e.type) {
|
|
163
|
+
case "context_served": served.push(e); injByProject[e.projectPath || "?"] = (injByProject[e.projectPath || "?"] || 0) + 1; break;
|
|
164
|
+
case "cross_context_served": crossServed.push(e); break;
|
|
165
|
+
case "process_finish": case "auto_process_finish": procFinish.push(e); break;
|
|
166
|
+
case "auto_process_fail": case "process_fail": procFail.push(e); break;
|
|
167
|
+
case "recurate": recurates.push(e); break;
|
|
168
|
+
case "response_out": if (isServerFault(e)) http5xx.push(e); break; // client aborts (error:"aborted") are not server faults
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const sum = (arr, k) => arr.reduce((a, e) => a + (Number(e[k]) || 0), 0);
|
|
172
|
+
const inj = http5xx.filter((e) => classifyPath(e.path) === "injection");
|
|
173
|
+
const rec = http5xx.filter((e) => classifyPath(e.path) === "recording");
|
|
174
|
+
const other = http5xx.filter((e) => classifyPath(e.path) === "other");
|
|
175
|
+
return { types, served, crossServed, procFinish, procFail, recurates, http5xx, http5xxByPath: { inj, rec, other }, injByProject, sum };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function analyzeHookErrors() {
|
|
179
|
+
const all = readJsonlTail(HOOK_ERRORS).filter(within);
|
|
180
|
+
const classify = (m) => {
|
|
181
|
+
if (/Unknown Peon session/.test(m)) return { sig: "stale session-end (benign — now auto-degraded)", benign: true, mirror5xx: true };
|
|
182
|
+
if (/Unexpected token|Unexpected (number|string|end)|in JSON|not valid JSON/.test(m)) return { sig: "consolidation JSON parse (malformed model output)", benign: false, mirror5xx: true };
|
|
183
|
+
if (/ECONNREFUSED|fetch failed|ENOTFOUND/.test(m)) return { sig: "daemon unreachable", benign: false, mirror5xx: false };
|
|
184
|
+
if (/timed out|timeout/i.test(m)) return { sig: "timeout", benign: false, mirror5xx: false };
|
|
185
|
+
if (/failed with 5\d\d|\b5\d\d\b/.test(m)) return { sig: "daemon 5xx (other)", benign: false, mirror5xx: true };
|
|
186
|
+
if (/failed with 4\d\d|\b4\d\d\b/.test(m)) return { sig: "daemon 4xx", benign: false, mirror5xx: false };
|
|
187
|
+
return { sig: m.slice(0, 60) || "unknown", benign: false, mirror5xx: false };
|
|
188
|
+
};
|
|
189
|
+
const by = {}, samples = {};
|
|
190
|
+
let benign = 0, mirror5xx = 0, standalone = 0; // standalone = a failure with no server 5xx behind it
|
|
191
|
+
for (const e of all) {
|
|
192
|
+
const c = classify(String(e.error || ""));
|
|
193
|
+
const k = `${e.eventName || "?"} :: ${c.sig}`;
|
|
194
|
+
by[k] = (by[k] || 0) + 1;
|
|
195
|
+
if (!samples[k]) samples[k] = String(e.error || "").slice(0, 200);
|
|
196
|
+
if (c.benign) benign++;
|
|
197
|
+
if (c.mirror5xx) mirror5xx++; else standalone++;
|
|
198
|
+
}
|
|
199
|
+
return { count: all.length, by, samples, benign, mirror5xx, standalone };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function analyzeRestarts() {
|
|
203
|
+
let raw = "";
|
|
204
|
+
try { raw = readFileSync(ERR_LOG, "utf8"); } catch { return { boots: 0, stderr: [] }; }
|
|
205
|
+
const lines = raw.split(/\r?\n/).filter(Boolean);
|
|
206
|
+
return { boots: lines.filter((l) => /listening on/.test(l)).length, stderr: lines.filter((l) => !/listening on/.test(l)).slice(-8) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function analyzeProject(p) {
|
|
210
|
+
const brainDir = join(p, ".peon", "brain");
|
|
211
|
+
const rawDir = join(p, ".peon", "raw");
|
|
212
|
+
const records = readJsonl(join(brainDir, "memories.jsonl"));
|
|
213
|
+
const state = readJson(join(brainDir, "processing-state.json")) || {};
|
|
214
|
+
const quality = readJson(join(brainDir, "quality-report.json")) || {};
|
|
215
|
+
|
|
216
|
+
const byStatus = {}; let activeTotal = 0, newBeliefs = 0; const newByType = {};
|
|
217
|
+
for (const r of records) {
|
|
218
|
+
byStatus[r.status] = (byStatus[r.status] || 0) + 1;
|
|
219
|
+
if (r.status === "active") activeTotal++;
|
|
220
|
+
if (within(r)) { newBeliefs++; newByType[r.type] = (newByType[r.type] || 0) + 1; }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const msgs = countWithin(join(rawDir, "messages.jsonl"));
|
|
224
|
+
const evs = countWithin(join(rawDir, "events.jsonl"));
|
|
225
|
+
const tools = countWithin(join(rawDir, "tool-calls.jsonl"));
|
|
226
|
+
|
|
227
|
+
// CHECK 4 — consolidation runs for THIS project in the window
|
|
228
|
+
const runs = LOG.filter((e) => e.projectPath === p && /process_(finish|fail)$/.test(e.type));
|
|
229
|
+
const processed = runs.filter((e) => e.status === "processed");
|
|
230
|
+
const skipped = runs.filter((e) => e.status === "skipped");
|
|
231
|
+
const failed = runs.filter((e) => /process_fail$/.test(e.type));
|
|
232
|
+
const sum = (arr, k) => arr.reduce((a, e) => a + (Number(e[k]) || 0), 0);
|
|
233
|
+
const recordsAdded = sum(processed, "recordsAdded");
|
|
234
|
+
const reconciled = sum(processed, "superseded") + sum(processed, "merged") + sum(processed, "obsoleted");
|
|
235
|
+
const llmTokens = sum(processed, "estimatedTokens") + sum(failed, "estimatedTokens");
|
|
236
|
+
|
|
237
|
+
const conflicts = Array.isArray(quality.conflicts) ? quality.conflicts.length : 0;
|
|
238
|
+
const duplicates = Array.isArray(quality.duplicates) ? quality.duplicates.length : 0;
|
|
239
|
+
const stale = Array.isArray(quality.staleIds) ? quality.staleIds.length : 0;
|
|
240
|
+
|
|
241
|
+
// recovery-aware verdict — a single transient fail among many good runs is NOT "FAILING"
|
|
242
|
+
const attempts = processed.length + failed.length;
|
|
243
|
+
const failureRate = attempts ? failed.length / attempts : 0;
|
|
244
|
+
const lastFailTs = failed.length ? Math.max(...failed.map(ts)) : 0;
|
|
245
|
+
const recoveredAfterFail = failed.length > 0 && processed.some((e) => ts(e) > lastFailTs);
|
|
246
|
+
const lastRunFailed = runs.length > 0 && /process_fail$/.test(runs.slice().sort((a, b) => ts(a) - ts(b)).at(-1).type);
|
|
247
|
+
|
|
248
|
+
let verdict, reason;
|
|
249
|
+
if (runs.length === 0) { verdict = "idle"; reason = "no consolidation ran in window"; }
|
|
250
|
+
else if (failed.length === 0 && processed.length > 0) { verdict = "healthy"; reason = `${processed.length} run(s), +${recordsAdded} beliefs, ${reconciled} reconciled`; }
|
|
251
|
+
else if (failed.length === 0) { verdict = "gated-ok"; reason = `${skipped.length} run(s) correctly skipped below cost gate`; }
|
|
252
|
+
else if (failureRate >= HIGH_FAILURE_RATE || (lastRunFailed && !recoveredAfterFail)) {
|
|
253
|
+
verdict = "FAILING"; reason = `${failed.length}/${attempts} runs failed (${Math.round(failureRate * 100)}%)${lastRunFailed ? ", last run failed, no recovery" : ""}`;
|
|
254
|
+
} else if (processed.length > 0 && recordsAdded === 0 && reconciled === 0) {
|
|
255
|
+
verdict = "churning"; reason = `${processed.length} run(s) cost ${fmt(llmTokens)} tokens but learned/changed nothing`;
|
|
256
|
+
} else {
|
|
257
|
+
verdict = "healthy-with-failures"; reason = `${processed.length} ok + ${failed.length} transient fail(s) (${Math.round(failureRate * 100)}%), recovered (last run succeeded)`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// conflict backlog is a SEPARATE, pre-existing signal — do not frame it as decay caused by idleness
|
|
261
|
+
const conflictBacklog = conflicts >= CONFLICT_THRESHOLD;
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
name: basename(p), path: p,
|
|
265
|
+
recorded: { messages: msgs.window, events: evs.window, toolCalls: tools.window, newBeliefs, newByType },
|
|
266
|
+
brain: { total: records.length, active: activeTotal, byStatus, conflicts, duplicates, stale, conflictBacklog },
|
|
267
|
+
processing: {
|
|
268
|
+
runs: runs.length, processed: processed.length, skipped: skipped.length, failed: failed.length,
|
|
269
|
+
recordsAdded, reconciled, llmTokens, failureRate: Math.round(failureRate * 100),
|
|
270
|
+
lastStatus: state.lastStatus, lastProcessedAt: state.lastProcessedAt, lastModel: state.lastModel, verdict, reason
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------- remediation (only with --fix) ----------
|
|
276
|
+
async function daemonHealthy() {
|
|
277
|
+
const ctrl = new AbortController();
|
|
278
|
+
const t = setTimeout(() => ctrl.abort(), 4000);
|
|
279
|
+
try { return (await fetch(`${DAEMON}/health`, { signal: ctrl.signal, headers: { host: "127.0.0.1" } })).ok; }
|
|
280
|
+
catch { return false; } finally { clearTimeout(t); }
|
|
281
|
+
}
|
|
282
|
+
async function recurate(projectPath) {
|
|
283
|
+
const ctrl = new AbortController();
|
|
284
|
+
const t = setTimeout(() => ctrl.abort(), RECURATE_TIMEOUT_MS);
|
|
285
|
+
try {
|
|
286
|
+
const res = await fetch(`${DAEMON}/recurate`, {
|
|
287
|
+
method: "POST", headers: { "content-type": "application/json", host: "127.0.0.1" },
|
|
288
|
+
body: JSON.stringify({ projectPath }), signal: ctrl.signal
|
|
289
|
+
});
|
|
290
|
+
if (!res.ok) return { projectPath, ok: false, error: `HTTP ${res.status}` };
|
|
291
|
+
return { projectPath, ok: true, ...(await res.json()) };
|
|
292
|
+
} catch (e) { return { projectPath, ok: false, error: ctrl.signal.aborted ? `timed out after ${RECURATE_TIMEOUT_MS}ms` : (e?.message || "failed") }; }
|
|
293
|
+
finally { clearTimeout(t); }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------- LLM diagnosis (optional, graceful, one retry) ----------
|
|
297
|
+
async function diagnose(findings) {
|
|
298
|
+
const key = resolveOpenRouterKey();
|
|
299
|
+
if (!key) return null;
|
|
300
|
+
const model = process.env.PEON_PROCESSING_MODEL || process.env.PEON_SUMMARY_MODEL || "google/gemini-2.5-flash-lite";
|
|
301
|
+
const prompt = [
|
|
302
|
+
"You are Peon's ops doctor. Peon is a local memory-brain for Claude Code: it RECORDS sessions,",
|
|
303
|
+
"CONSOLIDATES them into beliefs, and INJECTS relevant memory back into Claude.",
|
|
304
|
+
"Below are the last-24h metrics from its self-tracking loop. Diagnose tersely:",
|
|
305
|
+
"1) The top 1-3 real problems (ignore healthy signals; transient-but-recovered fails are minor).",
|
|
306
|
+
"2) The most likely root cause of each.",
|
|
307
|
+
"3) One concrete fix or action per problem.",
|
|
308
|
+
"End with a one-line overall verdict. Plain markdown, no preamble.",
|
|
309
|
+
"", "```json", JSON.stringify(findings, null, 1), "```"
|
|
310
|
+
].join("\n");
|
|
311
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
312
|
+
const ctrl = new AbortController();
|
|
313
|
+
const t = setTimeout(() => ctrl.abort(), 45000);
|
|
314
|
+
try {
|
|
315
|
+
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
|
316
|
+
method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
|
|
317
|
+
body: JSON.stringify({ model, temperature: 0.2, messages: [{ role: "user", content: prompt }] }), signal: ctrl.signal
|
|
318
|
+
});
|
|
319
|
+
if (res.ok) { const data = await res.json(); return data?.choices?.[0]?.message?.content?.trim() || null; }
|
|
320
|
+
if (res.status < 500 || attempt === 1) return `_(diagnosis unavailable: HTTP ${res.status})_`;
|
|
321
|
+
} catch (e) { if (attempt === 1) return `_(diagnosis unavailable: ${e?.message || "error"})_`; }
|
|
322
|
+
finally { clearTimeout(t); }
|
|
323
|
+
}
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------- report ----------
|
|
328
|
+
function buildReport(d) {
|
|
329
|
+
const L = [];
|
|
330
|
+
const stamp = new Date(NOW).toISOString().replace("T", " ").slice(0, 16);
|
|
331
|
+
L.push(`# 🧠 Peon STL Cycle — ${stamp} (trailing ${WINDOW_HOURS}h)`, "");
|
|
332
|
+
L.push(`**Verdict:** ${d.headline}`, "");
|
|
333
|
+
L.push(`Projects: ${d.projects.length} · daemon requests: ${fmt(d.daemon.types.request_in || 0)} · injections: ${fmt(d.daemon.served.length)} · consolidations: ${fmt(d.daemon.procFinish.length)} (${d.daemon.procFail.length} failed) · distinct failures: ${d.errors.distinct} (${d.errors.serious} serious)`, "");
|
|
334
|
+
|
|
335
|
+
// CHECK 1
|
|
336
|
+
L.push(`## 1 · Recorded (last ${WINDOW_HOURS}h)`);
|
|
337
|
+
const rec = d.projects.reduce((a, p) => ({ m: a.m + p.recorded.messages, e: a.e + p.recorded.events, t: a.t + p.recorded.toolCalls, b: a.b + p.recorded.newBeliefs }), { m: 0, e: 0, t: 0, b: 0 });
|
|
338
|
+
L.push(`Captured **${fmt(rec.m)} messages**, ${fmt(rec.e)} events, ${fmt(rec.t)} tool-calls → wrote **${fmt(rec.b)} new beliefs**.`, "");
|
|
339
|
+
L.push("| Project | msgs | events | tools | new beliefs |", "|---|--:|--:|--:|--:|");
|
|
340
|
+
for (const p of d.projects.filter((p) => p.recorded.messages || p.recorded.events || p.recorded.newBeliefs))
|
|
341
|
+
L.push(`| ${p.name} | ${fmt(p.recorded.messages)} | ${fmt(p.recorded.events)} | ${fmt(p.recorded.toolCalls)} | ${fmt(p.recorded.newBeliefs)} |`);
|
|
342
|
+
L.push("", `_Counts read at report time; append-only raw logs may have grown since the window closed._`, "");
|
|
343
|
+
|
|
344
|
+
// CHECK 2
|
|
345
|
+
L.push(`## 2 · Injected into Claude (last ${WINDOW_HOURS}h)`);
|
|
346
|
+
const totalChars = d.daemon.sum(d.daemon.served, "chars");
|
|
347
|
+
const avg = d.daemon.served.length ? Math.round(totalChars / d.daemon.served.length) : 0;
|
|
348
|
+
const compacted = d.daemon.served.filter((e) => e.compacted).length;
|
|
349
|
+
L.push(`Injected context **${fmt(d.daemon.served.length)}×** (+${fmt(d.daemon.crossServed.length)} cross-project), ${fmt(totalChars)} chars total, ${fmt(avg)} avg/prompt. ${compacted} were budget-compacted.`, "");
|
|
350
|
+
// Serve-time telemetry (present on events emitted after the telemetry build) — averaged over the
|
|
351
|
+
// events that carry it, so the production cost/latency is OBSERVED, not assumed.
|
|
352
|
+
const withTel = d.daemon.served.filter((e) => typeof e.latencyMs === "number");
|
|
353
|
+
if (withTel.length) {
|
|
354
|
+
const avgMs = Math.round(d.daemon.sum(withTel, "latencyMs") / withTel.length);
|
|
355
|
+
const p95 = withTel.map((e) => e.latencyMs).sort((a, b) => a - b)[Math.min(withTel.length - 1, Math.floor(withTel.length * 0.95))];
|
|
356
|
+
const avgTok = Math.round(d.daemon.sum(withTel, "estTokens") / withTel.length);
|
|
357
|
+
L.push(`Serve cost (${withTel.length} instrumented): **~${fmt(avgTok)} tokens/injection**, latency avg ${fmt(avgMs)}ms / p95 ${fmt(p95)}ms.`, "");
|
|
358
|
+
}
|
|
359
|
+
const recent = d.daemon.served.slice(-6).map((e) => `\`${(e.query || "").replace(/\s+/g, " ").slice(0, 70)}\` → ${fmt(e.chars)}ch`).reverse();
|
|
360
|
+
if (recent.length) { L.push("Recent injection queries:"); for (const r of recent) L.push(`- ${r}`); }
|
|
361
|
+
L.push("", `_Peon logs each injection's query + size, not the served text — this is the envelope, not the substance. (Use the live monitor /overview to replay the last full injection.)_`, "");
|
|
362
|
+
|
|
363
|
+
// CHECK 3
|
|
364
|
+
L.push(`## 3 · What went wrong`);
|
|
365
|
+
const inj = d.daemon.http5xxByPath.inj, recd = d.daemon.http5xxByPath.rec, oth = d.daemon.http5xxByPath.other;
|
|
366
|
+
L.push(`**Injection path:** ${inj.length ? `${inj.length} server 5xx ⚠️` : "0 errors ✅"} · **Recording path:** ${recd.length ? `${recd.length} server 5xx` : "0 errors ✅"}${oth.length ? ` · other: ${oth.length}` : ""}`);
|
|
367
|
+
const grp = (arr) => { const m = {}; for (const e of arr) m[e.path || "?"] = (m[e.path || "?"] || 0) + 1; return Object.entries(m).map(([k, v]) => `${v}× \`${k}\``).join(", "); };
|
|
368
|
+
if (recd.length) L.push(`- recording 5xx: ${grp(recd)}`);
|
|
369
|
+
if (inj.length) L.push(`- injection 5xx: ${grp(inj)}`);
|
|
370
|
+
if (d.hooks.count) {
|
|
371
|
+
L.push(`\n**Hook errors (${d.hooks.count})** — what Claude's hooks saw (the ${d.hooks.mirror5xx} marked benign/mirror are the client-side echo of the server 5xx above, not separate failures):`);
|
|
372
|
+
for (const [k, v] of Object.entries(d.hooks.by).sort((a, b) => b[1] - a[1]))
|
|
373
|
+
L.push(` - ${v}× ${k}${d.hooks.samples[k] ? ` \n \`${d.hooks.samples[k].replace(/`/g, "'")}\`` : ""}`);
|
|
374
|
+
}
|
|
375
|
+
if (!inj.length && !recd.length && !oth.length && !d.hooks.count) L.push("No injection- or recording-path errors in the window. ✅");
|
|
376
|
+
L.push(`\n_Distinct failures: **${d.errors.distinct}** (server 5xx + connection failures with no server response), of which **${d.errors.serious}** serious (excludes ${d.hooks.benign} benign stale session-ends). Daemon boot banners in err.log: ${d.restarts.boots}._`, "");
|
|
377
|
+
|
|
378
|
+
// CHECK 4
|
|
379
|
+
L.push(`## 4 · process_last_24_hours — consolidation correctness`);
|
|
380
|
+
const proc = d.daemon;
|
|
381
|
+
L.push(`Consolidation ran **${proc.procFinish.length + proc.procFail.length}×**: ${proc.procFinish.filter((e) => e.status === "processed").length} processed, ${proc.procFinish.filter((e) => e.status === "skipped").length} skipped (cost-gated), **${proc.procFail.length} failed**.`, "");
|
|
382
|
+
L.push("| Project | runs | proc | skip | fail | learned | tokens | verdict |", "|---|--:|--:|--:|--:|--:|--:|---|");
|
|
383
|
+
for (const p of d.projects.filter((p) => p.processing.runs))
|
|
384
|
+
L.push(`| ${p.name} | ${p.processing.runs} | ${p.processing.processed} | ${p.processing.skipped} | ${p.processing.failed} | ${p.processing.recordsAdded}+${p.processing.reconciled} | ${fmt(p.processing.llmTokens)} | ${p.processing.verdict} |`);
|
|
385
|
+
const flagged = d.projects.filter((p) => ["FAILING", "churning"].includes(p.processing.verdict));
|
|
386
|
+
if (flagged.length) { L.push("", "**Flagged (needs attention):**"); for (const p of flagged) L.push(`- **${p.name}** — ${p.processing.reason}.`); }
|
|
387
|
+
const transient = d.projects.filter((p) => p.processing.verdict === "healthy-with-failures");
|
|
388
|
+
if (transient.length) { L.push("", "**Transient (self-recovered, informational):**"); for (const p of transient) L.push(`- ${p.name} — ${p.processing.reason}.`); }
|
|
389
|
+
L.push("");
|
|
390
|
+
|
|
391
|
+
// brain health + conflict backlog (pre-existing, not framed as decay)
|
|
392
|
+
L.push(`## Brain health (current state)`);
|
|
393
|
+
L.push("| Project | active | conflicted | conflicts | dupes | stale |", "|---|--:|--:|--:|--:|--:|");
|
|
394
|
+
for (const p of d.projects)
|
|
395
|
+
L.push(`| ${p.name} | ${fmt(p.brain.active)} | ${fmt(p.brain.byStatus.conflicted || 0)} | ${fmt(p.brain.conflicts)} | ${fmt(p.brain.duplicates)} | ${fmt(p.brain.stale)} |`);
|
|
396
|
+
const backlog = d.projects.filter((p) => p.brain.conflictBacklog);
|
|
397
|
+
if (backlog.length) {
|
|
398
|
+
L.push("", `**Conflict backlog** (pre-existing unresolved conflicts — reconcile via \`--fix\` or a consolidation pass; not caused by the window):`);
|
|
399
|
+
for (const p of backlog) L.push(`- ${p.name}: ${p.brain.conflicts} conflicts${p.processing.lastProcessedAt ? ` (last consolidation ${String(p.processing.lastProcessedAt).slice(0, 10)})` : ""}`);
|
|
400
|
+
}
|
|
401
|
+
L.push("");
|
|
402
|
+
|
|
403
|
+
// remediation
|
|
404
|
+
L.push(`## Remediation`);
|
|
405
|
+
if (!DO_FIX) {
|
|
406
|
+
L.push(backlog.length
|
|
407
|
+
? `Report-only run. Re-run with \`--fix\` to recurate the conflict backlog: ${backlog.map((p) => p.name).join(", ")}.`
|
|
408
|
+
: "Report-only run. No project crossed the remediation threshold.");
|
|
409
|
+
} else if (d.remediation?.skipped) {
|
|
410
|
+
L.push(`⚠️ \`--fix\` requested but ${d.remediation.skipped} — remediation skipped.`);
|
|
411
|
+
} else if (d.remediation?.results?.length) {
|
|
412
|
+
for (const r of d.remediation.results) L.push(`- ${r.ok ? `✅ ${basename(r.projectPath)} — archived ${r.archived}/${r.considered}` : `⚠️ ${basename(r.projectPath)} — ${r.error}`}`);
|
|
413
|
+
} else {
|
|
414
|
+
L.push("`--fix` enabled but no project crossed the remediation threshold.");
|
|
415
|
+
}
|
|
416
|
+
L.push("");
|
|
417
|
+
|
|
418
|
+
if (d.diagnosis) { L.push(`## 🩺 Diagnosis & recommended fixes`, "", d.diagnosis, ""); }
|
|
419
|
+
L.push(`---\n_STL cycle generated ${new Date(NOW).toISOString()} · window ${new Date(CUTOFF).toISOString()} → now · source: daemon.jsonl + per-project .peon brains._`);
|
|
420
|
+
return L.join("\n");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function writeReport(md, jsonPayload) {
|
|
424
|
+
mkdirSync(REPORT_DIR, { recursive: true });
|
|
425
|
+
const day = new Date(NOW).toISOString().slice(0, 10);
|
|
426
|
+
writeFileSync(join(REPORT_DIR, `${day}.md`), md);
|
|
427
|
+
writeFileSync(join(REPORT_DIR, "latest.md"), md);
|
|
428
|
+
writeFileSync(join(REPORT_DIR, "latest.json"), JSON.stringify(jsonPayload, null, 2));
|
|
429
|
+
return join(REPORT_DIR, `${day}.md`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ---------- main ----------
|
|
433
|
+
async function main() {
|
|
434
|
+
const daemon = analyzeDaemonLog();
|
|
435
|
+
const hooks = analyzeHookErrors();
|
|
436
|
+
const restarts = analyzeRestarts();
|
|
437
|
+
const projects = [];
|
|
438
|
+
for (const p of discoverProjects(LOG)) {
|
|
439
|
+
try { projects.push(analyzeProject(p)); }
|
|
440
|
+
catch (e) { console.error(`[peon-stl] skipped project ${p}: ${e?.message || e}`); }
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// de-duplicated failure counts (a hook 5xx is the client echo of a server 5xx — count once)
|
|
444
|
+
const distinct = daemon.http5xx.length + hooks.standalone; // server 5xx + connection-only failures
|
|
445
|
+
const serious = Math.max(0, distinct - hooks.benign); // exclude benign stale session-ends
|
|
446
|
+
const errors = { distinct, serious, injection: daemon.http5xxByPath.inj.length, recording: daemon.http5xxByPath.rec.length };
|
|
447
|
+
|
|
448
|
+
// headline — 🔴 only for genuinely broken; 🟡 for transient/backlog
|
|
449
|
+
const failing = projects.filter((p) => ["FAILING", "churning"].includes(p.processing.verdict));
|
|
450
|
+
const transient = projects.filter((p) => p.processing.verdict === "healthy-with-failures");
|
|
451
|
+
const backlog = projects.filter((p) => p.brain.conflictBacklog);
|
|
452
|
+
let headline = "🟢 Healthy — recording, injecting, and consolidating normally.";
|
|
453
|
+
if (errors.injection > 0) headline = `🔴 Attention — ${errors.injection} injection-path failure(s): Claude may be getting no/broken memory.`;
|
|
454
|
+
else if (failing.length) headline = `🔴 Attention — consolidation ${failing.map((p) => `${p.name} (${p.processing.verdict})`).join(", ")}.`;
|
|
455
|
+
else if (serious > 10) headline = `🟡 Degraded — ${serious} serious recording-path errors.`;
|
|
456
|
+
else if (transient.length || serious > 0 || backlog.length) {
|
|
457
|
+
const bits = [];
|
|
458
|
+
if (transient.length || daemon.procFail.length) bits.push(`${daemon.procFail.length} transient consolidation fail(s) (self-recovered)`);
|
|
459
|
+
if (backlog.length) bits.push(`conflict backlog in ${backlog.map((p) => p.name).join(", ")}`);
|
|
460
|
+
headline = `🟡 Watch — ${bits.join("; ") || `${serious} minor error(s)`}.`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// remediation (only with --fix, only when daemon is up)
|
|
464
|
+
let remediation = null;
|
|
465
|
+
if (DO_FIX) {
|
|
466
|
+
const targets = backlog.map((p) => p.path);
|
|
467
|
+
if (!targets.length) remediation = { results: [] };
|
|
468
|
+
else if (!(await daemonHealthy())) { remediation = { skipped: "daemon unreachable" }; headline += " (remediation skipped: daemon down)"; }
|
|
469
|
+
else { remediation = { results: [] }; for (const t of targets) remediation.results.push(await recurate(t)); }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const findings = {
|
|
473
|
+
window: { hours: WINDOW_HOURS, from: new Date(CUTOFF).toISOString(), to: new Date(NOW).toISOString() },
|
|
474
|
+
headline, errors,
|
|
475
|
+
recorded: projects.map((p) => ({ project: p.name, ...p.recorded })),
|
|
476
|
+
injected: { count: daemon.served.length, cross: daemon.crossServed.length, totalChars: daemon.sum(daemon.served, "chars"), byProject: daemon.injByProject },
|
|
477
|
+
wentWrong: { injectionPath5xx: errors.injection, recordingPath5xx: errors.recording, distinctFailures: distinct, seriousFailures: serious, benignStale: hooks.benign, hookErrors: hooks.by, daemonBoots: restarts.boots, consolidationFails: daemon.procFail.length },
|
|
478
|
+
processing: projects.map((p) => ({ project: p.name, ...p.processing })),
|
|
479
|
+
brain: projects.map((p) => ({ project: p.name, ...p.brain }))
|
|
480
|
+
};
|
|
481
|
+
const diagnosis = await diagnose(findings);
|
|
482
|
+
|
|
483
|
+
const md = buildReport({ daemon, hooks, restarts, projects, headline, errors, remediation, diagnosis });
|
|
484
|
+
const reportPath = writeReport(md, { ...findings, diagnosis, generatedAt: new Date(NOW).toISOString() });
|
|
485
|
+
appendFileSync(join(REPORT_DIR, "history.jsonl"), JSON.stringify({
|
|
486
|
+
at: new Date(NOW).toISOString(), headline, projects: projects.length,
|
|
487
|
+
recordedMsgs: findings.recorded.reduce((a, r) => a + r.messages, 0),
|
|
488
|
+
injections: daemon.served.length, consolidations: daemon.procFinish.length,
|
|
489
|
+
consolidationFails: daemon.procFail.length, distinctFailures: distinct, seriousFailures: serious,
|
|
490
|
+
injectionPath5xx: errors.injection, fixed: remediation?.results?.filter((r) => r.ok).length || 0
|
|
491
|
+
}) + "\n");
|
|
492
|
+
|
|
493
|
+
console.log(`[peon-stl] ${headline}`);
|
|
494
|
+
console.log(`[peon-stl] report → ${reportPath}`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ALWAYS leave a report behind — for an unattended monitor, a silent total failure is the worst outcome.
|
|
498
|
+
main().catch((e) => {
|
|
499
|
+
console.error("[peon-stl] analysis failed:", e?.stack || e);
|
|
500
|
+
try {
|
|
501
|
+
const md = `# 🧠 Peon STL Cycle — ${new Date(NOW).toISOString().slice(0, 16)}\n\n**Verdict:** 🔴 STL run errored before completing.\n\n\`\`\`\n${String(e?.stack || e).slice(0, 2000)}\n\`\`\`\n\n_The daily self-tracking loop hit an error; this stub is written so the failure is visible. Check daemon.jsonl + this script._\n`;
|
|
502
|
+
writeReport(md, { error: String(e?.message || e), generatedAt: new Date(NOW).toISOString() });
|
|
503
|
+
appendFileSync(join(REPORT_DIR, "history.jsonl"), JSON.stringify({ at: new Date(NOW).toISOString(), headline: "🔴 STL run errored", error: String(e?.message || e) }) + "\n");
|
|
504
|
+
} catch { /* last-resort: nothing else we can do */ }
|
|
505
|
+
process.exit(1);
|
|
506
|
+
});
|