@danypops/papyrus 0.13.1 → 0.13.3
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/extension/src/context-budget.ts +182 -65
- package/extension/src/context-view.ts +106 -99
- package/extension/src/index.ts +12 -7
- package/package.json +1 -1
- package/src/constants.ts +12 -0
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import { CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN } from "../../src/constants.ts";
|
|
3
|
+
import { CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN, CONTEXT_TREE_MAX_DEPTH, CONTEXT_TREE_MAX_NODES } from "../../src/constants.ts";
|
|
4
4
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
5
|
+
import type { TaskGraph } from "../../src/task-service.ts";
|
|
5
6
|
import { discoverSkillDirectories, scanSkillCatalogFootprint, type SkillCatalogFootprint } from "./skill-catalog-footprint.ts";
|
|
6
7
|
import { ruleInjectionPreview } from "./rules.ts";
|
|
7
8
|
|
|
8
|
-
const REPORT_MAX_ROWS = 5;
|
|
9
|
-
|
|
10
9
|
export interface RuleBudgetEntry {
|
|
11
10
|
id: string;
|
|
12
11
|
title: string;
|
|
@@ -68,6 +67,13 @@ export const DEFAULT_RESERVE_TOKENS = 16_384;
|
|
|
68
67
|
export interface ContextSegmentItem {
|
|
69
68
|
label: string;
|
|
70
69
|
estimatedTokens: number;
|
|
70
|
+
/**
|
|
71
|
+
* Recursive children, when this item has real hierarchy of its own -- conversation history
|
|
72
|
+
* (Pi's session entries form a genuine tree via id/parentId, docs/session-format.md) and
|
|
73
|
+
* Papyrus Tasks (containment via parentIds/childIds) both do; Rules and Skills don't, so
|
|
74
|
+
* their items simply omit this field, degenerating to a flat one-level tree.
|
|
75
|
+
*/
|
|
76
|
+
children?: ContextSegmentItem[];
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
export interface ContextSegment {
|
|
@@ -76,18 +82,32 @@ export interface ContextSegment {
|
|
|
76
82
|
estimatedTokens: number;
|
|
77
83
|
/** Drill-down items, when this segment can be broken down further. Absent for "other" -- an opaque remainder, not a real category. */
|
|
78
84
|
items?: ContextSegmentItem[];
|
|
85
|
+
/**
|
|
86
|
+
* True when this segment's size is genuinely unmeasured (not yet observed), as opposed to
|
|
87
|
+
* measured-and-actually-zero. A display layer that hides zero-token rows to cut noise must
|
|
88
|
+
* NOT hide an unknown segment just because its placeholder value happens to be zero --
|
|
89
|
+
* that would silently misrepresent "we don't know" as "there is nothing here", the same
|
|
90
|
+
* category of honesty problem overshootTokens exists to prevent for the unaccounted bucket.
|
|
91
|
+
*/
|
|
92
|
+
unknown?: boolean;
|
|
79
93
|
}
|
|
80
94
|
|
|
81
95
|
/**
|
|
82
|
-
* Session
|
|
83
|
-
*
|
|
84
|
-
*
|
|
96
|
+
* Session entries and tree nodes as SessionManager exposes them (docs/session-format.md,
|
|
97
|
+
* SessionTreeNode from @earendil-works/pi-coding-agent): a subset covering only the fields
|
|
98
|
+
* this estimate reads, so this stays testable with plain object literals instead of
|
|
99
|
+
* importing pi's own session types.
|
|
85
100
|
*/
|
|
86
|
-
export interface
|
|
101
|
+
export interface SessionEntryLike {
|
|
102
|
+
id: string;
|
|
87
103
|
type: string;
|
|
88
104
|
message?: unknown;
|
|
89
105
|
summary?: string;
|
|
90
106
|
}
|
|
107
|
+
export interface SessionTreeNodeLike {
|
|
108
|
+
entry: SessionEntryLike;
|
|
109
|
+
children: SessionTreeNodeLike[];
|
|
110
|
+
}
|
|
91
111
|
|
|
92
112
|
function messageContentCharacters(message: unknown): number {
|
|
93
113
|
if (typeof message !== "object" || message === null) return 0;
|
|
@@ -114,22 +134,84 @@ function messageContentCharacters(message: unknown): number {
|
|
|
114
134
|
return characters;
|
|
115
135
|
}
|
|
116
136
|
|
|
137
|
+
function messageSnippet(message: unknown, maxLength = 48): string {
|
|
138
|
+
if (typeof message !== "object" || message === null) return "";
|
|
139
|
+
const record = message as Record<string, unknown>;
|
|
140
|
+
if (record["role"] === "bashExecution") return String(record["command"] ?? "");
|
|
141
|
+
const content = record["content"];
|
|
142
|
+
const text = typeof content === "string"
|
|
143
|
+
? content
|
|
144
|
+
: Array.isArray(content)
|
|
145
|
+
? content.map((block) => (typeof block === "object" && block !== null && (block as Record<string, unknown>)["type"] === "text" ? String((block as Record<string, unknown>)["text"] ?? "") : "")).join(" ")
|
|
146
|
+
: "";
|
|
147
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
148
|
+
return collapsed.length > maxLength ? `${collapsed.slice(0, maxLength - 1)}…` : collapsed;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function entryLabel(entry: SessionEntryLike): string {
|
|
152
|
+
if (entry.type === "compaction") return "compaction summary";
|
|
153
|
+
if (entry.type === "branch_summary") return "branch summary";
|
|
154
|
+
const role = typeof entry.message === "object" && entry.message !== null ? (entry.message as Record<string, unknown>)["role"] : undefined;
|
|
155
|
+
const prefix = typeof role === "string" ? role : entry.type;
|
|
156
|
+
const snippet = messageSnippet(entry.message);
|
|
157
|
+
return snippet ? `${prefix}: ${snippet}` : prefix;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface MessageHistoryTree {
|
|
161
|
+
/** One item per real tree root (ordinarily one, the session's first entry). */
|
|
162
|
+
items: ContextSegmentItem[];
|
|
163
|
+
/** 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. */
|
|
164
|
+
activeTokens: number;
|
|
165
|
+
/** True if the walk hit CONTEXT_TREE_MAX_DEPTH or CONTEXT_TREE_MAX_NODES, or found a cycle -- the tree shown is a bounded prefix, not necessarily the complete session. */
|
|
166
|
+
truncated: boolean;
|
|
167
|
+
}
|
|
168
|
+
|
|
117
169
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
170
|
+
* Walks Pi's own real session tree (ctx.sessionManager.getTree(), docs/session-format.md --
|
|
171
|
+
* entries form a genuine tree via id/parentId, not just the linear current-branch path) to
|
|
172
|
+
* estimate the conversation's context contribution AND surface branches explored via /tree
|
|
173
|
+
* that are no longer on the active path -- content that cost real tokens to generate but is
|
|
174
|
+
* NOT currently part of the context window. Bounded and cycle-safe (CONTEXT_TREE_MAX_DEPTH /
|
|
175
|
+
* CONTEXT_TREE_MAX_NODES): a session file is external, mutable state, and this deliberately
|
|
176
|
+
* hardens past a confirmed real gap in Pi's own getBranch() (no cycle guard at all) rather
|
|
177
|
+
* than assuming the tree can never be malformed.
|
|
125
178
|
*/
|
|
126
|
-
export function
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
179
|
+
export function buildMessageHistoryTree(roots: ReadonlyArray<SessionTreeNodeLike>, activeEntryIds: ReadonlySet<string>): MessageHistoryTree {
|
|
180
|
+
const visited = new Set<string>();
|
|
181
|
+
let truncated = false;
|
|
182
|
+
let activeTokens = 0;
|
|
183
|
+
let nodesVisited = 0;
|
|
184
|
+
|
|
185
|
+
function visit(node: SessionTreeNodeLike, depth: number): ContextSegmentItem | null {
|
|
186
|
+
if (nodesVisited >= CONTEXT_TREE_MAX_NODES || depth > CONTEXT_TREE_MAX_DEPTH) { truncated = true; return null; }
|
|
187
|
+
if (visited.has(node.entry.id)) { truncated = true; return null; } // cycle guard
|
|
188
|
+
visited.add(node.entry.id);
|
|
189
|
+
nodesVisited++;
|
|
190
|
+
|
|
191
|
+
const entry = node.entry;
|
|
192
|
+
const characters = entry.type === "message"
|
|
193
|
+
? messageContentCharacters(entry.message)
|
|
194
|
+
: entry.type === "compaction" || entry.type === "branch_summary"
|
|
195
|
+
? (entry.summary ?? "").length
|
|
196
|
+
: 0;
|
|
197
|
+
const tokens = Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
|
|
198
|
+
const isActive = activeEntryIds.has(entry.id);
|
|
199
|
+
if (isActive) activeTokens += tokens;
|
|
200
|
+
|
|
201
|
+
const children = node.children
|
|
202
|
+
.map((child) => visit(child, depth + 1))
|
|
203
|
+
.filter((item): item is ContextSegmentItem => item !== null);
|
|
204
|
+
if (tokens === 0 && children.length === 0) return null; // no content, no descendants with content -- nothing to show
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
label: isActive ? entryLabel(entry) : `${entryLabel(entry)} (inactive branch)`,
|
|
208
|
+
estimatedTokens: tokens,
|
|
209
|
+
...(children.length > 0 ? { children } : {}),
|
|
210
|
+
};
|
|
131
211
|
}
|
|
132
|
-
|
|
212
|
+
|
|
213
|
+
const items = roots.map((root) => visit(root, 0)).filter((item): item is ContextSegmentItem => item !== null);
|
|
214
|
+
return { items, activeTokens, truncated };
|
|
133
215
|
}
|
|
134
216
|
|
|
135
217
|
export interface ContextBreakdown {
|
|
@@ -139,7 +221,16 @@ export interface ContextBreakdown {
|
|
|
139
221
|
contextWindow: number | null;
|
|
140
222
|
/** contextWindow - reserveTokens, mirroring Pi's own compaction-trigger formula. Null when contextWindow is unknown. */
|
|
141
223
|
effectiveBudget: number | null;
|
|
142
|
-
/**
|
|
224
|
+
/**
|
|
225
|
+
* How much the known/estimated segments (rules+tasks+skills+basePrompt+messageHistory)
|
|
226
|
+
* exceed the real total, when they do. Zero means no overshoot. This must stay visible
|
|
227
|
+
* rather than only being absorbed into "unaccounted" clamping to zero -- a clamped-to-zero
|
|
228
|
+
* unaccounted segment does NOT mean tool definitions and framework overhead are actually
|
|
229
|
+
* free; it means this estimate's other segments already consumed the entire real budget on
|
|
230
|
+
* paper. Hiding that distinction would make a genuinely nonzero cost look like zero.
|
|
231
|
+
*/
|
|
232
|
+
overshootTokens: number;
|
|
233
|
+
/** rules, tasks, skills, basePrompt, messageHistory, then "other" absorbing whatever real usage the rest don't account for. */
|
|
143
234
|
segments: ContextSegment[];
|
|
144
235
|
}
|
|
145
236
|
|
|
@@ -148,12 +239,58 @@ export interface BuildContextBreakdownInput {
|
|
|
148
239
|
contextWindow: number | null;
|
|
149
240
|
reserveTokens?: number;
|
|
150
241
|
ruleBudget: ContextBudget["rules"];
|
|
151
|
-
|
|
242
|
+
/** Open tasks contributing to the injected task-context summary, nested by containment (parentIds/childIds) so the Tasks segment reflects the real Task tree, not a flat list. */
|
|
243
|
+
taskItems: ContextSegmentItem[];
|
|
152
244
|
skills: SkillCatalogFootprint;
|
|
153
245
|
/** Pi's own base system prompt size, cached from the last observed before_agent_start turn. Null before any turn has run yet. */
|
|
154
246
|
basePromptEstimatedTokens: number | null;
|
|
155
|
-
/** From
|
|
156
|
-
|
|
247
|
+
/** From buildMessageHistoryTree() against the live session's real tree (ctx.sessionManager.getTree()). */
|
|
248
|
+
messageHistoryItems: ContextSegmentItem[];
|
|
249
|
+
/** buildMessageHistoryTree()'s activeTokens -- only entries on the current active path count toward the segment total; an abandoned /tree branch still appears in messageHistoryItems but contributes zero here. */
|
|
250
|
+
messageHistoryActiveTokens: number;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Sums a possibly-nested item tree's tokens recursively -- every node's own contribution, not just top-level items. */
|
|
254
|
+
function sumItemTree(items: ContextSegmentItem[]): number {
|
|
255
|
+
return items.reduce((sum, item) => sum + item.estimatedTokens + sumItemTree(item.children ?? []), 0);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Builds the Tasks segment's items from Papyrus's own real containment tree (parentIds/
|
|
260
|
+
* childIds), not a flat list -- Tasks are a genuine DAG (a task may have more than one
|
|
261
|
+
* parent, a deliberate design decision, not a defect: see /tasks contain). Open tasks only
|
|
262
|
+
* (done/canceled tasks are filtered first, matching taskContext()'s own "only open work
|
|
263
|
+
* matters" rule); a task whose real parent is done/canceled or otherwise filtered out
|
|
264
|
+
* becomes a root in THIS projection rather than being silently dropped. A task reachable
|
|
265
|
+
* from more than one open parent is shown once, under whichever parent this bounded walk
|
|
266
|
+
* reaches first -- the same spanning-tree compromise already applied to the task widget
|
|
267
|
+
* (extension/src/task-widget.ts) for the identical multi-parent-DAG-in-a-bounded-view
|
|
268
|
+
* problem, not a new inconsistency.
|
|
269
|
+
*/
|
|
270
|
+
export function buildTaskItemTree(graph: TaskGraph): ContextSegmentItem[] {
|
|
271
|
+
const byId = new Map(graph.nodes.map((node) => [node.task.id, node]));
|
|
272
|
+
const openIds = new Set(graph.nodes.filter((node) => node.task.status !== "done" && node.task.status !== "canceled").map((node) => node.task.id));
|
|
273
|
+
const visited = new Set<string>();
|
|
274
|
+
|
|
275
|
+
function visit(id: string, depth: number): ContextSegmentItem | null {
|
|
276
|
+
if (depth > CONTEXT_TREE_MAX_DEPTH || visited.size >= CONTEXT_TREE_MAX_NODES || visited.has(id) || !openIds.has(id)) return null;
|
|
277
|
+
visited.add(id);
|
|
278
|
+
const node = byId.get(id);
|
|
279
|
+
if (!node) return null;
|
|
280
|
+
const characters = node.task.title.length + node.task.body.length;
|
|
281
|
+
const tokens = Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
|
|
282
|
+
const children = node.childIds
|
|
283
|
+
.filter((childId) => openIds.has(childId))
|
|
284
|
+
.map((childId) => visit(childId, depth + 1))
|
|
285
|
+
.filter((item): item is ContextSegmentItem => item !== null);
|
|
286
|
+
return { label: node.task.title, estimatedTokens: tokens, ...(children.length > 0 ? { children } : {}) };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const rootIds = [...openIds].filter((id) => {
|
|
290
|
+
const node = byId.get(id)!;
|
|
291
|
+
return node.parentIds.length === 0 || !node.parentIds.some((parentId) => openIds.has(parentId));
|
|
292
|
+
});
|
|
293
|
+
return rootIds.map((id) => visit(id, 0)).filter((item): item is ContextSegmentItem => item !== null);
|
|
157
294
|
}
|
|
158
295
|
|
|
159
296
|
/**
|
|
@@ -161,11 +298,13 @@ export interface BuildContextBreakdownInput {
|
|
|
161
298
|
* catalog, cached base-prompt size, and the live session's own message history) against the
|
|
162
299
|
* real total Pi reports, deriving "unaccounted" (tool definitions and framework overhead --
|
|
163
300
|
* genuinely invisible to any extension) as the remainder. The remainder is clamped to zero
|
|
164
|
-
* rather than shown negative
|
|
165
|
-
* in the known segments must not display as a nonsensical negative bucket
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
301
|
+
* rather than shown negative -- char/4 token estimation is approximate, and a small overshoot
|
|
302
|
+
* in the known segments must not display as a nonsensical negative bucket -- but the clamp
|
|
303
|
+
* amount itself is preserved as overshootTokens rather than silently discarded, so a
|
|
304
|
+
* consumer can tell "genuinely zero" apart from "our other estimates already exceeded the
|
|
305
|
+
* real total". When the real total is unavailable, unaccounted is reported as zero and
|
|
306
|
+
* totalTokens surfaces as null so callers can label the whole breakdown as estimate-only
|
|
307
|
+
* rather than silently treating a partial sum as ground truth.
|
|
169
308
|
*/
|
|
170
309
|
export function buildContextBreakdown(input: BuildContextBreakdownInput): ContextBreakdown {
|
|
171
310
|
const reserveTokens = input.reserveTokens ?? DEFAULT_RESERVE_TOKENS;
|
|
@@ -175,7 +314,12 @@ export function buildContextBreakdown(input: BuildContextBreakdownInput): Contex
|
|
|
175
314
|
estimatedTokens: input.ruleBudget.totalEstimatedTokens,
|
|
176
315
|
items: input.ruleBudget.entries.map((entry) => ({ label: entry.title, estimatedTokens: entry.estimatedTokens })),
|
|
177
316
|
};
|
|
178
|
-
const tasks: ContextSegment = {
|
|
317
|
+
const tasks: ContextSegment = {
|
|
318
|
+
key: "tasks",
|
|
319
|
+
label: "Papyrus Tasks",
|
|
320
|
+
estimatedTokens: sumItemTree(input.taskItems),
|
|
321
|
+
items: input.taskItems,
|
|
322
|
+
};
|
|
179
323
|
const skills: ContextSegment = {
|
|
180
324
|
key: "skills",
|
|
181
325
|
label: "Pi Skills catalog",
|
|
@@ -186,57 +330,30 @@ export function buildContextBreakdown(input: BuildContextBreakdownInput): Contex
|
|
|
186
330
|
key: "basePrompt",
|
|
187
331
|
label: input.basePromptEstimatedTokens === null ? "Base system prompt (not observed yet)" : "Base system prompt (Pi + host instructions)",
|
|
188
332
|
estimatedTokens: input.basePromptEstimatedTokens ?? 0,
|
|
333
|
+
...(input.basePromptEstimatedTokens === null ? { unknown: true } : {}),
|
|
189
334
|
};
|
|
190
335
|
const messageHistory: ContextSegment = {
|
|
191
336
|
key: "messageHistory",
|
|
192
337
|
label: "Conversation message history",
|
|
193
|
-
estimatedTokens: input.
|
|
338
|
+
estimatedTokens: input.messageHistoryActiveTokens,
|
|
339
|
+
items: input.messageHistoryItems,
|
|
194
340
|
};
|
|
195
341
|
const knownTokens = rules.estimatedTokens + tasks.estimatedTokens + skills.estimatedTokens + basePrompt.estimatedTokens + messageHistory.estimatedTokens;
|
|
342
|
+
const overshootTokens = input.totalTokens === null ? 0 : Math.max(0, knownTokens - input.totalTokens);
|
|
196
343
|
const other: ContextSegment = {
|
|
197
344
|
key: "other",
|
|
198
|
-
label:
|
|
345
|
+
label: overshootTokens > 0
|
|
346
|
+
? `Unaccounted (tool definitions, framework overhead) -- estimate overshoot: other segments' estimates already exceed the real total by ~${overshootTokens} tokens, so this is a floor, not a real zero`
|
|
347
|
+
: "Unaccounted (tool definitions, framework overhead)",
|
|
199
348
|
estimatedTokens: input.totalTokens === null ? 0 : Math.max(0, input.totalTokens - knownTokens),
|
|
200
349
|
};
|
|
201
350
|
return {
|
|
202
351
|
totalTokens: input.totalTokens,
|
|
203
352
|
contextWindow: input.contextWindow,
|
|
204
353
|
effectiveBudget: input.contextWindow === null ? null : Math.max(0, input.contextWindow - reserveTokens),
|
|
354
|
+
overshootTokens,
|
|
205
355
|
segments: [rules, tasks, skills, basePrompt, messageHistory, other],
|
|
206
356
|
};
|
|
207
357
|
}
|
|
208
358
|
|
|
209
|
-
function truncate(text: string, max: number): string {
|
|
210
|
-
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** Pure text formatter, independent of live daemon/filesystem state, for direct unit testing. */
|
|
214
|
-
export function formatContextBudgetReport(budget: ContextBudget): string {
|
|
215
|
-
const lines: string[] = ["Papyrus passive context budget", ""];
|
|
216
|
-
|
|
217
|
-
lines.push(`Rules (active, injected every relevant turn): ${budget.rules.entries.length} rules · ${budget.rules.totalCharacters} chars · ~${budget.rules.totalEstimatedTokens} tokens`);
|
|
218
|
-
if (budget.rules.entries.length > 0) {
|
|
219
|
-
lines.push(" Largest:");
|
|
220
|
-
for (const entry of budget.rules.entries.slice(0, REPORT_MAX_ROWS)) {
|
|
221
|
-
lines.push(` ${entry.characters.toString().padStart(5)} chars (~${entry.estimatedTokens} tok) ${truncate(entry.title, 60)}`);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
lines.push("");
|
|
225
|
-
|
|
226
|
-
lines.push(`Skills (Pi-native catalog, injected at startup): ${budget.skills.entries.length} skills · ${budget.skills.totalCharacters} chars · ~${budget.skills.totalEstimatedTokens} tokens`);
|
|
227
|
-
if (budget.skills.entries.length > 0) {
|
|
228
|
-
lines.push(" Largest:");
|
|
229
|
-
for (const entry of budget.skills.entries.slice(0, REPORT_MAX_ROWS)) {
|
|
230
|
-
lines.push(` ${entry.characters.toString().padStart(5)} chars (~${entry.estimatedTokens} tok) ${truncate(entry.name, 40)}`);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
if (budget.skills.scannedDirectories.length > 0) {
|
|
234
|
-
lines.push(` Scanned: ${budget.skills.scannedDirectories.join(", ")}`);
|
|
235
|
-
} else {
|
|
236
|
-
lines.push(" No skill directories found (checked Pi's documented global/project locations and settings.json's skills array).");
|
|
237
|
-
}
|
|
238
|
-
lines.push("");
|
|
239
359
|
|
|
240
|
-
lines.push(`Total passive tax: ~${budget.totalEstimatedTokens} tokens across ${budget.rules.entries.length + budget.skills.entries.length} items, before a single user message or tool call.`);
|
|
241
|
-
return lines.join("\n");
|
|
242
|
-
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import type { ExtensionCommandContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { matchesKey, truncateToWidth, type TUI } from "@earendil-works/pi-tui";
|
|
3
3
|
import type { ContextBreakdown, ContextSegment, ContextSegmentItem } from "./context-budget.ts";
|
|
4
|
-
import { formatContextBudgetReport, type ContextBudget } from "./context-budget.ts";
|
|
5
4
|
|
|
6
|
-
const
|
|
5
|
+
const VISIBLE_ROWS = 24;
|
|
7
6
|
|
|
8
7
|
const SEGMENT_COLORS: Record<ContextSegment["key"], ThemeColor> = {
|
|
9
8
|
rules: "accent",
|
|
@@ -14,6 +13,20 @@ const SEGMENT_COLORS: Record<ContextSegment["key"], ThemeColor> = {
|
|
|
14
13
|
other: "muted",
|
|
15
14
|
};
|
|
16
15
|
|
|
16
|
+
/**
|
|
17
|
+
* One row in the unified scrollable view. Every segment that has any real (nonzero) content
|
|
18
|
+
* is fully expanded inline -- there is no separate "select a segment, then drill in" step.
|
|
19
|
+
* `key` drives this row's color; `isHeader` distinguishes a segment's own summary line from
|
|
20
|
+
* its item rows underneath it.
|
|
21
|
+
*/
|
|
22
|
+
export interface ContextRow {
|
|
23
|
+
key: ContextSegment["key"];
|
|
24
|
+
isHeader: boolean;
|
|
25
|
+
text: string;
|
|
26
|
+
/** Nesting depth for indentation -- 0 for a segment header or a top-level item, deeper for real tree children (message history branches, Task containment). */
|
|
27
|
+
depth: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
17
30
|
function formatTokenCount(tokens: number): string {
|
|
18
31
|
return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
|
|
19
32
|
}
|
|
@@ -23,35 +36,53 @@ function percentOf(part: number, whole: number): string {
|
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
39
|
+
* Flattens every segment with real content into one linear row list, filtering out anything
|
|
40
|
+
* that is genuinely zero rather than displaying a misleading "0 tok 0.0%" row -- a segment or
|
|
41
|
+
* item with literally nothing in it carries no information and is pure noise in a scrollable
|
|
42
|
+
* view meant to show where tokens actually go. A segment whose OWN total is zero but whose
|
|
43
|
+
* items are also all zero is dropped entirely; a segment with a nonzero total is always kept
|
|
44
|
+
* even if all its items individually round to zero (the total itself is real signal).
|
|
29
45
|
*/
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
/** Recursively flattens one item and its real tree children (message history branches, Task containment) into indented rows, sorted biggest-first at each level -- a parent always immediately precedes its own children, never scrambled by a global sort. */
|
|
47
|
+
function flattenItem(item: ContextSegmentItem, key: ContextSegment["key"], depth: number, rows: ContextRow[]): void {
|
|
48
|
+
rows.push({ key, isHeader: false, depth, text: `${item.estimatedTokens.toString().padStart(6)} tok ${item.label}` });
|
|
49
|
+
const children = (item.children ?? []).filter((child) => child.estimatedTokens > 0).sort((a, b) => b.estimatedTokens - a.estimatedTokens);
|
|
50
|
+
for (const child of children) flattenItem(child, key, depth + 1, rows);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildContextRows(breakdown: ContextBreakdown): ContextRow[] {
|
|
54
|
+
const rows: ContextRow[] = [];
|
|
55
|
+
const denominator = breakdown.totalTokens ?? breakdown.segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
|
|
56
|
+
for (const segment of breakdown.segments) {
|
|
57
|
+
const items = (segment.items ?? []).filter((item) => item.estimatedTokens > 0).sort((a, b) => b.estimatedTokens - a.estimatedTokens);
|
|
58
|
+
// A genuinely-unknown segment (basePrompt before the first observed turn) must stay
|
|
59
|
+
// visible even when its placeholder value is zero -- hiding it would misrepresent
|
|
60
|
+
// "not measured yet" as "measured and empty", the same honesty problem overshootTokens
|
|
61
|
+
// exists to prevent for the unaccounted bucket.
|
|
62
|
+
if (segment.estimatedTokens <= 0 && items.length === 0 && !segment.unknown) continue;
|
|
63
|
+
rows.push({
|
|
64
|
+
key: segment.key,
|
|
65
|
+
isHeader: true,
|
|
66
|
+
depth: 0,
|
|
67
|
+
text: `${segment.label} — ${segment.estimatedTokens} tok (${percentOf(segment.estimatedTokens, denominator)})`,
|
|
68
|
+
});
|
|
69
|
+
for (const item of items) flattenItem(item, segment.key, 1, rows);
|
|
70
|
+
}
|
|
71
|
+
return rows;
|
|
42
72
|
}
|
|
43
73
|
|
|
44
74
|
class ContextViewport {
|
|
45
|
-
private
|
|
46
|
-
private
|
|
47
|
-
private drillIndex = 0;
|
|
75
|
+
private offsetY = 0;
|
|
76
|
+
private readonly rows: ContextRow[];
|
|
48
77
|
|
|
49
78
|
constructor(
|
|
50
79
|
private readonly tui: TUI,
|
|
51
80
|
private readonly theme: Theme,
|
|
52
81
|
private readonly breakdown: ContextBreakdown,
|
|
53
82
|
private readonly close: () => void,
|
|
54
|
-
) {
|
|
83
|
+
) {
|
|
84
|
+
this.rows = buildContextRows(breakdown);
|
|
85
|
+
}
|
|
55
86
|
|
|
56
87
|
invalidate(): void {}
|
|
57
88
|
|
|
@@ -71,106 +102,82 @@ class ContextViewport {
|
|
|
71
102
|
} else if (this.breakdown.totalTokens !== null) {
|
|
72
103
|
lines.push(truncateToWidth(`${formatTokenCount(this.breakdown.totalTokens)} tokens (model context window unknown)`, contentWidth, ""));
|
|
73
104
|
} else {
|
|
74
|
-
lines.push(theme.fg("dim", "No real usage reported yet —
|
|
105
|
+
lines.push(theme.fg("dim", "No real usage reported yet — sizes below are Papyrus's own estimates only"));
|
|
75
106
|
}
|
|
76
107
|
lines.push(renderContextBar(theme, this.breakdown.segments, contentWidth));
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (this.drillDown) {
|
|
80
|
-
lines.push(...this.renderDrillDown(contentWidth));
|
|
81
|
-
} else {
|
|
82
|
-
lines.push(...this.renderSegments(contentWidth));
|
|
108
|
+
if (this.breakdown.overshootTokens > 0) {
|
|
109
|
+
lines.push(truncateToWidth(theme.fg("warning", `Estimates exceed real total by ~${this.breakdown.overshootTokens} tok — sizes below are approximate, not exact`), contentWidth, ""));
|
|
83
110
|
}
|
|
84
|
-
lines.push(
|
|
85
|
-
return lines;
|
|
86
|
-
}
|
|
111
|
+
lines.push("");
|
|
87
112
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
95
|
-
const swatch = theme.fg(SEGMENT_COLORS[segment.key], "██");
|
|
96
|
-
const title = selected ? theme.bold(segment.label) : segment.label;
|
|
97
|
-
const percent = percentOf(segment.estimatedTokens, denominator);
|
|
98
|
-
const drillHint = segment.items && segment.items.length > 0 ? theme.fg("dim", ` (${segment.items.length} items — enter to expand)`) : "";
|
|
99
|
-
lines.push(truncateToWidth(`${cursor} ${swatch} ${segment.estimatedTokens.toString().padStart(6)} tok ${percent.padStart(5)} ${title}${drillHint}`, width, ""));
|
|
113
|
+
this.visibleWindow().forEach(({ row, index }) => {
|
|
114
|
+
const gutter = theme.fg(SEGMENT_COLORS[row.key], "▌");
|
|
115
|
+
const indent = " ".repeat(row.depth);
|
|
116
|
+
const text = row.isHeader ? theme.bold(row.text) : `${indent}${row.text}`;
|
|
117
|
+
lines.push(truncateToWidth(`${gutter} ${text}`, contentWidth, ""));
|
|
118
|
+
void index;
|
|
100
119
|
});
|
|
120
|
+
if (this.rows.length === 0) lines.push(theme.fg("dim", " (nothing observed yet)"));
|
|
121
|
+
else lines.push(theme.fg("muted", ` ${Math.min(this.offsetY + VISIBLE_ROWS, this.rows.length)}/${this.rows.length}`));
|
|
122
|
+
|
|
101
123
|
lines.push("");
|
|
102
|
-
lines.push(theme.fg("dim", "↑↓
|
|
124
|
+
lines.push(theme.fg("dim", "↑↓ scroll · esc close"));
|
|
125
|
+
lines.push(border);
|
|
103
126
|
return lines;
|
|
104
127
|
}
|
|
105
128
|
|
|
106
|
-
private
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (items.length === 0) {
|
|
112
|
-
lines.push(theme.fg("dim", " (nothing to break down further)"));
|
|
113
|
-
} else {
|
|
114
|
-
const start = Math.max(0, Math.min(this.drillIndex - Math.floor(DRILLDOWN_VISIBLE_ROWS / 2), items.length - DRILLDOWN_VISIBLE_ROWS));
|
|
115
|
-
const end = Math.min(start + DRILLDOWN_VISIBLE_ROWS, items.length);
|
|
116
|
-
for (let index = start; index < end; index++) {
|
|
117
|
-
const item = items[index]!;
|
|
118
|
-
const selected = index === this.drillIndex;
|
|
119
|
-
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
120
|
-
const title = selected ? theme.bold(item.label) : item.label;
|
|
121
|
-
lines.push(truncateToWidth(`${cursor} ${item.estimatedTokens.toString().padStart(6)} tok ${title}`, width, ""));
|
|
122
|
-
}
|
|
123
|
-
lines.push(theme.fg("muted", ` ${this.drillIndex + 1}/${items.length}`));
|
|
124
|
-
}
|
|
125
|
-
lines.push("");
|
|
126
|
-
lines.push(theme.fg("dim", "↑↓ scroll · esc back"));
|
|
127
|
-
return lines;
|
|
129
|
+
private visibleWindow(): Array<{ row: ContextRow; index: number }> {
|
|
130
|
+
const end = Math.min(this.offsetY + VISIBLE_ROWS, this.rows.length);
|
|
131
|
+
const result: Array<{ row: ContextRow; index: number }> = [];
|
|
132
|
+
for (let index = this.offsetY; index < end; index++) result.push({ row: this.rows[index]!, index });
|
|
133
|
+
return result;
|
|
128
134
|
}
|
|
129
135
|
|
|
130
136
|
handleInput(data: string): void {
|
|
131
|
-
if (this.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
else if (matchesKey(data, "down")) this.drillIndex = Math.min(Math.max(0, items.length - 1), this.drillIndex + 1);
|
|
136
|
-
else return;
|
|
137
|
-
} else {
|
|
138
|
-
if (matchesKey(data, "escape")) { this.close(); return; }
|
|
139
|
-
if (matchesKey(data, "up")) this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
140
|
-
else if (matchesKey(data, "down")) this.selectedIndex = Math.min(this.breakdown.segments.length - 1, this.selectedIndex + 1);
|
|
141
|
-
else if (matchesKey(data, "enter")) {
|
|
142
|
-
const segment = this.breakdown.segments[this.selectedIndex];
|
|
143
|
-
if (segment?.items && segment.items.length > 0) {
|
|
144
|
-
this.drillDown = segment;
|
|
145
|
-
this.drillIndex = 0;
|
|
146
|
-
}
|
|
147
|
-
} else return;
|
|
148
|
-
}
|
|
137
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); return; }
|
|
138
|
+
if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
|
|
139
|
+
else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.rows.length - VISIBLE_ROWS), this.offsetY + 1);
|
|
140
|
+
else return;
|
|
149
141
|
this.tui.requestRender();
|
|
150
142
|
}
|
|
151
143
|
}
|
|
152
144
|
|
|
153
|
-
/**
|
|
154
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Renders the context window as one proportional stacked bar, one colored run of block
|
|
147
|
+
* characters per segment, matching each row's own gutter color above/below it. A zero-token
|
|
148
|
+
* breakdown (nothing observed yet) renders an empty dim track rather than a divide-by-zero.
|
|
149
|
+
* Zero-token segments contribute no cells and are effectively invisible in the bar, matching
|
|
150
|
+
* their exclusion from the row list below it.
|
|
151
|
+
*/
|
|
152
|
+
export function renderContextBar(theme: Theme, segments: ReadonlyArray<ContextSegment>, width: number): string {
|
|
153
|
+
const total = segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
|
|
154
|
+
if (total <= 0 || width <= 0) return theme.fg("dim", "░".repeat(Math.max(0, width)));
|
|
155
|
+
const nonZero = segments.filter((segment) => segment.estimatedTokens > 0);
|
|
156
|
+
let used = 0;
|
|
157
|
+
let output = "";
|
|
158
|
+
nonZero.forEach((segment, index) => {
|
|
159
|
+
const isLast = index === nonZero.length - 1;
|
|
160
|
+
const cells = isLast ? width - used : Math.round((segment.estimatedTokens / total) * width);
|
|
161
|
+
used += cells;
|
|
162
|
+
if (cells > 0) output += theme.fg(SEGMENT_COLORS[segment.key], "█".repeat(cells));
|
|
163
|
+
});
|
|
164
|
+
return output;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Non-interactive fallback (print mode, RPC, etc.): the same unified row list, as plain text lines. */
|
|
168
|
+
function fallbackReport(breakdown: ContextBreakdown): string {
|
|
155
169
|
const totalLine = breakdown.totalTokens !== null
|
|
156
170
|
? `Real usage: ${breakdown.totalTokens} tokens${breakdown.effectiveBudget !== null ? ` / ${breakdown.effectiveBudget} usable budget (${percentOf(breakdown.totalTokens, breakdown.effectiveBudget)})` : ""}`
|
|
157
171
|
: "Real usage: not yet reported";
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
return [
|
|
162
|
-
totalLine,
|
|
163
|
-
"",
|
|
164
|
-
"Segments:",
|
|
165
|
-
...segmentLines,
|
|
166
|
-
"",
|
|
167
|
-
formatContextBudgetReport({ rules: ruleBudget, skills: { entries: [], totalCharacters: 0, totalEstimatedTokens: skillsSegment?.estimatedTokens ?? 0, scannedDirectories: [] }, totalEstimatedTokens: ruleBudget.totalEstimatedTokens }),
|
|
168
|
-
].join("\n");
|
|
172
|
+
const overshootLine = breakdown.overshootTokens > 0 ? [`Estimates exceed real total by ~${breakdown.overshootTokens} tok -- sizes below are approximate, not exact`] : [];
|
|
173
|
+
const rows = buildContextRows(breakdown);
|
|
174
|
+
const rowLines = rows.length > 0 ? rows.map((row) => (row.isHeader ? row.text : `${" ".repeat(row.depth)}${row.text}`)) : ["(nothing observed yet)"];
|
|
175
|
+
return [totalLine, ...overshootLine, "", ...rowLines].join("\n");
|
|
169
176
|
}
|
|
170
177
|
|
|
171
|
-
export async function showContextView(ctx: ExtensionCommandContext, breakdown: ContextBreakdown
|
|
178
|
+
export async function showContextView(ctx: ExtensionCommandContext, breakdown: ContextBreakdown): Promise<void> {
|
|
172
179
|
if (ctx.mode !== "tui") {
|
|
173
|
-
ctx.ui.notify(fallbackReport(breakdown
|
|
180
|
+
ctx.ui.notify(fallbackReport(breakdown), "info");
|
|
174
181
|
return;
|
|
175
182
|
}
|
|
176
183
|
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => new ContextViewport(tui, theme, breakdown, done));
|
package/extension/src/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanI
|
|
|
27
27
|
import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
|
|
28
28
|
import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
|
|
29
29
|
import { buildContextInjection } from "./context-injection-telemetry.ts";
|
|
30
|
-
import { buildContextBreakdown, computeContextBudget, computeRuleBudget,
|
|
30
|
+
import { buildContextBreakdown, buildMessageHistoryTree, buildTaskItemTree, computeContextBudget, computeRuleBudget, type SessionEntryLike, type SessionTreeNodeLike } from "./context-budget.ts";
|
|
31
31
|
import { showContextView } from "./context-view.ts";
|
|
32
32
|
import { emitTaskFocusEvent, setTaskFocusEventBus } from "./task-focus-events.ts";
|
|
33
33
|
import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
|
|
@@ -421,24 +421,29 @@ export default async function (pi: ExtensionAPI) {
|
|
|
421
421
|
handler: async (_args, ctx) => {
|
|
422
422
|
try {
|
|
423
423
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
424
|
-
const [rules,
|
|
424
|
+
const [rules, taskGraph] = await Promise.all([
|
|
425
425
|
callService<Record<string, unknown>, Array<Pick<Artifact, "id" | "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd, session_id: sessionId }),
|
|
426
|
-
callService<Record<string, unknown>,
|
|
426
|
+
callService<Record<string, unknown>, TaskGraph>("tasks.graph", { project_root: ctx.cwd, session_id: sessionId }),
|
|
427
427
|
]);
|
|
428
428
|
const { skills } = computeContextBudget(rules, ctx.cwd);
|
|
429
429
|
const ruleBudget = computeRuleBudget(rules);
|
|
430
430
|
const usage = ctx.getContextUsage?.();
|
|
431
|
-
|
|
431
|
+
// Real tree (not just the linear current-branch path): surfaces content sitting in an
|
|
432
|
+
// abandoned /tree branch, which cost real tokens to generate but isn't in context now.
|
|
433
|
+
const tree = ctx.sessionManager.getTree() as unknown as SessionTreeNodeLike[];
|
|
434
|
+
const activeEntryIds = new Set((ctx.sessionManager.getBranch() as unknown as SessionEntryLike[]).map((entry) => entry.id));
|
|
435
|
+
const messageHistory = buildMessageHistoryTree(tree, activeEntryIds);
|
|
432
436
|
const breakdown = buildContextBreakdown({
|
|
433
437
|
totalTokens: usage?.tokens ?? null,
|
|
434
438
|
contextWindow: ctx.model?.contextWindow ?? null,
|
|
435
439
|
ruleBudget,
|
|
436
|
-
|
|
440
|
+
taskItems: buildTaskItemTree(taskGraph),
|
|
437
441
|
skills,
|
|
438
442
|
basePromptEstimatedTokens: lastObservedBasePromptTokens,
|
|
439
|
-
|
|
443
|
+
messageHistoryItems: messageHistory.items,
|
|
444
|
+
messageHistoryActiveTokens: messageHistory.activeTokens,
|
|
440
445
|
});
|
|
441
|
-
await showContextView(ctx, breakdown
|
|
446
|
+
await showContextView(ctx, breakdown);
|
|
442
447
|
} catch (error) {
|
|
443
448
|
ctx.ui.notify(`Context breakdown failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
444
449
|
}
|
package/package.json
CHANGED
package/src/constants.ts
CHANGED
|
@@ -30,6 +30,18 @@ export const PAPYRUS_TASK_FOCUS_CHANNEL = "papyrus.task-focus.v1";
|
|
|
30
30
|
export const PAPYRUS_TASK_FOCUS_SCHEMA = "papyrus.task-focus/v1";
|
|
31
31
|
export const CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN = 4;
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Bounds for walking Pi's real session tree (getTree()) and Papyrus's own Task containment
|
|
35
|
+
* tree when estimating /context's message-history and task segments. Both are genuine trees
|
|
36
|
+
* built from external, mutable state (a session file; the live Task graph) -- depth and
|
|
37
|
+
* total-node bounds are a defensive measure against a corrupted/adversarial parentId chain
|
|
38
|
+
* forming an accidental cycle, matching the same cycle-safety discipline already applied to
|
|
39
|
+
* ConversationJournal traversal and deliberately hardening past a real, confirmed gap in
|
|
40
|
+
* Pi's own getBranch() (no cycle guard at all).
|
|
41
|
+
*/
|
|
42
|
+
export const CONTEXT_TREE_MAX_DEPTH = 200;
|
|
43
|
+
export const CONTEXT_TREE_MAX_NODES = 2000;
|
|
44
|
+
|
|
33
45
|
/**
|
|
34
46
|
* A Papyrus Rule's condition+action+body is injected into EVERY relevant turn's system
|
|
35
47
|
* prompt for the lifetime of the rule -- the same permanent, always-on-context role as an
|