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.
Files changed (123) hide show
  1. package/README.md +46 -1
  2. package/dist/capabilities/quota.d.ts +34 -8
  3. package/dist/capabilities/quota.d.ts.map +1 -1
  4. package/dist/capabilities/quota.js +8 -6
  5. package/dist/capabilities/quota.js.map +1 -1
  6. package/dist/capabilities/science.d.ts +15 -0
  7. package/dist/capabilities/science.d.ts.map +1 -1
  8. package/dist/capabilities/science.js +79 -0
  9. package/dist/capabilities/science.js.map +1 -1
  10. package/dist/commands/quota.d.ts.map +1 -1
  11. package/dist/commands/quota.js +3 -0
  12. package/dist/commands/quota.js.map +1 -1
  13. package/dist/commands/science.d.ts +13 -0
  14. package/dist/commands/science.d.ts.map +1 -1
  15. package/dist/commands/science.js +243 -48
  16. package/dist/commands/science.js.map +1 -1
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +78 -38
  20. package/dist/index.js.map +1 -1
  21. package/dist/lib/artifacts.d.ts.map +1 -1
  22. package/dist/lib/artifacts.js +4 -1
  23. package/dist/lib/artifacts.js.map +1 -1
  24. package/dist/lib/async-job-state.d.ts +17 -0
  25. package/dist/lib/async-job-state.d.ts.map +1 -1
  26. package/dist/lib/async-job-state.js +29 -0
  27. package/dist/lib/async-job-state.js.map +1 -1
  28. package/dist/lib/batch-manifest.d.ts +9 -0
  29. package/dist/lib/batch-manifest.d.ts.map +1 -1
  30. package/dist/lib/batch-manifest.js +8 -0
  31. package/dist/lib/batch-manifest.js.map +1 -1
  32. package/dist/lib/cache.d.ts +89 -10
  33. package/dist/lib/cache.d.ts.map +1 -1
  34. package/dist/lib/cache.js +190 -42
  35. package/dist/lib/cache.js.map +1 -1
  36. package/dist/lib/errors.d.ts +29 -3
  37. package/dist/lib/errors.d.ts.map +1 -1
  38. package/dist/lib/errors.js +29 -5
  39. package/dist/lib/errors.js.map +1 -1
  40. package/dist/lib/execution.d.ts +14 -6
  41. package/dist/lib/execution.d.ts.map +1 -1
  42. package/dist/lib/execution.js +50 -6
  43. package/dist/lib/execution.js.map +1 -1
  44. package/dist/lib/mcp-client.d.ts.map +1 -1
  45. package/dist/lib/mcp-client.js +9 -6
  46. package/dist/lib/mcp-client.js.map +1 -1
  47. package/dist/lib/redact.d.ts +3 -0
  48. package/dist/lib/redact.d.ts.map +1 -1
  49. package/dist/lib/redact.js +29 -4
  50. package/dist/lib/redact.js.map +1 -1
  51. package/dist/lib/retry-after.d.ts +54 -0
  52. package/dist/lib/retry-after.d.ts.map +1 -0
  53. package/dist/lib/retry-after.js +124 -0
  54. package/dist/lib/retry-after.js.map +1 -0
  55. package/dist/lib/tool-cache.d.ts +6 -4
  56. package/dist/lib/tool-cache.d.ts.map +1 -1
  57. package/dist/lib/tool-cache.js +9 -7
  58. package/dist/lib/tool-cache.js.map +1 -1
  59. package/dist/lib/tty.d.ts.map +1 -1
  60. package/dist/lib/tty.js +10 -0
  61. package/dist/lib/tty.js.map +1 -1
  62. package/dist/providers/arxiv/client.d.ts.map +1 -1
  63. package/dist/providers/arxiv/client.js +5 -4
  64. package/dist/providers/arxiv/client.js.map +1 -1
  65. package/dist/providers/brave/adapter.d.ts.map +1 -1
  66. package/dist/providers/brave/adapter.js +9 -3
  67. package/dist/providers/brave/adapter.js.map +1 -1
  68. package/dist/providers/brave/client.d.ts.map +1 -1
  69. package/dist/providers/brave/client.js +59 -7
  70. package/dist/providers/brave/client.js.map +1 -1
  71. package/dist/providers/crossref/client.d.ts.map +1 -1
  72. package/dist/providers/crossref/client.js +5 -4
  73. package/dist/providers/crossref/client.js.map +1 -1
  74. package/dist/providers/europepmc/client.d.ts.map +1 -1
  75. package/dist/providers/europepmc/client.js +5 -4
  76. package/dist/providers/europepmc/client.js.map +1 -1
  77. package/dist/providers/exa/adapter.d.ts +7 -0
  78. package/dist/providers/exa/adapter.d.ts.map +1 -1
  79. package/dist/providers/exa/adapter.js +11 -3
  80. package/dist/providers/exa/adapter.js.map +1 -1
  81. package/dist/providers/firecrawl/adapter.d.ts.map +1 -1
  82. package/dist/providers/firecrawl/adapter.js +12 -4
  83. package/dist/providers/firecrawl/adapter.js.map +1 -1
  84. package/dist/providers/firecrawl/client.d.ts.map +1 -1
  85. package/dist/providers/firecrawl/client.js +7 -0
  86. package/dist/providers/firecrawl/client.js.map +1 -1
  87. package/dist/providers/jina/adapter.d.ts.map +1 -1
  88. package/dist/providers/jina/adapter.js +10 -3
  89. package/dist/providers/jina/adapter.js.map +1 -1
  90. package/dist/providers/jina/client.d.ts.map +1 -1
  91. package/dist/providers/jina/client.js +23 -10
  92. package/dist/providers/jina/client.js.map +1 -1
  93. package/dist/providers/linkup/adapter.d.ts.map +1 -1
  94. package/dist/providers/linkup/adapter.js +13 -12
  95. package/dist/providers/linkup/adapter.js.map +1 -1
  96. package/dist/providers/openalex/adapter.d.ts.map +1 -1
  97. package/dist/providers/openalex/adapter.js +2 -0
  98. package/dist/providers/openalex/adapter.js.map +1 -1
  99. package/dist/providers/openalex/client.d.ts.map +1 -1
  100. package/dist/providers/openalex/client.js +6 -5
  101. package/dist/providers/openalex/client.js.map +1 -1
  102. package/dist/providers/parallel/adapter.d.ts.map +1 -1
  103. package/dist/providers/parallel/adapter.js +17 -6
  104. package/dist/providers/parallel/adapter.js.map +1 -1
  105. package/dist/providers/pubmed/client.d.ts.map +1 -1
  106. package/dist/providers/pubmed/client.js +5 -4
  107. package/dist/providers/pubmed/client.js.map +1 -1
  108. package/dist/providers/registry.d.ts +12 -0
  109. package/dist/providers/registry.d.ts.map +1 -1
  110. package/dist/providers/registry.js +12 -0
  111. package/dist/providers/registry.js.map +1 -1
  112. package/dist/providers/tavily/adapter.d.ts.map +1 -1
  113. package/dist/providers/tavily/adapter.js +12 -5
  114. package/dist/providers/tavily/adapter.js.map +1 -1
  115. package/dist/providers/tavily/client.d.ts.map +1 -1
  116. package/dist/providers/tavily/client.js +8 -0
  117. package/dist/providers/tavily/client.js.map +1 -1
  118. package/dist/providers/zai/quota.d.ts +25 -5
  119. package/dist/providers/zai/quota.d.ts.map +1 -1
  120. package/dist/providers/zai/quota.js +122 -29
  121. package/dist/providers/zai/quota.js.map +1 -1
  122. package/package.json +3 -3
  123. package/skills/scoutline/SKILL.md +93 -82
