@danypops/papyrus 0.11.4 → 0.13.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 (57) hide show
  1. package/README.md +16 -2
  2. package/extension/src/active-task-continuation.ts +6 -0
  3. package/extension/src/artifact-browser.ts +13 -7
  4. package/extension/src/artifact-status-presentation.ts +53 -0
  5. package/extension/src/context-budget.ts +173 -0
  6. package/extension/src/context-view.ts +172 -0
  7. package/extension/src/docs.ts +6 -5
  8. package/extension/src/domain-tools.ts +108 -52
  9. package/extension/src/index.ts +124 -38
  10. package/extension/src/notes.ts +16 -4
  11. package/extension/src/rules.ts +7 -7
  12. package/extension/src/skill-catalog-footprint.ts +183 -0
  13. package/extension/src/skills.ts +2 -3
  14. package/extension/src/task-focus-events.ts +57 -0
  15. package/extension/src/task-widget.ts +13 -1
  16. package/extension/src/tasks.ts +51 -15
  17. package/extension/src/tool-rendering/artifact-card.ts +117 -0
  18. package/extension/src/tool-rendering/artifact-list.ts +179 -0
  19. package/extension/src/tool-rendering/index.ts +107 -0
  20. package/extension/src/tool-rendering/render-model.ts +406 -0
  21. package/package.json +4 -2
  22. package/src/adapters/in-memory-conversation-journal-store.ts +48 -0
  23. package/src/adapters/sqlite-artifact-scope-store.ts +36 -0
  24. package/src/adapters/sqlite-artifact-store.ts +20 -11
  25. package/src/adapters/sqlite-discourse-store.ts +325 -0
  26. package/src/adapters/sqlite-graph-projection-store.ts +41 -0
  27. package/src/adapters/sqlite-task-focus-store.ts +34 -15
  28. package/src/authority-registry.ts +115 -0
  29. package/src/cli.ts +904 -124
  30. package/src/constants.ts +77 -5
  31. package/src/conversation-journal-service.ts +87 -0
  32. package/src/db.ts +285 -33
  33. package/src/domain/artifact-event.ts +99 -0
  34. package/src/domain/conversation-journal.ts +168 -0
  35. package/src/domain/discourse-store.ts +142 -0
  36. package/src/domain/graph-projection.ts +74 -0
  37. package/src/domain/skill-definition.ts +57 -8
  38. package/src/domain/task-event.ts +4 -0
  39. package/src/domain-services.ts +201 -40
  40. package/src/graph-projection-service.ts +103 -0
  41. package/src/id-migration.ts +200 -0
  42. package/src/module-registry.ts +53 -0
  43. package/src/modules/docs.ts +77 -0
  44. package/src/modules/graph-projection.ts +82 -0
  45. package/src/modules/notes.ts +76 -0
  46. package/src/modules/rules.ts +81 -0
  47. package/src/modules/skills.ts +113 -0
  48. package/src/modules/tasks.ts +164 -0
  49. package/src/ops.ts +142 -15
  50. package/src/ports/artifact-scope-store.ts +20 -0
  51. package/src/ports/artifact-store.ts +10 -5
  52. package/src/ports/conversation-journal-store.ts +17 -0
  53. package/src/ports/graph-projection-store.ts +15 -0
  54. package/src/ports/task-focus-store.ts +62 -20
  55. package/src/service.ts +218 -223
  56. package/src/skill-execution.ts +169 -75
  57. package/src/task-service.ts +70 -38
@@ -15,6 +15,7 @@ import {
15
15
  TASK_DRIVER_MAX_TURNS,
16
16
  TASK_DRIVER_MAX_UNCHANGED_TURNS,
17
17
  PAPYRUS_CONTEXT_INJECTION_CHANNEL,
18
+ CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN,
18
19
  } from "../../src/constants.ts";
19
20
  import type { Artifact } from "../../src/domain/artifact.ts";
20
21
  import type { GateResult } from "../../src/domain/gate.ts";
@@ -26,9 +27,21 @@ import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanI
26
27
  import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
27
28
  import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
28
29
  import { buildContextInjection } from "./context-injection-telemetry.ts";
