@myagentroam/node 0.9.94 → 0.9.96

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.
@@ -6,7 +6,7 @@ import { bundledRipgrepPath } from '../ripgrep-binary.js';
6
6
  const RESULT_LIMIT = 200;
7
7
  const PER_FILE_LIMIT = 20;
8
8
  const FILE_BYTE_LIMIT = 2 * 1024 * 1024;
9
- const SCAN_BYTE_LIMIT = 128 * 1024 * 1024;
9
+ const SCAN_BYTE_LIMIT = 64 * 1024 * 1024;
10
10
  const OUTPUT_BYTE_LIMIT = 2 * 1024 * 1024;
11
11
  const TIMEOUT_MS = 5_000;
12
12
  const WINDOWS_ARGUMENT_BATCH_BYTES = 24 * 1024;
@@ -53,29 +53,38 @@ export class WorkspaceContentSearchService {
53
53
  search(workspace, input) {
54
54
  if (typeof input.query !== 'string' || input.query.length < 1 || input.query.length > 256)
55
55
  throw new Error('FILE_CONTENT_SEARCH_INVALID');
56
- const key = `${workspace.id}\0${input.query.toLocaleLowerCase()}`;
56
+ if (input.regex !== undefined && typeof input.regex !== 'boolean')
57
+ throw new Error('FILE_CONTENT_SEARCH_INVALID');
58
+ const search = { query: input.query, regex: input.regex === true };
59
+ const queryKey = search.regex ? search.query : search.query.toLocaleLowerCase();
60
+ const key = `${workspace.id}\0${search.regex}\0${queryKey}`;
57
61
  const existing = this.pending.get(key);
58
62
  if (existing !== undefined)
59
63
  return existing;
60
- const task = this.perform(workspace, input.query).finally(() => this.pending.delete(key));
64
+ const task = this.perform(workspace, search).finally(() => this.pending.delete(key));
61
65
  this.pending.set(key, task);
62
66
  return task;
63
67
  }
64
- async perform(workspace, query) {
68
+ async perform(workspace, search) {
65
69
  const deadline = Date.now() + TIMEOUT_MS;
66
70
  const release = await acquireSearch(deadline);
67
71
  try {
72
+ if (search.regex && this.ripgrep === null)
73
+ throw new Error('RIPGREP_RESOURCE_UNAVAILABLE');
68
74
  const prepared = await this.candidates(workspace, deadline);
69
- if (prepared.files.length === 0)
75
+ if (prepared.files.length === 0) {
76
+ if (search.regex)
77
+ await this.validateNativeRegex(workspace.path, search, deadline);
70
78
  return {
71
79
  matches: [],
72
80
  engine: 'NODE',
73
81
  truncated: prepared.truncated || Date.now() >= deadline,
74
82
  skippedFiles: prepared.skippedFiles
75
83
  };
84
+ }
76
85
  if (this.ripgrep !== null) {
77
86
  try {
78
- const matches = await this.nativeSearch('RIPGREP', workspace.path, prepared.files, query, deadline);
87
+ const matches = await this.nativeSearch('RIPGREP', workspace.path, prepared.files, search, deadline);
79
88
  return {
80
89
  matches: matches.slice(0, RESULT_LIMIT),
81
90
  engine: 'RIPGREP',
@@ -83,11 +92,13 @@ export class WorkspaceContentSearchService {
83
92
  skippedFiles: prepared.skippedFiles
84
93
  };
85
94
  }
86
- catch {
95
+ catch (error) {
96
+ if (search.regex)
97
+ throw error;
87
98
  // Missing, incompatible or failed bundled resources safely fall through.
88
99
  }
89
100
  }
90
- const fallback = await nodeSearch(prepared.files, query, deadline);
101
+ const fallback = await nodeSearch(prepared.files, search.query, deadline);
91
102
  return {
92
103
  matches: fallback.matches.slice(0, RESULT_LIMIT),
93
104
  engine: 'NODE',
@@ -99,6 +110,14 @@ export class WorkspaceContentSearchService {
99
110
  release();
100
111
  }
101
112
  }
113
+ async validateNativeRegex(root, search, deadline) {
114
+ const command = commandFor('RIPGREP', [], search, this.ripgrep ?? undefined);
115
+ const result = await runCommand(command.file, [...command.args, '-'], root, deadline);
116
+ if (result.code === 2)
117
+ throw new Error('FILE_CONTENT_SEARCH_INVALID');
118
+ if (!command.validCodes.includes(result.code))
119
+ throw new Error('CONTENT_SEARCH_COMMAND_FAILED');
120
+ }
102
121
  async candidates(workspace, deadline) {
103
122
  const root = await realpath(workspace.path);
104
123
  let candidates;
@@ -139,21 +158,24 @@ export class WorkspaceContentSearchService {
139
158
  }
140
159
  return { files, skippedFiles, truncated };
141
160
  }
142
- async nativeSearch(engine, root, files, query, deadline) {
161
+ async nativeSearch(engine, root, files, search, deadline) {
143
162
  const matches = [];
144
163
  const argumentBatchBytes = this.platform === 'win32' ? WINDOWS_ARGUMENT_BATCH_BYTES : POSIX_ARGUMENT_BATCH_BYTES;
145
164
  for (const batch of batches(files, argumentBatchBytes)) {
146
165
  if (Date.now() >= deadline || matches.length > RESULT_LIMIT)
147
166
  break;
148
- const command = commandFor(engine, batch, query, this.ripgrep ?? undefined);
167
+ const command = commandFor(engine, batch, search, this.ripgrep ?? undefined);
149
168
  const result = await runCommand(command.file, command.args, root, deadline, engine === 'RIPGREP');
150
- if (!command.validCodes.includes(result.code))
169
+ if (!command.validCodes.includes(result.code)) {
170
+ if (search.regex && result.code === 2)
171
+ throw new Error('FILE_CONTENT_SEARCH_INVALID');
151
172
  throw new Error('CONTENT_SEARCH_COMMAND_FAILED');
173
+ }
152
174
  const paths = new Map(batch.flatMap((file) => [
153
175
  [normalizeResultPath(file.path, this.platform), file.path],
154
176
  [normalizeResultPath(file.absolutePath, this.platform), file.path]
155
177
  ]));
156
- matches.push(...parseOutput(engine, result.stdout, query).map((match) => ({
178
+ matches.push(...parseOutput(engine, result.stdout, search).map((match) => ({
157
179
  ...match,
158
180
  path: paths.get(normalizeResultPath(match.path, this.platform)) ??
159
181
  (() => {
@@ -213,7 +235,7 @@ function batches(files, maximumBytes) {
213
235
  result.push(current);
214
236
  return result;
215
237
  }
216
- function commandFor(engine, files, query, ripgrep) {
238
+ function commandFor(engine, files, search, ripgrep) {
217
239
  const paths = files.map((file) => file.path);
218
240
  if (engine === 'RIPGREP')
219
241
  return {
@@ -224,12 +246,12 @@ function commandFor(engine, files, query, ripgrep) {
224
246
  args: [
225
247
  '--json',
226
248
  '--no-config',
227
- '--fixed-strings',
228
249
  '--ignore-case',
229
250
  '--max-count',
230
251
  String(PER_FILE_LIMIT),
252
+ ...(search.regex ? [] : ['--fixed-strings']),
231
253
  '--',
232
- query,
254
+ search.query,
233
255
  ...paths
234
256
  ],
235
257
  validCodes: [0, 1]
@@ -301,14 +323,14 @@ async function runCommand(file, args, cwd, deadline, stopAfterResultLimit = fals
301
323
  });
302
324
  });
303
325
  }
304
- function parseOutput(engine, output, query) {
326
+ function parseOutput(engine, output, search) {
305
327
  if (engine === 'RIPGREP')
306
- return parseRipgrep(output, query);
328
+ return parseRipgrep(output, search);
307
329
  if (engine === 'GREP')
308
- return parseGrep(output, query);
309
- return parsePowerShell(output, query);
330
+ return parseGrep(output, search);
331
+ return parsePowerShell(output, search);
310
332
  }
311
- function parseRipgrep(output, query) {
333
+ function parseRipgrep(output, search) {
312
334
  return output
313
335
  .toString('utf8')
314
336
  .split('\n')
@@ -320,7 +342,9 @@ function parseRipgrep(output, query) {
320
342
  return [];
321
343
  const data = value.data;
322
344
  const preview = String(data?.lines?.text).replace(/[\r\n]+$/u, '');
323
- const column = lowerIndex(preview, query);
345
+ const column = search.regex
346
+ ? regexRipgrepIndex(preview, data?.submatches)
347
+ : lowerIndex(preview, search.query);
324
348
  if (typeof data?.path?.text !== 'string' || !Number.isInteger(data.line_number) || column < 0)
325
349
  throw new Error('CONTENT_SEARCH_OUTPUT_INVALID');
326
350
  return [
@@ -333,7 +357,7 @@ function parseRipgrep(output, query) {
333
357
  ];
334
358
  });
335
359
  }
336
- function parseGrep(output, query) {
360
+ function parseGrep(output, search) {
337
361
  const matches = [];
338
362
  let offset = 0;
339
363
  while (offset < output.length) {
@@ -345,7 +369,7 @@ function parseGrep(output, query) {
345
369
  const record = output.subarray(separator + 1, end).toString('utf8');
346
370
  const colon = record.indexOf(':');
347
371
  const preview = record.slice(colon + 1).replace(/\r$/u, '');
348
- const column = lowerIndex(preview, query);
372
+ const column = lowerIndex(preview, search.query);
349
373
  if (colon < 1 || column < 0)
350
374
  throw new Error('CONTENT_SEARCH_OUTPUT_INVALID');
351
375
  matches.push({
@@ -358,7 +382,7 @@ function parseGrep(output, query) {
358
382
  }
359
383
  return matches;
360
384
  }
361
- function parsePowerShell(output, query) {
385
+ function parsePowerShell(output, search) {
362
386
  return output
363
387
  .toString('utf8')
364
388
  .split(/\r?\n/u)
@@ -370,7 +394,7 @@ function parsePowerShell(output, query) {
370
394
  !Number.isInteger(match.line) ||
371
395
  typeof match.preview !== 'string')
372
396
  throw new Error('CONTENT_SEARCH_OUTPUT_INVALID');
373
- const column = lowerIndex(match.preview, query);
397
+ const column = lowerIndex(match.preview, search.query);
374
398
  if (column < 0)
375
399
  throw new Error('CONTENT_SEARCH_OUTPUT_INVALID');
376
400
  return [
@@ -445,6 +469,12 @@ async function nodeSearch(files, query, deadline) {
445
469
  function lowerIndex(source, query) {
446
470
  return source.toLocaleLowerCase().indexOf(query.toLocaleLowerCase());
447
471
  }
472
+ function regexRipgrepIndex(preview, submatches) {
473
+ const start = submatches?.[0]?.start;
474
+ if (typeof start !== 'number' || !Number.isInteger(start) || start < 0)
475
+ return -1;
476
+ return Buffer.from(preview, 'utf8').subarray(0, start).toString('utf8').length;
477
+ }
448
478
  function limitPerFile(matches) {
449
479
  const counts = new Map();
450
480
  return matches.filter((match) => {
@@ -1,7 +1,7 @@
1
1
  import { tmpdir } from 'node:os';
2
2
  import { isAbsolute, win32 } from 'node:path';
3
3
  import { listWorkspaceFiles, mutateWorkspaceFile, normalizeFilePreviewPath, readAllowedFileContentRange, readWorkspaceFileContentRange, readAllowedTextFile, readWorkspaceTextFile, writeWorkspaceTextFile, WorkspaceFileIndex } from '../workspace.js';
4
- const INDEX_CACHE_LIMIT = 8;
4
+ const INDEX_CACHE_LIMIT = 32;
5
5
  export class WorkspaceFileService {
6
6
  state;
7
7
  allowedRoots;
@@ -1,16 +1,23 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { listGitHistoryRefs, openGitHistoryFileContent, readGitHistory, readGitHistoryFileDiff, readGitHistoryFileContentRange, readGitHistoryFiles, readCurrentChangeDiff, readRestrictedDiff, resolveGitRepository, resolveGitRepositoryTarget, restoreCurrentChange } from '../workspace.js';
3
+ import { WorkspaceCodeNavigationService } from './workspace-code-navigation-service.js';
3
4
  import { WorkspaceContentSearchService } from './workspace-content-search-service.js';
4
5
  import { WorkspaceWatchController } from './workspace-watch-controller.js';
5
6
  import { WorkspaceWatchSnapshotService } from './workspace-watch-snapshot-service.js';
6
7
  export class WorkspaceWorkbenchService {
7
8
  options;
8
9
  contentSearch;
10
+ codeNavigation;
9
11
  snapshots;
10
12
  watchController;
11
13
  constructor(options) {
12
14
  this.options = options;
13
15
  this.contentSearch = new WorkspaceContentSearchService(options.files);
16
+ this.codeNavigation = new WorkspaceCodeNavigationService({
17
+ files: options.files,
18
+ nodeId: options.nodeId,
19
+ searchContent: (workspace, query) => this.contentSearch.search(workspace, { query })
20
+ });
14
21
  this.snapshots = new WorkspaceWatchSnapshotService(options);
15
22
  this.watchController = new WorkspaceWatchController({
16
23
  requireWorkspace: options.requireWorkspace,
@@ -30,6 +37,8 @@ export class WorkspaceWorkbenchService {
30
37
  'workspace.files.search': (data) => this.searchFiles(record(data)),
31
38
  'workspace.files.mutate': (data) => this.mutateFile(record(data)),
32
39
  'workspace.content.search': (data) => this.searchContent(record(data)),
40
+ 'workspace.symbol.prepare': (data) => this.prepareSymbolIndex(record(data)),
41
+ 'workspace.symbol.definition': (data) => this.symbolDefinition(record(data)),
33
42
  'workspace.files.upload.preflight': (data) => this.preflight(record(data)),
34
43
  'workspace.files.upload.create': (data) => this.createUpload(record(data)),
35
44
  'workspace.files.upload.chunk': (data) => this.writeUpload(record(data)),
@@ -56,9 +65,19 @@ export class WorkspaceWorkbenchService {
56
65
  ensureTopicSnapshot(workspaceId, topic) {
57
66
  return this.snapshots.ensureSnapshot(workspaceId, topic);
58
67
  }
59
- clearWorkspace(workspaceId) {
68
+ /** Node-owned file mutations must not reuse a generation's old candidate list. */
69
+ invalidateWorkspaceFiles(workspaceId) {
70
+ this.codeNavigation.invalidate(workspaceId);
71
+ }
72
+ /** Releases page-watch state without discarding the durable code index. */
73
+ releaseWorkspace(workspaceId) {
60
74
  this.snapshots.clear(workspaceId);
61
75
  }
76
+ /** Removes all Workspace state after the Workspace itself is unregistered. */
77
+ clearWorkspace(workspaceId) {
78
+ this.releaseWorkspace(workspaceId);
79
+ this.codeNavigation.forget(workspaceId);
80
+ }
62
81
  clear() {
63
82
  this.snapshots.clearAll();
64
83
  }
@@ -94,6 +113,14 @@ export class WorkspaceWorkbenchService {
94
113
  const workspace = this.options.requireWorkspace(input.workspaceId);
95
114
  return { search: await this.contentSearch.search(workspace, input) };
96
115
  }
116
+ async symbolDefinition(input) {
117
+ const workspace = this.options.requireWorkspace(input.workspaceId);
118
+ return { definition: await this.codeNavigation.definition(workspace, input) };
119
+ }
120
+ async prepareSymbolIndex(input) {
121
+ const workspace = this.options.requireWorkspace(input.workspaceId);
122
+ return { preparation: await this.codeNavigation.prepare(workspace, input) };
123
+ }
97
124
  async preflight(input) {
98
125
  return {
99
126
  uploads: await this.options.uploads.preflight(this.options.requireWorkspace(input.workspaceId), input)
package/dist/workspace.js CHANGED
@@ -90,12 +90,51 @@ export class WorkspaceFileIndex {
90
90
  expiryTimer;
91
91
  refreshedAt = 0;
92
92
  refreshAttemptedAt = 0;
93
+ lifecycleGeneration = 0;
93
94
  constructor(workspaceRoot, limits = {}) {
94
95
  this.workspaceRoot = workspaceRoot;
95
96
  this.limits = limits;
96
97
  }
97
- async contentCandidates() {
98
- await this.ensureLoaded();
98
+ async contentCandidates(options = {}) {
99
+ await this.ensureLoaded(options.requireFresh === true);
100
+ return this.currentContentCandidates();
101
+ }
102
+ /**
103
+ * Returns the current candidate snapshot without synchronously rebuilding an
104
+ * empty or explicitly invalidated index. A stale loaded snapshot schedules
105
+ * its normal single-flight refresh before it is returned.
106
+ */
107
+ peekContentCandidates() {
108
+ if (this.stale || this.root === undefined)
109
+ return undefined;
110
+ if (Date.now() - this.refreshAttemptedAt >=
111
+ (this.limits.freshnessTtlMs ?? FILE_INDEX_FRESHNESS_TTL_MS))
112
+ this.refreshInBackground();
113
+ return this.currentContentCandidates();
114
+ }
115
+ /** Starts a single candidate refresh without making the caller await it. */
116
+ refreshContentCandidates(requireFresh = false) {
117
+ if (this.loading !== undefined)
118
+ return this.loading;
119
+ if (this.stale || this.root === undefined) {
120
+ this.loading = this.rebuild().finally(() => {
121
+ this.loading = undefined;
122
+ });
123
+ return this.loading;
124
+ }
125
+ if (requireFresh) {
126
+ this.refreshAttemptedAt = Date.now();
127
+ this.loading = this.rebuild().finally(() => {
128
+ this.loading = undefined;
129
+ });
130
+ return this.loading;
131
+ }
132
+ if (Date.now() - this.refreshAttemptedAt >=
133
+ (this.limits.freshnessTtlMs ?? FILE_INDEX_FRESHNESS_TTL_MS))
134
+ this.refreshInBackground();
135
+ return this.loading ?? Promise.resolve();
136
+ }
137
+ currentContentCandidates() {
99
138
  this.touch();
100
139
  return {
101
140
  entries: this.entries.filter((entry) => entry.kind === 'FILE'),
@@ -103,6 +142,7 @@ export class WorkspaceFileIndex {
103
142
  };
104
143
  }
105
144
  dispose() {
145
+ this.lifecycleGeneration += 1;
106
146
  if (this.expiryTimer !== undefined)
107
147
  clearTimeout(this.expiryTimer);
108
148
  this.expiryTimer = undefined;
@@ -150,11 +190,22 @@ export class WorkspaceFileIndex {
150
190
  truncated: next !== undefined || this.truncated
151
191
  };
152
192
  }
153
- async ensureLoaded() {
193
+ async ensureLoaded(requireFresh = false) {
154
194
  if (!this.stale && this.root !== undefined) {
155
195
  if (Date.now() - this.refreshAttemptedAt >=
156
- (this.limits.freshnessTtlMs ?? FILE_INDEX_FRESHNESS_TTL_MS))
157
- this.refreshInBackground();
196
+ (this.limits.freshnessTtlMs ?? FILE_INDEX_FRESHNESS_TTL_MS)) {
197
+ if (requireFresh) {
198
+ if (this.loading === undefined) {
199
+ this.refreshAttemptedAt = Date.now();
200
+ this.loading = this.rebuild().finally(() => {
201
+ this.loading = undefined;
202
+ });
203
+ }
204
+ await this.loading;
205
+ }
206
+ else
207
+ this.refreshInBackground();
208
+ }
158
209
  return;
159
210
  }
160
211
  if (this.loading === undefined) {
@@ -171,14 +222,21 @@ export class WorkspaceFileIndex {
171
222
  this.expiryTimer.unref();
172
223
  }
173
224
  async rebuild() {
225
+ const generation = this.lifecycleGeneration;
174
226
  const root = await resolveRegisteredWorkspaceRoot(this.workspaceRoot);
227
+ if (generation !== this.lifecycleGeneration)
228
+ return;
175
229
  const excludes = [...DEFAULT_QUICK_OPEN_EXCLUDES, ...(await workspaceExcludePatterns(root))];
230
+ if (generation !== this.lifecycleGeneration)
231
+ return;
176
232
  const indexed = [];
177
233
  const entryLimit = this.limits.entryLimit ?? FILE_INDEX_ENTRY_LIMIT;
178
234
  let truncated = false;
179
235
  const directories = ['.'];
180
236
  let directoryCursor = 0;
181
- while (directoryCursor < directories.length && !truncated) {
237
+ while (generation === this.lifecycleGeneration &&
238
+ directoryCursor < directories.length &&
239
+ !truncated) {
182
240
  const relativeDirectory = directories[directoryCursor];
183
241
  directoryCursor += 1;
184
242
  const directory = await resolveWorkspaceEntryFromCanonicalRoot(root, relativeDirectory, true).catch(() => undefined);
@@ -191,6 +249,8 @@ export class WorkspaceFileIndex {
191
249
  const appendPending = async () => {
192
250
  const resolvedEntries = await Promise.all(pending);
193
251
  pending = [];
252
+ if (generation !== this.lifecycleGeneration)
253
+ return;
194
254
  for (const entry of resolvedEntries) {
195
255
  if (entry === undefined)
196
256
  continue;
@@ -208,6 +268,8 @@ export class WorkspaceFileIndex {
208
268
  }
209
269
  };
210
270
  for await (const child of stream) {
271
+ if (generation !== this.lifecycleGeneration)
272
+ return;
211
273
  const candidatePath = relativeDirectory === '.'
212
274
  ? child.name
213
275
  : posix.join(relativeDirectory.replaceAll('\\', '/'), child.name);
@@ -224,6 +286,8 @@ export class WorkspaceFileIndex {
224
286
  if (!truncated && pending.length > 0)
225
287
  await appendPending();
226
288
  }
289
+ if (generation !== this.lifecycleGeneration)
290
+ return;
227
291
  this.root = root;
228
292
  this.entries = indexed;
229
293
  this.truncated = truncated;
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.9.94",
3
+ "version": "0.9.96",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
- "license": "Apache-2.0",
5
+ "license": "Apache-2.0 AND GPL-2.0-or-later",
6
6
  "type": "module",
7
7
  "files": [
8
8
  "dist/**/*.js",
9
9
  "vendor/ripgrep/**/*",
10
+ "vendor/ctags/**/*",
10
11
  "LICENSE",
11
12
  "NOTICE"
12
13
  ],
@@ -26,16 +27,18 @@
26
27
  "minimatch": "^10.2.6",
27
28
  "node-datachannel": "0.33.4",
28
29
  "node-pty": "1.1.0",
30
+ "npm": "11.12.1",
29
31
  "ws": "^8.21.3",
30
32
  "zod": "4.4.3",
31
- "@myagentroam/agent": "0.9.94",
32
- "@myagentroam/protocol": "0.9.94"
33
+ "@myagentroam/agent": "0.9.96",
34
+ "@myagentroam/protocol": "0.9.96"
33
35
  },
34
36
  "devDependencies": {
35
37
  "@types/ws": "^8.18.1"
36
38
  },
37
39
  "scripts": {
38
40
  "build": "node ../scripts/clean-build-output.mjs dist && tsc -p tsconfig.json",
41
+ "stage:computer-use-runtime": "node ../scripts/stage-computer-use-runtime.mjs",
39
42
  "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit",
40
43
  "dev": "tsx watch src/main.ts",
41
44
  "start": "node dist/main.js",
@@ -0,0 +1,10 @@
1
+ This directory distributes Universal Ctags 6.2.1 as a separate executable work.
2
+
3
+ Universal Ctags is licensed under GPL-2.0-or-later. Its complete corresponding
4
+ source is included at source/universal-ctags-6.2.1.tar.gz. The Linux x64 program
5
+ was built without source changes from that archive using configure --disable-xml
6
+ with optional pkg-config dependencies unavailable. The Windows x64 program is
7
+ the upstream universal-ctags/ctags-win32 v6.2.1 ctags-v6.2.1-x64.zip executable.
8
+
9
+ The Windows upstream package also distributes the MinGW-w64 runtime, jansson,
10
+ and libyaml license texts copied into licenses/.