recallnest 2.5.4 → 2.6.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 (81) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +34 -9
  3. package/README_CN.md +37 -12
  4. package/package.json +4 -4
  5. package/src/__tests__/access-tracker-novelty.test.ts +66 -0
  6. package/src/__tests__/api-server-security.test.ts +102 -0
  7. package/src/__tests__/belief-history.test.ts +220 -0
  8. package/src/__tests__/capture-engine.test.ts +93 -3
  9. package/src/__tests__/category-min-scores.test.ts +83 -0
  10. package/src/__tests__/cluster-consolidation.test.ts +167 -4
  11. package/src/__tests__/conflict-store.test.ts +46 -2
  12. package/src/__tests__/consolidation-engine.test.ts +97 -0
  13. package/src/__tests__/contracts.test.ts +46 -0
  14. package/src/__tests__/decay-exemption.test.ts +45 -0
  15. package/src/__tests__/dream-pipeline.test.ts +464 -5
  16. package/src/__tests__/freshness.test.ts +186 -0
  17. package/src/__tests__/hotness-blend.test.ts +207 -0
  18. package/src/__tests__/llm-client-pivot.test.ts +628 -0
  19. package/src/__tests__/maintenance-locks.test.ts +15 -0
  20. package/src/__tests__/memory-boundaries.test.ts +32 -0
  21. package/src/__tests__/noise-prototype-bank.test.ts +280 -0
  22. package/src/__tests__/pii-redact.test.ts +19 -0
  23. package/src/__tests__/pivot-apply.test.ts +373 -0
  24. package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
  25. package/src/__tests__/pivot-distill.test.ts +1178 -0
  26. package/src/__tests__/preference-matcher.test.ts +54 -2
  27. package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
  28. package/src/__tests__/recall-gate.test.ts +266 -0
  29. package/src/__tests__/resolve-db-path.test.ts +130 -0
  30. package/src/__tests__/resolve-tier.test.ts +64 -0
  31. package/src/__tests__/retrieval-eval.test.ts +163 -0
  32. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
  33. package/src/__tests__/skill-promotion.test.ts +53 -0
  34. package/src/__tests__/stable-context-length-contract.test.ts +62 -0
  35. package/src/__tests__/store-read-consistency.test.ts +122 -0
  36. package/src/__tests__/store-schema-migration.test.ts +87 -0
  37. package/src/__tests__/store-upsert.test.ts +17 -0
  38. package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
  39. package/src/__tests__/ui-server-security.test.ts +93 -0
  40. package/src/access-tracker.ts +53 -3
  41. package/src/adaptive-retrieval.ts +11 -0
  42. package/src/api-server.ts +9 -2
  43. package/src/auto-recall.ts +61 -1
  44. package/src/belief-history.ts +164 -0
  45. package/src/capture-engine.ts +88 -11
  46. package/src/cli.ts +109 -14
  47. package/src/conflict-engine.ts +24 -3
  48. package/src/consolidation-engine.ts +146 -12
  49. package/src/context-composer-stable-selection.ts +7 -1
  50. package/src/data-checkup.ts +1 -1
  51. package/src/decay-engine.ts +59 -7
  52. package/src/doctor.ts +4 -2
  53. package/src/dream-pipeline.ts +359 -11
  54. package/src/env-config.ts +41 -0
  55. package/src/eval.ts +154 -11
  56. package/src/freshness.ts +192 -0
  57. package/src/http-request-policy.ts +148 -0
  58. package/src/ingest.ts +277 -2
  59. package/src/kg-extractor.ts +7 -1
  60. package/src/llm-client.ts +106 -11
  61. package/src/mcp-server.ts +2 -1
  62. package/src/mcp-tools-advanced.ts +13 -0
  63. package/src/mcp-tools-core.ts +5 -1
  64. package/src/memory-boundaries.ts +23 -2
  65. package/src/memory-health-rebalance.ts +4 -0
  66. package/src/memory-output.ts +16 -2
  67. package/src/memory-store-port.ts +3 -0
  68. package/src/noise-prototype-bank.ts +408 -0
  69. package/src/pii-detector.ts +52 -2
  70. package/src/pivot-apply.ts +399 -0
  71. package/src/preference-matcher.ts +37 -3
  72. package/src/recall-gate.ts +305 -0
  73. package/src/retrieval-profiles.ts +46 -4
  74. package/src/retriever.ts +95 -18
  75. package/src/retroactive-boost.ts +6 -2
  76. package/src/runtime-config.ts +70 -5
  77. package/src/skill-promotion.ts +58 -2
  78. package/src/store.ts +54 -19
  79. package/src/term-registry.ts +35 -0
  80. package/src/ui-server.ts +7 -0
  81. package/src/version.ts +19 -0
