agent-sh 0.9.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 (78) hide show
  1. package/README.md +14 -21
  2. package/dist/agent/agent-loop.d.ts +43 -3
  3. package/dist/agent/agent-loop.js +811 -128
  4. package/dist/agent/conversation-state.d.ts +72 -21
  5. package/dist/agent/conversation-state.js +357 -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 +84 -3
  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 +34 -1
  15. package/dist/agent/system-prompt.js +96 -47
  16. package/dist/agent/token-budget.d.ts +5 -4
  17. package/dist/agent/token-budget.js +14 -19
  18. package/dist/agent/tool-protocol.d.ts +23 -1
  19. package/dist/agent/tool-protocol.js +169 -4
  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 +1 -1
  26. package/dist/context-manager.d.ts +17 -0
  27. package/dist/context-manager.js +37 -4
  28. package/dist/core.js +27 -6
  29. package/dist/event-bus.d.ts +59 -2
  30. package/dist/executor.d.ts +4 -3
  31. package/dist/executor.js +18 -15
  32. package/dist/extension-loader.js +50 -13
  33. package/dist/extensions/agent-backend.d.ts +8 -7
  34. package/dist/extensions/agent-backend.js +69 -48
  35. package/dist/extensions/index.js +0 -1
  36. package/dist/extensions/slash-commands.js +14 -9
  37. package/dist/extensions/tui-renderer.js +62 -78
  38. package/dist/index.js +25 -6
  39. package/dist/settings.d.ts +36 -5
  40. package/dist/settings.js +53 -9
  41. package/dist/shell/input-handler.d.ts +2 -1
  42. package/dist/shell/input-handler.js +82 -73
  43. package/dist/shell/shell.js +19 -2
  44. package/dist/types.d.ts +12 -0
  45. package/dist/utils/ansi.d.ts +5 -0
  46. package/dist/utils/ansi.js +1 -1
  47. package/dist/utils/compositor.d.ts +5 -0
  48. package/dist/utils/compositor.js +31 -3
  49. package/dist/utils/diff-renderer.d.ts +9 -0
  50. package/dist/utils/diff-renderer.js +221 -143
  51. package/dist/utils/diff.d.ts +21 -2
  52. package/dist/utils/diff.js +165 -89
  53. package/dist/utils/handler-registry.d.ts +5 -0
  54. package/dist/utils/handler-registry.js +6 -0
  55. package/dist/utils/line-editor.d.ts +11 -1
  56. package/dist/utils/line-editor.js +44 -5
  57. package/dist/utils/tool-display.d.ts +1 -1
  58. package/dist/utils/tool-display.js +4 -4
  59. package/examples/extensions/ash-acp-bridge/src/index.ts +4 -1
  60. package/examples/extensions/ash-mcp-bridge/index.ts +13 -3
  61. package/examples/extensions/claude-code-bridge/index.ts +198 -51
  62. package/examples/extensions/claude-code-bridge/package.json +1 -0
  63. package/examples/extensions/interactive-prompts.ts +39 -25
  64. package/examples/extensions/overlay-agent.ts +3 -3
  65. package/examples/extensions/peer-mesh.ts +115 -0
  66. package/examples/extensions/pi-bridge/index.ts +2 -2
  67. package/examples/extensions/questionnaire.ts +16 -5
  68. package/examples/extensions/subagents.ts +19 -4
  69. package/examples/extensions/terminal-buffer.ts +163 -0
  70. package/examples/extensions/user-shell.ts +136 -0
  71. package/examples/extensions/web-access.ts +8 -0
  72. package/package.json +36 -2
  73. package/dist/agent/tools/display.d.ts +0 -13
  74. package/dist/agent/tools/display.js +0 -70
  75. package/dist/agent/tools/user-shell.d.ts +0 -13
  76. package/dist/agent/tools/user-shell.js +0 -87
  77. package/dist/extensions/terminal-buffer.d.ts +0 -14
  78. package/dist/extensions/terminal-buffer.js +0 -134
