@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 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
- Choose global or project-local cache storage.
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
 
@@ -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
- return artifact;
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()) {
@@ -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 { compressMarkdown } from '../lib/research/compress.js';
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
- const compressed = inputFormat === 'detailed' ? compressMarkdown(rawInput) : rawInput;
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
- return {
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: ['agent-supplied research import'],
303
+ qualityNotes: artifact.metadata.quality_notes,
296
304
  fetchedAt: null,
297
305
  validatedAt: artifact.metadata.validated_at,
298
306
  staleAfter: artifact.metadata.stale_after,
@@ -1,6 +1,6 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { BaseCommand } from '../base-command.js';
3
- import { scanCacheDirs } from '../lib/research/storage.js';
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
- return scanCacheDirs(readRoots, (artifact, filePath) => {
153
+ const results = [];
154
+ for (const { artifact, filePath } of loadSearchableArtifacts(readRoots)) {
154
155
  if (artifact.metadata.status !== 'active')
155
- return null;
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
- return null;
159
+ continue;
159
160
  let score = this.calculateScore(artifact.metadata, artifact.summary, artifact.compressed, queryTerms, freshness);
160
161
  if (score <= 0)
161
- return null;
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
- return {
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';
@@ -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 { storage: loadStorageMode(configDir, cwd) };
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 ALL_KEYS = ['storage'];
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 nextHeadingIdx = body.indexOf('\n## ', start);
193
- const rawText = nextHeadingIdx === -1 ? body.slice(start) : body.slice(start, nextHeadingIdx);
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;