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
@@ -1,3 +1,11 @@
1
+ /**
2
+ * 孩子视图(《cotutor契约草案.md》§4,2026-09-10 改板书):最终文本剥掉「待裁量」「转交」段后,正文就是板书——
3
+ * 普通段落是讲稿(一行一句),围栏是卡(src/lib/board.ts 解析);kidText = 讲稿各句拼起来(家长视图的「孩子看到」摘要),
4
+ * 每句按 replyMaxChars 截;第一个 H2 起是给家长的尾巴(parentText),孩子看不到。出错什么都不出现。机械规则,不靠模型判断。
5
+ * 正文取自 kidSource:带卡 / 带固定段的顶层文本段 + 最后一段(老师板书之后又用了工具也不丢)。
6
+ */
7
+ import { stripSecrets } from "../cards/index.js";
8
+ import { parseBoard } from "./board.js";
1
9
  import { parseSections } from "./sections.js";
2
10
  const SENTENCE_END = new Set(['。', '!', '!', '?', '?', ';', ';', '\n']);
3
11
  /** 以空行分段,取最后一个非空段(段内换行保留) */
@@ -24,15 +32,62 @@ export function truncateReply(text, max) {
24
32
  return { text: cps.slice(0, cut).join('').trim(), truncated: true };
25
33
  return { text: `${cps.slice(0, max).join('').trim()}…`, truncated: true };
26
34
  }
