@taurgis/bonsai 4.0.0 → 4.1.1

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 (85) hide show
  1. package/README.md +24 -3
  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/bin/cli.mjs +0 -11
  14. package/dist/base-command.d.ts +5 -0
  15. package/dist/base-command.js +9 -0
  16. package/dist/commands/config/get.d.ts +1 -1
  17. package/dist/commands/config/get.js +1 -1
  18. package/dist/commands/config/list.d.ts +1 -1
  19. package/dist/commands/config/list.js +1 -1
  20. package/dist/commands/config/set.d.ts +1 -1
  21. package/dist/commands/config/set.js +1 -1
  22. package/dist/commands/config/unset.d.ts +1 -1
  23. package/dist/commands/config/unset.js +1 -1
  24. package/dist/commands/context.d.ts +20 -0
  25. package/dist/commands/context.js +65 -0
  26. package/dist/commands/list.d.ts +12 -3
  27. package/dist/commands/list.js +61 -100
  28. package/dist/commands/search.d.ts +43 -0
  29. package/dist/commands/search.js +233 -0
  30. package/dist/commands/setup.d.ts +27 -0
  31. package/dist/commands/setup.js +148 -0
  32. package/dist/commands.d.ts +6 -0
  33. package/dist/commands.js +6 -0
  34. package/dist/lib/argv.js +4 -1
  35. package/dist/lib/cli-flag-manifest.d.ts +2 -2
  36. package/dist/lib/cli-flag-manifest.js +15 -7
  37. package/dist/lib/cli-presentation.d.ts +19 -0
  38. package/dist/lib/cli-presentation.js +27 -0
  39. package/dist/lib/cli-result-types.d.ts +59 -0
  40. package/dist/lib/color.d.ts +2 -0
  41. package/dist/lib/color.js +6 -0
  42. package/dist/lib/common-metadata-filter-flags.d.ts +14 -0
  43. package/dist/lib/common-metadata-filter-flags.js +37 -0
  44. package/dist/{commands/config/base.d.ts → lib/config-command.d.ts} +7 -9
  45. package/dist/{commands/config/base.js → lib/config-command.js} +9 -20
  46. package/dist/lib/next-command.d.ts +15 -0
  47. package/dist/lib/next-command.js +40 -0
  48. package/dist/lib/research/context-dashboard.d.ts +19 -0
  49. package/dist/lib/research/context-dashboard.js +43 -0
  50. package/dist/lib/research/fetch-command-service.js +10 -0
  51. package/dist/lib/research/fetch-result.d.ts +1 -0
  52. package/dist/lib/research/fetch-result.js +4 -0
  53. package/dist/lib/research/fetcher.d.ts +11 -0
  54. package/dist/lib/research/fetcher.js +26 -0
  55. package/dist/lib/research/list-row.d.ts +15 -0
  56. package/dist/lib/research/list-row.js +31 -0
  57. package/dist/lib/research/metadata-filters.d.ts +22 -1
  58. package/dist/lib/research/metadata-filters.js +34 -0
  59. package/dist/lib/research/regression.js +6 -1
  60. package/dist/lib/research/schema.d.ts +2 -0
  61. package/dist/lib/research/schema.js +4 -0
  62. package/dist/lib/research/search-match.d.ts +39 -0
  63. package/dist/lib/research/search-match.js +134 -0
  64. package/dist/lib/scope-flags.d.ts +19 -0
  65. package/dist/lib/scope-flags.js +27 -0
  66. package/dist/lib/setup/agents.d.ts +16 -0
  67. package/dist/lib/setup/agents.js +20 -0
  68. package/dist/lib/setup/bin-command.d.ts +14 -0
  69. package/dist/lib/setup/bin-command.js +42 -0
  70. package/dist/lib/setup/hook-file-io.d.ts +37 -0
  71. package/dist/lib/setup/hook-file-io.js +80 -0
  72. package/dist/lib/text.d.ts +6 -0
  73. package/dist/lib/text.js +11 -0
  74. package/dist/lib/value-flags.d.ts +2 -0
  75. package/dist/lib/value-flags.js +11 -3
  76. package/dist/sites/salesforce-developer/fetch-page.d.ts +1 -1
  77. package/dist/sites/salesforce-developer/fetch-page.js +3 -1
  78. package/dist/sites/salesforce-developer/route-markdown.d.ts +3 -2
  79. package/dist/sites/salesforce-developer/route-markdown.js +8 -6
  80. package/dist/sites/salesforce-doc-fetch.d.ts +23 -11
  81. package/dist/sites/salesforce-doc-fetch.js +89 -27
  82. package/dist/sites/salesforce-dom-probe.d.ts +6 -0
  83. package/dist/sites/salesforce-dom-probe.js +40 -0
  84. package/oclif.manifest.json +428 -54
  85. package/package.json +8 -13
@@ -1,5 +1,5 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
- import { ConfigCommand, configScopeFlags } from './base.js';
2
+ import { ConfigCommand, configScopeFlags } from '../../lib/config-command.js';
3
3
  import { KEY_META, keyValuesHint } from '../../lib/config/index.js';
4
4
  import { persistConfigPatch } from '../../lib/config-persist.js';
5
5
  export default class ConfigSet extends ConfigCommand {
@@ -1,4 +1,4 @@
1
- import { ConfigCommand } from './base.js';
1
+ import { ConfigCommand } from '../../lib/config-command.js';
2
2
  import type { ConfigWriteResult } from '../../lib/cli-result-types.js';
3
3
  export default class ConfigUnset extends ConfigCommand<typeof ConfigUnset> {
4
4
  static id: string;
@@ -1,5 +1,5 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
- import { ConfigCommand, configScopeFlags } from './base.js';
2
+ import { ConfigCommand, configScopeFlags } from '../../lib/config-command.js';
3
3
  import { keyValuesHint } from '../../lib/config/index.js';
4
4
  import { persistConfigPatch } from '../../lib/config-persist.js';
5
5
  export default class ConfigUnset extends ConfigCommand {
@@ -0,0 +1,20 @@
1
+ import { BaseCommand } from '../base-command.js';
2
+ import type { ContextDashboard } from '../lib/cli-result-types.js';
3
+ /**
4
+ * Compact, directory-scoped cache summary (AXI principle 7: ambient session-start context).
5
+ * Plain-text human output is deliberately short — it's meant to be piped verbatim into a
6
+ * SessionStart hook's `additionalContext` by `bonsai setup`, so every line here is also a
7
+ * reasonable thing for an agent to read unprompted at the start of a session.
8
+ */
9
+ export default class Context extends BaseCommand<typeof Context> {
10
+ static id: string;
11
+ static summary: string;
12
+ static description: string;
13
+ static examples: {
14
+ description: string;
15
+ command: string;
16
+ }[];
17
+ static stdoutIsPrimaryData: boolean;
18
+ private logDashboard;
19
+ run(): Promise<ContextDashboard>;
20
+ }
@@ -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
+ }