@taurgis/bonsai 4.0.0 → 4.1.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 (61) hide show
  1. package/README.md +24 -1
  2. package/agents/agents/official-docs-researcher.agent.md +102 -0
  3. package/agents/agents/salesforce-docs-researcher.agent.md +16 -14
  4. package/agents/hooks/README.md +18 -2
  5. package/agents/hooks/claude-code/hooks/bonsai-web-fetch.sh +1 -1
  6. package/agents/hooks/codex/hooks/bonsai-user-prompt-context.sh +1 -1
  7. package/agents/hooks/codex/hooks/bonsai-web-fetch.sh +1 -1
  8. package/agents/hooks/cursor/hooks/bonsai-web-fetch.sh +1 -1
  9. package/agents/hooks/github-copilot/bonsai-web-fetch.sh +1 -1
  10. package/agents/instructions/salesforce-research.instructions.md +5 -4
  11. package/agents/instructions/web-research.instructions.md +45 -0
  12. package/agents/skills/web-research/SKILL.md +139 -0
  13. package/dist/base-command.d.ts +5 -0
  14. package/dist/base-command.js +9 -0
  15. package/dist/commands/config/base.d.ts +2 -7
  16. package/dist/commands/config/base.js +4 -18
  17. package/dist/commands/context.d.ts +20 -0
  18. package/dist/commands/context.js +65 -0
  19. package/dist/commands/list.d.ts +12 -3
  20. package/dist/commands/list.js +61 -100
  21. package/dist/commands/search.d.ts +43 -0
  22. package/dist/commands/search.js +233 -0
  23. package/dist/commands/setup.d.ts +27 -0
  24. package/dist/commands/setup.js +148 -0
  25. package/dist/commands.d.ts +6 -0
  26. package/dist/commands.js +6 -0
  27. package/dist/lib/argv.js +4 -1
  28. package/dist/lib/cli-presentation.d.ts +19 -0
  29. package/dist/lib/cli-presentation.js +27 -0
  30. package/dist/lib/cli-result-types.d.ts +59 -0
  31. package/dist/lib/color.d.ts +2 -0
  32. package/dist/lib/color.js +6 -0
  33. package/dist/lib/common-metadata-filter-flags.d.ts +14 -0
  34. package/dist/lib/common-metadata-filter-flags.js +37 -0
  35. package/dist/lib/next-command.d.ts +15 -0
  36. package/dist/lib/next-command.js +40 -0
  37. package/dist/lib/research/context-dashboard.d.ts +19 -0
  38. package/dist/lib/research/context-dashboard.js +43 -0
  39. package/dist/lib/research/fetch-command-service.js +10 -0
  40. package/dist/lib/research/fetch-result.d.ts +1 -0
  41. package/dist/lib/research/fetch-result.js +4 -0
  42. package/dist/lib/research/list-row.d.ts +15 -0
  43. package/dist/lib/research/list-row.js +31 -0
  44. package/dist/lib/research/metadata-filters.d.ts +22 -1
  45. package/dist/lib/research/metadata-filters.js +34 -0
  46. package/dist/lib/research/schema.d.ts +2 -0
  47. package/dist/lib/research/schema.js +4 -0
  48. package/dist/lib/research/search-match.d.ts +39 -0
  49. package/dist/lib/research/search-match.js +134 -0
  50. package/dist/lib/scope-flags.d.ts +19 -0
  51. package/dist/lib/scope-flags.js +27 -0
  52. package/dist/lib/setup/agents.d.ts +16 -0
  53. package/dist/lib/setup/agents.js +20 -0
  54. package/dist/lib/setup/bin-command.d.ts +14 -0
  55. package/dist/lib/setup/bin-command.js +42 -0
  56. package/dist/lib/setup/hook-file-io.d.ts +37 -0
  57. package/dist/lib/setup/hook-file-io.js +80 -0
  58. package/dist/lib/text.d.ts +6 -0
  59. package/dist/lib/text.js +11 -0
  60. package/oclif.manifest.json +288 -3
  61. package/package.json +1 -1
