agent-sh 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +25 -30
  2. package/dist/agent/agent-loop.d.ts +43 -6
  3. package/dist/agent/agent-loop.js +817 -157
  4. package/dist/agent/conversation-state.d.ts +72 -21
  5. package/dist/agent/conversation-state.js +364 -151
  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 +10 -13
  17. package/dist/agent/token-budget.js +6 -46
  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 -2
  26. package/dist/context-manager.d.ts +16 -19
  27. package/dist/context-manager.js +48 -152
  28. package/dist/core.js +27 -6
  29. package/dist/event-bus.d.ts +59 -3
  30. package/dist/executor.d.ts +4 -3
  31. package/dist/executor.js +18 -15
  32. package/dist/extension-loader.js +75 -17
  33. package/dist/extensions/agent-backend.d.ts +8 -7
  34. package/dist/extensions/agent-backend.js +72 -50
  35. package/dist/extensions/index.js +0 -2
  36. package/dist/extensions/slash-commands.js +14 -9
  37. package/dist/extensions/tui-renderer.js +67 -80
  38. package/dist/index.js +25 -6
  39. package/dist/settings.d.ts +39 -16
  40. package/dist/settings.js +51 -11
  41. package/dist/shell/input-handler.d.ts +2 -1
  42. package/dist/shell/input-handler.js +84 -76
  43. package/dist/shell/shell.js +19 -2
  44. package/dist/types.d.ts +15 -0
  45. package/dist/utils/ansi.d.ts +7 -0
  46. package/dist/utils/ansi.js +69 -8
  47. package/dist/utils/box-frame.js +8 -2
  48. package/dist/utils/compositor.d.ts +5 -0
  49. package/dist/utils/compositor.js +31 -3
  50. package/dist/utils/diff-renderer.d.ts +9 -0
  51. package/dist/utils/diff-renderer.js +221 -143
  52. package/dist/utils/diff.d.ts +21 -2
  53. package/dist/utils/diff.js +165 -89
  54. package/dist/utils/handler-registry.d.ts +5 -0
  55. package/dist/utils/handler-registry.js +6 -0
  56. package/dist/utils/line-editor.d.ts +11 -1
  57. package/dist/utils/line-editor.js +44 -5
  58. package/dist/utils/markdown.js +23 -8
  59. package/dist/utils/package-version.d.ts +1 -0
  60. package/dist/utils/package-version.js +10 -0
  61. package/dist/utils/shell-output-spill.d.ts +2 -0
  62. package/dist/utils/shell-output-spill.js +81 -0
  63. package/dist/utils/tool-display.d.ts +1 -1
  64. package/dist/utils/tool-display.js +4 -4
  65. package/examples/extensions/ash-acp-bridge/src/index.ts +4 -1
  66. package/examples/extensions/ash-mcp-bridge/index.ts +13 -3
  67. package/examples/extensions/claude-code-bridge/README.md +14 -0
  68. package/examples/extensions/claude-code-bridge/index.ts +204 -145
  69. package/examples/extensions/claude-code-bridge/package.json +1 -0
  70. package/examples/extensions/interactive-prompts.ts +39 -25
  71. package/examples/extensions/overlay-agent.ts +3 -3
  72. package/examples/extensions/peer-mesh.ts +115 -0
  73. package/examples/extensions/pi-bridge/README.md +16 -0
  74. package/examples/extensions/pi-bridge/index.ts +9 -155
  75. package/examples/extensions/questionnaire.ts +16 -5
  76. package/examples/extensions/subagents.ts +19 -4
  77. package/examples/extensions/terminal-buffer.ts +163 -0
  78. package/examples/extensions/user-shell.ts +136 -0
  79. package/examples/extensions/web-access.ts +8 -0
  80. package/package.json +36 -2
  81. package/dist/agent/tools/display.d.ts +0 -13
  82. package/dist/agent/tools/display.js +0 -70
  83. package/dist/agent/tools/user-shell.d.ts +0 -13
  84. package/dist/agent/tools/user-shell.js +0 -87
  85. package/dist/extensions/shell-recall.d.ts +0 -9
  86. package/dist/extensions/shell-recall.js +0 -8
  87. package/dist/extensions/terminal-buffer.d.ts +0 -14
  88. 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,327 @@ 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
- if (turns.length <= 2)
220
+ // With force, allow compacting down to 1 turn (the current response).
221
+ // Without force, keep at least 2 turns (user + agent) to avoid
222
+ // annihilating a young conversation.
223
+ if (turns.length <= (force ? 1 : 2))
72
224
  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);
