@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.
- package/README.md +24 -3
- package/agents/agents/official-docs-researcher.agent.md +102 -0
- package/agents/agents/salesforce-docs-researcher.agent.md +16 -14
- package/agents/hooks/README.md +18 -2
- package/agents/hooks/claude-code/hooks/bonsai-web-fetch.sh +1 -1
- package/agents/hooks/codex/hooks/bonsai-user-prompt-context.sh +1 -1
- package/agents/hooks/codex/hooks/bonsai-web-fetch.sh +1 -1
- package/agents/hooks/cursor/hooks/bonsai-web-fetch.sh +1 -1
- package/agents/hooks/github-copilot/bonsai-web-fetch.sh +1 -1
- package/agents/instructions/salesforce-research.instructions.md +5 -4
- package/agents/instructions/web-research.instructions.md +45 -0
- package/agents/skills/web-research/SKILL.md +139 -0
- package/bin/cli.mjs +0 -11
- package/dist/base-command.d.ts +5 -0
- package/dist/base-command.js +9 -0
- package/dist/commands/config/get.d.ts +1 -1
- package/dist/commands/config/get.js +1 -1
- package/dist/commands/config/list.d.ts +1 -1
- package/dist/commands/config/list.js +1 -1
- package/dist/commands/config/set.d.ts +1 -1
- package/dist/commands/config/set.js +1 -1
- package/dist/commands/config/unset.d.ts +1 -1
- package/dist/commands/config/unset.js +1 -1
- package/dist/commands/context.d.ts +20 -0
- package/dist/commands/context.js +65 -0
- package/dist/commands/list.d.ts +12 -3
- package/dist/commands/list.js +61 -100
- package/dist/commands/search.d.ts +43 -0
- package/dist/commands/search.js +233 -0
- package/dist/commands/setup.d.ts +27 -0
- package/dist/commands/setup.js +148 -0
- package/dist/commands.d.ts +6 -0
- package/dist/commands.js +6 -0
- package/dist/lib/argv.js +4 -1
- package/dist/lib/cli-flag-manifest.d.ts +2 -2
- package/dist/lib/cli-flag-manifest.js +15 -7
- package/dist/lib/cli-presentation.d.ts +19 -0
- package/dist/lib/cli-presentation.js +27 -0
- package/dist/lib/cli-result-types.d.ts +59 -0
- package/dist/lib/color.d.ts +2 -0
- package/dist/lib/color.js +6 -0
- package/dist/lib/common-metadata-filter-flags.d.ts +14 -0
- package/dist/lib/common-metadata-filter-flags.js +37 -0
- package/dist/{commands/config/base.d.ts → lib/config-command.d.ts} +7 -9
- package/dist/{commands/config/base.js → lib/config-command.js} +9 -20
- package/dist/lib/next-command.d.ts +15 -0
- package/dist/lib/next-command.js +40 -0
- package/dist/lib/research/context-dashboard.d.ts +19 -0
- package/dist/lib/research/context-dashboard.js +43 -0
- package/dist/lib/research/fetch-command-service.js +10 -0
- package/dist/lib/research/fetch-result.d.ts +1 -0
- package/dist/lib/research/fetch-result.js +4 -0
- package/dist/lib/research/fetcher.d.ts +11 -0
- package/dist/lib/research/fetcher.js +26 -0
- package/dist/lib/research/list-row.d.ts +15 -0
- package/dist/lib/research/list-row.js +31 -0
- package/dist/lib/research/metadata-filters.d.ts +22 -1
- package/dist/lib/research/metadata-filters.js +34 -0
- package/dist/lib/research/regression.js +6 -1
- package/dist/lib/research/schema.d.ts +2 -0
- package/dist/lib/research/schema.js +4 -0
- package/dist/lib/research/search-match.d.ts +39 -0
- package/dist/lib/research/search-match.js +134 -0
- package/dist/lib/scope-flags.d.ts +19 -0
- package/dist/lib/scope-flags.js +27 -0
- package/dist/lib/setup/agents.d.ts +16 -0
- package/dist/lib/setup/agents.js +20 -0
- package/dist/lib/setup/bin-command.d.ts +14 -0
- package/dist/lib/setup/bin-command.js +42 -0
- package/dist/lib/setup/hook-file-io.d.ts +37 -0
- package/dist/lib/setup/hook-file-io.js +80 -0
- package/dist/lib/text.d.ts +6 -0
- package/dist/lib/text.js +11 -0
- package/dist/lib/value-flags.d.ts +2 -0
- package/dist/lib/value-flags.js +11 -3
- package/dist/sites/salesforce-developer/fetch-page.d.ts +1 -1
- package/dist/sites/salesforce-developer/fetch-page.js +3 -1
- package/dist/sites/salesforce-developer/route-markdown.d.ts +3 -2
- package/dist/sites/salesforce-developer/route-markdown.js +8 -6
- package/dist/sites/salesforce-doc-fetch.d.ts +23 -11
- package/dist/sites/salesforce-doc-fetch.js +89 -27
- package/dist/sites/salesforce-dom-probe.d.ts +6 -0
- package/dist/sites/salesforce-dom-probe.js +40 -0
- package/oclif.manifest.json +428 -54
- 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
|
+
}
|
package/dist/lib/color.d.ts
CHANGED
|
@@ -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 '
|
|
3
|
-
import type { ConfigKey, ConfigScope } from '
|
|
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
|
|
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 {
|
|
2
|
-
import { BaseCommand } from '
|
|
3
|
-
import { isKnownKey, suggestKey, validKeysHint } from '
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|