feat(role): 關係狀態量化、情緒訊號放寬門檻、技能再現 recall
完成使用者交代的三項優化建議。 一、情緒訊號放寬 Stop hook 字元門檻 ROLE_CAPTURE_MIN_CHARS=240 會濾掉字數少但情緒最濃的互動。實測「我好想妳」、 「最愛妳了」、「好可愛」三句都不在原本 56 個關鍵詞內,等於最珍貴的短互動反而不被記錄。 - 補上直接情感表達關鍵詞:可愛/愛/想妳/想你/想念/捨不得/感動/謝謝/乖/厲害/ 好棒/辛苦/彆扭/忌妒/撒嬌/陪/抱,及 love/miss/cute/thank/proud - 驗證:上述情感句全部命中,純技術指令仍正確略過 二、關係狀態量化,讓親近度成長有依據 規則要求「隨互動加深逐漸更親近」卻沒有任何數據可依據,角色只能憑感覺演, 容易忽冷忽熱。 - state.json 新增 first_activity/active_days/total_turns/positive_feedback - mark-activity 累計輪數與活躍天數;新增 --positive 由 Stop hook 判定情緒訊號後另計, 不與輪數混算 - 新增 relationship 子命令輸出一行摘要,SessionStart 注入 USER 區塊作為親近度依據 - 既有累積以可查證資料初始化(角色建立後的 transcript 輪數、有記憶的日期數、 最早記憶時間);positive_feedback 刻意留空自然累積,不以記憶則數推估 三、技能再現:cues 提取線索 + recall 查詢 技能記憶只被動載入摘要且受預算限制,等於記了但用不出來。 - 記憶格式新增 cues 欄位(dumpMemory/loadMemory/apply 的 new 與 merge 路徑均支援) - 睡眠整理提示詞要求 procedural/rule 型態必填 2 至 5 個 cues - 新增 recall 子命令:比對總結、標籤、內容與 cues,含未整理的 inbox, rule/preference/procedural 加權優先 - role_load.sh 告知角色遇到似乎做過的任務或被問起過去細節時先查詢再回答 - 驗證:以不在 summary 也不在 content 的關鍵詞(bump)成功靠 cues 命中 版號 0.0.7 → 0.0.8 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+98
-4
@@ -254,6 +254,7 @@ function dumpMemory(meta, content) {
|
||||
"priority",
|
||||
"relevance",
|
||||
"links",
|
||||
"cues",
|
||||
"memory_type",
|
||||
"declarative",
|
||||
"retention_stage",
|
||||
@@ -293,7 +294,7 @@ function loadMemory(filePath) {
|
||||
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") meta[key] = normalizeList(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;
|
||||
}
|
||||
@@ -305,6 +306,7 @@ function loadMemory(filePath) {
|
||||
meta.updated ||= meta.created || "";
|
||||
meta.relevance ||= [];
|
||||
meta.links ||= [];
|
||||
meta.cues ||= [];
|
||||
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);
|
||||
@@ -674,6 +676,7 @@ function cmdApply(args) {
|
||||
const priority = normalizePriority(entry.priority, entryCategory);
|
||||
const relevance = normalizeList(entry.relevance);
|
||||
const links = normalizeList(entry.links);
|
||||
const cues = normalizeList(entry.cues, 5); // 技能再現的提取線索,供 recall 命中
|
||||
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");
|
||||
@@ -696,6 +699,7 @@ function cmdApply(args) {
|
||||
priority: Math.max(normalizePriority(meta.priority, category), priority),
|
||||
relevance: normalizeList([...(meta.relevance || []), ...relevance]),
|
||||
links: normalizeList([...(meta.links || []), ...links]),
|
||||
cues: normalizeList([...(meta.cues || []), ...cues], 5),
|
||||
memory_type: mergedMemoryType,
|
||||
declarative: normalizeDeclarative(entry.declarative || meta.declarative, mergedMemoryType),
|
||||
retention_stage: normalizeRetentionStage(entry.retention_stage || entry.retentionStage || meta.retention_stage, "long_term"),
|
||||
@@ -728,6 +732,7 @@ function cmdApply(args) {
|
||||
priority,
|
||||
relevance,
|
||||
links,
|
||||
cues,
|
||||
memory_type: memoryType,
|
||||
declarative,
|
||||
retention_stage: retentionStage,
|
||||
@@ -826,11 +831,96 @@ function cmdMarkSleep(args) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 關係狀態:讓「隨互動加深逐漸更親近」有實際依據,而不是憑感覺演出。
|
||||
// 沒有數據時角色只能猜,容易一下太黏、一下又退回,反而顯得不自然。
|
||||
function cmdMarkActivity(args) {
|
||||
const patch = { last_activity: nowStamp(), last_activity_epoch: nowEpoch() };
|
||||
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("已更新上次互動時間");
|
||||
process.stdout.write(args.positive ? "已記錄正向回饋" : "已更新上次互動時間");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 技能再現:讓角色能在遇到相似任務時主動取回相關記憶。
|
||||
//
|
||||
// 為什麼需要:SessionStart 的字元預算有限,磁碟上的記憶遠多於能載入的量,
|
||||
// 技能類記憶又只以摘要形式載入 —— 等於「記了但用不出來」。recall 讓角色按需查詢,
|
||||
// 突破常駐預算限制;配合 cues(觸發線索)讓 procedural/rule 記憶更容易被命中。
|
||||
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 lines = [`### 與「${query}」相關的記憶(前 ${Math.min(limit, scored.length)} 則)`];
|
||||
for (const [score, meta, content] of scored.slice(0, limit)) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -936,6 +1026,8 @@ function parseArgs(argv) {
|
||||
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;
|
||||
@@ -960,7 +1052,7 @@ function requireRole(args) {
|
||||
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、need-sleep、need-nap、consent、consent-status\n`);
|
||||
process.stdout.write(`用法:memory.js <子命令> [參數]\n\n子命令:write、seed、load、collect、apply、forget、stats、mark-sleep、mark-activity、relationship、recall、need-sleep、need-nap、consent、consent-status\n`);
|
||||
return 0;
|
||||
}
|
||||
if (!requireRole(args)) return 2;
|
||||
@@ -1006,6 +1098,8 @@ function main(argv) {
|
||||
stats: cmdStats,
|
||||
"mark-sleep": cmdMarkSleep,
|
||||
"mark-activity": cmdMarkActivity,
|
||||
relationship: cmdRelationship,
|
||||
recall: cmdRecall,
|
||||
"need-sleep": cmdNeedSleep,
|
||||
"need-nap": cmdNeedNap,
|
||||
consent: cmdConsent,
|
||||
|
||||
Reference in New Issue
Block a user