pi-lens 4.1.0 → 4.1.2

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 (206) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/README.md +106 -0
  3. package/dist/clients/actionable-warnings-logger.js +2 -1
  4. package/dist/clients/actionable-warnings.js +14 -5
  5. package/dist/clients/advisory-provenance.js +48 -18
  6. package/dist/clients/ast-grep-client.js +1 -3
  7. package/dist/clients/atomic-write-staging.js +16 -4
  8. package/dist/clients/bash-file-access.js +99 -6
  9. package/dist/clients/biome-client.js +1 -3
  10. package/dist/clients/blocker-freshness.js +21 -8
  11. package/dist/clients/blocker-past-eof.js +16 -6
  12. package/dist/clients/bounded-cache.js +24 -8
  13. package/dist/clients/bounded-pid-file-lock.js +4 -3
  14. package/dist/clients/bounded-telemetry.js +14 -0
  15. package/dist/clients/cache-manager.js +6 -7
  16. package/dist/clients/cache-observability.js +782 -110
  17. package/dist/clients/call-graph.js +238 -63
  18. package/dist/clients/codebase-model.js +13 -5
  19. package/dist/clients/complexity-client.js +1 -3
  20. package/dist/clients/dead-code-client.js +19 -8
  21. package/dist/clients/dead-code-logger.js +1 -2
  22. package/dist/clients/deadline-utils.js +3 -1
  23. package/dist/clients/debug-heap.js +8 -1
  24. package/dist/clients/degradation-ledger.js +22 -1
  25. package/dist/clients/demoted-finding-render.js +155 -0
  26. package/dist/clients/dependency-checker.js +11 -7
  27. package/dist/clients/deps/pi-tui.js +8 -2
  28. package/dist/clients/deps/typebox.js +8 -3
  29. package/dist/clients/diagnostic-dispositions.js +39 -2
  30. package/dist/clients/diagnostic-line-freshness.js +5 -1
  31. package/dist/clients/dispatch/auxiliary-lsp.js +2 -3
  32. package/dist/clients/dispatch/dispatcher.js +13 -1
  33. package/dist/clients/dispatch/facts/function-facts.js +3 -1
  34. package/dist/clients/dispatch/facts/import-facts.js +2 -1
  35. package/dist/clients/dispatch/facts/tree-sitter-facts.js +1 -1
  36. package/dist/clients/dispatch/integration.js +9 -4
  37. package/dist/clients/dispatch/rules/error-swallowing.js +3 -1
  38. package/dist/clients/dispatch/rules/pass-through-wrappers.js +6 -2
  39. package/dist/clients/dispatch/rules/unsafe-boundary.js +31 -7
  40. package/dist/clients/dispatch/runners/actionlint.js +5 -2
  41. package/dist/clients/dispatch/runners/biome-check.js +10 -14
  42. package/dist/clients/dispatch/runners/cpp-check.js +5 -1
  43. package/dist/clients/dispatch/runners/detekt.js +9 -6
  44. package/dist/clients/dispatch/runners/dotnet-build.js +1 -1
  45. package/dist/clients/dispatch/runners/elixir-check.js +3 -1
  46. package/dist/clients/dispatch/runners/eslint.js +25 -6
  47. package/dist/clients/dispatch/runners/fish-indent.js +1 -1
  48. package/dist/clients/dispatch/runners/gleam-check.js +1 -1
  49. package/dist/clients/dispatch/runners/golangci-lint.js +3 -1
  50. package/dist/clients/dispatch/runners/hadolint.js +22 -15
  51. package/dist/clients/dispatch/runners/helm-lint.js +4 -1
  52. package/dist/clients/dispatch/runners/htmlhint.js +33 -16
  53. package/dist/clients/dispatch/runners/javac.js +1 -1
  54. package/dist/clients/dispatch/runners/ktlint.js +1 -1
  55. package/dist/clients/dispatch/runners/lsp.js +1 -1
  56. package/dist/clients/dispatch/runners/markdownlint.js +34 -12
  57. package/dist/clients/dispatch/runners/mypy.js +15 -17
  58. package/dist/clients/dispatch/runners/oxlint.js +231 -15
  59. package/dist/clients/dispatch/runners/php-lint.js +1 -1
  60. package/dist/clients/dispatch/runners/phpstan.js +52 -15
  61. package/dist/clients/dispatch/runners/psscriptanalyzer.js +10 -6
  62. package/dist/clients/dispatch/runners/pyright.js +23 -14
  63. package/dist/clients/dispatch/runners/rust-clippy.js +1 -1
  64. package/dist/clients/dispatch/runners/shellcheck.js +7 -9
  65. package/dist/clients/dispatch/runners/shfmt.js +5 -2
  66. package/dist/clients/dispatch/runners/spellcheck.js +10 -15
  67. package/dist/clients/dispatch/runners/sqlfluff.js +25 -17
  68. package/dist/clients/dispatch/runners/stylelint.js +40 -20
  69. package/dist/clients/dispatch/runners/swiftlint.js +8 -8
  70. package/dist/clients/dispatch/runners/taplo.js +110 -23
  71. package/dist/clients/dispatch/runners/terragrunt.js +13 -8
  72. package/dist/clients/dispatch/runners/tflint.js +25 -15
  73. package/dist/clients/dispatch/runners/tree-sitter.js +17 -3
  74. package/dist/clients/dispatch/runners/utils/candidate-probe.js +7 -1
  75. package/dist/clients/dispatch/runners/utils/runner-helpers.js +24 -1
  76. package/dist/clients/dispatch/runners/utils/tool-failure.js +152 -1
  77. package/dist/clients/dispatch/runners/vale.js +16 -14
  78. package/dist/clients/dispatch/runners/yaml-rule-parser.js +1 -3
  79. package/dist/clients/dispatch/runners/yamllint.js +13 -16
  80. package/dist/clients/dispatch/runners/zig-check.js +1 -1
  81. package/dist/clients/dispatch/suppress-writer.js +2 -1
  82. package/dist/clients/dispatch/types.js +6 -1
  83. package/dist/clients/dispatch/utils/lsp-diagnostics.js +6 -2
  84. package/dist/clients/extension-log.js +5 -3
  85. package/dist/clients/feature-hints.js +8 -1
  86. package/dist/clients/file-kinds.js +34 -129
  87. package/dist/clients/file-utils.js +17 -1
  88. package/dist/clients/finding-delivery-gate.js +79 -2
  89. package/dist/clients/formatters.js +144 -27
  90. package/dist/clients/freshness.js +38 -0
  91. package/dist/clients/git-guard.js +43 -13
  92. package/dist/clients/go-client.js +1 -3
  93. package/dist/clients/grammar-source.js +1 -1
  94. package/dist/clients/gzip-stage-write.js +30 -4
  95. package/dist/clients/host-edit-normalize.js +1 -3
  96. package/dist/clients/install-diagnostics.js +4 -2
  97. package/dist/clients/installer/index.js +86 -77
  98. package/dist/clients/instance-reaper.js +5 -2
  99. package/dist/clients/instance-registry.js +4 -2
  100. package/dist/clients/jscpd-client.js +11 -3
  101. package/dist/clients/knip-client.js +29 -15
  102. package/dist/clients/language-profile.js +11 -3
  103. package/dist/clients/latency-logger.js +13 -2
  104. package/dist/clients/lens-config.js +1 -1
  105. package/dist/clients/lens-map.js +8 -3
  106. package/dist/clients/live-bus-emitter.js +1 -1
  107. package/dist/clients/lsp/cascade-tier.js +27 -2
  108. package/dist/clients/lsp/client.js +144 -5
  109. package/dist/clients/lsp/config.js +4 -6
  110. package/dist/clients/lsp/edits.js +119 -32
  111. package/dist/clients/lsp/index.js +245 -41
  112. package/dist/clients/lsp/inferred-project.js +2 -1
  113. package/dist/clients/lsp/jvm-runtime.js +4 -1
  114. package/dist/clients/lsp/launch.js +11 -3
  115. package/dist/clients/lsp/pending-aux-coverage.js +152 -0
  116. package/dist/clients/lsp/server.js +19 -7
  117. package/dist/clients/lsp/tsserver-sync.js +10 -2
  118. package/dist/clients/lsp/workspace-diagnostics-cache.js +9 -4
  119. package/dist/clients/lsp-document-symbols.js +1 -3
  120. package/dist/clients/lsp-mutation.js +10 -20
  121. package/dist/clients/mcp/host-shim.js +1 -1
  122. package/dist/clients/memory-sampler.js +73 -7
  123. package/dist/clients/metrics-client.js +1 -3
  124. package/dist/clients/middle-man-analysis.js +8 -3
  125. package/dist/clients/module-report-lsp.js +7 -1
  126. package/dist/clients/module-report.js +28 -18
  127. package/dist/clients/ndjson-logger.js +117 -28
  128. package/dist/clients/opaque-mutation-scan.js +220 -0
  129. package/dist/clients/package-manager.js +6 -1
  130. package/dist/clients/pipeline.js +57 -9
  131. package/dist/clients/project-conventions.js +2 -8
  132. package/dist/clients/project-diagnostics/cache.js +10 -7
  133. package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +2 -1
  134. package/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +3 -1
  135. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +13 -9
  136. package/dist/clients/project-diagnostics/runner-adapters/trivy.js +3 -1
  137. package/dist/clients/project-diagnostics/scanner.js +22 -10
  138. package/dist/clients/project-lens-config.js +12 -5
  139. package/dist/clients/project-report.js +62 -27
  140. package/dist/clients/project-snapshot-fingerprint.js +48 -0
  141. package/dist/clients/project-snapshot-persist-worker.js +15 -1
  142. package/dist/clients/project-snapshot.js +472 -66
  143. package/dist/clients/read-expansion.js +12 -2
  144. package/dist/clients/read-guard-logger.js +12 -6
  145. package/dist/clients/read-guard-tool-lines.js +5 -2
  146. package/dist/clients/read-guard.js +88 -19
  147. package/dist/clients/resource-sampler.js +1 -1
  148. package/dist/clients/reverse-deps.js +6 -5
  149. package/dist/clients/review-graph/builder.js +236 -48
  150. package/dist/clients/review-graph/git-identity.js +8 -2
  151. package/dist/clients/review-graph/import-resolvers.js +10 -1
  152. package/dist/clients/review-graph/query.js +2 -1
  153. package/dist/clients/review-graph/revision-drift.js +21 -0
  154. package/dist/clients/review-graph/tsconfig-paths.js +4 -2
  155. package/dist/clients/review-graph/workspace-modules.js +8 -5
  156. package/dist/clients/ruff-client.js +6 -5
  157. package/dist/clients/runtime-agent-end.js +63 -31
  158. package/dist/clients/runtime-context.js +12 -4
  159. package/dist/clients/runtime-coordinator.js +127 -4
  160. package/dist/clients/runtime-session.js +55 -0
  161. package/dist/clients/runtime-tool-call.js +59 -3
  162. package/dist/clients/runtime-tool-result.js +148 -34
  163. package/dist/clients/runtime-turn.js +194 -8
  164. package/dist/clients/rust-client.js +1 -3
  165. package/dist/clients/safe-spawn.js +150 -11
  166. package/dist/clients/security-scan-client.js +1 -3
  167. package/dist/clients/session-event-guard.js +184 -0
  168. package/dist/clients/session-summary.js +3 -1
  169. package/dist/clients/sg-runner.js +1 -3
  170. package/dist/clients/source-filter.js +16 -8
  171. package/dist/clients/source-walker.js +1 -1
  172. package/dist/clients/spawn-timeout-cooldown.js +96 -0
  173. package/dist/clients/startup-scan.js +9 -3
  174. package/dist/clients/tool-definition.js +7 -0
  175. package/dist/clients/tool-policy.js +26 -11
  176. package/dist/clients/tool-render.js +2 -1
  177. package/dist/clients/tree-sitter-client.js +10 -4
  178. package/dist/clients/tree-sitter-logger.js +2 -1
  179. package/dist/clients/tree-sitter-symbol-extractor.js +12 -5
  180. package/dist/clients/warm-attach.js +12 -5
  181. package/dist/clients/widget-state.js +27 -11
  182. package/dist/clients/word-index.js +3 -1
  183. package/dist/clients/workspace-topology.js +8 -2
  184. package/dist/clients/zizmor-config.js +9 -1
  185. package/dist/i18n.js +13 -5
  186. package/dist/index.js +15811 -12810
  187. package/dist/mcp/server.js +3 -1
  188. package/dist/tools/ast-grep-replace.js +68 -12
  189. package/dist/tools/lens-diagnostic-mark.js +21 -7
  190. package/dist/tools/lens-diagnostics.js +174 -38
  191. package/dist/tools/lsp-diagnostics.js +24 -7
  192. package/dist/tools/lsp-navigation.js +3 -1
  193. package/dist/tools/module-report.js +1 -1
  194. package/dist/tools/project-report.js +1 -1
  195. package/dist/tools/symbol-search.js +5 -2
  196. package/docs/durable-store-audit-1202.md +1 -1
  197. package/docs/servercapabilities.md +1 -1
  198. package/package.json +160 -143
  199. package/rules/typos/_typos.toml +2 -2
  200. package/scripts/analyze-pi-lens-logs.mjs +31 -13
  201. package/scripts/download-grammars.js +9 -3
  202. package/scripts/install-selftest.mjs +99 -11
  203. package/scripts/lib/host-provided-deps.mjs +75 -0
  204. package/scripts/lib/warm-loader-cache.mjs +285 -0
  205. package/scripts/rpc-load-check.mjs +30 -9
  206. package/scripts/warm-loader-cache.mjs +233 -0
