@danypops/papyrus 0.13.3 → 0.13.5
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 +101 -32
- package/extension/src/context-view.ts +67 -8
- package/package.json +2 -1
- package/src/constants.ts +12 -7
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import { CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN,
|
|
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
5
|
import type { TaskGraph } from "../../src/task-service.ts";
|
|
6
6
|
import { discoverSkillDirectories, scanSkillCatalogFootprint, type SkillCatalogFootprint } from "./skill-catalog-footprint.ts";
|
|
@@ -162,7 +162,7 @@ export interface MessageHistoryTree {
|
|
|
162
162
|
items: ContextSegmentItem[];
|
|
163
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
164
|
activeTokens: number;
|
|
165
|
-
/** True if the walk hit
|
|
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
166
|
truncated: boolean;
|
|
167
167
|
}
|
|
168
168
|
|
|
@@ -171,24 +171,49 @@ export interface MessageHistoryTree {
|
|
|
171
171
|
* entries form a genuine tree via id/parentId, not just the linear current-branch path) to
|
|
172
172
|
* estimate the conversation's context contribution AND surface branches explored via /tree
|
|
173
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 (
|
|
175
|
-
*
|
|
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
176
|
* hardens past a confirmed real gap in Pi's own getBranch() (no cycle guard at all) rather
|
|
177
177
|
* than assuming the tree can never be malformed.
|
|
178
178
|
*/
|
|
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
|
+
*/
|
|
179
193
|
export function buildMessageHistoryTree(roots: ReadonlyArray<SessionTreeNodeLike>, activeEntryIds: ReadonlySet<string>): MessageHistoryTree {
|
|
180
194
|
const visited = new Set<string>();
|
|
181
195
|
let truncated = false;
|
|
182
196
|
let activeTokens = 0;
|
|
183
|
-
let nodesVisited = 0;
|
|
184
197
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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);
|
|
209
|
+
}
|
|
210
|
+
if (stack.length > 0) truncated = true; // node bound hit with more work still queued
|
|
190
211
|
|
|
191
|
-
|
|
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;
|
|
192
217
|
const characters = entry.type === "message"
|
|
193
218
|
? messageContentCharacters(entry.message)
|
|
194
219
|
: entry.type === "compaction" || entry.type === "branch_summary"
|
|
@@ -198,19 +223,29 @@ export function buildMessageHistoryTree(roots: ReadonlyArray<SessionTreeNodeLike
|
|
|
198
223
|
const isActive = activeEntryIds.has(entry.id);
|
|
199
224
|
if (isActive) activeTokens += tokens;
|
|
200
225
|
|
|
201
|
-
const children =
|
|
202
|
-
|
|
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
|
|
226
|
+
const children = childItemsByParent.get(index) ?? [];
|
|
227
|
+
if (tokens === 0 && children.length === 0) continue; // no content, no descendants with content -- nothing to show
|
|
205
228
|
|
|
206
|
-
|
|
229
|
+
const item: ContextSegmentItem = {
|
|
207
230
|
label: isActive ? entryLabel(entry) : `${entryLabel(entry)} (inactive branch)`,
|
|
208
231
|
estimatedTokens: tokens,
|
|
209
232
|
...(children.length > 0 ? { children } : {}),
|
|
210
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
|
+
}
|
|
211
240
|
}
|
|
212
241
|
|
|
213
|
-
const items
|
|
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
|
+
}
|
|
214
249
|
return { items, activeTokens, truncated };
|
|
215
250
|
}
|
|
216
251
|
|
|
@@ -267,30 +302,64 @@ function sumItemTree(items: ContextSegmentItem[]): number {
|
|
|
267
302
|
* (extension/src/task-widget.ts) for the identical multi-parent-DAG-in-a-bounded-view
|
|
268
303
|
* problem, not a new inconsistency.
|
|
269
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. */
|
|
270
311
|
export function buildTaskItemTree(graph: TaskGraph): ContextSegmentItem[] {
|
|
271
312
|
const byId = new Map(graph.nodes.map((node) => [node.task.id, node]));
|
|
272
313
|
const openIds = new Set(graph.nodes.filter((node) => node.task.status !== "done" && node.task.status !== "canceled").map((node) => node.task.id));
|
|
273
314
|
const visited = new Set<string>();
|
|
274
315
|
|
|
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
316
|
const rootIds = [...openIds].filter((id) => {
|
|
290
317
|
const node = byId.get(id)!;
|
|
291
318
|
return node.parentIds.length === 0 || !node.parentIds.some((parentId) => openIds.has(parentId));
|
|
292
319
|
});
|
|
293
|
-
|
|
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;
|
|
294
363
|
}
|
|
295
364
|
|
|
296
365
|
/**
|
|
@@ -13,6 +13,19 @@ const SEGMENT_COLORS: Record<ContextSegment["key"], ThemeColor> = {
|
|
|
13
13
|
other: "muted",
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
/** Short, fixed-width column labels for the vertical deep-dive graph -- must match VERTICAL_BAR_WIDTH exactly so each label sits centered under its own bar. */
|
|
17
|
+
const SEGMENT_SHORT_LABELS: Record<ContextSegment["key"], string> = {
|
|
18
|
+
rules: "Rul",
|
|
19
|
+
tasks: "Tsk",
|
|
20
|
+
skills: "Skl",
|
|
21
|
+
basePrompt: "Bse",
|
|
22
|
+
messageHistory: "Msg",
|
|
23
|
+
other: "Oth",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const VERTICAL_BAR_HEIGHT = 6;
|
|
27
|
+
const VERTICAL_BAR_WIDTH = 3;
|
|
28
|
+
|
|
16
29
|
/**
|
|
17
30
|
* One row in the unified scrollable view. Every segment that has any real (nonzero) content
|
|
18
31
|
* is fully expanded inline -- there is no separate "select a segment, then drill in" step.
|
|
@@ -104,10 +117,16 @@ class ContextViewport {
|
|
|
104
117
|
} else {
|
|
105
118
|
lines.push(theme.fg("dim", "No real usage reported yet — sizes below are Papyrus's own estimates only"));
|
|
106
119
|
}
|
|
107
|
-
lines.push(renderContextBar(theme, this.breakdown.segments, contentWidth));
|
|
120
|
+
lines.push(renderContextBar(theme, this.breakdown.segments, contentWidth, this.breakdown.effectiveBudget ?? undefined));
|
|
108
121
|
if (this.breakdown.overshootTokens > 0) {
|
|
109
122
|
lines.push(truncateToWidth(theme.fg("warning", `Estimates exceed real total by ~${this.breakdown.overshootTokens} tok — sizes below are approximate, not exact`), contentWidth, ""));
|
|
110
123
|
}
|
|
124
|
+
const verticalBars = renderContextVerticalBars(theme, this.breakdown.segments);
|
|
125
|
+
if (verticalBars.length > 0) {
|
|
126
|
+
lines.push("");
|
|
127
|
+
lines.push(theme.fg("dim", "Composition of used tokens:"));
|
|
128
|
+
for (const barLine of verticalBars) lines.push(truncateToWidth(barLine, contentWidth, ""));
|
|
129
|
+
}
|
|
111
130
|
lines.push("");
|
|
112
131
|
|
|
113
132
|
this.visibleWindow().forEach(({ row, index }) => {
|
|
@@ -143,27 +162,67 @@ class ContextViewport {
|
|
|
143
162
|
}
|
|
144
163
|
|
|
145
164
|
/**
|
|
146
|
-
* Renders the context window as one
|
|
147
|
-
* characters per segment,
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
165
|
+
* Renders the context window as one horizontal stacked bar: one colored run of block
|
|
166
|
+
* characters per USED segment, followed by a gray/dim run of "░" cells for the remaining,
|
|
167
|
+
* genuinely EMPTY context window -- this is the "total used vs. unused" graph. A zero-token
|
|
168
|
+
* breakdown (nothing observed yet) renders an entirely gray/dim track rather than a
|
|
169
|
+
* divide-by-zero, since 0 used really does mean the whole window is empty right now.
|
|
170
|
+
*
|
|
171
|
+
* `capacity` is the real denominator (Papyrus's own effectiveBudget, matching the percentage
|
|
172
|
+
* already shown in the text line above this bar) that used-vs-unused is measured against. When
|
|
173
|
+
* omitted, or when usage has already exceeded it (overshoot / near-compaction), the bar falls
|
|
174
|
+
* back to filling 100% of its width proportionally among segments -- there is no "unused" left
|
|
175
|
+
* to show gray for once real usage has met or passed the real budget.
|
|
151
176
|
*/
|
|
152
|
-
export function renderContextBar(theme: Theme, segments: ReadonlyArray<ContextSegment>, width: number): string {
|
|
177
|
+
export function renderContextBar(theme: Theme, segments: ReadonlyArray<ContextSegment>, width: number, capacity?: number): string {
|
|
153
178
|
const total = segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
|
|
154
179
|
if (total <= 0 || width <= 0) return theme.fg("dim", "░".repeat(Math.max(0, width)));
|
|
155
180
|
const nonZero = segments.filter((segment) => segment.estimatedTokens > 0);
|
|
181
|
+
const usedWidth = capacity !== undefined && capacity > total ? Math.min(width, Math.round((total / capacity) * width)) : width;
|
|
182
|
+
|
|
156
183
|
let used = 0;
|
|
157
184
|
let output = "";
|
|
158
185
|
nonZero.forEach((segment, index) => {
|
|
159
186
|
const isLast = index === nonZero.length - 1;
|
|
160
|
-
const cells = isLast ?
|
|
187
|
+
const cells = isLast ? usedWidth - used : Math.round((segment.estimatedTokens / total) * usedWidth);
|
|
161
188
|
used += cells;
|
|
162
189
|
if (cells > 0) output += theme.fg(SEGMENT_COLORS[segment.key], "█".repeat(cells));
|
|
163
190
|
});
|
|
191
|
+
const emptyWidth = width - usedWidth;
|
|
192
|
+
if (emptyWidth > 0) output += theme.fg("dim", "░".repeat(emptyWidth));
|
|
164
193
|
return output;
|
|
165
194
|
}
|
|
166
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Renders the "used" portion's own composition as a small vertical bar chart, one column per
|
|
198
|
+
* segment with real content, scaled so the largest segment fills the full height -- the
|
|
199
|
+
* "deep dive" graph, complementing the horizontal used-vs-unused bar above it. Any segment
|
|
200
|
+
* with real (nonzero) tokens gets at least one filled row so it stays visible even next to a
|
|
201
|
+
* much larger segment. Returns an empty array (nothing to render) when no segment has any
|
|
202
|
+
* tokens yet, matching the same zero-noise principle as the row list below it.
|
|
203
|
+
*/
|
|
204
|
+
export function renderContextVerticalBars(theme: Theme, segments: ReadonlyArray<ContextSegment>): string[] {
|
|
205
|
+
const visible = segments.filter((segment) => segment.estimatedTokens > 0);
|
|
206
|
+
if (visible.length === 0) return [];
|
|
207
|
+
const max = Math.max(...visible.map((segment) => segment.estimatedTokens));
|
|
208
|
+
const filledRows = new Map(visible.map((segment) => [segment.key, Math.max(1, Math.round((segment.estimatedTokens / max) * VERTICAL_BAR_HEIGHT))]));
|
|
209
|
+
|
|
210
|
+
const lines: string[] = [];
|
|
211
|
+
for (let row = 0; row < VERTICAL_BAR_HEIGHT; row++) {
|
|
212
|
+
const rowsFromBottom = VERTICAL_BAR_HEIGHT - row;
|
|
213
|
+
let line = "";
|
|
214
|
+
for (const segment of visible) {
|
|
215
|
+
const filled = (filledRows.get(segment.key) ?? 0) >= rowsFromBottom;
|
|
216
|
+
line += `${filled ? theme.fg(SEGMENT_COLORS[segment.key], "█".repeat(VERTICAL_BAR_WIDTH)) : " ".repeat(VERTICAL_BAR_WIDTH)} `;
|
|
217
|
+
}
|
|
218
|
+
lines.push(line);
|
|
219
|
+
}
|
|
220
|
+
let legend = "";
|
|
221
|
+
for (const segment of visible) legend += `${theme.fg(SEGMENT_COLORS[segment.key], SEGMENT_SHORT_LABELS[segment.key])} `;
|
|
222
|
+
lines.push(legend);
|
|
223
|
+
return lines;
|
|
224
|
+
}
|
|
225
|
+
|
|
167
226
|
/** Non-interactive fallback (print mode, RPC, etc.): the same unified row list, as plain text lines. */
|
|
168
227
|
function fallbackReport(breakdown: ContextBreakdown): string {
|
|
169
228
|
const totalLine = breakdown.totalTokens !== null
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/papyrus",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.5",
|
|
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
|
@@ -33,14 +33,19 @@ export const CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN = 4;
|
|
|
33
33
|
/**
|
|
34
34
|
* Bounds for walking Pi's real session tree (getTree()) and Papyrus's own Task containment
|
|
35
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) --
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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.
|
|
41
47
|
*/
|
|
42
|
-
export const
|
|
43
|
-
export const CONTEXT_TREE_MAX_NODES = 2000;
|
|
48
|
+
export const CONTEXT_TREE_MAX_NODES = 50_000;
|
|
44
49
|
|
|
45
50
|
/**
|
|
46
51
|
* A Papyrus Rule's condition+action+body is injected into EVERY relevant turn's system
|