agent-sh 0.7.0 → 0.8.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.
Files changed (41) hide show
  1. package/README.md +5 -1
  2. package/dist/agent/agent-loop.d.ts +2 -2
  3. package/dist/agent/agent-loop.js +106 -13
  4. package/dist/agent/conversation-state.d.ts +39 -9
  5. package/dist/agent/conversation-state.js +336 -17
  6. package/dist/agent/history-file.d.ts +36 -0
  7. package/dist/agent/history-file.js +167 -0
  8. package/dist/agent/nuclear-form.d.ts +41 -0
  9. package/dist/agent/nuclear-form.js +175 -0
  10. package/dist/agent/system-prompt.d.ts +2 -2
  11. package/dist/agent/system-prompt.js +25 -4
  12. package/dist/agent/tools/user-shell.js +4 -1
  13. package/dist/context-manager.d.ts +0 -1
  14. package/dist/context-manager.js +5 -110
  15. package/dist/core.js +14 -0
  16. package/dist/event-bus.d.ts +14 -0
  17. package/dist/extensions/overlay-agent.d.ts +4 -1
  18. package/dist/extensions/overlay-agent.js +115 -11
  19. package/dist/extensions/slash-commands.js +28 -0
  20. package/dist/extensions/terminal-buffer.js +9 -4
  21. package/dist/extensions/tui-renderer.js +119 -84
  22. package/dist/settings.d.ts +19 -2
  23. package/dist/settings.js +21 -3
  24. package/dist/shell.js +4 -0
  25. package/dist/token-budget.d.ts +13 -0
  26. package/dist/token-budget.js +50 -0
  27. package/dist/types.d.ts +0 -22
  28. package/dist/utils/ansi.d.ts +10 -0
  29. package/dist/utils/ansi.js +27 -0
  30. package/dist/utils/floating-panel.d.ts +32 -3
  31. package/dist/utils/floating-panel.js +296 -79
  32. package/dist/utils/line-editor.d.ts +9 -0
  33. package/dist/utils/line-editor.js +44 -0
  34. package/dist/utils/markdown.js +3 -3
  35. package/dist/utils/terminal-buffer.d.ts +4 -0
  36. package/dist/utils/terminal-buffer.js +13 -0
  37. package/dist/utils/tool-display.d.ts +1 -0
  38. package/dist/utils/tool-display.js +1 -1
  39. package/examples/extensions/claude-code-bridge/index.ts +77 -1
  40. package/examples/extensions/pi-bridge/index.ts +87 -2
  41. package/package.json +1 -1
@@ -1,10 +1,22 @@
1
- /**
2
- * Manages the OpenAI chat messages array for the agent loop.
3
- * Separate from ContextManager — this is the LLM conversation,
4
- * not the shell history.
5
- */
1
+ import { getSettings } from "../settings.js";
2
+ import { toNuclearEntries, formatNuclearLine, isReadOnly, READ_ONLY_TOOLS, WRITE_TOOLS, } from "./nuclear-form.js";
6
3
  export class ConversationState {
4
+ // ── Tier 1: Active context ────────────────────────────────────
7
5
  messages = [];
6
+ // ── Tier 2: Nuclear memory ────────────────────────────────────
7
+ nuclearEntries = [];
8
+ recallArchive = new Map();
9
+ // ── Tier 3 reference ──────────────────────────────────────────
10
+ historyFile;
11
+ // ── Shared state ──────────────────────────────────────────────
12
+ nextSeq = 1;
13
+ constructor(historyFile) {
14
+ this.historyFile = historyFile ?? null;
15
+ }
16
+ get instanceId() {
17
+ return this.historyFile?.instanceId ?? "0000";
18
+ }
19
+ // ── Message API (unchanged) ───────────────────────────────────
8
20
  addUserMessage(text) {
9
21
  this.messages.push({ role: "user", content: text });
10
22
  }
@@ -31,29 +43,336 @@ export class ConversationState {
31
43
  content,
32
44
  });
