agent-sh 0.10.0 → 0.10.1
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 +11 -9
- package/dist/agent/agent-loop.d.ts +0 -3
- package/dist/agent/agent-loop.js +12 -35
- package/dist/agent/conversation-state.js +8 -2
- package/dist/agent/token-budget.d.ts +8 -12
- package/dist/agent/token-budget.js +5 -40
- package/dist/agent/types.d.ts +0 -1
- package/dist/context-manager.d.ts +1 -21
- package/dist/context-manager.js +26 -163
- package/dist/event-bus.d.ts +0 -1
- package/dist/extension-loader.js +25 -4
- package/dist/extensions/agent-backend.js +3 -2
- package/dist/extensions/index.js +0 -1
- package/dist/extensions/tui-renderer.js +5 -2
- package/dist/settings.d.ts +3 -11
- package/dist/settings.js +0 -4
- package/dist/shell/input-handler.js +8 -9
- package/dist/types.d.ts +3 -0
- package/dist/utils/ansi.d.ts +3 -1
- package/dist/utils/ansi.js +68 -7
- package/dist/utils/box-frame.js +8 -2
- package/dist/utils/markdown.js +23 -8
- package/dist/utils/package-version.d.ts +1 -0
- package/dist/utils/package-version.js +10 -0
- package/dist/utils/shell-output-spill.d.ts +2 -0
- package/dist/utils/shell-output-spill.js +81 -0
- package/examples/extensions/claude-code-bridge/README.md +14 -0
- package/examples/extensions/claude-code-bridge/index.ts +13 -101
- package/examples/extensions/pi-bridge/README.md +16 -0
- package/examples/extensions/pi-bridge/index.ts +8 -154
- package/package.json +1 -1
- package/dist/extensions/shell-recall.d.ts +0 -9
- package/dist/extensions/shell-recall.js +0 -8
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ Requires Node.js 18+.
|
|
|
44
44
|
|
|
45
45
|
**Context that just works.** Every query includes your cwd, recent commands, and their output. Run a failing test, type `> fix this`, and agent-sh knows exactly what happened. Context management works like shell history — continuous, persistent across restarts, no sessions to manage. See [Context Management](docs/context-management.md).
|
|
46
46
|
|
|
47
|
-
**Any LLM, any backend.** agent-sh works with any OpenAI-compatible API out of the box. Define multiple providers in settings and
|
|
47
|
+
**Any LLM, any backend.** agent-sh works with any OpenAI-compatible API out of the box. Define multiple providers in settings and switch models at runtime with `/model <name>`. Or swap in a completely different agent — [Claude Code](examples/extensions/claude-code-bridge/) and [pi](examples/extensions/pi-bridge/) run as drop-in backend extensions.
|
|
48
48
|
|
|
49
49
|
**Extensible by design.** The entire system is built on a typed event bus. Extensions can add custom input modes, content transforms (render LaTeX as images, Mermaid as diagrams), themes, slash commands, or replace the agent backend entirely. The built-in TUI renderer is itself just an extension.
|
|
50
50
|
|
|
@@ -52,14 +52,16 @@ Requires Node.js 18+.
|
|
|
52
52
|
|
|
53
53
|
## Documentation
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
55
|
+
Start with **Usage** to get running, then **Architecture** for the mental model.
|
|
56
|
+
|
|
57
|
+
1. [Usage Guide](docs/usage.md) — install, run, configure providers and models
|
|
58
|
+
2. [Architecture](docs/architecture.md) — pure kernel + extensions, the shell ↔ agent boundary
|
|
59
|
+
3. [The Built-in Agent: ash](docs/agent.md) — query flow, tools, system prompt, model switching
|
|
60
|
+
4. [Context Management](docs/context-management.md) — shell-output spill, three-tier conversation compaction, recall APIs
|
|
61
|
+
5. [Extensions](docs/extensions.md) — event bus, content transforms, custom agent backends, theming
|
|
62
|
+
6. [TUI Composition](docs/tui-composition.md) — compositor, render surfaces, stream routing
|
|
63
|
+
7. [Library Usage](docs/library.md) — embedding agent-sh in your own apps
|
|
64
|
+
8. [Troubleshooting](docs/troubleshooting.md) — common errors and debug mode
|
|
63
65
|
|
|
64
66
|
## Development
|
|
65
67
|
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* Subscribes to bus events in constructor:
|
|
5
5
|
* - agent:submit → run query through LLM tool loop
|
|
6
6
|
* - agent:cancel-request → abort current loop
|
|
7
|
-
* - config:cycle → cycle through modes
|
|
8
7
|
*
|
|
9
8
|
* Emits bus events during execution:
|
|
10
9
|
* - agent:query, agent:processing-start/done, agent:response-chunk/done
|
|
@@ -36,7 +35,6 @@ export declare class AgentLoop implements AgentBackend {
|
|
|
36
35
|
private historyFile;
|
|
37
36
|
private conversation;
|
|
38
37
|
private fileReadCache;
|
|
39
|
-
private tokenBudget;
|
|
40
38
|
private modes;
|
|
41
39
|
private currentModeIndex;
|
|
42
40
|
private boundListeners;
|
|
@@ -104,7 +102,6 @@ export declare class AgentLoop implements AgentBackend {
|
|
|
104
102
|
private cancel;
|
|
105
103
|
/** Check if reasoning_effort should be sent for the current model/provider. */
|
|
106
104
|
private shouldSendReasoningEffort;
|
|
107
|
-
private cycleMode;
|
|
108
105
|
private get currentMode();
|
|
109
106
|
private get currentModel();
|
|
110
107
|
/**
|
package/dist/agent/agent-loop.js
CHANGED
|
@@ -8,7 +8,8 @@ import { HistoryFile } from "./history-file.js";
|
|
|
8
8
|
import { nucleate, formatNuclearLine, isReadOnly } from "./nuclear-form.js";
|
|
9
9
|
import { STATIC_SYSTEM_PROMPT, buildDynamicContext, buildStaticByCwd, formatSkillsBlock, loadGlobalAgentsMd } from "./system-prompt.js";
|
|
10
10
|
import { createToolUI } from "../utils/tool-interactive.js";
|
|
11
|
-
import {
|
|
11
|
+
import { RESPONSE_RESERVE, DEFAULT_CONTEXT_WINDOW } from "./token-budget.js";
|
|
12
|
+
import { PACKAGE_VERSION } from "../utils/package-version.js";
|
|
12
13
|
import { getSettings, updateSettings } from "../settings.js";
|
|
13
14
|
import { createToolProtocol } from "./tool-protocol.js";
|
|
14
15
|
// Core tool factories
|
|
@@ -40,7 +41,6 @@ export class AgentLoop {
|
|
|
40
41
|
historyFile;
|
|
41
42
|
conversation;
|
|
42
43
|
fileReadCache = new Map();
|
|
43
|
-
tokenBudget;
|
|
44
44
|
modes;
|
|
45
45
|
currentModeIndex = 0;
|
|
46
46
|
boundListeners = [];
|
|
@@ -105,8 +105,6 @@ export class AgentLoop {
|
|
|
105
105
|
? config.modes
|
|
106
106
|
: [{ model: config.llmClient.model }];
|
|
107
107
|
this.currentModeIndex = config.initialModeIndex ?? 0;
|
|
108
|
-
// Unified token budget — adapts to current model's context window
|
|
109
|
-
this.tokenBudget = new TokenBudget(this.currentMode.contextWindow);
|
|
110
108
|
// Tool protocol — controls how tools are presented to the LLM
|
|
111
109
|
this.toolProtocol = createToolProtocol(getSettings().toolMode ?? "api");
|
|
112
110
|
// Register core tools
|
|
@@ -115,8 +113,6 @@ export class AgentLoop {
|
|
|
115
113
|
const protocolTools = this.toolProtocol.getProtocolTools?.() ?? [];
|
|
116
114
|
for (const t of protocolTools)
|
|
117
115
|
this.registerTool(t);
|
|
118
|
-
// Update token budget with tool count
|
|
119
|
-
this.tokenBudget.update(undefined, this.toolRegistry.all().length);
|
|
120
116
|
// Register handlers — extensions can advise these
|
|
121
117
|
this.registerHandlers();
|
|
122
118
|
// Subscribe to bus-based tool/instruction registration from extensions.
|
|
@@ -165,7 +161,6 @@ export class AgentLoop {
|
|
|
165
161
|
else {
|
|
166
162
|
this.llmClient.model = m.model;
|
|
167
163
|
}
|
|
168
|
-
this.tokenBudget.update(m.contextWindow, this.toolRegistry.all().length);
|
|
169
164
|
this.bus.emit("config:changed", {});
|
|
170
165
|
});
|
|
171
166
|
const getToolsPipe = () => ({ tools: this.getTools() });
|
|
@@ -184,7 +179,6 @@ export class AgentLoop {
|
|
|
184
179
|
on("agent:cancel-request", (e) => {
|
|
185
180
|
this.abortController?.abort(e.silent ? "silent" : undefined);
|
|
186
181
|
});
|
|
187
|
-
on("config:cycle", () => this.cycleMode());
|
|
188
182
|
on("config:switch-model", ({ model: target }) => {
|
|
189
183
|
const idx = this.modes.findIndex((m) => m.model === target);
|
|
190
184
|
if (idx === -1) {
|
|
@@ -199,9 +193,8 @@ export class AgentLoop {
|
|
|
199
193
|
else {
|
|
200
194
|
this.llmClient.model = m.model;
|
|
201
195
|
}
|
|
202
|
-
this.tokenBudget.update(m.contextWindow, this.toolRegistry.all().length);
|
|
203
196
|
const label = m.provider ? `${m.provider}: ${m.model}` : m.model;
|
|
204
|
-
this.bus.emit("agent:info", { name: "ash", version:
|
|
197
|
+
this.bus.emit("agent:info", { name: "ash", version: PACKAGE_VERSION, model: m.model, provider: m.provider, contextWindow: m.contextWindow });
|
|
205
198
|
// Persist as the new default — selection survives restart.
|
|
206
199
|
// Safe even for dynamic providers: agent-backend defers mode
|
|
207
200
|
// resolution to `core:extensions-loaded`, so the extension gets
|
|
@@ -428,30 +421,6 @@ export class AgentLoop {
|
|
|
428
421
|
return false;
|
|
429
422
|
return true;
|
|
430
423
|
}
|
|
431
|
-
cycleMode() {
|
|
432
|
-
const prevMode = this.modes[this.currentModeIndex];
|
|
433
|
-
this.currentModeIndex =
|
|
434
|
-
(this.currentModeIndex + 1) % this.modes.length;
|
|
435
|
-
const newMode = this.modes[this.currentModeIndex];
|
|
436
|
-
// Reconfigure LlmClient if provider changed
|
|
437
|
-
if (newMode.provider !== prevMode.provider && newMode.providerConfig) {
|
|
438
|
-
this.llmClient.reconfigure({
|
|
439
|
-
apiKey: newMode.providerConfig.apiKey,
|
|
440
|
-
baseURL: newMode.providerConfig.baseURL,
|
|
441
|
-
model: newMode.model,
|
|
442
|
-
});
|
|
443
|
-
}
|
|
444
|
-
else {
|
|
445
|
-
this.llmClient.model = newMode.model;
|
|
446
|
-
}
|
|
447
|
-
this.tokenBudget.update(newMode.contextWindow, this.toolRegistry.all().length);
|
|
448
|
-
const label = newMode.provider
|
|
449
|
-
? `${newMode.provider}: ${newMode.model}`
|
|
450
|
-
: newMode.model;
|
|
451
|
-
this.bus.emit("agent:info", { name: "ash", version: "0.4", model: newMode.model, provider: newMode.provider, contextWindow: newMode.contextWindow });
|
|
452
|
-
this.bus.emit("ui:info", { message: `Model: ${label}` });
|
|
453
|
-
this.bus.emit("config:changed", {});
|
|
454
|
-
}
|
|
455
424
|
get currentMode() {
|
|
456
425
|
return this.modes[this.currentModeIndex];
|
|
457
426
|
}
|
|
@@ -1066,7 +1035,15 @@ export class AgentLoop {
|
|
|
1066
1035
|
const contextWindow = this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
1067
1036
|
const threshold = Math.floor((contextWindow - RESPONSE_RESERVE) * getSettings().autoCompactThreshold);
|
|
1068
1037
|
if (totalEstimate > threshold) {
|
|
1069
|
-
this.compactWithHooks(threshold);
|
|
1038
|
+
const result = this.compactWithHooks(threshold);
|
|
1039
|
+
if (!result) {
|
|
1040
|
+
// Auto-compact fired but nothing was evictable. This can happen
|
|
1041
|
+
// in short conversations with heavy tool output where the pin
|
|
1042
|
+
// fraction consumes all turns. Log it so it's not silent.
|
|
1043
|
+
this.bus.emit("ui:info", {
|
|
1044
|
+
message: `[auto-compact] above threshold (${totalEstimate.toLocaleString()} > ${threshold.toLocaleString()}) but nothing to evict — conversation may be too short`,
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1070
1047
|
cachedSystemPrompt = undefined;
|
|
1071
1048
|
}
|
|
1072
1049
|
const currentCwd = this.contextManager.getCwd();
|
|
@@ -217,12 +217,18 @@ export class ConversationState {
|
|
|
217
217
|
if (!force && convEstimate <= convTarget)
|
|
218
218
|
return null;
|
|
219
219
|
const turns = this.parseTurns();
|
|
220
|
-
|
|
220
|
+
// With force, allow compacting down to 1 turn (the current response).
|
|
221
|
+
// Without force, keep at least 2 turns (user + agent) to avoid
|
|
222
|
+
// annihilating a young conversation.
|
|
223
|
+
if (turns.length <= (force ? 1 : 2))
|
|
221
224
|
return null;
|
|
222
225
|
// Cap the pinned window so enough turns remain evictable.
|
|
223
226
|
const maxPinnedFraction = force ? 0.4 : 0.6;
|
|
224
227
|
const maxPinned = Math.max(2, Math.floor(turns.length * maxPinnedFraction));
|
|
225
|
-
|
|
228
|
+
// Ensure at least 1 turn is evictable when force is true, even in
|
|
229
|
+
// very short conversations (e.g. 3 turns with heavy tool output).
|
|
230
|
+
const maxPinnedForced = force ? Math.min(maxPinned, turns.length - 2) : maxPinned;
|
|
231
|
+
const pinnedCount = Math.min(recentTurnsToKeep, turns.length - 1, Math.max(1, maxPinnedForced));
|
|
226
232
|
for (let i = 0; i < turns.length; i++) {
|
|
227
233
|
turns[i].priority = this.inferPriority(turns[i].messages);
|
|
228
234
|
}
|
|
@@ -1,14 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared token-budget constants used by auto-compaction.
|
|
3
|
+
*
|
|
4
|
+
* RESPONSE_RESERVE: tokens reserved for the model's output.
|
|
5
|
+
* DEFAULT_CONTEXT_WINDOW: fallback when the active mode doesn't declare one.
|
|
6
|
+
*/
|
|
1
7
|
/** Response reserve — tokens reserved for the model's output. */
|
|
2
|
-
declare const RESPONSE_RESERVE = 8192;
|
|
8
|
+
export declare const RESPONSE_RESERVE = 8192;
|
|
3
9
|
/** Fallback when contextWindow is unknown. */
|
|
4
|
-
declare const DEFAULT_CONTEXT_WINDOW = 60000;
|
|
5
|
-
export { RESPONSE_RESERVE, DEFAULT_CONTEXT_WINDOW };
|
|
6
|
-
export declare class TokenBudget {
|
|
7
|
-
private contextWindow;
|
|
8
|
-
private toolCount;
|
|
9
|
-
constructor(contextWindow?: number, toolCount?: number);
|
|
10
|
-
/** Update when model or tool set changes. */
|
|
11
|
-
update(contextWindow?: number, toolCount?: number): void;
|
|
12
|
-
/** Token budget for the shell context stream. */
|
|
13
|
-
get shellBudgetTokens(): number;
|
|
14
|
-
}
|
|
10
|
+
export declare const DEFAULT_CONTEXT_WINDOW = 60000;
|
|
@@ -1,45 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Shared token-budget constants used by auto-compaction.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* space is for the conversation, system prompt, tools, and response.
|
|
7
|
-
*
|
|
8
|
-
* Shell context is sized loosely — chars/4 accuracy is fine for this.
|
|
9
|
-
* Conversation and compaction decisions use API-grounded token counts
|
|
10
|
-
* (see ConversationState.estimatePromptTokens).
|
|
4
|
+
* RESPONSE_RESERVE: tokens reserved for the model's output.
|
|
5
|
+
* DEFAULT_CONTEXT_WINDOW: fallback when the active mode doesn't declare one.
|
|
11
6
|
*/
|
|
12
|
-
import { getSettings } from "../settings.js";
|
|
13
|
-
const SYSTEM_PROMPT_OVERHEAD = 800;
|
|
14
|
-
const DYNAMIC_CONTEXT_OVERHEAD = 500; // conventions, metadata, skills list
|
|
15
|
-
const TOKENS_PER_TOOL_DEFINITION = 50;
|
|
16
7
|
/** Response reserve — tokens reserved for the model's output. */
|
|
17
|
-
const RESPONSE_RESERVE = 8192;
|
|
8
|
+
export const RESPONSE_RESERVE = 8192;
|
|
18
9
|
/** Fallback when contextWindow is unknown. */
|
|
19
|
-
const DEFAULT_CONTEXT_WINDOW = 60_000;
|
|
20
|
-
export { RESPONSE_RESERVE, DEFAULT_CONTEXT_WINDOW };
|
|
21
|
-
export class TokenBudget {
|
|
22
|
-
contextWindow;
|
|
23
|
-
toolCount;
|
|
24
|
-
constructor(contextWindow, toolCount = 0) {
|
|
25
|
-
this.contextWindow = contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
26
|
-
this.toolCount = toolCount;
|
|
27
|
-
}
|
|
28
|
-
/** Update when model or tool set changes. */
|
|
29
|
-
update(contextWindow, toolCount) {
|
|
30
|
-
if (contextWindow != null)
|
|
31
|
-
this.contextWindow = contextWindow;
|
|
32
|
-
if (toolCount != null)
|
|
33
|
-
this.toolCount = toolCount;
|
|
34
|
-
}
|
|
35
|
-
/** Token budget for the shell context stream. */
|
|
36
|
-
get shellBudgetTokens() {
|
|
37
|
-
const overhead = SYSTEM_PROMPT_OVERHEAD +
|
|
38
|
-
DYNAMIC_CONTEXT_OVERHEAD +
|
|
39
|
-
this.toolCount * TOKENS_PER_TOOL_DEFINITION +
|
|
40
|
-
RESPONSE_RESERVE;
|
|
41
|
-
const contentBudget = Math.max(0, this.contextWindow - overhead);
|
|
42
|
-
const ratio = getSettings().shellContextRatio;
|
|
43
|
-
return Math.floor(contentBudget * ratio);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
10
|
+
export const DEFAULT_CONTEXT_WINDOW = 60_000;
|
package/dist/agent/types.d.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* Backends self-wire to bus events in their constructor:
|
|
5
5
|
* - agent:submit → handle queries
|
|
6
6
|
* - agent:cancel-request → handle cancellation
|
|
7
|
-
* - config:cycle → handle mode switching
|
|
8
7
|
*
|
|
9
8
|
* They emit bus events for results:
|
|
10
9
|
* - agent:response-chunk, agent:tool-started, agent:tool-completed, etc.
|
|
@@ -4,26 +4,13 @@ export declare class ContextManager {
|
|
|
4
4
|
private exchanges;
|
|
5
5
|
private nextId;
|
|
6
6
|
private currentCwd;
|
|
7
|
-
private sessionStart;
|
|
8
|
-
private firstPrompt;
|
|
9
7
|
private agentShellActive;
|
|
10
|
-
|
|
11
|
-
constructor(bus: EventBus, handlers?: HandlerRegistry);
|
|
8
|
+
constructor(bus: EventBus, _handlers?: HandlerRegistry);
|
|
12
9
|
getCwd(): string;
|
|
13
|
-
/**
|
|
14
|
-
* Build the <shell_context> block for the agent prompt.
|
|
15
|
-
* Pipeline: window → truncate → format
|
|
16
|
-
*/
|
|
17
|
-
getContext(budget?: number): string;
|
|
18
10
|
/**
|
|
19
11
|
* Regex/keyword search across all exchanges. Returns formatted results.
|
|
20
12
|
*/
|
|
21
13
|
search(query: string): string;
|
|
22
|
-
/**
|
|
23
|
-
* Return content for specific exchange IDs.
|
|
24
|
-
* Optional start/end restrict to a line range (1-indexed).
|
|
25
|
-
*/
|
|
26
|
-
expand(ids: number[], start?: number, end?: number): string;
|
|
27
14
|
/**
|
|
28
15
|
* Return shell events with id > afterId, formatted as an incremental
|
|
29
16
|
* delta suitable for injection into conversation history. Skips
|
|
@@ -45,17 +32,10 @@ export declare class ContextManager {
|
|
|
45
32
|
* One-line summaries of last N exchanges.
|
|
46
33
|
*/
|
|
47
34
|
getRecentSummary(n?: number): string;
|
|
48
|
-
/**
|
|
49
|
-
* Parse and handle shell_recall commands.
|
|
50
|
-
*/
|
|
51
|
-
handleRecallCommand(command: string): string;
|
|
52
35
|
/**
|
|
53
36
|
* Clear exchange history (used by /clear command).
|
|
54
37
|
*/
|
|
55
38
|
clear(): void;
|
|
56
|
-
private applyWindow;
|
|
57
|
-
private applyTruncation;
|
|
58
|
-
private formatContext;
|
|
59
39
|
private addExchange;
|
|
60
40
|
private formatExchangeTruncated;
|
|
61
41
|
private formatExchangeFull;
|
package/dist/context-manager.js
CHANGED
|
@@ -1,32 +1,43 @@
|
|
|
1
1
|
import { getSettings } from "./settings.js";
|
|
2
|
+
import { spillOutput } from "./utils/shell-output-spill.js";
|
|
2
3
|
export class ContextManager {
|
|
3
4
|
exchanges = [];
|
|
4
5
|
nextId = 1;
|
|
5
6
|
currentCwd;
|
|
6
|
-
sessionStart;
|
|
7
|
-
firstPrompt = true;
|
|
8
7
|
agentShellActive = false; // true while user_shell command is executing
|
|
9
|
-
|
|
10
|
-
constructor(bus, handlers) {
|
|
11
|
-
if (handlers) {
|
|
12
|
-
this.handlers = handlers;
|
|
13
|
-
// Extensions can advise this to inject extra context (e.g. terminal buffer)
|
|
14
|
-
handlers.define("context:build-extra", () => "");
|
|
15
|
-
}
|
|
8
|
+
constructor(bus, _handlers) {
|
|
16
9
|
this.currentCwd = process.cwd();
|
|
17
|
-
this.sessionStart = Date.now();
|
|
18
10
|
// ── Subscribe to shell events ──
|
|
19
11
|
bus.on("shell:command-done", (e) => {
|
|
20
12
|
const lines = e.output.split("\n");
|
|
13
|
+
const s = getSettings();
|
|
14
|
+
// Spill long outputs to a tempfile so the agent can `read_file` them
|
|
15
|
+
// on demand instead of carrying the full text in LLM context.
|
|
16
|
+
let output = e.output;
|
|
17
|
+
let spillPath;
|
|
18
|
+
if (lines.length > s.shellTruncateThreshold) {
|
|
19
|
+
// Reserve the id we're about to assign so the tempfile name matches.
|
|
20
|
+
const id = this.nextId;
|
|
21
|
+
try {
|
|
22
|
+
spillPath = spillOutput(id, e.output);
|
|
23
|
+
output = buildSpillStub(lines, s.shellHeadLines, s.shellTailLines, spillPath);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// If spill fails (e.g. disk full), fall back to keeping output in memory.
|
|
27
|
+
output = e.output;
|
|
28
|
+
spillPath = undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
21
31
|
this.addExchange({
|
|
22
32
|
type: "shell_command",
|
|
23
33
|
command: e.command,
|
|
24
|
-
output
|
|
34
|
+
output,
|
|
25
35
|
cwd: e.cwd,
|
|
26
36
|
exitCode: e.exitCode,
|
|
27
37
|
outputLines: lines.length,
|
|
28
38
|
outputBytes: e.output.length,
|
|
29
39
|
source: this.agentShellActive ? "agent" : "user",
|
|
40
|
+
spillPath,
|
|
30
41
|
});
|
|
31
42
|
});
|
|
32
43
|
bus.on("shell:cwd-change", (e) => {
|
|
@@ -46,16 +57,6 @@ export class ContextManager {
|
|
|
46
57
|
getCwd() {
|
|
47
58
|
return this.currentCwd;
|
|
48
59
|
}
|
|
49
|
-
/**
|
|
50
|
-
* Build the <shell_context> block for the agent prompt.
|
|
51
|
-
* Pipeline: window → truncate → format
|
|
52
|
-
*/
|
|
53
|
-
getContext(budget) {
|
|
54
|
-
budget ??= getSettings().contextBudget;
|
|
55
|
-
let exchanges = this.applyWindow(this.exchanges);
|
|
56
|
-
exchanges = this.applyTruncation(exchanges, budget);
|
|
57
|
-
return this.formatContext(exchanges);
|
|
58
|
-
}
|
|
59
60
|
/**
|
|
60
61
|
* Regex/keyword search across all exchanges. Returns formatted results.
|
|
61
62
|
*/
|
|
@@ -106,40 +107,6 @@ export class ContextManager {
|
|
|
106
107
|
}
|
|
107
108
|
return parts.join("\n");
|
|
108
109
|
}
|
|
109
|
-
/**
|
|
110
|
-
* Return content for specific exchange IDs.
|
|
111
|
-
* Optional start/end restrict to a line range (1-indexed).
|
|
112
|
-
*/
|
|
113
|
-
expand(ids, start, end) {
|
|
114
|
-
const results = [];
|
|
115
|
-
for (const id of ids) {
|
|
116
|
-
const ex = this.exchanges.find((e) => e.id === id);
|
|
117
|
-
if (!ex) {
|
|
118
|
-
results.push(`#${id}: not found`);
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
const text = this.formatExchangeFull(ex);
|
|
122
|
-
const lines = text.split("\n");
|
|
123
|
-
const total = lines.length;
|
|
124
|
-
if (start != null || end != null) {
|
|
125
|
-
// Line range requested
|
|
126
|
-
const s = Math.max(0, (start ?? 1) - 1);
|
|
127
|
-
const e = end ?? total;
|
|
128
|
-
results.push(lines.slice(s, e).join("\n") +
|
|
129
|
-
`\n[showing lines ${s + 1}-${Math.min(e, total)} of ${total}]`);
|
|
130
|
-
}
|
|
131
|
-
else if (total > getSettings().recallExpandMaxLines) {
|
|
132
|
-
// Too large — tell the agent to narrow down
|
|
133
|
-
results.push(`#${ex.id}: output is ${total} lines, too large to expand fully. ` +
|
|
134
|
-
`Use start/end params to select a line range (e.g. start=1, end=50), ` +
|
|
135
|
-
`or use search with a regex to find specific content.`);
|
|
136
|
-
}
|
|
137
|
-
else {
|
|
138
|
-
results.push(text);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
return results.join("\n\n");
|
|
142
|
-
}
|
|
143
110
|
/**
|
|
144
111
|
* Return shell events with id > afterId, formatted as an incremental
|
|
145
112
|
* delta suitable for injection into conversation history. Skips
|
|
@@ -156,18 +123,8 @@ export class ContextManager {
|
|
|
156
123
|
if (fresh.length === 0)
|
|
157
124
|
return null;
|
|
158
125
|
const lastSeq = this.exchanges[this.exchanges.length - 1].id;
|
|
159
|
-
//
|
|
160
|
-
const
|
|
161
|
-
if (ex.type === "shell_command") {
|
|
162
|
-
const s = getSettings();
|
|
163
|
-
return {
|
|
164
|
-
...ex,
|
|
165
|
-
output: truncateOutput(ex.output, s.shellTruncateThreshold, s.shellHeadLines, s.shellTailLines, ex.id),
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
return { ...ex };
|
|
169
|
-
});
|
|
170
|
-
const body = truncated.map((ex) => this.formatExchangeTruncated(ex)).join("\n");
|
|
126
|
+
// Outputs already carry head+tail+spillPath stubs from capture time.
|
|
127
|
+
const body = fresh.map((ex) => this.formatExchangeTruncated(ex)).join("\n");
|
|
171
128
|
return {
|
|
172
129
|
text: `<shell-events>\n${body}</shell-events>`,
|
|
173
130
|
lastSeq,
|
|
@@ -186,104 +143,13 @@ export class ContextManager {
|
|
|
186
143
|
return "No exchanges yet.";
|
|
187
144
|
return recent.map((ex) => this.exchangeOneLiner(ex)).join("\n");
|
|
188
145
|
}
|
|
189
|
-
/**
|
|
190
|
-
* Parse and handle shell_recall commands.
|
|
191
|
-
*/
|
|
192
|
-
handleRecallCommand(command) {
|
|
193
|
-
const args = command.replace(/^_*shell_recall\s*/, "").trim();
|
|
194
|
-
if (!args || args === "--help") {
|
|
195
|
-
return [
|
|
196
|
-
"Usage:",
|
|
197
|
-
" shell_recall Browse recent exchanges",
|
|
198
|
-
" shell_recall --search <query> Search all exchanges",
|
|
199
|
-
" shell_recall --expand <id,...> Show full content of exchanges",
|
|
200
|
-
"",
|
|
201
|
-
"Examples:",
|
|
202
|
-
' shell_recall --search "test fail"',
|
|
203
|
-
" shell_recall --expand 41",
|
|
204
|
-
" shell_recall --expand 41,42,43",
|
|
205
|
-
].join("\n");
|
|
206
|
-
}
|
|
207
|
-
const searchMatch = args.match(/^--search\s+(?:"([^"]+)"|(\S+))/);
|
|
208
|
-
if (searchMatch) {
|
|
209
|
-
return this.search(searchMatch[1] ?? searchMatch[2] ?? "");
|
|
210
|
-
}
|
|
211
|
-
const expandMatch = args.match(/^--expand\s+([\d,\s]+)/);
|
|
212
|
-
if (expandMatch) {
|
|
213
|
-
const ids = expandMatch[1]
|
|
214
|
-
.split(/[,\s]+/)
|
|
215
|
-
.map(Number)
|
|
216
|
-
.filter((n) => !isNaN(n));
|
|
217
|
-
if (ids.length === 0)
|
|
218
|
-
return "No valid IDs provided.";
|
|
219
|
-
return this.expand(ids);
|
|
220
|
-
}
|
|
221
|
-
// Default: browse
|
|
222
|
-
return this.getRecentSummary();
|
|
223
|
-
}
|
|
224
146
|
/**
|
|
225
147
|
* Clear exchange history (used by /clear command).
|
|
226
148
|
*/
|
|
227
149
|
clear() {
|
|
228
150
|
this.exchanges = [];
|
|
229
|
-
this.firstPrompt = true;
|
|
230
151
|
// Don't reset nextId — IDs should be globally unique within a session
|
|
231
152
|
}
|
|
232
|
-
// ── Pipeline stages ───────────────────────────────────────────
|
|
233
|
-
applyWindow(exchanges, windowSize) {
|
|
234
|
-
windowSize ??= getSettings().contextWindowSize;
|
|
235
|
-
return exchanges.slice(-windowSize);
|
|
236
|
-
}
|
|
237
|
-
applyTruncation(exchanges, budget) {
|
|
238
|
-
// Deep clone so we don't mutate the source
|
|
239
|
-
const result = exchanges.map((e) => ({ ...e }));
|
|
240
|
-
// Pass 1: per-type truncation
|
|
241
|
-
for (const ex of result) {
|
|
242
|
-
if (ex.type === "shell_command") {
|
|
243
|
-
const s = getSettings();
|
|
244
|
-
ex.output = truncateOutput(ex.output, s.shellTruncateThreshold, s.shellHeadLines, s.shellTailLines, ex.id);
|
|
245
|
-
}
|
|
246
|
-
// agent_query has no output to truncate
|
|
247
|
-
}
|
|
248
|
-
// Pass 2: budget enforcement — strip output from oldest if over budget
|
|
249
|
-
let totalSize = result.reduce((sum, ex) => sum + this.exchangeSize(ex), 0);
|
|
250
|
-
for (let i = 0; i < result.length - 1 && totalSize > budget; i++) {
|
|
251
|
-
const ex = result[i];
|
|
252
|
-
const before = this.exchangeSize(ex);
|
|
253
|
-
if (ex.type === "shell_command") {
|
|
254
|
-
ex.output = `[output omitted, use shell_recall tool to expand id ${ex.id}]`;
|
|
255
|
-
}
|
|
256
|
-
totalSize -= before - this.exchangeSize(ex);
|
|
257
|
-
}
|
|
258
|
-
return result;
|
|
259
|
-
}
|
|
260
|
-
formatContext(exchanges) {
|
|
261
|
-
const elapsed = Math.round((Date.now() - this.sessionStart) / 60000);
|
|
262
|
-
const totalCount = this.exchanges.length;
|
|
263
|
-
let out = "<shell_context>\n";
|
|
264
|
-
if (this.firstPrompt) {
|
|
265
|
-
out += `You are an AI assistant living inside agent-sh, a shell-first terminal.\n`;
|
|
266
|
-
out += `The user interacts with a real shell (PTY) and sends you queries inline. You are there to help them with their tasks.\n`;
|
|
267
|
-
out += `\n`;
|
|
268
|
-
out += `IMPORTANT tool usage rules:\n`;
|
|
269
|
-
out += `- Your internal tools (bash, read, write, ls, etc.) run in an isolated subprocess. The user CANNOT see their output.\n`;
|
|
270
|
-
out += `- Only use internal tools when YOU need to reason about content silently (e.g. reading a file to answer a question about it).\n`;
|
|
271
|
-
out += `- You can browse or search shell history with shell_recall.\n`;
|
|
272
|
-
out += `\n`;
|
|
273
|
-
this.firstPrompt = false;
|
|
274
|
-
}
|
|
275
|
-
out += `cwd: ${this.currentCwd}\n`;
|
|
276
|
-
out += `session: ${totalCount} exchanges, ${elapsed}m elapsed\n`;
|
|
277
|
-
for (const ex of exchanges) {
|
|
278
|
-
out += "\n" + this.formatExchangeTruncated(ex);
|
|
279
|
-
}
|
|
280
|
-
// Allow extensions to inject extra context (e.g. terminal buffer snapshot)
|
|
281
|
-
const extra = this.handlers?.call("context:build-extra");
|
|
282
|
-
if (extra)
|
|
283
|
-
out += "\n" + extra + "\n";
|
|
284
|
-
out += "\n</shell_context>\n";
|
|
285
|
-
return out;
|
|
286
|
-
}
|
|
287
153
|
// ── Internal helpers ──────────────────────────────────────────
|
|
288
154
|
addExchange(partial) {
|
|
289
155
|
const exchange = {
|
|
@@ -352,14 +218,11 @@ export class ContextManager {
|
|
|
352
218
|
}
|
|
353
219
|
}
|
|
354
220
|
// ── Utility functions ─────────────────────────────────────────
|
|
355
|
-
function
|
|
356
|
-
const lines = text.split("\n");
|
|
357
|
-
if (lines.length <= threshold)
|
|
358
|
-
return text;
|
|
221
|
+
function buildSpillStub(lines, headLines, tailLines, spillPath) {
|
|
359
222
|
const omitted = lines.length - headLines - tailLines;
|
|
360
223
|
return [
|
|
361
224
|
...lines.slice(0, headLines),
|
|
362
|
-
`[... ${omitted} lines truncated
|
|
225
|
+
`[... ${omitted} lines truncated — full output at ${spillPath}; use read_file to expand ...]`,
|
|
363
226
|
...lines.slice(-tailLines),
|
|
364
227
|
].join("\n");
|
|
365
228
|
}
|
package/dist/event-bus.d.ts
CHANGED
package/dist/extension-loader.js
CHANGED
|
@@ -5,12 +5,33 @@ const EXT_DIR = path.join(CONFIG_DIR, "extensions");
|
|
|
5
5
|
const TS_EXTS = [".ts", ".tsx", ".mts"];
|
|
6
6
|
const SCRIPT_EXTS = [".js", ".mjs", ".ts", ".tsx", ".mts"];
|
|
7
7
|
let tsRegistered = false;
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
let tsxUnregister = null;
|
|
9
|
+
/**
|
|
10
|
+
* Register tsx's ESM loader for .ts file support.
|
|
11
|
+
*
|
|
12
|
+
* Called before importing .ts extensions. The tsx loader uses Node's
|
|
13
|
+
* module.register() which creates a background thread with a MessageChannel.
|
|
14
|
+
* On reload, the old loader may become stale (the MessageChannel port can be
|
|
15
|
+
* GC'd or the loader thread can stop responding), so we unregister the old
|
|
16
|
+
* handle and re-register on each reload.
|
|
17
|
+
*
|
|
18
|
+
* Initial load: registers fresh.
|
|
19
|
+
* Reload: unregisters old handle, registers new one.
|
|
20
|
+
* Non-reload calls within the same load: no-op (tsRegistered guard).
|
|
21
|
+
*/
|
|
22
|
+
async function ensureTsSupport(force = false) {
|
|
23
|
+
if (tsRegistered && !force)
|
|
10
24
|
return;
|
|
11
25
|
try {
|
|
26
|
+
// Unregister previous loader if reloading
|
|
27
|
+
if (tsxUnregister) {
|
|
28
|
+
try {
|
|
29
|
+
await tsxUnregister();
|
|
30
|
+
}
|
|
31
|
+
catch { /* ignore stale handle */ }
|
|
32
|
+
}
|
|
12
33
|
const { register } = await import("tsx/esm/api");
|
|
13
|
-
register();
|
|
34
|
+
tsxUnregister = register();
|
|
14
35
|
tsRegistered = true;
|
|
15
36
|
}
|
|
16
37
|
catch {
|
|
@@ -166,7 +187,7 @@ async function loadSpecifiers(specifiers, ctx, bustCache, userSpecifiers) {
|
|
|
166
187
|
try {
|
|
167
188
|
let importPath = await resolveSpecifier(specifier);
|
|
168
189
|
if (TS_EXTS.some((ext) => importPath.endsWith(ext))) {
|
|
169
|
-
await ensureTsSupport();
|
|
190
|
+
await ensureTsSupport(bustCache);
|
|
170
191
|
}
|
|
171
192
|
// Append timestamp query to bust Node's module cache on reload
|
|
172
193
|
if (bustCache) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AgentLoop } from "../agent/agent-loop.js";
|
|
2
2
|
import { LlmClient } from "../utils/llm-client.js";
|
|
3
3
|
import { resolveProvider, getProviderNames, getSettings } from "../settings.js";
|
|
4
|
+
import { PACKAGE_VERSION } from "../utils/package-version.js";
|
|
4
5
|
/** Read the user's persisted defaultModel for a provider, if any. */
|
|
5
6
|
function persistedModelFor(providerName) {
|
|
6
7
|
if (!providerName)
|
|
@@ -71,7 +72,7 @@ export default function agentBackend(ctx) {
|
|
|
71
72
|
agentLoop.wire();
|
|
72
73
|
bus.emit("agent:info", {
|
|
73
74
|
name: "ash",
|
|
74
|
-
version:
|
|
75
|
+
version: PACKAGE_VERSION,
|
|
75
76
|
model: llmClient.model,
|
|
76
77
|
provider: modes[initialModeIndex]?.provider,
|
|
77
78
|
contextWindow: modes[initialModeIndex]?.contextWindow,
|
|
@@ -181,7 +182,7 @@ export default function agentBackend(ctx) {
|
|
|
181
182
|
};
|
|
182
183
|
});
|
|
183
184
|
bus.emit("config:set-modes", { modes: newModes });
|
|
184
|
-
bus.emit("agent:info", { name: "ash", version:
|
|
185
|
+
bus.emit("agent:info", { name: "ash", version: PACKAGE_VERSION, model: switchModel, provider: name, contextWindow: p.contextWindow });
|
|
185
186
|
bus.emit("ui:info", { message: `Switched to ${name} (${switchModel})` });
|
|
186
187
|
bus.emit("config:changed", {});
|
|
187
188
|
});
|