amalgm 0.1.112 → 0.1.113
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
CHANGED
|
@@ -9,6 +9,7 @@ const path = require('path');
|
|
|
9
9
|
const { notifyProjectContextPathChanged } = require('../project-context/store');
|
|
10
10
|
const { AMALGM_DIR } = require('../config');
|
|
11
11
|
const { appendStateEvent } = require('../state/events');
|
|
12
|
+
const { createFsSearchHandler } = require('./search');
|
|
12
13
|
|
|
13
14
|
const TEXT_EXTENSIONS = new Set([
|
|
14
15
|
'txt', 'md', 'json', 'js', 'ts', 'tsx', 'jsx', 'py', 'html', 'css',
|
|
@@ -299,6 +300,8 @@ function statusForError(error, fallbackStatus = 500) {
|
|
|
299
300
|
return fallbackStatus;
|
|
300
301
|
}
|
|
301
302
|
|
|
303
|
+
const handleSearch = createFsSearchHandler({ resolveSafePath });
|
|
304
|
+
|
|
302
305
|
function classifyFile(targetPath) {
|
|
303
306
|
const ext = path.extname(targetPath).slice(1).toLowerCase();
|
|
304
307
|
return {
|
|
@@ -765,6 +768,7 @@ module.exports = {
|
|
|
765
768
|
handleMkdir,
|
|
766
769
|
handleRead,
|
|
767
770
|
handleRename,
|
|
771
|
+
handleSearch,
|
|
768
772
|
handleWrite,
|
|
769
773
|
_private: {
|
|
770
774
|
buildFilesResource,
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { performance } = require('perf_hooks');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_SEARCH_BUDGET_MS = 800;
|
|
8
|
+
const DEFAULT_SEARCH_LIMIT = 40;
|
|
9
|
+
const DEFAULT_SEARCH_MAX_ENTRIES = 120000;
|
|
10
|
+
const SKILL_DESCRIPTION_PREVIEW_BYTES = 4096;
|
|
11
|
+
|
|
12
|
+
const SEARCH_IGNORE_DIRS = new Set([
|
|
13
|
+
'.git',
|
|
14
|
+
'.next',
|
|
15
|
+
'.next-electron-dev',
|
|
16
|
+
'.turbo',
|
|
17
|
+
'.cache',
|
|
18
|
+
'.agents',
|
|
19
|
+
'.vercel',
|
|
20
|
+
'.pnpm-store',
|
|
21
|
+
'node_modules',
|
|
22
|
+
'dist',
|
|
23
|
+
'build',
|
|
24
|
+
'coverage',
|
|
25
|
+
'.parcel-cache',
|
|
26
|
+
'.vite',
|
|
27
|
+
'out',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function toPosix(value) {
|
|
31
|
+
return String(value || '').replace(/\\/g, '/');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pathBasename(filePath) {
|
|
35
|
+
const normalized = toPosix(filePath);
|
|
36
|
+
return normalized.split('/').filter(Boolean).pop() || normalized;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeForFuzzy(value) {
|
|
40
|
+
return String(value || '').toLowerCase().replace(/[-_./\s]+/g, '');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function subsequenceScore(haystack, needle) {
|
|
44
|
+
if (!needle) return 0;
|
|
45
|
+
let needleIndex = 0;
|
|
46
|
+
let lastMatch = -1;
|
|
47
|
+
let gapPenalty = 0;
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < haystack.length && needleIndex < needle.length; i += 1) {
|
|
50
|
+
if (haystack[i] === needle[needleIndex]) {
|
|
51
|
+
if (lastMatch >= 0) gapPenalty += i - lastMatch - 1;
|
|
52
|
+
lastMatch = i;
|
|
53
|
+
needleIndex += 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (needleIndex < needle.length) return 0;
|
|
58
|
+
return Math.max(20, 40 - gapPenalty);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fuzzyScore(query, candidate) {
|
|
62
|
+
const normalizedQuery = normalizeForFuzzy(query);
|
|
63
|
+
if (!normalizedQuery) return 0;
|
|
64
|
+
const normalizedCandidate = normalizeForFuzzy(candidate);
|
|
65
|
+
if (!normalizedCandidate) return 0;
|
|
66
|
+
|
|
67
|
+
if (normalizedCandidate === normalizedQuery) return 100;
|
|
68
|
+
if (normalizedCandidate.startsWith(normalizedQuery)) return 90;
|
|
69
|
+
if (normalizedCandidate.includes(normalizedQuery)) return 70;
|
|
70
|
+
|
|
71
|
+
return subsequenceScore(normalizedCandidate, normalizedQuery);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fuzzyScorePath(query, candidatePath) {
|
|
75
|
+
const overall = fuzzyScore(query, candidatePath);
|
|
76
|
+
const leaf = fuzzyScore(query, pathBasename(candidatePath));
|
|
77
|
+
return Math.max(overall, leaf + 5);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isSkillFile(relativePath) {
|
|
81
|
+
return /(^|\/)\.agents\/skills\/[^/]+\/SKILL\.md$/i.test(toPosix(relativePath));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function statusForError(error, fallbackStatus = 500) {
|
|
85
|
+
if (typeof error?.statusCode === 'number') return error.statusCode;
|
|
86
|
+
if (error?.code === 'ENOENT') return 404;
|
|
87
|
+
if (error?.code === 'EACCES' || error?.code === 'EPERM') return 403;
|
|
88
|
+
return fallbackStatus;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function collectSearchEntries(root, deadline, maxEntries) {
|
|
92
|
+
const queue = [''];
|
|
93
|
+
const entries = [];
|
|
94
|
+
const metrics = {
|
|
95
|
+
visitedDirs: 0,
|
|
96
|
+
visitedFiles: 0,
|
|
97
|
+
skippedDirs: 0,
|
|
98
|
+
errors: 0,
|
|
99
|
+
timedOut: false,
|
|
100
|
+
truncated: false,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
while (queue.length > 0) {
|
|
104
|
+
if (performance.now() > deadline) {
|
|
105
|
+
metrics.timedOut = true;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
if (entries.length >= maxEntries) {
|
|
109
|
+
metrics.truncated = true;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const relativeDir = queue.shift();
|
|
114
|
+
const absDir = path.join(root, relativeDir);
|
|
115
|
+
let dirents;
|
|
116
|
+
try {
|
|
117
|
+
dirents = await fs.promises.readdir(absDir, { withFileTypes: true });
|
|
118
|
+
metrics.visitedDirs += 1;
|
|
119
|
+
} catch {
|
|
120
|
+
metrics.errors += 1;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
dirents.sort((left, right) => {
|
|
125
|
+
if (left.isDirectory() !== right.isDirectory()) return left.isDirectory() ? -1 : 1;
|
|
126
|
+
return left.name.localeCompare(right.name);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
for (const dirent of dirents) {
|
|
130
|
+
const childRelative = toPosix(path.join(relativeDir, dirent.name));
|
|
131
|
+
|
|
132
|
+
if (dirent.isDirectory()) {
|
|
133
|
+
if (SEARCH_IGNORE_DIRS.has(dirent.name)) {
|
|
134
|
+
metrics.skippedDirs += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
entries.push({ relativePath: childRelative, isDirectory: true });
|
|
138
|
+
queue.push(childRelative);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (dirent.isFile()) {
|
|
143
|
+
entries.push({ relativePath: childRelative, isDirectory: false });
|
|
144
|
+
metrics.visitedFiles += 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
metrics.collectedEntries = entries.length;
|
|
150
|
+
return { entries, metrics };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseSkillDescription(content) {
|
|
154
|
+
const frontmatterMatch = String(content || '').match(/^---\s*\n([\s\S]*?)\n---/);
|
|
155
|
+
const block = frontmatterMatch ? frontmatterMatch[1] : String(content || '').slice(0, SKILL_DESCRIPTION_PREVIEW_BYTES);
|
|
156
|
+
const descriptionMatch = block.match(/^description:\s*(.+?)\s*$/im);
|
|
157
|
+
if (!descriptionMatch) return null;
|
|
158
|
+
|
|
159
|
+
let description = descriptionMatch[1].trim();
|
|
160
|
+
if (
|
|
161
|
+
(description.startsWith('"') && description.endsWith('"')) ||
|
|
162
|
+
(description.startsWith("'") && description.endsWith("'"))
|
|
163
|
+
) {
|
|
164
|
+
description = description.slice(1, -1);
|
|
165
|
+
}
|
|
166
|
+
return description.trim() || null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function collectSkillEntries(root) {
|
|
170
|
+
const skillsRoot = path.join(root, '.agents', 'skills');
|
|
171
|
+
let dirents;
|
|
172
|
+
try {
|
|
173
|
+
dirents = await fs.promises.readdir(skillsRoot, { withFileTypes: true });
|
|
174
|
+
} catch {
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const skills = [];
|
|
179
|
+
for (const dirent of dirents) {
|
|
180
|
+
if (!dirent.isDirectory()) continue;
|
|
181
|
+
const skillFile = path.join(skillsRoot, dirent.name, 'SKILL.md');
|
|
182
|
+
try {
|
|
183
|
+
const stat = await fs.promises.stat(skillFile);
|
|
184
|
+
if (!stat.isFile()) continue;
|
|
185
|
+
} catch {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let description;
|
|
190
|
+
try {
|
|
191
|
+
const content = await fs.promises.readFile(skillFile, 'utf8');
|
|
192
|
+
description = parseSkillDescription(content) || undefined;
|
|
193
|
+
} catch {
|
|
194
|
+
description = undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
skills.push({
|
|
198
|
+
relativePath: toPosix(path.relative(root, skillFile)),
|
|
199
|
+
isDirectory: false,
|
|
200
|
+
description,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return skills;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function searchKindForEntry(entry) {
|
|
207
|
+
if (isSkillFile(entry.relativePath)) return 'skill';
|
|
208
|
+
return entry.isDirectory ? 'folder' : 'file';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function rankedSearchItem(root, query, entry) {
|
|
212
|
+
const kind = searchKindForEntry(entry);
|
|
213
|
+
const absolute = path.join(root, entry.relativePath);
|
|
214
|
+
const title = kind === 'skill'
|
|
215
|
+
? pathBasename(entry.relativePath.replace(/\/SKILL\.md$/i, ''))
|
|
216
|
+
: pathBasename(entry.relativePath);
|
|
217
|
+
const score = fuzzyScorePath(query, entry.relativePath);
|
|
218
|
+
if (score <= 0) return null;
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
id: `${kind}:${absolute}`,
|
|
222
|
+
kind,
|
|
223
|
+
title,
|
|
224
|
+
subtitle: absolute,
|
|
225
|
+
path: absolute,
|
|
226
|
+
isDirectory: kind === 'folder',
|
|
227
|
+
isSkill: kind === 'skill',
|
|
228
|
+
description: entry.description,
|
|
229
|
+
score,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function uniqueSearchEntries(entries) {
|
|
234
|
+
const seen = new Set();
|
|
235
|
+
const unique = [];
|
|
236
|
+
for (const entry of entries) {
|
|
237
|
+
const key = `${entry.isDirectory ? 'folder' : 'file'}:${entry.relativePath}`;
|
|
238
|
+
if (seen.has(key)) continue;
|
|
239
|
+
seen.add(key);
|
|
240
|
+
unique.push(entry);
|
|
241
|
+
}
|
|
242
|
+
return unique;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function searchRoot(resolveSafePath, root, query, deadline, maxEntries) {
|
|
246
|
+
const safeRoot = await resolveSafePath(root);
|
|
247
|
+
const stat = await fs.promises.stat(safeRoot);
|
|
248
|
+
if (!stat.isDirectory()) return null;
|
|
249
|
+
|
|
250
|
+
const realRoot = await fs.promises.realpath(safeRoot);
|
|
251
|
+
const collectStarted = performance.now();
|
|
252
|
+
const [collection, skills] = await Promise.all([
|
|
253
|
+
collectSearchEntries(realRoot, deadline, maxEntries),
|
|
254
|
+
collectSkillEntries(realRoot),
|
|
255
|
+
]);
|
|
256
|
+
const entries = uniqueSearchEntries([...collection.entries, ...skills]);
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
root: realRoot,
|
|
260
|
+
items: entries.map((entry) => rankedSearchItem(realRoot, query, entry)).filter(Boolean),
|
|
261
|
+
metrics: {
|
|
262
|
+
...collection.metrics,
|
|
263
|
+
skillEntries: skills.length,
|
|
264
|
+
collectMs: performance.now() - collectStarted,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function normalizeSearchRoots(value) {
|
|
270
|
+
const roots = Array.isArray(value) ? value : [value];
|
|
271
|
+
return Array.from(new Set(
|
|
272
|
+
roots
|
|
273
|
+
.map((root) => (typeof root === 'string' ? root.trim() : ''))
|
|
274
|
+
.filter(Boolean),
|
|
275
|
+
));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function createFsSearchHandler({ resolveSafePath }) {
|
|
279
|
+
return async function handleSearch(body, sendJson) {
|
|
280
|
+
const started = performance.now();
|
|
281
|
+
const query = typeof body?.query === 'string' ? body.query.trim() : '';
|
|
282
|
+
const roots = normalizeSearchRoots(body?.roots ?? body?.root);
|
|
283
|
+
const limit = Math.min(Math.max(Number.parseInt(String(body?.limit ?? DEFAULT_SEARCH_LIMIT), 10) || DEFAULT_SEARCH_LIMIT, 1), 50);
|
|
284
|
+
const budgetMs = Math.min(Math.max(Number.parseInt(String(body?.budgetMs ?? DEFAULT_SEARCH_BUDGET_MS), 10) || DEFAULT_SEARCH_BUDGET_MS, 100), 5000);
|
|
285
|
+
const maxEntries = Math.min(
|
|
286
|
+
Math.max(Number.parseInt(String(body?.maxEntries ?? DEFAULT_SEARCH_MAX_ENTRIES), 10) || DEFAULT_SEARCH_MAX_ENTRIES, 1000),
|
|
287
|
+
500000,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
if (!query) {
|
|
291
|
+
return sendJson(200, {
|
|
292
|
+
query,
|
|
293
|
+
roots,
|
|
294
|
+
items: [],
|
|
295
|
+
metrics: { backend: 'node', totalMs: performance.now() - started, collectedEntries: 0 },
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (roots.length === 0) {
|
|
300
|
+
return sendJson(400, { error: 'roots is required' });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const deadline = started + budgetMs;
|
|
304
|
+
const rootResults = [];
|
|
305
|
+
const errors = [];
|
|
306
|
+
|
|
307
|
+
for (const requestedRoot of roots) {
|
|
308
|
+
if (performance.now() > deadline) break;
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const result = await searchRoot(resolveSafePath, requestedRoot, query, deadline, maxEntries);
|
|
312
|
+
if (result) rootResults.push(result);
|
|
313
|
+
} catch (error) {
|
|
314
|
+
errors.push({
|
|
315
|
+
root: requestedRoot,
|
|
316
|
+
error: error instanceof Error ? error.message : 'Failed to search root',
|
|
317
|
+
status: statusForError(error),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const kindOrder = { skill: 0, file: 1, folder: 2 };
|
|
323
|
+
const seenItems = new Set();
|
|
324
|
+
const items = [];
|
|
325
|
+
|
|
326
|
+
for (const rootResult of rootResults) {
|
|
327
|
+
for (const item of rootResult.items) {
|
|
328
|
+
const key = `${item.kind}:${item.path}`;
|
|
329
|
+
if (seenItems.has(key)) continue;
|
|
330
|
+
seenItems.add(key);
|
|
331
|
+
items.push(item);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
items.sort((left, right) => {
|
|
336
|
+
if (right.score !== left.score) return right.score - left.score;
|
|
337
|
+
if (kindOrder[left.kind] !== kindOrder[right.kind]) return kindOrder[left.kind] - kindOrder[right.kind];
|
|
338
|
+
return left.path.localeCompare(right.path);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const metrics = rootResults.reduce((acc, result) => {
|
|
342
|
+
acc.visitedDirs += result.metrics.visitedDirs || 0;
|
|
343
|
+
acc.visitedFiles += result.metrics.visitedFiles || 0;
|
|
344
|
+
acc.skippedDirs += result.metrics.skippedDirs || 0;
|
|
345
|
+
acc.errors += result.metrics.errors || 0;
|
|
346
|
+
acc.collectedEntries += result.metrics.collectedEntries || 0;
|
|
347
|
+
acc.skillEntries += result.metrics.skillEntries || 0;
|
|
348
|
+
acc.timedOut = acc.timedOut || Boolean(result.metrics.timedOut);
|
|
349
|
+
acc.truncated = acc.truncated || Boolean(result.metrics.truncated);
|
|
350
|
+
return acc;
|
|
351
|
+
}, {
|
|
352
|
+
backend: 'node',
|
|
353
|
+
roots: rootResults.length,
|
|
354
|
+
visitedDirs: 0,
|
|
355
|
+
visitedFiles: 0,
|
|
356
|
+
skippedDirs: 0,
|
|
357
|
+
errors: errors.length,
|
|
358
|
+
collectedEntries: 0,
|
|
359
|
+
skillEntries: 0,
|
|
360
|
+
timedOut: performance.now() > deadline,
|
|
361
|
+
truncated: false,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
metrics.resultCount = Math.min(items.length, limit);
|
|
365
|
+
metrics.totalMs = performance.now() - started;
|
|
366
|
+
|
|
367
|
+
return sendJson(200, {
|
|
368
|
+
query,
|
|
369
|
+
roots: rootResults.map((result) => result.root),
|
|
370
|
+
items: items.slice(0, limit),
|
|
371
|
+
metrics,
|
|
372
|
+
errors,
|
|
373
|
+
});
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
module.exports = {
|
|
378
|
+
createFsSearchHandler,
|
|
379
|
+
_private: {
|
|
380
|
+
collectSearchEntries,
|
|
381
|
+
collectSkillEntries,
|
|
382
|
+
fuzzyScore,
|
|
383
|
+
fuzzyScorePath,
|
|
384
|
+
normalizeSearchRoots,
|
|
385
|
+
rankedSearchItem,
|
|
386
|
+
},
|
|
387
|
+
};
|
|
@@ -11,6 +11,10 @@ async function handleFileRoutes(ctx) {
|
|
|
11
11
|
fsRest.handleRead(ctx.getQuery(), ctx.sendJson);
|
|
12
12
|
return true;
|
|
13
13
|
}
|
|
14
|
+
if (ctx.pathname === '/fs/search' && ctx.method === 'POST') {
|
|
15
|
+
fsRest.handleSearch(await ctx.readJsonBody(), ctx.sendJson);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
14
18
|
if (ctx.pathname === '/fs/content' && (ctx.method === 'GET' || ctx.method === 'HEAD')) {
|
|
15
19
|
await fsRest.handleContent(ctx.getQuery(), ctx.req, ctx.res);
|
|
16
20
|
return true;
|