@myagentroam/node 0.11.61 → 0.11.63

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.
@@ -0,0 +1,188 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { AgentError, countModelTokens } from '@myagentroam/agent';
3
+ const DEFAULT_BUDGET = 2_000;
4
+ const SNAPSHOT_BUDGET = 32 * 1024 * 1024;
5
+ /** Process-local documents; callers supply the trusted storage/session scope. */
6
+ export class WebSnapshots {
7
+ maxBytes;
8
+ #entries = new Map();
9
+ #bytes = 0;
10
+ constructor(maxBytes = SNAPSHOT_BUDGET) {
11
+ this.maxBytes = maxBytes;
12
+ }
13
+ add(scope, document) {
14
+ const ref = randomUUID();
15
+ const size = Buffer.byteLength(JSON.stringify({ ...document, scope, ref })) + 128;
16
+ if (size > this.maxBytes)
17
+ throw unavailable();
18
+ while (this.#bytes + size > this.maxBytes) {
19
+ const oldest = this.#entries.keys().next().value;
20
+ if (oldest === undefined)
21
+ break;
22
+ this.#delete(oldest);
23
+ }
24
+ this.#entries.set(ref, { ...document, scope, size });
25
+ this.#bytes += size;
26
+ return ref;
27
+ }
28
+ clearScope(scope) {
29
+ for (const [ref, entry] of this.#entries)
30
+ if (entry.scope === scope)
31
+ this.#delete(ref);
32
+ }
33
+ clearHostedStorage(storage) {
34
+ for (const [ref, entry] of this.#entries) {
35
+ try {
36
+ const scope = JSON.parse(entry.scope);
37
+ if (Array.isArray(scope) && scope[0] === 'hosted' && scope[1] === storage)
38
+ this.#delete(ref);
39
+ }
40
+ catch {
41
+ /* Ignore unrelated scopes. */
42
+ }
43
+ }
44
+ }
45
+ clear() {
46
+ this.#entries.clear();
47
+ this.#bytes = 0;
48
+ }
49
+ open(scope, ref, options) {
50
+ const entry = this.#get(scope, ref);
51
+ const lines = entry.content.split('\n');
52
+ const budget = options.maxTokens ?? DEFAULT_BUDGET;
53
+ let line = options.line ?? 0;
54
+ let column = 0;
55
+ if (options.cursor !== undefined) {
56
+ const match = /^(\d+):(\d+)$/u.exec(options.cursor);
57
+ if (!match)
58
+ throw invalidCursor();
59
+ line = Number(match[1]);
60
+ column = Number(match[2]);
61
+ }
62
+ if (!Number.isSafeInteger(line) ||
63
+ !Number.isSafeInteger(column) ||
64
+ line < 0 ||
65
+ line >= lines.length ||
66
+ column < 0 ||
67
+ column > lines[line].length ||
68
+ (column > 0 && column < lines[line].length && /[\uDC00-\uDFFF]/u.test(lines[line][column])))
69
+ throw invalidCursor();
70
+ const startLine = line;
71
+ const header = `Ref: ${ref}\nTitle: ${short(entry.title ?? '', 160)}\nURL: ${short(entry.url, 240)}\n`;
72
+ let body = '';
73
+ let lastLine = line;
74
+ for (let seen = 0; line < lines.length && seen < 200; seen++) {
75
+ const remaining = lines[line].slice(column);
76
+ const points = Array.from(remaining);
77
+ const prefix = `${line}: `;
78
+ if (countModelTokens(header + body + prefix + '\n' + 'x'.repeat(180)) > budget)
79
+ break;
80
+ let low = 0;
81
+ let high = points.length;
82
+ while (low < high) {
83
+ const mid = Math.ceil((low + high) / 2);
84
+ if (countModelTokens(header + body + prefix + points.slice(0, mid).join('') + '\n' + 'x'.repeat(180)) <= budget)
85
+ low = mid;
86
+ else
87
+ high = mid - 1;
88
+ }
89
+ if (low === 0 && points.length > 0)
90
+ break;
91
+ const part = points.slice(0, low).join('');
92
+ body += prefix + part + '\n';
93
+ lastLine = line;
94
+ column += part.length;
95
+ if (column < lines[line].length)
96
+ break;
97
+ line++;
98
+ column = 0;
99
+ }
100
+ const hasMore = line < lines.length;
101
+ const nextCursor = hasMore ? `${line}:${column}` : undefined;
102
+ const footer = `Lines: ${startLine}-${lastLine} / ${lines.length}\nsourceTruncated: ${entry.sourceTruncated}\nhasMore: ${hasMore}${nextCursor ? `\nnextCursor: ${nextCursor}` : ''}`;
103
+ return {
104
+ content: header + body + footer,
105
+ data: {
106
+ ref,
107
+ url: entry.url,
108
+ title: entry.title,
109
+ status: entry.status,
110
+ contentType: entry.contentType,
111
+ format: /^text\/html\b/iu.test(entry.contentType) ? 'markdown' : 'text',
112
+ totalLines: lines.length,
113
+ startLine,
114
+ endLine: lastLine,
115
+ hasMore,
116
+ ...(nextCursor ? { nextCursor } : {}),
117
+ sourceTruncated: entry.sourceTruncated
118
+ }
119
+ };
120
+ }
121
+ find(scope, ref, pattern, maxTokens = DEFAULT_BUDGET) {
122
+ const entry = this.#get(scope, ref);
123
+ const needle = pattern.toLowerCase();
124
+ if (!needle)
125
+ throw new AgentError('MAR_AGENT_TOOL_INPUT_INVALID', 'Find pattern must not be empty.');
126
+ const matchLines = [];
127
+ const lines = entry.content.split('\n');
128
+ for (let i = 0; i < lines.length; i++)
129
+ if (lines[i].toLowerCase().includes(needle))
130
+ matchLines.push(i);
131
+ if (!matchLines.length)
132
+ return {
133
+ content: `Ref: ${ref}\nNo matches in the retained snapshot.\nsourceTruncated: ${entry.sourceTruncated}`,
134
+ data: { ref, matchLines: [], sourceTruncated: entry.sourceTruncated, partial: false }
135
+ };
136
+ const first = matchLines[0];
137
+ const matchOffset = lines[first].toLowerCase().indexOf(needle);
138
+ let originalOffset = 0;
139
+ let foldedOffset = 0;
140
+ for (const point of lines[first]) {
141
+ const next = foldedOffset + point.toLowerCase().length;
142
+ if (next > matchOffset)
143
+ break;
144
+ foldedOffset = next;
145
+ originalOffset += point.length;
146
+ }
147
+ let column = Math.max(0, originalOffset - 80);
148
+ if (column > 0 && /[\uDC00-\uDFFF]/u.test(lines[first][column]))
149
+ column--;
150
+ const shown = matchLines.slice(0, 40);
151
+ const partial = shown.length < matchLines.length;
152
+ const prefix = `Matches${partial ? ' (partial)' : ''}: ${shown.join(', ')}\n`;
153
+ const window = this.open(scope, ref, {
154
+ cursor: `${first}:${column}`,
155
+ maxTokens: maxTokens - countModelTokens(prefix) - 1
156
+ });
157
+ return {
158
+ content: prefix + window.content,
159
+ data: { ...window.data, matchLines: shown, partial }
160
+ };
161
+ }
162
+ #get(scope, ref) {
163
+ const entry = this.#entries.get(ref);
164
+ if (!entry || entry.scope !== scope)
165
+ throw unavailable();
166
+ this.#entries.delete(ref);
167
+ this.#entries.set(ref, entry);
168
+ return entry;
169
+ }
170
+ #delete(ref) {
171
+ const entry = this.#entries.get(ref);
172
+ if (entry)
173
+ this.#bytes -= entry.size;
174
+ this.#entries.delete(ref);
175
+ }
176
+ }
177
+ function short(value, maxBytes) {
178
+ if (Buffer.byteLength(value) <= maxBytes)
179
+ return value;
180
+ const bytes = Buffer.from(value);
181
+ return `${new TextDecoder().decode(bytes.subarray(0, maxBytes - 3), { stream: true })}…`;
182
+ }
183
+ function unavailable() {
184
+ return new AgentError('MAR_AGENT_WEB_SNAPSHOT_UNAVAILABLE', 'Page reference is unavailable. Open the URL again.');
185
+ }
186
+ function invalidCursor() {
187
+ return new AgentError('MAR_AGENT_TOOL_INPUT_INVALID', 'Page position is invalid.');
188
+ }
@@ -1,12 +1,17 @@
1
- import { AgentError } from '@myagentroam/agent';
1
+ import { AgentError, countModelTokens } from '@myagentroam/agent';
2
+ import { z } from 'zod';
2
3
  import { readWebSearchError } from './web-search-error.js';
