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.
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/agents/chinese-teacher.md +19 -0
- package/agents/homework-aide.md +17 -0
- package/agents/math-teacher.md +19 -0
- package/agents/planner.md +18 -0
- package/agents/reading-teacher.md +19 -0
- package/bin/cotutor.js +17 -0
- package/dist/cli/doctor.d.ts +19 -0
- package/dist/cli/doctor.js +190 -0
- package/dist/cli/init.d.ts +17 -0
- package/dist/cli/init.js +114 -0
- package/dist/cli/main.d.ts +1 -0
- package/dist/cli/main.js +123 -0
- package/dist/cli/serve.d.ts +19 -0
- package/dist/cli/serve.js +22 -0
- package/dist/cli/skeleton.d.ts +21 -0
- package/dist/cli/skeleton.js +82 -0
- package/dist/cli/workspace.d.ts +61 -0
- package/dist/cli/workspace.js +199 -0
- package/dist/lib/agent-file.d.ts +10 -0
- package/dist/lib/agent-file.js +16 -0
- package/dist/lib/context-pack.d.ts +12 -0
- package/dist/lib/context-pack.js +46 -0
- package/dist/lib/conversation.d.ts +28 -0
- package/dist/lib/conversation.js +44 -0
- package/dist/lib/index.d.ts +9 -0
- package/dist/lib/index.js +9 -0
- package/dist/lib/kid-view.d.ts +26 -0
- package/dist/lib/kid-view.js +38 -0
- package/dist/lib/ledger.d.ts +21 -0
- package/dist/lib/ledger.js +92 -0
- package/dist/lib/plan.d.ts +12 -0
- package/dist/lib/plan.js +78 -0
- package/dist/lib/sections.d.ts +12 -0
- package/dist/lib/sections.js +151 -0
- package/dist/lib/transcript.d.ts +36 -0
- package/dist/lib/transcript.js +95 -0
- package/dist/schema/config.d.ts +182 -0
- package/dist/schema/config.js +144 -0
- package/dist/schema/context-pack.d.ts +33 -0
- package/dist/schema/context-pack.js +26 -0
- package/dist/schema/conversation.d.ts +63 -0
- package/dist/schema/conversation.js +30 -0
- package/dist/schema/index.d.ts +17 -0
- package/dist/schema/index.js +17 -0
- package/dist/schema/issues.d.ts +6 -0
- package/dist/schema/issues.js +37 -0
- package/dist/schema/ledger.d.ts +100 -0
- package/dist/schema/ledger.js +44 -0
- package/dist/schema/plan.d.ts +26 -0
- package/dist/schema/plan.js +19 -0
- package/dist/schema/sections.d.ts +30 -0
- package/dist/schema/sections.js +26 -0
- package/dist/server/app.d.ts +13 -0
- package/dist/server/app.js +45 -0
- package/package.json +62 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话索引的纯函数:日期与 job 命名、空索引、把一次运行的结果并进索引(不改旧对象)。
|
|
3
|
+
*/
|
|
4
|
+
import { ConversationIndexSchema } from "../schema/index.js";
|
|
5
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
6
|
+
/** 本地日期 YYYY-MM-DD(会话按本地日切) */
|
|
7
|
+
export function localDate(d) {
|
|
8
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
9
|
+
}
|
|
10
|
+
/** 本地时间到分钟 YYYY-MM-DDTHH:MM(上下文包的 at) */
|
|
11
|
+
export function localMinute(d) {
|
|
12
|
+
return `${localDate(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
13
|
+
}
|
|
14
|
+
/** HHMM-<序号>,同一天内不重复 */
|
|
15
|
+
export function jobId(d, seq) {
|
|
16
|
+
return `${pad(d.getHours())}${pad(d.getMinutes())}-${seq}`;
|
|
17
|
+
}
|
|
18
|
+
export function emptyIndex(teacher, date) {
|
|
19
|
+
return ConversationIndexSchema.parse({ teacher, date });
|
|
20
|
+
}
|
|
21
|
+
/** conversations/<teacher>/<date>.json 与 <date>.<job>.log / .err.log */
|
|
22
|
+
export function conversationFiles(conversationsDir, teacher, date) {
|
|
23
|
+
const base = `${conversationsDir}/${teacher}/${date}`;
|
|
24
|
+
return { index: `${base}.json`, log: (job) => `${base}.${job}.log`, err: (job) => `${base}.${job}.err.log` };
|
|
25
|
+
}
|
|
26
|
+
export function addMessage(index, msg) {
|
|
27
|
+
return { ...index, messages: [...index.messages, msg] };
|
|
28
|
+
}
|
|
29
|
+
/** 一次运行收尾:写 result / cost / kidText / artifacts,首次拿到 session 就记下 */
|
|
30
|
+
export function applyRun(index, job, run) {
|
|
31
|
+
const { transcript, kidView } = run;
|
|
32
|
+
const messages = index.messages.map((m) => m.job === job
|
|
33
|
+
? {
|
|
34
|
+
...m,
|
|
35
|
+
result: transcript.final ? (transcript.final.ok ? 'ok' : 'error') : 'running',
|
|
36
|
+
costUsd: transcript.final?.costUsd,
|
|
37
|
+
kidText: kidView.kidText,
|
|
38
|
+
artifacts: run.artifacts ?? m.artifacts,
|
|
39
|
+
}
|
|
40
|
+
: m);
|
|
41
|
+
const session = index.session ?? (transcript.sessionId ? { id: transcript.sessionId, agent: run.agent } : null);
|
|
42
|
+
const costUsd = messages.reduce((s, m) => s + (m.costUsd ?? 0), 0);
|
|
43
|
+
return { ...index, session, messages, costUsd: Math.round(costUsd * 1e4) / 1e4 };
|
|
44
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** 纯函数层:全部离屏可测,不依赖文件系统与网络。 */
|
|
2
|
+
export * from './agent-file.ts';
|
|
3
|
+
export * from './sections.ts';
|
|
4
|
+
export * from './transcript.ts';
|
|
5
|
+
export * from './kid-view.ts';
|
|
6
|
+
export * from './context-pack.ts';
|
|
7
|
+
export * from './plan.ts';
|
|
8
|
+
export * from './ledger.ts';
|
|
9
|
+
export * from './conversation.ts';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** 纯函数层:全部离屏可测,不依赖文件系统与网络。 */
|
|
2
|
+
export * from "./agent-file.js";
|
|
3
|
+
export * from "./sections.js";
|
|
4
|
+
export * from "./transcript.js";
|
|
5
|
+
export * from "./kid-view.js";
|
|
6
|
+
export * from "./context-pack.js";
|
|
7
|
+
export * from "./plan.js";
|
|
8
|
+
export * from "./ledger.js";
|
|
9
|
+
export * from "./conversation.js";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 精简视图(《cotutor契约草案.md》§4):孩子只看每次运行最终文本的**最后一段**(剥掉「待裁量」「转交」段之后,
|
|
3
|
+
* 以空行分段取最后一段——家规让老师把给孩子的话放最后一段,前面的话是给家长看的思路),再按 replyMaxChars 截断;
|
|
4
|
+
* 出错什么都不出现。机械规则,不靠模型判断。
|
|
5
|
+
*/
|
|
6
|
+
import type { Handoff, HoldupAsk } from '../schema/index.ts';
|
|
7
|
+
import type { Transcript } from './transcript.ts';
|
|
8
|
+
export interface KidView {
|
|
9
|
+
/** 给孩子的话;null = 这次运行没有(出错、还在跑、或最终文本剥完是空的) */
|
|
10
|
+
kidText: string | null;
|
|
11
|
+
truncated: boolean;
|
|
12
|
+
holdup: HoldupAsk | null;
|
|
13
|
+
handoff: Handoff | null;
|
|
14
|
+
/** 运行是否正常收尾 */
|
|
15
|
+
ok: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** 以空行分段,取最后一个非空段(段内换行保留) */
|
|
18
|
+
export declare function lastParagraph(text: string): string;
|
|
19
|
+
/** 按码点数截断;能在句末断就在句末断(不加省略号),否则硬截加 … */
|
|
20
|
+
export declare function truncateReply(text: string, max: number): {
|
|
21
|
+
text: string;
|
|
22
|
+
truncated: boolean;
|
|
23
|
+
};
|
|
24
|
+
export declare function deriveKidView(t: Transcript, policy: {
|
|
25
|
+
replyMaxChars: number;
|
|
26
|
+
}): KidView;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { parseSections } from "./sections.js";
|
|
2
|
+
const SENTENCE_END = new Set(['。', '!', '!', '?', '?', ';', ';', '\n']);
|
|
3
|
+
/** 以空行分段,取最后一个非空段(段内换行保留) */
|
|
4
|
+
export function lastParagraph(text) {
|
|
5
|
+
const paras = text
|
|
6
|
+
.split(/\n[ \t]*\n+/)
|
|
7
|
+
.map((p) => p.trim())
|
|
8
|
+
.filter(Boolean);
|
|
9
|
+
return paras[paras.length - 1] ?? '';
|
|
10
|
+
}
|
|
11
|
+
/** 按码点数截断;能在句末断就在句末断(不加省略号),否则硬截加 … */
|
|
12
|
+
export function truncateReply(text, max) {
|
|
13
|
+
const cps = Array.from(text);
|
|
14
|
+
if (cps.length <= max)
|
|
15
|
+
return { text, truncated: false };
|
|
16
|
+
let cut = -1;
|
|
17
|
+
for (let i = Math.min(max, cps.length) - 1; i >= Math.floor(max * 0.4); i--) {
|
|
18
|
+
if (SENTENCE_END.has(cps[i])) {
|
|
19
|
+
cut = i + 1;
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (cut > 0)
|
|
24
|
+
return { text: cps.slice(0, cut).join('').trim(), truncated: true };
|
|
25
|
+
return { text: `${cps.slice(0, max).join('').trim()}…`, truncated: true };
|
|
26
|
+
}
|
|
27
|
+
export function deriveKidView(t, policy) {
|
|
28
|
+
if (!t.final)
|
|
29
|
+
return { kidText: null, truncated: false, holdup: null, handoff: null, ok: false };
|
|
30
|
+
if (!t.final.ok || !t.final.text)
|
|
31
|
+
return { kidText: null, truncated: false, holdup: null, handoff: null, ok: t.final.ok };
|
|
32
|
+
const { body, holdup, handoff } = parseSections(t.final.text);
|
|
33
|
+
const last = lastParagraph(body);
|
|
34
|
+
if (!last)
|
|
35
|
+
return { kidText: null, truncated: false, holdup, handoff, ok: true };
|
|
36
|
+
const { text, truncated } = truncateReply(last, policy.replyMaxChars);
|
|
37
|
+
return { kidText: text, truncated, holdup, handoff, ok: true };
|
|
38
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 账本读写的纯函数部分:逐行 JSON + zod,坏行报行号不中断;按 id 折叠,后者为准。
|
|
3
|
+
*/
|
|
4
|
+
import type { z } from 'zod';
|
|
5
|
+
import { type Artifact, type ArtifactEvent, type Observation, type ObservationLine } from '../schema/index.ts';
|
|
6
|
+
export interface JsonlParse<T> {
|
|
7
|
+
rows: T[];
|
|
8
|
+
errors: string[];
|
|
9
|
+
}
|
|
10
|
+
export declare function parseJsonl<T>(text: string, schema: z.ZodType<T>, file?: string): JsonlParse<T>;
|
|
11
|
+
export declare const parseObservations: (text: string) => JsonlParse<ObservationLine>;
|
|
12
|
+
export declare const parseArtifactEvents: (text: string) => JsonlParse<ArtifactEvent>;
|
|
13
|
+
/** 完整行建条目,纠错行改 retracted;顺序按首次出现 */
|
|
14
|
+
export declare function mergeObservations(lines: ObservationLine[]): Observation[];
|
|
15
|
+
/** 折叠产物事件;首行缺 kind / by 的产物报错(不进结果) */
|
|
16
|
+
export declare function mergeArtifacts(events: ArtifactEvent[]): {
|
|
17
|
+
artifacts: Artifact[];
|
|
18
|
+
errors: string[];
|
|
19
|
+
};
|
|
20
|
+
/** o-YYYYMMDD-NNN,同日顺延 */
|
|
21
|
+
export declare function nextObservationId(date: string, existing: Iterable<string>): string;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { ArtifactEventSchema, ObservationLineSchema, explainIssues, } from "../schema/index.js";
|
|
2
|
+
export function parseJsonl(text, schema, file = 'jsonl') {
|
|
3
|
+
const rows = [];
|
|
4
|
+
const errors = [];
|
|
5
|
+
text.split('\n').forEach((line, i) => {
|
|
6
|
+
if (!line.trim())
|
|
7
|
+
return;
|
|
8
|
+
let raw;
|
|
9
|
+
try {
|
|
10
|
+
raw = JSON.parse(line);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
errors.push(`${file} 第 ${i + 1} 行:不是合法 JSON`);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const r = schema.safeParse(raw);
|
|
17
|
+
if (r.success)
|
|
18
|
+
rows.push(r.data);
|
|
19
|
+
else
|
|
20
|
+
errors.push(`${file} 第 ${i + 1} 行:${explainIssues(r.error.issues).join(';')}`);
|
|
21
|
+
});
|
|
22
|
+
return { rows, errors };
|
|
23
|
+
}
|
|
24
|
+
export const parseObservations = (text) => parseJsonl(text, ObservationLineSchema, 'observations.jsonl');
|
|
25
|
+
export const parseArtifactEvents = (text) => parseJsonl(text, ArtifactEventSchema, 'artifacts.jsonl');
|
|
26
|
+
/** 完整行建条目,纠错行改 retracted;顺序按首次出现 */
|
|
27
|
+
export function mergeObservations(lines) {
|
|
28
|
+
const byId = new Map();
|
|
29
|
+
for (const l of lines) {
|
|
30
|
+
if ('claim' in l) {
|
|
31
|
+
const prev = byId.get(l.id);
|
|
32
|
+
byId.set(l.id, prev ? { ...prev, ...l, retracted: prev.retracted || l.retracted } : { ...l });
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
const prev = byId.get(l.id);
|
|
36
|
+
if (prev)
|
|
37
|
+
prev.retracted = true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return [...byId.values()];
|
|
41
|
+
}
|
|
42
|
+
/** 折叠产物事件;首行缺 kind / by 的产物报错(不进结果) */
|
|
43
|
+
export function mergeArtifacts(events) {
|
|
44
|
+
const byId = new Map();
|
|
45
|
+
const errors = [];
|
|
46
|
+
for (const e of events) {
|
|
47
|
+
const prev = byId.get(e.id);
|
|
48
|
+
if (!prev) {
|
|
49
|
+
if (!e.kind || !e.by) {
|
|
50
|
+
errors.push(`产物 ${e.id} 的第一条事件要带 kind 与 by`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
byId.set(e.id, {
|
|
54
|
+
id: e.id,
|
|
55
|
+
kind: e.kind,
|
|
56
|
+
by: e.by,
|
|
57
|
+
at: e.at,
|
|
58
|
+
status: e.status ?? 'draft',
|
|
59
|
+
path: e.path,
|
|
60
|
+
source: e.source,
|
|
61
|
+
hash: e.hash,
|
|
62
|
+
updatedAt: e.at,
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (e.kind)
|
|
67
|
+
prev.kind = e.kind;
|
|
68
|
+
if (e.by)
|
|
69
|
+
prev.by = e.by;
|
|
70
|
+
if (e.status)
|
|
71
|
+
prev.status = e.status;
|
|
72
|
+
if (e.path)
|
|
73
|
+
prev.path = e.path;
|
|
74
|
+
if (e.source)
|
|
75
|
+
prev.source = e.source;
|
|
76
|
+
if (e.hash)
|
|
77
|
+
prev.hash = e.hash;
|
|
78
|
+
prev.updatedAt = e.at;
|
|
79
|
+
}
|
|
80
|
+
return { artifacts: [...byId.values()], errors };
|
|
81
|
+
}
|
|
82
|
+
/** o-YYYYMMDD-NNN,同日顺延 */
|
|
83
|
+
export function nextObservationId(date, existing) {
|
|
84
|
+
const day = date.replaceAll('-', '').slice(0, 8);
|
|
85
|
+
let n = 0;
|
|
86
|
+
for (const id of existing) {
|
|
87
|
+
const m = new RegExp(`^o-${day}-(\\d{3,})$`).exec(id);
|
|
88
|
+
if (m)
|
|
89
|
+
n = Math.max(n, Number(m[1]));
|
|
90
|
+
}
|
|
91
|
+
return `o-${day}-${String(n + 1).padStart(3, '0')}`;
|
|
92
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 计划文件解析(《cotutor契约草案.md》§8),与课程表解析同法:纯函数,错误即修复指南带行号。
|
|
3
|
+
* frontmatter 要有 week(2026-W37)与 status(draft | confirmed);正文按老师显示名分 H2。
|
|
4
|
+
*/
|
|
5
|
+
import { type Plan } from '../schema/index.ts';
|
|
6
|
+
export interface PlanParse {
|
|
7
|
+
plan: Plan | null;
|
|
8
|
+
errors: string[];
|
|
9
|
+
}
|
|
10
|
+
export declare function parsePlan(md: string): PlanParse;
|
|
11
|
+
/** 抽「与这位老师相关的行」:H2 标题等于显示名(两侧空白不计),最多 limit 行 */
|
|
12
|
+
export declare function planLinesFor(plan: Plan, display: string, limit: number): string[];
|
package/dist/lib/plan.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 计划文件解析(《cotutor契约草案.md》§8),与课程表解析同法:纯函数,错误即修复指南带行号。
|
|
3
|
+
* frontmatter 要有 week(2026-W37)与 status(draft | confirmed);正文按老师显示名分 H2。
|
|
4
|
+
*/
|
|
5
|
+
import { PLAN_STATUS, WEEK_RE } from "../schema/index.js";
|
|
6
|
+
const LIST_PREFIX = /^\s*(?:[-*+]|\d+[.)])\s+/;
|
|
7
|
+
export function parsePlan(md) {
|
|
8
|
+
const lines = md.split('\n');
|
|
9
|
+
const errors = [];
|
|
10
|
+
if (!/^---\s*$/.test(lines[0] ?? '')) {
|
|
11
|
+
return { plan: null, errors: ['第 1 行:计划文件要以 --- 开头的 frontmatter 起,里面写 week: 2026-W37 和 status: draft'] };
|
|
12
|
+
}
|
|
13
|
+
let close = -1;
|
|
14
|
+
for (let i = 1; i < lines.length; i++) {
|
|
15
|
+
if (/^---\s*$/.test(lines[i])) {
|
|
16
|
+
close = i;
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (close < 0)
|
|
21
|
+
return { plan: null, errors: ['frontmatter 没有闭合:在 week / status 之后单独一行写 ---'] };
|
|
22
|
+
const fm = {};
|
|
23
|
+
for (let i = 1; i < close; i++) {
|
|
24
|
+
const m = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(lines[i]);
|
|
25
|
+
if (m)
|
|
26
|
+
fm[m[1]] = m[2].trim().replace(/^(["'])(.*)\1$/, '$2');
|
|
27
|
+
else if (lines[i].trim())
|
|
28
|
+
errors.push(`第 ${i + 1} 行:frontmatter 只认 key: value,这行认不出`);
|
|
29
|
+
}
|
|
30
|
+
if (!fm.week)
|
|
31
|
+
errors.push('frontmatter 缺 week(如 week: 2026-W37)');
|
|
32
|
+
else if (!WEEK_RE.test(fm.week))
|
|
33
|
+
errors.push(`week "${fm.week}" 格式不对,应为 YYYY-Www(如 2026-W37)`);
|
|
34
|
+
if (!fm.status)
|
|
35
|
+
errors.push('frontmatter 缺 status(draft 或 confirmed)');
|
|
36
|
+
else if (!PLAN_STATUS.includes(fm.status))
|
|
37
|
+
errors.push(`status "${fm.status}" 只能是 draft 或 confirmed`);
|
|
38
|
+
const sections = [];
|
|
39
|
+
let cur = null;
|
|
40
|
+
let fence = false;
|
|
41
|
+
for (let i = close + 1; i < lines.length; i++) {
|
|
42
|
+
const line = lines[i];
|
|
43
|
+
if (/^\s*```/.test(line)) {
|
|
44
|
+
fence = !fence;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (fence)
|
|
48
|
+
continue;
|
|
49
|
+
const h2 = /^##\s+(.+?)\s*$/.exec(line);
|
|
50
|
+
if (h2) {
|
|
51
|
+
cur = { title: h2[1], lines: [] };
|
|
52
|
+
sections.push(cur);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (/^#\s/.test(line))
|
|
56
|
+
continue;
|
|
57
|
+
const t = line.replace(LIST_PREFIX, '').trim();
|
|
58
|
+
if (!t)
|
|
59
|
+
continue;
|
|
60
|
+
if (!cur) {
|
|
61
|
+
errors.push(`第 ${i + 1} 行:正文要先有一个 ## 老师显示名 的标题,这行不知道归谁`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
cur.lines.push(t);
|
|
65
|
+
}
|
|
66
|
+
if (errors.length)
|
|
67
|
+
return { plan: null, errors };
|
|
68
|
+
return { plan: { week: fm.week, status: fm.status, author: fm.author, sections }, errors };
|
|
69
|
+
}
|
|
70
|
+
/** 抽「与这位老师相关的行」:H2 标题等于显示名(两侧空白不计),最多 limit 行 */
|
|
71
|
+
export function planLinesFor(plan, display, limit) {
|
|
72
|
+
const want = display.trim();
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const s of plan.sections)
|
|
75
|
+
if (s.title.trim() === want)
|
|
76
|
+
out.push(...s.lines);
|
|
77
|
+
return out.slice(0, limit);
|
|
78
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 最终文本里的「## 待裁量」「## 转交」段:剥出来给家长 / 应用,剩下的是给孩子的话。
|
|
3
|
+
* 宽容解析:段在但解析不出(缺 question、to 不合法…)就整段留在正文里——格式是增强不是门槛。
|
|
4
|
+
*/
|
|
5
|
+
import { type Handoff, type HoldupAsk } from '../schema/index.ts';
|
|
6
|
+
export interface ParsedSections {
|
|
7
|
+
/** 去掉两种固定段后的正文 */
|
|
8
|
+
body: string;
|
|
9
|
+
holdup: HoldupAsk | null;
|
|
10
|
+
handoff: Handoff | null;
|
|
11
|
+
}
|
|
12
|
+
export declare function parseSections(text: string): ParsedSections;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 最终文本里的「## 待裁量」「## 转交」段:剥出来给家长 / 应用,剩下的是给孩子的话。
|
|
3
|
+
* 宽容解析:段在但解析不出(缺 question、to 不合法…)就整段留在正文里——格式是增强不是门槛。
|
|
4
|
+
*/
|
|
5
|
+
import { HANDOFF_HEADING, HOLDUP_HEADING, HandoffSchema, HoldupAskSchema, } from "../schema/index.js";
|
|
6
|
+
const unquote = (s) => {
|
|
7
|
+
const t = s.trim();
|
|
8
|
+
return (t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")) ? t.slice(1, -1).trim() : t;
|
|
9
|
+
};
|
|
10
|
+
const H2 = /^##\s+(.+?)\s*$/;
|
|
11
|
+
const SPECIAL = new Set([HOLDUP_HEADING, HANDOFF_HEADING]);
|
|
12
|
+
/** 固定段里合法的行:key: value / 列表项 / 缩进的子键 */
|
|
13
|
+
const FIELD_LINE = /^(?:[a-z]+:\s*.*|\s*-\s+.*|\s+[a-z]+:\s*.*)$/;
|
|
14
|
+
/**
|
|
15
|
+
* 切段。普通 H2 段到下一个 H2 为止;「待裁量」「转交」这两种固定段只吃字段行——
|
|
16
|
+
* 遇到空行且下一非空行不是字段行,段就结束,后面的话回到正文(老师把给孩子的话放最后一段时不能被吞掉)。
|
|
17
|
+
*/
|
|
18
|
+
function segments(text) {
|
|
19
|
+
const lines = text.split('\n');
|
|
20
|
+
const out = [{ title: null, lines: [] }];
|
|
21
|
+
let fence = false;
|
|
22
|
+
for (let i = 0; i < lines.length; i++) {
|
|
23
|
+
const line = lines[i];
|
|
24
|
+
if (/^\s*```/.test(line))
|
|
25
|
+
fence = !fence;
|
|
26
|
+
const m = !fence && H2.exec(line);
|
|
27
|
+
if (m) {
|
|
28
|
+
out.push({ title: m[1], lines: [] });
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const cur = out[out.length - 1];
|
|
32
|
+
if (cur.title !== null && SPECIAL.has(cur.title) && !line.trim()) {
|
|
33
|
+
let j = i + 1;
|
|
34
|
+
while (j < lines.length && !lines[j].trim())
|
|
35
|
+
j++;
|
|
36
|
+
const next = lines[j];
|
|
37
|
+
if (next !== undefined && !H2.test(next) && !FIELD_LINE.test(next)) {
|
|
38
|
+
out.push({ title: null, lines: [] });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
cur.lines.push(line);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
function parseHoldupBody(lines) {
|
|
47
|
+
let question = '';
|
|
48
|
+
const options = [];
|
|
49
|
+
let inOptions = false;
|
|
50
|
+
for (const raw of lines) {
|
|
51
|
+
if (!raw.trim())
|
|
52
|
+
continue;
|
|
53
|
+
let m = /^question:\s*(.*)$/.exec(raw);
|
|
54
|
+
if (m) {
|
|
55
|
+
question = unquote(m[1]);
|
|
56
|
+
inOptions = false;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (/^options:\s*$/.test(raw)) {
|
|
60
|
+
inOptions = true;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!inOptions)
|
|
64
|
+
continue;
|
|
65
|
+
if (/^\S/.test(raw) && !raw.trimStart().startsWith('-')) {
|
|
66
|
+
inOptions = false;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
m = /^\s*-\s+label:\s*(.*)$/.exec(raw);
|
|
70
|
+
if (m) {
|
|
71
|
+
options.push({ label: unquote(m[1]) });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
m = /^\s*-\s+(.*)$/.exec(raw);
|
|
75
|
+
if (m) {
|
|
76
|
+
options.push({ label: unquote(m[1]) });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const cur = options[options.length - 1];
|
|
80
|
+
if (!cur)
|
|
81
|
+
continue;
|
|
82
|
+
m = /^\s+(label|note|recommended):\s*(.*)$/.exec(raw);
|
|
83
|
+
if (!m)
|
|
84
|
+
continue;
|
|
85
|
+
if (m[1] === 'label')
|
|
86
|
+
cur.label = unquote(m[2]);
|
|
87
|
+
else if (m[1] === 'note')
|
|
88
|
+
cur.note = unquote(m[2]);
|
|
89
|
+
else
|
|
90
|
+
cur.recommended = /^true$/i.test(m[2].trim());
|
|
91
|
+
}
|
|
92
|
+
const r = HoldupAskSchema.safeParse({ question, options: options.filter((o) => o.label) });
|
|
93
|
+
return r.success ? r.data : null;
|
|
94
|
+
}
|
|
95
|
+
function parseHandoffBody(lines) {
|
|
96
|
+
const kv = {};
|
|
97
|
+
const refs = [];
|
|
98
|
+
let inRefs = false;
|
|
99
|
+
for (const raw of lines) {
|
|
100
|
+
if (!raw.trim())
|
|
101
|
+
continue;
|
|
102
|
+
const m = /^([a-z]+):\s*(.*)$/.exec(raw);
|
|
103
|
+
if (m) {
|
|
104
|
+
inRefs = false;
|
|
105
|
+
if (m[1] === 'refs') {
|
|
106
|
+
const v = m[2].trim();
|
|
107
|
+
if (v.startsWith('[') && v.endsWith(']')) {
|
|
108
|
+
refs.push(...v.slice(1, -1).split(',').map(unquote).filter(Boolean));
|
|
109
|
+
}
|
|
110
|
+
else if (v)
|
|
111
|
+
refs.push(unquote(v));
|
|
112
|
+
else
|
|
113
|
+
inRefs = true;
|
|
114
|
+
}
|
|
115
|
+
else
|
|
116
|
+
kv[m[1]] = unquote(m[2]);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const li = /^\s*-\s+(.*)$/.exec(raw);
|
|
120
|
+
if (li && inRefs)
|
|
121
|
+
refs.push(unquote(li[1]));
|
|
122
|
+
}
|
|
123
|
+
const r = HandoffSchema.safeParse({ to: kv.to, why: kv.why, refs });
|
|
124
|
+
return r.success ? r.data : null;
|
|
125
|
+
}
|
|
126
|
+
export function parseSections(text) {
|
|
127
|
+
const segs = segments(text);
|
|
128
|
+
let holdup = null;
|
|
129
|
+
let handoff = null;
|
|
130
|
+
const keep = [];
|
|
131
|
+
for (const seg of segs) {
|
|
132
|
+
if (seg.title === HOLDUP_HEADING && !holdup) {
|
|
133
|
+
const h = parseHoldupBody(seg.lines);
|
|
134
|
+
if (h) {
|
|
135
|
+
holdup = h;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (seg.title === HANDOFF_HEADING && !handoff) {
|
|
140
|
+
const h = parseHandoffBody(seg.lines);
|
|
141
|
+
if (h) {
|
|
142
|
+
handoff = h;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (seg.title !== null)
|
|
147
|
+
keep.push(`## ${seg.title}`);
|
|
148
|
+
keep.push(...seg.lines);
|
|
149
|
+
}
|
|
150
|
+
return { body: keep.join('\n').trim(), holdup, handoff };
|
|
151
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stream-json NDJSON 转录 → 家长视图条目 + 最终文本。抄自 growth-apps 的 gen-transcript(2026-08-26 实测形状,
|
|
3
|
+
* claude 2.1.220;qwen code 同族),加两样:parent_tool_use_id 非空的事件标 sub(子代理),
|
|
4
|
+
* result.result 单独取出作 final——它是两个 CLI 都有的稳定锚点,孩子视图只看它。
|
|
5
|
+
*/
|
|
6
|
+
export interface TranscriptItem {
|
|
7
|
+
/** text 说话 / tool 用工具 / tool-error 工具报错 / done 收尾 */
|
|
8
|
+
kind: 'text' | 'tool' | 'tool-error' | 'done';
|
|
9
|
+
text: string;
|
|
10
|
+
/** 来自子代理(parent_tool_use_id 非空) */
|
|
11
|
+
sub?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export type TranscriptRow = TranscriptItem | {
|
|
14
|
+
kind: 'fold';
|
|
15
|
+
tools: TranscriptItem[];
|
|
16
|
+
};
|
|
17
|
+
export interface TranscriptFinal {
|
|
18
|
+
/** result.result;没有就是 null */
|
|
19
|
+
text: string | null;
|
|
20
|
+
ok: boolean;
|
|
21
|
+
/** 不 ok 时的原因(subtype 或 terminal_reason) */
|
|
22
|
+
reason: string | null;
|
|
23
|
+
costUsd?: number;
|
|
24
|
+
numTurns?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface Transcript {
|
|
27
|
+
sessionId: string | null;
|
|
28
|
+
items: TranscriptItem[];
|
|
29
|
+
/** 收尾;还在跑就是 null */
|
|
30
|
+
final: TranscriptFinal | null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 展示前折叠:连续 ≥3 条工具行收成一组;报错行永不折;末尾还在长的段留最后 2 条直播。
|
|
34
|
+
*/
|
|
35
|
+
export declare function foldRuns(items: TranscriptItem[]): TranscriptRow[];
|
|
36
|
+
export declare function parseTranscript(text: string): Transcript;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stream-json NDJSON 转录 → 家长视图条目 + 最终文本。抄自 growth-apps 的 gen-transcript(2026-08-26 实测形状,
|
|
3
|
+
* claude 2.1.220;qwen code 同族),加两样:parent_tool_use_id 非空的事件标 sub(子代理),
|
|
4
|
+
* result.result 单独取出作 final——它是两个 CLI 都有的稳定锚点,孩子视图只看它。
|
|
5
|
+
*/
|
|
6
|
+
const FOLD_AT = 3;
|
|
7
|
+
/**
|
|
8
|
+
* 展示前折叠:连续 ≥3 条工具行收成一组;报错行永不折;末尾还在长的段留最后 2 条直播。
|
|
9
|
+
*/
|
|
10
|
+
export function foldRuns(items) {
|
|
11
|
+
const rows = [];
|
|
12
|
+
let i = 0;
|
|
13
|
+
while (i < items.length) {
|
|
14
|
+
if (items[i].kind !== 'tool') {
|
|
15
|
+
rows.push(items[i]);
|
|
16
|
+
i++;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
let j = i;
|
|
20
|
+
while (j < items.length && items[j].kind === 'tool')
|
|
21
|
+
j++;
|
|
22
|
+
const run = items.slice(i, j);
|
|
23
|
+
const keep = j === items.length ? Math.min(2, run.length) : 0;
|
|
24
|
+
const folded = run.slice(0, run.length - keep);
|
|
25
|
+
if (folded.length >= FOLD_AT) {
|
|
26
|
+
rows.push({ kind: 'fold', tools: folded });
|
|
27
|
+
rows.push(...run.slice(run.length - keep));
|
|
28
|
+
}
|
|
29
|
+
else
|
|
30
|
+
rows.push(...run);
|
|
31
|
+
i = j;
|
|
32
|
+
}
|
|
33
|
+
return rows;
|
|
34
|
+
}
|
|
35
|
+
const clip = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
|
|
36
|
+
const blocks = (c) => (Array.isArray(c) ? c : []);
|
|
37
|
+
const blockText = (c) => (typeof c === 'string' ? c : blocks(c).map((b) => b.text ?? '').join(''));
|
|
38
|
+
function toolSummary(name, input) {
|
|
39
|
+
for (const k of ['description', 'command', 'file_path', 'skill', 'pattern', 'prompt', 'url']) {
|
|
40
|
+
const v = input?.[k];
|
|
41
|
+
if (typeof v === 'string' && v.trim())
|
|
42
|
+
return `${name} · ${clip(v.trim(), 120)}`;
|
|
43
|
+
}
|
|
44
|
+
return name;
|
|
45
|
+
}
|
|
46
|
+
export function parseTranscript(text) {
|
|
47
|
+
let sessionId = null;
|
|
48
|
+
const items = [];
|
|
49
|
+
let final = null;
|
|
50
|
+
for (const line of text.split('\n')) {
|
|
51
|
+
if (!line.trim())
|
|
52
|
+
continue;
|
|
53
|
+
let e;
|
|
54
|
+
try {
|
|
55
|
+
e = JSON.parse(line);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!sessionId && typeof e.session_id === 'string')
|
|
61
|
+
sessionId = e.session_id;
|
|
62
|
+
const sub = typeof e.parent_tool_use_id === 'string' && e.parent_tool_use_id.length > 0 ? true : undefined;
|
|
63
|
+
if (e.type === 'assistant') {
|
|
64
|
+
for (const b of blocks(e.message?.content)) {
|
|
65
|
+
if (b.type === 'text' && b.text?.trim())
|
|
66
|
+
items.push({ kind: 'text', text: b.text.trim(), sub });
|
|
67
|
+
else if (b.type === 'tool_use' && b.name)
|
|
68
|
+
items.push({ kind: 'tool', text: toolSummary(b.name, b.input), sub });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else if (e.type === 'user') {
|
|
72
|
+
for (const b of blocks(e.message?.content))
|
|
73
|
+
if (b.type === 'tool_result' && b.is_error)
|
|
74
|
+
items.push({ kind: 'tool-error', text: clip(blockText(b.content).trim(), 300), sub });
|
|
75
|
+
}
|
|
76
|
+
else if (e.type === 'result') {
|
|
77
|
+
const failed = Boolean(e.is_error) || Boolean(e.subtype && e.subtype !== 'success');
|
|
78
|
+
const resultText = typeof e.result === 'string' && e.result.trim() ? e.result.trim() : null;
|
|
79
|
+
if (resultText && !items.some((i) => i.kind === 'text' && !i.sub))
|
|
80
|
+
items.push({ kind: 'text', text: resultText });
|
|
81
|
+
const cost = typeof e.total_cost_usd === 'number' ? ` · $${e.total_cost_usd.toFixed(2)}` : '';
|
|
82
|
+
const turns = typeof e.num_turns === 'number' ? ` · ${e.num_turns} 轮` : '';
|
|
83
|
+
const reason = e.subtype && e.subtype !== 'success' ? e.subtype : (e.terminal_reason ?? '未知');
|
|
84
|
+
items.push({ kind: 'done', text: failed ? `本轮出错(${reason})${turns}${cost}` : `本轮结束${turns}${cost}` });
|
|
85
|
+
final = {
|
|
86
|
+
text: resultText,
|
|
87
|
+
ok: !failed,
|
|
88
|
+
reason: failed ? reason : null,
|
|
89
|
+
costUsd: typeof e.total_cost_usd === 'number' ? e.total_cost_usd : undefined,
|
|
90
|
+
numTurns: typeof e.num_turns === 'number' ? e.num_turns : undefined,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { sessionId, items, final };
|
|
95
|
+
}
|