@taurgis/bonsai 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +239 -0
  2. package/agents/agents/official-docs-researcher.agent.md +98 -0
  3. package/agents/instructions/web-research.instructions.md +29 -0
  4. package/agents/skills/web-research/SKILL.md +126 -0
  5. package/bin/cli.mjs +22 -0
  6. package/dist/base-command.d.ts +14 -0
  7. package/dist/base-command.js +56 -0
  8. package/dist/commands/config/base.d.ts +23 -0
  9. package/dist/commands/config/base.js +46 -0
  10. package/dist/commands/config/get.d.ts +20 -0
  11. package/dist/commands/config/get.js +48 -0
  12. package/dist/commands/config/list.d.ts +17 -0
  13. package/dist/commands/config/list.js +48 -0
  14. package/dist/commands/config/set.d.ts +31 -0
  15. package/dist/commands/config/set.js +94 -0
  16. package/dist/commands/config/unset.d.ts +21 -0
  17. package/dist/commands/config/unset.js +65 -0
  18. package/dist/commands/fetch.d.ts +38 -0
  19. package/dist/commands/fetch.js +311 -0
  20. package/dist/commands/import.d.ts +38 -0
  21. package/dist/commands/import.js +313 -0
  22. package/dist/commands/index.d.ts +35 -0
  23. package/dist/commands/index.js +306 -0
  24. package/dist/commands/inspect.d.ts +13 -0
  25. package/dist/commands/inspect.js +82 -0
  26. package/dist/commands/list.d.ts +25 -0
  27. package/dist/commands/list.js +143 -0
  28. package/dist/commands/prune.d.ts +24 -0
  29. package/dist/commands/prune.js +141 -0
  30. package/dist/commands/research/config/base.d.ts +23 -0
  31. package/dist/commands/research/config/base.js +46 -0
  32. package/dist/commands/research/config/get.d.ts +20 -0
  33. package/dist/commands/research/config/get.js +45 -0
  34. package/dist/commands/research/config/list.d.ts +17 -0
  35. package/dist/commands/research/config/list.js +42 -0
  36. package/dist/commands/research/config/set.d.ts +31 -0
  37. package/dist/commands/research/config/set.js +94 -0
  38. package/dist/commands/research/config/unset.d.ts +21 -0
  39. package/dist/commands/research/config/unset.js +59 -0
  40. package/dist/commands/research/import.d.ts +38 -0
  41. package/dist/commands/research/import.js +313 -0
  42. package/dist/commands/research/inspect.d.ts +13 -0
  43. package/dist/commands/research/inspect.js +86 -0
  44. package/dist/commands/research/list.d.ts +25 -0
  45. package/dist/commands/research/list.js +143 -0
  46. package/dist/commands/research/prune.d.ts +24 -0
  47. package/dist/commands/research/prune.js +141 -0
  48. package/dist/commands/research/search.d.ts +32 -0
  49. package/dist/commands/research/search.js +334 -0
  50. package/dist/commands/research/status.d.ts +17 -0
  51. package/dist/commands/research/status.js +108 -0
  52. package/dist/commands/research.d.ts +36 -0
  53. package/dist/commands/research.js +304 -0
  54. package/dist/commands/search.d.ts +32 -0
  55. package/dist/commands/search.js +334 -0
  56. package/dist/commands/status.d.ts +17 -0
  57. package/dist/commands/status.js +95 -0
  58. package/dist/commands.d.ts +24 -0
  59. package/dist/commands.js +24 -0
  60. package/dist/lib/config/index.d.ts +18 -0
  61. package/dist/lib/config/index.js +33 -0
  62. package/dist/lib/config/io.d.ts +8 -0
  63. package/dist/lib/config/io.js +75 -0
  64. package/dist/lib/config/resolve.d.ts +15 -0
  65. package/dist/lib/config/resolve.js +20 -0
  66. package/dist/lib/config/schema.d.ts +28 -0
  67. package/dist/lib/config/schema.js +32 -0
  68. package/dist/lib/research/artifact.d.ts +21 -0
  69. package/dist/lib/research/artifact.js +217 -0
  70. package/dist/lib/research/browser.d.ts +74 -0
  71. package/dist/lib/research/browser.js +359 -0
  72. package/dist/lib/research/cache-key.d.ts +5 -0
  73. package/dist/lib/research/cache-key.js +10 -0
  74. package/dist/lib/research/capture.d.ts +29 -0
  75. package/dist/lib/research/capture.js +181 -0
  76. package/dist/lib/research/compress.d.ts +5 -0
  77. package/dist/lib/research/compress.js +17 -0
  78. package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.d.ts +1 -0
  79. package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.js +2 -0
  80. package/dist/lib/research/docs/capabilities.d.ts +43 -0
  81. package/dist/lib/research/docs/capabilities.js +9 -0
  82. package/dist/lib/research/docs/clean-dom.d.ts +4 -0
  83. package/dist/lib/research/docs/clean-dom.js +41 -0
  84. package/dist/lib/research/docs/code-blocks.d.ts +5 -0
  85. package/dist/lib/research/docs/code-blocks.js +69 -0
  86. package/dist/lib/research/docs/detect.d.ts +6 -0
  87. package/dist/lib/research/docs/detect.js +239 -0
  88. package/dist/lib/research/docs/machine-readable.d.ts +21 -0
  89. package/dist/lib/research/docs/machine-readable.js +73 -0
  90. package/dist/lib/research/docs/markdown-source.d.ts +17 -0
  91. package/dist/lib/research/docs/markdown-source.js +194 -0
  92. package/dist/lib/research/docs/quality-gates.d.ts +9 -0
  93. package/dist/lib/research/docs/quality-gates.js +65 -0
  94. package/dist/lib/research/docs/remote-search-runner.d.ts +21 -0
  95. package/dist/lib/research/docs/remote-search-runner.js +54 -0
  96. package/dist/lib/research/docs/remote-search.d.ts +14 -0
  97. package/dist/lib/research/docs/remote-search.js +101 -0
  98. package/dist/lib/research/docs/rsc.d.ts +10 -0
  99. package/dist/lib/research/docs/rsc.js +30 -0
  100. package/dist/lib/research/docs/search-index.d.ts +13 -0
  101. package/dist/lib/research/docs/search-index.js +176 -0
  102. package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
  103. package/dist/lib/research/docs/section-artifacts.js +75 -0
  104. package/dist/lib/research/docs/sections.d.ts +12 -0
  105. package/dist/lib/research/docs/sections.js +67 -0
  106. package/dist/lib/research/docs/source-map.d.ts +19 -0
  107. package/dist/lib/research/docs/source-map.js +87 -0
  108. package/dist/lib/research/docs/validate.d.ts +15 -0
  109. package/dist/lib/research/docs/validate.js +63 -0
  110. package/dist/lib/research/extract.d.ts +11 -0
  111. package/dist/lib/research/extract.js +83 -0
  112. package/dist/lib/research/fetcher.d.ts +29 -0
  113. package/dist/lib/research/fetcher.js +192 -0
  114. package/dist/lib/research/freshness.d.ts +21 -0
  115. package/dist/lib/research/freshness.js +82 -0
  116. package/dist/lib/research/markdown.d.ts +6 -0
  117. package/dist/lib/research/markdown.js +66 -0
  118. package/dist/lib/research/regression.d.ts +23 -0
  119. package/dist/lib/research/regression.js +93 -0
  120. package/dist/lib/research/resolve-target.d.ts +18 -0
  121. package/dist/lib/research/resolve-target.js +20 -0
  122. package/dist/lib/research/revalidate.d.ts +32 -0
  123. package/dist/lib/research/revalidate.js +152 -0
  124. package/dist/lib/research/schema.d.ts +47 -0
  125. package/dist/lib/research/schema.js +1 -0
  126. package/dist/lib/research/secret-scan.d.ts +5 -0
  127. package/dist/lib/research/secret-scan.js +37 -0
  128. package/dist/lib/research/secure-write.d.ts +16 -0
  129. package/dist/lib/research/secure-write.js +17 -0
  130. package/dist/lib/research/storage.d.ts +52 -0
  131. package/dist/lib/research/storage.js +182 -0
  132. package/dist/lib/research/store-roots.d.ts +29 -0
  133. package/dist/lib/research/store-roots.js +28 -0
  134. package/dist/lib/research/token-estimate.d.ts +5 -0
  135. package/dist/lib/research/token-estimate.js +7 -0
  136. package/dist/lib/research/url.d.ts +24 -0
  137. package/dist/lib/research/url.js +121 -0
  138. package/dist/sites/index.d.ts +4 -0
  139. package/dist/sites/index.js +28 -0
  140. package/dist/sites/salesforce/browser-search.d.ts +7 -0
  141. package/dist/sites/salesforce/browser-search.js +82 -0
  142. package/dist/sites/salesforce/coveo.d.ts +23 -0
  143. package/dist/sites/salesforce/coveo.js +161 -0
  144. package/dist/sites/salesforce/fetch-page.d.ts +6 -0
  145. package/dist/sites/salesforce/fetch-page.js +42 -0
  146. package/dist/sites/salesforce/index.d.ts +2 -0
  147. package/dist/sites/salesforce/index.js +16 -0
  148. package/dist/sites/salesforce/token.d.ts +26 -0
  149. package/dist/sites/salesforce/token.js +154 -0
  150. package/dist/sites/salesforce-developer/fetch-page.d.ts +3 -0
  151. package/dist/sites/salesforce-developer/fetch-page.js +18 -0
  152. package/dist/sites/salesforce-developer/index.d.ts +2 -0
  153. package/dist/sites/salesforce-developer/index.js +12 -0
  154. package/dist/sites/salesforce-doc-fetch.d.ts +14 -0
  155. package/dist/sites/salesforce-doc-fetch.js +321 -0
  156. package/dist/sites/types.d.ts +27 -0
  157. package/dist/sites/types.js +1 -0
  158. package/oclif.manifest.json +845 -0
  159. package/package.json +78 -0
