@zq-silk/yui 2.0.0 → 2.2.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 +4 -0
- package/dist/cli/commandCatalog.js +318 -289
- package/dist/cli/commandDiscovery.js +72 -0
- package/dist/cli/completion.js +22 -227
- package/dist/cli/dynamicCompletion.js +12 -6
- package/dist/cli/invocationAuthority.js +2 -2
- package/dist/cli/invocationRouter.js +18 -7
- package/dist/cli.js +23 -3030
- package/dist/commands/capabilityCommands.js +5 -5
- package/dist/commands/globalRoleCommands.js +2 -1
- package/dist/commands/taskCommands.js +90 -21
- package/dist/commands/taskIntegrationCommands.js +3 -1
- package/dist/commands/taskUpstreamCommands.js +3 -1
- package/dist/context/runContextPack.js +132 -5
- package/dist/context/sourceRunContext.js +4 -2
- package/dist/context/taskContext.js +12 -3
- package/dist/controlPlaneCli.js +3092 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +19 -6
- package/dist/controller/jobControl.js +21 -0
- package/dist/executor/agentExecutor.js +1 -1
- package/dist/executor/effectiveLaunch.js +18 -5
- package/dist/executor/fileRoleLaunchPlanner.js +15 -19
- package/dist/integration/gitIntegrationService.js +50 -15
- package/dist/kernel/accessAssessment.js +1 -0
- package/dist/kernel/builtinCapabilities.js +28 -1
- package/dist/kernel/capabilityRegistry.js +64 -19
- package/dist/message/messageContinuation.js +7 -2
- package/dist/nativeAgent/agent.js +275 -0
- package/dist/nativeAgent/cliDemo.js +37 -0
- package/dist/nativeAgent/codingTools.js +11 -0
- package/dist/nativeAgent/commandTool.js +215 -0
- package/dist/nativeAgent/compactionDemo.js +158 -0
- package/dist/nativeAgent/composition.js +71 -0
- package/dist/nativeAgent/context/budget.js +44 -0
- package/dist/nativeAgent/context/index.js +339 -0
- package/dist/nativeAgent/context/providerCompressor.js +103 -0
- package/dist/nativeAgent/contracts.js +1 -0
- package/dist/nativeAgent/demo.js +40 -0
- package/dist/nativeAgent/evaluation/cases.js +38 -0
- package/dist/nativeAgent/evaluation/checks.js +91 -0
- package/dist/nativeAgent/evaluation/demo.js +19 -0
- package/dist/nativeAgent/evaluation/files.js +54 -0
- package/dist/nativeAgent/evaluation/fixture.js +36 -0
- package/dist/nativeAgent/evaluation/index.js +239 -0
- package/dist/nativeAgent/executionOwner.js +209 -0
- package/dist/nativeAgent/filePatterns.js +170 -0
- package/dist/nativeAgent/fileToolsSupport.js +202 -0
- package/dist/nativeAgent/index.js +16 -0
- package/dist/nativeAgent/interaction/cli.js +358 -0
- package/dist/nativeAgent/interaction/contracts.js +1 -0
- package/dist/nativeAgent/interaction/index.js +3 -0
- package/dist/nativeAgent/interaction/memoryDemo.js +115 -0
- package/dist/nativeAgent/interaction/renderer.js +34 -0
- package/dist/nativeAgent/localSafety.js +258 -0
- package/dist/nativeAgent/mockProvider.js +43 -0
- package/dist/nativeAgent/model/anthropicMessages.js +204 -0
- package/dist/nativeAgent/model/chatCompletions.js +210 -0
- package/dist/nativeAgent/model/errors.js +47 -0
- package/dist/nativeAgent/model/gateway.js +423 -0
- package/dist/nativeAgent/model/index.js +7 -0
- package/dist/nativeAgent/model/observationAdapter.js +21 -0
- package/dist/nativeAgent/model/protocols.js +19 -0
- package/dist/nativeAgent/model/responses.js +263 -0
- package/dist/nativeAgent/model/types.js +1 -0
- package/dist/nativeAgent/model/wire.js +73 -0
- package/dist/nativeAgent/observability/index.js +220 -0
- package/dist/nativeAgent/product/catalog.js +82 -0
- package/dist/nativeAgent/product/config.js +295 -0
- package/dist/nativeAgent/product/facts.js +30 -0
- package/dist/nativeAgent/product/index.js +62 -0
- package/dist/nativeAgent/product/location.js +44 -0
- package/dist/nativeAgent/product/runtime.js +276 -0
- package/dist/nativeAgent/product/storage.js +49 -0
- package/dist/nativeAgent/product/tools.js +47 -0
- package/dist/nativeAgent/product/transport.js +54 -0
- package/dist/nativeAgent/projectGuidance/index.js +425 -0
- package/dist/nativeAgent/searchTools.js +305 -0
- package/dist/nativeAgent/session/backends.js +293 -0
- package/dist/nativeAgent/session/catalog.js +97 -0
- package/dist/nativeAgent/session/catalogDemo.js +87 -0
- package/dist/nativeAgent/session/contracts.js +1 -0
- package/dist/nativeAgent/session/format.js +269 -0
- package/dist/nativeAgent/session/index.js +5 -0
- package/dist/nativeAgent/session/location.js +36 -0
- package/dist/nativeAgent/session/sqliteFormat.js +134 -0
- package/dist/nativeAgent/session/store.js +248 -0
- package/dist/nativeAgent/textTools.js +293 -0
- package/dist/nativeAgent/toolManager/executor.js +290 -0
- package/dist/nativeAgent/toolManager/index.js +4 -0
- package/dist/nativeAgent/validation.js +95 -0
- package/dist/plugins/pluginService.js +98 -8
- package/dist/resources/projectResourceService.js +23 -1
- package/dist/runtime/managedIdentity.js +6 -0
- package/dist/surface/surfaceContributions.js +3 -0
- package/dist/task/taskAuthority.js +61 -10
- package/dist/web/assets/assetManifest.js +38 -22
- package/dist/web/assets/client/api.js +115 -0
- package/dist/web/assets/client/app.js +615 -862
- package/dist/web/assets/client/components.js +323 -897
- package/dist/web/assets/client/detail.js +333 -0
- package/dist/web/assets/client/dock.js +225 -0
- package/dist/web/assets/client/dom.js +55 -3
- package/dist/web/assets/client/evidence.js +172 -0
- package/dist/web/assets/client/format.js +25 -14
- package/dist/web/assets/client/forms.js +230 -0
- package/dist/web/assets/client/i18n.js +1024 -791
- package/dist/web/assets/client/overview.js +118 -0
- package/dist/web/assets/client/records.js +63 -0
- package/dist/web/assets/client/sections.js +313 -0
- package/dist/web/assets/client/sidebar.js +146 -0
- package/dist/web/assets/client/theme.js +61 -22
- package/dist/web/assets/icons.js +39 -0
- package/dist/web/assets/shell.js +125 -118
- package/dist/web/assets/styles/base.js +41 -0
- package/dist/web/assets/styles/components.js +169 -0
- package/dist/web/assets/styles/layout.js +56 -77
- package/dist/web/assets/styles/markdown.js +16 -24
- package/dist/web/assets/styles/responsive.js +41 -44
- package/dist/web/assets/styles/tokens.js +65 -89
- package/dist/web/assets/styles/views.js +292 -0
- package/dist/web/webServer.js +2 -1
- package/docs/agent-result-consumption.md +16 -0
- package/docs/agent-result-consumption.zh-CN.md +13 -0
- package/docs/examples/agent-offline.mjs +194 -0
- package/docs/native-agent.md +283 -0
- package/docs/release-workflow.md +61 -0
- package/docs/release-workflow.zh-CN.md +46 -0
- package/docs/roles-and-configuration.md +32 -0
- package/docs/roles-and-configuration.zh-CN.md +26 -0
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +9 -0
- package/skills/yui-reviewer/SKILL.md +5 -0
- package/skills/yui-runtime/SKILL.md +33 -0
- package/dist/web/assets/client/taskSummary.js +0 -350
- package/dist/web/assets/client/taskSurface.js +0 -616
- package/dist/web/assets/client/view.js +0 -608
- package/dist/web/assets/styles/cards.js +0 -247
- package/dist/web/assets/styles/widgets.js +0 -168
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { digest } from './backends.js';
|
|
2
|
+
import { copyEvent, identity, immutable, inspectDocument, revision, SessionError, sessionLimits } from './format.js';
|
|
3
|
+
import { metadataRevision, normalizeTitle, queryBounds } from './catalog.js';
|
|
4
|
+
import { copyLocation } from './location.js';
|
|
5
|
+
export class SessionSaveError extends SessionError {
|
|
6
|
+
sessionId;
|
|
7
|
+
expectedRevision;
|
|
8
|
+
event;
|
|
9
|
+
location;
|
|
10
|
+
/** Conservative: a thrown backend write is not proof that no commit occurred. */
|
|
11
|
+
effect = 'unknown';
|
|
12
|
+
constructor(sessionId, expectedRevision, event, cause, location) {
|
|
13
|
+
super('save_failed', 'Necessary Session save failed; stop new effects and inspect exact saved facts', { cause });
|
|
14
|
+
this.sessionId = sessionId;
|
|
15
|
+
this.expectedRevision = expectedRevision;
|
|
16
|
+
this.event = event;
|
|
17
|
+
this.location = location;
|
|
18
|
+
this.name = 'SessionSaveError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class SessionMetadataSaveError extends SessionError {
|
|
22
|
+
sessionId;
|
|
23
|
+
title;
|
|
24
|
+
expectedMetadataRevision;
|
|
25
|
+
effect = 'unknown';
|
|
26
|
+
constructor(sessionId, title, expectedMetadataRevision, cause) {
|
|
27
|
+
super('metadata_save_failed', 'Metadata save confirmation failed; inspect exact title/revision before another write', { cause });
|
|
28
|
+
this.sessionId = sessionId;
|
|
29
|
+
this.title = title;
|
|
30
|
+
this.expectedMetadataRevision = expectedMetadataRevision;
|
|
31
|
+
this.name = 'SessionMetadataSaveError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function createSessionStore(backend) {
|
|
35
|
+
identity(backend.source.id);
|
|
36
|
+
const source = immutable(structuredClone(backend.source));
|
|
37
|
+
if (!['volatile', 'persistent'].includes(source.durability)) {
|
|
38
|
+
throw new SessionError('invalid_backend', 'Backend must advertise its persistence boundary');
|
|
39
|
+
}
|
|
40
|
+
let closed = false;
|
|
41
|
+
let backendClosed = false;
|
|
42
|
+
const observers = new Set();
|
|
43
|
+
const check = () => { if (closed)
|
|
44
|
+
throw new SessionError('closed', 'Session store is closed'); };
|
|
45
|
+
const catalog = () => {
|
|
46
|
+
check();
|
|
47
|
+
if (!backend.catalog)
|
|
48
|
+
throw new SessionError('unsupported_catalog', 'Backend does not implement the bounded SessionCatalog port');
|
|
49
|
+
return backend.catalog;
|
|
50
|
+
};
|
|
51
|
+
const read = async (operation) => {
|
|
52
|
+
check();
|
|
53
|
+
try {
|
|
54
|
+
const value = await operation();
|
|
55
|
+
check();
|
|
56
|
+
return immutable(value);
|
|
57
|
+
}
|
|
58
|
+
catch (cause) {
|
|
59
|
+
if (cause instanceof SessionError)
|
|
60
|
+
throw cause;
|
|
61
|
+
throw new SessionError('read_failed', 'Session query failed; no empty-page fallback', { cause });
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const receipt = (document) => immutable({
|
|
65
|
+
sessionId: document.sessionId, revision: revision(document), digest: digest(document), source,
|
|
66
|
+
});
|
|
67
|
+
const dispatch = (observer) => {
|
|
68
|
+
if (observer.busy || !observer.active || !observer.pending)
|
|
69
|
+
return;
|
|
70
|
+
observer.busy = true;
|
|
71
|
+
queueMicrotask(() => {
|
|
72
|
+
const next = observer.pending;
|
|
73
|
+
observer.pending = undefined;
|
|
74
|
+
if (!observer.active || !next) {
|
|
75
|
+
observer.busy = false;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// An observer has at most one in-flight callback and one coalesced receipt.
|
|
79
|
+
// A hung UI never blocks a commit or creates an unbounded notification queue.
|
|
80
|
+
void Promise.resolve().then(() => {
|
|
81
|
+
if (observer.active)
|
|
82
|
+
return observer.callback(next);
|
|
83
|
+
}).catch(() => {
|
|
84
|
+
observer.active = false;
|
|
85
|
+
observers.delete(observer);
|
|
86
|
+
}).finally(() => {
|
|
87
|
+
observer.busy = false;
|
|
88
|
+
dispatch(observer);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
const notify = (saved) => {
|
|
93
|
+
for (const observer of observers) {
|
|
94
|
+
if (observer.sessionId === saved.sessionId) {
|
|
95
|
+
observer.pending = saved;
|
|
96
|
+
dispatch(observer);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const save = async (document, expected, location) => {
|
|
101
|
+
check();
|
|
102
|
+
try {
|
|
103
|
+
if (location === undefined)
|
|
104
|
+
await backend.write(immutable(document), expected);
|
|
105
|
+
else
|
|
106
|
+
await backend.createWithLocation(immutable(document), location);
|
|
107
|
+
}
|
|
108
|
+
catch (cause) {
|
|
109
|
+
throw new SessionSaveError(document.sessionId, expected, document.events.at(-1), cause, location);
|
|
110
|
+
}
|
|
111
|
+
const saved = receipt(document);
|
|
112
|
+
notify(saved);
|
|
113
|
+
return saved;
|
|
114
|
+
};
|
|
115
|
+
const store = {
|
|
116
|
+
async listSessions(options = {}) { return read(() => catalog().listSessions(options)); },
|
|
117
|
+
async getSessionInfo(sessionId) {
|
|
118
|
+
identity(sessionId);
|
|
119
|
+
return read(() => catalog().getSessionInfo(sessionId));
|
|
120
|
+
},
|
|
121
|
+
async readHistory(sessionId, options = {}) {
|
|
122
|
+
identity(sessionId);
|
|
123
|
+
return read(() => catalog().readHistory(sessionId, options));
|
|
124
|
+
},
|
|
125
|
+
async renameSession(sessionId, title, expected) {
|
|
126
|
+
check();
|
|
127
|
+
identity(sessionId);
|
|
128
|
+
const normalized = normalizeTitle(title);
|
|
129
|
+
metadataRevision(expected);
|
|
130
|
+
const port = catalog();
|
|
131
|
+
try {
|
|
132
|
+
return immutable(await port.renameSession(sessionId, normalized, expected));
|
|
133
|
+
}
|
|
134
|
+
catch (cause) {
|
|
135
|
+
// These port errors are confirmed precondition refusals, not ambiguous saves.
|
|
136
|
+
if (cause instanceof SessionError && ['revision_conflict', 'not_found', 'closed'].includes(cause.code))
|
|
137
|
+
throw cause;
|
|
138
|
+
throw new SessionMetadataSaveError(sessionId, normalized, expected, cause);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
async create(sessionId, location) {
|
|
142
|
+
check();
|
|
143
|
+
identity(sessionId);
|
|
144
|
+
const selected = location === undefined ? undefined : immutable(copyLocation(location));
|
|
145
|
+
if (selected !== undefined && (!backend.createWithLocation || !backend.catalog)) {
|
|
146
|
+
throw new SessionError('unsupported_location', 'Backend lacks atomic location creation and bounded detail reconciliation');
|
|
147
|
+
}
|
|
148
|
+
return save({ schemaVersion: 2, sessionId, events: [] }, null, selected);
|
|
149
|
+
},
|
|
150
|
+
async load(sessionId) {
|
|
151
|
+
check();
|
|
152
|
+
identity(sessionId);
|
|
153
|
+
const raw = await backend.read(sessionId);
|
|
154
|
+
check();
|
|
155
|
+
if (raw === null)
|
|
156
|
+
throw new SessionError('not_found', 'Session not found');
|
|
157
|
+
const state = inspectDocument(raw);
|
|
158
|
+
if (state.document.sessionId !== sessionId)
|
|
159
|
+
throw new SessionError('invalid_session', 'Backend returned another Session');
|
|
160
|
+
return immutable({ ...receipt(state.document), ...state });
|
|
161
|
+
},
|
|
162
|
+
async append(event, expectedRevision) {
|
|
163
|
+
check();
|
|
164
|
+
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
|
165
|
+
throw new SessionError('revision_conflict', 'Expected revision must be a nonnegative integer');
|
|
166
|
+
}
|
|
167
|
+
const next = copyEvent(event);
|
|
168
|
+
const previous = await store.load(next.sessionId);
|
|
169
|
+
if (previous.revision !== expectedRevision) {
|
|
170
|
+
throw new SessionError('revision_conflict', 'Session revision conflict; read current facts');
|
|
171
|
+
}
|
|
172
|
+
const state = inspectDocument({ ...previous.document, events: [...previous.document.events, next] });
|
|
173
|
+
return save(state.document, expectedRevision);
|
|
174
|
+
},
|
|
175
|
+
async query(sessionId, options = {}) {
|
|
176
|
+
const after = options.after ?? 0;
|
|
177
|
+
const limit = options.limit ?? 50;
|
|
178
|
+
queryBounds(after, limit);
|
|
179
|
+
if (backend.query) {
|
|
180
|
+
identity(sessionId);
|
|
181
|
+
return read(() => backend.query(sessionId, { after, limit }));
|
|
182
|
+
}
|
|
183
|
+
const loaded = await store.load(sessionId);
|
|
184
|
+
if (after > loaded.revision)
|
|
185
|
+
throw new SessionError('invalid_query', 'Cursor exceeds the saved revision');
|
|
186
|
+
const end = Math.min(after + limit, loaded.revision);
|
|
187
|
+
return immutable({ ...receipt(loaded.document),
|
|
188
|
+
records: loaded.document.events.slice(after, end).map((event, offset) => ({ revision: after + offset + 1, event })),
|
|
189
|
+
nextCursor: end < loaded.revision ? end : null,
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
subscribe(sessionId, callback) {
|
|
193
|
+
check();
|
|
194
|
+
identity(sessionId);
|
|
195
|
+
if (typeof callback !== 'function' || observers.size >= sessionLimits.subscribers) {
|
|
196
|
+
throw new SessionError('invalid_subscription', 'Expected observer within subscription limit');
|
|
197
|
+
}
|
|
198
|
+
const observer = { sessionId, callback, active: true, busy: false };
|
|
199
|
+
observers.add(observer);
|
|
200
|
+
return () => { observer.active = false; observer.pending = undefined; observers.delete(observer); };
|
|
201
|
+
},
|
|
202
|
+
async recorder(sessionId) {
|
|
203
|
+
const saved = await store.load(sessionId);
|
|
204
|
+
if (saved.recovery.disposition !== 'ready') {
|
|
205
|
+
throw new SessionError('recovery_required', 'Session requires explicit recovery; automatic replay is forbidden');
|
|
206
|
+
}
|
|
207
|
+
let lastReceipt = receipt(saved.document);
|
|
208
|
+
let failure;
|
|
209
|
+
let busy = false;
|
|
210
|
+
return {
|
|
211
|
+
get lastReceipt() { return lastReceipt; },
|
|
212
|
+
get failure() { return failure; },
|
|
213
|
+
async record(event) {
|
|
214
|
+
if (failure)
|
|
215
|
+
throw new SessionError('recorder_failed', 'Session recorder is stopped after failure', { cause: failure });
|
|
216
|
+
if (busy)
|
|
217
|
+
throw new SessionError('recorder_busy', 'Session recorder requires sequential facts');
|
|
218
|
+
busy = true;
|
|
219
|
+
try {
|
|
220
|
+
if (event.sessionId !== sessionId)
|
|
221
|
+
throw new SessionError('invalid_session', 'Recorder Session mismatch');
|
|
222
|
+
lastReceipt = await store.append(event, lastReceipt.revision);
|
|
223
|
+
}
|
|
224
|
+
catch (cause) {
|
|
225
|
+
failure = cause instanceof Error ? cause : new Error(String(cause));
|
|
226
|
+
throw failure;
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
busy = false;
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
async close() {
|
|
235
|
+
if (backendClosed)
|
|
236
|
+
return;
|
|
237
|
+
closed = true;
|
|
238
|
+
for (const observer of observers) {
|
|
239
|
+
observer.active = false;
|
|
240
|
+
observer.pending = undefined;
|
|
241
|
+
}
|
|
242
|
+
observers.clear();
|
|
243
|
+
await backend.close();
|
|
244
|
+
backendClosed = true;
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
return store;
|
|
248
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { open, rename, unlink } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { FileFault, bounded, workspaceRoot, validPath, checkPath, readSnapshot, digest, fits, cursor, readCursor, replace, diff, assertText } from './fileToolsSupport.js';
|
|
5
|
+
const fail = (code, message) => ({ code, message, effect: 'none' });
|
|
6
|
+
const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
7
|
+
/** Controlled directories only. Queues are same-pack, not cross-process CAS or a multi-file transaction. */
|
|
8
|
+
export function createTextTools(options) {
|
|
9
|
+
const root = workspaceRoot(options.root);
|
|
10
|
+
const maxBytes = bounded(options.maxBytes, 8 * 1024 * 1024, 'maxBytes');
|
|
11
|
+
const maxOutputBytes = bounded(options.maxOutputBytes, 64 * 1024, 'maxOutputBytes');
|
|
12
|
+
const writers = new Map();
|
|
13
|
+
const targetsFor = (args, name) => name === 'patch'
|
|
14
|
+
? args.files : name === 'edit' ? [{
|
|
15
|
+
path: args.path, expectedSha256: args.expectedSha256,
|
|
16
|
+
edits: [{ oldText: args.oldText, newText: args.newText }],
|
|
17
|
+
}] : [args];
|
|
18
|
+
const validate = (args, name) => {
|
|
19
|
+
if (!object(args))
|
|
20
|
+
return fail('invalid_arguments', 'Expected an argument object');
|
|
21
|
+
const keys = name === 'patch' ? ['files'] : name === 'read' ? ['path', 'startLine', 'limit', 'cursor']
|
|
22
|
+
: name === 'edit' ? ['path', 'expectedSha256', 'oldText', 'newText'] : ['path', 'content', 'expectedSha256'];
|
|
23
|
+
if (Object.keys(args).some(key => !keys.includes(key)))
|
|
24
|
+
return fail('invalid_arguments', 'Unexpected tool fields');
|
|
25
|
+
if (Buffer.byteLength(JSON.stringify(args)) > 64 * 1024)
|
|
26
|
+
return fail('too_large', 'Arguments exceed 64 KiB');
|
|
27
|
+
if (name === 'read') {
|
|
28
|
+
if (!validPath(args.path))
|
|
29
|
+
return fail(typeof args.path === 'string' && (path.isAbsolute(args.path)
|
|
30
|
+
|| args.path.split(/[\\/]/).includes('..')) ? 'path_out_of_scope' : 'invalid_arguments', 'Expected a bounded relative file path');
|
|
31
|
+
if (['startLine', 'limit'].some(key => args[key] !== undefined && (typeof args[key] !== 'number'
|
|
32
|
+
|| !Number.isSafeInteger(args[key]) || args[key] < 1 || args[key] > 1_000_000))
|
|
33
|
+
|| (args.cursor !== undefined && (typeof args.cursor !== 'string' || !args.cursor || args.cursor.length > 2048))) {
|
|
34
|
+
return fail('invalid_arguments', 'Expected positive startLine/limit and an optional cursor');
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (name === 'patch' && (!Array.isArray(args.files) || !args.files.length || args.files.length > 16
|
|
39
|
+
|| !args.files.every(object)))
|
|
40
|
+
return fail('invalid_arguments', 'patch expects 1..16 existing files');
|
|
41
|
+
const targets = targetsFor(args, name);
|
|
42
|
+
for (const target of targets) {
|
|
43
|
+
if (!validPath(target.path))
|
|
44
|
+
return fail(typeof target.path === 'string' && (path.isAbsolute(target.path)
|
|
45
|
+
|| target.path.split(/[\\/]/).includes('..')) ? 'path_out_of_scope' : 'invalid_arguments', 'Expected bounded relative file paths');
|
|
46
|
+
if (name === 'patch' && Object.keys(target).some(key => !['path', 'expectedSha256', 'edits'].includes(key))) {
|
|
47
|
+
return fail('invalid_arguments', 'Unexpected patch file fields');
|
|
48
|
+
}
|
|
49
|
+
if (target.expectedSha256 !== undefined && (typeof target.expectedSha256 !== 'string'
|
|
50
|
+
|| !/^[a-f0-9]{64}$/.test(target.expectedSha256)))
|
|
51
|
+
return fail('invalid_arguments', 'Expected lowercase SHA-256');
|
|
52
|
+
if (name === 'write') {
|
|
53
|
+
if (typeof target.content !== 'string')
|
|
54
|
+
return fail('invalid_arguments', 'Expected content');
|
|
55
|
+
if (Buffer.byteLength(target.content) > maxBytes)
|
|
56
|
+
return fail('too_large', 'Text exceeds byte limit');
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
if (typeof target.expectedSha256 !== 'string' || !Array.isArray(target.edits) || !target.edits.length
|
|
60
|
+
|| target.edits.length > 32 || target.edits.some(edit => !object(edit)
|
|
61
|
+
|| Object.keys(edit).some(key => !['oldText', 'newText'].includes(key))
|
|
62
|
+
|| typeof edit.oldText !== 'string' || !edit.oldText || typeof edit.newText !== 'string')) {
|
|
63
|
+
return fail('invalid_arguments', 'Expected 1..32 exact edits and a SHA-256 precondition');
|
|
64
|
+
}
|
|
65
|
+
if (target.edits.some(edit => [edit.oldText, edit.newText].some(text => Buffer.byteLength(text) > maxBytes))) {
|
|
66
|
+
return fail('too_large', 'Text exceeds byte limit');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
};
|
|
72
|
+
const editSchema = { type: 'object', properties: { oldText: { type: 'string' }, newText: { type: 'string' } },
|
|
73
|
+
required: ['oldText', 'newText'], additionalProperties: false };
|
|
74
|
+
return ['read', 'write', 'edit', 'patch'].map(name => ({
|
|
75
|
+
definition: {
|
|
76
|
+
name,
|
|
77
|
+
description: name === 'read' ? 'Read UTF-8 pages with full-file SHA-256, exact byte offsets and request-bound cursor'
|
|
78
|
+
: name === 'patch' ? 'Unique nonoverlapping original-text edits to existing files; preflight all, sequential commits, bounded actual diffs; partial failures stop'
|
|
79
|
+
: name === 'edit' ? 'Replace one unique literal match with expected SHA-256 and actual diff'
|
|
80
|
+
: 'Create or replace UTF-8 text with SHA-256 precondition and actual diff',
|
|
81
|
+
inputSchema: { type: 'object', properties: name === 'patch' ? { files: { type: 'array', minItems: 1, maxItems: 16,
|
|
82
|
+
items: { type: 'object', properties: { path: { type: 'string' }, expectedSha256: { type: 'string' },
|
|
83
|
+
edits: { type: 'array', minItems: 1, maxItems: 32, items: editSchema } },
|
|
84
|
+
required: ['path', 'expectedSha256', 'edits'], additionalProperties: false } } }
|
|
85
|
+
: { path: { type: 'string' }, ...(name === 'read' ? { startLine: { type: 'integer' },
|
|
86
|
+
limit: { type: 'integer' }, cursor: { type: 'string' } } : { expectedSha256: { type: 'string' },
|
|
87
|
+
...(name === 'edit' ? editSchema.properties : { content: { type: 'string' } }) }) },
|
|
88
|
+
required: name === 'patch' ? ['files'] : name === 'read' ? ['path'] : name === 'edit'
|
|
89
|
+
? ['path', 'expectedSha256', 'oldText', 'newText'] : ['path', 'content'], additionalProperties: false },
|
|
90
|
+
},
|
|
91
|
+
validate: args => validate(args, name),
|
|
92
|
+
async execute(args, scope, signal) {
|
|
93
|
+
const invalid = validate(args, name);
|
|
94
|
+
if (invalid)
|
|
95
|
+
return { ok: false, error: invalid };
|
|
96
|
+
const outcome = (value) => ({ ok: true, content: JSON.stringify(value) });
|
|
97
|
+
const fit = (result) => fits(result, maxOutputBytes, name, scope.toolCallId);
|
|
98
|
+
const readText = async (target) => {
|
|
99
|
+
const snapshot = await readSnapshot(target, maxBytes, signal);
|
|
100
|
+
assertText(snapshot.text);
|
|
101
|
+
return snapshot;
|
|
102
|
+
};
|
|
103
|
+
const held = [];
|
|
104
|
+
const receipts = [];
|
|
105
|
+
let temporary;
|
|
106
|
+
let committing = false;
|
|
107
|
+
let index = 0;
|
|
108
|
+
try {
|
|
109
|
+
signal.throwIfAborted();
|
|
110
|
+
if (name === 'read') {
|
|
111
|
+
const request = args;
|
|
112
|
+
const snapshot = await readText(await checkPath(root, request.path));
|
|
113
|
+
const binding = digest(JSON.stringify([request.path, request.startLine ?? 1, request.limit ?? 200, maxBytes, maxOutputBytes]));
|
|
114
|
+
const fingerprint = snapshot.sha256 + ':' + snapshot.identity;
|
|
115
|
+
let start = readCursor(request.cursor, binding, fingerprint, snapshot.text.length);
|
|
116
|
+
if (!request.cursor) {
|
|
117
|
+
for (let line = 1; line < (request.startLine ?? 1); line++) {
|
|
118
|
+
const offset = snapshot.text.indexOf('\n', start);
|
|
119
|
+
if (offset < 0)
|
|
120
|
+
throw new FileFault('line_out_of_range', 'startLine is beyond EOF');
|
|
121
|
+
start = offset + 1;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (start > 0 && /[\uDC00-\uDFFF]/.test(snapshot.text[start] ?? ''))
|
|
125
|
+
throw new FileFault('invalid_cursor', 'Cursor splits a code point');
|
|
126
|
+
let end = start;
|
|
127
|
+
for (let count = 0; count < (request.limit ?? 200) && end < snapshot.text.length; count++) {
|
|
128
|
+
const offset = snapshot.text.indexOf('\n', end);
|
|
129
|
+
end = offset < 0 ? snapshot.text.length : offset + 1;
|
|
130
|
+
}
|
|
131
|
+
const requestedEnd = end;
|
|
132
|
+
const lineAt = (offset) => {
|
|
133
|
+
let line = 1;
|
|
134
|
+
for (let i = 0; i < offset; i++)
|
|
135
|
+
if (snapshot.text[i] === '\n')
|
|
136
|
+
line++;
|
|
137
|
+
return line;
|
|
138
|
+
};
|
|
139
|
+
const make = (stop) => outcome({ path: request.path, text: snapshot.text.slice(start, stop),
|
|
140
|
+
bytes: Buffer.byteLength(snapshot.text.slice(start, stop)), fileBytes: snapshot.bytes, sha256: snapshot.sha256,
|
|
141
|
+
byteStart: Buffer.byteLength(snapshot.text.slice(0, start)), byteEnd: Buffer.byteLength(snapshot.text.slice(0, stop)),
|
|
142
|
+
startLine: lineAt(start), endLine: lineAt(Math.max(start, stop - 1)),
|
|
143
|
+
complete: stop === snapshot.text.length, nextCursor: stop === snapshot.text.length ? null : cursor(binding, fingerprint, stop),
|
|
144
|
+
truncationReason: stop < requestedEnd ? 'output_bytes' : stop < snapshot.text.length ? 'line_limit' : null });
|
|
145
|
+
if (!fit(make(end))) {
|
|
146
|
+
let low = start;
|
|
147
|
+
let high = end;
|
|
148
|
+
while (low < high) {
|
|
149
|
+
let middle = Math.ceil((low + high) / 2);
|
|
150
|
+
if (/[\uDC00-\uDFFF]/.test(snapshot.text[middle] ?? ''))
|
|
151
|
+
middle--;
|
|
152
|
+
if (middle <= low)
|
|
153
|
+
break;
|
|
154
|
+
if (fit(make(middle)))
|
|
155
|
+
low = middle;
|
|
156
|
+
else
|
|
157
|
+
high = middle - 1;
|
|
158
|
+
}
|
|
159
|
+
end = low;
|
|
160
|
+
}
|
|
161
|
+
if ((end === start && start < snapshot.text.length) || !fit(make(end))) {
|
|
162
|
+
throw new FileFault('output_limit', 'Budget cannot hold one code point and metadata');
|
|
163
|
+
}
|
|
164
|
+
return make(end);
|
|
165
|
+
}
|
|
166
|
+
const targets = targetsFor(args, name);
|
|
167
|
+
const keys = targets.map(target => path.resolve(root, target.path));
|
|
168
|
+
if (new Set(keys).size !== keys.length)
|
|
169
|
+
throw new FileFault('edit_conflict', 'Duplicate target paths');
|
|
170
|
+
for (const key of [...keys].sort()) {
|
|
171
|
+
const previous = writers.get(key);
|
|
172
|
+
let release;
|
|
173
|
+
const promise = new Promise(resolve => { release = resolve; });
|
|
174
|
+
writers.set(key, promise);
|
|
175
|
+
held.push({ key, promise, release });
|
|
176
|
+
await previous;
|
|
177
|
+
signal.throwIfAborted();
|
|
178
|
+
}
|
|
179
|
+
const snapshots = [];
|
|
180
|
+
const candidates = [];
|
|
181
|
+
const snapshot = async (target) => {
|
|
182
|
+
const absolute = await checkPath(root, target.path, name === 'write');
|
|
183
|
+
if (absolute === root)
|
|
184
|
+
throw new FileFault('not_regular_file', 'Expected a file');
|
|
185
|
+
try {
|
|
186
|
+
return await readText(absolute);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (name === 'write' && error.code === 'ENOENT')
|
|
190
|
+
return undefined;
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
let totalBytes = 0;
|
|
195
|
+
for (const target of targets) {
|
|
196
|
+
signal.throwIfAborted();
|
|
197
|
+
const before = await snapshot(target);
|
|
198
|
+
if (target.expectedSha256 === undefined ? before !== undefined : before?.sha256 !== target.expectedSha256) {
|
|
199
|
+
throw new FileFault('edit_conflict', 'File changed or exists without an expected SHA-256');
|
|
200
|
+
}
|
|
201
|
+
const edits = target.edits ? target.edits.map(edit => {
|
|
202
|
+
const start = before.text.indexOf(edit.oldText);
|
|
203
|
+
if (start < 0 || before.text.indexOf(edit.oldText, start + 1) >= 0) {
|
|
204
|
+
throw new FileFault('edit_conflict', 'oldText must match exactly once, including overlapping matches');
|
|
205
|
+
}
|
|
206
|
+
return { start, end: start + edit.oldText.length, text: edit.newText };
|
|
207
|
+
}).sort((a, b) => a.start - b.start) : [{ start: 0, end: before?.text.length ?? 0, text: target.content }];
|
|
208
|
+
if (edits.some((edit, i) => i > 0 && edit.start < edits[i - 1].end))
|
|
209
|
+
throw new FileFault('edit_conflict', 'Edits overlap in original text');
|
|
210
|
+
const next = replace(before?.text ?? '', edits);
|
|
211
|
+
assertText(next);
|
|
212
|
+
const bytes = Buffer.byteLength(next);
|
|
213
|
+
totalBytes += bytes + (before?.bytes ?? 0);
|
|
214
|
+
if (bytes > maxBytes || totalBytes > 32 * 1024 * 1024)
|
|
215
|
+
throw new FileFault('too_large', 'Mutation snapshot byte budget exceeded');
|
|
216
|
+
snapshots.push(before);
|
|
217
|
+
candidates.push(next);
|
|
218
|
+
receipts.push({ path: target.path, status: 'not_attempted', beforeSha256: before?.sha256 ?? null,
|
|
219
|
+
sha256: null, candidateSha256: digest(next), bytes,
|
|
220
|
+
diff: diff(target.path, before?.text ?? '', edits, next, before === undefined) });
|
|
221
|
+
}
|
|
222
|
+
const success = () => outcome(name === 'patch' ? { files: receipts, complete: true } : receipts[0]);
|
|
223
|
+
const cleanupPath = targets.reduce((longest, target) => {
|
|
224
|
+
const candidate = path.join(path.dirname(target.path), `.agent-${'0'.repeat(36)}.tmp`);
|
|
225
|
+
return Buffer.byteLength(JSON.stringify(JSON.stringify(candidate))) > Buffer.byteLength(JSON.stringify(JSON.stringify(longest)))
|
|
226
|
+
? candidate : longest;
|
|
227
|
+
}, '');
|
|
228
|
+
const reserved = receipts.map(receipt => ({ ...receipt, status: 'not_attempted', sha256: receipt.candidateSha256 }));
|
|
229
|
+
const partial = { ok: false, error: { code: 'cancelled_before_commit', effect: 'unknown',
|
|
230
|
+
message: JSON.stringify({ complete: false, files: reserved, failedIndex: targets.length, cause: 'cancelled_before_commit',
|
|
231
|
+
temporary: cleanupPath }) + ' '.repeat(256) } };
|
|
232
|
+
// Include the most expensive doubly encoded cleanup path BEFORE touching any file.
|
|
233
|
+
if (!fit(outcome(name === 'patch' ? { files: reserved, complete: true } : reserved[0])) || !fit(partial)) {
|
|
234
|
+
throw new FileFault('output_limit', 'Encoded diff/receipt exceeds budget; no writes attempted');
|
|
235
|
+
}
|
|
236
|
+
committing = true;
|
|
237
|
+
for (index = 0; index < targets.length; index++) {
|
|
238
|
+
const target = targets[index];
|
|
239
|
+
signal.throwIfAborted();
|
|
240
|
+
const absolute = await checkPath(root, target.path, name === 'write');
|
|
241
|
+
const candidate = path.join(path.dirname(absolute), `.agent-${randomUUID()}.tmp`);
|
|
242
|
+
const file = await open(candidate, 'wx', 0o600);
|
|
243
|
+
temporary = candidate;
|
|
244
|
+
try {
|
|
245
|
+
await file.writeFile(candidates[index], { encoding: 'utf8', signal });
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
await file.close();
|
|
249
|
+
}
|
|
250
|
+
const after = await snapshot(target);
|
|
251
|
+
if (snapshots[index]?.identity !== after?.identity || snapshots[index]?.sha256 !== after?.sha256) {
|
|
252
|
+
throw new FileFault('edit_conflict', 'File changed before commit; no replacement was made');
|
|
253
|
+
}
|
|
254
|
+
signal.throwIfAborted();
|
|
255
|
+
await rename(temporary, absolute);
|
|
256
|
+
temporary = undefined;
|
|
257
|
+
receipts[index].status = 'committed';
|
|
258
|
+
receipts[index].sha256 = receipts[index].candidateSha256;
|
|
259
|
+
}
|
|
260
|
+
return success();
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
const code = error instanceof FileFault ? error.code : signal.aborted ? 'cancelled_before_commit'
|
|
264
|
+
: error.code === 'ENOENT' ? 'not_found' : 'io_error';
|
|
265
|
+
let cleanupFailed = false;
|
|
266
|
+
if (temporary) {
|
|
267
|
+
try {
|
|
268
|
+
await unlink(temporary);
|
|
269
|
+
temporary = undefined;
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
cleanupFailed = true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const committed = receipts.some(receipt => receipt.status === 'committed');
|
|
276
|
+
if (committing && index < receipts.length)
|
|
277
|
+
receipts[index].status = 'rejected';
|
|
278
|
+
if (committed || cleanupFailed)
|
|
279
|
+
return { ok: false, error: { code: cleanupFailed ? 'cleanup_failed' : code, effect: 'unknown',
|
|
280
|
+
message: JSON.stringify({ complete: false, files: receipts, failedIndex: index, cause: code,
|
|
281
|
+
temporary: temporary ? path.relative(root, temporary) : null }) } };
|
|
282
|
+
return { ok: false, error: fail(code, error instanceof FileFault ? error.message : `File operation failed (${code})`) };
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
for (const lock of held.reverse()) {
|
|
286
|
+
lock.release();
|
|
287
|
+
if (writers.get(lock.key) === lock.promise)
|
|
288
|
+
writers.delete(lock.key);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
}));
|
|
293
|
+
}
|