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.
Files changed (55) hide show
  1. package/dist/agent/agent-loop.d.ts +1 -1
  2. package/dist/agent/agent-loop.js +42 -31
  3. package/dist/agent/conversation-state.d.ts +3 -2
  4. package/dist/agent/conversation-state.js +20 -3
  5. package/dist/agent/events.d.ts +2 -0
  6. package/dist/agent/host-types.d.ts +3 -0
  7. package/dist/agent/index.js +2 -1
  8. package/dist/agent/subagent.d.ts +1 -1
  9. package/dist/agent/subagent.js +5 -1
  10. package/dist/agent/tool-protocol.d.ts +2 -2
  11. package/dist/agent/tool-protocol.js +5 -4
  12. package/dist/agent/tools/glob.d.ts +1 -1
  13. package/dist/agent/tools/glob.js +4 -2
  14. package/dist/agent/tools/grep.d.ts +1 -1
  15. package/dist/agent/tools/grep.js +4 -2
  16. package/dist/agent/tools/ls.d.ts +1 -1
  17. package/dist/agent/tools/ls.js +4 -2
  18. package/dist/agent/tools/read-file.d.ts +1 -1
  19. package/dist/agent/tools/read-file.js +30 -2
  20. package/dist/agent/types.d.ts +11 -1
  21. package/dist/agent/types.js +6 -1
  22. package/dist/cli/index.js +0 -0
  23. package/dist/core/index.d.ts +1 -1
  24. package/dist/core/settings.d.ts +3 -0
  25. package/dist/core/settings.js +2 -2
  26. package/dist/shell/index.d.ts +6 -0
  27. package/dist/shell/index.js +10 -10
  28. package/dist/shell/shell.d.ts +4 -0
  29. package/dist/shell/shell.js +15 -29
  30. package/dist/shell/terminal.d.ts +33 -0
  31. package/dist/shell/terminal.js +62 -0
  32. package/examples/extensions/ash-scheme/index.ts +2170 -0
  33. package/examples/extensions/ash-scheme/package.json +11 -0
  34. package/examples/extensions/ash-scheme-render.ts +58 -0
  35. package/examples/extensions/ashi/README.md +36 -26
  36. package/examples/extensions/ashi/package.json +9 -1
  37. package/examples/extensions/ashi/src/capture.ts +1 -0
  38. package/examples/extensions/ashi/src/cli.ts +21 -7
  39. package/examples/extensions/ashi/src/compaction.ts +25 -96
  40. package/examples/extensions/ashi/src/components.ts +64 -166
  41. package/examples/extensions/ashi/src/default-schema-renderers.ts +229 -0
  42. package/examples/extensions/ashi/src/display-config.ts +21 -22
  43. package/examples/extensions/ashi/src/frontend.ts +64 -65
  44. package/examples/extensions/ashi/src/hooks.ts +47 -63
  45. package/examples/extensions/ashi/src/multi-session-store.ts +44 -3
  46. package/examples/extensions/ashi/src/schema.ts +407 -0
  47. package/examples/extensions/ashi/src/session-store.ts +55 -4
  48. package/examples/extensions/ashi/src/status-footer.ts +27 -6
  49. package/examples/extensions/ashi-compact-llm.ts +93 -0
  50. package/examples/extensions/claude-code-bridge/index.ts +2 -0
  51. package/examples/extensions/opencode-bridge/index.ts +3 -0
  52. package/examples/extensions/opencode-provider.ts +252 -0
  53. package/examples/extensions/pi-bridge/index.ts +1 -0
  54. package/package.json +12 -1
  55. 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: string;
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(summary: string, firstKeptId: string, tokensBefore: number): Promise<string> {
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${c.summary}`,
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 { Container, Text } from "@earendil-works/pi-tui";
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
- private repaint(): void {
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", shortenCwd(cwd)));
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
- this.text.setText(parts.length === 0 ? "" : parts.join(sep));
71
+ return parts.length === 0 ? "" : parts.join(sep);
52
72
  }
53
73
  }
54
74
 
55
- function shortenCwd(cwd: string): string {
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),