recallnest 2.3.0 → 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 (258) hide show
  1. package/CHANGELOG.md +187 -0
  2. package/README.md +49 -38
  3. package/README_CN.md +46 -31
  4. package/assets/ui/app.js +1031 -0
  5. package/assets/ui/index.html +224 -0
  6. package/assets/ui/styles.css +781 -0
  7. package/config.json.example +4 -1
  8. package/docs/api-reference.md +1291 -0
  9. package/docs/architecture.md +168 -0
  10. package/docs/connector-spec.md +126 -0
  11. package/eval/cases-canary.json +58 -0
  12. package/eval/cases-scope-robustness.json +278 -0
  13. package/eval/cases.json +117 -20
  14. package/package.json +25 -4
  15. package/src/__tests__/access-tracker-novelty.test.ts +304 -0
  16. package/src/__tests__/activity-counter.test.ts +125 -0
  17. package/src/__tests__/adaptive-fidelity.test.ts +102 -0
  18. package/src/__tests__/admission-control.test.ts +206 -0
  19. package/src/__tests__/anchor-generator.test.ts +67 -0
  20. package/src/__tests__/api-server-security.test.ts +102 -0
  21. package/src/__tests__/auto-consolidation.test.ts +168 -0
  22. package/src/__tests__/auto-recall.test.ts +131 -0
  23. package/src/__tests__/babel-integration.test.ts +144 -0
  24. package/src/__tests__/batch-dedup.test.ts +65 -0
  25. package/src/__tests__/belief-history.test.ts +220 -0
  26. package/src/__tests__/canary-eval.test.ts +114 -0
  27. package/src/__tests__/capture-engine.test.ts +1328 -0
  28. package/src/__tests__/capture-heuristic.test.ts +171 -0
  29. package/src/__tests__/cascade-forget.test.ts +210 -0
  30. package/src/__tests__/category-min-scores.test.ts +116 -0
  31. package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
  32. package/src/__tests__/circuit-breaker.test.ts +99 -0
  33. package/src/__tests__/cluster-consolidation.test.ts +945 -0
  34. package/src/__tests__/confidence-metadata.test.ts +125 -0
  35. package/src/__tests__/confidence-tracker.test.ts +261 -0
  36. package/src/__tests__/conflict-advisor.test.ts +206 -0
  37. package/src/__tests__/conflict-escalation.test.ts +149 -0
  38. package/src/__tests__/conflict-lifecycle.test.ts +83 -0
  39. package/src/__tests__/conflict-output.test.ts +85 -0
  40. package/src/__tests__/conflict-store.test.ts +418 -0
  41. package/src/__tests__/connector-v1.test.ts +181 -0
  42. package/src/__tests__/consolidation-engine.test.ts +430 -0
  43. package/src/__tests__/constructive-retrieval.test.ts +509 -0
  44. package/src/__tests__/context-collapse-renderer.test.ts +214 -0
  45. package/src/__tests__/context-composer-pins.test.ts +64 -0
  46. package/src/__tests__/context-composer-task-selection.test.ts +24 -0
  47. package/src/__tests__/context-reconstructor.test.ts +65 -0
  48. package/src/__tests__/context-renderer.test.ts +91 -0
  49. package/src/__tests__/continuity-eval.test.ts +275 -0
  50. package/src/__tests__/continuity-schema.test.ts +97 -0
  51. package/src/__tests__/contracts.test.ts +262 -0
  52. package/src/__tests__/conversation-importer.test.ts +692 -0
  53. package/src/__tests__/core-recall-eval.test.ts +235 -0
  54. package/src/__tests__/core-summary.test.ts +109 -0
  55. package/src/__tests__/data-checkup.test.ts +276 -0
  56. package/src/__tests__/decay-exemption.test.ts +129 -0
  57. package/src/__tests__/dedup-actions.test.ts +58 -0
  58. package/src/__tests__/dedup-guard.test.ts +39 -0
  59. package/src/__tests__/deterministic-id.test.ts +39 -0
  60. package/src/__tests__/distill-lock.test.ts +305 -0
  61. package/src/__tests__/distill-session-argshape.test.ts +79 -0
  62. package/src/__tests__/doctor.test.ts +319 -0
  63. package/src/__tests__/dream-pipeline.test.ts +780 -0
  64. package/src/__tests__/drill-down.test.ts +108 -0
  65. package/src/__tests__/embedder.test.ts +122 -0
  66. package/src/__tests__/emotion-decay.test.ts +46 -0
  67. package/src/__tests__/emotion-detector.test.ts +56 -0
  68. package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
  69. package/src/__tests__/emotion-retrieval.test.ts +56 -0
  70. package/src/__tests__/entity-resolver.test.ts +58 -0
  71. package/src/__tests__/env-config.test.ts +151 -0
  72. package/src/__tests__/error-signature-boost.test.ts +87 -0
  73. package/src/__tests__/error-signature.test.ts +95 -0
  74. package/src/__tests__/error-taxonomy.test.ts +129 -0
  75. package/src/__tests__/ethics-layer.test.ts +476 -0
  76. package/src/__tests__/event-segmenter.test.ts +153 -0
  77. package/src/__tests__/feature-flags-integration.test.ts +54 -0
  78. package/src/__tests__/frequency-tracker.test.ts +113 -0
  79. package/src/__tests__/freshness.test.ts +186 -0
  80. package/src/__tests__/graph-export.test.ts +489 -0
  81. package/src/__tests__/hotness-blend.test.ts +283 -0
  82. package/src/__tests__/ingest-chunking.test.ts +44 -0
  83. package/src/__tests__/ingest.test.ts +360 -0
  84. package/src/__tests__/interference-detector.test.ts +203 -0
  85. package/src/__tests__/kg-extractor.test.ts +192 -0
  86. package/src/__tests__/kg-store.test.ts +300 -0
  87. package/src/__tests__/llm-client-pivot.test.ts +628 -0
  88. package/src/__tests__/llm-consolidation.test.ts +226 -0
  89. package/src/__tests__/maintenance-locks.test.ts +189 -0
  90. package/src/__tests__/mcp-efficiency.test.ts +288 -0
  91. package/src/__tests__/mcp-related-scopes.test.ts +151 -0
  92. package/src/__tests__/memory-boundaries.test.ts +282 -0
  93. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +345 -0
  94. package/src/__tests__/memory-evolution.test.ts +271 -0
  95. package/src/__tests__/memory-health-rebalance.test.ts +144 -0
  96. package/src/__tests__/memory-lint.test.ts +421 -0
  97. package/src/__tests__/memory-output.test.ts +215 -0
  98. package/src/__tests__/memory-promotion.test.ts +315 -0
  99. package/src/__tests__/migrate-dedup-ids.test.ts +65 -0
  100. package/src/__tests__/multi-vector.test.ts +242 -0
  101. package/src/__tests__/narrative-architecture.test.ts +456 -0
  102. package/src/__tests__/noise-filter.test.ts +205 -0
  103. package/src/__tests__/noise-prototype-bank.test.ts +280 -0
  104. package/src/__tests__/obsidian-connector.test.ts +363 -0
  105. package/src/__tests__/omitted-reporting.test.ts +98 -0
  106. package/src/__tests__/pending-review.test.ts +77 -0
  107. package/src/__tests__/pii-redact.test.ts +333 -0
  108. package/src/__tests__/pivot-apply.test.ts +373 -0
  109. package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
  110. package/src/__tests__/pivot-distill.test.ts +1178 -0
  111. package/src/__tests__/post-retrieval-filter.test.ts +178 -0
  112. package/src/__tests__/ppr-traversal.test.ts +256 -0
  113. package/src/__tests__/prediction-engine.test.ts +349 -0
  114. package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
  115. package/src/__tests__/preference-guard-smoke.test.ts +211 -0
  116. package/src/__tests__/preference-matcher.test.ts +282 -0
  117. package/src/__tests__/preference-slots.test.ts +205 -0
  118. package/src/__tests__/prospective-memory.test.ts +283 -0
  119. package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
  120. package/src/__tests__/query-entity-detector.test.ts +171 -0
  121. package/src/__tests__/query-expander-alias.test.ts +146 -0
  122. package/src/__tests__/query-expander.test.ts +34 -0
  123. package/src/__tests__/recall-gate.test.ts +266 -0
  124. package/src/__tests__/recall-governor.test.ts +227 -0
  125. package/src/__tests__/recall-mode.test.ts +64 -0
  126. package/src/__tests__/resolve-db-path.test.ts +130 -0
  127. package/src/__tests__/resolve-tier.test.ts +64 -0
  128. package/src/__tests__/result-synthesizer.test.ts +198 -0
  129. package/src/__tests__/resume-context.test.ts +4898 -0
  130. package/src/__tests__/retrieval-eval.test.ts +163 -0
  131. package/src/__tests__/retrieval-trace.test.ts +213 -0
  132. package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
  133. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
  134. package/src/__tests__/retroactive-boost.test.ts +223 -0
  135. package/src/__tests__/salience-scoring.test.ts +106 -0
  136. package/src/__tests__/scope-inventory.test.ts +200 -0
  137. package/src/__tests__/scope-policy.test.ts +69 -0
  138. package/src/__tests__/scope-suggester.test.ts +80 -0
  139. package/src/__tests__/session-checkpoint.test.ts +291 -0
  140. package/src/__tests__/session-distiller.test.ts +640 -0
  141. package/src/__tests__/session-gc.test.ts +215 -0
  142. package/src/__tests__/skill-engine.test.ts +552 -0
  143. package/src/__tests__/skill-promotion.test.ts +654 -0
  144. package/src/__tests__/skill-verifier.test.ts +122 -0
  145. package/src/__tests__/source-heartbeat.test.ts +250 -0
  146. package/src/__tests__/stable-context-length-contract.test.ts +62 -0
  147. package/src/__tests__/store-patch-metadata.test.ts +205 -0
  148. package/src/__tests__/store-read-consistency.test.ts +122 -0
  149. package/src/__tests__/store-schema-migration.test.ts +87 -0
  150. package/src/__tests__/store-scope-audit.test.ts +81 -0
  151. package/src/__tests__/store-upsert.test.ts +201 -0
  152. package/src/__tests__/supersede-chain.test.ts +154 -0
  153. package/src/__tests__/temporal-parser.test.ts +241 -0
  154. package/src/__tests__/temporal-validity.test.ts +160 -0
  155. package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
  156. package/src/__tests__/term-registry.test.ts +121 -0
  157. package/src/__tests__/tool-error-surfacing.test.ts +98 -0
  158. package/src/__tests__/tool-output-compressor.test.ts +160 -0
  159. package/src/__tests__/topic-tag.test.ts +113 -0
  160. package/src/__tests__/trusted-memory.test.ts +839 -0
  161. package/src/__tests__/ui-server-security.test.ts +93 -0
  162. package/src/__tests__/usage-tracker.test.ts +287 -0
  163. package/src/__tests__/version-manager.test.ts +213 -0
  164. package/src/__tests__/workflow-observation-skill-outcome.test.ts +352 -0
  165. package/src/__tests__/workflow-observation.test.ts +433 -0
  166. package/src/__tests__/write-verifier.test.ts +122 -0
  167. package/src/access-tracker.ts +90 -34
  168. package/src/activity-counter.ts +46 -18
  169. package/src/adaptive-retrieval.ts +11 -0
  170. package/src/aliases.ts +133 -0
  171. package/src/api-server.ts +36 -9
  172. package/src/audit-log.ts +4 -2
  173. package/src/auto-consolidation.ts +8 -3
  174. package/src/auto-gc.ts +85 -21
  175. package/src/auto-recall.ts +61 -1
  176. package/src/belief-history.ts +164 -0
  177. package/src/capture-engine.ts +142 -32
  178. package/src/cli.ts +268 -41
  179. package/src/confidence-tracker.ts +36 -18
  180. package/src/conflict-engine.ts +24 -3
  181. package/src/consolidation-engine.ts +255 -23
  182. package/src/context-composer-stable-selection.ts +7 -1
  183. package/src/context-composer.ts +8 -1
  184. package/src/data-checkup.ts +96 -7
  185. package/src/decay-engine.ts +59 -7
  186. package/src/distill-lock.ts +194 -15
  187. package/src/doctor.ts +13 -4
  188. package/src/dream-pipeline.ts +473 -41
  189. package/src/env-config.ts +118 -0
  190. package/src/error-signature.ts +153 -0
  191. package/src/error-taxonomy.ts +115 -0
  192. package/src/eval.ts +402 -14
  193. package/src/freshness.ts +192 -0
  194. package/src/http-request-policy.ts +148 -0
  195. package/src/idempotency.ts +8 -0
  196. package/src/ingest.ts +308 -12
  197. package/src/kg-extractor.ts +13 -3
  198. package/src/kg-store.ts +236 -44
  199. package/src/llm-client.ts +106 -11
  200. package/src/llm-consolidation.ts +2 -1
  201. package/src/mcp-server.ts +87 -1800
  202. package/src/mcp-tool-deps.ts +48 -0
  203. package/src/mcp-tools-advanced.ts +954 -0
  204. package/src/mcp-tools-core.ts +637 -0
  205. package/src/mcp-tools-governance.ts +475 -0
  206. package/src/memory-boundaries.ts +26 -3
  207. package/src/memory-evolution.ts +73 -1
  208. package/src/memory-health-rebalance.ts +4 -0
  209. package/src/memory-lint.ts +183 -10
  210. package/src/memory-output.ts +129 -4
  211. package/src/memory-promotion.ts +246 -0
  212. package/src/memory-schema.ts +25 -2
  213. package/src/memory-store-port.ts +46 -0
  214. package/src/multi-vector.ts +2 -1
  215. package/src/narrative-schema.ts +2 -1
  216. package/src/noise-filter.ts +57 -6
  217. package/src/noise-prototype-bank.ts +408 -0
  218. package/src/pii-detector.ts +207 -5
  219. package/src/pivot-apply.ts +399 -0
  220. package/src/preference-matcher.ts +37 -3
  221. package/src/query-expander.ts +90 -9
  222. package/src/recall-gate.ts +305 -0
  223. package/src/result-synthesizer.ts +2 -1
  224. package/src/retention-policy.ts +2 -1
  225. package/src/retrieval-profiles.ts +46 -4
  226. package/src/retriever.ts +354 -157
  227. package/src/retroactive-boost.ts +6 -2
  228. package/src/runtime-config.ts +78 -6
  229. package/src/scope-suggester.ts +113 -0
  230. package/src/session-distiller.ts +23 -2
  231. package/src/session-engine.ts +2 -1
  232. package/src/session-schema.ts +1 -0
  233. package/src/session-store.ts +5 -0
  234. package/src/skill-engine.ts +78 -0
  235. package/src/skill-promotion.ts +304 -48
  236. package/src/skill-schema.ts +16 -3
  237. package/src/skill-verifier.ts +152 -0
  238. package/src/store.ts +321 -57
  239. package/src/term-registry.ts +35 -0
  240. package/src/tool-output-compressor.ts +3 -1
  241. package/src/ui-server.ts +9 -1
  242. package/src/usage-tracker.ts +242 -0
  243. package/src/version-manager.ts +3 -2
  244. package/src/version.ts +19 -0
  245. package/src/workflow-observation-engine.ts +2 -1
  246. package/src/workflow-observation-schema.ts +2 -1
  247. package/src/workflow-observation-store.ts +6 -2
  248. package/.env +0 -4
  249. package/CLAUDE.md +0 -46
  250. package/config.json +0 -55
  251. package/daily-recall.sh +0 -110
  252. package/logs/daily-recall.error.log +0 -0
  253. package/logs/daily-recall.log +0 -60
  254. package/logs/ingest-2026-04-11.log +0 -110
  255. package/logs/ingest-2026-04-12.log +0 -90
  256. package/logs/ingest-2026-04-13.log +0 -87
  257. package/logs/launchagent-stderr.log +0 -0
  258. package/logs/launchagent-stdout.log +0 -0
