cotutor 0.1.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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/agents/chinese-teacher.md +19 -0
  4. package/agents/homework-aide.md +17 -0
  5. package/agents/math-teacher.md +19 -0
  6. package/agents/planner.md +18 -0
  7. package/agents/reading-teacher.md +19 -0
  8. package/bin/cotutor.js +17 -0
  9. package/dist/cli/doctor.d.ts +19 -0
  10. package/dist/cli/doctor.js +190 -0
  11. package/dist/cli/init.d.ts +17 -0
  12. package/dist/cli/init.js +114 -0
  13. package/dist/cli/main.d.ts +1 -0
  14. package/dist/cli/main.js +123 -0
  15. package/dist/cli/serve.d.ts +19 -0
  16. package/dist/cli/serve.js +22 -0
  17. package/dist/cli/skeleton.d.ts +21 -0
  18. package/dist/cli/skeleton.js +82 -0
  19. package/dist/cli/workspace.d.ts +61 -0
  20. package/dist/cli/workspace.js +199 -0
  21. package/dist/lib/agent-file.d.ts +10 -0
  22. package/dist/lib/agent-file.js +16 -0
  23. package/dist/lib/context-pack.d.ts +12 -0
  24. package/dist/lib/context-pack.js +46 -0
  25. package/dist/lib/conversation.d.ts +28 -0
  26. package/dist/lib/conversation.js +44 -0
  27. package/dist/lib/index.d.ts +9 -0
  28. package/dist/lib/index.js +9 -0
  29. package/dist/lib/kid-view.d.ts +26 -0
  30. package/dist/lib/kid-view.js +38 -0
  31. package/dist/lib/ledger.d.ts +21 -0
  32. package/dist/lib/ledger.js +92 -0
  33. package/dist/lib/plan.d.ts +12 -0
  34. package/dist/lib/plan.js +78 -0
  35. package/dist/lib/sections.d.ts +12 -0
  36. package/dist/lib/sections.js +151 -0
  37. package/dist/lib/transcript.d.ts +36 -0
  38. package/dist/lib/transcript.js +95 -0
  39. package/dist/schema/config.d.ts +182 -0
  40. package/dist/schema/config.js +144 -0
  41. package/dist/schema/context-pack.d.ts +33 -0
  42. package/dist/schema/context-pack.js +26 -0
  43. package/dist/schema/conversation.d.ts +63 -0
  44. package/dist/schema/conversation.js +30 -0
  45. package/dist/schema/index.d.ts +17 -0
  46. package/dist/schema/index.js +17 -0
  47. package/dist/schema/issues.d.ts +6 -0
  48. package/dist/schema/issues.js +37 -0
  49. package/dist/schema/ledger.d.ts +100 -0
  50. package/dist/schema/ledger.js +44 -0
  51. package/dist/schema/plan.d.ts +26 -0
  52. package/dist/schema/plan.js +19 -0
  53. package/dist/schema/sections.d.ts +30 -0
  54. package/dist/schema/sections.js +26 -0
  55. package/dist/server/app.d.ts +13 -0
  56. package/dist/server/app.js +45 -0
  57. package/package.json +62 -0
