@promptctl/cc-candybar 1.42.1 → 1.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/index.mjs +72 -71
  2. package/package.json +5 -6
  3. package/src/check.ts +0 -478
  4. package/src/cli-flags.ts +0 -8
  5. package/src/click/wire.ts +0 -158
  6. package/src/config/action.ts +0 -329
  7. package/src/config/cli.ts +0 -71
  8. package/src/config/default-dsl-config.ts +0 -1645
  9. package/src/config/disclosure.ts +0 -170
  10. package/src/config/dsl-loader.ts +0 -339
  11. package/src/config/dsl-types.ts +0 -581
  12. package/src/config/edit-chrome.ts +0 -559
  13. package/src/config/help.ts +0 -151
  14. package/src/config/ident.ts +0 -22
  15. package/src/config/layout-ops.ts +0 -177
  16. package/src/config/loader/actions.ts +0 -972
  17. package/src/config/loader/cache.ts +0 -206
  18. package/src/config/loader/cross-ref.ts +0 -714
  19. package/src/config/loader/cycles.ts +0 -148
  20. package/src/config/loader/diagnostics.ts +0 -99
  21. package/src/config/loader/discovery.ts +0 -182
  22. package/src/config/loader/edit-mode.ts +0 -137
  23. package/src/config/loader/emit-schema.ts +0 -68
  24. package/src/config/loader/globals.ts +0 -269
  25. package/src/config/loader/helpers.ts +0 -48
  26. package/src/config/loader/layout.ts +0 -693
  27. package/src/config/loader/looks.ts +0 -96
  28. package/src/config/loader/menu-synth.ts +0 -435
  29. package/src/config/loader/merge.ts +0 -115
  30. package/src/config/loader/persist-target.ts +0 -67
  31. package/src/config/loader/presets.ts +0 -119
  32. package/src/config/loader/refs.ts +0 -100
  33. package/src/config/loader/reserved-namespace.ts +0 -38
  34. package/src/config/loader/segments.ts +0 -120
  35. package/src/config/loader/validate-core.ts +0 -737
  36. package/src/config/loader/variables.ts +0 -260
  37. package/src/config/menu-keys.ts +0 -139
  38. package/src/config/option-domain.ts +0 -164
  39. package/src/config/presets.ts +0 -326
  40. package/src/config/settings-menu.ts +0 -775
  41. package/src/daemon/acquire.ts +0 -684
  42. package/src/daemon/cache/git.ts +0 -649
  43. package/src/daemon/cache/render.ts +0 -623
  44. package/src/daemon/cache/session-usage-store.ts +0 -720
  45. package/src/daemon/cache/watchers.ts +0 -249
  46. package/src/daemon/client-debug.ts +0 -120
  47. package/src/daemon/client-stats.ts +0 -130
  48. package/src/daemon/client-transport.ts +0 -273
  49. package/src/daemon/client.ts +0 -78
  50. package/src/daemon/config-overrides-store.ts +0 -663
  51. package/src/daemon/debug-types.ts +0 -91
  52. package/src/daemon/debug.ts +0 -264
  53. package/src/daemon/fork-bomb-breaker.ts +0 -351
  54. package/src/daemon/limits.ts +0 -211
  55. package/src/daemon/log.ts +0 -81
  56. package/src/daemon/parent-watchdog.ts +0 -87
  57. package/src/daemon/paths.ts +0 -211
  58. package/src/daemon/process-fingerprint.ts +0 -146
  59. package/src/daemon/protocol.ts +0 -292
  60. package/src/daemon/render-payload.ts +0 -1256
  61. package/src/daemon/server.ts +0 -1330
  62. package/src/daemon/session-state-file.ts +0 -108
  63. package/src/daemon/session-state.ts +0 -237
  64. package/src/daemon/socket-lease.ts +0 -209
  65. package/src/daemon/socket-ownership.ts +0 -209
  66. package/src/daemon/stats.ts +0 -235
  67. package/src/daemon/verbs/config-validators.ts +0 -250
  68. package/src/daemon/verbs/index.ts +0 -706
  69. package/src/daemon/verbs/state-validators.ts +0 -249
  70. package/src/daemon/verbs/validator-registry.ts +0 -457
  71. package/src/demo/dsl.ts +0 -143
  72. package/src/demo/mock-data.ts +0 -67
  73. package/src/demo/statusline.json5 +0 -94
  74. package/src/dsl/node-registry.ts +0 -374
  75. package/src/dsl/render.ts +0 -803
  76. package/src/help-text.ts +0 -90
  77. package/src/index.ts +0 -210
  78. package/src/install/currency.ts +0 -197
  79. package/src/install/index.ts +0 -557
  80. package/src/proc/launch.ts +0 -459
  81. package/src/proc/stats-handle.ts +0 -13
  82. package/src/render/action.ts +0 -883
  83. package/src/render/active-segment.ts +0 -78
  84. package/src/render/diagnostic-style.ts +0 -23
  85. package/src/render/diagnostic-text.ts +0 -77
  86. package/src/render/error-glyph.ts +0 -53
  87. package/src/render/menu.ts +0 -257
  88. package/src/render/outcome-plan.ts +0 -45
  89. package/src/render/picker.ts +0 -372
  90. package/src/render/segment-color.ts +0 -74
  91. package/src/render/split-lines.ts +0 -51
  92. package/src/render/strip.ts +0 -228
  93. package/src/segments/cache.ts +0 -131
  94. package/src/segments/context.ts +0 -190
  95. package/src/segments/git.ts +0 -1084
  96. package/src/segments/metrics.ts +0 -187
  97. package/src/segments/pricing.ts +0 -452
  98. package/src/segments/session.ts +0 -23
  99. package/src/segments/tmux.ts +0 -74
  100. package/src/template-engine/cells.ts +0 -90
  101. package/src/template-engine/colors.ts +0 -124
  102. package/src/template-engine/engine.ts +0 -108
  103. package/src/template-engine/funcs.ts +0 -232
  104. package/src/template-engine/index.ts +0 -11
  105. package/src/template-engine/layout.ts +0 -133
  106. package/src/template-engine/scope.ts +0 -62
  107. package/src/template-engine/sparkline.ts +0 -79
  108. package/src/themes/index.ts +0 -20
  109. package/src/themes/palette-resolvers.ts +0 -84
  110. package/src/themes/policy.ts +0 -393
  111. package/src/utils/cache.ts +0 -206
  112. package/src/utils/claude.ts +0 -683
  113. package/src/utils/color-support.ts +0 -118
  114. package/src/utils/formatters.ts +0 -99
  115. package/src/utils/logger.ts +0 -5
  116. package/src/utils/outcome.ts +0 -33
  117. package/src/utils/schema-validator.ts +0 -126
  118. package/src/utils/single-flight.ts +0 -57
  119. package/src/utils/terminal-width.ts +0 -51
  120. package/src/utils/terminal.ts +0 -11
  121. package/src/utils/transcript-fs.ts +0 -279
  122. package/src/var-system/index.ts +0 -24
  123. package/src/var-system/sources.ts +0 -1047
  124. package/src/var-system/store.ts +0 -223
  125. package/src/var-system/types.ts +0 -57
  126. package/src/version.ts +0 -17
