@taurgis/bonsai 3.2.0 → 3.2.2

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 (162) hide show
  1. package/README.md +7 -4
  2. package/agents/agents/salesforce-docs-researcher.agent.md +14 -1
  3. package/agents/instructions/salesforce-research.instructions.md +2 -1
  4. package/bin/cli.mjs +9 -17
  5. package/dist/base-command.d.ts +22 -12
  6. package/dist/base-command.js +86 -49
  7. package/dist/commands/config/base.js +11 -2
  8. package/dist/commands/config/get.js +8 -12
  9. package/dist/commands/config/index.js +2 -2
  10. package/dist/commands/config/list.js +10 -16
  11. package/dist/commands/config/set.d.ts +2 -1
  12. package/dist/commands/config/set.js +34 -27
  13. package/dist/commands/config/unset.d.ts +2 -1
  14. package/dist/commands/config/unset.js +25 -24
  15. package/dist/commands/fetch.d.ts +7 -15
  16. package/dist/commands/fetch.js +57 -401
  17. package/dist/commands/import.d.ts +4 -9
  18. package/dist/commands/import.js +56 -284
  19. package/dist/commands/inspect.d.ts +7 -1
  20. package/dist/commands/inspect.js +103 -52
  21. package/dist/commands/list.d.ts +13 -1
  22. package/dist/commands/list.js +79 -32
  23. package/dist/commands/prune.d.ts +4 -1
  24. package/dist/commands/prune.js +55 -75
  25. package/dist/commands/status.d.ts +3 -2
  26. package/dist/commands/status.js +52 -79
  27. package/dist/hooks/command-not-found/suggest.d.ts +1 -0
  28. package/dist/hooks/command-not-found/suggest.js +66 -39
  29. package/dist/lib/argv.d.ts +24 -3
  30. package/dist/lib/argv.js +124 -53
  31. package/dist/lib/batch.d.ts +41 -0
  32. package/dist/lib/batch.js +39 -0
  33. package/dist/lib/cache-view.d.ts +21 -0
  34. package/dist/lib/cache-view.js +30 -0
  35. package/dist/lib/cli-emit.d.ts +28 -0
  36. package/dist/lib/cli-emit.js +44 -0
  37. package/dist/lib/cli-error-policy.d.ts +57 -0
  38. package/dist/lib/cli-error-policy.js +74 -0
  39. package/dist/lib/cli-flag-manifest.d.ts +5 -0
  40. package/dist/lib/cli-flag-manifest.js +8 -0
  41. package/dist/lib/cli-presentation.d.ts +39 -0
  42. package/dist/lib/cli-presentation.js +50 -0
  43. package/dist/lib/cli-result-types.d.ts +88 -0
  44. package/dist/lib/cli-result-types.js +2 -0
  45. package/dist/lib/config/index.d.ts +21 -3
  46. package/dist/lib/config/index.js +58 -3
  47. package/dist/lib/config-persist.d.ts +28 -0
  48. package/dist/lib/config-persist.js +29 -0
  49. package/dist/lib/envelope.d.ts +41 -3
  50. package/dist/lib/envelope.js +147 -4
  51. package/dist/lib/help-preflight.d.ts +5 -8
  52. package/dist/lib/help-preflight.js +13 -32
  53. package/dist/lib/json-meta.d.ts +0 -2
  54. package/dist/lib/json-meta.js +21 -25
  55. package/dist/lib/limit-flag.d.ts +11 -1
  56. package/dist/lib/limit-flag.js +19 -6
  57. package/dist/lib/parse-error-ux.d.ts +5 -0
  58. package/dist/lib/parse-error-ux.js +73 -0
  59. package/dist/lib/prune-flags.d.ts +28 -0
  60. package/dist/lib/prune-flags.js +74 -0
  61. package/dist/lib/research/artifact-index.d.ts +11 -2
  62. package/dist/lib/research/artifact-index.js +7 -4
  63. package/dist/lib/research/browser.d.ts +41 -2
  64. package/dist/lib/research/browser.js +139 -50
  65. package/dist/lib/research/capture.d.ts +24 -2
  66. package/dist/lib/research/capture.js +24 -4
  67. package/dist/lib/research/cli-io.d.ts +33 -0
  68. package/dist/lib/research/cli-io.js +27 -0
  69. package/dist/lib/research/compress.d.ts +7 -0
  70. package/dist/lib/research/compress.js +7 -3
  71. package/dist/lib/research/docs/capabilities.d.ts +15 -0
  72. package/dist/lib/research/docs/capabilities.js +5 -0
  73. package/dist/lib/research/docs/clean-dom.d.ts +2 -0
  74. package/dist/lib/research/docs/clean-dom.js +2 -0
  75. package/dist/lib/research/docs/code-blocks.d.ts +2 -0
  76. package/dist/lib/research/docs/code-blocks.js +2 -0
  77. package/dist/lib/research/docs/detect.d.ts +4 -0
  78. package/dist/lib/research/docs/detect.js +4 -0
  79. package/dist/lib/research/docs/machine-readable.d.ts +18 -0
  80. package/dist/lib/research/docs/machine-readable.js +10 -0
  81. package/dist/lib/research/docs/markdown-source.d.ts +20 -0
  82. package/dist/lib/research/docs/markdown-source.js +19 -2
  83. package/dist/lib/research/docs/quality-gates.d.ts +5 -0
  84. package/dist/lib/research/docs/quality-gates.js +4 -0
  85. package/dist/lib/research/docs/rsc.d.ts +9 -2
  86. package/dist/lib/research/docs/rsc.js +5 -2
  87. package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
  88. package/dist/lib/research/docs/section-artifacts.js +12 -0
  89. package/dist/lib/research/docs/sections.d.ts +18 -0
  90. package/dist/lib/research/docs/sections.js +17 -1
  91. package/dist/lib/research/docs/source-map.d.ts +28 -0
  92. package/dist/lib/research/docs/source-map.js +27 -0
  93. package/dist/lib/research/docs/validate.d.ts +35 -2
  94. package/dist/lib/research/docs/validate.js +47 -2
  95. package/dist/lib/research/extract.d.ts +16 -0
  96. package/dist/lib/research/extract.js +12 -1
  97. package/dist/lib/research/fetch-command-service.d.ts +36 -0
  98. package/dist/lib/research/fetch-command-service.js +310 -0
  99. package/dist/lib/research/fetch-result.d.ts +114 -0
  100. package/dist/lib/research/fetch-result.js +212 -0
  101. package/dist/lib/research/fetcher.d.ts +46 -8
  102. package/dist/lib/research/fetcher.js +58 -30
  103. package/dist/lib/research/freshness.d.ts +54 -11
  104. package/dist/lib/research/freshness.js +91 -31
  105. package/dist/lib/research/import-command-service.d.ts +56 -0
  106. package/dist/lib/research/import-command-service.js +381 -0
  107. package/dist/lib/research/keywords.js +5 -1
  108. package/dist/lib/research/markdown.d.ts +12 -1
  109. package/dist/lib/research/markdown.js +13 -5
  110. package/dist/lib/research/persist-artifact.d.ts +23 -0
  111. package/dist/lib/research/persist-artifact.js +27 -0
  112. package/dist/lib/research/prompt-injection.d.ts +8 -0
  113. package/dist/lib/research/prompt-injection.js +42 -13
  114. package/dist/lib/research/proxy.d.ts +14 -8
  115. package/dist/lib/research/proxy.js +14 -10
  116. package/dist/lib/research/revalidate.d.ts +49 -20
  117. package/dist/lib/research/revalidate.js +80 -15
  118. package/dist/lib/research/schema.d.ts +7 -0
  119. package/dist/lib/research/schema.js +2 -0
  120. package/dist/lib/research/secret-scan.d.ts +6 -0
  121. package/dist/lib/research/secret-scan.js +8 -0
  122. package/dist/lib/research/secure-write.d.ts +4 -0
  123. package/dist/lib/research/secure-write.js +31 -2
  124. package/dist/lib/research/storage.d.ts +5 -1
  125. package/dist/lib/research/storage.js +8 -3
  126. package/dist/lib/research/summarize.d.ts +15 -0
  127. package/dist/lib/research/summarize.js +11 -0
  128. package/dist/lib/research/url.d.ts +9 -6
  129. package/dist/lib/research/url.js +33 -21
  130. package/dist/lib/text.d.ts +7 -2
  131. package/dist/lib/text.js +13 -2
  132. package/dist/lib/value-flags.d.ts +14 -0
  133. package/dist/lib/value-flags.js +28 -0
  134. package/dist/sites/coveo.d.ts +10 -0
  135. package/dist/sites/coveo.js +10 -0
  136. package/dist/sites/index.d.ts +16 -0
  137. package/dist/sites/index.js +16 -4
  138. package/dist/sites/markdown-twin.d.ts +31 -0
  139. package/dist/sites/markdown-twin.js +56 -0
  140. package/dist/sites/salesforce/fetch-page.d.ts +8 -2
  141. package/dist/sites/salesforce/fetch-page.js +14 -3
  142. package/dist/sites/salesforce/help-tooling-markdown.d.ts +30 -0
  143. package/dist/sites/salesforce/help-tooling-markdown.js +74 -0
  144. package/dist/sites/salesforce/index.d.ts +1 -0
  145. package/dist/sites/salesforce/index.js +1 -0
  146. package/dist/sites/salesforce-developer/fetch-page.d.ts +6 -1
  147. package/dist/sites/salesforce-developer/fetch-page.js +6 -1
  148. package/dist/sites/salesforce-developer/index.d.ts +1 -0
  149. package/dist/sites/salesforce-developer/index.js +1 -0
  150. package/dist/sites/salesforce-developer/route-markdown.d.ts +18 -6
  151. package/dist/sites/salesforce-developer/route-markdown.js +29 -67
  152. package/dist/sites/salesforce-doc-fetch.d.ts +39 -5
  153. package/dist/sites/salesforce-doc-fetch.js +52 -7
  154. package/dist/sites/salesforce-dom-probe.d.ts +20 -2
  155. package/dist/sites/salesforce-dom-probe.js +20 -2
  156. package/dist/sites/types.d.ts +14 -8
  157. package/dist/sites/types.js +3 -0
  158. package/oclif.manifest.json +104 -98
  159. package/package.json +19 -7
  160. package/agents/agents/official-docs-researcher.agent.md +0 -87
  161. package/agents/instructions/web-research.instructions.md +0 -44
  162. package/agents/skills/web-research/SKILL.md +0 -118
