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.
- package/README.md +25 -30
- package/dist/agent/agent-loop.d.ts +43 -6
- package/dist/agent/agent-loop.js +817 -157
- package/dist/agent/conversation-state.d.ts +72 -21
- package/dist/agent/conversation-state.js +364 -151
- 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 +10 -13
- package/dist/agent/token-budget.js +6 -46
- 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 -2
- package/dist/context-manager.d.ts +16 -19
- package/dist/context-manager.js +48 -152
- package/dist/core.js +27 -6
- package/dist/event-bus.d.ts +59 -3
- package/dist/executor.d.ts +4 -3
- package/dist/executor.js +18 -15
- package/dist/extension-loader.js +75 -17
- package/dist/extensions/agent-backend.d.ts +8 -7
- package/dist/extensions/agent-backend.js +72 -50
- package/dist/extensions/index.js +0 -2
- package/dist/extensions/slash-commands.js +14 -9
- package/dist/extensions/tui-renderer.js +67 -80
- package/dist/index.js +25 -6
- package/dist/settings.d.ts +39 -16
- package/dist/settings.js +51 -11
- package/dist/shell/input-handler.d.ts +2 -1
- package/dist/shell/input-handler.js +84 -76
- package/dist/shell/shell.js +19 -2
- package/dist/types.d.ts +15 -0
- package/dist/utils/ansi.d.ts +7 -0
- package/dist/utils/ansi.js +69 -8
- package/dist/utils/box-frame.js +8 -2
- 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/markdown.js +23 -8
- package/dist/utils/package-version.d.ts +1 -0
- package/dist/utils/package-version.js +10 -0
- package/dist/utils/shell-output-spill.d.ts +2 -0
- package/dist/utils/shell-output-spill.js +81 -0
- 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/README.md +14 -0
- package/examples/extensions/claude-code-bridge/index.ts +204 -145
- 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/README.md +16 -0
- package/examples/extensions/pi-bridge/index.ts +9 -155
- 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/shell-recall.d.ts +0 -9
- package/dist/extensions/shell-recall.js +0 -8
- 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,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
|
-
|
|
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
|
+
// 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
|
-
//
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
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 -
|
|
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) =>
|
|
87
|
-
|
|
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 =
|
|
257
|
+
let currentTokens = convEstimate;
|
|
90
258
|
for (const c of candidates) {
|
|
91
|
-
if (currentTokens <=
|
|
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
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
590
|
+
lines.push(`[tool_result] ${content}`);
|
|
378
591
|
}
|
|
379
592
|
}
|
|
380
593
|
return lines.join("\n");
|