cotutor 0.4.1 → 0.4.3

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 (46) hide show
  1. package/README.md +24 -6
  2. package/dist/app/cli/backup.d.ts +68 -0
  3. package/dist/app/cli/backup.js +243 -0
  4. package/dist/app/cli/doctor-fix.d.ts +18 -0
  5. package/dist/app/cli/doctor-fix.js +70 -0
  6. package/dist/app/cli/doctor.js +19 -2
  7. package/dist/app/cli/init.d.ts +2 -0
  8. package/dist/app/cli/init.js +6 -3
  9. package/dist/app/cli/main.js +117 -2
  10. package/dist/app/cli/report.d.ts +11 -0
  11. package/dist/app/cli/report.js +110 -0
  12. package/dist/app/cli/serve.d.ts +0 -1
  13. package/dist/app/cli/serve.js +14 -0
  14. package/dist/app/cli/service.d.ts +26 -0
  15. package/dist/app/cli/service.js +36 -5
  16. package/dist/app/cli/skeleton.d.ts +1 -1
  17. package/dist/app/cli/skeleton.js +2 -2
  18. package/dist/app/cli/tray.js +6 -4
  19. package/dist/app/cli/update-cmd.d.ts +38 -0
  20. package/dist/app/cli/update-cmd.js +174 -0
  21. package/dist/app/cli/usage.d.ts +1 -1
  22. package/dist/app/cli/usage.js +4 -1
  23. package/dist/app/http/app.d.ts +2 -0
  24. package/dist/app/http/app.js +18 -3
  25. package/dist/app/http/parent-home.js +4 -0
  26. package/dist/app/http/parent-setup.d.ts +19 -0
  27. package/dist/app/http/parent-setup.js +96 -0
  28. package/dist/app/machine/bailian.d.ts +26 -0
  29. package/dist/app/machine/bailian.js +91 -4
  30. package/dist/app/machine/update.d.ts +14 -1
  31. package/dist/app/machine/update.js +35 -14
  32. package/dist/brain/api.js +6 -3
  33. package/dist/memory/vault-notes.d.ts +4 -0
  34. package/dist/memory/vault-notes.js +11 -0
  35. package/dist/realtime/preview.d.ts +16 -0
  36. package/dist/realtime/preview.js +43 -1
  37. package/dist/runtime/live/call.d.ts +2 -0
  38. package/dist/runtime/live/call.js +6 -1
  39. package/dist/shared/upgrade.d.ts +5 -3
  40. package/dist/shared/upgrade.js +8 -4
  41. package/dist/ui/kid/kid-page.js +8 -2
  42. package/dist/ui/parent/parent-home-page.js +67 -3
  43. package/dist/ui/parent/parent-settings-page.js +65 -1
  44. package/package.json +7 -1
  45. package/skills/cotutor-analyze/references//345/221/275/344/273/244/344/270/216/346/226/207/344/273/266.md +1 -1
  46. package/skills/cotutor-tune/references//345/255/227/346/256/265.md +1 -1
package/README.md CHANGED
@@ -100,15 +100,36 @@ import { loadWorkspace } from 'cotutor/workspace'; // 解
100
100
 
101
101
  换了运行时,之前的话题接不上,也不导入历史:孩子接着说会开新话题;换之前聊的话题不记账,家长端卡上标「换过运行时 · 不记账」,记录照样能看。模型、预算与老师运行时都在 workspace 的 `cotutor.json` 里,编辑器按首行 `$schema` 提供字段说明。
102
102
 