@@ -47,15 +47,23 @@ function addGfmTableRule(service) {
47
47
  });
48
48
  }
49
49
  /**
50
- * Converts HTML content to clean, deterministic Markdown.
51
- * Configured to use ATX-style headings (#) and fenced code blocks (```), with GFM tables.
50
+ * Drops empty anchor links (e.g. `[](url)`) from Markdown. These arise from icon-only heading
51
+ * permalinks and ReadMe query-param chips that carry no text content, wasting tokens.
52
+ * Images (`![](url)`) are intentionally not matched.
53
+ *
54
+ * @param markdown - Markdown text to clean.
55
+ * @returns Markdown with text-less links removed.
52
56
  */
53
- // Icon-only/empty anchors (e.g. a heading's "#" permalink, a ReadMe query-param chip) convert to a
54
- // text-less link `[](url)` that carries no information and just wastes tokens. Drop them, but never
55
- // match images `![](url)` (empty alt is still a real image).
56
57
  export function dropEmptyLinks(markdown) {
57
58
  return markdown.replace(/(?<!!)\[\s*\]\([^)]*\)/g, '');
58
59
  }
60
+ /**
61
+ * Converts HTML content to clean, deterministic Markdown.
62
+ * Configured to use ATX-style headings (#) and fenced code blocks (```), with GFM tables.
63
+ *
64
+ * @param html - HTML string to convert.
65
+ * @returns Clean Markdown with empty links stripped.
66
+ */
59
67
  export function htmlToMarkdown(html) {
60
68
  const turndownService = new TurndownService({
61
69
  headingStyle: 'atx',
@@ -0,0 +1,23 @@
1
+ import { type SecureWriteResult } from './secure-write.js';
2
+ import type { StoreRoots } from './store-roots.js';
3
+ import type { ResearchArtifact } from './schema.js';
4
+ export type ArtifactWriteKind = 'import' | 'fetch';
5
+ export interface PersistArtifactResult extends SecureWriteResult {
6
+ dryRun: boolean;
7
+ /** Secret-redirect warning for stderr, or null when no redirect. */
8
+ redirectWarning: string | null;
9
+ }
10
+ /**
11
+ * Persist (or dry-run preview) an artifact with secret-safe routing. Owns redirect warning copy
12
+ * so fetch/import cannot drift. When `scratchDir` is set (fetch dry-run), also writes a throwaway
13
+ * copy there for downstream extraction while reporting the real would-be `dataDir`.
14
+ */
15
+ export declare function persistArtifact(opts: {
16
+ roots: StoreRoots;
17
+ cacheKey: string;
18
+ artifact: ResearchArtifact;
19
+ dryRun: boolean;
20
+ kind: ArtifactWriteKind;
21
+ /** Fetch dry-run throwaway directory; real destination still comes from the secure write. */
22
+ scratchDir?: string | null;
23
+ }): PersistArtifactResult;
@@ -0,0 +1,27 @@
1
+ import { writeArtifact } from './storage.js';
2
+ import { writeArtifactSecurely } from './secure-write.js';
3
+ function redirectWarningFor(kind, dryRun, secretLabel) {
4
+ const content = kind === 'import' ? 'imported content' : 'page content';
5
+ const verb = dryRun ? 'would store' : 'stored';
6
+ return `Detected ${secretLabel} in the ${content}; ${verb} in the global cache instead of the project to avoid committing secrets.`;
7
+ }
8
+ /**
9
+ * Persist (or dry-run preview) an artifact with secret-safe routing. Owns redirect warning copy
10
+ * so fetch/import cannot drift. When `scratchDir` is set (fetch dry-run), also writes a throwaway
11
+ * copy there for downstream extraction while reporting the real would-be `dataDir`.
12
+ */
13
+ export function persistArtifact(opts) {
14
+ const result = writeArtifactSecurely(opts.roots, opts.cacheKey, opts.artifact, {
15
+ dryRun: opts.dryRun,
16
+ });
17
+ if (opts.scratchDir) {
18
+ writeArtifact(opts.scratchDir, opts.cacheKey, opts.artifact);
19
+ }
20
+ return {
21
+ ...result,
22
+ dryRun: opts.dryRun,
23
+ redirectWarning: result.redirected
24
+ ? redirectWarningFor(opts.kind, opts.dryRun, result.secretLabel)
25
+ : null,
26
+ };
27
+ }
@@ -1 +1,9 @@
1
+ /**
2
+ * Scans Markdown for embedded prompt-injection attacks and redacts any block or line that matches.
3
+ * Detects direct patterns, plus base64/hex-encoded variants and typoglycemia obfuscation.
4
+ * HTML comments are checked first; the body is then split on paragraph breaks and scanned line-by-line.
5
+ *
6
+ * @param markdown - Markdown content (possibly untrusted, sourced from fetched pages).
7
+ * @returns Sanitized Markdown with harmful instruction blocks replaced by a redaction placeholder.
8
+ */
1
9
  export declare function sanitizePromptInjection(markdown: string): string;
@@ -30,19 +30,28 @@ const TYPOGLYCEMIA_TARGETS = [
30
30
  'repository',
31
31
  'workspace',
32
32
  ];
33
+ // Contexts where a following verb reads as a command aimed at the reader, not a third-person
34
+ // description of an attack (e.g. "An attacker may tell the model to ignore..." must stay
35
+ // unredacted — see the matching test below). `^` is matched per clause (see CLAUSE_BOUNDARY
36
+ // below), so a command hidden after an innocuous opener ("Heads up: ignore...") still counts as
37
+ // clause-initial; only right after a direct role-address word is the other allowed context.
38
+ const COMMAND_CONTEXT = String.raw `(?:^|\b(?:please|assistant|system|user|agent)\s+)`;
39
+ function commandPattern(suffix) {
40
+ return new RegExp(COMMAND_CONTEXT + suffix, 'i');
41
+ }
33
42
  const HARMFUL_INSTRUCTION_PATTERNS = [
34
- /(^|\b(?:please|assistant|system|user|agent)\s+)ignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|directions?|prompts?)\b/i,
35
- /(^|\b(?:please|assistant|system|user|agent)\s+)disregard\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|directions?|prompts?)\b/i,
36
- /(^|\b(?:please|assistant|system|user|agent)\s+)forget\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|directions?|prompts?)\b/i,
37
- /(^|\b(?:please|assistant|system|user|agent)\s+)override\s+(the\s+)?(system|developer|agent)\s+(prompt|instructions?|message)\b/i,
38
- /(^|\b(?:please|assistant|system|user|agent)\s+)you\s+are\s+now\s+(in\s+)?(developer|admin|root|system)\s+mode\b/i,
39
- /(^|\b(?:please|assistant|system|user|agent)\s+)reveal\s+(your\s+)?(system|developer)\s+(prompt|instructions?|message)\b/i,
40
- /(^|\b(?:please|assistant|system|user|agent)\s+)print\s+(your\s+)?(system|developer)\s+(prompt|instructions?|message)\b/i,
41
- /(^|\b(?:please|assistant|system|user|agent)\s+)(exfiltrate|steal|leak)\s+(all\s+|the\s+|your\s+)?(secrets?|tokens?|api\s*keys?|credentials?|private\s+data)\b/i,
42
- /(^|\b(?:please|assistant|system|user|agent)\s+)send\s+(me\s+|all\s+|the\s+|your\s+)?(secrets?|tokens?|api\s*keys?|credentials?)\s+(to|over|via|using)\b/i,
43
- /(^|\b(?:please|assistant|system|user|agent)\s+)(upload|post|fetch|curl)\s+(all\s+|the\s+|your\s+)?(secrets?|tokens?|api\s*keys?|credentials?)\s+(to|over|via|using)\b/i,
44
- /(^|\b(?:please|assistant|system|user|agent)\s+)delete\s+(all\s+)?(files?|the\s+repository|the\s+workspace|the\s+home\s+directory)\b/i,
45
- /(^|\b(?:please|assistant|system|user|agent)\s+)run\s+.*\b(rm\s+-rf|curl\s+.*\|\s*(sh|bash)|sudo)\b/i,
43
+ commandPattern(String.raw `ignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|directions?|prompts?)\b`),
44
+ commandPattern(String.raw `disregard\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|directions?|prompts?)\b`),
45
+ commandPattern(String.raw `forget\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|directions?|prompts?)\b`),
46
+ commandPattern(String.raw `override\s+(the\s+)?(system|developer|agent)\s+(prompt|instructions?|message)\b`),
47
+ commandPattern(String.raw `you\s+are\s+now\s+(in\s+)?(developer|admin|root|system)\s+mode\b`),
48
+ commandPattern(String.raw `reveal\s+(your\s+)?(system|developer)\s+(prompt|instructions?|message)\b`),
49
+ commandPattern(String.raw `print\s+(your\s+)?(system|developer)\s+(prompt|instructions?|message)\b`),
50
+ commandPattern(String.raw `(exfiltrate|steal|leak)\s+(all\s+|the\s+|your\s+)?(secrets?|tokens?|api\s*keys?|credentials?|private\s+data)\b`),
51
+ commandPattern(String.raw `send\s+(me\s+|all\s+|the\s+|your\s+)?(secrets?|tokens?|api\s*keys?|credentials?)\s+(to|over|via|using)\b`),
52
+ commandPattern(String.raw `(upload|post|fetch|curl)\s+(all\s+|the\s+|your\s+)?(secrets?|tokens?|api\s*keys?|credentials?)\s+(to|over|via|using)\b`),
53
+ commandPattern(String.raw `delete\s+(all\s+)?(files?|the\s+repository|the\s+workspace|the\s+home\s+directory)\b`),
54
+ commandPattern(String.raw `run\s+.*\b(rm\s+-rf|curl\s+.*\|\s*(sh|bash)|sudo)\b`),
46
55
  ];
