@north-light/crouter 0.3.182 → 0.3.184
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/canvas/nav-model.d.ts +1 -8
- package/dist/core/canvas/nav-model.js +30 -59
- package/dist/core/canvas/nav-render.d.ts +2 -2
- package/dist/core/canvas/nav-render.js +8 -6
- 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-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/daemon/__tests__/subtree-filter-freshness.test.d.ts +1 -0
- package/dist/daemon/__tests__/subtree-filter-freshness.test.js +63 -0
- 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/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
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Regression: crtrd served a PERMANENTLY stale `GET /v1/nodes?under=<id>`.
|
|
2
|
+
//
|
|
3
|
+
// `handleList`'s subtree scope came from nav-model's `subtreeIds`, whose reads
|
|
4
|
+
// memoized into module-global Maps that only a `beginFrame()` cleared — and
|
|
5
|
+
// `beginFrame()` had no call sites anywhere. In a TUI that re-renders, a memo is
|
|
6
|
+
// one frame stale; in crtrd, which lives for hours, it is forever. The first
|
|
7
|
+
// `?under=X` request that arrived BEFORE X had spawned children pinned `[]`
|
|
8
|
+
// under X, and every later request returned that `[]` — Northlight's onboarding
|
|
9
|
+
// progress bar sat at 0/100 for the life of the daemon while the unfiltered list
|
|
10
|
+
// showed all 23 children with correct parents.
|
|
11
|
+
//
|
|
12
|
+
// This drives the real route handler against a real canvas db in exactly that
|
|
13
|
+
// order: list the subtree while it is genuinely empty, THEN spawn children, then
|
|
14
|
+
// list again. Nothing here would have failed with a hand-checked subtree walk —
|
|
15
|
+
// only the request-order sequence catches it.
|
|
16
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { createNode, subscribe } from '../../core/canvas/canvas.js';
|
|
22
|
+
import { closeDb } from '../../core/canvas/db.js';
|
|
23
|
+
import { nodeRoutes } from '../api/handlers/nodes.js';
|
|
24
|
+
let home;
|
|
25
|
+
function node(id, parent) {
|
|
26
|
+
return {
|
|
27
|
+
node_id: id, name: id, created: new Date().toISOString(), cwd: '/tmp/work',
|
|
28
|
+
kind: 'general', mode: 'base', lifecycle: 'terminal', status: 'active',
|
|
29
|
+
...(parent !== undefined ? { parent } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** GET /v1/nodes?under=<id> through the registered route, as crtrd serves it. */
|
|
33
|
+
async function listUnder(id) {
|
|
34
|
+
const route = nodeRoutes.find((r) => r.method === 'GET' && r.pattern === '/v1/nodes');
|
|
35
|
+
const ctx = {
|
|
36
|
+
method: 'GET', path: '/v1/nodes', params: {},
|
|
37
|
+
query: new URLSearchParams({ under: id }), body: undefined,
|
|
38
|
+
};
|
|
39
|
+
const res = (await route.handler(ctx));
|
|
40
|
+
assert.equal(res.status, 200);
|
|
41
|
+
return res.body.map((r) => r.node_id);
|
|
42
|
+
}
|
|
43
|
+
before(() => { home = mkdtempSync(join(tmpdir(), 'crtr-subtree-')); process.env['CRTR_HOME'] = home; });
|
|
44
|
+
beforeEach(() => { closeDb(); rmSync(home, { recursive: true, force: true }); });
|
|
45
|
+
after(() => { closeDb(); rmSync(home, { recursive: true, force: true }); delete process.env['CRTR_HOME']; });
|
|
46
|
+
test('a subtree query answered before any child exists does not poison later queries on the same daemon process', async () => {
|
|
47
|
+
const coordinator = 'coordinator';
|
|
48
|
+
createNode(node(coordinator));
|
|
49
|
+
// The poisoning request: the gateway polls within a second of the coordinator
|
|
50
|
+
// being created, before it has spawned anything.
|
|
51
|
+
assert.deepEqual(await listUnder(coordinator), [], 'an empty subtree is genuinely empty');
|
|
52
|
+
// The coordinator now spawns its children (parent subscribes_to child).
|
|
53
|
+
createNode(node('research-gmail', coordinator));
|
|
54
|
+
createNode(node('research-misc', coordinator));
|
|
55
|
+
subscribe(coordinator, 'research-gmail');
|
|
56
|
+
subscribe(coordinator, 'research-misc');
|
|
57
|
+
assert.deepEqual((await listUnder(coordinator)).sort(), ['research-gmail', 'research-misc'], 'the same long-lived process must see children spawned after its first subtree query');
|
|
58
|
+
// A grandchild spawned later still surfaces — the walk is re-read at every
|
|
59
|
+
// level, not just the root's direct edges.
|
|
60
|
+
createNode(node('writer-connectivity', 'research-gmail'));
|
|
61
|
+
subscribe('research-gmail', 'writer-connectivity');
|
|
62
|
+
assert.deepEqual((await listUnder(coordinator)).sort(), ['research-gmail', 'research-misc', 'writer-connectivity'], 'a grandchild spawned after an earlier query surfaces too');
|
|
63
|
+
});
|
|
@@ -68,7 +68,7 @@ interface PiLike {
|
|
|
68
68
|
}
|
|
69
69
|
/** Build the full bearings baseline for a conversation without inherited
|
|
70
70
|
* crouter context. Exported for testing. */
|
|
71
|
-
export declare function buildContextIntro(nodeId: string): Promise<string>;
|
|
71
|
+
export declare function buildContextIntro(nodeId: string, seen?: Set<string>): Promise<string>;
|
|
72
72
|
/** Build the compact node-scoped update for a fork whose copied branch already
|
|
73
73
|
* carries a full crouter baseline. Exported for testing. */
|
|
74
74
|
export declare function buildForkContextIntro(nodeId: string): Promise<string>;
|
|
@@ -30,6 +30,7 @@ import { CONTEXT_INTRO_CUSTOM_TYPE } from '../shared/generated-context.js';
|
|
|
30
30
|
import { brokerExtensionState } from '../core/runtime/broker/daemon-ops.js';
|
|
31
31
|
import { buildContextBearingsFromState, buildForkBearingsFromState, buildProjectContextBlockForBroker } from '../core/runtime/broker-extension-render.js';
|
|
32
32
|
import { situationalContextBlock } from '../core/runtime/situational-context.js';
|
|
33
|
+
import { saveInjectedDocs, sharedInjectedDocs } from '../core/substrate/injected-store.js';
|
|
33
34
|
import { truncateToWidth } from './truncate.js';
|
|
34
35
|
/** The `customType` stamped on the injected session message. Used both to write
|
|
35
36
|
* the entry and to detect it on resume (the idempotency guard). Defined beside
|
|
@@ -41,9 +42,9 @@ export { CONTEXT_INTRO_CUSTOM_TYPE };
|
|
|
41
42
|
// ---------------------------------------------------------------------------
|
|
42
43
|
/** Build the full bearings baseline for a conversation without inherited
|
|
43
44
|
* crouter context. Exported for testing. */
|
|
44
|
-
export async function buildContextIntro(nodeId) {
|
|
45
|
+
export async function buildContextIntro(nodeId, seen) {
|
|
45
46
|
const state = await brokerExtensionState(nodeId);
|
|
46
|
-
return buildContextBearingsFromState(state, buildProjectContextBlockForBroker(state.node.cwd), situationalContextBlock(nodeId));
|
|
47
|
+
return buildContextBearingsFromState(state, buildProjectContextBlockForBroker(state.node.cwd), situationalContextBlock(nodeId), seen);
|
|
47
48
|
}
|
|
48
49
|
/** Build the compact node-scoped update for a fork whose copied branch already
|
|
49
50
|
* carries a full crouter baseline. Exported for testing. */
|
|
@@ -160,15 +161,25 @@ export function registerCanvasContextIntro(pi) {
|
|
|
160
161
|
// this fork's new node-scoped facts. A non-crouter branch has no such entry.
|
|
161
162
|
const inherited = contextEntries.length > 0;
|
|
162
163
|
const forked = state.node.fork_from !== null;
|
|
164
|
+
// The baseline branch renders workspace-open docs, so it threads the
|
|
165
|
+
// PROCESS-SHARED dedup set: the on-read hook (canvas-doc-substrate) holds
|
|
166
|
+
// the same Set instance, so a front door delivered here never re-delivers
|
|
167
|
+
// on a later read beneath its store. The fork branch renders no workspace
|
|
168
|
+
// docs and needs no set.
|
|
169
|
+
const seen = inherited && forked ? undefined : sharedInjectedDocs(nodeId);
|
|
163
170
|
const content = inherited && forked
|
|
164
171
|
? buildForkBearingsFromState(state, situationalContextBlock(nodeId))
|
|
165
|
-
: buildContextBearingsFromState(state, buildProjectContextBlockForBroker(state.node.cwd), situationalContextBlock(nodeId));
|
|
172
|
+
: buildContextBearingsFromState(state, buildProjectContextBlockForBroker(state.node.cwd), situationalContextBlock(nodeId), seen);
|
|
166
173
|
pi.sendMessage({
|
|
167
174
|
customType: CONTEXT_INTRO_CUSTOM_TYPE,
|
|
168
175
|
content,
|
|
169
176
|
display: true,
|
|
170
177
|
details: { nodeId },
|
|
171
178
|
});
|
|
179
|
+
// Persist what the baseline delivered so the dedup survives a later
|
|
180
|
+
// dormancy → revive(resume) into a fresh process.
|
|
181
|
+
if (seen !== undefined)
|
|
182
|
+
saveInjectedDocs(nodeId, seen);
|
|
172
183
|
});
|
|
173
184
|
}
|
|
174
185
|
export default registerCanvasContextIntro;
|
|
@@ -36,7 +36,7 @@ import { brokerExtensionState } from '../core/runtime/broker/daemon-ops.js';
|
|
|
36
36
|
import { renderPreferencesFromState } from '../core/runtime/broker-extension-render.js';
|
|
37
37
|
import { renderOnReadDocsForSubject } from '../core/substrate/on-read.js';
|
|
38
38
|
import { clearSessionCache } from '../core/substrate/session-cache.js';
|
|
39
|
-
import { loadInjectedDocs, saveInjectedDocs } from '../core/substrate/injected-store.js';
|
|
39
|
+
import { loadInjectedDocs, saveInjectedDocs, sharedInjectedDocs } from '../core/substrate/injected-store.js';
|
|
40
40
|
import { autoLoadedContextInner, mergeAutoLoadedContext } from './envelope-merge.js';
|
|
41
41
|
// ---------------------------------------------------------------------------
|
|
42
42
|
// Extension
|
|
@@ -65,7 +65,12 @@ export function registerCanvasDocSubstrate(pi) {
|
|
|
65
65
|
// dedup must carry forward. The one launch path that begins a FRESH transcript
|
|
66
66
|
// (reviveNode with resume=false) deletes the file, so a new conversation
|
|
67
67
|
// rehydrates empty here. See core/substrate/injected-store.ts.
|
|
68
|
-
|
|
68
|
+
//
|
|
69
|
+
// The set is the PROCESS-SHARED instance (sharedInjectedDocs), so the
|
|
70
|
+
// context-intro extension's workspace-open render grows the same set this
|
|
71
|
+
// hook dedups against — a front door delivered at boot never re-delivers on
|
|
72
|
+
// a later read beneath its store.
|
|
73
|
+
const injectedDocs = sharedInjectedDocs(nodeId);
|
|
69
74
|
pi.on('session_start', () => {
|
|
70
75
|
// Only the per-session substrate PARSE cache resets each session (so the
|
|
71
76
|
// corpus is re-scanned, picking up memory writes). injectedDocs is
|
|
@@ -112,6 +117,12 @@ export function registerCanvasDocSubstrate(pi) {
|
|
|
112
117
|
const expanded = rawPath.startsWith('~') ? join(homedir(), rawPath.slice(1)) : rawPath;
|
|
113
118
|
const absFile = resolve(cwd, expanded);
|
|
114
119
|
const state = await brokerExtensionState(nodeId);
|
|
120
|
+
// Merge the on-disk set: an out-of-process delivery (the daemon's
|
|
121
|
+
// warm-spare claim pushes bearings — including workspace-open docs —
|
|
122
|
+
// into the live session and persists what it delivered) must dedup here
|
|
123
|
+
// too. Cheap: one tiny JSON read per `read`.
|
|
124
|
+
for (const real of loadInjectedDocs(nodeId))
|
|
125
|
+
injectedDocs.add(real);
|
|
115
126
|
const injected = renderOnReadDocsForSubject(state.subject, absFile, injectedDocs);
|
|
116
127
|
if (injected === '')
|
|
117
128
|
return; // nothing surfaced — pass the read through unchanged
|
|
@@ -46,6 +46,10 @@ export declare function generatedContextText(message: GeneratedContextMessageLik
|
|
|
46
46
|
export declare function formatContextNudge(note: string): string;
|
|
47
47
|
export declare function isInboxDigest(text: string): boolean;
|
|
48
48
|
export declare function extractInboxSender(text: string): string | null;
|
|
49
|
+
/** Node ids whose section of this digest reports a finished node. Sections are
|
|
50
|
+
* delimited by their `From <sender>` headers, so a final never attaches to the
|
|
51
|
+
* neighbouring sender. */
|
|
52
|
+
export declare function inboxFinishedSenders(text: string): string[];
|
|
49
53
|
/** Exact producer-owned marker for the continuation a successor broker inserts
|
|
50
54
|
* after a runtime restart. Other recovery bodies intentionally do not match. */
|
|
51
55
|
export declare function isRuntimeRestartContinuation(message: GeneratedContextMessageLike): boolean;
|
|
@@ -101,14 +101,33 @@ function nudgeSummary(body) {
|
|
|
101
101
|
// Senders are node ids, the two reserved words, or the daemon itself (crtrd —
|
|
102
102
|
// review notes and other daemon-originated node messages).
|
|
103
103
|
const INBOX_HEADER_RE = /^From ([a-z0-9]+(?:-[a-z0-9]+)+|system|human|crtrd) — (\d+) update/m;
|
|
104
|
+
const INBOX_HEADERS_RE = /^From ([a-z0-9]+(?:-[a-z0-9]+)+|system|human|crtrd) — (\d+) update/gm;
|
|
105
|
+
// Entry lines inside a section, rendered by core/runtime/broker/inbox.ts as
|
|
106
|
+
// ` [<kind>]`. A final is the one kind that ends the sender's node.
|
|
107
|
+
const FINISHED_ENTRY_RE = /^ {2}\[(?:final|completed)\]/m;
|
|
104
108
|
export function isInboxDigest(text) {
|
|
105
109
|
return INBOX_HEADER_RE.test(text);
|
|
106
110
|
}
|
|
107
111
|
export function extractInboxSender(text) {
|
|
108
112
|
return INBOX_HEADER_RE.exec(text)?.[1] ?? null;
|
|
109
113
|
}
|
|
114
|
+
/** Node ids whose section of this digest reports a finished node. Sections are
|
|
115
|
+
* delimited by their `From <sender>` headers, so a final never attaches to the
|
|
116
|
+
* neighbouring sender. */
|
|
117
|
+
export function inboxFinishedSenders(text) {
|
|
118
|
+
const headers = [...text.matchAll(INBOX_HEADERS_RE)];
|
|
119
|
+
const senders = [];
|
|
120
|
+
for (const [index, header] of headers.entries()) {
|
|
121
|
+
const start = header.index ?? 0;
|
|
122
|
+
const end = headers[index + 1]?.index ?? text.length;
|
|
123
|
+
const sender = header[1];
|
|
124
|
+
if (sender !== undefined && FINISHED_ENTRY_RE.test(text.slice(start, end)))
|
|
125
|
+
senders.push(sender);
|
|
126
|
+
}
|
|
127
|
+
return senders;
|
|
128
|
+
}
|
|
110
129
|
function inboxSummary(body) {
|
|
111
|
-
const headers = [...body.matchAll(
|
|
130
|
+
const headers = [...body.matchAll(INBOX_HEADERS_RE)];
|
|
112
131
|
if (headers.length === 0)
|
|
113
132
|
return 'node update';
|
|
114
133
|
const updates = headers.reduce((total, match) => total + Number(match[2] ?? 0), 0);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** Broker-generated activity recap for one completed tool-call group. Changed
|
|
2
|
-
* files are counted deterministically by the viewer's
|
|
3
|
-
* deletions have no tool of their own, so only the model
|
|
2
|
+
* files and spawned/finished nodes are counted deterministically by the viewer's
|
|
3
|
+
* GroupActivityRecorder; deletions have no tool of their own, so only the model
|
|
4
|
+
* can evidence them. */
|
|
4
5
|
export interface ToolGroupSummary {
|
|
5
6
|
bullets: string[];
|
|
6
|
-
nodesSpawned: number;
|
|
7
7
|
filesDeleted: number;
|
|
8
8
|
}
|
|
9
9
|
export interface ToolGroupMessageLike {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Keep this independent of renderer state: both live event tracking and snapshot
|
|
3
3
|
// reconstruction must agree on which prose closes a run of tools.
|
|
4
4
|
import { generatedContextPresentation, generatedContextText } from './generated-context.js';
|
|
5
|
-
const TOOL_GROUP_SUMMARY_KEYS = new Set(['bullets', '
|
|
5
|
+
const TOOL_GROUP_SUMMARY_KEYS = new Set(['bullets', 'filesDeleted']);
|
|
6
6
|
const MAX_TOOL_GROUP_BULLETS = 5;
|
|
7
7
|
const MAX_TOOL_GROUP_BULLET_LENGTH = 1_000;
|
|
8
8
|
/** Validate the one durable summary shape and cap its display-sized fields.
|
|
@@ -19,15 +19,12 @@ export function normalizeToolGroupSummary(value) {
|
|
|
19
19
|
return null;
|
|
20
20
|
if (!record.bullets.every((bullet) => typeof bullet === 'string' && bullet.trim() !== ''))
|
|
21
21
|
return null;
|
|
22
|
-
if (typeof record.nodesSpawned !== 'number' || !Number.isSafeInteger(record.nodesSpawned) || record.nodesSpawned < 0)
|
|
23
|
-
return null;
|
|
24
22
|
if (typeof record.filesDeleted !== 'number' || !Number.isSafeInteger(record.filesDeleted) || record.filesDeleted < 0)
|
|
25
23
|
return null;
|
|
26
24
|
return {
|
|
27
25
|
bullets: record.bullets
|
|
28
26
|
.slice(0, MAX_TOOL_GROUP_BULLETS)
|
|
29
27
|
.map((bullet) => bullet.trim().slice(0, MAX_TOOL_GROUP_BULLET_LENGTH).trimEnd()),
|
|
30
|
-
nodesSpawned: record.nodesSpawned,
|
|
31
28
|
filesDeleted: record.filesDeleted,
|
|
32
29
|
};
|
|
33
30
|
}
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.184",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.184",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -40,7 +40,7 @@ async function main() {
|
|
|
40
40
|
console.warn('[crtr postinstall] runtime installation failed (non-fatal); no runtime generation was produced. Run `crtr` for remediation guidance or reinstall:', error?.message ?? error);
|
|
41
41
|
}
|
|
42
42
|
await provisionRenderer();
|
|
43
|
-
console.log('crouter installed. Run `crtr sys setup` to install the companion pi extensions, Claude OAuth, tmux/nvim
|
|
43
|
+
console.log('crouter installed. Run `crtr sys setup` to install the companion pi extensions, Claude OAuth, and tmux/nvim.');
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
await main();
|
|
@@ -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;
|