feat(hub): session transcript 连续性 + slash commands (/new /resume /reset)

修复每次 @bot 都是失忆状态的问题。
- transcript.ts: JSONL 文件存 session 对话消息(workspace/.cph/sessions/
  <id>.jsonl),append-only;和 Claude Code 一致;ADR-0003 说的'不存全
  历史'是群聊历史,session 对话是 Hub 自己产生的,不冲突
- runner.ts: RunRequest 加 transcriptPath;run 开始时读历史拼进初始
  messages(system prompt 只在首条);run 结束时 append 本轮新消息
  (loadedCount 区分历史 vs 新增,不重复写);best-effort(写失败不丢
  run 结果)
- trigger.ts: slash commands 在 lock 检查之前(/new /resume /reset
  不创建 run 不需要锁);transcriptPath 传给 runner
  /new = 归档当前 session,下次 @bot 自动建新的
  /resume = 恢复最近归档的 session
  /reset = 同 /new
  未知 /cmd 透传给 agent 当普通 prompt
- unit: transcript.test.ts 读写回环/append 累积/损坏行恢复/路径(6)
- integration: trigger.test.ts 加 /new /resume /reset /unknown(4)
修了 slash command 被 lock 挡住的问题(提到 lock 之前)。
vitest run 45/45 通过;tsc rc=0。
This commit is contained in:
2026-07-07 17:50:05 +08:00
parent e60aa43731
commit 0fe6cabc68
5 changed files with 283 additions and 7 deletions
+64
View File
@@ -0,0 +1,64 @@
/**
* Session transcript — append-only JSONL file in the workspace.
*
* Each line is one {@link Message}, 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, writeFile, mkdir, appendFile } from "node:fs/promises";
import { join, dirname } from "node:path";
import type { Message } from "./provider.js";
/// 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<Message[]> {
let content: string;
try {
content = await readFile(path, "utf8");
} catch {
return [];
}
const messages: Message[] = [];
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (trimmed === "") continue;
try {
messages.push(JSON.parse(trimmed) as Message);
} 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 Message[]): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const lines = messages.map((m) => JSON.stringify(m)).join("\n") + "\n";
await appendFile(path, lines, "utf8");
}