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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.112",
3
+ "version": "0.1.113",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -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;