@@ -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,204 +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();
45
103
  }
46
104
  /** Add tool results as a user message (for inline tool protocol). */
47
105
  addToolResultInline(content) {
48
106
  this.messages.push({ role: "user", content });
107
+ this.invalidateMessagesCache();
49
108
  }
50
109
  addSystemNote(text) {
51
110
  this.messages.push({ role: "user", content: text });
111
+ this.invalidateMessagesCache();
52
112
  }
53
113
  getMessages() {
54
114
  return this.messages;
55
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
+ }
56
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
+ }
57
201
  estimateTokens() {
58
- return Math.ceil(JSON.stringify(this.messages).length / 4);
202
+ return Math.ceil(this.getMessagesJson().length / 4);
59
203
  }
60
- // ── Tier 1 Tier 2: Compaction ───────────────────────────────
204
+ // ── Compaction (uses pre-computed nuclear entries) ─────────────
61
205
  /**
62
- * Priority-based compaction. Evicts lowest-priority turns, replacing
63
- * them with nuclear one-liner summaries that stay in the conversation.
64
- * 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.
65
210
  */
66
- compact(targetTokens, recentTurnsToKeep = 10, force = false) {
67
- const before = this.estimateTokens();
68
- if (!force && 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)
69
218
  return null;
70
219
  const turns = this.parseTurns();
71
220
  if (turns.length <= 2)
72
221
  return null;
73
- // Assign priorities
74
- const pinnedCount = Math.min(recentTurnsToKeep, turns.length - 1);
75
- for (const turn of turns) {
76
- 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);
77
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);
78
237
  turns[0].priority = 4 /* Priority.PINNED */;
79
- 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)
80
241
  turns[i].priority = 4 /* Priority.PINNED */;
81
- }
82
- // Sort candidates: lowest priority first, then oldest
83
242
  const candidates = turns
84
243
  .map((t, idx) => ({ turn: t, idx }))
85
244
  .filter((c) => c.turn.priority !== 4 /* Priority.PINNED */)
86
- .sort((a, b) => a.turn.priority - b.turn.priority || a.idx - b.idx);
87
- // 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
+ });
88
250
  const evictedIndices = new Set();
89
- let currentTokens = this.estimateTokens();
251
+ let currentTokens = convEstimate;
90
252
  for (const c of candidates) {
91
- if (currentTokens <= targetTokens)
253
+ if (currentTokens <= convTarget)
92
254
  break;
93
255
  const turnTokens = Math.ceil(JSON.stringify(c.turn.messages).length / 4);
94
256
  evictedIndices.add(c.idx);
95
257
  currentTokens -= turnTokens;
96
- // Generate nuclear entries from this turn
97
- const entries = toNuclearEntries(c.turn.messages, this.nextSeq, this.instanceId);
98
- this.nextSeq += entries.length;
99
- 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) {
100
264
  if (isReadOnly(entry)) {
101
- // Read-only: archive only (dropped from conversation), agent can re-read
102
265
  this.recallArchive.set(entry.seq, c.turn.messages);
103
266
  }
104
267
  else {
105
- // State-changing: keep nuclear one-liner in conversation + archive
106
268
  this.nuclearEntries.push(entry);
269
+ this.nuclearBySeq.set(entry.seq, entry);
107
270
  this.recallArchive.set(entry.seq, c.turn.messages);
108
271
  }
109
272
  }
110
273
  }
111
274
  if (evictedIndices.size === 0)
112
275
  return null;
113
- // Rebuild: first turn + nuclear summary block + remaining turns
114
276
  const rebuilt = [];
115
277
  let insertedNuclearBlock = false;