33
45
  }
34
- /** Inject a system-level note into the conversation (e.g. context change). */
35
46
  addSystemNote(text) {
36
47
  this.messages.push({ role: "user", content: text });
37
48
  }
38
49
  getMessages() {
39
50
  return this.messages;
40
51
  }
52
+ // ── Token estimation ──────────────────────────────────────────
53
+ estimateTokens() {
54
+ return Math.ceil(JSON.stringify(this.messages).length / 4);
55
+ }
56
+ // ── Tier 1 → Tier 2: Compaction ───────────────────────────────
57
+ /**
58
+ * Priority-based compaction. Evicts lowest-priority turns, replacing
59
+ * them with nuclear one-liner summaries that stay in the conversation.
60
+ * Read-only tool results are dropped entirely.
61
+ */
62
+ compact(targetTokens, recentTurnsToKeep = 10) {
63
+ const before = this.estimateTokens();
64
+ if (before <= targetTokens)
65
+ return null;
66
+ const turns = this.parseTurns();
67
+ if (turns.length <= 2)
68
+ return null;
69
+ // Assign priorities
70
+ const pinnedCount = Math.min(recentTurnsToKeep, turns.length - 1);
71
+ for (const turn of turns) {
72
+ turn.priority = this.inferPriority(turn.messages);
73
+ }
74
+ turns[0].priority = 4 /* Priority.PINNED */;
75
+ for (let i = turns.length - pinnedCount; i < turns.length; i++) {
76
+ turns[i].priority = 4 /* Priority.PINNED */;
77
+ }
78
+ // Sort candidates: lowest priority first, then oldest
79
+ const candidates = turns
80
+ .map((t, idx) => ({ turn: t, idx }))
81
+ .filter((c) => c.turn.priority !== 4 /* Priority.PINNED */)
82
+ .sort((a, b) => a.turn.priority - b.turn.priority || a.idx - b.idx);
83
+ // Evict until under budget
84
+ const evictedIndices = new Set();
85
+ let currentTokens = this.estimateTokens();
86
+ for (const c of candidates) {
87
+ if (currentTokens <= targetTokens)
88
+ break;
89
+ const turnTokens = Math.ceil(JSON.stringify(c.turn.messages).length / 4);
90
+ evictedIndices.add(c.idx);
91
+ currentTokens -= turnTokens;
92
+ // Generate nuclear entries from this turn
93
+ const entries = toNuclearEntries(c.turn.messages, this.nextSeq, this.instanceId);
94
+ this.nextSeq += entries.length;
95
+ for (const entry of entries) {
96
+ if (isReadOnly(entry)) {
97
+ // Read-only: archive only (dropped from conversation), agent can re-read
98
+ this.recallArchive.set(entry.seq, c.turn.messages);
99
+ }
100
+ else {
101
+ // State-changing: keep nuclear one-liner in conversation + archive
102
+ this.nuclearEntries.push(entry);
103
+ this.recallArchive.set(entry.seq, c.turn.messages);
104
+ }
105
+ }
106
+ }
107
+ if (evictedIndices.size === 0)
108
+ return null;
109
+ // Rebuild: first turn + nuclear summary block + remaining turns
110
+ const rebuilt = [];
111
+ let insertedNuclearBlock = false;
112
+ for (let i = 0; i < turns.length; i++) {
113
+ if (evictedIndices.has(i)) {
114
+ if (!insertedNuclearBlock) {
115
+ rebuilt.push(this.buildNuclearBlock());
116
+ insertedNuclearBlock = true;
117
+ }
118
+ }
119
+ else {
120
+ rebuilt.push(...turns[i].messages);
121
+ }
122
+ }
123
+ // If no nuclear block was inserted but we have entries from prior compactions,
124
+ // update the existing nuclear block
125
+ if (!insertedNuclearBlock && this.nuclearEntries.length > 0) {
126
+ this.updateNuclearBlockInMessages(rebuilt);
127
+ }
128
+ this.messages = rebuilt;
129
+ return { before, after: this.estimateTokens() };
130
+ }
131
+ // ── Tier 2 → Tier 3: Flush ───────────────────────────────────
41
132
  /**
42
- * Simple compaction drop oldest turns, keeping the first user message
43
- * (original task context) and the most recent turns.
133
+ * Flush oldest nuclear entries to the history file when the
134
+ * in-context nuclear block grows too large.
44
135
  */
