moflo 4.9.36 → 4.10.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 (79) hide show
  1. package/.claude/guidance/shipped/moflo-agent-rules.md +12 -0
  2. package/.claude/guidance/shipped/moflo-memory-protocol.md +34 -0
  3. package/.claude/guidance/shipped/moflo-memorydb-maintenance.md +22 -11
  4. package/.claude/guidance/shipped/moflo-root-cause-discipline.md +47 -0
  5. package/.claude/guidance/shipped/moflo-subagents.md +4 -0
  6. package/.claude/helpers/gate.cjs +3 -3
  7. package/.claude/helpers/statusline.cjs +69 -33
  8. package/.claude/helpers/subagent-bootstrap.json +1 -1
  9. package/.claude/helpers/subagent-start.cjs +1 -1
  10. package/.claude/skills/eldar/SKILL.md +8 -0
  11. package/bin/build-embeddings.mjs +6 -20
  12. package/bin/cli.js +5 -0
  13. package/bin/gate.cjs +3 -3
  14. package/bin/generate-code-map.mjs +4 -24
  15. package/bin/hooks.mjs +3 -12
  16. package/bin/index-all.mjs +3 -13
  17. package/bin/index-guidance.mjs +59 -119
  18. package/bin/index-patterns.mjs +6 -24
  19. package/bin/index-tests.mjs +4 -23
  20. package/bin/lib/db-repair.mjs +4 -25
  21. package/bin/lib/get-backend.mjs +306 -0
  22. package/bin/lib/incremental-write.mjs +27 -7
  23. package/bin/lib/moflo-paths.mjs +64 -4
  24. package/bin/lib/suppress-sqlite-warning.mjs +57 -0
  25. package/bin/migrations/knowledge-purge.mjs +7 -8
  26. package/bin/migrations/knowledge-to-learnings.mjs +7 -9
  27. package/bin/migrations/purge-doc-entries.mjs +52 -0
  28. package/bin/migrations/strip-context-preambles.mjs +95 -0
  29. package/bin/run-migrations.mjs +1 -10
  30. package/bin/semantic-search.mjs +11 -19
  31. package/bin/session-start-launcher.mjs +102 -100
  32. package/bin/simplify-classify.cjs +38 -17
  33. package/dist/src/cli/commands/daemon.js +38 -11
  34. package/dist/src/cli/commands/doctor-checks-coverage-truth.js +136 -0
  35. package/dist/src/cli/commands/doctor-checks-memory-access.js +244 -5
  36. package/dist/src/cli/commands/doctor-checks-memory.js +13 -18
  37. package/dist/src/cli/commands/doctor-checks-version-skew.js +94 -0
  38. package/dist/src/cli/commands/doctor-checks-writers-audit.js +170 -0
  39. package/dist/src/cli/commands/doctor-embedding-hygiene.js +3 -15
  40. package/dist/src/cli/commands/doctor-fixes.js +30 -0
  41. package/dist/src/cli/commands/doctor-registry.js +14 -0
  42. package/dist/src/cli/commands/doctor.js +1 -1
  43. package/dist/src/cli/commands/embeddings.js +17 -22
  44. package/dist/src/cli/commands/memory.js +54 -75
  45. package/dist/src/cli/embeddings/persistent-cache.js +44 -83
  46. package/dist/src/cli/init/claudemd-generator.js +4 -0
  47. package/dist/src/cli/init/moflo-init.js +40 -0
  48. package/dist/src/cli/mcp-tools/memory-tools.js +177 -32
  49. package/dist/src/cli/memory/bridge-core.js +256 -30
  50. package/dist/src/cli/memory/bridge-entries.js +76 -8
  51. package/dist/src/cli/memory/controller-registry.js +7 -2
  52. package/dist/src/cli/memory/controllers/batch-operations.js +5 -1
  53. package/dist/src/cli/memory/controllers/hierarchical-memory.js +7 -2
  54. package/dist/src/cli/memory/controllers/mutation-guard.js +22 -2
  55. package/dist/src/cli/memory/daemon-backend.js +400 -0
  56. package/dist/src/cli/memory/daemon-write-client.js +192 -15
  57. package/dist/src/cli/memory/database-provider.js +57 -40
  58. package/dist/src/cli/memory/hnsw-persistence.js +6 -8
  59. package/dist/src/cli/memory/index.js +0 -1
  60. package/dist/src/cli/memory/memory-bridge.js +40 -8
  61. package/dist/src/cli/memory/memory-initializer.js +286 -220
  62. package/dist/src/cli/memory/rvf-migration.js +25 -11
  63. package/dist/src/cli/memory/sqlite-backend.js +573 -0
  64. package/dist/src/cli/memory/suppress-sqlite-warning.js +49 -0
  65. package/dist/src/cli/services/cherry-pick-learnings.js +32 -21
  66. package/dist/src/cli/services/daemon-dashboard.js +13 -1
  67. package/dist/src/cli/services/daemon-lock.js +58 -1
  68. package/dist/src/cli/services/daemon-memory-rpc.js +245 -10
  69. package/dist/src/cli/services/embeddings-migration.js +9 -12
  70. package/dist/src/cli/services/ephemeral-namespace-purge.js +6 -11
  71. package/dist/src/cli/services/learning-service.js +12 -20
  72. package/dist/src/cli/services/project-root.js +69 -9
  73. package/dist/src/cli/services/soft-delete-purge.js +6 -11
  74. package/dist/src/cli/services/sqljs-migration-store.js +4 -1
  75. package/dist/src/cli/services/subagent-bootstrap.js +1 -1
  76. package/dist/src/cli/shared/events/event-store.js +26 -55
  77. package/dist/src/cli/version.js +1 -1
  78. package/package.json +2 -4
  79. package/dist/src/cli/memory/sqljs-backend.js +0 -643
