@taurgis/bonsai 3.2.2 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/agents/agents/salesforce-docs-researcher.agent.md +4 -4
- package/bin/cli.mjs +7 -4
- package/dist/base-command.d.ts +37 -0
- package/dist/base-command.js +97 -6
- package/dist/commands/config/base.js +1 -0
- package/dist/commands/config/get.js +2 -2
- package/dist/commands/config/list.js +2 -2
- package/dist/commands/config/set.js +2 -2
- package/dist/commands/config/unset.js +2 -2
- package/dist/commands/fetch.js +4 -0
- package/dist/commands/inspect.js +11 -2
- package/dist/commands/list.d.ts +12 -9
- package/dist/commands/list.js +67 -39
- package/dist/commands/prune.d.ts +2 -0
- package/dist/commands/prune.js +35 -4
- package/dist/commands/status.js +10 -0
- package/dist/hooks/command-not-found/suggest.d.ts +1 -1
- package/dist/hooks/command-not-found/suggest.js +12 -2
- package/dist/lib/argv.d.ts +5 -0
- package/dist/lib/argv.js +27 -5
- package/dist/lib/cli-flag-manifest.d.ts +7 -0
- package/dist/lib/cli-flag-manifest.js +7 -0
- package/dist/lib/cli-presentation.d.ts +6 -4
- package/dist/lib/cli-presentation.js +7 -4
- package/dist/lib/cli-result-types.d.ts +25 -1
- package/dist/lib/config/index.d.ts +2 -2
- package/dist/lib/config/index.js +2 -2
- package/dist/lib/config/io.d.ts +9 -0
- package/dist/lib/config/io.js +71 -21
- package/dist/lib/config/schema.d.ts +2 -0
- package/dist/lib/config/schema.js +7 -0
- package/dist/lib/envelope.js +11 -4
- package/dist/lib/prune-flags.d.ts +2 -0
- package/dist/lib/prune-flags.js +18 -6
- package/dist/lib/research/artifact-index.js +7 -1
- package/dist/lib/research/artifact.js +20 -2
- package/dist/lib/research/browser.d.ts +12 -0
- package/dist/lib/research/browser.js +63 -8
- package/dist/lib/research/cli-io.d.ts +7 -0
- package/dist/lib/research/fetch-command-service.js +80 -23
- package/dist/lib/research/fetcher.js +3 -1
- package/dist/lib/research/import-command-service.js +22 -1
- package/dist/lib/research/metadata-filters.d.ts +18 -0
- package/dist/lib/research/metadata-filters.js +37 -0
- package/dist/lib/research/metadata-flags.d.ts +34 -0
- package/dist/lib/research/metadata-flags.js +45 -0
- package/dist/lib/research/prompt-injection.d.ts +2 -1
- package/dist/lib/research/prompt-injection.js +18 -2
- package/dist/lib/research/resolve-target.d.ts +2 -0
- package/dist/lib/research/resolve-target.js +1 -1
- package/dist/lib/research/storage.d.ts +4 -7
- package/dist/lib/research/storage.js +26 -25
- package/dist/lib/text.d.ts +14 -8
- package/dist/lib/text.js +21 -9
- package/oclif.manifest.json +120 -12
- package/package.json +2 -1
|
@@ -5,6 +5,7 @@ import { loadSummaryLevel } from '../config/index.js';
|
|
|
5
5
|
import { buildCompressed } from './compress.js';
|
|
6
6
|
import { durationFlagError, resolveFreshnessPolicy } from './freshness.js';
|
|
7
7
|
import { applyAutoTags } from './keywords.js';
|
|
8
|
+
import { metadataLengthError, metadataNewlineError } from './metadata-flags.js';
|
|
8
9
|
import { persistArtifact } from './persist-artifact.js';
|
|
9
10
|
import { sanitizePromptInjection } from './prompt-injection.js';
|
|
10
11
|
import { getArtifactPath } from './storage.js';
|
|
@@ -14,6 +15,7 @@ import { deriveCacheKey } from './cache-key.js';
|
|
|
14
15
|
import { normalizeUrl } from './url.js';
|
|
15
16
|
import { failInvalidUrl } from './cli-io.js';
|
|
16
17
|
import { formatHumanFields } from '../cli-presentation.js';
|
|
18
|
+
import { sanitizeForTerminal } from '../text.js';
|
|
17
19
|
const INPUT_LIMIT_BYTES = 1024 * 1024;
|
|
18
20
|
// ponytail: 1s stdin idle timeout is enough for piped agent input; raise if interactive paste
|
|
19
21
|
// workflows need longer before the read aborts.
|
|
@@ -29,6 +31,22 @@ export async function prepareImportCommandService(opts) {
|
|
|
29
31
|
const ttlErr = durationFlagError('--ttl', flags.ttl);
|
|
30
32
|
if (ttlErr)
|
|
31
33
|
io.error(ttlErr, { exit: 2, code: 'INVALID_DURATION' });
|
|
34
|
+
const newlineErr = metadataNewlineError(flags);
|
|
35
|
+
if (newlineErr) {
|
|
36
|
+
io.error(newlineErr, {
|
|
37
|
+
exit: 2,
|
|
38
|
+
code: 'INVALID_METADATA_VALUE',
|
|
39
|
+
suggestions: ['Remove line breaks from the value.'],
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const lengthErr = metadataLengthError(flags);
|
|
43
|
+
if (lengthErr) {
|
|
44
|
+
io.error(lengthErr, {
|
|
45
|
+
exit: 2,
|
|
46
|
+
code: 'INVALID_METADATA_VALUE',
|
|
47
|
+
suggestions: ['Shorten the value.'],
|
|
48
|
+
});
|
|
49
|
+
}
|
|
32
50
|
validateSourceMode(hasSingle, hasMulti, flags, io);
|
|
33
51
|
const rawInput = await readAndValidateInput(flags, input, io);
|
|
34
52
|
const sourceUrls = getSourceUrls(hasMulti, flags.sourceUrls, args.url, io);
|
|
@@ -128,9 +146,12 @@ function logImportHumanSuccess(io, opts) {
|
|
|
128
146
|
['Cache Key', cacheKey],
|
|
129
147
|
['Storage Path', storagePath],
|
|
130
148
|
];
|
|
149
|
+
// The agent-supplied --topic is untrusted the same way fetched content is (it may be lifted
|
|
150
|
+
// straight from a page an agent just read), so it gets the same terminal sanitization as
|
|
151
|
+
// list/inspect/prune apply to a cached topic before printing it.
|
|
131
152
|
let topic;
|
|
132
153
|
if (!hasSingle) {
|
|
133
|
-
topic = resolvedTopic(flags);
|
|
154
|
+
topic = sanitizeForTerminal(resolvedTopic(flags));
|
|
134
155
|
fields.push(['Topic', topic], ['Source URLs', sourceUrls.join(', ')]);
|
|
135
156
|
}
|
|
136
157
|
for (const line of formatHumanFields(fields))
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ResearchArtifactMetadata } from './schema.js';
|
|
2
|
+
/** Whether an artifact's topic matches a `--topic` filter (case-insensitive exact match). */
|
|
3
|
+
export declare function matchesTopicFilter(meta: Pick<ResearchArtifactMetadata, 'topic'>, topic: string | undefined): boolean;
|
|
4
|
+
/** Whether an artifact's tags satisfy a `--tags` filter (case-insensitive, must match all). */
|
|
5
|
+
export declare function matchesTagsFilter(meta: Pick<ResearchArtifactMetadata, 'tags'>, tags: string[] | undefined): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Reject an explicitly passed, whitespace-only `--topic` filter. Silently treating it as "no
|
|
8
|
+
* filter" (matching everything, as an empty/falsy value normally would in {@link matchesTopicFilter})
|
|
9
|
+
* would hide what's almost always a shell-quoting mistake — the same reasoning `--url` already
|
|
10
|
+
* applies via `emptyUrlFilterError`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function emptyTopicFilterError(topic: string | undefined): string | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* Reject an explicitly passed, whitespace-only `--tags` filter entry. Unlike a missing/empty
|
|
15
|
+
* `--topic` (which reads as "no filter"), an empty tag string in {@link matchesTagsFilter} never
|
|
16
|
+
* matches any artifact's tags — silently returning zero results with no signal. Fail fast instead.
|
|
17
|
+
*/
|
|
18
|
+
export declare function emptyTagsFilterError(tags: string[] | undefined): string | undefined;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Whether an artifact's topic matches a `--topic` filter (case-insensitive exact match). */
|
|
2
|
+
export function matchesTopicFilter(meta, topic) {
|
|
3
|
+
if (!topic)
|
|
4
|
+
return true;
|
|
5
|
+
return !!meta.topic && meta.topic.trim().toLowerCase() === topic.trim().toLowerCase();
|
|
6
|
+
}
|
|
7
|
+
/** Whether an artifact's tags satisfy a `--tags` filter (case-insensitive, must match all). */
|
|
8
|
+
export function matchesTagsFilter(meta, tags) {
|
|
9
|
+
if (!tags || tags.length === 0)
|
|
10
|
+
return true;
|
|
11
|
+
const metaTagsLower = meta.tags.map((t) => t.toLowerCase());
|
|
12
|
+
return tags.every((t) => metaTagsLower.includes(t.toLowerCase()));
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Reject an explicitly passed, whitespace-only `--topic` filter. Silently treating it as "no
|
|
16
|
+
* filter" (matching everything, as an empty/falsy value normally would in {@link matchesTopicFilter})
|
|
17
|
+
* would hide what's almost always a shell-quoting mistake — the same reasoning `--url` already
|
|
18
|
+
* applies via `emptyUrlFilterError`.
|
|
19
|
+
*/
|
|
20
|
+
export function emptyTopicFilterError(topic) {
|
|
21
|
+
if (topic === undefined)
|
|
22
|
+
return undefined;
|
|
23
|
+
if (topic.trim() === '') {
|
|
24
|
+
return '--topic must be a non-empty value (e.g. "React Suspense").';
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Reject an explicitly passed, whitespace-only `--tags` filter entry. Unlike a missing/empty
|
|
30
|
+
* `--topic` (which reads as "no filter"), an empty tag string in {@link matchesTagsFilter} never
|
|
31
|
+
* matches any artifact's tags — silently returning zero results with no signal. Fail fast instead.
|
|
32
|
+
*/
|
|
33
|
+
export function emptyTagsFilterError(tags) {
|
|
34
|
+
if (!tags?.some((t) => t.trim() === ''))
|
|
35
|
+
return undefined;
|
|
36
|
+
return '--tags must be non-empty values (e.g. "react").';
|
|
37
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Longest `--topic` value accepted; topics are short labels, not free text. */
|
|
2
|
+
export declare const MAX_TOPIC_LENGTH = 200;
|
|
3
|
+
/** Longest single `--tags` value accepted; tags are short taxonomic labels. */
|
|
4
|
+
export declare const MAX_TAG_LENGTH = 100;
|
|
5
|
+
/**
|
|
6
|
+
* Reject a `--topic`/`--tags` value longer than its cap before it ever reaches the cache.
|
|
7
|
+
* Unbounded input here would still round-trip through YAML frontmatter and `list`/`inspect`
|
|
8
|
+
* output correctly, but a single unreasonably long value wraps a human-readable listing line
|
|
9
|
+
* across dozens of terminal rows, so cap it at the flag boundary the same way `metadataNewlineError`
|
|
10
|
+
* catches the newline case.
|
|
11
|
+
*
|
|
12
|
+
* @param flags - The command's parsed `topic`/`tags` flags.
|
|
13
|
+
* @returns An error message naming the offending flag and its length cap, or `null` when both fit.
|
|
14
|
+
*/
|
|
15
|
+
export declare function metadataLengthError(flags: {
|
|
16
|
+
topic?: string;
|
|
17
|
+
tags?: string[];
|
|
18
|
+
}): string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Reject a `--topic`/`--tags` value containing a line break before it ever reaches the cache.
|
|
21
|
+
* Stored as-is, an embedded newline would corrupt the single-line-per-field frontmatter format
|
|
22
|
+
* (see `sanitizeFrontmatterLine` in `artifact.ts`): at best a silently dropped stray line, at
|
|
23
|
+
* worst a spoofed `key: value` pair or a bare `---` that closes the frontmatter fence early and
|
|
24
|
+
* splices the rest of the metadata into the body. Failing fast here — rather than letting the
|
|
25
|
+
* serializer silently rewrite the caller's input — gives the same actionable-error treatment as
|
|
26
|
+
* every other flag validator (`durationFlagError`, etc.).
|
|
27
|
+
*
|
|
28
|
+
* @param flags - The command's parsed `topic`/`tags` flags.
|
|
29
|
+
* @returns An error message naming the offending flag, or `null` when both are clean.
|
|
30
|
+
*/
|
|
31
|
+
export declare function metadataNewlineError(flags: {
|
|
32
|
+
topic?: string;
|
|
33
|
+
tags?: string[];
|
|
34
|
+
}): string | null;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Longest `--topic` value accepted; topics are short labels, not free text. */
|
|
2
|
+
export const MAX_TOPIC_LENGTH = 200;
|
|
3
|
+
/** Longest single `--tags` value accepted; tags are short taxonomic labels. */
|
|
4
|
+
export const MAX_TAG_LENGTH = 100;
|
|
5
|
+
/**
|
|
6
|
+
* Reject a `--topic`/`--tags` value longer than its cap before it ever reaches the cache.
|
|
7
|
+
* Unbounded input here would still round-trip through YAML frontmatter and `list`/`inspect`
|
|
8
|
+
* output correctly, but a single unreasonably long value wraps a human-readable listing line
|
|
9
|
+
* across dozens of terminal rows, so cap it at the flag boundary the same way `metadataNewlineError`
|
|
10
|
+
* catches the newline case.
|
|
11
|
+
*
|
|
12
|
+
* @param flags - The command's parsed `topic`/`tags` flags.
|
|
13
|
+
* @returns An error message naming the offending flag and its length cap, or `null` when both fit.
|
|
14
|
+
*/
|
|
15
|
+
export function metadataLengthError(flags) {
|
|
16
|
+
if (flags.topic !== undefined && flags.topic.length > MAX_TOPIC_LENGTH) {
|
|
17
|
+
return `--topic must be ${MAX_TOPIC_LENGTH} characters or fewer (got ${flags.topic.length}).`;
|
|
18
|
+
}
|
|
19
|
+
const longTag = flags.tags?.find((tag) => tag.length > MAX_TAG_LENGTH);
|
|
20
|
+
if (longTag !== undefined) {
|
|
21
|
+
return `--tags must be ${MAX_TAG_LENGTH} characters or fewer (got ${longTag.length}).`;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Reject a `--topic`/`--tags` value containing a line break before it ever reaches the cache.
|
|
27
|
+
* Stored as-is, an embedded newline would corrupt the single-line-per-field frontmatter format
|
|
28
|
+
* (see `sanitizeFrontmatterLine` in `artifact.ts`): at best a silently dropped stray line, at
|
|
29
|
+
* worst a spoofed `key: value` pair or a bare `---` that closes the frontmatter fence early and
|
|
30
|
+
* splices the rest of the metadata into the body. Failing fast here — rather than letting the
|
|
31
|
+
* serializer silently rewrite the caller's input — gives the same actionable-error treatment as
|
|
32
|
+
* every other flag validator (`durationFlagError`, etc.).
|
|
33
|
+
*
|
|
34
|
+
* @param flags - The command's parsed `topic`/`tags` flags.
|
|
35
|
+
* @returns An error message naming the offending flag, or `null` when both are clean.
|
|
36
|
+
*/
|
|
37
|
+
export function metadataNewlineError(flags) {
|
|
38
|
+
if (flags.topic !== undefined && /[\r\n]/.test(flags.topic)) {
|
|
39
|
+
return '--topic cannot contain line breaks.';
|
|
40
|
+
}
|
|
41
|
+
if (flags.tags?.some((tag) => /[\r\n]/.test(tag))) {
|
|
42
|
+
return '--tags cannot contain line breaks.';
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* HTML comments are checked first; the body is then split on paragraph breaks and scanned line-by-line.
|
|
5
5
|
*
|
|
6
6
|
* @param markdown - Markdown content (possibly untrusted, sourced from fetched pages).
|
|
7
|
-
* @returns Sanitized Markdown with harmful instruction blocks replaced by a redaction placeholder
|
|
7
|
+
* @returns Sanitized Markdown with harmful instruction blocks replaced by a redaction placeholder,
|
|
8
|
+
* and ANSI/control bytes stripped so a cached read can never replay them to a terminal.
|
|
8
9
|
*/
|
|
9
10
|
export declare function sanitizePromptInjection(markdown: string): string;
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { CONTROL_CHAR_PATTERN } from '../text.js';
|
|
2
3
|
const REDACTION = '[Removed potentially unsafe agent instruction]';
|
|
3
4
|
const ZERO_WIDTH = /[\u200b-\u200f\ufeff]/g;
|
|
5
|
+
// Tab/LF/CR fall inside CONTROL_CHAR_PATTERN's Unicode "Control" category too, but they are
|
|
6
|
+
// legitimate Markdown whitespace, so the replacer below keeps those three and drops everything else.
|
|
7
|
+
const SAFE_CONTROL_CHARS = new Set(['\t', '\n', '\r']);
|
|
8
|
+
/**
|
|
9
|
+
* Removes ANSI escape codes and other control bytes a malicious page could embed in its visible
|
|
10
|
+
* text, while leaving Markdown-structural whitespace (tab/newline/CR) intact. Every fetch/import
|
|
11
|
+
* path renders its stored content directly to the terminal (`bonsai <url>`, cache hits, etc.), so
|
|
12
|
+
* raw control bytes surviving Readability/Turndown would replay as a terminal-injection attack on
|
|
13
|
+
* every subsequent read of the cache; this is the choke point all of those paths share.
|
|
14
|
+
*/
|
|
15
|
+
function stripUnsafeControlChars(text) {
|
|
16
|
+
return text.replace(CONTROL_CHAR_PATTERN, (char) => (SAFE_CONTROL_CHARS.has(char) ? char : ''));
|
|
17
|
+
}
|
|
4
18
|
const HTML_COMMENT = /<!--[\s\S]*?-->/g;
|
|
5
19
|
const BASE64_TOKEN = /(?<![A-Za-z0-9+/_-])(?:[A-Za-z0-9+/_-]{16,}={0,2})(?![A-Za-z0-9+/_-])/g;
|
|
6
20
|
const HEX_TOKEN = /(?<![a-f0-9])(?:0x)?[a-f0-9]{24,}(?![a-f0-9])/gi;
|
|
@@ -159,10 +173,11 @@ function stripQuotes(span) {
|
|
|
159
173
|
* HTML comments are checked first; the body is then split on paragraph breaks and scanned line-by-line.
|
|
160
174
|
*
|
|
161
175
|
* @param markdown - Markdown content (possibly untrusted, sourced from fetched pages).
|
|
162
|
-
* @returns Sanitized Markdown with harmful instruction blocks replaced by a redaction placeholder
|
|
176
|
+
* @returns Sanitized Markdown with harmful instruction blocks replaced by a redaction placeholder,
|
|
177
|
+
* and ANSI/control bytes stripped so a cached read can never replay them to a terminal.
|
|
163
178
|
*/
|
|
164
179
|
export function sanitizePromptInjection(markdown) {
|
|
165
|
-
|
|
180
|
+
const redacted = markdown
|
|
166
181
|
.replace(HTML_COMMENT, (comment) => redactBlock(comment))
|
|
167
182
|
.split(/(\n{2,})/)
|
|
168
183
|
.map((block) => {
|
|
@@ -175,4 +190,5 @@ export function sanitizePromptInjection(markdown) {
|
|
|
175
190
|
return lineRedacted === block ? redactBlock(block) : lineRedacted;
|
|
176
191
|
})
|
|
177
192
|
.join('');
|
|
193
|
+
return stripUnsafeControlChars(redacted);
|
|
178
194
|
}
|
|
@@ -7,6 +7,8 @@ export interface ResolveResearchTargetOptions {
|
|
|
7
7
|
dataDir: string;
|
|
8
8
|
flagOverride?: StorageMode;
|
|
9
9
|
lookup?: boolean;
|
|
10
|
+
/** Read-only/plan mode: a corrupt entry hit during lookup is reported but never archived on disk. */
|
|
11
|
+
readOnly?: boolean;
|
|
10
12
|
url: string;
|
|
11
13
|
}
|
|
12
14
|
export interface ResolvedResearchTarget {
|
|
@@ -13,7 +13,7 @@ export function resolveResearchTarget(opts) {
|
|
|
13
13
|
});
|
|
14
14
|
return {
|
|
15
15
|
cacheKey,
|
|
16
|
-
located: opts.lookup === false ? null : locateArtifact(roots.readRoots, cacheKey),
|
|
16
|
+
located: opts.lookup === false ? null : locateArtifact(roots.readRoots, cacheKey, opts.readOnly),
|
|
17
17
|
normalizedUrl,
|
|
18
18
|
roots,
|
|
19
19
|
};
|
|
@@ -13,13 +13,10 @@ export declare function getArtifactPath(dataDir: string, key: string): string;
|
|
|
13
13
|
/**
|
|
14
14
|
* Scans the research storage directory, reading metadata of all valid markdown files
|
|
15
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
|
|
16
|
+
* If a corrupt artifact is found, it is skipped and archived as corrupt — unless `readOnly` is set,
|
|
17
|
+
* in which case it is only reported, never renamed on disk.
|
|
17
18
|
*/
|
|
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;
|
|
19
|
+
export declare function findArtifact(dataDir: string, key: string, readOnly?: boolean): ResearchArtifact | null;
|
|
23
20
|
/**
|
|
24
21
|
* Writes a ResearchArtifact to disk, creating the research storage folder if needed.
|
|
25
22
|
* Writes to a temporary file first, then atomically renames it.
|
|
@@ -42,7 +39,7 @@ export interface LocatedArtifact {
|
|
|
42
39
|
* returning the first match along with where it lives. Implements the project→global
|
|
43
40
|
* read fallback: a missing project entry still resolves against the global cache.
|
|
44
41
|
*/
|
|
45
|
-
export declare function locateArtifact(dataDirs: string[], key: string): LocatedArtifact | null;
|
|
42
|
+
export declare function locateArtifact(dataDirs: string[], key: string, readOnly?: boolean): LocatedArtifact | null;
|
|
46
43
|
/**
|
|
47
44
|
* Multi-root variant of {@link scanCacheDir}. Scans the `research/` dir of each data dir in
|
|
48
45
|
* order and deduplicates by cache key so an entry present in an earlier root (project) shadows
|
|
@@ -18,7 +18,14 @@ export function scanCacheDir(dir, fn) {
|
|
|
18
18
|
continue;
|
|
19
19
|
try {
|
|
20
20
|
const content = readFileSync(join(dir, file), 'utf-8');
|
|
21
|
-
const
|
|
21
|
+
const artifact = parseArtifact(content);
|
|
22
|
+
// parseArtifact defaults unrecognized frontmatter to a valid-looking shell (cache_key: '',
|
|
23
|
+
// status: 'active'), so a foreign or otherwise-garbled *.md file with `---`/`---` fences never
|
|
24
|
+
// throws here — it would otherwise surface as a phantom entry with no usable cache key. Every
|
|
25
|
+
// artifact Bonsai itself writes always has one, so an empty cache_key means "not ours."
|
|
26
|
+
if (!artifact.metadata.cache_key)
|
|
27
|
+
continue;
|
|
28
|
+
const result = fn(artifact, join(dir, file));
|
|
22
29
|
if (result !== null)
|
|
23
30
|
results.push(result);
|
|
24
31
|
}
|
|
@@ -36,7 +43,7 @@ export function getArtifactPath(dataDir, key) {
|
|
|
36
43
|
}
|
|
37
44
|
return join(dataDir, 'research', `${key}.md`);
|
|
38
45
|
}
|
|
39
|
-
function processFileForLookup(file, dir, key) {
|
|
46
|
+
function processFileForLookup(file, dir, key, readOnly) {
|
|
40
47
|
// Same file filter as scanCacheDir — superseded/corrupt/tmp archives must not resolve as active.
|
|
41
48
|
if (!isResearchFile(file)) {
|
|
42
49
|
return null;
|
|
@@ -51,13 +58,17 @@ function processFileForLookup(file, dir, key) {
|
|
|
51
58
|
}
|
|
52
59
|
catch (err) {
|
|
53
60
|
console.warn(`Warning: Corrupt research artifact found at "${filePath}": ${err.message}`);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
// Archiving renames the file on disk — a write a read-only/plan-mode lookup (e.g. `status`,
|
|
62
|
+
// `inspect --read-only`) must never perform, even incidentally while diagnosing a corrupt entry.
|
|
63
|
+
if (!readOnly) {
|
|
64
|
+
try {
|
|
65
|
+
const corruptPath = `${filePath}.corrupt.${Date.now()}`;
|
|
66
|
+
renameSync(filePath, corruptPath);
|
|
67
|
+
console.warn(`Archived corrupt artifact to "${corruptPath}"`);
|
|
68
|
+
}
|
|
69
|
+
catch (archiveErr) {
|
|
70
|
+
console.error(`Failed to archive corrupt artifact: ${archiveErr.message}`);
|
|
71
|
+
}
|
|
61
72
|
}
|
|
62
73
|
}
|
|
63
74
|
return null;
|
|
@@ -65,9 +76,10 @@ function processFileForLookup(file, dir, key) {
|
|
|
65
76
|
/**
|
|
66
77
|
* Scans the research storage directory, reading metadata of all valid markdown files
|
|
67
78
|
* to find the active artifact for the given cache key with the newest validated_at timestamp.
|
|
68
|
-
* If a corrupt artifact is found, it is skipped and archived as corrupt
|
|
79
|
+
* If a corrupt artifact is found, it is skipped and archived as corrupt — unless `readOnly` is set,
|
|
80
|
+
* in which case it is only reported, never renamed on disk.
|
|
69
81
|
*/
|
|
70
|
-
export function findArtifact(dataDir, key) {
|
|
82
|
+
export function findArtifact(dataDir, key, readOnly = false) {
|
|
71
83
|
const dir = join(dataDir, 'research');
|
|
72
84
|
if (!existsSync(dir)) {
|
|
73
85
|
return null;
|
|
@@ -76,7 +88,7 @@ export function findArtifact(dataDir, key) {
|
|
|
76
88
|
let bestValidatedAt = -1;
|
|
77
89
|
const files = readdirSync(dir);
|
|
78
90
|
for (const file of files) {
|
|
79
|
-
const artifact = processFileForLookup(file, dir, key);
|
|
91
|
+
const artifact = processFileForLookup(file, dir, key, readOnly);
|
|
80
92
|
if (!artifact) {
|
|
81
93
|
continue;
|
|
82
94
|
}
|
|
@@ -90,17 +102,6 @@ export function findArtifact(dataDir, key) {
|
|
|
90
102
|
}
|
|
91
103
|
return bestArtifact;
|
|
92
104
|
}
|
|
93
|
-
/**
|
|
94
|
-
* Checks if a cached artifact exists on disk for a given cache key.
|
|
95
|
-
*/
|
|
96
|
-
export function hasArtifact(dataDir, key) {
|
|
97
|
-
try {
|
|
98
|
-
return findArtifact(dataDir, key) !== null;
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
105
|
/**
|
|
105
106
|
* Writes a ResearchArtifact to disk, creating the research storage folder if needed.
|
|
106
107
|
* Writes to a temporary file first, then atomically renames it.
|
|
@@ -149,9 +150,9 @@ export function readArtifact(dataDir, key) {
|
|
|
149
150
|
* returning the first match along with where it lives. Implements the project→global
|
|
150
151
|
* read fallback: a missing project entry still resolves against the global cache.
|
|
151
152
|
*/
|
|
152
|
-
export function locateArtifact(dataDirs, key) {
|
|
153
|
+
export function locateArtifact(dataDirs, key, readOnly = false) {
|
|
153
154
|
for (const dataDir of dataDirs) {
|
|
154
|
-
const artifact = findArtifact(dataDir, key);
|
|
155
|
+
const artifact = findArtifact(dataDir, key, readOnly);
|
|
155
156
|
if (artifact) {
|
|
156
157
|
return { artifact, dataDir, path: getArtifactPath(dataDir, key) };
|
|
157
158
|
}
|
package/dist/lib/text.d.ts
CHANGED
|
@@ -1,27 +1,33 @@
|
|
|
1
1
|
/** Display label when an artifact has no topic — not a filterable topic value. */
|
|
2
2
|
export declare const NO_TOPIC_LABEL = "(no topic)";
|
|
3
|
+
export declare const CONTROL_CHAR_PATTERN: RegExp;
|
|
4
|
+
/**
|
|
5
|
+
* Strip ANSI escape codes and other control characters from a cached metadata string before it
|
|
6
|
+
* reaches a human-readable terminal line. Fields like `topic` and section headings can originate
|
|
7
|
+
* from fetched web content or agent-supplied notes — both untrusted per the repo's trust-boundary
|
|
8
|
+
* rules — so a value containing raw escape bytes must never be echoed as-is to a TTY. Newlines and
|
|
9
|
+
* tabs collapse to a single space rather than being stripped outright, so injected content can't
|
|
10
|
+
* fake extra output lines while still being removed. JSON output is unaffected: `JSON.stringify`
|
|
11
|
+
* already escapes control characters, so this only needs to run on the human-readable path.
|
|
12
|
+
*/
|
|
13
|
+
export declare function sanitizeForTerminal(value: string): string;
|
|
3
14
|
/**
|
|
4
15
|
* Pick the singular or plural noun for a count so human-readable output stays grammatical
|
|
5
16
|
* ("1 entry" / "2 entries"). English has irregular plurals, so the plural is passed explicitly
|
|
6
17
|
* rather than derived.
|
|
7
18
|
*/
|
|
8
19
|
export declare function pluralize(count: number, singular: string, plural: string): string;
|
|
9
|
-
/**
|
|
20
|
+
/** Shared "Tip: " prefix for contextual next-step suggestions in human-mode output. */
|
|
21
|
+
export declare function formatTip(message: string): string;
|
|
22
|
+
/** How a command labels a result listing, so its heading stays consistent. */
|
|
10
23
|
export interface ResultListLabels {
|
|
11
24
|
/** Noun phrase after the count, e.g. "cached research"; the "entry/entries" plural is appended. */
|
|
12
25
|
noun: string;
|
|
13
26
|
/** Ordering word shown when truncated: "first" (recency order) or "top" (ranked order). */
|
|
14
27
|
order: 'first' | 'top';
|
|
15
|
-
/** The command's --limit ceiling, surfaced in the truncation hint. */
|
|
16
|
-
maxLimit: number;
|
|
17
28
|
}
|
|
18
29
|
/** Heading for a result listing; notes truncation inline when more matched than are shown. */
|
|
19
30
|
export declare function resultListHeading(total: number, shown: number, labels: ResultListLabels): string;
|
|
20
|
-
/**
|
|
21
|
-
* One-line truncation notice for stderr, or null when nothing was cut. Available for human-mode
|
|
22
|
-
* tips; `list --json` surfaces truncation via the envelope `truncation` field instead (#91).
|
|
23
|
-
*/
|
|
24
|
-
export declare function truncationNotice(total: number, shown: number, labels: ResultListLabels): string | null;
|
|
25
31
|
/** Max edit distance for a plausible typo; scales with input length. */
|
|
26
32
|
export declare function maxFuzzyDistance(input: string): number;
|
|
27
33
|
/**
|
package/dist/lib/text.js
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
/** Display label when an artifact has no topic — not a filterable topic value. */
|
|
2
2
|
export const NO_TOPIC_LABEL = '(no topic)';
|
|
3
|
+
// Unicode "Control" category (C0 + C1) covers ANSI/terminal escape sequences: the ESC control
|
|
4
|
+
// character that starts them is a member, so stripping this category is what keeps injected
|
|
5
|
+
// control bytes from reaching a real terminal. Exported so `prompt-injection.ts`'s
|
|
6
|
+
// multi-line-content variant (which preserves tab/newline/CR) shares this one definition.
|
|
7
|
+
export const CONTROL_CHAR_PATTERN = /\p{Cc}/gu;
|
|
8
|
+
/**
|
|
9
|
+
* Strip ANSI escape codes and other control characters from a cached metadata string before it
|
|
10
|
+
* reaches a human-readable terminal line. Fields like `topic` and section headings can originate
|
|
11
|
+
* from fetched web content or agent-supplied notes — both untrusted per the repo's trust-boundary
|
|
12
|
+
* rules — so a value containing raw escape bytes must never be echoed as-is to a TTY. Newlines and
|
|
13
|
+
* tabs collapse to a single space rather than being stripped outright, so injected content can't
|
|
14
|
+
* fake extra output lines while still being removed. JSON output is unaffected: `JSON.stringify`
|
|
15
|
+
* already escapes control characters, so this only needs to run on the human-readable path.
|
|
16
|
+
*/
|
|
17
|
+
export function sanitizeForTerminal(value) {
|
|
18
|
+
return value.replace(/[\t\n\r]/g, ' ').replace(CONTROL_CHAR_PATTERN, '');
|
|
19
|
+
}
|
|
3
20
|
/**
|
|
4
21
|
* Pick the singular or plural noun for a count so human-readable output stays grammatical
|
|
5
22
|
* ("1 entry" / "2 entries"). English has irregular plurals, so the plural is passed explicitly
|
|
@@ -8,6 +25,10 @@ export const NO_TOPIC_LABEL = '(no topic)';
|
|
|
8
25
|
export function pluralize(count, singular, plural) {
|
|
9
26
|
return count === 1 ? singular : plural;
|
|
10
27
|
}
|
|
28
|
+
/** Shared "Tip: " prefix for contextual next-step suggestions in human-mode output. */
|
|
29
|
+
export function formatTip(message) {
|
|
30
|
+
return `Tip: ${message}`;
|
|
31
|
+
}
|
|
11
32
|
/** Heading for a result listing; notes truncation inline when more matched than are shown. */
|
|
12
33
|
export function resultListHeading(total, shown, labels) {
|
|
13
34
|
const noun = `${labels.noun} ${pluralize(total, 'entry', 'entries')}`;
|
|
@@ -15,15 +36,6 @@ export function resultListHeading(total, shown, labels) {
|
|
|
15
36
|
? `Found ${total} ${noun} (showing ${labels.order} ${shown}; raise --limit to see more):`
|
|
16
37
|
: `Found ${total} ${noun}:`;
|
|
17
38
|
}
|
|
18
|
-
/**
|
|
19
|
-
* One-line truncation notice for stderr, or null when nothing was cut. Available for human-mode
|
|
20
|
-
* tips; `list --json` surfaces truncation via the envelope `truncation` field instead (#91).
|
|
21
|
-
*/
|
|
22
|
-
export function truncationNotice(total, shown, labels) {
|
|
23
|
-
if (total <= shown)
|
|
24
|
-
return null;
|
|
25
|
-
return `${total} entries matched; returning the ${labels.order} ${shown}. Raise --limit (max ${labels.maxLimit}) to see more.`;
|
|
26
|
-
}
|
|
27
39
|
/** Max edit distance for a plausible typo; scales with input length. */
|
|
28
40
|
export function maxFuzzyDistance(input) {
|
|
29
41
|
if (input.length <= 3)
|