opencode-context-indicator 1.6.0 → 1.7.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/lib/state.js CHANGED
@@ -23,6 +23,8 @@ import { join } from "node:path";
23
23
  import { claimOnce } from "./dedup.js";
24
24
  import {
25
25
  finitePos,
26
+ isHookMeasuredEntry,
27
+ isHookSource,
26
28
  isSafeSessionKey,
27
29
  oneLine,
28
30
  safeCount,
@@ -99,6 +101,10 @@ const DEDUP_DIR = join(tmpdir(), "context-indicator.dedup");
99
101
  // ~120 ms — a reasonable trade-off between surviving a transient holder and not
100
102
  // freezing a plugin tick.
101
103
  const STATE_LOCK_FILE = `${STATE_FILE}.lock`;
104
+ // The log file has the same read-merge-write shape (see writeLog) and needs its
105
+ // own lock: sharing the state lock would make every snapshot write contend with
106
+ // the state writers for no reason.
107
+ const LOG_LOCK_FILE = `${LOG_FILE}.lock`;
102
108
  const LOCK_MAX_ATTEMPTS = 6;
103
109
  const LOCK_RETRY_MS = 20;
104
110
  const LOCK_STALE_MS = 5000; // a lock older than this is presumed abandoned
@@ -128,8 +134,15 @@ function logErr(msg, err) {
128
134
  }
129
135
  }
130
136
 
137
+ const FINAL_SUMMARY_MARKER = "\n## final summaries\n";
138
+ const LIVE_MARKER = "\n## live snapshot\n";
139
+ // Line cap for the persisted summary section. `MAX_FINAL_SUMMARIES` bounds the
140
+ // in-memory BLOCKS; the merged on-disk section is capped by lines (blocks are
141
+ // dropped oldest-first) so a burst of notes cannot grow the file forever.
142
+ const MAX_FINAL_SUMMARY_LINES = 400;
143
+
131
144
  // Build the full file content: header + live snapshot + final summaries.
