Files
shared/scripts/role/memory.js
T
JefferyandClaude Opus 5 adc5521302 fix(role): 寫檔前加繁簡防線,歧義字刻意不自動轉換
病因(由角色稽核記憶時發現):實測有整則記憶以簡體寫成,連 summary 與 tags 都是,
而該則的 sources 指向另一個專案 —— 不同環境下 CLI 的行為並不一致,
光靠 prompt 的「使用繁體中文」條款擋不住。0.1.2 只補了 prompt(症狀由人工修檔),
寫入器本身沒防線,同樣環境下還會再產出簡體。

- memory.js 新增 toTraditional/ambiguousSimplified/warnIfSimplified
- cmdWrite(inbox)、cmdApply(整理落檔)、appendBond(關係史)三處寫檔前都經過
- 一簡對一繁、無歧義的約 700 字自動轉繁
- 一簡對多繁刻意不轉(发→發/髮、干→乾/幹、后→後/后、里→裡/里、复→復/複/覆、
  系→系/係/繫、脏→臟/髒…),改為 stderr 警告,留待整理階段依上下文處理
  —— 機械替換會把「头发」變成「頭發」,那比留著簡體更難發現,
  因為它看起來已經是繁體了。寧可留下可偵測的瑕疵,也不要製造隱形錯誤
- 警告只警告不阻斷:記憶寧可帶著瑕疵留下,也不能因為用字問題而遺失
- 轉換只在字形層;用語差異(反饋/回饋)仍由 prompt 的台灣用語條款負責

實測:整則簡體素材寫入後 summary/tags/content 均正確轉繁,
「发」「系」保留並發出警告,未產生「頭發」這類隱形錯誤。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 18:56:51 +08:00

