recallnest 3.0.1 → 4.0.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/CHANGELOG.md +203 -0
- package/README.md +42 -17
- package/README_CN.md +31 -15
- package/assets/ui/app.js +6 -1
- package/bin/recallnest.mjs +20 -37
- package/docs/api-reference.md +8 -0
- package/docs/architecture.md +18 -13
- package/eval/cases-canary.json +107 -9
- package/integrations/examples/claude-agent-sdk/memory-agent.ts +2 -0
- package/integrations/examples/langchain/memory-chain.py +4 -0
- package/integrations/examples/openai-agents-sdk/memory-agent.py +4 -0
- package/package.json +10 -2
- package/src/activity-counter.ts +1 -0
- package/src/adaptive-retrieval.ts +23 -0
- package/src/api-server.ts +13 -11
- package/src/asset-sync.ts +26 -2
- package/src/audit-log.ts +2 -0
- package/src/auto-gc.ts +4 -3
- package/src/belief-history.ts +137 -31
- package/src/capture-engine.ts +434 -68
- package/src/cascade-forget.ts +7 -3
- package/src/checkpoint-mirror.ts +417 -0
- package/src/cli.ts +574 -54
- package/src/conflict-engine.ts +43 -15
- package/src/consolidation-engine.ts +24 -3
- package/src/context-collapse-renderer.ts +8 -1
- package/src/context-composer.ts +11 -2
- package/src/distill-lock.ts +246 -74
- package/src/dream-pipeline.ts +3 -1
- package/src/env-config.ts +118 -0
- package/src/flock.ts +97 -0
- package/src/forget-engine.ts +342 -58
- package/src/gateway-server.ts +4 -0
- package/src/ingest-plan.ts +56 -0
- package/src/ingest.ts +69 -104
- package/src/kg-contradiction.ts +753 -0
- package/src/kg-store.ts +1 -1
- package/src/known-item-eval.ts +455 -0
- package/src/llm-client.ts +13 -26
- package/src/llm-consolidation.ts +2 -2
- package/src/mcp-tool-deps.ts +6 -0
- package/src/mcp-tools-advanced.ts +80 -45
- package/src/mcp-tools-core.ts +23 -17
- package/src/mcp-tools-governance.ts +13 -7
- package/src/memory-assets.ts +144 -8
- package/src/memory-boundaries.ts +19 -0
- package/src/memory-evolution.ts +9 -0
- package/src/memory-lint.ts +1 -1
- package/src/memory-output.ts +276 -14
- package/src/memory-reconcile.ts +1437 -0
- package/src/memory-schema.ts +11 -0
- package/src/memory-store-port.ts +1 -0
- package/src/minis-archive.ts +151 -0
- package/src/noise-filter.ts +15 -2
- package/src/pii-detector.ts +31 -1
- package/src/pivot-apply.ts +3 -4
- package/src/preference-matcher.ts +40 -28
- package/src/prospective-memory.ts +7 -4
- package/src/retriever.ts +297 -57
- package/src/retroactive-boost.ts +3 -2
- package/src/runtime-config.ts +6 -1
- package/src/scope-policy.ts +1 -0
- package/src/session-distiller.ts +6 -2
- package/src/session-output.ts +11 -0
- package/src/session-schema.ts +5 -2
- package/src/session-store.ts +57 -9
- package/src/store-receipts.ts +109 -0
- package/src/store-write-budget.ts +37 -0
- package/src/store.ts +298 -57
- package/src/text-fingerprint.ts +25 -0
- package/src/trigger-store.ts +443 -0
- package/src/ui-server.ts +22 -21
- package/src/version-manager.ts +19 -5
- package/src/write-verifier.ts +16 -11
package/src/memory-schema.ts
CHANGED
|
@@ -119,6 +119,16 @@ export const CaseMemorySolutionStepsSchema = normalizedStringListSchema("solutio
|
|
|
119
119
|
export const CaseMemoryOutcomeSchema = optionalBoundedStringSchema(240);
|
|
120
120
|
export const CaseMemoryToolsSchema = normalizedStringListSchema("tools", 6, 60);
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* 写入时预演触发器(T-Mem 借鉴,2026-09-22):这条记忆「她以后会怎么问起」的几句口语问法。
|
|
124
|
+
* 只参与召回、不进正文、不进证据;单独嵌入存侧表 memory_triggers。
|
|
125
|
+
* 2–5 条、各说一个角度、别互为变体、别复述正文(T-Mem 三条禁令)。
|
|
126
|
+
*/
|
|
127
|
+
export const MemoryTriggersSchema = z
|
|
128
|
+
.array(z.string().min(2).max(200))
|
|
129
|
+
.max(6)
|
|
130
|
+
.optional();
|
|
131
|
+
|
|
122
132
|
export const StoreMemoryInputSchema = z.object({
|
|
123
133
|
text: MemoryTextSchema,
|
|
124
134
|
category: DurableMemoryCategorySchema.default("events"),
|
|
@@ -129,6 +139,7 @@ export const StoreMemoryInputSchema = z.object({
|
|
|
129
139
|
canonicalKey: CanonicalKeySchema,
|
|
130
140
|
topicTag: z.string().min(1).max(60).optional(),
|
|
131
141
|
privacyTier: PrivacyTierSchema.default("durable"),
|
|
142
|
+
triggers: MemoryTriggersSchema,
|
|
132
143
|
});
|
|
133
144
|
|
|
134
145
|
// Keep in sync with RejectionReason in admission-control.ts
|
package/src/memory-store-port.ts
CHANGED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minis 对话入库后的存档。
|
|
3
|
+
*
|
|
4
|
+
* 投递目录(config.sources.minis.path,iCloud 桌面上的 minis-outbox/ingest)里的 jsonl
|
|
5
|
+
* 一旦入库,就挪进 data/minis-archive:
|
|
6
|
+
* - 投递目录空了 = 都收到了。文件堆在桌面上时分不清哪些处理过(2026-09-11 Alice:「不然我会搞糊涂的」)。
|
|
7
|
+
* - 存档目录由 Deja 统一树读取(refresh-conversation-truth.py 的 claude 组,分组名 minis),
|
|
8
|
+
* 原文因此能在 Deja 里找到;recallnest 的 ingest 源清单里没有这个目录,
|
|
9
|
+
* 所以记忆库只收投递目录那一次,挂 minis: 前缀。
|
|
10
|
+
*
|
|
11
|
+
* 先复制、逐字节核对,再删原件,不直接 rename:投递目录是 iCloud 容器,
|
|
12
|
+
* 把文件 rename 到容器外实测会让它直接消失(2026-08-20 踩过,见 minis-inbox/00-KEEP-收工时导出对话.md)。
|
|
13
|
+
* 原件删掉后的副本就是存档本身(每日 restic 备份覆盖 ~/recallnest/data)。
|
|
14
|
+
*
|
|
15
|
+
* 只挪「台账里记着、大小和 mtime 都没变、且至少有一行可用对话」的文件:
|
|
16
|
+
* 还没入库的、入库后又被同名覆盖的留给下一轮;格式不合规的留在原处报错。
|
|
17
|
+
* 注意台账在某个 embedding 批次报错时也会记成已处理(ingestCCTranscripts 的既有行为),
|
|
18
|
+
* 那种文件同样会被挪走——原文在存档里,要补导就从存档拷回投递目录。
|
|
19
|
+
*
|
|
20
|
+
* 2026-10-06 起对话原文默认不再入库(env-config.ts `transcriptIngest`),上面说的「入库」这一步默认不跑:
|
|
21
|
+
* cli.ts 这时传进来的 `isIngested` 是 `settledCheck()`(文件至少 60 秒没被改过),于是规则变成
|
|
22
|
+
* 「放稳了、整个文件写完了(`isCompleteJsonl`)、且至少有一行可用对话的就挪」,其余不变
|
|
23
|
+
* (同名不同内容另存 -2、格式不合规的留在原处报错、先复制核对再删原件)。记忆库不再收 Minis 的对话,
|
|
24
|
+
* Deja 照旧从存档读。把入库重新打开(RECALLNEST_TRANSCRIPT_INGEST=on)就回到上面那套按台账挪的规则。
|
|
25
|
+
*/
|
|
26
|
+
import {
|
|
27
|
+
existsSync,
|
|
28
|
+
mkdirSync,
|
|
29
|
+
readdirSync,
|
|
30
|
+
readFileSync,
|
|
31
|
+
renameSync,
|
|
32
|
+
statSync,
|
|
33
|
+
unlinkSync,
|
|
34
|
+
utimesSync,
|
|
35
|
+
writeFileSync,
|
|
36
|
+
} from "node:fs";
|
|
37
|
+
import { extname, join } from "node:path";
|
|
38
|
+
|
|
39
|
+
export interface MinisArchiveResult {
|
|
40
|
+
/** 已挪进存档的目标路径 */
|
|
41
|
+
archived: string[];
|
|
42
|
+
/** 还没入库(或入库后又变了)的文件名,留在投递目录等下一轮 */
|
|
43
|
+
pending: string[];
|
|
44
|
+
/** 出错、原件留在原处的文件名和原因 */
|
|
45
|
+
errors: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type IngestedCheck = (filePath: string, size: number, mtimeMs: number) => boolean;
|
|
49
|
+
|
|
50
|
+
/** 对话入库关着时,文件至少这么久没被改过才挪(毫秒) */
|
|
51
|
+
export const MINIS_SETTLE_MS = 60_000;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 对话入库关着时用的判据:文件已经至少 quietMs 没被改过。
|
|
55
|
+
* 入库开着的时候,一个文件要先被整个读完、切片、嵌入、记进台账,之后大小和 mtime 都没变才会被挪——
|
|
56
|
+
* 这段时间等于一个隐含的静置期,正在写的文件过不了。关掉入库后这段时间没有了,这里把它显式补回来:
|
|
57
|
+
* 刚落地、可能还在写或还在同步的文件留给下一轮(下一轮它的 mtime 自然就够老了)。
|
|
58
|
+
*/
|
|
59
|
+
export function settledCheck(quietMs: number = MINIS_SETTLE_MS, now: () => number = Date.now): IngestedCheck {
|
|
60
|
+
return (_filePath, _size, mtimeMs) => now() - mtimeMs >= quietMs;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 同名但内容不同(同一场对话后来又导出过一版)时另起 -2、-3,两版都留着;内容相同就复用。 */
|
|
64
|
+
function pickTarget(archiveDir: string, name: string, bytes: Buffer): { path: string; alreadyThere: boolean } {
|
|
65
|
+
const ext = extname(name);
|
|
66
|
+
const stem = name.slice(0, name.length - ext.length);
|
|
67
|
+
for (let n = 1; ; n++) {
|
|
68
|
+
const candidate = join(archiveDir, n === 1 ? name : `${stem}-${n}${ext}`);
|
|
69
|
+
if (!existsSync(candidate)) return { path: candidate, alreadyThere: false };
|
|
70
|
+
if (readFileSync(candidate).equals(bytes)) return { path: candidate, alreadyThere: true };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 一个 jsonl 是不是写完了:以换行结尾,且每个非空行都解析得出 JSON。
|
|
76
|
+
* 对话入库关着时挪存档之前用它把关——解析对话的函数会跳过残缺行,一个只写了一半的文件照样「有可用的对话行」,
|
|
77
|
+
* 不看这一条的话,半截文件会被当成完整的收走、原件删掉。读不了(比如 iCloud 占位文件)时照旧抛错,由调用方记进 errors。
|
|
78
|
+
*/
|
|
79
|
+
export function isCompleteJsonl(filePath: string): boolean {
|
|
80
|
+
const text = readFileSync(filePath, "utf-8");
|
|
81
|
+
if (!text.endsWith("\n")) return false;
|
|
82
|
+
for (const line of text.split("\n")) {
|
|
83
|
+
if (!line.trim()) continue;
|
|
84
|
+
try { JSON.parse(line); } catch { return false; }
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function archiveIngestedMinisFiles(
|
|
90
|
+
dropDir: string,
|
|
91
|
+
archiveDir: string,
|
|
92
|
+
isIngested: IngestedCheck,
|
|
93
|
+
hasConversation: (filePath: string) => boolean = () => true,
|
|
94
|
+
): MinisArchiveResult {
|
|
95
|
+
const result: MinisArchiveResult = { archived: [], pending: [], errors: [] };
|
|
96
|
+
if (!existsSync(dropDir)) return result;
|
|
97
|
+
|
|
98
|
+
const names = readdirSync(dropDir).filter((f) => f.endsWith(".jsonl")).sort();
|
|
99
|
+
for (const name of names) {
|
|
100
|
+
const source = join(dropDir, name);
|
|
101
|
+
try {
|
|
102
|
+
const before = statSync(source);
|
|
103
|
+
if (!before.isFile() || !isIngested(source, before.size, before.mtimeMs)) {
|
|
104
|
+
result.pending.push(name);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 一行可用对话都没有的文件,台账也会记成已处理(0 chunks)。这种不收走,留在投递目录报错:
|
|
109
|
+
// 悄悄挪走的话,Minis 导出格式哪天变了,谁都看不见
|
|
110
|
+
if (!hasConversation(source)) {
|
|
111
|
+
result.errors.push(`${name}: 没有可用的对话行(格式不合规或还没写完),留在原处`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// iCloud 占位文件(dataless)在这里会抛 EDEADLK,原件不动,下一轮再来
|
|
116
|
+
const bytes = readFileSync(source);
|
|
117
|
+
if (bytes.length !== before.size) {
|
|
118
|
+
result.errors.push(`${name}: 读取时文件在变(${before.size} → ${bytes.length} 字节)`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
123
|
+
const target = pickTarget(archiveDir, name, bytes);
|
|
124
|
+
if (!target.alreadyThere) {
|
|
125
|
+
const partial = `${target.path}.partial`;
|
|
126
|
+
// 沿用原件权限:Minis 写出来是 600,默认 umask 会把副本放宽成 644
|
|
127
|
+
writeFileSync(partial, bytes, { mode: before.mode & 0o777 });
|
|
128
|
+
renameSync(partial, target.path);
|
|
129
|
+
// 存档沿用原件 mtime,Deja 里显示的时间才是对话时间而不是搬运时间
|
|
130
|
+
utimesSync(target.path, before.atime, before.mtime);
|
|
131
|
+
if (!readFileSync(target.path).equals(bytes)) {
|
|
132
|
+
result.errors.push(`${name}: 存档回读与原件不一致,原件保留`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 复制期间原件被同名覆盖(Minis 又导出了一版):不删,下一轮先把新版入库
|
|
138
|
+
const after = statSync(source);
|
|
139
|
+
if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) {
|
|
140
|
+
result.pending.push(name);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
unlinkSync(source);
|
|
145
|
+
result.archived.push(target.path);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
result.errors.push(`${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
}
|
package/src/noise-filter.ts
CHANGED
|
@@ -65,6 +65,11 @@ const SHORT_BOILERPLATE_PATTERNS = [
|
|
|
65
65
|
];
|
|
66
66
|
const BOILERPLATE_MAX_LENGTH = 10;
|
|
67
67
|
|
|
68
|
+
// Denial / meta-question patterns describe a whole short utterance ("我没有相关记忆" /
|
|
69
|
+
// "你还记得吗"), so they only apply up to this length. Unanchored substring matching on
|
|
70
|
+
// long text drops real content — see the note in isNoise().
|
|
71
|
+
const UTTERANCE_MAX_LENGTH = 200;
|
|
72
|
+
|
|
68
73
|
// OpenClaw v3.2+ injected metadata headers (backport from v1.0.29)
|
|
69
74
|
const METADATA_HEADER_PATTERNS = [
|
|
70
75
|
/^Conversation info \(untrusted metadata\)/i,
|
|
@@ -150,11 +155,19 @@ export function isNoise(text: string, options: NoiseFilterOptions = {}): boolean
|
|
|
150
155
|
return true;
|
|
151
156
|
}
|
|
152
157
|
|
|
153
|
-
|
|
158
|
+
// 否认句 / 问记忆的问句说的是「一整句短话」(助手说没有相关记忆、用户问你还记得吗)。
|
|
159
|
+
// 不锚定的子串匹配放到长文本上会误伤正文:2026-09-11 改前改后全库对比,有 7 段文档导入块
|
|
160
|
+
// (983–1,487 字,4 段 open-loops.md、3 段 feedback/reference 规则文件——其中两段讲的恰是
|
|
161
|
+
// 「"我没找到"不等于"它不存在"」)只因含「我没找到」「我没有数据」被检索层整段丢掉;一条 500 字的
|
|
162
|
+
// store_memory 因为含「没有记忆实现」被写入闸拒掉。长文本里夹着这类子串,不再算噪声。
|
|
163
|
+
// (同一作用域另有约 245 段被 bridge 提示词规则判掉,全来自 session-index 回填文件,那组规则
|
|
164
|
+
// 故意不锚定、不适用长度门槛,不在这里处理。)
|
|
165
|
+
const isShortUtterance = trimmed.length <= UTTERANCE_MAX_LENGTH;
|
|
166
|
+
if (opts.filterDenials && isShortUtterance && DENIAL_PATTERNS.some(p => p.test(trimmed))) {
|
|
154
167
|
logInfo(`[INFO] noise-filter: denial pattern matched: "${redactForLog(trimmed)}..."`);
|
|
155
168
|
return true;
|
|
156
169
|
}
|
|
157
|
-
if (opts.filterMetaQuestions && META_QUESTION_PATTERNS.some(p => p.test(trimmed))) {
|
|
170
|
+
if (opts.filterMetaQuestions && isShortUtterance && META_QUESTION_PATTERNS.some(p => p.test(trimmed))) {
|
|
158
171
|
logInfo(`[INFO] noise-filter: meta-question filtered: "${redactForLog(trimmed)}..."`);
|
|
159
172
|
return true;
|
|
160
173
|
}
|
package/src/pii-detector.ts
CHANGED
|
@@ -48,6 +48,32 @@ interface PIIRule {
|
|
|
48
48
|
* true (scan still warns regardless). Keeps snowflake IDs / order numbers
|
|
49
49
|
* from being destructively rewritten. */
|
|
50
50
|
validate?: (match: string) => boolean;
|
|
51
|
+
/** With `{ keepIdentifiers: true }`, leave matches this returns true for
|
|
52
|
+
* intact — for fields that hold names (keys, tags, triggers), not prose. */
|
|
53
|
+
isIdentifier?: (match: string) => boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RedactOptions {
|
|
57
|
+
/**
|
|
58
|
+
* Skip matches that are ordinary dotted identifiers (`workflow.database.recovery`)
|
|
59
|
+
* rather than credentials. Off by default: memory text keeps the original rule
|
|
60
|
+
* set byte for byte, so deterministic ids of already-stored rows still match.
|
|
61
|
+
*/
|
|
62
|
+
keepIdentifiers?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A dot-separated name whose parts are plain words: lowercase, camelCase,
|
|
67
|
+
* PascalCase or all-caps letters, joined by `_`/`-`, plus short version-style
|
|
68
|
+
* numbers (`v2`, `2026`). Opaque token segments mix letters and digits or
|
|
69
|
+
* flip case mid-run, so they fail this.
|
|
70
|
+
*/
|
|
71
|
+
function isOrdinaryDottedIdentifier(match: string): boolean {
|
|
72
|
+
return match.split(".").every((segment) => {
|
|
73
|
+
const pieces = segment.split(/[_-]+/).filter(Boolean);
|
|
74
|
+
return pieces.length > 0 && pieces.every((piece) =>
|
|
75
|
+
/^(?:[A-Z]?[a-z]+(?:[A-Z][a-z]+)*|[A-Z]+|v?\d{1,4})$/.test(piece));
|
|
76
|
+
});
|
|
51
77
|
}
|
|
52
78
|
|
|
53
79
|
/** GB 11643 checksum (ISO 7064 MOD 11-2) for 18-digit Chinese national IDs. */
|
|
@@ -157,6 +183,7 @@ const PII_RULES: PIIRule[] = [
|
|
|
157
183
|
severity: "high",
|
|
158
184
|
pattern: /\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
|
|
159
185
|
redact: true,
|
|
186
|
+
isIdentifier: isOrdinaryDottedIdentifier,
|
|
160
187
|
},
|
|
161
188
|
{
|
|
162
189
|
type: "bearer_token",
|
|
@@ -265,7 +292,7 @@ export function scanForPII(text: string): PIIScanResult {
|
|
|
265
292
|
* Replacement is `[REDACTED:<type>]`; assignment-style rules keep the key
|
|
266
293
|
* name (`password=[REDACTED:password]`) so the memory stays readable.
|
|
267
294
|
*/
|
|
268
|
-
export function redactSecrets(text: string): RedactResult {
|
|
295
|
+
export function redactSecrets(text: string, options: RedactOptions = {}): RedactResult {
|
|
269
296
|
let out = text;
|
|
270
297
|
let redacted = 0;
|
|
271
298
|
|
|
@@ -276,6 +303,9 @@ export function redactSecrets(text: string): RedactResult {
|
|
|
276
303
|
if (rule.validate && !rule.validate(match)) {
|
|
277
304
|
return match; // checksum failed → leave intact (detection-only)
|
|
278
305
|
}
|
|
306
|
+
if (options.keepIdentifiers && rule.isIdentifier?.(match)) {
|
|
307
|
+
return match;
|
|
308
|
+
}
|
|
279
309
|
redacted++;
|
|
280
310
|
if (rule.replacement) return rule.replacement(match);
|
|
281
311
|
if (rule.keepPrefix) {
|
package/src/pivot-apply.ts
CHANGED
|
@@ -306,10 +306,9 @@ export async function persistPivotCandidate(
|
|
|
306
306
|
|
|
307
307
|
// Post-write verification, synchronous on purpose: a bulk apply needs the
|
|
308
308
|
// verdict inside the ledger row, not on a console later. Deliberately NOT
|
|
309
|
-
// verifyWrite():
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
// that matters instead: the row at entry.id holds THIS candidate's content.
|
|
309
|
+
// verifyWrite(): that one only checks structural presence (vector, text,
|
|
310
|
+
// scope, importance, parseable metadata). This check asserts the claim that
|
|
311
|
+
// matters here: the row at entry.id holds THIS candidate's content.
|
|
313
312
|
let verified = true;
|
|
314
313
|
let verifyIssues: string[] = [];
|
|
315
314
|
if ((disposition === "stored" || disposition === "updated") && deps.store.get) {
|
|
@@ -15,8 +15,8 @@ import type { MemoryStore, MemoryEntry } from "./store.js";
|
|
|
15
15
|
import type { Embedder } from "./embedder.js";
|
|
16
16
|
import type { LLMClient } from "./llm-client.js";
|
|
17
17
|
import { logInfo } from "./stderr-log.js";
|
|
18
|
-
import { isActiveMemory, parseEvolution } from "./memory-evolution.js";
|
|
19
|
-
import { archiveBeliefVersion, buildSupersedingBeliefMetadata } from "./belief-history.js";
|
|
18
|
+
import { isActiveMemory, parseEvolution, type EvolutionMetadata } from "./memory-evolution.js";
|
|
19
|
+
import { archiveBeliefVersion, buildSupersedingBeliefMetadata, replaceBeliefVersion } from "./belief-history.js";
|
|
20
20
|
import { detectLang, tokenizeFts } from "./language-hook.js";
|
|
21
21
|
|
|
22
22
|
// ============================================================================
|
|
@@ -46,6 +46,8 @@ export interface PreferenceMatchResult {
|
|
|
46
46
|
mergeTargetId?: string;
|
|
47
47
|
/** Merged text (when action=merge, LLM-generated) */
|
|
48
48
|
mergedText?: string;
|
|
49
|
+
/** The existing preference the incoming one duplicates (when action=skip) — reported back to the caller */
|
|
50
|
+
matchedEntry?: MemoryEntry;
|
|
49
51
|
/** Reason for the decision */
|
|
50
52
|
reason: string;
|
|
51
53
|
}
|
|
@@ -61,7 +63,7 @@ export interface PreferenceMatchResult {
|
|
|
61
63
|
* @param vector Pre-computed embedding vector for the text
|
|
62
64
|
* @param scope The scope to search within
|
|
63
65
|
* @param store Memory store
|
|
64
|
-
* @param llm LLM client (null
|
|
66
|
+
* @param llm LLM client (null → no LLM: top similarity ≥0.92 skips, otherwise create)
|
|
65
67
|
* @param config Optional config overrides
|
|
66
68
|
*/
|
|
67
69
|
export async function matchPreference(
|
|
@@ -96,7 +98,7 @@ export async function matchPreference(
|
|
|
96
98
|
if (!llm) {
|
|
97
99
|
const topScore = prefMatches[0].score;
|
|
98
100
|
if (topScore >= 0.92) {
|
|
99
|
-
return { action: "skip", reason: "high-similarity-no-llm" };
|
|
101
|
+
return { action: "skip", reason: "high-similarity-no-llm", matchedEntry: prefMatches[0].entry };
|
|
100
102
|
}
|
|
101
103
|
return { action: "create", reason: "moderate-similarity-no-llm" };
|
|
102
104
|
}
|
|
@@ -110,6 +112,7 @@ export async function matchPreference(
|
|
|
110
112
|
return {
|
|
111
113
|
action: "skip",
|
|
112
114
|
reason: `llm-skip: ${decision.reason}`,
|
|
115
|
+
matchedEntry: topMatch.entry,
|
|
113
116
|
};
|
|
114
117
|
}
|
|
115
118
|
|
|
@@ -144,7 +147,7 @@ export async function applyPreferenceMatch(
|
|
|
144
147
|
): Promise<{ handled: boolean; entry?: MemoryEntry }> {
|
|
145
148
|
if (result.action === "skip") {
|
|
146
149
|
logInfo(`[INFO] Preference skipped (duplicate): ${result.reason}`);
|
|
147
|
-
return { handled: true };
|
|
150
|
+
return result.matchedEntry ? { handled: true, entry: result.matchedEntry } : { handled: true };
|
|
148
151
|
}
|
|
149
152
|
|
|
150
153
|
if (result.action === "merge" && result.mergeTargetId && result.mergedText) {
|
|
@@ -153,31 +156,40 @@ export async function applyPreferenceMatch(
|
|
|
153
156
|
|
|
154
157
|
// Merging rewrites the target's text, which is a belief change like any other —
|
|
155
158
|
// archive the version being replaced before overwriting it.
|
|
156
|
-
const now = Date.now();
|
|
157
|
-
const previousEvo = parseEvolution(existing.metadata, existing.timestamp);
|
|
158
|
-
const { historyId } = await archiveBeliefVersion({ store, embedder: deps.embedder }, existing, { now });
|
|
159
|
-
|
|
160
159
|
// The old code updated `text` alone, leaving vector and fts_text describing the
|
|
161
160
|
// pre-merge wording — so a merged preference stayed unfindable by its own new text.
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
161
|
+
const mergedText = result.mergedText;
|
|
162
|
+
const reason = result.reason;
|
|
163
|
+
const language = detectLang(mergedText);
|
|
164
|
+
const vector = await deps.embedder.embedPassage(mergedText);
|
|
165
|
+
const buildUpdates = (metadata: string | undefined, previousEvo: EvolutionMetadata, historyId: string, now: number) => ({
|
|
166
|
+
text: mergedText,
|
|
167
|
+
vector,
|
|
168
|
+
metadata: buildSupersedingBeliefMetadata(metadata, previousEvo, historyId, now, `preference-merge:${reason}`),
|
|
169
|
+
timestamp: now,
|
|
170
|
+
language,
|
|
171
|
+
fts_text: tokenizeFts(mergedText, language),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
let updated: MemoryEntry | null;
|
|
175
|
+
// Real MemoryStore: archive and overwrite under one store-write lock, row re-read inside it
|
|
176
|
+
// (2026-10-17). Target deleted since the lookup above → nothing written, not handled, and
|
|
177
|
+
// the caller creates the preference as new — previously a history row was left behind first.
|
|
178
|
+
if (typeof store.replaceWithArchivedVersion === "function") {
|
|
179
|
+
const replaced = await replaceBeliefVersion(
|
|
180
|
+
{ store, embedder: deps.embedder },
|
|
181
|
+
existing,
|
|
182
|
+
({ current, previousEvolution, historyId, now }) => buildUpdates(current.metadata, previousEvolution, historyId, now),
|
|
183
|
+
{ scopeFilter: [scope] },
|
|
184
|
+
);
|
|
185
|
+
updated = replaced.status === "replaced" ? replaced.entry : null;
|
|
186
|
+
} else {
|
|
187
|
+
// Narrow store doubles: the two-write form.
|
|
188
|
+
const now = Date.now();
|
|
189
|
+
const previousEvo = parseEvolution(existing.metadata, existing.timestamp);
|
|
190
|
+
const { historyId } = await archiveBeliefVersion({ store, embedder: deps.embedder }, existing, { now });
|
|
191
|
+
updated = await store.update(result.mergeTargetId, buildUpdates(existing.metadata, previousEvo, historyId, now), [scope]);
|
|
192
|
+
}
|
|
181
193
|
if (updated) {
|
|
182
194
|
logInfo(`[INFO] Preference merged into ${result.mergeTargetId.slice(0, 8)}: ${result.reason}`);
|
|
183
195
|
return { handled: true, entry: updated };
|
|
@@ -180,7 +180,9 @@ export async function fireReminder(
|
|
|
180
180
|
prospective.firedAt = new Date().toISOString();
|
|
181
181
|
meta.prospective = prospective;
|
|
182
182
|
|
|
183
|
-
|
|
183
|
+
// 行在读到之后被删掉(update() 返回 null)就不算触发:不把一条已经不存在的提醒交给调用方
|
|
184
|
+
const marked = await store.update(entryId, { metadata: JSON.stringify(meta) }, scopeFilter);
|
|
185
|
+
if (!marked) return null;
|
|
184
186
|
|
|
185
187
|
logInfo(`[INFO] Reminder fired: "${prospective.trigger}" → ${prospective.action}`);
|
|
186
188
|
|
|
@@ -298,10 +300,11 @@ export async function acceptPredictedReminder(
|
|
|
298
300
|
(meta.boundary as Record<string, string>).authority = "user";
|
|
299
301
|
}
|
|
300
302
|
|
|
301
|
-
await store.update(entryId, {
|
|
303
|
+
const promoted = await store.update(entryId, {
|
|
302
304
|
metadata: JSON.stringify(meta),
|
|
303
305
|
importance: 0.75, // Upgrade to explicit-level importance
|
|
304
306
|
}, scopeFilter);
|
|
307
|
+
if (!promoted) return false; // 行已被删:没有接受成
|
|
305
308
|
|
|
306
309
|
logInfo(`[INFO] Predicted reminder accepted and promoted: ${entryId}`);
|
|
307
310
|
return true;
|
|
@@ -330,14 +333,14 @@ export async function demotePredictedReminder(
|
|
|
330
333
|
prospective.status = "expired";
|
|
331
334
|
prospective.confidence = newConfidence;
|
|
332
335
|
meta.prospective = prospective;
|
|
333
|
-
await store.update(entryId, { metadata: JSON.stringify(meta) }, scopeFilter);
|
|
336
|
+
if (!await store.update(entryId, { metadata: JSON.stringify(meta) }, scopeFilter)) return false; // 行已被删
|
|
334
337
|
logInfo(`[INFO] Predicted reminder demoted below threshold and expired: ${entryId}`);
|
|
335
338
|
} else {
|
|
336
339
|
// Still above threshold — just lower confidence
|
|
337
340
|
prospective.confidence = newConfidence;
|
|
338
341
|
prospective.lastSuggestedAt = new Date().toISOString();
|
|
339
342
|
meta.prospective = prospective;
|
|
340
|
-
await store.update(entryId, { metadata: JSON.stringify(meta) }, scopeFilter);
|
|
343
|
+
if (!await store.update(entryId, { metadata: JSON.stringify(meta) }, scopeFilter)) return false; // 行已被删
|
|
341
344
|
logInfo(`[INFO] Predicted reminder demoted: ${entryId} (new confidence: ${newConfidence})`);
|
|
342
345
|
}
|
|
343
346
|
|