@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,148 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { BaseCommand } from '../base-command.js';
6
+ import { atomicWriteFile } from '../lib/atomic-write.js';
7
+ import { resolveBinCommand } from '../lib/setup/bin-command.js';
8
+ import { SETUP_AGENTS, SETUP_AGENT_TARGETS, SETUP_HOOK_COMMAND_SUFFIX, SETUP_HOOK_MANAGED_BY, } from '../lib/setup/agents.js';
9
+ import { InvalidHookFileError, planSessionStartHookInstall, } from '../lib/setup/hook-file-io.js';
10
+ import { closestMatch, maxFuzzyDistance } from '../lib/text.js';
11
+ import { assertScopeFlagsExclusive, scopeFlags } from '../lib/scope-flags.js';
12
+ function isSetupAgent(value) {
13
+ return SETUP_AGENTS.includes(value);
14
+ }
15
+ /**
16
+ * Nearest known agent for a typo'd `agentArg`, or `null` if nothing plausible matches. Checks a
17
+ * prefix relationship first (either direction) so a short, valid-looking typo like `claude` for
18
+ * `claude-code` is caught even though its edit distance to the full name exceeds the general
19
+ * Levenshtein threshold; falls back to edit distance for closer misspellings like `codexx`.
20
+ */
21
+ function suggestAgent(agentArg) {
22
+ const prefixMatch = SETUP_AGENTS.find((agent) => agent.startsWith(agentArg) || agentArg.startsWith(agent));
23
+ if (prefixMatch)
24
+ return prefixMatch;
25
+ // closestMatch's candidates are exactly SETUP_AGENTS, so any non-null result is a SetupAgent.
26
+ return closestMatch(agentArg, SETUP_AGENTS, maxFuzzyDistance(agentArg));
27
+ }
28
+ /** Maps a plan outcome to the reported status, remapping write outcomes to `would_*` under dry-run. */
29
+ function reportSetupStatus(planStatus, dryRun) {
30
+ if (!dryRun)
31
+ return planStatus;
32
+ if (planStatus === 'unchanged')
33
+ return 'unchanged';
34
+ return planStatus === 'repaired' ? 'would_repair' : 'would_install';
35
+ }
36
+ const SETUP_STATUS_LABEL = {
37
+ installed: 'Installed',
38
+ repaired: 'Repaired',
39
+ unchanged: 'Already up to date:',
40
+ would_install: '[dry-run] Would install',
41
+ would_repair: '[dry-run] Would repair',
42
+ };
43
+ /**
44
+ * Installs (or repairs) a `SessionStart` hook that runs `bonsai context` at the start of every
45
+ * agent session, so the cache's current state is visible before the agent takes any action (AXI
46
+ * principle 7: ambient context via session integrations).
47
+ */
48
+ export default class Setup extends BaseCommand {
49
+ static id = 'setup';
50
+ static summary = 'Install a SessionStart hook that shows live cache context';
51
+ static description = 'Writes (or repairs) a SessionStart hook entry that runs `bonsai context` at the start of ' +
52
+ 'every session, so the agent sees the cache state before doing anything. Project-scoped by ' +
53
+ 'default (shareable via version control); pass --global for a user-level, machine-only install. ' +
54
+ 'Re-running is idempotent: an unchanged install is a no-op, and a stale executable path is repaired.';
55
+ static examples = [
56
+ {
57
+ description: 'install the hook for this project (Claude Code)',
58
+ command: '<%= config.bin %> setup claude-code',
59
+ },
60
+ {
61
+ description: 'install the hook for this project (Codex)',
62
+ command: '<%= config.bin %> setup codex',
63
+ },
64
+ {
65
+ description: 'preview a user-level install without writing anything',
66
+ command: '<%= config.bin %> setup claude-code --global --dry-run --json',
67
+ },
68
+ ];
69
+ static args = {
70
+ agent: Args.string({
71
+ required: true,
72
+ description: `agent to install the hook for (${SETUP_AGENTS.join(', ')})`,
73
+ }),
74
+ };
75
+ static flags = {
76
+ ...scopeFlags({
77
+ global: 'install to the user-level config instead of the project (default: project)',
78
+ local: 'install to the project config (default; explicit form of the default)',
79
+ }),
80
+ 'dry-run': Flags.boolean({
81
+ description: 'preview the install without writing anything',
82
+ default: false,
83
+ }),
84
+ };
85
+ static stdoutIsPrimaryData = true;
86
+ assertKnownAgent(agentArg) {
87
+ if (isSetupAgent(agentArg))
88
+ return;
89
+ if (agentArg === 'opencode') {
90
+ this.error('OpenCode is not supported by `setup` yet: its plugin hook signature for injecting ' +
91
+ "session-start context isn't confirmed in official docs, so Bonsai won't guess at one. " +
92
+ 'See the "Ambient session context" docs for status.', { exit: 2, code: 'UNKNOWN_AGENT', suggestions: [`Use one of: ${SETUP_AGENTS.join(', ')}`] });
93
+ }
94
+ const suggestion = suggestAgent(agentArg);
95
+ const hint = suggestion ? ` Did you mean "${suggestion}"?` : '';
96
+ this.error(`Unknown agent: "${agentArg}".${hint} Supported agents: ${SETUP_AGENTS.join(', ')}.`, {
97
+ exit: 2,
98
+ code: 'UNKNOWN_AGENT',
99
+ suggestions: suggestion
100
+ ? [`Use "${suggestion}": ${this.config.bin} setup ${suggestion}`]
101
+ : [`Use one of: ${SETUP_AGENTS.join(', ')}`],
102
+ });
103
+ }
104
+ async run() {
105
+ const agentArg = this.args.agent;
106
+ this.assertKnownAgent(agentArg);
107
+ assertScopeFlagsExclusive(this.error.bind(this), this.flags.global, this.flags.local);
108
+ const target = SETUP_AGENT_TARGETS[agentArg];
109
+ const scope = this.flags.global ? 'user' : 'project';
110
+ const root = scope === 'user' ? homedir() : process.cwd();
111
+ const path = join(root, target.relativePath);
112
+ const binCommand = resolveBinCommand(process.argv[1] ?? this.config.bin);
113
+ const command = `${binCommand} ${SETUP_HOOK_COMMAND_SUFFIX}`;
114
+ const existingContent = existsSync(path) ? readFileSync(path, 'utf-8') : null;
115
+ let plan;
116
+ try {
117
+ plan = planSessionStartHookInstall(existingContent, {
118
+ eventName: target.eventName,
119
+ matcher: target.matcher,
120
+ command,
121
+ timeout: target.timeout,
122
+ managedBy: SETUP_HOOK_MANAGED_BY,
123
+ });
124
+ }
125
+ catch (err) {
126
+ if (err instanceof InvalidHookFileError) {
127
+ this.error(`Cannot install into ${path}: ${err.message}.`, {
128
+ exit: 1,
129
+ code: 'INVALID_HOOK_FILE',
130
+ suggestions: [
131
+ 'Fix or remove the existing file so it contains a single JSON object, then re-run setup.',
132
+ ],
133
+ });
134
+ }
135
+ throw err;
136
+ }
137
+ const dryRun = this.effectiveDryRun(this.flags['dry-run']);
138
+ const status = reportSetupStatus(plan.status, dryRun);
139
+ if (!dryRun && plan.status !== 'unchanged') {
140
+ mkdirSync(dirname(path), { recursive: true });
141
+ atomicWriteFile(path, plan.content);
142
+ }
143
+ if (!this.jsonEnabled()) {
144
+ this.log(`${SETUP_STATUS_LABEL[status]} ${agentArg} SessionStart hook at ${path} (${scope})`);
145
+ }
146
+ return { agent: agentArg, scope, path, binCommand, status, dryRun };
147
+ }
148
+ }
@@ -3,18 +3,24 @@ import ConfigIndex from './commands/config/index.js';
3
3
  import ConfigList from './commands/config/list.js';
