@taurgis/bonsai 1.0.5 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -12
- package/agents/agents/official-docs-researcher.agent.md +15 -26
- package/agents/agents/salesforce-docs-researcher.agent.md +17 -35
- package/agents/hooks/README.md +2 -9
- 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 +7 -6
- package/agents/instructions/web-research.instructions.md +3 -2
- package/agents/skills/web-research/SKILL.md +24 -32
- package/bin/cli.mjs +44 -14
- package/dist/base-command.d.ts +17 -1
- package/dist/base-command.js +70 -9
- 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 +4 -5
- package/dist/commands/config/index.d.ts +12 -0
- package/dist/commands/config/index.js +34 -0
- package/dist/commands/config/list.js +2 -2
- package/dist/commands/config/set.js +18 -12
- package/dist/commands/config/unset.d.ts +1 -1
- package/dist/commands/config/unset.js +9 -6
- package/dist/commands/fetch.d.ts +2 -0
- package/dist/commands/fetch.js +69 -45
- package/dist/commands/import.d.ts +3 -0
- package/dist/commands/import.js +91 -53
- package/dist/commands/inspect.d.ts +8 -0
- package/dist/commands/inspect.js +59 -30
- package/dist/commands/list.d.ts +5 -3
- package/dist/commands/list.js +60 -35
- package/dist/commands/prune.d.ts +2 -1
- package/dist/commands/prune.js +62 -19
- package/dist/commands/status.d.ts +8 -0
- package/dist/commands/status.js +84 -26
- package/dist/commands.d.ts +2 -2
- package/dist/commands.js +2 -2
- package/dist/hooks/command-not-found/suggest.d.ts +7 -1
- package/dist/hooks/command-not-found/suggest.js +100 -46
- package/dist/lib/argv.d.ts +10 -0
- package/dist/lib/argv.js +97 -0
- package/dist/lib/color.d.ts +20 -0
- package/dist/lib/color.js +36 -0
- package/dist/lib/envelope.d.ts +36 -0
- package/dist/lib/envelope.js +82 -0
- package/dist/lib/help-preflight.d.ts +15 -0
- package/dist/lib/help-preflight.js +81 -0
- package/dist/lib/json-meta.d.ts +10 -0
- package/dist/lib/json-meta.js +98 -0
- package/dist/lib/limit-flag.d.ts +1 -0
- package/dist/lib/limit-flag.js +24 -0
- package/dist/lib/research/{search-index.d.ts → artifact-index.d.ts} +5 -5
- package/dist/lib/research/{search-index.js → artifact-index.js} +10 -10
- package/dist/lib/research/browser.d.ts +0 -8
- package/dist/lib/research/browser.js +20 -43
- package/dist/lib/research/regression.d.ts +18 -0
- package/dist/lib/research/regression.js +47 -0
- 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/storage.d.ts +0 -1
- package/dist/lib/research/storage.js +15 -13
- package/dist/lib/research/url.d.ts +22 -0
- package/dist/lib/research/url.js +63 -1
- package/dist/lib/text.d.ts +5 -1
- package/dist/lib/text.js +11 -1
- package/dist/sites/coveo.d.ts +7 -0
- package/dist/sites/coveo.js +33 -0
- package/dist/sites/index.js +1 -2
- package/dist/sites/salesforce/fetch-page.js +1 -1
- package/dist/sites/salesforce/index.js +3 -8
- package/dist/sites/salesforce-developer/fetch-page.js +3 -1
- package/dist/sites/salesforce-developer/index.js +1 -2
- package/dist/sites/salesforce-doc-fetch.d.ts +11 -0
- package/dist/sites/salesforce-doc-fetch.js +117 -76
- package/dist/sites/salesforce-dom-probe.d.ts +16 -0
- package/dist/sites/salesforce-dom-probe.js +114 -0
- package/dist/sites/types.d.ts +0 -6
- package/oclif.manifest.json +153 -155
- package/package.json +38 -25
- package/dist/commands/index.d.ts +0 -35
- package/dist/commands/index.js +0 -306
- package/dist/commands/research/config/base.d.ts +0 -23
- package/dist/commands/research/config/base.js +0 -46
- package/dist/commands/research/config/get.d.ts +0 -20
- package/dist/commands/research/config/get.js +0 -45
- package/dist/commands/research/config/list.d.ts +0 -17
- package/dist/commands/research/config/list.js +0 -42
- package/dist/commands/research/config/set.d.ts +0 -31
- package/dist/commands/research/config/set.js +0 -94
- package/dist/commands/research/config/unset.d.ts +0 -21
- package/dist/commands/research/config/unset.js +0 -59
- package/dist/commands/research/import.d.ts +0 -38
- package/dist/commands/research/import.js +0 -313
- package/dist/commands/research/inspect.d.ts +0 -13
- package/dist/commands/research/inspect.js +0 -86
- package/dist/commands/research/list.d.ts +0 -25
- package/dist/commands/research/list.js +0 -143
- package/dist/commands/research/prune.d.ts +0 -24
- package/dist/commands/research/prune.js +0 -141
- package/dist/commands/research/search.d.ts +0 -32
- package/dist/commands/research/search.js +0 -334
- package/dist/commands/research/status.d.ts +0 -17
- package/dist/commands/research/status.js +0 -108
- package/dist/commands/research.d.ts +0 -36
- package/dist/commands/research.js +0 -304
- package/dist/commands/search.d.ts +0 -31
- package/dist/commands/search.js +0 -329
- package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.d.ts +0 -1
- package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.js +0 -2
- package/dist/lib/research/docs/discovery-index.d.ts +0 -14
- package/dist/lib/research/docs/discovery-index.js +0 -112
- package/dist/lib/research/docs/remote-search-runner.d.ts +0 -21
- package/dist/lib/research/docs/remote-search-runner.js +0 -114
- package/dist/lib/research/docs/remote-search.d.ts +0 -14
- package/dist/lib/research/docs/remote-search.js +0 -101
- package/dist/lib/research/docs/search-index.d.ts +0 -13
- package/dist/lib/research/docs/search-index.js +0 -176
- package/dist/sites/salesforce/browser-search.d.ts +0 -7
- package/dist/sites/salesforce/browser-search.js +0 -82
- package/dist/sites/salesforce/coveo.d.ts +0 -23
- package/dist/sites/salesforce/coveo.js +0 -161
- package/dist/sites/salesforce/token.d.ts +0 -26
- package/dist/sites/salesforce/token.js +0 -154
|
@@ -1,304 +0,0 @@
|
|
|
1
|
-
import { Args, Flags } from '@oclif/core';
|
|
2
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { BaseCommand } from '../base-command.js';
|
|
6
|
-
import { normalizeUrl } from '../lib/research/url.js';
|
|
7
|
-
import { deriveCacheKey } from '../lib/research/cache-key.js';
|
|
8
|
-
import { locateArtifact, writeArtifact, getArtifactPath } from '../lib/research/storage.js';
|
|
9
|
-
import { loadStoreRoots } from '../lib/research/store-roots.js';
|
|
10
|
-
import { writeArtifactSecurely } from '../lib/research/secure-write.js';
|
|
11
|
-
import { evaluateFreshness, parseTtlToMs, checkMaxAgeExpired } from '../lib/research/freshness.js';
|
|
12
|
-
import { revalidateCache, createArtifactFromFetch } from '../lib/research/revalidate.js';
|
|
13
|
-
import { fetchStaticHtml, fetchText } from '../lib/research/fetcher.js';
|
|
14
|
-
import { fetchRenderedHtml } from '../lib/research/browser.js';
|
|
15
|
-
import { capturePage } from '../lib/research/capture.js';
|
|
16
|
-
import { persistSectionArtifacts } from '../lib/research/docs/section-artifacts.js';
|
|
17
|
-
import { detectSite } from '../sites/index.js';
|
|
18
|
-
const CAPTURE_DEPS = {
|
|
19
|
-
fetchStatic: (url) => fetchStaticHtml(url),
|
|
20
|
-
fetchRendered: (url) => fetchRenderedHtml(url),
|
|
21
|
-
fetchText: (url) => fetchText(url),
|
|
22
|
-
};
|
|
23
|
-
export default class Research extends BaseCommand {
|
|
24
|
-
static id = 'research';
|
|
25
|
-
static summary = 'An advanced, locally cached web research tool optimized for LLM ingestion.';
|
|
26
|
-
static description = 'Scrapes the specified URL, strips HTML boilerplate, converts the semantic payload into clean Markdown format, and caches the result locally using dynamic TTL rules.';
|
|
27
|
-
static examples = [
|
|
28
|
-
{
|
|
29
|
-
description: 'Research a URL with detailed output, tagged with topic and tags',
|
|
30
|
-
command: '<%= config.bin %> research https://docs.nestjs.com/ --topic "Backend Frameworks" --tags "Node" --tags "NestJS" --format detailed --ttl 30d',
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
description: 'Research a volatile page with compressed output and short TTL, returned as JSON',
|
|
34
|
-
command: '<%= config.bin %> research https://news.ycombinator.com/ --format compressed --ttl 2h --json',
|
|
35
|
-
},
|
|
36
|
-
];
|
|
37
|
-
static args = {
|
|
38
|
-
url: Args.string({
|
|
39
|
-
required: true,
|
|
40
|
-
description: 'The full HTTP/HTTPS URL of the web page to research.',
|
|
41
|
-
}),
|
|
42
|
-
};
|
|
43
|
-
static flags = {
|
|
44
|
-
topic: Flags.string({
|
|
45
|
-
char: 't',
|
|
46
|
-
description: 'The main category/topic of the research for metadata tagging.',
|
|
47
|
-
}),
|
|
48
|
-
tags: Flags.string({
|
|
49
|
-
char: 'g',
|
|
50
|
-
description: 'Taxonomic tags for this research (can be repeated).',
|
|
51
|
-
multiple: true,
|
|
52
|
-
}),
|
|
53
|
-
format: Flags.option({
|
|
54
|
-
char: 'f',
|
|
55
|
-
description: 'Desired data density.',
|
|
56
|
-
options: ['compressed', 'detailed'],
|
|
57
|
-
default: 'compressed',
|
|
58
|
-
})(),
|
|
59
|
-
tier: Flags.option({
|
|
60
|
-
description: 'Freshness tier policy.',
|
|
61
|
-
options: ['stable', 'standard', 'volatile'],
|
|
62
|
-
default: 'standard',
|
|
63
|
-
})(),
|
|
64
|
-
ttl: Flags.string({
|
|
65
|
-
char: 'l',
|
|
66
|
-
description: 'Predicted lifespan of the data (e.g., "2h", "7d", "3m").',
|
|
67
|
-
}),
|
|
68
|
-
'max-age': Flags.string({
|
|
69
|
-
description: 'Maximum age of cache to accept (e.g., "1d", "30d").',
|
|
70
|
-
}),
|
|
71
|
-
force: Flags.boolean({
|
|
72
|
-
description: 'Force a fresh fetch, ignoring any cached entries.',
|
|
73
|
-
default: false,
|
|
74
|
-
}),
|
|
75
|
-
'dry-run': Flags.boolean({
|
|
76
|
-
description: 'Perform validation and fetch without saving to local cache.',
|
|
77
|
-
default: false,
|
|
78
|
-
}),
|
|
79
|
-
'allow-stale': Flags.boolean({
|
|
80
|
-
description: 'Allow serving stale cache if the remote server is unreachable.',
|
|
81
|
-
default: false,
|
|
82
|
-
}),
|
|
83
|
-
rendered: Flags.boolean({
|
|
84
|
-
description: 'Force using a browser-rendered scraping path for dynamic pages.',
|
|
85
|
-
default: false,
|
|
86
|
-
}),
|
|
87
|
-
storage: Flags.option({
|
|
88
|
-
description: 'Override where this result is cached (overrides configured default). Secret-bearing pages are always stored globally.',
|
|
89
|
-
options: ['global', 'project'],
|
|
90
|
-
})(),
|
|
91
|
-
};
|
|
92
|
-
static stdoutIsPrimaryData = true;
|
|
93
|
-
async init() {
|
|
94
|
-
await super.init();
|
|
95
|
-
const { args, flags } = await this.parse(Research);
|
|
96
|
-
this.args = args;
|
|
97
|
-
this.flags = flags;
|
|
98
|
-
}
|
|
99
|
-
async executeCacheHit(cached, targetDir, currentTime) {
|
|
100
|
-
const { ttl, 'max-age': maxAge, 'allow-stale': allowStale, rendered } = this.flags;
|
|
101
|
-
const isExpired = checkMaxAgeExpired(cached, currentTime, maxAge);
|
|
102
|
-
const freshnessState = isExpired
|
|
103
|
-
? 'stale_expired'
|
|
104
|
-
: evaluateFreshness(cached.metadata, currentTime, ttl);
|
|
105
|
-
if (freshnessState === 'fresh') {
|
|
106
|
-
return { cacheStatus: 'hit', freshnessState, artifact: cached };
|
|
107
|
-
}
|
|
108
|
-
const revalResult = await revalidateCache(targetDir, cached, currentTime, {
|
|
109
|
-
allowStale: Boolean(allowStale),
|
|
110
|
-
ttlOverride: ttl,
|
|
111
|
-
rendered: Boolean(rendered),
|
|
112
|
-
});
|
|
113
|
-
handleStaleRevalidationResult(this, revalResult);
|
|
114
|
-
return {
|
|
115
|
-
cacheStatus: revalResult.status,
|
|
116
|
-
freshnessState,
|
|
117
|
-
artifact: revalResult.artifact,
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
async executeCacheMiss(normalizedUrl, currentTime, cacheKey) {
|
|
121
|
-
const { topic, tags, tier, ttl, rendered } = this.flags;
|
|
122
|
-
const siteModule = detectSite(normalizedUrl);
|
|
123
|
-
const useRendered = rendered || Boolean(siteModule?.defaults?.rendered);
|
|
124
|
-
let fetchResult;
|
|
125
|
-
let extraction;
|
|
126
|
-
let capture = null;
|
|
127
|
-
if (siteModule?.fetchPage) {
|
|
128
|
-
// Custom site modules own their fetch/extract strategy; the generic capture path is skipped.
|
|
129
|
-
({ fetchResult, extraction } = await siteModule.fetchPage(normalizedUrl));
|
|
130
|
-
}
|
|
131
|
-
else {
|
|
132
|
-
capture = await capturePage(normalizedUrl, { forceRendered: useRendered }, CAPTURE_DEPS);
|
|
133
|
-
fetchResult = capture.fetchResult;
|
|
134
|
-
extraction = capture.extraction;
|
|
135
|
-
}
|
|
136
|
-
const artifact = createArtifactFromFetch(normalizedUrl, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl || null, currentTime);
|
|
137
|
-
artifact.metadata.topic = topic || null;
|
|
138
|
-
artifact.metadata.tags = tags || [];
|
|
139
|
-
artifact.metadata.site_module_id = siteModule?.id ?? null;
|
|
140
|
-
if (capture) {
|
|
141
|
-
applyCaptureMetadata(artifact, capture);
|
|
142
|
-
}
|
|
143
|
-
else if (useRendered) {
|
|
144
|
-
artifact.metadata.capture_method = 'browser_fallback';
|
|
145
|
-
}
|
|
146
|
-
return artifact;
|
|
147
|
-
}
|
|
148
|
-
buildResultData(url, normalizedUrl, cacheKey, storageDir, storageMode, cacheStatus, freshnessState, format, artifact, content, redirectedToGlobal) {
|
|
149
|
-
return {
|
|
150
|
-
schemaVersion: 1,
|
|
151
|
-
command: 'research',
|
|
152
|
-
cache: {
|
|
153
|
-
key: cacheKey,
|
|
154
|
-
status: cacheStatus,
|
|
155
|
-
freshness: freshnessState,
|
|
156
|
-
path: getArtifactPath(storageDir, cacheKey),
|
|
157
|
-
storage: storageMode,
|
|
158
|
-
redirectedToGlobal,
|
|
159
|
-
},
|
|
160
|
-
source: {
|
|
161
|
-
url,
|
|
162
|
-
normalizedUrl,
|
|
163
|
-
captureMethod: artifact.metadata.capture_method,
|
|
164
|
-
extractionStatus: artifact.metadata.extraction_status,
|
|
165
|
-
extractionConfidence: artifact.metadata.extraction_confidence,
|
|
166
|
-
qualityNotes: artifact.metadata.quality_notes,
|
|
167
|
-
fetchedAt: artifact.metadata.fetched_at,
|
|
168
|
-
validatedAt: artifact.metadata.validated_at,
|
|
169
|
-
staleAfter: artifact.metadata.stale_after,
|
|
170
|
-
},
|
|
171
|
-
artifactType: artifact.metadata.artifact_type,
|
|
172
|
-
docsEngine: artifact.metadata.docs_engine,
|
|
173
|
-
docsFramework: artifact.metadata.docs_framework,
|
|
174
|
-
sourceDocUrl: artifact.metadata.source_doc_url,
|
|
175
|
-
searchProvider: artifact.metadata.search_provider,
|
|
176
|
-
format,
|
|
177
|
-
tokenEstimate: format === 'compressed'
|
|
178
|
-
? artifact.metadata.token_estimate.compressed
|
|
179
|
-
: artifact.metadata.token_estimate.detailed,
|
|
180
|
-
content,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
// Validates the duration flags up front, exiting with code 2 on a malformed value.
|
|
184
|
-
validateDurationFlags(ttl, maxAge) {
|
|
185
|
-
for (const [label, value] of [
|
|
186
|
-
['TTL', ttl],
|
|
187
|
-
['max-age', maxAge],
|
|
188
|
-
]) {
|
|
189
|
-
if (!value)
|
|
190
|
-
continue;
|
|
191
|
-
try {
|
|
192
|
-
parseTtlToMs(value);
|
|
193
|
-
}
|
|
194
|
-
catch (err) {
|
|
195
|
-
this.error(`Invalid ${label}: ${err.message}`, { exit: 2 });
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
// Serves the cache when a fresh/revalidatable entry exists, otherwise fetches fresh. Reads fall
|
|
200
|
-
// back project→global; the resolved artifact is returned with the dir it lives in / landed in.
|
|
201
|
-
async resolveArtifact(normalizedUrl, cacheKey, roots, tmpDir, currentTime) {
|
|
202
|
-
const located = this.flags.force ? null : locateArtifact(roots.readRoots, cacheKey);
|
|
203
|
-
if (located) {
|
|
204
|
-
// Revalidate where the entry already lives; on dry-run use the throwaway dir so the cache is
|
|
205
|
-
// never mutated. ponytail: revalidation rewrites in place, so a refreshed project entry that
|
|
206
|
-
// gains a secret is not re-routed — only first-time project writes are scanned.
|
|
207
|
-
const revalDir = tmpDir ?? located.dataDir;
|
|
208
|
-
const hit = await this.executeCacheHit(located.artifact, revalDir, currentTime);
|
|
209
|
-
return { ...hit, storageDir: located.dataDir, redirectedToGlobal: false };
|
|
210
|
-
}
|
|
211
|
-
const artifact = await this.executeCacheMiss(normalizedUrl, currentTime, cacheKey);
|
|
212
|
-
const { dir, redirectedToGlobal } = this.persistFreshArtifact(roots, tmpDir, cacheKey, artifact);
|
|
213
|
-
return { cacheStatus: 'miss', freshnessState: 'stale_expired', artifact, storageDir: dir, redirectedToGlobal };
|
|
214
|
-
}
|
|
215
|
-
// Writes a freshly fetched artifact, honoring dry-run (throwaway dir) and the secret-safety
|
|
216
|
-
// redirect (project→global). Returns the data dir reported to the user and whether a redirect
|
|
217
|
-
// occurred (so the JSON envelope mirrors `research import`).
|
|
218
|
-
persistFreshArtifact(roots, tmpDir, cacheKey, artifact) {
|
|
219
|
-
if (tmpDir) {
|
|
220
|
-
writeArtifact(tmpDir, cacheKey, artifact);
|
|
221
|
-
// Report the real would-be location, not the throwaway dir that is about to be deleted.
|
|
222
|
-
return { dir: roots.writeRoot, redirectedToGlobal: false };
|
|
223
|
-
}
|
|
224
|
-
const result = writeArtifactSecurely(roots, cacheKey, artifact);
|
|
225
|
-
if (result.redirected) {
|
|
226
|
-
this.warn(`Detected ${result.secretLabel} in the page content; stored in the global cache instead of the project to avoid committing secrets.`);
|
|
227
|
-
}
|
|
228
|
-
return { dir: result.dataDir, redirectedToGlobal: result.redirected };
|
|
229
|
-
}
|
|
230
|
-
// Long references are split into searchable/inspectable section children whenever the page artifact
|
|
231
|
-
// is freshly written (T-22). Best-effort: never let chunking break the main result.
|
|
232
|
-
persistSectionsIfFresh(targetDir, artifact, currentTime, cacheStatus) {
|
|
233
|
-
if (cacheStatus !== 'miss' && cacheStatus !== 'refreshed')
|
|
234
|
-
return;
|
|
235
|
-
try {
|
|
236
|
-
persistSectionArtifacts(targetDir, artifact, currentTime);
|
|
237
|
-
}
|
|
238
|
-
catch {
|
|
239
|
-
/* section generation is non-essential; ignore failures */
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
async execute() {
|
|
243
|
-
const { url } = this.args;
|
|
244
|
-
const { format, ttl, 'max-age': maxAge, 'dry-run': dryRun } = this.flags;
|
|
245
|
-
this.validateDurationFlags(ttl, maxAge);
|
|
246
|
-
let normalizedUrl;
|
|
247
|
-
let cacheKey;
|
|
248
|
-
try {
|
|
249
|
-
normalizedUrl = normalizeUrl(url);
|
|
250
|
-
cacheKey = deriveCacheKey(normalizedUrl);
|
|
251
|
-
}
|
|
252
|
-
catch (err) {
|
|
253
|
-
this.error(`Invalid URL: ${err.message}`, { exit: 2 });
|
|
254
|
-
}
|
|
255
|
-
const roots = loadStoreRoots({
|
|
256
|
-
configDir: this.config.configDir,
|
|
257
|
-
cwd: process.cwd(),
|
|
258
|
-
dataDir: this.config.dataDir,
|
|
259
|
-
flagOverride: this.flags.storage,
|
|
260
|
-
});
|
|
261
|
-
const tmpDir = dryRun ? mkdtempSync(join(tmpdir(), 'fnr-dry-run-')) : null;
|
|
262
|
-
const currentTime = new Date();
|
|
263
|
-
try {
|
|
264
|
-
const { cacheStatus, freshnessState, artifact, storageDir, redirectedToGlobal } = await this.resolveArtifact(normalizedUrl, cacheKey, roots, tmpDir, currentTime);
|
|
265
|
-
this.persistSectionsIfFresh(tmpDir ?? storageDir, artifact, currentTime, cacheStatus);
|
|
266
|
-
const content = format === 'compressed' ? artifact.compressed : artifact.detailed;
|
|
267
|
-
const resultData = this.buildResultData(url, normalizedUrl, cacheKey, storageDir, roots.mode, cacheStatus, freshnessState, format, artifact, content, redirectedToGlobal);
|
|
268
|
-
if (!this.requestedJson()) {
|
|
269
|
-
this.log(content);
|
|
270
|
-
}
|
|
271
|
-
return resultData;
|
|
272
|
-
}
|
|
273
|
-
finally {
|
|
274
|
-
if (tmpDir) {
|
|
275
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
// Copies Phase 2 capability provenance from a capture outcome onto the artifact metadata.
|
|
281
|
-
function applyCaptureMetadata(artifact, capture) {
|
|
282
|
-
const meta = artifact.metadata;
|
|
283
|
-
meta.capture_method = capture.captureMethod;
|
|
284
|
-
meta.docs_engine = capture.capabilities.docsEngine ?? null;
|
|
285
|
-
meta.docs_framework = capture.capabilities.framework ?? null;
|
|
286
|
-
meta.source_doc_url = capture.sourceDocUrl;
|
|
287
|
-
meta.search_provider = capture.capabilities.search?.provider ?? null;
|
|
288
|
-
if (capture.extraction.isIndexHub)
|
|
289
|
-
meta.artifact_type = 'index';
|
|
290
|
-
if (capture.sourceDocUrl && !meta.source_urls.includes(capture.sourceDocUrl)) {
|
|
291
|
-
meta.source_urls.push(capture.sourceDocUrl);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
function handleStaleRevalidationResult(command, revalResult) {
|
|
295
|
-
if (revalResult.status !== 'stale')
|
|
296
|
-
return;
|
|
297
|
-
if (revalResult.allowed) {
|
|
298
|
-
command.warn(`Serving stale content: revalidation failed: ${revalResult.error}`);
|
|
299
|
-
}
|
|
300
|
-
else {
|
|
301
|
-
command.warn(`Serving stale content (exit 5): revalidation failed: ${revalResult.error}`);
|
|
302
|
-
process.exitCode = 5;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { BaseCommand } from '../base-command.js';
|
|
2
|
-
export default class ResearchSearch extends BaseCommand<typeof ResearchSearch> {
|
|
3
|
-
static id: string;
|
|
4
|
-
static summary: string;
|
|
5
|
-
static description: string;
|
|
6
|
-
static args: {
|
|
7
|
-
query: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
8
|
-
};
|
|
9
|
-
static flags: {
|
|
10
|
-
topic: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
-
tags: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
-
'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | "index" | "section" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
-
limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
-
'include-stale': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
|
-
domain: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
|
-
remote: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
|
-
};
|
|
18
|
-
static stdoutIsPrimaryData: boolean;
|
|
19
|
-
private validateSearchFlags;
|
|
20
|
-
private getSearchQueryTerms;
|
|
21
|
-
private matchesFilters;
|
|
22
|
-
private phraseMatchBonus;
|
|
23
|
-
private calculateScore;
|
|
24
|
-
private makeSnippet;
|
|
25
|
-
private scanCacheDirForResults;
|
|
26
|
-
private logSearchResults;
|
|
27
|
-
private printResults;
|
|
28
|
-
private executeSiteSearch;
|
|
29
|
-
private executeRemoteSearch;
|
|
30
|
-
run(): Promise<unknown>;
|
|
31
|
-
}
|
package/dist/commands/search.js
DELETED
|
@@ -1,329 +0,0 @@
|
|
|
1
|
-
import { Args, Flags } from '@oclif/core';
|
|
2
|
-
import { BaseCommand } from '../base-command.js';
|
|
3
|
-
import { loadSearchableArtifacts } from '../lib/research/search-index.js';
|
|
4
|
-
import { levenshtein, resultListHeading, truncationNotice, } from '../lib/text.js';
|
|
5
|
-
import { loadStoreRoots } from '../lib/research/store-roots.js';
|
|
6
|
-
import { evaluateFreshness } from '../lib/research/freshness.js';
|
|
7
|
-
import { detectSite } from '../sites/index.js';
|
|
8
|
-
import { fetchStaticHtml, fetchText, postJson } from '../lib/research/fetcher.js';
|
|
9
|
-
import { runRemoteDocsSearch, } from '../lib/research/docs/remote-search-runner.js';
|
|
10
|
-
// Results are ranked, so the truncation word is "top"; --limit caps at 50.
|
|
11
|
-
const SEARCH_LABELS = {
|
|
12
|
-
noun: 'matching cached research',
|
|
13
|
-
order: 'top',
|
|
14
|
-
maxLimit: 50,
|
|
15
|
-
};
|
|
16
|
-
const REMOTE_SEARCH_DEPS = {
|
|
17
|
-
fetchStatic: async (url) => {
|
|
18
|
-
const res = await fetchStaticHtml(url);
|
|
19
|
-
return { content: res.content, finalUrl: res.finalUrl };
|
|
20
|
-
},
|
|
21
|
-
fetchText: async (url) => {
|
|
22
|
-
const res = await fetchText(url);
|
|
23
|
-
return { content: res.content, status: res.status };
|
|
24
|
-
},
|
|
25
|
-
postJson: (url, body, headers) => postJson(url, body, headers),
|
|
26
|
-
};
|
|
27
|
-
const FRESHNESS_BONUS = { fresh: 30, stale_grace: 10 };
|
|
28
|
-
export default class ResearchSearch extends BaseCommand {
|
|
29
|
-
static id = 'search';
|
|
30
|
-
static summary = 'Search locally cached research artifacts by keywords.';
|
|
31
|
-
static description = 'Scans the local research cache database and ranks matching entries based on topic, tags, source URLs, summary, and content.';
|
|
32
|
-
static args = {
|
|
33
|
-
query: Args.string({
|
|
34
|
-
required: true,
|
|
35
|
-
description: 'The search query containing terms to match.',
|
|
36
|
-
}),
|
|
37
|
-
};
|
|
38
|
-
static flags = {
|
|
39
|
-
topic: Flags.string({
|
|
40
|
-
char: 't',
|
|
41
|
-
description: 'Filter results by exact topic (case-insensitive).',
|
|
42
|
-
}),
|
|
43
|
-
tags: Flags.string({
|
|
44
|
-
char: 'g',
|
|
45
|
-
description: 'Filter results by tags (must match all tags specified).',
|
|
46
|
-
multiple: true,
|
|
47
|
-
}),
|
|
48
|
-
'artifact-type': Flags.option({
|
|
49
|
-
description: 'Filter results by artifact type.',
|
|
50
|
-
options: ['source', 'research_note', 'index', 'section'],
|
|
51
|
-
})(),
|
|
52
|
-
limit: Flags.integer({
|
|
53
|
-
description: 'Maximum number of results to return (default 10, max 50).',
|
|
54
|
-
default: 10,
|
|
55
|
-
}),
|
|
56
|
-
'include-stale': Flags.boolean({
|
|
57
|
-
description: 'Include stale expired cache entries in the search results.',
|
|
58
|
-
default: false,
|
|
59
|
-
}),
|
|
60
|
-
domain: Flags.string({
|
|
61
|
-
description: 'Search a documentation site directly (e.g. help.salesforce.com) instead of the local cache. Requires a site module that implements search.',
|
|
62
|
-
}),
|
|
63
|
-
remote: Flags.string({
|
|
64
|
-
description: 'Discover uncached docs via a site’s public search or index (Algolia DocSearch, MkDocs/Sphinx/Just-the-Docs, llms.txt, sitemap.xml). Pass a docs page URL; falls back to local cache search if no connector applies.',
|
|
65
|
-
}),
|
|
66
|
-
};
|
|
67
|
-
static stdoutIsPrimaryData = true;
|
|
68
|
-
validateSearchFlags() {
|
|
69
|
-
const limit = this.flags.limit;
|
|
70
|
-
if (limit !== undefined && (limit < 1 || limit > 50)) {
|
|
71
|
-
this.error('Limit must be between 1 and 50.', { exit: 2 });
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
getSearchQueryTerms(query) {
|
|
75
|
-
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
76
|
-
if (terms.length === 0) {
|
|
77
|
-
this.error('Query string cannot be empty.', { exit: 2 });
|
|
78
|
-
}
|
|
79
|
-
return terms;
|
|
80
|
-
}
|
|
81
|
-
matchesFilters(meta, freshness, topicFlag, tagsFlag, artTypeFlag, includeStale) {
|
|
82
|
-
if (topicFlag &&
|
|
83
|
-
(!meta.topic || meta.topic.trim().toLowerCase() !== topicFlag.trim().toLowerCase())) {
|
|
84
|
-
return false;
|
|
85
|
-
}
|
|
86
|
-
if (tagsFlag && tagsFlag.length > 0) {
|
|
87
|
-
const metaTagsLower = (meta.tags || []).map((t) => t.toLowerCase());
|
|
88
|
-
const hasAllTags = tagsFlag.every((t) => metaTagsLower.includes(t.toLowerCase()));
|
|
89
|
-
if (!hasAllTags) {
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
if (artTypeFlag && meta.artifact_type !== artTypeFlag) {
|
|
94
|
-
return false;
|
|
95
|
-
}
|
|
96
|
-
if (!includeStale && freshness === 'stale_expired') {
|
|
97
|
-
return false;
|
|
98
|
-
}
|
|
99
|
-
return true;
|
|
100
|
-
}
|
|
101
|
-
phraseMatchBonus(topic, tags, summ, comp, queryTerms) {
|
|
102
|
-
if (queryTerms.length <= 1)
|
|
103
|
-
return 0;
|
|
104
|
-
const fullQuery = queryTerms.join(' ');
|
|
105
|
-
let bonus = 0;
|
|
106
|
-
if (topic === fullQuery)
|
|
107
|
-
bonus += 200;
|
|
108
|
-
if (topic.includes(fullQuery))
|
|
109
|
-
bonus += 100;
|
|
110
|
-
if (tags.includes(fullQuery))
|
|
111
|
-
bonus += 150;
|
|
112
|
-
if (summ.includes(fullQuery))
|
|
113
|
-
bonus += 50 + Math.min(summ.split(fullQuery).length - 1, 5) * 10;
|
|
114
|
-
if (comp.includes(fullQuery))
|
|
115
|
-
bonus += 20 + Math.min(comp.split(fullQuery).length - 1, 10) * 5;
|
|
116
|
-
return bonus;
|
|
117
|
-
}
|
|
118
|
-
calculateScore(meta, summary, compressed, queryTerms, freshness) {
|
|
119
|
-
const topic = (meta.topic || '').toLowerCase();
|
|
120
|
-
const tags = (meta.tags || []).map((t) => t.toLowerCase());
|
|
121
|
-
const sourceUrl = (meta.source_url || '').toLowerCase();
|
|
122
|
-
const sourceUrls = (meta.source_urls || []).map((u) => u.toLowerCase());
|
|
123
|
-
const summ = summary.toLowerCase();
|
|
124
|
-
const comp = compressed.toLowerCase();
|
|
125
|
-
let score = 0;
|
|
126
|
-
let matchedAny = false;
|
|
127
|
-
for (const term of queryTerms) {
|
|
128
|
-
const termScore = scoreSingleTerm(term, topic, tags, sourceUrl, sourceUrls, summ, comp);
|
|
129
|
-
if (termScore > 0) {
|
|
130
|
-
score += termScore;
|
|
131
|
-
matchedAny = true;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
if (!matchedAny)
|
|
135
|
-
return 0;
|
|
136
|
-
score += this.phraseMatchBonus(topic, tags, summ, comp, queryTerms);
|
|
137
|
-
score += FRESHNESS_BONUS[freshness] ?? 0;
|
|
138
|
-
return score;
|
|
139
|
-
}
|
|
140
|
-
makeSnippet(text, queryTerms) {
|
|
141
|
-
const lower = text.toLowerCase();
|
|
142
|
-
let bestIdx = 0;
|
|
143
|
-
for (const term of queryTerms) {
|
|
144
|
-
const idx = lower.indexOf(term);
|
|
145
|
-
if (idx !== -1) {
|
|
146
|
-
bestIdx = idx;
|
|
147
|
-
break;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
const start = Math.max(0, bestIdx - 40);
|
|
151
|
-
const end = Math.min(text.length, bestIdx + 110);
|
|
152
|
-
let snippet = text.slice(start, end).replace(/\s+/g, ' ').trim();
|
|
153
|
-
if (start > 0)
|
|
154
|
-
snippet = '...' + snippet;
|
|
155
|
-
if (end < text.length)
|
|
156
|
-
snippet = snippet + '...';
|
|
157
|
-
return snippet;
|
|
158
|
-
}
|
|
159
|
-
scanCacheDirForResults(readRoots, queryTerms, currentTime) {
|
|
160
|
-
const results = [];
|
|
161
|
-
for (const { artifact, filePath } of loadSearchableArtifacts(readRoots)) {
|
|
162
|
-
if (artifact.metadata.status !== 'active')
|
|
163
|
-
continue;
|
|
164
|
-
const freshness = evaluateFreshness(artifact.metadata, currentTime, null);
|
|
165
|
-
if (!this.matchesFilters(artifact.metadata, freshness, this.flags.topic, this.flags.tags, this.flags['artifact-type'], Boolean(this.flags['include-stale'])))
|
|
166
|
-
continue;
|
|
167
|
-
let score = this.calculateScore(artifact.metadata, artifact.summary, artifact.compressed, queryTerms, freshness);
|
|
168
|
-
if (score <= 0)
|
|
169
|
-
continue;
|
|
170
|
-
// Section children are more precise than the whole page, so rank a section hit slightly
|
|
171
|
-
// above its parent for the same query (T-22).
|
|
172
|
-
if (artifact.metadata.artifact_type === 'section')
|
|
173
|
-
score += 15;
|
|
174
|
-
const snippetText = [artifact.summary, artifact.compressed].filter(Boolean).join('\n');
|
|
175
|
-
results.push({
|
|
176
|
-
cacheKey: artifact.metadata.cache_key,
|
|
177
|
-
path: filePath,
|
|
178
|
-
artifactType: artifact.metadata.artifact_type,
|
|
179
|
-
sourceUrls: artifact.metadata.source_urls,
|
|
180
|
-
topic: artifact.metadata.topic,
|
|
181
|
-
tags: artifact.metadata.tags,
|
|
182
|
-
freshness,
|
|
183
|
-
captureMethod: artifact.metadata.capture_method,
|
|
184
|
-
tokenEstimate: artifact.metadata.token_estimate,
|
|
185
|
-
snippet: this.makeSnippet(snippetText, queryTerms),
|
|
186
|
-
siteModuleId: artifact.metadata.site_module_id,
|
|
187
|
-
score,
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
return results;
|
|
191
|
-
}
|
|
192
|
-
logSearchResults(finalResults, totalMatched) {
|
|
193
|
-
if (this.jsonEnabled())
|
|
194
|
-
return;
|
|
195
|
-
if (finalResults.length === 0) {
|
|
196
|
-
this.log('No matching cached research entries found.');
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
this.log(`${resultListHeading(totalMatched, finalResults.length, SEARCH_LABELS)}\n`);
|
|
200
|
-
finalResults.forEach((res, index) => {
|
|
201
|
-
this.log(`${index + 1}. [${res.topic || 'No Topic'}] Score: ${res.score}`);
|
|
202
|
-
this.log(` Cache Key: ${res.cacheKey}`);
|
|
203
|
-
this.log(` Snippet: ${res.snippet}`);
|
|
204
|
-
this.log(` Source URLs: ${res.sourceUrls.join(', ')}\n`);
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
// Prints a numbered result list to stdout for human (non-JSON) output. Shared by site and remote
|
|
208
|
-
// search so the two paths can't drift in formatting.
|
|
209
|
-
printResults(heading, results) {
|
|
210
|
-
if (this.jsonEnabled())
|
|
211
|
-
return;
|
|
212
|
-
this.log(heading);
|
|
213
|
-
results.forEach((r, i) => {
|
|
214
|
-
this.log(`${i + 1}. ${r.title}`);
|
|
215
|
-
this.log(` URL: ${r.url}`);
|
|
216
|
-
if (r.snippet)
|
|
217
|
-
this.log(` ${r.snippet}`);
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
async executeSiteSearch(query, domain) {
|
|
221
|
-
const siteModule = detectSite(`https://${domain}`);
|
|
222
|
-
if (!siteModule) {
|
|
223
|
-
this.error(`No site module registered for domain: ${domain}`, { exit: 2 });
|
|
224
|
-
}
|
|
225
|
-
if (!siteModule.search) {
|
|
226
|
-
this.error(`Site module '${siteModule.id}' does not implement search.`, { exit: 2 });
|
|
227
|
-
}
|
|
228
|
-
const results = await siteModule.search(query);
|
|
229
|
-
this.printResults(`Found ${results.length} results from ${siteModule.name}:\n`, results);
|
|
230
|
-
return results.map((r) => ({ ...r, site_module_id: siteModule.id }));
|
|
231
|
-
}
|
|
232
|
-
// Remote docs discovery. On any connector failure, degrade to local cache search with a warning
|
|
233
|
-
// (T-20). Returns discovery results tagged remote: true so callers can tell them from cache hits.
|
|
234
|
-
async executeRemoteSearch(query, docsUrl) {
|
|
235
|
-
try {
|
|
236
|
-
const { provider, results } = await runRemoteDocsSearch(docsUrl, query, REMOTE_SEARCH_DEPS);
|
|
237
|
-
this.printResults(`Found ${results.length} remote results via ${provider}:\n`, results);
|
|
238
|
-
return results.map((r) => ({ ...r, remote: true }));
|
|
239
|
-
}
|
|
240
|
-
catch (err) {
|
|
241
|
-
this.warn(`Remote docs search unavailable, using local cache: ${err.message}`);
|
|
242
|
-
return undefined;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
async run() {
|
|
246
|
-
const { query } = this.args;
|
|
247
|
-
const { domain, remote } = this.flags;
|
|
248
|
-
if (domain) {
|
|
249
|
-
return this.executeSiteSearch(query, domain);
|
|
250
|
-
}
|
|
251
|
-
if (remote) {
|
|
252
|
-
const remoteResults = await this.executeRemoteSearch(query, remote);
|
|
253
|
-
if (remoteResults !== undefined)
|
|
254
|
-
return remoteResults;
|
|
255
|
-
// else: fall through to local cache search
|
|
256
|
-
}
|
|
257
|
-
this.validateSearchFlags();
|
|
258
|
-
const queryTerms = this.getSearchQueryTerms(query);
|
|
259
|
-
const roots = loadStoreRoots({
|
|
260
|
-
configDir: this.config.configDir,
|
|
261
|
-
cwd: process.cwd(),
|
|
262
|
-
dataDir: this.config.dataDir,
|
|
263
|
-
});
|
|
264
|
-
const currentTime = new Date();
|
|
265
|
-
const results = this.scanCacheDirForResults(roots.readRoots, queryTerms, currentTime);
|
|
266
|
-
results.sort((a, b) => b.score - a.score);
|
|
267
|
-
const finalResults = results.slice(0, this.flags.limit);
|
|
268
|
-
// Under --json the human heading is suppressed, so surface truncation on stderr instead (warn
|
|
269
|
-
// always emits, even in --json) without touching the stdout envelope. Human mode already shows
|
|
270
|
-
// it in the heading, so only warn under --json.
|
|
271
|
-
const notice = truncationNotice(results.length, finalResults.length, SEARCH_LABELS);
|
|
272
|
-
if (notice && this.jsonEnabled())
|
|
273
|
-
this.warn(notice);
|
|
274
|
-
this.logSearchResults(finalResults, results.length);
|
|
275
|
-
return finalResults;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
function scoreSingleTerm(term, topic, tags, sourceUrl, sourceUrls, summ, comp) {
|
|
279
|
-
let termScore = 0;
|
|
280
|
-
let termMatched = false;
|
|
281
|
-
// 1. Topic Match
|
|
282
|
-
if (topic === term) {
|
|
283
|
-
termScore += 100;
|
|
284
|
-
termMatched = true;
|
|
285
|
-
}
|
|
286
|
-
else if (topic.includes(term)) {
|
|
287
|
-
termScore += 60;
|
|
288
|
-
termMatched = true;
|
|
289
|
-
}
|
|
290
|
-
else if (isFuzzyMatch(term, topic)) {
|
|
291
|
-
termScore += 40;
|
|
292
|
-
termMatched = true;
|
|
293
|
-
}
|
|
294
|
-
// 2. Tags Match
|
|
295
|
-
if (tags.includes(term)) {
|
|
296
|
-
termScore += 80;
|
|
297
|
-
termMatched = true;
|
|
298
|
-
}
|
|
299
|
-
else if (tags.some((t) => isFuzzyMatch(term, t) || t.includes(term))) {
|
|
300
|
-
termScore += 30;
|
|
301
|
-
termMatched = true;
|
|
302
|
-
}
|
|
303
|
-
// 3. Source URL Match
|
|
304
|
-
if (sourceUrl.includes(term) || sourceUrls.some((u) => u.includes(term))) {
|
|
305
|
-
termScore += 50;
|
|
306
|
-
termMatched = true;
|
|
307
|
-
}
|
|
308
|
-
// 4. Summary Term Frequency Match
|
|
309
|
-
const summMatches = summ.split(term).length - 1;
|
|
310
|
-
if (summMatches > 0) {
|
|
311
|
-
termScore += 20 + Math.min(summMatches, 5) * 5;
|
|
312
|
-
termMatched = true;
|
|
313
|
-
}
|
|
314
|
-
// 5. Compressed Content Term Frequency Match
|
|
315
|
-
const compMatches = comp.split(term).length - 1;
|
|
316
|
-
if (compMatches > 0) {
|
|
317
|
-
termScore += 5 + Math.min(compMatches, 10) * 2;
|
|
318
|
-
termMatched = true;
|
|
319
|
-
}
|
|
320
|
-
if (termMatched) {
|
|
321
|
-
termScore += 10;
|
|
322
|
-
}
|
|
323
|
-
return termScore;
|
|
324
|
-
}
|
|
325
|
-
function isFuzzyMatch(term, target) {
|
|
326
|
-
if (term.length < 4 || target.length < 4)
|
|
327
|
-
return false;
|
|
328
|
-
return levenshtein(term, target) <= 2;
|
|
329
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
Search.setIndex({ "docnames": ["index", "library/functions", "library/string"], "filenames": ["index.rst", "library/functions.rst", "library/string.rst"], "titles": ["Python Documentation", "Built-in Functions", "String Methods"], "terms": { "function": [1], "string": [2], "print": [1], "format": [2] }, "titleterms": { "function": [1], "string": [2], "python": [0], "built": [1], "methods": [2] }, "objects": {}, "envversion": {} });
|
|
2
|
-
export {};
|