@danypops/pi-jittor 0.1.0 → 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,40 @@
1
+ import {
2
+ CONTEXT_HUB_CONTRIBUTION_DEDUP_LIMIT,
3
+ validateContextContribution,
4
+ type ContextSegment,
5
+ } from "@danypops/jittor";
6
+
7
+ /**
8
+ * Merges Jittor's own directly-computed segments (tool ledger, real usage) with whatever
9
+ * segments other extensions contributed on CONTEXT_HUB_CONTRIBUTION_CHANNEL for the current
10
+ * session. Keeps only the latest contribution per producer -- a producer re-emits every turn
11
+ * (mirroring Papyrus's own context-injection.v1 cadence), so an older segment from the same
12
+ * producer is stale, not a second real contributor.
13
+ */
14
+ export class ContextHubCapability {
15
+ private readonly latestByProducer = new Map<string, ContextSegment>();
16
+ private readonly seen = new Set<string>();
17
+
18
+ /** Validates and records one contribution; silently drops a malformed, stale, or duplicate one without retaining its payload or crashing the caller. */
19
+ observe(payload: unknown, now = Date.now()): void {
20
+ try {
21
+ const contribution = validateContextContribution(payload, now);
22
+ const key = `${contribution.producerName}:${contribution.sequence}`;
23
+ if (this.seen.has(key)) return;
24
+ this.seen.add(key);
25
+ if (this.seen.size > CONTEXT_HUB_CONTRIBUTION_DEDUP_LIMIT) this.seen.delete(this.seen.values().next().value!);
26
+ this.latestByProducer.set(contribution.producerName, contribution.segment);
27
+ } catch {
28
+ // Reject malformed or stale cross-extension contributions without retaining payloads.
29
+ }
30
+ }
31
+
32
+ contributedSegments(): ContextSegment[] {
33
+ return [...this.latestByProducer.values()];
34
+ }
35
+
36
+ reset(): void {
37
+ this.latestByProducer.clear();
38
+ this.seen.clear();
39
+ }
40
+ }
@@ -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
+ }
@@ -0,0 +1,49 @@
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";
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
+
8
+ function formatTokens(tokens: number): string {
9
+ return tokens >= 1_000 ? `${(tokens / 1_000).toFixed(1)}k` : String(tokens);
10
+ }
11
+
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 };
20
+ }
21
+
22
+ /**
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.
28
+ */
29
+ export function buildContextReport(breakdown: ContextBreakdown): string {
30
+ const lines: string[] = [];
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)`);
35
+ } else {
36
+ lines.push("Real usage: not yet reported -- sizes below are estimates only");
37
+ }
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) {
43
+ lines.push("", "(no segments observed yet)");
44
+ return lines.join("\n");
45
+ }
46
+ lines.push("");
47
+ for (const row of rows) lines.push(row.isHeader ? row.text : `${" ".repeat(row.depth)}${row.text}`);
48
+ return lines.join("\n");
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
+ }
@@ -1,6 +1,7 @@
1
1
  import { isAbsolute, relative, resolve, sep } from "node:path";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+ import { ProgressBar } from "malevich-tui-components";
4
5
  import {
5
6
  FOOTER_BAR_MAX_WIDTH,
6
7
  FOOTER_BAR_MIN_WIDTH,
@@ -119,11 +120,11 @@ function barWidth(width: number): number {
119
120
  return width >= FOOTER_WIDE_TERMINAL_WIDTH ? FOOTER_BAR_MAX_WIDTH : FOOTER_BAR_MIN_WIDTH;
120
121
  }
121
122
 
123
+ // Thin wrapper over Malevich's ProgressBar.format() -- keeps this file's own call sites
124
+ // (fraction-first, width-second, tolerant of null/non-finite) unchanged.
122
125
  function progressBar(fraction: number | null, width: number): string {
123
- if (fraction === null || !Number.isFinite(fraction)) return "░".repeat(width);
124
- const clamped = Math.min(1, Math.max(0, fraction));
125
- const filled = Math.round(width * clamped);
126
- return "█".repeat(filled) + "░".repeat(width - filled);
126
+ const value = fraction === null || !Number.isFinite(fraction) ? 0 : fraction;
127
+ return new ProgressBar({ value, max: 1, width }).format(width);
127
128
  }
128
129
 
129
130
  function fillColor(fraction: number | null): FooterColor {
@@ -1,5 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
+ CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN,
4
+ CONTEXT_HUB_CONTRIBUTION_CHANNEL,
3
5
  FOOTER_COMPACTION_RENDER_INTERVAL_MS,
4
6
  MAX_DYNAMIC_ROUTES,
5
7
  PAPYRUS_CONTEXT_INJECTION_CHANNEL,
@@ -10,6 +12,7 @@ import {
10
12
  validatePapyrusContextInjection,
11
13
  applyTaskFocusEvent,
12
14
  validateTaskFocusEvent,
15
+ toolLedgerSegment,
13
16
  TASK_DOMAINS,
14
17
  TASK_TYPES,
15
18
  USAGE_PERIODS,
@@ -35,6 +38,10 @@ import { showUsagePanel } from "./usage.ts";
35
38
  import { CodexRecoveryCapability, SYSTEM_RECOVERY_RUNTIME, type CodexRecoveryRuntime } from "./capabilities/codex-recovery.ts";
36
39
  import { ProviderResponseTelemetry } from "./capabilities/provider-response-telemetry.ts";
37
40
  import { LocalRunTelemetry } from "./capabilities/local-run-telemetry.ts";
41
+ import { ContextHubCapability } from "./capabilities/context-hub.ts";
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";
38
45
 
39
46
  export { formatFooterStatus } from "./tui.ts";
40
47
  export type { CodexRecoveryRuntime } from "./capabilities/codex-recovery.ts";
@@ -253,6 +260,14 @@ export function registerJittorExtension(
253
260
  const localRunTelemetry = new LocalRunTelemetry();
254
261
  const providerResponseTelemetry = new ProviderResponseTelemetry();
255
262
  const codexRecoveryCapability = new CodexRecoveryCapability(pi, codexRecovery, recoveryRuntime);
263
+ const contextHub = new ContextHubCapability();
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[] = [];
256
271
  const contextObservations = new Set<string>();
257
272
  const stopPapyrusContext = pi.events?.on?.(PAPYRUS_CONTEXT_INJECTION_CHANNEL, (payload) => {
258
273
  try {
@@ -458,6 +473,30 @@ export function registerJittorExtension(
458
473
  },
459
474
  });
460
475
 
476
+ pi.registerCommand("context", {
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",
478
+ handler: async (_args, ctx) => {
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);
497
+ },
498
+ });
499
+
461
500
  pi.registerCommand("usage", {
462
501
  description: "Cumulative token/cost usage graph with hourly/daily/weekly/monthly/quarterly views",
463
502
  handler: async (args, ctx) => {
@@ -502,6 +541,7 @@ export function registerJittorExtension(
502
541
  finishCompactionUi();
503
542
  compactionTelemetry = new CompactionTelemetry();
504
543
  localRunTelemetry.reset();
544
+ contextHub.reset();
505
545
  cancelRecovery(true);
506
546
  providerResponseTelemetry.resetTurn();
507
547
  ctx.ui.setStatus("jittor", undefined);
@@ -526,6 +566,15 @@ export function registerJittorExtension(
526
566
  }
527
567
  });
528
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
+
529
578
  pi.on("session_before_compact", async (event, ctx) => {
530
579
  beginCompactionUi(ctx, event.signal);
531
580
  const usage = ctx.getContextUsage();
@@ -642,6 +691,7 @@ export function registerJittorExtension(
642
691
  if (compactionTelemetry.hasOpenCompaction()) await recordMetrics(client, [compactionTelemetry.abort(Date.now(), "session-shutdown")]).catch(() => undefined);
643
692
  stopPapyrusContext?.();
644
693
  stopPapyrusTaskFocus?.();
694
+ stopContextHub?.();
645
695
  cancelRecovery(true);
646
696
  localRunTelemetry.reset();
647
697
  const session_id = ctx.sessionManager.getSessionId();
@@ -1,4 +1,4 @@
1
- import { createRetryingClient, type RetryingClient } from "@danypops/daemon-kit/pi-client";
1
+ import { createRetryingClient, type RetryingClient } from "@danypops/vehicle-client/daemon-client";
2
2
  import { connectJittorClient, type JittorClient, type OperationInputs, type OperationName, type OperationOutputs } from "@danypops/jittor";
3
3
 
4
4
  type JittorConnector = () => Promise<JittorClient>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-jittor",
3
- "version": "0.1.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"],
@@ -12,8 +12,9 @@
12
12
  "extensions": ["extension/src/index.ts"]
13
13
  },
14
14
  "dependencies": {
15
- "@danypops/daemon-kit": "^0.4.0",
16
- "@danypops/jittor": "^0.12.0"
15
+ "@danypops/vehicle-client": "^0.1.1",
16
+ "@danypops/jittor": "^0.14.0",
17
+ "malevich-tui-components": "^0.6.0"
17
18
  },
18
19
  "peerDependencies": {
19
20
  "@earendil-works/pi-coding-agent": "*",