@taurgis/bonsai 1.0.3 → 1.0.4
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 +10 -4
- package/dist/commands/fetch.js +17 -11
- package/dist/commands/import.js +13 -5
- package/dist/commands/search.js +10 -8
- package/dist/lib/config/index.d.ts +8 -3
- package/dist/lib/config/index.js +19 -4
- package/dist/lib/config/resolve.d.ts +14 -1
- package/dist/lib/config/resolve.js +19 -1
- package/dist/lib/config/schema.d.ts +3 -0
- package/dist/lib/config/schema.js +11 -1
- package/dist/lib/research/artifact.d.ts +13 -2
- package/dist/lib/research/artifact.js +27 -7
- package/dist/lib/research/compress.d.ts +19 -0
- package/dist/lib/research/compress.js +31 -0
- package/dist/lib/research/docs/code-blocks.js +24 -0
- package/dist/lib/research/docs/section-artifacts.d.ts +3 -2
- package/dist/lib/research/docs/section-artifacts.js +5 -5
- package/dist/lib/research/keywords.d.ts +17 -0
- package/dist/lib/research/keywords.js +220 -0
- package/dist/lib/research/revalidate.d.ts +3 -1
- package/dist/lib/research/revalidate.js +8 -4
- package/dist/lib/research/search-index.d.ts +18 -0
- package/dist/lib/research/search-index.js +109 -0
- package/dist/lib/research/storage.d.ts +1 -0
- package/dist/lib/research/storage.js +1 -1
- package/dist/lib/research/summarize.d.ts +17 -0
- package/dist/lib/research/summarize.js +400 -0
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -101,7 +101,7 @@ npx @taurgis/bonsai <url> [flags]
|
|
|
101
101
|
Common flags:
|
|
102
102
|
|
|
103
103
|
* `--topic`, `-t`: Primary category/topic for metadata tagging.
|
|
104
|
-
* `--tags`, `-g`: Searchable tags. Can be repeated.
|
|
104
|
+
* `--tags`, `-g`: Searchable tags. Can be repeated. When omitted, tags are auto-generated from the content by keyword detection (recorded with an `auto-generated tags via keyword extraction` quality note); passing `--tags` always takes precedence.
|
|
105
105
|
* `--format`, `-f`: Output density: `compressed` or `detailed`.
|
|
106
106
|
* `--tier`: Freshness tier: `stable`, `standard`, or `volatile`.
|
|
107
107
|
* `--ttl`, `-l`: Custom predicted lifespan, such as `2h`, `7d`, or `30d`.
|
|
@@ -114,7 +114,7 @@ Common flags:
|
|
|
114
114
|
|
|
115
115
|
### `import [url]`
|
|
116
116
|
|
|
117
|
-
Save custom Markdown notes directly to the cache.
|
|
117
|
+
Save custom Markdown notes directly to the cache. As with `fetch`, when `--tags` is omitted the tags are auto-generated from the note content by keyword detection.
|
|
118
118
|
|
|
119
119
|
```bash
|
|
120
120
|
npx @taurgis/bonsai import [url] --stdin [flags]
|
|
@@ -146,13 +146,19 @@ npx @taurgis/bonsai search "<keywords>" [flags]
|
|
|
146
146
|
|
|
147
147
|
### `config`
|
|
148
148
|
|
|
149
|
-
|
|
149
|
+
Two keys are configurable:
|
|
150
|
+
|
|
151
|
+
| Key | Values | Default | Effect |
|
|
152
|
+
| --- | --- | --- | --- |
|
|
153
|
+
| `storage` | `global`, `project` | `global` | Where cache files are stored. |
|
|
154
|
+
| `summary` | `conservative`, `balanced`, `aggressive` | `conservative` | How aggressively the `compressed` format is shortened when structural compression alone leaves it close to `detailed`. Prose is condensed extractively (no LLM); headings, code blocks, tables, and lists are always preserved. |
|
|
150
155
|
|
|
151
156
|
```bash
|
|
152
157
|
npx @taurgis/bonsai config set storage project --local
|
|
158
|
+
npx @taurgis/bonsai config set summary balanced
|
|
153
159
|
```
|
|
154
160
|
|
|
155
|
-
Project config is stored in `.bonsai.json`; project cache files are stored under `.bonsai/research/`.
|
|
161
|
+
Both keys can also be overridden per-invocation via the `BONSAI_STORAGE` and `BONSAI_SUMMARY` environment variables. Project config is stored in `.bonsai.json`; project cache files are stored under `.bonsai/research/`.
|
|
156
162
|
|
|
157
163
|
---
|
|
158
164
|
|
package/dist/commands/fetch.js
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { BaseCommand } from '../base-command.js';
|
|
6
6
|
import { writeArtifact, getArtifactPath } from '../lib/research/storage.js';
|
|
7
7
|
import { writeArtifactSecurely } from '../lib/research/secure-write.js';
|
|
8
|
+
import { loadSummaryLevel } from '../lib/config/index.js';
|
|
8
9
|
import { evaluateFreshness, parseTtlToMs, checkMaxAgeExpired } from '../lib/research/freshness.js';
|
|
9
10
|
import { revalidateCache, createArtifactFromFetch } from '../lib/research/revalidate.js';
|
|
10
11
|
import { fetchStaticHtml, fetchText } from '../lib/research/fetcher.js';
|
|
@@ -12,6 +13,7 @@ import { fetchRenderedHtml } from '../lib/research/browser.js';
|
|
|
12
13
|
import { capturePage } from '../lib/research/capture.js';
|
|
13
14
|
import { persistSectionArtifacts } from '../lib/research/docs/section-artifacts.js';
|
|
14
15
|
import { resolveResearchTarget } from '../lib/research/resolve-target.js';
|
|
16
|
+
import { applyAutoTags } from '../lib/research/keywords.js';
|
|
15
17
|
import { detectSite } from '../sites/index.js';
|
|
16
18
|
const CAPTURE_DEPS = {
|
|
17
19
|
fetchStatic: (url) => fetchStaticHtml(url),
|
|
@@ -92,7 +94,7 @@ export default class FetchCommand extends BaseCommand {
|
|
|
92
94
|
envelopeCommandId() {
|
|
93
95
|
return this.config.bin;
|
|
94
96
|
}
|
|
95
|
-
async executeCacheHit(cached, targetDir, currentTime) {
|
|
97
|
+
async executeCacheHit(cached, targetDir, currentTime, summaryLevel) {
|
|
96
98
|
const { ttl, 'max-age': maxAge, 'allow-stale': allowStale, rendered } = this.flags;
|
|
97
99
|
const isExpired = checkMaxAgeExpired(cached, currentTime, maxAge);
|
|
98
100
|
const freshnessState = isExpired
|
|
@@ -105,6 +107,7 @@ export default class FetchCommand extends BaseCommand {
|
|
|
105
107
|
allowStale: Boolean(allowStale),
|
|
106
108
|
ttlOverride: ttl,
|
|
107
109
|
rendered: Boolean(rendered),
|
|
110
|
+
summaryLevel,
|
|
108
111
|
});
|
|
109
112
|
handleStaleRevalidationResult(this, revalResult);
|
|
110
113
|
return {
|
|
@@ -113,7 +116,7 @@ export default class FetchCommand extends BaseCommand {
|
|
|
113
116
|
artifact: revalResult.artifact,
|
|
114
117
|
};
|
|
115
118
|
}
|
|
116
|
-
async executeCacheMiss(normalizedUrl, currentTime, cacheKey) {
|
|
119
|
+
async executeCacheMiss(normalizedUrl, currentTime, cacheKey, summaryLevel) {
|
|
117
120
|
const { topic, tags, tier, ttl, rendered } = this.flags;
|
|
118
121
|
const siteModule = detectSite(normalizedUrl);
|
|
119
122
|
const useRendered = rendered || Boolean(siteModule?.defaults?.rendered);
|
|
@@ -129,7 +132,7 @@ export default class FetchCommand extends BaseCommand {
|
|
|
129
132
|
fetchResult = capture.fetchResult;
|
|
130
133
|
extraction = capture.extraction;
|
|
131
134
|
}
|
|
132
|
-
const artifact = createArtifactFromFetch(normalizedUrl, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl || null, currentTime);
|
|
135
|
+
const artifact = createArtifactFromFetch(normalizedUrl, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl || null, currentTime, summaryLevel);
|
|
133
136
|
artifact.metadata.topic = topic || null;
|
|
134
137
|
artifact.metadata.tags = tags || [];
|
|
135
138
|
artifact.metadata.site_module_id = siteModule?.id ?? null;
|
|
@@ -139,7 +142,9 @@ export default class FetchCommand extends BaseCommand {
|
|
|
139
142
|
else if (useRendered) {
|
|
140
143
|
artifact.metadata.capture_method = 'browser_fallback';
|
|
141
144
|
}
|
|
142
|
-
|
|
145
|
+
// Auto-tag from the fetched content when the caller supplied none, keeping cached pages
|
|
146
|
+
// searchable by keyword. Explicit --tags always win (handled in applyAutoTags).
|
|
147
|
+
return applyAutoTags(artifact);
|
|
143
148
|
}
|
|
144
149
|
buildResultData(url, normalizedUrl, cacheKey, storageDir, storageMode, cacheStatus, freshnessState, format, artifact, content, redirectedToGlobal) {
|
|
145
150
|
return {
|
|
@@ -194,16 +199,16 @@ export default class FetchCommand extends BaseCommand {
|
|
|
194
199
|
}
|
|
195
200
|
// Serves the cache when a fresh/revalidatable entry exists, otherwise fetches fresh. Reads fall
|
|
196
201
|
// back project→global; the resolved artifact is returned with the dir it lives in / landed in.
|
|
197
|
-
async resolveArtifact(normalizedUrl, cacheKey, roots, tmpDir, currentTime, located) {
|
|
202
|
+
async resolveArtifact(normalizedUrl, cacheKey, roots, tmpDir, currentTime, located, summaryLevel) {
|
|
198
203
|
if (located) {
|
|
199
204
|
// Revalidate where the entry already lives; on dry-run use the throwaway dir so the cache is
|
|
200
205
|
// never mutated. ponytail: revalidation rewrites in place, so a refreshed project entry that
|
|
201
206
|
// gains a secret is not re-routed — only first-time project writes are scanned.
|
|
202
207
|
const revalDir = tmpDir ?? located.dataDir;
|
|
203
|
-
const hit = await this.executeCacheHit(located.artifact, revalDir, currentTime);
|
|
208
|
+
const hit = await this.executeCacheHit(located.artifact, revalDir, currentTime, summaryLevel);
|
|
204
209
|
return { ...hit, storageDir: located.dataDir, redirectedToGlobal: false };
|
|
205
210
|
}
|
|
206
|
-
const artifact = await this.executeCacheMiss(normalizedUrl, currentTime, cacheKey);
|
|
211
|
+
const artifact = await this.executeCacheMiss(normalizedUrl, currentTime, cacheKey, summaryLevel);
|
|
207
212
|
const { dir, redirectedToGlobal } = this.persistFreshArtifact(roots, tmpDir, cacheKey, artifact);
|
|
208
213
|
return {
|
|
209
214
|
cacheStatus: 'miss',
|
|
@@ -230,11 +235,11 @@ export default class FetchCommand extends BaseCommand {
|
|
|
230
235
|
}
|
|
231
236
|
// Long references are split into searchable/inspectable section children whenever the page artifact
|
|
232
237
|
// is freshly written (T-22). Best-effort: never let chunking break the main result.
|
|
233
|
-
persistSectionsIfFresh(targetDir, artifact, currentTime, cacheStatus) {
|
|
238
|
+
persistSectionsIfFresh(targetDir, artifact, currentTime, cacheStatus, summaryLevel) {
|
|
234
239
|
if (cacheStatus !== 'miss' && cacheStatus !== 'refreshed')
|
|
235
240
|
return;
|
|
236
241
|
try {
|
|
237
|
-
persistSectionArtifacts(targetDir, artifact, currentTime);
|
|
242
|
+
persistSectionArtifacts(targetDir, artifact, currentTime, summaryLevel);
|
|
238
243
|
}
|
|
239
244
|
catch {
|
|
240
245
|
/* section generation is non-essential; ignore failures */
|
|
@@ -259,11 +264,12 @@ export default class FetchCommand extends BaseCommand {
|
|
|
259
264
|
this.error(`Invalid URL: ${err.message}`, { exit: 2 });
|
|
260
265
|
}
|
|
261
266
|
const { cacheKey, located, normalizedUrl, roots } = target;
|
|
267
|
+
const summaryLevel = loadSummaryLevel(this.config.configDir, process.cwd());
|
|
262
268
|
const tmpDir = dryRun ? mkdtempSync(join(tmpdir(), 'fnr-dry-run-')) : null;
|
|
263
269
|
const currentTime = new Date();
|
|
264
270
|
try {
|
|
265
|
-
const { cacheStatus, freshnessState, artifact, storageDir, redirectedToGlobal } = await this.resolveArtifact(normalizedUrl, cacheKey, roots, tmpDir, currentTime, located);
|
|
266
|
-
this.persistSectionsIfFresh(tmpDir ?? storageDir, artifact, currentTime, cacheStatus);
|
|
271
|
+
const { cacheStatus, freshnessState, artifact, storageDir, redirectedToGlobal } = await this.resolveArtifact(normalizedUrl, cacheKey, roots, tmpDir, currentTime, located, summaryLevel);
|
|
272
|
+
this.persistSectionsIfFresh(tmpDir ?? storageDir, artifact, currentTime, cacheStatus, summaryLevel);
|
|
267
273
|
const content = format === 'compressed' ? artifact.compressed : artifact.detailed;
|
|
268
274
|
const resultData = this.buildResultData(url, normalizedUrl, cacheKey, storageDir, roots.mode, cacheStatus, freshnessState, format, artifact, content, redirectedToGlobal);
|
|
269
275
|
if (!this.jsonEnabled()) {
|
package/dist/commands/import.js
CHANGED
|
@@ -8,8 +8,10 @@ import { deriveCacheKey } from '../lib/research/cache-key.js';
|
|
|
8
8
|
import { getArtifactPath } from '../lib/research/storage.js';
|
|
9
9
|
import { loadStoreRoots } from '../lib/research/store-roots.js';
|
|
10
10
|
import { writeArtifactSecurely } from '../lib/research/secure-write.js';
|
|
11
|
+
import { loadSummaryLevel } from '../lib/config/index.js';
|
|
11
12
|
import { getPolicy } from '../lib/research/freshness.js';
|
|
12
|
-
import {
|
|
13
|
+
import { buildCompressed } from '../lib/research/compress.js';
|
|
14
|
+
import { applyAutoTags } from '../lib/research/keywords.js';
|
|
13
15
|
import { estimateTokens } from '../lib/research/token-estimate.js';
|
|
14
16
|
export default class ResearchImport extends BaseCommand {
|
|
15
17
|
static id = 'import';
|
|
@@ -200,7 +202,11 @@ export default class ResearchImport extends BaseCommand {
|
|
|
200
202
|
const staleAfterTime = new Date(currentTime.getTime() + freshWindowMs);
|
|
201
203
|
const inputFormat = this.flags['input-format'];
|
|
202
204
|
const detailed = rawInput;
|
|
203
|
-
|
|
205
|
+
// A caller-supplied compressed input is trusted as-is; only a detailed import is condensed here,
|
|
206
|
+
// and it shares the same buildCompressed policy (structural pass + extractive fallback) as fetch.
|
|
207
|
+
const compressed = inputFormat === 'detailed'
|
|
208
|
+
? buildCompressed(rawInput, loadSummaryLevel(this.config.configDir, process.cwd()))
|
|
209
|
+
: rawInput;
|
|
204
210
|
const contentHash = createHash('sha256').update(detailed).digest('hex');
|
|
205
211
|
const metadata = {
|
|
206
212
|
schema_version: 1,
|
|
@@ -240,13 +246,15 @@ export default class ResearchImport extends BaseCommand {
|
|
|
240
246
|
section_anchor: null,
|
|
241
247
|
section_heading_path: null,
|
|
242
248
|
};
|
|
243
|
-
|
|
249
|
+
// Auto-tag from the content when the caller supplied none, so imported notes stay searchable by
|
|
250
|
+
// keyword without forcing manual tagging. Explicit --tags always win (handled in applyAutoTags).
|
|
251
|
+
return applyAutoTags({
|
|
244
252
|
metadata,
|
|
245
253
|
summary: `Synthesized research for ${hasSingle ? singleNormalizedUrl : this.flags.topic}`,
|
|
246
254
|
compressed,
|
|
247
255
|
detailed,
|
|
248
256
|
provenance: `Imported via agent-supplied research at ${currentTime.toISOString()}`,
|
|
249
|
-
};
|
|
257
|
+
});
|
|
250
258
|
}
|
|
251
259
|
async run() {
|
|
252
260
|
const hasSingle = Boolean(this.args.url);
|
|
@@ -292,7 +300,7 @@ export default class ResearchImport extends BaseCommand {
|
|
|
292
300
|
captureMethod: 'agent_supplied',
|
|
293
301
|
extractionStatus: 'agent_supplied',
|
|
294
302
|
extractionConfidence: 'high',
|
|
295
|
-
qualityNotes:
|
|
303
|
+
qualityNotes: artifact.metadata.quality_notes,
|
|
296
304
|
fetchedAt: null,
|
|
297
305
|
validatedAt: artifact.metadata.validated_at,
|
|
298
306
|
staleAfter: artifact.metadata.stale_after,
|
package/dist/commands/search.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { BaseCommand } from '../base-command.js';
|
|
3
|
-
import {
|
|
3
|
+
import { loadSearchableArtifacts } from '../lib/research/search-index.js';
|
|
4
4
|
import { loadStoreRoots } from '../lib/research/store-roots.js';
|
|
5
5
|
import { evaluateFreshness } from '../lib/research/freshness.js';
|
|
6
6
|
import { detectSite } from '../sites/index.js';
|
|
@@ -150,21 +150,22 @@ export default class ResearchSearch extends BaseCommand {
|
|
|
150
150
|
return snippet;
|
|
151
151
|
}
|
|
152
152
|
scanCacheDirForResults(readRoots, queryTerms, currentTime) {
|
|
153
|
-
|
|
153
|
+
const results = [];
|
|
154
|
+
for (const { artifact, filePath } of loadSearchableArtifacts(readRoots)) {
|
|
154
155
|
if (artifact.metadata.status !== 'active')
|
|
155
|
-
|
|
156
|
+
continue;
|
|
156
157
|
const freshness = evaluateFreshness(artifact.metadata, currentTime, null);
|
|
157
158
|
if (!this.matchesFilters(artifact.metadata, freshness, this.flags.topic, this.flags.tags, this.flags['artifact-type'], Boolean(this.flags['include-stale'])))
|
|
158
|
-
|
|
159
|
+
continue;
|
|
159
160
|
let score = this.calculateScore(artifact.metadata, artifact.summary, artifact.compressed, queryTerms, freshness);
|
|
160
161
|
if (score <= 0)
|
|
161
|
-
|
|
162
|
+
continue;
|
|
162
163
|
// Section children are more precise than the whole page, so rank a section hit slightly
|
|
163
164
|
// above its parent for the same query (T-22).
|
|
164
165
|
if (artifact.metadata.artifact_type === 'section')
|
|
165
166
|
score += 15;
|
|
166
167
|
const snippetText = [artifact.summary, artifact.compressed].filter(Boolean).join('\n');
|
|
167
|
-
|
|
168
|
+
results.push({
|
|
168
169
|
cacheKey: artifact.metadata.cache_key,
|
|
169
170
|
path: filePath,
|
|
170
171
|
artifactType: artifact.metadata.artifact_type,
|
|
@@ -177,8 +178,9 @@ export default class ResearchSearch extends BaseCommand {
|
|
|
177
178
|
snippet: this.makeSnippet(snippetText, queryTerms),
|
|
178
179
|
siteModuleId: artifact.metadata.site_module_id,
|
|
179
180
|
score,
|
|
180
|
-
};
|
|
181
|
-
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return results;
|
|
182
184
|
}
|
|
183
185
|
logSearchResults(finalResults) {
|
|
184
186
|
if (this.jsonEnabled())
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
export { type ConfigValues, type ResolvedConfig, type ConfigKey, type StorageMode, type KeyMeta, SCHEMA_VERSION, STORAGE_MODES, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
|
|
1
|
+
export { type ConfigValues, type ResolvedConfig, type ConfigKey, type StorageMode, type SummaryLevel, type KeyMeta, SCHEMA_VERSION, STORAGE_MODES, SUMMARY_LEVELS, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
|
|
2
2
|
export { readUserConfig, readProjectConfig, writeUserConfig, writeProjectConfig, USER_CONFIG_FILENAME, PROJECT_CONFIG_FILENAME, } from './io.js';
|
|
3
|
-
export { resolveStorageMode, parseEnvStorage, STORAGE_ENV_VAR, type ResolveStorageInput, } from './resolve.js';
|
|
4
|
-
import type { ConfigValues, ResolvedConfig, StorageMode } from './schema.js';
|
|
3
|
+
export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, type ResolveStorageInput, type ResolveSummaryInput, } from './resolve.js';
|
|
4
|
+
import type { ConfigValues, ResolvedConfig, StorageMode, SummaryLevel } from './schema.js';
|
|
5
5
|
/**
|
|
6
6
|
* Read both config files once and resolve the effective storage mode.
|
|
7
7
|
* `flag` (a per-invocation `--storage` override) wins over everything else.
|
|
8
8
|
*/
|
|
9
9
|
export declare function loadStorageMode(configDir: string | undefined, cwd: string, flag?: StorageMode, env?: Record<string, string | undefined>): StorageMode;
|
|
10
|
+
/**
|
|
11
|
+
* Read both config files once and resolve the effective summary level.
|
|
12
|
+
* `flag` (a per-invocation override) wins over everything else.
|
|
13
|
+
*/
|
|
14
|
+
export declare function loadSummaryLevel(configDir: string | undefined, cwd: string, flag?: SummaryLevel, env?: Record<string, string | undefined>): SummaryLevel;
|
|
10
15
|
/** The resolved value of every config key, factoring file + env layers. Extend as keys are added. */
|
|
11
16
|
export declare function effectiveConfig(configDir: string | undefined, cwd: string): ResolvedConfig;
|
|
12
17
|
export type ConfigScope = 'global' | 'local' | 'effective';
|
package/dist/lib/config/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export { SCHEMA_VERSION, STORAGE_MODES, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
|
|
1
|
+
export { SCHEMA_VERSION, STORAGE_MODES, SUMMARY_LEVELS, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
|
|
2
2
|
export { readUserConfig, readProjectConfig, writeUserConfig, writeProjectConfig, USER_CONFIG_FILENAME, PROJECT_CONFIG_FILENAME, } from './io.js';
|
|
3
|
-
export { resolveStorageMode, parseEnvStorage, STORAGE_ENV_VAR, } from './resolve.js';
|
|
3
|
+
export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, } from './resolve.js';
|
|
4
4
|
import { readUserConfig, readProjectConfig } from './io.js';
|
|
5
|
-
import { resolveStorageMode } from './resolve.js';
|
|
5
|
+
import { resolveStorageMode, resolveSummaryLevel } from './resolve.js';
|
|
6
6
|
/**
|
|
7
7
|
* Read both config files once and resolve the effective storage mode.
|
|
8
8
|
* `flag` (a per-invocation `--storage` override) wins over everything else.
|
|
@@ -15,9 +15,24 @@ export function loadStorageMode(configDir, cwd, flag, env = process.env) {
|
|
|
15
15
|
userConfig: readUserConfig(configDir),
|
|
16
16
|
});
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Read both config files once and resolve the effective summary level.
|
|
20
|
+
* `flag` (a per-invocation override) wins over everything else.
|
|
21
|
+
*/
|
|
22
|
+
export function loadSummaryLevel(configDir, cwd, flag, env = process.env) {
|
|
23
|
+
return resolveSummaryLevel({
|
|
24
|
+
flag,
|
|
25
|
+
env,
|
|
26
|
+
projectConfig: readProjectConfig(cwd),
|
|
27
|
+
userConfig: readUserConfig(configDir),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
18
30
|
/** The resolved value of every config key, factoring file + env layers. Extend as keys are added. */
|
|
19
31
|
export function effectiveConfig(configDir, cwd) {
|
|
20
|
-
return {
|
|
32
|
+
return {
|
|
33
|
+
storage: loadStorageMode(configDir, cwd),
|
|
34
|
+
summary: loadSummaryLevel(configDir, cwd),
|
|
35
|
+
};
|
|
21
36
|
}
|
|
22
37
|
/**
|
|
23
38
|
* The config values for a requested scope: the user file (`global`), the project file (`local`),
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import type { ConfigValues, StorageMode } from './schema.js';
|
|
1
|
+
import type { ConfigValues, StorageMode, SummaryLevel } from './schema.js';
|
|
2
2
|
/** Scoped env override, following oclif's `<BIN>_*` env-var convention for this CLI. */
|
|
3
3
|
export declare const STORAGE_ENV_VAR = "BONSAI_STORAGE";
|
|
4
|
+
export declare const SUMMARY_ENV_VAR = "BONSAI_SUMMARY";
|
|
4
5
|
export declare function parseEnvStorage(env: Record<string, string | undefined>): StorageMode | undefined;
|
|
6
|
+
export declare function parseEnvSummary(env: Record<string, string | undefined>): SummaryLevel | undefined;
|
|
5
7
|
export interface ResolveStorageInput {
|
|
6
8
|
flag?: StorageMode;
|
|
7
9
|
env: Record<string, string | undefined>;
|
|
@@ -13,3 +15,14 @@ export interface ResolveStorageInput {
|
|
|
13
15
|
* flag > env > project file > user file > built-in default.
|
|
14
16
|
*/
|
|
15
17
|
export declare function resolveStorageMode(input: ResolveStorageInput): StorageMode;
|
|
18
|
+
export interface ResolveSummaryInput {
|
|
19
|
+
flag?: SummaryLevel;
|
|
20
|
+
env: Record<string, string | undefined>;
|
|
21
|
+
projectConfig: Partial<ConfigValues>;
|
|
22
|
+
userConfig: Partial<ConfigValues>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the effective summary level in precedence order:
|
|
26
|
+
* flag > env > project file > user file > built-in default.
|
|
27
|
+
*/
|
|
28
|
+
export declare function resolveSummaryLevel(input: ResolveSummaryInput): SummaryLevel;
|
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
import { BUILT_IN_DEFAULTS, STORAGE_MODES } from './schema.js';
|
|
1
|
+
import { BUILT_IN_DEFAULTS, STORAGE_MODES, SUMMARY_LEVELS } from './schema.js';
|
|
2
2
|
/** Scoped env override, following oclif's `<BIN>_*` env-var convention for this CLI. */
|
|
3
3
|
export const STORAGE_ENV_VAR = 'BONSAI_STORAGE';
|
|
4
|
+
export const SUMMARY_ENV_VAR = 'BONSAI_SUMMARY';
|
|
4
5
|
export function parseEnvStorage(env) {
|
|
5
6
|
const raw = env[STORAGE_ENV_VAR]?.trim();
|
|
6
7
|
return STORAGE_MODES.includes(raw ?? '')
|
|
7
8
|
? raw
|
|
8
9
|
: undefined;
|
|
9
10
|
}
|
|
11
|
+
export function parseEnvSummary(env) {
|
|
12
|
+
const raw = env[SUMMARY_ENV_VAR]?.trim();
|
|
13
|
+
return SUMMARY_LEVELS.includes(raw ?? '')
|
|
14
|
+
? raw
|
|
15
|
+
: undefined;
|
|
16
|
+
}
|
|
10
17
|
/**
|
|
11
18
|
* Resolve the effective storage mode in precedence order:
|
|
12
19
|
* flag > env > project file > user file > built-in default.
|
|
@@ -18,3 +25,14 @@ export function resolveStorageMode(input) {
|
|
|
18
25
|
input.userConfig.storage ??
|
|
19
26
|
BUILT_IN_DEFAULTS.storage);
|
|
20
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the effective summary level in precedence order:
|
|
30
|
+
* flag > env > project file > user file > built-in default.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveSummaryLevel(input) {
|
|
33
|
+
return (input.flag ??
|
|
34
|
+
parseEnvSummary(input.env) ??
|
|
35
|
+
input.projectConfig.summary ??
|
|
36
|
+
input.userConfig.summary ??
|
|
37
|
+
BUILT_IN_DEFAULTS.summary);
|
|
38
|
+
}
|
|
@@ -4,13 +4,16 @@
|
|
|
4
4
|
* where the research cache lives.
|
|
5
5
|
*/
|
|
6
6
|
export type StorageMode = 'global' | 'project';
|
|
7
|
+
export type SummaryLevel = 'conservative' | 'balanced' | 'aggressive';
|
|
7
8
|
export interface ConfigValues {
|
|
8
9
|
storage?: StorageMode;
|
|
10
|
+
summary?: SummaryLevel;
|
|
9
11
|
}
|
|
10
12
|
export type ResolvedConfig = Required<ConfigValues>;
|
|
11
13
|
export type ConfigKey = keyof ConfigValues;
|
|
12
14
|
export declare const SCHEMA_VERSION = 1;
|
|
13
15
|
export declare const STORAGE_MODES: readonly StorageMode[];
|
|
16
|
+
export declare const SUMMARY_LEVELS: readonly SummaryLevel[];
|
|
14
17
|
export declare const ALL_KEYS: readonly ConfigKey[];
|
|
15
18
|
export declare const BUILT_IN_DEFAULTS: ResolvedConfig;
|
|
16
19
|
export interface KeyMeta {
|
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export const SCHEMA_VERSION = 1;
|
|
7
7
|
export const STORAGE_MODES = ['global', 'project'];
|
|
8
|
-
export const
|
|
8
|
+
export const SUMMARY_LEVELS = ['conservative', 'balanced', 'aggressive'];
|
|
9
|
+
export const ALL_KEYS = ['storage', 'summary'];
|
|
9
10
|
export const BUILT_IN_DEFAULTS = {
|
|
10
11
|
// Default to global so existing installs keep using the OCLIF data dir unchanged.
|
|
11
12
|
storage: 'global',
|
|
13
|
+
// Default to the gentlest summarization so cached content keeps the most detail out of the box.
|
|
14
|
+
summary: 'conservative',
|
|
12
15
|
};
|
|
13
16
|
export const KEY_META = {
|
|
14
17
|
storage: {
|
|
@@ -18,6 +21,13 @@ export const KEY_META = {
|
|
|
18
21
|
isValid: (v) => STORAGE_MODES.includes(String(v)),
|
|
19
22
|
format: (v) => String(v),
|
|
20
23
|
},
|
|
24
|
+
summary: {
|
|
25
|
+
description: `How aggressively the compressed format is summarized (${SUMMARY_LEVELS.join('|')}).`,
|
|
26
|
+
values: SUMMARY_LEVELS,
|
|
27
|
+
parseValue: (raw) => raw.trim(),
|
|
28
|
+
isValid: (v) => SUMMARY_LEVELS.includes(String(v)),
|
|
29
|
+
format: (v) => String(v),
|
|
30
|
+
},
|
|
21
31
|
};
|
|
22
32
|
export function isKnownKey(key) {
|
|
23
33
|
return ALL_KEYS.includes(key);
|
|
@@ -12,9 +12,20 @@ export declare function serializeArtifact(artifact: ResearchArtifact): string;
|
|
|
12
12
|
*/
|
|
13
13
|
export declare function parseMetadata(lines: string[]): ResearchArtifactMetadata;
|
|
14
14
|
/**
|
|
15
|
-
* Extracts a named markdown section (e.g. ## Summary) from body text.
|
|
15
|
+
* Extracts a named markdown section (e.g. ## Summary) from body text. The section ends at the next
|
|
16
|
+
* known section header (`nextSection`), matched as the exact serialized separator `\n\n## <name>` —
|
|
17
|
+
* NOT at the next `## ` heading, because section bodies legitimately contain their own `##` headings
|
|
18
|
+
* (a compressed/detailed page often *starts* with one). Bounding on a generic heading would truncate
|
|
19
|
+
* such content to nothing. Omit `nextSection` for the final section to read to end-of-body.
|
|
16
20
|
*/
|
|
17
|
-
export declare function extractSection(body: string, sectionName: string): string;
|
|
21
|
+
export declare function extractSection(body: string, sectionName: string, nextSection?: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Parses an artifact for search/listing without materializing the (often large) Detailed and
|
|
24
|
+
* Provenance bodies. Truncates at the Detailed section boundary before parsing so megabyte-scale
|
|
25
|
+
* detailed content is never split or sliced. `detailed` and `provenance` come back empty — callers
|
|
26
|
+
* that need them must use {@link parseArtifact}. Shares all parsing rules with parseArtifact.
|
|
27
|
+
*/
|
|
28
|
+
export declare function parseArtifactShallow(content: string): ResearchArtifact;
|
|
18
29
|
/**
|
|
19
30
|
* Parses full Markdown content into a structured ResearchArtifact.
|
|
20
31
|
*/
|
|
@@ -181,18 +181,38 @@ export function parseMetadata(lines) {
|
|
|
181
181
|
return state.meta;
|
|
182
182
|
}
|
|
183
183
|
/**
|
|
184
|
-
* Extracts a named markdown section (e.g. ## Summary) from body text.
|
|
184
|
+
* Extracts a named markdown section (e.g. ## Summary) from body text. The section ends at the next
|
|
185
|
+
* known section header (`nextSection`), matched as the exact serialized separator `\n\n## <name>` —
|
|
186
|
+
* NOT at the next `## ` heading, because section bodies legitimately contain their own `##` headings
|
|
187
|
+
* (a compressed/detailed page often *starts* with one). Bounding on a generic heading would truncate
|
|
188
|
+
* such content to nothing. Omit `nextSection` for the final section to read to end-of-body.
|
|
185
189
|
*/
|
|
186
|
-
export function extractSection(body, sectionName) {
|
|
190
|
+
export function extractSection(body, sectionName, nextSection) {
|
|
187
191
|
const header = `## ${sectionName}`;
|
|
188
192
|
const idx = body.indexOf(header);
|
|
189
193
|
if (idx === -1)
|
|
190
194
|
return '';
|
|
191
195
|
const start = idx + header.length;
|
|
192
|
-
const
|
|
193
|
-
const rawText =
|
|
196
|
+
const endIdx = nextSection ? body.indexOf(`\n\n## ${nextSection}`, start) : -1;
|
|
197
|
+
const rawText = endIdx === -1 ? body.slice(start) : body.slice(start, endIdx);
|
|
194
198
|
return rawText.trim();
|
|
195
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Parses an artifact for search/listing without materializing the (often large) Detailed and
|
|
202
|
+
* Provenance bodies. Truncates at the Detailed section boundary before parsing so megabyte-scale
|
|
203
|
+
* detailed content is never split or sliced. `detailed` and `provenance` come back empty — callers
|
|
204
|
+
* that need them must use {@link parseArtifact}. Shares all parsing rules with parseArtifact.
|
|
205
|
+
*/
|
|
206
|
+
export function parseArtifactShallow(content) {
|
|
207
|
+
// Only look for the Detailed boundary in the body (after the closing frontmatter fence) and match
|
|
208
|
+
// the exact serialized separator `\n\n## Detailed`, so neither a frontmatter value nor an earlier
|
|
209
|
+
// body heading named "Detailed" can truncate the content prematurely.
|
|
210
|
+
const bodyStart = content.indexOf('\n---', 3);
|
|
211
|
+
const searchFrom = bodyStart === -1 ? 0 : bodyStart + 4;
|
|
212
|
+
const detailedIdx = content.indexOf('\n\n## Detailed', searchFrom);
|
|
213
|
+
const sliced = detailedIdx === -1 ? content : content.slice(0, detailedIdx);
|
|
214
|
+
return parseArtifact(sliced);
|
|
215
|
+
}
|
|
196
216
|
/**
|
|
197
217
|
* Parses full Markdown content into a structured ResearchArtifact.
|
|
198
218
|
*/
|
|
@@ -209,9 +229,9 @@ export function parseArtifact(content) {
|
|
|
209
229
|
const bodyLines = parts.slice(closingIdx + 1);
|
|
210
230
|
const bodyText = bodyLines.join('\n');
|
|
211
231
|
const metadata = parseMetadata(frontmatterLines);
|
|
212
|
-
const summary = extractSection(bodyText, 'Summary');
|
|
213
|
-
const compressed = extractSection(bodyText, 'Compressed');
|
|
214
|
-
const detailed = extractSection(bodyText, 'Detailed');
|
|
232
|
+
const summary = extractSection(bodyText, 'Summary', 'Compressed');
|
|
233
|
+
const compressed = extractSection(bodyText, 'Compressed', 'Detailed');
|
|
234
|
+
const detailed = extractSection(bodyText, 'Detailed', 'Provenance');
|
|
215
235
|
const provenance = extractSection(bodyText, 'Provenance');
|
|
216
236
|
return { metadata, summary, compressed, detailed, provenance };
|
|
217
237
|
}
|
|
@@ -1,5 +1,24 @@
|
|
|
1
|
+
import { type SummaryLevel } from './summarize.js';
|
|
1
2
|
/**
|
|
2
3
|
* Compresses Markdown text by removing images, simplifying links from [text](url) to [text],
|
|
3
4
|
* and collapsing multiple consecutive blank lines.
|
|
4
5
|
*/
|
|
5
6
|
export declare function compressMarkdown(markdown: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Produces the `compressed` form of fetched content. Structural compression (strip images,
|
|
9
|
+
* simplify links, collapse blank lines) runs first, then the dependency-free extractive summarizer
|
|
10
|
+
* condenses prose to the chosen aggressiveness while keeping headings, code, tables, and lists
|
|
11
|
+
* intact. The summarizer self-guards: documents below the token floor, or with no prose to drop
|
|
12
|
+
* (e.g. pure structure once links/images are stripped), come back unchanged. Running it
|
|
13
|
+
* unconditionally — rather than only as a fallback when structural compression underperforms — is
|
|
14
|
+
* what makes the `summary` level matter even on link-heavy pages that structural compression
|
|
15
|
+
* already shrinks. This is the single place the "how do we build compressed" policy lives.
|
|
16
|
+
*
|
|
17
|
+
* A trust guard backs all of this: `compressed` is only useful if it is a non-empty, no-larger view
|
|
18
|
+
* of `detailed`. Both steps only ever remove content, so that should always hold — but rather than
|
|
19
|
+
* assume it, we verify and fall back through progressively safer forms (summarized → structural →
|
|
20
|
+
* detailed). This means the cache can never end up serving a `compressed` that is larger than
|
|
21
|
+
* `detailed`, or one that is empty while `detailed` has content — if the auto-compaction can't be
|
|
22
|
+
* trusted, the caller transparently gets the (verified) structural form or the detailed text itself.
|
|
23
|
+
*/
|
|
24
|
+
export declare function buildCompressed(detailed: string, level: SummaryLevel): string;
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { summarizeMarkdown } from './summarize.js';
|
|
2
|
+
// Below this token count a page is never extractively summarized — there is nothing to gain.
|
|
3
|
+
const SUMMARY_FLOOR_TOKENS = 200;
|
|
1
4
|
/**
|
|
2
5
|
* Compresses Markdown text by removing images, simplifying links from [text](url) to [text],
|
|
3
6
|
* and collapsing multiple consecutive blank lines.
|
|
@@ -15,3 +18,31 @@ export function compressMarkdown(markdown) {
|
|
|
15
18
|
result = result.replace(/\n{3,}/g, '\n\n');
|
|
16
19
|
return result.trim();
|
|
17
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Produces the `compressed` form of fetched content. Structural compression (strip images,
|
|
23
|
+
* simplify links, collapse blank lines) runs first, then the dependency-free extractive summarizer
|
|
24
|
+
* condenses prose to the chosen aggressiveness while keeping headings, code, tables, and lists
|
|
25
|
+
* intact. The summarizer self-guards: documents below the token floor, or with no prose to drop
|
|
26
|
+
* (e.g. pure structure once links/images are stripped), come back unchanged. Running it
|
|
27
|
+
* unconditionally — rather than only as a fallback when structural compression underperforms — is
|
|
28
|
+
* what makes the `summary` level matter even on link-heavy pages that structural compression
|
|
29
|
+
* already shrinks. This is the single place the "how do we build compressed" policy lives.
|
|
30
|
+
*
|
|
31
|
+
* A trust guard backs all of this: `compressed` is only useful if it is a non-empty, no-larger view
|
|
32
|
+
* of `detailed`. Both steps only ever remove content, so that should always hold — but rather than
|
|
33
|
+
* assume it, we verify and fall back through progressively safer forms (summarized → structural →
|
|
34
|
+
* detailed). This means the cache can never end up serving a `compressed` that is larger than
|
|
35
|
+
* `detailed`, or one that is empty while `detailed` has content — if the auto-compaction can't be
|
|
36
|
+
* trusted, the caller transparently gets the (verified) structural form or the detailed text itself.
|
|
37
|
+
*/
|
|
38
|
+
export function buildCompressed(detailed, level) {
|
|
39
|
+
const structural = compressMarkdown(detailed);
|
|
40
|
+
const summarized = summarizeMarkdown(structural, { level, minTokens: SUMMARY_FLOOR_TOKENS });
|
|
41
|
+
// Trustworthy = not larger than detailed, and not empty unless detailed is itself empty.
|
|
42
|
+
const trustworthy = (candidate) => candidate.length <= detailed.length && (candidate.trim() !== '' || detailed.trim() === '');
|
|
43
|
+
if (trustworthy(summarized))
|
|
44
|
+
return summarized;
|
|
45
|
+
if (trustworthy(structural))
|
|
46
|
+
return structural;
|
|
47
|
+
return detailed;
|
|
48
|
+
}
|
|
@@ -42,6 +42,12 @@ function reconstructLines(code) {
|
|
|
42
42
|
function removeCopyButtons(document) {
|
|
43
43
|
const candidates = document.querySelectorAll('button, [class*="copy" i], [class*="Copy"], [aria-label*="copy" i]');
|
|
44
44
|
for (const el of candidates) {
|
|
45
|
+
// Never remove a container that wraps a code block. Some highlighters put a copy-related class
|
|
46
|
+
// on the <pre>'s wrapper (e.g. Elementor's `<div class="copy-to-clipboard"><pre>…`); removing
|
|
47
|
+
// it would delete the entire code example, not just the button. Guard on <pre> specifically: a
|
|
48
|
+
// real copy button never contains a <pre>, but may contain a decorative <code> icon.
|
|
49
|
+
if (el.querySelector?.('pre'))
|
|
50
|
+
continue;
|
|
45
51
|
const cls = (el.getAttribute('class') || '').toString();
|
|
46
52
|
const aria = el.getAttribute('aria-label') || '';
|
|
47
53
|
const text = (el.textContent || '').trim();
|
|
@@ -50,6 +56,23 @@ function removeCopyButtons(document) {
|
|
|
50
56
|
}
|
|
51
57
|
}
|
|
52
58
|
}
|
|
59
|
+
// Some highlighters (e.g. Elementor's code-highlight widget) wrap the authored source in a
|
|
60
|
+
// deprecated <xmp>/<plaintext> element and pretty-print the markup, so <code> is not <pre>'s first
|
|
61
|
+
// child. Turndown's fenced-code rule requires <code> as the first child; without this the block
|
|
62
|
+
// collapses onto a single inline span. Flatten to a clean <pre><code>…</code></pre>.
|
|
63
|
+
function flattenCodeStructure(pre, code) {
|
|
64
|
+
if (code === pre)
|
|
65
|
+
return;
|
|
66
|
+
const rawHolder = code.querySelector?.('xmp, plaintext');
|
|
67
|
+
const raw = rawHolder?.textContent ?? '';
|
|
68
|
+
if (raw)
|
|
69
|
+
code.textContent = raw;
|
|
70
|
+
if (pre.firstChild !== code || pre.childNodes.length > 1) {
|
|
71
|
+
while (pre.firstChild)
|
|
72
|
+
pre.removeChild(pre.firstChild);
|
|
73
|
+
pre.appendChild(code);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
53
76
|
/**
|
|
54
77
|
* Reconstructs newline-delimited text for every highlighted code block in the document and tags it
|
|
55
78
|
* with a `language-x` class when one is detectable. Idempotent and a no-op on plain `<pre>` blocks.
|
|
@@ -65,5 +88,6 @@ export function normalizeCodeBlocks(document) {
|
|
|
65
88
|
const existing = (code.getAttribute('class') || '').replace(/language-[\w-]+/gi, '').trim();
|
|
66
89
|
code.setAttribute('class', `${existing} language-${language}`.trim());
|
|
67
90
|
}
|
|
91
|
+
flattenCodeStructure(pre, code);
|
|
68
92
|
}
|
|
69
93
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { ResearchArtifact } from '../schema.js';
|
|
2
|
+
import type { SummaryLevel } from '../../config/schema.js';
|
|
2
3
|
/**
|
|
3
4
|
* Derives section child artifacts from a parent page artifact. Returns [] when the page is short
|
|
4
5
|
* or has too few headings to chunk.
|
|
5
6
|
*/
|
|
6
|
-
export declare function buildSectionArtifacts(parent: ResearchArtifact, currentTime: Date): ResearchArtifact[];
|
|
7
|
+
export declare function buildSectionArtifacts(parent: ResearchArtifact, currentTime: Date, summaryLevel: SummaryLevel): ResearchArtifact[];
|
|
7
8
|
/**
|
|
8
9
|
* Regenerates a page's section artifacts: writes fresh children and archives any prior child whose
|
|
9
10
|
* heading no longer exists, so a revalidated parent never leaves orphaned sections active. Returns
|
|
10
11
|
* the number of active section children written.
|
|
11
12
|
*/
|
|
12
|
-
export declare function persistSectionArtifacts(dataDir: string, parent: ResearchArtifact, currentTime: Date): number;
|
|
13
|
+
export declare function persistSectionArtifacts(dataDir: string, parent: ResearchArtifact, currentTime: Date, summaryLevel: SummaryLevel): number;
|