@danypops/papyrus 0.38.0 → 0.38.1

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.0",
3
+ "version": "0.38.1",
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"],
@@ -34,6 +34,8 @@
34
34
  },
35
35
  "files": ["src", "README.md"],
36
36
  "dependencies": {
37
- "@danypops/daemon-kit": "^0.10.0"
37
+ "@danypops/daemon-kit": "^0.10.0",
38
+ "@danypops/vehicle-core": "^0.1.0",
39
+ "@danypops/vehicle-server": "^0.1.0"
38
40
  }
39
41
  }
package/src/client.ts CHANGED
@@ -78,3 +78,22 @@ export function resolvePushChannelTarget(dir: string = daemonStateDir()): PushCh
78
78
  if (!handle) return undefined;
79
79
  return { url: `${handle.baseUrl.replace(/^http/, "ws")}/push`, token: handle.token };
80
80
  }
81
+
82
+ export interface VehicleClientTarget {
83
+ /** Base URL for a domain migrated onto VehicleRegistry (see src/vehicle/*.ts) -- @danypops/vehicle-client's RemoteVehicleClient mounts its own /vehicle/manifest, /vehicle/invoke, /vehicle/cancel routes under this. */
84
+ baseUrl: string;
85
+ token: string;
86
+ }
87
+
88
+ /**
89
+ * Narrow surface for a Vehicle-projected domain consumer -- same daemon, same
90
+ * handle file, same Bearer token every other Papyrus RPC call already uses (see
91
+ * service.ts's createApp, which mounts the Vehicle HTTP app at /vehicle/* on
92
+ * this same port). Returns undefined rather than throwing when the daemon has
93
+ * never started, matching resolvePushChannelTarget's own tolerance.
94
+ */
95
+ export function resolveVehicleClientTarget(dir: string = daemonStateDir()): VehicleClientTarget | undefined {
96
+ const handle = readDaemonHandle(dir);
97
+ if (!handle) return undefined;
98
+ return { baseUrl: handle.baseUrl, token: handle.token };
99
+ }
package/src/index.ts CHANGED
@@ -19,7 +19,7 @@ export type { TaskViewSelection } from "./domain/task-scope.ts";
19
19
  export type { ArtifactStore } from "./ports/artifact-store.ts";
20
20
  export type { GraphRenderer } from "./ports/graph-renderer.ts";
21
21
 
22
- export { connectPapyrusClient, resolvePushChannelTarget, type PapyrusClient, type PushChannelTarget } from "./client.ts";
22
+ export { connectPapyrusClient, resolvePushChannelTarget, resolveVehicleClientTarget, type PapyrusClient, type PushChannelTarget, type VehicleClientTarget } from "./client.ts";
23
23
  export type { DiscussionAndRounds } from "./discussion-service.ts";
24
24
  export { NOTE_DISPOSITIONS } from "./note-service.ts";
25
25
  export type { OperationName, SchemaState } from "./service.ts";
package/src/service.ts CHANGED
@@ -28,6 +28,9 @@ 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";
32
+ import type { VehicleRegistry } from "@danypops/vehicle-server";
33
+ import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
31
34
  import { Logs } from "./log-service.ts";
32
35
  import { SQLiteLogStore } from "./adapters/sqlite-log-store.ts";
33
36
  import { SessionIdentity, InvalidSessionSecretError } from "./session-identity-service.ts";
