@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,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
+ }
@@ -1,9 +1,13 @@
1
1
  import { Command } from '@oclif/core';
2
- import { BaseCommand } from '../../base-command.js';
3
- import type { ConfigKey, ConfigScope } from '../../lib/config/index.js';
2
+ import { BaseCommand } from '../base-command.js';
3
+ import type { ConfigKey, ConfigScope } from './config/index.js';
4
+ import { scopeFlags } from './scope-flags.js';
4
5
  /**
5
6
  * Shared base for the four `config` subcommands: the mutually-exclusive
6
7
  * scope-flag guard, the binary write-scope helper, and known-key validation.
8
+ *
9
+ * Lives outside `src/commands` so oclif's file-based command discovery does not
10
+ * publish it as `config base`.
7
11
  */
8
12
  export declare abstract class ConfigCommand<T extends typeof Command> extends BaseCommand<T> {
9
13
  protected assertScopeFlagsExclusive(global?: boolean, local?: boolean): void;
@@ -16,10 +20,4 @@ export declare abstract class ConfigCommand<T extends typeof Command> extends Ba
16
20
  protected validateConfigKeyAndScope(key: string | undefined, global?: boolean, local?: boolean): asserts key is ConfigKey;
17
21
  }
18
22
  /** Build the `--global`/`--local` scope-flag pair with per-command descriptions. */
19
- export declare function configScopeFlags(descriptions: {
20
- global: string;
21
- local: string;
22
- }): {
23
- global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
24
- local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
25
- };
23
+ export declare const configScopeFlags: typeof scopeFlags;
@@ -1,19 +1,17 @@
1
- import { Flags, toConfiguredId } from '@oclif/core';
2
- import { BaseCommand } from '../../base-command.js';
3
- import { isKnownKey, suggestKey, validKeysHint } from '../../lib/config/index.js';
1
+ import { toConfiguredId } from '@oclif/core';
2
+ import { BaseCommand } from '../base-command.js';
3
+ import { isKnownKey, suggestKey, validKeysHint } from './config/index.js';
4
+ import { assertScopeFlagsExclusive, scopeFlags } from './scope-flags.js';
4
5
  /**
5
6
  * Shared base for the four `config` subcommands: the mutually-exclusive
6
7
  * scope-flag guard, the binary write-scope helper, and known-key validation.
8
+ *
9
+ * Lives outside `src/commands` so oclif's file-based command discovery does not
10
+ * publish it as `config base`.
7
11
  */
8
12
  export class ConfigCommand extends BaseCommand {
9
13
  assertScopeFlagsExclusive(global, local) {
10
- if (global && local) {
11
- this.error('--global and --local are mutually exclusive.', {
12
- exit: 2,
13
- code: 'CONFLICTING_FLAGS',
14
- suggestions: ['Pass --global or --local, not both.'],
15
- });
16
- }
14
+ assertScopeFlagsExclusive(this.error.bind(this), global, local);
17
15
  }
18
16
  /** `--local` writes/reads the project file; otherwise the user-level file. */
19
17
  writeScope(local) {
@@ -58,13 +56,4 @@ export class ConfigCommand extends BaseCommand {
58
56
  }
59
57
  }
60
58
  /** Build the `--global`/`--local` scope-flag pair with per-command descriptions. */
61
- export function configScopeFlags(descriptions) {
62
- return {
63
- global: Flags.boolean({ char: 'g', description: descriptions.global }),
64
- local: Flags.boolean({
65
- description: descriptions.local,
66
- aliases: ['project'],
67
- charAliases: ['p'],
68
- }),
69
- };
70
- }
59
+ export const configScopeFlags = scopeFlags;
@@ -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
  }
@@ -65,3 +65,14 @@ export declare function fetchStaticHtml(url: string, options?: FetchOptions): Pr
65
65
  * @throws {Error} On unsafe DNS, HTTP failure, or redirect limits.
66
66
  */
67
67
  export declare function fetchText(url: string, options?: FetchOptions): Promise<FetchResult>;