@@ -0,0 +1,65 @@
1
+ import { BaseCommand } from '../base-command.js';
2
+ import { loadStoreRoots } from '../lib/research/store-roots.js';
3
+ import { buildContextDashboard } from '../lib/research/context-dashboard.js';
4
+ import { NO_TOPIC_LABEL, formatTip, pluralize, sanitizeForTerminal } from '../lib/text.js';
5
+ import { colors, FRESHNESS_COLOR } from '../lib/color.js';
6
+ /**
7
+ * Compact, directory-scoped cache summary (AXI principle 7: ambient session-start context).
8
+ * Plain-text human output is deliberately short — it's meant to be piped verbatim into a
9
+ * SessionStart hook's `additionalContext` by `bonsai setup`, so every line here is also a
10
+ * reasonable thing for an agent to read unprompted at the start of a session.
11
+ */
12
+ export default class Context extends BaseCommand {
13
+ static id = 'context';
14
+ static summary = 'Show a compact cache dashboard for ambient session context';
15
+ static description = 'Print a short summary of the current research cache: total entries, a freshness ' +
16
+ 'breakdown, and the most recently touched pages. This is what `bonsai setup` wires into a ' +
17
+ "SessionStart hook, so a session starts with the cache's state already visible.";
18
+ static examples = [
19
+ {
20
+ description: 'preview what a SessionStart hook would show',
21
+ command: '<%= config.bin %> context',
22
+ },
23
+ {
24
+ description: 'get the dashboard as JSON',
25
+ command: '<%= config.bin %> context --json',
26
+ },
27
+ ];
28
+ static stdoutIsPrimaryData = true;
29
+ logDashboard(dashboard) {
30
+ if (this.jsonEnabled())
31
+ return;
32
+ if (dashboard.total === 0) {
33
+ this.log(`${this.config.bin} cache: 0 entries found.`);
34
+ this.log(formatTip(`populate the cache: ${colors.cyan(`${this.config.bin} <url>`)}`));
35
+ return;
36
+ }
37
+ const { fresh, stale_grace: staleGrace, stale_expired: staleExpired } = dashboard.byFreshness;
38
+ this.log(`${this.config.bin} cache: ${dashboard.total} ${pluralize(dashboard.total, 'entry', 'entries')} ` +
39
+ `(${fresh} fresh, ${staleGrace} stale_grace, ${staleExpired} stale_expired)`);
40
+ for (const entry of dashboard.entries) {
41
+ const topicStr = entry.topic
42
+ ? colors.cyan(sanitizeForTerminal(entry.topic))
43
+ : colors.gray(NO_TOPIC_LABEL);
44
+ const freshnessStr = FRESHNESS_COLOR[entry.freshness](entry.freshness);
45
+ const url = entry.sourceUrls[0] ?? '';
46
+ this.log(`- [${topicStr}] ${freshnessStr} — ${colors.gray(url)}`);
47
+ }
48
+ if (dashboard.total > dashboard.shown) {
49
+ this.log(formatTip(`see all ${dashboard.total} entries: ${colors.cyan(`${this.config.bin} list`)}`));
50
+ }
51
+ this.log(formatTip(`research a new page: ${colors.cyan(`${this.config.bin} <url>`)}`));
52
+ }
53
+ async run() {
54
+ const roots = loadStoreRoots({
55
+ configDir: this.config.configDir,
56
+ cwd: process.cwd(),
57
+ dataDir: this.config.dataDir,
58
+ });
59
+ const dashboard = buildContextDashboard(roots.readRoots, new Date(), {
60
+ persistIndex: !this.readOnly,
61
+ });
62
+ this.logDashboard(dashboard);
63
+ return dashboard;
64
+ }
65
+ }
@@ -16,19 +16,28 @@ export default class ResearchList extends BaseCommand<typeof ResearchList> {
16
16
  command: string;
17
17
  }[];
18
18
  static flags: {
19
+ limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
20
+ full: import("@oclif/core/interfaces").BooleanFlag<boolean>;
21
+ identity: import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
22
  topic: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
20
23
  tags: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
21
24
  url: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
22
25
  freshness: import("@oclif/core/interfaces").OptionFlag<"fresh" | "stale_grace" | "stale_expired" | undefined, import("@oclif/core/interfaces").CustomOptions>;
23
26
  'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | "index" | undefined, import("@oclif/core/interfaces").CustomOptions>;
24
27
  'capture-method': import("@oclif/core/interfaces").OptionFlag<"static_fetch" | "browser_fallback" | "agent_supplied" | "route_markdown" | "github_source" | undefined, import("@oclif/core/interfaces").CustomOptions>;
25
- limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
26
- full: import("@oclif/core/interfaces").BooleanFlag<boolean>;
27
28
  };
28
29
  static stdoutIsPrimaryData: boolean;
30
+ /** The shared filter flags as `matchesCommonMetadataFilters`/`hasActiveMetadataFilters` expect them. */
31
+ private metadataFilterFlags;
29
32
  private matchesFilters;
30
33
  private scanCacheDirForList;
31
- private hasActiveFilters;
34
+ /**
35
+ * Identity header for the AXI "home view" (bare `bonsai`, no args): the tool's own bin path and
36
+ * one-sentence description, shown before the live data so an agent orients on what it's looking
37
+ * at without a separate `--help` round trip. Human mode only — the hidden `--identity` flag is
38
+ * only ever set by the argv rewrite for a bare invocation; it is not documented for direct use.
39
+ */
40
+ private logIdentityHeader;
32
41
  private logListResults;
