scoutline 0.23.0 → 0.24.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 (47) hide show
  1. package/README.md +49 -1
  2. package/dist/capabilities/investigation.d.ts +134 -0
  3. package/dist/capabilities/investigation.d.ts.map +1 -0
  4. package/dist/capabilities/investigation.js +278 -0
  5. package/dist/capabilities/investigation.js.map +1 -0
  6. package/dist/commands/investigate.d.ts +232 -0
  7. package/dist/commands/investigate.d.ts.map +1 -0
  8. package/dist/commands/investigate.js +838 -0
  9. package/dist/commands/investigate.js.map +1 -0
  10. package/dist/commands/vision.d.ts.map +1 -1
  11. package/dist/commands/vision.js +15 -2
  12. package/dist/commands/vision.js.map +1 -1
  13. package/dist/index.d.ts +21 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +373 -6
  16. package/dist/index.js.map +1 -1
  17. package/dist/lib/investigate-claims.d.ts +90 -0
  18. package/dist/lib/investigate-claims.d.ts.map +1 -0
  19. package/dist/lib/investigate-claims.js +188 -0
  20. package/dist/lib/investigate-claims.js.map +1 -0
  21. package/dist/lib/investigate-extract.d.ts +35 -0
  22. package/dist/lib/investigate-extract.d.ts.map +1 -0
  23. package/dist/lib/investigate-extract.js +101 -0
  24. package/dist/lib/investigate-extract.js.map +1 -0
  25. package/dist/lib/investigate-planner.d.ts +58 -0
  26. package/dist/lib/investigate-planner.d.ts.map +1 -0
  27. package/dist/lib/investigate-planner.js +141 -0
  28. package/dist/lib/investigate-planner.js.map +1 -0
  29. package/dist/providers/registry.d.ts.map +1 -1
  30. package/dist/providers/registry.js +4 -1
  31. package/dist/providers/registry.js.map +1 -1
  32. package/dist/providers/types.d.ts +30 -1
  33. package/dist/providers/types.d.ts.map +1 -1
  34. package/dist/providers/types.js.map +1 -1
  35. package/dist/providers/zai/adapter.d.ts.map +1 -1
  36. package/dist/providers/zai/adapter.js +217 -3
  37. package/dist/providers/zai/adapter.js.map +1 -1
  38. package/dist/providers/zai/layout-parsing.d.ts +76 -0
  39. package/dist/providers/zai/layout-parsing.d.ts.map +1 -0
  40. package/dist/providers/zai/layout-parsing.js +151 -0
  41. package/dist/providers/zai/layout-parsing.js.map +1 -0
  42. package/dist/providers/zai/media.d.ts +19 -0
  43. package/dist/providers/zai/media.d.ts.map +1 -1
  44. package/dist/providers/zai/media.js +61 -0
  45. package/dist/providers/zai/media.js.map +1 -1
  46. package/package.json +1 -1
  47. package/skills/scoutline/SKILL.md +71 -1
package/dist/index.js CHANGED
@@ -20,6 +20,8 @@ import { handleFetch } from "./commands/fetch.js";
20
20
  import { handleArchive, parseArchiveArgs } from "./commands/archive.js";
21
21
  import { handleWatch } from "./commands/watch.js";
22
22
  import { handleScience } from "./commands/science.js";
23
+ import { investigate, INVESTIGATE_HELP, } from "./commands/investigate.js";
24
+ import { ZaiApiClient } from "./lib/api-client.js";
23
25
  import { buildProviderCacheKey, cacheStats, clearAllCaches, parsePruneDuration, pruneCaches, } from "./lib/cache.js";
24
26
  import { parseBatchManifest } from "./lib/batch-manifest.js";
25
27
  import { assignBatchProviders } from "./lib/batch-assign.js";
