@bbigbang/agent-node 0.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/agentHost.js +483 -0
- package/dist/appVersion.js +14 -0
- package/dist/assetCachePaths.js +35 -0
- package/dist/attachmentInput.js +588 -0
- package/dist/attachmentMaterializer.js +230 -0
- package/dist/bigbangCli.js +17 -0
- package/dist/bigbangMessageSendDetection.js +284 -0
- package/dist/builtinSkillRoots.js +54 -0
- package/dist/claudeConfig.js +32 -0
- package/dist/claudeDirectRuntime.js +1960 -0
- package/dist/claudeSessionControls.js +78 -0
- package/dist/claudeTranscriptFs.js +147 -0
- package/dist/codexAppServerClient.js +188 -0
- package/dist/codexAppServerEnv.js +14 -0
- package/dist/codexAppServerRpc.js +273 -0
- package/dist/codexAppServerRuntime.js +3495 -0
- package/dist/codexBuiltinPrompt.js +117 -0
- package/dist/codexConversationSummarizer.js +76 -0
- package/dist/codexTranscriptFs.js +145 -0
- package/dist/config.js +129 -0
- package/dist/connection.js +151 -0
- package/dist/dispatchQueueStore.js +39 -0
- package/dist/dreamEnv.js +1 -0
- package/dist/dreamMemoryFallback.js +118 -0
- package/dist/dreamToolPolicy.js +293 -0
- package/dist/droidMissionRunner.js +808 -0
- package/dist/executor.js +1078 -0
- package/dist/hostRuntime.js +1 -0
- package/dist/libraryAuthorityFs.js +74 -0
- package/dist/libraryMirror.js +183 -0
- package/dist/main.js +1659 -0
- package/dist/native-worker/native-worker.mjs +475 -0
- package/dist/nativeMissionAgentDispatch.js +463 -0
- package/dist/nativeMissionRunner.js +461 -0
- package/dist/nativeSkillMounts.js +204 -0
- package/dist/nativeWorkerHost.js +142 -0
- package/dist/nodeSink.js +142 -0
- package/dist/panelHttpFetch.js +334 -0
- package/dist/runtimeDrivers.js +62 -0
- package/dist/skillFs.js +229 -0
- package/dist/soloHost.js +165 -0
- package/dist/soloNodeSink.js +138 -0
- package/dist/terminalManager.js +254 -0
- package/dist/workspaceFs.js +1020 -0
- package/dist/workspaceGit.js +694 -0
- package/dist/workspaceInspect.js +22 -0
- package/package.json +49 -0
|
@@ -0,0 +1,1020 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { TextDecoder } from 'node:util';
|
|
4
|
+
import { gunzipSync, gzipSync } from 'node:zlib';
|
|
5
|
+
import { resolveWorkspacePath } from '@bbigbang/runtime-acp';
|
|
6
|
+
export const MAX_TEXT_PREVIEW_BYTES = 256 * 1024;
|
|
7
|
+
export const MAX_IMAGE_PREVIEW_BYTES = 5 * 1024 * 1024;
|
|
8
|
+
const WORKSPACE_ARCHIVE_MAX_ENTRY_COUNT = 10_000;
|
|
9
|
+
const UTF8_FATAL_DECODER = new TextDecoder('utf-8', { fatal: true });
|
|
10
|
+
function buildRoleScaffoldLine(agentName) {
|
|
11
|
+
const normalizedAgentName = agentName
|
|
12
|
+
?.replace(/[\r\n]+/g, ' ')
|
|
13
|
+
.replace(/\s+/g, ' ')
|
|
14
|
+
.trim();
|
|
15
|
+
if (!normalizedAgentName)
|
|
16
|
+
return '- Capture your current role and responsibilities here.';
|
|
17
|
+
return `- you are ${normalizedAgentName}. Capture your current role and responsibilities here.`;
|
|
18
|
+
}
|
|
19
|
+
const BEIJING_DATE_FORMATTER = new Intl.DateTimeFormat('en-CA', {
|
|
20
|
+
timeZone: 'Asia/Shanghai',
|
|
21
|
+
year: 'numeric',
|
|
22
|
+
month: '2-digit',
|
|
23
|
+
day: '2-digit',
|
|
24
|
+
});
|
|
25
|
+
function getCurrentBeijingISOWeek() {
|
|
26
|
+
const parts = BEIJING_DATE_FORMATTER.formatToParts(new Date());
|
|
27
|
+
const getPart = (type) => Number(parts.find((part) => part.type === type)?.value);
|
|
28
|
+
const year = getPart('year');
|
|
29
|
+
const month = getPart('month');
|
|
30
|
+
const day = getPart('day');
|
|
31
|
+
const beijingDate = new Date(Date.UTC(year, month - 1, day));
|
|
32
|
+
const dayNum = beijingDate.getUTCDay() || 7;
|
|
33
|
+
beijingDate.setUTCDate(beijingDate.getUTCDate() + 4 - dayNum);
|
|
34
|
+
const yearStart = new Date(Date.UTC(beijingDate.getUTCFullYear(), 0, 1));
|
|
35
|
+
const weekNo = Math.ceil((((beijingDate.getTime() - yearStart.getTime()) / 86_400_000) + 1) / 7);
|
|
36
|
+
return `${beijingDate.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
|
|
37
|
+
}
|
|
38
|
+
export class WorkspaceFsError extends Error {
|
|
39
|
+
code;
|
|
40
|
+
constructor(code, message) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.code = code;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function listWorkspaceDirectory(workspaceRoot, relativePath, options) {
|
|
46
|
+
if (options?.scaffold !== false)
|
|
47
|
+
ensureWorkspaceScaffold(workspaceRoot, options?.agentName);
|
|
48
|
+
const resolved = resolveRelativeWorkspacePath(workspaceRoot, relativePath);
|
|
49
|
+
const stat = fs.statSync(resolved, { throwIfNoEntry: false });
|
|
50
|
+
if (!stat)
|
|
51
|
+
throw new WorkspaceFsError('not_found', 'Path not found.');
|
|
52
|
+
if (!stat.isDirectory())
|
|
53
|
+
throw new WorkspaceFsError('not_directory', 'Path is not a directory.');
|
|
54
|
+
const candidates = fs
|
|
55
|
+
.readdirSync(resolved, { withFileTypes: true })
|
|
56
|
+
.map((entry) => {
|
|
57
|
+
const absoluteEntry = path.join(resolved, entry.name);
|
|
58
|
+
return classifyWorkspaceEntryCandidate(entry, absoluteEntry);
|
|
59
|
+
})
|
|
60
|
+
.sort((a, b) => {
|
|
61
|
+
if (a.kind !== b.kind)
|
|
62
|
+
return a.kind === 'directory' ? -1 : 1;
|
|
63
|
+
return a.dirent.name.localeCompare(b.dirent.name);
|
|
64
|
+
});
|
|
65
|
+
const { candidates: pageCandidates, page } = paginateWorkspaceEntryCandidates(candidates, {
|
|
66
|
+
cursor: options?.cursor,
|
|
67
|
+
limit: options?.limit,
|
|
68
|
+
});
|
|
69
|
+
const entries = pageCandidates.map((candidate) => workspaceEntryFromCandidate(workspaceRoot, candidate));
|
|
70
|
+
return {
|
|
71
|
+
relativePath: normalizeRelativePath(relativePath),
|
|
72
|
+
entries,
|
|
73
|
+
directoryPage: page,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export function readWorkspaceFile(workspaceRoot, relativePath, options) {
|
|
77
|
+
if (options?.scaffold !== false)
|
|
78
|
+
ensureWorkspaceScaffold(workspaceRoot, options?.agentName);
|
|
79
|
+
const resolved = resolveRelativeWorkspacePath(workspaceRoot, relativePath);
|
|
80
|
+
const stat = fs.statSync(resolved, { throwIfNoEntry: false });
|
|
81
|
+
if (!stat)
|
|
82
|
+
throw new WorkspaceFsError('not_found', 'Path not found.');
|
|
83
|
+
if (!stat.isFile())
|
|
84
|
+
throw new WorkspaceFsError('not_file', 'Path is not a file.');
|
|
85
|
+
const chunkRequest = normalizeFileChunkRequest(options);
|
|
86
|
+
const mimeType = getPreviewMimeType(resolved);
|
|
87
|
+
const isImage = mimeType.startsWith('image/');
|
|
88
|
+
if (chunkRequest && isImage) {
|
|
89
|
+
throw new WorkspaceFsError('invalid_request', 'Chunked preview is only supported for text files.');
|
|
90
|
+
}
|
|
91
|
+
if (chunkRequest) {
|
|
92
|
+
return readWorkspaceTextChunk(resolved, relativePath, stat, mimeType, chunkRequest);
|
|
93
|
+
}
|
|
94
|
+
const maxPreviewBytes = isImage ? MAX_IMAGE_PREVIEW_BYTES : MAX_TEXT_PREVIEW_BYTES;
|
|
95
|
+
if (stat.size > maxPreviewBytes) {
|
|
96
|
+
throw new WorkspaceFsError('file_too_large', `File exceeds preview limit (${maxPreviewBytes} bytes).`);
|
|
97
|
+
}
|
|
98
|
+
const contentBuffer = fs.readFileSync(resolved);
|
|
99
|
+
if (isImage) {
|
|
100
|
+
return {
|
|
101
|
+
relativePath: normalizeRelativePath(relativePath),
|
|
102
|
+
content: `data:${mimeType};base64,${contentBuffer.toString('base64')}`,
|
|
103
|
+
mimeType,
|
|
104
|
+
size: stat.size,
|
|
105
|
+
modifiedAt: Math.floor(stat.mtimeMs),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (looksBinary(contentBuffer)) {
|
|
109
|
+
throw new WorkspaceFsError('binary_file', 'Binary files are not supported for preview.');
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
relativePath: normalizeRelativePath(relativePath),
|
|
113
|
+
content: contentBuffer.toString('utf8'),
|
|
114
|
+
mimeType,
|
|
115
|
+
size: stat.size,
|
|
116
|
+
modifiedAt: Math.floor(stat.mtimeMs),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export function createWorkspaceDirectoryArchive(workspaceRoot, relativePath, options = {}) {
|
|
120
|
+
const maxBytes = normalizeArchiveMaxBytes(options.maxBytes);
|
|
121
|
+
const root = resolveRelativeWorkspacePath(workspaceRoot, relativePath);
|
|
122
|
+
const rootStat = fs.lstatSync(root, { throwIfNoEntry: false });
|
|
123
|
+
if (!rootStat)
|
|
124
|
+
throw new WorkspaceFsError('not_found', 'Path not found.');
|
|
125
|
+
if (rootStat.isSymbolicLink()) {
|
|
126
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Symlink archives are not supported.');
|
|
127
|
+
}
|
|
128
|
+
if (!rootStat.isDirectory()) {
|
|
129
|
+
throw new WorkspaceFsError('not_directory', 'Path is not a directory.');
|
|
130
|
+
}
|
|
131
|
+
const entries = [];
|
|
132
|
+
let byteSize = 0;
|
|
133
|
+
let fileCount = 0;
|
|
134
|
+
const pushEntry = (entry) => {
|
|
135
|
+
if (entries.length >= WORKSPACE_ARCHIVE_MAX_ENTRY_COUNT) {
|
|
136
|
+
throw new WorkspaceFsError('file_too_large', `Archive contains too many entries (${WORKSPACE_ARCHIVE_MAX_ENTRY_COUNT}).`);
|
|
137
|
+
}
|
|
138
|
+
entries.push(entry);
|
|
139
|
+
};
|
|
140
|
+
const visit = (absoluteDirectory, archiveDirectoryPath) => {
|
|
141
|
+
const directoryStat = fs.lstatSync(absoluteDirectory, { throwIfNoEntry: false });
|
|
142
|
+
if (!directoryStat)
|
|
143
|
+
throw new WorkspaceFsError('not_found', 'Path not found.');
|
|
144
|
+
if (directoryStat.isSymbolicLink()) {
|
|
145
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Symlink archives are not supported.');
|
|
146
|
+
}
|
|
147
|
+
if (!directoryStat.isDirectory()) {
|
|
148
|
+
throw new WorkspaceFsError('not_directory', 'Path is not a directory.');
|
|
149
|
+
}
|
|
150
|
+
if (archiveDirectoryPath) {
|
|
151
|
+
pushEntry({
|
|
152
|
+
kind: 'directory',
|
|
153
|
+
path: archiveDirectoryPath,
|
|
154
|
+
mode: directoryStat.mode & 0o777,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
for (const dirent of fs.readdirSync(absoluteDirectory, { withFileTypes: true }).sort(compareDirents)) {
|
|
158
|
+
const absoluteChild = path.join(absoluteDirectory, dirent.name);
|
|
159
|
+
const archiveChildPath = archiveDirectoryPath
|
|
160
|
+
? `${archiveDirectoryPath}/${dirent.name}`
|
|
161
|
+
: dirent.name;
|
|
162
|
+
const childStat = fs.lstatSync(absoluteChild, { throwIfNoEntry: false });
|
|
163
|
+
if (!childStat)
|
|
164
|
+
continue;
|
|
165
|
+
if (childStat.isSymbolicLink()) {
|
|
166
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Symlink archives are not supported.');
|
|
167
|
+
}
|
|
168
|
+
if (childStat.isDirectory()) {
|
|
169
|
+
visit(absoluteChild, archiveChildPath);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (!childStat.isFile()) {
|
|
173
|
+
throw new WorkspaceFsError('not_file', 'Only regular files are supported in workspace archives.');
|
|
174
|
+
}
|
|
175
|
+
byteSize += childStat.size;
|
|
176
|
+
if (byteSize > maxBytes) {
|
|
177
|
+
throw new WorkspaceFsError('file_too_large', `Archive exceeds size limit (${maxBytes} bytes).`);
|
|
178
|
+
}
|
|
179
|
+
const content = fs.readFileSync(absoluteChild);
|
|
180
|
+
pushEntry({
|
|
181
|
+
kind: 'file',
|
|
182
|
+
path: archiveChildPath,
|
|
183
|
+
mode: childStat.mode & 0o777,
|
|
184
|
+
contentBase64: content.toString('base64'),
|
|
185
|
+
});
|
|
186
|
+
fileCount += 1;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
visit(root, '');
|
|
190
|
+
const payload = { version: 1, entries };
|
|
191
|
+
const payloadBuffer = Buffer.from(JSON.stringify(payload), 'utf8');
|
|
192
|
+
const maxArchiveBytes = normalizeArchiveSerializedMaxBytes(maxBytes);
|
|
193
|
+
if (payloadBuffer.length > maxArchiveBytes) {
|
|
194
|
+
throw new WorkspaceFsError('file_too_large', `Archive metadata exceeds size limit (${maxArchiveBytes} bytes).`);
|
|
195
|
+
}
|
|
196
|
+
const compressed = gzipSync(payloadBuffer);
|
|
197
|
+
if (compressed.length > maxArchiveBytes) {
|
|
198
|
+
throw new WorkspaceFsError('file_too_large', `Archive exceeds serialized size limit (${maxArchiveBytes} bytes).`);
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
relativePath: normalizeRelativePath(relativePath),
|
|
202
|
+
archiveBase64: compressed.toString('base64'),
|
|
203
|
+
byteSize,
|
|
204
|
+
fileCount,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
export function extractWorkspaceDirectoryArchive(workspaceRoot, relativePath, archiveBase64, options = {}) {
|
|
208
|
+
const maxBytes = normalizeArchiveMaxBytes(options.maxBytes);
|
|
209
|
+
const resolvedTarget = resolveRelativeWorkspacePath(workspaceRoot, relativePath);
|
|
210
|
+
assertWorkspaceMutationParentPath(workspaceRoot, resolvedTarget);
|
|
211
|
+
const existingTarget = fs.lstatSync(resolvedTarget, { throwIfNoEntry: false });
|
|
212
|
+
if (existingTarget && !options.overwrite) {
|
|
213
|
+
throw new WorkspaceFsError('invalid_request', 'Target path already exists.');
|
|
214
|
+
}
|
|
215
|
+
if (existingTarget?.isSymbolicLink()) {
|
|
216
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Refusing to extract over a symlink path.');
|
|
217
|
+
}
|
|
218
|
+
const payload = decodeWorkspaceArchivePayload(archiveBase64, maxBytes);
|
|
219
|
+
if (existingTarget && options.overwrite) {
|
|
220
|
+
assertWorkspaceMutationPath(workspaceRoot, resolvedTarget);
|
|
221
|
+
fs.rmSync(resolvedTarget, { recursive: true, force: true });
|
|
222
|
+
}
|
|
223
|
+
fs.mkdirSync(resolvedTarget, { recursive: true });
|
|
224
|
+
assertWorkspaceMutationPath(workspaceRoot, resolvedTarget);
|
|
225
|
+
let byteSize = 0;
|
|
226
|
+
let fileCount = 0;
|
|
227
|
+
for (const entry of payload.entries) {
|
|
228
|
+
const normalizedEntryPath = normalizeArchiveEntryPath(entry.path);
|
|
229
|
+
const absoluteEntryPath = path.resolve(resolvedTarget, normalizedEntryPath);
|
|
230
|
+
assertArchiveEntryInsideTarget(resolvedTarget, absoluteEntryPath);
|
|
231
|
+
if (entry.kind === 'directory') {
|
|
232
|
+
fs.mkdirSync(absoluteEntryPath, { recursive: true });
|
|
233
|
+
fs.chmodSync(absoluteEntryPath, normalizeArchiveMode(entry.mode, 0o755));
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
fs.mkdirSync(path.dirname(absoluteEntryPath), { recursive: true });
|
|
237
|
+
const content = decodeArchiveEntryContent(entry.contentBase64);
|
|
238
|
+
byteSize += content.length;
|
|
239
|
+
if (byteSize > maxBytes) {
|
|
240
|
+
throw new WorkspaceFsError('file_too_large', `Archive exceeds size limit (${maxBytes} bytes).`);
|
|
241
|
+
}
|
|
242
|
+
fs.writeFileSync(absoluteEntryPath, content);
|
|
243
|
+
fs.chmodSync(absoluteEntryPath, normalizeArchiveMode(entry.mode, 0o644));
|
|
244
|
+
fileCount += 1;
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
relativePath: normalizeRelativePath(relativePath),
|
|
248
|
+
byteSize,
|
|
249
|
+
fileCount,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
export function deleteWorkspaceFile(workspaceRoot, relativePath) {
|
|
253
|
+
const resolved = resolveRelativeWorkspacePath(workspaceRoot, relativePath);
|
|
254
|
+
assertWorkspaceMutationPath(workspaceRoot, resolved);
|
|
255
|
+
const stat = fs.statSync(resolved, { throwIfNoEntry: false });
|
|
256
|
+
if (!stat)
|
|
257
|
+
throw new WorkspaceFsError('not_found', 'Path not found.');
|
|
258
|
+
if (!stat.isFile())
|
|
259
|
+
throw new WorkspaceFsError('not_file', 'Path is not a file.');
|
|
260
|
+
fs.unlinkSync(resolved);
|
|
261
|
+
return { relativePath: normalizeRelativePath(relativePath) };
|
|
262
|
+
}
|
|
263
|
+
export function deleteWorkspacePath(workspaceRoot, relativePath, options) {
|
|
264
|
+
const normalized = normalizeRelativePath(relativePath);
|
|
265
|
+
if (!normalized) {
|
|
266
|
+
throw new WorkspaceFsError('invalid_request', 'Refusing to delete workspace root.');
|
|
267
|
+
}
|
|
268
|
+
const resolved = resolveRelativeWorkspacePath(workspaceRoot, normalized);
|
|
269
|
+
assertWorkspaceMutationPath(workspaceRoot, resolved);
|
|
270
|
+
const stat = fs.lstatSync(resolved, { throwIfNoEntry: false });
|
|
271
|
+
if (!stat)
|
|
272
|
+
throw new WorkspaceFsError('not_found', 'Path not found.');
|
|
273
|
+
if (stat.isDirectory() && options?.recursive !== true) {
|
|
274
|
+
throw new WorkspaceFsError('invalid_request', 'Path is a directory. Set recursive=true to delete directories.');
|
|
275
|
+
}
|
|
276
|
+
fs.rmSync(resolved, { recursive: stat.isDirectory(), force: false });
|
|
277
|
+
return { relativePath: normalized };
|
|
278
|
+
}
|
|
279
|
+
function decodeBase64WorkspaceContent(content) {
|
|
280
|
+
const compact = content.replace(/\s+/g, '');
|
|
281
|
+
if (!compact)
|
|
282
|
+
return Buffer.alloc(0);
|
|
283
|
+
if (compact.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(compact)) {
|
|
284
|
+
throw new WorkspaceFsError('invalid_request', 'Invalid base64 content.');
|
|
285
|
+
}
|
|
286
|
+
const decoded = Buffer.from(compact, 'base64');
|
|
287
|
+
if (decoded.toString('base64') !== compact) {
|
|
288
|
+
throw new WorkspaceFsError('invalid_request', 'Invalid base64 content.');
|
|
289
|
+
}
|
|
290
|
+
return decoded;
|
|
291
|
+
}
|
|
292
|
+
function decodeArchiveEntryContent(content) {
|
|
293
|
+
const compact = content.replace(/\s+/g, '');
|
|
294
|
+
if (!compact)
|
|
295
|
+
return Buffer.alloc(0);
|
|
296
|
+
if (compact.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(compact)) {
|
|
297
|
+
throw new WorkspaceFsError('invalid_request', 'Invalid archive file content.');
|
|
298
|
+
}
|
|
299
|
+
return Buffer.from(compact, 'base64');
|
|
300
|
+
}
|
|
301
|
+
function decodeWorkspaceArchivePayload(archiveBase64, maxBytes) {
|
|
302
|
+
const archive = decodeArchiveEntryContent(archiveBase64);
|
|
303
|
+
const maxArchiveBytes = normalizeArchiveSerializedMaxBytes(maxBytes);
|
|
304
|
+
if (archive.length > maxArchiveBytes) {
|
|
305
|
+
throw new WorkspaceFsError('file_too_large', `Archive exceeds serialized size limit (${maxArchiveBytes} bytes).`);
|
|
306
|
+
}
|
|
307
|
+
let parsed;
|
|
308
|
+
try {
|
|
309
|
+
parsed = JSON.parse(gunzipSync(archive, { maxOutputLength: maxArchiveBytes }).toString('utf8'));
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
throw new WorkspaceFsError('invalid_request', 'Invalid workspace archive.');
|
|
313
|
+
}
|
|
314
|
+
if (!parsed || typeof parsed !== 'object' || parsed.version !== 1 || !Array.isArray(parsed.entries)) {
|
|
315
|
+
throw new WorkspaceFsError('invalid_request', 'Invalid workspace archive.');
|
|
316
|
+
}
|
|
317
|
+
const entries = [];
|
|
318
|
+
for (const rawEntry of parsed.entries) {
|
|
319
|
+
if (entries.length >= WORKSPACE_ARCHIVE_MAX_ENTRY_COUNT) {
|
|
320
|
+
throw new WorkspaceFsError('file_too_large', `Archive contains too many entries (${WORKSPACE_ARCHIVE_MAX_ENTRY_COUNT}).`);
|
|
321
|
+
}
|
|
322
|
+
if (!rawEntry || typeof rawEntry !== 'object') {
|
|
323
|
+
throw new WorkspaceFsError('invalid_request', 'Invalid workspace archive entry.');
|
|
324
|
+
}
|
|
325
|
+
const record = rawEntry;
|
|
326
|
+
const kind = record.kind;
|
|
327
|
+
const entryPath = typeof record.path === 'string' ? normalizeArchiveEntryPath(record.path) : null;
|
|
328
|
+
const mode = typeof record.mode === 'number' && Number.isInteger(record.mode) ? record.mode : null;
|
|
329
|
+
if (!entryPath || mode == null) {
|
|
330
|
+
throw new WorkspaceFsError('invalid_request', 'Invalid workspace archive entry.');
|
|
331
|
+
}
|
|
332
|
+
if (kind === 'directory') {
|
|
333
|
+
entries.push({ kind, path: entryPath, mode });
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (kind === 'file' && typeof record.contentBase64 === 'string') {
|
|
337
|
+
entries.push({ kind, path: entryPath, mode, contentBase64: record.contentBase64 });
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
throw new WorkspaceFsError('invalid_request', 'Invalid workspace archive entry.');
|
|
341
|
+
}
|
|
342
|
+
return { version: 1, entries };
|
|
343
|
+
}
|
|
344
|
+
function normalizeArchiveEntryPath(value) {
|
|
345
|
+
const normalized = value.replace(/\\/g, '/').replace(/^\/+/, '').trim();
|
|
346
|
+
const posixNormalized = path.posix.normalize(normalized).replace(/^\.\/+/, '');
|
|
347
|
+
if (!posixNormalized || posixNormalized === '.' || posixNormalized === '..' || posixNormalized.startsWith('../') || path.posix.isAbsolute(posixNormalized)) {
|
|
348
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Archive entry escapes target path.');
|
|
349
|
+
}
|
|
350
|
+
return posixNormalized;
|
|
351
|
+
}
|
|
352
|
+
function assertArchiveEntryInsideTarget(resolvedTarget, absoluteEntryPath) {
|
|
353
|
+
const relative = path.relative(path.resolve(resolvedTarget), path.resolve(absoluteEntryPath));
|
|
354
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
355
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Archive entry escapes target path.');
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function normalizeArchiveMaxBytes(value) {
|
|
359
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
360
|
+
return 25 * 1024 * 1024;
|
|
361
|
+
return Math.min(100 * 1024 * 1024, Math.max(1, Math.floor(value)));
|
|
362
|
+
}
|
|
363
|
+
function normalizeArchiveSerializedMaxBytes(maxBytes) {
|
|
364
|
+
return Math.max(8 * 1024, maxBytes * 4);
|
|
365
|
+
}
|
|
366
|
+
function normalizeArchiveMode(value, fallback) {
|
|
367
|
+
if (!Number.isInteger(value))
|
|
368
|
+
return fallback;
|
|
369
|
+
const mode = value & 0o777;
|
|
370
|
+
return mode > 0 ? mode : fallback;
|
|
371
|
+
}
|
|
372
|
+
function normalizeFileChunkRequest(options) {
|
|
373
|
+
if (options?.offset === undefined && options?.limit === undefined)
|
|
374
|
+
return null;
|
|
375
|
+
const offset = options.offset ?? 0;
|
|
376
|
+
const limit = options.limit ?? MAX_TEXT_PREVIEW_BYTES;
|
|
377
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
378
|
+
throw new WorkspaceFsError('invalid_request', 'offset must be a non-negative integer.');
|
|
379
|
+
}
|
|
380
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_TEXT_PREVIEW_BYTES) {
|
|
381
|
+
throw new WorkspaceFsError('invalid_request', `limit must be between 1 and ${MAX_TEXT_PREVIEW_BYTES}.`);
|
|
382
|
+
}
|
|
383
|
+
return { offset, limit };
|
|
384
|
+
}
|
|
385
|
+
function readWorkspaceTextChunk(resolved, relativePath, stat, mimeType, chunkRequest) {
|
|
386
|
+
if (chunkRequest.offset > stat.size) {
|
|
387
|
+
throw new WorkspaceFsError('invalid_request', 'offset is beyond the end of the file.');
|
|
388
|
+
}
|
|
389
|
+
const readLength = Math.min(chunkRequest.limit, Math.max(0, stat.size - chunkRequest.offset));
|
|
390
|
+
const contentBuffer = Buffer.alloc(readLength);
|
|
391
|
+
const fd = fs.openSync(resolved, 'r');
|
|
392
|
+
try {
|
|
393
|
+
fs.readSync(fd, contentBuffer, 0, readLength, chunkRequest.offset);
|
|
394
|
+
}
|
|
395
|
+
finally {
|
|
396
|
+
fs.closeSync(fd);
|
|
397
|
+
}
|
|
398
|
+
if (looksBinary(contentBuffer)) {
|
|
399
|
+
throw new WorkspaceFsError('binary_file', 'Binary files are not supported for preview.');
|
|
400
|
+
}
|
|
401
|
+
const safeLength = findUtf8SafeLength(contentBuffer);
|
|
402
|
+
const safeBuffer = safeLength === contentBuffer.length ? contentBuffer : contentBuffer.subarray(0, safeLength);
|
|
403
|
+
const nextOffset = chunkRequest.offset + safeBuffer.length;
|
|
404
|
+
return {
|
|
405
|
+
relativePath: normalizeRelativePath(relativePath),
|
|
406
|
+
content: safeBuffer.toString('utf8'),
|
|
407
|
+
mimeType,
|
|
408
|
+
size: stat.size,
|
|
409
|
+
modifiedAt: Math.floor(stat.mtimeMs),
|
|
410
|
+
offset: chunkRequest.offset,
|
|
411
|
+
limit: chunkRequest.limit,
|
|
412
|
+
nextOffset: nextOffset < stat.size ? nextOffset : null,
|
|
413
|
+
hasMore: nextOffset < stat.size,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
function findUtf8SafeLength(buffer) {
|
|
417
|
+
if (buffer.length === 0)
|
|
418
|
+
return 0;
|
|
419
|
+
for (let length = buffer.length; length >= Math.max(1, buffer.length - 3); length -= 1) {
|
|
420
|
+
try {
|
|
421
|
+
UTF8_FATAL_DECODER.decode(buffer.subarray(0, length));
|
|
422
|
+
return length;
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
// Keep trimming up to one UTF-8 sequence so chunk boundaries do not split a code point.
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return buffer.length;
|
|
429
|
+
}
|
|
430
|
+
export function readExactPath(absolutePath, options = {}) {
|
|
431
|
+
if (!path.isAbsolute(absolutePath)) {
|
|
432
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Path must be absolute.');
|
|
433
|
+
}
|
|
434
|
+
const resolved = path.resolve(absolutePath);
|
|
435
|
+
if (options.allowedRoot) {
|
|
436
|
+
const resolvedRoot = path.resolve(options.allowedRoot);
|
|
437
|
+
const relative = path.relative(resolvedRoot, resolved);
|
|
438
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
439
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Path escapes allowed root.');
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const stat = fs.lstatSync(resolved, { throwIfNoEntry: false });
|
|
443
|
+
if (!stat)
|
|
444
|
+
throw new WorkspaceFsError('not_found', 'Path not found.');
|
|
445
|
+
if (stat.isSymbolicLink()) {
|
|
446
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Symlink previews require direct access to the resolved target.');
|
|
447
|
+
}
|
|
448
|
+
assertRealPathInsideAllowedRoot(resolved, options.allowedRoot, options.trustedRoot);
|
|
449
|
+
if (stat.isDirectory()) {
|
|
450
|
+
const sortedEntries = fs
|
|
451
|
+
.readdirSync(resolved, { withFileTypes: true })
|
|
452
|
+
.filter((entry) => !entry.isSymbolicLink())
|
|
453
|
+
.sort(compareDirents);
|
|
454
|
+
const { dirents, page } = paginateDirents(sortedEntries, options);
|
|
455
|
+
const entries = dirents
|
|
456
|
+
.map((entry) => {
|
|
457
|
+
const absoluteEntry = path.join(resolved, entry.name);
|
|
458
|
+
const entryStat = fs.lstatSync(absoluteEntry, { throwIfNoEntry: false });
|
|
459
|
+
if (!entryStat || entryStat.isSymbolicLink())
|
|
460
|
+
return null;
|
|
461
|
+
return {
|
|
462
|
+
name: entry.name,
|
|
463
|
+
path: absoluteEntry,
|
|
464
|
+
kind: entryStat.isDirectory() ? 'directory' : 'file',
|
|
465
|
+
size: entryStat.isDirectory() ? null : entryStat.size,
|
|
466
|
+
modifiedAt: entryStat.mtimeMs ? Math.floor(entryStat.mtimeMs) : null,
|
|
467
|
+
};
|
|
468
|
+
})
|
|
469
|
+
.filter((entry) => entry != null);
|
|
470
|
+
return {
|
|
471
|
+
absolutePath: resolved,
|
|
472
|
+
kind: 'directory',
|
|
473
|
+
entries,
|
|
474
|
+
directoryPage: page,
|
|
475
|
+
modifiedAt: Math.floor(stat.mtimeMs),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
if (!stat.isFile())
|
|
479
|
+
throw new WorkspaceFsError('not_file', 'Path is not a file.');
|
|
480
|
+
const chunkRequest = normalizeFileChunkRequest({ offset: options.offset, limit: options.limit });
|
|
481
|
+
const mimeType = getPreviewMimeType(resolved);
|
|
482
|
+
const isImage = mimeType.startsWith('image/');
|
|
483
|
+
if (options.rawContentBase64 && chunkRequest) {
|
|
484
|
+
throw new WorkspaceFsError('invalid_request', 'Raw exact path reads do not support chunk offsets.');
|
|
485
|
+
}
|
|
486
|
+
if (chunkRequest && isImage) {
|
|
487
|
+
throw new WorkspaceFsError('invalid_request', 'Chunked preview is only supported for text files.');
|
|
488
|
+
}
|
|
489
|
+
if (chunkRequest) {
|
|
490
|
+
const chunk = readWorkspaceTextChunk(resolved, resolved, stat, mimeType, chunkRequest);
|
|
491
|
+
return {
|
|
492
|
+
absolutePath: resolved,
|
|
493
|
+
kind: 'file',
|
|
494
|
+
content: chunk.content,
|
|
495
|
+
mimeType: chunk.mimeType,
|
|
496
|
+
size: chunk.size,
|
|
497
|
+
modifiedAt: chunk.modifiedAt,
|
|
498
|
+
offset: chunk.offset,
|
|
499
|
+
limit: chunk.limit,
|
|
500
|
+
nextOffset: chunk.nextOffset,
|
|
501
|
+
hasMore: chunk.hasMore,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
const maxPreviewBytes = options.maxPreviewBytes ?? (isImage ? MAX_IMAGE_PREVIEW_BYTES : MAX_TEXT_PREVIEW_BYTES);
|
|
505
|
+
if (stat.size > maxPreviewBytes) {
|
|
506
|
+
throw new WorkspaceFsError('file_too_large', `File exceeds preview limit (${maxPreviewBytes} bytes).`);
|
|
507
|
+
}
|
|
508
|
+
const contentBuffer = fs.readFileSync(resolved);
|
|
509
|
+
if (options.rawContentBase64) {
|
|
510
|
+
return {
|
|
511
|
+
absolutePath: resolved,
|
|
512
|
+
kind: 'file',
|
|
513
|
+
content: contentBuffer.toString('base64'),
|
|
514
|
+
mimeType,
|
|
515
|
+
size: stat.size,
|
|
516
|
+
modifiedAt: Math.floor(stat.mtimeMs),
|
|
517
|
+
rawContentBase64: true,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
if (isImage) {
|
|
521
|
+
return {
|
|
522
|
+
absolutePath: resolved,
|
|
523
|
+
kind: 'file',
|
|
524
|
+
content: `data:${mimeType};base64,${contentBuffer.toString('base64')}`,
|
|
525
|
+
mimeType,
|
|
526
|
+
size: stat.size,
|
|
527
|
+
modifiedAt: Math.floor(stat.mtimeMs),
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
if (looksBinary(contentBuffer)) {
|
|
531
|
+
throw new WorkspaceFsError('binary_file', 'Binary files are not supported for preview.');
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
absolutePath: resolved,
|
|
535
|
+
kind: 'file',
|
|
536
|
+
content: contentBuffer.toString('utf8'),
|
|
537
|
+
mimeType,
|
|
538
|
+
size: stat.size,
|
|
539
|
+
modifiedAt: Math.floor(stat.mtimeMs),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
export function suggestDirectoryPaths(query, options = {}) {
|
|
543
|
+
const normalizedQuery = query.trim();
|
|
544
|
+
if (!normalizedQuery) {
|
|
545
|
+
throw new WorkspaceFsError('invalid_request', 'Path query is required.');
|
|
546
|
+
}
|
|
547
|
+
if (/[\r\n]/.test(normalizedQuery)) {
|
|
548
|
+
throw new WorkspaceFsError('invalid_request', 'Path query must not contain newlines.');
|
|
549
|
+
}
|
|
550
|
+
if (!path.isAbsolute(normalizedQuery)) {
|
|
551
|
+
throw new WorkspaceFsError('invalid_request', 'Path query must be absolute.');
|
|
552
|
+
}
|
|
553
|
+
const limit = normalizePathSuggestionLimit(options.limit);
|
|
554
|
+
const queryEndsWithSeparator = /[\\/]$/.test(normalizedQuery);
|
|
555
|
+
const parentPath = queryEndsWithSeparator
|
|
556
|
+
? path.resolve(normalizedQuery)
|
|
557
|
+
: path.dirname(normalizedQuery);
|
|
558
|
+
const prefix = queryEndsWithSeparator ? '' : path.basename(normalizedQuery);
|
|
559
|
+
const parentStat = fs.lstatSync(parentPath, { throwIfNoEntry: false });
|
|
560
|
+
if (!parentStat)
|
|
561
|
+
throw new WorkspaceFsError('not_found', 'Parent directory not found.');
|
|
562
|
+
if (parentStat.isSymbolicLink()) {
|
|
563
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Symlink directory suggestions are not supported.');
|
|
564
|
+
}
|
|
565
|
+
if (!parentStat.isDirectory()) {
|
|
566
|
+
throw new WorkspaceFsError('not_directory', 'Parent path is not a directory.');
|
|
567
|
+
}
|
|
568
|
+
const normalizedPrefix = prefix.toLocaleLowerCase();
|
|
569
|
+
const suggestions = fs
|
|
570
|
+
.readdirSync(parentPath, { withFileTypes: true })
|
|
571
|
+
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
|
|
572
|
+
.filter((entry) => !normalizedPrefix || entry.name.toLocaleLowerCase().startsWith(normalizedPrefix))
|
|
573
|
+
.sort(compareDirents)
|
|
574
|
+
.slice(0, limit)
|
|
575
|
+
.map((entry) => {
|
|
576
|
+
const suggestionPath = path.join(parentPath, entry.name);
|
|
577
|
+
return {
|
|
578
|
+
path: suggestionPath,
|
|
579
|
+
name: entry.name,
|
|
580
|
+
parentPath,
|
|
581
|
+
isGitRepo: isGitRepositoryDirectory(suggestionPath),
|
|
582
|
+
};
|
|
583
|
+
});
|
|
584
|
+
return { query: normalizedQuery, suggestions };
|
|
585
|
+
}
|
|
586
|
+
export function statExactPathRawStream(absolutePath, options = {}) {
|
|
587
|
+
const { resolved, stat } = statExactFilePath(absolutePath, options);
|
|
588
|
+
const mimeType = getPreviewMimeType(resolved);
|
|
589
|
+
if (!mimeType.startsWith('image/')) {
|
|
590
|
+
throw new WorkspaceFsError('binary_file', 'Raw streaming previews are only supported for image files.');
|
|
591
|
+
}
|
|
592
|
+
if (stat.size > MAX_IMAGE_PREVIEW_BYTES) {
|
|
593
|
+
throw new WorkspaceFsError('file_too_large', `File exceeds preview limit (${MAX_IMAGE_PREVIEW_BYTES} bytes).`);
|
|
594
|
+
}
|
|
595
|
+
return {
|
|
596
|
+
absolutePath: resolved,
|
|
597
|
+
mimeType,
|
|
598
|
+
size: stat.size,
|
|
599
|
+
modifiedAt: Math.floor(stat.mtimeMs),
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
export function createExactPathReadStream(absolutePath) {
|
|
603
|
+
const { resolved } = statExactFilePath(absolutePath);
|
|
604
|
+
return fs.createReadStream(resolved, { highWaterMark: 64 * 1024 });
|
|
605
|
+
}
|
|
606
|
+
function statExactFilePath(absolutePath, options = {}) {
|
|
607
|
+
if (!path.isAbsolute(absolutePath)) {
|
|
608
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Path must be absolute.');
|
|
609
|
+
}
|
|
610
|
+
const resolved = path.resolve(absolutePath);
|
|
611
|
+
if (options.allowedRoot) {
|
|
612
|
+
const resolvedRoot = path.resolve(options.allowedRoot);
|
|
613
|
+
const relative = path.relative(resolvedRoot, resolved);
|
|
614
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
615
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Path escapes allowed root.');
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
const stat = fs.lstatSync(resolved, { throwIfNoEntry: false });
|
|
619
|
+
if (!stat)
|
|
620
|
+
throw new WorkspaceFsError('not_found', 'Path not found.');
|
|
621
|
+
if (stat.isSymbolicLink()) {
|
|
622
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Symlink previews require direct access to the resolved target.');
|
|
623
|
+
}
|
|
624
|
+
assertRealPathInsideAllowedRoot(resolved, options.allowedRoot, options.trustedRoot);
|
|
625
|
+
if (!stat.isFile())
|
|
626
|
+
throw new WorkspaceFsError('not_file', 'Path is not a file.');
|
|
627
|
+
return { resolved, stat };
|
|
628
|
+
}
|
|
629
|
+
function assertRealPathInsideAllowedRoot(resolvedPath, allowedRoot, trustedRoot) {
|
|
630
|
+
if (!allowedRoot)
|
|
631
|
+
return;
|
|
632
|
+
let realRoot;
|
|
633
|
+
let realTarget;
|
|
634
|
+
let realTrustedRoot = null;
|
|
635
|
+
try {
|
|
636
|
+
realRoot = fs.realpathSync(path.resolve(allowedRoot));
|
|
637
|
+
realTarget = fs.realpathSync(resolvedPath);
|
|
638
|
+
if (trustedRoot) {
|
|
639
|
+
realTrustedRoot = fs.realpathSync(path.resolve(trustedRoot));
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
throw new WorkspaceFsError('not_found', 'Path not found.');
|
|
644
|
+
}
|
|
645
|
+
if (realTrustedRoot) {
|
|
646
|
+
const rootRelativeToTrustedRoot = path.relative(realTrustedRoot, realRoot);
|
|
647
|
+
if (rootRelativeToTrustedRoot.startsWith('..') || path.isAbsolute(rootRelativeToTrustedRoot)) {
|
|
648
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Allowed root escapes trusted root.');
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const relativeRealPath = path.relative(realRoot, realTarget);
|
|
652
|
+
if (relativeRealPath.startsWith('..') || path.isAbsolute(relativeRealPath)) {
|
|
653
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Path escapes allowed root.');
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
const DIRECTORY_CURSOR_PREFIX = 'k:';
|
|
657
|
+
function classifyWorkspaceEntryCandidate(entry, absolutePath) {
|
|
658
|
+
const isSymlink = entry.isSymbolicLink();
|
|
659
|
+
if (isSymlink) {
|
|
660
|
+
const targetStat = fs.statSync(absolutePath, { throwIfNoEntry: false });
|
|
661
|
+
return {
|
|
662
|
+
dirent: entry,
|
|
663
|
+
absolutePath,
|
|
664
|
+
kind: targetStat?.isDirectory() ? 'directory' : 'file',
|
|
665
|
+
isSymlink,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
return {
|
|
669
|
+
dirent: entry,
|
|
670
|
+
absolutePath,
|
|
671
|
+
kind: entry.isDirectory() ? 'directory' : 'file',
|
|
672
|
+
isSymlink,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
function workspaceEntryFromCandidate(workspaceRoot, candidate) {
|
|
676
|
+
const stat = fs.statSync(candidate.absolutePath, { throwIfNoEntry: false });
|
|
677
|
+
return {
|
|
678
|
+
name: candidate.dirent.name,
|
|
679
|
+
path: toRelativeWorkspacePath(workspaceRoot, candidate.absolutePath),
|
|
680
|
+
kind: candidate.kind,
|
|
681
|
+
size: candidate.kind === 'directory' ? null : (stat?.size ?? null),
|
|
682
|
+
modifiedAt: stat?.mtimeMs ? Math.floor(stat.mtimeMs) : null,
|
|
683
|
+
...(candidate.isSymlink ? { isSymlink: true } : {}),
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
function paginateWorkspaceEntryCandidates(candidates, options) {
|
|
687
|
+
const limit = normalizeWorkbenchDirectoryLimit(options.limit);
|
|
688
|
+
const offset = parseWorkspaceEntryCursor(options.cursor, candidates);
|
|
689
|
+
const pageCandidates = candidates.slice(offset, offset + limit);
|
|
690
|
+
const nextOffset = offset + pageCandidates.length;
|
|
691
|
+
const hasMore = nextOffset < candidates.length;
|
|
692
|
+
return {
|
|
693
|
+
candidates: pageCandidates,
|
|
694
|
+
page: {
|
|
695
|
+
nextCursor: hasMore && pageCandidates.length ? encodeWorkspaceEntryCursor(pageCandidates[pageCandidates.length - 1]) : null,
|
|
696
|
+
hasMore,
|
|
697
|
+
limit,
|
|
698
|
+
},
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
function paginateDirents(dirents, options) {
|
|
702
|
+
const limit = normalizeDirectoryLimit(options.limit);
|
|
703
|
+
const offset = parseDirectoryCursor(options.cursor, dirents);
|
|
704
|
+
const pageDirents = dirents.slice(offset, offset + limit);
|
|
705
|
+
const nextOffset = offset + pageDirents.length;
|
|
706
|
+
const hasMore = nextOffset < dirents.length;
|
|
707
|
+
return {
|
|
708
|
+
dirents: pageDirents,
|
|
709
|
+
page: {
|
|
710
|
+
nextCursor: hasMore && pageDirents.length ? encodeDirectoryCursor(pageDirents[pageDirents.length - 1]) : null,
|
|
711
|
+
hasMore,
|
|
712
|
+
limit,
|
|
713
|
+
},
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
function compareDirents(left, right) {
|
|
717
|
+
return compareDirectoryEntryKeys(direntKind(left), left.name, direntKind(right), right.name);
|
|
718
|
+
}
|
|
719
|
+
function direntKind(entry) {
|
|
720
|
+
return entry.isDirectory() ? 'directory' : 'file';
|
|
721
|
+
}
|
|
722
|
+
function compareDirectoryEntryKeys(leftKind, leftName, rightKind, rightName) {
|
|
723
|
+
if (leftKind !== rightKind)
|
|
724
|
+
return leftKind === 'directory' ? -1 : 1;
|
|
725
|
+
return leftName.localeCompare(rightName);
|
|
726
|
+
}
|
|
727
|
+
function normalizePathSuggestionLimit(limit) {
|
|
728
|
+
if (typeof limit !== 'number' || !Number.isFinite(limit))
|
|
729
|
+
return 20;
|
|
730
|
+
return Math.min(100, Math.max(1, Math.floor(limit)));
|
|
731
|
+
}
|
|
732
|
+
function isGitRepositoryDirectory(directoryPath) {
|
|
733
|
+
try {
|
|
734
|
+
return fs.lstatSync(path.join(directoryPath, '.git'), { throwIfNoEntry: false })?.isDirectory() ?? false;
|
|
735
|
+
}
|
|
736
|
+
catch {
|
|
737
|
+
return false;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
function encodeDirectoryCursor(entry) {
|
|
741
|
+
const payload = { kind: direntKind(entry), name: entry.name };
|
|
742
|
+
return `${DIRECTORY_CURSOR_PREFIX}${Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')}`;
|
|
743
|
+
}
|
|
744
|
+
function normalizeDirectoryLimit(value) {
|
|
745
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
746
|
+
return 100;
|
|
747
|
+
return Math.min(200, Math.max(1, Math.floor(value)));
|
|
748
|
+
}
|
|
749
|
+
function normalizeWorkbenchDirectoryLimit(value) {
|
|
750
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
751
|
+
return 500;
|
|
752
|
+
return Math.min(1000, Math.max(1, Math.floor(value)));
|
|
753
|
+
}
|
|
754
|
+
function parseWorkspaceEntryCursor(value, candidates) {
|
|
755
|
+
const decoded = decodeDirectoryCursor(value);
|
|
756
|
+
if (!decoded)
|
|
757
|
+
return 0;
|
|
758
|
+
const index = candidates.findIndex((entry) => compareDirectoryEntryKeys(entry.kind, entry.dirent.name, decoded.kind, decoded.name) > 0);
|
|
759
|
+
return index === -1 ? candidates.length : index;
|
|
760
|
+
}
|
|
761
|
+
function parseDirectoryCursor(value, dirents) {
|
|
762
|
+
if (!value)
|
|
763
|
+
return 0;
|
|
764
|
+
const decoded = decodeDirectoryCursor(value);
|
|
765
|
+
if (decoded) {
|
|
766
|
+
const index = dirents.findIndex((entry) => compareDirectoryEntryKeys(direntKind(entry), entry.name, decoded.kind, decoded.name) > 0);
|
|
767
|
+
return index === -1 ? dirents.length : index;
|
|
768
|
+
}
|
|
769
|
+
const parsed = Number.parseInt(value, 10);
|
|
770
|
+
if (!Number.isFinite(parsed) || parsed < 0)
|
|
771
|
+
return 0;
|
|
772
|
+
return parsed;
|
|
773
|
+
}
|
|
774
|
+
function encodeWorkspaceEntryCursor(entry) {
|
|
775
|
+
const payload = { kind: entry.kind, name: entry.dirent.name };
|
|
776
|
+
return `${DIRECTORY_CURSOR_PREFIX}${Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')}`;
|
|
777
|
+
}
|
|
778
|
+
function decodeDirectoryCursor(value) {
|
|
779
|
+
if (!value?.startsWith(DIRECTORY_CURSOR_PREFIX))
|
|
780
|
+
return null;
|
|
781
|
+
try {
|
|
782
|
+
const parsed = JSON.parse(Buffer.from(value.slice(DIRECTORY_CURSOR_PREFIX.length), 'base64url').toString('utf8'));
|
|
783
|
+
const cursorKind = parsed.kind;
|
|
784
|
+
const cursorName = parsed.name;
|
|
785
|
+
if ((cursorKind === 'directory' || cursorKind === 'file') && typeof cursorName === 'string') {
|
|
786
|
+
return { kind: cursorKind, name: cursorName };
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
export function resetWorkspaceDirectory(workspaceRoot, agentName) {
|
|
795
|
+
const resolvedRoot = path.resolve(workspaceRoot);
|
|
796
|
+
if (resolvedRoot === path.parse(resolvedRoot).root) {
|
|
797
|
+
throw new WorkspaceFsError('io_error', 'Refusing to reset filesystem root.');
|
|
798
|
+
}
|
|
799
|
+
fs.mkdirSync(resolvedRoot, { recursive: true });
|
|
800
|
+
for (const entry of fs.readdirSync(resolvedRoot, { withFileTypes: true })) {
|
|
801
|
+
const target = path.join(resolvedRoot, entry.name);
|
|
802
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
803
|
+
}
|
|
804
|
+
ensureWorkspaceScaffold(resolvedRoot, agentName);
|
|
805
|
+
}
|
|
806
|
+
export function writeWorkspaceFile(workspaceRoot, relativePath, content, mode, options) {
|
|
807
|
+
if (options?.scaffold !== false)
|
|
808
|
+
ensureWorkspaceScaffold(workspaceRoot, options?.agentName);
|
|
809
|
+
const resolved = resolveRelativeWorkspacePath(workspaceRoot, relativePath);
|
|
810
|
+
assertWorkspaceMutationParentPath(workspaceRoot, resolved);
|
|
811
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
812
|
+
assertWorkspaceMutationPath(workspaceRoot, resolved);
|
|
813
|
+
const existing = fs.statSync(resolved, { throwIfNoEntry: false });
|
|
814
|
+
if (existing?.isDirectory()) {
|
|
815
|
+
throw new WorkspaceFsError('not_file', 'Path is a directory.');
|
|
816
|
+
}
|
|
817
|
+
const contentEncoding = options?.contentEncoding ?? 'utf8';
|
|
818
|
+
const payload = contentEncoding === 'base64'
|
|
819
|
+
? decodeBase64WorkspaceContent(content)
|
|
820
|
+
: content;
|
|
821
|
+
if (mode === 'append') {
|
|
822
|
+
fs.appendFileSync(resolved, payload, contentEncoding === 'base64' ? undefined : 'utf8');
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
fs.writeFileSync(resolved, payload, contentEncoding === 'base64' ? undefined : 'utf8');
|
|
826
|
+
}
|
|
827
|
+
const stat = fs.statSync(resolved, { throwIfNoEntry: false });
|
|
828
|
+
return {
|
|
829
|
+
relativePath: normalizeRelativePath(relativePath),
|
|
830
|
+
modifiedAt: stat?.mtimeMs ? Math.floor(stat.mtimeMs) : null,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
function resolveRelativeWorkspacePath(workspaceRoot, relativePath) {
|
|
834
|
+
const normalized = normalizeRelativePath(relativePath);
|
|
835
|
+
const absoluteRequested = normalized
|
|
836
|
+
? path.resolve(workspaceRoot, normalized)
|
|
837
|
+
: path.resolve(workspaceRoot);
|
|
838
|
+
try {
|
|
839
|
+
return resolveWorkspacePath(workspaceRoot, absoluteRequested);
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Path escapes workspace root.');
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
function assertWorkspaceMutationPath(workspaceRoot, resolvedPath) {
|
|
846
|
+
let workspaceRealPath;
|
|
847
|
+
let parentRealPath;
|
|
848
|
+
try {
|
|
849
|
+
workspaceRealPath = fs.realpathSync(workspaceRoot);
|
|
850
|
+
parentRealPath = fs.realpathSync(path.dirname(resolvedPath));
|
|
851
|
+
}
|
|
852
|
+
catch {
|
|
853
|
+
throw new WorkspaceFsError('not_found', 'Path not found.');
|
|
854
|
+
}
|
|
855
|
+
const parentRelative = path.relative(workspaceRealPath, parentRealPath);
|
|
856
|
+
if (parentRelative === '..' || parentRelative.startsWith(`..${path.sep}`) || path.isAbsolute(parentRelative)) {
|
|
857
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Path escapes workspace root.');
|
|
858
|
+
}
|
|
859
|
+
const existing = fs.lstatSync(resolvedPath, { throwIfNoEntry: false });
|
|
860
|
+
if (existing?.isSymbolicLink()) {
|
|
861
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Refusing to mutate a symlink path.');
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
function assertWorkspaceMutationParentPath(workspaceRoot, resolvedPath) {
|
|
865
|
+
let workspaceRealPath;
|
|
866
|
+
try {
|
|
867
|
+
workspaceRealPath = fs.realpathSync(workspaceRoot);
|
|
868
|
+
}
|
|
869
|
+
catch {
|
|
870
|
+
throw new WorkspaceFsError('not_found', 'Path not found.');
|
|
871
|
+
}
|
|
872
|
+
const workspaceAbsolute = path.resolve(workspaceRoot);
|
|
873
|
+
const parentRelative = path.relative(workspaceAbsolute, path.dirname(resolvedPath));
|
|
874
|
+
if (parentRelative === '..' || parentRelative.startsWith(`..${path.sep}`) || path.isAbsolute(parentRelative)) {
|
|
875
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Path escapes workspace root.');
|
|
876
|
+
}
|
|
877
|
+
let cursor = workspaceAbsolute;
|
|
878
|
+
for (const part of parentRelative.split(path.sep).filter(Boolean)) {
|
|
879
|
+
cursor = path.join(cursor, part);
|
|
880
|
+
const existing = fs.lstatSync(cursor, { throwIfNoEntry: false });
|
|
881
|
+
if (!existing)
|
|
882
|
+
return;
|
|
883
|
+
if (existing.isSymbolicLink()) {
|
|
884
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Refusing to mutate through a symlink path.');
|
|
885
|
+
}
|
|
886
|
+
let cursorRealPath;
|
|
887
|
+
try {
|
|
888
|
+
cursorRealPath = fs.realpathSync(cursor);
|
|
889
|
+
}
|
|
890
|
+
catch {
|
|
891
|
+
throw new WorkspaceFsError('not_found', 'Path not found.');
|
|
892
|
+
}
|
|
893
|
+
const realRelative = path.relative(workspaceRealPath, cursorRealPath);
|
|
894
|
+
if (realRelative === '..' || realRelative.startsWith(`..${path.sep}`) || path.isAbsolute(realRelative)) {
|
|
895
|
+
throw new WorkspaceFsError('path_outside_workspace', 'Path escapes workspace root.');
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
/** One-time migration: move legacy flat work-log to the weekly directory structure. */
|
|
900
|
+
export function migrateWorkspaceLegacyFiles(workspaceRoot) {
|
|
901
|
+
const legacyWorkLogPath = path.join(workspaceRoot, 'notes', 'work-log.md');
|
|
902
|
+
const legacyDestPath = path.join(workspaceRoot, 'notes', 'work-log', 'legacy.md');
|
|
903
|
+
if (!fs.existsSync(legacyWorkLogPath))
|
|
904
|
+
return;
|
|
905
|
+
if (!fs.existsSync(legacyDestPath)) {
|
|
906
|
+
fs.renameSync(legacyWorkLogPath, legacyDestPath);
|
|
907
|
+
}
|
|
908
|
+
else {
|
|
909
|
+
// legacy.md already exists; append orphan content rather than discard it
|
|
910
|
+
const orphan = fs.readFileSync(legacyWorkLogPath, 'utf8').trim();
|
|
911
|
+
if (orphan) {
|
|
912
|
+
fs.appendFileSync(legacyDestPath, `\n\n<!-- re-migrated -->\n${orphan}\n`, 'utf8');
|
|
913
|
+
}
|
|
914
|
+
fs.rmSync(legacyWorkLogPath);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
export function ensureWorkspaceScaffold(workspaceRoot, agentName) {
|
|
918
|
+
fs.mkdirSync(workspaceRoot, { recursive: true });
|
|
919
|
+
fs.mkdirSync(path.join(workspaceRoot, 'artifacts'), { recursive: true });
|
|
920
|
+
fs.mkdirSync(path.join(workspaceRoot, 'notes'), { recursive: true });
|
|
921
|
+
fs.mkdirSync(path.join(workspaceRoot, 'notes', 'channels'), { recursive: true });
|
|
922
|
+
fs.mkdirSync(path.join(workspaceRoot, 'notes', 'work-log'), { recursive: true });
|
|
923
|
+
fs.mkdirSync(path.join(workspaceRoot, 'notes', 'archive'), { recursive: true });
|
|
924
|
+
fs.mkdirSync(path.join(workspaceRoot, 'projects'), { recursive: true });
|
|
925
|
+
migrateWorkspaceLegacyFiles(workspaceRoot);
|
|
926
|
+
const taskNotesPath = path.join(workspaceRoot, 'notes', 'tasks.md');
|
|
927
|
+
if (!fs.existsSync(taskNotesPath)) {
|
|
928
|
+
fs.writeFileSync(taskNotesPath, [
|
|
929
|
+
'# Task Notes',
|
|
930
|
+
'',
|
|
931
|
+
'Active tasks only (todo / in_progress / in_review). Remove a task here when it is done and archive it.',
|
|
932
|
+
'',
|
|
933
|
+
].join('\n'), 'utf8');
|
|
934
|
+
}
|
|
935
|
+
const archiveIndexPath = path.join(workspaceRoot, 'notes', 'archive', 'INDEX.md');
|
|
936
|
+
if (!fs.existsSync(archiveIndexPath)) {
|
|
937
|
+
fs.writeFileSync(archiveIndexPath, [
|
|
938
|
+
'# Archive Index',
|
|
939
|
+
'',
|
|
940
|
+
'One line per week: YYYY-Www: <task title> — <one-line result>',
|
|
941
|
+
'',
|
|
942
|
+
].join('\n'), 'utf8');
|
|
943
|
+
}
|
|
944
|
+
const currentWeek = getCurrentBeijingISOWeek();
|
|
945
|
+
const currentWorkLogPath = path.join(workspaceRoot, 'notes', 'work-log', `${currentWeek}.md`);
|
|
946
|
+
if (!fs.existsSync(currentWorkLogPath)) {
|
|
947
|
+
fs.writeFileSync(currentWorkLogPath, [
|
|
948
|
+
`# Work Log ${currentWeek}`,
|
|
949
|
+
'',
|
|
950
|
+
'Append key decisions, completed work, and follow-ups for this Beijing week here.',
|
|
951
|
+
'',
|
|
952
|
+
].join('\n'), 'utf8');
|
|
953
|
+
}
|
|
954
|
+
const memoryPath = path.join(workspaceRoot, 'MEMORY.md');
|
|
955
|
+
if (!fs.existsSync(memoryPath)) {
|
|
956
|
+
fs.writeFileSync(memoryPath, [
|
|
957
|
+
'# Memory',
|
|
958
|
+
'',
|
|
959
|
+
'## Role',
|
|
960
|
+
buildRoleScaffoldLine(agentName),
|
|
961
|
+
'',
|
|
962
|
+
'## Key Knowledge',
|
|
963
|
+
'- Read `notes/user-preferences.md` for stable user preferences and conventions.',
|
|
964
|
+
'- Read `notes/channels/<name>.md` for each channel\'s current purpose and active context.',
|
|
965
|
+
'- Read `notes/tasks.md` for active tasks only (todo / in_progress / in_review).',
|
|
966
|
+
'- Weekly work log in `notes/work-log/`. Read the current week\'s file for recent decisions; if `notes/work-log/legacy.md` exists, scan it for pre-migration history when older decisions may matter.',
|
|
967
|
+
'- Completed task archive in `notes/archive/`. Scan `notes/archive/INDEX.md` to locate a specific week.',
|
|
968
|
+
'- Shared documents, when available, are mounted at `.library/shared`. Before modifying them, read `.library/shared/README.txt`.',
|
|
969
|
+
'- Keep agent-owned code, scripts, and small projects under `projects/`.',
|
|
970
|
+
'- Keep generated outputs, exports, logs, screenshots, and other execution results under `artifacts/`.',
|
|
971
|
+
'- Add topic-specific knowledge under `notes/<topic>.md` as needed.',
|
|
972
|
+
'',
|
|
973
|
+
'## Active Context',
|
|
974
|
+
'- First startup.',
|
|
975
|
+
'',
|
|
976
|
+
].join('\n'), 'utf8');
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
function normalizeRelativePath(relativePath) {
|
|
980
|
+
return relativePath.replace(/^\/+/, '').trim();
|
|
981
|
+
}
|
|
982
|
+
function toRelativeWorkspacePath(workspaceRoot, absolutePath) {
|
|
983
|
+
const relative = path.relative(path.resolve(workspaceRoot), path.resolve(absolutePath));
|
|
984
|
+
return relative === '.' ? '' : relative.split(path.sep).join('/');
|
|
985
|
+
}
|
|
986
|
+
export function getPreviewMimeType(filePath) {
|
|
987
|
+
const normalized = filePath.toLowerCase();
|
|
988
|
+
if (normalized.endsWith('.md')
|
|
989
|
+
|| normalized.endsWith('.mdx')
|
|
990
|
+
|| normalized.endsWith('.markdown')
|
|
991
|
+
|| normalized.endsWith('.mdown')
|
|
992
|
+
|| normalized.endsWith('.mkd')) {
|
|
993
|
+
return 'text/markdown';
|
|
994
|
+
}
|
|
995
|
+
if (normalized.endsWith('.png'))
|
|
996
|
+
return 'image/png';
|
|
997
|
+
if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg'))
|
|
998
|
+
return 'image/jpeg';
|
|
999
|
+
if (normalized.endsWith('.webp'))
|
|
1000
|
+
return 'image/webp';
|
|
1001
|
+
if (normalized.endsWith('.gif'))
|
|
1002
|
+
return 'image/gif';
|
|
1003
|
+
if (normalized.endsWith('.svg'))
|
|
1004
|
+
return 'image/svg+xml';
|
|
1005
|
+
if (normalized.endsWith('.avif'))
|
|
1006
|
+
return 'image/avif';
|
|
1007
|
+
if (normalized.endsWith('.bmp'))
|
|
1008
|
+
return 'image/bmp';
|
|
1009
|
+
if (normalized.endsWith('.ico'))
|
|
1010
|
+
return 'image/x-icon';
|
|
1011
|
+
return 'text/plain';
|
|
1012
|
+
}
|
|
1013
|
+
export function looksBinary(content) {
|
|
1014
|
+
const limit = Math.min(content.length, 8_000);
|
|
1015
|
+
for (let index = 0; index < limit; index += 1) {
|
|
1016
|
+
if (content[index] === 0)
|
|
1017
|
+
return true;
|
|
1018
|
+
}
|
|
1019
|
+
return false;
|
|
1020
|
+
}
|