agent-sh 0.14.8 → 0.14.9

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 (49) hide show
  1. package/dist/agent/agent-loop.d.ts +0 -4
  2. package/dist/agent/agent-loop.js +8 -166
  3. package/dist/agent/entry-format.d.ts +5 -0
  4. package/dist/agent/entry-format.js +9 -0
  5. package/dist/agent/extensions/rolling-history/constants.d.ts +1 -0
  6. package/dist/agent/extensions/rolling-history/constants.js +1 -0
  7. package/dist/agent/extensions/rolling-history/index.d.ts +4 -0
  8. package/dist/agent/extensions/rolling-history/index.js +203 -0
  9. package/dist/agent/extensions/rolling-history/recall.d.ts +4 -0
  10. package/dist/agent/extensions/rolling-history/recall.js +122 -0
  11. package/dist/agent/extensions/rolling-history/strategy.d.ts +70 -0
  12. package/dist/agent/extensions/rolling-history/strategy.js +336 -0
  13. package/dist/agent/host-types.d.ts +0 -3
  14. package/dist/agent/index.js +44 -5
  15. package/dist/agent/live-view.d.ts +57 -0
  16. package/dist/agent/live-view.js +238 -0
  17. package/dist/agent/llm-client.d.ts +1 -0
  18. package/dist/agent/llm-client.js +1 -1
  19. package/dist/agent/session-store.d.ts +90 -0
  20. package/dist/agent/session-store.js +288 -0
  21. package/dist/agent/store.d.ts +74 -0
  22. package/dist/agent/store.js +284 -0
  23. package/dist/agent/subagent.js +2 -2
  24. package/dist/agent/tool-protocol.d.ts +11 -11
  25. package/dist/cli/index.js +4 -2
  26. package/dist/core/index.d.ts +0 -1
  27. package/dist/core/index.js +0 -1
  28. package/dist/core/settings.d.ts +5 -1
  29. package/dist/core/settings.js +62 -1
  30. package/dist/extensions/index.d.ts +1 -0
  31. package/dist/shell/events.d.ts +1 -0
  32. package/dist/shell/input-handler.js +4 -0
  33. package/dist/shell/tui-renderer.js +5 -2
  34. package/dist/utils/diff-renderer.js +9 -7
  35. package/examples/extensions/ash-acp-bridge/src/index.ts +1 -2
  36. package/examples/extensions/ashi/package.json +2 -2
  37. package/examples/extensions/ashi/src/capture.ts +1 -1
  38. package/examples/extensions/ashi/src/cli.ts +3 -4
  39. package/examples/extensions/ashi/src/compaction.ts +6 -2
  40. package/examples/extensions/ashi/src/frontend.ts +13 -13
  41. package/examples/extensions/ashi/src/multi-session-store.ts +35 -12
  42. package/examples/extensions/ashi/src/session-commands.ts +1 -1
  43. package/examples/extensions/ashi/src/user-shell-intents.ts +17 -0
  44. package/package.json +13 -1
  45. package/dist/agent/conversation-state.d.ts +0 -142
  46. package/dist/agent/conversation-state.js +0 -788
  47. package/dist/agent/history-file.d.ts +0 -81
  48. package/dist/agent/history-file.js +0 -271
  49. package/examples/extensions/ashi/src/session-store.ts +0 -363