@@ -33,7 +35,7 @@ import { buildJournalEntry, buildNoteEntry, buildSearchSkeleton, buildReadSkelet
33
35
  import { buildJournalRepeatMarker } from "./lib/journal.js";
34
36
  import { applyBudget } from "./lib/output-budget.js";
35
37
  import { persistCompaction } from "./lib/output-budget-persistence.js";
36
- import { FileError, ValidationError, UnsupportedCapabilityError, CommandOptionUnsupportedError, getErrorExitCode, } from "./lib/errors.js";
38
+ import { ApiError, FileError, ValidationError, UnsupportedCapabilityError, CommandOptionUnsupportedError, getErrorExitCode, } from "./lib/errors.js";
37
39
  import * as os from "node:os";
38
40
  import * as path from "node:path";
39
41
  import * as fs from "node:fs/promises";
@@ -50,6 +52,7 @@ import { createUsageLedgerSink, readUsageLedger, resolveUsageLedgerPath, } from
50
52
  import { classifyCredentialState, formatEnvOnlyHint, isCommandHelpInvocation, isDryRunBatchInvocation, OBSERVATIONAL_COMMANDS, } from "./lib/trigger-detection.js";
51
53
  import { resolveProviderId, resolveEffectiveProvider, parseProviderId, } from "./providers/selection.js";
52
54
  import { BUILT_IN_PROVIDER_DESCRIPTORS } from "./providers/registry.js";
55
+ import { createZaiDescriptor } from "./providers/zai/adapter.js";
53
56
  import { PROVIDER_IDS } from "./providers/types.js";
54
57
  import { SHARED_PROVIDER_FLAG_IDS, SHARED_PROVIDER_IDS } from "./providers/catalog.js";
55
58
  import { executeWithFallback } from "./lib/provider-fallback.js";
@@ -101,6 +104,10 @@ Commands:
101
104
  DOI, PMID, arXiv ids; keyless scholarly suppliers:
102
105
  openalex, arxiv, crossref, pubmed, europepmc)
103
106
  code Execute TypeScript tool chains (Code Mode, Z.AI)
107
+ investigate Local investigation pipeline: plan sub-queries, fan out
108
+ search, read top sources, extract passages into an
109
+ EvidencePack (search+reader supplier union; the pack is data,
110
+ text modes fall back to JSON)
104
111
  init Interactive onboarding wizard (writes ~/.scoutline/config.json)
105
112
  config Manage ~/.scoutline/config.json keys (get / set / unset,
106
113
  credential-free)
@@ -166,6 +173,7 @@ Help:
166
173
  scoutline archive --help
167
174
  scoutline watch --help
168
175
  scoutline science --help
176
+ scoutline investigate --help
169
177
  scoutline init --help