3
4
  import { WebFetchService } from './web-fetch-service.js';
4
- import { webFetchResultSchema } from './web-fetch-contracts.js';
5
+ import { browserFetchResultSchema } from './web-fetch-contracts.js';
6
+ import { WebSnapshots } from './web-snapshots.js';
5
7
  /** Owns Node web reads and the authenticated Server search adapter. */
6
8
  export class WebToolService {
7
9
  #config;
8
10
  #request;
9
11
  #fetcher;
12
+ #snapshots = new WebSnapshots();
13
+ #pending = new Map();
14
+ #closed = false;
10
15
  constructor(config, request = fetch) {
11
16
  this.#config = config;
12
17
  this.#request = request;
@@ -15,19 +20,80 @@ export class WebToolService {
15
20
  webSearch(input, signal) {
16
21
  return this.#execute(input, signal);
17
22
  }
18
- webFetch = async (input, signal, allowUntrustedTls) => {
19
- const result = await this.#fetcher.fetch(input, signal, {
20
- ...(allowUntrustedTls === undefined ? {} : { allowUntrustedTls })
21
- });
22
- const parsed = webFetchResultSchema.safeParse(result);
23
+ webFetch = async (input, signal, scope, allowUntrustedTls) => {
24
+ signal.throwIfAborted();
25
+ if (this.#closed)
26
+ throw new AgentError('MAR_AGENT_WEB_FETCH_CANCELLED', 'Web service is closed.');
27
+ if (input.action === 'find')
28
+ return this.#snapshots.find(scope, input.ref, input.pattern, input.maxTokens);
29
+ if ('ref' in input)
30
+ return this.#snapshots.open(scope, input.ref, {
31
+ maxTokens: input.maxTokens,
32
+ ...(input.line === undefined ? {} : { line: input.line }),
33
+ ...(input.cursor === undefined ? {} : { cursor: input.cursor })
34
+ });
35
+ const controller = new AbortController();
36
+ const pending = this.#pending.get(scope) ?? new Set();
37
+ this.#pending.set(scope, pending);
38
+ pending.add(controller);
39
+ const combined = AbortSignal.any([signal, controller.signal]);
40
+ let result;
41
+ try {
42
+ result = await this.#fetcher.fetch({ url: input.url, mode: 'browser' }, combined, {
43
+ ...(allowUntrustedTls === undefined ? {} : { allowUntrustedTls })
44
+ });
45
+ combined.throwIfAborted();
46
+ }
47
+ finally {
48
+ pending.delete(controller);
49
+ if (pending.size === 0)
50
+ this.#pending.delete(scope);
51
+ }
52
+ const parsed = browserFetchResultSchema.safeParse(result);
23
53
  if (!parsed.success)
