@danypops/papyrus 0.13.0 → 0.13.1
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.
|
@@ -71,13 +71,67 @@ export interface ContextSegmentItem {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
export interface ContextSegment {
|
|
74
|
-
key: "rules" | "tasks" | "skills" | "other";
|
|
74
|
+
key: "rules" | "tasks" | "skills" | "basePrompt" | "messageHistory" | "other";
|
|
75
75
|
label: string;
|
|
76
76
|
estimatedTokens: number;
|
|
77
77
|
/** Drill-down items, when this segment can be broken down further. Absent for "other" -- an opaque remainder, not a real category. */
|
|
78
78
|
items?: ContextSegmentItem[];
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Session branch entries as SessionManager exposes them (docs/session-format.md): a subset
|
|
83
|
+
* covering only the fields this estimate reads, so this stays testable with plain object
|
|
84
|
+
* literals instead of importing pi's own session types.
|
|
85
|
+
*/
|
|
86
|
+
export interface SessionBranchEntryLike {
|
|
87
|
+
type: string;
|
|
88
|
+
message?: unknown;
|
|
89
|
+
summary?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function messageContentCharacters(message: unknown): number {
|
|
93
|
+
if (typeof message !== "object" || message === null) return 0;
|
|
94
|
+
const record = message as Record<string, unknown>;
|
|
95
|
+
if (record["role"] === "bashExecution") {
|
|
96
|
+
// Pi's own context builder excludes "!!"-prefixed bash output from context; match that.
|
|
97
|
+
if (record["excludeFromContext"] === true) return 0;
|
|
98
|
+
return String(record["command"] ?? "").length + String(record["output"] ?? "").length;
|
|
99
|
+
}
|
|
100
|
+
const content = record["content"];
|
|
101
|
+
if (typeof content === "string") return content.length;
|
|
102
|
+
if (!Array.isArray(content)) return 0;
|
|
103
|
+
let characters = 0;
|
|
104
|
+
for (const block of content) {
|
|
105
|
+
if (typeof block !== "object" || block === null) continue;
|
|
106
|
+
const b = block as Record<string, unknown>;
|
|
107
|
+
if (b["type"] === "text") characters += String(b["text"] ?? "").length;
|
|
108
|
+
else if (b["type"] === "thinking") characters += String(b["thinking"] ?? "").length;
|
|
109
|
+
else if (b["type"] === "toolCall") characters += JSON.stringify(b["arguments"] ?? {}).length;
|
|
110
|
+
// "image" blocks are deliberately not counted here -- image tokens follow a different,
|
|
111
|
+
// non-character-based cost model this char/4 estimate cannot represent; this is a real,
|
|
112
|
+
// documented undercount for image-heavy sessions, not a silent approximation.
|
|
113
|
+
}
|
|
114
|
+
return characters;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Estimates the conversation transcript's own context contribution by walking the actual
|
|
119
|
+
* session branch (docs/session-format.md's buildSessionContext(): message/compaction/
|
|
120
|
+
* branch_summary entries participate in context, plain "custom" entries do not). This is
|
|
121
|
+
* character-count estimation like every other Papyrus segment here, not exact -- but it is
|
|
122
|
+
* real session content, not a guess, and in a long-running session this is very likely the
|
|
123
|
+
* dominant contributor to "the base prompt, message history, and tool definitions" bucket
|
|
124
|
+
* that would otherwise stay fully opaque.
|
|
125
|
+
*/
|
|
126
|
+
export function estimateMessageHistoryTokens(branch: ReadonlyArray<SessionBranchEntryLike>): number {
|
|
127
|
+
let characters = 0;
|
|
128
|
+
for (const entry of branch) {
|
|
129
|
+
if (entry.type === "message") characters += messageContentCharacters(entry.message);
|
|
130
|
+
else if (entry.type === "compaction" || entry.type === "branch_summary") characters += (entry.summary ?? "").length;
|
|
131
|
+
}
|
|
132
|
+
return Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
|
|
133
|
+
}
|
|
134
|
+
|
|
81
135
|
export interface ContextBreakdown {
|
|
82
136
|
/** Real usage from ctx.getContextUsage() -- ground truth, not estimated. Null only when Pi has no usage yet (e.g. before the first turn). */
|
|
83
137
|
totalTokens: number | null;
|
|
@@ -96,17 +150,22 @@ export interface BuildContextBreakdownInput {
|
|
|
96
150
|
ruleBudget: ContextBudget["rules"];
|
|
97
151
|
taskEstimatedTokens: number;
|
|
98
152
|
skills: SkillCatalogFootprint;
|
|
153
|
+
/** Pi's own base system prompt size, cached from the last observed before_agent_start turn. Null before any turn has run yet. */
|
|
154
|
+
basePromptEstimatedTokens: number | null;
|
|
155
|
+
/** From estimateMessageHistoryTokens() against the live session branch. */
|
|
156
|
+
messageHistoryEstimatedTokens: number;
|
|
99
157
|
}
|
|
100
158
|
|
|
101
159
|
/**
|
|
102
|
-
* Composes Papyrus
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
160
|
+
* Composes every segment Papyrus can actually measure or estimate (rules, tasks, skills
|
|
161
|
+
* catalog, cached base-prompt size, and the live session's own message history) against the
|
|
162
|
+
* real total Pi reports, deriving "unaccounted" (tool definitions and framework overhead --
|
|
163
|
+
* genuinely invisible to any extension) as the remainder. The remainder is clamped to zero
|
|
164
|
+
* rather than shown negative: char/4 token estimation is approximate, and a small overshoot
|
|
165
|
+
* in the known segments must not display as a nonsensical negative bucket. When the real
|
|
166
|
+
* total is unavailable, unaccounted is reported as zero and totalTokens surfaces as null so
|
|
167
|
+
* callers can label the whole breakdown as estimate-only rather than silently treating a
|
|
168
|
+
* partial sum as ground truth.
|
|
110
169
|
*/
|
|
111
170
|
export function buildContextBreakdown(input: BuildContextBreakdownInput): ContextBreakdown {
|
|
112
171
|
const reserveTokens = input.reserveTokens ?? DEFAULT_RESERVE_TOKENS;
|
|
@@ -123,17 +182,27 @@ export function buildContextBreakdown(input: BuildContextBreakdownInput): Contex
|
|
|
123
182
|
estimatedTokens: input.skills.totalEstimatedTokens,
|
|
124
183
|
items: input.skills.entries.map((entry) => ({ label: entry.name, estimatedTokens: entry.estimatedTokens })),
|
|
125
184
|
};
|
|
126
|
-
const
|
|
185
|
+
const basePrompt: ContextSegment = {
|
|
186
|
+
key: "basePrompt",
|
|
187
|
+
label: input.basePromptEstimatedTokens === null ? "Base system prompt (not observed yet)" : "Base system prompt (Pi + host instructions)",
|
|
188
|
+
estimatedTokens: input.basePromptEstimatedTokens ?? 0,
|
|
189
|
+
};
|
|
190
|
+
const messageHistory: ContextSegment = {
|
|
191
|
+
key: "messageHistory",
|
|
192
|
+
label: "Conversation message history",
|
|
193
|
+
estimatedTokens: input.messageHistoryEstimatedTokens,
|
|
194
|
+
};
|
|
195
|
+
const knownTokens = rules.estimatedTokens + tasks.estimatedTokens + skills.estimatedTokens + basePrompt.estimatedTokens + messageHistory.estimatedTokens;
|
|
127
196
|
const other: ContextSegment = {
|
|
128
197
|
key: "other",
|
|
129
|
-
label: "
|
|
198
|
+
label: "Unaccounted (tool definitions, framework overhead)",
|
|
130
199
|
estimatedTokens: input.totalTokens === null ? 0 : Math.max(0, input.totalTokens - knownTokens),
|
|
131
200
|
};
|
|
132
201
|
return {
|
|
133
202
|
totalTokens: input.totalTokens,
|
|
134
203
|
contextWindow: input.contextWindow,
|
|
135
204
|
effectiveBudget: input.contextWindow === null ? null : Math.max(0, input.contextWindow - reserveTokens),
|
|
136
|
-
segments: [rules, tasks, skills, other],
|
|
205
|
+
segments: [rules, tasks, skills, basePrompt, messageHistory, other],
|
|
137
206
|
};
|
|
138
207
|
}
|
|
139
208
|
|
|
@@ -9,6 +9,8 @@ const SEGMENT_COLORS: Record<ContextSegment["key"], ThemeColor> = {
|
|
|
9
9
|
rules: "accent",
|
|
10
10
|
tasks: "success",
|
|
11
11
|
skills: "mdLink",
|
|
12
|
+
basePrompt: "warning",
|
|
13
|
+
messageHistory: "syntaxFunction",
|
|
12
14
|
other: "muted",
|
|
13
15
|
};
|
|
14
16
|
|
|
@@ -148,16 +150,19 @@ class ContextViewport {
|
|
|
148
150
|
}
|
|
149
151
|
}
|
|
150
152
|
|
|
151
|
-
/** Non-interactive fallback (print mode, RPC, etc.)
|
|
153
|
+
/** Non-interactive fallback (print mode, RPC, etc.): every segment listed plainly, plus the existing per-rule/per-skill breakdown for the two segments that support drill-down. */
|
|
152
154
|
function fallbackReport(breakdown: ContextBreakdown, ruleBudget: ContextBudget["rules"]): string {
|
|
153
|
-
const skillsSegment = breakdown.segments.find((segment) => segment.key === "skills");
|
|
154
|
-
const other = breakdown.segments.find((segment) => segment.key === "other")!;
|
|
155
155
|
const totalLine = breakdown.totalTokens !== null
|
|
156
156
|
? `Real usage: ${breakdown.totalTokens} tokens${breakdown.effectiveBudget !== null ? ` / ${breakdown.effectiveBudget} usable budget (${percentOf(breakdown.totalTokens, breakdown.effectiveBudget)})` : ""}`
|
|
157
157
|
: "Real usage: not yet reported";
|
|
158
|
+
const denominator = breakdown.totalTokens ?? breakdown.segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
|
|
159
|
+
const segmentLines = breakdown.segments.map((segment) => ` ${segment.estimatedTokens.toString().padStart(7)} tok ${percentOf(segment.estimatedTokens, denominator).padStart(5)} ${segment.label}`);
|
|
160
|
+
const skillsSegment = breakdown.segments.find((segment) => segment.key === "skills");
|
|
158
161
|
return [
|
|
159
162
|
totalLine,
|
|
160
|
-
|
|
163
|
+
"",
|
|
164
|
+
"Segments:",
|
|
165
|
+
...segmentLines,
|
|
161
166
|
"",
|
|
162
167
|
formatContextBudgetReport({ rules: ruleBudget, skills: { entries: [], totalCharacters: 0, totalEstimatedTokens: skillsSegment?.estimatedTokens ?? 0, scannedDirectories: [] }, totalEstimatedTokens: ruleBudget.totalEstimatedTokens }),
|
|
163
168
|
].join("\n");
|
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,
|
|
@@ -423,12 +428,15 @@ export default async function (pi: ExtensionAPI) {
|
|
|
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[];
|
|
426
432
|
const breakdown = buildContextBreakdown({
|
|
427
433
|
totalTokens: usage?.tokens ?? null,
|
|
428
434
|
contextWindow: ctx.model?.contextWindow ?? null,
|
|
429
435
|
ruleBudget,
|
|
430
436
|
taskEstimatedTokens: taskSummary ? Math.ceil(taskSummary.length / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN) : 0,
|
|
431
437
|
skills,
|
|
438
|
+
basePromptEstimatedTokens: lastObservedBasePromptTokens,
|
|
439
|
+
messageHistoryEstimatedTokens: estimateMessageHistoryTokens(branch),
|
|
432
440
|
});
|
|
433
441
|
await showContextView(ctx, breakdown, ruleBudget);
|
|
434
442
|
} catch (error) {
|
|
@@ -502,6 +510,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
502
510
|
previousFingerprint: previousContextInjectionFingerprint,
|
|
503
511
|
});
|
|
504
512
|
previousContextInjectionFingerprint = injection.observation.fingerprint;
|
|
513
|
+
lastObservedBasePromptTokens = Math.ceil(injection.observation.before.characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
|
|
505
514
|
pi.events.emit(PAPYRUS_CONTEXT_INJECTION_CHANNEL, injection.observation);
|
|
506
515
|
if (injection.prompt !== (event.systemPrompt ?? "")) return { systemPrompt: injection.prompt };
|
|
507
516
|
} catch {
|
package/package.json
CHANGED