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