@herbertgao/pi-extensions 2026.8.7 → 2026.8.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/node_modules/@czottmann/pi-automode/CHANGELOG.md +24 -0
- package/node_modules/@czottmann/pi-automode/README.md +78 -112
- package/node_modules/@czottmann/pi-automode/docs/GLOSSARY.md +18 -14
- package/node_modules/@czottmann/pi-automode/docs/adr/ADR-001-permission-precedence-and-trust-boundaries.md +44 -0
- package/node_modules/@czottmann/pi-automode/docs/adr/INDEX.md +5 -0
- package/node_modules/@czottmann/pi-automode/docs/automode-classifier-flow.md +198 -104
- package/node_modules/@czottmann/pi-automode/docs/configuration.md +155 -0
- package/node_modules/@czottmann/pi-automode/docs/defaults.md +55 -14
- package/node_modules/@czottmann/pi-automode/docs/diagnostics.md +90 -0
- package/node_modules/@czottmann/pi-automode/docs/observability-logging.md +61 -26
- package/node_modules/@czottmann/pi-automode/examples/automode.local.json +5 -0
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +172 -18
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/config.ts +127 -30
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/constants.ts +3 -0
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/extension.ts +273 -31
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/log.ts +60 -5
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/paths.ts +92 -8
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/permissions.ts +203 -13
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/state.ts +1 -0
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/types.ts +11 -0
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/utils.ts +9 -1
- package/node_modules/@czottmann/pi-automode/package.json +9 -2
- package/node_modules/@czottmann/pi-automode/skills/automode-diagnostics/SKILL.md +63 -0
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +3 -3
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +3 -3
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +8 -3
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +63 -15
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +56 -29
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +78 -16
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +13 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +30 -17
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
- package/node_modules/@herbertgao/pi-handoff/CHANGELOG.md +29 -0
- package/node_modules/@herbertgao/pi-handoff/README.md +15 -18
- package/node_modules/@herbertgao/pi-handoff/package.json +11 -4
- package/node_modules/@herbertgao/pi-handoff/src/complete-text.ts +33 -0
- package/node_modules/@herbertgao/pi-handoff/src/index.ts +127 -232
- package/node_modules/@herbertgao/pi-handoff/src/session-query.ts +15 -30
- package/node_modules/@herbertgao/pi-mermaid-open/CHANGELOG.md +14 -0
- package/node_modules/@herbertgao/pi-mermaid-open/README.md +21 -4
- package/node_modules/@herbertgao/pi-mermaid-open/herdr-plugin/herdr-plugin.toml +11 -0
- package/node_modules/@herbertgao/pi-mermaid-open/herdr-plugin/viewer.mjs +305 -0
- package/node_modules/@herbertgao/pi-mermaid-open/package.json +15 -4
- package/node_modules/@herbertgao/pi-mermaid-open/src/index.ts +351 -101
- package/node_modules/@herbertgao/pi-preferred-thinking/CHANGELOG.md +12 -0
- package/node_modules/@herbertgao/pi-preferred-thinking/README.md +30 -14
- package/node_modules/@herbertgao/pi-preferred-thinking/package.json +7 -4
- package/node_modules/@herbertgao/pi-preferred-thinking/src/index.ts +140 -85
- package/node_modules/@herbertgao/pi-recap/CHANGELOG.md +12 -0
- package/node_modules/@herbertgao/pi-recap/README.md +5 -3
- package/node_modules/@herbertgao/pi-recap/package.json +9 -3
- package/node_modules/@herbertgao/pi-recap/src/index.ts +3 -7
- package/node_modules/@herbertgao/pi-recap/src/model-picker.ts +6 -19
- package/node_modules/@herbertgao/pi-recap/src/models.ts +9 -21
- package/node_modules/@herbertgao/pi-recap/src/tui.ts +1 -1
- package/node_modules/@herbertgao/pi-rename/CHANGELOG.md +49 -0
- package/node_modules/@herbertgao/pi-rename/README.md +13 -11
- package/node_modules/@herbertgao/pi-rename/package.json +13 -3
- package/node_modules/@herbertgao/pi-rename/src/index.ts +135 -59
- package/node_modules/@herbertgao/pi-rename/src/models.ts +5 -13
- package/node_modules/@herbertgao/pi-rename/src/naming.ts +2 -6
- package/node_modules/@herbertgao/pi-stash/package.json +3 -3
- package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +8 -0
- package/node_modules/@herbertgao/pi-subagents/README.md +16 -3
- package/node_modules/@herbertgao/pi-subagents/package.json +1 -1
- package/node_modules/@herbertgao/pi-subagents/src/cross-extension-rpc.ts +15 -4
- package/node_modules/@herbertgao/pi-subagents/src/index.ts +16 -0
- package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +5 -1
- package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +4 -4
- package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +154 -96
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +3 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +10 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +7 -5
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/tool-schema.md +11 -5
- package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +3 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +137 -96
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +44 -32
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/selectors/projections.ts +22 -15
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/state-reducer.ts +11 -1
- package/node_modules/@juicesharp/rpiv-ask-user-question/tool/response-envelope.ts +11 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/tool/types.ts +11 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/multi-select-view.ts +31 -17
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/preview/preview-block-renderer.ts +15 -6
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/wrapping-select.ts +15 -6
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +56 -34
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +50 -4
- package/node_modules/@narumitw/pi-btw/package.json +2 -2
- package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +44 -1856
- package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
- package/node_modules/@pi-plugins/fast-mode/package.json +4 -6
- package/node_modules/pi-lens/CHANGELOG.md +90 -0
- package/node_modules/pi-lens/README.md +106 -0
- package/node_modules/pi-lens/dist/clients/bash-file-access.js +97 -6
- package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +16 -6
- package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +14 -0
- package/node_modules/pi-lens/dist/clients/cache-observability.js +353 -76
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +22 -1
- package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +155 -0
- package/node_modules/pi-lens/dist/clients/deps/pi-tui.js +8 -2
- package/node_modules/pi-lens/dist/clients/deps/typebox.js +8 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +9 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +9 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +8 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +8 -10
- package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +10 -10
- package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +20 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +44 -11
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -15
- package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +19 -11
- package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +35 -13
- package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +8 -8
- package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +110 -23
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +12 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +117 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +16 -14
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +8 -9
- package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +22 -0
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +5 -2
- package/node_modules/pi-lens/dist/clients/language-profile.js +7 -2
- package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +27 -2
- package/node_modules/pi-lens/dist/clients/lsp/client.js +144 -5
- package/node_modules/pi-lens/dist/clients/lsp/index.js +151 -6
- package/node_modules/pi-lens/dist/clients/module-report.js +14 -1
- package/node_modules/pi-lens/dist/clients/ndjson-logger.js +114 -23
- package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +18 -9
- package/node_modules/pi-lens/dist/clients/project-report.js +62 -27
- package/node_modules/pi-lens/dist/clients/read-guard-logger.js +7 -2
- package/node_modules/pi-lens/dist/clients/read-guard.js +88 -19
- package/node_modules/pi-lens/dist/clients/review-graph/builder.js +194 -31
- package/node_modules/pi-lens/dist/clients/review-graph/revision-drift.js +21 -0
- package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +6 -3
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +44 -0
- package/node_modules/pi-lens/dist/clients/runtime-session.js +41 -0
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +47 -2
- package/node_modules/pi-lens/dist/clients/session-event-guard.js +184 -0
- package/node_modules/pi-lens/dist/clients/source-filter.js +6 -2
- package/node_modules/pi-lens/dist/clients/startup-scan.js +5 -2
- package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +1 -0
- package/node_modules/pi-lens/dist/index.js +1224 -393
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +10 -3
- package/node_modules/pi-lens/package.json +18 -5
- package/node_modules/pi-lens/scripts/install-selftest.mjs +99 -11
- package/node_modules/pi-lens/scripts/lib/host-provided-deps.mjs +75 -0
- package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +285 -0
- package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +233 -0
- package/node_modules/pi-web-access/CHANGELOG.md +27 -0
- package/node_modules/pi-web-access/README.md +8 -7
- package/node_modules/pi-web-access/chrome-cookies.ts +96 -20
- package/node_modules/pi-web-access/data-uri-sanitize.ts +406 -0
- package/node_modules/pi-web-access/extract.ts +12 -2
- package/node_modules/pi-web-access/firecrawl.ts +18 -2
- package/node_modules/pi-web-access/gemini-search.ts +28 -10
- package/node_modules/pi-web-access/github-extract.ts +65 -36
- package/node_modules/pi-web-access/index.ts +43 -14
- package/node_modules/pi-web-access/openai-search.ts +17 -6
- package/node_modules/pi-web-access/package.json +1 -1
- package/node_modules/pi-web-access/pdf-extract.ts +18 -5
- package/node_modules/pi-web-access/ssrf-protection.ts +12 -2
- package/package.json +19 -18
|
@@ -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
|
-
|
|
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,
|
|
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"),
|
|
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,
|
|
137
|
-
bytes: Math.min(Buffer.byteLength(bounded.slice(0,
|
|
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
|
|
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(
|
|
148
|
-
bytes = Math.min(
|
|
149
|
-
if (chars ===
|
|
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 >=
|
|
388
|
+
capped: chars >= limits.maxChars || bytes >= limits.maxBytes,
|
|
156
389
|
};
|
|
157
390
|
}
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
182
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
/**
|
|
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
|
-
|
|
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;
|