@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.
- package/README.md +5 -189
- package/package.json +8 -16
- package/src/artifact-relationship-view.ts +23 -0
- package/src/cli.ts +0 -0
- package/src/index.ts +32 -0
- package/src/task-relationship-view.ts +2 -1
- package/extension/src/active-task-continuation.ts +0 -131
- package/extension/src/artifact-browser.ts +0 -229
- package/extension/src/artifact-detail-format.ts +0 -31
- package/extension/src/artifact-detail-view.ts +0 -112
- package/extension/src/artifact-format.ts +0 -84
- package/extension/src/artifact-status-presentation.ts +0 -71
- package/extension/src/base-prompt-breakdown.ts +0 -55
- package/extension/src/beautiful-mermaid-renderer.ts +0 -68
- package/extension/src/bounded-poll.ts +0 -20
- package/extension/src/context-budget.ts +0 -503
- package/extension/src/context-injection-telemetry.ts +0 -88
- package/extension/src/context-view.ts +0 -222
- package/extension/src/discuss-ask-layout.ts +0 -193
- package/extension/src/discuss-ask-view.ts +0 -1301
- package/extension/src/discuss.ts +0 -134
- package/extension/src/discussion-detail-view.ts +0 -136
- package/extension/src/docs.ts +0 -58
- package/extension/src/domain-tools.ts +0 -886
- package/extension/src/index.ts +0 -776
- package/extension/src/markdown.ts +0 -60
- package/extension/src/note-widget.ts +0 -8
- package/extension/src/notes.ts +0 -102
- package/extension/src/playbook-bridge.ts +0 -91
- package/extension/src/playbooks.ts +0 -97
- package/extension/src/rules.ts +0 -51
- package/extension/src/service-client.ts +0 -29
- package/extension/src/session-identity.ts +0 -22
- package/extension/src/skill-catalog-footprint.ts +0 -183
- package/extension/src/skills.ts +0 -127
- package/extension/src/task-context.ts +0 -1
- package/extension/src/task-detail-format.ts +0 -110
- package/extension/src/task-detail-view.ts +0 -139
- package/extension/src/task-focus-events.ts +0 -57
- package/extension/src/task-graph.ts +0 -116
- package/extension/src/task-presentation.ts +0 -26
- package/extension/src/task-widget.ts +0 -70
- package/extension/src/tasks.ts +0 -418
- package/extension/src/tool-rendering/artifact-card.ts +0 -117
- package/extension/src/tool-rendering/artifact-list.ts +0 -179
- package/extension/src/tool-rendering/index.ts +0 -109
- 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
|
-
}
|