45
- compact(maxTurns) {
46
- if (this.messages.length <= maxTurns * 2)
136
+ async flush() {
137
+ const maxEntries = getSettings().nuclearMaxEntries;
138
+ if (this.nuclearEntries.length <= maxEntries)
47
139
  return;
48
- const first = this.messages[0];
49
- const recent = this.messages.slice(-(maxTurns * 2));
50
- this.messages = [
51
- first,
52
- { role: "user", content: "[Earlier conversation turns omitted for context space]" },
53
- ...recent,
54
- ];
140
+ const flushCount = this.nuclearEntries.length - maxEntries;
141
+ const toFlush = this.nuclearEntries.slice(0, flushCount);
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);
153
+ }
154
+ // ── Startup: Load prior history ───────────────────────────────
155
+ /**
156
+ * Inject prior session history from the history file as a context note.
157
+ */
158
+ loadPriorHistory(entries) {
159
+ if (entries.length === 0)
160
+ return;
161
+ // Update nextSeq to avoid collisions
162
+ const maxSeq = Math.max(...entries.map((e) => e.seq));
163
+ if (maxSeq >= this.nextSeq)
164
+ this.nextSeq = maxSeq + 1;
165
+ const lines = entries.map(formatNuclearLine);
166
+ this.messages.push({
167
+ role: "user",
168
+ content: `[Prior session history — loaded from ~/.agent-sh/history]\n${lines.join("\n")}`,
169
+ });
170
+ }
171
+ // ── Conversation recall ───────────────────────────────────────
172
+ /** Search Tier 2 archive + Tier 3 history file. */
173
+ async search(query) {
174
+ if (!query.trim())
175
+ return "No query provided.";
176
+ const parts = [];
177
+ // Search Tier 2 (in-memory archive)
178
+ const archiveResults = this.searchArchive(query);
179
+ if (archiveResults)
180
+ parts.push(archiveResults);
181
+ // Search Tier 3 (history file)
182
+ if (this.historyFile) {
183
+ const fileResults = await this.historyFile.search(query);
184
+ if (fileResults.length > 0) {
185
+ parts.push(`History file matches (${fileResults.length}):`);
186
+ for (const r of fileResults.slice(0, 20)) {
187
+ parts.push(` ${r.line}`);
188
+ }
189
+ }
190
+ }
191
+ if (parts.length === 0)
192
+ return `No results found for "${query}".`;
193
+ return parts.join("\n\n");
194
+ }
195
+ /** Expand full content of a nuclear entry by seq number. */
196
+ async expand(seq) {
197
+ // Check Tier 2 archive first
198
+ const archived = this.recallArchive.get(seq);
199
+ if (archived) {
200
+ const entry = this.nuclearEntries.find((e) => e.seq === seq);
201
+ const header = entry ? formatNuclearLine(entry) : `#${seq}`;
202
+ return `${header}\n\n${this.turnToText(archived)}`;
203
+ }
204
+ return `Entry #${seq}: not found in recall archive (may have been flushed to history file).`;
205
+ }
206
+ /** Browse nuclear entries (Tier 2) + recent history (Tier 3). */
207
+ async browse() {
208
+ const parts = [];
209
+ if (this.nuclearEntries.length > 0) {
210
+ parts.push("In-context nuclear entries:");
211
+ for (const e of this.nuclearEntries) {
212
+ parts.push(` ${formatNuclearLine(e)}`);
213
+ }
214
+ }
215
+ if (this.historyFile) {
216
+ const recent = await this.historyFile.readRecent(25);
217
+ if (recent.length > 0) {
218
+ parts.push("\nRecent history file entries:");
219
+ for (const e of recent) {
220
+ parts.push(` ${formatNuclearLine(e)}`);
221
+ }
222
+ }
223
+ }
224
+ if (parts.length === 0)
225
+ return "No conversation history.";
226
+ return parts.join("\n");
227
+ }
228
+ // ── Stats ─────────────────────────────────────────────────────
229
+ getNuclearEntryCount() {
230
+ return this.nuclearEntries.length;
231
+ }
232
+ getRecallArchiveSize() {
233
+ return this.recallArchive.size;
55
234
  }
