@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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
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"],