278
+ this.nuclearBlockIdx = -1;
116
279
  for (let i = 0; i < turns.length; i++) {
117
280
  if (evictedIndices.has(i)) {
118
281
  if (!insertedNuclearBlock) {
119
- rebuilt.push(this.buildNuclearBlock());
282
+ const block = this.buildNuclearBlock();
283
+ this.nuclearBlockIdx = rebuilt.length;
284
+ rebuilt.push(block);
120
285
  insertedNuclearBlock = true;
121
286
  }
122
287
  }
288
+ else if (slimmedIndices.has(i)) {
289
+ rebuilt.push(...this.slimTurn(turns[i].messages));
290
+ }
123
291
  else {
124
292
  rebuilt.push(...turns[i].messages);
125
293
  }
126
294
  }
127
- // If no nuclear block was inserted but we have entries from prior compactions,
128
- // update the existing nuclear block
129
295
  if (!insertedNuclearBlock && this.nuclearEntries.length > 0) {
130
296
  this.updateNuclearBlockInMessages(rebuilt);
131
297
  }
132
298
  this.messages = rebuilt;
133
- return { before, after: this.estimateTokens() };
134
- }
135
- // ── Tier 2 Tier 3: Flush ───────────────────────────────────
136
- /**
137
- * Flush oldest nuclear entries to the history file when the
138
- * in-context nuclear block grows too large.
139
- */
140
- async flush() {
141
- const maxEntries = getSettings().nuclearMaxEntries;
142
- if (this.nuclearEntries.length <= maxEntries)
143
- return;
144
- const flushCount = this.nuclearEntries.length - maxEntries;
145
- const toFlush = this.nuclearEntries.slice(0, flushCount);
146
- // Write to history file
147
- if (this.historyFile) {
148
- await this.historyFile.append(toFlush);
149
- }
150
- // Remove flushed entries from memory
151
- for (const entry of toFlush) {
152
- this.recallArchive.delete(entry.seq);
153
- }
154
- this.nuclearEntries = this.nuclearEntries.slice(flushCount);
155
- // Update the nuclear block in messages
156
- 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
+ };
157
312
  }
158
313
  // ── Startup: Load prior history ───────────────────────────────
159
314
  /**
160
- * 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.
161
318
  */
162
319
  loadPriorHistory(entries) {
163
- if (entries.length === 0)
320
+ if (entries.length === 0 || !this.handlers)
164
321
  return;
165
- // Update nextSeq to avoid collisions
166
322
  const maxSeq = Math.max(...entries.map((e) => e.seq));
167
323
  if (maxSeq >= this.nextSeq)
168
324
  this.nextSeq = maxSeq + 1;
169
- const lines = entries.map(formatNuclearLine);
170
- this.messages.push({
171
- role: "user",
172
- content: `[Prior session history — loaded from ~/.agent-sh/history]\n${lines.join("\n")}`,
173
- });
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();
174
330
  }
175
331
  // ── Conversation recall ───────────────────────────────────────
