agent-sh 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +27 -43
  2. package/dist/agent/agent-loop.d.ts +69 -6
  3. package/dist/agent/agent-loop.js +954 -153
  4. package/dist/agent/conversation-state.d.ts +74 -21
  5. package/dist/agent/conversation-state.js +361 -150
  6. package/dist/agent/history-file.d.ts +13 -4
  7. package/dist/agent/history-file.js +110 -36
  8. package/dist/agent/nuclear-form.d.ts +28 -3
  9. package/dist/agent/nuclear-form.js +88 -6
  10. package/dist/agent/skills.d.ts +2 -4
  11. package/dist/agent/skills.js +10 -4
  12. package/dist/agent/subagent.d.ts +23 -0
  13. package/dist/agent/subagent.js +53 -11
  14. package/dist/agent/system-prompt.d.ts +37 -5
  15. package/dist/agent/system-prompt.js +100 -67
  16. package/dist/{token-budget.d.ts → agent/token-budget.d.ts} +5 -4
  17. package/dist/{token-budget.js → agent/token-budget.js} +15 -20
  18. package/dist/agent/tool-protocol.d.ts +105 -0
  19. package/dist/agent/tool-protocol.js +551 -0
  20. package/dist/agent/tools/bash.js +3 -3
  21. package/dist/agent/tools/edit-file.js +9 -6
  22. package/dist/agent/tools/glob.js +4 -2
  23. package/dist/agent/tools/grep.js +27 -3
  24. package/dist/agent/tools/ls.js +5 -6
  25. package/dist/agent/types.d.ts +22 -2
  26. package/dist/context-manager.d.ts +17 -0
  27. package/dist/context-manager.js +37 -4
  28. package/dist/core.d.ts +7 -7
  29. package/dist/core.js +99 -196
  30. package/dist/event-bus.d.ts +85 -2
  31. package/dist/event-bus.js +20 -1
  32. package/dist/executor.d.ts +4 -3
  33. package/dist/executor.js +18 -15
  34. package/dist/extension-loader.d.ts +5 -0
  35. package/dist/extension-loader.js +143 -19
  36. package/dist/extensions/agent-backend.d.ts +14 -0
  37. package/dist/extensions/agent-backend.js +188 -0
  38. package/dist/extensions/command-suggest.d.ts +3 -3
  39. package/dist/extensions/command-suggest.js +4 -3
  40. package/dist/extensions/index.d.ts +19 -0
  41. package/dist/extensions/index.js +24 -0
  42. package/dist/extensions/slash-commands.d.ts +1 -1
  43. package/dist/extensions/slash-commands.js +30 -10
  44. package/dist/extensions/tui-renderer.js +117 -113
  45. package/dist/index.js +39 -26
  46. package/dist/settings.d.ts +40 -3
  47. package/dist/settings.js +57 -10
  48. package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +3 -2
  49. package/dist/{input-handler.js → shell/input-handler.js} +111 -85
  50. package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
  51. package/dist/{output-parser.js → shell/output-parser.js} +1 -1
  52. package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
  53. package/dist/{shell.js → shell/shell.js} +39 -8
  54. package/dist/types.d.ts +61 -10
  55. package/dist/utils/ansi.d.ts +5 -0
  56. package/dist/utils/ansi.js +1 -1
  57. package/dist/utils/compositor.d.ts +67 -0
  58. package/dist/utils/compositor.js +116 -0
  59. package/dist/utils/diff-renderer.d.ts +9 -0
  60. package/dist/utils/diff-renderer.js +312 -146
  61. package/dist/utils/diff.d.ts +21 -2
  62. package/dist/utils/diff.js +165 -89
  63. package/dist/utils/floating-panel.d.ts +2 -0
  64. package/dist/utils/floating-panel.js +30 -14
  65. package/dist/utils/handler-registry.d.ts +31 -10
  66. package/dist/utils/handler-registry.js +58 -16
  67. package/dist/utils/line-editor.d.ts +33 -3
  68. package/dist/utils/line-editor.js +221 -44
  69. package/dist/utils/markdown.d.ts +1 -0
  70. package/dist/utils/markdown.js +1 -1
  71. package/dist/utils/message-utils.d.ts +35 -0
  72. package/dist/utils/message-utils.js +75 -0
  73. package/dist/utils/terminal-buffer.d.ts +5 -1
  74. package/dist/utils/terminal-buffer.js +18 -2
  75. package/dist/utils/tool-display.d.ts +1 -1
  76. package/dist/utils/tool-display.js +4 -4
  77. package/dist/utils/tool-interactive.d.ts +12 -0
  78. package/dist/utils/tool-interactive.js +53 -0
  79. package/examples/extensions/ash-acp-bridge/README.md +39 -0
  80. package/examples/extensions/ash-acp-bridge/package.json +23 -0
  81. package/examples/extensions/ash-acp-bridge/src/index.ts +574 -0
  82. package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
  83. package/examples/extensions/ash-mcp-bridge/README.md +72 -0
  84. package/examples/extensions/ash-mcp-bridge/index.ts +164 -0
  85. package/examples/extensions/ash-mcp-bridge/package.json +9 -0
  86. package/examples/extensions/claude-code-bridge/index.ts +198 -51
  87. package/examples/extensions/claude-code-bridge/package.json +1 -0
  88. package/examples/extensions/interactive-prompts.ts +98 -112
  89. package/examples/extensions/overlay-agent.ts +84 -38
  90. package/examples/extensions/peer-mesh.ts +565 -0
  91. package/examples/extensions/pi-bridge/index.ts +2 -2
  92. package/examples/extensions/questionnaire.ts +260 -0
  93. package/examples/extensions/subagents.ts +19 -4
  94. package/examples/extensions/terminal-buffer.ts +32 -53
  95. package/examples/extensions/tmux-pane.ts +307 -0
  96. package/examples/extensions/user-shell.ts +136 -0
  97. package/examples/extensions/web-access.ts +335 -0
  98. package/package.json +44 -2
  99. package/dist/agent/tools/display.d.ts +0 -13
  100. package/dist/agent/tools/display.js +0 -70
  101. package/dist/agent/tools/user-shell.d.ts +0 -13
  102. package/dist/agent/tools/user-shell.js +0 -87
  103. package/dist/extensions/overlay-agent.d.ts +0 -14
  104. package/dist/extensions/overlay-agent.js +0 -147
  105. package/dist/extensions/terminal-buffer.d.ts +0 -14
  106. package/dist/extensions/terminal-buffer.js +0 -125
