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