@danypops/pi-papyrus 0.39.0 → 0.41.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 +6 -6
- package/extension/src/context-budget.ts +12 -366
- package/extension/src/context-hub-contribution.ts +42 -0
- package/extension/src/domain-tools.ts +7 -220
- package/extension/src/index.ts +29 -64
- package/extension/src/vehicle-notes-client.ts +27 -3
- package/package.json +5 -4
- package/extension/src/base-prompt-breakdown.ts +0 -55
- package/extension/src/context-view.ts +0 -222
package/README.md
CHANGED
|
@@ -10,14 +10,14 @@ The `papyrus_*` tools are the low-level graph-store API:
|
|
|
10
10
|
- **`papyrus_graph`** — link artifacts, perform bounded traversal, or read the mutation event log
|
|
11
11
|
- **`papyrus_show`** — read nested metadata and bounded edges, optionally running gates
|
|
12
12
|
|
|
13
|
-
Agent-facing domain tools own lifecycle invariants and sit above this store API:
|
|
13
|
+
Agent-facing domain tools own lifecycle invariants and sit above this store API. `tasks` and `discuss` are still single tools with an `action` parameter; `notes`, `docs`, `rules`, `skills`, and `playbooks` are projected from Papyrus's own Vehicle as one real tool per operation (`notes_capture`, `rules_create`, `skills_run`, `playbooks_invoke`, and so on) -- no `action` dispatch, each with its own schema:
|
|
14
14
|
|
|
15
15
|
- **`tasks`** — create/update/list/show/plan, manage the singleton active focus, replace evidence-bearing checklists, hierarchy/dependencies, lifecycle transitions, non-blocking gates, and review completion that focuses one deterministic ready successor without claiming effort
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
16
|
+
- **notes** (`notes_capture`, `notes_list`, `notes_show`, `notes_consume`, `notes_promote`, `notes_archive`) — capture/list/show deferred human intent, mark it consumed, promote it to an existing Task/Doc/Rule/Skill, or archive it with an explicit disposition
|
|
17
|
+
- **docs** (`docs_create`, `docs_list`, `docs_show`, `docs_activate`, `docs_archive`, `docs_reopen`, `docs_link`, `docs_assign_project`, `docs_update`) — activate/archive/reopen and document-safe graph links; Note mutations remain behind the Notes facade
|
|
18
|
+
- **rules** (`rules_create`, `rules_list`, `rules_show`, `rules_preview`, `rules_enable`, `rules_disable`, `rules_gate`, `rules_assign_project`, `rules_update`) — enable/disable and attach governance gates to tasks
|
|
19
|
+
- **skills** (`skills_create`, `skills_create_template`, `skills_list`, `skills_show`, `skills_invoke`, `skills_run`, `skills_enable`, `skills_disable`, `skills_instantiate`, `skills_assign_project`, `skills_update`) — `skills_run` atomically instantiates a parameterized workflow run; `skills_instantiate` instantiates a compatibility artifact-template
|
|
20
|
+
- **playbooks** (`playbooks_create`, `playbooks_list`, `playbooks_show`, `playbooks_invoke`, `playbooks_preview`, `playbooks_enable`, `playbooks_disable`, `playbooks_assign_project`, `playbooks_update`, `playbooks_contain`, `playbooks_uncontain`, `playbooks_depend`, `playbooks_undepend`) — a completely different beast from Skills at the authoring level (a trigger and an ordered list of steps, written as prose), but `playbooks_invoke` recycles the same materialization engine workflow Skills use: it compiles the steps and any `contain`/`depend` composition into real Tasks (one per step, plus a container task per playbook in the tree), wires them with `dependsOn` so completing one auto-focuses the next, and focuses the first one. No text dump — one step surfaces at a time, as it becomes the focused task, same as any other Task. `playbooks_contain`/`playbooks_uncontain` nest a child Playbook inside a parent (its steps run after the parent's own, as part of it); `playbooks_depend`/`playbooks_undepend` chain a prerequisite Playbook before another (it must fully complete first). `playbooks_preview` renders the whole tree as text with no side effects, for reading before invoking. A Playbook can declare named arguments (`{name, description?, required?}`, required defaults true; referenced in step text as `{{name}}`); invoking with a required one unsupplied creates nothing and reports exactly which are still missing, directing the agent to ask via `discuss` with `live:true` rather than guess
|
|
21
21
|
|
|
22
22
|
Every tool operation is registered in the daemon's `/api/v1/ops` registry; parity is verified in tests. The task consumer uses the `tasks.graph` operation, which returns task nodes with explicit parent, child, and dependency IDs rather than leaking SQLite rows or asking the UI to reconstruct relationships.
|
|
23
23
|
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import type { ContextSegmentItem } from "@danypops/jittor";
|
|
3
4
|
import { CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN, CONTEXT_TREE_MAX_NODES, type Artifact, type TaskGraph } from "@danypops/papyrus";
|
|
4
5
|
import { discoverSkillDirectories, scanSkillCatalogFootprint, type SkillCatalogFootprint } from "./skill-catalog-footprint.ts";
|
|
5
6
|
import { ruleInjectionPreview } from "./rules.ts";
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Papyrus's own real data for the Context Hub: Rules injection cost, the Task containment tree,
|
|
10
|
+
* and the Pi Skills catalog footprint -- contributed to Jittor's Context Hub as one segment
|
|
11
|
+
* (context-hub-contribution.ts) instead of rendering a whole breakdown locally. The Pi-generic
|
|
12
|
+
* segments (base prompt, message history, tool definitions) and the composer that reconciles
|
|
13
|
+
* every producer's segments against the real total now live in pi-jittor's own
|
|
14
|
+
* context-breakdown.ts.
|
|
15
|
+
*/
|
|
16
|
+
|
|
7
17
|
export interface RuleBudgetEntry {
|
|
8
18
|
id: string;
|
|
9
19
|
title: string;
|
|
@@ -59,295 +69,11 @@ export function computeContextBudget(
|
|
|
59
69
|
return { rules: ruleBudget, skills, totalEstimatedTokens: ruleBudget.totalEstimatedTokens + skills.totalEstimatedTokens };
|
|
60
70
|
}
|
|
61
71
|
|
|
62
|
-
/** Pi's own documented compaction-reserve default (docs/compaction.md): headroom kept free for the model's response. */
|
|
63
|
-
export const DEFAULT_RESERVE_TOKENS = 16_384;
|
|
64
|
-
|
|
65
|
-
export interface ContextSegmentItem {
|
|
66
|
-
label: string;
|
|
67
|
-
estimatedTokens: number;
|
|
68
|
-
/**
|
|
69
|
-
* Recursive children, when this item has real hierarchy of its own -- conversation history
|
|
70
|
-
* (Pi's session entries form a genuine tree via id/parentId, docs/session-format.md) and
|
|
71
|
-
* Papyrus Tasks (containment via parentIds/childIds) both do; Rules and Skills don't, so
|
|
72
|
-
* their items simply omit this field, degenerating to a flat one-level tree.
|
|
73
|
-
*/
|
|
74
|
-
children?: ContextSegmentItem[];
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export interface ContextSegment {
|
|
78
|
-
key: "rules" | "tasks" | "skills" | "basePrompt" | "messageHistory" | "toolDefinitions" | "other";
|
|
79
|
-
label: string;
|
|
80
|
-
estimatedTokens: number;
|
|
81
|
-
/** Drill-down items, when this segment can be broken down further. Absent for "other" -- an opaque remainder, not a real category. */
|
|
82
|
-
items?: ContextSegmentItem[];
|
|
83
|
-
/**
|
|
84
|
-
* True when this segment's size is genuinely unmeasured (not yet observed), as opposed to
|
|
85
|
-
* measured-and-actually-zero. A display layer that hides zero-token rows to cut noise must
|
|
86
|
-
* NOT hide an unknown segment just because its placeholder value happens to be zero --
|
|
87
|
-
* that would silently misrepresent "we don't know" as "there is nothing here", the same
|
|
88
|
-
* category of honesty problem overshootTokens exists to prevent for the unaccounted bucket.
|
|
89
|
-
*/
|
|
90
|
-
unknown?: boolean;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Session entries and tree nodes as SessionManager exposes them (docs/session-format.md,
|
|
95
|
-
* SessionTreeNode from @earendil-works/pi-coding-agent): a subset covering only the fields
|
|
96
|
-
* this estimate reads, so this stays testable with plain object literals instead of
|
|
97
|
-
* importing pi's own session types.
|
|
98
|
-
*/
|
|
99
|
-
export interface SessionEntryLike {
|
|
100
|
-
id: string;
|
|
101
|
-
type: string;
|
|
102
|
-
message?: unknown;
|
|
103
|
-
summary?: string;
|
|
104
|
-
}
|
|
105
|
-
export interface SessionTreeNodeLike {
|
|
106
|
-
entry: SessionEntryLike;
|
|
107
|
-
children: SessionTreeNodeLike[];
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function messageContentCharacters(message: unknown): number {
|
|
111
|
-
if (typeof message !== "object" || message === null) return 0;
|
|
112
|
-
const record = message as Record<string, unknown>;
|
|
113
|
-
if (record["role"] === "bashExecution") {
|
|
114
|
-
// Pi's own context builder excludes "!!"-prefixed bash output from context; match that.
|
|
115
|
-
if (record["excludeFromContext"] === true) return 0;
|
|
116
|
-
return String(record["command"] ?? "").length + String(record["output"] ?? "").length;
|
|
117
|
-
}
|
|
118
|
-
const content = record["content"];
|
|
119
|
-
if (typeof content === "string") return content.length;
|
|
120
|
-
if (!Array.isArray(content)) return 0;
|
|
121
|
-
let characters = 0;
|
|
122
|
-
for (const block of content) {
|
|
123
|
-
if (typeof block !== "object" || block === null) continue;
|
|
124
|
-
const b = block as Record<string, unknown>;
|
|
125
|
-
if (b["type"] === "text") characters += String(b["text"] ?? "").length;
|
|
126
|
-
else if (b["type"] === "thinking") characters += String(b["thinking"] ?? "").length;
|
|
127
|
-
else if (b["type"] === "toolCall") characters += JSON.stringify(b["arguments"] ?? {}).length;
|
|
128
|
-
// "image" blocks are deliberately not counted here -- image tokens follow a different,
|
|
129
|
-
// non-character-based cost model this char/4 estimate cannot represent; this is a real,
|
|
130
|
-
// documented undercount for image-heavy sessions, not a silent approximation.
|
|
131
|
-
}
|
|
132
|
-
return characters;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function messageSnippet(message: unknown, maxLength = 48): string {
|
|
136
|
-
if (typeof message !== "object" || message === null) return "";
|
|
137
|
-
const record = message as Record<string, unknown>;
|
|
138
|
-
if (record["role"] === "bashExecution") return String(record["command"] ?? "");
|
|
139
|
-
const content = record["content"];
|
|
140
|
-
const text = typeof content === "string"
|
|
141
|
-
? content
|
|
142
|
-
: Array.isArray(content)
|
|
143
|
-
? content.map((block) => (typeof block === "object" && block !== null && (block as Record<string, unknown>)["type"] === "text" ? String((block as Record<string, unknown>)["text"] ?? "") : "")).join(" ")
|
|
144
|
-
: "";
|
|
145
|
-
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
146
|
-
return collapsed.length > maxLength ? `${collapsed.slice(0, maxLength - 1)}…` : collapsed;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function entryLabel(entry: SessionEntryLike): string {
|
|
150
|
-
if (entry.type === "compaction") return "compaction summary";
|
|
151
|
-
if (entry.type === "branch_summary") return "branch summary";
|
|
152
|
-
const role = typeof entry.message === "object" && entry.message !== null ? (entry.message as Record<string, unknown>)["role"] : undefined;
|
|
153
|
-
const prefix = typeof role === "string" ? role : entry.type;
|
|
154
|
-
const snippet = messageSnippet(entry.message);
|
|
155
|
-
return snippet ? `${prefix}: ${snippet}` : prefix;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
export interface MessageHistoryTree {
|
|
159
|
-
/** One item per real tree root (ordinarily one, the session's first entry). */
|
|
160
|
-
items: ContextSegmentItem[];
|
|
161
|
-
/** 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. */
|
|
162
|
-
activeTokens: number;
|
|
163
|
-
/** 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. */
|
|
164
|
-
truncated: boolean;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Walks Pi's own real session tree (ctx.sessionManager.getTree(), docs/session-format.md --
|
|
169
|
-
* entries form a genuine tree via id/parentId, not just the linear current-branch path) to
|
|
170
|
-
* estimate the conversation's context contribution AND surface branches explored via /tree
|
|
171
|
-
* that are no longer on the active path -- content that cost real tokens to generate but is
|
|
172
|
-
* NOT currently part of the context window. Bounded and cycle-safe (CONTEXT_TREE_MAX_NODES):
|
|
173
|
-
* a session file is external, mutable state, and this deliberately hardens past a confirmed
|
|
174
|
-
* real gap in Pi's own getBranch() (no cycle guard at all) rather than assuming the tree can
|
|
175
|
-
* never be malformed.
|
|
176
|
-
*
|
|
177
|
-
* `activeEntryIds` MUST come from ctx.sessionManager.buildContextEntries(), not getBranch().
|
|
178
|
-
* getBranch()'s own docstring says it "[i]ncludes all entry types... Use buildSessionContext()
|
|
179
|
-
* to get the resolved messages for the LLM" -- it does not skip entries a real compaction has
|
|
180
|
-
* already summarized away. A real session with 3 compactions confirmed using getBranch() here
|
|
181
|
-
* overcounts activeTokens by over 13x, since every pre-compaction message still reads as
|
|
182
|
-
* "active". buildContextEntries() is Pi's own compaction-aware entry list: the latest
|
|
183
|
-
* compaction entry, its kept entries from firstKeptEntryId onward, and everything after.
|
|
184
|
-
*
|
|
185
|
-
* `branchEntryIds` (optional) is the full raw current-path id set (getBranch()'s own output).
|
|
186
|
-
* When given, an entry on the branch path but excluded from activeEntryIds is labeled
|
|
187
|
-
* "(compacted)" rather than the less accurate "(inactive branch)", which is reserved for
|
|
188
|
-
* entries not on the current path at all (a genuinely abandoned /tree branch). Omitting it
|
|
189
|
-
* preserves the simpler binary active/inactive-branch labeling for callers that only have one
|
|
190
|
-
* set to give (e.g. tests).
|
|
191
|
-
*/
|
|
192
|
-
interface WalkFrame {
|
|
193
|
-
node: SessionTreeNodeLike;
|
|
194
|
-
parentIndex: number | null;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Iterative (not recursive) two-pass walk: an explicit-stack pre-order discovery pass
|
|
199
|
-
* followed by a reverse-order (children-before-parent) construction pass. A real, ordinary
|
|
200
|
-
* (non-branching) long-running session is one long linear chain, so recursion depth would
|
|
201
|
-
* equal entry count -- a session observed in production with 6,924 entries on its own active
|
|
202
|
-
* branch confirmed this is not a hypothetical concern; a naive recursive walk risks a real
|
|
203
|
-
* JavaScript call-stack overflow at that scale, independent of the CONTEXT_TREE_MAX_NODES
|
|
204
|
-
* bound entirely.
|
|
205
|
-
*/
|
|
206
|
-
export function buildMessageHistoryTree(roots: ReadonlyArray<SessionTreeNodeLike>, activeEntryIds: ReadonlySet<string>, branchEntryIds?: ReadonlySet<string>): MessageHistoryTree {
|
|
207
|
-
const visited = new Set<string>();
|
|
208
|
-
let truncated = false;
|
|
209
|
-
let activeTokens = 0;
|
|
210
|
-
|
|
211
|
-
const order: WalkFrame[] = [];
|
|
212
|
-
const stack: WalkFrame[] = [...roots].reverse().map((root) => ({ node: root, parentIndex: null }));
|
|
213
|
-
while (stack.length > 0) {
|
|
214
|
-
const frame = stack.pop()!;
|
|
215
|
-
if (order.length >= CONTEXT_TREE_MAX_NODES) { truncated = true; break; }
|
|
216
|
-
if (visited.has(frame.node.entry.id)) { truncated = true; continue; } // cycle guard
|
|
217
|
-
visited.add(frame.node.entry.id);
|
|
218
|
-
const index = order.length;
|
|
219
|
-
order.push(frame);
|
|
220
|
-
const children = [...frame.node.children].reverse().map((child) => ({ node: child, parentIndex: index }));
|
|
221
|
-
stack.push(...children);
|
|
222
|
-
}
|
|
223
|
-
if (stack.length > 0) truncated = true; // node bound hit with more work still queued
|
|
224
|
-
|
|
225
|
-
const childItemsByParent = new Map<number, ContextSegmentItem[]>();
|
|
226
|
-
const itemByIndex = new Map<number, ContextSegmentItem>();
|
|
227
|
-
for (let index = order.length - 1; index >= 0; index--) {
|
|
228
|
-
const frame = order[index]!;
|
|
229
|
-
const entry = frame.node.entry;
|
|
230
|
-
const characters = entry.type === "message"
|
|
231
|
-
? messageContentCharacters(entry.message)
|
|
232
|
-
: entry.type === "compaction" || entry.type === "branch_summary"
|
|
233
|
-
? (entry.summary ?? "").length
|
|
234
|
-
: 0;
|
|
235
|
-
const tokens = Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
|
|
236
|
-
const isActive = activeEntryIds.has(entry.id);
|
|
237
|
-
if (isActive) activeTokens += tokens;
|
|
238
|
-
const isOnBranch = branchEntryIds ? branchEntryIds.has(entry.id) : isActive; // no branch set given -- fall back to the old binary active/inactive-branch label
|
|
239
|
-
|
|
240
|
-
const children = childItemsByParent.get(index) ?? [];
|
|
241
|
-
if (tokens === 0 && children.length === 0) continue; // no content, no descendants with content -- nothing to show
|
|
242
|
-
|
|
243
|
-
const item: ContextSegmentItem = {
|
|
244
|
-
label: isActive ? entryLabel(entry) : isOnBranch ? `${entryLabel(entry)} (compacted)` : `${entryLabel(entry)} (inactive branch)`,
|
|
245
|
-
estimatedTokens: tokens,
|
|
246
|
-
...(children.length > 0 ? { children } : {}),
|
|
247
|
-
};
|
|
248
|
-
itemByIndex.set(index, item);
|
|
249
|
-
if (frame.parentIndex !== null) {
|
|
250
|
-
const siblings = childItemsByParent.get(frame.parentIndex) ?? [];
|
|
251
|
-
siblings.unshift(item); // reverse-order processing -- unshift restores original document order
|
|
252
|
-
childItemsByParent.set(frame.parentIndex, siblings);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const items: ContextSegmentItem[] = [];
|
|
257
|
-
for (let index = 0; index < order.length; index++) {
|
|
258
|
-
if (order[index]!.parentIndex === null) {
|
|
259
|
-
const item = itemByIndex.get(index);
|
|
260
|
-
if (item) items.push(item);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
return { items, activeTokens, truncated };
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
export interface ContextBreakdown {
|
|
267
|
-
/** Real usage from ctx.getContextUsage() -- ground truth, not estimated. Null only when Pi has no usage yet (e.g. before the first turn). */
|
|
268
|
-
totalTokens: number | null;
|
|
269
|
-
/** From ctx.model.contextWindow. Null when the active model's context window is unknown. */
|
|
270
|
-
contextWindow: number | null;
|
|
271
|
-
/** contextWindow - reserveTokens, mirroring Pi's own compaction-trigger formula. Null when contextWindow is unknown. */
|
|
272
|
-
effectiveBudget: number | null;
|
|
273
|
-
/**
|
|
274
|
-
* How much the known/estimated segments (rules+tasks+skills+basePrompt+messageHistory+
|
|
275
|
-
* toolDefinitions) exceed the real total, when they do. Zero means no overshoot. This must
|
|
276
|
-
* stay visible rather than only being absorbed into "unaccounted" clamping to zero -- a
|
|
277
|
-
* clamped-to-zero unaccounted segment does NOT mean wire-protocol overhead is actually free;
|
|
278
|
-
* it means this estimate's other segments already consumed the entire real budget on paper.
|
|
279
|
-
* Hiding that distinction would make a genuinely nonzero cost look like zero.
|
|
280
|
-
*/
|
|
281
|
-
overshootTokens: number;
|
|
282
|
-
/** rules, tasks, skills, basePrompt, messageHistory, toolDefinitions, then "other" absorbing whatever real usage the rest don't account for. */
|
|
283
|
-
segments: ContextSegment[];
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
export interface BuildContextBreakdownInput {
|
|
287
|
-
totalTokens: number | null;
|
|
288
|
-
contextWindow: number | null;
|
|
289
|
-
reserveTokens?: number;
|
|
290
|
-
ruleBudget: ContextBudget["rules"];
|
|
291
|
-
/** 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. */
|
|
292
|
-
taskItems: ContextSegmentItem[];
|
|
293
|
-
skills: SkillCatalogFootprint;
|
|
294
|
-
/** Pi's own base system prompt size, cached from the last observed before_agent_start turn. Null before any turn has run yet. */
|
|
295
|
-
basePromptEstimatedTokens: number | null;
|
|
296
|
-
/** Structural sub-breakdown (tool snippets, Skills, context files, template remainder) from the same cached observation, built by buildBasePromptItems(). Empty when basePromptEstimatedTokens is null. */
|
|
297
|
-
basePromptItems?: ContextSegmentItem[];
|
|
298
|
-
/** From buildMessageHistoryTree() against the live session's real tree (ctx.sessionManager.getTree()). */
|
|
299
|
-
messageHistoryItems: ContextSegmentItem[];
|
|
300
|
-
/** 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. */
|
|
301
|
-
messageHistoryActiveTokens: number;
|
|
302
|
-
/** From buildToolDefinitionItems() against pi.getAllTools() filtered to pi.getActiveTools(). Defaults to empty when omitted. */
|
|
303
|
-
toolDefinitionItems?: ContextSegmentItem[];
|
|
304
|
-
}
|
|
305
|
-
|
|
306
72
|
/** Sums a possibly-nested item tree's tokens recursively -- every node's own contribution, not just top-level items. */
|
|
307
|
-
function sumItemTree(items: ContextSegmentItem[]): number {
|
|
73
|
+
export function sumItemTree(items: ContextSegmentItem[]): number {
|
|
308
74
|
return items.reduce((sum, item) => sum + item.estimatedTokens + sumItemTree(item.children ?? []), 0);
|
|
309
75
|
}
|
|
310
76
|
|
|
311
|
-
/** 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. */
|
|
312
|
-
export interface ActiveToolDefinitionLike {
|
|
313
|
-
name: string;
|
|
314
|
-
description: string;
|
|
315
|
-
parameters: unknown;
|
|
316
|
-
sourceInfo: { source: string };
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Tool definitions (name + description + JSON schema) are actually measurable, unlike genuine
|
|
321
|
-
* wire-protocol framework overhead (message envelope/role wrapping, cache-control markers) which
|
|
322
|
-
* really is invisible to any extension -- this is what lets "other" stop absorbing them as an
|
|
323
|
-
* unmeasured guess. Grouped by extension/package source with each tool as a drill-down child
|
|
324
|
-
* (mirrors the Tasks segment's own parent/child shape) rather than one flat list, since a real
|
|
325
|
-
* session can have dozens of active tools spread across many extensions.
|
|
326
|
-
*/
|
|
327
|
-
export function buildToolDefinitionItems(tools: ReadonlyArray<ActiveToolDefinitionLike>): ContextSegmentItem[] {
|
|
328
|
-
const bySource = new Map<string, ActiveToolDefinitionLike[]>();
|
|
329
|
-
for (const tool of tools) {
|
|
330
|
-
const list = bySource.get(tool.sourceInfo.source) ?? [];
|
|
331
|
-
list.push(tool);
|
|
332
|
-
bySource.set(tool.sourceInfo.source, list);
|
|
333
|
-
}
|
|
334
|
-
const items: ContextSegmentItem[] = [];
|
|
335
|
-
for (const [source, toolsForSource] of bySource) {
|
|
336
|
-
const children = toolsForSource
|
|
337
|
-
.map((tool) => {
|
|
338
|
-
const characters = tool.name.length + tool.description.length + JSON.stringify(tool.parameters ?? {}).length;
|
|
339
|
-
return { label: tool.name, estimatedTokens: Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN) };
|
|
340
|
-
})
|
|
341
|
-
.sort((a, b) => b.estimatedTokens - a.estimatedTokens);
|
|
342
|
-
items.push({
|
|
343
|
-
label: `${source} (${toolsForSource.length} tool${toolsForSource.length === 1 ? "" : "s"})`,
|
|
344
|
-
estimatedTokens: children.reduce((sum, child) => sum + child.estimatedTokens, 0),
|
|
345
|
-
children,
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
return items.sort((a, b) => b.estimatedTokens - a.estimatedTokens);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
77
|
/**
|
|
352
78
|
* Builds the Tasks segment's items from Papyrus's own real containment tree (parentIds/
|
|
353
79
|
* childIds), not a flat list -- Tasks are a genuine DAG (a task may have more than one
|
|
@@ -365,7 +91,7 @@ interface TaskWalkFrame {
|
|
|
365
91
|
parentIndex: number | null;
|
|
366
92
|
}
|
|
367
93
|
|
|
368
|
-
/**
|
|
94
|
+
/** Bounded, iterative two-pass walk (an explicit-stack pre-order discovery pass, then a reverse-order construction pass) -- containment depth is not assumed to stay small just because it usually does. */
|
|
369
95
|
export function buildTaskItemTree(graph: TaskGraph): ContextSegmentItem[] {
|
|
370
96
|
const byId = new Map(graph.nodes.map((node) => [node.task.id, node]));
|
|
371
97
|
const openIds = new Set(graph.nodes.filter((node) => node.task.status !== "done" && node.task.status !== "canceled").map((node) => node.task.id));
|
|
@@ -419,83 +145,3 @@ export function buildTaskItemTree(graph: TaskGraph): ContextSegmentItem[] {
|
|
|
419
145
|
}
|
|
420
146
|
return items;
|
|
421
147
|
}
|
|
422
|
-
|
|
423
|
-
/**
|
|
424
|
-
* Composes every segment Papyrus can actually measure or estimate (rules, tasks, skills
|
|
425
|
-
* catalog, cached base-prompt size, active tool definitions, and the live session's own
|
|
426
|
-
* message history) against the real total Pi reports, deriving "unaccounted" (genuine
|
|
427
|
-
* wire-protocol overhead -- message envelope/role wrapping, cache-control markers -- which
|
|
428
|
-
* really is invisible to any extension) as the remainder. The remainder is clamped to zero
|
|
429
|
-
* rather than shown negative -- char/4 token estimation is approximate, and a small overshoot
|
|
430
|
-
* in the known segments must not display as a nonsensical negative bucket -- but the clamp
|
|
431
|
-
* amount itself is preserved as overshootTokens rather than silently discarded, so a
|
|
432
|
-
* consumer can tell "genuinely zero" apart from "our other estimates already exceeded the
|
|
433
|
-
* real total". When the real total is unavailable, unaccounted is reported as zero and
|
|
434
|
-
* totalTokens surfaces as null so callers can label the whole breakdown as estimate-only
|
|
435
|
-
* rather than silently treating a partial sum as ground truth.
|
|
436
|
-
*/
|
|
437
|
-
export function buildContextBreakdown(input: BuildContextBreakdownInput): ContextBreakdown {
|
|
438
|
-
const reserveTokens = input.reserveTokens ?? DEFAULT_RESERVE_TOKENS;
|
|
439
|
-
const rules: ContextSegment = {
|
|
440
|
-
key: "rules",
|
|
441
|
-
label: "Papyrus Rules",
|
|
442
|
-
estimatedTokens: input.ruleBudget.totalEstimatedTokens,
|
|
443
|
-
items: input.ruleBudget.entries.map((entry) => ({ label: entry.title, estimatedTokens: entry.estimatedTokens })),
|
|
444
|
-
};
|
|
445
|
-
const tasks: ContextSegment = {
|
|
446
|
-
key: "tasks",
|
|
447
|
-
label: "Papyrus Tasks",
|
|
448
|
-
estimatedTokens: sumItemTree(input.taskItems),
|
|
449
|
-
items: input.taskItems,
|
|
450
|
-
};
|
|
451
|
-
const skills: ContextSegment = {
|
|
452
|
-
key: "skills",
|
|
453
|
-
label: "Pi Skills catalog",
|
|
454
|
-
estimatedTokens: input.skills.totalEstimatedTokens,
|
|
455
|
-
items: input.skills.entries.map((entry) => ({ label: entry.name, estimatedTokens: entry.estimatedTokens })),
|
|
456
|
-
};
|
|
457
|
-
const basePrompt: ContextSegment = {
|
|
458
|
-
key: "basePrompt",
|
|
459
|
-
label: input.basePromptEstimatedTokens === null ? "Base system prompt (not observed yet)" : "Base system prompt (Pi + host instructions)",
|
|
460
|
-
estimatedTokens: input.basePromptEstimatedTokens ?? 0,
|
|
461
|
-
...(input.basePromptEstimatedTokens === null ? { unknown: true } : {}),
|
|
462
|
-
...(input.basePromptItems && input.basePromptItems.length > 0 ? { items: input.basePromptItems } : {}),
|
|
463
|
-
};
|
|
464
|
-
const messageHistory: ContextSegment = {
|
|
465
|
-
key: "messageHistory",
|
|
466
|
-
label: "Conversation message history",
|
|
467
|
-
estimatedTokens: input.messageHistoryActiveTokens,
|
|
468
|
-
items: input.messageHistoryItems,
|
|
469
|
-
};
|
|
470
|
-
const toolDefinitionItems = input.toolDefinitionItems ?? [];
|
|
471
|
-
const toolCount = toolDefinitionItems.reduce((sum, item) => sum + (item.children?.length ?? 1), 0);
|
|
472
|
-
const toolDefinitions: ContextSegment = {
|
|
473
|
-
key: "toolDefinitions",
|
|
474
|
-
label: `Active tool definitions (${toolCount} tool${toolCount === 1 ? "" : "s"})`,
|
|
475
|
-
// Top-level sum only, NOT sumItemTree: unlike Tasks/message-history, whose parent nodes
|
|
476
|
-
// carry their own independent content genuinely additive with their children, a
|
|
477
|
-
// buildToolDefinitionItems() group node's own estimatedTokens IS the sum of its children
|
|
478
|
-
// (by construction, for a meaningful collapsed-row total) -- summing the tree here would
|
|
479
|
-
// double-count every tool once as itself and once inside its group's total.
|
|
480
|
-
estimatedTokens: toolDefinitionItems.reduce((sum, item) => sum + item.estimatedTokens, 0),
|
|
481
|
-
...(toolDefinitionItems.length > 0 ? { items: toolDefinitionItems } : {}),
|
|
482
|
-
};
|
|
483
|
-
const knownTokens = rules.estimatedTokens + tasks.estimatedTokens + skills.estimatedTokens + basePrompt.estimatedTokens + messageHistory.estimatedTokens + toolDefinitions.estimatedTokens;
|
|
484
|
-
const overshootTokens = input.totalTokens === null ? 0 : Math.max(0, knownTokens - input.totalTokens);
|
|
485
|
-
const other: ContextSegment = {
|
|
486
|
-
key: "other",
|
|
487
|
-
label: overshootTokens > 0
|
|
488
|
-
? `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`
|
|
489
|
-
: "Unaccounted (message envelope, cache-control markers, and other wire-protocol overhead)",
|
|
490
|
-
estimatedTokens: input.totalTokens === null ? 0 : Math.max(0, input.totalTokens - knownTokens),
|
|
491
|
-
};
|
|
492
|
-
return {
|
|
493
|
-
totalTokens: input.totalTokens,
|
|
494
|
-
contextWindow: input.contextWindow,
|
|
495
|
-
effectiveBudget: input.contextWindow === null ? null : Math.max(0, input.contextWindow - reserveTokens),
|
|
496
|
-
overshootTokens,
|
|
497
|
-
segments: [rules, tasks, skills, basePrompt, messageHistory, toolDefinitions, other],
|
|
498
|
-
};
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ContextSegment, ContextSegmentItem } from "@danypops/jittor";
|
|
2
|
+
import { sumItemTree, type ContextBudget } from "./context-budget.ts";
|
|
3
|
+
import type { SkillCatalogFootprint } from "./skill-catalog-footprint.ts";
|
|
4
|
+
|
|
5
|
+
/** Human-readable producer identity on Jittor's Context Hub bus -- distinct from PAPYRUS_CONTEXT_INJECTION_CHANNEL's own opaque per-process producerId, which identifies a specific injection stream rather than "which extension". */
|
|
6
|
+
export const PAPYRUS_CONTEXT_HUB_PRODUCER_NAME = "papyrus";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Bundles Rules + Tasks + Skills catalog into ONE contributed ContextSegment. Jittor's
|
|
10
|
+
* ContextHubCapability keeps only the latest segment per producer (a producer re-emits every
|
|
11
|
+
* turn, mirroring papyrus.context-injection.v1's own cadence), so contributing three separate
|
|
12
|
+
* top-level segments would need three fake producer identities instead of Papyrus's one real
|
|
13
|
+
* one -- nested as up to three drill-down item groups under a single "papyrus" segment instead,
|
|
14
|
+
* preserving the same per-category fidelity the original local /context breakdown had.
|
|
15
|
+
*/
|
|
16
|
+
export function papyrusContextSegment(ruleBudget: ContextBudget["rules"], taskItems: ContextSegmentItem[], skills: SkillCatalogFootprint): ContextSegment {
|
|
17
|
+
const items: ContextSegmentItem[] = [];
|
|
18
|
+
if (ruleBudget.entries.length > 0) {
|
|
19
|
+
items.push({
|
|
20
|
+
label: "Active Rules",
|
|
21
|
+
estimatedTokens: ruleBudget.totalEstimatedTokens,
|
|
22
|
+
children: ruleBudget.entries.map((entry) => ({ label: entry.title, estimatedTokens: entry.estimatedTokens })),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
if (taskItems.length > 0) {
|
|
26
|
+
items.push({ label: "Open Tasks", estimatedTokens: sumItemTree(taskItems), children: taskItems });
|
|
27
|
+
}
|
|
28
|
+
if (skills.entries.length > 0) {
|
|
29
|
+
items.push({
|
|
30
|
+
label: "Pi Skills catalog",
|
|
31
|
+
estimatedTokens: skills.totalEstimatedTokens,
|
|
32
|
+
children: skills.entries.map((entry) => ({ label: entry.name, estimatedTokens: entry.estimatedTokens })),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
key: "papyrus",
|
|
37
|
+
label: "Papyrus (Rules, Tasks, Skills)",
|
|
38
|
+
estimatedTokens: items.reduce((sum, item) => sum + item.estimatedTokens, 0),
|
|
39
|
+
confidence: "exact-cooperative",
|
|
40
|
+
...(items.length > 0 ? { items } : {}),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -8,9 +8,6 @@ import {
|
|
|
8
8
|
type DiscussionRound,
|
|
9
9
|
type GateResult,
|
|
10
10
|
type OperationName,
|
|
11
|
-
type PlaybookInvocationResult,
|
|
12
|
-
type PlaybookMissingArguments,
|
|
13
|
-
type WorkflowRunResult,
|
|
14
11
|
type TaskCompletion,
|
|
15
12
|
type TaskExecutionPlan,
|
|
16
13
|
type TaskGraph,
|
|
@@ -185,23 +182,6 @@ async function resolveArtifactIdByName(listOperation: OperationName, baseRequest
|
|
|
185
182
|
}
|
|
186
183
|
}
|
|
187
184
|
|
|
188
|
-
/**
|
|
189
|
-
* Playbook `arguments` is intentionally untyped in this tool's schema (an array on create, a
|
|
190
|
-
* {name: value} map on invoke) -- unlike every other JSON-shaped field here, which has a concrete
|
|
191
|
-
* array/record schema the calling layer can serialize correctly. A genuinely schema-less field can
|
|
192
|
-
* arrive pre-serialized as JSON text instead of a parsed value; parse it back in place before it
|
|
193
|
-
* reaches the service, the same tolerance the CLI's own --arguments-json/--*-json flags already give.
|
|
194
|
-
*/
|
|
195
|
-
export function normalizeJsonEncodedField(params: Record<string, unknown>, key: string): void {
|
|
196
|
-
const value = params[key];
|
|
197
|
-
if (typeof value !== "string") return;
|
|
198
|
-
try {
|
|
199
|
-
params[key] = JSON.parse(value);
|
|
200
|
-
} catch {
|
|
201
|
-
throw new Error(`${key} must be valid JSON`);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
185
|
/**
|
|
206
186
|
* Resolves every {nameKey -> idKey} pair present and not already satisfied by an explicit id, in
|
|
207
187
|
* place. `notes`, when given, collects a message for each name that only resolved by widening
|
|
@@ -540,200 +520,9 @@ export function registerTasksTool(pi: ExtensionAPI): void {
|
|
|
540
520
|
});
|
|
541
521
|
}
|
|
542
522
|
|
|
543
|
-
// notes.*, rules.*, docs.*, and the shared artifact.* are
|
|
544
|
-
// (see ../vehicle-notes-client.ts and @danypops/papyrus's
|
|
545
|
-
// not pi.registerTool()s in this file.
|
|
546
|
-
|
|
547
|
-
export function registerPlaybooksTool(pi: ExtensionAPI): void {
|
|
548
|
-
pi.registerTool({
|
|
549
|
-
name: "playbooks",
|
|
550
|
-
label: "Playbooks",
|
|
551
|
-
description: "Playbook domain tool -- a completely different beast from the skills tool at the AUTHORING level (a Playbook is prose: a trigger and an ordered list of steps), but invoke recycles the exact same materialization engine workflow Skills use: it compiles the Playbook's steps and its contains/depends_on composition tree into real Tasks (one per step, plus one container task per playbook in the tree), wires them with dependsOn so completing one auto-focuses the next, and focuses the first one -- no text dump, one step (page) surfaces at a time as it becomes the focused task, exactly like any other Task. contain/uncontain nest a child Playbook inside a parent (its steps run AFTER the parent's own, as part of it); depend/undepend chain a prerequisite Playbook before another (it must fully complete FIRST). Both are bounded; a composition cycle is a hard invoke-time error (real Tasks would otherwise be created in a loop), unlike preview's degrade-to-a-marker. ACTIONS: create, list, show, invoke, preview, enable, disable, assign_project, update, contain, uncontain, depend, undepend, remove, remove_subtree, restore. project_root is optional everywhere (omitted = unscoped). On create, `arguments` declares named inputs the Playbook needs: [{name, description?, required?}] (required defaults true) -- referenced in step text as `{{name}}`, substituted at invoke time. On invoke, `arguments` supplies known values as {name: value}; if any declared REQUIRED argument is still missing, invoke creates nothing and returns `missingArguments` -- ask the human for these (discuss tool, live:true) and invoke again, never guess or invent a value. A successful invoke returns `entryTaskId` (now focused) and `created.tasks` -- drive it forward with the tasks tool (start/submit/complete) like any other Task; contains/depends_on wiring auto-focuses each next step on completion. preview renders the whole tree as text with no side effects, for a human who just wants to read it first. update changes title/body/labels (at least one required) and is refused for a read-only external projection. remove moves a Playbook to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline; remove_subtree extends this to the whole nested-Playbook tree in one call. PREFER `name` (the playbook's exact title) over `id`, and `parent_name`/`child_name`/`dependency_name` over `parent_id`/`child_id`/`dependency_id` for contain/uncontain/depend/undepend -- all are backend implementation details, resolved from name automatically.",
|
|
552
|
-
parameters: Type.Object({
|
|
553
|
-
action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
|
|
554
|
-
body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
|
|
555
|
-
tools: Type.Optional(Type.Array(Type.String())), labels: Type.Optional(Type.Array(Type.String())),
|
|
556
|
-
arguments: Type.Optional(Type.Unknown()),
|
|
557
|
-
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
|
|
558
|
-
text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()),
|
|
559
|
-
parent_id: Type.Optional(Type.String()), parent_name: Type.Optional(Type.String()),
|
|
560
|
-
child_id: Type.Optional(Type.String()), child_name: Type.Optional(Type.String()),
|
|
561
|
-
dependency_id: Type.Optional(Type.String()), dependency_name: Type.Optional(Type.String()),
|
|
562
|
-
project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
|
|
563
|
-
}),
|
|
564
|
-
renderCall(args, theme) { return renderPapyrusToolCall("Playbooks", args, theme); },
|
|
565
|
-
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
566
|
-
async execute(_id, rawParams, _signal, _onUpdate, ctx) {
|
|
567
|
-
try {
|
|
568
|
-
const params: Record<string, unknown> = { ...rawParams };
|
|
569
|
-
const action = params.action;
|
|
570
|
-
// Name resolution must search regardless of project scope -- a Playbook itself is
|
|
571
|
-
// commonly unscoped (e.g. a cross-repo lab-deploy playbook), so resolutionRequest
|
|
572
|
-
// uses the caller's ORIGINAL project_root (undefined unless explicitly given), never
|
|
573
|
-
// the invoke-specific default applied below -- that default is only for where the
|
|
574
|
-
// resulting TASKS land, not for finding the playbook artifact itself.
|
|
575
|
-
const resolutionRequest = { project_root: params.project_root };
|
|
576
|
-
await resolveNameFields(params, [
|
|
577
|
-
{ nameKey: "name", idKey: "id", listOperation: "playbooks.list", baseRequest: resolutionRequest },
|
|
578
|
-
{ nameKey: "parent_name", idKey: "parent_id", listOperation: "playbooks.list", baseRequest: resolutionRequest },
|
|
579
|
-
{ nameKey: "child_name", idKey: "child_id", listOperation: "playbooks.list", baseRequest: resolutionRequest },
|
|
580
|
-
{ nameKey: "dependency_name", idKey: "dependency_id", listOperation: "playbooks.list", baseRequest: resolutionRequest },
|
|
581
|
-
]);
|
|
582
|
-
// invoke ends by calling tasks.focus server-side -- that focus write must land in the
|
|
583
|
-
// SAME session scope the tasks tool reads from (ctx.sessionManager.getSessionId()),
|
|
584
|
-
// the same resolution the tasks tool itself always applies, or the entry task's focus
|
|
585
|
-
// is invisible to tasks(action=focused/active) despite invoke reporting it as focused.
|
|
586
|
-
// project_root defaults to ctx.cwd for the same reason: the tasks tool always scopes
|
|
587
|
-
// its OWN reads to ctx.cwd unless told otherwise, so an unscoped playbook-materialized
|
|
588
|
-
// task is invisible to tasks(action=focused) even with the right session -- confirmed
|
|
589
|
-
// live (the focus_set event existed with the correct sessionId, but Tasks.focused's own
|
|
590
|
-
// project-scope filter silently excluded the unscoped task from a cwd-scoped read).
|
|
591
|
-
// Applied AFTER name resolution: it must never affect finding the playbook itself.
|
|
592
|
-
if (action === "invoke") {
|
|
593
|
-
const resolvedSessionId = params.session_id ?? ctx.sessionManager.getSessionId();
|
|
594
|
-
Object.assign(params, {
|
|
595
|
-
project_root: params.project_root ?? ctx.cwd,
|
|
596
|
-
session_id: resolvedSessionId,
|
|
597
|
-
...sessionSecretField(resolvedSessionId as string),
|
|
598
|
-
});
|
|
599
|
-
}
|
|
600
|
-
if (action === "create" || action === "invoke" || action === "preview") normalizeJsonEncodedField(params, "arguments");
|
|
601
|
-
if (action === "create") {
|
|
602
|
-
const artifact = await callService<Record<string, unknown>, Artifact>("playbooks.create", params);
|
|
603
|
-
return text(`Created playbook ${artifactLine(artifact)}`, createArtifactDetails("playbooks.create", artifact));
|
|
604
|
-
}
|
|
605
|
-
if (action === "list") {
|
|
606
|
-
const rows = await callService<Record<string, unknown>, Artifact[]>("playbooks.list", params);
|
|
607
|
-
return text(rows.length ? artifactLines(rows).join("\n") : "No playbooks found.", createArtifactListDetails("playbooks.list", rows));
|
|
608
|
-
}
|
|
609
|
-
if (action === "preview") {
|
|
610
|
-
const rendered = await callService<Record<string, unknown>, string>("playbooks.preview", params);
|
|
611
|
-
return text(rendered, createPreviewDetails("playbooks.preview", "Playbook preview", rendered));
|
|
612
|
-
}
|
|
613
|
-
if (action === "invoke") {
|
|
614
|
-
const invocation = await callService<Record<string, unknown>, PlaybookInvocationResult | PlaybookMissingArguments>("playbooks.invoke", params);
|
|
615
|
-
if ("missingArguments" in invocation) {
|
|
616
|
-
const message = `Missing required argument(s): ${invocation.missingArguments.join(", ")}. Nothing was created -- ask the human for these (discuss tool, live:true), then invoke again.`;
|
|
617
|
-
return text(message, createInvocationDetails("playbooks.invoke", invocation.playbookId, { tasks: [], docs: [], rules: [], roots: [] }));
|
|
618
|
-
}
|
|
619
|
-
const nodeTitleCounts = new Map<string, number>();
|
|
620
|
-
for (const node of invocation.execution.nodes) nodeTitleCounts.set(node.title, (nodeTitleCounts.get(node.title) ?? 0) + 1);
|
|
621
|
-
const execution = invocation.execution.nodes.map((node) => (nodeTitleCounts.get(node.title) ?? 0) > 1
|
|
622
|
-
? ` [${node.state}] ${node.title} (${node.id})`
|
|
623
|
-
: ` [${node.state}] ${node.title}`).join("\n");
|
|
624
|
-
const nodeById = new Map(invocation.execution.nodes.map((node) => [node.id, node]));
|
|
625
|
-
const rootLabels = invocation.rootTaskIds.map((id) => nodeById.get(id)?.title ?? "unknown task");
|
|
626
|
-
const entryLabel = nodeById.get(invocation.entryTaskId)?.title ?? invocation.entryTaskId;
|
|
627
|
-
const createdLabels = await artifactLabelsById([...invocation.created.docs, ...invocation.created.rules]);
|
|
628
|
-
return text([
|
|
629
|
-
`Invoked playbook run ${invocation.runId}: ${invocation.created.tasks.length} task(s), ${invocation.created.rules.length} rule(s), ${invocation.created.docs.length} doc(s) created.`,
|
|
630
|
-
`Entry task now focused: ${entryLabel}. Drive it forward with the tasks tool (start/submit/complete) -- contains/depends_on wiring auto-focuses each next step.`,
|
|
631
|
-
`Ready roots: ${rootLabels.join(", ") || "none"}.`,
|
|
632
|
-
`Context docs: ${invocation.created.docs.map((id) => createdLabels.get(id) ?? "unknown document").join(", ") || "none"}.`,
|
|
633
|
-
`Scoped rules: ${invocation.created.rules.map((id) => createdLabels.get(id) ?? "unknown rule").join(", ") || "none"}.`,
|
|
634
|
-
...(execution ? ["Execution:", execution] : []),
|
|
635
|
-
].join("\n"), createInvocationDetails("playbooks.invoke", invocation.runId, {
|
|
636
|
-
tasks: invocation.created.tasks,
|
|
637
|
-
docs: invocation.created.docs,
|
|
638
|
-
rules: invocation.created.rules,
|
|
639
|
-
roots: invocation.rootTaskIds,
|
|
640
|
-
}));
|
|
641
|
-
}
|
|
642
|
-
const trashResult = await handleArtifactRemoveRestore(action, params);
|
|
643
|
-
if (trashResult) return trashResult;
|
|
644
|
-
const operations = {
|
|
645
|
-
show: "playbooks.show", enable: "playbooks.enable", disable: "playbooks.disable", assign_project: "playbooks.assign_project", update: "playbooks.update",
|
|
646
|
-
contain: "playbooks.contain", uncontain: "playbooks.uncontain", depend: "playbooks.depend", undepend: "playbooks.undepend",
|
|
647
|
-
} as const;
|
|
648
|
-
const operation = operations[action as keyof typeof operations];
|
|
649
|
-
if (!operation) throw new Error(`unknown playbooks action: ${action}`);
|
|
650
|
-
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
651
|
-
return text(`${artifactLine(artifact)}${action === "show" ? `\n\n${artifact.body}` : ""}`, createArtifactDetails(operation, artifact));
|
|
652
|
-
} catch (error) {
|
|
653
|
-
throw new Error(`playbooks failed: ${error instanceof Error ? error.message : error}`);
|
|
654
|
-
}
|
|
655
|
-
},
|
|
656
|
-
});
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
export function registerSkillsTool(pi: ExtensionAPI): void {
|
|
660
|
-
pi.registerTool({
|
|
661
|
-
name: "skills",
|
|
662
|
-
label: "Skills",
|
|
663
|
-
description: "Papyrus Skill workflow and compatibility-template domain tool. Papyrus Skills are parameterized Task/Rule/Doc bundles, distinct from prompt-only skills. ACTIONS: create, create_template, list, show, invoke, run, enable, disable, instantiate, assign_project, update, remove, remove_subtree, restore. run validates arguments and atomically creates one scoped workflow run. project_root is optional at creation (omitted = unscoped) for create/create_template; assign_project reassigns it later, or unscopes when project_root is omitted. update changes title/body/labels (at least one required) and is refused for a read-only external projection. remove moves a Skill to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline; remove_subtree extends this to a whole `contains` subtree in one call. PREFER `name` (the skill's exact title) over `id`, and `template_name` over `template_id` for instantiate -- both are backend implementation details, resolved from name automatically.",
|
|
664
|
-
parameters: Type.Object({
|
|
665
|
-
action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
|
|
666
|
-
body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
|
|
667
|
-
tools: Type.Optional(Type.Array(Type.String())), definition: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
668
|
-
arguments: Type.Optional(Type.Record(Type.String(), Type.Unknown())), run_id: Type.Optional(Type.String()),
|
|
669
|
-
labels: Type.Optional(Type.Array(Type.String())),
|
|
670
|
-
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
|
|
671
|
-
text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), template_id: Type.Optional(Type.String()),
|
|
672
|
-
template_name: Type.Optional(Type.String()),
|
|
673
|
-
target_kind: Type.Optional(Type.String()), defaults: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
674
|
-
required: Type.Optional(Type.Array(Type.String())), kind: Type.Optional(Type.String()), subtype: Type.Optional(Type.String()),
|
|
675
|
-
project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
|
|
676
|
-
}),
|
|
677
|
-
renderCall(args, theme) { return renderPapyrusToolCall("Skills", args, theme); },
|
|
678
|
-
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
679
|
-
async execute(_id, rawParams, _signal, _onUpdate, ctx) {
|
|
680
|
-
try {
|
|
681
|
-
const params: Record<string, unknown> = { ...rawParams };
|
|
682
|
-
const action = params.action;
|
|
683
|
-
const request = { ...params, project_root: params.project_root ?? ctx.cwd };
|
|
684
|
-
await resolveNameFields(params, [
|
|
685
|
-
{ nameKey: "name", idKey: "id", listOperation: "skills.list", baseRequest: { project_root: params.project_root } },
|
|
686
|
-
{ nameKey: "template_name", idKey: "template_id", listOperation: "skills.list", baseRequest: { project_root: params.project_root } },
|
|
687
|
-
]);
|
|
688
|
-
if (action === "create" || action === "create_template") {
|
|
689
|
-
const operation = action === "create" ? "skills.create" : "skills.create_template";
|
|
690
|
-
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
691
|
-
return text(`Created skill ${artifactLine(artifact)}`, createArtifactDetails(operation, artifact));
|
|
692
|
-
}
|
|
693
|
-
if (action === "list") {
|
|
694
|
-
const rows = await callService<Record<string, unknown>, Artifact[]>("skills.list", params);
|
|
695
|
-
return text(rows.length ? artifactLines(rows).join("\n") : "No skills found.", createArtifactListDetails("skills.list", rows));
|
|
696
|
-
}
|
|
697
|
-
if (action === "invoke") {
|
|
698
|
-
const invocation = await callService<Record<string, unknown>, string>("skills.invoke", params);
|
|
699
|
-
return text(invocation, createPreviewDetails("skills.invoke", "Skill invocation", invocation));
|
|
700
|
-
}
|
|
701
|
-
if (action === "run") {
|
|
702
|
-
const run = await callService<Record<string, unknown>, WorkflowRunResult>("skills.run", request);
|
|
703
|
-
const runTitleCounts = new Map<string, number>();
|
|
704
|
-
for (const node of run.execution.nodes) runTitleCounts.set(node.title, (runTitleCounts.get(node.title) ?? 0) + 1);
|
|
705
|
-
const execution = run.execution.nodes.map((node) => (runTitleCounts.get(node.title) ?? 0) > 1
|
|
706
|
-
? ` [${node.state}] ${node.title} (${node.id})`
|
|
707
|
-
: ` [${node.state}] ${node.title}`).join("\n");
|
|
708
|
-
const nodeById = new Map(run.execution.nodes.map((node) => [node.id, node]));
|
|
709
|
-
const rootLabels = run.rootTaskIds.map((id) => nodeById.get(id)?.title ?? "unknown task");
|
|
710
|
-
const createdLabels = await artifactLabelsById([...run.created.docs, ...run.created.rules]);
|
|
711
|
-
return text([
|
|
712
|
-
`Created Skill run ${run.runId}: ${run.created.tasks.length} tasks, ${run.created.rules.length} rules, ${run.created.docs.length} docs.`,
|
|
713
|
-
`Ready roots: ${rootLabels.join(", ") || "none"}.`,
|
|
714
|
-
`Context docs: ${run.created.docs.map((id) => createdLabels.get(id) ?? "unknown document").join(", ") || "none"}.`,
|
|
715
|
-
`Scoped rules: ${run.created.rules.map((id) => createdLabels.get(id) ?? "unknown rule").join(", ") || "none"}.`,
|
|
716
|
-
...(execution ? ["Execution:", execution] : []),
|
|
717
|
-
].join("\n"), createInvocationDetails("skills.run", run.runId, {
|
|
718
|
-
tasks: run.created.tasks,
|
|
719
|
-
docs: run.created.docs,
|
|
720
|
-
rules: run.created.rules,
|
|
721
|
-
roots: run.rootTaskIds,
|
|
722
|
-
}));
|
|
723
|
-
}
|
|
724
|
-
const trashResult = await handleArtifactRemoveRestore(action, params);
|
|
725
|
-
if (trashResult) return trashResult;
|
|
726
|
-
const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate", assign_project: "skills.assign_project", update: "skills.update" } as const;
|
|
727
|
-
const operation = operations[action as keyof typeof operations];
|
|
728
|
-
if (!operation) throw new Error(`unknown skills action: ${action}`);
|
|
729
|
-
const artifact = await callService<Record<string, unknown>, Artifact>(operation, action === "instantiate" ? request : params);
|
|
730
|
-
return text(`${artifactLine(artifact)}${action === "show" ? `\n\n${artifact.body}` : ""}`, createArtifactDetails(operation, artifact));
|
|
731
|
-
} catch (error) {
|
|
732
|
-
throw new Error(`skills failed: ${error instanceof Error ? error.message : error}`);
|
|
733
|
-
}
|
|
734
|
-
},
|
|
735
|
-
});
|
|
736
|
-
}
|
|
523
|
+
// notes.*, rules.*, docs.*, skills.*, playbooks.*, and the shared artifact.* are
|
|
524
|
+
// registered as Vehicles (see ../vehicle-notes-client.ts and @danypops/papyrus's
|
|
525
|
+
// src/vehicle/papyrus-vehicle.ts), not pi.registerTool()s in this file.
|
|
737
526
|
|
|
738
527
|
export function registerDiscussTool(pi: ExtensionAPI): void {
|
|
739
528
|
pi.registerTool({
|
|
@@ -838,13 +627,11 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
|
|
|
838
627
|
}
|
|
839
628
|
|
|
840
629
|
/** Thin orchestrator: each domain's tool is independently navigable/testable via its own registerXTool function. */
|
|
841
|
-
// docs and
|
|
842
|
-
// (registerNotesVehicle in vehicle-notes-client.ts, wired at session_start in
|
|
843
|
-
//
|
|
844
|
-
//
|
|
630
|
+
// notes, rules, docs, skills, and playbooks are no longer registered here -- all migrated onto
|
|
631
|
+
// Vehicle (registerNotesVehicle in vehicle-notes-client.ts, wired at session_start in index.ts),
|
|
632
|
+
// replacing their own pi.registerTool() mega-tools. See @danypops/papyrus's
|
|
633
|
+
// src/vehicle/papyrus-vehicle.ts for the server side.
|
|
845
634
|
export function registerDomainTools(pi: ExtensionAPI): void {
|
|
846
635
|
registerTasksTool(pi);
|
|
847
|
-
registerPlaybooksTool(pi);
|
|
848
|
-
registerSkillsTool(pi);
|
|
849
636
|
registerDiscussTool(pi);
|
|
850
637
|
}
|
package/extension/src/index.ts
CHANGED
|
@@ -37,9 +37,9 @@ import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanI
|
|
|
37
37
|
import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
|
|
38
38
|
import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
|
|
39
39
|
import { buildContextInjection } from "./context-injection-telemetry.ts";
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
40
|
+
import { buildTaskItemTree, computeContextBudget } from "./context-budget.ts";
|
|
41
|
+
import { PAPYRUS_CONTEXT_HUB_PRODUCER_NAME, papyrusContextSegment } from "./context-hub-contribution.ts";
|
|
42
|
+
import { CONTEXT_DEFAULT_RESERVE_TOKENS, CONTEXT_HUB_CONTRIBUTION_CHANNEL, CONTEXT_HUB_CONTRIBUTION_SCHEMA } from "@danypops/jittor";
|
|
43
43
|
import { emitTaskFocusEvent, setTaskFocusEventBus } from "./task-focus-events.ts";
|
|
44
44
|
import { cacheSessionSecret, forgetSessionSecret, sessionSecretField } from "./session-identity.ts";
|
|
45
45
|
import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
|
|
@@ -327,15 +327,11 @@ export default async function (pi: ExtensionAPI) {
|
|
|
327
327
|
const contextInjectionProducerId = randomUUID();
|
|
328
328
|
let previousContextInjectionFingerprint: string | undefined;
|
|
329
329
|
let logTurnSequence = 0;
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
// loaded without re-discovering resources", per Pi's own doc comment) -- no new hook, no new
|
|
336
|
-
// risk, just reading a field before_agent_start already hands over.
|
|
337
|
-
let lastObservedBasePromptTokens: number | null = null;
|
|
338
|
-
let lastObservedBasePromptItems: ContextSegmentItem[] = [];
|
|
330
|
+
// Papyrus's own Context Hub contribution (rules/tasks/skills, bundled into one segment --
|
|
331
|
+
// see context-hub-contribution.ts) re-emits every turn alongside the existing injection
|
|
332
|
+
// observation, its own independent monotonic sequence, same cadence and shape as
|
|
333
|
+
// contextInjectionSequence but on a different channel/schema.
|
|
334
|
+
let contextHubContributionSequence = 0;
|
|
339
335
|
const taskContinuation = new ActiveTaskContinuation({
|
|
340
336
|
maxTurns: TASK_DRIVER_MAX_TURNS,
|
|
341
337
|
maxUnchangedTurns: TASK_DRIVER_MAX_UNCHANGED_TURNS,
|
|
@@ -388,7 +384,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
388
384
|
if (!usage || usage.tokens === null) return; // nothing real to report yet (e.g. before the first assistant turn, or right after compaction)
|
|
389
385
|
const totalTokens = usage.tokens;
|
|
390
386
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
391
|
-
const effectiveBudget = Math.max(0, usage.contextWindow -
|
|
387
|
+
const effectiveBudget = Math.max(0, usage.contextWindow - CONTEXT_DEFAULT_RESERVE_TOKENS);
|
|
392
388
|
const percentOfBudget = effectiveBudget > 0 ? Math.round((totalTokens / effectiveBudget) * 1000) / 10 : null;
|
|
393
389
|
await callService("logs.append", {
|
|
394
390
|
source_id: PI_SESSION_CONTEXT_LOG_SOURCE,
|
|
@@ -628,52 +624,6 @@ export default async function (pi: ExtensionAPI) {
|
|
|
628
624
|
description: "Browse Papyrus Discussions and reply, defer, resume, settle, or block/unblock a task (interactive)",
|
|
629
625
|
handler: async (_args, ctx) => { await discussModule.showDiscussions(ctx); },
|
|
630
626
|
});
|
|
631
|
-
pi.registerCommand("context", {
|
|
632
|
-
description: "Structured, per-segment breakdown of the context window: real usage against the model's window, drilling into Papyrus Rules and the Pi-native skill catalog",
|
|
633
|
-
handler: async (_args, ctx) => {
|
|
634
|
-
try {
|
|
635
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
636
|
-
const [rules, taskGraph] = await Promise.all([
|
|
637
|
-
callService<Record<string, unknown>, Array<Pick<Artifact, "id" | "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd, session_id: sessionId }),
|
|
638
|
-
callService<Record<string, unknown>, TaskGraph>("tasks.graph", { project_root: ctx.cwd, session_id: sessionId }),
|
|
639
|
-
]);
|
|
640
|
-
const { skills } = computeContextBudget(rules, ctx.cwd);
|
|
641
|
-
const ruleBudget = computeRuleBudget(rules);
|
|
642
|
-
const usage = ctx.getContextUsage?.();
|
|
643
|
-
// Real tree (not just the linear current-branch path): surfaces content sitting in an
|
|
644
|
-
// abandoned /tree branch, which cost real tokens to generate but isn't in context now.
|
|
645
|
-
const tree = ctx.sessionManager.getTree() as SessionTreeNodeLike[];
|
|
646
|
-
// buildContextEntries(), NOT getBranch(): getBranch() returns every raw entry on the
|
|
647
|
-
// current path including everything a real compaction has already summarized away.
|
|
648
|
-
// A session with 3 real compactions confirmed this made "active" message-history
|
|
649
|
-
// tokens overcount the real total by over 13x -- getBranch()'s own docstring already
|
|
650
|
-
// says as much ("Use buildSessionContext() to get the resolved messages for the
|
|
651
|
-
// LLM"); buildContextEntries() is the compaction-aware entry list matching what the
|
|
652
|
-
// LLM actually sees (the latest compaction entry itself, plus kept entries from its
|
|
653
|
-
// firstKeptEntryId onward, plus everything after -- older summarized entries omitted).
|
|
654
|
-
const activeEntryIds = new Set((ctx.sessionManager.buildContextEntries() as SessionEntryLike[]).map((entry) => entry.id));
|
|
655
|
-
const branchEntryIds = new Set((ctx.sessionManager.getBranch() as SessionEntryLike[]).map((entry) => entry.id));
|
|
656
|
-
const messageHistory = buildMessageHistoryTree(tree, activeEntryIds, branchEntryIds);
|
|
657
|
-
const activeToolNames = new Set(pi.getActiveTools());
|
|
658
|
-
const toolDefinitionItems = buildToolDefinitionItems(pi.getAllTools().filter((tool) => activeToolNames.has(tool.name)));
|
|
659
|
-
const breakdown = buildContextBreakdown({
|
|
660
|
-
totalTokens: usage?.tokens ?? null,
|
|
661
|
-
contextWindow: ctx.model?.contextWindow ?? null,
|
|
662
|
-
ruleBudget,
|
|
663
|
-
taskItems: buildTaskItemTree(taskGraph),
|
|
664
|
-
skills,
|
|
665
|
-
basePromptEstimatedTokens: lastObservedBasePromptTokens,
|
|
666
|
-
basePromptItems: lastObservedBasePromptItems,
|
|
667
|
-
toolDefinitionItems,
|
|
668
|
-
messageHistoryItems: messageHistory.items,
|
|
669
|
-
messageHistoryActiveTokens: messageHistory.activeTokens,
|
|
670
|
-
});
|
|
671
|
-
await showContextView(ctx, breakdown);
|
|
672
|
-
} catch (error) {
|
|
673
|
-
ctx.ui.notify(`Context breakdown failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
674
|
-
}
|
|
675
|
-
},
|
|
676
|
-
});
|
|
677
627
|
|
|
678
628
|
// ── Task widget (TodoOverlay pattern: factory form, requestRender) ──
|
|
679
629
|
|
|
@@ -780,12 +730,14 @@ export default async function (pi: ExtensionAPI) {
|
|
|
780
730
|
// tasks, they're explicitly called out — the agent should address them.
|
|
781
731
|
|
|
782
732
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
733
|
+
let result: { systemPrompt: string } | undefined;
|
|
783
734
|
try {
|
|
784
735
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
785
|
-
const [rules, playbooks, summary] = await Promise.all([
|
|
786
|
-
callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd, session_id: sessionId }),
|
|
736
|
+
const [rules, playbooks, summary, taskGraph] = await Promise.all([
|
|
737
|
+
callService<Record<string, unknown>, Array<Pick<Artifact, "id" | "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd, session_id: sessionId }),
|
|
787
738
|
callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "extra">>>("playbooks.list", { status: "active", limit: PLAYBOOK_BRIDGE_MAX_PLAYBOOKS }),
|
|
788
739
|
callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd, session_id: sessionId, verbosity: "summary" }),
|
|
740
|
+
callService<Record<string, unknown>, TaskGraph>("tasks.graph", { project_root: ctx.cwd, session_id: sessionId }),
|
|
789
741
|
]);
|
|
790
742
|
const injection = buildContextInjection({
|
|
791
743
|
basePrompt: event.systemPrompt ?? "",
|
|
@@ -798,12 +750,25 @@ export default async function (pi: ExtensionAPI) {
|
|
|
798
750
|
previousFingerprint: previousContextInjectionFingerprint,
|
|
799
751
|
});
|
|
800
752
|
previousContextInjectionFingerprint = injection.observation.fingerprint;
|
|
801
|
-
lastObservedBasePromptTokens = Math.ceil(injection.observation.before.characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
|
|
802
|
-
lastObservedBasePromptItems = buildBasePromptItems(event.systemPromptOptions, injection.observation.before.characters);
|
|
803
753
|
pi.events.emit(PAPYRUS_CONTEXT_INJECTION_CHANNEL, injection.observation);
|
|
804
|
-
if (injection.prompt !== (event.systemPrompt ?? ""))
|
|
754
|
+
if (injection.prompt !== (event.systemPrompt ?? "")) result = { systemPrompt: injection.prompt };
|
|
755
|
+
// Context Hub contribution is best-effort observability for /context -- its own failure
|
|
756
|
+
// must never block this turn's actual rules/tasks injection above.
|
|
757
|
+
try {
|
|
758
|
+
const { rules: ruleBudget, skills } = computeContextBudget(rules, ctx.cwd);
|
|
759
|
+
pi.events.emit(CONTEXT_HUB_CONTRIBUTION_CHANNEL, {
|
|
760
|
+
schema: CONTEXT_HUB_CONTRIBUTION_SCHEMA,
|
|
761
|
+
observedAt: Date.now(),
|
|
762
|
+
sequence: ++contextHubContributionSequence,
|
|
763
|
+
producerName: PAPYRUS_CONTEXT_HUB_PRODUCER_NAME,
|
|
764
|
+
segment: papyrusContextSegment(ruleBudget, buildTaskItemTree(taskGraph), skills),
|
|
765
|
+
});
|
|
766
|
+
} catch {
|
|
767
|
+
// Malformed/unreachable daemon data for this turn's contribution -- drop it silently.
|
|
768
|
+
}
|
|
805
769
|
} catch {
|
|
806
770
|
// DB not ready
|
|
807
771
|
}
|
|
772
|
+
return result;
|
|
808
773
|
});
|
|
809
774
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Registers every Vehicle-projected domain (notes.*, rules.*, docs.*,
|
|
3
|
-
* as real Pi tools -- see @danypops/papyrus's
|
|
2
|
+
* Registers every Vehicle-projected domain (notes.*, rules.*, docs.*, skills.*,
|
|
3
|
+
* playbooks.*, artifact.*) as real Pi tools -- see @danypops/papyrus's
|
|
4
|
+
* src/vehicle/papyrus-vehicle.ts.
|
|
4
5
|
*
|
|
5
6
|
* Fails silently on a stale/unreachable daemon handle instead of aborting extension
|
|
6
7
|
* setup: Papyrus's daemon doesn't auto-spawn, and a tool that failed to register
|
|
@@ -14,8 +15,12 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
14
15
|
import { RemoteVehicleClient } from "@danypops/vehicle-client/http";
|
|
15
16
|
import { registerVehicleTools } from "@danypops/vehicle-client-pi";
|
|
16
17
|
import { currentVehicleClientTarget } from "./service-client.ts";
|
|
18
|
+
import { sessionSecretField } from "./session-identity.ts";
|
|
17
19
|
|
|
18
|
-
const REGISTERED_PERMISSIONS = [
|
|
20
|
+
const REGISTERED_PERMISSIONS = [
|
|
21
|
+
"notes:read", "notes:write", "rules:read", "rules:write", "docs:read", "docs:write",
|
|
22
|
+
"skills:read", "skills:write", "playbooks:read", "playbooks:write", "artifact:read", "artifact:write",
|
|
23
|
+
];
|
|
19
24
|
|
|
20
25
|
export async function registerNotesVehicle(pi: ExtensionAPI): Promise<void> {
|
|
21
26
|
const target = currentVehicleClientTarget();
|
|
@@ -25,6 +30,25 @@ export async function registerNotesVehicle(pi: ExtensionAPI): Promise<void> {
|
|
|
25
30
|
await registerVehicleTools(pi, client, {
|
|
26
31
|
permissions: REGISTERED_PERMISSIONS,
|
|
27
32
|
principal: { id: "pi-papyrus" },
|
|
33
|
+
// playbooks.invoke's own module handler authorizes an internal Task Focus write via
|
|
34
|
+
// sessionIdentity.assertAuthorized(session_id, session_secret) -- see
|
|
35
|
+
// @danypops/papyrus's src/vehicle/playbooks-vehicle.ts. That secret must never be a
|
|
36
|
+
// model-visible input field (the model has no business knowing or supplying it), so
|
|
37
|
+
// it travels here instead, in principal.claims, from this extension's own already-
|
|
38
|
+
// cached secret (registered at session_start -- see index.ts) -- the same value
|
|
39
|
+
// sessionSecretField() used to thread through as a raw RPC input field before this
|
|
40
|
+
// operation moved onto Vehicle.
|
|
41
|
+
resolveInvocation: ({ descriptor, context }) => {
|
|
42
|
+
if (descriptor.name !== "playbooks.invoke") return {};
|
|
43
|
+
const sessionId = context.sessionManager.getSessionId();
|
|
44
|
+
const { session_secret: sessionSecret } = sessionSecretField(sessionId);
|
|
45
|
+
// Omit sessionSecret entirely when nothing is cached (unregistered session) --
|
|
46
|
+
// {sessionSecret: null} would fail the module's own optionalString(input,
|
|
47
|
+
// "session_secret") check (undefined-or-string, not null), a real regression from
|
|
48
|
+
// sessionSecretField()'s own {} (key omitted) return for the same case.
|
|
49
|
+
const claims: Record<string, string> = sessionSecret ? { sessionId, sessionSecret } : { sessionId };
|
|
50
|
+
return { principal: { id: "pi-papyrus", claims } };
|
|
51
|
+
},
|
|
28
52
|
});
|
|
29
53
|
} catch {
|
|
30
54
|
// Daemon state is stale/unreachable -- degrade silently, matching
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-papyrus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.0",
|
|
4
4
|
"description": "Pi host extension for Papyrus: native tools, TUI panels, and context injection over the daemon-backed graph store",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": ["pi-package"],
|
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
"typebox": "*"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@danypops/
|
|
21
|
-
"@danypops/
|
|
20
|
+
"@danypops/jittor": "^0.14.0",
|
|
21
|
+
"@danypops/papyrus": "^0.40.0",
|
|
22
|
+
"@danypops/vehicle-core": "^0.2.0",
|
|
22
23
|
"@danypops/vehicle-server": "^0.1.1",
|
|
23
24
|
"@danypops/vehicle-client": "^0.1.1",
|
|
24
|
-
"@danypops/vehicle-client-pi": "^0.
|
|
25
|
+
"@danypops/vehicle-client-pi": "^0.2.0",
|
|
25
26
|
"beautiful-mermaid": "1.1.3",
|
|
26
27
|
"malevich-tui-components": "^0.5.0"
|
|
27
28
|
},
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import type { BuildSystemPromptOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN } from "@danypops/papyrus";
|
|
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
|
-
}
|
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
import type { ExtensionCommandContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { matchesKey, truncateToWidth, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
-
import type { ContextBreakdown, ContextSegment, ContextSegmentItem } from "./context-budget.ts";
|
|
4
|
-
|
|
5
|
-
const VISIBLE_ROWS = 24;
|
|
6
|
-
|
|
7
|
-
const SEGMENT_COLORS: Record<ContextSegment["key"], ThemeColor> = {
|
|
8
|
-
rules: "accent",
|
|
9
|
-
tasks: "success",
|
|
10
|
-
skills: "mdLink",
|
|
11
|
-
basePrompt: "warning",
|
|
12
|
-
messageHistory: "syntaxFunction",
|
|
13
|
-
toolDefinitions: "syntaxKeyword",
|
|
14
|
-
other: "muted",
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* One row in the unified scrollable view. Every segment that has any real (nonzero) content
|
|
19
|
-
* is fully expanded inline -- there is no separate "select a segment, then drill in" step.
|
|
20
|
-
* `key` drives this row's color; `isHeader` distinguishes a segment's own summary line from
|
|
21
|
-
* its item rows underneath it.
|
|
22
|
-
*/
|
|
23
|
-
export interface ContextRow {
|
|
24
|
-
key: ContextSegment["key"];
|
|
25
|
-
isHeader: boolean;
|
|
26
|
-
text: string;
|
|
27
|
-
/** Nesting depth for indentation -- 0 for a segment header or a top-level item, deeper for real tree children (message history branches, Task containment). */
|
|
28
|
-
depth: number;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function formatTokenCount(tokens: number): string {
|
|
32
|
-
return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function percentOf(part: number, whole: number): string {
|
|
36
|
-
return whole > 0 ? `${((part / whole) * 100).toFixed(1)}%` : "—";
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Flattens every segment with real content into one linear row list, filtering out anything
|
|
41
|
-
* that is genuinely zero rather than displaying a misleading "0 tok 0.0%" row -- a segment or
|
|
42
|
-
* item with literally nothing in it carries no information and is pure noise in a scrollable
|
|
43
|
-
* view meant to show where tokens actually go. A segment whose OWN total is zero but whose
|
|
44
|
-
* items are also all zero is dropped entirely; a segment with a nonzero total is always kept
|
|
45
|
-
* even if all its items individually round to zero (the total itself is real signal).
|
|
46
|
-
*/
|
|
47
|
-
/** Recursively flattens one item and its real tree children (message history branches, Task containment) into indented rows, sorted biggest-first at each level -- a parent always immediately precedes its own children, never scrambled by a global sort. */
|
|
48
|
-
function flattenItem(item: ContextSegmentItem, key: ContextSegment["key"], depth: number, rows: ContextRow[]): void {
|
|
49
|
-
rows.push({ key, isHeader: false, depth, text: `${item.estimatedTokens.toString().padStart(6)} tok ${item.label}` });
|
|
50
|
-
const children = (item.children ?? []).filter((child) => child.estimatedTokens > 0).sort((a, b) => b.estimatedTokens - a.estimatedTokens);
|
|
51
|
-
for (const child of children) flattenItem(child, key, depth + 1, rows);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function buildContextRows(breakdown: ContextBreakdown): ContextRow[] {
|
|
55
|
-
const rows: ContextRow[] = [];
|
|
56
|
-
const denominator = breakdown.totalTokens ?? breakdown.segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
|
|
57
|
-
for (const segment of breakdown.segments) {
|
|
58
|
-
const items = (segment.items ?? []).filter((item) => item.estimatedTokens > 0).sort((a, b) => b.estimatedTokens - a.estimatedTokens);
|
|
59
|
-
// A genuinely-unknown segment (basePrompt before the first observed turn) must stay
|
|
60
|
-
// visible even when its placeholder value is zero -- hiding it would misrepresent
|
|
61
|
-
// "not measured yet" as "measured and empty", the same honesty problem overshootTokens
|
|
62
|
-
// exists to prevent for the unaccounted bucket.
|
|
63
|
-
if (segment.estimatedTokens <= 0 && items.length === 0 && !segment.unknown) continue;
|
|
64
|
-
rows.push({
|
|
65
|
-
key: segment.key,
|
|
66
|
-
isHeader: true,
|
|
67
|
-
depth: 0,
|
|
68
|
-
text: `${segment.label} — ${segment.estimatedTokens} tok (${percentOf(segment.estimatedTokens, denominator)})`,
|
|
69
|
-
});
|
|
70
|
-
for (const item of items) flattenItem(item, segment.key, 1, rows);
|
|
71
|
-
}
|
|
72
|
-
return rows;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
class ContextViewport {
|
|
76
|
-
private offsetY = 0;
|
|
77
|
-
private readonly rows: ContextRow[];
|
|
78
|
-
|
|
79
|
-
constructor(
|
|
80
|
-
private readonly tui: TUI,
|
|
81
|
-
private readonly theme: Theme,
|
|
82
|
-
private readonly breakdown: ContextBreakdown,
|
|
83
|
-
private readonly close: () => void,
|
|
84
|
-
) {
|
|
85
|
-
this.rows = buildContextRows(breakdown);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
invalidate(): void {}
|
|
89
|
-
|
|
90
|
-
render(width: number): string[] {
|
|
91
|
-
const theme = this.theme;
|
|
92
|
-
const contentWidth = Math.max(1, width);
|
|
93
|
-
const border = theme.fg("borderMuted", "─".repeat(contentWidth));
|
|
94
|
-
const lines: string[] = [border, truncateToWidth(theme.fg("accent", theme.bold("Context")), contentWidth, "")];
|
|
95
|
-
|
|
96
|
-
if (this.breakdown.totalTokens !== null && this.breakdown.effectiveBudget !== null) {
|
|
97
|
-
const percent = percentOf(this.breakdown.totalTokens, this.breakdown.effectiveBudget);
|
|
98
|
-
lines.push(truncateToWidth(
|
|
99
|
-
`${formatTokenCount(this.breakdown.totalTokens)} / ${formatTokenCount(this.breakdown.effectiveBudget)} tokens (${percent} of usable budget)`,
|
|
100
|
-
contentWidth,
|
|
101
|
-
"",
|
|
102
|
-
));
|
|
103
|
-
} else if (this.breakdown.totalTokens !== null) {
|
|
104
|
-
lines.push(truncateToWidth(`${formatTokenCount(this.breakdown.totalTokens)} tokens (model context window unknown)`, contentWidth, ""));
|
|
105
|
-
} else {
|
|
106
|
-
lines.push(theme.fg("dim", "No real usage reported yet — sizes below are Papyrus's own estimates only"));
|
|
107
|
-
}
|
|
108
|
-
lines.push(renderContextBar(theme, this.breakdown.segments, contentWidth, this.breakdown.effectiveBudget ?? undefined, this.breakdown.totalTokens ?? undefined));
|
|
109
|
-
if (this.breakdown.overshootTokens > 0) {
|
|
110
|
-
lines.push(truncateToWidth(theme.fg("warning", `Estimates exceed real total by ~${this.breakdown.overshootTokens} tok — sizes below are approximate, not exact`), contentWidth, ""));
|
|
111
|
-
}
|
|
112
|
-
lines.push("");
|
|
113
|
-
|
|
114
|
-
this.visibleWindow().forEach(({ row, index }) => {
|
|
115
|
-
const gutter = theme.fg(SEGMENT_COLORS[row.key], "▌");
|
|
116
|
-
const indent = " ".repeat(row.depth);
|
|
117
|
-
const text = row.isHeader ? theme.bold(row.text) : `${indent}${row.text}`;
|
|
118
|
-
lines.push(truncateToWidth(`${gutter} ${text}`, contentWidth, ""));
|
|
119
|
-
void index;
|
|
120
|
-
});
|
|
121
|
-
if (this.rows.length === 0) lines.push(theme.fg("dim", " (nothing observed yet)"));
|
|
122
|
-
else lines.push(theme.fg("muted", ` ${Math.min(this.offsetY + VISIBLE_ROWS, this.rows.length)}/${this.rows.length}`));
|
|
123
|
-
|
|
124
|
-
lines.push("");
|
|
125
|
-
lines.push(theme.fg("dim", "↑↓ scroll · esc close"));
|
|
126
|
-
lines.push(border);
|
|
127
|
-
return lines;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
private visibleWindow(): Array<{ row: ContextRow; index: number }> {
|
|
131
|
-
const end = Math.min(this.offsetY + VISIBLE_ROWS, this.rows.length);
|
|
132
|
-
const result: Array<{ row: ContextRow; index: number }> = [];
|
|
133
|
-
for (let index = this.offsetY; index < end; index++) result.push({ row: this.rows[index]!, index });
|
|
134
|
-
return result;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
handleInput(data: string): void {
|
|
138
|
-
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); return; }
|
|
139
|
-
if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
|
|
140
|
-
else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.rows.length - VISIBLE_ROWS), this.offsetY + 1);
|
|
141
|
-
else return;
|
|
142
|
-
this.tui.requestRender();
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Distributes `totalCells` proportionally across `weights` (parallel arrays), guaranteeing
|
|
148
|
-
* every genuinely-positive weight gets at least one cell when there is room for all of them
|
|
149
|
-
* to (totalCells >= weights.length) -- a real, nonzero segment must stay visible even when
|
|
150
|
-
* dwarfed by a much larger one, not round away to nothing. The largest resulting cell count
|
|
151
|
-
* absorbs whatever rounding leaves over or short, so the sum always equals totalCells exactly.
|
|
152
|
-
*/
|
|
153
|
-
function distributeCells(weights: readonly number[], totalCells: number): number[] {
|
|
154
|
-
const sum = weights.reduce((a, b) => a + b, 0);
|
|
155
|
-
if (sum <= 0 || totalCells <= 0 || weights.length === 0) return weights.map(() => 0);
|
|
156
|
-
let cells = weights.map((weight) => Math.round((weight / sum) * totalCells));
|
|
157
|
-
if (totalCells >= weights.length) cells = cells.map((count) => (count === 0 ? 1 : count));
|
|
158
|
-
const diff = totalCells - cells.reduce((a, b) => a + b, 0);
|
|
159
|
-
if (diff !== 0) {
|
|
160
|
-
const maxIndex = cells.indexOf(Math.max(...cells));
|
|
161
|
-
cells[maxIndex] = (cells[maxIndex] ?? 0) + diff;
|
|
162
|
-
}
|
|
163
|
-
return cells;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Renders the context window as one horizontal stacked bar: one colored run of block
|
|
168
|
-
* characters per USED segment, followed by a gray/dim run of "░" cells for the remaining,
|
|
169
|
-
* genuinely EMPTY context window -- this is the "total used vs. unused" graph. A zero-token
|
|
170
|
-
* breakdown (nothing observed yet) renders an entirely gray/dim track rather than a
|
|
171
|
-
* divide-by-zero, since 0 used really does mean the whole window is empty right now.
|
|
172
|
-
*
|
|
173
|
-
* `capacity` is the real denominator (Papyrus's own effectiveBudget, matching the percentage
|
|
174
|
-
* already shown in the text line above this bar). `usedTokens` is the real, ground-truth used
|
|
175
|
-
* amount (breakdown.totalTokens) the used-vs-unused split is measured against -- NOT the sum of
|
|
176
|
-
* `segments`' own estimates. That distinction is load-bearing: a live-reported bug showed a
|
|
177
|
-
* fully solid bar with zero gray even though the header read "55.9% of usable budget", because
|
|
178
|
-
* the old code compared `capacity` against the SUM of estimated segments, which independently
|
|
179
|
-
* overshot both the real total and the capacity itself (a session whose message-history
|
|
180
|
-
* estimate alone summed to over 1.5M tokens against a real ~550k total) -- the exact estimate-
|
|
181
|
-
* overshoot dishonesty `overshootTokens` exists to surface elsewhere was silently defeating the
|
|
182
|
-
* bar's own gray/used split. `usedTokens` defaults to the segment sum only when omitted, for
|
|
183
|
-
* callers with no real total available. Segments still split the USED portion proportionally to
|
|
184
|
-
* their own estimated share of each other (via distributeCells, which also guarantees a tiny
|
|
185
|
-
* nonzero segment stays visible rather than rounding to nothing next to a much larger one).
|
|
186
|
-
*/
|
|
187
|
-
export function renderContextBar(theme: Theme, segments: ReadonlyArray<ContextSegment>, width: number, capacity?: number, usedTokens?: number): string {
|
|
188
|
-
const estimatedSum = segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
|
|
189
|
-
if (estimatedSum <= 0 || width <= 0) return theme.fg("dim", "░".repeat(Math.max(0, width)));
|
|
190
|
-
const realUsed = usedTokens ?? estimatedSum;
|
|
191
|
-
const usedWidth = capacity !== undefined ? Math.max(0, Math.min(width, Math.round((realUsed / capacity) * width))) : width;
|
|
192
|
-
|
|
193
|
-
const nonZero = segments.filter((segment) => segment.estimatedTokens > 0);
|
|
194
|
-
const cellCounts = distributeCells(nonZero.map((segment) => segment.estimatedTokens), usedWidth);
|
|
195
|
-
let output = "";
|
|
196
|
-
nonZero.forEach((segment, index) => {
|
|
197
|
-
const cells = cellCounts[index] ?? 0;
|
|
198
|
-
if (cells > 0) output += theme.fg(SEGMENT_COLORS[segment.key], "█".repeat(cells));
|
|
199
|
-
});
|
|
200
|
-
const emptyWidth = width - usedWidth;
|
|
201
|
-
if (emptyWidth > 0) output += theme.fg("dim", "░".repeat(emptyWidth));
|
|
202
|
-
return output;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/** Non-interactive fallback (print mode, RPC, etc.): the same unified row list, as plain text lines. */
|
|
206
|
-
function fallbackReport(breakdown: ContextBreakdown): string {
|
|
207
|
-
const totalLine = breakdown.totalTokens !== null
|
|
208
|
-
? `Real usage: ${breakdown.totalTokens} tokens${breakdown.effectiveBudget !== null ? ` / ${breakdown.effectiveBudget} usable budget (${percentOf(breakdown.totalTokens, breakdown.effectiveBudget)})` : ""}`
|
|
209
|
-
: "Real usage: not yet reported";
|
|
210
|
-
const overshootLine = breakdown.overshootTokens > 0 ? [`Estimates exceed real total by ~${breakdown.overshootTokens} tok -- sizes below are approximate, not exact`] : [];
|
|
211
|
-
const rows = buildContextRows(breakdown);
|
|
212
|
-
const rowLines = rows.length > 0 ? rows.map((row) => (row.isHeader ? row.text : `${" ".repeat(row.depth)}${row.text}`)) : ["(nothing observed yet)"];
|
|
213
|
-
return [totalLine, ...overshootLine, "", ...rowLines].join("\n");
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
export async function showContextView(ctx: ExtensionCommandContext, breakdown: ContextBreakdown): Promise<void> {
|
|
217
|
-
if (ctx.mode !== "tui") {
|
|
218
|
-
ctx.ui.notify(fallbackReport(breakdown), "info");
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => new ContextViewport(tui, theme, breakdown, done));
|
|
222
|
-
}
|