@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.
@@ -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
+ }