@danypops/papyrus 0.34.2 → 0.35.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.
Files changed (47) hide show
  1. package/README.md +5 -189
  2. package/package.json +8 -16
  3. package/src/artifact-relationship-view.ts +23 -0
  4. package/src/cli.ts +0 -0
  5. package/src/index.ts +32 -0
  6. package/src/task-relationship-view.ts +2 -1
  7. package/extension/src/active-task-continuation.ts +0 -131
  8. package/extension/src/artifact-browser.ts +0 -229
  9. package/extension/src/artifact-detail-format.ts +0 -31
  10. package/extension/src/artifact-detail-view.ts +0 -112
  11. package/extension/src/artifact-format.ts +0 -84
  12. package/extension/src/artifact-status-presentation.ts +0 -71
  13. package/extension/src/base-prompt-breakdown.ts +0 -55
  14. package/extension/src/beautiful-mermaid-renderer.ts +0 -68
  15. package/extension/src/bounded-poll.ts +0 -20
  16. package/extension/src/context-budget.ts +0 -503
  17. package/extension/src/context-injection-telemetry.ts +0 -88
  18. package/extension/src/context-view.ts +0 -222
  19. package/extension/src/discuss-ask-layout.ts +0 -193
  20. package/extension/src/discuss-ask-view.ts +0 -1301
  21. package/extension/src/discuss.ts +0 -134
  22. package/extension/src/discussion-detail-view.ts +0 -136
  23. package/extension/src/docs.ts +0 -58
  24. package/extension/src/domain-tools.ts +0 -886
  25. package/extension/src/index.ts +0 -776
  26. package/extension/src/markdown.ts +0 -60
  27. package/extension/src/note-widget.ts +0 -8
  28. package/extension/src/notes.ts +0 -102
  29. package/extension/src/playbook-bridge.ts +0 -91
  30. package/extension/src/playbooks.ts +0 -97
  31. package/extension/src/rules.ts +0 -51
  32. package/extension/src/service-client.ts +0 -29
  33. package/extension/src/session-identity.ts +0 -22
  34. package/extension/src/skill-catalog-footprint.ts +0 -183
  35. package/extension/src/skills.ts +0 -127
  36. package/extension/src/task-context.ts +0 -1
  37. package/extension/src/task-detail-format.ts +0 -110
  38. package/extension/src/task-detail-view.ts +0 -139
  39. package/extension/src/task-focus-events.ts +0 -57
  40. package/extension/src/task-graph.ts +0 -116
  41. package/extension/src/task-presentation.ts +0 -26
  42. package/extension/src/task-widget.ts +0 -70
  43. package/extension/src/tasks.ts +0 -418
  44. package/extension/src/tool-rendering/artifact-card.ts +0 -117
  45. package/extension/src/tool-rendering/artifact-list.ts +0 -179
  46. package/extension/src/tool-rendering/index.ts +0 -109
  47. package/extension/src/tool-rendering/render-model.ts +0 -410
