@claude-flow/cli 3.42.5 → 3.43.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/helpers/helpers.manifest.json +2 -2
- package/.claude/helpers/router.js +1 -1
- package/catalog-manifest.json +2 -2
- package/dist/src/commands/doctor.js +22 -1
- package/dist/src/commands/hooks.js +9 -3
- package/dist/src/init/helpers-generator.js +11 -10
- package/dist/src/mcp-tools/capability-brain.js +4 -2
- package/dist/src/mcp-tools/hooks-tools.d.ts +5 -0
- package/dist/src/mcp-tools/hooks-tools.js +44 -11
- package/dist/src/mcp-tools/memory-tools.js +42 -0
- package/dist/src/memory/graph-edge-writer.d.ts +10 -0
- package/dist/src/memory/graph-edge-writer.js +77 -1
- package/dist/src/memory/memory-bridge.d.ts +6 -0
- package/dist/src/memory/memory-bridge.js +76 -28
- package/dist/src/memory/memory-initializer.d.ts +3 -0
- package/dist/src/memory/memory-initializer.js +53 -11
- package/dist/src/ruvector/typesafe-router.d.ts +119 -0
- package/dist/src/ruvector/typesafe-router.js +245 -0
- package/dist/src/services/policy-runtime.js +40 -11
- package/package.json +5 -1
|
@@ -801,6 +801,52 @@ async function rescueAgentdbEmbedder(agentdb) {
|
|
|
801
801
|
emb.__ruvectorRescued = true;
|
|
802
802
|
_embedderPatched = true;
|
|
803
803
|
}
|
|
804
|
+
/**
|
|
805
|
+
* #3325: embed text for a bridge row or query.
|
|
806
|
+
*
|
|
807
|
+
* AgentDB's embedder is tried first. If it is absent or throws, fall back to
|
|
808
|
+
* the LOCAL chain — `generateLocalEmbedding`, never the bridge-first
|
|
809
|
+
* `generateEmbedding`, which would re-enter this bridge (#2312). Only a real
|
|
810
|
+
* ONNX vector is accepted from the local chain; a hash vector would make the
|
|
811
|
+
* row look embedded while carrying no meaning. Previously both failure modes
|
|
812
|
+
* were a bare `catch {}` that stored embedding=NULL and reported nothing.
|
|
813
|
+
*/
|
|
814
|
+
async function embedForBridge(agentdb, text) {
|
|
815
|
+
let agentdbProblem;
|
|
816
|
+
const embedder = agentdb?.embedder;
|
|
817
|
+
// Same mock signal bridgeGenerateEmbedding honours (AUDIT #3): the rescue
|
|
818
|
+
// tags a degraded embedder backend='mock' when it cannot replace it.
|
|
819
|
+
const agentdbIsMock = embedder?.isMock === true || embedder?.backend === 'mock';
|
|
820
|
+
if (agentdbIsMock) {
|
|
821
|
+
agentdbProblem = 'agentdb embedder is serving mock vectors';
|
|
822
|
+
}
|
|
823
|
+
else if (embedder && typeof embedder.embed === 'function') {
|
|
824
|
+
try {
|
|
825
|
+
const emb = await embedder.embed(text);
|
|
826
|
+
if (emb && emb.length > 0) {
|
|
827
|
+
return { vector: Array.from(emb), model: 'Xenova/all-MiniLM-L6-v2' };
|
|
828
|
+
}
|
|
829
|
+
agentdbProblem = 'agentdb embedder returned no vector';
|
|
830
|
+
}
|
|
831
|
+
catch (err) {
|
|
832
|
+
agentdbProblem = `agentdb embedder threw: ${err instanceof Error ? err.message : String(err)}`;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
else {
|
|
836
|
+
agentdbProblem = 'agentdb embedder unavailable';
|
|
837
|
+
}
|
|
838
|
+
try {
|
|
839
|
+
const { generateLocalEmbedding } = await import('./memory-initializer.js');
|
|
840
|
+
const local = await generateLocalEmbedding(text);
|
|
841
|
+
if (local.backend === 'onnx' && local.embedding.length > 0) {
|
|
842
|
+
return { vector: Array.from(local.embedding), model: local.model };
|
|
843
|
+
}
|
|
844
|
+
return { vector: null, reason: `${agentdbProblem}; local embedding chain has no real model (backend=${local.backend})` };
|
|
845
|
+
}
|
|
846
|
+
catch (err) {
|
|
847
|
+
return { vector: null, reason: `${agentdbProblem}; local embedding chain failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
848
|
+
}
|
|
849
|
+
}
|
|
804
850
|
// ===== Bridge functions — match memory-initializer.ts signatures =====
|
|
805
851
|
/**
|
|
806
852
|
* Store an entry via AgentDB v3.
|
|
@@ -851,26 +897,24 @@ export async function bridgeStoreEntry(options) {
|
|
|
851
897
|
if (!guardResult.allowed) {
|
|
852
898
|
return { success: false, id, error: `MutationGuard rejected: ${guardResult.reason}` };
|
|
853
899
|
}
|
|
854
|
-
// Generate embedding via AgentDB's embedder
|
|
900
|
+
// Generate embedding via AgentDB's embedder, falling back to the local
|
|
901
|
+
// chain (#3325). If neither can produce one, the row is still written but
|
|
902
|
+
// the result says why instead of silently storing embedding=NULL.
|
|
855
903
|
let embeddingJson = null;
|
|
856
904
|
let embeddingArr = null;
|
|
857
905
|
let dimensions = 0;
|
|
858
906
|
let model = 'local';
|
|
907
|
+
let embeddingError;
|
|
859
908
|
if (options.generateEmbeddingFlag !== false && value.length > 0) {
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
embeddingJson = JSON.stringify(embeddingArr);
|
|
867
|
-
dimensions = emb.length;
|
|
868
|
-
model = 'Xenova/all-MiniLM-L6-v2';
|
|
869
|
-
}
|
|
870
|
-
}
|
|
909
|
+
const emb = await embedForBridge(ctx.agentdb, value);
|
|
910
|
+
if (emb.vector) {
|
|
911
|
+
embeddingArr = emb.vector;
|
|
912
|
+
embeddingJson = JSON.stringify(embeddingArr);
|
|
913
|
+
dimensions = embeddingArr.length;
|
|
914
|
+
model = emb.model;
|
|
871
915
|
}
|
|
872
|
-
|
|
873
|
-
|
|
916
|
+
else {
|
|
917
|
+
embeddingError = emb.reason;
|
|
874
918
|
}
|
|
875
919
|
}
|
|
876
920
|
// #2775: strict-insert path now auto-resurrects soft-deleted tombstones
|
|
@@ -1011,6 +1055,7 @@ export async function bridgeStoreEntry(options) {
|
|
|
1011
1055
|
cached: true,
|
|
1012
1056
|
attested: true,
|
|
1013
1057
|
...(persistWarning ? { persistWarning } : {}),
|
|
1058
|
+
...(embeddingError ? { embeddingError } : {}),
|
|
1014
1059
|
};
|
|
1015
1060
|
}
|
|
1016
1061
|
catch (err) {
|
|
@@ -1061,18 +1106,10 @@ export async function bridgeSearchEntries(options) {
|
|
|
1061
1106
|
const { query: queryStr, namespace, limit = 10, threshold = 0.3, provenanceFilter } = options;
|
|
1062
1107
|
const effectiveNamespace = namespace || 'all';
|
|
1063
1108
|
const startTime = Date.now();
|
|
1064
|
-
// Generate query embedding
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
if (embedder) {
|
|
1069
|
-
const emb = await embedder.embed(queryStr);
|
|
1070
|
-
queryEmbedding = Array.from(emb);
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
catch {
|
|
1074
|
-
// Fall back to keyword search
|
|
1075
|
-
}
|
|
1109
|
+
// Generate query embedding — same agentdb-then-local chain as the write
|
|
1110
|
+
// path (#3325), so rows embedded by the local fallback are searchable.
|
|
1111
|
+
// No vector → keyword (BM25) search only.
|
|
1112
|
+
const queryEmbedding = (await embedForBridge(ctx.agentdb, queryStr)).vector;
|
|
1076
1113
|
// better-sqlite3: .prepare().all() returns array of objects
|
|
1077
1114
|
// ADR-323: compose namespace + provenance filters into one WHERE clause.
|
|
1078
1115
|
const filters = [];
|
|
@@ -1300,7 +1337,10 @@ export async function bridgeGetEntry(options) {
|
|
|
1300
1337
|
accessCount: cached.accessCount ?? 0,
|
|
1301
1338
|
createdAt: cached.createdAt || new Date().toISOString(),
|
|
1302
1339
|
updatedAt: cached.updatedAt || new Date().toISOString(),
|
|
1303
|
-
|
|
1340
|
+
// #3325: the cache holds the `entry` object built below, which has
|
|
1341
|
+
// `hasEmbedding` but no `embedding` field — so `!!cached.embedding`
|
|
1342
|
+
// reported false on every cache hit, even for embedded rows.
|
|
1343
|
+
hasEmbedding: typeof cached.hasEmbedding === 'boolean' ? cached.hasEmbedding : !!cached.embedding,
|
|
1304
1344
|
tags: cached.tags || [],
|
|
1305
1345
|
},
|
|
1306
1346
|
};
|
|
@@ -1888,7 +1928,15 @@ export async function bridgeStorePattern(options) {
|
|
|
1888
1928
|
// was actually stored under. getEntry/memory_retrieve look up by `key`,
|
|
1889
1929
|
// so returning result.id here handed the caller a handle that can never
|
|
1890
1930
|
// be read back — return `patternId` (the real key) instead.
|
|
1891
|
-
|
|
1931
|
+
// #3325: say whether the row got a vector. Without one, Tier-1 (semantic)
|
|
1932
|
+
// pattern search cannot find it — report that here, at write time.
|
|
1933
|
+
return {
|
|
1934
|
+
success: true,
|
|
1935
|
+
patternId,
|
|
1936
|
+
controller: 'bridge-fallback',
|
|
1937
|
+
hasEmbedding: !!result.embedding,
|
|
1938
|
+
...(result.embeddingError ? { embeddingError: result.embeddingError } : {}),
|
|
1939
|
+
};
|
|
1892
1940
|
}
|
|
1893
1941
|
catch (err) {
|
|
1894
1942
|
// #3327 Finding A — this catch is what hid the defect for months. When
|
|
@@ -452,6 +452,9 @@ export declare function storeEntry(options: {
|
|
|
452
452
|
/** #2968: set when the bridge's checkpoint failed in a way indicating
|
|
453
453
|
* this write may not be durably persisted (sql.js fallback driver). */
|
|
454
454
|
persistWarning?: string;
|
|
455
|
+
/** #3325: set by the bridge when an embedding was requested but none could
|
|
456
|
+
* be produced — the row is stored without a vector. */
|
|
457
|
+
embeddingError?: string;
|
|
455
458
|
}>;
|
|
456
459
|
/**
|
|
457
460
|
* Search entries using sql.js with vector similarity
|
|
@@ -81,6 +81,23 @@ function hasNativeWalSidecars(dbPath) {
|
|
|
81
81
|
return true;
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* #3397 — this process's own graph-edge-writer handle keeps -wal/-shm on disk
|
|
86
|
+
* between its idle-release ticks. Release (checkpoint + close) it before the
|
|
87
|
+
* #2735 guard runs, so the guard only sees sidecars some OTHER native
|
|
88
|
+
* connection is holding. The guard itself is unchanged: even a same-process
|
|
89
|
+
* open WAL connection makes a whole-image sql.js write unsafe until its WAL
|
|
90
|
+
* has been checkpointed, which is exactly what releasing does.
|
|
91
|
+
*/
|
|
92
|
+
async function releaseOwnNativeHandle(dbPath) {
|
|
93
|
+
try {
|
|
94
|
+
const { releaseBridgeDb } = await import('./graph-edge-writer.js');
|
|
95
|
+
releaseBridgeDb(dbPath);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Writer module unavailable — nothing of ours to release; the guard decides.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
84
101
|
/**
|
|
85
102
|
* #1854: previously every site that needed the memory directory hardcoded
|
|
86
103
|
* `getMemoryRoot()`, so the documented config entry
|
|
@@ -1957,7 +1974,17 @@ export async function applyTemporalDecay(dbPath) {
|
|
|
1957
1974
|
};
|
|
1958
1975
|
}
|
|
1959
1976
|
}
|
|
1977
|
+
/**
|
|
1978
|
+
* State of the LOCAL embedding chain only (transformers.js / agentic-flow /
|
|
1979
|
+
* ruvector ONNX / hash). #3375: the AgentDB bridge's result is cached
|
|
1980
|
+
* separately in `bridgeEmbeddingInfo` and must never be written here — it used
|
|
1981
|
+
* to be recorded as `{ loaded: true, model: null }`, which made
|
|
1982
|
+
* generateLocalEmbedding() skip loading any local model and always return the
|
|
1983
|
+
* hash fallback, so rescueAgentdbEmbedder()'s `backend === 'onnx'` probe could
|
|
1984
|
+
* never pass.
|
|
1985
|
+
*/
|
|
1960
1986
|
let embeddingModelState = null;
|
|
1987
|
+
let bridgeEmbeddingInfo = null;
|
|
1961
1988
|
/**
|
|
1962
1989
|
* Lazy load ONNX embedding model
|
|
1963
1990
|
* Only loads when first embedding is requested
|
|
@@ -1966,10 +1993,11 @@ export async function loadEmbeddingModel(options) {
|
|
|
1966
1993
|
const { verbose = false } = options || {};
|
|
1967
1994
|
const startTime = Date.now();
|
|
1968
1995
|
// Already loaded
|
|
1969
|
-
|
|
1996
|
+
const cached = bridgeEmbeddingInfo ?? (embeddingModelState?.loaded ? embeddingModelState : null);
|
|
1997
|
+
if (cached) {
|
|
1970
1998
|
return {
|
|
1971
1999
|
success: true,
|
|
1972
|
-
dimensions:
|
|
2000
|
+
dimensions: cached.dimensions,
|
|
1973
2001
|
modelName: 'cached',
|
|
1974
2002
|
loadTime: 0
|
|
1975
2003
|
};
|
|
@@ -1979,16 +2007,24 @@ export async function loadEmbeddingModel(options) {
|
|
|
1979
2007
|
if (bridge) {
|
|
1980
2008
|
const bridgeResult = await bridge.bridgeLoadEmbeddingModel();
|
|
1981
2009
|
if (bridgeResult && bridgeResult.success) {
|
|
1982
|
-
//
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
model: null, // Bridge handles embedding
|
|
1986
|
-
tokenizer: null,
|
|
1987
|
-
dimensions: bridgeResult.dimensions
|
|
1988
|
-
};
|
|
2010
|
+
// #3375: cache the bridge result on its own. Do NOT mark the local
|
|
2011
|
+
// chain as loaded — the bridge's model is not callable from here.
|
|
2012
|
+
bridgeEmbeddingInfo = { dimensions: bridgeResult.dimensions };
|
|
1989
2013
|
return bridgeResult;
|
|
1990
2014
|
}
|
|
1991
2015
|
}
|
|
2016
|
+
return loadLocalEmbeddingChain(verbose, startTime);
|
|
2017
|
+
}
|
|
2018
|
+
/**
|
|
2019
|
+
* Load the LOCAL embedding chain into `embeddingModelState`, never consulting
|
|
2020
|
+
* the AgentDB bridge. Used by loadEmbeddingModel() after the bridge declines,
|
|
2021
|
+
* and directly by generateLocalEmbedding() so the "bridge-free" contract the
|
|
2022
|
+
* #2312 comment on that function describes actually holds (#3375).
|
|
2023
|
+
*/
|
|
2024
|
+
async function loadLocalEmbeddingChain(verbose = false, startTime = Date.now()) {
|
|
2025
|
+
if (embeddingModelState?.loaded) {
|
|
2026
|
+
return { success: true, dimensions: embeddingModelState.dimensions, modelName: 'cached', loadTime: 0 };
|
|
2027
|
+
}
|
|
1992
2028
|
try {
|
|
1993
2029
|
// ADR-094: prefer @huggingface/transformers (clears protobufjs <7.5.5
|
|
1994
2030
|
// critical RCE chain), fall back to legacy @xenova/transformers.
|
|
@@ -2195,9 +2231,12 @@ export async function generateEmbedding(text) {
|
|
|
2195
2231
|
* Keeping the local chain as its own export breaks that cycle structurally.
|
|
2196
2232
|
*/
|
|
2197
2233
|
export async function generateLocalEmbedding(text) {
|
|
2198
|
-
// Ensure model is loaded
|
|
2234
|
+
// Ensure the LOCAL model is loaded. #3375: this must not go through
|
|
2235
|
+
// loadEmbeddingModel(), which is bridge-first — when the bridge answered
|
|
2236
|
+
// there, no local model was ever loaded and this function always returned
|
|
2237
|
+
// the hash fallback.
|
|
2199
2238
|
if (!embeddingModelState?.loaded) {
|
|
2200
|
-
await
|
|
2239
|
+
await loadLocalEmbeddingChain();
|
|
2201
2240
|
}
|
|
2202
2241
|
// #2461: loadEmbeddingModel() can leave embeddingModelState null when an
|
|
2203
2242
|
// earlier loader (transformers fetch, ruvector init) throws and we never
|
|
@@ -2535,6 +2574,7 @@ export async function storeEntry(options) {
|
|
|
2535
2574
|
// this closes and its known residual (the narrow assess-then-write
|
|
2536
2575
|
// race). This check gates ensureSchemaColumns()'s own whole-image
|
|
2537
2576
|
// write below too, not just this function's.
|
|
2577
|
+
await releaseOwnNativeHandle(dbPath);
|
|
2538
2578
|
if (hasNativeWalSidecars(dbPath)) {
|
|
2539
2579
|
return {
|
|
2540
2580
|
success: false,
|
|
@@ -3043,6 +3083,7 @@ export async function getEntry(options) {
|
|
|
3043
3083
|
// this closes. Applies here too because the fallback's access_count
|
|
3044
3084
|
// bump is itself a whole-image write, not a lightweight read, even
|
|
3045
3085
|
// though this function's contract reads as a "get".
|
|
3086
|
+
await releaseOwnNativeHandle(dbPath);
|
|
3046
3087
|
if (hasNativeWalSidecars(dbPath)) {
|
|
3047
3088
|
return {
|
|
3048
3089
|
success: false,
|
|
@@ -3162,6 +3203,7 @@ export async function deleteEntry(options) {
|
|
|
3162
3203
|
}
|
|
3163
3204
|
// #2735 — see storeEntry's identical gate for the corruption mechanism
|
|
3164
3205
|
// this closes.
|
|
3206
|
+
await releaseOwnNativeHandle(dbPath);
|
|
3165
3207
|
if (hasNativeWalSidecars(dbPath)) {
|
|
3166
3208
|
return {
|
|
3167
3209
|
success: false,
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* typesafe-router.ts — opt-in `@ruvector/typesafe` augmentation for `hooks_route`.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors ADR-150's MetaHarness rules for optional integrations:
|
|
5
|
+
* 1. Removable — the package is loaded with a dynamic import only; any load,
|
|
6
|
+
* construction or decide error falls back to the existing router.
|
|
7
|
+
* 2. Opt-in — does nothing unless `CLAUDE_FLOW_ROUTER_TYPESAFE=1`. With the
|
|
8
|
+
* flag unset the package is never imported and the legacy result is
|
|
9
|
+
* returned unchanged (same object, no added fields).
|
|
10
|
+
* 3. Optional — declared as an optional peer of `@claude-flow/cli`.
|
|
11
|
+
* 4. Honest — the result carries `routedBy`, typesafe's confidence, abstain
|
|
12
|
+
* mass and `calibrated` flag verbatim. The default `hash` embedder is
|
|
13
|
+
* uncalibrated, so its confidence is reported as `confidenceCalibrated:
|
|
14
|
+
* false` and is never copied into `estimatedMetrics.successProbability`.
|
|
15
|
+
*
|
|
16
|
+
* Gate: typesafe's answer is used only when all hold —
|
|
17
|
+
* - `abstain <= maxAbstain` (default 0.30)
|
|
18
|
+
* - lift = top-1 probability × option count >= `minLift` (default 1.2, i.e.
|
|
19
|
+
* 20% above chance). Lift, not raw confidence, because confidence scales
|
|
20
|
+
* with the option count (~0.1 for ten agents) and differs per embedder.
|
|
21
|
+
* - top-1 beats the runner-up by >= `minMargin` (default 0.005); a uniform
|
|
22
|
+
* distribution — text that matches nothing — has margin 0.
|
|
23
|
+
* Otherwise the legacy route is kept and `typesafe.reason` says which gate failed.
|
|
24
|
+
*
|
|
25
|
+
* @module typesafe-router
|
|
26
|
+
*/
|
|
27
|
+
/** Keyword/agent table shape shared with hooks-tools' TASK_PATTERNS. */
|
|
28
|
+
export interface RoutingPatternLike {
|
|
29
|
+
keywords: string[];
|
|
30
|
+
agents: string[];
|
|
31
|
+
}
|
|
32
|
+
/** One `choice` option in typesafe's `{ what, not_for, examples }` form. */
|
|
33
|
+
export interface TypesafeCriterion {
|
|
34
|
+
what: string;
|
|
35
|
+
not_for?: string;
|
|
36
|
+
examples?: string[];
|
|
37
|
+
}
|
|
38
|
+
/** The subset of a typesafe choice answer this adapter reads. */
|
|
39
|
+
export interface TypesafeChoiceAnswer {
|
|
40
|
+
choice: string;
|
|
41
|
+
probabilities: Record<string, number>;
|
|
42
|
+
confidence: number;
|
|
43
|
+
abstain: number;
|
|
44
|
+
calibrated: boolean;
|
|
45
|
+
head?: string;
|
|
46
|
+
model?: string;
|
|
47
|
+
}
|
|
48
|
+
/** The subset of `@ruvector/typesafe`'s module surface this adapter uses. */
|
|
49
|
+
export interface TypesafeModuleLike {
|
|
50
|
+
createTypesafe(opts?: Record<string, unknown>): {
|
|
51
|
+
readonly backend?: string;
|
|
52
|
+
decide(state: string, questions: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
53
|
+
};
|
|
54
|
+
choice?(criteria: Record<string, TypesafeCriterion>): unknown;
|
|
55
|
+
}
|
|
56
|
+
export interface TypesafeRouterConfig {
|
|
57
|
+
enabled: boolean;
|
|
58
|
+
minLift: number;
|
|
59
|
+
maxAbstain: number;
|
|
60
|
+
minMargin: number;
|
|
61
|
+
/** `'hash'` (default, uncalibrated) or an ONNX model dir + manifest. */
|
|
62
|
+
embedder: 'hash' | {
|
|
63
|
+
kind: 'onnx';
|
|
64
|
+
modelDir: string;
|
|
65
|
+
manifest: string;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export interface TypesafeRouteOutcome {
|
|
69
|
+
used: boolean;
|
|
70
|
+
reason: string;
|
|
71
|
+
answer?: TypesafeChoiceAnswer;
|
|
72
|
+
backend?: string;
|
|
73
|
+
embedder?: string;
|
|
74
|
+
/** top-1 probability × option count (1.0 = chance). */
|
|
75
|
+
lift?: number;
|
|
76
|
+
thresholds: Pick<TypesafeRouterConfig, 'minLift' | 'maxAbstain' | 'minMargin'>;
|
|
77
|
+
}
|
|
78
|
+
/** Injectable deps (tests). `loadModule` defaults to a dynamic import of the package. */
|
|
79
|
+
export interface TypesafeRouterDeps {
|
|
80
|
+
env?: NodeJS.ProcessEnv;
|
|
81
|
+
loadModule?: () => Promise<unknown>;
|
|
82
|
+
debug?: (msg: string) => void;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Build choice options from the router's pattern table: every primary agent of
|
|
86
|
+
* a pattern, plus the profiled roles (researcher/reviewer) the table only lists
|
|
87
|
+
* as alternates. Each pattern's keywords are appended to its primary agent's
|
|
88
|
+
* `what`, so the options track TASK_PATTERNS rather than a parallel list.
|
|
89
|
+
*/
|
|
90
|
+
export declare function buildAgentCriteria(patterns: Record<string, RoutingPatternLike>): Record<string, TypesafeCriterion>;
|
|
91
|
+
/** Read config from env. Invalid numeric values fall back to the defaults. */
|
|
92
|
+
export declare function readTypesafeConfig(env?: NodeJS.ProcessEnv): TypesafeRouterConfig;
|
|
93
|
+
/**
|
|
94
|
+
* Stateful router: loads the module and builds one engine on first use, caches
|
|
95
|
+
* a load failure so a missing package costs one import attempt per process.
|
|
96
|
+
*/
|
|
97
|
+
export declare class TypesafeRouter {
|
|
98
|
+
private engine;
|
|
99
|
+
private mod;
|
|
100
|
+
private loadError;
|
|
101
|
+
private readonly env;
|
|
102
|
+
private readonly loadModule;
|
|
103
|
+
private readonly debug;
|
|
104
|
+
constructor(deps?: TypesafeRouterDeps);
|
|
105
|
+
isEnabled(): boolean;
|
|
106
|
+
private ensureEngine;
|
|
107
|
+
/** Ask typesafe for an agent. Never throws; `used: false` means keep the legacy route. */
|
|
108
|
+
route(task: string, patterns: Record<string, RoutingPatternLike>): Promise<TypesafeRouteOutcome>;
|
|
109
|
+
}
|
|
110
|
+
type RouteResult = Record<string, unknown>;
|
|
111
|
+
/**
|
|
112
|
+
* Merge a typesafe outcome into a legacy `hooks_route` result. Disabled or
|
|
113
|
+
* error results (`success: false`) pass through untouched.
|
|
114
|
+
*/
|
|
115
|
+
export declare function applyTypesafeRouting(params: Record<string, unknown>, legacy: RouteResult, patterns: Record<string, RoutingPatternLike>, router: TypesafeRouter): Promise<RouteResult>;
|
|
116
|
+
/** Process-wide router used by hooks_route. */
|
|
117
|
+
export declare function getTypesafeRouter(): TypesafeRouter;
|
|
118
|
+
export {};
|
|
119
|
+
//# sourceMappingURL=typesafe-router.d.ts.map
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* typesafe-router.ts — opt-in `@ruvector/typesafe` augmentation for `hooks_route`.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors ADR-150's MetaHarness rules for optional integrations:
|
|
5
|
+
* 1. Removable — the package is loaded with a dynamic import only; any load,
|
|
6
|
+
* construction or decide error falls back to the existing router.
|
|
7
|
+
* 2. Opt-in — does nothing unless `CLAUDE_FLOW_ROUTER_TYPESAFE=1`. With the
|
|
8
|
+
* flag unset the package is never imported and the legacy result is
|
|
9
|
+
* returned unchanged (same object, no added fields).
|
|
10
|
+
* 3. Optional — declared as an optional peer of `@claude-flow/cli`.
|
|
11
|
+
* 4. Honest — the result carries `routedBy`, typesafe's confidence, abstain
|
|
12
|
+
* mass and `calibrated` flag verbatim. The default `hash` embedder is
|
|
13
|
+
* uncalibrated, so its confidence is reported as `confidenceCalibrated:
|
|
14
|
+
* false` and is never copied into `estimatedMetrics.successProbability`.
|
|
15
|
+
*
|
|
16
|
+
* Gate: typesafe's answer is used only when all hold —
|
|
17
|
+
* - `abstain <= maxAbstain` (default 0.30)
|
|
18
|
+
* - lift = top-1 probability × option count >= `minLift` (default 1.2, i.e.
|
|
19
|
+
* 20% above chance). Lift, not raw confidence, because confidence scales
|
|
20
|
+
* with the option count (~0.1 for ten agents) and differs per embedder.
|
|
21
|
+
* - top-1 beats the runner-up by >= `minMargin` (default 0.005); a uniform
|
|
22
|
+
* distribution — text that matches nothing — has margin 0.
|
|
23
|
+
* Otherwise the legacy route is kept and `typesafe.reason` says which gate failed.
|
|
24
|
+
*
|
|
25
|
+
* @module typesafe-router
|
|
26
|
+
*/
|
|
27
|
+
const MODULE_ID = '@ruvector/typesafe';
|
|
28
|
+
/**
|
|
29
|
+
* Default loader. The variable specifier keeps tsc/bundlers off the optional peer;
|
|
30
|
+
* `require` (the package is CJS) covers hosts that rewrite dynamic import (vite-node).
|
|
31
|
+
* An absent package still surfaces as MODULE_NOT_FOUND → "not installed".
|
|
32
|
+
*/
|
|
33
|
+
async function loadTypesafeModule() {
|
|
34
|
+
try {
|
|
35
|
+
return await import(/* @vite-ignore */ MODULE_ID);
|
|
36
|
+
}
|
|
37
|
+
catch (importErr) {
|
|
38
|
+
try {
|
|
39
|
+
const { createRequire } = await import('node:module');
|
|
40
|
+
return createRequire(import.meta.url)(MODULE_ID);
|
|
41
|
+
}
|
|
42
|
+
catch (requireErr) {
|
|
43
|
+
throw requireErr?.code === 'MODULE_NOT_FOUND' ? requireErr : importErr;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Agent descriptions + `not_for` hints that separate neighbouring roles. */
|
|
48
|
+
const AGENT_PROFILES = {
|
|
49
|
+
tester: { what: 'write and run unit tests, integration tests, e2e tests, test coverage and specs', not_for: 'reviewing code, researching issues or reading the latest news' },
|
|
50
|
+
reviewer: { what: 'review code quality, pull requests, diffs and best practices', not_for: 'writing tests, implementing features or researching background information' },
|
|
51
|
+
researcher: { what: 'research, investigate, explore, read and summarize issues, docs, discussions and prior art', not_for: 'writing code, writing tests or reviewing a diff' },
|
|
52
|
+
coder: { what: 'implement features, write code, fix bugs and build functionality', not_for: 'research, review or test-only work' },
|
|
53
|
+
architect: { what: 'design system architecture, module boundaries, APIs, schemas and refactoring plans', not_for: 'running tests or small bug fixes' },
|
|
54
|
+
'security-architect': { what: 'security, authentication, authorization, encryption, vulnerabilities, CVEs and audits', not_for: 'general feature work or performance tuning' },
|
|
55
|
+
'performance-engineer': { what: 'performance optimization, profiling, benchmarks, latency and bottlenecks', not_for: 'security review or writing documentation' },
|
|
56
|
+
devops: { what: 'deployment, CI/CD pipelines, docker, kubernetes and infrastructure', not_for: 'application feature code or unit tests' },
|
|
57
|
+
'memory-specialist': { what: 'memory systems, caches, vector stores, embeddings and persistence', not_for: 'UI work or deployment pipelines' },
|
|
58
|
+
'swarm-specialist': { what: 'multi-agent swarms, coordinators, hive-mind, mesh topology and agent orchestration', not_for: 'single-file code edits' },
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Build choice options from the router's pattern table: every primary agent of
|
|
62
|
+
* a pattern, plus the profiled roles (researcher/reviewer) the table only lists
|
|
63
|
+
* as alternates. Each pattern's keywords are appended to its primary agent's
|
|
64
|
+
* `what`, so the options track TASK_PATTERNS rather than a parallel list.
|
|
65
|
+
*/
|
|
66
|
+
export function buildAgentCriteria(patterns) {
|
|
67
|
+
const keywordsByAgent = new Map();
|
|
68
|
+
for (const { agents, keywords } of Object.values(patterns)) {
|
|
69
|
+
const primary = agents[0];
|
|
70
|
+
if (!primary)
|
|
71
|
+
continue;
|
|
72
|
+
const set = keywordsByAgent.get(primary) ?? new Set();
|
|
73
|
+
keywords.forEach(k => set.add(k));
|
|
74
|
+
keywordsByAgent.set(primary, set);
|
|
75
|
+
}
|
|
76
|
+
for (const agent of ['researcher', 'reviewer', 'tester', 'coder']) {
|
|
77
|
+
if (!keywordsByAgent.has(agent))
|
|
78
|
+
keywordsByAgent.set(agent, new Set());
|
|
79
|
+
}
|
|
80
|
+
const criteria = {};
|
|
81
|
+
for (const [agent, kws] of keywordsByAgent) {
|
|
82
|
+
const profile = AGENT_PROFILES[agent] ?? { what: `${agent.replace(/-/g, ' ')} tasks` };
|
|
83
|
+
const kw = [...kws].join(', ');
|
|
84
|
+
criteria[agent] = {
|
|
85
|
+
what: kw ? `${profile.what}; keywords: ${kw}` : profile.what,
|
|
86
|
+
...(profile.not_for ? { not_for: profile.not_for } : {}),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return criteria;
|
|
90
|
+
}
|
|
91
|
+
function num(raw, dflt, max = 1) {
|
|
92
|
+
const n = raw === undefined || raw === '' ? NaN : Number(raw);
|
|
93
|
+
return Number.isFinite(n) && n >= 0 && n <= max ? n : dflt;
|
|
94
|
+
}
|
|
95
|
+
/** Read config from env. Invalid numeric values fall back to the defaults. */
|
|
96
|
+
export function readTypesafeConfig(env = process.env) {
|
|
97
|
+
const modelDir = env.CLAUDE_FLOW_ROUTER_TYPESAFE_MODEL_DIR;
|
|
98
|
+
const manifest = env.CLAUDE_FLOW_ROUTER_TYPESAFE_MANIFEST;
|
|
99
|
+
return {
|
|
100
|
+
enabled: env.CLAUDE_FLOW_ROUTER_TYPESAFE === '1',
|
|
101
|
+
minLift: num(env.CLAUDE_FLOW_ROUTER_TYPESAFE_MIN_LIFT, 1.2, 255),
|
|
102
|
+
maxAbstain: num(env.CLAUDE_FLOW_ROUTER_TYPESAFE_MAX_ABSTAIN, 0.3),
|
|
103
|
+
minMargin: num(env.CLAUDE_FLOW_ROUTER_TYPESAFE_MIN_MARGIN, 0.005),
|
|
104
|
+
embedder: modelDir && manifest ? { kind: 'onnx', modelDir, manifest } : 'hash',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Stateful router: loads the module and builds one engine on first use, caches
|
|
109
|
+
* a load failure so a missing package costs one import attempt per process.
|
|
110
|
+
*/
|
|
111
|
+
export class TypesafeRouter {
|
|
112
|
+
engine = null;
|
|
113
|
+
mod = null;
|
|
114
|
+
loadError = null;
|
|
115
|
+
env;
|
|
116
|
+
loadModule;
|
|
117
|
+
debug;
|
|
118
|
+
constructor(deps = {}) {
|
|
119
|
+
this.env = deps.env ?? process.env;
|
|
120
|
+
this.loadModule = deps.loadModule ?? loadTypesafeModule;
|
|
121
|
+
this.debug = deps.debug ?? ((m) => { if (this.env.CLAUDE_FLOW_LOG_LEVEL === 'debug')
|
|
122
|
+
console.error(`[typesafe-router] ${m}`); });
|
|
123
|
+
}
|
|
124
|
+
isEnabled() { return readTypesafeConfig(this.env).enabled; }
|
|
125
|
+
async ensureEngine(cfg) {
|
|
126
|
+
if (this.engine || this.loadError)
|
|
127
|
+
return this.engine;
|
|
128
|
+
try {
|
|
129
|
+
const raw = (await this.loadModule());
|
|
130
|
+
const mod = (typeof raw.createTypesafe === 'function' ? raw : raw.default);
|
|
131
|
+
if (!mod || typeof mod.createTypesafe !== 'function')
|
|
132
|
+
throw new Error('module has no createTypesafe export');
|
|
133
|
+
this.mod = mod;
|
|
134
|
+
this.engine = mod.createTypesafe({ embedder: cfg.embedder });
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
const e = err;
|
|
138
|
+
this.loadError = e?.code === 'ERR_MODULE_NOT_FOUND' || e?.code === 'MODULE_NOT_FOUND'
|
|
139
|
+
? `${MODULE_ID} not installed`
|
|
140
|
+
: `${MODULE_ID} failed to load: ${e?.message ?? String(err)}`;
|
|
141
|
+
this.debug(this.loadError);
|
|
142
|
+
}
|
|
143
|
+
return this.engine;
|
|
144
|
+
}
|
|
145
|
+
/** Ask typesafe for an agent. Never throws; `used: false` means keep the legacy route. */
|
|
146
|
+
async route(task, patterns) {
|
|
147
|
+
const cfg = readTypesafeConfig(this.env);
|
|
148
|
+
const thresholds = { minLift: cfg.minLift, maxAbstain: cfg.maxAbstain, minMargin: cfg.minMargin };
|
|
149
|
+
const embedder = cfg.embedder === 'hash' ? 'hash' : 'onnx';
|
|
150
|
+
if (!cfg.enabled)
|
|
151
|
+
return { used: false, reason: 'CLAUDE_FLOW_ROUTER_TYPESAFE is not 1', thresholds };
|
|
152
|
+
const engine = await this.ensureEngine(cfg);
|
|
153
|
+
if (!engine)
|
|
154
|
+
return { used: false, reason: this.loadError ?? 'typesafe unavailable', thresholds, embedder };
|
|
155
|
+
let answer;
|
|
156
|
+
try {
|
|
157
|
+
const criteria = buildAgentCriteria(patterns);
|
|
158
|
+
const question = this.mod?.choice ? this.mod.choice(criteria) : { type: 'choice', criteria };
|
|
159
|
+
const res = await engine.decide(task, { agent: question });
|
|
160
|
+
answer = res.agent;
|
|
161
|
+
if (!answer || typeof answer.choice !== 'string')
|
|
162
|
+
throw new Error('no choice in answer');
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
const reason = `typesafe decide failed: ${err?.message ?? String(err)}`;
|
|
166
|
+
this.debug(reason);
|
|
167
|
+
return { used: false, reason, thresholds, embedder, backend: engine.backend };
|
|
168
|
+
}
|
|
169
|
+
const probs = Object.values(answer.probabilities ?? {}).sort((a, b) => b - a);
|
|
170
|
+
const margin = (probs[0] ?? 0) - (probs[1] ?? 0);
|
|
171
|
+
const lift = (probs[0] ?? 0) * probs.length;
|
|
172
|
+
const base = { answer, lift, thresholds, embedder, backend: engine.backend };
|
|
173
|
+
const f = (n) => n.toFixed(2);
|
|
174
|
+
if (answer.abstain > cfg.maxAbstain) {
|
|
175
|
+
return { ...base, used: false, reason: `typesafe abstain ${f(answer.abstain)} > max ${f(cfg.maxAbstain)}; kept existing router` };
|
|
176
|
+
}
|
|
177
|
+
if (lift < cfg.minLift) {
|
|
178
|
+
return { ...base, used: false, reason: `typesafe lift ${f(lift)} (top-1 × ${probs.length} options) < min ${f(cfg.minLift)}; kept existing router` };
|
|
179
|
+
}
|
|
180
|
+
if (margin < cfg.minMargin) {
|
|
181
|
+
return { ...base, used: false, reason: `typesafe top-1 margin ${margin.toFixed(3)} < min ${cfg.minMargin.toFixed(3)} (no clear winner); kept existing router` };
|
|
182
|
+
}
|
|
183
|
+
return { ...base, used: true, reason: `typesafe choice "${answer.choice}" (confidence ${f(answer.confidence)}, lift ${f(lift)}, abstain ${f(answer.abstain)}, ${answer.calibrated ? 'calibrated' : 'UNCALIBRATED'} ${embedder} embedder)` };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const round2 = (n) => Math.round(n * 100) / 100;
|
|
187
|
+
/**
|
|
188
|
+
* Merge a typesafe outcome into a legacy `hooks_route` result. Disabled or
|
|
189
|
+
* error results (`success: false`) pass through untouched.
|
|
190
|
+
*/
|
|
191
|
+
export async function applyTypesafeRouting(params, legacy, patterns, router) {
|
|
192
|
+
if (!router.isEnabled() || !legacy || legacy.success === false || typeof params.task !== 'string')
|
|
193
|
+
return legacy;
|
|
194
|
+
const text = typeof params.context === 'string' && params.context ? `${params.task} ${params.context}` : params.task;
|
|
195
|
+
const outcome = await router.route(text, patterns);
|
|
196
|
+
const legacyRouting = (legacy.routing ?? {});
|
|
197
|
+
const legacyPrimary = (legacy.primaryAgent ?? {});
|
|
198
|
+
const a = outcome.answer;
|
|
199
|
+
const typesafe = {
|
|
200
|
+
used: outcome.used,
|
|
201
|
+
reason: outcome.reason,
|
|
202
|
+
...(a ? {
|
|
203
|
+
choice: a.choice,
|
|
204
|
+
confidence: round2(a.confidence),
|
|
205
|
+
abstain: round2(a.abstain),
|
|
206
|
+
lift: outcome.lift === undefined ? undefined : round2(outcome.lift),
|
|
207
|
+
calibrated: a.calibrated,
|
|
208
|
+
head: a.head,
|
|
209
|
+
model: a.model,
|
|
210
|
+
probabilities: Object.fromEntries(Object.entries(a.probabilities).map(([k, v]) => [k, round2(v)])),
|
|
211
|
+
} : {}),
|
|
212
|
+
backend: outcome.backend,
|
|
213
|
+
embedder: outcome.embedder,
|
|
214
|
+
thresholds: outcome.thresholds,
|
|
215
|
+
};
|
|
216
|
+
if (!outcome.used || !a) {
|
|
217
|
+
return { ...legacy, routedBy: String(legacyRouting.method ?? 'legacy'), typesafe };
|
|
218
|
+
}
|
|
219
|
+
const alternatives = Object.entries(a.probabilities)
|
|
220
|
+
.filter(([k]) => k !== a.choice)
|
|
221
|
+
.sort((x, y) => y[1] - x[1])
|
|
222
|
+
.slice(0, 2)
|
|
223
|
+
.map(([type, p]) => ({ type, confidence: round2(p), reason: 'typesafe runner-up (probability share)' }));
|
|
224
|
+
return {
|
|
225
|
+
...legacy,
|
|
226
|
+
routing: { ...legacyRouting, method: 'typesafe', backend: `@ruvector/typesafe (${outcome.backend ?? 'unknown'}, ${outcome.embedder} embedder)` },
|
|
227
|
+
routedBy: 'typesafe',
|
|
228
|
+
matchedPattern: `typesafe:${a.choice}`,
|
|
229
|
+
primaryAgent: {
|
|
230
|
+
type: a.choice,
|
|
231
|
+
confidence: round2(a.confidence),
|
|
232
|
+
confidenceCalibrated: a.calibrated,
|
|
233
|
+
reason: outcome.reason,
|
|
234
|
+
},
|
|
235
|
+
alternativeAgents: alternatives,
|
|
236
|
+
fallbackRoute: { agent: legacyPrimary.type, confidence: legacyPrimary.confidence, method: legacyRouting.method },
|
|
237
|
+
typesafe,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
let sharedRouter = null;
|
|
241
|
+
/** Process-wide router used by hooks_route. */
|
|
242
|
+
export function getTypesafeRouter() {
|
|
243
|
+
return (sharedRouter ??= new TypesafeRouter());
|
|
244
|
+
}
|
|
245
|
+
//# sourceMappingURL=typesafe-router.js.map
|