@@ -0,0 +1,152 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { getSiteModuleById } from '../../sites/index.js';
3
+ import { fetchStaticHtml } from './fetcher.js';
4
+ import { extractHtmlContent } from './extract.js';
5
+ import { writeArtifact } from './storage.js';
6
+ import { evaluateFreshness, getPolicy } from './freshness.js';
7
+ import { compressMarkdown } from './compress.js';
8
+ import { estimateTokens } from './token-estimate.js';
9
+ import { fetchRenderedHtml } from './browser.js';
10
+ function buildMetadata(url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime, compressed) {
11
+ const contentHash = createHash('sha256').update(extraction.detailedMarkdown).digest('hex');
12
+ const staleAfterTime = new Date(currentTime);
13
+ const { freshWindowMs } = getPolicy(tier, ttl);
14
+ staleAfterTime.setTime(staleAfterTime.getTime() + freshWindowMs);
15
+ return {
16
+ schema_version: 1,
17
+ artifact_type: 'source',
18
+ source_url: url,
19
+ source_urls: [url],
20
+ normalized_url: normalizedUrl,
21
+ cache_key: cacheKey,
22
+ topic: null,
23
+ tags: [],
24
+ format_available: ['compressed', 'detailed'],
25
+ tier,
26
+ ttl,
27
+ fetched_at: currentTime.toISOString(),
28
+ validated_at: currentTime.toISOString(),
29
+ stale_after: staleAfterTime.toISOString(),
30
+ capture_method: 'static_fetch',
31
+ extraction_status: 'extracted',
32
+ extraction_confidence: extraction.confidence,
33
+ quality_notes: extraction.qualityNotes,
34
+ supplied_at: null,
35
+ supplied_by: null,
36
+ etag: fetchResult.etag,
37
+ last_modified: fetchResult.lastModified,
38
+ content_hash: contentHash,
39
+ token_estimate: {
40
+ compressed: estimateTokens(compressed),
41
+ detailed: estimateTokens(extraction.detailedMarkdown),
42
+ },
43
+ status: 'active',
44
+ site_module_id: null,
45
+ docs_engine: null,
46
+ docs_framework: null,
47
+ source_doc_url: null,
48
+ search_provider: null,
49
+ parent_cache_key: null,
50
+ section_anchor: null,
51
+ section_heading_path: null,
52
+ };
53
+ }
54
+ /**
55
+ * Helper to construct a ResearchArtifact from a fresh HTML fetch.
56
+ */
57
+ export function createArtifactFromFetch(url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime) {
58
+ const compressed = compressMarkdown(extraction.detailedMarkdown);
59
+ const metadata = buildMetadata(url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime, compressed);
60
+ return {
61
+ metadata,
62
+ summary: extraction.title,
63
+ compressed,
64
+ detailed: extraction.detailedMarkdown,
65
+ provenance: `Fetched from ${url} on ${currentTime.toISOString()}`,
66
+ };
67
+ }
68
+ function preserveUserMetadata(source, target, rendered) {
69
+ target.metadata.topic = source.topic;
70
+ target.metadata.tags = [...source.tags];
71
+ target.metadata.site_module_id = source.site_module_id;
72
+ // Carry forward docs-engine capability provenance so revalidation doesn't drop it.
73
+ target.metadata.docs_engine = source.docs_engine;
74
+ target.metadata.docs_framework = source.docs_framework;
75
+ target.metadata.search_provider = source.search_provider;
76
+ target.metadata.source_doc_url = source.source_doc_url;
77
+ if (rendered)
78
+ target.metadata.capture_method = 'browser_fallback';
79
+ }
80
+ // Builds a refreshed artifact from a fresh fetch, carries over user metadata, persists it, and
81
+ // returns the "refreshed" result. Shared by both refresh paths (conditional-request fallthrough
82
+ // and site-module re-fetch), which differ only in how fetchResult/extraction are obtained.
83
+ function persistRefreshedArtifact(dataDir, meta, fetchResult, extraction, currentTime, options) {
84
+ const refreshed = createArtifactFromFetch(meta.source_url, meta.normalized_url, meta.cache_key, fetchResult, extraction, meta.tier, options.ttlOverride || meta.ttl, currentTime);
85
+ preserveUserMetadata(meta, refreshed, options.rendered);
86
+ writeArtifact(dataDir, meta.cache_key, refreshed);
87
+ return { status: 'refreshed', artifact: refreshed };
88
+ }
89
+ async function handleRevalidateResponse(dataDir, existing, fetchResult, currentTime, options) {
90
+ const meta = existing.metadata;
91
+ if (fetchResult.status === 304) {
92
+ const updated = {
93
+ ...existing,
94
+ metadata: {
95
+ ...meta,
96
+ validated_at: currentTime.toISOString(),
97
+ },
98
+ };
99
+ const staleAfterTime = new Date(currentTime);
100
+ const { freshWindowMs } = getPolicy(meta.tier, options.ttlOverride || meta.ttl);
101
+ staleAfterTime.setTime(staleAfterTime.getTime() + freshWindowMs);
102
+ updated.metadata.stale_after = staleAfterTime.toISOString();
103
+ writeArtifact(dataDir, meta.cache_key, updated);
104
+ return { status: 'revalidated', artifact: updated };
105
+ }
106
+ const extraction = extractHtmlContent(fetchResult.content, fetchResult.finalUrl);
107
+ return persistRefreshedArtifact(dataDir, meta, fetchResult, extraction, currentTime, options);
108
+ }
109
+ /**
110
+ * Revalidates a stale cache artifact using conditional request headers,
111
+ * falling back to stale serving within the grace period if the remote host is offline.
112
+ */
113
+ export async function revalidateCache(dataDir, existing, currentTime, options) {
114
+ const meta = existing.metadata;
115
+ const freshness = evaluateFreshness(meta, currentTime, options.ttlOverride);
116
+ if (freshness === 'fresh') {
117
+ return { status: 'revalidated', artifact: existing };
118
+ }
119
+ const revalHeaders = {};
120
+ if (meta.etag) {
121
+ revalHeaders['If-None-Match'] = meta.etag;
122
+ }
123
+ if (meta.last_modified) {
124
+ revalHeaders['If-Modified-Since'] = meta.last_modified;
125
+ }
126
+ try {
127
+ const siteModule = meta.site_module_id ? getSiteModuleById(meta.site_module_id) : null;
128
+ if (siteModule?.fetchPage) {
129
+ // Site modules use custom fetch strategies that don't speak HTTP conditional requests
130
+ // (no ETag/If-Modified-Since), so a full re-fetch is the only correct revalidation here.
131
+ const { fetchResult, extraction } = await siteModule.fetchPage(meta.source_url);
132
+ return persistRefreshedArtifact(dataDir, meta, fetchResult, extraction, currentTime, options);
133
+ }
134
+ const fetchResult = options.rendered
135
+ ? await fetchRenderedHtml(meta.source_url)
136
+ : await fetchStaticHtml(meta.source_url, {
137
+ headers: revalHeaders,
138
+ });
139
+ return await handleRevalidateResponse(dataDir, existing, fetchResult, currentTime, options);
140
+ }
141
+ catch (err) {
142
+ if (freshness === 'stale_grace') {
143
+ return {
144
+ status: 'stale',
145
+ artifact: existing,
146
+ allowed: options.allowStale,
147
+ error: `Revalidation failed: ${err.message}. Serving stale content within grace period.`,
148
+ };
149
+ }
150
+ throw new Error(`Revalidation failed and cache has expired beyond the grace period: ${err.message}`);
151
+ }
152
+ }
@@ -0,0 +1,47 @@
1
+ export interface TokenEstimate {
2
+ compressed: number | null;
3
+ detailed: number | null;
4
+ }
5
+ export type CaptureMethod = 'static_fetch' | 'browser_fallback' | 'agent_supplied' | 'route_markdown' | 'github_source';
6
+ export interface ResearchArtifactMetadata {
7
+ schema_version: number;
8
+ artifact_type: 'source' | 'research_note' | 'index' | 'section';
9
+ source_url: string;
10
+ source_urls: string[];
11
+ normalized_url: string;
12
+ cache_key: string;
13
+ topic: string | null;
14
+ tags: string[];
15
+ format_available: string[];
16
+ tier: 'stable' | 'standard' | 'volatile';
17
+ ttl: string | null;
18
+ fetched_at: string | null;
19
+ validated_at: string | null;
20
+ stale_after: string | null;
21
+ capture_method: CaptureMethod | null;
22
+ extraction_status: 'extracted' | 'agent_supplied' | 'failed' | null;
23
+ extraction_confidence: 'high' | 'medium' | 'low' | null;
24
+ quality_notes: string[];
25
+ supplied_at: string | null;
26
+ supplied_by: string | null;
27
+ etag: string | null;
28
+ last_modified: string | null;
29
+ content_hash: string;
30
+ token_estimate: TokenEstimate;
31
+ status: 'active' | 'archived';
32
+ site_module_id: string | null;
33
+ docs_engine: string | null;
34
+ docs_framework: string | null;
35
+ source_doc_url: string | null;
36
+ search_provider: string | null;
37
+ parent_cache_key: string | null;
38
+ section_anchor: string | null;
39
+ section_heading_path: string | null;
40
+ }
41
+ export interface ResearchArtifact {
42
+ metadata: ResearchArtifactMetadata;
43
+ summary: string;
44
+ compressed: string;
45
+ detailed: string;
46
+ provenance: string;
47
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ import type { ResearchArtifact } from './schema.js';
2
+ /** Return the label of the first secret pattern found in `text`, or null if none match. */
3
+ export declare function detectSecret(text: string): string | null;
4
+ /** Scan every content field of an artifact. Returns the detected secret's label, or null. */
5
+ export declare function scanArtifactForSecret(artifact: ResearchArtifact): string | null;
@@ -0,0 +1,37 @@
1
+ const SECRET_PATTERNS = [
2
+ { label: 'AWS access key id', re: /\bAKIA[0-9A-Z]{16}\b/ },
3
+ { label: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/ },
4
+ { label: 'GitHub fine-grained PAT', re: /\bgithub_pat_[A-Za-z0-9_]{80,}\b/ },
5
+ { label: 'Slack token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/ },
6
+ { label: 'OpenAI/Anthropic-style API key', re: /\bsk-[A-Za-z0-9_-]{20,}\b/ },
7
+ { label: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
8
+ {
9
+ label: 'JSON Web Token',
10
+ re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/,
11
+ },
12
+ { label: 'Bearer token', re: /\bBearer\s+[A-Za-z0-9._~+/-]{20,}=*/ },
13
+ {
14
+ label: 'Private key block',
15
+ re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/,
16
+ },
17
+ {
18
+ // Generic `api_key = "…"` / `secret: …` style assignments with a non-trivial value.
19
+ label: 'credential assignment',
20
+ re: /\b(?:api[_-]?key|secret|token|password|passwd|client[_-]?secret|access[_-]?token)\b\s*[:=]\s*['"]?[A-Za-z0-9._\-/+]{16,}/i,
21
+ },
22
+ ];
23
+ /** Return the label of the first secret pattern found in `text`, or null if none match. */
24
+ export function detectSecret(text) {
25
+ for (const { label, re } of SECRET_PATTERNS) {
26
+ if (re.test(text))
27
+ return label;
28
+ }
29
+ return null;
30
+ }
31
+ /** Scan every content field of an artifact. Returns the detected secret's label, or null. */
32
+ export function scanArtifactForSecret(artifact) {
33
+ const haystack = [artifact.summary, artifact.compressed, artifact.detailed, artifact.provenance]
34
+ .filter(Boolean)
35
+ .join('\n');
36
+ return detectSecret(haystack);
37
+ }
@@ -0,0 +1,16 @@
1
+ import type { StoreRoots } from './store-roots.js';
2
+ import type { ResearchArtifact } from './schema.js';
3
+ export interface SecureWriteResult {
4
+ /** Data dir the artifact was actually written to. */
5
+ dataDir: string;
6
+ /** True when a project-bound write was redirected to global because of a detected secret. */
7
+ redirected: boolean;
8
+ /** Label of the detected secret type when redirected, otherwise null. Never the secret value. */
9
+ secretLabel: string | null;
10
+ }
11
+ /**
12
+ * Write an artifact to the configured write root, except when it contains a secret and the
13
+ * target is a (potentially committed) project cache — those are redirected to the global cache.
14
+ * Returns where it landed so the caller can warn the user and report the real path.
15
+ */
16
+ export declare function writeArtifactSecurely(roots: StoreRoots, key: string, artifact: ResearchArtifact): SecureWriteResult;
@@ -0,0 +1,17 @@
1
+ import { writeArtifact } from './storage.js';
2
+ import { scanArtifactForSecret } from './secret-scan.js';
3
+ /**
4
+ * Write an artifact to the configured write root, except when it contains a secret and the
5
+ * target is a (potentially committed) project cache — those are redirected to the global cache.
6
+ * Returns where it landed so the caller can warn the user and report the real path.
7
+ */
8
+ export function writeArtifactSecurely(roots, key, artifact) {
9
+ const isProjectWrite = roots.writeRoot !== roots.globalRoot;
10
+ const secretLabel = isProjectWrite ? scanArtifactForSecret(artifact) : null;
11
+ if (secretLabel) {
12
+ writeArtifact(roots.globalRoot, key, artifact);
13
+ return { dataDir: roots.globalRoot, redirected: true, secretLabel };
14
+ }
15
+ writeArtifact(roots.writeRoot, key, artifact);
16
+ return { dataDir: roots.writeRoot, redirected: false, secretLabel: null };
17
+ }
@@ -0,0 +1,52 @@
1
+ import type { ResearchArtifact } from './schema.js';
2
+ /**
3
+ * Scans a cache directory and maps each valid artifact file through a callback.
4
+ * Return null from the callback to skip an entry; all non-null results are collected.
5
+ * Parse/read errors are swallowed silently, matching the convention in the command layer.
6
+ */
7
+ export declare function scanCacheDir<T>(dir: string, fn: (artifact: ResearchArtifact, filePath: string) => T | null): T[];
8
+ /**
9
+ * Returns the absolute file path for a cache key within the data directory.
10
+ * Ensures that the key is simple hex to prevent directory traversal.
11
+ */
12
+ export declare function getArtifactPath(dataDir: string, key: string): string;
13
+ /**
14
+ * Scans the research storage directory, reading metadata of all valid markdown files
15
+ * to find the active artifact for the given cache key with the newest validated_at timestamp.
16
+ * If a corrupt artifact is found, it is skipped and archived as corrupt.
17
+ */
18
+ export declare function findArtifact(dataDir: string, key: string): ResearchArtifact | null;
19
+ /**
20
+ * Checks if a cached artifact exists on disk for a given cache key.
21
+ */
22
+ export declare function hasArtifact(dataDir: string, key: string): boolean;
23
+ /**
24
+ * Writes a ResearchArtifact to disk, creating the research storage folder if needed.
25
+ * Writes to a temporary file first, then atomically renames it.
26
+ * Archives the previous artifact if it exists.
27
+ */
28
+ export declare function writeArtifact(dataDir: string, key: string, artifact: ResearchArtifact): void;
29
+ /**
30
+ * Reads and parses a ResearchArtifact from disk using the scan-based lookup.
31
+ */
32
+ export declare function readArtifact(dataDir: string, key: string): ResearchArtifact;
33
+ export interface LocatedArtifact {
34
+ artifact: ResearchArtifact;
35
+ /** The data dir the artifact was found in (project or global). */
36
+ dataDir: string;
37
+ /** The artifact's actual file path within that data dir. */
38
+ path: string;
39
+ }
40
+ /**
41
+ * Looks up a cache key across an ordered list of data dirs (project first, then global),
42
+ * returning the first match along with where it lives. Implements the project→global
43
+ * read fallback: a missing project entry still resolves against the global cache.
44
+ */
45
+ export declare function locateArtifact(dataDirs: string[], key: string): LocatedArtifact | null;
46
+ /**
47
+ * Multi-root variant of {@link scanCacheDir}. Scans the `research/` dir of each data dir in
48
+ * order and deduplicates by cache key so an entry present in an earlier root (project) shadows
49
+ * the same key in a later root (global) — even when the callback filters it out. Composes
50
+ * scanCacheDir so the file-format and read/parse rules stay defined in exactly one place.
51
+ */
52
+ export declare function scanCacheDirs<T>(dataDirs: string[], fn: (artifact: ResearchArtifact, filePath: string) => T | null): T[];
@@ -0,0 +1,182 @@
1
+ import { join } from 'node:path';
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlinkSync, renameSync, } from 'node:fs';
3
+ import { parseArtifact, serializeArtifact } from './artifact.js';
4
+ function isResearchFile(file) {
5
+ return (file.endsWith('.md') &&
6
+ !file.includes('.tmp') &&
7
+ !file.includes('.corrupt') &&
8
+ !file.includes('.superseded'));
9
+ }
10
+ /**
11
+ * Scans a cache directory and maps each valid artifact file through a callback.
12
+ * Return null from the callback to skip an entry; all non-null results are collected.
13
+ * Parse/read errors are swallowed silently, matching the convention in the command layer.
14
+ */
15
+ export function scanCacheDir(dir, fn) {
16
+ if (!existsSync(dir))
17
+ return [];
18
+ const results = [];
19
+ for (const file of readdirSync(dir)) {
20
+ if (!isResearchFile(file))
21
+ continue;
22
+ try {
23
+ const content = readFileSync(join(dir, file), 'utf-8');
24
+ const result = fn(parseArtifact(content), join(dir, file));
25
+ if (result !== null)
26
+ results.push(result);
27
+ }
28
+ catch { }
29
+ }
30
+ return results;
31
+ }
32
+ /**
33
+ * Returns the absolute file path for a cache key within the data directory.
34
+ * Ensures that the key is simple hex to prevent directory traversal.
35
+ */
36
+ export function getArtifactPath(dataDir, key) {
37
+ if (!/^[a-f0-9]+$/.test(key)) {
38
+ throw new Error(`Invalid cache key "${key}". Keys must be alphanumeric hex strings.`);
39
+ }
40
+ return join(dataDir, 'research', `${key}.md`);
41
+ }
42
+ function processFileForLookup(file, dir, key) {
43
+ if (!file.endsWith('.md') || file.includes('.tmp')) {
44
+ return null;
45
+ }
46
+ const filePath = join(dir, file);
47
+ try {
48
+ const content = readFileSync(filePath, 'utf-8');
49
+ const artifact = parseArtifact(content);
50
+ if (artifact.metadata.cache_key === key && artifact.metadata.status === 'active') {
51
+ return artifact;
52
+ }
53
+ }
54
+ catch (err) {
55
+ console.warn(`Warning: Corrupt research artifact found at "${filePath}": ${err.message}`);
56
+ try {
57
+ const corruptPath = `${filePath}.corrupt.${Date.now()}`;
58
+ renameSync(filePath, corruptPath);
59
+ console.warn(`Archived corrupt artifact to "${corruptPath}"`);
60
+ }
61
+ catch (archiveErr) {
62
+ console.error(`Failed to archive corrupt artifact: ${archiveErr.message}`);
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+ /**
68
+ * Scans the research storage directory, reading metadata of all valid markdown files
69
+ * to find the active artifact for the given cache key with the newest validated_at timestamp.
70
+ * If a corrupt artifact is found, it is skipped and archived as corrupt.
71
+ */
72
+ export function findArtifact(dataDir, key) {
73
+ const dir = join(dataDir, 'research');
74
+ if (!existsSync(dir)) {
75
+ return null;
76
+ }
77
+ let bestArtifact = null;
78
+ let bestValidatedAt = -1;
79
+ const files = readdirSync(dir);
80
+ for (const file of files) {
81
+ const artifact = processFileForLookup(file, dir, key);
82
+ if (!artifact) {
83
+ continue;
84
+ }
85
+ const validatedAt = artifact.metadata.validated_at
86
+ ? new Date(artifact.metadata.validated_at).getTime()
87
+ : 0;
88
+ if (validatedAt > bestValidatedAt) {
89
+ bestArtifact = artifact;
90
+ bestValidatedAt = validatedAt;
91
+ }
92
+ }
93
+ return bestArtifact;
94
+ }
95
+ /**
96
+ * Checks if a cached artifact exists on disk for a given cache key.
97
+ */
98
+ export function hasArtifact(dataDir, key) {
99
+ try {
100
+ return findArtifact(dataDir, key) !== null;
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ }
106
+ /**
107
+ * Writes a ResearchArtifact to disk, creating the research storage folder if needed.
108
+ * Writes to a temporary file first, then atomically renames it.
109
+ * Archives the previous artifact if it exists.
110
+ */
111
+ export function writeArtifact(dataDir, key, artifact) {
112
+ const path = getArtifactPath(dataDir, key);
113
+ const dir = join(dataDir, 'research');
114
+ if (!existsSync(dir)) {
115
+ mkdirSync(dir, { recursive: true });
116
+ }
117
+ const tmpPath = `${path}.${Date.now()}.${Math.random().toString(36).substring(2, 7)}.tmp`;
118
+ const content = serializeArtifact(artifact);
119
+ writeFileSync(tmpPath, content, 'utf-8');
120
+ try {
121
+ if (existsSync(path)) {
122
+ try {
123
+ const existingContent = readFileSync(path, 'utf-8');
124
+ const archivePath = join(dir, `${key}.superseded.${Date.now()}.md`);
125
+ writeFileSync(archivePath, existingContent, 'utf-8');
126
+ }
127
+ catch (err) {
128
+ console.warn(`Warning: failed to archive superseded artifact: ${err.message}`);
129
+ }
130
+ }
131
+ renameSync(tmpPath, path);
132
+ }
133
+ catch (err) {
134
+ if (existsSync(tmpPath)) {
135
+ try {
136
+ unlinkSync(tmpPath);
137
+ }
138
+ catch { }
139
+ }
140
+ throw err;
141
+ }
142
+ }
143
+ /**
144
+ * Reads and parses a ResearchArtifact from disk using the scan-based lookup.
145
+ */
146
+ export function readArtifact(dataDir, key) {
147
+ const artifact = findArtifact(dataDir, key);
148
+ if (!artifact) {
149
+ throw new Error(`Artifact not found for key: ${key}`);
150
+ }
151
+ return artifact;
152
+ }
153
+ /**
154
+ * Looks up a cache key across an ordered list of data dirs (project first, then global),
155
+ * returning the first match along with where it lives. Implements the project→global
156
+ * read fallback: a missing project entry still resolves against the global cache.
157
+ */
158
+ export function locateArtifact(dataDirs, key) {
159
+ for (const dataDir of dataDirs) {
160
+ const artifact = findArtifact(dataDir, key);
161
+ if (artifact) {
162
+ return { artifact, dataDir, path: getArtifactPath(dataDir, key) };
163
+ }
164
+ }
165
+ return null;
166
+ }
167
+ /**
168
+ * Multi-root variant of {@link scanCacheDir}. Scans the `research/` dir of each data dir in
169
+ * order and deduplicates by cache key so an entry present in an earlier root (project) shadows
170
+ * the same key in a later root (global) — even when the callback filters it out. Composes
171
+ * scanCacheDir so the file-format and read/parse rules stay defined in exactly one place.
172
+ */
173
+ export function scanCacheDirs(dataDirs, fn) {
174
+ const seen = new Set();
175
+ return dataDirs.flatMap((dataDir) => scanCacheDir(join(dataDir, 'research'), (artifact, filePath) => {
176
+ const key = artifact.metadata.cache_key;
177
+ if (seen.has(key))
178
+ return null;
179
+ seen.add(key);
180
+ return fn(artifact, filePath);
181
+ }));
182
+ }
@@ -0,0 +1,29 @@
1
+ import type { StorageMode } from '../config/schema.js';
2
+ /**
3
+ * Project-level cache directory name, placed at the current working directory.
4
+ * Storage helpers append `research/` to a data dir, so the on-disk cache for a
5
+ * project lives at `<cwd>/.bonsai/research/`.
6
+ */
7
+ export declare const PROJECT_CACHE_DIRNAME = ".bonsai";
8
+ export declare function projectDataDir(cwd: string): string;
9
+ export interface StoreRoots {
10
+ mode: StorageMode;
11
+ /** Data dir new artifacts are written to (before secret routing). */
12
+ writeRoot: string;
13
+ /** Global data dir — the safe destination for secret-bearing artifacts. */
14
+ globalRoot: string;
15
+ /** Data dirs to read from, in lookup order. Project first, then global fallback. */
16
+ readRoots: string[];
17
+ }
18
+ /**
19
+ * Map a resolved storage mode onto concrete data dirs. In project mode, reads fall
20
+ * back to the global cache so a missing project entry still resolves against global.
21
+ */
22
+ export declare function resolveStoreRoots(mode: StorageMode, oclifDataDir: string, cwd: string): StoreRoots;
23
+ /** Load config (file + env + optional flag) and resolve the concrete store roots. */
24
+ export declare function loadStoreRoots(opts: {
25
+ configDir: string | undefined;
26
+ cwd: string;
27
+ dataDir: string;
28
+ flagOverride?: StorageMode;
29
+ }): StoreRoots;
@@ -0,0 +1,28 @@
1
+ import { join } from 'node:path';
2
+ import { loadStorageMode } from '../config/index.js';
3
+ /**
4
+ * Project-level cache directory name, placed at the current working directory.
5
+ * Storage helpers append `research/` to a data dir, so the on-disk cache for a
6
+ * project lives at `<cwd>/.bonsai/research/`.
7
+ */
8
+ export const PROJECT_CACHE_DIRNAME = '.bonsai';
9
+ export function projectDataDir(cwd) {
10
+ return join(cwd, PROJECT_CACHE_DIRNAME);
11
+ }
12
+ /**
13
+ * Map a resolved storage mode onto concrete data dirs. In project mode, reads fall
14
+ * back to the global cache so a missing project entry still resolves against global.
15
+ */
16
+ export function resolveStoreRoots(mode, oclifDataDir, cwd) {
17
+ const globalRoot = oclifDataDir;
18
+ if (mode === 'project') {
19
+ const project = projectDataDir(cwd);
20
+ return { mode, writeRoot: project, globalRoot, readRoots: [project, globalRoot] };
21
+ }
22
+ return { mode, writeRoot: globalRoot, globalRoot, readRoots: [globalRoot] };
23
+ }
24
+ /** Load config (file + env + optional flag) and resolve the concrete store roots. */
25
+ export function loadStoreRoots(opts) {
26
+ const mode = loadStorageMode(opts.configDir, opts.cwd, opts.flagOverride);
27
+ return resolveStoreRoots(mode, opts.dataDir, opts.cwd);
28
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Estimates the token count of a given text string.
3
+ * Uses the simple rule-of-thumb formula: ceil(character_count / 4).
4
+ */
5
+ export declare function estimateTokens(text: string): number;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Estimates the token count of a given text string.
3
+ * Uses the simple rule-of-thumb formula: ceil(character_count / 4).
4
+ */
5
+ export function estimateTokens(text) {
6
+ return Math.ceil((text || '').length / 4);
7
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Checks if a literal IPv4 address is in a safe range.
3
+ * Rejects loopback, private RFC1918, and link-local ranges.
4
+ */
5
+ export declare function isSafeIpv4(ip: string): boolean;
6
+ /**
7
+ * Checks if a literal IPv6 address is in a safe range.
8
+ * Rejects loopback, unspecified, link-local, and mapped unsafe IPv4 ranges.
9
+ */
10
+ export declare function isSafeIpv6(ip: string): boolean;
11
+ /**
12
+ * Checks if a literal IP address is a safe public IP.
13
+ * Returns false for loopback, private, link-local, or unspecified ranges.
14
+ */
15
+ export declare function isSafeIp(ip: string): boolean;
16
+ /**
17
+ * Validates a hostname to ensure it is not blocked (e.g. localhost or literal private IP).
18
+ */
19
+ export declare function validateHostname(hostname: string): void;
20
+ /**
21
+ * Parses and normalizes a URL. Strips fragments, credentials, default ports,
22
+ * and sorts query parameters lexicographically.
23
+ */
24
+ export declare function normalizeUrl(input: string): string;