176
- /** Search Tier 2 archive + Tier 3 history file. */
177
332
  async search(query) {
178
333
  if (!query.trim())
179
334
  return "No query provided.";
180
- const parts = [];
181
- // Search Tier 2 (in-memory archive)
182
- const archiveResults = this.searchArchive(query);
183
- if (archiveResults)
184
- parts.push(archiveResults);
185
- // Search Tier 3 (history file)
186
- if (this.historyFile) {
187
- const fileResults = await this.historyFile.search(query);
188
- if (fileResults.length > 0) {
189
- parts.push(`History file matches (${fileResults.length}):`);
190
- for (const r of fileResults.slice(0, 20)) {
191
- parts.push(` ${r.line}`);
192
- }
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}`);
193
346
  }
194
347
  }
195
- 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)
196
361
  return `No results found for "${query}".`;
197
- 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")}`;
198
365
  }
199
- /** Expand full content of a nuclear entry by seq number. */
200
366
  async expand(seq) {
201
- // Check Tier 2 archive first
202
367
  const archived = this.recallArchive.get(seq);
203
368
  if (archived) {
204
- const entry = this.nuclearEntries.find((e) => e.seq === seq);
369
+ const entry = this.nuclearBySeq.get(seq);
205
370
  const header = entry ? formatNuclearLine(entry) : `#${seq}`;
206
371
  return `${header}\n\n${this.turnToText(archived)}`;
207
372
  }
208
- 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.`;
209
379
  }
210
- /** Browse nuclear entries (Tier 2) + recent history (Tier 3). */
211
380
  async browse() {
212
381
  const parts = [];
213
382
  if (this.nuclearEntries.length > 0) {
214
383
  parts.push("In-context nuclear entries:");
215
- for (const e of this.nuclearEntries) {
384
+ for (const e of this.nuclearEntries)
216
385
  parts.push(` ${formatNuclearLine(e)}`);
217
- }
218
386
  }
219
- if (this.historyFile) {
220
- const recent = await this.historyFile.readRecent(25);
221
- if (recent.length > 0) {
222
- parts.push("\nRecent history file entries:");
223
- for (const e of recent) {
224
- parts.push(` ${formatNuclearLine(e)}`);
225
- }
226
- }
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)}`);
227
394
  }
228
395
  if (parts.length === 0)
229
396
  return "No conversation history.";
230
397
  return parts.join("\n");
231
398
  }
232
399
  // ── Stats ─────────────────────────────────────────────────────
400
+ getNuclearEntries() {
401
+ return this.nuclearEntries;
402
+ }
233
403
  getNuclearEntryCount() {
234
404
  return this.nuclearEntries.length;
235
405
  }
406
+ getNuclearSummary() {
407
+ if (this.nuclearEntries.length === 0)
408
+ return null;
409
+ return this.nuclearEntries.map(formatNuclearLine).join("\n");
410
+ }
236
411
  getRecallArchiveSize() {
237
412
  return this.recallArchive.size;
238
413
  }
@@ -240,7 +415,11 @@ export class ConversationState {
240
415
  clear() {
241
416
  this.messages = [];
242
417
  this.nuclearEntries = [];
418
+ this.nuclearBySeq.clear();
243
419
  this.recallArchive.clear();
420
+ this.invalidateMessagesCache();
421
+ this.lastApiTokenCount = null;
422
+ this.lastApiMessageCount = 0;
244
423
  }
245
424
  // ── Internal: Nuclear block management ────────────────────────
246
425
  buildNuclearBlock() {
@@ -250,20 +429,32 @@ export class ConversationState {
250
429
  content: `[Conversation history — use conversation_recall to expand any entry]\n${lines.join("\n")}`,
251
430
  };
252
431
  }
432
+ /** Index of the nuclear block in messages[], or -1 if not present. */
433
+ nuclearBlockIdx = -1;
253
434
  updateNuclearBlockInMessages(messages) {
254
435
  if (this.nuclearEntries.length === 0)
255
436
  return;
256
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
+ }
257
449
  for (let i = 0; i < messages.length; i++) {
258
450
  const msg = messages[i];
259
451
  if (msg.role === "user" && typeof msg.content === "string" && msg.content.startsWith(marker)) {
260
- messages[i] = this.buildNuclearBlock();
452
+ this.nuclearBlockIdx = i;
453
+ messages[i] = newBlock;
261
454
  return;
262
455
  }
263
456
  }
264
- // No existing block found — insert after the first turn
265
457
  if (messages.length > 0) {
266
- // Find end of first turn (next user message or end)
267
458
  let insertIdx = 1;
268
459
  for (let i = 1; i < messages.length; i++) {
269
460
  if (messages[i].role === "user") {
@@ -272,8 +463,50 @@ export class ConversationState {
272
463
  }
273
464
  insertIdx = i + 1;
274
465
  }
275
- 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);
276
508
  }
509
+ return result;
277
510
  }
278
511
  // ── Internal: Turn parsing and priority ───────────────────────