33
42
  /**
34
43
  * Empty-cache / no-match tip (human mode only). Under `--json`/`--toon`, `data: []` is paired with
@@ -1,24 +1,28 @@
1
1
  import { Flags } from '@oclif/core';
2
+ import { homedir } from 'node:os';
2
3
  import { BaseCommand } from '../base-command.js';
3
4
  import { scanCacheDirs } from '../lib/research/storage.js';
4
5
  import { loadStoreRoots } from '../lib/research/store-roots.js';
5
6
  import { evaluateFreshness } from '../lib/research/freshness.js';
6
- import { ARTIFACT_TYPES, CAPTURE_METHODS, } from '../lib/research/schema.js';
7
- import { NO_TOPIC_LABEL, formatTip, resultListHeading, sanitizeForTerminal, } from '../lib/text.js';
7
+ import { collapseHomeDir, formatTip, resultListHeading, } from '../lib/text.js';
8
8
  import { limitFlag } from '../lib/limit-flag.js';
9
- import { artifactMatchesUrlFilter, emptyUrlFilterError } from '../lib/research/url.js';
10
- import { matchesTopicFilter, matchesTagsFilter, emptyTopicFilterError, emptyTagsFilterError, } from '../lib/research/metadata-filters.js';
11
- import { colors } from '../lib/color.js';
12
- import { CLI_FLAG_DESCRIPTIONS } from '../lib/cli-presentation.js';
9
+ import { countByFreshness, toListRow } from '../lib/research/list-row.js';
10
+ import { emptyUrlFilterError } from '../lib/research/url.js';
11
+ import { matchesCommonMetadataFilters, hasActiveMetadataFilters, emptyTopicFilterError, emptyTagsFilterError, } from '../lib/research/metadata-filters.js';
12
+ import { commonMetadataFilterFlags } from '../lib/common-metadata-filter-flags.js';
13
+ import { buildNextLimitCommand } from '../lib/next-command.js';
14
+ import { colors, FRESHNESS_COLOR } from '../lib/color.js';
15
+ import { CLI_FLAG_DESCRIPTIONS, formatResultRowHeader, formatResultRowSourceUrls, formatResultRowTokens, } from '../lib/cli-presentation.js';
13
16
  // Listings are ordered newest-first, so the truncation word is "first"; --limit caps at this value.
14
17
  const LIST_DEFAULT_MAX_LIMIT = 100;
18
+ // A small default keeps an unfiltered `list` from flooding an agent's context with everything ever
19
+ // cached; `summary.truncated`/`nextCommand` (and the human-mode tip) make raising --limit an
20
+ // explicit, deliberate next step rather than a silent, unbounded dump.
21
+ const LIST_DEFAULT_LIMIT = 10;
15
22
  const LIST_LABELS = {
16
23
  noun: 'cached research',
17
24
  order: 'first',
18
25
  };
19
- // `list` answers "what pages/notes do I have?" and deliberately omits section children (see
20
- // scanCacheDirForList), so `section` is not an offered filter — every other artifact type can appear.
21
- const LISTABLE_ARTIFACT_TYPES = ARTIFACT_TYPES.filter((type) => type !== 'section');
22
26
  /** Project a full row down to the default minimal shape (see {@link ListRowMinimal}). */
