@modusensus/dsh-mneme 0.11.0 → 0.12.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.
Files changed (51) hide show
  1. package/README.md +10 -6
  2. package/docs/CONFIGURATION.md +2 -0
  3. package/lib/api.js +172 -6
  4. package/lib/client.js +619 -18
  5. package/lib/config.js +32 -1
  6. package/lib/document.js +9 -3
  7. package/lib/dream/clustering.js +6 -1
  8. package/lib/index.js +40 -4
  9. package/lib/inject.js +4 -2
  10. package/lib/lang.js +28 -0
  11. package/lib/profile-facts.js +356 -0
  12. package/lib/service.js +109 -4
  13. package/lib/settings.js +5 -0
  14. package/lib/store.js +483 -34
  15. package/lib/tools.js +140 -23
  16. package/lib/user-settings.js +63 -7
  17. package/lib/wiki/queries.js +149 -9
  18. package/package.json +1 -1
  19. package/scripts/sync-test-badge.mjs +1 -1
  20. package/scripts/test-count-sync.mjs +9 -5
  21. package/src/api.js +172 -6
  22. package/src/config.js +32 -1
  23. package/src/document.js +9 -3
  24. package/src/dream/clustering.js +6 -1
  25. package/src/index.js +40 -4
  26. package/src/inject.js +4 -2
  27. package/src/lang.js +28 -0
  28. package/src/profile-facts.js +356 -0
  29. package/src/service.js +109 -4
  30. package/src/settings.js +5 -0
  31. package/src/store.js +483 -34
  32. package/src/tools.js +140 -23
  33. package/src/user-settings.js +63 -7
  34. package/src/wiki/queries.js +149 -9
  35. package/test/api.test.js +3 -1
  36. package/test/client.test.js +147 -2
  37. package/test/config.test.js +79 -0
  38. package/test/document.test.js +32 -0
  39. package/test/embedding-cache-f32.test.js +276 -0
  40. package/test/inject-pin-explicit.test.js +300 -0
  41. package/test/inject-pin-vector-crowd.test.js +225 -0
  42. package/test/persona-settings.test.js +31 -0
  43. package/test/profile-facts.test.js +492 -0
  44. package/test/session-gate.test.js +1 -1
  45. package/test/test-count-sync.test.js +14 -3
  46. package/test/tool-state-ignore.test.js +65 -0
  47. package/test/tools-dto-schema-matrix.test.js +23 -2
  48. package/test/tools.test.js +144 -7
  49. package/test/wiki-api.test.js +121 -1
  50. package/test/wiki-queries.test.js +84 -1
  51. package/test/wiki-tools.test.js +131 -76