@@ -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
- const identity = journal ? capability.cacheIdentity?.(request) : undefined;
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
- return { works: await capability.invoke(request, signal), identity, armId: pinned.id };
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
- throw new ApiError("science request was aborted by the caller (Ctrl-C or external signal)", 499);
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
- throw new ApiError("science request was aborted by the caller (Ctrl-C or external signal)", 499);
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
- const nextIdentity = journal ? nextCapability.cacheIdentity?.(request) : undefined;
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
- throw new ApiError("science request was aborted by the caller (Ctrl-C or external signal)", 499);
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 will use once the executor consults it (T10):
642
- * `buildProviderCacheKey` over the identity, namespace verbatim.
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 interim direct-invoke executor: the supplier
663
- * capability is invoked directly (no response-cache consult yet —
664
- * T10's executor adds that seam), so every completed run served LIVE
665
- * and journals ONE full entry. Facts, all read AFTER dispatch
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: the interim single-arm pin
671
- * {mode:"single", effective:<served supplier>, servedFrom:"live"}
672
- * from the capture cell; once T10 fans out, the arm routing takes
673
- * over per the fan-out journal rules.
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: fannedOut && arms !== undefined && arms.length > 0
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
- await appendJournalEntry(resolveArtifactsDir(deps.env), entry);
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 bypasses the shared
864
- // execution layer, so the supplier's (capture-wrapped)
865
- // cacheIdentity is consulted HERE — pre-invoke, matching
866
- // execution.ts step 2. Science identities use `supplier`
867
- // (not `provider`); per-arm identities are captured here
868
- // and the journal cacheKey is derived from the FIRST
869
- // FULFILLED arm below (review: a failed first arm must
870
- // not stamp the journal's provider partition).
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] = capability.cacheIdentity?.(request);
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 await capability.invoke(request, controller.signal);
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
- throw new ApiError("science request was aborted by the caller (Ctrl-C or external signal)", 499);
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 bypasses the shared execution
1010
- // layer, so the supplier's (capture-wrapped) cacheIdentity is
1011
- // consulted HERE — pre-invoke, matching execution.ts step 2.
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 = capability.cacheIdentity?.(request);
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
- throw new ApiError("science request was aborted by the caller (Ctrl-C or external signal)", 499);
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 {