@taurgis/bonsai 1.0.3 → 1.0.5

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 (72) hide show
  1. package/README.md +38 -6
  2. package/agents/agents/salesforce-docs-researcher.agent.md +117 -0
  3. package/agents/hooks/README.md +62 -0
  4. package/agents/hooks/claude-code/hooks/bonsai-web-fetch.sh +22 -0
  5. package/agents/hooks/claude-code/settings.json +16 -0
  6. package/agents/hooks/codex/hooks/bonsai-user-prompt-context.sh +18 -0
  7. package/agents/hooks/codex/hooks/bonsai-web-fetch.sh +22 -0
  8. package/agents/hooks/codex/hooks.json +28 -0
  9. package/agents/hooks/cursor/hooks/bonsai-web-fetch.sh +21 -0
  10. package/agents/hooks/cursor/hooks.json +12 -0
  11. package/agents/hooks/github-copilot/bonsai-web-fetch.json +23 -0
  12. package/agents/hooks/github-copilot/bonsai-web-fetch.sh +19 -0
  13. package/agents/instructions/salesforce-research.instructions.md +57 -0
  14. package/agents/instructions/web-research.instructions.md +2 -1
  15. package/bin/cli.mjs +13 -3
  16. package/dist/base-command.d.ts +20 -0
  17. package/dist/base-command.js +27 -1
  18. package/dist/commands/config/get.js +2 -2
  19. package/dist/commands/config/list.js +2 -2
  20. package/dist/commands/config/set.js +2 -2
  21. package/dist/commands/config/unset.js +2 -2
  22. package/dist/commands/fetch.d.ts +5 -0
  23. package/dist/commands/fetch.js +98 -27
  24. package/dist/commands/import.d.ts +1 -0
  25. package/dist/commands/import.js +51 -11
  26. package/dist/commands/list.js +18 -3
  27. package/dist/commands/prune.js +22 -6
  28. package/dist/commands/search.js +31 -30
  29. package/dist/commands/status.js +10 -9
  30. package/dist/hooks/command-not-found/suggest.d.ts +9 -0
  31. package/dist/hooks/command-not-found/suggest.js +93 -0
  32. package/dist/lib/config/index.d.ts +8 -3
  33. package/dist/lib/config/index.js +19 -4
  34. package/dist/lib/config/resolve.d.ts +21 -1
  35. package/dist/lib/config/resolve.js +42 -1
  36. package/dist/lib/config/schema.d.ts +3 -0
  37. package/dist/lib/config/schema.js +11 -1
  38. package/dist/lib/research/artifact.d.ts +13 -2
  39. package/dist/lib/research/artifact.js +27 -7
  40. package/dist/lib/research/browser.d.ts +12 -0
  41. package/dist/lib/research/browser.js +40 -3
  42. package/dist/lib/research/compress.d.ts +19 -0
  43. package/dist/lib/research/compress.js +31 -0
  44. package/dist/lib/research/docs/capabilities.d.ts +1 -1
  45. package/dist/lib/research/docs/code-blocks.js +24 -0
  46. package/dist/lib/research/docs/discovery-index.d.ts +14 -0
  47. package/dist/lib/research/docs/discovery-index.js +112 -0
  48. package/dist/lib/research/docs/machine-readable.d.ts +2 -2
  49. package/dist/lib/research/docs/machine-readable.js +3 -7
  50. package/dist/lib/research/docs/remote-search-runner.js +61 -1
  51. package/dist/lib/research/docs/section-artifacts.d.ts +3 -2
  52. package/dist/lib/research/docs/section-artifacts.js +5 -5
  53. package/dist/lib/research/docs/sections.d.ts +1 -0
  54. package/dist/lib/research/docs/sections.js +19 -1
  55. package/dist/lib/research/freshness.d.ts +9 -1
  56. package/dist/lib/research/freshness.js +24 -2
  57. package/dist/lib/research/keywords.d.ts +24 -0
  58. package/dist/lib/research/keywords.js +247 -0
  59. package/dist/lib/research/revalidate.d.ts +3 -1
  60. package/dist/lib/research/revalidate.js +8 -4
  61. package/dist/lib/research/search-index.d.ts +18 -0
  62. package/dist/lib/research/search-index.js +109 -0
  63. package/dist/lib/research/secret-scan.js +4 -2
  64. package/dist/lib/research/storage.d.ts +1 -0
  65. package/dist/lib/research/storage.js +1 -1
  66. package/dist/lib/research/summarize.d.ts +17 -0
  67. package/dist/lib/research/summarize.js +400 -0
  68. package/dist/lib/research/url.js +5 -2
  69. package/dist/lib/text.d.ts +33 -0
  70. package/dist/lib/text.js +63 -0
  71. package/oclif.manifest.json +8 -8
  72. package/package.json +17 -3
@@ -1,11 +1,18 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { BaseCommand } from '../base-command.js';
3
- import { scanCacheDirs } from '../lib/research/storage.js';
3
+ import { loadSearchableArtifacts } from '../lib/research/search-index.js';
4
+ import { levenshtein, resultListHeading, truncationNotice, } from '../lib/text.js';
4
5
  import { loadStoreRoots } from '../lib/research/store-roots.js';
5
6
  import { evaluateFreshness } from '../lib/research/freshness.js';
6
7
  import { detectSite } from '../sites/index.js';
7
8
  import { fetchStaticHtml, fetchText, postJson } from '../lib/research/fetcher.js';