235
+ // ── Clear ─────────────────────────────────────────────────────
56
236
  clear() {
57
237
  this.messages = [];
238
+ this.nuclearEntries = [];
239
+ this.recallArchive.clear();
240
+ }
241
+ // ── Internal: Nuclear block management ────────────────────────
242
+ buildNuclearBlock() {
243
+ const lines = this.nuclearEntries.map(formatNuclearLine);
244
+ return {
245
+ role: "user",
246
+ content: `[Conversation history — use conversation_recall to expand any entry]\n${lines.join("\n")}`,
247
+ };
248
+ }
249
+ updateNuclearBlockInMessages(messages) {
250
+ if (this.nuclearEntries.length === 0)
251
+ return;
252
+ const marker = "[Conversation history — use conversation_recall";
253
+ for (let i = 0; i < messages.length; i++) {
254
+ const msg = messages[i];
255
+ if (msg.role === "user" && typeof msg.content === "string" && msg.content.startsWith(marker)) {
256
+ messages[i] = this.buildNuclearBlock();
257
+ return;
258
+ }
259
+ }
260
+ // No existing block found — insert after the first turn
261
+ if (messages.length > 0) {
262
+ // Find end of first turn (next user message or end)
263
+ let insertIdx = 1;
264
+ for (let i = 1; i < messages.length; i++) {
265
+ if (messages[i].role === "user") {
266
+ insertIdx = i;
267
+ break;
268
+ }
269
+ insertIdx = i + 1;
270
+ }
271
+ messages.splice(insertIdx, 0, this.buildNuclearBlock());
272
+ }
273
+ }
274
+ // ── Internal: Turn parsing and priority ───────────────────────
275
+ parseTurns() {
276
+ const turns = [];
277
+ let current = [];
278
+ for (const msg of this.messages) {
279
+ if (msg.role === "user" && current.length > 0) {
280
+ turns.push({ messages: current, priority: 2 /* Priority.MEDIUM */ });
281
+ current = [];
282
+ }
283
+ current.push(msg);
284
+ }
285
+ if (current.length > 0) {
286
+ turns.push({ messages: current, priority: 2 /* Priority.MEDIUM */ });
287
+ }
288
+ return turns;
289
+ }
290
+ inferPriority(messages) {
291
+ let hasError = false;
292
+ let hasWriteTool = false;
293
+ let allReadOnly = true;
294
+ let hasToolResult = false;
295
+ for (const msg of messages) {
296
+ if (msg.role === "user")
297
+ return 3 /* Priority.HIGH */;
298
+ if (msg.role === "tool") {
299
+ hasToolResult = true;
300
+ const content = typeof msg.content === "string" ? msg.content : "";
301
+ if (content.startsWith("Error:") || content.includes("error")) {
302
+ hasError = true;
303
+ }
304
+ }
305
+ if (msg.role === "assistant" && "tool_calls" in msg && msg.tool_calls) {
306
+ for (const tc of msg.tool_calls) {
307
+ const fn = "function" in tc ? tc.function : undefined;
308
+ if (!fn)
309
+ continue;
310
+ const name = fn.name;
311
+ if (WRITE_TOOLS.has(name))
312
+ hasWriteTool = true;
313
+ if (!READ_ONLY_TOOLS.has(name))
314
+ allReadOnly = false;
315
+ }
316
+ }
317
+ }
318
+ if (hasError)
319
+ return 3 /* Priority.HIGH */;
320
+ if (hasWriteTool)
321
+ return 2 /* Priority.MEDIUM */;
322
+ if (hasToolResult && allReadOnly)
323
+ return 0 /* Priority.LOWEST */;
324
+ if (hasToolResult)
325
+ return 1 /* Priority.LOW */;
326
+ return 2 /* Priority.MEDIUM */;
327
+ }
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
+ turnToText(messages) {
354
+ const lines = [];
355
+ for (const msg of messages) {
356
+ if (msg.role === "user") {
357
+ lines.push(`[user] ${typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content)}`);
358
+ }
359
+ else if (msg.role === "assistant") {
360
+ if (typeof msg.content === "string" && msg.content) {
361
+ lines.push(`[assistant] ${msg.content}`);
362
+ }
363
+ if ("tool_calls" in msg && msg.tool_calls) {
364
+ for (const tc of msg.tool_calls) {
365
+ if ("function" in tc) {
366
+ lines.push(`[tool_call] ${tc.function.name}(${tc.function.arguments.slice(0, 200)})`);
367
+ }
368
+ }
369
+ }
370
+ }
371
+ else if (msg.role === "tool") {
372
+ const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
373
+ lines.push(`[tool_result] ${content.slice(0, 500)}`);
374
+ }
375
+ }
376
+ return lines.join("\n");
58
377
  }
