dsh-memory-palace 1.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.
@@ -0,0 +1,78 @@
1
+ // memory-palace 记忆文件路径解析(工厂)。
2
+ // 全部函数接受可选 cwd(缺省用运行时 activeCwd)——按钮蒸馏 route 按 session.header.cwd 显式传参,
3
+ // 避免依赖闭包 activeCwd 造成「会话 cwd ≠ 活跃 cwd」时写错位置(v1.2.0 修过的手动蒸馏 bug)。
4
+ import { join, dirname } from "node:path";
5
+ import { existsSync } from "node:fs";
6
+
7
+ /**
8
+ * @param {() => object} getConfig - 读取当前生效配置(settings 热读)。
9
+ * @param {() => string | null} [getCwd] - 默认 cwd 读取器(运行时 activeCwd)。
10
+ */
11
+ export function createPaths(getConfig, getCwd = () => null) {
12
+ const cfg = () => getConfig();
13
+ const activeCwd = () => getCwd() || null;
14
+
15
+ // 磁盘上【已存在】的 buddy 项目记忆目录(按配置顺序)。绝不主动新建 buddy 目录。
16
+ function buddyDirs(cwd = activeCwd()) {
17
+ const c = cfg();
18
+ if (!c.enabled || !c.bridgeBuddyMemory || !cwd) return [];
19
+ return c.buddyWorkspaceMemoryDirs
20
+ .map((rel) => join(cwd, rel))
21
+ .filter((d) => existsSync(d));
22
+ }
23
+ // 读取源:存在 buddy 目录则用它们;否则仅当 legacy dsh 目录已存在才读(不创建)。
24
+ function readDirs(cwd = activeCwd()) {
25
+ const c = cfg();
26
+ if (!c.enabled) return [];
27
+ const b = buddyDirs(cwd);
28
+ if (b.length) return b;
29
+ const d = cwd ? join(cwd, c.workspaceMemoryDir) : null;
30
+ // 不设 existsSync 门槛:即便每日日志目录尚未创建,也要能读到同级的 .deepseek-harness/MEMORY.md
31
+ return d ? [d] : [];
32
+ }
33
+ // 写入目标:存在 buddy 目录则全部同步写入;否则回退 dsh 目录(按需创建)。
34
+ function writeDirs(cwd = activeCwd()) {
35
+ const c = cfg();
36
+ if (!c.enabled) return [];
37
+ const b = buddyDirs(cwd);
38
+ if (b.length) return b;
39
+ const d = cwd ? join(cwd, c.workspaceMemoryDir) : null;
40
+ return d ? [d] : [];
41
+ }
42
+
43
+ // 项目级 MEMORY.md 路径解析:
44
+ // - dsh 原生目录(workspaceMemoryDir,默认 .deepseek-harness/memory):MEMORY.md 与 memory/ 同级
45
+ // = join(cwd, dirname(workspaceMemoryDir), "MEMORY.md"),即 .deepseek-harness/MEMORY.md
46
+ // (读取兼容:旧嵌套位置 .deepseek-harness/memory/MEMORY.md 仍存在时不丢数据)
47
+ // - buddy 目录(.workbuddy/memory 等):保持嵌套 memory/MEMORY.md(与 WB/CB 原生格式兼容)
48
+ function dshDailyDir(cwd = activeCwd()) {
49
+ const c = cfg();
50
+ return cwd ? join(cwd, c.workspaceMemoryDir) : null;
51
+ }
52
+ function dshMemoryFile(cwd = activeCwd()) {
53
+ const c = cfg();
54
+ return cwd ? join(cwd, dirname(c.workspaceMemoryDir), "MEMORY.md") : null;
55
+ }
56
+ function isDshDir(dir, cwd = activeCwd()) {
57
+ const d = dshDailyDir(cwd);
58
+ return !!(d && dir && dir === d);
59
+ }
60
+ // 写入用:返回该目录对应的 MEMORY.md 绝对路径(dsh 原生→同级;buddy→嵌套;已是文件则原样返回)
61
+ function memoryFileOf(dir, cwd = activeCwd()) {
62
+ if (!dir) return dir;
63
+ if (dir.endsWith("MEMORY.md")) return dir; // 用户级路径本身即文件
64
+ return isDshDir(dir, cwd) ? dshMemoryFile(cwd) : join(dir, "MEMORY.md");
65
+ }
66
+ // 读取用:dsh 原生返回 [同级, 旧嵌套] 两个候选;buddy 仅嵌套;已是文件则原样返回
67
+ function memoryReadCandidates(dir, cwd = activeCwd()) {
68
+ if (!dir) return [];
69
+ if (dir.endsWith("MEMORY.md")) return [dir];
70
+ if (isDshDir(dir, cwd)) {
71
+ const f = dshMemoryFile(cwd);
72
+ return f ? [f, join(dir, "MEMORY.md")] : [join(dir, "MEMORY.md")];
73
+ }
74
+ return [join(dir, "MEMORY.md")];
75
+ }
76
+
77
+ return { buddyDirs, readDirs, writeDirs, dshDailyDir, dshMemoryFile, isDshDir, memoryFileOf, memoryReadCandidates };
78
+ }
@@ -0,0 +1,71 @@
1
+ // memory-palace 共享常量:场景关键词 + 两个 LLM prompt(智能模式摘要 / 项目记忆蒸馏)。
2
+ // 纯常量模块,无状态、可被任意模块与单测直接引用。
3
+
4
+ // 场景关键词(增强原方案 D):命中即视为"用户告知偏好 / 做出技术决策",纯文本轮次也写。
5
+ export const SCENE_KEYWORDS = ["记住", "记一下", "remind", "偏好", "决定", "以后都", "约定", "采用", "根因", "修复"];
6
+
7
+ // 智能模式(memoryMode==="smart")的摘要提炼 prompt:让 LLM 输出 {summary, durable:[{scope,fact}]}。
8
+ // 产物:summary → 每日日志(带 [smart] 标记);durable → MEMORY.md(- [smart] <fact>,按 scope 分层)。
9
+ export const SUMMARY_PROMPT =
10
+ "你是会话记忆提炼助手。根据下面这段【新产生的】对话(含工具调用与结果),提炼两样东西:\n" +
11
+ "1) summary:一段简洁中文摘要——这段对话做了什么、关键结果(路径/命令/数字)。\n" +
12
+ "2) durable:值得跨 session 长期记住的事实列表(最多 3 条),每条带 scope:\n" +
13
+ ' - "project" = 当前项目的约定/决策/技术事实\n' +
14
+ ' - "user" = 跨项目的个人偏好/习惯\n' +
15
+ " 闲聊、一次性操作、显而易见的内容、纯错误现象(错误已有独立记录)不要提炼;没有就留空数组。\n" +
16
+ "只输出 JSON,不要多余文字,格式:\n" +
17
+ '{"summary":"...","durable":[{"scope":"project","fact":"一句话"}]}';
18
+
19
+ // v1.2.0 项目记忆蒸馏 prompt(固化自 upgrade plan/v1.2.0/记忆蒸馏Prompt.md,供按钮「蒸馏项目记忆」稳定调用)。
20
+ // 用法:system = 本 prompt,user = 项目 MEMORY.md 全文,输出 = 精炼后的项目记忆(直接覆盖写回)。
21
+ export const DISTILL_PROMPT =
22
+ "你是一名「项目记忆蒸馏器」。我会给你一份项目的原始材料(可能是记忆文件、会话记录、零散笔记,或多份材料的拼合)。你的任务:把它提炼成一段精炼的项目记忆。\n" +
23
+ "\n" +
24
+ "【提炼总原则】\n" +
25
+ "只保留「以后还会用到」的信息,删掉「一次性过程」。\n" +
26
+ "唯一判断标准:删了这条,下次开工会不会出错 / 返工 / 重踩坑?会 → 留;不会 → 删。\n" +
27
+ "\n" +
28
+ "【只提炼 7 类信息,无关的类别直接省略,不要硬凑】\n" +
29
+ "1. 定位 —— 一句话说清任务本质 + 最终产出什么。\n" +
30
+ "2. 当前状态 —— 现在推进到哪一步、卡在哪、下一步是什么。\n" +
31
+ "3. 核心约束 —— 什么绝对不能变。注意:丢了会出事的才叫约束,丢了只是不方便的是细节,细节不写。\n" +
32
+ "4. AI 行为边界 —— AI 能做什么、绝不能做什么、什么留给用户。所有「谁来做 / 谁授权 / 谁拍板」的约定都要显式归入这里,不能混在别的条目里。\n" +
33
+ "5. 关键路径 —— 绝对路径、命令、数据源、模板、依赖。原样精确保留,禁止用占位符。\n" +
34
+ "6. 决策记录 —— 为什么这么做(而非只记做了什么),尽量带日期或版本锚点。废弃方案单独标注为「已废弃」,不要删除。\n" +
35
+ "7. 坑与教训 —— 现象 → 根因 → 解法,一条一个坑。一句话能讲清的坑不算坑、不记。\n" +
36
+ "\n" +
37
+ "【写法规则】\n" +
38
+ "- 结论先行:一条 = 结论 + 关键参数,不写推导过程。\n" +
39
+ "- 强动词:约束/边界用「必须 / 绝不 / 勿 / 除非…明确同意」这类词,不留模糊空间。\n" +
40
+ "- 精确值:路径、命令、字段名、版本号原样保留,禁用 ~/、占位符或「大概」。\n" +
41
+ "- 时间锚点:决策和坑尽量带日期或版本,让人知道这是哪个阶段的结论。\n" +
42
+ "\n" +
43
+ "【输出格式】严格按以下骨架输出,无关章节整节删除:\n" +
44
+ "\n" +
45
+ "# <项目名> 项目笔记\n" +
46
+ "<一句话定位 + 当前状态>\n" +
47
+ "\n" +
48
+ "## 核心约束\n" +
49
+ "- ...\n" +
50
+ "\n" +
51
+ "## AI 行为边界\n" +
52
+ "- ...\n" +
53
+ "\n" +
54
+ "## 关键路径\n" +
55
+ "- ...\n" +
56
+ "\n" +
57
+ "## 决策记录\n" +
58
+ "- ...\n" +
59
+ "\n" +
60
+ "## 坑与教训\n" +
61
+ "- ...\n" +
62
+ "\n" +
63
+ "【输出前自检,逐条过一遍】\n" +
64
+ "1. 有没有混入一次性过程 / 临时路径 / 某次报错全文?\n" +
65
+ "2. 每条「约束」删了会不会出事?\n" +
66
+ "3. 边界是否显式、无歧义、没有混在别的条目里?\n" +
67
+ "4. 路径 / 命令 / 版本号是否精确?\n" +
68
+ "5. 每个坑是否「现象 → 根因 → 解法」三要素齐全?\n" +
69
+ "6. 是否只输出了骨架要求的章节,没有额外发挥?\n" +
70
+ "\n" +
71
+ "现在开始处理我提供的材料。";
@@ -0,0 +1,209 @@
1
+ // memory-palace 记忆记录读写(去重追加 / 匹配删除 / 每日日志 / 迁移 / 轻量条目 / 错误捕获)。
2
+ // 纯函数直接导出;依赖配置/路径解析的写入类函数经 createRecords 工厂注入。
3
+ import { mkdir, readdir, unlink, readFile, writeFile } from "node:fs/promises";
4
+ import { readFileSync } from "node:fs";
5
+ import { join, dirname } from "node:path";
6
+ import { todayISO, expandHome, readMdSync, toHomeShort, normLine, isStructural } from "./text.mjs";
7
+
8
+ // 追加一行记忆,若目标文件已包含相同内容则跳过(去重),返回是否实际写入。
9
+ export async function appendLineDedup(file, line) {
10
+ await mkdir(dirname(file), { recursive: true });
11
+ let cur = "";
12
+ try {
13
+ cur = readFileSync(file, "utf8");
14
+ } catch {
15
+ /* 文件尚不存在 */
16
+ }
17
+ if (cur.includes(line)) return false;
18
+ await writeFile(file, `${cur}\n${line}\n`, "utf8");
19
+ return true;
20
+ }
21
+
22
+ // 在文件中按内容匹配查找记忆条目行(不修改文件)。返回每条匹配:{tier,path,content}。
23
+ export function findMatches(file, match, tier, pathShort) {
24
+ const m = (match || "").trim();
25
+ if (!m) return [];
26
+ let cur = "";
27
+ try {
28
+ cur = readFileSync(file, "utf8");
29
+ } catch {
30
+ return [];
31
+ }
32
+ const nm = normLine(m);
33
+ const out = [];
34
+ for (const raw of cur.split("\n")) {
35
+ const t = raw.trim();
36
+ if (!t || isStructural(t)) continue;
37
+ if (normLine(t).includes(nm)) out.push({ tier, path: pathShort, content: t });
38
+ }
39
+ return out;
40
+ }
41
+
42
+ // 按内容匹配删除 MEMORY.md / 每日日志中的条目行;保护结构行;返回删除条数与被删内容明细。
43
+ export async function removeLineByMatch(file, match) {
44
+ const m = (match || "").trim();
45
+ if (!m) return { removed: 0, ok: false, reason: "empty-match", lines: [] };
46
+ let cur = "";
47
+ try {
48
+ cur = readFileSync(file, "utf8");
49
+ } catch {
50
+ return { removed: 0, ok: true, reason: "no-file", lines: [] };
51
+ }
52
+ const nm = normLine(m);
53
+ const lines = cur.split("\n");
54
+ const kept = [];
55
+ let removed = 0;
56
+ const removedLines = [];
57
+ for (const line of lines) {
58
+ const t = line.trim();
59
+ if (!t) {
60
+ kept.push(line);
61
+ continue;
62
+ }
63
+ if (isStructural(t)) {
64
+ kept.push(line);
65
+ continue;
66
+ }
67
+ if (normLine(t).includes(nm)) {
68
+ removed++;
69
+ removedLines.push(t);
70
+ continue;
71
+ }
72
+ kept.push(line);
73
+ }
74
+ if (removed > 0) await writeFile(file, kept.join("\n"), "utf8");
75
+ return { removed, ok: true, reason: "done", lines: removedLines };
76
+ }
77
+
78
+ // 读取目录下最近的非今日每日日志日期(最多 limit 份,降序)。供删除范围圈定。
79
+ export async function recentLogDates(dir, limit = 3) {
80
+ let files = [];
81
+ try {
82
+ files = await readdir(dir);
83
+ } catch {
84
+ return [];
85
+ }
86
+ const today = todayISO();
87
+ return files
88
+ .map((f) => /^(\d{4}-\d{2}-\d{2})\.md$/.exec(f))
89
+ .filter((mm) => mm && mm[1] < today)
90
+ .map((mm) => mm[1])
91
+ .sort()
92
+ .reverse()
93
+ .slice(0, limit);
94
+ }
95
+
96
+ /**
97
+ * 依赖配置/路径解析的记录写入类函数工厂。
98
+ * @param {{ getConfig: () => object, paths: ReturnType<import("./paths.mjs").createPaths> }} deps
99
+ */
100
+ export function createRecords({ getConfig, paths }) {
101
+ const cfg = () => getConfig();
102
+
103
+ async function appendDaily(dir, entry) {
104
+ await mkdir(dir, { recursive: true });
105
+ const file = join(dir, `${todayISO()}.md`);
106
+ let existing = "";
107
+ try {
108
+ existing = readFileSync(file, "utf8");
109
+ } catch {
110
+ /* 文件尚不存在 */
111
+ }
112
+ // v1.1.0:每日日志按天分割,文件头写当天日期(# YYYY-MM-DD),条目不再每条带时间戳。
113
+ // 新文件 → 标题+条目;旧文件(无当天一级标题,含历史 `## 时间戳` 条目)→ 补标题到文件头。
114
+ const todayTitle = `# ${todayISO()}`;
115
+ const trimmed = existing.trim();
116
+ if (!trimmed) {
117
+ await writeFile(file, `${todayTitle}\n\n${entry}`, "utf8");
118
+ } else {
119
+ const hasTitle = existing.split("\n").some((l) => l.trim() === todayTitle);
120
+ await writeFile(file, (hasTitle ? "" : `${todayTitle}\n\n`) + existing + entry, "utf8");
121
+ }
122
+ await prune(dir);
123
+ }
124
+
125
+ async function prune(dir) {
126
+ const c = cfg();
127
+ const cutoff = Date.now() - c.dailyLogRetentionDays * 86400000;
128
+ let files = [];
129
+ try {
130
+ files = await readdir(dir);
131
+ } catch {
132
+ return;
133
+ }
134
+ const mem = paths.memoryFileOf(dir);
135
+ for (const f of files) {
136
+ const m = /^(\d{4}-\d{2}-\d{2})\.md$/.exec(f);
137
+ if (!m) continue;
138
+ if (new Date(m[1]).getTime() < cutoff) {
139
+ const old = join(dir, f);
140
+ const text = await readFile(old, "utf8").catch(() => "");
141
+ if (text) {
142
+ let cur = "";
143
+ try {
144
+ cur = readFileSync(mem, "utf8");
145
+ } catch {
146
+ /* 尚无 MEMORY.md */
147
+ }
148
+ await mkdir(dirname(mem), { recursive: true });
149
+ await writeFile(mem, `${cur}\n<!-- distilled from ${f} -->\n${text}`, "utf8");
150
+ }
151
+ await unlink(old).catch(() => {});
152
+ }
153
+ }
154
+ }
155
+
156
+ // 读取目录里最近的(非今日)每日日志,拼成一段供 memory_read 展示(最多 3 份)。
157
+ async function recentLogs(dir) {
158
+ let files = [];
159
+ try {
160
+ files = await readdir(dir);
161
+ } catch {
162
+ return "";
163
+ }
164
+ const today = todayISO();
165
+ const dated = files
166
+ .map((f) => /^(\d{4}-\d{2}-\d{2})\.md$/.exec(f))
167
+ .filter((m) => m && m[1] < today)
168
+ .map((m) => m[1])
169
+ .sort()
170
+ .reverse()
171
+ .slice(0, 3);
172
+ const parts = [];
173
+ for (const date of dated) {
174
+ const text = readMdSync(join(dir, `${date}.md`));
175
+ if (text) parts.push(`# 日志 ${date} @ ${toHomeShort(dir)}\n${text.slice(0, cfg().workspaceBudgetChars)}`);
176
+ }
177
+ return parts.join("\n\n");
178
+ }
179
+
180
+ // 轻量结构化条目(无 LLM):截断原始文本,错误带 [ERROR] 前缀。
181
+ async function writeLightEntry(dirs, turn, isError) {
182
+ const userText = turn.find((b) => b.role === "user")?.text ?? "";
183
+ const asstText = turn.filter((b) => b.role === "assistant").map((b) => b.text).join(" ");
184
+ const summary = (userText || asstText || "(no message captured)").slice(0, 200);
185
+ const tag = isError ? "[ERROR] " : "";
186
+ const entry = `\n${tag}${summary}\n\n`;
187
+ for (const dir of dirs) await appendDaily(dir, entry);
188
+ }
189
+
190
+ // 错误捕获(目标2):按 scope 落对应 MEMORY.md,条目仅含「错误现象」。
191
+ // (v0.7.1 起不再调 LLM 生成方案——「根因/绕过手法」由 agent 按记忆公民指令场景①主动调 memory_note 记。)
192
+ async function captureError(turn, reason) {
193
+ const c = cfg();
194
+ const dirs = paths.writeDirs();
195
+ if (!dirs.length && !expandHome(c.userMemoryPath)) return;
196
+ const scope = dirs.length ? "project" : "user";
197
+ const errText = (reason?.message || reason?.error?.message || "(in-session error, no message)")
198
+ .toString()
199
+ .slice(0, 300);
200
+ const line = `- [${new Date().toISOString().slice(0, 10)}] in-session 错误(${scope}):${errText}`;
201
+ const targets = dirs.length ? dirs : [expandHome(c.userMemoryPath)];
202
+ for (const dir of targets) {
203
+ const mem = paths.memoryFileOf(dir);
204
+ await appendLineDedup(mem, line).catch(() => {});
205
+ }
206
+ }
207
+
208
+ return { appendDaily, prune, recentLogs, writeLightEntry, captureError };
209
+ }
@@ -0,0 +1,120 @@
1
+ // memory-palace 共享纯函数:日期/时间戳、路径简写、文本提取、预算截断、行归一化。
2
+ // 全部无状态、纯函数,可被任意模块与单测直接引用。
3
+
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { readFileSync } from "node:fs";
7
+
8
+ // 本地日期(YYYY-MM-DD)。绝不能再用 toISOString()——它返回 UTC 日期,本地 0-8 点会
9
+ // 把记忆写到"昨天"的日志(真机实测:本地 2026-08-18 00:26 的条目写进了 2026-08-17.md)。
10
+ export const todayISO = () => {
11
+ const d = new Date();
12
+ const m = String(d.getMonth() + 1).padStart(2, "0");
13
+ const day = String(d.getDate()).padStart(2, "0");
14
+ return `${d.getFullYear()}-${m}-${day}`;
15
+ };
16
+
17
+ // 本地时间戳(YYYY-MM-DDTHH-mm-ss,`:` 换 `-` 兼容 Windows 文件名)。
18
+ // 同 todayISO 的教训:必须用本地时间,不能用 toISOString()(UTC 会差 8 小时)。
19
+ // 供项目记忆蒸馏备份文件名(MEMORY.md.{时间戳})使用。
20
+ export const nowStamp = () => {
21
+ const d = new Date();
22
+ const p = (n) => String(n).padStart(2, "0");
23
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`;
24
+ };
25
+
26
+ // 自己展开 ~,避免依赖 dsh-home-paths 的解析负担(homedir 即可)。
27
+ export function expandHome(p) {
28
+ if (!p) return p;
29
+ if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
30
+ return join(homedir(), p.slice(1));
31
+ }
32
+ return p;
33
+ }
34
+
35
+ // 反向简写:home 下的绝对路径 → ~ 简写(统一正斜杠)。AI 转述绝对路径极易拼错
36
+ // (实测出现过 lovezi0.deepseek-harness 缺分隔符),一律喂给它 ~ 简写。
37
+ export function toHomeShort(p) {
38
+ if (!p) return p;
39
+ const h = homedir();
40
+ if (p === h) return "~";
41
+ if (p.startsWith(h + "\\") || p.startsWith(h + "/")) {
42
+ return "~" + p.slice(h.length).replace(/\\/g, "/");
43
+ }
44
+ return p;
45
+ }
46
+
47
+ // 读取 Markdown 文件(同步,systemPrompt section 要求同步)。
48
+ export function readMdSync(path) {
49
+ if (!path) return "";
50
+ try {
51
+ return readFileSync(path, "utf8").trim();
52
+ } catch {
53
+ return "";
54
+ }
55
+ }
56
+
57
+ // v1.2.0 预算截断:注入 system prompt 时按字符上限截断(保留头部 + 截断标记)。
58
+ // budget <= 0 / 未配置视为不限。userBudgetChars / workspaceBudgetChars 在 text() 注入路径生效,
59
+ // 修复 v1.1.x 之前两配置为死配置的问题(与 README「注入系统提示词的长度上限」语义对齐)。
60
+ export function budgetClip(text, budget) {
61
+ if (!text || !budget || budget <= 0) return text;
62
+ if (text.length <= budget) return text;
63
+ return text.slice(0, budget) + `\n…(已截断,原 ${text.length} 字符,仅注入前 ${budget} 字符)`;
64
+ }
65
+
66
+ // 行归一化:去掉 `- `/`* ` 前缀、合并空白、转小写,使"带不带前缀"的子串匹配都鲁棒。
67
+ export const normLine = (s) => s.replace(/^[-*]\s+/, "").replace(/\s+/g, " ").trim().toLowerCase();
68
+
69
+ // 结构行(标题 / 蒸馏注释)受保护,永不被删除。
70
+ export const isStructural = (t) => t.startsWith("#") || t.startsWith("<!--");
71
+
72
+ // 从单个 content block 抽取文本。兼容两类:
73
+ // - text block:顶层 {type:"text", text:"..."}(user/assistant 消息)
74
+ // - tool-result block:{type:"tool-result", content: ContentBlock[]},文本嵌套在 content 内(真机 tool/result 即如此,
75
+ // 若只取 .text 会取空——这是此前「工具任务轮次不落记忆」的根因)
76
+ export function blockText(x) {
77
+ if (typeof x === "string") return x;
78
+ if (!x || typeof x !== "object") return "";
79
+ if (typeof x.text === "string") {
80
+ // 丢弃 harness 注入的运行时上下文快照块(真机 assistant/message 常以一个
81
+ // "Current runtime context. This snapshot supersedes…" 开头的独立 text block 出现),
82
+ // 避免把 DSH file policy 等系统噪声记进记忆。
83
+ if (/^Current runtime context\./i.test(x.text)) return "";
84
+ return x.text;
85
+ }
86
+ if (Array.isArray(x.content)) return x.content.map(blockText).join("");
87
+ return "";
88
+ }
89
+
90
+ export function extractText(event) {
91
+ try {
92
+ const d = event?.data ?? {};
93
+ // user/message 的 event.data 直接是 UserMessage(无外层包装)→ 走 d.content;
94
+ // assistant/message、tool/result 的 event.data 带 {message: ...} → 走 d.message.content。
95
+ const c = d.message?.content ?? d.content ?? d.text;
96
+ let text;
97
+ if (typeof c === "string") text = c;
98
+ else if (Array.isArray(c)) text = c.map(blockText).join("");
99
+ else if (c && typeof c === "object") text = JSON.stringify(c);
100
+ else text = "";
101
+ // 剥离 harness 注入的 <system-reminder>…</system-reminder> 系统块(实测 hello 轮次会抓到可用 skills 列表),
102
+ // 避免把系统噪声记进记忆 / 误命中关键词。runtime-context 快照块在 blockText 层按块丢弃。
103
+ return text.replace(/<system-reminder[\s\S]*?<\/system-reminder>/gi, "").trim();
104
+ } catch {
105
+ return "";
106
+ }
107
+ }
108
+
109
+ // 从工具执行结果中识别错误。in-session 错误未必以 turn/end 的 reason.kind==='error' 暴露——
110
+ // 代码运行报错、工具执行失败常以 tool/result 形式呈现,agent 看到错误后正常收尾(reason.kind 为 completed),
111
+ // 故需单独扫描工具结果文本。命中即视为错误信号(受 autoCaptureErrors 门控)。
112
+ export function extractToolErrorText(turn) {
113
+ for (const b of turn) {
114
+ if (b.role !== "tool") continue;
115
+ if (/code run failed|exception[:\s]|referenceerror|typeerror|syntaxerror|error:\s|traceback|执行失败|运行出错|运行报错/i.test(b.text)) {
116
+ return b.text.slice(0, 300);
117
+ }
118
+ }
119
+ return null;
120
+ }