@claude-flow/cli 3.42.0 → 3.42.1
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/helpers/helpers.manifest.json +2 -2
- package/catalog-manifest.json +2 -2
- package/dist/src/commands/doctor.d.ts +7 -0
- package/dist/src/commands/doctor.js +58 -35
- package/dist/src/commands/swarm.js +89 -18
- package/dist/src/mcp-tools/agentbbs-tools.d.ts +15 -0
- package/dist/src/mcp-tools/agentbbs-tools.js +98 -14
- package/dist/src/mcp-tools/memory-tools.js +102 -31
- package/dist/src/mcp-tools/seraphina-tools.js +7 -2
- package/dist/src/mcp-tools/x-federation-tools.d.ts +12 -0
- package/dist/src/mcp-tools/x-federation-tools.js +87 -5
- package/dist/src/memory/memory-initializer.js +19 -2
- package/dist/src/ruvector/graph-backend.js +112 -26
- package/node_modules/@claude-flow/codex/dist/cli.js +0 -0
- package/node_modules/@claude-flow/plugin-agent-federation/dist/bin.js +0 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest": {
|
|
3
|
-
"version": "3.42.
|
|
3
|
+
"version": "3.42.1",
|
|
4
4
|
"files": {
|
|
5
5
|
"auto-memory-hook.mjs": "85fe05c757421c52137c0bc8545a0896bab6b4714538c2a11d1d0835bfcc8c1c",
|
|
6
6
|
"hook-handler.cjs": "209d9fafe10e17d1be0866727f6f9cf9ac66f9a0793f1c793a4f58319e8e4583",
|
|
@@ -8,6 +8,6 @@
|
|
|
8
8
|
"statusline.cjs": "4a48353b4f1566fa4379b00fd0321b6676a22b6cc91fbbd8380ac5183d619468"
|
|
9
9
|
}
|
|
10
10
|
},
|
|
11
|
-
"signature": "
|
|
11
|
+
"signature": "dp+TQNrg1n2wi3wjdGNFDBLjRnKSN7vuEvm4z8IC6Vmzlfh4DaTHX4sozhmxAt5GeVcLxjEq6urtK8s0vUADCQ==",
|
|
12
12
|
"algorithm": "ed25519"
|
|
13
13
|
}
|
package/catalog-manifest.json
CHANGED
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
* Created with ruv.io
|
|
6
6
|
*/
|
|
7
7
|
import type { Command } from '../types.js';
|
|
8
|
+
interface HealthCheck {
|
|
9
|
+
name: string;
|
|
10
|
+
status: 'pass' | 'warn' | 'fail';
|
|
11
|
+
message: string;
|
|
12
|
+
fix?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function checkMemoryPersistenceDriver(): Promise<HealthCheck>;
|
|
8
15
|
export declare const doctorCommand: Command;
|
|
9
16
|
export default doctorCommand;
|
|
10
17
|
//# sourceMappingURL=doctor.d.ts.map
|
|
@@ -506,23 +506,13 @@ async function checkMemoryStructuralIntegrity() {
|
|
|
506
506
|
catch { /* best-effort */ }
|
|
507
507
|
}
|
|
508
508
|
}
|
|
509
|
-
// #2968
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
// sql.js
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
|
|
516
|
-
// check gives a standing, read-only signal so the driver split is visible
|
|
517
|
-
// any time doctor runs, independent of any single store call.
|
|
518
|
-
//
|
|
519
|
-
// Table count in the on-disk memory.db is the cheap, reliable signal from
|
|
520
|
-
// the issue report: the native driver's schema produces 47 tables, the
|
|
521
|
-
// sql.js fallback's produces only 10. Deliberately does NOT change install
|
|
522
|
-
// behavior (that's option 2 from #2968, explicitly out of scope here) —
|
|
523
|
-
// this only reports, it never repairs.
|
|
524
|
-
const MEMORY_DRIVER_NATIVE_TABLE_FLOOR = 20; // roughly midpoint of sql.js's ~10 and native's ~47
|
|
525
|
-
async function checkMemoryPersistenceDriver() {
|
|
509
|
+
// #2968/#3321 — read-only native SQLite capability probe. A skipped
|
|
510
|
+
// postinstall can leave the wrapper importable but its binding unavailable.
|
|
511
|
+
// Schema size cannot identify the runtime driver or the database's history:
|
|
512
|
+
// memory init creates its schema with sql.js even when native is available.
|
|
513
|
+
// This probe does not verify schema compatibility or cross-process writes;
|
|
514
|
+
// integrity checks and memory store's persistWarning retain their own roles.
|
|
515
|
+
export async function checkMemoryPersistenceDriver() {
|
|
526
516
|
const NAME = 'Memory Persistence Driver';
|
|
527
517
|
const dbPath = await resolveMemoryDbPath();
|
|
528
518
|
if (!dbPath) {
|
|
@@ -549,6 +539,7 @@ async function checkMemoryPersistenceDriver() {
|
|
|
549
539
|
let tableCount = null;
|
|
550
540
|
let nativeUnavailableReason = null;
|
|
551
541
|
let nativeOpenOtherError = null;
|
|
542
|
+
let nativeQueryError = null;
|
|
552
543
|
if (Database) {
|
|
553
544
|
let db;
|
|
554
545
|
try {
|
|
@@ -566,10 +557,15 @@ async function checkMemoryPersistenceDriver() {
|
|
|
566
557
|
if (db) {
|
|
567
558
|
try {
|
|
568
559
|
const row = db.prepare("SELECT count(*) AS c FROM sqlite_master WHERE type='table'").get();
|
|
569
|
-
|
|
560
|
+
if (typeof row?.c === 'number' && Number.isSafeInteger(row.c) && row.c >= 0) {
|
|
561
|
+
tableCount = row.c;
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
nativeQueryError = 'query returned no reliable table count';
|
|
565
|
+
}
|
|
570
566
|
}
|
|
571
|
-
catch {
|
|
572
|
-
|
|
567
|
+
catch (e) {
|
|
568
|
+
nativeQueryError = e.message || String(e);
|
|
573
569
|
}
|
|
574
570
|
finally {
|
|
575
571
|
try {
|
|
@@ -589,7 +585,10 @@ async function checkMemoryPersistenceDriver() {
|
|
|
589
585
|
if (sdb) {
|
|
590
586
|
try {
|
|
591
587
|
const res = sdb.exec("SELECT count(*) FROM sqlite_master WHERE type='table'");
|
|
592
|
-
|
|
588
|
+
const count = res[0]?.values?.[0]?.[0];
|
|
589
|
+
if (typeof count === 'number' && Number.isSafeInteger(count) && count >= 0) {
|
|
590
|
+
tableCount = count;
|
|
591
|
+
}
|
|
593
592
|
}
|
|
594
593
|
catch {
|
|
595
594
|
// leave null
|
|
@@ -604,12 +603,12 @@ async function checkMemoryPersistenceDriver() {
|
|
|
604
603
|
}
|
|
605
604
|
const tableSummary = tableCount === null
|
|
606
605
|
? 'table count unavailable'
|
|
607
|
-
: `${tableCount} tables
|
|
606
|
+
: `${tableCount} tables`;
|
|
608
607
|
if (nativeUnavailableReason) {
|
|
609
608
|
return {
|
|
610
609
|
name: NAME,
|
|
611
610
|
status: 'warn',
|
|
612
|
-
message: `${dbPath} —
|
|
611
|
+
message: `${dbPath} — native better-sqlite3 binding unavailable: ${nativeUnavailableReason} — native read-only probe unavailable; runtime driver and write persistence not verified (#2968) [${tableSummary}; sql.js main-image-only fallback]`,
|
|
613
612
|
fix: 'reinstall with npm install scripts enabled, or run `npm rebuild better-sqlite3`; then rerun this check',
|
|
614
613
|
};
|
|
615
614
|
}
|
|
@@ -620,18 +619,17 @@ async function checkMemoryPersistenceDriver() {
|
|
|
620
619
|
message: `${dbPath} — native better-sqlite3 module loadable, but could not open this database (${nativeOpenOtherError}) — see Memory Integrity above for the corruption/encryption diagnosis [${tableSummary}]`,
|
|
621
620
|
};
|
|
622
621
|
}
|
|
623
|
-
if (
|
|
622
|
+
if (nativeQueryError || tableCount === null) {
|
|
624
623
|
return {
|
|
625
624
|
name: NAME,
|
|
626
625
|
status: 'warn',
|
|
627
|
-
message: `${dbPath} —
|
|
628
|
-
fix: 'back up .swarm/memory.db then `claude-flow memory init --force` to rebuild under the native driver',
|
|
626
|
+
message: `${dbPath} — native better-sqlite3 opened read-only, but table count unavailable: ${nativeQueryError ?? 'query returned no reliable result'}; runtime driver and write persistence not verified`,
|
|
629
627
|
};
|
|
630
628
|
}
|
|
631
629
|
return {
|
|
632
630
|
name: NAME,
|
|
633
631
|
status: 'pass',
|
|
634
|
-
message: `${dbPath} —
|
|
632
|
+
message: `${dbPath} — native better-sqlite3 read-only open and schema query succeeded [${tableSummary}]; runtime driver, schema compatibility, and write persistence not verified`,
|
|
635
633
|
};
|
|
636
634
|
}
|
|
637
635
|
// Check 1 (--component memory, deep path) — #2737 part 2 strengthens this:
|
|
@@ -2011,20 +2009,45 @@ async function checkMetaharnessDeclaredPackages() {
|
|
|
2011
2009
|
}
|
|
2012
2010
|
async function checkMetaharness() {
|
|
2013
2011
|
try {
|
|
2014
|
-
|
|
2015
|
-
//
|
|
2016
|
-
|
|
2017
|
-
|
|
2012
|
+
// `metaharness` has no --version flag (it falls through to the usage
|
|
2013
|
+
// banner, which never matches a version regex) and shelling out via
|
|
2014
|
+
// `npx metaharness@latest` ignores the installed version and hits the
|
|
2015
|
+
// network every run. Resolve the version from the installed package's
|
|
2016
|
+
// own package.json instead. `import.meta.resolve` is the reliable route:
|
|
2017
|
+
// `require('metaharness/package.json')` is blocked by the package's
|
|
2018
|
+
// `exports` map, and `createRequire().resolve()` fails on its ESM-only
|
|
2019
|
+
// entry point.
|
|
2020
|
+
const resolved = import.meta.resolve('metaharness');
|
|
2021
|
+
let dir = dirname(fileURLToPath(resolved));
|
|
2022
|
+
let version = null;
|
|
2023
|
+
for (let i = 0; i < 8; i++) {
|
|
2024
|
+
const pj = join(dir, 'package.json');
|
|
2025
|
+
if (existsSync(pj)) {
|
|
2026
|
+
try {
|
|
2027
|
+
const j = JSON.parse(readFileSync(pj, 'utf-8'));
|
|
2028
|
+
if (j.name === 'metaharness') {
|
|
2029
|
+
version = j.version ?? null;
|
|
2030
|
+
break;
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
catch { /* keep walking */ }
|
|
2034
|
+
}
|
|
2035
|
+
const parent = dirname(dir);
|
|
2036
|
+
if (parent === dir)
|
|
2037
|
+
break;
|
|
2038
|
+
dir = parent;
|
|
2039
|
+
}
|
|
2040
|
+
if (!version) {
|
|
2018
2041
|
return {
|
|
2019
2042
|
name: 'MetaHarness (ADR-150)',
|
|
2020
2043
|
status: 'warn',
|
|
2021
|
-
message: 'Installed but
|
|
2044
|
+
message: 'Installed but its package.json was not found while walking up from the resolved module; integration may still work',
|
|
2022
2045
|
};
|
|
2023
2046
|
}
|
|
2024
2047
|
return {
|
|
2025
2048
|
name: 'MetaHarness (ADR-150)',
|
|
2026
2049
|
status: 'pass',
|
|
2027
|
-
message: `v${
|
|
2050
|
+
message: `v${version} — run \`npx ruflo metaharness score\` for the full scorecard`,
|
|
2028
2051
|
};
|
|
2029
2052
|
}
|
|
2030
2053
|
catch {
|
|
@@ -2348,7 +2371,7 @@ export const doctorCommand = {
|
|
|
2348
2371
|
checkDaemonStatus,
|
|
2349
2372
|
checkMemoryDatabase,
|
|
2350
2373
|
checkMemoryStructuralIntegrity, // #2737 — bounded, native quick_check on every default run
|
|
2351
|
-
checkMemoryPersistenceDriver, // #2968 —
|
|
2374
|
+
checkMemoryPersistenceDriver, // #2968/#3321 — read-only native capability probe
|
|
2352
2375
|
checkLearningBridge, // #2545 — can the auto-memory hook actually load @claude-flow/memory?
|
|
2353
2376
|
checkApiKeys,
|
|
2354
2377
|
checkMcpServers,
|
|
@@ -2386,7 +2409,7 @@ export const doctorCommand = {
|
|
|
2386
2409
|
'memory': [
|
|
2387
2410
|
checkMemoryDatabase, // existing: exists + statable (unchanged)
|
|
2388
2411
|
checkMemoryIntegrity, // #2677 check 1: sql.js open + PRAGMA integrity_check
|
|
2389
|
-
checkMemoryPersistenceDriver, // #2968:
|
|
2412
|
+
checkMemoryPersistenceDriver, // #2968/#3321: read-only native capability probe
|
|
2390
2413
|
checkMemoryContent, // #2677 check 2: memory_entries content coverage
|
|
2391
2414
|
checkMemoryEmbeddingCoverage, // #2677 check 3: vector coverage on populated rows
|
|
2392
2415
|
checkMemoryReflexionCoverage, // #2677 check 6: episodes are retrievable
|
|
@@ -8,6 +8,56 @@ import { callMCPTool, MCPClientError } from '../mcp-client.js';
|
|
|
8
8
|
import { swarmJoinCommand } from './agntcy/swarm-join.js';
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import * as path from 'path';
|
|
11
|
+
// Read the CLI-side swarm state file (`.swarm/state.json`), written by
|
|
12
|
+
// `swarm init` and rewritten by `swarm start` / `swarm stop`.
|
|
13
|
+
function readLocalSwarmState() {
|
|
14
|
+
const swarmStateFile = path.join(process.cwd(), '.swarm', 'state.json');
|
|
15
|
+
if (!fs.existsSync(swarmStateFile))
|
|
16
|
+
return null;
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fs.readFileSync(swarmStateFile, 'utf-8'));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Ignore parse errors
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// Resolve the id of the current swarm. Three writers persist it, under two
|
|
26
|
+
// different keys and in two different files:
|
|
27
|
+
//
|
|
28
|
+
// `.swarm/state.json` `id` ← `swarm init`
|
|
29
|
+
// `.swarm/state.json` `swarmId` ← `swarm start`
|
|
30
|
+
// `.claude-flow/swarm/swarm-state.json` `swarms[id].swarmId` ← MCP `swarm_init`
|
|
31
|
+
//
|
|
32
|
+
// The status payload used to read only the first key of the first file, so a
|
|
33
|
+
// Claude Code session (which drives the MCP path) reported an active swarm
|
|
34
|
+
// with agents but no usable id. Check every writer, most local first.
|
|
35
|
+
function resolveSwarmId(swarmState) {
|
|
36
|
+
// A state file left behind by a previous `swarm stop` must not shadow a
|
|
37
|
+
// swarm that is still live in the MCP store — otherwise `stop` names the
|
|
38
|
+
// dead id while `swarm_shutdown`'s own "most recent running" fallback
|
|
39
|
+
// terminates a different swarm.
|
|
40
|
+
if (swarmState?.status !== 'stopped') {
|
|
41
|
+
const fromState = swarmState?.id ?? swarmState?.swarmId;
|
|
42
|
+
if (typeof fromState === 'string' && fromState)
|
|
43
|
+
return fromState;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const storePath = path.join(process.cwd(), '.claude-flow', 'swarm', 'swarm-state.json');
|
|
47
|
+
if (!fs.existsSync(storePath))
|
|
48
|
+
return null;
|
|
49
|
+
const store = JSON.parse(fs.readFileSync(storePath, 'utf-8'));
|
|
50
|
+
// Most recently updated swarm that has not been shut down.
|
|
51
|
+
const live = Object.values(store.swarms ?? {})
|
|
52
|
+
.filter(swarm => swarm.status !== 'terminated')
|
|
53
|
+
.sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime());
|
|
54
|
+
return live[0]?.swarmId ?? null;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Ignore — an unreadable MCP store just means no id to resolve.
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
11
61
|
// Get dynamic swarm status from memory/session files
|
|
12
62
|
function getSwarmStatus(swarmId) {
|
|
13
63
|
const swarmDir = path.join(process.cwd(), '.swarm');
|
|
@@ -17,16 +67,7 @@ function getSwarmStatus(swarmId) {
|
|
|
17
67
|
path.join(process.cwd(), '.claude', 'memory.db'),
|
|
18
68
|
];
|
|
19
69
|
// Check for active swarm state file
|
|
20
|
-
const
|
|
21
|
-
let swarmState = null;
|
|
22
|
-
if (fs.existsSync(swarmStateFile)) {
|
|
23
|
-
try {
|
|
24
|
-
swarmState = JSON.parse(fs.readFileSync(swarmStateFile, 'utf-8'));
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
// Ignore parse errors
|
|
28
|
-
}
|
|
29
|
-
}
|
|
70
|
+
const swarmState = readLocalSwarmState();
|
|
30
71
|
// Count active agents from process files
|
|
31
72
|
let activeAgents = 0;
|
|
32
73
|
let totalAgents = 0;
|
|
@@ -175,10 +216,22 @@ function getSwarmStatus(swarmId) {
|
|
|
175
216
|
status = 'completed';
|
|
176
217
|
}
|
|
177
218
|
else if (swarmState) {
|
|
178
|
-
|
|
219
|
+
// The file's own lifecycle state wins over the "a file exists, so we are
|
|
220
|
+
// ready" default — a swarm explicitly recorded as stopped is not ready.
|
|
221
|
+
// Live agents still win above: they are observed reality, whereas the file
|
|
222
|
+
// records an intent that may be stale.
|
|
223
|
+
status = swarmState.status === 'stopped' ? 'stopped' : 'ready';
|
|
179
224
|
}
|
|
225
|
+
// Resolve once — the id also settles whether there is a swarm to report.
|
|
226
|
+
// An MCP `swarm_init` with no agents spawned yet leaves no `.swarm/state.json`
|
|
227
|
+
// and no agent store, so without this the payload contradicted itself:
|
|
228
|
+
// a real `id` alongside `hasActiveSwarm: false`.
|
|
229
|
+
const resolvedId = swarmId || resolveSwarmId(swarmState);
|
|
180
230
|
return {
|
|
181
|
-
|
|
231
|
+
// `null` when genuinely unknown — never a sentinel string. `status` and
|
|
232
|
+
// `hasActiveSwarm` already carry the "no active swarm" fact, and a
|
|
233
|
+
// consumer reading `.id` must get an id or nothing.
|
|
234
|
+
id: resolvedId,
|
|
182
235
|
topology: swarmState?.topology || 'none',
|
|
183
236
|
status,
|
|
184
237
|
objective: swarmState?.objective || 'No active objective',
|
|
@@ -290,7 +343,13 @@ function getSwarmStatus(swarmId) {
|
|
|
290
343
|
}
|
|
291
344
|
return { consensusRounds, messagesSent, conflictsResolved };
|
|
292
345
|
})(),
|
|
293
|
-
|
|
346
|
+
// A state file left behind by `swarm stop` is not an active swarm — without
|
|
347
|
+
// this it claimed one it could not name (`id: null`, zero agents), which is
|
|
348
|
+
// the same contradiction from the other side. Live agents or a resolvable
|
|
349
|
+
// id still count, so a stopped file never masks real activity.
|
|
350
|
+
hasActiveSwarm: (!!swarmState && swarmState.status !== 'stopped')
|
|
351
|
+
|| totalAgents > 0
|
|
352
|
+
|| resolvedId !== null
|
|
294
353
|
};
|
|
295
354
|
}
|
|
296
355
|
// Swarm topologies
|
|
@@ -630,6 +689,9 @@ const startCommand = {
|
|
|
630
689
|
if (!fs.existsSync(swarmDir))
|
|
631
690
|
fs.mkdirSync(swarmDir, { recursive: true });
|
|
632
691
|
const executionState = {
|
|
692
|
+
// `id` mirrors what `swarm init` writes so both files share one key;
|
|
693
|
+
// `swarmId` stays for existing files and readers.
|
|
694
|
+
id: swarmId,
|
|
633
695
|
swarmId,
|
|
634
696
|
objective,
|
|
635
697
|
strategy,
|
|
@@ -673,7 +735,7 @@ const statusCommand = {
|
|
|
673
735
|
output.writeln();
|
|
674
736
|
return { success: true, data: status };
|
|
675
737
|
}
|
|
676
|
-
output.writeln(output.bold(`Swarm Status: ${status.id}`));
|
|
738
|
+
output.writeln(output.bold(`Swarm Status: ${status.id ?? output.dim('unknown id')}`));
|
|
677
739
|
output.writeln();
|
|
678
740
|
// Progress bar
|
|
679
741
|
output.writeln(`Overall Progress: ${output.progressBar(status.progress, 100, 40)}`);
|
|
@@ -747,10 +809,16 @@ const stopCommand = {
|
|
|
747
809
|
}
|
|
748
810
|
],
|
|
749
811
|
action: async (ctx) => {
|
|
750
|
-
|
|
812
|
+
// Bare `swarm stop` used to hard-fail, and no CLI surface handed out an
|
|
813
|
+
// id to pass (there is no `swarm list`). Default to the persisted swarm;
|
|
814
|
+
// an explicit argument still wins.
|
|
815
|
+
const swarmId = ctx.args[0] || resolveSwarmId(readLocalSwarmState());
|
|
751
816
|
const force = ctx.flags.force;
|
|
752
817
|
if (!swarmId) {
|
|
753
|
-
output.printError('
|
|
818
|
+
output.printError('No swarm found to stop');
|
|
819
|
+
output.writeln(output.dim(' Find an id with: claude-flow swarm status --format json (the "id" field)'));
|
|
820
|
+
output.writeln(output.dim(' Or pass one: claude-flow swarm stop <swarm-id>'));
|
|
821
|
+
output.writeln(output.dim(' Or start one: claude-flow swarm init'));
|
|
754
822
|
return { success: false, exitCode: 1 };
|
|
755
823
|
}
|
|
756
824
|
if (ctx.interactive && !force) {
|
|
@@ -810,11 +878,14 @@ const scaleCommand = {
|
|
|
810
878
|
}
|
|
811
879
|
],
|
|
812
880
|
action: async (ctx) => {
|
|
813
|
-
|
|
881
|
+
// Same resolution as `stop` — the id is persisted, so don't demand it.
|
|
882
|
+
const swarmId = ctx.args[0] || resolveSwarmId(readLocalSwarmState());
|
|
814
883
|
const targetAgents = ctx.flags.agents;
|
|
815
884
|
const agentType = ctx.flags.type;
|
|
816
885
|
if (!swarmId) {
|
|
817
|
-
output.printError('
|
|
886
|
+
output.printError('No swarm found to scale');
|
|
887
|
+
output.writeln(output.dim(' Find an id with: claude-flow swarm status --format json (the "id" field)'));
|
|
888
|
+
output.writeln(output.dim(' Or pass one: claude-flow swarm scale <swarm-id> --agents N'));
|
|
818
889
|
return { success: false, exitCode: 1 };
|
|
819
890
|
}
|
|
820
891
|
if (!targetAgents) {
|
|
@@ -35,5 +35,20 @@
|
|
|
35
35
|
* @module @claude-flow/cli/mcp-tools/agentbbs
|
|
36
36
|
*/
|
|
37
37
|
import type { MCPTool } from './types.js';
|
|
38
|
+
/** Last envelope's seq by scanning only the file tail; full-read fallback keeps it exact. */
|
|
39
|
+
export declare function nextSeq(path: string): number;
|
|
40
|
+
/** Timestamp (ms) of the most recent PeerHello for a room, or -Infinity if none. */
|
|
41
|
+
export declare function lastPeerHelloMs(logPath: string): number;
|
|
42
|
+
/**
|
|
43
|
+
* Whether a `register` call should append a PeerHello: only for a genuinely new
|
|
44
|
+
* registration, or once the heartbeat window has elapsed since the last one.
|
|
45
|
+
* A re-register within the window is a retry and must NOT spam a near-identical
|
|
46
|
+
* hello. Pure — the decision the handler makes, exported for test.
|
|
47
|
+
*/
|
|
48
|
+
export declare function shouldEmitHello(alreadyRegistered: boolean, nowMs: number, lastHelloMs: number, windowSecs: number): boolean;
|
|
49
|
+
/** Collapse duplicate envelopeIds, preserving order (first occurrence wins). Pure. */
|
|
50
|
+
export declare function dedupEnvelopes<T extends {
|
|
51
|
+
envelopeId: string;
|
|
52
|
+
}>(list: T[]): T[];
|
|
38
53
|
export declare const agentbbsTools: MCPTool[];
|
|
39
54
|
//# sourceMappingURL=agentbbs-tools.d.ts.map
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
*
|
|
35
35
|
* @module @claude-flow/cli/mcp-tools/agentbbs
|
|
36
36
|
*/
|
|
37
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
37
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
38
38
|
import { resolve, isAbsolute, join } from 'node:path';
|
|
39
39
|
import { randomBytes, createHash } from 'node:crypto';
|
|
40
40
|
import { execFileSync } from 'node:child_process';
|
|
@@ -133,12 +133,83 @@ function readEnvelopes(path) {
|
|
|
133
133
|
}
|
|
134
134
|
return out;
|
|
135
135
|
}
|
|
136
|
-
|
|
136
|
+
// Tail of the log we scan to find the last seq. Envelopes are small; one line
|
|
137
|
+
// always fits well inside this. Reading only the tail keeps append O(1) instead
|
|
138
|
+
// of O(n) as a room log grows (ADR-164 §3.2.2 requires monotonic seq, and a
|
|
139
|
+
// full re-parse on every publish was the cost).
|
|
140
|
+
const SEQ_TAIL_BYTES = 65536;
|
|
141
|
+
/** Last envelope's seq by scanning only the file tail; full-read fallback keeps it exact. */
|
|
142
|
+
export function nextSeq(path) {
|
|
143
|
+
if (!existsSync(path))
|
|
144
|
+
return 1;
|
|
145
|
+
try {
|
|
146
|
+
const size = statSync(path).size;
|
|
147
|
+
if (size === 0)
|
|
148
|
+
return 1;
|
|
149
|
+
const start = Math.max(0, size - SEQ_TAIL_BYTES);
|
|
150
|
+
const fd = openSync(path, 'r');
|
|
151
|
+
let text;
|
|
152
|
+
try {
|
|
153
|
+
const buf = Buffer.alloc(size - start);
|
|
154
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
155
|
+
text = buf.toString('utf-8');
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
closeSync(fd);
|
|
159
|
+
}
|
|
160
|
+
// If we started mid-file the first fragment may be a partial line — drop it.
|
|
161
|
+
const lines = text.split(/\r?\n/).filter(l => l.trim());
|
|
162
|
+
const candidates = start > 0 && lines.length > 1 ? lines.slice(1) : lines;
|
|
163
|
+
for (let i = candidates.length - 1; i >= 0; i--) {
|
|
164
|
+
try {
|
|
165
|
+
const e = JSON.parse(candidates[i]);
|
|
166
|
+
if (typeof e.seq === 'number')
|
|
167
|
+
return e.seq + 1;
|
|
168
|
+
}
|
|
169
|
+
catch { /* keep scanning upward for the last parseable line */ }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch { /* fall through to the exact full-read path */ }
|
|
173
|
+
// Fallback: whole-file parse (rare — huge single line, or a tail with no seq).
|
|
137
174
|
const env = readEnvelopes(path);
|
|
138
175
|
if (env.length === 0)
|
|
139
176
|
return 1;
|
|
140
177
|
return (env[env.length - 1].seq ?? env.length) + 1;
|
|
141
178
|
}
|
|
179
|
+
/** Window within which a fresh PeerHello is suppressed as a duplicate (heartbeat, not spam). */
|
|
180
|
+
const HELLO_WINDOW_SECS = Number(process.env.FEDERATION_BBS_HELLO_WINDOW_SECS ?? 60);
|
|
181
|
+
/** Timestamp (ms) of the most recent PeerHello for a room, or -Infinity if none. */
|
|
182
|
+
export function lastPeerHelloMs(logPath) {
|
|
183
|
+
if (!existsSync(logPath))
|
|
184
|
+
return -Infinity;
|
|
185
|
+
const env = readEnvelopes(logPath);
|
|
186
|
+
for (let i = env.length - 1; i >= 0; i--) {
|
|
187
|
+
if (env[i].msgType === 'PeerHello') {
|
|
188
|
+
const t = Date.parse(env[i].timestamp);
|
|
189
|
+
return Number.isNaN(t) ? -Infinity : t;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return -Infinity;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Whether a `register` call should append a PeerHello: only for a genuinely new
|
|
196
|
+
* registration, or once the heartbeat window has elapsed since the last one.
|
|
197
|
+
* A re-register within the window is a retry and must NOT spam a near-identical
|
|
198
|
+
* hello. Pure — the decision the handler makes, exported for test.
|
|
199
|
+
*/
|
|
200
|
+
export function shouldEmitHello(alreadyRegistered, nowMs, lastHelloMs, windowSecs) {
|
|
201
|
+
return !alreadyRegistered || nowMs - lastHelloMs >= windowSecs * 1000;
|
|
202
|
+
}
|
|
203
|
+
/** Collapse duplicate envelopeIds, preserving order (first occurrence wins). Pure. */
|
|
204
|
+
export function dedupEnvelopes(list) {
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
return list.filter(e => {
|
|
207
|
+
if (seen.has(e.envelopeId))
|
|
208
|
+
return false;
|
|
209
|
+
seen.add(e.envelopeId);
|
|
210
|
+
return true;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
142
213
|
/**
|
|
143
214
|
* Ephemeral per-process Ed25519 keypair for human-join token signing.
|
|
144
215
|
* Phase 1 contract: keys are NOT persisted across process restart — every
|
|
@@ -212,6 +283,7 @@ export const agentbbsTools = [
|
|
|
212
283
|
const registryPath = roomsRegistryPath(basePath);
|
|
213
284
|
const registry = existsSync(registryPath) ? JSON.parse(readFileSync(registryPath, 'utf-8')) : {};
|
|
214
285
|
// Idempotent — re-registering the same label updates timestamp but keeps id stable.
|
|
286
|
+
const alreadyRegistered = roomId in registry;
|
|
215
287
|
const entry = {
|
|
216
288
|
roomId,
|
|
217
289
|
roomLabel,
|
|
@@ -220,17 +292,25 @@ export const agentbbsTools = [
|
|
|
220
292
|
};
|
|
221
293
|
registry[roomId] = entry;
|
|
222
294
|
writeFileSync(registryPath, JSON.stringify(registry, null, 2));
|
|
223
|
-
// Emit a
|
|
295
|
+
// Emit a PeerHello only when it carries information: a genuinely new
|
|
296
|
+
// registration, or a heartbeat refresh once HELLO_WINDOW_SECS has elapsed.
|
|
297
|
+
// A re-register within the window is a retry/no-op — appending a
|
|
298
|
+
// near-identical hello every call is the spam ADR-164 dedup guards against
|
|
299
|
+
// (observed live: one node published 8 near-identical PeerHellos).
|
|
224
300
|
const logPath = roomLogPath(basePath, roomId);
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
301
|
+
const nowMs = Date.parse(entry.registeredAt);
|
|
302
|
+
const helloEmitted = shouldEmitHello(alreadyRegistered, nowMs, lastPeerHelloMs(logPath), HELLO_WINDOW_SECS);
|
|
303
|
+
if (helloEmitted) {
|
|
304
|
+
const env = {
|
|
305
|
+
envelopeId: base64url(randomBytes(12)),
|
|
306
|
+
roomId,
|
|
307
|
+
seq: nextSeq(logPath),
|
|
308
|
+
msgType: 'PeerHello',
|
|
309
|
+
payload: { roomLabel, trustLevel: 'attested' },
|
|
310
|
+
timestamp: entry.registeredAt,
|
|
311
|
+
};
|
|
312
|
+
appendFileSync(logPath, JSON.stringify(env) + '\n');
|
|
313
|
+
}
|
|
234
314
|
// nodeId: deterministic per (cwd, roomId) so re-registers reuse identity.
|
|
235
315
|
const nodeId = createHash('sha256')
|
|
236
316
|
.update(`agentbbs:node:${basePath}:${roomId}`)
|
|
@@ -241,6 +321,7 @@ export const agentbbsTools = [
|
|
|
241
321
|
roomId,
|
|
242
322
|
nodeId,
|
|
243
323
|
trustLevel: 'attested',
|
|
324
|
+
helloEmitted,
|
|
244
325
|
};
|
|
245
326
|
},
|
|
246
327
|
},
|
|
@@ -357,13 +438,16 @@ export const agentbbsTools = [
|
|
|
357
438
|
const idx = all.findIndex(e => e.envelopeId === sinceEnvelopeId);
|
|
358
439
|
slice = idx >= 0 ? all.slice(idx + 1) : all;
|
|
359
440
|
}
|
|
360
|
-
|
|
441
|
+
// Dedup by envelopeId before applying the limit — a relay replay or a
|
|
442
|
+
// double-append must not surface the same envelope twice to a watcher.
|
|
443
|
+
const deduped = dedupEnvelopes(slice);
|
|
444
|
+
const envelopes = deduped.slice(-limit);
|
|
361
445
|
return {
|
|
362
446
|
success: true,
|
|
363
447
|
roomId,
|
|
364
448
|
envelopes,
|
|
365
449
|
count: envelopes.length,
|
|
366
|
-
hasMore:
|
|
450
|
+
hasMore: deduped.length > envelopes.length,
|
|
367
451
|
};
|
|
368
452
|
},
|
|
369
453
|
},
|
|
@@ -238,6 +238,58 @@ async function describeBackend() {
|
|
|
238
238
|
return 'sqlite';
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
|
+
/** #3311: one page per round trip, so a store larger than one page is
|
|
242
|
+
* counted rather than silently cut off at the old hardcoded 100000. */
|
|
243
|
+
const MEMORY_STATS_PAGE = 10000;
|
|
244
|
+
/** #3311: a hard stop so a `total` that never agrees with the rows returned
|
|
245
|
+
* cannot spin forever. Reaching it is reported as a truncated count, not as
|
|
246
|
+
* a complete one. */
|
|
247
|
+
const MEMORY_STATS_MAX_PAGES = 200;
|
|
248
|
+
/**
|
|
249
|
+
* Page through the store and return every row, or the first failure.
|
|
250
|
+
*
|
|
251
|
+
* The old call asked for `limit: 100000` in one shot and read only
|
|
252
|
+
* `entries`/`total` off the result — so a bigger store had its namespace
|
|
253
|
+
* breakdown and embedding coverage computed from a prefix, and a listing
|
|
254
|
+
* that reported `success: false` was read as an empty one.
|
|
255
|
+
*/
|
|
256
|
+
async function collectAllEntries(listEntries) {
|
|
257
|
+
const entries = [];
|
|
258
|
+
let total = 0;
|
|
259
|
+
for (let page = 0; page < MEMORY_STATS_MAX_PAGES; page++) {
|
|
260
|
+
const result = await listEntries({ limit: MEMORY_STATS_PAGE, offset: entries.length });
|
|
261
|
+
if (!result.success) {
|
|
262
|
+
return { success: false, entries, total: result.total ?? total, error: result.error };
|
|
263
|
+
}
|
|
264
|
+
total = result.total;
|
|
265
|
+
entries.push(...result.entries);
|
|
266
|
+
if (result.entries.length === 0 || entries.length >= total)
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
return { success: true, entries, total };
|
|
270
|
+
}
|
|
271
|
+
/** #3311: unavailable is its own answer. The zeros this replaces were
|
|
272
|
+
* indistinguishable from a store that really is empty. */
|
|
273
|
+
function memoryStatsUnavailable(error) {
|
|
274
|
+
return { initialized: null, available: false, error };
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Version and feature labels from the initialization probe.
|
|
278
|
+
*
|
|
279
|
+
* Metadata only. The probe reads a whole-image snapshot that cannot see live
|
|
280
|
+
* WAL frames, so it is allowed to fail without that meaning the store is
|
|
281
|
+
* missing — which is exactly the conflation #3311 reports.
|
|
282
|
+
*/
|
|
283
|
+
async function readMemoryStatusLabels() {
|
|
284
|
+
try {
|
|
285
|
+
const { checkMemoryInitialization } = await getMemoryFunctions();
|
|
286
|
+
const status = await checkMemoryInitialization();
|
|
287
|
+
return { version: status.version, features: status.features };
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return {};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
241
293
|
/**
|
|
242
294
|
* Ensure memory database is initialized and migrate legacy data if needed.
|
|
243
295
|
* #1606: Wrapped in try/catch to prevent process-level crashes that kill
|
|
@@ -704,41 +756,60 @@ export const memoryTools = [
|
|
|
704
756
|
},
|
|
705
757
|
handler: async () => {
|
|
706
758
|
await ensureInitialized();
|
|
707
|
-
const {
|
|
759
|
+
const { listEntries } = await getMemoryFunctions();
|
|
760
|
+
// #3311: the store's own listing decides whether memory is there.
|
|
761
|
+
// `checkMemoryInitialization` opens a whole-image sql.js snapshot of
|
|
762
|
+
// the main database file, which cannot include live SQLite WAL frames
|
|
763
|
+
// — so a store the bridge reads and searches perfectly well came back
|
|
764
|
+
// `initialized: false` from this tool alone. The probe is still read
|
|
765
|
+
// below, for the version and feature labels it is the only source of,
|
|
766
|
+
// but it no longer gets to overrule a working store.
|
|
767
|
+
let listing;
|
|
708
768
|
try {
|
|
709
|
-
|
|
710
|
-
const allEntries = await listEntries({ limit: 100000 });
|
|
711
|
-
// Count by namespace
|
|
712
|
-
const namespaces = {};
|
|
713
|
-
let withEmbeddings = 0;
|
|
714
|
-
for (const entry of allEntries.entries) {
|
|
715
|
-
namespaces[entry.namespace] = (namespaces[entry.namespace] || 0) + 1;
|
|
716
|
-
if (entry.hasEmbedding)
|
|
717
|
-
withEmbeddings++;
|
|
718
|
-
}
|
|
719
|
-
return {
|
|
720
|
-
initialized: status.initialized,
|
|
721
|
-
totalEntries: allEntries.total,
|
|
722
|
-
entriesWithEmbeddings: withEmbeddings,
|
|
723
|
-
embeddingCoverage: allEntries.total > 0
|
|
724
|
-
? `${((withEmbeddings / allEntries.total) * 100).toFixed(1)}%`
|
|
725
|
-
: '0%',
|
|
726
|
-
namespaces,
|
|
727
|
-
backend: await describeBackend(),
|
|
728
|
-
version: status.version || '3.0.0',
|
|
729
|
-
features: status.features || {
|
|
730
|
-
vectorEmbeddings: true,
|
|
731
|
-
hnswIndex: true,
|
|
732
|
-
semanticSearch: true,
|
|
733
|
-
},
|
|
734
|
-
};
|
|
769
|
+
listing = await collectAllEntries(listEntries);
|
|
735
770
|
}
|
|
736
771
|
catch (error) {
|
|
737
|
-
return
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
772
|
+
return memoryStatsUnavailable(error instanceof Error ? error.message : 'Unknown error');
|
|
773
|
+
}
|
|
774
|
+
if (!listing.success) {
|
|
775
|
+
// A failed query is not an empty store. Reporting zeros here is
|
|
776
|
+
// what made a WAL refusal look like "you have no memories".
|
|
777
|
+
return memoryStatsUnavailable(listing.error || 'listEntries reported failure');
|
|
741
778
|
}
|
|
779
|
+
// Object.create(null): a namespace literally named `__proto__` is a
|
|
780
|
+
// legal key, and assigning it on an object literal sets the prototype
|
|
781
|
+
// instead of counting anything — so that namespace's entries vanished
|
|
782
|
+
// from the breakdown while still being counted in the total.
|
|
783
|
+
const namespaces = Object.create(null);
|
|
784
|
+
let withEmbeddings = 0;
|
|
785
|
+
for (const entry of listing.entries) {
|
|
786
|
+
namespaces[entry.namespace] = (namespaces[entry.namespace] || 0) + 1;
|
|
787
|
+
if (entry.hasEmbedding)
|
|
788
|
+
withEmbeddings++;
|
|
789
|
+
}
|
|
790
|
+
const counted = listing.entries.length;
|
|
791
|
+
const status = await readMemoryStatusLabels();
|
|
792
|
+
return {
|
|
793
|
+
initialized: true,
|
|
794
|
+
totalEntries: listing.total,
|
|
795
|
+
entriesCounted: counted,
|
|
796
|
+
// The breakdown below covers `entriesCounted` rows, which is every
|
|
797
|
+
// row unless the listing was truncated; say so rather than letting
|
|
798
|
+
// a partial count read as the whole store.
|
|
799
|
+
...(counted < listing.total ? { truncated: true } : {}),
|
|
800
|
+
entriesWithEmbeddings: withEmbeddings,
|
|
801
|
+
embeddingCoverage: counted > 0
|
|
802
|
+
? `${((withEmbeddings / counted) * 100).toFixed(1)}%`
|
|
803
|
+
: '0%',
|
|
804
|
+
namespaces: { ...namespaces },
|
|
805
|
+
backend: await describeBackend(),
|
|
806
|
+
version: status.version || '3.0.0',
|
|
807
|
+
features: status.features || {
|
|
808
|
+
vectorEmbeddings: true,
|
|
809
|
+
hnswIndex: true,
|
|
810
|
+
semanticSearch: true,
|
|
811
|
+
},
|
|
812
|
+
};
|
|
742
813
|
},
|
|
743
814
|
},
|
|
744
815
|
{
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { relayPayload } from './x-federation-tools.js';
|
|
1
2
|
// ADR-125 precedence: explicit tool args (metaLlmUrl / gatewayUrl) take precedence over the
|
|
2
3
|
// SERAPHINA_METALLM_URL / RUFLO_X_GATEWAY_URL env vars, which precede the defaults.
|
|
3
4
|
const META_LLM = (override) => (override || process.env.SERAPHINA_METALLM_URL || 'https://api.cognitum.one').replace(/\/$/, '');
|
|
@@ -14,7 +15,9 @@ async function gatewayRead(uri, gatewayUrl) {
|
|
|
14
15
|
const text = await res.text();
|
|
15
16
|
const line = text.split('\n').find((l) => l.startsWith('data:'));
|
|
16
17
|
const p = JSON.parse(line ? line.slice(5) : text);
|
|
17
|
-
|
|
18
|
+
// roster and claims are relay-sourced and therefore fenced (#3300). These values
|
|
19
|
+
// are indexed directly below, so take the payload, not the envelope.
|
|
20
|
+
return relayPayload(p.result?.contents?.[0]?.text ?? '{}');
|
|
18
21
|
}
|
|
19
22
|
async function gatewaySync(sinceSeconds, limit, gatewayUrl) {
|
|
20
23
|
const res = await fetch(`${GATEWAY(gatewayUrl)}/mcp`, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
|
@@ -22,7 +25,9 @@ async function gatewaySync(sinceSeconds, limit, gatewayUrl) {
|
|
|
22
25
|
const text = await res.text();
|
|
23
26
|
const line = text.split('\n').find((l) => l.startsWith('data:'));
|
|
24
27
|
const p = JSON.parse(line ? line.slice(5) : text);
|
|
25
|
-
|
|
28
|
+
// federation_sync is relay-sourced and therefore fenced (#3300); `.messages` is
|
|
29
|
+
// read directly below, so an envelope here would silently mean "empty swarm".
|
|
30
|
+
return relayPayload(p.result?.content?.[0]?.text ?? '{}');
|
|
26
31
|
}
|
|
27
32
|
export async function askSeraphina(goal, opts = {}) {
|
|
28
33
|
// Credential: intentionally env-only (never a CLI flag). Registered in audit-env-var-precedence.mjs.
|
|
@@ -8,5 +8,17 @@
|
|
|
8
8
|
* `ruv://federation/registry` resource), not through these gateway-identity tools.
|
|
9
9
|
*/
|
|
10
10
|
import type { MCPTool } from './types.js';
|
|
11
|
+
export declare function parseGatewayText(text: string): Record<string, unknown>;
|
|
12
|
+
/**
|
|
13
|
+
* The payload, for consumers INSIDE this package that immediately index the
|
|
14
|
+
* value (`recent.messages`, `Object.keys(roster)`).
|
|
15
|
+
*
|
|
16
|
+
* At the MCP boundary we return the whole envelope so the caller can see whose
|
|
17
|
+
* words these are. Internally that shape is a hazard: reading `.messages` off an
|
|
18
|
+
* envelope yields undefined and `Object.keys()` yields the envelope's own five
|
|
19
|
+
* keys, so a miscount looks like a real answer. Never do a bare property read on
|
|
20
|
+
* a parseGatewayText result — come through here.
|
|
21
|
+
*/
|
|
22
|
+
export declare function relayPayload(text: string): Record<string, unknown>;
|
|
11
23
|
export declare const xFederationTools: MCPTool[];
|
|
12
24
|
//# sourceMappingURL=x-federation-tools.d.ts.map
|
|
@@ -18,18 +18,100 @@ async function gatewayRpc(method, params, gatewayUrl) {
|
|
|
18
18
|
throw new Error(`x.ruv.io: ${payload.error.message ?? 'rpc error'}`);
|
|
19
19
|
return payload.result;
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Relay-sourced gateway responses are not bare JSON. Since #3300 the gateway
|
|
23
|
+
* wraps anything published by other federation members in a provenance envelope
|
|
24
|
+
* (plugins/ruflo-x-gateway/src/untrusted.mjs): several lines of gateway-authored
|
|
25
|
+
* prose, then the JSON body between a matched
|
|
26
|
+
* `<<<UNTRUSTED_RELAY_DATA <uuid>>>>` / `<<<END_UNTRUSTED_RELAY_DATA <uuid>>>>`
|
|
27
|
+
* pair. `JSON.parse` on the whole string fails on the first prose word, which is
|
|
28
|
+
* the `Unexpected token 'T', "The block "...` seen from every federation read.
|
|
29
|
+
*
|
|
30
|
+
* Three things keep a publisher from closing the block early, and it is worth
|
|
31
|
+
* being precise about which one is doing the work today:
|
|
32
|
+
* 1. The body is JSON.stringify'd, so it is a SINGLE line — a publisher's text
|
|
33
|
+
* cannot contain a raw newline, and the markers below are newline-anchored.
|
|
34
|
+
* This is what actually neutralises forged markers in relay content today.
|
|
35
|
+
* 2. The token backreference: a forged END carrying any other token does not
|
|
36
|
+
* terminate the region. This is the defence that survives (1) — if the body
|
|
37
|
+
* is ever pretty-printed, it becomes the only one left. It is tested
|
|
38
|
+
* directly rather than incidentally, so it cannot be refactored away quietly.
|
|
39
|
+
* 3. Exactly one opening marker is permitted. `fenceUntrusted` splices its
|
|
40
|
+
* `note` verbatim BEFORE the fence, so a caller that ever interpolates
|
|
41
|
+
* relay-derived text into a note could otherwise smuggle in a complete
|
|
42
|
+
* earlier envelope; first-match-wins would return it, with untrusted:false.
|
|
43
|
+
*
|
|
44
|
+
* We deliberately return the WHOLE envelope (`untrusted`, `provenance`, `relay`,
|
|
45
|
+
* `retrievedAt`, `data`) rather than lifting `data` out of it. The point of the
|
|
46
|
+
* envelope is that a caller can tell whose words these are; quietly unwrapping to
|
|
47
|
+
* the payload would restore valid JSON by discarding the labelling that made it
|
|
48
|
+
* safe to read. Unfenced responses (the registry resource, gateway-authored
|
|
49
|
+
* errors) parse unchanged.
|
|
50
|
+
*/
|
|
51
|
+
const UNTRUSTED_FENCE = /<<<UNTRUSTED_RELAY_DATA ([0-9a-fA-F-]{36})>>>\n([\s\S]*?)\n<<<END_UNTRUSTED_RELAY_DATA \1>>>/;
|
|
52
|
+
// Count only NEWLINE-ANCHORED opening markers. A marker inside the body is just
|
|
53
|
+
// characters — the body is one JSON line, so it can never be preceded by a raw
|
|
54
|
+
// newline and can never open a fence. Counting raw occurrences instead would make
|
|
55
|
+
// a publisher able to hard-fail every read simply by typing the marker into a
|
|
56
|
+
// message, which trades a parse bug for a denial of service.
|
|
57
|
+
const OPEN_MARKER_ANCHORED = /(?:^|\n)<<<UNTRUSTED_RELAY_DATA /g;
|
|
58
|
+
export function parseGatewayText(text) {
|
|
59
|
+
// One response carries exactly one envelope. More than one means something
|
|
60
|
+
// upstream spliced an envelope-shaped string into the response, and picking
|
|
61
|
+
// either is a guess — refuse rather than choose.
|
|
62
|
+
const opens = (text.match(OPEN_MARKER_ANCHORED) ?? []).length;
|
|
63
|
+
if (opens > 1) {
|
|
64
|
+
throw new Error('x.ruv.io: response carries more than one untrusted-data envelope (tampered response)');
|
|
65
|
+
}
|
|
66
|
+
const fenced = UNTRUSTED_FENCE.exec(text);
|
|
67
|
+
if (fenced)
|
|
68
|
+
return JSON.parse(fenced[2]);
|
|
69
|
+
// An opening marker with no matching close is a truncated or tampered response.
|
|
70
|
+
// Fail loudly: parsing the remainder would silently drop relay content.
|
|
71
|
+
if (opens === 1) {
|
|
72
|
+
throw new Error('x.ruv.io: untrusted-data envelope is unterminated (truncated or tampered response)');
|
|
73
|
+
}
|
|
74
|
+
return JSON.parse(text);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The payload, for consumers INSIDE this package that immediately index the
|
|
78
|
+
* value (`recent.messages`, `Object.keys(roster)`).
|
|
79
|
+
*
|
|
80
|
+
* At the MCP boundary we return the whole envelope so the caller can see whose
|
|
81
|
+
* words these are. Internally that shape is a hazard: reading `.messages` off an
|
|
82
|
+
* envelope yields undefined and `Object.keys()` yields the envelope's own five
|
|
83
|
+
* keys, so a miscount looks like a real answer. Never do a bare property read on
|
|
84
|
+
* a parseGatewayText result — come through here.
|
|
85
|
+
*/
|
|
86
|
+
export function relayPayload(text) {
|
|
87
|
+
const parsed = parseGatewayText(text);
|
|
88
|
+
return (parsed.untrusted === true && parsed.data !== undefined
|
|
89
|
+
? parsed.data
|
|
90
|
+
: parsed);
|
|
91
|
+
}
|
|
21
92
|
async function gatewayTool(name, args) {
|
|
22
93
|
const { gatewayUrl, ...rest } = args;
|
|
23
94
|
const r = (await gatewayRpc('tools/call', { name, arguments: rest }, gatewayUrl));
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
95
|
+
const raw = r.content?.[0]?.text ?? '{}';
|
|
96
|
+
// An isError result is the SDK's createToolError, whose message is raw text and
|
|
97
|
+
// not JSON. Parsing first turns "private channels cannot be published…" into
|
|
98
|
+
// "Unexpected token 'p'" — the same class of bug this parser exists to fix.
|
|
99
|
+
if (r.isError) {
|
|
100
|
+
let msg = raw;
|
|
101
|
+
try {
|
|
102
|
+
msg = String(parseGatewayText(raw).error ?? raw);
|
|
103
|
+
}
|
|
104
|
+
catch { /* raw text: use as-is */ }
|
|
105
|
+
throw new Error(msg || 'gateway tool error');
|
|
106
|
+
}
|
|
107
|
+
const parsed = parseGatewayText(raw);
|
|
108
|
+
if (parsed.error)
|
|
109
|
+
throw new Error(String(parsed.error));
|
|
28
110
|
return parsed;
|
|
29
111
|
}
|
|
30
112
|
async function gatewayResource(uri, gatewayUrl) {
|
|
31
113
|
const r = (await gatewayRpc('resources/read', { uri }, gatewayUrl));
|
|
32
|
-
return
|
|
114
|
+
return parseGatewayText(r.contents?.[0]?.text ?? '{}');
|
|
33
115
|
}
|
|
34
116
|
// Credential: intentionally env-only (a secret must never be a CLI flag — it would land in
|
|
35
117
|
// shell history / process lists). Registered in scripts/audit-env-var-precedence.mjs.
|
|
@@ -1855,12 +1855,15 @@ export async function checkMemoryInitialization(dbPath) {
|
|
|
1855
1855
|
if (!fs.existsSync(path_)) {
|
|
1856
1856
|
return { initialized: false };
|
|
1857
1857
|
}
|
|
1858
|
+
// #3249: declared outside the try so the handle can be released on the
|
|
1859
|
+
// failure path as well as the success path.
|
|
1860
|
+
let db;
|
|
1858
1861
|
try {
|
|
1859
1862
|
// Try to load with sql.js
|
|
1860
1863
|
const initSqlJs = (await import('sql.js')).default;
|
|
1861
1864
|
const SQL = await initSqlJs();
|
|
1862
1865
|
const fileBuffer = fs.readFileSync(path_);
|
|
1863
|
-
|
|
1866
|
+
db = new SQL.Database(fileBuffer);
|
|
1864
1867
|
// Check for metadata table
|
|
1865
1868
|
const tables = db.exec("SELECT name FROM sqlite_master WHERE type='table'");
|
|
1866
1869
|
const tableNames = tables[0]?.values?.map(v => v[0]) || [];
|
|
@@ -1876,7 +1879,6 @@ export async function checkMemoryInitialization(dbPath) {
|
|
|
1876
1879
|
catch {
|
|
1877
1880
|
// Metadata table might not exist
|
|
1878
1881
|
}
|
|
1879
|
-
db.close();
|
|
1880
1882
|
return {
|
|
1881
1883
|
initialized: true,
|
|
1882
1884
|
version,
|
|
@@ -1893,6 +1895,21 @@ export async function checkMemoryInitialization(dbPath) {
|
|
|
1893
1895
|
// Could not read database
|
|
1894
1896
|
return { initialized: false };
|
|
1895
1897
|
}
|
|
1898
|
+
finally {
|
|
1899
|
+
// #3249: release the handle on every path. An RFE1-encrypted image is not
|
|
1900
|
+
// parseable as SQLite, so the schema query above throws and the catch
|
|
1901
|
+
// returns — which used to skip the inline db.close() entirely, leaving the
|
|
1902
|
+
// sql.js Database and its MEMFS copy open for the life of the process.
|
|
1903
|
+
// Every memory MCP tool call runs this check, so a long session accumulates
|
|
1904
|
+
// one unclosed handle per call. Measured retention is a few hundred KiB per
|
|
1905
|
+
// leaked handle (it does not scale with image size).
|
|
1906
|
+
try {
|
|
1907
|
+
db?.close();
|
|
1908
|
+
}
|
|
1909
|
+
catch {
|
|
1910
|
+
// Already closed, or never successfully constructed.
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1896
1913
|
}
|
|
1897
1914
|
/**
|
|
1898
1915
|
* Apply temporal decay to patterns
|
|
@@ -13,51 +13,137 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { join } from 'path';
|
|
15
15
|
// Lazy-loaded graph-node module
|
|
16
|
-
let
|
|
17
|
-
let
|
|
18
|
-
let graphBackendLoaded = false;
|
|
16
|
+
let graphNodeModulePromise = null;
|
|
17
|
+
let graphDbPromise = null;
|
|
19
18
|
let graphBackendAvailable = false;
|
|
20
19
|
const DEFAULT_EMBEDDING_DIM = 8; // Minimal embedding for graph structure
|
|
20
|
+
const DEFAULT_DISTANCE_METRIC = 'Cosine';
|
|
21
21
|
/**
|
|
22
|
-
* Load @ruvector/graph-node via createRequire (CJS package)
|
|
22
|
+
* Load @ruvector/graph-node via createRequire (CJS package).
|
|
23
|
+
*
|
|
24
|
+
* The promise is the memo rather than a "loaded" flag. The flag was set
|
|
25
|
+
* BEFORE its own `await`, so a caller arriving inside that window would take
|
|
26
|
+
* the early return and read `graphNodeModule` while it was still `null`.
|
|
27
|
+
* I could not make that window observable -- `import('module')` resolves a
|
|
28
|
+
* builtin before another caller gets a turn -- so this is hardening on the
|
|
29
|
+
* same shape as the open memo below, not a defect with a reproduction behind
|
|
30
|
+
* it.
|
|
23
31
|
*/
|
|
24
32
|
async function loadGraphNode() {
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
if (!graphNodeModulePromise) {
|
|
34
|
+
graphNodeModulePromise = (async () => {
|
|
35
|
+
try {
|
|
36
|
+
const { createRequire } = await import('module');
|
|
37
|
+
const requireCjs = createRequire(import.meta.url);
|
|
38
|
+
const mod = requireCjs('@ruvector/graph-node');
|
|
39
|
+
graphBackendAvailable = true;
|
|
40
|
+
return mod;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
graphBackendAvailable = false;
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
})();
|
|
34
47
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
48
|
+
return graphNodeModulePromise;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Report a graph-backend problem.
|
|
52
|
+
*
|
|
53
|
+
* No "already warned" flag: the open runs once per process because
|
|
54
|
+
* `getGraphDb` memoizes the promise, and a second flag would only hide it if
|
|
55
|
+
* that ever stopped being true.
|
|
56
|
+
*/
|
|
57
|
+
function warnGraphInit(message) {
|
|
58
|
+
console.warn(`[graph-backend] ${message}`);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Whether this handle is actually backed by the file we asked for.
|
|
62
|
+
*
|
|
63
|
+
* `@ruvector/graph-node` 2.1.0 accepts a bare path STRING without throwing
|
|
64
|
+
* and hands back a volatile in-memory instance -- `isPersistent() === false`,
|
|
65
|
+
* `getStoragePath() === null`. Nothing downstream notices: writes succeed,
|
|
66
|
+
* reads succeed, and the graph is gone at exit. Passing the options object
|
|
67
|
+
* fixes that, and this check is what makes the fix self-reporting instead of
|
|
68
|
+
* something a future signature change can quietly undo.
|
|
69
|
+
*
|
|
70
|
+
* A build that exposes neither accessor cannot be interrogated, so it is
|
|
71
|
+
* accepted rather than refused -- this guards against a silent downgrade, not
|
|
72
|
+
* against an unfamiliar version.
|
|
73
|
+
*/
|
|
74
|
+
function isPersistentAt(db, storagePath) {
|
|
75
|
+
const canReport = typeof db?.isPersistent === 'function' || typeof db?.getStoragePath === 'function';
|
|
76
|
+
if (!canReport)
|
|
77
|
+
return true;
|
|
78
|
+
if (typeof db.isPersistent === 'function' && db.isPersistent() !== true)
|
|
79
|
+
return false;
|
|
80
|
+
if (typeof db.getStoragePath === 'function' && db.getStoragePath() !== storagePath) {
|
|
81
|
+
return false;
|
|
38
82
|
}
|
|
83
|
+
return true;
|
|
39
84
|
}
|
|
40
85
|
/**
|
|
41
|
-
*
|
|
86
|
+
* Open the graph database, or return null with a stated reason.
|
|
87
|
+
*
|
|
88
|
+
* The old fallback replaced an open failure with `new mod.GraphDatabase()` --
|
|
89
|
+
* an empty in-memory graph that answers every query successfully and persists
|
|
90
|
+
* nothing. A permission error, a lock held by another process and a healthy
|
|
91
|
+
* database were indistinguishable from the outside. Callers already handle
|
|
92
|
+
* `null` by degrading to `backend: 'unavailable'`, which is the honest shape
|
|
93
|
+
* for "the graph is not there".
|
|
42
94
|
*/
|
|
43
|
-
async function
|
|
44
|
-
if (graphDb)
|
|
45
|
-
return graphDb;
|
|
95
|
+
async function openGraphDb() {
|
|
46
96
|
const mod = await loadGraphNode();
|
|
47
97
|
if (!mod)
|
|
48
98
|
return null;
|
|
49
|
-
// Use persistent path if available, otherwise in-memory
|
|
50
99
|
const dataDir = join(process.cwd(), '.claude-flow', 'graph');
|
|
100
|
+
const storagePath = join(dataDir, 'agents.db');
|
|
101
|
+
let db;
|
|
51
102
|
try {
|
|
52
103
|
const fs = await import('fs');
|
|
53
104
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
54
|
-
|
|
105
|
+
// The options object, not the path string: see `isPersistentAt`.
|
|
106
|
+
db = new mod.GraphDatabase({
|
|
107
|
+
storagePath,
|
|
108
|
+
dimensions: DEFAULT_EMBEDDING_DIM,
|
|
109
|
+
distanceMetric: DEFAULT_DISTANCE_METRIC,
|
|
110
|
+
});
|
|
55
111
|
}
|
|
56
|
-
catch {
|
|
57
|
-
|
|
58
|
-
|
|
112
|
+
catch (error) {
|
|
113
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
114
|
+
warnGraphInit(`could not open ${storagePath}: ${reason}. Graph backend disabled.`);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
if (!isPersistentAt(db, storagePath)) {
|
|
118
|
+
const reported = typeof db?.getStoragePath === 'function' ? db.getStoragePath() : 'unknown';
|
|
119
|
+
warnGraphInit(`opened a non-persistent graph (storage path ${String(reported)}, wanted ${storagePath}). ` +
|
|
120
|
+
'Graph backend disabled rather than writing to a graph that vanishes at exit.');
|
|
121
|
+
try {
|
|
122
|
+
db?.close?.();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// A handle we are already discarding.
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
return db;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Get or create the singleton graph database instance.
|
|
133
|
+
*
|
|
134
|
+
* The promise is the singleton, not the handle: two callers racing the first
|
|
135
|
+
* call used to each run the constructor, and the second overwrote the first's
|
|
136
|
+
* `graphDb` while nodes were already being written through it.
|
|
137
|
+
*/
|
|
138
|
+
async function getGraphDb() {
|
|
139
|
+
if (!graphDbPromise) {
|
|
140
|
+
graphDbPromise = openGraphDb().catch((error) => {
|
|
141
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
142
|
+
warnGraphInit(`initialization failed: ${reason}. Graph backend disabled.`);
|
|
143
|
+
return null;
|
|
144
|
+
});
|
|
59
145
|
}
|
|
60
|
-
return
|
|
146
|
+
return graphDbPromise;
|
|
61
147
|
}
|
|
62
148
|
/**
|
|
63
149
|
* Create a minimal embedding for non-vector graph operations.
|
|
File without changes
|
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claude-flow/cli",
|
|
3
|
-
"version": "3.42.
|
|
3
|
+
"version": "3.42.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Ruflo CLI - Enterprise AI agent orchestration with 60+ specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
|
|
6
6
|
"main": "dist/src/index.js",
|