59
378
  }
@@ -0,0 +1,36 @@
1
+ import { type NuclearEntry } from "./nuclear-form.js";
2
+ export declare class HistoryFile {
3
+ readonly instanceId: string;
4
+ private filePath;
5
+ constructor(opts?: {
6
+ filePath?: string;
7
+ instanceId?: string;
8
+ });
9
+ /**
10
+ * Append entries atomically. Uses O_APPEND for concurrency safety.
11
+ * Triggers truncation check after writing.
12
+ */
13
+ append(entries: NuclearEntry[]): Promise<void>;
14
+ /**
15
+ * Read the most recent N entries from the history file.
16
+ */
17
+ readRecent(maxEntries?: number): Promise<NuclearEntry[]>;
18
+ /**
19
+ * Search history entries by regex/keyword.
20
+ */
21
+ search(query: string): Promise<{
22
+ entry: NuclearEntry;
23
+ line: string;
24
+ }[]>;
25
+ /**
26
+ * Get file size in bytes. Returns 0 if file doesn't exist.
27
+ */
28
+ getSize(): Promise<number>;
29
+ /**
30
+ * Truncate from the front if file exceeds historyMaxBytes.
31
+ * Uses a lock file for the rewrite operation.
32
+ */
33
+ private maybeTruncate;
34
+ private acquireLock;
35
+ private releaseLock;
36
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Persistent history file — Tier 3 of the three-tier history system.
3
+ *
4
+ * Append-only JSONL file at ~/.agent-sh/history. Multiple agent-sh
5
+ * instances can write concurrently — each line is under PIPE_BUF so
6
+ * O_APPEND writes are atomic. Only truncation (which rewrites the file)
7
+ * uses a lock file for safety.
8
+ */
9
+ import * as fs from "node:fs/promises";
10
+ import * as fss from "node:fs";
11
+ import * as path from "node:path";
12
+ import * as crypto from "node:crypto";
13
+ import { CONFIG_DIR, getSettings } from "../settings.js";
14
+ import { serializeEntry, deserializeEntry, formatNuclearLine, } from "./nuclear-form.js";
15
+ const HISTORY_PATH = path.join(CONFIG_DIR, "history");
16
+ const LOCK_PATH = HISTORY_PATH + ".lock";
17
+ const LOCK_STALE_MS = 10_000; // consider lock stale after 10s
18
+ export class HistoryFile {
19
+ instanceId;
20
+ filePath;
21
+ constructor(opts) {
22
+ this.filePath = opts?.filePath ?? HISTORY_PATH;
23
+ this.instanceId = opts?.instanceId ?? crypto.randomBytes(2).toString("hex");
24
+ }
25
+ /**
26
+ * Append entries atomically. Uses O_APPEND for concurrency safety.
27
+ * Triggers truncation check after writing.
28
+ */
29
+ async append(entries) {
30
+ if (entries.length === 0)
31
+ return;
32
+ const lines = entries.map((e) => serializeEntry(e) + "\n").join("");
33
+ await fs.appendFile(this.filePath, lines, { flag: "a" });
34
+ await this.maybeTruncate();
35
+ }
36
+ /**
37
+ * Read the most recent N entries from the history file.
38
+ */
39
+ async readRecent(maxEntries) {
40
+ maxEntries ??= getSettings().historyStartupEntries;
41
+ let content;
42
+ try {
43
+ content = await fs.readFile(this.filePath, "utf-8");
44
+ }
45
+ catch {
46
+ return [];
47
+ }
48
+ const lines = content.trim().split("\n").filter(Boolean);
49
+ const recent = lines.slice(-maxEntries);
50
+ const entries = [];
51
+ for (const line of recent) {
52
+ const entry = deserializeEntry(line);
53
+ if (entry)
54
+ entries.push(entry);
55
+ }
56
+ return entries;
57
+ }
58
+ /**
59
+ * Search history entries by regex/keyword.
60
+ */
61
+ async search(query) {
62
+ if (!query.trim())
63
+ return [];
64
+ let regex;
65
+ try {
66
+ regex = new RegExp(query, "i");
67
+ }
68
+ catch {
69
+ const words = query.split(/\s+/).filter((w) => w.length > 0);
70
+ const pattern = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
71
+ regex = new RegExp(pattern, "i");
72
+ }
73
+ let content;
74
+ try {
75
+ content = await fs.readFile(this.filePath, "utf-8");
76
+ }
77
+ catch {
78
+ return [];
79
+ }
80
+ const results = [];
81
+ for (const line of content.trim().split("\n")) {
82
+ const entry = deserializeEntry(line);
83
+ if (entry && regex.test(entry.sum)) {
84
+ results.push({ entry, line: formatNuclearLine(entry) });
85
+ }
86
+ }
87
+ return results;
88
+ }
89
+ /**
90
+ * Get file size in bytes. Returns 0 if file doesn't exist.
91
+ */
92
+ async getSize() {
93
+ try {
94
+ const stat = await fs.stat(this.filePath);
95
+ return stat.size;
96
+ }
97
+ catch {
98
+ return 0;
99
+ }
100
+ }
101
+ // ── Truncation ──────────────────────────────────────────────────
102
+ /**
103
+ * Truncate from the front if file exceeds historyMaxBytes.
104
+ * Uses a lock file for the rewrite operation.
105
+ */
106
+ async maybeTruncate() {
107
+ const maxBytes = getSettings().historyMaxBytes;
108
+ const size = await this.getSize();
109
+ // Only truncate when significantly over (150%) to avoid frequent rewrites
110
+ if (size <= maxBytes * 1.5)
111
+ return;
112
+ const acquired = await this.acquireLock();
113
+ if (!acquired)
114
+ return; // another process is truncating
115
+ try {
116
+ let content;
117
+ try {
118
+ content = await fs.readFile(this.filePath, "utf-8");
119
+ }
120
+ catch {
121
+ return;
122
+ }
123
+ const lines = content.split("\n").filter(Boolean);
124
+ // Drop oldest lines until under maxBytes
125
+ let totalBytes = Buffer.byteLength(content, "utf-8");
126
+ let dropCount = 0;
127
+ while (totalBytes > maxBytes && dropCount < lines.length - 1) {
128
+ totalBytes -= Buffer.byteLength(lines[dropCount] + "\n", "utf-8");
129
+ dropCount++;
130
+ }
131
+ if (dropCount === 0)
132
+ return;
133
+ const remaining = lines.slice(dropCount).join("\n") + "\n";
134
+ // Atomic rewrite: write temp → rename
135
+ const tmpPath = this.filePath + ".tmp." + process.pid;
136
+ await fs.writeFile(tmpPath, remaining);
137
+ await fs.rename(tmpPath, this.filePath);
138
+ }
139
+ finally {
140
+ await this.releaseLock();
141
+ }
142
+ }
143
+ async acquireLock() {
144
+ try {
145
+ // Check for stale lock
146
+ try {
147
+ const stat = await fs.stat(LOCK_PATH);
148
+ if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
149
+ await fs.unlink(LOCK_PATH).catch(() => { });
150
+ }
151
+ }
152
+ catch {
153
+ // Lock doesn't exist — good
154
+ }
155
+ // O_EXCL ensures atomicity
156
+ const fd = await fs.open(LOCK_PATH, fss.constants.O_CREAT | fss.constants.O_EXCL | fss.constants.O_WRONLY);
157
+ await fd.close();
158
+ return true;
159
+ }
160
+ catch {
161
+ return false; // lock held by another process
162
+ }
163
+ }
164
+ async releaseLock() {
165
+ await fs.unlink(LOCK_PATH).catch(() => { });
166
+ }
167
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Nuclear form — compact one-liner summaries of conversation actions.
3
+ *
4
+ * Used by the three-tier history system:
5
+ * Tier 1 (full content) → compacts into → Tier 2 (nuclear one-liners)
6
+ * Tier 2 → flushes to → Tier 3 (history file on disk)
7
+ *
8
+ * Nuclear entries are the currency of Tier 2 and Tier 3.
9
+ */
10
+ import type { ChatCompletionMessageParam } from "../utils/llm-client.js";
11
+ export interface NuclearEntry {
12
+ /** Global sequence number. */
13
+ seq: number;
14
+ /** Timestamp (Date.now()). */
15
+ ts: number;
16
+ /** Instance ID — 4-char hex identifying the agent-sh process. */
17
+ iid: string;
18
+ /** Entry kind. */
19
+ kind: "user" | "agent" | "tool" | "error";
20
+ /** Tool name (for kind=tool or kind=error). */
21
+ tool?: string;
22
+ /** The one-liner summary. */
23
+ sum: string;
24
+ }
25
+ /** Read-only tools whose results are dropped at Tier 1→2 (agent can re-read). */
26
+ export declare const READ_ONLY_TOOLS: Set<string>;
27
+ /** State-changing tools whose summaries are kept in nuclear memory. */
28
+ export declare const WRITE_TOOLS: Set<string>;
29
+ /**
30
+ * Generate nuclear entries from a logical turn (a sequence of messages
31
+ * starting with a user message, followed by assistant + tool messages).
32
+ */
33
+ export declare function toNuclearEntries(messages: ChatCompletionMessageParam[], startSeq: number, instanceId: string): NuclearEntry[];
34
+ /** Format a nuclear entry as a display line (for in-context injection). */
35
+ export declare function formatNuclearLine(entry: NuclearEntry): string;
36
+ /** Serialize a nuclear entry to a JSONL line. */
37
+ export declare function serializeEntry(entry: NuclearEntry): string;
38
+ /** Deserialize a JSONL line to a nuclear entry. Returns null on parse failure. */
39
+ export declare function deserializeEntry(line: string): NuclearEntry | null;
40
+ /** Check if a nuclear entry represents a read-only action (should be dropped). */
41
+ export declare function isReadOnly(entry: NuclearEntry): boolean;