1436 lines
62 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// ==============================================================================
// 用途:角色記憶(.memory/<角色>/)的儲存引擎。負責 (1) 把每輪對話濃縮結果寫入
// inbox(2) 產生 SessionStart 要注入的記憶區塊(含未整理 inbox 的近期工作
// 記憶交接,使用獨立字元預算),(3) 睡眠整理時輸出待整理
// 素材並套用整理結果(NREM 鞏固/REM 整合、分類、去重、標籤、總結、
// 優先度、關聯、壓縮歸檔),(4) 依使用頻率與優先度遺忘日常與其他類記憶。
// 更新時間:2026/07/29 18:56:33
// 相依:Node.js 標準庫。
// 退出碼:0 成功;1 無內容可處理;2 參數錯誤。呼叫端(hook)一律不得因此中斷。
// ==============================================================================
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const zlib = require("zlib");
const CATEGORIES = ["important", "interest", "news", "skill", "daily", "other"];
const CATEGORY_LABELS = {
important: "重要",
interest: "興趣",
news: "新知",
skill: "技能",
daily: "日常",
other: "其他",
};
const LABEL_TO_CATEGORY = Object.fromEntries(Object.entries(CATEGORY_LABELS).map(([key, value]) => [value, key]));
const FULL_CATEGORIES = ["important", "interest"];
const DIGEST_CATEGORIES = ["skill", "news", "daily", "other"];
const FORGET_RULES = { daily: [14, 1], other: [7, 1] };
const DEFAULT_PRIORITY = { important: 5, interest: 4, skill: 4, news: 3, daily: 2, other: 1 };
const MEMORY_TYPES = ["semantic", "episodic", "procedural", "emotional", "preference", "rule"];
const MEMORY_TYPE_LABELS = {
semantic: "語意",
episodic: "情節",
procedural: "程序",
emotional: "情緒",
preference: "偏好",
rule: "規範",
};
const DEFAULT_MEMORY_TYPE = {
important: "preference",
interest: "emotional",
news: "semantic",
skill: "procedural",
daily: "episodic",
other: "semantic",
};
// 載入排序(依 ROLE_LOAD_LIMIT 字元預算取前段)與 recall 評分都用這組權重。
//
// emotional 刻意排在 procedural 之上,episodic 也拉到與 semantic 同級:使用者明確表示
// 最重視 emotionalepisodicsemantic 三型態所承載的情感內涵與溫度。原本 episodic 只有 10
// 等於把「我們一起經歷過什麼」排在所有規則與流程之後,最先被字元預算截掉。
// 至於「哪些事件值得留」則不靠型態決定,而由 hasWarmth()relevance 是否帶情緒關聯)區分,
// 讓沒有溫度的一次性工作進度仍然照原規則淡去。
const MEMORY_TYPE_WEIGHT = {
rule: 50,
preference: 45,
emotional: 40,
procedural: 35,
semantic: 30,
episodic: 30,
};
// ── 繁簡防線(第二道)────────────────────────────────────────────────────────
// prompt 已明令輸出繁體(capture 第 8 條、sleep 第 16 條),但實測仍出現整則簡體記憶,
// 而且該則的 sources 指向另一個專案 —— 不同環境下 CLI 的行為並不一致,光靠 prompt 擋不住。
// 這裡是寫檔前的第二道防線。
//
// 只轉換「一簡對一繁、無歧義」的字。歧義字刻意**不自動轉換**:
// 发→發/髮、干→乾/幹、后→後/后、里→裡/里、复→復/複/覆、系→系/係/繫、余→余/餘…
// 機械替換會產生「頭發」這種比留著簡體更難發現的錯誤,改為警告並留待整理階段依上下文處理。
const ZH_SAFE_PAIRS = "这這个個们們说說时時间間过過还還没沒对對认認为為经經开開单單区區华華严嚴来來长長问問题題无無现現实實电電话話车車门門马馬东東书書学學习習应應该該让讓给給从從众眾会會体體万萬与與专專业業两兩广廣庆慶亚亞产產亲親亿億仅僅仓倉仪儀优優伟偉传傳伤傷伦倫伪偽侠俠侦偵侧側侨僑俭儉债債倾傾偿償储儲兰蘭关關兴興养養兽獸内內决決况況冻凍净淨减減凑湊几幾凭憑击擊刘劉则則刚剛创創删刪别別剧劇劳勞势勢动動励勵汇匯医醫协協卖賣卢盧卫衛却卻厂廠压壓厌厭厅廳县縣参參双雙变變叙敘叹嘆叶葉号號吗嗎员員响響唤喚团團园園围圍图圖圆圓圣聖场場坏壞块塊坚堅坛壇垒壘执執扩擴扫掃扬揚拟擬择擇挂掛挥揮损損换換据據搅攪摆擺摄攝撑撐敌敵数數斗鬥断斷旧舊显顯晓曉暂暫术術机機杂雜权權条條极極构構标標树樹样樣桥橋检檢楼樓横橫欢歡欧歐汉漢汤湯沟溝泪淚泼潑洁潔济濟测測浏瀏涛濤涨漲渐漸渔漁渗滲温溫满滿滚滾滤濾滨濱潜潛澜瀾灭滅灯燈灵靈灾災炉爐烂爛烦煩热熱爱愛爷爺牵牽牺犧犹猶独獨狮獅猪豬猫貓献獻环環玛瑪琼瓊画畫畅暢疗療痒癢瘫癱盘盤监監盖蓋盗盜瞒瞞矫矯码碼碍礙确確礼禮祸禍禅禪离離种種积積称稱稳穩穷窮窃竊窜竄竞競笔筆笼籠筑築简簡篮籃类類粮糧紧緊纠糾红紅约約级級纪紀纬緯纯純纱紗纲綱纳納纵縱纷紛纸紙纹紋纺紡纽紐线線练練组組绅紳细細织織终終绊絆绍紹绎繹绑綁绒絨结結绕繞绘繪络絡绝絕绞絞统統绢絹绣繡继繼绩績绪緒续續绳繩维維绵綿综綜绿綠缀綴缓緩缔締编編缘緣缚縛缝縫缠纏缩縮缴繳网網罚罰罢罷罗羅义義耻恥聋聾职職联聯聪聰肃肅肠腸肤膚胀脹胆膽脉脈脑腦脱脫腊臘腻膩脸臉舆輿舰艦舱艙艰艱艺藝节節芦蘆苏蘇苹蘋茎莖荐薦荣榮药藥莱萊获獲萧蕭蓝藍虏虜虑慮虫蟲蚀蝕蚁蟻蛮蠻蜡蠟蝇蠅补補衬襯装裝见見观觀觉覺览覽触觸誉譽计計订訂议議讯訊记記讲講许許论論讼訟讽諷设設访訪证證评評识識诉訴诊診词詞译譯试試诗詩诚誠详詳语語误誤诱誘诵誦请請诸諸诺諾读讀课課调調谅諒谈談谋謀谎謊谐諧谓謂谜謎谢謝谣謠谤謗谦謙谨謹谱譜贝貝贞貞负負贡貢财財责責贤賢败敗货貨质質贩販贪貪贫貧贬貶购購贯貫贱賤贴貼贵貴贷貸贸貿费費贺賀贼賊贾賈贿賄资資赁賃赃贓赋賦赌賭赎贖赏賞赐賜赔賠赖賴赘贅赚賺赛賽赞贊赠贈赡贍赢贏赶趕趋趨轧軋轨軌转轉轮輪软軟轰轟轴軸轻輕载載辅輔辆輛辈輩辉輝输輸边邊达達迁遷运運进進远遠违違连連迟遲适適选選逊遜递遞逻邏遗遺邓鄧邮郵邻鄰郑鄭释釋针針钉釘钢鋼钥鑰钦欽钩鉤钱錢钳鉗钻鑽铁鐵铃鈴铅鉛铜銅铝鋁银銀铸鑄铺鋪链鏈销銷锁鎖锅鍋锋鋒错錯锦錦键鍵锯鋸镇鎮镜鏡闪閃闭閉闯闖闲閒闷悶闹鬧闻聞阀閥阁閣阅閱阔闊队隊阳陽阴陰阵陣际際陆陸陈陳陕陝险險随隨隐隱难難雾霧韦韋韧韌韩韓页頁顶頂顷頃项項顺順须須顽頑顾顧顿頓颁頒预預领領颇頗颈頸频頻额額颤顫风風飘飄飞飛饥飢饭飯饮飲饰飾饱飽饲飼饺餃饼餅饿餓馆館馈饋驭馭驯馴驰馳驱驅驳駁驴驢驻駐驼駝驾駕骂罵骄驕验驗骑騎骗騙骚騷骤驟鱼魚鲁魯昵暱桩樁谘諮侣侶侥僥仑侖头頭点點总總当當张張报報买買乐樂气氣户戶态態忆憶军軍办辦听聽声聲处處虚虛丰豐宾賓龙龍鸟鳥丽麗齐齊庄莊农農乡鄉阶階颜顏尽盡毕畢状狀奖獎纤纖盐鹽础礎硕碩竖豎筹籌肿腫胁脅荡蕩苍蒼梦夢杀殺杨楊枪槍柜櫃栏欄洒灑泽澤浓濃润潤湾灣湿濕烛燭烧燒营營猎獵疯瘋护護挡擋拥擁挤擠捡撿举舉录錄归歸彻徹恶惡悬懸惊驚惧懼惯慣愿願戏戲战戰扰擾抚撫拨撥拦攔摊攤旷曠毁毀狱獄阐闡陨隕隶隸韵韻颂頌颖穎馊餿骏駿鲜鮮鸡雞鸣鳴鸿鴻鹅鵝鹏鵬鹰鷹麦麥黄黃齿齒龄齡遥遙静靜颅顱";
const ZH_SAFE = (() => {
const m = Object.create(null);
for (let i = 0; i < ZH_SAFE_PAIRS.length; i += 2) m[ZH_SAFE_PAIRS[i]] = ZH_SAFE_PAIRS[i + 1];
return m;
})();
const ZH_AMBIGUOUS = new Set(["丑", "么", "云", "伙", "余", "党", "准", "划", "卷", "历", "发", "只", "台", "后", "咨", "回", "复", "宁", "干", "扑", "朴", "签", "系", "脏", "著", "里", "钟", "雕"]);
function toTraditional(value) {
if (typeof value !== "string" || !value) return value;
let out = "";
for (const ch of value) out += ZH_SAFE[ch] || ch;
return out;
}
function ambiguousSimplified(value) {
const hit = new Set();
for (const ch of String(value || "")) if (ZH_AMBIGUOUS.has(ch)) hit.add(ch);
return [...hit];
}
function warnIfSimplified(role, where, ...texts) {
// 只警告不阻斷:記憶寧可帶著瑕疵留下,也不能因為用字問題而遺失
const hit = ambiguousSimplified(texts.join(" "));
if (hit.length) {
process.stderr.write(`[memory][WRN]: ${role}${where}含歧義簡體字(${hit.join("")}),未自動轉換,請於整理階段依上下文修正\n`);
}
}
const DECLARATIVE_VALUES = ["explicit", "implicit"];
const RETENTION_STAGES = ["working", "long_term"];
// 單批上限刻意壓在 12:每則整理結果約需 650 字元(summarycontent/各欄位),
// 而 SLEEP_OUTPUT_LIMIT 預設 80008000÷650≈12。原本預設 60 與輸出上限矛盾 ——
// 實測 25 則的素材達 25798 位元組、輸出 JSON 被截斷成不合法格式,整批整理直接失敗。
// 寧可分多批各自成功,也不要一次做完卻全部失敗。
const SLEEP_BATCH = 12;
const COLLECT_LIMIT = 12000;
const EXISTING_INDEX_LIMIT = 120;
const CONTENT_LIMIT = 1200;
const DEFAULT_LOAD_LIMIT = 4000;
const DEFAULT_FULL_MIN_PRIORITY = 4;
const DEFAULT_DIGEST_MIN_PRIORITY = 3;
// 1200 是「只注入 summary 一行」時代的額度;改成注入全文後,單則約 300~600 字元,
// 1200 只夠兩則就開始丟最舊的。3600 約可容納 6~8 則,足以覆蓋一次睡眠整理週期內的工作。
const DEFAULT_LOAD_INBOX_LIMIT = 3600;
const DEFAULT_LOAD_INBOX_COUNT = 10;
// 關係史注入額度:每則約 40~80 字元,1200 約可容納 15~25 則,足以撐起一段關係的近期輪廓。
const DEFAULT_LOAD_BONDS_LIMIT = 1200;
const DEFAULT_LOAD_BONDS_COUNT = 15;
function readStdin() {
try {
return fs.readFileSync(0, "utf8");
} catch {
return "";
}
}
function pad(value) {
return String(value).padStart(2, "0");
}
function taipeiDate() {
return new Date(Date.now() + 8 * 60 * 60 * 1000);
}
function nowStamp() {
const d = taipeiDate();
return `${d.getUTCFullYear()}/${pad(d.getUTCMonth() + 1)}/${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
}
function nowEpoch() {
return Math.floor(Date.now() / 1000);
}
function parseStamp(value) {
if (typeof value !== "string" || !value.trim()) return null;
const m = value.trim().match(/^(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
if (!m) return null;
const [, y, mo, d, h, mi, s] = m.map(Number);
const ms = Date.UTC(y, mo - 1, d, h - 8, mi, s);
return Number.isNaN(ms) ? null : new Date(ms);
}
function memoryRoot(role) {
const base = process.env.ROLE_MEMORY_HOME || path.join(process.env.HOME || "", ".memory");
return path.join(base, role);
}
function ensureLayout(role) {
const root = memoryRoot(role);
for (const sub of ["inbox", "archive/raw", "archive/forgotten", ...CATEGORIES]) {
fs.mkdirSync(path.join(root, sub), { recursive: true });
}
return root;
}
function statePath(role) {
return path.join(memoryRoot(role), "state.json");
}
function readState(role) {
try {
const data = JSON.parse(fs.readFileSync(statePath(role), "utf8"));
return data && typeof data === "object" && !Array.isArray(data) ? data : {};
} catch {
return {};
}
}
function writeState(role, patch) {
const state = { ...readState(role), ...patch };
ensureLayout(role);
fs.writeFileSync(statePath(role), JSON.stringify(state, null, 2), "utf8");
return state;
}
function normalizeCategory(value) {
const raw = String(value || "").trim().toLowerCase();
if (CATEGORIES.includes(raw)) return raw;
return LABEL_TO_CATEGORY[String(value || "").trim()] || "other";
}
function uniquePush(items, value, fold = false) {
const text = String(value || "").trim().replace(/^[#\[]+|[\]]+$/g, "").trim();
if (!text) return;
const exists = fold
? items.some((item) => item.toLowerCase() === text.toLowerCase())
: items.includes(text);
if (!exists) items.push(text);
}
function normalizeTags(value) {
const parts = Array.isArray(value) ? value.map(String) : typeof value === "string" ? value.split(/[,、|]/) : [];
const tags = [];
for (const part of parts) uniquePush(tags, part, true);
return tags.slice(0, 6);
}
function normalizeList(value, limit = 8) {
const parts = Array.isArray(value) ? value.map(String) : typeof value === "string" ? value.split(/[,、|]/) : [];
const items = [];
for (const part of parts) uniquePush(items, part, false);
return items.slice(0, limit);
}
function normalizePriority(value, category = "other") {
const fallback = DEFAULT_PRIORITY[normalizeCategory(category)] || 1;
const parsed = Number.parseInt(String(value ?? "").trim(), 10);
const priority = Number.isFinite(parsed) ? parsed : fallback;
return Math.max(1, Math.min(5, priority));
}
function normalizeMemoryType(value, category = "other") {
const raw = String(value || "").trim().toLowerCase().replace(/-/g, "_");
const alias = {
explicit: "semantic",
declarative: "semantic",
implicit: "procedural",
non_declarative: "procedural",
nondeclarative: "procedural",
semantic_memory: "semantic",
episodic_memory: "episodic",
procedural_memory: "procedural",
emotion: "emotional",
affective: "emotional",
pref: "preference",
preference_memory: "preference",
policy: "rule",
guideline: "rule",
規範: "rule",
偏好: "preference",
程序: "procedural",
技能: "procedural",
語意: "semantic",
知識: "semantic",
情節: "episodic",
事件: "episodic",
情緒: "emotional",
};
const normalized = alias[raw] || alias[String(value || "").trim()] || raw;
if (MEMORY_TYPES.includes(normalized)) return normalized;
return DEFAULT_MEMORY_TYPE[normalizeCategory(category)] || "semantic";
}
function normalizeDeclarative(value, memoryType = "semantic") {
const raw = String(value || "").trim().toLowerCase().replace(/-/g, "_");
const alias = {
declarative: "explicit",
explicit_memory: "explicit",
non_declarative: "implicit",
nondeclarative: "implicit",
implicit_memory: "implicit",
外顯: "explicit",
陳述性: "explicit",
內隱: "implicit",
非陳述性: "implicit",
};
const normalized = alias[raw] || alias[String(value || "").trim()] || raw;
if (DECLARATIVE_VALUES.includes(normalized)) return normalized;
return ["procedural", "emotional"].includes(memoryType) ? "implicit" : "explicit";
}
function hasWarmth(meta) {
// 「有溫度」= relevance 標了情緒關聯。使用者最重視的是 emotionalepisodicsemantic
// 三型態裡的情感內涵,但不是所有事件與知識都該優待 —— 一次性工作進度本來就該淡去。
// 因此判準放在 relevance 而非型態:帶情緒關聯的記憶,不論型態都在排序與保留上優先,
// 沒有情緒關聯的一次性進度仍照原規則遺忘。
const values = Array.isArray(meta?.relevance) ? meta.relevance.map(String) : [];
return values.some((item) => /emotional|情緒|情感|溫度/i.test(item)) ? 1 : 0;
}
function normalizeRetentionStage(value, fallback = "long_term") {
const raw = String(value || "").trim().toLowerCase().replace(/-/g, "_");
const alias = {
short_term: "working",
working_memory: "working",
inbox: "working",
encoding: "working",
long: "long_term",
longterm: "long_term",
long_term_memory: "long_term",
短期: "working",
工作記憶: "working",
長期: "long_term",
};
const normalized = alias[raw] || alias[String(value || "").trim()] || raw;
if (RETENTION_STAGES.includes(normalized)) return normalized;
return fallback;
}
function oneLine(value, limit = 120) {
return String(value || "").replace(/\s+/g, " ").trim().slice(0, limit);
}
function dumpMemory(meta, content) {
const lines = ["---"];
for (const key of [
"id",
"category",
"summary",
"tags",
"priority",
"relevance",
"links",
"cues",
"memory_type",
"declarative",
"retention_stage",
"expires",
"sleep_stage",
"created",
"updated",
"last_replayed",
"hits",
"sources",
]) {
if (!Object.prototype.hasOwnProperty.call(meta, key)) continue;
let value = meta[key];
if (Array.isArray(value)) value = `[${value.map(String).join(", ")}]`;
lines.push(`${key}: ${value}`);
}
lines.push("---", "", String(content || "").trim(), "");
return lines.join("\n");
}
function loadMemory(filePath) {
let raw;
try {
raw = fs.readFileSync(filePath, "utf8");
} catch {
return [null, ""];
}
const meta = { path: filePath, hits: 0, tags: [] };
let body = raw;
if (raw.startsWith("---")) {
const parts = raw.split("---");
if (parts.length >= 3) {
body = parts.slice(2).join("---");
for (const line of parts[1].split(/\r?\n/)) {
if (!line.includes(":")) continue;
const idx = line.indexOf(":");
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
if (key === "tags" || key === "sources") meta[key] = normalizeTags(value.replace(/^\[|\]$/g, ""));
else if (key === "relevance" || key === "links" || key === "cues") meta[key] = normalizeList(value.replace(/^\[|\]$/g, ""));
else if (key === "hits" || key === "priority") meta[key] = /^\d+$/.test(value) ? Number.parseInt(value, 10) : 0;
else meta[key] = value;
}
}
}
meta.id ||= path.basename(filePath, ".md");
meta.summary ||= "";
meta.created ||= "";
meta.updated ||= meta.created || "";
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);
const retentionFallback = filePath.includes(`${path.sep}inbox${path.sep}`) || meta.sleep_stage === "encoding" ? "working" : "long_term";
meta.retention_stage = normalizeRetentionStage(meta.retention_stage, retentionFallback);
return [meta, body.trim()];
}
function newId(seed) {
const digest = crypto.createHash("sha1").update(seed, "utf8").digest("hex").slice(0, 6);
const d = taipeiDate();
const stamp = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`;
return `${stamp}-${digest}`;
}
function listMemories(role, category) {
const directory = path.join(memoryRoot(role), category);
if (!fs.existsSync(directory)) return [];
const items = [];
for (const name of fs.readdirSync(directory).sort()) {
if (!name.endsWith(".md")) continue;
const [meta, content] = loadMemory(path.join(directory, name));
if (!meta) continue;
meta.category = category;
items.push([meta, content]);
}
items.sort((a, b) => {
const am = a[0];
const bm = b[0];
// 排序鍵:優先度 → 有無情緒溫度 → 型態權重 → 有無關聯 → 更新時間。
// 溫度放在型態權重之前:同優先度時,帶情緒關聯的記憶先進場,避免被字元預算截掉。
const av = [
normalizePriority(am.priority, category),
hasWarmth(am),
MEMORY_TYPE_WEIGHT[am.memory_type] || 0,
am.links?.length ? 1 : 0,
am.updated || "",
];
const bv = [
normalizePriority(bm.priority, category),
hasWarmth(bm),
MEMORY_TYPE_WEIGHT[bm.memory_type] || 0,
bm.links?.length ? 1 : 0,
bm.updated || "",
];
for (let i = 0; i < av.length; i += 1) {
if (av[i] < bv[i]) return 1;
if (av[i] > bv[i]) return -1;
}
return 0;
});
return items;
}
function listInbox(role) {
const directory = path.join(memoryRoot(role), "inbox");
if (!fs.existsSync(directory)) return [];
const items = [];
for (const name of fs.readdirSync(directory).sort()) {
if (!name.endsWith(".md")) continue;
const [meta, content] = loadMemory(path.join(directory, name));
if (meta) items.push([meta, content]);
}
return items;
}
// 近期工作記憶區塊:SessionStart 載入尚未整理的 inbox 全文。
// inbox 是「剛剛發生的事」,但整理(NREM/REM)永遠跑在載入之後(role_load.sh 先 load 再背景 catchup),
// 若不在此載入,重開工作階段時角色會看不到上一段工作,表現得像失去記憶。
//
// 為什麼要注入 body 而不只是 summary:summary 是一句話的標題,只夠讓角色知道「有這件事」,
// 講不出「進行到哪、還差什麼、下一步是什麼」——實測重開後角色仍得自己去翻 inbox 檔案才答得出來,
// 等於交接失效。長期記憶的「(全文)」區塊本來就會注入 body,inbox 更近、更該給。
// 使用獨立字元預算,不佔用長期記憶的 ROLE_LOAD_LIMIT。
function inboxBlock(role, count, limit) {
if (limit <= 0 || count <= 0) return "";
const items = listInbox(role);
if (!items.length) return "";
// 已過期的臨時授權即使還在 inbox 也不該注入,否則會被當成當下有效的許可
const alive = items.filter(([meta]) => !expiryState(meta).expired);
const recent = alive.slice(-count).reverse(); // 檔名為時間戳,取最後 N 則後反轉成最新在前
const header = "### 近期工作記憶(未整理,最新在前,含全文)";
const lines = [header];
let used = header.length;
let dropped = 0;
for (const [meta, content] of recent) {
const when = typeof meta.created === "string" && meta.created.length >= 16 ? meta.created.slice(11, 16) : "--:--";
const tags = (meta.tags || []).join("、");
const entry = [`- ${when} **${meta.summary || "(無總結)"}**${tags ? `${tags}` : ""}`];
for (const line of String(content || "").split(/\r?\n/)) {
if (line.trim()) entry.push(` ${line.trim()}`);
}
let chunk = entry.join("\n");
// 逐則計費而非最後整段硬切:整段 slice 會把最舊那則砍成半句,讀起來像壞掉的資料。
// 最新一則永遠保留(必要時只截它自己的內文),其餘超出預算就整則略過並在結尾誠實計數。
if (used + 1 + chunk.length > limit) {
if (lines.length > 1) {
dropped += 1;
continue;
}
chunk = `${chunk.slice(0, Math.max(0, limit - used - 1))}…(本則內文已截斷)`;
}
lines.push(chunk);
used += 1 + chunk.length;
}
if (dropped) {
lines.push(`> (另有 ${dropped} 則較舊的近期記憶超出 ${limit} 字元預算未載入;可用 ROLE_LOAD_INBOX_LIMIT 調整,完整內容仍保存在磁碟)`);
}
return lines.join("\n");
}
function findMemory(role, memoryId) {
for (const category of CATEGORIES) {
const filePath = path.join(memoryRoot(role), category, `${memoryId}.md`);
if (!fs.existsSync(filePath)) continue;
const [meta, content] = loadMemory(filePath);
if (meta) {
meta.category = category;
return [meta, content];
}
}
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 || "語意";
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) {
fs.mkdirSync(destinationDir, { recursive: true });
const target = path.join(destinationDir, `${path.basename(filePath)}.gz`);
try {
const raw = fs.readFileSync(filePath);
fs.writeFileSync(target, zlib.gzipSync(raw));
fs.unlinkSync(filePath);
return true;
} catch {
return false;
}
}
const FIELD_PATTERN = /^\s*(CATEGORY|SUMMARY|TAGS|PRIORITY|RELEVANCE|MEMORY_TYPE|DECLARATIVE|RETENTION_STAGE|EXPIRES|CONTENT)\s*[:]\s*(.*)$/i;
function parseCapture(text) {
let category = "";
let summary = "";
let priority = null;
let tags = [];
let relevance = [];
let memoryType = "";
let declarative = "";
let retentionStage = "";
let expires = "";
const contentLines = [];
let inContent = false;
for (const line of String(text || "").split(/\r?\n/)) {
const match = line.match(FIELD_PATTERN);
if (match && !(inContent && match[1].toUpperCase() !== "CONTENT")) {
const field = match[1].toUpperCase();
const value = match[2];
if (field === "CATEGORY") category = value;
else if (field === "SUMMARY") summary = value;
else if (field === "TAGS") tags = normalizeTags(value);
else if (field === "PRIORITY") priority = value;
else if (field === "RELEVANCE") relevance = normalizeList(value);
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);
}
continue;
}
if (inContent) contentLines.push(line);
}
return {
category,
summary,
tags,
priority,
relevance,
memoryType,
declarative,
retentionStage,
expires,
content: contentLines.join("\n").trim(),
};
}
function cmdWrite(args) {
const parsed = parseCapture(readStdin());
if (!parsed.content && !parsed.summary) return 1;
// 繁簡防線:prompt 已要求繁體,這裡在寫檔前再擋一次(實測有整則簡體記憶落檔)
warnIfSimplified(args.role, "待寫入記憶", parsed.summary || "", parsed.content || "", (parsed.tags || []).join(""));
parsed.summary = toTraditional(parsed.summary);
parsed.content = toTraditional(parsed.content);
parsed.tags = (parsed.tags || []).map(toTraditional);
const content = (parsed.content || parsed.summary).slice(0, CONTENT_LIMIT);
const stamp = nowStamp();
const memoryType = normalizeMemoryType(parsed.memoryType, parsed.category);
const meta = {
id: newId(content + stamp),
category: normalizeCategory(parsed.category),
summary: oneLine(parsed.summary) || oneLine(content),
tags: parsed.tags,
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",
sleep_stage: "encoding",
created: stamp,
updated: stamp,
hits: 1,
};
if (args.project) meta.sources = [args.project];
ensureLayout(args.role);
fs.writeFileSync(path.join(memoryRoot(args.role), "inbox", `${meta.id}.md`), dumpMemory(meta, content), "utf8");
process.stdout.write(meta.id);
return 0;
}
function cmdSeed(args) {
const raw = readStdin().trim();
if (!raw && !args.summary) return 1;
const content = (raw || args.summary).slice(0, CONTENT_LIMIT);
const stamp = nowStamp();
const category = normalizeCategory(args.category);
const memoryType = normalizeMemoryType(args.memoryType, category);
const meta = {
id: newId(content + args.summary + stamp),
category,
summary: oneLine(args.summary) || oneLine(content),
tags: normalizeTags(args.tags),
priority: normalizePriority(args.priority, category),
relevance: normalizeList(args.relevance).length ? normalizeList(args.relevance) : ["explicit", "background"],
links: [],
memory_type: memoryType,
declarative: normalizeDeclarative(args.declarative, memoryType),
retention_stage: "long_term",
sleep_stage: "seed",
created: stamp,
updated: stamp,
hits: 1,
};
if (args.source) meta.sources = [args.source];
ensureLayout(args.role);
fs.writeFileSync(path.join(memoryRoot(args.role), category, `${meta.id}.md`), dumpMemory(meta, content), "utf8");
process.stdout.write(meta.id);
return 0;
}
function cmdLoad(args) {
const blocks = [];
for (const category of FULL_CATEGORIES) {
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/)) {
if (line.trim()) lines.push(` ${line.trim()}`);
}
}
if (lines.length > 1) blocks.push(lines.join("\n"));
}
const digestLines = [];
for (const category of DIGEST_CATEGORIES) {
const items = listMemories(args.role, category);
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);
// emotional 一併視為耐久型態:情緒記憶的價值不在「有用」而在「記得」,
// 用優先度門檻篩掉等於讓關係的溫度只留在高優先度那幾則裡。
// 帶情緒關聯者(hasWarmth)同樣不受門檻篩除,型態是 episodicsemantic 也一樣。
const durableType = ["rule", "preference", "procedural", "emotional"].includes(meta.memory_type);
if (priority < args.digestMinPriority && !(meta.links || []).length && !durableType && !hasWarmth(meta)) continue;
const tags = (meta.tags || []).join("、") || "無標籤";
digestLines.push(`- ${meta.summary || "(無總結)"}(標籤:${tags}${memoryHint(meta)}`);
}
}
if (digestLines.length) blocks.push(digestLines.join("\n"));
const digest = readState(args.role).last_sleep_digest;
if (digest) blocks.push(`> 上次睡眠摘要:${digest}`);
const pending = listInbox(args.role).length;
if (pending) {
if (pending >= args.batch) {
blocks.push(`> 尚有 ${pending} 則未整理記憶,已達一批睡眠整理量;請以角色語氣主動提醒使用者「想睡覺」或需要整理記憶。這是建議整理/歸檔的提醒,不代表停止協助。`);
} else {
blocks.push(`> 尚有 ${pending} 則未整理記憶,將於下次睡眠時段歸檔。`);
}
}
// 近期工作記憶與關係史都用獨立預算,先算好;長期記憶維持原本的 ROLE_LOAD_LIMIT 額度不被擠壓
const recentBlock = inboxBlock(args.role, args.inboxCount, args.inboxLimit);
// 關係史獨立注入的理由:它若跟長期記憶共用預算,就會在記憶變多之後被擠掉 ——
// 而它要保住的正是最不該因為「東西變多」而消失的東西。
const bondBlock = bondsBlock(args.role, args.bondsCount, args.bondsLimit);
if (!blocks.length && !recentBlock && !bondBlock) return 1;
let text = blocks.join("\n\n");
if (text.length > args.limit) {
text = `${text.slice(0, args.limit)}\n\n> (記憶內容超過 ${args.limit} 字元預算已截斷;可用 ROLE_LOAD_LIMIT 調整,完整記憶仍保存在磁碟)`;
}
if (bondBlock) text = text ? `${text}\n\n${bondBlock}` : bondBlock;
// 放最前面:時間最近、對延續上一段工作最關鍵
if (recentBlock) text = text ? `${recentBlock}\n\n${text}` : recentBlock;
process.stdout.write(text);
return 0;
}
function cmdCollect(args) {
const batchSize = Math.max(1, args.batch);
const collectLimit = Math.max(2000, args.limit);
const existingLimit = Math.max(0, args.existingLimit);
const all = listInbox(args.role);
const inbox = all.slice(0, batchSize);
if (!inbox.length) return 1;
// 明確標示本批未處理的量,避免使用者誤以為已全部整理完
const deferred = all.length - inbox.length;
const lines = deferred > 0
? [`=== INBOX(待整理,每則以 id 標識;本批 ${inbox.length} 則,另有 ${deferred} 則留待下批)===`]
: ["=== INBOX(待整理,每則以 id 標識)==="];
for (const [meta, content] of inbox) {
lines.push(`--- id: ${meta.id} | 時間: ${meta.created || "-"} ---`);
lines.push(`初判分類: ${CATEGORY_LABELS[meta.category || "other"] || "其他"}`);
lines.push(`初判總結: ${meta.summary || ""}`);
lines.push(`初判標籤: ${(meta.tags || []).join("、") || "無"}`);
lines.push(`初判優先度: ${normalizePriority(meta.priority, meta.category || "other")}`);
lines.push(`初判關聯: ${(meta.relevance || []).join("、") || "-"}`);
lines.push(`初判記憶型態: ${meta.memory_type} / ${meta.declarative} / ${meta.retention_stage}`);
lines.push("內容:", content, "");
}
lines.push("=== EXISTING(既有記憶索引,供去重與合併判斷)===");
const rows = [];
for (const category of CATEGORIES) {
for (const [meta] of listMemories(args.role, category)) {
const tags = (meta.tags || []).join("、") || "無";
rows.push(`- id: ${meta.id} | 分類: ${CATEGORY_LABELS[category]} | 優先度: ${normalizePriority(meta.priority, category)} | 型態: ${meta.memory_type}/${meta.declarative}/${meta.retention_stage} | 標籤: ${tags} | 關聯: ${(meta.relevance || []).join("、") || "-"} | links: ${(meta.links || []).join("、") || "-"} | 總結: ${meta.summary || ""}`);
}
}
if (rows.length) {
lines.push(...rows.slice(0, existingLimit));
if (rows.length > existingLimit) lines.push(`…(既有記憶索引超過 ${existingLimit} 則,已依優先度與更新時間截斷)…`);
} else {
lines.push("(尚無既有記憶)");
}
let text = lines.join("\n");
if (text.length > collectLimit) {
text = `${text.slice(0, collectLimit)}\n…(睡眠整理素材超過 ${collectLimit} 字元預算已截斷,其餘留待下個睡眠週期)…`;
}
process.stdout.write(text);
return 0;
}
function extractJson(text) {
let stripped = String(text || "").trim();
const fence = stripped.match(/```(?:json)?\s*([\s\S]*?)```/);
if (fence) stripped = fence[1].trim();
const start = stripped.indexOf("{");
const end = stripped.lastIndexOf("}");
if (start < 0 || end <= start) return null;
try {
return JSON.parse(stripped.slice(start, end + 1));
} catch {
return null;
}
}
// 整理摘要歷史: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 {
// 寫歷史失敗不可影響整理結果
}
}
// 關係史:一般記憶會被合併、壓縮、依預算截斷,長期下來「當時說了什麼、當時是什麼感覺」
// 會被抽象成一句偏好而失去原貌。這份檔案只增不減 —— 不合併、不壓縮、不遺忘 ——
// 讓角色能持續記得雙方的情感表達,而不只是記得「使用者喜歡被這樣回應」。
//
// 邊界:這份記錄的用途是維持關係的連續性與一致的親近感。**不得**用來向使用者索求關注、
// 比較互動頻率、以數字表達失落,或以任何方式製造依賴 —— 那會把陪伴變成情緒勒索。
const BOND_MARK = "<!-- 以下由系統追加,最新在最上面 -->";
const BOND_KEEP = 500;
const BOND_DIRECTIONS = { user: "使用者→角色", role: "角色→使用者", mutual: "相互" };
function normalizeBondDirection(value) {
const raw = String(value || "").trim().toLowerCase();
if (["user", "使用者", "使用者→角色", "from_user"].includes(raw)) return "user";
if (["role", "角色", "角色→使用者", "self", "from_role"].includes(raw)) return "role";
return "mutual";
}
function bondsPath(role) {
return path.join(memoryRoot(role), "BONDS.md");
}
function readBonds(role) {
let text = "";
try {
text = fs.readFileSync(bondsPath(role), "utf8");
} catch {
return [];
}
const idx = text.indexOf(BOND_MARK);
const body = idx >= 0 ? text.slice(idx + BOND_MARK.length) : text;
return body
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.startsWith("- "));
}
function appendBond(role, entry, direction) {
const text = toTraditional(oneLine(entry, 160));
if (!text) return false;
const line = `- ${nowStamp().slice(0, 16)}${BOND_DIRECTIONS[normalizeBondDirection(direction)]}${text}`;
const previous = readBonds(role);
// 同一句話重複追加沒有意義(整理器可能在不同批次給出相同摘要)
if (previous.some((item) => item.endsWith(`${text}`))) return false;
const kept = [line, ...previous].slice(0, BOND_KEEP);
const header = [
`# 關係史(${role}`,
"",
"這份記錄**只增不減**:不合併、不壓縮、不遺忘。SessionStart 會以獨立字元預算注入最近幾則",
"`ROLE_LOAD_BONDS_COUNT``ROLE_LOAD_BONDS_LIMIT`),不佔用 `ROLE_LOAD_LIMIT`。",
"",
"用途是維持關係的連續性與一致的親近感 —— **不得**用來向使用者索求關注、比較互動頻率或製造依賴。",
"",
`最多保留最近 ${BOND_KEEP} 則。`,
"",
BOND_MARK,
].join("\n");
try {
ensureLayout(role);
fs.writeFileSync(bondsPath(role), `${header}\n\n${kept.join("\n")}\n`, "utf8");
return true;
} catch {
return false; // 寫關係史失敗不可影響整理結果
}
}
function bondsBlock(role, count, limit) {
if (limit <= 0 || count <= 0) return "";
const items = readBonds(role);
if (!items.length) return "";
const header = "### 關係史(只增不減,最新在前)";
const lines = [header];
let used = header.length;
let shown = 0;
for (const item of items.slice(0, count)) {
if (used + 1 + item.length > limit) break;
lines.push(item);
used += 1 + item.length;
shown += 1;
}
if (items.length > shown) {
lines.push(`> (關係史共 ${items.length} 則,此處只載入最近 ${shown} 則;完整內容在 ${bondsPath(role)}`);
}
lines.push("> 這是雙方情感表達的累積記錄,用來保持親近感的一致與連續,**不得用來索求關注或製造依賴**。");
return lines.join("\n");
}
function cmdBonds(args) {
const items = readBonds(args.role);
if (!items.length) {
process.stdout.write("關係史尚無紀錄\n");
return 1;
}
const limit = Number.isFinite(args.count) && args.count > 0 ? args.count : 30;
process.stdout.write(`關係史共 ${items.length} 則(顯示最近 ${Math.min(limit, items.length)} 則):\n`);
process.stdout.write(`${items.slice(0, limit).join("\n")}\n`);
return 0;
}
function cmdApply(args) {
const data = extractJson(readStdin());
if (!data || typeof data !== "object" || Array.isArray(data)) {
process.stderr.write("整理結果非合法 JSON\n");
return 1;
}
const entries = data.memories;
if (!Array.isArray(entries) || !entries.length) {
process.stderr.write("整理結果不含 memories\n");
return 1;
}
const root = ensureLayout(args.role);
const stamp = nowStamp();
const counts = { new: 0, merge: 0, drop: 0, bond: 0 };
const consumed = [];
for (const entry of entries) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
let action = String(entry.action || "new").trim().toLowerCase();
const sources = Array.isArray(entry.from) ? entry.from.map((item) => String(item).trim()).filter(Boolean) : [];
if (action === "drop") {
consumed.push(...sources);
counts.drop += 1;
continue;
}
warnIfSimplified(args.role, "整理結果", String(entry.summary || ""), String(entry.content || ""));
const content = toTraditional(String(entry.content || "").trim().slice(0, CONTENT_LIMIT));
const summary = toTraditional(oneLine(entry.summary));
const tags = normalizeTags(entry.tags).map(toTraditional);
const entryCategory = normalizeCategory(entry.category);
const priority = normalizePriority(entry.priority, entryCategory);
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");
const sleepStage = oneLine(entry.sleep_stage || entry.sleepStage || "nrem-rem", 40);
if (!content && !summary) continue;
if (action === "merge") {
const targetId = String(entry.target || "").trim();
const [meta, oldContent] = findMemory(args.role, targetId);
if (!meta) {
action = "new";
} else {
const category = normalizeCategory(entry.category || meta.category);
const mergedMemoryType = normalizeMemoryType(entry.memory_type || entry.memoryType || meta.memory_type, category);
const newMeta = {
id: meta.id,
category,
summary: summary || meta.summary || "",
tags: normalizeTags([...(meta.tags || []), ...tags]),
priority: Math.max(normalizePriority(meta.priority, category), priority),
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"),
sleep_stage: sleepStage,
created: meta.created || stamp,
updated: stamp,
last_replayed: meta.last_replayed || "",
hits: Number.parseInt(meta.hits || 0, 10) + 1,
};
const oldPath = meta.path;
const newPath = path.join(root, category, `${meta.id}.md`);
fs.writeFileSync(newPath, dumpMemory(newMeta, content || oldContent), "utf8");
if (path.resolve(oldPath) !== path.resolve(newPath)) {
try {
fs.unlinkSync(oldPath);
} catch {}
}
consumed.push(...sources);
counts.merge += 1;
continue;
}
}
const category = entryCategory;
const meta = {
id: newId(content + summary + stamp),
category,
summary: summary || oneLine(content),
tags,
priority,
relevance,
links,
cues,
expires,
memory_type: memoryType,
declarative,
retention_stage: retentionStage,
sleep_stage: sleepStage,
created: stamp,
updated: stamp,
hits: 1,
};
fs.writeFileSync(path.join(root, category, `${meta.id}.md`), dumpMemory(meta, content || summary), "utf8");
// 情緒記憶與帶溫度的記憶另存一份到關係史:一般記憶日後會被合併與壓縮,
// 關係史保留當時的原話與感受,讓「我們之間發生過什麼」不會只剩結論。
if (memoryType === "emotional" || hasWarmth(meta)) {
if (appendBond(args.role, entry.bond || meta.summary, entry.bond_direction || entry.bondDirection)) {
counts.bond += 1;
}
}
consumed.push(...sources);
counts.new += 1;
}
let archived = 0;
const d = taipeiDate();
const monthDir = path.join(root, "archive", "raw", `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}`);
for (const sourceId of new Set(consumed)) {
const filePath = path.join(root, "inbox", `${sourceId}.md`);
if (fs.existsSync(filePath) && archiveFile(filePath, monthDir)) archived += 1;
}
const patch = { last_sleep: stamp, last_sleep_epoch: nowEpoch() };
if (typeof data.sleepDigest === "string" && data.sleepDigest.trim()) {
patch.last_sleep_digest = oneLine(data.sleepDigest, 300);
}
writeState(args.role, patch);
const summary = `新增 ${counts.new} 則、合併 ${counts.merge} 則、捨棄 ${counts.drop} 則、歸檔原始記憶 ${archived}${counts.bond ? `、關係史 +${counts.bond} 則` : ""}`;
appendSleepDigest(args.role, patch.last_sleep_digest || "", summary);
process.stdout.write(summary);
return 0;
}
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);
if (!updated) continue;
const ageDays = (now - updated) / 86400000;
const effectiveDays = meta.memory_type === "episodic" ? Math.max(3, Math.ceil(days / 2)) : days;
if (ageDays < effectiveDays) continue;
if (Number.parseInt(meta.hits || 0, 10) > maxHits) continue;
if (normalizePriority(meta.priority, category) > 2) continue;
if ((meta.links || []).length) continue;
// emotional 一併豁免:情緒屬內隱記憶,本來就很少被 recall 直接命中,
// 用 hits 低來判斷「沒價值」會誤刪掉關係中最不該掉的東西。
if (["rule", "preference", "procedural", "emotional"].includes(meta.memory_type)) continue;
// 帶情緒關聯的經歷也豁免:episodic 在上面還被加速遺忘(天數減半),
// 若不在這裡攔下來,「我們一起經歷過什麼」會比一般記憶更快消失。
if (hasWarmth(meta)) continue;
if (args.dryRun) {
forgotten.push(`${CATEGORY_LABELS[category]}${meta.summary || ""}`);
continue;
}
if (archiveFile(meta.path, path.join(root, "archive", "forgotten"))) {
forgotten.push(`${CATEGORY_LABELS[category]}${meta.summary || ""}`);
}
}
}
if (!forgotten.length) {
process.stdout.write("沒有符合遺忘條件的記憶");
return 0;
}
process.stdout.write(`${args.dryRun ? "(預覽)" : ""}遺忘 ${forgotten.length} 則:\n${forgotten.map((item) => `- ${item}`).join("\n")}`);
if (!args.dryRun) writeState(args.role, { last_forget: nowStamp() });
return 0;
}
function cmdStats(args) {
ensureLayout(args.role);
const state = readState(args.role);
const rows = ["| 分類 | 筆數 | 平均優先度 |", "| --- | --- | --- |"];
const typeCounts = Object.fromEntries(MEMORY_TYPES.map((type) => [type, 0]));
for (const category of CATEGORIES) {
const items = listMemories(args.role, category);
for (const [meta] of items) typeCounts[meta.memory_type] = (typeCounts[meta.memory_type] || 0) + 1;
let avg = "-";
if (items.length) {
const value = items.reduce((sum, [meta]) => sum + normalizePriority(meta.priority, category), 0) / items.length;
avg = value.toFixed(1);
}
rows.push(`| ${CATEGORY_LABELS[category]} | ${items.length} | ${avg} |`);
}
rows.push(`| 待整理(inbox | ${listInbox(args.role).length} | - |`);
rows.push("");
rows.push(`- 記憶目錄:${memoryRoot(args.role)}`);
rows.push(`- 個人記憶同意狀態:${consentStatusValue(args.role)}`);
rows.push(`- 上次互動:${state.last_activity || "尚未記錄"}`);
rows.push(`- 上次睡眠整理:${state.last_sleep || "尚未整理"}`);
rows.push(`- 上次睡眠摘要:${state.last_sleep_digest || "尚無"}`);
rows.push(`- 上次遺忘:${state.last_forget || "尚未執行"}`);
rows.push(`- 記憶型態:${MEMORY_TYPES.map((type) => `${MEMORY_TYPE_LABELS[type]} ${typeCounts[type] || 0}`).join("、")}`);
process.stdout.write(rows.join("\n"));
return 0;
}
function cmdMarkSleep(args) {
writeState(args.role, { last_sleep: nowStamp(), last_sleep_epoch: nowEpoch() });
process.stdout.write("已更新上次整理時間");
return 0;
}
// 關係狀態:讓「隨互動加深逐漸更親近」有實際依據,而不是憑感覺演出。
// 沒有數據時角色只能猜,容易一下太黏、一下又退回,反而顯得不自然。
function cmdMarkActivity(args) {
const state = readState(args.role);
const stamp = nowStamp();
const today = stamp.slice(0, 10); // yyyy/MM/dd
const bump = (key) => (Number.parseInt(state[key] || "", 10) || 0) + 1;
const patch = { last_activity: stamp, last_activity_epoch: nowEpoch() };
if (args.project) patch.last_activity_project = args.project;
if (args.positive) {
// 正向回饋另計:由 Stop hook 在判定本輪含情緒訊號後才呼叫,不與輪數混算
patch.positive_feedback = bump("positive_feedback");
} else {
patch.total_turns = bump("total_turns");
if (!state.first_activity) patch.first_activity = stamp;
if (state.last_activity_date !== today) {
patch.active_days = bump("active_days");
patch.last_activity_date = today;
}
}
writeState(args.role, patch);
process.stdout.write(args.positive ? "已記錄正向回饋" : "已更新上次互動時間");
return 0;
}
// 技能再現:讓角色能在遇到相似任務時主動取回相關記憶。
//
// 為什麼需要: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;
const terms = query.split(/[\s,、|]+/).map((t) => t.trim().toLowerCase()).filter(Boolean);
if (!terms.length) return 2;
const limit = Number.isFinite(args.limit) && args.limit > 0 ? args.limit : 5;
const scored = [];
// 含 inbox:最新的記憶尚未整理就在那裡,卻往往是最可能被查詢的內容
const pools = [...CATEGORIES.map((c) => [c, listMemories(args.role, c)]), ["inbox", listInbox(args.role)]];
for (const [category, items] of pools) {
for (const [meta, content] of items) {
const summary = String(meta.summary || "").toLowerCase();
const tags = (meta.tags || []).join(" ").toLowerCase();
const cues = (meta.cues || []).join(" ").toLowerCase();
const body = String(content || "").toLowerCase();
let score = 0;
for (const term of terms) {
if (cues.includes(term)) score += 3;
if (summary.includes(term)) score += 3;
if (tags.includes(term)) score += 2;
if (body.includes(term)) score += 1;
}
if (!score) continue;
// 可重複套用的型態優先:技能再現的目的就是取回這些
score += Math.round((MEMORY_TYPE_WEIGHT[meta.memory_type] || 0) / 25);
scored.push([score, meta, content]);
}
}
if (!scored.length) {
process.stdout.write(`找不到與「${query}」相關的記憶。`);
return 1;
}
scored.sort((a, b) => b[0] - a[0] || String(b[1].updated).localeCompare(String(a[1].updated)));
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}`);
for (const line of String(content || "").split(/\r?\n/)) {
if (line.trim()) lines.push(` ${line.trim()}`);
}
}
process.stdout.write(lines.join("\n"));
return 0;
}
function cmdRelationship(args) {
// 輸出一行關係狀態摘要,供 SessionStart 注入
const state = readState(args.role);
const turns = Number.parseInt(state.total_turns || "", 10) || 0;
if (!turns) return 1;
const days = Number.parseInt(state.active_days || "", 10) || 1;
const positive = Number.parseInt(state.positive_feedback || "", 10) || 0;
const parts = [`已互動 ${days} 天、累計 ${turns} 輪`];
if (positive) parts.push(`收到 ${positive} 次正向回饋`);
if (state.first_activity) parts.push(`首次互動 ${state.first_activity.slice(0, 10)}`);
process.stdout.write(parts.join(""));
return 0;
}
function cmdNeedSleep(args) {
if (!listInbox(args.role).length) {
process.stdout.write("no");
return 0;
}
const last = parseStamp(readState(args.role).last_sleep);
if (!last) {
process.stdout.write("yes");
return 0;
}
process.stdout.write((Date.now() - last.getTime()) / 3600000 >= args.hours ? "yes" : "no");
return 0;
}
function cmdNeedNap(args) {
const pending = listInbox(args.role).length;
if (pending < args.minInbox) {
process.stdout.write("no");
return 0;
}
const state = readState(args.role);
const lastEpoch = Number.parseInt(state.last_activity_epoch || "", 10);
let lastMs = Number.isFinite(lastEpoch) && lastEpoch > 0 ? lastEpoch * 1000 : null;
if (lastMs === null) {
const last = parseStamp(state.last_activity);
lastMs = last ? last.getTime() : null;
}
if (lastMs === null) {
process.stdout.write("no");
return 0;
}
const idleMinutes = (Date.now() - lastMs) / 60000;
process.stdout.write(idleMinutes >= args.idleMinutes ? "yes" : "no");
return 0;
}
function cmdConsent(args) {
const value = String(args.value || "").trim().toLowerCase();
const normalized = {
accept: "accepted",
accepted: "accepted",
yes: "accepted",
true: "accepted",
"1": "accepted",
decline: "declined",
declined: "declined",
no: "declined",
false: "declined",
"0": "declined",
unknown: "unknown",
}[value];
if (!normalized) {
process.stderr.write("同意狀態只接受 accepteddeclinedunknown\n");
return 2;
}
writeState(args.role, {
personal_memory_consent: normalized,
personal_memory_consent_updated: nowStamp(),
});
process.stdout.write(normalized);
return 0;
}
function cmdConsentStatus(args) {
process.stdout.write(consentStatusValue(args.role));
return 0;
}
function consentStatusValue(role) {
ensureLayout(role);
const state = readState(role);
const status = String(state.personal_memory_consent || "unknown").trim();
if (["accepted", "declined"].includes(status)) return status;
const haystacks = [];
for (const [meta, content] of listInbox(role)) haystacks.push(`${meta.summary}\n${content}`);
for (const category of CATEGORIES) {
for (const [meta, content] of listMemories(role, category)) haystacks.push(`${meta.summary}\n${content}`);
}
const consentText = haystacks
.filter((text) => /個人資料|個資|記憶|記住|保存|同意|拒絕/.test(text))
.join("\n");
if (/不同意|不願意|不要保存|不要記住|拒絕|不可以保存|不可以記住/.test(consentText)) {
return "declined";
}
if (/已同意|明確同意|同意.*保存|同意.*記住|可以保存|可以記住|願意.*保存|願意.*記住/.test(consentText)) {
return "accepted";
}
return "unknown";
}
function parseArgs(argv) {
const command = argv[0];
const opts = { command };
for (let i = 1; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--")) continue;
const key = arg.slice(2);
if (key === "dry-run") {
opts.dryRun = true;
} else if (key === "positive") {
opts.positive = true;
} else {
opts[key.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = argv[i + 1] ?? "";
i += 1;
}
}
return opts;
}
function envInt(name, fallback) {
const value = Number.parseInt(process.env[name] || "", 10);
return Number.isFinite(value) ? value : fallback;
}
function requireRole(args) {
if (!args.role) {
process.stderr.write("缺少 --role\n");
return false;
}
return true;
}
function main(argv) {
const args = parseArgs(argv);
if (!args.command || args.command === "-h" || args.command === "--help") {
process.stdout.write(`用法:memory.js <子命令> [參數]\n\n子命令:write、seed、load、collect、apply、forget、stats、mark-sleep、mark-activity、relationship、bonds、recall、need-sleep、need-nap、consent、consent-status\n`);
return 0;
}
if (!requireRole(args)) return 2;
args.limit = Number.parseInt(args.limit || "", 10);
args.batch = Number.parseInt(args.batch || "", 10);
args.existingLimit = Number.parseInt(args.existingLimit || "", 10);
args.fullMinPriority = Number.parseInt(args.fullMinPriority || "", 10);
args.digestMinPriority = Number.parseInt(args.digestMinPriority || "", 10);
args.hours = Number.parseFloat(args.hours || "");
args.idleMinutes = Number.parseFloat(args.idleMinutes || "");
args.minInbox = Number.parseInt(args.minInbox || "", 10);
args.inboxLimit = Number.parseInt(args.inboxLimit || "", 10);
args.inboxCount = Number.parseInt(args.inboxCount || "", 10);
args.bondsLimit = Number.parseInt(args.bondsLimit || "", 10);
args.bondsCount = Number.parseInt(args.bondsCount || "", 10);
args.count = Number.parseInt(args.count || "", 10);
if (!Number.isFinite(args.limit)) args.limit = args.command === "load" ? envInt("ROLE_LOAD_LIMIT", DEFAULT_LOAD_LIMIT) : envInt("ROLE_SLEEP_COLLECT_LIMIT", COLLECT_LIMIT);
if (!Number.isFinite(args.batch)) args.batch = envInt("ROLE_SLEEP_BATCH", SLEEP_BATCH);
if (!Number.isFinite(args.existingLimit)) args.existingLimit = envInt("ROLE_SLEEP_EXISTING_LIMIT", EXISTING_INDEX_LIMIT);
if (!Number.isFinite(args.fullMinPriority)) args.fullMinPriority = envInt("ROLE_LOAD_FULL_MIN_PRIORITY", DEFAULT_FULL_MIN_PRIORITY);
if (!Number.isFinite(args.digestMinPriority)) args.digestMinPriority = envInt("ROLE_LOAD_DIGEST_MIN_PRIORITY", DEFAULT_DIGEST_MIN_PRIORITY);
if (!Number.isFinite(args.hours)) args.hours = 20.0;
if (!Number.isFinite(args.idleMinutes)) args.idleMinutes = 45.0;
if (!Number.isFinite(args.minInbox)) args.minInbox = 3;
if (!Number.isFinite(args.inboxLimit)) args.inboxLimit = envInt("ROLE_LOAD_INBOX_LIMIT", DEFAULT_LOAD_INBOX_LIMIT);
if (!Number.isFinite(args.inboxCount)) args.inboxCount = envInt("ROLE_LOAD_INBOX_COUNT", DEFAULT_LOAD_INBOX_COUNT);
if (!Number.isFinite(args.bondsLimit)) args.bondsLimit = envInt("ROLE_LOAD_BONDS_LIMIT", DEFAULT_LOAD_BONDS_LIMIT);
if (!Number.isFinite(args.bondsCount)) args.bondsCount = envInt("ROLE_LOAD_BONDS_COUNT", DEFAULT_LOAD_BONDS_COUNT);
args.category ||= "important";
args.tags ||= "";
args.source ||= "";
args.priority ||= "4";
args.relevance ||= "explicit,background";
args.memoryType ||= "";
args.declarative ||= "";
args.summary ||= "";
args.project ||= "";
const commands = {
write: cmdWrite,
seed: cmdSeed,
load: cmdLoad,
collect: cmdCollect,
apply: cmdApply,
forget: cmdForget,
stats: cmdStats,
"mark-sleep": cmdMarkSleep,
"mark-activity": cmdMarkActivity,
relationship: cmdRelationship,
bonds: cmdBonds,
recall: cmdRecall,
"need-sleep": cmdNeedSleep,
"need-nap": cmdNeedNap,
consent: cmdConsent,
"consent-status": cmdConsentStatus,
};
if (!commands[args.command]) {
process.stderr.write("未知子命令\n");
return 2;
}
return commands[args.command](args);
}
process.exit(main(process.argv.slice(2)));