@@ -1,503 +0,0 @@
1
- import { homedir } from "node:os";
2
- import { readFileSync } from "node:fs";
3
- import { CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN, CONTEXT_TREE_MAX_NODES } from "../../src/constants.ts";
4
- import type { Artifact } from "../../src/domain/artifact.ts";
5
- import type { TaskGraph } from "../../src/task-service.ts";
6
- import { discoverSkillDirectories, scanSkillCatalogFootprint, type SkillCatalogFootprint } from "./skill-catalog-footprint.ts";
7
- import { ruleInjectionPreview } from "./rules.ts";
8
-
9
- export interface RuleBudgetEntry {
10
- id: string;
11
- title: string;
12
- characters: number;
13
- estimatedTokens: number;
14
- }
15
-
16
- export interface ContextBudget {
17
- rules: {
18
- entries: RuleBudgetEntry[]; // sorted descending by characters
19
- totalCharacters: number;
20
- totalEstimatedTokens: number;
21
- };
22
- skills: SkillCatalogFootprint;
23
- totalEstimatedTokens: number;
24
- }
25
-
26
- /** Active Rules are injected into every relevant turn -- the same permanent tax role as a Pi-native skill's catalog entry. */
27
- export function computeRuleBudget(rules: ReadonlyArray<Pick<Artifact, "id" | "title" | "body" | "extra">>): ContextBudget["rules"] {
28
- const entries = rules
29
- .map((rule) => {
30
- const characters = ruleInjectionPreview(rule).length;
31
- return { id: rule.id, title: rule.title, characters, estimatedTokens: Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN) };
32
- })
33
- .sort((a, b) => b.characters - a.characters);
34
- return {
35
- entries,
36
- totalCharacters: entries.reduce((sum, entry) => sum + entry.characters, 0),
37
- totalEstimatedTokens: entries.reduce((sum, entry) => sum + entry.estimatedTokens, 0),
38
- };
39
- }
40
-
41
- /** Best-effort: a missing, unreadable, or malformed settings.json contributes no extra skill directories rather than failing the whole report. */
42
- function readSettingsSkillPaths(settingsPath: string): string[] {
43
- try {
44
- const raw = JSON.parse(readFileSync(settingsPath, "utf8")) as { skills?: unknown };
45
- if (!Array.isArray(raw.skills)) return [];
46
- return raw.skills.filter((entry): entry is string => typeof entry === "string");
47
- } catch {
48
- return [];
49
- }
50
- }
51
-
52
- export function computeContextBudget(
53
- rules: ReadonlyArray<Pick<Artifact, "id" | "title" | "body" | "extra">>,
54
- cwd: string,
55
- homeDirectory: string = homedir(),
56
- ): ContextBudget {
57
- const settingsSkills = readSettingsSkillPaths(`${homeDirectory}/.pi/agent/settings.json`);
58
- const directories = discoverSkillDirectories(homeDirectory, cwd, settingsSkills);
59
- const skills = scanSkillCatalogFootprint(directories);
60
- const ruleBudget = computeRuleBudget(rules);
61
- return { rules: ruleBudget, skills, totalEstimatedTokens: ruleBudget.totalEstimatedTokens + skills.totalEstimatedTokens };
62
- }
63
-
64
- /** Pi's own documented compaction-reserve default (docs/compaction.md): headroom kept free for the model's response. */
65
- export const DEFAULT_RESERVE_TOKENS = 16_384;
66
-
67
- export interface ContextSegmentItem {
68
- label: string;
69
- estimatedTokens: number;
70
- /**
71
- * Recursive children, when this item has real hierarchy of its own -- conversation history
72
- * (Pi's session entries form a genuine tree via id/parentId, docs/session-format.md) and
73
- * Papyrus Tasks (containment via parentIds/childIds) both do; Rules and Skills don't, so
74
- * their items simply omit this field, degenerating to a flat one-level tree.
75
- */
76
- children?: ContextSegmentItem[];
77
- }
78
-
79
- export interface ContextSegment {
80
- key: "rules" | "tasks" | "skills" | "basePrompt" | "messageHistory" | "toolDefinitions" | "other";
81
- label: string;
82
- estimatedTokens: number;
83
- /** Drill-down items, when this segment can be broken down further. Absent for "other" -- an opaque remainder, not a real category. */
84
- items?: ContextSegmentItem[];
85
- /**
86
- * True when this segment's size is genuinely unmeasured (not yet observed), as opposed to
87
- * measured-and-actually-zero. A display layer that hides zero-token rows to cut noise must
88
- * NOT hide an unknown segment just because its placeholder value happens to be zero --
89
- * that would silently misrepresent "we don't know" as "there is nothing here", the same
90
- * category of honesty problem overshootTokens exists to prevent for the unaccounted bucket.
91
- */
92
- unknown?: boolean;
93
- }
94
-
95
- /**
96
- * Session entries and tree nodes as SessionManager exposes them (docs/session-format.md,
97
- * SessionTreeNode from @earendil-works/pi-coding-agent): a subset covering only the fields
98
- * this estimate reads, so this stays testable with plain object literals instead of
99
- * importing pi's own session types.
100
- */
101
- export interface SessionEntryLike {
102
- id: string;
103
- type: string;
104
- message?: unknown;
105
- summary?: string;
106
- }
107
- export interface SessionTreeNodeLike {
108
- entry: SessionEntryLike;
109
- children: SessionTreeNodeLike[];
110
- }
111
-
112
- function messageContentCharacters(message: unknown): number {
113
- if (typeof message !== "object" || message === null) return 0;
114
- const record = message as Record<string, unknown>;
115
- if (record["role"] === "bashExecution") {
116
- // Pi's own context builder excludes "!!"-prefixed bash output from context; match that.
117
- if (record["excludeFromContext"] === true) return 0;
118
- return String(record["command"] ?? "").length + String(record["output"] ?? "").length;
119
- }
120
- const content = record["content"];
121
- if (typeof content === "string") return content.length;
122
- if (!Array.isArray(content)) return 0;
123
- let characters = 0;
124
- for (const block of content) {
125
- if (typeof block !== "object" || block === null) continue;
126
- const b = block as Record<string, unknown>;
127
- if (b["type"] === "text") characters += String(b["text"] ?? "").length;
128
- else if (b["type"] === "thinking") characters += String(b["thinking"] ?? "").length;
129
- else if (b["type"] === "toolCall") characters += JSON.stringify(b["arguments"] ?? {}).length;
130
- // "image" blocks are deliberately not counted here -- image tokens follow a different,
131
- // non-character-based cost model this char/4 estimate cannot represent; this is a real,
132
- // documented undercount for image-heavy sessions, not a silent approximation.
133
- }
134
- return characters;
135
- }
136
-
137
- function messageSnippet(message: unknown, maxLength = 48): string {
138
- if (typeof message !== "object" || message === null) return "";
139
- const record = message as Record<string, unknown>;
140
- if (record["role"] === "bashExecution") return String(record["command"] ?? "");
141
- const content = record["content"];
142
- const text = typeof content === "string"
143
- ? content
144
- : Array.isArray(content)
145
- ? content.map((block) => (typeof block === "object" && block !== null && (block as Record<string, unknown>)["type"] === "text" ? String((block as Record<string, unknown>)["text"] ?? "") : "")).join(" ")
146
- : "";
147
- const collapsed = text.replace(/\s+/g, " ").trim();
148
- return collapsed.length > maxLength ? `${collapsed.slice(0, maxLength - 1)}…` : collapsed;
149
- }
150
-
151
- function entryLabel(entry: SessionEntryLike): string {
152
- if (entry.type === "compaction") return "compaction summary";
153
- if (entry.type === "branch_summary") return "branch summary";
154
- const role = typeof entry.message === "object" && entry.message !== null ? (entry.message as Record<string, unknown>)["role"] : undefined;
155
- const prefix = typeof role === "string" ? role : entry.type;
156
- const snippet = messageSnippet(entry.message);
157
- return snippet ? `${prefix}: ${snippet}` : prefix;
158
- }
159
-
160
- export interface MessageHistoryTree {
161
- /** One item per real tree root (ordinarily one, the session's first entry). */
162
- items: ContextSegmentItem[];
163
- /** Sum of tokens for entries on the CURRENT active path only -- what actually feeds the LLM's context right now, unlike content sitting in an abandoned /tree branch. */
164
- activeTokens: number;
165
- /** True if the walk hit CONTEXT_TREE_MAX_NODES or found a cycle -- the tree shown is a bounded prefix, not necessarily the complete session. */
166
- truncated: boolean;
167
- }
168
-
169
- /**
170
- * Walks Pi's own real session tree (ctx.sessionManager.getTree(), docs/session-format.md --
171
- * entries form a genuine tree via id/parentId, not just the linear current-branch path) to
172
- * estimate the conversation's context contribution AND surface branches explored via /tree
173
- * that are no longer on the active path -- content that cost real tokens to generate but is
174
- * NOT currently part of the context window. Bounded and cycle-safe (CONTEXT_TREE_MAX_NODES):
175
- * a session file is external, mutable state, and this deliberately hardens past a confirmed
176
- * real gap in Pi's own getBranch() (no cycle guard at all) rather than assuming the tree can
177
- * never be malformed.
178
- *
179
- * `activeEntryIds` MUST come from ctx.sessionManager.buildContextEntries(), not getBranch().
180
- * getBranch()'s own docstring says it "[i]ncludes all entry types... Use buildSessionContext()
181
- * to get the resolved messages for the LLM" -- it does not skip entries a real compaction has
182
- * already summarized away. A real session with 3 compactions confirmed using getBranch() here
183
- * overcounts activeTokens by over 13x, since every pre-compaction message still reads as
184
- * "active". buildContextEntries() is Pi's own compaction-aware entry list: the latest
185
- * compaction entry, its kept entries from firstKeptEntryId onward, and everything after.
186
- *
187
- * `branchEntryIds` (optional) is the full raw current-path id set (getBranch()'s own output).
188
- * When given, an entry on the branch path but excluded from activeEntryIds is labeled
189
- * "(compacted)" rather than the less accurate "(inactive branch)", which is reserved for
190
- * entries not on the current path at all (a genuinely abandoned /tree branch). Omitting it
191
- * preserves the simpler binary active/inactive-branch labeling for callers that only have one
192
- * set to give (e.g. tests).
193
- */
194
- interface WalkFrame {
195
- node: SessionTreeNodeLike;
196
- parentIndex: number | null;
197
- }
198
-
199
- /**
200
- * Iterative (not recursive) two-pass walk: an explicit-stack pre-order discovery pass
201
- * followed by a reverse-order (children-before-parent) construction pass. A real, ordinary
202
- * (non-branching) long-running session is one long linear chain, so recursion depth would
203
- * equal entry count -- a session observed in production with 6,924 entries on its own active
204
- * branch confirmed this is not a hypothetical concern; a naive recursive walk risks a real
205
- * JavaScript call-stack overflow at that scale, independent of the CONTEXT_TREE_MAX_NODES
206
- * bound entirely.
207
- */
208
- export function buildMessageHistoryTree(roots: ReadonlyArray<SessionTreeNodeLike>, activeEntryIds: ReadonlySet<string>, branchEntryIds?: ReadonlySet<string>): MessageHistoryTree {
209
- const visited = new Set<string>();
210
- let truncated = false;
211
- let activeTokens = 0;
212
-
213
- const order: WalkFrame[] = [];
214
- const stack: WalkFrame[] = [...roots].reverse().map((root) => ({ node: root, parentIndex: null }));
215
- while (stack.length > 0) {
216
- const frame = stack.pop()!;
217
- if (order.length >= CONTEXT_TREE_MAX_NODES) { truncated = true; break; }
218
- if (visited.has(frame.node.entry.id)) { truncated = true; continue; } // cycle guard
219
- visited.add(frame.node.entry.id);
220
- const index = order.length;
221
- order.push(frame);
222
- const children = [...frame.node.children].reverse().map((child) => ({ node: child, parentIndex: index }));
223
- stack.push(...children);
224
- }
225
- if (stack.length > 0) truncated = true; // node bound hit with more work still queued
226
-
227
- const childItemsByParent = new Map<number, ContextSegmentItem[]>();
228
- const itemByIndex = new Map<number, ContextSegmentItem>();
229
- for (let index = order.length - 1; index >= 0; index--) {
230
- const frame = order[index]!;
231
- const entry = frame.node.entry;
232
- const characters = entry.type === "message"
233
- ? messageContentCharacters(entry.message)
234
- : entry.type === "compaction" || entry.type === "branch_summary"
235
- ? (entry.summary ?? "").length
236
- : 0;
237
- const tokens = Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
238
- const isActive = activeEntryIds.has(entry.id);
239
- if (isActive) activeTokens += tokens;
240
- const isOnBranch = branchEntryIds ? branchEntryIds.has(entry.id) : isActive; // no branch set given -- fall back to the old binary active/inactive-branch label
241
-
242
- const children = childItemsByParent.get(index) ?? [];
243
- if (tokens === 0 && children.length === 0) continue; // no content, no descendants with content -- nothing to show
244
-
245
- const item: ContextSegmentItem = {
246
- label: isActive ? entryLabel(entry) : isOnBranch ? `${entryLabel(entry)} (compacted)` : `${entryLabel(entry)} (inactive branch)`,
247
- estimatedTokens: tokens,
248
- ...(children.length > 0 ? { children } : {}),
249
- };
250
- itemByIndex.set(index, item);
251
- if (frame.parentIndex !== null) {
252
- const siblings = childItemsByParent.get(frame.parentIndex) ?? [];
253
- siblings.unshift(item); // reverse-order processing -- unshift restores original document order
254
- childItemsByParent.set(frame.parentIndex, siblings);
255
- }
256
- }
257
-
258
- const items: ContextSegmentItem[] = [];
259
- for (let index = 0; index < order.length; index++) {
260
- if (order[index]!.parentIndex === null) {
261
- const item = itemByIndex.get(index);
262
- if (item) items.push(item);
263
- }
264
- }
265
- return { items, activeTokens, truncated };
266
- }
267
-
268
- export interface ContextBreakdown {
269
- /** Real usage from ctx.getContextUsage() -- ground truth, not estimated. Null only when Pi has no usage yet (e.g. before the first turn). */
270
- totalTokens: number | null;
271
- /** From ctx.model.contextWindow. Null when the active model's context window is unknown. */
272
- contextWindow: number | null;
273
- /** contextWindow - reserveTokens, mirroring Pi's own compaction-trigger formula. Null when contextWindow is unknown. */
274
- effectiveBudget: number | null;
275
- /**
276
- * How much the known/estimated segments (rules+tasks+skills+basePrompt+messageHistory+
277
- * toolDefinitions) exceed the real total, when they do. Zero means no overshoot. This must
278
- * stay visible rather than only being absorbed into "unaccounted" clamping to zero -- a
279
- * clamped-to-zero unaccounted segment does NOT mean wire-protocol overhead is actually free;
280
- * it means this estimate's other segments already consumed the entire real budget on paper.
281
- * Hiding that distinction would make a genuinely nonzero cost look like zero.
282
- */
283
- overshootTokens: number;
284
- /** rules, tasks, skills, basePrompt, messageHistory, toolDefinitions, then "other" absorbing whatever real usage the rest don't account for. */
285
- segments: ContextSegment[];
286
- }
287
-
288
- export interface BuildContextBreakdownInput {
289
- totalTokens: number | null;
290
- contextWindow: number | null;
291
- reserveTokens?: number;
292
- ruleBudget: ContextBudget["rules"];
293
- /** Open tasks contributing to the injected task-context summary, nested by containment (parentIds/childIds) so the Tasks segment reflects the real Task tree, not a flat list. */
294
- taskItems: ContextSegmentItem[];
295
- skills: SkillCatalogFootprint;
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
- 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[];
300
- /** From buildMessageHistoryTree() against the live session's real tree (ctx.sessionManager.getTree()). */
301
- messageHistoryItems: ContextSegmentItem[];
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. */
303
- messageHistoryActiveTokens: number;
304
- /** From buildToolDefinitionItems() against pi.getAllTools() filtered to pi.getActiveTools(). Defaults to empty when omitted. */
305
- toolDefinitionItems?: ContextSegmentItem[];
306
- }
307
-
308
- /** Sums a possibly-nested item tree's tokens recursively -- every node's own contribution, not just top-level items. */
309
- function sumItemTree(items: ContextSegmentItem[]): number {
310
- return items.reduce((sum, item) => sum + item.estimatedTokens + sumItemTree(item.children ?? []), 0);
311
- }
312
-
313
- /** The subset of pi.getAllTools()'s ToolInfo this estimate actually reads -- kept minimal so this stays testable with plain object literals instead of importing Pi's own extension types. */
314
- export interface ActiveToolDefinitionLike {
315
- name: string;
316
- description: string;
317
- parameters: unknown;
318
- sourceInfo: { source: string };
319
- }
320
-
321
- /**
322
- * Tool definitions (name + description + JSON schema) are actually measurable, unlike genuine
323
- * wire-protocol framework overhead (message envelope/role wrapping, cache-control markers) which
324
- * really is invisible to any extension -- this is what lets "other" stop absorbing them as an
325
- * unmeasured guess. Grouped by extension/package source with each tool as a drill-down child
326
- * (mirrors the Tasks segment's own parent/child shape) rather than one flat list, since a real
327
- * session can have dozens of active tools spread across many extensions.
328
- */
329
- export function buildToolDefinitionItems(tools: ReadonlyArray<ActiveToolDefinitionLike>): ContextSegmentItem[] {
330
- const bySource = new Map<string, ActiveToolDefinitionLike[]>();
331
- for (const tool of tools) {
332
- const list = bySource.get(tool.sourceInfo.source) ?? [];
333
- list.push(tool);
334
- bySource.set(tool.sourceInfo.source, list);
335
- }
336
- const items: ContextSegmentItem[] = [];
337
- for (const [source, toolsForSource] of bySource) {
338
- const children = toolsForSource
339
- .map((tool) => {
340
- const characters = tool.name.length + tool.description.length + JSON.stringify(tool.parameters ?? {}).length;
341
- return { label: tool.name, estimatedTokens: Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN) };
342
- })
343
- .sort((a, b) => b.estimatedTokens - a.estimatedTokens);
344
- items.push({
345
- label: `${source} (${toolsForSource.length} tool${toolsForSource.length === 1 ? "" : "s"})`,
346
- estimatedTokens: children.reduce((sum, child) => sum + child.estimatedTokens, 0),
347
- children,
348
- });
349
- }
350
- return items.sort((a, b) => b.estimatedTokens - a.estimatedTokens);
351
- }
352
-
353
- /**
354
- * Builds the Tasks segment's items from Papyrus's own real containment tree (parentIds/
355
- * childIds), not a flat list -- Tasks are a genuine DAG (a task may have more than one
356
- * parent, a deliberate design decision, not a defect: see /tasks contain). Open tasks only
357
- * (done/canceled tasks are filtered first, matching taskContext()'s own "only open work
358
- * matters" rule); a task whose real parent is done/canceled or otherwise filtered out
359
- * becomes a root in THIS projection rather than being silently dropped. A task reachable
360
- * from more than one open parent is shown once, under whichever parent this bounded walk
361
- * reaches first -- the same spanning-tree compromise already applied to the task widget
362
- * (extension/src/task-widget.ts) for the identical multi-parent-DAG-in-a-bounded-view
363
- * problem, not a new inconsistency.
364
- */
365
- interface TaskWalkFrame {
366
- taskId: string;
367
- parentIndex: number | null;
368
- }
369
-
370
- /** Same iterative two-pass shape as buildMessageHistoryTree, for the same reason: don't assume containment depth stays small just because it usually does. */
371
- export function buildTaskItemTree(graph: TaskGraph): ContextSegmentItem[] {
372
- const byId = new Map(graph.nodes.map((node) => [node.task.id, node]));
373
- const openIds = new Set(graph.nodes.filter((node) => node.task.status !== "done" && node.task.status !== "canceled").map((node) => node.task.id));
374
- const visited = new Set<string>();
375
-
376
- const rootIds = [...openIds].filter((id) => {
377
- const node = byId.get(id)!;
378
- return node.parentIds.length === 0 || !node.parentIds.some((parentId) => openIds.has(parentId));
379
- });
380
-
381
- const order: TaskWalkFrame[] = [];
382
- const stack: TaskWalkFrame[] = [...rootIds].reverse().map((taskId) => ({ taskId, parentIndex: null }));
383
- while (stack.length > 0) {
384
- const frame = stack.pop()!;
385
- if (order.length >= CONTEXT_TREE_MAX_NODES) break;
386
- if (visited.has(frame.taskId) || !openIds.has(frame.taskId)) continue; // cycle guard + open-only filter
387
- visited.add(frame.taskId);
388
- const index = order.length;
389
- order.push(frame);
390
- const node = byId.get(frame.taskId);
391
- const children = [...(node?.childIds ?? [])].reverse()
392
- .filter((childId) => openIds.has(childId))
393
- .map((childId) => ({ taskId: childId, parentIndex: index }));
394
- stack.push(...children);
395
- }
396
-
397
- const childItemsByParent = new Map<number, ContextSegmentItem[]>();
398
- const itemByIndex = new Map<number, ContextSegmentItem>();
399
- for (let index = order.length - 1; index >= 0; index--) {
400
- const frame = order[index]!;
401
- const node = byId.get(frame.taskId);
402
- if (!node) continue;
403
- const characters = node.task.title.length + node.task.body.length;
404
- const tokens = Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
405
- const children = childItemsByParent.get(index) ?? [];
406
- const item: ContextSegmentItem = { label: node.task.title, estimatedTokens: tokens, ...(children.length > 0 ? { children } : {}) };
407
- itemByIndex.set(index, item);
408
- if (frame.parentIndex !== null) {
409
- const siblings = childItemsByParent.get(frame.parentIndex) ?? [];
410
- siblings.unshift(item);
411
- childItemsByParent.set(frame.parentIndex, siblings);
412
- }
413
- }
414
-
415
- const items: ContextSegmentItem[] = [];
416
- for (let index = 0; index < order.length; index++) {
417
- if (order[index]!.parentIndex === null) {
418
- const item = itemByIndex.get(index);
419
- if (item) items.push(item);
420
- }
421
- }
422
- return items;
423
- }
424
-
425
- /**
426
- * Composes every segment Papyrus can actually measure or estimate (rules, tasks, skills
427
- * catalog, cached base-prompt size, active tool definitions, and the live session's own
428
- * message history) against the real total Pi reports, deriving "unaccounted" (genuine
429
- * wire-protocol overhead -- message envelope/role wrapping, cache-control markers -- which
430
- * really is invisible to any extension) as the remainder. The remainder is clamped to zero
431
- * rather than shown negative -- char/4 token estimation is approximate, and a small overshoot
432
- * in the known segments must not display as a nonsensical negative bucket -- but the clamp
433
- * amount itself is preserved as overshootTokens rather than silently discarded, so a
434
- * consumer can tell "genuinely zero" apart from "our other estimates already exceeded the
435
- * real total". When the real total is unavailable, unaccounted is reported as zero and
436
- * totalTokens surfaces as null so callers can label the whole breakdown as estimate-only
437
- * rather than silently treating a partial sum as ground truth.
438
- */
439
- export function buildContextBreakdown(input: BuildContextBreakdownInput): ContextBreakdown {
440
- const reserveTokens = input.reserveTokens ?? DEFAULT_RESERVE_TOKENS;
441
- const rules: ContextSegment = {
442
- key: "rules",
443
- label: "Papyrus Rules",
444
- estimatedTokens: input.ruleBudget.totalEstimatedTokens,
445
- items: input.ruleBudget.entries.map((entry) => ({ label: entry.title, estimatedTokens: entry.estimatedTokens })),
446
- };
447
- const tasks: ContextSegment = {
448
- key: "tasks",
449
- label: "Papyrus Tasks",
450
- estimatedTokens: sumItemTree(input.taskItems),
451
- items: input.taskItems,
452
- };
453
- const skills: ContextSegment = {
454
- key: "skills",
455
- label: "Pi Skills catalog",
456
- estimatedTokens: input.skills.totalEstimatedTokens,
457
- items: input.skills.entries.map((entry) => ({ label: entry.name, estimatedTokens: entry.estimatedTokens })),
458
- };
459
- const basePrompt: ContextSegment = {
460
- key: "basePrompt",
461
- label: input.basePromptEstimatedTokens === null ? "Base system prompt (not observed yet)" : "Base system prompt (Pi + host instructions)",
462
- estimatedTokens: input.basePromptEstimatedTokens ?? 0,
463
- ...(input.basePromptEstimatedTokens === null ? { unknown: true } : {}),
464
- ...(input.basePromptItems && input.basePromptItems.length > 0 ? { items: input.basePromptItems } : {}),
465
- };
466
- const messageHistory: ContextSegment = {
467
- key: "messageHistory",
468
- label: "Conversation message history",
469
- estimatedTokens: input.messageHistoryActiveTokens,
470
- items: input.messageHistoryItems,
471
- };
472
- const toolDefinitionItems = input.toolDefinitionItems ?? [];
473
- const toolCount = toolDefinitionItems.reduce((sum, item) => sum + (item.children?.length ?? 1), 0);
474
- const toolDefinitions: ContextSegment = {
475
- key: "toolDefinitions",
476
- label: `Active tool definitions (${toolCount} tool${toolCount === 1 ? "" : "s"})`,
477
- // Top-level sum only, NOT sumItemTree: unlike Tasks/message-history, whose parent nodes
478
- // carry their own independent content genuinely additive with their children, a
479
- // buildToolDefinitionItems() group node's own estimatedTokens IS the sum of its children
480
- // (by construction, for a meaningful collapsed-row total) -- summing the tree here would
481
- // double-count every tool once as itself and once inside its group's total.
482
- estimatedTokens: toolDefinitionItems.reduce((sum, item) => sum + item.estimatedTokens, 0),
483
- ...(toolDefinitionItems.length > 0 ? { items: toolDefinitionItems } : {}),
484
- };
485
- const knownTokens = rules.estimatedTokens + tasks.estimatedTokens + skills.estimatedTokens + basePrompt.estimatedTokens + messageHistory.estimatedTokens + toolDefinitions.estimatedTokens;
486
- const overshootTokens = input.totalTokens === null ? 0 : Math.max(0, knownTokens - input.totalTokens);
487
- const other: ContextSegment = {
488
- key: "other",
489
- label: overshootTokens > 0
490
- ? `Unaccounted (message envelope, cache-control markers, and other wire-protocol overhead) -- estimate overshoot: other segments' estimates already exceed the real total by ~${overshootTokens} tokens, so this is a floor, not a real zero`
491
- : "Unaccounted (message envelope, cache-control markers, and other wire-protocol overhead)",
492
- estimatedTokens: input.totalTokens === null ? 0 : Math.max(0, input.totalTokens - knownTokens),
493
- };
494
- return {
495
- totalTokens: input.totalTokens,
496
- contextWindow: input.contextWindow,
497
- effectiveBudget: input.contextWindow === null ? null : Math.max(0, input.contextWindow - reserveTokens),
498
- overshootTokens,
499
- segments: [rules, tasks, skills, basePrompt, messageHistory, toolDefinitions, other],
500
- };
501
- }
502
-
503
-
@@ -1,88 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import {
3
- CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN,
4
- PAPYRUS_CONTEXT_INJECTION_SCHEMA,
5
- } from "../../src/constants.ts";
6
- import type { Artifact } from "../../src/domain/artifact.ts";
7
- import { ruleInjectionPreview } from "./rules.ts";
8
- import { playbookInjectionPreview } from "./playbook-bridge.ts";
9
-
10
- export interface ContextPayloadSize {
11
- characters: number;
12
- bytes: number;
13
- }
14
-
15
- export interface PapyrusContextInjectionObservation {
16
- schema: typeof PAPYRUS_CONTEXT_INJECTION_SCHEMA;
17
- observedAt: number;
18
- sequence: number;
19
- producerId: string;
20
- before: ContextPayloadSize;
21
- rules: ContextPayloadSize & { count: number };
22
- playbooks: ContextPayloadSize & { count: number };
23
- tasks: ContextPayloadSize;
24
- injected: ContextPayloadSize;
25
- after: ContextPayloadSize;
26
- estimatedTokens: number;
27
- share: number;
28
- fingerprint: string;
29
- unchanged: boolean;
30
- }
31
-
32
- export interface BuildContextInjectionInput {
33
- basePrompt: string;
34
- rules: Array<Pick<Artifact, "title" | "body" | "extra">>;
35
- playbooks: Array<Pick<Artifact, "title" | "extra">>;
36
- taskSummary: string | null;
37
- observedAt: number;
38
- sequence: number;
39
- producerId: string;
40
- previousFingerprint?: string;
41
- }
42
-
43
- const encoder = new TextEncoder();
44
-
45
- function size(value: string): ContextPayloadSize {
46
- return { characters: value.length, bytes: encoder.encode(value).byteLength };
47
- }
48
-
49
- export function buildContextInjection(input: BuildContextInjectionInput): {
50
- prompt: string;
51
- ruleBlock: string;
52
- playbookBlock: string;
53
- taskBlock: string;
54
- observation: PapyrusContextInjectionObservation;
55
- } {
56
- const ruleContent = input.rules.map(ruleInjectionPreview).join("\n");
57
- const ruleBlock = ruleContent ? `\n\n## Active rules (Papyrus)\n\n${ruleContent}\n` : "";
58
- const playbookContent = input.playbooks.map(playbookInjectionPreview).join("\n");
59
- const playbookBlock = playbookContent ? `\n\n## Available playbooks (Papyrus)\n\n${playbookContent}\n` : "";
60
- const taskBlock = input.taskSummary ? `\n\n## Open tasks (Papyrus)\n\n${input.taskSummary}\n` : "";
61
- const injected = `${ruleBlock}${playbookBlock}${taskBlock}`;
62
- const prompt = `${input.basePrompt}${injected}`;
63
- const fingerprint = createHash("sha256").update(injected).digest("hex");
64
- const injectedSize = size(injected);
65
- const afterSize = size(prompt);
66
- return {
67
- prompt,
68
- ruleBlock,
69
- playbookBlock,
70
- taskBlock,
71
- observation: {
72
- schema: PAPYRUS_CONTEXT_INJECTION_SCHEMA,
73
- observedAt: input.observedAt,
74
- sequence: input.sequence,
75
- producerId: input.producerId,
76
- before: size(input.basePrompt),
77
- rules: { ...size(ruleBlock), count: input.rules.length },
78
- playbooks: { ...size(playbookBlock), count: input.playbooks.length },
79
- tasks: size(taskBlock),
80
- injected: injectedSize,
81
- after: afterSize,
82
- estimatedTokens: Math.ceil(injectedSize.characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN),
83
- share: afterSize.characters === 0 ? 0 : injectedSize.characters / afterSize.characters,
84
- fingerprint,
85
- unchanged: input.previousFingerprint === fingerprint,
86
- },
87
- };
88
- }