@danypops/pi-jittor 0.2.0 → 0.3.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 (25) hide show
  1. package/README.md +19 -3
  2. package/docs/USAGE_PRIOR_ART.md +1 -1
  3. package/extension/src/index.ts +379 -118
  4. package/extension/src/{context-breakdown.ts → observability/context-breakdown.ts} +251 -47
  5. package/extension/src/observability/context-growth.ts +26 -0
  6. package/extension/src/{capabilities → observability}/context-hub.ts +1 -5
  7. package/extension/src/observability/context-report.ts +92 -0
  8. package/extension/src/observability/context-view.ts +264 -0
  9. package/extension/src/{footer.ts → observability/footer.ts} +63 -26
  10. package/extension/src/{capabilities/local-run-telemetry.ts → observability/model-run.ts} +14 -10
  11. package/extension/src/observability/provider-context-snapshot.ts +246 -0
  12. package/extension/src/{capabilities/provider-response-telemetry.ts → observability/provider-response.ts} +21 -7
  13. package/extension/src/{tui.ts → observability/status.ts} +202 -72
  14. package/extension/src/observability/usage.ts +314 -0
  15. package/extension/src/optimization/model-selection-panel.ts +160 -0
  16. package/extension/src/{capabilities/codex-recovery.ts → optimization/recovery/codex.ts} +41 -25
  17. package/extension/src/service-client.ts +49 -2
  18. package/extension/src/settings-tui.ts +73 -33
  19. package/extension/src/settings.ts +40 -29
  20. package/package.json +11 -5
  21. package/extension/src/benchmark-tui.ts +0 -113
  22. package/extension/src/context-report.ts +0 -49
  23. package/extension/src/context-view.ts +0 -108
  24. package/extension/src/usage.ts +0 -324
  25. /package/extension/src/{capabilities → observability}/http-headers.ts +0 -0
@@ -1,5 +1,17 @@
1
+ import {
2
+ CONTEXT_DEFAULT_RESERVE_TOKENS,
3
+ CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN,
4
+ CONTEXT_TREE_MAX_NODES,
5
+ type ContextSegment,
6
+ type ContextSegmentItem,
7
+ countTextWithFallback,
8
+ type RequestTokenReconciliation,
9
+ reconcileRequestTokens,
10
+ StructuralTextTokenCounter,
11
+ type TextTokenCounter,
12
+ type TokenMeasurement,
13
+ } from "@danypops/jittor";
1
14
  import type { BuildSystemPromptOptions } from "@earendil-works/pi-coding-agent";
2
- import { CONTEXT_DEFAULT_RESERVE_TOKENS, CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN, CONTEXT_TREE_MAX_NODES, type ContextSegment, type ContextSegmentItem } from "@danypops/jittor";
3
15
 