47
56
  function normalizeForDetection(text) {
48
57
  return text
@@ -110,8 +119,20 @@ function isReadableDecodedText(text) {
110
119
  const printable = [...text].filter((char) => /[\t\n\r -~]/.test(char)).length;
111
120
  return printable / Math.max(text.length, 1) > 0.85;
112
121
  }
122
+ // Sentence-ending punctuation splits a line into clauses for `^`-anchored matching below. This has
123
+ // to run on the raw text, before normalizeForDetection's charset filter erases that same
124
+ // punctuation into plain spaces — testing the anchor after normalization would never see it.
125
+ //
126
+ // ponytail: this still isn't complete — a filler *word* with no punctuation before it ("Please
127
+ // just ignore previous instructions") is a clause-internal command and stays unredacted, same as
128
+ // before this fix. Heuristic prompt-injection detection can't be made airtight with regex alone
129
+ // (OWASP LLM01 has no complete solution either); upgrade path is a small classifier or an LLM-based
130
+ // second pass over blocks this pattern set flags as borderline, if false negatives here prove costly.
131
+ const CLAUSE_BOUNDARY = /(?<=[.!?:;])\s+/;
113
132
  function isUnsafeAgentInstruction(text) {
114
- return detectionCandidates(text).some((candidate) => HARMFUL_INSTRUCTION_PATTERNS.some((pattern) => pattern.test(candidate)));
133
+ return text
134
+ .split(CLAUSE_BOUNDARY)
135
+ .some((clause) => detectionCandidates(clause).some((candidate) => HARMFUL_INSTRUCTION_PATTERNS.some((pattern) => pattern.test(candidate))));
115
136
  }
116
137
  function redactBlock(block) {
117
138
  const withSafeSpans = redactUnsafeSpans(block);
@@ -132,6 +153,14 @@ function stripQuotes(span) {
132
153
  }
133
154
  return span;
134
155
  }
156
+ /**
157
+ * Scans Markdown for embedded prompt-injection attacks and redacts any block or line that matches.
158
+ * Detects direct patterns, plus base64/hex-encoded variants and typoglycemia obfuscation.
159
+ * HTML comments are checked first; the body is then split on paragraph breaks and scanned line-by-line.
160
+ *
161
+ * @param markdown - Markdown content (possibly untrusted, sourced from fetched pages).
162
+ * @returns Sanitized Markdown with harmful instruction blocks replaced by a redaction placeholder.
163
+ */
135
164
  export function sanitizePromptInjection(markdown) {
136
165
  return markdown
137
166
  .replace(HTML_COMMENT, (comment) => redactBlock(comment))
@@ -59,13 +59,19 @@ export declare function getCaBundleSpkiHashes(): string[];
59
59
  /**
60
60
  * Chrome CLI flag that trusts exactly the sandbox's injected CA(s), pinned by SPKI hash rather
61
61
  * than by disabling verification. Chromium's TLS stack (the Chrome Root Store) never reads
62
- * NODE_EXTRA_CA_CERTS/SSL_CERT_FILE/CURL_CA_BUNDLE the way Node/curl/Python do, so a sandbox that
63
- * MITMs HTTPS through its proxy (see isProxyConfigured) makes every other tool trust the proxy's
64
- * cert while Chrome's handshake with it fails with ERR_CERT_AUTHORITY_INVALID. This is scoped
65
- * narrowly to the specific CA(s) discovered via the CA bundle env vars — never
66
- * --ignore-certificate-errors, which would disable verification for every host, including ones
67
- * that have nothing to do with the sandbox's proxy. A no-op (empty array) when no proxy is
68
- * configured or no CA bundle is discoverable, so default Chrome trust is untouched on ordinary
69
- * developer machines.
62
+ * NODE_EXTRA_CA_CERTS/SSL_CERT_FILE/CURL_CA_BUNDLE the way Node/curl/Python do, so an environment
63
+ * that MITMs HTTPS makes every other tool trust that CA while Chrome's handshake with it fails
64
+ * with ERR_CERT_AUTHORITY_INVALID. This is scoped narrowly to the specific CA(s) discovered via
65
+ * the CA bundle env vars — never --ignore-certificate-errors, which would disable verification
66
+ * for every host, including ones that have nothing to do with the sandbox.
67
+ *
68
+ * Deliberately independent of isProxyConfigured(): some sandboxes intercept HTTPS transparently
69
+ * (or via a proxy Chrome reaches without an explicit --proxy-server flag) while still exporting a
70
+ * CA bundle env var for CLI tools to trust — HTTPS_PROXY/HTTP_PROXY and the CA bundle vars are
71
+ * two separate signals that usually co-occur but aren't guaranteed to. Gating on proxy detection
72
+ * left Chrome untrusting a CA bundle that was in fact discoverable, so it rejected every
73
+ * browser-rendered fetch with ERR_CERT_AUTHORITY_INVALID in that shape of environment. A no-op
74
+ * (empty array) when no CA bundle is discoverable, so default Chrome trust is untouched on
75
+ * ordinary developer machines.
70
76
  */
71
77
  export declare function getChromeSpkiArgs(): string[];
@@ -169,18 +169,22 @@ export function getCaBundleSpkiHashes() {
169
169
  /**
170
170
  * Chrome CLI flag that trusts exactly the sandbox's injected CA(s), pinned by SPKI hash rather
171
171
  * than by disabling verification. Chromium's TLS stack (the Chrome Root Store) never reads
172
- * NODE_EXTRA_CA_CERTS/SSL_CERT_FILE/CURL_CA_BUNDLE the way Node/curl/Python do, so a sandbox that
173
- * MITMs HTTPS through its proxy (see isProxyConfigured) makes every other tool trust the proxy's
174
- * cert while Chrome's handshake with it fails with ERR_CERT_AUTHORITY_INVALID. This is scoped
175
- * narrowly to the specific CA(s) discovered via the CA bundle env vars — never
176
- * --ignore-certificate-errors, which would disable verification for every host, including ones
177
- * that have nothing to do with the sandbox's proxy. A no-op (empty array) when no proxy is
178
- * configured or no CA bundle is discoverable, so default Chrome trust is untouched on ordinary
179
- * developer machines.
172
+ * NODE_EXTRA_CA_CERTS/SSL_CERT_FILE/CURL_CA_BUNDLE the way Node/curl/Python do, so an environment
173
+ * that MITMs HTTPS makes every other tool trust that CA while Chrome's handshake with it fails
174
+ * with ERR_CERT_AUTHORITY_INVALID. This is scoped narrowly to the specific CA(s) discovered via
175
+ * the CA bundle env vars — never --ignore-certificate-errors, which would disable verification
176
+ * for every host, including ones that have nothing to do with the sandbox.
177
+ *
178
+ * Deliberately independent of isProxyConfigured(): some sandboxes intercept HTTPS transparently
179
+ * (or via a proxy Chrome reaches without an explicit --proxy-server flag) while still exporting a
180
+ * CA bundle env var for CLI tools to trust — HTTPS_PROXY/HTTP_PROXY and the CA bundle vars are
181
+ * two separate signals that usually co-occur but aren't guaranteed to. Gating on proxy detection
182
+ * left Chrome untrusting a CA bundle that was in fact discoverable, so it rejected every
183
+ * browser-rendered fetch with ERR_CERT_AUTHORITY_INVALID in that shape of environment. A no-op
184
+ * (empty array) when no CA bundle is discoverable, so default Chrome trust is untouched on
185
+ * ordinary developer machines.
180
186
  */
181
187
  export function getChromeSpkiArgs() {
182
- if (!isProxyConfigured())
183
- return [];
184
188
  const hashes = getCaBundleSpkiHashes();
185
189
  if (!hashes.length)
186
190
  return [];
@@ -1,34 +1,63 @@
1
1
  import type { ResearchArtifact } from './schema.js';
2
+ import { type FetchedContent } from './fetcher.js';
3
+ import { type ExtractionResult } from './extract.js';
4
+ import type { StoreRoots } from './store-roots.js';
2
5
  import type { SummaryLevel } from '../config/schema.js';
6
+ /**
7
+ * When set, project-located revalidation writes reuse {@link persistArtifact} (secret-scan +
8
+ * redirect) so a refresh cannot leave credentials in a committable project cache (#90).
9
+ */
10
+ export interface RevalidateSecureWrite {
11
+ roots: StoreRoots;
12
+ dryRun?: boolean;
13
+ scratchDir?: string | null;
14
+ }
15
+ /** Result of conditional revalidation or a full refresh when validators miss. */
3
16
  export interface RevalidationResult {
17
+ /** Outcome of the revalidation attempt. */
4
18
  status: 'revalidated' | 'refreshed' | 'stale';
19
+ /** Artifact to serve (updated, refreshed, or the prior stale entry). */
5
20
  artifact: ResearchArtifact;
21
+ /** When status is `stale`, whether serving stale content is allowed. */
6
22
  allowed?: boolean;
23
+ /** Revalidation failure message when status is `stale`. */
7
24
  error?: string;
25
+ /** Data dir the write landed in (or would land in under dry-run), when a write ran. */
26
+ storageDir?: string;
27
+ /** True when a project refresh was redirected to global due to a detected secret. */
28
+ redirectedToGlobal?: boolean;
29
+ /** Secret-redirect warning for stderr, or null/undefined when none. */
30
+ redirectWarning?: string | null;
31
+ }
32
+ type RevalidateOptions = {
33
+ allowStale: boolean;
34
+ ttlOverride?: string | null;
35
+ rendered?: boolean;
36
+ summaryLevel: SummaryLevel;
37
+ /** Present only when the live entry is in the project cache. */
38
+ secure?: RevalidateSecureWrite;
39
+ };
40
+ interface CreateArtifactFromFetchInput {
41
+ url: string;
42
+ normalizedUrl: string;
43
+ cacheKey: string;
44
+ fetchResult: FetchedContent;
45
+ extraction: ExtractionResult;
46
+ tier: 'stable' | 'standard' | 'volatile';
47
+ ttl: string | null;
48
+ currentTime: Date;
49
+ summaryLevel: SummaryLevel;
8
50
  }
9
51
  /**
10
- * Helper to construct a ResearchArtifact from a fresh HTML fetch.
52
+ * Construct a ResearchArtifact from a fresh HTML fetch (or error-page marker).
53
+ *
54
+ * @param input - URL, fetch result, extraction, tier/TTL, clock, and summary level.
55
+ * @returns A ready-to-persist research artifact.
11
56
  */
12
- export declare function createArtifactFromFetch(url: string, normalizedUrl: string, cacheKey: string, fetchResult: {
13
- contentType: string | null;
14
- etag: string | null;
15
- lastModified: string | null;
16
- finalUrl: string;
17
- responseSize: number;
18
- content: string;
19
- }, extraction: {
20
- title: string;
21
- detailedMarkdown: string;
22
- confidence: 'high' | 'medium' | 'low';
23
- qualityNotes: string[];
24
- }, tier: 'stable' | 'standard' | 'volatile', ttl: string | null, currentTime: Date, summaryLevel: SummaryLevel): ResearchArtifact;
57
+ export declare function createArtifactFromFetch(input: CreateArtifactFromFetchInput): ResearchArtifact;
25
58
  /**
26
59
  * Revalidates a stale cache artifact using conditional request headers,
27
60
  * falling back to stale serving within the grace period if the remote host is offline.
28
61
  */
29
- export declare function revalidateCache(dataDir: string, existing: ResearchArtifact, currentTime: Date, options: {
30
- allowStale: boolean;
31
- ttlOverride?: string | null;
32
- rendered?: boolean;
33
- summaryLevel: SummaryLevel;
34
- }): Promise<RevalidationResult>;
62
+ export declare function revalidateCache(dataDir: string, existing: ResearchArtifact, currentTime: Date, options: RevalidateOptions): Promise<RevalidationResult>;
63
+ export {};
@@ -4,16 +4,37 @@ import { applySiteFetchProvenance } from '../../sites/types.js';
4
4
  import { fetchStaticHtml } from './fetcher.js';
5
5
  import { extractHtmlContent } from './extract.js';
6
6
  import { writeArtifact } from './storage.js';
7
- import { evaluateFreshness, getPolicy } from './freshness.js';
7
+ import { persistArtifact } from './persist-artifact.js';
8
+ import { evaluateFreshness, resolveFreshnessPolicy } from './freshness.js';
8
9
  import { buildCompressed } from './compress.js';
9
10
  import { applyAutoTags } from './keywords.js';
10
11
  import { estimateTokens } from './token-estimate.js';
11
12
  import { fetchRenderedHtml } from './browser.js';
12
13
  import { looksLikeErrorPage } from './docs/validate.js';
13
- function buildMetadata(url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime, compressed) {
14
+ function persistRevalidated(dataDir, key, artifact, secure) {
15
+ if (secure) {
16
+ const result = persistArtifact({
17
+ roots: secure.roots,
18
+ cacheKey: key,
19
+ artifact,
20
+ dryRun: Boolean(secure.dryRun),
21
+ kind: 'fetch',
22
+ scratchDir: secure.scratchDir,
23
+ });
24
+ return {
25
+ storageDir: result.dataDir,
26
+ redirectedToGlobal: result.redirected,
27
+ redirectWarning: result.redirectWarning,
28
+ };
29
+ }
30
+ writeArtifact(dataDir, key, artifact);
31
+ return { storageDir: dataDir, redirectedToGlobal: false, redirectWarning: null };
32
+ }
33
+ function buildMetadata(input) {
34
+ const { url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime, compressed, } = input;
14
35
  const contentHash = createHash('sha256').update(extraction.detailedMarkdown).digest('hex');
15
36
  const staleAfterTime = new Date(currentTime);
16
- const { freshWindowMs } = getPolicy(tier, ttl);
37
+ const { freshWindowMs } = resolveFreshnessPolicy(tier, ttl);
17
38
  staleAfterTime.setTime(staleAfterTime.getTime() + freshWindowMs);
18
39
  return {
19
40
  schema_version: 1,
@@ -57,7 +78,8 @@ function buildMetadata(url, normalizedUrl, cacheKey, fetchResult, extraction, ti
57
78
  // Builds a compact error marker instead of caching a full error page. Subsequent lookups serve this
58
79
  // tiny marker (a handful of tokens) instead of re-fetching, and revalidation re-checks it when stale
59
80
  // so a transient failure recovers into a real artifact.
60
- function buildErrorArtifact(url, normalizedUrl, cacheKey, fetchResult, tier, ttl, currentTime, reason) {
81
+ function buildErrorArtifact(input) {
82
+ const { url, normalizedUrl, cacheKey, fetchResult, tier, ttl, currentTime, reason } = input;
61
83
  const marker = `Error: ${url} could not be cached — ${reason}. ` +
62
84
  'The page returned an error, so its content was not stored; it will be re-fetched when this entry goes stale.';
63
85
  const extraction = {
@@ -66,7 +88,17 @@ function buildErrorArtifact(url, normalizedUrl, cacheKey, fetchResult, tier, ttl
66
88
  confidence: 'low',
67
89
  qualityNotes: [`error: ${reason}`],
68
90
  };
69
- const metadata = buildMetadata(url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime, marker);
91
+ const metadata = buildMetadata({
92
+ url,
93
+ normalizedUrl,
94
+ cacheKey,
95
+ fetchResult,
96
+ extraction,
97
+ tier,
98
+ ttl,
99
+ currentTime,
100
+ compressed: marker,
101
+ });
70
102
  metadata.extraction_status = 'failed';
71
103
  metadata.extraction_confidence = null;
72
104
  return {
@@ -78,17 +110,40 @@ function buildErrorArtifact(url, normalizedUrl, cacheKey, fetchResult, tier, ttl
78
110
  };
79
111
  }
80
112
  /**
81
- * Helper to construct a ResearchArtifact from a fresh HTML fetch.
113
+ * Construct a ResearchArtifact from a fresh HTML fetch (or error-page marker).
114
+ *
115
+ * @param input - URL, fetch result, extraction, tier/TTL, clock, and summary level.
116
+ * @returns A ready-to-persist research artifact.
82
117
  */
83
- export function createArtifactFromFetch(url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime, summaryLevel) {
118
+ export function createArtifactFromFetch(input) {
119
+ const { url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime, summaryLevel, } = input;
84
120
  // A managed platform / SPA can return HTTP 200 but render only a "not found" / "something went
85
121
  // wrong" shell. Cache a compact marker for those instead of the full error markdown, so repeat
86
122
  // lookups cost a few tokens and revalidation still re-checks the page when the entry goes stale.
87
123
  if (looksLikeErrorPage(extraction.detailedMarkdown)) {
88
- return buildErrorArtifact(url, normalizedUrl, cacheKey, fetchResult, tier, ttl, currentTime, 'page reported an error or was not found');
124
+ return buildErrorArtifact({
125
+ url,
126
+ normalizedUrl,
127
+ cacheKey,
128
+ fetchResult,
129
+ tier,
130
+ ttl,
131
+ currentTime,
132
+ reason: 'page reported an error or was not found',
133
+ });
89
134
  }
90
135
  const compressed = buildCompressed(extraction.detailedMarkdown, summaryLevel);
91
- const metadata = buildMetadata(url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime, compressed);
136
+ const metadata = buildMetadata({
137
+ url,
138
+ normalizedUrl,
139
+ cacheKey,
140
+ fetchResult,
141
+ extraction,
142
+ tier,
143
+ ttl,
144
+ currentTime,
145
+ compressed,
146
+ });
92
147
  return {
93
148
  metadata,
94
149
  summary: extraction.title,
@@ -113,7 +168,17 @@ function preserveUserMetadata(source, target, rendered) {
113
168
  // returns the "refreshed" result. Shared by both refresh paths (conditional-request fallthrough
114
169
  // and site-module re-fetch), which differ only in how fetchResult/extraction are obtained.
115
170
  function persistRefreshedArtifact(dataDir, meta, fetchResult, extraction, currentTime, options, siteFetch) {
116
- const refreshed = createArtifactFromFetch(meta.source_url, meta.normalized_url, meta.cache_key, fetchResult, extraction, meta.tier, options.ttlOverride || meta.ttl, currentTime, options.summaryLevel);
171
+ const refreshed = createArtifactFromFetch({
172
+ url: meta.source_url,
173
+ normalizedUrl: meta.normalized_url,
174
+ cacheKey: meta.cache_key,
175
+ fetchResult,
176
+ extraction,
177
+ tier: meta.tier,
178
+ ttl: options.ttlOverride || meta.ttl,
179
+ currentTime,
180
+ summaryLevel: options.summaryLevel,
181
+ });
117
182
  preserveUserMetadata(meta, refreshed, options.rendered);
118
183
  // A site-module refresh reports how it actually captured this time; that overrides the
119
184
  // carried-over provenance, so a withdrawn .md twin can't leave a stale source_doc_url behind.
@@ -122,8 +187,8 @@ function persistRefreshedArtifact(dataDir, meta, fetchResult, extraction, curren
122
187
  // Back-fill keyword tags when the carried-over set is empty (e.g. an artifact first cached before
123
188
  // auto-tagging, or one originally stored without tags), so refreshing keeps it searchable.
124
189
  applyAutoTags(refreshed);
125
- writeArtifact(dataDir, meta.cache_key, refreshed);
126
- return { status: 'refreshed', artifact: refreshed };
190
+ const write = persistRevalidated(dataDir, meta.cache_key, refreshed, options.secure);
191
+ return { status: 'refreshed', artifact: refreshed, ...write };
127
192
  }
128
193
  async function handleRevalidateResponse(dataDir, existing, fetchResult, currentTime, options) {
129
194
  const meta = existing.metadata;
@@ -136,11 +201,11 @@ async function handleRevalidateResponse(dataDir, existing, fetchResult, currentT
136
201
  },
137
202
  };
138
203
  const staleAfterTime = new Date(currentTime);
139
- const { freshWindowMs } = getPolicy(meta.tier, options.ttlOverride || meta.ttl);
204
+ const { freshWindowMs } = resolveFreshnessPolicy(meta.tier, options.ttlOverride || meta.ttl);
140
205
  staleAfterTime.setTime(staleAfterTime.getTime() + freshWindowMs);
141
206
  updated.metadata.stale_after = staleAfterTime.toISOString();
142
- writeArtifact(dataDir, meta.cache_key, updated);
143
- return { status: 'revalidated', artifact: updated };
207
+ const write = persistRevalidated(dataDir, meta.cache_key, updated, options.secure);
208
+ return { status: 'revalidated', artifact: updated, ...write };
144
209
  }
145
210
  const extraction = extractHtmlContent(fetchResult.content, fetchResult.finalUrl);
146
211
  return persistRefreshedArtifact(dataDir, meta, fetchResult, extraction, currentTime, options);
@@ -1,11 +1,17 @@
1
+ /** Token counts for the two stored representations of a research artifact. */
1
2
  export interface TokenEstimate {
2
3
  compressed: number | null;
3
4
  detailed: number | null;
4
5
  }
6
+ /** All recognized capture methods. Command flag option lists derive from this constant. */
5
7
  export declare const CAPTURE_METHODS: readonly ["static_fetch", "browser_fallback", "agent_supplied", "route_markdown", "github_source"];
8
+ /** How a research artifact's content was obtained. */
6
9
  export type CaptureMethod = (typeof CAPTURE_METHODS)[number];
10
+ /** All recognized artifact types. `index` = navigation hub or llms.txt; `section` = heading-level child of a page. */
7
11
  export declare const ARTIFACT_TYPES: readonly ["source", "research_note", "index", "section"];
12
+ /** Classification of a research artifact's role. */
8
13
  export type ArtifactType = (typeof ARTIFACT_TYPES)[number];
14
+ /** Full metadata block stored alongside every cached research artifact. */
9
15
  export interface ResearchArtifactMetadata {
10
16
  schema_version: number;
11
17
  artifact_type: ArtifactType;
@@ -41,6 +47,7 @@ export interface ResearchArtifactMetadata {
41
47
  section_anchor: string | null;
42
48
  section_heading_path: string | null;
43
49
  }
50
+ /** A complete research artifact: metadata, summary, compressed and detailed Markdown, and provenance. */
44
51
  export interface ResearchArtifact {
45
52
  metadata: ResearchArtifactMetadata;
46
53
  summary: string;
@@ -1,5 +1,6 @@
1
1
  // Single source of truth for the capture-method enum. Command flag `options` lists derive from this
2
2
  // so a new method can never drift out of sync with the filters that are supposed to expose it.
3
+ /** All recognized capture methods. Command flag option lists derive from this constant. */
3
4
  export const CAPTURE_METHODS = [
4
5
  'static_fetch',
5
6
  'browser_fallback',
@@ -9,4 +10,5 @@ export const CAPTURE_METHODS = [
9
10
  ];
10
11
  // Single source of truth for the artifact-type enum. `index` = navigation/hub or llms.txt site
11
12
  // index; `section` = a heading-level child of a page. Command flag `options` derive from this.
13
+ /** All recognized artifact types. `index` = navigation hub or llms.txt; `section` = heading-level child of a page. */
12
14
  export const ARTIFACT_TYPES = ['source', 'research_note', 'index', 'section'];
@@ -1,5 +1,11 @@
1
1
  import type { ResearchArtifact } from './schema.js';
2
2
  /** Return the label of the first secret pattern found in `text`, or null if none match. */
3
3
  export declare function detectSecret(text: string): string | null;
4
+ /**
5
+ * Strip every secret-pattern match from `text`, replacing each with a single space. Matches the
6
+ * original (case-preserved, unsplit) text, so callers must run this before any lowercasing or
7
+ * tokenization step that would otherwise hide a pattern like `AKIA…` or `ghp_…` from detection.
8
+ */
9
+ export declare function stripSecrets(text: string): string;
4
10
  /** Scan every content field of an artifact. Returns the detected secret's label, or null. */
5
11
  export declare function scanArtifactForSecret(artifact: ResearchArtifact): string | null;
@@ -30,6 +30,14 @@ export function detectSecret(text) {
30
30
  }
31
31
  return null;
32
32
  }
33
+ /**
34
+ * Strip every secret-pattern match from `text`, replacing each with a single space. Matches the
35
+ * original (case-preserved, unsplit) text, so callers must run this before any lowercasing or
36
+ * tokenization step that would otherwise hide a pattern like `AKIA…` or `ghp_…` from detection.
37
+ */
38
+ export function stripSecrets(text) {
39
+ return SECRET_PATTERNS.reduce((result, { re }) => result.replace(new RegExp(re.source, `${re.flags}g`), ' '), text);
40
+ }
33
41
  /** Scan every content field of an artifact. Returns the detected secret's label, or null. */
34
42
  export function scanArtifactForSecret(artifact) {
35
43
  const haystack = [artifact.summary, artifact.compressed, artifact.detailed, artifact.provenance]
@@ -16,6 +16,10 @@ export interface SecureWriteResult {
16
16
  * `dryRun` still runs the secret scan and reports the real would-be destination, but skips the
17
17
  * actual `writeArtifact` persist — so a read-only preview never gives a falsely reassuring answer
18
18
  * about where secret-bearing content would land.
19
+ *
20
+ * On redirect, any existing project copy is archived and removed so lookup cannot keep serving the
21
+ * project shadow after the secret-bearing content moved to global (revalidation parity with
22
+ * first-time project writes — see #90 / AUDIT_71_FINAL).
19
23
  */
20
24
  export declare function writeArtifactSecurely(roots: StoreRoots, key: string, artifact: ResearchArtifact, options?: {
21
25
  dryRun?: boolean;
@@ -1,5 +1,27 @@
1
- import { writeArtifact } from './storage.js';
1
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { writeArtifact, getArtifactPath } from './storage.js';
2
4
  import { scanArtifactForSecret } from './secret-scan.js';
5
+ /**
6
+ * Archive then remove an active project artifact so a redirected global copy is not shadowed by
7
+ * project→global lookup order. Best-effort: a failed archive/unlink never undoes the global write.
8
+ *
9
+ * @see https://nodejs.org/api/fs.html#fsunlinksyncpath
10
+ */
11
+ function clearProjectArtifactAfterRedirect(projectRoot, key) {
12
+ const path = getArtifactPath(projectRoot, key);
13
+ if (!existsSync(path))
14
+ return;
15
+ try {
16
+ const content = readFileSync(path, 'utf-8');
17
+ const dir = join(projectRoot, 'research');
18
+ writeFileSync(join(dir, `${key}.superseded.${Date.now()}.md`), content, 'utf-8');
19
+ unlinkSync(path);
20
+ }
21
+ catch (err) {
22
+ console.warn(`Warning: failed to clear project artifact after secret redirect: ${err.message}`);
23
+ }
24
+ }
3
25
  /**
4
26
  * Write an artifact to the configured write root, except when it contains a secret and the
5
27
  * target is a (potentially committed) project cache — those are redirected to the global cache.
@@ -8,12 +30,19 @@ import { scanArtifactForSecret } from './secret-scan.js';
8
30
  * `dryRun` still runs the secret scan and reports the real would-be destination, but skips the
9
31
  * actual `writeArtifact` persist — so a read-only preview never gives a falsely reassuring answer
10
32
  * about where secret-bearing content would land.
33
+ *
34
+ * On redirect, any existing project copy is archived and removed so lookup cannot keep serving the
35
+ * project shadow after the secret-bearing content moved to global (revalidation parity with
36
+ * first-time project writes — see #90 / AUDIT_71_FINAL).
11
37
  */
12
38
  export function writeArtifactSecurely(roots, key, artifact, options = {}) {
13
39
  const isProjectWrite = roots.writeRoot !== roots.globalRoot;
14
40
  const secretLabel = isProjectWrite ? scanArtifactForSecret(artifact) : null;
15
41
  const dataDir = secretLabel ? roots.globalRoot : roots.writeRoot;
16
- if (!options.dryRun)
42
+ if (!options.dryRun) {
17
43
  writeArtifact(dataDir, key, artifact);
44
+ if (secretLabel)
45
+ clearProjectArtifactAfterRedirect(roots.writeRoot, key);
46
+ }
18
47
  return { dataDir, redirected: Boolean(secretLabel), secretLabel };
19
48
  }
@@ -48,5 +48,9 @@ export declare function locateArtifact(dataDirs: string[], key: string): Located
48
48
  * order and deduplicates by cache key so an entry present in an earlier root (project) shadows
49
49
  * the same key in a later root (global) — even when the callback filters it out. Composes
50
50
  * scanCacheDir so the file-format and read/parse rules stay defined in exactly one place.
51
+ *
52
+ * Pass `persistIndex: false` under read-only/plan mode so listing never creates `.search-index.json`.
51
53
  */
52
- export declare function scanCacheDirs<T>(dataDirs: string[], fn: (artifact: ResearchArtifact, filePath: string) => T | null): T[];
54
+ export declare function scanCacheDirs<T>(dataDirs: string[], fn: (artifact: ResearchArtifact, filePath: string) => T | null, options?: {
55
+ persistIndex?: boolean;
56
+ }): T[];