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.
- package/README.md +35 -24
- package/agents/chinese-tutor.md +67 -0
- package/agents/{homework-aide.md → homework-tutor.md} +4 -4
- package/agents/math-tutor.md +68 -0
- package/agents/reading-tutor.md +65 -0
- package/agents/scene-maker.md +41 -0
- package/dist/cards/canvas.d.ts +22 -0
- package/dist/cards/canvas.js +65 -0
- package/dist/cards/choice.d.ts +20 -0
- package/dist/cards/choice.js +76 -0
- package/dist/cards/code.d.ts +9 -0
- package/dist/cards/code.js +13 -0
- package/dist/cards/fill.d.ts +14 -0
- package/dist/cards/fill.js +64 -0
- package/dist/cards/image.d.ts +13 -0
- package/dist/cards/image.js +35 -0
- package/dist/cards/index.d.ts +48 -0
- package/dist/cards/index.js +115 -0
- package/dist/cards/kind.d.ts +27 -0
- package/dist/cards/kind.js +7 -0
- package/dist/cards/read.d.ts +8 -0
- package/dist/cards/read.js +27 -0
- package/dist/cards/scene.d.ts +24 -0
- package/dist/cards/scene.js +67 -0
- package/dist/cards/text.d.ts +18 -0
- package/dist/cards/text.js +41 -0
- package/dist/cli/cert.d.ts +7 -0
- package/dist/cli/cert.js +36 -0
- package/dist/cli/doctor.d.ts +5 -2
- package/dist/cli/doctor.js +183 -18
- package/dist/cli/init.d.ts +1 -1
- package/dist/cli/init.js +41 -39
- package/dist/cli/main.js +128 -8
- package/dist/cli/serve.d.ts +15 -4
- package/dist/cli/serve.js +40 -6
- package/dist/cli/skeleton.d.ts +23 -5
- package/dist/cli/skeleton.js +131 -29
- package/dist/cli/skills.d.ts +40 -0
- package/dist/cli/skills.js +170 -0
- package/dist/cli/tutors.d.ts +84 -0
- package/dist/cli/tutors.js +274 -0
- package/dist/cli/workspace.d.ts +9 -1
- package/dist/cli/workspace.js +25 -4
- package/dist/lib/agent-file.d.ts +1 -1
- package/dist/lib/board.d.ts +15 -0
- package/dist/lib/board.js +107 -0
- package/dist/lib/context-pack.js +10 -1
- package/dist/lib/conversation.d.ts +43 -6
- package/dist/lib/conversation.js +53 -9
- package/dist/lib/index.d.ts +4 -0
- package/dist/lib/index.js +4 -0
- package/dist/lib/kid-board.d.ts +148 -0
- package/dist/lib/kid-board.js +294 -0
- package/dist/lib/kid-view.d.ts +44 -7
- package/dist/lib/kid-view.js +63 -8
- package/dist/lib/ledger.d.ts +8 -0
- package/dist/lib/ledger.js +8 -0
- package/dist/lib/plan.d.ts +2 -0
- package/dist/lib/plan.js +9 -0
- package/dist/lib/run-plan.d.ts +30 -0
- package/dist/lib/run-plan.js +32 -0
- package/dist/lib/stream.d.ts +15 -0
- package/dist/lib/stream.js +69 -0
- package/dist/lib/timetable.d.ts +22 -0
- package/dist/lib/timetable.js +106 -0
- package/dist/schema/board.d.ts +59 -0
- package/dist/schema/board.js +30 -0
- package/dist/schema/config.d.ts +76 -18
- package/dist/schema/config.js +67 -37
- package/dist/schema/context-pack.d.ts +7 -0
- package/dist/schema/context-pack.js +6 -0
- package/dist/schema/conversation.d.ts +114 -4
- package/dist/schema/conversation.js +29 -5
- package/dist/schema/index.d.ts +6 -2
- package/dist/schema/index.js +6 -2
- package/dist/schema/json-schema.d.ts +2 -0
- package/dist/schema/json-schema.js +23 -0
- package/dist/schema/ledger.d.ts +1 -1
- package/dist/schema/timetable.d.ts +26 -0
- package/dist/schema/timetable.js +18 -0
- package/dist/server/app.d.ts +67 -6
- package/dist/server/app.js +399 -38
- package/dist/server/kid-page.d.ts +1 -0
- package/dist/server/kid-page.js +768 -0
- package/dist/server/mock.d.ts +58 -0
- package/dist/server/mock.js +403 -0
- package/dist/server/parent-page.d.ts +8 -0
- package/dist/server/parent-page.js +393 -0
- package/dist/server/runner.d.ts +64 -0
- package/dist/server/runner.js +277 -0
- package/dist/server/scene-props.d.ts +16 -0
- package/dist/server/scene-props.js +39 -0
- package/dist/server/stage.d.ts +15 -0
- package/dist/server/stage.js +69 -0
- package/dist/server/store.d.ts +43 -0
- package/dist/server/store.js +210 -0
- package/dist/server/tts.d.ts +51 -0
- package/dist/server/tts.js +99 -0
- package/dist/stage/Assistant-Bold-ZDZZ6JHA.woff2 +0 -0
- package/dist/stage/Assistant-Medium-DZ25RZU3.woff2 +0 -0
- package/dist/stage/Assistant-Regular-PLF2XOGW.woff2 +0 -0
- package/dist/stage/Assistant-SemiBold-CZ5MX6FK.woff2 +0 -0
- package/dist/stage/index.html +10 -0
- package/dist/stage/stage.css +1 -0
- package/dist/stage/stage.js +4383 -0
- package/package.json +13 -4
- package/agents/chinese-teacher.md +0 -19
- package/agents/math-teacher.md +0 -19
- package/agents/reading-teacher.md +0 -19
package/dist/cli/doctor.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* cotutor doctor:环境 +
|
|
2
|
+
* cotutor doctor:环境 + workspace逐项体检,失败项附修复命令(错误信息即修复指南)。
|
|
3
3
|
* 静默失败摆到明面:配置坏了、老师链断了、账本有坏行、角色指向踩空。
|
|
4
4
|
* exit 约定同 drawtell / voxtell doctor:必需项全过 exit 0,否则 1;--json 带 ok 与整份 checks。
|
|
5
5
|
*/
|
|
@@ -9,7 +9,10 @@ import { basename, join } from 'node:path';
|
|
|
9
9
|
import { promisify } from 'node:util';
|
|
10
10
|
import { parseAgentFile } from "../lib/agent-file.js";
|
|
11
11
|
import { parseArtifactEvents, parseObservations } from "../lib/ledger.js";
|
|
12
|
+
import { parseTimetable } from "../lib/timetable.js";
|
|
13
|
+
import { httpsFiles } from "./serve.js";
|
|
12
14
|
import { DIRS } from "./skeleton.js";
|
|
15
|
+
import { tutorStatuses } from "./tutors.js";
|
|
13
16
|
import { CONFIG_FILE, ConfigError, USER_CONFIG, UsageError, assembleWorkspace, expandPath, parseConfig, readJson, redactHome, resolveRoot, } from "./workspace.js";
|
|
14
17
|
const execFileP = promisify(execFile);
|
|
15
18
|
async function statOrNull(p) {
|
|
@@ -23,17 +26,113 @@ async function evictedCount(dir) {
|
|
|
23
26
|
return 0;
|
|
24
27
|
}
|
|
25
28
|
}
|
|
26
|
-
/**
|
|
27
|
-
export function
|
|
29
|
+
/** 运行时的 CLI 名(run[0] 的 basename):决定链在 .claude 还是 .qwen 下 */
|
|
30
|
+
export function runtimeCli(run) {
|
|
28
31
|
return basename(run[0] ?? '');
|
|
29
32
|
}
|
|
33
|
+
/** 已知的环境坑,从 CLI 的输出里认出来给修复命令(错误信息即修复指南) */
|
|
34
|
+
export function explainLlmFailure(text, runtime) {
|
|
35
|
+
const bin = runtimeCli(runtime);
|
|
36
|
+
if (/does not support this model|version [\d.]+ or newer is required/i.test(text)) {
|
|
37
|
+
return `这版 ${bin} 不认全局 settings 里的模型:给 cotutor.json 的 ${bin} 运行时 run / resume 末尾加 "--model", "sonnet"(或 ${bin} update 升级)`;
|
|
38
|
+
}
|
|
39
|
+
if (/nested|CLAUDECODE|already running inside/i.test(text))
|
|
40
|
+
return '在 Claude Code 会话里嵌套起 claude 被拒:换个普通终端,或 unset CLAUDECODE 及 CLAUDE_CODE_* 后再跑';
|
|
41
|
+
if (/not logged in|login|authentication|401|unauthorized/i.test(text))
|
|
42
|
+
return `${bin} 没登录或 key 失效:先在终端跑一次 ${bin} 登录`;
|
|
43
|
+
if (/ENOENT|command not found/i.test(text))
|
|
44
|
+
return `PATH 里没有 ${bin}`;
|
|
45
|
+
if (/budget|max_budget/i.test(text))
|
|
46
|
+
return '预算旗太小:调大运行时模板里的 --max-budget-usd';
|
|
47
|
+
return `看上面的原文;不认识的错先在终端手跑一次同样的命令`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* --live:真起一次老师(运行时的 run 模板,第一位开着的老师,只回一个字),把 API 层的坑(模型不认、没登录)摆到明面。
|
|
51
|
+
* 花一分钱级别的费用,所以缺省不跑。配音同理:老师配了 voice 就合成一句。
|
|
52
|
+
*/
|
|
53
|
+
async function probeLive(ws, push, env) {
|
|
54
|
+
const { spawn } = await import('node:child_process');
|
|
55
|
+
const { fillRuntime, fillTts, listTutors } = await import("../schema/index.js");
|
|
56
|
+
const { parseTranscript } = await import("../lib/transcript.js");
|
|
57
|
+
const { parseAgentFile } = await import("../lib/agent-file.js");
|
|
58
|
+
const { tmpdir } = await import('node:os');
|
|
59
|
+
const { mkdtemp, rm } = await import('node:fs/promises');
|
|
60
|
+
const tutors = listTutors(ws.config).filter((t) => t.enabled);
|
|
61
|
+
const first = tutors[0];
|
|
62
|
+
if (!first) {
|
|
63
|
+
push({ name: 'live.agent', ok: false, required: false, detail: '没有开着的老师,没法探', fix: 'cotutor.json 里至少开一位' });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const runtimeName = ws.config.runtimes.default;
|
|
67
|
+
const runtime = ws.config.runtimes[runtimeName];
|
|
68
|
+
if (!runtime || typeof runtime === 'string')
|
|
69
|
+
return;
|
|
70
|
+
let agentBody;
|
|
71
|
+
try {
|
|
72
|
+
agentBody = parseAgentFile(await readFile(join(ws.dirs.claudeAgents, `${first.name}.md`), 'utf8')).body;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
/* 上面 tutor.* 已报 */
|
|
76
|
+
}
|
|
77
|
+
const argv = fillRuntime(runtime.run, { agent: first.name, prompt: '只回一个字:好', agentBody });
|
|
78
|
+
const cwd = join(ws.dirs.agents, first.name);
|
|
79
|
+
const run = await new Promise((resolveRun) => {
|
|
80
|
+
let out = '';
|
|
81
|
+
let err = '';
|
|
82
|
+
const child = spawn(argv[0], argv.slice(1), { cwd, env: { ...env, COTUTOR_WORKSPACE: ws.root }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
83
|
+
const timer = setTimeout(() => child.kill(), 120_000);
|
|
84
|
+
child.stdout.on('data', (d) => (out += d.toString()));
|
|
85
|
+
child.stderr.on('data', (d) => (err += d.toString()));
|
|
86
|
+
child.once('error', (e) => {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
resolveRun({ out, err, code: null, spawnErr: e.message });
|
|
89
|
+
});
|
|
90
|
+
child.once('close', (code) => {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
resolveRun({ out, err, code });
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
const t = parseTranscript(run.out);
|
|
96
|
+
const said = t.items.filter((i) => i.kind === 'text' && !i.sub).map((i) => i.text).join(' ');
|
|
97
|
+
const ok = Boolean(t.final?.ok) && !run.spawnErr;
|
|
98
|
+
const raw = run.spawnErr ?? (t.final ? `${t.final.reason ?? ''} ${said}`.trim() : `没有 result 事件(exit ${run.code}):${run.err.trim().split('\n').slice(-3).join(' / ')}`);
|
|
99
|
+
push({
|
|
100
|
+
name: `live.${runtimeName}`,
|
|
101
|
+
ok,
|
|
102
|
+
required: false,
|
|
103
|
+
detail: ok ? `${first.display} 回了「${(t.final?.text ?? '').slice(0, 20)}」${t.final?.costUsd !== undefined ? ` · $${t.final.costUsd.toFixed(3)}` : ''}` : `起不来或没答上:${raw.slice(0, 300)}`,
|
|
104
|
+
fix: ok ? undefined : explainLlmFailure(raw, runtime.run),
|
|
105
|
+
});
|
|
106
|
+
const voiced = tutors.find((x) => x.voice);
|
|
107
|
+
if (!voiced) {
|
|
108
|
+
push({ name: 'live.tts', ok: true, required: false, detail: '没有老师配 voice,不探配音(孩子端用浏览器的声)' });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const dir = await mkdtemp(join(tmpdir(), 'cotutor-doctor-'));
|
|
112
|
+
const out = join(dir, 'probe.mp3');
|
|
113
|
+
const argvT = fillTts(ws.config.tts.say, { text: '你好', voice: voiced.voice, out });
|
|
114
|
+
const r = await new Promise((resolveTts) => {
|
|
115
|
+
execFile(argvT[0], argvT.slice(1), { env, timeout: 60_000 }, async (e, _o, se) => {
|
|
116
|
+
if (e)
|
|
117
|
+
return resolveTts({ ok: false, msg: e.code === 'ENOENT' ? `PATH 里没有 ${argvT[0]}` : `${e.message} ${String(se).trim().slice(-200)}` });
|
|
118
|
+
const st = await stat(out).catch(() => null);
|
|
119
|
+
resolveTts(st?.isFile() && st.size > 0 ? { ok: true, msg: `${voiced.display} 的音色 ${voiced.voice} 能合成(${st.size} 字节)` } : { ok: false, msg: '命令跑完但没出文件' });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
await rm(dir, { recursive: true, force: true });
|
|
123
|
+
push({ name: 'live.tts', ok: r.ok, required: false, detail: r.msg, fix: r.ok ? undefined : `改 cotutor.json 的 tts.say(voxtell 不在 PATH 就写完整路径),或查 voxtell doctor` });
|
|
124
|
+
}
|
|
30
125
|
export async function doctorWorkspace(override, opts = {}) {
|
|
31
126
|
const probeEnv = opts.probeEnv ?? true;
|
|
127
|
+
const env = opts.env ?? process.env;
|
|
32
128
|
const checks = [];
|
|
33
129
|
const push = (c) => checks.push(c);
|
|
34
130
|
const [maj, min] = process.versions.node.split('.').map(Number);
|
|
35
131
|
const nodeOk = maj > 22 || (maj === 22 && min >= 18);
|
|
36
132
|
push({ name: 'node', ok: nodeOk, required: true, detail: `node ${process.versions.node}`, fix: nodeOk ? undefined : '需要 Node ≥ 22.18' });
|
|
133
|
+
if (env.CLAUDECODE) {
|
|
134
|
+
push({ name: 'env.nested', ok: false, required: false, detail: '现在在 Claude Code 会话里(CLAUDECODE 已设),从这里起的 claude 老师会被当嵌套拒掉', fix: '换个普通终端跑 cotutor serve;或 unset CLAUDECODE CLAUDE_CODE_ENTRYPOINT CLAUDE_CODE_SESSION_ID CLAUDE_CODE_CHILD_SESSION CLAUDE_PID CLAUDE_EFFORT' });
|
|
135
|
+
}
|
|
37
136
|
let root = null;
|
|
38
137
|
let source = null;
|
|
39
138
|
try {
|
|
@@ -67,7 +166,7 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
67
166
|
name: CONFIG_FILE,
|
|
68
167
|
ok: true,
|
|
69
168
|
required: true,
|
|
70
|
-
detail: `kid ${config.kid.slug}、${Object.keys(config.
|
|
169
|
+
detail: `kid ${config.kid.slug}、${Object.keys(config.tutors).length} 位老师、运行时 ${config.runtimes.default}、端口 ${config.server.port}`,
|
|
71
170
|
});
|
|
72
171
|
}
|
|
73
172
|
}
|
|
@@ -97,9 +196,11 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
97
196
|
fix: rulesMissing.length ? 'cotutor init 补上(已有的不动)' : undefined,
|
|
98
197
|
});
|
|
99
198
|
if (ws) {
|
|
100
|
-
// ----
|
|
101
|
-
const defaultCli =
|
|
102
|
-
|
|
199
|
+
// ---- 老师:定义文件(拷贝)+ 名字一致 + 出厂 / 自定义状态 + 老师目录 ----
|
|
200
|
+
const defaultCli = runtimeCli(ws.config.runtimes[ws.config.runtimes.default]?.run ?? []);
|
|
201
|
+
const statuses = new Map((await tutorStatuses(root)).map((s) => [s.name, s]));
|
|
202
|
+
for (const name of Object.keys(ws.config.tutors)) {
|
|
203
|
+
const shipped = statuses.has(name);
|
|
103
204
|
for (const [cli, dir] of [
|
|
104
205
|
['claude', ws.dirs.claudeAgents],
|
|
105
206
|
['qwen', ws.dirs.qwenAgents],
|
|
@@ -117,16 +218,53 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
117
218
|
detail = `${redactHome(file)} 读不到(链断了或没建)`;
|
|
118
219
|
}
|
|
119
220
|
push({
|
|
120
|
-
name: `
|
|
221
|
+
name: `tutor.${name}.${cli}`,
|
|
121
222
|
ok,
|
|
122
223
|
required,
|
|
123
224
|
detail,
|
|
124
|
-
fix: ok
|
|
225
|
+
fix: ok
|
|
226
|
+
? undefined
|
|
227
|
+
: shipped
|
|
228
|
+
? `cotutor init 补拷(拷自本包 agents/${name}.md),或把老师键改成文件里的 name`
|
|
229
|
+
: cli === 'qwen'
|
|
230
|
+
? 'cotutor init 补链(.qwen/agents/ 指向 .claude/agents/)'
|
|
231
|
+
: `这是自家加的老师:写 .claude/agents/${name}.md(frontmatter name: ${name}),cotutor add ${name} --display <显示名> 可出模板;或把 cotutor.json 里这条删掉`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
const st = statuses.get(name);
|
|
235
|
+
if (!st)
|
|
236
|
+
push({ name: `tutor.${name}.origin`, ok: true, required: false, detail: '自家加的老师(不是出厂件,upgrade 不碰)' });
|
|
237
|
+
if (st) {
|
|
238
|
+
const label = { latest: '出厂件,最新', upgradable: `出厂件,基于 ${st.basedOn},包已更新`, custom: `自定义(基于 ${st.basedOn})`, untracked: '自定义(没有出厂记录)', missing: '缺', broken: '读不到' };
|
|
239
|
+
push({
|
|
240
|
+
name: `tutor.${name}.origin`,
|
|
241
|
+
ok: st.state !== 'upgradable' && !st.legacyLink,
|
|
242
|
+
required: false,
|
|
243
|
+
detail: st.legacyLink ? '还是指向包的旧链(改它会改到包里)' : label[st.state],
|
|
244
|
+
fix: st.legacyLink ? 'cotutor init 换成拷贝' : st.state === 'upgradable' ? 'cotutor upgrade 换新版' : undefined,
|
|
125
245
|
});
|
|
126
246
|
}
|
|
127
247
|
const home = join(ws.dirs.agents, name);
|
|
128
248
|
const there = (await statOrNull(home))?.isDirectory() ?? false;
|
|
129
|
-
push({ name: `
|
|
249
|
+
push({ name: `tutor.${name}.home`, ok: there, required: true, detail: there ? `agents/${name}/ 在(会话 cwd)` : `agents/${name}/ 不在`, fix: there ? undefined : 'cotutor init 补建' });
|
|
250
|
+
}
|
|
251
|
+
// ---- 板书语法表:老师讲解前读它;机器文件,init / upgrade 刷新 ----
|
|
252
|
+
{
|
|
253
|
+
const { SYNTAX_FILE } = await import("./skeleton.js");
|
|
254
|
+
const there = (await statOrNull(join(root, SYNTAX_FILE)))?.isFile() ?? false;
|
|
255
|
+
push({ name: 'board.syntax', ok: there, required: true, detail: there ? `${SYNTAX_FILE} 在(老师讲解前读的语法表)` : `${SYNTAX_FILE} 不在,老师不知道卡怎么写`, fix: there ? undefined : 'cotutor init 或 cotutor upgrade 生成' });
|
|
256
|
+
// 舞台包与 drawtell:场景卡 / 画板卡要它们;没有只是重卡打不开,轻卡与对话照常
|
|
257
|
+
const { stageBuilt } = await import("../server/stage.js");
|
|
258
|
+
const built = stageBuilt();
|
|
259
|
+
push({ name: 'stage.bundle', ok: built, required: false, detail: built ? 'dist/stage/ 在(舞台包:场景卡与画板卡的播放器)' : 'dist/stage/ 不在,场景卡与画板卡的舞台打不开(轻卡照常)', fix: built ? undefined : '仓库根 pnpm run build:stage(npm 装的包自带)' });
|
|
260
|
+
const { TOOL_SHIM, drawtellBin, skillStatuses } = await import("./skills.js");
|
|
261
|
+
const dt = drawtellBin();
|
|
262
|
+
const shim = (await statOrNull(join(root, TOOL_SHIM)))?.isFile() ?? false;
|
|
263
|
+
push({ name: 'drawtell', ok: dt !== null && shim, required: false, detail: !dt ? 'node_modules 里没有 drawtell,场景作业跑不了' : shim ? `${TOOL_SHIM} 在,指向本包的 drawtell(scene-maker 用它 check / build / dub / snap)` : `${TOOL_SHIM} 不在,scene-maker 找不到 drawtell`, fix: !dt ? '仓库根 pnpm install' : shim ? undefined : 'cotutor init 或 cotutor upgrade 生成' });
|
|
264
|
+
for (const sk of await skillStatuses(root)) {
|
|
265
|
+
const label = { latest: '出厂件,最新', upgradable: `出厂件,基于 ${sk.basedOn},包已更新`, custom: `自定义(基于 ${sk.basedOn})`, untracked: '自定义(没有出厂记录)', missing: '缺', unavailable: 'drawtell-skills 没装,没法拷' };
|
|
266
|
+
push({ name: `skill.${sk.name}`, ok: sk.state !== 'missing' && sk.state !== 'unavailable' && sk.state !== 'upgradable', required: false, detail: `.claude/skills/${sk.name}/:${label[sk.state]}`, fix: sk.state === 'missing' ? 'cotutor init 补拷' : sk.state === 'upgradable' ? 'cotutor upgrade 换新版' : sk.state === 'unavailable' ? '仓库根 pnpm install' : undefined });
|
|
267
|
+
}
|
|
130
268
|
}
|
|
131
269
|
// ---- paths 角色指向:配了就该在 ----
|
|
132
270
|
for (const [role, value] of Object.entries(ws.config.paths)) {
|
|
@@ -153,23 +291,50 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
153
291
|
push({ name: `ledger.${name}`, ok: true, required: false, detail: '没有(还没记过);cotutor init 会建空文件' });
|
|
154
292
|
}
|
|
155
293
|
}
|
|
156
|
-
// ----
|
|
294
|
+
// ---- 课程表:有就要能解析(孩子端首页与上下文包的 slot 靠它;没有不算错)----
|
|
295
|
+
try {
|
|
296
|
+
const tt = parseTimetable(await readFile(ws.paths.timetable, 'utf8'));
|
|
297
|
+
push({
|
|
298
|
+
name: 'timetable',
|
|
299
|
+
ok: tt.found && tt.errors.length === 0,
|
|
300
|
+
required: false,
|
|
301
|
+
detail: tt.found ? (tt.errors.length ? tt.errors.slice(0, 3).join(';') : `${tt.entries.length} 个时段`) : tt.errors[0],
|
|
302
|
+
fix: tt.found && !tt.errors.length ? undefined : `改 ${redactHome(ws.paths.timetable)}:表头 星期/时间/学科(孩子列可选),时间形如 19:00–19:40`,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
push({ name: 'timetable', ok: true, required: false, detail: `没有课程表(${redactHome(ws.paths.timetable)});孩子端「今天」只画老师,不画时段` });
|
|
307
|
+
}
|
|
308
|
+
// ---- HTTPS:iPad 上录音要;没有只提醒 ----
|
|
309
|
+
const tls = httpsFiles(ws);
|
|
310
|
+
push({ name: 'https', ok: tls !== null, required: false, detail: tls ? `${redactHome(tls.cert)}` : '没有证书,serve 走 HTTP(iPad Safari 上按住说话不可用)', fix: tls ? undefined : 'cotutor cert(需要 mkcert:brew install mkcert && mkcert -install)' });
|
|
311
|
+
// ---- 运行时的 CLI 在不在 ----
|
|
157
312
|
if (probeEnv) {
|
|
158
|
-
|
|
159
|
-
|
|
313
|
+
const ttsBin = runtimeCli(ws.config.tts.say);
|
|
314
|
+
try {
|
|
315
|
+
await execFileP(ttsBin, ['--version'], { timeout: 8000 });
|
|
316
|
+
push({ name: 'tts', ok: true, required: false, detail: `${ttsBin} 在;老师配了 voice 的回复会配音` });
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
push({ name: 'tts', ok: false, required: false, detail: `PATH 里没有 ${ttsBin},回复不配音(孩子端用浏览器的声)`, fix: `装 ${ttsBin},或改 cotutor.json 的 tts.say` });
|
|
320
|
+
}
|
|
321
|
+
for (const [runtime, p] of Object.entries(ws.config.runtimes)) {
|
|
322
|
+
if (runtime === 'default' || typeof p === 'string')
|
|
160
323
|
continue;
|
|
161
|
-
const bin =
|
|
162
|
-
const required =
|
|
324
|
+
const bin = runtimeCli(p.run);
|
|
325
|
+
const required = runtime === ws.config.runtimes.default;
|
|
163
326
|
try {
|
|
164
327
|
const { stdout } = await execFileP(bin, ['--version'], { timeout: 8000 });
|
|
165
|
-
push({ name: `
|
|
328
|
+
push({ name: `runtime.${runtime}`, ok: true, required, detail: `${bin} ${stdout.trim().split('\n')[0]}` });
|
|
166
329
|
}
|
|
167
330
|
catch {
|
|
168
|
-
push({ name: `
|
|
331
|
+
push({ name: `runtime.${runtime}`, ok: false, required, detail: `PATH 里没有 ${bin}`, fix: required ? `装 ${bin},或把 runtimes.default 改成装了的那个运行时` : undefined });
|
|
169
332
|
}
|
|
170
333
|
}
|
|
171
334
|
}
|
|
172
335
|
}
|
|
336
|
+
if (ws && opts.live)
|
|
337
|
+
await probeLive(ws, push, env);
|
|
173
338
|
// ---- 用户配置 ----
|
|
174
339
|
try {
|
|
175
340
|
const text = await readFile(USER_CONFIG, 'utf8');
|
|
@@ -185,6 +350,6 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
185
350
|
push({ name: 'user-config', ok: true, required: false, detail: `未配置(可选):{"workspace": "…"} 写进 ${redactHome(USER_CONFIG)};cotutor init 会代劳` });
|
|
186
351
|
}
|
|
187
352
|
const gitThere = (await statOrNull(join(root, '.git'))) !== null;
|
|
188
|
-
push({ name: 'git', ok: gitThere, required: false, detail: gitThere ? '
|
|
353
|
+
push({ name: 'git', ok: gitThere, required: false, detail: gitThere ? 'workspace是 git 仓' : 'workspace不在 git 里', fix: gitThere ? undefined : '建议 git init:老师记忆、账本、政策都是不可再生状态' });
|
|
189
354
|
return { ok: checks.filter((c) => c.required).every((c) => c.ok), root, source, checks };
|
|
190
355
|
}
|
package/dist/cli/init.d.ts
CHANGED
package/dist/cli/init.js
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* cotutor init <slug>:建 ~/cotutor/<slug>/ 骨架。语义是**幂等补缺**——已有的文件与目录一律不动,缺什么补什么。
|
|
3
3
|
* 三条边界(沿 drawtell init):政策文件只在缺失时写模板;用户配置只在还没指定 workspace 时补;不动 git。
|
|
4
|
-
*
|
|
4
|
+
* 老师文件**拷贝**进 .claude/agents/(2026-09-09 拍板,原来是链):拷进来就是家长的,想改就改;出厂 hash 记 .cotutor/shipped.json,
|
|
5
|
+
* cotutor upgrade 据此换新或报 diff。旧workspace里指向包的链会被换成拷贝。.qwen/agents/ 是指向 .claude/agents/ 的相对链。
|
|
5
6
|
*/
|
|
6
|
-
import { mkdir, readFile, stat,
|
|
7
|
-
import {
|
|
8
|
-
import { DIRS, GITIGNORE, LEDGER_FILES, RULES, configTemplate, shippedAgents } from "./skeleton.js";
|
|
9
|
-
import {
|
|
7
|
+
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { dirname, join, resolve } from 'node:path';
|
|
9
|
+
import { DIRS, GITIGNORE, LEDGER_FILES, RULES, SYNTAX_FILE, configTemplate, shippedAgents, writeSchemaFile, writeSyntaxFile } from "./skeleton.js";
|
|
10
|
+
import { installTutors } from "./tutors.js";
|
|
11
|
+
import { TOOL_SHIM, installSkills, writeToolShim } from "./skills.js";
|
|
12
|
+
import { CONFIG_FILE, ConfigError, HOME_ROOT, USER_CONFIG, expandPath, parseConfig, readJson } from "./workspace.js";
|
|
10
13
|
async function exists(p) {
|
|
11
14
|
return (await stat(p).catch(() => null)) !== null;
|
|
12
15
|
}
|
|
13
|
-
async function lexists(p) {
|
|
14
|
-
const { lstat } = await import('node:fs/promises');
|
|
15
|
-
return (await lstat(p).catch(() => null)) !== null;
|
|
16
|
-
}
|
|
17
16
|
export async function initWorkspace(opts) {
|
|
18
17
|
const root = opts.dir ? expandPath(opts.dir, resolve(process.cwd())) : join(HOME_ROOT, opts.slug);
|
|
19
18
|
const steps = [];
|
|
@@ -31,26 +30,36 @@ export async function initWorkspace(opts) {
|
|
|
31
30
|
await mkdir(p, { recursive: true });
|
|
32
31
|
steps.push({ item: `${dir}/`, action: 'created' });
|
|
33
32
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
await mkdir(home, { recursive: true });
|
|
41
|
-
await writeFile(join(home, '.gitkeep'), '');
|
|
42
|
-
steps.push({ item: `agents/${a.name}/`, action: 'created' });
|
|
43
|
-
}
|
|
44
|
-
for (const sub of ['.claude/agents', '.qwen/agents']) {
|
|
45
|
-
const link = join(root, sub, basename(a.file));
|
|
46
|
-
if (await lexists(link)) {
|
|
47
|
-
steps.push({ item: `${sub}/${basename(a.file)}`, action: 'exists' });
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
await symlink(a.file, link);
|
|
51
|
-
steps.push({ item: `${sub}/${basename(a.file)}`, action: 'created', note: `→ ${a.file}` });
|
|
52
|
-
}
|
|
33
|
+
const config = join(root, CONFIG_FILE);
|
|
34
|
+
if (await exists(config))
|
|
35
|
+
steps.push({ item: CONFIG_FILE, action: 'kept', note: '政策文件不覆盖(家长的决定)' });
|
|
36
|
+
else {
|
|
37
|
+
await writeFile(config, configTemplate({ slug: opts.slug, name: opts.name, port: opts.port, tutors: agents }));
|
|
38
|
+
steps.push({ item: CONFIG_FILE, action: 'created' });
|
|
53
39
|
}
|
|
40
|
+
// 老师按 cotutor.json 里有谁走(家长自己加的也补目录与链);配置坏了就只管出厂的,doctor 去报
|
|
41
|
+
let tutorNames = [];
|
|
42
|
+
try {
|
|
43
|
+
const raw = readJson(config);
|
|
44
|
+
if (raw !== null)
|
|
45
|
+
tutorNames = Object.keys(parseConfig(raw, config).tutors);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (!(err instanceof ConfigError))
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
steps.push(...(await installTutors(root, tutorNames)));
|
|
52
|
+
const schemaThere = await exists(join(root, '.cotutor', 'cotutor.schema.json'));
|
|
53
|
+
await writeSchemaFile(root);
|
|
54
|
+
steps.push({ item: '.cotutor/cotutor.schema.json', action: schemaThere ? 'exists' : 'created', note: schemaThere ? '已按本包刷新(机器文件)' : 'cotutor.json 的 JSON Schema,编辑器补全用' });
|
|
55
|
+
const syntaxThere = await exists(join(root, SYNTAX_FILE));
|
|
56
|
+
await writeSyntaxFile(root);
|
|
57
|
+
steps.push({ item: SYNTAX_FILE, action: syntaxThere ? 'exists' : 'created', note: syntaxThere ? '已按本包刷新(机器文件)' : '给老师看的板书语法表,从卡的注册表生成' });
|
|
58
|
+
// 出厂 skill(scene-maker 的四个领域 skill)与 drawtell 壳脚本
|
|
59
|
+
steps.push(...(await installSkills(root)));
|
|
60
|
+
const shimThere = await exists(join(root, TOOL_SHIM));
|
|
61
|
+
const shim = await writeToolShim(root);
|
|
62
|
+
steps.push({ item: TOOL_SHIM, action: shimThere ? 'exists' : 'created', note: shim.available ? (shimThere ? '已按本包刷新(机器文件)' : 'drawtell CLI 的壳,scene-maker 用 ../../.cotutor/drawtell 跑它') : 'node_modules 里没有 drawtell,壳只会报错' });
|
|
54
63
|
for (const f of LEDGER_FILES) {
|
|
55
64
|
const p = join(root, f);
|
|
56
65
|
if (await exists(p))
|
|
@@ -60,13 +69,6 @@ export async function initWorkspace(opts) {
|
|
|
60
69
|
steps.push({ item: f, action: 'created' });
|
|
61
70
|
}
|
|
62
71
|
}
|
|
63
|
-
const config = join(root, CONFIG_FILE);
|
|
64
|
-
if (await exists(config))
|
|
65
|
-
steps.push({ item: CONFIG_FILE, action: 'kept', note: '政策文件不覆盖(家长的决定)' });
|
|
66
|
-
else {
|
|
67
|
-
await writeFile(config, configTemplate({ slug: opts.slug, name: opts.name, port: opts.port, teachers: agents }));
|
|
68
|
-
steps.push({ item: CONFIG_FILE, action: 'created' });
|
|
69
|
-
}
|
|
70
72
|
for (const f of ['CLAUDE.md', 'QWEN.md']) {
|
|
71
73
|
const p = join(root, f);
|
|
72
74
|
if (await exists(p))
|
|
@@ -97,18 +99,18 @@ export async function initWorkspace(opts) {
|
|
|
97
99
|
steps.push({ item: 'user-config', action: 'kept', note: '解析失败,未动(cotutor doctor 可体检)' });
|
|
98
100
|
else if (configured) {
|
|
99
101
|
steps.push(expandPath(configured, root) === root
|
|
100
|
-
? { item: 'user-config', action: 'exists', note: '
|
|
101
|
-
: { item: 'user-config', action: 'kept', note: '
|
|
102
|
+
? { item: 'user-config', action: 'exists', note: '已指向本workspace' }
|
|
103
|
+
: { item: 'user-config', action: 'kept', note: '已指向别的workspace,未改;多孩子时一 workspace 一进程,用 --workspace / COTUTOR_WORKSPACE 指定' });
|
|
102
104
|
}
|
|
103
105
|
else {
|
|
104
106
|
await mkdir(dirname(USER_CONFIG), { recursive: true });
|
|
105
107
|
await writeFile(USER_CONFIG, `${JSON.stringify({ ...(user ?? {}), workspace: root }, null, 2)}\n`);
|
|
106
|
-
steps.push({ item: 'user-config', action: 'created', note: 'workspace
|
|
108
|
+
steps.push({ item: 'user-config', action: 'created', note: 'workspace 指向本workspace' });
|
|
107
109
|
}
|
|
108
110
|
const suggestions = [];
|
|
109
111
|
if (!(await exists(join(root, '.git'))))
|
|
110
112
|
suggestions.push('git init(建议:老师记忆、账本、政策都是不可再生状态,git 是破坏后的兜底)');
|
|
111
|
-
suggestions.push(`改 ${CONFIG_FILE}:paths.vault 指到孩子的 Obsidian vault,
|
|
113
|
+
suggestions.push(`改 ${CONFIG_FILE}:paths.vault 指到孩子的 Obsidian vault,tutors.*.voice 填 voxtell 音色 id`);
|
|
112
114
|
suggestions.push('cotutor doctor 逐项体检;cotutor serve 起服务');
|
|
113
115
|
return { root, steps, suggestions };
|
|
114
116
|
}
|
package/dist/cli/main.js
CHANGED
|
@@ -6,14 +6,27 @@ import { readFileSync } from 'node:fs';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { doctorWorkspace } from "./doctor.js";
|
|
8
8
|
import { initWorkspace } from "./init.js";
|
|
9
|
+
import { makeCert } from "./cert.js";
|
|
10
|
+
import { addTutorFile, upgradeTutors } from "./tutors.js";
|
|
11
|
+
import { upgradeSkills, writeToolShim } from "./skills.js";
|
|
12
|
+
import { patchConfig } from "../server/store.js";
|
|
13
|
+
import { resolveRoot } from "./workspace.js";
|
|
9
14
|
import { serveWorkspace } from "./serve.js";
|
|
10
|
-
import {
|
|
15
|
+
import { serveMock } from "../server/mock.js";
|
|
16
|
+
import { UsageError, loadWorkspace, redactDeep, redactHome, workspaceReport } from "./workspace.js";
|
|
17
|
+
import { createContext } from "../server/app.js";
|
|
18
|
+
import { MESSAGE_FROM } from "../schema/index.js";
|
|
11
19
|
const USAGE = `用法:
|
|
12
20
|
cotutor init <slug> [--dir <path>] [--name <孩子名>] [--port <n>] 建 ~/cotutor/<slug>/ 骨架(幂等补缺)
|
|
13
|
-
cotutor doctor [--workspace <dir>] [--json]
|
|
14
|
-
cotutor
|
|
21
|
+
cotutor doctor [--workspace <dir>] [--json] [--live] 逐项体检;--live 真起一次老师与配音(花一分钱)把 API 层的坑摆出来
|
|
22
|
+
cotutor upgrade [--workspace <dir>] [--force <老师>]... 老师文件换新版:没改过的直接换,改过的只报 diff(--force 才覆盖,原文留 .bak)
|
|
23
|
+
cotutor add <老师名> --display <显示名> [--subject <学科>] [--avatar <emoji>] [--hidden] 加一位自家的老师:出模板文件、进 cotutor.json、建目录
|
|
24
|
+
cotutor serve [--workspace <dir>] [--port <n>] [--http] 起服务(一 workspace 一进程;~/.config/cotutor/certs/ 有证书就走 HTTPS)
|
|
25
|
+
cotutor cert [--host <名或IP>]... 用 mkcert 建这台机器的自签证书到 ~/.config/cotutor/certs/(iPad 上录音要 HTTPS;所有 workspace 共用)
|
|
26
|
+
cotutor send <老师> <消息> [--from parent|kid|system] [--runtime <名>] 终端里发一条,等老师说完打印结果(与页面同一条路)
|
|
27
|
+
cotutor mock [--port <n>] [--scenario normal|limit|offline] [--delay <ms>] [--http] 不经真实老师与配音,用固定的板书 JSON 起孩子端,测前端交互与渲染(不需要 workspace)
|
|
15
28
|
cotutor --version | --help
|
|
16
|
-
|
|
29
|
+
workspace解析:--workspace > COTUTOR_WORKSPACE > cwd 或祖先有 cotutor.json > ~/.config/cotutor/config.json > ~/cotutor/ 下唯一的孩子目录
|
|
17
30
|
`;
|
|
18
31
|
function version() {
|
|
19
32
|
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8'));
|
|
@@ -47,7 +60,7 @@ function parseArgs(argv, valued) {
|
|
|
47
60
|
export async function main(argv) {
|
|
48
61
|
let json = false;
|
|
49
62
|
try {
|
|
50
|
-
const { cmd, positionals, flags } = parseArgs(argv, ['workspace', 'dir', 'name', 'port']);
|
|
63
|
+
const { cmd, positionals, flags } = parseArgs(argv, ['workspace', 'dir', 'name', 'port', 'from', 'runtime', 'force', 'display', 'subject', 'avatar', 'description', 'scenario', 'delay']);
|
|
51
64
|
json = flags.json === true;
|
|
52
65
|
const workspace = typeof flags.workspace === 'string' ? flags.workspace : undefined;
|
|
53
66
|
// --version / --help 是旗标不是命令,parseArgs 把它们收进 flags,cmd 拿不到,所以在 switch 前处理
|
|
@@ -81,7 +94,7 @@ export async function main(argv) {
|
|
|
81
94
|
return;
|
|
82
95
|
}
|
|
83
96
|
case 'doctor': {
|
|
84
|
-
const r = await doctorWorkspace(workspace);
|
|
97
|
+
const r = await doctorWorkspace(workspace, { live: flags.live === true });
|
|
85
98
|
if (json)
|
|
86
99
|
process.stdout.write(`${JSON.stringify(redactDeep(r), null, 2)}\n`);
|
|
87
100
|
else {
|
|
@@ -98,11 +111,118 @@ export async function main(argv) {
|
|
|
98
111
|
}
|
|
99
112
|
case 'serve': {
|
|
100
113
|
const port = typeof flags.port === 'string' ? Number(flags.port) : undefined;
|
|
101
|
-
const r = await serveWorkspace({ workspace, port });
|
|
114
|
+
const r = await serveWorkspace({ workspace, port, http: flags.http === true });
|
|
102
115
|
process.stdout.write(`cotutor serve ${JSON.stringify(workspaceReport(r.ws))}\n`);
|
|
103
116
|
for (const w of r.warnings)
|
|
104
117
|
process.stdout.write(` ! ${w}\n`);
|
|
105
|
-
|
|
118
|
+
if (!r.https)
|
|
119
|
+
process.stdout.write(' ! HTTP:iPad Safari 上按住说话要 HTTPS;cotutor cert 建证书后重启即走 HTTPS\n');
|
|
120
|
+
for (const u of r.urls)
|
|
121
|
+
process.stdout.write(` ${u}\n`);
|
|
122
|
+
process.stdout.write(` 孩子端 /,家长端 /parent\n`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
case 'mock': {
|
|
126
|
+
const port = typeof flags.port === 'string' ? Number(flags.port) : undefined;
|
|
127
|
+
if (port !== undefined && !(Number.isInteger(port) && port > 0 && port < 65536))
|
|
128
|
+
throw new UsageError('--port 要是 1–65535 的整数');
|
|
129
|
+
const scenario = typeof flags.scenario === 'string' ? flags.scenario : 'normal';
|
|
130
|
+
if (!['normal', 'limit', 'offline'].includes(scenario))
|
|
131
|
+
throw new UsageError('--scenario 只能是 normal / limit / offline');
|
|
132
|
+
const delayMs = typeof flags.delay === 'string' ? Number(flags.delay) : undefined;
|
|
133
|
+
if (delayMs !== undefined && !(Number.isInteger(delayMs) && delayMs >= 0))
|
|
134
|
+
throw new UsageError('--delay 要是非负整数(毫秒)');
|
|
135
|
+
const r = await serveMock({ port, scenario: scenario, delayMs, http: flags.http === true });
|
|
136
|
+
process.stdout.write(`cotutor mock 场景 ${scenario}(不经真实老师与配音;配音退回浏览器合成声)\n`);
|
|
137
|
+
if (!r.https)
|
|
138
|
+
process.stdout.write(' ! HTTP:iPad Safari 上按住说话要 HTTPS;cotutor cert 建证书后重启即走 HTTPS\n');
|
|
139
|
+
for (const u of r.urls)
|
|
140
|
+
process.stdout.write(` ${u}\n`);
|
|
141
|
+
process.stdout.write(' 孩子端 /;直接开某位老师并停在某句:/?tutor=chinese-tutor&step=0.3\n');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
case 'add': {
|
|
145
|
+
const name = positionals[0];
|
|
146
|
+
const display = typeof flags.display === 'string' ? flags.display : undefined;
|
|
147
|
+
if (!name || !display)
|
|
148
|
+
throw new UsageError(`add 需要老师名和 --display 显示名,如 cotutor add science-tutor --display 科学老师 --subject 科学 --avatar 🔬。\n${USAGE}`);
|
|
149
|
+
const ws = loadWorkspace(workspace);
|
|
150
|
+
if (ws.config.tutors[name])
|
|
151
|
+
throw new UsageError(`cotutor.json 里已经有 ${name} 了;要改人设去老师团页或直接改文件`);
|
|
152
|
+
const subject = typeof flags.subject === 'string' ? flags.subject : undefined;
|
|
153
|
+
const r = await addTutorFile(ws.root, { name, display, subject, description: typeof flags.description === 'string' ? flags.description : undefined });
|
|
154
|
+
await patchConfig(ws, { tutors: { [name]: { display, ...(subject ? { subject } : {}), ...(typeof flags.avatar === 'string' ? { avatar: flags.avatar } : {}), enabled: true, ...(flags.hidden === true ? { hidden: true } : {}) } } });
|
|
155
|
+
if (json)
|
|
156
|
+
process.stdout.write(`${JSON.stringify(redactDeep(r), null, 2)}\n`);
|
|
157
|
+
else {
|
|
158
|
+
process.stdout.write(`加了 ${display}(${name}):\n ${redactHome(r.file)} ← 老师文件,打开把括号里那句换成这位老师的性子\n cotutor.json tutors.${name} ← 人设与政策(老师团页也能改)\n ${redactHome(r.home)}/ ← 它的家\n`);
|
|
159
|
+
process.stdout.write('服务不用重启;孩子端和家长端刷新就有。\n');
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
case 'upgrade': {
|
|
164
|
+
const { root } = resolveRoot(workspace);
|
|
165
|
+
const force = typeof flags.force === 'string' ? [flags.force, ...positionals] : positionals;
|
|
166
|
+
const steps = await upgradeTutors(root, { force });
|
|
167
|
+
const skillSteps = await upgradeSkills(root);
|
|
168
|
+
await writeToolShim(root);
|
|
169
|
+
if (json)
|
|
170
|
+
process.stdout.write(`${JSON.stringify(redactDeep({ root, steps, skills: skillSteps }), null, 2)}\n`);
|
|
171
|
+
else {
|
|
172
|
+
const word = { upgraded: '已换新', latest: '已是最新', 'kept-custom': '自定义,保留', forced: '已覆盖(原文 .bak)', installed: '补上了', unavailable: 'drawtell-skills 没装,没法换' };
|
|
173
|
+
for (const s of skillSteps)
|
|
174
|
+
process.stdout.write(`${s.action === 'kept-custom' || s.action === 'unavailable' ? '!' : '✓'} skill ${s.name.padEnd(18)} ${word[s.action]}${s.basedOn && s.action !== 'latest' ? `(基于 ${s.basedOn})` : ''}\n`);
|
|
175
|
+
for (const s of steps) {
|
|
176
|
+
process.stdout.write(`${s.action === 'kept-custom' ? '!' : '✓'} ${s.name.padEnd(18)} ${word[s.action]}${s.basedOn && s.action !== 'latest' ? `(基于 ${s.basedOn})` : ''}\n`);
|
|
177
|
+
if (s.diff?.length) {
|
|
178
|
+
process.stdout.write(` 你的 vs 新版(- 你的 / + 新版),想用新版:cotutor upgrade --force ${s.name}\n`);
|
|
179
|
+
for (const l of s.diff.slice(0, 40))
|
|
180
|
+
process.stdout.write(` ${l}\n`);
|
|
181
|
+
if (s.diff.length > 40)
|
|
182
|
+
process.stdout.write(` …还有 ${s.diff.length - 40} 行\n`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
case 'cert': {
|
|
189
|
+
const r = await makeCert(positionals.concat(typeof flags.host === 'string' ? [flags.host] : []));
|
|
190
|
+
if (json)
|
|
191
|
+
process.stdout.write(`${JSON.stringify(redactDeep(r), null, 2)}\n`);
|
|
192
|
+
else {
|
|
193
|
+
process.stdout.write(`证书:${redactHome(r.cert)}\n私钥:${redactHome(r.key)}\n主机:${r.hosts.join(' ')}\n`);
|
|
194
|
+
process.stdout.write(`iPad 要先信任这台机器的根证书:把 ${redactHome(r.caRoot)}/rootCA.pem 隔空投送到 iPad → 设置里安装描述文件 → 通用 › 关于本机 › 证书信任设置里打开;然后重启 cotutor serve,用 https 打开。\n`);
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
case 'send': {
|
|
199
|
+
const [tutor, ...words] = positionals;
|
|
200
|
+
const text = words.join(' ');
|
|
201
|
+
if (!tutor || !text)
|
|
202
|
+
throw new UsageError(`send 需要老师名和消息,如 cotutor send math-tutor "这一步为什么要借位"。\n${USAGE}`);
|
|
203
|
+
const from = typeof flags.from === 'string' ? flags.from : 'parent';
|
|
204
|
+
if (!MESSAGE_FROM.includes(from))
|
|
205
|
+
throw new UsageError(`--from 只能是 ${MESSAGE_FROM.join(' / ')}`);
|
|
206
|
+
const ctx = createContext(loadWorkspace(workspace));
|
|
207
|
+
const started = await ctx.runner.send(tutor, { from: from, text, runtime: typeof flags.runtime === 'string' ? flags.runtime : undefined });
|
|
208
|
+
if (!json)
|
|
209
|
+
process.stdout.write(`→ ${tutor} ${started.date} ${started.job}(${started.plan.runtime}${started.plan.resume ? ',resume ' + started.plan.session : ',新会话'})…\n`);
|
|
210
|
+
const index = await started.done;
|
|
211
|
+
const m = index.messages.find((x) => x.job === started.job);
|
|
212
|
+
if (json)
|
|
213
|
+
process.stdout.write(`${JSON.stringify({ tutor, date: started.date, job: started.job, runtime: started.plan.runtime, resume: started.plan.resume, message: m, session: index.session, costUsd: index.costUsd }, null, 2)}\n`);
|
|
214
|
+
else if (!m || m.result !== 'ok') {
|
|
215
|
+
process.stdout.write(`本轮出错:${m?.error ?? '未知'};看 conversations/${tutor}/${started.date}.${started.job}.err.log\n`);
|
|
216
|
+
process.exitCode = 1;
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
process.stdout.write(`孩子看到:${m.kidText ?? '(没有给孩子的话)'}\n`);
|
|
220
|
+
if (m.holdup)
|
|
221
|
+
process.stdout.write(`待裁量:${m.holdup.question}${m.holdup.options.length ? ' → ' + m.holdup.options.map((o) => o.label).join(' / ') : ''}\n`);
|
|
222
|
+
if (m.handoff)
|
|
223
|
+
process.stdout.write(`转交:${m.handoff.to}${m.handoff.why ? ' — ' + m.handoff.why : ''}\n`);
|
|
224
|
+
process.stdout.write(`会话 ${index.session?.id ?? '?'} · 本轮 $${(m.costUsd ?? 0).toFixed(2)} · 今日 $${index.costUsd.toFixed(2)}\n`);
|
|
225
|
+
}
|
|
106
226
|
return;
|
|
107
227
|
}
|
|
108
228
|
default:
|
package/dist/cli/serve.d.ts
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* cotutor serve:一 workspace 一进程(2026-09-08 拍板),端口读 cotutor.json(--port 覆盖)。
|
|
3
|
-
* 启动打印解析结果与体检警告(配置坏了直接不起,骨架缺失只警告)。
|
|
4
|
-
*/
|
|
5
1
|
import { type Server } from 'node:http';
|
|
2
|
+
import { type AppContext } from '../server/app.ts';
|
|
6
3
|
import { type ResolveOptions, type Workspace } from './workspace.ts';
|
|
7
4
|
export interface ServeOptions extends ResolveOptions {
|
|
8
5
|
workspace?: string;
|
|
9
6
|
port?: number;
|
|
10
7
|
host?: string;
|
|
8
|
+
/** 强制走 HTTP(哪怕证书在) */
|
|
9
|
+
http?: boolean;
|
|
11
10
|
}
|
|
12
11
|
export interface ServeResult {
|
|
13
12
|
server: Server;
|
|
13
|
+
ctx: AppContext;
|
|
14
14
|
ws: Workspace;
|
|
15
15
|
port: number;
|
|
16
|
+
https: boolean;
|
|
17
|
+
/** 本机名与局域网 IPv4 各一条 */
|
|
18
|
+
urls: string[];
|
|
19
|
+
/** 兼容:urls[0] */
|
|
16
20
|
url: string;
|
|
17
21
|
warnings: string[];
|
|
18
22
|
}
|
|
23
|
+
/** 证书路径:cotutor.json 的 server.https 优先(相对 workspace 根),否则机器级 ~/.config/cotutor/certs/;都没有 → null */
|
|
24
|
+
export declare function httpsFiles(ws: Workspace): {
|
|
25
|
+
cert: string;
|
|
26
|
+
key: string;
|
|
27
|
+
} | null;
|
|
28
|
+
/** 局域网 IPv4(iPad 用它访问;.local 名不一定解析得到) */
|
|
29
|
+
export declare function lanAddresses(): string[];
|
|
19
30
|
export declare function serveWorkspace(opts?: ServeOptions): Promise<ServeResult>;
|