@@ -1,788 +0,0 @@
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
- // Head+tail because the start (command, opening lines) and end (final
33
- // result, exit code) are the informative parts of shell/file output.
34
- function slimToolContent(content, maxLen) {
35
- const exitMatch = content.match(/exit code:?\s*(\d+)/i);
36
- const exitSuffix = exitMatch ? ` (exit ${exitMatch[1]})` : "";
37
- const lines = content.split("\n");
38
- if (lines.length > 6) {
39
- const head = lines.slice(0, 3).join("\n");
40
- const tail = lines.slice(-2).join("\n");
41
- return `${head}\n... [${lines.length - 5} lines trimmed by compact]\n${tail}${exitSuffix}`;
42
- }
43
- return `${content.slice(0, maxLen)}\n... [${content.length - maxLen} chars trimmed by compact]${exitSuffix}`;
44
- }
45
- /**
46
- * Conversation state with eager nucleation — shell-history shaped.
47
- *
48
- * Every add nucleates into a one-line NuclearEntry and flushes to disk.
49
- * Compaction evicts turns, replacing them with their nuclear one-liners
50
- * in context; the originals stay searchable via `conversation_recall`
51
- * and survive restarts in `~/.agent-sh/history`.
52
- *
53
- * Nucleation and history I/O go through advisable handlers — extensions
54
- * swap strategies without touching this class. When no handlers are
55
- * provided (subagents, tests), both become no-ops and this becomes a
56
- * plain message buffer.
57
- */
58
- export class ConversationState {
59
- messages = [];
60
- messagesDirty = true;
61
- cachedMessagesJson = null;
62
- // tool_call_ids whose results came back with isError=true.
63
- toolErrors = new Set();
64
- nuclearEntries = [];
65
- nuclearBySeq = new Map();
66
- recallArchive = new Map();
67
- instanceId;
68
- handlers;
69
- nextSeq = 1;
70
- lastApiTokenCount = null;
71
- lastApiMessageCount = 0;
72
- // Buffered when addSystemNote/appendUserMessage fires mid-tool-pair;
73
- // flushed once the trailing tool_result lands. Splicing into the gap
74
- // breaks reasoning_content pairing and is rejected by strict providers.
75
- pendingMessages = [];
76
- constructor(handlers, instanceId = "0000") {
77
- this.handlers = handlers ?? null;
78
- this.instanceId = instanceId;
79
- }
80
- /** Get JSON.stringify of messages, cached until next mutation. */
81
- getMessagesJson() {
82
- if (this.messagesDirty || this.cachedMessagesJson === null) {
83
- this.cachedMessagesJson = JSON.stringify(this.messages);
84
- this.messagesDirty = false;
85
- }
86
- return this.cachedMessagesJson;
87
- }
88
- invalidateMessagesCache() {
89
- this.messagesDirty = true;
90
- this.cachedMessagesJson = null;
91
- }
92
- // ── Message API (with eager nucleation) ───────────────────────
93
- addUserMessage(text) {
94
- this.messages.push({ role: "user", content: text });
95
- this.invalidateMessagesCache();
96
- this.eagerNucleateUser(text);
97
- }
98
- addAssistantMessage(content, toolCalls, extras) {
99
- const hasToolCalls = !!toolCalls?.length;
100
- // Promote reasoning into content on reasoning-only turns; strict
101
- // providers (DeepSeek native) reject content="" with no tool_calls.
102
- if (!content && !hasToolCalls) {
103
- const r = (extras?.reasoning_content ?? extras?.reasoning);
104
- if (typeof r === "string" && r)
105
- content = r;
106
- }
107
- if (!content && !hasToolCalls)
108
- return;
109
- const base = {
110
- role: "assistant",
111
- content: hasToolCalls ? (content ?? null) : content,
112
- };
113
- if (hasToolCalls) {
114
- base.tool_calls = toolCalls.map((tc) => ({
115
- id: tc.id,
116
- type: "function",
117
- function: tc.function,
118
- }));
119
- }
120
- if (extras)
121
- Object.assign(base, extras);
122
- this.messages.push(base);
123
- this.invalidateMessagesCache();
124
- }
125
- addToolResult(toolCallId, content, isError = false) {
126
- if (typeof content === "string") {
127
- this.messages.push({ role: "tool", tool_call_id: toolCallId, content });
128
- }
129
- else {
130
- // Assembles OpenAI vision content parts for multimodal tool results.
131
- // This format (array of text + image_url blocks on a tool message) is
132
- // supported by OpenAI and most OpenAI-compatible providers. Providers
133
- // that don't support it should not declare image modalities, so this
134
- // path is only reached for providers known to handle it.
135
- const parts = [];
136
- for (const img of content) {
137
- parts.push({ type: "image_url", image_url: { url: `data:${img.mimeType};base64,${img.data}` } });
138
- }
139
- const label = isError ? `Error: [${content.length} image(s)]` : `[${content.length} image(s)]`;
140
- parts.unshift({ type: "text", text: label });
141
- this.messages.push({ role: "tool", tool_call_id: toolCallId, content: parts });
142
- }
143
- if (isError)
144
- this.toolErrors.add(toolCallId);
145
- this.invalidateMessagesCache();
146
- this.flushPendingMessages();
147
- }
148
- /** Add tool results as a user message (for inline tool protocol). */
149
- addToolResultInline(content) {
150
- this.messages.push({ role: "user", content });
151
- this.invalidateMessagesCache();
152
- this.flushPendingMessages();
153
- }
154
- /** Safe from any context: queues if mid-tool-pair, appends otherwise. */
155
- addSystemNote(text) {
156
- if (this.hasOpenToolCalls()) {
157
- this.pendingMessages.push({ kind: "system", text });
158
- return;
159
- }
160
- this.messages.push({ role: "user", content: text });
161
- this.invalidateMessagesCache();
162
- }
163
- appendUserMessage(text) {
164
- if (this.hasOpenToolCalls()) {
165
- this.pendingMessages.push({ kind: "user", text });
166
- return;
167
- }
168
- this.addUserMessage(text);
169
- }
170
- hasOpenToolCalls() {
171
- for (let i = this.messages.length - 1; i >= 0; i--) {
172
- const msg = this.messages[i];
173
- if (msg.role === "tool")
174
- continue;
175
- if (msg.role !== "assistant")
176
- return false;
177
- if (!("tool_calls" in msg) || !msg.tool_calls)
178
- return false;
179
- const answered = new Set();
180
- for (let j = i + 1; j < this.messages.length; j++) {
181
- const m = this.messages[j];
182
- if (m.role !== "tool")
183
- break;
184
- answered.add(m.tool_call_id);
185
- }
186
- return msg.tool_calls.some((tc) => !answered.has(tc.id));
187
- }
188
- return false;
189
- }
190
- flushPendingMessages() {
191
- if (this.pendingMessages.length === 0)
192
- return;
193
- if (this.hasOpenToolCalls())
194
- return;
195
- const pending = this.pendingMessages;
196
- this.pendingMessages = [];
197
- for (const m of pending) {
198
- if (m.kind === "user") {
199
- this.addUserMessage(m.text);
200
- }
201
- else {
202
- this.messages.push({ role: "user", content: m.text });
203
- }
204
- }
205
- this.invalidateMessagesCache();
206
- }
207
- getMessages() {
208
- return this.normalizeReasoningConsistency(this.stubDanglingToolCalls(this.dropOrphanToolMessages(this.messages)));
209
- }
210
- /** Drop tool messages with no matching preceding tool_call — strict
211
- * providers (DeepSeek) 400, and compaction can leave such orphans. */
212
- dropOrphanToolMessages(messages) {
213
- const knownIds = new Set();
214
- const result = [];
215
- for (const msg of messages) {
216
- if (msg.role === "assistant" && "tool_calls" in msg && msg.tool_calls) {
217
- for (const tc of msg.tool_calls)
218
- knownIds.add(tc.id);
219
- }
220
- if (msg.role === "tool" && !knownIds.has(msg.tool_call_id)) {
221
- continue;
222
- }
223
- result.push(msg);
224
- }
225
- return result;
226
- }
227
- /**
228
- * If a stream was interrupted mid-tool-execution, an assistant message
229
- * with tool_calls can land in history without matching tool results.
230
- * Strict providers (DeepSeek) 400 on this. Stub each missing result
231
- * with a [cancelled] marker so the protocol stays valid.
232
- */
233
- stubDanglingToolCalls(messages) {
234
- const result = [];
235
- let i = 0;
236
- while (i < messages.length) {
237
- const msg = messages[i];
238
- result.push(msg);
239
- i++;
240
- if (msg.role !== "assistant" || !("tool_calls" in msg) || !msg.tool_calls)
241
- continue;
242
- const seen = new Set();
243
- while (i < messages.length && messages[i].role === "tool") {
244
- const t = messages[i];
245
- seen.add(t.tool_call_id);
246
- result.push(t);
247
- i++;
248
- }
249
- for (const tc of msg.tool_calls) {
250
- if (!seen.has(tc.id)) {
251
- result.push({ role: "tool", tool_call_id: tc.id, content: "[cancelled]" });
252
- }
253
- }
254
- }
255
- return result;
256
- }
257
- /**
258
- * DeepSeek 400s if any assistant in a thinking-mode conversation is
259
- * missing reasoning_content. Cross-alias here (OpenRouter streams as
260
- * `reasoning`, DeepSeek input expects `reasoning_content`) and stub
261
- * gaps (text-only turns, pre-fix messages) with empty string.
262
- */
263
- normalizeReasoningConsistency(messages) {
264
- const needsNormalize = messages.some((m) => m.role === "assistant" && (m.reasoning !== undefined ||
265
- m.reasoning_content !== undefined ||
266
- m.reasoning_details !== undefined));
267
- if (!needsNormalize)
268
- return messages;
269
- return messages.map((m) => {
270
- if (m.role !== "assistant")
271
- return m;
272
- const a = m;
273
- if (a.reasoning_content !== undefined)
274
- return m;
275
- return { ...m, reasoning_content: a.reasoning ?? "" };
276
- });
277
- }
278
- /**
279
- * Replace the messages array wholesale — the write side for custom
280
- * compaction strategies. Invalidates API token baseline since the
281
- * new array's token count is unknown.
282
- */
283
- replaceMessages(messages) {
284
- this.messages = messages;
285
- this.pruneToolErrors();
286
- this.invalidateMessagesCache();
287
- this.lastApiTokenCount = null;
288
- this.lastApiMessageCount = 0;
289
- this.flushPendingMessages();
290
- }
291
- pruneToolErrors() {
292
- if (this.toolErrors.size === 0)
293
- return;
294
- const live = new Set();
295
- for (const msg of this.messages) {
296
- if (msg.role === "tool" && typeof msg.tool_call_id === "string") {
297
- live.add(msg.tool_call_id);
298
- }
299
- }
300
- for (const id of this.toolErrors) {
301
- if (!live.has(id))
302
- this.toolErrors.delete(id);
303
- }
304
- }
305
- // ── Eager nucleation (via advisable handlers) ─────────────────
306
- eagerNucleateUser(text) {
307
- if (!this.handlers)
308
- return;
309
- const seq = this.nextSeq++;
310
- const entry = this.handlers.call("conversation:nucleate-user", text, this.instanceId, seq);
311
- this.recordNuclearEntry(entry, [{ role: "user", content: text }]);
312
- this.appendToHistory([entry]);
313
- }
314
- /** Nucleate an agent text response. Called by agent-loop when the loop finishes without tool calls. */
315
- eagerNucleateAgent(text) {
316
- if (!text || !this.handlers)
317
- return;
318
- const seq = this.nextSeq++;
319
- const entry = this.handlers.call("conversation:nucleate-agent", text, this.instanceId, seq);
320
- this.recordNuclearEntry(entry, [{ role: "assistant", content: text }]);
321
- this.appendToHistory([entry]);
322
- }
323
- /** Nucleate tool call results. One entry per tool call, enriched with result. */
324
- eagerNucleateTools(results) {
325
- if (!this.handlers || results.length === 0)
326
- return;
327
- const entries = [];
328
- for (const r of results) {
329
- const seq = this.nextSeq++;
330
- const text = typeof r.content === "string" ? r.content : `[${r.content.length} image(s)]`;
331
- const entry = this.handlers.call("conversation:nucleate-tool", r.toolName, r.args, text, r.isError, this.instanceId, seq);
332
- entries.push(entry);
333
- this.recordNuclearEntry(entry, [
334
- { role: "assistant", content: null, tool_calls: [{ id: `seq_${seq}`, type: "function", function: { name: r.toolName, arguments: JSON.stringify(r.args) } }] },
335
- { role: "tool", tool_call_id: `seq_${seq}`, content: text },
336
- ]);
337
- }
338
- this.appendToHistory(entries);
339
- }
340
- /** Track an entry in memory (nuclear list + recall archive). */
341
- recordNuclearEntry(entry, originalMessages) {
342
- this.nuclearEntries.push(entry);
343
- this.nuclearBySeq.set(entry.seq, entry);
344
- this.recallArchive.set(entry.seq, originalMessages);
345
- }
346
- appendToHistory(entries) {
347
- if (!this.handlers || entries.length === 0)
348
- return;
349
- this.handlers.call("history:append", entries);
350
- }
351
- /** Bump and return the global sequence counter. For extensions that
352
- * synthesize their own NuclearEntries (e.g. compaction summaries that
353
- * should land in the same sequence space as kernel-produced entries). */
354
- allocateSeq() {
355
- return this.nextSeq++;
356
- }
357
- /** Clear nuclear bookkeeping and reset the seq counter. For extensions
358
- * that swap sessions (multi-session history adapters) so the in-memory
359
- * nuclear list, recall archive, and seq counter don't carry over from
360
- * the previous session's tree. */
361
- resetForSession(nextSeq) {
362
- this.nuclearEntries = [];
363
- this.nuclearBySeq.clear();
364
- this.recallArchive.clear();
365
- this.nextSeq = nextSeq;
366
- }
367
- // ── Token estimation ──────────────────────────────────────────
368
- updateApiTokenCount(promptTokens) {
369
- this.lastApiTokenCount = promptTokens;
370
- this.lastApiMessageCount = this.messages.length;
371
- }
372
- estimatePromptTokens() {
373
- if (this.lastApiTokenCount === null)
374
- return this.estimateTokens();
375
- const trailing = this.messages.length - this.lastApiMessageCount;
376
- if (trailing <= 0)
377
- return this.lastApiTokenCount;
378
- const trailingMessages = this.messages.slice(this.lastApiMessageCount);
379
- return this.lastApiTokenCount + Math.ceil(JSON.stringify(trailingMessages).length / 4);
380
- }
381
- estimateTokens() {
382
- return Math.ceil(this.getMessagesJson().length / 4);
383
- }
384
- // ── Compaction (uses pre-computed nuclear entries) ─────────────
385
- /**
386
- * Two-tier pin compaction: evict lowest-priority turns (replaced by
387
- * their nuclear one-liners), slim the window before the last verbatim
388
- * turn, drop read-only tool results entirely. Extensions replace the
389
- * whole strategy by advising `conversation:compact` and skipping next.
390
- */
391
- compact(maxPromptTokens, recentTurnsToKeep = 10, force = false) {
392
- const promptEstimate = this.estimatePromptTokens();
393
- const convEstimate = this.estimateTokens();
394
- const overhead = promptEstimate - convEstimate;
395
- const convTarget = Math.max(0, maxPromptTokens - overhead);
396
- const before = promptEstimate;
397
- if (!force && convEstimate <= convTarget)
398
- return null;
399
- const turns = this.parseTurns();
400
- // With force, allow compacting down to 1 turn (the current response).
401
- // Without force, keep at least 2 turns (user + agent) to avoid
402
- // annihilating a young conversation.
403
- if (turns.length <= (force ? 1 : 2))
404
- return null;
405
- // Cap the pinned window so enough turns remain evictable.
406
- const maxPinnedFraction = force ? 0.4 : 0.6;
407
- const maxPinned = Math.max(2, Math.floor(turns.length * maxPinnedFraction));
408
- // Ensure at least 1 turn is evictable when force is true, even in
409
- // very short conversations (e.g. 3 turns with heavy tool output).
410
- const maxPinnedForced = force ? Math.min(maxPinned, turns.length - 2) : maxPinned;
411
- const pinnedCount = Math.min(recentTurnsToKeep, turns.length - 1, Math.max(1, maxPinnedForced));
412
- for (let i = 0; i < turns.length; i++) {
413
- turns[i].priority = this.inferPriority(turns[i].messages);
414
- }
415
- // Two-tier pin: last turn verbatim, next (pinnedCount-1) slimmed.
416
- const verbatimCount = 1;
417
- const slimmedCount = Math.max(0, pinnedCount - verbatimCount);
418
- const slimStart = turns.length - pinnedCount;
419
- const slimEnd = slimStart + slimmedCount;
420
- const slimmedIndices = new Set();
421
- for (let i = slimStart; i < slimEnd; i++)
422
- slimmedIndices.add(i);
423
- turns[0].priority = 4 /* Priority.PINNED */;
424
- for (let i = turns.length - verbatimCount; i < turns.length; i++)
425
- turns[i].priority = 4 /* Priority.PINNED */;
426
- for (const i of slimmedIndices)
427
- turns[i].priority = 4 /* Priority.PINNED */;
428
- const candidates = turns
429
- .map((t, idx) => ({ turn: t, idx }))
430
- .filter((c) => c.turn.priority !== 4 /* Priority.PINNED */)
431
- .sort((a, b) => {
432
- const effA = a.turn.priority * recencyWeight(a.idx, turns.length);
433
- const effB = b.turn.priority * recencyWeight(b.idx, turns.length);
434
- return effA - effB || a.idx - b.idx;
435
- });
436
- const evictedIndices = new Set();
437
- let currentTokens = convEstimate;
438
- for (const c of candidates) {
439
- if (currentTokens <= convTarget)
440
- break;
441
- const turnTokens = Math.ceil(JSON.stringify(c.turn.messages).length / 4);
442
- evictedIndices.add(c.idx);
443
- currentTokens -= turnTokens;
444
- // Fallback for turn messages that missed eager nucleation (e.g.
445
- // injected system notes). Entries already nucleated live in
446
- // nuclearEntries under their original seqs.
447
- const turnEntries = toNuclearEntries(c.turn.messages, this.nextSeq, this.instanceId);
448
- this.nextSeq += turnEntries.length;
449
- for (const entry of turnEntries) {
450
- if (isReadOnly(entry)) {
451
- this.recallArchive.set(entry.seq, c.turn.messages);
452
- }
453
- else {
454
- this.nuclearEntries.push(entry);
455
- this.nuclearBySeq.set(entry.seq, entry);
456
- this.recallArchive.set(entry.seq, c.turn.messages);
457
- }
458
- }
459
- }
460
- if (evictedIndices.size === 0)
461
- return null;
462
- const rebuilt = [];
463
- let insertedNuclearBlock = false;
464
- this.nuclearBlockIdx = -1;
465
- for (let i = 0; i < turns.length; i++) {
466
- if (evictedIndices.has(i)) {
467
- if (!insertedNuclearBlock) {
468
- const block = this.buildNuclearBlock();
469
- this.nuclearBlockIdx = rebuilt.length;
470
- rebuilt.push(block);
471
- insertedNuclearBlock = true;
472
- }
473
- }
474
- else if (slimmedIndices.has(i)) {
475
- rebuilt.push(...this.slimTurn(turns[i].messages));
476
- }
477
- else {
478
- rebuilt.push(...turns[i].messages);
479
- }
480
- }
481
- if (!insertedNuclearBlock && this.nuclearEntries.length > 0) {
482
- this.updateNuclearBlockInMessages(rebuilt);
483
- }
484
- this.messages = rebuilt;
485
- this.pruneToolErrors();
486
- this.invalidateMessagesCache();
487
- // Preserve system+tools+dynamic overhead so estimatePromptTokens() stays
488
- // full-prompt-accurate until the next API call refines it. Nulling here
489
- // caused /context to under-report by ~overhead tokens after every compact.
490
- const after = overhead + this.estimateTokens();
491
- this.lastApiTokenCount = after;
492
- this.lastApiMessageCount = this.messages.length;
493
- return {
494
- before,
495
- after,
496
- evictedCount: evictedIndices.size,
497
- };
498
- }
499
- // ── Startup: Load prior history ───────────────────────────────
500
- /**
501
- * Inject prior session history as a context preamble. The preamble
502
- * layout goes through the `conversation:format-prior-history` handler,
503
- * so extensions can swap the flat list for grouped/richer rendering.
504
- */
505
- loadPriorHistory(entries) {
506
- if (entries.length === 0 || !this.handlers)
507
- return;
508
- const maxSeq = Math.max(...entries.map((e) => e.seq));
509
- if (maxSeq >= this.nextSeq)
510
- this.nextSeq = maxSeq + 1;
511
- const content = this.handlers.call("conversation:format-prior-history", entries);
512
- if (!content)
513
- return;
514
- this.messages.push({ role: "user", content });
515
- this.invalidateMessagesCache();
516
- }
517
- // ── Conversation recall ───────────────────────────────────────
518
- async search(query) {
519
- if (!query.trim())
520
- return "No query provided.";
521
- const regex = buildSearchRegex(query);
522
- const seenSeqs = new Set();
523
- const hits = [];
524
- for (const [seq, msgs] of this.recallArchive) {
525
- const text = this.turnToText(msgs);
526
- const excerpt = firstMatchExcerpt(text, regex);
527
- if (excerpt) {
528
- seenSeqs.add(seq);
529
- const entry = this.nuclearBySeq.get(seq);
530
- const header = entry ? formatNuclearLine(entry) : `#${seq}`;
531
- hits.push(`${header}\n ${excerpt}`);
532
- }
533
- }
534
- const fileResults = this.handlers
535
- ? (await this.handlers.call("history:search", query))
536
- : undefined;
537
- if (fileResults) {
538
- for (const r of fileResults) {
539
- if (seenSeqs.has(r.entry.seq))
540
- continue;
541
- seenSeqs.add(r.entry.seq);
542
- const excerpt = r.entry.body ? firstMatchExcerpt(r.entry.body, regex) : null;
543
- hits.push(excerpt ? `${r.line}\n ${excerpt}` : r.line);
544
- }
545
- }
546
- if (hits.length === 0)
547
- return `No results found for "${query}".`;
548
- const total = hits.length;
549
- const summary = `Found ${total} match${total === 1 ? "" : "es"} for "${query}"`;
550
- return `${summary}\n\n${hits.slice(0, 30).join("\n\n")}`;
551
- }
552
- async expand(seq) {
553
- const archived = this.recallArchive.get(seq);
554
- if (archived) {
555
- const entry = this.nuclearBySeq.get(seq);
556
- const header = entry ? formatNuclearLine(entry) : `#${seq}`;
557
- return `${header}\n\n${this.turnToText(archived)}`;
558
- }
559
- if (this.handlers) {
560
- const entry = (await this.handlers.call("history:find-by-seq", seq));
561
- if (entry?.body)
562
- return `${formatNuclearLine(entry)}\n\n${entry.body}`;
563
- }
564
- return `Entry #${seq}: no expanded content available.`;
565
- }
566
- async browse() {
567
- const parts = [];
568
- if (this.nuclearEntries.length > 0) {
569
- parts.push("In-context nuclear entries:");
570
- for (const e of this.nuclearEntries)
571
- parts.push(` ${formatNuclearLine(e)}`);
572
- }
573
- const recent = this.handlers
574
- ? (await this.handlers.call("history:read-recent", 25))
575
- : undefined;
576
- if (recent && recent.length > 0) {
577
- parts.push("\nRecent history file entries:");
578
- for (const e of recent)
579
- parts.push(` ${formatNuclearLine(e)}`);
580
- }
581
- if (parts.length === 0)
582
- return "No conversation history.";
583
- return parts.join("\n");
584
- }
585
- // ── Stats ─────────────────────────────────────────────────────
586
- getNuclearEntries() {
587
- return this.nuclearEntries;
588
- }
589
- getNuclearEntryCount() {
590
- return this.nuclearEntries.length;
591
- }
592
- getNuclearSummary() {
593
- if (this.nuclearEntries.length === 0)
594
- return null;
595
- return this.nuclearEntries.map(formatNuclearLine).join("\n");
596
- }
597
- getRecallArchiveSize() {
598
- return this.recallArchive.size;
599
- }
600
- // ── Clear ─────────────────────────────────────────────────────
601
- clear() {
602
- this.messages = [];
603
- this.nuclearEntries = [];
604
- this.nuclearBySeq.clear();
605
- this.recallArchive.clear();
606
- this.pendingMessages = [];
607
- this.invalidateMessagesCache();
608
- this.lastApiTokenCount = null;
609
- this.lastApiMessageCount = 0;
610
- }
611
- // ── Internal: Nuclear block management ────────────────────────
612
- buildNuclearBlock() {
613
- const lines = this.nuclearEntries.map(formatNuclearLine);
614
- return {
615
- role: "user",
616
- content: `[Conversation history — use conversation_recall to expand any entry]\n${lines.join("\n")}`,
617
- };
618
- }
619
- /** Index of the nuclear block in messages[], or -1 if not present. */
620
- nuclearBlockIdx = -1;
621
- updateNuclearBlockInMessages(messages) {
622
- if (this.nuclearEntries.length === 0)
623
- return;
624
- const marker = "[Conversation history — use conversation_recall";
625
- const newBlock = this.buildNuclearBlock();
626
- // Verify the cached index still points at the nuclear block; stale if
627
- // messages[] was mutated elsewhere since compaction.
628
- if (this.nuclearBlockIdx >= 0 && this.nuclearBlockIdx < messages.length) {
629
- const slot = messages[this.nuclearBlockIdx];
630
- if (slot.role === "user" && typeof slot.content === "string" && slot.content.startsWith(marker)) {
631
- messages[this.nuclearBlockIdx] = newBlock;
632
- return;
633
- }
634
- this.nuclearBlockIdx = -1;
635
- }
636
- for (let i = 0; i < messages.length; i++) {
637
- const msg = messages[i];
638
- if (msg.role === "user" && typeof msg.content === "string" && msg.content.startsWith(marker)) {
639
- this.nuclearBlockIdx = i;
640
- messages[i] = newBlock;
641
- return;
642
- }
643
- }
644
- if (messages.length > 0) {
645
- let insertIdx = 1;
646
- for (let i = 1; i < messages.length; i++) {
647
- if (messages[i].role === "user") {
648
- insertIdx = i;
649
- break;
650
- }
651
- insertIdx = i + 1;
652
- }
653
- messages.splice(insertIdx, 0, newBlock);
654
- this.nuclearBlockIdx = insertIdx;
655
- }
656
- }
657
- // ── Internal: Two-tier pin for recent turns ────────────────────
658
- slimTurn(messages) {
659
- const MAX_RESULT_LEN = 1500;
660
- const MAX_ASSISTANT_LEN = 1500;
661
- const result = [];
662
- const droppedToolIds = new Set();
663
- for (const msg of messages) {
664
- if (msg.role === "assistant" && "tool_calls" in msg && msg.tool_calls) {
665
- const kept = msg.tool_calls.filter((tc) => {
666
- if (!("function" in tc))
667
- return true;
668
- if (READ_ONLY_TOOLS.has(tc.function.name)) {
669
- droppedToolIds.add(tc.id);
670
- return false;
671
- }
672
- return true;
673
- });
674
- if (kept.length === 0) {
675
- // No content + no tool_calls is malformed (DeepSeek 400); drop the husk.
676
- const text = typeof msg.content === "string" ? msg.content.trim() : "";
677
- if (!text)
678
- continue;
679
- const { tool_calls: _, ...rest } = msg;
680
- result.push(rest);
681
- }
682
- else {
683
- result.push({ ...msg, tool_calls: kept });
684
- }
685
- continue;
686
- }
687
- if (msg.role === "tool") {
688
- if (droppedToolIds.has(msg.tool_call_id))
689
- continue;
690
- const content = typeof msg.content === "string" ? msg.content : "";
691
- if (content.length > MAX_RESULT_LEN) {
692
- result.push({ ...msg, content: slimToolContent(content, MAX_RESULT_LEN) });
693
- }
694
- else {
695
- result.push(msg);
696
- }
697
- continue;
698
- }
699
- if (msg.role === "assistant" && typeof msg.content === "string" && msg.content.length > MAX_ASSISTANT_LEN) {
700
- const head = msg.content.slice(0, Math.floor(MAX_ASSISTANT_LEN * 0.6));
701
- const tail = msg.content.slice(-Math.floor(MAX_ASSISTANT_LEN * 0.2));
702
- const trimmed = msg.content.length - head.length - tail.length;
703
- result.push({ ...msg, content: `${head}\n... [${trimmed} chars trimmed by compact]\n${tail}` });
704
- continue;
705
- }
706
- result.push(msg);
707
- }
708
- return result;
709
- }
710
- // ── Internal: Turn parsing and priority ───────────────────────
711
- parseTurns() {
712
- const turns = [];
713
- let current = [];
714
- for (const msg of this.messages) {
715
- if (msg.role === "user" && current.length > 0) {
716
- turns.push({ messages: current, priority: 2 /* Priority.MEDIUM */ });
717
- current = [];
718
- }
719
- current.push(msg);
720
- }
721
- if (current.length > 0)
722
- turns.push({ messages: current, priority: 2 /* Priority.MEDIUM */ });
723
- return turns;
724
- }
725
- inferPriority(messages) {
726
- let hasError = false;
727
- let hasWriteTool = false;
728
- let allReadOnly = true;
729
- let hasToolResult = false;
730
- for (const msg of messages) {
731
- if (msg.role === "user")
732
- return 3 /* Priority.HIGH */;
733
- if (msg.role === "tool") {
734
- hasToolResult = true;
735
- // Structured flag is primary; the "Error:" prefix check covers
736
- // callers that didn't thread isError (extensions, legacy paths).
737
- const id = typeof msg.tool_call_id === "string" ? msg.tool_call_id : "";
738
- const content = typeof msg.content === "string" ? msg.content : "";
739
- if (this.toolErrors.has(id) || content.startsWith("Error:")) {
740
- hasError = true;
741
- }
742
- }
743
- if (msg.role === "assistant" && "tool_calls" in msg && msg.tool_calls) {
744
- for (const tc of msg.tool_calls) {
745
- const fn = "function" in tc ? tc.function : undefined;
746
- if (!fn)
747
- continue;
748
- if (WRITE_TOOLS.has(fn.name))
749
- hasWriteTool = true;
750
- if (!READ_ONLY_TOOLS.has(fn.name))
751
- allReadOnly = false;
752
- }
753
- }
754
- }
755
- if (hasError)
756
- return 3 /* Priority.HIGH */;
757
- if (hasWriteTool)
758
- return 2 /* Priority.MEDIUM */;
759
- if (hasToolResult && allReadOnly)
760
- return 0 /* Priority.LOWEST */;
761
- if (hasToolResult)
762
- return 1 /* Priority.LOW */;
763
- return 2 /* Priority.MEDIUM */;
764
- }
765
- turnToText(messages) {
766
- const lines = [];
767
- for (const msg of messages) {
768
- if (msg.role === "user") {
769
- lines.push(`[user] ${typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content)}`);
770
- }
771
- else if (msg.role === "assistant") {
772
- if (typeof msg.content === "string" && msg.content)
773
- lines.push(`[assistant] ${msg.content}`);
774
- if ("tool_calls" in msg && msg.tool_calls) {
775
- for (const tc of msg.tool_calls) {
776
- if ("function" in tc)
777
- lines.push(`[tool_call] ${tc.function.name}(${tc.function.arguments})`);
778
- }
779
- }
780
- }
781
- else if (msg.role === "tool") {
782
- const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
783
- lines.push(`[tool_result] ${content}`);
784
- }
785
- }
786
- return lines.join("\n");
787
- }
788
- }