cotutor 0.1.0 → 0.2.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.
Files changed (109) hide show
  1. package/README.md +35 -24
  2. package/agents/chinese-tutor.md +67 -0
  3. package/agents/{homework-aide.md → homework-tutor.md} +4 -4
  4. package/agents/math-tutor.md +68 -0
  5. package/agents/reading-tutor.md +65 -0
  6. package/agents/scene-maker.md +41 -0
  7. package/dist/cards/canvas.d.ts +22 -0
  8. package/dist/cards/canvas.js +65 -0
  9. package/dist/cards/choice.d.ts +20 -0
  10. package/dist/cards/choice.js +76 -0
  11. package/dist/cards/code.d.ts +9 -0
  12. package/dist/cards/code.js +13 -0
  13. package/dist/cards/fill.d.ts +14 -0
  14. package/dist/cards/fill.js +64 -0
  15. package/dist/cards/image.d.ts +13 -0
  16. package/dist/cards/image.js +35 -0
  17. package/dist/cards/index.d.ts +48 -0
  18. package/dist/cards/index.js +115 -0
  19. package/dist/cards/kind.d.ts +27 -0
  20. package/dist/cards/kind.js +7 -0
  21. package/dist/cards/read.d.ts +8 -0
  22. package/dist/cards/read.js +27 -0
  23. package/dist/cards/scene.d.ts +24 -0
  24. package/dist/cards/scene.js +67 -0
  25. package/dist/cards/text.d.ts +18 -0
  26. package/dist/cards/text.js +41 -0
  27. package/dist/cli/cert.d.ts +7 -0
  28. package/dist/cli/cert.js +36 -0
  29. package/dist/cli/doctor.d.ts +5 -2
  30. package/dist/cli/doctor.js +183 -18
  31. package/dist/cli/init.d.ts +1 -1
  32. package/dist/cli/init.js +41 -39
  33. package/dist/cli/main.js +128 -8
  34. package/dist/cli/serve.d.ts +15 -4
  35. package/dist/cli/serve.js +40 -6
  36. package/dist/cli/skeleton.d.ts +23 -5
  37. package/dist/cli/skeleton.js +131 -29
  38. package/dist/cli/skills.d.ts +40 -0
  39. package/dist/cli/skills.js +170 -0
  40. package/dist/cli/tutors.d.ts +84 -0
  41. package/dist/cli/tutors.js +274 -0
  42. package/dist/cli/workspace.d.ts +9 -1
  43. package/dist/cli/workspace.js +25 -4
  44. package/dist/lib/agent-file.d.ts +1 -1
  45. package/dist/lib/board.d.ts +15 -0
  46. package/dist/lib/board.js +107 -0
  47. package/dist/lib/context-pack.js +10 -1
  48. package/dist/lib/conversation.d.ts +43 -6
  49. package/dist/lib/conversation.js +53 -9
  50. package/dist/lib/index.d.ts +4 -0
  51. package/dist/lib/index.js +4 -0
  52. package/dist/lib/kid-board.d.ts +148 -0
  53. package/dist/lib/kid-board.js +294 -0
  54. package/dist/lib/kid-view.d.ts +44 -7
  55. package/dist/lib/kid-view.js +63 -8
  56. package/dist/lib/ledger.d.ts +8 -0
  57. package/dist/lib/ledger.js +8 -0
  58. package/dist/lib/plan.d.ts +2 -0
  59. package/dist/lib/plan.js +9 -0
  60. package/dist/lib/run-plan.d.ts +30 -0
  61. package/dist/lib/run-plan.js +32 -0
  62. package/dist/lib/stream.d.ts +15 -0
  63. package/dist/lib/stream.js +69 -0
  64. package/dist/lib/timetable.d.ts +22 -0
  65. package/dist/lib/timetable.js +106 -0
  66. package/dist/schema/board.d.ts +59 -0
  67. package/dist/schema/board.js +30 -0
  68. package/dist/schema/config.d.ts +76 -18
  69. package/dist/schema/config.js +67 -37
  70. package/dist/schema/context-pack.d.ts +7 -0
  71. package/dist/schema/context-pack.js +6 -0
  72. package/dist/schema/conversation.d.ts +114 -4
  73. package/dist/schema/conversation.js +29 -5
  74. package/dist/schema/index.d.ts +6 -2
  75. package/dist/schema/index.js +6 -2
  76. package/dist/schema/json-schema.d.ts +2 -0
  77. package/dist/schema/json-schema.js +23 -0
  78. package/dist/schema/ledger.d.ts +1 -1
  79. package/dist/schema/timetable.d.ts +26 -0
  80. package/dist/schema/timetable.js +18 -0
  81. package/dist/server/app.d.ts +67 -6
  82. package/dist/server/app.js +399 -38
  83. package/dist/server/kid-page.d.ts +1 -0
  84. package/dist/server/kid-page.js +768 -0
  85. package/dist/server/mock.d.ts +58 -0
  86. package/dist/server/mock.js +403 -0
  87. package/dist/server/parent-page.d.ts +8 -0
  88. package/dist/server/parent-page.js +393 -0
  89. package/dist/server/runner.d.ts +64 -0
  90. package/dist/server/runner.js +277 -0
  91. package/dist/server/scene-props.d.ts +16 -0
  92. package/dist/server/scene-props.js +39 -0
  93. package/dist/server/stage.d.ts +15 -0
  94. package/dist/server/stage.js +69 -0
  95. package/dist/server/store.d.ts +43 -0
  96. package/dist/server/store.js +210 -0
  97. package/dist/server/tts.d.ts +51 -0
  98. package/dist/server/tts.js +99 -0
  99. package/dist/stage/Assistant-Bold-ZDZZ6JHA.woff2 +0 -0
  100. package/dist/stage/Assistant-Medium-DZ25RZU3.woff2 +0 -0
  101. package/dist/stage/Assistant-Regular-PLF2XOGW.woff2 +0 -0
  102. package/dist/stage/Assistant-SemiBold-CZ5MX6FK.woff2 +0 -0
  103. package/dist/stage/index.html +10 -0
  104. package/dist/stage/stage.css +1 -0
  105. package/dist/stage/stage.js +4383 -0
  106. package/package.json +13 -4
  107. package/agents/chinese-teacher.md +0 -19
  108. package/agents/math-teacher.md +0 -19
  109. package/agents/reading-teacher.md +0 -19
