@danypops/papyrus 0.13.2 → 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,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_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
 
@@ -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,84 @@ 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_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
+
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_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.
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
+ 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
+ };
137
211
  }
138
- 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 };
139
215
  }
140
216
 
141
217
  export interface ContextBreakdown {
@@ -163,13 +239,58 @@ export interface BuildContextBreakdownInput {
163
239
  contextWindow: number | null;
164
240
  reserveTokens?: number;
165
241
  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. */
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. */
167
243
  taskItems: ContextSegmentItem[];
168
244
  skills: SkillCatalogFootprint;
169
245
  /** Pi's own base system prompt size, cached from the last observed before_agent_start turn. Null before any turn has run yet. */
170
246
  basePromptEstimatedTokens: number | null;
171
- /** From estimateMessageHistoryTokens() against the live session branch. */
172
- 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);
173
294
  }
174
295
 
175
296
  /**
@@ -196,7 +317,7 @@ export function buildContextBreakdown(input: BuildContextBreakdownInput): Contex
196
317
  const tasks: ContextSegment = {
197
318
  key: "tasks",
198
319
  label: "Papyrus Tasks",
199
- estimatedTokens: input.taskItems.reduce((sum, item) => sum + item.estimatedTokens, 0),
320
+ estimatedTokens: sumItemTree(input.taskItems),
200
321
  items: input.taskItems,
201
322
  };
202
323
  const skills: ContextSegment = {
@@ -214,7 +335,8 @@ export function buildContextBreakdown(input: BuildContextBreakdownInput): Contex
214
335
  const messageHistory: ContextSegment = {
215
336
  key: "messageHistory",
216
337
  label: "Conversation message history",
217
- estimatedTokens: input.messageHistoryEstimatedTokens,
338
+ estimatedTokens: input.messageHistoryActiveTokens,
339
+ items: input.messageHistoryItems,
218
340
  };
219
341
  const knownTokens = rules.estimatedTokens + tasks.estimatedTokens + skills.estimatedTokens + basePrompt.estimatedTokens + messageHistory.estimatedTokens;
220
342
  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.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