@restregular/dsh-filekit 0.0.0-stage → 0.2.2
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/LICENSE +21 -0
- package/README.md +160 -2
- package/client.js +3385 -0
- package/cordis.patch.yml +10 -0
- package/icon.svg +15 -0
- package/index.js +651 -0
- package/lib/filekit.js +586 -0
- package/lib/notice.js +48 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +65 -4
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# dsh-filekit bundle patch: inserts the Host plugin (the `filekit` model tool
|
|
2
|
+
# and the same-origin loopback route `/dsh-filekit/api`) and the Client plugin
|
|
3
|
+
# (the enhanced workspace-files page, the FileKit page, its guide button,
|
|
4
|
+
# document-header actions, and the FileKit settings page).
|
|
5
|
+
#
|
|
6
|
+
# `name` must be byte-for-byte the package.json name: the client module graph
|
|
7
|
+
# locates the package by the Loader row name.
|
|
8
|
+
- insert:
|
|
9
|
+
- id: dsh-filekit
|
|
10
|
+
name: '@restregular/dsh-filekit'
|
package/icon.svg
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36" role="img" aria-label="FileKit">
|
|
2
|
+
<title>FileKit</title>
|
|
3
|
+
<defs>
|
|
4
|
+
<linearGradient id="filekit-brand" x1="0" y1="0" x2="1" y2="1">
|
|
5
|
+
<stop offset="0" stop-color="#f9ce56"/>
|
|
6
|
+
<stop offset="0.52" stop-color="#f4932a"/>
|
|
7
|
+
<stop offset="1" stop-color="#f4511e"/>
|
|
8
|
+
</linearGradient>
|
|
9
|
+
</defs>
|
|
10
|
+
<rect x="4" y="4" width="28" height="28" rx="8" fill="url(#filekit-brand)"/>
|
|
11
|
+
<rect x="4.55" y="4.55" width="26.9" height="26.9" rx="7.5" fill="none" stroke="#fff" stroke-opacity="0.22" stroke-width="0.9"/>
|
|
12
|
+
<path d="M9.5 14.2c0-1 .8-1.7 1.7-1.7h3.3l1.7 1.7h8.6c1 0 1.7.8 1.7 1.7v7.4c0 1-.8 1.7-1.7 1.7H11.2c-1 0-1.7-.8-1.7-1.7z" fill="#fff" fill-opacity="0.12" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/>
|
|
13
|
+
<path d="M13 19.3h6.5M13 21.7h4.5" fill="none" stroke="#fff" stroke-opacity="0.8" stroke-width="1.3" stroke-linecap="round"/>
|
|
14
|
+
<path d="M23.2 13.8l.85 2.75 2.75.85-2.75.85-.85 2.75-.85-2.75-2.75-.85 2.75-.85z" fill="#fff"/>
|
|
15
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-filekit — Host half.
|
|
3
|
+
*
|
|
4
|
+
* Two callers share one operation set (`./lib/filekit.js`):
|
|
5
|
+
* - the same-origin loopback route `/dsh-filekit/api`, used by the browser
|
|
6
|
+
* panel registered in the right sidebar's workspace-files tab;
|
|
7
|
+
* - the `filekit` model tool, so the Agent can perform and inspect the same
|
|
8
|
+
* operations instead of maintaining a second implementation.
|
|
9
|
+
*
|
|
10
|
+
* UI mutations inject a short change notice into the session's live Agent
|
|
11
|
+
* without waking it, so the next step the model takes already knows what the
|
|
12
|
+
* user changed.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-filekit
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { buildChangeNotice, isWellFormedUserMessage } from './lib/notice.js';
|
|
18
|
+
import {
|
|
19
|
+
FileKitError,
|
|
20
|
+
copyEntry,
|
|
21
|
+
createEntry,
|
|
22
|
+
DEFAULT_MAX_TEXT_BYTES,
|
|
23
|
+
DEFAULT_TRASH_DIR,
|
|
24
|
+
emptyTrash,
|
|
25
|
+
listDirectory,
|
|
26
|
+
listTrash,
|
|
27
|
+
moveEntry,
|
|
28
|
+
purgeTrashEntry,
|
|
29
|
+
readTextFile,
|
|
30
|
+
renameEntry,
|
|
31
|
+
resolveRoot,
|
|
32
|
+
restoreTrashEntry,
|
|
33
|
+
trashEntry,
|
|
34
|
+
writeTextFile,
|
|
35
|
+
} from './lib/filekit.js';
|
|
36
|
+
|
|
37
|
+
/** Loopback route prefix. */
|
|
38
|
+
const ROUTE = '/dsh-filekit';
|
|
39
|
+
|
|
40
|
+
/** Plugin version mirrored in package.json. */
|
|
41
|
+
const VERSION = '0.2.2';
|
|
42
|
+
|
|
43
|
+
/** Request body cap: larger than the editor's text cap, small enough to stay sane. */
|
|
44
|
+
const BODY_LIMIT = 16 * 1024 * 1024;
|
|
45
|
+
|
|
46
|
+
/** Operations the UI may call. */
|
|
47
|
+
const OPERATIONS = new Set([
|
|
48
|
+
'ping',
|
|
49
|
+
'workspace',
|
|
50
|
+
'list',
|
|
51
|
+
'read',
|
|
52
|
+
'write',
|
|
53
|
+
'create',
|
|
54
|
+
'rename',
|
|
55
|
+
'move',
|
|
56
|
+
'copy',
|
|
57
|
+
'delete',
|
|
58
|
+
'trashList',
|
|
59
|
+
'restore',
|
|
60
|
+
'purge',
|
|
61
|
+
'emptyTrash',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Small HTTP helpers
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/** @param {import('node:http').ServerResponse} res */
|
|
69
|
+
function sendJson(res, payload, status = 200) {
|
|
70
|
+
const body = JSON.stringify(payload);
|
|
71
|
+
res.writeHead(status, {
|
|
72
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
73
|
+
'Cache-Control': 'no-store',
|
|
74
|
+
});
|
|
75
|
+
res.end(body);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read and parse a JSON body.
|
|
80
|
+
* @param {import('node:http').IncomingMessage} req
|
|
81
|
+
* @param {number} limit
|
|
82
|
+
*/
|
|
83
|
+
function readJsonBody(req, limit = BODY_LIMIT) {
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
const chunks = [];
|
|
86
|
+
let total = 0;
|
|
87
|
+
req.on('data', (chunk) => {
|
|
88
|
+
total += chunk.length;
|
|
89
|
+
if (total > limit) {
|
|
90
|
+
reject(new FileKitError('too-large', 'Request body is too large.'));
|
|
91
|
+
req.destroy();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
chunks.push(chunk);
|
|
95
|
+
});
|
|
96
|
+
req.on('end', () => {
|
|
97
|
+
try {
|
|
98
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
99
|
+
const value = text === '' ? {} : JSON.parse(text);
|
|
100
|
+
if (typeof value !== 'object' || value === null) throw new Error('not an object');
|
|
101
|
+
resolve(value);
|
|
102
|
+
} catch {
|
|
103
|
+
reject(new FileKitError('bad-input', 'Request body is not a JSON object.'));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
req.on('error', reject);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Only the same machine may drive the route. */
|
|
111
|
+
function isLoopback(req) {
|
|
112
|
+
const address = req.socket?.remoteAddress ?? '';
|
|
113
|
+
return (
|
|
114
|
+
address === '127.0.0.1' ||
|
|
115
|
+
address === '::1' ||
|
|
116
|
+
address.startsWith('::ffff:127.') ||
|
|
117
|
+
address === ''
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Reject cross-origin form posts; the page is served from the same origin. */
|
|
122
|
+
function sameOrigin(req) {
|
|
123
|
+
const origin = req.headers.origin;
|
|
124
|
+
if (typeof origin !== 'string' || origin === '') return true;
|
|
125
|
+
try {
|
|
126
|
+
return new URL(origin).host === req.headers.host;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Workspace validation
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
/** Case-insensitive path equality (Windows) with lexical normalization. */
|
|
137
|
+
function samePath(a, b) {
|
|
138
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
139
|
+
const norm = (value) => {
|
|
140
|
+
let out = value.replace(/[\\/]+/gu, '/').replace(/\/+$/u, '');
|
|
141
|
+
if (process.platform === 'win32') out = out.toLowerCase();
|
|
142
|
+
return out;
|
|
143
|
+
};
|
|
144
|
+
return norm(a) === norm(b);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Whether `child` is lexically inside `parent`. */
|
|
148
|
+
function withinPath(parent, child) {
|
|
149
|
+
if (typeof parent !== 'string' || typeof child !== 'string') return false;
|
|
150
|
+
let p = parent.replace(/[\\/]+/gu, '/').replace(/\/+$/u, '');
|
|
151
|
+
let c = child.replace(/[\\/]+/gu, '/').replace(/\/+$/u, '');
|
|
152
|
+
if (process.platform === 'win32') {
|
|
153
|
+
p = p.toLowerCase();
|
|
154
|
+
c = c.toLowerCase();
|
|
155
|
+
}
|
|
156
|
+
return c === p || c.startsWith(p + '/');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Resolve the workspace root the request is allowed to touch.
|
|
161
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
162
|
+
* @param {Record<string, unknown>} args
|
|
163
|
+
* @returns {Promise<string>}
|
|
164
|
+
*/
|
|
165
|
+
async function resolveRequestRoot(ctx, args) {
|
|
166
|
+
const requested = typeof args.root === 'string' ? args.root : '';
|
|
167
|
+
if (requested === '') {
|
|
168
|
+
throw new FileKitError('bad-root', 'root is required.');
|
|
169
|
+
}
|
|
170
|
+
const root = await resolveRoot(requested);
|
|
171
|
+
if (await isKnownRoot(ctx, root, args)) return root;
|
|
172
|
+
throw new FileKitError('unknown-workspace', 'This directory is not a registered workspace.');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A root is allowed when it is a registered workspace or the exact working
|
|
177
|
+
* directory of the requesting session.
|
|
178
|
+
*/
|
|
179
|
+
async function isKnownRoot(ctx, root, args) {
|
|
180
|
+
const registry = ctx.get('workspaceRegistry');
|
|
181
|
+
if (registry !== undefined && typeof registry.list === 'function') {
|
|
182
|
+
const known = registry.list();
|
|
183
|
+
if (Array.isArray(known) && known.some((workspace) => samePath(workspace?.path, root))) return true;
|
|
184
|
+
}
|
|
185
|
+
const sessionId = typeof args.sessionId === 'string' ? args.sessionId : '';
|
|
186
|
+
if (sessionId !== '') {
|
|
187
|
+
const agent = ctx.get('agents')?.get?.(sessionId);
|
|
188
|
+
const cwd = agent?.session?.header?.cwd;
|
|
189
|
+
if (samePath(cwd, root)) return true;
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Resolve a workspace for a caller that only knows a session or a file.
|
|
196
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
197
|
+
* @param {{ sessionId?: string, file?: string }} args
|
|
198
|
+
*/
|
|
199
|
+
async function findWorkspace(ctx, args) {
|
|
200
|
+
const registry = ctx.get('workspaceRegistry');
|
|
201
|
+
const candidates = typeof registry?.list === 'function' ? registry.list() : [];
|
|
202
|
+
const sessionId = typeof args.sessionId === 'string' ? args.sessionId : '';
|
|
203
|
+
if (sessionId !== '') {
|
|
204
|
+
const agent = ctx.get('agents')?.get?.(sessionId);
|
|
205
|
+
const session = agent?.session;
|
|
206
|
+
if (session !== undefined) {
|
|
207
|
+
let cwd = session.header?.cwd;
|
|
208
|
+
const workingDirectory = ctx.get('workingDirectory');
|
|
209
|
+
try {
|
|
210
|
+
if (workingDirectory !== undefined) cwd = workingDirectory.get(session) ?? cwd;
|
|
211
|
+
} catch {
|
|
212
|
+
/* fall back to the header cwd */
|
|
213
|
+
}
|
|
214
|
+
if (typeof cwd === 'string' && cwd !== '') {
|
|
215
|
+
const resolved = await resolveRoot(cwd).catch(() => undefined);
|
|
216
|
+
if (resolved !== undefined) return resolved;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const file = typeof args.file === 'string' ? args.file : '';
|
|
221
|
+
if (file !== '' && Array.isArray(candidates)) {
|
|
222
|
+
const matches = candidates
|
|
223
|
+
.filter((workspace) => withinPath(workspace?.path, file))
|
|
224
|
+
.sort((a, b) => String(b.path).length - String(a.path).length);
|
|
225
|
+
if (matches.length > 0) return resolveRoot(matches[0].path);
|
|
226
|
+
}
|
|
227
|
+
throw new FileKitError('no-workspace', 'Could not resolve a workspace for this request.');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// Operations
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Dispatch one operation.
|
|
236
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
237
|
+
* @param {string} op
|
|
238
|
+
* @param {Record<string, unknown>} args
|
|
239
|
+
*/
|
|
240
|
+
async function runOperation(ctx, op, args) {
|
|
241
|
+
const maxTextBytes = DEFAULT_MAX_TEXT_BYTES;
|
|
242
|
+
switch (op) {
|
|
243
|
+
case 'ping':
|
|
244
|
+
return { plugin: 'dsh-filekit', version: VERSION };
|
|
245
|
+
case 'workspace':
|
|
246
|
+
return { root: await findWorkspace(ctx, args) };
|
|
247
|
+
case 'list': {
|
|
248
|
+
const root = await resolveRequestRoot(ctx, args);
|
|
249
|
+
return listDirectory(root, args.path, { trashDir: DEFAULT_TRASH_DIR });
|
|
250
|
+
}
|
|
251
|
+
case 'read': {
|
|
252
|
+
const root = await resolveRequestRoot(ctx, args);
|
|
253
|
+
return readTextFile(root, args.path, { maxTextBytes });
|
|
254
|
+
}
|
|
255
|
+
case 'write': {
|
|
256
|
+
const root = await resolveRequestRoot(ctx, args);
|
|
257
|
+
const result = await writeTextFile(root, args.path, args.content, {
|
|
258
|
+
expectedMtimeMs: typeof args.expectedMtimeMs === 'number' ? args.expectedMtimeMs : undefined,
|
|
259
|
+
maxTextBytes,
|
|
260
|
+
});
|
|
261
|
+
return { ...result, change: { kind: 'write', ...result } };
|
|
262
|
+
}
|
|
263
|
+
case 'create': {
|
|
264
|
+
const root = await resolveRequestRoot(ctx, args);
|
|
265
|
+
const result = await createEntry(root, args.path, args.kind === 'directory' ? 'directory' : 'file');
|
|
266
|
+
return { ...result, change: { kind: 'create', path: result.path, entryKind: result.kind } };
|
|
267
|
+
}
|
|
268
|
+
case 'rename': {
|
|
269
|
+
const root = await resolveRequestRoot(ctx, args);
|
|
270
|
+
const result = await renameEntry(root, args.path, args.name);
|
|
271
|
+
return { ...result, change: { kind: 'rename', ...result } };
|
|
272
|
+
}
|
|
273
|
+
case 'move': {
|
|
274
|
+
const root = await resolveRequestRoot(ctx, args);
|
|
275
|
+
const result = await moveEntry(root, args.path, args.toDir, { unique: args.unique === true });
|
|
276
|
+
return { ...result, change: { kind: 'move', ...result } };
|
|
277
|
+
}
|
|
278
|
+
case 'copy': {
|
|
279
|
+
const root = await resolveRequestRoot(ctx, args);
|
|
280
|
+
const result = await copyEntry(root, args.path, args.toDir, { unique: args.unique === true });
|
|
281
|
+
return { ...result, change: { kind: 'copy', ...result } };
|
|
282
|
+
}
|
|
283
|
+
case 'delete': {
|
|
284
|
+
const root = await resolveRequestRoot(ctx, args);
|
|
285
|
+
const result = await trashEntry(root, args.path, { trashDir: DEFAULT_TRASH_DIR });
|
|
286
|
+
return { ...result, change: { kind: 'delete', path: result.entry.originalPath, entry: result.entry } };
|
|
287
|
+
}
|
|
288
|
+
case 'trashList': {
|
|
289
|
+
const root = await resolveRequestRoot(ctx, args);
|
|
290
|
+
return listTrash(root, { trashDir: DEFAULT_TRASH_DIR });
|
|
291
|
+
}
|
|
292
|
+
case 'restore': {
|
|
293
|
+
const root = await resolveRequestRoot(ctx, args);
|
|
294
|
+
const result = await restoreTrashEntry(root, args.id, { trashDir: DEFAULT_TRASH_DIR });
|
|
295
|
+
return { ...result, change: { kind: 'restore', ...result } };
|
|
296
|
+
}
|
|
297
|
+
case 'purge': {
|
|
298
|
+
const root = await resolveRequestRoot(ctx, args);
|
|
299
|
+
const result = await purgeTrashEntry(root, args.id, { trashDir: DEFAULT_TRASH_DIR });
|
|
300
|
+
return { ...result, change: { kind: 'purge', ...result } };
|
|
301
|
+
}
|
|
302
|
+
case 'emptyTrash': {
|
|
303
|
+
const root = await resolveRequestRoot(ctx, args);
|
|
304
|
+
const result = await emptyTrash(root, { trashDir: DEFAULT_TRASH_DIR });
|
|
305
|
+
return { ...result, change: { kind: 'emptyTrash', ...result } };
|
|
306
|
+
}
|
|
307
|
+
default:
|
|
308
|
+
throw new FileKitError('unknown-op', `Unknown operation "${op}".`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// Change context injection
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
/** Human-readable one-line description of one mutation. */
|
|
317
|
+
function describeChange(change) {
|
|
318
|
+
switch (change.kind) {
|
|
319
|
+
case 'write':
|
|
320
|
+
return `${change.created ? '新建并写入' : '编辑'} ${change.path}(${change.lines} 行 / ${formatBytes(change.size)})`;
|
|
321
|
+
case 'create':
|
|
322
|
+
return `${change.entryKind === 'directory' ? '新建目录' : '新建文件'} ${change.path}`;
|
|
323
|
+
case 'rename':
|
|
324
|
+
return `重命名 ${change.from} → ${change.path}`;
|
|
325
|
+
case 'move':
|
|
326
|
+
return `移动 ${change.from} → ${change.path}`;
|
|
327
|
+
case 'copy':
|
|
328
|
+
return `复制 ${change.from} → ${change.path}`;
|
|
329
|
+
case 'delete':
|
|
330
|
+
return `删除 ${change.path}(已移入 ${DEFAULT_TRASH_DIR},可恢复)`;
|
|
331
|
+
case 'restore':
|
|
332
|
+
return `从回收站恢复 ${change.path}`;
|
|
333
|
+
case 'purge':
|
|
334
|
+
return `永久删除回收站条目 ${change.id}`;
|
|
335
|
+
case 'emptyTrash':
|
|
336
|
+
return `清空回收站(${change.count} 项)`;
|
|
337
|
+
default:
|
|
338
|
+
return JSON.stringify(change);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Compact byte counter. */
|
|
343
|
+
function formatBytes(size) {
|
|
344
|
+
if (typeof size !== 'number' || !Number.isFinite(size)) return '?';
|
|
345
|
+
if (size < 1024) return `${size} B`;
|
|
346
|
+
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
|
347
|
+
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Tell the requesting session's Agent what the user just changed. `inject`
|
|
352
|
+
* never wakes an idle Agent: the notice waits for the next admitted step.
|
|
353
|
+
* Kept to two short lines: the model only needs the fact, not a transcript.
|
|
354
|
+
*
|
|
355
|
+
* The inbox does not normalise its input: the object handed to `inject` is
|
|
356
|
+
* committed to the session log and later serialised into the provider request,
|
|
357
|
+
* so it must be a complete user message (`id` + `role` + `content` + `source`).
|
|
358
|
+
* A notice without `role` poisons the whole session with a 422
|
|
359
|
+
* `missing field role` on every later request.
|
|
360
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
361
|
+
* @param {string} sessionId
|
|
362
|
+
* @param {Record<string, unknown>} change
|
|
363
|
+
* @param {string} root
|
|
364
|
+
*/
|
|
365
|
+
async function injectChange(ctx, sessionId, change, root) {
|
|
366
|
+
if (typeof sessionId !== 'string' || sessionId === '') return;
|
|
367
|
+
const agent = ctx.get('agents')?.get?.(sessionId);
|
|
368
|
+
if (agent === undefined || typeof agent.inject !== 'function') return;
|
|
369
|
+
const text = `[FileKit · 工作区变更] ${describeChange(change)}(工作区:${root})。需要更多操作可调用 filekit 工具。`;
|
|
370
|
+
const message = buildChangeNotice(text);
|
|
371
|
+
if (!isWellFormedUserMessage(message)) {
|
|
372
|
+
ctx.logger?.warn?.('dsh-filekit: change notice rejected: malformed user message');
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
agent.inject(message);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
ctx.logger?.warn?.(`dsh-filekit: change notice failed: ${error?.message ?? error}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// HTTP route
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Mount the loopback route.
|
|
388
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
389
|
+
*/
|
|
390
|
+
function mountRoutes(ctx) {
|
|
391
|
+
const webServer = ctx.webServer ?? ctx.get('webServer');
|
|
392
|
+
if (webServer === undefined) return;
|
|
393
|
+
ctx.effect(
|
|
394
|
+
() =>
|
|
395
|
+
webServer.register({
|
|
396
|
+
kind: 'prefix',
|
|
397
|
+
path: ROUTE,
|
|
398
|
+
async handler(req, res) {
|
|
399
|
+
if (!isLoopback(req) || !sameOrigin(req)) {
|
|
400
|
+
sendJson(res, { ok: false, code: 'forbidden', error: 'Forbidden.' }, 403);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
if (req.method === 'GET') {
|
|
405
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
406
|
+
if (!url.pathname.startsWith(ROUTE)) {
|
|
407
|
+
sendJson(res, { ok: false, code: 'not-found', error: 'Not found.' }, 404);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const op = url.searchParams.get('op') ?? 'ping';
|
|
411
|
+
if (op !== 'ping' && op !== 'workspace') {
|
|
412
|
+
sendJson(res, { ok: false, code: 'method', error: 'Use POST for this operation.' }, 405);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const args = {
|
|
416
|
+
sessionId: url.searchParams.get('sessionId') ?? undefined,
|
|
417
|
+
file: url.searchParams.get('file') ?? undefined,
|
|
418
|
+
};
|
|
419
|
+
const result = await runOperation(ctx, op, args);
|
|
420
|
+
sendJson(res, { ok: true, ...result });
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (req.method !== 'POST') {
|
|
424
|
+
sendJson(res, { ok: false, code: 'method', error: 'Only GET and POST are supported.' }, 405);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const body = await readJsonBody(req);
|
|
428
|
+
const op = String(body.op ?? '');
|
|
429
|
+
if (!OPERATIONS.has(op)) {
|
|
430
|
+
sendJson(res, { ok: false, code: 'unknown-op', error: `Unknown operation "${op}".` }, 404);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const result = await runOperation(ctx, op, body);
|
|
434
|
+
const change = result?.change;
|
|
435
|
+
if (change !== undefined && body.source === 'ui' && body.notify !== false && change.unchanged !== true) {
|
|
436
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
|
|
437
|
+
const root = typeof result?.root === 'string' ? result.root : '';
|
|
438
|
+
await injectChange(ctx, sessionId, change, root);
|
|
439
|
+
}
|
|
440
|
+
sendJson(res, { ok: true, ...result });
|
|
441
|
+
} catch (error) {
|
|
442
|
+
if (error instanceof FileKitError) {
|
|
443
|
+
sendJson(res, { ok: false, code: error.code, error: error.message }, 400);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
ctx.logger?.warn?.(`dsh-filekit: ${error?.stack ?? error}`);
|
|
447
|
+
sendJson(res, { ok: false, code: 'internal', error: String(error?.message ?? error) }, 500);
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
}),
|
|
451
|
+
'dsh-filekit: loopback route',
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// Model tool
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
/** Tool parameter schema. */
|
|
460
|
+
const TOOL_PARAMETERS = {
|
|
461
|
+
type: 'object',
|
|
462
|
+
properties: {
|
|
463
|
+
action: {
|
|
464
|
+
type: 'string',
|
|
465
|
+
enum: [
|
|
466
|
+
'list',
|
|
467
|
+
'read',
|
|
468
|
+
'write',
|
|
469
|
+
'create',
|
|
470
|
+
'rename',
|
|
471
|
+
'move',
|
|
472
|
+
'copy',
|
|
473
|
+
'delete',
|
|
474
|
+
'trash',
|
|
475
|
+
'restore',
|
|
476
|
+
'purge',
|
|
477
|
+
'emptyTrash',
|
|
478
|
+
],
|
|
479
|
+
description: 'Operation to perform inside the current session workspace.',
|
|
480
|
+
},
|
|
481
|
+
path: {
|
|
482
|
+
type: 'string',
|
|
483
|
+
description: 'Workspace-relative path (use "/" separators). The workspace root itself is the empty string only for list.',
|
|
484
|
+
},
|
|
485
|
+
name: {
|
|
486
|
+
type: 'string',
|
|
487
|
+
description: 'New entry name for rename (no path separators).',
|
|
488
|
+
},
|
|
489
|
+
toDir: {
|
|
490
|
+
type: 'string',
|
|
491
|
+
description: 'Destination directory (workspace-relative) for move or copy.',
|
|
492
|
+
},
|
|
493
|
+
content: {
|
|
494
|
+
type: 'string',
|
|
495
|
+
description: 'UTF-8 text for write.',
|
|
496
|
+
},
|
|
497
|
+
kind: {
|
|
498
|
+
type: 'string',
|
|
499
|
+
enum: ['file', 'directory'],
|
|
500
|
+
description: 'Entry kind for create; defaults to file.',
|
|
501
|
+
},
|
|
502
|
+
id: {
|
|
503
|
+
type: 'string',
|
|
504
|
+
description: 'Trash entry id for restore or purge.',
|
|
505
|
+
},
|
|
506
|
+
expectedMtimeMs: {
|
|
507
|
+
type: 'number',
|
|
508
|
+
description: 'Optional optimistic-concurrency guard for write: the mtimeMs returned by read.',
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
required: ['action'],
|
|
512
|
+
additionalProperties: false,
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
/** Resolve the session's effective workspace for the tool. */
|
|
516
|
+
async function toolRoot(ctx, exec) {
|
|
517
|
+
const session = exec?.agent?.session;
|
|
518
|
+
if (session === undefined) throw new FileKitError('no-session', 'No live session for this tool call.');
|
|
519
|
+
let cwd = session.header?.cwd;
|
|
520
|
+
const workingDirectory = ctx.get('workingDirectory');
|
|
521
|
+
try {
|
|
522
|
+
if (workingDirectory !== undefined) cwd = workingDirectory.get(session) ?? cwd;
|
|
523
|
+
} catch {
|
|
524
|
+
/* header cwd is the fallback */
|
|
525
|
+
}
|
|
526
|
+
if (typeof cwd !== 'string' || cwd === '') throw new FileKitError('no-workspace', 'The session has no workspace directory.');
|
|
527
|
+
return resolveRoot(cwd);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Format a tool result as model-facing text. */
|
|
531
|
+
function formatToolResult(action, value) {
|
|
532
|
+
switch (action) {
|
|
533
|
+
case 'list': {
|
|
534
|
+
const lines = [`Directory ${value.path === '' ? '.' : value.path} (${value.entries.length} entries):`];
|
|
535
|
+
for (const entry of value.entries) {
|
|
536
|
+
const suffix = entry.kind === 'directory' ? '/' : entry.size !== undefined ? ` (${formatBytes(entry.size)})` : '';
|
|
537
|
+
lines.push(`- ${entry.kind === 'directory' ? '[dir]' : '[file]'} ${entry.path}${suffix}`);
|
|
538
|
+
}
|
|
539
|
+
if (value.entries.length === 0) lines.push('(empty)');
|
|
540
|
+
return lines.join('\n');
|
|
541
|
+
}
|
|
542
|
+
case 'read': {
|
|
543
|
+
if (value.binary === true) return `Binary file ${value.path} (${formatBytes(value.size)}); it has no text representation.`;
|
|
544
|
+
const note = value.truncated === true ? `\n\n[truncated at ${formatBytes(DEFAULT_MAX_TEXT_BYTES)}; file is ${formatBytes(value.size)}]` : '';
|
|
545
|
+
return `File ${value.path} (${value.lines ?? ''}${value.lines === undefined ? '' : ' lines'}; ${formatBytes(value.size)}):\n\n${value.text}${note}`;
|
|
546
|
+
}
|
|
547
|
+
case 'write':
|
|
548
|
+
return `${value.created ? 'Created' : 'Wrote'} ${value.path} (${value.lines} lines, ${formatBytes(value.size)}).`;
|
|
549
|
+
case 'create':
|
|
550
|
+
return `Created ${value.kind} ${value.path}.`;
|
|
551
|
+
case 'rename':
|
|
552
|
+
return `Renamed ${value.from} -> ${value.path}.`;
|
|
553
|
+
case 'move':
|
|
554
|
+
return `Moved ${value.from} -> ${value.path}.`;
|
|
555
|
+
case 'copy':
|
|
556
|
+
return `Copied ${value.from} -> ${value.path}.`;
|
|
557
|
+
case 'delete':
|
|
558
|
+
return `Moved ${value.entry.name} to the workspace trash (entry ${value.entry.id}); restore with action "restore".`;
|
|
559
|
+
case 'trash': {
|
|
560
|
+
if (value.entries.length === 0) return 'The workspace trash is empty.';
|
|
561
|
+
return [
|
|
562
|
+
'Workspace trash (newest first):',
|
|
563
|
+
...value.entries.map((entry) => `- ${entry.id} | ${entry.name} | ${entry.originalPath} | ${entry.deletedAt}`),
|
|
564
|
+
].join('\n');
|
|
565
|
+
}
|
|
566
|
+
case 'restore':
|
|
567
|
+
return `Restored ${value.path} from the workspace trash.`;
|
|
568
|
+
case 'purge':
|
|
569
|
+
return `Permanently deleted trash entry ${value.id}.`;
|
|
570
|
+
case 'emptyTrash':
|
|
571
|
+
return `Emptied the workspace trash (${value.count} entries).`;
|
|
572
|
+
default:
|
|
573
|
+
return JSON.stringify(value);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Build the `filekit` tool definition. */
|
|
578
|
+
function filekitTool(ctx) {
|
|
579
|
+
return {
|
|
580
|
+
name: 'filekit',
|
|
581
|
+
description:
|
|
582
|
+
'Manage files in the current session workspace through one shared operation set: list, read, write, create, rename, move, copy, delete, trash, restore, purge, and emptyTrash. Paths are workspace-relative and cannot escape the workspace root. Deletes are recoverable: they move the entry into <workspace>/.dsh-trash, and "trash"/"restore"/"purge"/"emptyTrash" manage that recycle bin. Use this tool when the user asks to reorganize, edit, or recover workspace files; it performs the same operations as the right-sidebar file panel.',
|
|
583
|
+
parameters: TOOL_PARAMETERS,
|
|
584
|
+
output: {
|
|
585
|
+
schema: { type: 'string' },
|
|
586
|
+
render(_args, value) {
|
|
587
|
+
return [{ type: 'text', text: String(value) }];
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
async execute(args, exec) {
|
|
591
|
+
const root = await toolRoot(ctx, exec);
|
|
592
|
+
const action = String(args.action ?? '');
|
|
593
|
+
switch (action) {
|
|
594
|
+
case 'list':
|
|
595
|
+
return formatToolResult(action, await listDirectory(root, args.path ?? '', { trashDir: DEFAULT_TRASH_DIR }));
|
|
596
|
+
case 'read': {
|
|
597
|
+
const value = await readTextFile(root, args.path, { maxTextBytes: DEFAULT_MAX_TEXT_BYTES });
|
|
598
|
+
if (value.binary === true) return formatToolResult(action, value);
|
|
599
|
+
return formatToolResult(action, { ...value, lines: value.text === '' ? 0 : value.text.split(/\r\n|\r|\n/u).length });
|
|
600
|
+
}
|
|
601
|
+
case 'write': {
|
|
602
|
+
const value = await writeTextFile(root, args.path, args.content, {
|
|
603
|
+
expectedMtimeMs: typeof args.expectedMtimeMs === 'number' ? args.expectedMtimeMs : undefined,
|
|
604
|
+
maxTextBytes: DEFAULT_MAX_TEXT_BYTES,
|
|
605
|
+
});
|
|
606
|
+
return formatToolResult(action, value);
|
|
607
|
+
}
|
|
608
|
+
case 'create':
|
|
609
|
+
return formatToolResult(action, await createEntry(root, args.path, args.kind === 'directory' ? 'directory' : 'file'));
|
|
610
|
+
case 'rename':
|
|
611
|
+
return formatToolResult(action, await renameEntry(root, args.path, args.name));
|
|
612
|
+
case 'move':
|
|
613
|
+
return formatToolResult(action, await moveEntry(root, args.path, args.toDir));
|
|
614
|
+
case 'copy':
|
|
615
|
+
return formatToolResult(action, await copyEntry(root, args.path, args.toDir));
|
|
616
|
+
case 'delete':
|
|
617
|
+
return formatToolResult(action, await trashEntry(root, args.path, { trashDir: DEFAULT_TRASH_DIR }));
|
|
618
|
+
case 'trash':
|
|
619
|
+
return formatToolResult(action, await listTrash(root, { trashDir: DEFAULT_TRASH_DIR }));
|
|
620
|
+
case 'restore':
|
|
621
|
+
return formatToolResult(action, await restoreTrashEntry(root, args.id, { trashDir: DEFAULT_TRASH_DIR }));
|
|
622
|
+
case 'purge':
|
|
623
|
+
return formatToolResult(action, await purgeTrashEntry(root, args.id, { trashDir: DEFAULT_TRASH_DIR }));
|
|
624
|
+
case 'emptyTrash':
|
|
625
|
+
return formatToolResult(action, await emptyTrash(root, { trashDir: DEFAULT_TRASH_DIR }));
|
|
626
|
+
default:
|
|
627
|
+
throw new FileKitError('bad-action', `Unknown filekit action "${action}".`);
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ---------------------------------------------------------------------------
|
|
634
|
+
// Plugin body
|
|
635
|
+
// ---------------------------------------------------------------------------
|
|
636
|
+
|
|
637
|
+
/** Loader identity of the Host half. */
|
|
638
|
+
export const name = 'dsh-filekit';
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Mount the model tool and the browser route. Both halves degrade
|
|
642
|
+
* independently: without `webServer` the tool still works, and without `tools`
|
|
643
|
+
* the panel still works.
|
|
644
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
645
|
+
*/
|
|
646
|
+
export function apply(ctx) {
|
|
647
|
+
ctx.inject(['tools'], (scoped) => {
|
|
648
|
+
scoped.effect(() => scoped.tools.register(filekitTool(scoped)), 'dsh-filekit: filekit tool');
|
|
649
|
+
});
|
|
650
|
+
ctx.inject(['webServer'], (scoped) => mountRoutes(scoped));
|
|
651
|
+
}
|