@@ -33,10 +33,9 @@
33
33
  /* eslint-disable @typescript-eslint/no-explicit-any */
34
34
  import * as fs from 'fs';
35
35
  import * as path from 'path';
36
- import { mofloImport } from './moflo-require.js';
37
- import { atomicWriteFileSync } from './atomic-file-write.js';
38
36
  import { legacyMemoryDbBakPath, memoryDbCandidatePaths, memoryDbPath, } from './moflo-paths.js';
39
37
  import { MEMORY_SCHEMA_V3 } from '../memory/memory-initializer.js';
38
+ import { openDaemonDatabase } from '../memory/daemon-backend.js';
40
39
  /** Namespaces preserved across upgrades. Everything else is derived. */
41
40
  export const DURABLE_NAMESPACES = ['learnings', 'knowledge'];
42
41
  /**
@@ -84,15 +83,34 @@ export async function cherryPickLearningsFromLegacy(options = {}) {
84
83
  sources: [],
85
84
  target,
86
85
  };
87
- const initSqlJs = (await mofloImport('sql.js'))?.default;
88
- if (!initSqlJs)
86
+ // Fast-path: skip target materialization entirely if no legacy source is
87
+ // even on disk. A fresh install has no .swarm/memory.db etc. — opening
88
+ // targetDb would create an empty .moflo/moflo.db that the regular memory
89
+ // initializer should create lazily on first MCP write. Saves an unnecessary
90
+ // atomic-write per fresh-install upgrade and keeps the existsSync(target)
91
+ // contract that the launcher relies on.
92
+ const presentSources = legacyPaths.filter((p) => p !== target && fs.existsSync(p));
93
+ if (presentSources.length === 0) {
94
+ // Still emit a self-reference report if the canonical was in the list,
95
+ // for symmetry with the multi-source path.
96
+ for (const sourcePath of legacyPaths) {
97
+ if (sourcePath === target) {
98
+ result.sources.push({
99
+ path: sourcePath,
100
+ rowsRead: 0,
101
+ rowsInserted: 0,
102
+ reason: CHERRY_PICK_SKIP_REASONS.SELF_REFERENCE,
103
+ });
104
+ }
105
+ }
89
106
  return result;
90
- const SQL = (await initSqlJs());
107
+ }
108
+ // node:sqlite via the unified factory (Phase 5 / #1084). openDaemonDatabase
109
+ // creates parent dirs + applies WAL pragma; the WAL-incremental persistence
110
+ // model means no atomicWriteFileSync at the end — every INSERT below
111
+ // commits to disk through the WAL.
91
112
  fs.mkdirSync(path.dirname(target), { recursive: true });
92
- const targetExists = fs.existsSync(target);
93
- const targetDb = targetExists
94
- ? new SQL.Database(fs.readFileSync(target))
95
- : new SQL.Database();
113
+ const targetDb = openDaemonDatabase(target);
96
114
  let insertStmt = null;
97
115
  try {
98
116
  targetDb.run(MEMORY_SCHEMA_V3);
@@ -100,8 +118,8 @@ export async function cherryPickLearningsFromLegacy(options = {}) {
100
118
  const selectSql = `SELECT id, key, namespace, content, type, embedding, embedding_model, ` +
101
119
  `embedding_dimensions, tags, metadata, owner_id, created_at, updated_at, status ` +
102
120
  `FROM memory_entries WHERE namespace IN (${placeholders})`;
103
- // Hoisted prepare — avoids re-parsing the SQL inside sql.js for every
104
- // INSERT. Matters for legacy DBs with hundreds of learnings rows.
121
+ // Hoisted prepare — avoids re-parsing the SQL for every INSERT. Matters
122
+ // for legacy DBs with hundreds of learnings rows.
105
123
  insertStmt = targetDb.prepare(`INSERT OR IGNORE INTO memory_entries ` +
106
124
  `(id, key, namespace, content, type, embedding, embedding_model, ` +
107
125
  ` embedding_dimensions, tags, metadata, owner_id, created_at, updated_at, status) ` +
@@ -118,18 +136,11 @@ export async function cherryPickLearningsFromLegacy(options = {}) {
118
136
  }
119
137
  if (!fs.existsSync(sourcePath))
120
138
  continue;
121
- const report = readAndInsert(SQL, sourcePath, targetDb, insertStmt, selectSql, namespaces);
139
+ const report = readAndInsert(sourcePath, targetDb, insertStmt, selectSql, namespaces);
122
140
  result.sources.push(report);
123
141
  result.copied += report.rowsInserted;
124
142
  result.considered += report.rowsRead;
125
143
  }
126
- // Skip the atomic write when there's nothing to persist:
127
- // - copied=0 + target didn't exist → don't materialize an empty DB
128
- // (the regular initializer creates it on first real write).
129
- // - copied=0 + target already existed → no diff, nothing to flush.
130
- if (result.copied > 0) {
131
- atomicWriteFileSync(target, Buffer.from(targetDb.export()));
132
- }
133
144
  }
134
145
  finally {
135
146
  if (insertStmt) {
@@ -142,10 +153,10 @@ export async function cherryPickLearningsFromLegacy(options = {}) {
142
153
  }
143
154
  return result;
144
155
  }
145
- function readAndInsert(SQL, sourcePath, targetDb, insertStmt, selectSql, namespaces) {
156
+ function readAndInsert(sourcePath, targetDb, insertStmt, selectSql, namespaces) {
146
157
  let sourceDb;
147
158
  try {
148
- sourceDb = new SQL.Database(fs.readFileSync(sourcePath));
159
+ sourceDb = openDaemonDatabase(sourcePath);
149
160
  }
150
161
  catch {
151
162
  return {
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { createServer } from 'node:http';
16
16
  import { errorDetail } from '../shared/utils/error-detail.js';
17
- import { handleMemoryStore, handleMemoryDelete, handleMemoryBatch, matchMemoryRpcRoute, } from './daemon-memory-rpc.js';
17
+ import { handleMemoryStore, handleMemoryDelete, handleMemoryBatch, handleMemoryGet, handleMemorySearch, handleMemoryList, matchMemoryRpcRoute, } from './daemon-memory-rpc.js';
18
18
  import { aggregateClaudeStats, emptyClaudeStatsShape } from './claude-stats.js';
19
19
  export const DEFAULT_DASHBOARD_PORT = 3117;
20
20
  /**
@@ -407,6 +407,18 @@ async function handleRequest(req, res, daemon, opts) {
407
407
  await handleMemoryBatch(req, res, opts.memory);
408
408
  return;
409
409
  }
410
+ if (memoryRoute === 'get') {
411
+ await handleMemoryGet(req, res, opts.memory);
412
+ return;
413
+ }
414
+ if (memoryRoute === 'search') {
415
+ await handleMemorySearch(req, res, opts.memory);
416
+ return;
417
+ }
418
+ if (memoryRoute === 'list') {
419
+ await handleMemoryList(req, res, opts.memory);
420
+ return;
421
+ }
410
422
  }
411
423
  if (method !== 'GET') {
412
424
  sendJson(res, 405, { error: 'Method not allowed' });
@@ -9,7 +9,8 @@
9
9
  * and verifying the process command line before trusting a "live" PID.
10
10
  */