23
27
  function toMinimalListRow(row) {
24
28
  return {
@@ -28,13 +32,6 @@ function toMinimalListRow(row) {
28
32
  tokenEstimate: row.tokenEstimate,
29
33
  };
30
34
  }
31
- /** Count matched rows per freshness tier for the envelope's `summary.byFreshness`. */
32
- function countByFreshness(rows) {
33
- const counts = { fresh: 0, stale_grace: 0, stale_expired: 0 };
34
- for (const row of rows)
35
- counts[row.freshness]++;
36
- return counts;
37
- }
38
35
  /** List cached research artifacts by metadata filters. */
39
36
  export default class ResearchList extends BaseCommand {
40
37
  static id = 'list';
@@ -70,57 +67,30 @@ export default class ResearchList extends BaseCommand {
70
67
  },
71
68
  ];
72
69
  static flags = {
73
- topic: Flags.string({
74
- char: 't',
75
- description: CLI_FLAG_DESCRIPTIONS.filterTopic,
76
- }),
77
- tags: Flags.string({
78
- char: 'g',
79
- description: CLI_FLAG_DESCRIPTIONS.filterTags,
80
- multiple: true,
81
- }),
82
- url: Flags.string({
83
- description: CLI_FLAG_DESCRIPTIONS.sourceUrlGlob,
84
- }),
85
- freshness: Flags.option({
86
- description: 'freshness state',
87
- options: ['fresh', 'stale_grace', 'stale_expired'],
88
- })(),
89
- 'artifact-type': Flags.option({
90
- description: CLI_FLAG_DESCRIPTIONS.listArtifactType,
91
- options: LISTABLE_ARTIFACT_TYPES,
92
- })(),
93
- 'capture-method': Flags.option({
94
- description: 'capture method',
95
- options: CAPTURE_METHODS,
96
- })(),
97
- limit: limitFlag(LIST_DEFAULT_MAX_LIMIT, 50, `result count (max ${LIST_DEFAULT_MAX_LIMIT})`),
70
+ ...commonMetadataFilterFlags(CLI_FLAG_DESCRIPTIONS.listArtifactType),
71
+ limit: limitFlag(LIST_DEFAULT_MAX_LIMIT, LIST_DEFAULT_LIMIT, `result count (max ${LIST_DEFAULT_MAX_LIMIT}, default ${LIST_DEFAULT_LIMIT})`),
98
72
  full: Flags.boolean({
99
73
  default: false,
100
74
  description: CLI_FLAG_DESCRIPTIONS.listFull,
101
75
  }),
76
+ // Only ever set by the argv rewrite for a truly bare `bonsai` invocation (see normalizeArgv);
77
+ // hidden because it is not documented or intended for direct use.
78
+ identity: Flags.boolean({ default: false, hidden: true }),
102
79
  };
103
80
  static stdoutIsPrimaryData = true;
81
+ /** The shared filter flags as `matchesCommonMetadataFilters`/`hasActiveMetadataFilters` expect them. */
82
+ metadataFilterFlags() {
83
+ return {
84
+ topic: this.flags.topic,
85
+ tags: this.flags.tags,
86
+ url: this.flags.url,
87
+ artifactType: this.flags['artifact-type'],
88
+ captureMethod: this.flags['capture-method'],
89
+ freshness: this.flags.freshness,
90
+ };
91
+ }
104
92
  matchesFilters(meta, freshness) {
105
- if (!matchesTopicFilter(meta, this.flags.topic)) {
106
- return false;
107
- }
108
- if (!matchesTagsFilter(meta, this.flags.tags)) {
109
- return false;
110
- }
111
- if (this.flags.url && !artifactMatchesUrlFilter(meta, this.flags.url)) {
112
- return false;
113
- }
114
- if (this.flags['artifact-type'] && meta.artifact_type !== this.flags['artifact-type']) {
115
- return false;
116
- }
117
- if (this.flags['capture-method'] && meta.capture_method !== this.flags['capture-method']) {
118
- return false;
119
- }
120
- if (this.flags.freshness && freshness !== this.flags.freshness) {
121
- return false;
122
- }
123
- return true;
93
+ return matchesCommonMetadataFilters(meta, freshness, this.metadataFilterFlags());
124
94
  }
125
95
  scanCacheDirForList(readRoots, currentTime) {
126
96
  // Honor effective read-only: listing must not persist the derived search-index sidecar.
@@ -131,38 +101,30 @@ export default class ResearchList extends BaseCommand {
131
101
  // listing (one page yields dozens) and aren't in the documented source/research_note contract.
132
102
  // They stay discoverable through `inspect` (which lists a page's sections). `list` answers "what pages/notes do I have?", so keep it page-level. This
133
103
  // unconditional guard owns the exclusion (the default no-filter case relies on it);
134
- // LISTABLE_ARTIFACT_TYPES just hides `section` from --artifact-type so no one filters for a
104
+ // PAGE_LEVEL_ARTIFACT_TYPES just hides `section` from --artifact-type so no one filters for a
135
105
  // type list can never return. Keep both in sync if section handling ever changes.
136
106
  if (artifact.metadata.artifact_type === 'section')
137
107
  return null;
138
108
  const freshness = evaluateFreshness(artifact.metadata, currentTime, null);
139
109
  if (!this.matchesFilters(artifact.metadata, freshness))
140
110
  return null;
141
- return {
142
- cacheKey: artifact.metadata.cache_key,
143
- path: filePath,
144
- artifactType: artifact.metadata.artifact_type,
145
- sourceUrls: artifact.metadata.source_urls,
146
- topic: artifact.metadata.topic,
147
- tags: artifact.metadata.tags,
148
- freshness,
149
- captureMethod: artifact.metadata.capture_method,
150
- tokenEstimate: artifact.metadata.token_estimate,
151
- qualityNotes: artifact.metadata.quality_notes,
152
- fetchedAt: artifact.metadata.fetched_at,
153
- validatedAt: artifact.metadata.validated_at,
154
- };
111
+ return toListRow(artifact, filePath, freshness);
155
112
  }, { persistIndex: !this.readOnly });
156
113
  }
