@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.
- package/extension/src/context-budget.ts +214 -23
- package/extension/src/context-view.ts +15 -6
- package/extension/src/index.ts +11 -12
- package/package.json +2 -1
- package/src/constants.ts +17 -0
|
@@ -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
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
|
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
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
172
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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,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,
|
|
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
|
-
|
|
432
|
-
//
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const
|
|
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
|
-
|
|
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.
|
|
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
|