@@ -1,24 +1,82 @@
1
- import { getSettings } from "../settings.js";
2
1
  import { toNuclearEntries, formatNuclearLine, isReadOnly, READ_ONLY_TOOLS, WRITE_TOOLS, } from "./nuclear-form.js";
2
+ // ── Search helpers ────────────────────────────────────────────────
3
+ function buildSearchRegex(query) {
4
+ try {
5
+ return new RegExp(query, "i");
6
+ }
7
+ catch {
8
+ const words = query.split(/\s+/).filter((w) => w.length > 0);
9
+ const escaped = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
10
+ const lookaheads = escaped.map((w) => `(?=.*${w})`).join("");
11
+ return new RegExp(lookaheads, "i");
12
+ }
13
+ }
14
+ function firstMatchExcerpt(text, regex) {
15
+ const idx = text.search(regex);
16
+ if (idx === -1)
17
+ return null;
18
+ const lineStart = text.lastIndexOf("\n", idx) + 1;
19
+ const lineEnd = text.indexOf("\n", idx);
20
+ const line = text.slice(lineStart, lineEnd === -1 ? text.length : lineEnd).trim();
21
+ if (line.length > 120) {
22
+ const matchInLine = idx - lineStart;
23
+ const start = Math.max(0, matchInLine - 40);
24
+ const end = Math.min(line.length, matchInLine + 80);
25
+ return (start > 0 ? "\u2026" : "") + line.slice(start, end) + (end < line.length ? "\u2026" : "");
26
+ }
27
+ return line;
28
+ }
29
+ function recencyWeight(idx, total) {
30
+ return Math.max(0.1, 1 - idx / total);
31
+ }
32
+ /**
33
+ * Conversation state with eager nucleation — shell-history shaped.
34
+ *
35
+ * Every add nucleates into a one-line NuclearEntry and flushes to disk.
36
+ * Compaction evicts turns, replacing them with their nuclear one-liners
37
+ * in context; the originals stay searchable via `conversation_recall`
38
+ * and survive restarts in `~/.agent-sh/history`.
39
+ *
40
+ * Nucleation and history I/O go through advisable handlers — extensions
41
+ * swap strategies without touching this class. When no handlers are
42
+ * provided (subagents, tests), both become no-ops and this becomes a
43
+ * plain message buffer.
44
+ */
3
45
  export class ConversationState {
4
- // ── Tier 1: Active context ────────────────────────────────────
5
46
  messages = [];
6
- // ── Tier 2: Nuclear memory ────────────────────────────────────
47
+ messagesDirty = true;
48
+ cachedMessagesJson = null;
49
+ // tool_call_ids whose results came back with isError=true.
50
+ toolErrors = new Set();
7
51
  nuclearEntries = [];
52
+ nuclearBySeq = new Map();
8
53
  recallArchive = new Map();
9
- // ── Tier 3 reference ──────────────────────────────────────────
10
- historyFile;
11
- // ── Shared state ──────────────────────────────────────────────
54
+ instanceId;
55
+ handlers;
12
56
  nextSeq = 1;
13
- constructor(historyFile) {
14
- this.historyFile = historyFile ?? null;
57
+ lastApiTokenCount = null;
58
+ lastApiMessageCount = 0;
59
+ constructor(handlers, instanceId = "0000") {
60
+ this.handlers = handlers ?? null;
61
+ this.instanceId = instanceId;
15
62
  }
16
- get instanceId() {
17
- return this.historyFile?.instanceId ?? "0000";
63
+ /** Get JSON.stringify of messages, cached until next mutation. */
64
+ getMessagesJson() {
65
+ if (this.messagesDirty || this.cachedMessagesJson === null) {
66
+ this.cachedMessagesJson = JSON.stringify(this.messages);
67
+ this.messagesDirty = false;
68
+ }
69
+ return this.cachedMessagesJson;
70
+ }
71
+ invalidateMessagesCache() {
72
+ this.messagesDirty = true;
73
+ this.cachedMessagesJson = null;
18
74
  }
19
- // ── Message API (unchanged) ───────────────────────────────────
75
+ // ── Message API (with eager nucleation) ───────────────────────
20
76
  addUserMessage(text) {
21
77
  this.messages.push({ role: "user", content: text });
78
+ this.invalidateMessagesCache();
79
+ this.eagerNucleateUser(text);
22
80
  }
23
81
  addAssistantMessage(content, toolCalls) {
24
82
  if (toolCalls?.length) {
@@ -35,200 +93,321 @@ export class ConversationState {
35
93
  else {
36
94
  this.messages.push({ role: "assistant", content: content ?? "" });
37
95
  }
96
+ this.invalidateMessagesCache();
38
97
  }
39
- addToolResult(toolCallId, content) {
40
- this.messages.push({
41
- role: "tool",
42
- tool_call_id: toolCallId,
43
- content,
44
- });
98
+ addToolResult(toolCallId, content, isError = false) {
99
+ this.messages.push({ role: "tool", tool_call_id: toolCallId, content });
100
+ if (isError)
101
+ this.toolErrors.add(toolCallId);
102
+ this.invalidateMessagesCache();
103
+ }
104
+ /** Add tool results as a user message (for inline tool protocol). */
105
+ addToolResultInline(content) {
106
+ this.messages.push({ role: "user", content });
107
+ this.invalidateMessagesCache();
45
108
  }
46
109
  addSystemNote(text) {
47
110
  this.messages.push({ role: "user", content: text });
111
+ this.invalidateMessagesCache();
48
112
  }
49
113
  getMessages() {
50
114
  return this.messages;
51
115
  }
116
+ /**
117
+ * Replace the messages array wholesale — the write side for custom
118
+ * compaction strategies. Invalidates API token baseline since the
119
+ * new array's token count is unknown.
120
+ */
121
+ replaceMessages(messages) {
122
+ this.messages = messages;
123
+ this.pruneToolErrors();
124
+ this.invalidateMessagesCache();
125
+ this.lastApiTokenCount = null;
126
+ this.lastApiMessageCount = 0;
127
+ }
128
+ pruneToolErrors() {
129
+ if (this.toolErrors.size === 0)
130
+ return;
131
+ const live = new Set();
132
+ for (const msg of this.messages) {
133
+ if (msg.role === "tool" && typeof msg.tool_call_id === "string") {
134
+ live.add(msg.tool_call_id);
135
+ }
136
+ }
137
+ for (const id of this.toolErrors) {
138
+ if (!live.has(id))
139
+ this.toolErrors.delete(id);
140
+ }
141
+ }
142
+ // ── Eager nucleation (via advisable handlers) ─────────────────
143
+ eagerNucleateUser(text) {
144
+ if (!this.handlers)
145
+ return;
146
+ const seq = this.nextSeq++;
147
+ const entry = this.handlers.call("conversation:nucleate-user", text, this.instanceId, seq);
148
+ this.recordNuclearEntry(entry, [{ role: "user", content: text }]);
149
+ this.appendToHistory([entry]);
150
+ }
151
+ /** Nucleate an agent text response. Called by agent-loop when the loop finishes without tool calls. */
152
+ eagerNucleateAgent(text) {
153
+ if (!text || !this.handlers)
154
+ return;
155
+ const seq = this.nextSeq++;
156
+ const entry = this.handlers.call("conversation:nucleate-agent", text, this.instanceId, seq);
157
+ this.recordNuclearEntry(entry, [{ role: "assistant", content: text }]);
158
+ this.appendToHistory([entry]);
159
+ }
160
+ /** Nucleate tool call results. One entry per tool call, enriched with result. */
161
+ eagerNucleateTools(results) {
162
+ if (!this.handlers || results.length === 0)
163
+ return;
164
+ const entries = [];
165
+ for (const r of results) {
166
+ const seq = this.nextSeq++;
167
+ const entry = this.handlers.call("conversation:nucleate-tool", r.toolName, r.args, r.content, r.isError, this.instanceId, seq);
168
+ entries.push(entry);
169
+ this.recordNuclearEntry(entry, [
170
+ { role: "assistant", content: null, tool_calls: [{ id: `seq_${seq}`, type: "function", function: { name: r.toolName, arguments: JSON.stringify(r.args) } }] },
171
+ { role: "tool", tool_call_id: `seq_${seq}`, content: r.content },
172
+ ]);
173
+ }
174
+ this.appendToHistory(entries);
175
+ }
176
+ /** Track an entry in memory (nuclear list + recall archive). */
177
+ recordNuclearEntry(entry, originalMessages) {
178
+ this.nuclearEntries.push(entry);
179
+ this.nuclearBySeq.set(entry.seq, entry);
180
+ this.recallArchive.set(entry.seq, originalMessages);
181
+ }
182
+ appendToHistory(entries) {
183
+ if (!this.handlers || entries.length === 0)
184
+ return;
185
+ this.handlers.call("history:append", entries);
186
+ }
52
187
  // ── Token estimation ──────────────────────────────────────────
188
+ updateApiTokenCount(promptTokens) {
189
+ this.lastApiTokenCount = promptTokens;
190
+ this.lastApiMessageCount = this.messages.length;
191
+ }
192
+ estimatePromptTokens() {
193
+ if (this.lastApiTokenCount === null)
194
+ return this.estimateTokens();
195
+ const trailing = this.messages.length - this.lastApiMessageCount;
196
+ if (trailing <= 0)
197
+ return this.lastApiTokenCount;
198
+ const trailingMessages = this.messages.slice(this.lastApiMessageCount);
199
+ return this.lastApiTokenCount + Math.ceil(JSON.stringify(trailingMessages).length / 4);
200
+ }
53
201
  estimateTokens() {
54
- return Math.ceil(JSON.stringify(this.messages).length / 4);
202
+ return Math.ceil(this.getMessagesJson().length / 4);
55
203
  }
56
- // ── Tier 1 Tier 2: Compaction ───────────────────────────────
204
+ // ── Compaction (uses pre-computed nuclear entries) ─────────────
57
205
  /**
58
- * Priority-based compaction. Evicts lowest-priority turns, replacing
59
- * them with nuclear one-liner summaries that stay in the conversation.
60
- * Read-only tool results are dropped entirely.
206
+ * Two-tier pin compaction: evict lowest-priority turns (replaced by
207
+ * their nuclear one-liners), slim the window before the last verbatim
208
+ * turn, drop read-only tool results entirely. Extensions replace the
209
+ * whole strategy by advising `conversation:compact` and skipping next.
61
210
  */
62
- compact(targetTokens, recentTurnsToKeep = 10) {
63
- const before = this.estimateTokens();
64
- if (before <= targetTokens)
211
+ compact(maxPromptTokens, recentTurnsToKeep = 10, force = false) {
212
+ const promptEstimate = this.estimatePromptTokens();
213
+ const convEstimate = this.estimateTokens();
214
+ const overhead = promptEstimate - convEstimate;
215
+ const convTarget = Math.max(0, maxPromptTokens - overhead);
216
+ const before = promptEstimate;
217
+ if (!force && convEstimate <= convTarget)
65
218
  return null;
66
219
  const turns = this.parseTurns();
67
220
  if (turns.length <= 2)
68
221
  return null;
69
- // Assign priorities
70
- const pinnedCount = Math.min(recentTurnsToKeep, turns.length - 1);
71
- for (const turn of turns) {
72
- turn.priority = this.inferPriority(turn.messages);
222
+ // Cap the pinned window so enough turns remain evictable.
223
+ const maxPinnedFraction = force ? 0.4 : 0.6;
224
+ const maxPinned = Math.max(2, Math.floor(turns.length * maxPinnedFraction));
225
+ const pinnedCount = Math.min(recentTurnsToKeep, turns.length - 1, maxPinned);
226
+ for (let i = 0; i < turns.length; i++) {
227
+ turns[i].priority = this.inferPriority(turns[i].messages);
73
228
  }
229
+ // Two-tier pin: last turn verbatim, next (pinnedCount-1) slimmed.
230
+ const verbatimCount = 1;
231
+ const slimmedCount = Math.max(0, pinnedCount - verbatimCount);
232
+ const slimStart = turns.length - pinnedCount;
233
+ const slimEnd = slimStart + slimmedCount;
234
+ const slimmedIndices = new Set();
235
+ for (let i = slimStart; i < slimEnd; i++)
236
+ slimmedIndices.add(i);
74
237
  turns[0].priority = 4 /* Priority.PINNED */;
75
- for (let i = turns.length - pinnedCount; i < turns.length; i++) {
238
+ for (let i = turns.length - verbatimCount; i < turns.length; i++)
239
+ turns[i].priority = 4 /* Priority.PINNED */;
240
+ for (const i of slimmedIndices)
76
241
  turns[i].priority = 4 /* Priority.PINNED */;
77
- }
78
- // Sort candidates: lowest priority first, then oldest
79
242
  const candidates = turns
80
243
  .map((t, idx) => ({ turn: t, idx }))
81
244
  .filter((c) => c.turn.priority !== 4 /* Priority.PINNED */)
82
- .sort((a, b) => a.turn.priority - b.turn.priority || a.idx - b.idx);
83
- // Evict until under budget
245
+ .sort((a, b) => {
246
+ const effA = a.turn.priority * recencyWeight(a.idx, turns.length);
247
+ const effB = b.turn.priority * recencyWeight(b.idx, turns.length);
248
+ return effA - effB || a.idx - b.idx;
249
+ });
84
250
  const evictedIndices = new Set();
85
- let currentTokens = this.estimateTokens();
251
+ let currentTokens = convEstimate;
86
252
  for (const c of candidates) {
87
- if (currentTokens <= targetTokens)
253
+ if (currentTokens <= convTarget)
88
254
  break;
89
255
  const turnTokens = Math.ceil(JSON.stringify(c.turn.messages).length / 4);
90
256
  evictedIndices.add(c.idx);
91
257
  currentTokens -= turnTokens;
92
- // Generate nuclear entries from this turn
93
- const entries = toNuclearEntries(c.turn.messages, this.nextSeq, this.instanceId);
94
- this.nextSeq += entries.length;
95
- for (const entry of entries) {
258
+ // Fallback for turn messages that missed eager nucleation (e.g.
259
+ // injected system notes). Entries already nucleated live in
260
+ // nuclearEntries under their original seqs.
261
+ const turnEntries = toNuclearEntries(c.turn.messages, this.nextSeq, this.instanceId);
262
+ this.nextSeq += turnEntries.length;
263
+ for (const entry of turnEntries) {
96
264
  if (isReadOnly(entry)) {
97
- // Read-only: archive only (dropped from conversation), agent can re-read
98
265
  this.recallArchive.set(entry.seq, c.turn.messages);
99
266
  }
100
267
  else {
101
- // State-changing: keep nuclear one-liner in conversation + archive
102
268
  this.nuclearEntries.push(entry);
269
+ this.nuclearBySeq.set(entry.seq, entry);
103
270
  this.recallArchive.set(entry.seq, c.turn.messages);
104
271
  }
105
272
  }
106
273
  }
107
274
  if (evictedIndices.size === 0)
108
275
  return null;
109
- // Rebuild: first turn + nuclear summary block + remaining turns
110
276
  const rebuilt = [];
111
277
  let insertedNuclearBlock = false;
278
+ this.nuclearBlockIdx = -1;
112
279
  for (let i = 0; i < turns.length; i++) {
113
280
  if (evictedIndices.has(i)) {
114
281
  if (!insertedNuclearBlock) {
115
- rebuilt.push(this.buildNuclearBlock());
282
+ const block = this.buildNuclearBlock();
283
+ this.nuclearBlockIdx = rebuilt.length;
284
+ rebuilt.push(block);
116
285
  insertedNuclearBlock = true;
117
286
  }
118
287
  }
288
+ else if (slimmedIndices.has(i)) {
289
+ rebuilt.push(...this.slimTurn(turns[i].messages));
290
+ }
119
291
  else {
120
292
  rebuilt.push(...turns[i].messages);
121
293
  }
122
294
  }
123
- // If no nuclear block was inserted but we have entries from prior compactions,
124
- // update the existing nuclear block
125
295
  if (!insertedNuclearBlock && this.nuclearEntries.length > 0) {
126
296
  this.updateNuclearBlockInMessages(rebuilt);
127
297
  }
128
298
  this.messages = rebuilt;
129
- return { before, after: this.estimateTokens() };
130
- }
131
- // ── Tier 2 Tier 3: Flush ───────────────────────────────────
132
- /**
133
- * Flush oldest nuclear entries to the history file when the
134
- * in-context nuclear block grows too large.
135
- */
136
- async flush() {
137
- const maxEntries = getSettings().nuclearMaxEntries;
138
- if (this.nuclearEntries.length <= maxEntries)
139
- return;
140
- const flushCount = this.nuclearEntries.length - maxEntries;
141
- const toFlush = this.nuclearEntries.slice(0, flushCount);
142
- // Write to history file
143
- if (this.historyFile) {
144
- await this.historyFile.append(toFlush);
145
- }
146
- // Remove flushed entries from memory
147
- for (const entry of toFlush) {
148
- this.recallArchive.delete(entry.seq);
149
- }
150
- this.nuclearEntries = this.nuclearEntries.slice(flushCount);
151
- // Update the nuclear block in messages
152
- this.updateNuclearBlockInMessages(this.messages);
299
+ this.pruneToolErrors();
300
+ this.invalidateMessagesCache();
301
+ // Preserve system+tools+dynamic overhead so estimatePromptTokens() stays
302
+ // full-prompt-accurate until the next API call refines it. Nulling here
303
+ // caused /context to under-report by ~overhead tokens after every compact.
304
+ const after = overhead + this.estimateTokens();
305
+ this.lastApiTokenCount = after;
306
+ this.lastApiMessageCount = this.messages.length;
307
+ return {
308
+ before,
309
+ after,
310
+ evictedCount: evictedIndices.size,
311
+ };
153
312
  }
154
313
  // ── Startup: Load prior history ───────────────────────────────
155
314
  /**
156
- * Inject prior session history from the history file as a context note.
315
+ * Inject prior session history as a context preamble. The preamble
316
+ * layout goes through the `conversation:format-prior-history` handler,
317
+ * so extensions can swap the flat list for grouped/richer rendering.
157
318
  */
158
319
  loadPriorHistory(entries) {
159
- if (entries.length === 0)
320
+ if (entries.length === 0 || !this.handlers)
160
321
  return;
161
- // Update nextSeq to avoid collisions
162
322
  const maxSeq = Math.max(...entries.map((e) => e.seq));
163
323
  if (maxSeq >= this.nextSeq)
164
324
  this.nextSeq = maxSeq + 1;
165
- const lines = entries.map(formatNuclearLine);
166
- this.messages.push({
167
- role: "user",
168
- content: `[Prior session history — loaded from ~/.agent-sh/history]\n${lines.join("\n")}`,
169
- });
325
+ const content = this.handlers.call("conversation:format-prior-history", entries);
326
+ if (!content)
327
+ return;
328
+ this.messages.push({ role: "user", content });
329
+ this.invalidateMessagesCache();
170
330
  }
171
331
  // ── Conversation recall ───────────────────────────────────────
172
- /** Search Tier 2 archive + Tier 3 history file. */
173
332
  async search(query) {
174
333
  if (!query.trim())
175
334
  return "No query provided.";
176
- const parts = [];
177
- // Search Tier 2 (in-memory archive)
178
- const archiveResults = this.searchArchive(query);
179
- if (archiveResults)
180
- parts.push(archiveResults);
181
- // Search Tier 3 (history file)
182
- if (this.historyFile) {
183
- const fileResults = await this.historyFile.search(query);
184
- if (fileResults.length > 0) {
185
- parts.push(`History file matches (${fileResults.length}):`);
186
- for (const r of fileResults.slice(0, 20)) {
187
- parts.push(` ${r.line}`);
188
- }
335
+ const regex = buildSearchRegex(query);
336
+ const seenSeqs = new Set();
337
+ const hits = [];
338
+ for (const [seq, msgs] of this.recallArchive) {
339
+ const text = this.turnToText(msgs);
340
+ const excerpt = firstMatchExcerpt(text, regex);
341
+ if (excerpt) {
342
+ seenSeqs.add(seq);
343
+ const entry = this.nuclearBySeq.get(seq);
344
+ const header = entry ? formatNuclearLine(entry) : `#${seq}`;
345
+ hits.push(`${header}\n ${excerpt}`);
189
346
  }
190
347
  }
191
- if (parts.length === 0)
348
+ const fileResults = this.handlers
349
+ ? (await this.handlers.call("history:search", query))
350
+ : undefined;
351
+ if (fileResults) {
352
+ for (const r of fileResults) {
353
+ if (seenSeqs.has(r.entry.seq))
354
+ continue;
355
+ seenSeqs.add(r.entry.seq);
356
+ const excerpt = r.entry.body ? firstMatchExcerpt(r.entry.body, regex) : null;
357
+ hits.push(excerpt ? `${r.line}\n ${excerpt}` : r.line);
358
+ }
359
+ }
360
+ if (hits.length === 0)
192
361
  return `No results found for "${query}".`;
193
- return parts.join("\n\n");
362
+ const total = hits.length;
363
+ const summary = `Found ${total} match${total === 1 ? "" : "es"} for "${query}"`;
364
+ return `${summary}\n\n${hits.slice(0, 30).join("\n\n")}`;
194
365
  }
195
- /** Expand full content of a nuclear entry by seq number. */
196
366
  async expand(seq) {
197
- // Check Tier 2 archive first
198
367
  const archived = this.recallArchive.get(seq);
199
368
  if (archived) {
200
- const entry = this.nuclearEntries.find((e) => e.seq === seq);
369
+ const entry = this.nuclearBySeq.get(seq);
201
370
  const header = entry ? formatNuclearLine(entry) : `#${seq}`;
202
371
  return `${header}\n\n${this.turnToText(archived)}`;
203
372
  }
204
- return `Entry #${seq}: not found in recall archive (may have been flushed to history file).`;
373
+ if (this.handlers) {
374
+ const entry = (await this.handlers.call("history:find-by-seq", seq));
375
+ if (entry?.body)
376
+ return `${formatNuclearLine(entry)}\n\n${entry.body}`;
377
+ }
378
+ return `Entry #${seq}: no expanded content available.`;
205
379
  }
206
- /** Browse nuclear entries (Tier 2) + recent history (Tier 3). */
207
380
  async browse() {
208
381
  const parts = [];
209
382
  if (this.nuclearEntries.length > 0) {
210
383
  parts.push("In-context nuclear entries:");
211
- for (const e of this.nuclearEntries) {
384
+ for (const e of this.nuclearEntries)
212
385
  parts.push(` ${formatNuclearLine(e)}`);
213
- }
214
386
  }
215
- if (this.historyFile) {
216
- const recent = await this.historyFile.readRecent(25);
217
- if (recent.length > 0) {
218
- parts.push("\nRecent history file entries:");
219
- for (const e of recent) {
220
- parts.push(` ${formatNuclearLine(e)}`);
221
- }
222
- }
387
+ const recent = this.handlers
388
+ ? (await this.handlers.call("history:read-recent", 25))
389
+ : undefined;
390
+ if (recent && recent.length > 0) {
391
+ parts.push("\nRecent history file entries:");
392
+ for (const e of recent)
393
+ parts.push(` ${formatNuclearLine(e)}`);
223
394
  }
224
395
  if (parts.length === 0)
225
396
  return "No conversation history.";
226
397
  return parts.join("\n");
227
398
  }
228
399
  // ── Stats ─────────────────────────────────────────────────────
400
+ getNuclearEntries() {
401
+ return this.nuclearEntries;
402
+ }
229
403
  getNuclearEntryCount() {
230
404
  return this.nuclearEntries.length;
231
405
  }
406
+ getNuclearSummary() {
407
+ if (this.nuclearEntries.length === 0)
408
+ return null;
409
+ return this.nuclearEntries.map(formatNuclearLine).join("\n");
410
+ }
232
411
  getRecallArchiveSize() {
233
412
  return this.recallArchive.size;
234
413
  }
@@ -236,7 +415,11 @@ export class ConversationState {
236
415
  clear() {
237
416
  this.messages = [];
238
417
  this.nuclearEntries = [];
418
+ this.nuclearBySeq.clear();
239
419
  this.recallArchive.clear();
420
+ this.invalidateMessagesCache();
421
+ this.lastApiTokenCount = null;
422
+ this.lastApiMessageCount = 0;
240
423
  }
241
424
  // ── Internal: Nuclear block management ────────────────────────
242
425
  buildNuclearBlock() {
@@ -246,20 +429,32 @@ export class ConversationState {
246
429
  content: `[Conversation history — use conversation_recall to expand any entry]\n${lines.join("\n")}`,
247
430
  };
248
431
  }
432
+ /** Index of the nuclear block in messages[], or -1 if not present. */
433
+ nuclearBlockIdx = -1;
249
434
  updateNuclearBlockInMessages(messages) {
250
435
  if (this.nuclearEntries.length === 0)
251
436
  return;
252
437
  const marker = "[Conversation history — use conversation_recall";
438
+ const newBlock = this.buildNuclearBlock();
439
+ // Verify the cached index still points at the nuclear block; stale if
440
+ // messages[] was mutated elsewhere since compaction.
441
+ if (this.nuclearBlockIdx >= 0 && this.nuclearBlockIdx < messages.length) {
442
+ const slot = messages[this.nuclearBlockIdx];
443
+ if (slot.role === "user" && typeof slot.content === "string" && slot.content.startsWith(marker)) {
444
+ messages[this.nuclearBlockIdx] = newBlock;
445
+ return;
446
+ }
447
+ this.nuclearBlockIdx = -1;
448
+ }
253
449
  for (let i = 0; i < messages.length; i++) {
254
450
  const msg = messages[i];
255
451
  if (msg.role === "user" && typeof msg.content === "string" && msg.content.startsWith(marker)) {
256
- messages[i] = this.buildNuclearBlock();
452
+ this.nuclearBlockIdx = i;
453
+ messages[i] = newBlock;
257
454
  return;
258
455
  }
259
456
  }
260
- // No existing block found — insert after the first turn
261
457
  if (messages.length > 0) {
262
- // Find end of first turn (next user message or end)
263
458
  let insertIdx = 1;
264
459
  for (let i = 1; i < messages.length; i++) {
265
460
  if (messages[i].role === "user") {
@@ -268,8 +463,50 @@ export class ConversationState {
268
463
  }
269
464
  insertIdx = i + 1;
270
465
  }
271
- messages.splice(insertIdx, 0, this.buildNuclearBlock());
466
+ messages.splice(insertIdx, 0, newBlock);
467
+ this.nuclearBlockIdx = insertIdx;
468
+ }
469
+ }
470
+ // ── Internal: Two-tier pin for recent turns ────────────────────
471
+ slimTurn(messages) {
472
+ const MAX_RESULT_LEN = 1500;
473
+ const result = [];
474
+ const readOnlyToolIds = new Set();
475
+ for (const msg of messages) {
476
+ if (msg.role === "assistant" && "tool_calls" in msg && msg.tool_calls) {
477
+ const kept = msg.tool_calls.filter((tc) => {
478
+ if (!("function" in tc))
479
+ return true;
480
+ if (READ_ONLY_TOOLS.has(tc.function.name)) {
481
+ readOnlyToolIds.add(tc.id);
482
+ return false;
483
+ }
484
+ return true;
485
+ });
486
+ if (kept.length === 0) {
487
+ const { tool_calls: _, ...rest } = msg;
488
+ result.push(rest);
489
+ }
490
+ else {
491
+ result.push({ ...msg, tool_calls: kept });
492
+ }
493
+ continue;
494
+ }
495
+ if (msg.role === "tool") {
496
+ if (readOnlyToolIds.has(msg.tool_call_id))
497
+ continue;
498
+ const content = typeof msg.content === "string" ? msg.content : "";
499
+ if (content.length > MAX_RESULT_LEN) {
500
+ result.push({ ...msg, content: content.slice(0, MAX_RESULT_LEN) + "\n... [truncated by compact]" });
501
+ }
502
+ else {
503
+ result.push(msg);
504
+ }
505
+ continue;
506
+ }
507
+ result.push(msg);
272
508
  }
509
+ return result;
273
510
  }
274
511
  // ── Internal: Turn parsing and priority ───────────────────────
275
512
  parseTurns() {
@@ -282,9 +519,8 @@ export class ConversationState {
282
519
  }
283
520
  current.push(msg);
284
521
  }
285
- if (current.length > 0) {
522
+ if (current.length > 0)
286
523
  turns.push({ messages: current, priority: 2 /* Priority.MEDIUM */ });
287
- }
288
524
  return turns;
289
525
  }
290
526
  inferPriority(messages) {
@@ -297,8 +533,11 @@ export class ConversationState {
297
533
  return 3 /* Priority.HIGH */;
298
534
  if (msg.role === "tool") {
299
535
  hasToolResult = true;
536
+ // Structured flag is primary; the "Error:" prefix check covers
537
+ // callers that didn't thread isError (extensions, legacy paths).
538
+ const id = typeof msg.tool_call_id === "string" ? msg.tool_call_id : "";
300
539
  const content = typeof msg.content === "string" ? msg.content : "";
301
- if (content.startsWith("Error:") || content.includes("error")) {
540
+ if (this.toolErrors.has(id) || content.startsWith("Error:")) {
302
541
  hasError = true;
303
542
  }
304
543
  }
@@ -307,10 +546,9 @@ export class ConversationState {
307
546
  const fn = "function" in tc ? tc.function : undefined;
308
547
  if (!fn)
309
548
  continue;
310
- const name = fn.name;
311
- if (WRITE_TOOLS.has(name))
549
+ if (WRITE_TOOLS.has(fn.name))
312
550
  hasWriteTool = true;
313
- if (!READ_ONLY_TOOLS.has(name))
551
+ if (!READ_ONLY_TOOLS.has(fn.name))
314
552
  allReadOnly = false;
315
553
  }
316
554
  }
@@ -325,31 +563,6 @@ export class ConversationState {
325
563
  return 1 /* Priority.LOW */;
326
564
  return 2 /* Priority.MEDIUM */;
327
565
  }
328
- // ── Internal: Search helpers ──────────────────────────────────
329
- searchArchive(query) {
330
- if (this.recallArchive.size === 0)
331
- return null;
332
- let regex;
333
- try {
334
- regex = new RegExp(query, "i");
335
- }
336
- catch {
337
- const words = query.split(/\s+/).filter((w) => w.length > 0);
338
- const pattern = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
339
- regex = new RegExp(pattern, "i");
340
- }
341
- const matches = [];
342
- for (const [seq, msgs] of this.recallArchive) {
343
- const text = this.turnToText(msgs);
344
- if (regex.test(text)) {
345
- const entry = this.nuclearEntries.find((e) => e.seq === seq);
346
- matches.push(entry ? formatNuclearLine(entry) : `#${seq}`);
347
- }
348
- }
349
- if (matches.length === 0)
350
- return null;
351
- return `Recall archive matches (${matches.length}):\n${matches.map((m) => ` ${m}`).join("\n")}`;
352
- }
353
566
  turnToText(messages) {
354
567
  const lines = [];
355
568
  for (const msg of messages) {
@@ -357,20 +570,18 @@ export class ConversationState {
357
570
  lines.push(`[user] ${typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content)}`);
358
571
  }
359
572
  else if (msg.role === "assistant") {
360
- if (typeof msg.content === "string" && msg.content) {
573
+ if (typeof msg.content === "string" && msg.content)
361
574
  lines.push(`[assistant] ${msg.content}`);
362
- }
363
575
  if ("tool_calls" in msg && msg.tool_calls) {
364
576
  for (const tc of msg.tool_calls) {
365
- if ("function" in tc) {
366
- lines.push(`[tool_call] ${tc.function.name}(${tc.function.arguments.slice(0, 200)})`);
367
- }
577
+ if ("function" in tc)
578
+ lines.push(`[tool_call] ${tc.function.name}(${tc.function.arguments})`);
368
579
  }
369
580
  }
370
581
  }
371
582
  else if (msg.role === "tool") {
372
583
  const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
373
- lines.push(`[tool_result] ${content.slice(0, 500)}`);
584
+ lines.push(`[tool_result] ${content}`);
374
585
  }
375
586
  }
376
587
  return lines.join("\n");