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.
- package/.claude/guidance/shipped/moflo-agent-rules.md +12 -0
- package/.claude/guidance/shipped/moflo-memory-protocol.md +34 -0
- package/.claude/guidance/shipped/moflo-memorydb-maintenance.md +22 -11
- package/.claude/guidance/shipped/moflo-root-cause-discipline.md +47 -0
- package/.claude/guidance/shipped/moflo-subagents.md +4 -0
- package/.claude/helpers/gate.cjs +3 -3
- package/.claude/helpers/statusline.cjs +69 -33
- package/.claude/helpers/subagent-bootstrap.json +1 -1
- package/.claude/helpers/subagent-start.cjs +1 -1
- package/.claude/skills/eldar/SKILL.md +8 -0
- package/bin/build-embeddings.mjs +6 -20
- package/bin/cli.js +5 -0
- package/bin/gate.cjs +3 -3
- package/bin/generate-code-map.mjs +4 -24
- package/bin/hooks.mjs +3 -12
- package/bin/index-all.mjs +3 -13
- package/bin/index-guidance.mjs +59 -119
- package/bin/index-patterns.mjs +6 -24
- package/bin/index-tests.mjs +4 -23
- package/bin/lib/db-repair.mjs +4 -25
- package/bin/lib/get-backend.mjs +306 -0
- package/bin/lib/incremental-write.mjs +27 -7
- package/bin/lib/moflo-paths.mjs +64 -4
- package/bin/lib/suppress-sqlite-warning.mjs +57 -0
- package/bin/migrations/knowledge-purge.mjs +7 -8
- package/bin/migrations/knowledge-to-learnings.mjs +7 -9
- package/bin/migrations/purge-doc-entries.mjs +52 -0
- package/bin/migrations/strip-context-preambles.mjs +95 -0
- package/bin/run-migrations.mjs +1 -10
- package/bin/semantic-search.mjs +11 -19
- package/bin/session-start-launcher.mjs +102 -100
- package/bin/simplify-classify.cjs +38 -17
- package/dist/src/cli/commands/daemon.js +38 -11
- package/dist/src/cli/commands/doctor-checks-coverage-truth.js +136 -0
- package/dist/src/cli/commands/doctor-checks-memory-access.js +244 -5
- package/dist/src/cli/commands/doctor-checks-memory.js +13 -18
- package/dist/src/cli/commands/doctor-checks-version-skew.js +94 -0
- package/dist/src/cli/commands/doctor-checks-writers-audit.js +170 -0
- package/dist/src/cli/commands/doctor-embedding-hygiene.js +3 -15
- package/dist/src/cli/commands/doctor-fixes.js +30 -0
- package/dist/src/cli/commands/doctor-registry.js +14 -0
- package/dist/src/cli/commands/doctor.js +1 -1
- package/dist/src/cli/commands/embeddings.js +17 -22
- package/dist/src/cli/commands/memory.js +54 -75
- package/dist/src/cli/embeddings/persistent-cache.js +44 -83
- package/dist/src/cli/init/claudemd-generator.js +4 -0
- package/dist/src/cli/init/moflo-init.js +40 -0
- package/dist/src/cli/mcp-tools/memory-tools.js +177 -32
- package/dist/src/cli/memory/bridge-core.js +256 -30
- package/dist/src/cli/memory/bridge-entries.js +76 -8
- package/dist/src/cli/memory/controller-registry.js +7 -2
- package/dist/src/cli/memory/controllers/batch-operations.js +5 -1
- package/dist/src/cli/memory/controllers/hierarchical-memory.js +7 -2
- package/dist/src/cli/memory/controllers/mutation-guard.js +22 -2
- package/dist/src/cli/memory/daemon-backend.js +400 -0
- package/dist/src/cli/memory/daemon-write-client.js +192 -15
- package/dist/src/cli/memory/database-provider.js +57 -40
- package/dist/src/cli/memory/hnsw-persistence.js +6 -8
- package/dist/src/cli/memory/index.js +0 -1
- package/dist/src/cli/memory/memory-bridge.js +40 -8
- package/dist/src/cli/memory/memory-initializer.js +286 -220
- package/dist/src/cli/memory/rvf-migration.js +25 -11
- package/dist/src/cli/memory/sqlite-backend.js +573 -0
- package/dist/src/cli/memory/suppress-sqlite-warning.js +49 -0
- package/dist/src/cli/services/cherry-pick-learnings.js +32 -21
- package/dist/src/cli/services/daemon-dashboard.js +13 -1
- package/dist/src/cli/services/daemon-lock.js +58 -1
- package/dist/src/cli/services/daemon-memory-rpc.js +245 -10
- package/dist/src/cli/services/embeddings-migration.js +9 -12
- package/dist/src/cli/services/ephemeral-namespace-purge.js +6 -11
- package/dist/src/cli/services/learning-service.js +12 -20
- package/dist/src/cli/services/project-root.js +69 -9
- package/dist/src/cli/services/soft-delete-purge.js +6 -11
- package/dist/src/cli/services/sqljs-migration-store.js +4 -1
- package/dist/src/cli/services/subagent-bootstrap.js +1 -1
- package/dist/src/cli/shared/events/event-store.js +26 -55
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -4
- 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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
104
|
-
//
|
|
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(
|
|
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(
|
|
156
|
+
function readAndInsert(sourcePath, targetDb, insertStmt, selectSql, namespaces) {
|
|
146
157
|
let sourceDb;
|
|
147
158
|
try {
|
|
148
|
-
sourceDb =
|
|
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
|
|
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
|
|
5
|
-
* server. The daemon
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
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
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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 {
|