@danypops/papyrus 0.13.0 → 0.13.2
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 +114 -50
- package/extension/src/context-view.ts +100 -97
- package/extension/src/index.ts +20 -5
- package/package.json +1 -1
|
@@ -5,8 +5,6 @@ import type { Artifact } from "../../src/domain/artifact.ts";
|
|
|
5
5
|
import { discoverSkillDirectories, scanSkillCatalogFootprint, type SkillCatalogFootprint } from "./skill-catalog-footprint.ts";
|
|
6
6
|
import { ruleInjectionPreview } from "./rules.ts";
|
|
7
7
|
|
|
8
|
-
const REPORT_MAX_ROWS = 5;
|
|
9
|
-
|
|
10
8
|
export interface RuleBudgetEntry {
|
|
11
9
|
id: string;
|
|
12
10
|
title: string;
|
|
@@ -71,11 +69,73 @@ export interface ContextSegmentItem {
|
|
|
71
69
|
}
|
|
72
70
|
|
|
73
71
|
export interface ContextSegment {
|
|
74
|
-
key: "rules" | "tasks" | "skills" | "other";
|
|
72
|
+
key: "rules" | "tasks" | "skills" | "basePrompt" | "messageHistory" | "other";
|
|
75
73
|
label: string;
|
|
76
74
|
estimatedTokens: number;
|
|
77
75
|
/** Drill-down items, when this segment can be broken down further. Absent for "other" -- an opaque remainder, not a real category. */
|
|
78
76
|
items?: ContextSegmentItem[];
|
|
77
|
+
/**
|
|
78
|
+
* True when this segment's size is genuinely unmeasured (not yet observed), as opposed to
|
|
79
|
+
* measured-and-actually-zero. A display layer that hides zero-token rows to cut noise must
|
|
80
|
+
* NOT hide an unknown segment just because its placeholder value happens to be zero --
|
|
81
|
+
* that would silently misrepresent "we don't know" as "there is nothing here", the same
|
|
82
|
+
* category of honesty problem overshootTokens exists to prevent for the unaccounted bucket.
|
|
83
|
+
*/
|
|
84
|
+
unknown?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
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.
|
|
91
|
+
*/
|
|
92
|
+
export interface SessionBranchEntryLike {
|
|
93
|
+
type: string;
|
|
94
|
+
message?: unknown;
|
|
95
|
+
summary?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function messageContentCharacters(message: unknown): number {
|
|
99
|
+
if (typeof message !== "object" || message === null) return 0;
|
|
100
|
+
const record = message as Record<string, unknown>;
|
|
101
|
+
if (record["role"] === "bashExecution") {
|
|
102
|
+
// Pi's own context builder excludes "!!"-prefixed bash output from context; match that.
|
|
103
|
+
if (record["excludeFromContext"] === true) return 0;
|
|
104
|
+
return String(record["command"] ?? "").length + String(record["output"] ?? "").length;
|
|
105
|
+
}
|
|
106
|
+
const content = record["content"];
|
|
107
|
+
if (typeof content === "string") return content.length;
|
|
108
|
+
if (!Array.isArray(content)) return 0;
|
|
109
|
+
let characters = 0;
|
|
110
|
+
for (const block of content) {
|
|
111
|
+
if (typeof block !== "object" || block === null) continue;
|
|
112
|
+
const b = block as Record<string, unknown>;
|
|
113
|
+
if (b["type"] === "text") characters += String(b["text"] ?? "").length;
|
|
114
|
+
else if (b["type"] === "thinking") characters += String(b["thinking"] ?? "").length;
|
|
115
|
+
else if (b["type"] === "toolCall") characters += JSON.stringify(b["arguments"] ?? {}).length;
|
|
116
|
+
// "image" blocks are deliberately not counted here -- image tokens follow a different,
|
|
117
|
+
// non-character-based cost model this char/4 estimate cannot represent; this is a real,
|
|
118
|
+
// documented undercount for image-heavy sessions, not a silent approximation.
|
|
119
|
+
}
|
|
120
|
+
return characters;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
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.
|
|
131
|
+
*/
|
|
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;
|
|
137
|
+
}
|
|
138
|
+
return Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
|
|
79
139
|
}
|
|
80
140
|
|
|
81
141
|
export interface ContextBreakdown {
|
|
@@ -85,7 +145,16 @@ export interface ContextBreakdown {
|
|
|
85
145
|
contextWindow: number | null;
|
|
86
146
|
/** contextWindow - reserveTokens, mirroring Pi's own compaction-trigger formula. Null when contextWindow is unknown. */
|
|
87
147
|
effectiveBudget: number | null;
|
|
88
|
-
/**
|
|
148
|
+
/**
|
|
149
|
+
* How much the known/estimated segments (rules+tasks+skills+basePrompt+messageHistory)
|
|
150
|
+
* exceed the real total, when they do. Zero means no overshoot. This must stay visible
|
|
151
|
+
* rather than only being absorbed into "unaccounted" clamping to zero -- a clamped-to-zero
|
|
152
|
+
* unaccounted segment does NOT mean tool definitions and framework overhead are actually
|
|
153
|
+
* free; it means this estimate's other segments already consumed the entire real budget on
|
|
154
|
+
* paper. Hiding that distinction would make a genuinely nonzero cost look like zero.
|
|
155
|
+
*/
|
|
156
|
+
overshootTokens: number;
|
|
157
|
+
/** rules, tasks, skills, basePrompt, messageHistory, then "other" absorbing whatever real usage the rest don't account for. */
|
|
89
158
|
segments: ContextSegment[];
|
|
90
159
|
}
|
|
91
160
|
|
|
@@ -94,19 +163,27 @@ export interface BuildContextBreakdownInput {
|
|
|
94
163
|
contextWindow: number | null;
|
|
95
164
|
reserveTokens?: number;
|
|
96
165
|
ruleBudget: ContextBudget["rules"];
|
|
97
|
-
|
|
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. */
|
|
167
|
+
taskItems: ContextSegmentItem[];
|
|
98
168
|
skills: SkillCatalogFootprint;
|
|
169
|
+
/** Pi's own base system prompt size, cached from the last observed before_agent_start turn. Null before any turn has run yet. */
|
|
170
|
+
basePromptEstimatedTokens: number | null;
|
|
171
|
+
/** From estimateMessageHistoryTokens() against the live session branch. */
|
|
172
|
+
messageHistoryEstimatedTokens: number;
|
|
99
173
|
}
|
|
100
174
|
|
|
101
175
|
/**
|
|
102
|
-
* Composes Papyrus
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
176
|
+
* Composes every segment Papyrus can actually measure or estimate (rules, tasks, skills
|
|
177
|
+
* catalog, cached base-prompt size, and the live session's own message history) against the
|
|
178
|
+
* real total Pi reports, deriving "unaccounted" (tool definitions and framework overhead --
|
|
179
|
+
* genuinely invisible to any extension) as the remainder. The remainder is clamped to zero
|
|
180
|
+
* rather than shown negative -- char/4 token estimation is approximate, and a small overshoot
|
|
181
|
+
* in the known segments must not display as a nonsensical negative bucket -- but the clamp
|
|
182
|
+
* amount itself is preserved as overshootTokens rather than silently discarded, so a
|
|
183
|
+
* consumer can tell "genuinely zero" apart from "our other estimates already exceeded the
|
|
184
|
+
* real total". When the real total is unavailable, unaccounted is reported as zero and
|
|
185
|
+
* totalTokens surfaces as null so callers can label the whole breakdown as estimate-only
|
|
186
|
+
* rather than silently treating a partial sum as ground truth.
|
|
110
187
|
*/
|
|
111
188
|
export function buildContextBreakdown(input: BuildContextBreakdownInput): ContextBreakdown {
|
|
112
189
|
const reserveTokens = input.reserveTokens ?? DEFAULT_RESERVE_TOKENS;
|
|
@@ -116,58 +193,45 @@ export function buildContextBreakdown(input: BuildContextBreakdownInput): Contex
|
|
|
116
193
|
estimatedTokens: input.ruleBudget.totalEstimatedTokens,
|
|
117
194
|
items: input.ruleBudget.entries.map((entry) => ({ label: entry.title, estimatedTokens: entry.estimatedTokens })),
|
|
118
195
|
};
|
|
119
|
-
const tasks: ContextSegment = {
|
|
196
|
+
const tasks: ContextSegment = {
|
|
197
|
+
key: "tasks",
|
|
198
|
+
label: "Papyrus Tasks",
|
|
199
|
+
estimatedTokens: input.taskItems.reduce((sum, item) => sum + item.estimatedTokens, 0),
|
|
200
|
+
items: input.taskItems,
|
|
201
|
+
};
|
|
120
202
|
const skills: ContextSegment = {
|
|
121
203
|
key: "skills",
|
|
122
204
|
label: "Pi Skills catalog",
|
|
123
205
|
estimatedTokens: input.skills.totalEstimatedTokens,
|
|
124
206
|
items: input.skills.entries.map((entry) => ({ label: entry.name, estimatedTokens: entry.estimatedTokens })),
|
|
125
207
|
};
|
|
126
|
-
const
|
|
208
|
+
const basePrompt: ContextSegment = {
|
|
209
|
+
key: "basePrompt",
|
|
210
|
+
label: input.basePromptEstimatedTokens === null ? "Base system prompt (not observed yet)" : "Base system prompt (Pi + host instructions)",
|
|
211
|
+
estimatedTokens: input.basePromptEstimatedTokens ?? 0,
|
|
212
|
+
...(input.basePromptEstimatedTokens === null ? { unknown: true } : {}),
|
|
213
|
+
};
|
|
214
|
+
const messageHistory: ContextSegment = {
|
|
215
|
+
key: "messageHistory",
|
|
216
|
+
label: "Conversation message history",
|
|
217
|
+
estimatedTokens: input.messageHistoryEstimatedTokens,
|
|
218
|
+
};
|
|
219
|
+
const knownTokens = rules.estimatedTokens + tasks.estimatedTokens + skills.estimatedTokens + basePrompt.estimatedTokens + messageHistory.estimatedTokens;
|
|
220
|
+
const overshootTokens = input.totalTokens === null ? 0 : Math.max(0, knownTokens - input.totalTokens);
|
|
127
221
|
const other: ContextSegment = {
|
|
128
222
|
key: "other",
|
|
129
|
-
label:
|
|
223
|
+
label: overshootTokens > 0
|
|
224
|
+
? `Unaccounted (tool definitions, framework overhead) -- estimate overshoot: other segments' estimates already exceed the real total by ~${overshootTokens} tokens, so this is a floor, not a real zero`
|
|
225
|
+
: "Unaccounted (tool definitions, framework overhead)",
|
|
130
226
|
estimatedTokens: input.totalTokens === null ? 0 : Math.max(0, input.totalTokens - knownTokens),
|
|
131
227
|
};
|
|
132
228
|
return {
|
|
133
229
|
totalTokens: input.totalTokens,
|
|
134
230
|
contextWindow: input.contextWindow,
|
|
135
231
|
effectiveBudget: input.contextWindow === null ? null : Math.max(0, input.contextWindow - reserveTokens),
|
|
136
|
-
|
|
232
|
+
overshootTokens,
|
|
233
|
+
segments: [rules, tasks, skills, basePrompt, messageHistory, other],
|
|
137
234
|
};
|
|
138
235
|
}
|
|
139
236
|
|
|
140
|
-
function truncate(text: string, max: number): string {
|
|
141
|
-
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Pure text formatter, independent of live daemon/filesystem state, for direct unit testing. */
|
|
145
|
-
export function formatContextBudgetReport(budget: ContextBudget): string {
|
|
146
|
-
const lines: string[] = ["Papyrus passive context budget", ""];
|
|
147
|
-
|
|
148
|
-
lines.push(`Rules (active, injected every relevant turn): ${budget.rules.entries.length} rules · ${budget.rules.totalCharacters} chars · ~${budget.rules.totalEstimatedTokens} tokens`);
|
|
149
|
-
if (budget.rules.entries.length > 0) {
|
|
150
|
-
lines.push(" Largest:");
|
|
151
|
-
for (const entry of budget.rules.entries.slice(0, REPORT_MAX_ROWS)) {
|
|
152
|
-
lines.push(` ${entry.characters.toString().padStart(5)} chars (~${entry.estimatedTokens} tok) ${truncate(entry.title, 60)}`);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
lines.push("");
|
|
156
|
-
|
|
157
|
-
lines.push(`Skills (Pi-native catalog, injected at startup): ${budget.skills.entries.length} skills · ${budget.skills.totalCharacters} chars · ~${budget.skills.totalEstimatedTokens} tokens`);
|
|
158
|
-
if (budget.skills.entries.length > 0) {
|
|
159
|
-
lines.push(" Largest:");
|
|
160
|
-
for (const entry of budget.skills.entries.slice(0, REPORT_MAX_ROWS)) {
|
|
161
|
-
lines.push(` ${entry.characters.toString().padStart(5)} chars (~${entry.estimatedTokens} tok) ${truncate(entry.name, 40)}`);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
if (budget.skills.scannedDirectories.length > 0) {
|
|
165
|
-
lines.push(` Scanned: ${budget.skills.scannedDirectories.join(", ")}`);
|
|
166
|
-
} else {
|
|
167
|
-
lines.push(" No skill directories found (checked Pi's documented global/project locations and settings.json's skills array).");
|
|
168
|
-
}
|
|
169
|
-
lines.push("");
|
|
170
237
|
|
|
171
|
-
lines.push(`Total passive tax: ~${budget.totalEstimatedTokens} tokens across ${budget.rules.entries.length + budget.skills.entries.length} items, before a single user message or tool call.`);
|
|
172
|
-
return lines.join("\n");
|
|
173
|
-
}
|
|
@@ -1,17 +1,30 @@
|
|
|
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
|
|
4
|
-
import { formatContextBudgetReport, type ContextBudget } from "./context-budget.ts";
|
|
3
|
+
import type { ContextBreakdown, ContextSegment } from "./context-budget.ts";
|
|
5
4
|
|
|
6
|
-
const
|
|
5
|
+
const VISIBLE_ROWS = 24;
|
|
7
6
|
|
|
8
7
|
const SEGMENT_COLORS: Record<ContextSegment["key"], ThemeColor> = {
|
|
9
8
|
rules: "accent",
|
|
10
9
|
tasks: "success",
|
|
11
10
|
skills: "mdLink",
|
|
11
|
+
basePrompt: "warning",
|
|
12
|
+
messageHistory: "syntaxFunction",
|
|
12
13
|
other: "muted",
|
|
13
14
|
};
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* One row in the unified scrollable view. Every segment that has any real (nonzero) content
|
|
18
|
+
* is fully expanded inline -- there is no separate "select a segment, then drill in" step.
|
|
19
|
+
* `key` drives this row's color; `isHeader` distinguishes a segment's own summary line from
|
|
20
|
+
* its item rows underneath it.
|
|
21
|
+
*/
|
|
22
|
+
export interface ContextRow {
|
|
23
|
+
key: ContextSegment["key"];
|
|
24
|
+
isHeader: boolean;
|
|
25
|
+
text: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
15
28
|
function formatTokenCount(tokens: number): string {
|
|
16
29
|
return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
|
|
17
30
|
}
|
|
@@ -21,35 +34,47 @@ function percentOf(part: number, whole: number): string {
|
|
|
21
34
|
}
|
|
22
35
|
|
|
23
36
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
37
|
+
* Flattens every segment with real content into one linear row list, filtering out anything
|
|
38
|
+
* that is genuinely zero rather than displaying a misleading "0 tok 0.0%" row -- a segment or
|
|
39
|
+
* item with literally nothing in it carries no information and is pure noise in a scrollable
|
|
40
|
+
* view meant to show where tokens actually go. A segment whose OWN total is zero but whose
|
|
41
|
+
* items are also all zero is dropped entirely; a segment with a nonzero total is always kept
|
|
42
|
+
* even if all its items individually round to zero (the total itself is real signal).
|
|
27
43
|
*/
|
|
28
|
-
export function
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
export function buildContextRows(breakdown: ContextBreakdown): ContextRow[] {
|
|
45
|
+
const rows: ContextRow[] = [];
|
|
46
|
+
const denominator = breakdown.totalTokens ?? breakdown.segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
|
|
47
|
+
for (const segment of breakdown.segments) {
|
|
48
|
+
const items = (segment.items ?? []).filter((item) => item.estimatedTokens > 0).sort((a, b) => b.estimatedTokens - a.estimatedTokens);
|
|
49
|
+
// A genuinely-unknown segment (basePrompt before the first observed turn) must stay
|
|
50
|
+
// visible even when its placeholder value is zero -- hiding it would misrepresent
|
|
51
|
+
// "not measured yet" as "measured and empty", the same honesty problem overshootTokens
|
|
52
|
+
// exists to prevent for the unaccounted bucket.
|
|
53
|
+
if (segment.estimatedTokens <= 0 && items.length === 0 && !segment.unknown) continue;
|
|
54
|
+
rows.push({
|
|
55
|
+
key: segment.key,
|
|
56
|
+
isHeader: true,
|
|
57
|
+
text: `${segment.label} — ${segment.estimatedTokens} tok (${percentOf(segment.estimatedTokens, denominator)})`,
|
|
58
|
+
});
|
|
59
|
+
for (const item of items) {
|
|
60
|
+
rows.push({ key: segment.key, isHeader: false, text: ` ${item.estimatedTokens.toString().padStart(6)} tok ${item.label}` });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return rows;
|
|
40
64
|
}
|
|
41
65
|
|
|
42
66
|
class ContextViewport {
|
|
43
|
-
private
|
|
44
|
-
private
|
|
45
|
-
private drillIndex = 0;
|
|
67
|
+
private offsetY = 0;
|
|
68
|
+
private readonly rows: ContextRow[];
|
|
46
69
|
|
|
47
70
|
constructor(
|
|
48
71
|
private readonly tui: TUI,
|
|
49
72
|
private readonly theme: Theme,
|
|
50
73
|
private readonly breakdown: ContextBreakdown,
|
|
51
74
|
private readonly close: () => void,
|
|
52
|
-
) {
|
|
75
|
+
) {
|
|
76
|
+
this.rows = buildContextRows(breakdown);
|
|
77
|
+
}
|
|
53
78
|
|
|
54
79
|
invalidate(): void {}
|
|
55
80
|
|
|
@@ -69,103 +94,81 @@ class ContextViewport {
|
|
|
69
94
|
} else if (this.breakdown.totalTokens !== null) {
|
|
70
95
|
lines.push(truncateToWidth(`${formatTokenCount(this.breakdown.totalTokens)} tokens (model context window unknown)`, contentWidth, ""));
|
|
71
96
|
} else {
|
|
72
|
-
lines.push(theme.fg("dim", "No real usage reported yet —
|
|
97
|
+
lines.push(theme.fg("dim", "No real usage reported yet — sizes below are Papyrus's own estimates only"));
|
|
73
98
|
}
|
|
74
99
|
lines.push(renderContextBar(theme, this.breakdown.segments, contentWidth));
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (this.drillDown) {
|
|
78
|
-
lines.push(...this.renderDrillDown(contentWidth));
|
|
79
|
-
} else {
|
|
80
|
-
lines.push(...this.renderSegments(contentWidth));
|
|
100
|
+
if (this.breakdown.overshootTokens > 0) {
|
|
101
|
+
lines.push(truncateToWidth(theme.fg("warning", `Estimates exceed real total by ~${this.breakdown.overshootTokens} tok — sizes below are approximate, not exact`), contentWidth, ""));
|
|
81
102
|
}
|
|
82
|
-
lines.push(
|
|
83
|
-
return lines;
|
|
84
|
-
}
|
|
103
|
+
lines.push("");
|
|
85
104
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const selected = index === this.selectedIndex;
|
|
92
|
-
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
93
|
-
const swatch = theme.fg(SEGMENT_COLORS[segment.key], "██");
|
|
94
|
-
const title = selected ? theme.bold(segment.label) : segment.label;
|
|
95
|
-
const percent = percentOf(segment.estimatedTokens, denominator);
|
|
96
|
-
const drillHint = segment.items && segment.items.length > 0 ? theme.fg("dim", ` (${segment.items.length} items — enter to expand)`) : "";
|
|
97
|
-
lines.push(truncateToWidth(`${cursor} ${swatch} ${segment.estimatedTokens.toString().padStart(6)} tok ${percent.padStart(5)} ${title}${drillHint}`, width, ""));
|
|
105
|
+
this.visibleWindow().forEach(({ row, index }) => {
|
|
106
|
+
const gutter = theme.fg(SEGMENT_COLORS[row.key], "▌");
|
|
107
|
+
const text = row.isHeader ? theme.bold(row.text) : row.text;
|
|
108
|
+
lines.push(truncateToWidth(`${gutter} ${text}`, contentWidth, ""));
|
|
109
|
+
void index;
|
|
98
110
|
});
|
|
111
|
+
if (this.rows.length === 0) lines.push(theme.fg("dim", " (nothing observed yet)"));
|
|
112
|
+
else lines.push(theme.fg("muted", ` ${Math.min(this.offsetY + VISIBLE_ROWS, this.rows.length)}/${this.rows.length}`));
|
|
113
|
+
|
|
99
114
|
lines.push("");
|
|
100
|
-
lines.push(theme.fg("dim", "↑↓
|
|
115
|
+
lines.push(theme.fg("dim", "↑↓ scroll · esc close"));
|
|
116
|
+
lines.push(border);
|
|
101
117
|
return lines;
|
|
102
118
|
}
|
|
103
119
|
|
|
104
|
-
private
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (items.length === 0) {
|
|
110
|
-
lines.push(theme.fg("dim", " (nothing to break down further)"));
|
|
111
|
-
} else {
|
|
112
|
-
const start = Math.max(0, Math.min(this.drillIndex - Math.floor(DRILLDOWN_VISIBLE_ROWS / 2), items.length - DRILLDOWN_VISIBLE_ROWS));
|
|
113
|
-
const end = Math.min(start + DRILLDOWN_VISIBLE_ROWS, items.length);
|
|
114
|
-
for (let index = start; index < end; index++) {
|
|
115
|
-
const item = items[index]!;
|
|
116
|
-
const selected = index === this.drillIndex;
|
|
117
|
-
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
118
|
-
const title = selected ? theme.bold(item.label) : item.label;
|
|
119
|
-
lines.push(truncateToWidth(`${cursor} ${item.estimatedTokens.toString().padStart(6)} tok ${title}`, width, ""));
|
|
120
|
-
}
|
|
121
|
-
lines.push(theme.fg("muted", ` ${this.drillIndex + 1}/${items.length}`));
|
|
122
|
-
}
|
|
123
|
-
lines.push("");
|
|
124
|
-
lines.push(theme.fg("dim", "↑↓ scroll · esc back"));
|
|
125
|
-
return lines;
|
|
120
|
+
private visibleWindow(): Array<{ row: ContextRow; index: number }> {
|
|
121
|
+
const end = Math.min(this.offsetY + VISIBLE_ROWS, this.rows.length);
|
|
122
|
+
const result: Array<{ row: ContextRow; index: number }> = [];
|
|
123
|
+
for (let index = this.offsetY; index < end; index++) result.push({ row: this.rows[index]!, index });
|
|
124
|
+
return result;
|
|
126
125
|
}
|
|
127
126
|
|
|
128
127
|
handleInput(data: string): void {
|
|
129
|
-
if (this.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
else if (matchesKey(data, "down")) this.drillIndex = Math.min(Math.max(0, items.length - 1), this.drillIndex + 1);
|
|
134
|
-
else return;
|
|
135
|
-
} else {
|
|
136
|
-
if (matchesKey(data, "escape")) { this.close(); return; }
|
|
137
|
-
if (matchesKey(data, "up")) this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
138
|
-
else if (matchesKey(data, "down")) this.selectedIndex = Math.min(this.breakdown.segments.length - 1, this.selectedIndex + 1);
|
|
139
|
-
else if (matchesKey(data, "enter")) {
|
|
140
|
-
const segment = this.breakdown.segments[this.selectedIndex];
|
|
141
|
-
if (segment?.items && segment.items.length > 0) {
|
|
142
|
-
this.drillDown = segment;
|
|
143
|
-
this.drillIndex = 0;
|
|
144
|
-
}
|
|
145
|
-
} else return;
|
|
146
|
-
}
|
|
128
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); return; }
|
|
129
|
+
if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
|
|
130
|
+
else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.rows.length - VISIBLE_ROWS), this.offsetY + 1);
|
|
131
|
+
else return;
|
|
147
132
|
this.tui.requestRender();
|
|
148
133
|
}
|
|
149
134
|
}
|
|
150
135
|
|
|
151
|
-
/**
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Renders the context window as one proportional stacked bar, one colored run of block
|
|
138
|
+
* characters per segment, matching each row's own gutter color above/below it. A zero-token
|
|
139
|
+
* breakdown (nothing observed yet) renders an empty dim track rather than a divide-by-zero.
|
|
140
|
+
* Zero-token segments contribute no cells and are effectively invisible in the bar, matching
|
|
141
|
+
* their exclusion from the row list below it.
|
|
142
|
+
*/
|
|
143
|
+
export function renderContextBar(theme: Theme, segments: ReadonlyArray<ContextSegment>, width: number): string {
|
|
144
|
+
const total = segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
|
|
145
|
+
if (total <= 0 || width <= 0) return theme.fg("dim", "░".repeat(Math.max(0, width)));
|
|
146
|
+
const nonZero = segments.filter((segment) => segment.estimatedTokens > 0);
|
|
147
|
+
let used = 0;
|
|
148
|
+
let output = "";
|
|
149
|
+
nonZero.forEach((segment, index) => {
|
|
150
|
+
const isLast = index === nonZero.length - 1;
|
|
151
|
+
const cells = isLast ? width - used : Math.round((segment.estimatedTokens / total) * width);
|
|
152
|
+
used += cells;
|
|
153
|
+
if (cells > 0) output += theme.fg(SEGMENT_COLORS[segment.key], "█".repeat(cells));
|
|
154
|
+
});
|
|
155
|
+
return output;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Non-interactive fallback (print mode, RPC, etc.): the same unified row list, as plain text lines. */
|
|
159
|
+
function fallbackReport(breakdown: ContextBreakdown): string {
|
|
155
160
|
const totalLine = breakdown.totalTokens !== null
|
|
156
161
|
? `Real usage: ${breakdown.totalTokens} tokens${breakdown.effectiveBudget !== null ? ` / ${breakdown.effectiveBudget} usable budget (${percentOf(breakdown.totalTokens, breakdown.effectiveBudget)})` : ""}`
|
|
157
162
|
: "Real usage: not yet reported";
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
formatContextBudgetReport({ rules: ruleBudget, skills: { entries: [], totalCharacters: 0, totalEstimatedTokens: skillsSegment?.estimatedTokens ?? 0, scannedDirectories: [] }, totalEstimatedTokens: ruleBudget.totalEstimatedTokens }),
|
|
163
|
-
].join("\n");
|
|
163
|
+
const overshootLine = breakdown.overshootTokens > 0 ? [`Estimates exceed real total by ~${breakdown.overshootTokens} tok -- sizes below are approximate, not exact`] : [];
|
|
164
|
+
const rows = buildContextRows(breakdown);
|
|
165
|
+
const rowLines = rows.length > 0 ? rows.map((row) => row.text) : ["(nothing observed yet)"];
|
|
166
|
+
return [totalLine, ...overshootLine, "", ...rowLines].join("\n");
|
|
164
167
|
}
|
|
165
168
|
|
|
166
|
-
export async function showContextView(ctx: ExtensionCommandContext, breakdown: ContextBreakdown
|
|
169
|
+
export async function showContextView(ctx: ExtensionCommandContext, breakdown: ContextBreakdown): Promise<void> {
|
|
167
170
|
if (ctx.mode !== "tui") {
|
|
168
|
-
ctx.ui.notify(fallbackReport(breakdown
|
|
171
|
+
ctx.ui.notify(fallbackReport(breakdown), "info");
|
|
169
172
|
return;
|
|
170
173
|
}
|
|
171
174
|
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => new ContextViewport(tui, theme, breakdown, done));
|
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 } from "./context-budget.ts";
|
|
30
|
+
import { buildContextBreakdown, computeContextBudget, computeRuleBudget, estimateMessageHistoryTokens, type SessionBranchEntryLike } 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";
|
|
@@ -160,6 +160,11 @@ export default async function (pi: ExtensionAPI) {
|
|
|
160
160
|
let contextInjectionSequence = 0;
|
|
161
161
|
const contextInjectionProducerId = randomUUID();
|
|
162
162
|
let previousContextInjectionFingerprint: string | undefined;
|
|
163
|
+
// Cached from the most recent before_agent_start observation: Pi's own base system prompt
|
|
164
|
+
// is only ever visible transiently inside that hook's event.systemPrompt, so /context
|
|
165
|
+
// reuses the size buildContextInjection already computes every turn rather than going
|
|
166
|
+
// without it entirely.
|
|
167
|
+
let lastObservedBasePromptTokens: number | null = null;
|
|
163
168
|
const taskContinuation = new ActiveTaskContinuation({
|
|
164
169
|
maxTurns: TASK_DRIVER_MAX_TURNS,
|
|
165
170
|
maxUnchangedTurns: TASK_DRIVER_MAX_UNCHANGED_TURNS,
|
|
@@ -416,21 +421,30 @@ export default async function (pi: ExtensionAPI) {
|
|
|
416
421
|
handler: async (_args, ctx) => {
|
|
417
422
|
try {
|
|
418
423
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
419
|
-
const [rules,
|
|
424
|
+
const [rules, openTasks] = await Promise.all([
|
|
420
425
|
callService<Record<string, unknown>, Array<Pick<Artifact, "id" | "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd, session_id: sessionId }),
|
|
421
|
-
callService<Record<string, unknown>,
|
|
426
|
+
callService<Record<string, unknown>, Artifact[]>("tasks.list", { project_root: ctx.cwd, session_id: sessionId, limit: 200 }),
|
|
422
427
|
]);
|
|
423
428
|
const { skills } = computeContextBudget(rules, ctx.cwd);
|
|
424
429
|
const ruleBudget = computeRuleBudget(rules);
|
|
425
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) }));
|
|
426
438
|
const breakdown = buildContextBreakdown({
|
|
427
439
|
totalTokens: usage?.tokens ?? null,
|
|
428
440
|
contextWindow: ctx.model?.contextWindow ?? null,
|
|
429
441
|
ruleBudget,
|
|
430
|
-
|
|
442
|
+
taskItems,
|
|
431
443
|
skills,
|
|
444
|
+
basePromptEstimatedTokens: lastObservedBasePromptTokens,
|
|
445
|
+
messageHistoryEstimatedTokens: estimateMessageHistoryTokens(branch),
|
|
432
446
|
});
|
|
433
|
-
await showContextView(ctx, breakdown
|
|
447
|
+
await showContextView(ctx, breakdown);
|
|
434
448
|
} catch (error) {
|
|
435
449
|
ctx.ui.notify(`Context breakdown failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
436
450
|
}
|
|
@@ -502,6 +516,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
502
516
|
previousFingerprint: previousContextInjectionFingerprint,
|
|
503
517
|
});
|
|
504
518
|
previousContextInjectionFingerprint = injection.observation.fingerprint;
|
|
519
|
+
lastObservedBasePromptTokens = Math.ceil(injection.observation.before.characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
|
|
505
520
|
pi.events.emit(PAPYRUS_CONTEXT_INJECTION_CHANNEL, injection.observation);
|
|
506
521
|
if (injection.prompt !== (event.systemPrompt ?? "")) return { systemPrompt: injection.prompt };
|
|
507
522
|
} catch {
|
package/package.json
CHANGED