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