103
- `cotutor doctor` 检查缺失配置并给修复建议;`--live` 会实际连接服务。页面上的老师起不来时,先看 doctor 和 `conversations/<老师>/<日期>.<job>.err.log`。家长端与工作台需要访问锁时,运行 `cotutor pin` 设置本机 PIN。
103
+ 百炼欠费、key 失效或这台电脑连不上百炼时,图标出橙点并通知,家长端首页说清楚是哪一种;要家长动手的那几种,孩子端显示「老师们还没到,叫爸爸妈妈来看一下」。处理好以后几分钟内自己恢复,也可以在家长端点「试连」。
104
+
105
+ `cotutor doctor` 检查缺失配置并给修复建议;`--fix` 把能自动修的修掉,`--live` 会实际连接服务。页面上的老师起不来时,先看 doctor 和 `conversations/<老师>/<日期>.<job>.err.log`。家长端与工作台需要访问锁时,运行 `cotutor pin` 设置本机 PIN。
106
+
107
+ ## 遇到问题
108
+
109
+ 家长端「首页」顶部的「还差几步」和橙色提示会说要做什么。还解决不了,到家长端「设置」点「生成诊断信息」(或在电脑上跑 `cotutor report`),看一遍再复制,到 [GitHub Issues](https://github.com/TarsLab/cotutor/issues) 反馈,或者贴给帮你的 AI 助手。里面没有孩子的对话、照片、录音、档案和 key,名字和 IP 已经抹掉。
104
110
 
105
111
  ## 数据与升级
106
112
 
107
113
  默认 workspace 是 `~/cotutor/<孩子>/`:配置在 `cotutor.json`,老师在 `agents/<老师>.md`,activity 在 `agents/<老师>/activities/`,技能在 `.agents/skills/`,对话在 `conversations/`,账本在 `ledger/`,照片在 `captures/`,课包在 `bundles/`,首页在 `home/`。长期档案、记忆和日记在配置的 Obsidian vault,规则见 [obsidian 仓库设计](docs/obsidian仓库设计.md)。备份时保留整个 workspace 与 vault。
108
114
 
115
+ **备份**放在孩子文件夹旁边的 `.backups/<孩子>/`,一份就是一个普通目录。服务开着时每天自动备一份(留 14 份),`cotutor upgrade` 前也备一份(留 5 份),没变的文件不占第二份地方;`cotutor backup` 随时手动备。**录音(wav)不在备份里**,它占了绝大部分地方;想留就自己拷 `conversations/`。硬盘坏了这些也跟着没了,重要的话把 `.backups` 再拷到别处。
116
+
117
+ ```sh
118
+ cotutor backup list # 看有哪些
119
+ cotutor backup restore <名字> --yes # 恢复:先把现在的样子也备一份,再拷回去
120
+ ```
121
+
109
122
  **有新版本时**,后台服务每天问一次 npm(只发一个普通请求,不带孩子或使用的任何信息),菜单栏(任务栏)图标的菜单和家长端「首页」顶部会说,通知一次,孩子端不说。不会自动升级,因为要重启服务,孩子可能正在上课。不想让它问:在 `~/.config/cotutor/update.json` 写 `{"off": true}`。
110
123
 
111
- 升级挑孩子不用的时候,依次运行(分开写是因为 Windows 默认的 PowerShell 不认 `&&`):
124
+ 升级挑孩子不用的时候,运行一条命令就够了(Windows 上写成 `cotutor.cmd update`,因为 PowerShell 缺省禁止运行脚本,提示里给的就是这样):
125
+
126
+ ```sh
127
+ cotutor update
128
+ ```
129
+
130
+ 它问 npm 有没有新版本,换上新程序,把每个孩子未修改的出厂老师、activity、主题与技能更新掉,再重起服务和图标。孩子正在通话时它不动;下载没成时服务也不动,还跑旧版。每个版本改了什么见 [更新说明](CHANGELOG.md)。
131
+
132
+ 0.4.1 及更早的版本还没有这条命令,第一次要依次运行下面三条,Windows 上同样写成 `npm.cmd`、`cotutor.cmd`。几个孩子的话,后两条每个孩子各跑一次,加 `--workspace ~/cotutor/<孩子>`:
112
133
 
113
134
  ```sh
114
135
  npm i -g cotutor # 换上新版程序
@@ -116,12 +137,9 @@ cotutor upgrade # 更新未修改的出厂老师、activit
116
137
  cotutor service restart # 服务和图标换上新代码(在终端里跑的就重开 serve)
117
138
  ```
118
139
 
119
- Windows 上写成 `npm.cmd`、`cotutor.cmd`(PowerShell 缺省禁止运行脚本),提示里给的就是这样。
120
-
121
- 几个孩子的话,后两条每个孩子各跑一次,加 `--workspace ~/cotutor/<孩子>`。然后看一眼体检,它提示要迁移配置时再跑后面两条:
140
+ 升完它提示要迁移配置时再跑:
122
141
 
123
142
  ```sh
124
- cotutor doctor
125
143
  cotutor upgrade --config --dry-run # 预览 cotutor.json 的迁移
126
144
  cotutor upgrade --config # 执行配置迁移
127
145
  ```
@@ -0,0 +1,68 @@
1
+ export declare const BACKUP_DIR = ".backups";
2
+ /** 磁盘快满了不备份:备份不能把孩子正在用的电脑撑爆 */
3
+ export declare const MIN_FREE_BYTES: number;
4
+ export type BackupWhy = 'daily' | 'upgrade' | 'manual' | 'restore';
5
+ export interface BackupMeta {
6
+ name: string;
7
+ at: string;
8
+ why: BackupWhy;
9
+ version: string | null;
10
+ files: number;
11
+ /** 这一份看上去多大 */
12
+ bytes: number;
13
+ /** 真正新占的地方(其余和上一份硬链接) */
14
+ newBytes: number;
15
+ /** 没备份的录音有几个 */
16
+ audioSkipped: number;
17
+ vault: 'inside' | 'copied' | 'unreadable' | 'none';
18
+ }
19
+ export interface BackupTarget {
20
+ root: string;
21
+ vault?: string;
22
+ }
23
+ export declare const backupsOf: (root: string) => string;
24
+ /** 要不要进备份:rel 是相对 workspace 根的路径,统一成 / */
25
+ export declare function wanted(rel: string): boolean;
26
+ export declare function listBackups(root: string): Promise<BackupMeta[]>;
27
+ export interface BackupOptions {
28
+ why: BackupWhy;
29
+ version?: string | null;
30
+ now?: Date;
31
+ /** 测试:这块盘还剩多少字节 */
32
+ freeBytes?: () => Promise<number>;
33
+ }
34
+ /** 备份一份。写到临时目录再改名,中途断了不会留下半份 */
35
+ export declare function makeBackup(t: BackupTarget, o: BackupOptions): Promise<BackupMeta>;
36
+ /** 每种留最近几份;刚做的那份不删。硬链接删了不影响别的份 */
37
+ export declare function pruneBackups(root: string, keepName?: string): Promise<string[]>;
38
+ /** 该不该做今天这份 */
39
+ export declare function dailyDue(list: readonly BackupMeta[], now: number): boolean;
40
+ /**
41
+ * 恢复:先把现在的样子也备份一份(恢复错了还能回来),再把那一份拷回去。只覆盖备份里有的文件,之后新多出来的不删。
42
+ * 服务要先停(main.ts 管)。外面的 vault 不动,回 vaultCopy 告诉家长在哪。
43
+ */
44
+ export declare function restoreBackup(t: BackupTarget, name: string, o?: {
45
+ version?: string | null;
46
+ now?: Date;
47
+ freeBytes?: () => Promise<number>;
48
+ }): Promise<{
49
+ files: number;
50
+ safety: string;
51
+ vaultCopy: string | null;
52
+ }>;
53
+ /** 一份给人看的样子 */
54
+ export declare function describeBackup(b: BackupMeta): string;
55
+ /**
56
+ * serve 起来时调:隔一阵看一眼,该备份了就备一份。孩子在通话时往后推(拷文件占磁盘,不和通话抢)。
57
+ * 备份失败不影响服务,只进日志,doctor 看「最近一份是多久以前」就知道了。定时器不拦进程退出。
58
+ */
59
+ export declare function startDailyBackups(o: {
60
+ target: () => BackupTarget | null;
61
+ busy: () => boolean;
62
+ version: string | null;
63
+ now?: () => number;
64
+ firstMs?: number;
65
+ everyMs?: number;
66
+ note?: (text: string) => void;
67
+ freeBytes?: () => Promise<number>;
68
+ }): () => void;
@@ -0,0 +1,243 @@
1
+ /**
2
+ * 备份:孩子的记录删了找不回来。升级前留一份,每天留一份,放在 workspace 旁边的 .backups/<目录名>/<名字>/,一份就是一个普通目录,打开就能看。
3
+ * 防的是升级弄坏、手滑改坏、误删文件夹;硬盘坏了防不住,要防就把 .backups 再拷到别处。
4
+ *
5
+ * 不备份录音(wav):2026-11-07 量真的 workspace,1.28 GB 里 1.21 GB 是录音,其余约 70 MB;这台电脑磁盘还只剩 4%。
6
+ * 也不备份能重新生成的(视频课 mp4、评测 evals、配音缓存)。保住的是文字记录、账本、照片、老师文件、配置。
7
+ *
8
+ * 没变的文件和上一份硬链接,不占第二份地方:每天一份也几乎不涨。一份里的文件不再改,所以硬链接是安全的;恢复时一律拷出去,不链回 workspace。
9
+ * vault 在 workspace 外面(Obsidian)的,只拷 md 到 _vault/,恢复时不动它:那是家长的,要不要拿回来家长定。macOS 上后台进程读不了「文稿」时记一笔,不算失败。
10
+ */
11
+ import { copyFile, link, lstat, mkdir, readFile, readdir, rename, rm, stat, statfs, utimes, writeFile } from 'node:fs/promises';
12
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
13
+ import { UsageError } from "../workspace.js";
14
+ export const BACKUP_DIR = '.backups';
15
+ const META = 'backup.json';
16
+ const VAULT_DIR = '_vault';
17
+ const TOP_SKIP = new Set(['.git', '.claude', '.sandbox', 'evals', 'node_modules']);
18
+ /** .cotutor 里能重新生成的缓存;别的(设备表、孩子端流水、出厂 hash)留着 */
19
+ const COTUTOR_SKIP = new Set(['qwen', 'live-preview', 'qr', 'board']);
20
+ const SKIP_EXT = /\.(wav|mp4|tmp)$/i;
21
+ const AUDIO = /\.wav$/i;
22
+ const VAULT_MAX_FILES = 5000;
23
+ const KEEP = { daily: 14, upgrade: 5, manual: 5, restore: 5 };
24
+ /** 磁盘快满了不备份:备份不能把孩子正在用的电脑撑爆 */
25
+ export const MIN_FREE_BYTES = 1024 ** 3;
26
+ const DAILY_EVERY_MS = 20 * 3600_000;
27
+ export const backupsOf = (root) => join(dirname(resolve(root)), BACKUP_DIR, basename(resolve(root)));
28
+ const pad = (n) => String(n).padStart(2, '0');
29
+ const stamp = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
30
+ /** 要不要进备份:rel 是相对 workspace 根的路径,统一成 / */
31
+ export function wanted(rel) {
32
+ const parts = rel.split('/');
33
+ if (TOP_SKIP.has(parts[0]))
34
+ return false;
35
+ if (parts[0] === '.cotutor' && parts.length > 1 && COTUTOR_SKIP.has(parts[1]))
36
+ return false;
37
+ return !SKIP_EXT.test(rel);
38
+ }
39
+ async function* walk(dir, base) {
40
+ for (const e of await readdir(dir, { withFileTypes: true })) {
41
+ const path = join(dir, e.name);
42
+ const rel = relative(base, path).split(sep).join('/');
43
+ // 只认普通文件和目录:链接(.claude/skills)不跟进去,不然会拷到 workspace 外面
44
+ if (e.isDirectory()) {
45
+ if (rel.split('/')[0] === BACKUP_DIR)
46
+ continue;
47
+ if (TOP_SKIP.has(rel) || (rel.startsWith('.cotutor/') && COTUTOR_SKIP.has(rel.slice('.cotutor/'.length))))
48
+ continue;
49
+ yield* walk(path, base);
50
+ }
51
+ else if (e.isFile()) {
52
+ const st = await lstat(path).catch(() => null);
53
+ if (st)
54
+ yield { path, rel, st };
55
+ }
56
+ }
57
+ }
58
+ /** 放一个文件:和上一份一样就硬链接,不一样(或链不了:跨盘、文件系统不支持)就拷。回新占了多少字节 */
59
+ async function place(src, st, dest, prev) {
60
+ await mkdir(dirname(dest), { recursive: true });
61
+ if (prev) {
62
+ const p = await stat(prev).catch(() => null);
63
+ if (p && p.size === st.size && Math.abs(p.mtimeMs - st.mtimeMs) < 2) {
64
+ try {
65
+ await link(prev, dest);
66
+ return 0;
67
+ }
68
+ catch { /* 链不了就拷 */ }
69
+ }
70
+ }
71
+ await copyFile(src, dest);
72
+ await utimes(dest, st.atime, st.mtime).catch(() => undefined);
73
+ return st.size;
74
+ }
75
+ export async function listBackups(root) {
76
+ const dir = backupsOf(root);
77
+ const names = await readdir(dir).catch(() => []);
78
+ const out = [];
79
+ for (const name of names) {
80
+ if (name.startsWith('.'))
81
+ continue;
82
+ try {
83
+ out.push({ ...JSON.parse(await readFile(join(dir, name, META), 'utf8')), name });
84
+ }
85
+ catch { /* 不是一份完整的备份 */ }
86
+ }
87
+ return out.sort((a, b) => b.at.localeCompare(a.at) || b.name.localeCompare(a.name));
88
+ }
89
+ const freeOf = async (dir) => {
90
+ const s = await statfs(dir);
91
+ return Number(s.bavail) * Number(s.bsize);
92
+ };
93
+ /** 备份一份。写到临时目录再改名,中途断了不会留下半份 */
94
+ export async function makeBackup(t, o) {
95
+ const root = resolve(t.root);
96
+ const dir = backupsOf(root);
97
+ await mkdir(dir, { recursive: true });
98
+ const free = await (o.freeBytes ?? (() => freeOf(dir)))().catch(() => Number.POSITIVE_INFINITY);
99
+ if (free < MIN_FREE_BYTES)
100
+ throw new UsageError(`磁盘只剩 ${(free / 1024 ** 3).toFixed(1)} GB,这次没备份:先腾点地方,备份不能把这台电脑撑爆`);
101
+ const now = o.now ?? new Date();
102
+ const list = await listBackups(root);
103
+ const prevDir = list[0] ? join(dir, list[0].name) : null;
104
+ let name = `${stamp(now)}-${o.why}`;
105
+ for (let i = 2; list.some((b) => b.name === name); i++)
106
+ name = `${stamp(now)}-${o.why}-${i}`;
107
+ const tmp = join(dir, `.tmp-${process.pid}-${now.getTime()}`);
108
+ await rm(tmp, { recursive: true, force: true });
109
+ await mkdir(tmp, { recursive: true });
110
+ const meta = { name, at: now.toISOString(), why: o.why, version: o.version ?? null, files: 0, bytes: 0, newBytes: 0, audioSkipped: 0, vault: 'none' };
111
+ try {
112
+ for await (const f of walk(root, root)) {
113
+ if (!wanted(f.rel)) {
114
+ if (AUDIO.test(f.rel))
115
+ meta.audioSkipped++;
116
+ continue;
117
+ }
118
+ meta.newBytes += await place(f.path, f.st, join(tmp, ...f.rel.split('/')), prevDir && join(prevDir, ...f.rel.split('/')));
119
+ meta.files++;
120
+ meta.bytes += f.st.size;
121
+ }
122
+ const vault = t.vault ? resolve(t.vault) : null;
123
+ if (vault && (vault === root || vault.startsWith(root + sep)))
124
+ meta.vault = 'inside';
125
+ else if (vault) {
126
+ try {
127
+ let n = 0;
128
+ for await (const f of walk(vault, vault)) {
129
+ if (!f.rel.endsWith('.md') || f.rel.split('/').some((p) => p.startsWith('.')))
130
+ continue;
131
+ if (++n > VAULT_MAX_FILES)
132
+ break;
133
+ meta.newBytes += await place(f.path, f.st, join(tmp, VAULT_DIR, ...f.rel.split('/')), prevDir && join(prevDir, VAULT_DIR, ...f.rel.split('/')));
134
+ meta.files++;
135
+ meta.bytes += f.st.size;
136
+ }
137
+ meta.vault = 'copied';
138
+ }
139
+ catch (err) {
140
+ const code = err.code;
141
+ // macOS 不让后台进程读「文稿」、iCloud 云盘;vault 不在了:都不拦这一份
142
+ if (code === 'EPERM' || code === 'EACCES' || code === 'ENOENT')
143
+ meta.vault = 'unreadable';
144
+ else
145
+ throw err;
146
+ }
147
+ }
148
+ await writeFile(join(tmp, META), `${JSON.stringify(meta, null, 2)}\n`);
149
+ await rename(tmp, join(dir, name));
150
+ }
151
+ catch (err) {
152
+ await rm(tmp, { recursive: true, force: true }).catch(() => undefined);
153
+ throw err;
154
+ }
155
+ await pruneBackups(root, name);
156
+ return meta;
157
+ }
158
+ /** 每种留最近几份;刚做的那份不删。硬链接删了不影响别的份 */
159
+ export async function pruneBackups(root, keepName) {
160
+ const dir = backupsOf(root);
161
+ const list = await listBackups(root);
162
+ const gone = [];
163
+ const seen = {};
164
+ for (const b of list) {
165
+ seen[b.why] = (seen[b.why] ?? 0) + 1;
166
+ if (seen[b.why] > (KEEP[b.why] ?? 5) && b.name !== keepName) {
167
+ await rm(join(dir, b.name), { recursive: true, force: true });
168
+ gone.push(b.name);
169
+ }
170
+ }
171
+ // 上次断在半路留下的临时目录
172
+ for (const n of await readdir(dir).catch(() => [])) {
173
+ if (!n.startsWith('.tmp-'))
174
+ continue;
175
+ const st = await stat(join(dir, n)).catch(() => null);
176
+ if (st && Date.now() - st.mtimeMs > 24 * 3600_000)
177
+ await rm(join(dir, n), { recursive: true, force: true });
178
+ }
179
+ return gone;
180
+ }
181
+ /** 该不该做今天这份 */
182
+ export function dailyDue(list, now) {
183
+ const last = list.find((b) => b.why === 'daily');
184
+ return !last || now - Date.parse(last.at) >= DAILY_EVERY_MS;
185
+ }
186
+ /**
187
+ * 恢复:先把现在的样子也备份一份(恢复错了还能回来),再把那一份拷回去。只覆盖备份里有的文件,之后新多出来的不删。
188
+ * 服务要先停(main.ts 管)。外面的 vault 不动,回 vaultCopy 告诉家长在哪。
189
+ */
190
+ export async function restoreBackup(t, name, o = {}) {
191
+ const root = resolve(t.root);
192
+ const target = (await listBackups(root)).find((b) => b.name === name);
193
+ if (!target)
194
+ throw new UsageError(`没有这份备份:${name}(cotutor backup list 看有哪些)`);
195
+ const from = join(backupsOf(root), name);
196
+ const safety = await makeBackup(t, { why: 'restore', version: o.version, now: o.now, freeBytes: o.freeBytes });
197
+ let files = 0;
198
+ for await (const f of walk(from, from)) {
199
+ if (f.rel === META || f.rel.split('/')[0] === VAULT_DIR)
200
+ continue;
201
+ const dest = join(root, ...f.rel.split('/'));
202
+ await mkdir(dirname(dest), { recursive: true });
203
+ await copyFile(f.path, dest);
204
+ await utimes(dest, f.st.atime, f.st.mtime).catch(() => undefined);
205
+ files++;
206
+ }
207
+ return { files, safety: safety.name, vaultCopy: target.vault === 'copied' ? join(from, VAULT_DIR) : null };
208
+ }
209
+ /** 一份给人看的样子 */
210
+ export function describeBackup(b) {
211
+ const mb = (n) => (n < 1024 * 1024 ? `${Math.max(1, Math.round(n / 1024))} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
212
+ const why = { daily: '每天', upgrade: '升级前', manual: '手动', restore: '恢复前' }[b.why] ?? b.why;
213
+ return `${b.name} ${why} · ${b.files} 个文件 · ${mb(b.bytes)}(新占 ${mb(b.newBytes)})${b.version ? ` · ${b.version}` : ''}`;
214
+ }
215
+ /**
216
+ * serve 起来时调:隔一阵看一眼,该备份了就备一份。孩子在通话时往后推(拷文件占磁盘,不和通话抢)。
217
+ * 备份失败不影响服务,只进日志,doctor 看「最近一份是多久以前」就知道了。定时器不拦进程退出。
218
+ */
219
+ export function startDailyBackups(o) {
220
+ let running = false;
221
+ const now = o.now ?? Date.now;
222
+ const check = async () => {
223
+ const t = o.target();
224
+ if (running || !t || o.busy())
225
+ return;
226
+ running = true;
227
+ try {
228
+ if (dailyDue(await listBackups(t.root), now()))
229
+ await makeBackup(t, { why: 'daily', version: o.version, now: new Date(now()), freeBytes: o.freeBytes });
230
+ }
231
+ catch (err) {
232
+ (o.note ?? ((s) => console.error(s)))(`备份没成:${err instanceof Error ? err.message : String(err)}`);
233
+ }
234
+ finally {
235
+ running = false;
236
+ }
237
+ };
238
+ const first = setTimeout(() => { void check(); }, o.firstMs ?? 5 * 60_000);
239
+ first.unref();
240
+ const every = setInterval(() => { void check(); }, o.everyMs ?? 3600_000);
241
+ every.unref();
242
+ return () => { clearTimeout(first); clearInterval(every); };
243
+ }
@@ -0,0 +1,18 @@
1
+ import type { DoctorReport } from './doctor.ts';
2
+ /** 按空格拆,双引号里的算一段;$HOME 换成真的家目录 */
3
+ export declare function parseCmd(cmd: string, home?: string): string[];
4
+ export interface FixDeps {
5
+ doctor(): Promise<DoctorReport>;
6
+ run(argv: string[]): Promise<{
7
+ code: number;
8
+ out: string;
9
+ }>;
10
+ print(line: string): void;
11
+ }
12
+ /** 修到没有可修的;同一条命令只跑一次,修不好的不反复试。回最后那次体检 */
13
+ export declare function runFixes(d: FixDeps): Promise<DoctorReport>;
14
+ /** 真的跑:cotutor 用现在这个 node 和 bin,不靠 PATH */
15
+ export declare function runArgv(argv: string[]): Promise<{
16
+ code: number;
17
+ out: string;
18
+ }>;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * cotutor doctor --fix:把体检里能自动修的(带 cmd 的那些:幂等、不碰孩子的数据、不用人输东西)一条条跑掉,修完再查,直到没有可修的;要人来的留给人。
3
+ * cmd 是给人和 agent 看的字符串(路径写成 $HOME,用户名不传出去),这里拆成参数直接起进程,不经 shell:Windows 的 cmd.exe 不认 $HOME,脚本策略也拦不到这里。
4
+ * 只跑 cotutor 和 git:cmd 是我们自己写的,但万一哪天有一条写错了,不该变成任意命令。
5
+ */
6
+ import { spawn } from 'node:child_process';
7
+ import { homedir } from 'node:os';
8
+ import { binPath } from "./service.js";
9
+ /** 按空格拆,双引号里的算一段;$HOME 换成真的家目录 */
10
+ export function parseCmd(cmd, home = homedir()) {
11
+ const out = [];
12
+ let cur = '';
13
+ let quoted = false;
14
+ let has = false;
15
+ for (const ch of cmd) {
16
+ if (ch === '"') {
17
+ quoted = !quoted;
18
+ has = true;
19
+ continue;
20
+ }
21
+ if (ch === ' ' && !quoted) {
22
+ if (has)
23
+ out.push(cur);
24
+ cur = '';
25
+ has = false;
26
+ continue;
27
+ }
28
+ cur += ch;
29
+ has = true;
30
+ }
31
+ if (has)
32
+ out.push(cur);
33
+ return out.map((a) => a.replaceAll('$HOME', home));
34
+ }
35
+ const ROUNDS = 12;
36
+ /** 修到没有可修的;同一条命令只跑一次,修不好的不反复试。回最后那次体检 */
37
+ export async function runFixes(d) {
38
+ const tried = new Set();
39
+ let report = await d.doctor();
40
+ for (let i = 0; i < ROUNDS; i++) {
41
+ const next = report.checks.find((c) => !c.ok && c.cmd && !tried.has(c.cmd));
42
+ if (!next?.cmd)
43
+ break;
44
+ tried.add(next.cmd);
45
+ const argv = parseCmd(next.cmd);
46
+ if (argv[0] !== 'cotutor' && argv[0] !== 'git') {
47
+ d.print(`! ${next.name}:不认识这条命令,没跑:${next.cmd}`);
48
+ continue;
49
+ }
50
+ const r = await d.run(argv);
51
+ d.print(`${r.code === 0 ? '✓' : '✗'} ${next.name}:${next.cmd}`);
52
+ if (r.code !== 0)
53
+ for (const l of r.out.trim().split('\n').slice(-4))
54
+ d.print(` ${l}`);
55
+ report = await d.doctor();
56
+ }
57
+ return report;
58
+ }
59
+ /** 真的跑:cotutor 用现在这个 node 和 bin,不靠 PATH */
60
+ export function runArgv(argv) {
61
+ const [cmd, args] = argv[0] === 'cotutor' ? [process.execPath, [binPath(), ...argv.slice(1)]] : [argv[0], argv.slice(1)];
62
+ return new Promise((resolve) => {
63
+ const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
64
+ let out = '';
65
+ child.stdout?.on('data', (c) => { out += c.toString('utf8'); });
66
+ child.stderr?.on('data', (c) => { out += c.toString('utf8'); });
67
+ child.on('error', (err) => resolve({ code: 1, out: `${out}${err.message}\n` }));
68
+ child.on('close', (code) => resolve({ code: code ?? 1, out }));
69
+ });
70
+ }
@@ -263,8 +263,13 @@ export async function doctorWorkspace(override, opts = {}) {
263
263
  const { portHolder, portState } = await import("./port.js");
264
264
  const port = opts.port ?? ws.config.server.port;
265
265
  const fix = '腾出这个端口;或改 cotutor.json 的 server.port,平板要重新扫码、重加主屏幕图标';
266
- if (await probeHealth(port))
266
+ const running = await probeHealth(port);
267
+ if (running) {
267
268
  push({ name: 'server.port', ok: true, required: false, detail: `端口 ${port} 上 cotutor 在答应` });
269
+ // 配好了不等于用得了:欠费、key 失效是跑起来才知道的,问在跑的那个服务
270
+ if (running.bailianTrouble)
271
+ push({ name: 'bailian.runtime', ok: false, required: false, detail: `${running.bailianTrouble.message}(${running.bailianTrouble.why ?? ''})`, fix: running.bailianTrouble.fix });
272
+ }
268
273
  else {
269
274
  const state = await portState(port);
270
275
  if (state === 'free')
@@ -540,7 +545,11 @@ export async function doctorWorkspace(override, opts = {}) {
540
545
  if (!s)
541
546
  push({ name: 'service', ok: false, required: false, detail: '没装后台服务:在终端里跑的 cotutor serve,关了终端就停', fix: 'cotutor service install(登录后自己起来、崩了自己再起)', cmd: `cotutor service install --no-open${flag}` });
542
547
  else {
548
+ const { stableNode } = await import("./service.js");
543
549
  const gone = s.args.slice(0, 2).filter((f) => !existsSync(f));
550
+ // 还没坏,但 brew upgrade 以后就坏了,而且坏的时候图标也起不来,没人报警
551
+ if (!gone.length && stableNode(s.args[0]) !== s.args[0])
552
+ push({ name: 'service.node', ok: false, required: false, detail: `后台服务写死了带版本号的 node(${redactHome(s.args[0])}),brew upgrade 以后服务和图标都起不来`, fix: 'cotutor service install(换成不带版本号的路径)', cmd: `cotutor service install --no-open${flag}` });
544
553
  push({ name: 'service', ok: !gone.length, required: false, detail: gone.length ? `后台服务 ${s.label} 指的 ${gone.map((f) => redactHome(f)).join('、')} 不在了(换了 node 版本、包挪了地方)` : `后台服务 ${s.label}(cotutor service status 看在不在跑)`, fix: gone.length ? 'cotutor service install(照现在的 node 与包重写一遍)' : undefined, cmd: gone.length ? `cotutor service install${flag}` : undefined });
545
554
  }
546
555
  }
@@ -560,12 +569,20 @@ export async function doctorWorkspace(override, opts = {}) {
560
569
  if (sec !== null)
561
570
  push({ name: 'windows.sleep', ok: sec === 0, required: false, detail: sec === 0 ? '接着电源时不闲置睡眠' : `接着电源 ${Math.round(sec / 60)} 分钟没人碰就睡,睡着时平板连不上(有通话时 cotutor 拦着它睡,别的时候拦不了)`, fix: sec === 0 ? undefined : '设置 › 系统 › 电源和电池 › 屏幕、睡眠和休眠超时,「插入电源时,使设备在以下时间后进入睡眠状态」选「从不」;合盖看「电源按钮和盖子」那一项' });
562
571
  }
572
+ // ---- 备份:服务开着时每天一份;好几天没有说明服务没在跑、或者备份一直失败(磁盘满、读不了) ----
573
+ if (probeEnv && ws && !ws.sandbox) {
574
+ const { backupsOf, listBackups } = await import("./backup.js");
575
+ const list = await listBackups(root).catch(() => []);
576
+ const last = list[0];
577
+ const ageMs = last ? (opts.now ?? new Date()).getTime() - Date.parse(last.at) : Number.POSITIVE_INFINITY;
578
+ push({ name: 'backup', ok: ageMs < 3 * 24 * 3600_000, required: false, detail: last ? `最近一份 ${last.at.slice(0, 10)},共 ${list.length} 份(${redactHome(backupsOf(root))})` : '还没有备份', fix: 'cotutor backup 备一份;服务开着时每天自己备', cmd: `cotutor backup${flag}` });
579
+ }
563
580
  // ---- 有没有新版本:只看服务问来存下的那份,体检不上网;什么时候升级家长定(要重起服务,孩子可能在上课),所以没有 cmd ----
564
581
  {
565
582
  const { readUpdateRecord } = await import("../machine/update.js");
566
583
  const u = await readUpdateRecord();
567
584
  if (!u.off && isNewer(u.latest, BUILD.version))
568
- push({ name: 'update', ok: false, required: false, detail: `有新版本 ${u.latest}(现在是 ${BUILD.version})`, fix: `挑孩子不用的时候,依次运行:${upgradeSteps().join(' ; ')}` });
585
+ push({ name: 'update', ok: false, required: false, detail: `有新版本 ${u.latest}(现在是 ${BUILD.version})`, fix: `挑孩子不用的时候,运行:${upgradeSteps().join(' ; ')}` });
569
586
  }
570
587
  // ---- 用户配置 ----
571
588
  try {
@@ -15,5 +15,7 @@ export interface InitOptions {
15
15
  port?: number;
16
16
  /** 出厂教学包年级,不是孩子档案的覆盖值。 */
17
17
  grade?: number;
18
+ /** 测试:算学期用的今天 */
19
+ now?: Date;
18
20
  }
19
21
  export declare function initWorkspace(opts: InitOptions): Promise<InitResult>;
@@ -9,6 +9,7 @@ import { dirname, join, resolve } from 'node:path';
9
9
  import { DIRS, GITIGNORE, LEDGER_FILES, REFERENCE_README, configTemplate, profileTemplate, shippedAgents, writeSchemaFile } from "./skeleton.js";
10
10
  import { installTutors, readManifest, writeManifest } from "./tutors.js";
11
11
  import { DEFAULT_GRADE, LEGACY_GRADE, requireGradePack } from "./grades.js";
12
+ import { schoolStartFor } from "../../memory/vault-notes.js";
12
13
  import { installActivities } from "./activities.js";
13
14
  import { installSkills, linkClaudeSkills } from "./skills.js";
14
15
  import { migrateLayout } from "./migrate.js";
@@ -102,8 +103,10 @@ export async function initWorkspace(opts) {
102
103
  steps.push({ item: redactHome(profile), action: 'kept', note: '没有 cotutor: profile 属性,老师读不到;在 frontmatter 加上(cotutor doctor 有写法)' });
103
104
  else {
104
105
  await mkdir(dirname(profile), { recursive: true });
105
- await writeFile(profile, profileTemplate(ws.config.kid.name ?? ws.config.kid.slug));
106
- steps.push({ item: redactHome(profile), action: 'created', note: '档案:填 school_start 与孩子的情况,每个话题开头整篇进上下文包' });
106
+ // 给了年级就算好读一年级的年月,老师第一天就知道是几年级哪个学期;没给就不猜(教学包的缺省年级不等于孩子的年级)
107
+ const start = opts.grade === undefined ? undefined : schoolStartFor(opts.grade, opts.now ?? new Date());
108
+ await writeFile(profile, profileTemplate(ws.config.kid.name ?? ws.config.kid.slug, start));
109
+ steps.push({ item: redactHome(profile), action: 'created', note: start ? `档案:按 ${opts.grade} 年级算,读一年级是 ${start};孩子的情况写在正文,每个话题开头整篇进上下文包` : '档案:填 school_start 与孩子的情况,每个话题开头整篇进上下文包' });
107
110
  }
108
111
  const readme = join(ws.paths.reference, 'README.md');
109
112
  if (await exists(readme))
@@ -149,7 +152,7 @@ export async function initWorkspace(opts) {
149
152
  const suggestions = [];
150
153
  if (!(await exists(join(root, '.git'))))
151
154
  suggestions.push('git init(建议:老师记忆、账本、政策都是不可再生状态,git 是破坏后的兜底)');
152
- suggestions.push('档案里填 school_start(读一年级的年月,如 2025-09),老师才知道现在是几年级哪个学期;想用自己的 Obsidian 库,改 cotutor.json 的 paths.vault');
155
+ suggestions.push(`${opts.grade === undefined ? '档案里填 school_start(读一年级的年月,如 2025-09),老师才知道现在是几年级哪个学期;' : ''}想用自己的 Obsidian 库,改 cotutor.json 的 paths.vault`);
153
156
  suggestions.push('cotutor service install 装成后台服务(登录后自己起来、关了终端照样跑;在终端里调试用 cotutor serve),第一次装会打开家长端(以后 cotutor open),在「首页」填百炼的按量 API Key 与业务空间 ID(老师的嘴和脑都用它;没填孩子端只出「老师们还没到」)');
154
157
  suggestions.push('在平板、手机上用:cotutor cert 签证书(要 mkcert),根证书装到设备上并打开信任,iPad / iPhone 的步骤见 README 里的《iPad 与 iPhone》');
155
158
  suggestions.push('可选:点读、单词卡的配音用百炼(和老师的嘴同一把 key),到家长端首页点老师的「样子与声音」挑声音;cotutor pin 给家长端与工作台加锁');