@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
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@danypops/papyrus",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": ["pi-package"],
|
|
7
|
+
"bin": {
|
|
8
|
+
"papyrus": "src/cli.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "bun test",
|
|
12
|
+
"cli": "bun src/cli.ts",
|
|
13
|
+
"serve": "bun src/cli.ts serve",
|
|
14
|
+
"service:install": "bun src/cli.ts service install",
|
|
15
|
+
"guard:install": "git config core.hooksPath .githooks"
|
|
16
|
+
},
|
|
17
|
+
"pi": {
|
|
18
|
+
"extensions": ["extension/src/index.ts"]
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
22
|
+
"@earendil-works/pi-tui": "*",
|
|
23
|
+
"typebox": "*"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"bun-types": "latest"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/DanyPops/papyrus.git"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/DanyPops/papyrus#readme",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/DanyPops/papyrus/issues"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"files": ["src", "extension", "README.md"],
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"beautiful-mermaid": "1.1.3"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Db } from "../db.ts";
|
|
2
|
+
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
3
|
+
import type {
|
|
4
|
+
Artifact,
|
|
5
|
+
ArtifactEdge,
|
|
6
|
+
ArtifactGraphOptions,
|
|
7
|
+
ArtifactLink,
|
|
8
|
+
ArtifactQuery,
|
|
9
|
+
CreateArtifactInput,
|
|
10
|
+
RelationshipQuery,
|
|
11
|
+
} from "../domain/artifact.ts";
|
|
12
|
+
import { createArtifact, getArtifact, linkArtifacts, queryArtifacts, updateExtra, updateStatus } from "../ops.ts";
|
|
13
|
+
|
|
14
|
+
export class SQLiteArtifactStore implements ArtifactStore {
|
|
15
|
+
constructor(private readonly db: Db) {}
|
|
16
|
+
|
|
17
|
+
create(input: CreateArtifactInput): Artifact {
|
|
18
|
+
return createArtifact(this.db, input);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get(id: string, options?: ArtifactGraphOptions): Artifact | null {
|
|
22
|
+
return getArtifact(this.db, id, options);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
query(filter: ArtifactQuery): Artifact[] {
|
|
26
|
+
return queryArtifacts(this.db, filter);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
link(link: ArtifactLink): void {
|
|
30
|
+
linkArtifacts(this.db, link.from, link.relation, link.to);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
setStatus(id: string, status: string): Artifact | null {
|
|
34
|
+
return updateStatus(this.db, id, status);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
setExtra(id: string, extra: Record<string, unknown>): Artifact | null {
|
|
38
|
+
return updateExtra(this.db, id, extra);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
relationships(filter: RelationshipQuery = {}): ArtifactEdge[] {
|
|
42
|
+
if (filter.artifactIds?.length === 0) return [];
|
|
43
|
+
const conditions: string[] = [];
|
|
44
|
+
const parameters: unknown[] = [];
|
|
45
|
+
if (filter.kind) {
|
|
46
|
+
conditions.push("source.kind = ? AND target.kind = ?");
|
|
47
|
+
parameters.push(filter.kind, filter.kind);
|
|
48
|
+
}
|
|
49
|
+
if (filter.artifactIds) {
|
|
50
|
+
const placeholders = filter.artifactIds.map(() => "?").join(", ");
|
|
51
|
+
conditions.push(`(edges.from_id IN (${placeholders}) OR edges.to_id IN (${placeholders}))`);
|
|
52
|
+
parameters.push(...filter.artifactIds, ...filter.artifactIds);
|
|
53
|
+
}
|
|
54
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
55
|
+
return this.db.prepare(`
|
|
56
|
+
SELECT edges.from_id AS "from", edges.relation, edges.to_id AS "to"
|
|
57
|
+
FROM edges
|
|
58
|
+
JOIN artifacts AS source ON source.id = edges.from_id
|
|
59
|
+
JOIN artifacts AS target ON target.id = edges.to_id
|
|
60
|
+
${where}
|
|
61
|
+
ORDER BY edges.rowid
|
|
62
|
+
`).all(...parameters) as ArtifactEdge[];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Db } from "../db.ts";
|
|
2
|
+
import type { GateResult } from "../domain/gate.ts";
|
|
3
|
+
import type { GateRunner } from "../ports/gate-runner.ts";
|
|
4
|
+
import { runGates, runGatesAsync } from "../ops.ts";
|
|
5
|
+
|
|
6
|
+
export class SQLiteGateRunner implements GateRunner {
|
|
7
|
+
constructor(private readonly db: Db) {}
|
|
8
|
+
|
|
9
|
+
run(artifactId: string): GateResult[] {
|
|
10
|
+
return runGates(this.db, artifactId);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
runAsync(artifactId: string): Promise<GateResult[]> {
|
|
14
|
+
return runGatesAsync(this.db, artifactId);
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { DAEMON_UNIT_NAME } from "./constants.ts";
|
|
8
|
+
import { serveMain } from "./daemon.ts";
|
|
9
|
+
|
|
10
|
+
export interface SystemdUnitOptions {
|
|
11
|
+
bunBin: string;
|
|
12
|
+
cliPath: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function renderSystemdUnit(options: SystemdUnitOptions): string {
|
|
16
|
+
return `[Unit]
|
|
17
|
+
Description=Papyrus graph artifact service
|
|
18
|
+
After=default.target
|
|
19
|
+
|
|
20
|
+
[Service]
|
|
21
|
+
Type=simple
|
|
22
|
+
ExecStart=${options.bunBin} ${options.cliPath} serve
|
|
23
|
+
Restart=always
|
|
24
|
+
RestartSec=2
|
|
25
|
+
|
|
26
|
+
[Install]
|
|
27
|
+
WantedBy=default.target
|
|
28
|
+
`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function unitPath(): string {
|
|
32
|
+
const configHome = process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config");
|
|
33
|
+
return join(configHome, "systemd", "user", DAEMON_UNIT_NAME);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function systemctl(...args: string[]): void {
|
|
37
|
+
execFileSync("systemctl", ["--user", ...args], { stdio: "inherit" });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function installService(): void {
|
|
41
|
+
const path = unitPath();
|
|
42
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
43
|
+
writeFileSync(path, renderSystemdUnit({
|
|
44
|
+
bunBin: process.execPath,
|
|
45
|
+
cliPath: fileURLToPath(import.meta.url),
|
|
46
|
+
}));
|
|
47
|
+
systemctl("daemon-reload");
|
|
48
|
+
systemctl("enable", DAEMON_UNIT_NAME);
|
|
49
|
+
systemctl("restart", DAEMON_UNIT_NAME);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function usage(): never {
|
|
53
|
+
console.error("Usage: papyrus serve | service <install|start|stop|restart|status>");
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function main(args: string[] = process.argv.slice(2)): void {
|
|
58
|
+
const [command, action] = args;
|
|
59
|
+
if (command === "serve") { serveMain(); return; }
|
|
60
|
+
if (command !== "service") usage();
|
|
61
|
+
switch (action) {
|
|
62
|
+
case "install": installService(); break;
|
|
63
|
+
case "start": systemctl("start", DAEMON_UNIT_NAME); break;
|
|
64
|
+
case "stop": systemctl("stop", DAEMON_UNIT_NAME); break;
|
|
65
|
+
case "restart": systemctl("restart", DAEMON_UNIT_NAME); break;
|
|
66
|
+
case "status": systemctl("status", DAEMON_UNIT_NAME); break;
|
|
67
|
+
default: usage();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (import.meta.main) main();
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { DAEMON_CLIENT_TIMEOUT_MS, DAEMON_PROBE_TIMEOUT_MS } from "./constants.ts";
|
|
2
|
+
import { daemonStateDir, readDaemonHandle } from "./daemon-state.ts";
|
|
3
|
+
import type { OperationName } from "./service.ts";
|
|
4
|
+
|
|
5
|
+
export type FetchAdapter = (request: Request) => Promise<Response>;
|
|
6
|
+
|
|
7
|
+
export class PapyrusClient {
|
|
8
|
+
constructor(
|
|
9
|
+
private readonly baseUrl: string,
|
|
10
|
+
private readonly token: string,
|
|
11
|
+
private readonly fetchAdapter: FetchAdapter = (request) => fetch(request),
|
|
12
|
+
private readonly timeoutMs: number = DAEMON_CLIENT_TIMEOUT_MS,
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
16
|
+
const request = new Request(`${this.baseUrl}${path}`, {
|
|
17
|
+
...init,
|
|
18
|
+
headers: {
|
|
19
|
+
authorization: `Bearer ${this.token}`,
|
|
20
|
+
"content-type": "application/json",
|
|
21
|
+
...init.headers,
|
|
22
|
+
},
|
|
23
|
+
signal: init.signal ?? AbortSignal.timeout(this.timeoutMs),
|
|
24
|
+
});
|
|
25
|
+
const response = await this.fetchAdapter(request);
|
|
26
|
+
const body = await response.json() as { error?: string } & T;
|
|
27
|
+
if (!response.ok) throw new Error(body.error ?? `Papyrus daemon HTTP ${response.status}`);
|
|
28
|
+
return body;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
health(): Promise<{ ok: true; version: string }> {
|
|
32
|
+
return this.request("/health");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async operations(): Promise<OperationName[]> {
|
|
36
|
+
const body = await this.request<{ operations: OperationName[] }>("/api/v1/ops");
|
|
37
|
+
return body.operations;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async call<Input extends Record<string, unknown>, Output>(operation: OperationName, input: Input): Promise<Output> {
|
|
41
|
+
const body = await this.request<{ result: Output }>("/api/v1/ops", {
|
|
42
|
+
method: "POST",
|
|
43
|
+
body: JSON.stringify({ op: operation, input }),
|
|
44
|
+
});
|
|
45
|
+
return body.result;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function connectPapyrusClient(dir: string = daemonStateDir()): Promise<PapyrusClient> {
|
|
50
|
+
const handle = readDaemonHandle(dir);
|
|
51
|
+
if (!handle) throw new Error("Papyrus daemon is not running; install/start papyrus.service");
|
|
52
|
+
const probe = new PapyrusClient(handle.baseUrl, handle.token, (request) => fetch(request), DAEMON_PROBE_TIMEOUT_MS);
|
|
53
|
+
try {
|
|
54
|
+
await probe.health();
|
|
55
|
+
return new PapyrusClient(handle.baseUrl, handle.token);
|
|
56
|
+
} catch {
|
|
57
|
+
throw new Error("Papyrus daemon state is stale or unreachable; restart papyrus.service");
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
export const VERSION = "0.1.0";
|
|
2
|
+
|
|
3
|
+
/** Long-running daemon transport and state. */
|
|
4
|
+
export const DAEMON_HOST = "127.0.0.1";
|
|
5
|
+
export const DAEMON_PORT_FILE = "port";
|
|
6
|
+
export const DAEMON_TOKEN_FILE = "token";
|
|
7
|
+
export const DAEMON_CLIENT_TIMEOUT_MS = 15_000;
|
|
8
|
+
export const DAEMON_PROBE_TIMEOUT_MS = 800;
|
|
9
|
+
export const DAEMON_UNIT_NAME = "papyrus.service";
|
|
10
|
+
export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
|
|
11
|
+
export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
12
|
+
export const SQLITE_SCHEMA_VERSION = 1;
|
|
13
|
+
export const SERVICE_MAX_BODY_BYTES = 1_048_576;
|
|
14
|
+
export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
|
|
15
|
+
export const DB_OPTIMIZE_INTERVAL_MS = 24 * 60 * 60_000;
|
|
16
|
+
export const GATE_COMMAND_TIMEOUT_MS = 30_000;
|
|
17
|
+
export const GATE_TEST_TIMEOUT_MS = 60_000;
|
|
18
|
+
export const GATE_OUTPUT_LIMIT = 200;
|
|
19
|
+
export const GATE_MAX_BUFFER_BYTES = 1_048_576;
|
|
20
|
+
|
|
21
|
+
/** Compact task-context limits keep recurring prompt injection bounded. */
|
|
22
|
+
export const TASK_CONTEXT_ACTIVE_LIMIT = 3;
|
|
23
|
+
export const TASK_CONTEXT_FAILED_LIMIT = 3;
|
|
24
|
+
export const TASK_WIDGET_ACTIVE_LIMIT = 3;
|
|
25
|
+
export const TASK_DETAIL_MIN_VISIBLE_LINES = 8;
|
|
26
|
+
export const TASK_DETAIL_MAX_VISIBLE_LINES = 24;
|
|
27
|
+
export const TASK_DETAIL_RESERVED_ROWS = 8;
|
|
28
|
+
export const TASK_DETAIL_HORIZONTAL_PAN_COLUMNS = 4;
|
|
29
|
+
export const TASK_GRAPH_MIN_VISIBLE_LINES = 8;
|
|
30
|
+
export const TASK_GRAPH_MAX_VISIBLE_LINES = 30;
|
|
31
|
+
export const TASK_GRAPH_RESERVED_ROWS = 8;
|
|
32
|
+
export const TASK_GRAPH_HORIZONTAL_PAN_COLUMNS = 4;
|
|
33
|
+
export const GRAPH_RENDER_PADDING_X = 2;
|
|
34
|
+
export const GRAPH_RENDER_PADDING_Y = 1;
|
|
35
|
+
export const GRAPH_RENDER_BOX_PADDING = 0;
|
|
36
|
+
|
|
37
|
+
/** Safe defaults and hard ceilings for graph expansion. */
|
|
38
|
+
export const DEFAULT_GRAPH_DEPTH = 4;
|
|
39
|
+
export const DEFAULT_GRAPH_MAX_NODES = 100;
|
|
40
|
+
export const MAX_GRAPH_DEPTH = 20;
|
|
41
|
+
export const MAX_GRAPH_NODES = 1_000;
|
|
42
|
+
|
|
43
|
+
/** Bounds for recursively rendered artifact metadata. */
|
|
44
|
+
export const DEFAULT_METADATA_DEPTH = 6;
|
|
45
|
+
export const DEFAULT_METADATA_ITEMS = 100;
|
|
46
|
+
export const MAX_METADATA_DEPTH = 12;
|
|
47
|
+
export const MAX_METADATA_ITEMS = 500;
|
|
48
|
+
|
|
49
|
+
/** Reconciliation instruction appended whenever Papyrus has open work. */
|
|
50
|
+
export const TASK_RECONCILIATION_INSTRUCTION = [
|
|
51
|
+
"Reconcile before concluding or moving on:",
|
|
52
|
+
'• For each current task, ask: "Did we accomplish this task?"',
|
|
53
|
+
"• If yes, run its gates before marking it done; a claim is not verification.",
|
|
54
|
+
"• If no, continue with the next concrete action toward its desired state.",
|
|
55
|
+
"• Address blocked work or explicitly leave it failed with the reason.",
|
|
56
|
+
].join("\n");
|
|
57
|
+
|
|
58
|
+
/** $XDG_DATA_HOME/papyrus/papyrus.db */
|
|
59
|
+
export function dbPath(): string {
|
|
60
|
+
const xdg = process.env["XDG_DATA_HOME"] || `${process.env["HOME"]}/.local/share`;
|
|
61
|
+
return `${xdg}/papyrus/papyrus.db`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Four purpose-built kinds — the enforced vocabulary.
|
|
66
|
+
*
|
|
67
|
+
* doc = Knowledge — descriptive ("here is what the architecture looks like")
|
|
68
|
+
* task = Work — prescriptive action items with gates and checklists
|
|
69
|
+
* rule = Governance — context injection ("when doing X, follow Y").
|
|
70
|
+
* Maps to AGENTS.md semantics: active rules with inject:true are
|
|
71
|
+
* appended to the system prompt on before_agent_start.
|
|
72
|
+
* skill = Procedural — "when using X,Y,Z do A,B,C" (richer SKILL.md metadata)
|
|
73
|
+
*/
|
|
74
|
+
export const SEED_KINDS = [
|
|
75
|
+
{ name: "doc", description: "Knowledge — descriptive reference (specs, decisions, research, designs)" },
|
|
76
|
+
{ name: "task", description: "Work — action items with gates, checklists, and dependencies" },
|
|
77
|
+
{ name: "rule", description: "Governance — context injection (when doing X, follow Y). Maps to AGENTS.md" },
|
|
78
|
+
{ name: "skill", description: "Procedural — when using X,Y,Z do A,B,C (SKILL.md metadata)" },
|
|
79
|
+
] as const;
|
|
80
|
+
|
|
81
|
+
export const SEED_STATUSES = [
|
|
82
|
+
{ name: "draft", kind: "doc" },
|
|
83
|
+
{ name: "active", kind: "doc" },
|
|
84
|
+
{ name: "archived", kind: "doc" },
|
|
85
|
+
{ name: "pending", kind: "task" },
|
|
86
|
+
{ name: "active", kind: "task" },
|
|
87
|
+
{ name: "done", kind: "task" },
|
|
88
|
+
{ name: "failed", kind: "task" },
|
|
89
|
+
{ name: "active", kind: "rule" },
|
|
90
|
+
{ name: "deprecated", kind: "rule" },
|
|
91
|
+
{ name: "active", kind: "skill" },
|
|
92
|
+
{ name: "deprecated", kind: "skill" },
|
|
93
|
+
] as const;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Universal relation names — any kind can link to any kind.
|
|
97
|
+
*
|
|
98
|
+
* references: source material (doc→doc, doc→task, doc→rule)
|
|
99
|
+
* implements: this work satisfies that (task→doc, task→rule)
|
|
100
|
+
* follows: this work obeys that (task→rule, task→skill)
|
|
101
|
+
* depends_on: DAG ordering (task→task)
|
|
102
|
+
* documents: describes (doc→task, doc→rule, doc→skill)
|
|
103
|
+
* blocks: blocking relationship (task→task)
|
|
104
|
+
* supersedes: replaces (doc→doc, rule→rule)
|
|
105
|
+
* relates_to: catch-all (any→any)
|
|
106
|
+
* gates: this rule gates that task (rule→task)
|
|
107
|
+
* triggers: this skill applies to that work (skill→task)
|
|
108
|
+
*/
|
|
109
|
+
export const SEED_RELATIONS = [
|
|
110
|
+
"references", "implements", "follows", "depends_on",
|
|
111
|
+
"documents", "blocks", "supersedes", "relates_to",
|
|
112
|
+
"gates", "triggers", "contains", "part_of",
|
|
113
|
+
] as const;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
DAEMON_DIR_ENV,
|
|
7
|
+
DAEMON_HOST,
|
|
8
|
+
DAEMON_PORT_FILE,
|
|
9
|
+
DAEMON_TOKEN_FILE,
|
|
10
|
+
} from "./constants.ts";
|
|
11
|
+
|
|
12
|
+
export interface DaemonHandle {
|
|
13
|
+
baseUrl: string;
|
|
14
|
+
token: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function daemonStateDir(
|
|
18
|
+
env: Record<string, string | undefined> = process.env,
|
|
19
|
+
home: string = homedir(),
|
|
20
|
+
): string {
|
|
21
|
+
if (env[DAEMON_DIR_ENV]) return env[DAEMON_DIR_ENV];
|
|
22
|
+
if (env["XDG_RUNTIME_DIR"]) return join(env["XDG_RUNTIME_DIR"], "papyrus");
|
|
23
|
+
if (env["XDG_STATE_HOME"]) return join(env["XDG_STATE_HOME"], "papyrus");
|
|
24
|
+
return join(home, ".local", "state", "papyrus");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function loadOrCreateToken(dir: string): string {
|
|
28
|
+
const path = join(dir, DAEMON_TOKEN_FILE);
|
|
29
|
+
try {
|
|
30
|
+
const token = readFileSync(path, "utf8").trim();
|
|
31
|
+
if (token) return token;
|
|
32
|
+
} catch {
|
|
33
|
+
// First daemon start.
|
|
34
|
+
}
|
|
35
|
+
const token = randomBytes(32).toString("hex");
|
|
36
|
+
mkdirSync(dir, { recursive: true });
|
|
37
|
+
writeFileSync(path, `${token}\n`, { mode: 0o600 });
|
|
38
|
+
return token;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function writeDaemonPort(dir: string, port: number): void {
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
writeFileSync(join(dir, DAEMON_PORT_FILE), `${port}\n`, { mode: 0o600 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function clearDaemonPort(dir: string): void {
|
|
47
|
+
rmSync(join(dir, DAEMON_PORT_FILE), { force: true });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readDaemonHandle(dir: string): DaemonHandle | undefined {
|
|
51
|
+
try {
|
|
52
|
+
const token = readFileSync(join(dir, DAEMON_TOKEN_FILE), "utf8").trim();
|
|
53
|
+
const port = Number(readFileSync(join(dir, DAEMON_PORT_FILE), "utf8").trim());
|
|
54
|
+
if (!token || !Number.isInteger(port) || port < 1 || port > 65_535) return undefined;
|
|
55
|
+
return { baseUrl: `http://${DAEMON_HOST}:${port}`, token };
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { DAEMON_HOST, DB_OPTIMIZE_INTERVAL_MS, WAL_CHECKPOINT_INTERVAL_MS, dbPath } from "./constants.ts";
|
|
2
|
+
import { clearDaemonPort, daemonStateDir, loadOrCreateToken, writeDaemonPort } from "./daemon-state.ts";
|
|
3
|
+
import { createApp, createPapyrusService } from "./service.ts";
|
|
4
|
+
|
|
5
|
+
/** Start the supervised, long-running Papyrus service. */
|
|
6
|
+
export function serveMain(): void {
|
|
7
|
+
const stateDir = daemonStateDir();
|
|
8
|
+
const token = loadOrCreateToken(stateDir);
|
|
9
|
+
const service = createPapyrusService(dbPath());
|
|
10
|
+
const app = createApp({ service, token });
|
|
11
|
+
const server = Bun.serve({
|
|
12
|
+
hostname: DAEMON_HOST,
|
|
13
|
+
port: 0,
|
|
14
|
+
fetch: (request) => app.fetch(request),
|
|
15
|
+
});
|
|
16
|
+
if (!server.port) {
|
|
17
|
+
service.close();
|
|
18
|
+
throw new Error("Papyrus daemon failed to bind a listener");
|
|
19
|
+
}
|
|
20
|
+
writeDaemonPort(stateDir, server.port);
|
|
21
|
+
const checkpointTimer = setInterval(() => {
|
|
22
|
+
try { service.checkpoint(); } catch (error) { console.error("[papyrus] checkpoint failed", error); }
|
|
23
|
+
}, WAL_CHECKPOINT_INTERVAL_MS);
|
|
24
|
+
const optimizeTimer = setInterval(() => {
|
|
25
|
+
try { service.optimize(); } catch (error) { console.error("[papyrus] optimize failed", error); }
|
|
26
|
+
}, DB_OPTIMIZE_INTERVAL_MS);
|
|
27
|
+
|
|
28
|
+
let stopping = false;
|
|
29
|
+
const shutdown = () => {
|
|
30
|
+
if (stopping) return;
|
|
31
|
+
stopping = true;
|
|
32
|
+
clearInterval(checkpointTimer);
|
|
33
|
+
clearInterval(optimizeTimer);
|
|
34
|
+
clearDaemonPort(stateDir);
|
|
35
|
+
service.close();
|
|
36
|
+
void server.stop(true).finally(() => process.exit(0));
|
|
37
|
+
};
|
|
38
|
+
process.on("SIGINT", shutdown);
|
|
39
|
+
process.on("SIGTERM", shutdown);
|
|
40
|
+
console.error(`[papyrus] listening on ${DAEMON_HOST}:${server.port}`);
|
|
41
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* db.ts — enforced-schema SQLite store for Papyrus.
|
|
3
|
+
* Dual-runtime: bun:sqlite (Bun) / node:sqlite (Node/pi host).
|
|
4
|
+
* Four kinds (doc/task/rule/skill) are FK-enforced; relations are universal (any→any).
|
|
5
|
+
*/
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { mkdirSync } from "node:fs";
|
|
8
|
+
import { join, dirname } from "node:path";
|
|
9
|
+
import { SQLITE_BUSY_TIMEOUT_MS, SQLITE_SCHEMA_VERSION } from "./constants.ts";
|
|
10
|
+
|
|
11
|
+
const require_ = createRequire(import.meta.url);
|
|
12
|
+
const IS_BUN = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
|
|
13
|
+
const backend = IS_BUN
|
|
14
|
+
? (require_("bun:sqlite") as typeof import("bun:sqlite"))
|
|
15
|
+
: (require_("node:sqlite") as unknown as typeof import("bun:sqlite"));
|
|
16
|
+
|
|
17
|
+
const DatabaseCtor = (
|
|
18
|
+
"DatabaseSync" in backend ? (backend as { DatabaseSync: unknown }).DatabaseSync : backend.Database
|
|
19
|
+
) as new (path: string, opts?: { create?: boolean }) => Db;
|
|
20
|
+
|
|
21
|
+
export interface DbStatement {
|
|
22
|
+
run(...params: unknown[]): { lastInsertRowid: number | bigint };
|
|
23
|
+
get(...params: unknown[]): unknown;
|
|
24
|
+
all(...params: unknown[]): unknown[];
|
|
25
|
+
}
|
|
26
|
+
export interface Db {
|
|
27
|
+
exec(sql: string): unknown;
|
|
28
|
+
prepare(sql: string): DbStatement;
|
|
29
|
+
close(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function inTransaction<T>(db: Db, fn: () => T): T {
|
|
33
|
+
db.exec("BEGIN IMMEDIATE");
|
|
34
|
+
try {
|
|
35
|
+
const result = fn();
|
|
36
|
+
db.exec("COMMIT");
|
|
37
|
+
return result;
|
|
38
|
+
} catch (e) {
|
|
39
|
+
db.exec("ROLLBACK");
|
|
40
|
+
throw e;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const SCHEMA = `
|
|
45
|
+
CREATE TABLE IF NOT EXISTS kinds (
|
|
46
|
+
name TEXT PRIMARY KEY,
|
|
47
|
+
description TEXT
|
|
48
|
+
);
|
|
49
|
+
CREATE TABLE IF NOT EXISTS statuses (
|
|
50
|
+
name TEXT NOT NULL,
|
|
51
|
+
kind TEXT NOT NULL REFERENCES kinds(name),
|
|
52
|
+
PRIMARY KEY (name, kind)
|
|
53
|
+
);
|
|
54
|
+
CREATE TABLE IF NOT EXISTS artifacts (
|
|
55
|
+
id TEXT PRIMARY KEY,
|
|
56
|
+
kind TEXT NOT NULL REFERENCES kinds(name),
|
|
57
|
+
title TEXT NOT NULL,
|
|
58
|
+
status TEXT NOT NULL,
|
|
59
|
+
subtype TEXT DEFAULT '',
|
|
60
|
+
body TEXT DEFAULT '',
|
|
61
|
+
labels TEXT DEFAULT '[]',
|
|
62
|
+
extra TEXT DEFAULT '{}',
|
|
63
|
+
created_at TEXT NOT NULL,
|
|
64
|
+
updated_at TEXT NOT NULL,
|
|
65
|
+
FOREIGN KEY (kind, status) REFERENCES statuses(kind, name)
|
|
66
|
+
);
|
|
67
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
68
|
+
from_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
69
|
+
relation TEXT NOT NULL REFERENCES relation_names(name),
|
|
70
|
+
to_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
71
|
+
PRIMARY KEY (from_id, relation, to_id)
|
|
72
|
+
);
|
|
73
|
+
CREATE TABLE IF NOT EXISTS relation_names (
|
|
74
|
+
name TEXT PRIMARY KEY,
|
|
75
|
+
description TEXT
|
|
76
|
+
);
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
const SEED_SQL = `
|
|
80
|
+
INSERT OR IGNORE INTO kinds VALUES ('doc','Knowledge — what we know (specs, decisions, research, designs)');
|
|
81
|
+
INSERT OR IGNORE INTO kinds VALUES ('task','Work — what we are doing (goals, steps, checklists)');
|
|
82
|
+
INSERT OR IGNORE INTO kinds VALUES ('rule','Governance — when doing X, follow Y');
|
|
83
|
+
INSERT OR IGNORE INTO kinds VALUES ('skill','Procedural — when using X,Y,Z do A,B,C');
|
|
84
|
+
INSERT OR IGNORE INTO statuses VALUES ('draft','doc');
|
|
85
|
+
INSERT OR IGNORE INTO statuses VALUES ('active','doc');
|
|
86
|
+
INSERT OR IGNORE INTO statuses VALUES ('archived','doc');
|
|
87
|
+
INSERT OR IGNORE INTO statuses VALUES ('pending','task');
|
|
88
|
+
INSERT OR IGNORE INTO statuses VALUES ('active','task');
|
|
89
|
+
INSERT OR IGNORE INTO statuses VALUES ('done','task');
|
|
90
|
+
INSERT OR IGNORE INTO statuses VALUES ('failed','task');
|
|
91
|
+
INSERT OR IGNORE INTO statuses VALUES ('active','rule');
|
|
92
|
+
INSERT OR IGNORE INTO statuses VALUES ('deprecated','rule');
|
|
93
|
+
INSERT OR IGNORE INTO statuses VALUES ('active','skill');
|
|
94
|
+
INSERT OR IGNORE INTO statuses VALUES ('deprecated','skill');
|
|
95
|
+
INSERT OR IGNORE INTO relation_names VALUES ('references','Source material (doc→doc, doc→task, doc→rule)');
|
|
96
|
+
INSERT OR IGNORE INTO relation_names VALUES ('implements','This work satisfies that (task→doc, task→rule)');
|
|
97
|
+
INSERT OR IGNORE INTO relation_names VALUES ('follows','This work obeys that (task→rule, task→skill)');
|
|
98
|
+
INSERT OR IGNORE INTO relation_names VALUES ('depends_on','DAG ordering (task→task)');
|
|
99
|
+
INSERT OR IGNORE INTO relation_names VALUES ('documents','Describes (doc→task, doc→rule, doc→skill)');
|
|
100
|
+
INSERT OR IGNORE INTO relation_names VALUES ('blocks','Blocking relationship (task→task)');
|
|
101
|
+
INSERT OR IGNORE INTO relation_names VALUES ('supersedes','Replaces (doc→doc, rule→rule)');
|
|
102
|
+
INSERT OR IGNORE INTO relation_names VALUES ('relates_to','Catch-all (any→any)');
|
|
103
|
+
INSERT OR IGNORE INTO relation_names VALUES ('gates','This rule gates that task (rule→task)');
|
|
104
|
+
INSERT OR IGNORE INTO relation_names VALUES ('triggers','This skill applies to that work (skill→task)');
|
|
105
|
+
INSERT OR IGNORE INTO relation_names VALUES ('contains','Parent contains a nested artifact (any→any)');
|
|
106
|
+
INSERT OR IGNORE INTO relation_names VALUES ('part_of','Artifact belongs to a parent artifact (any→any)');
|
|
107
|
+
CREATE INDEX IF NOT EXISTS edges_to_id_idx ON edges(to_id);
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
function migrate(db: Db): void {
|
|
111
|
+
const row = db.prepare("PRAGMA user_version").get() as { user_version: number };
|
|
112
|
+
let version = row.user_version;
|
|
113
|
+
if (version > SQLITE_SCHEMA_VERSION) {
|
|
114
|
+
throw new Error(`database schema ${version} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
|
|
115
|
+
}
|
|
116
|
+
if (version < 1) {
|
|
117
|
+
inTransaction(db, () => {
|
|
118
|
+
db.exec(SCHEMA);
|
|
119
|
+
db.exec(SEED_SQL);
|
|
120
|
+
db.exec("PRAGMA user_version = 1");
|
|
121
|
+
});
|
|
122
|
+
version = 1;
|
|
123
|
+
}
|
|
124
|
+
if (version !== SQLITE_SCHEMA_VERSION) {
|
|
125
|
+
throw new Error(`missing migration from schema ${version} to ${SQLITE_SCHEMA_VERSION}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function openDb(path: string): Db {
|
|
130
|
+
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
|
|
131
|
+
const db = IS_BUN ? new DatabaseCtor(path, { create: true }) : new DatabaseCtor(path);
|
|
132
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
133
|
+
db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
|
|
134
|
+
if (path !== ":memory:") db.exec("PRAGMA journal_mode = WAL");
|
|
135
|
+
migrate(db);
|
|
136
|
+
db.exec("PRAGMA optimize=0x10002");
|
|
137
|
+
return db;
|
|
138
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export interface ArtifactEdge {
|
|
2
|
+
from: string;
|
|
3
|
+
relation: string;
|
|
4
|
+
to: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface Artifact {
|
|
8
|
+
id: string;
|
|
9
|
+
kind: string;
|
|
10
|
+
title: string;
|
|
11
|
+
status: string;
|
|
12
|
+
subtype: string;
|
|
13
|
+
body: string;
|
|
14
|
+
labels: string[];
|
|
15
|
+
extra: Record<string, unknown>;
|
|
16
|
+
created_at: string;
|
|
17
|
+
updated_at: string;
|
|
18
|
+
edges?: ArtifactEdge[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CreateArtifactInput {
|
|
22
|
+
kind?: string;
|
|
23
|
+
title?: string;
|
|
24
|
+
status?: string;
|
|
25
|
+
body?: string;
|
|
26
|
+
labels?: string[];
|
|
27
|
+
extra?: Record<string, unknown>;
|
|
28
|
+
id?: string;
|
|
29
|
+
subtype?: string;
|
|
30
|
+
templateId?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ArtifactQuery {
|
|
34
|
+
kind?: string;
|
|
35
|
+
status?: string;
|
|
36
|
+
text?: string;
|
|
37
|
+
labels?: string[];
|
|
38
|
+
limit?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ArtifactGraphOptions {
|
|
42
|
+
tree?: boolean;
|
|
43
|
+
depth?: number;
|
|
44
|
+
maxNodes?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ArtifactLink {
|
|
48
|
+
from: string;
|
|
49
|
+
relation: string;
|
|
50
|
+
to: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RelationshipQuery {
|
|
54
|
+
kind?: string;
|
|
55
|
+
artifactIds?: string[];
|
|
56
|
+
}
|