moflo 4.10.7 → 4.10.9
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-cli-reference.md +1 -1
- package/.claude/guidance/shipped/moflo-memory-strategy.md +1 -1
- package/.claude/guidance/shipped/moflo-yaml-reference.md +4 -4
- package/.claude/skills/memory-optimization/SKILL.md +1 -1
- package/.claude/skills/memory-patterns/SKILL.md +3 -3
- package/.claude/skills/vector-search/SKILL.md +2 -2
- package/README.md +5 -5
- package/bin/hooks.mjs +3 -2
- package/bin/index-all.mjs +3 -2
- package/bin/index-guidance.mjs +4 -4
- package/bin/lib/daemon-port.mjs +66 -0
- package/bin/lib/process-manager.mjs +3 -3
- package/dist/src/cli/commands/daemon.js +31 -10
- package/dist/src/cli/commands/doctor-checks-config.js +182 -10
- package/dist/src/cli/commands/doctor-fixes.js +208 -3
- package/dist/src/cli/commands/doctor-registry.js +16 -1
- package/dist/src/cli/commands/memory.js +8 -8
- package/dist/src/cli/commands/neural.js +8 -6
- package/dist/src/cli/config/moflo-config.js +68 -3
- package/dist/src/cli/index.js +18 -19
- package/dist/src/cli/init/moflo-yaml-template.js +1 -1
- package/dist/src/cli/mcp-server.js +59 -10
- package/dist/src/cli/mcp-tools/memory-tools.js +46 -27
- package/dist/src/cli/memory/auto-memory-bridge.js +1 -1
- package/dist/src/cli/memory/controllers/attestation-log.js +1 -1
- package/dist/src/cli/memory/controllers/causal-graph.js +1 -1
- package/dist/src/cli/memory/daemon-write-client.js +178 -49
- package/dist/src/cli/memory/database-provider.js +58 -3
- package/dist/src/cli/memory/intelligence.js +54 -26
- package/dist/src/cli/memory/memory-initializer.js +21 -11
- package/dist/src/cli/movector/model-router.js +1 -1
- package/dist/src/cli/movector/q-learning-router.js +2 -2
- package/dist/src/cli/services/daemon-dashboard.js +94 -25
- package/dist/src/cli/services/daemon-lock.js +390 -3
- package/dist/src/cli/services/daemon-port.js +252 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
- package/dist/src/cli/config-adapter.js +0 -182
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Memory MCP Tools for CLI
|
|
2
|
+
* Memory MCP Tools for CLI — node:sqlite + HNSW backend
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
* - Persistent SQLite storage (WASM)
|
|
8
|
-
* - Backward compatible with legacy JSON storage (auto-migrates)
|
|
4
|
+
* Backed by Node's built-in `node:sqlite` engine (Phase 4 #1083 flipped the
|
|
5
|
+
* default; Phase 5 #1084 deleted the prior sql.js path) plus an HNSW vector
|
|
6
|
+
* index for semantic search. Auto-migrates legacy JSON stores on first use.
|
|
9
7
|
*
|
|
10
8
|
* @module v3/cli/mcp-tools/memory-tools
|
|
11
9
|
*/
|
|
12
10
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
13
11
|
import { join, resolve } from 'path';
|
|
14
12
|
import { GateService } from '../services/spell-gate.js';
|
|
13
|
+
import { BACKEND_LABEL } from '../memory/database-provider.js';
|
|
15
14
|
// Paths
|
|
16
15
|
const MEMORY_DIR = '.moflo/memory';
|
|
17
16
|
const LEGACY_MEMORY_FILE = 'store.json';
|
|
@@ -108,7 +107,7 @@ function shapeRetrievedEntry(entry) {
|
|
|
108
107
|
hasEmbedding: entry.hasEmbedding,
|
|
109
108
|
navigation: parseNavigation(entry.metadata, 'full'),
|
|
110
109
|
found: true,
|
|
111
|
-
backend:
|
|
110
|
+
backend: BACKEND_LABEL,
|
|
112
111
|
};
|
|
113
112
|
}
|
|
114
113
|
function validateMemoryInput(key, value, query) {
|
|
@@ -185,7 +184,7 @@ async function ensureInitialized() {
|
|
|
185
184
|
if (hasLegacyStore()) {
|
|
186
185
|
const legacyStore = loadLegacyStore();
|
|
187
186
|
if (legacyStore && Object.keys(legacyStore.entries).length > 0) {
|
|
188
|
-
console.error('[MCP Memory] Migrating legacy JSON store to
|
|
187
|
+
console.error('[MCP Memory] Migrating legacy JSON store to node:sqlite...');
|
|
189
188
|
let migrated = 0;
|
|
190
189
|
for (const [key, entry] of Object.entries(legacyStore.entries)) {
|
|
191
190
|
try {
|
|
@@ -211,7 +210,7 @@ async function ensureInitialized() {
|
|
|
211
210
|
export const memoryTools = [
|
|
212
211
|
{
|
|
213
212
|
name: 'memory_store',
|
|
214
|
-
description: 'Store a value in memory with vector embedding for semantic search (
|
|
213
|
+
description: 'Store a value in memory with vector embedding for semantic search (node:sqlite + HNSW backend). Upserts by default — pass upsert:false to fail on duplicate keys. Optional `metadata` lets chunk-row producers set the navigation fields (parentDoc, prevChunk, nextChunk, siblings, …) that `memory_get_neighbors` reads.',
|
|
215
214
|
category: 'memory',
|
|
216
215
|
inputSchema: {
|
|
217
216
|
type: 'object',
|
|
@@ -268,7 +267,7 @@ export const memoryTools = [
|
|
|
268
267
|
storedAt: new Date().toISOString(),
|
|
269
268
|
hasEmbedding: !!result.embedding,
|
|
270
269
|
embeddingDimensions: result.embedding?.dimensions || null,
|
|
271
|
-
backend:
|
|
270
|
+
backend: BACKEND_LABEL,
|
|
272
271
|
storeTime: `${duration.toFixed(2)}ms`,
|
|
273
272
|
error: result.error,
|
|
274
273
|
};
|
|
@@ -387,7 +386,7 @@ export const memoryTools = [
|
|
|
387
386
|
query,
|
|
388
387
|
results,
|
|
389
388
|
total: results.length,
|
|
390
|
-
backend:
|
|
389
|
+
backend: BACKEND_LABEL,
|
|
391
390
|
};
|
|
392
391
|
}
|
|
393
392
|
catch (error) {
|
|
@@ -479,7 +478,7 @@ export const memoryTools = [
|
|
|
479
478
|
include,
|
|
480
479
|
neighbors,
|
|
481
480
|
total: neighbors.length,
|
|
482
|
-
backend:
|
|
481
|
+
backend: BACKEND_LABEL,
|
|
483
482
|
};
|
|
484
483
|
}
|
|
485
484
|
catch (error) {
|
|
@@ -524,7 +523,7 @@ export const memoryTools = [
|
|
|
524
523
|
key,
|
|
525
524
|
namespace,
|
|
526
525
|
deleted,
|
|
527
|
-
backend:
|
|
526
|
+
backend: BACKEND_LABEL,
|
|
528
527
|
...(errorReason ? { error: errorReason } : {}),
|
|
529
528
|
};
|
|
530
529
|
}
|
|
@@ -577,7 +576,7 @@ export const memoryTools = [
|
|
|
577
576
|
total: result.total,
|
|
578
577
|
limit,
|
|
579
578
|
offset,
|
|
580
|
-
backend:
|
|
579
|
+
backend: BACKEND_LABEL,
|
|
581
580
|
};
|
|
582
581
|
}
|
|
583
582
|
catch (error) {
|
|
@@ -601,27 +600,47 @@ export const memoryTools = [
|
|
|
601
600
|
},
|
|
602
601
|
handler: async () => {
|
|
603
602
|
await ensureInitialized();
|
|
604
|
-
const { checkMemoryInitialization
|
|
603
|
+
const { checkMemoryInitialization } = await getMemoryFunctions();
|
|
605
604
|
try {
|
|
606
605
|
const status = await checkMemoryInitialization();
|
|
607
|
-
|
|
608
|
-
//
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
606
|
+
// #1149 — server-side aggregation. The pre-fix path iterated every
|
|
607
|
+
// namespace via listEntries({limit:100000}) and tripped the daemon's
|
|
608
|
+
// `limit ≤ 10 000` cap → 400 → tryDaemonList defaulted total to 0 →
|
|
609
|
+
// the MCP tool silently reported zero entries on populated DBs.
|
|
610
|
+
// Route through the dedicated stats endpoint; on routed:false, run
|
|
611
|
+
// the same GROUP BY in-process so users always see real counts; on
|
|
612
|
+
// a daemon error, surface it rather than masking it as zero.
|
|
613
|
+
const { tryDaemonStats } = await import('../memory/daemon-write-client.js');
|
|
614
|
+
const routed = await tryDaemonStats();
|
|
615
|
+
let namespaces;
|
|
616
|
+
let totalEntries;
|
|
617
|
+
let withEmbeddings;
|
|
618
|
+
if (routed.routed && routed.data) {
|
|
619
|
+
({ namespaces, totalEntries, withEmbeddings } = routed.data);
|
|
620
|
+
}
|
|
621
|
+
else if (routed.routed && routed.error) {
|
|
622
|
+
return {
|
|
623
|
+
initialized: status.initialized,
|
|
624
|
+
error: `daemon memory_stats failed: ${routed.error}`,
|
|
625
|
+
backend: BACKEND_LABEL,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
const { getNamespaceCounts } = await import('../memory/memory-initializer.js');
|
|
630
|
+
const direct = await getNamespaceCounts();
|
|
631
|
+
namespaces = direct.namespaces;
|
|
632
|
+
totalEntries = direct.total;
|
|
633
|
+
withEmbeddings = direct.withEmbeddings;
|
|
615
634
|
}
|
|
616
635
|
return {
|
|
617
636
|
initialized: status.initialized,
|
|
618
|
-
totalEntries
|
|
637
|
+
totalEntries,
|
|
619
638
|
entriesWithEmbeddings: withEmbeddings,
|
|
620
|
-
embeddingCoverage:
|
|
621
|
-
? `${((withEmbeddings /
|
|
639
|
+
embeddingCoverage: totalEntries > 0
|
|
640
|
+
? `${((withEmbeddings / totalEntries) * 100).toFixed(1)}%`
|
|
622
641
|
: '0%',
|
|
623
642
|
namespaces,
|
|
624
|
-
backend:
|
|
643
|
+
backend: BACKEND_LABEL,
|
|
625
644
|
version: status.version || '3.0.0',
|
|
626
645
|
features: status.features || {
|
|
627
646
|
vectorEmbeddings: true,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Per ADR-048: Bridges Claude Code's auto memory (markdown files at
|
|
5
5
|
* ~/.claude/projects/<project>/memory/) with claude-flow's unified memory
|
|
6
|
-
* system (MofloDb:
|
|
6
|
+
* system (MofloDb: node:sqlite + HNSW).
|
|
7
7
|
*
|
|
8
8
|
* Auto memory files are human-readable markdown that Claude loads into its
|
|
9
9
|
* system prompt. MEMORY.md (first 200 lines) is the entrypoint; topic files
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* AttestationLog — moflo-owned append-only audit log (epic #464 Phase C2).
|
|
3
3
|
*
|
|
4
4
|
* Replaces `agentdb.AttestationLog`. Writes observability records into a
|
|
5
|
-
*
|
|
5
|
+
* SQLite table with a hash chain so tampering can be detected.
|
|
6
6
|
*
|
|
7
7
|
* Consumer surface (from src/cli/memory/memory-bridge.ts):
|
|
8
8
|
* - record({ operation, entryId, timestamp?, ...metadata })
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* CausalGraph — moflo-owned causal edge store.
|
|
3
3
|
*
|
|
4
4
|
* Replaces `agentdb.CausalGraph.addEdge`. Stores typed edges between
|
|
5
|
-
* memory-entry IDs in a
|
|
5
|
+
* memory-entry IDs in a SQLite table with composite indexes so
|
|
6
6
|
* CausalRecall's BFS walks don't hit a full scan on the relation filter.
|
|
7
7
|
*/
|
|
8
8
|
import { clamp01, clampInt, parseJsonSafe } from './_shared.js';
|
|
@@ -37,11 +37,18 @@
|
|
|
37
37
|
* @module cli/memory/daemon-write-client
|
|
38
38
|
*/
|
|
39
39
|
import * as http from 'node:http';
|
|
40
|
+
import { findProjectRoot } from '../services/project-root.js';
|
|
41
|
+
import { resolveClientPort, LEGACY_DEFAULT_PORT, probeDaemonHealth as probeDaemonHealthIdentity, normalizeProjectRoot, } from '../services/daemon-port.js';
|
|
40
42
|
// ============================================================================
|
|
41
43
|
// Constants
|
|
42
44
|
// ============================================================================
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Read-only legacy default exported for tests; the actual port comes from
|
|
47
|
+
* `getDaemonPort()` which delegates to `resolveClientPort(findProjectRoot())`.
|
|
48
|
+
* Routes through `LEGACY_DEFAULT_PORT` so no literal port number lives in
|
|
49
|
+
* this file — see `daemon-port.ts` and the no-fixed-port regression guard.
|
|
50
|
+
*/
|
|
51
|
+
const DEFAULT_DAEMON_PORT = LEGACY_DEFAULT_PORT;
|
|
45
52
|
/** HTTP timeout for ALL daemon requests (probe + write). Bounds the worst-case CLI hang. */
|
|
46
53
|
const DAEMON_HTTP_TIMEOUT_MS = 100;
|
|
47
54
|
/** Health-probe cache TTL. Probe at most once per 5s in either direction. */
|
|
@@ -55,18 +62,35 @@ let configCache = null;
|
|
|
55
62
|
export function _resetForTest() {
|
|
56
63
|
healthCache = null;
|
|
57
64
|
configCache = null;
|
|
65
|
+
identityCache = null;
|
|
66
|
+
_portCache = null;
|
|
67
|
+
_identityWarnedFor.clear();
|
|
58
68
|
}
|
|
59
69
|
// ============================================================================
|
|
60
70
|
// Resolve daemon port (env override → moflo.yaml unused for v1 → default)
|
|
61
71
|
// ============================================================================
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the daemon HTTP port for this project.
|
|
74
|
+
*
|
|
75
|
+
* Delegates to `resolveClientPort(findProjectRoot())`:
|
|
76
|
+
* 1. `MOFLO_DAEMON_PORT` env override (consumer pin)
|
|
77
|
+
* 2. `port` field in `<projectRoot>/.moflo/daemon.lock` (server records
|
|
78
|
+
* the actual bound port after startup — #1145)
|
|
79
|
+
* 3. Deterministic per-project port `33000 + sha256(path)%1000`
|
|
80
|
+
*
|
|
81
|
+
* Cached per-process — the lock-file path doesn't change once a process is
|
|
82
|
+
* up. On a routed-failure the health cache is invalidated (which triggers
|
|
83
|
+
* the next port resolve), keeping the client honest about daemon location
|
|
84
|
+
* after a recycle.
|
|
85
|
+
*/
|
|
86
|
+
let _portCache = null;
|
|
62
87
|
function getDaemonPort() {
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return DEFAULT_DAEMON_PORT;
|
|
88
|
+
const projectRoot = findProjectRoot();
|
|
89
|
+
if (_portCache && _portCache.projectRoot === projectRoot)
|
|
90
|
+
return _portCache.port;
|
|
91
|
+
const port = resolveClientPort(projectRoot);
|
|
92
|
+
_portCache = { port, projectRoot };
|
|
93
|
+
return port;
|
|
70
94
|
}
|
|
71
95
|
// ============================================================================
|
|
72
96
|
// Daemon-disabled check (cached) — reads `daemon.auto_start` from moflo.yaml
|
|
@@ -90,12 +114,18 @@ async function isDaemonEnabledInConfig() {
|
|
|
90
114
|
configCache = { daemonEnabled: enabled, checkedAt: now };
|
|
91
115
|
return enabled;
|
|
92
116
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
117
|
+
let identityCache = null;
|
|
118
|
+
/**
|
|
119
|
+
* Ports we've already warned about during this process — bounded by the
|
|
120
|
+
* number of distinct daemon ports a single client process can see in its
|
|
121
|
+
* lifetime (usually 1). Keeps the stderr noise to a single line per
|
|
122
|
+
* mismatched daemon per process.
|
|
123
|
+
*/
|
|
124
|
+
const _identityWarnedFor = new Set();
|
|
96
125
|
/**
|
|
97
126
|
* Cached daemon health probe. Returns true iff the daemon's HTTP server
|
|
98
|
-
* is reachable on `127.0.0.1:<port>` within {@link DAEMON_HTTP_TIMEOUT_MS}
|
|
127
|
+
* is reachable on `127.0.0.1:<port>` within {@link DAEMON_HTTP_TIMEOUT_MS}
|
|
128
|
+
* AND its `/api/health` reports a `projectRoot` matching ours (#1145).
|
|
99
129
|
*
|
|
100
130
|
* Cache survives 5s in either direction — so a daemon that just came up
|
|
101
131
|
* is missed for ≤5s, and a daemon that just died is incorrectly assumed
|
|
@@ -113,9 +143,74 @@ export async function isDaemonAvailable() {
|
|
|
113
143
|
if (healthCache && (now - healthCache.checkedAt) < HEALTH_CACHE_TTL_MS) {
|
|
114
144
|
return healthCache.available;
|
|
115
145
|
}
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
146
|
+
const port = getDaemonPort();
|
|
147
|
+
const reachable = await probeDaemonHealth(port);
|
|
148
|
+
if (!reachable) {
|
|
149
|
+
healthCache = { available: false, checkedAt: now };
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
// 4) Identity check — daemon reachable but is it OUR daemon?
|
|
153
|
+
const identityOk = await isDaemonIdentityMatch(port);
|
|
154
|
+
healthCache = { available: identityOk, checkedAt: now };
|
|
155
|
+
return identityOk;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Probe `/api/health` and confirm the daemon's reported `projectRoot`
|
|
159
|
+
* matches ours. Caches the result for {@link HEALTH_CACHE_TTL_MS}.
|
|
160
|
+
*
|
|
161
|
+
* Mismatch consequence: this function returns `false`, the caller falls
|
|
162
|
+
* through to the direct-SQL path (the path that is provably correct, see
|
|
163
|
+
* the `MOFLO_DISABLE_DAEMON_ROUTING=1` reproducer in
|
|
164
|
+
* `docs/internal/1145-daemon-port-collision-analysis.md`), and we emit
|
|
165
|
+
* ONE stderr line per port per process so the user can see the wrong-
|
|
166
|
+
* project daemon is the problem.
|
|
167
|
+
*
|
|
168
|
+
* Tolerant of legacy daemons that don't expose `/api/health`: a 404 means
|
|
169
|
+
* the daemon predates #1145, so we trust the legacy port resolution (the
|
|
170
|
+
* client is presumably hitting the same project's daemon anyway) and
|
|
171
|
+
* return `true`. The lock-file port-discovery path is the primary
|
|
172
|
+
* collision defence; identity check is the safety net.
|
|
173
|
+
*/
|
|
174
|
+
async function isDaemonIdentityMatch(port) {
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
const ourProjectRoot = findProjectRoot();
|
|
177
|
+
if (identityCache &&
|
|
178
|
+
identityCache.ourProjectRoot === ourProjectRoot &&
|
|
179
|
+
(now - identityCache.checkedAt) < HEALTH_CACHE_TTL_MS) {
|
|
180
|
+
return identityCache.matches;
|
|
181
|
+
}
|
|
182
|
+
const probe = await probeDaemonHealthIdentity(port, DAEMON_HTTP_TIMEOUT_MS);
|
|
183
|
+
if (probe.kind === 'legacy' || probe.kind === 'unreachable') {
|
|
184
|
+
// No identity to compare — daemon either predates #1145 or the probe
|
|
185
|
+
// itself failed transport-side. Fall open: rely on port-discovery
|
|
186
|
+
// (lock file + deterministic hash) as the primary defence. Only a
|
|
187
|
+
// CONFIRMED mismatch blocks routing — that's the conservative safety
|
|
188
|
+
// net that doesn't break upgraded-client-against-legacy-daemon.
|
|
189
|
+
//
|
|
190
|
+
// Asymmetry with doctor's `checkDaemonIdentity`: the healer probes
|
|
191
|
+
// LEGACY_DEFAULT_PORT explicitly and flags a foreign legacy daemon
|
|
192
|
+
// as `fail`, while this hot path lets it through. That's intentional
|
|
193
|
+
// — the doctor runs on-demand for diagnostics, and live writes must
|
|
194
|
+
// not block when the cluster is mid-upgrade. The CHANGELOG migration
|
|
195
|
+
// window is the agreed remediation surface.
|
|
196
|
+
identityCache = { matches: true, checkedAt: now, ourProjectRoot };
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
const matches = normalizeProjectRoot(probe.projectRoot) === normalizeProjectRoot(ourProjectRoot);
|
|
200
|
+
identityCache = {
|
|
201
|
+
matches,
|
|
202
|
+
checkedAt: now,
|
|
203
|
+
ourProjectRoot,
|
|
204
|
+
daemonProjectRoot: probe.projectRoot,
|
|
205
|
+
};
|
|
206
|
+
if (!matches && !_identityWarnedFor.has(port)) {
|
|
207
|
+
_identityWarnedFor.add(port);
|
|
208
|
+
// One stderr line per mismatched daemon, ever. Quiet enough that scripts
|
|
209
|
+
// don't drown but loud enough that healer-class diagnostics surface it.
|
|
210
|
+
process.stderr.write(`[moflo] daemon at 127.0.0.1:${port} claims project '${probe.projectRoot}' but cwd is '${ourProjectRoot}' — ` +
|
|
211
|
+
`using direct DB. Run flo healer --fix to repair daemon binding (#1145).\n`);
|
|
212
|
+
}
|
|
213
|
+
return matches;
|
|
119
214
|
}
|
|
120
215
|
function probeDaemonHealth(port) {
|
|
121
216
|
return new Promise((resolve) => {
|
|
@@ -212,6 +307,31 @@ export async function tryDaemonList(opts) {
|
|
|
212
307
|
total: typeof data?.total === 'number' ? data.total : 0,
|
|
213
308
|
}));
|
|
214
309
|
}
|
|
310
|
+
/**
|
|
311
|
+
* Route a memory-stats query through the daemon (#1149). Single GROUP BY
|
|
312
|
+
* query server-side — replaces the list-and-iterate path in the MCP
|
|
313
|
+
* `memory_stats` handler that fetched up to 100 000 rows just to count
|
|
314
|
+
* them and tripped the daemon's `limit ≤ 10 000` cap.
|
|
315
|
+
*
|
|
316
|
+
* Returns `{ routed: false }` when the daemon is unavailable or any 5xx /
|
|
317
|
+
* transport fault fires — caller falls back to a direct
|
|
318
|
+
* `getNamespaceCounts()` so users never see a fake zero.
|
|
319
|
+
*/
|
|
320
|
+
export async function tryDaemonStats() {
|
|
321
|
+
if (!(await isDaemonAvailable()))
|
|
322
|
+
return { routed: false };
|
|
323
|
+
return requestReadJson('GET', '/api/memory/stats', undefined, (data) => {
|
|
324
|
+
if (typeof data?.totalEntries !== 'number')
|
|
325
|
+
return null;
|
|
326
|
+
return {
|
|
327
|
+
namespaces: (data?.namespaces && typeof data.namespaces === 'object')
|
|
328
|
+
? data.namespaces
|
|
329
|
+
: {},
|
|
330
|
+
totalEntries: data.totalEntries,
|
|
331
|
+
withEmbeddings: typeof data?.withEmbeddings === 'number' ? data.withEmbeddings : 0,
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
}
|
|
215
335
|
// ============================================================================
|
|
216
336
|
// Internal HTTP poster — never throws, bounded timeout
|
|
217
337
|
// ============================================================================
|
|
@@ -261,8 +381,14 @@ function postJson(path, body) {
|
|
|
261
381
|
// On routed-failure, invalidate the health cache so the next call
|
|
262
382
|
// re-probes and trips back to direct-write quickly when the daemon
|
|
263
383
|
// is dying.
|
|
264
|
-
if (result.routed === false)
|
|
384
|
+
if (result.routed === false) {
|
|
385
|
+
// Daemon recycled to a different port (post #1145 server restart)
|
|
386
|
+
// → invalidate the port cache too so the next call re-reads
|
|
387
|
+
// .moflo/daemon.lock. Otherwise we'd keep hammering a stale port.
|
|
265
388
|
healthCache = null;
|
|
389
|
+
identityCache = null;
|
|
390
|
+
_portCache = null;
|
|
391
|
+
}
|
|
266
392
|
resolve(result);
|
|
267
393
|
};
|
|
268
394
|
const payload = JSON.stringify(body);
|
|
@@ -322,45 +448,49 @@ function postJson(path, body) {
|
|
|
322
448
|
});
|
|
323
449
|
}
|
|
324
450
|
/**
|
|
325
|
-
* Generic JSON
|
|
326
|
-
*
|
|
327
|
-
*
|
|
451
|
+
* Generic JSON read-request that returns a daemon-read envelope. Never
|
|
452
|
+
* throws, bounded by `DAEMON_HTTP_TIMEOUT_MS`, invalidates the health +
|
|
453
|
+
* identity + port caches on any routed-failure (daemon-recycle covers).
|
|
328
454
|
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
455
|
+
* Failure-shape contract (#1101):
|
|
456
|
+
* 2xx → routed:true with shape(data)
|
|
457
|
+
* 4xx → routed:true with error (caller propagates)
|
|
458
|
+
* 5xx / transport / parse-fail → routed:false (caller falls back)
|
|
459
|
+
*
|
|
460
|
+
* `body === undefined` ⇒ GET (no Content-* headers); otherwise POST with
|
|
461
|
+
* JSON body. The `shape` callback maps parsed JSON to the typed payload;
|
|
462
|
+
* returning `null` downgrades the response to routed:false so the caller
|
|
463
|
+
* falls back to bridge-direct.
|
|
332
464
|
*/
|
|
333
|
-
function
|
|
465
|
+
function requestReadJson(method, path, body,
|
|
466
|
+
// `data` is a JSON.parse boundary — typed `any` here mirrors JSON.parse's
|
|
467
|
+
// own return type so callers can do safe optional-chaining narrowing.
|
|
468
|
+
shape) {
|
|
334
469
|
return new Promise((resolve) => {
|
|
335
470
|
let done = false;
|
|
336
471
|
const finish = (result) => {
|
|
337
472
|
if (done)
|
|
338
473
|
return;
|
|
339
474
|
done = true;
|
|
340
|
-
if (result.routed === false)
|
|
475
|
+
if (result.routed === false) {
|
|
476
|
+
// Daemon may have recycled to a new port (post-#1145 restart) →
|
|
477
|
+
// invalidate the lock-file-port cache and the identity probe so
|
|
478
|
+
// the next call re-discovers reality.
|
|
341
479
|
healthCache = null;
|
|
480
|
+
identityCache = null;
|
|
481
|
+
_portCache = null;
|
|
482
|
+
}
|
|
342
483
|
resolve(result);
|
|
343
484
|
};
|
|
344
|
-
const payload = JSON.stringify(body);
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
method: 'POST',
|
|
350
|
-
timeout: DAEMON_HTTP_TIMEOUT_MS,
|
|
351
|
-
headers: {
|
|
352
|
-
'Content-Type': 'application/json',
|
|
353
|
-
'Content-Length': Buffer.byteLength(payload),
|
|
354
|
-
},
|
|
355
|
-
}, (res) => {
|
|
485
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
486
|
+
const headers = payload === undefined
|
|
487
|
+
? undefined
|
|
488
|
+
: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) };
|
|
489
|
+
const req = http.request({ host: '127.0.0.1', port: getDaemonPort(), path, method, timeout: DAEMON_HTTP_TIMEOUT_MS, headers }, (res) => {
|
|
356
490
|
let buf = '';
|
|
357
491
|
res.setEncoding('utf8');
|
|
358
492
|
res.on('data', (chunk) => { buf += chunk; });
|
|
359
493
|
res.on('end', () => {
|
|
360
|
-
// #1101 — mirror postJson contract for reads:
|
|
361
|
-
// 2xx → routed:true with shaped data
|
|
362
|
-
// 4xx → routed:true with error (no data) — caller propagates
|
|
363
|
-
// 5xx → routed:false (caller falls back)
|
|
364
494
|
const status = res.statusCode ?? 0;
|
|
365
495
|
if (status >= 500 || status < 200) {
|
|
366
496
|
finish({ routed: false });
|
|
@@ -371,13 +501,8 @@ function postReadJson(path, body, shape) {
|
|
|
371
501
|
return;
|
|
372
502
|
}
|
|
373
503
|
try {
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
if (shaped === null) {
|
|
377
|
-
finish({ routed: false });
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
finish({ routed: true, data: shaped });
|
|
504
|
+
const shaped = shape(JSON.parse(buf));
|
|
505
|
+
finish(shaped === null ? { routed: false } : { routed: true, data: shaped });
|
|
381
506
|
}
|
|
382
507
|
catch {
|
|
383
508
|
finish({ routed: false });
|
|
@@ -387,8 +512,12 @@ function postReadJson(path, body, shape) {
|
|
|
387
512
|
});
|
|
388
513
|
req.on('error', () => finish({ routed: false }));
|
|
389
514
|
req.on('timeout', () => { req.destroy(); finish({ routed: false }); });
|
|
390
|
-
|
|
515
|
+
if (payload !== undefined)
|
|
516
|
+
req.write(payload);
|
|
391
517
|
req.end();
|
|
392
518
|
});
|
|
393
519
|
}
|
|
520
|
+
function postReadJson(path, body, shape) {
|
|
521
|
+
return requestReadJson('POST', path, body, shape);
|
|
522
|
+
}
|
|
394
523
|
//# sourceMappingURL=daemon-write-client.js.map
|
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
import { platform } from 'node:os';
|
|
12
12
|
import { existsSync } from 'node:fs';
|
|
13
13
|
import { SqliteBackend } from './sqlite-backend.js';
|
|
14
|
+
/**
|
|
15
|
+
* Canonical label returned in MCP `backend` fields and other consumer-visible
|
|
16
|
+
* surfaces. Single source of truth so a future engine swap is a one-line edit
|
|
17
|
+
* instead of an 8-site grep. Phase 5 (#1084) finalized node:sqlite as the
|
|
18
|
+
* only SQLite backend; the HNSW vector index sits on top.
|
|
19
|
+
*/
|
|
20
|
+
export const BACKEND_LABEL = 'node:sqlite + HNSW';
|
|
14
21
|
/**
|
|
15
22
|
* Detect platform and recommend provider
|
|
16
23
|
*/
|
|
@@ -118,9 +125,15 @@ async function selectProvider(preferred, verbose = false) {
|
|
|
118
125
|
* ```
|
|
119
126
|
*/
|
|
120
127
|
export async function createDatabase(path, options = {}) {
|
|
121
|
-
const { provider
|
|
122
|
-
//
|
|
123
|
-
|
|
128
|
+
const { provider, verbose = false, walMode: _walMode = true, optimize = true, defaultNamespace = 'default', maxEntries = 1000000, autoPersistInterval = 5000, wasmPath: _wasmPath, } = options;
|
|
129
|
+
// When no explicit provider is given, consult moflo.yaml's
|
|
130
|
+
// `memory.backend` knob (#1144). This is what makes the YAML value
|
|
131
|
+
// truthful instead of cosmetic — the runtime now actually honours
|
|
132
|
+
// whatever the consumer put in their config. Falls back to `'auto'` if
|
|
133
|
+
// the config can't be loaded (e.g. running from a directory with no
|
|
134
|
+
// `moflo.yaml`), preserving the previous behaviour for raw callers.
|
|
135
|
+
const effectiveProvider = provider ?? (await preferredProviderFromConfig(verbose)) ?? 'auto';
|
|
136
|
+
const selectedProvider = await selectProvider(effectiveProvider, verbose);
|
|
124
137
|
if (verbose) {
|
|
125
138
|
console.log(`[DatabaseProvider] Creating database with provider: ${selectedProvider}`);
|
|
126
139
|
console.log(`[DatabaseProvider] Database path: ${path}`);
|
|
@@ -178,6 +191,48 @@ export async function createDatabase(path, options = {}) {
|
|
|
178
191
|
export function getPlatformInfo() {
|
|
179
192
|
return detectPlatform();
|
|
180
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Read `memory.backend` from the project's `moflo.yaml`, resolve any
|
|
196
|
+
* deprecated aliases (sql.js → node-sqlite), and return a value
|
|
197
|
+
* `selectProvider()` understands. Returns `null` on any failure so
|
|
198
|
+
* `createDatabase()` cleanly falls back to platform auto-detection
|
|
199
|
+
* — config loading must never break the runtime.
|
|
200
|
+
*
|
|
201
|
+
* Wrapped in a dynamic import so the memory subtree doesn't pull
|
|
202
|
+
* `js-yaml` / `fs` into hot paths (e.g. the in-memory test backend).
|
|
203
|
+
*
|
|
204
|
+
* Memoised per (cwd, process) — a test suite or daemon that opens many
|
|
205
|
+
* DBs in sequence parses moflo.yaml once. Keyed on cwd so a test that
|
|
206
|
+
* `chdir`s into a temp dir gets a fresh resolution.
|
|
207
|
+
*/
|
|
208
|
+
const _resolvedProviderCache = new Map();
|
|
209
|
+
async function preferredProviderFromConfig(verbose) {
|
|
210
|
+
const key = process.cwd();
|
|
211
|
+
if (_resolvedProviderCache.has(key)) {
|
|
212
|
+
return _resolvedProviderCache.get(key) ?? null;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const { loadMofloConfig, resolveDatabaseProvider } = await import('../config/moflo-config.js');
|
|
216
|
+
const cfg = loadMofloConfig();
|
|
217
|
+
const resolved = resolveDatabaseProvider(cfg.memory.backend);
|
|
218
|
+
if (verbose) {
|
|
219
|
+
console.log(`[DatabaseProvider] moflo.yaml memory.backend="${cfg.memory.backend}" → ${resolved}`);
|
|
220
|
+
}
|
|
221
|
+
_resolvedProviderCache.set(key, resolved);
|
|
222
|
+
return resolved;
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
if (verbose) {
|
|
226
|
+
console.warn(`[DatabaseProvider] Could not load moflo.yaml backend preference (${err.message}) — falling back to auto-detection`);
|
|
227
|
+
}
|
|
228
|
+
_resolvedProviderCache.set(key, null);
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** @internal — test hook only; resets the per-cwd cache between cases. */
|
|
233
|
+
export function _resetPreferredProviderCache() {
|
|
234
|
+
_resolvedProviderCache.clear();
|
|
235
|
+
}
|
|
181
236
|
/**
|
|
182
237
|
* Check which providers are available.
|
|
183
238
|
*
|