35
+ const none = (ok, holdup = null, handoff = null) => ({ kidText: null, truncated: false, holdup, handoff, ok, section: null, warnings: [], parentText: '' });
36
+ const FENCE_OR_H2 = /^\s*(?:```|~~~|## )/m;
37
+ /**
38
+ * 孩子看的正文从哪来(2026-09-10 拍板,两次真跑的教训):`result.result` 只是这轮**最后一段**顶层文本——老师写完板书再用一次工具、再补一句,
39
+ * 板书和「## 转交」段就没了。所以正文 = 这轮里**带围栏(卡)或带 H2 固定段的顶层文本段** + **最后一段**(顺序不变,重复不算两次);
40
+ * 中间那些「我先看看账本」之类没卡没段的话不进。子代理的话本来就不在顶层。
41
+ */
42
+ export function kidSource(t) {
43
+ if (!t.final?.text)
44
+ return null;
45
+ const last = t.final.text.trim();
46
+ const blocks = t.items.filter((i) => i.kind === 'text' && !i.sub).map((i) => i.text.trim());
47
+ const kept = blocks.filter((b) => b !== last && FENCE_OR_H2.test(b));
48
+ return [...kept, last].join('\n\n');
49
+ }
27
50
  export function deriveKidView(t, policy) {
28
51
  if (!t.final)
29
- return { kidText: null, truncated: false, holdup: null, handoff: null, ok: false };
52
+ return none(false);
30
53
  if (!t.final.ok || !t.final.text)
31
- return { kidText: null, truncated: false, holdup: null, handoff: null, ok: t.final.ok };
32
- const { body, holdup, handoff } = parseSections(t.final.text);
33
- const last = lastParagraph(body);
34
- if (!last)
35
- return { kidText: null, truncated: false, holdup, handoff, ok: true };
36
- const { text, truncated } = truncateReply(last, policy.replyMaxChars);
37
- return { kidText: text, truncated, holdup, handoff, ok: true };
54
+ return none(t.final.ok);
55
+ const { body, holdup, handoff } = parseSections(kidSource(t) ?? t.final.text);
56
+ const board = parseBoard(body);
57
+ const { cards, lines } = board.section;
58
+ if (!cards.length && !lines.length)
59
+ return { ...none(true, holdup, handoff), warnings: board.warnings, parentText: board.tail };
60
+ let truncated = false;
61
+ const cut = lines.map((l) => {
62
+ const r = truncateReply(l.text, policy.replyMaxChars);
63
+ truncated = truncated || r.truncated;
64
+ return { ...l, text: r.text };
65
+ });
66
+ const section = { ...board.section, lines: cut };
67
+ const kidText = cut.map((l) => l.text).join('\n');
68
+ return { kidText: kidText || null, truncated, holdup, handoff, ok: true, section, warnings: board.warnings, parentText: board.tail };
69
+ }
70
+ /**
71
+ * 对话索引 → 孩子端条目(《契约草案.md》§4 的机械过滤在服务端做):不带 result / error / holdup / handoff / 费用 / 家长尾巴;
72
+ * 卡上的答案剥掉,孩子自己做的状态(states)与已生成的资产(assets,都从 .cards/ 读)并到卡上。出错的运行:没有 question 的直接不出现;有 question 的只留问句(老师头像不灰,下一条照常)。
73
+ */
74
+ export function kidConversation(index, states = {}, assets = {}) {
75
+ const out = [];
76
+ for (const m of index.messages) {
77
+ const question = m.from === 'kid' ? m.text : null;
78
+ const reply = m.result === 'ok' ? (m.kidText ?? null) : null;
79
+ const pending = m.result === 'running';
80
+ if (question === null && reply === null && !pending)
81
+ continue;
82
+ const per = states[m.job];
83
+ const files = assets[m.job];
84
+ const withState = m.section && (per || files) ? { ...m.section, cards: m.section.cards.map((c, n) => ({ ...c, ...(per?.[n] ? { state: per[n].state } : {}), ...(files?.[n]?.length ? { assets: files[n] } : {}) })) } : m.section;
85
+ const section = m.result === 'ok' && withState ? stripSecrets(withState) : undefined;
86
+ out.push({ job: m.job, at: m.at, question, reply, audio: reply ? (m.audio ?? null) : null, pending, artifacts: reply ? [...m.artifacts] : [], ...(section ? { section } : {}) });
87
+ }
88
+ return out;
89
+ }
90
+ /** 今天孩子已发的条数(每日上限按它算;家长发的不算,「继续」不算,交答案算) */
91
+ export function kidMessageCount(index) {
92
+ return index.messages.filter((m) => m.from === 'kid' && m.action !== 'continue').length;
38
93
  }
@@ -19,3 +19,11 @@ export declare function mergeArtifacts(events: ArtifactEvent[]): {
19
19
  };
20
20
  /** o-YYYYMMDD-NNN,同日顺延 */
21
21
  export declare function nextObservationId(date: string, existing: Iterable<string>): string;
22
+ /** 上下文包的 recent:未撤回、本学科(没配 subject 就全量)的最近 n 条,按日期与出现顺序,只带 date + claim */
23
+ export declare function recentObservations(obs: Observation[], opts: {
24
+ subject?: string;
25
+ n: number;
26
+ }): {
27
+ date: string;
28
+ claim: string;
29
+ }[];
@@ -90,3 +90,11 @@ export function nextObservationId(date, existing) {
90
90
  }
91
91
  return `o-${day}-${String(n + 1).padStart(3, '0')}`;
92
92
  }
93
+ /** 上下文包的 recent:未撤回、本学科(没配 subject 就全量)的最近 n 条,按日期与出现顺序,只带 date + claim */
94
+ export function recentObservations(obs, opts) {
95
+ return obs
96
+ .filter((o) => !o.retracted && (!opts.subject || o.subject === opts.subject))
97
+ .sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
98
+ .slice(-opts.n)
99
+ .map((o) => ({ date: o.date, claim: o.claim }));
100
+ }
@@ -10,3 +10,5 @@ export interface PlanParse {
10
10
  export declare function parsePlan(md: string): PlanParse;
11
11
  /** 抽「与这位老师相关的行」:H2 标题等于显示名(两侧空白不计),最多 limit 行 */
12
12
  export declare function planLinesFor(plan: Plan, display: string, limit: number): string[];
13
+ /** ISO 周号 YYYY-Www(周一起,含 1 月 4 日的那周是第 1 周),计划文件按它命名 */
14
+ export declare function isoWeek(d: Date): string;
package/dist/lib/plan.js CHANGED
@@ -76,3 +76,12 @@ export function planLinesFor(plan, display, limit) {
76
76
  out.push(...s.lines);
77
77
  return out.slice(0, limit);
78
78
  }
79
+ /** ISO 周号 YYYY-Www(周一起,含 1 月 4 日的那周是第 1 周),计划文件按它命名 */
80
+ export function isoWeek(d) {
81
+ const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
82
+ const day = t.getUTCDay() || 7;
83
+ t.setUTCDate(t.getUTCDate() + 4 - day);
84
+ const yearStart = Date.UTC(t.getUTCFullYear(), 0, 1);
85
+ const week = Math.ceil(((t.getTime() - yearStart) / 86400000 + 1) / 7);
86
+ return `${t.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
87
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 一次运行的命令规划(纯函数):按运行时选 run 还是 resume、填占位符、定 cwd 相对路径。
3
+ * 规则:当天索引里已有会话**且**是同一运行时跑出来的 → resume;否则新开(跨天索引本来就是新的,零点后第一条自然不带 --resume;
4
+ * 换了运行时也新开——claude 的会话 id qwen 不认)。{agentBody} 只给模板里真用到它的运行时读老师正文。
5
+ */
6
+ import { type Runtime, type ConversationIndex, type CotutorConfig } from '../schema/index.ts';
7
+ export interface RunPlan {
8
+ /** 运行时名(runtimes 里的键) */
9
+ runtime: string;
10
+ /** 完整命令行,argv[0] 是可执行文件 */
11
+ argv: string[];
12
+ /** 这次是 resume 还是新开 */
13
+ resume: boolean;
14
+ /** resume 的会话 id */
15
+ session: string | null;
16
+ }
17
+ export declare class RuntimeError extends Error {
18
+ }
19
+ export declare function getRuntime(config: CotutorConfig, name?: string): {
20
+ name: string;
21
+ runtime: Runtime;
22
+ };
23
+ /** 模板里有没有用到某个占位符(决定要不要去读老师正文) */
24
+ export declare function runtimeUses(runtime: Runtime, placeholder: string): boolean;
25
+ export declare function planRun(config: CotutorConfig, index: Pick<ConversationIndex, 'session'>, vars: {
26
+ agent: string;
27
+ prompt: string;
28
+ agentBody?: string;
29
+ runtime?: string;
30
+ }): RunPlan;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 一次运行的命令规划(纯函数):按运行时选 run 还是 resume、填占位符、定 cwd 相对路径。
3
+ * 规则:当天索引里已有会话**且**是同一运行时跑出来的 → resume;否则新开(跨天索引本来就是新的,零点后第一条自然不带 --resume;
4
+ * 换了运行时也新开——claude 的会话 id qwen 不认)。{agentBody} 只给模板里真用到它的运行时读老师正文。
5
+ */
6
+ import { fillRuntime } from "../schema/index.js";
7
+ export class RuntimeError extends Error {
8
+ }
9
+ export function getRuntime(config, name) {
10
+ const n = name ?? config.runtimes.default;
11
+ const p = config.runtimes[n];
12
+ if (n === 'default' || !p || typeof p === 'string') {
13
+ const known = Object.keys(config.runtimes).filter((k) => k !== 'default');
14
+ throw new RuntimeError(`运行时 "${n}" 不存在;cotutor.json 的 runtimes 里有:${known.join('、')}`);
15
+ }
16
+ return { name: n, runtime: p };
17
+ }
18
+ /** 模板里有没有用到某个占位符(决定要不要去读老师正文) */
19
+ export function runtimeUses(runtime, placeholder) {
20
+ return [...runtime.run, ...runtime.resume].some((a) => a.includes(placeholder));
21
+ }
22
+ export function planRun(config, index, vars) {
23
+ const { name, runtime } = getRuntime(config, vars.runtime);
24
+ const session = index.session && index.session.runtime === name ? index.session.id : null;
25
+ const template = session ? runtime.resume : runtime.run;
26
+ return {
27
+ runtime: name,
28
+ argv: fillRuntime(template, { agent: vars.agent, prompt: vars.prompt, session: session ?? undefined, agentBody: vars.agentBody }),
29
+ resume: session !== null,
30
+ session,
31
+ };
32
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 流式:老师还在说的时候,从 stream-json 的增量事件里拼出「当前这条顶层回复的正文」,给板书的 partial 解析用。
3
+ * claude 2.1.220 加 `--include-partial-messages` 后每个字来一条 `stream_event`(content_block_delta / text_delta),
4
+ * 每条新消息前有 message_start;qwen 0.21.13 没有增量开关,只有整条 `assistant` 事件——两种都认:
5
+ * delta 往当前文本上追加,message_start 时当前段带围栏或 H2 就留下(与 kid-view 的 kidSource 同一条规则:老师板书完再用工具,卡不丢)、否则丢掉,
6
+ * 新段从空开始;顶层 assistant 的 text 块直接当作当前段(与拼出来的一样)。text() = 留下的段 + 当前段。
7
+ * parent_tool_use_id 非空的(子代理)一律跳过;喂进来的是原始字节块,行没收完的先攒着。
8
+ */
9
+ export interface PartialReader {
10
+ /** 喂一块 stdout;返回文本有没有变 */
11
+ feed(chunk: string): boolean;
12
+ /** 当前这条顶层回复的正文(还在长) */
13
+ text(): string;
14
+ }
15
+ export declare function createPartialReader(): PartialReader;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * 流式:老师还在说的时候,从 stream-json 的增量事件里拼出「当前这条顶层回复的正文」,给板书的 partial 解析用。
3
+ * claude 2.1.220 加 `--include-partial-messages` 后每个字来一条 `stream_event`(content_block_delta / text_delta),
4
+ * 每条新消息前有 message_start;qwen 0.21.13 没有增量开关,只有整条 `assistant` 事件——两种都认:
5
+ * delta 往当前文本上追加,message_start 时当前段带围栏或 H2 就留下(与 kid-view 的 kidSource 同一条规则:老师板书完再用工具,卡不丢)、否则丢掉,
6
+ * 新段从空开始;顶层 assistant 的 text 块直接当作当前段(与拼出来的一样)。text() = 留下的段 + 当前段。
7
+ * parent_tool_use_id 非空的(子代理)一律跳过;喂进来的是原始字节块,行没收完的先攒着。
8
+ */
9
+ const KEEP = /^\s*(?:```|~~~|## )/m;
10
+ export function createPartialReader() {
11
+ let rest = '';
12
+ let text = '';
13
+ const kept = [];
14
+ const apply = (line) => {
15
+ if (!line.trim())
16
+ return false;
17
+ let e;
18
+ try {
19
+ e = JSON.parse(line);
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ if (typeof e.parent_tool_use_id === 'string' && e.parent_tool_use_id)
25
+ return false;
26
+ if (e.type === 'stream_event') {
27
+ const ev = e.event;
28
+ if (ev?.type === 'message_start') {
29
+ if (!text)
30
+ return false;
31
+ if (KEEP.test(text))
32
+ kept.push(text.trim());
33
+ text = '';
34
+ return true;
35
+ }
36
+ if (ev?.type === 'content_block_delta' && ev.delta?.type === 'text_delta' && typeof ev.delta.text === 'string' && ev.delta.text) {
37
+ text += ev.delta.text;
38
+ return true;
39
+ }
40
+ return false;
41
+ }
42
+ if (e.type === 'assistant' && Array.isArray(e.message?.content)) {
43
+ const t = e.message.content
44
+ .filter((b) => b.type === 'text' && typeof b.text === 'string')
45
+ .map((b) => b.text)
46
+ .join('');
47
+ if (t && t !== text) {
48
+ text = t;
49
+ return true;
50
+ }
51
+ }
52
+ return false;
53
+ };
54
+ return {
55
+ feed(chunk) {
56
+ rest += chunk;
57
+ let changed = false;
58
+ let at;
59
+ while ((at = rest.indexOf('\n')) >= 0) {
60
+ const line = rest.slice(0, at);
61
+ rest = rest.slice(at + 1);
62
+ if (apply(line))
63
+ changed = true;
64
+ }
65
+ return changed;
66
+ },
67
+ text: () => [...kept, text].filter(Boolean).join('\n\n'),
68
+ };
69
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 课程表解析(抄自 growth-apps apps/web/src/timetable.ts,2026-09-09;孩子列改为可选)。
3
+ * 表头须含 星期/时间/学科 三列(孩子列可有可无,顺序不限,多余列忽略);可以有多张表;列名对不上的表整张跳过。
4
+ * 错误即修复指南,带行号;解析尽量往下走,一次报全。
5
+ */
6
+ import type { TimetableEntry } from '../schema/index.ts';
7
+ export interface TimetableParse {
8
+ entries: TimetableEntry[];
9
+ /** 没找到任何课程表时 entries 为空且这里有整体指南 */
10
+ errors: string[];
11
+ found: boolean;
12
+ }
13
+ export declare function dayName(day: number): string;
14
+ export declare function parseTimetable(md: string): TimetableParse;
15
+ /** js Date → 1–7(周一–周日) */
16
+ export declare function dayOf(now: Date): number;
17
+ /** 本周(周一起算)第 day 天的日期 YYYY-MM-DD */
18
+ export declare function weekDate(now: Date, day: number): string;
19
+ /** 当前正在进行的时段(第一条命中;不在任何时段 = null,不猜) */
20
+ export declare function currentSlot(entries: readonly TimetableEntry[], now: Date): TimetableEntry | null;
21
+ /** 上下文包的 slot 字段:「数学 16:00-17:00」 */
22
+ export declare function slotLabel(e: TimetableEntry): string;
@@ -0,0 +1,106 @@
1
+ const REQUIRED = ['星期', '时间', '学科'];
2
+ const DAY_MAP = { 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 日: 7, 天: 7 };
3
+ const DAY_NAMES = ['', '一', '二', '三', '四', '五', '六', '日'];
4
+ export function dayName(day) {
5
+ return `周${DAY_NAMES[day] ?? '?'}`;
6
+ }
7
+ const cells = (line) => line
8
+ .replace(/^\s*\|/, '')
9
+ .replace(/\|\s*$/, '')
10
+ .split('|')
11
+ .map((c) => c.trim());
12
+ const isTableRow = (line) => /^\s*\|.*\|\s*$/.test(line);
13
+ const isSeparatorRow = (line) => isTableRow(line) && cells(line).every((c) => /^:?-{2,}:?$/.test(c) || c === '');
14
+ function parseDay(raw) {
15
+ const s = raw.replace(/^(星期|周)/, '').trim();
16
+ if (/^[1-7]$/.test(s))
17
+ return Number(s);
18
+ return DAY_MAP[s] ?? null;
19
+ }
20
+ const TIME_RE = /^(\d{1,2}):(\d{2})\s*[-–—~至]+\s*(\d{1,2}):(\d{2})$/;
21
+ function parseTime(raw) {
22
+ const m = TIME_RE.exec(raw.trim());
23
+ if (!m)
24
+ return null;
25
+ const [h1, m1, h2, m2] = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
26
+ if (h1 > 23 || h2 > 23 || m1 > 59 || m2 > 59)
27
+ return null;
28
+ const start = `${String(h1).padStart(2, '0')}:${m[2]}`;
29
+ const end = `${String(h2).padStart(2, '0')}:${m[4]}`;
30
+ if (start >= end)
31
+ return null;
32
+ return { start, end };
33
+ }
34
+ export function parseTimetable(md) {
35
+ const lines = md.split('\n');
36
+ const errors = [];
37
+ const entries = [];
38
+ let found = false;
39
+ let i = 0;
40
+ while (i < lines.length) {
41
+ if (!isTableRow(lines[i])) {
42
+ i++;
43
+ continue;
44
+ }
45
+ const header = cells(lines[i]);
46
+ const col = { 星期: header.indexOf('星期'), 时间: header.indexOf('时间'), 学科: header.indexOf('学科'), 孩子: header.indexOf('孩子') };
47
+ if (REQUIRED.some((name) => col[name] < 0)) {
48
+ i++;
49
+ while (i < lines.length && isTableRow(lines[i]))
50
+ i++;
51
+ continue;
52
+ }
53
+ found = true;
54
+ i++;
55
+ if (i < lines.length && isSeparatorRow(lines[i]))
56
+ i++;
57
+ for (; i < lines.length && isTableRow(lines[i]); i++) {
58
+ const row = cells(lines[i]);
59
+ const ln = i + 1;
60
+ const get = (name) => (col[name] >= 0 ? (row[col[name]] ?? '') : '');
61
+ const day = parseDay(get('星期'));
62
+ if (day === null) {
63
+ errors.push(`第 ${ln} 行:星期「${get('星期')}」认不出;写 一/二/…/日(或 1–7)`);
64
+ continue;
65
+ }
66
+ const time = parseTime(get('时间'));
67
+ if (!time) {
68
+ errors.push(`第 ${ln} 行:时间「${get('时间')}」应形如 19:00–19:40(起止用 – 或 - 连接,且结束晚于开始)`);
69
+ continue;
70
+ }
71
+ const subject = get('学科');
72
+ if (!subject) {
73
+ errors.push(`第 ${ln} 行:学科为空;写 语文/数学/英语 等(与 cotutor.json 里老师的 subject 对齐即归到那位老师)`);
74
+ continue;
75
+ }
76
+ const kid = get('孩子');
77
+ entries.push({ day, ...time, subject, ...(kid ? { kid } : {}) });
78
+ }
79
+ }
80
+ if (!found)
81
+ return { entries: [], errors: ['没找到课程表:课程表.md 里需要一张含 星期/时间/学科 三列表头的 markdown 表(孩子列可选)'], found: false };
82
+ entries.sort((a, b) => a.day - b.day || a.start.localeCompare(b.start));
83
+ return { entries, errors, found: true };
84
+ }
85
+ /** js Date → 1–7(周一–周日) */
86
+ export function dayOf(now) {
87
+ const d = now.getDay();
88
+ return d === 0 ? 7 : d;
89
+ }
90
+ /** 本周(周一起算)第 day 天的日期 YYYY-MM-DD */
91
+ export function weekDate(now, day) {
92
+ const d = new Date(now);
93
+ d.setDate(d.getDate() + (day - dayOf(now)));
94
+ const p = (n) => String(n).padStart(2, '0');
95
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
96
+ }
97
+ /** 当前正在进行的时段(第一条命中;不在任何时段 = null,不猜) */
98
+ export function currentSlot(entries, now) {
99
+ const day = dayOf(now);
100
+ const hm = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
101
+ return entries.find((e) => e.day === day && e.start <= hm && hm < e.end) ?? null;
102
+ }
103
+ /** 上下文包的 slot 字段:「数学 16:00-17:00」 */
104
+ export function slotLabel(e) {
105
+ return `${e.subject} ${e.start}-${e.end}`;
106
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * 板书(孩子端老师页)一节的契约:卡 {kind, props} + 讲稿句。类型的真相在 src/lib/kid-board.ts(它要内联进页面,不能 import),
3
+ * 这里的 zod 与之对齐,文件底部用赋值断言锁住。每种卡的 props 契约在 src/cards/<kind>.ts。
4
+ */
5
+ import { z } from 'zod';
6
+ export declare const BoardCardSchema: z.ZodObject<{
7
+ kind: z.ZodString;
8
+ props: z.ZodRecord<z.ZodString, z.ZodUnknown>;
9
+ state: z.ZodOptional<z.ZodUnknown>;
10
+ assets: z.ZodOptional<z.ZodArray<z.ZodString>>;
11
+ }, z.core.$strip>;
12
+ export declare const BoardMarkSchema: z.ZodObject<{
13
+ card: z.ZodNumber;
14
+ phrase: z.ZodString;
15
+ }, z.core.$strip>;
16
+ export declare const BoardCueSchema: z.ZodObject<{
17
+ card: z.ZodNumber;
18
+ name: z.ZodString;
19
+ arg: z.ZodOptional<z.ZodString>;
20
+ }, z.core.$strip>;
21
+ export declare const BoardLineSchema: z.ZodObject<{
22
+ text: z.ZodString;
23
+ audio: z.ZodNullable<z.ZodString>;
24
+ marks: z.ZodArray<z.ZodObject<{
25
+ card: z.ZodNumber;
26
+ phrase: z.ZodString;
27
+ }, z.core.$strip>>;
28
+ ask: z.ZodBoolean;
29
+ anchor: z.ZodNullable<z.ZodNumber>;
30
+ cues: z.ZodArray<z.ZodObject<{
31
+ card: z.ZodNumber;
32
+ name: z.ZodString;
33
+ arg: z.ZodOptional<z.ZodString>;
34
+ }, z.core.$strip>>;
35
+ }, z.core.$strip>;
36
+ export declare const BoardSectionSchema: z.ZodObject<{
37
+ cards: z.ZodArray<z.ZodObject<{
38
+ kind: z.ZodString;
39
+ props: z.ZodRecord<z.ZodString, z.ZodUnknown>;
40
+ state: z.ZodOptional<z.ZodUnknown>;
41
+ assets: z.ZodOptional<z.ZodArray<z.ZodString>>;
42
+ }, z.core.$strip>>;
43
+ lines: z.ZodArray<z.ZodObject<{
44
+ text: z.ZodString;
45
+ audio: z.ZodNullable<z.ZodString>;
46
+ marks: z.ZodArray<z.ZodObject<{
47
+ card: z.ZodNumber;
48
+ phrase: z.ZodString;
49
+ }, z.core.$strip>>;
50
+ ask: z.ZodBoolean;
51
+ anchor: z.ZodNullable<z.ZodNumber>;
52
+ cues: z.ZodArray<z.ZodObject<{
53
+ card: z.ZodNumber;
54
+ name: z.ZodString;
55
+ arg: z.ZodOptional<z.ZodString>;
56
+ }, z.core.$strip>>;
57
+ }, z.core.$strip>>;
58
+ partial: z.ZodOptional<z.ZodBoolean>;
59
+ }, z.core.$strip>;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 板书(孩子端老师页)一节的契约:卡 {kind, props} + 讲稿句。类型的真相在 src/lib/kid-board.ts(它要内联进页面,不能 import),
3
+ * 这里的 zod 与之对齐,文件底部用赋值断言锁住。每种卡的 props 契约在 src/cards/<kind>.ts。
4
+ */
5
+ import { z } from 'zod';
6
+ export const BoardCardSchema = z.object({
7
+ kind: z.string().min(1),
8
+ props: z.record(z.string(), z.unknown()),
9
+ /** 孩子在卡上做的事(形状由那种卡的 state 契约定);索引里不存,下发孩子端时从 .cards/ 文件并进来 */
10
+ state: z.unknown().optional(),
11
+ /** 已生成好的配音资产(相对 conversations/<老师>/);索引里不存,下发时从 .cards/<n>/ 目录并进来 */
12
+ assets: z.array(z.string()).optional(),
13
+ });
14
+ export const BoardMarkSchema = z.object({ card: z.number().int().nonnegative(), phrase: z.string().min(1) });
15
+ export const BoardCueSchema = z.object({ card: z.number().int().nonnegative(), name: z.string().min(1), arg: z.string().optional() });
16
+ export const BoardLineSchema = z.object({
17
+ text: z.string(),
18
+ audio: z.string().nullable(),
19
+ marks: z.array(BoardMarkSchema),
20
+ ask: z.boolean(),
21
+ anchor: z.number().int().nonnegative().nullable(),
22
+ cues: z.array(BoardCueSchema),
23
+ });
24
+ export const BoardSectionSchema = z.object({
25
+ cards: z.array(BoardCardSchema),
26
+ lines: z.array(BoardLineSchema),
27
+ partial: z.boolean().optional(),
28
+ });
29
+ const assertAligned = (s) => s;
30
+ void assertAligned;