@herbertgao/pi-extensions 2026.8.7 → 2026.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +7 -5
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +63 -15
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +78 -16
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +13 -0
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +17 -6
  8. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  9. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +4 -4
  10. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +154 -96
  11. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +3 -0
  12. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +10 -2
  13. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +7 -5
  14. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/tool-schema.md +11 -5
  15. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +3 -0
  16. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  17. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +137 -96
  18. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +44 -32
  19. package/node_modules/@juicesharp/rpiv-ask-user-question/state/selectors/projections.ts +22 -15
  20. package/node_modules/@juicesharp/rpiv-ask-user-question/state/state-reducer.ts +11 -1
  21. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/response-envelope.ts +11 -0
  22. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/types.ts +11 -0
  23. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/multi-select-view.ts +31 -17
  24. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/preview/preview-block-renderer.ts +15 -6
  25. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/wrapping-select.ts +15 -6
  26. package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +56 -34
  27. package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +50 -4
  28. package/node_modules/@narumitw/pi-btw/package.json +2 -2
  29. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +44 -1856
  30. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
  31. package/node_modules/@pi-plugins/fast-mode/package.json +4 -6
  32. package/node_modules/pi-lens/CHANGELOG.md +90 -0
  33. package/node_modules/pi-lens/README.md +106 -0
  34. package/node_modules/pi-lens/dist/clients/bash-file-access.js +97 -6
  35. package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +16 -6
  36. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +14 -0
  37. package/node_modules/pi-lens/dist/clients/cache-observability.js +353 -76
  38. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +22 -1
  39. package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +155 -0
  40. package/node_modules/pi-lens/dist/clients/deps/pi-tui.js +8 -2
  41. package/node_modules/pi-lens/dist/clients/deps/typebox.js +8 -3
  42. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +9 -6
  43. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +9 -7
  44. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +8 -6
  45. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +8 -10
  46. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +10 -10
  47. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +20 -5
  48. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +44 -11
  49. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -15
  50. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +19 -11
  51. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +35 -13
  52. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +8 -8
  53. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +110 -23
  54. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +12 -7
  55. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +117 -1
  56. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +16 -14
  57. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +8 -9
  58. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +22 -0
  59. package/node_modules/pi-lens/dist/clients/jscpd-client.js +5 -2
  60. package/node_modules/pi-lens/dist/clients/language-profile.js +7 -2
  61. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +27 -2
  62. package/node_modules/pi-lens/dist/clients/lsp/client.js +144 -5
  63. package/node_modules/pi-lens/dist/clients/lsp/index.js +151 -6
  64. package/node_modules/pi-lens/dist/clients/module-report.js +14 -1
  65. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +114 -23
  66. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +18 -9
  67. package/node_modules/pi-lens/dist/clients/project-report.js +62 -27
  68. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +7 -2
  69. package/node_modules/pi-lens/dist/clients/read-guard.js +88 -19
  70. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +194 -31
  71. package/node_modules/pi-lens/dist/clients/review-graph/revision-drift.js +21 -0
  72. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +6 -3
  73. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +44 -0
  74. package/node_modules/pi-lens/dist/clients/runtime-session.js +41 -0
  75. package/node_modules/pi-lens/dist/clients/runtime-turn.js +47 -2
  76. package/node_modules/pi-lens/dist/clients/session-event-guard.js +184 -0
  77. package/node_modules/pi-lens/dist/clients/source-filter.js +6 -2
  78. package/node_modules/pi-lens/dist/clients/startup-scan.js +5 -2
  79. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +1 -0
  80. package/node_modules/pi-lens/dist/index.js +1224 -393
  81. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +10 -3
  82. package/node_modules/pi-lens/package.json +18 -5
  83. package/node_modules/pi-lens/scripts/install-selftest.mjs +99 -11
  84. package/node_modules/pi-lens/scripts/lib/host-provided-deps.mjs +75 -0
  85. package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +285 -0
  86. package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +233 -0
  87. package/node_modules/pi-web-access/CHANGELOG.md +14 -0
  88. package/node_modules/pi-web-access/README.md +6 -5
  89. package/node_modules/pi-web-access/chrome-cookies.ts +86 -15
  90. package/node_modules/pi-web-access/data-uri-sanitize.ts +406 -0
  91. package/node_modules/pi-web-access/extract.ts +12 -2
  92. package/node_modules/pi-web-access/firecrawl.ts +18 -2
  93. package/node_modules/pi-web-access/github-extract.ts +65 -36
  94. package/node_modules/pi-web-access/index.ts +19 -4
  95. package/node_modules/pi-web-access/openai-search.ts +17 -6
  96. package/node_modules/pi-web-access/package.json +1 -1
  97. package/node_modules/pi-web-access/pdf-extract.ts +18 -5
  98. package/node_modules/pi-web-access/ssrf-protection.ts +12 -2
  99. package/package.json +9 -9
