scoutline 0.15.0 → 0.16.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/README.md +68 -11
- package/dist/capabilities/repository.d.ts +96 -0
- package/dist/capabilities/repository.d.ts.map +1 -1
- package/dist/capabilities/repository.js.map +1 -1
- package/dist/commands/cache.d.ts +38 -0
- package/dist/commands/cache.d.ts.map +1 -1
- package/dist/commands/cache.js +64 -5
- package/dist/commands/cache.js.map +1 -1
- package/dist/commands/config.d.ts +17 -1
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +25 -2
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/repo.d.ts +96 -1
- package/dist/commands/repo.d.ts.map +1 -1
- package/dist/commands/repo.js +420 -0
- package/dist/commands/repo.js.map +1 -1
- package/dist/commands/search.d.ts +150 -0
- package/dist/commands/search.d.ts.map +1 -1
- package/dist/commands/search.js +368 -27
- package/dist/commands/search.js.map +1 -1
- package/dist/index.d.ts +199 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +352 -20
- package/dist/index.js.map +1 -1
- package/dist/lib/cache.d.ts +111 -7
- package/dist/lib/cache.d.ts.map +1 -1
- package/dist/lib/cache.js +316 -7
- package/dist/lib/cache.js.map +1 -1
- package/dist/lib/config-store.d.ts +51 -1
- package/dist/lib/config-store.d.ts.map +1 -1
- package/dist/lib/config-store.js +90 -6
- package/dist/lib/config-store.js.map +1 -1
- package/dist/lib/url.d.ts +43 -0
- package/dist/lib/url.d.ts.map +1 -0
- package/dist/lib/url.js +172 -0
- package/dist/lib/url.js.map +1 -0
- package/dist/providers/selection.d.ts +21 -0
- package/dist/providers/selection.d.ts.map +1 -1
- package/dist/providers/selection.js +58 -0
- package/dist/providers/selection.js.map +1 -1
- package/package.json +1 -1
|
@@ -13,6 +13,8 @@ import type { CommandContext, CommandResult } from "../command-invocation.js";
|
|
|
13
13
|
import type { SearchCapability, SearchTopic, SearchType } from "../capabilities/search.js";
|
|
14
14
|
import type { ResponseCache } from "../lib/cache.js";
|
|
15
15
|
import type { RetryPolicy } from "../lib/execution.js";
|
|
16
|
+
import type { ProviderDescriptor, ProviderId } from "../providers/types.js";
|
|
17
|
+
import type { ConsumptionSink } from "../lib/consumption.js";
|
|
16
18
|
type RecencyFilter = "oneDay" | "oneWeek" | "oneMonth" | "oneYear" | "noLimit";
|
|
17
19
|
export interface SearchOptions {
|
|
18
20
|
count?: number;
|
|
@@ -40,7 +42,155 @@ export interface SearchExecutionDependencies {
|
|
|
40
42
|
readonly random: () => number;
|
|
41
43
|
readonly retryPolicy?: RetryPolicy;
|
|
42
44
|
}
|
|
45
|
+
interface FormattedResult {
|
|
46
|
+
rank: number;
|
|
47
|
+
title: string;
|
|
48
|
+
url: string;
|
|
49
|
+
summary: string;
|
|
50
|
+
source?: string;
|
|
51
|
+
date?: string;
|
|
52
|
+
/** Set when merging multiple queries: how many sub-queries surfaced this URL. */
|
|
53
|
+
occurrences?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Provenance (fan-out only, DESIGN D3): distinct providers that
|
|
56
|
+
* surfaced this URL, in first-encounter order. Omitted on the
|
|
57
|
+
* single-provider path — SCHEMA.md.
|
|
58
|
+
*/
|
|
59
|
+
mergedFrom?: ProviderId[];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* One arm of a merge grid: a Provider's results split into sub-queries.
|
|
63
|
+
* `provider` is absent on the single-provider `--merge` path (no
|
|
64
|
+
* provenance is tracked there); the fan-out path always sets it.
|
|
65
|
+
*/
|
|
66
|
+
export interface MergeGridArm {
|
|
67
|
+
provider?: ProviderId;
|
|
68
|
+
results: FormattedResult[][];
|
|
69
|
+
}
|
|
70
|
+
export interface MergeResultsOptions {
|
|
71
|
+
/** Emit mergedFrom provenance on every result (fan-out active). */
|
|
72
|
+
emitMergedFrom?: boolean;
|
|
73
|
+
/** Post-merge --count cap. Each arm was already asked for this count. */
|
|
74
|
+
count?: number;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Merge results from an (arm × sub-query) grid (DESIGN D3). Generalizes
|
|
78
|
+
* the pre-fan-out sub-query merge with exactly one new key: dedupe by
|
|
79
|
+
* `canonicalUrl(url)` (DESIGN D4) instead of the raw string. Ranking is
|
|
80
|
+
* unchanged — (occurrence count desc, best position asc) — but
|
|
81
|
+
* `occurrences` now counts across the whole arms × sub-queries grid.
|
|
82
|
+
* First-writer-wins: the earlier arm's title/summary/url win a collision
|
|
83
|
+
* (arm order is the tiebreak priority). Every URL accumulates
|
|
84
|
+
* `mergedFrom` (distinct providers, first-encounter order) when
|
|
85
|
+
* `emitMergedFrom` is set, and the merged list is sliced to `count`
|
|
86
|
+
* post-merge. The single-provider `--merge` path and the fan-out path
|
|
87
|
+
* share this one implementation.
|
|
88
|
+
*/
|
|
89
|
+
export declare function mergeResults(grid: MergeGridArm[], options?: MergeResultsOptions): FormattedResult[];
|
|
43
90
|
export declare function search(query: string, options: SearchOptions | undefined, deps: SearchExecutionDependencies, context?: CommandContext): Promise<CommandResult>;
|
|
91
|
+
/**
|
|
92
|
+
* Resolved activation plan. `mode` is the dispatcher hinge; `arms` is
|
|
93
|
+
* the ordered Provider list to execute (already expanded and
|
|
94
|
+
* filtered — never the `"all"` sentinel; the resolver owns the
|
|
95
|
+
* expansion against its injected descriptors). `suppress` is the
|
|
96
|
+
* human-readable reason the resolver ignored fan-out (e.g. "explicit
|
|
97
|
+
* pin: fan-out ignored"); emitted on the single-pin path so the user
|
|
98
|
+
* understands why fan-out did not engage.
|
|
99
|
+
*/
|
|
100
|
+
export interface FanoutPlan {
|
|
101
|
+
readonly mode: "single" | "fanout";
|
|
102
|
+
readonly arms: ProviderId[];
|
|
103
|
+
readonly suppress?: string;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Options for {@link resolveFanoutPlan}. The resolver is pure: every
|
|
107
|
+
* input is an injected value, no I/O, no environment reads. `configFanout`
|
|
108
|
+
* is the lone scalar read from the user config (Ticket 4 owns the
|
|
109
|
+
* registry row); `routing` is the typed routing table already in
|
|
110
|
+
* `HandlerDependencies`. `descriptors` is the live provider registry —
|
|
111
|
+
* the resolver uses it for `isConfigured(env)` + `capabilities()`
|
|
112
|
+
* filtering (the same gate `resolveEffectiveProvider` uses) so the
|
|
113
|
+
* arm list the executor sees is the same list the executor would
|
|
114
|
+
* otherwise walk, giving the dispatcher a single source of truth.
|
|
115
|
+
*/
|
|
116
|
+
export interface ResolveFanoutPlanOptions {
|
|
117
|
+
/** Raw `--provider` value (or undefined). Empty string is treated as absent. */
|
|
118
|
+
readonly explicitProviderRaw: string | undefined;
|
|
119
|
+
/** The resolved env the handler runs under (env + file-configured keys). */
|
|
120
|
+
readonly env: NodeJS.ProcessEnv;
|
|
121
|
+
/** Whether `fanout` is enabled in the active config. */
|
|
122
|
+
readonly configFanout: boolean;
|
|
123
|
+
/** Resolved per-capability routing table (routing-table plan). */
|
|
124
|
+
readonly routing?: Readonly<Record<string, readonly ProviderId[]>>;
|
|
125
|
+
/** Live provider registry; the resolver filters by capability "search". */
|
|
126
|
+
readonly descriptors: readonly ProviderDescriptor[];
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Resolve the activation plan (D1). Tiers in precedence order:
|
|
130
|
+
*
|
|
131
|
+
* 1. **Explicit raw** (D1.1): a comma list or `"all"` → fanout
|
|
132
|
+
* (`"all"` expands to configured ∩ advertising in registry
|
|
133
|
+
* order); a single id → single (with suppress when
|
|
134
|
+
* `configFanout=true`).
|
|
135
|
+
* 2. **SCOUTLINE_PROVIDER env** (D1.2): a pin → single (suppress
|
|
136
|
+
* when `configFanout=true`).
|
|
137
|
+
* 3. **`configFanout=true` with no pin** (D1.3): fanout; arms =
|
|
138
|
+
* `routing.search` filtered/deduped (if set), else configured ∩
|
|
139
|
+
* advertising in registry order.
|
|
140
|
+
* 4. **Default** (D1.4): single; arm = first configured ∩
|
|
141
|
+
* advertising in registry order (informational — the dispatcher
|
|
142
|
+
* still consults `resolveEffectiveProvider` for quota ranking).
|
|
143
|
+
*
|
|
144
|
+
* The resolver exposes its decision only through the returned
|
|
145
|
+
* `FanoutPlan`; the dispatcher hinges on `mode`. The truth table is
|
|
146
|
+
* pinned by Ticket 3 tests.
|
|
147
|
+
*/
|
|
148
|
+
export declare function resolveFanoutPlan(options: ResolveFanoutPlanOptions): FanoutPlan;
|
|
149
|
+
/**
|
|
150
|
+
* Options for {@link executeFanoutPlan}. The executor owns the
|
|
151
|
+
* per-arm Lifecycle (descriptor.create → adapter → executeSearch →
|
|
152
|
+
* close) and the merged error envelope. `query` is the resolved raw
|
|
153
|
+
* query; when `searchOptions.merge` is set the executor splits it on
|
|
154
|
+
* unescaped `|` and runs EVERY sub-query on EVERY arm — DESIGN D3's
|
|
155
|
+
* (arm × sub-query) merge grid.
|
|
156
|
+
*
|
|
157
|
+
* `dependencies` is a subset of {@link SearchExecutionDependencies}:
|
|
158
|
+
* the per-arm capability is constructed internally by the executor
|
|
159
|
+
* (one per arm), so the caller does not supply a single shared
|
|
160
|
+
* capability. Only the shared cache/policy seams cross arms.
|
|
161
|
+
*/
|
|
162
|
+
export interface FanoutExecutionDependencies {
|
|
163
|
+
readonly cache: ResponseCache;
|
|
164
|
+
readonly sleep: (ms: number) => Promise<void>;
|
|
165
|
+
readonly random: () => number;
|
|
166
|
+
readonly retryPolicy?: RetryPolicy;
|
|
167
|
+
/**
|
|
168
|
+
* Optional consumption sink (PB-T2 parity with the other billable
|
|
169
|
+
* seams). When present, every arm's `executeSearch` emits one
|
|
170
|
+
* consumption event per billable invoke attempt through it — fan-out
|
|
171
|
+
* arms must not silently skip local quota accounting (review fix,
|
|
172
|
+
* PR #36).
|
|
173
|
+
*/
|
|
174
|
+
readonly consume?: ConsumptionSink;
|
|
175
|
+
/** Timestamp source for consumption events; defaults to `Date.now`. */
|
|
176
|
+
readonly now?: () => number;
|
|
177
|
+
}
|
|
178
|
+
export interface FanoutExecutionOptions {
|
|
179
|
+
readonly descriptors: readonly ProviderDescriptor[];
|
|
180
|
+
readonly env: NodeJS.ProcessEnv;
|
|
181
|
+
readonly query: string;
|
|
182
|
+
readonly searchOptions: SearchOptions;
|
|
183
|
+
readonly dependencies: FanoutExecutionDependencies;
|
|
184
|
+
readonly secrets?: string[];
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Execute the fan-out plan: parallel per-arm search, settled wrapper,
|
|
188
|
+
* per-arm notices, D5/D6 exit policy, combined summary notice. The
|
|
189
|
+
* single-pin path is NOT routed through this executor (Tier 2 / Tier 4
|
|
190
|
+
* stay verbatim through `executeWithFallback`); the executor asserts
|
|
191
|
+
* `mode === "fanout"` on entry so a misuse throws loudly.
|
|
192
|
+
*/
|
|
193
|
+
export declare function executeFanoutPlan(plan: FanoutPlan, options: FanoutExecutionOptions, context?: CommandContext): Promise<CommandResult>;
|
|
44
194
|
export declare const SEARCH_HELP: string;
|
|
45
195
|
export {};
|
|
46
196
|
//# sourceMappingURL=search.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/commands/search.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAqB,MAAM,0BAA0B,CAAC;AACjG,OAAO,KAAK,EACV,gBAAgB,EAIhB,WAAW,EACX,UAAU,EACX,MAAM,2BAA2B,CAAC;AACnC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/commands/search.ts"],"names":[],"mappings":"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"}
|
package/dist/commands/search.js
CHANGED
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
* execution and never enters an Adapter request or cache identity.
|
|
11
11
|
*/
|
|
12
12
|
import { executeSearch } from "../lib/execution.js";
|
|
13
|
+
import { canonicalUrl } from "../lib/url.js";
|
|
13
14
|
import { formatSearchResultsPretty } from "../lib/tty.js";
|
|
15
|
+
import { ValidationError, UnsupportedOptionError, UnsupportedCapabilityError, } from "../lib/errors.js";
|
|
16
|
+
import { redactCredentialString } from "../lib/redact.js";
|
|
17
|
+
import { parseProviderId, parseProviderIds } from "../providers/selection.js";
|
|
14
18
|
function truncate(text, max) {
|
|
15
19
|
if (!text)
|
|
16
20
|
return "";
|
|
@@ -52,38 +56,68 @@ function buildControls(options) {
|
|
|
52
56
|
return Object.keys(controls).length > 0 ? controls : undefined;
|
|
53
57
|
}
|
|
54
58
|
/**
|
|
55
|
-
* Merge results from
|
|
56
|
-
*
|
|
57
|
-
*
|
|
59
|
+
* Merge results from an (arm × sub-query) grid (DESIGN D3). Generalizes
|
|
60
|
+
* the pre-fan-out sub-query merge with exactly one new key: dedupe by
|
|
61
|
+
* `canonicalUrl(url)` (DESIGN D4) instead of the raw string. Ranking is
|
|
62
|
+
* unchanged — (occurrence count desc, best position asc) — but
|
|
63
|
+
* `occurrences` now counts across the whole arms × sub-queries grid.
|
|
64
|
+
* First-writer-wins: the earlier arm's title/summary/url win a collision
|
|
65
|
+
* (arm order is the tiebreak priority). Every URL accumulates
|
|
66
|
+
* `mergedFrom` (distinct providers, first-encounter order) when
|
|
67
|
+
* `emitMergedFrom` is set, and the merged list is sliced to `count`
|
|
68
|
+
* post-merge. The single-provider `--merge` path and the fan-out path
|
|
69
|
+
* share this one implementation.
|
|
58
70
|
*/
|
|
59
|
-
function mergeResults(
|
|
71
|
+
export function mergeResults(grid, options = {}) {
|
|
72
|
+
const { emitMergedFrom = false, count } = options;
|
|
60
73
|
const map = new Map();
|
|
61
|
-
for (const
|
|
62
|
-
for (const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
+
for (const arm of grid) {
|
|
75
|
+
for (const results of arm.results) {
|
|
76
|
+
for (const r of results) {
|
|
77
|
+
// canonicalUrl is the Map key only; the emitted url stays the
|
|
78
|
+
// first writer's original string (DESIGN D3 "Identity").
|
|
79
|
+
const key = canonicalUrl(r.url);
|
|
80
|
+
const existing = map.get(key);
|
|
81
|
+
if (existing) {
|
|
82
|
+
existing.occurrences += 1;
|
|
83
|
+
existing.bestPos = Math.min(existing.bestPos, r.rank);
|
|
84
|
+
if (emitMergedFrom && arm.provider && !existing.mergedFrom.includes(arm.provider)) {
|
|
85
|
+
existing.mergedFrom.push(arm.provider);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
map.set(key, {
|
|
90
|
+
...r,
|
|
91
|
+
occurrences: 1,
|
|
92
|
+
bestPos: r.rank,
|
|
93
|
+
mergedFrom: emitMergedFrom && arm.provider ? [arm.provider] : [],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
74
96
|
}
|
|
75
97
|
}
|
|
76
98
|
}
|
|
77
|
-
|
|
99
|
+
let merged = Array.from(map.values());
|
|
78
100
|
merged.sort((a, b) => {
|
|
79
101
|
if (b.occurrences !== a.occurrences)
|
|
80
102
|
return b.occurrences - a.occurrences;
|
|
81
103
|
return a.bestPos - b.bestPos;
|
|
82
104
|
});
|
|
105
|
+
// Post-merge --count slice (each arm was already asked for this count).
|
|
106
|
+
if (count !== undefined) {
|
|
107
|
+
merged = merged.slice(0, Math.max(0, count));
|
|
108
|
+
}
|
|
83
109
|
return merged.map((r, i) => {
|
|
84
110
|
const { bestPos: _bp, ...rest } = r;
|
|
85
111
|
void _bp;
|
|
86
|
-
|
|
112
|
+
const rank = i + 1;
|
|
113
|
+
if (!emitMergedFrom) {
|
|
114
|
+
// Omit mergedFrom entirely on the single path (SCHEMA.md) so the
|
|
115
|
+
// emitted objects stay byte-identical to the pre-fan-out merge.
|
|
116
|
+
const { mergedFrom: _mf, ...restNoProvenance } = rest;
|
|
117
|
+
void _mf;
|
|
118
|
+
return { ...restNoProvenance, rank };
|
|
119
|
+
}
|
|
120
|
+
return { ...rest, rank };
|
|
87
121
|
});
|
|
88
122
|
}
|
|
89
123
|
function renderTextFormat(results, mode) {
|
|
@@ -130,19 +164,30 @@ function formatSources(sources, maxSummary) {
|
|
|
130
164
|
return formatted;
|
|
131
165
|
});
|
|
132
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Split a `--merge` query into sub-queries: split on unescaped `|` (a
|
|
169
|
+
* literal pipe is escaped as `\|`), trim, and drop empty fragments. A
|
|
170
|
+
* merge query with no non-empty fragments is a usage error. Shared by
|
|
171
|
+
* the single-provider path and the fan-out executor — DESIGN D3's merge
|
|
172
|
+
* grid is (arm × sub-query), so every arm runs every sub-query.
|
|
173
|
+
*/
|
|
174
|
+
function splitMergeSubQueries(query) {
|
|
175
|
+
const subQueries = query
|
|
176
|
+
.split(/(?<!\\)\|/)
|
|
177
|
+
.map((q) => q.replace(/\\\|/g, "|").trim())
|
|
178
|
+
.filter((q) => q.length > 0);
|
|
179
|
+
if (subQueries.length === 0) {
|
|
180
|
+
throw new Error("--merge requires at least one non-empty query (split with '|')");
|
|
181
|
+
}
|
|
182
|
+
return subQueries;
|
|
183
|
+
}
|
|
133
184
|
export async function search(query, options = {}, deps, context) {
|
|
134
185
|
const { capability, cache, sleep, random, retryPolicy } = deps;
|
|
135
186
|
// Split query on `|` if --merge is set. Empty fragments are dropped.
|
|
136
187
|
// A literal pipe in a single query can be escaped as `\|` (won't split).
|
|
137
188
|
let subQueries = [query];
|
|
138
189
|
if (options.merge) {
|
|
139
|
-
subQueries = query
|
|
140
|
-
.split(/(?<!\\)\|/)
|
|
141
|
-
.map((q) => q.replace(/\\\|/g, "|").trim())
|
|
142
|
-
.filter((q) => q.length > 0);
|
|
143
|
-
if (subQueries.length === 0) {
|
|
144
|
-
throw new Error("--merge requires at least one non-empty query (split with '|')");
|
|
145
|
-
}
|
|
190
|
+
subQueries = splitMergeSubQueries(query);
|
|
146
191
|
}
|
|
147
192
|
const isMerge = subQueries.length > 1;
|
|
148
193
|
const controls = buildControls(options);
|
|
@@ -159,8 +204,12 @@ export async function search(query, options = {}, deps, context) {
|
|
|
159
204
|
}, executionDeps);
|
|
160
205
|
}));
|
|
161
206
|
const perQueryFormatted = perQuerySources.map((sources) => formatSources(sources, options.maxSummary));
|
|
207
|
+
// The single-provider --merge path and the fan-out path (Ticket 3)
|
|
208
|
+
// share one merge implementation. The single path passes no provider
|
|
209
|
+
// and no count, so its output is byte-identical to the pre-fan-out
|
|
210
|
+
// merge (no mergedFrom field, no post-merge slice).
|
|
162
211
|
const formattedResults = isMerge
|
|
163
|
-
? mergeResults(perQueryFormatted)
|
|
212
|
+
? mergeResults([{ results: perQueryFormatted }])
|
|
164
213
|
: perQueryFormatted[0] || [];
|
|
165
214
|
if (isMerge && context) {
|
|
166
215
|
context.notice(`ℹ️ merged ${subQueries.length} queries → ${formattedResults.length} unique results`);
|
|
@@ -171,6 +220,267 @@ export async function search(query, options = {}, deps, context) {
|
|
|
171
220
|
: formattedResults;
|
|
172
221
|
return { kind: "data", data, presentations };
|
|
173
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Configured ∩ advertising descriptors for `search`, in the registry
|
|
225
|
+
* order the descriptors were injected (production passes the stable
|
|
226
|
+
* `PROVIDER_IDS` order). Shared by the `"all"` expansion and the
|
|
227
|
+
* tier-3 default fan-out arms.
|
|
228
|
+
*/
|
|
229
|
+
function eligibleSearchArms(descriptors, env) {
|
|
230
|
+
const arms = [];
|
|
231
|
+
for (const descriptor of descriptors) {
|
|
232
|
+
if (!descriptor.isConfigured(env, "search"))
|
|
233
|
+
continue;
|
|
234
|
+
if (!descriptor.capabilities().has("search"))
|
|
235
|
+
continue;
|
|
236
|
+
arms.push(descriptor.id);
|
|
237
|
+
}
|
|
238
|
+
return arms;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Resolve the activation plan (D1). Tiers in precedence order:
|
|
242
|
+
*
|
|
243
|
+
* 1. **Explicit raw** (D1.1): a comma list or `"all"` → fanout
|
|
244
|
+
* (`"all"` expands to configured ∩ advertising in registry
|
|
245
|
+
* order); a single id → single (with suppress when
|
|
246
|
+
* `configFanout=true`).
|
|
247
|
+
* 2. **SCOUTLINE_PROVIDER env** (D1.2): a pin → single (suppress
|
|
248
|
+
* when `configFanout=true`).
|
|
249
|
+
* 3. **`configFanout=true` with no pin** (D1.3): fanout; arms =
|
|
250
|
+
* `routing.search` filtered/deduped (if set), else configured ∩
|
|
251
|
+
* advertising in registry order.
|
|
252
|
+
* 4. **Default** (D1.4): single; arm = first configured ∩
|
|
253
|
+
* advertising in registry order (informational — the dispatcher
|
|
254
|
+
* still consults `resolveEffectiveProvider` for quota ranking).
|
|
255
|
+
*
|
|
256
|
+
* The resolver exposes its decision only through the returned
|
|
257
|
+
* `FanoutPlan`; the dispatcher hinges on `mode`. The truth table is
|
|
258
|
+
* pinned by Ticket 3 tests.
|
|
259
|
+
*/
|
|
260
|
+
export function resolveFanoutPlan(options) {
|
|
261
|
+
const { explicitProviderRaw, env, configFanout, routing, descriptors } = options;
|
|
262
|
+
// Tier 1: explicit raw. parseProviderIds does the heavy lifting:
|
|
263
|
+
// comma-list → IDs, "all" → sentinel, single id → one-element
|
|
264
|
+
// array, garbage → null. We then translate each result into the
|
|
265
|
+
// dispatcher-facing mode. Unknown ids are left to the existing
|
|
266
|
+
// `resolveProviderId` path to surface VALIDATION_ERROR.
|
|
267
|
+
if (explicitProviderRaw !== undefined && explicitProviderRaw.trim().length > 0) {
|
|
268
|
+
const parsed = parseProviderIds(explicitProviderRaw);
|
|
269
|
+
if (parsed === "all") {
|
|
270
|
+
// `"all"` expands NOW so the executor never sees the sentinel.
|
|
271
|
+
return { mode: "fanout", arms: eligibleSearchArms(descriptors, env) };
|
|
272
|
+
}
|
|
273
|
+
if (Array.isArray(parsed)) {
|
|
274
|
+
if (parsed.length > 1) {
|
|
275
|
+
return { mode: "fanout", arms: parsed };
|
|
276
|
+
}
|
|
277
|
+
// Single id → single mode. Suppress notice when fanout=true so the
|
|
278
|
+
// user knows the explicit pin precedence overrode the switch.
|
|
279
|
+
const arm = parsed[0];
|
|
280
|
+
if (configFanout) {
|
|
281
|
+
return { mode: "single", arms: [arm], suppress: `explicit pin: fan-out ignored` };
|
|
282
|
+
}
|
|
283
|
+
return { mode: "single", arms: [arm] };
|
|
284
|
+
}
|
|
285
|
+
// parsed === null: a non-empty explicit value that names no valid
|
|
286
|
+
// id, comma list, or the "all" sentinel. Surface the typed
|
|
287
|
+
// VALIDATION_ERROR HERE (parseProviderId owns the CLI's
|
|
288
|
+
// provider-error wording and throws for every input this parse
|
|
289
|
+
// rejects) — falling through would let tier 3 (fanout=true)
|
|
290
|
+
// silently convert the typo'd pin into a fan-out activation and
|
|
291
|
+
// the error would never surface (review fix, PR #36).
|
|
292
|
+
parseProviderId(explicitProviderRaw);
|
|
293
|
+
// Unreachable: parseProviderId throws for every null-parse input.
|
|
294
|
+
// The return keeps the branch total should the parsers diverge.
|
|
295
|
+
return { mode: "single", arms: [] };
|
|
296
|
+
}
|
|
297
|
+
// Tier 2: SCOUTLINE_PROVIDER env pin → single.
|
|
298
|
+
const envProvider = env.SCOUTLINE_PROVIDER;
|
|
299
|
+
if (envProvider !== undefined && envProvider.trim().length > 0) {
|
|
300
|
+
const arm = envProvider.trim().toLowerCase();
|
|
301
|
+
if (configFanout) {
|
|
302
|
+
return { mode: "single", arms: [arm], suppress: `SCOUTLINE_PROVIDER pin: fan-out ignored` };
|
|
303
|
+
}
|
|
304
|
+
return { mode: "single", arms: [arm] };
|
|
305
|
+
}
|
|
306
|
+
// Tier 3: fanout=true with no pin → fanout; arms = routing.search
|
|
307
|
+
// (filtered/deduped) when set, else configured ∩ advertising in
|
|
308
|
+
// registry order.
|
|
309
|
+
if (configFanout) {
|
|
310
|
+
const routed = routing?.["search"];
|
|
311
|
+
if (routed !== undefined && routed.length > 0) {
|
|
312
|
+
// Filter against configured∩advertising while preserving the
|
|
313
|
+
// routed order; dedupe by first encounter (the routing list is
|
|
314
|
+
// a user-supplied preference, so first-write wins on the user's
|
|
315
|
+
// intent).
|
|
316
|
+
const eligible = new Set(eligibleSearchArms(descriptors, env));
|
|
317
|
+
const arms = [];
|
|
318
|
+
const seen = new Set();
|
|
319
|
+
for (const id of routed) {
|
|
320
|
+
if (seen.has(id))
|
|
321
|
+
continue;
|
|
322
|
+
seen.add(id);
|
|
323
|
+
if (!eligible.has(id))
|
|
324
|
+
continue;
|
|
325
|
+
arms.push(id);
|
|
326
|
+
}
|
|
327
|
+
return { mode: "fanout", arms };
|
|
328
|
+
}
|
|
329
|
+
return { mode: "fanout", arms: eligibleSearchArms(descriptors, env) };
|
|
330
|
+
}
|
|
331
|
+
// Tier 4: default single path. The dispatcher still consults
|
|
332
|
+
// `resolveEffectiveProvider` for quota-ranked first-pick; the arm
|
|
333
|
+
// reported here is the informational first-eligible in registry
|
|
334
|
+
// order.
|
|
335
|
+
const firstEligible = eligibleSearchArms(descriptors, env);
|
|
336
|
+
return { mode: "single", arms: firstEligible.slice(0, 1) };
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Run a single arm: create the adapter, run one executeSearch PER
|
|
340
|
+
* SUB-QUERY, and format the normalized sources into one
|
|
341
|
+
* FormattedResult[] per sub-query. The arm owns its transport per
|
|
342
|
+
* call (the one-client-per-query pattern unconditionally — each
|
|
343
|
+
* executeSearch isolates its client exactly like the single-provider
|
|
344
|
+
* `--merge` sub-queries in `search`).
|
|
345
|
+
*/
|
|
346
|
+
async function runFanoutArm(armId, subQueries, options) {
|
|
347
|
+
const descriptor = options.descriptors.find((d) => d.id === armId);
|
|
348
|
+
if (!descriptor) {
|
|
349
|
+
// Should never happen — the resolver already filtered — but a
|
|
350
|
+
// surface error here is safer than a bare undefined deref.
|
|
351
|
+
throw new ValidationError(`Unknown provider "${armId}" in fan-out plan.`, "Re-run with a configured provider.");
|
|
352
|
+
}
|
|
353
|
+
const adapter = descriptor.create({ env: options.env });
|
|
354
|
+
const capability = adapter.search;
|
|
355
|
+
if (!capability) {
|
|
356
|
+
throw new UnsupportedCapabilityError(armId, "search");
|
|
357
|
+
}
|
|
358
|
+
const controls = buildControls(options.searchOptions);
|
|
359
|
+
// One executeSearch per (arm × sub-query): sub-queries run in
|
|
360
|
+
// parallel inside the arm (same scheduling as the single-provider
|
|
361
|
+
// `--merge` path) and each call owns its transport. The consumption
|
|
362
|
+
// sink + clock are threaded so every billable call records.
|
|
363
|
+
const perSubQuerySources = await Promise.all(subQueries.map((q) => {
|
|
364
|
+
const request = controls ? { query: q, controls } : { query: q };
|
|
365
|
+
return executeSearch(capability, request, {
|
|
366
|
+
count: options.searchOptions.count,
|
|
367
|
+
noCache: options.searchOptions.noCache,
|
|
368
|
+
retryPolicy: options.dependencies.retryPolicy,
|
|
369
|
+
}, {
|
|
370
|
+
cache: options.dependencies.cache,
|
|
371
|
+
sleep: options.dependencies.sleep,
|
|
372
|
+
random: options.dependencies.random,
|
|
373
|
+
...(options.dependencies.consume !== undefined
|
|
374
|
+
? { consume: options.dependencies.consume }
|
|
375
|
+
: {}),
|
|
376
|
+
...(options.dependencies.now !== undefined ? { now: options.dependencies.now } : {}),
|
|
377
|
+
});
|
|
378
|
+
}));
|
|
379
|
+
return perSubQuerySources.map((sources) => formatSources(sources, options.searchOptions.maxSummary));
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Build a single arm's notice line (D5). Every failed arm is a "drop"
|
|
383
|
+
* in the D6 sense ("drops as notices + data"): an
|
|
384
|
+
* `UnsupportedOptionError` names the rejected control; any other
|
|
385
|
+
* error names its code and a redacted message. The message is
|
|
386
|
+
* redacted via `redactCredentialString` so a transport error that
|
|
387
|
+
* echoes a credential never reaches stderr verbatim.
|
|
388
|
+
*/
|
|
389
|
+
function armNotice(armId, error, secrets) {
|
|
390
|
+
if (error instanceof UnsupportedOptionError) {
|
|
391
|
+
return `arm ${armId} dropped: UNSUPPORTED_OPTION (${error.option})`;
|
|
392
|
+
}
|
|
393
|
+
const code = error?.code ?? "UNKNOWN_ERROR";
|
|
394
|
+
const rawMessage = error?.message ?? String(error);
|
|
395
|
+
const safeMessage = redactCredentialString(rawMessage, [...secrets]);
|
|
396
|
+
return `arm ${armId} dropped: ${code}: ${safeMessage}`;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Execute the fan-out plan: parallel per-arm search, settled wrapper,
|
|
400
|
+
* per-arm notices, D5/D6 exit policy, combined summary notice. The
|
|
401
|
+
* single-pin path is NOT routed through this executor (Tier 2 / Tier 4
|
|
402
|
+
* stay verbatim through `executeWithFallback`); the executor asserts
|
|
403
|
+
* `mode === "fanout"` on entry so a misuse throws loudly.
|
|
404
|
+
*/
|
|
405
|
+
export async function executeFanoutPlan(plan, options, context) {
|
|
406
|
+
if (plan.mode !== "fanout") {
|
|
407
|
+
throw new ValidationError("executeFanoutPlan only accepts mode='fanout'.", "Use the single-pin path for single mode.");
|
|
408
|
+
}
|
|
409
|
+
const arms = plan.arms;
|
|
410
|
+
if (arms.length === 0) {
|
|
411
|
+
// D6: zero arms resolved → VALIDATION_ERROR naming the configured
|
|
412
|
+
// set and the registry so the operator sees both halves of the
|
|
413
|
+
// mismatch (what could serve vs. what exists).
|
|
414
|
+
const configuredSet = options.descriptors
|
|
415
|
+
.filter((d) => d.isConfigured(options.env, "search") && d.capabilities().has("search"))
|
|
416
|
+
.map((d) => d.id)
|
|
417
|
+
.join(", ") || "(none)";
|
|
418
|
+
const registrySet = options.descriptors.map((d) => d.id).join(", ") || "(none)";
|
|
419
|
+
throw new ValidationError(`Fan-out produced no arms; configured search providers: ${configuredSet} (registry: ${registrySet}).`, "Configure at least one provider's API key, or unset 'fanout' / routing.search.");
|
|
420
|
+
}
|
|
421
|
+
// Sub-query split (DESIGN D3: the merge grid is arms × sub-queries).
|
|
422
|
+
// `--merge` composes with fan-out — every arm runs every sub-query
|
|
423
|
+
// (review fix, PR #36); without it the raw query is one sub-query.
|
|
424
|
+
const subQueries = options.searchOptions.merge
|
|
425
|
+
? splitMergeSubQueries(options.query)
|
|
426
|
+
: [options.query];
|
|
427
|
+
// Run every arm in parallel. Promise.allSettled ensures one arm's
|
|
428
|
+
// failure does not abort the rest; the per-arm pipeline is isolated
|
|
429
|
+
// (one client per arm, no fallback chain).
|
|
430
|
+
const settled = await Promise.allSettled(arms.map((armId) => runFanoutArm(armId, subQueries, options).then((results) => ({ arm: armId, results }))));
|
|
431
|
+
const successes = [];
|
|
432
|
+
const failures = [];
|
|
433
|
+
for (let i = 0; i < settled.length; i++) {
|
|
434
|
+
const entry = settled[i];
|
|
435
|
+
const armId = arms[i];
|
|
436
|
+
if (entry !== undefined && entry.status === "fulfilled") {
|
|
437
|
+
successes.push(entry.value);
|
|
438
|
+
}
|
|
439
|
+
else if (entry !== undefined) {
|
|
440
|
+
failures.push({ arm: armId, error: entry.reason });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// Per-arm notices (D5). Every non-success arm surfaces here as a
|
|
444
|
+
// drop; UnsupportedOptionError carries the rejected control name.
|
|
445
|
+
if (context) {
|
|
446
|
+
for (const { arm, error } of failures) {
|
|
447
|
+
context.notice(armNotice(arm, error, options.secrets ?? []));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// D6: all-fail → throw the last arm's typed error through the boundary.
|
|
451
|
+
if (successes.length === 0) {
|
|
452
|
+
const last = failures[failures.length - 1];
|
|
453
|
+
if (last)
|
|
454
|
+
throw last.error;
|
|
455
|
+
throw new Error("Fan-out produced no successes and no failures; plan was empty after filtering.");
|
|
456
|
+
}
|
|
457
|
+
// Build the (arm × sub-query) grid for mergeResults. Each success
|
|
458
|
+
// carries one FormattedResult[] per sub-query; occurrence counting
|
|
459
|
+
// and mergedFrom accumulation span the whole grid (D3).
|
|
460
|
+
const grid = successes.map((s) => ({
|
|
461
|
+
provider: s.arm,
|
|
462
|
+
results: s.results,
|
|
463
|
+
}));
|
|
464
|
+
const merged = mergeResults(grid, {
|
|
465
|
+
emitMergedFrom: true,
|
|
466
|
+
count: options.searchOptions.count,
|
|
467
|
+
});
|
|
468
|
+
// Summary notice (D5). K = total raw results across the whole
|
|
469
|
+
// arms × sub-queries grid (the post-truncate counts the executor
|
|
470
|
+
// collected); M = merged unique count. An arm with 0 results
|
|
471
|
+
// contributes 0 to K — the notice still names the arm so the
|
|
472
|
+
// operator sees the full set.
|
|
473
|
+
const totalRaw = successes.reduce((sum, s) => sum + s.results.reduce((n, list) => n + list.length, 0), 0);
|
|
474
|
+
if (context) {
|
|
475
|
+
const armList = arms.join(", ");
|
|
476
|
+
context.notice(`fanned out to ${arms.length} providers (${armList}) → ${merged.length} unique of ${totalRaw} results`);
|
|
477
|
+
}
|
|
478
|
+
const presentations = buildPresentations(merged);
|
|
479
|
+
const data = options.searchOptions.fields && options.searchOptions.fields.length > 0
|
|
480
|
+
? merged.map((r) => filterFields(r, options.searchOptions.fields))
|
|
481
|
+
: merged;
|
|
482
|
+
return { kind: "data", data, presentations };
|
|
483
|
+
}
|
|
174
484
|
// Help text
|
|
175
485
|
export const SEARCH_HELP = `
|
|
176
486
|
Search Command - Real-time web search (Z.AI, MiniMax, Tavily, Exa, or Brave)
|
|
@@ -181,6 +491,36 @@ Provider selection (precedence: explicit flag, then SCOUTLINE_PROVIDER, then zai
|
|
|
181
491
|
--provider <zai|minimax|tavily|exa|brave|firecrawl|parallel|perplexity|jina> Select the search provider (default: zai)
|
|
182
492
|
SCOUTLINE_PROVIDER=<id> Fallback when --provider is not passed
|
|
183
493
|
|
|
494
|
+
Multi-provider fan-out — activation tiers (highest precedence first):
|
|
495
|
+
1. --provider <tavily,exa,...> or --provider all
|
|
496
|
+
Fan-out on the listed providers; \`all\` expands to every
|
|
497
|
+
configured provider that advertises search, registry order.
|
|
498
|
+
2. A single --provider id, or SCOUTLINE_PROVIDER set
|
|
499
|
+
Single provider only — an explicit pin; fan-out is ignored.
|
|
500
|
+
3. \`scoutline config set fanout true\` (with no pin)
|
|
501
|
+
Fan-out on routing.search when set, else every configured
|
|
502
|
+
search provider, registry order.
|
|
503
|
+
4. No pin and fanout off (the default)
|
|
504
|
+
Single provider via the standard selection order.
|
|
505
|
+
|
|
506
|
+
Cost (when fan-out is active):
|
|
507
|
+
every search will bill ALL configured search providers — N arms = N billable calls.
|
|
508
|
+
Disable with \`scoutline config set fanout false\`.
|
|
509
|
+
|
|
510
|
+
Merging: arms run in parallel (one client each, pinned — no per-arm
|
|
511
|
+
fallback) and are deduped by canonical URL identity: scheme and host
|
|
512
|
+
lowercased; :80/:443 ports, fragments, and trailing slashes stripped;
|
|
513
|
+
utm_* and fbclid parameters removed (names are matched after
|
|
514
|
+
percent-decoding, so ?%66bclid=x collapses with ?fbclid=x; the raw
|
|
515
|
+
path — including dot segments — and userinfo are preserved verbatim).
|
|
516
|
+
The canonical form is a dedupe identity ONLY — the emitted url,
|
|
517
|
+
title, and summary are the first arm's originals. The first arm wins
|
|
518
|
+
metadata collisions; \`occurrences\` counts across all arms; the
|
|
519
|
+
merged list is sliced to --count after merging; \`--merge\` composes
|
|
520
|
+
with fan-out: every arm runs every sub-query and occurrences span
|
|
521
|
+
the arms × sub-queries grid. A failed arm is dropped with a stderr
|
|
522
|
+
notice; if every arm fails, the last arm's error surfaces.
|
|
523
|
+
|
|
184
524
|
Note: support for the optional controls below varies by provider AND by
|
|
185
525
|
control — a control accepted by one provider may be rejected
|
|
186
526
|
(UNSUPPORTED_OPTION) by another before invocation. Run
|
|
@@ -237,6 +577,7 @@ Default JSON shape:
|
|
|
237
577
|
"source": "example.com",
|
|
238
578
|
"date": "2024-01-15",
|
|
239
579
|
"occurrences": 2 // only present with --merge when >1
|
|
580
|
+
"mergedFrom": ["tavily", "exa"] // fan-out only: arms that surfaced this result
|
|
240
581
|
}
|
|
241
582
|
]
|
|
242
583
|
`.trim();
|