11
11
  import * as fs from 'fs';
12
- import { join } from 'path';
12
+ import { dirname, join } from 'path';
13
+ import { fileURLToPath } from 'url';
13
14
  import { execSync } from 'child_process';
14
15
  const LOCK_FILENAME = 'daemon.lock';
15
16
  const LOCK_LABEL = 'moflo-daemon';
@@ -17,6 +18,33 @@ const LOCK_LABEL = 'moflo-daemon';
17
18
  export function lockPath(projectRoot) {
18
19
  return join(projectRoot, '.moflo', LOCK_FILENAME);
19
20
  }
21
+ /**
22
+ * Read this daemon's own moflo package version by walking up from the
23
+ * compiled module location until a `package.json` with `"name": "moflo"`
24
+ * is found. Mirrors the pattern in `mcp-server.ts:260-279`. Returns
25
+ * `undefined` if the package.json can't be located — the launcher treats
26
+ * an undefined version the same as a mismatch, so this stays safe.
27
+ */
28
+ export function readOwnMofloVersion() {
29
+ try {
30
+ let dir = dirname(fileURLToPath(import.meta.url));
31
+ for (;;) {
32
+ try {
33
+ const pkg = JSON.parse(fs.readFileSync(join(dir, 'package.json'), 'utf8'));
34
+ if (pkg.name === 'moflo' && typeof pkg.version === 'string')
35
+ return pkg.version;
36
+ }
37
+ catch { /* ignore — keep walking */ }
38
+ const parent = dirname(dir);
39
+ if (parent === dir)
40
+ return undefined;
41
+ dir = parent;
42
+ }
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ }
20
48
  /**
21
49
  * Try to acquire the daemon lock atomically.
22
50
  *
@@ -34,6 +62,7 @@ export function acquireDaemonLock(projectRoot, pid = process.pid) {
34
62
  pid,
35
63
  startedAt: Date.now(),
36
64
  label: LOCK_LABEL,
65
+ version: readOwnMofloVersion(),
37
66
  };
38
67
  // Attempt 1: atomic exclusive create
39
68
  const result = tryExclusiveWrite(lock, payload);
@@ -95,6 +124,7 @@ export function transferDaemonLock(projectRoot, newPid, fromPid = process.pid) {
95
124
  pid: newPid,
96
125
  startedAt: Date.now(),
97
126
  label: LOCK_LABEL,
127
+ version: existing.version ?? readOwnMofloVersion(),
98
128
  };
99
129
  try {
100
130
  // Atomic overwrite — no unlink/recreate gap
@@ -105,6 +135,27 @@ export function transferDaemonLock(projectRoot, newPid, fromPid = process.pid) {
105
135
  return false;
106
136
  }
107
137
  }
138
+ /**
139
+ * Read the full daemon-lock payload (or null if no daemon, corrupt lock,
140
+ * or the holder is dead). Used by the launcher to compare the daemon's
141
+ * reported moflo version against the installed package.json version
142
+ * (epic #1054 — kill stale daemons that survived `npm install moflo@new`).
143
+ */
144
+ export function getDaemonLockPayload(projectRoot) {
145
+ const lock = lockPath(projectRoot);
146
+ if (!fs.existsSync(lock))
147
+ return null;
148
+ const existing = readLockPayload(lock);
149
+ if (!existing) {
150
+ safeUnlink(lock);
151
+ return null;
152
+ }
153
+ if (isProcessAlive(existing.pid) && isDaemonProcess(existing.pid)) {
154
+ return existing;
155
+ }
156
+ safeUnlink(lock);
157
+ return null;
158
+ }
108
159
  /**
109
160
  * Check if the daemon lock is currently held by a live daemon.
110
161
  * Returns the holder PID or null.
@@ -183,6 +234,12 @@ function isProcessAlive(pid) {
183
234
  * to avoid accidentally allowing duplicates on exotic platforms.
184
235
  */
185
236
  export function isDaemonProcess(pid) {
237
+ // #1086: Windows execSync introspection (8s worst-case: tasklist 3s +
238
+ // powershell 5s) starves under parallel vitest workers and pushes tests
239
+ // past the 5s budget. Production never sets this env var.
240
+ if (process.env.MOFLO_TEST_TRUST_DAEMON_PID === '1') {
241
+ return true;
242
+ }
186
243
  try {
187
244
  if (process.platform === 'win32') {
188
245
  return isDaemonProcessWindows(pid);
@@ -1,13 +1,18 @@
1
1
  /**
2
- * Daemon HTTP RPC for memory writes (#981 — single-writer architecture).
2
+ * Daemon HTTP RPC for memory writes + reads (#981 / #1058 — single-writer
3
+ * architecture and its read-side symmetry).
3
4
  *
4
- * Adds POST /api/memory/{store,delete,batch} to the existing daemon HTTP
5
- * server. The daemon process becomes the single authoritative writer when
6
- * these endpoints are called; other processes (CLI, MCP server) route
7
- * writes here via the daemon-write-client (Story #984).
5
+ * Adds POST /api/memory/{store,delete,batch,get,search,list} to the existing
6
+ * daemon HTTP server. The daemon becomes the single authoritative writer AND
7
+ * the single source-of-truth for reads when reachable; other processes (CLI,
8
+ * MCP server) route through the daemon-write-client (Story #984 / #1058) so
9
+ * they never serve stale rows from a per-process sql.js snapshot.
8
10
  *
9
- * Story #983 ships these endpoints purely additively nothing in the
10
- * codebase calls them yet. Stories #985 / #986 wire consumer callers.
11
+ * Read endpoints (#1058): bridgeGetEntry/Search/List in a non-daemon process
12
+ * queries the bridge's in-memory snapshot loaded at process start. sql.js
13
+ * never re-reads disk, so any write by the daemon is invisible to that
14
+ * process until restart. Routing reads here lets every process see the
15
+ * daemon's authoritative state in real time.
11
16
  *
12
17
  * Loopback-only: the parent server binds 127.0.0.1, so no auth/CSRF.
13
18
  *
@@ -29,6 +34,8 @@ const NAMESPACE_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/;
29
34
  const KEY_MAX_LENGTH = 256;
30
35
  /** Max ops per batch. Bounds memory + write time per request. */
31
36
  export const BATCH_MAX_OPS = 100;
37
+ /** Cap on serialised `metadata` per op (#1064); 64 KB leaves room for a 100-op batch under {@link MEMORY_RPC_MAX_BODY_BYTES}. */
38
+ const METADATA_MAX_BYTES = 64 * 1024;
32
39
  // ============================================================================
33
40
  // JSON body reader (size-capped, never throws)
34
41
  // ============================================================================
@@ -88,6 +95,37 @@ function isKey(key) {
88
95
  function isStringArray(value) {
89
96
  return Array.isArray(value) && value.every(v => typeof v === 'string');
90
97
  }
98
+ /** Validate optional `metadata` and return the already-serialised string so the INSERT site doesn't re-stringify (#1064). */
99
+ function validateMetadata(value) {
100
+ if (value === undefined)
101
+ return { ok: true, metadata: undefined };
102
+ if (typeof value === 'string') {
103
+ if (Buffer.byteLength(value, 'utf8') > METADATA_MAX_BYTES) {
104
+ return { ok: false, error: `metadata exceeds ${METADATA_MAX_BYTES} bytes` };
105
+ }
106
+ try {
107
+ JSON.parse(value);
108
+ }
109
+ catch {
110
+ return { ok: false, error: 'metadata string is not valid JSON' };
111
+ }
112
+ return { ok: true, metadata: value };
113
+ }
114
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
115
+ return { ok: false, error: 'metadata must be a JSON object or stringified JSON' };
116
+ }
117
+ let serialised;
118
+ try {
119
+ serialised = JSON.stringify(value);
120
+ }
121
+ catch {
122
+ return { ok: false, error: 'metadata is not serialisable' };
123
+ }
124
+ if (Buffer.byteLength(serialised, 'utf8') > METADATA_MAX_BYTES) {
125
+ return { ok: false, error: `metadata exceeds ${METADATA_MAX_BYTES} bytes` };
126
+ }
127
+ return { ok: true, metadata: serialised };
128
+ }
91
129
  function validateStorePayload(body) {
92
130
  if (typeof body !== 'object' || body === null)
93
131
  return { ok: false, error: 'body must be a JSON object' };
@@ -103,6 +141,9 @@ function validateStorePayload(body) {
103
141
  if (b.ttl !== undefined && (typeof b.ttl !== 'number' || !Number.isFinite(b.ttl) || b.ttl <= 0)) {
104
142
  return { ok: false, error: 'ttl must be a positive finite number (seconds)' };
105
143
  }
144
+ const metaResult = validateMetadata(b.metadata);
145
+ if (!metaResult.ok)
146
+ return { ok: false, error: metaResult.error };
106
147
  return {
107
148
  ok: true,
108
149
  op: {
@@ -111,6 +152,7 @@ function validateStorePayload(body) {
111
152
  value: b.value,
112
153
  tags: b.tags,
113
154
  ttl: b.ttl,
155
+ metadata: metaResult.metadata,
114
156
  },
115
157
  };
116
158
  }
@@ -124,6 +166,63 @@ function validateDeletePayload(body) {
124
166
  return { ok: false, error: 'invalid key' };
125
167
  return { ok: true, op: { namespace: b.namespace, key: b.key } };
126
168
  }
169
+ function validateGetPayload(body) {
170
+ // Get takes the same shape as delete.
171
+ return validateDeletePayload(body);
172
+ }
173
+ /** Max query length for /api/memory/search — matches the existing memory_search MCP tool bound. */
174
+ const MAX_SEARCH_QUERY_LENGTH = 4096;
175
+ function validateSearchPayload(body) {
176
+ if (typeof body !== 'object' || body === null)
177
+ return { ok: false, error: 'body must be a JSON object' };
178
+ const b = body;
179
+ if (typeof b.query !== 'string' || b.query.length === 0) {
180
+ return { ok: false, error: 'query must be a non-empty string' };
181
+ }
182
+ if (b.query.length > MAX_SEARCH_QUERY_LENGTH) {
183
+ return { ok: false, error: `query exceeds ${MAX_SEARCH_QUERY_LENGTH} chars` };
184
+ }
185
+ // Namespace is optional; when provided must validate (allow 'all' for cross-NS search).
186
+ if (b.namespace !== undefined && b.namespace !== 'all' && !isNamespace(b.namespace)) {
187
+ return { ok: false, error: `invalid namespace (must match ${NAMESPACE_PATTERN}, 'all', or omitted)` };
188
+ }
189
+ if (b.limit !== undefined && (typeof b.limit !== 'number' || !Number.isInteger(b.limit) || b.limit <= 0 || b.limit > 1000)) {
190
+ return { ok: false, error: 'limit must be a positive integer ≤1000' };
191
+ }
192
+ if (b.threshold !== undefined && (typeof b.threshold !== 'number' || b.threshold < 0 || b.threshold > 1)) {
193
+ return { ok: false, error: 'threshold must be a number in [0, 1]' };
194
+ }
195
+ return {
196
+ ok: true,
197
+ op: {
198
+ query: b.query,
199
+ namespace: b.namespace,
200
+ limit: b.limit,
201
+ threshold: b.threshold,
202
+ },
203
+ };
204
+ }
205
+ function validateListPayload(body) {
206
+ // body may be empty for list-all; coerce null → {}.
207
+ const b = body && typeof body === 'object' ? body : {};
208
+ if (b.namespace !== undefined && !isNamespace(b.namespace)) {
209
+ return { ok: false, error: `invalid namespace (must match ${NAMESPACE_PATTERN} or be omitted)` };
210
+ }
211
+ if (b.limit !== undefined && (typeof b.limit !== 'number' || !Number.isInteger(b.limit) || b.limit <= 0 || b.limit > 10_000)) {
212
+ return { ok: false, error: 'limit must be a positive integer ≤10000' };
213
+ }
214
+ if (b.offset !== undefined && (typeof b.offset !== 'number' || !Number.isInteger(b.offset) || b.offset < 0)) {
215
+ return { ok: false, error: 'offset must be a non-negative integer' };
216
+ }
217
+ return {
218
+ ok: true,
219
+ op: {
220
+ namespace: b.namespace,
221
+ limit: b.limit,
222
+ offset: b.offset,
223
+ },
224
+ };
225
+ }
127
226
  function validateBatchPayload(body) {
128
227
  if (typeof body !== 'object' || body === null)
129
228
  return { ok: false, error: 'body must be a JSON object' };
@@ -179,7 +278,13 @@ function valueToString(value) {
179
278
  /** Lazy import to avoid a circular dep with memory-initializer's heavy graph. */
180
279
  async function getMemoryFns() {
181
280
  const mod = await import('../memory/memory-initializer.js');
182
- return { storeEntry: mod.storeEntry, deleteEntry: mod.deleteEntry };
281
+ return {
282
+ storeEntry: mod.storeEntry,
283
+ deleteEntry: mod.deleteEntry,
284
+ getEntry: mod.getEntry,
285
+ searchEntries: mod.searchEntries,
286
+ listEntries: mod.listEntries,
287
+ };
183
288
  }
184
289
  /**
185
290
  * POST /api/memory/store — write a single entry through the daemon's
@@ -209,13 +314,18 @@ export async function handleMemoryStore(req, res, memory) {
209
314
  namespace: v.op.namespace,
210
315
  tags: v.op.tags,
211
316
  ttl: v.op.ttl,
317
+ metadata: v.op.metadata,
212
318
  upsert: true,
213
319
  });
214
320
  if (!result.success) {
215
321
  sendJson(res, 500, { error: 'Store failed', message: result.error ?? 'unknown' });
216
322
  return;
217
323
  }
218
- sendJson(res, 200, { ok: true, stored: true, id: result.id });
324
+ // #1065 preserve the bridge's `embedding: { dimensions, model }` shape
325
+ // through the wire boundary. Without this, MCP `memory_store` reports
326
+ // `hasEmbedding: false` on every daemon-routed write that actually
327
+ // succeeded, and the doctor Memory Access check fails.
328
+ sendJson(res, 200, { ok: true, stored: true, id: result.id, embedding: result.embedding });
219
329
  }
220
330
  catch (err) {
221
331
  sendJson(res, 500, { error: 'Internal error', message: errorDetail(err) });
@@ -292,10 +402,13 @@ export async function handleMemoryBatch(req, res, memory) {
292
402
  namespace: op.namespace,
293
403
  tags: op.tags,
294
404
  ttl: op.ttl,
405
+ metadata: op.metadata,
295
406
  upsert: true,
296
407
  });
297
408
  if (r.success) {
298
- results.push({ ok: true, id: r.id });
409
+ // #1065 same shape carry-through as /store: include embedding
410
+ // so batch callers can report hasEmbedding accurately.
411
+ results.push({ ok: true, id: r.id, embedding: r.embedding });
299
412
  }
300
413
  else {
301
414
  results.push({ ok: false, error: r.error ?? 'unknown' });
@@ -320,6 +433,122 @@ export async function handleMemoryBatch(req, res, memory) {
320
433
  }
321
434
  sendJson(res, anyFailed ? 207 : 200, { ok: !anyFailed, results });
322
435
  }
436
+ /**
437
+ * POST /api/memory/get — retrieve a single entry from the daemon's
438
+ * authoritative bridge. In a non-daemon process the bridge holds a stale
439
+ * sql.js snapshot from process-start time (#1058); routing the read here
440
+ * lets the daemon serve the up-to-date row.
441
+ */
442
+ export async function handleMemoryGet(req, res, memory) {
443
+ if (!memory) {
444
+ sendJson(res, 503, { error: 'Memory accessor not attached' });
445
+ return;
446
+ }
447
+ const body = await readJsonBody(req);
448
+ if (body === null) {
449
+ sendJson(res, 400, { error: 'Invalid or oversized JSON body' });
450
+ return;
451
+ }
452
+ const v = validateGetPayload(body);
453
+ if (!v.ok) {
454
+ sendJson(res, 400, { error: 'Invalid get request', message: v.error });
455
+ return;
456
+ }
457
+ try {
458
+ const { getEntry } = await getMemoryFns();
459
+ const result = await getEntry({ key: v.op.key, namespace: v.op.namespace });
460
+ if (!result.success) {
461
+ sendJson(res, 500, { error: 'Get failed', message: result.error ?? 'unknown' });
462
+ return;
463
+ }
464
+ sendJson(res, 200, { ok: true, found: result.found, entry: result.entry });
465
+ }
466
+ catch (err) {
467
+ sendJson(res, 500, { error: 'Internal error', message: errorDetail(err) });
468
+ }
469
+ }
470
+ /**
471
+ * POST /api/memory/search — semantic vector search routed through the
472
+ * daemon's authoritative bridge.
473
+ */
474
+ export async function handleMemorySearch(req, res, memory) {
475
+ if (!memory) {
476
+ sendJson(res, 503, { error: 'Memory accessor not attached' });
477
+ return;
478
+ }
479
+ const body = await readJsonBody(req);
480
+ if (body === null) {
481
+ sendJson(res, 400, { error: 'Invalid or oversized JSON body' });
482
+ return;
483
+ }
484
+ const v = validateSearchPayload(body);
485
+ if (!v.ok) {
486
+ sendJson(res, 400, { error: 'Invalid search request', message: v.error });
487
+ return;
488
+ }
489
+ try {
490
+ const { searchEntries } = await getMemoryFns();
491
+ const result = await searchEntries({
492
+ query: v.op.query,
493
+ namespace: v.op.namespace,
494
+ limit: v.op.limit,
495
+ threshold: v.op.threshold,
496
+ });
497
+ if (!result.success) {
498
+ sendJson(res, 500, { error: 'Search failed', message: result.error ?? 'unknown' });
499
+ return;
500
+ }
501
+ sendJson(res, 200, { ok: true, results: result.results, searchTime: result.searchTime });
502
+ }
503
+ catch (err) {
504
+ sendJson(res, 500, { error: 'Internal error', message: errorDetail(err) });
505
+ }
506
+ }
507
+ /**
508
+ * POST /api/memory/list — list entries via the daemon's bridge with optional
509
+ * namespace filter and pagination.
510
+ */
511
+ export async function handleMemoryList(req, res, memory) {
512
+ if (!memory) {
513
+ sendJson(res, 503, { error: 'Memory accessor not attached' });
514
+ return;
515
+ }
516
+ const body = await readJsonBody(req);
517
+ // List allows an empty body (list-all default); a JSON-parse failure on a
518
+ // non-empty body is still a 400, but a literal empty body coerces to {}.
519
+ let parsed = body;
520
+ if (body === null) {
521
+ // Distinguish "empty body" from "invalid JSON" — readJsonBody returns null
522
+ // for both, so probe content-length to tell them apart.
523
+ const contentLength = parseInt(req.headers['content-length'] ?? '0', 10);
524
+ if (contentLength > 0) {
525
+ sendJson(res, 400, { error: 'Invalid or oversized JSON body' });
526
+ return;
527
+ }
528
+ parsed = {};
529
+ }
530
+ const v = validateListPayload(parsed);
531
+ if (!v.ok) {
532
+ sendJson(res, 400, { error: 'Invalid list request', message: v.error });
533
+ return;
534
+ }
535
+ try {
536
+ const { listEntries } = await getMemoryFns();
537
+ const result = await listEntries({
538
+ namespace: v.op.namespace,
539
+ limit: v.op.limit,
540
+ offset: v.op.offset,
541
+ });
542
+ if (!result.success) {
543
+ sendJson(res, 500, { error: 'List failed', message: result.error ?? 'unknown' });
544
+ return;
545
+ }
546
+ sendJson(res, 200, { ok: true, entries: result.entries, total: result.total });
547
+ }
548
+ catch (err) {
549
+ sendJson(res, 500, { error: 'Internal error', message: errorDetail(err) });
550
+ }
551
+ }
323
552
  export function matchMemoryRpcRoute(url) {
324
553
  if (!url)
325
554
  return null;
@@ -330,6 +559,12 @@ export function matchMemoryRpcRoute(url) {
330
559
  return 'delete';
331
560
  if (path === '/api/memory/batch')
332
561
  return 'batch';
562
+ if (path === '/api/memory/get')
563
+ return 'get';
564
+ if (path === '/api/memory/search')
565
+ return 'search';
566
+ if (path === '/api/memory/list')
567
+ return 'list';
333
568
  return null;
334
569
  }
335
570
  //# sourceMappingURL=daemon-memory-rpc.js.map
@@ -15,9 +15,8 @@
15
15
  * @module cli/services/embeddings-migration
16
16
  */
17
17
  /* eslint-disable @typescript-eslint/no-explicit-any */
18
- import { mofloImport } from './moflo-require.js';
19
- import { atomicWriteFileSync } from './atomic-file-write.js';
20
18
  import { memoryDbPath } from './moflo-paths.js';
19
+ import { openDaemonDatabase } from '../memory/daemon-backend.js';
21
20
  // EMBEDDINGS_VERSION is a number constant in a leaf types module — pulling it
22
21
  // eagerly is cheap. The heavy imports (fastembed wrapper, upgrade renderer,
23
22
  // etc.) stay deferred behind the early returns so session-start stays fast.
@@ -37,12 +36,11 @@ export async function runEmbeddingsMigrationIfNeeded(options = {}) {
37
36
  const dbPath = path.resolve(options.dbPath ?? memoryDbPath(process.cwd()));
38
37
  if (!fs.existsSync(dbPath))
39
38
  return false;
40
- const initSqlJs = (await mofloImport('sql.js'))?.default;
41
- if (!initSqlJs)
42
- return false;
43
- const SQL = await initSqlJs();
44
- const buffer = fs.readFileSync(dbPath);
45
- const db = new SQL.Database(buffer);
39
+ // node:sqlite via the unified factory (Phase 5 / #1084). The migration
40
+ // store runs UPDATE statements via the SAME handle, so WAL persists each
41
+ // re-embed incrementally — no need for the old atomicWriteFileSync at the
42
+ // end (which was the sql.js whole-file-dump that motivated #1078).
43
+ const db = openDaemonDatabase(dbPath);
46
44
  try {
47
45
  // Probe: only migrate DBs that carry the v3 memory_entries schema. The
48
46
  // old `embedding` column alone isn't enough — the migration writes back
@@ -105,10 +103,9 @@ export async function runEmbeddingsMigrationIfNeeded(options = {}) {
105
103
  },
106
104
  });
107
105
  if (summary.status === 'completed') {
108
- // `db.export()` returns a Uint8Array; `writeFileSync` accepts it directly,
109
- // so no Buffer.from() copy. Atomic temp-file + rename so SIGINT mid-write
110
- // cannot truncate moflo.db see atomic-file-write.ts.
111
- atomicWriteFileSync(dbPath, db.export());
106
+ // node:sqlite + WAL persists each UPDATE incrementally — no whole-file
107
+ // dump at the end. Phase 5 (#1084) deleted the atomicWriteFileSync that
108
+ // used to live here; that pattern was the sql.js clobber vector.
112
109
  options.onMigrationComplete?.(summary.totalItemsMigrated);
113
110
  return true;
114
111
  }
@@ -28,9 +28,8 @@
28
28
  */
29
29
  /* eslint-disable @typescript-eslint/no-explicit-any */
30
30
  import { PURGE_ON_SESSION_START_NAMESPACES, TASKLIST_RETENTION_CAP, } from '../memory/bridge-embedder.js';
31
- import { mofloImport } from './moflo-require.js';
32
- import { atomicWriteFileSync } from './atomic-file-write.js';
33
31
  import { memoryDbPath } from './moflo-paths.js';
32
+ import { openDaemonDatabase } from '../memory/daemon-backend.js';
34
33
  /**
35
34
  * Hard-delete rows in {@link PURGE_ON_SESSION_START_NAMESPACES} and trim the
36
35
  * `tasklist` namespace to its retention cap, then VACUUM. Returns
@@ -45,12 +44,9 @@ export async function purgeEphemeralNamespaces(options = {}) {
45
44
  const dbPath = path.resolve(options.dbPath ?? memoryDbPath(process.cwd()));
46
45
  if (!fs.existsSync(dbPath))
47
46
  return { purged: 0, trimmed: 0 };
48
- const initSqlJs = (await mofloImport('sql.js'))?.default;
49
- if (!initSqlJs)
50
- return { purged: 0, trimmed: 0 };
51
- const SQL = await initSqlJs();
52
- const buffer = fs.readFileSync(dbPath);
53
- const db = new SQL.Database(buffer);
47
+ // node:sqlite via the unified factory (Phase 5 / #1084). WAL persists each
48
+ // DELETE/VACUUM incrementally; no atomicWriteFileSync needed.
49
+ const db = openDaemonDatabase(dbPath);
54
50
  try {
55
51
  // Probe: schema must carry `memory_entries`. Older / non-moflo DBs are
56
52
  // a no-op so we don't VACUUM unrelated SQLite files.
@@ -90,10 +86,9 @@ export async function purgeEphemeralNamespaces(options = {}) {
90
86
  }
91
87
  if (purged === 0 && trimmed === 0)
92
88
  return { purged: 0, trimmed: 0 };
93
- // VACUUM has to run outside any open transaction; sql.js auto-commits
94
- // each `db.run`, so this is safe to chain.
89
+ // VACUUM has to run outside any open transaction; node:sqlite/sql.js
90
+ // both auto-commit each `db.run`, so this is safe to chain.
95
91
  db.run('VACUUM');
96
- atomicWriteFileSync(dbPath, db.export());
97
92
  return { purged, trimmed };
98
93
  }
99
94
  finally {