24
54
  throw new AgentError('MAR_AGENT_WEB_FETCH_FAILED', 'Web fetch returned an invalid result.');
25
- return parsed.data;
55
+ signal.throwIfAborted();
56
+ if (this.#closed)
57
+ throw new AgentError('MAR_AGENT_WEB_FETCH_CANCELLED', 'Web service is closed.');
58
+ const { content, data, truncated } = parsed.data;
59
+ const ref = this.#snapshots.add(scope, {
60
+ content,
61
+ url: data.url,
62
+ status: data.status,
63
+ contentType: data.contentType,
64
+ ...(data.title === undefined ? {} : { title: data.title }),
65
+ sourceTruncated: truncated
66
+ });
67
+ return this.#snapshots.open(scope, ref, { maxTokens: input.maxTokens });
26
68
  };
27
- close() {
28
- return this.#fetcher.close();
69
+ clearSession(scope) {
70
+ for (const controller of this.#pending.get(scope) ?? [])
71
+ controller.abort();
72
+ this.#snapshots.clearScope(scope);
73
+ }
74
+ clearHostedStorage(storage) {
75
+ for (const [scope, controllers] of this.#pending) {
76
+ try {
77
+ const parts = JSON.parse(scope);
78
+ if (Array.isArray(parts) && parts[0] === 'hosted' && parts[1] === storage)
79
+ for (const controller of controllers)
80
+ controller.abort();
81
+ }
82
+ catch {
83
+ /* Ignore unrelated scopes. */
84
+ }
85
+ }
86
+ this.#snapshots.clearHostedStorage(storage);
87
+ }
88
+ async close() {
89
+ this.#closed = true;
90
+ await this.#fetcher.close();
91
+ this.#snapshots.clear();
29
92
  }
30
93
  async #execute(input, signal) {
94
+ signal.throwIfAborted();
95
+ if (this.#closed)
96
+ throw new AgentError('MAR_AGENT_WEB_SEARCH_FAILED', 'Web service is closed.');
31
97
  const config = this.#config();
32
98
  if (config?.nodeId === undefined || config.credential === undefined)
33
99
  throw new Error('NODE_UNAUTHENTICATED');
@@ -39,7 +105,7 @@ export class WebToolService {
39
105
  'x-mar-node-id': config.nodeId,
40
106
  authorization: `Bearer ${config.credential}`
41
107
  },
42
- body: JSON.stringify(input),
108
+ body: JSON.stringify({ query: input.query, maxResults: input.maxResults }),
43
109
  signal
44
110
  });
45
111
  if (!response.ok)
@@ -51,12 +117,55 @@ export class WebToolService {
51
117
  catch {
52
118
  throw invalidResult();
53
119
  }
54
- if (result === null || typeof result !== 'object')
120
+ const parsed = z
121
+ .object({
122
+ query: z.string().max(500),
123
+ results: z
124
+ .array(z.object({ title: z.string(), url: z.string().max(8192), snippet: z.string() }))
125
+ .max(10)
126
+ })
127
+ .safeParse(result);
128
+ if (!parsed.success)
55
129
  throw invalidResult();
56
- const content = JSON.stringify(result);
57
- if (content === undefined)
130
+ const budget = input.maxTokens ?? 2000;
131
+ let snippetLimit = 2000;
132
+ let metadataLimit = 8192;
133
+ let results = parsed.data.results;
134
+ let content = '';
135
+ let truncated = false;
136
+ do {
137
+ results = parsed.data.results.map((item) => ({
138
+ title: clip(item.title, metadataLimit),
139
+ url: clip(item.url, metadataLimit),
140
+ snippet: clip(item.snippet, snippetLimit)
141
+ }));
142
+ truncated = results.some((item, i) => item.title !== parsed.data.results[i].title ||
143
+ item.url !== parsed.data.results[i].url ||
144
+ item.snippet !== parsed.data.results[i].snippet);
145
+ content =
146
+ `Search results${truncated ? ' (truncated)' : ''}:\n` +
147
+ results
148
+ .map((item, i) => `${i + 1}. ${item.title}\nURL: ${item.url}\nSearch snippet: ${item.snippet}`)
149
+ .join('\n');
150
+ if (snippetLimit > 0)
151
+ snippetLimit = snippetLimit <= 32 ? 0 : Math.floor(snippetLimit / 2);
152
+ else
153
+ metadataLimit = Math.floor(metadataLimit / 2);
154
+ } while (countModelTokens(content) > budget && metadataLimit >= 32);
155
+ if (countModelTokens(content) > budget)
58
156
  throw invalidResult();
59
- return { content, data: result };
157
+ return {
158
+ content,
159
+ data: {
160
+ query: parsed.data.query,
161
+ results: results.map((item, index) => ({
162
+ ...item,
163
+ url: parsed.data.results[index].url
164
+ })),
165
+ truncated
166
+ },
167
+ truncated
168
+ };
60
169
  }
61
170
  catch (error) {
62
171
  if (error instanceof AgentError || signal.aborted)
@@ -67,6 +176,14 @@ export class WebToolService {
67
176
  }
68
177
  }
69
178
  }
