peon-mem 1.0.0 → 1.0.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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # 🧠 Peon — a memory brain for your AI coding agents
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/peon-mem)](https://www.npmjs.com/package/peon-mem) [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![tests](https://img.shields.io/badge/tests-255%20passing-brightgreen)](test/)
4
+
3
5
  **Local-first, hierarchical, self-improving memory for Claude Code, Codex, and any MCP client.**
4
6
 
5
7
  Your AI forgets everything between sessions. Peon doesn't. It records your sessions, consolidates
@@ -40,6 +42,9 @@ automatically, from a daemon that never leaves your machine.
40
42
  ![The Neural Universe — every belief is a star, projects are galaxies](docs/assets/neural-universe.png)
41
43
  *The live monitor: 18k real beliefs rendered as stars. Type to make matching beliefs flare; click one to inspect it.*
42
44
 
45
+ ![Search flare — type a query and matching beliefs light up across every galaxy](docs/assets/search-flare.gif)
46
+ *Ask the field: typing "wulver cluster" makes 400+ matching beliefs flare while the rest dim, and the camera flies to them.*
47
+
43
48
  ## Why "Peon"?
44
49
 
45
50
  The name comes from Indian offices. Every office had a **peon** — the person who walked desk to
@@ -78,10 +83,10 @@ recommended (consolidation + semantic embeddings); without one Peon still works
78
83
  One line:
79
84
 
80
85
  ```bash
81
- curl -fsSL https://raw.githubusercontent.com/VineetV2/peon-mem/main/install.sh | bash
86
+ npm install -g peon-mem && peon-mem install
82
87
  ```
83
88
 
84
- (or, once you have the CLI: `npm install -g peon-mem && peon-mem install`)
89
+ (no Node? `curl -fsSL https://raw.githubusercontent.com/VineetV2/peon-mem/main/install.sh | bash`)
85
90
 
86
91
  The guided setup asks four things:
87
92
 
@@ -276,6 +281,69 @@ launchctl unload ~/Library/LaunchAgents/com.peon.daemon.plist
276
281
  swallow a project's memory.
277
282
  - The global brain lives in `~/Library/Application Support/Peon/global/` (macOS).
278
283
 
284
+ ## Using Peon with NO AI at all
285
+
286
+ Some people want a memory system that never calls a model — no API keys, no local LLM, no
287
+ embeddings, fully deterministic. Peon supports that as a first-class mode: pick **skip** in the
288
+ install wizard, or set two env vars in `<memory-home>/.env`:
289
+
290
+ ```
291
+ PEON_AI_MODE=off
292
+ PEON_EMBEDDING_MODE=off
293
+ ```
294
+
295
+ **What still works (all of it deterministic code, no model anywhere):**
296
+
297
+ - **Capture** — hooks record every prompt, tool call, and session event to plain JSONL in
298
+ `<project>/.peon/raw/`.
299
+ - **Real-time brain files** — decisions, preferences, open questions, and artifacts are written
300
+ live to readable `.md` files by rule-based extraction as events arrive.
301
+ - **Injection** — session-start context comes from those real-time files, query-focused and
302
+ budgeted, same as always.
303
+ - **Search** — lexical retrieval (RRF over keyword rank + recency + importance + type priors).
304
+ No embeddings needed; this is the same degrade path the semantic stack falls back to.
305
+ - **Episodic recall** — verbatim what-was-said lookup is lexical by design, so it is unaffected.
306
+ - **Monitor UI, token tracking, cross-project search, backups** — all model-free.
307
+
308
+ **What you give up:** consolidation (raw events are never distilled into deduplicated beliefs —
309
+ memory grows as an append-only journal), semantic search (paraphrased queries need shared
310
+ keywords), automatic entity extraction, and stale-shadow demotion (it compares embeddings).
311
+
312
+ **Two escape hatches if you want curation without external AI:**
313
+
314
+ 1. `process_memory` accepts a pre-built `aiResult` — the coding agent you already run (Claude
315
+ Code, Codex) can do the distillation itself in-session and hand Peon the structured result.
316
+ Memory stays curated, and Peon itself never spends a token.
317
+ 2. Everything is plain JSONL/Markdown on disk — you can edit beliefs by hand or through the
318
+ monitor's memory endpoints. Peon backs up before every mutation.
319
+
320
+ ## Measured: does memory actually save tokens?
321
+
322
+ A/B test, real `claude -p` sessions, one question per session, same repo, same model. ON = Peon
323
+ hooks active (memory injected at session start), OFF = `PEON_DISABLED=1` (agent falls back to
324
+ reading files). 20 questions across procedures, past results, decisions, and current-state facts;
325
+ 15 clean ON/OFF pairs survived tooling issues. Token counts read from Claude Code's own
326
+ session transcripts.
327
+
328
+ | paired, n=15/arm | ON (Peon) | OFF | delta |
329
+ |---|---|---|---|
330
+ | avg tokens (in+out) | **511** | 878 | **−42%** |
331
+ | median tokens | **225** | 1,048 | **−79%** |
332
+ | cache-read tokens | 81.6k | 110.5k | 1.35× less |
333
+ | cheaper arm | **ON wins 12/15** | | |
334
+
335
+ Answer quality (graded against repo ground truth): 8 ties, 1 clear Peon win, 5 baseline wins,
336
+ 1 both-weak. The Peon win is the interesting one: a rule that was only ever stated in a
337
+ conversation (a professor's citation policy from an email) — the baseline answered *"no such
338
+ rule found"*, Peon recited it exactly. Conversation-borne knowledge has no file to grep.
339
+
340
+ Honest caveats: the test repo has unusually good docs (a maintained research log), which makes
341
+ the baseline strong — most repos aren't like that; n=15 is small; questions were picked to have
342
+ known answers, not sampled from real usage. Two weaknesses this test exposed — a stale
343
+ superseded belief outranking the newer truth, and token rows lost when consolidation outlived
344
+ the hook timeout — are both fixed (stale-shadow demotion at retrieval; usage logged before
345
+ consolidation).
346
+
279
347
  ## Honesty section
280
348
 
281
349
  Peon's development is eval-gated and keeps its negative results: an associative entity graph was
package/bin/peon-mem.mjs CHANGED
@@ -205,7 +205,12 @@ if (cmd === "install") {
205
205
  if (!up) log(" → Ollama not running. Install: https://ollama.com then: ollama pull llama3.2 && ollama pull nomic-embed-text");
206
206
  else log(" ✔ Ollama detected on :11434 — pull models if missing: ollama pull llama3.2 && ollama pull nomic-embed-text");
207
207
  } else {
208
- envLines.push("# no provider configured — Peon runs lexical-only. Re-run `peon-mem install` anytime.");
208
+ envLines.push(
209
+ "# No-AI mode: no provider, no model calls, no embeddings. Peon runs as a",
210
+ "# deterministic memory recorder with lexical retrieval. Re-run `peon-mem install` anytime.",
211
+ "PEON_AI_MODE=off",
212
+ "PEON_EMBEDDING_MODE=off"
213
+ );
209
214
  }
210
215
  if (existsSync(envFile)) log(" ✔ keeping existing " + envFile);
211
216
  else act("write " + envFile, () => writeFileSync(envFile, envLines.join("\n") + "\n"));
@@ -7,7 +7,7 @@ import { cosineSimilarity, createEmbeddingClient } from "./embeddings.js";
7
7
  import { applyDelete, applyMerge, applyPin, applyUpdate } from "./memory-mutations.js";
8
8
  import { runSleepCycle } from "./brain.js";
9
9
  import { readdir, rm } from "node:fs/promises";
10
- import { rankMemoryRecords as rankWithRetrieval, computeGraphActivation, diversifyByMMR } from "./retrieval.js";
10
+ import { rankMemoryRecords as rankWithRetrieval, computeGraphActivation, demoteStaleShadows, diversifyByMMR } from "./retrieval.js";
11
11
  import { currentAsOf, changesBetween } from "./temporal.js";
12
12
  import { inferCanonicalEntities, buildEntityRegistry, canonicalizeEntity } from "./entities.js";
13
13
  import { redactSecrets } from "./injection.js";
@@ -464,14 +464,14 @@ export class PeonMemoryStore {
464
464
  const limit = options.limit ?? 50;
465
465
  const direct = rankWithRetrieval(records, query, { limit, semantic });
466
466
  if (!options.expandGraph || direct.length === 0)
467
- return direct;
467
+ return demoteStaleShadows(direct, semantic?.vectorById);
468
468
  // FUSED associative recall: spread activation from the direct hits through the entity graph,
469
469
  // then RE-RANK with that activation as a (damped) signal — so a strongly-associated belief can
470
470
  // enter the top-K and displace a weak direct hit, instead of being appended out of the window.
471
471
  const graphActivation = computeGraphActivation(direct, records);
472
472
  if (graphActivation.size === 0)
473
- return direct;
474
- return rankWithRetrieval(records, query, { limit, semantic, graphActivation });
473
+ return demoteStaleShadows(direct, semantic?.vectorById);
474
+ return demoteStaleShadows(rankWithRetrieval(records, query, { limit, semantic, graphActivation }), semantic?.vectorById);
475
475
  }
476
476
  /**
477
477
  * Rank records WITHOUT mutating anything — uses only embeddings already on disk
@@ -497,7 +497,7 @@ export class PeonMemoryStore {
497
497
  // lexical-only on any embedding failure
498
498
  }
499
499
  }
500
- return rankWithRetrieval(records, query, { limit: options.limit ?? 50, semantic });
500
+ return demoteStaleShadows(rankWithRetrieval(records, query, { limit: options.limit ?? 50, semantic }), semantic?.vectorById);
501
501
  }
502
502
  async buildSemanticInput(query, records) {
503
503
  if (!query || !query.trim() || !this.embeddingClient || !this.embeddingStore || records.length === 0) {
package/dist/monitor.js CHANGED
@@ -92,6 +92,10 @@ const CLIENT_SCRIPT = String.raw `
92
92
  function renderRoute(force){
93
93
  var r=currentRoute();
94
94
  ROUTES.forEach(function(x){ var p=EL("page-"+x); if(p) p.hidden=(x!==r); });
95
+ // The project dropdown is CONTEXT, not chrome: it only applies inside a project's
96
+ // Insights/Memory pages. Global pages (Neural Core / Sectors / Systems) hide it —
97
+ // switching projects there happens by picking a galaxy or a sector card.
98
+ var sw=EL("switcher"); if(sw) sw.hidden=!(r==="overview"||r==="memory");
95
99
  // Nav highlight: overview/memory belong under "projects".
96
100
  ["brain","projects","ops"].forEach(function(x){ var t=EL("nav-"+x); if(t) t.classList.toggle("on", x===r || ((r==="overview"||r==="memory")&&x==="projects")); });
97
101
  if(r==="brain"){ loadDashboard(force); renderBrainHome(); }
@@ -184,6 +188,20 @@ const CLIENT_SCRIPT = String.raw `
184
188
  ' <span class="stl-dot" style="background:'+dotc+'" title="'+esc(stl)+'"></span>';
185
189
  }
186
190
 
191
+ var SPRITES={};
192
+ function nodeSprite(color, glow){
193
+ var key=color+(glow?"G":"");
194
+ if(SPRITES[key]) return SPRITES[key];
195
+ var pad=glow?10:2, R=6, size=(R+pad)*2;
196
+ var sc=document.createElement("canvas"); sc.width=size; sc.height=size;
197
+ var g=sc.getContext("2d");
198
+ if(glow){ g.shadowColor=color; g.shadowBlur=9; }
199
+ g.fillStyle=color; g.beginPath(); g.arc(size/2,size/2,R,0,6.29); g.fill();
200
+ if(glow){ g.fill(); }
201
+ SPRITES[key]={c:sc,R:R,half:size/2};
202
+ return SPRITES[key];
203
+ }
204
+
187
205
  function uniProject(n){ /* world position with slow galaxy rotation */
188
206
  var t=(Date.now()-UNI.t0);
189
207
  var th=n.th + t*n.cl.spin;
@@ -237,18 +255,22 @@ const CLIENT_SCRIPT = String.raw `
237
255
  });
238
256
  // nodes
239
257
  var hits=UNI.hits, dimOthers=!!(hits&&hits.size);
258
+ // viewport bounds in world coords (with margin) for culling
259
+ var vw=W/2/UNI.cam.z+30, vh=H/2/UNI.cam.z+30, vcx=UNI.cam.x, vcy=UNI.cam.y;
240
260
  UNI.nodes.forEach(function(n){
241
261
  uniProject(n);
262
+ if(n.x<vcx-vw||n.x>vcx+vw||n.y<vcy-vh||n.y>vcy+vh) return; // offscreen — skip draw
242
263
  var tw=0.78+0.22*Math.sin(t/900+n.tw);
243
264
  var a=n.a*tw, r=n.r;
244
265
  if(dimOthers){ if(hits.has(n.id)){ a=1; r=n.r*1.7; } else a*=0.08; }
245
266
  if(n.hot) r*=1.15;
246
- ctx.beginPath(); ctx.arc(n.x,n.y,r/Math.sqrt(UNI.cam.z),0,6.29);
267
+ var glow=(dimOthers&&hits.has(n.id))||n===UNI.hover||n===UNI.selected||n.rec.status==="conflicted";
268
+ var sp=nodeSprite(n.c, glow);
269
+ var scale=(r/Math.sqrt(UNI.cam.z))/sp.R;
247
270
  ctx.globalAlpha=Math.min(1,a);
248
- ctx.fillStyle=n.c;
249
- if((dimOthers&&hits.has(n.id))||n===UNI.hover||n===UNI.selected||n.rec.status==="conflicted"){ ctx.shadowColor=n.c; ctx.shadowBlur=12; } else ctx.shadowBlur=0;
250
- ctx.fill(); ctx.shadowBlur=0; ctx.globalAlpha=1;
271
+ ctx.drawImage(sp.c, n.x-sp.half*scale, n.y-sp.half*scale, sp.c.width*scale, sp.c.height*scale);
251
272
  });
273
+ ctx.globalAlpha=1;
252
274
  // hover crosshair
253
275
  if(UNI.hover){ var hN=UNI.hover; var hr=10/UNI.cam.z;
254
276
  ctx.strokeStyle="rgba(234,255,255,.85)"; ctx.lineWidth=1/UNI.cam.z;
@@ -257,14 +279,15 @@ const CLIENT_SCRIPT = String.raw `
257
279
  ctx.moveTo(hN.x,hN.y-hr*1.8); ctx.lineTo(hN.x,hN.y-hr); ctx.moveTo(hN.x,hN.y+hr); ctx.lineTo(hN.x,hN.y+hr*1.8); ctx.stroke();
258
280
  }
259
281
  ctx.restore();
260
- uniGridBuild();
282
+ if(!UNI.lastGrid||t-UNI.lastGrid>250){ uniGridBuild(); UNI.lastGrid=t; }
261
283
  // tooltip
262
284
  var tip=EL("uni-tip");
263
285
  if(tip){ if(UNI.hover){ var sp=uniToScreen(UNI.hover,W,H);
264
286
  tip.hidden=false; tip.style.left=Math.min(W-330,Math.max(8,sp.x+16))+"px"; tip.style.top=Math.max(8,sp.y-14)+"px";
265
287
  tip.innerHTML='<span class="tt-type" style="color:'+UNI.hover.c+'">'+esc(UNI.hover.rec.type)+'</span> '+esc(clip(UNI.hover.rec.content,140));
266
288
  } else tip.hidden=true; }
267
- UNI.raf=requestAnimationFrame(uniDraw);
289
+ // ~30fps is indistinguishable here and halves the draw cost on big brains
290
+ UNI.raf=requestAnimationFrame(function(){ setTimeout(function(){ UNI.raf=0; uniDraw(); }, 15); });
268
291
  }
