@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,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* md-link TUI plugin — silent toggle for the Obsidian live mirror.
|
|
3
|
+
*
|
|
4
|
+
* V2 loader contract: default export is { id, setup } via Plugin.define.
|
|
5
|
+
* setup may call ctx.keymap.layer() directly (the layer is removed when the
|
|
6
|
+
* plugin deactivates) and returns a cleanup that runs on deactivate/exit.
|
|
7
|
+
* Dialog API: await ctx.ui.dialog.prompt({...}) → string | undefined.
|
|
8
|
+
*
|
|
9
|
+
* Commands (all purely client-side — nothing is ever sent to the LLM):
|
|
10
|
+
* ctrl+alt:m / /md-link [dir] toggle mirroring for the focused session;
|
|
11
|
+
* ON backfills up to 10 recent messages
|
|
12
|
+
* (replies + your prompts)
|
|
13
|
+
* /md-link-dir set the default output directory
|
|
14
|
+
* /md-link-keep keep only the N newest replies (empty = all);
|
|
15
|
+
* existing mirrors are trimmed immediately
|
|
16
|
+
* /md-link-persist [on|off] persistent mirrors: while ON, TUI exit keeps
|
|
17
|
+
* this project's mirror files + session entries
|
|
18
|
+
* and mirroring auto-resumes next launch;
|
|
19
|
+
* explicit /md-link OFF still deletes at once
|
|
20
|
+
*
|
|
21
|
+
* On TUI exit, mirror files belonging to this project are deleted unless
|
|
22
|
+
* persistence (/md-link-persist) is ON. See core.ts for state/file contracts.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { accessSync, constants as fsConstants, existsSync, readFileSync, rmSync, statSync } from "fs"
|
|
26
|
+
import { isAbsolute, join, normalize } from "path"
|
|
27
|
+
import { homedir } from "os"
|
|
28
|
+
import type { OpenCodeClient } from "@opencode/client"
|
|
29
|
+
import type { Plugin } from "@opencode/plugin/tui"
|
|
30
|
+
import {
|
|
31
|
+
appendMessage,
|
|
32
|
+
disableSession,
|
|
33
|
+
hasBlock,
|
|
34
|
+
isEnabled,
|
|
35
|
+
loadState,
|
|
36
|
+
messageText,
|
|
37
|
+
mirrorFile,
|
|
38
|
+
pruneMirror,
|
|
39
|
+
saveState,
|
|
40
|
+
touchMirror,
|
|
41
|
+
userPromptBlock,
|
|
42
|
+
} from "./core.ts"
|
|
43
|
+
|
|
44
|
+
type DirResult = { ok: true; abs: string } | { ok: false; error: string }
|
|
45
|
+
type ToastVariant = "info" | "success" | "warning" | "error"
|
|
46
|
+
|
|
47
|
+
/** Cap on how many historical messages a single enable may backfill (V1 parity). */
|
|
48
|
+
const MAX_CATCHUP = 10
|
|
49
|
+
|
|
50
|
+
/** Validate a user-supplied output dir. Must already exist and be writable. */
|
|
51
|
+
function validateDir(raw: string, pwd: string): DirResult {
|
|
52
|
+
let d = String(raw ?? "").trim().replace(/^["']+|["']+$/g, "")
|
|
53
|
+
if (d.startsWith("~")) d = join(homedir(), d.slice(1))
|
|
54
|
+
if (d.length === 0) d = "."
|
|
55
|
+
if (d.includes("\0")) return { ok: false, error: "invalid path" }
|
|
56
|
+
if (d.split("/").includes("..")) return { ok: false, error: "path may not contain '..'" }
|
|
57
|
+
if (!/^[A-Za-z0-9 ._\-\/]+$/.test(d)) return { ok: false, error: `unsupported characters in "${d}"` }
|
|
58
|
+
|
|
59
|
+
const abs = isAbsolute(d) ? normalize(d) : normalize(join(pwd, d))
|
|
60
|
+
try {
|
|
61
|
+
if (!existsSync(abs)) return { ok: false, error: `directory does not exist: ${abs}` }
|
|
62
|
+
if (!statSync(abs).isDirectory()) return { ok: false, error: `not a directory: ${abs}` }
|
|
63
|
+
accessSync(abs, fsConstants.W_OK)
|
|
64
|
+
} catch (e: any) {
|
|
65
|
+
return { ok: false, error: `cannot use ${abs}: ${e?.code ?? e?.message ?? e}` }
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, abs }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Pull a free-form argument out of whatever the slash/keybind run() hands us. */
|
|
71
|
+
function extractArg(input: unknown): string {
|
|
72
|
+
if (typeof input === "string") return input
|
|
73
|
+
if (Array.isArray(input)) return input.map((x) => extractArg(x)).join(" ")
|
|
74
|
+
const r = input as any
|
|
75
|
+
if (r && typeof r === "object") {
|
|
76
|
+
for (const k of ["value", "arg", "args", "input", "arguments", "text", "message", "prompt"]) {
|
|
77
|
+
const v = r[k]
|
|
78
|
+
if (typeof v === "string") return v
|
|
79
|
+
if (Array.isArray(v)) return v.map((x) => extractArg(x)).join(" ")
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return ""
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Backfill recent history when mirroring is enabled for a session: up to
|
|
87
|
+
* MAX_CATCHUP newest messages, BOTH assistant replies and user prompts,
|
|
88
|
+
* appended oldest-first so the mirror stays chronological (V1 catch-up parity).
|
|
89
|
+
*
|
|
90
|
+
* The page is newest-first via the plugin client
|
|
91
|
+
* (ctx.client.message.list = GET /api/session/:id/message). Walking stops at
|
|
92
|
+
* the first already-mirrored marker, so a live event that won the race never
|
|
93
|
+
* produces a duplicate block.
|
|
94
|
+
*
|
|
95
|
+
* Replies still streaming when the toggle fires are skipped: their
|
|
96
|
+
* time.created is too recent, and live ordinals will finish them anyway.
|
|
97
|
+
*/
|
|
98
|
+
async function backfillLatestTurn(
|
|
99
|
+
client: OpenCodeClient,
|
|
100
|
+
sessionID: string,
|
|
101
|
+
file: string,
|
|
102
|
+
keep: number | null,
|
|
103
|
+
enabledAt: number,
|
|
104
|
+
): Promise<number> {
|
|
105
|
+
let written = 0
|
|
106
|
+
try {
|
|
107
|
+
const { data: items } = await client.message.list({
|
|
108
|
+
sessionID,
|
|
109
|
+
order: "desc",
|
|
110
|
+
limit: 50,
|
|
111
|
+
})
|
|
112
|
+
const pending: { id: string; text: string }[] = []
|
|
113
|
+
for (const m of items) {
|
|
114
|
+
if (m.type !== "assistant" && m.type !== "user") continue
|
|
115
|
+
const created = Number(m.time?.created ?? 0)
|
|
116
|
+
if (m.type === "assistant" && created && enabledAt - created < 15_000) continue // likely still streaming
|
|
117
|
+
const text = messageText(m)
|
|
118
|
+
if (!text) continue
|
|
119
|
+
if (hasBlock(file, m.id)) break // already mirrored — everything older is too
|
|
120
|
+
pending.push({
|
|
121
|
+
id: `${m.id}#backfill`,
|
|
122
|
+
text: m.type === "user" ? userPromptBlock(text) : text,
|
|
123
|
+
})
|
|
124
|
+
if (pending.length >= MAX_CATCHUP) break
|
|
125
|
+
}
|
|
126
|
+
for (let i = pending.length - 1; i >= 0; i--) {
|
|
127
|
+
const res = appendMessage(file, { text: pending[i].text, markerKey: pending[i].id, replace: true }, keep)
|
|
128
|
+
if (res === "written") written++
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
return written
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function setupMdLinkTui(ctx: Plugin.Context): Promise<Plugin.Cleanup> {
|
|
135
|
+
const pwd = (): string => ctx.data.location.default().directory || process.cwd()
|
|
136
|
+
|
|
137
|
+
const toast = (message: string, variant: ToastVariant = "info") => {
|
|
138
|
+
try {
|
|
139
|
+
ctx.ui.toast.show({ title: "md-link", message, variant })
|
|
140
|
+
} catch {}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const short = (s: string) => (s.length > 28 ? s.slice(0, 28) + "…" : s)
|
|
144
|
+
|
|
145
|
+
// ——— toggle ———
|
|
146
|
+
|
|
147
|
+
/** Collapse duplicate activations: stacked layers from hot-reloads fire
|
|
148
|
+
* run() twice per keypress (ON then instantly OFF). */
|
|
149
|
+
let lastRun = 0
|
|
150
|
+
async function run(input?: unknown): Promise<void> {
|
|
151
|
+
const now = Date.now()
|
|
152
|
+
if (now - lastRun < 500) return
|
|
153
|
+
lastRun = now
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const cur = ctx.ui.router.current()
|
|
157
|
+
const sessionID = cur.type === "session" ? cur.sessionID : undefined
|
|
158
|
+
if (!sessionID || typeof sessionID !== "string") {
|
|
159
|
+
toast("No active session found", "warning")
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const st = loadState()
|
|
164
|
+
const arg = extractArg(input).trim()
|
|
165
|
+
const res = validateDir(arg || st.defaultDir || pwd(), pwd())
|
|
166
|
+
if (!res.ok) {
|
|
167
|
+
toast(`Invalid dir "${arg || st.defaultDir}": ${res.error}`, "error")
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
if (arg) st.defaultDir = res.abs // remember explicitly requested dirs
|
|
171
|
+
|
|
172
|
+
const file = join(res.abs, `${sessionID}_link.md`)
|
|
173
|
+
if (isEnabled(st, sessionID)) {
|
|
174
|
+
const oldDir = st.sessions[sessionID]
|
|
175
|
+
disableSession(st, sessionID)
|
|
176
|
+
for (const dir of new Set([oldDir, res.abs])) {
|
|
177
|
+
try { rmSync(join(dir ?? "", `${sessionID}_link.md`), { force: true }) } catch {}
|
|
178
|
+
}
|
|
179
|
+
toast(`Live mirror OFF (${short(sessionID)})`, "warning")
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
st.sessions[sessionID] = res.abs
|
|
184
|
+
saveState(st)
|
|
185
|
+
touchMirror(file)
|
|
186
|
+
const backfilled = await backfillLatestTurn(ctx.client, sessionID, file, st.keep, Date.now())
|
|
187
|
+
const tag = st.persist ? " (persistent)" : ""
|
|
188
|
+
toast(
|
|
189
|
+
backfilled > 0
|
|
190
|
+
? `Live mirror ON, backfilled ${backfilled} message${backfilled === 1 ? "" : "s"} → ${short(file)}${tag}`
|
|
191
|
+
: `Live mirror ON → ${file}${tag}`,
|
|
192
|
+
)
|
|
193
|
+
} catch (e: any) {
|
|
194
|
+
toast(`Failed: ${e?.message ?? e}`, "error")
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ——— dialogs ———
|
|
199
|
+
|
|
200
|
+
async function openSetDirDialog(): Promise<void> {
|
|
201
|
+
try {
|
|
202
|
+
const v = await ctx.ui.dialog.prompt({
|
|
203
|
+
title: "md-link output directory",
|
|
204
|
+
placeholder: `relative to project (${pwd()}) or absolute path`,
|
|
205
|
+
value: loadState().defaultDir ?? "",
|
|
206
|
+
})
|
|
207
|
+
if (typeof v !== "string") return // cancelled
|
|
208
|
+
const res = validateDir(v, pwd())
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
toast(`Invalid dir: ${res.error}`, "error")
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
const st = loadState()
|
|
214
|
+
st.defaultDir = res.abs
|
|
215
|
+
saveState(st)
|
|
216
|
+
toast(`Default output dir set → ${res.abs}`)
|
|
217
|
+
} catch (e: any) {
|
|
218
|
+
toast(`Cannot open dialog: ${e?.message ?? e}`, "error")
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function openKeepDialog(): Promise<void> {
|
|
223
|
+
try {
|
|
224
|
+
const cur = loadState().keep
|
|
225
|
+
const v = await ctx.ui.dialog.prompt({
|
|
226
|
+
title: "md-link: show last N messages",
|
|
227
|
+
placeholder: "number of recent messages to keep (empty = unlimited)",
|
|
228
|
+
value: cur != null ? String(cur) : "",
|
|
229
|
+
})
|
|
230
|
+
if (typeof v !== "string") return // cancelled
|
|
231
|
+
const t = v.trim()
|
|
232
|
+
let keep: number | null = null
|
|
233
|
+
if (t.length > 0) {
|
|
234
|
+
const n = Number(t)
|
|
235
|
+
if (!Number.isInteger(n) || n < 1 || n > 500) {
|
|
236
|
+
toast(`Invalid count "${t}" — use 1..500 or empty`, "error")
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
keep = n
|
|
240
|
+
}
|
|
241
|
+
const st = loadState()
|
|
242
|
+
st.keep = keep
|
|
243
|
+
saveState(st)
|
|
244
|
+
if (keep == null) {
|
|
245
|
+
toast("Mirror keeps all messages")
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
// Apply immediately: trim every enabled session's live mirror now.
|
|
249
|
+
let trimmed = 0
|
|
250
|
+
for (const sid of Object.keys(st.sessions)) {
|
|
251
|
+
const f = mirrorFile(st, sid, pwd())
|
|
252
|
+
if (!f || !existsSync(f)) continue
|
|
253
|
+
const before = readFileSync(f, "utf-8")
|
|
254
|
+
pruneMirror(f, keep)
|
|
255
|
+
if (readFileSync(f, "utf-8") !== before) trimmed++
|
|
256
|
+
}
|
|
257
|
+
toast(`Mirror shows only the last ${keep}${trimmed ? ` (${trimmed} mirror${trimmed === 1 ? "" : "s"} trimmed)` : ""}`)
|
|
258
|
+
} catch (e: any) {
|
|
259
|
+
toast(`Cannot open dialog: ${e?.message ?? e}`, "error")
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ——— persistence toggle ———
|
|
264
|
+
|
|
265
|
+
/** Flip (or force via on|off arg) persistent-mirror mode: kept mirrors
|
|
266
|
+
* survive TUI exit and their sessions auto-resume on next launch. */
|
|
267
|
+
async function togglePersistence(input?: unknown): Promise<void> {
|
|
268
|
+
try {
|
|
269
|
+
const st = loadState()
|
|
270
|
+
const arg = extractArg(input).trim().toLowerCase()
|
|
271
|
+
if (arg === "on" || arg === "true" || arg === "1") st.persist = true
|
|
272
|
+
else if (arg === "off" || arg === "false" || arg === "0") st.persist = false
|
|
273
|
+
else st.persist = !st.persist
|
|
274
|
+
saveState(st)
|
|
275
|
+
toast(
|
|
276
|
+
st.persist
|
|
277
|
+
? "Persistent mirrors ON — survive TUI exit, resume next launch"
|
|
278
|
+
: "Persistent mirrors OFF — mirrors deleted on TUI exit",
|
|
279
|
+
st.persist ? "info" : "warning",
|
|
280
|
+
)
|
|
281
|
+
} catch (e: any) {
|
|
282
|
+
toast(`Failed: ${e?.message ?? e}`, "error")
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ——— startup notice for persistent mirrors ———
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
const root = pwd()
|
|
290
|
+
const resumed = Object.values(loadState().sessions).filter((d) => root && d.startsWith(root)).length
|
|
291
|
+
if (resumed > 0) toast(`md-link: resumed ${resumed} persistent mirror${resumed === 1 ? "" : "s"}`)
|
|
292
|
+
} catch {}
|
|
293
|
+
|
|
294
|
+
// ——— cleanup on TUI close (skipped while persistence is ON) ———
|
|
295
|
+
|
|
296
|
+
let cleaned = false
|
|
297
|
+
function cleanupOnExit(): void {
|
|
298
|
+
if (cleaned) return
|
|
299
|
+
cleaned = true
|
|
300
|
+
try {
|
|
301
|
+
const root = pwd()
|
|
302
|
+
const st = loadState()
|
|
303
|
+
if (st.persist) return // persistent mode: keep files + entries, resume next launch
|
|
304
|
+
for (const [sid, dir] of Object.entries(st.sessions)) {
|
|
305
|
+
// only remove mirrors owned by THIS project — other TUIs keep theirs
|
|
306
|
+
if (root && !dir.startsWith(root)) continue
|
|
307
|
+
try { rmSync(join(dir || ".", `${sid}_link.md`), { force: true }) } catch {}
|
|
308
|
+
delete st.sessions[sid]
|
|
309
|
+
}
|
|
310
|
+
saveState(st)
|
|
311
|
+
} catch {}
|
|
312
|
+
}
|
|
313
|
+
// belt-and-braces: host cleanup covers normal exit; this covers hard exits
|
|
314
|
+
try { process.once("exit", cleanupOnExit) } catch {}
|
|
315
|
+
|
|
316
|
+
// ——— registration (callable directly from setup; removed on deactivate) ———
|
|
317
|
+
|
|
318
|
+
ctx.keymap.layer(() => ({
|
|
319
|
+
mode: "global",
|
|
320
|
+
priority: 1,
|
|
321
|
+
enabled: true,
|
|
322
|
+
commands: [
|
|
323
|
+
{
|
|
324
|
+
id: "md-link.toggle",
|
|
325
|
+
title: "Live mirror",
|
|
326
|
+
group: "md-link",
|
|
327
|
+
bind: "ctrl+alt+m",
|
|
328
|
+
palette: true,
|
|
329
|
+
slash: { name: "md-link" },
|
|
330
|
+
run,
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
id: "md-link.setdir",
|
|
334
|
+
title: "Output directory…",
|
|
335
|
+
group: "md-link",
|
|
336
|
+
palette: true,
|
|
337
|
+
slash: { name: "md-link-dir" },
|
|
338
|
+
run: () => openSetDirDialog(),
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
id: "md-link.keep",
|
|
342
|
+
title: "Keep last N",
|
|
343
|
+
group: "md-link",
|
|
344
|
+
palette: true,
|
|
345
|
+
slash: { name: "md-link-keep" },
|
|
346
|
+
run: () => openKeepDialog(),
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
id: "md-link.persist",
|
|
350
|
+
title: "Persistence",
|
|
351
|
+
group: "md-link",
|
|
352
|
+
palette: true,
|
|
353
|
+
slash: { name: "md-link-persist" },
|
|
354
|
+
run: (input?: unknown) => togglePersistence(input),
|
|
355
|
+
},
|
|
356
|
+
],
|
|
357
|
+
}))
|
|
358
|
+
|
|
359
|
+
return cleanupOnExit
|
|
360
|
+
}
|