byterover-cli 3.2.0 → 3.4.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/.env.production +0 -4
- package/dist/agent/core/domain/swarm/types.d.ts +132 -0
- package/dist/agent/core/domain/swarm/types.js +128 -0
- package/dist/agent/core/domain/tools/constants.d.ts +2 -0
- package/dist/agent/core/domain/tools/constants.js +2 -0
- package/dist/agent/core/interfaces/i-memory-provider.d.ts +45 -0
- package/dist/agent/core/interfaces/i-memory-provider.js +1 -0
- package/dist/agent/core/interfaces/i-sandbox-service.d.ts +8 -0
- package/dist/agent/core/interfaces/i-swarm-coordinator.d.ts +127 -0
- package/dist/agent/core/interfaces/i-swarm-coordinator.js +1 -0
- package/dist/agent/infra/agent/service-initializer.js +48 -0
- package/dist/agent/infra/map/map-shared.d.ts +2 -2
- package/dist/agent/infra/sandbox/sandbox-service.d.ts +10 -0
- package/dist/agent/infra/sandbox/sandbox-service.js +13 -0
- package/dist/agent/infra/sandbox/tools-sdk.d.ts +25 -0
- package/dist/agent/infra/sandbox/tools-sdk.js +24 -1
- package/dist/agent/infra/swarm/adapters/byterover-adapter.d.ts +39 -0
- package/dist/agent/infra/swarm/adapters/byterover-adapter.js +62 -0
- package/dist/agent/infra/swarm/adapters/gbrain-adapter.d.ts +63 -0
- package/dist/agent/infra/swarm/adapters/gbrain-adapter.js +209 -0
- package/dist/agent/infra/swarm/adapters/local-markdown-adapter.d.ts +41 -0
- package/dist/agent/infra/swarm/adapters/local-markdown-adapter.js +256 -0
- package/dist/agent/infra/swarm/adapters/memory-wiki-adapter.d.ts +29 -0
- package/dist/agent/infra/swarm/adapters/memory-wiki-adapter.js +244 -0
- package/dist/agent/infra/swarm/adapters/obsidian-adapter.d.ts +37 -0
- package/dist/agent/infra/swarm/adapters/obsidian-adapter.js +201 -0
- package/dist/agent/infra/swarm/cli/query-renderer.d.ts +15 -0
- package/dist/agent/infra/swarm/cli/query-renderer.js +126 -0
- package/dist/agent/infra/swarm/config/swarm-config-loader.d.ts +14 -0
- package/dist/agent/infra/swarm/config/swarm-config-loader.js +82 -0
- package/dist/agent/infra/swarm/config/swarm-config-schema.d.ts +667 -0
- package/dist/agent/infra/swarm/config/swarm-config-schema.js +305 -0
- package/dist/agent/infra/swarm/provider-factory.d.ts +21 -0
- package/dist/agent/infra/swarm/provider-factory.js +67 -0
- package/dist/agent/infra/swarm/search-precision.d.ts +95 -0
- package/dist/agent/infra/swarm/search-precision.js +141 -0
- package/dist/agent/infra/swarm/swarm-coordinator.d.ts +59 -0
- package/dist/agent/infra/swarm/swarm-coordinator.js +436 -0
- package/dist/agent/infra/swarm/swarm-graph.d.ts +63 -0
- package/dist/agent/infra/swarm/swarm-graph.js +167 -0
- package/dist/agent/infra/swarm/swarm-merger.d.ts +29 -0
- package/dist/agent/infra/swarm/swarm-merger.js +66 -0
- package/dist/agent/infra/swarm/swarm-router.d.ts +12 -0
- package/dist/agent/infra/swarm/swarm-router.js +40 -0
- package/dist/agent/infra/swarm/swarm-write-router.d.ts +23 -0
- package/dist/agent/infra/swarm/swarm-write-router.js +45 -0
- package/dist/agent/infra/swarm/validation/config-validator.d.ts +16 -0
- package/dist/agent/infra/swarm/validation/config-validator.js +402 -0
- package/dist/agent/infra/swarm/validation/memory-swarm-validation-error.d.ts +33 -0
- package/dist/agent/infra/swarm/validation/memory-swarm-validation-error.js +27 -0
- package/dist/agent/infra/swarm/wizard/config-scaffolder.d.ts +36 -0
- package/dist/agent/infra/swarm/wizard/config-scaffolder.js +96 -0
- package/dist/agent/infra/swarm/wizard/provider-detector.d.ts +54 -0
- package/dist/agent/infra/swarm/wizard/provider-detector.js +153 -0
- package/dist/agent/infra/swarm/wizard/swarm-wizard.d.ts +61 -0
- package/dist/agent/infra/swarm/wizard/swarm-wizard.js +187 -0
- package/dist/agent/infra/system-prompt/contributors/index.d.ts +1 -0
- package/dist/agent/infra/system-prompt/contributors/index.js +1 -0
- package/dist/agent/infra/system-prompt/contributors/swarm-state-contributor.d.ts +15 -0
- package/dist/agent/infra/system-prompt/contributors/swarm-state-contributor.js +65 -0
- package/dist/agent/infra/tools/implementations/curate-tool.d.ts +14 -14
- package/dist/agent/infra/tools/implementations/curate-tool.js +2 -0
- package/dist/agent/infra/tools/implementations/search-knowledge-service.js +12 -2
- package/dist/agent/infra/tools/implementations/swarm-query-tool.d.ts +9 -0
- package/dist/agent/infra/tools/implementations/swarm-query-tool.js +44 -0
- package/dist/agent/infra/tools/implementations/swarm-store-tool.d.ts +9 -0
- package/dist/agent/infra/tools/implementations/swarm-store-tool.js +43 -0
- package/dist/agent/infra/tools/tool-provider.js +1 -0
- package/dist/agent/infra/tools/tool-registry.d.ts +3 -0
- package/dist/agent/infra/tools/tool-registry.js +25 -1
- package/dist/agent/resources/tools/code_exec.txt +2 -0
- package/dist/agent/resources/tools/swarm_query.txt +38 -0
- package/dist/agent/resources/tools/swarm_store.txt +35 -0
- package/dist/oclif/commands/curate/index.d.ts +1 -0
- package/dist/oclif/commands/curate/index.js +15 -1
- package/dist/oclif/commands/query.d.ts +1 -0
- package/dist/oclif/commands/query.js +17 -3
- package/dist/oclif/commands/search.d.ts +20 -0
- package/dist/oclif/commands/search.js +186 -0
- package/dist/oclif/commands/status.js +4 -0
- package/dist/oclif/commands/swarm/curate.d.ts +13 -0
- package/dist/oclif/commands/swarm/curate.js +81 -0
- package/dist/oclif/commands/swarm/onboard.d.ts +6 -0
- package/dist/oclif/commands/swarm/onboard.js +233 -0
- package/dist/oclif/commands/swarm/query.d.ts +14 -0
- package/dist/oclif/commands/swarm/query.js +84 -0
- package/dist/oclif/commands/swarm/status.d.ts +41 -0
- package/dist/oclif/commands/swarm/status.js +278 -0
- package/dist/oclif/lib/daemon-client.js +0 -1
- package/dist/oclif/lib/search-format.d.ts +10 -0
- package/dist/oclif/lib/search-format.js +25 -0
- package/dist/oclif/lib/task-client.d.ts +6 -0
- package/dist/oclif/lib/task-client.js +10 -3
- package/dist/server/constants.d.ts +3 -2
- package/dist/server/constants.js +10 -7
- package/dist/server/core/domain/errors/task-error.d.ts +2 -2
- package/dist/server/core/domain/errors/task-error.js +5 -4
- package/dist/server/core/domain/source/source-schema.d.ts +6 -6
- package/dist/server/core/domain/transport/schemas.d.ts +14 -14
- package/dist/server/core/domain/transport/schemas.js +3 -3
- package/dist/server/core/interfaces/executor/i-search-executor.d.ts +34 -0
- package/dist/server/core/interfaces/executor/i-search-executor.js +1 -0
- package/dist/server/core/interfaces/executor/index.d.ts +1 -0
- package/dist/server/core/interfaces/executor/index.js +1 -0
- package/dist/server/infra/daemon/agent-process.js +20 -7
- package/dist/server/infra/executor/search-executor.d.ts +17 -0
- package/dist/server/infra/executor/search-executor.js +30 -0
- package/dist/server/infra/http/provider-model-fetchers.js +1 -0
- package/dist/server/infra/process/feature-handlers.js +13 -0
- package/dist/server/infra/project/project-registry.js +13 -1
- package/dist/server/infra/transport/handlers/locations-handler.d.ts +2 -0
- package/dist/server/infra/transport/handlers/locations-handler.js +16 -1
- package/dist/server/infra/transport/handlers/pull-handler.js +3 -3
- package/dist/server/infra/transport/handlers/push-handler.js +3 -3
- package/dist/server/infra/transport/handlers/status-handler.js +25 -18
- package/dist/server/infra/transport/handlers/vc-handler.d.ts +0 -4
- package/dist/server/infra/transport/handlers/vc-handler.js +5 -16
- package/dist/server/templates/skill/SKILL.md +188 -5
- package/dist/server/utils/gitignore.d.ts +1 -0
- package/dist/server/utils/gitignore.js +36 -4
- package/dist/shared/transport/search-content.d.ts +28 -0
- package/dist/shared/transport/search-content.js +38 -0
- package/dist/shared/transport/types/dto.d.ts +1 -1
- package/dist/tui/features/status/utils/format-status.js +5 -0
- package/dist/tui/utils/error-messages.js +2 -2
- package/oclif.manifest.json +581 -317
- package/package.json +2 -2
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fuse results from multiple providers using Weighted Reciprocal Rank Fusion.
|
|
3
|
+
*
|
|
4
|
+
* Algorithm:
|
|
5
|
+
* RRF_score(r) = Σᵢ wᵢ / (K + rankᵢ(r))
|
|
6
|
+
* where wᵢ is the provider weight and rankᵢ(r) is the 0-based rank.
|
|
7
|
+
*
|
|
8
|
+
* Deduplication: results with identical content are merged (highest provider weight kept).
|
|
9
|
+
*
|
|
10
|
+
* @param resultSets - Map of provider ID → ranked results
|
|
11
|
+
* @param providerWeights - Map of provider ID → weight (0-1)
|
|
12
|
+
* @param options - Merger options
|
|
13
|
+
* @returns Fused, ranked, deduplicated results
|
|
14
|
+
*/
|
|
15
|
+
export function mergeResults(resultSets, providerWeights, options) {
|
|
16
|
+
const K = options?.K ?? 60;
|
|
17
|
+
const maxResults = options?.maxResults ?? 10;
|
|
18
|
+
const minRRFScore = options?.minRRFScore;
|
|
19
|
+
const rrfGapRatio = options?.rrfGapRatio;
|
|
20
|
+
const deduped = new Map();
|
|
21
|
+
for (const [providerId, results] of resultSets) {
|
|
22
|
+
const weight = providerWeights.get(providerId) ?? 0.5;
|
|
23
|
+
const seenContent = new Set();
|
|
24
|
+
for (const [rank, result] of results.entries()) {
|
|
25
|
+
if (seenContent.has(result.content)) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
seenContent.add(result.content);
|
|
29
|
+
const existing = deduped.get(result.content) ?? [];
|
|
30
|
+
existing.push({ rank, result, weight });
|
|
31
|
+
deduped.set(result.content, existing);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const scored = [];
|
|
35
|
+
for (const [, occurrences] of deduped) {
|
|
36
|
+
let rrfScore = 0;
|
|
37
|
+
let bestContribution = -1;
|
|
38
|
+
let representative = occurrences[0]?.result;
|
|
39
|
+
for (const occurrence of occurrences) {
|
|
40
|
+
const contribution = occurrence.weight / (K + occurrence.rank);
|
|
41
|
+
rrfScore += contribution;
|
|
42
|
+
if (contribution > bestContribution ||
|
|
43
|
+
(contribution === bestContribution && occurrence.result.score > (representative?.score ?? -1))) {
|
|
44
|
+
bestContribution = contribution;
|
|
45
|
+
representative = occurrence.result;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (representative) {
|
|
49
|
+
scored.push({ result: { ...representative, score: rrfScore }, rrfScore });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Sort descending by RRF score
|
|
53
|
+
scored.sort((a, b) => b.rrfScore - a.rrfScore);
|
|
54
|
+
// T4: Absolute RRF score floor
|
|
55
|
+
let filtered = scored;
|
|
56
|
+
if (minRRFScore !== undefined) {
|
|
57
|
+
filtered = filtered.filter((s) => s.rrfScore >= minRRFScore);
|
|
58
|
+
}
|
|
59
|
+
// T5: Relative RRF gap ratio (topRRF = best remaining score after T4)
|
|
60
|
+
if (rrfGapRatio !== undefined && filtered.length > 0) {
|
|
61
|
+
const topRRF = filtered[0].rrfScore;
|
|
62
|
+
const floor = topRRF * rrfGapRatio;
|
|
63
|
+
filtered = filtered.filter((s) => s.rrfScore >= floor);
|
|
64
|
+
}
|
|
65
|
+
return filtered.slice(0, maxResults).map((s) => s.result);
|
|
66
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { QueryType } from '../../core/domain/swarm/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Classify a natural language query into a query type.
|
|
4
|
+
* Uses lightweight regex rules — no LLM call needed.
|
|
5
|
+
*/
|
|
6
|
+
export declare function classifyQuery(query: string): QueryType;
|
|
7
|
+
/**
|
|
8
|
+
* Select which providers to activate for a given query type.
|
|
9
|
+
* Only returns providers that are in the available list.
|
|
10
|
+
* Matches by prefix so `local-markdown:notes` matches the `local-markdown` selector.
|
|
11
|
+
*/
|
|
12
|
+
export declare function selectProviders(queryType: QueryType, availableProviderIds: string[]): string[];
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const TEMPORAL_SIGNALS = /\b(after|before|last week|recently|since|today|this month|when did|yesterday)\b/i;
|
|
2
|
+
const PERSONAL_SIGNALS = /\b(how do I usually|I like|I prefer|I tend to|my opinion|my style)\b/i;
|
|
3
|
+
const RELATIONAL_SIGNALS = /\b(connected|depends? on|links to|mentioned in|related to|see also)\b/i;
|
|
4
|
+
/**
|
|
5
|
+
* Classify a natural language query into a query type.
|
|
6
|
+
* Uses lightweight regex rules — no LLM call needed.
|
|
7
|
+
*/
|
|
8
|
+
export function classifyQuery(query) {
|
|
9
|
+
if (TEMPORAL_SIGNALS.test(query))
|
|
10
|
+
return 'temporal';
|
|
11
|
+
if (PERSONAL_SIGNALS.test(query))
|
|
12
|
+
return 'personal';
|
|
13
|
+
if (RELATIONAL_SIGNALS.test(query))
|
|
14
|
+
return 'relational';
|
|
15
|
+
return 'factual';
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Provider selection matrix per query type.
|
|
19
|
+
* ByteRover is always included. Other providers are conditionally active.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Provider selection matrix per query type.
|
|
23
|
+
* Honcho and Hindsight are temporarily disabled — adapters coming in Phase 3.
|
|
24
|
+
* When re-enabled, add 'honcho' to personal and 'hindsight' to temporal/relational.
|
|
25
|
+
*/
|
|
26
|
+
const SELECTION_MATRIX = {
|
|
27
|
+
factual: ['byterover', 'obsidian', 'local-markdown', 'gbrain', 'memory-wiki'],
|
|
28
|
+
personal: ['byterover', 'obsidian', 'local-markdown'],
|
|
29
|
+
relational: ['byterover', 'obsidian', 'local-markdown', 'gbrain', 'memory-wiki'],
|
|
30
|
+
temporal: ['byterover', 'obsidian', 'local-markdown', 'gbrain', 'memory-wiki'],
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Select which providers to activate for a given query type.
|
|
34
|
+
* Only returns providers that are in the available list.
|
|
35
|
+
* Matches by prefix so `local-markdown:notes` matches the `local-markdown` selector.
|
|
36
|
+
*/
|
|
37
|
+
export function selectProviders(queryType, availableProviderIds) {
|
|
38
|
+
const selectors = SELECTION_MATRIX[queryType];
|
|
39
|
+
return availableProviderIds.filter((id) => selectors.some((selector) => id === selector || id.startsWith(`${selector}:`)));
|
|
40
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { IMemoryProvider } from '../../core/interfaces/i-memory-provider.js';
|
|
2
|
+
/**
|
|
3
|
+
* Write type classification for routing store operations.
|
|
4
|
+
*/
|
|
5
|
+
export type WriteType = 'entity' | 'general' | 'note';
|
|
6
|
+
/**
|
|
7
|
+
* Classify content into a write type using lightweight regex rules.
|
|
8
|
+
* Same pattern as classifyQuery() for reads.
|
|
9
|
+
*/
|
|
10
|
+
export declare function classifyWrite(content: string): WriteType;
|
|
11
|
+
/**
|
|
12
|
+
* Select the best writable provider for a given write type.
|
|
13
|
+
*
|
|
14
|
+
* Filters providers to writable + healthy candidates internally.
|
|
15
|
+
*
|
|
16
|
+
* Priority:
|
|
17
|
+
* - entity → GBrain > first writable
|
|
18
|
+
* - note → first local-markdown > first writable
|
|
19
|
+
* - general → first writable (config order)
|
|
20
|
+
*
|
|
21
|
+
* @returns null if no writable+healthy provider is available
|
|
22
|
+
*/
|
|
23
|
+
export declare function selectWriteTarget(writeType: WriteType, providers: IMemoryProvider[], healthCache: Map<string, boolean>): IMemoryProvider | null;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const ENTITY_SIGNALS = /\b(CEO of|CTO of|co-founded|founded|founded by|is a .{0,20} at|president of|VP of|works at)\b/i;
|
|
2
|
+
const NOTE_SIGNALS = /\b(action items?|draft|idea|meeting notes?|minutes|standup|TODO)\b/i;
|
|
3
|
+
/**
|
|
4
|
+
* Classify content into a write type using lightweight regex rules.
|
|
5
|
+
* Same pattern as classifyQuery() for reads.
|
|
6
|
+
*/
|
|
7
|
+
export function classifyWrite(content) {
|
|
8
|
+
if (ENTITY_SIGNALS.test(content))
|
|
9
|
+
return 'entity';
|
|
10
|
+
if (NOTE_SIGNALS.test(content))
|
|
11
|
+
return 'note';
|
|
12
|
+
return 'general';
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Select the best writable provider for a given write type.
|
|
16
|
+
*
|
|
17
|
+
* Filters providers to writable + healthy candidates internally.
|
|
18
|
+
*
|
|
19
|
+
* Priority:
|
|
20
|
+
* - entity → GBrain > first writable
|
|
21
|
+
* - note → first local-markdown > first writable
|
|
22
|
+
* - general → first writable (config order)
|
|
23
|
+
*
|
|
24
|
+
* @returns null if no writable+healthy provider is available
|
|
25
|
+
*/
|
|
26
|
+
export function selectWriteTarget(writeType, providers, healthCache) {
|
|
27
|
+
// Filter to writable + healthy
|
|
28
|
+
const candidates = providers.filter((p) => p.capabilities.writeSupported && healthCache.get(p.id) !== false);
|
|
29
|
+
if (candidates.length === 0) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
// Type-specific preference
|
|
33
|
+
if (writeType === 'entity') {
|
|
34
|
+
const gbrain = candidates.find((p) => p.type === 'gbrain');
|
|
35
|
+
if (gbrain)
|
|
36
|
+
return gbrain;
|
|
37
|
+
}
|
|
38
|
+
if (writeType === 'note') {
|
|
39
|
+
const localMd = candidates.find((p) => p.type === 'local-markdown');
|
|
40
|
+
if (localMd)
|
|
41
|
+
return localMd;
|
|
42
|
+
}
|
|
43
|
+
// Fallback: first writable candidate (deterministic by config order)
|
|
44
|
+
return candidates[0];
|
|
45
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SwarmConfig } from '../config/swarm-config-schema.js';
|
|
2
|
+
import type { ValidationIssue } from './memory-swarm-validation-error.js';
|
|
3
|
+
/**
|
|
4
|
+
* Result of runtime provider validation.
|
|
5
|
+
*/
|
|
6
|
+
export type ProviderValidationResult = {
|
|
7
|
+
cascadeNote?: string;
|
|
8
|
+
errors: ValidationIssue[];
|
|
9
|
+
warnings: ValidationIssue[];
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Run runtime validation on all enabled providers.
|
|
13
|
+
* Checks paths exist, env vars are resolved, connections are reachable.
|
|
14
|
+
* Returns accumulated errors and warnings (never throws).
|
|
15
|
+
*/
|
|
16
|
+
export declare function validateSwarmProviders(config: SwarmConfig): Promise<ProviderValidationResult>;
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
/**
|
|
7
|
+
* Check if a string value looks like an unresolved env var reference.
|
|
8
|
+
*/
|
|
9
|
+
function isUnresolvedEnvVar(value) {
|
|
10
|
+
return /^\$\{\w+\}$/.test(value);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Check if a credential string is effectively unusable:
|
|
14
|
+
* empty, whitespace-only, or an unresolved env var placeholder.
|
|
15
|
+
*/
|
|
16
|
+
function isInvalidCredential(value) {
|
|
17
|
+
if (value.trim().length === 0)
|
|
18
|
+
return 'empty';
|
|
19
|
+
if (isUnresolvedEnvVar(value))
|
|
20
|
+
return 'unresolved';
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Validate obsidian provider config at runtime.
|
|
25
|
+
*/
|
|
26
|
+
function validateObsidian(config, errors, warnings) {
|
|
27
|
+
const { vaultPath } = config;
|
|
28
|
+
if (!existsSync(vaultPath)) {
|
|
29
|
+
errors.push({
|
|
30
|
+
field: 'vault_path',
|
|
31
|
+
message: `Obsidian vault not found at ${vaultPath}`,
|
|
32
|
+
provider: 'obsidian',
|
|
33
|
+
suggestion: `Verify the path exists or run \`brv swarm onboard\` to reconfigure.`,
|
|
34
|
+
});
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (!existsSync(join(vaultPath, '.obsidian'))) {
|
|
38
|
+
warnings.push({
|
|
39
|
+
field: 'vault_path',
|
|
40
|
+
message: `Path ${vaultPath} exists but has no .obsidian/ directory. It may not be an Obsidian vault.`,
|
|
41
|
+
provider: 'obsidian',
|
|
42
|
+
suggestion: `Ensure this is the correct vault path.`,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Validate local-markdown provider config at runtime.
|
|
48
|
+
*/
|
|
49
|
+
function validateLocalMarkdown(config, errors) {
|
|
50
|
+
for (const folder of config.folders) {
|
|
51
|
+
if (!existsSync(folder.path)) {
|
|
52
|
+
errors.push({
|
|
53
|
+
field: 'folders.path',
|
|
54
|
+
message: `Folder ${folder.path} (${folder.name}) not found`,
|
|
55
|
+
provider: 'local-markdown',
|
|
56
|
+
suggestion: `Create the folder or update the path in config.`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Validate honcho provider config at runtime.
|
|
63
|
+
*/
|
|
64
|
+
function validateHoncho(config, errors) {
|
|
65
|
+
const keyReason = isInvalidCredential(config.apiKey);
|
|
66
|
+
if (keyReason === 'empty') {
|
|
67
|
+
errors.push({
|
|
68
|
+
field: 'api_key',
|
|
69
|
+
message: `Honcho API key is empty`,
|
|
70
|
+
provider: 'honcho',
|
|
71
|
+
suggestion: `Set the HONCHO_API_KEY environment variable or provide a valid key.`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
else if (keyReason === 'unresolved') {
|
|
75
|
+
errors.push({
|
|
76
|
+
field: 'api_key',
|
|
77
|
+
message: `Honcho API key is unresolved: ${config.apiKey}`,
|
|
78
|
+
provider: 'honcho',
|
|
79
|
+
suggestion: `Set the HONCHO_API_KEY environment variable.`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (config.appId.trim().length === 0) {
|
|
83
|
+
errors.push({
|
|
84
|
+
field: 'app_id',
|
|
85
|
+
message: `Honcho app_id is empty`,
|
|
86
|
+
provider: 'honcho',
|
|
87
|
+
suggestion: `Provide a valid Honcho app ID in config.`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Validate hindsight provider config at runtime.
|
|
93
|
+
*/
|
|
94
|
+
function validateHindsight(config, errors) {
|
|
95
|
+
const reason = isInvalidCredential(config.connectionString);
|
|
96
|
+
if (reason) {
|
|
97
|
+
errors.push({
|
|
98
|
+
field: 'connection_string',
|
|
99
|
+
message: reason === 'empty'
|
|
100
|
+
? `Hindsight connection string is empty`
|
|
101
|
+
: `Hindsight connection string is unresolved: ${config.connectionString}`,
|
|
102
|
+
provider: 'hindsight',
|
|
103
|
+
suggestion: `Set the HINDSIGHT_DB_URL environment variable.`,
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Validate it looks like a postgres:// URL
|
|
108
|
+
if (!config.connectionString.startsWith('postgres://') && !config.connectionString.startsWith('postgresql://')) {
|
|
109
|
+
errors.push({
|
|
110
|
+
field: 'connection_string',
|
|
111
|
+
message: `Hindsight connection string does not look like a valid Postgres URL: ${config.connectionString}`,
|
|
112
|
+
provider: 'hindsight',
|
|
113
|
+
suggestion: `Expected format: postgres://user:password@host:port/database`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Validate gbrain provider config at runtime.
|
|
119
|
+
*/
|
|
120
|
+
async function validateGBrain(config, errors) {
|
|
121
|
+
if (!existsSync(config.repoPath)) {
|
|
122
|
+
errors.push({
|
|
123
|
+
field: 'repo_path',
|
|
124
|
+
message: `GBrain repo not found at ${config.repoPath}`,
|
|
125
|
+
provider: 'gbrain',
|
|
126
|
+
suggestion: `Verify the path or run \`brv swarm onboard\` to reconfigure.`,
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// Verify gbrain CLI is invokable.
|
|
131
|
+
// Check: is `gbrain` in PATH, or does src/cli.ts exist in repo/workspace (Bun fallback)?
|
|
132
|
+
let gbrainReachable = false;
|
|
133
|
+
// Option A: gbrain globally installed
|
|
134
|
+
try {
|
|
135
|
+
await execFileAsync('gbrain', ['--version'], { encoding: 'utf8', timeout: 5000 });
|
|
136
|
+
gbrainReachable = true;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Not in PATH
|
|
140
|
+
}
|
|
141
|
+
// Option B: local Bun script (repoPath or workspace sibling)
|
|
142
|
+
if (!gbrainReachable) {
|
|
143
|
+
const candidates = [
|
|
144
|
+
join(config.repoPath, 'src', 'cli.ts'),
|
|
145
|
+
join(process.cwd(), '..', 'gbrain', 'src', 'cli.ts'),
|
|
146
|
+
];
|
|
147
|
+
const scriptFound = candidates.some((p) => existsSync(p));
|
|
148
|
+
if (scriptFound) {
|
|
149
|
+
// Script exists — verify bun is available to run it
|
|
150
|
+
try {
|
|
151
|
+
await execFileAsync('bun', ['--version'], { encoding: 'utf8', timeout: 5000 });
|
|
152
|
+
gbrainReachable = true;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
errors.push({
|
|
156
|
+
field: 'gbrain',
|
|
157
|
+
message: `GBrain source found but \`bun\` is not installed. GBrain requires Bun to run from source.`,
|
|
158
|
+
provider: 'gbrain',
|
|
159
|
+
suggestion: `Install Bun: https://bun.sh/docs/installation`,
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (!gbrainReachable) {
|
|
166
|
+
errors.push({
|
|
167
|
+
field: 'gbrain',
|
|
168
|
+
message: `GBrain CLI not found. Not in PATH and no src/cli.ts in ${config.repoPath}`,
|
|
169
|
+
provider: 'gbrain',
|
|
170
|
+
suggestion: `Install globally with \`bun add -g gbrain\`, or set repo_path to the gbrain source directory.`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Collect the set of provider IDs that are enabled in config.
|
|
176
|
+
* Uses prefix matching (e.g., `local-markdown` matches if any local-markdown folder exists).
|
|
177
|
+
*/
|
|
178
|
+
function getEnabledProviderIds(providers) {
|
|
179
|
+
const ids = new Set();
|
|
180
|
+
if (providers.byterover.enabled)
|
|
181
|
+
ids.add('byterover');
|
|
182
|
+
if (providers.obsidian?.enabled)
|
|
183
|
+
ids.add('obsidian');
|
|
184
|
+
if (providers.localMarkdown?.enabled) {
|
|
185
|
+
ids.add('local-markdown');
|
|
186
|
+
for (const folder of providers.localMarkdown.folders) {
|
|
187
|
+
ids.add(`local-markdown:${folder.name}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (providers.honcho?.enabled)
|
|
191
|
+
ids.add('honcho');
|
|
192
|
+
if (providers.hindsight?.enabled)
|
|
193
|
+
ids.add('hindsight');
|
|
194
|
+
if (providers.gbrain?.enabled)
|
|
195
|
+
ids.add('gbrain');
|
|
196
|
+
if (providers.memoryWiki?.enabled)
|
|
197
|
+
ids.add('memory-wiki');
|
|
198
|
+
return ids;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Check if a provider ID matches an enabled provider (with prefix matching).
|
|
202
|
+
*/
|
|
203
|
+
function matchesEnabledProvider(edgeEndpoint, enabledIds) {
|
|
204
|
+
if (enabledIds.has(edgeEndpoint))
|
|
205
|
+
return true;
|
|
206
|
+
// Prefix match: "local-markdown" matches "local-markdown:notes"
|
|
207
|
+
for (const id of enabledIds) {
|
|
208
|
+
if (id.startsWith(`${edgeEndpoint}:`))
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Check if a provider ID references a configured (but possibly disabled) provider.
|
|
215
|
+
*/
|
|
216
|
+
function isConfiguredProvider(edgeEndpoint, providers) {
|
|
217
|
+
if (edgeEndpoint === 'byterover')
|
|
218
|
+
return true;
|
|
219
|
+
if (edgeEndpoint === 'obsidian')
|
|
220
|
+
return providers.obsidian !== undefined;
|
|
221
|
+
if (edgeEndpoint === 'honcho')
|
|
222
|
+
return providers.honcho !== undefined;
|
|
223
|
+
if (edgeEndpoint === 'hindsight')
|
|
224
|
+
return providers.hindsight !== undefined;
|
|
225
|
+
if (edgeEndpoint === 'gbrain')
|
|
226
|
+
return providers.gbrain !== undefined;
|
|
227
|
+
if (edgeEndpoint === 'memory-wiki')
|
|
228
|
+
return providers.memoryWiki !== undefined;
|
|
229
|
+
// Generic "local-markdown" — valid if the section exists
|
|
230
|
+
if (edgeEndpoint === 'local-markdown')
|
|
231
|
+
return providers.localMarkdown !== undefined;
|
|
232
|
+
// Folder-scoped "local-markdown:<name>" — must match an actual configured folder
|
|
233
|
+
if (edgeEndpoint.startsWith('local-markdown:')) {
|
|
234
|
+
const folderName = edgeEndpoint.slice('local-markdown:'.length);
|
|
235
|
+
return providers.localMarkdown?.folders.some((f) => f.name === folderName) ?? false;
|
|
236
|
+
}
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Detect cycles in enrichment edges using DFS.
|
|
241
|
+
*/
|
|
242
|
+
function hasCycle(edges) {
|
|
243
|
+
const adjacency = new Map();
|
|
244
|
+
for (const edge of edges) {
|
|
245
|
+
const existing = adjacency.get(edge.from) ?? [];
|
|
246
|
+
existing.push(edge.to);
|
|
247
|
+
adjacency.set(edge.from, existing);
|
|
248
|
+
}
|
|
249
|
+
const visited = new Set();
|
|
250
|
+
const inStack = new Set();
|
|
251
|
+
function dfs(node) {
|
|
252
|
+
if (inStack.has(node))
|
|
253
|
+
return true;
|
|
254
|
+
if (visited.has(node))
|
|
255
|
+
return false;
|
|
256
|
+
visited.add(node);
|
|
257
|
+
inStack.add(node);
|
|
258
|
+
for (const neighbor of adjacency.get(node) ?? []) {
|
|
259
|
+
if (dfs(neighbor))
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
inStack.delete(node);
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
const allNodes = new Set([...edges.map((e) => e.from), ...edges.map((e) => e.to)]);
|
|
266
|
+
for (const node of allNodes) {
|
|
267
|
+
if (dfs(node))
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Expand a config edge endpoint to concrete provider IDs.
|
|
274
|
+
* "local-markdown" → ["local-markdown:notes", "local-markdown:docs"]
|
|
275
|
+
* "obsidian" → ["obsidian"]
|
|
276
|
+
*
|
|
277
|
+
* Prefers prefix expansion: if "local-markdown" has folder-scoped children,
|
|
278
|
+
* expand to those children rather than treating the generic ID as concrete.
|
|
279
|
+
*/
|
|
280
|
+
function resolveEndpoint(endpoint, enabledIds) {
|
|
281
|
+
// Prefer prefix expansion over exact match — generic IDs like "local-markdown"
|
|
282
|
+
// should expand to their concrete folder IDs, not stay as the generic form.
|
|
283
|
+
const prefixMatches = [...enabledIds].filter((id) => id.startsWith(`${endpoint}:`));
|
|
284
|
+
if (prefixMatches.length > 0)
|
|
285
|
+
return prefixMatches;
|
|
286
|
+
if (enabledIds.has(endpoint))
|
|
287
|
+
return [endpoint];
|
|
288
|
+
return [endpoint];
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Validate enrichment edges: no self-edges, no cycles, endpoints must exist.
|
|
292
|
+
* Validates against the EXPANDED graph (generic endpoints resolved to concrete IDs)
|
|
293
|
+
* so that expansion-induced cycles and self-edges are caught at config time.
|
|
294
|
+
*/
|
|
295
|
+
function validateEnrichmentEdges(config, errors, warnings) {
|
|
296
|
+
const configEdges = config.enrichment?.edges ?? [];
|
|
297
|
+
if (configEdges.length === 0)
|
|
298
|
+
return;
|
|
299
|
+
const enabledIds = getEnabledProviderIds(config.providers);
|
|
300
|
+
// 1. Check raw endpoint existence/enabled status
|
|
301
|
+
for (const edge of configEdges) {
|
|
302
|
+
for (const endpoint of [edge.from, edge.to]) {
|
|
303
|
+
if (!isConfiguredProvider(endpoint, config.providers)) {
|
|
304
|
+
errors.push({
|
|
305
|
+
field: 'enrichment.edges',
|
|
306
|
+
message: `Enrichment edge references unknown provider '${endpoint}'`,
|
|
307
|
+
provider: 'enrichment',
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
else if (!matchesEnabledProvider(endpoint, enabledIds)) {
|
|
311
|
+
warnings.push({
|
|
312
|
+
field: 'enrichment.edges',
|
|
313
|
+
message: `Enrichment edge references disabled provider '${endpoint}'`,
|
|
314
|
+
provider: 'enrichment',
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// 2. Expand only edges where BOTH endpoints are enabled.
|
|
320
|
+
// Disabled endpoints already produced warnings above — don't let them
|
|
321
|
+
// create phantom cycles or self-edges in the expanded graph.
|
|
322
|
+
const seen = new Set();
|
|
323
|
+
const expanded = [];
|
|
324
|
+
for (const edge of configEdges) {
|
|
325
|
+
if (!matchesEnabledProvider(edge.from, enabledIds) || !matchesEnabledProvider(edge.to, enabledIds)) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const fromIds = resolveEndpoint(edge.from, enabledIds);
|
|
329
|
+
const toIds = resolveEndpoint(edge.to, enabledIds);
|
|
330
|
+
for (const from of fromIds) {
|
|
331
|
+
for (const to of toIds) {
|
|
332
|
+
const key = `${from}->${to}`;
|
|
333
|
+
if (!seen.has(key)) {
|
|
334
|
+
seen.add(key);
|
|
335
|
+
expanded.push({ from, to });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// 3. Self-edge check on expanded graph
|
|
341
|
+
for (const edge of expanded) {
|
|
342
|
+
if (edge.from === edge.to) {
|
|
343
|
+
errors.push({
|
|
344
|
+
field: 'enrichment.edges',
|
|
345
|
+
message: `Enrichment self-edge after expansion: '${edge.from}' cannot enrich itself`,
|
|
346
|
+
provider: 'enrichment',
|
|
347
|
+
suggestion: `The generic endpoint expands to the same concrete provider on both sides.`,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// 4. Cycle detection on expanded graph
|
|
352
|
+
if (hasCycle(expanded)) {
|
|
353
|
+
errors.push({
|
|
354
|
+
field: 'enrichment.edges',
|
|
355
|
+
message: `Enrichment edges contain a cycle after expansion. The topology must be a directed acyclic graph (DAG).`,
|
|
356
|
+
provider: 'enrichment',
|
|
357
|
+
suggestion: `Generic endpoints like 'local-markdown' expand to concrete folder IDs, which may create cycles with specific endpoints. Remove one edge to break the cycle.`,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Run runtime validation on all enabled providers.
|
|
363
|
+
* Checks paths exist, env vars are resolved, connections are reachable.
|
|
364
|
+
* Returns accumulated errors and warnings (never throws).
|
|
365
|
+
*/
|
|
366
|
+
export async function validateSwarmProviders(config) {
|
|
367
|
+
const errors = [];
|
|
368
|
+
const warnings = [];
|
|
369
|
+
const { providers } = config;
|
|
370
|
+
// ByteRover is always valid (built-in)
|
|
371
|
+
if (providers.obsidian?.enabled) {
|
|
372
|
+
validateObsidian(providers.obsidian, errors, warnings);
|
|
373
|
+
}
|
|
374
|
+
if (providers.localMarkdown?.enabled) {
|
|
375
|
+
validateLocalMarkdown(providers.localMarkdown, errors);
|
|
376
|
+
}
|
|
377
|
+
if (providers.honcho?.enabled) {
|
|
378
|
+
validateHoncho(providers.honcho, errors);
|
|
379
|
+
}
|
|
380
|
+
if (providers.hindsight?.enabled) {
|
|
381
|
+
validateHindsight(providers.hindsight, errors);
|
|
382
|
+
}
|
|
383
|
+
if (providers.gbrain?.enabled) {
|
|
384
|
+
await validateGBrain(providers.gbrain, errors);
|
|
385
|
+
}
|
|
386
|
+
if (providers.memoryWiki?.enabled && !existsSync(providers.memoryWiki.vaultPath)) {
|
|
387
|
+
errors.push({
|
|
388
|
+
field: 'providers.memory_wiki.vault_path',
|
|
389
|
+
message: `Memory Wiki vault not found at ${providers.memoryWiki.vaultPath}`,
|
|
390
|
+
provider: 'memory-wiki',
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
// Validate enrichment edges
|
|
394
|
+
validateEnrichmentEdges(config, errors, warnings);
|
|
395
|
+
// Generate cascade note if cloud providers failed (exclude enrichment errors)
|
|
396
|
+
const CLOUD_PROVIDER_IDS = new Set(['gbrain', 'hindsight', 'honcho']);
|
|
397
|
+
const cloudErrors = errors.filter((e) => e.provider && CLOUD_PROVIDER_IDS.has(e.provider));
|
|
398
|
+
const cascadeNote = cloudErrors.length > 0
|
|
399
|
+
? `${cloudErrors.length} cloud provider(s) failed validation. Routing will use local providers only until resolved.`
|
|
400
|
+
: undefined;
|
|
401
|
+
return { cascadeNote, errors, warnings };
|
|
402
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single validation issue (error or warning) for a swarm provider.
|
|
3
|
+
*/
|
|
4
|
+
export type ValidationIssue = {
|
|
5
|
+
/** Config field that caused the issue */
|
|
6
|
+
field?: string;
|
|
7
|
+
/** Human-readable description of the issue */
|
|
8
|
+
message: string;
|
|
9
|
+
/** Which provider this issue relates to */
|
|
10
|
+
provider?: string;
|
|
11
|
+
/** Actionable fix hint */
|
|
12
|
+
suggestion?: string;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Error that accumulates all validation issues instead of failing on the first one.
|
|
16
|
+
* Contains both hard errors (blocking) and soft warnings (informational).
|
|
17
|
+
*/
|
|
18
|
+
export declare class MemorySwarmValidationError extends Error {
|
|
19
|
+
readonly errors: ValidationIssue[];
|
|
20
|
+
readonly warnings: ValidationIssue[];
|
|
21
|
+
readonly cascadeNote?: string | undefined;
|
|
22
|
+
readonly name = "MemorySwarmValidationError";
|
|
23
|
+
constructor(errors: ValidationIssue[], warnings: ValidationIssue[], cascadeNote?: string | undefined);
|
|
24
|
+
/**
|
|
25
|
+
* Serialize to a plain JSON object for CLI output or logging.
|
|
26
|
+
*/
|
|
27
|
+
toJSON(): {
|
|
28
|
+
cascadeNote?: string;
|
|
29
|
+
errors: ValidationIssue[];
|
|
30
|
+
message: string;
|
|
31
|
+
warnings: ValidationIssue[];
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error that accumulates all validation issues instead of failing on the first one.
|
|
3
|
+
* Contains both hard errors (blocking) and soft warnings (informational).
|
|
4
|
+
*/
|
|
5
|
+
export class MemorySwarmValidationError extends Error {
|
|
6
|
+
errors;
|
|
7
|
+
warnings;
|
|
8
|
+
cascadeNote;
|
|
9
|
+
name = 'MemorySwarmValidationError';
|
|
10
|
+
constructor(errors, warnings, cascadeNote) {
|
|
11
|
+
super(`Memory swarm validation failed with ${errors.length} error(s)`);
|
|
12
|
+
this.errors = errors;
|
|
13
|
+
this.warnings = warnings;
|
|
14
|
+
this.cascadeNote = cascadeNote;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Serialize to a plain JSON object for CLI output or logging.
|
|
18
|
+
*/
|
|
19
|
+
toJSON() {
|
|
20
|
+
return {
|
|
21
|
+
cascadeNote: this.cascadeNote,
|
|
22
|
+
errors: this.errors,
|
|
23
|
+
message: this.message,
|
|
24
|
+
warnings: this.warnings,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|