@@ -0,0 +1,123 @@
1
+ /**
2
+ * cotutor CLI 入口。命令:init / doctor / serve;全局 --workspace <dir>、--json。
3
+ * exit:0 通过 / 1 体检不过 / 2 用法错误 / 4 内部错误。
4
+ */
5
+ import { readFileSync } from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { doctorWorkspace } from "./doctor.js";
8
+ import { initWorkspace } from "./init.js";
9
+ import { serveWorkspace } from "./serve.js";
10
+ import { UsageError, redactDeep, redactHome, workspaceReport } from "./workspace.js";
11
+ const USAGE = `用法:
12
+ cotutor init <slug> [--dir <path>] [--name <孩子名>] [--port <n>] 建 ~/cotutor/<slug>/ 骨架(幂等补缺)
13
+ cotutor doctor [--workspace <dir>] [--json] 逐项体检
14
+ cotutor serve [--workspace <dir>] [--port <n>] 起服务(一 workspace 一进程)
15
+ cotutor --version | --help
16
+ 工作区解析:--workspace > COTUTOR_WORKSPACE > cwd 或祖先有 cotutor.json > ~/.config/cotutor/config.json > ~/cotutor/ 下唯一的孩子目录
17
+ `;
18
+ function version() {
19
+ const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8'));
20
+ return pkg.version;
21
+ }
22
+ function parseArgs(argv, valued) {
23
+ const flags = {};
24
+ const positionals = [];
25
+ let cmd;
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const a = argv[i];
28
+ if (a.startsWith('--')) {
29
+ const key = a.slice(2);
30
+ if (valued.includes(key)) {
31
+ const next = argv[i + 1];
32
+ if (next === undefined || next.startsWith('--'))
33
+ throw new UsageError(`选项 --${key} 需要一个值。\n${USAGE}`);
34
+ flags[key] = next;
35
+ i++;
36
+ }
37
+ else
38
+ flags[key] = true;
39
+ }
40
+ else if (cmd === undefined)
41
+ cmd = a;
42
+ else
43
+ positionals.push(a);
44
+ }
45
+ return { cmd, positionals, flags };
46
+ }
47
+ export async function main(argv) {
48
+ let json = false;
49
+ try {
50
+ const { cmd, positionals, flags } = parseArgs(argv, ['workspace', 'dir', 'name', 'port']);
51
+ json = flags.json === true;
52
+ const workspace = typeof flags.workspace === 'string' ? flags.workspace : undefined;
53
+ // --version / --help 是旗标不是命令,parseArgs 把它们收进 flags,cmd 拿不到,所以在 switch 前处理
54
+ if (flags.version === true || cmd === '-v') {
55
+ process.stdout.write(`${version()}\n`);
56
+ return;
57
+ }
58
+ if (flags.help === true || cmd === undefined || cmd === '-h') {
59
+ process.stdout.write(USAGE);
60
+ return;
61
+ }
62
+ switch (cmd) {
63
+ case 'init': {
64
+ const slug = positionals[0];
65
+ if (!slug || !/^[a-z0-9][a-z0-9-]*$/.test(slug))
66
+ throw new UsageError(`init 需要一个 slug(小写字母数字连字符,是短名不是真名),如 cotutor init ming。\n${USAGE}`);
67
+ const port = typeof flags.port === 'string' ? Number(flags.port) : undefined;
68
+ if (port !== undefined && !(Number.isInteger(port) && port > 0 && port < 65536))
69
+ throw new UsageError('--port 要是 1–65535 的整数');
70
+ const r = await initWorkspace({ slug, dir: typeof flags.dir === 'string' ? flags.dir : undefined, name: typeof flags.name === 'string' ? flags.name : undefined, port });
71
+ if (json)
72
+ process.stdout.write(`${JSON.stringify(redactDeep(r), null, 2)}\n`);
73
+ else {
74
+ process.stdout.write(`cotutor init → ${redactHome(r.root)}\n`);
75
+ for (const s of r.steps)
76
+ process.stdout.write(` ${s.action.padEnd(7)} ${s.item}${s.note ? ` (${s.note})` : ''}\n`);
77
+ process.stdout.write('下一步:\n');
78
+ for (const s of r.suggestions)
79
+ process.stdout.write(` - ${s}\n`);
80
+ }
81
+ return;
82
+ }
83
+ case 'doctor': {
84
+ const r = await doctorWorkspace(workspace);
85
+ if (json)
86
+ process.stdout.write(`${JSON.stringify(redactDeep(r), null, 2)}\n`);
87
+ else {
88
+ for (const c of r.checks) {
89
+ process.stdout.write(`${c.ok ? '✓' : c.required ? '✗' : '!'} ${c.name.padEnd(28)} ${c.detail}\n`);
90
+ if (!c.ok && c.fix)
91
+ process.stdout.write(` 修复:${c.fix}\n`);
92
+ }
93
+ process.stdout.write(r.ok ? '体检通过\n' : '体检不过(✗ 是必需项)\n');
94
+ }
95
+ if (!r.ok)
96
+ process.exitCode = 1;
97
+ return;
98
+ }
99
+ case 'serve': {
100
+ const port = typeof flags.port === 'string' ? Number(flags.port) : undefined;
101
+ const r = await serveWorkspace({ workspace, port });
102
+ process.stdout.write(`cotutor serve ${JSON.stringify(workspaceReport(r.ws))}\n`);
103
+ for (const w of r.warnings)
104
+ process.stdout.write(` ! ${w}\n`);
105
+ process.stdout.write(` ${r.url}\n`);
106
+ return;
107
+ }
108
+ default:
109
+ throw new UsageError(`未知命令 '${cmd}'。\n${USAGE}`);
110
+ }
111
+ }
112
+ catch (err) {
113
+ if (err instanceof UsageError) {
114
+ if (json)
115
+ process.stdout.write(`${JSON.stringify({ ok: false, error: 'usage', message: err.message })}\n`);
116
+ else
117
+ process.stderr.write(`cotutor: ${err.message}\n`);
118
+ process.exitCode = 2;
119
+ return;
120
+ }
121
+ throw err;
122
+ }
123
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * cotutor serve:一 workspace 一进程(2026-09-08 拍板),端口读 cotutor.json(--port 覆盖)。
3
+ * 启动打印解析结果与体检警告(配置坏了直接不起,骨架缺失只警告)。
4
+ */
5
+ import { type Server } from 'node:http';
6
+ import { type ResolveOptions, type Workspace } from './workspace.ts';
7
+ export interface ServeOptions extends ResolveOptions {
8
+ workspace?: string;
9
+ port?: number;
10
+ host?: string;
11
+ }
12
+ export interface ServeResult {
13
+ server: Server;
14
+ ws: Workspace;
15
+ port: number;
16
+ url: string;
17
+ warnings: string[];
18
+ }
19
+ export declare function serveWorkspace(opts?: ServeOptions): Promise<ServeResult>;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * cotutor serve:一 workspace 一进程(2026-09-08 拍板),端口读 cotutor.json(--port 覆盖)。
3
+ * 启动打印解析结果与体检警告(配置坏了直接不起,骨架缺失只警告)。
4
+ */
5
+ import { createServer } from 'node:http';
6
+ import { hostname } from 'node:os';
7
+ import { doctorWorkspace } from "./doctor.js";
8
+ import { createHandler } from "../server/app.js";
9
+ import { loadWorkspace } from "./workspace.js";
10
+ export async function serveWorkspace(opts = {}) {
11
+ const ws = loadWorkspace(opts.workspace, opts);
12
+ const report = await doctorWorkspace(ws.root, { ...opts, probeEnv: false });
13
+ const warnings = report.checks.filter((c) => !c.ok).map((c) => `${c.name}:${c.detail}${c.fix ? `(${c.fix})` : ''}`);
14
+ const port = opts.port ?? ws.config.server.port;
15
+ const server = createServer(createHandler(ws));
16
+ await new Promise((resolveListen, reject) => {
17
+ server.once('error', reject);
18
+ server.listen(port, opts.host ?? '0.0.0.0', () => resolveListen());
19
+ });
20
+ const actual = server.address().port;
21
+ return { server, ws, port: actual, url: `http://${hostname()}:${actual}/`, warnings };
22
+ }
@@ -0,0 +1,21 @@
1
+ export declare const DIRS: readonly ["agents", "ledger", "conversations", ".claude/agents", ".qwen/agents"];
2
+ export declare const LEDGER_FILES: readonly ["ledger/observations.jsonl", "ledger/artifacts.jsonl"];
3
+ /** 本包自带的老师定义目录(仓库检出与 npm 安装都在包根 agents/) */
4
+ export declare const PACKAGE_AGENTS_DIR: string;
5
+ export interface ShippedAgent {
6
+ name: string;
7
+ file: string;
8
+ description: string;
9
+ }
10
+ export declare function shippedAgents(dir?: string): Promise<ShippedAgent[]>;
11
+ export declare const GITIGNORE = "# \u8F6C\u5F55\u65E5\u5FD7\u4F53\u79EF\u5927\u3001\u53EF\u4ECE\u4F1A\u8BDD\u7D22\u5F15\u91CD\u5EFA\u5173\u952E\u4FE1\u606F;\u8981\u7559\u5168\u6863\u5C31\u5220\u6389\u4E0B\u9762\u8FD9\u884C\nconversations/**/*.log\nnode_modules/\n.DS_Store\n";
12
+ /** 家规:所有老师常驻(CLAUDE.md 与 QWEN.md 同一份)。故意短。 */
13
+ export declare const RULES = "# \u5BB6\u89C4\n\n- \u5173\u4E8E\u5B69\u5B50\u7684\u89C2\u5BDF,\u5199\u4E00\u6761\u5230 ledger/observations.jsonl(\u4E00\u884C JSON:id\u3001date\u3001author=\u4F60\u7684\u540D\u5B57\u3001claim \u4E00\u53E5\u8BDD\u3001evidence \u6307\u5411\u4F1A\u8BDD\u6216\u4EA7\u7269),\u522B\u7684\u8001\u5E08\u548C\u89C4\u5212\u8001\u5E08\u4ECE\u90A3\u91CC\u8BFB\u3002\n- \u4F60\u81EA\u5DF1\u7684\u7ECF\u9A8C\u8BB0\u8FDB\u4F60\u7684\u8BB0\u5FC6\u76EE\u5F55(MEMORY.md \u4E00\u884C\u7D22\u5F15 + \u4E3B\u9898\u6587\u4EF6)\u3002\n- \u4F60\u8BF4\u7ED9\u5B69\u5B50\u542C\u7684\u8BDD\u653E\u5728\u56DE\u590D\u7684**\u6700\u540E\u4E00\u6BB5**,\u5B69\u5B50\u53EA\u770B\u5F97\u5230\u6700\u540E\u4E00\u6BB5;\u4E2D\u95F4\u7684\u8BDD\u548C\u5DE5\u5177\u8FC7\u7A0B\u53EA\u6709\u5BB6\u957F\u770B\u3002\n- \u9700\u8981\u5BB6\u957F\u62CD\u677F\u7684\u4E8B\u5199\u6210\u4E00\u6BB5\u300C## \u5F85\u88C1\u91CF\u300D(question: \u4E00\u53E5\u8BDD;options: \u5217\u8868),\u4E0D\u8981\u505C\u4E0B\u6765\u7B49\u3002\n- \u8981\u4EA4\u7ED9\u522B\u7684\u8001\u5E08\u7684\u4E8B\u5199\u6210\u4E00\u6BB5\u300C## \u8F6C\u4EA4\u300D(to: \u8001\u5E08\u540D;why: \u4E00\u53E5\u8BDD;refs: \u76F8\u5173\u6587\u4EF6),\u7B2C\u4E00\u671F\u53EA\u5141\u8BB8\u4E00\u8DF3\u3002\n- \u4F1A\u8BDD\u6309\u5929,\u660E\u5929\u4ECE\u8D26\u672C\u548C\u4F60\u7684\u8BB0\u5FC6\u63A5\u7740\u6765,\u4E0D\u8981\u6307\u671B\u4ECA\u5929\u7684\u5BF9\u8BDD\u8FD8\u5728\u3002\n";
14
+ export interface ConfigTemplateInput {
15
+ slug: string;
16
+ name?: string;
17
+ port?: number;
18
+ teachers: ShippedAgent[];
19
+ }
20
+ /** cotutor.json 模板:只在文件不存在时写入;政策文件永不自动重建或覆盖(家长的决定不由机器替她拍板)。 */
21
+ export declare function configTemplate(input: ConfigTemplateInput): string;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * 工作区骨架清单:init 建、doctor 查,同一张清单(两边各写一遍必然漂移)。
3
+ * 布局见《cotutor-agent层设计.md》§2:
4
+ * cotutor.json / CLAUDE.md QWEN.md(家规)/ .claude/agents .qwen/agents(老师定义链自本包 agents/)
5
+ * agents/<name>/(老师的家 = 会话 cwd)/ ledger/(两本账)/ conversations/(会话索引与转录)
6
+ */
7
+ import { readFile, readdir } from 'node:fs/promises';
8
+ import { join } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { parseAgentFile } from "../lib/agent-file.js";
11
+ export const DIRS = ['agents', 'ledger', 'conversations', '.claude/agents', '.qwen/agents'];
12
+ export const LEDGER_FILES = ['ledger/observations.jsonl', 'ledger/artifacts.jsonl'];
13
+ /** 本包自带的老师定义目录(仓库检出与 npm 安装都在包根 agents/) */
14
+ export const PACKAGE_AGENTS_DIR = fileURLToPath(new URL('../../agents/', import.meta.url));
15
+ export async function shippedAgents(dir = PACKAGE_AGENTS_DIR) {
16
+ const out = [];
17
+ for (const f of (await readdir(dir)).filter((f) => f.endsWith('.md')).sort()) {
18
+ const file = join(dir, f);
19
+ const { frontmatter } = parseAgentFile(await readFile(file, 'utf8'));
20
+ if (frontmatter.name)
21
+ out.push({ name: frontmatter.name, file, description: frontmatter.description ?? '' });
22
+ }
23
+ return out;
24
+ }
25
+ export const GITIGNORE = `# 转录日志体积大、可从会话索引重建关键信息;要留全档就删掉下面这行
26
+ conversations/**/*.log
27
+ node_modules/
28
+ .DS_Store
29
+ `;
30
+ /** 家规:所有老师常驻(CLAUDE.md 与 QWEN.md 同一份)。故意短。 */
31
+ export const RULES = `# 家规
32
+
33
+ - 关于孩子的观察,写一条到 ledger/observations.jsonl(一行 JSON:id、date、author=你的名字、claim 一句话、evidence 指向会话或产物),别的老师和规划老师从那里读。
34
+ - 你自己的经验记进你的记忆目录(MEMORY.md 一行索引 + 主题文件)。
35
+ - 你说给孩子听的话放在回复的**最后一段**,孩子只看得到最后一段;中间的话和工具过程只有家长看。
36
+ - 需要家长拍板的事写成一段「## 待裁量」(question: 一句话;options: 列表),不要停下来等。
37
+ - 要交给别的老师的事写成一段「## 转交」(to: 老师名;why: 一句话;refs: 相关文件),第一期只允许一跳。
38
+ - 会话按天,明天从账本和你的记忆接着来,不要指望今天的对话还在。
39
+ `;
40
+ const TEACHER_PRESETS = {
41
+ 'math-teacher': { display: '数学老师', subject: '数学', avatar: '🧮' },
42
+ 'chinese-teacher': { display: '语文老师', subject: '语文', avatar: '📚' },
43
+ 'reading-teacher': { display: '朗读老师', subject: '英语', avatar: '📖' },
44
+ 'homework-aide': { display: '作业助教', avatar: '📷' },
45
+ planner: { display: '规划老师', avatar: '🗓', hidden: true },
46
+ };
47
+ /** cotutor.json 模板:只在文件不存在时写入;政策文件永不自动重建或覆盖(家长的决定不由机器替她拍板)。 */
48
+ export function configTemplate(input) {
49
+ const teachers = {};
50
+ for (const a of input.teachers) {
51
+ const p = TEACHER_PRESETS[a.name];
52
+ teachers[a.name] = p
53
+ ? { display: p.display, ...(p.subject ? { subject: p.subject } : {}), avatar: p.avatar, enabled: true, ...(p.hidden ? { hidden: true } : {}) }
54
+ : { display: a.name, enabled: true };
55
+ }
56
+ const cfg = {
57
+ version: 1,
58
+ title: input.name ? `${input.name}的书房` : 'cotutor',
59
+ kid: { slug: input.slug, ...(input.name ? { name: input.name } : {}) },
60
+ server: { port: input.port ?? 5180 },
61
+ paths: {},
62
+ policyDefaults: {},
63
+ teachers,
64
+ agents: {
65
+ default: 'claude',
66
+ claude: {
67
+ run: ['claude', '--agent', '{agent}', '-p', '{prompt}', '--dangerously-skip-permissions', '--output-format', 'stream-json', '--verbose', '--max-budget-usd', '2'],
68
+ resume: ['claude', '--agent', '{agent}', '-p', '--resume', '{session}', '{prompt}', '--dangerously-skip-permissions', '--output-format', 'stream-json', '--verbose', '--max-budget-usd', '2'],
69
+ },
70
+ qwen: {
71
+ run: ['qwen', '-p', '{prompt}', '--append-system-prompt', '{agentBody}', '--yolo', '--output-format', 'stream-json', '--max-wall-time', '10m'],
72
+ resume: ['qwen', '-p', '{prompt}', '--resume', '{session}', '--append-system-prompt', '{agentBody}', '--yolo', '--output-format', 'stream-json', '--max-wall-time', '10m'],
73
+ },
74
+ },
75
+ _note: '一孩一 workspace 的政策文件,家长改这里;机器不会自动重建或覆盖,写坏了靠 git 回退,cotutor doctor 可体检。' +
76
+ 'teachers = 老师表(key 与 .claude/agents/<key>.md 的 name 一致):display 显示名、avatar、voice 用 voxtell 音色 id、enabled 开关、hidden 孩子端不露、policy 覆盖 policyDefaults。' +
77
+ 'policyDefaults 缺省:replyMaxChars 60、dailyMessages 30、dailyRegen 3、reviewGate false、contextPack {recent 10, planLines 10}。' +
78
+ 'paths = 角色映射:vault 指 Obsidian vault 根(一孩一 vault),photos/diary/plans/profile/timetable 相对 vault。' +
79
+ 'agents = 运行时预设,占位 {agent} {agentBody} {prompt} {session};政策旋钮(预算、时限)写进模板。',
80
+ };
81
+ return `${JSON.stringify(cfg, null, 2)}\n`;
82
+ }
@@ -0,0 +1,61 @@
1
+ import { type CotutorConfig, type PathRole } from '../schema/index.ts';
2
+ /** 用法错误:main 捕获后 exit 2,错误信息即修复指南。 */
3
+ export declare class UsageError extends Error {
4
+ }
5
+ /** 配置文件损坏或形状不对:文件在,但用不了。 */
6
+ export declare class ConfigError extends UsageError {
7
+ readonly file: string;
8
+ constructor(file: string, cause: string);
9
+ }
10
+ export declare const CONFIG_FILE = "cotutor.json";
11
+ export declare const USER_CONFIG: string;
12
+ export declare const HOME_ROOT: string;
13
+ export type RootSource = 'flag' | 'env' | 'cwd' | 'user-config' | 'home-single';
14
+ export interface ResolvedRoot {
15
+ root: string;
16
+ source: RootSource;
17
+ }
18
+ export declare function expandPath(p: string, base: string): string;
19
+ export declare function redactHome(value: string): string;
20
+ export declare function redactDeep<T>(value: T): T;
21
+ /** 读 JSON:不存在 → null;坏 → ConfigError */
22
+ export declare function readJson(file: string): unknown | null;
23
+ /** 从 dir 向上找 cotutor.json,找到返回目录 */
24
+ export declare function findUp(dir: string): string | null;
25
+ /** ~/cotutor/ 下带 cotutor.json 的子目录 */
26
+ export declare function homeWorkspaces(homeRoot?: string): string[];
27
+ export interface ResolveOptions {
28
+ env?: NodeJS.ProcessEnv;
29
+ cwd?: string;
30
+ homeRoot?: string;
31
+ }
32
+ export declare function resolveRoot(override?: string, opts?: ResolveOptions): ResolvedRoot;
33
+ export interface Workspace {
34
+ root: string;
35
+ source: RootSource;
36
+ config: CotutorConfig;
37
+ /** 角色 → 绝对路径;vault 侧角色相对 vault 解析,没配 vault 就相对 workspace 根 */
38
+ paths: Record<PathRole, string> & Record<string, string>;
39
+ dirs: {
40
+ agents: string;
41
+ ledger: string;
42
+ conversations: string;
43
+ claudeAgents: string;
44
+ qwenAgents: string;
45
+ };
46
+ files: {
47
+ config: string;
48
+ observations: string;
49
+ artifacts: string;
50
+ rulesClaude: string;
51
+ rulesQwen: string;
52
+ };
53
+ }
54
+ /** 校验 cotutor.json 的形状;失败 → ConfigError,信息是逐条修复指南 */
55
+ export declare function parseConfig(raw: unknown, file: string): CotutorConfig;
56
+ export declare function resolvePaths(root: string, paths: Record<string, string>): Workspace['paths'];
57
+ export declare function assembleWorkspace(root: string, source: RootSource, config: CotutorConfig): Workspace;
58
+ /** 定根 + 读配置 + 校验 + 展开角色。cotutor.json 缺失也是错(它是必需的)。 */
59
+ export declare function loadWorkspace(override?: string, opts?: ResolveOptions): Workspace;
60
+ /** --json / 启动日志里回报工作区(脱敏) */
61
+ export declare function workspaceReport(ws: Workspace): Record<string, unknown>;
@@ -0,0 +1,199 @@
1
+ /**
2
+ * 工作区解析:一孩一 workspace(`~/cotutor/<slug>/`,2026-09-08 拍板)。
3
+ * 解析链(定根):`--workspace <dir>` → 环境变量 COTUTOR_WORKSPACE → cwd 或其祖先有 cotutor.json
4
+ * (老师的 cwd 是 <ws>/agents/<name>/,从那里跑 doctor 也要找得到根)→ 用户配置
5
+ * ~/.config/cotutor/config.json 的 workspace → ~/cotutor/ 下**唯一**的一个孩子目录 → 都没有就报错附修复指南。
6
+ * 没有"cwd 兜底":cotutor.json 是必需的政策文件(老师表在里面),没有它什么都跑不了。
7
+ * 配置"在但解析不了"响亮报错,不静默走缺省(沿 drawtell 的教训)。
8
+ */
9
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
12
+ import { CotutorConfigSchema, PATH_ROLES, ROLE_DEFAULTS, explainIssues, } from "../schema/index.js";
13
+ /** 用法错误:main 捕获后 exit 2,错误信息即修复指南。 */
14
+ export class UsageError extends Error {
15
+ }
16
+ /** 配置文件损坏或形状不对:文件在,但用不了。 */
17
+ export class ConfigError extends UsageError {
18
+ file;
19
+ constructor(file, cause) {
20
+ super(`配置文件用不了:${redactHome(file)}\n${cause}\n修好它;文件在 git 里就 git diff 看改动、git checkout 回退。cotutor doctor 可逐项体检。`);
21
+ this.file = file;
22
+ }
23
+ }
24
+ export const CONFIG_FILE = 'cotutor.json';
25
+ export const USER_CONFIG = join(homedir(), '.config', 'cotutor', 'config.json');
26
+ export const HOME_ROOT = join(homedir(), 'cotutor');
27
+ export function expandPath(p, base) {
28
+ if (p === '~')
29
+ return homedir();
30
+ if (p.startsWith('~/'))
31
+ return join(homedir(), p.slice(2));
32
+ return isAbsolute(p) ? p : resolve(base, p);
33
+ }
34
+ export function redactHome(value) {
35
+ const home = homedir();
36
+ return home && value.startsWith(home) ? '$HOME' + value.slice(home.length) : value;
37
+ }
38
+ export function redactDeep(value) {
39
+ if (typeof value === 'string')
40
+ return redactHome(value);
41
+ if (Array.isArray(value))
42
+ return value.map(redactDeep);
43
+ if (value && typeof value === 'object') {
44
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactDeep(v)]));
45
+ }
46
+ return value;
47
+ }
48
+ const isDir = (p) => {
49
+ try {
50
+ return statSync(p).isDirectory();
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ };
56
+ const isFile = (p) => {
57
+ try {
58
+ return statSync(p).isFile();
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ };
64
+ /** 读 JSON:不存在 → null;坏 → ConfigError */
65
+ export function readJson(file) {
66
+ let text;
67
+ try {
68
+ text = readFileSync(file, 'utf8');
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ try {
74
+ return JSON.parse(text);
75
+ }
76
+ catch (err) {
77
+ throw new ConfigError(file, `不是合法 JSON:${err instanceof Error ? err.message : String(err)}`);
78
+ }
79
+ }
80
+ /** 从 dir 向上找 cotutor.json,找到返回目录 */
81
+ export function findUp(dir) {
82
+ let cur = resolve(dir);
83
+ for (;;) {
84
+ if (isFile(join(cur, CONFIG_FILE)))
85
+ return cur;
86
+ const parent = dirname(cur);
87
+ if (parent === cur)
88
+ return null;
89
+ cur = parent;
90
+ }
91
+ }
92
+ /** ~/cotutor/ 下带 cotutor.json 的子目录 */
93
+ export function homeWorkspaces(homeRoot = HOME_ROOT) {
94
+ try {
95
+ return readdirSync(homeRoot)
96
+ .map((d) => join(homeRoot, d))
97
+ .filter((p) => isDir(p) && isFile(join(p, CONFIG_FILE)))
98
+ .sort();
99
+ }
100
+ catch {
101
+ return [];
102
+ }
103
+ }
104
+ export function resolveRoot(override, opts = {}) {
105
+ const env = opts.env ?? process.env;
106
+ const cwd = resolve(opts.cwd ?? process.cwd());
107
+ if (override !== undefined) {
108
+ const root = expandPath(override, cwd);
109
+ if (!isDir(root)) {
110
+ throw new UsageError(`--workspace 指向的目录不存在:${redactHome(root)}(由 '${redactHome(override)}' 解析)。先 cotutor init <slug>(缺省建在 ~/cotutor/<slug>/),或改指向已有的 workspace 根。`);
111
+ }
112
+ return { root, source: 'flag' };
113
+ }
114
+ if (env.COTUTOR_WORKSPACE) {
115
+ const root = expandPath(env.COTUTOR_WORKSPACE, cwd);
116
+ if (!isDir(root))
117
+ throw new UsageError(`COTUTOR_WORKSPACE 指向的目录不存在:${redactHome(root)}。改环境变量,或先 cotutor init。`);
118
+ return { root, source: 'env' };
119
+ }
120
+ const up = findUp(cwd);
121
+ if (up)
122
+ return { root: up, source: 'cwd' };
123
+ const user = readJson(USER_CONFIG);
124
+ if (user && typeof user.workspace === 'string' && user.workspace) {
125
+ return { root: expandPath(user.workspace, cwd), source: 'user-config' };
126
+ }
127
+ const homes = homeWorkspaces(opts.homeRoot);
128
+ if (homes.length === 1)
129
+ return { root: homes[0], source: 'home-single' };
130
+ if (homes.length > 1) {
131
+ throw new UsageError(`~/cotutor/ 下有 ${homes.length} 个 workspace(${homes.map((h) => redactHome(h)).join('、')}),不知道要哪个。` +
132
+ '用 --workspace <dir> 或 COTUTOR_WORKSPACE 指定,或在 workspace 目录里跑;一 workspace 一进程。');
133
+ }
134
+ throw new UsageError('找不到 workspace:没有 --workspace、COTUTOR_WORKSPACE、cwd 里的 cotutor.json、用户配置,~/cotutor/ 下也没有。先 cotutor init <slug>。');
135
+ }
136
+ /** 校验 cotutor.json 的形状;失败 → ConfigError,信息是逐条修复指南 */
137
+ export function parseConfig(raw, file) {
138
+ const r = CotutorConfigSchema.safeParse(raw);
139
+ if (r.success)
140
+ return r.data;
141
+ throw new ConfigError(file, explainIssues(r.error.issues).map((l) => ` - ${l}`).join('\n'));
142
+ }
143
+ export function resolvePaths(root, paths) {
144
+ const vault = expandPath(paths.vault ?? ROLE_DEFAULTS.vault, root);
145
+ const out = { vault };
146
+ for (const role of PATH_ROLES) {
147
+ if (role === 'vault')
148
+ continue;
149
+ out[role] = expandPath(paths[role] ?? ROLE_DEFAULTS[role], vault);
150
+ }
151
+ for (const [role, value] of Object.entries(paths))
152
+ if (!(role in out))
153
+ out[role] = expandPath(value, vault);
154
+ return out;
155
+ }
156
+ export function assembleWorkspace(root, source, config) {
157
+ return {
158
+ root,
159
+ source,
160
+ config,
161
+ paths: resolvePaths(root, config.paths),
162
+ dirs: {
163
+ agents: join(root, 'agents'),
164
+ ledger: join(root, 'ledger'),
165
+ conversations: join(root, 'conversations'),
166
+ claudeAgents: join(root, '.claude', 'agents'),
167
+ qwenAgents: join(root, '.qwen', 'agents'),
168
+ },
169
+ files: {
170
+ config: join(root, CONFIG_FILE),
171
+ observations: join(root, 'ledger', 'observations.jsonl'),
172
+ artifacts: join(root, 'ledger', 'artifacts.jsonl'),
173
+ rulesClaude: join(root, 'CLAUDE.md'),
174
+ rulesQwen: join(root, 'QWEN.md'),
175
+ },
176
+ };
177
+ }
178
+ /** 定根 + 读配置 + 校验 + 展开角色。cotutor.json 缺失也是错(它是必需的)。 */
179
+ export function loadWorkspace(override, opts = {}) {
180
+ const { root, source } = resolveRoot(override, opts);
181
+ const file = join(root, CONFIG_FILE);
182
+ const raw = readJson(file);
183
+ if (raw === null)
184
+ throw new ConfigError(file, `不存在。cotutor init <slug> --dir ${redactHome(root)} 可生成模板(已有文件不动)。`);
185
+ return assembleWorkspace(root, source, parseConfig(raw, file));
186
+ }
187
+ /** --json / 启动日志里回报工作区(脱敏) */
188
+ export function workspaceReport(ws) {
189
+ return redactDeep({
190
+ workspace: ws.root,
191
+ workspaceSource: ws.source,
192
+ kid: ws.config.kid.slug,
193
+ title: ws.config.title,
194
+ port: ws.config.server.port,
195
+ vault: ws.paths.vault,
196
+ teachers: Object.keys(ws.config.teachers),
197
+ agent: ws.config.agents.default,
198
+ });
199
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * 老师定义文件(.claude/agents/<name>.md 同一份链到 .qwen/agents/):YAML frontmatter + 正文即系统提示。
3
+ * 这里只认平铺的 `key: value` frontmatter(两个 CLI 的公共子集:name / description / maxTurns / permissionMode / memory);
4
+ * 正文给没有 --agent 的 CLI 经 {agentBody} 塞进 --append-system-prompt。
5
+ */
6
+ export interface AgentFile {
7
+ frontmatter: Record<string, string>;
8
+ body: string;
9
+ }
10
+ export declare function parseAgentFile(text: string): AgentFile;
@@ -0,0 +1,16 @@
1
+ export function parseAgentFile(text) {
2
+ const open = /^---[^\S\n]*\n/.exec(text);
3
+ if (!open)
4
+ return { frontmatter: {}, body: text.trim() };
5
+ const rest = text.slice(open[0].length);
6
+ const end = /\n---[^\S\n]*(\n|$)/.exec(rest);
7
+ if (!end)
8
+ return { frontmatter: {}, body: text.trim() };
9
+ const frontmatter = {};
10
+ for (const raw of rest.slice(0, end.index).split('\n')) {
11
+ const m = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(raw);
12
+ if (m)
13
+ frontmatter[m[1]] = m[2].trim().replace(/^(["'])(.*)\1$/, '$2');
14
+ }
15
+ return { frontmatter, body: rest.slice(end.index + end[0].length).trim() };
16
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * 上下文包序列化(《cotutor契约草案.md》§2):固定 YAML 块 + `---` + 消息原文。
3
+ * 手写 YAML 子集:标量能裸写就裸写,其余 JSON 双引号(合法 YAML);空的 plan / recent 不写。
4
+ */
5
+ import { type ContextPack } from '../schema/index.ts';
6
+ export declare function yamlScalar(v: string | number | boolean): string;
7
+ export declare function renderContextPack(pack: ContextPack): string;
8
+ /** 按政策截 plan / recent,再拼成「YAML 块 --- 消息」 */
9
+ export declare function buildContextPack(pack: ContextPack, message: string, limits: {
10
+ recent: number;
11
+ planLines: number;
12
+ }): string;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 上下文包序列化(《cotutor契约草案.md》§2):固定 YAML 块 + `---` + 消息原文。
3
+ * 手写 YAML 子集:标量能裸写就裸写,其余 JSON 双引号(合法 YAML);空的 plan / recent 不写。
4
+ */
5
+ import { ContextPackSchema } from "../schema/index.js";
6
+ const BARE = /^[A-Za-z0-9_][A-Za-z0-9_\-.:T]*$/;
7
+ export function yamlScalar(v) {
8
+ if (typeof v !== 'string')
9
+ return String(v);
10
+ return BARE.test(v) && !/^(true|false|null|~|yes|no)$/i.test(v) ? v : JSON.stringify(v);
11
+ }
12
+ export function renderContextPack(pack) {
13
+ const p = ContextPackSchema.parse(pack);
14
+ const out = ['cotutor:', ` from: ${p.from}`, ` at: ${yamlScalar(p.at)}`];
15
+ if (p.slot)
16
+ out.push(` slot: ${yamlScalar(p.slot)}`);
17
+ if (p.focus && (p.focus.artifact || p.focus.step !== undefined || p.focus.circled?.length)) {
18
+ out.push(' focus:');
19
+ if (p.focus.artifact)
20
+ out.push(` artifact: ${yamlScalar(p.focus.artifact)}`);
21
+ if (p.focus.step !== undefined)
22
+ out.push(` step: ${p.focus.step}`);
23
+ if (p.focus.circled?.length)
24
+ out.push(` circled: [${p.focus.circled.map(yamlScalar).join(', ')}]`);
25
+ }
26
+ if (p.plan.length) {
27
+ out.push(' plan:');
28
+ for (const l of p.plan)
29
+ out.push(` - ${yamlScalar(l)}`);
30
+ }
31
+ if (p.recent.length) {
32
+ out.push(' recent:');
33
+ for (const r of p.recent)
34
+ out.push(` - ${yamlScalar(`${r.date} ${r.claim}`)}`);
35
+ }
36
+ return out.join('\n');
37
+ }
38
+ /** 按政策截 plan / recent,再拼成「YAML 块 --- 消息」 */
39
+ export function buildContextPack(pack, message, limits) {
40
+ const trimmed = {
41
+ ...pack,
42
+ plan: (pack.plan ?? []).slice(0, limits.planLines),
43
+ recent: (pack.recent ?? []).slice(-limits.recent),
44
+ };
45
+ return `${renderContextPack(trimmed)}\n---\n${message.trim()}\n`;
46
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 会话索引的纯函数:日期与 job 命名、空索引、把一次运行的结果并进索引(不改旧对象)。
3
+ */
4
+ import { type ConversationIndex, type ConversationMessage } from '../schema/index.ts';
5
+ import type { KidView } from './kid-view.ts';
6
+ import type { Transcript } from './transcript.ts';
7
+ /** 本地日期 YYYY-MM-DD(会话按本地日切) */
8
+ export declare function localDate(d: Date): string;
9
+ /** 本地时间到分钟 YYYY-MM-DDTHH:MM(上下文包的 at) */
10
+ export declare function localMinute(d: Date): string;
11
+ /** HHMM-<序号>,同一天内不重复 */
12
+ export declare function jobId(d: Date, seq: number): string;
13
+ export declare function emptyIndex(teacher: string, date: string): ConversationIndex;
14
+ export interface ConversationFiles {
15
+ index: string;
16
+ log: (job: string) => string;
17
+ err: (job: string) => string;
18
+ }
19
+ /** conversations/<teacher>/<date>.json 与 <date>.<job>.log / .err.log */
20
+ export declare function conversationFiles(conversationsDir: string, teacher: string, date: string): ConversationFiles;
21
+ export declare function addMessage(index: ConversationIndex, msg: ConversationMessage): ConversationIndex;
22
+ /** 一次运行收尾:写 result / cost / kidText / artifacts,首次拿到 session 就记下 */
23
+ export declare function applyRun(index: ConversationIndex, job: string, run: {
24
+ transcript: Transcript;
25
+ kidView: KidView;
26
+ agent: string;
27
+ artifacts?: string[];
28
+ }): ConversationIndex;