@@ -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,26 @@
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. Each `cache_usage` record
33
+ * carries the inter-turn gap and a `cacheMissCause` verdict, and each
34
+ * `cache_context` record splits a mixed injection payload by contributing
35
+ * source. #1996 makes every unexplained miss name the absent/conflicting
36
+ * evidence and emits one fixed-key `cache_usage_summary` at session shutdown.
37
+ * Heuristic causes require complete request hashing and full provider/model
38
+ * identity evidence. Identity comparison uses SHA-256 over the full normalized
39
+ * value while logs retain only a bounded display prefix; malformed numeric
40
+ * usage is sanitized before it reaches JSON metadata.
41
+ * The per-turn records still ride events that already fire once per turn, and
42
+ * all attribution derives from bounded in-process state — nothing reads
43
+ * latency.log back or serializes transcript content.
44
+ *
25
45
  * All paths are defensive: `usage` (and its fields) may be absent on older
26
46
  * hosts or non-assistant messages, so every access is guarded and the handlers
27
47
  * never throw — on error they `dbg(...)` and no-op, like the other index.ts
28
48
  * event handlers.
29
49
  */
30
50
  import { createHash } from "node:crypto";
51
+ import { lazyEnvNumber } from "./env-utils.js";
31
52
  import { logLatency } from "./latency-logger.js";
32
53
  /** RuntimeCoordinator's counter is process-global when sessions share a host. */
33
54
  const PROCESS_TURN_SCOPE = "process-global-runtime";
@@ -43,6 +64,404 @@ const MAX_REPORTED_MESSAGES = 10_000;
43
64
  const MAX_INJECTED_CHARS = 16_384;
44
65
  const MAX_INJECTED_BYTES = 65_536;
45
66
  let contextObservationCounter = 0;
