@taurgis/bonsai 1.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 +239 -0
- package/agents/agents/official-docs-researcher.agent.md +98 -0
- package/agents/instructions/web-research.instructions.md +29 -0
- package/agents/skills/web-research/SKILL.md +126 -0
- package/bin/cli.mjs +22 -0
- package/dist/base-command.d.ts +14 -0
- package/dist/base-command.js +56 -0
- package/dist/commands/config/base.d.ts +23 -0
- package/dist/commands/config/base.js +46 -0
- package/dist/commands/config/get.d.ts +20 -0
- package/dist/commands/config/get.js +48 -0
- package/dist/commands/config/list.d.ts +17 -0
- package/dist/commands/config/list.js +48 -0
- package/dist/commands/config/set.d.ts +31 -0
- package/dist/commands/config/set.js +94 -0
- package/dist/commands/config/unset.d.ts +21 -0
- package/dist/commands/config/unset.js +65 -0
- package/dist/commands/fetch.d.ts +38 -0
- package/dist/commands/fetch.js +311 -0
- package/dist/commands/import.d.ts +38 -0
- package/dist/commands/import.js +313 -0
- package/dist/commands/index.d.ts +35 -0
- package/dist/commands/index.js +306 -0
- package/dist/commands/inspect.d.ts +13 -0
- package/dist/commands/inspect.js +82 -0
- package/dist/commands/list.d.ts +25 -0
- package/dist/commands/list.js +143 -0
- package/dist/commands/prune.d.ts +24 -0
- package/dist/commands/prune.js +141 -0
- package/dist/commands/research/config/base.d.ts +23 -0
- package/dist/commands/research/config/base.js +46 -0
- package/dist/commands/research/config/get.d.ts +20 -0
- package/dist/commands/research/config/get.js +45 -0
- package/dist/commands/research/config/list.d.ts +17 -0
- package/dist/commands/research/config/list.js +42 -0
- package/dist/commands/research/config/set.d.ts +31 -0
- package/dist/commands/research/config/set.js +94 -0
- package/dist/commands/research/config/unset.d.ts +21 -0
- package/dist/commands/research/config/unset.js +59 -0
- package/dist/commands/research/import.d.ts +38 -0
- package/dist/commands/research/import.js +313 -0
- package/dist/commands/research/inspect.d.ts +13 -0
- package/dist/commands/research/inspect.js +86 -0
- package/dist/commands/research/list.d.ts +25 -0
- package/dist/commands/research/list.js +143 -0
- package/dist/commands/research/prune.d.ts +24 -0
- package/dist/commands/research/prune.js +141 -0
- package/dist/commands/research/search.d.ts +32 -0
- package/dist/commands/research/search.js +334 -0
- package/dist/commands/research/status.d.ts +17 -0
- package/dist/commands/research/status.js +108 -0
- package/dist/commands/research.d.ts +36 -0
- package/dist/commands/research.js +304 -0
- package/dist/commands/search.d.ts +32 -0
- package/dist/commands/search.js +334 -0
- package/dist/commands/status.d.ts +17 -0
- package/dist/commands/status.js +95 -0
- package/dist/commands.d.ts +24 -0
- package/dist/commands.js +24 -0
- package/dist/lib/config/index.d.ts +18 -0
- package/dist/lib/config/index.js +33 -0
- package/dist/lib/config/io.d.ts +8 -0
- package/dist/lib/config/io.js +75 -0
- package/dist/lib/config/resolve.d.ts +15 -0
- package/dist/lib/config/resolve.js +20 -0
- package/dist/lib/config/schema.d.ts +28 -0
- package/dist/lib/config/schema.js +32 -0
- package/dist/lib/research/artifact.d.ts +21 -0
- package/dist/lib/research/artifact.js +217 -0
- package/dist/lib/research/browser.d.ts +74 -0
- package/dist/lib/research/browser.js +359 -0
- package/dist/lib/research/cache-key.d.ts +5 -0
- package/dist/lib/research/cache-key.js +10 -0
- package/dist/lib/research/capture.d.ts +29 -0
- package/dist/lib/research/capture.js +181 -0
- package/dist/lib/research/compress.d.ts +5 -0
- package/dist/lib/research/compress.js +17 -0
- package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.d.ts +1 -0
- package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.js +2 -0
- package/dist/lib/research/docs/capabilities.d.ts +43 -0
- package/dist/lib/research/docs/capabilities.js +9 -0
- package/dist/lib/research/docs/clean-dom.d.ts +4 -0
- package/dist/lib/research/docs/clean-dom.js +41 -0
- package/dist/lib/research/docs/code-blocks.d.ts +5 -0
- package/dist/lib/research/docs/code-blocks.js +69 -0
- package/dist/lib/research/docs/detect.d.ts +6 -0
- package/dist/lib/research/docs/detect.js +239 -0
- package/dist/lib/research/docs/machine-readable.d.ts +21 -0
- package/dist/lib/research/docs/machine-readable.js +73 -0
- package/dist/lib/research/docs/markdown-source.d.ts +17 -0
- package/dist/lib/research/docs/markdown-source.js +194 -0
- package/dist/lib/research/docs/quality-gates.d.ts +9 -0
- package/dist/lib/research/docs/quality-gates.js +65 -0
- package/dist/lib/research/docs/remote-search-runner.d.ts +21 -0
- package/dist/lib/research/docs/remote-search-runner.js +54 -0
- package/dist/lib/research/docs/remote-search.d.ts +14 -0
- package/dist/lib/research/docs/remote-search.js +101 -0
- package/dist/lib/research/docs/rsc.d.ts +10 -0
- package/dist/lib/research/docs/rsc.js +30 -0
- package/dist/lib/research/docs/search-index.d.ts +13 -0
- package/dist/lib/research/docs/search-index.js +176 -0
- package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
- package/dist/lib/research/docs/section-artifacts.js +75 -0
- package/dist/lib/research/docs/sections.d.ts +12 -0
- package/dist/lib/research/docs/sections.js +67 -0
- package/dist/lib/research/docs/source-map.d.ts +19 -0
- package/dist/lib/research/docs/source-map.js +87 -0
- package/dist/lib/research/docs/validate.d.ts +15 -0
- package/dist/lib/research/docs/validate.js +63 -0
- package/dist/lib/research/extract.d.ts +11 -0
- package/dist/lib/research/extract.js +83 -0
- package/dist/lib/research/fetcher.d.ts +29 -0
- package/dist/lib/research/fetcher.js +192 -0
- package/dist/lib/research/freshness.d.ts +21 -0
- package/dist/lib/research/freshness.js +82 -0
- package/dist/lib/research/markdown.d.ts +6 -0
- package/dist/lib/research/markdown.js +66 -0
- package/dist/lib/research/regression.d.ts +23 -0
- package/dist/lib/research/regression.js +93 -0
- package/dist/lib/research/resolve-target.d.ts +18 -0
- package/dist/lib/research/resolve-target.js +20 -0
- package/dist/lib/research/revalidate.d.ts +32 -0
- package/dist/lib/research/revalidate.js +152 -0
- package/dist/lib/research/schema.d.ts +47 -0
- package/dist/lib/research/schema.js +1 -0
- package/dist/lib/research/secret-scan.d.ts +5 -0
- package/dist/lib/research/secret-scan.js +37 -0
- package/dist/lib/research/secure-write.d.ts +16 -0
- package/dist/lib/research/secure-write.js +17 -0
- package/dist/lib/research/storage.d.ts +52 -0
- package/dist/lib/research/storage.js +182 -0
- package/dist/lib/research/store-roots.d.ts +29 -0
- package/dist/lib/research/store-roots.js +28 -0
- package/dist/lib/research/token-estimate.d.ts +5 -0
- package/dist/lib/research/token-estimate.js +7 -0
- package/dist/lib/research/url.d.ts +24 -0
- package/dist/lib/research/url.js +121 -0
- package/dist/sites/index.d.ts +4 -0
- package/dist/sites/index.js +28 -0
- package/dist/sites/salesforce/browser-search.d.ts +7 -0
- package/dist/sites/salesforce/browser-search.js +82 -0
- package/dist/sites/salesforce/coveo.d.ts +23 -0
- package/dist/sites/salesforce/coveo.js +161 -0
- package/dist/sites/salesforce/fetch-page.d.ts +6 -0
- package/dist/sites/salesforce/fetch-page.js +42 -0
- package/dist/sites/salesforce/index.d.ts +2 -0
- package/dist/sites/salesforce/index.js +16 -0
- package/dist/sites/salesforce/token.d.ts +26 -0
- package/dist/sites/salesforce/token.js +154 -0
- package/dist/sites/salesforce-developer/fetch-page.d.ts +3 -0
- package/dist/sites/salesforce-developer/fetch-page.js +18 -0
- package/dist/sites/salesforce-developer/index.d.ts +2 -0
- package/dist/sites/salesforce-developer/index.js +12 -0
- package/dist/sites/salesforce-doc-fetch.d.ts +14 -0
- package/dist/sites/salesforce-doc-fetch.js +321 -0
- package/dist/sites/types.d.ts +27 -0
- package/dist/sites/types.js +1 -0
- package/oclif.manifest.json +845 -0
- package/package.json +78 -0
|
@@ -0,0 +1,311 @@
|
|
|
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 { writeArtifact, getArtifactPath } from '../lib/research/storage.js';
|
|
7
|
+
import { writeArtifactSecurely } from '../lib/research/secure-write.js';
|
|
8
|
+
import { evaluateFreshness, parseTtlToMs, checkMaxAgeExpired } from '../lib/research/freshness.js';
|
|
9
|
+
import { revalidateCache, createArtifactFromFetch } from '../lib/research/revalidate.js';
|
|
10
|
+
import { fetchStaticHtml, fetchText } from '../lib/research/fetcher.js';
|
|
11
|
+
import { fetchRenderedHtml } from '../lib/research/browser.js';
|
|
12
|
+
import { capturePage } from '../lib/research/capture.js';
|
|
13
|
+
import { persistSectionArtifacts } from '../lib/research/docs/section-artifacts.js';
|
|
14
|
+
import { resolveResearchTarget } from '../lib/research/resolve-target.js';
|
|
15
|
+
import { detectSite } from '../sites/index.js';
|
|
16
|
+
const CAPTURE_DEPS = {
|
|
17
|
+
fetchStatic: (url) => fetchStaticHtml(url),
|
|
18
|
+
fetchRendered: (url) => fetchRenderedHtml(url),
|
|
19
|
+
fetchText: (url) => fetchText(url),
|
|
20
|
+
};
|
|
21
|
+
export default class FetchCommand extends BaseCommand {
|
|
22
|
+
static id = 'fetch';
|
|
23
|
+
static hidden = true;
|
|
24
|
+
static summary = 'An advanced, locally cached web research tool optimized for LLM ingestion.';
|
|
25
|
+
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.';
|
|
26
|
+
static examples = [
|
|
27
|
+
{
|
|
28
|
+
description: 'Research a URL with detailed output, tagged with topic and tags',
|
|
29
|
+
command: '<%= config.bin %> https://docs.nestjs.com/ --topic "Backend Frameworks" --tags "Node" --tags "NestJS" --format detailed --ttl 30d',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
description: 'Research a volatile page with compressed output and short TTL, returned as JSON',
|
|
33
|
+
command: '<%= config.bin %> https://news.ycombinator.com/ --format compressed --ttl 2h --json',
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
static args = {
|
|
37
|
+
url: Args.string({
|
|
38
|
+
required: true,
|
|
39
|
+
description: 'The full HTTP/HTTPS URL of the web page to research.',
|
|
40
|
+
}),
|
|
41
|
+
};
|
|
42
|
+
static flags = {
|
|
43
|
+
topic: Flags.string({
|
|
44
|
+
char: 't',
|
|
45
|
+
description: 'The main category/topic of the research for metadata tagging.',
|
|
46
|
+
}),
|
|
47
|
+
tags: Flags.string({
|
|
48
|
+
char: 'g',
|
|
49
|
+
description: 'Taxonomic tags for this research (can be repeated).',
|
|
50
|
+
multiple: true,
|
|
51
|
+
}),
|
|
52
|
+
format: Flags.option({
|
|
53
|
+
char: 'f',
|
|
54
|
+
description: 'Desired data density.',
|
|
55
|
+
options: ['compressed', 'detailed'],
|
|
56
|
+
default: 'compressed',
|
|
57
|
+
})(),
|
|
58
|
+
tier: Flags.option({
|
|
59
|
+
description: 'Freshness tier policy.',
|
|
60
|
+
options: ['stable', 'standard', 'volatile'],
|
|
61
|
+
default: 'standard',
|
|
62
|
+
})(),
|
|
63
|
+
ttl: Flags.string({
|
|
64
|
+
char: 'l',
|
|
65
|
+
description: 'Predicted lifespan of the data (e.g., "2h", "7d", "3m").',
|
|
66
|
+
}),
|
|
67
|
+
'max-age': Flags.string({
|
|
68
|
+
description: 'Maximum age of cache to accept (e.g., "1d", "30d").',
|
|
69
|
+
}),
|
|
70
|
+
force: Flags.boolean({
|
|
71
|
+
description: 'Force a fresh fetch, ignoring any cached entries.',
|
|
72
|
+
default: false,
|
|
73
|
+
}),
|
|
74
|
+
'dry-run': Flags.boolean({
|
|
75
|
+
description: 'Perform validation and fetch without saving to local cache.',
|
|
76
|
+
default: false,
|
|
77
|
+
}),
|
|
78
|
+
'allow-stale': Flags.boolean({
|
|
79
|
+
description: 'Allow serving stale cache if the remote server is unreachable.',
|
|
80
|
+
default: false,
|
|
81
|
+
}),
|
|
82
|
+
rendered: Flags.boolean({
|
|
83
|
+
description: 'Force using a browser-rendered scraping path for dynamic pages.',
|
|
84
|
+
default: false,
|
|
85
|
+
}),
|
|
86
|
+
storage: Flags.option({
|
|
87
|
+
description: 'Override where this result is cached (overrides configured default). Secret-bearing pages are always stored globally.',
|
|
88
|
+
options: ['global', 'project'],
|
|
89
|
+
})(),
|
|
90
|
+
};
|
|
91
|
+
static stdoutIsPrimaryData = true;
|
|
92
|
+
envelopeCommandId() {
|
|
93
|
+
return this.config.bin;
|
|
94
|
+
}
|
|
95
|
+
async init() {
|
|
96
|
+
await super.init();
|
|
97
|
+
const { args, flags } = await this.parse(FetchCommand);
|
|
98
|
+
this.args = args;
|
|
99
|
+
this.flags = flags;
|
|
100
|
+
}
|
|
101
|
+
async executeCacheHit(cached, targetDir, currentTime) {
|
|
102
|
+
const { ttl, 'max-age': maxAge, 'allow-stale': allowStale, rendered } = this.flags;
|
|
103
|
+
const isExpired = checkMaxAgeExpired(cached, currentTime, maxAge);
|
|
104
|
+
const freshnessState = isExpired
|
|
105
|
+
? 'stale_expired'
|
|
106
|
+
: evaluateFreshness(cached.metadata, currentTime, ttl);
|
|
107
|
+
if (freshnessState === 'fresh') {
|
|
108
|
+
return { cacheStatus: 'hit', freshnessState, artifact: cached };
|
|
109
|
+
}
|
|
110
|
+
const revalResult = await revalidateCache(targetDir, cached, currentTime, {
|
|
111
|
+
allowStale: Boolean(allowStale),
|
|
112
|
+
ttlOverride: ttl,
|
|
113
|
+
rendered: Boolean(rendered),
|
|
114
|
+
});
|
|
115
|
+
handleStaleRevalidationResult(this, revalResult);
|
|
116
|
+
return {
|
|
117
|
+
cacheStatus: revalResult.status,
|
|
118
|
+
freshnessState,
|
|
119
|
+
artifact: revalResult.artifact,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
async executeCacheMiss(normalizedUrl, currentTime, cacheKey) {
|
|
123
|
+
const { topic, tags, tier, ttl, rendered } = this.flags;
|
|
124
|
+
const siteModule = detectSite(normalizedUrl);
|
|
125
|
+
const useRendered = rendered || Boolean(siteModule?.defaults?.rendered);
|
|
126
|
+
let fetchResult;
|
|
127
|
+
let extraction;
|
|
128
|
+
let capture = null;
|
|
129
|
+
if (siteModule?.fetchPage) {
|
|
130
|
+
// Custom site modules own their fetch/extract strategy; the generic capture path is skipped.
|
|
131
|
+
({ fetchResult, extraction } = await siteModule.fetchPage(normalizedUrl));
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
capture = await capturePage(normalizedUrl, { forceRendered: useRendered }, CAPTURE_DEPS);
|
|
135
|
+
fetchResult = capture.fetchResult;
|
|
136
|
+
extraction = capture.extraction;
|
|
137
|
+
}
|
|
138
|
+
const artifact = createArtifactFromFetch(normalizedUrl, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl || null, currentTime);
|
|
139
|
+
artifact.metadata.topic = topic || null;
|
|
140
|
+
artifact.metadata.tags = tags || [];
|
|
141
|
+
artifact.metadata.site_module_id = siteModule?.id ?? null;
|
|
142
|
+
if (capture) {
|
|
143
|
+
applyCaptureMetadata(artifact, capture);
|
|
144
|
+
}
|
|
145
|
+
else if (useRendered) {
|
|
146
|
+
artifact.metadata.capture_method = 'browser_fallback';
|
|
147
|
+
}
|
|
148
|
+
return artifact;
|
|
149
|
+
}
|
|
150
|
+
buildResultData(url, normalizedUrl, cacheKey, storageDir, storageMode, cacheStatus, freshnessState, format, artifact, content, redirectedToGlobal) {
|
|
151
|
+
return {
|
|
152
|
+
schemaVersion: 1,
|
|
153
|
+
command: this.config.bin,
|
|
154
|
+
cache: {
|
|
155
|
+
key: cacheKey,
|
|
156
|
+
status: cacheStatus,
|
|
157
|
+
freshness: freshnessState,
|
|
158
|
+
path: getArtifactPath(storageDir, cacheKey),
|
|
159
|
+
storage: storageMode,
|
|
160
|
+
redirectedToGlobal,
|
|
161
|
+
},
|
|
162
|
+
source: {
|
|
163
|
+
url,
|
|
164
|
+
normalizedUrl,
|
|
165
|
+
captureMethod: artifact.metadata.capture_method,
|
|
166
|
+
extractionStatus: artifact.metadata.extraction_status,
|
|
167
|
+
extractionConfidence: artifact.metadata.extraction_confidence,
|
|
168
|
+
qualityNotes: artifact.metadata.quality_notes,
|
|
169
|
+
fetchedAt: artifact.metadata.fetched_at,
|
|
170
|
+
validatedAt: artifact.metadata.validated_at,
|
|
171
|
+
staleAfter: artifact.metadata.stale_after,
|
|
172
|
+
},
|
|
173
|
+
artifactType: artifact.metadata.artifact_type,
|
|
174
|
+
docsEngine: artifact.metadata.docs_engine,
|
|
175
|
+
docsFramework: artifact.metadata.docs_framework,
|
|
176
|
+
sourceDocUrl: artifact.metadata.source_doc_url,
|
|
177
|
+
searchProvider: artifact.metadata.search_provider,
|
|
178
|
+
format,
|
|
179
|
+
tokenEstimate: format === 'compressed'
|
|
180
|
+
? artifact.metadata.token_estimate.compressed
|
|
181
|
+
: artifact.metadata.token_estimate.detailed,
|
|
182
|
+
content,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
// Validates the duration flags up front, exiting with code 2 on a malformed value.
|
|
186
|
+
validateDurationFlags(ttl, maxAge) {
|
|
187
|
+
for (const [label, value] of [
|
|
188
|
+
['TTL', ttl],
|
|
189
|
+
['max-age', maxAge],
|
|
190
|
+
]) {
|
|
191
|
+
if (!value)
|
|
192
|
+
continue;
|
|
193
|
+
try {
|
|
194
|
+
parseTtlToMs(value);
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
this.error(`Invalid ${label}: ${err.message}`, { exit: 2 });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Serves the cache when a fresh/revalidatable entry exists, otherwise fetches fresh. Reads fall
|
|
202
|
+
// back project→global; the resolved artifact is returned with the dir it lives in / landed in.
|
|
203
|
+
async resolveArtifact(normalizedUrl, cacheKey, roots, tmpDir, currentTime, located) {
|
|
204
|
+
if (located) {
|
|
205
|
+
// Revalidate where the entry already lives; on dry-run use the throwaway dir so the cache is
|
|
206
|
+
// never mutated. ponytail: revalidation rewrites in place, so a refreshed project entry that
|
|
207
|
+
// gains a secret is not re-routed — only first-time project writes are scanned.
|
|
208
|
+
const revalDir = tmpDir ?? located.dataDir;
|
|
209
|
+
const hit = await this.executeCacheHit(located.artifact, revalDir, currentTime);
|
|
210
|
+
return { ...hit, storageDir: located.dataDir, redirectedToGlobal: false };
|
|
211
|
+
}
|
|
212
|
+
const artifact = await this.executeCacheMiss(normalizedUrl, currentTime, cacheKey);
|
|
213
|
+
const { dir, redirectedToGlobal } = this.persistFreshArtifact(roots, tmpDir, cacheKey, artifact);
|
|
214
|
+
return {
|
|
215
|
+
cacheStatus: 'miss',
|
|
216
|
+
freshnessState: 'stale_expired',
|
|
217
|
+
artifact,
|
|
218
|
+
storageDir: dir,
|
|
219
|
+
redirectedToGlobal,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
// Writes a freshly fetched artifact, honoring dry-run (throwaway dir) and the secret-safety
|
|
223
|
+
// redirect (project→global). Returns the data dir reported to the user and whether a redirect
|
|
224
|
+
// occurred (so the JSON envelope mirrors `import`).
|
|
225
|
+
persistFreshArtifact(roots, tmpDir, cacheKey, artifact) {
|
|
226
|
+
if (tmpDir) {
|
|
227
|
+
writeArtifact(tmpDir, cacheKey, artifact);
|
|
228
|
+
// Report the real would-be location, not the throwaway dir that is about to be deleted.
|
|
229
|
+
return { dir: roots.writeRoot, redirectedToGlobal: false };
|
|
230
|
+
}
|
|
231
|
+
const result = writeArtifactSecurely(roots, cacheKey, artifact);
|
|
232
|
+
if (result.redirected) {
|
|
233
|
+
this.warn(`Detected ${result.secretLabel} in the page content; stored in the global cache instead of the project to avoid committing secrets.`);
|
|
234
|
+
}
|
|
235
|
+
return { dir: result.dataDir, redirectedToGlobal: result.redirected };
|
|
236
|
+
}
|
|
237
|
+
// Long references are split into searchable/inspectable section children whenever the page artifact
|
|
238
|
+
// is freshly written (T-22). Best-effort: never let chunking break the main result.
|
|
239
|
+
persistSectionsIfFresh(targetDir, artifact, currentTime, cacheStatus) {
|
|
240
|
+
if (cacheStatus !== 'miss' && cacheStatus !== 'refreshed')
|
|
241
|
+
return;
|
|
242
|
+
try {
|
|
243
|
+
persistSectionArtifacts(targetDir, artifact, currentTime);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
/* section generation is non-essential; ignore failures */
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async execute() {
|
|
250
|
+
const { url } = this.args;
|
|
251
|
+
const { format, ttl, 'max-age': maxAge, 'dry-run': dryRun } = this.flags;
|
|
252
|
+
this.validateDurationFlags(ttl, maxAge);
|
|
253
|
+
let target;
|
|
254
|
+
try {
|
|
255
|
+
target = resolveResearchTarget({
|
|
256
|
+
configDir: this.config.configDir,
|
|
257
|
+
cwd: process.cwd(),
|
|
258
|
+
dataDir: this.config.dataDir,
|
|
259
|
+
flagOverride: this.flags.storage,
|
|
260
|
+
lookup: !this.flags.force,
|
|
261
|
+
url,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
this.error(`Invalid URL: ${err.message}`, { exit: 2 });
|
|
266
|
+
}
|
|
267
|
+
const { cacheKey, located, normalizedUrl, roots } = target;
|
|
268
|
+
const tmpDir = dryRun ? mkdtempSync(join(tmpdir(), 'fnr-dry-run-')) : null;
|
|
269
|
+
const currentTime = new Date();
|
|
270
|
+
try {
|
|
271
|
+
const { cacheStatus, freshnessState, artifact, storageDir, redirectedToGlobal } = await this.resolveArtifact(normalizedUrl, cacheKey, roots, tmpDir, currentTime, located);
|
|
272
|
+
this.persistSectionsIfFresh(tmpDir ?? storageDir, artifact, currentTime, cacheStatus);
|
|
273
|
+
const content = format === 'compressed' ? artifact.compressed : artifact.detailed;
|
|
274
|
+
const resultData = this.buildResultData(url, normalizedUrl, cacheKey, storageDir, roots.mode, cacheStatus, freshnessState, format, artifact, content, redirectedToGlobal);
|
|
275
|
+
if (!this.requestedJson()) {
|
|
276
|
+
this.log(content);
|
|
277
|
+
}
|
|
278
|
+
return resultData;
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
if (tmpDir) {
|
|
282
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// Copies Phase 2 capability provenance from a capture outcome onto the artifact metadata.
|
|
288
|
+
function applyCaptureMetadata(artifact, capture) {
|
|
289
|
+
const meta = artifact.metadata;
|
|
290
|
+
meta.capture_method = capture.captureMethod;
|
|
291
|
+
meta.docs_engine = capture.capabilities.docsEngine ?? null;
|
|
292
|
+
meta.docs_framework = capture.capabilities.framework ?? null;
|
|
293
|
+
meta.source_doc_url = capture.sourceDocUrl;
|
|
294
|
+
meta.search_provider = capture.capabilities.search?.provider ?? null;
|
|
295
|
+
if (capture.extraction.isIndexHub)
|
|
296
|
+
meta.artifact_type = 'index';
|
|
297
|
+
if (capture.sourceDocUrl && !meta.source_urls.includes(capture.sourceDocUrl)) {
|
|
298
|
+
meta.source_urls.push(capture.sourceDocUrl);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function handleStaleRevalidationResult(command, revalResult) {
|
|
302
|
+
if (revalResult.status !== 'stale')
|
|
303
|
+
return;
|
|
304
|
+
if (revalResult.allowed) {
|
|
305
|
+
command.warn(`Serving stale content: revalidation failed: ${revalResult.error}`);
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
command.warn(`Serving stale content (exit 5): revalidation failed: ${revalResult.error}`);
|
|
309
|
+
process.exitCode = 5;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import { BaseCommand } from '../base-command.js';
|
|
3
|
+
export default class ResearchImport extends BaseCommand<typeof ResearchImport> {
|
|
4
|
+
static id: string;
|
|
5
|
+
static summary: string;
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: {
|
|
8
|
+
description: string;
|
|
9
|
+
command: string;
|
|
10
|
+
}[];
|
|
11
|
+
static args: {
|
|
12
|
+
url: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
13
|
+
};
|
|
14
|
+
static flags: {
|
|
15
|
+
stdin: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
|
+
file: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
|
+
'input-format': import("@oclif/core/interfaces").OptionFlag<"compressed" | "detailed", import("@oclif/core/interfaces").CustomOptions>;
|
|
18
|
+
topic: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
19
|
+
tags: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
20
|
+
tier: import("@oclif/core/interfaces").OptionFlag<"stable" | "standard" | "volatile", import("@oclif/core/interfaces").CustomOptions>;
|
|
21
|
+
'source-url': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
22
|
+
ttl: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
23
|
+
storage: import("@oclif/core/interfaces").OptionFlag<"global" | "project" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
24
|
+
};
|
|
25
|
+
static stdoutIsPrimaryData: boolean;
|
|
26
|
+
init(): Promise<void>;
|
|
27
|
+
private readStdin;
|
|
28
|
+
private getSourceUrls;
|
|
29
|
+
private validateSourceMode;
|
|
30
|
+
protected fsExistsSync(filePath: string): boolean;
|
|
31
|
+
protected fsStatSync(filePath: string): fs.Stats;
|
|
32
|
+
protected fsReadFileSync(filePath: string): string;
|
|
33
|
+
private readAndValidateFile;
|
|
34
|
+
private readAndValidateInput;
|
|
35
|
+
private deriveImportCacheKey;
|
|
36
|
+
private buildImportArtifact;
|
|
37
|
+
execute(): Promise<unknown>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as path 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 { 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 { getPolicy } from '../lib/research/freshness.js';
|
|
12
|
+
import { compressMarkdown } from '../lib/research/compress.js';
|
|
13
|
+
import { estimateTokens } from '../lib/research/token-estimate.js';
|
|
14
|
+
export default class ResearchImport extends BaseCommand {
|
|
15
|
+
static id = 'import';
|
|
16
|
+
static summary = 'Import agent-supplied clean Markdown notes into the local cache.';
|
|
17
|
+
static description = 'Accepts a synthesized Markdown note via stdin and registers it under the single target URL or multiple source URLs.';
|
|
18
|
+
static examples = [
|
|
19
|
+
{
|
|
20
|
+
description: 'Import detailed research for a single URL via stdin',
|
|
21
|
+
command: 'echo "# My Article" | <%= config.bin %> import https://example.com/docs --stdin',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
description: 'Import research synthesized from multiple source URLs',
|
|
25
|
+
command: 'echo "# Synthesized" | <%= config.bin %> import --stdin --topic "React docs" --source-url https://react.dev/a --source-url https://react.dev/b',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
description: 'Import research from a local Markdown file',
|
|
29
|
+
command: '<%= config.bin %> import https://example.com/docs --file path/to/notes.md',
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
static args = {
|
|
33
|
+
url: Args.string({
|
|
34
|
+
required: false,
|
|
35
|
+
description: 'The single source URL of the page (only for single-source import).',
|
|
36
|
+
}),
|
|
37
|
+
};
|
|
38
|
+
static flags = {
|
|
39
|
+
stdin: Flags.boolean({
|
|
40
|
+
description: 'Read Markdown from stdin.',
|
|
41
|
+
default: false,
|
|
42
|
+
}),
|
|
43
|
+
file: Flags.string({
|
|
44
|
+
description: 'Path to a Markdown file containing research notes to import.',
|
|
45
|
+
}),
|
|
46
|
+
'input-format': Flags.option({
|
|
47
|
+
description: 'Format of the input provided.',
|
|
48
|
+
options: ['compressed', 'detailed'],
|
|
49
|
+
default: 'detailed',
|
|
50
|
+
})(),
|
|
51
|
+
topic: Flags.string({
|
|
52
|
+
char: 't',
|
|
53
|
+
description: 'The main topic for this research note.',
|
|
54
|
+
}),
|
|
55
|
+
tags: Flags.string({
|
|
56
|
+
char: 'g',
|
|
57
|
+
description: 'Taxonomic tags (can be repeated).',
|
|
58
|
+
multiple: true,
|
|
59
|
+
}),
|
|
60
|
+
tier: Flags.option({
|
|
61
|
+
description: 'Freshness tier policy.',
|
|
62
|
+
options: ['stable', 'standard', 'volatile'],
|
|
63
|
+
default: 'standard',
|
|
64
|
+
})(),
|
|
65
|
+
'source-url': Flags.string({
|
|
66
|
+
description: 'Repeated source URLs for multi-source import.',
|
|
67
|
+
multiple: true,
|
|
68
|
+
}),
|
|
69
|
+
ttl: Flags.string({
|
|
70
|
+
char: 'l',
|
|
71
|
+
description: 'Predicted lifespan of the data (e.g. "24h", "7d").',
|
|
72
|
+
}),
|
|
73
|
+
storage: Flags.option({
|
|
74
|
+
description: 'Override where this note is cached (overrides configured default). Secret-bearing notes are always stored globally.',
|
|
75
|
+
options: ['global', 'project'],
|
|
76
|
+
})(),
|
|
77
|
+
};
|
|
78
|
+
static stdoutIsPrimaryData = true;
|
|
79
|
+
async init() {
|
|
80
|
+
await super.init();
|
|
81
|
+
const { args, flags } = await this.parse(ResearchImport);
|
|
82
|
+
this.args = args;
|
|
83
|
+
this.flags = flags;
|
|
84
|
+
}
|
|
85
|
+
async readStdin(limitBytes = 1024 * 1024) {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
let data = '';
|
|
88
|
+
let bytesRead = 0;
|
|
89
|
+
process.stdin.setEncoding('utf8');
|
|
90
|
+
const onData = (chunk) => {
|
|
91
|
+
bytesRead += Buffer.byteLength(chunk, 'utf8');
|
|
92
|
+
if (bytesRead > limitBytes) {
|
|
93
|
+
process.stdin.removeListener('data', onData);
|
|
94
|
+
reject(new Error('stdin size limit exceeded (max 1 MiB)'));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
data += chunk;
|
|
98
|
+
};
|
|
99
|
+
process.stdin.on('data', onData);
|
|
100
|
+
const onEnd = () => {
|
|
101
|
+
process.stdin.removeListener('data', onData);
|
|
102
|
+
resolve(data);
|
|
103
|
+
};
|
|
104
|
+
process.stdin.once('end', onEnd);
|
|
105
|
+
process.stdin.once('error', (err) => {
|
|
106
|
+
process.stdin.removeListener('data', onData);
|
|
107
|
+
process.stdin.removeListener('end', onEnd);
|
|
108
|
+
reject(err);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
getSourceUrls(hasMulti, multiUrls, singleUrl) {
|
|
113
|
+
const urls = hasMulti ? multiUrls : singleUrl ? [singleUrl] : [];
|
|
114
|
+
const normalized = urls.map((u) => {
|
|
115
|
+
try {
|
|
116
|
+
return normalizeUrl(u);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
this.error(`Invalid URL: ${err.message}`, { exit: 2 });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
return hasMulti ? normalized.sort() : normalized;
|
|
123
|
+
}
|
|
124
|
+
validateSourceMode(hasSingle, hasMulti, multiUrls) {
|
|
125
|
+
if (hasSingle && hasMulti) {
|
|
126
|
+
this.error('Cannot specify both positional <url> and --source-url flags.', { exit: 2 });
|
|
127
|
+
}
|
|
128
|
+
if (!hasSingle && !hasMulti) {
|
|
129
|
+
this.error('Must specify either positional <url> (for single-source) or --source-url (for multi-source) import.', { exit: 2 });
|
|
130
|
+
}
|
|
131
|
+
if (hasMulti && !this.flags.topic) {
|
|
132
|
+
this.error('Multi-source import requires the --topic flag.', { exit: 2 });
|
|
133
|
+
}
|
|
134
|
+
if (!this.flags.stdin && !this.flags.file) {
|
|
135
|
+
this.error('Either --stdin or --file <path> must be specified to import content.', {
|
|
136
|
+
exit: 2,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (this.flags.stdin && this.flags.file) {
|
|
140
|
+
this.error('Cannot specify both --stdin and --file <path>. Choose one.', { exit: 2 });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
fsExistsSync(filePath) {
|
|
144
|
+
return fs.existsSync(filePath);
|
|
145
|
+
}
|
|
146
|
+
fsStatSync(filePath) {
|
|
147
|
+
return fs.statSync(filePath);
|
|
148
|
+
}
|
|
149
|
+
fsReadFileSync(filePath) {
|
|
150
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
151
|
+
}
|
|
152
|
+
readAndValidateFile(filePath) {
|
|
153
|
+
if (!this.fsExistsSync(filePath)) {
|
|
154
|
+
this.error(`File does not exist: ${filePath}`, { exit: 2 });
|
|
155
|
+
}
|
|
156
|
+
const stat = this.fsStatSync(filePath);
|
|
157
|
+
if (!stat.isFile()) {
|
|
158
|
+
this.error(`Path is not a file: ${filePath}`, { exit: 2 });
|
|
159
|
+
}
|
|
160
|
+
if (stat.size > 1024 * 1024) {
|
|
161
|
+
this.error('File size limit exceeded (max 1 MiB).', { exit: 1 });
|
|
162
|
+
}
|
|
163
|
+
const content = this.fsReadFileSync(filePath);
|
|
164
|
+
if (!content.trim()) {
|
|
165
|
+
this.error('Empty file content provided.', { exit: 2 });
|
|
166
|
+
}
|
|
167
|
+
return content;
|
|
168
|
+
}
|
|
169
|
+
async readAndValidateInput() {
|
|
170
|
+
if (this.flags.stdin) {
|
|
171
|
+
try {
|
|
172
|
+
const rawInput = await this.readStdin();
|
|
173
|
+
if (!rawInput.trim()) {
|
|
174
|
+
this.error('Empty stdin content provided.', { exit: 2 });
|
|
175
|
+
}
|
|
176
|
+
return rawInput;
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
this.error(err.message, { exit: 1 });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (this.flags.file) {
|
|
183
|
+
const filePath = path.resolve(this.flags.file);
|
|
184
|
+
try {
|
|
185
|
+
return this.readAndValidateFile(filePath);
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
this.error(`Failed to read file: ${err.message}`, { exit: 1 });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
this.error('No input source specified.', { exit: 2 });
|
|
192
|
+
}
|
|
193
|
+
deriveImportCacheKey(hasSingle, singleNormalizedUrl, sourceUrls) {
|
|
194
|
+
if (hasSingle) {
|
|
195
|
+
return deriveCacheKey(singleNormalizedUrl);
|
|
196
|
+
}
|
|
197
|
+
const normalizedTopic = this.flags.topic.trim().toLowerCase();
|
|
198
|
+
const combinedString = [normalizedTopic, ...sourceUrls].join('|');
|
|
199
|
+
return createHash('sha256').update(combinedString).digest('hex');
|
|
200
|
+
}
|
|
201
|
+
buildImportArtifact(hasSingle, singleNormalizedUrl, sourceUrls, cacheKey, rawInput) {
|
|
202
|
+
const currentTime = new Date();
|
|
203
|
+
const tier = this.flags.tier;
|
|
204
|
+
const ttl = this.flags.ttl || null;
|
|
205
|
+
const { freshWindowMs } = getPolicy(tier, ttl);
|
|
206
|
+
const staleAfterTime = new Date(currentTime.getTime() + freshWindowMs);
|
|
207
|
+
const inputFormat = this.flags['input-format'];
|
|
208
|
+
const detailed = rawInput;
|
|
209
|
+
const compressed = inputFormat === 'detailed' ? compressMarkdown(rawInput) : rawInput;
|
|
210
|
+
const contentHash = createHash('sha256').update(detailed).digest('hex');
|
|
211
|
+
const metadata = {
|
|
212
|
+
schema_version: 1,
|
|
213
|
+
artifact_type: hasSingle ? 'source' : 'research_note',
|
|
214
|
+
source_url: hasSingle ? this.args.url : '',
|
|
215
|
+
source_urls: sourceUrls,
|
|
216
|
+
normalized_url: singleNormalizedUrl,
|
|
217
|
+
cache_key: cacheKey,
|
|
218
|
+
topic: this.flags.topic || null,
|
|
219
|
+
tags: this.flags.tags || [],
|
|
220
|
+
format_available: ['compressed', 'detailed'],
|
|
221
|
+
tier,
|
|
222
|
+
ttl,
|
|
223
|
+
fetched_at: null,
|
|
224
|
+
validated_at: currentTime.toISOString(),
|
|
225
|
+
stale_after: staleAfterTime.toISOString(),
|
|
226
|
+
capture_method: 'agent_supplied',
|
|
227
|
+
extraction_status: 'agent_supplied',
|
|
228
|
+
extraction_confidence: 'high',
|
|
229
|
+
quality_notes: ['agent-supplied research import'],
|
|
230
|
+
supplied_at: currentTime.toISOString(),
|
|
231
|
+
supplied_by: null,
|
|
232
|
+
etag: null,
|
|
233
|
+
last_modified: null,
|
|
234
|
+
content_hash: contentHash,
|
|
235
|
+
token_estimate: {
|
|
236
|
+
compressed: estimateTokens(compressed),
|
|
237
|
+
detailed: estimateTokens(detailed),
|
|
238
|
+
},
|
|
239
|
+
status: 'active',
|
|
240
|
+
site_module_id: null,
|
|
241
|
+
docs_engine: null,
|
|
242
|
+
docs_framework: null,
|
|
243
|
+
source_doc_url: null,
|
|
244
|
+
search_provider: null,
|
|
245
|
+
parent_cache_key: null,
|
|
246
|
+
section_anchor: null,
|
|
247
|
+
section_heading_path: null,
|
|
248
|
+
};
|
|
249
|
+
return {
|
|
250
|
+
metadata,
|
|
251
|
+
summary: `Synthesized research for ${hasSingle ? singleNormalizedUrl : this.flags.topic}`,
|
|
252
|
+
compressed,
|
|
253
|
+
detailed,
|
|
254
|
+
provenance: `Imported via agent-supplied research at ${currentTime.toISOString()}`,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
async execute() {
|
|
258
|
+
const hasSingle = Boolean(this.args.url);
|
|
259
|
+
const multiUrls = this.flags['source-url'] || [];
|
|
260
|
+
const hasMulti = multiUrls.length > 0;
|
|
261
|
+
this.validateSourceMode(hasSingle, hasMulti, multiUrls);
|
|
262
|
+
const rawInput = await this.readAndValidateInput();
|
|
263
|
+
const sourceUrls = this.getSourceUrls(hasMulti, multiUrls, this.args.url);
|
|
264
|
+
const singleNormalizedUrl = hasSingle ? sourceUrls[0] || '' : '';
|
|
265
|
+
const cacheKey = this.deriveImportCacheKey(hasSingle, singleNormalizedUrl, sourceUrls);
|
|
266
|
+
const artifact = this.buildImportArtifact(hasSingle, singleNormalizedUrl, sourceUrls, cacheKey, rawInput);
|
|
267
|
+
const roots = loadStoreRoots({
|
|
268
|
+
configDir: this.config.configDir,
|
|
269
|
+
cwd: process.cwd(),
|
|
270
|
+
dataDir: this.config.dataDir,
|
|
271
|
+
flagOverride: this.flags.storage,
|
|
272
|
+
});
|
|
273
|
+
const writeResult = writeArtifactSecurely(roots, cacheKey, artifact);
|
|
274
|
+
const storagePath = getArtifactPath(writeResult.dataDir, cacheKey);
|
|
275
|
+
if (writeResult.redirected) {
|
|
276
|
+
this.warn(`Detected ${writeResult.secretLabel} in the imported content; stored in the global cache instead of the project to avoid committing secrets.`);
|
|
277
|
+
}
|
|
278
|
+
if (!this.requestedJson()) {
|
|
279
|
+
this.log(`Successfully imported research artifact.`);
|
|
280
|
+
this.log(`Cache Key: ${cacheKey}`);
|
|
281
|
+
this.log(`Storage Path: ${storagePath}`);
|
|
282
|
+
}
|
|
283
|
+
const inputFormat = this.flags['input-format'];
|
|
284
|
+
return {
|
|
285
|
+
schemaVersion: 1,
|
|
286
|
+
command: 'import',
|
|
287
|
+
cache: {
|
|
288
|
+
key: cacheKey,
|
|
289
|
+
status: 'imported',
|
|
290
|
+
freshness: 'fresh',
|
|
291
|
+
path: storagePath,
|
|
292
|
+
storage: roots.mode,
|
|
293
|
+
redirectedToGlobal: writeResult.redirected,
|
|
294
|
+
},
|
|
295
|
+
source: {
|
|
296
|
+
url: hasSingle ? this.args.url : '',
|
|
297
|
+
normalizedUrl: singleNormalizedUrl,
|
|
298
|
+
captureMethod: 'agent_supplied',
|
|
299
|
+
extractionStatus: 'agent_supplied',
|
|
300
|
+
extractionConfidence: 'high',
|
|
301
|
+
qualityNotes: ['agent-supplied research import'],
|
|
302
|
+
fetchedAt: null,
|
|
303
|
+
validatedAt: artifact.metadata.validated_at,
|
|
304
|
+
staleAfter: artifact.metadata.stale_after,
|
|
305
|
+
},
|
|
306
|
+
format: inputFormat,
|
|
307
|
+
tokenEstimate: inputFormat === 'compressed'
|
|
308
|
+
? artifact.metadata.token_estimate.compressed
|
|
309
|
+
: artifact.metadata.token_estimate.detailed,
|
|
310
|
+
content: inputFormat === 'compressed' ? artifact.compressed : artifact.detailed,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
}
|