157
- hasActiveFilters() {
158
- return Boolean(this.flags.topic ||
159
- (this.flags.tags && this.flags.tags.length > 0) ||
160
- this.flags.freshness ||
161
- this.flags['artifact-type'] ||
162
- this.flags['capture-method'] ||
163
- this.flags.url);
114
+ /**
115
+ * Identity header for the AXI "home view" (bare `bonsai`, no args): the tool's own bin path and
116
+ * one-sentence description, shown before the live data so an agent orients on what it's looking
117
+ * at without a separate `--help` round trip. Human mode only — the hidden `--identity` flag is
118
+ * only ever set by the argv rewrite for a bare invocation; it is not documented for direct use.
119
+ */
120
+ logIdentityHeader() {
121
+ if (!this.flags.identity || this.jsonEnabled())
122
+ return;
123
+ const binPath = collapseHomeDir(process.argv[1] ?? this.config.bin, homedir());
124
+ this.log(`bin: ${binPath}`);
125
+ this.log(`description: ${this.config.pjson.description ?? this.config.bin}\n`);
164
126
  }
165
- logListResults(finalResults, totalMatched) {
127
+ logListResults(finalResults, totalMatched, nextCommand) {
166
128
  if (finalResults.length === 0) {
167
129
  this.emitEmptyListGuidance();
168
130
  return;
@@ -171,22 +133,13 @@ export default class ResearchList extends BaseCommand {
171
133
  return;
172
134
  this.log(`${resultListHeading(totalMatched, finalResults.length, LIST_LABELS)}\n`);
173
135
  finalResults.forEach((res, index) => {
174
- const topicStr = res.topic
175
- ? colors.cyan(sanitizeForTerminal(res.topic))
176
- : colors.gray(NO_TOPIC_LABEL);
177
- const keyStr = colors.bold(res.cacheKey);
178
- this.log(`${index + 1}. [${topicStr}] Key: ${keyStr}`);
179
- const freshnessColorMap = {
180
- fresh: colors.green,
181
- stale_grace: colors.yellow,
182
- stale_expired: colors.red,
183
- };
184
- const freshnessColor = freshnessColorMap[res.freshness];
185
- const freshnessStr = freshnessColor(res.freshness);
136
+ this.log(formatResultRowHeader(index, res.topic, res.cacheKey));
137
+ const freshnessStr = FRESHNESS_COLOR[res.freshness](res.freshness);
186
138
  this.log(` Type: ${colors.bold(res.artifactType)} | Freshness: ${freshnessStr}`);
187
- this.log(` Tokens: compressed=${colors.bold(String(res.tokenEstimate?.compressed || 0))}, detailed=${colors.bold(String(res.tokenEstimate?.detailed || 0))}`);
188
- this.log(` Source URLs: ${colors.gray(res.sourceUrls.join(', '))}\n`);
139
+ this.log(formatResultRowTokens(res.tokenEstimate));
140
+ this.log(formatResultRowSourceUrls(res.sourceUrls));
189
141
  });
142
+ this.tipNextCommand(nextCommand);
190
143
  }
191
144
  /**
192
145
  * Empty-cache / no-match tip (human mode only). Under `--json`/`--toon`, `data: []` is paired with
@@ -195,7 +148,7 @@ export default class ResearchList extends BaseCommand {
195
148
  emitEmptyListGuidance() {
196
149
  if (this.jsonEnabled())
197
150
  return;
198
- const filtered = this.hasActiveFilters();
151
+ const filtered = hasActiveMetadataFilters(this.metadataFilterFlags());
199
152
  const headline = filtered
200
153
  ? 'No cached research entries match the given filters.'
201
154
  : 'No cached research entries found.';
@@ -217,6 +170,7 @@ export default class ResearchList extends BaseCommand {
217
170
  emptyTagsFilterError(this.flags.tags);
218
171
  if (flagErr)
219
172
  this.error(flagErr, { exit: 2, code: 'INVALID_FLAG_VALUE' });
173
+ this.logIdentityHeader();
220
174
  const roots = loadStoreRoots({
221
175
  configDir: this.config.configDir,
222
176
  cwd: process.cwd(),
@@ -230,17 +184,24 @@ export default class ResearchList extends BaseCommand {
230
184
  return timeB - timeA;
231
185
  });
232
186
  const finalResults = results.slice(0, this.flags.limit);
187
+ const truncated = results.length > finalResults.length;
188
+ // Suggest exactly enough to see every match, capped at the command's own max — a caller who
189
+ // truncated at the default can raise --limit once and be done, rather than guessing a value.
190
+ const nextCommand = truncated
191
+ ? buildNextLimitCommand(this.config.bin, 'list', this.argv, Math.min(results.length, LIST_DEFAULT_MAX_LIMIT))
192
+ : null;
233
193
  // Human heading carries truncation inline; --json/--toon get the same counts (plus a freshness
234
194
  // breakdown and an explicit `empty` flag) as an always-present envelope `summary` object.
235
195
  this.jsonSummary = {
236
196
  total: results.length,
237
197
  shown: finalResults.length,
238
198
  limit: this.flags.limit,
239
- truncated: results.length > finalResults.length,
199
+ truncated,
240
200
  empty: results.length === 0,
241
201
  byFreshness: countByFreshness(results),
202
+ nextCommand,
242
203
  };
243
- this.logListResults(finalResults, results.length);
204
+ this.logListResults(finalResults, results.length, nextCommand);
244
205
  return this.flags.full ? finalResults : finalResults.map(toMinimalListRow);
245
206
  }
246
207
  /** Attach the aggregate `summary` computed in `run()` (see {@link ListSummary}). */
@@ -0,0 +1,43 @@
1
+ import { BaseCommand } from '../base-command.js';
2
+ import type { SearchRow, SearchRowMinimal } from '../lib/cli-result-types.js';
3
+ /** Search cached research artifacts by tag/content keywords and metadata filters. */
4
+ export default class ResearchSearch extends BaseCommand<typeof ResearchSearch> {
5
+ static id: string;
6
+ static summary: string;
7
+ static description: string;
8
+ /** Aggregate counts (and an explicit empty/truncation signal); see `list.ts`'s equivalent. */
9
+ private jsonSummary;
10
+ static examples: {
11
+ description: string;
12
+ command: string;
13
+ }[];
14
+ static flags: {
15
+ limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
16
+ full: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
+ topic: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
18
+ tags: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
19
+ url: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
20
+ freshness: import("@oclif/core/interfaces").OptionFlag<"fresh" | "stale_grace" | "stale_expired" | undefined, import("@oclif/core/interfaces").CustomOptions>;
21
+ 'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | "index" | undefined, import("@oclif/core/interfaces").CustomOptions>;
22
+ 'capture-method': import("@oclif/core/interfaces").OptionFlag<"static_fetch" | "browser_fallback" | "agent_supplied" | "route_markdown" | "github_source" | undefined, import("@oclif/core/interfaces").CustomOptions>;
23
+ query: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
24
+ 'match-any': import("@oclif/core/interfaces").BooleanFlag<boolean>;
25
+ };
26
+ static stdoutIsPrimaryData: boolean;
27
+ /** The shared filter flags as `matchesCommonMetadataFilters`/`hasActiveMetadataFilters` expect them. */
28
+ private metadataFilterFlags;
29
+ private matchesMetadataFilters;
30
+ private scanCacheDirForSearch;
31
+ /** Newest-first (no query) or highest-score-first (query given), both tie-broken by cache key. */
32
+ private sortResults;
33
+ private hasActiveFilters;
34
+ private logSearchResults;
35
+ /**
36
+ * Empty-cache / no-match tip (human mode only). Under `--json`/`--toon`, `data: []` is paired with
37
+ * `summary.empty: true` instead of prose — see {@link toSuccessJson}.
38
+ */
39
+ private emitEmptySearchGuidance;
40
+ run(): Promise<SearchRow[] | SearchRowMinimal[]>;
41
+ /** Attach the aggregate `summary` computed in `run()` (see {@link SearchSummary}). */
42
+ protected toSuccessJson(data: unknown): Record<string, unknown>;
43
+ }
@@ -0,0 +1,233 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../base-command.js';
3
+ import { scanCacheDirs } from '../lib/research/storage.js';
4
+ import { loadStoreRoots } from '../lib/research/store-roots.js';
5
+ import { evaluateFreshness } from '../lib/research/freshness.js';
6
+ import { formatTip, resultListHeading, sanitizeForTerminal, } from '../lib/text.js';
7
+ import { limitFlag } from '../lib/limit-flag.js';
8
+ import { countByFreshness, toListRow } from '../lib/research/list-row.js';
9
+ import { emptyUrlFilterError } from '../lib/research/url.js';
10
+ import { matchesCommonMetadataFilters, hasActiveMetadataFilters, emptyTopicFilterError, emptyTagsFilterError, } from '../lib/research/metadata-filters.js';
11
+ import { commonMetadataFilterFlags } from '../lib/common-metadata-filter-flags.js';
12
+ import { buildNextLimitCommand } from '../lib/next-command.js';
13
+ import { tokenizeSearchQuery, emptySearchQueryError, scoreSearchMatch, extractSearchSnippet, } from '../lib/research/search-match.js';
14
+ import { colors, FRESHNESS_COLOR } from '../lib/color.js';
15
+ import { CLI_FLAG_DESCRIPTIONS, formatResultRowHeader, formatResultRowSourceUrls, formatResultRowTokens, } from '../lib/cli-presentation.js';
16
+ const SEARCH_DEFAULT_MAX_LIMIT = 100;
17
+ // A small default (matching `list`'s) keeps a broad query from flooding an agent's context;
18
+ // `summary.truncated`/`nextCommand` (and the human-mode tip) make raising --limit an explicit,
19
+ // deliberate next step rather than a silent, unbounded dump.
20
+ const SEARCH_DEFAULT_LIMIT = 10;
21
+ const NO_MATCH_SCORE = { score: 0, matchedFields: [] };
22
+ /** Project a full row down to the default minimal shape (see {@link SearchRowMinimal}). */
23
+ function toMinimalSearchRow(row) {
24
+ return {
25
+ sourceUrls: row.sourceUrls,
26
+ topic: row.topic,
27
+ freshness: row.freshness,
28
+ tokenEstimate: row.tokenEstimate,
29
+ score: row.score,
30
+ matchedFields: row.matchedFields,
31
+ snippet: row.snippet,
32
+ };
33
+ }
34
+ /** Search cached research artifacts by tag/content keywords and metadata filters. */
35
+ export default class ResearchSearch extends BaseCommand {
36
+ static id = 'search';
37
+ static summary = 'Search cached research artifacts by tags and content';
38
+ static description = 'Rank cached page-level artifacts by a --query matched against topic, tags, summary, and ' +
39
+ 'compressed content, plus the same metadata filters as `list` (topic, tags, URL, freshness, ' +
40
+ 'artifact type, capture method). Reads only the token-cheap indexed summary/compressed text, ' +
41
+ 'never the full detailed body, so ranking a large cache stays fast. Pass --full for every ' +
42
+ 'metadata field.';
43
+ /** Aggregate counts (and an explicit empty/truncation signal); see `list.ts`'s equivalent. */
44
+ jsonSummary = null;
45
+ static examples = [
46
+ {
47
+ description: 'search content and tags for a keyword',
48
+ command: '<%= config.bin %> search --query "suspense boundary"',
49
+ },
50
+ {
51
+ description: 'match any of several terms instead of requiring all',
52
+ command: '<%= config.bin %> search --query "suspense streaming" --match-any',
53
+ },
54
+ {
55
+ description: 'combine a query with metadata filters',
56
+ command: '<%= config.bin %> search --query cache --tags react --freshness fresh',
57
+ },
58
+ {
59
+ description: 'filter by tags/topic alone, ranked like list (no --query)',
60
+ command: '<%= config.bin %> search --tags node --json',
61
+ },
62
+ ];
63
+ static flags = {
64
+ query: Flags.string({
65
+ char: 'q',
66
+ description: CLI_FLAG_DESCRIPTIONS.searchQuery,
67
+ }),
68
+ 'match-any': Flags.boolean({
69
+ default: false,
70
+ description: CLI_FLAG_DESCRIPTIONS.searchMatchAny,
71
+ }),
72
+ ...commonMetadataFilterFlags(CLI_FLAG_DESCRIPTIONS.searchArtifactType),
73
+ limit: limitFlag(SEARCH_DEFAULT_MAX_LIMIT, SEARCH_DEFAULT_LIMIT, `result count (max ${SEARCH_DEFAULT_MAX_LIMIT}, default ${SEARCH_DEFAULT_LIMIT})`),
74
+ full: Flags.boolean({
75
+ default: false,
76
+ description: CLI_FLAG_DESCRIPTIONS.searchFull,
77
+ }),
78
+ };
79
+ static stdoutIsPrimaryData = true;
80
+ /** The shared filter flags as `matchesCommonMetadataFilters`/`hasActiveMetadataFilters` expect them. */
81
+ metadataFilterFlags() {
82
+ return {
83
+ topic: this.flags.topic,
84
+ tags: this.flags.tags,
85
+ url: this.flags.url,
86
+ artifactType: this.flags['artifact-type'],
87
+ captureMethod: this.flags['capture-method'],
88
+ freshness: this.flags.freshness,
89
+ };
90
+ }
91
+ matchesMetadataFilters(meta, freshness) {
92
+ return matchesCommonMetadataFilters(meta, freshness, this.metadataFilterFlags());
93
+ }
94
+ scanCacheDirForSearch(readRoots, currentTime, terms) {
95
+ const matchAny = this.flags['match-any'];
96
+ // Honor effective read-only: searching must not persist the derived search-index sidecar.
97
+ return scanCacheDirs(readRoots, (artifact, filePath) => {
98
+ if (artifact.metadata.status !== 'active')
99
+ return null;
100
+ // Page-level only, same reasoning as `list`: sections are sub-chunks of a page, not
101
+ // artifacts a user "has" — find them via `inspect` instead.
102
+ if (artifact.metadata.artifact_type === 'section')
103
+ return null;
104
+ const freshness = evaluateFreshness(artifact.metadata, currentTime, null);
105
+ if (!this.matchesMetadataFilters(artifact.metadata, freshness))
106
+ return null;
107
+ let match = NO_MATCH_SCORE;
108
+ let snippet = null;
109
+ if (terms.length > 0) {
110
+ const scored = scoreSearchMatch({
111
+ topic: artifact.metadata.topic,
112
+ tags: artifact.metadata.tags,
113
+ summary: artifact.summary,
114
+ compressed: artifact.compressed,
115
+ }, terms, matchAny);
116
+ if (!scored)
117
+ return null;
118
+ match = scored;
119
+ snippet = extractSearchSnippet({ summary: artifact.summary, compressed: artifact.compressed }, terms);
120
+ }
121
+ return {
122
+ ...toListRow(artifact, filePath, freshness),
123
+ score: match.score,
124
+ matchedFields: match.matchedFields,
125
+ snippet,
126
+ };
127
+ }, { persistIndex: !this.readOnly });
128
+ }
129
+ /** Newest-first (no query) or highest-score-first (query given), both tie-broken by cache key. */
130
+ sortResults(results, queried) {
131
+ results.sort((a, b) => {
132
+ if (queried && b.score !== a.score)
133
+ return b.score - a.score;
134
+ const timeA = new Date(a.validatedAt || a.fetchedAt || 0).getTime();
135
+ const timeB = new Date(b.validatedAt || b.fetchedAt || 0).getTime();
136
+ if (timeB !== timeA)
137
+ return timeB - timeA;
138
+ return a.cacheKey.localeCompare(b.cacheKey);
139
+ });
140
+ }
141
+ hasActiveFilters() {
142
+ return Boolean(this.flags.query) || hasActiveMetadataFilters(this.metadataFilterFlags());
143
+ }
144
+ logSearchResults(finalResults, totalMatched, queried, nextCommand) {
145
+ if (finalResults.length === 0) {
146
+ this.emitEmptySearchGuidance();
147
+ return;
148
+ }
149
+ if (this.jsonEnabled())
150
+ return;
151
+ const labels = queried
152
+ ? { noun: 'matching cached research', order: 'top' }
153
+ : { noun: 'cached research', order: 'first' };
154
+ this.log(`${resultListHeading(totalMatched, finalResults.length, labels)}\n`);
155
+ finalResults.forEach((res, index) => {
156
+ this.log(formatResultRowHeader(index, res.topic, res.cacheKey));
157
+ const freshnessStr = FRESHNESS_COLOR[res.freshness](res.freshness);
158
+ const scoreStr = queried ? ` | Score: ${colors.bold(String(res.score))}` : '';
159
+ this.log(` Type: ${colors.bold(res.artifactType)} | Freshness: ${freshnessStr}${scoreStr}`);
160
+ if (queried && res.matchedFields.length > 0) {
161
+ this.log(` Matched: ${colors.gray(res.matchedFields.join(', '))}`);
162
+ }
163
+ if (res.snippet) {
164
+ this.log(` Snippet: ${colors.gray(sanitizeForTerminal(res.snippet))}`);
165
+ }
166
+ this.log(formatResultRowTokens(res.tokenEstimate));
167
+ this.log(formatResultRowSourceUrls(res.sourceUrls));
168
+ });
169
+ this.tipNextCommand(nextCommand);
170
+ }
171
+ /**
172
+ * Empty-cache / no-match tip (human mode only). Under `--json`/`--toon`, `data: []` is paired with
173
+ * `summary.empty: true` instead of prose — see {@link toSuccessJson}.
174
+ */
175
+ emitEmptySearchGuidance() {
176
+ if (this.jsonEnabled())
177
+ return;
178
+ const filtered = this.hasActiveFilters();
179
+ const headline = filtered
180
+ ? 'No cached research entries match the given search.'
181
+ : 'No cached research entries found.';
182
+ const tipCmd = filtered ? `${this.config.bin} search` : `${this.config.bin} <url>`;
183
+ const tipLead = filtered
184
+ ? 'try a broader --query, --match-any, or relax other filters: '
185
+ : 'populate the cache first: ';
186
+ this.log(headline);
187
+ this.log(`\n${formatTip(`${tipLead}${colors.cyan(tipCmd)}`)}`);
188
+ }
189
+ async run() {
190
+ const flagErr = emptyUrlFilterError(this.flags.url) ??
191
+ emptyTopicFilterError(this.flags.topic) ??
192
+ emptyTagsFilterError(this.flags.tags) ??
193
+ emptySearchQueryError(this.flags.query);
194
+ if (flagErr)
195
+ this.error(flagErr, { exit: 2, code: 'INVALID_FLAG_VALUE' });
196
+ const roots = loadStoreRoots({
197
+ configDir: this.config.configDir,
198
+ cwd: process.cwd(),
199
+ dataDir: this.config.dataDir,
200
+ });
201
+ const currentTime = new Date();
202
+ const terms = this.flags.query ? tokenizeSearchQuery(this.flags.query) : [];
203
+ const queried = terms.length > 0;
204
+ const results = this.scanCacheDirForSearch(roots.readRoots, currentTime, terms);
205
+ this.sortResults(results, queried);
206
+ const finalResults = results.slice(0, this.flags.limit);
207
+ const truncated = results.length > finalResults.length;
208
+ // Suggest exactly enough to see every match, capped at the command's own max — a caller who
209
+ // truncated at the default can raise --limit once and be done, rather than guessing a value.
210
+ const nextCommand = truncated
211
+ ? buildNextLimitCommand(this.config.bin, 'search', this.argv, Math.min(results.length, SEARCH_DEFAULT_MAX_LIMIT))
212
+ : null;
213
+ this.jsonSummary = {
214
+ total: results.length,
215
+ shown: finalResults.length,
216
+ limit: this.flags.limit,
217
+ truncated,
218
+ empty: results.length === 0,
219
+ byFreshness: countByFreshness(results),
220
+ queried,
221
+ nextCommand,
222
+ };
223
+ this.logSearchResults(finalResults, results.length, queried, nextCommand);
224
+ return this.flags.full ? finalResults : finalResults.map(toMinimalSearchRow);
225
+ }
226
+ /** Attach the aggregate `summary` computed in `run()` (see {@link SearchSummary}). */
227
+ toSuccessJson(data) {
228
+ const envelope = this.baseSuccessJson(data);
229
+ if (!this.jsonSummary)
230
+ return envelope;
231
+ return { ...envelope, summary: this.jsonSummary };
232
+ }
233
+ }
@@ -0,0 +1,27 @@
1
+ import { BaseCommand } from '../base-command.js';
2
+ import type { SetupResult } from '../lib/cli-result-types.js';
3
+ /**
4
+ * Installs (or repairs) a `SessionStart` hook that runs `bonsai context` at the start of every
5
+ * agent session, so the cache's current state is visible before the agent takes any action (AXI
6
+ * principle 7: ambient context via session integrations).
7
+ */
8
+ export default class Setup extends BaseCommand<typeof Setup> {
9
+ static id: string;
10
+ static summary: string;
11
+ static description: string;
12
+ static examples: {
13
+ description: string;
14
+ command: string;
15
+ }[];
16
+ static args: {
17
+ agent: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
18
+ };
19
+ static flags: {
20
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
21
+ global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
22
+ local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
23
+ };
24
+ static stdoutIsPrimaryData: boolean;
25
+ private assertKnownAgent;
26
+ run(): Promise<SetupResult>;
27
+ }