@danypops/papyrus 0.38.3 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.38.3",
3
+ "version": "0.39.0",
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"],
@@ -35,6 +35,7 @@
35
35
  "files": ["src", "README.md"],
36
36
  "dependencies": {
37
37
  "@danypops/vehicle-core": "^0.1.1",
38
+ "@danypops/vehicle-client": "^0.1.1",
38
39
  "@danypops/vehicle-server": "^0.3.2"
39
40
  }
40
41
  }
package/src/client.ts CHANGED
@@ -1,5 +1,8 @@
1
- import { DAEMON_CLIENT_TIMEOUT_MS, DAEMON_PROBE_TIMEOUT_MS } from "./constants.ts";
2
- import { daemonStateDir, readDaemonHandle } from "./daemon-state.ts";
1
+ import { spawn as spawnProcess } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import { connectWithPolicy, spawnDetachedDaemon } from "@danypops/vehicle-client/daemon-client";
4
+ import { DAEMON_CLIENT_TIMEOUT_MS, DAEMON_DIR_ENV, DAEMON_PROBE_TIMEOUT_MS } from "./constants.ts";
5
+ import { daemonStateDir, readDaemonHandle, type DaemonHandle } from "./daemon-state.ts";
3
6
  import type { OperationName, SchemaState } from "./service.ts";
4
7
 
5
8
  export type FetchAdapter = (request: Request) => Promise<Response>;
@@ -46,9 +49,7 @@ export class PapyrusClient {
46
49
  }
47
50
  }
48
51
 