225
+ // Cap the pinned window so enough turns remain evictable.
226
+ const maxPinnedFraction = force ? 0.4 : 0.6;
227
+ const maxPinned = Math.max(2, Math.floor(turns.length * maxPinnedFraction));
228
+ // Ensure at least 1 turn is evictable when force is true, even in
229
+ // very short conversations (e.g. 3 turns with heavy tool output).
230
+ const maxPinnedForced = force ? Math.min(maxPinned, turns.length - 2) : maxPinned;
231
+ const pinnedCount = Math.min(recentTurnsToKeep, turns.length - 1, Math.max(1, maxPinnedForced));
232
+ for (let i = 0; i < turns.length; i++) {
233
+ turns[i].priority = this.inferPriority(turns[i].messages);
77
234
  }
235
+ // Two-tier pin: last turn verbatim, next (pinnedCount-1) slimmed.
236
+ const verbatimCount = 1;
237
+ const slimmedCount = Math.max(0, pinnedCount - verbatimCount);
238
+ const slimStart = turns.length - pinnedCount;
239
+ const slimEnd = slimStart + slimmedCount;
240
+ const slimmedIndices = new Set();
241
+ for (let i = slimStart; i < slimEnd; i++)
242
+ slimmedIndices.add(i);
78
243
  turns[0].priority = 4 /* Priority.PINNED */;
79
- for (let i = turns.length - pinnedCount; i < turns.length; i++) {
244
+ for (let i = turns.length - verbatimCount; i < turns.length; i++)
245
+ turns[i].priority = 4 /* Priority.PINNED */;
246
+ for (const i of slimmedIndices)
80
247
  turns[i].priority = 4 /* Priority.PINNED */;
81
- }
82
- // Sort candidates: lowest priority first, then oldest
83
248
  const candidates = turns
84
249
  .map((t, idx) => ({ turn: t, idx }))
85
250
  .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
251
+ .sort((a, b) => {
252
+ const effA = a.turn.priority * recencyWeight(a.idx, turns.length);
253
+ const effB = b.turn.priority * recencyWeight(b.idx, turns.length);
254
+ return effA - effB || a.idx - b.idx;
255
+ });
88
256
  const evictedIndices = new Set();
89
- let currentTokens = this.estimateTokens();
257
+ let currentTokens = convEstimate;
90
258
  for (const c of candidates) {
91
- if (currentTokens <= targetTokens)
259
+ if (currentTokens <= convTarget)
92
260
  break;
93
261
  const turnTokens = Math.ceil(JSON.stringify(c.turn.messages).length / 4);
94
262
  evictedIndices.add(c.idx);
95
263
  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) {
264
+ // Fallback for turn messages that missed eager nucleation (e.g.
265
+ // injected system notes). Entries already nucleated live in
266
+ // nuclearEntries under their original seqs.
267
+ const turnEntries = toNuclearEntries(c.turn.messages, this.nextSeq, this.instanceId);
268
+ this.nextSeq += turnEntries.length;
269
+ for (const entry of turnEntries) {
100
270
  if (isReadOnly(entry)) {
101
- // Read-only: archive only (dropped from conversation), agent can re-read
102
271
  this.recallArchive.set(entry.seq, c.turn.messages);
103
272
  }
104
273
  else {
105
- // State-changing: keep nuclear one-liner in conversation + archive
106
274
  this.nuclearEntries.push(entry);
275
+ this.nuclearBySeq.set(entry.seq, entry);
107
276
  this.recallArchive.set(entry.seq, c.turn.messages);
108
277
  }
109
278
  }
110
279
  }
111
280
  if (evictedIndices.size === 0)
112
281
  return null;
113
- // Rebuild: first turn + nuclear summary block + remaining turns
114
282
  const rebuilt = [];
115
283
  let insertedNuclearBlock = false;
284
+ this.nuclearBlockIdx = -1;
116
285
  for (let i = 0; i < turns.length; i++) {
117
286
  if (evictedIndices.has(i)) {
118
287
  if (!insertedNuclearBlock) {
119
- rebuilt.push(this.buildNuclearBlock());
288
+ const block = this.buildNuclearBlock();
289
+ this.nuclearBlockIdx = rebuilt.length;
290
+ rebuilt.push(block);
120
291
  insertedNuclearBlock = true;
121
292
  }
122
293
  }
294
+ else if (slimmedIndices.has(i)) {
295
+ rebuilt.push(...this.slimTurn(turns[i].messages));
296
+ }
123
297
  else {
124
298
  rebuilt.push(...turns[i].messages);
125
299
  }
126
300
  }