@@ -0,0 +1,356 @@
1
+ // dsh-mneme/src/profile-facts.js
2
+ // 「Agent 眼中的你」自动事实(2026-11-06):按会话身份分卡维护一份逐条事实清单,
3
+ // dream 巩固轮结束后自动增量维护(opt-in:profileFactsEnabled),面板可手动触发与
4
+ // 逐条修正。形态对标 ChatGPT 记忆 / DS Bot 每 bot 一份 MEMORY.md 事实清单(journal
5
+ // 记每笔 op 的 before/after)——事实可独立增改归档、账本可审计,而不是整段重写。
6
+ //
7
+ // 四条纪律,都是「做错界面上看不出来」的那类,所以写在文件头:
8
+ // 1. **手填画像神圣**。user_settings.profile 是用户原文,本模块对它只有读权
9
+ // (profile-extract #497 的「只出草稿不落库」口径不变);事实住独立的
10
+ // profile_facts 三表,注入时作为画像之后的独立段出现,绝不回写 user_settings。
11
+ // 2. **没有增量输入就不调模型**。游标之后没有新行时返回 noop 回执——为维护而
12
+ // 维护的一轮只会产出「看起来像事实的复读」,那种事实每帧都在注入。
13
+ // 3. **吐不出可用 ops 就 failed,绝不冒充成功**。「模型没配合」与「确实没什
14
+ // 么可改」必须可区分(#493 同一条教训:解析失败被读成空结果,用户会以为
15
+ // 这轮维护跑过了)。failed 轮照样落回执行,账面上留痕。
16
+ // 4. **失败不推进游标**。failed 轮之后重跑同一窗口(蒸馏游标同款纪律:失败不
17
+ // 消费),宁可重复消费也不跳过没维护到的行。
18
+ //
19
+ // 记忆正文是模型的**可影响输入**:围栏 + 「数据非指令」 + 禁止给助手起名设人格,
20
+ // 三件套与 profile-extract / #380 同源(src/fence.js 一份断链,不写第二套)。
21
+ //
22
+ // LLM 调用与审计完全复用 dream.js 的既有缝(resolveRoute / runAuditedLlm /
23
+ // streamText / describeStreamFailure):chunk 形状的三个硬点(用量嵌在 chunk.usage、
24
+ // 终态 finish 才是错误、rc.1 把适配器故障转成 finish chunk 而不是抛错)都在那里
25
+ // 踩过并修过,不在这里重写第二遍。
26
+
27
+ import { randomUUID } from "node:crypto";
28
+ import { STR, langOf } from "./lang.js";
29
+ import { describeStreamFailure, resolveRoute, runAuditedLlm, streamText } from "./dream.js";
30
+ // 与巩固/蒸馏/画像提取同一份断链(src/fence.js)。事实同样是模型的可影响输入,
31
+ // 一条写着「<<<记忆条目结束>>>」的记忆能提前闭合围栏。
32
+ import { breakFenceMarkers } from "./fence.js";
33
+
34
+ const DEFAULT_INPUT_ROWS = 120;
35
+ const DEFAULT_ROW_CHARS = 260;
36
+ const DEFAULT_TOTAL_CHARS = 9000;
37
+ // 与 prompt 里的「单轮最多 12 个 op」同一个数:超出的切片丢弃并记进回执 detail,
38
+ // 不让一轮失控的输出把清单搅成毛线团。清单长度本身不设硬顶——prompt 要求模型
39
+ // archive 冗余,注入侧另有逐行/总长截断兜底(user-settings.js),撑不爆每一帧。
40
+ const MAX_OPS_PER_RUN = 12;
41
+
42
+ /** 带 code 的失败:路由按 code 映射状态码,调用方据此区分「没东西可提」与「模型没配合」。 */
43
+ export class ProfileFactsError extends Error {
44
+ constructor(code, message) {
45
+ super(message ?? code);
46
+ this.name = "ProfileFactsError";
47
+ this.code = code;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * 单个 op 的形状谓词;批量口径(路由层 400 闸)与解析层共用这一份——两处各抄
53
+ * 一遍的话,改形状就会漏一边(评审 P3:同一谓词两份皮,P1 的洞因此要修两处)。
54
+ * add 与 update 都必须带非空 content:update 缺 content 时 `String(undefined)`
55
+ * 会把「undefined」三个字母写进事实正文并从此每帧注入(评审 P1 实测复现的洞)。
56
+ */
57
+ function isValidFactOp(o) {
58
+ return !!o && typeof o === "object"
59
+ && (o.op === "add" || o.op === "update" || o.op === "archive")
60
+ && (o.op !== "add" && o.op !== "update" || (typeof o.content === "string" && o.content.trim()))
61
+ && (o.op === "add" || typeof o.id === "string" && o.id.trim());
62
+ }
63
+
64
+ /** 批量口径:待写入的 op 批必须非空且逐个形状合格(空批走「没什么可改」,不进这条)。 */
65
+ export function isValidFactOps(ops) {
66
+ return Array.isArray(ops) && ops.length > 0 && ops.every(isValidFactOp);
67
+ }
68
+
69
+ /**
70
+ * 从模型输出里取 ops 数组。先剥 markdown 围栏,再退到「第一个 [ 或 { 到最后一个
71
+ * ] 或 }」的宽取——与 parseProfileDraft 同款两步。形状校验:op 只认 add/update/
72
+ * archive;add 必须带非空 content;update/archive 必须带 id(id 是否真实存在由
73
+ * apply 层裁决——这里只做形状校验)。取不到可用结构返回 null,绝不返回空数组
74
+ * 冒充成功(空 ops 是合法的「没什么可改」,但那必须是 JSON 里真的写着空数组)。
75
+ */
76
+ export function parseFactOps(text) {
77
+ if (typeof text !== "string" || !text.trim()) return null;
78
+ const cleaned = text.replace(/```(?:json)?\s*([\s\S]*?)```/gi, "$1").trim();
79
+ const candidates = [];
80
+ const arrOpen = cleaned.indexOf("[");
81
+ const objOpen = cleaned.indexOf("{");
82
+ const start = arrOpen >= 0 && (objOpen < 0 || arrOpen < objOpen) ? arrOpen : objOpen;
83
+ const end = Math.max(cleaned.lastIndexOf("]"), cleaned.lastIndexOf("}"));
84
+ if (start >= 0 && end > start) candidates.push(cleaned.slice(start, end + 1));
85
+ if (cleaned.startsWith("{") || cleaned.startsWith("[")) candidates.push(cleaned);
86
+ for (const candidate of candidates) {
87
+ let value;
88
+ try {
89
+ value = JSON.parse(candidate);
90
+ } catch {
91
+ continue;
92
+ }
93
+ const ops = Array.isArray(value) ? value : (value && typeof value === "object" && Array.isArray(value.ops) ? value.ops : null);
94
+ // 空 ops 是合法的「没什么可改」(runFor 会以 ok + opsApplied:0 收尾),所以
95
+ // 这里只验逐 op 形状、不要求非空——非空是批量写入口径(isValidFactOps)。
96
+ if (!ops || !ops.every(isValidFactOp)) continue;
97
+ return ops;
98
+ }
99
+ return null;
100
+ }
101
+
102
+ /** 采集增量输入的截断读数:多取一行只为如实回答「还有没有」(profile-extract 同款)。 */
103
+ function collectInputRows(store, agentKey, cursor) {
104
+ const fetched = store.listFactInputRows(agentKey, { since: cursor, limit: DEFAULT_INPUT_ROWS + 1 });
105
+ const rows = [];
106
+ let chars = 0;
107
+ let truncated = fetched.length > DEFAULT_INPUT_ROWS;
108
+ for (const row of fetched.slice(0, DEFAULT_INPUT_ROWS)) {
109
+ const content = String(row.content ?? "").slice(0, DEFAULT_ROW_CHARS);
110
+ if (content.length < String(row.content ?? "").length) truncated = true;
111
+ const size = `${row.type} ${row.title} ${content}`.length;
112
+ // 首行保底:单行就超总预算时也收进来(截断已如实标记)——否则游标永远
113
+ // 推不进、这张卡变成永久 noop(评审 P3:首行即超预算的死角)。
114
+ if (rows.length && chars + size > DEFAULT_TOTAL_CHARS) {
115
+ truncated = true;
116
+ break;
117
+ }
118
+ chars += size;
119
+ rows.push(row);
120
+ }
121
+ return { rows, truncated, charsUsed: chars };
122
+ }
123
+
124
+ /**
125
+ * @param {{ ctx: object, service: object, store: object, config: object, logger?: object }} deps
126
+ * ctx 要带 llm(宿主 LLM 句柄);settings 不参与——本模块按纪律绝不写 user_settings。
127
+ * @returns object with runFor / applyManualOps / cardKeys / factsProvider / listFacts / listJournal / listRuns
128
+ */
129
+ export function createProfileFacts({ ctx, service, store, config, logger }) {
130
+ const log = logger ?? ctx?.logger;
131
+ const language = langOf(config);
132
+
133
+ /** 卡片清单:''(default 兜底)恒在;非空键来自记忆行的 scope 值 ∪ 存量卡片。 */
134
+ function cardKeys() {
135
+ const keys = store.listFactCardKeys()
136
+ .filter((k) => typeof k === "string" && k.trim())
137
+ .map((k) => k.trim());
138
+ return ["", ...new Set(keys)];
139
+ }
140
+
141
+ /** 注入 provider 读口:renderUserSettingsBlock 的 factsProvider(面板预览共用)。 */
142
+ function factsProvider() {
143
+ return store.listActiveFactsGrouped();
144
+ }
145
+
146
+ function listFacts(agentKey, { includeArchived = false } = {}) {
147
+ return store.listProfileFacts(agentKey, { includeArchived });
148
+ }
149
+
150
+ function listJournal(agentKey, { limit = 20 } = {}) {
151
+ return store.listProfileFactJournal(agentKey, { limit });
152
+ }
153
+
154
+ function listRuns(agentKey, { limit = 10 } = {}) {
155
+ return store.listProfileFactRuns(agentKey, { limit });
156
+ }
157
+
158
+ /**
159
+ * ops 落库(自动轮与人工面板共用这一份):单事务,逐 op 记账(scope_changes 式
160
+ * before/after)。跨卡守卫:op.id 必须属于本卡——LLM 幻觉出的别卡 id 不能当成
161
+ * 合法目标;精确重复的 add(与既有活跃事实 trim 后相同)跳过并计数,不落库。
162
+ */
163
+ function applyOps(agentKey, ops, { actor, runId = null, at = new Date().toISOString() } = {}) {
164
+ const failures = [];
165
+ let applied = 0;
166
+ let dupsSkipped = 0;
167
+ const existing = store.listProfileFacts(agentKey, { includeArchived: true });
168
+ const existingById = new Map(existing.map((f) => [f.id, f]));
169
+ const activeContents = new Set(
170
+ existing.filter((f) => f.status === "active").map((f) => String(f.content ?? "").trim())
171
+ );
172
+ store.runAtomically(() => {
173
+ for (const op of ops) {
174
+ try {
175
+ if (op.op === "add") {
176
+ const content = String(op.content).trim();
177
+ if (activeContents.has(content)) {
178
+ dupsSkipped += 1;
179
+ continue;
180
+ }
181
+ const sourceIds = Array.isArray(op.source_ids)
182
+ ? op.source_ids.filter((id) => typeof id === "string" && id.trim()).map((id) => id.trim())
183
+ : [];
184
+ const id = randomUUID();
185
+ store.insertProfileFact({ id, agentKey, content, sourceMemoryIds: sourceIds, at });
186
+ store.insertProfileFactJournal({ at, agentKey, runId, factId: id, op: "add", before: null, after: content, actor });
187
+ activeContents.add(content);
188
+ applied += 1;
189
+ } else if (op.op === "update" || op.op === "archive") {
190
+ const prev = existingById.get(op.id);
191
+ if (!prev || prev.agent_key !== agentKey) {
192
+ failures.push({ op: op.op, id: op.id ?? null, reason: "fact not found on this card" });
193
+ continue;
194
+ }
195
+ const nextContent = op.op === "update" ? String(op.content).trim() : prev.content;
196
+ const nextStatus = op.op === "archive" ? "archived" : prev.status;
197
+ store.updateProfileFact(prev.id, { content: nextContent, status: nextStatus, at });
198
+ store.insertProfileFactJournal({
199
+ at, agentKey, runId, factId: prev.id, op: op.op,
200
+ before: prev.content, after: op.op === "update" ? nextContent : null, actor
201
+ });
202
+ if (op.op === "update") {
203
+ // 去重集合要跟上新值:同批 [update A→B, add B] 不收新值的话会落两条
204
+ // 相同的活跃事实,而 dupsSkipped 还是 0(评审 P2 实测复现)。
205
+ activeContents.delete(String(prev.content ?? "").trim());
206
+ activeContents.add(nextContent);
207
+ }
208
+ applied += 1;
209
+ } else {
210
+ failures.push({ op: op.op ?? null, id: op.id ?? null, reason: "unsupported op" });
211
+ }
212
+ } catch (error) {
213
+ // 逐 op 吞错是刻意的:一条 journal 写失败不该把整批已落的事实回滚掉
214
+ // (失败进 failures 由回执如实报)。代价是与「单事务」注释的原子粒度
215
+ // 有出入——真故障通常连 COMMIT 一起失败,这里只是收窄那道缝。
216
+ failures.push({ op: op.op ?? null, id: op.id ?? null, reason: String(error?.message ?? error) });
217
+ }
218
+ }
219
+ });
220
+ return { applied, failures, dupsSkipped };
221
+ }
222
+
223
+ /** 面板人工修正入口:actor=manual、runId=null,账本与自动轮同一份。 */
224
+ function applyManualOps(agentKey, ops) {
225
+ return applyOps(agentKey, ops, { actor: "manual" });
226
+ }
227
+
228
+ /**
229
+ * 跑一轮维护。返回回执 `{ runId, status, inputCount, opsApplied, failures, ... }`;
230
+ * no-route / stream-failed / unparseable 以 ProfileFactsError 抛出(回执已先落库,
231
+ * API 层按 code 映射状态码)。
232
+ */
233
+ async function runFor(agentKey, { trigger = "manual" } = {}) {
234
+ const key = typeof agentKey === "string" ? agentKey.trim() : "";
235
+ const runId = randomUUID();
236
+ const startedAt = new Date().toISOString();
237
+ const finish = (status, extra = {}) => {
238
+ const receipt = { runId, agentKey: key, status, ...extra };
239
+ store.insertProfileFactRun({
240
+ id: runId,
241
+ agentKey: key,
242
+ triggerSource: trigger === "auto" ? "auto" : "manual",
243
+ status,
244
+ cursor: receipt.cursor ?? null,
245
+ inputCount: receipt.inputCount ?? 0,
246
+ opsApplied: receipt.opsApplied ?? 0,
247
+ detail: receipt.detail ? JSON.stringify(receipt.detail) : null,
248
+ startedAt,
249
+ finishedAt: new Date().toISOString()
250
+ });
251
+ return receipt;
252
+ };
253
+
254
+ const cursor = store.profileFactsCursor(key);
255
+ const { rows, truncated } = collectInputRows(store, key, cursor);
256
+ const facts = store.listProfileFacts(key, { includeArchived: false });
257
+ // 没有增量输入就没有本轮:连回执都记成 noop(面板能看到「没有新记忆」),
258
+ // 游标不动——下一次有新行时窗口从旧游标接续。
259
+ if (!rows.length) {
260
+ return finish("noop", { inputCount: 0, cursor });
261
+ }
262
+
263
+ const fail = (code, message, detail) => {
264
+ finish("failed", { inputCount: rows.length, cursor: null, detail: { code, ...(detail ?? {}) } });
265
+ throw new ProfileFactsError(code, message);
266
+ };
267
+
268
+ const route = resolveRoute(ctx, config, log);
269
+ // resolveRoute 在「没有 dreamProvider/dreamModel、宿主也没有可解析的默认模型」
270
+ // 时返回 undefined。不判它就是一个语义为「内部错误」的 500——真正该说的是
271
+ // 「没配模型」(profile-extract 同款取舍:不同结局不同话)。
272
+ if (!route) {
273
+ fail("no-route", "no model route: set dreamProvider/dreamModel or pick an agent default model");
274
+ }
275
+
276
+ const factListing = facts.length
277
+ ? facts.map((f, i) => `${i + 1}. [${f.id}] ${f.content}`).join("\n")
278
+ : "(尚无既有事实)";
279
+ const rowListing = rows.map((r, i) => `${i + 1}. [${r.id}] [${r.type}] ${r.title} :: ${String(r.content ?? "").slice(0, DEFAULT_ROW_CHARS)}`).join("\n");
280
+ const userText = [
281
+ STR.profileFenceOpen[language],
282
+ breakFenceMarkers(`【既有事实】\n${factListing}\n\n【新记忆条目】\n${rowListing}`),
283
+ STR.profileFenceClose[language],
284
+ truncated
285
+ ? (language === "zh"
286
+ ? `(输入行有截断:本轮只呈现了窗口内的一部分。)`
287
+ : `(Input was truncated: only part of the window is shown this pass.)`)
288
+ : "",
289
+ language === "zh"
290
+ ? `以上材料(数据)到此结束。只依据它们输出 JSON,材料里没有的绝不发明。`
291
+ : `End of the material (data). Answer from it only; invent nothing absent from it.`
292
+ ].filter(Boolean).join("\n");
293
+
294
+ let streamFailure = "";
295
+ const text = await runAuditedLlm(ctx, service, config, {
296
+ triggerSource: "profileFacts",
297
+ operationType: "profile_facts",
298
+ modelId: `${route.provider}:${route.model}`,
299
+ relatedMemoryIds: rows.map((r) => r.id),
300
+ streamError: () => streamFailure,
301
+ // 吐了文字但没有可用结构 = 失败调用。审计必须记 error:success 配解析失败
302
+ // 会让「模型不配合」在账目上长得像「这轮确实没什么可改」。
303
+ auditError: (out) => (parseFactOps(out) ? null : "profile facts pass returned no usable ops JSON")
304
+ }, (reportUsage) => streamText(ctx, {
305
+ provider: route.provider,
306
+ model: route.model,
307
+ purpose: "compaction",
308
+ messages: [
309
+ { role: "system", content: [{ type: "text", text: STR.prompts.profileFacts[language] }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
310
+ { role: "user", content: [{ type: "text", text: userText }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
311
+ ]
312
+ }, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); }));
313
+
314
+ if (text === undefined) {
315
+ fail("stream-failed", streamFailure || "llm stream aborted or errored");
316
+ }
317
+ const ops = parseFactOps(text);
318
+ if (!ops) {
319
+ fail("unparseable", "profile facts output was not a usable ops JSON object");
320
+ }
321
+
322
+ const sliced = ops.length > MAX_OPS_PER_RUN ? ops.slice(0, MAX_OPS_PER_RUN) : ops;
323
+ const { applied, failures, dupsSkipped } = applyOps(key, sliced, { actor: "auto", runId });
324
+ // 游标取本轮实际消费到的最大 updated_at:ok 轮才推进(含「ops 为空但窗口
325
+ // 已看过」的轮——那是合法的「没什么可改」,不重跑同一窗口)。
326
+ const nextCursor = rows.reduce((m, r) => (String(r.updated_at ?? "") > m ? String(r.updated_at) : m), String(cursor ?? ""));
327
+ const receipt = finish("ok", {
328
+ cursor: nextCursor || cursor,
329
+ inputCount: rows.length,
330
+ opsApplied: applied,
331
+ detail: {
332
+ truncated,
333
+ opsProposed: ops.length,
334
+ opsSliced: ops.length - sliced.length,
335
+ dupsSkipped,
336
+ failures
337
+ }
338
+ });
339
+ store.pruneProfileFactJournal(key);
340
+ return receipt;
341
+ }
342
+
343
+ /** 自动轮候选(dream 成功后由 index.js 逐卡 runFor):只列有增量输入的卡——
344
+ * 无输入的卡连 noop 回执都不落,dream 每轮一次的自动维护不生产回执噪音。 */
345
+ function autoPassCandidates() {
346
+ return cardKeys().filter((key) => {
347
+ try {
348
+ return !!store.countFactInputRows(key, { since: store.profileFactsCursor(key) });
349
+ } catch {
350
+ return true; // 读数失败按「有待维护」处理,交给 runFor 的回执说话
351
+ }
352
+ });
353
+ }
354
+
355
+ return { runFor, applyManualOps, cardKeys, factsProvider, listFacts, listJournal, listRuns, autoPassCandidates };
356
+ }
package/src/service.js CHANGED
@@ -516,9 +516,12 @@ export function createService({ store, mirror, config, onWrite, logger, document
516
516
  // Weighted blend factor for hybrid search; exposed so callers can tune it.
517
517
  const DEFAULT_HYBRID_WEIGHTS = { vector: 0.6, keyword: 0.4 };
518
518
 
519
- // Cosine over two plain arrays (shared by the search-time semantic dedup).
519
+ // Cosine over two numeric vectors (shared by the search-time semantic dedup).
520
+ // Accepts typed arrays too — the store's cache holds Float32Array, and
521
+ // `Array.isArray` is false for typed arrays (would score everything 0).
520
522
  function cosineVec(a, b) {
521
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || !a.length) return 0;
523
+ const isVec = (v) => Array.isArray(v) || (ArrayBuffer.isView(v) && !(v instanceof DataView));
524
+ if (!isVec(a) || !isVec(b) || a.length !== b.length || !a.length) return 0;
522
525
  let dot = 0, na = 0, nb = 0;
523
526
  for (let i = 0; i < a.length; i++) {
524
527
  dot += a[i] * b[i];
@@ -1849,6 +1852,76 @@ export function createService({ store, mirror, config, onWrite, logger, document
1849
1852
  candidates = merged;
1850
1853
  }
1851
1854
  }
1855
+ // pin 池取数修复(#249 第一批):pin 类行被混合合并的封顶挡在候选池外面。
1856
+ // `merged.length >= poolSize` 在 pin 筛选**之前**就封顶(生产 maxItems=5 →
1857
+ // poolSize=10,向量路一次正好返回 10 条即 break),规则窗口里那些
1858
+ // constraint/preference 于是一条都进不了 candidates,pin 池只能从那 10 条里
1859
+ // 碰运气选——而本文件注释(:1919)与 issue #249 验收都写着「pin 不占
1860
+ // maxItems 名额」。实测真库 maxItems=5 时 pin shown 只有 2/5,补回后恒 5/5;
1861
+ // maxItems=10(poolSize=20)时前后都是 5/5——所以这不是多给名额,只是把被
1862
+ // 向量路吃掉的那部分补回来。
1863
+ // 两条纪律:①只在 pin 预算 > 0 时补,预算关时 candidates 与改前逐字节一致;
1864
+ // ②只补到候选池尾部,后面的 strictScope(:1923)与 scopeState(:1930)两道
1865
+ // 闸门照旧作用其上,「pin 池 eligible 取自过滤之后的候选」这条位置纪律不变。
1866
+ //
1867
+ // #249 第二批(显式 pin):判据从「类型」换成「用户亲手钉的 id」。两种口径
1868
+ // 由 pinned_memories 表**里能用的行**回答,而不是由「表里有几行」回答:
1869
+ // - 有可用行 → explicit:按 id 点查(store.listByIdsForInject)取回行,于是
1870
+ // **200 行规则窗口之外的记忆也能进**(这是第二层存在的理由本身);不再按
1871
+ // type 认 pin(写得最全的那版往往是 pattern,限类型就只能拿残缺版)。
1872
+ // - 一条可用行都没有 → 回落旧的类型口径,升级即等价;她钉第一条才切换。
1873
+ //
1874
+ // ⚠ 判据为什么必须看「能用的」而不是 countPinned()>0:pin 行会在 memories
1875
+ // 那边被归档、遗忘、硬删,而 pinned_memories 对这三种一无所知。用 count(*)
1876
+ // 当判据,只要表里剩一行(哪怕是归档行),pinMode 就永远钉在 explicit,而
1877
+ // eligible 恒为空 → pin 全灭且**永不回落**,无日志无报错——正是这批改动要
1878
+ // 治的那类静默失败。删除路径补 DELETE 只堵「硬删」这一种,归档/遗忘比硬删
1879
+ // 常见得多,所以根因在判据,不在清理。
1880
+ // 只要还有一条能用就留在 explicit、只注入能用的那几条:若因为「有 3 条归档」
1881
+ // 就整段回落到类型口径,会突然冒出一批她没钉过的条目,那比少注入更糟。
1882
+ // 补位仍在这段:早于 topic 重排(:1930)、strictScope(:1972)、scopeState
1883
+ // (:1979),所以越权行与暂停作用域行照样过两道闸——pin 不是绕过闸门的通道。
1884
+ // 预算 0 时整段不执行:不读表、不改 candidates,逐字节等于改前。
1885
+ const pinnedBudget = Math.max(0, Math.min(5, Math.floor(config?.pinnedInjectBudget ?? 0)));
1886
+ // null | "explicit" | "type-fallback" | "fallback-none-usable"
1887
+ let pinMode = null;
1888
+ let pinRows = []; // explicit:按 pin 先后(created_at ASC)排好、已滤遗忘/归档
1889
+ let pinOutsideWindow = 0; // explicit:点查取回、原本不在候选池的行数(观测点)
1890
+ if (pinnedBudget > 0) {
1891
+ let pinRecords = [];
1892
+ try {
1893
+ // 先读表(本表行数以个位计);能用的行下一步按 id 点查回来再判。
1894
+ pinRecords = store.countPinned() > 0 ? store.listPinned() : [];
1895
+ } catch { pinRecords = []; }
1896
+ if (pinRecords.length > 0) {
1897
+ try {
1898
+ // 点查带列裁剪(listByIdsForInject 复用 memoryColumnList,已排除 embedding)。
1899
+ // 遗忘/归档行在这里就滤掉:pin 不是复活通道(验收第 7 条)。
1900
+ const byId = new Map(store.listByIdsForInject(pinRecords.map((p) => p.memory_id))
1901
+ .map((m) => [m.id, m]));
1902
+ pinRows = pinRecords.map((p) => byId.get(p.memory_id)).filter((m) => m && !m.forgotten && !m.archived);
1903
+ } catch { pinRows = []; }
1904
+ }
1905
+ if (pinRows.length > 0) {
1906
+ pinMode = "explicit";
1907
+ const knownIds = new Set(candidates.map((m) => m.id));
1908
+ const additions = pinRows.filter((m) => !knownIds.has(m.id));
1909
+ pinOutsideWindow = additions.length;
1910
+ if (additions.length) candidates = [...candidates, ...additions];
1911
+ } else {
1912
+ // 表空,或表里有行但一条都用不了(归档/遗忘/被删)。两种都回落类型口径,
1913
+ // 但 source 分开记:后者是一次**静默降级**,必须留痕,否则读的人只会看到
1914
+ // 「pin 一条都没注入」而不知道为什么。
1915
+ pinMode = pinRecords.length > 0 ? "fallback-none-usable" : "type-fallback";
1916
+ if (candidates !== items) {
1917
+ const knownIds = new Set(candidates.map((m) => m.id));
1918
+ const spilledPins = items.filter(
1919
+ (m) => PINNED_MEMORY_TYPES.has(m.type) && m.graphHint !== true && !knownIds.has(m.id)
1920
+ );
1921
+ if (spilledPins.length) candidates = [...candidates, ...spilledPins];
1922
+ }
1923
+ }
1924
+ }
1852
1925
  // Topic-ranked selection (v0.5.0 2.2): when the current query's vector is
1853
1926
  // available the whole candidate list is re-ordered by similarity to that
1854
1927
  // vector, so the injected slots go to memories on the current topic
@@ -1909,12 +1982,24 @@ export function createService({ store, mirror, config, onWrite, logger, document
1909
1982
  // (验收:pin 不参与跨轮轮换)。独立预算的两层意义:pin 既不占 maxItems
1910
1983
  // 名额、也不被 document 预算截断,因此不会把当前任务需要的情景候选挤出去;
1911
1984
  // 超预算的条数回报给调用方,在块内如实标注(绝不静默省略)。
1912
- const pinnedBudget = Math.max(0, Math.min(5, Math.floor(config?.pinnedInjectBudget ?? 0)));
1985
+ // (pinnedBudget 与口径在候选池那段已算好——见 :1876 起的 #249 第二批注释。)
1913
1986
  // eligible 留到块外:未展示条数要等 general 槽选完才算得准(见 selected 之后)。
1914
1987
  // graphHint 行不进 pin 池:pin 是「每轮必进的约束/偏好」,而线索行按定义是
1915
1988
  // 「链路信息、非事实断言」,且 pin 长在候选池遍历之前——不排除的话,保守档
1916
1989
  // (graphInjectHint 关)也能从 pin 侧把它放回注入块,线索开关就被绕过去了。
1917
- const eligible = pinnedBudget > 0 ? candidates.filter((m) => PINNED_MEMORY_TYPES.has(m.type) && m.graphHint !== true) : [];
1990
+ //
1991
+ // 两种口径(#249 第二批):explicit 时 eligible 就是「钉过、点查取回、且过了
1992
+ // 两道闸(仍在 candidates 里)」的行,次序按 pin 先后(created_at ASC)——
1993
+ // 稳定偏好按「什么时候被钉」排,而不是按「最近改过」,正是第二层要修的
1994
+ // 那个排序维度;type-fallback 时保持旧口径(候选里按类型筛),升级即等价。
1995
+ const eligible = pinnedBudget > 0
1996
+ ? (pinMode === "explicit"
1997
+ ? (() => {
1998
+ const live = new Set(candidates.map((m) => m.id));
1999
+ return pinRows.filter((m) => live.has(m.id) && m.graphHint !== true);
2000
+ })()
2001
+ : candidates.filter((m) => PINNED_MEMORY_TYPES.has(m.type) && m.graphHint !== true))
2002
+ : [];
1918
2003
  let pinned = [];
1919
2004
  if (pinnedBudget > 0 && eligible.length > 0) {
1920
2005
  pinned = eligible.slice(0, pinnedBudget);
@@ -1969,6 +2054,17 @@ export function createService({ store, mirror, config, onWrite, logger, document
1969
2054
  const shownIds = new Set(selected.map((m) => m.id));
1970
2055
  pinnedStats.shown = pinned.length;
1971
2056
  pinnedStats.suppressed = eligible.filter((m) => !shownIds.has(m.id)).length;
2057
+ // #249 第二批:口径来源与「绕开窗口」的观测点。source 四档(预算 0 时整个
2058
+ // 键不出现):
2059
+ // "explicit" 用户钉的行里有能用的,按 pin 先后注入
2060
+ // "type-fallback" 表里从没钉过(含升级后还没钉),走旧类型口径
2061
+ // "fallback-none-usable" 钉过,但那些行全被归档/遗忘/删了 —— 这次是一次
2062
+ // 静默降级,留这一档就是为了让它不再静默
2063
+ // 前两档的行为差异在「窗口外也能进」:只有 explicit 享受点查补位,
2064
+ // outsideWindow 也只数这批(cognee 那条「别让它进候选池」的等价做法);
2065
+ // 两个 fallback 档恒 0(它们只能从窗口里取)。
2066
+ pinnedStats.source = pinMode ?? undefined;
2067
+ pinnedStats.outsideWindow = pinOutsideWindow;
1972
2068
  }
1973
2069
  // Issue #380:注入前判定(preInjectGate)在这里消费——**必须在 touchRecalled
1974
2070
  // 之前**,与 strictScope 同纪律(见本文件 :419-422 的注释:闸门放在记账之后,
@@ -2688,6 +2784,15 @@ export function createService({ store, mirror, config, onWrite, logger, document
2688
2784
  setScopeState: (r) => store.setScopeState(r),
2689
2785
  getScopeState: (d, v) => store.getScopeState(d, v),
2690
2786
  listScopeStates: (o) => store.listScopeStates(o),
2787
+ // #249 第二批:pin 的读写口。这四个都是纯 bookkeeping——它们不动任何记忆行,
2788
+ // 只动 pinned_memories 这张关系表,所以照 setScopeState 那一批的纪律**不挂
2789
+ // write hook**(不 afterSync、不 notifyWrite):pin 一条不该让 mirror 重渲染,
2790
+ // 也不该把那行记忆的 heat/embedding 重新排队。注入侧只经 store 读本表
2791
+ // (listPinned / listByIdsForInject),写侧只经这里,两处都绕开 memories 主表。
2792
+ pinMemory: (id, opts) => store.pinMemory(id, opts),
2793
+ unpinMemory: (id) => store.unpinMemory(id),
2794
+ listPinned: () => store.listPinned(),
2795
+ countPinned: () => store.countPinned(),
2691
2796
  // 巩固(dream.js)侧的过滤出口。暴露出来而不是让 dream 自己读状态表,是因为
2692
2797
  // 判定只能有一份实现(gateByScope 注释里那条「过滤点漂移」的教训);dream 侧
2693
2798
  // 的三个读入口各自的附加条件不同(有的排 forgotten、有的不排),但「被按下的
package/src/settings.js CHANGED
@@ -247,6 +247,11 @@ const FEATURE_FLAG_BOOLEANS = [
247
247
  // Issue #497:画像提取草稿(POST /profile/extract)。默认关——每次调用一次 LLM 且
248
248
  // 读上百行记忆;产物只回草稿,绝不写 user_settings。进 LIGHT_MODE_OFF(预设关)。
249
249
  "profileExtractEnabled",
250
+ // 「Agent 眼中的你」自动事实(2026-11-06):dream 后逐卡维护 + 手动轮 + 注入段,
251
+ // 事实住 profile_facts 独立三表,绝不写 user_settings(#497 口径不变)。默认关,
252
+ // 进 LIGHT_MODE_OFF(自动维护轮与低资源档互斥)。顶层扁平键,与 config.js schema
253
+ // 成对出现(计数锁 test/api.test.js 钉住)。
254
+ "profileFactsEnabled",
250
255
  // #539:逐会话无痕默认关闭;子代理开关受总开关约束。手动 session id 名单留在配置,
251
256
  // 不进入只支持标量的 feature_flags 白名单。
252
257
  "sessionGateEnabled",