@danypops/papyrus 0.34.2 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +5 -189
  2. package/package.json +8 -16
  3. package/src/artifact-relationship-view.ts +23 -0
  4. package/src/cli.ts +0 -0
  5. package/src/index.ts +32 -0
  6. package/src/task-relationship-view.ts +2 -1
  7. package/extension/src/active-task-continuation.ts +0 -131
  8. package/extension/src/artifact-browser.ts +0 -229
  9. package/extension/src/artifact-detail-format.ts +0 -31
  10. package/extension/src/artifact-detail-view.ts +0 -112
  11. package/extension/src/artifact-format.ts +0 -84
  12. package/extension/src/artifact-status-presentation.ts +0 -71
  13. package/extension/src/base-prompt-breakdown.ts +0 -55
  14. package/extension/src/beautiful-mermaid-renderer.ts +0 -68
  15. package/extension/src/bounded-poll.ts +0 -20
  16. package/extension/src/context-budget.ts +0 -503
  17. package/extension/src/context-injection-telemetry.ts +0 -88
  18. package/extension/src/context-view.ts +0 -222
  19. package/extension/src/discuss-ask-layout.ts +0 -193
  20. package/extension/src/discuss-ask-view.ts +0 -1301
  21. package/extension/src/discuss.ts +0 -134
  22. package/extension/src/discussion-detail-view.ts +0 -136
  23. package/extension/src/docs.ts +0 -58
  24. package/extension/src/domain-tools.ts +0 -886
  25. package/extension/src/index.ts +0 -776
  26. package/extension/src/markdown.ts +0 -60
  27. package/extension/src/note-widget.ts +0 -8
  28. package/extension/src/notes.ts +0 -102
  29. package/extension/src/playbook-bridge.ts +0 -91
  30. package/extension/src/playbooks.ts +0 -97
  31. package/extension/src/rules.ts +0 -51
  32. package/extension/src/service-client.ts +0 -29
  33. package/extension/src/session-identity.ts +0 -22
  34. package/extension/src/skill-catalog-footprint.ts +0 -183
  35. package/extension/src/skills.ts +0 -127
  36. package/extension/src/task-context.ts +0 -1
  37. package/extension/src/task-detail-format.ts +0 -110
  38. package/extension/src/task-detail-view.ts +0 -139
  39. package/extension/src/task-focus-events.ts +0 -57
  40. package/extension/src/task-graph.ts +0 -116
  41. package/extension/src/task-presentation.ts +0 -26
  42. package/extension/src/task-widget.ts +0 -70
  43. package/extension/src/tasks.ts +0 -418
  44. package/extension/src/tool-rendering/artifact-card.ts +0 -117
  45. package/extension/src/tool-rendering/artifact-list.ts +0 -179
  46. package/extension/src/tool-rendering/index.ts +0 -109
  47. package/extension/src/tool-rendering/render-model.ts +0 -410
