@sema-agent/core 2.0.0 → 2.1.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/dist/agents/observer.js +8 -3
- package/dist/agents/send-message-tool.js +112 -81
- package/dist/agents/subagent.d.ts +10 -4
- package/dist/agents/subagent.js +103 -53
- package/dist/core/memory-engine/dual-root.js +2 -0
- package/dist/core/memory-engine/engine.d.ts +4 -0
- package/dist/core/memory-engine/engine.js +6 -1
- package/dist/core/runner/prepare-memory.d.ts +4 -0
- package/dist/core/runner/prepare-memory.js +4 -1
- package/dist/core/runner/prepare-task.d.ts +8 -2
- package/dist/core/runner/prepare-task.js +39 -8
- package/dist/core/runner/runtask.js +910 -865
- package/dist/core/runner/turn-attachments.d.ts +15 -1
- package/dist/core/runner/turn-attachments.js +68 -9
- package/dist/core/tool-result-budget.js +2 -2
- package/dist/core/tool-result-store.d.ts +2 -0
- package/dist/core/tool-result-store.js +27 -2
- package/dist/core/tools.js +1 -0
- package/dist/core/types.d.ts +1 -1
- package/dist/core/workflow-journal-store.d.ts +13 -0
- package/dist/engine/loop/agent-loop.d.ts +1 -1
- package/dist/engine/loop/agent-loop.js +10 -9
- package/dist/engine/session/import-validate.js +29 -0
- package/dist/orchestration/workflow.js +28 -1
- package/dist/prompt-assembly/event-registry.js +1 -1
- package/dist/stores/cc/task-list-store.js +3 -3
- package/dist/stores/file/mailbox-store.js +33 -2
- package/dist/stores/file/memory-store.d.ts +3 -0
- package/dist/stores/file/memory-store.js +39 -12
- package/dist/stores/file/tool-result-store.js +16 -2
- package/dist/stores/file/workflow-journal-store.d.ts +17 -0
- package/dist/stores/file/workflow-journal-store.js +102 -2
- package/dist/tools/fs/bash-readonly-classifier.js +1 -1
- package/dist/tools/fs/fs-read.js +2 -2
- package/dist/tools/fs/fs-search-tools.js +3 -1
- package/dist/tools/fs/fs-shared.d.ts +2 -1
- package/dist/tools/fs/fs-shared.js +7 -3
- package/dist/tools/fs/fs-write.js +9 -11
- package/dist/tools/fs/safety.d.ts +3 -0
- package/dist/tools/fs/safety.js +20 -7
- package/dist/tools/fs/search.d.ts +1 -0
- package/dist/tools/fs/search.js +21 -3
- package/dist/tools/task-list.d.ts +1 -0
- package/dist/tools/task-list.js +13 -2
- package/dist/tools/web.js +79 -11
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ import { uuidv7 } from "../../internal/harness.js";
|
|
|
3
3
|
import { firstSentence, lexicalSearchMatch, } from "../../core/memory.js";
|
|
4
4
|
import { cosineDistance, jaccardDistance, termSet } from "../../core/memory-vector.js";
|
|
5
5
|
import { canonicalStoreKey, AppendLog, atomicWriteFile, ensureDir, readJsonlRecords, sanitizeScope, } from "./fs-atomic.js";
|
|
6
|
+
const MAX_OPEN_MEMORY_SCOPES = 64;
|
|
6
7
|
const sharedMemoryDirs = new Map();
|
|
7
8
|
export class FileMemoryStore {
|
|
8
9
|
root;
|
|
@@ -25,7 +26,7 @@ export class FileMemoryStore {
|
|
|
25
26
|
this.sharedScopes = live;
|
|
26
27
|
}
|
|
27
28
|
else {
|
|
28
|
-
this.sharedScopes = { scopes: new Map(), refs: 1 };
|
|
29
|
+
this.sharedScopes = { scopes: new Map(), refs: 1, openLogs: new Set() };
|
|
29
30
|
sharedMemoryDirs.set(this.scopeKey, this.sharedScopes);
|
|
30
31
|
}
|
|
31
32
|
this.tmpDir = join(root, "tmp");
|
|
@@ -90,11 +91,36 @@ export class FileMemoryStore {
|
|
|
90
91
|
vectors.set(ve.id, { embedding: ve.embedding, h: typeof ve.h === "string" ? ve.h : "" });
|
|
91
92
|
}
|
|
92
93
|
}
|
|
93
|
-
const state = { entries, log: new AppendLog(notesPath), dir, cursor, vectors,
|
|
94
|
+
const state = { entries, log: new AppendLog(notesPath), dir, cursor, vectors, vpath };
|
|
94
95
|
this.scopes.set(scope, state);
|
|
96
|
+
this.touchOpenLogs(scope);
|
|
95
97
|
return state;
|
|
96
98
|
}
|
|
97
|
-
|
|
99
|
+
touchOpenLogs(scope) {
|
|
100
|
+
const open = this.sharedScopes.openLogs;
|
|
101
|
+
open.delete(scope);
|
|
102
|
+
open.add(scope);
|
|
103
|
+
while (open.size > MAX_OPEN_MEMORY_SCOPES) {
|
|
104
|
+
const coldest = open.values().next().value;
|
|
105
|
+
if (coldest === undefined)
|
|
106
|
+
break;
|
|
107
|
+
open.delete(coldest);
|
|
108
|
+
const st = this.scopes.get(coldest);
|
|
109
|
+
st?.log.closeForSwap();
|
|
110
|
+
st?.vlog?.closeForSwap();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
writeLog(scope, state) {
|
|
114
|
+
this.touchOpenLogs(scope);
|
|
115
|
+
return state.log;
|
|
116
|
+
}
|
|
117
|
+
vectorLog(scope, state) {
|
|
118
|
+
if (state.vlog === undefined)
|
|
119
|
+
state.vlog = new AppendLog(state.vpath);
|
|
120
|
+
this.touchOpenLogs(scope);
|
|
121
|
+
return state.vlog;
|
|
122
|
+
}
|
|
123
|
+
queueEmbed(scope, state, id, body) {
|
|
98
124
|
const emb = this.embedder;
|
|
99
125
|
if (!emb)
|
|
100
126
|
return;
|
|
@@ -111,7 +137,7 @@ export class FileMemoryStore {
|
|
|
111
137
|
const cur = state.entries.find((e) => e.id === id);
|
|
112
138
|
if (!cur || hashBody(cur.text) !== h)
|
|
113
139
|
return;
|
|
114
|
-
state.
|
|
140
|
+
this.vectorLog(scope, state).append({ id, embedding: vec, h }, false);
|
|
115
141
|
state.vectors.set(id, { embedding: vec, h });
|
|
116
142
|
})
|
|
117
143
|
.catch(() => { });
|
|
@@ -155,14 +181,14 @@ export class FileMemoryStore {
|
|
|
155
181
|
}
|
|
156
182
|
commit(scope, entry) {
|
|
157
183
|
const state = this.scopeState(scope);
|
|
158
|
-
state.
|
|
184
|
+
this.writeLog(scope, state).append({ op: "append", entry }, true);
|
|
159
185
|
state.entries.push(entry);
|
|
160
186
|
this.writeProjection(state);
|
|
161
|
-
this.queueEmbed(state, entry.id, entry.text);
|
|
187
|
+
this.queueEmbed(scope, state, entry.id, entry.text);
|
|
162
188
|
}
|
|
163
189
|
clear(scope) {
|
|
164
190
|
const state = this.scopeState(scope);
|
|
165
|
-
state.
|
|
191
|
+
this.writeLog(scope, state).append({ op: "clear" }, true);
|
|
166
192
|
state.entries.length = 0;
|
|
167
193
|
state.cursor = undefined;
|
|
168
194
|
state.vectors.clear();
|
|
@@ -173,7 +199,7 @@ export class FileMemoryStore {
|
|
|
173
199
|
}
|
|
174
200
|
setConsolidationCursor(scope, cursor) {
|
|
175
201
|
const state = this.scopeState(scope);
|
|
176
|
-
state.
|
|
202
|
+
this.writeLog(scope, state).append({ op: "cursor", cursor }, true);
|
|
177
203
|
state.cursor = cursor;
|
|
178
204
|
}
|
|
179
205
|
search(scope, query, limit = 10) {
|
|
@@ -241,20 +267,20 @@ export class FileMemoryStore {
|
|
|
241
267
|
const ev = { op: "update", id, text: newBody, ts };
|
|
242
268
|
if (newDesc !== undefined)
|
|
243
269
|
ev.description = newDesc;
|
|
244
|
-
state.
|
|
270
|
+
this.writeLog(scope, state).append(ev, true);
|
|
245
271
|
entry.text = newBody;
|
|
246
272
|
entry.ts = ts;
|
|
247
273
|
entry.description = newDesc;
|
|
248
274
|
this.writeProjection(state);
|
|
249
275
|
state.vectors.delete(id);
|
|
250
|
-
this.queueEmbed(state, id, newBody);
|
|
276
|
+
this.queueEmbed(scope, state, id, newBody);
|
|
251
277
|
}
|
|
252
278
|
delete(scope, id) {
|
|
253
279
|
const state = this.scopeState(scope);
|
|
254
280
|
const i = state.entries.findIndex((e) => e.id === id);
|
|
255
281
|
if (i === -1)
|
|
256
282
|
return;
|
|
257
|
-
state.
|
|
283
|
+
this.writeLog(scope, state).append({ op: "delete", id }, true);
|
|
258
284
|
state.entries.splice(i, 1);
|
|
259
285
|
this.writeProjection(state);
|
|
260
286
|
}
|
|
@@ -269,8 +295,9 @@ export class FileMemoryStore {
|
|
|
269
295
|
sharedMemoryDirs.delete(this.scopeKey);
|
|
270
296
|
for (const s of this.scopes.values()) {
|
|
271
297
|
s.log.close();
|
|
272
|
-
s.vlog
|
|
298
|
+
s.vlog?.close();
|
|
273
299
|
}
|
|
300
|
+
this.sharedScopes.openLogs.clear();
|
|
274
301
|
}
|
|
275
302
|
}
|
|
276
303
|
function renderBullet(e) {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { readFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
4
|
+
import { assertSafeToolResultRef } from "../../core/tool-result-store.js";
|
|
3
5
|
import { ensureDir, sanitizePathComponent, writeThenLink } from "./fs-atomic.js";
|
|
4
6
|
export class FileToolResultStore {
|
|
5
7
|
dir;
|
|
@@ -8,9 +10,11 @@ export class FileToolResultStore {
|
|
|
8
10
|
ensureDir(this.dir);
|
|
9
11
|
}
|
|
10
12
|
pathFor(ref) {
|
|
11
|
-
|
|
13
|
+
assertSafeToolResultRef(ref);
|
|
14
|
+
return join(this.dir, `${sanitizePathComponent(encodeRefFilename(ref))}.txt`);
|
|
12
15
|
}
|
|
13
16
|
put(ref, content) {
|
|
17
|
+
assertSafeToolResultRef(ref);
|
|
14
18
|
try {
|
|
15
19
|
writeThenLink(this.pathFor(ref), content);
|
|
16
20
|
}
|
|
@@ -28,8 +32,10 @@ export class FileToolResultStore {
|
|
|
28
32
|
catch (err) {
|
|
29
33
|
if (err.code === "ENOENT")
|
|
30
34
|
return undefined;
|
|
31
|
-
if (err instanceof Error &&
|
|
35
|
+
if (err instanceof Error &&
|
|
36
|
+
(err.message.startsWith("file store: unsafe path component") || err.message.startsWith("tool-result store: unsafe ref"))) {
|
|
32
37
|
return undefined;
|
|
38
|
+
}
|
|
33
39
|
throw err;
|
|
34
40
|
}
|
|
35
41
|
const offset = Math.min(full.length, Math.max(0, intOr(opts?.offset, 0)));
|
|
@@ -38,6 +44,14 @@ export class FileToolResultStore {
|
|
|
38
44
|
return { content, offset, totalChars: full.length };
|
|
39
45
|
}
|
|
40
46
|
}
|
|
47
|
+
const NATIVE_FILENAME_CHARSET = /^[A-Za-z0-9_.-]+$/;
|
|
48
|
+
const MAX_FILENAME_CHARS = 180;
|
|
49
|
+
function encodeRefFilename(ref) {
|
|
50
|
+
if (ref.length <= MAX_FILENAME_CHARS && NATIVE_FILENAME_CHARSET.test(ref))
|
|
51
|
+
return ref;
|
|
52
|
+
const base = ref.replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 64);
|
|
53
|
+
return `${base || "ref"}-${createHash("sha256").update(ref, "utf8").digest("hex")}`;
|
|
54
|
+
}
|
|
41
55
|
function intOr(x, fallback) {
|
|
42
56
|
return Number.isFinite(x) ? Math.floor(x) : fallback;
|
|
43
57
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { type WorkflowJournalEntry, type WorkflowJournalStore } from "../../core/workflow-journal-store.js";
|
|
2
2
|
export { MAX_JOURNAL_RESULT_BYTES, oversizeJournalResult } from "../../core/workflow-journal-store.js";
|
|
3
|
+
export declare const RESUME_CLAIM_TTL_MS: number;
|
|
3
4
|
export declare class FileWorkflowJournalStore implements WorkflowJournalStore {
|
|
4
5
|
private readonly fsyncEnabled;
|
|
5
6
|
private readonly dir;
|
|
7
|
+
private readonly claimsDir;
|
|
6
8
|
private readonly shared;
|
|
7
9
|
private readonly sharedKey;
|
|
8
10
|
private closed;
|
|
@@ -14,6 +16,21 @@ export declare class FileWorkflowJournalStore implements WorkflowJournalStore {
|
|
|
14
16
|
private replay;
|
|
15
17
|
append(runId: string, scope: string, entry: WorkflowJournalEntry): Promise<void>;
|
|
16
18
|
load(runId: string, scope: string): Promise<WorkflowJournalEntry[]>;
|
|
19
|
+
private claimPathFor;
|
|
20
|
+
private readClaim;
|
|
21
|
+
resumeClaim(input: {
|
|
22
|
+
sourceRunId: string;
|
|
23
|
+
newRunId: string;
|
|
24
|
+
scope: string;
|
|
25
|
+
}): Promise<{
|
|
26
|
+
granted: boolean;
|
|
27
|
+
holder?: string;
|
|
28
|
+
}>;
|
|
29
|
+
releaseResumeClaim(input: {
|
|
30
|
+
sourceRunId: string;
|
|
31
|
+
newRunId: string;
|
|
32
|
+
scope: string;
|
|
33
|
+
}): Promise<void>;
|
|
17
34
|
deleteByRun(runId: string): Promise<number>;
|
|
18
35
|
dispose(): void;
|
|
19
36
|
}
|
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
1
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { callKeyOrdinal } from "../../core/workflow-journal-store.js";
|
|
4
5
|
import { AppendLog } from "./fs-atomic.js";
|
|
5
6
|
import { canonicalStoreKey, sanitizePathComponent } from "./fs-atomic.js";
|
|
6
7
|
import { oversizeJournalResult } from "../../core/workflow-journal-store.js";
|
|
7
8
|
export { MAX_JOURNAL_RESULT_BYTES, oversizeJournalResult } from "../../core/workflow-journal-store.js";
|
|
9
|
+
export const RESUME_CLAIM_TTL_MS = 60 * 60 * 1000;
|
|
8
10
|
const sharedJournalDirs = new Map();
|
|
11
|
+
const MAX_OPEN_JOURNAL_LOGS = 64;
|
|
9
12
|
export class FileWorkflowJournalStore {
|
|
10
13
|
fsyncEnabled;
|
|
11
14
|
dir;
|
|
15
|
+
claimsDir;
|
|
12
16
|
shared;
|
|
13
17
|
sharedKey;
|
|
14
18
|
closed = false;
|
|
@@ -22,6 +26,7 @@ export class FileWorkflowJournalStore {
|
|
|
22
26
|
this.fsyncEnabled = fsyncEnabled;
|
|
23
27
|
this.dir = join(root, "workflow-journal");
|
|
24
28
|
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
29
|
+
this.claimsDir = join(this.dir, "claims");
|
|
25
30
|
this.sharedKey = canonicalStoreKey(this.dir);
|
|
26
31
|
const existing = sharedJournalDirs.get(this.sharedKey);
|
|
27
32
|
if (existing !== undefined) {
|
|
@@ -87,7 +92,22 @@ export class FileWorkflowJournalStore {
|
|
|
87
92
|
let log = this.logs.get(runId);
|
|
88
93
|
if (log === undefined) {
|
|
89
94
|
log = new AppendLog(this.pathFor(runId));
|
|
90
|
-
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
this.logs.delete(runId);
|
|
98
|
+
}
|
|
99
|
+
this.logs.set(runId, log);
|
|
100
|
+
while (this.logs.size > MAX_OPEN_JOURNAL_LOGS) {
|
|
101
|
+
const coldest = this.logs.keys().next().value;
|
|
102
|
+
if (coldest === undefined || coldest === runId)
|
|
103
|
+
break;
|
|
104
|
+
const stale = this.logs.get(coldest);
|
|
105
|
+
this.logs.delete(coldest);
|
|
106
|
+
try {
|
|
107
|
+
stale?.close();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
}
|
|
91
111
|
}
|
|
92
112
|
const line = { scope, ordinal: callKeyOrdinal(entry.callKey), callKey: entry.callKey, result: entry.result };
|
|
93
113
|
log.append(line, this.fsyncEnabled);
|
|
@@ -98,6 +118,86 @@ export class FileWorkflowJournalStore {
|
|
|
98
118
|
return [];
|
|
99
119
|
return [...rec.byOrdinal.entries()].sort((a, b) => a[0] - b[0]).map(([, e]) => e);
|
|
100
120
|
}
|
|
121
|
+
claimPathFor(sourceRunId, scope) {
|
|
122
|
+
const scopeTag = createHash("sha256").update(scope).digest("hex").slice(0, 16);
|
|
123
|
+
return join(this.claimsDir, `${sanitizePathComponent(sourceRunId)}.${scopeTag}.json`);
|
|
124
|
+
}
|
|
125
|
+
readClaim(path) {
|
|
126
|
+
if (!existsSync(path))
|
|
127
|
+
return undefined;
|
|
128
|
+
try {
|
|
129
|
+
const rec = JSON.parse(readFileSync(path, "utf8"));
|
|
130
|
+
if (typeof rec.holder !== "string" || typeof rec.expiresAt !== "number" || !Number.isFinite(rec.expiresAt)) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
return rec;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async resumeClaim(input) {
|
|
140
|
+
const { sourceRunId, newRunId, scope } = input;
|
|
141
|
+
let path;
|
|
142
|
+
try {
|
|
143
|
+
path = this.claimPathFor(sourceRunId, scope);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return { granted: true };
|
|
147
|
+
}
|
|
148
|
+
mkdirSync(this.claimsDir, { recursive: true, mode: 0o700 });
|
|
149
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
150
|
+
const record = { sourceRunId, holder: newRunId, expiresAt: Date.now() + RESUME_CLAIM_TTL_MS };
|
|
151
|
+
try {
|
|
152
|
+
const fd = openSync(path, "wx", 0o600);
|
|
153
|
+
try {
|
|
154
|
+
writeSync(fd, JSON.stringify(record));
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
closeSync(fd);
|
|
158
|
+
}
|
|
159
|
+
return { granted: true };
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
if (err.code !== "EEXIST")
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
165
|
+
const existing = this.readClaim(path);
|
|
166
|
+
if (existing !== undefined && existing.holder === newRunId) {
|
|
167
|
+
return { granted: true };
|
|
168
|
+
}
|
|
169
|
+
if (existing === undefined || existing.expiresAt <= Date.now()) {
|
|
170
|
+
try {
|
|
171
|
+
unlinkSync(path);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
}
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
return { granted: false, holder: existing.holder };
|
|
178
|
+
}
|
|
179
|
+
const winner = this.readClaim(path);
|
|
180
|
+
if (winner === undefined || winner.holder === newRunId)
|
|
181
|
+
return { granted: true };
|
|
182
|
+
return { granted: false, holder: winner.holder };
|
|
183
|
+
}
|
|
184
|
+
async releaseResumeClaim(input) {
|
|
185
|
+
let path;
|
|
186
|
+
try {
|
|
187
|
+
path = this.claimPathFor(input.sourceRunId, input.scope);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const existing = this.readClaim(path);
|
|
193
|
+
if (existing === undefined || existing.holder !== input.newRunId)
|
|
194
|
+
return;
|
|
195
|
+
try {
|
|
196
|
+
unlinkSync(path);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
}
|
|
200
|
+
}
|
|
101
201
|
async deleteByRun(runId) {
|
|
102
202
|
const log = this.logs.get(runId);
|
|
103
203
|
if (log !== undefined) {
|
|
@@ -159,7 +159,7 @@ export function classifyCompoundReadonly(command, allow) {
|
|
|
159
159
|
const deviceArgs = toks.slice(1).filter((t) => !t.startsWith("-")).map(normalizeAbsPathLexically).filter(isBlockedDevicePath);
|
|
160
160
|
const rescuedByHead = headBoundIsSmall(name, toks) && deviceArgs.every((d) => GENERATOR_DEVICES.has(d));
|
|
161
161
|
if (!rescuedByHead && deviceArgs.length > 0) {
|
|
162
|
-
return "
|
|
162
|
+
return "reads a device/special file that is either unbounded (/dev/zero, /dev/stdin, /proc/<pid>/fd/0, … — blocks the pipeline until the tool timeout) or process-private (/proc/<pid>/environ, /proc/<pid>/mem, …) — not auto-allowed";
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
165
|
return undefined;
|
package/dist/tools/fs/fs-read.js
CHANGED
|
@@ -250,10 +250,10 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
|
|
|
250
250
|
}
|
|
251
251
|
const truncated = start > 1 || end < total || pageMarker !== undefined;
|
|
252
252
|
const prev = state.get(r.key);
|
|
253
|
-
if (total > 0 && prev?.seededFromContext && start === 1 && effLimit === undefined && prev.hash === hash) {
|
|
253
|
+
if (total > 0 && prev?.seededFromContext && !prev.isPartialView && start === 1 && effLimit === undefined && prev.hash === hash) {
|
|
254
254
|
return seededFileUnchangedReminder(r.key);
|
|
255
255
|
}
|
|
256
|
-
if (total > 0 && prev && prev.hash === hash && prev.view && prev.view.start === start && prev.view.end === end) {
|
|
256
|
+
if (total > 0 && prev && !prev.isPartialView && prev.hash === hash && prev.view && prev.view.start === start && prev.view.end === end) {
|
|
257
257
|
return `[${path}: unchanged since you last read it (lines ${start}-${end} of ${total}); content omitted to save context]`;
|
|
258
258
|
}
|
|
259
259
|
state.set(r.key, {
|
|
@@ -151,6 +151,8 @@ export function createGlobTool(env, rootCanonical, additionalRoots) {
|
|
|
151
151
|
scoped = r.key;
|
|
152
152
|
}
|
|
153
153
|
const r2 = await runGlobDetailed(env, rootCanonical, pattern, { path: scoped, max: max_results }, ctx.signal);
|
|
154
|
+
if (r2.error !== undefined)
|
|
155
|
+
return errorResult(r2.error);
|
|
154
156
|
return {
|
|
155
157
|
content: r2.text,
|
|
156
158
|
details: { type: "glob", filenames: r2.filenames, numFiles: r2.numFiles, truncated: r2.truncated, durationMs: r2.durationMs, totalMatches: r2.totalMatches, countIsComplete: r2.countIsComplete },
|
|
@@ -162,7 +164,7 @@ export const HAND_TOOL_EFFECTS = {
|
|
|
162
164
|
Read: "read",
|
|
163
165
|
Edit: "write",
|
|
164
166
|
MultiEdit: "write",
|
|
165
|
-
Write: "
|
|
167
|
+
Write: "write",
|
|
166
168
|
NotebookEdit: "write",
|
|
167
169
|
Grep: "read",
|
|
168
170
|
Glob: "read",
|
|
@@ -18,7 +18,8 @@ export declare function persistedTextOf(encoded: string | Uint8Array): string;
|
|
|
18
18
|
export declare function notReadRefusalText(env: ExecutionEnv, toolName: string, key: string, v: {
|
|
19
19
|
code: string;
|
|
20
20
|
message: string;
|
|
21
|
-
|
|
21
|
+
partialView?: boolean;
|
|
22
|
+
}, signal?: AbortSignal, fallbackHint?: string): Promise<string>;
|
|
22
23
|
export declare const MAX_IMAGE_READ_BYTES: number;
|
|
23
24
|
export declare const MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES: number;
|
|
24
25
|
export type ReadImageDownsamplerOption = ImageDownsampler | false | undefined;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { clipWithFilePointer } from "../../core/tool-errors.js";
|
|
3
|
-
import { sha256, similarNameSuggestion, OVERSIZE_READ_ESCAPE_HINT, } from "./safety.js";
|
|
3
|
+
import { sha256, similarNameSuggestion, OVERSIZE_READ_ESCAPE_HINT, PARTIAL_VIEW_READ_ESCAPE_HINT, } from "./safety.js";
|
|
4
4
|
import { decodeTextBytes, normalizeFileText } from "./encoding.js";
|
|
5
5
|
import { shellQuote } from "./search.js";
|
|
6
6
|
import { isNotebookPath } from "./notebook.js";
|
|
@@ -30,10 +30,14 @@ export function decodeEditBytes(bytes, path) {
|
|
|
30
30
|
export function persistedTextOf(encoded) {
|
|
31
31
|
return decodeTextBytes(typeof encoded === "string" ? Buffer.from(encoded, "utf8") : encoded).text;
|
|
32
32
|
}
|
|
33
|
-
export async function notReadRefusalText(env, toolName, key, v, signal) {
|
|
33
|
+
export async function notReadRefusalText(env, toolName, key, v, signal, fallbackHint) {
|
|
34
34
|
const base = `Error (${toolName}): ${v.message}`;
|
|
35
|
+
if (v.partialView === true && !isNotebookPath(key))
|
|
36
|
+
return `${base} ${PARTIAL_VIEW_READ_ESCAPE_HINT}`;
|
|
35
37
|
const info = await env.fileInfo(key, signal);
|
|
36
|
-
|
|
38
|
+
if (info.ok && info.value.kind !== "directory" && info.value.size > MAX_READ_BYTES && !isNotebookPath(key))
|
|
39
|
+
return `${base} ${OVERSIZE_READ_ESCAPE_HINT}`;
|
|
40
|
+
return fallbackHint === undefined ? base : `${base} ${fallbackHint}`;
|
|
37
41
|
}
|
|
38
42
|
export const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024;
|
|
39
43
|
export const MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES = 64 * 1024 * 1024;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { defineTool, errorResult } from "../../core/tools.js";
|
|
4
|
-
import { sha256, resolveKey, violationText, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, deletionOldString, countOccurrences, } from "./safety.js";
|
|
5
|
-
import { decodeTextBytes, encodeTextForFile, normalizeEditText, normalizeFileText
|
|
4
|
+
import { sha256, resolveKey, violationText, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, deletionOldString, countOccurrences, WRITE_ENCODING_DEADLOCK_ESCAPE_HINT, } from "./safety.js";
|
|
5
|
+
import { decodeTextBytes, encodeTextForFile, normalizeEditText, normalizeFileText } from "./encoding.js";
|
|
6
6
|
import { MAX_EDIT_BYTES, formatByteSize, decodeEditBytes, persistedTextOf, notReadRefusalText, enoentMessage, FILE_STATE_TRAILER, FILE_PATH_PARAMS, ipynbRedirect, countLines, } from "./fs-shared.js";
|
|
7
7
|
async function gateToolWrite(hook, tool, path, key, content) {
|
|
8
8
|
if (hook === undefined)
|
|
@@ -218,7 +218,7 @@ export function createWriteFileTool(env, state, rootCanonical, cwdRef, additiona
|
|
|
218
218
|
...FILE_PATH_PARAMS,
|
|
219
219
|
content: Type.String({ description: "The content to write to the file" }),
|
|
220
220
|
}),
|
|
221
|
-
effect: "
|
|
221
|
+
effect: "write",
|
|
222
222
|
execute: async (args, ctx) => {
|
|
223
223
|
const { content } = args;
|
|
224
224
|
const path = fileArgPath(args);
|
|
@@ -237,8 +237,7 @@ export function createWriteFileTool(env, state, rootCanonical, cwdRef, additiona
|
|
|
237
237
|
if (exists.value) {
|
|
238
238
|
const notRead = requireRead(state, r.key);
|
|
239
239
|
if (notRead) {
|
|
240
|
-
return errorResult(
|
|
241
|
-
`(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead — e.g. \`rm\` + rewrite, or \`iconv\`.)`);
|
|
240
|
+
return errorResult(await notReadRefusalText(env, "Write", r.key, notRead, ctx.signal, WRITE_ENCODING_DEADLOCK_ESCAPE_HINT));
|
|
242
241
|
}
|
|
243
242
|
const readBin = await env.readBinaryFile(r.key, ctx.signal);
|
|
244
243
|
if (!readBin.ok)
|
|
@@ -253,16 +252,15 @@ export function createWriteFileTool(env, state, rootCanonical, cwdRef, additiona
|
|
|
253
252
|
const gated = await gateToolWrite(beforeWrite, "Write", path, r.key, content);
|
|
254
253
|
if (gated !== undefined)
|
|
255
254
|
return errorResult(gated);
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
const write = await env.writeFile(r.key, encodeTextForFile(outgoing.text, writeEncoding, "preserve"), ctx.signal);
|
|
255
|
+
const encodedWrite = encodeTextForFile(content, decodedPrev.encoding, "preserve");
|
|
256
|
+
const write = await env.writeFile(r.key, encodedWrite, ctx.signal);
|
|
259
257
|
if (!write.ok)
|
|
260
258
|
return errorResult(`Error (Write): cannot write "${path}": ${write.error.message}`);
|
|
261
|
-
const
|
|
262
|
-
state.set(r.key, { hash: sha256(
|
|
259
|
+
const persistedWrite = persistedTextOf(encodedWrite);
|
|
260
|
+
state.set(r.key, { hash: sha256(persistedWrite), totalLines: countLines(persistedWrite), truncated: false, lastReadAt: Date.now() });
|
|
263
261
|
return {
|
|
264
262
|
content: `The file ${path} has been updated successfully.${FILE_STATE_TRAILER}`,
|
|
265
|
-
details: { type: "update", filePath: path, content:
|
|
263
|
+
details: { type: "update", filePath: path, content: persistedWrite, originalFile },
|
|
266
264
|
};
|
|
267
265
|
}
|
|
268
266
|
const gatedCreate = await gateToolWrite(beforeWrite, "Write", path, r.key, content);
|
|
@@ -17,6 +17,7 @@ export type ReadFileState = Map<string, ReadEntry>;
|
|
|
17
17
|
export declare function sha256(content: string): string;
|
|
18
18
|
export interface FsViolation {
|
|
19
19
|
code: "path_not_in_root" | "not_read" | "stale" | "ambiguous_edit" | "invalid";
|
|
20
|
+
partialView?: true;
|
|
20
21
|
message: string;
|
|
21
22
|
}
|
|
22
23
|
export declare function isBlockedDevicePath(key: string): boolean;
|
|
@@ -44,6 +45,8 @@ export declare function canonicalizeTarget(env: ExecutionEnv, path: string, sign
|
|
|
44
45
|
export declare function violationText(toolName: string, v: FsViolation): string;
|
|
45
46
|
export declare function requireRead(state: ReadFileState, key: string): FsViolation | undefined;
|
|
46
47
|
export declare const OVERSIZE_READ_ESCAPE_HINT = "(This file is over the Read tool's whole-file byte cap, so a default Read is refused \u2014 read it in slices with explicit offset/limit to satisfy the read-first rule, or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
|
|
48
|
+
export declare const PARTIAL_VIEW_READ_ESCAPE_HINT = "(Your last Read of this file returned only a PARTIAL view \u2014 the output token cap paginated it, so a default Read will keep returning the same page. Re-read it with explicit offset/limit (start from the page marker's next-page hint) until you have seen the part you are about to change; an explicit slice that fits satisfies the read-first rule. Or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
|
|
49
|
+
export declare const WRITE_ENCODING_DEADLOCK_ESCAPE_HINT = "(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead \u2014 e.g. `rm` + rewrite, or `iconv`.)";
|
|
47
50
|
export declare function checkNoChange(oldString: string, newString: string): FsViolation | undefined;
|
|
48
51
|
export declare function checkStale(entry: ReadEntry, currentHash: string): FsViolation | undefined;
|
|
49
52
|
export declare function countOccurrences(haystack: string, needle: string): number;
|
package/dist/tools/fs/safety.js
CHANGED
|
@@ -38,17 +38,23 @@ const BLOCKED_DEVICE_PATHS = new Set([
|
|
|
38
38
|
"/dev/stdin", "/dev/stdout", "/dev/stderr", "/dev/tty", "/dev/console",
|
|
39
39
|
"/dev/fd/0", "/dev/fd/1", "/dev/fd/2",
|
|
40
40
|
]);
|
|
41
|
-
|
|
41
|
+
function isProcStdioFd(key) {
|
|
42
|
+
return key.startsWith("/proc/") && (key.endsWith("/fd/0") || key.endsWith("/fd/1") || key.endsWith("/fd/2"));
|
|
43
|
+
}
|
|
44
|
+
const PROC_SENSITIVE_RE = /^\/proc\/[^/]+\/(environ|cmdline|auxv|maps|mem|stat)$/;
|
|
42
45
|
const WIN_RESERVED_RE = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.[^\\/]*)?$/i;
|
|
46
|
+
function isWinFormPath(p) {
|
|
47
|
+
return /^[A-Za-z]:[\\/]/.test(p) || p.startsWith("\\\\") || (!p.startsWith("/") && p.includes("\\"));
|
|
48
|
+
}
|
|
43
49
|
function isWinReservedDeviceKey(key) {
|
|
44
|
-
if (!(
|
|
50
|
+
if (!isWinFormPath(key))
|
|
45
51
|
return false;
|
|
46
52
|
const t = key.replace(/[\\/]+$/, "");
|
|
47
53
|
const base = t.slice(Math.max(t.lastIndexOf("/"), t.lastIndexOf("\\")) + 1);
|
|
48
54
|
return WIN_RESERVED_RE.test(base);
|
|
49
55
|
}
|
|
50
56
|
export function isBlockedDevicePath(key) {
|
|
51
|
-
return BLOCKED_DEVICE_PATHS.has(key) ||
|
|
57
|
+
return BLOCKED_DEVICE_PATHS.has(key) || isProcStdioFd(key) || PROC_SENSITIVE_RE.test(key) || isWinReservedDeviceKey(key);
|
|
52
58
|
}
|
|
53
59
|
export function normalizeAbsPathLexically(p) {
|
|
54
60
|
if (!p.startsWith("/"))
|
|
@@ -168,10 +174,11 @@ export async function resolveKey(env, rootCanonical, path, signal, baseCwd, addi
|
|
|
168
174
|
return { ok: true, key };
|
|
169
175
|
}
|
|
170
176
|
export async function canonicalizeTarget(env, path, signal, baseCwd) {
|
|
171
|
-
if (isUncPath(path)) {
|
|
172
|
-
return { ok:
|
|
177
|
+
if (isUncPath(path) && isWinFormPath(path)) {
|
|
178
|
+
return { ok: true, key: path };
|
|
173
179
|
}
|
|
174
|
-
const
|
|
180
|
+
const spelled = path.startsWith("//") ? path.replace(/^\/+/, "/") : path;
|
|
181
|
+
const target = baseCwd && !isAbsolutePathForm(spelled) ? `${baseCwd.replace(/[\\/]+$/, "")}/${spelled}` : spelled;
|
|
175
182
|
const absR = await env.absolutePath(target, signal);
|
|
176
183
|
if (!absR.ok)
|
|
177
184
|
return { ok: false, message: `cannot resolve path "${path}": ${absR.error.message}` };
|
|
@@ -230,11 +237,17 @@ export function violationText(toolName, v) {
|
|
|
230
237
|
export function requireRead(state, key) {
|
|
231
238
|
const entry = state.get(key);
|
|
232
239
|
if (entry === undefined || entry.isPartialView) {
|
|
233
|
-
return {
|
|
240
|
+
return {
|
|
241
|
+
code: "not_read",
|
|
242
|
+
message: "File has not been read yet. Read it first before writing to it.",
|
|
243
|
+
...(entry?.isPartialView ? { partialView: true } : {}),
|
|
244
|
+
};
|
|
234
245
|
}
|
|
235
246
|
return undefined;
|
|
236
247
|
}
|
|
237
248
|
export const OVERSIZE_READ_ESCAPE_HINT = "(This file is over the Read tool's whole-file byte cap, so a default Read is refused — read it in slices with explicit offset/limit to satisfy the read-first rule, or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
|
|
249
|
+
export const PARTIAL_VIEW_READ_ESCAPE_HINT = "(Your last Read of this file returned only a PARTIAL view — the output token cap paginated it, so a default Read will keep returning the same page. Re-read it with explicit offset/limit (start from the page marker's next-page hint) until you have seen the part you are about to change; an explicit slice that fits satisfies the read-first rule. Or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
|
|
250
|
+
export const WRITE_ENCODING_DEADLOCK_ESCAPE_HINT = "(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead — e.g. `rm` + rewrite, or `iconv`.)";
|
|
238
251
|
export function checkNoChange(oldString, newString) {
|
|
239
252
|
if (oldString === newString) {
|
|
240
253
|
return { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." };
|
package/dist/tools/fs/search.js
CHANGED
|
@@ -1089,6 +1089,13 @@ export async function runGlobDetailed(env, root, pattern, opts = {}, signal) {
|
|
|
1089
1089
|
const baseIgnore = await buildIgnore(env, root, signal);
|
|
1090
1090
|
const rootPrefix = root.replace(/[\\/]+$/, "") + (root.includes("\\") ? "\\" : "/");
|
|
1091
1091
|
const start = opts.path ? (opts.path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(opts.path) ? opts.path : `${rootPrefix}${opts.path}`) : root;
|
|
1092
|
+
if (opts.path !== undefined) {
|
|
1093
|
+
const probe = await env.fileInfo(start, signal);
|
|
1094
|
+
if (!probe.ok && probe.error.code === "not_found") {
|
|
1095
|
+
const error = `Error (Glob): path ${JSON.stringify(opts.path)} does not exist — check the path, or omit \`path\` to search the whole root.`;
|
|
1096
|
+
return { text: error, error, filenames: [], numFiles: 0, truncated: false, durationMs: Date.now() - t0, totalMatches: 0, countIsComplete: false };
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1092
1099
|
const pat = normalizeGlobToken(pattern);
|
|
1093
1100
|
const anchored = globIsAnchored(pattern);
|
|
1094
1101
|
const re = globTokenToRegExp(pat, anchored, true);
|
|
@@ -1131,6 +1138,7 @@ export async function runGlobDetailed(env, root, pattern, opts = {}, signal) {
|
|
|
1131
1138
|
return k < starStarIdx ? literalAt[k] === segs[k] : literalAfterStarStar.has(segs[k]);
|
|
1132
1139
|
};
|
|
1133
1140
|
let ignoredDirs = 0;
|
|
1141
|
+
let ignoredFiles = 0;
|
|
1134
1142
|
const rescued = [];
|
|
1135
1143
|
const ignore = (relPath, isDir) => {
|
|
1136
1144
|
const sr = toStartRel(relPath);
|
|
@@ -1144,8 +1152,12 @@ export async function runGlobDetailed(env, root, pattern, opts = {}, signal) {
|
|
|
1144
1152
|
if (rescued.length > 0 && rescued.some((r) => sr.startsWith(r)))
|
|
1145
1153
|
return false;
|
|
1146
1154
|
const ig = baseIgnore(relPath, isDir);
|
|
1147
|
-
if (ig
|
|
1148
|
-
|
|
1155
|
+
if (ig) {
|
|
1156
|
+
if (isDir)
|
|
1157
|
+
ignoredDirs++;
|
|
1158
|
+
else
|
|
1159
|
+
ignoredFiles++;
|
|
1160
|
+
}
|
|
1149
1161
|
return ig;
|
|
1150
1162
|
};
|
|
1151
1163
|
const walked = await walk(env, root, start, ignore, signal);
|
|
@@ -1173,6 +1185,12 @@ export async function runGlobDetailed(env, root, pattern, opts = {}, signal) {
|
|
|
1173
1185
|
const ignoreNote = matched.length === 0 && ignoredDirs > 0
|
|
1174
1186
|
? `\n[note: ${ignoredDirs} ignored director${ignoredDirs === 1 ? "y was" : "ies were"} not searched (dependency/build/VCS trees and .gitignore) — name a directory in the pattern (e.g. "dist/**") or pass \`path\` to include it]`
|
|
1175
1187
|
: "";
|
|
1176
|
-
const
|
|
1188
|
+
const ignoredFileNote = matched.length === 0 && ignoredFiles > 0
|
|
1189
|
+
? `\n[note: ${ignoredFiles} file(s) matching an ignore rule (.gitignore) were skipped — an empty result here is NOT proof the file is absent; name it literally with a root-anchored path (e.g. "/notes.secret", "logs/app.log") to include it, or read it directly]`
|
|
1190
|
+
: "";
|
|
1191
|
+
const budgetNote = matched.length === 0 && walked.incomplete
|
|
1192
|
+
? `\n[note: the file-walk budget (${WALK_MAX_FILES} files / ${WALK_MAX_DEPTH} directory levels) ran out before the whole tree was scanned — this empty result is NOT proof the file is absent; scope the search with \`path\`, or use a root-anchored pattern (e.g. "src/**/*.ts") so the walk is pruned toward it, then retry]`
|
|
1193
|
+
: "";
|
|
1194
|
+
const text = matched.length === 0 ? "No files matched." + ignoreNote + ignoredFileNote + budgetNote + caveat : matched.join("\n") + capNote + caveat;
|
|
1177
1195
|
return { text, filenames: matched, numFiles: matched.length, truncated, durationMs: Date.now() - t0, totalMatches, countIsComplete };
|
|
1178
1196
|
}
|
|
@@ -20,5 +20,6 @@ export interface TaskListStore {
|
|
|
20
20
|
}
|
|
21
21
|
export declare function normalizeTaskShape<T>(item: T): T;
|
|
22
22
|
export declare function assertJsonMetadata(value: unknown, path?: string): void;
|
|
23
|
+
export declare function compareTaskIds(a: string, b: string): number;
|
|
23
24
|
export declare function createMemoryTaskListStore(): TaskListStore;
|
|
24
25
|
export declare function createTaskListTools(store?: TaskListStore): ToolSpec[];
|