269
292
  function uniToScreen(n,W,H){ return { x:(n.x-UNI.cam.x)*UNI.cam.z+W/2, y:(n.y-UNI.cam.y)*UNI.cam.z+H/2 }; }
270
293
  function uniToWorld(sx,sy){ var c=uniCanvas(); var W=c.clientWidth,H=c.clientHeight;
@@ -822,7 +845,7 @@ const DOCUMENT = String.raw `<!doctype html>
822
845
  .uniwrap::before{content:""; position:absolute; top:0; left:0; width:16px; height:16px; border-top:1.5px solid var(--line2); border-left:1.5px solid var(--line2); z-index:5; pointer-events:none;}
823
846
  .uniwrap::after{content:""; position:absolute; bottom:0; right:0; width:16px; height:16px; border-bottom:1.5px solid var(--line2); border-right:1.5px solid var(--line2); z-index:5; pointer-events:none;}
824
847
  #uni{position:absolute; inset:0; width:100%; height:100%; cursor:crosshair;}
825
- .uni-search{position:absolute; top:14px; left:50%; transform:translateX(-50%); z-index:6; display:flex; gap:8px; align-items:center; width:min(560px,80%);}
848
+ .uni-search{position:absolute; top:68px; left:50%; transform:translateX(-50%); z-index:6; display:flex; gap:8px; align-items:center; width:min(560px,72%);}
826
849
  .uni-search input{flex:1; padding:9px 16px; border:1px solid rgba(89,227,255,.5); clip-path:var(--cham); background:rgba(3,12,22,.88);
827
850
  font-family:var(--mono); font-size:12px; color:var(--cyan-ink); backdrop-filter:blur(8px);}
828
851
  .uni-search input:focus{border-color:var(--cyan); outline:none; box-shadow:0 0 22px -6px rgba(89,227,255,.9);}
@@ -831,7 +854,7 @@ const DOCUMENT = String.raw `<!doctype html>
831
854
  .uni-hud{position:absolute; z-index:6; font-family:var(--mono); font-size:10px; letter-spacing:.12em; color:var(--muted);
832
855
  background:rgba(3,12,22,.72); border:1px solid var(--line); padding:7px 12px; clip-path:var(--cham); backdrop-filter:blur(6px);}
833
856
  .uni-hud b{color:var(--cyan-ink); font-weight:700;} .uni-hud b.warn{color:var(--amber);}
834
- .uni-hud.tl{top:14px; left:14px;} .uni-hud.tr{top:60px; right:14px;}
857
+ .uni-hud.tl{top:14px; left:14px; max-width:58%;} .uni-hud.tr{top:14px; right:14px;}
835
858
  .stl-dot{display:inline-block; width:8px; height:8px; border-radius:50%; margin-left:6px; vertical-align:-1px; box-shadow:0 0 8px currentColor;}
836
859
  .uni-legend{position:absolute; left:14px; bottom:76px; z-index:6; display:flex; flex-wrap:wrap; gap:9px; max-width:70%;}
837
860
  .ul{display:inline-flex; align-items:center; gap:5px; font-family:var(--mono); font-size:9px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted);}
