@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.
@@ -0,0 +1,314 @@
1
+ /**
2
+ * md-link — Obsidian live-mirror (server side).
3
+ *
4
+ * Mirrors a session to `<dir>/<sessionID>_link.md` for sessions enabled in
5
+ * the state file (see core.ts for the contracts). Sessions are toggled
6
+ * exclusively by the TUI plugin — this plugin never enables anything.
7
+ *
8
+ * Mirrors are transient by default — with persistence ON (/md-link-persist),
9
+ * session entries survive TUI exit and this subscription resumes them on next launch.
10
+ *
11
+ * What gets mirrored (teaching-session parity with the pi md-log):
12
+ * - assistant replies → ctx.event.subscribe session.text.delta (2.5 s
13
+ * throttle while streaming) + session.text.ended
14
+ * - user prompts → session.inbox.enqueued, as `> [!quote] YOU` callouts
15
+ * - quiz_ask / question → tool hooks: question callout written at
16
+ * execute.before (pre-answer, sanitized input),
17
+ * feedback callout at execute.after (quiz_grade /
18
+ * question results)
19
+ * The subscription is aborted by the cleanup returned from setup; an
20
+ * unexpected stream exit is logged and re-armed (the old poller self-recovered).
21
+ */
22
+
23
+ import type { Plugin } from "@opencode/plugin";
24
+ import {
25
+ appendMessage,
26
+ callout,
27
+ hasBlock,
28
+ isEnabled,
29
+ loadState,
30
+ mirrorFile,
31
+ touchMirror,
32
+ userPromptBlock,
33
+ } from "./core.ts";
34
+
35
+ /** Streaming cadence: rewrite an in-flight reply block at most this often. */
36
+ const DELTA_WRITE_MS = 2_500;
37
+ /** Wait before re-subscribing after the event stream exits unexpectedly. */
38
+ const RESUBSCRIBE_MS = 5_000;
39
+
40
+ /** Append one block, keeping the existing text when the key is already mirrored. */
41
+ function mirrorBlock(
42
+ file: string,
43
+ key: string,
44
+ text: string,
45
+ keep: number | null,
46
+ ): void {
47
+ if (!text || hasBlock(file, key)) return;
48
+ appendMessage(file, { text, markerKey: key, replace: true }, keep);
49
+ }
50
+
51
+ /** Write an assistant block, rewriting the existing one in place while streaming. */
52
+ function writeAssistant(
53
+ file: string,
54
+ key: string,
55
+ text: string,
56
+ keep: number | null,
57
+ ): void {
58
+ if (!text) return;
59
+ appendMessage(file, { text, markerKey: key, replace: true }, keep);
60
+ }
61
+
62
+ /** The subset of the session event stream this plugin mirrors. */
63
+ type MirrorEvent =
64
+ | {
65
+ type: "session.text.delta";
66
+ data: {
67
+ sessionID: string;
68
+ assistantMessageID: string;
69
+ ordinal: number;
70
+ delta: string;
71
+ };
72
+ }
73
+ | {
74
+ type: "session.text.ended";
75
+ data: {
76
+ sessionID: string;
77
+ assistantMessageID: string;
78
+ ordinal: number;
79
+ text: string;
80
+ };
81
+ }
82
+ | {
83
+ type: "session.inbox.enqueued";
84
+ data: {
85
+ sessionID: string;
86
+ inboxID: string;
87
+ item:
88
+ | { type: "user"; payload: { text: string } }
89
+ | { type: "synthetic" | "compaction" | "move" };
90
+ };
91
+ };
92
+
93
+ // ── Q&A callouts via tool hooks ─────────────────────────────────────────────
94
+
95
+ /** Resolve the mirror file for a session, or null if not enabled. */
96
+ function mirrorFor(sessionID: unknown): string | null {
97
+ if (typeof sessionID !== "string" || !sessionID) return null;
98
+ const st = loadState();
99
+ if (!isEnabled(st, sessionID)) return null;
100
+ return mirrorFile(st, sessionID, process.cwd());
101
+ }
102
+
103
+ function appendQa(
104
+ file: string,
105
+ markerKey: string,
106
+ text: string,
107
+ keep: number | null,
108
+ ): void {
109
+ const res = appendMessage(file, { text, markerKey, replace: true }, keep);
110
+ // "duplicate" is the expected steady state on service restarts / re-reads.
111
+ if (res === "failed") console.error("[md-link] qa append failed:", markerKey);
112
+ }
113
+
114
+ function questionCallout(
115
+ label: string,
116
+ question: string,
117
+ details: string | undefined,
118
+ options: any[],
119
+ ): string {
120
+ const body: string[] = [];
121
+ for (const line of String(question || "").split("\n")) body.push(line);
122
+ if (details) {
123
+ body.push("");
124
+ for (const line of details.split("\n")) body.push(line);
125
+ }
126
+ const opts = Array.isArray(options) ? options : [];
127
+ if (opts.length > 0) {
128
+ body.push("");
129
+ opts.forEach((o, i) => body.push(`${i + 1}. ${o?.label ?? ""}`));
130
+ }
131
+ return callout("question", label, body);
132
+ }
133
+
134
+ function resultText(result: any): string {
135
+ if (typeof result === "string") return result;
136
+ if (typeof result?.content === "string") return result.content;
137
+ if (Array.isArray(result?.content)) {
138
+ return result.content
139
+ .filter((c: any) => c?.type === "text")
140
+ .map((c: any) => c.text)
141
+ .join("\n");
142
+ }
143
+ return String(result?.output ?? "");
144
+ }
145
+
146
+ /** Feedback callout from quiz_grade's result text (we control that format). */
147
+ function gradeCallout(content: string): string {
148
+ const lines = content
149
+ .split("\n")
150
+ .filter(
151
+ (l) => l.trim().length > 0 && !/^Relay the verdict/i.test(l.trim()),
152
+ );
153
+ const first = lines[0] ?? "";
154
+ if (/answered correctly/i.test(first)) {
155
+ return callout("success", "Quiz — correct ✓", lines.slice(1));
156
+ }
157
+ if (/answered incorrectly/i.test(first)) {
158
+ return callout("failure", "Quiz — incorrect ✗", lines.slice(1));
159
+ }
160
+ if (/...?/i.test(content)) {
161
+ return callout("question", "Quiz — ...?", lines.slice(1));
162
+ }
163
+ if (/dismissed|not answered in time/i.test(content)) {
164
+ return callout("warning", "Quiz — dismissed", ["(no answer given)"]);
165
+ }
166
+ return callout("example", "Quiz", lines);
167
+ }
168
+
169
+ export async function setupMdLink(ctx: Plugin.Context): Promise<() => void> {
170
+ const controller = new AbortController();
171
+ /** In-flight assistant text per `<messageID>#<ordinal>` and its last write time. */
172
+ const inflight = new Map<string, { text: string; written: number }>();
173
+
174
+ const handleEvent = (event: MirrorEvent): void => {
175
+ try {
176
+ const st = loadState();
177
+ const sessionID = event.data.sessionID;
178
+ if (!isEnabled(st, sessionID)) return;
179
+ // Fallback chain: recorded dir → config defaultDir → cwd (no hardcoded paths).
180
+ const file = mirrorFile(st, sessionID, process.cwd());
181
+ if (!file) return;
182
+ touchMirror(file);
183
+
184
+ if (event.type === "session.inbox.enqueued") {
185
+ const item = event.data.item;
186
+ if (item.type !== "user") return;
187
+ const text = String(item.payload.text ?? "").trim();
188
+ if (text)
189
+ mirrorBlock(
190
+ file,
191
+ `${event.data.inboxID}`,
192
+ userPromptBlock(text),
193
+ st.keep,
194
+ );
195
+ return;
196
+ }
197
+
198
+ const key = `${event.data.assistantMessageID}#${event.data.ordinal}`;
199
+ if (event.type === "session.text.delta") {
200
+ const entry = inflight.get(key) ?? { text: "", written: 0 };
201
+ entry.text += event.data.delta;
202
+ const now = Date.now();
203
+ if (now - entry.written >= DELTA_WRITE_MS) {
204
+ entry.written = now;
205
+ writeAssistant(file, key, entry.text.trim(), st.keep);
206
+ }
207
+ inflight.set(key, entry);
208
+ return;
209
+ }
210
+
211
+ // session.text.ended: final text for the part, written immediately.
212
+ inflight.delete(key);
213
+ writeAssistant(file, key, event.data.text.trim(), st.keep);
214
+ } catch {} // one bad event must not stop the subscription
215
+ };
216
+
217
+ // Re-arm after an unexpected stream exit; the old poller recovered by itself.
218
+ const subscribe = async (): Promise<void> => {
219
+ while (!controller.signal.aborted) {
220
+ try {
221
+ for await (const event of ctx.event.subscribe({
222
+ types: [
223
+ "session.text.delta",
224
+ "session.text.ended",
225
+ "session.inbox.enqueued",
226
+ ],
227
+ signal: controller.signal,
228
+ })) {
229
+ handleEvent(event);
230
+ }
231
+ if (!controller.signal.aborted)
232
+ console.error("[md-link] event stream ended — resubscribing");
233
+ } catch (error) {
234
+ if (controller.signal.aborted) return;
235
+ console.error(
236
+ "[md-link] event subscription failed — resubscribing:",
237
+ error,
238
+ );
239
+ }
240
+ inflight.clear();
241
+ if (controller.signal.aborted) return;
242
+ await new Promise<void>((resolve) => {
243
+ const done = () => {
244
+ clearTimeout(timer);
245
+ controller.signal.removeEventListener("abort", done);
246
+ resolve();
247
+ };
248
+ const timer = setTimeout(done, RESUBSCRIBE_MS);
249
+ controller.signal.addEventListener("abort", done, { once: true });
250
+ });
251
+ }
252
+ };
253
+
254
+ void subscribe();
255
+
256
+ // ── Q&A capture ─────────────────────────────────────────────────────────
257
+ // execute.before: write the question callout BEFORE the learner answers
258
+ // (quiz_ask/question inputs are sanitized — no answer material).
259
+ if (typeof ctx?.tool?.hook === "function") {
260
+ await ctx.tool.hook("execute.before", async (event: any) => {
261
+ try {
262
+ const file = mirrorFor(event.sessionID);
263
+ if (!file) return;
264
+ const { keep } = loadState();
265
+ if (event.tool === "quiz_ask") {
266
+ const input = event.input ?? {};
267
+ const block = questionCallout(
268
+ "Quiz",
269
+ input.question,
270
+ input.details,
271
+ input.options,
272
+ );
273
+ appendQa(file, `qa-ask-${event.id}`, block, keep);
274
+ } else if (event.tool === "question") {
275
+ const q = event.input?.questions?.[0] ?? {};
276
+ const block = questionCallout(
277
+ "Question",
278
+ q.question ?? q.header ?? "",
279
+ undefined,
280
+ q.options,
281
+ );
282
+ appendQa(file, `qa-ask-${event.id}`, block, keep);
283
+ }
284
+ } catch {}
285
+ });
286
+
287
+ // execute.after: write the feedback callout (answer already given).
288
+ await ctx.tool.hook("execute.after", async (event: any) => {
289
+ try {
290
+ const file = mirrorFor(event.sessionID);
291
+ if (!file) return;
292
+ const { keep } = loadState();
293
+ if (event.tool === "quiz_grade") {
294
+ const content = resultText(event.result);
295
+ if (content)
296
+ appendQa(file, `qa-grade-${event.id}`, gradeCallout(content), keep);
297
+ } else if (event.tool === "question" && event.status === "completed") {
298
+ const content = resultText(event.result);
299
+ if (content)
300
+ appendQa(
301
+ file,
302
+ `qa-grade-${event.id}`,
303
+ callout("example", "Answer", content.split("\n")),
304
+ keep,
305
+ );
306
+ }
307
+ } catch {}
308
+ });
309
+ } else {
310
+ console.error("[md-link] ctx.tool.hook unavailable — Q&A capture disabled");
311
+ }
312
+
313
+ return () => controller.abort();
314
+ }
@@ -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
+ }