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.
- package/README.md +14 -21
- package/dist/agent/agent-loop.d.ts +43 -3
- package/dist/agent/agent-loop.js +811 -128
- package/dist/agent/conversation-state.d.ts +72 -21
- package/dist/agent/conversation-state.js +357 -150
- package/dist/agent/history-file.d.ts +13 -4
- package/dist/agent/history-file.js +110 -36
- package/dist/agent/nuclear-form.d.ts +28 -3
- package/dist/agent/nuclear-form.js +84 -3
- package/dist/agent/skills.d.ts +2 -4
- package/dist/agent/skills.js +10 -4
- package/dist/agent/subagent.d.ts +23 -0
- package/dist/agent/subagent.js +53 -11
- package/dist/agent/system-prompt.d.ts +34 -1
- package/dist/agent/system-prompt.js +96 -47
- package/dist/agent/token-budget.d.ts +5 -4
- package/dist/agent/token-budget.js +14 -19
- package/dist/agent/tool-protocol.d.ts +23 -1
- package/dist/agent/tool-protocol.js +169 -4
- package/dist/agent/tools/bash.js +3 -3
- package/dist/agent/tools/edit-file.js +9 -6
- package/dist/agent/tools/glob.js +4 -2
- package/dist/agent/tools/grep.js +27 -3
- package/dist/agent/tools/ls.js +5 -6
- package/dist/agent/types.d.ts +1 -1
- package/dist/context-manager.d.ts +17 -0
- package/dist/context-manager.js +37 -4
- package/dist/core.js +27 -6
- package/dist/event-bus.d.ts +59 -2
- package/dist/executor.d.ts +4 -3
- package/dist/executor.js +18 -15
- package/dist/extension-loader.js +50 -13
- package/dist/extensions/agent-backend.d.ts +8 -7
- package/dist/extensions/agent-backend.js +69 -48
- package/dist/extensions/index.js +0 -1
- package/dist/extensions/slash-commands.js +14 -9
- package/dist/extensions/tui-renderer.js +62 -78
- package/dist/index.js +25 -6
- package/dist/settings.d.ts +36 -5
- package/dist/settings.js +53 -9
- package/dist/shell/input-handler.d.ts +2 -1
- package/dist/shell/input-handler.js +82 -73
- package/dist/shell/shell.js +19 -2
- package/dist/types.d.ts +12 -0
- package/dist/utils/ansi.d.ts +5 -0
- package/dist/utils/ansi.js +1 -1
- package/dist/utils/compositor.d.ts +5 -0
- package/dist/utils/compositor.js +31 -3
- package/dist/utils/diff-renderer.d.ts +9 -0
- package/dist/utils/diff-renderer.js +221 -143
- package/dist/utils/diff.d.ts +21 -2
- package/dist/utils/diff.js +165 -89
- package/dist/utils/handler-registry.d.ts +5 -0
- package/dist/utils/handler-registry.js +6 -0
- package/dist/utils/line-editor.d.ts +11 -1
- package/dist/utils/line-editor.js +44 -5
- package/dist/utils/tool-display.d.ts +1 -1
- package/dist/utils/tool-display.js +4 -4
- package/examples/extensions/ash-acp-bridge/src/index.ts +4 -1
- package/examples/extensions/ash-mcp-bridge/index.ts +13 -3
- package/examples/extensions/claude-code-bridge/index.ts +198 -51
- package/examples/extensions/claude-code-bridge/package.json +1 -0
- package/examples/extensions/interactive-prompts.ts +39 -25
- package/examples/extensions/overlay-agent.ts +3 -3
- package/examples/extensions/peer-mesh.ts +115 -0
- package/examples/extensions/pi-bridge/index.ts +2 -2
- package/examples/extensions/questionnaire.ts +16 -5
- package/examples/extensions/subagents.ts +19 -4
- package/examples/extensions/terminal-buffer.ts +163 -0
- package/examples/extensions/user-shell.ts +136 -0
- package/examples/extensions/web-access.ts +8 -0
- package/package.json +36 -2
- package/dist/agent/tools/display.d.ts +0 -13
- package/dist/agent/tools/display.js +0 -70
- package/dist/agent/tools/user-shell.d.ts +0 -13
- package/dist/agent/tools/user-shell.js +0 -87
- package/dist/extensions/terminal-buffer.d.ts +0 -14
- 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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
// ── Shared state ──────────────────────────────────────────────
|
|
54
|
+
instanceId;
|
|
55
|
+
handlers;
|
|
12
56
|
nextSeq = 1;
|
|
13
|
-
|
|
14
|
-
|
|
57
|
+
lastApiTokenCount = null;
|
|
58
|
+
lastApiMessageCount = 0;
|
|
59
|
+
constructor(handlers, instanceId = "0000") {
|
|
60
|
+
this.handlers = handlers ?? null;
|
|
61
|
+
this.instanceId = instanceId;
|
|
15
62
|
}
|
|
16
|
-
|
|
17
|
-
|
|
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 (
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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(
|
|
202
|
+
return Math.ceil(this.getMessagesJson().length / 4);
|
|
59
203
|
}
|
|
60
|
-
// ──
|
|
204
|
+
// ── Compaction (uses pre-computed nuclear entries) ─────────────
|
|
61
205
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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(
|
|
67
|
-
const
|
|
68
|
-
|
|
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
|
-
//
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
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 -
|
|
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) =>
|
|
87
|
-
|
|
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 =
|
|
251
|
+
let currentTokens = convEstimate;
|
|
90
252
|
for (const c of candidates) {
|
|
91
|
-
if (currentTokens <=
|
|
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
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
584
|
+
lines.push(`[tool_result] ${content}`);
|
|
378
585
|
}
|
|
379
586
|
}
|
|
380
587
|
return lines.join("\n");
|