@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.
Files changed (39) hide show
  1. package/README.md +139 -0
  2. package/extension/src/artifact-browser.ts +213 -0
  3. package/extension/src/artifact-format.ts +82 -0
  4. package/extension/src/beautiful-mermaid-renderer.ts +45 -0
  5. package/extension/src/docs.ts +48 -0
  6. package/extension/src/facade-tools.ts +209 -0
  7. package/extension/src/index.ts +354 -0
  8. package/extension/src/rules.ts +44 -0
  9. package/extension/src/service-client.ts +45 -0
  10. package/extension/src/skills.ts +60 -0
  11. package/extension/src/task-context.ts +1 -0
  12. package/extension/src/task-detail-format.ts +66 -0
  13. package/extension/src/task-detail-view.ts +111 -0
  14. package/extension/src/task-graph.ts +97 -0
  15. package/extension/src/task-widget.ts +49 -0
  16. package/extension/src/tasks.ts +258 -0
  17. package/package.json +43 -0
  18. package/src/adapters/sqlite-artifact-store.ts +64 -0
  19. package/src/adapters/sqlite-gate-runner.ts +16 -0
  20. package/src/cli.ts +71 -0
  21. package/src/client.ts +59 -0
  22. package/src/constants.ts +113 -0
  23. package/src/daemon-state.ts +59 -0
  24. package/src/daemon.ts +41 -0
  25. package/src/db.ts +138 -0
  26. package/src/domain/artifact.ts +56 -0
  27. package/src/domain/checklist.ts +70 -0
  28. package/src/domain/display-graph.ts +23 -0
  29. package/src/domain/gate.ts +11 -0
  30. package/src/facades.ts +215 -0
  31. package/src/ops.ts +336 -0
  32. package/src/ports/artifact-store.ts +19 -0
  33. package/src/ports/gate-runner.ts +6 -0
  34. package/src/ports/graph-renderer.ts +5 -0
  35. package/src/service.ts +292 -0
  36. package/src/task-context.ts +52 -0
  37. package/src/task-graph-view.ts +34 -0
  38. package/src/task-relationship-view.ts +39 -0
  39. package/src/task-service.ts +176 -0
