@danypops/papyrus 0.33.1 → 0.33.3

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.
@@ -164,7 +164,7 @@ export function normalizeJsonEncodedField(params: Record<string, unknown>, key:
164
164
  }
165
165
 
166
166
  /** Resolves every {nameKey -> idKey} pair present and not already satisfied by an explicit id, in place. */
167
- async function resolveNameFields(
167
+ export async function resolveNameFields(
168
168
  params: Record<string, unknown>,
169
169
  fields: ReadonlyArray<{ nameKey: string; idKey: string; listOperation: OperationName; baseRequest: Record<string, unknown> }>,
170
170
  ): Promise<void> {
@@ -474,7 +474,7 @@ export function registerNotesTool(pi: ExtensionAPI): void {
474
474
  pi.registerTool({
475
475
  name: "notes",
476
476
  label: "Notes",
477
- description: "Deferred human-intent inbox. ACTIONS: capture, list, show, consume, promote, archive. Capture stores a request without creating work. Consume marks it considered. To promote, first create the resulting Task, Doc, Rule, or Skill through its domain tool, then link it with target_id. Archive requires an explicit disposition. PREFER `name` (the note's exact title) over `id` for show/consume/promote/archive -- id is a backend implementation detail, resolved from name automatically.",
477
+ description: "Deferred human-intent inbox. ACTIONS: capture, list, show, consume, promote, archive. Capture stores a request without creating work. Consume marks it considered. To promote, first create the resulting Task, Doc, Rule, or Skill through its domain tool, then link it with target_id (or target_name). Archive requires an explicit disposition. PREFER `name` (the note's exact title) over `id` for show/consume/promote/archive, and `target_name` over `target_id` for promote -- all are backend implementation details, resolved from name automatically (target_name searches across every kind, since a promotion target can be a task, doc, rule, or skill).",
478
478
  parameters: Type.Object({
479
479
  action: Type.String(),
480
480
  id: Type.Optional(Type.String()),
@@ -485,6 +485,7 @@ export function registerNotesTool(pi: ExtensionAPI): void {
485
485
  text: Type.Optional(Type.String()),
486
486
  limit: Type.Optional(Type.Number()),
487
487
  target_id: Type.Optional(Type.String()),
488
+ target_name: Type.Optional(Type.String()),
488
489
  disposition: Type.Optional(Type.Union(NOTE_DISPOSITIONS.map((value) => Type.Literal(value)))),
489
490
  reason: Type.Optional(Type.String()),
490
491
  session_id: Type.Optional(Type.String()),
@@ -497,7 +498,11 @@ export function registerNotesTool(pi: ExtensionAPI): void {
497
498
  const params: Record<string, unknown> = { ...rawParams };
498
499
  const action = params.action;
499
500
  const baseRequest = { project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "notes-tool" };
500
- await resolveNameFields(params, [{ nameKey: "name", idKey: "id", listOperation: "notes.list", baseRequest }]);
501
+ await resolveNameFields(params, [
502
+ { nameKey: "name", idKey: "id", listOperation: "notes.list", baseRequest },
503
+ // Kind-agnostic: a promotion target can be a task, doc, rule, or skill, so this searches every kind rather than only notes.
504
+ { nameKey: "target_name", idKey: "target_id", listOperation: "artifact.query", baseRequest },
505
+ ]);
501
506
  const request = { ...params, ...baseRequest };
502
507
  if (action === "capture") {
503
508
  const artifact = await callService<Record<string, unknown>, Artifact>("notes.capture", request);
@@ -16,12 +16,13 @@ import {
16
16
  TASK_DRIVER_MAX_UNCHANGED_TURNS,
17
17
  PAPYRUS_CONTEXT_INJECTION_CHANNEL,
18
18
  CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN,
19
+ TASK_WIDGET_POLL_INTERVAL_MS,
19
20
  } from "../../src/constants.ts";
20
21
  import type { Artifact } from "../../src/domain/artifact.ts";
21
22
  import type { GateResult } from "../../src/domain/gate.ts";
22
23
  import { formatMetadata } from "./artifact-format.ts";
23
24
  import { callService } from "./service-client.ts";
24
- import { registerDomainTools } from "./domain-tools.ts";
25
+ import { registerDomainTools, resolveNameFields } from "./domain-tools.ts";
25
26
  import { ensureTypingCourtesyTracking, isLiveAskPending } from "./discuss-ask-view.ts";
26
27
  import { PLAYBOOK_BRIDGE_MAX_PLAYBOOKS, registerPlaybookBridge } from "./playbook-bridge.ts";
27
28
  import type { TaskGraph, TaskStatus } from "../../src/task-service.ts";
@@ -102,6 +103,7 @@ export class TaskOverlay {
102
103
  private snapshot: TaskGraph = { nodes: [], rootIds: [] };
103
104
  private projectRoot: string | undefined;
104
105
  private sessionId: string | undefined;
106
+ private pollTimer: ReturnType<typeof setInterval> | undefined;
105
107
 
106
108
  setUI(ctx: ExtensionUIContext): void {
107
109
  if (ctx !== this.uiCtx) {
@@ -175,7 +177,24 @@ export class TaskOverlay {
175
177
  return renderTaskWidgetLines(theme, buildTaskWidgetProjection(this.snapshot), width);
176
178
  }
177
179
 
180
+ /**
181
+ * Fallback for a Task mutation no event announces -- the CLI run directly from a shell, or
182
+ * a second concurrent Pi session against the same daemon. Idempotent: a second call is a
183
+ * no-op rather than starting a competing timer.
184
+ */
185
+ startPolling(intervalMs: number = TASK_WIDGET_POLL_INTERVAL_MS): void {
186
+ if (this.pollTimer) return;
187
+ this.pollTimer = setInterval(() => { void this.refresh(); }, intervalMs);
188
+ }
189
+
190
+ stopPolling(): void {
191
+ if (!this.pollTimer) return;
192
+ clearInterval(this.pollTimer);
193
+ this.pollTimer = undefined;
194
+ }
195
+
178
196
  dispose(): void {
197
+ this.stopPolling();
179
198
  this.uiCtx?.setWidget(WIDGET_KEY, undefined);
180
199
  this.registered = false;
181
200
  this.tui = undefined;
@@ -312,12 +331,15 @@ export default async function (pi: ExtensionAPI) {
312
331
  "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), " +
313
332
  "tree (id → bounded BFS subgraph), " +
314
333
  "history (who did what, when — requires id, actor, or session_id). " +
315
- "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.",
334
+ "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. " +
335
+ "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.",
316
336
  parameters: Type.Object({
317
337
  action: Type.String({ description: "link | unlink | tree | status | history" }),
318
338
  from: Type.Optional(Type.String()),
339
+ from_name: Type.Optional(Type.String()),
319
340
  relation: Type.Optional(Type.String()),
320
341
  to: Type.Optional(Type.String()),
342
+ to_name: Type.Optional(Type.String()),
321
343
  id: Type.Optional(Type.String()),
322
344
  status: Type.Optional(Type.String()),
323
345
  depth: Type.Optional(Type.Number({ description: "tree traversal depth; bounded by a hard ceiling" })),
@@ -329,18 +351,26 @@ export default async function (pi: ExtensionAPI) {
329
351
  }),
330
352
  renderCall(args, theme) { return renderPapyrusToolCall("Artifact graph", args, theme); },
331
353
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
332
- async execute(_id, params, _signal, _onUpdate, _ctx) {
354
+ async execute(_id, rawParams, _signal, _onUpdate, _ctx) {
333
355
  try {
356
+ const params: Record<string, unknown> = { ...rawParams };
357
+ if (params.action === "link" || params.action === "unlink") {
358
+ // Kind-agnostic: either end of an edge can be a task, doc, rule, skill, or playbook.
359
+ await resolveNameFields(params, [
360
+ { nameKey: "from_name", idKey: "from", listOperation: "artifact.query", baseRequest: {} },
361
+ { nameKey: "to_name", idKey: "to", listOperation: "artifact.query", baseRequest: {} },
362
+ ]);
363
+ }
334
364
  if (params.action === "link") {
335
- await callService("graph.link", { from: params.from!, relation: params.relation!, to: params.to! });
336
- const names = await artifactNamesById([params.from!, params.to!]);
337
- const output = `Linked "${names.get(params.from!) ?? "unknown artifact"}" --${params.relation}--> "${names.get(params.to!) ?? "unknown artifact"}"`;
365
+ await callService("graph.link", { from: params.from as string, relation: params.relation as string, to: params.to as string });
366
+ const names = await artifactNamesById([params.from as string, params.to as string]);
367
+ const output = `Linked "${names.get(params.from as string) ?? "unknown artifact"}" --${params.relation}--> "${names.get(params.to as string) ?? "unknown artifact"}"`;
338
368
  return text(output, createPreviewDetails("graph.link", "Artifact relationship", output));
339
369
  }
340
370
  if (params.action === "unlink") {
341
- const result = await callService<Record<string, unknown>, { removed: boolean }>("graph.unlink", { from: params.from!, relation: params.relation!, to: params.to! });
342
- const names = await artifactNamesById([params.from!, params.to!]);
343
- const relationship = `"${names.get(params.from!) ?? "unknown artifact"}" --${params.relation}--> "${names.get(params.to!) ?? "unknown artifact"}"`;
371
+ 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 });
372
+ const names = await artifactNamesById([params.from as string, params.to as string]);
373
+ const relationship = `"${names.get(params.from as string) ?? "unknown artifact"}" --${params.relation}--> "${names.get(params.to as string) ?? "unknown artifact"}"`;
344
374
  const output = result.removed ? `Unlinked ${relationship}` : `No such relationship: ${relationship}`;
345
375
  return text(output, createPreviewDetails("graph.unlink", "Artifact relationship", output));
346
376
  }
@@ -549,6 +579,7 @@ export default async function (pi: ExtensionAPI) {
549
579
  overlay.setProjectRoot(ctx.cwd);
550
580
  overlay.setSessionId(ctx.sessionManager.getSessionId());
551
581
  await overlay.refresh();
582
+ overlay.startPolling(TASK_WIDGET_POLL_INTERVAL_MS);
552
583
  });
553
584
 
554
585
  pi.on("session_before_compact", () => { taskContinuation.onCompaction(); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.33.1",
3
+ "version": "0.33.3",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
package/src/constants.ts CHANGED
@@ -65,6 +65,12 @@ export const TASK_CONTEXT_CURRENT_LIMIT = 3;
65
65
  export const TASK_CONTEXT_REJECTED_LIMIT = 3;
66
66
  export const TASK_WIDGET_OPEN_LIMIT = 3;
67
67
  export const TASK_DETAIL_MIN_VISIBLE_LINES = 8;
68
+ /**
69
+ * Event-triggered refresh (tool_execution_end, session_compact/tree) can't see a Task
70
+ * mutation from outside this Pi session -- another concurrent session, or the CLI run
71
+ * directly from a shell. This bounded poll is the fallback for exactly that gap.
72
+ */
73
+ export const TASK_WIDGET_POLL_INTERVAL_MS = 20_000;
68
74
  export const TASK_DETAIL_MAX_VISIBLE_LINES = 24;
69
75
  export const TASK_DETAIL_RESERVED_ROWS = 8;
70
76
  export const TASK_DETAIL_HORIZONTAL_PAN_COLUMNS = 4;