@@ -8,6 +8,7 @@ import { createEmbedder, getVectorDimensions, type EmbeddingConfig } from "./emb
8
8
  import { createRetriever, type RetrievalConfig, DEFAULT_RETRIEVAL_CONFIG } from "./retriever.js";
9
9
  import { applyRetrievalProfile } from "./retrieval-profiles.js";
10
10
  import { AccessTracker } from "./access-tracker.js";
11
+ import { createAuditLogger } from "./audit-log.js";
11
12
  import { FrequencyTracker } from "./frequency-tracker.js";
12
13
  import { createLLMClient, type LLMClient, type LLMConfig } from "./llm-client.js";
13
14
  import { logInfo } from "./stderr-log.js";
@@ -67,7 +68,20 @@ export function loadDotEnv(): void {
67
68
  }
68
69
 
69
70
  export function findConfigPath(): string {
70
- if (process.env.LOCAL_MEMORY_CONFIG) return resolve(process.env.LOCAL_MEMORY_CONFIG);
71
+ if (process.env.LOCAL_MEMORY_CONFIG) {
72
+ const explicit = resolve(process.env.LOCAL_MEMORY_CONFIG);
73
+ // Check here rather than letting readFileSync throw a bare ENOENT: scripts
74
+ // resolve the database at module load, so a stale env var would otherwise
75
+ // surface as an unexplained crash before any of their own output appears.
76
+ if (!existsSync(explicit)) {
77
+ throw new Error(
78
+ `LOCAL_MEMORY_CONFIG points to a file that does not exist:\n` +
79
+ ` ${explicit}\n` +
80
+ ` Fix: correct the env var, or unset it to use the repo's config.json.`
81
+ );
82
+ }
83
+ return explicit;
84
+ }
71
85
 
72
86
  const localConfig = resolve(metaDir(import.meta), "../config.json");
73
87
  if (existsSync(localConfig)) return localConfig;
@@ -103,8 +117,35 @@ export function expandHome(p: string): string {
103
117
  return p;
104
118
  }
105
119
 
120
+ /**
121
+ * Single source of truth for "which LanceDB directory do we operate on".
122
+ *
123
+ * Every entry point — MCP server, CLI, and maintenance scripts — must resolve
124
+ * the database through this function. Scripts that hardcode "./data/lancedb"
125
+ * or read a non-existent config key silently operate on a *different* database
126
+ * than the server does, so a health check, cleanup, or backfill can report on
127
+ * an empty directory while the real store sits elsewhere.
128
+ *
129
+ * Relative dbPath values resolve against the repo root (not process.cwd()), so
130
+ * a script's behaviour does not depend on where it was invoked from.
131
+ *
132
+ * Pass an already-loaded config where you have one. Calling this with no
133
+ * argument reads config.json from disk — convenient at a script's top level,
134
+ * but it means a missing or misconfigured file surfaces here, before the
135
+ * script's own output. findConfigPath() raises an actionable error for that.
136
+ */
137
+ export function resolveDbPath(config?: LocalMemoryConfig): string {
138
+ const cfg = config ?? loadConfig();
139
+ return resolve(metaDir(import.meta), "..", expandHome(cfg.dbPath));
140
+ }
141
+
142
+ /** Sidecar directory (frequency-stats.json etc.) that lives beside the database. */
143
+ export function resolveDataDir(config?: LocalMemoryConfig): string {
144
+ return resolve(resolveDbPath(config), "..");
145
+ }
146
+
106
147
  export function createComponents(config: LocalMemoryConfig, profileName?: string) {
107
- const dbPath = resolve(metaDir(import.meta), "..", expandHome(config.dbPath));
148
+ const dbPath = resolveDbPath(config);
108
149
  validateStoragePath(dbPath);
109
150
 
110
151
  const embeddingConfig: EmbeddingConfig = {
@@ -119,24 +160,48 @@ export function createComponents(config: LocalMemoryConfig, profileName?: string
119
160
 
120
161
  const embedder = createEmbedder(embeddingConfig);
121
162
  const store = new MemoryStore({ dbPath, vectorDim: embedder.dimensions });
122
- const baseRetrievalConfig = {
163
+ const mergedRetrievalConfig = {
123
164
  ...DEFAULT_RETRIEVAL_CONFIG,
124
165
  ...(config.retrieval || {}),
125
166
  };
167
+ // rerankApiKey 与 embedding.apiKey 同款支持 ${ENV_VAR} 引用。此前 retrieval 段是整体
168
+ // 展开、不过 resolveEnv,导致这个字段只能写明文——等于逼配置文件存凭证,是 rerank
169
+ // 一直没被启用的障碍之一。
170
+ const baseRetrievalConfig = mergedRetrievalConfig.rerankApiKey
171
+ ? { ...mergedRetrievalConfig, rerankApiKey: resolveEnv(mergedRetrievalConfig.rerankApiKey) }
172
+ : mergedRetrievalConfig;
126
173
  const { profile, config: retrieverConfig } = applyRetrievalProfile(baseRetrievalConfig, profileName);
127
174
  const retriever = createRetriever(store, embedder, retrieverConfig);
128
175
 
129
176
  // Attach access tracker for reinforcement-based decay
130
177
  const accessTracker = new AccessTracker(store);
178
+ accessTracker.registerExitFlush();
131
179
  retriever.setAccessTracker(accessTracker);
132
180
 
133
181
  // P0.2: Attach frequency tracker for hit-count based boosting
134
- const dataDir = resolve(metaDir(import.meta), "..", expandHome(config.dbPath), "..");
182
+ const dataDir = resolveDataDir(config);
135
183
  const frequencyTracker = new FrequencyTracker({
136
184
  filePath: join(dataDir, "frequency-stats.json"),
137
185
  });
138
186
  retriever.setFrequencyTracker(frequencyTracker);
139
187
 
188
+ // F-1: 补接 audit logger,让 retrieve 操作真的被记录。
189
+ //
190
+ // 2026-08-12 修复:`setAuditLogger`(retriever.ts:749)自定义以来**全仓零调用者**,
191
+ // 而 retriever.ts:897 的 `this.auditLogger?.log({ operation: "retrieve", ... })`
192
+ // 早就写好了 —— 可选链让它永远静默 no-op。结果是 audit-log.ts 文件头注释声称
193
+ // "record every store/update/delete/retrieve operation"、AuditOperation 类型里
194
+ // 也列着 "retrieve",而生产 audit.jsonl 里 2841 条记录**零条 retrieve**。
195
+ //
196
+ // 同族的 setAccessTracker / setFrequencyTracker 就在上面两行,都接了,唯独这个漏了。
197
+ // 与同日修的 dream 末尾 resetWriteCount 漏传 scope 完全同型:接线漏一处、运行时
198
+ // 不报错、静默失效 —— 差别只在那个是漏参数、这个是漏调用。
199
+ //
200
+ // simplified: audit.jsonl 无轮转机制(修复时 644K / 2841 行,约 230 字节一行)。
201
+ // 接上 retrieve 后增速会明显上升,因为检索远比写入频繁。
202
+ // 升级触发条件:文件超过约 50MB 时加按月轮转,别等到它拖慢 append。
203
+ retriever.setAuditLogger(createAuditLogger(join(dataDir, "audit.jsonl")));
204
+
140
205
  // Create LLM client if configured (optional, graceful)
141
206
  let llm: LLMClient | null = null;
142
207
  if (config.llm) {
@@ -150,7 +215,7 @@ export function createComponents(config: LocalMemoryConfig, profileName?: string
150
215
  }
151
216
 
152
217
  export function createStoreOnly(config: LocalMemoryConfig): MemoryStore {
153
- const dbPath = resolve(metaDir(import.meta), "..", expandHome(config.dbPath));
218
+ const dbPath = resolveDbPath(config);
154
219
  validateStoragePath(dbPath);
155
220
  return new MemoryStore({
156
221
  dbPath,
@@ -28,6 +28,8 @@ export interface PromotionCandidate {
28
28
  confidence: number; // 0-1
29
29
  /** A2: zero-LLM verifier verdict (pattern_to_skill only). 失败候选保留披露、排后、不静默丢。 */
30
30
  verification?: VerifyResult;
31
+ /** Read-signal evidence aggregated from source entries(Artel 读驱动升华:被真实读过的候选提权)。 */
32
+ readEvidence?: { totalAccess: number; distinctReaders: number };
31
33
  }
32
34
 
33
35
  export interface PromotionScanResult {
@@ -266,6 +268,47 @@ export function greedyCluster(
266
268
  return clusters;
267
269
  }
268
270
 
271
+ // ---------------------------------------------------------------------------
272
+ // Read evidence (Artel-style read-driven promotion)
273
+ // ---------------------------------------------------------------------------
274
+
275
+ /** Max confidence boost from read evidence. Saturates at 4 distinct readers. */
276
+ const READ_BOOST_MAX = 0.15;
277
+
278
+ /**
279
+ * Aggregate read signals (accessCount / readerIds / distinctReaderCount, written
280
+ * by AccessTracker on retrieval) across a candidate's source entries.
281
+ * distinctReaders = max(union of readerIds, per-entry saturated count) — the
282
+ * union undercounts once READER_ID_CAP saturates, the per-entry count covers that.
283
+ */
284
+ export function aggregateReadEvidence(
285
+ entries: Array<{ metadata?: string }>,
286
+ ): { totalAccess: number; distinctReaders: number } {
287
+ let totalAccess = 0;
288
+ const readers = new Set<string>();
289
+ let saturatedMax = 0;
290
+ for (const e of entries) {
291
+ try {
292
+ const meta = JSON.parse(e.metadata || "{}") as Record<string, unknown>;
293
+ if (typeof meta.accessCount === "number") totalAccess += meta.accessCount;
294
+ if (Array.isArray(meta.readerIds)) {
295
+ for (const r of meta.readerIds) if (typeof r === "string") readers.add(r);
296
+ }
297
+ if (typeof meta.distinctReaderCount === "number") {
298
+ saturatedMax = Math.max(saturatedMax, meta.distinctReaderCount);
299
+ }
300
+ } catch { /* broken metadata → contributes nothing */ }
301
+ }
302
+ return { totalAccess, distinctReaders: Math.max(readers.size, saturatedMax) };
303
+ }
304
+
305
+ /** Blend read evidence into base confidence: +0..READ_BOOST_MAX, capped at 0.99.
306
+ * 提权不设门槛(observe-before-enforce:先积累读证据,硬门槛等有生产数据再定)。 */
307
+ export function applyReadBoost(base: number, distinctReaders: number): number {
308
+ const boost = Math.min(1, distinctReaders / 4) * READ_BOOST_MAX;
309
+ return Math.min(0.99, base + boost);
310
+ }
311
+
269
312
  // ---------------------------------------------------------------------------
270
313
  // Main scan
271
314
  // ---------------------------------------------------------------------------
@@ -339,6 +382,7 @@ export async function scanForPromotions(
339
382
  const avgSim = computeAverageIntraClusterSimilarity(cluster.members);
340
383
 
341
384
  caseCandidates++;
385
+ const caseReadEvidence = aggregateReadEvidence(cluster.members);
342
386
  result.candidates.push({
343
387
  type: "case_to_pattern",
344
388
  sourceEntries: cluster.members.map(m => ({
@@ -348,7 +392,11 @@ export async function scanForPromotions(
348
392
  })),
349
393
  suggestedName: extractName(cluster.seed.text),
350
394
  suggestedDescription: summarizeCluster(cluster.members),
351
- confidence: cluster.members.length / (cluster.members.length + 2), // Bayesian smoothing
395
+ confidence: applyReadBoost(
396
+ cluster.members.length / (cluster.members.length + 2), // Bayesian smoothing
397
+ caseReadEvidence.distinctReaders,
398
+ ),
399
+ readEvidence: caseReadEvidence,
352
400
  });
353
401
  }
354
402
  }
@@ -394,6 +442,7 @@ export async function scanForPromotions(
394
442
  );
395
443
 
396
444
  patternCandidates++;
445
+ const patternReadEvidence = aggregateReadEvidence([pattern, ...similarCases.map(h => h.entry)]);
397
446
  result.candidates.push({
398
447
  type: "pattern_to_skill",
399
448
  sourceEntries: [
@@ -407,8 +456,12 @@ export async function scanForPromotions(
407
456
  suggestedName: extractName(pattern.text),
408
457
  suggestedDescription: `Skill derived from pattern with ${similarCases.length} supporting cases`,
409
458
  suggestedImplementation: extractSteps(pattern.text),
410
- confidence: similarCases.length / (similarCases.length + 2),
459
+ confidence: applyReadBoost(
460
+ similarCases.length / (similarCases.length + 2),
461
+ patternReadEvidence.distinctReaders,
462
+ ),
411
463
  verification,
464
+ readEvidence: patternReadEvidence,
412
465
  });
413
466
  }
414
467
 
@@ -455,6 +508,9 @@ export function formatPromotionResult(result: PromotionScanResult): string {
455
508
  lines.push(`Confidence: ${(c.confidence * 100).toFixed(1)}%`);
456
509
  lines.push(`Description: ${c.suggestedDescription}`);
457
510
  lines.push(`Sources: ${c.sourceEntries.length} entries`);
511
+ if (c.readEvidence) {
512
+ lines.push(`Read evidence: ${c.readEvidence.totalAccess} total reads, ${c.readEvidence.distinctReaders} distinct reader(s)`);
513
+ }
458
514
  if (c.verification) {
459
515
  const v = c.verification;
460
516
  if (v.ok) {
package/src/store.ts CHANGED
@@ -7,6 +7,7 @@ import { createHash, randomUUID } from "node:crypto";
7
7
  import { existsSync, accessSync, constants, mkdirSync, realpathSync, lstatSync } from "node:fs";
8
8
  import { dirname, join } from "node:path";
9
9
  import { logWarn } from "./stderr-log.js";
10
+ import { readConsistencyInterval as envReadConsistencyInterval } from "./env-config.js";
10
11
  import type { DurableMemoryCategory } from "./memory-schema.js";
11
12
  import { matchesScopeFilter } from "./scope-policy.js";
12
13
  import { detectEmotionIfEnabled } from "./emotion-detector.js";
@@ -58,6 +59,13 @@ export interface MemorySearchResult {
58
59
  export interface StoreConfig {
59
60
  dbPath: string;
60
61
  vectorDim: number;
62
+ /**
63
+ * LanceDB readConsistencyInterval (seconds): 0 = strong consistency (external
64
+ * writes visible on every read), positive = bounded staleness window.
65
+ * Omitted → RECALLNEST_READ_CONSISTENCY_INTERVAL decides (default 0; "off"
66
+ * restores the legacy unchecked-handle behavior).
67
+ */
68
+ readConsistencyInterval?: number;
61
69
  }
62
70
 
63
71
  export type LegacyScopeIssueKind = "missing" | "empty" | "global";
@@ -222,6 +230,9 @@ export class MemoryStore implements MemoryStorePort {
222
230
  /** Per-id serial queues for patchMetadata; entries self-remove on settle. */
223
231
  private readonly metadataPatchQueues = new Map<string, Promise<void>>();
224
232
 
233
+ /** Set when the init-time schema migration fails; surfaced in store error messages. */
234
+ private schemaMigrationError: string | null = null;
235
+
225
236
  constructor(private readonly config: StoreConfig) {}
226
237
 
227
238
  get dbPath(): string {
@@ -248,7 +259,13 @@ export class MemoryStore implements MemoryStorePort {
248
259
 
249
260
  let db: LanceDB.Connection;
250
261
  try {
251
- db = await lancedb.connect(this.config.dbPath);
262
+ // Without a consistency interval a long-lived handle pins its manifest
263
+ // version — resident MCP/API/UI servers would never see CLI or
264
+ // sibling-process writes. Explicit config wins so tests can pin behavior.
265
+ db = await lancedb.connect(this.config.dbPath, {
266
+ readConsistencyInterval:
267
+ this.config.readConsistencyInterval ?? envReadConsistencyInterval(),
268
+ });
252
269
  } catch (err: any) {
253
270
  const code = err.code || "";
254
271
  const message = err.message || String(err);
@@ -266,24 +283,24 @@ export class MemoryStore implements MemoryStorePort {
266
283
  try {
267
284
  table = await db.openTable(TABLE_NAME);
268
285
 
269
- // Backward compatibility: add missing columns to existing tables
286
+ // Backward compatibility: add missing columns to existing tables.
287
+ // Read the schema definition, not sample rows — a row-sampling check can
288
+ // never fire on a 0-row table, which deadlocked every write with
289
+ // "Found field not in schema" on empty pre-language/fts_text tables.
270
290
  try {
271
- const sample = await table.query().limit(1).toArray();
272
- if (sample.length > 0) {
273
- if (!("scope" in sample[0])) {
274
- logWarn("Adding scope column for backward compatibility with existing data");
275
- }
276
- if (!("language" in sample[0])) {
277
- logWarn("Adding language column for backward compatibility");
278
- await table.addColumns([{ name: "language", valueSql: "'en'" }]);
279
- }
280
- if (!("fts_text" in sample[0])) {
281
- logWarn("Adding fts_text column for backward compatibility");
282
- await table.addColumns([{ name: "fts_text", valueSql: "text" }]);
283
- }
291
+ const schema = await table.schema();
292
+ const fields = new Set(schema.fields.map((f) => f.name));
293
+ const missing: Array<{ name: string; valueSql: string }> = [];
294
+ if (!fields.has("language")) missing.push({ name: "language", valueSql: "'en'" });
295
+ if (!fields.has("fts_text")) missing.push({ name: "fts_text", valueSql: "text" });
296
+ if (missing.length > 0) {
297
+ logWarn(`Adding missing columns for backward compatibility: ${missing.map((c) => c.name).join(", ")}`);
298
+ await table.addColumns(missing);
284
299
  }
300
+ this.schemaMigrationError = null;
285
301
  } catch (err) {
286
- logWarn("Could not check/migrate table schema:", err);
302
+ this.schemaMigrationError = err instanceof Error ? err.message : String(err);
303
+ logWarn("Could not check/migrate table schema — subsequent writes may fail:", err);
287
304
  }
288
305
  } catch (_openErr) {
289
306
  // Table doesn't exist yet — create it
@@ -388,7 +405,8 @@ export class MemoryStore implements MemoryStorePort {
388
405
  const code = err.code || "";
389
406
  const message = err.message || String(err);
390
407
  throw new Error(
391
- `Failed to store memory in "${this.config.dbPath}": ${code} ${message}`
408
+ `Failed to store memory in "${this.config.dbPath}": ${code} ${message}` +
409
+ (this.schemaMigrationError ? `\n Schema migration failed at init: ${this.schemaMigrationError}` : "")
392
410
  );
393
411
  }
394
412
  }
@@ -440,7 +458,8 @@ export class MemoryStore implements MemoryStorePort {
440
458
  const code = err.code || "";
441
459
  const message = err.message || String(err);
442
460
  throw new Error(
443
- `Failed to batch store ${entries.length} memories in "${this.config.dbPath}": ${code} ${message}`
461
+ `Failed to batch store ${entries.length} memories in "${this.config.dbPath}": ${code} ${message}` +
462
+ (this.schemaMigrationError ? `\n Schema migration failed at init: ${this.schemaMigrationError}` : "")
444
463
  );
445
464
  }
446
465
  // Per-scope write counts drive dream activation. Best-effort — never block writes.
@@ -1065,7 +1084,10 @@ export class MemoryStore implements MemoryStorePort {
1065
1084
  const updated: MemoryEntry = {
1066
1085
  id: row.id as string,
1067
1086
  text: updates.text ?? (row.text as string),
1068
- vector: updates.vector ?? (Array.from(row.vector as Iterable<number>)),
1087
+ // row.vector 可能为 null(历史病行,2026-07-23 dream/auto-gc 生产实锤:
1088
+ // cc:82a919ea 连崩三轮 Array.from(null))。null → [] 让 metadata-only update
1089
+ // 不再被病行绊倒;空向量本就不可检索,语义不变。
1090
+ vector: updates.vector ?? (row.vector ? Array.from(row.vector as Iterable<number>) : []),
1069
1091
  category: updates.category ?? (row.category as MemoryEntry["category"]),
1070
1092
  scope: rowScope,
1071
1093
  importance: updates.importance ?? Number(row.importance),
@@ -1212,6 +1234,19 @@ export class MemoryStore implements MemoryStorePort {
1212
1234
  return deleteCount;
1213
1235
  }
1214
1236
 
1237
+ /**
1238
+ * Force lazy initialization to complete.
1239
+ *
1240
+ * `hasFtsSupport` is meaningless before init: `ftsIndexCreated` starts false and
1241
+ * only flips once `doInitialize()` has attempted to build the FTS index. Any caller
1242
+ * that *branches* on `hasFtsSupport` must await this first, or it will read the
1243
+ * pre-init false and take the wrong branch. Cheap to call repeatedly —
1244
+ * `ensureInitialized()` returns immediately once the table exists.
1245
+ */
1246
+ async ready(): Promise<void> {
1247
+ await this.ensureInitialized();
1248
+ }
1249
+
1215
1250
  get hasFtsSupport(): boolean {
1216
1251
  return this.ftsIndexCreated;
1217
1252
  }
@@ -272,12 +272,24 @@ export const TASK_RESULT_SPECIFICITY_GROUPS = [
272
272
  "managed continuity rules",
273
273
  "global instruction file",
274
274
  ],
275
+ // ⚠️ 上面的 resultTerms 是**一次历史事件的专名**(2026 年那轮三端连续性触发验证),
276
+ // 不是端清单 —— 端接入时不要往里加端名:resultTerms 是「谁被这个组认领」,
277
+ // 加了 "kimi" 等于断言"任何提到 kimi 的记忆都是那次三端验证的结论",
278
+ // 会让大批 kimi 记忆在无关任务下被这个组滤掉。同款判断见 932ec37:
279
+ // ROADMAP 里 `✅ managed continuity rules installed by setup` 那处也按"历史"原样保留。
280
+ //
281
+ // taskTerms 是「什么问法下这条历史仍然相关」,所以只补她真实的问法:
282
+ // 表里原有 "three-terminal" / "三终端",但她写的是"三端"/"四端"("三端"不是"三终端"的子串,
283
+ // 今天真的匹配不上)。**同样不补端名** —— 补了会让「kimi 挂了」这种无关任务
284
+ // 也解除过滤、把这条三端历史塞进 context;真正相关的问法已被 触发/接入/规则 覆盖。
275
285
  taskTerms: [
276
286
  "claude code",
277
287
  "codex",
278
288
  "gemini cli",
279
289
  "three-terminal",
280
290
  "三终端",
291
+ "三端",
292
+ "四端",
281
293
  "trigger",
282
294
  "触发",
283
295
  "setup",
@@ -462,6 +474,18 @@ export const TASK_RESULT_SPECIFICITY_GROUPS = [
462
474
  },
463
475
  ];
464
476
 
477
+ // 「某个端的验收 / 独立验证视角」这一簇。判据(context-composer-stable-selection.ts:154-157):
478
+ // resultTerms 命中记忆正文 && taskTerms 全不命中任务 → 丢弃这条记忆
479
+ // 两个字段方向相反,接入新端时**不要两边对称地加**:
480
+ // · taskTerms 加词 = 放宽豁免,召回变多 —— 端接入时要补的是这一侧
481
+ // · resultTerms 加词 = 扩大被滤对象,召回变少 —— 这里**有意不补端名**:
482
+ // 概念词(smoke / integration / 验收 / 验证视角 / 独立验证 / sidecar)本就是端无关的,
483
+ // 已经覆盖了"kimi 的验收视角"这类记忆;而裸词是子串匹配,补进去会把"顺带提了一句 kimi"
484
+ // 的无关偏好整条变成可滤对象,爆炸半径远大于收益。
485
+ // 实证:现有的裸词 "codex" 已经在误认领 —— 记忆 b8731a81(讲规则该放 home 级还是项目级)
486
+ // 只因正文里有 `.codex/AGENTS.md` 这个**路径片段**就被本组claim,
487
+ // 于是"四端的触发规则都装好了吗"这一问反而把最对题的那条滤掉了(shadow 实测)。
488
+ // 再往 resultTerms 加端名 = 把这个已知缺陷复制三份。
465
489
  export const PREFERENCE_SPECIFICITY_GROUPS = [
466
490
  {
467
491
  resultTerms: [
@@ -480,6 +504,17 @@ export const PREFERENCE_SPECIFICITY_GROUPS = [
480
504
  "claude code",
481
505
  "codex",
482
506
  "gemini cli",
507
+ // 在栈四端 = Claude Code / Codex / Kimi / AGY。AGY 是一个端,
508
+ // "antigravity" 与 "agy" 是它的两类写法(scope 前缀写 antigravity:,
509
+ // 她口头和 MEMORY.md 里写 agy),两个都要,不是两个端。
510
+ "kimi",
511
+ "antigravity",
512
+ "agy",
513
+ // 问端拓扑本身(不点名某一端)时也该解除:shadow 实测
514
+ // "四端的触发规则都装好了吗"会把 b8731a81(规则该放 home 级还是项目级,
515
+ // 正文写着"shared-behaviors.md 放三端通用底线")这条最对题的记忆滤掉。
516
+ "三端",
517
+ "四端",
483
518
  "smoke",
484
519
  "integration",
485
520
  "验收",
package/src/ui-server.ts CHANGED
@@ -12,6 +12,7 @@ import { archiveDirtyBriefAsset, assetSummaryLine, buildBriefAsset, buildPinAsse
12
12
  import { indexAsset, indexPinnedAsset } from "./asset-sync.js";
13
13
  import { createComponentResolver, loadConfig, loadDotEnv } from "./runtime-config.js";
14
14
  import { buildRetrievalContext, resolveScopeSelection } from "./scope-policy.js";
15
+ import { enforceLocalHttpRequestPolicy, LOCAL_HTTP_HOSTNAME } from "./http-request-policy.js";
15
16
  import * as envConfig from "./env-config.js";
16
17
  const config = (loadDotEnv(), loadConfig());
17
18
  const getComponents = createComponentResolver(config);
@@ -188,8 +189,14 @@ async function handleSearch(mode: "search" | "explain" | "distill", body: Record
188
189
 
189
190
  const server = Bun.serve({
190
191
  port: readLimit(envConfig.uiPortRaw(), 4317, 1, 65535),
192
+ hostname: LOCAL_HTTP_HOSTNAME,
191
193
  async fetch(request) {
192
194
  try {
195
+ const policy = await enforceLocalHttpRequestPolicy(request);
196
+ if (!policy.allowed) {
197
+ return textResponse(policy.message, { status: policy.status });
198
+ }
199
+
193
200
  const url = new URL(request.url);
194
201
 
195
202
  if (request.method === "GET" && (url.pathname === "/" || url.pathname.startsWith("/ui/"))) {
package/src/version.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ import { metaDir } from "./compat.js";
5
+
6
+ type PackageMetadata = {
7
+ version?: unknown;
8
+ };
9
+
10
+ const packageMetadata = JSON.parse(
11
+ readFileSync(resolve(metaDir(import.meta), "../package.json"), "utf8"),
12
+ ) as PackageMetadata;
13
+
14
+ if (typeof packageMetadata.version !== "string" || packageMetadata.version.length === 0) {
15
+ throw new Error("package.json must contain a non-empty version");
16
+ }
17
+
18
+ /** The public RecallNest version. package.json is the single source of truth. */
19
+ export const PACKAGE_VERSION = packageMetadata.version;