8
9
  import { runRemoteDocsSearch, } from '../lib/research/docs/remote-search-runner.js';
10
+ // Results are ranked, so the truncation word is "top"; --limit caps at 50.
11
+ const SEARCH_LABELS = {
12
+ noun: 'matching cached research',
13
+ order: 'top',
14
+ maxLimit: 50,
15
+ };
9
16
  const REMOTE_SEARCH_DEPS = {
10
17
  fetchStatic: async (url) => {
11
18
  const res = await fetchStaticHtml(url);
@@ -54,7 +61,7 @@ export default class ResearchSearch extends BaseCommand {
54
61
  description: 'Search a documentation site directly (e.g. help.salesforce.com) instead of the local cache. Requires a site module that implements search.',
55
62
  }),
56
63
  remote: Flags.string({
57
- description: 'Discover uncached docs via a site’s public search (Algolia DocSearch, MkDocs/Sphinx/Just-the-Docs index). Pass a docs page URL; falls back to local cache search if no connector applies.',
64
+ description: 'Discover uncached docs via a site’s public search or index (Algolia DocSearch, MkDocs/Sphinx/Just-the-Docs, llms.txt, sitemap.xml). Pass a docs page URL; falls back to local cache search if no connector applies.',
58
65
  }),
59
66
  };
60
67
  static stdoutIsPrimaryData = true;
@@ -150,21 +157,22 @@ export default class ResearchSearch extends BaseCommand {
150
157
  return snippet;
151
158
  }