68
+ /**
69
+ * Same DNS, redirect, and status checks as {@link fetchText}, but the body is cancelled once the
70
+ * status line is known. Use this to reject HTTP 404 before a browser render without downloading
71
+ * the document.
72
+ *
73
+ * @param url - Absolute URL to probe.
74
+ * @param options - Optional timeout, redirect cap, and headers.
75
+ * @returns The final HTTP status.
76
+ * @throws {Error} On unsafe DNS, HTTP failure, or redirect limits.
77
+ */
78
+ export declare function fetchStatus(url: string, options?: FetchOptions): Promise<number>;
@@ -264,3 +264,29 @@ export async function fetchStaticHtml(url, options = {}) {
264
264
  export async function fetchText(url, options = {}) {
265
265
  return fetchWithRedirects(url, options, processTextResponse);
266
266
  }
267
+ /**
268
+ * Same DNS, redirect, and status checks as {@link fetchText}, but the body is cancelled once the
269
+ * status line is known. Use this to reject HTTP 404 before a browser render without downloading
270
+ * the document.
271
+ *
272
+ * @param url - Absolute URL to probe.
273
+ * @param options - Optional timeout, redirect cap, and headers.
274
+ * @returns The final HTTP status.
275
+ * @throws {Error} On unsafe DNS, HTTP failure, or redirect limits.
276
+ */
277
+ export async function fetchStatus(url, options = {}) {
278
+ const result = await fetchWithRedirects(url, options, async (res, _limit, currentUrl) => {
279
+ await res.body?.cancel().catch(() => { });
280
+ assertOk(res);
281
+ return {
282
+ status: res.status,
283
+ contentType: null,
284
+ etag: null,
285
+ lastModified: null,
286
+ finalUrl: currentUrl,
287
+ responseSize: 0,
288
+ content: '',
289
+ };
290
+ });
291
+ return result.status;
292
+ }
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import type { ResearchArtifactMetadata } from './schema.js';
1
+ import type { ArtifactType, CaptureMethod, ResearchArtifactMetadata } from './schema.js';
2
2
  /** Whether an artifact's topic matches a `--topic` filter (case-insensitive exact match). */
3
3
  export declare function matchesTopicFilter(meta: Pick<ResearchArtifactMetadata, 'topic'>, topic: string | undefined): boolean;
4
4
  /** Whether an artifact's tags satisfy a `--tags` filter (case-insensitive, must match all). */
@@ -16,3 +16,24 @@ export declare function emptyTopicFilterError(topic: string | undefined): string
16
16
  * matches any artifact's tags — silently returning zero results with no signal. Fail fast instead.
17
17
  */
18
18
  export declare function emptyTagsFilterError(tags: string[] | undefined): string | undefined;
19
+ /** The metadata filter flags `list` and `search` both expose, beyond content/topic search. */
20
+ export interface CommonMetadataFilterFlags {
21
+ topic?: string;
22
+ tags?: string[];
23
+ url?: string;
24
+ artifactType?: ArtifactType;
25
+ captureMethod?: CaptureMethod;
26
+ freshness?: 'fresh' | 'stale_grace' | 'stale_expired';
27
+ }
28
+ /**
29
+ * Shared page-level metadata filter combinator for `list` and `search` (topic/tags/url/artifact
30
+ * type/capture method/freshness) — single source of truth so the two commands can never drift on
31
+ * what "matches the given filters" means.
32
+ */
33
+ export declare function matchesCommonMetadataFilters(meta: ResearchArtifactMetadata, freshness: 'fresh' | 'stale_grace' | 'stale_expired', flags: CommonMetadataFilterFlags): boolean;
34
+ /**
35
+ * Whether any of the shared metadata filter flags was passed — used to pick empty-result guidance
36
+ * ("no matches for your filters" vs. "the cache is empty"). `search` combines this with its own
37
+ * `--query` check; `list` has no filters beyond these, so it uses the result directly.
38
+ */
39
+ export declare function hasActiveMetadataFilters(flags: CommonMetadataFilterFlags): boolean;
@@ -1,3 +1,4 @@
1
+ import { artifactMatchesUrlFilter } from './url.js';
1
2
  /** Whether an artifact's topic matches a `--topic` filter (case-insensitive exact match). */
2
3
  export function matchesTopicFilter(meta, topic) {
3
4
  if (!topic)
@@ -35,3 +36,36 @@ export function emptyTagsFilterError(tags) {
35
36
  return undefined;
36
37
  return '--tags must be non-empty values (e.g. "react").';
37
38
  }
39
+ /**
40
+ * Shared page-level metadata filter combinator for `list` and `search` (topic/tags/url/artifact
41
+ * type/capture method/freshness) — single source of truth so the two commands can never drift on
42
+ * what "matches the given filters" means.
43
+ */
44
+ export function matchesCommonMetadataFilters(meta, freshness, flags) {
45
+ if (!matchesTopicFilter(meta, flags.topic))
46
+ return false;
47
+ if (!matchesTagsFilter(meta, flags.tags))
48
+ return false;
49
+ if (flags.url && !artifactMatchesUrlFilter(meta, flags.url))
50
+ return false;
51
+ if (flags.artifactType && meta.artifact_type !== flags.artifactType)
52
+ return false;
53
+ if (flags.captureMethod && meta.capture_method !== flags.captureMethod)
54
+ return false;
55
+ if (flags.freshness && freshness !== flags.freshness)
56
+ return false;
57
+ return true;
58
+ }
59
+ /**
60
+ * Whether any of the shared metadata filter flags was passed — used to pick empty-result guidance
61
+ * ("no matches for your filters" vs. "the cache is empty"). `search` combines this with its own
62
+ * `--query` check; `list` has no filters beyond these, so it uses the result directly.
63
+ */
64
+ export function hasActiveMetadataFilters(flags) {
65
+ return Boolean(flags.topic ||
66
+ (flags.tags && flags.tags.length > 0) ||
67
+ flags.freshness ||
68
+ flags.artifactType ||
69
+ flags.captureMethod ||
70
+ flags.url);
71
+ }
@@ -117,7 +117,12 @@ const LEAKAGE_PATTERNS = [
117
117
  { label: 'base64-image', re: /data:image\/[a-z]+;base64,/i },
118
118
  { label: 'skip-to-content', re: /skip to (?:main )?content/i },
119
119
  { label: 'on-this-page', re: /^on this page$/im },
120
- { label: 'theme-toggle', re: /(?:toggle|switch to) (?:dark|light|the)? ?(?:mode|theme)/i },
120
+ // "dark"/"light" or the word "theme" is required. An optional group also matched
121
+ // "switch to mode" after inline code was stripped from "switch to `history` mode".
122
+ {
123
+ label: 'theme-toggle',
124
+ re: /(?:toggle|switch to) (?:the )?(?:dark|light)(?: mode)?|(?:toggle|switch to) (?:the )?theme/i,
125
+ },
121
126
  {
122
127
  label: 'cookie-consent',
123
128
  re: /(?:accept all cookies|we use cookies|cookie (?:policy|preferences|settings))/i,
@@ -11,6 +11,8 @@ export type CaptureMethod = (typeof CAPTURE_METHODS)[number];
11
11
  export declare const ARTIFACT_TYPES: readonly ["source", "research_note", "index", "section"];
12
12
  /** Classification of a research artifact's role. */
13
13
  export type ArtifactType = (typeof ARTIFACT_TYPES)[number];
14
+ /** Artifact types `list`/`search` report: every type except the heading-level `section` child. */
15
+ export declare const PAGE_LEVEL_ARTIFACT_TYPES: ("source" | "research_note" | "index")[];
14
16
  /** Full metadata block stored alongside every cached research artifact. */
15
17
  export interface ResearchArtifactMetadata {
16
18
  schema_version: number;
@@ -12,3 +12,7 @@ export const CAPTURE_METHODS = [
12
12
  // index; `section` = a heading-level child of a page. Command flag `options` derive from this.
13
13
  /** All recognized artifact types. `index` = navigation hub or llms.txt; `section` = heading-level child of a page. */
14
14
  export const ARTIFACT_TYPES = ['source', 'research_note', 'index', 'section'];
15
+ // Shared by `list` and `search`: both report pages, never the `section` chunks a page is split
16
+ // into (a single fetch can yield dozens) — `inspect` is where a page's sections are discoverable.
17
+ /** Artifact types `list`/`search` report: every type except the heading-level `section` child. */
18
+ export const PAGE_LEVEL_ARTIFACT_TYPES = ARTIFACT_TYPES.filter((type) => type !== 'section');
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Keyword scoring and snippet extraction for `search --query`. Matches only against fields already
3
+ * cheap to read from the search-index sidecar (topic, tags, summary, compressed) — see
4
+ * `artifact-index.ts` — so `search` never pays the cost of parsing the (often much larger)
5
+ * `detailed` body just to rank results.
6
+ */
7
+ /** A field a query term can match against, in priority order (most to least specific). */
8
+ export type SearchMatchField = 'topic' | 'tags' | 'summary' | 'compressed';
9
+ /** Fields of an artifact that `search --query` matches against. */
10
+ export interface SearchableFields {
11
+ topic: string | null;
12
+ tags: string[];
13
+ summary: string;
14
+ compressed: string;
15
+ }
16
+ /** Result of scoring one artifact against a tokenized query. */
17
+ export interface SearchMatch {
18
+ score: number;
19
+ matchedFields: SearchMatchField[];
20
+ }
21
+ /** Split a `--query` value into lowercase, deduplicated terms on whitespace. */
22
+ export declare function tokenizeSearchQuery(query: string): string[];
23
+ /**
24
+ * Reject a whitespace-only `--query` instead of silently matching nothing (an empty term list would
25
+ * either match every artifact or none, both surprising) — same reasoning as `--topic`/`--tags`/`--url`.
26
+ */
27
+ export declare function emptySearchQueryError(query: string | undefined): string | undefined;
28
+ /**
29
+ * Scores one artifact against tokenized query terms. Returns `null` when the artifact does not
30
+ * satisfy the match mode (every term must match somewhere, unless `matchAny` allows just one).
31
+ */
32
+ export declare function scoreSearchMatch(fields: SearchableFields, terms: readonly string[], matchAny: boolean): SearchMatch | null;
33
+ /**
34
+ * A short excerpt around the first query-term match in `compressed` (falling back to `summary`),
35
+ * so an agent can judge relevance from the token-cheap index alone before deciding to `inspect` or
36
+ * re-fetch the full page. Returns `null` when no term appears in either — e.g. the match was only
37
+ * against `topic`/`tags` — rather than an unrelated excerpt with no bearing on the query.
38
+ */
39
+ export declare function extractSearchSnippet(fields: Pick<SearchableFields, 'summary' | 'compressed'>, terms: readonly string[]): string | null;