agent-sh 0.14.1 → 0.14.2
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/agent/agent-loop.d.ts +1 -1
- package/dist/agent/agent-loop.js +42 -31
- package/dist/agent/conversation-state.d.ts +3 -2
- package/dist/agent/conversation-state.js +20 -3
- package/dist/agent/events.d.ts +2 -0
- package/dist/agent/host-types.d.ts +3 -0
- package/dist/agent/index.js +2 -1
- package/dist/agent/subagent.d.ts +1 -1
- package/dist/agent/subagent.js +5 -1
- package/dist/agent/tool-protocol.d.ts +2 -2
- package/dist/agent/tool-protocol.js +5 -4
- package/dist/agent/tools/glob.d.ts +1 -1
- package/dist/agent/tools/glob.js +4 -2
- package/dist/agent/tools/grep.d.ts +1 -1
- package/dist/agent/tools/grep.js +4 -2
- package/dist/agent/tools/ls.d.ts +1 -1
- package/dist/agent/tools/ls.js +4 -2
- package/dist/agent/tools/read-file.d.ts +1 -1
- package/dist/agent/tools/read-file.js +30 -2
- package/dist/agent/types.d.ts +11 -1
- package/dist/agent/types.js +6 -1
- package/dist/cli/index.js +0 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/core/settings.d.ts +3 -0
- package/dist/core/settings.js +2 -2
- package/dist/shell/index.d.ts +6 -0
- package/dist/shell/index.js +10 -10
- package/dist/shell/shell.d.ts +4 -0
- package/dist/shell/shell.js +15 -29
- package/dist/shell/terminal.d.ts +33 -0
- package/dist/shell/terminal.js +62 -0
- package/examples/extensions/ash-scheme/index.ts +2170 -0
- package/examples/extensions/ash-scheme/package.json +11 -0
- package/examples/extensions/ash-scheme-render.ts +58 -0
- package/examples/extensions/ashi/README.md +36 -26
- package/examples/extensions/ashi/package.json +9 -1
- package/examples/extensions/ashi/src/capture.ts +1 -0
- package/examples/extensions/ashi/src/cli.ts +21 -7
- package/examples/extensions/ashi/src/compaction.ts +25 -96
- package/examples/extensions/ashi/src/components.ts +64 -166
- package/examples/extensions/ashi/src/default-schema-renderers.ts +229 -0
- package/examples/extensions/ashi/src/display-config.ts +21 -22
- package/examples/extensions/ashi/src/frontend.ts +64 -65
- package/examples/extensions/ashi/src/hooks.ts +47 -63
- package/examples/extensions/ashi/src/multi-session-store.ts +44 -3
- package/examples/extensions/ashi/src/schema.ts +407 -0
- package/examples/extensions/ashi/src/session-store.ts +55 -4
- package/examples/extensions/ashi/src/status-footer.ts +27 -6
- package/examples/extensions/ashi-compact-llm.ts +93 -0
- package/examples/extensions/claude-code-bridge/index.ts +2 -0
- package/examples/extensions/opencode-bridge/index.ts +3 -0
- package/examples/extensions/opencode-provider.ts +252 -0
- package/examples/extensions/pi-bridge/index.ts +1 -0
- package/package.json +12 -1
- package/examples/extensions/ashi/src/default-renderers.ts +0 -171
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
// Declarative render schema for tool-call hooks.
|
|
2
|
+
//
|
|
3
|
+
// External renderers register one hook per tool:
|
|
4
|
+
// ctx.define("ashi:render-tool:scheme", () => ({ initial, reducers, view }))
|
|
5
|
+
//
|
|
6
|
+
// The view function is pure: `view(state, env)` returns a ToolDisplay describing
|
|
7
|
+
// title + status + body. Ashi owns the pi-tui mapping, theming, streaming
|
|
8
|
+
// buffer policy, diff reflow on resize, expand/collapse — everything that used
|
|
9
|
+
// to leak into renderer subclasses.
|
|
10
|
+
|
|
11
|
+
import { Container, Spacer, Text } from "@earendil-works/pi-tui";
|
|
12
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
13
|
+
import type { ThemeColor } from "./theme.js";
|
|
14
|
+
import type { ToolEntryConfig } from "./display-config.js";
|
|
15
|
+
|
|
16
|
+
export type { ToolEntryConfig, ToolResultMode } from "./display-config.js";
|
|
17
|
+
|
|
18
|
+
export type Color = ThemeColor;
|
|
19
|
+
|
|
20
|
+
export interface StyleHint {
|
|
21
|
+
color?: Color;
|
|
22
|
+
bold?: boolean;
|
|
23
|
+
dim?: boolean;
|
|
24
|
+
italic?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type Segment = string | { text: string; style?: StyleHint; highlight?: string };
|
|
28
|
+
|
|
29
|
+
export type Body =
|
|
30
|
+
| { kind: "text"; segments: Segment[] }
|
|
31
|
+
| { kind: "code"; lang?: string; text: string }
|
|
32
|
+
/** Diff body — framework supplies the width-aware renderer via setDiffRenderer
|
|
33
|
+
* (called from frontend.ts when the edit/write tool finalizes). Renderers
|
|
34
|
+
* opt in by returning { kind: "diff" } and reading hasDiff from state. */
|
|
35
|
+
| { kind: "diff" }
|
|
36
|
+
| { kind: "stream"; text: string }
|
|
37
|
+
| { kind: "lines"; lines: Segment[][] }
|
|
38
|
+
| { kind: "compound"; parts: Body[] };
|
|
39
|
+
|
|
40
|
+
export interface DisplayStatus {
|
|
41
|
+
exitCode: number | null;
|
|
42
|
+
elapsedMs: number;
|
|
43
|
+
summary?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Built-in icon set ashi knows how to theme. Renderers pick a category;
|
|
47
|
+
* ashi picks the glyph. Falls back to the generic gear if absent. */
|
|
48
|
+
export type TitleIcon = "read" | "search" | "edit" | "shell" | "generic" | "scheme";
|
|
49
|
+
|
|
50
|
+
export interface ToolDisplay {
|
|
51
|
+
titleIcon?: TitleIcon;
|
|
52
|
+
title: Segment[];
|
|
53
|
+
status?: DisplayStatus;
|
|
54
|
+
body?: Body;
|
|
55
|
+
expandable?: boolean;
|
|
56
|
+
defaultExpanded?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What the host tells the view about the rendering environment. Pure:
|
|
60
|
+
* changes here trigger a re-invocation of view(). `mode` and `previewLines`
|
|
61
|
+
* come from ashi.display.{name} — see display-config.ts. */
|
|
62
|
+
export interface Env {
|
|
63
|
+
width: number;
|
|
64
|
+
expanded: boolean;
|
|
65
|
+
finalized: boolean;
|
|
66
|
+
mode: "preview" | "summary" | "hidden";
|
|
67
|
+
previewLines: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type Reducer<S, P = unknown> = (state: S, payload: P) => S;
|
|
71
|
+
|
|
72
|
+
/** State as seen by view() — the user's S plus framework-tracked output/status.
|
|
73
|
+
* Renderers never need to wire `chunk` / `status` / `diff` reducers themselves.
|
|
74
|
+
* hasDiff is true once setDiffRenderer has been called (edit/write tools). */
|
|
75
|
+
export type ViewState<S> = S & {
|
|
76
|
+
output: string;
|
|
77
|
+
status?: DisplayStatus;
|
|
78
|
+
hasDiff: boolean;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export interface RenderInitArgs {
|
|
82
|
+
rawInput?: unknown;
|
|
83
|
+
name: string;
|
|
84
|
+
title: string;
|
|
85
|
+
kind?: string;
|
|
86
|
+
displayDetail?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface RenderModel<S = Record<string, never>> {
|
|
90
|
+
initial: (args: RenderInitArgs) => S;
|
|
91
|
+
/** Optional. `status` and `chunk` are tracked by the framework — declare
|
|
92
|
+
* reducers here only for tool-specific state transitions. */
|
|
93
|
+
reducers?: Record<string, Reducer<ViewState<S>, never>>;
|
|
94
|
+
view: (state: ViewState<S>, env: Env) => ToolDisplay;
|
|
95
|
+
display?: Partial<ToolEntryConfig>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function isRenderModel(v: unknown): v is RenderModel<unknown> {
|
|
99
|
+
if (!v || typeof v !== "object") return false;
|
|
100
|
+
const o = v as Record<string, unknown>;
|
|
101
|
+
return typeof o.initial === "function" && typeof o.view === "function";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Adapter: model → paired Components for call-side and result-side hooks.
|
|
106
|
+
//
|
|
107
|
+
// Both Components share a single state cell so that a `chunk` dispatch from
|
|
108
|
+
// the result side repaints the call line too (e.g. for renderers that show
|
|
109
|
+
// progress in the title).
|
|
110
|
+
|
|
111
|
+
import { theme } from "./theme.js";
|
|
112
|
+
|
|
113
|
+
interface DiffSlot {
|
|
114
|
+
fn?: (width: number) => string[];
|
|
115
|
+
lastWidth: number;
|
|
116
|
+
cached: string[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface SharedCell<S> {
|
|
120
|
+
state: S;
|
|
121
|
+
env: Env;
|
|
122
|
+
diff: DiffSlot;
|
|
123
|
+
callView?: SchemaCallComponent;
|
|
124
|
+
resultView?: SchemaResultComponent;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface RenderHandle<S> {
|
|
128
|
+
cell: SharedCell<S>;
|
|
129
|
+
model: RenderModel<S>;
|
|
130
|
+
toolCallId: string;
|
|
131
|
+
dispatch: (action: string, payload?: unknown) => void;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Per-toolCallId handle registry — sole purpose is letting the result-side
|
|
135
|
+
* mount find the call-side cell so they can share state. Once the result
|
|
136
|
+
* component is mounted, both views hold their own handle reference and the
|
|
137
|
+
* map entry is dead weight; cleared on finalize. */
|
|
138
|
+
const HANDLES = new Map<string, RenderHandle<unknown>>();
|
|
139
|
+
|
|
140
|
+
export interface MountArgs {
|
|
141
|
+
toolCallId: string;
|
|
142
|
+
name: string;
|
|
143
|
+
title: string;
|
|
144
|
+
kind?: string;
|
|
145
|
+
displayDetail?: string;
|
|
146
|
+
rawInput?: unknown;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function handleFor<S>(
|
|
150
|
+
args: MountArgs,
|
|
151
|
+
model: RenderModel<S>,
|
|
152
|
+
envInit: { width: number; mode: Env["mode"]; previewLines: number },
|
|
153
|
+
): RenderHandle<S> {
|
|
154
|
+
const existing = HANDLES.get(args.toolCallId) as RenderHandle<S> | undefined;
|
|
155
|
+
if (existing) return existing;
|
|
156
|
+
const userInitial = model.initial({
|
|
157
|
+
rawInput: args.rawInput,
|
|
158
|
+
name: args.name,
|
|
159
|
+
title: args.title,
|
|
160
|
+
kind: args.kind,
|
|
161
|
+
displayDetail: args.displayDetail,
|
|
162
|
+
});
|
|
163
|
+
const cell: SharedCell<ViewState<S>> = {
|
|
164
|
+
state: { ...(userInitial as object), output: "", hasDiff: false } as ViewState<S>,
|
|
165
|
+
env: { ...envInit, expanded: false, finalized: false },
|
|
166
|
+
diff: { lastWidth: -1, cached: [] },
|
|
167
|
+
};
|
|
168
|
+
const reducers = model.reducers ?? {};
|
|
169
|
+
const handle: RenderHandle<ViewState<S>> = {
|
|
170
|
+
cell,
|
|
171
|
+
model: model as unknown as RenderModel<ViewState<S>>,
|
|
172
|
+
toolCallId: args.toolCallId,
|
|
173
|
+
dispatch(action, payload) {
|
|
174
|
+
if (action === "status") {
|
|
175
|
+
cell.state = { ...cell.state, status: payload as DisplayStatus };
|
|
176
|
+
} else if (action === "chunk") {
|
|
177
|
+
cell.state = { ...cell.state, output: cell.state.output + (payload as string) };
|
|
178
|
+
} else if (action === "diff") {
|
|
179
|
+
cell.diff = { fn: payload as (w: number) => string[], lastWidth: -1, cached: [] };
|
|
180
|
+
cell.state = { ...cell.state, hasDiff: true };
|
|
181
|
+
} else {
|
|
182
|
+
const reducer = reducers[action];
|
|
183
|
+
if (!reducer) return;
|
|
184
|
+
cell.state = (reducer as Reducer<ViewState<S>, unknown>)(cell.state, payload);
|
|
185
|
+
}
|
|
186
|
+
cell.callView?.repaint();
|
|
187
|
+
cell.resultView?.repaint();
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
HANDLES.set(args.toolCallId, handle as unknown as RenderHandle<unknown>);
|
|
191
|
+
return handle as unknown as RenderHandle<S>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Segment / Body → ANSI string rendering. Lives here so it's the only place
|
|
196
|
+
// that knows about theme colors + highlighting; renderers stay pure-data.
|
|
197
|
+
|
|
198
|
+
import { highlight, supportsLanguage } from "cli-highlight";
|
|
199
|
+
|
|
200
|
+
function styleSegment(seg: Segment): string {
|
|
201
|
+
if (typeof seg === "string") return seg;
|
|
202
|
+
let text = seg.text;
|
|
203
|
+
if (seg.highlight && supportsLanguage(seg.highlight)) {
|
|
204
|
+
try { text = highlight(text, { language: seg.highlight, ignoreIllegals: true }); }
|
|
205
|
+
catch { /* fall through */ }
|
|
206
|
+
}
|
|
207
|
+
const s = seg.style;
|
|
208
|
+
if (!s) return text;
|
|
209
|
+
if (s.color) text = theme.fg(s.color, text);
|
|
210
|
+
if (s.bold) text = theme.bold(text);
|
|
211
|
+
if (s.italic) text = theme.italic(text);
|
|
212
|
+
if (s.dim) text = theme.fg("dim", text);
|
|
213
|
+
return text;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function segmentsToString(segs: Segment[]): string {
|
|
217
|
+
return segs.map(styleSegment).join("");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const TITLE_ICON_GLYPH: Record<TitleIcon, string> = {
|
|
221
|
+
read: "◆", search: "⌕", edit: "✎", shell: "$", scheme: "λ", generic: "⚙",
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
function iconString(icon?: TitleIcon): string {
|
|
225
|
+
if (!icon) return "";
|
|
226
|
+
return `${theme.fg("warning", TITLE_ICON_GLYPH[icon])} `;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function fmtElapsed(ms: number): string {
|
|
230
|
+
if (ms < 1000) return `${ms}ms`;
|
|
231
|
+
if (ms < 10_000) return `${(ms / 1000).toFixed(2)}s`;
|
|
232
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
233
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
234
|
+
return `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function statusSuffix(s?: DisplayStatus): string {
|
|
238
|
+
if (!s) return ` ${theme.fg("muted", "…")}`;
|
|
239
|
+
const ok = s.exitCode === null || s.exitCode === 0;
|
|
240
|
+
const mark = ok ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
241
|
+
const elapsed = s.elapsedMs > 0 ? ` ${theme.fg("muted", fmtElapsed(s.elapsedMs))}` : "";
|
|
242
|
+
const sum = s.summary ? ` ${theme.fg("muted", s.summary)}` : "";
|
|
243
|
+
return ` ${mark}${elapsed}${sum}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function renderBody(body: Body, env: Env, diff: DiffSlot, exitCode?: number | null): string {
|
|
247
|
+
switch (body.kind) {
|
|
248
|
+
case "text":
|
|
249
|
+
return segmentsToString(body.segments);
|
|
250
|
+
case "code": {
|
|
251
|
+
if (body.lang && supportsLanguage(body.lang)) {
|
|
252
|
+
try { return highlight(body.text, { language: body.lang, ignoreIllegals: true }); }
|
|
253
|
+
catch { /* fall through */ }
|
|
254
|
+
}
|
|
255
|
+
return body.text;
|
|
256
|
+
}
|
|
257
|
+
case "stream":
|
|
258
|
+
return renderStream(body.text, env, exitCode);
|
|
259
|
+
case "lines":
|
|
260
|
+
return body.lines.map(segmentsToString).join("\n");
|
|
261
|
+
case "diff": {
|
|
262
|
+
if (!diff.fn) return "";
|
|
263
|
+
if (diff.lastWidth !== env.width) {
|
|
264
|
+
diff.cached = diff.fn(env.width);
|
|
265
|
+
diff.lastWidth = env.width;
|
|
266
|
+
}
|
|
267
|
+
return diff.cached.join("\n");
|
|
268
|
+
}
|
|
269
|
+
case "compound":
|
|
270
|
+
return body.parts.map((p) => renderBody(p, env, diff, exitCode)).join("\n\n");
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Lifted from ToolResultBody.repaint() in components.ts — preview/summary/hidden
|
|
275
|
+
// policy is host-wide display config, not per-tool, so it lives here once and
|
|
276
|
+
// every schema renderer with a kind:"stream" body inherits it for free.
|
|
277
|
+
function renderStream(buffer: string, env: Env, exitCode: number | null | undefined): string {
|
|
278
|
+
const display = buffer.replace(/\n+$/, "");
|
|
279
|
+
if (env.expanded) return theme.fg("toolOutput", display);
|
|
280
|
+
if (env.mode === "hidden") {
|
|
281
|
+
if (!env.finalized) return "";
|
|
282
|
+
return lineCountHint(buffer, exitCode);
|
|
283
|
+
}
|
|
284
|
+
if (env.mode === "summary") {
|
|
285
|
+
if (!env.finalized) {
|
|
286
|
+
const tail = display.split("\n").slice(-2).join("\n");
|
|
287
|
+
return theme.fg("muted", tail);
|
|
288
|
+
}
|
|
289
|
+
return lineCountHint(buffer, exitCode);
|
|
290
|
+
}
|
|
291
|
+
if (!display) return "";
|
|
292
|
+
const lines = display.split("\n");
|
|
293
|
+
const trimmed = lines.slice(-env.previewLines).join("\n");
|
|
294
|
+
const remaining = Math.max(0, lines.length - env.previewLines);
|
|
295
|
+
const overflow = remaining > 0
|
|
296
|
+
? `\n${theme.fg("muted", `... (${remaining} more ${remaining === 1 ? "line" : "lines"})`)}`
|
|
297
|
+
: "";
|
|
298
|
+
return `${theme.fg("toolOutput", trimmed)}${overflow}`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function lineCountHint(buffer: string, exitCode: number | null | undefined): string {
|
|
302
|
+
const lines = buffer.split("\n").filter((l) => l.length > 0);
|
|
303
|
+
const label = lines.length === 1 ? "1 line" : `${lines.length} lines`;
|
|
304
|
+
const ok = exitCode === null || exitCode === 0;
|
|
305
|
+
const arrow = ok ? theme.fg("muted", "↳ ") : theme.fg("error", "↳ ");
|
|
306
|
+
return `${arrow}${theme.fg("muted", label)}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// Pi-tui Components produced by the adapter. Implement the existing
|
|
311
|
+
// ToolCallView / ToolResultView contracts so the ashi resolver doesn't care
|
|
312
|
+
// whether a renderer is legacy or schema-style.
|
|
313
|
+
|
|
314
|
+
class SchemaCallComponent extends Container {
|
|
315
|
+
private line: Text;
|
|
316
|
+
constructor(private handle: RenderHandle<unknown>) {
|
|
317
|
+
super();
|
|
318
|
+
this.line = new Text("", 1, 0);
|
|
319
|
+
this.addChild(new Spacer(1));
|
|
320
|
+
this.addChild(this.line);
|
|
321
|
+
handle.cell.callView = this;
|
|
322
|
+
this.repaint();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
setStatus(opts: { exitCode: number | null; elapsedMs: number; summary?: string }): void {
|
|
326
|
+
this.handle.dispatch("status", opts);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
repaint(): void {
|
|
330
|
+
const display = this.handle.model.view(this.handle.cell.state as ViewState<unknown>, this.handle.cell.env);
|
|
331
|
+
const icon = iconString(display.titleIcon);
|
|
332
|
+
const title = segmentsToString(display.title);
|
|
333
|
+
this.line.setText(`${icon}${title}${statusSuffix(display.status)}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
class SchemaResultComponent extends Container {
|
|
338
|
+
private body: Text;
|
|
339
|
+
constructor(private handle: RenderHandle<unknown>) {
|
|
340
|
+
super();
|
|
341
|
+
this.body = new Text("", 0, 0);
|
|
342
|
+
this.addChild(this.body);
|
|
343
|
+
handle.cell.resultView = this;
|
|
344
|
+
this.repaint();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
appendChunk(chunk: string): void { this.handle.dispatch("chunk", chunk); }
|
|
348
|
+
setDiffRenderer(fn: (width: number) => string[]): void { this.handle.dispatch("diff", fn); }
|
|
349
|
+
finalize(opts: { exitCode: number | null; summary?: string }): void {
|
|
350
|
+
this.handle.cell.env = { ...this.handle.cell.env, finalized: true };
|
|
351
|
+
this.handle.dispatch("status", { ...opts, elapsedMs: 0 });
|
|
352
|
+
HANDLES.delete(this.handle.toolCallId);
|
|
353
|
+
}
|
|
354
|
+
toggleExpanded(): void {
|
|
355
|
+
this.handle.cell.env = { ...this.handle.cell.env, expanded: !this.handle.cell.env.expanded };
|
|
356
|
+
this.repaint();
|
|
357
|
+
this.handle.cell.callView?.repaint();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
override render(width: number): string[] {
|
|
361
|
+
if (this.handle.cell.env.width !== width) {
|
|
362
|
+
this.handle.cell.env = { ...this.handle.cell.env, width };
|
|
363
|
+
this.repaint();
|
|
364
|
+
}
|
|
365
|
+
return super.render(width);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
repaint(): void {
|
|
369
|
+
const env = this.handle.cell.env;
|
|
370
|
+
const display = this.handle.model.view(this.handle.cell.state as ViewState<unknown>, env);
|
|
371
|
+
if (!display.body) { this.body.setText(""); return; }
|
|
372
|
+
// kind:"stream" embeds preview/summary/hidden policy.
|
|
373
|
+
// kind:"diff" shows in preview mode or when expanded.
|
|
374
|
+
// Other kinds show iff expanded or the view requested defaultExpanded.
|
|
375
|
+
if (display.body.kind === "diff" && !env.expanded && env.mode !== "preview") {
|
|
376
|
+
this.body.setText("");
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const policied = display.body.kind === "stream" || display.body.kind === "diff";
|
|
380
|
+
if (!policied && !env.expanded && !display.defaultExpanded) {
|
|
381
|
+
this.body.setText("");
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
this.body.setText(renderBody(display.body, env, this.handle.cell.diff, display.status?.exitCode));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// Public mount functions used by hooks.ts when resolving a schema-style
|
|
390
|
+
// renderer. Each returns a Component that satisfies the legacy view contract,
|
|
391
|
+
// so the rest of ashi doesn't need to know schema renderers exist.
|
|
392
|
+
|
|
393
|
+
export interface MountEnv {
|
|
394
|
+
width: number;
|
|
395
|
+
mode: Env["mode"];
|
|
396
|
+
previewLines: number;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function mountCall<S>(model: RenderModel<S>, args: MountArgs, env: MountEnv): Component {
|
|
400
|
+
const handle = handleFor(args, model, env);
|
|
401
|
+
return new SchemaCallComponent(handle as RenderHandle<unknown>);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function mountResult<S>(model: RenderModel<S>, args: MountArgs, env: MountEnv): Component {
|
|
405
|
+
const handle = handleFor(args, model, env);
|
|
406
|
+
return new SchemaResultComponent(handle as RenderHandle<unknown>);
|
|
407
|
+
}
|
|
@@ -39,7 +39,7 @@ export interface CompactionEntry {
|
|
|
39
39
|
id: string;
|
|
40
40
|
parentId: string;
|
|
41
41
|
timestamp: number;
|
|
42
|
-
summary
|
|
42
|
+
summary?: string;
|
|
43
43
|
firstKeptId: string;
|
|
44
44
|
tokensBefore: number;
|
|
45
45
|
}
|
|
@@ -55,6 +55,52 @@ export function newEntryId(): string {
|
|
|
55
55
|
return crypto.randomBytes(4).toString("hex");
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
function extractText(content: unknown): string {
|
|
59
|
+
if (typeof content === "string") return content;
|
|
60
|
+
if (Array.isArray(content)) {
|
|
61
|
+
return content.map((p) => {
|
|
62
|
+
if (typeof p === "string") return p;
|
|
63
|
+
const part = p as { text?: string; content?: string };
|
|
64
|
+
return part?.text ?? part?.content ?? "";
|
|
65
|
+
}).join(" ");
|
|
66
|
+
}
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function snippet(text: string, max: number): string {
|
|
71
|
+
const cleaned = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
72
|
+
if (cleaned.length <= max) return cleaned || "(empty)";
|
|
73
|
+
return cleaned.slice(0, max) + "…";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function summarizeMessage(m: AgentMessage): string {
|
|
77
|
+
const role = m.role ?? "?";
|
|
78
|
+
if (role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
|
|
79
|
+
const tools = m.tool_calls.map((tc) => {
|
|
80
|
+
const name = tc.function?.name ?? "tool";
|
|
81
|
+
const args = tc.function?.arguments;
|
|
82
|
+
return args ? `${name}(${snippet(args, 200)})` : name;
|
|
83
|
+
}).join(", ");
|
|
84
|
+
const text = extractText(m.content);
|
|
85
|
+
const prefix = text ? `${snippet(text, 400)} → ` : "";
|
|
86
|
+
return `assistant: ${prefix}called ${tools}`;
|
|
87
|
+
}
|
|
88
|
+
if (role === "tool") {
|
|
89
|
+
const text = typeof m.content === "string" ? m.content : extractText(m.content);
|
|
90
|
+
const isErr = /^error\b|: error\b/i.test(text.slice(0, 200));
|
|
91
|
+
return `tool result: ${snippet(text, isErr ? 1000 : 400)}`;
|
|
92
|
+
}
|
|
93
|
+
if (role === "user") {
|
|
94
|
+
return `user: ${snippet(extractText(m.content), 1000)}`;
|
|
95
|
+
}
|
|
96
|
+
return `${role}: ${snippet(extractText(m.content), 500)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function renderEvictedSummary(evicted: AgentMessage[]): string {
|
|
100
|
+
const lines = evicted.map((m) => `- ${summarizeMessage(m)}`);
|
|
101
|
+
return `${lines.length} message(s) elided\n${lines.join("\n")}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
58
104
|
/** One session = one JSONL file (entries) + sidecar files for leaf & meta.
|
|
59
105
|
* Tree is implicit via parentId pointers; entries kept in memory after load. */
|
|
60
106
|
export class SessionStore {
|
|
@@ -152,7 +198,7 @@ export class SessionStore {
|
|
|
152
198
|
return newIds;
|
|
153
199
|
}
|
|
154
200
|
|
|
155
|
-
async appendCompaction(
|
|
201
|
+
async appendCompaction(firstKeptId: string, tokensBefore: number, summary?: string): Promise<string> {
|
|
156
202
|
if (!this.entries.has(firstKeptId)) throw new Error(`firstKeptId unknown: ${firstKeptId}`);
|
|
157
203
|
this.flushHeader();
|
|
158
204
|
const e: CompactionEntry = {
|
|
@@ -160,9 +206,9 @@ export class SessionStore {
|
|
|
160
206
|
id: newEntryId(),
|
|
161
207
|
parentId: this.activeLeaf,
|
|
162
208
|
timestamp: Date.now(),
|
|
163
|
-
summary,
|
|
164
209
|
firstKeptId,
|
|
165
210
|
tokensBefore,
|
|
211
|
+
...(summary !== undefined ? { summary } : {}),
|
|
166
212
|
};
|
|
167
213
|
this.entries.set(e.id, e);
|
|
168
214
|
this.activeLeaf = e.id;
|
|
@@ -203,9 +249,14 @@ export class SessionStore {
|
|
|
203
249
|
const c = branch[compactionIdx] as CompactionEntry;
|
|
204
250
|
const firstKeptIdx = branch.findIndex((e) => e.id === c.firstKeptId);
|
|
205
251
|
const keepFrom = firstKeptIdx >= 0 ? firstKeptIdx : 0;
|
|
252
|
+
const summary = c.summary ?? renderEvictedSummary(
|
|
253
|
+
branch.slice(0, keepFrom)
|
|
254
|
+
.filter((e): e is MessageEntry => e.type === "message")
|
|
255
|
+
.map((e) => e.message),
|
|
256
|
+
);
|
|
206
257
|
const out: AgentMessage[] = [{
|
|
207
258
|
role: "user",
|
|
208
|
-
content: `[Compacted conversation summary]\n${
|
|
259
|
+
content: `[Compacted conversation summary]\n${summary}`,
|
|
209
260
|
}];
|
|
210
261
|
for (let i = keepFrom; i < branch.length; i++) {
|
|
211
262
|
const e = branch[i]!;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { Container, Text, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
3
|
import { theme } from "./theme.js";
|
|
3
4
|
|
|
4
5
|
interface StatusFields {
|
|
@@ -16,6 +17,7 @@ interface StatusFields {
|
|
|
16
17
|
export class StatusFooter extends Container {
|
|
17
18
|
private text: Text;
|
|
18
19
|
private fields: StatusFields = {};
|
|
20
|
+
private lastWidth = 0;
|
|
19
21
|
|
|
20
22
|
constructor() {
|
|
21
23
|
super();
|
|
@@ -25,10 +27,28 @@ export class StatusFooter extends Container {
|
|
|
25
27
|
|
|
26
28
|
update(patch: Partial<StatusFields>): void {
|
|
27
29
|
this.fields = { ...this.fields, ...patch };
|
|
28
|
-
this.repaint();
|
|
30
|
+
this.repaint(this.lastWidth);
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
render(width: number): string[] {
|
|
34
|
+
if (width !== this.lastWidth) {
|
|
35
|
+
this.lastWidth = width;
|
|
36
|
+
this.repaint(width);
|
|
37
|
+
}
|
|
38
|
+
return super.render(width);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private repaint(width: number): void {
|
|
42
|
+
const contentWidth = width > 0 ? Math.max(1, width - 2) : 0;
|
|
43
|
+
const full = this.buildLine("full");
|
|
44
|
+
if (contentWidth === 0 || visibleWidth(full) <= contentWidth) {
|
|
45
|
+
this.text.setText(full);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
this.text.setText(this.buildLine("basename"));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private buildLine(cwdMode: "full" | "basename"): string {
|
|
32
52
|
const { model, provider, contextWindow, cwd, branch, leaf, tokens, compactions, thinking } = this.fields;
|
|
33
53
|
const sep = theme.fg("dim", " | ");
|
|
34
54
|
const parts: string[] = [];
|
|
@@ -39,7 +59,7 @@ export class StatusFooter extends Container {
|
|
|
39
59
|
} else if (provider) {
|
|
40
60
|
parts.push(theme.fg("muted", `@${provider}`));
|
|
41
61
|
}
|
|
42
|
-
if (cwd) parts.push(theme.fg("muted",
|
|
62
|
+
if (cwd) parts.push(theme.fg("muted", formatCwd(cwd, cwdMode)));
|
|
43
63
|
if (branch) parts.push(theme.fg("muted", `⎇ ${branch}`));
|
|
44
64
|
if (leaf != null && leaf > 0) parts.push(theme.fg("muted", `#${leaf}`));
|
|
45
65
|
if (tokens != null) {
|
|
@@ -48,11 +68,12 @@ export class StatusFooter extends Container {
|
|
|
48
68
|
parts.push(`${theme.fg("muted", tokStr)}${pct}`);
|
|
49
69
|
}
|
|
50
70
|
if (compactions && compactions > 0) parts.push(theme.fg("muted", `⊟ ${compactions}`));
|
|
51
|
-
|
|
71
|
+
return parts.length === 0 ? "" : parts.join(sep);
|
|
52
72
|
}
|
|
53
73
|
}
|
|
54
74
|
|
|
55
|
-
function
|
|
75
|
+
function formatCwd(cwd: string, mode: "full" | "basename"): string {
|
|
76
|
+
if (mode === "basename") return basename(cwd) || cwd;
|
|
56
77
|
const home = process.env.HOME;
|
|
57
78
|
if (home && cwd.startsWith(`${home}/`)) return `~/${cwd.slice(home.length + 1)}`;
|
|
58
79
|
if (home && cwd === home) return "~";
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replaces ashi's default deterministic compaction summary with an
|
|
3
|
+
* LLM-generated structured one. Advises `ashi:compact:build-summary`;
|
|
4
|
+
* orchestration stays in ashi. Falls back to deterministic when the LLM
|
|
5
|
+
* is unavailable or the call fails.
|
|
6
|
+
*/
|
|
7
|
+
import type { AgentContext } from "agent-sh/types";
|
|
8
|
+
|
|
9
|
+
interface AgentMessage {
|
|
10
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
11
|
+
content?: unknown;
|
|
12
|
+
tool_calls?: { function?: { name?: string; arguments?: string } }[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const SUMMARY_PROMPT = `You are compacting a coding-agent conversation so the agent can continue with limited context.
|
|
16
|
+
|
|
17
|
+
Produce a Markdown summary using EXACTLY this structure:
|
|
18
|
+
|
|
19
|
+
## Goal
|
|
20
|
+
[What the user is trying to accomplish, one or two sentences]
|
|
21
|
+
|
|
22
|
+
## Constraints & Preferences
|
|
23
|
+
- [Bulleted user requirements / preferences expressed so far]
|
|
24
|
+
|
|
25
|
+
## Progress
|
|
26
|
+
### Done
|
|
27
|
+
- [x] [Completed work]
|
|
28
|
+
|
|
29
|
+
### In Progress
|
|
30
|
+
- [ ] [Active work and current sub-goal]
|
|
31
|
+
|
|
32
|
+
### Blocked
|
|
33
|
+
- [Issues, or "None"]
|
|
34
|
+
|
|
35
|
+
## Key Decisions
|
|
36
|
+
- **[Decision]**: [Rationale]
|
|
37
|
+
|
|
38
|
+
## Next Steps
|
|
39
|
+
1. [What should happen next]
|
|
40
|
+
|
|
41
|
+
## Critical Context
|
|
42
|
+
- [Specific paths, names, identifiers, or data the agent must remember]
|
|
43
|
+
|
|
44
|
+
Be concrete. Quote file paths, function names, error strings verbatim when relevant. Do not invent details that aren't in the conversation.`;
|
|
45
|
+
|
|
46
|
+
export default function activate(ctx: AgentContext): void {
|
|
47
|
+
ctx.advise(
|
|
48
|
+
"ashi:compact:build-summary",
|
|
49
|
+
async (next: (...a: unknown[]) => unknown, evicted: AgentMessage[]) => {
|
|
50
|
+
const llm = ctx.agent?.llm;
|
|
51
|
+
if (!llm?.available) return next(evicted);
|
|
52
|
+
try {
|
|
53
|
+
const summary = await llm.ask({
|
|
54
|
+
system: SUMMARY_PROMPT,
|
|
55
|
+
query: buildQuery(evicted),
|
|
56
|
+
maxTokens: 16384,
|
|
57
|
+
reasoningEffort: "low",
|
|
58
|
+
});
|
|
59
|
+
return summary.trim();
|
|
60
|
+
} catch (e) {
|
|
61
|
+
ctx.bus.emit("ui:error", {
|
|
62
|
+
message: `ashi-compact-llm: LLM failed (${(e as Error).message}); falling back to deterministic summary`,
|
|
63
|
+
});
|
|
64
|
+
return next(evicted);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildQuery(messages: AgentMessage[]): string {
|
|
71
|
+
const lines: string[] = ["Conversation to summarize:"];
|
|
72
|
+
for (const m of messages) {
|
|
73
|
+
const text = typeof m.content === "string" ? m.content : "";
|
|
74
|
+
if (m.role === "user") lines.push(`[User]: ${text}`);
|
|
75
|
+
else if (m.role === "assistant") {
|
|
76
|
+
if (text) lines.push(`[Assistant]: ${text}`);
|
|
77
|
+
if (m.tool_calls) {
|
|
78
|
+
for (const t of m.tool_calls) {
|
|
79
|
+
const args = t.function?.arguments ?? "";
|
|
80
|
+
lines.push(`[Assistant tool call]: ${t.function?.name ?? "?"}(${truncate(args, 400)})`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} else if (m.role === "tool") {
|
|
84
|
+
lines.push(`[Tool result]: ${truncate(text, 2000)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return lines.join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function truncate(s: string, max: number): string {
|
|
91
|
+
if (s.length <= max) return s;
|
|
92
|
+
return s.slice(0, max) + `\n[…truncated ${s.length - max} chars…]`;
|
|
93
|
+
}
|
|
@@ -140,6 +140,7 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
140
140
|
const kind = toolKind(meta.name);
|
|
141
141
|
bus.emit("agent:tool-started", {
|
|
142
142
|
title: meta.name,
|
|
143
|
+
name: meta.name,
|
|
143
144
|
toolCallId: meta.id,
|
|
144
145
|
kind,
|
|
145
146
|
icon: toolIcon(meta.name),
|
|
@@ -176,6 +177,7 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
176
177
|
const kind = toolKind(b.name);
|
|
177
178
|
bus.emit("agent:tool-started", {
|
|
178
179
|
title: b.name,
|
|
180
|
+
name: b.name,
|
|
179
181
|
toolCallId: b.id,
|
|
180
182
|
kind,
|
|
181
183
|
icon: toolIcon(b.name),
|