@danypops/pi-papyrus 0.38.1 → 0.38.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.
@@ -1,14 +1,12 @@
1
1
  import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import {
4
- NOTE_DISPOSITIONS,
5
4
  PROOF_TYPES,
6
5
  readDiscussionExtra,
7
6
  type Artifact,
8
7
  type DiscussionAndRounds,
9
8
  type DiscussionRound,
10
9
  type GateResult,
11
- type NoteHistoryPage,
12
10
  type OperationName,
13
11
  type WorkflowRunResult,
14
12
  type TaskCompletion,
@@ -491,69 +489,9 @@ export function registerTasksTool(pi: ExtensionAPI): void {
491
489
  });
492
490
  }
493
491
 
494
- export function registerNotesTool(pi: ExtensionAPI): void {
495
- pi.registerTool({
496
- name: "notes",
497
- label: "Notes",
498
- description: "Deferred human-intent inbox. ACTIONS: capture, list, show, history, 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. history returns this note's own real append-only event log (captured/consumed/promoted/archived), not the generic cross-kind graph.history. PREFER `name` (the note's exact title) over `id` for show/history/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).",
499
- parameters: Type.Object({
500
- action: Type.String(),
501
- id: Type.Optional(Type.String()),
502
- name: Type.Optional(Type.String()),
503
- body: Type.Optional(Type.String()),
504
- title: Type.Optional(Type.String()),
505
- status: Type.Optional(Type.Union([Type.Literal("draft"), Type.Literal("active"), Type.Literal("archived")])),
506
- text: Type.Optional(Type.String()),
507
- limit: Type.Optional(Type.Number()),
508
- target_id: Type.Optional(Type.String()),
509
- target_name: Type.Optional(Type.String()),
510
- disposition: Type.Optional(Type.Union(NOTE_DISPOSITIONS.map((value) => Type.Literal(value)))),
511
- reason: Type.Optional(Type.String()),
512
- session_id: Type.Optional(Type.String()),
513
- project_root: Type.Optional(Type.String()),
514
- }),
515
- renderCall(args, theme) { return renderPapyrusToolCall("Notes", args, theme); },
516
- renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
517
- async execute(_id, rawParams, _signal, _onUpdate, ctx) {
518
- try {
519
- const params: Record<string, unknown> = { ...rawParams };
520
- const action = params.action;
521
- const baseRequest = { project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "notes-tool" };
522
- await resolveNameFields(params, [
523
- { nameKey: "name", idKey: "id", listOperation: "notes.list", baseRequest },
524
- // Kind-agnostic: a promotion target can be a task, doc, rule, or skill, so this searches every kind rather than only notes.
525
- { nameKey: "target_name", idKey: "target_id", listOperation: "artifact.query", baseRequest },
526
- ]);
527
- const request = { ...params, ...baseRequest };
528
- if (action === "capture") {
529
- const artifact = await callService<Record<string, unknown>, Artifact>("notes.capture", request);
530
- return text(`Captured note ${artifactLine(artifact)}`, createArtifactDetails("notes.capture", artifact));
531
- }
532
- if (action === "list") {
533
- const rows = await callService<Record<string, unknown>, Artifact[]>("notes.list", request);
534
- return text(rows.length ? artifactLines(rows).join("\n") : "No open notes.", createArtifactListDetails("notes.list", rows));
535
- }
536
- if (action === "show") {
537
- const artifact = await callService<Record<string, unknown>, Artifact>("notes.show", request);
538
- return text(`${artifactLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("notes.show", artifact));
539
- }
540
- if (action === "history") {
541
- const page = await callService<Record<string, unknown>, NoteHistoryPage>("notes.history", request);
542
- const lines = page.events.map((event) => `${event.occurredAt} ${event.type} · ${event.actor}/${event.source}${event.relatedId ? ` · ${event.relatedId}` : ""}${event.disposition ? ` · ${event.disposition}` : ""}${event.reason ? ` · ${event.reason}` : ""}`);
543
- const output = lines.join("\n") || "No recorded history for this note.";
544
- return text(output, createPreviewDetails("notes.history", "Note history", output));
545
- }
546
- const operations = { consume: "notes.consume", promote: "notes.promote", archive: "notes.archive" } as const;
547
- const operation = operations[action as keyof typeof operations];
548
- if (!operation) throw new Error(`unknown notes action: ${action}`);
549
- const artifact = await callService<Record<string, unknown>, Artifact>(operation, request);
550
- return text(`${action}: ${artifactLine(artifact)}`, createArtifactDetails(operation, artifact));
551
- } catch (error) {
552
- throw new Error(`notes failed: ${error instanceof Error ? error.message : error}`);
553
- }
554
- },
555
- });
556
- }
492
+ // notes.* is registered as a real Vehicle (see ../vehicle-notes-client.ts and index.ts),
493
+ // not a hand-rolled pi.registerTool() -- the first domain migrated off the
494
+ // action-dispatch mega-tool pattern this file's other tools still use.
557
495
 
558
496
  export function registerDocsTool(pi: ExtensionAPI): void {
559
497
  pi.registerTool({
@@ -943,7 +881,6 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
943
881
  /** Thin orchestrator: each domain's tool is independently navigable/testable via its own registerXTool function. */
944
882
  export function registerDomainTools(pi: ExtensionAPI): void {
945
883
  registerTasksTool(pi);
946
- registerNotesTool(pi);
947
884
  registerDocsTool(pi);
948
885
  registerRulesTool(pi);
949
886
  registerPlaybooksTool(pi);
@@ -28,6 +28,7 @@ import { formatMetadata } from "./artifact-format.ts";
28
28
  import { callService, subscribeTaskPushChannel } from "./service-client.ts";
29
29
  import type { PushChannelClient } from "@danypops/daemon-kit/pi-client";
30
30
  import { registerDomainTools, resolveNameFields } from "./domain-tools.ts";
31
+ import { registerNotesVehicle } from "./vehicle-notes-client.ts";
31
32
  import { BoundedPoll } from "./bounded-poll.ts";
32
33
  import { renderNoteWidgetLines } from "./note-widget.ts";
33
34
  import { ensureTypingCourtesyTracking, isLiveAskPending } from "./discuss-ask-view.ts";
@@ -677,6 +678,18 @@ export default async function (pi: ExtensionAPI) {
677
678
  // ── Task widget (TodoOverlay pattern: factory form, requestRender) ──
678
679
 
679
680
  pi.on("session_start", async (_event, ctx) => {
681
+ // registerVehicleTools() (which registerNotesVehicle wraps) needs
682
+ // pi.getAllTools()/getActiveTools()/setActiveTools() -- Pi's extension
683
+ // runtime only finishes initializing after every extension's top-level
684
+ // factory (this one included) has resolved, so calling it directly from
685
+ // there throws "Extension runtime not initialized" (previously silently
686
+ // swallowed by registerNotesVehicle's own daemon-unreachable try/catch,
687
+ // making every projected notes.* tool invisible to the model with zero
688
+ // visible sign why -- confirmed live in the identical pi-tickets bug).
689
+ // session_start fires only after that initialization completes, and Pi
690
+ // awaits every session_start handler before the model's first turn, so
691
+ // registering here is both safe and still visible on turn one.
692
+ await registerNotesVehicle(pi);
680
693
  // Registers this session's identity with the daemon as early as possible -- before any
681
694
  // Focus-mutating call could plausibly happen -- shrinking (not eliminating; see
682
695
  // domain/session-identity.ts) the first-touch race window. Best-effort: the daemon may be
@@ -731,7 +744,9 @@ export default async function (pi: ExtensionAPI) {
731
744
  if (event.toolName.startsWith("papyrus_") || event.toolName === "tasks") {
732
745
  await overlay?.refresh();
733
746
  }
734
- if (event.toolName === "notes") {
747
+ // notes.* projects to notes_capture/notes_list/notes_show/... (see
748
+ // registerNotesVehicle) -- not a single "notes" tool name anymore.
749
+ if (event.toolName.startsWith("notes_")) {
735
750
  await noteOverlay?.refresh();
736
751
  }
737
752
  });
@@ -1,5 +1,5 @@
1
1
  import { connectPushChannel, createRetryingClient, type PushChannelClient, type PushChannelState, type RetryingClient } from "@danypops/daemon-kit/pi-client";
2
- import { connectPapyrusClient, resolvePushChannelTarget, type OperationName, type PapyrusClient } from "@danypops/papyrus";
2
+ import { connectPapyrusClient, resolvePushChannelTarget, resolveVehicleClientTarget, type OperationName, type PapyrusClient, type VehicleClientTarget } from "@danypops/papyrus";
3
3
 
4
4
  type ClientConnector = () => Promise<PapyrusClient>;
5
5
 
@@ -37,6 +37,27 @@ export function resetPushChannelTargetResolverForTests(): void {
37
37
  pushChannelTargetResolver = resolvePushChannelTarget;
38
38
  }
39
39
 
40
+ let vehicleClientTargetResolver: typeof resolveVehicleClientTarget = resolveVehicleClientTarget;
41
+
42
+ /**
43
+ * Defaults to the real daemonStateDir() -- every test that exercises the full extension
44
+ * entrypoint (registerPapyrus(api)), not just registerDomainTools, must override this
45
+ * first, the same way mockService already overrides setPapyrusClientConnectorForTests,
46
+ * or a hermetic unit test can silently start depending on whatever real Papyrus daemon
47
+ * handle happens to exist on the machine running it.
48
+ */
49
+ export function setVehicleClientTargetResolverForTests(value: () => VehicleClientTarget | undefined): void {
50
+ vehicleClientTargetResolver = value;
51
+ }
52
+
53
+ export function resetVehicleClientTargetResolverForTests(): void {
54
+ vehicleClientTargetResolver = resolveVehicleClientTarget;
55
+ }
56
+
57
+ export function currentVehicleClientTarget(): VehicleClientTarget | undefined {
58
+ return vehicleClientTargetResolver();
59
+ }
60
+
40
61
  /**
41
62
  * Subscribes to the daemon's "tasks" push topic so a widget can refresh the moment
42
63
  * a mutation happens, instead of waiting for its next poll tick. Returns undefined
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Registers notes.* as a real Vehicle instead of a hand-rolled `pi.registerTool()`
3
+ * mega-tool -- see @danypops/papyrus's src/vehicle/notes-vehicle.ts for the
4
+ * VehicleRegistry side. Same daemon, same handle file, same Bearer token every
5
+ * other Papyrus RPC call already uses (resolveVehicleClientTarget mirrors
6
+ * resolvePushChannelTarget's own resolution).
7
+ *
8
+ * Papyrus's daemon is expected to already be running as an installed service
9
+ * (see `packed install-service`), not auto-spawned on first use -- so unlike
10
+ * registerVehicleTools' README example, failure here (daemon not started yet,
11
+ * stale handle) is tolerated the same silent-degrade way
12
+ * subscribeTaskPushChannel already tolerates it, rather than letting a
13
+ * daemon-not-running condition abort the rest of extension setup. There is
14
+ * no retry-on-later-connect for a tool that was never registered at all --
15
+ * Pi has no way to add one after the fact outside the initial registration
16
+ * flow.
17
+ *
18
+ * Resolves the target through service-client.ts's currentVehicleClientTarget()
19
+ * (test-injectable, see setVehicleClientTargetResolverForTests), never
20
+ * @danypops/papyrus's resolveVehicleClientTarget() directly -- this runs from
21
+ * the bare extension entrypoint on every registerPapyrus(api) call, so an
22
+ * un-injected default would resolve the real daemonStateDir() in any test
23
+ * exercising the full entrypoint, not just ones about notes.
24
+ */
25
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
+ import { RemoteVehicleClient } from "@danypops/vehicle-client/http";
27
+ import { registerVehicleTools } from "@danypops/vehicle-client-pi";
28
+ import { currentVehicleClientTarget } from "./service-client.ts";
29
+
30
+ export async function registerNotesVehicle(pi: ExtensionAPI): Promise<void> {
31
+ const target = currentVehicleClientTarget();
32
+ if (!target) return;
33
+ try {
34
+ const client = new RemoteVehicleClient({ baseUrl: target.baseUrl, token: target.token });
35
+ await registerVehicleTools(pi, client, {
36
+ permissions: ["notes:read", "notes:write"],
37
+ principal: { id: "pi-papyrus" },
38
+ });
39
+ } catch {
40
+ // Daemon state is stale/unreachable -- degrade silently, matching
41
+ // subscribeTaskPushChannel's own tolerance for the same condition.
42
+ }
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-papyrus",
3
- "version": "0.38.1",
3
+ "version": "0.38.3",
4
4
  "description": "Pi host extension for Papyrus: native tools, TUI panels, and context injection over the daemon-backed graph store",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -18,7 +18,10 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@danypops/daemon-kit": "^0.10.0",
21
- "@danypops/papyrus": "^0.38.0",
21
+ "@danypops/papyrus": "^0.38.1",
22
+ "@danypops/vehicle-core": "^0.1.0",
23
+ "@danypops/vehicle-client": "^0.1.1",
24
+ "@danypops/vehicle-client-pi": "^0.1.5",
22
25
  "beautiful-mermaid": "1.1.3"
23
26
  },
24
27
  "devDependencies": {