@danypops/papyrus 0.15.1 → 0.16.0
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.
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { BuildSystemPromptOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN } from "../../src/constants.ts";
|
|
3
|
+
import type { ContextSegmentItem } from "./context-budget.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Splits Pi's base system prompt into real structural sub-segments instead of one opaque
|
|
7
|
+
* number, using BeforeAgentStartEvent's own systemPromptOptions field -- Pi's own doc comment
|
|
8
|
+
* on it: "Extensions can inspect this to understand what Pi loaded without re-discovering
|
|
9
|
+
* resources." No new hook, no new risk: before_agent_start is already wired.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately measures each INPUT's raw content size (tool snippet text, skill metadata,
|
|
12
|
+
* context file content) rather than attempting to byte-for-byte reproduce Pi's internal
|
|
13
|
+
* wrapping/tag format -- buildSystemPrompt() and formatSkillsForPrompt() are Pi-internal
|
|
14
|
+
* functions, not part of the public extension API Papyrus can call, so reproducing their
|
|
15
|
+
* exact template text here would be a real, silent drift risk if Pi ever changes it. The
|
|
16
|
+
* remainder item absorbs whatever wrapping/template text this doesn't attribute, so the
|
|
17
|
+
* segment's total always still matches the real observed prompt length exactly -- honesty
|
|
18
|
+
* preserved even though individual sub-segment sizes are approximate, matching the same
|
|
19
|
+
* known-segments-plus-honest-remainder pattern used everywhere else in this breakdown.
|
|
20
|
+
*/
|
|
21
|
+
export function buildBasePromptItems(options: BuildSystemPromptOptions, totalCharacters: number): ContextSegmentItem[] {
|
|
22
|
+
const items: ContextSegmentItem[] = [];
|
|
23
|
+
|
|
24
|
+
const toolSnippetEntries = Object.entries(options.toolSnippets ?? {});
|
|
25
|
+
// Mirrors buildSystemPrompt()'s own "- name: snippet\n" line shape closely enough to be a
|
|
26
|
+
// fair estimate without importing Pi-internal formatting code.
|
|
27
|
+
const toolSnippetsCharacters = toolSnippetEntries.reduce((sum, [name, snippet]) => sum + name.length + snippet.length + 4, 0);
|
|
28
|
+
if (toolSnippetsCharacters > 0) {
|
|
29
|
+
items.push({ label: `Tool snippets (${toolSnippetEntries.length} tools)`, estimatedTokens: toCeilTokens(toolSnippetsCharacters) });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const visibleSkills = (options.skills ?? []).filter((skill) => !skill.disableModelInvocation);
|
|
33
|
+
const skillsCharacters = visibleSkills.reduce((sum, skill) => sum + skill.name.length + skill.description.length + skill.filePath.length + 20, 0);
|
|
34
|
+
if (skillsCharacters > 0) {
|
|
35
|
+
items.push({ label: `Skills catalog (${visibleSkills.length} skills)`, estimatedTokens: toCeilTokens(skillsCharacters) });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const contextFiles = options.contextFiles ?? [];
|
|
39
|
+
const contextFilesCharacters = contextFiles.reduce((sum, file) => sum + file.path.length + file.content.length + 40, 0);
|
|
40
|
+
if (contextFilesCharacters > 0) {
|
|
41
|
+
items.push({ label: `Project context files (${contextFiles.length}, e.g. AGENTS.md)`, estimatedTokens: toCeilTokens(contextFilesCharacters) });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const knownCharacters = toolSnippetsCharacters + skillsCharacters + contextFilesCharacters;
|
|
45
|
+
const remainderCharacters = Math.max(0, totalCharacters - knownCharacters);
|
|
46
|
+
if (remainderCharacters > 0 || items.length === 0) {
|
|
47
|
+
items.push({ label: "Base template, guidelines, and formatting", estimatedTokens: toCeilTokens(remainderCharacters) });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return items;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toCeilTokens(characters: number): number {
|
|
54
|
+
return Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
|
|
55
|
+
}
|
|
@@ -295,6 +295,8 @@ export interface BuildContextBreakdownInput {
|
|
|
295
295
|
skills: SkillCatalogFootprint;
|
|
296
296
|
/** Pi's own base system prompt size, cached from the last observed before_agent_start turn. Null before any turn has run yet. */
|
|
297
297
|
basePromptEstimatedTokens: number | null;
|
|
298
|
+
/** Structural sub-breakdown (tool snippets, Skills, context files, template remainder) from the same cached observation, built by buildBasePromptItems(). Empty when basePromptEstimatedTokens is null. */
|
|
299
|
+
basePromptItems?: ContextSegmentItem[];
|
|
298
300
|
/** From buildMessageHistoryTree() against the live session's real tree (ctx.sessionManager.getTree()). */
|
|
299
301
|
messageHistoryItems: ContextSegmentItem[];
|
|
300
302
|
/** 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. */
|
|
@@ -416,6 +418,7 @@ export function buildContextBreakdown(input: BuildContextBreakdownInput): Contex
|
|
|
416
418
|
label: input.basePromptEstimatedTokens === null ? "Base system prompt (not observed yet)" : "Base system prompt (Pi + host instructions)",
|
|
417
419
|
estimatedTokens: input.basePromptEstimatedTokens ?? 0,
|
|
418
420
|
...(input.basePromptEstimatedTokens === null ? { unknown: true } : {}),
|
|
421
|
+
...(input.basePromptItems && input.basePromptItems.length > 0 ? { items: input.basePromptItems } : {}),
|
|
419
422
|
};
|
|
420
423
|
const messageHistory: ContextSegment = {
|
|
421
424
|
key: "messageHistory",
|
|
@@ -13,19 +13,6 @@ 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
|
-
|
|
29
16
|
/**
|
|
30
17
|
* One row in the unified scrollable view. Every segment that has any real (nonzero) content
|
|
31
18
|
* is fully expanded inline -- there is no separate "select a segment, then drill in" step.
|
|
@@ -121,12 +108,6 @@ class ContextViewport {
|
|
|
121
108
|
if (this.breakdown.overshootTokens > 0) {
|
|
122
109
|
lines.push(truncateToWidth(theme.fg("warning", `Estimates exceed real total by ~${this.breakdown.overshootTokens} tok — sizes below are approximate, not exact`), contentWidth, ""));
|
|
123
110
|
}
|
|
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
|
-
}
|
|
130
111
|
lines.push("");
|
|
131
112
|
|
|
132
113
|
this.visibleWindow().forEach(({ row, index }) => {
|
|
@@ -220,36 +201,6 @@ export function renderContextBar(theme: Theme, segments: ReadonlyArray<ContextSe
|
|
|
220
201
|
return output;
|
|
221
202
|
}
|
|
222
203
|
|
|
223
|
-
/**
|
|
224
|
-
* Renders the "used" portion's own composition as a small vertical bar chart, one column per
|
|
225
|
-
* segment with real content, scaled so the largest segment fills the full height -- the
|
|
226
|
-
* "deep dive" graph, complementing the horizontal used-vs-unused bar above it. Any segment
|
|
227
|
-
* with real (nonzero) tokens gets at least one filled row so it stays visible even next to a
|
|
228
|
-
* much larger segment. Returns an empty array (nothing to render) when no segment has any
|
|
229
|
-
* tokens yet, matching the same zero-noise principle as the row list below it.
|
|
230
|
-
*/
|
|
231
|
-
export function renderContextVerticalBars(theme: Theme, segments: ReadonlyArray<ContextSegment>): string[] {
|
|
232
|
-
const visible = segments.filter((segment) => segment.estimatedTokens > 0);
|
|
233
|
-
if (visible.length === 0) return [];
|
|
234
|
-
const max = Math.max(...visible.map((segment) => segment.estimatedTokens));
|
|
235
|
-
const filledRows = new Map(visible.map((segment) => [segment.key, Math.max(1, Math.round((segment.estimatedTokens / max) * VERTICAL_BAR_HEIGHT))]));
|
|
236
|
-
|
|
237
|
-
const lines: string[] = [];
|
|
238
|
-
for (let row = 0; row < VERTICAL_BAR_HEIGHT; row++) {
|
|
239
|
-
const rowsFromBottom = VERTICAL_BAR_HEIGHT - row;
|
|
240
|
-
let line = "";
|
|
241
|
-
for (const segment of visible) {
|
|
242
|
-
const filled = (filledRows.get(segment.key) ?? 0) >= rowsFromBottom;
|
|
243
|
-
line += `${filled ? theme.fg(SEGMENT_COLORS[segment.key], "█".repeat(VERTICAL_BAR_WIDTH)) : " ".repeat(VERTICAL_BAR_WIDTH)} `;
|
|
244
|
-
}
|
|
245
|
-
lines.push(line);
|
|
246
|
-
}
|
|
247
|
-
let legend = "";
|
|
248
|
-
for (const segment of visible) legend += `${theme.fg(SEGMENT_COLORS[segment.key], SEGMENT_SHORT_LABELS[segment.key])} `;
|
|
249
|
-
lines.push(legend);
|
|
250
|
-
return lines;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
204
|
/** Non-interactive fallback (print mode, RPC, etc.): the same unified row list, as plain text lines. */
|
|
254
205
|
function fallbackReport(breakdown: ContextBreakdown): string {
|
|
255
206
|
const totalLine = breakdown.totalTokens !== null
|
package/extension/src/index.ts
CHANGED
|
@@ -27,7 +27,8 @@ 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, buildMessageHistoryTree, buildTaskItemTree, computeContextBudget, computeRuleBudget, DEFAULT_RESERVE_TOKENS, type SessionEntryLike, type SessionTreeNodeLike } from "./context-budget.ts";
|
|
30
|
+
import { buildContextBreakdown, buildMessageHistoryTree, buildTaskItemTree, computeContextBudget, computeRuleBudget, DEFAULT_RESERVE_TOKENS, type ContextSegmentItem, type SessionEntryLike, type SessionTreeNodeLike } from "./context-budget.ts";
|
|
31
|
+
import { buildBasePromptItems } from "./base-prompt-breakdown.ts";
|
|
31
32
|
import { showContextView } from "./context-view.ts";
|
|
32
33
|
import { emitTaskFocusEvent, setTaskFocusEventBus } from "./task-focus-events.ts";
|
|
33
34
|
import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
|
|
@@ -164,8 +165,12 @@ export default async function (pi: ExtensionAPI) {
|
|
|
164
165
|
// Cached from the most recent before_agent_start observation: Pi's own base system prompt
|
|
165
166
|
// is only ever visible transiently inside that hook's event.systemPrompt, so /context
|
|
166
167
|
// reuses the size buildContextInjection already computes every turn rather than going
|
|
167
|
-
// without it entirely.
|
|
168
|
+
// without it entirely. basePromptItems is the structural sub-breakdown built from the same
|
|
169
|
+
// event's systemPromptOptions field ("Extensions can inspect this to understand what Pi
|
|
170
|
+
// loaded without re-discovering resources", per Pi's own doc comment) -- no new hook, no new
|
|
171
|
+
// risk, just reading a field before_agent_start already hands over.
|
|
168
172
|
let lastObservedBasePromptTokens: number | null = null;
|
|
173
|
+
let lastObservedBasePromptItems: ContextSegmentItem[] = [];
|
|
169
174
|
const taskContinuation = new ActiveTaskContinuation({
|
|
170
175
|
maxTurns: TASK_DRIVER_MAX_TURNS,
|
|
171
176
|
maxUnchangedTurns: TASK_DRIVER_MAX_UNCHANGED_TURNS,
|
|
@@ -480,6 +485,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
480
485
|
taskItems: buildTaskItemTree(taskGraph),
|
|
481
486
|
skills,
|
|
482
487
|
basePromptEstimatedTokens: lastObservedBasePromptTokens,
|
|
488
|
+
basePromptItems: lastObservedBasePromptItems,
|
|
483
489
|
messageHistoryItems: messageHistory.items,
|
|
484
490
|
messageHistoryActiveTokens: messageHistory.activeTokens,
|
|
485
491
|
});
|
|
@@ -559,6 +565,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
559
565
|
});
|
|
560
566
|
previousContextInjectionFingerprint = injection.observation.fingerprint;
|
|
561
567
|
lastObservedBasePromptTokens = Math.ceil(injection.observation.before.characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
|
|
568
|
+
lastObservedBasePromptItems = buildBasePromptItems(event.systemPromptOptions, injection.observation.before.characters);
|
|
562
569
|
pi.events.emit(PAPYRUS_CONTEXT_INJECTION_CHANNEL, injection.observation);
|
|
563
570
|
if (injection.prompt !== (event.systemPrompt ?? "")) return { systemPrompt: injection.prompt };
|
|
564
571
|
} catch {
|
package/package.json
CHANGED