@@ -204,6 +207,13 @@ export interface PapyrusService {
204
207
  operationNames(): OperationName[];
205
208
  schemaState(): SchemaState;
206
209
  execute(operation: string, input?: OperationInput): Promise<unknown>;
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.
215
+ */
216
+ readonly notesVehicle: VehicleRegistry;
207
217
  checkpoint(): void;
208
218
  optimize(): void;
209
219
  /** Time-based Task Focus reclamation (see Tasks.reapStaleFocus); returns how many rows were removed, for daemon logging. */
@@ -469,6 +479,7 @@ export function createPapyrusService(path: string): PapyrusService {
469
479
  const tasks = new Tasks(artifacts, gates, focus, events, scopes, leases);
470
480
  const noteEvents = new SQLiteNoteEventStore(db);
471
481
  const notes = new Notes(artifacts, noteEvents);
482
+ const notesVehicle = createNotesVehicleRegistry(notes, artifacts);
472
483
  const projections = new SQLiteGraphProjectionStore(db);
473
484
  const artifactScopes = new SQLiteArtifactScopeStore(db);
474
485
  const logs = new Logs(new SQLiteLogStore(db));
@@ -494,6 +505,7 @@ export function createPapyrusService(path: string): PapyrusService {
494
505
  return {
495
506
  operationNames: () => [...EXPECTED_OPERATION_NAMES],
496
507
  schemaState: state,
508
+ notesVehicle,
497
509
  async execute(operation, input = {}) {
498
510
  const handler = registry[operation as OperationName];
499
511
  if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
@@ -553,12 +565,17 @@ export function createApp(deps: {
553
565
  */
554
566
  onOperationExecuted?: (operation: string, input: OperationInput) => void;
555
567
  }): { 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 });
556
572
  return {
557
573
  async fetch(request: Request): Promise<Response> {
558
574
  if (request.headers.get("authorization") !== `Bearer ${deps.token}`) {
559
575
  return json({ error: "missing or invalid bearer token" }, { status: 401 });
560
576
  }
561
577
  const url = new URL(request.url);
578
+ if (url.pathname.startsWith("/vehicle/")) return vehicleApp.fetch(request);
562
579
  if (request.method === "GET" && url.pathname === "/health") {
563
580
  return json({ ok: true, version: VERSION, schema: deps.service.schemaState() });
564
581
  }
@@ -0,0 +1,210 @@
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).
8
+ *
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.
17
+ */
18
+ import { defineVehicleOperation, defineVehicleSchema, bindVehicleOperation, type VehicleSchemaCodec } from "@danypops/vehicle-core";
19
+ import { VehicleRegistry } from "@danypops/vehicle-server";
20
+ import type { ArtifactStore } from "../ports/artifact-store.ts";
21
+ import { Notes, NOTE_DISPOSITIONS } from "../note-service.ts";
22
+ import type { Artifact } from "../domain/artifact.ts";
23
+ import { notesOperations } from "../modules/notes.ts";
24
+
25
+ const OWNER = "notes";
26
+
27
+ const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
28
+
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
+ /** Resolves a note's id from either an explicit id or its title within projectRoot. */
74
+ function resolveNoteId(notes: Notes, projectRoot: string, id: unknown, name: unknown): string {
75
+ if (typeof id === "string" && id.length > 0) return id;
76
+ if (typeof name !== "string" || name.length === 0) throw new Error("id or name is required");
77
+ return matchArtifactByName(notes.list({ projectRoot, text: name }), name);
78
+ }
79
+
80
+ /** 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
+ function resolveArtifactId(artifacts: ArtifactStore, id: unknown, name: unknown): string {
82
+ if (typeof id === "string" && id.length > 0) return id;
83
+ 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);
85
+ }
86
+
87
+ /**
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`.
92
+ */
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." });
95
+ const moduleOperations = new Map(notesOperations(notes).map((op) => [op.name, op]));
96
+ const call = (name: string, input: Record<string, unknown>): unknown => moduleOperations.get(name)!.execute(input);
97
+
98
+ const define = (
99
+ action: string,
100
+ description: string,
101
+ effect: "read" | "local-write",
102
+ properties: Record<string, { type: string; enum?: readonly string[] }>,
103
+ required: readonly string[],
104
+ resolve: (input: Record<string, unknown>) => Record<string, unknown>,
105
+ ): void => {
106
+ const operation = defineVehicleOperation({
107
+ name: `notes.${action}`,
108
+ version: 1,
109
+ description,
110
+ input: looseObjectSchema(properties, required),
111
+ output: passthroughOutput,
112
+ permissions: ["notes:read", "notes:write"],
113
+ effect,
114
+ idempotency: { mode: effect === "read" ? "safe" : "unsafe" },
115
+ limits: LIMITS,
116
+ });
117
+ registry.register(OWNER, bindVehicleOperation(operation, () => async (context) => call(`notes.${action}`, resolve(context.input))));
118
+ };
119
+
120
+ const stringProp = { type: "string" } as const;
121
+ const numberProp = { type: "number" } as const;
122
+
123
+ define(
124
+ "capture",
125
+ "Stores a deferred request without creating work. Returns the created note.",
126
+ "local-write",
127
+ { body: stringProp, title: stringProp, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
128
+ ["body", "project_root"],
129
+ (input) => input,
130
+ );
131
+
132
+ define(
133
+ "list",
134
+ "Lists open (draft/active) notes, or a specific status, in a project.",
135
+ "read",
136
+ { project_root: stringProp, status: { type: "string", enum: ["draft", "active", "archived"] }, text: stringProp, limit: numberProp },
137
+ ["project_root"],
138
+ (input) => input,
139
+ );
140
+
141
+ define(
142
+ "show",
143
+ "Shows one note by id or title.",
144
+ "read",
145
+ { id: stringProp, name: stringProp, project_root: stringProp },
146
+ ["project_root"],
147
+ (input) => ({ ...input, id: resolveNoteId(notes, input.project_root as string, input.id, input.name) }),
148
+ );
149
+
150
+ define(
151
+ "history",
152
+ "This note's own real append-only event log (captured/consumed/promoted/archived).",
153
+ "read",
154
+ { id: stringProp, name: stringProp, project_root: stringProp, limit: numberProp, cursor: numberProp, direction: { type: "string", enum: ["asc", "desc"] } },
155
+ ["project_root"],
156
+ (input) => ({ ...input, id: resolveNoteId(notes, input.project_root as string, input.id, input.name) }),
157
+ );
158
+
159
+ define(
160
+ "consume",
161
+ "Marks a note as considered.",
162
+ "local-write",
163
+ { id: stringProp, name: stringProp, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp, reason: stringProp },
164
+ ["project_root"],
165
+ (input) => ({ ...input, id: resolveNoteId(notes, input.project_root as string, input.id, input.name) }),
166
+ );
167
+
168
+ define(
169
+ "promote",
170
+ "Links a note to the Task, Doc, Rule, or Skill it was promoted into, then archives it.",
171
+ "local-write",
172
+ {
173
+ id: stringProp,
174
+ name: stringProp,
175
+ target_id: stringProp,
176
+ target_name: stringProp,
177
+ project_root: stringProp,
178
+ actor: stringProp,
179
+ source: stringProp,
180
+ session_id: stringProp,
181
+ reason: stringProp,
182
+ },
183
+ ["project_root"],
184
+ (input) => ({
185
+ ...input,
186
+ id: resolveNoteId(notes, input.project_root as string, input.id, input.name),
187
+ target_id: resolveArtifactId(artifacts, input.target_id, input.target_name),
188
+ }),
189
+ );
190
+
191
+ define(
192
+ "archive",
193
+ "Archives a note with an explicit disposition.",
194
+ "local-write",
195
+ {
196
+ id: stringProp,
197
+ name: stringProp,
198
+ project_root: stringProp,
199
+ disposition: { type: "string", enum: [...NOTE_DISPOSITIONS] },
200
+ actor: stringProp,
201
+ source: stringProp,
202
+ session_id: stringProp,
203
+ reason: stringProp,
204
+ },
205
+ ["project_root", "disposition"],
206
+ (input) => ({ ...input, id: resolveNoteId(notes, input.project_root as string, input.id, input.name) }),
207
+ );
208
+
209
+ return registry;
210
+ }