179
+ function clip(value, maxBytes) {
180
+ if (maxBytes <= 0)
181
+ return value ? '…' : '';
182
+ const bytes = Buffer.from(value);
183
+ return bytes.length <= maxBytes
184
+ ? value
185
+ : `${new TextDecoder().decode(bytes.subarray(0, maxBytes - 3), { stream: true })}…`;
186
+ }
70
187
  function invalidResult() {
71
188
  return new AgentError('MAR_AGENT_WEB_SEARCH_FAILED', 'Web search returned an invalid result.');
72
189
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.11.61",
3
+ "version": "0.11.63",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "license": "Apache-2.0 AND GPL-2.0-or-later",
6
6
  "type": "module",
@@ -36,14 +36,17 @@
36
36
  "npm": "11.12.1",
37
37
  "playwright": "1.62.1",
38
38
  "sharp": "0.35.5",
39
+ "turndown": "^7.2.4",
40
+ "turndown-plugin-gfm": "^1.0.2",
39
41
  "ws": "^8.21.3",
40
42
  "zod": "4.4.3",
41
- "@myagentroam/agent": "0.11.61",
43
+ "@myagentroam/agent": "0.11.63",
42
44
  "@myagentroam/code-navigation": "0.11.1",
43
- "@myagentroam/protocol": "0.11.61",
44
- "@myagentroam/marctl": "0.11.11"
45
+ "@myagentroam/marctl": "0.11.11",
46
+ "@myagentroam/protocol": "0.11.63"
45
47
  },
46
48
  "devDependencies": {
49
+ "@types/turndown": "^5.0.6",
47
50
  "@types/ws": "^8.18.1"
48
51
  },
49
52
  "exports": {