@danypops/papyrus 0.38.4 → 0.40.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/README.md +1 -1
- package/package.json +2 -2
- package/src/modules/skills.ts +43 -1
- package/src/service.ts +13 -32
- package/src/vehicle/artifact-trash-vehicle.ts +95 -0
- package/src/vehicle/artifact-vehicle-shared.ts +126 -0
- package/src/vehicle/docs-vehicle.ts +145 -0
- package/src/vehicle/notes-vehicle.ts +18 -74
- package/src/vehicle/papyrus-vehicle.ts +48 -0
- package/src/vehicle/playbooks-vehicle.ts +243 -0
- package/src/vehicle/rules-vehicle.ts +153 -0
- package/src/vehicle/skills-vehicle.ts +194 -0
package/README.md
CHANGED
|
@@ -80,7 +80,7 @@ papyrus skills run <skill-id> \
|
|
|
80
80
|
--json
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
The existing `artifact-template` skill subtype remains a compatibility mechanism for one-artifact templates with metadata `{targetKind, defaults, required}`. Instantiate it
|
|
83
|
+
The existing `artifact-template` skill subtype remains a compatibility mechanism for one-artifact templates with metadata `{targetKind, defaults, required}`. Instantiate it via the `skills.instantiate` operation with `template_id`; defaults merge recursively, explicit arrays replace defaults, required paths such as `extra.owner` are validated, and target-kind mismatches are rejected.
|
|
84
84
|
|
|
85
85
|
### Removing an artifact
|
|
86
86
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/papyrus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.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"],
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"files": ["src", "README.md"],
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@danypops/vehicle-core": "^0.
|
|
37
|
+
"@danypops/vehicle-core": "^0.2.0",
|
|
38
38
|
"@danypops/vehicle-client": "^0.1.1",
|
|
39
39
|
"@danypops/vehicle-server": "^0.3.2"
|
|
40
40
|
}
|
package/src/modules/skills.ts
CHANGED
|
@@ -17,12 +17,15 @@
|
|
|
17
17
|
* extraction.
|
|
18
18
|
*/
|
|
19
19
|
import type { AuthorityRegistry } from "../authority-registry.ts";
|
|
20
|
-
import {
|
|
20
|
+
import type { Artifact, CreateArtifactInput } from "../domain/artifact.ts";
|
|
21
|
+
import type { ArtifactEventContext } from "../domain/artifact-event.ts";
|
|
22
|
+
import { assignSkillProject, createArtifactTemplate, createSkill, instantiateTemplate, listSkills, showSkill, skillInvocation, transitionSkill, updateSkill } from "../domain-services.ts";
|
|
21
23
|
import type { OperationDefinition } from "../module-registry.ts";
|
|
22
24
|
import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
|
|
23
25
|
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
24
26
|
import type { TaskEventStore } from "../ports/task-event-store.ts";
|
|
25
27
|
import type { TaskScopeStore } from "../ports/task-scope-store.ts";
|
|
28
|
+
import type { Tasks, TaskStatus } from "../task-service.ts";
|
|
26
29
|
import { instantiateSkillWorkflow } from "../workflow-execution.ts";
|
|
27
30
|
|
|
28
31
|
const MODULE_ID = "skills";
|
|
@@ -76,6 +79,45 @@ export interface SkillsModuleDeps {
|
|
|
76
79
|
authority: AuthorityRegistry;
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
function normalizeCreateInput(input: OperationInput): CreateArtifactInput {
|
|
83
|
+
const { template_id, ...rest } = input;
|
|
84
|
+
return { ...rest, templateId: typeof template_id === "string" ? template_id : undefined } as CreateArtifactInput;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* skills.instantiate's own branching logic (compatibility-template creation vs. a
|
|
89
|
+
* task-target template's tasks.create() call) -- shared between service.ts's raw RPC
|
|
90
|
+
* forwarder and skills-vehicle.ts's Vehicle operation, the two real callers, instead
|
|
91
|
+
* of reimplemented in each. Takes `tasks: Tasks` directly rather than through
|
|
92
|
+
* SkillsModuleDeps: a genuine cross-module dependency, the same category as
|
|
93
|
+
* rules.injectable and the module comment's own reason skills.instantiate isn't
|
|
94
|
+
* registered as an operation here.
|
|
95
|
+
*/
|
|
96
|
+
export interface InstantiateSkillDeps {
|
|
97
|
+
artifacts: ArtifactStore;
|
|
98
|
+
tasks: Tasks;
|
|
99
|
+
authority: AuthorityRegistry;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function instantiateSkillOrTemplate(deps: InstantiateSkillDeps, input: OperationInput, context?: ArtifactEventContext): Artifact {
|
|
103
|
+
const templateId = string(input, "template_id");
|
|
104
|
+
const template = deps.artifacts.get(templateId);
|
|
105
|
+
// Note ownership for a non-task template target is enforced inside instantiateTemplate's
|
|
106
|
+
// own rejectsNoteTemplate for the non-task branch below -- nothing else currently claims
|
|
107
|
+
// an unresolved (pre-template-resolution) kind, so there is no check to perform here.
|
|
108
|
+
if (template?.extra["targetKind"] !== "task") return instantiateTemplate(deps.artifacts, templateId, normalizeCreateInput(input), deps.authority, context);
|
|
109
|
+
return deps.tasks.create({
|
|
110
|
+
title: optionalString(input, "title") as string,
|
|
111
|
+
body: optionalString(input, "body"),
|
|
112
|
+
status: optionalString(input, "status") as TaskStatus | undefined,
|
|
113
|
+
labels: input["labels"] as string[] | undefined,
|
|
114
|
+
extra: input["extra"] as Record<string, unknown> | undefined,
|
|
115
|
+
templateId,
|
|
116
|
+
projectRoot: string(input, "project_root"),
|
|
117
|
+
projectSource: "cwd",
|
|
118
|
+
}, context);
|
|
119
|
+
}
|
|
120
|
+
|
|
79
121
|
/** Registers every skills.* operation except skills.instantiate (see module comment). Behavior is unchanged from the prior inline handlers in src/service.ts. */
|
|
80
122
|
/** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. skills.instantiate is deliberately absent -- see the module comment above. */
|
|
81
123
|
export const SKILLS_OPERATION_NAMES = [
|
package/src/service.ts
CHANGED
|
@@ -24,11 +24,10 @@ import type { TaskEventStore } from "./ports/task-event-store.ts";
|
|
|
24
24
|
import type { TaskScopeStore } from "./ports/task-scope-store.ts";
|
|
25
25
|
import { Tasks, type TaskStatus } from "./task-service.ts";
|
|
26
26
|
import {
|
|
27
|
-
instantiateTemplate,
|
|
28
27
|
listInjectableRules,
|
|
29
28
|
} from "./domain-services.ts";
|
|
30
29
|
import { Notes, NOTE_SUBTYPE } from "./note-service.ts";
|
|
31
|
-
import {
|
|
30
|
+
import { createPapyrusVehicleRegistry } from "./vehicle/papyrus-vehicle.ts";
|
|
32
31
|
import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
33
32
|
import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
|
|
34
33
|
import { Logs } from "./log-service.ts";
|
|
@@ -40,7 +39,7 @@ import { graphProjectionOperations, GRAPH_PROJECTION_OPERATION_NAMES } from "./m
|
|
|
40
39
|
import { logsOperations, LOGS_OPERATION_NAMES } from "./modules/logs.ts";
|
|
41
40
|
import { notesOperations, NOTES_OPERATION_NAMES } from "./modules/notes.ts";
|
|
42
41
|
import { rulesOperations, RULES_OPERATION_NAMES } from "./modules/rules.ts";
|
|
43
|
-
import { skillsOperations, SKILLS_OPERATION_NAMES } from "./modules/skills.ts";
|
|
42
|
+
import { instantiateSkillOrTemplate, skillsOperations, SKILLS_OPERATION_NAMES } from "./modules/skills.ts";
|
|
44
43
|
import { playbooksOperations, PLAYBOOKS_OPERATION_NAMES } from "./modules/playbooks.ts";
|
|
45
44
|
import { sessionIdentityOperations, SESSION_IDENTITY_OPERATION_NAMES } from "./modules/session-identity.ts";
|
|
46
45
|
import { discussOperations, DISCUSS_OPERATION_NAMES } from "./modules/discuss.ts";
|
|
@@ -208,12 +207,13 @@ export interface PapyrusService {
|
|
|
208
207
|
schemaState(): SchemaState;
|
|
209
208
|
execute(operation: string, input?: OperationInput): Promise<unknown>;
|
|
210
209
|
/**
|
|
211
|
-
*
|
|
212
|
-
* one honest VehicleOperation per real
|
|
213
|
-
*
|
|
214
|
-
* domain
|
|
210
|
+
* Every domain migrated onto Vehicle, merged into one registry/one HTTP mount
|
|
211
|
+
* (see ./vehicle/papyrus-vehicle.ts) -- one honest VehicleOperation per real
|
|
212
|
+
* action, replacing the Pi extension's old `<domain>(action=X)` mega-tools.
|
|
213
|
+
* Not every domain is migrated yet -- see papyrus-vehicle.ts's own doc comment
|
|
214
|
+
* for what still isn't and why.
|
|
215
215
|
*/
|
|
216
|
-
readonly
|
|
216
|
+
readonly vehicle: VehicleRegistry;
|
|
217
217
|
checkpoint(): void;
|
|
218
218
|
optimize(): void;
|
|
219
219
|
/** Time-based Task Focus reclamation (see Tasks.reapStaleFocus); returns how many rows were removed, for daemon logging. */
|
|
@@ -431,24 +431,7 @@ function handlers(
|
|
|
431
431
|
"playbooks.uncontain": forwardToModule("playbooks.uncontain"),
|
|
432
432
|
"playbooks.depend": forwardToModule("playbooks.depend"),
|
|
433
433
|
"playbooks.undepend": forwardToModule("playbooks.undepend"),
|
|
434
|
-
"skills.instantiate": (input) => {
|
|
435
|
-
const templateId = string(input, "template_id");
|
|
436
|
-
const template = artifacts.get(templateId);
|
|
437
|
-
// Note ownership for a non-task template target is enforced inside instantiateTemplate's
|
|
438
|
-
// own rejectsNoteTemplate for the non-task branch below -- nothing else currently claims
|
|
439
|
-
// an unresolved (pre-template-resolution) kind, so there is no check to perform here.
|
|
440
|
-
if (template?.extra["targetKind"] !== "task") return instantiateTemplate(artifacts, templateId, normalizeCreateInput(input), authority, eventContext(input));
|
|
441
|
-
return tasks.create({
|
|
442
|
-
title: optionalString(input, "title") as string,
|
|
443
|
-
body: optionalString(input, "body"),
|
|
444
|
-
status: optionalString(input, "status") as TaskStatus | undefined,
|
|
445
|
-
labels: input["labels"] as string[] | undefined,
|
|
446
|
-
extra: input["extra"] as Record<string, unknown> | undefined,
|
|
447
|
-
templateId,
|
|
448
|
-
projectRoot: string(input, "project_root"),
|
|
449
|
-
projectSource: "cwd",
|
|
450
|
-
}, eventContextFor(input, "template-instantiation"));
|
|
451
|
-
},
|
|
434
|
+
"skills.instantiate": (input) => instantiateSkillOrTemplate({ artifacts, tasks, authority }, input, eventContextFor(input, "template-instantiation")),
|
|
452
435
|
"graph_projection.apply": forwardToModule("graph_projection.apply"),
|
|
453
436
|
"graph_projection.checkpoint": forwardToModule("graph_projection.checkpoint"),
|
|
454
437
|
"logs.append": forwardToModule("logs.append"),
|
|
@@ -479,13 +462,13 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
479
462
|
const tasks = new Tasks(artifacts, gates, focus, events, scopes, leases);
|
|
480
463
|
const noteEvents = new SQLiteNoteEventStore(db);
|
|
481
464
|
const notes = new Notes(artifacts, noteEvents);
|
|
482
|
-
const notesVehicle = createNotesVehicleRegistry(notes, artifacts);
|
|
483
465
|
const projections = new SQLiteGraphProjectionStore(db);
|
|
484
466
|
const artifactScopes = new SQLiteArtifactScopeStore(db);
|
|
485
467
|
const logs = new Logs(new SQLiteLogStore(db));
|
|
486
468
|
const sessionIdentity = new SessionIdentity(new SQLiteSessionIdentityStore(db));
|
|
487
469
|
const discussions = new Discussions(artifacts, new SQLiteDiscussionRoundStore(db));
|
|
488
470
|
const authority = createAuthorityRegistry();
|
|
471
|
+
const vehicle = createPapyrusVehicleRegistry({ artifacts, scopes: artifactScopes, authority, notes, events, taskScopes: scopes, tasks, sessionIdentity });
|
|
489
472
|
const moduleRegistry = new OperationRegistry();
|
|
490
473
|
moduleRegistry.registerAll(notesOperations(notes));
|
|
491
474
|
moduleRegistry.registerAll(logsOperations(logs));
|
|
@@ -505,7 +488,7 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
505
488
|
return {
|
|
506
489
|
operationNames: () => [...EXPECTED_OPERATION_NAMES],
|
|
507
490
|
schemaState: state,
|
|
508
|
-
|
|
491
|
+
vehicle,
|
|
509
492
|
async execute(operation, input = {}) {
|
|
510
493
|
const handler = registry[operation as OperationName];
|
|
511
494
|
if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
|
|
@@ -565,10 +548,8 @@ export function createApp(deps: {
|
|
|
565
548
|
*/
|
|
566
549
|
onOperationExecuted?: (operation: string, input: OperationInput) => void;
|
|
567
550
|
}): { fetch(request: Request): Promise<Response> } {
|
|
568
|
-
// Same Bearer token as the rest of this API --
|
|
569
|
-
|
|
570
|
-
// not a second service to stand up or authenticate against separately.
|
|
571
|
-
const vehicleApp = createVehicleHttpApp({ registry: deps.service.notesVehicle, token: deps.token });
|
|
551
|
+
// Same Bearer token, daemon, and port as the rest of this API -- see ./vehicle/papyrus-vehicle.ts.
|
|
552
|
+
const vehicleApp = createVehicleHttpApp({ registry: deps.service.vehicle, token: deps.token });
|
|
572
553
|
return {
|
|
573
554
|
async fetch(request: Request): Promise<Response> {
|
|
574
555
|
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,126 @@
|
|
|
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, type VehicleContentBlock } from "@danypops/vehicle-core";
|
|
7
|
+
import type { Artifact } from "../domain/artifact.ts";
|
|
8
|
+
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
9
|
+
import type { TaskExecutionPlan } from "../task-execution.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* VehicleRegistry only ever calls a schema's own safeParse -- jsonSchema is
|
|
13
|
+
* descriptive metadata surfaced to a client/Pi projection, never itself
|
|
14
|
+
* enforced at runtime -- so a declared `enum` has to be checked here for
|
|
15
|
+
* real, or it's a documentation gesture, not an honest contract.
|
|
16
|
+
*/
|
|
17
|
+
export function looseObjectSchema(properties: Record<string, { type: string; enum?: readonly string[] }>, required: readonly string[] = []): VehicleSchemaCodec<Record<string, unknown>> {
|
|
18
|
+
return defineVehicleSchema<Record<string, unknown>>({
|
|
19
|
+
jsonSchema: { type: "object", properties, required: [...required], additionalProperties: false },
|
|
20
|
+
safeParse(value) {
|
|
21
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
22
|
+
return { success: false, issues: [{ path: [], message: "input must be an object" }] };
|
|
23
|
+
}
|
|
24
|
+
const input = value as Record<string, unknown>;
|
|
25
|
+
for (const key of required) {
|
|
26
|
+
if (!(key in input)) return { success: false, issues: [{ path: [key], message: `${key} is required` }] };
|
|
27
|
+
}
|
|
28
|
+
for (const [key, schema] of Object.entries(properties)) {
|
|
29
|
+
if (!schema.enum || !(key in input)) continue;
|
|
30
|
+
if (!schema.enum.includes(input[key] as string)) {
|
|
31
|
+
return { success: false, issues: [{ path: [key], message: `${key} must be one of ${schema.enum.join(", ")}` }] };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { success: true, value: input };
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const passthroughOutput: VehicleSchemaCodec<unknown> = defineVehicleSchema<unknown>({
|
|
40
|
+
jsonSchema: { type: "object" },
|
|
41
|
+
safeParse: (value) => ({ success: true, value }),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const stringProp = { type: "string" } as const;
|
|
45
|
+
export const numberProp = { type: "number" } as const;
|
|
46
|
+
|
|
47
|
+
/** A known LLM tool-calling quirk: a nested-object field arrives JSON-stringified rather than as a real object. Mutates input[key] in place when it's a string, leaves it untouched otherwise. */
|
|
48
|
+
export function normalizeJsonEncodedField(input: Record<string, unknown>, key: string): void {
|
|
49
|
+
const value = input[key];
|
|
50
|
+
if (typeof value !== "string") return;
|
|
51
|
+
try {
|
|
52
|
+
input[key] = JSON.parse(value);
|
|
53
|
+
} catch {
|
|
54
|
+
throw new Error(`${key} must be valid JSON`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 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. */
|
|
59
|
+
export function matchArtifactByName(candidates: readonly Artifact[], name: string): string {
|
|
60
|
+
const needle = name.trim().toLowerCase();
|
|
61
|
+
const matches = candidates.filter((artifact) => artifact.title.trim().toLowerCase() === needle);
|
|
62
|
+
if (matches.length === 0) throw new Error(`no artifact named "${name}" found in this scope`);
|
|
63
|
+
if (matches.length > 1) {
|
|
64
|
+
throw new Error(`${matches.length} artifacts are named "${name}": ${matches.map((a) => `${a.title} (${a.id})`).join(", ")} -- use id to disambiguate`);
|
|
65
|
+
}
|
|
66
|
+
return matches[0]!.id;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolves a name to an id, retrying against `fetchWidened` (an unscoped/cross-project
|
|
71
|
+
* search) only when `fetchCandidates` finds nothing. Owns the match-or-widen control
|
|
72
|
+
* flow only -- the caller supplies its own scoped/widened list calls, since scoping
|
|
73
|
+
* differs per domain. Omit `fetchWidened` when there is no wider scope to retry.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveArtifactIdWidened(name: string, fetchCandidates: () => readonly Artifact[], fetchWidened?: () => readonly Artifact[]): string {
|
|
76
|
+
try {
|
|
77
|
+
return matchArtifactByName(fetchCandidates(), name);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (!(error instanceof Error) || !error.message.startsWith("no artifact named") || !fetchWidened) throw error;
|
|
80
|
+
return matchArtifactByName(fetchWidened(), name);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Synchronous equivalent of pi-papyrus's own artifactLabelsById -- server-side, a direct ArtifactStore.get() replaces the extra RPC round-trip that helper needed client-side. Disambiguates same-titled artifacts by appending their id. */
|
|
85
|
+
export function labelsById(artifacts: ArtifactStore, ids: readonly string[]): Map<string, string> {
|
|
86
|
+
const uniqueIds = [...new Set(ids)];
|
|
87
|
+
const resolved = uniqueIds.map((id) => artifacts.get(id)).filter((artifact): artifact is Artifact => artifact !== null);
|
|
88
|
+
const titleCounts = new Map<string, number>();
|
|
89
|
+
for (const artifact of resolved) titleCounts.set(artifact.title, (titleCounts.get(artifact.title) ?? 0) + 1);
|
|
90
|
+
return new Map(resolved.map((artifact) => [artifact.id, (titleCounts.get(artifact.title) ?? 0) > 1 ? `${artifact.title} (${artifact.id})` : artifact.title]));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface WorkflowRunNarrativeInput {
|
|
94
|
+
runId: string;
|
|
95
|
+
created: { docs: readonly string[]; rules: readonly string[]; tasks: readonly string[] };
|
|
96
|
+
rootTaskIds: readonly string[];
|
|
97
|
+
execution: TaskExecutionPlan;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Shared between skills.run and playbooks.invoke's Vehicle operations -- both produce the
|
|
102
|
+
* same shaped narrative (ready roots, context docs, scoped rules, an execution tree), only
|
|
103
|
+
* the headline and whether an "entry task focused" line is present differ. Builds the model-
|
|
104
|
+
* facing `content` text directly, so the model reads a summary instead of the raw execution
|
|
105
|
+
* DAG -- the same shape pi-papyrus's own hand-rolled skills/playbooks tools built client-side,
|
|
106
|
+
* now built once here where the run result is actually produced.
|
|
107
|
+
*/
|
|
108
|
+
export function buildWorkflowRunContent(artifacts: ArtifactStore, headline: string, input: WorkflowRunNarrativeInput, extraLines: readonly string[] = []): VehicleContentBlock {
|
|
109
|
+
const nodeById = new Map(input.execution.nodes.map((node) => [node.id, node]));
|
|
110
|
+
const rootLabels = input.rootTaskIds.map((id) => nodeById.get(id)?.title ?? "unknown task");
|
|
111
|
+
const createdLabels = labelsById(artifacts, [...input.created.docs, ...input.created.rules]);
|
|
112
|
+
const titleCounts = new Map<string, number>();
|
|
113
|
+
for (const node of input.execution.nodes) titleCounts.set(node.title, (titleCounts.get(node.title) ?? 0) + 1);
|
|
114
|
+
const executionLines = input.execution.nodes
|
|
115
|
+
.map((node) => ((titleCounts.get(node.title) ?? 0) > 1 ? ` [${node.state}] ${node.title} (${node.id})` : ` [${node.state}] ${node.title}`))
|
|
116
|
+
.join("\n");
|
|
117
|
+
const text = [
|
|
118
|
+
headline,
|
|
119
|
+
...extraLines,
|
|
120
|
+
`Ready roots: ${rootLabels.join(", ") || "none"}.`,
|
|
121
|
+
`Context docs: ${input.created.docs.map((id) => createdLabels.get(id) ?? "unknown document").join(", ") || "none"}.`,
|
|
122
|
+
`Scoped rules: ${input.created.rules.map((id) => createdLabels.get(id) ?? "unknown rule").join(", ") || "none"}.`,
|
|
123
|
+
...(executionLines ? ["Execution:", executionLines] : []),
|
|
124
|
+
].join("\n");
|
|
125
|
+
return { type: "text", text };
|
|
126
|
+
}
|
|
@@ -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
|
+
}
|