package/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # Papyrus
2
+
3
+ Graph artifact service for Pi — enforced SQLite schema, domain facades, and native interactive frontends.
4
+
5
+ Artifacts are rows in SQLite. Edges are typed relations. Kinds and relations are **registered and enforced**—the schema is the protocol. A supervised Bun daemon is the sole database owner; Pi extensions and other clients use its authenticated loopback service API.
6
+
7
+ ## Architecture
8
+
9
+ ```text
10
+ Pi tools + TUI
11
+
12
+ tasks / docs / rules / skills facades
13
+
14
+ Papyrus client → authenticated loopback daemon
15
+
16
+ operation registry + lifecycle services
17
+
18
+ graph-store operations → SQLite (WAL)
19
+ ```
20
+
21
+ The `papyrus_*` tools remain low-level administration escape hatches. Normal agent work should use the domain facade tools.
22
+
23
+ ## Storage and service
24
+
25
+ ```text
26
+ $XDG_DATA_HOME/papyrus/papyrus.db # durable graph
27
+ $XDG_RUNTIME_DIR/papyrus/{port,token} # private daemon discovery
28
+ ```
29
+
30
+ ```bash
31
+ bun src/cli.ts service install # install, enable, and start user service
32
+ bun src/cli.ts service status
33
+ bun src/cli.ts service restart
34
+ ```
35
+
36
+ For repository work, install the versioned ownership guard once:
37
+
38
+ ```bash
39
+ bun run guard:install
40
+ ```
41
+
42
+ It blocks every Papyrus push whose destination is not `DanyPops/papyrus`, including explicit fallback URLs that bypass `origin`.
43
+
44
+ The daemon uses WAL, foreign keys, a bounded busy timeout, versioned migrations, periodic passive checkpoints, and periodic `PRAGMA optimize`. Keep the database on a local filesystem; SQLite WAL does not support network filesystems.
45
+
46
+ ## Schema protocol (enforceable)
47
+
48
+ Papyrus enforces four artifact kinds:
49
+
50
+ - `doc` — knowledge: specifications, decisions, and research
51
+ - `task` — work: desired outcomes, gates, checklists, and dependencies
52
+ - `rule` — governance injected into the Pi system prompt
53
+ - `skill` — reusable procedural knowledge
54
+
55
+ Each kind has an enforced status vocabulary. Every edge endpoint must exist, and every edge relation must be registered in `relation_names`. Relations are universal: any artifact kind can link to any other kind.
56
+
57
+ ### Hierarchy and traversal
58
+
59
+ Use `contains` and `part_of` for explicit parent/child structure; use `depends_on` for execution ordering. Graph reads are cycle-safe and bounded by `depth` and `max_nodes` (defaults: depth 4, 100 nodes; hard ceilings: depth 20, 1,000 nodes).
60
+
61
+ ### Artifact templates
62
+
63
+ Templates remain inside the four-kind model: create a `skill` with subtype `artifact-template` and metadata `{targetKind, defaults, required}`. Instantiate it through `papyrus_create` with `template_id`; defaults merge recursively, explicit arrays replace defaults, required paths such as `extra.owner` are validated, and target-kind mismatches are rejected.
64
+
65
+ ## Tools
66
+
67
+ The `papyrus_*` tools are the low-level graph-store API:
68
+
69
+ - **`papyrus_create`** — create directly or instantiate via `template_id`
70
+ - **`papyrus_query`** — filter by kind/status or search title and body
71
+ - **`papyrus_graph`** — link artifacts, perform bounded traversal, or update status
72
+ - **`papyrus_show`** — read nested metadata and bounded edges, optionally running gates
73
+
74
+ Agent-facing facade tools own domain lifecycle invariants and sit above this store API:
75
+
76
+ - **`tasks`** — create/list/show, replace evidence-bearing checklists, hierarchy/dependencies, start/fail/retry, non-blocking gates, and gate-enforced completion
77
+ - **`docs`** — create/list/show, activate/archive/reopen, and document-safe graph links
78
+ - **`rules`** — create/list/show/preview, enable/disable, and attach governance gates to tasks
79
+ - **`skills`** — create/list/show/invoke, enable/disable, create templates, and instantiate templates
80
+
81
+ Every tool operation is registered in the daemon’s `/api/v1/ops` registry; parity is verified in tests. The task consumer uses the `tasks.graph` operation, which returns task nodes with explicit parent, child, and dependency IDs rather than leaking SQLite rows or asking the UI to reconstruct relationships.
82
+
83
+ Internally, application services depend on the `ArtifactStore` and `GateRunner` ports. SQLite and subprocess execution are adapters composed only by the daemon; task behavior is unit-tested against fakes without a database. Task visualization projects the same `TaskGraph` into semantic display graphs and sends them through a `GraphRenderer` port; the Pi adapter uses `beautiful-mermaid` for terminal Unicode output without leaking Mermaid syntax into the task domain.
84
+
85
+ ## Interactive frontends
86
+
87
+ - `/tasks` — task lifecycle, gates, dependencies, and nested metadata
88
+ - `/docs` — searchable documents, lifecycle, details, and graph links
89
+ - `/rules` — severity/condition rows, exact injection preview, enable/disable, and task gating
90
+ - `/skills` — trigger/tools rows, invocation into the editor, and artifact templates
91
+
92
+ All four use daemon-backed domain operations; none opens SQLite from the Pi process.
93
+
94
+ ## Tasks
95
+
96
+ Run `/tasks` for the interactive task panel:
97
+
98
+ - `/` filters; arrow keys navigate; Enter opens task actions
99
+ - `g` opens the programmatic Unicode graph; Tab switches dependency/composition views and arrow keys pan
100
+ - advance the `pending → active → done` lifecycle or retry `failed → pending`
101
+ - inspect a nested task hierarchy, composition, dependencies, evidence-bearing checklists, and verification gates
102
+ - Show details keeps Checklist and Validation gates separate from incidental Metadata, then renders relationships as a Unicode graph footer; `↑/↓` scrolls and `←/→` pans wide graphs
103
+ - the compact persistent widget shows active work in containment order, indents active children beneath active parents, and points to `/tasks` for the complete graph
104
+
105
+ Checklist criteria are an item-to-proof map. Every new item requires one or more typed references to inspectable evidence; proof presence does not imply that the evidence passed an executable gate:
106
+
107
+ ```ts
108
+ checklist: {
109
+ "Write failing skill-row tests": {
110
+ proof: [
111
+ { type: "file", target: "test/frontends.test.ts" },
112
+ { type: "symbol", target: "test/frontends.test.ts#skill row test" }
113
+ ]
114
+ }
115
+ }
116
+ ```
117
+
118
+ Proof types are `file`, `symbol`, `code`, `test`, `command`, `artifact`, and `url`. Existing array checklists remain readable as legacy items with `proof: missing`; Papyrus does not invent evidence.
119
+
120
+ Papyrus also injects an Alef-style reconciliation block on every agent turn while work remains: `Current`, `Desired`, `Verify`, and `Next`. The agent is explicitly instructed to ask **“Did we accomplish this task?”** and run gates before marking it done. The injection disappears when every task is complete.
121
+
122
+ ## Why
123
+
124
+ Papyrus keeps SQLite’s local simplicity while centralizing writes, migrations, lifecycle invariants, gate execution, and maintenance in one small supervised process. The loopback bearer token prevents unrelated local HTTP callers from mutating the graph, while the native Pi extension provides richer domain tools and TUI integration.
125
+
126
+ ## Install
127
+
128
+ Install the published Pi package, then install its supervised user service:
129
+
130
+ ```bash
131
+ pi install npm:@danypops/papyrus
132
+ ~/.pi/agent/npm/node_modules/.bin/papyrus service install
133
+ ```
134
+
135
+ Reload Pi once the service is active. Git installs remain available for development builds:
136
+
137
+ ```bash
138
+ pi install git:github.com/DanyPops/papyrus
139
+ ```
@@ -0,0 +1,213 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
3
+ import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+ import { SEED_RELATIONS } from "../../src/constants.ts";
5
+ import type { Artifact } from "../../src/domain/artifact.ts";
6
+ import type { OperationName } from "../../src/service.ts";
7
+ import { formatMetadata } from "./artifact-format.ts";
8
+ import { callService } from "./service-client.ts";
9
+
10
+ const BROWSER_QUERY_LIMIT = 500;
11
+ const BROWSER_VISIBLE_ROWS = 20;
12
+ const DETAIL_GRAPH_DEPTH = 4;
13
+ const DETAIL_GRAPH_NODES = 100;
14
+
15
+ export interface ArtifactBrowserConfig {
16
+ kind: string;
17
+ title: string;
18
+ statusOrder: string[];
19
+ glyphs: Record<string, string>;
20
+ listOperation?: OperationName;
21
+ rowMeta(row: Artifact): string;
22
+ actions(row: Artifact): string[];
23
+ handleAction(choice: string, row: Artifact, ctx: ExtensionCommandContext): Promise<void>;
24
+ }
25
+
26
+ export function filterArtifactRows(rows: Artifact[], query: string): Artifact[] {
27
+ const needle = query.trim().toLowerCase();
28
+ if (!needle) return [...rows];
29
+ return rows.filter((row) => [
30
+ row.id,
31
+ row.title,
32
+ row.body,
33
+ row.subtype,
34
+ row.labels.join(" "),
35
+ JSON.stringify(row.extra),
36
+ ].some((value) => value.toLowerCase().includes(needle)));
37
+ }
38
+
39
+ export function statusSummary(rows: Artifact[], order: string[]): Array<{ status: string; count: number }> {
40
+ const counts = new Map<string, number>();
41
+ for (const row of rows) counts.set(row.status, (counts.get(row.status) ?? 0) + 1);
42
+ return order.filter((status) => counts.has(status)).map((status) => ({ status, count: counts.get(status)! }));
43
+ }
44
+
45
+ async function loadArtifacts(config: ArtifactBrowserConfig): Promise<Artifact[]> {
46
+ return callService<Record<string, unknown>, Artifact[]>(config.listOperation ?? "artifact.query", {
47
+ kind: config.kind,
48
+ limit: BROWSER_QUERY_LIMIT,
49
+ });
50
+ }
51
+
52
+ export async function showArtifactDetails(
53
+ ctx: ExtensionCommandContext,
54
+ id: string,
55
+ operation: OperationName = "artifact.show",
56
+ ): Promise<void> {
57
+ const artifact = await callService<Record<string, unknown>, Artifact | null>(operation, {
58
+ id,
59
+ tree: true,
60
+ depth: DETAIL_GRAPH_DEPTH,
61
+ max_nodes: DETAIL_GRAPH_NODES,
62
+ });
63
+ if (!artifact) { ctx.ui.notify(`Artifact ${id} not found`, "error"); return; }
64
+ let output = `${artifact.title}\n${artifact.id} [${artifact.kind}|${artifact.status}]`;
65
+ if (artifact.subtype) output += ` · ${artifact.subtype}`;
66
+ if (artifact.body) output += `\n\n${artifact.body}`;
67
+ if (artifact.labels.length > 0) output += `\n\nLabels: ${artifact.labels.join(", ")}`;
68
+ if (Object.keys(artifact.extra).length > 0) {
69
+ output += `\n\nMetadata:\n${formatMetadata(artifact.extra).map((line) => ` ${line}`).join("\n")}`;
70
+ }
71
+ if (artifact.edges?.length) {
72
+ output += `\n\nEdges:\n${artifact.edges.map((edge) => ` ${edge.from} --${edge.relation}--> ${edge.to}`).join("\n")}`;
73
+ }
74
+ ctx.ui.notify(output, "info");
75
+ }
76
+
77
+ export async function linkFromArtifact(ctx: ExtensionCommandContext, fromId: string, fixedRelation?: string): Promise<void> {
78
+ const target = await ctx.ui.input("Target artifact id:", "");
79
+ if (!target) return;
80
+ const relation = fixedRelation ?? await ctx.ui.select("Relation", [...SEED_RELATIONS]);
81
+ if (!relation) return;
82
+ try {
83
+ await callService("graph.link", { from: fromId, relation, to: target });
84
+ ctx.ui.notify(`Linked ${fromId} --${relation}--> ${target}`, "info");
85
+ } catch (error) {
86
+ ctx.ui.notify(`Link failed: ${error instanceof Error ? error.message : error}`, "error");
87
+ }
88
+ }
89
+
90
+ export async function setArtifactStatus(ctx: ExtensionCommandContext, id: string, status: string): Promise<void> {
91
+ try {
92
+ const artifact = await callService<Record<string, unknown>, Artifact | null>("graph.status", { id, status });
93
+ if (!artifact) { ctx.ui.notify(`Artifact ${id} not found`, "error"); return; }
94
+ ctx.ui.notify(`${artifact.id} → [${artifact.status}]`, "info");
95
+ } catch (error) {
96
+ ctx.ui.notify(`Status change failed: ${error instanceof Error ? error.message : error}`, "error");
97
+ }
98
+ }
99
+
100
+ export async function showArtifactBrowser(ctx: ExtensionCommandContext, config: ArtifactBrowserConfig): Promise<void> {
101
+ if (!ctx.hasUI) {
102
+ ctx.ui.notify(`/${config.kind}s requires interactive mode`, "warning");
103
+ return;
104
+ }
105
+ let rows = await loadArtifacts(config);
106
+ if (rows.length === 0) {
107
+ ctx.ui.notify(`No ${config.kind} artifacts yet. Ask the agent to create one.`, "info");
108
+ return;
109
+ }
110
+
111
+ for (;;) {
112
+ const selected = await renderPanel(ctx, rows, config);
113
+ if (selected === undefined) return;
114
+ if (selected === "refresh") { rows = await loadArtifacts(config); continue; }
115
+ const choices = config.actions(selected);
116
+ const choice = await ctx.ui.select(selected.title, choices);
117
+ if (!choice) continue;
118
+ await config.handleAction(choice, selected, ctx);
119
+ rows = await loadArtifacts(config);
120
+ }
121
+ }
122
+
123
+ function renderPanel(
124
+ ctx: ExtensionCommandContext,
125
+ rows: Artifact[],
126
+ config: ArtifactBrowserConfig,
127
+ ): Promise<Artifact | "refresh" | undefined> {
128
+ return ctx.ui.custom<Artifact | "refresh" | undefined>((tui, theme, _keybindings, done) => {
129
+ const input = new Input();
130
+ let searchActive = false;
131
+ let filtered = [...rows];
132
+ let selectedIndex = 0;
133
+
134
+ function applyFilter(): void {
135
+ filtered = filterArtifactRows(rows, input.getValue());
136
+ selectedIndex = 0;
137
+ }
138
+
139
+ const header = {
140
+ invalidate() {},
141
+ render(width: number): string[] {
142
+ const title = theme.bold(config.title);
143
+ const hint = searchActive
144
+ ? rawKeyHint("esc", "clear")
145
+ : [rawKeyHint("enter", "actions"), rawKeyHint("/", "filter"), rawKeyHint("r", "refresh"), rawKeyHint("esc", "close")]
146
+ .join(theme.fg("muted", " · "));
147
+ const spacing = Math.max(1, width - visibleWidth(title) - visibleWidth(hint));
148
+ const summary = statusSummary(rows, config.statusOrder)
149
+ .map(({ status, count }) => `${config.glyphs[status] ?? status} ${count} ${status}`)
150
+ .join(", ");
151
+ return [
152
+ truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, ""),
153
+ truncateToWidth(theme.fg("muted", summary), width, ""),
154
+ ];
155
+ },
156
+ };
157
+
158
+ const list = {
159
+ invalidate() {},
160
+ render(width: number): string[] {
161
+ const lines = searchActive ? [...input.render(width), ""] : [""];
162
+ if (filtered.length === 0) return [...lines, theme.fg("muted", ` No matching ${config.kind}s`)];
163
+ const start = Math.max(0, Math.min(selectedIndex - Math.floor(BROWSER_VISIBLE_ROWS / 2), filtered.length - BROWSER_VISIBLE_ROWS));
164
+ const end = Math.min(start + BROWSER_VISIBLE_ROWS, filtered.length);
165
+ for (let index = start; index < end; index++) {
166
+ const row = filtered[index]!;
167
+ const selected = index === selectedIndex;
168
+ const cursor = selected ? theme.fg("accent", "❯") : " ";
169
+ const glyph = config.glyphs[row.status] ?? "?";
170
+ const title = selected ? theme.bold(row.title) : row.title;
171
+ const meta = config.rowMeta(row);
172
+ lines.push(truncateToWidth(`${cursor} ${glyph} ${title}${meta ? theme.fg("dim", ` · ${meta}`) : ""}`, width, ""));
173
+ }
174
+ lines.push(theme.fg("muted", ` ${selectedIndex + 1}/${filtered.length} ${config.kind}`));
175
+ return lines;
176
+ },
177
+ };
178
+
179
+ const container = new Container();
180
+ container.addChild(new Spacer(1));
181
+ container.addChild(new DynamicBorder());
182
+ container.addChild(new Spacer(1));
183
+ container.addChild(header);
184
+ container.addChild(new Spacer(1));
185
+ container.addChild(list);
186
+ container.addChild(new Spacer(1));
187
+ container.addChild(new DynamicBorder());
188
+
189
+ return {
190
+ render: (width: number) => container.render(width),
191
+ invalidate: () => container.invalidate(),
192
+ handleInput(data: string) {
193
+ if (searchActive) {
194
+ if (data === "\x1b") { searchActive = false; applyFilter(); }
195
+ else if (data === "\r") searchActive = false;
196
+ else { input.handleInput(data); applyFilter(); }
197
+ tui.requestRender();
198
+ return;
199
+ }
200
+ switch (data) {
201
+ case "\x1b[A": selectedIndex = (selectedIndex - 1 + filtered.length) % Math.max(filtered.length, 1); break;
202
+ case "\x1b[B": selectedIndex = (selectedIndex + 1) % Math.max(filtered.length, 1); break;
203
+ case "/": searchActive = true; break;
204
+ case "r": done("refresh"); return;
205
+ case "\r": { const row = filtered[selectedIndex]; if (row) done(row); return; }
206
+ case "\x1b": done(undefined); return;
207
+ default: return;
208
+ }
209
+ tui.requestRender();
210
+ },
211
+ };
212
+ });
213
+ }
@@ -0,0 +1,82 @@
1
+ import {
2
+ DEFAULT_METADATA_DEPTH,
3
+ DEFAULT_METADATA_ITEMS,
4
+ MAX_METADATA_DEPTH,
5
+ MAX_METADATA_ITEMS,
6
+ } from "../../src/constants.ts";
7
+
8
+ const STATUS_GLYPHS: Record<string, string> = {
9
+ pending: "○",
10
+ active: "●",
11
+ done: "■",
12
+ failed: "▲",
13
+ };
14
+
15
+ export interface MetadataFormatOptions {
16
+ maxDepth?: number;
17
+ maxItems?: number;
18
+ }
19
+
20
+ function isRecord(value: unknown): value is Record<string, unknown> {
21
+ return typeof value === "object" && value !== null && !Array.isArray(value);
22
+ }
23
+
24
+ function scalar(value: unknown): string {
25
+ if (typeof value === "string") return value;
26
+ if (value === null) return "null";
27
+ if (value === undefined) return "undefined";
28
+ return JSON.stringify(value);
29
+ }
30
+
31
+ /** Render arbitrary nested artifact metadata into bounded, human-readable lines. */
32
+ export function formatMetadata(value: unknown, options: MetadataFormatOptions = {}): string[] {
33
+ const maxDepth = Math.min(MAX_METADATA_DEPTH, Math.max(0, Math.floor(options.maxDepth ?? DEFAULT_METADATA_DEPTH)));
34
+ const maxItems = Math.min(MAX_METADATA_ITEMS, Math.max(1, Math.floor(options.maxItems ?? DEFAULT_METADATA_ITEMS)));
35
+ let renderedItems = 0;
36
+
37
+ function render(current: unknown, indent: number, depth: number): string[] {
38
+ const pad = " ".repeat(indent);
39
+ if (renderedItems >= maxItems) return [`${pad}…`];
40
+ if ((Array.isArray(current) || isRecord(current)) && depth >= maxDepth) return [`${pad}…`];
41
+
42
+ if (Array.isArray(current)) {
43
+ const lines: string[] = [];
44
+ for (const item of current) {
45
+ if (renderedItems >= maxItems) { lines.push(`${pad}…`); break; }
46
+ renderedItems++;
47
+ if (isRecord(item) && typeof item["title"] === "string") {
48
+ const status = typeof item["status"] === "string" ? item["status"] : "";
49
+ const glyph = STATUS_GLYPHS[status];
50
+ lines.push(`${pad}- ${glyph ? `${glyph} ` : ""}${item["title"]}`);
51
+ const rest = Object.fromEntries(Object.entries(item).filter(([key]) => key !== "title" && key !== "status"));
52
+ if (Object.keys(rest).length > 0) lines.push(...render(rest, indent + 1, depth + 1));
53
+ } else if (Array.isArray(item) || isRecord(item)) {
54
+ lines.push(`${pad}-`);
55
+ lines.push(...render(item, indent + 1, depth + 1));
56
+ } else {
57
+ lines.push(`${pad}- ${scalar(item)}`);
58
+ }
59
+ }
60
+ return lines;
61
+ }
62
+
63
+ if (isRecord(current)) {
64
+ const lines: string[] = [];
65
+ for (const [key, item] of Object.entries(current)) {
66
+ if (renderedItems >= maxItems) { lines.push(`${pad}…`); break; }
67
+ renderedItems++;
68
+ if (Array.isArray(item) || isRecord(item)) {
69
+ lines.push(`${pad}${key}:`);
70
+ lines.push(...render(item, indent + 1, depth + 1));
71
+ } else {
72
+ lines.push(`${pad}${key}: ${scalar(item)}`);
73
+ }
74
+ }
75
+ return lines;
76
+ }
77
+
78
+ return [`${pad}${scalar(current)}`];
79
+ }
80
+
81
+ return render(value, 0, 0);
82
+ }
@@ -0,0 +1,45 @@
1
+ import { renderMermaidASCII } from "beautiful-mermaid";
2
+ import {
3
+ GRAPH_RENDER_BOX_PADDING,
4
+ GRAPH_RENDER_PADDING_X,
5
+ GRAPH_RENDER_PADDING_Y,
6
+ } from "../../src/constants.ts";
7
+ import type { DisplayGraph, RenderedGraph } from "../../src/domain/display-graph.ts";
8
+ import type { GraphRenderer } from "../../src/ports/graph-renderer.ts";
9
+
10
+ function nodeLabel(label: string): string {
11
+ return label.replace(/\s+/g, " ").trim().replaceAll('"', "'");
12
+ }
13
+
14
+ function edgeLabel(label: string): string {
15
+ return label.replace(/\s+/g, " ").trim().replaceAll("|", "/");
16
+ }
17
+
18
+ export function mermaidSource(graph: DisplayGraph): string {
19
+ const aliases = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`]));
20
+ const lines = [`flowchart ${graph.direction}`];
21
+ for (const node of graph.nodes) lines.push(` ${aliases.get(node.id)}["${nodeLabel(node.label)}"]`);
22
+ for (const edge of graph.edges) {
23
+ const from = aliases.get(edge.from);
24
+ const to = aliases.get(edge.to);
25
+ if (!from || !to) continue;
26
+ lines.push(edge.label
27
+ ? ` ${from} -->|${edgeLabel(edge.label)}| ${to}`
28
+ : ` ${from} --> ${to}`);
29
+ }
30
+ return lines.join("\n");
31
+ }
32
+
33
+ export class BeautifulMermaidRenderer implements GraphRenderer {
34
+ render(graph: DisplayGraph): RenderedGraph {
35
+ if (graph.nodes.length === 0) return { lines: [] };
36
+ const output = renderMermaidASCII(mermaidSource(graph), {
37
+ useAscii: false,
38
+ paddingX: GRAPH_RENDER_PADDING_X,
39
+ paddingY: GRAPH_RENDER_PADDING_Y,
40
+ boxBorderPadding: GRAPH_RENDER_BOX_PADDING,
41
+ colorMode: "none",
42
+ });
43
+ return { lines: output.replace(/\s+$/g, "").split("\n") };
44
+ }
45
+ }
@@ -0,0 +1,48 @@
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 DOC_GLYPHS: Record<string, string> = { draft: "○", active: "●", archived: "■" };
7
+ const DOC_ACTIONS: Record<string, string[]> = {
8
+ draft: ["Activate", "Archive"],
9
+ active: ["Archive"],
10
+ archived: ["Reopen"],
11
+ };
12
+ const DOC_RELATIONS = ["references", "documents", "supersedes", "relates_to", "contains", "part_of"];
13
+
14
+ export function documentRowMeta(document: Artifact): string {
15
+ return [document.subtype, document.labels.join(", ")].filter(Boolean).join(" · ");
16
+ }
17
+
18
+ export async function showDocs(ctx: ExtensionCommandContext): Promise<void> {
19
+ await showArtifactBrowser(ctx, {
20
+ kind: "doc",
21
+ title: "Documents",
22
+ listOperation: "docs.list",
23
+ statusOrder: ["draft", "active", "archived"],
24
+ glyphs: DOC_GLYPHS,
25
+ rowMeta: documentRowMeta,
26
+ actions: (document) => ["Show details", "Link artifact", ...(DOC_ACTIONS[document.status] ?? [])],
27
+ handleAction: async (choice, document, commandCtx) => {
28
+ if (choice === "Show details") {
29
+ await showArtifactDetails(commandCtx, document.id, "docs.show");
30
+ return;
31
+ }
32
+ if (choice === "Link artifact") {
33
+ const targetId = await commandCtx.ui.input("Target artifact id:", "");
34
+ if (!targetId) return;
35
+ const relation = await commandCtx.ui.select("Relation", DOC_RELATIONS);
36
+ if (!relation) return;
37
+ await callService("docs.link", { id: document.id, relation, target_id: targetId });
38
+ commandCtx.ui.notify(`Linked ${document.id} --${relation}--> ${targetId}`, "info");
39
+ return;
40
+ }
41
+ const operation = choice === "Activate" ? "docs.activate" : choice === "Archive" ? "docs.archive" : choice === "Reopen" ? "docs.reopen" : undefined;
42
+ if (operation) {
43
+ const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: document.id });
44
+ commandCtx.ui.notify(`${updated.id} → [${updated.status}]`, "info");
45
+ }
46
+ },
47
+ });
48
+ }