@danypops/papyrus 0.1.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 +139 -0
- package/extension/src/artifact-browser.ts +213 -0
- package/extension/src/artifact-format.ts +82 -0
- package/extension/src/beautiful-mermaid-renderer.ts +45 -0
- package/extension/src/docs.ts +48 -0
- package/extension/src/facade-tools.ts +209 -0
- package/extension/src/index.ts +354 -0
- package/extension/src/rules.ts +44 -0
- package/extension/src/service-client.ts +45 -0
- package/extension/src/skills.ts +60 -0
- package/extension/src/task-context.ts +1 -0
- package/extension/src/task-detail-format.ts +66 -0
- package/extension/src/task-detail-view.ts +111 -0
- package/extension/src/task-graph.ts +97 -0
- package/extension/src/task-widget.ts +49 -0
- package/extension/src/tasks.ts +258 -0
- package/package.json +43 -0
- package/src/adapters/sqlite-artifact-store.ts +64 -0
- package/src/adapters/sqlite-gate-runner.ts +16 -0
- package/src/cli.ts +71 -0
- package/src/client.ts +59 -0
- package/src/constants.ts +113 -0
- package/src/daemon-state.ts +59 -0
- package/src/daemon.ts +41 -0
- package/src/db.ts +138 -0
- package/src/domain/artifact.ts +56 -0
- package/src/domain/checklist.ts +70 -0
- package/src/domain/display-graph.ts +23 -0
- package/src/domain/gate.ts +11 -0
- package/src/facades.ts +215 -0
- package/src/ops.ts +336 -0
- package/src/ports/artifact-store.ts +19 -0
- package/src/ports/gate-runner.ts +6 -0
- package/src/ports/graph-renderer.ts +5 -0
- package/src/service.ts +292 -0
- package/src/task-context.ts +52 -0
- package/src/task-graph-view.ts +34 -0
- package/src/task-relationship-view.ts +39 -0
- package/src/task-service.ts +176 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
4
|
+
import { PROOF_TYPES } from "../../src/domain/checklist.ts";
|
|
5
|
+
import type { GateResult } from "../../src/domain/gate.ts";
|
|
6
|
+
import { callService } from "./service-client.ts";
|
|
7
|
+
|
|
8
|
+
function text(message: string, details: Record<string, unknown> = {}) {
|
|
9
|
+
return { content: [{ type: "text" as const, text: message }], details };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function artifactLine(artifact: Artifact): string {
|
|
13
|
+
return `${artifact.id} [${artifact.status}] ${artifact.title}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const proofReferenceSchema = Type.Object({
|
|
17
|
+
type: Type.Union(PROOF_TYPES.map((type) => Type.Literal(type))),
|
|
18
|
+
target: Type.String(),
|
|
19
|
+
expect: Type.Optional(Type.String()),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const checklistCriterionSchema = Type.Object({
|
|
23
|
+
proof: Type.Array(proofReferenceSchema, { minItems: 1 }),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export function registerFacadeTools(pi: ExtensionAPI): void {
|
|
27
|
+
pi.registerTool({
|
|
28
|
+
name: "tasks",
|
|
29
|
+
label: "Tasks",
|
|
30
|
+
description: "Task domain facade. ACTIONS: create, list, show, start, complete (runs gates and refuses done on failure), fail, retry, run_gates, set_checklist, depend, contain. Checklist is an item-to-proof map; every item requires one or more typed evidence references. Prefer this over low-level papyrus_* tools for task work.",
|
|
31
|
+
parameters: Type.Object({
|
|
32
|
+
action: Type.String(),
|
|
33
|
+
id: Type.Optional(Type.String()),
|
|
34
|
+
title: Type.Optional(Type.String()),
|
|
35
|
+
body: Type.Optional(Type.String()),
|
|
36
|
+
status: Type.Optional(Type.String()),
|
|
37
|
+
text: Type.Optional(Type.String()),
|
|
38
|
+
limit: Type.Optional(Type.Number()),
|
|
39
|
+
labels: Type.Optional(Type.Array(Type.String())),
|
|
40
|
+
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
41
|
+
gates: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()))),
|
|
42
|
+
checklist: Type.Optional(Type.Record(Type.String(), checklistCriterionSchema)),
|
|
43
|
+
template_id: Type.Optional(Type.String()),
|
|
44
|
+
parent_id: Type.Optional(Type.String()),
|
|
45
|
+
child_id: Type.Optional(Type.String()),
|
|
46
|
+
dependency_id: Type.Optional(Type.String()),
|
|
47
|
+
depends_on: Type.Optional(Type.Array(Type.String())),
|
|
48
|
+
}),
|
|
49
|
+
async execute(_id, params) {
|
|
50
|
+
try {
|
|
51
|
+
const action = params.action;
|
|
52
|
+
if (action === "create") {
|
|
53
|
+
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", params);
|
|
54
|
+
return text(`Created task ${artifactLine(artifact)}`, { artifact });
|
|
55
|
+
}
|
|
56
|
+
if (action === "list") {
|
|
57
|
+
const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list", params);
|
|
58
|
+
return text(rows.length ? rows.map(artifactLine).join("\n") : "No tasks found.", { rows });
|
|
59
|
+
}
|
|
60
|
+
if (action === "show") {
|
|
61
|
+
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.show", params);
|
|
62
|
+
return text(`${artifactLine(artifact)}\n\n${artifact.body}`, { artifact });
|
|
63
|
+
}
|
|
64
|
+
if (action === "set_checklist") {
|
|
65
|
+
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.set_checklist", params);
|
|
66
|
+
return text(`Updated checklist: ${artifactLine(artifact)}`, { artifact });
|
|
67
|
+
}
|
|
68
|
+
if (action === "complete") {
|
|
69
|
+
const result = await callService<Record<string, unknown>, { artifact: Artifact; gates: GateResult[]; completed: boolean }>("tasks.complete", params);
|
|
70
|
+
const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n");
|
|
71
|
+
return text(`${result.completed ? "Completed" : "Not completed"}: ${artifactLine(result.artifact)}${gates ? `\n${gates}` : ""}`, { ...result });
|
|
72
|
+
}
|
|
73
|
+
if (action === "run_gates") {
|
|
74
|
+
const gates = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", params);
|
|
75
|
+
return text(gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n") || "No gates configured.", { gates });
|
|
76
|
+
}
|
|
77
|
+
const operations = { start: "tasks.start", fail: "tasks.fail", retry: "tasks.retry", depend: "tasks.depend", contain: "tasks.contain" } as const;
|
|
78
|
+
const operation = operations[action as keyof typeof operations];
|
|
79
|
+
if (!operation) return text(`Unknown tasks action: ${action}`);
|
|
80
|
+
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
81
|
+
return text(artifactLine(artifact), { artifact });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return text(`tasks failed: ${error instanceof Error ? error.message : error}`);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
pi.registerTool({
|
|
89
|
+
name: "docs",
|
|
90
|
+
label: "Documents",
|
|
91
|
+
description: "Document domain facade. ACTIONS: create, list, show, activate, archive, reopen, link. Prefer this over low-level papyrus_* tools for document work.",
|
|
92
|
+
parameters: Type.Object({
|
|
93
|
+
action: Type.String(),
|
|
94
|
+
id: Type.Optional(Type.String()),
|
|
95
|
+
title: Type.Optional(Type.String()),
|
|
96
|
+
body: Type.Optional(Type.String()),
|
|
97
|
+
subtype: Type.Optional(Type.String()),
|
|
98
|
+
status: Type.Optional(Type.String()),
|
|
99
|
+
text: Type.Optional(Type.String()),
|
|
100
|
+
limit: Type.Optional(Type.Number()),
|
|
101
|
+
labels: Type.Optional(Type.Array(Type.String())),
|
|
102
|
+
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
103
|
+
template_id: Type.Optional(Type.String()),
|
|
104
|
+
relation: Type.Optional(Type.String()),
|
|
105
|
+
target_id: Type.Optional(Type.String()),
|
|
106
|
+
}),
|
|
107
|
+
async execute(_id, params) {
|
|
108
|
+
try {
|
|
109
|
+
const action = params.action;
|
|
110
|
+
if (action === "create") {
|
|
111
|
+
const artifact = await callService<Record<string, unknown>, Artifact>("docs.create", params);
|
|
112
|
+
return text(`Created document ${artifactLine(artifact)}`, { artifact });
|
|
113
|
+
}
|
|
114
|
+
if (action === "list") {
|
|
115
|
+
const rows = await callService<Record<string, unknown>, Artifact[]>("docs.list", params);
|
|
116
|
+
return text(rows.length ? rows.map(artifactLine).join("\n") : "No documents found.", { rows });
|
|
117
|
+
}
|
|
118
|
+
if (action === "show") {
|
|
119
|
+
const artifact = await callService<Record<string, unknown>, Artifact>("docs.show", params);
|
|
120
|
+
return text(`${artifactLine(artifact)}\n\n${artifact.body}`, { artifact });
|
|
121
|
+
}
|
|
122
|
+
const operations = { activate: "docs.activate", archive: "docs.archive", reopen: "docs.reopen", link: "docs.link" } as const;
|
|
123
|
+
const operation = operations[action as keyof typeof operations];
|
|
124
|
+
if (!operation) return text(`Unknown docs action: ${action}`);
|
|
125
|
+
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
126
|
+
return text(artifactLine(artifact), { artifact });
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return text(`docs failed: ${error instanceof Error ? error.message : error}`);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
pi.registerTool({
|
|
134
|
+
name: "rules",
|
|
135
|
+
label: "Rules",
|
|
136
|
+
description: "Rule domain facade. ACTIONS: create, list, show, preview, enable, disable, gate. Active rules inject into the agent system prompt.",
|
|
137
|
+
parameters: Type.Object({
|
|
138
|
+
action: Type.String(), id: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
|
|
139
|
+
body: Type.Optional(Type.String()), condition: Type.Optional(Type.String()), rule_action: Type.Optional(Type.String()),
|
|
140
|
+
severity: Type.Optional(Type.String()), labels: Type.Optional(Type.Array(Type.String())),
|
|
141
|
+
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
|
|
142
|
+
text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), task_id: Type.Optional(Type.String()),
|
|
143
|
+
}),
|
|
144
|
+
async execute(_id, params) {
|
|
145
|
+
try {
|
|
146
|
+
const action = params.action;
|
|
147
|
+
if (action === "create") {
|
|
148
|
+
const artifact = await callService<Record<string, unknown>, Artifact>("rules.create", params);
|
|
149
|
+
return text(`Created rule ${artifactLine(artifact)}`, { artifact });
|
|
150
|
+
}
|
|
151
|
+
if (action === "list") {
|
|
152
|
+
const rows = await callService<Record<string, unknown>, Artifact[]>("rules.list", params);
|
|
153
|
+
return text(rows.length ? rows.map(artifactLine).join("\n") : "No rules found.", { rows });
|
|
154
|
+
}
|
|
155
|
+
if (action === "preview") {
|
|
156
|
+
const preview = await callService<Record<string, unknown>, string>("rules.preview", params);
|
|
157
|
+
return text(preview, { preview });
|
|
158
|
+
}
|
|
159
|
+
const operations = { show: "rules.show", enable: "rules.enable", disable: "rules.disable", gate: "rules.gate" } as const;
|
|
160
|
+
const operation = operations[action as keyof typeof operations];
|
|
161
|
+
if (!operation) return text(`Unknown rules action: ${action}`);
|
|
162
|
+
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
163
|
+
return text(`${artifactLine(artifact)}${action === "show" ? `\n\n${artifact.body}` : ""}`, { artifact });
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return text(`rules failed: ${error instanceof Error ? error.message : error}`);
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
pi.registerTool({
|
|
171
|
+
name: "skills",
|
|
172
|
+
label: "Skills",
|
|
173
|
+
description: "Skill and artifact-template domain facade. ACTIONS: create, create_template, list, show, invoke, enable, disable, instantiate.",
|
|
174
|
+
parameters: Type.Object({
|
|
175
|
+
action: Type.String(), id: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
|
|
176
|
+
body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
|
|
177
|
+
tools: Type.Optional(Type.Array(Type.String())), labels: Type.Optional(Type.Array(Type.String())),
|
|
178
|
+
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
|
|
179
|
+
text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), template_id: Type.Optional(Type.String()),
|
|
180
|
+
target_kind: Type.Optional(Type.String()), defaults: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
181
|
+
required: Type.Optional(Type.Array(Type.String())), kind: Type.Optional(Type.String()), subtype: Type.Optional(Type.String()),
|
|
182
|
+
}),
|
|
183
|
+
async execute(_id, params) {
|
|
184
|
+
try {
|
|
185
|
+
const action = params.action;
|
|
186
|
+
if (action === "create" || action === "create_template") {
|
|
187
|
+
const operation = action === "create" ? "skills.create" : "skills.create_template";
|
|
188
|
+
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
189
|
+
return text(`Created skill ${artifactLine(artifact)}`, { artifact });
|
|
190
|
+
}
|
|
191
|
+
if (action === "list") {
|
|
192
|
+
const rows = await callService<Record<string, unknown>, Artifact[]>("skills.list", params);
|
|
193
|
+
return text(rows.length ? rows.map(artifactLine).join("\n") : "No skills found.", { rows });
|
|
194
|
+
}
|
|
195
|
+
if (action === "invoke") {
|
|
196
|
+
const invocation = await callService<Record<string, unknown>, string>("skills.invoke", params);
|
|
197
|
+
return text(invocation, { invocation });
|
|
198
|
+
}
|
|
199
|
+
const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate" } as const;
|
|
200
|
+
const operation = operations[action as keyof typeof operations];
|
|
201
|
+
if (!operation) return text(`Unknown skills action: ${action}`);
|
|
202
|
+
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
203
|
+
return text(`${artifactLine(artifact)}${action === "show" ? `\n\n${artifact.body}` : ""}`, { artifact });
|
|
204
|
+
} catch (error) {
|
|
205
|
+
return text(`skills failed: ${error instanceof Error ? error.message : error}`);
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-papyrus — native Pi extension for the Papyrus graph store.
|
|
3
|
+
*
|
|
4
|
+
* Tools: papyrus_create/query/graph/show.
|
|
5
|
+
* Command: /tasks (interactive task panel).
|
|
6
|
+
* Widget: persistent task status above editor (rpiv-todo pattern).
|
|
7
|
+
* Injection: active rules + open tasks appended to system prompt every turn.
|
|
8
|
+
* "Are we there yet?" — the agent sees its open work items.
|
|
9
|
+
*/
|
|
10
|
+
import type { ExtensionAPI, ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
13
|
+
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
14
|
+
import type { GateResult } from "../../src/domain/gate.ts";
|
|
15
|
+
import { formatMetadata } from "./artifact-format.ts";
|
|
16
|
+
import { callService } from "./service-client.ts";
|
|
17
|
+
import { registerFacadeTools } from "./facade-tools.ts";
|
|
18
|
+
import type { TaskGraph } from "../../src/task-service.ts";
|
|
19
|
+
import { buildTaskWidgetProjection } from "./task-widget.ts";
|
|
20
|
+
|
|
21
|
+
function text(t: string, details: Record<string, unknown> = {}) {
|
|
22
|
+
return { content: [{ type: "text" as const, text: t }], details };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Task widget (TodoOverlay pattern from rpiv-todo: factory form, requestRender)
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
const GLYPHS: Record<string, (theme: Theme) => string> = {
|
|
30
|
+
pending: (t) => t.fg("dim", "○"),
|
|
31
|
+
active: (t) => t.fg("warning", "●"),
|
|
32
|
+
done: (t) => t.fg("success", "■"),
|
|
33
|
+
failed: (t) => t.fg("error", "▲"),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const WIDGET_KEY = "pi-papyrus";
|
|
37
|
+
|
|
38
|
+
class TaskOverlay {
|
|
39
|
+
private uiCtx: ExtensionUIContext | undefined;
|
|
40
|
+
private registered = false;
|
|
41
|
+
private tui: any | undefined;
|
|
42
|
+
private snapshot: TaskGraph = { nodes: [], rootIds: [] };
|
|
43
|
+
|
|
44
|
+
setUI(ctx: ExtensionUIContext): void {
|
|
45
|
+
if (ctx !== this.uiCtx) {
|
|
46
|
+
this.uiCtx = ctx;
|
|
47
|
+
this.registered = false;
|
|
48
|
+
this.tui = undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async refresh(): Promise<void> {
|
|
53
|
+
try {
|
|
54
|
+
this.snapshot = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", { limit: 500 });
|
|
55
|
+
} catch {
|
|
56
|
+
this.snapshot = { nodes: [], rootIds: [] };
|
|
57
|
+
}
|
|
58
|
+
this.render();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private render(): void {
|
|
62
|
+
if (!this.uiCtx) return;
|
|
63
|
+
|
|
64
|
+
// Hide widget when no tasks
|
|
65
|
+
if (this.snapshot.nodes.length === 0) {
|
|
66
|
+
if (this.registered) {
|
|
67
|
+
this.uiCtx.setWidget(WIDGET_KEY, undefined);
|
|
68
|
+
this.registered = false;
|
|
69
|
+
this.tui = undefined;
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!this.registered) {
|
|
75
|
+
this.uiCtx.setWidget(
|
|
76
|
+
WIDGET_KEY,
|
|
77
|
+
(tui: any, theme: Theme) => {
|
|
78
|
+
this.tui = tui;
|
|
79
|
+
return {
|
|
80
|
+
render: (width: number) => this.renderLines(theme, width),
|
|
81
|
+
invalidate: () => {
|
|
82
|
+
// Theme changed — force re-registration
|
|
83
|
+
this.registered = false;
|
|
84
|
+
this.tui = undefined;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
{ placement: "aboveEditor" },
|
|
89
|
+
);
|
|
90
|
+
this.registered = true;
|
|
91
|
+
} else {
|
|
92
|
+
this.tui?.requestRender?.();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private renderLines(theme: Theme, width: number): string[] {
|
|
97
|
+
const projection = buildTaskWidgetProjection(this.snapshot);
|
|
98
|
+
if (projection.total === 0) return [];
|
|
99
|
+
|
|
100
|
+
if (projection.activeTotal === 0) {
|
|
101
|
+
return [truncateToWidth(theme.bold("Tasks · no active tasks · /tasks"), width, "…")];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const lines = [
|
|
105
|
+
truncateToWidth(
|
|
106
|
+
theme.bold(`Tasks · ${GLYPHS.active!(theme)} ${projection.activeTotal} active`),
|
|
107
|
+
width,
|
|
108
|
+
"…",
|
|
109
|
+
),
|
|
110
|
+
];
|
|
111
|
+
for (const row of projection.active) {
|
|
112
|
+
const hierarchy = row.depth === 0
|
|
113
|
+
? row.hasActiveChildren ? "▾" : "·"
|
|
114
|
+
: `${" ".repeat(row.depth)}↳`;
|
|
115
|
+
lines.push(truncateToWidth(` ${hierarchy} ${GLYPHS.active!(theme)} ${row.task.title}`, width, "…"));
|
|
116
|
+
}
|
|
117
|
+
return lines;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
dispose(): void {
|
|
121
|
+
this.uiCtx?.setWidget(WIDGET_KEY, undefined);
|
|
122
|
+
this.registered = false;
|
|
123
|
+
this.tui = undefined;
|
|
124
|
+
this.uiCtx = undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Entry point
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
export default async function (pi: ExtensionAPI) {
|
|
133
|
+
registerFacadeTools(pi);
|
|
134
|
+
|
|
135
|
+
// ── Low-level graph-store tools ────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
pi.registerTool({
|
|
138
|
+
name: "papyrus_create",
|
|
139
|
+
label: "Papyrus Create",
|
|
140
|
+
description:
|
|
141
|
+
"Create a graph artifact. KINDS: doc (knowledge — specs, decisions, research), " +
|
|
142
|
+
"task (work — with gates/checklists in extra), rule (governance — when doing X, follow Y; " +
|
|
143
|
+
"active rules inject into the system prompt), skill (procedural — when using X do A,B,C). " +
|
|
144
|
+
"RULE extra: {condition, action, severity: 'block'|'warn'|'info'}. " +
|
|
145
|
+
"TASK extra: {gates: [{type:'file-exists'|'contains'|'command'|'test', target, expect}], checklist: {'criterion': {proof: [{type:'file'|'symbol'|'code'|'test'|'command'|'artifact'|'url', target, expect}]}}}. " +
|
|
146
|
+
"SKILL extra: {trigger, steps: [...], tools: [...]}. " +
|
|
147
|
+
"Templates are skills with subtype='artifact-template' and extra {targetKind, defaults, required}; pass template_id to instantiate.",
|
|
148
|
+
parameters: Type.Object({
|
|
149
|
+
kind: Type.Optional(Type.String({ description: "doc | task | rule | skill; optional when template_id supplies targetKind" })),
|
|
150
|
+
title: Type.Optional(Type.String({ description: "required unless supplied by template defaults" })),
|
|
151
|
+
status: Type.Optional(Type.String({ description: "default: first registered for kind" })),
|
|
152
|
+
subtype: Type.Optional(Type.String()),
|
|
153
|
+
body: Type.Optional(Type.String()),
|
|
154
|
+
labels: Type.Optional(Type.Array(Type.String())),
|
|
155
|
+
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
156
|
+
template_id: Type.Optional(Type.String({ description: "skill/artifact-template id whose defaults and requirements apply" })),
|
|
157
|
+
}),
|
|
158
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
159
|
+
try {
|
|
160
|
+
const a = await callService<Record<string, unknown>, Artifact>("artifact.create", params);
|
|
161
|
+
return text(`Created ${a.id} [${a.kind}|${a.status}] ${a.title}`, { id: a.id });
|
|
162
|
+
} catch (e) {
|
|
163
|
+
return text(`papyrus_create failed: ${e instanceof Error ? e.message : e}`);
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
pi.registerTool({
|
|
169
|
+
name: "papyrus_query",
|
|
170
|
+
label: "Papyrus Query",
|
|
171
|
+
description: "Query artifacts by kind, status, or full-text search.",
|
|
172
|
+
parameters: Type.Object({
|
|
173
|
+
kind: Type.Optional(Type.String()),
|
|
174
|
+
status: Type.Optional(Type.String()),
|
|
175
|
+
text: Type.Optional(Type.String({ description: "substring across title and body" })),
|
|
176
|
+
limit: Type.Optional(Type.Number()),
|
|
177
|
+
}),
|
|
178
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
179
|
+
try {
|
|
180
|
+
const rows = await callService<Record<string, unknown>, Artifact[]>("artifact.query", { ...params, limit: params.limit ?? 50 });
|
|
181
|
+
if (rows.length === 0) return text("No artifacts found.");
|
|
182
|
+
const lines = rows.map((r: any, i: number) => `${i + 1}. ${r.id} [${r.kind}|${r.status}] ${r.title}`);
|
|
183
|
+
return text(`${rows.length} artifact(s):\n\n${lines.join("\n")}`, { rows });
|
|
184
|
+
} catch (e) {
|
|
185
|
+
return text(`papyrus_query failed: ${e instanceof Error ? e.message : e}`);
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
pi.registerTool({
|
|
191
|
+
name: "papyrus_graph",
|
|
192
|
+
label: "Papyrus Graph",
|
|
193
|
+
description:
|
|
194
|
+
"Link artifacts with typed edges (any kind → any kind), view subgraph, or update status. " +
|
|
195
|
+
"RELATIONS: references, implements, follows, depends_on, documents, blocks, supersedes, relates_to, gates, triggers, contains, part_of. " +
|
|
196
|
+
"ACTIONS: link (from+relation+to), tree (id → bounded BFS subgraph), status (id+status → lifecycle).",
|
|
197
|
+
parameters: Type.Object({
|
|
198
|
+
action: Type.String({ description: "link | tree | status" }),
|
|
199
|
+
from: Type.Optional(Type.String()),
|
|
200
|
+
relation: Type.Optional(Type.String()),
|
|
201
|
+
to: Type.Optional(Type.String()),
|
|
202
|
+
id: Type.Optional(Type.String()),
|
|
203
|
+
status: Type.Optional(Type.String()),
|
|
204
|
+
depth: Type.Optional(Type.Number({ description: "tree traversal depth; bounded by a hard ceiling" })),
|
|
205
|
+
max_nodes: Type.Optional(Type.Number({ description: "tree node cap; bounded by a hard ceiling" })),
|
|
206
|
+
}),
|
|
207
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
208
|
+
try {
|
|
209
|
+
if (params.action === "link") {
|
|
210
|
+
await callService("graph.link", { from: params.from!, relation: params.relation!, to: params.to! });
|
|
211
|
+
return text(`Linked ${params.from} --${params.relation}--> ${params.to}`);
|
|
212
|
+
}
|
|
213
|
+
if (params.action === "tree") {
|
|
214
|
+
const root = params.id ?? params.from;
|
|
215
|
+
if (!root) return text("Missing id for tree");
|
|
216
|
+
const a = await callService<Record<string, unknown>, Artifact | null>("graph.tree", {
|
|
217
|
+
id: root,
|
|
218
|
+
depth: params.depth,
|
|
219
|
+
max_nodes: params.max_nodes,
|
|
220
|
+
});
|
|
221
|
+
if (!a) return text(`Artifact ${root} not found`);
|
|
222
|
+
const edges = (a as any).edges ?? [];
|
|
223
|
+
if (edges.length === 0) return text(`${a.title} — no edges`);
|
|
224
|
+
return text(
|
|
225
|
+
`Subgraph from ${a.title} (${edges.length} edges):\n\n${edges.map((e: any) => ` ${e.from} --${e.relation}--> ${e.to}`).join("\n")}`,
|
|
226
|
+
{ edges },
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (params.action === "status") {
|
|
230
|
+
const a = await callService<Record<string, unknown>, Artifact | null>("graph.status", { id: params.id!, status: params.status! });
|
|
231
|
+
if (!a) return text(`Artifact ${params.id} not found`);
|
|
232
|
+
return text(`Updated ${a.id} → [${a.status}]`, { artifact: a });
|
|
233
|
+
}
|
|
234
|
+
return text(`Unknown action: ${params.action}. Use 'link', 'tree', or 'status'.`);
|
|
235
|
+
} catch (e) {
|
|
236
|
+
return text(`papyrus_graph failed: ${e instanceof Error ? e.message : e}`);
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
pi.registerTool({
|
|
242
|
+
name: "papyrus_show",
|
|
243
|
+
label: "Papyrus Show",
|
|
244
|
+
description: "Show one artifact with body, edges, and optionally run its gates.",
|
|
245
|
+
parameters: Type.Object({
|
|
246
|
+
id: Type.String(),
|
|
247
|
+
run_gates: Type.Optional(Type.Boolean()),
|
|
248
|
+
depth: Type.Optional(Type.Number({ description: "edge traversal depth" })),
|
|
249
|
+
max_nodes: Type.Optional(Type.Number({ description: "maximum traversed nodes" })),
|
|
250
|
+
}),
|
|
251
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
252
|
+
try {
|
|
253
|
+
const a = await callService<Record<string, unknown>, Artifact | null>("artifact.show", {
|
|
254
|
+
id: params.id,
|
|
255
|
+
tree: true,
|
|
256
|
+
depth: params.depth,
|
|
257
|
+
max_nodes: params.max_nodes,
|
|
258
|
+
});
|
|
259
|
+
if (!a) return text(`Artifact ${params.id} not found`);
|
|
260
|
+
let out = `${a.id} [${a.kind}|${a.status}]\n${a.title}\n\n${a.body}`;
|
|
261
|
+
if (Object.keys(a.extra).length > 0) {
|
|
262
|
+
out += `\n\nMetadata:\n${formatMetadata(a.extra).map((line) => ` ${line}`).join("\n")}`;
|
|
263
|
+
}
|
|
264
|
+
if ((a as any).edges?.length) {
|
|
265
|
+
out += `\n\nEdges:\n${(a as any).edges.map((e: any) => ` ${e.from} --${e.relation}--> ${e.to}`).join("\n")}`;
|
|
266
|
+
}
|
|
267
|
+
if (params.run_gates) {
|
|
268
|
+
const results = await callService<Record<string, unknown>, GateResult[]>("gates.run", { id: params.id });
|
|
269
|
+
out += `\n\nGates:\n${results.map((g: any) => ` ${g.passed ? "✓" : "✗"} ${g.gate.type}: ${g.gate.target} — ${g.output}`).join("\n")}`;
|
|
270
|
+
}
|
|
271
|
+
return text(out, { artifact: a });
|
|
272
|
+
} catch (e) {
|
|
273
|
+
return text(`papyrus_show failed: ${e instanceof Error ? e.message : e}`);
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// ── Interactive artifact browsers ──────────────────────────────────
|
|
279
|
+
|
|
280
|
+
// Lazy imports keep TUI components out of non-interactive startup paths.
|
|
281
|
+
const [tasksModule, docsModule, rulesModule, skillsModule] = await Promise.all([
|
|
282
|
+
import("./tasks.ts"),
|
|
283
|
+
import("./docs.ts"),
|
|
284
|
+
import("./rules.ts"),
|
|
285
|
+
import("./skills.ts"),
|
|
286
|
+
]);
|
|
287
|
+
let overlay: TaskOverlay | undefined;
|
|
288
|
+
|
|
289
|
+
pi.registerCommand("tasks", {
|
|
290
|
+
description: "Browse and manage Papyrus tasks (interactive)",
|
|
291
|
+
handler: async (_args, ctx) => {
|
|
292
|
+
await tasksModule.showTasks(ctx);
|
|
293
|
+
await overlay?.refresh();
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
pi.registerCommand("docs", {
|
|
297
|
+
description: "Browse and manage Papyrus documents (interactive)",
|
|
298
|
+
handler: async (_args, ctx) => { await docsModule.showDocs(ctx); },
|
|
299
|
+
});
|
|
300
|
+
pi.registerCommand("rules", {
|
|
301
|
+
description: "Browse, preview, and toggle Papyrus rules (interactive)",
|
|
302
|
+
handler: async (_args, ctx) => { await rulesModule.showRules(ctx); },
|
|
303
|
+
});
|
|
304
|
+
pi.registerCommand("skills", {
|
|
305
|
+
description: "Browse and invoke Papyrus skills and templates (interactive)",
|
|
306
|
+
handler: async (_args, ctx) => { await skillsModule.showSkills(ctx); },
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// ── Task widget (TodoOverlay pattern: factory form, requestRender) ──
|
|
310
|
+
|
|
311
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
312
|
+
if (!ctx.hasUI) return;
|
|
313
|
+
overlay ??= new TaskOverlay();
|
|
314
|
+
overlay.setUI(ctx.ui);
|
|
315
|
+
await overlay.refresh();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
pi.on("session_compact", async () => { await overlay?.refresh(); });
|
|
319
|
+
pi.on("session_tree", async () => { await overlay?.refresh(); });
|
|
320
|
+
pi.on("session_shutdown", async () => { overlay?.dispose(); overlay = undefined; });
|
|
321
|
+
|
|
322
|
+
// Update widget after any papyrus tool call
|
|
323
|
+
pi.on("tool_execution_end", async (event) => {
|
|
324
|
+
if (event.toolName.startsWith("papyrus_") || event.toolName === "tasks") {
|
|
325
|
+
await overlay?.refresh();
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ── "Are we there yet?" — inject active tasks into every turn ──────
|
|
330
|
+
// The agent sees its open work items every turn. If there are failed
|
|
331
|
+
// tasks, they're explicitly called out — the agent should address them.
|
|
332
|
+
|
|
333
|
+
pi.on("before_agent_start", async (event, _ctx) => {
|
|
334
|
+
try {
|
|
335
|
+
const [rules, summary] = await Promise.all([
|
|
336
|
+
callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", {}),
|
|
337
|
+
callService<Record<string, unknown>, string | null>("tasks.context", {}),
|
|
338
|
+
]);
|
|
339
|
+
let prompt = event.systemPrompt ?? "";
|
|
340
|
+
if (rules.length > 0) {
|
|
341
|
+
const block = rules.map(rulesModule.ruleInjectionPreview).join("\n");
|
|
342
|
+
prompt += `\n\n## Active rules (Papyrus)\n\n${block}\n`;
|
|
343
|
+
}
|
|
344
|
+
if (summary) {
|
|
345
|
+
prompt += `\n\n## Open tasks (Papyrus)\n\n${summary}\n`;
|
|
346
|
+
}
|
|
347
|
+
if (prompt !== (event.systemPrompt ?? "")) {
|
|
348
|
+
return { systemPrompt: prompt };
|
|
349
|
+
}
|
|
350
|
+
} catch {
|
|
351
|
+
// DB not ready
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
3
|
+
import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
|
|
4
|
+
import { callService } from "./service-client.ts";
|
|
5
|
+
|
|
6
|
+
const RULE_GLYPHS: Record<string, string> = { active: "●", deprecated: "○" };
|
|
7
|
+
|
|
8
|
+
export function ruleRowMeta(rule: Artifact): string {
|
|
9
|
+
const severity = typeof rule.extra["severity"] === "string" ? rule.extra["severity"].toUpperCase() : "INFO";
|
|
10
|
+
const condition = typeof rule.extra["condition"] === "string" ? `when ${rule.extra["condition"]}` : "always";
|
|
11
|
+
return `${severity} · ${condition}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ruleInjectionPreview(rule: Pick<Artifact, "title" | "body" | "extra">): string {
|
|
15
|
+
const condition = typeof rule.extra["condition"] === "string" ? ` (when: ${rule.extra["condition"]})` : "";
|
|
16
|
+
const action = rule.body || (typeof rule.extra["action"] === "string" ? rule.extra["action"] : "");
|
|
17
|
+
return `• ${rule.title}${condition}\n ${action}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function showRules(ctx: ExtensionCommandContext): Promise<void> {
|
|
21
|
+
await showArtifactBrowser(ctx, {
|
|
22
|
+
kind: "rule",
|
|
23
|
+
title: "Rules",
|
|
24
|
+
listOperation: "rules.list",
|
|
25
|
+
statusOrder: ["active", "deprecated"],
|
|
26
|
+
glyphs: RULE_GLYPHS,
|
|
27
|
+
rowMeta: ruleRowMeta,
|
|
28
|
+
actions: (rule) => ["Show details", "Preview injection", "Link gated task", rule.status === "active" ? "Disable" : "Enable"],
|
|
29
|
+
handleAction: async (choice, rule, commandCtx) => {
|
|
30
|
+
if (choice === "Show details") await showArtifactDetails(commandCtx, rule.id, "rules.show");
|
|
31
|
+
else if (choice === "Preview injection") {
|
|
32
|
+
const preview = await callService<Record<string, unknown>, string>("rules.preview", { id: rule.id });
|
|
33
|
+
commandCtx.ui.notify(preview, "info");
|
|
34
|
+
} else if (choice === "Link gated task") {
|
|
35
|
+
const taskId = await commandCtx.ui.input("Task artifact id:", "");
|
|
36
|
+
if (taskId) await callService("rules.gate", { id: rule.id, task_id: taskId });
|
|
37
|
+
} else {
|
|
38
|
+
const operation = choice === "Disable" ? "rules.disable" : "rules.enable";
|
|
39
|
+
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: rule.id });
|
|
40
|
+
commandCtx.ui.notify(`${updated.id} → [${updated.status}]`, "info");
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { connectPapyrusClient, type PapyrusClient } from "../../src/client.ts";
|
|
2
|
+
import type { OperationName } from "../../src/service.ts";
|
|
3
|
+
|
|
4
|
+
type ClientConnector = () => Promise<PapyrusClient>;
|
|
5
|
+
|
|
6
|
+
let connector: ClientConnector = () => connectPapyrusClient();
|
|
7
|
+
let cached: PapyrusClient | undefined;
|
|
8
|
+
|
|
9
|
+
export async function papyrusClient(): Promise<PapyrusClient> {
|
|
10
|
+
if (cached) return cached;
|
|
11
|
+
cached = await connector();
|
|
12
|
+
return cached;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function staleConnection(error: unknown): boolean {
|
|
16
|
+
if (error instanceof TypeError) return true;
|
|
17
|
+
if (!(error instanceof Error)) return false;
|
|
18
|
+
if (error.name === "AbortError" || error.name === "TimeoutError") return true;
|
|
19
|
+
return /fetch failed|network|socket|ECONNRESET|ECONNREFUSED|connection refused/i.test(error.message);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function callService<Input extends Record<string, unknown>, Output>(
|
|
23
|
+
operation: OperationName,
|
|
24
|
+
input: Input,
|
|
25
|
+
): Promise<Output> {
|
|
26
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
27
|
+
try {
|
|
28
|
+
return await (await papyrusClient()).call<Input, Output>(operation, input);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
cached = undefined;
|
|
31
|
+
if (attempt === 1 || !staleConnection(error)) throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
throw new Error("Papyrus daemon client retry exhausted");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function setPapyrusClientConnectorForTests(value: ClientConnector): void {
|
|
38
|
+
cached = undefined;
|
|
39
|
+
connector = value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resetPapyrusClientForTests(): void {
|
|
43
|
+
cached = undefined;
|
|
44
|
+
connector = () => connectPapyrusClient();
|
|
45
|
+
}
|