132
- function renderFile(liveLines) {
145
+ function renderFile(liveLines, summaryBlocks) {
133
146
  const lines = [];
134
147
  lines.push(
135
148
  "# context-breakdown.log",
@@ -142,17 +155,73 @@ function renderFile(liveLines) {
142
155
  "",
143
156
  "## final summaries",
144
157
  );
145
- if (finalSummaries.length === 0) {
158
+ if (!summaryBlocks || summaryBlocks.length === 0) {
146
159
  lines.push("(none yet)");
147
160
  } else {
148
- for (const s of finalSummaries) lines.push(s);
161
+ for (const s of summaryBlocks) lines.push(s);
149
162
  }
150
163
  return lines.join("\n") + "\n";
151
164
  }
152
165
 
166
+ // Every summary block starts with its own `[<ISO timestamp>]` header line (all
167
+ // callers of pushFinalSummary do). Splitting the persisted section on that
168
+ // marker recovers the BLOCK structure: de-duplicating line-by-line would delete
169
+ // the repeated table separator / header rows of a second /context-breakdown
170
+ // block and corrupt the persisted table, and it would drop the blank lines that
171
+ // separate the sections inside a block.
172
+ const SUMMARY_BLOCK_START = /^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
173
+
174
+ function splitSummaryBlocks(lines) {
175
+ const blocks = [];
176
+ let cur = null;
177
+ for (const line of lines) {
178
+ if (line === "(none yet)") continue;
179
+ if (cur === null || SUMMARY_BLOCK_START.test(line)) {
180
+ if (cur !== null) blocks.push(cur.join("\n").trimEnd());
181
+ cur = [line];
182
+ } else {
183
+ cur.push(line);
184
+ }
185
+ }
186
+ if (cur !== null) blocks.push(cur.join("\n").trimEnd());
187
+ return blocks.filter((b) => b.trim().length > 0);
188
+ }
189
+
190
+ // The log file is SHARED by every plugin instance while `finalSummaries` and
191
+ // `lastLiveLines` are per-process. Recover both sections from disk so a write
192
+ // from one instance neither drops a note another instance just appended nor
193
+ // blanks a fresher live snapshot with its own stale copy. Best-effort: any
194
+ // read/parse failure yields empty sections and the caller keeps its own data.
195
+ function readPersistedLog() {
196
+ try {
197
+ const text = readFileSync(LOG_FILE, "utf8");
198
+ const at = text.indexOf(FINAL_SUMMARY_MARKER);
199
+ const head = at < 0 ? text : text.slice(0, at);
200
+ const liveAt = head.indexOf(LIVE_MARKER);
201
+ const liveLines =
202
+ liveAt < 0
203
+ ? []
204
+ : head
205
+ .slice(liveAt + LIVE_MARKER.length)
206
+ .split("\n")
207
+ .filter((l) => l.length > 0);
208
+ const summaryBlocks =
209
+ at < 0
210
+ ? []
211
+ : splitSummaryBlocks(
212
+ text.slice(at + FINAL_SUMMARY_MARKER.length).split("\n"),
213
+ );
214
+ return { liveLines, summaryBlocks };
215
+ } catch {
216
+ return { liveLines: [], summaryBlocks: [] };
217
+ }
218
+ }
219
+
153
220
  // Atomically rewrite the whole log file (bounded by design). The temp+rename
154
- // makes concurrent writers from multiple plugin instances safe: a reader sees
155
- // either the previous or the new file, never a half-written one.
221
+ // makes a READER see either the previous or the new file, never a half-written
222
+ // one; the log lock serialises the read-merge-write across instances (without it
223
+ // two writers that read the same snapshot can each drop the other's note - the
224
+ // exact "compaction note vanished" defect this merge exists to fix).
156
225
  //
157
226
  // The last rendered live snapshot is remembered: callers that only add a final
158
227
  // summary / compaction note pass no live lines, and an empty snapshot section
@@ -161,33 +230,65 @@ let lastLiveLines = [];
161
230
 
162
231
  function writeLog(liveLines) {
163
232
  try {
164
- if (Array.isArray(liveLines) && liveLines.length > 0) {
165
- lastLiveLines = liveLines;
233
+ const lock = acquireLock(LOG_LOCK_FILE);
234
+ if (lock && !lock.ok) {
235
+ // Another instance is rewriting the file right now; its write already
236
+ // carries the merged section (and our in-memory blocks land on the next
237
+ // cycle). Skipping is cheaper than a torn merge.
238
+ logErr("writeLog skipped (log lock busy)");
239
+ return;
166
240
  }
167
- const content = renderFile(
168
- Array.isArray(liveLines) && liveLines.length > 0 ? liveLines : lastLiveLines,
169
- );
170
- // Random temp suffix + exclusive create ("wx"): a predictable name could be
171
- // pre-created as a symlink for us to follow (CWE-377). "wx" fails instead.
172
- const tmp = `${LOG_FILE}.${randomBytes(6).toString("hex")}.tmp`;
173
- // Unlike writeStateFile there is no ensureRegularFile(LOG_FILE) guard: the
174
- // destination is only ever replaced by rename(2), which swaps the directory
175
- // entry and never follows a symlink, so a pre-planted link cannot redirect a
176
- // write. The "wx" temp below already blocks the symlink-attack vector.
177
- // Create BEFORE the finally-guarded section (mirrors writeStateFile): on an
178
- // (astronomically unlikely) EEXIST collision with a peer's temp, THEIR file
179
- // must not be cleaned up by us — the write fails and is logged instead.
180
- writeFileSync(tmp, content, { encoding: "utf8", flag: "wx" });
181
241
  try {
182
- renameWithRetry(tmp, LOG_FILE);
183
- } finally {
184
- // Same hygiene as writeStateFile: a failed rename must not litter %TEMP%
185
- // with stale `<hex>.tmp` files.
242
+ const persisted = readPersistedLog();
243
+ const hasFreshLive = Array.isArray(liveLines) && liveLines.length > 0;
244
+ if (hasFreshLive) lastLiveLines = liveLines;
245
+ const live = hasFreshLive
246
+ ? liveLines
247
+ : persisted.liveLines.length > 0
248
+ ? persisted.liveLines
249
+ : lastLiveLines;
250
+ // Blocks, not lines: a repeated markdown table in a second command block
251
+ // must survive verbatim. Identity is the whole block (its timestamped
252
+ // header makes it unique).
253
+ const blocks = persisted.summaryBlocks.slice();
254
+ const seen = new Set(blocks);
255
+ for (const raw of finalSummaries) {
256
+ const block = String(raw).trimEnd();
257
+ if (!block || seen.has(block)) continue;
258
+ seen.add(block);
259
+ blocks.push(block);
260
+ }
261
+ // Line bound: drop the oldest blocks first (never the newest note).
262
+ let totalLines = blocks.reduce((n, b) => n + b.split("\n").length, 0);
263
+ while (blocks.length > 1 && totalLines > MAX_FINAL_SUMMARY_LINES) {
264
+ totalLines -= blocks[0].split("\n").length;
265
+ blocks.shift();
266
+ }
267
+ const content = renderFile(live, blocks);
268
+ // Random temp suffix + exclusive create ("wx"): a predictable name could be
269
+ // pre-created as a symlink for us to follow (CWE-377). "wx" fails instead.
270
+ const tmp = `${LOG_FILE}.${randomBytes(6).toString("hex")}.tmp`;
271
+ // Unlike writeStateFile there is no ensureRegularFile(LOG_FILE) guard: the
272
+ // destination is only ever replaced by rename(2), which swaps the directory
273
+ // entry and never follows a symlink, so a pre-planted link cannot redirect
274
+ // a write. The "wx" temp below already blocks the symlink-attack vector.
275
+ // Create BEFORE the finally-guarded section (mirrors writeStateFile): on an
276
+ // (astronomically unlikely) EEXIST collision with a peer's temp, THEIR file
277
+ // must not be cleaned up by us — the write fails and is logged instead.
278
+ writeFileSync(tmp, content, { encoding: "utf8", flag: "wx" });
186
279
  try {
187
- if (existsSync(tmp)) unlinkSync(tmp);
188
- } catch {
189
- /* ignore */
280
+ renameWithRetry(tmp, LOG_FILE);
281
+ } finally {
282
+ // Same hygiene as writeStateFile: a failed rename must not litter %TEMP%
283
+ // with stale `<hex>.tmp` files.
284
+ try {
285
+ if (existsSync(tmp)) unlinkSync(tmp);
286
+ } catch {
287
+ /* ignore */
288
+ }
190
289
  }
290
+ } finally {
291
+ if (lock && lock.ok) releaseLock(LOG_LOCK_FILE, lock.token);
191
292
  }
192
293
  } catch (err) {
193
294
  logErr("writeLog failed", err);
@@ -227,21 +328,24 @@ function ensureRegularFile(path) {
227
328
 
228
329
  // True when the lock file exists and looks abandoned (older than LOCK_STALE_MS).
229
330
  // Callers wrap it in try/catch: ENOENT (lock vanished) lands there and retries.
230
- function isStaleLock() {
231
- return Date.now() - statSync(STATE_LOCK_FILE).mtimeMs > LOCK_STALE_MS;
331
+ function isStaleLock(lockPath) {
332
+ return Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS;
232
333
  }
233
334
 
234
335
  // Returns `{ ok: true, token }` when the lock was taken (caller MUST release with
235
336
  // that token), `{ ok: false, token: null }` when it is busy (skip the write), or
236
337
  // `null` on an unexpected error — fail-open: the caller writes WITHOUT the lock
237
338
  // and must NOT release (nothing was acquired).
238
- function acquireStateLock() {
339
+ //
340
+ // Parameterised by path: the state file and the log file each have their own
341
+ // read-merge-write critical section and must not serialise against each other.
342
+ function acquireLock(lockPath) {
239
343
  const token = randomBytes(8).toString("hex"); // 16 hex chars, per acquisition
240
344
  for (let i = 0; i < LOCK_MAX_ATTEMPTS; i++) {
241
345
  try {
242
346
  // "wx" = exclusive create; fails EEXIST if held, and creates+writes the
243
347
  // token in ONE atomic step (no empty-file-then-write gap to race).
244
- writeFileSync(STATE_LOCK_FILE, token, { flag: "wx" });
348
+ writeFileSync(lockPath, token, { flag: "wx" });
245
349
  return { ok: true, token };
246
350
  } catch (err) {
247
351
  if (err && err.code === "EEXIST") {
@@ -254,12 +358,12 @@ function acquireStateLock() {
254
358
  // consecutive checks narrow the window to the stat->rename gap
255
359
  // (microseconds); the lock stays best-effort: the worst case is a
256
360
  // stale single-session entry, self-healed on the next write.
257
- if (isStaleLock() && isStaleLock()) {
361
+ if (isStaleLock(lockPath) && isStaleLock(lockPath)) {
258
362
  // Atomic reclaim: only ONE waiter can rename the stale file away;
259
363
  // the loser gets ENOENT and keeps waiting — neither can unlink a
260
364
  // lock a peer just re-created.
261
- const stale = `${STATE_LOCK_FILE}.stale.${randomBytes(4).toString("hex")}`;
262
- renameSync(STATE_LOCK_FILE, stale);
365
+ const stale = `${lockPath}.stale.${randomBytes(4).toString("hex")}`;
366
+ renameSync(lockPath, stale);
263
367
  unlinkSync(stale);
264
368
  continue; // retry immediately to claim the now-free lock
265
369
  }
@@ -270,7 +374,7 @@ function acquireStateLock() {
270
374
  } else {
271
375
  // Unexpected error (permissions, tmpdir issue): do not block forever —
272
376
  // fail-open so the state file still gets written.
273
- logErr("state lock acquire error (proceeding unlocked)", err);
377
+ logErr("lock acquire error (proceeding unlocked)", err);
274
378
  return null;
275
379
  }
276
380
  }
@@ -280,10 +384,10 @@ function acquireStateLock() {
280
384
 
281
385
  // Ownership-safe release: unlink ONLY while the lock still holds OUR token. A
282
386
  // peer that reclaimed an abandoned lock (or a later acquisition) is left alone.
283
- function releaseStateLock(token) {
387
+ function releaseLock(lockPath, token) {
284
388
  try {
285
- if (readFileSync(STATE_LOCK_FILE, "utf8") === token) {
286
- unlinkSync(STATE_LOCK_FILE);
389
+ if (readFileSync(lockPath, "utf8") === token) {
390
+ unlinkSync(lockPath);
287
391
  }
288
392
  } catch {
289
393
  /* already gone / never created -> nothing to release */
@@ -335,15 +439,15 @@ function writeStateFile(
335
439
  opts,
336
440
  ) {
337
441
  try {
338
- if (!sessionID) return;
442
+ if (!sessionID) return false;
339
443
  // A poisoned/odd sessionID ("__proto__", "constructor", …) must never be
340
444
  // used as an object key: plain assignment through it would mutate the
341
445
  // prototype instead of adding an entry (CWE-1321).
342
446
  if (!isSafeSessionKey(sessionID)) {
343
447
  logErr(`writeStateFile skipped unsafe session key ${oneLine(sessionID)}`);
344
- return;
448
+ return false;
345
449
  }
346
- const lock = acquireStateLock();
450
+ const lock = acquireLock(STATE_LOCK_FILE);
347
451
  // {ok:false} => busy: skip (value stays in memory, lands next cycle), as
348
452
  // before. null => unexpected error: fail-open, write WITHOUT the lock and do
349
453
  // NOT release (nothing was acquired — releasing could nuke a peer's lock).
@@ -351,7 +455,7 @@ function writeStateFile(
351
455
  logErr(
352
456
  `writeStateFile skipped (state lock busy) session=${oneLine(sessionID)}`,
353
457
  );
354
- return;
458
+ return false;
355
459
  }
356
460
  try {
357
461
  const bd = breakdownCache.get(sessionID) || null;
@@ -413,6 +517,26 @@ function writeStateFile(
413
517
  : null;
414
518
  const pc = prevEntry?.categories || null;
415
519
 
520
+ // Which breakdown is allowed to OWN the persisted categories?
521
+ //
522
+ // This file is shared by every plugin instance, and the instance that
523
+ // answers the Desktop RPC (the window's) never serves a model request, so
524
+ // its `breakdownCache` entry is always the client-context fallback rebuilt
525
+ // from the persisted message list. That fallback cannot see the assembled
526
+ // system prompt or the tool schemas, folds their whole mass into `other`,
527
+ // and after a compaction it can even read an empty conversation. Letting
528
+ // it overwrite the hook's measurement — the two writes landed 11 ms apart
529
+ // in the field — produced "system 0 / tool schemas 0 / other inflated" and
530
+ // could blank user/assistant. So: a hook-family source (see
531
+ // isHookSource) always wins; the fallback only fills an entry that no hook
532
+ // has ever measured.
533
+ //
534
+ // Legacy entries (written before `source` was persisted) carry no tag, but
535
+ // only a hook can produce a non-null system / tool-schema estimate.
536
+ const prevHookMeasured = isHookMeasuredEntry(prevEntry);
537
+ const bdFromHook = bd != null && isHookSource(bd.source);
538
+ const useBd = bd != null && (bdFromHook || !prevHookMeasured);
539
+
416
540
  // Resolve the model identity for THIS write. An unmeasured write from a
417
541
  // mirror/idle instance may not know the model (its caches were cleared);
418
542
  // an unknown model must not overwrite a known one (that would also drop
@@ -453,29 +577,29 @@ function writeStateFile(
453
577
 
454
578
  // Coerce every persisted category through the safe numeric helpers: the
455
579
  // snapshot is untrusted input and must never inject non-numbers.
456
- const catUser = bd ? safeCount(bd.userChars) : safeCount(pc?.user);
457
- const catAssistant = bd
580
+ const catUser = useBd ? safeCount(bd.userChars) : safeCount(pc?.user);
581
+ const catAssistant = useBd
458
582
  ? safeCount(bd.assistantChars)
459
583
  : safeCount(pc?.assistant);
460
- const catReasoning = bd
584
+ const catReasoning = useBd
461
585
  ? safeCount(bd.reasoningChars)
462
586
  : safeCount(pc?.reasoning);
463
- const catToolArgs = bd
587
+ const catToolArgs = useBd
464
588
  ? safeCount(bd.toolArgsChars)
465
589
  : safeCount(pc?.toolArgs);
466
590
  // system / tool schemas are hook-only; a fallback breakdown has them null
467
591
  // and must not erase a value the hook already persisted.
468
592
  const catSystem =
469
- bd && bd.systemChars != null
593
+ useBd && bd.systemChars != null
470
594
  ? safeCount(bd.systemChars)
471
595
  : safeCountOrNull(pc?.system);
472
596
  const catToolSchemas =
473
- bd && bd.toolSchemasChars != null
597
+ useBd && bd.toolSchemasChars != null
474
598
  ? safeCount(bd.toolSchemasChars)
475
599
  : safeCountOrNull(pc?.toolSchemas);
476
600
  // Tool results are counted by both the hook and the fallback path, so
477
601
  // this is a plain counted category (never null): a missing value is 0.
478
- const catToolOut = bd
602
+ const catToolOut = useBd
479
603
  ? safeCount(bd.toolOutChars)
480
604
  : safeCount(pc?.toolOut);
481
605
 
@@ -484,7 +608,7 @@ function writeStateFile(
484
608
  // split (an empty one means "no tool results now" — do NOT revive a stale
485
609
  // peer value); only fall back to the persisted map when this instance has
486
610
  // no live breakdown at all.
487
- const toolOutByTool = bd
611
+ const toolOutByTool = useBd
488
612
  ? safeToolOutByTool(bd.toolOutByTool)
489
613
  : safeToolOutByTool(prevEntry?.toolOutByTool);
490
614
 
@@ -492,14 +616,14 @@ function writeStateFile(
492
616
  // (keepTarget=false): state.json is a local temp file, but keeping it
493
617
  // path-free matches the redacted-export policy. A present breakdown owns
494
618
  // the value; otherwise inherit the persisted one.
495
- const reclaim = bd
619
+ const reclaim = useBd
496
620
  ? safeReclaim(bd.reclaim, false)
497
621
  : safeReclaim(prevEntry?.reclaim, false);
498
622
 
499
623
  // Heaviest single items still in the request (top-N). A present breakdown
500
624
  // OWNS the list; otherwise inherit the persisted one. Persisted WITHOUT
501
625
  // targets (path-free policy, same as reclaim).
502
- const top = bd
626
+ const top = useBd
503
627
  ? safeTop(bd.top, false)
504
628
  : safeTop(prevEntry?.top, false);
505
629
 
@@ -528,8 +652,11 @@ function writeStateFile(
528
652
  compactStale = true;
529
653
  } else if (compactedAt) {
530
654
  const cMs = Date.parse(compactedAt);
655
+ // Only a breakdown that actually OWNS the categories may clear the
656
+ // mark: a fallback fetch captured after the compaction never measured
657
+ // the new context, so it must not declare the entry fresh.
531
658
  const measuredMs =
532
- bd && typeof bd.capturedAt === "number" ? bd.capturedAt : NaN;
659
+ useBd && typeof bd.capturedAt === "number" ? bd.capturedAt : NaN;
533
660
  if (Number.isFinite(cMs) && Number.isFinite(measuredMs)) {
534
661
  compactStale = measuredMs <= cMs;
535
662
  } else {
@@ -623,6 +750,20 @@ function writeStateFile(
623
750
  : null,
624
751
  reasoning: safeCount(reasoningTokens),
625
752
  categories,
753
+ // Provenance of the persisted CATEGORIES (never of the token counts):
754
+ // "context-hook" / "compaction-hook" (V2) and "transform" /
755
+ // "system-transform" (V1) = a full payload with the system prompt and
756
+ // the tool schemas; "client-context" / "fallback" = rebuilt from the
757
+ // persisted messages. The next write reads it to decide which source may
758
+ // own the categories - see prevHookMeasured above. An untagged breakdown
759
+ // is treated as a fallback: never claim hook authority by default.
760
+ source: useBd
761
+ ? bd.source || "client-context"
762
+ : prevHookMeasured
763
+ ? prevEntry.source || "context-hook"
764
+ : pc
765
+ ? "snapshot"
766
+ : null,
626
767
  top,
627
768
  previous,
628
769
  toolOutByTool,
@@ -649,9 +790,10 @@ function writeStateFile(
649
790
  // Random temp suffix + exclusive create (CWE-377), then atomic rename.
650
791
  const tmp = `${STATE_FILE}.${randomBytes(6).toString("hex")}.tmp`;
651
792
  writeFileSync(tmp, JSON.stringify(all), { encoding: "utf8", flag: "wx" });
793
+ let renamed = false;
652
794
  try {
653
795
  ensureRegularFile(STATE_FILE); // never rename over a planted symlink
654
- renameWithRetry(tmp, STATE_FILE);
796
+ renamed = renameWithRetry(tmp, STATE_FILE);
655
797
  } finally {
656
798
  // A rename that exhausted its retries leaves the temp file behind; drop
657
799
  // it so repeated failures cannot litter %TEMP% (best-effort: on a
@@ -662,12 +804,14 @@ function writeStateFile(
662
804
  /* best-effort cleanup */
663
805
  }
664
806
  }
807
+ return renamed;
665
808
  } finally {
666
809
  // Release ONLY a lock we actually own (lock === null => fail-open, skip).
667
- if (lock && lock.ok) releaseStateLock(lock.token);
810
+ if (lock && lock.ok) releaseLock(STATE_LOCK_FILE, lock.token);
668
811
  }
669
812
  } catch (err) {
670
813
  logErr("writeStateFile failed", err);
814
+ return false;
671
815
  }
672
816
  }
673
817
 
package/lib/tui-data.js CHANGED
@@ -22,11 +22,16 @@ import { tmpdir } from "node:os";
22
22
  import { join } from "node:path";
23
23
  import { fileURLToPath } from "node:url";
24
24
  import { isSafeSessionKey, sumCategories } from "./estimate.js";
25
+ import { fmt, num } from "./format.js";
25
26
 
26
27
  // Re-exported so the panel keeps importing it from this module; the
27
28
  // implementation is the shared one (lib/estimate.js) — no second copy to drift.
28
29
  export { toolOutEntries } from "./estimate.js";
29
30
 
31
+ // Same for the number helpers: lib/format.js is pure (no node builtins), so the
32
+ // Desktop GUI bundle can inline it, while the panel keeps importing them here.
33
+ export { fmt, num };
34
+
30
35
  export const STATE_FILE =
31
36
  process.env.OPENCODE_CONTEXT_INDICATOR_STATE_FILE ||
32
37
  join(tmpdir(), "opencode-context-indicator-state.json");
@@ -123,15 +128,6 @@ export function collectFamily(snapshot, rootID) {
123
128
  return out
124
129
  }
125
130
 
126
- export function fmt(n) {
127
- if (!Number.isFinite(n)) return "0"
128
- return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n))
129
- }
130
-
131
- export function num(v) {
132
- return typeof v === "number" && Number.isFinite(v) ? v : 0
133
- }
134
-
135
131
  // Code-point-safe column fit: truncate on code points (never splitting a
136
132
  // surrogate pair at a column boundary), mark a truncated cell with a trailing
137
133
  // "~" so clipping is VISIBLE (a silently clipped cell reads as two columns
package/lib/v1.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // fallback, subagent aggregation, and the live log assembly). Extracted 1:1
7
7
  // (code + WHY comments) from index.js.
8
8
 
9
- import { estJson, estimateTokens, fmt, tokenCountOf } from "./estimate.js";
9
+ import { estJson, estimateTokens, fmt, isHookSource, tokenCountOf } from "./estimate.js";
10
10
  import {
11
11
  BREAKDOWN_THROTTLE_MS,
12
12
  breakdownCache,
@@ -624,7 +624,14 @@ function makeV1(client) {
624
624
  ...prev,
625
625
  systemChars,
626
626
  capturedAt: Date.now(),
627
- source: prev.source || "system-transform",
627
+ // Keep the provenance of a HOOK-family source only. A `fallback`
628
+ // record (built from session.messages on idle) can also sit in this
629
+ // cache: tagging the hook's system measurement with it would persist
630
+ // a non-null system prompt under a NON-hook `source`, which the
631
+ // authority rule (isHookMeasuredEntry) would then treat as
632
+ // overwritable by the next message-based fallback - the exact
633
+ // "system 0 / other inflated" defect, from the V1 side.
634
+ source: isHookSource(prev.source) ? prev.source : "system-transform",
628
635
  });
629
636
  pruneMap(breakdownCache, MAX_TRACKED_SESSIONS);
630
637
  } catch (err) {