4
4
  import ConfigSet from './commands/config/set.js';
5
5
  import ConfigUnset from './commands/config/unset.js';
6
+ import ContextCommand from './commands/context.js';
6
7
  import FetchCommand from './commands/fetch.js';
7
8
  import ImportCommand from './commands/import.js';
8
9
  import InspectCommand from './commands/inspect.js';
9
10
  import ListCommand from './commands/list.js';
10
11
  import PruneCommand from './commands/prune.js';
12
+ import SearchCommand from './commands/search.js';
13
+ import SetupCommand from './commands/setup.js';
11
14
  import StatusCommand from './commands/status.js';
12
15
  export declare const commands: {
16
+ context: typeof ContextCommand;
13
17
  fetch: typeof FetchCommand;
14
18
  import: typeof ImportCommand;
15
19
  inspect: typeof InspectCommand;
16
20
  list: typeof ListCommand;
17
21
  prune: typeof PruneCommand;
22
+ search: typeof SearchCommand;
23
+ setup: typeof SetupCommand;
18
24
  status: typeof StatusCommand;
19
25
  config: typeof ConfigIndex;
20
26
  'config:get': typeof ConfigGet;
package/dist/commands.js CHANGED
@@ -3,18 +3,24 @@ import ConfigIndex from './commands/config/index.js';
3
3
  import ConfigList from './commands/config/list.js';
4
4
  import ConfigSet from './commands/config/set.js';
5
5
  import ConfigUnset from './commands/config/unset.js';
6
+ import ContextCommand from './commands/context.js';
6
7
  import FetchCommand from './commands/fetch.js';
7
8
  import ImportCommand from './commands/import.js';
8
9
  import InspectCommand from './commands/inspect.js';
9
10
  import ListCommand from './commands/list.js';
10
11
  import PruneCommand from './commands/prune.js';
12
+ import SearchCommand from './commands/search.js';
13
+ import SetupCommand from './commands/setup.js';
11
14
  import StatusCommand from './commands/status.js';
12
15
  export const commands = {
16
+ context: ContextCommand,
13
17
  fetch: FetchCommand,
14
18
  import: ImportCommand,
15
19
  inspect: InspectCommand,
16
20
  list: ListCommand,
17
21
  prune: PruneCommand,
22
+ search: SearchCommand,
23
+ setup: SetupCommand,
18
24
  status: StatusCommand,
19
25
  config: ConfigIndex,
20
26
  'config:get': ConfigGet,
package/dist/lib/argv.js CHANGED
@@ -99,8 +99,11 @@ export function normalizeArgv(rawArgv, options) {
99
99
  // explicit path to the command reference (all of them are non-empty argv and skip this branch).
100
100
  // oclif has no built-in "default command" concept for empty argv (see oclif/oclif#277), so this
101
101
  // is a one-line argv rewrite, the same technique the URL-shorthand rewrite below already uses.
102
+ // The hidden `--identity` flag (AXI principle 10) tells `list` to also identify the tool (bin
103
+ // path, one-sentence description) before the live data — only the true bare invocation gets it,
104
+ // not an explicit `bonsai list`.
102
105
  if (rawArgv.length === 0) {
103
- return { argv: ['list'] };
106
+ return { argv: ['list', '--identity'] };
104
107
  }
105
108
  // oclif's JSON flag is command-scoped, so `bonsai list --json` works but
106
109
  // `bonsai --json list` is otherwise parsed as an unknown command named
@@ -1,3 +1,4 @@
1
+ import type { TokenEstimate } from './research/schema.js';
1
2
  /** Shared flag/help copy so identical flags read identically across commands. */
2
3
  export declare const CLI_FLAG_DESCRIPTIONS: {
3
4
  readonly freshnessTierPolicy: "freshness tier policy";
@@ -21,6 +22,10 @@ export declare const CLI_FLAG_DESCRIPTIONS: {
21
22
  readonly listArtifactType: "artifact type; section children are omitted from list - use inspect to see them";
22
23
  readonly pruneArtifactType: "artifact type to prune, including section children";
23
24
  readonly listFull: "include every metadata field (cache key, path, artifact type, tags, capture method, quality notes, timestamps); default output is a minimal row (source URLs, topic, freshness, token estimate)";
25
+ readonly searchQuery: "keyword(s) to search across topic, tags, summary, and compressed content (case-insensitive); every term must match somewhere unless --match-any is set";
26
+ readonly searchMatchAny: "match any query term instead of requiring all of them (OR instead of AND)";
27
+ readonly searchArtifactType: "artifact type; section children are omitted from search - use inspect to see them";
28
+ readonly searchFull: "include every metadata field (cache key, path, artifact type, tags, capture method, quality notes, timestamps) alongside score/matchedFields/snippet; default output is a minimal row (source URLs, topic, freshness, token estimate, score, matchedFields, snippet)";
24
29
  };
25
30
  /** One human-readable label/value pair for CLI stdout. */
26
31
  export type HumanField = readonly [label: string, value: string];
@@ -39,3 +44,17 @@ export declare function formatHumanField(label: string, value: string): string;
39
44
  * @returns Formatted lines in the same order.
40
45
  */
41
46
  export declare function formatHumanFields(fields: readonly HumanField[]): string[];
47
+ /**
48
+ * The "N. [Topic] Key: <key>" header line shared by `list`/`search` human-mode row rendering —
49
+ * same topic coloring, ANSI-sanitization, and "(no topic)" fallback either way.
50
+ *
51
+ * @param index - Zero-based row index; displayed as `index + 1`.
52
+ * @param topic - The row's topic, or `null` to show {@link NO_TOPIC_LABEL}.
53
+ * @param cacheKey - The row's cache key.
54
+ * @returns A single formatted line.
55
+ */
56
+ export declare function formatResultRowHeader(index: number, topic: string | null, cacheKey: string): string;
57
+ /** The "Tokens: compressed=N, detailed=N" line shared by `list`/`search` human-mode row rendering. */
58
+ export declare function formatResultRowTokens(tokenEstimate: TokenEstimate): string;
59
+ /** The "Source URLs: ..." line shared by `list`/`search` human-mode row rendering. */
60
+ export declare function formatResultRowSourceUrls(sourceUrls: readonly string[]): string;
@@ -1,5 +1,6 @@
1
1
  import { colors } from './color.js';
2
2
  import { MAX_TAG_LENGTH, MAX_TOPIC_LENGTH } from './research/metadata-flags.js';
3
+ import { NO_TOPIC_LABEL, sanitizeForTerminal } from './text.js';
3
4
  const HUMAN_LABEL_WIDTH = 25;
4
5
  // Repeated verbatim across several flag descriptions; single-use wording lives inline below.
5
6
  const DURATION_EXAMPLES = 'e.g. "2h", "7d", "6m"';
@@ -27,6 +28,10 @@ export const CLI_FLAG_DESCRIPTIONS = {
27
28
  listArtifactType: 'artifact type; section children are omitted from list - use inspect to see them',
28
29
  pruneArtifactType: 'artifact type to prune, including section children',
29
30
  listFull: 'include every metadata field (cache key, path, artifact type, tags, capture method, quality notes, timestamps); default output is a minimal row (source URLs, topic, freshness, token estimate)',
31
+ searchQuery: 'keyword(s) to search across topic, tags, summary, and compressed content (case-insensitive); every term must match somewhere unless --match-any is set',
32
+ searchMatchAny: 'match any query term instead of requiring all of them (OR instead of AND)',
33
+ searchArtifactType: 'artifact type; section children are omitted from search - use inspect to see them',
34
+ searchFull: 'include every metadata field (cache key, path, artifact type, tags, capture method, quality notes, timestamps) alongside score/matchedFields/snippet; default output is a minimal row (source URLs, topic, freshness, token estimate, score, matchedFields, snippet)',
30
35
  };
31
36
  function formatHumanLabel(label) {
32
37
  const text = `${label}:`;
@@ -51,3 +56,25 @@ export function formatHumanField(label, value) {
51
56
  export function formatHumanFields(fields) {
52
57
  return fields.map(([label, value]) => formatHumanField(label, value));
53
58
  }
59
+ /**
60
+ * The "N. [Topic] Key: <key>" header line shared by `list`/`search` human-mode row rendering —
61
+ * same topic coloring, ANSI-sanitization, and "(no topic)" fallback either way.
62
+ *
63
+ * @param index - Zero-based row index; displayed as `index + 1`.
64
+ * @param topic - The row's topic, or `null` to show {@link NO_TOPIC_LABEL}.
65
+ * @param cacheKey - The row's cache key.
66
+ * @returns A single formatted line.
67
+ */
68
+ export function formatResultRowHeader(index, topic, cacheKey) {
69
+ const topicStr = topic ? colors.cyan(sanitizeForTerminal(topic)) : colors.gray(NO_TOPIC_LABEL);
70
+ const keyStr = colors.bold(cacheKey);
71
+ return `${index + 1}. [${topicStr}] Key: ${keyStr}`;
72
+ }
73
+ /** The "Tokens: compressed=N, detailed=N" line shared by `list`/`search` human-mode row rendering. */
74
+ export function formatResultRowTokens(tokenEstimate) {
75
+ return ` Tokens: compressed=${colors.bold(String(tokenEstimate?.compressed || 0))}, detailed=${colors.bold(String(tokenEstimate?.detailed || 0))}`;
76
+ }
77
+ /** The "Source URLs: ..." line shared by `list`/`search` human-mode row rendering. */
78
+ export function formatResultRowSourceUrls(sourceUrls) {
79
+ return ` Source URLs: ${colors.gray(sourceUrls.join(', '))}\n`;
80
+ }
@@ -1,5 +1,6 @@
1
1
  /** Shared machine-facing write/result shapes for CLI JSON envelopes. */
2
2
  import type { ArtifactType, CaptureMethod, ResearchArtifactMetadata, TokenEstimate } from './research/schema.js';
3
+ import type { SearchMatchField } from './research/search-match.js';
3
4
  /** Result of `config set` / `config unset` for the JSON envelope. */
4
5
  export interface ConfigWriteResult {
5
6
  key: string;
@@ -71,6 +72,36 @@ export interface ListSummary {
71
72
  /** Explicit signal for `total === 0`, so an empty `data: []` is never ambiguous. */
72
73
  empty: boolean;
73
74
  byFreshness: Record<ListFreshness, number>;
75
+ /**
76
+ * A copy-pasteable next invocation that reproduces the caller's own filters with a raised
77
+ * `--limit`, so a truncated result never leaves an agent to guess the syntax for "show me more."
78
+ * `null` when `truncated` is false.
79
+ */
80
+ nextCommand: string | null;
81
+ }
82
+ /** One row in `search --json` output when `--full` is passed. */
83
+ export interface SearchRow extends ListRow {
84
+ /** Relevance score (0 when `--query` was omitted); see `search-match.ts` for the weighting. */
85
+ score: number;
86
+ /** Fields the query matched against; empty when `--query` was omitted. */
87
+ matchedFields: SearchMatchField[];
88
+ /** Excerpt around the first content match, or `null` when only topic/tags matched (or no query). */
89
+ snippet: string | null;
90
+ }
91
+ /**
92
+ * Default `search --json`/`--toon` row: enough to judge relevance (score, which fields matched, a
93
+ * short content excerpt) without the full metadata dump. Pass `--full` for every field on
94
+ * {@link SearchRow}.
95
+ */
96
+ export interface SearchRowMinimal extends ListRowMinimal {
97
+ score: number;
98
+ matchedFields: SearchMatchField[];
99
+ snippet: string | null;
100
+ }
101
+ /** Aggregate counts attached to `search`'s envelope so agents skip extra round trips. */
102
+ export interface SearchSummary extends ListSummary {
103
+ /** Whether `--query` was passed; when false every row scores 0 and results are recency-sorted. */
104
+ queried: boolean;
74
105
  }
75
106
  /** Section child summary nested under an inspect hit. */
76
107
  export interface InspectSectionRow {
@@ -110,3 +141,31 @@ export interface StatusRow {
110
141
  freshness: FreshnessState;
111
142
  action: 'would_fetch' | 'would_revalidate' | 'would_return_cached';
112
143
  }
144
+ /** One entry in the `context` dashboard's recency-ordered preview. */
145
+ export interface ContextDashboardEntry {
146
+ topic: string | null;
147
+ sourceUrls: string[];
148
+ freshness: ListFreshness;
149
+ }
150
+ /**
151
+ * Directory-scoped cache summary for `context` (AXI principle 7: ambient session-start context).
152
+ * `entries` is capped to a small preview; `total`/`byFreshness` always cover every matched
153
+ * artifact so the cap never hides the true count.
154
+ */
155
+ export interface ContextDashboard {
156
+ total: number;
157
+ byFreshness: Record<ListFreshness, number>;
158
+ shown: number;
159
+ entries: ContextDashboardEntry[];
160
+ }
161
+ /** Agents `setup` knows how to install a SessionStart hook for. */
162
+ export type SetupAgent = 'claude-code' | 'codex';
163
+ /** Result of `setup <agent>` for the JSON envelope. */
164
+ export interface SetupResult {
165
+ agent: SetupAgent;
166
+ scope: 'user' | 'project';
167
+ path: string;
168
+ binCommand: string;
169
+ status: 'installed' | 'repaired' | 'unchanged' | 'would_install' | 'would_repair';
170
+ dryRun: boolean;
171
+ }
@@ -18,3 +18,5 @@ export declare const colors: {
18
18
  gray: (t: string) => string;
19
19
  magenta: (t: string) => string;
20
20
  };
21
+ /** Shared fresh/stale_grace/stale_expired → color mapping for `list` and `context`'s human output. */
22
+ export declare const FRESHNESS_COLOR: Record<'fresh' | 'stale_grace' | 'stale_expired', (t: string) => string>;
package/dist/lib/color.js CHANGED
@@ -34,3 +34,9 @@ export const colors = {
34
34
  gray: (t) => color(t, '\x1b[90m'),
35
35
  magenta: (t) => color(t, '\x1b[35m'),
36
36
  };
37
+ /** Shared fresh/stale_grace/stale_expired → color mapping for `list` and `context`'s human output. */
38
+ export const FRESHNESS_COLOR = {
39
+ fresh: colors.green,
40
+ stale_grace: colors.yellow,
41
+ stale_expired: colors.red,
42
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Metadata filter flags shared by `list` and `search` (topic/tags/url/freshness/artifact
3
+ * type/capture method) — single source of truth so the two commands' flag definitions can never
4
+ * drift apart. `artifactTypeDescription` is the one piece that differs: each command names itself
5
+ * in its own `--artifact-type` help text (`list`/`search` currently, via `CLI_FLAG_DESCRIPTIONS`).
6
+ */
7
+ export declare function commonMetadataFilterFlags(artifactTypeDescription: string): {
8
+ topic: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ tags: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ url: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ freshness: import("@oclif/core/interfaces").OptionFlag<"fresh" | "stale_grace" | "stale_expired" | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ 'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | "index" | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ 'capture-method': import("@oclif/core/interfaces").OptionFlag<"static_fetch" | "browser_fallback" | "agent_supplied" | "route_markdown" | "github_source" | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ };
@@ -0,0 +1,37 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { CLI_FLAG_DESCRIPTIONS } from './cli-presentation.js';
3
+ import { CAPTURE_METHODS, PAGE_LEVEL_ARTIFACT_TYPES } from './research/schema.js';
4
+ /**
5
+ * Metadata filter flags shared by `list` and `search` (topic/tags/url/freshness/artifact
6
+ * type/capture method) — single source of truth so the two commands' flag definitions can never
7
+ * drift apart. `artifactTypeDescription` is the one piece that differs: each command names itself
8
+ * in its own `--artifact-type` help text (`list`/`search` currently, via `CLI_FLAG_DESCRIPTIONS`).
9
+ */
10
+ export function commonMetadataFilterFlags(artifactTypeDescription) {
11
+ return {
12
+ topic: Flags.string({
13
+ char: 't',
14
+ description: CLI_FLAG_DESCRIPTIONS.filterTopic,
15
+ }),
16
+ tags: Flags.string({
17
+ char: 'g',
18
+ description: CLI_FLAG_DESCRIPTIONS.filterTags,
19
+ multiple: true,
20
+ }),
21
+ url: Flags.string({
22
+ description: CLI_FLAG_DESCRIPTIONS.sourceUrlGlob,
23
+ }),
24
+ freshness: Flags.option({
25
+ description: 'freshness state',
26
+ options: ['fresh', 'stale_grace', 'stale_expired'],
27
+ })(),
28
+ 'artifact-type': Flags.option({
29
+ description: artifactTypeDescription,
30
+ options: PAGE_LEVEL_ARTIFACT_TYPES,
31
+ })(),
32
+ 'capture-method': Flags.option({
33
+ description: 'capture method',
34
+ options: CAPTURE_METHODS,
35
+ })(),
36
+ };
37
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Builds a suggested next CLI invocation for a truncated `list`/`search` result — a copy-pasteable
3
+ * command that reproduces the caller's own filters with a raised `--limit`, so the caller never has
4
+ * to guess the syntax for "show me more." Surfaced as `summary.nextCommand` in the JSON/TOON
5
+ * envelope and as a human-mode tip.
6
+ */
7
+ /**
8
+ * @param bin - The CLI's own binary name (`this.config.bin`).
9
+ * @param commandId - The command to suggest (`list` or `search`).
10
+ * @param argv - The current invocation's raw argv (`this.argv`), reused as-is so the suggestion can
11
+ * never drift from whatever filters/flags the caller actually passed.
12
+ * @param newLimit - The `--limit` value to suggest.
13
+ * @returns A copy-pasteable shell command string.
14
+ */
15
+ export declare function buildNextLimitCommand(bin: string, commandId: string, argv: readonly string[], newLimit: number): string;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Builds a suggested next CLI invocation for a truncated `list`/`search` result — a copy-pasteable
3
+ * command that reproduces the caller's own filters with a raised `--limit`, so the caller never has
4
+ * to guess the syntax for "show me more." Surfaced as `summary.nextCommand` in the JSON/TOON
5
+ * envelope and as a human-mode tip.
6
+ */
7
+ // Never valid to suggest back to a caller: `--limit` is being replaced by the new value, and
8
+ // `--identity` is a hidden, undocumented flag only ever injected by the bare-invocation argv
9
+ // rewrite (see argv.ts) — carrying it into a suggested command would leak an internal detail.
10
+ const DROPPED_FLAGS = new Set(['--limit', '--identity']);
11
+ // Tokens made only of these characters are safe to print bare in a shell command; anything else
12
+ // (spaces, quotes, glob patterns like `*`) is double-quoted so the suggestion stays copy-pasteable.
13
+ const SAFE_BARE_TOKEN = /^[A-Za-z0-9_@%+.:,/-]+$/;
14
+ function quoteShellArgIfNeeded(token) {
15
+ if (SAFE_BARE_TOKEN.test(token))
16
+ return token;
17
+ return `"${token.replace(/(["\\$`])/g, '\\$1')}"`;
18
+ }
19
+ /**
20
+ * @param bin - The CLI's own binary name (`this.config.bin`).
21
+ * @param commandId - The command to suggest (`list` or `search`).
22
+ * @param argv - The current invocation's raw argv (`this.argv`), reused as-is so the suggestion can
23
+ * never drift from whatever filters/flags the caller actually passed.
24
+ * @param newLimit - The `--limit` value to suggest.
25
+ * @returns A copy-pasteable shell command string.
26
+ */
27
+ export function buildNextLimitCommand(bin, commandId, argv, newLimit) {
28
+ const kept = [];
29
+ for (let i = 0; i < argv.length; i++) {
30
+ const token = argv[i];
31
+ if (token === '--limit') {
32
+ i++; // also drop its value
33
+ continue;
34
+ }
35
+ if (token.startsWith('--limit=') || DROPPED_FLAGS.has(token))
36
+ continue;
37
+ kept.push(quoteShellArgIfNeeded(token));
38
+ }
39
+ return [bin, commandId, ...kept, '--limit', String(newLimit)].join(' ');
40
+ }
@@ -0,0 +1,19 @@
1
+ import type { ContextDashboard } from '../cli-result-types.js';
2
+ /** Entries beyond this count are still counted in `total`/`byFreshness`, just not listed. */
3
+ export declare const CONTEXT_DASHBOARD_DEFAULT_LIMIT = 5;
4
+ /**
5
+ * Builds the directory-scoped cache summary shown by `bonsai context` and injected by the
6
+ * `setup`-installed SessionStart hooks (AXI principle 7: ambient session-start context). Reuses
7
+ * the same scan/freshness primitives as `list` so the two commands can never disagree about what
8
+ * "active" or "fresh" means; unlike `list`, this applies no filters and always includes every
9
+ * matched artifact in `total`/`byFreshness`, previewing only the most recent `limit` in `entries`.
10
+ *
11
+ * @param readRoots - Data dirs to scan, in lookup order (see {@link loadStoreRoots}).
12
+ * @param currentTime - Clock used for freshness evaluation.
13
+ * @param options.limit - Max entries to preview; the count itself is never truncated.
14
+ * @param options.persistIndex - Whether to persist the search-index sidecar (false under read-only).
15
+ */
16
+ export declare function buildContextDashboard(readRoots: string[], currentTime: Date, options?: {
17
+ limit?: number;
18
+ persistIndex?: boolean;
19
+ }): ContextDashboard;
@@ -0,0 +1,43 @@
1
+ import { scanCacheDirs } from './storage.js';
2
+ import { evaluateFreshness } from './freshness.js';
3
+ /** Entries beyond this count are still counted in `total`/`byFreshness`, just not listed. */
4
+ export const CONTEXT_DASHBOARD_DEFAULT_LIMIT = 5;
5
+ /**
6
+ * Builds the directory-scoped cache summary shown by `bonsai context` and injected by the
7
+ * `setup`-installed SessionStart hooks (AXI principle 7: ambient session-start context). Reuses
8
+ * the same scan/freshness primitives as `list` so the two commands can never disagree about what
9
+ * "active" or "fresh" means; unlike `list`, this applies no filters and always includes every
10
+ * matched artifact in `total`/`byFreshness`, previewing only the most recent `limit` in `entries`.
11
+ *
12
+ * @param readRoots - Data dirs to scan, in lookup order (see {@link loadStoreRoots}).
13
+ * @param currentTime - Clock used for freshness evaluation.
14
+ * @param options.limit - Max entries to preview; the count itself is never truncated.
15
+ * @param options.persistIndex - Whether to persist the search-index sidecar (false under read-only).
16
+ */
17
+ export function buildContextDashboard(readRoots, currentTime, options = {}) {
18
+ const limit = options.limit ?? CONTEXT_DASHBOARD_DEFAULT_LIMIT;
19
+ const rows = scanCacheDirs(readRoots, (artifact) => {
20
+ if (artifact.metadata.status !== 'active')
21
+ return null;
22
+ // Section children are sub-chunks of a page, not something a dashboard should list on its
23
+ // own — same exclusion `list` applies (see list.ts's scanCacheDirForList).
24
+ if (artifact.metadata.artifact_type === 'section')
25
+ return null;
26
+ return {
27
+ topic: artifact.metadata.topic,
28
+ sourceUrls: artifact.metadata.source_urls,
29
+ freshness: evaluateFreshness(artifact.metadata, currentTime, null),
30
+ recencyKey: artifact.metadata.validated_at ?? artifact.metadata.fetched_at ?? null,
31
+ };
32
+ }, { persistIndex: options.persistIndex ?? true });
33
+ rows.sort((a, b) => new Date(b.recencyKey ?? 0).getTime() - new Date(a.recencyKey ?? 0).getTime());
34
+ const byFreshness = { fresh: 0, stale_grace: 0, stale_expired: 0 };
35
+ for (const row of rows)
36
+ byFreshness[row.freshness]++;
37
+ const entries = rows.slice(0, limit).map((row) => ({
38
+ topic: row.topic,
39
+ sourceUrls: row.sourceUrls,
40
+ freshness: row.freshness,
41
+ }));
42
+ return { total: rows.length, byFreshness, shown: entries.length, entries };
43
+ }
@@ -343,6 +343,16 @@ function logHumanFetchResult(input) {
343
343
  io.warn(`${normalizedUrl}: ${warning}`);
344
344
  }
345
345
  io.log(resultData.content);
346
+ // AXI content-truncation: only nudge toward the escape hatch when something was actually
347
+ // condensed — `detailedTokenEstimate` equals `tokenEstimate` whenever `format` is already
348
+ // `detailed`, or when compression happened to produce no smaller result.
349
+ if (resultData.format === 'compressed' &&
350
+ typeof resultData.tokenEstimate === 'number' &&
351
+ typeof resultData.detailedTokenEstimate === 'number' &&
352
+ resultData.detailedTokenEstimate > resultData.tokenEstimate) {
353
+ io.warn(formatTip(`${io.bin} ${normalizedUrl} --format detailed for the full ${resultData.detailedTokenEstimate}-token ` +
354
+ `version (currently showing ${resultData.tokenEstimate}).`));
355
+ }
346
356
  if (!dryRun) {
347
357
  io.warn(formatTip(`${io.bin} inspect ${normalizedUrl} for cached metadata.`));
348
358
  }
@@ -57,6 +57,7 @@ export declare function buildFetchResultData(input: FetchResultInput): {
57
57
  searchProvider: string | null;
58
58
  format: "compressed" | "detailed";
59
59
  tokenEstimate: number | null;
60
+ detailedTokenEstimate: number | null;
60
61
  content: string;
61
62
  };
62
63
  export interface FetchFailureGuidance {
@@ -75,6 +75,10 @@ export function buildFetchResultData(input) {
75
75
  tokenEstimate: format === 'compressed'
76
76
  ? artifact.metadata.token_estimate.compressed
77
77
  : artifact.metadata.token_estimate.detailed,
78
+ // Always the true detailed count, even when `format` is `compressed` (AXI content-truncation
79
+ // principle: show the total size so the agent knows how much a `--format detailed` re-fetch
80
+ // would add, without a second round trip). Equals `tokenEstimate` when nothing was truncated.
81
+ detailedTokenEstimate: artifact.metadata.token_estimate.detailed,
78
82
  content,
79
83
  };
80
84
  }
@@ -0,0 +1,15 @@
1
+ import type { ListFreshness, ListRow } from '../cli-result-types.js';
2
+ import type { ResearchArtifact } from './schema.js';
3
+ /**
4
+ * Maps a parsed artifact to the shared page-level row shape (`ListRow`) that `list` and `search`
5
+ * both return — single source of truth so the two commands' JSON row schemas can never drift on
6
+ * which metadata fields a "row" exposes.
7
+ */
8
+ export declare function toListRow(artifact: ResearchArtifact, filePath: string, freshness: ListRow['freshness']): ListRow;
9
+ /**
10
+ * Counts rows per freshness tier for the `list`/`search` envelope's `summary.byFreshness` — single
11
+ * source of truth so the two commands' aggregate always agree on the shape and the counting logic.
12
+ */
13
+ export declare function countByFreshness(rows: readonly {
14
+ freshness: ListFreshness;
15
+ }[]): Record<ListFreshness, number>;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Maps a parsed artifact to the shared page-level row shape (`ListRow`) that `list` and `search`
3
+ * both return — single source of truth so the two commands' JSON row schemas can never drift on
4
+ * which metadata fields a "row" exposes.
5
+ */
6
+ export function toListRow(artifact, filePath, freshness) {
7
+ return {
8
+ cacheKey: artifact.metadata.cache_key,
9
+ path: filePath,
10
+ artifactType: artifact.metadata.artifact_type,
11
+ sourceUrls: artifact.metadata.source_urls,
12
+ topic: artifact.metadata.topic,
13
+ tags: artifact.metadata.tags,
14
+ freshness,
15
+ captureMethod: artifact.metadata.capture_method,
16
+ tokenEstimate: artifact.metadata.token_estimate,
17
+ qualityNotes: artifact.metadata.quality_notes,
18
+ fetchedAt: artifact.metadata.fetched_at,
19
+ validatedAt: artifact.metadata.validated_at,
20
+ };
21
+ }
22
+ /**
23
+ * Counts rows per freshness tier for the `list`/`search` envelope's `summary.byFreshness` — single
24
+ * source of truth so the two commands' aggregate always agree on the shape and the counting logic.
25
+ */
26
+ export function countByFreshness(rows) {
27
+ const counts = { fresh: 0, stale_grace: 0, stale_expired: 0 };
28
+ for (const row of rows)
29
+ counts[row.freshness]++;
30
+ return counts;
31
+ }