deepclause-pi 0.1.4 → 0.2.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 +23 -0
- package/dist/diagram/extract.d.ts +5 -0
- package/dist/diagram/extract.js +701 -0
- package/dist/diagram/grade.d.ts +41 -0
- package/dist/diagram/grade.js +70 -0
- package/dist/diagram/validate.d.ts +36 -0
- package/dist/diagram/validate.js +148 -0
- package/dist/diagram/viewer.d.ts +30 -0
- package/dist/diagram/viewer.js +94 -0
- package/dist/diagram/workspace.d.ts +24 -0
- package/dist/diagram/workspace.js +106 -0
- package/dist/index.js +142 -2
- package/dist/model.d.ts +16 -0
- package/dist/model.js +28 -0
- package/docs/BLOG_POST_PI_EXTENSION.md +253 -0
- package/docs/DIAGRAM_INTEGRATION_PROPOSAL.md +154 -0
- package/package.json +6 -2
- package/skills/handbook-dml/SKILL.md +265 -0
- package/src/assets/AGENTS.md +16 -0
- package/src/assets/vendor/mermaid.min.js +3636 -0
- package/src/assets/viewer.template.html +319 -0
- package/src/diagram/extract.ts +721 -0
- package/src/diagram/grade.ts +104 -0
- package/src/diagram/validate.ts +188 -0
- package/src/diagram/viewer.ts +133 -0
- package/src/diagram/workspace.ts +109 -0
- package/src/index.ts +158 -2
- package/src/model.ts +46 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { CheckResult, MermaidView } from "./validate.js";
|
|
2
|
+
export type DiagramGrade = "presentation" | "specification";
|
|
3
|
+
export type RequestedGrade = DiagramGrade | "both";
|
|
4
|
+
export declare const GRADE_SYSTEM_PROMPT = "You are a meticulous Mermaid v11 diagram editor. You output only valid Mermaid source, never wrapped in code fences, and you keep all facts accurate.";
|
|
5
|
+
export declare const GRADE_PROMPTS: Record<DiagramGrade, string>;
|
|
6
|
+
/**
|
|
7
|
+
* Map free-form user/tool wording onto a supported grade. Returns undefined when
|
|
8
|
+
* nothing matches so the caller can apply its default.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveGrade(text: string): RequestedGrade | undefined;
|
|
11
|
+
export declare function stripFences(raw: string): string;
|
|
12
|
+
export interface CompleteOptions {
|
|
13
|
+
systemPrompt: string;
|
|
14
|
+
prompt: string;
|
|
15
|
+
maxTokens: number;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
}
|
|
18
|
+
export interface PolishOptions {
|
|
19
|
+
grade: DiagramGrade;
|
|
20
|
+
view: MermaidView;
|
|
21
|
+
source: string;
|
|
22
|
+
seed: string;
|
|
23
|
+
maxTokens: number;
|
|
24
|
+
maxRounds?: number;
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
complete: (options: CompleteOptions) => Promise<{
|
|
27
|
+
text: string;
|
|
28
|
+
}>;
|
|
29
|
+
validate: (code: string) => Promise<CheckResult>;
|
|
30
|
+
onProgress?: (message: string) => void;
|
|
31
|
+
}
|
|
32
|
+
export interface PolishResult {
|
|
33
|
+
code: string;
|
|
34
|
+
rounds: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Ask the active pi model to rewrite the deterministic seed in the requested
|
|
38
|
+
* grade, validating each attempt and feeding failures back until it parses or
|
|
39
|
+
* the round budget is exhausted.
|
|
40
|
+
*/
|
|
41
|
+
export declare function polishDiagram(options: PolishOptions): Promise<PolishResult>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export const GRADE_SYSTEM_PROMPT = "You are a meticulous Mermaid v11 diagram editor. You output only valid Mermaid source, never wrapped in code fences, and you keep all facts accurate.";
|
|
2
|
+
export const GRADE_PROMPTS = {
|
|
3
|
+
presentation: "Produce a PRESENTATION-GRADE version: about 8-12 nodes, plain non-technical language, NO function names or framework jargon, highlight the headline numbers and the main steps, and add 1-2 short callouts. Keep it accurate but simple enough for a general audience.",
|
|
4
|
+
specification: "Produce a SPECIFICATION-GRADE (detailed engineering) version: keep the technical detail (function names, task/tool roles, post-conditions, seed data), but improve labels, grouping and readability so an engineer can follow it precisely.",
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Map free-form user/tool wording onto a supported grade. Returns undefined when
|
|
8
|
+
* nothing matches so the caller can apply its default.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveGrade(text) {
|
|
11
|
+
const value = text.toLowerCase();
|
|
12
|
+
if (/\bboth\b/.test(value))
|
|
13
|
+
return "both";
|
|
14
|
+
if (/(specification|spec\b|detailed|technical|engineering|developer)/.test(value))
|
|
15
|
+
return "specification";
|
|
16
|
+
if (/(presentation|present|simple|executive|slide|high[- ]level|overview)/.test(value))
|
|
17
|
+
return "presentation";
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
export function stripFences(raw) {
|
|
21
|
+
return raw
|
|
22
|
+
.split("\n")
|
|
23
|
+
.filter((line) => !line.trimStart().startsWith("```"))
|
|
24
|
+
.join("\n")
|
|
25
|
+
.trim();
|
|
26
|
+
}
|
|
27
|
+
function buildPrompt(grade, view, source, seed, feedback) {
|
|
28
|
+
const syntax = view === "sequence" ? "sequenceDiagram" : "flowchart TD";
|
|
29
|
+
return [
|
|
30
|
+
GRADE_PROMPTS[grade],
|
|
31
|
+
"",
|
|
32
|
+
`Rules: output ONLY Mermaid v11 source (no code fences); keep it a '${syntax}' diagram; keep the facts accurate; do not use reserved words such as 'end' as node ids or class names.`,
|
|
33
|
+
"",
|
|
34
|
+
"DML source:",
|
|
35
|
+
source,
|
|
36
|
+
"",
|
|
37
|
+
"Current diagram:",
|
|
38
|
+
seed,
|
|
39
|
+
"",
|
|
40
|
+
feedback,
|
|
41
|
+
].join("\n");
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Ask the active pi model to rewrite the deterministic seed in the requested
|
|
45
|
+
* grade, validating each attempt and feeding failures back until it parses or
|
|
46
|
+
* the round budget is exhausted.
|
|
47
|
+
*/
|
|
48
|
+
export async function polishDiagram(options) {
|
|
49
|
+
const maxRounds = Math.max(1, Math.min(options.maxRounds ?? 3, 6));
|
|
50
|
+
let feedback = `This is the first attempt at the ${options.grade} grade.`;
|
|
51
|
+
for (let round = 1; round <= maxRounds; round++) {
|
|
52
|
+
options.onProgress?.(`Generating ${options.grade} diagram (attempt ${round}/${maxRounds})…`);
|
|
53
|
+
const response = await options.complete({
|
|
54
|
+
systemPrompt: GRADE_SYSTEM_PROMPT,
|
|
55
|
+
prompt: buildPrompt(options.grade, options.view, options.source, options.seed, feedback),
|
|
56
|
+
maxTokens: options.maxTokens,
|
|
57
|
+
signal: options.signal,
|
|
58
|
+
});
|
|
59
|
+
const candidate = stripFences(response.text);
|
|
60
|
+
if (!candidate) {
|
|
61
|
+
feedback = "Your previous attempt was empty. Return only Mermaid source.";
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const check = await options.validate(candidate);
|
|
65
|
+
if (check.ok)
|
|
66
|
+
return { code: candidate, rounds: round };
|
|
67
|
+
feedback = `Your previous attempt failed validation: ${check.error}. Fix exactly that and return only Mermaid source.`;
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`Could not produce a valid ${options.grade} diagram after ${maxRounds} attempts (${feedback})`);
|
|
70
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type MermaidView = "flow" | "sequence";
|
|
2
|
+
export interface CheckResult {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
error?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface CommandResult {
|
|
7
|
+
stdout: string;
|
|
8
|
+
stderr: string;
|
|
9
|
+
code: number;
|
|
10
|
+
killed: boolean;
|
|
11
|
+
}
|
|
12
|
+
export type RunCommand = (command: string, args: string[], options?: {
|
|
13
|
+
timeout?: number;
|
|
14
|
+
}) => Promise<CommandResult>;
|
|
15
|
+
/**
|
|
16
|
+
* Cheap, dependency-free validation that catches the failure modes we care
|
|
17
|
+
* about: wrong diagram type, unbalanced blocks, unbalanced quotes, reserved
|
|
18
|
+
* node names, and absurdly large output. It cannot prove Mermaid correctness.
|
|
19
|
+
*/
|
|
20
|
+
export declare function structuralCheck(code: string, view: MermaidView): CheckResult;
|
|
21
|
+
export declare function findChrome(run: RunCommand): Promise<string | undefined>;
|
|
22
|
+
export declare function chromeMermaidCheck(code: string, chrome: string, vendorDir: string, run: RunCommand): Promise<CheckResult>;
|
|
23
|
+
export interface ValidateOptions {
|
|
24
|
+
run?: RunCommand;
|
|
25
|
+
vendorDir?: string;
|
|
26
|
+
chrome?: string | null;
|
|
27
|
+
}
|
|
28
|
+
export interface ValidationOutcome {
|
|
29
|
+
result: CheckResult;
|
|
30
|
+
gate: "structural" | "chrome";
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Structural check always runs. When a Chrome binary and a vendored Mermaid
|
|
34
|
+
* copy are available, the real Mermaid parser is used as a second gate.
|
|
35
|
+
*/
|
|
36
|
+
export declare function validateMermaid(code: string, view: MermaidView, options?: ValidateOptions): Promise<ValidationOutcome>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { access, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
const CHROME_CANDIDATES = [
|
|
6
|
+
"google-chrome",
|
|
7
|
+
"google-chrome-stable",
|
|
8
|
+
"chromium",
|
|
9
|
+
"chromium-browser",
|
|
10
|
+
"brave-browser",
|
|
11
|
+
];
|
|
12
|
+
const SEQUENCE_OPENERS = /^\s*(?:alt|loop|opt|par|critical|break|rect|box)\b/gm;
|
|
13
|
+
const FLOW_OPENERS = /^\s*subgraph\b/gm;
|
|
14
|
+
const CLOSERS = /^\s*end\s*$/gm;
|
|
15
|
+
function countMatches(source, pattern) {
|
|
16
|
+
pattern.lastIndex = 0;
|
|
17
|
+
let count = 0;
|
|
18
|
+
while (pattern.exec(source))
|
|
19
|
+
count++;
|
|
20
|
+
return count;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Cheap, dependency-free validation that catches the failure modes we care
|
|
24
|
+
* about: wrong diagram type, unbalanced blocks, unbalanced quotes, reserved
|
|
25
|
+
* node names, and absurdly large output. It cannot prove Mermaid correctness.
|
|
26
|
+
*/
|
|
27
|
+
export function structuralCheck(code, view) {
|
|
28
|
+
const trimmed = code.trim();
|
|
29
|
+
if (!trimmed)
|
|
30
|
+
return { ok: false, error: "the diagram is empty" };
|
|
31
|
+
const lines = trimmed.split("\n");
|
|
32
|
+
const header = (lines[0] ?? "").trim();
|
|
33
|
+
if (view === "sequence") {
|
|
34
|
+
if (!/^sequenceDiagram\b/.test(header)) {
|
|
35
|
+
return { ok: false, error: "expected a 'sequenceDiagram' header" };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else if (!/^(?:flowchart|graph)\b/.test(header)) {
|
|
39
|
+
return { ok: false, error: "expected a 'flowchart' or 'graph' header" };
|
|
40
|
+
}
|
|
41
|
+
if (trimmed.length > 200_000)
|
|
42
|
+
return { ok: false, error: "the diagram is too large" };
|
|
43
|
+
if (lines.length > 5_000)
|
|
44
|
+
return { ok: false, error: "the diagram has too many lines" };
|
|
45
|
+
if (((trimmed.match(/"/g) ?? []).length) % 2 !== 0) {
|
|
46
|
+
return { ok: false, error: "unbalanced double quotes" };
|
|
47
|
+
}
|
|
48
|
+
if (view === "flow" && /^\s*end\s*[\[\(\{]/m.test(trimmed)) {
|
|
49
|
+
return { ok: false, error: "'end' cannot be used as a node id" };
|
|
50
|
+
}
|
|
51
|
+
const openers = countMatches(trimmed, view === "sequence" ? SEQUENCE_OPENERS : FLOW_OPENERS);
|
|
52
|
+
const closers = countMatches(trimmed, CLOSERS);
|
|
53
|
+
if (openers !== closers) {
|
|
54
|
+
return { ok: false, error: `unbalanced blocks: ${openers} opener(s) but ${closers} 'end' line(s)` };
|
|
55
|
+
}
|
|
56
|
+
return { ok: true };
|
|
57
|
+
}
|
|
58
|
+
export async function findChrome(run) {
|
|
59
|
+
const envPath = process.env.CHROME_PATH;
|
|
60
|
+
if (envPath) {
|
|
61
|
+
try {
|
|
62
|
+
await access(envPath, constants.X_OK);
|
|
63
|
+
return envPath;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Ignore an invalid CHROME_PATH and fall back to discovery.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const binary of CHROME_CANDIDATES) {
|
|
70
|
+
try {
|
|
71
|
+
const result = await run("which", [binary], { timeout: 3_000 });
|
|
72
|
+
if (result.code === 0 && result.stdout.trim())
|
|
73
|
+
return binary;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// `which` missing or the candidate is absent; try the next one.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
function decodeEntities(value) {
|
|
82
|
+
return value
|
|
83
|
+
.replace(/&/g, "&")
|
|
84
|
+
.replace(/</g, "<")
|
|
85
|
+
.replace(/>/g, ">")
|
|
86
|
+
.replace(/"/g, '"')
|
|
87
|
+
.replace(/'/g, "'");
|
|
88
|
+
}
|
|
89
|
+
export async function chromeMermaidCheck(code, chrome, vendorDir, run) {
|
|
90
|
+
const tmp = path.join(vendorDir, `.check-${process.pid}-${Math.random().toString(36).slice(2, 8)}.html`);
|
|
91
|
+
const html = `<!doctype html><meta charset="utf-8">
|
|
92
|
+
<script>window.__CODE__ = ${JSON.stringify(code).replace(/</g, "\\u003c")};</script>
|
|
93
|
+
<script src="mermaid.min.js"></script>
|
|
94
|
+
<script>
|
|
95
|
+
window.addEventListener('DOMContentLoaded', async () => {
|
|
96
|
+
let out;
|
|
97
|
+
try {
|
|
98
|
+
mermaid.initialize({ startOnLoad: false, securityLevel: 'loose' });
|
|
99
|
+
await mermaid.parse(window.__CODE__);
|
|
100
|
+
out = 'OK';
|
|
101
|
+
} catch (e) {
|
|
102
|
+
out = 'ERROR: ' + ((e && e.message) || String(e));
|
|
103
|
+
}
|
|
104
|
+
const pre = document.createElement('pre');
|
|
105
|
+
pre.id = 'result';
|
|
106
|
+
pre.textContent = out;
|
|
107
|
+
document.body.appendChild(pre);
|
|
108
|
+
});
|
|
109
|
+
</script>`;
|
|
110
|
+
try {
|
|
111
|
+
await writeFile(tmp, html, "utf8");
|
|
112
|
+
const result = await run(chrome, [
|
|
113
|
+
"--headless=new",
|
|
114
|
+
"--disable-gpu",
|
|
115
|
+
"--no-sandbox",
|
|
116
|
+
"--virtual-time-budget=6000",
|
|
117
|
+
"--dump-dom",
|
|
118
|
+
pathToFileURL(tmp).href,
|
|
119
|
+
], { timeout: 60_000 });
|
|
120
|
+
const match = /<pre id="result">([\s\S]*?)<\/pre>/.exec(result.stdout);
|
|
121
|
+
if (!match)
|
|
122
|
+
return { ok: false, error: "the Mermaid validator produced no result" };
|
|
123
|
+
const output = decodeEntities(match[1] ?? "").trim();
|
|
124
|
+
return output === "OK" ? { ok: true } : { ok: false, error: output };
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
await rm(tmp, { force: true }).catch(() => { });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Structural check always runs. When a Chrome binary and a vendored Mermaid
|
|
135
|
+
* copy are available, the real Mermaid parser is used as a second gate.
|
|
136
|
+
*/
|
|
137
|
+
export async function validateMermaid(code, view, options = {}) {
|
|
138
|
+
const structural = structuralCheck(code, view);
|
|
139
|
+
if (!structural.ok)
|
|
140
|
+
return { result: structural, gate: "structural" };
|
|
141
|
+
if (options.run && options.vendorDir) {
|
|
142
|
+
const chrome = options.chrome === undefined ? await findChrome(options.run) : options.chrome;
|
|
143
|
+
if (chrome) {
|
|
144
|
+
return { result: await chromeMermaidCheck(code, chrome, options.vendorDir, options.run), gate: "chrome" };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { result: structural, gate: "structural" };
|
|
148
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { DiagramGrade } from "./grade.js";
|
|
3
|
+
export interface DiagramEntry {
|
|
4
|
+
name: string;
|
|
5
|
+
path: string;
|
|
6
|
+
flow: string;
|
|
7
|
+
seq: string;
|
|
8
|
+
dml: string;
|
|
9
|
+
presentation: string | null;
|
|
10
|
+
specification: string | null;
|
|
11
|
+
}
|
|
12
|
+
export interface ViewerBuildResult {
|
|
13
|
+
viewerPath: string;
|
|
14
|
+
diagramsDir: string;
|
|
15
|
+
entries: DiagramEntry[];
|
|
16
|
+
}
|
|
17
|
+
export interface BuildViewerOptions {
|
|
18
|
+
cwd: string;
|
|
19
|
+
templateText: string;
|
|
20
|
+
vendorAssetPath: string;
|
|
21
|
+
extraPaths?: string[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Extract every target DML, collect the optional presentation/specification
|
|
25
|
+
* sidecars, and write the self-contained viewer plus per-skill Markdown.
|
|
26
|
+
*/
|
|
27
|
+
export declare function buildViewer(options: BuildViewerOptions): Promise<ViewerBuildResult>;
|
|
28
|
+
export declare function writeSidecar(diagramsDir: string, name: string, grade: DiagramGrade, code: string): Promise<string>;
|
|
29
|
+
/** Open the viewer at a specific diagram and view using a fixed, OS-native argv. */
|
|
30
|
+
export declare function openViewerInBrowser(pi: Pick<ExtensionAPI, "exec">, viewerPath: string, name: string, view: string): Promise<boolean>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { renderDml, renderSequence } from "./extract.js";
|
|
5
|
+
import { collectDiagramTargets, diagramNameFor, displayPath, ensureDiagramDir, } from "./workspace.js";
|
|
6
|
+
async function readSidecar(diagramsDir, name, grade) {
|
|
7
|
+
try {
|
|
8
|
+
const value = (await readFile(path.join(diagramsDir, `${name}.${grade}.mmd`), "utf8")).trim();
|
|
9
|
+
return value || null;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function markdownFor(entry) {
|
|
16
|
+
const blocks = [];
|
|
17
|
+
if (entry.presentation)
|
|
18
|
+
blocks.push(`## Presentation\n\n\`\`\`mermaid\n${entry.presentation}\n\`\`\``);
|
|
19
|
+
if (entry.specification)
|
|
20
|
+
blocks.push(`## Specification\n\n\`\`\`mermaid\n${entry.specification}\n\`\`\``);
|
|
21
|
+
blocks.push(`## Flow\n\n\`\`\`mermaid\n${entry.flow}\n\`\`\``);
|
|
22
|
+
blocks.push(`## Sequence\n\n\`\`\`mermaid\n${entry.seq}\n\`\`\``);
|
|
23
|
+
return blocks.join("\n\n") + "\n";
|
|
24
|
+
}
|
|
25
|
+
async function renderViewer(diagramsDir, entries, templateText, vendorFile) {
|
|
26
|
+
const json = JSON.stringify(entries).replace(/</g, "\\u003c");
|
|
27
|
+
const generated = new Date().toISOString().slice(0, 16).replace("T", " ");
|
|
28
|
+
const html = templateText
|
|
29
|
+
.replace("__DIAGRAMS_JSON__", json)
|
|
30
|
+
.replace("__GENERATED__", generated)
|
|
31
|
+
.replace("vendor/mermaid.min.js", pathToFileURL(vendorFile).href);
|
|
32
|
+
const viewerPath = path.join(diagramsDir, "viewer.html");
|
|
33
|
+
await writeFile(viewerPath, html, "utf8");
|
|
34
|
+
const manifest = entries.map((entry) => ({
|
|
35
|
+
name: entry.name,
|
|
36
|
+
path: entry.path,
|
|
37
|
+
views: [
|
|
38
|
+
entry.presentation ? "presentation" : null,
|
|
39
|
+
entry.specification ? "specification" : null,
|
|
40
|
+
"flow",
|
|
41
|
+
"sequence",
|
|
42
|
+
].filter(Boolean),
|
|
43
|
+
}));
|
|
44
|
+
await writeFile(path.join(diagramsDir, "index.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
45
|
+
return viewerPath;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Extract every target DML, collect the optional presentation/specification
|
|
49
|
+
* sidecars, and write the self-contained viewer plus per-skill Markdown.
|
|
50
|
+
*/
|
|
51
|
+
export async function buildViewer(options) {
|
|
52
|
+
const { diagrams, vendorFile } = await ensureDiagramDir(options.cwd, options.vendorAssetPath);
|
|
53
|
+
const targets = await collectDiagramTargets(options.cwd, options.extraPaths ?? []);
|
|
54
|
+
const entries = [];
|
|
55
|
+
for (const target of targets) {
|
|
56
|
+
const name = diagramNameFor(target, targets, options.cwd);
|
|
57
|
+
const display = displayPath(options.cwd, target);
|
|
58
|
+
const source = await readFile(target, "utf8");
|
|
59
|
+
const entry = {
|
|
60
|
+
name,
|
|
61
|
+
path: display,
|
|
62
|
+
flow: renderDml(display, source, { hideOutput: true }),
|
|
63
|
+
seq: renderSequence(display, source),
|
|
64
|
+
dml: source,
|
|
65
|
+
presentation: await readSidecar(diagrams, name, "presentation"),
|
|
66
|
+
specification: await readSidecar(diagrams, name, "specification"),
|
|
67
|
+
};
|
|
68
|
+
entries.push(entry);
|
|
69
|
+
await writeFile(path.join(diagrams, `${name}.md`), markdownFor(entry), "utf8");
|
|
70
|
+
}
|
|
71
|
+
const viewerPath = await renderViewer(diagrams, entries, options.templateText, vendorFile);
|
|
72
|
+
return { viewerPath, diagramsDir: diagrams, entries };
|
|
73
|
+
}
|
|
74
|
+
export async function writeSidecar(diagramsDir, name, grade, code) {
|
|
75
|
+
const file = path.join(diagramsDir, `${name}.${grade}.mmd`);
|
|
76
|
+
await writeFile(file, code.endsWith("\n") ? code : `${code}\n`, "utf8");
|
|
77
|
+
return file;
|
|
78
|
+
}
|
|
79
|
+
/** Open the viewer at a specific diagram and view using a fixed, OS-native argv. */
|
|
80
|
+
export async function openViewerInBrowser(pi, viewerPath, name, view) {
|
|
81
|
+
const url = `${pathToFileURL(viewerPath).href}?view=${encodeURIComponent(view)}#${encodeURIComponent(name)}`;
|
|
82
|
+
try {
|
|
83
|
+
if (process.platform === "darwin")
|
|
84
|
+
await pi.exec("open", [url], { timeout: 10_000 });
|
|
85
|
+
else if (process.platform === "win32")
|
|
86
|
+
await pi.exec("cmd", ["/c", "start", "", url], { timeout: 10_000 });
|
|
87
|
+
else
|
|
88
|
+
await pi.exec("xdg-open", [url], { timeout: 10_000 });
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Resolve an arbitrary .dml path (relative to cwd or absolute) for diagramming. */
|
|
2
|
+
export declare function resolveDiagramSource(cwd: string, request: string): Promise<string>;
|
|
3
|
+
/**
|
|
4
|
+
* Every DML file that should appear in the viewer: all skills and plans in the
|
|
5
|
+
* active workspace, plus any extra target (which may live anywhere).
|
|
6
|
+
*/
|
|
7
|
+
export declare function collectDiagramTargets(cwd: string, extra?: string[]): Promise<string[]>;
|
|
8
|
+
/**
|
|
9
|
+
* Stable viewer/sidecar name. Same-named DML files in different directories get
|
|
10
|
+
* a short path hash so they can never collide.
|
|
11
|
+
*/
|
|
12
|
+
export declare function diagramNameFor(target: string, all: string[], cwd: string): string;
|
|
13
|
+
export declare function displayPath(cwd: string, target: string): string;
|
|
14
|
+
export interface DiagramDir {
|
|
15
|
+
root: string;
|
|
16
|
+
diagrams: string;
|
|
17
|
+
vendor: string;
|
|
18
|
+
vendorFile: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Create .pi/deepclause/diagrams/ and place the vendored Mermaid bundle so the
|
|
22
|
+
* viewer works offline. Existing files are never overwritten.
|
|
23
|
+
*/
|
|
24
|
+
export declare function ensureDiagramDir(cwd: string, vendorAssetPath: string): Promise<DiagramDir>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { access, copyFile, mkdir, readdir, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getPaths } from "../workspace.js";
|
|
4
|
+
/** Resolve an arbitrary .dml path (relative to cwd or absolute) for diagramming. */
|
|
5
|
+
export async function resolveDiagramSource(cwd, request) {
|
|
6
|
+
const raw = request.trim().replace(/^@/, "");
|
|
7
|
+
if (!raw)
|
|
8
|
+
throw new Error("A .dml path is required");
|
|
9
|
+
const absolute = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
|
|
10
|
+
let resolved;
|
|
11
|
+
try {
|
|
12
|
+
resolved = await realpath(absolute);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error.code === "ENOENT")
|
|
16
|
+
throw new Error(`DML file not found: ${request}`);
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
if (!resolved.endsWith(".dml"))
|
|
20
|
+
throw new Error("The diagram source must be a .dml file");
|
|
21
|
+
const info = await stat(resolved);
|
|
22
|
+
if (!info.isFile())
|
|
23
|
+
throw new Error(`Not a file: ${request}`);
|
|
24
|
+
return resolved;
|
|
25
|
+
}
|
|
26
|
+
async function walkDmlFiles(directory) {
|
|
27
|
+
let entries;
|
|
28
|
+
try {
|
|
29
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error.code === "ENOENT")
|
|
33
|
+
return [];
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
const files = [];
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
const full = path.join(directory, entry.name);
|
|
39
|
+
if (entry.isDirectory())
|
|
40
|
+
files.push(...await walkDmlFiles(full));
|
|
41
|
+
else if (entry.isFile() && entry.name.endsWith(".dml"))
|
|
42
|
+
files.push(full);
|
|
43
|
+
}
|
|
44
|
+
return files;
|
|
45
|
+
}
|
|
46
|
+
async function canonical(file) {
|
|
47
|
+
try {
|
|
48
|
+
return await realpath(file);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return path.resolve(file);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Every DML file that should appear in the viewer: all skills and plans in the
|
|
56
|
+
* active workspace, plus any extra target (which may live anywhere).
|
|
57
|
+
*/
|
|
58
|
+
export async function collectDiagramTargets(cwd, extra = []) {
|
|
59
|
+
const paths = getPaths(cwd);
|
|
60
|
+
const found = new Set();
|
|
61
|
+
for (const file of [...await walkDmlFiles(paths.skills), ...await walkDmlFiles(paths.plans), ...extra]) {
|
|
62
|
+
found.add(await canonical(file));
|
|
63
|
+
}
|
|
64
|
+
return [...found].sort();
|
|
65
|
+
}
|
|
66
|
+
function shortHash(value) {
|
|
67
|
+
let hash = 5381;
|
|
68
|
+
for (let index = 0; index < value.length; index++) {
|
|
69
|
+
hash = ((hash * 33) ^ value.charCodeAt(index)) >>> 0;
|
|
70
|
+
}
|
|
71
|
+
return hash.toString(16).padStart(8, "0").slice(0, 4);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Stable viewer/sidecar name. Same-named DML files in different directories get
|
|
75
|
+
* a short path hash so they can never collide.
|
|
76
|
+
*/
|
|
77
|
+
export function diagramNameFor(target, all, cwd) {
|
|
78
|
+
const base = path.basename(target, ".dml");
|
|
79
|
+
const sameBase = all.filter((candidate) => path.basename(candidate, ".dml") === base);
|
|
80
|
+
if (sameBase.length <= 1)
|
|
81
|
+
return base;
|
|
82
|
+
return `${base}-${shortHash(path.relative(cwd, target))}`;
|
|
83
|
+
}
|
|
84
|
+
export function displayPath(cwd, target) {
|
|
85
|
+
const relative = path.relative(cwd, target);
|
|
86
|
+
return relative && !relative.startsWith("..") && !path.isAbsolute(relative)
|
|
87
|
+
? relative.split(path.sep).join("/")
|
|
88
|
+
: target;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Create .pi/deepclause/diagrams/ and place the vendored Mermaid bundle so the
|
|
92
|
+
* viewer works offline. Existing files are never overwritten.
|
|
93
|
+
*/
|
|
94
|
+
export async function ensureDiagramDir(cwd, vendorAssetPath) {
|
|
95
|
+
const diagrams = path.join(getPaths(cwd).root, "diagrams");
|
|
96
|
+
const vendor = path.join(diagrams, "vendor");
|
|
97
|
+
await mkdir(vendor, { recursive: true });
|
|
98
|
+
const vendorFile = path.join(vendor, "mermaid.min.js");
|
|
99
|
+
try {
|
|
100
|
+
await access(vendorFile);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
await copyFile(vendorAssetPath, vendorFile);
|
|
104
|
+
}
|
|
105
|
+
return { root: getPaths(cwd).root, diagrams, vendor, vendorFile };
|
|
106
|
+
}
|