scoutline 0.21.2 → 0.22.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 +46 -1
- package/dist/capabilities/quota.d.ts +34 -8
- package/dist/capabilities/quota.d.ts.map +1 -1
- package/dist/capabilities/quota.js +8 -6
- package/dist/capabilities/quota.js.map +1 -1
- package/dist/capabilities/science.d.ts +15 -0
- package/dist/capabilities/science.d.ts.map +1 -1
- package/dist/capabilities/science.js +79 -0
- package/dist/capabilities/science.js.map +1 -1
- package/dist/commands/quota.d.ts.map +1 -1
- package/dist/commands/quota.js +3 -0
- package/dist/commands/quota.js.map +1 -1
- package/dist/commands/science.d.ts +13 -0
- package/dist/commands/science.d.ts.map +1 -1
- package/dist/commands/science.js +243 -48
- package/dist/commands/science.js.map +1 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +78 -38
- package/dist/index.js.map +1 -1
- package/dist/lib/artifacts.d.ts.map +1 -1
- package/dist/lib/artifacts.js +4 -1
- package/dist/lib/artifacts.js.map +1 -1
- package/dist/lib/async-job-state.d.ts +17 -0
- package/dist/lib/async-job-state.d.ts.map +1 -1
- package/dist/lib/async-job-state.js +29 -0
- package/dist/lib/async-job-state.js.map +1 -1
- package/dist/lib/batch-manifest.d.ts +9 -0
- package/dist/lib/batch-manifest.d.ts.map +1 -1
- package/dist/lib/batch-manifest.js +8 -0
- package/dist/lib/batch-manifest.js.map +1 -1
- package/dist/lib/cache.d.ts +89 -10
- package/dist/lib/cache.d.ts.map +1 -1
- package/dist/lib/cache.js +190 -42
- package/dist/lib/cache.js.map +1 -1
- package/dist/lib/errors.d.ts +29 -3
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +29 -5
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/execution.d.ts +14 -6
- package/dist/lib/execution.d.ts.map +1 -1
- package/dist/lib/execution.js +50 -6
- package/dist/lib/execution.js.map +1 -1
- package/dist/lib/mcp-client.d.ts.map +1 -1
- package/dist/lib/mcp-client.js +9 -6
- package/dist/lib/mcp-client.js.map +1 -1
- package/dist/lib/redact.d.ts +3 -0
- package/dist/lib/redact.d.ts.map +1 -1
- package/dist/lib/redact.js +29 -4
- package/dist/lib/redact.js.map +1 -1
- package/dist/lib/retry-after.d.ts +54 -0
- package/dist/lib/retry-after.d.ts.map +1 -0
- package/dist/lib/retry-after.js +124 -0
- package/dist/lib/retry-after.js.map +1 -0
- package/dist/lib/tool-cache.d.ts +6 -4
- package/dist/lib/tool-cache.d.ts.map +1 -1
- package/dist/lib/tool-cache.js +9 -7
- package/dist/lib/tool-cache.js.map +1 -1
- package/dist/lib/tty.d.ts.map +1 -1
- package/dist/lib/tty.js +10 -0
- package/dist/lib/tty.js.map +1 -1
- package/dist/providers/arxiv/client.d.ts.map +1 -1
- package/dist/providers/arxiv/client.js +5 -4
- package/dist/providers/arxiv/client.js.map +1 -1
- package/dist/providers/brave/adapter.d.ts.map +1 -1
- package/dist/providers/brave/adapter.js +9 -3
- package/dist/providers/brave/adapter.js.map +1 -1
- package/dist/providers/brave/client.d.ts.map +1 -1
- package/dist/providers/brave/client.js +59 -7
- package/dist/providers/brave/client.js.map +1 -1
- package/dist/providers/crossref/client.d.ts.map +1 -1
- package/dist/providers/crossref/client.js +5 -4
- package/dist/providers/crossref/client.js.map +1 -1
- package/dist/providers/europepmc/client.d.ts.map +1 -1
- package/dist/providers/europepmc/client.js +5 -4
- package/dist/providers/europepmc/client.js.map +1 -1
- package/dist/providers/exa/adapter.d.ts +7 -0
- package/dist/providers/exa/adapter.d.ts.map +1 -1
- package/dist/providers/exa/adapter.js +11 -3
- package/dist/providers/exa/adapter.js.map +1 -1
- package/dist/providers/firecrawl/adapter.d.ts.map +1 -1
- package/dist/providers/firecrawl/adapter.js +12 -4
- package/dist/providers/firecrawl/adapter.js.map +1 -1
- package/dist/providers/firecrawl/client.d.ts.map +1 -1
- package/dist/providers/firecrawl/client.js +7 -0
- package/dist/providers/firecrawl/client.js.map +1 -1
- package/dist/providers/jina/adapter.d.ts.map +1 -1
- package/dist/providers/jina/adapter.js +10 -3
- package/dist/providers/jina/adapter.js.map +1 -1
- package/dist/providers/jina/client.d.ts.map +1 -1
- package/dist/providers/jina/client.js +23 -10
- package/dist/providers/jina/client.js.map +1 -1
- package/dist/providers/linkup/adapter.d.ts.map +1 -1
- package/dist/providers/linkup/adapter.js +13 -12
- package/dist/providers/linkup/adapter.js.map +1 -1
- package/dist/providers/openalex/adapter.d.ts.map +1 -1
- package/dist/providers/openalex/adapter.js +2 -0
- package/dist/providers/openalex/adapter.js.map +1 -1
- package/dist/providers/openalex/client.d.ts.map +1 -1
- package/dist/providers/openalex/client.js +6 -5
- package/dist/providers/openalex/client.js.map +1 -1
- package/dist/providers/parallel/adapter.d.ts.map +1 -1
- package/dist/providers/parallel/adapter.js +17 -6
- package/dist/providers/parallel/adapter.js.map +1 -1
- package/dist/providers/pubmed/client.d.ts.map +1 -1
- package/dist/providers/pubmed/client.js +5 -4
- package/dist/providers/pubmed/client.js.map +1 -1
- package/dist/providers/registry.d.ts +12 -0
- package/dist/providers/registry.d.ts.map +1 -1
- package/dist/providers/registry.js +12 -0
- package/dist/providers/registry.js.map +1 -1
- package/dist/providers/tavily/adapter.d.ts.map +1 -1
- package/dist/providers/tavily/adapter.js +12 -5
- package/dist/providers/tavily/adapter.js.map +1 -1
- package/dist/providers/tavily/client.d.ts.map +1 -1
- package/dist/providers/tavily/client.js +8 -0
- package/dist/providers/tavily/client.js.map +1 -1
- package/dist/providers/zai/quota.d.ts +25 -5
- package/dist/providers/zai/quota.d.ts.map +1 -1
- package/dist/providers/zai/quota.js +122 -29
- package/dist/providers/zai/quota.js.map +1 -1
- package/package.json +3 -3
- package/skills/scoutline/SKILL.md +93 -82
package/dist/commands/science.js
CHANGED
|
@@ -35,16 +35,17 @@
|
|
|
35
35
|
* with a stderr note; `--no-fallback` fails strict.
|
|
36
36
|
*/
|
|
37
37
|
import { invokeCommand } from "../command-invocation.js";
|
|
38
|
-
import { parseScienceIdentifier } from "../capabilities/science.js";
|
|
38
|
+
import { decodeScienceWork, decodeScienceWorks, parseScienceIdentifier, } from "../capabilities/science.js";
|
|
39
39
|
import { applyBudget } from "../lib/output-budget.js";
|
|
40
40
|
import { persistCompaction } from "../lib/output-budget-persistence.js";
|
|
41
41
|
import { redactSecrets } from "../lib/redact.js";
|
|
42
|
-
import { ApiError, UnsupportedOptionError, ValidationError } from "../lib/errors.js";
|
|
42
|
+
import { ApiError, TimeoutError, UnsupportedOptionError, ValidationError } from "../lib/errors.js";
|
|
43
|
+
import { executeProviderOperation } from "../lib/execution.js";
|
|
43
44
|
import { parseBriefMaxChars } from "./repo.js";
|
|
44
45
|
import { createSaveArtifactHook } from "../lib/save-artifacts.js";
|
|
45
46
|
import { resolveArtifactsDir } from "../lib/artifacts.js";
|
|
46
47
|
import { buildProviderCacheKey } from "../lib/cache.js";
|
|
47
|
-
import { appendJournalEntry, buildJournalEntry, buildSearchSkeleton, } from "../lib/journal.js";
|
|
48
|
+
import { appendJournalEntry, appendJournalEntryMaybeRepeat, buildJournalEntry, buildJournalRepeatMarker, buildSearchSkeleton, } from "../lib/journal.js";
|
|
48
49
|
// ---------------------------------------------------------------------------
|
|
49
50
|
// D5 arm order (executor-side rule, NOT the registry listing order)
|
|
50
51
|
// ---------------------------------------------------------------------------
|
|
@@ -90,6 +91,7 @@ Search options:
|
|
|
90
91
|
--type <type> Filter by work type: article, preprint,
|
|
91
92
|
conference-paper, chapter, dataset, review, other
|
|
92
93
|
--provider <id> Pin one supplier (${D5_ARM_ORDER.join(", ")}); "all" is the default fan-out
|
|
94
|
+
--no-cache Bypass the response cache for this invocation (search and get)
|
|
93
95
|
|
|
94
96
|
Identifier grammar (get):
|
|
95
97
|
DOI 10.1038/nature12373 (bare — no "doi:" prefix)
|
|
@@ -230,6 +232,7 @@ async function applyScienceOutputBudget(result, maxChars, options) {
|
|
|
230
232
|
command: "science",
|
|
231
233
|
args: {
|
|
232
234
|
...(options.explicitProvider !== undefined ? { provider: options.explicitProvider } : {}),
|
|
235
|
+
...(options.noCache ? { "no-cache": true } : {}),
|
|
233
236
|
},
|
|
234
237
|
provider: {
|
|
235
238
|
mode: "single",
|
|
@@ -538,22 +541,43 @@ function mergeScienceWorks(works) {
|
|
|
538
541
|
* get path's effective-arm behavior.
|
|
539
542
|
*/
|
|
540
543
|
async function runScienceSearchWithReroute(pinned, options) {
|
|
541
|
-
const { request, env, descriptors, notice, journal, signal } = options;
|
|
544
|
+
const { request, env, descriptors, notice, journal, signal, sleep, random } = options;
|
|
542
545
|
const capability = pinned.create({ env }).science?.search;
|
|
543
546
|
if (capability === undefined) {
|
|
544
547
|
throw new ValidationError(`Provider "${pinned.id}" does not provide science search.`, `Science suppliers: ${D5_ARM_ORDER.join(", ")}.`);
|
|
545
548
|
}
|
|
546
549
|
capability.validate(request);
|
|
547
|
-
|
|
550
|
+
// #140 ruling 5: the identity consult is no longer journal-gated —
|
|
551
|
+
// the response cache needs the key whenever it is consulted.
|
|
552
|
+
const identity = capability.cacheIdentity?.(request);
|
|
553
|
+
const cacheKey = scienceCacheKey(identity);
|
|
554
|
+
// #140 T3: consult before invoke. A hit serves with ZERO invokes and
|
|
555
|
+
// no reroute notice (the cache never failed).
|
|
556
|
+
if (!options.noCache && cacheKey !== undefined) {
|
|
557
|
+
const cached = await options.cache.get(cacheKey, decodeScienceWorks);
|
|
558
|
+
if (cached !== null) {
|
|
559
|
+
// Wave 1 F4: a pre-aborted signal must never serve warm results
|
|
560
|
+
// (#47/#151 — the consult sits outside the retry executor's own
|
|
561
|
+
// pre-invoke check, so the guard lives at the hit).
|
|
562
|
+
if (signal?.aborted) {
|
|
563
|
+
throwCallerAborted();
|
|
564
|
+
}
|
|
565
|
+
return { works: cached, identity, armId: pinned.id, servedFrom: "cache" };
|
|
566
|
+
}
|
|
567
|
+
}
|
|
548
568
|
try {
|
|
549
|
-
|
|
569
|
+
const works = await executeProviderOperation("science-search", () => capability.invoke(request, signal), { sleep, random }, undefined, undefined, signal);
|
|
570
|
+
if (!options.noCache && cacheKey !== undefined) {
|
|
571
|
+
await options.cache.set(cacheKey, works);
|
|
572
|
+
}
|
|
573
|
+
return { works, identity, armId: pinned.id, servedFrom: "live" };
|
|
550
574
|
}
|
|
551
575
|
catch (error) {
|
|
552
576
|
// A caller cancel ends the walk. Rerouting from a user's Ctrl-C would
|
|
553
577
|
// attempt the next arm only to fast-fail at its pre-abort check and
|
|
554
578
|
// emit a misleading "rerouting to <arm>" notice for a cancellation.
|
|
555
579
|
if (signal?.aborted) {
|
|
556
|
-
|
|
580
|
+
throwCallerAborted();
|
|
557
581
|
}
|
|
558
582
|
// eligible = configured + capable + validating, D5 order, pin first
|
|
559
583
|
const order = [pinned.id, ...D5_ARM_ORDER.filter((id) => id !== pinned.id)];
|
|
@@ -564,7 +588,7 @@ async function runScienceSearchWithReroute(pinned, options) {
|
|
|
564
588
|
// checks while emitting misleading reroute notices.
|
|
565
589
|
// defense-in-depth: the per-attempt guard below normally fires first
|
|
566
590
|
if (signal?.aborted) {
|
|
567
|
-
|
|
591
|
+
throwCallerAborted();
|
|
568
592
|
}
|
|
569
593
|
const next = byId.get(id);
|
|
570
594
|
if (next === undefined)
|
|
@@ -582,18 +606,37 @@ async function runScienceSearchWithReroute(pinned, options) {
|
|
|
582
606
|
catch {
|
|
583
607
|
continue; // a rejecting arm is excluded, never the reroute target
|
|
584
608
|
}
|
|
585
|
-
|
|
609
|
+
// #140 ruling 5: identity unconditional at the consult site.
|
|
610
|
+
const nextIdentity = nextCapability.cacheIdentity?.(request);
|
|
611
|
+
const nextCacheKey = scienceCacheKey(nextIdentity);
|
|
612
|
+
if (!options.noCache && nextCacheKey !== undefined) {
|
|
613
|
+
const cachedNext = await options.cache.get(nextCacheKey, decodeScienceWorks);
|
|
614
|
+
if (cachedNext !== null) {
|
|
615
|
+
// Wave 1 F4: same pre-abort guard on the reroute-arm consult.
|
|
616
|
+
if (signal?.aborted) {
|
|
617
|
+
throwCallerAborted();
|
|
618
|
+
}
|
|
619
|
+
// The PINNED arm failed at invoke — its failure is still
|
|
620
|
+
// disclosed (visible narrowing); the serving attempt itself
|
|
621
|
+
// was a cache hit, so it emits nothing of its own.
|
|
622
|
+
notice(`scoutline: ${pinned.id} search failed (${error instanceof Error ? error.message : String(error)}) — rerouting to ${next.id}.`);
|
|
623
|
+
return { works: cachedNext, identity: nextIdentity, armId: next.id, servedFrom: "cache" };
|
|
624
|
+
}
|
|
625
|
+
}
|
|
586
626
|
try {
|
|
587
|
-
const works = await nextCapability.invoke(request, signal);
|
|
627
|
+
const works = await executeProviderOperation("science-search", () => nextCapability.invoke(request, signal), { sleep, random }, undefined, undefined, signal);
|
|
628
|
+
if (!options.noCache && nextCacheKey !== undefined) {
|
|
629
|
+
await options.cache.set(nextCacheKey, works);
|
|
630
|
+
}
|
|
588
631
|
notice(`scoutline: ${pinned.id} search failed (${error instanceof Error ? error.message : String(error)}) — rerouting to ${next.id}.`);
|
|
589
|
-
return { works, identity: nextIdentity, armId: next.id };
|
|
632
|
+
return { works, identity: nextIdentity, armId: next.id, servedFrom: "live" };
|
|
590
633
|
}
|
|
591
634
|
catch (nextError) {
|
|
592
635
|
// A caller cancel DURING a reroute attempt surfaces that
|
|
593
636
|
// attempt's honest abort error and ends the walk — no further
|
|
594
637
|
// arms, no "dropped from this reroute walk" notice.
|
|
595
638
|
if (signal?.aborted) {
|
|
596
|
-
|
|
639
|
+
throwCallerAborted();
|
|
597
640
|
}
|
|
598
641
|
notice(`scoutline: ${next.id} search failed (${nextError instanceof Error ? nextError.message : String(nextError)}) — dropped from this reroute walk.`);
|
|
599
642
|
continue;
|
|
@@ -604,11 +647,30 @@ async function runScienceSearchWithReroute(pinned, options) {
|
|
|
604
647
|
throw error;
|
|
605
648
|
}
|
|
606
649
|
}
|
|
650
|
+
/**
|
|
651
|
+
* Wave 3 (Kody, PR #182): the one honest caller-cancellation error every
|
|
652
|
+
* science abort site throws — walk cancels, consult pre-abort guards,
|
|
653
|
+
* per-arm warm-hit guards, and the post-settle warm re-check. Named so
|
|
654
|
+
* the byte-identical message lives in exactly one place.
|
|
655
|
+
*/
|
|
656
|
+
function throwCallerAborted() {
|
|
657
|
+
throw new ApiError("science request was aborted by the caller (Ctrl-C or external signal)", 499);
|
|
658
|
+
}
|
|
607
659
|
function isAbortClassed(reason) {
|
|
608
660
|
if (reason instanceof ApiError && reason.statusCode === 499)
|
|
609
661
|
return true;
|
|
610
662
|
if (reason instanceof Error && /aborted by the caller/.test(reason.message))
|
|
611
663
|
return true;
|
|
664
|
+
// #140 fix round (external review F1/F2): the retry executor classifies
|
|
665
|
+
// caller cancellation as TimeoutError, but the abort wording lives in
|
|
666
|
+
// TimeoutError.HELP — .message is always "Request timed out after Nms",
|
|
667
|
+
// so the old /aborted/-on-.message branch was dead code and an
|
|
668
|
+
// abort-during-backoff arm printed a misleading drop notice. Match the
|
|
669
|
+
// help field; a genuine pre-abort failure (plain ApiError 5xx) keeps
|
|
670
|
+
// its disclosure (#151 r2 pin).
|
|
671
|
+
if (reason instanceof TimeoutError && /aborted/.test(String(reason.help ?? ""))) {
|
|
672
|
+
return true;
|
|
673
|
+
}
|
|
612
674
|
return false;
|
|
613
675
|
}
|
|
614
676
|
// ---------------------------------------------------------------------------
|
|
@@ -638,10 +700,12 @@ function renderWorkText(work) {
|
|
|
638
700
|
* the `supplier`/`capability` naming differs from the
|
|
639
701
|
* provider-capability identities the capture wrapper keys off, so the
|
|
640
702
|
* journal derives the SAME partitioned-key shape the supplier's
|
|
641
|
-
* response cache
|
|
642
|
-
*
|
|
703
|
+
* response cache is keyed under (since #140 science consults and
|
|
704
|
+
* fills that cache at its own invoke sites, deriving the key right
|
|
705
|
+
* there): `buildProviderCacheKey` over the identity, namespace
|
|
706
|
+
* verbatim.
|
|
643
707
|
*/
|
|
644
|
-
function scienceCacheKey(identity) {
|
|
708
|
+
export function scienceCacheKey(identity) {
|
|
645
709
|
if (identity === null || typeof identity !== "object")
|
|
646
710
|
return undefined;
|
|
647
711
|
const record = identity;
|
|
@@ -659,22 +723,26 @@ function scienceCacheKey(identity) {
|
|
|
659
723
|
}
|
|
660
724
|
/**
|
|
661
725
|
* T7 journal hook — the science twin of main's `createJournalHook`,
|
|
662
|
-
* scoped to the
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
726
|
+
* scoped to the direct-invoke executor (which since #140 consults the
|
|
727
|
+
* response cache per arm/attempt before invoking, and invokes through
|
|
728
|
+
* the shared `executeProviderOperation` retry seam — one retry on
|
|
729
|
+
* transient timeout/network/429/5xx failures, QuotaError terminal).
|
|
730
|
+
* Facts, all read AFTER dispatch
|
|
666
731
|
* resolves (thunks, matching the search precedent):
|
|
667
732
|
* - query: what the USER passed verbatim — the search query or the
|
|
668
733
|
* get identifier (AC-12: journaled identity = user-visible
|
|
669
734
|
* identity, never a supplier-munged form).
|
|
670
|
-
* - provider:
|
|
671
|
-
* {mode:"single", effective
|
|
672
|
-
*
|
|
673
|
-
*
|
|
735
|
+
* - provider: {mode:"fanout", arms} for a run that fanned out,
|
|
736
|
+
* {mode:"single", effective, servedFrom} otherwise. servedFrom and
|
|
737
|
+
* the every-arm-cache gate read the per-arm hit truth threaded in
|
|
738
|
+
* by handleScience (#140 T5, ruling 4 — never the capture cell).
|
|
674
739
|
* - cacheKey/skeleton: the supplier's own science cacheIdentity
|
|
675
740
|
* recomputed through the capture wrapper's key derivation, and the
|
|
676
741
|
* url+title skeleton — the merged result-set list (search) or the
|
|
677
742
|
* single-work row (get).
|
|
743
|
+
* A cache-served run appends via appendJournalEntryMaybeRepeat — the
|
|
744
|
+
* tiny repeat marker when the cacheKey map resolves a prior FULL
|
|
745
|
+
* entry, journal-cold → that same full entry (honest servedFrom).
|
|
678
746
|
* Runs where no supplier resolved (pre-dispatch failures threw before
|
|
679
747
|
* this hook could exist) journal nothing; a capture without a
|
|
680
748
|
* cacheKey skips rather than poisons the log (validator NIT 1).
|
|
@@ -682,6 +750,7 @@ function scienceCacheKey(identity) {
|
|
|
682
750
|
function createScienceJournalHook(deps, meta) {
|
|
683
751
|
const { capability, capture } = meta.journal;
|
|
684
752
|
return async ({ resolvedSecrets, now }) => {
|
|
753
|
+
const artifactsDir = resolveArtifactsDir(deps.env);
|
|
685
754
|
const servedProvider = capture.servedProvider;
|
|
686
755
|
if (servedProvider === undefined)
|
|
687
756
|
return;
|
|
@@ -701,15 +770,19 @@ function createScienceJournalHook(deps, meta) {
|
|
|
701
770
|
// all-rejected run throws before resultRows resolves.
|
|
702
771
|
const arms = meta.arms?.();
|
|
703
772
|
const fannedOut = meta.fannedOut?.() === true;
|
|
773
|
+
// #140 T5 (ruling 4): the routing picks its own cache truth — the
|
|
774
|
+
// fan-out gate is EVERY attempted arm cache-served; the single shape
|
|
775
|
+
// reads the serving attempt's servedFrom. NEVER the capture cell
|
|
776
|
+
// (last-write-wins across arms; the save hook's seam).
|
|
777
|
+
const fanoutShape = fannedOut && arms !== undefined && arms.length > 0;
|
|
778
|
+
const everyArmCache = meta.everyArmCache?.() === true;
|
|
779
|
+
const servedFrom = meta.servedFrom?.() === "cache" ? "cache" : "live";
|
|
780
|
+
const provider = fanoutShape
|
|
781
|
+
? { mode: "fanout", arms }
|
|
782
|
+
: { mode: "single", effective: servedProvider, servedFrom };
|
|
704
783
|
const entry = buildJournalEntry({
|
|
705
784
|
capability,
|
|
706
|
-
provider
|
|
707
|
-
? { mode: "fanout", arms }
|
|
708
|
-
: {
|
|
709
|
-
mode: "single",
|
|
710
|
-
effective: servedProvider,
|
|
711
|
-
servedFrom: "live",
|
|
712
|
-
},
|
|
785
|
+
provider,
|
|
713
786
|
query: meta.query,
|
|
714
787
|
cacheKey,
|
|
715
788
|
skeleton,
|
|
@@ -717,11 +790,28 @@ function createScienceJournalHook(deps, meta) {
|
|
|
717
790
|
secrets: resolvedSecrets,
|
|
718
791
|
...(capture.savedRequestId !== undefined ? { saveRef: capture.savedRequestId } : {}),
|
|
719
792
|
});
|
|
720
|
-
|
|
793
|
+
// #140 T5: a cache-served run (fan-out: every arm; single: the
|
|
794
|
+
// serving attempt) is a warm re-ask — tiny repeat marker when the
|
|
795
|
+
// cacheKey map resolves a prior FULL entry, journal-cold → the SAME
|
|
796
|
+
// full entry (honest servedFrom). Any live arm → full entry.
|
|
797
|
+
if ((fanoutShape && everyArmCache) || (!fanoutShape && servedFrom === "cache")) {
|
|
798
|
+
await appendJournalEntryMaybeRepeat(artifactsDir, entry, (repeatOf) => buildJournalRepeatMarker({
|
|
799
|
+
capability,
|
|
800
|
+
provider,
|
|
801
|
+
repeatOf,
|
|
802
|
+
...(capture.savedRequestId !== undefined ? { saveRef: capture.savedRequestId } : {}),
|
|
803
|
+
now,
|
|
804
|
+
}));
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
await appendJournalEntry(artifactsDir, entry);
|
|
721
808
|
};
|
|
722
809
|
}
|
|
723
810
|
export async function handleScience(args, outputMode, deps, options = {}) {
|
|
724
811
|
const { subcommand, positional, flags, showHelp } = parseScienceArgs(args);
|
|
812
|
+
// #140: `--no-cache` skips BOTH the response-cache read and write for
|
|
813
|
+
// this invocation (house idiom: the valueless boolean flag form).
|
|
814
|
+
const noCache = flags["no-cache"] === true;
|
|
725
815
|
if (showHelp || subcommand === undefined) {
|
|
726
816
|
deps.invocation.writeStdout(SCIENCE_HELP);
|
|
727
817
|
return 0;
|
|
@@ -751,6 +841,7 @@ export async function handleScience(args, outputMode, deps, options = {}) {
|
|
|
751
841
|
args: {
|
|
752
842
|
...(explicitProvider !== undefined ? { provider: explicitProvider } : {}),
|
|
753
843
|
...(deps.fallbackEnabled ? {} : { "no-fallback": true }),
|
|
844
|
+
...(noCache ? { "no-cache": true } : {}),
|
|
754
845
|
},
|
|
755
846
|
provider: {
|
|
756
847
|
mode: "single",
|
|
@@ -799,6 +890,12 @@ export async function handleScience(args, outputMode, deps, options = {}) {
|
|
|
799
890
|
let journalRows;
|
|
800
891
|
let journalIdentity;
|
|
801
892
|
let journalArms;
|
|
893
|
+
// #140 T5: the single shape's cache truth — set by the reroute walk
|
|
894
|
+
// (pinned search) or the single-arm allSettled path below.
|
|
895
|
+
let journalServedFrom = "live";
|
|
896
|
+
// #140 T5: the every-arm-cache gate — computed from `armCacheHits`
|
|
897
|
+
// after the fan-out settles (every ATTEMPTED arm cache-served).
|
|
898
|
+
let journalEveryArmCache = false;
|
|
802
899
|
// T3: >=2 arms ATTEMPTED at resolution — captured before the one-arm
|
|
803
900
|
// reroute below (and before survivors are recomputed) so the routing
|
|
804
901
|
// SHAPE survives narrowing. `mode` follows this, not arms.length.
|
|
@@ -828,6 +925,10 @@ export async function handleScience(args, outputMode, deps, options = {}) {
|
|
|
828
925
|
descriptors: selectionOpts.descriptors,
|
|
829
926
|
notice: context.notice,
|
|
830
927
|
journal: deps.journal !== undefined,
|
|
928
|
+
cache: deps.scienceCache,
|
|
929
|
+
noCache,
|
|
930
|
+
sleep: deps.scienceSleep,
|
|
931
|
+
random: deps.scienceRandom,
|
|
831
932
|
signal: controller.signal,
|
|
832
933
|
});
|
|
833
934
|
if (served !== undefined) {
|
|
@@ -836,6 +937,10 @@ export async function handleScience(args, outputMode, deps, options = {}) {
|
|
|
836
937
|
journalIdentity = served.identity;
|
|
837
938
|
}
|
|
838
939
|
journalArms = [served.armId];
|
|
940
|
+
// #140 T5: the reroute walk's serving attempt is the single
|
|
941
|
+
// shape's cache truth (direct knowledge from the consult
|
|
942
|
+
// owner — never the capture cell).
|
|
943
|
+
journalServedFrom = served.servedFrom;
|
|
839
944
|
const single = {
|
|
840
945
|
kind: "data",
|
|
841
946
|
data: served.works,
|
|
@@ -847,6 +952,7 @@ export async function handleScience(args, outputMode, deps, options = {}) {
|
|
|
847
952
|
deps,
|
|
848
953
|
outputMode,
|
|
849
954
|
...(explicitProvider !== undefined ? { explicitProvider } : {}),
|
|
955
|
+
noCache,
|
|
850
956
|
});
|
|
851
957
|
}
|
|
852
958
|
}
|
|
@@ -854,25 +960,73 @@ export async function handleScience(args, outputMode, deps, options = {}) {
|
|
|
854
960
|
// orchestration shape). allSettled: a later arm's failure must
|
|
855
961
|
// not discard an earlier arm's already-merged works.
|
|
856
962
|
const armIdentities = [];
|
|
963
|
+
// #140 T2: per-arm cache-hit flags — direct truth from the
|
|
964
|
+
// consult owner. The journal's every-arm-cache gate (T5) reads
|
|
965
|
+
// THESE, never the capture cell (last-write-wins across arms
|
|
966
|
+
// and stays the save hook's seam).
|
|
967
|
+
const armCacheHits = [];
|
|
857
968
|
const settled = await Promise.allSettled(arms.map(async (arm, index) => {
|
|
858
969
|
const capability = arm.create({ env: deps.env }).science?.search;
|
|
859
970
|
if (capability === undefined) {
|
|
860
971
|
throw new ValidationError(`Provider "${arm.id}" does not provide science search.`, `Science suppliers: ${D5_ARM_ORDER.join(", ")}.`);
|
|
861
972
|
}
|
|
862
973
|
capability.validate(request);
|
|
863
|
-
// T7: the direct-invoke executor
|
|
864
|
-
// execution layer, so the supplier's
|
|
865
|
-
// cacheIdentity is consulted HERE —
|
|
866
|
-
// execution.ts step 2. Science
|
|
867
|
-
// (not `provider`); per-arm
|
|
868
|
-
// and the journal cacheKey is
|
|
869
|
-
// FULFILLED arm below (review: a
|
|
870
|
-
// not stamp the journal's provider
|
|
974
|
+
// T7: the direct-invoke executor runs outside the shared
|
|
975
|
+
// execution layer's cache step, so the supplier's
|
|
976
|
+
// (capture-wrapped) cacheIdentity is consulted HERE —
|
|
977
|
+
// pre-invoke, matching execution.ts step 2. Science
|
|
978
|
+
// identities use `supplier` (not `provider`); per-arm
|
|
979
|
+
// identities are captured here and the journal cacheKey is
|
|
980
|
+
// derived from the FIRST FULFILLED arm below (review: a
|
|
981
|
+
// failed first arm must not stamp the journal's provider
|
|
982
|
+
// partition).
|
|
983
|
+
// #140 ruling 5: the identity consult is no longer
|
|
984
|
+
// journal-gated — the response cache needs the key
|
|
985
|
+
// whenever it is consulted; extra capture-wrapper
|
|
986
|
+
// stamping on the added calls is harmless.
|
|
987
|
+
const identity = capability.cacheIdentity?.(request);
|
|
871
988
|
if (deps.journal !== undefined) {
|
|
872
|
-
armIdentities[index] =
|
|
989
|
+
armIdentities[index] = identity;
|
|
990
|
+
}
|
|
991
|
+
const cacheKey = scienceCacheKey(identity);
|
|
992
|
+
// #140 T2: per-arm cache consult. A hit is a FULFILLED arm
|
|
993
|
+
// (allSettled shape preserved — no invoke, no transport);
|
|
994
|
+
// a miss invokes and sets the FULL normalized works.
|
|
995
|
+
if (!noCache && cacheKey !== undefined) {
|
|
996
|
+
const cached = await deps.scienceCache.get(cacheKey, decodeScienceWorks);
|
|
997
|
+
if (cached !== null) {
|
|
998
|
+
// Wave 1 F4: a cancelled caller never receives warm
|
|
999
|
+
// results (the consult bypasses the executor's
|
|
1000
|
+
// pre-invoke check — the guard lives at the hit).
|
|
1001
|
+
if (controller.signal.aborted) {
|
|
1002
|
+
throwCallerAborted();
|
|
1003
|
+
}
|
|
1004
|
+
armCacheHits[index] = true;
|
|
1005
|
+
return cached;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
const works = await executeProviderOperation("science-search", () => capability.invoke(request, controller.signal), { sleep: deps.scienceSleep, random: deps.scienceRandom }, undefined, undefined, controller.signal);
|
|
1009
|
+
if (!noCache && cacheKey !== undefined) {
|
|
1010
|
+
await deps.scienceCache.set(cacheKey, works);
|
|
873
1011
|
}
|
|
874
|
-
return
|
|
1012
|
+
return works;
|
|
875
1013
|
}));
|
|
1014
|
+
// Wave 2 F6 (parent ruling, scoped): post-settle abort re-check
|
|
1015
|
+
// for the WARM path — a cancelled caller never receives results
|
|
1016
|
+
// no live work produced. Scoped to every-fulfilled-arm-was-cache-
|
|
1017
|
+
// served (armCacheHits is recorded at consult time REGARDLESS of
|
|
1018
|
+
// journaling, so a --no-journal run gets the same 499): the
|
|
1019
|
+
// #151 live-partial-abort contract (exit 0, survivors serve +
|
|
1020
|
+
// journal) is a deliberate, twice-pinned product ruling and
|
|
1021
|
+
// stays intact — an abort after genuinely-live completed arms
|
|
1022
|
+
// still serves. The per-arm hit guards (wave 1 F4) established
|
|
1023
|
+
// aborted+warm=499 at consult time; this closes the identical
|
|
1024
|
+
// gap between a passed guard and the merge.
|
|
1025
|
+
const warmOnly = settled.length > 0 &&
|
|
1026
|
+
settled.every((outcome, index) => outcome.status !== "fulfilled" || armCacheHits[index] === true);
|
|
1027
|
+
if (warmOnly && controller.signal.aborted) {
|
|
1028
|
+
throwCallerAborted();
|
|
1029
|
+
}
|
|
876
1030
|
// Deterministic failure: if every arm rejected, surface the
|
|
877
1031
|
// FIRST arm's (D5 order) error — never a silent all-fail.
|
|
878
1032
|
const firstRejected = settled.find((outcome) => outcome.status === "rejected");
|
|
@@ -895,7 +1049,7 @@ export async function handleScience(args, outputMode, deps, options = {}) {
|
|
|
895
1049
|
if (firstRejected !== undefined &&
|
|
896
1050
|
settled.every((outcome) => outcome.status === "rejected")) {
|
|
897
1051
|
if (controller.signal.aborted) {
|
|
898
|
-
|
|
1052
|
+
throwCallerAborted();
|
|
899
1053
|
}
|
|
900
1054
|
throw firstRejected.reason;
|
|
901
1055
|
}
|
|
@@ -919,6 +1073,16 @@ export async function handleScience(args, outputMode, deps, options = {}) {
|
|
|
919
1073
|
// set made a run with failed arms indistinguishable from a clean
|
|
920
1074
|
// full merge. settled order equals arms order (as above).
|
|
921
1075
|
journalArms = settled.flatMap((outcome, index) => outcome.status === "fulfilled" ? [arms[index]?.id ?? "unknown"] : []);
|
|
1076
|
+
// #140 T5 (ruling 4): the gate reads the per-arm hit flags —
|
|
1077
|
+
// every ATTEMPTED arm cache-served (a rejected arm's slot is
|
|
1078
|
+
// never true, so a mixed run stays a full entry). A !fannedOut
|
|
1079
|
+
// single-arm arrival through this path derives its servedFrom
|
|
1080
|
+
// from the same truth (arm index 0).
|
|
1081
|
+
journalEveryArmCache =
|
|
1082
|
+
settled.length > 0 && settled.every((_, index) => armCacheHits[index] === true);
|
|
1083
|
+
if (!journalFannedOut && settled.length === 1) {
|
|
1084
|
+
journalServedFrom = armCacheHits[0] === true ? "cache" : "live";
|
|
1085
|
+
}
|
|
922
1086
|
// T10 merge: DOI-first dedup identity (exact-url fallback) +
|
|
923
1087
|
// D12 field-wise union enrichment, first-arm (D5 order)
|
|
924
1088
|
// preference — mergeScienceWorks below.
|
|
@@ -935,6 +1099,7 @@ export async function handleScience(args, outputMode, deps, options = {}) {
|
|
|
935
1099
|
deps,
|
|
936
1100
|
outputMode,
|
|
937
1101
|
...(explicitProvider !== undefined ? { explicitProvider } : {}),
|
|
1102
|
+
noCache,
|
|
938
1103
|
});
|
|
939
1104
|
}, outputMode, deps.now, deps.secrets, saveHook, deps.journal === undefined
|
|
940
1105
|
? undefined
|
|
@@ -945,6 +1110,8 @@ export async function handleScience(args, outputMode, deps, options = {}) {
|
|
|
945
1110
|
cacheKey: () => scienceCacheKey(journalIdentity),
|
|
946
1111
|
arms: () => journalArms,
|
|
947
1112
|
fannedOut: () => journalFannedOut,
|
|
1113
|
+
servedFrom: () => journalServedFrom,
|
|
1114
|
+
everyArmCache: () => journalEveryArmCache,
|
|
948
1115
|
}));
|
|
949
1116
|
}
|
|
950
1117
|
// get
|
|
@@ -965,6 +1132,9 @@ export async function handleScience(args, outputMode, deps, options = {}) {
|
|
|
965
1132
|
const maxChars = rawMaxChars === undefined ? undefined : parseBriefMaxChars(rawMaxChars);
|
|
966
1133
|
let journalWork;
|
|
967
1134
|
let journalIdentity;
|
|
1135
|
+
// #140 T3: whether the serving get attempt was cache-served (the
|
|
1136
|
+
// direct consult truth the T5 journal hook reads).
|
|
1137
|
+
let journalCacheHit = false;
|
|
968
1138
|
return await invokeCommand(deps.invocation, async (context) => {
|
|
969
1139
|
// T10 get fallback (AC-5b): walk the id-type-filtered D5 arm
|
|
970
1140
|
// order; a supplier ApiError reroutes to the next configured arm
|
|
@@ -1006,18 +1176,39 @@ export async function handleScience(args, outputMode, deps, options = {}) {
|
|
|
1006
1176
|
throw new ValidationError(`Provider "${arm.id}" does not provide science get.`, `Science suppliers: ${D5_ARM_ORDER.join(", ")}.`);
|
|
1007
1177
|
}
|
|
1008
1178
|
capability.validate(request);
|
|
1009
|
-
// T7: the direct-invoke executor
|
|
1010
|
-
// layer, so the supplier's
|
|
1011
|
-
// consulted HERE —
|
|
1179
|
+
// T7: the direct-invoke executor runs outside the shared
|
|
1180
|
+
// execution layer's cache step, so the supplier's
|
|
1181
|
+
// (capture-wrapped) cacheIdentity is consulted HERE —
|
|
1182
|
+
// pre-invoke, matching execution.ts step 2.
|
|
1012
1183
|
// REPLACED per attempt (review): retaining the first supplier's
|
|
1013
1184
|
// identity would journal the fingerprint of a supplier that
|
|
1014
1185
|
// failed and rerouted; the loop breaks on success, so the last
|
|
1015
1186
|
// assignment is always the arm that actually served.
|
|
1187
|
+
// #140 ruling 5: identity unconditional at the consult site.
|
|
1188
|
+
const identity = capability.cacheIdentity?.(request);
|
|
1016
1189
|
if (deps.journal !== undefined) {
|
|
1017
|
-
journalIdentity =
|
|
1190
|
+
journalIdentity = identity;
|
|
1191
|
+
}
|
|
1192
|
+
const cacheKey = scienceCacheKey(identity);
|
|
1193
|
+
// #140 T3: consult per attempt. A hit breaks the walk with the
|
|
1194
|
+
// cached work — no invoke, no reroute toward later arms.
|
|
1195
|
+
if (!noCache && cacheKey !== undefined) {
|
|
1196
|
+
const cached = await deps.scienceCache.get(cacheKey, decodeScienceWork);
|
|
1197
|
+
if (cached !== null) {
|
|
1198
|
+
// Wave 1 F4: same pre-abort guard on the get-walk consult.
|
|
1199
|
+
if (controller.signal.aborted) {
|
|
1200
|
+
throwCallerAborted();
|
|
1201
|
+
}
|
|
1202
|
+
work = cached;
|
|
1203
|
+
journalCacheHit = true;
|
|
1204
|
+
break;
|
|
1205
|
+
}
|
|
1018
1206
|
}
|
|
1019
1207
|
try {
|
|
1020
|
-
work = await capability.invoke(request, controller.signal);
|
|
1208
|
+
work = await executeProviderOperation("science-get", () => capability.invoke(request, controller.signal), { sleep: deps.scienceSleep, random: deps.scienceRandom }, undefined, undefined, controller.signal);
|
|
1209
|
+
if (!noCache && cacheKey !== undefined) {
|
|
1210
|
+
await deps.scienceCache.set(cacheKey, work);
|
|
1211
|
+
}
|
|
1021
1212
|
break;
|
|
1022
1213
|
}
|
|
1023
1214
|
catch (error) {
|
|
@@ -1025,7 +1216,7 @@ export async function handleScience(args, outputMode, deps, options = {}) {
|
|
|
1025
1216
|
// reroute walk): the remaining arms would only fast-fail at their
|
|
1026
1217
|
// pre-abort check while emitting a misleading reroute notice.
|
|
1027
1218
|
if (controller.signal.aborted) {
|
|
1028
|
-
|
|
1219
|
+
throwCallerAborted();
|
|
1029
1220
|
}
|
|
1030
1221
|
const next = arms[attempt + 1];
|
|
1031
1222
|
if (next === undefined || deps.fallbackEnabled === false)
|
|
@@ -1049,6 +1240,7 @@ export async function handleScience(args, outputMode, deps, options = {}) {
|
|
|
1049
1240
|
deps,
|
|
1050
1241
|
outputMode,
|
|
1051
1242
|
...(explicitProvider !== undefined ? { explicitProvider } : {}),
|
|
1243
|
+
noCache,
|
|
1052
1244
|
});
|
|
1053
1245
|
}, outputMode, deps.now, deps.secrets, saveHook, deps.journal === undefined
|
|
1054
1246
|
? undefined
|
|
@@ -1058,6 +1250,9 @@ export async function handleScience(args, outputMode, deps, options = {}) {
|
|
|
1058
1250
|
// Single-work identity (AC-11 amendment 2): exactly one row.
|
|
1059
1251
|
resultRows: () => (journalWork === undefined ? undefined : [journalWork]),
|
|
1060
1252
|
cacheKey: () => scienceCacheKey(journalIdentity),
|
|
1253
|
+
// #140 T5: the get walk's own consult truth — never the
|
|
1254
|
+
// capture cell.
|
|
1255
|
+
servedFrom: () => (journalCacheHit ? "cache" : "live"),
|
|
1061
1256
|
}));
|
|
1062
1257
|
}
|
|
1063
1258
|
finally {
|