@yeaft/webchat-agent 1.0.208 → 1.0.210

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": "@yeaft/webchat-agent",
3
- "version": "1.0.208",
3
+ "version": "1.0.210",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -1577,40 +1577,72 @@ export class ConversationStore {
1577
1577
  const limit = Math.min(50, Math.max(1, Number.isFinite(opts.limit) ? Math.floor(opts.limit) : 20));
1578
1578
  const beforeSeq = Number.isFinite(opts.beforeSeq) ? opts.beforeSeq : Infinity;
1579
1579
  const results = [];
1580
- const seen = new Set();
1581
1580
  let hasMore = false;
1582
1581
 
1583
- for (const message of this.#iterateSessionRows(sessionId, { beforeSeq, desc: true })) {
1584
- if (!message || message.sessionId !== sessionId || isHiddenConversationRow(message)) continue;
1585
- if (message.role !== 'user' && message.role !== 'assistant') continue;
1586
- if (!message.id || seen.has(message.id)) continue;
1587
- seen.add(message.id);
1588
-
1589
- const text = this.#visibleSearchText(message.content);
1582
+ for (const entry of this.#iterateVisibleResponseEntries(sessionId, { beforeSeq })) {
1583
+ const text = entry.textParts.join(' ');
1590
1584
  const matchIndex = text.toLocaleLowerCase().indexOf(needle);
1591
1585
  if (matchIndex < 0) continue;
1592
1586
  if (results.length >= limit) {
1593
1587
  hasMore = true;
1594
1588
  break;
1595
1589
  }
1596
-
1597
- const seq = parseSeqFromId(message.id);
1598
- if (!Number.isFinite(seq)) continue;
1599
1590
  results.push({
1600
- messageId: message.id,
1601
- turnId: message.turnId || message.threadId || message.id,
1602
- seq,
1603
- role: message.role,
1604
- speakerVpId: message.speakerVpId || null,
1605
- timestamp: message.ts || message.time || null,
1591
+ ...this.#projectVisibleResponseEntry(entry),
1606
1592
  snippet: this.#searchSnippet(text, matchIndex, needle.length),
1607
1593
  });
1608
1594
  }
1609
1595
 
1596
+ const lastResult = results[results.length - 1] || null;
1597
+ return {
1598
+ results: results.map(({ _beforeSeq, ...result }) => result),
1599
+ hasMore,
1600
+ nextBeforeSeq: hasMore && lastResult ? lastResult._beforeSeq : null,
1601
+ };
1602
+ }
1603
+
1604
+ /**
1605
+ * Load a lightweight outline page for one Session. Only user and assistant
1606
+ * text metadata is projected; tool payloads, attachments and full message
1607
+ * bodies never leave the Agent through this API.
1608
+ *
1609
+ * @param {string} sessionId
1610
+ * @param {{ limit?: number, beforeSeq?: number|null, includeTotal?: boolean }} [opts]
1611
+ * @returns {{ results: object[], hasMore: boolean, nextBeforeSeq: number|null, totalCount: number|null }}
1612
+ */
1613
+ loadVisibleOutlineBySession(sessionId, opts = {}) {
1614
+ if (!sessionId) return { results: [], hasMore: false, nextBeforeSeq: null, totalCount: 0 };
1615
+
1616
+ const limit = Math.min(100, Math.max(1, Number.isFinite(opts.limit) ? Math.floor(opts.limit) : 50));
1617
+ const beforeSeq = Number.isFinite(opts.beforeSeq) ? opts.beforeSeq : Infinity;
1618
+ const newestFirst = [];
1619
+ let hasMore = false;
1620
+
1621
+ for (const entry of this.#iterateVisibleResponseEntries(sessionId, { beforeSeq })) {
1622
+ if (newestFirst.length >= limit) {
1623
+ hasMore = true;
1624
+ break;
1625
+ }
1626
+ const projected = this.#projectVisibleResponseEntry(entry);
1627
+ newestFirst.push({
1628
+ ...projected,
1629
+ snippet: this.#outlineSnippet(entry.textParts.join(' ')),
1630
+ });
1631
+ }
1632
+
1633
+ let totalCount = null;
1634
+ if (opts.includeTotal !== false) {
1635
+ totalCount = 0;
1636
+ for (const _entry of this.#iterateVisibleResponseEntries(sessionId)) totalCount += 1;
1637
+ }
1638
+
1639
+ const oldestEntry = newestFirst[newestFirst.length - 1] || null;
1640
+ const results = newestFirst.reverse().map(({ _beforeSeq, ...entry }) => entry);
1610
1641
  return {
1611
1642
  results,
1612
1643
  hasMore,
1613
- nextBeforeSeq: hasMore && results.length > 0 ? results[results.length - 1].seq : null,
1644
+ nextBeforeSeq: hasMore && oldestEntry ? oldestEntry._beforeSeq : null,
1645
+ totalCount,
1614
1646
  };
1615
1647
  }
1616
1648
 
@@ -2381,6 +2413,75 @@ export class ConversationStore {
2381
2413
  };
2382
2414
  }
2383
2415
 
2416
+ *#iterateVisibleResponseEntries(sessionId, opts = {}) {
2417
+ const beforeSeq = Number.isFinite(opts.beforeSeq) ? opts.beforeSeq : Infinity;
2418
+ const seen = new Set();
2419
+ let current = null;
2420
+
2421
+ const visibleRow = (message) => {
2422
+ if (!message || message.sessionId !== sessionId || isHiddenConversationRow(message)) return null;
2423
+ if (message.role !== 'user' && message.role !== 'assistant') return null;
2424
+ if (!message.id || seen.has(message.id)) return null;
2425
+ seen.add(message.id);
2426
+ const seq = parseSeqFromId(message.id);
2427
+ if (!Number.isFinite(seq)) return null;
2428
+ const text = this.#visibleSearchText(message.content);
2429
+ const speakerVpId = message.speakerVpId || null;
2430
+ return {
2431
+ message,
2432
+ seq,
2433
+ text,
2434
+ speakerVpId,
2435
+ groupKey: message.role === 'assistant'
2436
+ ? `assistant:${message.turnId || message.id}:${speakerVpId || ''}`
2437
+ : `user:${message.id}`,
2438
+ };
2439
+ };
2440
+ const startEntry = (row) => ({
2441
+ groupKey: row.groupKey,
2442
+ role: row.message.role,
2443
+ turnId: row.message.turnId || row.message.threadId || row.message.id,
2444
+ speakerVpId: row.speakerVpId,
2445
+ oldestSeq: row.seq,
2446
+ anchor: row,
2447
+ anchorHasText: !!row.text,
2448
+ textParts: row.text ? [row.text] : [],
2449
+ });
2450
+ const mergeRow = (entry, row) => {
2451
+ entry.oldestSeq = Math.min(entry.oldestSeq, row.seq);
2452
+ if (row.text) entry.textParts.unshift(row.text);
2453
+ if (!entry.anchorHasText && row.text) {
2454
+ entry.anchor = row;
2455
+ entry.anchorHasText = true;
2456
+ }
2457
+ };
2458
+
2459
+ for (const message of this.#iterateSessionRows(sessionId, { beforeSeq, desc: true })) {
2460
+ const row = visibleRow(message);
2461
+ if (!row) continue;
2462
+ if (current && current.groupKey === row.groupKey) {
2463
+ mergeRow(current, row);
2464
+ continue;
2465
+ }
2466
+ if (current) yield current;
2467
+ current = startEntry(row);
2468
+ }
2469
+ if (current) yield current;
2470
+ }
2471
+
2472
+ #projectVisibleResponseEntry(entry) {
2473
+ return {
2474
+ messageId: entry.anchor.message.id,
2475
+ ...(entry.anchor.message.clientMessageId ? { clientMessageId: entry.anchor.message.clientMessageId } : {}),
2476
+ turnId: entry.turnId,
2477
+ seq: entry.anchor.seq,
2478
+ role: entry.role,
2479
+ speakerVpId: entry.speakerVpId,
2480
+ timestamp: entry.anchor.message.ts || entry.anchor.message.time || null,
2481
+ _beforeSeq: entry.oldestSeq,
2482
+ };
2483
+ }
2484
+
2384
2485
  #visibleSearchText(content) {
