@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.
@@ -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 branch entries as SessionManager exposes them (docs/session-format.md): a subset
83
- * covering only the fields this estimate reads, so this stays testable with plain object
84
- * literals instead of importing pi's own session types.
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 SessionBranchEntryLike {
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
- * Estimates the conversation transcript's own context contribution by walking the actual
119
- * session branch (docs/session-format.md's buildSessionContext(): message/compaction/
120
- * branch_summary entries participate in context, plain "custom" entries do not). This is
121
- * character-count estimation like every other Papyrus segment here, not exact -- but it is
122
- * real session content, not a guess, and in a long-running session this is very likely the
123
- * dominant contributor to "the base prompt, message history, and tool definitions" bucket
124
- * that would otherwise stay fully opaque.
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 estimateMessageHistoryTokens(branch: ReadonlyArray<SessionBranchEntryLike>): number {
127
- let characters = 0;
128
- for (const entry of branch) {
129
- if (entry.type === "message") characters += messageContentCharacters(entry.message);
130
- else if (entry.type === "compaction" || entry.type === "branch_summary") characters += (entry.summary ?? "").length;
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
- return Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
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
- /** rules, tasks, skills, then "other" absorbing whatever real usage the first three don't account for. */
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
- taskEstimatedTokens: number;
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 estimateMessageHistoryTokens() against the live session branch. */
156
- messageHistoryEstimatedTokens: number;
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: char/4 token estimation is approximate, and a small overshoot
165
- * in the known segments must not display as a nonsensical negative bucket. When the real
166
- * total is unavailable, unaccounted is reported as zero and totalTokens surfaces as null so
167
- * callers can label the whole breakdown as estimate-only rather than silently treating a
168
- * partial sum as ground truth.
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 = { key: "tasks", label: "Papyrus Tasks", estimatedTokens: input.taskEstimatedTokens };
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.messageHistoryEstimatedTokens,
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: "Unaccounted (tool definitions, framework overhead)",
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 DRILLDOWN_VISIBLE_ROWS = 15;
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
- * Renders the context window as one proportional stacked bar, one colored run of block
27
- * characters per segment, matching each row's own swatch color below it. A zero-token
28
- * breakdown (nothing observed yet) renders an empty dim track rather than a divide-by-zero.
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
- export function renderContextBar(theme: Theme, segments: ReadonlyArray<ContextSegment>, width: number): string {
31
- const total = segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
32
- if (total <= 0 || width <= 0) return theme.fg("dim", "░".repeat(Math.max(0, width)));
33
- let used = 0;
34
- let output = "";
35
- segments.forEach((segment, index) => {
36
- const isLast = index === segments.length - 1;
37
- const cells = isLast ? width - used : Math.round((segment.estimatedTokens / total) * width);
38
- used += cells;
39
- if (cells > 0) output += theme.fg(SEGMENT_COLORS[segment.key], "█".repeat(cells));
40
- });
41
- return output;
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 selectedIndex = 0;
46
- private drillDown: ContextSegment | null = null;
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 — segment sizes below are Papyrus's own estimates only"));
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
- lines.push("");
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(border);
85
- return lines;
86
- }
111
+ lines.push("");
87
112
 
88
- private renderSegments(width: number): string[] {
89
- const theme = this.theme;
90
- const lines: string[] = [];
91
- const denominator = this.breakdown.totalTokens ?? this.breakdown.segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
92
- this.breakdown.segments.forEach((segment, index) => {
93
- const selected = index === this.selectedIndex;
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", "↑↓ select · enter expand · esc close"));
124
+ lines.push(theme.fg("dim", "↑↓ scroll · esc close"));
125
+ lines.push(border);
103
126
  return lines;
104
127
  }
105
128
 
106
- private renderDrillDown(width: number): string[] {
107
- const theme = this.theme;
108
- const segment = this.drillDown!;
109
- const items: ContextSegmentItem[] = segment.items ?? [];
110
- const lines: string[] = [truncateToWidth(theme.fg("muted", `${segment.label} — largest first`), width, "")];
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.drillDown) {
132
- const items = this.drillDown.items ?? [];
133
- if (matchesKey(data, "escape")) this.drillDown = null;
134
- else if (matchesKey(data, "up")) this.drillIndex = Math.max(0, this.drillIndex - 1);
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
- /** Non-interactive fallback (print mode, RPC, etc.): every segment listed plainly, plus the existing per-rule/per-skill breakdown for the two segments that support drill-down. */
154
- function fallbackReport(breakdown: ContextBreakdown, ruleBudget: ContextBudget["rules"]): string {
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 denominator = breakdown.totalTokens ?? breakdown.segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
159
- const segmentLines = breakdown.segments.map((segment) => ` ${segment.estimatedTokens.toString().padStart(7)} tok ${percentOf(segment.estimatedTokens, denominator).padStart(5)} ${segment.label}`);
160
- const skillsSegment = breakdown.segments.find((segment) => segment.key === "skills");
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, ruleBudget: ContextBudget["rules"]): Promise<void> {
178
+ export async function showContextView(ctx: ExtensionCommandContext, breakdown: ContextBreakdown): Promise<void> {
172
179
  if (ctx.mode !== "tui") {
173
- ctx.ui.notify(fallbackReport(breakdown, ruleBudget), "info");
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));
@@ -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, estimateMessageHistoryTokens, type SessionBranchEntryLike } from "./context-budget.ts";
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, taskSummary] = await Promise.all([
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>, string | null>("tasks.context", { project_root: ctx.cwd, session_id: sessionId }),
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
- const branch = ctx.sessionManager.getBranch() as unknown as SessionBranchEntryLike[];
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
- taskEstimatedTokens: taskSummary ? Math.ceil(taskSummary.length / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN) : 0,
440
+ taskItems: buildTaskItemTree(taskGraph),
437
441
  skills,
438
442
  basePromptEstimatedTokens: lastObservedBasePromptTokens,
439
- messageHistoryEstimatedTokens: estimateMessageHistoryTokens(branch),
443
+ messageHistoryItems: messageHistory.items,
444
+ messageHistoryActiveTokens: messageHistory.activeTokens,
440
445
  });
441
- await showContextView(ctx, breakdown, ruleBudget);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
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