4
16
  /**
5
17
  * Ported from pi-papyrus's context-budget.ts: the Pi-generic half (session message-history tree
@@ -26,52 +38,180 @@ export interface SessionTreeNodeLike {
26
38
  children: SessionTreeNodeLike[];
27
39
  }
28
40
 
29
- function messageContentCharacters(message: unknown): number {
30
- if (typeof message !== "object" || message === null) return 0;
41
+ interface MessageContentAnalysis {
42
+ characters: number;
43
+ text: string;
44
+ items: ContextSegmentItem[];
45
+ imageCount: number;
46
+ }
47
+
48
+ export interface ContextTokenMeasurementOptions {
49
+ provider?: string;
50
+ model?: string;
51
+ counters?: readonly TextTokenCounter[];
52
+ }
53
+
54
+ const STRUCTURAL_TEXT_COUNTER = new StructuralTextTokenCounter();
55
+
56
+ function measurementForText(text: string, options: ContextTokenMeasurementOptions = {}): TokenMeasurement {
57
+ return countTextWithFallback(
58
+ {
59
+ text,
60
+ scope: "context-item",
61
+ ...(options.provider === undefined ? {} : { provider: options.provider }),
62
+ ...(options.model === undefined ? {} : { model: options.model }),
63
+ },
64
+ options.counters ?? [],
65
+ STRUCTURAL_TEXT_COUNTER,
66
+ );
67
+ }
68
+
69
+ function measuredItem(label: string, textOrCharacters: string | number, options: ContextTokenMeasurementOptions = {}): ContextSegmentItem {
70
+ const characters = typeof textOrCharacters === "string" ? textOrCharacters.length : textOrCharacters;
71
+ const measurement =
72
+ typeof textOrCharacters === "string"
73
+ ? measurementForText(textOrCharacters, options)
74
+ : {
75
+ tokens: toCeilTokens(characters),
76
+ scope: "context-item" as const,
77
+ provenance: "structural-estimate" as const,
78
+ method: `char/${CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN}`,
79
+ };
80
+ const suffix =
81
+ measurement.provenance === "structural-estimate"
82
+ ? `${characters.toLocaleString()} chars (≈ char/4)`
83
+ : `${measurement.tokens.toLocaleString()} tok (${measurement.method}; exact text)`;
84
+ return { label: `${label} · ${suffix}`, estimatedTokens: measurement.tokens, measurement };
85
+ }
86
+
87
+ /** Breaks a message into Pi's public content fields and attaches explicit exact-text or structural provenance without retaining content. */
88
+ function analyzeMessageContent(message: unknown, options: ContextTokenMeasurementOptions = {}): MessageContentAnalysis {
89
+ if (typeof message !== "object" || message === null) return { characters: 0, text: "", items: [], imageCount: 0 };
31
90
  const record = message as Record<string, unknown>;
32
- if (record["role"] === "bashExecution") {
91
+ const effectiveOptions = options;
92
+ if (record.role === "bashExecution") {
33
93
  // Pi's own context builder excludes "!!"-prefixed bash output from context; match that.
34
- if (record["excludeFromContext"] === true) return 0;
35
- return String(record["command"] ?? "").length + String(record["output"] ?? "").length;
94
+ if (record.excludeFromContext === true) return { characters: 0, text: "", items: [], imageCount: 0 };
95
+ const command = String(record.command ?? "");
96
+ const output = String(record.output ?? "");
97
+ return {
98
+ characters: command.length + output.length,
99
+ text: command + output,
100
+ items: [measuredItem("command", command, effectiveOptions), measuredItem("output", output, effectiveOptions)].filter(
101
+ (item) => item.estimatedTokens > 0,
102
+ ),
103
+ imageCount: 0,
104
+ };
36
105
  }
37
- const content = record["content"];
38
- if (typeof content === "string") return content.length;
39
- if (!Array.isArray(content)) return 0;
106
+ const content = record.content;
107
+ if (typeof content === "string")
108
+ return {
109
+ characters: content.length,
110
+ text: content,
111
+ items: [measuredItem("text", content, effectiveOptions)],
112
+ imageCount: 0,
113
+ };
114
+ if (!Array.isArray(content)) return { characters: 0, text: "", items: [], imageCount: 0 };
40
115
  let characters = 0;
41
- for (const block of content) {
116
+ let imageCount = 0;
117
+ let text = "";
118
+ const items: ContextSegmentItem[] = [];
119
+ for (let index = 0; index < content.length; index++) {
120
+ const block = content[index];
42
121
  if (typeof block !== "object" || block === null) continue;
43
122
  const b = block as Record<string, unknown>;
44
- if (b["type"] === "text") characters += String(b["text"] ?? "").length;
45
- else if (b["type"] === "thinking") characters += String(b["thinking"] ?? "").length;
46
- else if (b["type"] === "toolCall") characters += JSON.stringify(b["arguments"] ?? {}).length;
47
- // "image" blocks are deliberately not counted here -- image tokens follow a different,
48
- // non-character-based cost model this char/4 estimate cannot represent; this is a real,
49
- // documented undercount for image-heavy sessions, not a silent approximation.
123
+ let blockText = "";
124
+ let label = `block ${index + 1}`;
125
+ if (b.type === "text") {
126
+ blockText = String(b.text ?? "");
127
+ label = "text";
128
+ } else if (b.type === "thinking") {
129
+ blockText = String(b.thinking ?? "");
130
+ label = "thinking";
131
+ } else if (b.type === "toolCall") {
132
+ blockText = JSON.stringify(b.arguments ?? {});
133
+ label = `tool call ${String(b.name ?? "(unknown)")} arguments`;
134
+ } else if (b.type === "image") {
135
+ imageCount += 1;
136
+ continue; // image tokens are provider/model-specific and cannot be derived from base64 characters
137
+ } else continue;
138
+ characters += blockText.length;
139
+ text += blockText;
140
+ if (blockText.length > 0) items.push(measuredItem(label, blockText, effectiveOptions));
50
141
  }
51
- return characters;
142
+ return { characters, text, items, imageCount };
52
143
  }
53
144
 
54
145
  function messageSnippet(message: unknown, maxLength = 48): string {
55
146
  if (typeof message !== "object" || message === null) return "";
56
147
  const record = message as Record<string, unknown>;
57
- if (record["role"] === "bashExecution") return String(record["command"] ?? "");
58
- const content = record["content"];
59
- const text = typeof content === "string"
60
- ? content
61
- : Array.isArray(content)
62
- ? content.map((block) => (typeof block === "object" && block !== null && (block as Record<string, unknown>)["type"] === "text" ? String((block as Record<string, unknown>)["text"] ?? "") : "")).join(" ")
63
- : "";
148
+ if (record.role === "bashExecution") return String(record.command ?? "");
149
+ const content = record.content;
150
+ const text =
151
+ typeof content === "string"
152
+ ? content
153
+ : Array.isArray(content)
154
+ ? content
155
+ .map((block) =>
156
+ typeof block === "object" && block !== null && (block as Record<string, unknown>).type === "text"
157
+ ? String((block as Record<string, unknown>).text ?? "")
158
+ : "",
159
+ )
160
+ .join(" ")
161
+ : "";
64
162
  const collapsed = text.replace(/\s+/g, " ").trim();
65
163
  return collapsed.length > maxLength ? `${collapsed.slice(0, maxLength - 1)}…` : collapsed;
66
164
  }
67
165
 
68
- function entryLabel(entry: SessionEntryLike): string {
166
+ interface ProviderPromptUsage {
167
+ label: string;
168
+ reconciliation: RequestTokenReconciliation;
169
+ }
170
+
171
+ function providerPromptUsage(message: unknown): ProviderPromptUsage | null {
172
+ if (typeof message !== "object" || message === null) return null;
173
+ const messageRecord = message as Record<string, unknown>;
174
+ const usage = messageRecord.usage;
175
+ if (typeof usage !== "object" || usage === null) return null;
176
+ const record = usage as Record<string, unknown>;
177
+ const amount = (value: unknown): number => (typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0);
178
+ const input = amount(record.input);
179
+ const cacheRead = amount(record.cacheRead);
180
+ const cacheWrite = amount(record.cacheWrite);
181
+ const promptTokens = input + cacheRead + cacheWrite;
182
+ if (!Number.isSafeInteger(promptTokens) || promptTokens <= 0) return null;
183
+ const identity =
184
+ typeof messageRecord.provider === "string" && typeof messageRecord.model === "string"
185
+ ? { provider: messageRecord.provider, model: messageRecord.model }
186
+ : {};
187
+ const reconciliation = reconcileRequestTokens(
188
+ {
189
+ tokens: promptTokens,
190
+ scope: "request-context",
191
+ provenance: "provider-reported",
192
+ method: "pi-assistant-usage",
193
+ ...identity,
194
+ },
195
+ [],
196
+ );
197
+ const cache =
198
+ cacheRead > 0 || cacheWrite > 0
199
+ ? `; new ${input.toLocaleString()}, cache read ${cacheRead.toLocaleString()}, write ${cacheWrite.toLocaleString()}`
200
+ : "";
201
+ return {
202
+ label: ` · provider-reported request context ${promptTokens.toLocaleString()} tok${cache} · unattributed residual ${reconciliation.residual.tokens.toLocaleString()} tok`,
203
+ reconciliation,
204
+ };
205
+ }
206
+
207
+ function entryLabel(entry: SessionEntryLike, imageCount = 0, providerUsage = ""): string {
69
208
  if (entry.type === "compaction") return "compaction summary";
70
209
  if (entry.type === "branch_summary") return "branch summary";
71
- const role = typeof entry.message === "object" && entry.message !== null ? (entry.message as Record<string, unknown>)["role"] : undefined;
210
+ const role = typeof entry.message === "object" && entry.message !== null ? (entry.message as Record<string, unknown>).role : undefined;
72
211
  const prefix = typeof role === "string" ? role : entry.type;
73
212
  const snippet = messageSnippet(entry.message);
74
- return snippet ? `${prefix}: ${snippet}` : prefix;
213
+ const images = imageCount > 0 ? ` · ${imageCount} image${imageCount === 1 ? "" : "s"} (token cost unavailable)` : "";
214
+ return `${snippet ? `${prefix}: ${snippet}` : prefix}${providerUsage}${images}`;
75
215
  }
76
216
 
77
217
  export interface MessageHistoryTree {
@@ -113,7 +253,12 @@ interface WalkFrame {
113
253
  * by a reverse-order (children-before-parent) construction pass -- an ordinary long-running
114
254
  * session is one long linear chain, so recursion depth would equal entry count.
115
255
  */
116
- export function buildMessageHistoryTree(roots: ReadonlyArray<SessionTreeNodeLike>, activeEntryIds: ReadonlySet<string>, branchEntryIds?: ReadonlySet<string>): MessageHistoryTree {
256
+ export function buildMessageHistoryTree(
257
+ roots: ReadonlyArray<SessionTreeNodeLike>,
258
+ activeEntryIds: ReadonlySet<string>,
259
+ branchEntryIds?: ReadonlySet<string>,
260
+ measurementOptions: ContextTokenMeasurementOptions = {},
261
+ ): MessageHistoryTree {
117
262
  const visited = new Set<string>();
118
263
  let truncated = false;
119
264
  let activeTokens = 0;
@@ -122,8 +267,14 @@ export function buildMessageHistoryTree(roots: ReadonlyArray<SessionTreeNodeLike
122
267
  const stack: WalkFrame[] = [...roots].reverse().map((root) => ({ node: root, parentIndex: null }));
123
268
  while (stack.length > 0) {
124
269
  const frame = stack.pop()!;
125
- if (order.length >= CONTEXT_TREE_MAX_NODES) { truncated = true; break; }
126
- if (visited.has(frame.node.entry.id)) { truncated = true; continue; } // cycle guard
270
+ if (order.length >= CONTEXT_TREE_MAX_NODES) {
271
+ truncated = true;
272
+ break;
273
+ }
274
+ if (visited.has(frame.node.entry.id)) {
275
+ truncated = true;
276
+ continue;
277
+ } // cycle guard
127
278
  visited.add(frame.node.entry.id);
128
279
  const index = order.length;
129
280
  order.push(frame);
@@ -137,22 +288,35 @@ export function buildMessageHistoryTree(roots: ReadonlyArray<SessionTreeNodeLike
137
288
  for (let index = order.length - 1; index >= 0; index--) {
138
289
  const frame = order[index]!;
139
290
  const entry = frame.node.entry;
140
- const characters = entry.type === "message"
141
- ? messageContentCharacters(entry.message)
142
- : entry.type === "compaction" || entry.type === "branch_summary"
143
- ? (entry.summary ?? "").length
144
- : 0;
145
- const tokens = Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
291
+ const entryMeasurementOptions = measurementOptions;
292
+ const analysis =
293
+ entry.type === "message"
294
+ ? analyzeMessageContent(entry.message, measurementOptions)
295
+ : { characters: 0, text: "", items: [], imageCount: 0 };
296
+ const text =
297
+ entry.type === "message"
298
+ ? analysis.text
299
+ : entry.type === "compaction" || entry.type === "branch_summary"
300
+ ? (entry.summary ?? "")
301
+ : "";
302
+ const measurement = measurementForText(text, entryMeasurementOptions);
303
+ const tokens = measurement.tokens;
146
304
  const isActive = activeEntryIds.has(entry.id);
147
305
  if (isActive) activeTokens += tokens;
148
306
  const isOnBranch = branchEntryIds ? branchEntryIds.has(entry.id) : isActive; // no branch set given -- fall back to the old binary active/inactive-branch label
149
307
 
150
- const children = childItemsByParent.get(index) ?? [];
151
- if (tokens === 0 && children.length === 0) continue; // no content, no descendants with content -- nothing to show
308
+ const treeChildren = childItemsByParent.get(index) ?? [];
309
+ const contentChildren = analysis.items;
310
+ const children = [...contentChildren, ...treeChildren];
311
+ if (tokens === 0 && children.length === 0 && analysis.imageCount === 0) continue; // no content, no descendants, and no unknown-cost image -- nothing to show
152
312
 
313
+ const promptUsage = providerPromptUsage(entry.message);
314
+ const label = entryLabel(entry, analysis.imageCount, promptUsage?.label);
153
315
  const item: ContextSegmentItem = {
154
- label: isActive ? entryLabel(entry) : isOnBranch ? `${entryLabel(entry)} (compacted)` : `${entryLabel(entry)} (inactive branch)`,
316
+ label: isActive ? label : isOnBranch ? `${label} (compacted)` : `${label} (inactive branch)`,
155
317
  estimatedTokens: tokens,
318
+ measurement,
319
+ ...(promptUsage ? { requestTokenReconciliation: promptUsage.reconciliation } : {}),
156
320
  ...(children.length > 0 ? { children } : {}),
157
321
  };
158
322
  itemByIndex.set(index, item);
@@ -204,24 +368,63 @@ export function buildBasePromptItems(options: BuildSystemPromptOptions, totalCha
204
368
  const toolSnippetEntries = Object.entries(options.toolSnippets ?? {});
205
369
  // Mirrors buildSystemPrompt()'s own "- name: snippet\n" line shape closely enough to be a
206
370
  // fair estimate without importing Pi-internal formatting code.
371
+ const toolSnippetDetails = toolSnippetEntries.map(([name, snippet]) => measuredItem(name, name.length + snippet.length + 4));
207
372
  const toolSnippetsCharacters = toolSnippetEntries.reduce((sum, [name, snippet]) => sum + name.length + snippet.length + 4, 0);
208
373
  if (toolSnippetsCharacters > 0) {
209
- items.push({ label: `Tool snippets (${toolSnippetEntries.length} tools)`, estimatedTokens: toCeilTokens(toolSnippetsCharacters) });
374
+ items.push({
375
+ label: `Tool snippets (${toolSnippetEntries.length} tools)`,
376
+ estimatedTokens: toCeilTokens(toolSnippetsCharacters),
377
+ children: toolSnippetDetails,
378
+ });
210
379
  }
211
380
 
212
381
  const visibleSkills = (options.skills ?? []).filter((skill) => !skill.disableModelInvocation);
213
- const skillsCharacters = visibleSkills.reduce((sum, skill) => sum + skill.name.length + skill.description.length + skill.filePath.length + 20, 0);
382
+ const skillsCharacters = visibleSkills.reduce(
383
+ (sum, skill) => sum + skill.name.length + skill.description.length + skill.filePath.length + 20,
384
+ 0,
385
+ );
214
386
  if (skillsCharacters > 0) {
215
- items.push({ label: `Skills catalog (${visibleSkills.length} skills)`, estimatedTokens: toCeilTokens(skillsCharacters) });
387
+ items.push({
388
+ label: `Skills catalog (${visibleSkills.length} skills)`,
389
+ estimatedTokens: toCeilTokens(skillsCharacters),
390
+ children: visibleSkills.map((skill) =>
391
+ measuredItem(skill.name, skill.name.length + skill.description.length + skill.filePath.length + 20),
392
+ ),
393
+ });
216
394
  }
217
395
 
218
396
  const contextFiles = options.contextFiles ?? [];
219
397
  const contextFilesCharacters = contextFiles.reduce((sum, file) => sum + file.path.length + file.content.length + 40, 0);
220
398
  if (contextFilesCharacters > 0) {
221
- items.push({ label: `Project context files (${contextFiles.length}, e.g. AGENTS.md)`, estimatedTokens: toCeilTokens(contextFilesCharacters) });
399
+ items.push({
400
+ label: `Project context files (${contextFiles.length}, e.g. AGENTS.md)`,
401
+ estimatedTokens: toCeilTokens(contextFilesCharacters),
402
+ children: contextFiles.map((file) => measuredItem(file.path, file.path.length + file.content.length + 40)),
403
+ });
222
404
  }
223
405
 
224
- const knownCharacters = toolSnippetsCharacters + skillsCharacters + contextFilesCharacters;
406
+ const promptGuidelines = options.promptGuidelines ?? [];
407
+ const promptGuidelinesCharacters = promptGuidelines.reduce((sum, guideline) => sum + guideline.length + 2, 0);
408
+ if (promptGuidelinesCharacters > 0) {
409
+ items.push({
410
+ label: `Tool guidelines (${promptGuidelines.length})`,
411
+ estimatedTokens: toCeilTokens(promptGuidelinesCharacters),
412
+ children: promptGuidelines.map((guideline, index) => measuredItem(`guideline ${index + 1}`, guideline.length + 2)),
413
+ });
414
+ }
415
+
416
+ const customPromptCharacters = options.customPrompt?.length ?? 0;
417
+ if (customPromptCharacters > 0) items.push(measuredItem("Custom system prompt", customPromptCharacters));
418
+ const appendedPromptCharacters = options.appendSystemPrompt?.length ?? 0;
419
+ if (appendedPromptCharacters > 0) items.push(measuredItem("Appended system prompt", appendedPromptCharacters));
420
+
421
+ const knownCharacters =
422
+ toolSnippetsCharacters +
423
+ skillsCharacters +
424
+ contextFilesCharacters +
425
+ promptGuidelinesCharacters +
426
+ customPromptCharacters +
427
+ appendedPromptCharacters;
225
428
  const remainderCharacters = Math.max(0, totalCharacters - knownCharacters);
226
429
  if (remainderCharacters > 0 || items.length === 0) {
227
430
  items.push({ label: "Base template, guidelines, and formatting", estimatedTokens: toCeilTokens(remainderCharacters) });
@@ -300,9 +503,10 @@ export function composeContextBreakdown(input: ComposeContextBreakdownInput): Co
300
503
  const overshootTokens = input.totalTokens === null ? 0 : Math.max(0, knownTokens - input.totalTokens);
301
504
  const other: ContextSegment = {
302
505
  key: "other",
303
- label: overshootTokens > 0
304
- ? `Unaccounted (message envelope, cache-control markers, and other wire-protocol overhead) -- estimate overshoot: other segments' estimates already exceed the real total by ~${overshootTokens} tokens, so this is a floor, not a real zero`
305
- : "Unaccounted (message envelope, cache-control markers, and other wire-protocol overhead)",
506
+ label:
507
+ overshootTokens > 0
508
+ ? `Unaccounted (message envelope, cache-control markers, and other wire-protocol overhead) -- estimate overshoot: other segments' estimates already exceed the real total by ~${overshootTokens} tokens, so this is a floor, not a real zero`
509
+ : "Unaccounted (message envelope, cache-control markers, and other wire-protocol overhead)",
306
510
  estimatedTokens: input.totalTokens === null ? 0 : Math.max(0, input.totalTokens - knownTokens),
307
511
  confidence: "correlated",
308
512
  };
@@ -0,0 +1,26 @@
1
+ export interface ContextGrowthPoint {
2
+ turn: number;
3
+ tokens: number;
4
+ }
5
+
6
+ /**
7
+ * Owns the current post-compaction context-growth observation window. Trend fitting and a
8
+ * trailing-size cap deliberately remain outside this task; reset() is the critical invariant
9
+ * that prevents one regression window from spanning a real compaction discontinuity.
10
+ */
11
+ export class ContextGrowthCapability {
12
+ private points: ContextGrowthPoint[] = [];
13
+
14
+ observe(turn: number, tokens: number): void {
15
+ if (!Number.isInteger(turn) || turn < 0 || !Number.isFinite(tokens) || tokens < 0) return;
16
+ this.points.push({ turn, tokens });
17
+ }
18
+
19
+ observations(): readonly ContextGrowthPoint[] {
20
+ return this.points.map((point) => ({ ...point }));
21
+ }
22
+
23
+ reset(): void {
24
+ this.points = [];
25
+ }
26
+ }
@@ -1,8 +1,4 @@
1
- import {
2
- CONTEXT_HUB_CONTRIBUTION_DEDUP_LIMIT,
3
- validateContextContribution,
4
- type ContextSegment,
5
- } from "@danypops/jittor";
1
+ import { CONTEXT_HUB_CONTRIBUTION_DEDUP_LIMIT, type ContextSegment, validateContextContribution } from "@danypops/jittor";
6
2
 
7
3
  /**
8
4
  * Merges Jittor's own directly-computed segments (tool ledger, real usage) with whatever
@@ -0,0 +1,92 @@
1
+ import type { ContextSegment } from "@danypops/jittor";
2
+ import type { ContextRow, ContextSegment as MalevichContextSegment } from "malevich-tui-components";
3
+ import type { ContextBreakdown } from "./context-breakdown.ts";
4
+
5
+ /** Bounds how many items render per segment in the plain-text fallback -- a notify-mode report is a scan-at-a-glance summary, not a full dump (the interactive TUI view has no such cap, since it scrolls). */
6
+ const MAX_ITEMS_PER_SEGMENT_LINE = 5;
7
+ const MAX_REPORT_ROWS = 200;
8
+
9
+ function formatTokens(tokens: number): string {
10
+ return tokens >= 1_000 ? `${(tokens / 1_000).toFixed(1)}k` : String(tokens);
11
+ }
12
+
13
+ function percentOf(part: number, whole: number): string {
14
+ return whole > 0 ? `${((part / whole) * 100).toFixed(1)}%` : "—";
15
+ }
16
+
17
+ /** Malevich-compatible row projection with an explicit stack, safe for long linear session trees. */
18
+ export function buildContextRowsIterative(segments: readonly MalevichContextSegment[], totalTokens?: number | null): ContextRow[] {
19
+ const rows: ContextRow[] = [];
20
+ const denominator = totalTokens ?? segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
21
+ for (const segment of segments) {
22
+ const items = [...(segment.items ?? [])]
23
+ .filter((item) => item.estimatedTokens > 0)
24
+ .sort((a, b) => b.estimatedTokens - a.estimatedTokens);
25
+ if (segment.estimatedTokens <= 0 && items.length === 0 && !segment.unknown) continue;
26
+ rows.push({
27
+ key: segment.key,
28
+ isHeader: true,
29
+ depth: 0,
30
+ text: `${segment.label} — ${segment.estimatedTokens} tok (${percentOf(segment.estimatedTokens, denominator)})`,
31
+ });
32
+ const stack = items.reverse().map((item) => ({ item, depth: 1 }));
33
+ while (stack.length > 0) {
34
+ const { item, depth } = stack.pop()!;
35
+ rows.push({ key: segment.key, isHeader: false, depth, text: `${item.estimatedTokens.toString().padStart(6)} tok ${item.label}` });
36
+ const children = [...(item.children ?? [])]
37
+ .filter((child) => child.estimatedTokens > 0)
38
+ .sort((a, b) => b.estimatedTokens - a.estimatedTokens);
39
+ for (let index = children.length - 1; index >= 0; index--) stack.push({ item: children[index]!, depth: depth + 1 });
40
+ }
41
+ }
42
+ return rows;
43
+ }
44
+
45
+ /** Malevich's row builder is confidence-unaware (it's a generic segment/item shape); folding the tier into the label is how it survives into the rendered row text, e.g. "Active Rules [exact-cooperative]". */
46
+ function withConfidenceLabel(segment: ContextSegment): MalevichContextSegment {
47
+ const items = [...(segment.items ?? [])]
48
+ .sort((left, right) => right.estimatedTokens - left.estimatedTokens)
49
+ .slice(0, MAX_ITEMS_PER_SEGMENT_LINE);
50
+ return {
51
+ key: segment.key,
52
+ label: `${segment.label} [${segment.confidence}]`,
53
+ estimatedTokens: segment.estimatedTokens,
54
+ items,
55
+ unknown: segment.unknown,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Plain-text Context Hub report (non-TUI fallback): real usage against the model's effective
61
+ * (reserve-adjusted) budget first -- matching Papyrus's own real-vs-estimate honesty accounting
62
+ * -- an explicit overshoot warning when the known segments' estimates exceed the real total, then
63
+ * every segment heaviest-first. Malevich's buildContextRows renders segments in the order given,
64
+ * so sorting by weight is this function's own policy, not Malevich's.
65
+ */
66
+ export function buildContextReport(breakdown: ContextBreakdown): string {
67
+ const lines: string[] = [];
68
+ if (breakdown.totalTokens !== null && breakdown.effectiveBudget !== null) {
69
+ lines.push(
70
+ `Real usage: ${formatTokens(breakdown.totalTokens)} / ${formatTokens(breakdown.effectiveBudget)} tokens (${percentOf(breakdown.totalTokens, breakdown.effectiveBudget)} of usable budget)`,
71
+ );
72
+ } else if (breakdown.totalTokens !== null) {
73
+ lines.push(`Real usage: ${formatTokens(breakdown.totalTokens)} tokens (model context window unknown)`);
74
+ } else {
75
+ lines.push("Real usage: not yet reported -- sizes below are estimates only");
76
+ }
77
+ if (breakdown.overshootTokens > 0)
78
+ lines.push(`Estimates exceed real total by ~${breakdown.overshootTokens} tok -- sizes below are approximate, not exact`);
79
+
80
+ const sorted = [...breakdown.segments].sort((left, right) => right.estimatedTokens - left.estimatedTokens).map(withConfidenceLabel);
81
+ const rows = buildContextRowsIterative(sorted, breakdown.totalTokens ?? undefined);
82
+ if (rows.length === 0) {
83
+ lines.push("", "(no segments observed yet)");
84
+ return lines.join("\n");
85
+ }
86
+ lines.push("");
87
+ const visibleRows = rows.slice(0, MAX_REPORT_ROWS);
88
+ for (const row of visibleRows) lines.push(row.isHeader ? row.text : `${" ".repeat(row.depth)}${row.text}`);
89
+ if (rows.length > visibleRows.length)
90
+ lines.push(`… ${rows.length - visibleRows.length} more context rows (open /context in TUI mode to search and filter)`);
91
+ return lines.join("\n");
92
+ }