feat(role): 召回統計、整理摘要歷史、臨時授權到期;還原誤刪的共用行為文件
依 OpenClaw 記憶架構的參考逐項評估後,採納三項並補上一個誤刪。 一、recall 命中時記錄召回(原本完全沒有) 實測 recall 寫回動作數為 0、hits 分布幾乎都是初始值,導致常被查詢的記憶與 從未用過的在遺忘判斷時待遇相同。 - 新增 touchMemory():只對實際輸出的前 N 則 hits +1 並更新 last_replayed 二、整理摘要保留歷史(對應 OpenClaw 的 DREAMS.md) state.json 的 last_sleep_digest 是單一欄位,每次整理直接覆寫,歷史過程全部遺失。 - 新增 DIGESTS.md:追加時間、摘要與套用結果,最新在上,保留最近 100 次,不注入 context 三、臨時授權會過期(對應 OpenClaw 的操作敏感型邊界) 原本沒有到期概念,使用者的一次性授權可能被整理成長期規則而導致日後越權。 - 記憶格式新增 expires;支援日期(自動判斷)與條件文字(標示由角色判斷) - 過期者不注入(分類與 inbox 兩處皆排除),memoryHint 標示有效範圍 - forget 優先淘汰過期項且不受分類限制 - 整理與濃縮提示詞都要求臨時授權必填,並列出「這次/先/暫時/今天」等判斷提示 - 修掉兩個會讓功能等於零的漏洞:FIELD_PATTERN 白名單沒有 EXPIRES(該行被當成 CONTENT 吃掉)、cmdWrite 的 meta 未帶 expires 與 cues 四、還原前次誤刪的共用行為文件(重要) 上一個 commit 替換「角色檔標準格式」章節時,以「找開頭到下一個標記」整段取代, 未檢查被切掉的範圍內容,連帶刪掉了 JSC-ROLE-COMMON 共用行為區塊共 87 行 47 條規則, 以及記憶章節的技能再現與關係狀態說明。 實際行為未受影響(規則真正生效處是 role_load.sh,完好無損),但 SKILL.md 是那些 規則的唯一文件來源,刪掉等於文件遺失。已自 git 完整還原並補上本次三項說明。 教訓:整段替換文件前必須先確認被切掉的範圍內有什麼。 未採納並記錄理由:向量/語意搜尋(需外部 embedding API,違反零依賴原則)、 外掛槽位(過度設計)、每日筆記日期檔(近期對話交接已用逐字對話解決且保真度更高)。 SQLite 索引記錄為未來觸發點:實測 45 則記憶 recall 耗時 196ms,訂在超過 300 則 或 500ms 再導入,在那之前屬過早優化。 版號沿用 0.0.5(master 為 0.0.4,同一 PR 不再累加) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+105
-6
@@ -258,6 +258,7 @@ function dumpMemory(meta, content) {
|
||||
"memory_type",
|
||||
"declarative",
|
||||
"retention_stage",
|
||||
"expires",
|
||||
"sleep_stage",
|
||||
"created",
|
||||
"updated",
|
||||
@@ -307,6 +308,7 @@ function loadMemory(filePath) {
|
||||
meta.relevance ||= [];
|
||||
meta.links ||= [];
|
||||
meta.cues ||= [];
|
||||
meta.expires ||= "";
|
||||
meta.priority = normalizePriority(meta.priority, meta.category || "other");
|
||||
meta.memory_type = normalizeMemoryType(meta.memory_type, meta.category || "other");
|
||||
meta.declarative = normalizeDeclarative(meta.declarative, meta.memory_type);
|
||||
@@ -377,7 +379,9 @@ function inboxBlock(role, count, limit) {
|
||||
if (limit <= 0 || count <= 0) return "";
|
||||
const items = listInbox(role);
|
||||
if (!items.length) return "";
|
||||
const recent = items.slice(-count).reverse(); // 檔名為時間戳,取最後 N 則後反轉成最新在前
|
||||
// 已過期的臨時授權即使還在 inbox 也不該注入,否則會被當成當下有效的許可
|
||||
const alive = items.filter(([meta]) => !expiryState(meta).expired);
|
||||
const recent = alive.slice(-count).reverse(); // 檔名為時間戳,取最後 N 則後反轉成最新在前
|
||||
const lines = ["### 近期工作記憶(未整理,最新在前)"];
|
||||
for (const [meta] of recent) {
|
||||
const when = typeof meta.created === "string" && meta.created.length >= 16 ? meta.created.slice(11, 16) : "--:--";
|
||||
@@ -404,11 +408,24 @@ function findMemory(role, memoryId) {
|
||||
return [null, ""];
|
||||
}
|
||||
|
||||
// 臨時授權/例外放行的有效範圍判斷。
|
||||
// expires 可寫日期(自動判斷過期)或條件文字(例如「本工作階段」「PR 合併後」,只能標示由角色自行判斷)。
|
||||
// 一次性許可若被當成長期規則沿用,日後會造成越權操作,因此過期者不再載入。
|
||||
function expiryState(meta) {
|
||||
const raw = String(meta.expires || "").trim();
|
||||
if (!raw) return { has: false, expired: false, note: "" };
|
||||
const dt = parseStamp(raw) || parseStamp(`${raw} 23:59:59`);
|
||||
if (!dt) return { has: true, expired: false, note: raw, byDate: false };
|
||||
return { has: true, expired: dt.getTime() < Date.now(), note: raw, byDate: true };
|
||||
}
|
||||
|
||||
function memoryHint(meta) {
|
||||
const relevance = (meta.relevance || []).join("、") || "-";
|
||||
const links = (meta.links || []).join("、") || "-";
|
||||
const type = MEMORY_TYPE_LABELS[meta.memory_type] || meta.memory_type || "語意";
|
||||
return `優先度:${normalizePriority(meta.priority, meta.category)};型態:${type}/${meta.declarative || "explicit"};關聯:${relevance};連結:${links}`;
|
||||
const expiry = expiryState(meta);
|
||||
const limit = expiry.has ? `;**有效範圍:${expiry.note}${expiry.expired ? "(已過期)" : ""}**` : "";
|
||||
return `優先度:${normalizePriority(meta.priority, meta.category)};型態:${type}/${meta.declarative || "explicit"};關聯:${relevance};連結:${links}${limit}`;
|
||||
}
|
||||
|
||||
function archiveFile(filePath, destinationDir) {
|
||||
@@ -424,7 +441,7 @@ function archiveFile(filePath, destinationDir) {
|
||||
}
|
||||
}
|
||||
|
||||
const FIELD_PATTERN = /^\s*(CATEGORY|SUMMARY|TAGS|PRIORITY|RELEVANCE|MEMORY_TYPE|DECLARATIVE|RETENTION_STAGE|CONTENT)\s*[::]\s*(.*)$/i;
|
||||
const FIELD_PATTERN = /^\s*(CATEGORY|SUMMARY|TAGS|PRIORITY|RELEVANCE|MEMORY_TYPE|DECLARATIVE|RETENTION_STAGE|EXPIRES|CONTENT)\s*[::]\s*(.*)$/i;
|
||||
|
||||
function parseCapture(text) {
|
||||
let category = "";
|
||||
@@ -435,6 +452,7 @@ function parseCapture(text) {
|
||||
let memoryType = "";
|
||||
let declarative = "";
|
||||
let retentionStage = "";
|
||||
let expires = "";
|
||||
const contentLines = [];
|
||||
let inContent = false;
|
||||
for (const line of String(text || "").split(/\r?\n/)) {
|
||||
@@ -450,6 +468,7 @@ function parseCapture(text) {
|
||||
else if (field === "MEMORY_TYPE") memoryType = value;
|
||||
else if (field === "DECLARATIVE") declarative = value;
|
||||
else if (field === "RETENTION_STAGE") retentionStage = value;
|
||||
else if (field === "EXPIRES") expires = value;
|
||||
else if (field === "CONTENT") {
|
||||
inContent = true;
|
||||
if (value.trim()) contentLines.push(value);
|
||||
@@ -467,6 +486,7 @@ function parseCapture(text) {
|
||||
memoryType,
|
||||
declarative,
|
||||
retentionStage,
|
||||
expires,
|
||||
content: contentLines.join("\n").trim(),
|
||||
};
|
||||
}
|
||||
@@ -485,6 +505,8 @@ function cmdWrite(args) {
|
||||
priority: normalizePriority(parsed.priority, parsed.category),
|
||||
relevance: parsed.relevance.length ? parsed.relevance : ["inbox"],
|
||||
links: [],
|
||||
cues: [],
|
||||
expires: oneLine(parsed.expires, 60),
|
||||
memory_type: memoryType,
|
||||
declarative: normalizeDeclarative(parsed.declarative, memoryType),
|
||||
retention_stage: "working",
|
||||
@@ -536,6 +558,7 @@ function cmdLoad(args) {
|
||||
const lines = [`### ${CATEGORY_LABELS[category]}記憶(全文)`];
|
||||
for (const [meta, content] of listMemories(args.role, category)) {
|
||||
if (normalizePriority(meta.priority, category) < args.fullMinPriority) continue;
|
||||
if (expiryState(meta).expired) continue; // 已過期的臨時授權不再注入,避免被當成有效規則
|
||||
const tags = (meta.tags || []).join("、") || "無標籤";
|
||||
lines.push(`- **${meta.summary || "(無總結)"}**(標籤:${tags};${memoryHint(meta)})`);
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
@@ -551,6 +574,7 @@ function cmdLoad(args) {
|
||||
if (!items.length) continue;
|
||||
digestLines.push(`### ${CATEGORY_LABELS[category]}記憶(總結)`);
|
||||
for (const [meta] of items) {
|
||||
if (expiryState(meta).expired) continue;
|
||||
const priority = normalizePriority(meta.priority, category);
|
||||
const durableType = ["rule", "preference", "procedural"].includes(meta.memory_type);
|
||||
if (priority < args.digestMinPriority && !(meta.links || []).length && !durableType) continue;
|
||||
@@ -641,6 +665,42 @@ function extractJson(text) {
|
||||
}
|
||||
}
|
||||
|
||||
// 整理摘要歷史:state.json 的 last_sleep_digest 是單一欄位,每次整理直接覆寫,
|
||||
// 歷史整理過程會全部遺失。這份檔案只供人工回顧「記憶是怎麼被整理的」,不注入 context。
|
||||
const DIGEST_MARK = "<!-- 以下由系統追加,最新在最上面 -->";
|
||||
const DIGEST_KEEP = 100;
|
||||
|
||||
function appendSleepDigest(role, digest, applied) {
|
||||
const file = path.join(memoryRoot(role), "DIGESTS.md");
|
||||
const parts = [`## ${nowStamp()}`, "", digest || "(無摘要)"];
|
||||
if (applied) parts.push("", `套用結果:${applied}`);
|
||||
const entry = parts.join("\n").trimEnd();
|
||||
|
||||
let text = "";
|
||||
try {
|
||||
text = fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
text = "";
|
||||
}
|
||||
if (!text.includes(DIGEST_MARK)) {
|
||||
text = `# 記憶整理摘要歷史(${role})\n\n本檔只供人工回顧整理過程,不會注入 context;最多保留最近 ${DIGEST_KEEP} 次。\n\n${DIGEST_MARK}\n`;
|
||||
}
|
||||
const idx = text.indexOf(DIGEST_MARK) + DIGEST_MARK.length;
|
||||
const head = text.slice(0, idx);
|
||||
const previous = text
|
||||
.slice(idx)
|
||||
.split(/\n(?=## )/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean);
|
||||
const kept = [entry, ...previous].slice(0, DIGEST_KEEP);
|
||||
try {
|
||||
ensureLayout(role);
|
||||
fs.writeFileSync(file, `${head}\n\n${kept.join("\n\n")}\n`, "utf8");
|
||||
} catch {
|
||||
// 寫歷史失敗不可影響整理結果
|
||||
}
|
||||
}
|
||||
|
||||
function cmdApply(args) {
|
||||
const data = extractJson(readStdin());
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||||
@@ -677,6 +737,8 @@ function cmdApply(args) {
|
||||
const relevance = normalizeList(entry.relevance);
|
||||
const links = normalizeList(entry.links);
|
||||
const cues = normalizeList(entry.cues, 5); // 技能再現的提取線索,供 recall 命中
|
||||
// 臨時授權/例外放行的有效範圍:一次性許可被記成長期規則會導致日後越權
|
||||
const expires = oneLine(entry.expires, 60);
|
||||
const memoryType = normalizeMemoryType(entry.memory_type || entry.memoryType, entryCategory);
|
||||
const declarative = normalizeDeclarative(entry.declarative, memoryType);
|
||||
const retentionStage = normalizeRetentionStage(entry.retention_stage || entry.retentionStage, "long_term");
|
||||
@@ -700,6 +762,7 @@ function cmdApply(args) {
|
||||
relevance: normalizeList([...(meta.relevance || []), ...relevance]),
|
||||
links: normalizeList([...(meta.links || []), ...links]),
|
||||
cues: normalizeList([...(meta.cues || []), ...cues], 5),
|
||||
expires: expires || meta.expires || "",
|
||||
memory_type: mergedMemoryType,
|
||||
declarative: normalizeDeclarative(entry.declarative || meta.declarative, mergedMemoryType),
|
||||
retention_stage: normalizeRetentionStage(entry.retention_stage || entry.retentionStage || meta.retention_stage, "long_term"),
|
||||
@@ -733,6 +796,7 @@ function cmdApply(args) {
|
||||
relevance,
|
||||
links,
|
||||
cues,
|
||||
expires,
|
||||
memory_type: memoryType,
|
||||
declarative,
|
||||
retention_stage: retentionStage,
|
||||
@@ -759,7 +823,9 @@ function cmdApply(args) {
|
||||
patch.last_sleep_digest = oneLine(data.sleepDigest, 300);
|
||||
}
|
||||
writeState(args.role, patch);
|
||||
process.stdout.write(`新增 ${counts.new} 則、合併 ${counts.merge} 則、捨棄 ${counts.drop} 則、歸檔原始記憶 ${archived} 則`);
|
||||
const summary = `新增 ${counts.new} 則、合併 ${counts.merge} 則、捨棄 ${counts.drop} 則、歸檔原始記憶 ${archived} 則`;
|
||||
appendSleepDigest(args.role, patch.last_sleep_digest || "", summary);
|
||||
process.stdout.write(summary);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -767,6 +833,22 @@ function cmdForget(args) {
|
||||
const root = ensureLayout(args.role);
|
||||
const now = new Date();
|
||||
const forgotten = [];
|
||||
|
||||
// 已過期的臨時授權優先淘汰,且不受分類限制 ——
|
||||
// 過期的一次性許可留在任何分類都是風險,不只 daily/other。
|
||||
for (const category of CATEGORIES) {
|
||||
for (const [meta] of listMemories(args.role, category)) {
|
||||
if (!expiryState(meta).expired) continue;
|
||||
if (args.dryRun) {
|
||||
forgotten.push(`${CATEGORY_LABELS[category]}|${meta.summary || meta.id}(已過期:${meta.expires})`);
|
||||
continue;
|
||||
}
|
||||
if (archiveFile(meta.path, path.join(root, "archive", "forgotten"))) {
|
||||
forgotten.push(`${CATEGORY_LABELS[category]}|${meta.summary || meta.id}(已過期:${meta.expires})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [category, [days, maxHits]] of Object.entries(FORGET_RULES)) {
|
||||
for (const [meta] of listMemories(args.role, category)) {
|
||||
const updated = parseStamp(meta.updated) || parseStamp(meta.created);
|
||||
@@ -862,6 +944,20 @@ function cmdMarkActivity(args) {
|
||||
// 為什麼需要:SessionStart 的字元預算有限,磁碟上的記憶遠多於能載入的量,
|
||||
// 技能類記憶又只以摘要形式載入 —— 等於「記了但用不出來」。recall 讓角色按需查詢,
|
||||
// 突破常駐預算限制;配合 cues(觸發線索)讓 procedural/rule 記憶更容易被命中。
|
||||
// 記一次召回:hits 供遺忘判斷與「常用記憶不該被淘汰」的依據,last_replayed 記錄最近取用時間。
|
||||
// 只對實際輸出給呼叫端的記憶計數 —— 有分數但未進前 N 的不算被用到。
|
||||
function touchMemory(meta, content) {
|
||||
if (!meta || !meta.path) return;
|
||||
try {
|
||||
if (!fs.existsSync(meta.path)) return;
|
||||
const next = { ...meta, hits: (Number.parseInt(meta.hits || 0, 10) || 0) + 1, last_replayed: nowStamp() };
|
||||
delete next.path;
|
||||
fs.writeFileSync(meta.path, dumpMemory(next, content), "utf8");
|
||||
} catch {
|
||||
// 召回統計失敗不可影響查詢結果
|
||||
}
|
||||
}
|
||||
|
||||
function cmdRecall(args) {
|
||||
const query = String(args.query || "").trim();
|
||||
if (!query) return 2;
|
||||
@@ -897,8 +993,11 @@ function cmdRecall(args) {
|
||||
}
|
||||
scored.sort((a, b) => b[0] - a[0] || String(b[1].updated).localeCompare(String(a[1].updated)));
|
||||
|
||||
const lines = [`### 與「${query}」相關的記憶(前 ${Math.min(limit, scored.length)} 則)`];
|
||||
for (const [score, meta, content] of scored.slice(0, limit)) {
|
||||
const top = scored.slice(0, limit);
|
||||
for (const [, meta, content] of top) touchMemory(meta, content);
|
||||
|
||||
const lines = [`### 與「${query}」相關的記憶(前 ${top.length} 則)`];
|
||||
for (const [score, meta, content] of top) {
|
||||
const tags = (meta.tags || []).join("、") || "無標籤";
|
||||
const label = CATEGORY_LABELS[meta.category] || (meta.retention_stage === "working" ? "待整理" : meta.category);
|
||||
lines.push(`- **${meta.summary || "(無總結)"}**(${label}|${memoryHint(meta)}|相關度 ${score};標籤:${tags})`);
|
||||
|
||||
@@ -107,6 +107,7 @@ TAGS: <2 至 4 個標籤,以逗號分隔>
|
||||
PRIORITY: <1 到 5>
|
||||
RELEVANCE: <1 至 4 個,以逗號分隔;explicit/future/repeated/novelty/emotional/temporary/inbox/project>
|
||||
MEMORY_TYPE: <semantic/episodic/procedural/emotional/preference/rule 六選一>
|
||||
EXPIRES: <臨時授權/一次性許可/例外放行才填其有效範圍,可為日期或條件;否則留空>
|
||||
CONTENT: <3 至 6 行要點,每行以「- 」開頭>
|
||||
2. 分類判準:
|
||||
- important(重要):使用者的長期偏好、規範、決策、身分背景、明確要求記住的事。
|
||||
@@ -129,8 +130,11 @@ CONTENT: <3 至 6 行要點,每行以「- 」開頭>
|
||||
8. 使用繁體中文(台灣用語)。**檔案路徑與目錄、網址、指令、環境變數名稱、版本號、識別碼、分支與議題
|
||||
編號、檔名一律逐字保留,不得摘要、改寫、簡寫或翻譯** —— 這類內容改一個字就失效,摘要等於遺失。
|
||||
第 7 條指的是不要整段抄程式碼,不是省略這些關鍵字串;第 9 條仍優先,憑證與個資一律不得輸出。
|
||||
9. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。
|
||||
10. 若這段對話沒有任何值得記住的內容(純寒暄、純確認、無結論、只有簡短狀態回報),只輸出一行:SKIP
|
||||
9. EXPIRES 只在內容屬於臨時授權、一次性許可、例外放行、暫時解除限制或帶條件的同意時才填,其餘留空。
|
||||
使用者說「這次」、「先」、「暫時」、「今天」、「這個 PR」時幾乎都屬於此類。
|
||||
一次性許可被記成長期規則,日後會導致越權操作,因此寧可填得保守也不要漏填。
|
||||
10. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。
|
||||
11. 若這段對話沒有任何值得記住的內容(純寒暄、純確認、無結論、只有簡短狀態回報),只輸出一行:SKIP
|
||||
|
||||
對話片段:
|
||||
${TURN}
|
||||
|
||||
@@ -132,7 +132,7 @@ sleep_cycle() {
|
||||
請模擬睡眠中的兩階段記憶整理,但最後只輸出一個 JSON 物件。
|
||||
|
||||
1. 只輸出一個 JSON 物件,不要前言、不要結語、不要 code fence,格式為:
|
||||
{"memories":[{"action":"new","category":"skill","summary":"一句話總結","tags":["標籤1","標籤2"],"priority":4,"relevance":["explicit","future"],"links":["既有記憶 id"],"cues":["觸發線索1","觸發線索2"],"memory_type":"procedural","declarative":"implicit","retention_stage":"long_term","sleep_stage":"nrem-rem","content":"- 要點\n- 要點","from":["inbox 的 id"]}],"sleepDigest":"本次睡眠整理摘要,80 字內"}
|
||||
{"memories":[{"action":"new","category":"skill","summary":"一句話總結","tags":["標籤1","標籤2"],"priority":4,"relevance":["explicit","future"],"links":["既有記憶 id"],"cues":["觸發線索1","觸發線索2"],"expires":"","memory_type":"procedural","declarative":"implicit","retention_stage":"long_term","sleep_stage":"nrem-rem","content":"- 要點\n- 要點","from":["inbox 的 id"]}],"sleepDigest":"本次睡眠整理摘要,80 字內"}
|
||||
2. NREM 鞏固階段先做:去除雜訊與流水帳、遮蔽憑證與個資、分類、去重、合併、壓縮成可長期保存的穩定記憶。
|
||||
3. REM 整合階段再做:找出新記憶與 EXISTING 的關聯,抽出可重複套用的規則、偏好、決策模式、角色語氣調整或未來提取線索。
|
||||
4. action 三選一:
|
||||
@@ -154,6 +154,11 @@ sleep_cycle() {
|
||||
9. retention_stage 必填:整理後可長期保存者填 long_term;仍只是短期暫存且不值得長期保存者請用 action=drop,不要輸出 working。
|
||||
10. relevance 必填 1 至 4 個,從下列語意挑選或用等價繁中詞:explicit(使用者明確要求)、future(未來會用)、repeated(反覆出現)、novelty(新知)、emotional(語氣/情緒/偏好)、temporary(短期)。
|
||||
11. links 可填 EXISTING 中相關記憶 id;沒有就填空陣列。merge 時若有舊 links,應保留並加上新關聯。
|
||||
11a. expires(有效範圍):**只要內容是臨時授權、一次性許可、例外放行、暫時解除限制或帶條件的同意,就必須填**,
|
||||
其餘一律留空字串。可填日期(例如 2026/07/29,系統會自動判斷過期後不再載入)或條件
|
||||
(例如「本工作階段」、「PR #17 合併後失效」,由角色自行判斷)。
|
||||
這是安全機制:一次性許可若被記成長期規則,日後會導致越權操作。
|
||||
判斷提示 —— 使用者說「這次」、「先」、「暫時」、「今天」、「這個 PR」時,幾乎都屬於臨時授權。
|
||||
11b. cues(觸發線索):memory_type 為 procedural 或 rule 時**必填** 2 至 5 個,其餘型態可填空陣列。
|
||||
寫「未來遇到什麼情況該想起這則」的關鍵詞,例如 ["plugin 版號","bump","manifest"]。
|
||||
這是技能再現的依據 —— 角色日後用 recall 查詢時靠 cues 命中,線索寫得準才叫得回來。
|
||||
|
||||
Reference in New Issue
Block a user