@leon135/opencode-learn 0.0.0-stage → 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 -2
- package/assets/thumbnail.png +0 -0
- package/package.json +42 -4
- package/plugins/agents.ts +211 -0
- package/plugins/index.ts +23 -0
- package/plugins/md-link/core.ts +238 -0
- package/plugins/md-link/index.ts +314 -0
- package/plugins/md-link/tui.ts +360 -0
- package/plugins/quiz.ts +722 -0
- package/plugins/skills.ts +46 -0
- package/plugins/tui.ts +17 -0
- package/plugins/viz/common.ts +152 -0
- package/plugins/viz/index.ts +245 -0
- package/plugins/viz/tui.ts +104 -0
- package/skills/teach/SKILL.md +179 -0
- package/skills/visualize/SKILL.md +78 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { fileURLToPath } from "node:url"
|
|
3
|
+
import { Plugin, Skill } from "@opencode/plugin"
|
|
4
|
+
|
|
5
|
+
const BUNDLED = [
|
|
6
|
+
{ id: "teach", file: "../skills/teach/SKILL.md" },
|
|
7
|
+
{ id: "visualize", file: "../skills/visualize/SKILL.md" },
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
function frontmatterField(frontmatter: string, key: string): string | undefined {
|
|
11
|
+
const line = frontmatter.split(/\r?\n/).find((candidate) => candidate.startsWith(`${key}:`))
|
|
12
|
+
if (line === undefined) return undefined
|
|
13
|
+
return line
|
|
14
|
+
.slice(key.length + 1)
|
|
15
|
+
.trim()
|
|
16
|
+
.replace(/^(['"])(.*)\1$/, "$2")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readSkill(id: string, file: string) {
|
|
20
|
+
const path = fileURLToPath(new URL(file, import.meta.url))
|
|
21
|
+
const raw = readFileSync(path, "utf8")
|
|
22
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw)
|
|
23
|
+
const frontmatter = match?.[1] ?? ""
|
|
24
|
+
return {
|
|
25
|
+
id,
|
|
26
|
+
name: frontmatterField(frontmatter, "name") ?? id,
|
|
27
|
+
description: frontmatterField(frontmatter, "description"),
|
|
28
|
+
path,
|
|
29
|
+
content: (match ? raw.slice(match[0].length) : raw).trim(),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function setupSkills(ctx: Plugin.Context): Promise<void> {
|
|
34
|
+
const skills = BUNDLED.map(({ id, file }) => readSkill(id, file))
|
|
35
|
+
await ctx.skill.transform((editor) => {
|
|
36
|
+
for (const skill of skills) {
|
|
37
|
+
editor.add({
|
|
38
|
+
id: Skill.ID.make(skill.id),
|
|
39
|
+
name: Skill.Name.make(skill.name),
|
|
40
|
+
description: skill.description,
|
|
41
|
+
path: Skill.Info.fields.path.make(skill.path),
|
|
42
|
+
content: skill.content,
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
}
|
package/plugins/tui.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/tui"
|
|
2
|
+
import { setupMdLinkTui } from "./md-link/tui.ts"
|
|
3
|
+
import { setupVizTui } from "./viz/tui.ts"
|
|
4
|
+
|
|
5
|
+
export default Plugin.define({
|
|
6
|
+
id: "opencode-learn.tui",
|
|
7
|
+
async setup(ctx) {
|
|
8
|
+
const cleanups: (Plugin.Cleanup | void)[] = []
|
|
9
|
+
cleanups.push(await setupVizTui(ctx))
|
|
10
|
+
cleanups.push(await setupMdLinkTui(ctx))
|
|
11
|
+
return () => {
|
|
12
|
+
for (const cleanup of cleanups.reverse()) {
|
|
13
|
+
if (typeof cleanup === "function") cleanup()
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
})
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the learn visual authoring loops (write_/edit_/render_
|
|
3
|
+
* trios for SVG and Mermaid, registered by plugins/viz/index.ts):
|
|
4
|
+
* subprocess runner, per-session managed source file, exact-match editor,
|
|
5
|
+
* and publishing a chosen render into <worktree>/viz with a unique filename.
|
|
6
|
+
*
|
|
7
|
+
* Ported from the pi visual-tools extension's _common.ts. Differences vs the
|
|
8
|
+
* original: macOS PATH extras dropped (Linux binaries live in /usr/bin),
|
|
9
|
+
* staging moved under /tmp/opencode, and sessions are keyed by OpenCode
|
|
10
|
+
* sessionID (one server process serves many sessions) instead of pid.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawn } from "node:child_process"
|
|
14
|
+
import { tmpdir } from "node:os"
|
|
15
|
+
import { join } from "node:path"
|
|
16
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
17
|
+
|
|
18
|
+
export const RENDER_TIMEOUT_MS_DEFAULT = 120_000
|
|
19
|
+
|
|
20
|
+
/** Transient session/preview files live outside any vault: only PUBLISHED
|
|
21
|
+
* PNGs ever land inside the learner's project (viz/). */
|
|
22
|
+
export const STAGING_ROOT = join(tmpdir(), "opencode", "viz-tools")
|
|
23
|
+
export const FILES_DIRNAME = "viz"
|
|
24
|
+
|
|
25
|
+
export interface RunResult {
|
|
26
|
+
code: number | null
|
|
27
|
+
stdout: string
|
|
28
|
+
stderr: string
|
|
29
|
+
timedOut: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function run(
|
|
33
|
+
cmd: string,
|
|
34
|
+
args: string[],
|
|
35
|
+
opts: { cwd?: string; timeoutMs?: number },
|
|
36
|
+
): Promise<RunResult> {
|
|
37
|
+
return new Promise((resolveRun) => {
|
|
38
|
+
const child = spawn(cmd, args, {
|
|
39
|
+
cwd: opts.cwd,
|
|
40
|
+
env: process.env,
|
|
41
|
+
})
|
|
42
|
+
let stdout = ""
|
|
43
|
+
let stderr = ""
|
|
44
|
+
let timedOut = false
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
timedOut = true
|
|
47
|
+
child.kill("SIGKILL")
|
|
48
|
+
}, opts.timeoutMs ?? RENDER_TIMEOUT_MS_DEFAULT)
|
|
49
|
+
child.stdout.on("data", (d) => (stdout += d.toString()))
|
|
50
|
+
child.stderr.on("data", (d) => (stderr += d.toString()))
|
|
51
|
+
child.on("error", (err) => {
|
|
52
|
+
clearTimeout(timer)
|
|
53
|
+
resolveRun({ code: null, stdout, stderr: stderr + String(err), timedOut })
|
|
54
|
+
})
|
|
55
|
+
child.on("close", (code) => {
|
|
56
|
+
clearTimeout(timer)
|
|
57
|
+
resolveRun({ code, stdout, stderr, timedOut })
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Per-session managed source file state. */
|
|
63
|
+
export interface Session {
|
|
64
|
+
workDir: string
|
|
65
|
+
bodyPath: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const sessions = new Map<string, Session>()
|
|
69
|
+
|
|
70
|
+
function sessionKey(group: string, sessionID: string): string {
|
|
71
|
+
// Sanitize: sessionIDs are [A-Za-z0-9_] but never trust wire data for paths.
|
|
72
|
+
const sid = sessionID.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 64) || "anon"
|
|
73
|
+
return `${group}-${sid}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Write the full source to the session's managed file, creating its work dir. */
|
|
77
|
+
export function writeBody(
|
|
78
|
+
group: string,
|
|
79
|
+
bodyFileName: string,
|
|
80
|
+
source: string,
|
|
81
|
+
sessionID: string,
|
|
82
|
+
): Session {
|
|
83
|
+
const key = sessionKey(group, sessionID)
|
|
84
|
+
const workDir = join(STAGING_ROOT, key)
|
|
85
|
+
mkdirSync(workDir, { recursive: true })
|
|
86
|
+
const bodyPath = join(workDir, bodyFileName)
|
|
87
|
+
writeFileSync(bodyPath, source, "utf8")
|
|
88
|
+
const s: Session = { workDir, bodyPath }
|
|
89
|
+
sessions.set(key, s)
|
|
90
|
+
return s
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Current managed file for this group+session, or undefined if none yet. */
|
|
94
|
+
export function currentBody(group: string, sessionID: string): Session | undefined {
|
|
95
|
+
return sessions.get(sessionKey(group, sessionID))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Exact-match single replacement on the current source: old_text must appear
|
|
100
|
+
* exactly once. Returns the updated content and the match offset, or throws
|
|
101
|
+
* a precise error.
|
|
102
|
+
*/
|
|
103
|
+
export function applyEdit(current: string, oldText: string, newText: string): { updated: string; index: number } {
|
|
104
|
+
if (oldText === "") throw new Error("`old_text` must be non-empty.")
|
|
105
|
+
if (oldText === newText) throw new Error("`old_text` and `new_text` are identical.")
|
|
106
|
+
const first = current.indexOf(oldText)
|
|
107
|
+
if (first === -1) {
|
|
108
|
+
throw new Error("`old_text` not found in the current source — match it exactly.")
|
|
109
|
+
}
|
|
110
|
+
const second = current.indexOf(oldText, first + 1)
|
|
111
|
+
if (second !== -1) {
|
|
112
|
+
let n = 0
|
|
113
|
+
let i = current.indexOf(oldText)
|
|
114
|
+
while (i !== -1) {
|
|
115
|
+
n++
|
|
116
|
+
i = current.indexOf(oldText, i + oldText.length)
|
|
117
|
+
}
|
|
118
|
+
throw new Error(`\`old_text\` appears ${n} times — add surrounding context to make it unique.`)
|
|
119
|
+
}
|
|
120
|
+
const updated = current.slice(0, first) + newText + current.slice(first + oldText.length)
|
|
121
|
+
return { updated, index: first }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A small numbered window of `content` around char offset `index`. */
|
|
125
|
+
export function snippetAround(content: string, index: number, contextLines = 3): string {
|
|
126
|
+
const before = content.slice(0, index)
|
|
127
|
+
const hitLine = before.split("\n").length - 1
|
|
128
|
+
const lines = content.split("\n")
|
|
129
|
+
const start = Math.max(0, hitLine - contextLines)
|
|
130
|
+
const end = Math.min(lines.length - 1, hitLine + contextLines)
|
|
131
|
+
const width = String(end + 1).length
|
|
132
|
+
const out: string[] = []
|
|
133
|
+
for (let i = start; i <= end; i++) out.push(`${String(i + 1).padStart(width)} ${lines[i]}`)
|
|
134
|
+
return out.join("\n")
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Copy a rendered PNG into `filesDir` (the exact output directory) with a unique, slugified name. */
|
|
138
|
+
export function publish(pngPath: string, slug: string, filesDir?: string): { filename: string; path: string } {
|
|
139
|
+
const dir = filesDir || join(process.cwd(), FILES_DIRNAME)
|
|
140
|
+
mkdirSync(dir, { recursive: true })
|
|
141
|
+
const clean =
|
|
142
|
+
slug
|
|
143
|
+
.toLowerCase()
|
|
144
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
145
|
+
.replace(/^-+|-+$/g, "") || "viz"
|
|
146
|
+
const filename = `viz-${clean}-${Date.now()}.png`
|
|
147
|
+
const dest = join(dir, filename)
|
|
148
|
+
copyFileSync(pngPath, dest)
|
|
149
|
+
return { filename, path: dest }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export { existsSync, join, mkdirSync, readFileSync, writeFileSync }
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { isAbsolute } from "node:path"
|
|
3
|
+
import type { Plugin } from "@opencode/plugin"
|
|
4
|
+
import {
|
|
5
|
+
applyEdit,
|
|
6
|
+
currentBody,
|
|
7
|
+
existsSync,
|
|
8
|
+
FILES_DIRNAME,
|
|
9
|
+
join,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
publish,
|
|
12
|
+
readFileSync,
|
|
13
|
+
run,
|
|
14
|
+
snippetAround,
|
|
15
|
+
writeBody,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from "./common.ts"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* opencode-learn (viz tools) — the visual maker authoring loops as v2 custom tools.
|
|
21
|
+
*
|
|
22
|
+
* Ported from the pi visual-tools extension (extensions/visual-tools/):
|
|
23
|
+
* svg → write_svg / edit_svg / render_svg (rsvg-convert, fallback magick)
|
|
24
|
+
* mermaid → write_mermaid / edit_mermaid / render_mermaid (mmdc from PATH)
|
|
25
|
+
*
|
|
26
|
+
* Each trio shares ONE per-session managed source file (keyed by OpenCode
|
|
27
|
+
* sessionID when the harness provides it, else a shared key), so a maker
|
|
28
|
+
* writes, exact-match-edits, and renders without naming files.
|
|
29
|
+
*
|
|
30
|
+
* v2 beta constraint (see ocv2-findings): custom tool results cannot deliver
|
|
31
|
+
* images to the model. render_* therefore returns the PNG's absolute path and
|
|
32
|
+
* instructs the agent to LOOK at it via the native read tool — same
|
|
33
|
+
* render-and-inspect loop, one extra step.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const SVG_GROUP = "svg"
|
|
37
|
+
const MERMAID_GROUP = "mermaid"
|
|
38
|
+
const SVG_BODY = "diagram.svg"
|
|
39
|
+
const MERMAID_BODY = "diagram.mmd"
|
|
40
|
+
|
|
41
|
+
/** Session key from the optional per-call context the harness passes execute. */
|
|
42
|
+
function sid(tctx: any): string {
|
|
43
|
+
const s = tctx?.sessionID ?? tctx?.callID ?? tctx?.messageID
|
|
44
|
+
return typeof s === "string" && s.length > 0 ? s : "shared"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function setupViz(ctx: Plugin.Context): Promise<void> {
|
|
48
|
+
// Publish base: the project directory is captured once at setup. A configured
|
|
49
|
+
// defaultDir (viz-state.json, md-link-style) overrides it and is re-read on
|
|
50
|
+
// every publish so /viz-dir changes apply immediately; empty means publish
|
|
51
|
+
// into the project's viz/.
|
|
52
|
+
const projectDir = ctx.location?.directory ?? process.cwd()
|
|
53
|
+
const VIZ_STATE_FILE = join(homedir(), ".config", "opencode", "viz-state.json")
|
|
54
|
+
|
|
55
|
+
function resolvePublishDir(): string {
|
|
56
|
+
try {
|
|
57
|
+
const st = JSON.parse(readFileSync(VIZ_STATE_FILE, "utf8"))
|
|
58
|
+
const configured = typeof st?.defaultDir === "string" ? st.defaultDir.trim() : ""
|
|
59
|
+
if (configured) {
|
|
60
|
+
return isAbsolute(configured) ? configured : join(projectDir, configured)
|
|
61
|
+
}
|
|
62
|
+
} catch {}
|
|
63
|
+
return join(projectDir, FILES_DIRNAME)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── managed-file helpers shared by both trios ────────────────────────────
|
|
67
|
+
function requireBody(group: string, bodyFile: string, tctx: any) {
|
|
68
|
+
const s = currentBody(group, sid(tctx))
|
|
69
|
+
if (!s || !existsSync(s.bodyPath)) return null
|
|
70
|
+
return s
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeWriteTool(group: string, bodyFile: string, kind: string, extraDesc: string) {
|
|
74
|
+
const cap = kind.toUpperCase()
|
|
75
|
+
return {
|
|
76
|
+
name: `write_${kind}`,
|
|
77
|
+
description:
|
|
78
|
+
`Write the FULL ${cap} source to this session's managed file (your first draft or a complete rewrite). ` +
|
|
79
|
+
`You do NOT name the file — edit_${kind} and render_${kind} act on the same one.\n\n` +
|
|
80
|
+
extraDesc +
|
|
81
|
+
` Writing does NOT render — call render_${kind} when ready. For a small fix, prefer edit_${kind} over rewriting.`,
|
|
82
|
+
input: {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: {
|
|
85
|
+
source: { type: "string", description: `The complete ${cap} document.` },
|
|
86
|
+
},
|
|
87
|
+
required: ["source"],
|
|
88
|
+
additionalProperties: false,
|
|
89
|
+
},
|
|
90
|
+
execute: async (input: any, tctx: any) => {
|
|
91
|
+
try {
|
|
92
|
+
const source = String(input.source ?? "").trim()
|
|
93
|
+
if (!source) throw new Error(`\`write_${kind}\` requires a non-empty \`source\`.`)
|
|
94
|
+
if (kind === "svg" && !source.includes("<svg")) {
|
|
95
|
+
throw new Error("`write_svg`: source must be a complete <svg>…</svg> document.")
|
|
96
|
+
}
|
|
97
|
+
const s = writeBody(group, bodyFile, source, sid(tctx))
|
|
98
|
+
const lines = source.split("\n").length
|
|
99
|
+
return {
|
|
100
|
+
content: `Wrote ${lines}-line ${cap} source.\nCall render_${kind} to render it, or edit_${kind} to tweak it.`,
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return { content: `❌ write_${kind} failed: ${error instanceof Error ? error.message : String(error)}` }
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function makeEditTool(kind: string) {
|
|
110
|
+
const cap = kind.toUpperCase()
|
|
111
|
+
return {
|
|
112
|
+
name: `edit_${kind}`,
|
|
113
|
+
description:
|
|
114
|
+
`Make a single exact-match replacement in this session's ${cap} source — the same contract as the built-in edit, locked to the one managed file. ` +
|
|
115
|
+
`\`old_text\` must appear EXACTLY ONCE (include surrounding context for uniqueness); on 0 or >1 matches the call fails and nothing changes. ` +
|
|
116
|
+
`Call write_${kind} first. Editing does NOT render.`,
|
|
117
|
+
input: {
|
|
118
|
+
type: "object",
|
|
119
|
+
properties: {
|
|
120
|
+
old_text: { type: "string", description: "Exact substring of the current source to replace (must match once)." },
|
|
121
|
+
new_text: { type: "string", description: "Replacement text for `old_text`." },
|
|
122
|
+
},
|
|
123
|
+
required: ["old_text", "new_text"],
|
|
124
|
+
additionalProperties: false,
|
|
125
|
+
},
|
|
126
|
+
execute: async (input: any, tctx: any) => {
|
|
127
|
+
try {
|
|
128
|
+
const s = requireBody(kind === "svg" ? SVG_GROUP : MERMAID_GROUP, kind === "svg" ? SVG_BODY : MERMAID_BODY, tctx)
|
|
129
|
+
if (!s) throw new Error(`edit_${kind}: no source yet — call write_${kind} first.`)
|
|
130
|
+
const current = readFileSync(s.bodyPath, "utf8")
|
|
131
|
+
const { updated, index } = applyEdit(current, String(input.old_text ?? ""), String(input.new_text ?? ""))
|
|
132
|
+
writeFileSync(s.bodyPath, updated, "utf8")
|
|
133
|
+
return {
|
|
134
|
+
content:
|
|
135
|
+
`Applied edit. Updated region:\n\`\`\`\n${snippetAround(updated, index)}\n\`\`\`\nCall render_${kind} to see it.`,
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return { content: `❌ edit_${kind} failed: ${error instanceof Error ? error.message : String(error)}` }
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function makeRenderTool(kind: string) {
|
|
145
|
+
const cap = kind.toUpperCase()
|
|
146
|
+
return {
|
|
147
|
+
name: `render_${kind}`,
|
|
148
|
+
description:
|
|
149
|
+
`Render the CURRENT session ${cap} source to a PNG, then OPEN the returned PNG path with the read tool and LOOK at it before continuing — ` +
|
|
150
|
+
`rendering success proves nothing about correctness. You do NOT pass the source here; call write_${kind} first.\n\n` +
|
|
151
|
+
`Iterate freely with no \`save_as\` (preview only). When it is correct and clean, call once more with \`save_as\` set to a short kebab-case topic slug: ` +
|
|
152
|
+
`that publishes the PNG into <project>/viz (or the dir configured via ~/.config/opencode/viz-state.json) with a unique filename and returns the filename to embed. On a render error this returns error text — fix with edit_${kind} and re-render.`,
|
|
153
|
+
input: {
|
|
154
|
+
type: "object",
|
|
155
|
+
properties: {
|
|
156
|
+
save_as: {
|
|
157
|
+
type: "string",
|
|
158
|
+
description:
|
|
159
|
+
"Short kebab-case topic slug (e.g. 'number-line'). When set, publishes to <project>/viz as viz-<slug>-<timestamp>.png. Omit for preview-only.",
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
additionalProperties: false,
|
|
163
|
+
},
|
|
164
|
+
execute: async (input: any, tctx: any) => {
|
|
165
|
+
let outPath = ""
|
|
166
|
+
try {
|
|
167
|
+
const group = kind === "svg" ? SVG_GROUP : MERMAID_GROUP
|
|
168
|
+
const bodyFile = kind === "svg" ? SVG_BODY : MERMAID_BODY
|
|
169
|
+
const s = requireBody(group, bodyFile, tctx)
|
|
170
|
+
if (!s) throw new Error(`render_${kind}: no source yet — call write_${kind} first.`)
|
|
171
|
+
mkdirSync(s.workDir, { recursive: true })
|
|
172
|
+
outPath = join(s.workDir, `render-${Date.now()}.png`)
|
|
173
|
+
|
|
174
|
+
let res =
|
|
175
|
+
kind === "svg"
|
|
176
|
+
? await run("rsvg-convert", ["-z", "2", s.bodyPath, "-o", outPath], {
|
|
177
|
+
cwd: s.workDir,
|
|
178
|
+
timeoutMs: 60_000,
|
|
179
|
+
})
|
|
180
|
+
: await run("mmdc", ["-i", s.bodyPath, "-o", outPath, "-s", "2", "-b", "white"], {
|
|
181
|
+
cwd: s.workDir,
|
|
182
|
+
timeoutMs: 120_000,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
// SVG fallback chain: rsvg-convert → ImageMagick.
|
|
186
|
+
if (kind === "svg" && !(res.code === 0 && existsSync(outPath))) {
|
|
187
|
+
res = await run("magick", ["-density", "192", "-background", "white", s.bodyPath, outPath], {
|
|
188
|
+
cwd: s.workDir,
|
|
189
|
+
timeoutMs: 60_000,
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (res.code !== 0 || !existsSync(outPath)) {
|
|
194
|
+
const detail = (res.stderr || res.stdout || "unknown error").split("\n").slice(-30).join("\n")
|
|
195
|
+
const note = res.timedOut ? `${kind === "svg" ? "SVG" : "mmdc"} render timed out.\n\n` : ""
|
|
196
|
+
return {
|
|
197
|
+
content:
|
|
198
|
+
`${note}${cap} render FAILED — no PNG produced. Fix the source with edit_${kind} and call render_${kind} again.\n\nError:\n${detail}`,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const look = `NOW open ${outPath} with the read tool and LOOK at it. `
|
|
203
|
+
if (input.save_as) {
|
|
204
|
+
const pub = publish(outPath, String(input.save_as), resolvePublishDir())
|
|
205
|
+
return {
|
|
206
|
+
content:
|
|
207
|
+
`Published to viz/.\nfilename: ${pub.filename}\npath: ${pub.path}\n\n${look}Confirm it is correct and true to the brief before returning it.`,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
content:
|
|
212
|
+
`Preview render (not yet saved): ${outPath}\n\n${look}` +
|
|
213
|
+
`Are coordinates/arrows/relationships correct, labels clear, nothing clipped or cramped? ` +
|
|
214
|
+
`Fix with edit_${kind}, or re-render with \`save_as\` to publish.`,
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
return { content: `❌ render_${kind} failed: ${error instanceof Error ? error.message : String(error)}` }
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
await ctx.tool.transform((tools: any) => {
|
|
224
|
+
tools.add(
|
|
225
|
+
makeWriteTool(
|
|
226
|
+
SVG_GROUP,
|
|
227
|
+
SVG_BODY,
|
|
228
|
+
"svg",
|
|
229
|
+
"`source` is a complete `<svg …>…</svg>` document with an explicit width/height (or viewBox), readable font sizes, and a light or transparent background.",
|
|
230
|
+
) as any,
|
|
231
|
+
)
|
|
232
|
+
tools.add(makeEditTool("svg") as any)
|
|
233
|
+
tools.add(makeRenderTool("svg") as any)
|
|
234
|
+
tools.add(
|
|
235
|
+
makeWriteTool(
|
|
236
|
+
MERMAID_GROUP,
|
|
237
|
+
MERMAID_BODY,
|
|
238
|
+
"mermaid",
|
|
239
|
+
"`source` is a complete Mermaid diagram, e.g. `graph TD`/`graph LR`, `sequenceDiagram`, `stateDiagram-v2`, `erDiagram`, `classDiagram`, `mindmap`, or `timeline`.",
|
|
240
|
+
) as any,
|
|
241
|
+
)
|
|
242
|
+
tools.add(makeEditTool("mermaid") as any)
|
|
243
|
+
tools.add(makeRenderTool("mermaid") as any)
|
|
244
|
+
})
|
|
245
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-learn.tui — TUI command for the viz publish directory.
|
|
3
|
+
*
|
|
4
|
+
* The opencode-learn server plugin reads defaultDir from
|
|
5
|
+
* ~/.config/opencode/viz-state.json on every publish (empty = the project dir
|
|
6
|
+
* captured at plugin setup, ctx.location.directory, + viz/). This module adds
|
|
7
|
+
* the /viz-dir command that edits it:
|
|
8
|
+
*
|
|
9
|
+
* /viz-dir open the dialog (prefilled with the current value)
|
|
10
|
+
* /viz-dir <path> set it directly; relative paths resolve against the
|
|
11
|
+
* session's project at publish time
|
|
12
|
+
*
|
|
13
|
+
* Empty input in the dialog clears the override. This module loads through
|
|
14
|
+
* the package's `./tui` export, reached from plugins/tui.ts.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync, writeFileSync } from "fs"
|
|
18
|
+
import { homedir } from "node:os"
|
|
19
|
+
import { isAbsolute, join } from "node:path"
|
|
20
|
+
import type { Plugin } from "@opencode/plugin/tui"
|
|
21
|
+
|
|
22
|
+
type DirResult = { ok: true; value: string } | { ok: false; error: string }
|
|
23
|
+
type ToastVariant = "info" | "success" | "warning" | "error"
|
|
24
|
+
|
|
25
|
+
const STATE_FILE = join(homedir(), ".config", "opencode", "viz-state.json")
|
|
26
|
+
|
|
27
|
+
function loadState(): { defaultDir: string } {
|
|
28
|
+
try {
|
|
29
|
+
const raw = JSON.parse(readFileSync(STATE_FILE, "utf8"))
|
|
30
|
+
return { defaultDir: typeof raw?.defaultDir === "string" ? raw.defaultDir : "" }
|
|
31
|
+
} catch {
|
|
32
|
+
return { defaultDir: "" }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function saveState(st: { defaultDir: string }): void {
|
|
37
|
+
writeFileSync(STATE_FILE, JSON.stringify(st, null, 2), "utf8")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Light validation: publish creates the dir, so it only has to be sane. */
|
|
41
|
+
function validateDir(raw: string): DirResult {
|
|
42
|
+
const v = String(raw ?? "").trim().replace(/^["']+|["']+$/g, "")
|
|
43
|
+
if (v.includes("\0")) return { ok: false, error: "invalid path" }
|
|
44
|
+
if (v.split("/").includes("..")) return { ok: false, error: "path may not contain '..'" }
|
|
45
|
+
if (v.length > 0 && !/^[A-Za-z0-9 ._\-\/~]+$/.test(v)) {
|
|
46
|
+
return { ok: false, error: `unsupported characters in "${v}"` }
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, value: v }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function setupVizTui(ctx: Plugin.Context): Promise<void> {
|
|
52
|
+
const toast = (message: string, variant: ToastVariant = "info") => {
|
|
53
|
+
try {
|
|
54
|
+
ctx.ui?.toast?.show?.({ title: "viz", message, variant })
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function openSetDirDialog(): Promise<void> {
|
|
59
|
+
try {
|
|
60
|
+
const v = await ctx.ui.dialog.prompt({
|
|
61
|
+
title: "viz output directory",
|
|
62
|
+
description: "Where render_* saves published PNGs. Empty = the session project's viz/.",
|
|
63
|
+
placeholder: "absolute path, or relative to the project",
|
|
64
|
+
value: loadState().defaultDir,
|
|
65
|
+
})
|
|
66
|
+
if (typeof v !== "string") return // cancelled
|
|
67
|
+
const res = validateDir(v)
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
toast(`Invalid dir: ${res.error}`, "error")
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
const st = loadState()
|
|
73
|
+
st.defaultDir = res.value
|
|
74
|
+
saveState(st)
|
|
75
|
+
toast(
|
|
76
|
+
res.value
|
|
77
|
+
? `Viz output dir set → ${res.value}${isAbsolute(res.value) ? "" : " (relative to project)"}`
|
|
78
|
+
: "Viz output dir cleared — publishing into the session project's viz/",
|
|
79
|
+
)
|
|
80
|
+
} catch (e: any) {
|
|
81
|
+
toast(`Cannot open dialog: ${e?.message ?? e}`, "error")
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ——— registration (callable directly from setup; removed on deactivate) ———
|
|
86
|
+
|
|
87
|
+
ctx.keymap.layer(() => ({
|
|
88
|
+
mode: "global",
|
|
89
|
+
priority: 1,
|
|
90
|
+
enabled: true,
|
|
91
|
+
commands: [
|
|
92
|
+
{
|
|
93
|
+
id: "viz.setdir",
|
|
94
|
+
title: "Output directory…",
|
|
95
|
+
group: "viz",
|
|
96
|
+
palette: true,
|
|
97
|
+
slash: { name: "viz-dir" },
|
|
98
|
+
run: () => openSetDirDialog(),
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
}))
|
|
102
|
+
|
|
103
|
+
return undefined
|
|
104
|
+
}
|