279
512
  parseTurns() {
@@ -286,9 +519,8 @@ export class ConversationState {
286
519
  }
287
520
  current.push(msg);
288
521
  }
289
- if (current.length > 0) {
522
+ if (current.length > 0)
290
523
  turns.push({ messages: current, priority: 2 /* Priority.MEDIUM */ });
291
- }
292
524
  return turns;
293
525
  }
294
526
  inferPriority(messages) {
@@ -301,8 +533,11 @@ export class ConversationState {
301
533
  return 3 /* Priority.HIGH */;
302
534
  if (msg.role === "tool") {
303
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 : "";
304
539
  const content = typeof msg.content === "string" ? msg.content : "";
305
- if (content.startsWith("Error:") || content.includes("error")) {
540
+ if (this.toolErrors.has(id) || content.startsWith("Error:")) {
306
541
  hasError = true;
307
542
  }
308
543
  }
@@ -311,10 +546,9 @@ export class ConversationState {
311
546
  const fn = "function" in tc ? tc.function : undefined;
312
547
  if (!fn)
313
548
  continue;
314
- const name = fn.name;
315
- if (WRITE_TOOLS.has(name))
549
+ if (WRITE_TOOLS.has(fn.name))
316
550
  hasWriteTool = true;
317
- if (!READ_ONLY_TOOLS.has(name))
551
+ if (!READ_ONLY_TOOLS.has(fn.name))
318
552
  allReadOnly = false;
319
553
  }
320
554
  }
@@ -329,31 +563,6 @@ export class ConversationState {
329
563
  return 1 /* Priority.LOW */;
330
564
  return 2 /* Priority.MEDIUM */;
331
565
  }
332
- // ── Internal: Search helpers ──────────────────────────────────
333
- searchArchive(query) {
334
- if (this.recallArchive.size === 0)
335
- return null;
336
- let regex;
337
- try {
338
- regex = new RegExp(query, "i");
339
- }
340
- catch {
341
- const words = query.split(/\s+/).filter((w) => w.length > 0);
342
- const pattern = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
343
- regex = new RegExp(pattern, "i");
344
- }
345
- const matches = [];
346
- for (const [seq, msgs] of this.recallArchive) {
347
- const text = this.turnToText(msgs);
348
- if (regex.test(text)) {
349
- const entry = this.nuclearEntries.find((e) => e.seq === seq);
350
- matches.push(entry ? formatNuclearLine(entry) : `#${seq}`);
351
- }
352
- }
353
- if (matches.length === 0)
354
- return null;
355
- return `Recall archive matches (${matches.length}):\n${matches.map((m) => ` ${m}`).join("\n")}`;
356
- }
357
566
  turnToText(messages) {
358
567
  const lines = [];
359
568
  for (const msg of messages) {
@@ -361,20 +570,18 @@ export class ConversationState {
361
570
  lines.push(`[user] ${typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content)}`);
362
571
  }
363
572
  else if (msg.role === "assistant") {
364
- if (typeof msg.content === "string" && msg.content) {
573
+ if (typeof msg.content === "string" && msg.content)
365
574
  lines.push(`[assistant] ${msg.content}`);
366
- }
367
575
  if ("tool_calls" in msg && msg.tool_calls) {
368
576
  for (const tc of msg.tool_calls) {
369
- if ("function" in tc) {
370
- lines.push(`[tool_call] ${tc.function.name}(${tc.function.arguments.slice(0, 200)})`);
371
- }
577
+ if ("function" in tc)
578
+ lines.push(`[tool_call] ${tc.function.name}(${tc.function.arguments})`);
372
579
  }
373
580
  }
374
581
  }
375
582
  else if (msg.role === "tool") {
376
583
  const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
377
- lines.push(`[tool_result] ${content.slice(0, 500)}`);
584
+ lines.push(`[tool_result] ${content}`);
378
585
  }
379
586
  }
380
587
  return lines.join("\n");