@danypops/papyrus 0.13.2 → 0.13.4

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,7 +1,8 @@
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_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
 
@@ -66,6 +67,13 @@ export const DEFAULT_RESERVE_TOKENS = 16_384;
66
67
  export interface ContextSegmentItem {
67
68
  label: string;
68
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[];
69
77
  }
70
78
 
71
79
  export interface ContextSegment {
@@ -85,15 +93,21 @@ export interface ContextSegment {
85
93
  }
86
94
 
87
95
  /**
88
- * Session branch entries as SessionManager exposes them (docs/session-format.md): a subset
89
- * covering only the fields this estimate reads, so this stays testable with plain object
90
- * 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.
91
100
  */
92
- export interface SessionBranchEntryLike {
101
+ export interface SessionEntryLike {
102
+ id: string;
93
103
  type: string;
94
104
  message?: unknown;
95
105
  summary?: string;
96
106
  }
107
+ export interface SessionTreeNodeLike {
108
+ entry: SessionEntryLike;
109
+ children: SessionTreeNodeLike[];
110
+ }
97
111
 
98
112
  function messageContentCharacters(message: unknown): number {
99
113
  if (typeof message !== "object" || message === null) return 0;
@@ -120,22 +134,119 @@ function messageContentCharacters(message: unknown): number {
120
134
  return characters;
121
135
  }
122
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_NODES or found a cycle -- the tree shown is a bounded prefix, not necessarily the complete session. */
166
+ truncated: boolean;
167
+ }
168
+
123
169
  /**
124
- * Estimates the conversation transcript's own context contribution by walking the actual
125
- * session branch (docs/session-format.md's buildSessionContext(): message/compaction/
126
- * branch_summary entries participate in context, plain "custom" entries do not). This is
127
- * character-count estimation like every other Papyrus segment here, not exact -- but it is
128
- * real session content, not a guess, and in a long-running session this is very likely the
129
- * dominant contributor to "the base prompt, message history, and tool definitions" bucket
130
- * 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_NODES):
175
+ * 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.
131
178
  */
132
- export function estimateMessageHistoryTokens(branch: ReadonlyArray<SessionBranchEntryLike>): number {
133
- let characters = 0;
134
- for (const entry of branch) {
135
- if (entry.type === "message") characters += messageContentCharacters(entry.message);
136
- else if (entry.type === "compaction" || entry.type === "branch_summary") characters += (entry.summary ?? "").length;
179
+ interface WalkFrame {
180
+ node: SessionTreeNodeLike;
181
+ parentIndex: number | null;
182
+ }
183
+
184
+ /**
185
+ * Iterative (not recursive) two-pass walk: an explicit-stack pre-order discovery pass
186
+ * followed by a reverse-order (children-before-parent) construction pass. A real, ordinary
187
+ * (non-branching) long-running session is one long linear chain, so recursion depth would
188
+ * equal entry count -- a session observed in production with 6,924 entries on its own active
189
+ * branch confirmed this is not a hypothetical concern; a naive recursive walk risks a real
190
+ * JavaScript call-stack overflow at that scale, independent of the CONTEXT_TREE_MAX_NODES
191
+ * bound entirely.
192
+ */
193
+ export function buildMessageHistoryTree(roots: ReadonlyArray<SessionTreeNodeLike>, activeEntryIds: ReadonlySet<string>): MessageHistoryTree {
194
+ const visited = new Set<string>();
195
+ let truncated = false;
196
+ let activeTokens = 0;
197
+
198
+ const order: WalkFrame[] = [];
199
+ const stack: WalkFrame[] = [...roots].reverse().map((root) => ({ node: root, parentIndex: null }));
200
+ while (stack.length > 0) {
201
+ const frame = stack.pop()!;
202
+ if (order.length >= CONTEXT_TREE_MAX_NODES) { truncated = true; break; }
203
+ if (visited.has(frame.node.entry.id)) { truncated = true; continue; } // cycle guard
204
+ visited.add(frame.node.entry.id);
205
+ const index = order.length;
206
+ order.push(frame);
207
+ const children = [...frame.node.children].reverse().map((child) => ({ node: child, parentIndex: index }));
208
+ stack.push(...children);
137
209
  }
138
- return Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
210
+ if (stack.length > 0) truncated = true; // node bound hit with more work still queued
211
+
212
+ const childItemsByParent = new Map<number, ContextSegmentItem[]>();
213
+ const itemByIndex = new Map<number, ContextSegmentItem>();
214
+ for (let index = order.length - 1; index >= 0; index--) {
215
+ const frame = order[index]!;
216
+ const entry = frame.node.entry;
217
+ const characters = entry.type === "message"
218
+ ? messageContentCharacters(entry.message)
219
+ : entry.type === "compaction" || entry.type === "branch_summary"
220
+ ? (entry.summary ?? "").length
221
+ : 0;
222
+ const tokens = Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
223
+ const isActive = activeEntryIds.has(entry.id);
224
+ if (isActive) activeTokens += tokens;
225
+
226
+ const children = childItemsByParent.get(index) ?? [];
227
+ if (tokens === 0 && children.length === 0) continue; // no content, no descendants with content -- nothing to show
228
+
229
+ const item: ContextSegmentItem = {
230
+ label: isActive ? entryLabel(entry) : `${entryLabel(entry)} (inactive branch)`,
231
+ estimatedTokens: tokens,
232
+ ...(children.length > 0 ? { children } : {}),
233
+ };
234
+ itemByIndex.set(index, item);
235
+ if (frame.parentIndex !== null) {
236
+ const siblings = childItemsByParent.get(frame.parentIndex) ?? [];
237
+ siblings.unshift(item); // reverse-order processing -- unshift restores original document order
238
+ childItemsByParent.set(frame.parentIndex, siblings);
239
+ }
240
+ }
241
+
242
+ const items: ContextSegmentItem[] = [];
243
+ for (let index = 0; index < order.length; index++) {
244
+ if (order[index]!.parentIndex === null) {
245
+ const item = itemByIndex.get(index);
246
+ if (item) items.push(item);
247
+ }
248
+ }
249
+ return { items, activeTokens, truncated };
139
250
  }
140
251
 
141
252
  export interface ContextBreakdown {
@@ -163,13 +274,92 @@ export interface BuildContextBreakdownInput {
163
274
  contextWindow: number | null;
164
275
  reserveTokens?: number;
165
276
  ruleBudget: ContextBudget["rules"];
166
- /** Open tasks contributing to the injected task-context summary, each sized individually so the Tasks segment can be drilled into like Rules and Skills. */
277
+ /** 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. */
167
278
  taskItems: ContextSegmentItem[];
168
279
  skills: SkillCatalogFootprint;
169
280
  /** Pi's own base system prompt size, cached from the last observed before_agent_start turn. Null before any turn has run yet. */
170
281
  basePromptEstimatedTokens: number | null;
171
- /** From estimateMessageHistoryTokens() against the live session branch. */
172
- messageHistoryEstimatedTokens: number;
282
+ /** From buildMessageHistoryTree() against the live session's real tree (ctx.sessionManager.getTree()). */
283
+ messageHistoryItems: ContextSegmentItem[];
284
+ /** 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. */
285
+ messageHistoryActiveTokens: number;
286
+ }
287
+
288
+ /** Sums a possibly-nested item tree's tokens recursively -- every node's own contribution, not just top-level items. */
289
+ function sumItemTree(items: ContextSegmentItem[]): number {
290
+ return items.reduce((sum, item) => sum + item.estimatedTokens + sumItemTree(item.children ?? []), 0);
291
+ }
292
+
293
+ /**
294
+ * Builds the Tasks segment's items from Papyrus's own real containment tree (parentIds/
295
+ * childIds), not a flat list -- Tasks are a genuine DAG (a task may have more than one
296
+ * parent, a deliberate design decision, not a defect: see /tasks contain). Open tasks only
297
+ * (done/canceled tasks are filtered first, matching taskContext()'s own "only open work
298
+ * matters" rule); a task whose real parent is done/canceled or otherwise filtered out
299
+ * becomes a root in THIS projection rather than being silently dropped. A task reachable
300
+ * from more than one open parent is shown once, under whichever parent this bounded walk
301
+ * reaches first -- the same spanning-tree compromise already applied to the task widget
302
+ * (extension/src/task-widget.ts) for the identical multi-parent-DAG-in-a-bounded-view
303
+ * problem, not a new inconsistency.
304
+ */
305
+ interface TaskWalkFrame {
306
+ taskId: string;
307
+ parentIndex: number | null;
308
+ }
309
+
310
+ /** Same iterative two-pass shape as buildMessageHistoryTree, for the same reason: don't assume containment depth stays small just because it usually does. */
311
+ export function buildTaskItemTree(graph: TaskGraph): ContextSegmentItem[] {
312
+ const byId = new Map(graph.nodes.map((node) => [node.task.id, node]));
313
+ const openIds = new Set(graph.nodes.filter((node) => node.task.status !== "done" && node.task.status !== "canceled").map((node) => node.task.id));
314
+ const visited = new Set<string>();
315
+
316
+ const rootIds = [...openIds].filter((id) => {
317
+ const node = byId.get(id)!;
318
+ return node.parentIds.length === 0 || !node.parentIds.some((parentId) => openIds.has(parentId));
319
+ });
320
+
321
+ const order: TaskWalkFrame[] = [];
322
+ const stack: TaskWalkFrame[] = [...rootIds].reverse().map((taskId) => ({ taskId, parentIndex: null }));
323
+ while (stack.length > 0) {
324
+ const frame = stack.pop()!;
325
+ if (order.length >= CONTEXT_TREE_MAX_NODES) break;
326
+ if (visited.has(frame.taskId) || !openIds.has(frame.taskId)) continue; // cycle guard + open-only filter
327
+ visited.add(frame.taskId);
328
+ const index = order.length;
329
+ order.push(frame);
330
+ const node = byId.get(frame.taskId);
331
+ const children = [...(node?.childIds ?? [])].reverse()
332
+ .filter((childId) => openIds.has(childId))
333
+ .map((childId) => ({ taskId: childId, parentIndex: index }));
334
+ stack.push(...children);
335
+ }
336
+
337
+ const childItemsByParent = new Map<number, ContextSegmentItem[]>();
338
+ const itemByIndex = new Map<number, ContextSegmentItem>();
339
+ for (let index = order.length - 1; index >= 0; index--) {
340
+ const frame = order[index]!;
341
+ const node = byId.get(frame.taskId);
342
+ if (!node) continue;
343
+ const characters = node.task.title.length + node.task.body.length;
344
+ const tokens = Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
345
+ const children = childItemsByParent.get(index) ?? [];
346
+ const item: ContextSegmentItem = { label: node.task.title, estimatedTokens: tokens, ...(children.length > 0 ? { children } : {}) };
347
+ itemByIndex.set(index, item);
348
+ if (frame.parentIndex !== null) {
349
+ const siblings = childItemsByParent.get(frame.parentIndex) ?? [];
350
+ siblings.unshift(item);
351
+ childItemsByParent.set(frame.parentIndex, siblings);
352
+ }
353
+ }
354
+
355
+ const items: ContextSegmentItem[] = [];
356
+ for (let index = 0; index < order.length; index++) {
357
+ if (order[index]!.parentIndex === null) {
358
+ const item = itemByIndex.get(index);
359
+ if (item) items.push(item);
360
+ }
361
+ }
362
+ return items;
173
363
  }
174
364
 
175
365
  /**
@@ -196,7 +386,7 @@ export function buildContextBreakdown(input: BuildContextBreakdownInput): Contex
196
386
  const tasks: ContextSegment = {
197
387
  key: "tasks",
198
388
  label: "Papyrus Tasks",
199
- estimatedTokens: input.taskItems.reduce((sum, item) => sum + item.estimatedTokens, 0),
389
+ estimatedTokens: sumItemTree(input.taskItems),
200
390
  items: input.taskItems,
201
391
  };
202
392
  const skills: ContextSegment = {
@@ -214,7 +404,8 @@ export function buildContextBreakdown(input: BuildContextBreakdownInput): Contex
214
404
  const messageHistory: ContextSegment = {
215
405
  key: "messageHistory",
216
406
  label: "Conversation message history",
217
- estimatedTokens: input.messageHistoryEstimatedTokens,
407
+ estimatedTokens: input.messageHistoryActiveTokens,
408
+ items: input.messageHistoryItems,
218
409
  };
219
410
  const knownTokens = rules.estimatedTokens + tasks.estimatedTokens + skills.estimatedTokens + basePrompt.estimatedTokens + messageHistory.estimatedTokens;
220
411
  const overshootTokens = input.totalTokens === null ? 0 : Math.max(0, knownTokens - input.totalTokens);
@@ -1,6 +1,6 @@
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
- import type { ContextBreakdown, ContextSegment } from "./context-budget.ts";
3
+ import type { ContextBreakdown, ContextSegment, ContextSegmentItem } from "./context-budget.ts";
4
4
 
5
5
  const VISIBLE_ROWS = 24;
6
6
 
@@ -23,6 +23,8 @@ export interface ContextRow {
23
23
  key: ContextSegment["key"];
24
24
  isHeader: boolean;
25
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;
26
28
  }
27
29
 
28
30
  function formatTokenCount(tokens: number): string {
@@ -41,6 +43,13 @@ function percentOf(part: number, whole: number): string {
41
43
  * items are also all zero is dropped entirely; a segment with a nonzero total is always kept
42
44
  * even if all its items individually round to zero (the total itself is real signal).
43
45
  */
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
+
44
53
  export function buildContextRows(breakdown: ContextBreakdown): ContextRow[] {
45
54
  const rows: ContextRow[] = [];
46
55
  const denominator = breakdown.totalTokens ?? breakdown.segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
@@ -54,11 +63,10 @@ export function buildContextRows(breakdown: ContextBreakdown): ContextRow[] {
54
63
  rows.push({
55
64
  key: segment.key,
56
65
  isHeader: true,
66
+ depth: 0,
57
67
  text: `${segment.label} — ${segment.estimatedTokens} tok (${percentOf(segment.estimatedTokens, denominator)})`,
58
68
  });
59
- for (const item of items) {
60
- rows.push({ key: segment.key, isHeader: false, text: ` ${item.estimatedTokens.toString().padStart(6)} tok ${item.label}` });
61
- }
69
+ for (const item of items) flattenItem(item, segment.key, 1, rows);
62
70
  }
63
71
  return rows;
64
72
  }
@@ -104,7 +112,8 @@ class ContextViewport {
104
112
 
105
113
  this.visibleWindow().forEach(({ row, index }) => {
106
114
  const gutter = theme.fg(SEGMENT_COLORS[row.key], "▌");
107
- const text = row.isHeader ? theme.bold(row.text) : row.text;
115
+ const indent = " ".repeat(row.depth);
116
+ const text = row.isHeader ? theme.bold(row.text) : `${indent}${row.text}`;
108
117
  lines.push(truncateToWidth(`${gutter} ${text}`, contentWidth, ""));
109
118
  void index;
110
119
  });
@@ -162,7 +171,7 @@ function fallbackReport(breakdown: ContextBreakdown): string {
162
171
  : "Real usage: not yet reported";
163
172
  const overshootLine = breakdown.overshootTokens > 0 ? [`Estimates exceed real total by ~${breakdown.overshootTokens} tok -- sizes below are approximate, not exact`] : [];
164
173
  const rows = buildContextRows(breakdown);
165
- const rowLines = rows.length > 0 ? rows.map((row) => row.text) : ["(nothing observed yet)"];
174
+ const rowLines = rows.length > 0 ? rows.map((row) => (row.isHeader ? row.text : `${" ".repeat(row.depth)}${row.text}`)) : ["(nothing observed yet)"];
166
175
  return [totalLine, ...overshootLine, "", ...rowLines].join("\n");
167
176
  }
168
177
 
@@ -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,28 +421,27 @@ 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, openTasks] = 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>, Artifact[]>("tasks.list", { project_root: ctx.cwd, session_id: sessionId, limit: 200 }),
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[];
432
- // Sized individually (title+body) so the Tasks segment can be drilled into like Rules
433
- // and Skills; this is a per-task approximation, not a byte-identical reproduction of
434
- // tasks.context's own current/next/rejected selection and rendering.
435
- const taskItems = openTasks
436
- .filter((task) => task.status !== "done" && task.status !== "canceled")
437
- .map((task) => ({ label: task.title, estimatedTokens: Math.ceil((task.title.length + task.body.length) / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN) }));
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);
438
436
  const breakdown = buildContextBreakdown({
439
437
  totalTokens: usage?.tokens ?? null,
440
438
  contextWindow: ctx.model?.contextWindow ?? null,
441
439
  ruleBudget,
442
- taskItems,
440
+ taskItems: buildTaskItemTree(taskGraph),
443
441
  skills,
444
442
  basePromptEstimatedTokens: lastObservedBasePromptTokens,
445
- messageHistoryEstimatedTokens: estimateMessageHistoryTokens(branch),
443
+ messageHistoryItems: messageHistory.items,
444
+ messageHistoryActiveTokens: messageHistory.activeTokens,
446
445
  });
447
446
  await showContextView(ctx, breakdown);
448
447
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
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"],
@@ -24,6 +24,7 @@
24
24
  "typebox": "*"
25
25
  },
26
26
  "devDependencies": {
27
+ "@earendil-works/pi-coding-agent": "^0.80.10",
27
28
  "bun-types": "latest",
28
29
  "typescript": "^5.7.3"
29
30
  },
package/src/constants.ts CHANGED
@@ -30,6 +30,23 @@ 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) -- the node bound
37
+ * is a defensive measure against a corrupted/adversarial parentId chain forming an accidental
38
+ * cycle, matching the same cycle-safety discipline already applied to ConversationJournal
39
+ * traversal and deliberately hardening past a real, confirmed gap in Pi's own getBranch() (no
40
+ * cycle guard at all). Set generously: a real, ordinary (non-branching) long-running session
41
+ * is one long linear chain, so a naively small bound truncates the walk after counting only a
42
+ * small fraction of the real conversation -- a session observed in production with 6,924
43
+ * entries on its own active branch confirmed an earlier, much smaller bound did exactly that,
44
+ * making the derived "unaccounted" remainder balloon to absorb almost the entire real total.
45
+ * The walk itself is iterative (an explicit stack), not recursive, specifically so a chain
46
+ * this long cannot also risk a real JavaScript call-stack overflow independent of this bound.
47
+ */
48
+ export const CONTEXT_TREE_MAX_NODES = 50_000;
49
+
33
50
  /**
34
51
  * A Papyrus Rule's condition+action+body is injected into EVERY relevant turn's system
35
52
  * prompt for the lifetime of the rule -- the same permanent, always-on-context role as an