2385
2486
  if (typeof content === 'string') return content.replace(/\s+/g, ' ').trim();
2386
2487
  if (!Array.isArray(content)) return '';
@@ -2399,6 +2500,11 @@ export class ConversationStore {
2399
2500
  return `${start > 0 ? '…' : ''}${text.slice(start, end)}${end < text.length ? '…' : ''}`;
2400
2501
  }
2401
2502
 
2503
+ #outlineSnippet(text) {
2504
+ const limit = 180;
2505
+ return text.length > limit ? `${text.slice(0, limit).trimEnd()}…` : text;
2506
+ }
2507
+
2402
2508
  #readSegmentRows(conversationDir, opts = {}) {
2403
2509
  return this.#segmentStoreForConversationDir(conversationDir).readAll(opts);
2404
2510
  }
package/yeaft/engine.js CHANGED
@@ -46,7 +46,7 @@ import { countTurns } from './turn-utils.js';
46
46
  import { attachRouterPlan, extractPriorPlan, stripMetaForWire } from './router/continuity.js';
47
47
  import { resolveThinking } from './router/thinking.js';
48
48
  import { approxTokens } from './memory/budget.js';
49
- import { COLLAB_TOOL_POLICY, normalizeToolOutput, truncateToolResultIfNeeded } from './tools/registry.js';
49
+ import { COLLAB_TOOL_POLICY, isToolErrorOutput, normalizeToolOutput, truncateToolResultIfNeeded } from './tools/registry.js';
50
50
  import { extractDisplayImages, stripDisplayImageData } from './image-assets.js';