@@ -104,3 +104,19 @@ export declare function computeGraphActivation(seeds: RankedMemoryRecord[], pool
104
104
  * rankMemoryRecords' graphActivation option, which lets associations compete inside the top-K.
105
105
  */
106
106
  export declare function expandByEntityGraph(seeds: RankedMemoryRecord[], pool: MemoryRecord[], options?: GraphExpandOptions): RankedMemoryRecord[];
107
+ /**
108
+ * Stale-shadow demotion. Measured failure mode (token A/B, question x1): recall answered with a
109
+ * SUPERSEDED architecture description because the old belief was still active, semantically strong,
110
+ * and outranked the newer truth. When two active beliefs in the ranked window describe the same fact
111
+ * — near-duplicate vectors, or moderately similar with a shared entity and the same type — but were
112
+ * written in different eras, the older one is treated as a stale shadow of the newer: its score is
113
+ * scaled down so the newer belief always outranks it. Nothing is deleted or re-statused here; real
114
+ * supersession stays the consolidator's job. Pinned records are never demoted.
115
+ */
116
+ export declare function demoteStaleShadows(ranked: RankedMemoryRecord[], vectorById: Map<string, EmbeddingVector> | undefined, options?: {
117
+ scan?: number;
118
+ hardSim?: number;
119
+ softSim?: number;
120
+ ageGapMs?: number;
121
+ penalty?: number;
122
+ }): RankedMemoryRecord[];
package/dist/retrieval.js CHANGED
@@ -390,3 +390,70 @@ function clamp(value) {
390
390
  function roundScore(value) {
391
391
  return Math.round(value * 1000) / 1000;
392
392
  }
393
+ /**
394
+ * Stale-shadow demotion. Measured failure mode (token A/B, question x1): recall answered with a
395
+ * SUPERSEDED architecture description because the old belief was still active, semantically strong,
396
+ * and outranked the newer truth. When two active beliefs in the ranked window describe the same fact
397
+ * — near-duplicate vectors, or moderately similar with a shared entity and the same type — but were
398
+ * written in different eras, the older one is treated as a stale shadow of the newer: its score is
399
+ * scaled down so the newer belief always outranks it. Nothing is deleted or re-statused here; real
400
+ * supersession stays the consolidator's job. Pinned records are never demoted.
401
+ */
402
+ export function demoteStaleShadows(ranked, vectorById, options = {}) {
403
+ if (!vectorById || vectorById.size === 0 || ranked.length < 2)
404
+ return ranked;
405
+ const scan = options.scan ?? 30;
406
+ const hardSim = options.hardSim ?? 0.8;
407
+ const softSim = options.softSim ?? 0.6;
408
+ const ageGapMs = options.ageGapMs ?? 3 * 24 * 60 * 60 * 1000;
409
+ const penalty = options.penalty ?? 0.35;
410
+ const window = ranked.slice(0, scan);
411
+ const recordTime = (item) => timestamp(item.record.updatedAt || item.record.createdAt);
412
+ const shadowOf = new Map(); // demoted id -> newer id it shadows
413
+ for (let i = 0; i < window.length; i += 1) {
414
+ for (let j = i + 1; j < window.length; j += 1) {
415
+ const a = window[i];
416
+ const b = window[j];
417
+ if (a.record.type !== b.record.type)
418
+ continue;
419
+ if (a.record.status !== "active" || b.record.status !== "active")
420
+ continue;
421
+ const va = vectorById.get(a.record.id);
422
+ const vb = vectorById.get(b.record.id);
423
+ if (!va || !vb)
424
+ continue;
425
+ const sim = cosineSimilarity(va, vb);
426
+ if (sim < softSim)
427
+ continue;
428
+ const sharedEntity = a.record.entities.some((e) => b.record.entities.includes(e));
429
+ if (sim < hardSim && !sharedEntity)
430
+ continue;
431
+ const [older, newer] = recordTime(a) <= recordTime(b) ? [a, b] : [b, a];
432
+ if (recordTime(newer) - recordTime(older) < ageGapMs)
433
+ continue;
434
+ if (older.record.pinned)
435
+ continue;
436
+ if (!shadowOf.has(older.record.id))
437
+ shadowOf.set(older.record.id, newer.record.id);
438
+ }
439
+ }
440
+ if (shadowOf.size === 0)
441
+ return ranked;
442
+ return ranked
443
+ .map((item) => {
444
+ const newerId = shadowOf.get(item.record.id);
445
+ if (!newerId)
446
+ return item;
447
+ return {
448
+ ...item,
449
+ score: roundScore(item.score * penalty),
450
+ explanation: `${item.explanation}; stale shadow of newer belief ${newerId}`,
451
+ reasons: [...item.reasons, { kind: "status", label: `stale shadow of ${newerId}`, score: 0 }]
452
+ };
453
+ })
454
+ .sort((left, right) => {
455
+ if (right.score !== left.score)
456
+ return right.score - left.score;
457
+ return timestamp(right.record.updatedAt) - timestamp(left.record.updatedAt);
458
+ });
459
+ }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peon-mem",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -1,14 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { appendFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { createReadStream as fsCreateReadStream, existsSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { createInterface } from "node:readline";
7
7
 
8
- // Hard off-switch (for A/B testing). When PEON_DISABLED is set, the hook does
9
- // nothing at all — no recording, no context injection, no daemon calls — so the
10
- // session runs purely on the cloud model with zero Peon involvement.
11
- if (/^(1|true|yes|on)$/i.test(process.env.PEON_DISABLED || "")) process.exit(0);
8
+ // Hard off-switch (for A/B testing). When PEON_DISABLED is set, Peon does nothing —
9
+ // no injection, no recording, no daemon calls — EXCEPT counting the session's token
10
+ // usage on Stop/SessionEnd, because the OFF arm's whole purpose is to be the baseline
11
+ // in the token A/B comparison. (Handled inside the main block below.)
12
+ const PEON_OFF = /^(1|true|yes|on)$/i.test(process.env.PEON_DISABLED || "");
12
13
 
13
14
  const daemonUrl = (process.env.PEON_DAEMON_URL || "http://127.0.0.1:3737").replace(/\/$/, "");
14
15
  const stateDir =
@@ -39,6 +40,12 @@ const PEON_FIRST_DIRECTIVE =
39
40
  // so it is initialized before the top-level await block calls getProjectContext.
40
41
  const MAX_CONTEXT_QUERY_CHARS = 2000;
41
42
 
43
+ // Token A/B ledger paths — declared BEFORE the top-level await block (everything the main
44
+ // block touches must be initialized first, or the reference throws a silent TDZ error;
45
+ // that exact bug made token tracking a no-op for weeks).
46
+ const TOKEN_AB_LOG = join(homedir(), "Library", "Application Support", "Peon", "token-ab-log.jsonl");
47
+ const TOKEN_AB_LOGGED_SESSIONS = join(homedir(), "Library", "Application Support", "Peon", "token-ab-sessions.json");
48
+
42
49
  // Resolve any working directory to its ONE project brain, so memory never fragments:
43
50
  // 1. collapse git-worktree paths to the repo root (…/.claude/worktrees/x → repo)
44
51
  // 2. walk UP to the TOPMOST ancestor that already holds a Peon brain (.peon), bounded by
@@ -88,6 +95,11 @@ try {
88
95
  readText(input, ["session_id", "sessionId", "conversation_id", "conversationId", "thread_id", "threadId"]) ||
89
96
  externalSessionId;
90
97
 
98
+ if (PEON_OFF) {
99
+ if (eventName === "Stop" || eventName === "SessionEnd") await trackTokenUsage(input, projectPath, externalSessionId);
100
+ process.exit(0);
101
+ }
102
+
91
103
  if (eventName === "SessionStart") {
92
104
  await ensurePeonSession({ projectPath, externalSessionId, client: hookClient });
93
105
  const context = await getProjectContext(projectPath, "recent project context decisions artifacts current work");
@@ -159,14 +171,17 @@ try {
159
171
  // A response TURN finished — capture the summary and let Peon consolidate,
160
172
  // but keep the session ALIVE. Stop fires after every turn; the Claude session
161
173
  // (and the Peon session) continues across turns and only ends on SessionEnd.
174
+ // Token tracking runs FIRST: consolidation below can outlive the hook timeout
175
+ // ("Hook cancelled"), which used to silently drop the token ledger row.
176
+ await trackTokenUsage(input, projectPath, externalSessionId);
162
177
  const finalMessage = extractAssistantSummary(input);
163
178
  if (finalMessage.trim()) {
164
179
  await recordWithSession({ projectPath, externalSessionId, client: hookClient }, (sessionId) =>
165
180
  postJson("/events", { sessionId, type: "assistant_summary", content: finalMessage.slice(0, 2000) }));
166
181
  }
167
182
  await postJson("/process/auto", { projectPath, trigger: "turn_end" }).catch(() => undefined);
168
- await trackTokenUsage(input, projectPath, externalSessionId);
169
183
  } else if (eventName === "SessionEnd") {
184
+ await trackTokenUsage(input, projectPath, externalSessionId);
170
185
  const finalMessage = extractAssistantSummary(input);
171
186
  if (finalMessage.trim()) {
172
187
  await recordWithSession({ projectPath, externalSessionId, client: hookClient }, (sessionId) =>
@@ -180,7 +195,6 @@ try {
180
195
  // stale session id gets replayed forever and recording silently dies.
181
196
  await removeSession(externalSessionId);
182
197
  }
183
- await trackTokenUsage(input, projectPath, externalSessionId);
184
198
  }
185
199
  } catch (error) {
186
200
  await appendLocalError({
@@ -400,16 +414,11 @@ function safeName(value) {
400
414
  return String(value).replace(/[^a-zA-Z0-9_.-]/g, "_");
401
415
  }
402
416
 
403
- const TOKEN_AB_LOG = join(homedir(), "Library", "Application Support", "Peon", "token-ab-log.jsonl");
404
-
405
- const TOKEN_AB_LOGGED_SESSIONS = join(homedir(), "Library", "Application Support", "Peon", "token-ab-sessions.json");
406
-
407
417
  async function recordTokenUsage({ projectPath, externalSessionId, transcriptPath }) {
408
418
  try {
409
- // Only log once per session Stop fires on every response turn, not just session end.
410
- const logged = JSON.parse(await readFile(TOKEN_AB_LOGGED_SESSIONS, "utf8").catch(() => "[]"));
411
- if (logged.includes(externalSessionId)) return;
412
-
419
+ // Upsert per session: Stop fires after every turn, so each firing re-reads the full
420
+ // transcript and replaces this session's row — the last Stop leaves the final totals.
421
+ // (The old once-per-session gate froze turn-1 partials and dropped the rest.)
413
422
  const totals = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0, model: "unknown" };
414
423
  const rl = createInterface({ input: fsCreateReadStream(transcriptPath), crlfDelay: Infinity });
415
424
  for await (const line of rl) {
@@ -425,6 +434,9 @@ async function recordTokenUsage({ projectPath, externalSessionId, transcriptPath
425
434
  if (obj?.message?.model) totals.model = obj.message.model;
426
435
  } catch { /* skip malformed lines */ }
427
436
  }
437
+ // A Stop can fire before any usage lines hit the transcript — never write an empty row.
438
+ if (totals.input + totals.output === 0) return;
439
+
428
440
  const peonEnabled = !/^(1|true|yes|on)$/i.test(process.env.PEON_DISABLED || "");
429
441
  const record = {
430
442
  ts: new Date().toISOString(),
@@ -438,13 +450,20 @@ async function recordTokenUsage({ projectPath, externalSessionId, transcriptPath
438
450
  cacheCreateTokens: totals.cacheCreate,
439
451
  totalTokens: totals.input + totals.output,
440
452
  };
453
+ if (process.env.PEON_AB_TAG) record.tag = process.env.PEON_AB_TAG;
441
454
  const dir = join(homedir(), "Library", "Application Support", "Peon");
442
455
  await mkdir(dir, { recursive: true });
443
- await appendFile(TOKEN_AB_LOG, JSON.stringify(record) + "\n", "utf8");
444
- // Mark session as logged (keep last 500 to avoid unbounded growth)
445
- logged.push(externalSessionId);
446
- await writeFile(TOKEN_AB_LOGGED_SESSIONS, JSON.stringify(logged.slice(-500)), "utf8");
447
- } catch { /* non-fatal */ }
456
+ // Rewrite the log with this session's previous rows dropped — file stays one row per session.
457
+ const existing = (await readFile(TOKEN_AB_LOG, "utf8").catch(() => ""))
458
+ .split("\n").filter(Boolean)
459
+ .filter((line) => {
460
+ try { return JSON.parse(line).sessionId !== externalSessionId; } catch { return true; }
461
+ });
462
+ existing.push(JSON.stringify(record));
463
+ const tmp = TOKEN_AB_LOG + ".tmp";
464
+ await writeFile(tmp, existing.slice(-2000).join("\n") + "\n", "utf8");
465
+ await rename(tmp, TOKEN_AB_LOG);
466
+ } catch (e) { if (process.env.PEON_AB_DEBUG) console.error("AB-ERR:", e && e.message); }
448
467
  }
449
468
 
450
469
  async function appendLocalError(error) {