@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 CHANGED
@@ -1,3 +1,108 @@
1
- # Temporary Holding Version
1
+ # opencode-learn
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }
@@ -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
+ }