dsh-memory-palace 1.4.1 → 1.6.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/README.md +22 -4
- package/lib/api.mjs +17 -0
- package/lib/client.js +61 -32
- package/lib/common/prompts.mjs +7 -4
- package/lib/common/records.mjs +7 -2
- package/lib/common/sections.mjs +167 -0
- package/lib/common/text.mjs +51 -5
- package/lib/distill.mjs +6 -3
- package/lib/hybrid/index.mjs +18 -0
- package/lib/hybrid/prompts.mjs +42 -0
- package/lib/hybrid/subagent.mjs +349 -0
- package/lib/hybrid/tools.mjs +327 -0
- package/lib/index.js +79 -24
- package/package.json +8 -8
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// memory-palace v1.6.0 hybrid 模式提示词:注入段二(HYBRID_PROACTIVE)+ 记忆子 agent
|
|
2
|
+
// system prompt(SUBAGENT_SYSTEM)。纯常量,无状态。
|
|
3
|
+
//
|
|
4
|
+
// 定案锚点(爹 2026-08-30 拍板):
|
|
5
|
+
// - 日志由子 agent 每轮自动维护,agent 无需重复记录过程性信息;
|
|
6
|
+
// - MEMORY.md 写入权归还 agent(主动调工具、章节化格式);
|
|
7
|
+
// - 项目级全量重整双门禁(超预算 + 冷却 ≥7 天,机器校验),用户级禁止重整;
|
|
8
|
+
// - 操作规范写在工具 description(定案 #11),本文件只做方向性引导,不堆格式细节。
|
|
9
|
+
|
|
10
|
+
// hybrid 模式注入 system prompt 的段二(替换 plugin 的记忆公民指令 / smart 的"无需主动"说明)。
|
|
11
|
+
export const HYBRID_PROACTIVE =
|
|
12
|
+
"\n\n[记忆说明] 分工如下:\n" +
|
|
13
|
+
"① 每轮结束由记忆子代理自动提炼摘要写入今日工作日志(含去重:重复/过时条目会以删除线标记),你无需调用工具记录过程性信息,也无需复述日志内容。\n" +
|
|
14
|
+
"② 长期记忆 MEMORY.md(项目级与用户级)由你主动维护:完成任务、关键决策、踩坑修复、用户偏好等 durable 事实,用 memory_write 追加(章节不存在会自动新建);过时内容用 memory_update_section 整章节精确替换或标删,绝不手写覆盖记忆文件。\n" +
|
|
15
|
+
"③ 项目级 MEMORY.md 仅在「超出注入预算 且 距上次重整 ≥ 重整冷却期」两条件同时满足时,才可调用 memory_reorganize 全量重整;重整前先读日志与现有记忆对照,切勿丢失关键信息。不满足条件时只能用 memory_update_section 做章节级修正。\n" +
|
|
16
|
+
"④ 用户级 MEMORY.md 禁止全量重整,只允许增量写入与整章节精确替换。";
|
|
17
|
+
|
|
18
|
+
// 记忆子 agent system prompt(turn/end 触发,判定 + 产出一体)。
|
|
19
|
+
// {date} 注入今日日期;回喂正文见 subagent.mjs 的首轮 user 消息。
|
|
20
|
+
export function SUBAGENT_SYSTEM() {
|
|
21
|
+
return (
|
|
22
|
+
"你是记忆子代理,负责把本轮对话沉淀进【今日工作日志】(Markdown 文件,按 `## 章节` + `- 条目` 组织)。\n" +
|
|
23
|
+
"\n" +
|
|
24
|
+
"【判定(第一优先)】先判断本轮对话是否包含值得写入日志的实质内容:完成了任务、修了 bug、做了决策、得出结论、用户表达了偏好或约束。\n" +
|
|
25
|
+
"- 无重点(闲聊、寒暄、一句话问答、无任何可沉淀信息):直接输出一行纯文本说明,不调用任何工具,立即结束。\n" +
|
|
26
|
+
"- 有重点:通过工具把格式化摘要写入日志,然后输出一行简短确认,结束。\n" +
|
|
27
|
+
"\n" +
|
|
28
|
+
"【日志写入规则(严格)】\n" +
|
|
29
|
+
"1. 只能通过 log_write_ops 工具写入,每次可批量提交多个操作;禁止请求重写整个文件。\n" +
|
|
30
|
+
"2. 三种操作:\n" +
|
|
31
|
+
" - append:章节存在则在其末尾追加条目,不存在则自动新增章节(不要强行匹配现有章节,语义确实不符就走新章节名);\n" +
|
|
32
|
+
" - new_section:显式新建章节并写入首条条目(若章节已存在会被拒绝,此时改用 append);\n" +
|
|
33
|
+
" - mark_delete:把日志中重复、过时或被本轮推翻的旧条目标记删除(删除线墓碑,非物理删除)。oldText 必须是日志中该条目的原文(可含或不含 `- ` 前缀),逐行匹配,匹配失败会被拒绝。\n" +
|
|
34
|
+
"3. 去重是你的职责:回喂的今日日志中已存在的结论禁止再次追加;发现语义重复或相互矛盾的旧条目,用 mark_delete 标记其中过时的一条(保留更完整/更新的那条)。\n" +
|
|
35
|
+
"4. 条目格式:一行一条、以结论开头、带关键细节(命令/路径/数字),客观第三人称,禁止对话体、请示、内心独白;禁止输出 [smart] 等任何标签。【entry 一律不带列表符(`- `/`* `)——插件写入时会自动补齐,你带了会变成双列表符脏数据】\n" +
|
|
36
|
+
"5. 章节名用简短的中文主题词(如 `## 环境必知`、`## 渠道映射排查`),宁多章节少混杂。\n" +
|
|
37
|
+
"6. 今日日志为空(新的一天)时直接用 append/new_section 建章节写入即可。\n" +
|
|
38
|
+
"7. 需要读取日志章节(目录模式回喂时)用 log_read_section;需要多个章节时务必在单次调用中把 sections 数组全部传齐,避免多轮往返浪费轮次。\n" +
|
|
39
|
+
"\n" +
|
|
40
|
+
"【输出】全程只做上述两件事:要么纯文本收尾,要么调工具写日志后收尾。不要向用户提问,不要输出多余解释。"
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// memory-palace v1.6.0 hybrid 模式:记忆子 agent(turn/end 触发的轻量工具循环)。
|
|
2
|
+
// 职责(定案):每轮必跑,判定+产出一体——无重点纯文本收尾(1 次调用);有重点通过循环内
|
|
3
|
+
// 白名单工具把格式化摘要写入今日工作日志(2-5 次调用)。只操作今日日志,三种操作:
|
|
4
|
+
// 新增章节 / 章节内追加 / 标记删除(~~删除线~~ 墓碑),禁止覆盖整文件与物理删除。
|
|
5
|
+
// 架构依据(v1.6.0 调研):ctx.llm.stream 支持 GenerateOptions.tools,finish.kind ===
|
|
6
|
+
// 'tool-calls' 时用 BlockAssembler.message() 回喂 assistant 消息 + createToolResultMessage
|
|
7
|
+
// 回喂工具结果,自建循环;ctx.subagents 需活 Agent 作父,插件不可用。
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { writeFile, mkdir } from "node:fs/promises";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { BlockAssembler, createUserMessage, createToolResultMessage } from "@deepseek-ai/dsh-llm";
|
|
12
|
+
import { todayISO, readMdSync } from "../common/text.mjs";
|
|
13
|
+
import { appendToSectionText, upsertSectionText, createSectionText, markEntryDeletedText, parseSections, locateSection } from "../common/sections.mjs";
|
|
14
|
+
import { HYBRID_PROACTIVE, SUBAGENT_SYSTEM } from "./prompts.mjs";
|
|
15
|
+
|
|
16
|
+
export { HYBRID_PROACTIVE };
|
|
17
|
+
|
|
18
|
+
const MAX_ROUNDS = 6; // 循环轮次上限(含首轮);超限降级
|
|
19
|
+
const SURFACE = new Set(["user/message", "assistant/message", "tool/result"]);
|
|
20
|
+
|
|
21
|
+
// 日志文件写锁(全局 promise 链串行化):子 agent 落盘与手动蒸馏按钮/其他写路径并发防护。
|
|
22
|
+
let logWriteChain = Promise.resolve();
|
|
23
|
+
function withLogLock(file, fn) {
|
|
24
|
+
const run = logWriteChain.then(fn, fn);
|
|
25
|
+
logWriteChain = run.catch(() => {});
|
|
26
|
+
return run;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 模型解析(与 distill.mjs resolveModel 同逻辑,改动须两处同步):summaryModel 注册表反查
|
|
30
|
+
// > 前缀兜底 > 会话 requestHeader 兜底。复制而非导出原因:hybrid 走独立模块定案,不碰 distill。
|
|
31
|
+
async function resolveModel(cfg, ctx, session) {
|
|
32
|
+
const sm = (cfg.summaryModel || "").trim();
|
|
33
|
+
if (sm) {
|
|
34
|
+
try {
|
|
35
|
+
const providers = typeof ctx.llm?.listProviders === "function" ? ctx.llm.listProviders() : [];
|
|
36
|
+
for (const p of providers || []) {
|
|
37
|
+
try {
|
|
38
|
+
const models = await ctx.llm.listModels(p.id);
|
|
39
|
+
const m = (models || []).find((x) => x.id === sm);
|
|
40
|
+
if (m) return { provider: m.provider, model: m.id };
|
|
41
|
+
} catch { /* 该 provider 无模型列表,跳过 */ }
|
|
42
|
+
}
|
|
43
|
+
const provider = sm.split("/")[0].trim();
|
|
44
|
+
if (provider && sm.includes("/")) return { provider, model: sm };
|
|
45
|
+
} catch { /* 注册表不可用,走会话兜底 */ }
|
|
46
|
+
}
|
|
47
|
+
const conf = session?.requestHeader?.()?.config;
|
|
48
|
+
if (conf && conf.provider && conf.model) return { provider: conf.provider, model: conf.model };
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 单次流式调用(带 tools)。finish.kind 为 error/aborted 时抛出由上层降级;tool-calls/stop 正常返回。
|
|
53
|
+
async function streamTurn(ctx, params, timeoutMs) {
|
|
54
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
55
|
+
const res = ctx.llm.stream({ ...params, signal });
|
|
56
|
+
const asm = new BlockAssembler();
|
|
57
|
+
for await (const ch of res) {
|
|
58
|
+
signal.throwIfAborted();
|
|
59
|
+
asm.push(ch);
|
|
60
|
+
}
|
|
61
|
+
const finish = asm.finish;
|
|
62
|
+
if (finish.kind === "error" || finish.kind === "aborted") {
|
|
63
|
+
throw new Error(finish.failure?.message || finish.kind);
|
|
64
|
+
}
|
|
65
|
+
return { finish, asm };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 把本轮 surface 事件投影为消息数组(seq >= fromSeq;与 distillSessionCore 同源逻辑)。
|
|
69
|
+
function projectTurnMessages(session, fromSeq) {
|
|
70
|
+
const hist = [];
|
|
71
|
+
for (const e of session.events || []) {
|
|
72
|
+
if (e.seq < fromSeq || !SURFACE.has(e.type)) continue;
|
|
73
|
+
const m = session.deriveEventMessage ? session.deriveEventMessage(e) : null;
|
|
74
|
+
if (m) hist.push(m);
|
|
75
|
+
}
|
|
76
|
+
return hist;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 今日日志回喂正文:≤ subagentLogBudget 全文;超出仅章节目录(标题 + 条目数 + 首条),
|
|
80
|
+
// 由子 agent 用 log_read_section 按需拉取(定案)。空日志(跨零点新一天)明确告知。
|
|
81
|
+
function buildLogBlock(dir, budget) {
|
|
82
|
+
const file = join(dir, `${todayISO()}.md`);
|
|
83
|
+
const text = readMdSync(file);
|
|
84
|
+
if (!text) {
|
|
85
|
+
return { file, oversized: false, body: "(今日日志为空——新的一天,直接用 append/new_section 建章节写入即可。)" };
|
|
86
|
+
}
|
|
87
|
+
if (text.length <= budget) {
|
|
88
|
+
return { file, oversized: false, body: text };
|
|
89
|
+
}
|
|
90
|
+
const { sections } = parseSections(text);
|
|
91
|
+
const toc = sections
|
|
92
|
+
.map((s) => {
|
|
93
|
+
const items = s.lines.slice(1).filter((l) => l.trim());
|
|
94
|
+
const first = items[0]?.trim().slice(0, 80) || "";
|
|
95
|
+
return `- ${s.title}(${items.length} 条,首条:${first})`;
|
|
96
|
+
})
|
|
97
|
+
.join("\n");
|
|
98
|
+
return {
|
|
99
|
+
file,
|
|
100
|
+
oversized: true,
|
|
101
|
+
body:
|
|
102
|
+
`(日志过大(${text.length} 字符),以下仅目录;需要某章节完整内容时调用 log_read_section 读取)\n${toc}`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 循环内工具执行:仅两个白名单工具,其余一律报错(模型越权时结果回喂 isError,不中断循环)。
|
|
107
|
+
async function execLoopTool(block, logFiles, cfg) {
|
|
108
|
+
const name = block.name;
|
|
109
|
+
let args = {};
|
|
110
|
+
try {
|
|
111
|
+
args = JSON.parse(block.arguments || "{}");
|
|
112
|
+
} catch {
|
|
113
|
+
return { ok: false, message: "工具参数不是合法 JSON。" };
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
if (name === "log_read_section") {
|
|
117
|
+
// v1.6.0 A 改进:一次读取多个章节(sections 数组),合并返回;兼容旧单数 section 参数。
|
|
118
|
+
const file = logFiles[0];
|
|
119
|
+
const md = readMdSync(file) || "";
|
|
120
|
+
const wants = Array.isArray(args.sections)
|
|
121
|
+
? args.sections.filter((s) => s && typeof s === "string")
|
|
122
|
+
: args.section
|
|
123
|
+
? [args.section]
|
|
124
|
+
: [];
|
|
125
|
+
if (!wants.length) return { ok: false, message: "sections 不能为空(数组,可一次传多个章节标题)。" };
|
|
126
|
+
const parts = [];
|
|
127
|
+
const missing = [];
|
|
128
|
+
for (const s of wants) {
|
|
129
|
+
const loc = locateSection(md, String(s).trim());
|
|
130
|
+
if (!loc) {
|
|
131
|
+
missing.push(String(s).trim());
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
parts.push(loc.lines.slice(loc.start, loc.end).join("\n").trim());
|
|
135
|
+
}
|
|
136
|
+
if (!parts.length) {
|
|
137
|
+
return { ok: false, message: `未找到章节:${missing.join("、")}。可用章节:${parseSections(md).sections.map((s) => s.title).join("、") || "(日志为空)"}` };
|
|
138
|
+
}
|
|
139
|
+
const note = missing.length ? `\n\n(以下章节未找到:${missing.join("、")})` : "";
|
|
140
|
+
return { ok: true, content: parts.join("\n\n---\n\n") + note };
|
|
141
|
+
}
|
|
142
|
+
if (name === "log_write_ops") {
|
|
143
|
+
const ops = Array.isArray(args.ops) ? args.ops : [];
|
|
144
|
+
if (!ops.length) return { ok: false, message: "ops 为空。" };
|
|
145
|
+
return await applyLogOps(logFiles, todayISO(), ops);
|
|
146
|
+
}
|
|
147
|
+
return { ok: false, message: `未知工具 ${name}(仅 log_read_section / log_write_ops 可用)。` };
|
|
148
|
+
} catch (e) {
|
|
149
|
+
return { ok: false, message: `执行失败:${e?.message || String(e)}` };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 把 ops 序列应用到每个日志文件(读-改-写,经全局写锁串行化)。op → sections 纯函数:
|
|
154
|
+
// append=upsert(存在追加/不存在新增章节,"不强行匹配")、new_section=已存在拒绝、
|
|
155
|
+
// mark_delete=删除线墓碑(结构行保护)。
|
|
156
|
+
function applyLogOps(logFiles, dateStr, ops) {
|
|
157
|
+
const apply = (md) => {
|
|
158
|
+
let cur = md || "";
|
|
159
|
+
const results = [];
|
|
160
|
+
for (const op of ops) {
|
|
161
|
+
if (!op || typeof op !== "object") {
|
|
162
|
+
results.push({ op: "invalid", ok: false, message: "op 非对象" });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const section = String(op.section ?? "").trim();
|
|
166
|
+
if (!section) {
|
|
167
|
+
results.push({ op: op.op, ok: false, message: "section 不能为空" });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (op.op === "append") {
|
|
171
|
+
const r = upsertSectionText(cur, section, op.entry);
|
|
172
|
+
results.push({ op: "append", section, ok: r.ok, message: r.reason });
|
|
173
|
+
if (r.ok) cur = r.text;
|
|
174
|
+
} else if (op.op === "new_section") {
|
|
175
|
+
const r = createSectionText(cur, section, op.entry);
|
|
176
|
+
results.push({ op: "new_section", section, ok: r.ok, message: r.reason });
|
|
177
|
+
if (r.ok) cur = r.text;
|
|
178
|
+
} else if (op.op === "mark_delete") {
|
|
179
|
+
const r = markEntryDeletedText(cur, section, op.oldText);
|
|
180
|
+
results.push({ op: "mark_delete", section, ok: r.ok, message: r.reason });
|
|
181
|
+
if (r.ok && r.text !== undefined) cur = r.text;
|
|
182
|
+
} else {
|
|
183
|
+
results.push({ op: op.op, ok: false, message: "未知 op(仅 append/new_section/mark_delete)" });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { cur, results };
|
|
187
|
+
};
|
|
188
|
+
return withLogLock(logFiles[0], async () => {
|
|
189
|
+
const all = [];
|
|
190
|
+
for (const file of logFiles) {
|
|
191
|
+
let md = "";
|
|
192
|
+
try {
|
|
193
|
+
md = readFileSync(file, "utf8");
|
|
194
|
+
} catch { /* 文件尚不存在 */ }
|
|
195
|
+
const { cur, results } = apply(md);
|
|
196
|
+
if (cur !== md) {
|
|
197
|
+
await mkdir(dirname(file), { recursive: true });
|
|
198
|
+
await writeFile(file, cur, "utf8");
|
|
199
|
+
}
|
|
200
|
+
all.push(...results);
|
|
201
|
+
}
|
|
202
|
+
const applied = all.filter((r) => r.ok).length;
|
|
203
|
+
return { ok: true, applied, total: all.length, results: all, date: dateStr };
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 记忆子 agent 主入口。每轮必跑(定案:不走闸门);plan 模式拦截由调用方(index.mjs _settle)负责。
|
|
208
|
+
// 返回 { ok, mode }:mode = "noop"(无重点收尾)| "written"(有落盘)| "no-tool-support";
|
|
209
|
+
// ok=false 表示整体失败(调用方降级 writeLightEntry,不推进断点)。
|
|
210
|
+
export async function runMemorySubagent({ ctx, getConfig, paths, state, session, dirs, isError }) {
|
|
211
|
+
const cfg = getConfig();
|
|
212
|
+
// 调试日志(v1.6.0):与 distill.mjs 同款门控。dbgFail=失败/跳过留痕(无条件输出,排障可观测);
|
|
213
|
+
// dbg=受 distillDebugLog 开关控制的详单(模型解析/请求参数/流进度/ops 结果计数)。
|
|
214
|
+
// 隐私红线:只打计数/字符数/元数据/错误 message,绝不打印对话或日志正文文本。
|
|
215
|
+
const dbgFail = (why, extra) => console.error(`[memory-palace] subagent skip: ${why}`, extra ? JSON.stringify(extra) : "");
|
|
216
|
+
const dbg = (why, extra) => { if (!cfg.distillDebugLog) return; console.error(`[memory-palace][debug] subagent ${why}`, extra !== undefined ? JSON.stringify(extra) : ""); };
|
|
217
|
+
if (!cfg.enabled || !session || !dirs.length) {
|
|
218
|
+
dbgFail("disabled or no session/dirs", { enabled: !!cfg.enabled, hasSession: !!session, dirs: dirs.length });
|
|
219
|
+
return { ok: false, mode: "disabled" };
|
|
220
|
+
}
|
|
221
|
+
const resolved = await resolveModel(cfg, ctx, session);
|
|
222
|
+
if (!resolved) {
|
|
223
|
+
dbgFail("no model", { summaryModel: cfg.summaryModel });
|
|
224
|
+
return { ok: false, mode: "no-model" };
|
|
225
|
+
}
|
|
226
|
+
const { provider, model } = resolved;
|
|
227
|
+
dbg("entry", { sessionId: session.id, fromSeq: state.lastSummarizedSeq, provider, model });
|
|
228
|
+
|
|
229
|
+
const hist = projectTurnMessages(session, state.lastSummarizedSeq);
|
|
230
|
+
if (!hist.length) {
|
|
231
|
+
dbg("no events in range", { fromSeq: state.lastSummarizedSeq });
|
|
232
|
+
return { ok: true, mode: "noop" };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const log = buildLogBlock(dirs[0], cfg.subagentLogBudget || 20000);
|
|
236
|
+
const logFiles = dirs.map((dir) => join(dir, `${todayISO()}.md`));
|
|
237
|
+
dbg("log feed", { file: log.file, oversized: log.oversized, chars: log.body.length, logFiles: logFiles.length });
|
|
238
|
+
|
|
239
|
+
// 首轮 user 消息:本轮对话已在 hist 中逐条保留,末尾追加日志回喂 + 任务指令。
|
|
240
|
+
const brief = isError ? "\n\n(提示:本轮对话中出现过错误信号,错误现象值得记录。)" : "";
|
|
241
|
+
const kickoff = createUserMessage({
|
|
242
|
+
content: [
|
|
243
|
+
{
|
|
244
|
+
type: "text",
|
|
245
|
+
text:
|
|
246
|
+
`【今日工作日志(${todayISO()})】\n${log.body}\n\n` +
|
|
247
|
+
`请按系统指令处理上面的本轮对话(对话见前文消息):判定是否有实质内容需要写入日志,并执行。${brief}`,
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
source: { kind: "plugin", plugin: "memory-palace" },
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const tools = [
|
|
254
|
+
{
|
|
255
|
+
name: "log_read_section",
|
|
256
|
+
description:
|
|
257
|
+
"读取今日工作日志中指定章节的完整内容(含标题行与全部条目)。sections 接受数组,可一次读取多个章节(合并返回,未找到的章节会注明)——需要多个章节时务必在单次调用中全部传入,避免多轮往返。仅在回喂为目录模式(日志过大)时需要调用;全文回喂时无需调用。",
|
|
258
|
+
parameters: {
|
|
259
|
+
type: "object",
|
|
260
|
+
additionalProperties: false,
|
|
261
|
+
properties: {
|
|
262
|
+
sections: {
|
|
263
|
+
type: "array",
|
|
264
|
+
description: "要读取的章节标题列表(不含 `## ` 前缀),可一次传多个。",
|
|
265
|
+
items: { type: "string" },
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
required: ["sections"],
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: "log_write_ops",
|
|
273
|
+
description:
|
|
274
|
+
"批量写入今日工作日志(只能操作今日日志)。ops 数组,每个元素:\n" +
|
|
275
|
+
'- {"op":"append","section":"章节名","entry":"条目文本(一行,结论先行+关键细节,【不带 - 前缀】——插件会自动补)"}:章节存在则末尾追加,不存在则自动新增章节(不要强行匹配语义不符的章节);\n' +
|
|
276
|
+
'- {"op":"new_section","section":"新章节名","entry":"首条条目(不带 - 前缀)"}:显式新建章节(已存在会拒绝,改用 append);\n' +
|
|
277
|
+
'- {"op":"mark_delete","section":"章节名","oldText":"日志中该条目原文(可带或不带 - 前缀)"}:把重复/过时/被推翻的旧条目标记为删除线墓碑(非物理删除),oldText 逐行匹配,匹配失败会被拒绝;\n' +
|
|
278
|
+
"一条消息可带多个 ops 一次提交;entry 为客观第三人称单行文本(不带列表符),禁止对话体与任何标签。",
|
|
279
|
+
parameters: {
|
|
280
|
+
type: "object",
|
|
281
|
+
additionalProperties: false,
|
|
282
|
+
properties: {
|
|
283
|
+
ops: {
|
|
284
|
+
type: "array",
|
|
285
|
+
description: "日志写操作数组(见 description)。",
|
|
286
|
+
items: {
|
|
287
|
+
type: "object",
|
|
288
|
+
additionalProperties: false,
|
|
289
|
+
properties: {
|
|
290
|
+
op: { type: "string", description: "append | new_section | mark_delete" },
|
|
291
|
+
section: { type: "string", description: "章节标题(不含 ## 前缀)。" },
|
|
292
|
+
entry: { type: "string", description: "append/new_section 的条目文本(一行)。" },
|
|
293
|
+
oldText: { type: "string", description: "mark_delete 的目标条目原文。" },
|
|
294
|
+
},
|
|
295
|
+
required: ["op", "section"],
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
required: ["ops"],
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
|
|
304
|
+
const system = SUBAGENT_SYSTEM();
|
|
305
|
+
const messages = [...hist, kickoff];
|
|
306
|
+
const timeoutMs = cfg.summaryTimeoutMs || 60000;
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
310
|
+
const { finish, asm } = await streamTurn(ctx, { provider, model, system, messages, tools }, timeoutMs);
|
|
311
|
+
if (finish.kind === "stop") {
|
|
312
|
+
state.lastSummarizedSeq = session.seq;
|
|
313
|
+
dbg("done", { round, mode: round === 0 ? "noop" : "written" });
|
|
314
|
+
return { ok: true, mode: round === 0 ? "noop" : "written" };
|
|
315
|
+
}
|
|
316
|
+
if (finish.kind !== "tool-calls") {
|
|
317
|
+
// max-tokens 等异常收尾:返回失败,断点不推进——下一次 turn/end 子代理自动补蒸。
|
|
318
|
+
// v1.6.0 定案:不降级 writeLightEntry(无格式原文会破坏日志章节化结构),宁缺勿滥。
|
|
319
|
+
dbgFail(`finish ${finish.kind}`, { round });
|
|
320
|
+
return { ok: false, mode: `finish-${finish.kind}` };
|
|
321
|
+
}
|
|
322
|
+
messages.push(asm.message({ kind: "model", provider, model }));
|
|
323
|
+
const calls = asm.blocks().filter((b) => b.type === "tool-call");
|
|
324
|
+
dbg("tool-calls round", { round, calls: calls.length, names: calls.map((c) => c.name) });
|
|
325
|
+
for (const call of calls) {
|
|
326
|
+
const result = await execLoopTool(call, logFiles, cfg);
|
|
327
|
+
const body = result.ok && result.content !== undefined ? String(result.content) : JSON.stringify(result);
|
|
328
|
+
messages.push(
|
|
329
|
+
createToolResultMessage({
|
|
330
|
+
callId: call.id,
|
|
331
|
+
content: [{ type: "text", text: body }],
|
|
332
|
+
isError: !result.ok,
|
|
333
|
+
}),
|
|
334
|
+
);
|
|
335
|
+
if (!result.ok) dbgFail(`tool ${call.name} rejected`, { message: result.message, opApplied: result.applied });
|
|
336
|
+
else dbg("tool ok", { name: call.name, applied: result.applied, total: result.total });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
dbgFail("max-rounds exceeded", { rounds: MAX_ROUNDS });
|
|
340
|
+
return { ok: false, mode: "max-rounds" };
|
|
341
|
+
} catch (e) {
|
|
342
|
+
if (e?.name === "TimeoutError" || /timeout|abort/i.test(String(e?.message))) {
|
|
343
|
+
dbgFail("timeout", { timeoutMs });
|
|
344
|
+
return { ok: false, mode: "timeout" };
|
|
345
|
+
}
|
|
346
|
+
dbgFail("error", { message: e?.message || String(e) });
|
|
347
|
+
return { ok: false, mode: "error", message: e?.message || String(e) };
|
|
348
|
+
}
|
|
349
|
+
}
|