@@ -14,7 +14,14 @@
14
14
  * transformation, placement, injection sources, and privacy-preserving
15
15
  * hashes. This is what pi-lens observed locally, not the final provider
16
16
  * request after other context handlers have run and not a provider cache
17
- * result.
17
+ * result. #1938: this record used to also carry a `prefixObservation` /
18
+ * `firstMessageChange` pair derived from a SEPARATE bounded hash of the
19
+ * pre-injection prefix (capped at `MAX_HASHED_MESSAGES` messages /
20
+ * `MAX_HASHED_CONTENT_CHARS` chars). Every real session's transcript
21
+ * exceeds that cap almost immediately, so the pair reported "unknown" on
22
+ * 97% of records — dead weight in every record, answering nothing. The
23
+ * pair is removed; signal 3 below (`cache_prefix_break`) already covers
24
+ * first-message stability with an unbounded hash and never truncates.
18
25
  *
19
26
  * 3. Request-side prefix stability (`cache_prefix_break`) — a content hash of
20
27
  * `messages[0]` observed on every `context` call. After #1016 the first
@@ -22,12 +29,20 @@
22
29
  * flags that something (pi-lens or otherwise) broke the cache prefix. This
23
30
  * remains a local observation, never a provider cache-miss claim.
24
31
  *
32
+ * #1071 adds miss ATTRIBUTION on top of those three, without a new phase. Each
33
+ * `cache_usage` record carries the inter-turn gap and a `cacheMissCause`
34
+ * verdict, and each `cache_context` record splits a mixed injection payload by
35
+ * contributing source. Both ride records that already fire once per turn, and
36
+ * both derive from state this module already keeps in process — nothing reads
37
+ * latency.log back.
38
+ *
25
39
  * All paths are defensive: `usage` (and its fields) may be absent on older
26
40
  * hosts or non-assistant messages, so every access is guarded and the handlers
27
41
  * never throw — on error they `dbg(...)` and no-op, like the other index.ts
28
42
  * event handlers.
29
43
  */
30
44
  import { createHash } from "node:crypto";
45
+ import { lazyEnvNumber } from "./env-utils.js";
31
46
  import { logLatency } from "./latency-logger.js";
32
47
  /** RuntimeCoordinator's counter is process-global when sessions share a host. */
33
48
  const PROCESS_TURN_SCOPE = "process-global-runtime";
@@ -43,6 +58,214 @@ const MAX_REPORTED_MESSAGES = 10_000;
43
58
  const MAX_INJECTED_CHARS = 16_384;
44
59
  const MAX_INJECTED_BYTES = 65_536;
45
60
  let contextObservationCounter = 0;