152
159
  scanCacheDirForResults(readRoots, queryTerms, currentTime) {
153
- return scanCacheDirs(readRoots, (artifact, filePath) => {
160
+ const results = [];
161
+ for (const { artifact, filePath } of loadSearchableArtifacts(readRoots)) {
154
162
  if (artifact.metadata.status !== 'active')
155
- return null;
163
+ continue;
156
164
  const freshness = evaluateFreshness(artifact.metadata, currentTime, null);
157
165
  if (!this.matchesFilters(artifact.metadata, freshness, this.flags.topic, this.flags.tags, this.flags['artifact-type'], Boolean(this.flags['include-stale'])))
158
- return null;
166
+ continue;
159
167
  let score = this.calculateScore(artifact.metadata, artifact.summary, artifact.compressed, queryTerms, freshness);
160
168
  if (score <= 0)
161
- return null;
169
+ continue;
162
170
  // Section children are more precise than the whole page, so rank a section hit slightly
163
171
  // above its parent for the same query (T-22).
164
172
  if (artifact.metadata.artifact_type === 'section')
165
173
  score += 15;
166
174
  const snippetText = [artifact.summary, artifact.compressed].filter(Boolean).join('\n');
167
- return {
175
+ results.push({
168
176
  cacheKey: artifact.metadata.cache_key,
169
177
  path: filePath,
170
178
  artifactType: artifact.metadata.artifact_type,
@@ -177,13 +185,18 @@ export default class ResearchSearch extends BaseCommand {
177
185
  snippet: this.makeSnippet(snippetText, queryTerms),
178
186
  siteModuleId: artifact.metadata.site_module_id,
179
187
  score,
180
- };
181
- });
188
+ });
189
+ }
190
+ return results;
182
191
  }
183
- logSearchResults(finalResults) {
192
+ logSearchResults(finalResults, totalMatched) {
184
193
  if (this.jsonEnabled())
185
194
  return;
186
- this.log(`Found ${finalResults.length} matching cached research entries:\n`);
195
+ if (finalResults.length === 0) {
196
+ this.log('No matching cached research entries found.');
197
+ return;
198
+ }
199
+ this.log(`${resultListHeading(totalMatched, finalResults.length, SEARCH_LABELS)}\n`);
187
200
  finalResults.forEach((res, index) => {
188
201
  this.log(`${index + 1}. [${res.topic || 'No Topic'}] Score: ${res.score}`);
189
202
  this.log(` Cache Key: ${res.cacheKey}`);
@@ -252,7 +265,13 @@ export default class ResearchSearch extends BaseCommand {
252
265
  const results = this.scanCacheDirForResults(roots.readRoots, queryTerms, currentTime);
253
266
  results.sort((a, b) => b.score - a.score);
254
267
  const finalResults = results.slice(0, this.flags.limit);
255
- this.logSearchResults(finalResults);
268
+ // Under --json the human heading is suppressed, so surface truncation on stderr instead (warn
269
+ // always emits, even in --json) without touching the stdout envelope. Human mode already shows
270
+ // it in the heading, so only warn under --json.
271
+ const notice = truncationNotice(results.length, finalResults.length, SEARCH_LABELS);
272
+ if (notice && this.jsonEnabled())
273
+ this.warn(notice);
274
+ this.logSearchResults(finalResults, results.length);
256
275
  return finalResults;
257
276
  }
258
277
  }
@@ -303,24 +322,6 @@ function scoreSingleTerm(term, topic, tags, sourceUrl, sourceUrls, summ, comp) {
303
322
  }
304
323
  return termScore;
305
324
  }
306
- function levenshtein(s1, s2) {
307
- if (s1.length < s2.length)
308
- return levenshtein(s2, s1);
309
- if (s2.length === 0)
310
- return s1.length;
311
- let prevRow = Array.from({ length: s2.length + 1 }, (_, i) => i);
312
- for (let i = 0; i < s1.length; i++) {
313
- const currRow = [i + 1];
314
- for (let j = 0; j < s2.length; j++) {
315
- const deletions = prevRow[j + 1] + 1;
316
- const insertions = currRow[j] + 1;
317
- const substitutions = prevRow[j] + (s1[i] === s2[j] ? 0 : 1);
318
- currRow.push(Math.min(deletions, insertions, substitutions));
319
- }
320
- prevRow = currRow;
321
- }
322
- return prevRow[s2.length];
323
- }
324
325
  function isFuzzyMatch(term, target) {
325
326
  if (term.length < 4 || target.length < 4)
326
327
  return false;
@@ -1,7 +1,7 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { BaseCommand } from '../base-command.js';
3
3
  import { getArtifactPath } from '../lib/research/storage.js';
4
- import { evaluateFreshness, checkMaxAgeExpired } from '../lib/research/freshness.js';
4
+ import { evaluateFreshness, checkMaxAgeExpired, durationFlagError, } from '../lib/research/freshness.js';
5
5
  import { resolveResearchTarget } from '../lib/research/resolve-target.js';
6
6
  function resolveFreshness(cached, currentTime, ttl, maxAge) {
7
7
  const isMaxAgeExpired = maxAge ? checkMaxAgeExpired(cached, currentTime, maxAge) : false;
@@ -11,7 +11,7 @@ function resolveFreshness(cached, currentTime, ttl, maxAge) {
11
11
  }
12
12
  function describeCacheStatus(cached, currentTime, ttl, maxAge) {
13
13
  if (!cached) {
14
- return { status: 'miss', freshness: 'stale_expired', action: 'would_fetch' };
14
+ return { status: 'miss', freshness: 'none', action: 'would_fetch' };
15
15
  }
16
16
  const freshness = resolveFreshness(cached, currentTime, ttl, maxAge);
17
17
  return freshness === 'fresh'
@@ -46,6 +46,12 @@ export default class ResearchStatus extends BaseCommand {
46
46
  async run() {
47
47
  const { url } = this.args;
48
48
  const { ttl, tier, 'max-age': maxAge } = this.flags;
49
+ // Validate the duration flags up front so a malformed value reports the exact flag that is
50
+ // wrong, rather than later surfacing as a misattributed parse failure.
51
+ for (const msg of [durationFlagError('--ttl', ttl), durationFlagError('--max-age', maxAge)]) {
52
+ if (msg)
53
+ this.error(msg, { exit: 2 });
54
+ }
49
55
  let target;
50
56
  try {
51
57
  target = resolveResearchTarget({
@@ -61,13 +67,8 @@ export default class ResearchStatus extends BaseCommand {
61
67
  const { cacheKey, located, normalizedUrl, roots } = target;
62
68
  const cached = located?.artifact ?? null;
63
69
  const currentTime = new Date();
64
- let result;
65
- try {
66
- result = describeCacheStatus(cached, currentTime, ttl, maxAge);
67
- }
68
- catch (err) {
69
- this.error(`Invalid max-age: ${err.message}`, { exit: 2 });
70
- }
70
+ // Both duration flags were validated above, so freshness resolution cannot throw on a parse error.
71
+ const result = describeCacheStatus(cached, currentTime, ttl, maxAge);
71
72
  // On a hit, report where it actually lives; on a miss, where a fetch would write it.
72
73
  const artifactPath = located?.path ?? getArtifactPath(roots.writeRoot, cacheKey);
73
74
  if (!this.jsonEnabled()) {
@@ -0,0 +1,9 @@
1
+ import { type Hook } from '@oclif/core';
2
+ /**
3
+ * Typo-aware "command not found" handler. oclif fires this before printing its own terse error;
4
+ * throwing here (via `this.error`) replaces that default with a more helpful message. A correction
5
+ * is offered only when the nearest command is a close match, and it is never auto-run — the user
6
+ * re-runs the corrected command themselves, so they learn the right syntax (clig.dev guidance).
7
+ */
8
+ declare const hook: Hook<'command_not_found'>;
9
+ export default hook;
@@ -0,0 +1,93 @@
1
+ import { toConfiguredId } from '@oclif/core';
2
+ import { closestMatch } from '../../lib/text.js';
3
+ // Nearest command must be within this edit distance to be offered as a correction. Bonsai's command
4
+ // ids are short, so 3 catches realistic typos ("statuss", "improt") without suggesting an unrelated
5
+ // command for input that resembles nothing.
6
+ const MAX_SUGGESTION_DISTANCE = 3;
7
+ /**
8
+ * The nearest visible command to a typo, plus how many leading segments of the attempted id name
9
+ * the command. With `topicSeparator: ' '`, oclif folds every leading token of an unknown command
10
+ * into one colon-delimited id, so `bonsai serch hello` arrives as `serch:hello`. Matching that whole
11
+ * string never finds a short command, so walk prefixes longest→shortest and suggest off the closest:
12
+ * `serch:hello` resolves to nothing, but its prefix `serch` resolves to `search`. The matched prefix
13
+ * length doubles as the command-segment count, so a folded-in positional arg is dropped from display.
14
+ */
15
+ function findSuggestion(segments, visibleIds) {
16
+ for (let n = segments.length; n >= 1; n--) {
17
+ const suggestion = closestMatch(segments.slice(0, n).join(':'), visibleIds, MAX_SUGGESTION_DISTANCE);
18
+ if (suggestion)
19
+ return { suggestion, commandSegments: n };
20
+ }
21
+ return { suggestion: null, commandSegments: 0 };
22
+ }
23
+ /**
24
+ * How many leading segments name the command when no suggestion exists: the known-topic chain plus
25
+ * the first unknown segment. Keeps `config frobnicate` from rendering as the misleading bare `config`
26
+ * (a real topic) while still dropping a positional arg from `frobnicate banana`.
27
+ */
28
+ function topicChainLength(segments, config) {
29
+ let i = 0;
30
+ while (i < segments.length - 1 && config.findTopic(segments.slice(0, i + 1).join(':')))
31
+ i++;
32
+ return i + 1;
33
+ }
34
+ // Dot-separated, non-empty labels: a real domain (`docs.nestjs.com`) or IPv4 (`192.168.1.1`). The
35
+ // URL parser also accepts a leading-dot filename (`.env`) or a relative path (`./x`) as a "host",
36
+ // whose suggestion would be a nonsense `https://.env`; requiring well-formed labels rejects those.
37
+ // Anchored with no overlapping quantifiers, so it is ReDoS-safe on the (already length-bounded) host.
38
+ const DOMAIN_OR_IPV4 = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
39
+ /**
40
+ * The input as-typed when it looks like a URL missing its scheme (`example.com`,
41
+ * `docs.nestjs.com/guide`), else null. `bonsai <url>` is the headline command, but bin/cli.mjs only
42
+ * routes args carrying a `://` scheme to the fetch shorthand — so a scheme-less URL falls through to
43
+ * `command_not_found`. A domain-shaped host separates a forgotten-scheme URL from an ordinary command
44
+ * typo: `statuss` has no dot and never matches, while `config:frobnicate` fails to parse (the second
45
+ * segment becomes a non-numeric port), so neither is misread as a URL.
46
+ */
47
+ function bareUrlInput(id) {
48
+ if (id.includes('://'))
49
+ return null;
50
+ let hostname;
51
+ try {
52
+ hostname = new URL(`https://${id}`).hostname;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ return DOMAIN_OR_IPV4.test(hostname) ? id : null;
58
+ }
59
+ /**
60
+ * Typo-aware "command not found" handler. oclif fires this before printing its own terse error;
61
+ * throwing here (via `this.error`) replaces that default with a more helpful message. A correction
62
+ * is offered only when the nearest command is a close match, and it is never auto-run — the user
63
+ * re-runs the corrected command themselves, so they learn the right syntax (clig.dev guidance).
64
+ */
65
+ const hook = async function (opts) {
66
+ const hiddenIds = new Set(opts.config.commands.filter((c) => c.hidden).map((c) => c.id));
67
+ const visibleIds = opts.config.commandIDs.filter((id) => !hiddenIds.has(id));
68
+ // A scheme-less URL is the most common "not a command" mistake for this CLI, so steer the user to
69
+ // the `bonsai <url>` shorthand with a scheme before falling back to nearest-command matching (which
70
+ // never finds a command for a hostname). The correction is shown, never auto-run (clig.dev).
71
+ const bareUrl = bareUrlInput(opts.id);
72
+ if (bareUrl) {
73
+ this.error([
74
+ `${bareUrl} is not a ${opts.config.bin} command.`,
75
+ `Run ${opts.config.bin} help for a list of available commands.`,
76
+ `Did you mean ${opts.config.bin} https://${bareUrl}? URLs need an http:// or https:// scheme.`,
77
+ ].join('\n'), { exit: 2 });
78
+ }
79
+ const segments = opts.id.split(':');
80
+ const { suggestion, commandSegments } = findSuggestion(segments, visibleIds);
81
+ // Show only the segments that name the command, never the positional args oclif folded into the id
82
+ // (which would otherwise glue the arg on and turn `://` into ` //`).
83
+ const displaySegments = commandSegments || topicChainLength(segments, opts.config);
84
+ const attempted = toConfiguredId(segments.slice(0, displaySegments).join(':'), opts.config);
85
+ // Order so the most actionable line lands last, where the eye rests (clig.dev): the help pointer
86
+ // is the floor, and a concrete "Did you mean …?" correction — when we have one — goes below it.
87
+ const lines = [`${attempted} is not a ${opts.config.bin} command.`];
88
+ lines.push(`Run ${opts.config.bin} help for a list of available commands.`);
89
+ if (suggestion)
90
+ lines.push(`Did you mean ${toConfiguredId(suggestion, opts.config)}?`);
91
+ this.error(lines.join('\n'), { exit: 2 });
92
+ };
93
+ export default hook;
@@ -1,12 +1,17 @@
1
- export { type ConfigValues, type ResolvedConfig, type ConfigKey, type StorageMode, type KeyMeta, SCHEMA_VERSION, STORAGE_MODES, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
1
+ export { type ConfigValues, type ResolvedConfig, type ConfigKey, type StorageMode, type SummaryLevel, type KeyMeta, SCHEMA_VERSION, STORAGE_MODES, SUMMARY_LEVELS, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
2
2
  export { readUserConfig, readProjectConfig, writeUserConfig, writeProjectConfig, USER_CONFIG_FILENAME, PROJECT_CONFIG_FILENAME, } from './io.js';
3
- export { resolveStorageMode, parseEnvStorage, STORAGE_ENV_VAR, type ResolveStorageInput, } from './resolve.js';
4
- import type { ConfigValues, ResolvedConfig, StorageMode } from './schema.js';
3
+ export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, invalidEnvOverrideWarnings, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, type ResolveStorageInput, type ResolveSummaryInput, } from './resolve.js';
4
+ import type { ConfigValues, ResolvedConfig, StorageMode, SummaryLevel } from './schema.js';
5
5
  /**
6
6
  * Read both config files once and resolve the effective storage mode.
7
7
  * `flag` (a per-invocation `--storage` override) wins over everything else.
8
8
  */
9
9
  export declare function loadStorageMode(configDir: string | undefined, cwd: string, flag?: StorageMode, env?: Record<string, string | undefined>): StorageMode;
10
+ /**
11
+ * Read both config files once and resolve the effective summary level.
12
+ * `flag` (a per-invocation override) wins over everything else.
13
+ */
14
+ export declare function loadSummaryLevel(configDir: string | undefined, cwd: string, flag?: SummaryLevel, env?: Record<string, string | undefined>): SummaryLevel;
10
15
  /** The resolved value of every config key, factoring file + env layers. Extend as keys are added. */
11
16
  export declare function effectiveConfig(configDir: string | undefined, cwd: string): ResolvedConfig;
12
17
  export type ConfigScope = 'global' | 'local' | 'effective';
@@ -1,8 +1,8 @@
1
- export { SCHEMA_VERSION, STORAGE_MODES, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
1
+ export { SCHEMA_VERSION, STORAGE_MODES, SUMMARY_LEVELS, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
2
2
  export { readUserConfig, readProjectConfig, writeUserConfig, writeProjectConfig, USER_CONFIG_FILENAME, PROJECT_CONFIG_FILENAME, } from './io.js';
3
- export { resolveStorageMode, parseEnvStorage, STORAGE_ENV_VAR, } from './resolve.js';
3
+ export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, invalidEnvOverrideWarnings, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, } from './resolve.js';
4
4
  import { readUserConfig, readProjectConfig } from './io.js';
5
- import { resolveStorageMode } from './resolve.js';
5
+ import { resolveStorageMode, resolveSummaryLevel } from './resolve.js';
6
6
  /**
7
7
  * Read both config files once and resolve the effective storage mode.
8
8
  * `flag` (a per-invocation `--storage` override) wins over everything else.
@@ -15,9 +15,24 @@ export function loadStorageMode(configDir, cwd, flag, env = process.env) {
15
15
  userConfig: readUserConfig(configDir),
16
16
  });
17
17
  }
18
+ /**
19
+ * Read both config files once and resolve the effective summary level.
20
+ * `flag` (a per-invocation override) wins over everything else.
21
+ */
22
+ export function loadSummaryLevel(configDir, cwd, flag, env = process.env) {
23
+ return resolveSummaryLevel({
24
+ flag,
25
+ env,
26
+ projectConfig: readProjectConfig(cwd),
27
+ userConfig: readUserConfig(configDir),
28
+ });
29
+ }
18
30
  /** The resolved value of every config key, factoring file + env layers. Extend as keys are added. */
19
31
  export function effectiveConfig(configDir, cwd) {
20
- return { storage: loadStorageMode(configDir, cwd) };
32
+ return {
33
+ storage: loadStorageMode(configDir, cwd),
34
+ summary: loadSummaryLevel(configDir, cwd),
35
+ };
21
36
  }
22
37
  /**
23
38
  * The config values for a requested scope: the user file (`global`), the project file (`local`),
@@ -1,7 +1,16 @@
1
- import type { ConfigValues, StorageMode } from './schema.js';
1
+ import type { ConfigValues, StorageMode, SummaryLevel } from './schema.js';
2
2
  /** Scoped env override, following oclif's `<BIN>_*` env-var convention for this CLI. */
3
3
  export declare const STORAGE_ENV_VAR = "BONSAI_STORAGE";
4
+ export declare const SUMMARY_ENV_VAR = "BONSAI_SUMMARY";
5
+ /**
6
+ * Warnings for Bonsai env overrides that are set but hold an unrecognized value. Such a value is
7
+ * dropped during resolution (falling back to config/default), which would otherwise silently mask
8
+ * a typo like `BONSAI_SUMMARY=agressive`. Callers surface these on stderr so the misconfiguration
9
+ * is visible without breaking the run. Returns one message per offending variable, or empty.
10
+ */
11
+ export declare function invalidEnvOverrideWarnings(env: Record<string, string | undefined>): string[];
4
12
  export declare function parseEnvStorage(env: Record<string, string | undefined>): StorageMode | undefined;
13
+ export declare function parseEnvSummary(env: Record<string, string | undefined>): SummaryLevel | undefined;
5
14
  export interface ResolveStorageInput {
6
15
  flag?: StorageMode;
7
16
  env: Record<string, string | undefined>;
@@ -13,3 +22,14 @@ export interface ResolveStorageInput {
13
22
  * flag > env > project file > user file > built-in default.
14
23
  */
15
24
  export declare function resolveStorageMode(input: ResolveStorageInput): StorageMode;
25
+ export interface ResolveSummaryInput {
26
+ flag?: SummaryLevel;
27
+ env: Record<string, string | undefined>;
28
+ projectConfig: Partial<ConfigValues>;
29
+ userConfig: Partial<ConfigValues>;
30
+ }
31
+ /**
32
+ * Resolve the effective summary level in precedence order:
33
+ * flag > env > project file > user file > built-in default.
34
+ */
35
+ export declare function resolveSummaryLevel(input: ResolveSummaryInput): SummaryLevel;
@@ -1,12 +1,42 @@
1
- import { BUILT_IN_DEFAULTS, STORAGE_MODES } from './schema.js';
1
+ import { BUILT_IN_DEFAULTS, STORAGE_MODES, SUMMARY_LEVELS } from './schema.js';
2
2
  /** Scoped env override, following oclif's `<BIN>_*` env-var convention for this CLI. */
3
3
  export const STORAGE_ENV_VAR = 'BONSAI_STORAGE';
4
+ export const SUMMARY_ENV_VAR = 'BONSAI_SUMMARY';
5
+ /**
6
+ * Warnings for Bonsai env overrides that are set but hold an unrecognized value. Such a value is
7
+ * dropped during resolution (falling back to config/default), which would otherwise silently mask
8
+ * a typo like `BONSAI_SUMMARY=agressive`. Callers surface these on stderr so the misconfiguration
9
+ * is visible without breaking the run. Returns one message per offending variable, or empty.
10
+ */
11
+ export function invalidEnvOverrideWarnings(env) {
12
+ const checks = [
13
+ [STORAGE_ENV_VAR, STORAGE_MODES],
14
+ [SUMMARY_ENV_VAR, SUMMARY_LEVELS],
15
+ ];
16
+ const warnings = [];
17
+ for (const [name, valid] of checks) {
18
+ // After trim, an empty/whitespace-only value is falsy and treated as "unset" (no warning),
19
+ // matching parseEnv*; matching is case-sensitive, so a wrong-case value is invalid and warns.
20
+ const raw = env[name]?.trim();
21
+ if (raw && !valid.includes(raw)) {
22
+ warnings.push(`Ignoring ${name}="${raw}": not one of ${valid.join(', ')}. ` +
23
+ `Using the configured value or default instead.`);
24
+ }
25
+ }
26
+ return warnings;
27
+ }
4
28
  export function parseEnvStorage(env) {
5
29
  const raw = env[STORAGE_ENV_VAR]?.trim();
6
30
  return STORAGE_MODES.includes(raw ?? '')
7
31
  ? raw
8
32
  : undefined;
9
33
  }
34
+ export function parseEnvSummary(env) {
35
+ const raw = env[SUMMARY_ENV_VAR]?.trim();
36
+ return SUMMARY_LEVELS.includes(raw ?? '')
37
+ ? raw
38
+ : undefined;
39
+ }
10
40
  /**
11
41
  * Resolve the effective storage mode in precedence order:
12
42
  * flag > env > project file > user file > built-in default.
@@ -18,3 +48,14 @@ export function resolveStorageMode(input) {
18
48
  input.userConfig.storage ??
19
49
  BUILT_IN_DEFAULTS.storage);
20
50
  }
51
+ /**
52
+ * Resolve the effective summary level in precedence order:
53
+ * flag > env > project file > user file > built-in default.
54
+ */
55
+ export function resolveSummaryLevel(input) {
56
+ return (input.flag ??
57
+ parseEnvSummary(input.env) ??
58
+ input.projectConfig.summary ??
59
+ input.userConfig.summary ??
60
+ BUILT_IN_DEFAULTS.summary);
61
+ }
@@ -4,13 +4,16 @@
4
4
  * where the research cache lives.
5
5
  */
6
6
  export type StorageMode = 'global' | 'project';
7
+ export type SummaryLevel = 'conservative' | 'balanced' | 'aggressive';
7
8
  export interface ConfigValues {
8
9
  storage?: StorageMode;
10
+ summary?: SummaryLevel;
9
11
  }
10
12
  export type ResolvedConfig = Required<ConfigValues>;
11
13
  export type ConfigKey = keyof ConfigValues;
12
14
  export declare const SCHEMA_VERSION = 1;
13
15
  export declare const STORAGE_MODES: readonly StorageMode[];
16
+ export declare const SUMMARY_LEVELS: readonly SummaryLevel[];
14
17
  export declare const ALL_KEYS: readonly ConfigKey[];
15
18
  export declare const BUILT_IN_DEFAULTS: ResolvedConfig;
16
19
  export interface KeyMeta {
@@ -5,10 +5,13 @@
5
5
  */
6
6
  export const SCHEMA_VERSION = 1;
7
7
  export const STORAGE_MODES = ['global', 'project'];
8
- export const ALL_KEYS = ['storage'];
8
+ export const SUMMARY_LEVELS = ['conservative', 'balanced', 'aggressive'];
9
+ export const ALL_KEYS = ['storage', 'summary'];
9
10
  export const BUILT_IN_DEFAULTS = {
10
11
  // Default to global so existing installs keep using the OCLIF data dir unchanged.
11
12
  storage: 'global',
13
+ // Default to the gentlest summarization so cached content keeps the most detail out of the box.
14
+ summary: 'conservative',
12
15
  };
13
16
  export const KEY_META = {
14
17
  storage: {
@@ -18,6 +21,13 @@ export const KEY_META = {
18
21
  isValid: (v) => STORAGE_MODES.includes(String(v)),
19
22
  format: (v) => String(v),
20
23
  },
24
+ summary: {
25
+ description: `How aggressively the compressed format is summarized (${SUMMARY_LEVELS.join('|')}).`,
26
+ values: SUMMARY_LEVELS,
27
+ parseValue: (raw) => raw.trim(),
28
+ isValid: (v) => SUMMARY_LEVELS.includes(String(v)),
29
+ format: (v) => String(v),
30
+ },
21
31
  };
22
32
  export function isKnownKey(key) {
23
33
  return ALL_KEYS.includes(key);
@@ -12,9 +12,20 @@ export declare function serializeArtifact(artifact: ResearchArtifact): string;
12
12
  */
13
13
  export declare function parseMetadata(lines: string[]): ResearchArtifactMetadata;
14
14
  /**
15
- * Extracts a named markdown section (e.g. ## Summary) from body text.
15
+ * Extracts a named markdown section (e.g. ## Summary) from body text. The section ends at the next
16
+ * known section header (`nextSection`), matched as the exact serialized separator `\n\n## <name>` —
17
+ * NOT at the next `## ` heading, because section bodies legitimately contain their own `##` headings
18
+ * (a compressed/detailed page often *starts* with one). Bounding on a generic heading would truncate
19
+ * such content to nothing. Omit `nextSection` for the final section to read to end-of-body.
16
20
  */
17
- export declare function extractSection(body: string, sectionName: string): string;
21
+ export declare function extractSection(body: string, sectionName: string, nextSection?: string): string;
22
+ /**
23
+ * Parses an artifact for search/listing without materializing the (often large) Detailed and
24
+ * Provenance bodies. Truncates at the Detailed section boundary before parsing so megabyte-scale
25
+ * detailed content is never split or sliced. `detailed` and `provenance` come back empty — callers
26
+ * that need them must use {@link parseArtifact}. Shares all parsing rules with parseArtifact.
27
+ */
28
+ export declare function parseArtifactShallow(content: string): ResearchArtifact;
18
29
  /**
19
30
  * Parses full Markdown content into a structured ResearchArtifact.
20
31
  */
@@ -181,18 +181,38 @@ export function parseMetadata(lines) {
181
181
  return state.meta;
182
182
  }
183
183
  /**
184
- * Extracts a named markdown section (e.g. ## Summary) from body text.
184
+ * Extracts a named markdown section (e.g. ## Summary) from body text. The section ends at the next
185
+ * known section header (`nextSection`), matched as the exact serialized separator `\n\n## <name>` —
186
+ * NOT at the next `## ` heading, because section bodies legitimately contain their own `##` headings
187
+ * (a compressed/detailed page often *starts* with one). Bounding on a generic heading would truncate
188
+ * such content to nothing. Omit `nextSection` for the final section to read to end-of-body.
185
189
  */
186
- export function extractSection(body, sectionName) {
190
+ export function extractSection(body, sectionName, nextSection) {
187
191
  const header = `## ${sectionName}`;
188
192
  const idx = body.indexOf(header);
189
193
  if (idx === -1)
190
194
  return '';
191
195
  const start = idx + header.length;
192
- const nextHeadingIdx = body.indexOf('\n## ', start);
193
- const rawText = nextHeadingIdx === -1 ? body.slice(start) : body.slice(start, nextHeadingIdx);
196
+ const endIdx = nextSection ? body.indexOf(`\n\n## ${nextSection}`, start) : -1;
197
+ const rawText = endIdx === -1 ? body.slice(start) : body.slice(start, endIdx);
194
198
  return rawText.trim();
195
199
  }
200
+ /**
201
+ * Parses an artifact for search/listing without materializing the (often large) Detailed and
202
+ * Provenance bodies. Truncates at the Detailed section boundary before parsing so megabyte-scale
203
+ * detailed content is never split or sliced. `detailed` and `provenance` come back empty — callers
204
+ * that need them must use {@link parseArtifact}. Shares all parsing rules with parseArtifact.
205
+ */
206
+ export function parseArtifactShallow(content) {
207
+ // Only look for the Detailed boundary in the body (after the closing frontmatter fence) and match
208
+ // the exact serialized separator `\n\n## Detailed`, so neither a frontmatter value nor an earlier
209
+ // body heading named "Detailed" can truncate the content prematurely.
210
+ const bodyStart = content.indexOf('\n---', 3);
211
+ const searchFrom = bodyStart === -1 ? 0 : bodyStart + 4;
212
+ const detailedIdx = content.indexOf('\n\n## Detailed', searchFrom);
213
+ const sliced = detailedIdx === -1 ? content : content.slice(0, detailedIdx);
214
+ return parseArtifact(sliced);
215
+ }
196
216
  /**
197
217
  * Parses full Markdown content into a structured ResearchArtifact.
198
218
  */
@@ -209,9 +229,9 @@ export function parseArtifact(content) {
209
229
  const bodyLines = parts.slice(closingIdx + 1);
210
230
  const bodyText = bodyLines.join('\n');
211
231
  const metadata = parseMetadata(frontmatterLines);
212
- const summary = extractSection(bodyText, 'Summary');
213
- const compressed = extractSection(bodyText, 'Compressed');
214
- const detailed = extractSection(bodyText, 'Detailed');
232
+ const summary = extractSection(bodyText, 'Summary', 'Compressed');
233
+ const compressed = extractSection(bodyText, 'Compressed', 'Detailed');
234
+ const detailed = extractSection(bodyText, 'Detailed', 'Provenance');
215
235
  const provenance = extractSection(bodyText, 'Provenance');
216
236
  return { metadata, summary, compressed, detailed, provenance };
217
237
  }
@@ -39,6 +39,18 @@ export interface CdpPage {
39
39
  sessionId: string;
40
40
  close: () => Promise<void>;
41
41
  }
42
+ /** Status line of the main-frame document response, captured from Network.responseReceived. */
43
+ export interface MainDocumentResponse {
44
+ status: number;
45
+ statusText: string;
46
+ }
47
+ /**
48
+ * Rejects a rendered page whose main document returned an HTTP error, mirroring the static
49
+ * fetcher's `assertOk`. Without this, `--rendered` on a 404/500 would capture the server's (or
50
+ * the browser's) error page as if it were real content and cache it. A missing response (no
51
+ * Document event seen — e.g. data: or about: targets) is left to the caller's content checks.
52
+ */
53
+ export declare function assertRenderedHttpOk(mainDoc: MainDocumentResponse | undefined): void;
42
54
  /**
43
55
  * Spawns a headless Chrome, attaches a flat CDP session, and enables the Page, Network,
44
56
  * and Runtime domains. The caller drives navigation and must call close() when finished.
@@ -90,7 +90,8 @@ export function findChromePath() {
90
90
  }
91
91
  }
92
92
  catch { }
93
- throw new Error('Chrome executable not found. Please set CHROME_PATH environment variable.');
93
+ throw new Error('No Chrome or Chromium browser found for browser-based extraction. Install Chrome/Chromium or ' +
94
+ 'set CHROME_PATH to the browser executable. Static (non-rendered) fetching does not require a browser.');
94
95
  }
95
96
  async function spawnChrome(chromePath) {
96
97
  const chromeProcess = spawn(chromePath, [
@@ -220,6 +221,17 @@ export const BLOCKED_ASSET_URLS = [
220
221
  '*.ico',
221
222
  ];
222
223
  const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
224
+ /**
225
+ * Rejects a rendered page whose main document returned an HTTP error, mirroring the static
226
+ * fetcher's `assertOk`. Without this, `--rendered` on a 404/500 would capture the server's (or
227
+ * the browser's) error page as if it were real content and cache it. A missing response (no
228
+ * Document event seen — e.g. data: or about: targets) is left to the caller's content checks.
229
+ */
230
+ export function assertRenderedHttpOk(mainDoc) {
231
+ if (mainDoc && mainDoc.status >= 400) {
232
+ throw new Error(`Fetch failed with status ${mainDoc.status} ${mainDoc.statusText}`.trimEnd());
233
+ }
234
+ }
223
235
  /**
224
236
  * Spawns a headless Chrome, attaches a flat CDP session, and enables the Page, Network,
225
237
  * and Runtime domains. The caller drives navigation and must call close() when finished.
@@ -332,8 +344,33 @@ export async function fetchRenderedHtml(url, options = {}) {
332
344
  const page = await openCdpPage();
333
345
  try {
334
346
  await page.client.send('Network.setBlockedURLs', { urls: BLOCKED_ASSET_URLS }, page.sessionId);
335
- await page.client.send('Page.navigate', { url: currentUrl }, page.sessionId);
347
+ // Record the status of each frame's first Document response so we can reject HTTP errors after
348
+ // the page settles. Keyed by frameId; the navigated top frame is matched via the navigate result.
349
+ const documentResponses = new Map();
350
+ page.client.on(`${page.sessionId}:Network.responseReceived`, (params) => {
351
+ const { frameId } = params;
352
+ const status = params.response?.status;
353
+ // Only record real numeric statuses: a Document event without a usable status would
354
+ // otherwise store NaN, which silently slips past the `>= 400` guard and the `?? 200`
355
+ // fallback alike, leaking a NaN into BrowserFetchResult.status (declared `number`).
356
+ if (params.type !== 'Document' || !frameId || typeof status !== 'number')
357
+ return;
358
+ if (documentResponses.has(frameId))
359
+ return;
360
+ documentResponses.set(frameId, {
361
+ status,
362
+ statusText: params.response?.statusText ?? '',
363
+ });
364
+ });
365
+ const nav = await page.client.send('Page.navigate', { url: currentUrl }, page.sessionId);
366
+ // A network-level failure (DNS, refused/closed connection, TLS) never fires a load event, so
367
+ // fail fast here instead of waiting out the full navigation timeout on a dead page.
368
+ if (nav.errorText) {
369
+ throw new Error(`Navigation failed: ${nav.errorText}`);
370
+ }
336
371
  await waitForLoad(page.client, page.sessionId, timeout, options.settleMs ?? 1000);
372
+ const mainDoc = nav.frameId ? documentResponses.get(nav.frameId) : undefined;
373
+ assertRenderedHttpOk(mainDoc);
337
374
  const evalResult = await page.client.send('Runtime.evaluate', { expression: 'document.documentElement.outerHTML', returnByValue: true }, page.sessionId);
338
375
  const html = evalResult.result?.value;
339
376
  if (typeof html !== 'string') {
@@ -344,7 +381,7 @@ export async function fetchRenderedHtml(url, options = {}) {
344
381
  throw new Error(`Response body size limit exceeded. Rendered HTML is ${byteLength} bytes.`);
345
382
  }
346
383
  return {
347
- status: 200,
384
+ status: mainDoc?.status ?? 200,
348
385
  contentType: 'text/html',
349
386
  etag: null,
350
387
  lastModified: null,