@danypops/papyrus 0.13.4 → 0.13.6
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.
|
@@ -172,9 +172,24 @@ export interface MessageHistoryTree {
|
|
|
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
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
|
-
*
|
|
177
|
-
*
|
|
175
|
+
* a session file is external, mutable state, and this deliberately hardens past a confirmed
|
|
176
|
+
* real gap in Pi's own getBranch() (no cycle guard at all) rather than assuming the tree can
|
|
177
|
+
* never be malformed.
|
|
178
|
+
*
|
|
179
|
+
* `activeEntryIds` MUST come from ctx.sessionManager.buildContextEntries(), not getBranch().
|
|
180
|
+
* getBranch()'s own docstring says it "[i]ncludes all entry types... Use buildSessionContext()
|
|
181
|
+
* to get the resolved messages for the LLM" -- it does not skip entries a real compaction has
|
|
182
|
+
* already summarized away. A real session with 3 compactions confirmed using getBranch() here
|
|
183
|
+
* overcounts activeTokens by over 13x, since every pre-compaction message still reads as
|
|
184
|
+
* "active". buildContextEntries() is Pi's own compaction-aware entry list: the latest
|
|
185
|
+
* compaction entry, its kept entries from firstKeptEntryId onward, and everything after.
|
|
186
|
+
*
|
|
187
|
+
* `branchEntryIds` (optional) is the full raw current-path id set (getBranch()'s own output).
|
|
188
|
+
* When given, an entry on the branch path but excluded from activeEntryIds is labeled
|
|
189
|
+
* "(compacted)" rather than the less accurate "(inactive branch)", which is reserved for
|
|
190
|
+
* entries not on the current path at all (a genuinely abandoned /tree branch). Omitting it
|
|
191
|
+
* preserves the simpler binary active/inactive-branch labeling for callers that only have one
|
|
192
|
+
* set to give (e.g. tests).
|
|
178
193
|
*/
|
|
179
194
|
interface WalkFrame {
|
|
180
195
|
node: SessionTreeNodeLike;
|
|
@@ -190,7 +205,7 @@ interface WalkFrame {
|
|
|
190
205
|
* JavaScript call-stack overflow at that scale, independent of the CONTEXT_TREE_MAX_NODES
|
|
191
206
|
* bound entirely.
|
|
192
207
|
*/
|
|
193
|
-
export function buildMessageHistoryTree(roots: ReadonlyArray<SessionTreeNodeLike>, activeEntryIds: ReadonlySet<string>): MessageHistoryTree {
|
|
208
|
+
export function buildMessageHistoryTree(roots: ReadonlyArray<SessionTreeNodeLike>, activeEntryIds: ReadonlySet<string>, branchEntryIds?: ReadonlySet<string>): MessageHistoryTree {
|
|
194
209
|
const visited = new Set<string>();
|
|
195
210
|
let truncated = false;
|
|
196
211
|
let activeTokens = 0;
|
|
@@ -222,12 +237,13 @@ export function buildMessageHistoryTree(roots: ReadonlyArray<SessionTreeNodeLike
|
|
|
222
237
|
const tokens = Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
|
|
223
238
|
const isActive = activeEntryIds.has(entry.id);
|
|
224
239
|
if (isActive) activeTokens += tokens;
|
|
240
|
+
const isOnBranch = branchEntryIds ? branchEntryIds.has(entry.id) : isActive; // no branch set given -- fall back to the old binary active/inactive-branch label
|
|
225
241
|
|
|
226
242
|
const children = childItemsByParent.get(index) ?? [];
|
|
227
243
|
if (tokens === 0 && children.length === 0) continue; // no content, no descendants with content -- nothing to show
|
|
228
244
|
|
|
229
245
|
const item: ContextSegmentItem = {
|
|
230
|
-
label: isActive ? entryLabel(entry) : `${entryLabel(entry)} (inactive branch)`,
|
|
246
|
+
label: isActive ? entryLabel(entry) : isOnBranch ? `${entryLabel(entry)} (compacted)` : `${entryLabel(entry)} (inactive branch)`,
|
|
231
247
|
estimatedTokens: tokens,
|
|
232
248
|
...(children.length > 0 ? { children } : {}),
|
|
233
249
|
};
|
|
@@ -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/extension/src/index.ts
CHANGED
|
@@ -431,8 +431,17 @@ export default async function (pi: ExtensionAPI) {
|
|
|
431
431
|
// Real tree (not just the linear current-branch path): surfaces content sitting in an
|
|
432
432
|
// abandoned /tree branch, which cost real tokens to generate but isn't in context now.
|
|
433
433
|
const tree = ctx.sessionManager.getTree() as unknown as SessionTreeNodeLike[];
|
|
434
|
-
|
|
435
|
-
|
|
434
|
+
// buildContextEntries(), NOT getBranch(): getBranch() returns every raw entry on the
|
|
435
|
+
// current path including everything a real compaction has already summarized away.
|
|
436
|
+
// A session with 3 real compactions confirmed this made "active" message-history
|
|
437
|
+
// tokens overcount the real total by over 13x -- getBranch()'s own docstring already
|
|
438
|
+
// says as much ("Use buildSessionContext() to get the resolved messages for the
|
|
439
|
+
// LLM"); buildContextEntries() is the compaction-aware entry list matching what the
|
|
440
|
+
// LLM actually sees (the latest compaction entry itself, plus kept entries from its
|
|
441
|
+
// firstKeptEntryId onward, plus everything after -- older summarized entries omitted).
|
|
442
|
+
const activeEntryIds = new Set((ctx.sessionManager.buildContextEntries() as unknown as SessionEntryLike[]).map((entry) => entry.id));
|
|
443
|
+
const branchEntryIds = new Set((ctx.sessionManager.getBranch() as unknown as SessionEntryLike[]).map((entry) => entry.id));
|
|
444
|
+
const messageHistory = buildMessageHistoryTree(tree, activeEntryIds, branchEntryIds);
|
|
436
445
|
const breakdown = buildContextBreakdown({
|
|
437
446
|
totalTokens: usage?.tokens ?? null,
|
|
438
447
|
contextWindow: ctx.model?.contextWindow ?? null,
|
package/package.json
CHANGED