61
+ /**
62
+ * Documented deterministic char→token estimate. Four characters per token is
63
+ * the same ratio #1071's issue body used to size the sampled injections. It is
64
+ * an ESTIMATE: never present a value derived from it as provider-measured
65
+ * usage.
66
+ */
67
+ const CHARS_PER_ESTIMATED_TOKEN = 4;
68
+ /**
69
+ * Idle gap after which a provider prompt cache entry is assumed expired.
70
+ *
71
+ * The gap this is compared against is measured to REQUEST time, not to the
72
+ * `message_end` that follows it — see {@link resolveInterTurnGap}. The provider
73
+ * looks the cache up when the request arrives, so the response's own generation
74
+ * time is not idle time and must not count toward expiry.
75
+ *
76
+ * Evidence (2026-08-21 KV-cache investigation, 63 sessions, 2,288 turns): the
77
+ * 34 zero-`cacheRead` turns had a MEDIAN inter-turn gap of 166s, against 9s for
78
+ * every other turn. Those 34 turns carried 6.96M fresh input tokens, 35.1% of
79
+ * all fresh input in the window. 60s sits between the two populations and
80
+ * matches the documented 5-minute-minus-safety-margin behavior of the shortest
81
+ * provider TTL tier we run against. Override with
82
+ * `PI_LENS_PROVIDER_CACHE_TTL_MS` when a provider's TTL differs.
83
+ */
84
+ export const DEFAULT_PROVIDER_CACHE_TTL_MS = 60_000;
85
+ const _providerCacheTtl = lazyEnvNumber("PI_LENS_PROVIDER_CACHE_TTL_MS", DEFAULT_PROVIDER_CACHE_TTL_MS);
86
+ export const getProviderCacheTtlMs = _providerCacheTtl.get;
87
+ export const _resetProviderCacheTtlForTests = _providerCacheTtl._resetForTests;
88
+ /**
89
+ * A turn counts as "anomalously low" when it reads back less than half of what
90
+ * the previous turn read. The investigation's partial-break population (90
91
+ * turns, upper-bounded at 2.7M tokens) was selected by exactly this shape: a
92
+ * non-zero `cacheRead` well below the prior turn's, which a zero-read filter
93
+ * misses entirely.
94
+ */
95
+ const LOW_CACHE_READ_RATIO = 0.5;
96
+ /**
97
+ * Fresh input must exceed the estimated new content by this factor before a
98
+ * shortfall reads as provider-side eviction rather than as our own payload.
99
+ * The investigation upper-bounds a whole session's injections at ~2.7 tokens
100
+ * per turn, so fresh input several times the measured new content cannot be
101
+ * explained by what pi-lens added.
102
+ */
103
+ const PARTIAL_EVICTION_INPUT_FACTOR = 4;
104
+ /**
105
+ * Floor for the same rule, so a turn with almost no new content does not read
106
+ * as eviction on a handful of tokens.
107
+ */
108
+ const PARTIAL_EVICTION_MIN_FRESH_TOKENS = 512;
109
+ /**
110
+ * Cap on the per-session character accumulators used for attribution, and on a
111
+ * single transcript-growth measurement feeding them.
112
+ *
113
+ * This is deliberately NOT `MAX_INJECTED_CHARS`. That 16 KiB cap exists to keep
114
+ * the reported injection size small, and pi-lens injections average about 2.7
115
+ * estimated tokens per turn, so it is never reached in practice. Transcript
116
+ * growth is a different population: one ordinary tool result can add 20,000
117
+ * characters in a single turn. Measuring growth through the injection cap
118
+ * latched `attributionCharsCapped` on a routine turn and suppressed the
119
+ * `partial-eviction` verdict, which made the verdict near-unreachable (#1071
120
+ * review round 1, F2).
121
+ */
122
+ const MAX_ATTRIBUTION_CHARS = 262_144;
123
+ /** Byte companion to {@link MAX_ATTRIBUTION_CHARS}, at four bytes per char. */
124
+ const MAX_ATTRIBUTION_BYTES = 1_048_576;
125
+ const attributionBySession = new Map();
126
+ function newAttributionState() {
127
+ return {
128
+ prefixBrokeSinceLastUsage: false,
129
+ injectedCharsSinceLastUsage: 0,
130
+ newTranscriptCharsSinceLastUsage: 0,
131
+ attributionCharsCapped: false,
132
+ };
133
+ }
134
+ /**
135
+ * Fetch (creating if absent) one session's attribution state, refreshing LRU
136
+ * recency and evicting the oldest entries past the cap — same bounding as
137
+ * {@link recordSessionHash}, so a long-lived process cycling through sessions
138
+ * cannot grow this map without limit.
139
+ */
140
+ function attributionFor(key) {
141
+ const existing = attributionBySession.get(key);
142
+ const state = existing ?? newAttributionState();
143
+ attributionBySession.delete(key);
144
+ attributionBySession.set(key, state);
145
+ while (attributionBySession.size > MAX_TRACKED_SESSIONS) {
146
+ const oldest = attributionBySession.keys().next().value;
147
+ if (oldest === undefined)
148
+ break;
149
+ attributionBySession.delete(oldest);
150
+ }
151
+ return state;
152
+ }
153
+ /**
154
+ * Fold one `context` observation into the session's attribution state: how much
155
+ * pi-lens injected, and how much the transcript itself grew since the previous
156
+ * observation. The transcript delta is what the "far above the injected and new
157
+ * content" rule measures fresh input against. The first observation in a
158
+ * session only anchors the message-count baseline, because there is no earlier
159
+ * point to measure growth from.
160
+ */
161
+ function recordContextAttribution(key, existingMessages, injectedSize) {
162
+ const state = attributionFor(key);
163
+ const baseline = Math.min(state.lastObservedMessageCount ?? existingMessages.length, existingMessages.length);
164
+ const grown = measureTranscriptGrowth(existingMessages.slice(baseline));
165
+ state.injectedCharsSinceLastUsage = Math.min(MAX_ATTRIBUTION_CHARS, state.injectedCharsSinceLastUsage + injectedSize.chars);
166
+ state.newTranscriptCharsSinceLastUsage = Math.min(MAX_ATTRIBUTION_CHARS, state.newTranscriptCharsSinceLastUsage + grown.chars);
167
+ if (injectedSize.capped ||
168
+ grown.capped ||
169
+ state.injectedCharsSinceLastUsage >= MAX_ATTRIBUTION_CHARS ||
170
+ state.newTranscriptCharsSinceLastUsage >= MAX_ATTRIBUTION_CHARS) {
171
+ // The accumulators are now floors, not totals. The verdict rules must not
172
+ // treat a floor as a measured value.
173
+ state.attributionCharsCapped = true;
174
+ }
175
+ state.lastObservedMessageCount = existingMessages.length;
176
+ // #1071 review round 1, F1: this handler runs at REQUEST time, which is when
177
+ // the provider looks the prompt cache up. Stamping it here is what lets the
178
+ // gap exclude the response's own generation time.
179
+ state.lastContextAtMs = Date.now();
180
+ }
181
+ /**
182
+ * Idle milliseconds before this turn's provider request.
183
+ *
184
+ * Measuring `message_end` to `message_end` would fold the response's own
185
+ * generation time into the gap, and generation dominates: over a real corpus
186
+ * the median generation time is 7,407 ms against a median true idle of 228 ms,
187
+ * a factor of 30. At the 60s threshold, 8.7% of consecutive pairs measure over
188
+ * on the message-end basis while only 3.3% were truly idle over. That is a 3.4x
189
+ * inflation of `ttl-expired`, which would have confirmed the investigation's
190
+ * hypothesis by construction (#1071 review round 1, F1).
191
+ */
192
+ function resolveInterTurnGap(state, nowMs) {
193
+ if (state.lastUsageAtMs === undefined) {
194
+ return { gapMs: null, basis: "no-prior-turn" };
195
+ }
196
+ if (state.lastContextAtMs !== undefined) {
197
+ return {
198
+ gapMs: Math.max(0, state.lastContextAtMs - state.lastUsageAtMs),
199
+ basis: "request-time",
200
+ };
201
+ }
202
+ return {
203
+ gapMs: Math.max(0, nowMs - state.lastUsageAtMs),
204
+ basis: "message-end-fallback",
205
+ };
206
+ }
207
+ function estimateTokens(chars) {
208
+ return Math.ceil(chars / CHARS_PER_ESTIMATED_TOKEN);
209
+ }
210
+ /**
211
+ * Classify a turn's prompt-cache shortfall. Returns `null` when there is
212
+ * nothing to explain: no prior usage record in this session (the first turn
213
+ * legitimately reads nothing), an unreadable `cacheRead`, or a healthy read.
214
+ *
215
+ * Rules, in the order they are applied, each stated with the evidence behind
216
+ * it (2026-08-21 investigation, 63 sessions, 2,288 turns, 94.2% overall hit
217
+ * rate):
218
+ *
219
+ * 1. `prefix-broke` — a `cache_prefix_break` CHANGE fired in this session
220
+ * since the previous usage record. This is a DIRECT local observation of
221
+ * the cause, so it outranks the timing heuristics below. The window was
222
+ * byte-stable throughout, so this verdict should stay rare; if it does
223
+ * not, #1016's fix has regressed.
224
+ * 2. `ttl-expired` — the gap since the previous usage record reached the
225
+ * provider TTL threshold. The 34 zero-read turns had a median gap of 166s
226
+ * against 9s elsewhere, and carried 35.1% of all fresh input.
227
+ * 3. `partial-eviction` — a non-zero but low read, where fresh input runs far
228
+ * above the new content we measured. pi-lens injections average ~2.7
229
+ * estimated tokens per turn, so they cannot account for a multiple of the
230
+ * measured content; the remainder is provider-side eviction, typically at
231
+ * a context limit. Suppressed when the character accumulators were capped,
232
+ * because a floor estimate would manufacture the verdict.
233
+ * 4. `unknown` — a real shortfall with no local explanation. Reported, never
234
+ * guessed at.
235
+ */
236
+ function classifyCacheMiss(args) {
237
+ const { cacheRead, priorCacheRead } = args;
238
+ if (typeof cacheRead !== "number" || !Number.isFinite(cacheRead))
239
+ return null;
240
+ // No baseline turn in this session: a first turn reads zero because nothing
241
+ // is cached yet, not because anything went wrong.
242
+ if (typeof priorCacheRead !== "number" || !Number.isFinite(priorCacheRead)) {
243
+ return null;
244
+ }
245
+ let kind;
246
+ if (cacheRead === 0) {
247
+ kind = "zero-read";
248
+ }
249
+ else if (priorCacheRead > 0 &&
250
+ cacheRead < priorCacheRead * LOW_CACHE_READ_RATIO) {
251
+ kind = "low-read";
252
+ }
253
+ else {
254
+ return null;
255
+ }
256
+ if (args.prefixBroke)
257
+ return { kind, cause: "prefix-broke" };
258
+ if (args.interTurnGapMs !== null && args.interTurnGapMs >= args.ttlMs) {
259
+ return { kind, cause: "ttl-expired" };
260
+ }
261
+ if (kind === "low-read" &&
262
+ !args.attributionCharsCapped &&
263
+ args.input >
264
+ Math.max(PARTIAL_EVICTION_MIN_FRESH_TOKENS, args.estimatedNewTokens * PARTIAL_EVICTION_INPUT_FACTOR)) {
265
+ return { kind, cause: "partial-eviction" };
266
+ }
267
+ return { kind, cause: "unknown" };
268
+ }
46
269
  function boundedHashScalar(value, state) {
47
270
  if (value === null)
48
271
  return "null";
@@ -123,65 +346,63 @@ function hashMessageSequence(messages) {
123
346
  contentTruncated: state.contentTruncated,
124
347
  };
125
348
  }
