agent-sh 0.14.11 → 0.15.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 +38 -42
- package/dist/agent/agent-loop.d.ts +9 -17
- package/dist/agent/agent-loop.js +104 -136
- package/dist/agent/events.d.ts +8 -11
- package/dist/agent/host-types.d.ts +17 -11
- package/dist/agent/index.d.ts +1 -1
- package/dist/agent/index.js +38 -22
- package/dist/agent/providers/deepseek.js +9 -1
- package/dist/agent/session-store.js +1 -1
- package/dist/agent/system-prompt.d.ts +7 -3
- package/dist/agent/system-prompt.js +11 -14
- package/dist/agent/tool-protocol.js +0 -7
- package/dist/cli/args.js +2 -1
- package/dist/cli/install.d.ts +1 -0
- package/dist/cli/install.js +29 -1
- package/dist/cli/subcommands.js +1 -0
- package/dist/core/event-bus.js +0 -2
- package/dist/core/extension-loader.js +3 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +3 -2
- package/dist/extensions/slash-commands/index.js +16 -11
- package/dist/shell/index.js +9 -0
- package/dist/shell/shell-context.d.ts +2 -2
- package/dist/shell/shell-context.js +26 -11
- package/dist/shell/tui-renderer.js +0 -1
- package/dist/utils/diff-renderer.js +2 -9
- package/dist/utils/handler-registry.d.ts +1 -6
- package/dist/utils/handler-registry.js +1 -6
- package/dist/utils/line-editor.js +0 -2
- package/dist/utils/palette.js +4 -4
- package/dist/utils/terminal-buffer.d.ts +2 -0
- package/dist/utils/terminal-buffer.js +4 -0
- package/examples/extensions/ash-acp-bridge/src/index.ts +11 -7
- package/examples/extensions/ash-scheme/index.ts +104 -74
- package/examples/extensions/ashi/EXTENDING.md +2 -0
- package/examples/extensions/ashi/README.md +17 -1
- package/examples/extensions/ashi/docs/ui-surface-protocol.md +163 -0
- package/examples/extensions/ashi/package.json +9 -1
- package/examples/extensions/ashi/src/capture.ts +45 -7
- package/examples/extensions/ashi/src/chat/assistant.ts +23 -43
- package/examples/extensions/ashi/src/chat/lines.ts +20 -1
- package/examples/extensions/ashi/src/cli.ts +25 -3
- package/examples/extensions/ashi/src/clipboard-image.ts +1 -1
- package/examples/extensions/ashi/src/dialogs.ts +67 -0
- package/examples/extensions/ashi/src/display-config.ts +7 -0
- package/examples/extensions/ashi/src/docks.ts +31 -0
- package/examples/extensions/ashi/src/events.ts +16 -0
- package/examples/extensions/ashi/src/frontend.ts +134 -27
- package/examples/extensions/ashi/src/hooks.ts +6 -12
- package/examples/extensions/ashi/src/input-prompt.ts +64 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/index.ts +7 -3
- package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +67 -10
- package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +11 -1
- package/examples/extensions/ashi/src/schema.ts +3 -0
- package/examples/extensions/ashi/src/session-commands.ts +2 -1
- package/examples/extensions/ashi/src/status-footer.ts +21 -3
- package/examples/extensions/ashi/src/ui.ts +88 -0
- package/examples/extensions/ashi-ink/README.md +2 -0
- package/examples/extensions/ashi-scheme-render.ts +8 -2
- package/examples/extensions/ashi-ui-demo.ts +63 -0
- package/examples/extensions/latex-images.ts +57 -9
- package/examples/extensions/overlay-agent.ts +5 -5
- package/examples/extensions/pi-bridge/index.ts +7 -12
- package/package.json +1 -1
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { getSettings } from "../core/settings.js";
|
|
2
2
|
import { spillOutput } from "../utils/shell-output-spill.js";
|
|
3
|
+
// The cwd-drift note applies only under the shell frontend (where the agent shares
|
|
4
|
+
// the user's cwd); other frontends own a fixed cwd.
|
|
5
|
+
const SHELL_EVENTS_NOTE = `When the user runs shell commands, they appear as \`<shell_events>\` inside \`<query_context>\` on your next turn — use them to ground "fix this" / "what just happened" requests.`;
|
|
6
|
+
const CWD_DRIFT_NOTE = `\`<cwd>\` is the working directory your own tool calls run in: relative paths resolve against it, and it follows the user's shell \`cd\`, so it can change from one turn to the next. Always act on the latest \`<cwd>\`, not one from earlier in the conversation.`;
|
|
7
|
+
const PREFERENCES_NOTE = `Treat the user's commands as standing preferences: check them for recurring patterns and apply them proactively, without waiting to be asked.`;
|
|
3
8
|
export default function activate(ctx) {
|
|
4
9
|
const { bus } = ctx;
|
|
10
|
+
// The agent shares the user's cwd only under the shell frontend (which installs
|
|
11
|
+
// ctx.shell); other frontends — e.g. ashi — keep their own fixed cwd.
|
|
12
|
+
const ownsAgentCwd = !!ctx.shell;
|
|
5
13
|
const exchanges = [];
|
|
6
14
|
let nextId = 1;
|
|
7
15
|
let currentCwd = process.cwd();
|
|
@@ -50,23 +58,30 @@ export default function activate(ctx) {
|
|
|
50
58
|
bus.on("shell:agent-exec-start", () => { agentShellActive = true; });
|
|
51
59
|
bus.on("shell:agent-exec-done", () => { agentShellActive = false; });
|
|
52
60
|
bus.on("shell:user-exec-exclude-next", () => { nextUserExcluded = true; });
|
|
53
|
-
|
|
61
|
+
if (ownsAgentCwd)
|
|
62
|
+
ctx.advise("cwd", () => currentCwd);
|
|
54
63
|
// Advise the core handler directly: this loads before the agent host
|
|
55
64
|
// attaches `ctx.agent`, so the sugar isn't available yet.
|
|
56
65
|
ctx.advise("query-context:build", (next) => {
|
|
57
66
|
const base = next();
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (fresh.length === 0)
|
|
62
|
-
return cwdTag;
|
|
67
|
+
const fresh = exchanges.filter((ex) => ex.id > lastSeq && ex.source === "user");
|
|
68
|
+
let shellEvents = "";
|
|
69
|
+
if (fresh.length > 0) {
|
|
63
70
|
lastSeq = exchanges[exchanges.length - 1].id;
|
|
64
71
|
const text = fresh.map(formatExchangeTruncated).filter(Boolean).join("\n");
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
if (text)
|
|
73
|
+
shellEvents = `<shell_events>\n${text}\n</shell_events>`;
|
|
74
|
+
}
|
|
75
|
+
const part = ownsAgentCwd
|
|
76
|
+
? [`<cwd>${currentCwd}</cwd>`, shellEvents].filter(Boolean).join("\n")
|
|
77
|
+
: shellEvents;
|
|
78
|
+
return [base, part].filter(Boolean).join("\n\n");
|
|
79
|
+
});
|
|
80
|
+
bus.onPipe("agent:instructions", (acc) => {
|
|
81
|
+
const text = [SHELL_EVENTS_NOTE, ownsAgentCwd ? CWD_DRIFT_NOTE : "", PREFERENCES_NOTE]
|
|
82
|
+
.filter(Boolean).join("\n\n");
|
|
83
|
+
acc.instructions.push({ name: "shell-events", text });
|
|
84
|
+
return acc;
|
|
70
85
|
});
|
|
71
86
|
ctx.define("shell:context-recent", (n = 25) => {
|
|
72
87
|
const recent = exchanges.slice(-n);
|
|
@@ -176,17 +176,14 @@ function findChangePairs(hunk) {
|
|
|
176
176
|
const lines = hunk.lines;
|
|
177
177
|
let i = 0;
|
|
178
178
|
while (i < lines.length) {
|
|
179
|
-
// Find a run of removed lines
|
|
180
179
|
const removedStart = i;
|
|
181
180
|
while (i < lines.length && lines[i].type === "removed")
|
|
182
181
|
i++;
|
|
183
182
|
const removedEnd = i;
|
|
184
|
-
// Find a run of added lines immediately after
|
|
185
183
|
const addedStart = i;
|
|
186
184
|
while (i < lines.length && lines[i].type === "added")
|
|
187
185
|
i++;
|
|
188
186
|
const addedEnd = i;
|
|
189
|
-
// Pair them 1:1
|
|
190
187
|
const removedCount = removedEnd - removedStart;
|
|
191
188
|
const addedCount = addedEnd - addedStart;
|
|
192
189
|
const pairCount = Math.min(removedCount, addedCount);
|
|
@@ -254,7 +251,7 @@ function renderUnifiedHunk(hunk, layout) {
|
|
|
254
251
|
const gutter = (n) => `${p.dim}${n} │${p.reset} `;
|
|
255
252
|
const change = (no, sigil, bg, fg, text) => {
|
|
256
253
|
if (!gutterLine) {
|
|
257
|
-
return `${bg}${padToWidth(`${no} ${
|
|
254
|
+
return `${bg}${fg}${padToWidth(`${no} ${sigil} ${preserveBg(text, bg)}`, textWidth)}${p.reset}`;
|
|
258
255
|
}
|
|
259
256
|
if (useTrueColor)
|
|
260
257
|
return gutter(no) + padToWidth(`${bg}${fg}${sigil} ${preserveBg(text, bg)}`, bgWidth) + p.reset;
|
|
@@ -267,7 +264,7 @@ function renderUnifiedHunk(hunk, layout) {
|
|
|
267
264
|
const raw = truncateText(line.text, lineTextW);
|
|
268
265
|
const text = lang ? highlightLine(raw, lang) : raw;
|
|
269
266
|
// The flush gutter dims only the line number; the code stays normal/highlighted.
|
|
270
|
-
out.push(!gutterLine ? `${p.dim}${no}${p.reset}
|
|
267
|
+
out.push(!gutterLine ? `${p.dim}${no}${p.reset} ${text}` : `${gutter(no)} ${p.dim}${text}${p.reset}`);
|
|
271
268
|
continue;
|
|
272
269
|
}
|
|
273
270
|
if (line.type === "removed") {
|
|
@@ -418,19 +415,16 @@ function buildSplitRows(hunk) {
|
|
|
418
415
|
i++;
|
|
419
416
|
continue;
|
|
420
417
|
}
|
|
421
|
-
// Collect a run of removed lines
|
|
422
418
|
const removed = [];
|
|
423
419
|
while (i < lines.length && lines[i].type === "removed") {
|
|
424
420
|
removed.push(lines[i]);
|
|
425
421
|
i++;
|
|
426
422
|
}
|
|
427
|
-
// Collect a run of added lines
|
|
428
423
|
const added = [];
|
|
429
424
|
while (i < lines.length && lines[i].type === "added") {
|
|
430
425
|
added.push(lines[i]);
|
|
431
426
|
i++;
|
|
432
427
|
}
|
|
433
|
-
// Pair them side by side
|
|
434
428
|
const maxLen = Math.max(removed.length, added.length);
|
|
435
429
|
for (let k = 0; k < maxLen; k++) {
|
|
436
430
|
rows.push({
|
|
@@ -517,7 +511,6 @@ function trimHunksToFit(hunks, maxLines) {
|
|
|
517
511
|
changeCount++;
|
|
518
512
|
}
|
|
519
513
|
}
|
|
520
|
-
// Separators between hunks
|
|
521
514
|
const separators = Math.max(0, hunks.length - 1);
|
|
522
515
|
// How many context lines can we afford?
|
|
523
516
|
const contextBudget = Math.max(0, maxLines - changeCount - separators);
|
|
@@ -50,13 +50,8 @@ export declare class HandlerRegistry {
|
|
|
50
50
|
* Returns undefined if no handler is registered.
|
|
51
51
|
*/
|
|
52
52
|
call(name: string, ...args: any[]): any;
|
|
53
|
-
/**
|
|
54
|
-
* Check if a named handler exists.
|
|
55
|
-
*/
|
|
56
53
|
has(name: string): boolean;
|
|
57
|
-
/**
|
|
58
|
-
* Names of all registered handlers. For diagnostic/introspection use.
|
|
59
|
-
*/
|
|
54
|
+
/** Names of all registered handlers — for diagnostics/introspection. */
|
|
60
55
|
list(): string[];
|
|
61
56
|
}
|
|
62
57
|
export {};
|
|
@@ -79,15 +79,10 @@ export class HandlerRegistry {
|
|
|
79
79
|
}
|
|
80
80
|
return fn(...args);
|
|
81
81
|
}
|
|
82
|
-
/**
|
|
83
|
-
* Check if a named handler exists.
|
|
84
|
-
*/
|
|
85
82
|
has(name) {
|
|
86
83
|
return this.entries.has(name);
|
|
87
84
|
}
|
|
88
|
-
/**
|
|
89
|
-
* Names of all registered handlers. For diagnostic/introspection use.
|
|
90
|
-
*/
|
|
85
|
+
/** Names of all registered handlers — for diagnostics/introspection. */
|
|
91
86
|
list() {
|
|
92
87
|
return [...this.entries.keys()];
|
|
93
88
|
}
|
|
@@ -309,11 +309,9 @@ export class LineEditor {
|
|
|
309
309
|
pushHistory(line) {
|
|
310
310
|
if (!line.trim())
|
|
311
311
|
return;
|
|
312
|
-
// Deduplicate: remove if already at top
|
|
313
312
|
if (this.history.length > 0 && this.history[0] === line)
|
|
314
313
|
return;
|
|
315
314
|
this.history.unshift(line);
|
|
316
|
-
// Cap history size
|
|
317
315
|
if (this.history.length > 100)
|
|
318
316
|
this.history.pop();
|
|
319
317
|
}
|
package/dist/utils/palette.js
CHANGED
|
@@ -14,10 +14,10 @@ const defaultPalette = {
|
|
|
14
14
|
warning: "\x1b[33m", // yellow
|
|
15
15
|
error: "\x1b[31m", // red
|
|
16
16
|
muted: "\x1b[90m", // gray
|
|
17
|
-
successBg: "\x1b[48;2;
|
|
18
|
-
errorBg: "\x1b[48;2;
|
|
19
|
-
successBgEmph: "\x1b[48;2;
|
|
20
|
-
errorBgEmph: "\x1b[48;2;
|
|
17
|
+
successBg: "\x1b[48;2;34;92;43m",
|
|
18
|
+
errorBg: "\x1b[48;2;122;41;54m",
|
|
19
|
+
successBgEmph: "\x1b[48;2;56;166;96m",
|
|
20
|
+
errorBgEmph: "\x1b[48;2;179;89;107m",
|
|
21
21
|
bold: "\x1b[1m",
|
|
22
22
|
dim: "\x1b[2m",
|
|
23
23
|
italic: "\x1b[3m",
|
|
@@ -47,6 +47,8 @@ export declare class TerminalBuffer {
|
|
|
47
47
|
readScreen(opts?: {
|
|
48
48
|
includeScrollback?: boolean;
|
|
49
49
|
}): ScreenSnapshot;
|
|
50
|
+
/** Read the screen and wrap it as a `<terminal_buffer>` context block. */
|
|
51
|
+
formatScreen(maxLines?: number, baseContext?: string): string;
|
|
50
52
|
/**
|
|
51
53
|
* Get terminal screen as lines, padded/trimmed to exactly `rows` lines.
|
|
52
54
|
* Clean text only (ANSI stripped). Reads from the active buffer's
|
|
@@ -111,6 +111,10 @@ export class TerminalBuffer {
|
|
|
111
111
|
cursorY: buf.cursorY,
|
|
112
112
|
};
|
|
113
113
|
}
|
|
114
|
+
/** Read the screen and wrap it as a `<terminal_buffer>` context block. */
|
|
115
|
+
formatScreen(maxLines, baseContext) {
|
|
116
|
+
return formatScreenContext(this.readScreen(), maxLines, baseContext);
|
|
117
|
+
}
|
|
114
118
|
/**
|
|
115
119
|
* Get terminal screen as lines, padded/trimmed to exactly `rows` lines.
|
|
116
120
|
* Clean text only (ANSI stripped). Reads from the active buffer's
|
|
@@ -434,10 +434,10 @@ function waitForModelsToSettle(
|
|
|
434
434
|
timer = setTimeout(done, Math.max(0, Math.min(quietMs, remaining)));
|
|
435
435
|
};
|
|
436
436
|
const done = () => {
|
|
437
|
-
core.bus.off("agent:
|
|
437
|
+
core.bus.off("agent:models-changed", arm);
|
|
438
438
|
resolve();
|
|
439
439
|
};
|
|
440
|
-
core.bus.on("agent:
|
|
440
|
+
core.bus.on("agent:models-changed", arm);
|
|
441
441
|
arm();
|
|
442
442
|
});
|
|
443
443
|
}
|
|
@@ -446,15 +446,14 @@ function getModelsPayload(): Record<string, unknown> | undefined {
|
|
|
446
446
|
if (!core) return undefined;
|
|
447
447
|
const info = core.bus.emitPipe("config:get-models", { models: [], active: null });
|
|
448
448
|
if (!info.models.length) return undefined;
|
|
449
|
-
const idFor = (m: {
|
|
450
|
-
m.provider ? `${m.model}@${m.provider}` : m.model;
|
|
449
|
+
const idFor = (m: { id: string; provider: string }) => `${m.id}@${m.provider}`;
|
|
451
450
|
const current = info.active ?? info.models[0]!;
|
|
452
451
|
return {
|
|
453
452
|
currentModelId: idFor(current),
|
|
454
453
|
availableModels: info.models.map((m) => ({
|
|
455
454
|
modelId: idFor(m),
|
|
456
|
-
name:
|
|
457
|
-
description:
|
|
455
|
+
name: `${m.provider}/${m.id}`,
|
|
456
|
+
description: `Provider: ${m.provider}`,
|
|
458
457
|
})),
|
|
459
458
|
};
|
|
460
459
|
}
|
|
@@ -601,7 +600,12 @@ function dispatch(msg: JsonRpcRequest): void {
|
|
|
601
600
|
break;
|
|
602
601
|
case "session/set_model":
|
|
603
602
|
if (core && params?.modelId) {
|
|
604
|
-
|
|
603
|
+
const raw = params.modelId as string;
|
|
604
|
+
const at = raw.lastIndexOf("@");
|
|
605
|
+
core.bus.emit("config:switch-model", {
|
|
606
|
+
id: at > 0 ? raw.slice(0, at) : raw,
|
|
607
|
+
provider: at > 0 ? raw.slice(at + 1) : "",
|
|
608
|
+
});
|
|
605
609
|
}
|
|
606
610
|
sendResult(id!, {
|
|
607
611
|
models: getModelsPayload() ?? {},
|
|
@@ -27,7 +27,7 @@ async function withDisplay(
|
|
|
27
27
|
): Promise<ToolResult> {
|
|
28
28
|
const toolCallId = `scheme-${toolName}-${++callCounter}`;
|
|
29
29
|
bus.emit("agent:tool-started", {
|
|
30
|
-
title: toolName, toolCallId, kind, rawInput, displayDetail,
|
|
30
|
+
title: toolName, toolCallId, kind, rawInput, displayDetail, nested: true,
|
|
31
31
|
});
|
|
32
32
|
const result = await run();
|
|
33
33
|
// Stream the result so the TUI's tracked `output` holds it, not just the summary.
|
|
@@ -38,6 +38,7 @@ async function withDisplay(
|
|
|
38
38
|
rawOutput: result.content,
|
|
39
39
|
kind,
|
|
40
40
|
resultDisplay: result.display,
|
|
41
|
+
nested: true,
|
|
41
42
|
});
|
|
42
43
|
return result;
|
|
43
44
|
}
|
|
@@ -1383,23 +1384,23 @@ function unwrapSchemeBool(v: any): any {
|
|
|
1383
1384
|
// plain bash tool call drops it before the model ever sees it).
|
|
1384
1385
|
type HostSig = { name: string; sig: string; ret: string; doc: string };
|
|
1385
1386
|
const HOST_SIGS: HostSig[] = [
|
|
1386
|
-
{ name: "bash", sig: '(bash "cmd" [timeout
|
|
1387
|
+
{ name: "bash", sig: '(bash "cmd" [:timeout sec])',
|
|
1387
1388
|
ret: "((output . str) (exit-code . n) (error . bool))",
|
|
1388
1389
|
doc: "run a shell command; full result. Accessors: output-of exit-code-of ok? error?" },
|
|
1389
|
-
{ name: "sh", sig: '(sh "cmd" [timeout
|
|
1390
|
+
{ name: "sh", sig: '(sh "cmd" [:timeout sec])', ret: "str",
|
|
1390
1391
|
doc: "run a shell command, return stdout only (stderr text on failure)" },
|
|
1391
|
-
{ name: "read-file", sig: '(read-file "path" [offset] [limit])', ret: "str | #f",
|
|
1392
|
-
doc: "file contents, or #f on error. offset is 1-indexed; limit caps lines" },
|
|
1392
|
+
{ name: "read-file", sig: '(read-file "path" [:offset n] [:limit n])', ret: "str | #f",
|
|
1393
|
+
doc: "file contents, or #f on error. :offset is 1-indexed; :limit caps lines" },
|
|
1393
1394
|
{ name: "write-file", sig: '(write-file "path" "content")', ret: "#t | err-str",
|
|
1394
1395
|
doc: "overwrite a file" },
|
|
1395
|
-
{ name: "edit-file", sig: '(edit-file "path" "old" "new" [replace-all])', ret: "#t | err-str",
|
|
1396
|
-
doc: "replace exact text;
|
|
1396
|
+
{ name: "edit-file", sig: '(edit-file "path" "old" "new" [:replace-all #t])', ret: "#t | err-str",
|
|
1397
|
+
doc: "replace exact text; :replace-all #t replaces every occurrence" },
|
|
1397
1398
|
{ name: "grep", sig: '(grep "pat" ["dir"] [:opt val …])',
|
|
1398
1399
|
ret: "(listof ((file . str) (line . n) (text . str)))",
|
|
1399
|
-
doc: "ripgrep search. options: :include :case-insensitive :context-before :context-after :limit :offset" },
|
|
1400
|
+
doc: "ripgrep search. options: :path :include :case-insensitive :context-before :context-after :limit :offset" },
|
|
1400
1401
|
{ name: "grep-files", sig: '(grep-files "pat" ["dir"] [:opt val …])', ret: "(listof str)",
|
|
1401
|
-
doc: "files containing a match. options: :include :case-insensitive :limit :offset" },
|
|
1402
|
-
{ name: "glob", sig: '(glob "pat" ["dir"])', ret: "(listof str)",
|
|
1402
|
+
doc: "files containing a match. options: :path :include :case-insensitive :limit :offset" },
|
|
1403
|
+
{ name: "glob", sig: '(glob "pat" [:path "dir"])', ret: "(listof str)",
|
|
1403
1404
|
doc: "paths matching a glob, mtime-sorted" },
|
|
1404
1405
|
];
|
|
1405
1406
|
const sigLine = (h: HostSig): string => `${h.sig} → ${h.ret}`;
|
|
@@ -1429,10 +1430,8 @@ const isKwSym = (x: any): boolean => {
|
|
|
1429
1430
|
return typeof n === "string" && n.startsWith(":");
|
|
1430
1431
|
};
|
|
1431
1432
|
|
|
1432
|
-
// Split a
|
|
1433
|
-
//
|
|
1434
|
-
// quote leading-colon symbols (otherwise they'd be looked up as unbound
|
|
1435
|
-
// variables); they arrive here as plain LSymbols named ":foo".
|
|
1433
|
+
// Split a primitive's args into leading positionals and a trailing :key value
|
|
1434
|
+
// option map. An unknown key throws so a wrong option name teaches the valid set.
|
|
1436
1435
|
function splitArgs(
|
|
1437
1436
|
args: any[], keyMap: Record<string, string>, numericKeys?: Set<string>,
|
|
1438
1437
|
): { positionals: any[]; opts: Record<string, unknown> } {
|
|
@@ -1442,22 +1441,30 @@ function splitArgs(
|
|
|
1442
1441
|
const opts: Record<string, unknown> = {};
|
|
1443
1442
|
while (i < args.length) {
|
|
1444
1443
|
if (!isKwSym(args[i])) { i++; continue; }
|
|
1445
|
-
const
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
: unwrapSchemeBool(toJsStr(raw));
|
|
1444
|
+
const key = symName(args[i])!.slice(1);
|
|
1445
|
+
const tgt = keyMap[key];
|
|
1446
|
+
if (tgt === undefined) {
|
|
1447
|
+
const valid = Object.keys(keyMap).map((k) => `:${k}`).join(" ");
|
|
1448
|
+
throw new Error(`unknown option :${key}; valid options: ${valid || "(none)"}`);
|
|
1451
1449
|
}
|
|
1450
|
+
const raw = args[i + 1];
|
|
1451
|
+
opts[tgt] = numericKeys && numericKeys.has(tgt)
|
|
1452
|
+
? Number(raw)
|
|
1453
|
+
: unwrapSchemeBool(toJsStr(raw));
|
|
1452
1454
|
i += 2;
|
|
1453
1455
|
}
|
|
1454
1456
|
return { positionals, opts };
|
|
1455
1457
|
}
|
|
1456
1458
|
|
|
1459
|
+
// Cache executors on globalThis (survives reload's module-cache bust): in
|
|
1460
|
+
// schemeOnly the built-ins get unregistered, but the tool objects outlive it.
|
|
1461
|
+
const EXECUTOR_CACHE: Record<string, ToolExecutor> =
|
|
1462
|
+
((globalThis as any).__ashSchemeExecutors ??= {});
|
|
1457
1463
|
function resolveExecutor(ctx: AgentContext, name: string): ToolExecutor {
|
|
1458
1464
|
const tool = ctx.agent.getTools().find((t) => t.name === name);
|
|
1459
|
-
if (
|
|
1460
|
-
|
|
1465
|
+
if (tool) return (EXECUTOR_CACHE[name] = (args) => tool.execute(args));
|
|
1466
|
+
if (EXECUTOR_CACHE[name]) return EXECUTOR_CACHE[name];
|
|
1467
|
+
throw new Error(`scheme bridge: tool '${name}' not registered`);
|
|
1461
1468
|
}
|
|
1462
1469
|
|
|
1463
1470
|
function installBindings(
|
|
@@ -1470,6 +1477,35 @@ function installBindings(
|
|
|
1470
1477
|
grep: ToolExecutor | null,
|
|
1471
1478
|
glob: ToolExecutor | null,
|
|
1472
1479
|
): void {
|
|
1480
|
+
// Optional args are :key value pairs; a trailing positional is still accepted.
|
|
1481
|
+
const READ_KEYMAP: Record<string, string> = { "offset": "offset", "limit": "limit" };
|
|
1482
|
+
const READ_NUMERIC = new Set(["offset", "limit"]);
|
|
1483
|
+
const BASH_KEYMAP: Record<string, string> = { "timeout": "timeout" };
|
|
1484
|
+
const BASH_NUMERIC = new Set(["timeout"]);
|
|
1485
|
+
const EDIT_KEYMAP: Record<string, string> = { "replace-all": "replace_all" };
|
|
1486
|
+
const GLOB_KEYMAP: Record<string, string> = { "path": "path" };
|
|
1487
|
+
const GREP_KEYMAP: Record<string, string> = {
|
|
1488
|
+
"include": "include",
|
|
1489
|
+
"case-insensitive": "case_insensitive",
|
|
1490
|
+
"context-before": "context_before",
|
|
1491
|
+
"context-after": "context_after",
|
|
1492
|
+
"limit": "head_limit",
|
|
1493
|
+
"offset": "offset",
|
|
1494
|
+
"path": "path",
|
|
1495
|
+
};
|
|
1496
|
+
const GREP_NUMERIC = new Set([
|
|
1497
|
+
"context_before", "context_after", "head_limit", "offset",
|
|
1498
|
+
]);
|
|
1499
|
+
|
|
1500
|
+
// LIPS has no keyword-argument syntax: bind each option key to a self-quoting
|
|
1501
|
+
// symbol so a bare `:offset` yields the symbol instead of an unbound error.
|
|
1502
|
+
for (const km of [READ_KEYMAP, BASH_KEYMAP, EDIT_KEYMAP, GLOB_KEYMAP, GREP_KEYMAP]) {
|
|
1503
|
+
for (const key of Object.keys(km)) {
|
|
1504
|
+
const kw = `:${key}`;
|
|
1505
|
+
env.set(kw, new LSymbol(kw));
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1473
1509
|
const runBash = async (command: string, timeoutSec?: number) => {
|
|
1474
1510
|
const args: Record<string, unknown> = { command: toJsStr(command) };
|
|
1475
1511
|
if (typeof timeoutSec === "number") args.timeout = timeoutSec;
|
|
@@ -1483,9 +1519,17 @@ function installBindings(
|
|
|
1483
1519
|
// carries it: a plain bash tool call drops it before the model.
|
|
1484
1520
|
return { exitCode: result.exitCode ?? (result.isError ? 1 : 0), output, error: result.isError };
|
|
1485
1521
|
};
|
|
1486
|
-
|
|
1522
|
+
const bashTimeout = (positionals: any[], opts: Record<string, unknown>): number | undefined => {
|
|
1523
|
+
const t = opts.timeout !== undefined ? opts.timeout : positionals[1];
|
|
1524
|
+
if (t === undefined || t === null) return undefined;
|
|
1525
|
+
const n = Number(t);
|
|
1526
|
+
return isNaN(n) ? undefined : n;
|
|
1527
|
+
};
|
|
1528
|
+
env.set("bash", withSig("bash", async (...rest: any[]) => {
|
|
1529
|
+
const { positionals, opts } = splitArgs(rest, BASH_KEYMAP, BASH_NUMERIC);
|
|
1530
|
+
const command = positionals[0];
|
|
1487
1531
|
try {
|
|
1488
|
-
const r = await runBash(command,
|
|
1532
|
+
const r = await runBash(command, bashTimeout(positionals, opts));
|
|
1489
1533
|
return alist([
|
|
1490
1534
|
["output", r.output],
|
|
1491
1535
|
["exit-code", r.exitCode],
|
|
@@ -1498,17 +1542,22 @@ function installBindings(
|
|
|
1498
1542
|
}));
|
|
1499
1543
|
// Shortcut: stdout as a string. Use `bash` when you need the exit code, or
|
|
1500
1544
|
// `(ok? (bash "…"))` for a success predicate.
|
|
1501
|
-
env.set("sh", withSig("sh", async (
|
|
1545
|
+
env.set("sh", withSig("sh", async (...rest: any[]) => {
|
|
1546
|
+
const { positionals, opts } = splitArgs(rest, BASH_KEYMAP, BASH_NUMERIC);
|
|
1547
|
+
const command = positionals[0];
|
|
1502
1548
|
try {
|
|
1503
|
-
return (await runBash(command,
|
|
1549
|
+
return (await runBash(command, bashTimeout(positionals, opts))).output;
|
|
1504
1550
|
} catch (e: any) {
|
|
1505
1551
|
logErr("sh", e, { command });
|
|
1506
1552
|
return "";
|
|
1507
1553
|
}
|
|
1508
1554
|
}));
|
|
1509
1555
|
|
|
1510
|
-
env.set("read-file", withSig("read-file", async (
|
|
1511
|
-
const
|
|
1556
|
+
env.set("read-file", withSig("read-file", async (...rest: any[]) => {
|
|
1557
|
+
const { positionals, opts } = splitArgs(rest, READ_KEYMAP, READ_NUMERIC);
|
|
1558
|
+
const args: Record<string, unknown> = { path: toJsStr(positionals[0]), bypass_cache: true };
|
|
1559
|
+
const offset = opts.offset !== undefined ? opts.offset : positionals[1];
|
|
1560
|
+
const limit = opts.limit !== undefined ? opts.limit : positionals[2];
|
|
1512
1561
|
if (offset !== undefined && offset !== null) {
|
|
1513
1562
|
const n = Number(offset);
|
|
1514
1563
|
if (!isNaN(n)) args.offset = n;
|
|
@@ -1532,8 +1581,12 @@ function installBindings(
|
|
|
1532
1581
|
}));
|
|
1533
1582
|
|
|
1534
1583
|
if (editFile) {
|
|
1535
|
-
env.set("edit-file", withSig("edit-file", async (
|
|
1536
|
-
|
|
1584
|
+
env.set("edit-file", withSig("edit-file", async (...rest: any[]) => {
|
|
1585
|
+
const { positionals, opts } = splitArgs(rest, EDIT_KEYMAP);
|
|
1586
|
+
const filePath = toJsStr(positionals[0]);
|
|
1587
|
+
const oldStr = toJsStr(positionals[1]);
|
|
1588
|
+
const newStr = toJsStr(positionals[2]);
|
|
1589
|
+
const replaceAll = opts.replace_all !== undefined ? opts.replace_all : positionals[3];
|
|
1537
1590
|
const toolArgs: Record<string, unknown> = { path: filePath, old_text: oldStr, new_text: newStr };
|
|
1538
1591
|
if (unwrapSchemeBool(replaceAll) === true) toolArgs.replace_all = true;
|
|
1539
1592
|
const result = await withDisplay(
|
|
@@ -1545,18 +1598,6 @@ function installBindings(
|
|
|
1545
1598
|
}
|
|
1546
1599
|
|
|
1547
1600
|
if (grep) {
|
|
1548
|
-
const GREP_KEYMAP: Record<string, string> = {
|
|
1549
|
-
"include": "include",
|
|
1550
|
-
"case-insensitive": "case_insensitive",
|
|
1551
|
-
"context-before": "context_before",
|
|
1552
|
-
"context-after": "context_after",
|
|
1553
|
-
"limit": "head_limit",
|
|
1554
|
-
"offset": "offset",
|
|
1555
|
-
};
|
|
1556
|
-
const GREP_NUMERIC = new Set([
|
|
1557
|
-
"context_before", "context_after", "head_limit", "offset",
|
|
1558
|
-
]);
|
|
1559
|
-
|
|
1560
1601
|
// Ripgrep uses Rust/ERE regex, but models write BRE (the default flavor
|
|
1561
1602
|
// of plain grep/sed) where \| \( \) \{ \} \+ \? are metacharacters.
|
|
1562
1603
|
// Translate BRE escapes to their ERE equivalents so the model's intent is
|
|
@@ -1569,33 +1610,33 @@ function installBindings(
|
|
|
1569
1610
|
.replace(/\\\+/g, "+").replace(/\\\?/g, "?");
|
|
1570
1611
|
};
|
|
1571
1612
|
|
|
1572
|
-
// pattern
|
|
1573
|
-
|
|
1574
|
-
env.set("%grep", withSig("grep", async (...rest: any[]) => {
|
|
1613
|
+
// pattern is positional; search root is the 2nd positional or :path.
|
|
1614
|
+
env.set("grep", withSig("grep", async (...rest: any[]) => {
|
|
1575
1615
|
const { positionals, opts } = splitArgs(rest, GREP_KEYMAP, GREP_NUMERIC);
|
|
1576
|
-
const pStr = toJsStr(positionals[1]);
|
|
1577
1616
|
const args: Record<string, unknown> = {
|
|
1578
1617
|
pattern: normalizePattern(String(positionals[0] ?? "")), output_mode: "content", ...opts,
|
|
1579
1618
|
};
|
|
1580
|
-
|
|
1619
|
+
const posPath = toJsStr(positionals[1]);
|
|
1620
|
+
if (args.path === undefined && typeof posPath === "string") args.path = posPath;
|
|
1621
|
+
const pStr = typeof args.path === "string" ? args.path : undefined;
|
|
1581
1622
|
const result = await grep(args);
|
|
1582
1623
|
if (result.isError) return nil;
|
|
1583
1624
|
if (result.content === "No matches found.") return nil;
|
|
1584
1625
|
const rows: unknown[] = [];
|
|
1585
1626
|
for (const line of stripPagination(result.content as string)) {
|
|
1586
|
-
const parsed = parseGrepLine(line,
|
|
1627
|
+
const parsed = parseGrepLine(line, pStr);
|
|
1587
1628
|
if (parsed) rows.push(parsed);
|
|
1588
1629
|
}
|
|
1589
1630
|
return toSchemeList(rows);
|
|
1590
1631
|
}));
|
|
1591
1632
|
|
|
1592
|
-
env.set("
|
|
1633
|
+
env.set("grep-files", withSig("grep-files", async (...rest: any[]) => {
|
|
1593
1634
|
const { positionals, opts } = splitArgs(rest, GREP_KEYMAP, GREP_NUMERIC);
|
|
1594
|
-
const pStr = toJsStr(positionals[1]);
|
|
1595
1635
|
const args: Record<string, unknown> = {
|
|
1596
1636
|
pattern: normalizePattern(String(positionals[0] ?? "")), output_mode: "files_with_matches", ...opts,
|
|
1597
1637
|
};
|
|
1598
|
-
|
|
1638
|
+
const posPath = toJsStr(positionals[1]);
|
|
1639
|
+
if (args.path === undefined && typeof posPath === "string") args.path = posPath;
|
|
1599
1640
|
const result = await grep(args);
|
|
1600
1641
|
if (result.isError || result.content === "No matches found.") return nil;
|
|
1601
1642
|
return toSchemeList(stripPagination(result.content as string));
|
|
@@ -1605,9 +1646,10 @@ function installBindings(
|
|
|
1605
1646
|
if (glob) {
|
|
1606
1647
|
// Strip leading "./" so glob paths match grep's — otherwise eq? on the
|
|
1607
1648
|
// file field fails across the two.
|
|
1608
|
-
env.set("glob", withSig("glob", async (
|
|
1609
|
-
const
|
|
1610
|
-
const args: Record<string, unknown> = { pattern: toJsStr(
|
|
1649
|
+
env.set("glob", withSig("glob", async (...rest: any[]) => {
|
|
1650
|
+
const { positionals, opts } = splitArgs(rest, GLOB_KEYMAP);
|
|
1651
|
+
const args: Record<string, unknown> = { pattern: toJsStr(positionals[0]) };
|
|
1652
|
+
const pStr = opts.path !== undefined ? String(opts.path) : toJsStr(positionals[1]);
|
|
1611
1653
|
if (typeof pStr === "string") args.path = pStr;
|
|
1612
1654
|
const result = await glob(args);
|
|
1613
1655
|
if (result.isError || result.content === "No files matched.") return nil;
|
|
@@ -1687,9 +1729,13 @@ const DESCRIPTION = [
|
|
|
1687
1729
|
"char and hash-table ops, …). The only novel surface is the host bindings.",
|
|
1688
1730
|
"",
|
|
1689
1731
|
"Calling convention:",
|
|
1690
|
-
" - Required arguments are positional: (read-file \"x\"), (grep \"pat\"
|
|
1691
|
-
" -
|
|
1732
|
+
" - Required arguments are positional: (read-file \"x\"), (grep \"pat\").",
|
|
1733
|
+
" - Optional arguments are :key value pairs, and the same key reads the same",
|
|
1734
|
+
" on every binding — :offset/:limit on read-file and grep, :timeout on bash:",
|
|
1735
|
+
" (read-file \"x\" :offset 40 :limit 20)",
|
|
1692
1736
|
" (grep \"TODO\" \"src/\" :include \"*.ts\" :context-after 2)",
|
|
1737
|
+
" (A trailing positional is still accepted for each optional, but :key is",
|
|
1738
|
+
" the canonical form and never depends on argument order.)",
|
|
1693
1739
|
" - Each binding returns the natural Scheme value for its job (a string, a",
|
|
1694
1740
|
" list, a boolean); bash returns a record because you usually want the code.",
|
|
1695
1741
|
"",
|
|
@@ -1704,7 +1750,7 @@ const DESCRIPTION = [
|
|
|
1704
1750
|
"",
|
|
1705
1751
|
"Composition is the point: chain read-only bindings in one submission so",
|
|
1706
1752
|
"intermediate results stay in the Scheme heap instead of the conversation.",
|
|
1707
|
-
" (map (lambda (m) (read-file (cdr (assoc 'file m)) (cdr (assoc 'line m)) 3))",
|
|
1753
|
+
" (map (lambda (m) (read-file (cdr (assoc 'file m)) :offset (cdr (assoc 'line m)) :limit 3))",
|
|
1708
1754
|
" (grep \"TODO\" \"src/\"))",
|
|
1709
1755
|
"Side-effecting calls (write-file, edit-file, mutating bash) are clearer one",
|
|
1710
1756
|
"at a time so you can react to each result.",
|
|
@@ -1724,8 +1770,7 @@ const DESCRIPTION = [
|
|
|
1724
1770
|
].join("\n");
|
|
1725
1771
|
|
|
1726
1772
|
// Scheme prelude (Lisp `define-macro`s), run after std is bootstrapped.
|
|
1727
|
-
// cond/when/unless/newline/assq for convenience
|
|
1728
|
-
// quote :key option symbols so the bridge can read them as an option map.
|
|
1773
|
+
// cond/when/unless/newline/assq for convenience.
|
|
1729
1774
|
const PRELUDE = `
|
|
1730
1775
|
(define-macro (cond . clauses)
|
|
1731
1776
|
(if (null? clauses)
|
|
@@ -1746,21 +1791,6 @@ const PRELUDE = `
|
|
|
1746
1791
|
;; R7RS shims for things models commonly reach for that LIPS doesn't ship.
|
|
1747
1792
|
(define (newline) (display "\n"))
|
|
1748
1793
|
(define assq assoc)
|
|
1749
|
-
|
|
1750
|
-
;; grep / grep-files take :key value options. LIPS has no keyword args, so a
|
|
1751
|
-
;; bare :include would be evaluated as an unbound variable; the macro quotes
|
|
1752
|
-
;; leading-colon symbols and the %grep bridge splits them from the positionals.
|
|
1753
|
-
(define (%kw-symbol? s)
|
|
1754
|
-
(and (symbol? s)
|
|
1755
|
-
(let ((str (symbol->string s)))
|
|
1756
|
-
(and (> (string-length str) 0)
|
|
1757
|
-
(string=? (substring str 0 1) ":")))))
|
|
1758
|
-
|
|
1759
|
-
(define (%quote-kw args)
|
|
1760
|
-
(map (lambda (a) (if (%kw-symbol? a) (list 'quote a) a)) args))
|
|
1761
|
-
|
|
1762
|
-
(define-macro (grep . args) (cons '%grep (%quote-kw args)))
|
|
1763
|
-
(define-macro (grep-files . args) (cons '%grep-files (%quote-kw args)))
|
|
1764
1794
|
`;
|
|
1765
1795
|
|
|
1766
1796
|
export default function activate(ctx: AgentContext): void {
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Other extensions can customize how chat entries and tool results render — and even swap the whole TUI renderer — without forking ashi. For non-render concerns (commands, settings, tools, providers) use the standard `agent-sh` extension API; see the [agent-sh extension docs](https://github.com/guanyilun/agent-sh/blob/main/docs/extensions.md).
|
|
4
4
|
|
|
5
|
+
To **drive** the UI from an extension — post a notice, add a status segment, pin a dock widget, or open a select/confirm/input dialog — use the UI-surface protocol (bus events + named handlers, no `ctx.ui` object); see [`docs/ui-surface-protocol.md`](docs/ui-surface-protocol.md) and the worked example `examples/extensions/ashi-ui-demo.ts`.
|
|
6
|
+
|
|
5
7
|
## Chat hooks
|
|
6
8
|
|
|
7
9
|
These return a renderer-agnostic chat-entry view built from the active renderer's
|
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
Same backend, tools, slash commands, providers, and skills as `agent-sh`, mounted in a chat-style interface with session history, branching, and LLM-driven compaction.
|
|
9
9
|
|
|
10
|
+
Rendering is **decoupled** — even *how* ashi draws tool calls and results is a swappable render extension. Same agent, same conversation; load a different render extension and the whole TUI restyles, no code changes:
|
|
11
|
+
|
|
12
|
+
| pi-style rendering | claude-code-style rendering |
|
|
13
|
+
|---|---|
|
|
14
|
+
|  |  |
|
|
15
|
+
|
|
16
|
+
The claude-code-style renderer is [ashi-ink](../ashi-ink), a working Ink (React) renderer; see [Extending ashi](#extending-ashi) for the contract.
|
|
17
|
+
|
|
10
18
|
## Install
|
|
11
19
|
|
|
12
20
|
```bash
|
|
@@ -151,7 +159,7 @@ Each tool inherits from `default` and is overridden by its own block. Unknown to
|
|
|
151
159
|
## Extending ashi
|
|
152
160
|
|
|
153
161
|
Other extensions can customize chat and tool-result rendering — and even swap the whole
|
|
154
|
-
TUI renderer (pi-tui, Ink, …) — without forking ashi. See **[EXTENDING.md](EXTENDING.md)**
|
|
162
|
+
TUI renderer (pi-tui, [Ink](../ashi-ink), …) — without forking ashi. See **[EXTENDING.md](EXTENDING.md)**
|
|
155
163
|
for the chat/tool render hooks, the declarative tool render schema, and the renderer
|
|
156
164
|
contract. For non-render concerns (commands, settings, tools, providers), use the
|
|
157
165
|
standard [agent-sh extension API](https://github.com/guanyilun/agent-sh/blob/main/docs/extensions.md).
|
|
@@ -167,6 +175,14 @@ export PATH="$HOME/.agent-sh/bin:$PATH"
|
|
|
167
175
|
|
|
168
176
|
`agent-sh install` runs `npm install` and `npm run build` in the copied directory and symlinks the built bin into `~/.agent-sh/bin/`.
|
|
169
177
|
|
|
178
|
+
By default the copy pulls the **published** `agent-sh` from npm. When ashi's source depends on an unreleased core — a kernel change you haven't published yet — add `--dev` so the install links against the checkout you're running instead:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
agent-sh install ashi --dev --force
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`--dev` repoints the copied package's `agent-sh` dependency at the running host's checkout (the one the global `agent-sh` is linked to). The build then sees the local types, and a later `npm run build` at the repo root flows through without reinstalling — the kernel rebuild rule from [Development](#development) applies. Re-run the install to refresh ashi's own dist after frontend changes.
|
|
185
|
+
|
|
170
186
|
## Development
|
|
171
187
|
|
|
172
188
|
`@guanyilun/ashi` depends on the published `agent-sh` package. To iterate against a local checkout, use `npm link`:
|