@@ -0,0 +1,277 @@
1
+ /**
2
+ * 发消息 = 拼上下文包 → 按运行时 spawn / resume → stream-json 落 conversations/<老师>/<日期>.<job>.log → 索引更新
3
+ * (最终文本 → 板书节 section + 讲稿逐句配音 <日期>.<job>.<n>.mp3 + 家长尾巴 parentText + 解析提醒 warnings)。
4
+ * 流式:stdout 经本进程落盘,同时喂 PartialReader 拼当前回复正文 → parseBoard(partial) → 内存里的 partial section
5
+ * (孩子端 pending 条目带着它,卡随围栏闭合逐张出现);讲稿每定稿一句就开始配音,整轮跑完只等没配完的;
6
+ * 点读段等资产在索引写好之后后台接着配(同一条队列),孩子端点到还没好的段用浏览器的声。
7
+ * 「## 转交」自动起目标老师的一轮(from: system,转交单 = 谁转的、why、refs、孩子的话、voice):目标老师不在 / 关着 / 忙 / 场景作业到了 dailyMax
8
+ * 都不起,原因进这条消息的 warnings;起了记 handoffJob。目标老师用自己的 runtime(cotutor.json tutors.<name>.runtime)。
9
+ * 一老师同时只跑一条(老师还在回上一条就 409),跨天自动新开(索引按本地日期分文件,新文件没 session 就不带 --resume)。
10
+ * 进程 cwd 是老师目录 agents/<name>/(《agent层设计.md》拍板)。
11
+ */
12
+ import { spawn } from 'node:child_process';
13
+ import { closeSync, createWriteStream, existsSync, openSync } from 'node:fs';
14
+ import { mkdir, readFile } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ import { buildContextPack } from "../lib/context-pack.js";
17
+ import { addMessage, applyRun, cardId, changedCards, conversationFiles, jobId, localDate, localMinute } from "../lib/conversation.js";
18
+ import { BUNDLE_ID_RE, cardAssets, describeCard } from "../cards/index.js";
19
+ import { parseBoard } from "../lib/board.js";
20
+ import { deriveKidView, truncateReply } from "../lib/kid-view.js";
21
+ import { createPartialReader } from "../lib/stream.js";
22
+ import { mergeObservations, parseObservations, recentObservations } from "../lib/ledger.js";
23
+ import { isoWeek, parsePlan, planLinesFor } from "../lib/plan.js";
24
+ import { getRuntime, planRun, runtimeUses } from "../lib/run-plan.js";
25
+ import { currentSlot, parseTimetable, slotLabel } from "../lib/timetable.js";
26
+ import { parseTranscript } from "../lib/transcript.js";
27
+ import { resolvePolicy } from "../schema/index.js";
28
+ import { UsageError } from "../cli/workspace.js";
29
+ import { readAgentBody, readCardStates, readIndex, writeIndex } from "./store.js";
30
+ import { DubQueue, LineDubber, dubReply } from "./tts.js";
31
+ export class BusyError extends Error {
32
+ constructor(tutor, job) {
33
+ super(`${tutor} 还在回上一条(${job}),等它说完再发`);
34
+ }
35
+ }
36
+ /** 上下文包的取材:课程表命中的时段 + 本周计划里本老师的行 + 本学科最近观察 */
37
+ export async function gatherContext(ws, tutor, input) {
38
+ const t = ws.config.tutors[tutor];
39
+ const policy = resolvePolicy(ws.config, tutor);
40
+ const pack = { from: input.from, at: localMinute(input.at), focus: input.focus, plan: [], recent: [] };
41
+ try {
42
+ const slot = currentSlot(parseTimetable(await readFile(ws.paths.timetable, 'utf8')).entries, input.at);
43
+ if (slot)
44
+ pack.slot = slotLabel(slot);
45
+ }
46
+ catch {
47
+ /* 没有课程表:不带 slot */
48
+ }
49
+ try {
50
+ const { plan } = parsePlan(await readFile(join(ws.paths.plans, `${isoWeek(input.at)}.md`), 'utf8'));
51
+ if (plan && t)
52
+ pack.plan = planLinesFor(plan, t.display, policy.contextPack.planLines);
53
+ }
54
+ catch {
55
+ /* 没有本周计划,或读不到:上下文包不带 plan */
56
+ }
57
+ try {
58
+ const { rows } = parseObservations(await readFile(ws.files.observations, 'utf8'));
59
+ pack.recent = recentObservations(mergeObservations(rows), { subject: t?.subject, n: policy.contextPack.recent });
60
+ }
61
+ catch {
62
+ /* 账本还没有 */
63
+ }
64
+ return pack;
65
+ }
66
+ export class Runner {
67
+ active = new Map();
68
+ /** 还在后台跑的资产生成(测试与关服前 flush) */
69
+ background = new Set();
70
+ getWs;
71
+ opts;
72
+ constructor(getWs, opts = {}) {
73
+ this.getWs = getWs;
74
+ this.opts = opts;
75
+ }
76
+ running(tutor) {
77
+ const a = this.active.get(tutor);
78
+ return a ? { job: a.job, date: a.date } : null;
79
+ }
80
+ /** 等所有后台资产生成完(测试用) */
81
+ async flush() {
82
+ while (this.background.size)
83
+ await Promise.all([...this.background]);
84
+ }
85
+ /** 正在回的那条已经出来的板书(流式);没在跑或还没出卡 → null */
86
+ partial(tutor) {
87
+ return this.active.get(tutor)?.partial ?? null;
88
+ }
89
+ async send(tutor, input) {
90
+ const ws = this.getWs();
91
+ const t = ws.config.tutors[tutor];
92
+ if (!t)
93
+ throw new UsageError(`没有叫 ${tutor} 的老师;cotutor.json 的 tutors 里有:${Object.keys(ws.config.tutors).join('、')}`);
94
+ if (!t.enabled)
95
+ throw new UsageError(`${t.display} 已关闭(cotutor.json tutors.${tutor}.enabled = false),打开再发`);
96
+ const text = input.text.trim() || (input.action === 'continue' ? '继续' : input.action === 'submit' ? '(交了答案,没说话)' : '');
97
+ if (!text)
98
+ throw new UsageError('消息是空的');
99
+ const busy = this.active.get(tutor);
100
+ if (busy)
101
+ throw new BusyError(tutor, busy.job);
102
+ const now = (this.opts.now ?? (() => new Date()))();
103
+ const date = localDate(now);
104
+ const index = await readIndex(ws, tutor, date);
105
+ const job = jobId(now, index.messages.length + 1);
106
+ const { runtime } = getRuntime(ws.config, input.runtime ?? t.runtime);
107
+ const agentBody = runtimeUses(runtime, '{agentBody}') ? await readAgentBody(ws, tutor) : undefined;
108
+ const pack = await gatherContext(ws, tutor, { from: input.from, at: now, focus: input.focus });
109
+ const policy = resolvePolicy(ws.config, tutor);
110
+ if (policy.board === 'off')
111
+ pack.board = 'off';
112
+ // 上一轮之后孩子在卡上做的事:逐张 describe 进上下文包,也记进这条消息(家长视图「孩子在板书上做的」)
113
+ const cards = changedCards(index, await readCardStates(ws, tutor, date)).map((c) => {
114
+ const card = index.messages.find((m) => m.job === c.job)?.section?.cards[c.n];
115
+ const id = cardId(c.job, c.n);
116
+ return { card: id, text: card ? describeCard(card, c.file.state) : JSON.stringify(c.file.state) };
117
+ });
118
+ if (cards.length)
119
+ pack.cards = cards.map((c) => `${c.card} ${c.text}`);
120
+ const prompt = buildContextPack(pack, text, policy.contextPack);
121
+ const plan = planRun(ws.config, index, { agent: tutor, prompt, agentBody, runtime: input.runtime ?? t.runtime });
122
+ const started = addMessage(index, { job, at: pack.at, from: input.from, text, focus: input.focus, ...(input.action ? { action: input.action } : {}), ...(cards.length ? { cards } : {}), result: 'running', artifacts: [], runtime: plan.runtime });
123
+ await writeIndex(ws, started);
124
+ const active = { job, date, partial: null, done: Promise.resolve(started) };
125
+ active.done = this.spawn(ws, tutor, date, job, plan, policy.replyMaxChars, active).finally(() => this.active.delete(tutor));
126
+ this.active.set(tutor, active);
127
+ return { tutor, date, job, plan, done: active.done };
128
+ }
129
+ /**
130
+ * 给 scene-maker 的转交,课包 id 要在卡、refs、作业三处一致:refs[0] 不是合法 id 就取这节里场景卡的 bundle;
131
+ * scenes/<id>.ts 或 bundles/<id>/ 已经有了(老师起名撞了,真跑见过:孩子卡上放了旧课包)就改成 <id>-2、-3…,卡上的 bundle 一起改。
132
+ */
133
+ settleBundleId(ws, h, section) {
134
+ const warnings = [];
135
+ const fromCard = section?.cards.find((c) => c.kind === 'scene' && typeof c.props.bundle === 'string')?.props.bundle;
136
+ let id = h.refs.find((r) => BUNDLE_ID_RE.test(r)) ?? fromCard;
137
+ if (!id)
138
+ return { handoff: h, section, warnings: ['转交 scene-maker 没给课包 id(refs 里放 id,或正文里放 scene 卡),画图老师会自己起名,孩子端的卡对不上'] };
139
+ const taken = (x) => existsSync(join(ws.dirs.scenes, `${x}.ts`)) || existsSync(join(ws.dirs.scenes, `${x}.md`)) || existsSync(join(ws.dirs.bundles, x));
140
+ if (taken(id)) {
141
+ let n = 2;
142
+ while (taken(`${id}-${n}`))
143
+ n++;
144
+ const fresh = `${id}-${n}`;
145
+ warnings.push(`课包 id ${id} 已占用,改成 ${fresh}(卡与转交单一起改)`);
146
+ id = fresh;
147
+ }
148
+ const refs = [id, ...h.refs.filter((r) => r !== h.refs.find((x) => BUNDLE_ID_RE.test(x)) && r !== fromCard)];
149
+ const cards = section?.cards.map((c) => (c.kind === 'scene' && c.props.bundle === fromCard ? { ...c, props: { ...c.props, bundle: id } } : c));
150
+ return { handoff: { ...h, refs }, section: section && cards ? { ...section, cards } : section, warnings };
151
+ }
152
+ /** 转交单:目标老师看到的那条消息(from: system) */
153
+ static handoffText(ws, from, job, h, kidText) {
154
+ const t = ws.config.tutors[from];
155
+ const lines = [`转交自 ${t?.display ?? from}(${from},job ${job}):`, `why: ${h.why ?? ''}`];
156
+ if (h.refs.length)
157
+ lines.push(`refs: ${h.refs.join(', ')}`);
158
+ if (kidText.trim())
159
+ lines.push(`孩子刚才说的:${kidText.trim()}`);
160
+ if (t?.voice)
161
+ lines.push(`voice: ${t.voice}`);
162
+ return lines.join('\n');
163
+ }
164
+ async handoff(ws, from, job, h, kidText) {
165
+ const target = ws.config.tutors[h.to];
166
+ if (!target || !target.enabled)
167
+ return { started: null, warning: `转交没起:${h.to} ${target ? '关着' : '不在 cotutor.json 里'}` };
168
+ if (h.to === from)
169
+ return { started: null, warning: '转交没起:转给了自己' };
170
+ if (h.to === 'scene-maker') {
171
+ const policy = resolvePolicy(ws.config, h.to);
172
+ const today = await readIndex(ws, h.to, localDate((this.opts.now ?? (() => new Date()))()));
173
+ if (today.messages.length >= policy.scenes.dailyMax)
174
+ return { started: null, warning: `转交没起:场景作业今天已到上限 ${policy.scenes.dailyMax}(policy scenes.dailyMax)` };
175
+ }
176
+ try {
177
+ const r = await this.send(h.to, { from: 'system', text: Runner.handoffText(ws, from, job, h, kidText) });
178
+ return { started: { tutor: h.to, job: r.job } };
179
+ }
180
+ catch (err) {
181
+ return { started: null, warning: `转交没起:${err instanceof Error ? err.message : String(err)}` };
182
+ }
183
+ }
184
+ async spawn(ws, tutor, date, job, plan, replyMaxChars, active) {
185
+ const files = conversationFiles(ws.dirs.conversations, tutor, date);
186
+ const cwd = join(ws.dirs.agents, tutor);
187
+ await mkdir(cwd, { recursive: true });
188
+ const out = createWriteStream(files.log(job));
189
+ const err = openSync(files.err(job), 'w');
190
+ const voice = ws.config.tutors[tutor]?.voice;
191
+ const queue = voice ? new DubQueue(ws.config.tts, voice, files.err(job), this.opts.env) : null;
192
+ const dubber = queue ? new LineDubber(queue, (n) => files.lineAudio(job, n)) : null;
193
+ // 流式:stdout 的每一块先落盘再喂读取器;正文变了就(节流 150ms)重解析成 partial 板书,定稿的句子交去配音
194
+ const reader = createPartialReader();
195
+ let dirty = false;
196
+ let timer = null;
197
+ const reparse = () => {
198
+ timer = null;
199
+ if (!dirty)
200
+ return;
201
+ dirty = false;
202
+ const { section } = parseBoard(reader.text(), { partial: true });
203
+ const lines = section.lines.map((l) => ({ ...l, text: truncateReply(l.text, replyMaxChars).text }));
204
+ active.partial = section.cards.length || lines.length ? { cards: section.cards, lines, partial: true } : null;
205
+ if (dubber)
206
+ lines.forEach((l, i) => dubber.add(i, l.text));
207
+ };
208
+ const exit = await new Promise((resolveExit) => {
209
+ const child = spawn(plan.argv[0], plan.argv.slice(1), {
210
+ cwd,
211
+ env: { ...(this.opts.env ?? process.env), COTUTOR_WORKSPACE: ws.root },
212
+ stdio: ['ignore', 'pipe', err],
213
+ });
214
+ child.stdout?.on('data', (chunk) => {
215
+ out.write(chunk);
216
+ if (reader.feed(chunk.toString('utf8'))) {
217
+ dirty = true;
218
+ if (!timer)
219
+ timer = setTimeout(reparse, 150);
220
+ }
221
+ });
222
+ child.once('error', (e) => resolveExit({ code: null, spawnError: e }));
223
+ child.once('close', (code) => resolveExit({ code }));
224
+ });
225
+ if (timer)
226
+ clearTimeout(timer);
227
+ await new Promise((r) => out.end(r));
228
+ closeSync(err);
229
+ if (exit.spawnError) {
230
+ const { appendFile } = await import('node:fs/promises');
231
+ await appendFile(files.err(job), `cotutor: 起不来 ${plan.argv[0]}:${exit.spawnError.message}\n`);
232
+ }
233
+ const transcript = parseTranscript(await readFile(files.log(job), 'utf8').catch(() => ''));
234
+ if (!transcript.final) {
235
+ // 进程退了但没有 result 事件:起不来、被杀、或 CLI 崩了;标 error,原因指向 err.log
236
+ transcript.final = { text: null, ok: false, reason: exit.spawnError ? `spawn:${exit.spawnError.message}` : `exit:${exit.code ?? 'signal'}` };
237
+ transcript.items.push({ kind: 'done', text: `本轮没有收尾(${transcript.final.reason}),看 ${date}.${job}.err.log` });
238
+ }
239
+ const kidView = deriveKidView(transcript, { replyMaxChars });
240
+ // 配音:老师配了音色才合成;失败不响,孩子端用浏览器的声。
241
+ // 有板书讲稿就逐句配(流式时大多已在路上,这里只等没配完的);没有讲稿只有一段话(老形状)才整段配。
242
+ let audio = null;
243
+ if (dubber && voice && kidView.section?.lines.length) {
244
+ const names = await dubber.finish(kidView.section.lines);
245
+ kidView.section.lines.forEach((l, i) => { l.audio = names[i]; });
246
+ }
247
+ else if (voice && kidView.kidText)
248
+ audio = await dubReply(ws.config.tts, kidView.kidText, voice, { audio: files.audio(job), err: files.err(job) }, this.opts.env);
249
+ // 重新读索引再并入:跑的这段时间里别的字段(比如家长改了别的)不被旧对象盖掉
250
+ const latest = await readIndex(ws, tutor, date);
251
+ let next = applyRun(latest, job, { transcript, kidView, runtime: plan.runtime, audio });
252
+ // 转交:自动起目标老师的一轮;起不了的原因记进 warnings。给 scene-maker 的先把课包 id 理顺(refs 没写对就取卡上的;已占用就改成 -2,卡与 refs 一起改)
253
+ if (kidView.handoff) {
254
+ const fixed = kidView.handoff.to === 'scene-maker' ? this.settleBundleId(ws, kidView.handoff, kidView.section) : { handoff: kidView.handoff, section: kidView.section, warnings: [] };
255
+ const r = await this.handoff(ws, tutor, job, fixed.handoff, latest.messages.find((m) => m.job === job)?.text ?? '');
256
+ const warns = [...fixed.warnings, ...(r.warning ? [r.warning] : [])];
257
+ next = { ...next, messages: next.messages.map((m) => (m.job === job ? { ...m, handoff: fixed.handoff, section: fixed.section, handoffJob: r.started, ...(warns.length ? { warnings: [...(m.warnings ?? []), ...warns] } : {}) } : m)) };
258
+ }
259
+ await writeIndex(ws, next);
260
+ // 资产:点读段逐段配音到 .cards/<n>/<k>.mp3,后台跑,不拦着这一轮收尾;孩子端每次拉 today 都会看到新生成好的
261
+ if (queue && kidView.section) {
262
+ const jobs = [];
263
+ kidView.section.cards.forEach((card, n) => {
264
+ const list = cardAssets(card);
265
+ if (!list.length)
266
+ return;
267
+ jobs.push(mkdir(files.cardAssetsDir(job, n), { recursive: true }).then(() => Promise.all(list.map((a) => queue.add(files.cardAsset(job, n, a.file), a.text, `第 ${n + 1} 张卡的 ${a.file} `)))));
268
+ });
269
+ if (jobs.length) {
270
+ const bg = Promise.all(jobs).then(() => undefined, () => undefined);
271
+ this.background.add(bg);
272
+ void bg.finally(() => this.background.delete(bg));
273
+ }
274
+ }
275
+ return next;
276
+ }
277
+ }
@@ -0,0 +1,16 @@
1
+ import type { BoardSection } from '../lib/kid-board.ts';
2
+ export interface SceneDirs {
3
+ bundles: string;
4
+ /** 截图目录;缩略图路径按它相对 workspace 根算(thumbBase),没有就不给缩略图 */
5
+ snaps?: string;
6
+ thumbBase?: string;
7
+ }
8
+ export declare function sceneSnapshot(dirs: SceneDirs, bundle: string): Promise<{
9
+ title?: string;
10
+ problem?: string;
11
+ steps?: string[];
12
+ ready: boolean;
13
+ thumb?: string;
14
+ }>;
15
+ /** 一节里的场景卡全部补上快照(别的卡原样) */
16
+ export declare function enrichScenes(dirs: SceneDirs, section: BoardSection): Promise<BoardSection>;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * 场景卡下发前的快照:课包在不在、题面、每步讲稿、终帧缩略图——每次 kidDay 现读 bundles/<id>/,不存状态,
3
+ * 所以 scene-maker 作业落地的那一刻,孩子端下一次轮询就看到 ready(卡自己变成可播)。
4
+ */
5
+ import { readFile, stat } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ export async function sceneSnapshot(dirs, bundle) {
8
+ try {
9
+ const dir = join(dirs.bundles, bundle);
10
+ const [scene, manifest] = await Promise.all([readFile(join(dir, 'scene.json'), 'utf8'), readFile(join(dir, 'manifest.json'), 'utf8')]);
11
+ const s = JSON.parse(scene);
12
+ const m = JSON.parse(manifest);
13
+ const steps = (m.steps ?? []).map((x) => (typeof x.line === 'string' ? x.line : ''));
14
+ let thumb;
15
+ if (dirs.snaps && dirs.thumbBase !== undefined) {
16
+ const png = join(dirs.snaps, bundle, `step-${steps.length}.png`);
17
+ if ((await stat(png).catch(() => null))?.isFile())
18
+ thumb = `${dirs.thumbBase}/${bundle}/step-${steps.length}.png`;
19
+ }
20
+ return { ...(typeof s.title === 'string' ? { title: s.title } : {}), ...(typeof s.problem === 'string' ? { problem: s.problem } : {}), steps, ready: true, ...(thumb ? { thumb } : {}) };
21
+ }
22
+ catch {
23
+ return { ready: false };
24
+ }
25
+ }
26
+ /** 一节里的场景卡全部补上快照(别的卡原样) */
27
+ export async function enrichScenes(dirs, section) {
28
+ if (!section.cards.some((c) => c.kind === 'scene'))
29
+ return section;
30
+ const cards = [];
31
+ for (const c of section.cards) {
32
+ if (c.kind !== 'scene' || typeof c.props.bundle !== 'string') {
33
+ cards.push(c);
34
+ continue;
35
+ }
36
+ cards.push({ ...c, props: { ...c.props, ...(await sceneSnapshot(dirs, c.props.bundle)) } });
37
+ }
38
+ return { ...section, cards };
39
+ }
@@ -0,0 +1,15 @@
1
+ export declare const STAGE_DIR: string;
2
+ export declare const FONTS_DIR: string;
3
+ export declare const MIME: Record<string, string>;
4
+ export declare const ext: (p: string) => string;
5
+ export declare function stageBuilt(): boolean;
6
+ export interface StaticFile {
7
+ file: string;
8
+ contentType: string;
9
+ }
10
+ /** 根目录以内的一个文件(越界、不存在、不是文件 → null) */
11
+ export declare function fileUnder(root: string, rel: string): Promise<StaticFile | null>;
12
+ /** /stage/ → index.html;/stage/stage.js|css;/stage/fonts/<路径> → excalidraw 的字体目录 */
13
+ export declare function stageAsset(pathname: string): Promise<StaticFile | null>;
14
+ /** 课包文件 /api/bundles/<id>/<路径> → <bundles>/<id>/<路径>(id 只认小写字母数字连字符) */
15
+ export declare function bundleAsset(bundlesDir: string, pathname: string): Promise<StaticFile | null>;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * 舞台包的静态文件:dist/stage/(esbuild 打的 stage.js / stage.css / index.html)+ /stage/fonts/(excalidraw 的字体,从 node_modules 现取,不进 npm 包)。
3
+ * serve 与 mock 共用;没打包(开发时没跑 pnpm run build:stage)→ null,页面开重卡舞台时什么都不出现,doctor 点名。
4
+ */
5
+ import { existsSync } from 'node:fs';
6
+ import { stat } from 'node:fs/promises';
7
+ import { createRequire } from 'node:module';
8
+ import { dirname, join, resolve, sep } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ export const STAGE_DIR = fileURLToPath(new URL('../../dist/stage/', import.meta.url));
11
+ /**
12
+ * excalidraw 的字体目录(dist/prod/fonts/):从包入口现解析,不写死 `../../node_modules/`——
13
+ * npm 扁平安装时依赖在消费者的 node_modules 里,不在本包下,写死的话装出来的 /stage/fonts/ 全 404。
14
+ */
15
+ function excalidrawFontsDir() {
16
+ try {
17
+ return join(dirname(createRequire(import.meta.url).resolve('@excalidraw/excalidraw')), 'fonts') + sep;
18
+ }
19
+ catch {
20
+ return fileURLToPath(new URL('../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/', import.meta.url));
21
+ }
22
+ }
23
+ export const FONTS_DIR = excalidrawFontsDir();
24
+ export const MIME = {
25
+ html: 'text/html; charset=utf-8',
26
+ js: 'text/javascript; charset=utf-8',
27
+ css: 'text/css; charset=utf-8',
28
+ json: 'application/json; charset=utf-8',
29
+ mp3: 'audio/mpeg',
30
+ wav: 'audio/wav',
31
+ png: 'image/png',
32
+ svg: 'image/svg+xml',
33
+ woff2: 'font/woff2',
34
+ woff: 'font/woff',
35
+ ttf: 'font/ttf',
36
+ };
37
+ export const ext = (p) => p.slice(p.lastIndexOf('.') + 1).toLowerCase();
38
+ export function stageBuilt() {
39
+ return existsSync(join(STAGE_DIR, 'stage.js')) && existsSync(join(STAGE_DIR, 'index.html'));
40
+ }
41
+ /** 根目录以内的一个文件(越界、不存在、不是文件 → null) */
42
+ export async function fileUnder(root, rel) {
43
+ const base = resolve(root);
44
+ const file = resolve(base, rel);
45
+ if (!rel || rel.includes('\0') || (file !== base && !file.startsWith(base + sep)))
46
+ return null;
47
+ const type = MIME[ext(file)];
48
+ if (!type)
49
+ return null;
50
+ if (!(await stat(file).catch(() => null))?.isFile())
51
+ return null;
52
+ return { file, contentType: type };
53
+ }
54
+ /** /stage/ → index.html;/stage/stage.js|css;/stage/fonts/<路径> → excalidraw 的字体目录 */
55
+ export async function stageAsset(pathname) {
56
+ if (!pathname.startsWith('/stage/'))
57
+ return null;
58
+ const rel = pathname.slice('/stage/'.length);
59
+ if (rel.startsWith('fonts/'))
60
+ return fileUnder(FONTS_DIR, rel.slice('fonts/'.length));
61
+ return fileUnder(STAGE_DIR, rel === '' ? 'index.html' : rel);
62
+ }
63
+ /** 课包文件 /api/bundles/<id>/<路径> → <bundles>/<id>/<路径>(id 只认小写字母数字连字符) */
64
+ export async function bundleAsset(bundlesDir, pathname) {
65
+ const m = /^\/api\/bundles\/([a-z0-9][a-z0-9-]*)\/(.+)$/.exec(pathname);
66
+ if (!m)
67
+ return null;
68
+ return fileUnder(join(bundlesDir, m[1]), m[2]);
69
+ }
@@ -0,0 +1,43 @@
1
+ import { type CardAssets, type CardStateFile, type CardStates } from '../lib/conversation.ts';
2
+ import { type Transcript } from '../lib/transcript.ts';
3
+ import { type ConversationIndex, type CotutorConfig } from '../schema/index.ts';
4
+ import { type Workspace } from '../cli/workspace.ts';
5
+ export declare class IndexError extends Error {
6
+ constructor(file: string, cause: string);
7
+ }
8
+ export declare function readIndex(ws: Workspace, tutor: string, date: string): Promise<ConversationIndex>;
9
+ /** 先写 .tmp 再 rename:进程半路死掉不会留下半份索引 */
10
+ export declare function writeIndex(ws: Workspace, index: ConversationIndex): Promise<void>;
11
+ /** 有过对话的日期,新的在前 */
12
+ export declare function listDates(ws: Workspace, tutor: string): Promise<string[]>;
13
+ export declare function readTranscript(ws: Workspace, tutor: string, date: string, job: string): Promise<Transcript | null>;
14
+ /**
15
+ * 一天里卡的目录 <date>.<job>.cards/ 全扫一遍:<n>.json 是孩子做的事(坏文件跳过,孩子端不报),<n>/<k>.mp3 是后台生成好的资产。
16
+ */
17
+ export declare function scanCards(ws: Workspace, tutor: string, date: string): Promise<{
18
+ states: CardStates;
19
+ assets: CardAssets;
20
+ }>;
21
+ /** 只要孩子做的事(发消息时挑「上一轮之后改过的」) */
22
+ export declare function readCardStates(ws: Workspace, tutor: string, date: string): Promise<CardStates>;
23
+ /** 存一张卡的状态(先 .tmp 再 rename);turn = 存的时候索引里最后一条的 job */
24
+ export declare function writeCardState(ws: Workspace, tutor: string, date: string, job: string, n: number, file: CardStateFile): Promise<void>;
25
+ /** 画板导出的 png:<date>.<job>.cards/<n>.png;返回相对 conversations/<老师>/ 的名字 */
26
+ export declare function writeCardImage(ws: Workspace, tutor: string, date: string, job: string, n: number, png: Buffer): Promise<string>;
27
+ export declare function readErrLog(ws: Workspace, tutor: string, date: string, job: string): Promise<string>;
28
+ /** 老师文件正文(系统提示),给 {agentBody};从 .claude/agents/ 读,那里的链是必需项 */
29
+ export declare function readAgentBody(ws: Workspace, name: string): Promise<string>;
30
+ /** PATCH 允许改的顶层键(老师团页与设置页);kid / version / 运行时模板走编辑器 */
31
+ export declare const CONFIG_PATCH_KEYS: readonly ["title", "policyDefaults", "tutors", "agents", "paths", "server", "tts"];
32
+ /** 深合并:对象递归、其余覆盖、null 删键(老师条目可整体删:tutors.x = null) */
33
+ export declare function deepMerge(base: unknown, patch: unknown): unknown;
34
+ /**
35
+ * 补丁合到原始 JSON 上(保留 _note 等机器不认识的键),整份过契约再写;写坏了不落盘。
36
+ * 返回新配置;agents 只允许改 default(运行时模板走编辑器,页面上改错一个引号就把老师全弄哑)。
37
+ */
38
+ export declare function patchConfig(ws: Workspace, patch: Record<string, unknown>): Promise<CotutorConfig>;
39
+ /** 文件改了(页面写的、或家长在编辑器改的)就重读;坏了抛 ConfigError,调用方决定是留旧的还是响 */
40
+ export declare function reloadIfChanged(ws: Workspace, lastMtime: number): Promise<{
41
+ ws: Workspace;
42
+ mtime: number;
43
+ }>;