@north-light/crouter 0.3.181 → 0.3.183
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/dist/build-root.js +10 -3
- package/dist/builtin-memory/internal/memory-loading.md +3 -1
- package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
- package/dist/clients/attach/__tests__/titled-editor-paste.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/titled-editor-paste.test.js +114 -0
- package/dist/clients/attach/__tests__/titled-editor-preview.test.js +4 -1
- package/dist/clients/attach/input/titled-editor.d.ts +18 -0
- package/dist/clients/attach/input/titled-editor.js +62 -0
- package/dist/clients/attach/render/chat-view.d.ts +3 -0
- package/dist/clients/attach/render/chat-view.js +15 -6
- package/dist/clients/attach/render/context-message.js +2 -2
- package/dist/clients/attach/render/group-activity.d.ts +18 -1
- package/dist/clients/attach/render/group-activity.js +54 -6
- package/dist/clients/attach/render/group-recap.js +13 -2
- package/dist/clients/attach/render/{markdown-headings.d.ts → markdown-source.d.ts} +4 -4
- package/dist/clients/attach/render/markdown-source.js +165 -0
- package/dist/clients/attach/session/bindings.js +5 -2
- package/dist/clients/attach/viewer.js +396 -394
- package/dist/clients/inbox/review/keys.js +2 -9
- package/dist/clients/inbox/tui/keys.js +2 -9
- package/dist/commands/cron.js +1 -1
- package/dist/commands/memory/delete.js +1 -1
- package/dist/commands/memory/find.js +1 -1
- package/dist/commands/memory/lint.js +28 -3
- package/dist/commands/memory/list.js +1 -1
- package/dist/commands/memory/origin.js +1 -1
- package/dist/commands/memory/read.js +4 -4
- package/dist/commands/memory/write.js +1 -1
- package/dist/commands/sys/settings-shell.d.ts +0 -3
- package/dist/commands/sys/settings-shell.js +1 -38
- package/dist/commands/sys/settings.js +2 -3
- package/dist/commands/sys/setup-core.d.ts +2 -17
- package/dist/commands/sys/setup-core.js +9 -33
- package/dist/commands/sys/setup-wizard.d.ts +1 -3
- package/dist/commands/sys/setup-wizard.js +9 -39
- package/dist/commands/sys/setup.js +6 -6
- package/dist/commands/sys/sync-project-guidance.js +62 -12
- package/dist/core/__tests__/fault-classifier.test.js +20 -0
- package/dist/core/__tests__/nested-store-discovery.test.d.ts +1 -0
- package/dist/core/__tests__/nested-store-discovery.test.js +89 -0
- package/dist/core/__tests__/on-read-nested-store.test.d.ts +1 -0
- package/dist/core/__tests__/on-read-nested-store.test.js +117 -0
- package/dist/core/__tests__/plugin-link-symlinked-scope-root.test.d.ts +1 -0
- package/dist/core/__tests__/plugin-link-symlinked-scope-root.test.js +38 -0
- package/dist/core/__tests__/serial/tmux-surface.test.js +8 -5
- package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +34 -1
- package/dist/core/command.js +5 -3
- package/dist/core/fault-classifier.d.ts +1 -1
- package/dist/core/fault-classifier.js +7 -0
- package/dist/core/fs-utils.js +6 -1
- package/dist/core/keybindings/index.d.ts +1 -1
- package/dist/core/keybindings/index.js +1 -1
- package/dist/core/keybindings/match.d.ts +11 -0
- package/dist/core/keybindings/match.js +22 -0
- package/dist/core/memory/doc-link-grammar.js +3 -7
- package/dist/core/memory/inline-ref-guidance.js +1 -1
- package/dist/core/memory-resolver.d.ts +7 -1
- package/dist/core/memory-resolver.js +20 -7
- package/dist/core/nested-stores.d.ts +12 -0
- package/dist/core/nested-stores.js +146 -0
- package/dist/core/preview-registry.js +4 -2
- package/dist/core/runtime/bearings.d.ts +1 -1
- package/dist/core/runtime/bearings.js +2 -2
- package/dist/core/runtime/broker/fault-retry.js +23 -4
- package/dist/core/runtime/broker/inbox.d.ts +4 -0
- package/dist/core/runtime/broker/inbox.js +17 -8
- package/dist/core/runtime/broker-extension-render.d.ts +1 -1
- package/dist/core/runtime/broker-extension-render.js +2 -2
- package/dist/core/runtime/broker-protocol.d.ts +1 -1
- package/dist/core/runtime/deliver-live.js +11 -1
- package/dist/core/runtime/fault.js +1 -1
- package/dist/core/runtime/tmux-bindings.js +42 -20
- package/dist/core/runtime/tool-group-summary.js +2 -6
- package/dist/core/substrate/injected-store.d.ts +6 -0
- package/dist/core/substrate/injected-store.js +19 -0
- package/dist/core/substrate/on-read-node.d.ts +4 -2
- package/dist/core/substrate/on-read-node.js +5 -3
- package/dist/core/substrate/on-read.d.ts +4 -2
- package/dist/core/substrate/on-read.js +27 -5
- package/dist/pi-extensions/canvas-context-intro.d.ts +1 -1
- package/dist/pi-extensions/canvas-context-intro.js +14 -3
- package/dist/pi-extensions/canvas-doc-substrate.js +13 -2
- package/dist/pi-extensions/canvas-inbox-watcher.js +34 -1
- package/dist/shared/generated-context.d.ts +4 -0
- package/dist/shared/generated-context.js +20 -1
- package/dist/shared/tool-groups.d.ts +3 -3
- package/dist/shared/tool-groups.js +1 -4
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/scripts/postinstall.mjs +1 -1
- package/dist/clients/attach/render/markdown-headings.js +0 -93
- package/dist/commands/search/answer.d.ts +0 -1
- package/dist/commands/search/answer.js +0 -50
- package/dist/commands/search/contents.d.ts +0 -1
- package/dist/commands/search/contents.js +0 -96
- package/dist/commands/search/exa.d.ts +0 -53
- package/dist/commands/search/exa.js +0 -157
- package/dist/commands/search/puremd.d.ts +0 -11
- package/dist/commands/search/puremd.js +0 -53
- package/dist/commands/search/web.d.ts +0 -1
- package/dist/commands/search/web.js +0 -65
- package/dist/commands/search.d.ts +0 -2
- package/dist/commands/search.js +0 -24
- package/dist/commands/sys/panels/keys-panel.d.ts +0 -16
- package/dist/commands/sys/panels/keys-panel.js +0 -60
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
// Terminal-specific Markdown heading hierarchy for the attach viewer.
|
|
2
|
-
//
|
|
3
|
-
// pi-tui's MarkdownTheme exposes one `heading(text)` callback without the
|
|
4
|
-
// heading depth. Its renderer does know the depth, but does not pass it to the
|
|
5
|
-
// theme, so every heading otherwise receives the same yellow mdHeading paint.
|
|
6
|
-
// Decorate only the source text of H3–H6 before it reaches pi-tui: pi still
|
|
7
|
-
// owns parsing, wrapping, inline formatting, and H1/H2's established styling.
|
|
8
|
-
// This stays attach-local — persisted messages and copied text remain ordinary
|
|
9
|
-
// Markdown without terminal escape sequences.
|
|
10
|
-
//
|
|
11
|
-
// The same per-block copy carries the viewer's other source-text transform:
|
|
12
|
-
// private-use `U+XXXX` references become their glyphs (see glyph-codepoints.ts),
|
|
13
|
-
// so an agent that cannot type a Nerd Font character can still put one on
|
|
14
|
-
// screen.
|
|
15
|
-
import { expandGlyphCodepoints } from './glyph-codepoints.js';
|
|
16
|
-
const ATX_HEADING = /^(\s{0,3})(#{3,6})(?:[ \t]+|$)(.*?)([ \t]+#+[ \t]*)?$/;
|
|
17
|
-
const FENCE_OPEN = /^\s{0,3}(`{3,}|~{3,})/;
|
|
18
|
-
// A constant-lightness ramp from warm yellow to cool blue, with saturation
|
|
19
|
-
// deliberately reduced at each step so the hierarchy recedes rather than
|
|
20
|
-
// becoming a tour of unrelated accent colors.
|
|
21
|
-
const HEADING_COLORS = [
|
|
22
|
-
[232, 194, 104],
|
|
23
|
-
[207, 194, 145],
|
|
24
|
-
[181, 184, 174],
|
|
25
|
-
[157, 170, 180],
|
|
26
|
-
[135, 153, 164],
|
|
27
|
-
[116, 135, 147],
|
|
28
|
-
];
|
|
29
|
-
function headingColor(level) {
|
|
30
|
-
const [r, g, b] = HEADING_COLORS[level - 1];
|
|
31
|
-
return (text) => `\x1b[1;38;2;${r};${g};${b}m${text}\x1b[22;39m`;
|
|
32
|
-
}
|
|
33
|
-
/** Style ATX headings with a scan-friendly terminal hierarchy. The Markdown
|
|
34
|
-
* markers are syntax, not content, so omit them from the rendered line.
|
|
35
|
-
* Fenced examples remain literal Markdown, never decorated terminal output. */
|
|
36
|
-
export function styleAttachMarkdownHeadings(markdown) {
|
|
37
|
-
let fence;
|
|
38
|
-
return markdown.split('\n').map((line) => {
|
|
39
|
-
const fenceMatch = FENCE_OPEN.exec(line);
|
|
40
|
-
if (fence !== undefined) {
|
|
41
|
-
if (fenceMatch?.[1][0] === fence)
|
|
42
|
-
fence = undefined;
|
|
43
|
-
return line;
|
|
44
|
-
}
|
|
45
|
-
if (fenceMatch) {
|
|
46
|
-
fence = fenceMatch[1][0];
|
|
47
|
-
return line;
|
|
48
|
-
}
|
|
49
|
-
const match = ATX_HEADING.exec(line);
|
|
50
|
-
if (!match)
|
|
51
|
-
return line;
|
|
52
|
-
const [, indent, hashes, body, closing = ''] = match;
|
|
53
|
-
if (body === '')
|
|
54
|
-
return line;
|
|
55
|
-
return `${indent}${headingColor(hashes.length)(body)}`;
|
|
56
|
-
}).join('\n');
|
|
57
|
-
}
|
|
58
|
-
/** Every source-text transform the viewer applies to a Markdown-bearing block,
|
|
59
|
-
* in one place: heading hierarchy, then glyph expansion. */
|
|
60
|
-
function renderableText(markdown) {
|
|
61
|
-
return expandGlyphCodepoints(styleAttachMarkdownHeadings(markdown));
|
|
62
|
-
}
|
|
63
|
-
/** Copy a pi message for terminal rendering and decorate only Markdown-bearing
|
|
64
|
-
* text/thinking blocks. The broker's source event is never mutated. */
|
|
65
|
-
export function styleAttachMessageMarkdown(message) {
|
|
66
|
-
if (typeof message !== 'object' || message === null || !('content' in message))
|
|
67
|
-
return message;
|
|
68
|
-
const candidate = message;
|
|
69
|
-
if (typeof candidate.content === 'string') {
|
|
70
|
-
return { ...candidate, content: renderableText(candidate.content) };
|
|
71
|
-
}
|
|
72
|
-
if (!Array.isArray(candidate.content))
|
|
73
|
-
return message;
|
|
74
|
-
return {
|
|
75
|
-
...candidate,
|
|
76
|
-
content: candidate.content.map((part) => {
|
|
77
|
-
if (typeof part !== 'object' || part === null)
|
|
78
|
-
return part;
|
|
79
|
-
const block = part;
|
|
80
|
-
if (block.type === 'text' && typeof block.text === 'string') {
|
|
81
|
-
return { ...block, text: renderableText(block.text) };
|
|
82
|
-
}
|
|
83
|
-
if (block.type === 'thinking' && typeof block.thinking === 'string') {
|
|
84
|
-
return { ...block, thinking: renderableText(block.thinking) };
|
|
85
|
-
}
|
|
86
|
-
return part;
|
|
87
|
-
}),
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
/** Copy an expanded pi summary without changing its persisted source. */
|
|
91
|
-
export function styleAttachSummaryMarkdown(message) {
|
|
92
|
-
return { ...message, summary: renderableText(message.summary) };
|
|
93
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare const answerLeaf: import("../../core/command.js").LeafDef;
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { defineLeaf } from '../../core/command.js';
|
|
2
|
-
import { exaAnswer } from './exa.js';
|
|
3
|
-
export const answerLeaf = defineLeaf({
|
|
4
|
-
name: 'answer',
|
|
5
|
-
description: 'get one grounded, cited answer to a question',
|
|
6
|
-
whenToUse: 'you have a specific question and want a single synthesized answer grounded in sources, rather than a ranked list of pages to read yourself. Best for factual lookups and "what/who/when" questions where you want the conclusion plus its citations. Reach for `web` instead when you want to browse and judge the raw results, or when the task is open-ended research rather than one answerable question.',
|
|
7
|
-
help: {
|
|
8
|
-
name: 'search answer',
|
|
9
|
-
summary: 'grounded answer via Exa — one synthesized natural-language answer plus the sources it cites',
|
|
10
|
-
params: [
|
|
11
|
-
{ kind: 'positional', name: 'question', required: true, constraint: 'The question to answer. Natural language; phrase it as a question.' },
|
|
12
|
-
],
|
|
13
|
-
output: [
|
|
14
|
-
{ name: 'answer', type: 'string', required: true, constraint: 'The synthesized natural-language answer grounded in the cited sources.' },
|
|
15
|
-
{ name: 'citations', type: 'object[]', required: true, constraint: 'The sources the answer draws on, each: title and url.' },
|
|
16
|
-
{ name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next command — read a citation in full, or fall back to raw results.' },
|
|
17
|
-
],
|
|
18
|
-
outputKind: 'object',
|
|
19
|
-
effects: ['Sends one answer request to the Exa API (network). No local state changes.'],
|
|
20
|
-
},
|
|
21
|
-
run: async (input) => {
|
|
22
|
-
const question = input['question'];
|
|
23
|
-
const res = await exaAnswer({ query: question });
|
|
24
|
-
return {
|
|
25
|
-
answer: res.answer ?? '',
|
|
26
|
-
citations: res.citations ?? [],
|
|
27
|
-
follow_up: 'Read any cited source in full with the contents leaf after checking its schema. Want raw ranked results instead of a synthesized answer? Use the web leaf.',
|
|
28
|
-
};
|
|
29
|
-
},
|
|
30
|
-
render: (result) => {
|
|
31
|
-
const answer = result['answer'];
|
|
32
|
-
const citations = result['citations'];
|
|
33
|
-
const followUp = result['follow_up'];
|
|
34
|
-
if (answer.trim() === '') {
|
|
35
|
-
return `No answer was returned.\n\n${followUp}`;
|
|
36
|
-
}
|
|
37
|
-
const lines = [answer.trim(), ''];
|
|
38
|
-
if (citations.length > 0) {
|
|
39
|
-
lines.push(`Sources (${citations.length}):`);
|
|
40
|
-
citations.forEach((c, i) => {
|
|
41
|
-
const title = c.title !== undefined && c.title !== '' ? c.title : '(untitled)';
|
|
42
|
-
const url = c.url !== undefined ? ` — ${c.url}` : '';
|
|
43
|
-
lines.push(`${i + 1}. ${title}${url}`);
|
|
44
|
-
});
|
|
45
|
-
lines.push('');
|
|
46
|
-
}
|
|
47
|
-
lines.push(followUp);
|
|
48
|
-
return lines.join('\n');
|
|
49
|
-
},
|
|
50
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare const contentsLeaf: import("../../core/command.js").LeafDef;
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { defineLeaf } from '../../core/command.js';
|
|
2
|
-
import { usage } from '../../core/errors.js';
|
|
3
|
-
import { exaContents, parseUrls, renderResult, TEXT_MAX_CHARACTERS, } from './exa.js';
|
|
4
|
-
import { puremdFetch } from './puremd.js';
|
|
5
|
-
export const contentsLeaf = defineLeaf({
|
|
6
|
-
name: 'contents',
|
|
7
|
-
description: 'extract clean content from URLs you already have',
|
|
8
|
-
whenToUse: 'you already hold one or more URLs — from a prior `search web`/`answer`, a database, an RSS feed, or user input — and need their cleaned content or highlights. This does NOT search; it only extracts from the URLs you give it. Reach for `web` when you still need to find the pages, and `answer` when you want a synthesized response rather than raw page content.',
|
|
9
|
-
help: {
|
|
10
|
-
name: 'search contents',
|
|
11
|
-
summary: 'content extraction via Exa — cleaned highlights or full text for URLs you already have',
|
|
12
|
-
params: [
|
|
13
|
-
{ kind: 'positional', name: 'urls', repeatable: true, required: true, constraint: 'One or more URL tokens to extract. Each token may contain URLs separated by commas or whitespace.' },
|
|
14
|
-
{ kind: 'flag', name: 'text', type: 'bool', required: false, constraint: `Return cleaned full page text (capped at ${TEXT_MAX_CHARACTERS} characters per URL) instead of highlight excerpts. Off by default.` },
|
|
15
|
-
{ kind: 'flag', name: 'max-age-hours', type: 'int', required: false, constraint: 'Maximum acceptable age of cached content, in hours; content older than this is freshly crawled. 0 forces a fresh crawl every time. Omit to use cache when available and crawl as fallback.' },
|
|
16
|
-
],
|
|
17
|
-
output: [
|
|
18
|
-
{ name: 'results', type: 'object[]', required: true, constraint: 'One per successfully extracted URL, each: title, url, and either highlight excerpts (default) or capped full text (--text).' },
|
|
19
|
-
{ name: 'failures', type: 'object[]', required: true, constraint: 'URLs that could not be fetched by Exa or the pure.md fallback, each: url and reason.' },
|
|
20
|
-
{ name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next command for retrying failures or refreshing stale content.' },
|
|
21
|
-
],
|
|
22
|
-
outputKind: 'object',
|
|
23
|
-
effects: ['Sends one contents request to the Exa API (network). URLs Exa fails to fetch are retried through the pure.md markdown service. No local state changes.'],
|
|
24
|
-
},
|
|
25
|
-
run: async (input) => {
|
|
26
|
-
const urls = input['urls'].flatMap(parseUrls);
|
|
27
|
-
if (urls.length === 0)
|
|
28
|
-
throw usage('no URLs provided', { next: 'Pass one or more URLs separated by commas or whitespace.' });
|
|
29
|
-
const wantText = input['text'];
|
|
30
|
-
const maxAgeHours = input['maxAgeHours'];
|
|
31
|
-
const body = { urls };
|
|
32
|
-
if (wantText)
|
|
33
|
-
body['text'] = { maxCharacters: TEXT_MAX_CHARACTERS };
|
|
34
|
-
else
|
|
35
|
-
body['highlights'] = true;
|
|
36
|
-
if (maxAgeHours !== undefined)
|
|
37
|
-
body['maxAgeHours'] = maxAgeHours;
|
|
38
|
-
const res = await exaContents(body);
|
|
39
|
-
const results = res.results ?? [];
|
|
40
|
-
const fetched = new Set(results.map((r) => r.url).filter((u) => u !== undefined));
|
|
41
|
-
const exaFailures = [];
|
|
42
|
-
for (const s of res.statuses ?? []) {
|
|
43
|
-
if (s.status === 'success')
|
|
44
|
-
continue;
|
|
45
|
-
const url = s.id ?? '(unknown url)';
|
|
46
|
-
if (fetched.has(url))
|
|
47
|
-
continue;
|
|
48
|
-
const reason = typeof s.error === 'string'
|
|
49
|
-
? s.error
|
|
50
|
-
: s.error?.tag ?? s.status ?? 'unknown error';
|
|
51
|
-
exaFailures.push({ url, reason });
|
|
52
|
-
}
|
|
53
|
-
// Fall back to pure.md for every URL Exa could not fetch. A pure.md success
|
|
54
|
-
// becomes a normal text result (flagged so the renderer can show its source);
|
|
55
|
-
// only URLs both services miss remain genuine failures.
|
|
56
|
-
const failures = [];
|
|
57
|
-
const fallbacks = await Promise.all(exaFailures.map(async (f) => ({ failure: f, pure: await puremdFetch(f.url, TEXT_MAX_CHARACTERS) })));
|
|
58
|
-
for (const { failure, pure } of fallbacks) {
|
|
59
|
-
if (pure.ok) {
|
|
60
|
-
results.push({ url: failure.url, title: failure.url, text: pure.text, source: 'pure.md' });
|
|
61
|
-
}
|
|
62
|
-
else {
|
|
63
|
-
failures.push({ url: failure.url, reason: `${failure.reason} (pure.md fallback: ${pure.reason})` });
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return {
|
|
67
|
-
results,
|
|
68
|
-
failures,
|
|
69
|
-
follow_up: 'Stale or empty content? Re-run with --max-age-hours 0 to force a fresh crawl. Need to find more pages? Use the web leaf after checking its schema.',
|
|
70
|
-
};
|
|
71
|
-
},
|
|
72
|
-
render: (result) => {
|
|
73
|
-
const results = result['results'];
|
|
74
|
-
const failures = result['failures'];
|
|
75
|
-
const followUp = result['follow_up'];
|
|
76
|
-
const lines = [];
|
|
77
|
-
if (results.length === 0) {
|
|
78
|
-
lines.push('No content extracted.');
|
|
79
|
-
}
|
|
80
|
-
else {
|
|
81
|
-
lines.push(`Extracted ${results.length} URL${results.length === 1 ? '' : 's'}:`, '');
|
|
82
|
-
results.forEach((r, i) => {
|
|
83
|
-
lines.push(renderResult(r, i + 1));
|
|
84
|
-
lines.push('');
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
if (failures.length > 0) {
|
|
88
|
-
lines.push(`Failed (${failures.length}):`);
|
|
89
|
-
for (const f of failures)
|
|
90
|
-
lines.push(`- ${f.url} — ${f.reason}`);
|
|
91
|
-
lines.push('');
|
|
92
|
-
}
|
|
93
|
-
lines.push(followUp);
|
|
94
|
-
return lines.join('\n');
|
|
95
|
-
},
|
|
96
|
-
});
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
/** Resolve the Exa API key: EXA_API_KEY env first, then ~/.crouter/exa.key.
|
|
2
|
-
* Never prompts and never proceeds keyless — throws a usage error naming both
|
|
3
|
-
* options when neither is present. */
|
|
4
|
-
export declare function getApiKey(): string;
|
|
5
|
-
/** One Exa result row as returned by /search and /contents. Fields are present
|
|
6
|
-
* only when the API supplies them. */
|
|
7
|
-
export interface ExaResult {
|
|
8
|
-
title?: string;
|
|
9
|
-
url?: string;
|
|
10
|
-
publishedDate?: string;
|
|
11
|
-
author?: string;
|
|
12
|
-
highlights?: string[];
|
|
13
|
-
text?: string;
|
|
14
|
-
score?: number;
|
|
15
|
-
/** Set when the row came from a fallback fetcher (e.g. 'pure.md') rather than Exa. */
|
|
16
|
-
source?: string;
|
|
17
|
-
}
|
|
18
|
-
export interface ExaSearchResponse {
|
|
19
|
-
results?: ExaResult[];
|
|
20
|
-
}
|
|
21
|
-
export interface ExaAnswerResponse {
|
|
22
|
-
answer?: string;
|
|
23
|
-
citations?: Array<{
|
|
24
|
-
title?: string;
|
|
25
|
-
url?: string;
|
|
26
|
-
}>;
|
|
27
|
-
}
|
|
28
|
-
/** Per-URL fetch status returned by /contents (and /search livecrawls). */
|
|
29
|
-
export interface ExaStatus {
|
|
30
|
-
id?: string;
|
|
31
|
-
status?: string;
|
|
32
|
-
error?: {
|
|
33
|
-
tag?: string;
|
|
34
|
-
httpStatusCode?: number;
|
|
35
|
-
} | string;
|
|
36
|
-
}
|
|
37
|
-
export interface ExaContentsResponse {
|
|
38
|
-
results?: ExaResult[];
|
|
39
|
-
statuses?: ExaStatus[];
|
|
40
|
-
}
|
|
41
|
-
export declare function exaSearch(body: Record<string, unknown>): Promise<ExaSearchResponse>;
|
|
42
|
-
export declare function exaAnswer(body: Record<string, unknown>): Promise<ExaAnswerResponse>;
|
|
43
|
-
export declare function exaContents(body: Record<string, unknown>): Promise<ExaContentsResponse>;
|
|
44
|
-
/** Cap (characters) applied to full-text extraction so a single call cannot
|
|
45
|
-
* blow up the caller's context. Highlights remain the default content mode. */
|
|
46
|
-
export declare const TEXT_MAX_CHARACTERS = 4000;
|
|
47
|
-
/** Split a positional URL argument on commas or whitespace into a clean list. */
|
|
48
|
-
export declare function parseUrls(raw: string): string[];
|
|
49
|
-
/** Split a comma-separated domain flag into a clean list, or undefined when empty. */
|
|
50
|
-
export declare function parseDomains(raw: string | undefined): string[] | undefined;
|
|
51
|
-
/** Render one result as agent-ready markdown: a numbered heading with title +
|
|
52
|
-
* metadata line, then highlights as bullets or capped text as a blockquote. */
|
|
53
|
-
export declare function renderResult(r: ExaResult, index: number): string;
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
// The only module that talks to api.exa.ai. Owns API-key resolution, the three
|
|
2
|
-
// endpoint calls (/search, /answer, /contents), request shaping, and translation
|
|
3
|
-
// of HTTP/transport failures into the crtr error taxonomy. Leaves never build
|
|
4
|
-
// HTTP requests directly. Uses the Node global fetch — no HTTP dependency.
|
|
5
|
-
import { join } from 'node:path';
|
|
6
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
7
|
-
import { usage, network } from '../../core/errors.js';
|
|
8
|
-
import { classify } from '../../core/fault-classifier.js';
|
|
9
|
-
import { clearFaultForCurrentNode, recordFaultForCurrentNode } from '../../core/runtime/fault.js';
|
|
10
|
-
import { userScopeRoot } from '../../core/scope.js';
|
|
11
|
-
const EXA_BASE = 'https://api.exa.ai';
|
|
12
|
-
function endpointToOp(endpoint) {
|
|
13
|
-
switch (endpoint) {
|
|
14
|
-
case '/search':
|
|
15
|
-
return 'search /search';
|
|
16
|
-
case '/answer':
|
|
17
|
-
return 'search /answer';
|
|
18
|
-
case '/contents':
|
|
19
|
-
return 'search /contents';
|
|
20
|
-
default:
|
|
21
|
-
return `search ${endpoint}`;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
/** Resolve the Exa API key: EXA_API_KEY env first, then ~/.crouter/exa.key.
|
|
25
|
-
* Never prompts and never proceeds keyless — throws a usage error naming both
|
|
26
|
-
* options when neither is present. */
|
|
27
|
-
export function getApiKey() {
|
|
28
|
-
const env = process.env['EXA_API_KEY'];
|
|
29
|
-
if (env !== undefined && env.trim() !== '')
|
|
30
|
-
return env.trim();
|
|
31
|
-
const keyFile = join(userScopeRoot(), 'exa.key');
|
|
32
|
-
if (existsSync(keyFile)) {
|
|
33
|
-
const fromFile = readFileSync(keyFile, 'utf8').trim();
|
|
34
|
-
if (fromFile !== '')
|
|
35
|
-
return fromFile;
|
|
36
|
-
}
|
|
37
|
-
throw usage('no Exa API key found', {
|
|
38
|
-
next: `Set the EXA_API_KEY environment variable, or write the key to ${keyFile}.`,
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
/** POST a JSON body to an Exa endpoint and return the parsed JSON. Any non-2xx
|
|
42
|
-
* response or transport failure becomes a crtr network error carrying Exa's
|
|
43
|
-
* status/message and a concrete recovery hint. */
|
|
44
|
-
async function exaPost(endpoint, body) {
|
|
45
|
-
const apiKey = getApiKey();
|
|
46
|
-
let res;
|
|
47
|
-
try {
|
|
48
|
-
res = await fetch(`${EXA_BASE}${endpoint}`, {
|
|
49
|
-
method: 'POST',
|
|
50
|
-
headers: { 'x-api-key': apiKey, 'content-type': 'application/json' },
|
|
51
|
-
body: JSON.stringify(body),
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
catch (err) {
|
|
55
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
56
|
-
const message = `Exa request failed: ${msg}`;
|
|
57
|
-
const classified = classify('cli→exa', err);
|
|
58
|
-
recordFaultForCurrentNode({
|
|
59
|
-
link: 'cli→exa',
|
|
60
|
-
op: endpointToOp(endpoint),
|
|
61
|
-
kind: classified.kind,
|
|
62
|
-
retry: { disposition: classified.disposition },
|
|
63
|
-
message,
|
|
64
|
-
});
|
|
65
|
-
throw network(message, {
|
|
66
|
-
next: 'Check network connectivity and retry. If it persists, simplify the query or reduce --num.',
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
if (!res.ok) {
|
|
70
|
-
let detail = '';
|
|
71
|
-
try {
|
|
72
|
-
detail = (await res.text()).slice(0, 500);
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
/* body unreadable — status line is enough */
|
|
76
|
-
}
|
|
77
|
-
const message = `Exa returned ${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`;
|
|
78
|
-
const classified = classify('cli→exa', {
|
|
79
|
-
status: res.status,
|
|
80
|
-
statusText: res.statusText,
|
|
81
|
-
ok: res.ok,
|
|
82
|
-
message: detail,
|
|
83
|
-
});
|
|
84
|
-
recordFaultForCurrentNode({
|
|
85
|
-
link: 'cli→exa',
|
|
86
|
-
op: endpointToOp(endpoint),
|
|
87
|
-
kind: classified.kind,
|
|
88
|
-
retry: { disposition: classified.disposition },
|
|
89
|
-
message,
|
|
90
|
-
});
|
|
91
|
-
throw network(message, {
|
|
92
|
-
// The transport status, never `received` — that slot names the caller's
|
|
93
|
-
// offending VALUE, and Exa is not complaining about the number 401.
|
|
94
|
-
http_status: res.status,
|
|
95
|
-
next: res.status === 401
|
|
96
|
-
? 'The API key was rejected. Verify EXA_API_KEY or ~/.crouter/exa.key.'
|
|
97
|
-
: 'Retry; if it persists, simplify the query, reduce --num, or drop domain filters.',
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
clearFaultForCurrentNode({ link: 'cli→exa' });
|
|
101
|
-
return (await res.json());
|
|
102
|
-
}
|
|
103
|
-
export function exaSearch(body) {
|
|
104
|
-
return exaPost('/search', body);
|
|
105
|
-
}
|
|
106
|
-
export function exaAnswer(body) {
|
|
107
|
-
return exaPost('/answer', body);
|
|
108
|
-
}
|
|
109
|
-
export function exaContents(body) {
|
|
110
|
-
return exaPost('/contents', body);
|
|
111
|
-
}
|
|
112
|
-
/** Cap (characters) applied to full-text extraction so a single call cannot
|
|
113
|
-
* blow up the caller's context. Highlights remain the default content mode. */
|
|
114
|
-
export const TEXT_MAX_CHARACTERS = 4000;
|
|
115
|
-
/** Split a positional URL argument on commas or whitespace into a clean list. */
|
|
116
|
-
export function parseUrls(raw) {
|
|
117
|
-
return raw
|
|
118
|
-
.split(/[\s,]+/)
|
|
119
|
-
.map((u) => u.trim())
|
|
120
|
-
.filter((u) => u !== '');
|
|
121
|
-
}
|
|
122
|
-
/** Split a comma-separated domain flag into a clean list, or undefined when empty. */
|
|
123
|
-
export function parseDomains(raw) {
|
|
124
|
-
if (raw === undefined)
|
|
125
|
-
return undefined;
|
|
126
|
-
const list = raw
|
|
127
|
-
.split(',')
|
|
128
|
-
.map((d) => d.trim())
|
|
129
|
-
.filter((d) => d !== '');
|
|
130
|
-
return list.length > 0 ? list : undefined;
|
|
131
|
-
}
|
|
132
|
-
/** Render one result as agent-ready markdown: a numbered heading with title +
|
|
133
|
-
* metadata line, then highlights as bullets or capped text as a blockquote. */
|
|
134
|
-
export function renderResult(r, index) {
|
|
135
|
-
const lines = [];
|
|
136
|
-
const title = r.title !== undefined && r.title !== '' ? r.title : '(untitled)';
|
|
137
|
-
lines.push(`### ${index}. ${title}`);
|
|
138
|
-
const meta = [];
|
|
139
|
-
if (typeof r.url === 'string' && r.url !== '')
|
|
140
|
-
meta.push(r.url);
|
|
141
|
-
if (typeof r.publishedDate === 'string' && r.publishedDate !== '')
|
|
142
|
-
meta.push(r.publishedDate.slice(0, 10));
|
|
143
|
-
if (typeof r.author === 'string' && r.author !== '')
|
|
144
|
-
meta.push(r.author);
|
|
145
|
-
if (typeof r.source === 'string' && r.source !== '')
|
|
146
|
-
meta.push(`via ${r.source}`);
|
|
147
|
-
if (meta.length > 0)
|
|
148
|
-
lines.push(meta.join(' · '));
|
|
149
|
-
if (r.highlights !== undefined && r.highlights.length > 0) {
|
|
150
|
-
for (const h of r.highlights)
|
|
151
|
-
lines.push(`- ${h.replace(/\s+/g, ' ').trim()}`);
|
|
152
|
-
}
|
|
153
|
-
else if (r.text !== undefined && r.text !== '') {
|
|
154
|
-
lines.push(r.text.trim());
|
|
155
|
-
}
|
|
156
|
-
return lines.join('\n');
|
|
157
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
export type PuremdResult = {
|
|
2
|
-
ok: true;
|
|
3
|
-
text: string;
|
|
4
|
-
} | {
|
|
5
|
-
ok: false;
|
|
6
|
-
reason: string;
|
|
7
|
-
};
|
|
8
|
-
/** Fetch a single URL's content as markdown via pure.md, capped at maxChars.
|
|
9
|
-
* Never throws — any transport or HTTP failure becomes { ok: false, reason }
|
|
10
|
-
* so the caller can keep the URL in its failures list. */
|
|
11
|
-
export declare function puremdFetch(url: string, maxChars: number): Promise<PuremdResult>;
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
// Fallback content fetcher: when Exa cannot fetch a URL, we re-fetch it through
|
|
2
|
-
// pure.md (https://pure.md), which renders any page to clean markdown. This is
|
|
3
|
-
// the ONLY module that talks to pure.md. API-key resolution is optional — the
|
|
4
|
-
// service works keyless (rate-limited); a key raises limits.
|
|
5
|
-
import { join } from 'node:path';
|
|
6
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
7
|
-
import { userScopeRoot } from '../../core/scope.js';
|
|
8
|
-
const PUREMD_BASE = 'https://pure.md';
|
|
9
|
-
/** Resolve an optional pure.md API token: PUREMD_API_KEY env first, then
|
|
10
|
-
* ~/.crouter/puremd.key. Returns undefined when neither is set — pure.md is
|
|
11
|
-
* usable keyless, so a missing token is not an error. */
|
|
12
|
-
function getApiKey() {
|
|
13
|
-
const env = process.env['PUREMD_API_KEY'];
|
|
14
|
-
if (env !== undefined && env.trim() !== '')
|
|
15
|
-
return env.trim();
|
|
16
|
-
const keyFile = join(userScopeRoot(), 'puremd.key');
|
|
17
|
-
if (existsSync(keyFile)) {
|
|
18
|
-
const fromFile = readFileSync(keyFile, 'utf8').trim();
|
|
19
|
-
if (fromFile !== '')
|
|
20
|
-
return fromFile;
|
|
21
|
-
}
|
|
22
|
-
return undefined;
|
|
23
|
-
}
|
|
24
|
-
/** Fetch a single URL's content as markdown via pure.md, capped at maxChars.
|
|
25
|
-
* Never throws — any transport or HTTP failure becomes { ok: false, reason }
|
|
26
|
-
* so the caller can keep the URL in its failures list. */
|
|
27
|
-
export async function puremdFetch(url, maxChars) {
|
|
28
|
-
const headers = {};
|
|
29
|
-
const key = getApiKey();
|
|
30
|
-
if (key !== undefined)
|
|
31
|
-
headers['x-puremd-api-token'] = key;
|
|
32
|
-
let res;
|
|
33
|
-
try {
|
|
34
|
-
res = await fetch(`${PUREMD_BASE}/${url}`, { headers });
|
|
35
|
-
}
|
|
36
|
-
catch (err) {
|
|
37
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
38
|
-
return { ok: false, reason: `pure.md request failed: ${msg}` };
|
|
39
|
-
}
|
|
40
|
-
if (!res.ok) {
|
|
41
|
-
return { ok: false, reason: `pure.md returned ${res.status} ${res.statusText}` };
|
|
42
|
-
}
|
|
43
|
-
let text;
|
|
44
|
-
try {
|
|
45
|
-
text = (await res.text()).trim();
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
return { ok: false, reason: 'pure.md response body unreadable' };
|
|
49
|
-
}
|
|
50
|
-
if (text === '')
|
|
51
|
-
return { ok: false, reason: 'pure.md returned empty content' };
|
|
52
|
-
return { ok: true, text: text.slice(0, maxChars) };
|
|
53
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare const webLeaf: import("../../core/command.js").LeafDef;
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { defineLeaf } from '../../core/command.js';
|
|
2
|
-
import { exaSearch, parseDomains, renderResult, TEXT_MAX_CHARACTERS, } from './exa.js';
|
|
3
|
-
const SEARCH_TYPES = ['auto', 'fast', 'instant', 'deep-lite', 'deep', 'deep-reasoning'];
|
|
4
|
-
export const webLeaf = defineLeaf({
|
|
5
|
-
name: 'web',
|
|
6
|
-
description: 'find web pages relevant to a query, with highlight excerpts',
|
|
7
|
-
whenToUse: 'you need to discover web pages relevant to a query and read query-matched excerpts from them — the default web search. Use this for open-ended research, finding sources, or gathering current information. Reach for `answer` instead when you want one synthesized, cited answer to a specific question rather than a ranked list of pages; reach for `contents` when you already hold the URLs and only need their content.',
|
|
8
|
-
help: {
|
|
9
|
-
name: 'search web',
|
|
10
|
-
summary: 'web search via Exa — ranked results with query-relevant highlight excerpts (or full text)',
|
|
11
|
-
params: [
|
|
12
|
-
{ kind: 'positional', name: 'query', required: true, constraint: 'The search query. Natural language; be specific.' },
|
|
13
|
-
{ kind: 'flag', name: 'type', type: 'enum', choices: [...SEARCH_TYPES], required: false, default: 'auto', constraint: 'Search depth. auto balances relevance and speed; fast/instant trade depth for latency; deep-lite/deep/deep-reasoning run multi-query expansion and rank the combined set for harder synthesis.' },
|
|
14
|
-
{ kind: 'flag', name: 'num', type: 'int', required: false, default: 10, constraint: 'Number of results to return.' },
|
|
15
|
-
{ kind: 'flag', name: 'text', type: 'bool', required: false, constraint: `Return cleaned full page text (capped at ${TEXT_MAX_CHARACTERS} characters per result) instead of highlight excerpts. Off by default; highlights keep token usage predictable.` },
|
|
16
|
-
{ kind: 'flag', name: 'include-domains', type: 'string', required: false, constraint: 'Comma-separated domain allowlist — restrict results to these domains.' },
|
|
17
|
-
{ kind: 'flag', name: 'exclude-domains', type: 'string', required: false, constraint: 'Comma-separated domain blocklist — drop results from these domains.' },
|
|
18
|
-
],
|
|
19
|
-
output: [
|
|
20
|
-
{ name: 'query', type: 'string', required: true, constraint: 'Echo of the query searched.' },
|
|
21
|
-
{ name: 'results', type: 'object[]', required: true, constraint: 'Ranked best-first, each: title, url, published date and author when present, and either highlight excerpts (default) or capped full text (--text).' },
|
|
22
|
-
{ name: 'follow_up', type: 'string', required: true, constraint: 'Decision road sign: either read selected result URLs through the contents leaf after checking its schema, or refine the query.' },
|
|
23
|
-
],
|
|
24
|
-
outputKind: 'object',
|
|
25
|
-
effects: ['Sends one search request to the Exa API (network). No local state changes.'],
|
|
26
|
-
},
|
|
27
|
-
run: async (input) => {
|
|
28
|
-
const query = input['query'];
|
|
29
|
-
const type = input['type'];
|
|
30
|
-
const num = input['num'];
|
|
31
|
-
const wantText = input['text'];
|
|
32
|
-
const contents = wantText
|
|
33
|
-
? { text: { maxCharacters: TEXT_MAX_CHARACTERS } }
|
|
34
|
-
: { highlights: true };
|
|
35
|
-
const body = { query, type, numResults: num, contents };
|
|
36
|
-
const include = parseDomains(input['includeDomains']);
|
|
37
|
-
const exclude = parseDomains(input['excludeDomains']);
|
|
38
|
-
if (include !== undefined)
|
|
39
|
-
body['includeDomains'] = include;
|
|
40
|
-
if (exclude !== undefined)
|
|
41
|
-
body['excludeDomains'] = exclude;
|
|
42
|
-
const res = await exaSearch(body);
|
|
43
|
-
const results = res.results ?? [];
|
|
44
|
-
return {
|
|
45
|
-
query,
|
|
46
|
-
results,
|
|
47
|
-
follow_up: 'Fetch full content for selected result URLs with the contents leaf after checking its schema. No good hits? Broaden the query, drop domain filters, or try --type deep.',
|
|
48
|
-
};
|
|
49
|
-
},
|
|
50
|
-
render: (result) => {
|
|
51
|
-
const query = result['query'];
|
|
52
|
-
const results = result['results'];
|
|
53
|
-
const followUp = result['follow_up'];
|
|
54
|
-
if (results.length === 0) {
|
|
55
|
-
return `No results for "${query}".\n\n${followUp}`;
|
|
56
|
-
}
|
|
57
|
-
const lines = [`${results.length} results for "${query}":`, ''];
|
|
58
|
-
results.forEach((r, i) => {
|
|
59
|
-
lines.push(renderResult(r, i + 1));
|
|
60
|
-
lines.push('');
|
|
61
|
-
});
|
|
62
|
-
lines.push(followUp);
|
|
63
|
-
return lines.join('\n');
|
|
64
|
-
},
|
|
65
|
-
});
|
package/dist/commands/search.js
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
// `crtr search` subtree — web search for agents, backed by the Exa API. Three
|
|
2
|
-
// leaves: web (find pages), answer (one grounded cited answer), contents
|
|
3
|
-
// (extract from URLs you already have). API key resolves from EXA_API_KEY or
|
|
4
|
-
// ~/.crouter/exa.key (see ./search/exa.ts).
|
|
5
|
-
import { defineBranch } from '../core/command.js';
|
|
6
|
-
import { webLeaf } from './search/web.js';
|
|
7
|
-
import { answerLeaf } from './search/answer.js';
|
|
8
|
-
import { contentsLeaf } from './search/contents.js';
|
|
9
|
-
export function registerSearch() {
|
|
10
|
-
return defineBranch({
|
|
11
|
-
name: 'search',
|
|
12
|
-
rootEntry: {
|
|
13
|
-
concept: 'web search for agents — find pages, get grounded answers, extract page content (Exa)',
|
|
14
|
-
desc: 'search the web, answer a question with citations, or extract content from known URLs',
|
|
15
|
-
useWhen: 'you need information from the live web — current events, documentation, sources, facts beyond your training. Use web for relevant pages with excerpts, answer for one synthesized cited answer, and contents for clean text from URLs you already hold. This reaches public pages read-only, not logged-in pages or service APIs. Needs an Exa API key (EXA_API_KEY or ~/.crouter/exa.key).',
|
|
16
|
-
},
|
|
17
|
-
help: {
|
|
18
|
-
name: 'search',
|
|
19
|
-
summary: 'web search via the Exa API — find pages, answer questions with citations, extract page content',
|
|
20
|
-
model: 'Three leaves split by what you have and what you want. `web` is the default: a query in, ranked pages out with query-relevant highlight excerpts (or full text with --text) — use it for discovery and research. `answer` collapses a question into one synthesized, source-cited answer — use it when you want the conclusion, not a reading list. `contents` does no searching; it extracts cleaned content from URLs you already hold. Highlights are the default content mode everywhere (token-predictable); full text is opt-in and length-capped. Every leaf needs an Exa API key from EXA_API_KEY or ~/.crouter/exa.key.',
|
|
21
|
-
},
|
|
22
|
-
children: [webLeaf, answerLeaf, contentsLeaf],
|
|
23
|
-
});
|
|
24
|
-
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { type SetupPanel } from "./panel.js";
|
|
2
|
-
export declare class KeysPanel implements SetupPanel {
|
|
3
|
-
private readonly input;
|
|
4
|
-
private _focused;
|
|
5
|
-
private notice;
|
|
6
|
-
onSubmit?: () => void;
|
|
7
|
-
onCancel?: () => void;
|
|
8
|
-
constructor();
|
|
9
|
-
set focused(value: boolean);
|
|
10
|
-
get focused(): boolean;
|
|
11
|
-
get value(): string;
|
|
12
|
-
setNotice(notice: string, clearValue?: boolean): void;
|
|
13
|
-
badge(): string;
|
|
14
|
-
handleInput(data: string): boolean;
|
|
15
|
-
render(width: number): string[];
|
|
16
|
-
}
|