forked from EduCraft/curriculum-project-hub
a2c8fa8eaf
将 provider/runner/tools 三件手搓轮子替换为 AI SDK v7 的 generateText + tool:
- 删 openrouter-provider.ts(翻译层消失)、provider.ts 自定义消息类型
- runner.ts 循环体改为 generateText({stopWhen: stepCountIs}),保留 RunRequest/RunResult 公共签名
- tools.ts ToolRegistry 改为 ToolFactory + build(ctx, names?) 按 role 白名单构建 tools record
- workspace/cph/feishu 工具改写为 tool() 定义,execute 闭包捕获 per-run ToolContext
- transcript.ts 改为 ModelMessage[] JSONL(0.0.0 cutover,无迁移)
- server.ts/trigger.ts 接 modelFactory + toolWhitelist
- 测试:helpers 写 MockLanguageModel implements LanguageModelV4;5 个测试改写 + 新增 runner.test.ts
- 移除未使用的 openai 依赖
- ADR-0017 Consequences 同步:loop 委托给 AI SDK,provider seam 是 LanguageModel
tsc 干净,54 tests 全过。
65 lines
2.5 KiB
TypeScript
65 lines
2.5 KiB
TypeScript
/**
|
|
* Session transcript — append-only JSONL file in the workspace.
|
|
*
|
|
* Each line is one AI SDK {@link ModelMessage}, JSON-encoded. The transcript lives at
|
|
* `workspaceDir/.cph/sessions/<sessionId>.jsonl`, following the same "workspace
|
|
* is a directory tree" principle as the engineering file itself (ADR-0007).
|
|
*
|
|
* This mirrors how Claude Code stores its transcript (a `.jsonl` file per
|
|
* session). The agent can read its own history via `read_file` — no special
|
|
* "load history" code path. ADR-0002's lock guarantees one run per project at a
|
|
* time, so the transcript is never concurrently written.
|
|
*
|
|
* ADR-0003: this stores **agent session messages** (prompt + response + tool
|
|
* calls produced by the Hub), NOT Feishu group chat history. The two are
|
|
* distinct; storing session messages does not conflict with "the Hub avoids
|
|
* becoming a full chat history store."
|
|
*/
|
|
import { readFile, mkdir, appendFile } from "node:fs/promises";
|
|
import { join, dirname } from "node:path";
|
|
import type { ModelMessage } from "ai";
|
|
|
|
/// Subdirectory within a workspace where session transcripts live.
|
|
export const TRANSCRIPT_DIR = ".cph/sessions";
|
|
|
|
/** Path for a session's transcript file within a workspace. */
|
|
export function transcriptPath(workspaceDir: string, sessionId: string): string {
|
|
return join(workspaceDir, TRANSCRIPT_DIR, `${sessionId}.jsonl`);
|
|
}
|
|
|
|
/**
|
|
* Read prior messages from a transcript file. Returns an empty array if the
|
|
* file doesn't exist yet (first run in this session). Corrupt lines are
|
|
* skipped — a partially-written last line (crash mid-append) won't break the
|
|
* next run.
|
|
*/
|
|
export async function readTranscript(path: string): Promise<ModelMessage[]> {
|
|
let content: string;
|
|
try {
|
|
content = await readFile(path, "utf8");
|
|
} catch {
|
|
return [];
|
|
}
|
|
const messages: ModelMessage[] = [];
|
|
for (const line of content.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (trimmed === "") continue;
|
|
try {
|
|
messages.push(JSON.parse(trimmed) as ModelMessage);
|
|
} catch {
|
|
// Skip corrupt line — likely a partial write from a crash.
|
|
}
|
|
}
|
|
return messages;
|
|
}
|
|
|
|
/**
|
|
* Append messages to a transcript file. Creates the parent directory if needed.
|
|
* Each message is written as one JSON line.
|
|
*/
|
|
export async function appendTranscript(path: string, messages: readonly ModelMessage[]): Promise<void> {
|
|
await mkdir(dirname(path), { recursive: true });
|
|
const lines = messages.map((m) => JSON.stringify(m)).join("\n") + "\n";
|
|
await appendFile(path, lines, "utf8");
|
|
}
|