@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.
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { join } from 'node:path';
3
- import { compressMarkdown } from '../compress.js';
3
+ import { buildCompressed } from '../compress.js';
4
4
  import { estimateTokens } from '../token-estimate.js';
5
5
  import { scanCacheDir, writeArtifact } from '../storage.js';
6
6
  import { splitMarkdownSections } from './sections.js';
@@ -17,7 +17,7 @@ function childKey(parentKey, anchor) {
17
17
  * Derives section child artifacts from a parent page artifact. Returns [] when the page is short
18
18
  * or has too few headings to chunk.
19
19
  */
20
- export function buildSectionArtifacts(parent, currentTime) {
20
+ export function buildSectionArtifacts(parent, currentTime, summaryLevel) {
21
21
  if (estimateTokens(parent.detailed) <= SECTION_TOKEN_THRESHOLD)
22
22
  return [];
23
23
  const sections = splitMarkdownSections(parent.detailed);
@@ -25,7 +25,7 @@ export function buildSectionArtifacts(parent, currentTime) {
25
25
  return sections.map((section) => {
26
26
  const key = childKey(pm.cache_key, section.anchor);
27
27
  const detailed = section.content;
28
- const compressed = compressMarkdown(detailed);
28
+ const compressed = buildCompressed(detailed, summaryLevel);
29
29
  return {
30
30
  metadata: {
31
31
  ...pm,
@@ -59,8 +59,8 @@ function activeChildrenOf(dataDir, parentKey) {
59
59
  * heading no longer exists, so a revalidated parent never leaves orphaned sections active. Returns
60
60
  * the number of active section children written.
61
61
  */
62
- export function persistSectionArtifacts(dataDir, parent, currentTime) {
63
- const children = buildSectionArtifacts(parent, currentTime);
62
+ export function persistSectionArtifacts(dataDir, parent, currentTime, summaryLevel) {
63
+ const children = buildSectionArtifacts(parent, currentTime, summaryLevel);
64
64
  const freshKeys = new Set(children.map((c) => c.metadata.cache_key));
65
65
  for (const orphan of activeChildrenOf(dataDir, parent.metadata.cache_key)) {
66
66
  if (!freshKeys.has(orphan.metadata.cache_key)) {
@@ -0,0 +1,17 @@
1
+ import type { ResearchArtifact } from './schema.js';
2
+ export declare const STOPWORDS: Set<string>;
3
+ export declare function tokenize(text: string): string[];
4
+ /**
5
+ * Derives up to `max` keyword tags from free-form Markdown/text by term frequency.
6
+ * Code blocks, inline code, and URLs are stripped first; stopwords, pure numbers, and very short
7
+ * tokens are dropped; terms appearing in Markdown headings get a frequency boost. Ranking is
8
+ * deterministic (frequency desc, then alphabetical) so the same input always yields the same tags.
9
+ */
10
+ export declare function extractKeywords(text: string, max?: number): string[];
11
+ /**
12
+ * Back-fills keyword tags for an artifact that has none, deriving them from its detailed content and
13
+ * recording a quality note. No-op when tags are already present, so user- or caller-supplied tags
14
+ * always win. Mutates and returns the same artifact. This is the single entry point every ingestion
15
+ * path (import, fetch, revalidation) uses, so the auto-tag policy lives in exactly one place.
16
+ */
17
+ export declare function applyAutoTags(artifact: ResearchArtifact): ResearchArtifact;
@@ -0,0 +1,220 @@
1
+ // Dependency-free keyword extraction used to auto-tag imported/fetched research when the caller
2
+ // supplies no tags. This is a deliberately simple term-frequency heuristic.
3
+ // ponytail: TF + stopwords + heading boost. Ceiling: no phrase/entity detection, English-only
4
+ // stopwords. Upgrade path: swap in RAKE/YAKE or an LLM pass behind the same extractKeywords() API.
5
+ const AUTO_TAG_NOTE = 'auto-generated tags via keyword extraction';
6
+ // Common English function words plus a few markdown/doc-noise words. Kept inline (no dependency)
7
+ // because the list is small and stable.
8
+ export const STOPWORDS = new Set([
9
+ 'the',
10
+ 'a',
11
+ 'an',
12
+ 'and',
13
+ 'or',
14
+ 'but',
15
+ 'if',
16
+ 'then',
17
+ 'else',
18
+ 'when',
19
+ 'while',
20
+ 'for',
21
+ 'of',
22
+ 'to',
23
+ 'in',
24
+ 'on',
25
+ 'at',
26
+ 'by',
27
+ 'with',
28
+ 'from',
29
+ 'into',
30
+ 'onto',
31
+ 'as',
32
+ 'is',
33
+ 'are',
34
+ 'was',
35
+ 'were',
36
+ 'be',
37
+ 'been',
38
+ 'being',
39
+ 'am',
40
+ 'do',
41
+ 'does',
42
+ 'did',
43
+ 'doing',
44
+ 'have',
45
+ 'has',
46
+ 'had',
47
+ 'having',
48
+ 'this',
49
+ 'that',
50
+ 'these',
51
+ 'those',
52
+ 'it',
53
+ 'its',
54
+ 'they',
55
+ 'them',
56
+ 'their',
57
+ 'there',
58
+ 'here',
59
+ 'we',
60
+ 'you',
61
+ 'your',
62
+ 'our',
63
+ 'us',
64
+ 'i',
65
+ 'me',
66
+ 'my',
67
+ 'he',
68
+ 'she',
69
+ 'his',
70
+ 'her',
71
+ 'not',
72
+ 'no',
73
+ 'nor',
74
+ 'so',
75
+ 'than',
76
+ 'too',
77
+ 'very',
78
+ 'can',
79
+ 'will',
80
+ 'just',
81
+ 'should',
82
+ 'would',
83
+ 'could',
84
+ 'may',
85
+ 'might',
86
+ 'must',
87
+ 'shall',
88
+ 'about',
89
+ 'above',
90
+ 'after',
91
+ 'again',
92
+ 'against',
93
+ 'all',
94
+ 'any',
95
+ 'because',
96
+ 'before',
97
+ 'below',
98
+ 'between',
99
+ 'both',
100
+ 'down',
101
+ 'during',
102
+ 'each',
103
+ 'few',
104
+ 'further',
105
+ 'how',
106
+ 'more',
107
+ 'most',
108
+ 'other',
109
+ 'over',
110
+ 'own',
111
+ 'same',
112
+ 'some',
113
+ 'such',
114
+ 'only',
115
+ 'out',
116
+ 'up',
117
+ 'off',
118
+ 'where',
119
+ 'which',
120
+ 'who',
121
+ 'whom',
122
+ 'why',
123
+ 'what',
124
+ 'whose',
125
+ 'also',
126
+ 'use',
127
+ 'used',
128
+ 'using',
129
+ 'via',
130
+ 'per',
131
+ 'e.g',
132
+ 'i.e',
133
+ 'etc',
134
+ 'get',
135
+ 'set',
136
+ 'see',
137
+ 'note',
138
+ 'example',
139
+ 'one',
140
+ 'two',
141
+ 'three',
142
+ 'new',
143
+ 'like',
144
+ 'within',
145
+ 'without',
146
+ ]);
147
+ // Splits text into candidate tokens, keeping tech-flavoured shapes intact (e.g. "node.js", "c#",
148
+ // "asp.net") while trimming leading/trailing separators left over from sentence punctuation.
149
+ export function tokenize(text) {
150
+ const raw = text.toLowerCase().match(/[a-z0-9][a-z0-9+#.-]*/g) ?? [];
151
+ return raw.map((w) => w.replace(/^[.\-]+|[.\-]+$/g, '')).filter(Boolean);
152
+ }
153
+ function cleanNoise(text) {
154
+ return text
155
+ .replace(/```[\s\S]*?```/g, ' ')
156
+ .replace(/`[^`]*`/g, ' ')
157
+ .replace(/https?:\/\/\S+/g, ' ');
158
+ }
159
+ function getHeadingTerms(cleaned) {
160
+ const headingTerms = new Set();
161
+ for (const line of cleaned.split('\n')) {
162
+ if (/^\s{0,3}#{1,6}\s/.test(line)) {
163
+ for (const term of tokenize(line)) {
164
+ headingTerms.add(term);
165
+ }
166
+ }
167
+ }
168
+ return headingTerms;
169
+ }
170
+ function calculateFrequencies(cleaned, headingTerms) {
171
+ const counts = new Map();
172
+ for (const token of tokenize(cleaned)) {
173
+ if (token.length < 3 || STOPWORDS.has(token) || /^\d+$/.test(token)) {
174
+ continue;
175
+ }
176
+ counts.set(token, (counts.get(token) ?? 0) + 1);
177
+ }
178
+ for (const term of headingTerms) {
179
+ const val = counts.get(term);
180
+ if (val !== undefined) {
181
+ counts.set(term, val + 3);
182
+ }
183
+ }
184
+ return counts;
185
+ }
186
+ /**
187
+ * Derives up to `max` keyword tags from free-form Markdown/text by term frequency.
188
+ * Code blocks, inline code, and URLs are stripped first; stopwords, pure numbers, and very short
189
+ * tokens are dropped; terms appearing in Markdown headings get a frequency boost. Ranking is
190
+ * deterministic (frequency desc, then alphabetical) so the same input always yields the same tags.
191
+ */
192
+ export function extractKeywords(text, max = 5) {
193
+ if (!text || !text.trim() || max <= 0)
194
+ return [];
195
+ const cleaned = cleanNoise(text);
196
+ const headingTerms = getHeadingTerms(cleaned);
197
+ const counts = calculateFrequencies(cleaned, headingTerms);
198
+ if (counts.size === 0)
199
+ return [];
200
+ return [...counts.entries()]
201
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
202
+ .slice(0, max)
203
+ .map(([term]) => term);
204
+ }
205
+ /**
206
+ * Back-fills keyword tags for an artifact that has none, deriving them from its detailed content and
207
+ * recording a quality note. No-op when tags are already present, so user- or caller-supplied tags
208
+ * always win. Mutates and returns the same artifact. This is the single entry point every ingestion
209
+ * path (import, fetch, revalidation) uses, so the auto-tag policy lives in exactly one place.
210
+ */
211
+ export function applyAutoTags(artifact) {
212
+ if (artifact.metadata.tags.length > 0)
213
+ return artifact;
214
+ const tags = extractKeywords(artifact.detailed);
215
+ if (tags.length === 0)
216
+ return artifact;
217
+ artifact.metadata.tags = tags;
218
+ artifact.metadata.quality_notes = [...artifact.metadata.quality_notes, AUTO_TAG_NOTE];
219
+ return artifact;
220
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ResearchArtifact } from './schema.js';
2
+ import type { SummaryLevel } from '../config/schema.js';
2
3
  export interface RevalidationResult {
3
4
  status: 'revalidated' | 'refreshed' | 'stale';
4
5
  artifact: ResearchArtifact;
@@ -20,7 +21,7 @@ export declare function createArtifactFromFetch(url: string, normalizedUrl: stri
20
21
  detailedMarkdown: string;
21
22
  confidence: 'high' | 'medium' | 'low';
22
23
  qualityNotes: string[];
23
- }, tier: 'stable' | 'standard' | 'volatile', ttl: string | null, currentTime: Date): ResearchArtifact;
24
+ }, tier: 'stable' | 'standard' | 'volatile', ttl: string | null, currentTime: Date, summaryLevel: SummaryLevel): ResearchArtifact;
24
25
  /**
25
26
  * Revalidates a stale cache artifact using conditional request headers,
26
27
  * falling back to stale serving within the grace period if the remote host is offline.
@@ -29,4 +30,5 @@ export declare function revalidateCache(dataDir: string, existing: ResearchArtif
29
30
  allowStale: boolean;
30
31
  ttlOverride?: string | null;
31
32
  rendered?: boolean;
33
+ summaryLevel: SummaryLevel;
32
34
  }): Promise<RevalidationResult>;
@@ -4,7 +4,8 @@ import { fetchStaticHtml } from './fetcher.js';
4
4
  import { extractHtmlContent } from './extract.js';
5
5
  import { writeArtifact } from './storage.js';
6
6
  import { evaluateFreshness, getPolicy } from './freshness.js';
7
- import { compressMarkdown } from './compress.js';
7
+ import { buildCompressed } from './compress.js';
8
+ import { applyAutoTags } from './keywords.js';
8
9
  import { estimateTokens } from './token-estimate.js';
9
10
  import { fetchRenderedHtml } from './browser.js';
10
11
  import { looksLikeErrorPage } from './docs/validate.js';
@@ -78,14 +79,14 @@ function buildErrorArtifact(url, normalizedUrl, cacheKey, fetchResult, tier, ttl
78
79
  /**
79
80
  * Helper to construct a ResearchArtifact from a fresh HTML fetch.
80
81
  */
81
- export function createArtifactFromFetch(url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime) {
82
+ export function createArtifactFromFetch(url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime, summaryLevel) {
82
83
  // A managed platform / SPA can return HTTP 200 but render only a "not found" / "something went
83
84
  // wrong" shell. Cache a compact marker for those instead of the full error markdown, so repeat
84
85
  // lookups cost a few tokens and revalidation still re-checks the page when the entry goes stale.
85
86
  if (looksLikeErrorPage(extraction.detailedMarkdown)) {
86
87
  return buildErrorArtifact(url, normalizedUrl, cacheKey, fetchResult, tier, ttl, currentTime, 'page reported an error or was not found');
87
88
  }
88
- const compressed = compressMarkdown(extraction.detailedMarkdown);
89
+ const compressed = buildCompressed(extraction.detailedMarkdown, summaryLevel);
89
90
  const metadata = buildMetadata(url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime, compressed);
90
91
  return {
91
92
  metadata,
@@ -111,8 +112,11 @@ function preserveUserMetadata(source, target, rendered) {
111
112
  // returns the "refreshed" result. Shared by both refresh paths (conditional-request fallthrough
112
113
  // and site-module re-fetch), which differ only in how fetchResult/extraction are obtained.
113
114
  function persistRefreshedArtifact(dataDir, meta, fetchResult, extraction, currentTime, options) {
114
- const refreshed = createArtifactFromFetch(meta.source_url, meta.normalized_url, meta.cache_key, fetchResult, extraction, meta.tier, options.ttlOverride || meta.ttl, currentTime);
115
+ const refreshed = createArtifactFromFetch(meta.source_url, meta.normalized_url, meta.cache_key, fetchResult, extraction, meta.tier, options.ttlOverride || meta.ttl, currentTime, options.summaryLevel);
115
116
  preserveUserMetadata(meta, refreshed, options.rendered);
117
+ // Back-fill keyword tags when the carried-over set is empty (e.g. an artifact first cached before
118
+ // auto-tagging, or one originally stored without tags), so refreshing keeps it searchable.
119
+ applyAutoTags(refreshed);
116
120
  writeArtifact(dataDir, meta.cache_key, refreshed);
117
121
  return { status: 'refreshed', artifact: refreshed };
118
122
  }
@@ -0,0 +1,18 @@
1
+ import type { ResearchArtifact } from './schema.js';
2
+ export interface SearchableArtifact {
3
+ artifact: ResearchArtifact;
4
+ filePath: string;
5
+ }
6
+ /**
7
+ * Returns the searchable view of every active artifact in one research dir, using the sidecar index
8
+ * to skip unchanged files. Re-reads only files whose signature changed, then persists the refreshed
9
+ * index (best-effort; a write failure never breaks search). Corrupt/unreadable files are skipped —
10
+ * the real read paths in storage.ts handle archiving them.
11
+ */
12
+ export declare function loadSearchableArtifactsForDir(researchDir: string): SearchableArtifact[];
13
+ /**
14
+ * Multi-root variant: scans the `research/` dir of each data dir in order and deduplicates by cache
15
+ * key so a project entry shadows the same key in the global cache. Mirrors the dedup semantics of
16
+ * {@link scanCacheDirs} but reads through the per-root sidecar index.
17
+ */
18
+ export declare function loadSearchableArtifacts(dataDirs: string[]): SearchableArtifact[];
@@ -0,0 +1,109 @@
1
+ import { join } from 'node:path';
2
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
3
+ import { atomicWriteFile } from '../atomic-write.js';
4
+ import { parseArtifactShallow } from './artifact.js';
5
+ import { isResearchFile } from './storage.js';
6
+ // Sidecar index that caches the lightweight (metadata + summary + compressed) view of each artifact
7
+ // so repeat searches avoid re-reading and re-parsing every file. It is a derived cache only: a stale
8
+ // or missing index never changes results, because each entry is validated against the file's current
9
+ // signature and re-read on any mismatch.
10
+ const INDEX_FILE = '.search-index.json';
11
+ const INDEX_VERSION = 1;
12
+ // The repo writes artifacts via temp-file + rename, so every rewrite yields a new inode. Combining
13
+ // mtime + size + ino makes any change a guaranteed cache miss even when filesystem mtime resolution
14
+ // is coarse (HFS+/FAT) and a same-size edit lands within the same timestamp tick.
15
+ function fileSignature(stat) {
16
+ return `${stat.mtimeMs}:${stat.size}:${stat.ino}`;
17
+ }
18
+ function loadIndex(indexPath) {
19
+ try {
20
+ const parsed = JSON.parse(readFileSync(indexPath, 'utf-8'));
21
+ if (parsed && parsed.version === INDEX_VERSION && parsed.entries) {
22
+ return parsed;
23
+ }
24
+ }
25
+ catch {
26
+ // Missing or corrupt index: start fresh and rebuild.
27
+ }
28
+ return { version: INDEX_VERSION, entries: {} };
29
+ }
30
+ function getOrUpdateIndexEntry(filePath, cached, onChanged) {
31
+ let sig;
32
+ try {
33
+ sig = fileSignature(statSync(filePath));
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ if (cached && cached.sig === sig) {
39
+ return cached;
40
+ }
41
+ try {
42
+ const entry = { sig, artifact: parseArtifactShallow(readFileSync(filePath, 'utf-8')) };
43
+ onChanged();
44
+ return entry;
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ }
50
+ /**
51
+ * Returns the searchable view of every active artifact in one research dir, using the sidecar index
52
+ * to skip unchanged files. Re-reads only files whose signature changed, then persists the refreshed
53
+ * index (best-effort; a write failure never breaks search). Corrupt/unreadable files are skipped —
54
+ * the real read paths in storage.ts handle archiving them.
55
+ */
56
+ export function loadSearchableArtifactsForDir(researchDir) {
57
+ if (!existsSync(researchDir))
58
+ return [];
59
+ const indexPath = join(researchDir, INDEX_FILE);
60
+ const previous = loadIndex(indexPath);
61
+ const entries = {};
62
+ const results = [];
63
+ let changed = false;
64
+ for (const file of readdirSync(researchDir)) {
65
+ if (!isResearchFile(file))
66
+ continue;
67
+ const filePath = join(researchDir, file);
68
+ const entry = getOrUpdateIndexEntry(filePath, previous.entries[file], () => {
69
+ changed = true;
70
+ });
71
+ if (entry) {
72
+ entries[file] = entry;
73
+ results.push({ artifact: entry.artifact, filePath });
74
+ }
75
+ }
76
+ // Files removed since the last run leave dangling entries; drop them so the index can't grow
77
+ // without bound.
78
+ if (!changed) {
79
+ changed = Object.keys(previous.entries).some((key) => !(key in entries));
80
+ }
81
+ if (changed) {
82
+ try {
83
+ atomicWriteFile(indexPath, JSON.stringify({ version: INDEX_VERSION, entries }));
84
+ }
85
+ catch {
86
+ // Best-effort cache write; search results are already computed without it.
87
+ }
88
+ }
89
+ return results;
90
+ }
91
+ /**
92
+ * Multi-root variant: scans the `research/` dir of each data dir in order and deduplicates by cache
93
+ * key so a project entry shadows the same key in the global cache. Mirrors the dedup semantics of
94
+ * {@link scanCacheDirs} but reads through the per-root sidecar index.
95
+ */
96
+ export function loadSearchableArtifacts(dataDirs) {
97
+ const seen = new Set();
98
+ const all = [];
99
+ for (const dataDir of dataDirs) {
100
+ for (const located of loadSearchableArtifactsForDir(join(dataDir, 'research'))) {
101
+ const key = located.artifact.metadata.cache_key;
102
+ if (seen.has(key))
103
+ continue;
104
+ seen.add(key);
105
+ all.push(located);
106
+ }
107
+ }
108
+ return all;
109
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ResearchArtifact } from './schema.js';
2
+ export declare function isResearchFile(file: string): boolean;
2
3
  /**
3
4
  * Scans a cache directory and maps each valid artifact file through a callback.
4
5
  * Return null from the callback to skip an entry; all non-null results are collected.
@@ -2,7 +2,7 @@ import { join } from 'node:path';
2
2
  import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, renameSync, } from 'node:fs';
3
3
  import { atomicWriteFile } from '../atomic-write.js';
4
4
  import { parseArtifact, serializeArtifact } from './artifact.js';
5
- function isResearchFile(file) {
5
+ export function isResearchFile(file) {
6
6
  return (file.endsWith('.md') &&
7
7
  !file.includes('.tmp') &&
8
8
  !file.includes('.corrupt') &&
@@ -0,0 +1,17 @@
1
+ import type { SummaryLevel } from '../config/schema.js';
2
+ export type { SummaryLevel };
3
+ export interface SummarizeOptions {
4
+ /** Aggressiveness of prose reduction. Default 'conservative'. */
5
+ level?: SummaryLevel;
6
+ /** Below this token count the whole document is returned unchanged. Default 200. */
7
+ minTokens?: number;
8
+ /** Prose paragraphs with fewer sentences than this are kept whole. Default 2. */
9
+ minSentences?: number;
10
+ }
11
+ export declare function splitSentences(paragraph: string): string[];
12
+ /**
13
+ * Extractively shortens the prose paragraphs of a Markdown document while preserving headings,
14
+ * fenced code blocks, tables, and lists verbatim. Returns the input unchanged when it is below
15
+ * `minTokens` or contains no prose to condense. Deterministic for identical input + options.
16
+ */
17
+ export declare function summarizeMarkdown(markdown: string, options?: SummarizeOptions): string;