67
+ /**
68
+ * Documented deterministic char→token estimate. Four characters per token is
69
+ * the same ratio #1071's issue body used to size the sampled injections. It is
70
+ * an ESTIMATE: never present a value derived from it as provider-measured
71
+ * usage.
72
+ */
73
+ const CHARS_PER_ESTIMATED_TOKEN = 4;
74
+ /**
75
+ * Idle gap after which a provider prompt cache entry is assumed expired.
76
+ *
77
+ * The gap this is compared against is measured to REQUEST time, not to the
78
+ * `message_end` that follows it — see {@link resolveInterTurnGap}. The provider
79
+ * looks the cache up when the request arrives, so the response's own generation
80
+ * time is not idle time and must not count toward expiry.
81
+ *
82
+ * Evidence (2026-08-21 KV-cache investigation, 63 sessions, 2,288 turns): the
83
+ * 34 zero-`cacheRead` turns had a MEDIAN inter-turn gap of 166s, against 9s for
84
+ * every other turn. Those 34 turns carried 6.96M fresh input tokens, 35.1% of
85
+ * all fresh input in the window. 60s sits between the two populations and
86
+ * matches the documented 5-minute-minus-safety-margin behavior of the shortest
87
+ * provider TTL tier we run against. Override with
88
+ * `PI_LENS_PROVIDER_CACHE_TTL_MS` when a provider's TTL differs.
89
+ */
90
+ export const DEFAULT_PROVIDER_CACHE_TTL_MS = 60_000;
91
+ const _providerCacheTtl = lazyEnvNumber("PI_LENS_PROVIDER_CACHE_TTL_MS", DEFAULT_PROVIDER_CACHE_TTL_MS);
92
+ export const getProviderCacheTtlMs = _providerCacheTtl.get;
93
+ export const _resetProviderCacheTtlForTests = _providerCacheTtl._resetForTests;
94
+ /**
95
+ * A turn counts as "anomalously low" when it reads back less than half of what
96
+ * the previous turn read. The investigation's partial-break population (90
97
+ * turns, upper-bounded at 2.7M tokens) was selected by exactly this shape: a
98
+ * non-zero `cacheRead` well below the prior turn's, which a zero-read filter
99
+ * misses entirely.
100
+ */
101
+ const LOW_CACHE_READ_RATIO = 0.5;
102
+ /**
103
+ * Fresh input must exceed the estimated new content by this factor before a
104
+ * shortfall reads as provider-side eviction rather than as our own payload.
105
+ * The investigation upper-bounds a whole session's injections at ~2.7 tokens
106
+ * per turn, so fresh input several times the measured new content cannot be
107
+ * explained by what pi-lens added.
108
+ */
109
+ const PARTIAL_EVICTION_INPUT_FACTOR = 4;
110
+ /**
111
+ * Floor for the same rule, so a turn with almost no new content does not read
112
+ * as eviction on a handful of tokens.
113
+ */
114
+ const PARTIAL_EVICTION_MIN_FRESH_TOKENS = 512;
115
+ /**
116
+ * Cap on the per-session character accumulators used for attribution, and on a
117
+ * single transcript-growth measurement feeding them.
118
+ *
119
+ * This is deliberately NOT `MAX_INJECTED_CHARS`. That 16 KiB cap exists to keep
120
+ * the reported injection size small, and pi-lens injections average about 2.7
121
+ * estimated tokens per turn, so it is never reached in practice. Transcript
122
+ * growth is a different population: one ordinary tool result can add 20,000
123
+ * characters in a single turn. Measuring growth through the injection cap
124
+ * latched `attributionCharsCapped` on a routine turn and suppressed the
125
+ * `partial-eviction` verdict, which made the verdict near-unreachable (#1071
126
+ * review round 1, F2).
127
+ */
128
+ const MAX_ATTRIBUTION_CHARS = 262_144;
129
+ /** Byte companion to {@link MAX_ATTRIBUTION_CHARS}, at four bytes per char. */
130
+ const MAX_ATTRIBUTION_BYTES = 1_048_576;
131
+ function emptyCacheMissCauses() {
132
+ return {
133
+ "ttl-expired": 0,
134
+ "prefix-broke": 0,
135
+ "partial-eviction": 0,
136
+ "model-provider-changed": 0,
137
+ unknown: 0,
138
+ };
139
+ }
140
+ function emptyUnknownEvidenceReasons() {
141
+ return {
142
+ "no-prior-sample": 0,
143
+ "cache-read-unavailable": 0,
144
+ "malformed-provider-usage": 0,
145
+ "request-correlation-unavailable": 0,
146
+ "request-evidence-incomplete": 0,
147
+ "sequence-hash-truncated": 0,
148
+ "model-provider-unavailable": 0,
149
+ "provider-reported-zero-stable-prefix": 0,
150
+ "provider-reported-low-read-stable-prefix": 0,
151
+ "no-local-explanation": 0,
152
+ };
153
+ }
154
+ function newCacheUsageSummary() {
155
+ return {
156
+ usageRecords: 0,
157
+ cacheHits: 0,
158
+ missObservations: 0,
159
+ cacheMissCauses: emptyCacheMissCauses(),
160
+ unknownEvidenceReasons: emptyUnknownEvidenceReasons(),
161
+ };
162
+ }
163
+ const attributionBySession = new Map();
164
+ function newAttributionState() {
165
+ return {
166
+ prefixBrokeSinceLastUsage: false,
167
+ prefixStableSinceLastUsage: false,
168
+ sequenceHashTruncatedSinceLastUsage: false,
169
+ requestEvidenceIncompleteSinceLastUsage: false,
170
+ injectedCharsSinceLastUsage: 0,
171
+ newTranscriptCharsSinceLastUsage: 0,
172
+ attributionCharsCapped: false,
173
+ summary: newCacheUsageSummary(),
174
+ };
175
+ }
176
+ /**
177
+ * Fetch (creating if absent) one session's attribution state, refreshing LRU
178
+ * recency and evicting the oldest entries past the cap — same bounding as
179
+ * {@link recordSessionHash}, so a long-lived process cycling through sessions
180
+ * cannot grow this map without limit.
181
+ */
182
+ function attributionFor(key) {
183
+ const existing = attributionBySession.get(key);
184
+ const state = existing ?? newAttributionState();
185
+ attributionBySession.delete(key);
186
+ attributionBySession.set(key, state);
187
+ while (attributionBySession.size > MAX_TRACKED_SESSIONS) {
188
+ const oldest = attributionBySession.keys().next().value;
189
+ if (oldest === undefined)
190
+ break;
191
+ attributionBySession.delete(oldest);
192
+ }
193
+ return state;
194
+ }
195
+ /**
196
+ * Fold one `context` observation into the session's attribution state: how much
197
+ * pi-lens injected, and how much the transcript itself grew since the previous
198
+ * observation. The transcript delta is what the "far above the injected and new
199
+ * content" rule measures fresh input against. The first observation in a
200
+ * session only anchors the message-count baseline, because there is no earlier
201
+ * point to measure growth from.
202
+ */
203
+ function recordContextAttribution(key, existingMessages, injectedSize) {
204
+ const state = attributionFor(key);
205
+ const baseline = Math.min(state.lastObservedMessageCount ?? existingMessages.length, existingMessages.length);
206
+ const grown = measureTranscriptGrowth(existingMessages.slice(baseline));
207
+ state.injectedCharsSinceLastUsage = Math.min(MAX_ATTRIBUTION_CHARS, state.injectedCharsSinceLastUsage + injectedSize.chars);
208
+ state.newTranscriptCharsSinceLastUsage = Math.min(MAX_ATTRIBUTION_CHARS, state.newTranscriptCharsSinceLastUsage + grown.chars);
209
+ if (injectedSize.capped ||
210
+ grown.capped ||
211
+ state.injectedCharsSinceLastUsage >= MAX_ATTRIBUTION_CHARS ||
212
+ state.newTranscriptCharsSinceLastUsage >= MAX_ATTRIBUTION_CHARS) {
213
+ // The accumulators are now floors, not totals. The verdict rules must not
214
+ // treat a floor as a measured value.
215
+ state.attributionCharsCapped = true;
216
+ }
217
+ state.lastObservedMessageCount = existingMessages.length;
218
+ // #1071 review round 1, F1: this handler runs at REQUEST time, which is when
219
+ // the provider looks the prompt cache up. Stamping it here is what lets the
220
+ // gap exclude the response's own generation time.
221
+ state.lastContextAtMs = Date.now();
222
+ }
223
+ /**
224
+ * Idle milliseconds before this turn's provider request.
225
+ *
226
+ * Measuring `message_end` to `message_end` would fold the response's own
227
+ * generation time into the gap, and generation dominates: over a real corpus
228
+ * the median generation time is 7,407 ms against a median true idle of 228 ms,
229
+ * a factor of 30. At the 60s threshold, 8.7% of consecutive pairs measure over
230
+ * on the message-end basis while only 3.3% were truly idle over. That is a 3.4x
231
+ * inflation of `ttl-expired`, which would have confirmed the investigation's
232
+ * hypothesis by construction (#1071 review round 1, F1).
233
+ */
234
+ function resolveInterTurnGap(state, nowMs) {
235
+ if (state.lastUsageAtMs === undefined) {
236
+ return { gapMs: null, basis: "no-prior-turn" };
237
+ }
238
+ if (state.lastContextAtMs !== undefined) {
239
+ return {
240
+ gapMs: Math.max(0, state.lastContextAtMs - state.lastUsageAtMs),
241
+ basis: "request-time",
242
+ };
243
+ }
244
+ return {
245
+ gapMs: Math.max(0, nowMs - state.lastUsageAtMs),
246
+ basis: "message-end-fallback",
247
+ };
248
+ }
249
+ function estimateTokens(chars) {
250
+ return Math.ceil(chars / CHARS_PER_ESTIMATED_TOKEN);
251
+ }
252
+ function readableTokenCount(value) {
253
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
254
+ ? value
255
+ : undefined;
256
+ }
257
+ function readNumericEvidence(record, key) {
258
+ let raw;
259
+ try {
260
+ raw = record[key];
261
+ }
262
+ catch {
263
+ return { status: "malformed" };
264
+ }
265
+ if (raw === undefined)
266
+ return { status: "absent" };
267
+ const value = readableTokenCount(raw);
268
+ return value === undefined
269
+ ? { status: "malformed" }
270
+ : { status: "available", value };
271
+ }
272
+ const MAX_LOGGED_IDENTITY_CHARS = 200;
273
+ /**
274
+ * Keep the human-readable identity bounded while comparing a SHA-256 digest of
275
+ * the FULL normalized value. Comparing the display prefix would make two long
276
+ * provider/model ids that differ after the cap collide (#1996 review).
277
+ */
278
+ function identityEvidence(value) {
279
+ if (typeof value !== "string")
280
+ return { status: "unavailable" };
281
+ const normalized = value.trim();
282
+ if (!normalized)
283
+ return { status: "unavailable" };
284
+ const truncated = normalized.length > MAX_LOGGED_IDENTITY_CHARS;
285
+ return {
286
+ display: normalized.slice(0, MAX_LOGGED_IDENTITY_CHARS),
287
+ hash: createHash("sha256").update(normalized).digest("hex"),
288
+ status: truncated ? "truncated" : "complete",
289
+ };
290
+ }
291
+ /**
292
+ * Classify a turn's prompt-cache shortfall. Returns `null` when no comparable
293
+ * miss exists: no prior readable usage record or a healthy read. The caller
294
+ * separately records why an unreadable/first zero sample could not be
295
+ * classified, so those evidence gaps remain visible in the session summary.
296
+ *
297
+ * Rules, in the order they are applied, each stated with the evidence behind
298
+ * it (2026-08-21 investigation, 63 sessions, 2,288 turns, 94.2% overall hit
299
+ * rate):
300
+ *
301
+ * 1. `prefix-broke` — a `cache_prefix_break` CHANGE fired in this session
302
+ * since the previous usage record. This is a DIRECT local observation of
303
+ * the cause, so it outranks the timing heuristics below. The window was
304
+ * byte-stable throughout, so this verdict should stay rare; if it does
305
+ * not, #1016's fix has regressed.
306
+ * 2. `model-provider-changed` — both adjacent identities were present and
307
+ * differ. This is direct local evidence and outranks timing heuristics.
308
+ * 3. Evidence gates — heuristic causes require a REQUEST-time observation,
309
+ * a complete bounded sequence hash, adjacent full provider/model identity,
310
+ * and well-formed provider usage numbers.
311
+ * Missing evidence returns a specific unknown reason; it never lets a
312
+ * plausible but unproved heuristic win.
313
+ * 4. `ttl-expired` — a REQUEST-time gap reached the configured provider TTL.
314
+ * A message-end fallback includes generation time and cannot prove this.
315
+ * 5. `partial-eviction` — a non-zero but low read, where fresh input runs far
316
+ * above the new content we measured. pi-lens injections average ~2.7
317
+ * estimated tokens per turn, so they cannot account for a multiple of the
318
+ * measured content; the remainder is provider-side eviction, typically at
319
+ * a context limit. Suppressed when the character accumulators were capped,
320
+ * because a floor estimate would manufacture the verdict.
321
+ * 6. `unknown` — a real shortfall with no local explanation. A bounded
322
+ * `cacheMissUnknownReason` names the missing or conflicting evidence;
323
+ * provider behavior is never guessed.
324
+ */
325
+ function classifyCacheMiss(args) {
326
+ const cacheRead = readableTokenCount(args.cacheRead);
327
+ if (cacheRead === undefined)
328
+ return null;
329
+ // No baseline turn in this session: a first turn reads zero because nothing
330
+ // is cached yet, not because anything went wrong.
331
+ const priorCacheRead = readableTokenCount(args.priorCacheRead);
332
+ if (priorCacheRead === undefined) {
333
+ return null;
334
+ }
335
+ let kind;
336
+ if (cacheRead === 0) {
337
+ kind = "zero-read";
338
+ }
339
+ else if (priorCacheRead > 0 &&
340
+ cacheRead < priorCacheRead * LOW_CACHE_READ_RATIO) {
341
+ kind = "low-read";
342
+ }
343
+ else {
344
+ return null;
345
+ }
346
+ // A missing stable session id makes every cross-event baseline ambiguous. Do
347
+ // not let the shared fallback bucket turn another session's evidence into a
348
+ // cause claim (#1996 concurrent primary/secondary invariant).
349
+ if (!args.stableSessionIdentity) {
350
+ return {
351
+ kind,
352
+ cause: "unknown",
353
+ unknownReason: "request-correlation-unavailable",
354
+ };
355
+ }
356
+ if (args.prefixBroke)
357
+ return { kind, cause: "prefix-broke" };
358
+ if (args.modelProviderChanged) {
359
+ return { kind, cause: "model-provider-changed" };
360
+ }
361
+ // TTL and transcript-growth claims need the request-side context observation.
362
+ // A response-time fallback includes generation and can only explain why the
363
+ // verdict stayed unknown; it cannot establish cache expiry.
364
+ if (args.gapBasis !== "request-time") {
365
+ return {
366
+ kind,
367
+ cause: "unknown",
368
+ unknownReason: "request-correlation-unavailable",
369
+ };
370
+ }
371
+ // TTL and eviction are heuristics. They are only evidence-backed if the
372
+ // bounded request observation is complete and both adjacent usage records
373
+ // identify the same provider/model. Otherwise an unseen prompt or identity
374
+ // change remains a competing explanation, so classification must fail closed.
375
+ if (args.requestEvidenceIncomplete) {
376
+ return {
377
+ kind,
378
+ cause: "unknown",
379
+ unknownReason: "request-evidence-incomplete",
380
+ };
381
+ }
382
+ if (args.sequenceHashTruncated) {
383
+ return {
384
+ kind,
385
+ cause: "unknown",
386
+ unknownReason: "sequence-hash-truncated",
387
+ };
388
+ }
389
+ if (args.providerUsageMalformed) {
390
+ return {
391
+ kind,
392
+ cause: "unknown",
393
+ unknownReason: "malformed-provider-usage",
394
+ };
395
+ }
396
+ if (!args.modelProviderEvidenceAvailable) {
397
+ return {
398
+ kind,
399
+ cause: "unknown",
400
+ unknownReason: "model-provider-unavailable",
401
+ };
402
+ }
403
+ if (args.interTurnGapMs !== null && args.interTurnGapMs >= args.ttlMs) {
404
+ return { kind, cause: "ttl-expired" };
405
+ }
406
+ if (kind === "low-read" &&
407
+ !args.attributionCharsCapped &&
408
+ args.input >
409
+ Math.max(PARTIAL_EVICTION_MIN_FRESH_TOKENS, args.estimatedNewTokens * PARTIAL_EVICTION_INPUT_FACTOR)) {
410
+ return { kind, cause: "partial-eviction" };
411
+ }
412
+ if (args.prefixStable) {
413
+ return {
414
+ kind,
415
+ cause: "unknown",
416
+ unknownReason: kind === "zero-read"
417
+ ? "provider-reported-zero-stable-prefix"
418
+ : "provider-reported-low-read-stable-prefix",
419
+ };
420
+ }
421
+ return {
422
+ kind,
423
+ cause: "unknown",
424
+ unknownReason: "no-local-explanation",
425
+ };
426
+ }
427
+ function recordCacheUsageSummary(args) {
428
+ const summary = args.state.summary;
429
+ summary.usageRecords += 1;
430
+ let unknownReasonRecorded = false;
431
+ if (args.unknownReason === "malformed-provider-usage") {
432
+ summary.unknownEvidenceReasons["malformed-provider-usage"] += 1;
433
+ unknownReasonRecorded = true;
434
+ }
435
+ if (args.cacheRead === undefined) {
436
+ if (!unknownReasonRecorded) {
437
+ summary.unknownEvidenceReasons["cache-read-unavailable"] += 1;
438
+ }
439
+ return;
440
+ }
441
+ if (args.priorCacheRead === undefined) {
442
+ if (args.cacheRead === 0) {
443
+ summary.missObservations += 1;
444
+ if (!unknownReasonRecorded) {
445
+ summary.unknownEvidenceReasons["no-prior-sample"] += 1;
446
+ }
447
+ }
448
+ else {
449
+ summary.cacheHits += 1;
450
+ }
451
+ return;
452
+ }
453
+ if (!args.verdict) {
454
+ summary.cacheHits += 1;
455
+ return;
456
+ }
457
+ summary.missObservations += 1;
458
+ summary.cacheMissCauses[args.verdict.cause] += 1;
459
+ if (args.verdict.cause === "unknown" &&
460
+ args.unknownReason &&
461
+ !unknownReasonRecorded) {
462
+ summary.unknownEvidenceReasons[args.unknownReason] += 1;
463
+ }
464
+ }
46
465
  function boundedHashScalar(value, state) {
47
466
  if (value === null)
48
467
  return "null";
@@ -80,16 +499,18 @@ function boundedHashObject(value, depth, seen, state) {
80
499
  return `object:${keys.length}:{${fields.join(",")}}`;
81
500
  }
82
501
  function boundedHashValue(value, depth = 0, seen = new Set(), state) {
83
- const hashState = state ?? { contentTruncated: false };
502
+ const hashState = state ?? { contentTruncated: false, incomplete: false };
84
503
  if (value === null || typeof value !== "object") {
85
504
  return boundedHashScalar(value, hashState);
86
505
  }
87
506
  if (depth >= 3) {
88
507
  hashState.contentTruncated = true;
89
- return Object.prototype.toString.call(value);
508
+ return "[depth-limit]";
90
509
  }
91
- if (seen.has(value))
510
+ if (seen.has(value)) {
511
+ hashState.incomplete = true;
92
512
  return "[cycle]";
513
+ }
93
514
  seen.add(value);
94
515
  try {
95
516
  if (Array.isArray(value)) {
@@ -98,6 +519,7 @@ function boundedHashValue(value, depth = 0, seen = new Set(), state) {
98
519
  return boundedHashObject(value, depth, seen, hashState);
99
520
  }
100
521
  catch {
522
+ hashState.incomplete = true;
101
523
  return "[unreadable]";
102
524
  }
103
525
  finally {
@@ -106,82 +528,127 @@ function boundedHashValue(value, depth = 0, seen = new Set(), state) {
106
528
  }
107
529
  function hashMessageSequence(messages) {
108
530
  const hash = createHash("sha256");
109
- const state = { contentTruncated: false };
110
- hash.update(`message-count:${messages.length};`);
111
- const sampled = Math.min(messages.length, MAX_HASHED_MESSAGES);
531
+ const state = {
532
+ contentTruncated: false,
533
+ incomplete: false,
534
+ };
535
+ let messageCount;
536
+ try {
537
+ messageCount = messages.length;
538
+ if (!Number.isSafeInteger(messageCount) || messageCount < 0) {
539
+ throw new Error("invalid message count");
540
+ }
541
+ }
542
+ catch {
543
+ state.incomplete = true;
544
+ hash.update("message-count:[unreadable];");
545
+ return {
546
+ hash: hash.digest("hex"),
547
+ truncated: false,
548
+ contentTruncated: false,
549
+ incomplete: true,
550
+ };
551
+ }
552
+ hash.update(`message-count:${messageCount};`);
553
+ const sampled = Math.min(messageCount, MAX_HASHED_MESSAGES);
112
554
  for (let i = 0; i < sampled; i++) {
113
- const message = messages[i];
114
555
  const seen = new Set();
115
- hash.update(`${i}|role:${boundedHashValue(message?.role, 0, seen, state)}|content:${boundedHashValue(message?.content, 0, seen, state)};`);
556
+ let role;
557
+ let content;
558
+ try {
559
+ const message = messages[i];
560
+ role = message?.role;
561
+ content = message?.content;
562
+ }
563
+ catch {
564
+ state.incomplete = true;
565
+ role = "[unreadable]";
566
+ content = "[unreadable]";
567
+ }
568
+ hash.update(`${i}|role:${boundedHashValue(role, 0, seen, state)}|content:${boundedHashValue(content, 0, seen, state)};`);
116
569
  }
117
- const truncated = messages.length > sampled;
570
+ const truncated = messageCount > sampled;
118
571
  if (truncated)
119
572
  hash.update(`truncated-after:${sampled};`);
120
573
  return {
121
574
  hash: hash.digest("hex"),
122
575
  truncated,
123
576
  contentTruncated: state.contentTruncated,
577
+ incomplete: state.incomplete,
124
578
  };
125
579
  }
126
- function messageTextSize(content) {
580
+ /** Bounds for reporting an injected payload's size in the record. */
581
+ const INJECTED_LIMITS = {
582
+ maxChars: MAX_INJECTED_CHARS,
583
+ maxBytes: MAX_INJECTED_BYTES,
584
+ };
585
+ /** Bounds for the transcript-growth accumulator that feeds the verdict. */
586
+ const TRANSCRIPT_LIMITS = {
587
+ maxChars: MAX_ATTRIBUTION_CHARS,
588
+ maxBytes: MAX_ATTRIBUTION_BYTES,
589
+ };
590
+ function messageTextSize(content, limits) {
127
591
  if (typeof content === "string") {
128
- const chars = Math.min(content.length, MAX_INJECTED_CHARS);
592
+ const chars = Math.min(content.length, limits.maxChars);
129
593
  return {
130
594
  chars,
131
- bytes: Math.min(Buffer.byteLength(content.slice(0, chars), "utf8"), MAX_INJECTED_BYTES),
595
+ bytes: Math.min(Buffer.byteLength(content.slice(0, chars), "utf8"), limits.maxBytes),
132
596
  };
133
597
  }
134
- const bounded = boundedHashValue(content);
598
+ const bounded = boundedHashValue(content, 0, new Set(), {
599
+ contentTruncated: false,
600
+ incomplete: false,
601
+ });
135
602
  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),
603
+ chars: Math.min(bounded.length, limits.maxChars),
604
+ bytes: Math.min(Buffer.byteLength(bounded.slice(0, limits.maxChars), "utf8"), limits.maxBytes),
138
605
  };
139
606
  }
140
- function measureInjectedMessages(messages) {
607
+ function measureMessages(messages, limits) {
141
608
  let chars = 0;
142
609
  let bytes = 0;
143
610
  const measuredCount = Math.min(messages.length, MAX_REPORTED_MESSAGES);
144
611
  for (let i = 0; i < measuredCount; i++) {
145
612
  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)
613
+ const size = messageTextSize(message?.content, limits);
614
+ chars = Math.min(limits.maxChars, chars + size.chars);
615
+ bytes = Math.min(limits.maxBytes, bytes + size.bytes);
616
+ if (chars === limits.maxChars || bytes === limits.maxBytes)
150
617
  break;
151
618
  }
152
619
  return {
153
620
  chars,
154
621
  bytes,
155
- capped: chars >= MAX_INJECTED_CHARS || bytes >= MAX_INJECTED_BYTES,
622
+ capped: chars >= limits.maxChars || bytes >= limits.maxBytes,
156
623
  };
157
624
  }
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;
625
+ /** Size of an injected payload, bounded for reporting. */
626
+ function measureInjectedMessages(messages) {
627
+ return measureMessages(messages, INJECTED_LIMITS);
168
628
  }
169
- function resolvePrefixObservation(truncated, observation) {
170
- if (truncated)
171
- return "unknown";
172
- return observation ?? "empty";
629
+ /**
630
+ * Size of the transcript's own growth, bounded far higher. A routine tool
631
+ * result is an order of magnitude past the injection cap, and measuring it
632
+ * through that cap made `partial-eviction` near-unreachable (#1071 review
633
+ * round 1, F2).
634
+ */
635
+ function measureTranscriptGrowth(messages) {
636
+ return measureMessages(messages, TRANSCRIPT_LIMITS);
173
637
  }
174
- function prefixBaselineForObservation(observation) {
175
- if (observation === "baseline")
176
- return true;
177
- if (observation === "empty" || observation === "unknown")
178
- return null;
179
- return false;
638
+ function sessionKey(sessionId) {
639
+ return sessionId?.trim() ? sessionId.trim() : NO_SESSION_KEY;
180
640
  }
181
- function firstMessageChangeFor(truncated, before, after) {
182
- if (truncated)
183
- return "unknown";
184
- return before !== after ? "changed" : "unchanged";
641
+ /**
642
+ * A stable host session id is the only safe cross-event identity. When it is
643
+ * absent, keep primary and concurrent-secondary observations in separate
644
+ * buckets and let classification fail closed instead of allowing a secondary
645
+ * to overwrite the primary's baseline.
646
+ */
647
+ function attributionKey(sessionId, sessionRole) {
648
+ const key = sessionKey(sessionId);
649
+ if (key !== NO_SESSION_KEY)
650
+ return key;
651
+ return `${sessionRole ?? "unknown"}:${key}`;
185
652
  }
186
653
  /**
187
654
  * Log one bounded request-side observation for every `context` call. This is
@@ -197,34 +664,40 @@ export function observeCacheContext(args) {
197
664
  try {
198
665
  const existingMessages = args.existingMessages ?? [];
199
666
  const resultMessages = args.resultMessages ?? existingMessages;
200
- const injectedMessages = args.injectedMessages ?? [];
667
+ // Drop empty slices before deriving anything: a source that contributed
668
+ // no message is not a contributor, and listing it would overstate the
669
+ // payload's provenance.
670
+ const injectionSlices = (args.injectionSlices ?? []).filter((slice) => slice.messages.length > 0);
671
+ const injectedMessages = injectionSlices.flatMap((slice) => slice.messages);
201
672
  const placement = args.placement ?? "none";
202
673
  const beforeSequence = hashMessageSequence(existingMessages);
203
674
  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
675
  const sizes = measureInjectedMessages(injectedMessages);
676
+ // Per-source split of a mixed payload (#1071 AC 2). The old record named
677
+ // which sources fired but attributed cost by CALL, so a turn carrying
678
+ // turn-findings plus an agent nudge was one undivided number.
679
+ const sourceBreakdown = injectionSlices.map((slice) => {
680
+ const sliceSize = measureInjectedMessages(slice.messages);
681
+ return {
682
+ source: slice.source,
683
+ messageCount: Math.min(slice.messages.length, MAX_REPORTED_MESSAGES),
684
+ chars: sliceSize.chars,
685
+ bytes: sliceSize.bytes,
686
+ estimatedTokens: estimateTokens(sliceSize.chars),
687
+ countsCapped: sliceSize.capped,
688
+ };
689
+ });
226
690
  const messageCountCapped = existingMessages.length > MAX_REPORTED_MESSAGES ||
227
691
  resultMessages.length > MAX_REPORTED_MESSAGES;
692
+ recordContextAttribution(attributionKey(args.sessionId, args.sessionRole), existingMessages, sizes);
693
+ const state = attributionFor(attributionKey(args.sessionId, args.sessionRole));
694
+ state.sequenceHashTruncatedSinceLastUsage ||=
695
+ beforeSequence.truncated ||
696
+ beforeSequence.contentTruncated ||
697
+ afterSequence.truncated ||
698
+ afterSequence.contentTruncated;
699
+ state.requestEvidenceIncompleteSinceLastUsage ||=
700
+ beforeSequence.incomplete || afterSequence.incomplete;
228
701
  logLatency({
229
702
  type: "phase",
230
703
  filePath: "<pi-lens>",
@@ -240,43 +713,44 @@ export function observeCacheContext(args) {
240
713
  ? { turnScope: SECONDARY_TURN_SCOPE }
241
714
  : { turnIndex: args.turnIndex, turnScope: PROCESS_TURN_SCOPE }),
242
715
  injectionEnabled: args.injectionEnabled,
243
- injectionSources: Array.from(args.injectionSources ?? []),
716
+ injectionSources: sourceBreakdown.map((entry) => entry.source),
717
+ injectionSourceBreakdown: sourceBreakdown,
718
+ injectionOccurred: injectedMessages.length > 0,
244
719
  injectedMessageCount: Math.min(injectedMessages.length, MAX_REPORTED_MESSAGES),
245
720
  injectedMessageCountCapped: injectedMessages.length > MAX_REPORTED_MESSAGES,
246
721
  injectedChars: sizes.chars,
247
722
  injectedBytes: sizes.bytes,
723
+ injectedEstimatedTokens: estimateTokens(sizes.chars),
724
+ injectedTokenBasis: "chars-per-token-4-estimate-not-provider-measured",
248
725
  injectedCountsCapped: sizes.capped,
249
726
  existingMessageCount: Math.min(existingMessages.length, MAX_REPORTED_MESSAGES),
250
727
  resultMessageCount: Math.min(resultMessages.length, MAX_REPORTED_MESSAGES),
251
728
  messageCountCapped,
252
729
  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
730
  beforeSequenceHash: beforeSequence.hash,
264
731
  afterSequenceHash: afterSequence.hash,
265
- beforePrefixHash: beforePrefix.hash,
266
- afterPrefixHash: afterPrefix.hash,
267
732
  sequenceHashTruncated: beforeSequence.truncated ||
268
733
  afterSequence.truncated ||
269
734
  beforeSequence.contentTruncated ||
270
735
  afterSequence.contentTruncated,
271
736
  sequenceMessageCountTruncated: beforeSequence.truncated || afterSequence.truncated,
272
737
  sequenceContentHashTruncated: beforeSequence.contentTruncated || afterSequence.contentTruncated,
273
- prefixHashTruncated,
274
- prefixMessageCountTruncated: beforePrefix.truncated || afterPrefix.truncated,
275
- prefixContentHashTruncated: beforePrefix.contentTruncated || afterPrefix.contentTruncated,
738
+ sequenceHashIncomplete: beforeSequence.incomplete || afterSequence.incomplete,
276
739
  },
277
740
  });
278
741
  }
279
742
  catch (err) {
743
+ // The context event still occurred at request time. Preserve that clock but
744
+ // fail the evidence closed so a later usage row cannot infer TTL/eviction
745
+ // from an observation whose hashing/measurement never completed.
746
+ try {
747
+ const state = attributionFor(attributionKey(args.sessionId, args.sessionRole));
748
+ state.lastContextAtMs = Date.now();
749
+ state.requestEvidenceIncompleteSinceLastUsage = true;
750
+ }
751
+ catch {
752
+ // The outer contract is no-throw; there is no safe attribution key left.
753
+ }
280
754
  args.dbg?.(`cache-context: failed to log context observation: ${err}`);
281
755
  }
282
756
  }
@@ -300,21 +774,170 @@ export function logCacheUsage(message, dbg, context) {
300
774
  const usage = msg.usage;
301
775
  if (!usage || typeof usage !== "object")
302
776
  return;
303
- const u = usage;
777
+ // SAFETY: `usage` is verified non-null object above; widening to an
778
+ // indexable record is only so the numeric-evidence readers can probe
779
+ // each field defensively. Every read re-validates its own type.
780
+ const usageRecord = usage;
781
+ // #1071: attribute the shortfall in-process, from state this module already
782
+ // keeps, so nothing re-reads latency.log.
783
+ const stateKey = attributionKey(context?.sessionId, context?.sessionRole);
784
+ const state = attributionFor(stateKey);
785
+ const nowMs = Date.now();
786
+ const gap = resolveInterTurnGap(state, nowMs);
787
+ const interTurnGapMs = gap.gapMs;
788
+ const ttlMs = getProviderCacheTtlMs();
789
+ const inputEvidence = readNumericEvidence(usageRecord, "input");
790
+ const outputEvidence = readNumericEvidence(usageRecord, "output");
791
+ const cacheReadEvidence = readNumericEvidence(usageRecord, "cacheRead");
792
+ const cacheWriteEvidence = readNumericEvidence(usageRecord, "cacheWrite");
793
+ let costEvidence;
794
+ try {
795
+ const cost = usageRecord.cost;
796
+ costEvidence =
797
+ cost === undefined
798
+ ? { status: "absent" }
799
+ : cost && typeof cost === "object"
800
+ ? readNumericEvidence(cost, "total")
801
+ : { status: "malformed" };
802
+ }
803
+ catch {
804
+ costEvidence = { status: "malformed" };
805
+ }
806
+ const usageEvidenceFields = [
807
+ ["input", inputEvidence],
808
+ ["output", outputEvidence],
809
+ ["cacheRead", cacheReadEvidence],
810
+ ["cacheWrite", cacheWriteEvidence],
811
+ ["cost.total", costEvidence],
812
+ ];
813
+ const malformedProviderUsageFields = usageEvidenceFields
814
+ .filter(([, evidence]) => evidence.status === "malformed")
815
+ .map(([field]) => field);
816
+ const providerUsageMalformed = malformedProviderUsageFields.length > 0;
817
+ const cacheRead = cacheReadEvidence.value;
818
+ const priorCacheReadValue = readableTokenCount(state.lastCacheRead);
819
+ // SAFETY: `msg` passed the AssistantMessageLike role check above; the
820
+ // record view backs only the guarded identity-field probes below, each
821
+ // re-validated by `identityEvidence` at its own boundary.
822
+ const messageRecord = msg;
823
+ const safeMessageField = (key) => {
824
+ try {
825
+ return identityEvidence(messageRecord[key]);
826
+ }
827
+ catch {
828
+ return { status: "unavailable" };
829
+ }
830
+ };
831
+ const currentProvider = safeMessageField("provider");
832
+ const responseModel = safeMessageField("responseModel");
833
+ const currentModel = responseModel.hash !== undefined
834
+ ? responseModel
835
+ : safeMessageField("model");
836
+ const modelProviderEvidenceAvailable = Boolean(currentProvider.hash &&
837
+ currentModel.hash &&
838
+ state.lastProviderIdentityHash &&
839
+ state.lastModelIdentityHash);
840
+ const modelProviderChanged = Boolean(modelProviderEvidenceAvailable &&
841
+ (currentProvider.hash !== state.lastProviderIdentityHash ||
842
+ currentModel.hash !== state.lastModelIdentityHash));
843
+ const estimatedNewTokens = estimateTokens(state.injectedCharsSinceLastUsage +
844
+ state.newTranscriptCharsSinceLastUsage);
845
+ const verdict = classifyCacheMiss({
846
+ cacheRead,
847
+ // Missing/malformed input cannot prove fresh-input pressure. The explicit
848
+ // malformed gate below also keeps every heuristic fail-closed.
849
+ input: inputEvidence.value ?? 0,
850
+ priorCacheRead: priorCacheReadValue,
851
+ interTurnGapMs,
852
+ gapBasis: gap.basis,
853
+ ttlMs,
854
+ prefixBroke: state.prefixBrokeSinceLastUsage,
855
+ prefixStable: state.prefixStableSinceLastUsage,
856
+ sequenceHashTruncated: state.sequenceHashTruncatedSinceLastUsage,
857
+ requestEvidenceIncomplete: state.requestEvidenceIncompleteSinceLastUsage,
858
+ stableSessionIdentity: sessionKey(context?.sessionId) !== NO_SESSION_KEY,
859
+ modelProviderChanged,
860
+ modelProviderEvidenceAvailable,
861
+ providerUsageMalformed,
862
+ estimatedNewTokens,
863
+ attributionCharsCapped: state.attributionCharsCapped,
864
+ });
865
+ const unknownReason = providerUsageMalformed
866
+ ? "malformed-provider-usage"
867
+ : cacheRead === undefined
868
+ ? "cache-read-unavailable"
869
+ : priorCacheReadValue === undefined && cacheRead === 0
870
+ ? "no-prior-sample"
871
+ : verdict?.unknownReason;
872
+ recordCacheUsageSummary({
873
+ state,
874
+ cacheRead,
875
+ priorCacheRead: priorCacheReadValue,
876
+ verdict,
877
+ unknownReason,
878
+ });
879
+ const priorCacheRead = priorCacheReadValue ?? null;
880
+ const injectedCharsSinceLastTurn = state.injectedCharsSinceLastUsage;
881
+ const newTranscriptCharsSinceLastTurn = state.newTranscriptCharsSinceLastUsage;
882
+ const attributionCharsCapped = state.attributionCharsCapped;
883
+ // This record is the turn boundary: reset the per-turn accumulators and
884
+ // re-arm the prefix-break flag so the next verdict describes the NEXT gap.
885
+ state.lastUsageAtMs = nowMs;
886
+ // A record whose `cacheRead` is absent or non-finite carries no baseline.
887
+ // Clear the stored one rather than keeping it: the next turn's gap would be
888
+ // one turn long while its baseline was two turns old, and a verdict built
889
+ // on that mismatch is worse than no verdict (#1071 review round 1, F4).
890
+ state.lastCacheRead = cacheRead;
891
+ state.lastProviderIdentityHash = currentProvider.hash;
892
+ state.lastModelIdentityHash = currentModel.hash;
893
+ state.prefixBrokeSinceLastUsage = false;
894
+ state.prefixStableSinceLastUsage = false;
895
+ state.sequenceHashTruncatedSinceLastUsage = false;
896
+ state.requestEvidenceIncompleteSinceLastUsage = false;
897
+ state.injectedCharsSinceLastUsage = 0;
898
+ state.newTranscriptCharsSinceLastUsage = 0;
899
+ state.attributionCharsCapped = false;
900
+ // Clear the request stamp too: the next turn measures from ITS request, and
901
+ // a turn whose `context` call pi-lens never saw must fall back rather than
902
+ // reuse this one.
903
+ state.lastContextAtMs = undefined;
304
904
  logLatency({
305
905
  type: "phase",
306
906
  filePath: "<pi-lens>",
307
907
  phase: "cache_usage",
308
908
  durationMs: 0,
309
909
  metadata: {
310
- provider: typeof msg.provider === "string" ? msg.provider : undefined,
311
- model: typeof msg.model === "string" ? msg.model : undefined,
312
- cacheRead: u.cacheRead,
313
- cacheWrite: u.cacheWrite,
314
- input: u.input,
315
- output: u.output,
910
+ provider: currentProvider.display,
911
+ model: currentModel.display,
912
+ providerIdentityHash: currentProvider.hash,
913
+ modelIdentityHash: currentModel.hash,
914
+ providerIdentityStatus: currentProvider.status,
915
+ modelIdentityStatus: currentModel.status,
916
+ providerIdentityTruncated: currentProvider.status === "truncated",
917
+ modelIdentityTruncated: currentModel.status === "truncated",
918
+ cacheRead: cacheReadEvidence.value ?? null,
919
+ cacheWrite: cacheWriteEvidence.value ?? null,
920
+ input: inputEvidence.value ?? null,
921
+ output: outputEvidence.value ?? null,
316
922
  // `Usage.cost` is a breakdown object; the total is the headline number.
317
- cost: u.cost?.total,
923
+ cost: costEvidence.value ?? null,
924
+ providerUsageMalformedFields: malformedProviderUsageFields,
925
+ // Idle milliseconds before this turn's request, or null for the first
926
+ // record in the session. This is the field the 2026-08-21
927
+ // investigation had to reconstruct by hand-joining timestamps.
928
+ interTurnGapMs,
929
+ gapBasis: gap.basis,
930
+ // null means "nothing to explain", not "cause unknown"; `unknown` is
931
+ // the explicit no-local-explanation verdict.
932
+ cacheMissCause: verdict?.cause ?? null,
933
+ cacheMissKind: verdict?.kind ?? null,
934
+ cacheMissUnknownReason: unknownReason ?? null,
935
+ modelProviderChanged,
936
+ cacheTtlThresholdMs: ttlMs,
937
+ priorCacheRead,
938
+ injectedCharsSinceLastTurn,
939
+ newTranscriptCharsSinceLastTurn,
940
+ attributionCharsCapped,
318
941
  ...(context
319
942
  ? {
320
943
  // MessageEndEvent has no request/context id in the host API. These
@@ -381,9 +1004,10 @@ function hashFirstMessage(first) {
381
1004
  const MAX_TRACKED_SESSIONS = 32;
382
1005
  const prefixHashBySession = new Map();
383
1006
  /**
384
- * Bucket key used when no session id is available (undefined/empty). Degrades
385
- * gracefully to the old single-var semantics — one shared baseline — rather than
386
- * throwing or dropping the signal.
1007
+ * Reported key used when no session id is available (undefined/empty). Internal
1008
+ * attribution additionally separates primary and concurrent-secondary roles;
1009
+ * neither role may overwrite the other's baseline when stable identity is
1010
+ * absent.
387
1011
  */
388
1012
  const NO_SESSION_KEY = "<no-session>";
389
1013
  /**
@@ -415,10 +1039,10 @@ function recordSessionHash(key, hash) {
415
1039
  * baseline. (`runtime.telemetrySessionId` cannot be used here: per the #473
416
1040
  * guard a concurrent secondary skips `updateRuntimeIdentityFromEvent`, so that
417
1041
  * process-global singleton stays pinned to the PARENT — it would collapse
418
- * parent and subagent onto one baseline.) Residual limitation: if the host ever
419
- * does NOT supply a stable id, all such sessions collapse onto `NO_SESSION_KEY`
420
- * and behave like the old single-var (a concurrent subagent could then still
421
- * cross-contaminate) — accepted as graceful degradation, not silently perfect.
1042
+ * parent and subagent onto one baseline.) If the host does NOT supply a stable
1043
+ * id, primary and secondary roles still use separate fallback buckets, but
1044
+ * multiple same-role sessions cannot be correlated. Miss attribution therefore
1045
+ * fails closed with `request-correlation-unavailable`.
422
1046
  *
423
1047
  * Does nothing on an empty transcript (nothing to anchor a prefix to yet).
424
1048
  */
@@ -429,13 +1053,16 @@ export function observeCachePrefix(messages, turnIndex, sessionId, sessionRole,
429
1053
  const first = messages[0];
430
1054
  if (!first || typeof first !== "object")
431
1055
  return "empty";
432
- const key = sessionKey(sessionId);
1056
+ const key = attributionKey(sessionId, sessionRole);
1057
+ const reportedSessionId = sessionKey(sessionId);
433
1058
  const currentHash = hashFirstMessage(first);
434
1059
  const previousHash = prefixHashBySession.get(key);
1060
+ const state = attributionFor(key);
435
1061
  if (previousHash === undefined) {
436
1062
  // Baseline: record this session's starting prefix so a later break has a
437
1063
  // reference point in the log. `previousHash: null` marks the baseline.
438
1064
  recordSessionHash(key, currentHash);
1065
+ state.prefixStableSinceLastUsage = true;
439
1066
  logLatency({
440
1067
  type: "phase",
441
1068
  filePath: "<pi-lens>",
@@ -448,13 +1075,17 @@ export function observeCachePrefix(messages, turnIndex, sessionId, sessionRole,
448
1075
  previousHash: null,
449
1076
  currentHash,
450
1077
  baseline: true,
451
- sessionId: key,
1078
+ sessionId: reportedSessionId,
452
1079
  sessionRole,
453
1080
  },
454
1081
  });
455
1082
  return "baseline";
456
1083
  }
457
1084
  if (currentHash !== previousHash) {
1085
+ // #1071: arm the direct-cause flag the next `cache_usage` verdict reads.
1086
+ // A locally observed prefix change outranks the timing heuristics.
1087
+ state.prefixBrokeSinceLastUsage = true;
1088
+ state.prefixStableSinceLastUsage = false;
458
1089
  logLatency({
459
1090
  type: "phase",
460
1091
  filePath: "<pi-lens>",
@@ -466,11 +1097,14 @@ export function observeCachePrefix(messages, turnIndex, sessionId, sessionRole,
466
1097
  : { turnIndex, turnScope: PROCESS_TURN_SCOPE }),
467
1098
  previousHash,
468
1099
  currentHash,
469
- sessionId: key,
1100
+ sessionId: reportedSessionId,
470
1101
  sessionRole,
471
1102
  },
472
1103
  });
473
1104
  }
1105
+ if (currentHash === previousHash) {
1106
+ state.prefixStableSinceLastUsage = true;
1107
+ }
474
1108
  // Refresh recency (and update the stored hash after a break) so an active
475
1109
  // session stays warm in the LRU and isn't evicted while still in use.
476
1110
  recordSessionHash(key, currentHash);
@@ -482,17 +1116,55 @@ export function observeCachePrefix(messages, turnIndex, sessionId, sessionRole,
482
1116
  }
483
1117
  }
484
1118
  /**
485
- * Drop a single session's prefix baseline. Called from the `session_shutdown`
486
- * handler (primary path only — the concurrent-secondary guard there returns
487
- * first, and the LRU cap backstops any secondary entry left behind) so an ended
488
- * conversation's entry is reclaimed promptly rather than only when the LRU
489
- * evicts it. Idempotent and never throws.
1119
+ * Drop a single session's prefix baseline and attribution state. Called from
1120
+ * both role-specific `session_shutdown` paths so an ended conversation is
1121
+ * reclaimed promptly rather than only when the LRU evicts it. Idempotent and
1122
+ * never throws.
490
1123
  */
491
- export function clearCachePrefixSession(sessionId) {
492
- const key = sessionKey(sessionId);
1124
+ export function clearCachePrefixSession(sessionId, sessionRole) {
1125
+ const key = attributionKey(sessionId, sessionRole);
493
1126
  prefixHashBySession.delete(key);
1127
+ // The miss-attribution state has the same lifetime and the same reason to be
1128
+ // reclaimed at session end; leaving it would let a reused session id inherit
1129
+ // a stale gap baseline and a stale prefix-break flag.
1130
+ attributionBySession.delete(key);
494
1131
  }
495
- /** Clear all per-session prefix hashes. For tests / session boundaries. */
1132
+ /**
1133
+ * Emit one bounded session summary and retire its attribution state. The
1134
+ * summary contains fixed-key counters only; it never serializes transcript or
1135
+ * context content. A session with no usage records emits nothing.
1136
+ */
1137
+ export function emitCacheUsageSummaryAtSessionEnd(sessionId, sessionRole) {
1138
+ const key = attributionKey(sessionId, sessionRole);
1139
+ const state = attributionBySession.get(key);
1140
+ if (!state || state.summary.usageRecords === 0)
1141
+ return;
1142
+ logLatency({
1143
+ type: "phase",
1144
+ filePath: "<pi-lens>",
1145
+ phase: "cache_usage_summary",
1146
+ durationMs: 0,
1147
+ metadata: {
1148
+ version: 1,
1149
+ sessionId: sessionKey(sessionId),
1150
+ sessionRole,
1151
+ usageRecords: state.summary.usageRecords,
1152
+ cacheHits: state.summary.cacheHits,
1153
+ missObservations: state.summary.missObservations,
1154
+ cacheMissCauses: { ...state.summary.cacheMissCauses },
1155
+ unknownEvidenceReasons: {
1156
+ ...state.summary.unknownEvidenceReasons,
1157
+ },
1158
+ },
1159
+ });
1160
+ attributionBySession.delete(key);
1161
+ }
1162
+ /**
1163
+ * Clear all per-session prefix hashes and miss-attribution state. For tests and
1164
+ * session boundaries. Both maps are cleared together so a test can never start
1165
+ * with a half-reset session.
1166
+ */
496
1167
  export function resetCachePrefixObservation() {
497
1168
  prefixHashBySession.clear();
1169
+ attributionBySession.clear();
498
1170
  }