126
- function messageTextSize(content) {
349
+ /** Bounds for reporting an injected payload's size in the record. */
350
+ const INJECTED_LIMITS = {
351
+ maxChars: MAX_INJECTED_CHARS,
352
+ maxBytes: MAX_INJECTED_BYTES,
353
+ };
354
+ /** Bounds for the transcript-growth accumulator that feeds the verdict. */
355
+ const TRANSCRIPT_LIMITS = {
356
+ maxChars: MAX_ATTRIBUTION_CHARS,
357
+ maxBytes: MAX_ATTRIBUTION_BYTES,
358
+ };
359
+ function messageTextSize(content, limits) {
127
360
  if (typeof content === "string") {
128
- const chars = Math.min(content.length, MAX_INJECTED_CHARS);
361
+ const chars = Math.min(content.length, limits.maxChars);
129
362
  return {
130
363
  chars,
131
- bytes: Math.min(Buffer.byteLength(content.slice(0, chars), "utf8"), MAX_INJECTED_BYTES),
364
+ bytes: Math.min(Buffer.byteLength(content.slice(0, chars), "utf8"), limits.maxBytes),
132
365
  };
133
366
  }
134
367
  const bounded = boundedHashValue(content);
135
368
  return {
136
- chars: Math.min(bounded.length, MAX_INJECTED_CHARS),
137
- bytes: Math.min(Buffer.byteLength(bounded.slice(0, MAX_INJECTED_CHARS), "utf8"), MAX_INJECTED_BYTES),
369
+ chars: Math.min(bounded.length, limits.maxChars),
370
+ bytes: Math.min(Buffer.byteLength(bounded.slice(0, limits.maxChars), "utf8"), limits.maxBytes),
138
371
  };
139
372
  }
140
- function measureInjectedMessages(messages) {
373
+ function measureMessages(messages, limits) {
141
374
  let chars = 0;
142
375
  let bytes = 0;
143
376
  const measuredCount = Math.min(messages.length, MAX_REPORTED_MESSAGES);
144
377
  for (let i = 0; i < measuredCount; i++) {
145
378
  const message = messages[i];
146
- const size = messageTextSize(message?.content);
147
- chars = Math.min(MAX_INJECTED_CHARS, chars + size.chars);
148
- bytes = Math.min(MAX_INJECTED_BYTES, bytes + size.bytes);
149
- if (chars === MAX_INJECTED_CHARS || bytes === MAX_INJECTED_BYTES)
379
+ const size = messageTextSize(message?.content, limits);
380
+ chars = Math.min(limits.maxChars, chars + size.chars);
381
+ bytes = Math.min(limits.maxBytes, bytes + size.bytes);
382
+ if (chars === limits.maxChars || bytes === limits.maxBytes)
150
383
  break;
151
384
  }
152
385
  return {
153
386
  chars,
154
387
  bytes,
155
- capped: chars >= MAX_INJECTED_CHARS || bytes >= MAX_INJECTED_BYTES,
388
+ capped: chars >= limits.maxChars || bytes >= limits.maxBytes,
156
389
  };
157
390
  }
158
- function sessionKey(sessionId) {
159
- return sessionId?.trim() ? sessionId.trim() : NO_SESSION_KEY;
160
- }
161
- function prefixLengthForPlacement(placement, messageCount) {
162
- if (placement === "insert-before-final") {
163
- return Math.max(0, messageCount - 1);
164
- }
165
- if (placement === "prepend")
166
- return 0;
167
- return messageCount;
168
- }
169
- function resolvePrefixObservation(truncated, observation) {
170
- if (truncated)
171
- return "unknown";
172
- return observation ?? "empty";
391
+ /** Size of an injected payload, bounded for reporting. */
392
+ function measureInjectedMessages(messages) {
393
+ return measureMessages(messages, INJECTED_LIMITS);
173
394
  }
174
- function prefixBaselineForObservation(observation) {
175
- if (observation === "baseline")
176
- return true;
177
- if (observation === "empty" || observation === "unknown")
178
- return null;
179
- return false;
395
+ /**
396
+ * Size of the transcript's own growth, bounded far higher. A routine tool
397
+ * result is an order of magnitude past the injection cap, and measuring it
398
+ * through that cap made `partial-eviction` near-unreachable (#1071 review
399
+ * round 1, F2).
400
+ */
401
+ function measureTranscriptGrowth(messages) {
402
+ return measureMessages(messages, TRANSCRIPT_LIMITS);
180
403
  }
181
- function firstMessageChangeFor(truncated, before, after) {
182
- if (truncated)
183
- return "unknown";
184
- return before !== after ? "changed" : "unchanged";
404
+ function sessionKey(sessionId) {
405
+ return sessionId?.trim() ? sessionId.trim() : NO_SESSION_KEY;
185
406
  }
186
407
  /**
187
408
  * Log one bounded request-side observation for every `context` call. This is
@@ -197,34 +418,32 @@ export function observeCacheContext(args) {
197
418
  try {
198
419
  const existingMessages = args.existingMessages ?? [];
199
420
  const resultMessages = args.resultMessages ?? existingMessages;
200
- const injectedMessages = args.injectedMessages ?? [];
421
+ // Drop empty slices before deriving anything: a source that contributed
422
+ // no message is not a contributor, and listing it would overstate the
423
+ // payload's provenance.
424
+ const injectionSlices = (args.injectionSlices ?? []).filter((slice) => slice.messages.length > 0);
425
+ const injectedMessages = injectionSlices.flatMap((slice) => slice.messages);
201
426
  const placement = args.placement ?? "none";
202
427
  const beforeSequence = hashMessageSequence(existingMessages);
203
428
  const afterSequence = hashMessageSequence(resultMessages);
204
- const beforePrefixLength = prefixLengthForPlacement(placement, existingMessages.length);
205
- const afterPrefixLength = Math.min(beforePrefixLength, resultMessages.length);
206
- const beforePrefix = hashMessageSequence(existingMessages.slice(0, beforePrefixLength));
207
- const afterPrefix = hashMessageSequence(resultMessages.slice(0, afterPrefixLength));
208
- const beforeFirstSequence = existingMessages.length
209
- ? hashMessageSequence([existingMessages[0]])
210
- : undefined;
211
- const afterFirstSequence = resultMessages.length
212
- ? hashMessageSequence([resultMessages[0]])
213
- : undefined;
214
- const beforeFirst = beforeFirstSequence?.hash ?? null;
215
- const afterFirst = afterFirstSequence?.hash ?? null;
216
- const firstMessageHashTruncated = beforeFirstSequence?.truncated === true ||
217
- beforeFirstSequence?.contentTruncated === true ||
218
- afterFirstSequence?.truncated === true ||
219
- afterFirstSequence?.contentTruncated === true;
220
- const prefixHashTruncated = beforePrefix.truncated ||
221
- afterPrefix.truncated ||
222
- beforePrefix.contentTruncated ||
223
- afterPrefix.contentTruncated;
224
- const prefixObservation = resolvePrefixObservation(prefixHashTruncated, args.prefixObservation);
225
429
  const sizes = measureInjectedMessages(injectedMessages);
430
+ // Per-source split of a mixed payload (#1071 AC 2). The old record named
431
+ // which sources fired but attributed cost by CALL, so a turn carrying
432
+ // turn-findings plus an agent nudge was one undivided number.
433
+ const sourceBreakdown = injectionSlices.map((slice) => {
434
+ const sliceSize = measureInjectedMessages(slice.messages);
435
+ return {
436
+ source: slice.source,
437
+ messageCount: Math.min(slice.messages.length, MAX_REPORTED_MESSAGES),
438
+ chars: sliceSize.chars,
439
+ bytes: sliceSize.bytes,
440
+ estimatedTokens: estimateTokens(sliceSize.chars),
441
+ countsCapped: sliceSize.capped,
442
+ };
443
+ });
226
444
  const messageCountCapped = existingMessages.length > MAX_REPORTED_MESSAGES ||
227
445
  resultMessages.length > MAX_REPORTED_MESSAGES;
446
+ recordContextAttribution(sessionKey(args.sessionId), existingMessages, sizes);
228
447
  logLatency({
229
448
  type: "phase",
230
449
  filePath: "<pi-lens>",
@@ -240,39 +459,28 @@ export function observeCacheContext(args) {
240
459
  ? { turnScope: SECONDARY_TURN_SCOPE }
241
460
  : { turnIndex: args.turnIndex, turnScope: PROCESS_TURN_SCOPE }),
242
461
  injectionEnabled: args.injectionEnabled,
243
- injectionSources: Array.from(args.injectionSources ?? []),
462
+ injectionSources: sourceBreakdown.map((entry) => entry.source),
463
+ injectionSourceBreakdown: sourceBreakdown,
464
+ injectionOccurred: injectedMessages.length > 0,
244
465
  injectedMessageCount: Math.min(injectedMessages.length, MAX_REPORTED_MESSAGES),
245
466
  injectedMessageCountCapped: injectedMessages.length > MAX_REPORTED_MESSAGES,
246
467
  injectedChars: sizes.chars,
247
468
  injectedBytes: sizes.bytes,
469
+ injectedEstimatedTokens: estimateTokens(sizes.chars),
470
+ injectedTokenBasis: "chars-per-token-4-estimate-not-provider-measured",
248
471
  injectedCountsCapped: sizes.capped,
249
472
  existingMessageCount: Math.min(existingMessages.length, MAX_REPORTED_MESSAGES),
250
473
  resultMessageCount: Math.min(resultMessages.length, MAX_REPORTED_MESSAGES),
251
474
  messageCountCapped,
252
475
  placement,
253
- prefixObservation,
254
- prefixObservationUnknown: prefixObservation === "unknown",
255
- prefixBaseline: prefixBaselineForObservation(prefixObservation),
256
- firstMessageChanged: firstMessageHashTruncated
257
- ? null
258
- : beforeFirst !== afterFirst,
259
- firstMessageChange: firstMessageChangeFor(firstMessageHashTruncated, beforeFirst, afterFirst),
260
- firstMessageHashTruncated,
261
- beforeFirstMessageHash: beforeFirst,
262
- afterFirstMessageHash: afterFirst,
263
476
  beforeSequenceHash: beforeSequence.hash,
264
477
  afterSequenceHash: afterSequence.hash,
265
- beforePrefixHash: beforePrefix.hash,
266
- afterPrefixHash: afterPrefix.hash,
267
478
  sequenceHashTruncated: beforeSequence.truncated ||
268
479
  afterSequence.truncated ||
269
480
  beforeSequence.contentTruncated ||
270
481
  afterSequence.contentTruncated,
271
482
  sequenceMessageCountTruncated: beforeSequence.truncated || afterSequence.truncated,
272
483
  sequenceContentHashTruncated: beforeSequence.contentTruncated || afterSequence.contentTruncated,
273
- prefixHashTruncated,
274
- prefixMessageCountTruncated: beforePrefix.truncated || afterPrefix.truncated,
275
- prefixContentHashTruncated: beforePrefix.contentTruncated || afterPrefix.contentTruncated,
276
484
  },
277
485
  });
278
486
  }
@@ -301,6 +509,49 @@ export function logCacheUsage(message, dbg, context) {
301
509
  if (!usage || typeof usage !== "object")
302
510
  return;
303
511
  const u = usage;
512
+ // #1071: attribute the shortfall in-process, from state this module already
513
+ // keeps, so nothing re-reads latency.log.
514
+ const attributionKey = sessionKey(context?.sessionId);
515
+ const state = attributionFor(attributionKey);
516
+ const nowMs = Date.now();
517
+ const gap = resolveInterTurnGap(state, nowMs);
518
+ const interTurnGapMs = gap.gapMs;
519
+ const ttlMs = getProviderCacheTtlMs();
520
+ const estimatedNewTokens = estimateTokens(state.injectedCharsSinceLastUsage +
521
+ state.newTranscriptCharsSinceLastUsage);
522
+ const verdict = classifyCacheMiss({
523
+ cacheRead: u.cacheRead,
524
+ input: typeof u.input === "number" ? u.input : 0,
525
+ priorCacheRead: state.lastCacheRead,
526
+ interTurnGapMs,
527
+ ttlMs,
528
+ prefixBroke: state.prefixBrokeSinceLastUsage,
529
+ estimatedNewTokens,
530
+ attributionCharsCapped: state.attributionCharsCapped,
531
+ });
532
+ const priorCacheRead = typeof state.lastCacheRead === "number" ? state.lastCacheRead : null;
533
+ const injectedCharsSinceLastTurn = state.injectedCharsSinceLastUsage;
534
+ const newTranscriptCharsSinceLastTurn = state.newTranscriptCharsSinceLastUsage;
535
+ const attributionCharsCapped = state.attributionCharsCapped;
536
+ // This record is the turn boundary: reset the per-turn accumulators and
537
+ // re-arm the prefix-break flag so the next verdict describes the NEXT gap.
538
+ state.lastUsageAtMs = nowMs;
539
+ // A record whose `cacheRead` is absent or non-finite carries no baseline.
540
+ // Clear the stored one rather than keeping it: the next turn's gap would be
541
+ // one turn long while its baseline was two turns old, and a verdict built
542
+ // on that mismatch is worse than no verdict (#1071 review round 1, F4).
543
+ state.lastCacheRead =
544
+ typeof u.cacheRead === "number" && Number.isFinite(u.cacheRead)
545
+ ? u.cacheRead
546
+ : undefined;
547
+ state.prefixBrokeSinceLastUsage = false;
548
+ state.injectedCharsSinceLastUsage = 0;
549
+ state.newTranscriptCharsSinceLastUsage = 0;
550
+ state.attributionCharsCapped = false;
551
+ // Clear the request stamp too: the next turn measures from ITS request, and
552
+ // a turn whose `context` call pi-lens never saw must fall back rather than
553
+ // reuse this one.
554
+ state.lastContextAtMs = undefined;
304
555
  logLatency({
305
556
  type: "phase",
306
557
  filePath: "<pi-lens>",
@@ -315,6 +566,20 @@ export function logCacheUsage(message, dbg, context) {
315
566
  output: u.output,
316
567
  // `Usage.cost` is a breakdown object; the total is the headline number.
317
568
  cost: u.cost?.total,
569
+ // Idle milliseconds before this turn's request, or null for the first
570
+ // record in the session. This is the field the 2026-08-21
571
+ // investigation had to reconstruct by hand-joining timestamps.
572
+ interTurnGapMs,
573
+ gapBasis: gap.basis,
574
+ // null means "nothing to explain", not "cause unknown"; `unknown` is
575
+ // the explicit no-local-explanation verdict.
576
+ cacheMissCause: verdict?.cause ?? null,
577
+ cacheMissKind: verdict?.kind ?? null,
578
+ cacheTtlThresholdMs: ttlMs,
579
+ priorCacheRead,
580
+ injectedCharsSinceLastTurn,
581
+ newTranscriptCharsSinceLastTurn,
582
+ attributionCharsCapped,
318
583
  ...(context
319
584
  ? {
320
585
  // MessageEndEvent has no request/context id in the host API. These
@@ -455,6 +720,9 @@ export function observeCachePrefix(messages, turnIndex, sessionId, sessionRole,
455
720
  return "baseline";
456
721
  }
457
722
  if (currentHash !== previousHash) {
723
+ // #1071: arm the direct-cause flag the next `cache_usage` verdict reads.
724
+ // A locally observed prefix change outranks the timing heuristics.
725
+ attributionFor(key).prefixBrokeSinceLastUsage = true;
458
726
  logLatency({
459
727
  type: "phase",
460
728
  filePath: "<pi-lens>",
@@ -491,8 +759,17 @@ export function observeCachePrefix(messages, turnIndex, sessionId, sessionRole,
491
759
  export function clearCachePrefixSession(sessionId) {
492
760
  const key = sessionKey(sessionId);
493
761
  prefixHashBySession.delete(key);
762
+ // The miss-attribution state has the same lifetime and the same reason to be
763
+ // reclaimed at session end; leaving it would let a reused session id inherit
764
+ // a stale gap baseline and a stale prefix-break flag.
765
+ attributionBySession.delete(key);
494
766
  }
495
- /** Clear all per-session prefix hashes. For tests / session boundaries. */
767
+ /**
768
+ * Clear all per-session prefix hashes and miss-attribution state. For tests and
769
+ * session boundaries. Both maps are cleared together so a test can never start
770
+ * with a half-reset session.
771
+ */
496
772
  export function resetCachePrefixObservation() {
497
773
  prefixHashBySession.clear();
774
+ attributionBySession.clear();
498
775
  }
@@ -2,6 +2,7 @@
2
2
  import { logExtension } from "./extension-log.js";
3
3
  import { LEDGER_FIELD_MAX, truncateForLedger } from "./ledger-bounds.js";
4
4
  import { logLatency } from "./latency-logger.js";
5
+ import { getSinkWriteFailures, resetSinkWriteFailures } from "./ndjson-logger.js";
5
6
  // Re-exported so existing importers keep one name for the ledger's bound.
6
7
  export { LEDGER_FIELD_MAX, truncateForLedger };
7
8
  const ENTRIES_PER_KIND = 20;
@@ -143,12 +144,28 @@ function boundedKind(value) {
143
144
  return groups.size < MAX_DISTINCT_KINDS - 1 ? kind : OVERFLOW_KIND;
144
145
  }
145
146
  export function getDegradationSummary() {
146
- return [...groups.entries()].map(([kind, group]) => ({
147
+ const summary = [...groups.entries()].map(([kind, group]) => ({
147
148
  kind,
148
149
  count: group.count,
149
150
  droppedCount: group.count - group.entries.length,
150
151
  latestReasons: group.entries.map((entry) => ({ ...entry })),
151
152
  }));
153
+ // Folded in at read time, not written into `groups` (#1970) — see the
154
+ // `log-sink-write-failure` doc comment on `DegradationKind` for why this
155
+ // kind never goes through `recordDegradation`.
156
+ const sinkFailures = getSinkWriteFailures();
157
+ if (sinkFailures.length > 0) {
158
+ summary.push({
159
+ kind: "log-sink-write-failure",
160
+ count: sinkFailures.reduce((total, sink) => total + sink.droppedCount, 0),
161
+ droppedCount: 0,
162
+ latestReasons: sinkFailures.map((sink) => ({
163
+ subject: truncateForLedger(sink.file),
164
+ reason: truncateForLedger(`${sink.droppedCount} dropped write(s) after reopen-retry failed`),
165
+ })),
166
+ });
167
+ }
168
+ return summary;
152
169
  }
153
170
  function isRenderableSummary(value) {
154
171
  if (!Array.isArray(value))
@@ -197,6 +214,10 @@ export function resetDegradationLedger() {
197
214
  onceKeys.clear();
198
215
  tallies.clear();
199
216
  ledgerGeneration++;
217
+ // #1970, catalog shape 17: the sink write-failure tally is a
218
+ // process-lifetime latch too — it re-arms alongside the rest of the
219
+ // ledger rather than surviving past the session that observed it.
220
+ resetSinkWriteFailures();
200
221
  }
201
222
  export const DEGRADATION_ENTRIES_PER_KIND = ENTRIES_PER_KIND;
202
223
  export const DEGRADATION_MAX_DISTINCT_KINDS = MAX_DISTINCT_KINDS;