170
178
  `.trim();
171
179
  function parseArgs(args) {
@@ -464,6 +472,25 @@ export const STRICT_FLAG_ALLOWLIST = {
464
472
  "max-chars",
465
473
  "no-journal",
466
474
  ]),
475
+ // investigate-pipeline T6: the documented control surface only
476
+ // (PRD AC-1). The rejected trio (depth/arms/budget-tokens) and
477
+ // --context-stdin are deliberately ABSENT — under strict flags they
478
+ // reject here, and the lenient default rejects them inside
479
+ // handleInvestigate (the rejection IS the feature).
480
+ investigate: new Set([
481
+ "help",
482
+ "h",
483
+ "provider",
484
+ "context",
485
+ "sources",
486
+ "max-chars",
487
+ "no-cache",
488
+ "no-journal",
489
+ "synthesize",
490
+ // investigate-verify lane (DESIGN D5): claim-corroboration mode
491
+ // flag. Pair-rejected with --context below (verify owns planning).
492
+ "verify",
493
+ ]),
467
494
  };
468
495
  /**
469
496
  * The strict-flag gate (#241): scan a command's argv (the tokens AFTER
@@ -554,6 +581,7 @@ const SAVE_CAPABLE_COMMANDS = new Set([
554
581
  "research",
555
582
  "repo",
556
583
  "vision",
584
+ "investigate",
557
585
  ]);
558
586
  /**
559
587
  * ADR-0007 D5 — every command the dispatcher routes (below): the
@@ -588,6 +616,7 @@ export const DISPATCHED_COMMANDS = new Set([
588
616
  "archive",
589
617
  "watch",
590
618
  "science",
619
+ "investigate",
591
620
  ]);
592
621
  /**
593
622
  * ADR-0007 D5 — commands WITHOUT an Output Budget ladder. They reject
@@ -632,6 +661,11 @@ export const ACCEPT_NO_JOURNAL_COMMANDS = new Set([
632
661
  // Science verticals (T7): the science noun journals (skeleton entries,
633
662
  // PRD AC-5c), so its --no-journal per-call escape must exist too.
634
663
  "science",
664
+ // investigate-pipeline T6 (PRD AC-10): the run journals its
665
+ // UNDERLYING search/read ops exactly as running them standalone
666
+ // would (umbrella capability "search" — see the journalWiring map),
667
+ // so its --no-journal per-call escape exists on the same seam.
668
+ "investigate",
635
669
  ]);
636
670
  /**
637
671
  * Review N10 (fix-round F-6) — the dispatch surface, extracted from
@@ -1018,7 +1052,19 @@ async function handleVision(args, outputMode, deps) {
1018
1052
  // descriptor ID is the *attempted* provider (not the
1019
1053
  // registry-derived effective provider) so fallback attempts
1020
1054
  // record the actual descriptor that invoked transport.
1021
- ...(deps.consume !== undefined ? { consume: deps.consume } : {}),
1055
+ // glm-ocr lane (ADR-0014 D7): extract-text on zai counts
1056
+ // usage-ledger attempts at the ADAPTER (the OCR cache is
1057
+ // adapter-internal; an 1113+fallback run is two attempts
1058
+ // inside one executor invoke; a cache hit is zero). The
1059
+ // adapter's seam receives the sink through the zai
1060
+ // descriptor's layoutParsingConsume dep (threaded at
1061
+ // registry construction below); the executor emission is
1062
+ // suppressed here so attempts are counted exactly once.
1063
+ ...((deps.consume !== undefined &&
1064
+ !(descriptor.zaiOcrLedgerSeam === true &&
1065
+ operation === "extract-text"))
1066
+ ? { consume: deps.consume }
1067
+ : {}),
1022
1068
  ...(deps.consume !== undefined ? { provider: descriptor.id } : {}),
1023
1069
  ...(deps.now !== undefined ? { now: deps.now } : {}),
1024
1070
  };
@@ -3541,6 +3587,273 @@ async function handleHistoryExport(args, outputMode, deps) {
3541
3587
  ...(since !== undefined ? { since } : {}),
3542
3588
  }), outputMode, now, deps.secrets);
3543
3589
  }
3590
+ // ---------------------------------------------------------------------------
3591
+ // investigate-pipeline T6 (ADR-0013): the CLI wiring for the local
3592
+ // investigation pipeline. Mirrors handleSearch's ORDER — valueless-flag
3593
+ // guards BEFORE the help-gate, count-class validation BEFORE provider
3594
+ // resolution — and hands the parsed controls to commands/investigate.ts
3595
+ // (T4/T5), which owns the plan → fan-out → merge → read → pack
3596
+ // orchestration. The command is NOT routed through executeWithFallback:
3597
+ // it owns its provider grid through resolveFanoutPlan tiers (D3).
3598
+ // ---------------------------------------------------------------------------
3599
+ /**
3600
+ * Strict positive-integer parse for `--sources` (the parseAndValidateCount
3601
+ * class — not the lax parseInt idiom). Parse-level, before the help-gate
3602
+ * is bypassed and before provider resolution, so a bad value is
3603
+ * VALIDATION_ERROR regardless of credentials or provider state.
3604
+ */
3605
+ export function parseAndValidateSources(raw) {
3606
+ if (raw === undefined || raw === "")
3607
+ return undefined;
3608
+ if (raw === true) {
3609
+ throw new ValidationError("--sources requires a value.", "Use a positive integer (e.g. --sources 5).");
3610
+ }
3611
+ const str = typeof raw === "string" ? raw : String(raw);
3612
+ if (!/^\d+$/.test(str)) {
3613
+ throw new ValidationError(`Invalid --sources value "${str}": must be a positive integer`, "Use a positive integer (e.g. --sources 5).");
3614
+ }
3615
+ const parsed = Number(str);
3616
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
3617
+ throw new ValidationError(`Invalid --sources value "${str}": must be a positive safe integer`, "Use a positive integer (e.g. --sources 5).");
3618
+ }
3619
+ return parsed;
3620
+ }
3621
+ async function handleInvestigate(args, outputMode, deps) {
3622
+ const { flags, positional } = parseArgs(args);
3623
+ // Valueless --context guard BEFORE the help-gate (the handleSearch
3624
+ // D1 placement pin): parseArgs records `true` for a valueless flag,
3625
+ // so without this check `investigate --context` would short-circuit
3626
+ // to HELP + exit 0 with the malformed flag silently swallowed.
3627
+ if (flags.context === true) {
3628
+ throw new ValidationError("--context requires a value.", "Pass a file path: --context <path>.");
3629
+ }
3630
+ // PRD AC-1 pins the accepted controls exactly; --context-stdin is NOT
3631
+ // investigate's (search-only spelling — the question's pipes and
3632
+ // --context cover the sub-query sources). Rejected explicitly, never
3633
+ // accepted-and-dropped; before the help-gate like every other
3634
+ // valueless/malformed-flag guard here.
3635
+ if (flags["context-stdin"] !== undefined) {
3636
+ throw new ValidationError("investigate has no --context-stdin flag.", "Pipe sub-queries with | in the question, or pass --context <path> (see `scoutline investigate --help`).");
3637
+ }
3638
+ // investigate-verify lane (DESIGN D5, PRD AC-1): --verify owns
3639
+ // planning — the claims ARE the grid — so a --context file is a
3640
+ // mode conflict. Both spellings of --context carry; the valueless
3641
+ // --context guard above already fired for the true case.
3642
+ if (flags.verify !== undefined && flags.context !== undefined) {
3643
+ throw new ValidationError("--verify and --context cannot be combined: --verify splits the statement into claims (verify owns planning).", "Drop --context, or run without --verify to plan from the notes file.");
3644
+ }
3645
+ // Valueless/malformed --verify guards (the --synthesize pattern):
3646
+ // parseArgs would swallow a following token as the value.
3647
+ for (const token of args) {
3648
+ if (typeof token === "string" && token.startsWith("--verify=")) {
3649
+ throw new ValidationError(`Invalid flag "${token}": the --flag=value form is not supported; --verify takes no value.`, "Pass the bare --verify to enable claim-corroboration mode, or omit it.");
3650
+ }
3651
+ }
3652
+ if (flags.verify !== undefined && flags.verify !== true) {
3653
+ throw new ValidationError("--verify is a boolean flag and takes no value", "Pass the bare --verify to enable claim-corroboration mode, or omit it.");
3654
+ }
3655
+ const verify = flags.verify === true;
3656
+ // PRD AC-1 / ADR-0013 rejected extensions: --depth, --arms, and
3657
+ // --budget-tokens DO NOT EXIST (the parser rejecting them IS the
3658
+ // feature — no accept-and-drop). rejectFlagPair guards BOTH
3659
+ // spellings (#242); before the help-gate for the same reason as
3660
+ // --context above.
3661
+ rejectFlagPair(flags, "depth", () => new ValidationError("investigate has no --depth flag; planning is deterministic (pipes > --context > template).", "Structure the question with | sub-queries or --context <path> instead."));
3662
+ rejectFlagPair(flags, "arms", () => new ValidationError("investigate has no --arms flag; the arm set IS the provider pin.", "Use --provider <tavily,exa|all> to fan out, or a single --provider id for one arm."));
3663
+ rejectFlagPair(flags, "budget-tokens", () => new ValidationError("investigate has no --budget-tokens flag; --max-chars is the budget.", "Use --max-chars <n> (characters, not tokens)."));
3664
+ // T7 (PRD AC-7): --synthesize is VALUELESS. parseArgs assigns the
3665
+ // next non-dash token as a flag value, so `--synthesize foo` would
3666
+ // swallow the value; the =-form parses as a garbage key and is
3667
+ // silently dropped (#172 review F6). Both reject here, before the
3668
+ // help-gate, like every other malformed-flag guard in this handler.
3669
+ for (const token of args) {
3670
+ if (typeof token === "string" && token.startsWith("--synthesize=")) {
3671
+ throw new ValidationError(`Invalid flag "${token}": the --flag=value form is not supported; --synthesize takes no value.`, "Pass the bare --synthesize to enable it, or omit it.");
3672
+ }
3673
+ }
3674
+ if (flags.synthesize !== undefined && flags.synthesize !== true) {
3675
+ throw new ValidationError("--synthesize is a boolean flag and takes no value", "Pass the bare --synthesize to enable it, or omit it.");
3676
+ }
3677
+ const synthesize = flags.synthesize === true;
3678
+ if (flags.help || flags.h || positional.length === 0) {
3679
+ deps.invocation.writeStdout(INVESTIGATE_HELP);
3680
+ return 0;
3681
+ }
3682
+ // Z.AI-only (PRD AC-7, the Code Mode precedent): the escape hatch
3683
+ // is Z.AI chat regardless of the provider pin. The handler is the
3684
+ // seam where the RAW pin is visible — the command cannot see it —
3685
+ // so the notice fires here, before any billable work.
3686
+ if (synthesize && deps.provider !== undefined && deps.provider.trim().length > 0) {
3687
+ const pinned = deps.provider.trim().toLowerCase();
3688
+ if (pinned !== "zai") {
3689
+ deps.invocation.writeStderr(`investigate: --synthesize is Z.AI-only; ignoring the --provider ${pinned} pin for the brief (the pack still uses it).`);
3690
+ }
3691
+ }
3692
+ // The synthesis dep (T7): a Z.AI chat completion through the
3693
+ // in-repo /chat/completions surface. Built HERE, at the handler
3694
+ // seam, exactly like every other capability — the command holds no
3695
+ // transport. `visionComplete` is the only chat-completions method
3696
+ // on ZaiApiClient; the transport is fetch-per-call (no client to
3697
+ // close), and nothing is constructed when the flag is absent.
3698
+ const synthesizeDep = !synthesize
3699
+ ? undefined
3700
+ : deps.synthesize ??
3701
+ (async (prompt) => {
3702
+ const client = new ZaiApiClient(undefined, deps.env);
3703
+ const response = await client.visionComplete([
3704
+ {
3705
+ role: "system",
3706
+ content: "You are given an evidence pack assembled from web sources: a question, " +
3707
+ "its planned sub-queries, and verbatim passage quotes extracted from the " +
3708
+ "read sources. Write a short, direct brief answering the question using " +
3709
+ "ONLY the quoted evidence. Do not invent facts, do not add citations that " +
3710
+ "are not in the quotes, and say plainly when the evidence is insufficient.",
3711
+ },
3712
+ {
3713
+ role: "user",
3714
+ // Deterministic serialization: fixed key order, compact JSON.
3715
+ content: JSON.stringify({
3716
+ question: prompt.question,
3717
+ subQueries: [...prompt.subQueries],
3718
+ quotes: [...prompt.quotes],
3719
+ }),
3720
+ },
3721
+ ]);
3722
+ const content = response.choices?.[0]?.message?.content;
3723
+ if (typeof content !== "string") {
3724
+ throw new ApiError("synthesis returned no brief text", 502);
3725
+ }
3726
+ return content;
3727
+ });
3728
+ // Count-class validation BEFORE provider resolution (Fixup D order):
3729
+ // a bad --sources is VALIDATION_ERROR even with no credentials, and
3730
+ // --max-chars rides the strict parseMaxCharsFlag (parseBriefMaxChars
3731
+ // class). Both are re-validated inside the command's trust boundary.
3732
+ const sources = parseAndValidateSources(flags.sources);
3733
+ const maxChars = parseMaxCharsFlag(flags);
3734
+ const question = positional.join(" ");
3735
+ // Resolve the fan-out activation plan once HERE (pure — the same
3736
+ // inputs handleSearch passes) so the journal hook can stamp per-arm
3737
+ // serving cells BEFORE any arm runs; the command resolves its own
3738
+ // plan identically from the same inputs (the tiers are deterministic,
3739
+ // so the two resolutions agree by construction).
3740
+ const fanoutPlan = resolveFanoutPlan({
3741
+ explicitProviderRaw: deps.provider,
3742
+ env: deps.env,
3743
+ configFanout: deps.configFanout === true,
3744
+ ...(deps.routing !== undefined ? { routing: deps.routing } : {}),
3745
+ descriptors: deps.providerDescriptors,
3746
+ });
3747
+ // Save wiring (D7): the pack is a first-class saveable result. The
3748
+ // provider routing mirrors the in-code vocabulary — fan-out records
3749
+ // the arm list with no single effective; single records the resolved
3750
+ // arm (the resolver's single-mode arm, not a quota-ranked pick — the
3751
+ // command runs exactly that arm). Args carry the
3752
+ // provider-influencing allow-list only.
3753
+ const investigateSaveArgs = {
3754
+ ...(deps.provider !== undefined ? { provider: deps.provider } : {}),
3755
+ ...(sources !== undefined ? { sources } : {}),
3756
+ ...(flags["no-cache"] === true ? { "no-cache": true } : {}),
3757
+ ...(synthesize ? { synthesize: true } : {}),
3758
+ ...(verify ? { verify: true } : {}),
3759
+ };
3760
+ const investigateProviderRouting = fanoutPlan.mode === "fanout"
3761
+ ? {
3762
+ mode: "fanout",
3763
+ ...(deps.provider !== undefined ? { requested: deps.provider } : {}),
3764
+ arms: fanoutPlan.arms.map((arm) => String(arm)),
3765
+ }
3766
+ : {
3767
+ mode: "single",
3768
+ ...(deps.provider !== undefined ? { requested: deps.provider } : {}),
3769
+ effective: String(fanoutPlan.arms[0]),
3770
+ };
3771
+ const save = createSaveArtifactHook(deps, {
3772
+ command: "investigate",
3773
+ outputMode,
3774
+ args: investigateSaveArgs,
3775
+ provider: investigateProviderRouting,
3776
+ });
3777
+ // Journal wiring (PRD AC-10): investigate journals its UNDERLYING
3778
+ // search/read ops exactly as running them standalone would. The
3779
+ // umbrella capability is "search" — JournalableCapability is closed
3780
+ // over {search, read, research, science} and lib/journal.ts is a
3781
+ // read-only seam for this lane; a literal "investigate" entry fails
3782
+ // asJournalEntry validation and would poison the whole log
3783
+ // (fail-open drop + corruption notice). Entry shape: query = the
3784
+ // question, skeleton rows = the pack sources (url/title) — fed via
3785
+ // the resultRows thunk read after dispatch resolves. Fan-out runs
3786
+ // stamp per-arm cells on the shared capture first (the arm-race fix),
3787
+ // exactly like handleSearch.
3788
+ let journalRows;
3789
+ if (fanoutPlan.mode === "fanout" && deps.journal !== undefined) {
3790
+ installFanoutArmCells(deps.journal.capture, fanoutPlan.arms);
3791
+ }
3792
+ const journal = deps.journal === undefined
3793
+ ? undefined
3794
+ : createJournalHook(deps, {
3795
+ journal: deps.journal,
3796
+ query: question,
3797
+ resultRows: () => journalRows,
3798
+ fanoutRouting: fanoutPlan.mode === "fanout"
3799
+ ? {
3800
+ mode: "fanout",
3801
+ ...(deps.provider !== undefined ? { requested: deps.provider } : {}),
3802
+ arms: fanoutPlan.arms,
3803
+ }
3804
+ : undefined,
3805
+ });
3806
+ return invokeCommand(deps.invocation, async (context) => {
3807
+ const result = await investigate(question, {
3808
+ ...(deps.provider !== undefined ? { provider: deps.provider } : {}),
3809
+ ...(typeof flags.context === "string" ? { contextFile: flags.context } : {}),
3810
+ ...(sources !== undefined ? { sources } : {}),
3811
+ ...(maxChars !== undefined ? { maxChars } : {}),
3812
+ noCache: flags["no-cache"] === true,
3813
+ noJournal: collectLongFlagValues(args, "no-journal").length > 0,
3814
+ ...(synthesize ? { synthesize: true } : {}),
3815
+ ...(verify ? { verify: true } : {}),
3816
+ }, {
3817
+ descriptors: deps.providerDescriptors,
3818
+ env: deps.env,
3819
+ configFanout: deps.configFanout === true,
3820
+ ...(deps.routing !== undefined ? { routing: deps.routing } : {}),
3821
+ // One shared cache for the search arms AND the reads — the
3822
+ // production defaultCache aliases every capability triple to
3823
+ // the same root, and the command's warm-cacheHit accounting
3824
+ // (search + reader partitions) assumes the one store.
3825
+ cache: deps.searchCache,
3826
+ sleep: deps.searchSleep,
3827
+ random: deps.searchRandom,
3828
+ ...(deps.consume !== undefined ? { consume: deps.consume } : {}),
3829
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
3830
+ fusionMode: deps.fusionMode ?? "rrf",
3831
+ ...(deps.now !== undefined ? { nowWall: () => new Date(deps.now()) } : {}),
3832
+ loadContextText: async (filePath) => (await readContextSource({ file: filePath }, {
3833
+ readFile: (f) => fs.readFile(f),
3834
+ readStdin: () => {
3835
+ throw new Error("stdin context is not investigate's flag");
3836
+ },
3837
+ })).text,
3838
+ readerCapabilityFor: (descriptor) => descriptor.create({ env: deps.env }).reader,
3839
+ ...(deps.secrets !== undefined ? { secrets: deps.secrets } : {}),
3840
+ ...(synthesizeDep !== undefined ? { synthesize: synthesizeDep } : {}),
3841
+ }, context);
3842
+ // Feed the journal skeleton from the pack BEFORE the result
3843
+ // leaves the behavior — url/title identities of the read
3844
+ // sources, the same row shape search journals.
3845
+ if (result.kind === "data") {
3846
+ const pack = result.data;
3847
+ if (Array.isArray(pack.sources)) {
3848
+ journalRows = pack.sources.map((source) => ({
3849
+ url: typeof source.url === "string" ? source.url : undefined,
3850
+ title: typeof source.title === "string" ? source.title : undefined,
3851
+ }));
3852
+ }
3853
+ }
3854
+ return result;
3855
+ }, outputMode, deps.now, deps.secrets, save, journal);
3856
+ }
3544
3857
  export { handleFetch, fetchCommand, executeFetch, FETCH_HELP } from "./commands/fetch.js";
3545
3858
  export { handleArchive, archiveCdxCommand, archiveGetCommand, ARCHIVE_HELP, } from "./commands/archive.js";
3546
3859
  async function handleQuota(args, outputMode, deps) {
@@ -4103,7 +4416,34 @@ export async function main(args, dependencies) {
4103
4416
  const depsConfig = dependencies.config;
4104
4417
  const loadScoutlineConfig = dependencies.loadScoutlineConfig ??
4105
4418
  (depsConfig !== undefined ? async () => depsConfig : undefined);
4419
+ // glm-ocr lane (ADR-0014 D7): in full production mode (no injected
4420
+ // descriptor list), rebuild the zai entry with the shared consumption
4421
+ // sink threaded into its layoutParsingConsume seam so the extract-text
4422
+ // OCR arm counts ledger attempts at the adapter (cache hits = zero
4423
+ // rows; 1113+fallback = two rows). The sink binding exists BELOW this
4424
+ // point (main body order), so this is a lazy rebuild: the array holds
4425
+ // a descriptor whose create() closes over a sink REFERENCE resolved
4426
+ // at first vision dispatch. Tests injecting providerDescriptors keep
4427
+ // full control (their descriptor carries their own seams).
4106
4428
  const providerDescriptors = dependencies.providerDescriptors ?? BUILT_IN_PROVIDER_DESCRIPTORS;
4429
+ const productionZaiLedgerDescriptors = dependencies.providerDescriptors
4430
+ ? providerDescriptors
4431
+ : providerDescriptors.map((descriptor) => descriptor.id === "zai"
4432
+ ? Object.assign(createZaiDescriptor({
4433
+ notice: (line) => process.stderr.write(`${line}\n`),
4434
+ layoutParsingConsume: {
4435
+ record: (event) => {
4436
+ if (zaiLedgerSink === undefined)
4437
+ return Promise.resolve();
4438
+ return zaiLedgerSink.record(event);
4439
+ },
4440
+ },
4441
+ }),
4442
+ // Marker: this descriptor's zai adapter owns extract-text
4443
+ // ledger rows through its seam (checked in handleVision
4444
+ // to suppress the executor emission exactly once).
4445
+ { zaiOcrLedgerSeam: true })
4446
+ : descriptor);
4107
4447
  // Resolve configured Provider credentials from the INJECTED env (B3) so
4108
4448
  // redaction follows the same environment the handlers see — a secret
4109
4449
  // that exists only in MainDependencies.env is still redacted from output.
@@ -4329,6 +4669,9 @@ export async function main(args, dependencies) {
4329
4669
  // like the quota sink). The composite isolates each side — one
4330
4670
  // sink's failure becomes one redacted warning and never blocks or
4331
4671
  // fails the other.
4672
+ // glm-ocr lane: late-bound sink reference for the rebuilt zai
4673
+ // descriptor (assigned immediately after `consume` below).
4674
+ let zaiLedgerSink;
4332
4675
  const consume = dependencies.consume ??
4333
4676
  // ADR-0006 §5: --isolated runs skip local state persistence
4334
4677
  // entirely — no usage-ledger writes, no quota-snapshot writes — so
@@ -4346,6 +4689,7 @@ export async function main(args, dependencies) {
4346
4689
  filePath: resolveUsageLedgerPath(resolveConfigRootPure(env, { homedir: os.homedir() })),
4347
4690
  }))
4348
4691
  : undefined);
4692
+ zaiLedgerSink = consume;
4349
4693
  // PB-T4: quota snapshot for selection. Declared here so
4350
4694
  // `buildHandlerDeps` closes over the binding; assigned AFTER the
4351
4695
  // PB-T1 pre-command refresh so observational commands' fresh data is
@@ -4362,11 +4706,12 @@ export async function main(args, dependencies) {
4362
4706
  // env/secrets/fallbackEnabled depend on whether config has been loaded.
4363
4707
  const buildHandlerDeps = (credEnv, credSecrets, credFallback, credRouting = undefined, credFanout = undefined, credFusion = undefined) => ({
4364
4708
  invocation,
4709
+ synthesize: dependencies.synthesize,
4365
4710
  env: credEnv,
4366
4711
  secrets: credSecrets,
4367
4712
  now,
4368
4713
  provider,
4369
- providerDescriptors,
4714
+ providerDescriptors: productionZaiLedgerDescriptors,
4370
4715
  fallbackEnabled: credFallback,
4371
4716
  routing: credRouting,
4372
4717
  configFanout: credFanout,
@@ -5016,12 +5361,27 @@ export async function main(args, dependencies) {
5016
5361
  config.journal !== false
5017
5362
  ? {}
5018
5363
  : undefined;
5364
+ // M1 (glm-ocr review): journal/save chains build from the ledger-
5365
+ // rebuilt list so --save vision runs keep the adapter-owned
5366
+ // extract-text seam (marker survives the capture spread).
5019
5367
  const journalingDescriptors = journalCapture === undefined
5020
- ? providerDescriptors
5021
- : captureServingDescriptors(providerDescriptors, journalCapture);
5368
+ ? productionZaiLedgerDescriptors
5369
+ : captureServingDescriptors(productionZaiLedgerDescriptors, journalCapture);
5022
5370
  const journalWiring = journalCapture === undefined
5023
5371
  ? undefined
5024
- : { capability: command, capture: journalCapture };
5372
+ : {
5373
+ // investigate-pipeline T6: the run journals its UNDERLYING
5374
+ // search/read ops under the umbrella capability "search" —
5375
+ // JournalableCapability is closed over
5376
+ // {search,read,research,science} (lib/journal.ts is a
5377
+ // read-only seam for this lane) and a literal "investigate"
5378
+ // entry would fail asJournalEntry validation and poison the
5379
+ // whole log (fail-open drop + corruption notice). The
5380
+ // underlying ops ARE search/read; running them standalone
5381
+ // journals exactly so (orchestrator ruling 2026-09-20).
5382
+ capability: (command === "investigate" ? "search" : command),
5383
+ capture: journalCapture,
5384
+ };
5025
5385
  const saveWiring = saveRequest === undefined || isHelpInvocation
5026
5386
  ? undefined
5027
5387
  : buildSaveWiring(saveRequest, journalingDescriptors, journalCapture);
@@ -5126,6 +5486,13 @@ export async function main(args, dependencies) {
5126
5486
  commandRecognized = true;
5127
5487
  exitCode = await handleQuota(commandArgs, outputMode, handlerDepsWithSelection);
5128
5488
  break;
5489
+ case "investigate":
5490
+ commandRecognized = true;
5491
+ // PRD AC-9: --isolated is ACCEPTED (never rejected — investigate
5492
+ // has no stateful dir; resumability is pure cache replay, and the
5493
+ // isolated/<pid> cache segment is exactly the right namespace).
5494
+ exitCode = await handleInvestigate(commandArgs, outputMode, handlerDepsWithSave);
5495
+ break;
5129
5496
  case "code":
5130
5497
  commandRecognized = true;
5131
5498
  exitCode = await handleCode(commandArgs, outputMode, handlerDepsWithSelection);