@danypops/pi-jittor 0.1.1 → 0.2.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.
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
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
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Ported from pi-papyrus's context-budget.ts: the Pi-generic half (session message-history tree
|
|
6
|
+
* walk, base-prompt structural breakdown, and the known-segments-vs-real-total composer) that
|
|
7
|
+
* has nothing to do with Papyrus's own artifacts. Papyrus's rules/tasks segments stay in
|
|
8
|
+
* pi-papyrus, contributed to this same breakdown over CONTEXT_HUB_CONTRIBUTION_CHANNEL instead
|
|
9
|
+
* of being computed here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Session entries and tree nodes as SessionManager exposes them (docs/session-format.md,
|
|
14
|
+
* SessionTreeNode from @earendil-works/pi-coding-agent): a subset covering only the fields
|
|
15
|
+
* this estimate reads, so this stays testable with plain object literals instead of
|
|
16
|
+
* importing pi's own session types.
|
|
17
|
+
*/
|
|
18
|
+
export interface SessionEntryLike {
|
|
19
|
+
id: string;
|
|
20
|
+
type: string;
|
|
21
|
+
message?: unknown;
|
|
22
|
+
summary?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface SessionTreeNodeLike {
|
|
25
|
+
entry: SessionEntryLike;
|
|
26
|
+
children: SessionTreeNodeLike[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function messageContentCharacters(message: unknown): number {
|
|
30
|
+
if (typeof message !== "object" || message === null) return 0;
|
|
31
|
+
const record = message as Record<string, unknown>;
|
|
32
|
+
if (record["role"] === "bashExecution") {
|
|
33
|
+
// 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;
|
|
36
|
+
}
|
|
37
|
+
const content = record["content"];
|
|
38
|
+
if (typeof content === "string") return content.length;
|
|
39
|
+
if (!Array.isArray(content)) return 0;
|
|
40
|
+
let characters = 0;
|
|
41
|
+
for (const block of content) {
|
|
42
|
+
if (typeof block !== "object" || block === null) continue;
|
|
43
|
+
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.
|
|
50
|
+
}
|
|
51
|
+
return characters;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function messageSnippet(message: unknown, maxLength = 48): string {
|
|
55
|
+
if (typeof message !== "object" || message === null) return "";
|
|
56
|
+
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
|
+
: "";
|
|
64
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
65
|
+
return collapsed.length > maxLength ? `${collapsed.slice(0, maxLength - 1)}…` : collapsed;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function entryLabel(entry: SessionEntryLike): string {
|
|
69
|
+
if (entry.type === "compaction") return "compaction summary";
|
|
70
|
+
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;
|
|
72
|
+
const prefix = typeof role === "string" ? role : entry.type;
|
|
73
|
+
const snippet = messageSnippet(entry.message);
|
|
74
|
+
return snippet ? `${prefix}: ${snippet}` : prefix;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface MessageHistoryTree {
|
|
78
|
+
/** One item per real tree root (ordinarily one, the session's first entry). */
|
|
79
|
+
items: ContextSegmentItem[];
|
|
80
|
+
/** Sum of tokens for entries on the CURRENT active path only -- what actually feeds the LLM's context right now, unlike content sitting in an abandoned /tree branch. */
|
|
81
|
+
activeTokens: number;
|
|
82
|
+
/** True if the walk hit CONTEXT_TREE_MAX_NODES or found a cycle -- the tree shown is a bounded prefix, not necessarily the complete session. */
|
|
83
|
+
truncated: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface WalkFrame {
|
|
87
|
+
node: SessionTreeNodeLike;
|
|
88
|
+
parentIndex: number | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Walks Pi's own real session tree (ctx.sessionManager.getTree(), docs/session-format.md --
|
|
93
|
+
* entries form a genuine tree via id/parentId, not just the linear current-branch path) to
|
|
94
|
+
* estimate the conversation's context contribution AND surface branches explored via /tree
|
|
95
|
+
* that are no longer on the active path -- content that cost real tokens to generate but is
|
|
96
|
+
* NOT currently part of the context window. Bounded and cycle-safe (CONTEXT_TREE_MAX_NODES).
|
|
97
|
+
*
|
|
98
|
+
* `activeEntryIds` MUST come from ctx.sessionManager.buildContextEntries(), not getBranch().
|
|
99
|
+
* getBranch()'s own docstring says it "[i]ncludes all entry types... Use buildSessionContext()
|
|
100
|
+
* to get the resolved messages for the LLM" -- it does not skip entries a real compaction has
|
|
101
|
+
* already summarized away; using it here would overcount activeTokens for any session that has
|
|
102
|
+
* been compacted at all. buildContextEntries() is Pi's own compaction-aware entry list: the
|
|
103
|
+
* latest compaction entry, its kept entries from firstKeptEntryId onward, and everything after.
|
|
104
|
+
*
|
|
105
|
+
* `branchEntryIds` (optional) is the full raw current-path id set (getBranch()'s own output).
|
|
106
|
+
* When given, an entry on the branch path but excluded from activeEntryIds is labeled
|
|
107
|
+
* "(compacted)" rather than the less accurate "(inactive branch)", which is reserved for
|
|
108
|
+
* entries not on the current path at all (a genuinely abandoned /tree branch). Omitting it
|
|
109
|
+
* preserves the simpler binary active/inactive-branch labeling for callers that only have one
|
|
110
|
+
* set to give (e.g. tests).
|
|
111
|
+
*
|
|
112
|
+
* Iterative (not recursive) two-pass walk: an explicit-stack pre-order discovery pass followed
|
|
113
|
+
* by a reverse-order (children-before-parent) construction pass -- an ordinary long-running
|
|
114
|
+
* session is one long linear chain, so recursion depth would equal entry count.
|
|
115
|
+
*/
|
|
116
|
+
export function buildMessageHistoryTree(roots: ReadonlyArray<SessionTreeNodeLike>, activeEntryIds: ReadonlySet<string>, branchEntryIds?: ReadonlySet<string>): MessageHistoryTree {
|
|
117
|
+
const visited = new Set<string>();
|
|
118
|
+
let truncated = false;
|
|
119
|
+
let activeTokens = 0;
|
|
120
|
+
|
|
121
|
+
const order: WalkFrame[] = [];
|
|
122
|
+
const stack: WalkFrame[] = [...roots].reverse().map((root) => ({ node: root, parentIndex: null }));
|
|
123
|
+
while (stack.length > 0) {
|
|
124
|
+
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
|
|
127
|
+
visited.add(frame.node.entry.id);
|
|
128
|
+
const index = order.length;
|
|
129
|
+
order.push(frame);
|
|
130
|
+
const children = [...frame.node.children].reverse().map((child) => ({ node: child, parentIndex: index }));
|
|
131
|
+
stack.push(...children);
|
|
132
|
+
}
|
|
133
|
+
if (stack.length > 0) truncated = true; // node bound hit with more work still queued
|
|
134
|
+
|
|
135
|
+
const childItemsByParent = new Map<number, ContextSegmentItem[]>();
|
|
136
|
+
const itemByIndex = new Map<number, ContextSegmentItem>();
|
|
137
|
+
for (let index = order.length - 1; index >= 0; index--) {
|
|
138
|
+
const frame = order[index]!;
|
|
139
|
+
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);
|
|
146
|
+
const isActive = activeEntryIds.has(entry.id);
|
|
147
|
+
if (isActive) activeTokens += tokens;
|
|
148
|
+
const isOnBranch = branchEntryIds ? branchEntryIds.has(entry.id) : isActive; // no branch set given -- fall back to the old binary active/inactive-branch label
|
|
149
|
+
|
|
150
|
+
const children = childItemsByParent.get(index) ?? [];
|
|
151
|
+
if (tokens === 0 && children.length === 0) continue; // no content, no descendants with content -- nothing to show
|
|
152
|
+
|
|
153
|
+
const item: ContextSegmentItem = {
|
|
154
|
+
label: isActive ? entryLabel(entry) : isOnBranch ? `${entryLabel(entry)} (compacted)` : `${entryLabel(entry)} (inactive branch)`,
|
|
155
|
+
estimatedTokens: tokens,
|
|
156
|
+
...(children.length > 0 ? { children } : {}),
|
|
157
|
+
};
|
|
158
|
+
itemByIndex.set(index, item);
|
|
159
|
+
if (frame.parentIndex !== null) {
|
|
160
|
+
const siblings = childItemsByParent.get(frame.parentIndex) ?? [];
|
|
161
|
+
siblings.unshift(item); // reverse-order processing -- unshift restores original document order
|
|
162
|
+
childItemsByParent.set(frame.parentIndex, siblings);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const items: ContextSegmentItem[] = [];
|
|
167
|
+
for (let index = 0; index < order.length; index++) {
|
|
168
|
+
if (order[index]!.parentIndex === null) {
|
|
169
|
+
const item = itemByIndex.get(index);
|
|
170
|
+
if (item) items.push(item);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { items, activeTokens, truncated };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function toCeilTokens(characters: number): number {
|
|
177
|
+
return Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Splits Pi's base system prompt into real structural sub-segments instead of one opaque
|
|
182
|
+
* number, using BeforeAgentStartEvent's own systemPromptOptions field -- Pi's own doc comment
|
|
183
|
+
* on it: "Extensions can inspect this to understand what Pi loaded without re-discovering
|
|
184
|
+
* resources." No new hook, no new risk: before_agent_start is already wired.
|
|
185
|
+
*
|
|
186
|
+
* Deliberately measures each INPUT's raw content size (tool snippet text, skill metadata,
|
|
187
|
+
* context file content) rather than attempting to byte-for-byte reproduce Pi's internal
|
|
188
|
+
* wrapping/tag format -- buildSystemPrompt() and formatSkillsForPrompt() are Pi-internal
|
|
189
|
+
* functions, not part of the public extension API. The remainder item absorbs whatever
|
|
190
|
+
* wrapping/template text this doesn't attribute, so the segment's total always still matches
|
|
191
|
+
* the real observed prompt length exactly.
|
|
192
|
+
*
|
|
193
|
+
* Measured as of THIS extension's own before_agent_start handler, which runs at whatever point
|
|
194
|
+
* Pi's own extension-load order places it in the before_agent_start chain -- an earlier
|
|
195
|
+
* extension's own systemPrompt mutation (e.g. an injected Rules/Tasks block) is already baked
|
|
196
|
+
* into event.systemPrompt by the time a later handler sees it. There is no per-handler identity
|
|
197
|
+
* in Pi's event payload to detect this, so this measurement is only as "pure Pi base prompt" as
|
|
198
|
+
* this extension's actual position in the load order happens to make it -- a real, documented
|
|
199
|
+
* limitation, not a promise.
|
|
200
|
+
*/
|
|
201
|
+
export function buildBasePromptItems(options: BuildSystemPromptOptions, totalCharacters: number): ContextSegmentItem[] {
|
|
202
|
+
const items: ContextSegmentItem[] = [];
|
|
203
|
+
|
|
204
|
+
const toolSnippetEntries = Object.entries(options.toolSnippets ?? {});
|
|
205
|
+
// Mirrors buildSystemPrompt()'s own "- name: snippet\n" line shape closely enough to be a
|
|
206
|
+
// fair estimate without importing Pi-internal formatting code.
|
|
207
|
+
const toolSnippetsCharacters = toolSnippetEntries.reduce((sum, [name, snippet]) => sum + name.length + snippet.length + 4, 0);
|
|
208
|
+
if (toolSnippetsCharacters > 0) {
|
|
209
|
+
items.push({ label: `Tool snippets (${toolSnippetEntries.length} tools)`, estimatedTokens: toCeilTokens(toolSnippetsCharacters) });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
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);
|
|
214
|
+
if (skillsCharacters > 0) {
|
|
215
|
+
items.push({ label: `Skills catalog (${visibleSkills.length} skills)`, estimatedTokens: toCeilTokens(skillsCharacters) });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const contextFiles = options.contextFiles ?? [];
|
|
219
|
+
const contextFilesCharacters = contextFiles.reduce((sum, file) => sum + file.path.length + file.content.length + 40, 0);
|
|
220
|
+
if (contextFilesCharacters > 0) {
|
|
221
|
+
items.push({ label: `Project context files (${contextFiles.length}, e.g. AGENTS.md)`, estimatedTokens: toCeilTokens(contextFilesCharacters) });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const knownCharacters = toolSnippetsCharacters + skillsCharacters + contextFilesCharacters;
|
|
225
|
+
const remainderCharacters = Math.max(0, totalCharacters - knownCharacters);
|
|
226
|
+
if (remainderCharacters > 0 || items.length === 0) {
|
|
227
|
+
items.push({ label: "Base template, guidelines, and formatting", estimatedTokens: toCeilTokens(remainderCharacters) });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return items;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Wraps a cached before_agent_start observation into the basePrompt ContextSegment -- `unknown` before any turn has run yet, so a display layer never mistakes "not observed yet" for "measured and empty". */
|
|
234
|
+
export function basePromptSegment(estimatedTokens: number | null, items: ContextSegmentItem[]): ContextSegment {
|
|
235
|
+
return {
|
|
236
|
+
key: "basePrompt",
|
|
237
|
+
label: estimatedTokens === null ? "Base system prompt (not observed yet)" : "Base system prompt (Pi + host instructions)",
|
|
238
|
+
estimatedTokens: estimatedTokens ?? 0,
|
|
239
|
+
confidence: "exact-structural",
|
|
240
|
+
...(estimatedTokens === null ? { unknown: true } : {}),
|
|
241
|
+
...(items.length > 0 ? { items } : {}),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Wraps a buildMessageHistoryTree() result into the messageHistory ContextSegment -- only the active-path token sum counts toward the segment total; an abandoned /tree branch still appears in items but contributes zero. */
|
|
246
|
+
export function messageHistorySegment(tree: MessageHistoryTree): ContextSegment {
|
|
247
|
+
return {
|
|
248
|
+
key: "messageHistory",
|
|
249
|
+
label: "Conversation message history",
|
|
250
|
+
estimatedTokens: tree.activeTokens,
|
|
251
|
+
confidence: "exact-structural",
|
|
252
|
+
...(tree.items.length > 0 ? { items: tree.items } : {}),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface ContextBreakdown {
|
|
257
|
+
/** Real usage from ctx.getContextUsage() -- ground truth, not estimated. Null only when Pi has no usage yet (e.g. before the first turn). */
|
|
258
|
+
totalTokens: number | null;
|
|
259
|
+
/** From ctx.model.contextWindow. Null when the active model's context window is unknown. */
|
|
260
|
+
contextWindow: number | null;
|
|
261
|
+
/** contextWindow - reserveTokens, mirroring Pi's own compaction-trigger formula. Null when contextWindow is unknown. */
|
|
262
|
+
effectiveBudget: number | null;
|
|
263
|
+
/**
|
|
264
|
+
* How much the known segments (Jittor's own plus whatever else was contributed) exceed the
|
|
265
|
+
* real total, when they do. Zero means no overshoot. This must stay visible rather than only
|
|
266
|
+
* being absorbed into "unaccounted" clamping to zero -- a clamped-to-zero unaccounted segment
|
|
267
|
+
* does NOT mean wire-protocol overhead is actually free; it means the other segments already
|
|
268
|
+
* consumed the entire real budget on paper. Hiding that distinction would make a genuinely
|
|
269
|
+
* nonzero cost look like zero.
|
|
270
|
+
*/
|
|
271
|
+
overshootTokens: number;
|
|
272
|
+
/** Every input segment, in the order given, plus "other" absorbing whatever real usage the rest don't account for. */
|
|
273
|
+
segments: ContextSegment[];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface ComposeContextBreakdownInput {
|
|
277
|
+
totalTokens: number | null;
|
|
278
|
+
contextWindow: number | null;
|
|
279
|
+
reserveTokens?: number;
|
|
280
|
+
/** Every segment currently known: Jittor's own directly-computed ones (basePrompt, messageHistory, toolDefinitions) plus whatever else was contributed on CONTEXT_HUB_CONTRIBUTION_CHANNEL (e.g. Papyrus's rules/tasks). Order is preserved for rendering. */
|
|
281
|
+
segments: ContextSegment[];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Composes every segment currently known (Jittor's own, plus whatever any extension
|
|
286
|
+
* contributed) against the real total Pi reports, deriving "unaccounted" (genuine
|
|
287
|
+
* wire-protocol overhead -- message envelope/role wrapping, cache-control markers -- which
|
|
288
|
+
* really is invisible to any extension) as the remainder. The remainder is clamped to zero
|
|
289
|
+
* rather than shown negative -- char/4 token estimation is approximate, and a small overshoot
|
|
290
|
+
* in the known segments must not display as a nonsensical negative bucket -- but the clamp
|
|
291
|
+
* amount itself is preserved as overshootTokens rather than silently discarded, so a consumer
|
|
292
|
+
* can tell "genuinely zero" apart from "our other estimates already exceeded the real total".
|
|
293
|
+
* When the real total is unavailable, unaccounted is reported as zero and totalTokens surfaces
|
|
294
|
+
* as null so callers can label the whole breakdown as estimate-only rather than silently
|
|
295
|
+
* treating a partial sum as ground truth.
|
|
296
|
+
*/
|
|
297
|
+
export function composeContextBreakdown(input: ComposeContextBreakdownInput): ContextBreakdown {
|
|
298
|
+
const reserveTokens = input.reserveTokens ?? CONTEXT_DEFAULT_RESERVE_TOKENS;
|
|
299
|
+
const knownTokens = input.segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
|
|
300
|
+
const overshootTokens = input.totalTokens === null ? 0 : Math.max(0, knownTokens - input.totalTokens);
|
|
301
|
+
const other: ContextSegment = {
|
|
302
|
+
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)",
|
|
306
|
+
estimatedTokens: input.totalTokens === null ? 0 : Math.max(0, input.totalTokens - knownTokens),
|
|
307
|
+
confidence: "correlated",
|
|
308
|
+
};
|
|
309
|
+
return {
|
|
310
|
+
totalTokens: input.totalTokens,
|
|
311
|
+
contextWindow: input.contextWindow,
|
|
312
|
+
effectiveBudget: input.contextWindow === null ? null : Math.max(0, input.contextWindow - reserveTokens),
|
|
313
|
+
overshootTokens,
|
|
314
|
+
segments: [...input.segments, other],
|
|
315
|
+
};
|
|
316
|
+
}
|
|
@@ -1,46 +1,49 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { buildContextRows, type ContextSegment as MalevichContextSegment } from "malevich-tui-components";
|
|
2
|
+
import type { ContextBreakdown } from "./context-breakdown.ts";
|
|
3
|
+
import type { ContextSegment } from "@danypops/jittor";
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
tokens: number | null;
|
|
5
|
-
contextWindow: number;
|
|
6
|
-
percent: number | null;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/** Bounds how many items render per segment -- a report is a scan-at-a-glance summary, not a full dump. */
|
|
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). */
|
|
10
6
|
const MAX_ITEMS_PER_SEGMENT_LINE = 5;
|
|
11
7
|
|
|
12
8
|
function formatTokens(tokens: number): string {
|
|
13
9
|
return tokens >= 1_000 ? `${(tokens / 1_000).toFixed(1)}k` : String(tokens);
|
|
14
10
|
}
|
|
15
11
|
|
|
16
|
-
function
|
|
17
|
-
return
|
|
12
|
+
function percentOf(part: number, whole: number): string {
|
|
13
|
+
return whole > 0 ? `${((part / whole) * 100).toFixed(1)}%` : "—";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 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]". */
|
|
17
|
+
function withConfidenceLabel(segment: ContextSegment): MalevichContextSegment {
|
|
18
|
+
const items = [...(segment.items ?? [])].sort((left, right) => right.estimatedTokens - left.estimatedTokens).slice(0, MAX_ITEMS_PER_SEGMENT_LINE);
|
|
19
|
+
return { key: segment.key, label: `${segment.label} [${segment.confidence}]`, estimatedTokens: segment.estimatedTokens, items, unknown: segment.unknown };
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
|
-
* Plain-text Context Hub report: real
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* Plain-text Context Hub report (non-TUI fallback): real usage against the model's effective
|
|
24
|
+
* (reserve-adjusted) budget first -- matching Papyrus's own real-vs-estimate honesty accounting
|
|
25
|
+
* -- an explicit overshoot warning when the known segments' estimates exceed the real total, then
|
|
26
|
+
* every segment heaviest-first. Malevich's buildContextRows renders segments in the order given,
|
|
27
|
+
* so sorting by weight is this function's own policy, not Malevich's.
|
|
26
28
|
*/
|
|
27
|
-
export function buildContextReport(
|
|
29
|
+
export function buildContextReport(breakdown: ContextBreakdown): string {
|
|
28
30
|
const lines: string[] = [];
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
if (breakdown.totalTokens !== null && breakdown.effectiveBudget !== null) {
|
|
32
|
+
lines.push(`Real usage: ${formatTokens(breakdown.totalTokens)} / ${formatTokens(breakdown.effectiveBudget)} tokens (${percentOf(breakdown.totalTokens, breakdown.effectiveBudget)} of usable budget)`);
|
|
33
|
+
} else if (breakdown.totalTokens !== null) {
|
|
34
|
+
lines.push(`Real usage: ${formatTokens(breakdown.totalTokens)} tokens (model context window unknown)`);
|
|
32
35
|
} else {
|
|
33
36
|
lines.push("Real usage: not yet reported -- sizes below are estimates only");
|
|
34
37
|
}
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
if (breakdown.overshootTokens > 0) lines.push(`Estimates exceed real total by ~${breakdown.overshootTokens} tok -- sizes below are approximate, not exact`);
|
|
39
|
+
|
|
40
|
+
const sorted = [...breakdown.segments].sort((left, right) => right.estimatedTokens - left.estimatedTokens).map(withConfidenceLabel);
|
|
41
|
+
const rows = buildContextRows(sorted, breakdown.totalTokens ?? undefined);
|
|
42
|
+
if (rows.length === 0) {
|
|
37
43
|
lines.push("", "(no segments observed yet)");
|
|
38
44
|
return lines.join("\n");
|
|
39
45
|
}
|
|
40
46
|
lines.push("");
|
|
41
|
-
for (const
|
|
42
|
-
lines.push(`${segment.label} — ${formatTokens(segment.estimatedTokens)} tok [${segment.confidence}]`);
|
|
43
|
-
for (const item of topItems(segment.items)) lines.push(` ${formatTokens(item.estimatedTokens)} tok ${item.label}`);
|
|
44
|
-
}
|
|
47
|
+
for (const row of rows) lines.push(row.isHeader ? row.text : `${" ".repeat(row.depth)}${row.text}`);
|
|
45
48
|
return lines.join("\n");
|
|
46
49
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { ExtensionCommandContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { matchesKey, truncateToWidth, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { buildContextRows, renderContextRowLines, renderContextUsageBar, type ContextBarTheme, type ContextRow, type ContextRowsTheme, type ContextSegment as MalevichContextSegment } from "malevich-tui-components";
|
|
4
|
+
import type { ContextSegment } from "@danypops/jittor";
|
|
5
|
+
import type { ContextBreakdown } from "./context-breakdown.ts";
|
|
6
|
+
import { buildContextReport } from "./context-report.ts";
|
|
7
|
+
|
|
8
|
+
const VISIBLE_ROWS = 24;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A dynamically-contributed segment set (any string key from any extension, not a fixed enum)
|
|
12
|
+
* can't use a hardcoded per-key color map the way Papyrus's own ContextViewport did for its
|
|
13
|
+
* fixed seven segments -- this cycles a small categorical palette keyed by a stable hash of the
|
|
14
|
+
* segment key, so the same key always renders the same color within one process without needing
|
|
15
|
+
* every possible contributor's key to be known in advance.
|
|
16
|
+
*/
|
|
17
|
+
const PALETTE: ThemeColor[] = ["accent", "success", "syntaxFunction", "warning", "syntaxKeyword", "syntaxType", "muted"];
|
|
18
|
+
|
|
19
|
+
function paletteColor(key: string): ThemeColor {
|
|
20
|
+
let hash = 0;
|
|
21
|
+
for (let index = 0; index < key.length; index += 1) hash = (hash * 31 + key.charCodeAt(index)) >>> 0;
|
|
22
|
+
return PALETTE[hash % PALETTE.length]!;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatTokenCount(tokens: number): string {
|
|
26
|
+
return tokens >= 1_000 ? `${(tokens / 1_000).toFixed(1)}k` : String(tokens);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function percentOf(part: number, whole: number): string {
|
|
30
|
+
return whole > 0 ? `${((part / whole) * 100).toFixed(1)}%` : "—";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Folds each segment's confidence tier into its label so it survives Malevich's confidence-unaware row builder. */
|
|
34
|
+
function withConfidenceLabel(segment: ContextSegment): MalevichContextSegment {
|
|
35
|
+
return { key: segment.key, label: `${segment.label} [${segment.confidence}]`, estimatedTokens: segment.estimatedTokens, items: segment.items, unknown: segment.unknown };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class ContextViewport {
|
|
39
|
+
private offsetY = 0;
|
|
40
|
+
private readonly rows: ContextRow[];
|
|
41
|
+
private readonly segments: readonly MalevichContextSegment[];
|
|
42
|
+
|
|
43
|
+
constructor(
|
|
44
|
+
private readonly tui: TUI,
|
|
45
|
+
private readonly theme: Theme,
|
|
46
|
+
private readonly breakdown: ContextBreakdown,
|
|
47
|
+
private readonly close: () => void,
|
|
48
|
+
) {
|
|
49
|
+
// Heaviest-first: Malevich renders segments in the order given, so sorting by weight for a
|
|
50
|
+
// merged multi-producer view is this viewport's own policy, matching context-report.ts.
|
|
51
|
+
this.segments = [...breakdown.segments].sort((left, right) => right.estimatedTokens - left.estimatedTokens).map(withConfidenceLabel);
|
|
52
|
+
this.rows = buildContextRows(this.segments, breakdown.totalTokens ?? undefined);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
invalidate(): void {}
|
|
56
|
+
|
|
57
|
+
render(width: number): string[] {
|
|
58
|
+
const theme = this.theme;
|
|
59
|
+
const contentWidth = Math.max(1, width);
|
|
60
|
+
const border = theme.fg("borderMuted", "─".repeat(contentWidth));
|
|
61
|
+
const lines: string[] = [border, truncateToWidth(theme.fg("accent", theme.bold("Context")), contentWidth, "")];
|
|
62
|
+
|
|
63
|
+
const { totalTokens, effectiveBudget } = this.breakdown;
|
|
64
|
+
if (totalTokens !== null && effectiveBudget !== null) {
|
|
65
|
+
lines.push(truncateToWidth(`${formatTokenCount(totalTokens)} / ${formatTokenCount(effectiveBudget)} tokens (${percentOf(totalTokens, effectiveBudget)} of usable budget)`, contentWidth, ""));
|
|
66
|
+
} else if (totalTokens !== null) {
|
|
67
|
+
lines.push(truncateToWidth(`${formatTokenCount(totalTokens)} tokens (model context window unknown)`, contentWidth, ""));
|
|
68
|
+
} else {
|
|
69
|
+
lines.push(theme.fg("dim", "No real usage reported yet — sizes below are estimates only"));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const colorFor = (key: string) => (s: string) => theme.fg(paletteColor(key), s);
|
|
73
|
+
const barTheme: ContextBarTheme = { colorFor, empty: (s) => theme.fg("dim", s) };
|
|
74
|
+
lines.push(renderContextUsageBar(barTheme, this.segments, contentWidth, effectiveBudget ?? undefined, totalTokens ?? undefined));
|
|
75
|
+
if (this.breakdown.overshootTokens > 0) {
|
|
76
|
+
lines.push(truncateToWidth(theme.fg("warning", `Estimates exceed real total by ~${this.breakdown.overshootTokens} tok — sizes below are approximate, not exact`), contentWidth, ""));
|
|
77
|
+
}
|
|
78
|
+
lines.push("");
|
|
79
|
+
|
|
80
|
+
const rowsTheme: ContextRowsTheme = { colorFor, header: (s) => theme.bold(s) };
|
|
81
|
+
const visible = this.rows.slice(this.offsetY, this.offsetY + VISIBLE_ROWS);
|
|
82
|
+
lines.push(...renderContextRowLines(visible, contentWidth, rowsTheme));
|
|
83
|
+
if (this.rows.length === 0) lines.push(theme.fg("dim", " (nothing observed yet)"));
|
|
84
|
+
else lines.push(theme.fg("muted", ` ${Math.min(this.offsetY + VISIBLE_ROWS, this.rows.length)}/${this.rows.length}`));
|
|
85
|
+
|
|
86
|
+
lines.push("");
|
|
87
|
+
lines.push(theme.fg("dim", "↑↓ scroll · esc close"));
|
|
88
|
+
lines.push(border);
|
|
89
|
+
return lines;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
handleInput(data: string): void {
|
|
93
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); return; }
|
|
94
|
+
if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
|
|
95
|
+
else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.rows.length - VISIBLE_ROWS), this.offsetY + 1);
|
|
96
|
+
else return;
|
|
97
|
+
this.tui.requestRender();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Interactive scrollable Context Hub view in TUI mode; the same plain-text report as /context's non-interactive path otherwise. */
|
|
102
|
+
export async function showContextView(ctx: ExtensionCommandContext, breakdown: ContextBreakdown): Promise<void> {
|
|
103
|
+
if (ctx.mode !== "tui") {
|
|
104
|
+
ctx.ui.notify(buildContextReport(breakdown), "info");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => new ContextViewport(tui, theme, breakdown, done));
|
|
108
|
+
}
|
package/extension/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import {
|
|
3
|
+
CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN,
|
|
3
4
|
CONTEXT_HUB_CONTRIBUTION_CHANNEL,
|
|
4
5
|
FOOTER_COMPACTION_RENDER_INTERVAL_MS,
|
|
5
6
|
MAX_DYNAMIC_ROUTES,
|
|
@@ -38,7 +39,9 @@ import { CodexRecoveryCapability, SYSTEM_RECOVERY_RUNTIME, type CodexRecoveryRun
|
|
|
38
39
|
import { ProviderResponseTelemetry } from "./capabilities/provider-response-telemetry.ts";
|
|
39
40
|
import { LocalRunTelemetry } from "./capabilities/local-run-telemetry.ts";
|
|
40
41
|
import { ContextHubCapability } from "./capabilities/context-hub.ts";
|
|
41
|
-
import {
|
|
42
|
+
import { basePromptSegment, buildBasePromptItems, buildMessageHistoryTree, composeContextBreakdown, messageHistorySegment, type SessionEntryLike, type SessionTreeNodeLike } from "./context-breakdown.ts";
|
|
43
|
+
import { showContextView } from "./context-view.ts";
|
|
44
|
+
import type { ContextSegmentItem } from "@danypops/jittor";
|
|
42
45
|
|
|
43
46
|
export { formatFooterStatus } from "./tui.ts";
|
|
44
47
|
export type { CodexRecoveryRuntime } from "./capabilities/codex-recovery.ts";
|
|
@@ -259,6 +262,12 @@ export function registerJittorExtension(
|
|
|
259
262
|
const codexRecoveryCapability = new CodexRecoveryCapability(pi, codexRecovery, recoveryRuntime);
|
|
260
263
|
const contextHub = new ContextHubCapability();
|
|
261
264
|
const stopContextHub = pi.events?.on?.(CONTEXT_HUB_CONTRIBUTION_CHANNEL, (payload) => contextHub.observe(payload));
|
|
265
|
+
// Cached from the most recent before_agent_start observation: Pi's own base system prompt is
|
|
266
|
+
// only ever visible transiently inside that hook's event, so /context reuses this rather than
|
|
267
|
+
// going without it entirely. Measured as of THIS extension's own place in the before_agent_start
|
|
268
|
+
// chain -- see buildBasePromptItems' own doc comment for the load-order caveat this implies.
|
|
269
|
+
let lastObservedBasePromptTokens: number | null = null;
|
|
270
|
+
let lastObservedBasePromptItems: ContextSegmentItem[] = [];
|
|
262
271
|
const contextObservations = new Set<string>();
|
|
263
272
|
const stopPapyrusContext = pi.events?.on?.(PAPYRUS_CONTEXT_INJECTION_CHANNEL, (payload) => {
|
|
264
273
|
try {
|
|
@@ -465,11 +474,26 @@ export function registerJittorExtension(
|
|
|
465
474
|
});
|
|
466
475
|
|
|
467
476
|
pi.registerCommand("context", {
|
|
468
|
-
description: "Context Hub: real usage plus every segment's estimated size (tool schemas by owning extension, and whatever other extensions contributed), each tagged with how it was attributed",
|
|
477
|
+
description: "Context Hub: real usage plus every segment's estimated size (base prompt, message history, tool schemas by owning extension, and whatever other extensions contributed), each tagged with how it was attributed",
|
|
469
478
|
handler: async (_args, ctx) => {
|
|
470
|
-
const
|
|
471
|
-
const
|
|
472
|
-
|
|
479
|
+
const activeToolNames = new Set(pi.getActiveTools());
|
|
480
|
+
const toolSegment = toolLedgerSegment(pi.getAllTools().filter((tool) => activeToolNames.has(tool.name)));
|
|
481
|
+
// Real tree (not just the linear current-branch path): surfaces content sitting in an
|
|
482
|
+
// abandoned /tree branch, which cost real tokens to generate but isn't in context now.
|
|
483
|
+
const tree = ctx.sessionManager.getTree() as SessionTreeNodeLike[];
|
|
484
|
+
// buildContextEntries(), NOT getBranch(): getBranch() returns every raw entry on the current
|
|
485
|
+
// path including everything a real compaction has already summarized away.
|
|
486
|
+
const activeEntryIds = new Set((ctx.sessionManager.buildContextEntries() as SessionEntryLike[]).map((entry) => entry.id));
|
|
487
|
+
const branchEntryIds = new Set((ctx.sessionManager.getBranch() as SessionEntryLike[]).map((entry) => entry.id));
|
|
488
|
+
const messageHistory = buildMessageHistoryTree(tree, activeEntryIds, branchEntryIds);
|
|
489
|
+
const usage = ctx.getContextUsage();
|
|
490
|
+
const ownSegments = [basePromptSegment(lastObservedBasePromptTokens, lastObservedBasePromptItems), messageHistorySegment(messageHistory), toolSegment];
|
|
491
|
+
const breakdown = composeContextBreakdown({
|
|
492
|
+
totalTokens: usage?.tokens ?? null,
|
|
493
|
+
contextWindow: ctx.model?.contextWindow ?? null,
|
|
494
|
+
segments: [...ownSegments, ...contextHub.contributedSegments()],
|
|
495
|
+
});
|
|
496
|
+
await showContextView(ctx, breakdown);
|
|
473
497
|
},
|
|
474
498
|
});
|
|
475
499
|
|
|
@@ -542,6 +566,15 @@ export function registerJittorExtension(
|
|
|
542
566
|
}
|
|
543
567
|
});
|
|
544
568
|
|
|
569
|
+
pi.on("before_agent_start", async (event) => {
|
|
570
|
+
// No new hook, no new risk: measures event.systemPrompt's length and structural
|
|
571
|
+
// event.systemPromptOptions as-of this handler's own place in the before_agent_start chain --
|
|
572
|
+
// see buildBasePromptItems' own doc comment for the resulting load-order caveat.
|
|
573
|
+
const characters = (event.systemPrompt ?? "").length;
|
|
574
|
+
lastObservedBasePromptTokens = Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
|
|
575
|
+
lastObservedBasePromptItems = buildBasePromptItems(event.systemPromptOptions, characters);
|
|
576
|
+
});
|
|
577
|
+
|
|
545
578
|
pi.on("session_before_compact", async (event, ctx) => {
|
|
546
579
|
beginCompactionUi(ctx, event.signal);
|
|
547
580
|
const usage = ctx.getContextUsage();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-jittor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Pi extension for Jittor: native routing enforcement, footer, settings, usage graphs, and benchmark panels backed by the @danypops/jittor daemon",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": ["pi-package", "llm-router", "token-budget"],
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@danypops/vehicle-client": "^0.1.1",
|
|
16
|
-
"@danypops/jittor": "^0.
|
|
17
|
-
"malevich-tui-components": "^0.
|
|
16
|
+
"@danypops/jittor": "^0.14.0",
|
|
17
|
+
"malevich-tui-components": "^0.6.0"
|
|
18
18
|
},
|
|
19
19
|
"peerDependencies": {
|
|
20
20
|
"@earendil-works/pi-coding-agent": "*",
|