49
- export async function connectPapyrusClient(dir: string = daemonStateDir()): Promise<PapyrusClient> {
50
- const handle = readDaemonHandle(dir);
51
- if (!handle) throw new Error("Papyrus daemon is not running; install/start papyrus.service");
52
+ async function probedPapyrusClient(handle: DaemonHandle): Promise<PapyrusClient> {
52
53
  const probe = new PapyrusClient(handle.baseUrl, handle.token, (request) => fetch(request), DAEMON_PROBE_TIMEOUT_MS);
53
54
  try {
54
55
  await probe.health();
@@ -58,6 +59,54 @@ export async function connectPapyrusClient(dir: string = daemonStateDir()): Prom
58
59
  }
59
60
  }
60
61
 
62
+ /** packages/papyrus/src/cli.ts, resolved relative to this file's own installed location, not require.resolve('@danypops/papyrus') -- this module IS that package, no cross-package lookup needed. */
63
+ function papyrusCliPath(): string {
64
+ return fileURLToPath(new URL("cli.ts", import.meta.url));
65
+ }
66
+
67
+ export interface ConnectPapyrusClientOptions {
68
+ /**
69
+ * Environment passed to an auto-spawned daemon child. Defaults to the current
70
+ * process.env. Always carries DAEMON_DIR_ENV=dir so the spawned child computes
71
+ * the exact same state directory this call itself reads/polls -- without this,
72
+ * a caller-supplied `dir` (every real test; production always uses the default
73
+ * daemonStateDir(), which the child would derive identically on its own) would
74
+ * silently diverge from wherever the child actually starts writing its handle.
75
+ */
76
+ env?: Record<string, string | undefined>;
77
+ }
78
+
79
+ /**
80
+ * Transparently starts the daemon first if it is not already running -- matches
81
+ * every other daemon-backed ecosystem package that opted into auto-start (see
82
+ * @danypops/vehicle-client's connectWithPolicy doc comment: web-spider opts in,
83
+ * lector/pi-packed fail closed by design). Papyrus previously failed closed with
84
+ * "install/start papyrus.service", requiring a human to separately discover and
85
+ * run `papyrus service install` before the very first tool call could succeed.
86
+ * A handle file that exists but points at a dead/unreachable daemon is a distinct
87
+ * failure (stale, not "never started") and is NOT auto-recovered here -- it still
88
+ * throws its own actionable "restart manually" error, unchanged from before.
89
+ */
90
+ export async function connectPapyrusClient(dir: string = daemonStateDir(), options: ConnectPapyrusClientOptions = {}): Promise<PapyrusClient> {
91
+ return connectWithPolicy({
92
+ readHandle: () => readDaemonHandle(dir) ?? null,
93
+ buildClient: probedPapyrusClient,
94
+ autoStart: true,
95
+ spawn: () => {
96
+ spawnDetachedDaemon({
97
+ binPath: papyrusCliPath(),
98
+ args: ["serve"],
99
+ env: { ...(options.env ?? process.env), [DAEMON_DIR_ENV]: dir },
100
+ spawn: (command, args, spawnOptions) => {
101
+ const child = spawnProcess(command, args, spawnOptions);
102
+ child.unref();
103
+ },
104
+ });
105
+ },
106
+ fallbackMessage: "Papyrus daemon failed to start automatically; run `papyrus service install` or `papyrus serve` manually.",
107
+ });
108
+ }
109
+
61
110
  export interface PushChannelTarget {
62
111
  /** ws:// URL for the daemon's push-invalidation channel (see push-channel.ts in vehicle-server). */
63
112
  url: string;
@@ -12,6 +12,9 @@ import {
12
12
  export interface DaemonHandle {
13
13
  baseUrl: string;
14
14
  token: string;
15
+ host: string;
16
+ port: number;
17
+ pid: number;
15
18
  }
16
19
 
17
20
  export function daemonStateDir(
@@ -38,9 +41,9 @@ export function loadOrCreateToken(dir: string): string {
38
41
  return token;
39
42
  }
40
43
 
41
- export function writeDaemonPort(dir: string, port: number): void {
44
+ export function writeDaemonPort(dir: string, port: number, pid: number = process.pid): void {
42
45
  mkdirSync(dir, { recursive: true });
43
- writeFileSync(join(dir, DAEMON_PORT_FILE), `${port}\n`, { mode: 0o600 });
46
+ writeFileSync(join(dir, DAEMON_PORT_FILE), `${port}\n${pid}\n`, { mode: 0o600 });
44
47
  }
45
48
 
46
49
  export function clearDaemonPort(dir: string): void {
@@ -50,9 +53,14 @@ export function clearDaemonPort(dir: string): void {
50
53
  export function readDaemonHandle(dir: string): DaemonHandle | undefined {
51
54
  try {
52
55
  const token = readFileSync(join(dir, DAEMON_TOKEN_FILE), "utf8").trim();
53
- const port = Number(readFileSync(join(dir, DAEMON_PORT_FILE), "utf8").trim());
56
+ const lines = readFileSync(join(dir, DAEMON_PORT_FILE), "utf8").trim().split("\n");
57
+ const port = Number(lines[0]);
58
+ // pid is absent for a handle written before this field existed -- 0 is a safe
59
+ // "unknown" sentinel (never a real pid), not a crash. Nothing currently reads
60
+ // pid to make a kill/staleness decision, so a stale 0 is inert, not unsafe.
61
+ const pid = lines[1] ? Number(lines[1]) : 0;
54
62
  if (!token || !Number.isInteger(port) || port < 1 || port > 65_535) return undefined;
55
- return { baseUrl: `http://${DAEMON_HOST}:${port}`, token };
63
+ return { baseUrl: `http://${DAEMON_HOST}:${port}`, token, host: DAEMON_HOST, port, pid };
56
64
  } catch {
57
65
  return undefined;
58
66
  }
package/src/service.ts CHANGED
@@ -28,7 +28,7 @@ import {
28
28
  listInjectableRules,
29
29
  } from "./domain-services.ts";
30
30
  import { Notes, NOTE_SUBTYPE } from "./note-service.ts";
31
- import { createNotesVehicleRegistry } from "./vehicle/notes-vehicle.ts";
31
+ import { createPapyrusVehicleRegistry } from "./vehicle/papyrus-vehicle.ts";
32
32
  import type { VehicleRegistry } from "@danypops/vehicle-server";
33
33
  import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
34
34
  import { Logs } from "./log-service.ts";
@@ -208,12 +208,13 @@ export interface PapyrusService {
208
208
  schemaState(): SchemaState;
209
209
  execute(operation: string, input?: OperationInput): Promise<unknown>;
210
210
  /**
211
- * Notes projected as a real VehicleRegistry (see ./vehicle/notes-vehicle.ts) --
212
- * one honest VehicleOperation per real action, replacing the Pi extension's old
213
- * `notes(action=X)` mega-tool. The first domain migrated this way; not every
214
- * domain has one yet.
211
+ * Every domain migrated onto Vehicle, merged into one registry/one HTTP mount
212
+ * (see ./vehicle/papyrus-vehicle.ts) -- one honest VehicleOperation per real
213
+ * action, replacing the Pi extension's old `<domain>(action=X)` mega-tools.
214
+ * Not every domain is migrated yet -- see papyrus-vehicle.ts's own doc comment
215
+ * for what still isn't and why.
215
216
  */
216
- readonly notesVehicle: VehicleRegistry;
217
+ readonly vehicle: VehicleRegistry;
217
218
  checkpoint(): void;
218
219
  optimize(): void;
219
220
  /** Time-based Task Focus reclamation (see Tasks.reapStaleFocus); returns how many rows were removed, for daemon logging. */
@@ -479,13 +480,13 @@ export function createPapyrusService(path: string): PapyrusService {
479
480
  const tasks = new Tasks(artifacts, gates, focus, events, scopes, leases);
480
481
  const noteEvents = new SQLiteNoteEventStore(db);
481
482
  const notes = new Notes(artifacts, noteEvents);
482
- const notesVehicle = createNotesVehicleRegistry(notes, artifacts);
483
483
  const projections = new SQLiteGraphProjectionStore(db);
484
484
  const artifactScopes = new SQLiteArtifactScopeStore(db);
485
485
  const logs = new Logs(new SQLiteLogStore(db));
486
486
  const sessionIdentity = new SessionIdentity(new SQLiteSessionIdentityStore(db));
487
487
  const discussions = new Discussions(artifacts, new SQLiteDiscussionRoundStore(db));
488
488
  const authority = createAuthorityRegistry();
489
+ const vehicle = createPapyrusVehicleRegistry({ artifacts, scopes: artifactScopes, authority, notes });
489
490
  const moduleRegistry = new OperationRegistry();
490
491
  moduleRegistry.registerAll(notesOperations(notes));
491
492
  moduleRegistry.registerAll(logsOperations(logs));
@@ -505,7 +506,7 @@ export function createPapyrusService(path: string): PapyrusService {
505
506
  return {
506
507
  operationNames: () => [...EXPECTED_OPERATION_NAMES],
507
508
  schemaState: state,
508
- notesVehicle,
509
+ vehicle,
509
510
  async execute(operation, input = {}) {
510
511
  const handler = registry[operation as OperationName];
511
512
  if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
@@ -565,10 +566,8 @@ export function createApp(deps: {
565
566
  */
566
567
  onOperationExecuted?: (operation: string, input: OperationInput) => void;
567
568
  }): { fetch(request: Request): Promise<Response> } {
568
- // Same Bearer token as the rest of this API -- a Vehicle-projected domain (see
569
- // ./vehicle/notes-vehicle.ts) rides the same daemon, same auth, same port; it is
570
- // not a second service to stand up or authenticate against separately.
571
- const vehicleApp = createVehicleHttpApp({ registry: deps.service.notesVehicle, token: deps.token });
569
+ // Same Bearer token, daemon, and port as the rest of this API -- see ./vehicle/papyrus-vehicle.ts.
570
+ const vehicleApp = createVehicleHttpApp({ registry: deps.service.vehicle, token: deps.token });
572
571
  return {
573
572
  async fetch(request: Request): Promise<Response> {
574
573
  if (request.headers.get("authorization") !== `Bearer ${deps.token}`) {
@@ -0,0 +1,95 @@
1
+ /**
2
+ * artifact.* -- show/remove/remove_subtree/restore, identical regardless of an
3
+ * artifact's kind. Registered once here, shared by every domain, instead of
4
+ * duplicated as rules.remove/docs.remove/etc.
5
+ */
6
+ import { defineVehicleOperation, bindVehicleOperation } from "@danypops/vehicle-core";
7
+ import type { VehicleRegistry } from "@danypops/vehicle-server";
8
+ import { removeArtifactSubtree } from "../artifact-subtree.ts";
9
+ import type { ArtifactStore } from "../ports/artifact-store.ts";
10
+ import type { ArtifactTrashStore } from "../ports/artifact-trash-store.ts";
11
+ import { looseObjectSchema, numberProp, passthroughOutput, stringProp } from "./artifact-vehicle-shared.ts";
12
+
13
+ const OWNER = "artifact";
14
+ const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
15
+
16
+ function eventContext(input: Record<string, unknown>): { actor?: string; source?: string; sessionId?: string } {
17
+ const actor = input["actor"];
18
+ const source = input["source"];
19
+ const sessionId = input["session_id"] ?? input["sessionId"];
20
+ return {
21
+ actor: typeof actor === "string" ? actor : undefined,
22
+ source: typeof source === "string" ? source : undefined,
23
+ sessionId: typeof sessionId === "string" ? sessionId : undefined,
24
+ };
25
+ }
26
+
27
+ function requireId(input: Record<string, unknown>): string {
28
+ const id = input["id"];
29
+ if (typeof id !== "string" || id.length === 0) throw new Error("id is required");
30
+ return id;
31
+ }
32
+
33
+ export function registerArtifactTrashOperations(registry: VehicleRegistry, artifacts: ArtifactStore & ArtifactTrashStore): void {
34
+ const define = (
35
+ action: string,
36
+ description: string,
37
+ effect: "read" | "local-write" | "destructive",
38
+ properties: Record<string, { type: string; enum?: readonly string[] }>,
39
+ required: readonly string[],
40
+ execute: (input: Record<string, unknown>) => unknown,
41
+ ): void => {
42
+ const operation = defineVehicleOperation({
43
+ name: `artifact.${action}`,
44
+ version: 1,
45
+ description,
46
+ input: looseObjectSchema(properties, required),
47
+ output: passthroughOutput,
48
+ permissions: ["artifact:read", "artifact:write"],
49
+ effect,
50
+ idempotency: { mode: effect === "read" ? "safe" : "unsafe" },
51
+ limits: LIMITS,
52
+ });
53
+ registry.register(OWNER, bindVehicleOperation(operation, () => async (context) => execute(context.input)));
54
+ };
55
+
56
+ define(
57
+ "show",
58
+ "Shows any artifact (doc, task, rule, skill, playbook) by id, regardless of kind.",
59
+ "read",
60
+ { id: stringProp, tree: { type: "boolean" } as unknown as { type: string }, depth: numberProp, max_nodes: numberProp },
61
+ ["id"],
62
+ (input) => artifacts.get(requireId(input), {
63
+ tree: input["tree"] === true,
64
+ depth: typeof input["depth"] === "number" ? input["depth"] : undefined,
65
+ maxNodes: typeof input["max_nodes"] === "number" ? input["max_nodes"] : undefined,
66
+ }),
67
+ );
68
+
69
+ define(
70
+ "remove",
71
+ "Moves any artifact to a time-gated trash, excluded from list/query but still directly showable, restorable via artifact.restore until the purge deadline.",
72
+ "local-write",
73
+ { id: stringProp, reason: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
74
+ ["id"],
75
+ (input) => artifacts.trash(requireId(input), { reason: typeof input["reason"] === "string" ? input["reason"] : undefined, context: eventContext(input) }),
76
+ );
77
+
78
+ define(
79
+ "remove_subtree",
80
+ "Trashes an artifact and its whole `contains` subtree in one call, skipping already-trashed nodes.",
81
+ "local-write",
82
+ { id: stringProp, reason: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
83
+ ["id"],
84
+ (input) => removeArtifactSubtree(artifacts, requireId(input), { reason: typeof input["reason"] === "string" ? input["reason"] : undefined, context: eventContext(input) }),
85
+ );
86
+
87
+ define(
88
+ "restore",
89
+ "Restores a trashed artifact. Idempotent: restoring one that isn't trashed is a real no-op, not an error.",
90
+ "local-write",
91
+ { id: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
92
+ ["id"],
93
+ (input) => artifacts.restore(requireId(input), eventContext(input)),
94
+ );
95
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Shared schema helpers and name->id resolution for every per-domain
3
+ * VehicleRegistry projection (notes-vehicle.ts, rules-vehicle.ts, docs-vehicle.ts,
4
+ * artifact-trash-vehicle.ts).
5
+ */
6
+ import { defineVehicleSchema, type VehicleSchemaCodec } from "@danypops/vehicle-core";
7
+ import type { Artifact } from "../domain/artifact.ts";
8
+
9
+ /**
10
+ * VehicleRegistry only ever calls a schema's own safeParse -- jsonSchema is
11
+ * descriptive metadata surfaced to a client/Pi projection, never itself
12
+ * enforced at runtime -- so a declared `enum` has to be checked here for
13
+ * real, or it's a documentation gesture, not an honest contract.
14
+ */
15
+ export function looseObjectSchema(properties: Record<string, { type: string; enum?: readonly string[] }>, required: readonly string[] = []): VehicleSchemaCodec<Record<string, unknown>> {
16
+ return defineVehicleSchema<Record<string, unknown>>({
17
+ jsonSchema: { type: "object", properties, required: [...required], additionalProperties: false },
18
+ safeParse(value) {
19
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
20
+ return { success: false, issues: [{ path: [], message: "input must be an object" }] };
21
+ }
22
+ const input = value as Record<string, unknown>;
23
+ for (const key of required) {
24
+ if (!(key in input)) return { success: false, issues: [{ path: [key], message: `${key} is required` }] };
25
+ }
26
+ for (const [key, schema] of Object.entries(properties)) {
27
+ if (!schema.enum || !(key in input)) continue;
28
+ if (!schema.enum.includes(input[key] as string)) {
29
+ return { success: false, issues: [{ path: [key], message: `${key} must be one of ${schema.enum.join(", ")}` }] };
30
+ }
31
+ }
32
+ return { success: true, value: input };
33
+ },
34
+ });
35
+ }
36
+
37
+ export const passthroughOutput: VehicleSchemaCodec<unknown> = defineVehicleSchema<unknown>({
38
+ jsonSchema: { type: "object" },
39
+ safeParse: (value) => ({ success: true, value }),
40
+ });
41
+
42
+ export const stringProp = { type: "string" } as const;
43
+ export const numberProp = { type: "number" } as const;
44
+
45
+ /** Exact match semantics as the Pi-extension helper this replaces (domain-tools.ts's matchArtifactByName) -- case-insensitive exact title match, refuses to guess between ambiguous matches. */
46
+ export function matchArtifactByName(candidates: readonly Artifact[], name: string): string {
47
+ const needle = name.trim().toLowerCase();
48
+ const matches = candidates.filter((artifact) => artifact.title.trim().toLowerCase() === needle);
49
+ if (matches.length === 0) throw new Error(`no artifact named "${name}" found in this scope`);
50
+ if (matches.length > 1) {
51
+ throw new Error(`${matches.length} artifacts are named "${name}": ${matches.map((a) => `${a.title} (${a.id})`).join(", ")} -- use id to disambiguate`);
52
+ }
53
+ return matches[0]!.id;
54
+ }
55
+
56
+ /**
57
+ * Resolves a name to an id, retrying against `fetchWidened` (an unscoped/cross-project
58
+ * search) only when `fetchCandidates` finds nothing. Owns the match-or-widen control
59
+ * flow only -- the caller supplies its own scoped/widened list calls, since scoping
60
+ * differs per domain. Omit `fetchWidened` when there is no wider scope to retry.
61
+ */
62
+ export function resolveArtifactIdWidened(name: string, fetchCandidates: () => readonly Artifact[], fetchWidened?: () => readonly Artifact[]): string {
63
+ try {
64
+ return matchArtifactByName(fetchCandidates(), name);
65
+ } catch (error) {
66
+ if (!(error instanceof Error) || !error.message.startsWith("no artifact named") || !fetchWidened) throw error;
67
+ return matchArtifactByName(fetchWidened(), name);
68
+ }
69
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Docs projected as a real VehicleRegistry: one VehicleOperation per real action.
3
+ * Wraps modules/docs.ts's operation definitions. remove/restore/remove_subtree
4
+ * are not duplicated here -- see ./artifact-trash-vehicle.ts.
5
+ */
6
+ import { defineVehicleOperation, bindVehicleOperation } from "@danypops/vehicle-core";
7
+ import type { VehicleRegistry } from "@danypops/vehicle-server";
8
+ import type { AuthorityRegistry } from "../authority-registry.ts";
9
+ import { listDocuments } from "../domain-services.ts";
10
+ import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
11
+ import type { ArtifactStore } from "../ports/artifact-store.ts";
12
+ import { docsOperations } from "../modules/docs.ts";
13
+ import { looseObjectSchema, numberProp, passthroughOutput, resolveArtifactIdWidened, stringProp } from "./artifact-vehicle-shared.ts";
14
+
15
+ const OWNER = "docs";
16
+ const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
17
+
18
+ function resolveDocId(artifacts: ArtifactStore, scopes: ArtifactScopeStore, projectRoot: string | undefined, id: unknown, name: unknown): string {
19
+ if (typeof id === "string" && id.length > 0) return id;
20
+ if (typeof name !== "string" || name.length === 0) throw new Error("id or name is required");
21
+ return resolveArtifactIdWidened(
22
+ name,
23
+ () => listDocuments(artifacts, scopes, { text: name, projectRoot }),
24
+ projectRoot === undefined ? undefined : () => listDocuments(artifacts, scopes, { text: name }),
25
+ );
26
+ }
27
+
28
+ /** Cross-kind resolution for a link target -- can be a doc, task, rule, or skill. Unscoped, matching the exact behavior of the artifact.query-backed resolution it replaces. */
29
+ function resolveTargetId(artifacts: ArtifactStore, id: unknown, name: unknown): string {
30
+ if (typeof id === "string" && id.length > 0) return id;
31
+ if (typeof name !== "string" || name.length === 0) throw new Error("target_id or target_name is required");
32
+ return resolveArtifactIdWidened(name, () => artifacts.query({ text: name }));
33
+ }
34
+
35
+ export function registerDocsVehicleOperations(registry: VehicleRegistry, artifacts: ArtifactStore, scopes: ArtifactScopeStore, authority: AuthorityRegistry): void {
36
+ const moduleOperations = new Map(docsOperations(artifacts, scopes, authority).map((op) => [op.name, op]));
37
+ const call = (name: string, input: Record<string, unknown>): unknown => moduleOperations.get(name)!.execute(input);
38
+
39
+ const define = (
40
+ action: string,
41
+ description: string,
42
+ effect: "read" | "local-write",
43
+ properties: Record<string, { type: string; enum?: readonly string[] }>,
44
+ required: readonly string[],
45
+ resolve: (input: Record<string, unknown>) => Record<string, unknown>,
46
+ ): void => {
47
+ const operation = defineVehicleOperation({
48
+ name: `docs.${action}`,
49
+ version: 1,
50
+ description,
51
+ input: looseObjectSchema(properties, required),
52
+ output: passthroughOutput,
53
+ permissions: ["docs:read", "docs:write"],
54
+ effect,
55
+ idempotency: { mode: effect === "read" ? "safe" : "unsafe" },
56
+ limits: LIMITS,
57
+ });
58
+ registry.register(OWNER, bindVehicleOperation(operation, () => async (context) => call(`docs.${action}`, resolve(context.input))));
59
+ };
60
+
61
+ define(
62
+ "create",
63
+ "Creates a Doc -- descriptive knowledge, not actionable work. project_root is optional (omitted = unscoped).",
64
+ "local-write",
65
+ { title: stringProp, body: stringProp, subtype: stringProp, labels: { type: "array" } as unknown as { type: string }, extra: { type: "object" } as unknown as { type: string }, template_id: stringProp, project_root: stringProp },
66
+ ["title"],
67
+ (input) => input,
68
+ );
69
+
70
+ define(
71
+ "list",
72
+ "Lists Docs matching an optional status/text filter, scoped to project_root when given.",
73
+ "read",
74
+ { status: stringProp, text: stringProp, limit: numberProp, project_root: stringProp },
75
+ [],
76
+ (input) => input,
77
+ );
78
+
79
+ define(
80
+ "show",
81
+ "Shows one Doc by id or title.",
82
+ "read",
83
+ { id: stringProp, name: stringProp, project_root: stringProp },
84
+ [],
85
+ (input) => ({ ...input, id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
86
+ );
87
+
88
+ define(
89
+ "activate",
90
+ "Activates a draft Doc.",
91
+ "local-write",
92
+ { id: stringProp, name: stringProp, project_root: stringProp },
93
+ [],
94
+ (input) => ({ ...input, id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
95
+ );
96
+
97
+ define(
98
+ "archive",
99
+ "Archives an active Doc.",
100
+ "local-write",
101
+ { id: stringProp, name: stringProp, project_root: stringProp },
102
+ [],
103
+ (input) => ({ ...input, id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
104
+ );
105
+
106
+ define(
107
+ "reopen",
108
+ "Reopens an archived Doc back to active.",
109
+ "local-write",
110
+ { id: stringProp, name: stringProp, project_root: stringProp },
111
+ [],
112
+ (input) => ({ ...input, id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
113
+ );
114
+
115
+ define(
116
+ "link",
117
+ "Links a Doc to another artifact via a typed relation. Prefer target_name over target_id -- resolved server-side, searching every kind since a link target can be a doc, task, rule, or skill.",
118
+ "local-write",
119
+ { id: stringProp, name: stringProp, relation: { type: "string", enum: ["references", "documents", "supersedes", "relates_to", "contains", "part_of"] }, target_id: stringProp, target_name: stringProp, project_root: stringProp },
120
+ ["relation"],
121
+ (input) => ({
122
+ ...input,
123
+ id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name),
124
+ target_id: resolveTargetId(artifacts, input.target_id, input.target_name),
125
+ }),
126
+ );
127
+
128
+ define(
129
+ "assign_project",
130
+ "Reassigns a Doc's project_root, or unscopes it when project_root is omitted.",
131
+ "local-write",
132
+ { id: stringProp, name: stringProp, project_root: stringProp },
133
+ [],
134
+ (input) => ({ ...input, id: resolveDocId(artifacts, scopes, undefined, input.id, input.name) }),
135
+ );
136
+
137
+ define(
138
+ "update",
139
+ "Changes a Doc's title/body/labels (at least one required). Refused for a read-only external projection (e.g. web-spider-ingested Docs) -- capture a correction as a new linked Doc instead.",
140
+ "local-write",
141
+ { id: stringProp, name: stringProp, title: stringProp, body: stringProp, labels: { type: "array" } as unknown as { type: string }, project_root: stringProp },
142
+ [],
143
+ (input) => ({ ...input, id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
144
+ );
145
+ }
@@ -1,97 +1,46 @@
1
1
  /**
2
- * Notes projected as a real VehicleRegistry: one VehicleOperation per real
3
- * action (capture/list/show/history/consume/promote/archive), each with its
4
- * own honest effect and narrow schema -- replacing pi-papyrus's hand-rolled
5
- * `notes(action=X)` mega-tool (unconstrained `action: Type.String()`, 14
6
- * fields unioned across all 7 branches, the "God Parameters"/"Kitchen Sink
7
- * tool" anti-pattern @danypops/vehicle's own README documents).
2
+ * Notes projected as a real VehicleRegistry: one VehicleOperation per real action
3
+ * (capture/list/show/history/consume/promote/archive), each with its own effect
4
+ * and narrow schema, instead of one `action: Type.String()` dispatch tool.
8
5
  *
9
- * Wraps modules/notes.ts's existing operation definitions rather than
10
- * reimplementing their input parsing -- this is a projection/contract layer
11
- * on top of the existing domain logic, not a second copy of it. Adds the
12
- * one thing those definitions don't do: resolving a human-readable
13
- * `name`/`target_name` to the `id`/`target_id` the domain logic actually
14
- * needs, server-side in the same call. The Pi-extension-side
15
- * `resolveNameFields` helper it replaces needed a separate round trip per
16
- * name before the real call; this does it in one.
6
+ * Wraps modules/notes.ts's operation definitions rather than reimplementing their
7
+ * input parsing. Resolves `name`/`target_name` to `id`/`target_id` server-side, in
8
+ * the same call -- avoids a separate round trip per name before the real call.
17
9
  */
18
- import { defineVehicleOperation, defineVehicleSchema, bindVehicleOperation, type VehicleSchemaCodec } from "@danypops/vehicle-core";
19
- import { VehicleRegistry } from "@danypops/vehicle-server";
10
+ import { defineVehicleOperation, bindVehicleOperation } from "@danypops/vehicle-core";
11
+ import type { VehicleRegistry } from "@danypops/vehicle-server";
20
12
  import type { ArtifactStore } from "../ports/artifact-store.ts";
21
13
  import { Notes, NOTE_DISPOSITIONS } from "../note-service.ts";
22
- import type { Artifact } from "../domain/artifact.ts";
23
14
  import { notesOperations } from "../modules/notes.ts";
15
+ import { looseObjectSchema, numberProp, passthroughOutput, resolveArtifactIdWidened, stringProp } from "./artifact-vehicle-shared.ts";
24
16
 
25
17
  const OWNER = "notes";
26
18
 
27
19
  const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
28
20
 
29
- /**
30
- * VehicleRegistry only ever calls a schema's own safeParse -- jsonSchema is
31
- * descriptive metadata surfaced to a client/Pi projection, never itself
32
- * enforced at runtime -- so a declared `enum` has to be checked here for
33
- * real, or it's a documentation gesture, not an honest contract.
34
- */
35
- function looseObjectSchema(properties: Record<string, { type: string; enum?: readonly string[] }>, required: readonly string[] = []): VehicleSchemaCodec<Record<string, unknown>> {
36
- return defineVehicleSchema<Record<string, unknown>>({
37
- jsonSchema: { type: "object", properties, required: [...required], additionalProperties: false },
38
- safeParse(value) {
39
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
40
- return { success: false, issues: [{ path: [], message: "input must be an object" }] };
41
- }
42
- const input = value as Record<string, unknown>;
43
- for (const key of required) {
44
- if (!(key in input)) return { success: false, issues: [{ path: [key], message: `${key} is required` }] };
45
- }
46
- for (const [key, schema] of Object.entries(properties)) {
47
- if (!schema.enum || !(key in input)) continue;
48
- if (!schema.enum.includes(input[key] as string)) {
49
- return { success: false, issues: [{ path: [key], message: `${key} must be one of ${schema.enum.join(", ")}` }] };
50
- }
51
- }
52
- return { success: true, value: input };
53
- },
54
- });
55
- }
56
-
57
- const passthroughOutput = defineVehicleSchema<unknown>({
58
- jsonSchema: { type: "object" },
59
- safeParse: (value) => ({ success: true, value }),
60
- });
61
-
62
- /** Exact match semantics as the Pi-extension helper it replaces (domain-tools.ts's matchArtifactByName) -- case-insensitive exact title match, refuses to guess between ambiguous matches. */
63
- function matchArtifactByName(candidates: readonly Artifact[], name: string): string {
64
- const needle = name.trim().toLowerCase();
65
- const matches = candidates.filter((artifact) => artifact.title.trim().toLowerCase() === needle);
66
- if (matches.length === 0) throw new Error(`no artifact named "${name}" found in this scope`);
67
- if (matches.length > 1) {
68
- throw new Error(`${matches.length} artifacts are named "${name}": ${matches.map((a) => `${a.title} (${a.id})`).join(", ")} -- use id to disambiguate`);
69
- }
70
- return matches[0]!.id;
71
- }
72
-
73
21
  /** Resolves a note's id from either an explicit id or its title within projectRoot. */
74
22
  function resolveNoteId(notes: Notes, projectRoot: string, id: unknown, name: unknown): string {
75
23
  if (typeof id === "string" && id.length > 0) return id;
76
24
  if (typeof name !== "string" || name.length === 0) throw new Error("id or name is required");
77
- return matchArtifactByName(notes.list({ projectRoot, text: name }), name);
25
+ return resolveArtifactIdWidened(name, () => notes.list({ projectRoot, text: name }));
78
26
  }
79
27
 
80
28
  /** Cross-kind equivalent for a promotion target -- a target can be a task, doc, rule, or skill, not just a note. Unscoped by project, matching the exact behavior of the artifact.query-backed resolution it replaces. */
81
29
  function resolveArtifactId(artifacts: ArtifactStore, id: unknown, name: unknown): string {
82
30
  if (typeof id === "string" && id.length > 0) return id;
83
31
  if (typeof name !== "string" || name.length === 0) throw new Error("target_id or target_name is required");
84
- return matchArtifactByName(artifacts.query({ text: name }), name);
32
+ return resolveArtifactIdWidened(name, () => artifacts.query({ text: name }));
85
33
  }
86
34
 
87
35
  /**
88
- * Builds a VehicleRegistry exposing every notes.* action as its own honest
89
- * operation. `artifacts` is only needed for promote's cross-kind
90
- * target_name resolution -- every other operation only ever touches notes
91
- * themselves via `notes`.
36
+ * Registers every notes.* action as its own honest VehicleOperation onto an
37
+ * existing registry (see ./papyrus-vehicle.ts for the composition root that
38
+ * merges every domain's operations into one registry/one HTTP mount).
39
+ * `artifacts` is only needed for promote's cross-kind target_name
40
+ * resolution -- every other operation only ever touches notes themselves
41
+ * via `notes`.
92
42
  */
93
- export function createNotesVehicleRegistry(notes: Notes, artifacts: ArtifactStore): VehicleRegistry {
94
- const registry = new VehicleRegistry({ name: "papyrus-notes", version: "1.0.0", description: "Papyrus's deferred human-intent inbox." });
43
+ export function registerNotesVehicleOperations(registry: VehicleRegistry, notes: Notes, artifacts: ArtifactStore): void {
95
44
  const moduleOperations = new Map(notesOperations(notes).map((op) => [op.name, op]));
96
45
  const call = (name: string, input: Record<string, unknown>): unknown => moduleOperations.get(name)!.execute(input);
97
46
 
@@ -117,9 +66,6 @@ export function createNotesVehicleRegistry(notes: Notes, artifacts: ArtifactStor
117
66
  registry.register(OWNER, bindVehicleOperation(operation, () => async (context) => call(`notes.${action}`, resolve(context.input))));
118
67
  };
119
68
 
120
- const stringProp = { type: "string" } as const;
121
- const numberProp = { type: "number" } as const;
122
-
123
69
  define(
124
70
  "capture",
125
71
  "Stores a deferred request without creating work. Returns the created note.",
@@ -205,6 +151,4 @@ export function createNotesVehicleRegistry(notes: Notes, artifacts: ArtifactStor
205
151
  ["project_root", "disposition"],
206
152
  (input) => ({ ...input, id: resolveNoteId(notes, input.project_root as string, input.id, input.name) }),
207
153
  );
208
-
209
- return registry;
210
154
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Composition root for every domain projected onto Vehicle -- one VehicleRegistry,
3
+ * one HTTP mount (see service.ts's createApp). Operation names are already globally
4
+ * unique via their own dotted prefix (notes.*, rules.*, docs.*, artifact.*), so
5
+ * merging costs nothing and avoids a separate registry/mount/client per domain.
6
+ *
7
+ * skills, playbooks, discuss, and tasks still register via pi-papyrus's own
8
+ * pi.registerTool() in domain-tools.ts, not here.
9
+ */
10
+ import { VehicleRegistry } from "@danypops/vehicle-server";
11
+ import type { AuthorityRegistry } from "../authority-registry.ts";
12
+ import type { Notes } from "../note-service.ts";
13
+ import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
14
+ import type { ArtifactStore } from "../ports/artifact-store.ts";
15
+ import type { ArtifactTrashStore } from "../ports/artifact-trash-store.ts";
16
+ import { registerArtifactTrashOperations } from "./artifact-trash-vehicle.ts";
17
+ import { registerDocsVehicleOperations } from "./docs-vehicle.ts";
18
+ import { registerNotesVehicleOperations } from "./notes-vehicle.ts";
19
+ import { registerRulesVehicleOperations } from "./rules-vehicle.ts";
20
+
21
+ export interface PapyrusVehicleDeps {
22
+ artifacts: ArtifactStore & ArtifactTrashStore;
23
+ scopes: ArtifactScopeStore;
24
+ authority: AuthorityRegistry;
25
+ notes: Notes;
26
+ }
27
+
28
+ export function createPapyrusVehicleRegistry(deps: PapyrusVehicleDeps): VehicleRegistry {
29
+ const registry = new VehicleRegistry({ name: "papyrus", version: "1.0.0", description: "Papyrus's graph-artifact domains, one honest operation per real action." });
30
+ registerNotesVehicleOperations(registry, deps.notes, deps.artifacts);
31
+ registerRulesVehicleOperations(registry, deps.artifacts, deps.scopes);
32
+ registerDocsVehicleOperations(registry, deps.artifacts, deps.scopes, deps.authority);
33
+ registerArtifactTrashOperations(registry, deps.artifacts);
34
+ return registry;
35
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Rules projected as a real VehicleRegistry: one VehicleOperation per real action.
3
+ * Wraps modules/rules.ts's operation definitions (rules.injectable stays a
4
+ * composition-root-only concern, absent here too). remove/restore/remove_subtree
5
+ * are not duplicated here -- see ./artifact-trash-vehicle.ts.
6
+ */
7
+ import { defineVehicleOperation, bindVehicleOperation } from "@danypops/vehicle-core";
8
+ import type { VehicleRegistry } from "@danypops/vehicle-server";
9
+ import { listRules } from "../domain-services.ts";
10
+ import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
11
+ import type { ArtifactStore } from "../ports/artifact-store.ts";
12
+ import { rulesOperations } from "../modules/rules.ts";
13
+ import { looseObjectSchema, numberProp, passthroughOutput, resolveArtifactIdWidened, stringProp } from "./artifact-vehicle-shared.ts";
14
+
15
+ const OWNER = "rules";
16
+ const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
17
+
18
+ /** Resolves a rule's id from either an explicit id or its title, widened past project_root when unscoped-vs-scoped search finds nothing. */
19
+ function resolveRuleId(artifacts: ArtifactStore, scopes: ArtifactScopeStore, projectRoot: string | undefined, id: unknown, name: unknown): string {
20
+ if (typeof id === "string" && id.length > 0) return id;
21
+ if (typeof name !== "string" || name.length === 0) throw new Error("id or name is required");
22
+ return resolveArtifactIdWidened(
23
+ name,
24
+ () => listRules(artifacts, scopes, { text: name, projectRoot }),
25
+ projectRoot === undefined ? undefined : () => listRules(artifacts, scopes, { text: name }),
26
+ );
27
+ }
28
+
29
+ /**
30
+ * Resolves a task's id from its title for rules.gate. No ambient cwd to default
31
+ * project_root to server-side -- pass project_root explicitly, or this searches
32
+ * unscoped.
33
+ */
34
+ function resolveTaskId(artifacts: ArtifactStore, projectRoot: string | undefined, id: unknown, name: unknown): string | undefined {
35
+ if (typeof id === "string" && id.length > 0) return id;
36
+ if (typeof name !== "string" || name.length === 0) return undefined;
37
+ return resolveArtifactIdWidened(
38
+ name,
39
+ () => artifacts.query({ kind: "task", text: name }),
40
+ );
41
+ }
42
+
43
+ export function registerRulesVehicleOperations(registry: VehicleRegistry, artifacts: ArtifactStore, scopes: ArtifactScopeStore): void {
44
+ const moduleOperations = new Map(rulesOperations(artifacts, scopes).map((op) => [op.name, op]));
45
+ const call = (name: string, input: Record<string, unknown>): unknown => moduleOperations.get(name)!.execute(input);
46
+
47
+ const define = (
48
+ action: string,
49
+ description: string,
50
+ effect: "read" | "local-write",
51
+ properties: Record<string, { type: string; enum?: readonly string[] }>,
52
+ required: readonly string[],
53
+ resolve: (input: Record<string, unknown>) => Record<string, unknown>,
54
+ ): void => {
55
+ const operation = defineVehicleOperation({
56
+ name: `rules.${action}`,
57
+ version: 1,
58
+ description,
59
+ input: looseObjectSchema(properties, required),
60
+ output: passthroughOutput,
61
+ permissions: ["rules:read", "rules:write"],
62
+ effect,
63
+ idempotency: { mode: effect === "read" ? "safe" : "unsafe" },
64
+ limits: LIMITS,
65
+ });
66
+ registry.register(OWNER, bindVehicleOperation(operation, () => async (context) => call(`rules.${action}`, resolve(context.input))));
67
+ };
68
+
69
+ define(
70
+ "create",
71
+ "Creates a Rule -- a standing constraint injected into the agent system prompt while active. project_root is optional (omitted = unscoped).",
72
+ "local-write",
73
+ { title: stringProp, body: stringProp, condition: stringProp, rule_action: stringProp, severity: { type: "string", enum: ["block", "warn", "info"] }, labels: { type: "array" } as unknown as { type: string }, extra: { type: "object" } as unknown as { type: string }, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
74
+ ["title"],
75
+ (input) => input,
76
+ );
77
+
78
+ define(
79
+ "list",
80
+ "Lists Rules matching an optional status/text filter, scoped to project_root when given.",
81
+ "read",
82
+ { status: stringProp, text: stringProp, limit: numberProp, project_root: stringProp },
83
+ [],
84
+ (input) => input,
85
+ );
86
+
87
+ define(
88
+ "show",
89
+ "Shows one Rule by id or title.",
90
+ "read",
91
+ { id: stringProp, name: stringProp, project_root: stringProp },
92
+ [],
93
+ (input) => ({ ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
94
+ );
95
+
96
+ define(
97
+ "preview",
98
+ "Renders a Rule's own condition/action/body preview text with no side effects.",
99
+ "read",
100
+ { id: stringProp, name: stringProp, project_root: stringProp },
101
+ [],
102
+ (input) => ({ ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
103
+ );
104
+
105
+ define(
106
+ "enable",
107
+ "Enables a Rule so it starts injecting into the agent system prompt.",
108
+ "local-write",
109
+ { id: stringProp, name: stringProp, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
110
+ [],
111
+ (input) => ({ ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
112
+ );
113
+
114
+ define(
115
+ "disable",
116
+ "Disables a Rule; it stops injecting into the agent system prompt.",
117
+ "local-write",
118
+ { id: stringProp, name: stringProp, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
119
+ [],
120
+ (input) => ({ ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
121
+ );
122
+
123
+ define(
124
+ "gate",
125
+ "Attaches a Rule as a gate condition on a Task. Prefer task_name over task_id -- resolved server-side (unscoped if project_root is omitted, since there is no ambient cwd to default to here).",
126
+ "local-write",
127
+ { id: stringProp, name: stringProp, task_id: stringProp, task_name: stringProp, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
128
+ [],
129
+ (input) => {
130
+ const taskId = resolveTaskId(artifacts, input.project_root as string | undefined, input.task_id, input.task_name);
131
+ if (!taskId) throw new Error("task_id or task_name is required");
132
+ return { ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name), task_id: taskId };
133
+ },
134
+ );
135
+
136
+ define(
137
+ "assign_project",
138
+ "Reassigns a Rule's project_root, or unscopes it when project_root is omitted.",
139
+ "local-write",
140
+ { id: stringProp, name: stringProp, project_root: stringProp },
141
+ [],
142
+ (input) => ({ ...input, id: resolveRuleId(artifacts, scopes, undefined, input.id, input.name) }),
143
+ );
144
+
145
+ define(
146
+ "update",
147
+ "Changes a Rule's title/body/labels (at least one required). Body updates still enforce the same combined condition+action+body context-tax bound as creation.",
148
+ "local-write",
149
+ { id: stringProp, name: stringProp, title: stringProp, body: stringProp, labels: { type: "array" } as unknown as { type: string }, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
150
+ [],
151
+ (input) => ({ ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
152
+ );
153
+ }