@@ -1,228 +0,0 @@
1
- import {
2
- Strip,
3
- RichText,
4
- Style,
5
- PowerlineJoiner,
6
- CapsuleJoiner,
7
- PlainJoiner,
8
- FlexStrip,
9
- renderToString,
10
- type Joiner,
11
- type PowerlineJoinerOptions,
12
- type CapsuleJoinerOptions,
13
- } from "@promptctl/rich-js";
14
- import type {
15
- Charset,
16
- ColorCompatibility,
17
- StripStyle,
18
- } from "../themes/policy.js";
19
-
20
- export interface RenderedSegmentLike {
21
- type: string;
22
- text: string;
23
- bgHex?: string;
24
- fgHex?: string;
25
- }
26
-
27
- // [LAW:one-source-of-truth] `StripStyle`/`Charset` and their value lists live
28
- // in themes/policy.ts (the render-identifier policy module, importable by the
29
- // option-source machinery without a render→template-engine cycle). Re-exported
30
- // here so render-layer consumers can keep importing them from the strip module.
31
- export type { Charset, ColorCompatibility, StripStyle };
32
-
33
- // [LAW:one-source-of-truth] Raw terminal cols we assume when the wire
34
- // didn't give us one (older client, env-stripped spawn). RAW — not
35
- // post-reserve — so the Claude-Code-UI reserve applies uniformly across
36
- // wire and fallback paths (callers thread this through
37
- // applyClaudeCodeReserve from src/utils/terminal-width).
38
- export const DEFAULT_TERMINAL_WIDTH = 120;
39
-
40
- // [LAW:one-source-of-truth] autoWrap's and padding's defaults live in
41
- // themes/policy.ts beside their resolvers and padding's range, because the
42
- // config loader needs them too and config must not import render
43
- // [LAW:one-way-deps]. Re-exported here on the same terms as the types above, so
44
- // render-layer consumers keep importing them from the strip module.
45
- export { DEFAULT_WRAP, DEFAULT_PADDING } from "../themes/policy.js";
46
-
47
- // [LAW:one-source-of-truth] The one statement of the globals.charset default
48
- // (powerline unicode glyphs — current behavior, matching the legacy
49
- // display.charset). Every resolver of the config global
50
- // (`globals.charset ?? DEFAULT_CHARSET`) derives from this constant.
51
- export const DEFAULT_CHARSET: Charset = "unicode";
52
-
53
- // [LAW:one-source-of-truth] The one statement of the globals.colorCompatibility
54
- // default (truecolor — the CURRENT pinned value, deliberately NOT the legacy
55
- // "auto" default, which would change rendering for existing users). Every
56
- // resolver of the config global (`globals.colorCompatibility ??
57
- // DEFAULT_COLOR_COMPATIBILITY`) derives from this constant.
58
- export const DEFAULT_COLOR_COMPATIBILITY: ColorCompatibility = "truecolor";
59
-
60
- export interface BuildLineOptions {
61
- style: StripStyle;
62
- // [LAW:types-are-the-program] Narrower than rich-js ColorSystemSpec on
63
- // purpose: the four explicit depths only. "auto"/null never reach a render —
64
- // the daemon is detached, so env detection would read the wrong terminal;
65
- // the loader rejects "auto" at the trust boundary (see COLOR_COMPATIBILITIES
66
- // in themes/policy.ts), and every construction site states a resolved depth.
67
- colorCompatibility: ColorCompatibility;
68
- separator?: string;
69
- // [LAW:types-are-the-program] Every render carries a width. Required (not
70
- // optional) so callers cannot silently drop the wire's value.
71
- // [LAW:one-source-of-truth] Width is a FACT (usable cells) feeding two
72
- // consumers — FlexStrip's wrap limit AND the picker's pagination
73
- // (`term.cols`). It stays finite even when wrapping is off; `wrap` below is
74
- // the separate POLICY of whether rows may soft-break at that width.
75
- width: number;
76
- // [LAW:types-are-the-program] Required for the same reason as width: the
77
- // wrap decision (globals.autoWrap, default on) must reach every render
78
- // explicitly — encoding "no wrap" as width=Infinity would corrupt the
79
- // picker's pagination, which reads the same width value.
80
- wrap: boolean;
81
- // [LAW:one-source-of-truth] Spaces inside each segment cell per side
82
- // (globals.padding, default 1 — the legacy display.padding, intra-cell,
83
- // not rich-js FlexStrip's inter-item gap). Required so every construction
84
- // site states the resolved value; the cell builders derive from it and
85
- // never re-default.
86
- padding: number;
87
- // [LAW:one-source-of-truth] Which glyph vocabulary the joiners render with
88
- // (globals.charset, default "unicode" — the legacy display.charset).
89
- // Required for the same reason as padding: the resolved value reaches every
90
- // render explicitly; pickJoiner derives from it and never re-defaults.
91
- charset: Charset;
92
- }
93
-
94
- // [LAW:dataflow-not-control-flow] Charset variability lives in these VALUES,
95
- // not in branches: per style, each charset names the joiner-construction
96
- // options. The unicode entries are empty on purpose — rich-js owns its
97
- // canonical powerline glyphs (U+E0B0 / U+E0B6+U+E0B4) and restating them here
98
- // would be a second source that could drift [LAW:one-source-of-truth]. The
99
- // ascii glyphs are DELIBERATELY single-column (same display width as the
100
- // unicode caps) so stripChromeCols stays charset-invariant; the
101
- // measured-chrome pin in test/picker-pagination.test.ts checks that for every
102
- // style × charset. Widen a glyph and that pin fails loudly.
103
- const POWERLINE_GLYPHS: Record<Charset, PowerlineJoinerOptions> = {
104
- unicode: {},
105
- ascii: { glyph: ">" },
106
- };
107
- const CAPSULE_GLYPHS: Record<Charset, CapsuleJoinerOptions> = {
108
- unicode: {},
109
- ascii: { left: "(", right: ")" },
110
- };
111
-
112
- function pickJoiner(
113
- style: StripStyle,
114
- charset: Charset,
115
- separator?: string,
116
- ): Joiner {
117
- // [LAW:dataflow-not-control-flow] joiner choice is data-driven; one arm per
118
- // shape. Style picks the joiner CLASS, charset indexes the glyph options fed
119
- // to it — the two dimensions stay orthogonal (any style renders under either
120
- // charset). Plain takes no charset lookup: its separator is already user
121
- // data (globals.default_separator) and its default (" | ") is ASCII-safe.
122
- // [LAW:types-are-the-program] Total over StripStyle — the `never`
123
- // default makes adding a STRIP_STYLES member a compile error here until it
124
- // gets a joiner, so the picker's domain can never offer an unrenderable shape.
125
- switch (style) {
126
- case "capsule":
127
- return new CapsuleJoiner(CAPSULE_GLYPHS[charset]);
128
- case "plain":
129
- return new PlainJoiner(separator !== undefined ? { separator } : {});
130
- case "powerline":
131
- return new PowerlineJoiner(POWERLINE_GLYPHS[charset]);
132
- default: {
133
- const _exhaustive: never = style;
134
- return _exhaustive;
135
- }
136
- }
137
- }
138
-
139
- // [LAW:single-enforcer] Strip geometry has one owner — this module builds every
140
- // joiner (pickJoiner) and so alone knows the structural chrome a styled row costs
141
- // beyond its content: the joiner's end-caps, which FlexStrip paints OUTSIDE the
142
- // width budget. A single full-width row's content can occupy only `width - chrome`
143
- // before the caps push the line past `width`. Returned per style so a width-fit
144
- // widget (the picker) can reserve it and never overflow the wrapped line.
145
- //
146
- // [LAW:dataflow-not-control-flow] / [LAW:types-are-the-program] Total over
147
- // StripStyle — the `never` default makes adding a STRIP_STYLES member a compile
148
- // error here until its chrome is declared, the same guard pickJoiner carries. The
149
- // numbers are the cap glyphs pickJoiner constructs: powerline appends ONE
150
- // trailing separator (1 col); capsule brackets BOTH edges (2 cols); plain has no
151
- // caps. Charset does NOT change these — both glyph vocabularies use
152
- // single-column caps by construction (see the glyph tables above), which is why
153
- // this stays total over StripStyle alone. test/picker-pagination.test.ts
154
- // measures the real rendered chrome against these for every style × charset so
155
- // the declaration cannot drift from rich-js or from the ascii glyph choice.
156
- export function stripChromeCols(style: StripStyle): number {
157
- switch (style) {
158
- case "powerline":
159
- return 1;
160
- case "capsule":
161
- return 2;
162
- case "plain":
163
- return 0;
164
- default: {
165
- const _exhaustive: never = style;
166
- return _exhaustive;
167
- }
168
- }
169
- }
170
-
171
- function toCell(seg: RenderedSegmentLike, padding: number): RichText {
172
- // [LAW:one-source-of-truth] Intra-cell padding derives from the one resolved
173
- // globals.padding value on BuildLineOptions — the joiners sit between cells;
174
- // padding sits inside, inheriting the cell's wrapping style (bg fill).
175
- const style = new Style({
176
- bgcolor: seg.bgHex || undefined,
177
- color: seg.fgHex || undefined,
178
- });
179
- return new RichText(seg.text, { style, end: "", noWrap: true }).pad(padding);
180
- }
181
-
182
- /**
183
- * [LAW:single-enforcer] The one place RichText cells become an ANSI byte
184
- * string. Every render path (DSL RichText[] via the template-engine
185
- * pipeline, buildLineStrip's input-shape adapter, debug per-segment
186
- * serialization) flows through here. The wrap dispatch lives here too:
187
- * wrap enabled at a finite width → FlexStrip (rich-js owns the wrap
188
- * algebra); otherwise → Strip, one unbounded line.
189
- *
190
- * [LAW:dataflow-not-control-flow] The dispatch is on values, not on caller
191
- * branches: `wrap` (the globals.autoWrap policy) gates whether the finite
192
- * width acts as a break limit. An infinite width has nothing to break at,
193
- * so it renders unbounded regardless of `wrap`.
194
- */
195
- export function renderStripCells(
196
- cells: readonly RichText[],
197
- options: BuildLineOptions,
198
- ): string {
199
- if (cells.length === 0) return "";
200
- const joiner = pickJoiner(options.style, options.charset, options.separator);
201
- if (options.wrap && Number.isFinite(options.width)) {
202
- const flex = new FlexStrip([...cells], { joiner });
203
- const out = renderToString(flex, {
204
- width: options.width,
205
- colorSystem: options.colorCompatibility,
206
- });
207
- return out.endsWith("\n") ? out.slice(0, -1) : out;
208
- }
209
- const strip = new Strip([...cells], joiner);
210
- return renderToString(strip, {
211
- colorSystem: options.colorCompatibility,
212
- });
213
- }
214
-
215
- /**
216
- * Input-shape adapter for callers that hold RenderedSegmentLike[] rather
217
- * than pre-constructed RichText cells. Wrap behavior is identical to
218
- * renderStripCells — the cell construction is the only difference.
219
- */
220
- export function buildLineStrip(
221
- segments: readonly RenderedSegmentLike[],
222
- options: BuildLineOptions,
223
- ): string {
224
- return renderStripCells(
225
- segments.map((seg) => toCell(seg, options.padding)),
226
- options,
227
- );
228
- }
@@ -1,131 +0,0 @@
1
- // Prompt-cache warmth provider.
2
- //
3
- // Anthropic's prompt cache has a fixed TTL (1h): each turn that reads or
4
- // creates cache entries refreshes it, and after the TTL the next turn pays
5
- // full cache-creation cost again. This provider answers one question — when
6
- // does the current session's cache go cold? — by tail-reading the transcript
7
- // for the most recent entry that touched the cache and projecting its
8
- // timestamp forward by the TTL.
9
- //
10
- // [LAW:dataflow-not-control-flow] The datum is a single epoch instant, not a
11
- // rendered string. Whether the timer shows "12m", "cold", or hides entirely,
12
- // and what color it takes, are all functions of this one number evaluated in
13
- // the DSL template — the same shape block/weekly use with `resetsAt`. The
14
- // provider carries no display policy.
15
- //
16
- // [LAW:types-are-the-program] The return is `Outcome<number>`: a known expiry
17
- // instant (`ok`), "no cache activity found" (`absent` — no transcript yet, or
18
- // no cache-bearing entry), or a real read failure (`failed` — the transcript
19
- // exists but couldn't be read). Absent becomes a missing payload field, which
20
- // the segment's `when` predicate reads as hidden; failed reaches the payload
21
- // boundary, the one place that logs it — there is no "0 means hidden"
22
- // ambiguity to defend against downstream, and no failure dressed as absence.
23
-
24
- import { ABSENT, ok, type Outcome } from "../utils/outcome.js";
25
- import { readTail } from "../utils/transcript-fs.js";
26
-
27
- // Anthropic prompt cache TTL. A const, not a knob: it is a property of the
28
- // upstream cache, not of this renderer. If a future cache tier ships a
29
- // different TTL, that is a new arm here, not a user config field.
30
- const CACHE_TTL_MS = 60 * 60 * 1000;
31
- const TAIL_CHUNK = 64 * 1024;
32
- const TAIL_MAX = 1 * 1024 * 1024;
33
-
34
- // [LAW:types-are-the-program] A cheap CANDIDATE filter, not the authority. It
35
- // matches any line mentioning a non-zero cache-token field, which includes a
36
- // line whose *message content* merely quotes the string (a pasted JSON snippet,
37
- // a transcript of a review discussing these very fields). The authoritative
38
- // check is the parsed `message.usage` value — the regex only avoids JSON.parsing
39
- // every line; a match is verified before its timestamp is trusted. The `[1-9]`
40
- // rejects the `":0` common case so most non-cache lines never reach the parser.
41
- const CACHE_HIT_RE =
42
- /"(?:cache_read_input_tokens|cache_creation_input_tokens)":[1-9]/;
43
-
44
- // The transcript-line shape this provider reads. Untrusted JSON — every field is
45
- // optional and narrowed at use; only positive `message.usage` cache tokens count.
46
- interface UsageLine {
47
- readonly timestamp?: string;
48
- readonly message?: {
49
- readonly usage?: {
50
- readonly cache_read_input_tokens?: number;
51
- readonly cache_creation_input_tokens?: number;
52
- };
53
- };
54
- }
55
-
56
- // Parse a candidate line and return its millisecond timestamp ONLY if its
57
- // `message.usage` actually records positive cache activity. A content-only
58
- // mention (or unparseable line) yields null, so a false-positive regex match
59
- // can never set the timer warm.
60
- function cacheActivityTs(line: string): number | null {
61
- let parsed: UsageLine;
62
- try {
63
- parsed = JSON.parse(line) as UsageLine;
64
- } catch {
65
- return null;
66
- }
67
- const usage = parsed.message?.usage;
68
- const positive =
69
- (usage?.cache_read_input_tokens ?? 0) > 0 ||
70
- (usage?.cache_creation_input_tokens ?? 0) > 0;
71
- if (!positive) return null;
72
- const ms = parsed.timestamp != null ? Date.parse(parsed.timestamp) : NaN;
73
- return Number.isNaN(ms) ? null : ms;
74
- }
75
-
76
- /**
77
- * Epoch *seconds* at which the session's prompt cache expires; `absent` when
78
- * no cache-bearing transcript entry can be found; `failed` when the
79
- * transcript exists but couldn't be read. Seconds (not millis) to match the
80
- * unit of block/weekly `resetsAt`, so the DSL composes
81
- * `minutesUntilReset .cache.expiresAt` with no unit translation.
82
- */
83
- export async function cacheExpiresAt(
84
- transcriptPath: string,
85
- ): Promise<Outcome<number>> {
86
- const lastCacheMs = await findLastCacheActivityTs(transcriptPath);
87
- if (lastCacheMs.kind !== "ok") return lastCacheMs;
88
- return ok(Math.floor((lastCacheMs.value + CACHE_TTL_MS) / 1000));
89
- }
90
-
91
- // Tail-read the JSONL transcript and return the millisecond timestamp of the
92
- // last entry with cache activity. The relevant entry is almost always within
93
- // the final few KB, so the common case reads one TAIL_CHUNK; only a transcript
94
- // whose last cache hit is deeper grows to TAIL_MAX. [LAW:single-enforcer] both
95
- // reads go through the gated transcript-fs seam (readTail), so this scanner is
96
- // bounded with every other transcript read instead of blocking the event loop
97
- // on synchronous fs.
98
- async function findLastCacheActivityTs(
99
- transcriptPath: string,
100
- ): Promise<Outcome<number>> {
101
- for (const maxBytes of [TAIL_CHUNK, TAIL_MAX]) {
102
- const tail = await readTail(transcriptPath, maxBytes);
103
- // absent (no transcript yet) and failed (unreadable) both end the scan;
104
- // the outcome carries which one happened to the payload boundary.
105
- if (tail.kind !== "ok") return tail;
106
- const ts = scanBufferForLastCacheTs(tail.value.buf, tail.value.fromStart);
107
- if (ts != null) return ok(ts);
108
- // The window reached the file start: the whole transcript is scanned, no
109
- // hit exists — growing further would re-read the same bytes.
110
- if (tail.value.fromStart) return ABSENT;
111
- }
112
- return ABSENT;
113
- }
114
-
115
- function scanBufferForLastCacheTs(
116
- buf: Buffer,
117
- bufStartsAtFileBeginning: boolean,
118
- ): number | null {
119
- const text = buf.toString("utf8");
120
- const lines = text.split("\n");
121
- // When the window doesn't start at the file beginning, the first line is
122
- // likely a partial JSON object — skip it so we never mis-parse a fragment.
123
- const start = bufStartsAtFileBeginning ? 0 : 1;
124
- for (let i = lines.length - 1; i >= start; i--) {
125
- const line = lines[i];
126
- if (!line || !CACHE_HIT_RE.test(line)) continue; // cheap candidate filter
127
- const ts = cacheActivityTs(line); // authoritative: parsed usage must be > 0
128
- if (ts != null) return ts;
129
- }
130
- return null;
131
- }
@@ -1,190 +0,0 @@
1
- import type { ParsedEntry, ClaudeHookData } from "../utils/claude";
2
-
3
- import { debug } from "../utils/logger";
4
- import { parseJsonlFile } from "../utils/claude";
5
- import { ABSENT, failed, ok, type Outcome } from "../utils/outcome";
6
-
7
- export interface ContextInfo {
8
- totalTokens: number;
9
- // Used / remaining percentages. Sourced from Claude's native
10
- // context_window.used_percentage / remaining_percentage when present; a
11
- // plain token-ratio is the only fallback (no auto-compact buffer guess).
12
- percentage: number;
13
- contextLeftPercentage: number;
14
- maxTokens: number;
15
- }
16
-
17
- interface ContextUsageThresholds {
18
- LOW: number;
19
- MEDIUM: number;
20
- }
21
-
22
- // [LAW:one-source-of-truth] The context-window size is NEVER guessed from the
23
- // model name. Claude Code reports the real size for the active model in
24
- // `context_window.context_window_size` (1M for the [1m] variants, 200k
25
- // otherwise) — that field is the single authority. This constant is the
26
- // last-resort floor for ancient clients that omit `context_window` entirely;
27
- // it is not a per-model table and must not grow into one.
28
- const DEFAULT_CONTEXT_WINDOW = 200000;
29
-
30
- export class ContextProvider {
31
- private readonly thresholds: ContextUsageThresholds = {
32
- LOW: 50,
33
- MEDIUM: 80,
34
- };
35
-
36
- getContextUsageThresholds(): ContextUsageThresholds {
37
- return this.thresholds;
38
- }
39
-
40
- // Token-ratio percentages — the fallback ONLY. Used when Claude doesn't
41
- // report used_percentage / remaining_percentage natively (transcript path,
42
- // or a native window whose percentages are still null pre-first-call). No
43
- // auto-compact buffer: that was a hardcoded guess at Claude's threshold and
44
- // a soft second source; the native remaining_percentage is authoritative.
45
- private ratioPercentages(
46
- totalTokens: number,
47
- contextLimit: number,
48
- ): Pick<ContextInfo, "percentage" | "contextLeftPercentage"> {
49
- const percentage = Math.min(
50
- 100,
51
- Math.max(0, Math.round((totalTokens / contextLimit) * 100)),
52
- );
53
- return { percentage, contextLeftPercentage: Math.max(0, 100 - percentage) };
54
- }
55
-
56
- /**
57
- * Calculate context info from native Claude Code context_window data (preferred).
58
- * Requires Claude Code 2.0.70+ with current_usage field.
59
- */
60
- calculateContextFromHookData(hookData: ClaudeHookData): ContextInfo | null {
61
- const cw = hookData.context_window;
62
- if (!cw?.current_usage) {
63
- debug(
64
- "No current_usage in hook data, falling back to transcript parsing",
65
- );
66
- return null;
67
- }
68
-
69
- const currentUsage = cw.current_usage;
70
- // [LAW:no-defensive-null-guards] context_window_size is a required `number`
71
- // within context_window; reaching here proves cw is present, so the size
72
- // is too. No `|| default` — that would mask a malformed payload as 200k.
73
- const contextLimit = cw.context_window_size;
74
- const totalTokens =
75
- (currentUsage.input_tokens || 0) +
76
- (currentUsage.cache_creation_input_tokens || 0) +
77
- (currentUsage.cache_read_input_tokens || 0);
78
-
79
- debug(
80
- `Native current_usage: input=${currentUsage.input_tokens}, cache_create=${currentUsage.cache_creation_input_tokens}, cache_read=${currentUsage.cache_read_input_tokens}, total=${totalTokens} (limit: ${contextLimit})`,
81
- );
82
-
83
- // [LAW:one-source-of-truth] Claude's reported used/remaining percentages
84
- // are authoritative; the token-ratio is only a floor for the window whose
85
- // percentages are still null (pre-first-call). remaining_percentage is NOT
86
- // recomputed from a local buffer — it measures real headroom to the limit.
87
- const ratio = this.ratioPercentages(totalTokens, contextLimit);
88
- return {
89
- totalTokens,
90
- maxTokens: contextLimit,
91
- percentage:
92
- cw.used_percentage != null
93
- ? Math.round(cw.used_percentage)
94
- : ratio.percentage,
95
- contextLeftPercentage:
96
- cw.remaining_percentage != null
97
- ? Math.round(cw.remaining_percentage)
98
- : ratio.contextLeftPercentage,
99
- };
100
- }
101
-
102
- /**
103
- * Calculate context tokens by parsing the transcript file (fallback).
104
- * Used for older Claude Code versions that don't provide context_window.
105
- *
106
- * [LAW:no-silent-failure] An unreadable transcript is `failed` (the payload
107
- * boundary logs it); a transcript with no usable usage entry is `absent`.
108
- * The old catch-to-null collapsed both into "no data".
109
- */
110
- async calculateContextTokensFromTranscript(
111
- transcriptPath: string,
112
- contextLimit: number,
113
- ): Promise<Outcome<ContextInfo>> {
114
- try {
115
- debug(`Calculating context tokens from transcript: ${transcriptPath}`);
116
-
117
- const parsedEntries = await parseJsonlFile(transcriptPath);
118
-
119
- if (parsedEntries.length === 0) {
120
- debug("No entries in transcript");
121
- return ABSENT;
122
- }
123
-
124
- let mostRecentEntry: ParsedEntry | null = null;
125
-
126
- for (let i = parsedEntries.length - 1; i >= 0; i--) {
127
- const entry = parsedEntries[i];
128
- if (!entry) continue;
129
-
130
- if (!entry.message?.usage?.input_tokens) continue;
131
- if (entry.isSidechain === true) continue;
132
-
133
- mostRecentEntry = entry;
134
- debug(
135
- `Context segment: Found most recent entry at ${entry.timestamp.toISOString()}, stopping search`,
136
- );
137
- break;
138
- }
139
-
140
- if (mostRecentEntry?.message?.usage) {
141
- const usage = mostRecentEntry.message.usage;
142
- const totalTokens =
143
- (usage.input_tokens || 0) +
144
- (usage.cache_read_input_tokens || 0) +
145
- (usage.cache_creation_input_tokens || 0);
146
-
147
- debug(
148
- `Most recent main chain context: ${totalTokens} tokens (limit: ${contextLimit})`,
149
- );
150
-
151
- return ok({
152
- totalTokens,
153
- maxTokens: contextLimit,
154
- ...this.ratioPercentages(totalTokens, contextLimit),
155
- });
156
- }
157
-
158
- debug("No main chain entries with usage data found");
159
- return ABSENT;
160
- } catch (error) {
161
- return failed(
162
- `context transcript: ${error instanceof Error ? error.message : String(error)}`,
163
- );
164
- }
165
- }
166
-
167
- /**
168
- * Get context info using native data if available, falling back to transcript parsing.
169
- */
170
- async getContextInfo(
171
- hookData: ClaudeHookData,
172
- ): Promise<Outcome<ContextInfo>> {
173
- const nativeContext = this.calculateContextFromHookData(hookData);
174
- if (nativeContext) {
175
- return ok(nativeContext);
176
- }
177
-
178
- // [LAW:one-source-of-truth] current_usage can be null (pre-first-call or
179
- // post-/compact) while context_window_size is still present and
180
- // authoritative — prefer it here too, and only fall to the floor when the
181
- // client omits context_window entirely.
182
- const contextLimit =
183
- hookData.context_window?.context_window_size ?? DEFAULT_CONTEXT_WINDOW;
184
-
185
- return this.calculateContextTokensFromTranscript(
186
- hookData.transcript_path,
187
- contextLimit,
188
- );
189
- }
190
- }