@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.
- package/README.md +19 -3
- package/docs/USAGE_PRIOR_ART.md +1 -1
- package/extension/src/index.ts +379 -118
- package/extension/src/{context-breakdown.ts → observability/context-breakdown.ts} +251 -47
- package/extension/src/observability/context-growth.ts +26 -0
- package/extension/src/{capabilities → observability}/context-hub.ts +1 -5
- package/extension/src/observability/context-report.ts +92 -0
- package/extension/src/observability/context-view.ts +264 -0
- package/extension/src/{footer.ts → observability/footer.ts} +63 -26
- package/extension/src/{capabilities/local-run-telemetry.ts → observability/model-run.ts} +14 -10
- package/extension/src/observability/provider-context-snapshot.ts +246 -0
- package/extension/src/{capabilities/provider-response-telemetry.ts → observability/provider-response.ts} +21 -7
- package/extension/src/{tui.ts → observability/status.ts} +202 -72
- package/extension/src/observability/usage.ts +314 -0
- package/extension/src/optimization/model-selection-panel.ts +160 -0
- package/extension/src/{capabilities/codex-recovery.ts → optimization/recovery/codex.ts} +41 -25
- package/extension/src/service-client.ts +49 -2
- package/extension/src/settings-tui.ts +73 -33
- package/extension/src/settings.ts +40 -29
- package/package.json +11 -5
- package/extension/src/benchmark-tui.ts +0 -113
- package/extension/src/context-report.ts +0 -49
- package/extension/src/context-view.ts +0 -108
- package/extension/src/usage.ts +0 -324
- /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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
35
|
-
|
|
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
|
|
38
|
-
if (typeof content === "string")
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
58
|
-
const content = record
|
|
59
|
-
const text =
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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>)
|
|
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
|
-
|
|
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(
|
|
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) {
|
|
126
|
-
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
? (entry.
|
|
144
|
-
: 0;
|
|
145
|
-
const
|
|
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
|
|
151
|
-
|
|
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 ?
|
|
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({
|
|
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(
|
|
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({
|
|
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({
|
|
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
|
|
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:
|
|
304
|
-
|
|
305
|
-
|
|
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
|
+
}
|