@@ -1,776 +0,0 @@
1
- /**
2
- * pi-papyrus — native Pi extension for the Papyrus graph store.
3
- *
4
- * Tools: papyrus_query/graph/show (low-level), plus one native tool per domain (docs/rules/skills/playbooks/tasks/discuss/notes).
5
- * Command: /tasks (interactive task panel).
6
- * Widget: persistent task status above editor (rpiv-todo pattern).
7
- * Injection: active rules + open tasks appended to system prompt every turn.
8
- * "Are we there yet?" — the agent sees its open work items.
9
- */
10
- import { randomUUID } from "node:crypto";
11
- import type { ExtensionAPI, ExtensionContext, ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
12
- import { Type } from "typebox";
13
- import { truncateToWidth } from "@earendil-works/pi-tui";
14
- import {
15
- TASK_DRIVER_MAX_TURNS,
16
- TASK_DRIVER_MAX_UNCHANGED_TURNS,
17
- PAPYRUS_CONTEXT_INJECTION_CHANNEL,
18
- CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN,
19
- TASK_WIDGET_POLL_INTERVAL_MS,
20
- NOTE_WIDGET_POLL_INTERVAL_MS,
21
- NOTE_LIST_MAX_LIMIT,
22
- } from "../../src/constants.ts";
23
- import type { Artifact } from "../../src/domain/artifact.ts";
24
- import type { GateResult } from "../../src/domain/gate.ts";
25
- import { formatMetadata } from "./artifact-format.ts";
26
- import { callService } from "./service-client.ts";
27
- import { registerDomainTools, resolveNameFields } from "./domain-tools.ts";
28
- import { BoundedPoll } from "./bounded-poll.ts";
29
- import { renderNoteWidgetLines } from "./note-widget.ts";
30
- import { ensureTypingCourtesyTracking, isLiveAskPending } from "./discuss-ask-view.ts";
31
- import { PLAYBOOK_BRIDGE_MAX_PLAYBOOKS, registerPlaybookBridge } from "./playbook-bridge.ts";
32
- import type { TaskGraph, TaskStatus } from "../../src/task-service.ts";
33
- import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanInput, type ActiveTaskMarker } from "./active-task-continuation.ts";
34
- import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
35
- import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
36
- import { buildContextInjection } from "./context-injection-telemetry.ts";
37
- import { buildContextBreakdown, buildMessageHistoryTree, buildTaskItemTree, buildToolDefinitionItems, computeContextBudget, computeRuleBudget, DEFAULT_RESERVE_TOKENS, type ContextSegmentItem, type SessionEntryLike, type SessionTreeNodeLike } from "./context-budget.ts";
38
- import { buildBasePromptItems } from "./base-prompt-breakdown.ts";
39
- import { showContextView } from "./context-view.ts";
40
- import { emitTaskFocusEvent, setTaskFocusEventBus } from "./task-focus-events.ts";
41
- import { cacheSessionSecret, forgetSessionSecret, sessionSecretField } from "./session-identity.ts";
42
- import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
43
- import {
44
- createArtifactDetails,
45
- createArtifactListDetails,
46
- createGraphDetails,
47
- createModelContent,
48
- createPreviewDetails,
49
- } from "./tool-rendering/render-model.ts";
50
-
51
- function text(value: string, details: unknown = {}) {
52
- const modelContent = createModelContent(value);
53
- return { content: [{ type: "text" as const, text: modelContent.text }], details };
54
- }
55
-
56
- function artifactTextLabel(artifact: Artifact): string {
57
- return `[${artifact.kind}|${artifact.status}] ${artifact.title}`;
58
- }
59
-
60
- function artifactTextLines(artifacts: readonly Artifact[]): string[] {
61
- const titleCounts = new Map<string, number>();
62
- for (const artifact of artifacts) titleCounts.set(artifact.title, (titleCounts.get(artifact.title) ?? 0) + 1);
63
- return artifacts.map((artifact) => titleCounts.get(artifact.title)! > 1
64
- ? `${artifactTextLabel(artifact)} (${artifact.id})`
65
- : artifactTextLabel(artifact));
66
- }
67
-
68
- /** Resolves graph protocol ids into model-facing names; equal titles retain ids only to disambiguate. */
69
- async function artifactNamesById(ids: readonly string[]): Promise<Map<string, string>> {
70
- const uniqueIds = [...new Set(ids)];
71
- const artifacts = (await Promise.all(uniqueIds.map((id) => callService<Record<string, unknown>, Artifact | null>("artifact.show", { id })))).filter((artifact): artifact is Artifact => artifact !== null);
72
- const titleCounts = new Map<string, number>();
73
- for (const artifact of artifacts) titleCounts.set(artifact.title, (titleCounts.get(artifact.title) ?? 0) + 1);
74
- return new Map(artifacts.map((artifact) => [artifact.id, titleCounts.get(artifact.title)! > 1 ? `${artifact.title} (${artifact.id})` : artifact.title]));
75
- }
76
-
77
- // ---------------------------------------------------------------------------
78
- // Task widget (TodoOverlay pattern from rpiv-todo: factory form, requestRender)
79
- // ---------------------------------------------------------------------------
80
-
81
- const WIDGET_KEY = "pi-papyrus";
82
-
83
- export function renderTaskWidgetLines(theme: Theme, projection: TaskWidgetProjection, width: number): string[] {
84
- if (projection.openTotal === 0) return [];
85
- const lines: string[] = [theme.fg("muted", `Tasks · ${projection.scopeLabel}`)];
86
- for (let index = 0; index < projection.rows.length; index++) {
87
- const row = projection.rows[index]!;
88
- const laterSibling = projection.rows.slice(index + 1).some((candidate) => candidate.depth === row.depth);
89
- const hierarchy = taskTreeConnector({ depth: row.depth, hasChildren: row.hasOpenChildren, hasLaterSibling: laterSibling });
90
- const focus = row.active ? theme.fg("accent", row.focusStatus === "paused" ? "Ⅱ" : "▶") : " ";
91
- const presentation = TASK_STATUS_PRESENTATION[row.task.status as TaskStatus];
92
- const glyph = presentation ? theme.fg(presentation.color, presentation.glyph) : theme.fg("muted", "?");
93
- // Task containment is a DAG: a task with more than one parent is only ever shown once in
94
- // this bounded tree (under whichever parent this walk reached first). Flag it rather than
95
- // silently hiding that it also lives elsewhere -- see /tasks graph's composition view for
96
- // the full multi-parent picture.
97
- const multiParent = row.parentCount > 1 ? theme.fg("dim", ` ⥂${row.parentCount}`) : "";
98
- lines.push(truncateToWidth(`${focus} ${hierarchy} ${glyph} ${row.task.title}${multiParent}`, width, "…"));
99
- }
100
- return lines;
101
- }
102
-
103
- export class TaskOverlay {
104
- private uiCtx: ExtensionUIContext | undefined;
105
- private registered = false;
106
- private tui: any | undefined;
107
- private snapshot: TaskGraph = { nodes: [], rootIds: [] };
108
- private projectRoot: string | undefined;
109
- private sessionId: string | undefined;
110
- private readonly poll = new BoundedPoll();
111
-
112
- setUI(ctx: ExtensionUIContext): void {
113
- if (ctx !== this.uiCtx) {
114
- this.uiCtx = ctx;
115
- this.registered = false;
116
- this.tui = undefined;
117
- }
118
- }
119
-
120
- setProjectRoot(projectRoot: string): void { this.projectRoot = projectRoot; }
121
- // Scopes the widget's "active" glyph to this Pi session's own Focus, so a second
122
- // concurrent agent's focused task never shows as active in this session's widget.
123
- setSessionId(sessionId: string): void { this.sessionId = sessionId; }
124
-
125
- /**
126
- * Never throws: called from several pi.on(...) handlers, some of which (session_compact,
127
- * session_tree, tool_execution_end) don't wrap it themselves -- Pi's event emitter does not
128
- * guarantee catching a handler's rejection, so an unguarded throw here would become an
129
- * unhandled rejection at the call site instead of a stability issue contained to this widget.
130
- */
131
- async refresh(): Promise<void> {
132
- if (!this.projectRoot) return;
133
- try {
134
- this.snapshot = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", { limit: 500, project_root: this.projectRoot, session_id: this.sessionId });
135
- } catch {
136
- this.snapshot = { nodes: [], rootIds: [] };
137
- }
138
- try {
139
- this.render();
140
- } catch {
141
- // A rendering bug must not crash the extension host over a best-effort status widget.
142
- }
143
- }
144
-
145
- private render(): void {
146
- if (!this.uiCtx) return;
147
-
148
- // Hide widget when no tasks
149
- if (this.snapshot.nodes.length === 0) {
150
- if (this.registered) {
151
- this.uiCtx.setWidget(WIDGET_KEY, undefined);
152
- this.registered = false;
153
- this.tui = undefined;
154
- }
155
- return;
156
- }
157
-
158
- if (!this.registered) {
159
- this.uiCtx.setWidget(
160
- WIDGET_KEY,
161
- (tui: any, theme: Theme) => {
162
- this.tui = tui;
163
- return {
164
- render: (width: number) => this.renderLines(theme, width),
165
- invalidate: () => {
166
- // Theme changed — force re-registration
167
- this.registered = false;
168
- this.tui = undefined;
169
- },
170
- };
171
- },
172
- { placement: "aboveEditor" },
173
- );
174
- this.registered = true;
175
- } else {
176
- this.tui?.requestRender?.();
177
- }
178
- }
179
-
180
- private renderLines(theme: Theme, width: number): string[] {
181
- return renderTaskWidgetLines(theme, buildTaskWidgetProjection(this.snapshot), width);
182
- }
183
-
184
- /**
185
- * Fallback for a Task mutation no event announces -- the CLI run directly from a shell, or
186
- * a second concurrent Pi session against the same daemon.
187
- */
188
- startPolling(intervalMs: number = TASK_WIDGET_POLL_INTERVAL_MS): void {
189
- this.poll.start(intervalMs, () => { void this.refresh(); });
190
- }
191
-
192
- stopPolling(): void {
193
- this.poll.stop();
194
- }
195
-
196
- dispose(): void {
197
- this.stopPolling();
198
- this.uiCtx?.setWidget(WIDGET_KEY, undefined);
199
- this.registered = false;
200
- this.tui = undefined;
201
- this.uiCtx = undefined;
202
- this.projectRoot = undefined;
203
- this.sessionId = undefined;
204
- }
205
- }
206
-
207
- const NOTE_WIDGET_KEY = "pi-papyrus-notes";
208
-
209
- /**
210
- * Deliberately simple, unlike TaskOverlay's tree: just an open-note count for this session's own
211
- * CWD -- notes.list already scopes to project_root exactly (a note's projectRoot is fixed at
212
- * capture time), so passing this overlay's projectRoot is what makes the count CWD-aware by
213
- * default.
214
- */
215
- export class NoteOverlay {
216
- private uiCtx: ExtensionUIContext | undefined;
217
- private registered = false;
218
- private tui: any | undefined;
219
- private openCount = 0;
220
- private projectRoot: string | undefined;
221
- private readonly poll = new BoundedPoll();
222
-
223
- setUI(ctx: ExtensionUIContext): void {
224
- if (ctx !== this.uiCtx) {
225
- this.uiCtx = ctx;
226
- this.registered = false;
227
- this.tui = undefined;
228
- }
229
- }
230
-
231
- setProjectRoot(projectRoot: string): void { this.projectRoot = projectRoot; }
232
-
233
- async refresh(): Promise<void> {
234
- if (!this.projectRoot) return;
235
- try {
236
- const rows = await callService<Record<string, unknown>, Artifact[]>("notes.list", { project_root: this.projectRoot, limit: NOTE_LIST_MAX_LIMIT });
237
- this.openCount = rows.length;
238
- } catch {
239
- this.openCount = 0;
240
- }
241
- try {
242
- this.render();
243
- } catch {
244
- // A rendering bug must not crash the extension host over a best-effort status widget.
245
- }
246
- }
247
-
248
- private render(): void {
249
- if (!this.uiCtx) return;
250
-
251
- if (this.openCount === 0) {
252
- if (this.registered) {
253
- this.uiCtx.setWidget(NOTE_WIDGET_KEY, undefined);
254
- this.registered = false;
255
- this.tui = undefined;
256
- }
257
- return;
258
- }
259
-
260
- if (!this.registered) {
261
- this.uiCtx.setWidget(
262
- NOTE_WIDGET_KEY,
263
- (tui: any, theme: Theme) => {
264
- this.tui = tui;
265
- return {
266
- render: (width: number) => renderNoteWidgetLines(theme, this.openCount, width),
267
- invalidate: () => {
268
- this.registered = false;
269
- this.tui = undefined;
270
- },
271
- };
272
- },
273
- { placement: "aboveEditor" },
274
- );
275
- this.registered = true;
276
- } else {
277
- this.tui?.requestRender?.();
278
- }
279
- }
280
-
281
- startPolling(intervalMs: number = NOTE_WIDGET_POLL_INTERVAL_MS): void {
282
- this.poll.start(intervalMs, () => { void this.refresh(); });
283
- }
284
-
285
- stopPolling(): void {
286
- this.poll.stop();
287
- }
288
-
289
- dispose(): void {
290
- this.stopPolling();
291
- this.uiCtx?.setWidget(NOTE_WIDGET_KEY, undefined);
292
- this.registered = false;
293
- this.tui = undefined;
294
- this.uiCtx = undefined;
295
- this.projectRoot = undefined;
296
- }
297
- }
298
-
299
- // ---------------------------------------------------------------------------
300
- // Entry point
301
- // ---------------------------------------------------------------------------
302
-
303
- export default async function (pi: ExtensionAPI) {
304
- setTaskFocusEventBus(pi);
305
- registerDomainTools(pi);
306
- registerPlaybookBridge(pi);
307
- let contextInjectionSequence = 0;
308
- const contextInjectionProducerId = randomUUID();
309
- let previousContextInjectionFingerprint: string | undefined;
310
- let logTurnSequence = 0;
311
- // Cached from the most recent before_agent_start observation: Pi's own base system prompt
312
- // is only ever visible transiently inside that hook's event.systemPrompt, so /context
313
- // reuses the size buildContextInjection already computes every turn rather than going
314
- // without it entirely. basePromptItems is the structural sub-breakdown built from the same
315
- // event's systemPromptOptions field ("Extensions can inspect this to understand what Pi
316
- // loaded without re-discovering resources", per Pi's own doc comment) -- no new hook, no new
317
- // risk, just reading a field before_agent_start already hands over.
318
- let lastObservedBasePromptTokens: number | null = null;
319
- let lastObservedBasePromptItems: ContextSegmentItem[] = [];
320
- const taskContinuation = new ActiveTaskContinuation({
321
- maxTurns: TASK_DRIVER_MAX_TURNS,
322
- maxUnchangedTurns: TASK_DRIVER_MAX_UNCHANGED_TURNS,
323
- });
324
-
325
- const driveActiveTasks = async (ctx: ExtensionContext): Promise<void> => {
326
- if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
327
- // ctx.isIdle() means "not streaming a model response" -- it reads true while a live discuss
328
- // ask is still genuinely pending, blocked on the human. Queuing a "continue the active task"
329
- // nudge here would start a second, concurrent turn reasoning about the very Discussion this
330
- // live ask is already resolving. See discuss-ask-view.ts's isLiveAskPending() doc comment.
331
- if (isLiveAskPending()) return;
332
- try {
333
- const sessionId = ctx.sessionManager.getSessionId();
334
- const active = await callService<Record<string, unknown>, ActiveTaskMarker | null>("tasks.active", { project_root: ctx.cwd, session_id: sessionId });
335
- const decision = taskContinuation.evaluate(active, {
336
- idle: ctx.isIdle(),
337
- pendingMessages: ctx.hasPendingMessages(),
338
- });
339
- if (decision.action === "continue" && decision.prompt) {
340
- pi.sendMessage({
341
- customType: "papyrus-task-continuation",
342
- content: decision.prompt,
343
- display: false,
344
- }, { triggerTurn: true, deliverAs: "nextTurn" });
345
- } else if (decision.action === "pause") {
346
- const paused = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>("tasks.pause", {
347
- actor: "system",
348
- source: "task-continuation",
349
- reason: automaticPauseReason(decision.reason),
350
- session_id: sessionId,
351
- ...sessionSecretField(sessionId),
352
- });
353
- emitTaskFocusEvent({ taskId: paused.artifact.id, sessionId, status: "paused" });
354
- if (ctx.hasUI) ctx.ui.notify(`Papyrus task driving paused: ${decision.reason}. Human input resumes it automatically.`, "warning");
355
- }
356
- } catch {
357
- // The daemon may be unavailable during startup, reload, or shutdown.
358
- }
359
- };
360
-
361
- // ── Post-mortem AND live: every settled turn, log a real context-usage snapshot ────
362
- // Deliberately lean (real usage + budget numbers only, no full segment breakdown --
363
- // that stays /context's job on demand) so this stays cheap enough to run every turn:
364
- // no extra daemon round-trips beyond the one logs.append call, no session tree walk.
365
- const PI_SESSION_CONTEXT_LOG_SOURCE = "pi-session-context";
366
- const logSessionContextSnapshot = async (ctx: ExtensionContext): Promise<void> => {
367
- try {
368
- const usage = ctx.getContextUsage?.();
369
- if (!usage || usage.tokens === null) return; // nothing real to report yet (e.g. before the first assistant turn, or right after compaction)
370
- const totalTokens = usage.tokens;
371
- const sessionId = ctx.sessionManager.getSessionId();
372
- const effectiveBudget = Math.max(0, usage.contextWindow - DEFAULT_RESERVE_TOKENS);
373
- const percentOfBudget = effectiveBudget > 0 ? Math.round((totalTokens / effectiveBudget) * 1000) / 10 : null;
374
- await callService("logs.append", {
375
- source_id: PI_SESSION_CONTEXT_LOG_SOURCE,
376
- source_label: "Pi session context usage",
377
- project_root: ctx.cwd,
378
- level: "info",
379
- message: `context usage: ${totalTokens} tok / ${effectiveBudget} tok budget (${percentOfBudget}%)`,
380
- operation_id: `${sessionId}:${++logTurnSequence}`,
381
- session_id: sessionId,
382
- fields: { totalTokens, contextWindow: usage.contextWindow, effectiveBudget, percentOfBudget },
383
- });
384
- } catch {
385
- // The daemon may be unavailable during startup, reload, or shutdown -- a missed
386
- // snapshot is not worth surfacing to the user, matching every other best-effort
387
- // per-turn daemon call in this extension.
388
- }
389
- };
390
-
391
- // ── Low-level graph-store tools ────────────────────────────────────
392
-
393
- pi.registerTool({
394
- name: "papyrus_query",
395
- label: "Papyrus Query",
396
- description: "Query artifacts by kind, status, or full-text search.",
397
- parameters: Type.Object({
398
- kind: Type.Optional(Type.String()),
399
- status: Type.Optional(Type.String()),
400
- text: Type.Optional(Type.String({ description: "substring across title and body" })),
401
- limit: Type.Optional(Type.Number()),
402
- }),
403
- renderCall(args, theme) { return renderPapyrusToolCall("Query artifacts", args, theme); },
404
- renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
405
- async execute(_id, params, _signal, _onUpdate, _ctx) {
406
- try {
407
- const rows = await callService<Record<string, unknown>, Artifact[]>("artifact.query", { ...params, limit: params.limit ?? 50 });
408
- if (rows.length === 0) return text("No artifacts found.", createArtifactListDetails("artifact.query", rows));
409
- const lines = artifactTextLines(rows).map((line, index) => `${index + 1}. ${line}`);
410
- return text(`${rows.length} artifact(s):\n\n${lines.join("\n")}`, createArtifactListDetails("artifact.query", rows));
411
- } catch (e) {
412
- throw new Error(`papyrus_query failed: ${e instanceof Error ? e.message : e}`);
413
- }
414
- },
415
- });
416
-
417
- pi.registerTool({
418
- name: "papyrus_graph",
419
- label: "Papyrus Graph",
420
- description:
421
- "Link artifacts with typed edges (any kind → any kind), view subgraph, or read the mutation event log. " +
422
- "RELATIONS: references, implements, follows, depends_on, documents, blocks, supersedes, relates_to, gates, triggers, contains, part_of. " +
423
- "ACTIONS: link (from+relation+to), unlink (from+relation+to — idempotent, no error if already absent; for Task depends_on/contains prefer the tasks tool's undepend/uncontain), " +
424
- "tree (id → bounded BFS subgraph), " +
425
- "history (who did what, when — requires id, actor, or session_id). " +
426
- "status (id+status) exists at the protocol level but is refused for every kind with its own lifecycle (Doc/Rule/Skill/Playbook/Task/Note all reject it) -- use that kind's own domain tool for status changes (docs.activate, rules.enable, tasks.start, etc), never this. " +
427
- "PREFER `from_name`/`to_name` over `from`/`to` for link/unlink -- both are backend implementation details, resolved from name automatically, searching across every kind since either end of an edge can be any artifact.",
428
- parameters: Type.Object({
429
- action: Type.String({ description: "link | unlink | tree | status | history" }),
430
- from: Type.Optional(Type.String()),
431
- from_name: Type.Optional(Type.String()),
432
- relation: Type.Optional(Type.String()),
433
- to: Type.Optional(Type.String()),
434
- to_name: Type.Optional(Type.String()),
435
- id: Type.Optional(Type.String()),
436
- status: Type.Optional(Type.String()),
437
- depth: Type.Optional(Type.Number({ description: "tree traversal depth; bounded by a hard ceiling" })),
438
- max_nodes: Type.Optional(Type.Number({ description: "tree node cap; bounded by a hard ceiling" })),
439
- actor: Type.Optional(Type.String({ description: "history: filter by actor" })),
440
- session_id: Type.Optional(Type.String({ description: "history: filter by session" })),
441
- since: Type.Optional(Type.String({ description: "history: RFC3339 lower bound" })),
442
- limit: Type.Optional(Type.Number({ description: "history: bounded page size" })),
443
- }),
444
- renderCall(args, theme) { return renderPapyrusToolCall("Artifact graph", args, theme); },
445
- renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
446
- async execute(_id, rawParams, _signal, _onUpdate, _ctx) {
447
- try {
448
- const params: Record<string, unknown> = { ...rawParams };
449
- if (params.action === "link" || params.action === "unlink") {
450
- // Kind-agnostic: either end of an edge can be a task, doc, rule, skill, or playbook.
451
- await resolveNameFields(params, [
452
- { nameKey: "from_name", idKey: "from", listOperation: "artifact.query", baseRequest: {} },
453
- { nameKey: "to_name", idKey: "to", listOperation: "artifact.query", baseRequest: {} },
454
- ]);
455
- }
456
- if (params.action === "link") {
457
- await callService("graph.link", { from: params.from as string, relation: params.relation as string, to: params.to as string });
458
- const names = await artifactNamesById([params.from as string, params.to as string]);
459
- const output = `Linked "${names.get(params.from as string) ?? "unknown artifact"}" --${params.relation}--> "${names.get(params.to as string) ?? "unknown artifact"}"`;
460
- return text(output, createPreviewDetails("graph.link", "Artifact relationship", output));
461
- }
462
- if (params.action === "unlink") {
463
- const result = await callService<Record<string, unknown>, { removed: boolean }>("graph.unlink", { from: params.from as string, relation: params.relation as string, to: params.to as string });
464
- const names = await artifactNamesById([params.from as string, params.to as string]);
465
- const relationship = `"${names.get(params.from as string) ?? "unknown artifact"}" --${params.relation}--> "${names.get(params.to as string) ?? "unknown artifact"}"`;
466
- const output = result.removed ? `Unlinked ${relationship}` : `No such relationship: ${relationship}`;
467
- return text(output, createPreviewDetails("graph.unlink", "Artifact relationship", output));
468
- }
469
- if (params.action === "tree") {
470
- const root = params.id ?? params.from;
471
- if (!root) throw new Error("missing id for tree");
472
- const a = await callService<Record<string, unknown>, Artifact | null>("graph.tree", {
473
- id: root,
474
- depth: params.depth,
475
- max_nodes: params.max_nodes,
476
- });
477
- if (!a) throw new Error(`artifact ${root} not found`);
478
- const edges = a.edges ?? [];
479
- if (edges.length === 0) return text(`${a.title} — no edges`, createGraphDetails("graph.tree", [a], []));
480
- const names = await artifactNamesById(edges.flatMap((edge) => [edge.from, edge.to]));
481
- return text(
482
- `Subgraph from ${a.title} (${edges.length} edges):\n\n${edges.map((edge) => ` "${names.get(edge.from) ?? "unknown artifact"}" --${edge.relation}--> "${names.get(edge.to) ?? "unknown artifact"}"`).join("\n")}`,
483
- createGraphDetails("graph.tree", [a], edges),
484
- );
485
- }
486
- if (params.action === "status") {
487
- const a = await callService<Record<string, unknown>, Artifact | null>("graph.status", { id: params.id!, status: params.status! });
488
- if (!a) throw new Error(`artifact ${params.id} not found`);
489
- return text(`Updated "${a.title}" → [${a.status}]`, createArtifactDetails("graph.status", a));
490
- }
491
- if (params.action === "history") {
492
- const page = await callService<Record<string, unknown>, { events: Array<Record<string, unknown>> }>("graph.history", {
493
- id: params.id, actor: params.actor, session_id: params.session_id, since: params.since, limit: params.limit,
494
- });
495
- if (page.events.length === 0) return text("No recorded events.", createPreviewDetails("graph.history", "Mutation event log", "No recorded events."));
496
- const eventIds = page.events.map((event) => event["artifactId"]).filter((id): id is string => typeof id === "string");
497
- const names = await artifactNamesById(eventIds);
498
- const output = page.events.map((event) => `${event["occurredAt"]} "${typeof event["artifactId"] === "string" ? names.get(event["artifactId"]) ?? "unknown artifact" : "unknown artifact"}" ${event["type"]} · ${event["actor"]}/${event["source"]}`).join("\n");
499
- return text(output, createPreviewDetails("graph.history", "Mutation event log", output));
500
- }
501
- throw new Error(`unknown action: ${params.action}; use link, tree, status, or history`);
502
- } catch (e) {
503
- throw new Error(`papyrus_graph failed: ${e instanceof Error ? e.message : e}`);
504
- }
505
- },
506
- });
507
-
508
- pi.registerTool({
509
- name: "papyrus_show",
510
- label: "Papyrus Show",
511
- description: "Show one artifact with body, edges, and optionally run its gates.",
512
- parameters: Type.Object({
513
- id: Type.String(),
514
- run_gates: Type.Optional(Type.Boolean()),
515
- depth: Type.Optional(Type.Number({ description: "edge traversal depth" })),
516
- max_nodes: Type.Optional(Type.Number({ description: "maximum traversed nodes" })),
517
- }),
518
- renderCall(args, theme) { return renderPapyrusToolCall("Show artifact", args, theme); },
519
- renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
520
- async execute(_id, params, _signal, _onUpdate, _ctx) {
521
- try {
522
- const a = await callService<Record<string, unknown>, Artifact | null>("artifact.show", {
523
- id: params.id,
524
- tree: true,
525
- depth: params.depth,
526
- max_nodes: params.max_nodes,
527
- });
528
- if (!a) throw new Error(`artifact ${params.id} not found`);
529
- let out = `${artifactTextLabel(a)}\n\n${a.body}`;
530
- if (Object.keys(a.extra).length > 0) {
531
- out += `\n\nMetadata:\n${formatMetadata(a.extra).map((line) => ` ${line}`).join("\n")}`;
532
- }
533
- if (a.edges?.length) {
534
- const names = await artifactNamesById(a.edges.flatMap((edge) => [edge.from, edge.to]));
535
- out += `\n\nEdges:\n${a.edges.map((edge) => ` "${names.get(edge.from) ?? "unknown artifact"}" --${edge.relation}--> "${names.get(edge.to) ?? "unknown artifact"}"`).join("\n")}`;
536
- }
537
- if (params.run_gates) {
538
- const results = await callService<Record<string, unknown>, GateResult[]>("gates.run", { id: params.id });
539
- out += `\n\nGates:\n${results.map((gate) => ` ${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n")}`;
540
- }
541
- return text(out, createArtifactDetails("artifact.show", a));
542
- } catch (e) {
543
- throw new Error(`papyrus_show failed: ${e instanceof Error ? e.message : e}`);
544
- }
545
- },
546
- });
547
-
548
- // ── Interactive artifact browsers ──────────────────────────────────
549
-
550
- // Lazy imports keep TUI components out of non-interactive startup paths.
551
- const [tasksModule, docsModule, notesModule, rulesModule, skillsModule, playbooksModule, discussModule] = await Promise.all([
552
- import("./tasks.ts"),
553
- import("./docs.ts"),
554
- import("./notes.ts"),
555
- import("./rules.ts"),
556
- import("./skills.ts"),
557
- import("./playbooks.ts"),
558
- import("./discuss.ts"),
559
- ]);
560
- let overlay: TaskOverlay | undefined;
561
- let noteOverlay: NoteOverlay | undefined;
562
-
563
- pi.registerCommand("tasks", {
564
- description: "Browse and manage Papyrus tasks (interactive)",
565
- handler: async (_args, ctx) => {
566
- overlay?.setProjectRoot(ctx.cwd);
567
- overlay?.setSessionId(ctx.sessionManager.getSessionId());
568
- await tasksModule.showTasks(ctx);
569
- await overlay?.refresh();
570
- },
571
- });
572
- pi.registerCommand("docs", {
573
- description: "Browse and manage Papyrus documents (interactive)",
574
- handler: async (_args, ctx) => { await docsModule.showDocs(ctx); },
575
- });
576
- pi.registerCommand("note", {
577
- description: "Capture a deferred request directly in Papyrus",
578
- handler: async (args, ctx) => {
579
- await notesModule.captureNote(args, ctx);
580
- await noteOverlay?.refresh();
581
- },
582
- });
583
- pi.registerCommand("notes", {
584
- description: "Browse and triage the project Notes inbox",
585
- handler: async (_args, ctx) => {
586
- noteOverlay?.setProjectRoot(ctx.cwd);
587
- await notesModule.showNotes(ctx);
588
- await noteOverlay?.refresh();
589
- },
590
- });
591
- pi.registerCommand("rules", {
592
- description: "Browse, preview, and toggle Papyrus rules (interactive)",
593
- handler: async (_args, ctx) => { await rulesModule.showRules(ctx); },
594
- });
595
- pi.registerCommand("skills", {
596
- description: "Browse and invoke Papyrus skills and templates (interactive)",
597
- handler: async (_args, ctx) => { await skillsModule.showSkills(ctx); },
598
- });
599
- pi.registerCommand("playbooks", {
600
- description: "Browse, edit, and invoke Papyrus playbooks -- trigger/steps guidance an agent reads and follows (interactive)",
601
- handler: async (_args, ctx) => { await playbooksModule.showPlaybooks(ctx); },
602
- });
603
- pi.registerCommand("playbook", {
604
- description: "Open one Papyrus playbook directly by name (tab-completes active playbook titles) and place its invocation in the editor; no argument opens the full /playbooks browser instead",
605
- getArgumentCompletions: (argumentPrefix) => playbooksModule.playbookArgumentCompletions(argumentPrefix),
606
- handler: async (args, ctx) => { await playbooksModule.openPlaybookByName(args, ctx); },
607
- });
608
- pi.registerCommand("discuss", {
609
- description: "Browse Papyrus Discussions and reply, defer, resume, settle, or block/unblock a task (interactive)",
610
- handler: async (_args, ctx) => { await discussModule.showDiscussions(ctx); },
611
- });
612
- pi.registerCommand("context", {
613
- 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",
614
- handler: async (_args, ctx) => {
615
- try {
616
- const sessionId = ctx.sessionManager.getSessionId();
617
- const [rules, taskGraph] = await Promise.all([
618
- callService<Record<string, unknown>, Array<Pick<Artifact, "id" | "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd, session_id: sessionId }),
619
- callService<Record<string, unknown>, TaskGraph>("tasks.graph", { project_root: ctx.cwd, session_id: sessionId }),
620
- ]);
621
- const { skills } = computeContextBudget(rules, ctx.cwd);
622
- const ruleBudget = computeRuleBudget(rules);
623
- const usage = ctx.getContextUsage?.();
624
- // Real tree (not just the linear current-branch path): surfaces content sitting in an
625
- // abandoned /tree branch, which cost real tokens to generate but isn't in context now.
626
- const tree = ctx.sessionManager.getTree() as SessionTreeNodeLike[];
627
- // buildContextEntries(), NOT getBranch(): getBranch() returns every raw entry on the
628
- // current path including everything a real compaction has already summarized away.
629
- // A session with 3 real compactions confirmed this made "active" message-history
630
- // tokens overcount the real total by over 13x -- getBranch()'s own docstring already
631
- // says as much ("Use buildSessionContext() to get the resolved messages for the
632
- // LLM"); buildContextEntries() is the compaction-aware entry list matching what the
633
- // LLM actually sees (the latest compaction entry itself, plus kept entries from its
634
- // firstKeptEntryId onward, plus everything after -- older summarized entries omitted).
635
- const activeEntryIds = new Set((ctx.sessionManager.buildContextEntries() as SessionEntryLike[]).map((entry) => entry.id));
636
- const branchEntryIds = new Set((ctx.sessionManager.getBranch() as SessionEntryLike[]).map((entry) => entry.id));
637
- const messageHistory = buildMessageHistoryTree(tree, activeEntryIds, branchEntryIds);
638
- const activeToolNames = new Set(pi.getActiveTools());
639
- const toolDefinitionItems = buildToolDefinitionItems(pi.getAllTools().filter((tool) => activeToolNames.has(tool.name)));
640
- const breakdown = buildContextBreakdown({
641
- totalTokens: usage?.tokens ?? null,
642
- contextWindow: ctx.model?.contextWindow ?? null,
643
- ruleBudget,
644
- taskItems: buildTaskItemTree(taskGraph),
645
- skills,
646
- basePromptEstimatedTokens: lastObservedBasePromptTokens,
647
- basePromptItems: lastObservedBasePromptItems,
648
- toolDefinitionItems,
649
- messageHistoryItems: messageHistory.items,
650
- messageHistoryActiveTokens: messageHistory.activeTokens,
651
- });
652
- await showContextView(ctx, breakdown);
653
- } catch (error) {
654
- ctx.ui.notify(`Context breakdown failed: ${error instanceof Error ? error.message : error}`, "error");
655
- }
656
- },
657
- });
658
-
659
- // ── Task widget (TodoOverlay pattern: factory form, requestRender) ──
660
-
661
- pi.on("session_start", async (_event, ctx) => {
662
- // Registers this session's identity with the daemon as early as possible -- before any
663
- // Focus-mutating call could plausibly happen -- shrinking (not eliminating; see
664
- // domain/session-identity.ts) the first-touch race window. Best-effort: the daemon may be
665
- // unavailable during startup, and every other Focus-mutating call already tolerates an
666
- // unregistered/never-armored session_id (opt-in armor), so a missed registration here is
667
- // not worth surfacing to the user.
668
- try {
669
- const sessionId = ctx.sessionManager.getSessionId();
670
- const { secret } = await callService<Record<string, unknown>, { sessionId: string; secret: string }>("session.register", { session_id: sessionId });
671
- cacheSessionSecret(sessionId, secret);
672
- } catch {
673
- // intentionally silent -- see comment above
674
- }
675
- if (!ctx.hasUI) return;
676
- // Attached from session start, not lazily on first ask -- a per-ask listener would only see
677
- // keystrokes from the moment that tool call happens to begin, missing typing already in
678
- // progress when it started (the exact case Discuss's typing-courtesy wait protects against).
679
- ensureTypingCourtesyTracking(ctx.ui);
680
- overlay ??= new TaskOverlay();
681
- overlay.setUI(ctx.ui);
682
- overlay.setProjectRoot(ctx.cwd);
683
- overlay.setSessionId(ctx.sessionManager.getSessionId());
684
- await overlay.refresh();
685
- overlay.startPolling(TASK_WIDGET_POLL_INTERVAL_MS);
686
-
687
- noteOverlay ??= new NoteOverlay();
688
- noteOverlay.setUI(ctx.ui);
689
- noteOverlay.setProjectRoot(ctx.cwd);
690
- await noteOverlay.refresh();
691
- noteOverlay.startPolling(NOTE_WIDGET_POLL_INTERVAL_MS);
692
- });
693
-
694
- pi.on("session_before_compact", () => { taskContinuation.onCompaction(); });
695
- pi.on("session_compact", async () => { await Promise.all([overlay?.refresh(), noteOverlay?.refresh()]); });
696
- pi.on("session_tree", async () => { await Promise.all([overlay?.refresh(), noteOverlay?.refresh()]); });
697
- pi.on("session_shutdown", async (_event, ctx) => {
698
- overlay?.dispose();
699
- overlay = undefined;
700
- noteOverlay?.dispose();
701
- noteOverlay = undefined;
702
- try {
703
- const sessionId = ctx.sessionManager.getSessionId();
704
- await callService("session.release", { session_id: sessionId, ...sessionSecretField(sessionId) });
705
- forgetSessionSecret(sessionId);
706
- } catch {
707
- // intentionally silent -- see session_start's comment above
708
- }
709
- });
710
-
711
- // Update widgets after any papyrus tool call
712
- pi.on("tool_execution_end", async (event) => {
713
- if (event.toolName.startsWith("papyrus_") || event.toolName === "tasks") {
714
- await overlay?.refresh();
715
- }
716
- if (event.toolName === "notes") {
717
- await noteOverlay?.refresh();
718
- }
719
- });
720
-
721
- // ── Keep driving active work after Pi has exhausted built-in continuations ──
722
- // agent_settled is intentionally later than agent_end: Pi guarantees that
723
- // retry, compaction retry, and queued follow-up processing have finished.
724
-
725
- pi.on("input", async (event, ctx) => {
726
- if (event.source === "extension") return;
727
- taskContinuation.onHumanInput();
728
- try {
729
- const sessionId = ctx.sessionManager.getSessionId();
730
- const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string; pauseReason?: string } | null>("tasks.focused", { session_id: sessionId });
731
- if (focus && shouldResumeFocusOnHumanInput(focus.status, focus.pauseReason)) {
732
- await callService("tasks.unpause", { actor: "system", source: "task-continuation", reason: "human input resumed automatic task continuation", session_id: sessionId, ...sessionSecretField(sessionId) });
733
- emitTaskFocusEvent({ taskId: focus.artifact.id, sessionId, status: "unpaused" });
734
- }
735
- } catch {
736
- // The daemon may be unavailable during startup, reload, or shutdown.
737
- }
738
- });
739
- pi.on("agent_start", () => { taskContinuation.onAgentStart(); });
740
- pi.on("agent_settled", async (_event, ctx) => {
741
- await driveActiveTasks(ctx);
742
- await logSessionContextSnapshot(ctx);
743
- });
744
-
745
- // ── "Are we there yet?" — inject active tasks into every turn ──────
746
- // The agent sees its open work items every turn. If there are rejected
747
- // tasks, they're explicitly called out — the agent should address them.
748
-
749
- pi.on("before_agent_start", async (event, ctx) => {
750
- try {
751
- const sessionId = ctx.sessionManager.getSessionId();
752
- const [rules, playbooks, summary] = await Promise.all([
753
- callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd, session_id: sessionId }),
754
- callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "extra">>>("playbooks.list", { status: "active", limit: PLAYBOOK_BRIDGE_MAX_PLAYBOOKS }),
755
- callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd, session_id: sessionId, verbosity: "summary" }),
756
- ]);
757
- const injection = buildContextInjection({
758
- basePrompt: event.systemPrompt ?? "",
759
- rules,
760
- playbooks,
761
- taskSummary: summary,
762
- observedAt: Date.now(),
763
- sequence: ++contextInjectionSequence,
764
- producerId: contextInjectionProducerId,
765
- previousFingerprint: previousContextInjectionFingerprint,
766
- });
767
- previousContextInjectionFingerprint = injection.observation.fingerprint;
768
- lastObservedBasePromptTokens = Math.ceil(injection.observation.before.characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
769
- lastObservedBasePromptItems = buildBasePromptItems(event.systemPromptOptions, injection.observation.before.characters);
770
- pi.events.emit(PAPYRUS_CONTEXT_INJECTION_CHANNEL, injection.observation);
771
- if (injection.prompt !== (event.systemPrompt ?? "")) return { systemPrompt: injection.prompt };
772
- } catch {
773
- // DB not ready
774
- }
775
- });
776
- }