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.
Files changed (41) hide show
  1. package/README.md +68 -11
  2. package/dist/capabilities/repository.d.ts +96 -0
  3. package/dist/capabilities/repository.d.ts.map +1 -1
  4. package/dist/capabilities/repository.js.map +1 -1
  5. package/dist/commands/cache.d.ts +38 -0
  6. package/dist/commands/cache.d.ts.map +1 -1
  7. package/dist/commands/cache.js +64 -5
  8. package/dist/commands/cache.js.map +1 -1
  9. package/dist/commands/config.d.ts +17 -1
  10. package/dist/commands/config.d.ts.map +1 -1
  11. package/dist/commands/config.js +25 -2
  12. package/dist/commands/config.js.map +1 -1
  13. package/dist/commands/repo.d.ts +96 -1
  14. package/dist/commands/repo.d.ts.map +1 -1
  15. package/dist/commands/repo.js +420 -0
  16. package/dist/commands/repo.js.map +1 -1
  17. package/dist/commands/search.d.ts +150 -0
  18. package/dist/commands/search.d.ts.map +1 -1
  19. package/dist/commands/search.js +368 -27
  20. package/dist/commands/search.js.map +1 -1
  21. package/dist/index.d.ts +199 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +352 -20
  24. package/dist/index.js.map +1 -1
  25. package/dist/lib/cache.d.ts +111 -7
  26. package/dist/lib/cache.d.ts.map +1 -1
  27. package/dist/lib/cache.js +316 -7
  28. package/dist/lib/cache.js.map +1 -1
  29. package/dist/lib/config-store.d.ts +51 -1
  30. package/dist/lib/config-store.d.ts.map +1 -1
  31. package/dist/lib/config-store.js +90 -6
  32. package/dist/lib/config-store.js.map +1 -1
  33. package/dist/lib/url.d.ts +43 -0
  34. package/dist/lib/url.d.ts.map +1 -0
  35. package/dist/lib/url.js +172 -0
  36. package/dist/lib/url.js.map +1 -0
  37. package/dist/providers/selection.d.ts +21 -0
  38. package/dist/providers/selection.d.ts.map +1 -1
  39. package/dist/providers/selection.js +58 -0
  40. package/dist/providers/selection.js.map +1 -1
  41. 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;AAIvD,KAAK,aAAa,GAAG,QAAQ,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,GAAG,SAAS,CAAC;AAE/E,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB,WAAW,CAAC,EAAE,QAAQ,GAAG,MAAM,CAAC;IAChC,QAAQ,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACvB,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;;;;GAKG;AACH,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,UAAU,EAAE,gBAAgB,CAAC;IACtC,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C,QAAQ,CAAC,MAAM,EAAE,MAAM,MAAM,CAAC;IAC9B,QAAQ,CAAC,WAAW,CAAC,EAAE,WAAW,CAAC;CACpC;AA+HD,wBAAsB,MAAM,CAC1B,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,aAAa,YAAK,EAC3B,IAAI,EAAE,2BAA2B,EACjC,OAAO,CAAC,EAAE,cAAc,GACvB,OAAO,CAAC,aAAa,CAAC,CA2DxB;AAGD,eAAO,MAAM,WAAW,QAmEhB,CAAC"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/commands/search.ts"],"names":[],"mappings":"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"}
@@ -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 N parallel sub-queries: dedupe by URL, rank by
56
- * (occurrence count desc, then best position asc). First sub-query's
57
- * title/summary wins for each URL (highest-priority query).
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(byQuery) {
71
+ export function mergeResults(grid, options = {}) {
72
+ const { emitMergedFrom = false, count } = options;
60
73
  const map = new Map();
61
- for (const [, results] of byQuery.entries()) {
62
- for (const r of results) {
63
- const existing = map.get(r.url);
64
- if (existing) {
65
- existing.occurrences += 1;
66
- existing.bestPos = Math.min(existing.bestPos, r.rank);
67
- }
68
- else {
69
- map.set(r.url, {
70
- ...r,
71
- occurrences: 1,
72
- bestPos: r.rank,
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
- const merged = Array.from(map.values());
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
- return { ...rest, rank: i + 1 };
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();