127
- // If no nuclear block was inserted but we have entries from prior compactions,
128
- // update the existing nuclear block
129
301
  if (!insertedNuclearBlock && this.nuclearEntries.length > 0) {
130
302
  this.updateNuclearBlockInMessages(rebuilt);
131
303
  }
132
304
  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);
305
+ this.pruneToolErrors();
306
+ this.invalidateMessagesCache();
307
+ // Preserve system+tools+dynamic overhead so estimatePromptTokens() stays
308
+ // full-prompt-accurate until the next API call refines it. Nulling here
309
+ // caused /context to under-report by ~overhead tokens after every compact.
310
+ const after = overhead + this.estimateTokens();
311
+ this.lastApiTokenCount = after;
312
+ this.lastApiMessageCount = this.messages.length;
313
+ return {
314
+ before,
315
+ after,
316
+ evictedCount: evictedIndices.size,
317
+ };
157
318
  }
158
319
  // ── Startup: Load prior history ───────────────────────────────
159
320
  /**
160
- * Inject prior session history from the history file as a context note.
321
+ * Inject prior session history as a context preamble. The preamble
322
+ * layout goes through the `conversation:format-prior-history` handler,
323
+ * so extensions can swap the flat list for grouped/richer rendering.
161
324
  */
162
325
  loadPriorHistory(entries) {
163
- if (entries.length === 0)
326
+ if (entries.length === 0 || !this.handlers)
164
327
  return;
165
- // Update nextSeq to avoid collisions
166
328
  const maxSeq = Math.max(...entries.map((e) => e.seq));
167
329
  if (maxSeq >= this.nextSeq)
168
330
  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
- });
331
+ const content = this.handlers.call("conversation:format-prior-history", entries);
332
+ if (!content)
333
+ return;
334
+ this.messages.push({ role: "user", content });
335
+ this.invalidateMessagesCache();
174
336
  }
175
337
  // ── Conversation recall ───────────────────────────────────────
176
- /** Search Tier 2 archive + Tier 3 history file. */
177
338
  async search(query) {
178
339
  if (!query.trim())
179
340
  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
- }
341
+ const regex = buildSearchRegex(query);
342
+ const seenSeqs = new Set();
343
+ const hits = [];
344
+ for (const [seq, msgs] of this.recallArchive) {
345
+ const text = this.turnToText(msgs);
346
+ const excerpt = firstMatchExcerpt(text, regex);
347
+ if (excerpt) {
348
+ seenSeqs.add(seq);
349
+ const entry = this.nuclearBySeq.get(seq);
350
+ const header = entry ? formatNuclearLine(entry) : `#${seq}`;
351
+ hits.push(`${header}\n ${excerpt}`);
193
352
  }
194
353
  }
195
- if (parts.length === 0)
354
+ const fileResults = this.handlers
355
+ ? (await this.handlers.call("history:search", query))
356
+ : undefined;
357
+ if (fileResults) {
358
+ for (const r of fileResults) {
359
+ if (seenSeqs.has(r.entry.seq))
360
+ continue;
361
+ seenSeqs.add(r.entry.seq);
362
+ const excerpt = r.entry.body ? firstMatchExcerpt(r.entry.body, regex) : null;
363
+ hits.push(excerpt ? `${r.line}\n ${excerpt}` : r.line);
364
+ }
365
+ }
366
+ if (hits.length === 0)
196
367
  return `No results found for "${query}".`;
197
- return parts.join("\n\n");
368
+ const total = hits.length;
369
+ const summary = `Found ${total} match${total === 1 ? "" : "es"} for "${query}"`;
370
+ return `${summary}\n\n${hits.slice(0, 30).join("\n\n")}`;
198
371
  }
199
- /** Expand full content of a nuclear entry by seq number. */
200
372
  async expand(seq) {
201
- // Check Tier 2 archive first
202
373
  const archived = this.recallArchive.get(seq);
203
374
  if (archived) {
204
- const entry = this.nuclearEntries.find((e) => e.seq === seq);
375
+ const entry = this.nuclearBySeq.get(seq);
205
376
  const header = entry ? formatNuclearLine(entry) : `#${seq}`;
206
377
  return `${header}\n\n${this.turnToText(archived)}`;
207
378
  }
208
- return `Entry #${seq}: not found in recall archive (may have been flushed to history file).`;
379
+ if (this.handlers) {
380
+ const entry = (await this.handlers.call("history:find-by-seq", seq));
381
+ if (entry?.body)
382
+ return `${formatNuclearLine(entry)}\n\n${entry.body}`;
383
+ }
384
+ return `Entry #${seq}: no expanded content available.`;
209
385
  }
