@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's own estimated segments (rules/tasks/skills) against the real total Pi
103
- * reports, deriving "everything else" (base system prompt, message history, tool definitions
104
- * -- none of which Papyrus can see or estimate) as the remainder. The remainder is clamped to
105
- * zero rather than shown negative: char/4 token estimation is approximate, and a small
106
- * overshoot in the known segments must not display as a nonsensical negative "other" bucket.
107
- * When the real total is unavailable, "other" is reported as zero and totalTokens surfaces as
108
- * null so callers can label the whole breakdown as estimate-only rather than silently treating
109
- * a partial sum as ground truth.
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 knownTokens = rules.estimatedTokens + tasks.estimatedTokens + skills.estimatedTokens;
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: "Everything else (base prompt, message history, tool definitions)",
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.) reuses the existing plain-text report shape. */
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
- `Everything else (base prompt, message history, tool defs): ~${other.estimatedTokens} tokens`,
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");
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
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"],