dsh-mesh-node 0.0.5 → 0.0.6
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/package.json +1 -1
- package/src/core/config.js +13 -0
- package/src/core/roles.js +8 -1
- package/src/tools/files.js +412 -0
- package/src/tools/index.js +4 -2
package/package.json
CHANGED
package/src/core/config.js
CHANGED
|
@@ -59,6 +59,13 @@ const M3_DEFAULT_ALLOW = [
|
|
|
59
59
|
export const DEFAULT_ALLOW = [
|
|
60
60
|
...M3_DEFAULT_ALLOW,
|
|
61
61
|
'fs_stats',
|
|
62
|
+
'file_read_chunk',
|
|
63
|
+
'file_hash',
|
|
64
|
+
'transfer_usage',
|
|
65
|
+
'find_files',
|
|
66
|
+
'search_text',
|
|
67
|
+
'tool_inventory',
|
|
68
|
+
'file_write_chunk',
|
|
62
69
|
'host_instances',
|
|
63
70
|
'mesh_overview',
|
|
64
71
|
'host_self',
|
|
@@ -178,6 +185,12 @@ export function defaultConfig() {
|
|
|
178
185
|
channelPollMs: 800,
|
|
179
186
|
channelMaxMessages: 60
|
|
180
187
|
},
|
|
188
|
+
/** 文件传输限额(公网/内网流量有成本):单块/单文件/每日总量,字节 */
|
|
189
|
+
transfer: {
|
|
190
|
+
maxChunkBytes: 4194304,
|
|
191
|
+
maxFileBytes: 268435456,
|
|
192
|
+
dailyBytes: 2147483648
|
|
193
|
+
},
|
|
181
194
|
/** One-time pairing codes: a peer redeems a short code for its own bearer token. */
|
|
182
195
|
pairing: {
|
|
183
196
|
enabled: true,
|
package/src/core/roles.js
CHANGED
|
@@ -43,7 +43,13 @@ const OBSERVER = [
|
|
|
43
43
|
'delegate_list',
|
|
44
44
|
'delegate_status',
|
|
45
45
|
'delegate_candidates',
|
|
46
|
-
'repair_status'
|
|
46
|
+
'repair_status',
|
|
47
|
+
'file_read_chunk',
|
|
48
|
+
'file_hash',
|
|
49
|
+
'transfer_usage',
|
|
50
|
+
'find_files',
|
|
51
|
+
'search_text',
|
|
52
|
+
'tool_inventory'
|
|
47
53
|
]
|
|
48
54
|
|
|
49
55
|
const CONTROLLER = [
|
|
@@ -63,6 +69,7 @@ const CONTROLLER = [
|
|
|
63
69
|
export const ROLE_PRESETS = {
|
|
64
70
|
observer: { allow: OBSERVER, description: '只读观察:状态 / 文件 / git / 日志 / 委托列表' },
|
|
65
71
|
controller: { allow: CONTROLLER, description: '协调者:观察 + 委托 + 桌面控制(不能跨机写/重启/改配置)' },
|
|
72
|
+
operator: { allow: [...CONTROLLER, 'file_write_chunk'], description: '操作者:controller + 文件写入/传输(危险,需用户显式指定)' },
|
|
66
73
|
node: { allow: null, description: '节点:继承本机 ACL(与旧行为一致)' },
|
|
67
74
|
admin: { allow: null, description: '管理员:预留,当前等同 node(真正提权需改节点 ACL)' }
|
|
68
75
|
}
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// @generated files:文件传输 + 快速检索 + 工具清单(封装 Everything/rg/fd/7za 等 AI 友好 CLI)
|
|
2
|
+
// [why] 2026-11-10 用户要求:① 跨设备直接传文件(但要限额限速,公网/内网流量有成本);
|
|
3
|
+
// ② 把 Everything 这类毫秒级检索工具提供给 AI 用。这里用 MCP 分块读写(不占额外端口),
|
|
4
|
+
// 读写都受 fsRoots / 单块上限 / 单文件上限 / 每日配额约束;检索优先用 es.exe→fd→Node 回退。
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, writeFileSync, writeSync } from 'node:fs'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
9
|
+
import { spawnSync } from 'node:child_process'
|
|
10
|
+
import { z } from 'zod'
|
|
11
|
+
import { isInsideRoots } from '../core/config.js'
|
|
12
|
+
import { denied, internal, invalidArg, notFound, ok } from '../core/result.js'
|
|
13
|
+
|
|
14
|
+
const TOOLS_DIR = join(homedir(), '.dsh-mesh', 'tools')
|
|
15
|
+
const DEFAULT_TRANSFER = {
|
|
16
|
+
maxChunkBytes: 4 * 1024 * 1024, // 单块 4 MB(MCP base64 安全余量)
|
|
17
|
+
maxFileBytes: 256 * 1024 * 1024, // 单文件 256 MB
|
|
18
|
+
dailyBytes: 2 * 1024 * 1024 * 1024 // 每日 2 GB
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function limitsOf(ctx) {
|
|
22
|
+
return { ...DEFAULT_TRANSFER, ...(ctx.getConfig()?.transfer || {}) }
|
|
23
|
+
}
|
|
24
|
+
function usagePath(home) {
|
|
25
|
+
return join(home, 'transfer-usage.json')
|
|
26
|
+
}
|
|
27
|
+
function readUsage(home) {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(readFileSync(usagePath(home), 'utf8'))
|
|
30
|
+
} catch {
|
|
31
|
+
return {}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function dayKey() {
|
|
35
|
+
return new Date().toISOString().slice(0, 10)
|
|
36
|
+
}
|
|
37
|
+
function usedToday(home) {
|
|
38
|
+
const u = readUsage(home)
|
|
39
|
+
return u.day === dayKey() ? Number(u.bytes || 0) : 0
|
|
40
|
+
}
|
|
41
|
+
function addUsage(home, bytes) {
|
|
42
|
+
const u = readUsage(home)
|
|
43
|
+
if (u.day !== dayKey()) {
|
|
44
|
+
u.day = dayKey()
|
|
45
|
+
u.bytes = 0
|
|
46
|
+
}
|
|
47
|
+
u.bytes = Number(u.bytes || 0) + bytes
|
|
48
|
+
try {
|
|
49
|
+
mkdirSync(dirname(usagePath(home)), { recursive: true })
|
|
50
|
+
writeFileSync(usagePath(home), JSON.stringify(u, null, 2) + '\n', 'utf8')
|
|
51
|
+
} catch {
|
|
52
|
+
/* 用量记录失败不阻塞传输 */
|
|
53
|
+
}
|
|
54
|
+
return u.bytes
|
|
55
|
+
}
|
|
56
|
+
function quotaError(ctx, size) {
|
|
57
|
+
const lim = limitsOf(ctx)
|
|
58
|
+
const used = usedToday(ctx.home)
|
|
59
|
+
if (size > lim.maxChunkBytes) return invalidArg(`chunk too large: ${size} > ${lim.maxChunkBytes} bytes`)
|
|
60
|
+
if (used + size > lim.dailyBytes) {
|
|
61
|
+
return denied(`daily transfer quota exceeded: ${used + size} > ${lim.dailyBytes} bytes`, { limits: lim, usedToday: used })
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
function resolveGuarded(raw, config) {
|
|
66
|
+
const abs = resolve(String(raw || ''))
|
|
67
|
+
const allowed = config?.acl?.fsRoots || []
|
|
68
|
+
if (!isInsideRoots(abs, allowed)) return { error: denied(`path is outside the allowed fsRoots: ${abs}`, { fsRoots: allowed }) }
|
|
69
|
+
return { abs }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function findOnDisk(name) {
|
|
73
|
+
const win = process.platform === 'win32'
|
|
74
|
+
const exe = win ? `${name}.exe` : name
|
|
75
|
+
const dirs = [
|
|
76
|
+
TOOLS_DIR,
|
|
77
|
+
join(TOOLS_DIR, 'rg', 'ripgrep-14.1.1-x86_64-pc-windows-msvc'),
|
|
78
|
+
join(TOOLS_DIR, 'fd', 'fd-v10.2.0-x86_64-pc-windows-msvc'),
|
|
79
|
+
join(TOOLS_DIR, 'node_modules', '7zip-bin', 'win', 'x64'),
|
|
80
|
+
'C:/Program Files/Everything',
|
|
81
|
+
'C:/Program Files/7-Zip',
|
|
82
|
+
'C:/Program Files (x86)/7-Zip'
|
|
83
|
+
]
|
|
84
|
+
for (const dir of dirs) {
|
|
85
|
+
const p = join(dir, exe)
|
|
86
|
+
if (existsSync(p)) return p
|
|
87
|
+
}
|
|
88
|
+
const which = spawnSync(win ? 'where.exe' : 'which', [exe], { encoding: 'utf8', windowsHide: true, timeout: 5000 })
|
|
89
|
+
const line = String(which.stdout || '').split(/\r?\n/).map((s) => s.trim()).find(Boolean)
|
|
90
|
+
return line || null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const TOOL_CACHE = new Map()
|
|
94
|
+
function toolInfo(name) {
|
|
95
|
+
if (TOOL_CACHE.has(name)) return TOOL_CACHE.get(name)
|
|
96
|
+
const path = findOnDisk(name)
|
|
97
|
+
let version = null
|
|
98
|
+
if (path) {
|
|
99
|
+
const flag = name === 'es' ? '-version' : name === '7za' ? '' : '--version'
|
|
100
|
+
if (flag) {
|
|
101
|
+
const r = spawnSync(path, [flag], { encoding: 'utf8', windowsHide: true, timeout: 8000 })
|
|
102
|
+
version = String((r.stdout || '') + (r.stderr || '')).trim().split(/\r?\n/).map((s) => s.trim()).find(Boolean) || null
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const info = { path, version }
|
|
106
|
+
TOOL_CACHE.set(name, info)
|
|
107
|
+
return info
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isoMs(value) {
|
|
111
|
+
const n = Number(value)
|
|
112
|
+
return Number.isFinite(n) && n > 0 ? Math.round(n) : null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 遍历回退(无 Everything/fd 时);限制文件数,避免卡死。 */
|
|
116
|
+
function walkMatch(root, match, limit) {
|
|
117
|
+
const out = []
|
|
118
|
+
const stack = [root]
|
|
119
|
+
while (stack.length && out.length < limit) {
|
|
120
|
+
const dir = stack.pop()
|
|
121
|
+
let entries
|
|
122
|
+
try {
|
|
123
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
124
|
+
} catch {
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (out.length >= limit) break
|
|
129
|
+
if (entry.name === 'node_modules' || entry.name === '.git') continue
|
|
130
|
+
const abs = join(dir, entry.name)
|
|
131
|
+
if (entry.isDirectory()) stack.push(abs)
|
|
132
|
+
if (match(entry.name, abs)) {
|
|
133
|
+
try {
|
|
134
|
+
const st = statSync(abs)
|
|
135
|
+
out.push({ path: abs, name: entry.name, type: st.isDirectory() ? 'dir' : 'file', sizeBytes: st.isDirectory() ? null : st.size, mtimeMs: Math.round(st.mtimeMs) })
|
|
136
|
+
} catch {
|
|
137
|
+
/* ignore */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function findFiles(ctx, { query, root, limit = 100, exts = null, minSizeBytes = null, modifiedAfter = null }) {
|
|
146
|
+
const cfg = ctx.getConfig()
|
|
147
|
+
const guarded = resolveGuarded(root || (cfg.acl?.fsRoots?.[0]?.path ?? cfg.acl?.fsRoots?.[0] ?? homedir()), cfg)
|
|
148
|
+
if (guarded.error) return guarded.error
|
|
149
|
+
const absRoot = guarded.abs
|
|
150
|
+
if (!existsSync(absRoot)) return notFound(`root not found: ${absRoot}`)
|
|
151
|
+
const es = toolInfo('es')
|
|
152
|
+
if (es.path) {
|
|
153
|
+
const args = ['-json', '-n', String(Math.min(limit, 500)), '-path', absRoot]
|
|
154
|
+
if (exts?.length) args.push('-r', `.*\\.(${exts.map((e) => String(e).replace(/^\\./, '')).join('|')})$`)
|
|
155
|
+
args.push(String(query || ''))
|
|
156
|
+
const r = spawnSync(es.path, args, { encoding: 'utf8', windowsHide: true, timeout: 15000 })
|
|
157
|
+
try {
|
|
158
|
+
const rows = JSON.parse(r.stdout || '[]')
|
|
159
|
+
return ok({
|
|
160
|
+
tool: 'everything',
|
|
161
|
+
count: rows.length,
|
|
162
|
+
entries: rows.slice(0, 500).map((row) => ({
|
|
163
|
+
path: String(row.path || row.full_path || ''),
|
|
164
|
+
name: String(row.name || basename(String(row.path || ''))),
|
|
165
|
+
type: Number(row.type) === 2 ? 'dir' : 'file',
|
|
166
|
+
sizeBytes: row.size == null ? null : Number(row.size),
|
|
167
|
+
mtimeMs: isoMs(row.date_modified)
|
|
168
|
+
}))
|
|
169
|
+
})
|
|
170
|
+
} catch {
|
|
171
|
+
/* fallthrough */
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const fd = toolInfo('fd')
|
|
175
|
+
if (fd.path) {
|
|
176
|
+
const args = ['--hidden', '--max-results', String(Math.min(limit, 500)), '--absolute-path']
|
|
177
|
+
if (exts?.length) for (const e of exts) args.push('-e', String(e).replace(/^\\./, ''))
|
|
178
|
+
if (query) args.push(String(query))
|
|
179
|
+
args.push(absRoot)
|
|
180
|
+
const r = spawnSync(fd.path, args, { encoding: 'utf8', windowsHide: true, timeout: 15000 })
|
|
181
|
+
const entries = String(r.stdout || '').split(/\r?\n/).filter(Boolean).slice(0, 500).map((p) => {
|
|
182
|
+
try {
|
|
183
|
+
const st = statSync(p)
|
|
184
|
+
return { path: p, name: basename(p), type: st.isDirectory() ? 'dir' : 'file', sizeBytes: st.isDirectory() ? null : st.size, mtimeMs: Math.round(st.mtimeMs) }
|
|
185
|
+
} catch {
|
|
186
|
+
return { path: p, name: basename(p), type: 'file', sizeBytes: null, mtimeMs: null }
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
return ok({ tool: 'fd', count: entries.length, entries })
|
|
190
|
+
}
|
|
191
|
+
const lowered = String(query || '').toLowerCase()
|
|
192
|
+
const entries = walkMatch(absRoot, (name) => !lowered || name.toLowerCase().includes(lowered), Math.min(limit, 500))
|
|
193
|
+
return ok({ tool: 'walk', count: entries.length, entries })
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function searchText(ctx, { pattern, root, glob = null, maxResults = 100, contextLines = 0 }) {
|
|
197
|
+
const cfg = ctx.getConfig()
|
|
198
|
+
const guarded = resolveGuarded(root || (cfg.acl?.fsRoots?.[0]?.path ?? cfg.acl?.fsRoots?.[0] ?? homedir()), cfg)
|
|
199
|
+
if (guarded.error) return guarded.error
|
|
200
|
+
const absRoot = guarded.abs
|
|
201
|
+
if (!existsSync(absRoot)) return notFound(`root not found: ${absRoot}`)
|
|
202
|
+
const limit = Math.min(Math.max(Number(maxResults) || 100, 1), 200)
|
|
203
|
+
const rg = toolInfo('rg')
|
|
204
|
+
if (rg.path) {
|
|
205
|
+
const args = ['--no-heading', '--line-number', '--color', 'never', '--max-count', String(limit), ...(glob ? ['-g', String(glob)] : []), ...(Number(contextLines) > 0 ? ['-C', String(Math.min(Number(contextLines), 5))] : []), String(pattern), absRoot]
|
|
206
|
+
const r = spawnSync(rg.path, args, { encoding: 'utf8', windowsHide: true, timeout: 20000, maxBuffer: 8 * 1024 * 1024 })
|
|
207
|
+
const matches = String(r.stdout || '').split(/\r?\n/).filter(Boolean).slice(0, limit)
|
|
208
|
+
return ok({ tool: 'ripgrep', count: matches.length, matches })
|
|
209
|
+
}
|
|
210
|
+
const re = new RegExp(String(pattern), 'i')
|
|
211
|
+
const hits = []
|
|
212
|
+
const stack = [absRoot]
|
|
213
|
+
while (stack.length && hits.length < limit) {
|
|
214
|
+
const dir = stack.pop()
|
|
215
|
+
let entries
|
|
216
|
+
try {
|
|
217
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
218
|
+
} catch {
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
for (const entry of entries) {
|
|
222
|
+
if (hits.length >= limit) break
|
|
223
|
+
if (entry.name === 'node_modules' || entry.name === '.git') continue
|
|
224
|
+
const abs = join(dir, entry.name)
|
|
225
|
+
if (entry.isDirectory()) {
|
|
226
|
+
stack.push(abs)
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
if (statSync(abs).size > 2 * 1024 * 1024) continue
|
|
231
|
+
const lines = readFileSync(abs, 'utf8').split(/\r?\n/)
|
|
232
|
+
lines.forEach((line, idx) => {
|
|
233
|
+
if (hits.length < limit && re.test(line)) hits.push(`${abs}:${idx + 1}:${line.slice(0, 300)}`)
|
|
234
|
+
})
|
|
235
|
+
} catch {
|
|
236
|
+
/* skip binary/unreadable */
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return ok({ tool: 'walk', count: hits.length, matches: hits })
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function buildFiles(ctx) {
|
|
244
|
+
return [
|
|
245
|
+
{
|
|
246
|
+
name: 'file_read_chunk',
|
|
247
|
+
title: 'Read a file chunk as base64 (file transfer)',
|
|
248
|
+
description:
|
|
249
|
+
'Read up to transfer.maxChunkBytes (default 4 MB) of a file as base64 for cross-device transfer. Path must be inside fsRoots. Use offset to continue; eof=true on last chunk. Counts against the daily transfer quota.',
|
|
250
|
+
inputSchema: z.object({
|
|
251
|
+
path: z.string().describe('Absolute file path inside an allowed fsRoot'),
|
|
252
|
+
offset: z.number().int().min(0).optional().describe('Byte offset (default 0)'),
|
|
253
|
+
length: z.number().int().min(1).optional().describe('Bytes to read (default/max = transfer.maxChunkBytes)')
|
|
254
|
+
}),
|
|
255
|
+
handler: async ({ path, offset = 0, length }) => {
|
|
256
|
+
const lim = limitsOf(ctx)
|
|
257
|
+
const cfg = ctx.getConfig()
|
|
258
|
+
const guarded = resolveGuarded(path, cfg)
|
|
259
|
+
if (guarded.error) return guarded.error
|
|
260
|
+
if (!existsSync(guarded.abs)) return notFound(`file not found: ${guarded.abs}`)
|
|
261
|
+
const st = statSync(guarded.abs)
|
|
262
|
+
if (!st.isFile()) return invalidArg('path is not a regular file')
|
|
263
|
+
const size = st.size
|
|
264
|
+
if (size > lim.maxFileBytes) return denied(`file too large: ${size} > ${lim.maxFileBytes} bytes`, { limits: lim })
|
|
265
|
+
const want = Math.min(Number(length) || lim.maxChunkBytes, lim.maxChunkBytes)
|
|
266
|
+
const quota = quotaError(ctx, want)
|
|
267
|
+
if (quota) return quota
|
|
268
|
+
const buf = Buffer.alloc(Math.min(want, Math.max(0, size - offset)))
|
|
269
|
+
const fd = openSync(guarded.abs, 'r')
|
|
270
|
+
try {
|
|
271
|
+
const read = readSync(fd, buf, 0, buf.length, offset)
|
|
272
|
+
addUsage(ctx.home, read)
|
|
273
|
+
return ok({
|
|
274
|
+
path: guarded.abs,
|
|
275
|
+
sizeBytes: size,
|
|
276
|
+
offset,
|
|
277
|
+
length: read,
|
|
278
|
+
eof: offset + read >= size,
|
|
279
|
+
base64: buf.subarray(0, read).toString('base64')
|
|
280
|
+
})
|
|
281
|
+
} finally {
|
|
282
|
+
closeSync(fd)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
name: 'file_write_chunk',
|
|
288
|
+
title: 'Write a base64 chunk at an offset (file transfer)',
|
|
289
|
+
description:
|
|
290
|
+
'Write up to transfer.maxChunkBytes (default 4 MB) decoded from base64 into a file inside fsRoots. offset=0 with truncate=true creates/overwrites; later chunks continue at offset. Counts against the daily transfer quota.',
|
|
291
|
+
inputSchema: z.object({
|
|
292
|
+
path: z.string().describe('Absolute file path inside an allowed fsRoot'),
|
|
293
|
+
offset: z.number().int().min(0).optional().describe('Byte offset (default 0)'),
|
|
294
|
+
base64: z.string().describe('Base64 payload (max transfer.maxChunkBytes decoded)'),
|
|
295
|
+
truncate: z.boolean().optional().describe('When offset=0, truncate the file first (default false)')
|
|
296
|
+
}),
|
|
297
|
+
handler: async ({ path, offset = 0, base64, truncate = false }) => {
|
|
298
|
+
const lim = limitsOf(ctx)
|
|
299
|
+
const cfg = ctx.getConfig()
|
|
300
|
+
const guarded = resolveGuarded(path, cfg)
|
|
301
|
+
if (guarded.error) return guarded.error
|
|
302
|
+
let buf
|
|
303
|
+
try {
|
|
304
|
+
buf = Buffer.from(String(base64 || ''), 'base64')
|
|
305
|
+
} catch {
|
|
306
|
+
return invalidArg('base64 payload is invalid')
|
|
307
|
+
}
|
|
308
|
+
if (buf.length === 0) return invalidArg('empty payload')
|
|
309
|
+
if (buf.length > lim.maxChunkBytes) return invalidArg(`chunk too large: ${buf.length} > ${lim.maxChunkBytes}`)
|
|
310
|
+
const quota = quotaError(ctx, buf.length)
|
|
311
|
+
if (quota) return quota
|
|
312
|
+
try {
|
|
313
|
+
mkdirSync(dirname(guarded.abs), { recursive: true })
|
|
314
|
+
const flags = offset === 0 && truncate ? 'w' : existsSync(guarded.abs) ? 'r+' : 'w'
|
|
315
|
+
const fd = openSync(guarded.abs, flags)
|
|
316
|
+
try {
|
|
317
|
+
writeSync(fd, buf, 0, buf.length, offset)
|
|
318
|
+
} finally {
|
|
319
|
+
closeSync(fd)
|
|
320
|
+
}
|
|
321
|
+
addUsage(ctx.home, buf.length)
|
|
322
|
+
return ok({ path: guarded.abs, offset, written: buf.length, sizeBytes: statSync(guarded.abs).size })
|
|
323
|
+
} catch (error) {
|
|
324
|
+
return internal(`write failed: ${error?.message || error}`)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
name: 'file_hash',
|
|
330
|
+
title: 'SHA-256 of a file or range',
|
|
331
|
+
description: 'Compute SHA-256 for a file (or a byte range) so transfers can be verified end-to-end.',
|
|
332
|
+
inputSchema: z.object({
|
|
333
|
+
path: z.string().describe('Absolute file path inside an allowed fsRoot'),
|
|
334
|
+
offset: z.number().int().min(0).optional(),
|
|
335
|
+
length: z.number().int().min(1).optional()
|
|
336
|
+
}),
|
|
337
|
+
handler: async ({ path, offset = 0, length }) => {
|
|
338
|
+
const cfg = ctx.getConfig()
|
|
339
|
+
const guarded = resolveGuarded(path, cfg)
|
|
340
|
+
if (guarded.error) return guarded.error
|
|
341
|
+
if (!existsSync(guarded.abs)) return notFound(`file not found: ${guarded.abs}`)
|
|
342
|
+
const st = statSync(guarded.abs)
|
|
343
|
+
if (!st.isFile()) return invalidArg('path is not a regular file')
|
|
344
|
+
const start = Math.min(Number(offset) || 0, st.size)
|
|
345
|
+
const want = Number(length) || st.size - start
|
|
346
|
+
const fd = openSync(guarded.abs, 'r')
|
|
347
|
+
try {
|
|
348
|
+
const hash = createHash('sha256')
|
|
349
|
+
const chunk = Buffer.alloc(Math.min(want, 1024 * 1024))
|
|
350
|
+
let pos = start
|
|
351
|
+
let remaining = Math.min(want, st.size - start)
|
|
352
|
+
while (remaining > 0) {
|
|
353
|
+
const read = readSync(fd, chunk, 0, Math.min(chunk.length, remaining), pos)
|
|
354
|
+
if (read <= 0) break
|
|
355
|
+
hash.update(chunk.subarray(0, read))
|
|
356
|
+
pos += read
|
|
357
|
+
remaining -= read
|
|
358
|
+
}
|
|
359
|
+
return ok({ path: guarded.abs, sizeBytes: st.size, algorithm: 'sha256', digest: hash.digest('hex') })
|
|
360
|
+
} finally {
|
|
361
|
+
closeSync(fd)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
name: 'transfer_usage',
|
|
367
|
+
title: 'File-transfer quota and usage',
|
|
368
|
+
description: 'Show transfer limits (chunk/file/daily) and how many bytes this node has moved today.',
|
|
369
|
+
inputSchema: z.object({}),
|
|
370
|
+
handler: async () => ok({ limits: limitsOf(ctx), usedToday: usedToday(ctx.home), day: dayKey(), usageFile: usagePath(ctx.home) })
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
name: 'find_files',
|
|
374
|
+
title: 'Fast file search (Everything / fd)',
|
|
375
|
+
description:
|
|
376
|
+
'Find files by name under a root: uses Everything es.exe (millisecond index) when available, else fd, else a bounded walk. Returns up to 500 entries with size/mtime. Read-only.',
|
|
377
|
+
inputSchema: z.object({
|
|
378
|
+
query: z.string().describe('Substring / Everything query to match in file names'),
|
|
379
|
+
root: z.string().optional().describe('Root directory (default: first fsRoot)'),
|
|
380
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
381
|
+
exts: z.array(z.string()).optional().describe('Only these extensions, e.g. ["md","ts"]'),
|
|
382
|
+
minSizeBytes: z.number().int().min(0).optional(),
|
|
383
|
+
modifiedAfter: z.string().optional().describe('ISO time lower bound on mtime')
|
|
384
|
+
}),
|
|
385
|
+
handler: async (args) => findFiles(ctx, args)
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
name: 'search_text',
|
|
389
|
+
title: 'Fast text search (ripgrep)',
|
|
390
|
+
description: 'Search file contents with ripgrep when available (regex supported), else bounded walk. Returns "path:line:text" strings. Read-only.',
|
|
391
|
+
inputSchema: z.object({
|
|
392
|
+
pattern: z.string().describe('Regex / plain pattern'),
|
|
393
|
+
root: z.string().optional().describe('Root directory (default: first fsRoot)'),
|
|
394
|
+
glob: z.string().optional().describe('ripgrep glob filter, e.g. "*.md"'),
|
|
395
|
+
maxResults: z.number().int().min(1).max(200).optional(),
|
|
396
|
+
contextLines: z.number().int().min(0).max(5).optional()
|
|
397
|
+
}),
|
|
398
|
+
handler: async (args) => searchText(ctx, args)
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
name: 'tool_inventory',
|
|
402
|
+
title: 'Available AI/terminal tools on this node',
|
|
403
|
+
description: 'Report which high-value CLI tools are installed (Everything es.exe, ripgrep, fd, jq, 7za) with paths and versions. Use before asking this node for fast search/archive work.',
|
|
404
|
+
inputSchema: z.object({}),
|
|
405
|
+
handler: async () =>
|
|
406
|
+
ok({
|
|
407
|
+
platform: process.platform,
|
|
408
|
+
tools: Object.fromEntries(['es', 'rg', 'fd', 'jq', '7za'].map((name) => [name, toolInfo(name)]))
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
]
|
|
412
|
+
}
|
package/src/tools/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { buildCore } from './core.js'
|
|
2
|
-
import { buildFs } from './fs.js'
|
|
2
|
+
import { buildFs } from './fs.js'
|
|
3
|
+
import { buildFiles } from './files.js'
|
|
3
4
|
import { buildGit } from './git.js'
|
|
4
5
|
import { buildShell } from './shell.js'
|
|
5
6
|
import { buildTasks } from './tasks.js'
|
|
@@ -22,7 +23,8 @@ import { short } from '../core/util.js'
|
|
|
22
23
|
export function buildAll(ctx) {
|
|
23
24
|
return [
|
|
24
25
|
...buildCore(ctx),
|
|
25
|
-
...buildFs(ctx),
|
|
26
|
+
...buildFs(ctx),
|
|
27
|
+
...buildFiles(ctx),
|
|
26
28
|
...buildGit(ctx),
|
|
27
29
|
...buildShell(ctx),
|
|
28
30
|
...buildTasks(ctx),
|