210
- /** Browse nuclear entries (Tier 2) + recent history (Tier 3). */
211
386
  async browse() {
212
387
  const parts = [];
213
388
  if (this.nuclearEntries.length > 0) {
214
389
  parts.push("In-context nuclear entries:");
215
- for (const e of this.nuclearEntries) {
390
+ for (const e of this.nuclearEntries)
216
391
  parts.push(` ${formatNuclearLine(e)}`);
217
- }
218
392
  }
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
- }
393
+ const recent = this.handlers
394
+ ? (await this.handlers.call("history:read-recent", 25))
395
+ : undefined;
396
+ if (recent && recent.length > 0) {
397
+ parts.push("\nRecent history file entries:");
398
+ for (const e of recent)
399
+ parts.push(` ${formatNuclearLine(e)}`);
227
400
  }
228
401
  if (parts.length === 0)
229
402
  return "No conversation history.";
230
403
  return parts.join("\n");
231
404
  }
232
405
  // ── Stats ─────────────────────────────────────────────────────
406
+ getNuclearEntries() {
407
+ return this.nuclearEntries;
408
+ }
233
409
  getNuclearEntryCount() {
234
410
  return this.nuclearEntries.length;
235
411
  }
412
+ getNuclearSummary() {
413
+ if (this.nuclearEntries.length === 0)
414
+ return null;
415
+ return this.nuclearEntries.map(formatNuclearLine).join("\n");
416
+ }
236
417
  getRecallArchiveSize() {
237
418
  return this.recallArchive.size;
238
419
  }