51
51
  import { acknowledgePendingNotifications, formatNotificationsForPrompt, peekPendingNotifications } from './sub-agent/notifications.js';
52
52
  import {
@@ -3344,6 +3344,7 @@ export class Engine {
3344
3344
  let output;
3345
3345
  let displayImages = [];
3346
3346
  let isError = false;
3347
+ let toolErrorOutput = null;
3347
3348
  currentToolCallForAsyncTask = {
3348
3349
  id: tc.id,
3349
3350
  name: tc.name,
@@ -3363,9 +3364,11 @@ export class Engine {
3363
3364
  try {
3364
3365
  yield { type: 'tool_start', id: tc.id, name: tc.name, input: tc.input, threadId: this.currentThreadId };
3365
3366
  if (this.#toolRegistry) {
3367
+ toolErrorOutput = this.#toolRegistry.get(tc.name)?.errorOutput || null;
3366
3368
  output = await this.#toolRegistry.execute(tc.name, tc.input, toolCtx);
3367
3369
  } else {
3368
3370
  const tool = this.#tools.get(tc.name);
3371
+ toolErrorOutput = tool.errorOutput || null;
3369
3372
  // Pass the full toolCtx (cwd, workDir, signal, …) — not just
3370
3373
  // `{ signal }`. Legacy registerTool() callers historically got
3371
3374
  // a 1-field ctx, but that means tools like bash/file-read run
@@ -3380,7 +3383,8 @@ export class Engine {
3380
3383
  if (displayImages.length > 0) {
3381
3384
  output = stripDisplayImageData(output, displayImages);
3382
3385
  }
3383
- yield { type: 'tool_end', id: tc.id, name: tc.name, output, displayImages, isError: false, threadId: this.currentThreadId };
3386
+ isError = toolErrorOutput === 'json-error-envelope' && isToolErrorOutput(output);
3387
+ yield { type: 'tool_end', id: tc.id, name: tc.name, output, displayImages, isError, threadId: this.currentThreadId };
3384
3388
  if (displayImages.some(image => image.deliveryQueued === true)) hasDisplayImageAnchor = true;
3385
3389
  } catch (err) {
3386
3390
  output = `Error: ${err.message}`;
@@ -210,6 +210,7 @@ Guidelines:
210
210
  },
211
211
  required: ['command'],
212
212
  },
213
+ errorOutput: null,
213
214
  isConcurrencySafe: () => false,
214
215
  isReadOnly: () => false,
215
216
  isDestructive: (input) => {
@@ -223,7 +224,7 @@ Guidelines:
223
224
  },
224
225
  async execute(input, ctx) {
225
226
  const { command, cwd: inputCwd, timeout_ms, background = false, taskTitle } = input;
226
- if (!command) return JSON.stringify({ error: 'command is required' });
227
+ if (!command) throw new Error('command is required');
227
228
 
228
229
  // Resolve working directory
229
230
  const cwd = inputCwd
@@ -231,7 +232,7 @@ Guidelines:
231
232
  : (ctx?.cwd || process.cwd());
232
233
 
233
234
  if (!existsSync(cwd)) {
234
- return JSON.stringify({ error: `Working directory does not exist: ${cwd}` });
235
+ throw new Error(`Working directory does not exist: ${cwd}`);
235
236
  }
236
237
 
237
238
  // Clamp timeout
@@ -240,7 +241,7 @@ Guidelines:
240
241
 
241
242
  if (background) {
242
243
  if (!ctx?.taskManager) {
243
- return JSON.stringify({ error: 'background tasks are unavailable in this runtime' });
244
+ throw new Error('background tasks are unavailable in this runtime');
244
245
  }
245
246
  try {
246
247
  const task = ctx.taskManager.startShellTask({
@@ -263,7 +264,7 @@ Guidelines:
263
264
  try { ctx.registerAsyncTask?.(task.id, currentToolCall || {}); } catch { /* never block tool return on coord errors */ }
264
265
  return `Started background task ${task.id}.\nStatus: ${task.status}\nLog: ${task.log?.path || ''}\nUse ListTasks, ReadTaskLog, or CancelTask to inspect or control it.`;
265
266
  } catch (err) {
266
- return JSON.stringify({ error: err?.message || String(err) });
267
+ throw new Error(err?.message || String(err));
267
268
  }
268
269
  }
269
270
 
@@ -287,7 +288,7 @@ Guidelines:
287
288
  }
288
289
  return output || '(no output)';
289
290
  } catch (err) {
290
- return JSON.stringify({ error: err.message });
291
+ throw new Error(err.message);
291
292
  }
292
293
  },
293
294
  });
@@ -34,6 +34,50 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024;
34
34
  * round-trip this tool's prompt guidance promises to avoid). */
35
35
  const DEFAULT_LIMIT = 3000;
36
36
 
37
+ /** Keep raw output close to the model-facing 32 KiB budget while leaving
38
+ * room for line metadata and the Registry's localized truncation marker. */
39
+ const DEFAULT_OUTPUT_BYTES = 30 * 1024;
40
+
41
+ function takeUtf8(text, maxBytes) {
42
+ const chars = [];
43
+ let bytes = 0;
44
+ for (const char of text) {
45
+ const size = Buffer.byteLength(char, 'utf8');
46
+ if (bytes + size > maxBytes) break;
47
+ chars.push(char);
48
+ bytes += size;
49
+ }
50
+ return { text: chars.join(''), charCount: chars.length, bytes };
51
+ }
52
+
53
+ function formatLinesWithinBudget(allLines, startLine, endLine, startColumn = 0, maxBytes = DEFAULT_OUTPUT_BYTES) {
54
+ const parts = [];
55
+ let usedBytes = 0;
56
+ let nextLine = startLine;
57
+ let nextColumn = startColumn;
58
+ for (let i = startLine; i < endLine; i += 1) {
59
+ const prefix = parts.length > 0 ? '\n' : '';
60
+ const lineChars = Array.from(allLines[i]);
61
+ const column = i === startLine ? Math.min(startColumn, lineChars.length) : 0;
62
+ const linePrefix = `${i + 1}\t${column > 0 ? `[column ${column}] ` : ''}`;
63
+ const formatted = linePrefix + lineChars.slice(column).join('');
64
+ const remaining = maxBytes - usedBytes - Buffer.byteLength(prefix, 'utf8');
65
+ if (remaining <= 0) break;
66
+ const bounded = takeUtf8(formatted, remaining);
67
+ if (!bounded.text) break;
68
+ parts.push(prefix + bounded.text);
69
+ usedBytes += Buffer.byteLength(prefix, 'utf8') + bounded.bytes;
70
+ if (bounded.text !== formatted) {
71
+ nextLine = i;
72
+ nextColumn = column + Math.max(0, bounded.charCount - Array.from(linePrefix).length);
73
+ break;
74
+ }
75
+ nextLine = i + 1;
76
+ nextColumn = 0;
77
+ }
78
+ return { text: parts.join(''), nextLine, nextColumn };
79
+ }
80
+
37
81
  export default defineTool({
38
82
  name: 'FileRead',
39
83
  description: {
@@ -70,14 +114,21 @@ Guidelines:
70
114
  },
71
115
  },
72
116
  offset: {
73
- type: 'number',
117
+ type: 'integer',
118
+ minimum: 0,
74
119
  description: {
75
120
  en: 'Line number to start reading from (0-based, default: 0)',
76
121
  zh: '起始行号(从 0 开始计数,默认 0)',
77
122
  },
78
123
  },
124
+ column_offset: {
125
+ type: 'integer',
126
+ minimum: 0,
127
+ description: { en: 'Unicode character offset within the first requested line (0-based, default: 0)', zh: '首个待读行内的 Unicode 字符偏移量(从 0 开始,默认 0)' },
128
+ },
79
129
  limit: {
80
- type: 'number',
130
+ type: 'integer',
131
+ minimum: 1,
81
132
  description: {
82
133
  en: `Maximum number of lines to read (default: ${DEFAULT_LIMIT})`,
83
134
  zh: `最多读取行数(默认 ${DEFAULT_LIMIT} 行)`,
@@ -89,8 +140,17 @@ Guidelines:
89
140
  isConcurrencySafe: () => true,
90
141
  isReadOnly: () => true,
91
142
  async execute(input, ctx) {
92
- const { file_path, offset = 0, limit = DEFAULT_LIMIT } = input;
143
+ const { file_path, offset = 0, column_offset = 0, limit = DEFAULT_LIMIT } = input;
93
144
  if (!file_path) return JSON.stringify({ error: 'file_path is required' });
145
+ if (!Number.isInteger(offset) || offset < 0) {
146
+ return JSON.stringify({ error: 'offset must be a non-negative integer' });
147
+ }
148
+ if (!Number.isInteger(column_offset) || column_offset < 0) {
149
+ return JSON.stringify({ error: 'column_offset must be a non-negative integer' });
150
+ }
151
+ if (!Number.isInteger(limit) || limit < 1) {
152
+ return JSON.stringify({ error: 'limit must be a positive integer' });
153
+ }
94
154
 
95
155
  const cwd = ctx?.cwd || process.cwd();
96
156
  const absPath = resolve(cwd, file_path);
@@ -126,20 +186,28 @@ Guidelines:
126
186
  const allLines = content.split('\n');
127
187
  const totalLines = allLines.length;
128
188
 
189
+ if (offset > totalLines) {
190
+ return JSON.stringify({ error: `offset ${offset} exceeds file length (${totalLines} lines)` });
191
+ }
192
+ if (offset === totalLines) {
193
+ return `[Offset ${offset} is at end of file (${totalLines} lines total).]`;
194
+ }
195
+
129
196
  // Apply offset and limit
130
- const startLine = Math.max(0, Math.min(offset, totalLines));
197
+ const startLine = offset;
131
198
  const endLine = Math.min(startLine + limit, totalLines);
132
- const lines = allLines.slice(startLine, endLine);
133
-
134
- // Format with line numbers (1-based like cat -n)
135
- const numbered = lines.map((line, i) => {
136
- const lineNum = startLine + i + 1;
137
- return `${lineNum}\t${line}`;
138
- }).join('\n');
139
-
140
- // Add metadata if partial
141
- if (startLine > 0 || endLine < totalLines) {
142
- return `${numbered}\n\n[Showing lines ${startLine + 1}-${endLine} of ${totalLines} total]`;
199
+ const startColumn = column_offset;
200
+ const { text: numbered, nextLine, nextColumn } = formatLinesWithinBudget(allLines, startLine, endLine, startColumn);
201
+
202
+ const hasMoreContent = nextColumn > 0 || nextLine < totalLines;
203
+ if (startLine > 0 || startColumn > 0 || hasMoreContent) {
204
+ const continuation = hasMoreContent
205
+ ? nextColumn > 0
206
+ ? ` Continue with offset=${nextLine}, column_offset=${nextColumn}.`
207
+ : ` Continue with offset=${nextLine}.`
208
+ : '';
209
+ const shownEnd = nextColumn > 0 ? nextLine + 1 : nextLine;
210
+ return `${numbered}\n\n[Showing lines ${startLine + 1}-${shownEnd} of ${totalLines} total.${continuation}]`;
143
211
  }
144
212
 
145
213
  return numbered;
@@ -10,6 +10,13 @@ import { readdir, stat } from 'fs/promises';
10
10
  import { existsSync } from 'fs';
11
11
  import { resolve, join, relative } from 'path';
12
12
 
13
+ const STAT_CONCURRENCY = 32;
14
+ const SKIP_DIRS = new Set([
15
+ 'node_modules', '.git', '__pycache__', '.next', '.nuxt',
16
+ 'dist', 'build', '.cache', '.venv', 'venv', '.tox',
17
+ 'vendor', 'target', '.gradle', '.idea', '.vscode',
18
+ ]);
19
+
13
20
  /**
14
21
  * Simple glob pattern matcher (supports * and **).
15
22
  * @param {string} pattern
@@ -44,19 +51,13 @@ async function* walkDir(dir, baseDir, maxDepth = 10, depth = 0) {
44
51
  return;
45
52
  }
46
53
 
47
- // Skip common large/irrelevant directories
48
- const SKIP = new Set([
49
- 'node_modules', '.git', '__pycache__', '.next', '.nuxt',
50
- 'dist', 'build', '.cache', '.venv', 'venv', '.tox',
51
- 'vendor', 'target', '.gradle', '.idea', '.vscode',
52
- ]);
53
-
54
54
  for (const entry of entries) {
55
55
  const fullPath = join(dir, entry.name);
56
56
  const relPath = relative(baseDir, fullPath);
57
57
 
58
58
  if (entry.isDirectory()) {
59
- if (SKIP.has(entry.name)) continue;
59
+ const normalized = relPath.replace(/\\/g, '/');
60
+ if (SKIP_DIRS.has(entry.name) || normalized === '.yeaft/worktrees' || normalized.startsWith('.yeaft/worktrees/')) continue;
60
61
  yield { path: relPath, isDir: true };
61
62
  yield* walkDir(fullPath, baseDir, maxDepth, depth + 1);
62
63
  } else {
@@ -106,7 +107,8 @@ Guidelines:
106
107
  },
107
108
  },
108
109
  limit: {
109
- type: 'number',
110
+ type: 'integer',
111
+ minimum: 1,
110
112
  description: {
111
113
  en: 'Maximum number of results (default: 500)',
112
114
  zh: '最多返回结果数(默认 500)',
@@ -120,6 +122,9 @@ Guidelines:
120
122
  async execute(input, ctx) {
121
123
  const { pattern, path: searchPath, limit = 500 } = input;
122
124
  if (!pattern) return JSON.stringify({ error: 'pattern is required' });
125
+ if (!Number.isInteger(limit) || limit < 1) {
126
+ return JSON.stringify({ error: 'limit must be a positive integer' });
127
+ }
123
128
 
124
129
  const cwd = ctx?.cwd || process.cwd();
125
130
  const baseDir = searchPath ? resolve(cwd, searchPath) : cwd;
@@ -129,29 +134,25 @@ Guidelines:
129
134
  }
130
135
 
131
136
  try {
132
- const matches = [];
133
-
137
+ const paths = [];
134
138
  for await (const entry of walkDir(baseDir, baseDir)) {
135
- if (matches.length >= limit * 2) break; // over-fetch for sorting
139
+ if (!entry.isDir && matchGlob(pattern, entry.path)) paths.push(entry.path);
140
+ }
136
141
 
137
- if (!entry.isDir && matchGlob(pattern, entry.path)) {
138
- // Get mtime for sorting
142
+ // Exact newest-first semantics require every matching mtime. Batch the
143
+ // metadata reads instead of serializing one syscall per path.
144
+ const matches = [];
145
+ for (let i = 0; i < paths.length; i += STAT_CONCURRENCY) {
146
+ matches.push(...await Promise.all(paths.slice(i, i + STAT_CONCURRENCY).map(async (path) => {
139
147
  try {
140
- const fileStat = await stat(join(baseDir, entry.path));
141
- matches.push({
142
- path: entry.path,
143
- mtime: fileStat.mtimeMs,
144
- });
148
+ const fileStat = await stat(join(baseDir, path));
149
+ return { path, mtime: fileStat.mtimeMs };
145
150
  } catch {
146
- matches.push({ path: entry.path, mtime: 0 });
151
+ return { path, mtime: 0 };
147
152
  }
148
- }
153
+ })));
149
154
  }
150
-
151
- // Sort by mtime (newest first)
152
155
  matches.sort((a, b) => b.mtime - a.mtime);
153
-
154
- // Trim to limit
155
156
  const trimmed = matches.slice(0, limit);
156
157
 
157
158
  return trimmed.map(m => m.path).join('\n') || '(no matches)';