29
-
30
- function text(t: string, details: Record<string, unknown> = {}) {
31
- return { content: [{ type: "text" as const, text: t }], details };
30
+ import { buildContextBreakdown, computeContextBudget, computeRuleBudget } from "./context-budget.ts";
31
+ import { showContextView } from "./context-view.ts";
32
+ import { emitTaskFocusEvent, setTaskFocusEventBus } from "./task-focus-events.ts";
33
+ import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
34
+ import {
35
+ createArtifactDetails,
36
+ createArtifactListDetails,
37
+ createGraphDetails,
38
+ createModelContent,
39
+ createPreviewDetails,
40
+ } from "./tool-rendering/render-model.ts";
41
+
42
+ function text(value: string, details: unknown = {}) {
43
+ const modelContent = createModelContent(value);
44
+ return { content: [{ type: "text" as const, text: modelContent.text }], details };
32
45
  }
33
46
 
34
47
  // ---------------------------------------------------------------------------
@@ -47,7 +60,12 @@ export function renderTaskWidgetLines(theme: Theme, projection: TaskWidgetProjec
47
60
  const focus = row.active ? theme.fg("accent", row.focusStatus === "paused" ? "Ⅱ" : "▶") : " ";
48
61
  const presentation = TASK_STATUS_PRESENTATION[row.task.status as TaskStatus];
49
62
  const glyph = presentation ? theme.fg(presentation.color, presentation.glyph) : theme.fg("muted", "?");
50
- lines.push(truncateToWidth(`${focus} ${hierarchy} ${glyph} ${row.task.title}`, width, "…"));
63
+ // Task containment is a DAG: a task with more than one parent is only ever shown once in
64
+ // this bounded tree (under whichever parent this walk reached first). Flag it rather than
65
+ // silently hiding that it also lives elsewhere -- see /tasks graph's composition view for
66
+ // the full multi-parent picture.
67
+ const multiParent = row.parentCount > 1 ? theme.fg("dim", ` ⥂${row.parentCount}`) : "";
68
+ lines.push(truncateToWidth(`${focus} ${hierarchy} ${glyph} ${row.task.title}${multiParent}`, width, "…"));
51
69
  }
52
70
  return lines;
53
71
  }