@@ -240,7 +421,11 @@ export class ConversationState {
240
421
  clear() {
241
422
  this.messages = [];
242
423
  this.nuclearEntries = [];
424
+ this.nuclearBySeq.clear();
243
425
  this.recallArchive.clear();
426
+ this.invalidateMessagesCache();
427
+ this.lastApiTokenCount = null;
428
+ this.lastApiMessageCount = 0;
244
429
  }
245
430
  // ── Internal: Nuclear block management ────────────────────────
246
431
  buildNuclearBlock() {
@@ -250,20 +435,32 @@ export class ConversationState {
250
435
  content: `[Conversation history — use conversation_recall to expand any entry]\n${lines.join("\n")}`,
251
436
  };
252
437
  }
438
+ /** Index of the nuclear block in messages[], or -1 if not present. */
439
+ nuclearBlockIdx = -1;
253
440
  updateNuclearBlockInMessages(messages) {
254
441
  if (this.nuclearEntries.length === 0)
255
442
  return;
256
443
  const marker = "[Conversation history — use conversation_recall";
444
+ const newBlock = this.buildNuclearBlock();
445
+ // Verify the cached index still points at the nuclear block; stale if
446
+ // messages[] was mutated elsewhere since compaction.
447
+ if (this.nuclearBlockIdx >= 0 && this.nuclearBlockIdx < messages.length) {
448
+ const slot = messages[this.nuclearBlockIdx];
449
+ if (slot.role === "user" && typeof slot.content === "string" && slot.content.startsWith(marker)) {
450
+ messages[this.nuclearBlockIdx] = newBlock;
451
+ return;
452
+ }
453
+ this.nuclearBlockIdx = -1;
454
+ }
257
455
  for (let i = 0; i < messages.length; i++) {
258
456
  const msg = messages[i];
259
457
  if (msg.role === "user" && typeof msg.content === "string" && msg.content.startsWith(marker)) {
260
- messages[i] = this.buildNuclearBlock();
458
+ this.nuclearBlockIdx = i;
459
+ messages[i] = newBlock;
261
460
  return;
262
461
  }
263
462
  }
264
- // No existing block found — insert after the first turn
265
463
  if (messages.length > 0) {
266
- // Find end of first turn (next user message or end)
267
464
  let insertIdx = 1;
268
465
  for (let i = 1; i < messages.length; i++) {
269
466
  if (messages[i].role === "user") {
@@ -272,8 +469,50 @@ export class ConversationState {
272
469
  }
273
470
  insertIdx = i + 1;
274
471
  }
275
- messages.splice(insertIdx, 0, this.buildNuclearBlock());
472
+ messages.splice(insertIdx, 0, newBlock);
473
+ this.nuclearBlockIdx = insertIdx;
474
+ }
475
+ }
476
+ // ── Internal: Two-tier pin for recent turns ────────────────────
477
+ slimTurn(messages) {
478
+ const MAX_RESULT_LEN = 1500;
479
+ const result = [];
480
+ const readOnlyToolIds = new Set();
481
+ for (const msg of messages) {
482
+ if (msg.role === "assistant" && "tool_calls" in msg && msg.tool_calls) {
483
+ const kept = msg.tool_calls.filter((tc) => {
484
+ if (!("function" in tc))
485
+ return true;
486
+ if (READ_ONLY_TOOLS.has(tc.function.name)) {
487
+ readOnlyToolIds.add(tc.id);
488
+ return false;
489
+ }
490
+ return true;
491
+ });
492
+ if (kept.length === 0) {
493
+ const { tool_calls: _, ...rest } = msg;
494
+ result.push(rest);
495
+ }
496
+ else {
497
+ result.push({ ...msg, tool_calls: kept });
498
+ }
499
+ continue;
500
+ }
501
+ if (msg.role === "tool") {
502
+ if (readOnlyToolIds.has(msg.tool_call_id))
503
+ continue;
504
+ const content = typeof msg.content === "string" ? msg.content : "";
505
+ if (content.length > MAX_RESULT_LEN) {
506
+ result.push({ ...msg, content: content.slice(0, MAX_RESULT_LEN) + "\n... [truncated by compact]" });
507
+ }
508
+ else {
509
+ result.push(msg);
510
+ }
511
+ continue;
512
+ }
513
+ result.push(msg);
276
514
  }
515
+ return result;
277
516
  }
278
517
  // ── Internal: Turn parsing and priority ───────────────────────
279
518
  parseTurns() {
@@ -286,9 +525,8 @@ export class ConversationState {
286
525
  }
287
526
  current.push(msg);
288
527
  }
289
- if (current.length > 0) {
528
+ if (current.length > 0)
290
529
  turns.push({ messages: current, priority: 2 /* Priority.MEDIUM */ });
291
- }
292
530
  return turns;
293
531
  }
294
532
  inferPriority(messages) {
@@ -301,8 +539,11 @@ export class ConversationState {
301
539
  return 3 /* Priority.HIGH */;
302
540
  if (msg.role === "tool") {
303
541
  hasToolResult = true;
542
+ // Structured flag is primary; the "Error:" prefix check covers
543
+ // callers that didn't thread isError (extensions, legacy paths).
544
+ const id = typeof msg.tool_call_id === "string" ? msg.tool_call_id : "";
304
545
  const content = typeof msg.content === "string" ? msg.content : "";
305
- if (content.startsWith("Error:") || content.includes("error")) {
546
+ if (this.toolErrors.has(id) || content.startsWith("Error:")) {
306
547
  hasError = true;
307
548
  }
308
549
  }
@@ -311,10 +552,9 @@ export class ConversationState {
311
552
  const fn = "function" in tc ? tc.function : undefined;
312
553
  if (!fn)
313
554
  continue;
314
- const name = fn.name;
315
- if (WRITE_TOOLS.has(name))
555
+ if (WRITE_TOOLS.has(fn.name))
316
556
  hasWriteTool = true;
317
- if (!READ_ONLY_TOOLS.has(name))
557
+ if (!READ_ONLY_TOOLS.has(fn.name))
318
558
  allReadOnly = false;
319
559
  }
320
560
  }
@@ -329,31 +569,6 @@ export class ConversationState {
329
569
  return 1 /* Priority.LOW */;
330
570
  return 2 /* Priority.MEDIUM */;
331
571
  }
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
572
  turnToText(messages) {
358
573
  const lines = [];
359
574
  for (const msg of messages) {
@@ -361,20 +576,18 @@ export class ConversationState {
361
576
  lines.push(`[user] ${typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content)}`);
362
577
  }
363
578
  else if (msg.role === "assistant") {
364
- if (typeof msg.content === "string" && msg.content) {
579
+ if (typeof msg.content === "string" && msg.content)
365
580
  lines.push(`[assistant] ${msg.content}`);
366
- }
367
581
  if ("tool_calls" in msg && msg.tool_calls) {
368
582
  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
- }
583
+ if ("function" in tc)
584
+ lines.push(`[tool_call] ${tc.function.name}(${tc.function.arguments})`);
372
585
  }
373
586
  }
374
587
  }
375
588
  else if (msg.role === "tool") {
376
589
  const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
377
- lines.push(`[tool_result] ${content.slice(0, 500)}`);
590
+ lines.push(`[tool_result] ${content}`);
378
591
  }
379
592
  }
380
593
  return lines.join("\n");