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,274 @@
1
+ /**
2
+ * 老师文件的安装与升级(2026-09-09 拍板:**拷贝,不链**)。
3
+ * 包里的 agents/<name>.md 是出厂件;init 把它拷进 workspace 的 .claude/agents/<name>.md(这份是家长的,想改就改),
4
+ * .qwen/agents/<name>.md 是指向它的相对链(一份真相两处可见,链在 workspace 内,不怕包搬家或 npx 缓存回收)。
5
+ * 出厂时的内容 hash 记在 .cotutor/shipped.json(机器文件,家长不用管),据此分三种状态:
6
+ * latest 与本包一致
7
+ * upgradable 与记录的出厂 hash 一致(没改过,只是包更新了)→ cotutor upgrade 直接换新
8
+ * custom 改过 → upgrade 只报 diff,不动
9
+ * untracked 没有记录(旧workspace、或家长手写的老师)→ 当 custom 对待
10
+ */
11
+ import { createHash } from 'node:crypto';
12
+ import { lstat, mkdir, readFile, readlink, rename, symlink, unlink, writeFile } from 'node:fs/promises';
13
+ import { basename, dirname, join, relative } from 'node:path';
14
+ import { PACKAGE_AGENTS_DIR, PACKAGE_VERSION, shippedAgents, tutorTemplate, writeSchemaFile, writeSyntaxFile } from "./skeleton.js";
15
+ import { AGENT_NAME_RE } from "../schema/index.js";
16
+ import { UsageError } from "./workspace.js";
17
+ export const SHIPPED_FILE = '.cotutor/shipped.json';
18
+ export const sha256 = (text) => `sha256:${createHash('sha256').update(text).digest('hex')}`;
19
+ export async function readManifest(root) {
20
+ try {
21
+ const raw = JSON.parse(await readFile(join(root, SHIPPED_FILE), 'utf8'));
22
+ return { version: raw.version ?? '0', tutors: raw.tutors ?? {} };
23
+ }
24
+ catch {
25
+ return { version: '0', tutors: {} };
26
+ }
27
+ }
28
+ export async function writeManifest(root, m) {
29
+ const file = join(root, SHIPPED_FILE);
30
+ await mkdir(dirname(file), { recursive: true });
31
+ const tmp = `${file}.tmp`;
32
+ await writeFile(tmp, `${JSON.stringify({ ...m, _note: '出厂时老师文件的 hash,cotutor upgrade 据此分辨「没改过」与「改过」;机器写,不用手改' }, null, 2)}\n`);
33
+ await rename(tmp, file);
34
+ }
35
+ /** 老师文件在 workspace 里的位置:.claude/agents/<name>.md 是真相,.qwen/agents/<name>.md 是相对链 */
36
+ export function tutorFiles(root, name) {
37
+ return { claude: join(root, '.claude', 'agents', `${name}.md`), qwen: join(root, '.qwen', 'agents', `${name}.md`) };
38
+ }
39
+ async function readState(root, a, manifest) {
40
+ const { claude } = tutorFiles(root, a.name);
41
+ const st = await lstat(claude).catch(() => null);
42
+ if (!st)
43
+ return { name: a.name, state: 'missing', file: claude };
44
+ let legacyLink = false;
45
+ if (st.isSymbolicLink()) {
46
+ const target = await readlink(claude).catch(() => '');
47
+ legacyLink = target.startsWith(PACKAGE_AGENTS_DIR) || target === a.file;
48
+ }
49
+ let text;
50
+ try {
51
+ text = await readFile(claude, 'utf8');
52
+ }
53
+ catch {
54
+ return { name: a.name, state: 'broken', file: claude, legacyLink };
55
+ }
56
+ const shipped = await readFile(a.file, 'utf8');
57
+ const h = sha256(text);
58
+ const rec = manifest.tutors[a.name];
59
+ if (h === sha256(shipped))
60
+ return { name: a.name, state: 'latest', file: claude, basedOn: rec?.version, legacyLink };
61
+ if (rec && rec.hash === h)
62
+ return { name: a.name, state: 'upgradable', file: claude, basedOn: rec.version, legacyLink };
63
+ return { name: a.name, state: rec ? 'custom' : 'untracked', file: claude, basedOn: rec?.version, legacyLink };
64
+ }
65
+ export async function tutorStatuses(root) {
66
+ const manifest = await readManifest(root);
67
+ const out = [];
68
+ for (const a of await shippedAgents())
69
+ out.push(await readState(root, a, manifest));
70
+ return out;
71
+ }
72
+ /** 把出厂件写进 workspace(覆盖),并记 hash */
73
+ async function installOne(root, a, manifest) {
74
+ const { claude } = tutorFiles(root, a.name);
75
+ await mkdir(dirname(claude), { recursive: true });
76
+ const text = await readFile(a.file, 'utf8');
77
+ await unlink(claude).catch(() => { });
78
+ await writeFile(claude, text);
79
+ manifest.tutors[a.name] = { hash: sha256(text), version: PACKAGE_VERSION };
80
+ }
81
+ /** .qwen/agents/<name>.md → ../../.claude/agents/<name>.md;已是这条链就不动;是旧的包内链或别的东西就换 */
82
+ async function ensureQwenLink(root, name) {
83
+ const { claude, qwen } = tutorFiles(root, name);
84
+ await mkdir(dirname(qwen), { recursive: true });
85
+ const want = relative(dirname(qwen), claude);
86
+ const st = await lstat(qwen).catch(() => null);
87
+ if (st?.isSymbolicLink() && (await readlink(qwen).catch(() => '')) === want)
88
+ return 'exists';
89
+ if (st) {
90
+ if (!st.isSymbolicLink()) {
91
+ // 家长手放了实体文件在 .qwen 下:留着,当作她的
92
+ return 'exists';
93
+ }
94
+ await unlink(qwen);
95
+ await symlink(want, qwen);
96
+ return 'replaced';
97
+ }
98
+ await symlink(want, qwen);
99
+ return 'created';
100
+ }
101
+ /**
102
+ * init 用。出厂的:缺的拷,旧的包内链换成拷贝,家长的文件不动;
103
+ * cotutor.json 里的每一位(含家长自己加的):补老师目录 agents/<name>/ 与 .qwen 链——「加老师 = 加文件 + 目录,没有注册表」,
104
+ * 自己加的老师文件不在这里生成(cotutor add 生成模板),缺了由 doctor 点名。
105
+ */
106
+ export async function installTutors(root, configured = []) {
107
+ const steps = [];
108
+ const manifest = await readManifest(root);
109
+ let touched = false;
110
+ const shipped = await shippedAgents();
111
+ for (const a of shipped) {
112
+ const s = await readState(root, a, manifest);
113
+ const item = `.claude/agents/${basename(a.file)}`;
114
+ if (s.state === 'missing' || s.legacyLink || s.state === 'broken') {
115
+ await installOne(root, a, manifest);
116
+ touched = true;
117
+ steps.push({ item, action: s.state === 'missing' ? 'created' : 'replaced', note: s.legacyLink ? '旧的包内链换成拷贝(2026-09-09 起老师文件是你的)' : s.state === 'broken' ? '读不到,重新拷贝' : `拷自本包 ${PACKAGE_VERSION}` });
118
+ }
119
+ else if (s.state === 'untracked') {
120
+ // 没记录但内容在:当家长的,记一笔当前 hash 免得以后一直是 untracked?不记——记了就等于宣称它是出厂件。留 untracked。
121
+ steps.push({ item, action: 'kept', note: '已有(不是出厂件,或没有记录);升级时当自定义对待' });
122
+ }
123
+ else {
124
+ steps.push({ item, action: 'exists', note: s.state === 'custom' ? `自定义(基于 ${s.basedOn})` : s.state === 'upgradable' ? `可升级(cotutor upgrade)` : undefined });
125
+ }
126
+ const q = await ensureQwenLink(root, a.name);
127
+ steps.push({ item: `.qwen/agents/${basename(a.file)}`, action: q, note: q === 'exists' ? undefined : '→ ../../.claude/agents/' });
128
+ }
129
+ for (const name of configured.filter((n) => !shipped.some((a) => a.name === n))) {
130
+ const { claude } = tutorFiles(root, name);
131
+ if (await lstat(claude).catch(() => null)) {
132
+ const q = await ensureQwenLink(root, name);
133
+ steps.push({ item: `.qwen/agents/${name}.md`, action: q, note: q === 'exists' ? undefined : '→ ../../.claude/agents/(自家的老师)' });
134
+ }
135
+ else
136
+ steps.push({ item: `.claude/agents/${name}.md`, action: 'kept', note: `缺:cotutor.json 里有 ${name},文件还没写;cotutor add ${name} 出模板,或自己写(frontmatter name: ${name})` });
137
+ }
138
+ for (const name of [...new Set([...shipped.map((a) => a.name), ...configured])]) {
139
+ const home = join(root, 'agents', name);
140
+ if (await lstat(home).catch(() => null)) {
141
+ steps.push({ item: `agents/${name}/`, action: 'exists' });
142
+ continue;
143
+ }
144
+ await mkdir(home, { recursive: true });
145
+ await writeFile(join(home, '.gitkeep'), '');
146
+ steps.push({ item: `agents/${name}/`, action: 'created' });
147
+ }
148
+ if (touched || !(await lstat(join(root, SHIPPED_FILE)).catch(() => null)))
149
+ await writeManifest(root, { ...manifest, version: PACKAGE_VERSION });
150
+ return steps;
151
+ }
152
+ /**
153
+ * cotutor add <name>:按出厂老师的结构写一份模板(约定都带上,人设一句留给家长填)、建目录与 .qwen 链。
154
+ * 不动 cotutor.json——那是调用方(main / 页面)用 patchConfig 加的,同一条路。已有同名文件就拒,不覆盖。
155
+ */
156
+ export async function addTutorFile(root, input) {
157
+ if (!AGENT_NAME_RE.test(input.name))
158
+ throw new UsageError(`老师名 "${input.name}" 不合规:小写字母数字连字符,如 science-tutor`);
159
+ const { claude } = tutorFiles(root, input.name);
160
+ if (await lstat(claude).catch(() => null))
161
+ throw new UsageError(`${claude} 已经在了;要改就直接改它,要重来先把它挪开`);
162
+ await mkdir(dirname(claude), { recursive: true });
163
+ await writeFile(claude, tutorTemplate(input));
164
+ await ensureQwenLink(root, input.name);
165
+ const home = join(root, 'agents', input.name);
166
+ await mkdir(home, { recursive: true });
167
+ await writeFile(join(home, '.gitkeep'), '').catch(() => { });
168
+ return { name: input.name, file: claude, home };
169
+ }
170
+ /** 最小行 diff(LCS),只为让家长看清改了哪几行;不追求 git 那么好 */
171
+ export function lineDiff(a, b) {
172
+ const A = a.split('\n');
173
+ const B = b.split('\n');
174
+ const n = A.length;
175
+ const m = B.length;
176
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
177
+ for (let i = n - 1; i >= 0; i--)
178
+ for (let j = m - 1; j >= 0; j--)
179
+ dp[i][j] = A[i] === B[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
180
+ const out = [];
181
+ let i = 0;
182
+ let j = 0;
183
+ while (i < n && j < m) {
184
+ if (A[i] === B[j]) {
185
+ i++;
186
+ j++;
187
+ }
188
+ else if (dp[i + 1][j] >= dp[i][j + 1])
189
+ out.push(`- ${A[i++]}`);
190
+ else
191
+ out.push(`+ ${B[j++]}`);
192
+ }
193
+ while (i < n)
194
+ out.push(`- ${A[i++]}`);
195
+ while (j < m)
196
+ out.push(`+ ${B[j++]}`);
197
+ return out;
198
+ }
199
+ /**
200
+ * cotutor upgrade:latest 跳过;upgradable 换新;custom / untracked 只报 diff 并保留(--force <name> 才覆盖,原文留 .bak);缺的补。
201
+ */
202
+ export async function upgradeTutors(root, opts = {}) {
203
+ const manifest = await readManifest(root);
204
+ const steps = [];
205
+ for (const a of await shippedAgents()) {
206
+ const s = await readState(root, a, manifest);
207
+ const force = opts.force?.includes(a.name) ?? false;
208
+ if (s.state === 'latest' && !s.legacyLink) {
209
+ steps.push({ name: a.name, action: 'latest', basedOn: s.basedOn });
210
+ }
211
+ else if (s.state === 'missing' || s.state === 'broken') {
212
+ await installOne(root, a, manifest);
213
+ steps.push({ name: a.name, action: 'installed' });
214
+ }
215
+ else if (s.state === 'upgradable' || s.legacyLink || (s.state === 'latest' && s.legacyLink)) {
216
+ await installOne(root, a, manifest);
217
+ steps.push({ name: a.name, action: 'upgraded', basedOn: s.basedOn });
218
+ }
219
+ else if (force) {
220
+ const mine = await readFile(s.file, 'utf8');
221
+ await writeFile(`${s.file}.bak`, mine);
222
+ await installOne(root, a, manifest);
223
+ steps.push({ name: a.name, action: 'forced', basedOn: s.basedOn });
224
+ }
225
+ else {
226
+ const mine = await readFile(s.file, 'utf8');
227
+ const shipped = await readFile(a.file, 'utf8');
228
+ steps.push({ name: a.name, action: 'kept-custom', basedOn: s.basedOn, diff: lineDiff(mine, shipped) });
229
+ }
230
+ await ensureQwenLink(root, a.name);
231
+ }
232
+ await writeManifest(root, { ...manifest, version: PACKAGE_VERSION });
233
+ await writeSchemaFile(root);
234
+ await writeSyntaxFile(root);
235
+ return steps;
236
+ }
237
+ export async function readTutorFile(root, name) {
238
+ const { claude } = tutorFiles(root, name);
239
+ const text = await readFile(claude, 'utf8').catch(() => null);
240
+ if (text === null)
241
+ throw new UsageError(`老师文件不在:${claude};cotutor add ${name} --display <显示名> 可出模板`);
242
+ const st = (await tutorStatuses(root)).find((s) => s.name === name);
243
+ return { name, text, state: st?.state ?? 'own', basedOn: st?.basedOn };
244
+ }
245
+ /** 页面上改老师正文:frontmatter 的 name 必须还是它;写完 doctor 会把它标成自定义 */
246
+ export async function writeTutorFile(root, name, text) {
247
+ const { parseAgentFile } = await import("../lib/agent-file.js");
248
+ const fm = parseAgentFile(text).frontmatter;
249
+ if (fm.name !== name)
250
+ throw new UsageError(`frontmatter 的 name 要是 ${name}(现在是 "${fm.name ?? ''}"),老师键靠它对上`);
251
+ const { claude } = tutorFiles(root, name);
252
+ await mkdir(dirname(claude), { recursive: true });
253
+ const tmp = `${claude}.tmp`;
254
+ await writeFile(tmp, text.endsWith('\n') ? text : `${text}\n`);
255
+ await rename(tmp, claude);
256
+ await ensureQwenLink(root, name);
257
+ return readTutorFile(root, name);
258
+ }
259
+ /** 删自家加的老师文件:改名成 .removed-<时间>(不真删;记忆目录与对话不动);出厂的不让删,用开关 */
260
+ export async function removeTutorFile(root, name) {
261
+ if ((await shippedAgents()).some((a) => a.name === name))
262
+ throw new UsageError(`${name} 是出厂老师,不删文件;不想用就在老师团页关掉`);
263
+ const { claude, qwen } = tutorFiles(root, name);
264
+ await unlink(qwen).catch(() => { });
265
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
266
+ const moved = `${claude}.removed-${stamp}`;
267
+ try {
268
+ await rename(claude, moved);
269
+ return { moved };
270
+ }
271
+ catch {
272
+ return { moved: null };
273
+ }
274
+ }
@@ -9,6 +9,8 @@ export declare class ConfigError extends UsageError {
9
9
  }
10
10
  export declare const CONFIG_FILE = "cotutor.json";
11
11
  export declare const USER_CONFIG: string;
12
+ /** 机器级自签证书目录(cotutor cert 写 cert.pem + key.pem):证书签的是本机名与局域网 IP,与孩子无关,所以不在 workspace 里(2026-09-09 拍板) */
13
+ export declare const USER_CERT_DIR: string;
12
14
  export declare const HOME_ROOT: string;
13
15
  export type RootSource = 'flag' | 'env' | 'cwd' | 'user-config' | 'home-single';
14
16
  export interface ResolvedRoot {
@@ -42,6 +44,12 @@ export interface Workspace {
42
44
  conversations: string;
43
45
  claudeAgents: string;
44
46
  qwenAgents: string;
47
+ /** 场景源(scene-maker 写的 scenes/<id>.ts 与说明文件) */
48
+ scenes: string;
49
+ /** 课包(drawtell build 的产物,场景卡从这里播;派生物,gitignore) */
50
+ bundles: string;
51
+ /** 关键帧截图(drawtell snap;派生物) */
52
+ snaps: string;
45
53
  };
46
54
  files: {
47
55
  config: string;
@@ -57,5 +65,5 @@ export declare function resolvePaths(root: string, paths: Record<string, string>
57
65
  export declare function assembleWorkspace(root: string, source: RootSource, config: CotutorConfig): Workspace;
58
66
  /** 定根 + 读配置 + 校验 + 展开角色。cotutor.json 缺失也是错(它是必需的)。 */
59
67
  export declare function loadWorkspace(override?: string, opts?: ResolveOptions): Workspace;
60
- /** --json / 启动日志里回报工作区(脱敏) */
68
+ /** --json / 启动日志里回报workspace(脱敏) */
61
69
  export declare function workspaceReport(ws: Workspace): Record<string, unknown>;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 工作区解析:一孩一 workspace(`~/cotutor/<slug>/`,2026-09-08 拍板)。
2
+ * workspace解析:一孩一 workspace(`~/cotutor/<slug>/`,2026-09-08 拍板)。
3
3
  * 解析链(定根):`--workspace <dir>` → 环境变量 COTUTOR_WORKSPACE → cwd 或其祖先有 cotutor.json
4
4
  * (老师的 cwd 是 <ws>/agents/<name>/,从那里跑 doctor 也要找得到根)→ 用户配置
5
5
  * ~/.config/cotutor/config.json 的 workspace → ~/cotutor/ 下**唯一**的一个孩子目录 → 都没有就报错附修复指南。
@@ -23,6 +23,8 @@ export class ConfigError extends UsageError {
23
23
  }
24
24
  export const CONFIG_FILE = 'cotutor.json';
25
25
  export const USER_CONFIG = join(homedir(), '.config', 'cotutor', 'config.json');
26
+ /** 机器级自签证书目录(cotutor cert 写 cert.pem + key.pem):证书签的是本机名与局域网 IP,与孩子无关,所以不在 workspace 里(2026-09-09 拍板) */
27
+ export const USER_CERT_DIR = join(homedir(), '.config', 'cotutor', 'certs');
26
28
  export const HOME_ROOT = join(homedir(), 'cotutor');
27
29
  export function expandPath(p, base) {
28
30
  if (p === '~')
@@ -135,6 +137,22 @@ export function resolveRoot(override, opts = {}) {
135
137
  }
136
138
  /** 校验 cotutor.json 的形状;失败 → ConfigError,信息是逐条修复指南 */
137
139
  export function parseConfig(raw, file) {
140
+ // 2026-09-09 术语统一(teacher → tutor,homework-aide → homework-tutor):旧键旧名响亮报,不静默变成零位老师
141
+ if (raw && typeof raw === 'object') {
142
+ const o = raw;
143
+ if ('teachers' in o && !('tutors' in o)) {
144
+ throw new ConfigError(file, ' - teachers:这个键 2026-09-09 起叫 tutors(术语统一为 tutor / 老师);把 "teachers" 改成 "tutors",老师名 *-teacher 改成 *-tutor、homework-aide 改成 homework-tutor,.claude/agents/ 下的文件同名改,然后 cotutor init 补齐');
145
+ }
146
+ if ('agents' in o && !('runtimes' in o)) {
147
+ throw new ConfigError(file, ' - agents:这个键 2026-09-09 起叫 runtimes(它是跑老师的运行时,与 .claude/agents/ 的 agent 文件、agents/<老师>/ 的老师目录是三回事);把 "agents" 改成 "runtimes" 即可,模板内容与占位 {agent} {agentBody} 不变');
148
+ }
149
+ const tutors = o.tutors;
150
+ if (tutors && typeof tutors === 'object') {
151
+ const old = Object.keys(tutors).filter((k) => k.endsWith('-teacher') || k === 'homework-aide');
152
+ if (old.length)
153
+ throw new ConfigError(file, ` - tutors:${old.join('、')} 是旧名;2026-09-09 起叫 ${old.map((k) => (k === 'homework-aide' ? 'homework-tutor' : k.replace(/-teacher$/, '-tutor'))).join('、')}(文件名与 frontmatter name 一起改),然后 cotutor init 补齐`);
154
+ }
155
+ }
138
156
  const r = CotutorConfigSchema.safeParse(raw);
139
157
  if (r.success)
140
158
  return r.data;
@@ -165,6 +183,9 @@ export function assembleWorkspace(root, source, config) {
165
183
  conversations: join(root, 'conversations'),
166
184
  claudeAgents: join(root, '.claude', 'agents'),
167
185
  qwenAgents: join(root, '.qwen', 'agents'),
186
+ scenes: join(root, 'scenes'),
187
+ bundles: join(root, 'bundles'),
188
+ snaps: join(root, 'snaps'),
168
189
  },
169
190
  files: {
170
191
  config: join(root, CONFIG_FILE),
@@ -184,7 +205,7 @@ export function loadWorkspace(override, opts = {}) {
184
205
  throw new ConfigError(file, `不存在。cotutor init <slug> --dir ${redactHome(root)} 可生成模板(已有文件不动)。`);
185
206
  return assembleWorkspace(root, source, parseConfig(raw, file));
186
207
  }
187
- /** --json / 启动日志里回报工作区(脱敏) */
208
+ /** --json / 启动日志里回报workspace(脱敏) */
188
209
  export function workspaceReport(ws) {
189
210
  return redactDeep({
190
211
  workspace: ws.root,
@@ -193,7 +214,7 @@ export function workspaceReport(ws) {
193
214
  title: ws.config.title,
194
215
  port: ws.config.server.port,
195
216
  vault: ws.paths.vault,
196
- teachers: Object.keys(ws.config.teachers),
197
- agent: ws.config.agents.default,
217
+ tutors: Object.keys(ws.config.tutors),
218
+ runtime: ws.config.runtimes.default,
198
219
  });
199
220
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 老师定义文件(.claude/agents/<name>.md 同一份链到 .qwen/agents/):YAML frontmatter + 正文即系统提示。
2
+ * 老师文件文件(.claude/agents/<name>.md 同一份链到 .qwen/agents/):YAML frontmatter + 正文即系统提示。
3
3
  * 这里只认平铺的 `key: value` frontmatter(两个 CLI 的公共子集:name / description / maxTurns / permissionMode / memory);
4
4
  * 正文给没有 --agent 的 CLI 经 {agentBody} 塞进 --append-system-prompt。
5
5
  */
@@ -0,0 +1,15 @@
1
+ import { type BoardSection } from './kid-board.ts';
2
+ export interface ParseBoardOptions {
3
+ /** 文本还在长(流式):压住没闭合的围栏与没换行结束的最后一行 */
4
+ partial?: boolean;
5
+ }
6
+ export interface ParsedBoard {
7
+ section: BoardSection;
8
+ /** 没解析成的卡等,家长视图转录里显示;孩子端不报 */
9
+ warnings: string[];
10
+ /** 第一个 H2 起的尾巴(「## 家长」等,原文含标题行);没有 = 空串 */
11
+ tail: string;
12
+ }
13
+ /** 讲稿一行的清洗:去掉老师不该写但常写的标题号、列表号、引用号、粗体号;整行的 HTML 注释不算话 */
14
+ export declare function cleanSpeech(line: string): string;
15
+ export declare function parseBoard(text: string, opts?: ParseBoardOptions): ParsedBoard;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * 板书解析器:老师的回复正文(已剥掉「待裁量」「转交」段)→ 一节 BoardSection。
3
+ * 三条规则(《板书卡片设计.md》§2):围栏 = 卡(标签第一个词是 kind),普通行 = 讲稿一句,第一个 H2 起是给家长的尾巴。
4
+ * 讲稿句里 [词] 是标注(落到第一张含这个词的卡,整节找,卡在前在后都行),[[名 参数]] 是对上一张卡的动作(cue)。
5
+ * partial 模式给流式用:没闭合的围栏和没换行结束的最后一行压着不算,下次整段重解析自然补上。
6
+ * 永不抛错:围栏没闭合当到文末,卡解析不出退成文字卡并记一句 warning。
7
+ */
8
+ import { parseCard } from "../cards/index.js";
9
+ import { anchorMarks, isQuestion, phrasesIn, plainLine } from "./kid-board.js";
10
+ const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/;
11
+ const H2 = /^##\s+\S/;
12
+ const CUE = /\[\[([^\[\]]+)\]\]/g;
13
+ function fenceClose(line, fence) {
14
+ const m = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line);
15
+ return Boolean(m && m[1][0] === fence[0] && m[1].length >= fence.length);
16
+ }
17
+ /** 讲稿一行的清洗:去掉老师不该写但常写的标题号、列表号、引用号、粗体号;整行的 HTML 注释不算话 */
18
+ export function cleanSpeech(line) {
19
+ const t = line.trim();
20
+ if (/^<!--.*-->$/.test(t))
21
+ return '';
22
+ return t
23
+ .replace(/^#{1,6}\s+/, '')
24
+ .replace(/^(?:[-*+]|\d+[.)])\s+/, '')
25
+ .replace(/^>\s*/, '')
26
+ .replace(/\*\*/g, '')
27
+ .trim();
28
+ }
29
+ function rawLine(line, cards) {
30
+ const anchor = cards.length ? cards.length - 1 : null;
31
+ const cues = [];
32
+ const text = line
33
+ .replace(CUE, (_, inner) => {
34
+ const [name, ...rest] = inner.trim().split(/\s+/);
35
+ if (name && anchor !== null)
36
+ cues.push({ card: anchor, name: name.toLowerCase(), ...(rest.length ? { arg: rest.join(' ') } : {}) });
37
+ return '';
38
+ })
39
+ .replace(/\s{2,}/g, ' ')
40
+ .trim();
41
+ return { text, anchor, cues };
42
+ }
43
+ export function parseBoard(text, opts = {}) {
44
+ const partial = opts.partial === true;
45
+ const all = text.split('\n');
46
+ const lines = partial && !text.endsWith('\n') ? all.slice(0, -1) : all;
47
+ const cards = [];
48
+ const raws = [];
49
+ const warnings = [];
50
+ let tail = null;
51
+ let i = 0;
52
+ while (i < lines.length) {
53
+ const line = lines[i];
54
+ if (tail) {
55
+ tail.push(line);
56
+ i++;
57
+ continue;
58
+ }
59
+ const open = FENCE_OPEN.exec(line);
60
+ if (open) {
61
+ const fence = open[1];
62
+ const tag = open[2].trim();
63
+ const body = [];
64
+ let j = i + 1;
65
+ let closed = false;
66
+ for (; j < lines.length; j++) {
67
+ if (fenceClose(lines[j], fence)) {
68
+ closed = true;
69
+ break;
70
+ }
71
+ body.push(lines[j]);
72
+ }
73
+ if (!closed && partial)
74
+ break;
75
+ if (!closed)
76
+ warnings.push(`围栏没闭合(${tag || '无标签'}),当到文末`);
77
+ const r = parseCard(tag, body.join('\n'));
78
+ cards.push(r.card);
79
+ if (r.warning)
80
+ warnings.push(r.warning);
81
+ i = closed ? j + 1 : j;
82
+ continue;
83
+ }
84
+ if (H2.test(line)) {
85
+ tail = [line];
86
+ i++;
87
+ continue;
88
+ }
89
+ const speech = cleanSpeech(line);
90
+ if (speech)
91
+ raws.push(rawLine(speech, cards));
92
+ i++;
93
+ }
94
+ // 标注整节找(老师把卡写在句子后面也认),所以卡收齐了再落
95
+ const out = raws
96
+ .filter((r) => r.text)
97
+ .map((r) => {
98
+ const marks = anchorMarks(cards, phrasesIn(r.text));
99
+ const plain = plainLine(r.text);
100
+ return { text: plain, audio: null, marks, ask: isQuestion(plain), anchor: r.anchor, cues: r.cues };
101
+ });
102
+ return {
103
+ section: { cards, lines: out, ...(partial ? { partial: true } : {}) },
104
+ warnings,
105
+ tail: tail ? tail.join('\n').trim() : '',
106
+ };
107
+ }
@@ -14,7 +14,7 @@ export function renderContextPack(pack) {
14
14
  const out = ['cotutor:', ` from: ${p.from}`, ` at: ${yamlScalar(p.at)}`];
15
15
  if (p.slot)
16
16
  out.push(` slot: ${yamlScalar(p.slot)}`);
17
- if (p.focus && (p.focus.artifact || p.focus.step !== undefined || p.focus.circled?.length)) {
17
+ if (p.focus && (p.focus.artifact || p.focus.step !== undefined || p.focus.circled?.length || p.focus.card)) {
18
18
  out.push(' focus:');
19
19
  if (p.focus.artifact)
20
20
  out.push(` artifact: ${yamlScalar(p.focus.artifact)}`);
@@ -22,6 +22,8 @@ export function renderContextPack(pack) {
22
22
  out.push(` step: ${p.focus.step}`);
23
23
  if (p.focus.circled?.length)
24
24
  out.push(` circled: [${p.focus.circled.map(yamlScalar).join(', ')}]`);
25
+ if (p.focus.card)
26
+ out.push(` card: ${yamlScalar(p.focus.card)}`);
25
27
  }
26
28
  if (p.plan.length) {
27
29
  out.push(' plan:');
@@ -33,6 +35,13 @@ export function renderContextPack(pack) {
33
35
  for (const r of p.recent)
34
36
  out.push(` - ${yamlScalar(`${r.date} ${r.claim}`)}`);
35
37
  }
38
+ if (p.board === 'off')
39
+ out.push(' board: off');
40
+ if (p.cards?.length) {
41
+ out.push(' cards:');
42
+ for (const c of p.cards)
43
+ out.push(` - ${yamlScalar(c)}`);
44
+ }
36
45
  return out.join('\n');
37
46
  }
38
47
  /** 按政策截 plan / recent,再拼成「YAML 块 --- 消息」 */
@@ -1,28 +1,65 @@
1
1
  /**
2
- * 会话索引的纯函数:日期与 job 命名、空索引、把一次运行的结果并进索引(不改旧对象)。
2
+ * 对话索引的纯函数:日期与 job 命名、空索引、把一次运行的结果并进索引(不改旧对象)。
3
3
  */
4
4
  import { type ConversationIndex, type ConversationMessage } from '../schema/index.ts';
5
5
  import type { KidView } from './kid-view.ts';
6
6
  import type { Transcript } from './transcript.ts';
7
- /** 本地日期 YYYY-MM-DD(会话按本地日切) */
7
+ /** 本地日期 YYYY-MM-DD(对话按本地日切) */
8
8
  export declare function localDate(d: Date): string;
9
9
  /** 本地时间到分钟 YYYY-MM-DDTHH:MM(上下文包的 at) */
10
10
  export declare function localMinute(d: Date): string;
11
11
  /** HHMM-<序号>,同一天内不重复 */
12
12
  export declare function jobId(d: Date, seq: number): string;
13
- export declare function emptyIndex(teacher: string, date: string): ConversationIndex;
13
+ export declare function emptyIndex(tutor: string, date: string): ConversationIndex;
14
14
  export interface ConversationFiles {
15
15
  index: string;
16
16
  log: (job: string) => string;
17
17
  err: (job: string) => string;
18
+ audio: (job: string) => string;
19
+ /** 板书讲稿第 n 句的配音(n 从 1 起) */
20
+ lineAudio: (job: string, n: number) => string;
21
+ /** 这一轮各张卡的状态目录 <date>.<job>.cards/ */
22
+ cardsDir: (job: string) => string;
23
+ /** 第 n 张卡(0 起)的状态文件 <date>.<job>.cards/<n>.json */
24
+ card: (job: string, n: number) => string;
25
+ /** 第 n 张卡的资产目录 <date>.<job>.cards/<n>/ */
26
+ cardAssetsDir: (job: string, n: number) => string;
27
+ /** 第 n 张卡的一个资产文件(点读第 k 段 = <k>.mp3) */
28
+ cardAsset: (job: string, n: number, file: string) => string;
18
29
  }
19
- /** conversations/<teacher>/<date>.json 与 <date>.<job>.log / .err.log */
20
- export declare function conversationFiles(conversationsDir: string, teacher: string, date: string): ConversationFiles;
30
+ /** conversations/<tutor>/<date>.json 与 <date>.<job>.log / .err.log / .mp3(整段配音)/ .<n>.mp3(讲稿第 n 句)/ .cards/<n>.json(卡的状态) */
31
+ export declare function conversationFiles(conversationsDir: string, tutor: string, date: string): ConversationFiles;
32
+ /** 资产在孩子端的名字:相对 conversations/<老师>/,/api/audio 认它 */
33
+ export declare function cardAssetName(date: string, job: string, n: number, file: string): string;
34
+ /** 卡的 id:<job>/<n>(n 是节里的下标,0 起);页面、上下文包、状态文件名都用它 */
35
+ export declare function cardId(job: string, n: number): string;
36
+ /** 一张卡的状态文件:孩子做的事 + 什么时候做的 + 做的时候最后一轮是谁(下一条消息只带「上一轮之后改过的」) */
37
+ export interface CardStateFile {
38
+ at: string;
39
+ /** 存的时候索引里最后一条消息的 job;下一条消息发出时 turn === 当时的末条 → 这张卡要带上 */
40
+ turn: string;
41
+ state: unknown;
42
+ }
43
+ /** 索引里每条消息 job → 它那节里卡下标 → 状态文件(没做过的卡不在) */
44
+ export type CardStates = Record<string, Record<number, CardStateFile>>;
45
+ /** job → 卡下标 → 已生成好的资产名(相对 conversations/<老师>/) */
46
+ export type CardAssets = Record<string, Record<number, string[]>>;
47
+ /** 上一轮之后改过状态的卡(按 job、下标排):发消息时逐张 describe 进上下文包 */
48
+ export declare function changedCards(index: {
49
+ messages: readonly {
50
+ job: string;
51
+ }[];
52
+ }, states: CardStates): {
53
+ job: string;
54
+ n: number;
55
+ file: CardStateFile;
56
+ }[];
21
57
  export declare function addMessage(index: ConversationIndex, msg: ConversationMessage): ConversationIndex;
22
58
  /** 一次运行收尾:写 result / cost / kidText / artifacts,首次拿到 session 就记下 */
23
59
  export declare function applyRun(index: ConversationIndex, job: string, run: {
24
60
  transcript: Transcript;
25
61
  kidView: KidView;
26
- agent: string;
62
+ runtime: string;
27
63
  artifacts?: string[];
64
+ audio?: string | null;
28
65
  }): ConversationIndex;