package/src/store.ts CHANGED
@@ -5,11 +5,15 @@
5
5
  import type * as LanceDB from "@lancedb/lancedb";
6
6
  import { createHash, randomUUID } from "node:crypto";
7
7
  import { existsSync, accessSync, constants, mkdirSync, realpathSync, lstatSync } from "node:fs";
8
- import { dirname } from "node:path";
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";
14
+ import { withWriteLock } from "./distill-lock.js";
15
+ import { incrementWriteCount } from "./activity-counter.js";
16
+ import type { MemoryStorePort, MemoryStoreStats, MemoryStoreUpdate } from "./memory-store-port.js";
13
17
 
14
18
  // ============================================================================
15
19
  // Types
@@ -55,6 +59,13 @@ export interface MemorySearchResult {
55
59
  export interface StoreConfig {
56
60
  dbPath: string;
57
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;
58
69
  }
59
70
 
60
71
  export type LegacyScopeIssueKind = "missing" | "empty" | "global";
@@ -211,11 +222,16 @@ export function validateStoragePath(dbPath: string): string {
211
222
 
212
223
  const TABLE_NAME = "memories";
213
224
 
214
- export class MemoryStore {
225
+ export class MemoryStore implements MemoryStorePort {
215
226
  private db: LanceDB.Connection | null = null;
216
227
  private table: LanceDB.Table | null = null;
217
228
  private initPromise: Promise<void> | null = null;
218
229
  private ftsIndexCreated = false;
230
+ /** Per-id serial queues for patchMetadata; entries self-remove on settle. */
231
+ private readonly metadataPatchQueues = new Map<string, Promise<void>>();
232
+
233
+ /** Set when the init-time schema migration fails; surfaced in store error messages. */
234
+ private schemaMigrationError: string | null = null;
219
235
 
220
236
  constructor(private readonly config: StoreConfig) {}
221
237
 
@@ -243,7 +259,13 @@ export class MemoryStore {
243
259
 
244
260
  let db: LanceDB.Connection;
245
261
  try {
246
- 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
+ });
247
269
  } catch (err: any) {
248
270
  const code = err.code || "";
249
271
  const message = err.message || String(err);
@@ -261,24 +283,24 @@ export class MemoryStore {
261
283
  try {
262
284
  table = await db.openTable(TABLE_NAME);
263
285
 
264
- // 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.
265
290
  try {
266
- const sample = await table.query().limit(1).toArray();
267
- if (sample.length > 0) {
268
- if (!("scope" in sample[0])) {
269
- logWarn("Adding scope column for backward compatibility with existing data");
270
- }
271
- if (!("language" in sample[0])) {
272
- logWarn("Adding language column for backward compatibility");
273
- await table.addColumns([{ name: "language", valueSql: "'en'" }]);
274
- }
275
- if (!("fts_text" in sample[0])) {
276
- logWarn("Adding fts_text column for backward compatibility");
277
- await table.addColumns([{ name: "fts_text", valueSql: "text" }]);
278
- }
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);
279
299
  }
300
+ this.schemaMigrationError = null;
280
301
  } catch (err) {
281
- 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);
282
304
  }
283
305
  } catch (_openErr) {
284
306
  // Table doesn't exist yet — create it
@@ -375,28 +397,33 @@ export class MemoryStore {
375
397
  }
376
398
 
377
399
  try {
378
- await this.table!.add([fullEntry]);
400
+ // P0-2: go through upsert (delete+add under the global store-write lock) rather
401
+ // than a bare table.add — with the deterministic id above, storing the same
402
+ // (scope,text) twice now yields one row, not a dup-id pair (fixes the append bug).
403
+ return await this.upsert(fullEntry);
379
404
  } catch (err: any) {
380
405
  const code = err.code || "";
381
406
  const message = err.message || String(err);
382
407
  throw new Error(
383
- `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}` : "")
384
410
  );
385
411
  }
386
- return fullEntry;
387
412
  }
388
413
 
389
414
  /**
390
415
  * Batch store multiple entries at once — much faster than individual store() calls.
391
416
  * LanceDB handles bulk inserts efficiently with a single index update.
392
417
  */
393
- async storeBatch(entries: Omit<MemoryEntry, "id" | "timestamp">[]): Promise<number> {
418
+ async storeBatch(entries: (Omit<MemoryEntry, "id" | "timestamp"> & { id?: string })[]): Promise<number> {
394
419
  if (entries.length === 0) return 0;
395
420
  await this.ensureInitialized();
396
421
 
397
422
  const fullEntries: MemoryEntry[] = entries.map(entry => ({
398
423
  ...entry,
399
- id: deterministicId(entry.scope, entry.text),
424
+ // P0-2: honor a caller-supplied id (consolidation insight/pattern idempotency),
425
+ // else the deterministic content id — unified with store() (was: always recompute).
426
+ id: entry.id ?? deterministicId(entry.scope, entry.text),
400
427
  timestamp: Date.now(),
401
428
  metadata: entry.metadata || "{}",
402
429
  language: entry.language || "en",
@@ -413,16 +440,66 @@ export class MemoryStore {
413
440
  return e;
414
441
  });
415
442
 
443
+ // P2: collapse in-batch duplicate ids (latest-wins) BEFORE writing. Two entries
444
+ // with the same (scope,text) → same deterministic id; adding both would create a
445
+ // fresh dup-id pair in one shot (delete-set was deduped but the add was not).
446
+ const dedupedById = new Map<string, MemoryEntry>();
447
+ for (const e of enrichedEntries) dedupedById.set(e.id, e); // later occurrence wins
448
+ const toWrite = [...dedupedById.values()];
449
+
416
450
  try {
417
- await this.table!.add(enrichedEntries);
451
+ await withWriteLock("store-write", async () => {
452
+ // P0-1/P0-2/P1: atomic idempotent batch write via mergeInsert (no delete+add
453
+ // window a crash could tear). toWrite is already in-batch deduped by id above,
454
+ // so the merge key matches ≤1 existing row (given no pre-existing dup-id rows).
455
+ await this.table!.mergeInsert("id").whenMatchedUpdateAll().whenNotMatchedInsertAll().execute(toWrite);
456
+ }, { expireMs: 30_000 });
418
457
  } catch (err: any) {
419
458
  const code = err.code || "";
420
459
  const message = err.message || String(err);
421
460
  throw new Error(
422
- `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}` : "")
423
463
  );
424
464
  }
425
- return fullEntries.length;
465
+ // Per-scope write counts drive dream activation. Best-effort — never block writes.
466
+ try {
467
+ const statsPath = this.activityStatsPath();
468
+ const countByScope = new Map<string, number>();
469
+ for (const e of toWrite) countByScope.set(e.scope, (countByScope.get(e.scope) ?? 0) + 1);
470
+ for (const [scope, n] of countByScope) incrementWriteCount(scope, n, { statsPath });
471
+ } catch { /* activity tracking is best-effort */ }
472
+ return toWrite.length;
473
+ }
474
+
475
+ /**
476
+ * P0-1/P0-2/P1: Idempotent, crash-atomic write of a fully-formed entry, serialized
477
+ * across processes.
478
+ *
479
+ * Uses mergeInsert("id") (same primitive as update()) so the write is atomic — no
480
+ * delete+add window that a crash between the two steps could tear, losing the row
481
+ * (P1). The global `store-write` lock still serializes concurrent merges across the
482
+ * 11 mcp-server processes so two whenNotMatched inserts can't both fire for the same
483
+ * id (the P0-1 race). "Same id overwrites" gives content-addressed idempotency to
484
+ * every store()/storeBatch() caller whose id is deterministicId(scope,text).
485
+ * Precondition: no pre-existing dup-id rows in the table (run the dedup migration
486
+ * scripts/cleanup-duplicate-ids.ts first) — else the merge key match is ambiguous.
487
+ */
488
+ /** Path to this store's activity-counter stats file, beside the LanceDB dir. */
489
+ private activityStatsPath(): string {
490
+ return join(dirname(this.config.dbPath), "activity-stats.json");
491
+ }
492
+
493
+ async upsert(entry: MemoryEntry): Promise<MemoryEntry> {
494
+ await this.ensureInitialized();
495
+ await withWriteLock("store-write", async () => {
496
+ await this.table!.mergeInsert("id").whenMatchedUpdateAll().whenNotMatchedInsertAll().execute([entry]);
497
+ }, { expireMs: 30_000 });
498
+ // Per-scope write count drives dream activation. Best-effort — never block a write.
499
+ try {
500
+ incrementWriteCount(entry.scope, 1, { statsPath: this.activityStatsPath() });
501
+ } catch { /* activity tracking is best-effort */ }
502
+ return entry;
426
503
  }
427
504
 
428
505
  /**
@@ -617,11 +694,19 @@ export class MemoryStore {
617
694
  if (isFullId) {
618
695
  candidates = await this.table!.query().where(`id = '${id}'`).limit(1).toArray();
619
696
  } else {
620
- // Prefix match: fetch candidates and filter in app layer
621
- const all = await this.table!.query().select(["id", "scope"]).limit(1000).toArray();
622
- candidates = all.filter((r: any) => (r.id as string).startsWith(id));
697
+ // v2.5.2 (2026-05-27): Use SQL LIKE for prefix lookup instead of app-layer scan
698
+ // with limit(1000) — the old `.select().limit(1000).filter()` pattern silently
699
+ // missed entries beyond row 1000 in 90K+ libraries (Codex trio review finding,
700
+ // ref: ~/Desktop/codex-v2.5.1-fix-review-20260527.md, store.delete limit 漏洞).
701
+ // Now mirrors getById prefix behavior: LIKE with limit(2) detects ambiguity.
702
+ const safeId = escapeSqlLiteral(id);
703
+ candidates = await this.table!.query()
704
+ .select(["id", "scope"])
705
+ .where(`id LIKE '${safeId}%'`)
706
+ .limit(2)
707
+ .toArray();
623
708
  if (candidates.length > 1) {
624
- throw new Error(`Ambiguous prefix "${id}" matches ${candidates.length} memories. Use a longer prefix or full ID.`);
709
+ throw new Error(`Ambiguous prefix "${id}" matches ${candidates.length}+ memories. Use a longer prefix or full ID.`);
625
710
  }
626
711
  }
627
712
  if (candidates.length === 0) {
@@ -688,6 +773,72 @@ export class MemoryStore {
688
773
  .slice(offset, offset + limit);
689
774
  }
690
775
 
776
+ /**
777
+ * True DB-level pagination — where/limit/offset are pushed down to LanceDB,
778
+ * so unlike list() this never materializes the full match set in JS. Use for
779
+ * full-corpus maintenance sweeps (auto-gc, promotion scan, lint/checkup).
780
+ *
781
+ * Caveats:
782
+ * - No orderBy support in the LanceDB query API: rows come back in fragment
783
+ * scan order, NOT timestamp order. Stable across pages for a read-only
784
+ * sweep, but concurrent writes rewrite fragments and may shift rows
785
+ * between pages (a missed row is picked up by the next sweep). Do not use
786
+ * for user-facing ordered listings — that's what list() is for.
787
+ * - includeVector loads the embedding column; only request it when the
788
+ * consumer actually needs vectors (they dominate row size).
789
+ */
790
+ async listPage(opts: {
791
+ scopeFilter?: string[];
792
+ category?: string;
793
+ limit?: number;
794
+ offset?: number;
795
+ includeVector?: boolean;
796
+ } = {}): Promise<MemoryEntry[]> {
797
+ await this.ensureInitialized();
798
+ const { scopeFilter, category, limit = 1000, offset = 0, includeVector = false } = opts;
799
+
800
+ let query = this.table!.query();
801
+
802
+ const conditions: string[] = [];
803
+ if (scopeFilter && scopeFilter.length > 0) {
804
+ const scopeConditions = scopeFilter
805
+ .map(scope => {
806
+ const safe = escapeSqlLiteral(scope);
807
+ return scope.includes(":")
808
+ ? `scope = '${safe}'`
809
+ : `scope LIKE '${safe}%'`;
810
+ })
811
+ .join(" OR ");
812
+ conditions.push(`((${scopeConditions}))`);
813
+ }
814
+ if (category) {
815
+ conditions.push(`category = '${escapeSqlLiteral(category)}'`);
816
+ }
817
+ if (conditions.length > 0) {
818
+ query = query.where(conditions.join(" AND "));
819
+ }
820
+
821
+ const columns = ["id", "text", "category", "scope", "importance", "timestamp", "metadata"];
822
+ if (includeVector) columns.push("vector");
823
+
824
+ const results = await query
825
+ .select(columns)
826
+ .limit(limit)
827
+ .offset(offset)
828
+ .toArray();
829
+
830
+ return results.map((row): MemoryEntry => ({
831
+ id: row.id as string,
832
+ text: row.text as string,
833
+ vector: includeVector ? Array.from(row.vector as Iterable<number>) : [],
834
+ category: row.category as MemoryEntry["category"],
835
+ scope: (row.scope as string | undefined) ?? "",
836
+ importance: Number(row.importance),
837
+ timestamp: Number(row.timestamp),
838
+ metadata: (row.metadata as string) || "{}",
839
+ }));
840
+ }
841
+
691
842
  async get(id: string, scopeFilter?: string[]): Promise<MemoryEntry | null> {
692
843
  await this.ensureInitialized();
693
844
 
@@ -741,18 +892,35 @@ export class MemoryStore {
741
892
  }
742
893
 
743
894
  /**
744
- * Get a single entry by exact ID without scope filtering.
745
- * Lightweight read used by AccessTracker for metadata updates.
895
+ * Get a single entry by ID (full UUID) or 8+ hex prefix.
896
+ * Lightweight read used by AccessTracker for metadata updates and by skill-engine
897
+ * for outcome recording.
898
+ *
899
+ * v2.5.1 (2026-05-27) — 加 prefix lookup support:和 update() / delete() 行为对齐,
900
+ * 让 agent 拿到 short id(如 store_skill 返回的 8 位前缀、人类粘贴的截断 id)也能
901
+ * resolve 到完整 entry。歧义 prefix(匹配 >1 条)返回 null 让 caller 处理。
902
+ * 现有 caller 全部传完整 UUID,向后兼容。
746
903
  */
747
904
  async getById(id: string): Promise<MemoryEntry | null> {
748
905
  await this.ensureInitialized();
906
+
907
+ // Support both full UUID and 8+ hex prefix (same pattern as update() / delete()).
908
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
909
+ const prefixRegex = /^[0-9a-f]{8,}$/i;
910
+ const isFullId = uuidRegex.test(id);
911
+ const isPrefix = !isFullId && prefixRegex.test(id);
912
+ if (!isFullId && !isPrefix) return null;
913
+
749
914
  const safeId = escapeSqlLiteral(id);
915
+ const whereClause = isFullId ? `id = '${safeId}'` : `id LIKE '${safeId}%'`;
916
+
750
917
  const rows = await this.table!.query()
751
918
  .select(["id", "text", "vector", "category", "scope", "importance", "timestamp", "metadata"])
752
- .where(`id = '${safeId}'`)
753
- .limit(1)
919
+ .where(whereClause)
920
+ .limit(2) // detect ambiguous prefix (matches >1)
754
921
  .toArray();
755
922
  if (rows.length === 0) return null;
923
+ if (isPrefix && rows.length > 1) return null; // ambiguous prefix
756
924
  const row = rows[0];
757
925
  return {
758
926
  id: row.id as string,
@@ -773,28 +941,28 @@ export class MemoryStore {
773
941
  */
774
942
  async getVectors(ids: string[]): Promise<Map<string, number[]>> {
775
943
  await this.ensureInitialized();
776
- if (ids.length === 0) return new Map();
777
-
778
- const conditions = ids.map(id => `id = '${escapeSqlLiteral(id)}'`).join(" OR ");
779
- const rows = await this.table!.query()
780
- .select(["id", "vector"])
781
- .where(conditions)
782
- .limit(ids.length)
783
- .toArray();
784
-
785
944
  const result = new Map<string, number[]>();
786
- for (const row of rows) {
787
- const vec = Array.from(row.vector as Iterable<number>);
788
- if (vec.length > 0) result.set(row.id as string, vec);
945
+ if (ids.length === 0) return result;
946
+
947
+ // 分批:避免 id 很多时 OR 链过长(如 data-checkup/memory-lint 一次补几千条向量)。
948
+ const BATCH = 500;
949
+ for (let i = 0; i < ids.length; i += BATCH) {
950
+ const batch = ids.slice(i, i + BATCH);
951
+ const conditions = batch.map(id => `id = '${escapeSqlLiteral(id)}'`).join(" OR ");
952
+ const rows = await this.table!.query()
953
+ .select(["id", "vector"])
954
+ .where(conditions)
955
+ .limit(batch.length)
956
+ .toArray();
957
+ for (const row of rows) {
958
+ const vec = Array.from(row.vector as Iterable<number>);
959
+ if (vec.length > 0) result.set(row.id as string, vec);
960
+ }
789
961
  }
790
962
  return result;
791
963
  }
792
964
 
793
- async stats(scopeFilter?: string[]): Promise<{
794
- totalCount: number;
795
- scopeCounts: Record<string, number>;
796
- categoryCounts: Record<string, number>
797
- }> {
965
+ async stats(scopeFilter?: string[]): Promise<MemoryStoreStats> {
798
966
  await this.ensureInitialized();
799
967
 
800
968
  let query = this.table!.query();
@@ -874,7 +1042,7 @@ export class MemoryStore {
874
1042
 
875
1043
  async update(
876
1044
  id: string,
877
- updates: { text?: string; vector?: number[]; importance?: number; category?: MemoryEntry["category"]; metadata?: string; timestamp?: number; language?: string; fts_text?: string },
1045
+ updates: MemoryStoreUpdate,
878
1046
  scopeFilter?: string[]
879
1047
  ): Promise<MemoryEntry | null> {
880
1048
  await this.ensureInitialized();
@@ -916,7 +1084,10 @@ export class MemoryStore {
916
1084
  const updated: MemoryEntry = {
917
1085
  id: row.id as string,
918
1086
  text: updates.text ?? (row.text as string),
919
- 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>) : []),
920
1091
  category: updates.category ?? (row.category as MemoryEntry["category"]),
921
1092
  scope: rowScope,
922
1093
  importance: updates.importance ?? Number(row.importance),
@@ -936,14 +1107,94 @@ export class MemoryStore {
936
1107
  }
937
1108
  }
938
1109
 
939
- // LanceDB doesn't support in-place update; delete + re-add
940
- const resolvedId = escapeSqlLiteral(row.id as string);
941
- await this.table!.delete(`id = '${resolvedId}'`);
942
- await this.table!.add([updated]);
1110
+ // Atomic upsert via mergeInsert — replaces the previous delete+add two-step,
1111
+ // which could lose the row entirely on a crash between steps and exposed a
1112
+ // window where concurrent reads saw the id momentarily vanish.
1113
+ await this.table!
1114
+ .mergeInsert("id")
1115
+ .whenMatchedUpdateAll()
1116
+ .whenNotMatchedInsertAll()
1117
+ .execute([updated]);
943
1118
 
944
1119
  return updated;
945
1120
  }
946
1121
 
1122
+ /**
1123
+ * Serialized read-modify-write for the metadata column.
1124
+ *
1125
+ * Every direct `getById → mutate metadata → update` round-trip can lose
1126
+ * concurrent increments (last write wins). This method funnels metadata
1127
+ * patches for the same id through a per-id promise queue so each patch
1128
+ * reads the result of the previous one.
1129
+ *
1130
+ * patchFn receives the parsed metadata object plus the freshly-read entry
1131
+ * (for callers that need importance/timestamp context) and returns the new
1132
+ * metadata object. A throwing patchFn abandons that patch — the error
1133
+ * propagates to the caller, later queued patches still run.
1134
+ *
1135
+ * Scope of guarantee: in-process only. Cross-process writers (multiple
1136
+ * mcp-server instances) and the remaining direct update() callers
1137
+ * (consolidation/capture/forget/prospective/conflict engines, auto-gc)
1138
+ * are NOT serialized by this queue. Long-term direction: make this the
1139
+ * only legal metadata write path.
1140
+ */
1141
+ async patchMetadata(
1142
+ id: string,
1143
+ patchFn: (meta: Record<string, unknown>, entry: MemoryEntry) => Record<string, unknown>,
1144
+ scopeFilter?: string[],
1145
+ ): Promise<MemoryEntry | null> {
1146
+ await this.ensureInitialized();
1147
+
1148
+ // Queue key must be the resolved full UUID — otherwise a prefix caller and
1149
+ // a full-id caller targeting the same row would race on separate queues.
1150
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1151
+ let fullId: string;
1152
+ if (uuidRegex.test(id)) {
1153
+ fullId = id;
1154
+ } else {
1155
+ const resolved = await this.getById(id);
1156
+ if (!resolved) return null;
1157
+ fullId = resolved.id;
1158
+ }
1159
+
1160
+ const run = async (): Promise<MemoryEntry | null> => {
1161
+ const entry = await this.getById(fullId);
1162
+ if (!entry) return null;
1163
+ let meta: Record<string, unknown>;
1164
+ try {
1165
+ const parsed: unknown = JSON.parse(entry.metadata || "{}");
1166
+ meta = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
1167
+ ? (parsed as Record<string, unknown>)
1168
+ : {};
1169
+ } catch {
1170
+ meta = {};
1171
+ }
1172
+ const patched = patchFn(meta, entry);
1173
+ return this.update(fullId, { metadata: JSON.stringify(patched) }, scopeFilter);
1174
+ };
1175
+
1176
+ const prev = this.metadataPatchQueues.get(fullId) ?? Promise.resolve();
1177
+ // Isolate prior failures so one rejected patch can't wedge the queue.
1178
+ const result = prev.then(run, run);
1179
+ // The stored tail settles regardless of outcome; the caller keeps the real
1180
+ // rejection via `result`. Only the current tail removes itself.
1181
+ const tail = result.then(
1182
+ () => undefined,
1183
+ () => undefined,
1184
+ ).then(() => {
1185
+ if (this.metadataPatchQueues.get(fullId) === tail) {
1186
+ this.metadataPatchQueues.delete(fullId);
1187
+ }
1188
+ });
1189
+ this.metadataPatchQueues.set(fullId, tail);
1190
+ return result;
1191
+ }
1192
+
1193
+ /** Number of ids with in-flight metadata patches (test/diagnostic hook). */
1194
+ get pendingMetadataPatchCount(): number {
1195
+ return this.metadataPatchQueues.size;
1196
+ }
1197
+
947
1198
  async bulkDelete(scopeFilter: string[], beforeTimestamp?: number): Promise<number> {
948
1199
  await this.ensureInitialized();
949
1200
 
@@ -983,6 +1234,19 @@ export class MemoryStore {
983
1234
  return deleteCount;
984
1235
  }
985
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
+
986
1250
  get hasFtsSupport(): boolean {
987
1251
  return this.ftsIndexCreated;
988
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
  "验收",
@@ -59,7 +59,9 @@ const RULES: CompressionRule[] = [
59
59
 
60
60
  // Patterns that identify tool output blocks in conversation text.
61
61
  // Claude Code / Codex format: command on one line, output follows.
62
- const TOOL_OUTPUT_BLOCK = /^(?:\$\s+|❯\s+|>\s+)(.+)\n([\s\S]*?)(?=\n(?:\$\s+|❯\s+|>\s+)|\n\n[A-Z]|\n##|\z)/gm;
62
+ // 末项用 (?![\s\S]) 表示「字符串绝对结尾」。不能用 \z(JS 无此锚点,会被当字面字符 z →
63
+ // 在输出里第一个 z 处误截断),也不能用 $(gm 模式下匹配每行尾,惰性 [\s\S]*? 会在第一行就停)。
64
+ const TOOL_OUTPUT_BLOCK = /^(?:\$\s+|❯\s+|>\s+)(.+)\n([\s\S]*?)(?=\n(?:\$\s+|❯\s+|>\s+)|\n\n[A-Z]|\n##|(?![\s\S]))/gm;
63
65
 
64
66
  // Base64 image data (screenshots, etc.)
65
67
  const BASE64_BLOCK = /(?:data:image\/[^;]+;base64,)?[A-Za-z0-9+/]{500,}={0,2}/g;
package/src/ui-server.ts CHANGED
@@ -12,6 +12,8 @@ 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";
16
+ import * as envConfig from "./env-config.js";
15
17
  const config = (loadDotEnv(), loadConfig());
16
18
  const getComponents = createComponentResolver(config);
17
19
 
@@ -186,9 +188,15 @@ async function handleSearch(mode: "search" | "explain" | "distill", body: Record
186
188
  }
187
189
 
188
190
  const server = Bun.serve({
189
- port: readLimit(process.env.RECALLNEST_UI_PORT, 4317, 1, 65535),
191
+ port: readLimit(envConfig.uiPortRaw(), 4317, 1, 65535),
192
+ hostname: LOCAL_HTTP_HOSTNAME,
190
193
  async fetch(request) {
191
194
  try {
195
+ const policy = await enforceLocalHttpRequestPolicy(request);
196
+ if (!policy.allowed) {
197
+ return textResponse(policy.message, { status: policy.status });
198
+ }
199
+
192
200
  const url = new URL(request.url);
193
201
 
194
202
  if (request.method === "GET" && (url.pathname === "/" || url.pathname.startsWith("/ui/"))) {