@taurgis/bonsai 1.0.4 → 1.0.6
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 +81 -2
- package/agents/agents/official-docs-researcher.agent.md +10 -1
- package/agents/agents/salesforce-docs-researcher.agent.md +126 -0
- package/agents/hooks/README.md +62 -0
- package/agents/hooks/claude-code/hooks/bonsai-web-fetch.sh +22 -0
- package/agents/hooks/claude-code/settings.json +16 -0
- package/agents/hooks/codex/hooks/bonsai-user-prompt-context.sh +18 -0
- package/agents/hooks/codex/hooks/bonsai-web-fetch.sh +22 -0
- package/agents/hooks/codex/hooks.json +28 -0
- package/agents/hooks/cursor/hooks/bonsai-web-fetch.sh +21 -0
- package/agents/hooks/cursor/hooks.json +12 -0
- package/agents/hooks/github-copilot/bonsai-web-fetch.json +23 -0
- package/agents/hooks/github-copilot/bonsai-web-fetch.sh +19 -0
- package/agents/instructions/salesforce-research.instructions.md +59 -0
- package/agents/instructions/web-research.instructions.md +5 -2
- package/agents/skills/web-research/SKILL.md +8 -3
- package/bin/cli.mjs +35 -8
- package/dist/base-command.d.ts +23 -0
- package/dist/base-command.js +67 -8
- package/dist/commands/config/base.d.ts +2 -0
- package/dist/commands/config/base.js +15 -1
- package/dist/commands/config/get.d.ts +1 -1
- package/dist/commands/config/get.js +6 -7
- package/dist/commands/config/index.d.ts +12 -0
- package/dist/commands/config/index.js +34 -0
- package/dist/commands/config/list.js +4 -4
- package/dist/commands/config/set.js +20 -14
- package/dist/commands/config/unset.d.ts +1 -1
- package/dist/commands/config/unset.js +11 -8
- package/dist/commands/fetch.d.ts +5 -0
- package/dist/commands/fetch.js +122 -47
- package/dist/commands/import.d.ts +4 -0
- package/dist/commands/import.js +99 -37
- package/dist/commands/inspect.d.ts +4 -0
- package/dist/commands/inspect.js +24 -19
- package/dist/commands/list.d.ts +2 -2
- package/dist/commands/list.js +39 -16
- package/dist/commands/prune.d.ts +1 -1
- package/dist/commands/prune.js +50 -17
- package/dist/commands/search.d.ts +4 -0
- package/dist/commands/search.js +102 -41
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.js +58 -31
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +2 -0
- package/dist/hooks/command-not-found/suggest.d.ts +9 -0
- package/dist/hooks/command-not-found/suggest.js +122 -0
- package/dist/lib/argv.d.ts +10 -0
- package/dist/lib/argv.js +53 -0
- package/dist/lib/config/index.d.ts +1 -1
- package/dist/lib/config/index.js +1 -1
- package/dist/lib/config/resolve.d.ts +7 -0
- package/dist/lib/config/resolve.js +23 -0
- package/dist/lib/envelope.d.ts +36 -0
- package/dist/lib/envelope.js +76 -0
- package/dist/lib/json-meta.d.ts +10 -0
- package/dist/lib/json-meta.js +98 -0
- package/dist/lib/research/browser.d.ts +12 -0
- package/dist/lib/research/browser.js +40 -3
- package/dist/lib/research/docs/capabilities.d.ts +1 -1
- package/dist/lib/research/docs/discovery-index.d.ts +14 -0
- package/dist/lib/research/docs/discovery-index.js +112 -0
- package/dist/lib/research/docs/machine-readable.d.ts +2 -2
- package/dist/lib/research/docs/machine-readable.js +3 -7
- package/dist/lib/research/docs/remote-search-runner.js +61 -1
- package/dist/lib/research/docs/sections.d.ts +1 -0
- package/dist/lib/research/docs/sections.js +19 -1
- package/dist/lib/research/freshness.d.ts +9 -1
- package/dist/lib/research/freshness.js +24 -2
- package/dist/lib/research/keywords.d.ts +10 -3
- package/dist/lib/research/keywords.js +32 -5
- package/dist/lib/research/revalidate.js +3 -1
- package/dist/lib/research/schema.d.ts +5 -2
- package/dist/lib/research/schema.js +12 -1
- package/dist/lib/research/secret-scan.js +4 -2
- package/dist/lib/research/url.js +5 -2
- package/dist/lib/text.d.ts +35 -0
- package/dist/lib/text.js +65 -0
- package/dist/sites/algolia.d.ts +9 -0
- package/dist/sites/algolia.js +26 -0
- package/dist/sites/auto.d.ts +8 -0
- package/dist/sites/auto.js +30 -0
- package/dist/sites/index.js +17 -1
- package/dist/sites/nextjs/index.d.ts +2 -0
- package/dist/sites/nextjs/index.js +52 -0
- package/dist/sites/react/index.d.ts +2 -0
- package/dist/sites/react/index.js +17 -0
- package/dist/sites/tailwind/index.d.ts +2 -0
- package/dist/sites/tailwind/index.js +17 -0
- package/dist/sites/vue/index.d.ts +2 -0
- package/dist/sites/vue/index.js +17 -0
- package/oclif.manifest.json +154 -81
- package/package.json +24 -4
package/dist/commands/inspect.js
CHANGED
|
@@ -1,49 +1,54 @@
|
|
|
1
1
|
import { Args } from '@oclif/core';
|
|
2
2
|
import { BaseCommand } from '../base-command.js';
|
|
3
3
|
import { scanCacheDirs } from '../lib/research/storage.js';
|
|
4
|
-
import { resolveResearchTarget } from '../lib/research/resolve-target.js';
|
|
5
4
|
export default class ResearchInspect extends BaseCommand {
|
|
6
5
|
static id = 'inspect';
|
|
7
6
|
static summary = 'Inspect cached research metadata for a URL.';
|
|
8
7
|
static description = 'Inspects local storage and displays full frontmatter metadata for the URL (without printing the content body).';
|
|
8
|
+
static examples = [
|
|
9
|
+
{
|
|
10
|
+
description: 'inspect cached research metadata for a specific URL',
|
|
11
|
+
command: '<%= config.bin %> <%= command.id %> https://example.com/docs',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
description: 'output the full metadata as JSON for use by other tools',
|
|
15
|
+
command: '<%= config.bin %> <%= command.id %> https://example.com/docs --json',
|
|
16
|
+
},
|
|
17
|
+
];
|
|
9
18
|
static args = {
|
|
10
19
|
url: Args.string({
|
|
11
20
|
required: true,
|
|
12
|
-
description: '
|
|
21
|
+
description: 'the URL to inspect',
|
|
13
22
|
}),
|
|
14
23
|
};
|
|
15
24
|
static stdoutIsPrimaryData = true;
|
|
16
25
|
async run() {
|
|
17
26
|
const { url } = this.args;
|
|
18
|
-
|
|
19
|
-
try {
|
|
20
|
-
target = resolveResearchTarget({
|
|
21
|
-
configDir: this.config.configDir,
|
|
22
|
-
cwd: process.cwd(),
|
|
23
|
-
dataDir: this.config.dataDir,
|
|
24
|
-
url,
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
catch (err) {
|
|
28
|
-
this.error(`Invalid URL: ${err.message}`, { exit: 2 });
|
|
29
|
-
}
|
|
27
|
+
const target = this.resolveResearchTargetOrFail(url);
|
|
30
28
|
const { cacheKey, located, normalizedUrl, roots } = target;
|
|
31
29
|
if (!located) {
|
|
32
|
-
|
|
30
|
+
// inspect makes no network call, so the bare message reads like the network-failure case the
|
|
31
|
+
// exit-1 contract describes. Point at the fix instead: fetch the page so it lands in the cache.
|
|
32
|
+
this.error(`No cached research found for URL: ${normalizedUrl}`, {
|
|
33
|
+
exit: 1,
|
|
34
|
+
code: 'CACHE_MISS',
|
|
35
|
+
suggestions: [`Fetch and cache it first: ${this.config.bin} ${normalizedUrl}`],
|
|
36
|
+
});
|
|
33
37
|
}
|
|
34
38
|
const cached = located.artifact;
|
|
35
39
|
const artifactPath = located.path;
|
|
36
40
|
const sections = this.findSections(roots.readRoots, cacheKey);
|
|
37
41
|
if (!this.jsonEnabled()) {
|
|
38
|
-
this.log(
|
|
39
|
-
this.log(
|
|
42
|
+
this.log(`${'Cache Key:'.padEnd(25)} ${cacheKey}`);
|
|
43
|
+
this.log(`${'Cache Path:'.padEnd(25)} ${artifactPath}`);
|
|
40
44
|
this.log(`--- Metadata ---`);
|
|
41
45
|
for (const [key, val] of Object.entries(cached.metadata)) {
|
|
46
|
+
const paddedKey = (key + ':').padEnd(Math.max(25, key.length + 2));
|
|
42
47
|
if (typeof val === 'object' && val !== null) {
|
|
43
|
-
this.log(`${
|
|
48
|
+
this.log(`${paddedKey} ${JSON.stringify(val)}`);
|
|
44
49
|
}
|
|
45
50
|
else {
|
|
46
|
-
this.log(`${
|
|
51
|
+
this.log(`${paddedKey} ${val}`);
|
|
47
52
|
}
|
|
48
53
|
}
|
|
49
54
|
if (sections.length) {
|
package/dist/commands/list.d.ts
CHANGED
|
@@ -11,8 +11,8 @@ export default class ResearchList extends BaseCommand<typeof ResearchList> {
|
|
|
11
11
|
topic: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
12
|
tags: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
13
|
freshness: import("@oclif/core/interfaces").OptionFlag<"fresh" | "stale_grace" | "stale_expired" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
-
'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
-
'capture-method': import("@oclif/core/interfaces").OptionFlag<"static_fetch" | "browser_fallback" | "agent_supplied" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | "index" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
'capture-method': import("@oclif/core/interfaces").OptionFlag<"static_fetch" | "browser_fallback" | "agent_supplied" | "route_markdown" | "github_source" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
16
|
limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
17
|
};
|
|
18
18
|
static stdoutIsPrimaryData: boolean;
|
package/dist/commands/list.js
CHANGED
|
@@ -3,48 +3,55 @@ import { BaseCommand } from '../base-command.js';
|
|
|
3
3
|
import { scanCacheDirs } from '../lib/research/storage.js';
|
|
4
4
|
import { loadStoreRoots } from '../lib/research/store-roots.js';
|
|
5
5
|
import { evaluateFreshness } from '../lib/research/freshness.js';
|
|
6
|
+
import { ARTIFACT_TYPES, CAPTURE_METHODS } from '../lib/research/schema.js';
|
|
7
|
+
import { NO_TOPIC_LABEL, resultListHeading, truncationNotice, } from '../lib/text.js';
|
|
8
|
+
// Listings are ordered newest-first, so the truncation word is "first"; --limit caps at 100.
|
|
9
|
+
const LIST_LABELS = { noun: 'cached research', order: 'first', maxLimit: 100 };
|
|
10
|
+
// `list` answers "what pages/notes do I have?" and deliberately omits section children (see
|
|
11
|
+
// scanCacheDirForList), so `section` is not an offered filter — every other artifact type can appear.
|
|
12
|
+
const LISTABLE_ARTIFACT_TYPES = ARTIFACT_TYPES.filter((type) => type !== 'section');
|
|
6
13
|
export default class ResearchList extends BaseCommand {
|
|
7
14
|
static id = 'list';
|
|
8
15
|
static summary = 'List cached research artifacts by metadata filters.';
|
|
9
16
|
static description = 'Lists cached research artifacts, including metadata details like path, source count, freshness, token estimates, and quality metrics without printing full content.';
|
|
10
17
|
static examples = [
|
|
11
18
|
{
|
|
12
|
-
description: '
|
|
19
|
+
description: 'list all cached entries',
|
|
13
20
|
command: '<%= config.bin %> list',
|
|
14
21
|
},
|
|
15
22
|
{
|
|
16
|
-
description: '
|
|
23
|
+
description: 'list cached entries for a specific topic with JSON output',
|
|
17
24
|
command: '<%= config.bin %> list --topic "React Suspense" --json',
|
|
18
25
|
},
|
|
19
26
|
{
|
|
20
|
-
description: '
|
|
27
|
+
description: 'list only fresh entries filtered by tags',
|
|
21
28
|
command: '<%= config.bin %> list --freshness fresh --tags node --tags url',
|
|
22
29
|
},
|
|
23
30
|
];
|
|
24
31
|
static flags = {
|
|
25
32
|
topic: Flags.string({
|
|
26
33
|
char: 't',
|
|
27
|
-
description: '
|
|
34
|
+
description: 'filter by exact topic (case-insensitive)',
|
|
28
35
|
}),
|
|
29
36
|
tags: Flags.string({
|
|
30
37
|
char: 'g',
|
|
31
|
-
description: '
|
|
38
|
+
description: 'filter by tags (must match all tags specified)',
|
|
32
39
|
multiple: true,
|
|
33
40
|
}),
|
|
34
41
|
freshness: Flags.option({
|
|
35
|
-
description: '
|
|
42
|
+
description: 'filter by freshness state',
|
|
36
43
|
options: ['fresh', 'stale_grace', 'stale_expired'],
|
|
37
44
|
})(),
|
|
38
45
|
'artifact-type': Flags.option({
|
|
39
|
-
description: '
|
|
40
|
-
options:
|
|
46
|
+
description: 'filter by artifact type',
|
|
47
|
+
options: LISTABLE_ARTIFACT_TYPES,
|
|
41
48
|
})(),
|
|
42
49
|
'capture-method': Flags.option({
|
|
43
|
-
description: '
|
|
44
|
-
options:
|
|
50
|
+
description: 'filter by capture method',
|
|
51
|
+
options: CAPTURE_METHODS,
|
|
45
52
|
})(),
|
|
46
53
|
limit: Flags.integer({
|
|
47
|
-
description: '
|
|
54
|
+
description: 'maximum number of results to return (default 50, max 100)',
|
|
48
55
|
default: 50,
|
|
49
56
|
}),
|
|
50
57
|
};
|
|
@@ -52,7 +59,7 @@ export default class ResearchList extends BaseCommand {
|
|
|
52
59
|
validateListFlags() {
|
|
53
60
|
const limit = this.flags.limit;
|
|
54
61
|
if (limit !== undefined && (limit < 1 || limit > 100)) {
|
|
55
|
-
this.error('Limit must be between 1 and 100.', { exit: 2 });
|
|
62
|
+
this.error('Limit must be between 1 and 100.', { exit: 2, code: 'INVALID_LIMIT' });
|
|
56
63
|
}
|
|
57
64
|
}
|
|
58
65
|
matchesFilters(meta, freshness) {
|
|
@@ -82,6 +89,15 @@ export default class ResearchList extends BaseCommand {
|
|
|
82
89
|
return scanCacheDirs(readRoots, (artifact, filePath) => {
|
|
83
90
|
if (artifact.metadata.status !== 'active')
|
|
84
91
|
return null;
|
|
92
|
+
// Section children are sub-chunks of a page, not artifacts a user "has" — they would flood the
|
|
93
|
+
// listing (one page yields dozens) and aren't in the documented source/research_note contract.
|
|
94
|
+
// They stay discoverable through `search` (which ranks them) and `inspect` (which lists a
|
|
95
|
+
// page's sections). `list` answers "what pages/notes do I have?", so keep it page-level. This
|
|
96
|
+
// unconditional guard owns the exclusion (the default no-filter case relies on it);
|
|
97
|
+
// LISTABLE_ARTIFACT_TYPES just hides `section` from --artifact-type so no one filters for a
|
|
98
|
+
// type list can never return. Keep both in sync if section handling ever changes.
|
|
99
|
+
if (artifact.metadata.artifact_type === 'section')
|
|
100
|
+
return null;
|
|
85
101
|
const freshness = evaluateFreshness(artifact.metadata, currentTime, null);
|
|
86
102
|
if (!this.matchesFilters(artifact.metadata, freshness))
|
|
87
103
|
return null;
|
|
@@ -101,16 +117,17 @@ export default class ResearchList extends BaseCommand {
|
|
|
101
117
|
};
|
|
102
118
|
});
|
|
103
119
|
}
|
|
104
|
-
logListResults(finalResults) {
|
|
120
|
+
logListResults(finalResults, totalMatched) {
|
|
105
121
|
if (this.jsonEnabled())
|
|
106
122
|
return;
|
|
107
123
|
if (finalResults.length === 0) {
|
|
108
124
|
this.log('No cached research entries found matching filters.');
|
|
125
|
+
this.log(`\nTip: populate the cache first: ${this.config.bin} <url>`);
|
|
109
126
|
return;
|
|
110
127
|
}
|
|
111
|
-
this.log(
|
|
128
|
+
this.log(`${resultListHeading(totalMatched, finalResults.length, LIST_LABELS)}\n`);
|
|
112
129
|
finalResults.forEach((res, index) => {
|
|
113
|
-
this.log(`${index + 1}. [${res.topic ||
|
|
130
|
+
this.log(`${index + 1}. [${res.topic || NO_TOPIC_LABEL}] Key: ${res.cacheKey}`);
|
|
114
131
|
this.log(` Type: ${res.artifactType} | Freshness: ${res.freshness}`);
|
|
115
132
|
this.log(` Tokens: compressed=${res.tokenEstimate?.compressed || 0}, detailed=${res.tokenEstimate?.detailed || 0}`);
|
|
116
133
|
this.log(` Source URLs: ${res.sourceUrls.join(', ')}\n`);
|
|
@@ -132,7 +149,13 @@ export default class ResearchList extends BaseCommand {
|
|
|
132
149
|
return timeB - timeA;
|
|
133
150
|
});
|
|
134
151
|
const finalResults = results.slice(0, this.flags.limit);
|
|
135
|
-
|
|
152
|
+
// Under --json the human heading is suppressed, so surface truncation on stderr instead (warn
|
|
153
|
+
// always emits, even in --json) without touching the stdout envelope. Human mode already shows
|
|
154
|
+
// it in the heading, so only warn under --json.
|
|
155
|
+
const notice = truncationNotice(results.length, finalResults.length, LIST_LABELS);
|
|
156
|
+
if (notice && this.jsonEnabled())
|
|
157
|
+
this.warn(notice);
|
|
158
|
+
this.logListResults(finalResults, results.length);
|
|
136
159
|
return finalResults;
|
|
137
160
|
}
|
|
138
161
|
}
|
package/dist/commands/prune.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export default class ResearchPrune extends BaseCommand<typeof ResearchPrune> {
|
|
|
10
10
|
static flags: {
|
|
11
11
|
'older-than': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
12
|
inactive: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
-
'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | "index" | "section" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
14
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
15
|
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
16
|
};
|
package/dist/commands/prune.js
CHANGED
|
@@ -4,49 +4,76 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { BaseCommand } from '../base-command.js';
|
|
5
5
|
import { scanCacheDir } from '../lib/research/storage.js';
|
|
6
6
|
import { loadStoreRoots } from '../lib/research/store-roots.js';
|
|
7
|
-
import { parseTtlToMs } from '../lib/research/freshness.js';
|
|
7
|
+
import { parseTtlToMs, durationFlagError } from '../lib/research/freshness.js';
|
|
8
|
+
import { ARTIFACT_TYPES } from '../lib/research/schema.js';
|
|
9
|
+
import { NO_TOPIC_LABEL, pluralize } from '../lib/text.js';
|
|
8
10
|
export default class ResearchPrune extends BaseCommand {
|
|
9
11
|
static id = 'prune';
|
|
10
12
|
static summary = 'Clean up old or inactive research cache entries.';
|
|
11
13
|
static description = 'Prunes cached research entries based on age, inactivity, or artifact type to free up disk space.';
|
|
12
14
|
static examples = [
|
|
13
15
|
{
|
|
14
|
-
description: '
|
|
16
|
+
description: 'perform a dry run of pruning entries older than 90 days',
|
|
15
17
|
command: '<%= config.bin %> prune --older-than 90d --dry-run',
|
|
16
18
|
},
|
|
17
19
|
{
|
|
18
|
-
description: '
|
|
20
|
+
description: 'actually prune entries older than 30 days that are source scrapes',
|
|
19
21
|
command: '<%= config.bin %> prune --older-than 30d --artifact-type source --yes',
|
|
20
22
|
},
|
|
21
23
|
];
|
|
22
24
|
static flags = {
|
|
23
25
|
'older-than': Flags.string({
|
|
24
|
-
description: '
|
|
26
|
+
description: 'prune entries older than duration (e.g. "30d", "90d")',
|
|
25
27
|
}),
|
|
26
28
|
inactive: Flags.string({
|
|
27
|
-
description: '
|
|
29
|
+
description: 'prune entries inactive (unvalidated/unfetched) for duration (e.g. "14d", "30d")',
|
|
28
30
|
}),
|
|
29
31
|
'artifact-type': Flags.option({
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
// Prune operates on every cached file, so it can target any artifact type — including the
|
|
33
|
+
// `section`/`index` children a page generates (e.g. to clear orphans left after a source is
|
|
34
|
+
// pruned with `--artifact-type source`).
|
|
35
|
+
description: 'filter pruning to specific artifact type',
|
|
36
|
+
options: ARTIFACT_TYPES,
|
|
32
37
|
})(),
|
|
33
38
|
'dry-run': Flags.boolean({
|
|
34
|
-
description: '
|
|
39
|
+
description: 'list files that would be deleted without actually deleting them',
|
|
35
40
|
default: false,
|
|
36
41
|
}),
|
|
37
42
|
yes: Flags.boolean({
|
|
38
43
|
char: 'y',
|
|
39
|
-
description: '
|
|
44
|
+
description: 'confirm deletion and prune matched entries (required unless --dry-run)',
|
|
40
45
|
default: false,
|
|
41
46
|
}),
|
|
42
47
|
};
|
|
43
48
|
static stdoutIsPrimaryData = true;
|
|
44
49
|
validatePruneFlags() {
|
|
45
50
|
if (!this.flags['older-than'] && !this.flags.inactive && !this.flags['artifact-type']) {
|
|
46
|
-
this.error('Must specify at least one pruning filter: --older-than, --inactive, or --artifact-type.', {
|
|
51
|
+
this.error('Must specify at least one pruning filter: --older-than, --inactive, or --artifact-type.', {
|
|
52
|
+
exit: 2,
|
|
53
|
+
code: 'MISSING_FILTER',
|
|
54
|
+
suggestions: [
|
|
55
|
+
`Preview age-based pruning: ${this.config.bin} prune --older-than 90d --dry-run`,
|
|
56
|
+
],
|
|
57
|
+
});
|
|
47
58
|
}
|
|
48
59
|
if (!this.flags['dry-run'] && !this.flags.yes) {
|
|
49
|
-
|
|
60
|
+
const olderThanPart = this.flags['older-than']
|
|
61
|
+
? ` --older-than ${this.flags['older-than']}`
|
|
62
|
+
: '';
|
|
63
|
+
this.error('Safety check: use --yes to confirm pruning, or --dry-run to preview files that would be deleted.', {
|
|
64
|
+
exit: 2,
|
|
65
|
+
code: 'SAFETY_CHECK_REQUIRED',
|
|
66
|
+
suggestions: [`Preview first: ${this.config.bin} prune --dry-run${olderThanPart}`],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// Validate durations up front: scanCacheDir swallows per-file errors, so a malformed
|
|
70
|
+
// --older-than/--inactive would otherwise be silently ignored and report "0 entries".
|
|
71
|
+
for (const msg of [
|
|
72
|
+
durationFlagError('--older-than', this.flags['older-than']),
|
|
73
|
+
durationFlagError('--inactive', this.flags.inactive),
|
|
74
|
+
]) {
|
|
75
|
+
if (msg)
|
|
76
|
+
this.error(msg, { exit: 2, code: 'INVALID_DURATION' });
|
|
50
77
|
}
|
|
51
78
|
}
|
|
52
79
|
shouldPrune(meta, currentTime) {
|
|
@@ -109,24 +136,30 @@ export default class ResearchPrune extends BaseCommand {
|
|
|
109
136
|
const filesToPrune = this.findPruneCandidates(roots.readRoots, currentTime);
|
|
110
137
|
const dryRun = this.flags['dry-run'];
|
|
111
138
|
const count = filesToPrune.length;
|
|
139
|
+
// Track deletions actually performed so the JSON envelope and human output agree even when an
|
|
140
|
+
// unlink fails (e.g. a permission error). Reporting candidate count as `prunedCount` would
|
|
141
|
+
// overstate success and mislead an agent branching on the result.
|
|
142
|
+
let prunedCount = 0;
|
|
112
143
|
if (dryRun) {
|
|
113
144
|
if (!this.jsonEnabled()) {
|
|
114
|
-
|
|
145
|
+
const noun = pluralize(count, 'entry', 'entries');
|
|
146
|
+
this.log(`[Dry Run] Found ${count} research cache ${noun} that would be pruned:\n`);
|
|
115
147
|
filesToPrune.forEach((f) => {
|
|
116
|
-
this.log(`- [${f.topic ||
|
|
148
|
+
this.log(`- [${f.topic || NO_TOPIC_LABEL}] Key: ${f.cacheKey} (${f.url || 'Imported note'})`);
|
|
117
149
|
});
|
|
118
150
|
}
|
|
119
151
|
}
|
|
120
152
|
else {
|
|
121
|
-
|
|
153
|
+
prunedCount = this.deletePruneCandidates(filesToPrune);
|
|
122
154
|
if (!this.jsonEnabled()) {
|
|
123
|
-
|
|
155
|
+
const noun = pluralize(count, 'entry', 'entries');
|
|
156
|
+
this.log(`Successfully pruned ${prunedCount} of ${count} research cache ${noun}.`);
|
|
124
157
|
}
|
|
125
158
|
}
|
|
126
159
|
return {
|
|
127
160
|
dryRun,
|
|
128
|
-
prunedCount
|
|
129
|
-
candidateCount:
|
|
161
|
+
prunedCount,
|
|
162
|
+
candidateCount: count,
|
|
130
163
|
files: filesToPrune.map((f) => ({
|
|
131
164
|
cacheKey: f.cacheKey,
|
|
132
165
|
path: f.path,
|
|
@@ -3,6 +3,10 @@ export default class ResearchSearch extends BaseCommand<typeof ResearchSearch> {
|
|
|
3
3
|
static id: string;
|
|
4
4
|
static summary: string;
|
|
5
5
|
static description: string;
|
|
6
|
+
static examples: {
|
|
7
|
+
description: string;
|
|
8
|
+
command: string;
|
|
9
|
+
}[];
|
|
6
10
|
static args: {
|
|
7
11
|
query: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
8
12
|
};
|
package/dist/commands/search.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
|
-
import { Args, Flags } from '@oclif/core';
|
|
1
|
+
import { Args, Flags, ux } from '@oclif/core';
|
|
2
2
|
import { BaseCommand } from '../base-command.js';
|
|
3
3
|
import { loadSearchableArtifacts } from '../lib/research/search-index.js';
|
|
4
|
+
import { levenshtein, NO_TOPIC_LABEL, resultListHeading, truncationNotice, } from '../lib/text.js';
|
|
4
5
|
import { loadStoreRoots } from '../lib/research/store-roots.js';
|
|
5
6
|
import { evaluateFreshness } from '../lib/research/freshness.js';
|
|
7
|
+
import { ARTIFACT_TYPES } from '../lib/research/schema.js';
|
|
6
8
|
import { detectSite } from '../sites/index.js';
|
|
7
9
|
import { fetchStaticHtml, fetchText, postJson } from '../lib/research/fetcher.js';
|
|
10
|
+
import { normalizeUrl } from '../lib/research/url.js';
|
|
8
11
|
import { runRemoteDocsSearch, } from '../lib/research/docs/remote-search-runner.js';
|
|
12
|
+
// Results are ranked, so the truncation word is "top"; --limit caps at 50.
|
|
13
|
+
const SEARCH_LABELS = {
|
|
14
|
+
noun: 'matching cached research',
|
|
15
|
+
order: 'top',
|
|
16
|
+
maxLimit: 50,
|
|
17
|
+
};
|
|
9
18
|
const REMOTE_SEARCH_DEPS = {
|
|
10
19
|
fetchStatic: async (url) => {
|
|
11
20
|
const res = await fetchStaticHtml(url);
|
|
@@ -22,52 +31,66 @@ export default class ResearchSearch extends BaseCommand {
|
|
|
22
31
|
static id = 'search';
|
|
23
32
|
static summary = 'Search locally cached research artifacts by keywords.';
|
|
24
33
|
static description = 'Scans the local research cache database and ranks matching entries based on topic, tags, source URLs, summary, and content.';
|
|
34
|
+
static examples = [
|
|
35
|
+
{
|
|
36
|
+
description: 'search for cached notes containing specific keywords',
|
|
37
|
+
command: '<%= config.bin %> <%= command.id %> "authentication flow"',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
description: 'search with remote docs fallback',
|
|
41
|
+
command: '<%= config.bin %> <%= command.id %> "router" --remote https://react.dev',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
description: 'search local cache with tag filtering',
|
|
45
|
+
command: '<%= config.bin %> <%= command.id %> "setup" --tags node --tags tutorial',
|
|
46
|
+
},
|
|
47
|
+
];
|
|
25
48
|
static args = {
|
|
26
49
|
query: Args.string({
|
|
27
50
|
required: true,
|
|
28
|
-
description: '
|
|
51
|
+
description: 'the search query containing terms to match',
|
|
29
52
|
}),
|
|
30
53
|
};
|
|
31
54
|
static flags = {
|
|
32
55
|
topic: Flags.string({
|
|
33
56
|
char: 't',
|
|
34
|
-
description: '
|
|
57
|
+
description: 'filter results by exact topic (case-insensitive)',
|
|
35
58
|
}),
|
|
36
59
|
tags: Flags.string({
|
|
37
60
|
char: 'g',
|
|
38
|
-
description: '
|
|
61
|
+
description: 'filter results by tags (must match all tags specified)',
|
|
39
62
|
multiple: true,
|
|
40
63
|
}),
|
|
41
64
|
'artifact-type': Flags.option({
|
|
42
|
-
description: '
|
|
43
|
-
options:
|
|
65
|
+
description: 'filter results by artifact type',
|
|
66
|
+
options: ARTIFACT_TYPES,
|
|
44
67
|
})(),
|
|
45
68
|
limit: Flags.integer({
|
|
46
|
-
description: '
|
|
69
|
+
description: 'maximum number of results to return (default 10, max 50)',
|
|
47
70
|
default: 10,
|
|
48
71
|
}),
|
|
49
72
|
'include-stale': Flags.boolean({
|
|
50
|
-
description: '
|
|
73
|
+
description: 'include stale expired cache entries in the search results',
|
|
51
74
|
default: false,
|
|
52
75
|
}),
|
|
53
76
|
domain: Flags.string({
|
|
54
|
-
description: '
|
|
77
|
+
description: 'search a documentation site directly (requires compatible site module)',
|
|
55
78
|
}),
|
|
56
79
|
remote: Flags.string({
|
|
57
|
-
description: '
|
|
80
|
+
description: 'discover uncached docs via public search or index (falls back to local cache)',
|
|
58
81
|
}),
|
|
59
82
|
};
|
|
60
83
|
static stdoutIsPrimaryData = true;
|
|
61
84
|
validateSearchFlags() {
|
|
62
85
|
const limit = this.flags.limit;
|
|
63
86
|
if (limit !== undefined && (limit < 1 || limit > 50)) {
|
|
64
|
-
this.error('Limit must be between 1 and 50.', { exit: 2 });
|
|
87
|
+
this.error('Limit must be between 1 and 50.', { exit: 2, code: 'INVALID_LIMIT' });
|
|
65
88
|
}
|
|
66
89
|
}
|
|
67
90
|
getSearchQueryTerms(query) {
|
|
68
91
|
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
69
92
|
if (terms.length === 0) {
|
|
70
|
-
this.error('Query string cannot be empty.', { exit: 2 });
|
|
93
|
+
this.error('Query string cannot be empty.', { exit: 2, code: 'EMPTY_QUERY' });
|
|
71
94
|
}
|
|
72
95
|
return terms;
|
|
73
96
|
}
|
|
@@ -182,12 +205,17 @@ export default class ResearchSearch extends BaseCommand {
|
|
|
182
205
|
}
|
|
183
206
|
return results;
|
|
184
207
|
}
|
|
185
|
-
logSearchResults(finalResults) {
|
|
208
|
+
logSearchResults(finalResults, totalMatched) {
|
|
186
209
|
if (this.jsonEnabled())
|
|
187
210
|
return;
|
|
188
|
-
|
|
211
|
+
if (finalResults.length === 0) {
|
|
212
|
+
this.log('No matching cached research entries found.');
|
|
213
|
+
this.log(`\nTip: populate the cache first: ${this.config.bin} <url>`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
this.log(`${resultListHeading(totalMatched, finalResults.length, SEARCH_LABELS)}\n`);
|
|
189
217
|
finalResults.forEach((res, index) => {
|
|
190
|
-
this.log(`${index + 1}. [${res.topic ||
|
|
218
|
+
this.log(`${index + 1}. [${res.topic || NO_TOPIC_LABEL}] Score: ${res.score}`);
|
|
191
219
|
this.log(` Cache Key: ${res.cacheKey}`);
|
|
192
220
|
this.log(` Snippet: ${res.snippet}`);
|
|
193
221
|
this.log(` Source URLs: ${res.sourceUrls.join(', ')}\n`);
|
|
@@ -209,24 +237,47 @@ export default class ResearchSearch extends BaseCommand {
|
|
|
209
237
|
async executeSiteSearch(query, domain) {
|
|
210
238
|
const siteModule = detectSite(`https://${domain}`);
|
|
211
239
|
if (!siteModule) {
|
|
212
|
-
this.error(`No site module registered for domain: ${domain}`, {
|
|
240
|
+
this.error(`No site module registered for domain: ${domain}`, {
|
|
241
|
+
exit: 2,
|
|
242
|
+
code: 'UNSUPPORTED_DOMAIN',
|
|
243
|
+
});
|
|
213
244
|
}
|
|
214
245
|
if (!siteModule.search) {
|
|
215
|
-
this.error(`Site module '${siteModule.id}' does not implement search.`, {
|
|
246
|
+
this.error(`Site module '${siteModule.id}' does not implement search.`, {
|
|
247
|
+
exit: 2,
|
|
248
|
+
code: 'SEARCH_NOT_SUPPORTED',
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
if (!this.jsonEnabled())
|
|
253
|
+
ux.action.start(`Searching ${domain}`);
|
|
254
|
+
const results = await siteModule.search(query);
|
|
255
|
+
if (!this.jsonEnabled())
|
|
256
|
+
ux.action.stop();
|
|
257
|
+
this.printResults(`Found ${results.length} results from ${siteModule.name}:\n`, results);
|
|
258
|
+
return results.map((r) => ({ ...r, site_module_id: siteModule.id }));
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
if (!this.jsonEnabled())
|
|
262
|
+
ux.action.stop('failed');
|
|
263
|
+
throw err;
|
|
216
264
|
}
|
|
217
|
-
const results = await siteModule.search(query);
|
|
218
|
-
this.printResults(`Found ${results.length} results from ${siteModule.name}:\n`, results);
|
|
219
|
-
return results.map((r) => ({ ...r, site_module_id: siteModule.id }));
|
|
220
265
|
}
|
|
221
266
|
// Remote docs discovery. On any connector failure, degrade to local cache search with a warning
|
|
222
267
|
// (T-20). Returns discovery results tagged remote: true so callers can tell them from cache hits.
|
|
223
268
|
async executeRemoteSearch(query, docsUrl) {
|
|
224
269
|
try {
|
|
270
|
+
if (!this.jsonEnabled())
|
|
271
|
+
ux.action.start(`Searching ${docsUrl}`);
|
|
225
272
|
const { provider, results } = await runRemoteDocsSearch(docsUrl, query, REMOTE_SEARCH_DEPS);
|
|
273
|
+
if (!this.jsonEnabled())
|
|
274
|
+
ux.action.stop();
|
|
226
275
|
this.printResults(`Found ${results.length} remote results via ${provider}:\n`, results);
|
|
227
276
|
return results.map((r) => ({ ...r, remote: true }));
|
|
228
277
|
}
|
|
229
278
|
catch (err) {
|
|
279
|
+
if (!this.jsonEnabled())
|
|
280
|
+
ux.action.stop('failed');
|
|
230
281
|
this.warn(`Remote docs search unavailable, using local cache: ${err.message}`);
|
|
231
282
|
return undefined;
|
|
232
283
|
}
|
|
@@ -234,14 +285,36 @@ export default class ResearchSearch extends BaseCommand {
|
|
|
234
285
|
async run() {
|
|
235
286
|
const { query } = this.args;
|
|
236
287
|
const { domain, remote } = this.flags;
|
|
288
|
+
// Reject whitespace-only queries on every path (local, --domain, --remote).
|
|
289
|
+
this.getSearchQueryTerms(query);
|
|
290
|
+
if (domain && remote) {
|
|
291
|
+
this.error('--domain and --remote are mutually exclusive. Use one discovery mode at a time.', {
|
|
292
|
+
exit: 2,
|
|
293
|
+
code: 'CONFLICTING_FLAGS',
|
|
294
|
+
suggestions: [
|
|
295
|
+
`Local site API: ${this.config.bin} search "${query}" --domain ${domain}`,
|
|
296
|
+
`Remote index: ${this.config.bin} search "${query}" --remote ${remote}`,
|
|
297
|
+
],
|
|
298
|
+
});
|
|
299
|
+
}
|
|
237
300
|
if (domain) {
|
|
238
301
|
return this.executeSiteSearch(query, domain);
|
|
239
302
|
}
|
|
240
303
|
if (remote) {
|
|
241
|
-
|
|
304
|
+
let normalizedRemote;
|
|
305
|
+
try {
|
|
306
|
+
normalizedRemote = normalizeUrl(remote);
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
this.error(`Invalid --remote URL: ${err.message}`, {
|
|
310
|
+
exit: 2,
|
|
311
|
+
code: 'INVALID_URL',
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
const remoteResults = await this.executeRemoteSearch(query, normalizedRemote);
|
|
242
315
|
if (remoteResults !== undefined)
|
|
243
316
|
return remoteResults;
|
|
244
|
-
// else: fall through to local cache search
|
|
317
|
+
// else: fall through to local cache search (connector/network failure only)
|
|
245
318
|
}
|
|
246
319
|
this.validateSearchFlags();
|
|
247
320
|
const queryTerms = this.getSearchQueryTerms(query);
|
|
@@ -254,7 +327,13 @@ export default class ResearchSearch extends BaseCommand {
|
|
|
254
327
|
const results = this.scanCacheDirForResults(roots.readRoots, queryTerms, currentTime);
|
|
255
328
|
results.sort((a, b) => b.score - a.score);
|
|
256
329
|
const finalResults = results.slice(0, this.flags.limit);
|
|
257
|
-
|
|
330
|
+
// Under --json the human heading is suppressed, so surface truncation on stderr instead (warn
|
|
331
|
+
// always emits, even in --json) without touching the stdout envelope. Human mode already shows
|
|
332
|
+
// it in the heading, so only warn under --json.
|
|
333
|
+
const notice = truncationNotice(results.length, finalResults.length, SEARCH_LABELS);
|
|
334
|
+
if (notice && this.jsonEnabled())
|
|
335
|
+
this.warn(notice);
|
|
336
|
+
this.logSearchResults(finalResults, results.length);
|
|
258
337
|
return finalResults;
|
|
259
338
|
}
|
|
260
339
|
}
|
|
@@ -305,24 +384,6 @@ function scoreSingleTerm(term, topic, tags, sourceUrl, sourceUrls, summ, comp) {
|
|
|
305
384
|
}
|
|
306
385
|
return termScore;
|
|
307
386
|
}
|
|
308
|
-
function levenshtein(s1, s2) {
|
|
309
|
-
if (s1.length < s2.length)
|
|
310
|
-
return levenshtein(s2, s1);
|
|
311
|
-
if (s2.length === 0)
|
|
312
|
-
return s1.length;
|
|
313
|
-
let prevRow = Array.from({ length: s2.length + 1 }, (_, i) => i);
|
|
314
|
-
for (let i = 0; i < s1.length; i++) {
|
|
315
|
-
const currRow = [i + 1];
|
|
316
|
-
for (let j = 0; j < s2.length; j++) {
|
|
317
|
-
const deletions = prevRow[j + 1] + 1;
|
|
318
|
-
const insertions = currRow[j] + 1;
|
|
319
|
-
const substitutions = prevRow[j] + (s1[i] === s2[j] ? 0 : 1);
|
|
320
|
-
currRow.push(Math.min(deletions, insertions, substitutions));
|
|
321
|
-
}
|
|
322
|
-
prevRow = currRow;
|
|
323
|
-
}
|
|
324
|
-
return prevRow[s2.length];
|
|
325
|
-
}
|
|
326
387
|
function isFuzzyMatch(term, target) {
|
|
327
388
|
if (term.length < 4 || target.length < 4)
|
|
328
389
|
return false;
|
|
@@ -3,6 +3,10 @@ export default class ResearchStatus extends BaseCommand<typeof ResearchStatus> {
|
|
|
3
3
|
static id: string;
|
|
4
4
|
static summary: string;
|
|
5
5
|
static description: string;
|
|
6
|
+
static examples: {
|
|
7
|
+
description: string;
|
|
8
|
+
command: string;
|
|
9
|
+
}[];
|
|
6
10
|
static args: {
|
|
7
11
|
url: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
8
12
|
};
|
|
@@ -12,5 +16,7 @@ export default class ResearchStatus extends BaseCommand<typeof ResearchStatus> {
|
|
|
12
16
|
'max-age': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
17
|
};
|
|
14
18
|
static stdoutIsPrimaryData: boolean;
|
|
19
|
+
/** Enrich cache-miss envelopes with the same CACHE_MISS code and suggestions as inspect. */
|
|
20
|
+
protected toSuccessJson(data: unknown): Record<string, unknown>;
|
|
15
21
|
run(): Promise<unknown>;
|
|
16
22
|
}
|