@leon135/opencode-learn 0.0.0-stage → 0.1.1
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 +107 -2
- 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
package/README.md
CHANGED
|
@@ -1,3 +1,108 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-learn
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An AI teaching system packaged as an [OpenCode v2](https://opencode.ai/v2/docs/) plugin. The `teach` skill probes what you already know, plans a dependency map, then teaches node by node — checking each node lands with a graded quiz before building on top of it.
|
|
4
|
+
|
|
5
|
+
## Where it comes from
|
|
6
|
+
|
|
7
|
+
Three generations of the same system:
|
|
8
|
+
|
|
9
|
+
1. **[amosblomqvist/learn](https://github.com/amosblomqvist/learn)** — the original, built as [pi](https://github.com/earendil-works/pi) configuration. The teaching philosophy, the skills and the system design are all his, from the video [How I Use AI to Learn Things](https://www.youtube.com/watch?v=kzcI5F4tGiU).
|
|
10
|
+
2. **[FAZuH/oc-learn](https://github.com/FAZuH/oc-learn)** — a port of that system to OpenCode.
|
|
11
|
+
3. **This repository** — takes FAZuH's port and brings it up to date with the current OpenCode v2 plugin API, adds [OpenChamber](https://github.com/openchamber/openchamber) support, and adds a simple cross-session memory.
|
|
12
|
+
|
|
13
|
+
> [!WARNING]
|
|
14
|
+
> Experimental — some things may not work or may break.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
Requires OpenCode v2.
|
|
19
|
+
|
|
20
|
+
```jsonc
|
|
21
|
+
{
|
|
22
|
+
"plugins": ["@leon135/opencode-learn"],
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Visuals publish into the session project's `viz/` by default — the project dir is captured once when the plugin sets up (`ctx.location.directory`), so publishes resolve against it even if the process cwd differs. To always publish somewhere else (an Obsidian vault, say), run `/viz-dir` in the TUI — or point `~/.config/opencode/viz-state.json` at it directly; the override still wins, and empty or missing means the project dir:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{ "defaultDir": "/home/you/Notes/viz" }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
md-link keeps its own state next to it in `~/.config/opencode/md-link-state.json` (`defaultDir`, `keep`, `persist`, and the enabled sessions).
|
|
33
|
+
|
|
34
|
+
## Memory
|
|
35
|
+
|
|
36
|
+
Teaching spans sessions; session context does not. The `teach` skill keeps a **project-scoped learner profile** at `.memory/profile.md` — one short file, read in full at session start and updated as the session goes rather than in one dump at the end:
|
|
37
|
+
|
|
38
|
+
- **Known foundations** — per subject: what you reliably hold, and where your edge is
|
|
39
|
+
- **Struggles** — concepts and moves that cost visible effort
|
|
40
|
+
- **Typical mistakes** — recurring slips, phrased specifically enough to quote back
|
|
41
|
+
- **Practice queue** — what to drill next and why
|
|
42
|
+
- **How to explain to you** — which analogies and cadence work, and which don't
|
|
43
|
+
- **Active plan / where we left off** — the current plan, stuck point, next-session hook
|
|
44
|
+
|
|
45
|
+
## Quiz modes
|
|
46
|
+
|
|
47
|
+
**Chat-UI mode** returns the question plus a ready `openui-lang` program for `openchamber_render`; [OpenChamber](https://github.com/openchamber/openchamber) renders it, returns the chosen letter(s) and any note, and the model relays them to `quiz_grade(selected, note)`. The question, its details and the lettered option texts all render inside the callout (markdown, so LaTeX works there), while the Choice buttons stay plain letters (A, B, C…); a letter resolves to the shuffled option value through the same letter/value logic as before. Without that tool the result includes a plain-text lettered fallback.
|
|
48
|
+
|
|
49
|
+
**Form mode** (TUI, and `auto` when chat detection fails) is a hybrid: the session form carries only lettered options (A, B, C…, plus "...?") and a note field, with the fixed title "Quiz — answer the question shown in chat". The full question, its details and the lettered option texts come back in the `quiz_ask` result, which tells the model to post that block as a chat message (markdown; math renders per the TUI's own pipeline). The form opens before that message can appear, so you may see the lettered form first — an accepted ordering wrinkle of the design. Form grading uses the same letter/value resolution as chat.
|
|
50
|
+
|
|
51
|
+
The plugin option `mode` is `"auto"` (default), `"chat"`, or `"form"`:
|
|
52
|
+
|
|
53
|
+
```jsonc
|
|
54
|
+
{
|
|
55
|
+
"plugins": [
|
|
56
|
+
{
|
|
57
|
+
"package": "@leon135/opencode-learn",
|
|
58
|
+
"options": { "mode": "auto" },
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`auto` detects `openchamber_render` in the session's tool catalog (memoized per session): present → chat, absent → form. If detection fails or has not run yet, it degrades to chat — a plain-text question still works there, while an invisible form does not.
|
|
65
|
+
|
|
66
|
+
No synchronous HTML/KaTeX transport ships: a plugin-POSTed page is never displayed (the chat draws only `openchamber_render` tool-call pages) and an inlined-KaTeX page exceeds OpenChamber's `render.html` 200 000-character page cap.
|
|
67
|
+
|
|
68
|
+
## Requirements
|
|
69
|
+
|
|
70
|
+
- [OpenCode v2](https://opencode.ai/v2/docs)
|
|
71
|
+
- `rsvg-convert` (librsvg) or ImageMagick — SVG rendering
|
|
72
|
+
- `mmdc` (`@mermaid-js/mermaid-cli`) on PATH — Mermaid rendering
|
|
73
|
+
- [Bun](https://bun.sh) only for development: `bun install`, then `bun x tsc --noEmit` to typecheck
|
|
74
|
+
- Subagents inherit the session's model — edit `plugins/agents.ts` to tune them
|
|
75
|
+
- For quizzes: nothing extra — chat-UI mode auto-detects `openchamber_render` (OpenChamber) and otherwise uses OpenCode v2's native TUI forms. See [Quiz modes](#quiz-modes) to force one.
|
|
76
|
+
|
|
77
|
+
## Notes
|
|
78
|
+
|
|
79
|
+
- The teaching flow: `teach` probes your level with graded quizzes (`quiz_ask`/`quiz_grade`), plans a dependency map, then teaches node by node with a quiz check after each one. Non-graded questions use OpenCode's built-in `question` tool.
|
|
80
|
+
- Visuals are never hand-faked: the skill briefs a maker subagent, which authors, renders to PNG, **looks at the result**, iterates, and only then publishes into `viz/` for embedding.
|
|
81
|
+
- `md-link` mirrors only reading-relevant content: your prompts, lesson prose, and quiz Q&A. Tool noise (bash, reads, edits) is omitted. It is event-driven: assistant text is rewritten in place at most every 2.5 s while streaming, the final reply is written when the stream ends, and user prompts are mirrored as they are enqueued. Commands: `ctrl+alt:m` or `/md-link [dir]` toggles the focused session (ON backfills the last 10 messages — replies and your prompts), `/md-link-dir` sets the default output directory, `/md-link-keep` keeps only the N newest replies, and `/md-link-persist [on|off]` keeps mirrors across TUI restarts and auto-resumes them on next launch. `/md-link` OFF still deletes immediately.
|
|
82
|
+
- The maker and researcher agents are registered with deny-all permissions plus only the few actions they need. Under v2's last-match-wins permission rule, a later permissive global permission can widen that sandbox; the plugin cannot pin it, so keep global `allow` rules narrow if you rely on it.
|
|
83
|
+
- The makers render but the tools return a path, not an image — `render_*` returns the PNG path and the maker opens it with `read` to inspect. Same verify-by-looking loop, one extra step.
|
|
84
|
+
- You can run the system without subagents: the main session does the teaching. You lose the researcher (truth verification) and the generated visuals.
|
|
85
|
+
|
|
86
|
+
## Differences from the original
|
|
87
|
+
|
|
88
|
+
Carried over from the OpenCode port (FAZuH's work, relative to the pi original):
|
|
89
|
+
|
|
90
|
+
- **Configurable viz output** — `viz-state.json` (`defaultDir`) sends published visuals to any directory; the original always wrote to `<cwd>/viz`.
|
|
91
|
+
- **md-link TUI commands** — `ctrl+alt:m` / `/md-link` with per-session enable, plus `/md-link-dir`, `/md-link-keep`, and `/md-link-persist`; the original had `/md-log` / `/md-unlog` commands only.
|
|
92
|
+
- **quiz is two tools** — `quiz_ask` poses the question, `quiz_grade` carries the answer. The TUI shows raw tool arguments, so splitting at the answer boundary makes the correct answer structurally invisible until after you answer (the original hid it inside the popup renderer).
|
|
93
|
+
- **No `ask-user-question` extension** — OpenCode v2's built-in `question` tool covers non-graded questions.
|
|
94
|
+
- **No subagent extension** — v2's built-in `subagent` tool replaces `pi-interactive-subagents` (the original's recommended tmux-only dependency).
|
|
95
|
+
- **Render-and-inspect via path** — `render_*` returns the PNG path and the maker opens it with `read` (the original returned the image inline).
|
|
96
|
+
- **Linux-first rendering** — `mmdc` / `rsvg-convert` from PATH instead of a bundled mermaid-cli and macOS Chrome paths.
|
|
97
|
+
|
|
98
|
+
New in this repository:
|
|
99
|
+
|
|
100
|
+
- **Current OpenCode v2 plugin API** — brought up to date with the current `@opencode/plugin` surface: one package entry carrying both the server and TUI parts (replacing FAZuH's symlinking `install.sh`/`uninstall.sh`), session event subscription instead of message polling, and the plugin client instead of spawning `opencode2`. Agents and skills are registered by the plugin itself.
|
|
101
|
+
- **OpenChamber support** — [OpenChamber](https://github.com/openchamber/openchamber)-aware quiz transport: chat-UI mode auto-detects `openchamber_render` and renders the question as an interactive callout with lettered buttons.
|
|
102
|
+
- **Simple memory** — the project-scoped learner profile at `.memory/profile.md`, so the teacher picks up where it left off across sessions. See [Memory](#memory).
|
|
103
|
+
|
|
104
|
+
## Credits
|
|
105
|
+
|
|
106
|
+
- **[amosblomqvist](https://github.com/amosblomqvist)** — original system, teaching philosophy, and the video: [amosblomqvist/learn](https://github.com/amosblomqvist/learn) (pi configuration)
|
|
107
|
+
- **[FAZuH](https://github.com/FAZuH)** — the OpenCode port: [FAZuH/oc-learn](https://github.com/FAZuH/oc-learn)
|
|
108
|
+
- This repository — fork of that port, updated for the current OpenCode v2, with OpenChamber support and the learner profile added
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leon135/opencode-learn",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Opencode AI learning system.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./plugins/index.ts",
|
|
8
|
+
"./tui": "./plugins/tui.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"plugins",
|
|
12
|
+
"skills",
|
|
13
|
+
"assets"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/leon135/opencode-learn.git"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"opencode",
|
|
21
|
+
"learn"
|
|
22
|
+
],
|
|
23
|
+
"author": "leon135",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/leon135/opencode-learn/issues"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/leon135/opencode-learn#readme",
|
|
29
|
+
"scripts": {
|
|
30
|
+
"typecheck": "tsc --noEmit"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@opencode/client": "^2.2.5",
|
|
37
|
+
"@opencode/plugin": "^2.2.5"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "latest",
|
|
41
|
+
"solid-js": "^1.9.18",
|
|
42
|
+
"typescript": "latest"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin"
|
|
2
|
+
|
|
3
|
+
type Permission = { action: string; resource: string; effect: "allow" | "deny" | "ask" }
|
|
4
|
+
|
|
5
|
+
type AgentDefinition = {
|
|
6
|
+
name: string
|
|
7
|
+
description: string
|
|
8
|
+
steps: number
|
|
9
|
+
permissions: Permission[]
|
|
10
|
+
system: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const AGENTS: AgentDefinition[] = [
|
|
14
|
+
{
|
|
15
|
+
name: "researcher",
|
|
16
|
+
description: "Web researcher — searches the web and synthesizes findings",
|
|
17
|
+
steps: 32,
|
|
18
|
+
permissions: [
|
|
19
|
+
{ action: "*", resource: "*", effect: "deny" },
|
|
20
|
+
{ action: "websearch", resource: "*", effect: "allow" },
|
|
21
|
+
{ action: "webfetch", resource: "*", effect: "allow" },
|
|
22
|
+
],
|
|
23
|
+
system: `You are a research specialist. Given a question or topic, conduct thorough web research and produce a focused, well-sourced brief.
|
|
24
|
+
|
|
25
|
+
You operate in an isolated context with no knowledge of any prior conversation. All necessary context is in the task description.
|
|
26
|
+
|
|
27
|
+
Process:
|
|
28
|
+
1. Break the question into 2-4 searchable facets
|
|
29
|
+
2. Search with \`websearch\` using varied angles
|
|
30
|
+
3. Read the answers. Identify what's well-covered, what has gaps.
|
|
31
|
+
4. For the 2-3 most promising source URLs, use \`webfetch\` to get full page content
|
|
32
|
+
5. Synthesize everything into a brief that directly answers the question
|
|
33
|
+
|
|
34
|
+
Search strategy — always vary your angles:
|
|
35
|
+
- Direct answer query (the obvious one)
|
|
36
|
+
- Authoritative source query (official docs, specs, primary sources)
|
|
37
|
+
- Practical experience query (case studies, benchmarks, real-world usage)
|
|
38
|
+
- Recent developments query (only if the topic is time-sensitive)
|
|
39
|
+
|
|
40
|
+
Evaluation — what to keep vs drop:
|
|
41
|
+
- Official docs and primary sources outweigh blog posts and forum threads
|
|
42
|
+
- Recent sources outweigh stale ones
|
|
43
|
+
- Sources that directly address the question outweigh tangentially related ones
|
|
44
|
+
- Drop: SEO filler, outdated info, beginner tutorials (unless that's the audience)
|
|
45
|
+
|
|
46
|
+
If the first round of searches doesn't fully answer the question, search again with refined queries targeting the gaps.
|
|
47
|
+
|
|
48
|
+
Your FINAL assistant message is your entire deliverable — it must stand alone, using this format:
|
|
49
|
+
|
|
50
|
+
## Summary
|
|
51
|
+
2-3 sentence direct answer.
|
|
52
|
+
|
|
53
|
+
## Findings
|
|
54
|
+
Numbered findings with inline source citations:
|
|
55
|
+
1. **Finding** — explanation. [Source](url)
|
|
56
|
+
2. **Finding** — explanation. [Source](url)
|
|
57
|
+
|
|
58
|
+
## Sources
|
|
59
|
+
- Kept: Source Title (url) — why relevant
|
|
60
|
+
- Dropped: Source Title — why excluded
|
|
61
|
+
|
|
62
|
+
## Gaps
|
|
63
|
+
What couldn't be answered. Suggested next steps.`,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "svg-maker",
|
|
67
|
+
description: "Authors ONE hand-written SVG from a brief, renders it to a PNG, LOOKS at the result, iterates until it is correct and clean, publishes the PNG into the Obsidian vault, and returns the filename. For spatial/geometric visuals Mermaid can't express — coordinate geometry, number lines, vectors, function plots, physical layouts, custom shapes with exact positions.",
|
|
68
|
+
steps: 24,
|
|
69
|
+
permissions: [
|
|
70
|
+
{ action: "*", resource: "*", effect: "deny" },
|
|
71
|
+
{ action: "external_directory", resource: "/tmp/opencode/**", effect: "allow" },
|
|
72
|
+
{ action: "read", resource: "*", effect: "allow" },
|
|
73
|
+
{ action: "write_svg", resource: "*", effect: "allow" },
|
|
74
|
+
{ action: "edit_svg", resource: "*", effect: "allow" },
|
|
75
|
+
{ action: "render_svg", resource: "*", effect: "allow" },
|
|
76
|
+
],
|
|
77
|
+
system: `# SVG Maker
|
|
78
|
+
|
|
79
|
+
You are a **diagram author + renderer** for spatial and geometric pictures. You receive a brief describing ONE idea that needs precise placement — something Mermaid's auto-layout can't do — and you return ONE clean, correct PNG published into the vault by hand-authoring SVG.
|
|
80
|
+
|
|
81
|
+
You do NOT decide *what* idea to show — the caller (a teacher) already decided that, and you must preserve it exactly. Your job is faithful, precise composition, and — above everything — **correctness**: the picture must not assert anything false. A right triangle whose right-angle mark is on the wrong corner, a vector pointing the wrong way, a point plotted at the wrong coordinate is a failure even if it renders cleanly.
|
|
82
|
+
|
|
83
|
+
You have exactly three authoring tools — \`write_svg\`, \`edit_svg\`, \`render_svg\` — plus \`read\`. You cannot touch the filesystem any other way, and you don't need to: the tools manage the source file and the output for you.
|
|
84
|
+
|
|
85
|
+
## Your superpower: exact control
|
|
86
|
+
|
|
87
|
+
Unlike auto-laid-out diagrams, you place every element at coordinates you choose, so what you write is exactly what appears — fully deterministic. That precision is the whole reason to use SVG. It also means correctness is entirely on you: do the geometry deliberately, and verify it by looking.
|
|
88
|
+
|
|
89
|
+
## The one rule that matters most: verify by looking
|
|
90
|
+
|
|
91
|
+
You are done only when you have **looked at the rendered PNG and confirmed it is true to the brief**. \`render_svg\` returns the PNG's absolute path — open it with the \`read\` tool and actually look at it. Rendering success only proves the SVG parsed; it says nothing about whether the geometry is right or the picture is readable.
|
|
92
|
+
|
|
93
|
+
## Workflow (the render-and-inspect loop)
|
|
94
|
+
|
|
95
|
+
1. **Plan the coordinate space.** Choose a \`viewBox\` and sketch where each element sits before drawing. Leave margins so nothing touches the edge. Keep it to ONE idea and few elements.
|
|
96
|
+
2. **Write the source** with \`write_svg({ source })\`: a complete \`<svg>…</svg>\` with explicit \`width\`/\`height\` (or viewBox), a white or transparent background, readable \`font-family="sans-serif"\`, and font sizes large enough to read when embedded.
|
|
97
|
+
3. **Render a preview** with \`render_svg({})\` (no \`save_as\`), then open the returned PNG path with \`read\` and look at it.
|
|
98
|
+
4. **LOOK critically:**
|
|
99
|
+
- Is every coordinate, angle, direction, and proportion actually correct? Re-derive the geometry if unsure.
|
|
100
|
+
- Are labels placed clearly, not overlapping lines or each other?
|
|
101
|
+
- Is anything clipped by the viewBox, too small to read, or cramped?
|
|
102
|
+
- Would the learner instantly read the intended idea from this picture alone?
|
|
103
|
+
5. **Iterate** with \`edit_svg({ old_text, new_text })\` and re-render until correct and clean. If \`render_svg\` returns an error, read it, fix the source, re-render.
|
|
104
|
+
6. **Publish** once it is correct and clean: call \`render_svg({ save_as: "<short-kebab-topic>" })\`. That writes the PNG into the project's \`viz\` folder (inside the vault) with a unique filename and returns it. Open the published image with \`read\` one last time.
|
|
105
|
+
|
|
106
|
+
## Your output
|
|
107
|
+
|
|
108
|
+
End your response with EXACTLY this block (nothing after it):
|
|
109
|
+
|
|
110
|
+
\`\`\`
|
|
111
|
+
RESULT:
|
|
112
|
+
filename: <the viz-...-<timestamp>.png filename returned by render_svg>
|
|
113
|
+
path: <the absolute path returned by render_svg>
|
|
114
|
+
\`\`\`
|
|
115
|
+
|
|
116
|
+
If you genuinely cannot make a correct, sensible picture of the brief, return:
|
|
117
|
+
|
|
118
|
+
\`\`\`
|
|
119
|
+
RESULT:
|
|
120
|
+
NONE
|
|
121
|
+
\`\`\`
|
|
122
|
+
|
|
123
|
+
with a one-line reason (e.g. the idea is purely relational and belongs to the mermaid-maker).
|
|
124
|
+
|
|
125
|
+
## Guidelines
|
|
126
|
+
|
|
127
|
+
- **Correctness is non-negotiable.** Never publish a picture you have not looked at. Do the arithmetic/geometry deliberately; don't eyeball positions that need to be exact.
|
|
128
|
+
- **One idea, fewest elements.** Sparse and large beats busy and tiny.
|
|
129
|
+
- **Draw only what the brief specifies.** Don't invent data points, values, or shapes to fill space.
|
|
130
|
+
- **Keep type legible.** Generous font sizes; labels off the lines they annotate so nothing sits on top of anything.
|
|
131
|
+
- **Prefer plain, clean styling.** A light background, dark strokes, one accent color at most. This is an explanatory diagram, not art.`,
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: "mermaid-maker",
|
|
135
|
+
description: "Authors ONE Mermaid diagram from a brief, renders it to a PNG, LOOKS at the result, iterates until it is correct and clean, publishes the PNG into the Obsidian vault, and returns the filename. For structural/relational visuals — dependency graphs, flows, sequences, state machines, trees, ER, timelines.",
|
|
136
|
+
steps: 24,
|
|
137
|
+
permissions: [
|
|
138
|
+
{ action: "*", resource: "*", effect: "deny" },
|
|
139
|
+
{ action: "external_directory", resource: "/tmp/opencode/**", effect: "allow" },
|
|
140
|
+
{ action: "read", resource: "*", effect: "allow" },
|
|
141
|
+
{ action: "write_mermaid", resource: "*", effect: "allow" },
|
|
142
|
+
{ action: "edit_mermaid", resource: "*", effect: "allow" },
|
|
143
|
+
{ action: "render_mermaid", resource: "*", effect: "allow" },
|
|
144
|
+
],
|
|
145
|
+
system: `# Mermaid Maker
|
|
146
|
+
|
|
147
|
+
You are a **diagram author + renderer**. You receive a brief describing ONE idea to visualize as a Mermaid diagram, and you return ONE clean, correct PNG published into the vault.
|
|
148
|
+
|
|
149
|
+
You do NOT decide *what* idea to show — the caller (a teacher) already decided that, and you must preserve it exactly. Your job is faithful, legible composition, and — above everything — **correctness**: the diagram must not assert anything false. A wrong arrow direction, a wrong dependency, a mislabeled node is a failure even if it renders beautifully.
|
|
150
|
+
|
|
151
|
+
You have exactly three authoring tools — \`write_mermaid\`, \`edit_mermaid\`, \`render_mermaid\` — plus \`read\`. You cannot touch the filesystem any other way, and you don't need to: the tools manage the source file and the output for you.
|
|
152
|
+
|
|
153
|
+
## The one rule that matters most: verify by looking
|
|
154
|
+
|
|
155
|
+
You are not done when the diagram renders. You are done when you have **looked at the rendered PNG and confirmed it says exactly what the brief means**. \`render_mermaid\` returns the PNG's absolute path — open it with the \`read\` tool and actually look at it. Rendering success only proves the syntax parsed; it says nothing about whether the picture is true or readable.
|
|
156
|
+
|
|
157
|
+
## Workflow (the render-and-inspect loop)
|
|
158
|
+
|
|
159
|
+
1. **Understand the idea, then cut.** A brief is a wish-list, not a spec. Keep the idea intact but drop any node/label that doesn't earn its place. If you're about to draw more than ~7 nodes, stop and simplify — a diagram of 4 nodes that each pull weight beats one of 12 that fight for space. Cramming is the #1 way these fail.
|
|
160
|
+
2. **Write the source** with \`write_mermaid({ source })\`. Pick the diagram type that fits: \`graph TD\`/\`LR\` (dependency graphs, flows), \`sequenceDiagram\`, \`stateDiagram-v2\`, \`erDiagram\`, \`mindmap\`, \`timeline\`, \`classDiagram\`.
|
|
161
|
+
3. **Render a preview** with \`render_mermaid({})\` (no \`save_as\`), then open the returned PNG path with \`read\` and look at it.
|
|
162
|
+
4. **LOOK critically:**
|
|
163
|
+
- Is every arrow pointing the right way? Is every dependency/relationship actually true to the brief?
|
|
164
|
+
- Are the labels correct and unambiguous?
|
|
165
|
+
- Is anything overlapping, clipped, cramped, or unreadable? If so the fix is usually **fewer elements**, not more.
|
|
166
|
+
- Would the learner instantly read the intended idea from this picture alone?
|
|
167
|
+
5. **Iterate** with \`edit_mermaid({ old_text, new_text })\` and re-render. A few passes is normal. If \`render_mermaid\` returns an error instead of a path, read it, fix the source, re-render.
|
|
168
|
+
6. **Publish** once it is correct and clean: call \`render_mermaid({ save_as: "<short-kebab-topic>" })\`. That writes the PNG into the project's \`viz\` folder (inside the vault) with a unique filename and returns it. Open the published image with \`read\` one last time.
|
|
169
|
+
|
|
170
|
+
## Your output
|
|
171
|
+
|
|
172
|
+
End your response with EXACTLY this block (nothing after it):
|
|
173
|
+
|
|
174
|
+
\`\`\`
|
|
175
|
+
RESULT:
|
|
176
|
+
filename: <the viz-...-<timestamp>.png filename returned by render_mermaid>
|
|
177
|
+
path: <the absolute path returned by render_mermaid>
|
|
178
|
+
\`\`\`
|
|
179
|
+
|
|
180
|
+
If you genuinely cannot make a correct, sensible diagram of the brief, return:
|
|
181
|
+
|
|
182
|
+
\`\`\`
|
|
183
|
+
RESULT:
|
|
184
|
+
NONE
|
|
185
|
+
\`\`\`
|
|
186
|
+
|
|
187
|
+
with a one-line reason (e.g. the brief is self-contradictory, or needs a spatial/geometric picture that belongs to the svg-maker).
|
|
188
|
+
|
|
189
|
+
## Guidelines
|
|
190
|
+
|
|
191
|
+
- **Correctness is non-negotiable.** Never publish a diagram you have not looked at. If unsure whether an edge is true, it's better to omit it than to assert something false.
|
|
192
|
+
- **One idea, fewest elements.** Sparse beats busy — for both readability and layout reliability.
|
|
193
|
+
- **Keep labels short.** Nodes hold a term or short phrase, not a sentence. Long labels wreck layout.
|
|
194
|
+
- **Don't invent content.** Visualize only what the brief specifies. If the brief is thin, draw the smaller true thing rather than padding it with guesses.
|
|
195
|
+
- **Match the pedagogy when it fits.** Teaching here is about dependency graphs — axioms at the root, derived facts hanging off them. \`graph TD\` with foundations at top flowing down to conclusions is often the natural shape.`,
|
|
196
|
+
},
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
export async function setupAgents(ctx: Plugin.Context): Promise<void> {
|
|
200
|
+
await ctx.agent.transform((editor) => {
|
|
201
|
+
for (const agent of AGENTS) {
|
|
202
|
+
editor.update(agent.name, (current) => {
|
|
203
|
+
current.description = agent.description
|
|
204
|
+
current.mode = "subagent"
|
|
205
|
+
current.steps = agent.steps
|
|
206
|
+
current.permissions = agent.permissions.map((rule) => ({ ...rule }))
|
|
207
|
+
current.system = agent.system
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
}
|
package/plugins/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin"
|
|
2
|
+
import { setupAgents } from "./agents.ts"
|
|
3
|
+
import { setupMdLink } from "./md-link/index.ts"
|
|
4
|
+
import { setupQuiz } from "./quiz.ts"
|
|
5
|
+
import { setupSkills } from "./skills.ts"
|
|
6
|
+
import { setupViz } from "./viz/index.ts"
|
|
7
|
+
|
|
8
|
+
export default Plugin.define({
|
|
9
|
+
id: "opencode-learn",
|
|
10
|
+
async setup(ctx) {
|
|
11
|
+
const cleanups: (Plugin.Cleanup | void)[] = []
|
|
12
|
+
cleanups.push(await setupAgents(ctx))
|
|
13
|
+
cleanups.push(await setupSkills(ctx))
|
|
14
|
+
cleanups.push(await setupViz(ctx))
|
|
15
|
+
cleanups.push(await setupQuiz(ctx))
|
|
16
|
+
cleanups.push(await setupMdLink(ctx))
|
|
17
|
+
return () => {
|
|
18
|
+
for (const cleanup of cleanups.reverse()) {
|
|
19
|
+
if (typeof cleanup === "function") cleanup()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
})
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* md-link core — single source of truth for the Obsidian live-mirror pair.
|
|
3
|
+
*
|
|
4
|
+
* Consumers:
|
|
5
|
+
* plugins/md-link/index.ts (server: mirrors assistant replies)
|
|
6
|
+
* plugins/md-link/tui.ts (TUI: ctrl+alt+m / /md-link toggle)
|
|
7
|
+
*
|
|
8
|
+
* The package's `.` entry (plugins/index.ts) registers the server part; the
|
|
9
|
+
* TUI part loads through the package's `./tui` export (plugins/tui.ts).
|
|
10
|
+
*
|
|
11
|
+
* State file contract (~/.config/opencode/md-link-state.json):
|
|
12
|
+
* {
|
|
13
|
+
* "defaultDir": "/abs/dir" | null, // fallback output dir
|
|
14
|
+
* "keep": 25 | null, // prune mirrors to N newest replies
|
|
15
|
+
* "sessions": { "<sessionID>": "/abs/dir" } // "" dir = use defaultDir
|
|
16
|
+
* "persist": true|false // true = mirrors survive TUI exit
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* With persist=true, TUI exit keeps this project's mirror files AND their
|
|
20
|
+
* session entries, so the event subscription resumes mirroring into the same
|
|
21
|
+
* files on the next launch. Explicit /md-link OFF always deletes regardless.
|
|
22
|
+
*
|
|
23
|
+
* Mirror file contract (<dir>/<sessionID>.md):
|
|
24
|
+
* Contains ONLY response blocks — no frontmatter, titles, or separators:
|
|
25
|
+
* <!-- msg:<assistantMessageID>#<ordinal> --> (dedup marker, invisible
|
|
26
|
+
* **HH:MM** in Obsidian preview)
|
|
27
|
+
*
|
|
28
|
+
* <reply text>
|
|
29
|
+
*
|
|
30
|
+
* The server re-reads the state file on every event so TUI toggles apply
|
|
31
|
+
* instantly; writers use read-modify-write to stay concurrency-safe.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, appendFileSync } from "fs"
|
|
35
|
+
import { dirname, join } from "path"
|
|
36
|
+
import { homedir } from "os"
|
|
37
|
+
|
|
38
|
+
export const STATE_FILE = join(homedir(), ".config", "opencode", "md-link-state.json")
|
|
39
|
+
|
|
40
|
+
export type MdLinkState = {
|
|
41
|
+
defaultDir: string | null
|
|
42
|
+
keep: number | null
|
|
43
|
+
sessions: Record<string, string>
|
|
44
|
+
/** True = mirror files + session entries survive TUI exit (resume next launch). */
|
|
45
|
+
persist?: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const EMPTY_STATE: MdLinkState = { defaultDir: null, keep: null, sessions: {}, persist: false }
|
|
49
|
+
|
|
50
|
+
/** Read state, migrating legacy formats (plain array / {sessions:[...]}). */
|
|
51
|
+
export function loadState(): MdLinkState {
|
|
52
|
+
try {
|
|
53
|
+
if (!existsSync(STATE_FILE)) return { ...EMPTY_STATE, sessions: {} }
|
|
54
|
+
const raw = JSON.parse(readFileSync(STATE_FILE, "utf-8"))
|
|
55
|
+
|
|
56
|
+
if (Array.isArray(raw)) {
|
|
57
|
+
const sessions: Record<string, string> = {}
|
|
58
|
+
for (const x of raw) if (typeof x === "string") sessions[x] = ""
|
|
59
|
+
return { ...EMPTY_STATE, sessions }
|
|
60
|
+
}
|
|
61
|
+
if (raw && typeof raw === "object") {
|
|
62
|
+
const sessions: Record<string, string> = {}
|
|
63
|
+
const s = (raw as any).sessions
|
|
64
|
+
if (s && typeof s === "object" && !Array.isArray(s)) {
|
|
65
|
+
for (const [k, v] of Object.entries(s)) {
|
|
66
|
+
if (typeof k === "string" && k && typeof v === "string") sessions[k] = v
|
|
67
|
+
}
|
|
68
|
+
} else if (Array.isArray(s)) {
|
|
69
|
+
for (const x of s) if (typeof x === "string") sessions[x] = ""
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
defaultDir: typeof raw.defaultDir === "string" ? raw.defaultDir : null,
|
|
73
|
+
keep: typeof raw.keep === "number" && raw.keep >= 1 ? Math.floor(raw.keep) : null,
|
|
74
|
+
sessions,
|
|
75
|
+
persist: raw.persist === true,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch {}
|
|
79
|
+
return { ...EMPTY_STATE, sessions: {} }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Atomic-enough write for our scale (single writer per toggle, tiny file). */
|
|
83
|
+
export function saveState(st: MdLinkState): void {
|
|
84
|
+
try {
|
|
85
|
+
mkdirSync(dirname(STATE_FILE), { recursive: true })
|
|
86
|
+
writeFileSync(STATE_FILE, JSON.stringify(st, null, 2), "utf-8")
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isEnabled(st: MdLinkState, sessionID: string): boolean {
|
|
91
|
+
return sessionID in st.sessions
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Add a session (read-modify-write). Preserves any dir already recorded. */
|
|
95
|
+
export function enableSession(st: MdLinkState, sessionID: string, dir = ""): void {
|
|
96
|
+
if (!(sessionID in st.sessions)) st.sessions[sessionID] = dir
|
|
97
|
+
saveState(st)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Remove a session (read-modify-write). */
|
|
101
|
+
export function disableSession(st: MdLinkState, sessionID: string): void {
|
|
102
|
+
delete st.sessions[sessionID]
|
|
103
|
+
saveState(st)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Resolve where a session's mirror lives. Falls back through recorded dir → defaultDir → fallbackDir. */
|
|
107
|
+
export function mirrorFile(st: MdLinkState, sessionID: string, fallbackDir?: string): string | null {
|
|
108
|
+
let dir = st.sessions[sessionID] ?? ""
|
|
109
|
+
if (!dir && st.defaultDir) dir = st.defaultDir
|
|
110
|
+
if (!dir && fallbackDir) dir = fallbackDir
|
|
111
|
+
if (!dir) return null
|
|
112
|
+
return join(dir, `${sessionID}_link.md`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Create an empty mirror file (responses only — zero boilerplate). No-op if it exists. */
|
|
116
|
+
export function touchMirror(file: string): void {
|
|
117
|
+
if (existsSync(file)) return
|
|
118
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
119
|
+
writeFileSync(file, "", "utf-8")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type AppendResult = "written" | "updated" | "duplicate" | "failed"
|
|
123
|
+
|
|
124
|
+
/** Extract full reply text from a /message item (shapes vary: content[] parts or flat text). */
|
|
125
|
+
export function messageText(m: any): string {
|
|
126
|
+
let text = ""
|
|
127
|
+
if (Array.isArray(m?.content)) {
|
|
128
|
+
text = m.content
|
|
129
|
+
.filter((c: any) => c?.type === "text" && typeof c.text === "string")
|
|
130
|
+
.map((c: any) => c.text)
|
|
131
|
+
.join("\n")
|
|
132
|
+
}
|
|
133
|
+
if (!text && typeof m?.text === "string") text = m.text
|
|
134
|
+
return text.trim()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Skill declarations are system-injected context, not learner prose. */
|
|
138
|
+
export function stripSkillBlocks(text: string): string {
|
|
139
|
+
return text.replace(/<skill\b([^>]*)>[\s\S]*?<\/skill>/g, (_m, attrs: string) => {
|
|
140
|
+
const name = /name="([^"]+)"/.exec(attrs)?.[1]
|
|
141
|
+
return `[SKILL loaded: ${name ?? "(unknown)"}]`
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Obsidian callout block: `> [!type] title` + quoted body lines. */
|
|
146
|
+
export function callout(type: string, title: string, bodyLines: string[]): string {
|
|
147
|
+
const lines = [`> [!${type}] ${title}`]
|
|
148
|
+
for (const line of bodyLines) lines.push(line.length === 0 ? ">" : `> ${line}`)
|
|
149
|
+
return lines.join("\n")
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** User-prompt block, identical for the live event path and history backfill. */
|
|
153
|
+
export function userPromptBlock(text: string): string {
|
|
154
|
+
return callout("quote", "YOU", stripSkillBlocks(text.trim()).split("\n"))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** True if the file contains a block whose marker starts with `<!-- msg:<key>` */
|
|
158
|
+
export function hasBlock(file: string, key: string): boolean {
|
|
159
|
+
try {
|
|
160
|
+
return readFileSync(file, "utf-8").includes(`<!-- msg:${key}`)
|
|
161
|
+
} catch {
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Append one reply block, deduped/upserted by markerKey, then prune to `keep`.
|
|
168
|
+
* With replace=true, an existing block for the same key is rewritten when its
|
|
169
|
+
* text differs (used by the poller while replies are still streaming).
|
|
170
|
+
* Block format (see module doc): marker comment, local HH:MM, blank line, text.
|
|
171
|
+
*/
|
|
172
|
+
export function appendMessage(
|
|
173
|
+
file: string,
|
|
174
|
+
opts: { text: string; markerKey?: string | null; replace?: boolean },
|
|
175
|
+
keep: number | null,
|
|
176
|
+
): AppendResult {
|
|
177
|
+
const key = opts.markerKey ?? null
|
|
178
|
+
try {
|
|
179
|
+
if (key && hasBlock(file, key)) {
|
|
180
|
+
if (!opts.replace) return "duplicate"
|
|
181
|
+
return replaceBlock(file, key, opts.text) ? "updated" : "duplicate"
|
|
182
|
+
}
|
|
183
|
+
if (key === null && hasBlockLoose(file, opts.text)) return "duplicate"
|
|
184
|
+
const time = clock()
|
|
185
|
+
const marker = key ? `<!-- msg:${key} -->\n` : ""
|
|
186
|
+
appendFileSync(file, `${marker}**${time}**\n\n${opts.text.trim()}\n\n`, "utf-8")
|
|
187
|
+
pruneMirror(file, keep)
|
|
188
|
+
return "written"
|
|
189
|
+
} catch {
|
|
190
|
+
return "failed"
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function hasBlockLoose(file: string, text: string): boolean {
|
|
195
|
+
try {
|
|
196
|
+
return readFileSync(file, "utf-8").includes(text.trim().slice(0, 80))
|
|
197
|
+
} catch {
|
|
198
|
+
return false
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function clock(): string {
|
|
203
|
+
return new Date().toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Rewrite an existing block's text in place. Returns false if unchanged/not found. */
|
|
207
|
+
function replaceBlock(file: string, key: string, text: string): boolean {
|
|
208
|
+
const c = readFileSync(file, "utf-8")
|
|
209
|
+
const first = c.search(/<!-- msg:/)
|
|
210
|
+
if (first === -1) return false
|
|
211
|
+
const head = c.slice(0, first)
|
|
212
|
+
const blocks = c.slice(first).split(/(?=<!-- msg:)/g).filter((b) => b.trim().length > 0)
|
|
213
|
+
const idx = blocks.findIndex((b) => b.startsWith(`<!-- msg:${key}`))
|
|
214
|
+
if (idx === -1) return false
|
|
215
|
+
const fresh = `<!-- msg:${key} -->\n**${clock()}**\n\n${text.trim()}\n\n`
|
|
216
|
+
const norm = (s: string) => s.replace(/\s+/g, " ").trim()
|
|
217
|
+
if (norm(blocks[idx]) === norm(fresh)) return false
|
|
218
|
+
blocks[idx] = fresh
|
|
219
|
+
writeFileSync(file, head + blocks.join(""), "utf-8")
|
|
220
|
+
return true
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Keep only the newest `keep` reply blocks. keep <= 0/null = unlimited. */
|
|
224
|
+
export function pruneMirror(file: string, keep: number | null): void {
|
|
225
|
+
try {
|
|
226
|
+
if (!keep || keep < 1) return
|
|
227
|
+
const c = readFileSync(file, "utf-8")
|
|
228
|
+
const first = c.search(/<!-- msg:/)
|
|
229
|
+
if (first === -1) return
|
|
230
|
+
const head = c.slice(0, first)
|
|
231
|
+
const blocks = c
|
|
232
|
+
.slice(first)
|
|
233
|
+
.split(/(?=<!-- msg:)/g)
|
|
234
|
+
.filter((b) => b.trim().length > 0)
|
|
235
|
+
if (blocks.length <= keep) return
|
|
236
|
+
writeFileSync(file, head + blocks.slice(-keep).join(""), "utf-8")
|
|
237
|
+
} catch {}
|
|
238
|
+
}
|