@@ -58,6 +76,7 @@ class TaskOverlay {
58
76
  private tui: any | undefined;
59
77
  private snapshot: TaskGraph = { nodes: [], rootIds: [] };
60
78
  private projectRoot: string | undefined;
79
+ private sessionId: string | undefined;
61
80
 
62
81
  setUI(ctx: ExtensionUIContext): void {
63
82
  if (ctx !== this.uiCtx) {
@@ -68,11 +87,14 @@ class TaskOverlay {
68
87
  }
69
88
 
70
89
  setProjectRoot(projectRoot: string): void { this.projectRoot = projectRoot; }
90
+ // Scopes the widget's "active" glyph to this Pi session's own Focus, so a second
91
+ // concurrent agent's focused task never shows as active in this session's widget.
92
+ setSessionId(sessionId: string): void { this.sessionId = sessionId; }
71
93
 
72
94
  async refresh(): Promise<void> {
73
95
  if (!this.projectRoot) return;
74
96
  try {
75
- this.snapshot = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", { limit: 500, project_root: this.projectRoot });
97
+ this.snapshot = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", { limit: 500, project_root: this.projectRoot, session_id: this.sessionId });
76
98
  } catch {
77
99
  this.snapshot = { nodes: [], rootIds: [] };
78
100
  }
@@ -124,6 +146,7 @@ class TaskOverlay {
124
146
  this.tui = undefined;
125
147
  this.uiCtx = undefined;
126
148
  this.projectRoot = undefined;
149
+ this.sessionId = undefined;
127
150
  }
128
151
  }
129
152
 
@@ -132,6 +155,7 @@ class TaskOverlay {
132
155
  // ---------------------------------------------------------------------------
133
156
 
134
157
  export default async function (pi: ExtensionAPI) {
158
+ setTaskFocusEventBus(pi);
135
159
  registerDomainTools(pi);
136
160
  let contextInjectionSequence = 0;
137
161
  const contextInjectionProducerId = randomUUID();
@@ -144,7 +168,8 @@ export default async function (pi: ExtensionAPI) {
144
168
  const driveActiveTasks = async (ctx: ExtensionContext): Promise<void> => {
145
169
  if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
146
170
  try {
147
- const active = await callService<Record<string, unknown>, ActiveTaskMarker | null>("tasks.active", { project_root: ctx.cwd });
171
+ const sessionId = ctx.sessionManager.getSessionId();
172
+ const active = await callService<Record<string, unknown>, ActiveTaskMarker | null>("tasks.active", { project_root: ctx.cwd, session_id: sessionId });
148
173
  const decision = taskContinuation.evaluate(active, {
149
174
  idle: ctx.isIdle(),
150
175
  pendingMessages: ctx.hasPendingMessages(),
@@ -156,11 +181,13 @@ export default async function (pi: ExtensionAPI) {
156
181
  display: false,
157
182
  }, { triggerTurn: true, deliverAs: "nextTurn" });
158
183
  } else if (decision.action === "pause") {
159
- await callService("tasks.pause", {
184
+ const paused = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>("tasks.pause", {
160
185
  actor: "system",
161
186
  source: "task-continuation",
162
187
  reason: automaticPauseReason(decision.reason),
188
+ session_id: sessionId,
163
189
  });
190
+ emitTaskFocusEvent({ taskId: paused.artifact.id, sessionId, status: "paused" });
164
191
  if (ctx.hasUI) ctx.ui.notify(`Papyrus task driving paused: ${decision.reason}. Human input resumes it automatically.`, "warning");
165
192
  }
166
193
  } catch {
@@ -192,15 +219,17 @@ export default async function (pi: ExtensionAPI) {
192
219
  template_id: Type.Optional(Type.String({ description: "skill/artifact-template id whose defaults and requirements apply" })),
193
220
  project_root: Type.Optional(Type.String({ description: "required for Tasks; defaults to Pi cwd" })),
194
221
  }),
222
+ renderCall(args, theme) { return renderPapyrusToolCall("Create artifact", args, theme); },
223
+ renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
195
224
  async execute(_id, params, _signal, _onUpdate, ctx) {
196
225
  try {
197
226
  const a = await callService<Record<string, unknown>, Artifact>("artifact.create", {
198
227
  ...params,
199
228
  ...(params.kind === "task" ? { project_root: params.project_root ?? ctx.cwd } : {}),
200
229
  });
201
- return text(`Created ${a.id} [${a.kind}|${a.status}] ${a.title}`, { id: a.id });
230
+ return text(`Created ${a.id} [${a.kind}|${a.status}] ${a.title}`, createArtifactDetails("artifact.create", a));
202
231
  } catch (e) {
203
- return text(`papyrus_create failed: ${e instanceof Error ? e.message : e}`);
232
+ throw new Error(`papyrus_create failed: ${e instanceof Error ? e.message : e}`);
204
233
  }
205
234
  },
206
235
  });
@@ -215,14 +244,16 @@ export default async function (pi: ExtensionAPI) {
215
244
  text: Type.Optional(Type.String({ description: "substring across title and body" })),
216
245
  limit: Type.Optional(Type.Number()),
217
246
  }),
247
+ renderCall(args, theme) { return renderPapyrusToolCall("Query artifacts", args, theme); },
248
+ renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
218
249
  async execute(_id, params, _signal, _onUpdate, _ctx) {
219
250
  try {
220
251
  const rows = await callService<Record<string, unknown>, Artifact[]>("artifact.query", { ...params, limit: params.limit ?? 50 });
221
- if (rows.length === 0) return text("No artifacts found.");
222
- const lines = rows.map((r: any, i: number) => `${i + 1}. ${r.id} [${r.kind}|${r.status}] ${r.title}`);
223
- return text(`${rows.length} artifact(s):\n\n${lines.join("\n")}`, { rows });
252
+ if (rows.length === 0) return text("No artifacts found.", createArtifactListDetails("artifact.query", rows));
253
+ const lines = rows.map((row, index) => `${index + 1}. ${row.id} [${row.kind}|${row.status}] ${row.title}`);
254
+ return text(`${rows.length} artifact(s):\n\n${lines.join("\n")}`, createArtifactListDetails("artifact.query", rows));
224
255
  } catch (e) {
225
- return text(`papyrus_query failed: ${e instanceof Error ? e.message : e}`);
256
+ throw new Error(`papyrus_query failed: ${e instanceof Error ? e.message : e}`);
226
257
  }
227
258
  },
228
259
  });
@@ -231,11 +262,13 @@ export default async function (pi: ExtensionAPI) {
231
262
  name: "papyrus_graph",
232
263
  label: "Papyrus Graph",
233
264
  description:
234
- "Link artifacts with typed edges (any kind → any kind), view subgraph, or update status. " +
265
+ "Link artifacts with typed edges (any kind → any kind), view subgraph, update status, or read the mutation event log. " +
235
266
  "RELATIONS: references, implements, follows, depends_on, documents, blocks, supersedes, relates_to, gates, triggers, contains, part_of. " +
236
- "ACTIONS: link (from+relation+to), tree (id bounded BFS subgraph), status (id+status lifecycle).",
267
+ "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), " +
268
+ "tree (id → bounded BFS subgraph), status (id+status → lifecycle), " +
269
+ "history (who did what, when — requires id, actor, or session_id).",
237
270
  parameters: Type.Object({
238
- action: Type.String({ description: "link | tree | status" }),
271
+ action: Type.String({ description: "link | unlink | tree | status | history" }),
239
272
  from: Type.Optional(Type.String()),
240
273
  relation: Type.Optional(Type.String()),
241
274
  to: Type.Optional(Type.String()),
@@ -243,37 +276,57 @@ export default async function (pi: ExtensionAPI) {
243
276
  status: Type.Optional(Type.String()),
244
277
  depth: Type.Optional(Type.Number({ description: "tree traversal depth; bounded by a hard ceiling" })),
245
278
  max_nodes: Type.Optional(Type.Number({ description: "tree node cap; bounded by a hard ceiling" })),
279
+ actor: Type.Optional(Type.String({ description: "history: filter by actor" })),
280
+ session_id: Type.Optional(Type.String({ description: "history: filter by session" })),
281
+ since: Type.Optional(Type.String({ description: "history: RFC3339 lower bound" })),
282
+ limit: Type.Optional(Type.Number({ description: "history: bounded page size" })),
246
283
  }),
284
+ renderCall(args, theme) { return renderPapyrusToolCall("Artifact graph", args, theme); },
285
+ renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
247
286
  async execute(_id, params, _signal, _onUpdate, _ctx) {
248
287
  try {
249
288
  if (params.action === "link") {
250
289
  await callService("graph.link", { from: params.from!, relation: params.relation!, to: params.to! });
251
- return text(`Linked ${params.from} --${params.relation}--> ${params.to}`);
290
+ const output = `Linked ${params.from} --${params.relation}--> ${params.to}`;
291
+ return text(output, createPreviewDetails("graph.link", "Artifact relationship", output));
292
+ }
293
+ if (params.action === "unlink") {
294
+ const result = await callService<Record<string, unknown>, { removed: boolean }>("graph.unlink", { from: params.from!, relation: params.relation!, to: params.to! });
295
+ const output = result.removed ? `Unlinked ${params.from} --${params.relation}--> ${params.to}` : `No such relationship: ${params.from} --${params.relation}--> ${params.to}`;
296
+ return text(output, createPreviewDetails("graph.unlink", "Artifact relationship", output));
252
297
  }
253
298
  if (params.action === "tree") {
254
299
  const root = params.id ?? params.from;
255
- if (!root) return text("Missing id for tree");
300
+ if (!root) throw new Error("missing id for tree");
256
301
  const a = await callService<Record<string, unknown>, Artifact | null>("graph.tree", {
257
302
  id: root,
258
303
  depth: params.depth,
259
304
  max_nodes: params.max_nodes,
260
305
  });
261
- if (!a) return text(`Artifact ${root} not found`);
262
- const edges = (a as any).edges ?? [];
263
- if (edges.length === 0) return text(`${a.title} — no edges`);
306
+ if (!a) throw new Error(`artifact ${root} not found`);
307
+ const edges = a.edges ?? [];
308
+ if (edges.length === 0) return text(`${a.title} — no edges`, createGraphDetails("graph.tree", [a], []));
264
309
  return text(
265
- `Subgraph from ${a.title} (${edges.length} edges):\n\n${edges.map((e: any) => ` ${e.from} --${e.relation}--> ${e.to}`).join("\n")}`,
266
- { edges },
310
+ `Subgraph from ${a.title} (${edges.length} edges):\n\n${edges.map((edge: any) => ` ${edge.from} --${edge.relation}--> ${edge.to}`).join("\n")}`,
311
+ createGraphDetails("graph.tree", [a], edges),
267
312
  );
268
313
  }
269
314
  if (params.action === "status") {
270
315
  const a = await callService<Record<string, unknown>, Artifact | null>("graph.status", { id: params.id!, status: params.status! });
271
- if (!a) return text(`Artifact ${params.id} not found`);
272
- return text(`Updated ${a.id} → [${a.status}]`, { artifact: a });
316
+ if (!a) throw new Error(`artifact ${params.id} not found`);
317
+ return text(`Updated ${a.id} → [${a.status}]`, createArtifactDetails("graph.status", a));
273
318
  }
274
- return text(`Unknown action: ${params.action}. Use 'link', 'tree', or 'status'.`);
319
+ if (params.action === "history") {
320
+ const page = await callService<Record<string, unknown>, { events: Array<Record<string, unknown>> }>("graph.history", {
321
+ id: params.id, actor: params.actor, session_id: params.session_id, since: params.since, limit: params.limit,
322
+ });
323
+ if (page.events.length === 0) return text("No recorded events.", createPreviewDetails("graph.history", "Mutation event log", "No recorded events."));
324
+ const output = page.events.map((event) => `${event["occurredAt"]} ${event["artifactId"]} ${event["type"]} · ${event["actor"]}/${event["source"]}`).join("\n");
325
+ return text(output, createPreviewDetails("graph.history", "Mutation event log", output));
326
+ }
327
+ throw new Error(`unknown action: ${params.action}; use link, tree, status, or history`);
275
328
  } catch (e) {
276
- return text(`papyrus_graph failed: ${e instanceof Error ? e.message : e}`);
329
+ throw new Error(`papyrus_graph failed: ${e instanceof Error ? e.message : e}`);
277
330
  }
278
331
  },
279
332
  });
@@ -288,6 +341,8 @@ export default async function (pi: ExtensionAPI) {
288
341
  depth: Type.Optional(Type.Number({ description: "edge traversal depth" })),
289
342
  max_nodes: Type.Optional(Type.Number({ description: "maximum traversed nodes" })),
290
343
  }),
344
+ renderCall(args, theme) { return renderPapyrusToolCall("Show artifact", args, theme); },
345
+ renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
291
346
  async execute(_id, params, _signal, _onUpdate, _ctx) {
292
347
  try {
293
348
  const a = await callService<Record<string, unknown>, Artifact | null>("artifact.show", {
@@ -296,21 +351,21 @@ export default async function (pi: ExtensionAPI) {
296
351
  depth: params.depth,
297
352
  max_nodes: params.max_nodes,
298
353
  });
299
- if (!a) return text(`Artifact ${params.id} not found`);
354
+ if (!a) throw new Error(`artifact ${params.id} not found`);
300
355
  let out = `${a.id} [${a.kind}|${a.status}]\n${a.title}\n\n${a.body}`;
301
356
  if (Object.keys(a.extra).length > 0) {
302
357
  out += `\n\nMetadata:\n${formatMetadata(a.extra).map((line) => ` ${line}`).join("\n")}`;
303
358
  }
304
- if ((a as any).edges?.length) {
305
- out += `\n\nEdges:\n${(a as any).edges.map((e: any) => ` ${e.from} --${e.relation}--> ${e.to}`).join("\n")}`;
359
+ if (a.edges?.length) {
360
+ out += `\n\nEdges:\n${a.edges.map((edge) => ` ${edge.from} --${edge.relation}--> ${edge.to}`).join("\n")}`;
306
361
  }
307
362
  if (params.run_gates) {
308
363
  const results = await callService<Record<string, unknown>, GateResult[]>("gates.run", { id: params.id });
309
- out += `\n\nGates:\n${results.map((g: any) => ` ${g.passed ? "✓" : "✗"} ${g.gate.type}: ${g.gate.target} — ${g.output}`).join("\n")}`;
364
+ out += `\n\nGates:\n${results.map((gate) => ` ${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n")}`;
310
365
  }
311
- return text(out, { artifact: a });
366
+ return text(out, createArtifactDetails("artifact.show", a));
312
367
  } catch (e) {
313
- return text(`papyrus_show failed: ${e instanceof Error ? e.message : e}`);
368
+ throw new Error(`papyrus_show failed: ${e instanceof Error ? e.message : e}`);
314
369
  }
315
370
  },
316
371
  });
@@ -331,6 +386,7 @@ export default async function (pi: ExtensionAPI) {
331
386
  description: "Browse and manage Papyrus tasks (interactive)",
332
387
  handler: async (_args, ctx) => {
333
388
  overlay?.setProjectRoot(ctx.cwd);
389
+ overlay?.setSessionId(ctx.sessionManager.getSessionId());
334
390
  await tasksModule.showTasks(ctx);
335
391
  await overlay?.refresh();
336
392
  },
@@ -355,6 +411,31 @@ export default async function (pi: ExtensionAPI) {
355
411
  description: "Browse and invoke Papyrus skills and templates (interactive)",
356
412
  handler: async (_args, ctx) => { await skillsModule.showSkills(ctx); },
357
413
  });
414
+ pi.registerCommand("context", {
415
+ 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",
416
+ handler: async (_args, ctx) => {
417
+ try {
418
+ const sessionId = ctx.sessionManager.getSessionId();
419
+ const [rules, taskSummary] = await Promise.all([
420
+ callService<Record<string, unknown>, Array<Pick<Artifact, "id" | "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd, session_id: sessionId }),
421
+ callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd, session_id: sessionId }),
422
+ ]);
423
+ const { skills } = computeContextBudget(rules, ctx.cwd);
424
+ const ruleBudget = computeRuleBudget(rules);
425
+ const usage = ctx.getContextUsage?.();
426
+ const breakdown = buildContextBreakdown({
427
+ totalTokens: usage?.tokens ?? null,
428
+ contextWindow: ctx.model?.contextWindow ?? null,
429
+ ruleBudget,
430
+ taskEstimatedTokens: taskSummary ? Math.ceil(taskSummary.length / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN) : 0,
431
+ skills,
432
+ });
433
+ await showContextView(ctx, breakdown, ruleBudget);
434
+ } catch (error) {
435
+ ctx.ui.notify(`Context breakdown failed: ${error instanceof Error ? error.message : error}`, "error");
436
+ }
437
+ },
438
+ });
358
439
 
359
440
  // ── Task widget (TodoOverlay pattern: factory form, requestRender) ──
360
441
 
@@ -363,9 +444,11 @@ export default async function (pi: ExtensionAPI) {
363
444
  overlay ??= new TaskOverlay();
364
445
  overlay.setUI(ctx.ui);
365
446
  overlay.setProjectRoot(ctx.cwd);
447
+ overlay.setSessionId(ctx.sessionManager.getSessionId());
366
448
  await overlay.refresh();
367
449
  });
368
450
 
451
+ pi.on("session_before_compact", () => { taskContinuation.onCompaction(); });
369
452
  pi.on("session_compact", async () => { await overlay?.refresh(); });
370
453
  pi.on("session_tree", async () => { await overlay?.refresh(); });
371
454
  pi.on("session_shutdown", async () => { overlay?.dispose(); overlay = undefined; });
@@ -381,13 +464,15 @@ export default async function (pi: ExtensionAPI) {
381
464
  // agent_settled is intentionally later than agent_end: Pi guarantees that
382
465
  // retry, compaction retry, and queued follow-up processing have finished.
383
466
 
384
- pi.on("input", async (event) => {
467
+ pi.on("input", async (event, ctx) => {
385
468
  if (event.source === "extension") return;
386
469
  taskContinuation.onHumanInput();
387
470
  try {
388
- const focus = await callService<Record<string, never>, { status: string; pauseReason?: string } | null>("tasks.focused", {});
471
+ const sessionId = ctx.sessionManager.getSessionId();
472
+ const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string; pauseReason?: string } | null>("tasks.focused", { session_id: sessionId });
389
473
  if (focus && shouldResumeFocusOnHumanInput(focus.status, focus.pauseReason)) {
390
- await callService("tasks.unpause", { actor: "system", source: "task-continuation", reason: "human input resumed automatic task continuation" });
474
+ await callService("tasks.unpause", { actor: "system", source: "task-continuation", reason: "human input resumed automatic task continuation", session_id: sessionId });
475
+ emitTaskFocusEvent({ taskId: focus.artifact.id, sessionId, status: "unpaused" });
391
476
  }
392
477
  } catch {
393
478
  // The daemon may be unavailable during startup, reload, or shutdown.
@@ -402,9 +487,10 @@ export default async function (pi: ExtensionAPI) {
402
487
 
403
488
  pi.on("before_agent_start", async (event, ctx) => {
404
489
  try {
490
+ const sessionId = ctx.sessionManager.getSessionId();
405
491
  const [rules, summary] = await Promise.all([
406
- callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd }),
407
- callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd }),
492
+ callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd, session_id: sessionId }),
493
+ callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd, session_id: sessionId }),
408
494
  ]);
409
495
  const injection = buildContextInjection({
410
496
  basePrompt: event.systemPrompt ?? "",
@@ -1,11 +1,11 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { NOTE_LIST_MAX_LIMIT } from "../../src/constants.ts";
2
3
  import { NOTE_DISPOSITIONS } from "../../src/note-service.ts";
3
4
  import type { Artifact } from "../../src/domain/artifact.ts";
4
5
  import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
6
+ import { NOTE_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
5
7
  import { callService } from "./service-client.ts";
6
8
 
7
- const NOTE_GLYPHS: Record<string, string> = { draft: "○", active: "●", archived: "■" };
8
-
9
9
  export function noteRowMeta(note: Artifact): string {
10
10
  const history = Array.isArray(note.extra["noteHistory"]) ? note.extra["noteHistory"].length : 0;
11
11
  return `${history} event${history === 1 ? "" : "s"}`;
@@ -17,6 +17,18 @@ export function noteCaptureInput(request: string, projectRoot: string): Record<s
17
17
  return { body, project_root: projectRoot, actor: "human", source: "note-command" };
18
18
  }
19
19
 
20
+ /**
21
+ * The generic artifact browser (extension/src/artifact-browser.ts) requests a fixed 500-row
22
+ * page by default, but notes.list enforces its own tighter NOTE_LIST_MAX_LIMIT (200) — an
23
+ * unqualified /notes call exceeded that bound and the browser surfaced the daemon's rejection
24
+ * as an opaque extension error instead of ever rendering. Passing an explicit limit here that
25
+ * respects the Notes-specific bound is the fix; the generic browser's default stays as-is
26
+ * since no other kind's list operation has a bound below 500.
27
+ */
28
+ export function noteListInput(projectRoot: string): Record<string, unknown> {
29
+ return { project_root: projectRoot, limit: NOTE_LIST_MAX_LIMIT };
30
+ }
31
+
20
32
  export async function captureNote(request: string, ctx: ExtensionCommandContext): Promise<Artifact | null> {
21
33
  const input = noteCaptureInput(request, ctx.cwd);
22
34
  if (!input) {
@@ -38,9 +50,9 @@ export async function showNotes(ctx: ExtensionCommandContext): Promise<void> {
38
50
  kind: "note",
39
51
  title: "Notes inbox",
40
52
  listOperation: "notes.list",
41
- listInput: { project_root: ctx.cwd },
53
+ listInput: noteListInput(ctx.cwd),
42
54
  statusOrder: ["draft", "active", "archived"],
43
- glyphs: NOTE_GLYPHS,
55
+ presentation: NOTE_STATUS_PRESENTATION,
44
56
  rowMeta: noteRowMeta,
45
57
  actions: (note) => [
46
58
  "Show details",
@@ -1,14 +1,14 @@
1
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import type { Artifact } from "../../src/domain/artifact.ts";
3
3
  import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
4
+ import { RULE_STATUS_PRESENTATION, severityColor } from "./artifact-status-presentation.ts";
4
5
  import { callService } from "./service-client.ts";
5
6
 
6
- const RULE_GLYPHS: Record<string, string> = { active: "●", deprecated: "○" };
7
-
8
- export function ruleRowMeta(rule: Artifact): string {
9
- const severity = typeof rule.extra["severity"] === "string" ? rule.extra["severity"].toUpperCase() : "INFO";
7
+ export function ruleRowMeta(rule: Artifact, theme: Theme): string {
8
+ const severity = typeof rule.extra["severity"] === "string" ? rule.extra["severity"] : "info";
9
+ const severityText = theme.fg(severityColor(severity), severity.toUpperCase());
10
10
  const condition = typeof rule.extra["condition"] === "string" ? `when ${rule.extra["condition"]}` : "always";
11
- return `${severity} · ${condition}`;
11
+ return `${severityText} · ${condition}`;
12
12
  }
13
13
 
14
14
  export function ruleInjectionPreview(rule: Pick<Artifact, "title" | "body" | "extra">): string {
@@ -23,7 +23,7 @@ export async function showRules(ctx: ExtensionCommandContext): Promise<void> {
23
23
  title: "Rules",
24
24
  listOperation: "rules.list",
25
25
  statusOrder: ["active", "deprecated"],
26
- glyphs: RULE_GLYPHS,
26
+ presentation: RULE_STATUS_PRESENTATION,
27
27
  rowMeta: ruleRowMeta,
28
28
  actions: (rule) => ["Show details", "Preview injection", "Link gated task", rule.status === "active" ? "Disable" : "Enable"],
29
29
  handleAction: async (choice, rule, commandCtx) => {
@@ -0,0 +1,183 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN } from "../../src/constants.ts";
4
+
5
+ /**
6
+ * Pi-native skills (SKILL.md) carry a real, permanent context tax independent of Papyrus:
7
+ * per Pi's own docs, every discovered skill's name+description is injected into the system
8
+ * prompt unconditionally at startup (the Agent Skills spec's "catalog" tier, ~50-100 tokens
9
+ * per skill). This module measures that tax by replicating Pi's own documented discovery
10
+ * rules (docs/skills.md "Locations" section) directly against the filesystem, rather than
11
+ * trying to parse it back out of the assembled system prompt -- Pi does not document (and
12
+ * this repo must not depend on) the exact wire format it uses to inject the catalog, so
13
+ * re-deriving the same inputs Pi itself reads is the robust approach, not a fragile one.
14
+ * Package-declared skills (pi.skills in package.json / packages' own skills/ directories)
15
+ * are deliberately out of scope: enumerating every installed package for skill declarations
16
+ * is a materially larger, slower scan than reading a handful of known directories, and this
17
+ * tool is a budget estimate, not an exhaustive audit.
18
+ */
19
+ export interface SkillCatalogEntry {
20
+ name: string;
21
+ description: string;
22
+ location: string;
23
+ characters: number;
24
+ estimatedTokens: number;
25
+ }
26
+
27
+ export interface SkillCatalogFootprint {
28
+ entries: SkillCatalogEntry[];
29
+ totalCharacters: number;
30
+ totalEstimatedTokens: number;
31
+ scannedDirectories: string[];
32
+ }
33
+
34
+ export const SKILL_SCAN_MAX_DEPTH = 6;
35
+ export const SKILL_SCAN_MAX_DIRECTORIES = 2000;
36
+ export const SKILL_SCAN_MAX_SKILLS = 500;
37
+
38
+ function unquote(value: string): string {
39
+ if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
40
+ return value.slice(1, -1);
41
+ }
42
+ return value;
43
+ }
44
+
45
+ /**
46
+ * Extracts `name` and `description` from a SKILL.md's YAML frontmatter, tolerating the
47
+ * folded (`>`) and literal (`|`) block-scalar forms real-world skills commonly use for
48
+ * multi-line descriptions. Deliberately not a general YAML parser -- only the two fields
49
+ * the Agent Skills spec requires are extracted; anything else in the frontmatter is ignored.
50
+ */
51
+ export function parseSkillFrontmatter(content: string): { name: string; description: string } | null {
52
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
53
+ if (!match) return null;
54
+ const lines = match[1]!.split(/\r?\n/);
55
+ let name = "";
56
+ let description = "";
57
+ for (let index = 0; index < lines.length; index++) {
58
+ const line = lines[index]!;
59
+ const nameMatch = line.match(/^name:\s*(.*)$/);
60
+ if (nameMatch) {
61
+ name = unquote(nameMatch[1]!.trim());
62
+ continue;
63
+ }
64
+ const descriptionMatch = line.match(/^description:\s*(.*)$/);
65
+ if (!descriptionMatch) continue;
66
+ const rest = descriptionMatch[1]!.trim();
67
+ if (rest === ">" || rest === ">-" || rest === "|" || rest === "|-") {
68
+ const collected: string[] = [];
69
+ let cursor = index + 1;
70
+ while (cursor < lines.length && (lines[cursor] === "" || /^\s+/.test(lines[cursor]!))) {
71
+ collected.push(lines[cursor]!.trim());
72
+ cursor++;
73
+ }
74
+ description = collected.join(rest.startsWith("|") ? "\n" : " ").trim();
75
+ index = cursor - 1;
76
+ } else {
77
+ description = unquote(rest);
78
+ }
79
+ }
80
+ if (!name || !description) return null;
81
+ return { name, description };
82
+ }
83
+
84
+ /** True at the filesystem root on POSIX (`/`) and Windows (`C:\`, `D:\`, ...). */
85
+ function isFilesystemRoot(path: string): boolean {
86
+ return dirname(path) === path;
87
+ }
88
+
89
+ /**
90
+ * Global and project skill directories per Pi's own documented discovery rules, plus any
91
+ * explicit paths configured in settings.json's `skills` array. Project directories are
92
+ * collected walking from `cwd` up to the git repository root (or filesystem root when not
93
+ * in a repo), matching "up to git repo root, or filesystem root when not in a repo" exactly.
94
+ */
95
+ export function discoverSkillDirectories(homeDirectory: string, cwd: string, settingsSkills: readonly string[] = []): string[] {
96
+ const directories = [join(homeDirectory, ".pi", "agent", "skills"), join(homeDirectory, ".agents", "skills")];
97
+ let current = cwd;
98
+ for (let depth = 0; depth < SKILL_SCAN_MAX_DIRECTORIES; depth++) {
99
+ directories.push(join(current, ".pi", "skills"), join(current, ".agents", "skills"));
100
+ if (existsSync(join(current, ".git")) || isFilesystemRoot(current)) break;
101
+ current = dirname(current);
102
+ }
103
+ directories.push(...settingsSkills);
104
+ return [...new Set(directories)];
105
+ }
106
+
107
+ interface ScanContext {
108
+ entries: SkillCatalogEntry[];
109
+ seenLocations: Set<string>;
110
+ directoriesVisited: number;
111
+ }
112
+
113
+ /** Root-level .md files count as individual skills only in these two locations, per Pi's docs. */
114
+ function allowsRootMarkdownFiles(directory: string): boolean {
115
+ return directory.endsWith(join(".pi", "agent", "skills")) || directory.endsWith(join(".pi", "skills"));
116
+ }
117
+
118
+ function recordSkillFile(context: ScanContext, path: string): void {
119
+ if (context.seenLocations.has(path) || context.entries.length >= SKILL_SCAN_MAX_SKILLS) return;
120
+ let content: string;
121
+ try {
122
+ content = readFileSync(path, "utf8");
123
+ } catch {
124
+ return;
125
+ }
126
+ const parsed = parseSkillFrontmatter(content);
127
+ if (!parsed) return;
128
+ context.seenLocations.add(path);
129
+ const characters = parsed.name.length + parsed.description.length;
130
+ context.entries.push({
131
+ name: parsed.name,
132
+ description: parsed.description,
133
+ location: path,
134
+ characters,
135
+ estimatedTokens: Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN),
136
+ });
137
+ }
138
+
139
+ function walk(context: ScanContext, directory: string, depth: number, allowRootMarkdown: boolean): void {
140
+ if (depth > SKILL_SCAN_MAX_DEPTH || context.directoriesVisited >= SKILL_SCAN_MAX_DIRECTORIES) return;
141
+ context.directoriesVisited++;
142
+ let names: string[];
143
+ try {
144
+ names = readdirSync(directory);
145
+ } catch {
146
+ return;
147
+ }
148
+ for (const name of names) {
149
+ if (name === "node_modules" || name === ".git") continue;
150
+ const path = join(directory, name);
151
+ let stat: ReturnType<typeof statSync>;
152
+ try {
153
+ stat = statSync(path);
154
+ } catch {
155
+ continue;
156
+ }
157
+ if (stat.isDirectory()) {
158
+ const skillFile = join(path, "SKILL.md");
159
+ if (existsSync(skillFile)) recordSkillFile(context, skillFile);
160
+ else walk(context, path, depth + 1, false);
161
+ } else if (allowRootMarkdown && depth === 0 && name.toLowerCase().endsWith(".md")) {
162
+ recordSkillFile(context, path);
163
+ }
164
+ }
165
+ }
166
+
167
+ /** Bounded, best-effort scan: a missing or unreadable directory is silently skipped, not an error. */
168
+ export function scanSkillCatalogFootprint(directories: readonly string[]): SkillCatalogFootprint {
169
+ const context: ScanContext = { entries: [], seenLocations: new Set(), directoriesVisited: 0 };
170
+ const scanned: string[] = [];
171
+ for (const directory of directories) {
172
+ if (!existsSync(directory) || !statSync(directory).isDirectory()) continue;
173
+ scanned.push(directory);
174
+ walk(context, directory, 0, allowsRootMarkdownFiles(directory));
175
+ }
176
+ const entries = context.entries.sort((a, b) => b.characters - a.characters);
177
+ return {
178
+ entries,
179
+ totalCharacters: entries.reduce((sum, entry) => sum + entry.characters, 0),
180
+ totalEstimatedTokens: entries.reduce((sum, entry) => sum + entry.estimatedTokens, 0),
181
+ scannedDirectories: scanned,
182
+ };
183
+ }
@@ -3,11 +3,10 @@ import type { Artifact } from "../../src/domain/artifact.ts";
3
3
  import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
4
4
  import type { TaskGraph } from "../../src/task-service.ts";
5
5
  import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
6
+ import { SKILL_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
6
7
  import { callService } from "./service-client.ts";
7
8
  import { showTaskGraph } from "./task-graph.ts";
8
9
 
9
- const SKILL_GLYPHS: Record<string, string> = { active: "●", deprecated: "○" };
10
-
11
10
  function strings(value: unknown): string[] {
12
11
  return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
13
12
  }
@@ -73,7 +72,7 @@ export async function showSkills(ctx: ExtensionCommandContext): Promise<void> {
73
72
  title: "Skills",
74
73
  listOperation: "skills.list",
75
74
  statusOrder: ["active", "deprecated"],
76
- glyphs: SKILL_GLYPHS,
75
+ presentation: SKILL_STATUS_PRESENTATION,
77
76
  rowMeta: skillRowMeta,
78
77
  actions: (skill) => [
79
78
  "Show details",