agent-sh 0.14.8 → 0.14.10
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/dist/agent/agent-loop.d.ts +0 -4
- package/dist/agent/agent-loop.js +8 -166
- package/dist/agent/entry-format.d.ts +5 -0
- package/dist/agent/entry-format.js +9 -0
- package/dist/agent/extensions/rolling-history/constants.d.ts +1 -0
- package/dist/agent/extensions/rolling-history/constants.js +1 -0
- package/dist/agent/extensions/rolling-history/index.d.ts +4 -0
- package/dist/agent/extensions/rolling-history/index.js +203 -0
- package/dist/agent/extensions/rolling-history/recall.d.ts +4 -0
- package/dist/agent/extensions/rolling-history/recall.js +122 -0
- package/dist/agent/extensions/rolling-history/strategy.d.ts +70 -0
- package/dist/agent/extensions/rolling-history/strategy.js +336 -0
- package/dist/agent/host-types.d.ts +0 -3
- package/dist/agent/index.js +50 -5
- package/dist/agent/live-view.d.ts +57 -0
- package/dist/agent/live-view.js +238 -0
- package/dist/agent/llm-client.d.ts +1 -0
- package/dist/agent/llm-client.js +1 -1
- package/dist/agent/providers/ollama.d.ts +11 -0
- package/dist/agent/providers/ollama.js +72 -0
- package/dist/agent/providers/opencode.d.ts +10 -0
- package/dist/agent/providers/opencode.js +112 -0
- package/dist/agent/providers/zai-coding-plan.d.ts +5 -0
- package/dist/agent/providers/zai-coding-plan.js +26 -0
- package/dist/agent/session-store.d.ts +90 -0
- package/dist/agent/session-store.js +288 -0
- package/dist/agent/store.d.ts +74 -0
- package/dist/agent/store.js +284 -0
- package/dist/agent/subagent.js +2 -2
- package/dist/agent/tool-protocol.d.ts +11 -11
- package/dist/cli/args.js +2 -2
- package/dist/cli/index.js +4 -2
- package/dist/core/index.d.ts +0 -1
- package/dist/core/index.js +0 -1
- package/dist/core/settings.d.ts +5 -1
- package/dist/core/settings.js +62 -1
- package/dist/extensions/index.d.ts +1 -0
- package/dist/shell/events.d.ts +1 -0
- package/dist/shell/input-handler.js +4 -0
- package/dist/shell/tui-renderer.js +5 -2
- package/dist/utils/diff-renderer.js +9 -7
- package/examples/extensions/ads/index.ts +695 -0
- package/examples/extensions/ash-acp-bridge/src/index.ts +1 -2
- package/examples/extensions/ash-scheme/index.ts +77 -3
- package/examples/extensions/ashi/package.json +2 -2
- package/examples/extensions/ashi/src/capture.ts +1 -1
- package/examples/extensions/ashi/src/cli.ts +5 -6
- package/examples/extensions/ashi/src/compaction.ts +6 -2
- package/examples/extensions/ashi/src/frontend.ts +13 -13
- package/examples/extensions/ashi/src/multi-session-store.ts +35 -12
- package/examples/extensions/ashi/src/session-commands.ts +1 -1
- package/examples/extensions/ashi/src/user-shell-intents.ts +17 -0
- package/package.json +13 -1
- package/dist/agent/conversation-state.d.ts +0 -142
- package/dist/agent/conversation-state.js +0 -788
- package/dist/agent/history-file.d.ts +0 -81
- package/dist/agent/history-file.js +0 -271
- package/examples/extensions/ashi/src/session-store.ts +0 -363
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as fsp from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as crypto from "node:crypto";
|
|
5
|
+
export function newEntryId() {
|
|
6
|
+
return crypto.randomBytes(4).toString("hex");
|
|
7
|
+
}
|
|
8
|
+
function extractText(content) {
|
|
9
|
+
if (typeof content === "string")
|
|
10
|
+
return content;
|
|
11
|
+
if (Array.isArray(content)) {
|
|
12
|
+
return content.map((p) => {
|
|
13
|
+
if (typeof p === "string")
|
|
14
|
+
return p;
|
|
15
|
+
const part = p;
|
|
16
|
+
return part?.text ?? part?.content ?? "";
|
|
17
|
+
}).join(" ");
|
|
18
|
+
}
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
function snippet(text, max) {
|
|
22
|
+
const cleaned = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
23
|
+
if (cleaned.length <= max)
|
|
24
|
+
return cleaned || "(empty)";
|
|
25
|
+
return cleaned.slice(0, max) + "…";
|
|
26
|
+
}
|
|
27
|
+
export function summarizeMessage(m) {
|
|
28
|
+
const role = m.role ?? "?";
|
|
29
|
+
if (role === "assistant") {
|
|
30
|
+
const tc = m.tool_calls;
|
|
31
|
+
if (Array.isArray(tc) && tc.length > 0) {
|
|
32
|
+
const tools = tc.map((t) => {
|
|
33
|
+
const name = t.function?.name ?? "tool";
|
|
34
|
+
const args = t.function?.arguments;
|
|
35
|
+
return args ? `${name}(${snippet(args, 200)})` : name;
|
|
36
|
+
}).join(", ");
|
|
37
|
+
const text = extractText(m.content);
|
|
38
|
+
const prefix = text ? `${snippet(text, 400)} → ` : "";
|
|
39
|
+
return `assistant: ${prefix}called ${tools}`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (role === "tool") {
|
|
43
|
+
const text = typeof m.content === "string" ? m.content : extractText(m.content);
|
|
44
|
+
const isErr = /^error\b|: error\b/i.test(text.slice(0, 200));
|
|
45
|
+
return `tool result: ${snippet(text, isErr ? 1000 : 400)}`;
|
|
46
|
+
}
|
|
47
|
+
if (role === "user") {
|
|
48
|
+
return `user: ${snippet(extractText(m.content), 1000)}`;
|
|
49
|
+
}
|
|
50
|
+
return `${role}: ${snippet(extractText(m.content), 500)}`;
|
|
51
|
+
}
|
|
52
|
+
/** For displayed user text. Loops because both wrappers can stack at the head. */
|
|
53
|
+
export function stripContextWrappers(content) {
|
|
54
|
+
let out = content;
|
|
55
|
+
for (;;) {
|
|
56
|
+
const next = out.replace(/^\s*<(query_context|dynamic_context)>[\s\S]*?<\/\1>\s*/, "");
|
|
57
|
+
if (next === out)
|
|
58
|
+
return out;
|
|
59
|
+
out = next;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function renderEvictedSummary(evicted) {
|
|
63
|
+
const lines = evicted.map((m) => `- ${summarizeMessage(m)}`);
|
|
64
|
+
return `${lines.length} message(s) elided\n${lines.join("\n")}`;
|
|
65
|
+
}
|
|
66
|
+
export class SessionStore {
|
|
67
|
+
entriesPath;
|
|
68
|
+
leafPath;
|
|
69
|
+
entries = new Map();
|
|
70
|
+
rootId = "";
|
|
71
|
+
activeLeaf = "";
|
|
72
|
+
pendingHeader = null;
|
|
73
|
+
id;
|
|
74
|
+
constructor(filePath, opts) {
|
|
75
|
+
this.entriesPath = filePath;
|
|
76
|
+
this.leafPath = filePath + ".leaf";
|
|
77
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
78
|
+
if (opts?.create) {
|
|
79
|
+
this.id = opts.create.sessionId;
|
|
80
|
+
const header = {
|
|
81
|
+
type: "session",
|
|
82
|
+
id: opts.create.sessionId,
|
|
83
|
+
parentId: null,
|
|
84
|
+
timestamp: Date.now(),
|
|
85
|
+
cwd: opts.create.cwd,
|
|
86
|
+
version: 1,
|
|
87
|
+
};
|
|
88
|
+
this.entries.set(header.id, header);
|
|
89
|
+
this.rootId = header.id;
|
|
90
|
+
this.activeLeaf = header.id;
|
|
91
|
+
this.pendingHeader = header;
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
this.id = "";
|
|
95
|
+
this.load();
|
|
96
|
+
if (!this.rootId)
|
|
97
|
+
throw new Error(`session file lacks a session header: ${filePath}`);
|
|
98
|
+
this.id = this.rootId;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Deferred so an opened-but-unused session leaves no files on disk. */
|
|
102
|
+
flushHeader() {
|
|
103
|
+
if (!this.pendingHeader)
|
|
104
|
+
return;
|
|
105
|
+
const headerLine = JSON.stringify(this.pendingHeader) + "\n";
|
|
106
|
+
this.pendingHeader = null;
|
|
107
|
+
fs.writeFileSync(this.entriesPath, headerLine);
|
|
108
|
+
this.persistLeaf();
|
|
109
|
+
}
|
|
110
|
+
getActiveLeaf() { return this.activeLeaf; }
|
|
111
|
+
setActiveLeaf(id) {
|
|
112
|
+
if (!this.entries.has(id))
|
|
113
|
+
throw new Error(`unknown entry: ${id}`);
|
|
114
|
+
this.activeLeaf = id;
|
|
115
|
+
this.persistLeaf();
|
|
116
|
+
}
|
|
117
|
+
getRootId() { return this.rootId; }
|
|
118
|
+
getEntry(id) { return this.entries.get(id); }
|
|
119
|
+
getAllEntries() { return [...this.entries.values()]; }
|
|
120
|
+
async appendMessages(messages) {
|
|
121
|
+
if (messages.length === 0)
|
|
122
|
+
return [];
|
|
123
|
+
this.flushHeader();
|
|
124
|
+
let parent = this.activeLeaf;
|
|
125
|
+
const lines = [];
|
|
126
|
+
const newIds = [];
|
|
127
|
+
for (const m of messages) {
|
|
128
|
+
const e = {
|
|
129
|
+
type: "message",
|
|
130
|
+
id: newEntryId(),
|
|
131
|
+
parentId: parent,
|
|
132
|
+
timestamp: Date.now(),
|
|
133
|
+
message: m,
|
|
134
|
+
};
|
|
135
|
+
this.entries.set(e.id, e);
|
|
136
|
+
lines.push(JSON.stringify(e));
|
|
137
|
+
newIds.push(e.id);
|
|
138
|
+
parent = e.id;
|
|
139
|
+
}
|
|
140
|
+
this.activeLeaf = parent;
|
|
141
|
+
await fsp.appendFile(this.entriesPath, lines.join("\n") + "\n");
|
|
142
|
+
this.persistLeaf();
|
|
143
|
+
return newIds;
|
|
144
|
+
}
|
|
145
|
+
async appendShellExchange(e) {
|
|
146
|
+
this.flushHeader();
|
|
147
|
+
const entry = {
|
|
148
|
+
type: "shell-exchange",
|
|
149
|
+
id: newEntryId(),
|
|
150
|
+
parentId: this.activeLeaf,
|
|
151
|
+
timestamp: Date.now(),
|
|
152
|
+
command: e.command,
|
|
153
|
+
output: e.output,
|
|
154
|
+
exitCode: e.exitCode,
|
|
155
|
+
...(e.cwd !== undefined ? { cwd: e.cwd } : {}),
|
|
156
|
+
...(e.private ? { private: true } : {}),
|
|
157
|
+
};
|
|
158
|
+
this.entries.set(entry.id, entry);
|
|
159
|
+
this.activeLeaf = entry.id;
|
|
160
|
+
await fsp.appendFile(this.entriesPath, JSON.stringify(entry) + "\n");
|
|
161
|
+
this.persistLeaf();
|
|
162
|
+
return entry.id;
|
|
163
|
+
}
|
|
164
|
+
async appendCompaction(firstKeptId, tokensBefore, summary) {
|
|
165
|
+
if (!this.entries.has(firstKeptId))
|
|
166
|
+
throw new Error(`firstKeptId unknown: ${firstKeptId}`);
|
|
167
|
+
this.flushHeader();
|
|
168
|
+
const e = {
|
|
169
|
+
type: "compaction",
|
|
170
|
+
id: newEntryId(),
|
|
171
|
+
parentId: this.activeLeaf,
|
|
172
|
+
timestamp: Date.now(),
|
|
173
|
+
firstKeptId,
|
|
174
|
+
tokensBefore,
|
|
175
|
+
...(summary !== undefined ? { summary } : {}),
|
|
176
|
+
};
|
|
177
|
+
this.entries.set(e.id, e);
|
|
178
|
+
this.activeLeaf = e.id;
|
|
179
|
+
await fsp.appendFile(this.entriesPath, JSON.stringify(e) + "\n");
|
|
180
|
+
this.persistLeaf();
|
|
181
|
+
return e.id;
|
|
182
|
+
}
|
|
183
|
+
/** Returns oldest-first. */
|
|
184
|
+
getBranch(leafId = this.activeLeaf) {
|
|
185
|
+
const out = [];
|
|
186
|
+
const seen = new Set();
|
|
187
|
+
let cur = leafId;
|
|
188
|
+
while (cur && !seen.has(cur)) {
|
|
189
|
+
seen.add(cur);
|
|
190
|
+
const e = this.entries.get(cur);
|
|
191
|
+
if (!e)
|
|
192
|
+
break;
|
|
193
|
+
out.push(e);
|
|
194
|
+
cur = e.parentId;
|
|
195
|
+
}
|
|
196
|
+
return out.reverse();
|
|
197
|
+
}
|
|
198
|
+
/** Latest compaction on the branch replaces the evicted prefix with
|
|
199
|
+
* its stored summary, or a rendered one if no summary was stored. */
|
|
200
|
+
buildMessages(leafId = this.activeLeaf) {
|
|
201
|
+
return this.buildBranchWithIds(leafId).messages;
|
|
202
|
+
}
|
|
203
|
+
/** Parallel entryIds array — null for the synthetic compaction-summary
|
|
204
|
+
* slot — so callers can map message indices back to on-disk ids. */
|
|
205
|
+
buildBranchWithIds(leafId = this.activeLeaf) {
|
|
206
|
+
const branch = this.getBranch(leafId);
|
|
207
|
+
let compactionIdx = -1;
|
|
208
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
209
|
+
if (branch[i].type === "compaction") {
|
|
210
|
+
compactionIdx = i;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const messages = [];
|
|
215
|
+
const entryIds = [];
|
|
216
|
+
let startIdx = 0;
|
|
217
|
+
if (compactionIdx >= 0) {
|
|
218
|
+
const c = branch[compactionIdx];
|
|
219
|
+
const firstKeptIdx = branch.findIndex((e) => e.id === c.firstKeptId);
|
|
220
|
+
startIdx = firstKeptIdx >= 0 ? firstKeptIdx : 0;
|
|
221
|
+
const summary = c.summary ?? renderEvictedSummary(branch.slice(0, startIdx)
|
|
222
|
+
.filter((e) => e.type === "message")
|
|
223
|
+
.map((e) => e.message));
|
|
224
|
+
messages.push({ role: "user", content: `[Compacted conversation summary]\n${summary}` });
|
|
225
|
+
entryIds.push(null);
|
|
226
|
+
}
|
|
227
|
+
for (let i = startIdx; i < branch.length; i++) {
|
|
228
|
+
const e = branch[i];
|
|
229
|
+
if (e.type === "message") {
|
|
230
|
+
messages.push(e.message);
|
|
231
|
+
entryIds.push(e.id);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { messages, entryIds };
|
|
235
|
+
}
|
|
236
|
+
getPreview() {
|
|
237
|
+
for (const e of this.entries.values()) {
|
|
238
|
+
if (e.type === "message" && e.message.role === "user") {
|
|
239
|
+
const raw = typeof e.message.content === "string" ? e.message.content : "";
|
|
240
|
+
const txt = stripContextWrappers(raw);
|
|
241
|
+
if (txt)
|
|
242
|
+
return txt.slice(0, 80);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return "(empty)";
|
|
246
|
+
}
|
|
247
|
+
load() {
|
|
248
|
+
let raw;
|
|
249
|
+
try {
|
|
250
|
+
raw = fs.readFileSync(this.entriesPath, "utf-8");
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
for (const line of raw.split("\n")) {
|
|
256
|
+
if (!line)
|
|
257
|
+
continue;
|
|
258
|
+
try {
|
|
259
|
+
const e = JSON.parse(line);
|
|
260
|
+
if (!e.id)
|
|
261
|
+
continue;
|
|
262
|
+
this.entries.set(e.id, e);
|
|
263
|
+
if (e.type === "session")
|
|
264
|
+
this.rootId = e.id;
|
|
265
|
+
}
|
|
266
|
+
catch { /* skip malformed */ }
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
this.activeLeaf = fs.readFileSync(this.leafPath, "utf-8").trim();
|
|
270
|
+
if (!this.entries.has(this.activeLeaf))
|
|
271
|
+
this.activeLeaf = this.rootId;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
this.activeLeaf = this.lastEntryId();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
lastEntryId() {
|
|
278
|
+
let lastId = this.rootId;
|
|
279
|
+
for (const e of this.entries.values())
|
|
280
|
+
lastId = e.id;
|
|
281
|
+
return lastId;
|
|
282
|
+
}
|
|
283
|
+
persistLeaf() {
|
|
284
|
+
if (this.pendingHeader)
|
|
285
|
+
return;
|
|
286
|
+
fs.writeFileSync(this.leafPath, this.activeLeaf);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export interface Entry {
|
|
2
|
+
id: string;
|
|
3
|
+
parentId?: string;
|
|
4
|
+
ts: number;
|
|
5
|
+
kind: string;
|
|
6
|
+
payload: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
export interface AppendOpts {
|
|
9
|
+
/** Memory-only; never persisted. */
|
|
10
|
+
ephemeral?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface SearchHit {
|
|
13
|
+
entry: Entry;
|
|
14
|
+
line: string;
|
|
15
|
+
}
|
|
16
|
+
/** Append-only — no edit or delete. Implementations may apply bulk
|
|
17
|
+
* retention (front-truncation, GC), but strategies cannot remove a
|
|
18
|
+
* specific entry. */
|
|
19
|
+
export interface Store {
|
|
20
|
+
append(entries: Entry[], opts?: AppendOpts): Promise<void>;
|
|
21
|
+
findById(id: string): Promise<Entry | null>;
|
|
22
|
+
readRecent(n?: number): Promise<Entry[]>;
|
|
23
|
+
search(query: string): Promise<SearchHit[]>;
|
|
24
|
+
}
|
|
25
|
+
export interface TreeStore extends Store {
|
|
26
|
+
getBranch(leafId?: string): Promise<Entry[]>;
|
|
27
|
+
setLeaf(id: string): void;
|
|
28
|
+
getLeaf(): string;
|
|
29
|
+
}
|
|
30
|
+
export declare function newEntryId(): string;
|
|
31
|
+
export declare function isTreeStore(s: Store): s is TreeStore;
|
|
32
|
+
export declare class NoopStore implements Store {
|
|
33
|
+
append(): Promise<void>;
|
|
34
|
+
findById(): Promise<Entry | null>;
|
|
35
|
+
readRecent(): Promise<Entry[]>;
|
|
36
|
+
search(): Promise<SearchHit[]>;
|
|
37
|
+
}
|
|
38
|
+
export declare class InMemoryStore implements TreeStore {
|
|
39
|
+
private entries;
|
|
40
|
+
private order;
|
|
41
|
+
private leaf;
|
|
42
|
+
constructor(opts?: {
|
|
43
|
+
root?: Entry;
|
|
44
|
+
});
|
|
45
|
+
append(entries: Entry[]): Promise<void>;
|
|
46
|
+
findById(id: string): Promise<Entry | null>;
|
|
47
|
+
readRecent(n?: number): Promise<Entry[]>;
|
|
48
|
+
search(query: string): Promise<SearchHit[]>;
|
|
49
|
+
getBranch(leafId?: string): Promise<Entry[]>;
|
|
50
|
+
setLeaf(id: string): void;
|
|
51
|
+
getLeaf(): string;
|
|
52
|
+
}
|
|
53
|
+
export interface SharedFileStoreOpts {
|
|
54
|
+
filePath: string;
|
|
55
|
+
/** Front-truncate above this size; truncation fires at 150% of the
|
|
56
|
+
* cap to avoid frequent rewrites. */
|
|
57
|
+
maxBytes?: number;
|
|
58
|
+
}
|
|
59
|
+
export declare class SharedFileStore implements Store {
|
|
60
|
+
private filePath;
|
|
61
|
+
private lockPath;
|
|
62
|
+
private maxBytes;
|
|
63
|
+
constructor(opts: SharedFileStoreOpts);
|
|
64
|
+
append(entries: Entry[], opts?: AppendOpts): Promise<void>;
|
|
65
|
+
findById(id: string): Promise<Entry | null>;
|
|
66
|
+
readRecent(n?: number): Promise<Entry[]>;
|
|
67
|
+
search(query: string): Promise<SearchHit[]>;
|
|
68
|
+
/** Yield lines newest-first by reading reverse-chunked blocks,
|
|
69
|
+
* stitching across boundaries. */
|
|
70
|
+
private streamReverseLines;
|
|
71
|
+
private maybeTruncate;
|
|
72
|
+
private acquireLock;
|
|
73
|
+
private releaseLock;
|
|
74
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as fsp from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as crypto from "node:crypto";
|
|
5
|
+
export function newEntryId() {
|
|
6
|
+
return crypto.randomBytes(4).toString("hex");
|
|
7
|
+
}
|
|
8
|
+
export function isTreeStore(s) {
|
|
9
|
+
return (typeof s.setLeaf === "function" &&
|
|
10
|
+
typeof s.getLeaf === "function" &&
|
|
11
|
+
typeof s.getBranch === "function");
|
|
12
|
+
}
|
|
13
|
+
function escapeRegex(s) {
|
|
14
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
15
|
+
}
|
|
16
|
+
function compileSearchRegex(query) {
|
|
17
|
+
return new RegExp(escapeRegex(query), "i");
|
|
18
|
+
}
|
|
19
|
+
function matchEntry(entry, re) {
|
|
20
|
+
const line = JSON.stringify(entry);
|
|
21
|
+
return re.test(line) ? { entry, line } : null;
|
|
22
|
+
}
|
|
23
|
+
export class NoopStore {
|
|
24
|
+
async append() { }
|
|
25
|
+
async findById() { return null; }
|
|
26
|
+
async readRecent() { return []; }
|
|
27
|
+
async search() { return []; }
|
|
28
|
+
}
|
|
29
|
+
export class InMemoryStore {
|
|
30
|
+
entries = new Map();
|
|
31
|
+
order = [];
|
|
32
|
+
leaf;
|
|
33
|
+
constructor(opts) {
|
|
34
|
+
if (opts?.root) {
|
|
35
|
+
this.entries.set(opts.root.id, opts.root);
|
|
36
|
+
this.order.push(opts.root.id);
|
|
37
|
+
this.leaf = opts.root.id;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
this.leaf = "";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async append(entries) {
|
|
44
|
+
for (const e of entries) {
|
|
45
|
+
this.entries.set(e.id, e);
|
|
46
|
+
this.order.push(e.id);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async findById(id) {
|
|
50
|
+
return this.entries.get(id) ?? null;
|
|
51
|
+
}
|
|
52
|
+
async readRecent(n) {
|
|
53
|
+
const slice = n == null ? this.order : this.order.slice(-n);
|
|
54
|
+
return slice.map((id) => this.entries.get(id));
|
|
55
|
+
}
|
|
56
|
+
async search(query) {
|
|
57
|
+
if (!query.trim())
|
|
58
|
+
return [];
|
|
59
|
+
const re = compileSearchRegex(query);
|
|
60
|
+
const out = [];
|
|
61
|
+
for (let i = this.order.length - 1; i >= 0; i--) {
|
|
62
|
+
const m = matchEntry(this.entries.get(this.order[i]), re);
|
|
63
|
+
if (m)
|
|
64
|
+
out.push(m);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
async getBranch(leafId = this.leaf) {
|
|
69
|
+
const out = [];
|
|
70
|
+
const seen = new Set();
|
|
71
|
+
let cur = leafId;
|
|
72
|
+
while (cur && !seen.has(cur)) {
|
|
73
|
+
seen.add(cur);
|
|
74
|
+
const e = this.entries.get(cur);
|
|
75
|
+
if (!e)
|
|
76
|
+
break;
|
|
77
|
+
out.push(e);
|
|
78
|
+
cur = e.parentId;
|
|
79
|
+
}
|
|
80
|
+
return out.reverse();
|
|
81
|
+
}
|
|
82
|
+
setLeaf(id) {
|
|
83
|
+
if (!this.entries.has(id))
|
|
84
|
+
throw new Error(`unknown entry: ${id}`);
|
|
85
|
+
this.leaf = id;
|
|
86
|
+
}
|
|
87
|
+
getLeaf() {
|
|
88
|
+
return this.leaf;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Multi-writer JSONL Store. O_APPEND with PIPE_BUF-bounded line
|
|
92
|
+
* writes for atomic concurrent appends; lock-based front-truncation
|
|
93
|
+
* for retention; reads stream the tail for cheap recent slices. */
|
|
94
|
+
const LOCK_STALE_MS = 10_000;
|
|
95
|
+
const DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
|
|
96
|
+
export class SharedFileStore {
|
|
97
|
+
filePath;
|
|
98
|
+
lockPath;
|
|
99
|
+
maxBytes;
|
|
100
|
+
constructor(opts) {
|
|
101
|
+
this.filePath = opts.filePath;
|
|
102
|
+
this.lockPath = opts.filePath + ".lock";
|
|
103
|
+
this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
104
|
+
try {
|
|
105
|
+
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
106
|
+
}
|
|
107
|
+
catch { /* ignore */ }
|
|
108
|
+
}
|
|
109
|
+
async append(entries, opts) {
|
|
110
|
+
if (entries.length === 0)
|
|
111
|
+
return;
|
|
112
|
+
if (opts?.ephemeral)
|
|
113
|
+
return; // memory-only writes are a no-op on a file-only store
|
|
114
|
+
const lines = entries.map((e) => JSON.stringify(e) + "\n").join("");
|
|
115
|
+
await fsp.appendFile(this.filePath, lines, { flag: "a" });
|
|
116
|
+
await this.maybeTruncate();
|
|
117
|
+
}
|
|
118
|
+
async findById(id) {
|
|
119
|
+
for await (const line of this.streamReverseLines()) {
|
|
120
|
+
try {
|
|
121
|
+
const e = JSON.parse(line);
|
|
122
|
+
if (e.id === id)
|
|
123
|
+
return e;
|
|
124
|
+
}
|
|
125
|
+
catch { /* skip malformed */ }
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
async readRecent(n) {
|
|
130
|
+
const want = n ?? Infinity;
|
|
131
|
+
const recent = []; // newest-first
|
|
132
|
+
for await (const line of this.streamReverseLines()) {
|
|
133
|
+
try {
|
|
134
|
+
const e = JSON.parse(line);
|
|
135
|
+
if (!e.id)
|
|
136
|
+
continue;
|
|
137
|
+
recent.push(e);
|
|
138
|
+
if (recent.length >= want)
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
catch { /* skip malformed */ }
|
|
142
|
+
}
|
|
143
|
+
return recent.reverse();
|
|
144
|
+
}
|
|
145
|
+
async search(query) {
|
|
146
|
+
if (!query.trim())
|
|
147
|
+
return [];
|
|
148
|
+
const re = compileSearchRegex(query);
|
|
149
|
+
const budgetBytes = 20 * 1024 * 1024;
|
|
150
|
+
let scanned = 0;
|
|
151
|
+
const out = [];
|
|
152
|
+
for await (const line of this.streamReverseLines()) {
|
|
153
|
+
scanned += line.length + 1;
|
|
154
|
+
if (scanned > budgetBytes)
|
|
155
|
+
break;
|
|
156
|
+
try {
|
|
157
|
+
const e = JSON.parse(line);
|
|
158
|
+
const m = matchEntry(e, re);
|
|
159
|
+
if (m)
|
|
160
|
+
out.push(m);
|
|
161
|
+
}
|
|
162
|
+
catch { /* skip malformed */ }
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
/** Yield lines newest-first by reading reverse-chunked blocks,
|
|
167
|
+
* stitching across boundaries. */
|
|
168
|
+
async *streamReverseLines(chunkBytes = 1 << 20) {
|
|
169
|
+
let handle;
|
|
170
|
+
let fileSize;
|
|
171
|
+
try {
|
|
172
|
+
const stat = await fsp.stat(this.filePath);
|
|
173
|
+
fileSize = stat.size;
|
|
174
|
+
if (fileSize === 0)
|
|
175
|
+
return;
|
|
176
|
+
handle = await fsp.open(this.filePath, "r");
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
let position = fileSize;
|
|
183
|
+
let pending = Buffer.alloc(0);
|
|
184
|
+
while (position > 0) {
|
|
185
|
+
const readSize = Math.min(chunkBytes, position);
|
|
186
|
+
position -= readSize;
|
|
187
|
+
const buf = Buffer.alloc(readSize);
|
|
188
|
+
await handle.read(buf, 0, readSize, position);
|
|
189
|
+
const combined = Buffer.concat([buf, pending]);
|
|
190
|
+
const newlineIdxs = [];
|
|
191
|
+
for (let i = 0; i < combined.length; i++) {
|
|
192
|
+
if (combined[i] === 0x0A)
|
|
193
|
+
newlineIdxs.push(i);
|
|
194
|
+
}
|
|
195
|
+
if (newlineIdxs.length === 0) {
|
|
196
|
+
pending = combined;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const firstNl = newlineIdxs[0];
|
|
200
|
+
const lastNl = newlineIdxs[newlineIdxs.length - 1];
|
|
201
|
+
const trailing = combined.subarray(lastNl + 1);
|
|
202
|
+
if (trailing.length > 0)
|
|
203
|
+
yield trailing.toString("utf-8");
|
|
204
|
+
for (let i = newlineIdxs.length - 1; i >= 1; i--) {
|
|
205
|
+
const seg = combined.subarray(newlineIdxs[i - 1] + 1, newlineIdxs[i]);
|
|
206
|
+
if (seg.length > 0)
|
|
207
|
+
yield seg.toString("utf-8");
|
|
208
|
+
}
|
|
209
|
+
const leading = combined.subarray(0, firstNl);
|
|
210
|
+
if (position === 0) {
|
|
211
|
+
if (leading.length > 0)
|
|
212
|
+
yield leading.toString("utf-8");
|
|
213
|
+
pending = Buffer.alloc(0);
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
pending = leading;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (pending.length > 0)
|
|
220
|
+
yield pending.toString("utf-8");
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
await handle.close();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async maybeTruncate() {
|
|
227
|
+
let size = 0;
|
|
228
|
+
try {
|
|
229
|
+
size = (await fsp.stat(this.filePath)).size;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (size <= this.maxBytes * 1.5)
|
|
235
|
+
return;
|
|
236
|
+
if (!(await this.acquireLock()))
|
|
237
|
+
return;
|
|
238
|
+
try {
|
|
239
|
+
let content;
|
|
240
|
+
try {
|
|
241
|
+
content = await fsp.readFile(this.filePath, "utf-8");
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const lines = content.split("\n").filter(Boolean);
|
|
247
|
+
let totalBytes = Buffer.byteLength(content, "utf-8");
|
|
248
|
+
let dropCount = 0;
|
|
249
|
+
while (totalBytes > this.maxBytes && dropCount < lines.length - 1) {
|
|
250
|
+
totalBytes -= Buffer.byteLength(lines[dropCount] + "\n", "utf-8");
|
|
251
|
+
dropCount++;
|
|
252
|
+
}
|
|
253
|
+
if (dropCount === 0)
|
|
254
|
+
return;
|
|
255
|
+
const remaining = lines.slice(dropCount).join("\n") + "\n";
|
|
256
|
+
const tmpPath = this.filePath + ".tmp." + process.pid;
|
|
257
|
+
await fsp.writeFile(tmpPath, remaining);
|
|
258
|
+
await fsp.rename(tmpPath, this.filePath);
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
await this.releaseLock();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async acquireLock() {
|
|
265
|
+
try {
|
|
266
|
+
try {
|
|
267
|
+
const stat = await fsp.stat(this.lockPath);
|
|
268
|
+
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
|
269
|
+
await fsp.unlink(this.lockPath).catch(() => { });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch { /* lock absent — good */ }
|
|
273
|
+
const fd = await fsp.open(this.lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
|
|
274
|
+
await fd.close();
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async releaseLock() {
|
|
282
|
+
await fsp.unlink(this.lockPath).catch(() => { });
|
|
283
|
+
}
|
|
284
|
+
}
|
package/dist/agent/subagent.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { contentText } from "./types.js";
|
|
2
|
-
import {
|
|
2
|
+
import { LiveView } from "./live-view.js";
|
|
3
3
|
import { normalizeToolArgs } from "./normalize-args.js";
|
|
4
4
|
import { wrapTrailingWithDynamicContext } from "../utils/message-utils.js";
|
|
5
5
|
/**
|
|
@@ -17,7 +17,7 @@ export async function runSubagent(opts) {
|
|
|
17
17
|
parameters: t.input_schema,
|
|
18
18
|
},
|
|
19
19
|
}));
|
|
20
|
-
const conversation = new
|
|
20
|
+
const conversation = new LiveView();
|
|
21
21
|
conversation.addUserMessage(task);
|
|
22
22
|
let fullResponseText = "";
|
|
23
23
|
let iterations = 0;
|