testronaut 1.3.1 → 1.3.2

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.
@@ -41,7 +41,7 @@ function appendFileEvent(eventObj = {}) {
41
41
  }
42
42
 
43
43
  export class ChromeBrowser {
44
- constructor() {
44
+ constructor(options = {}) {
45
45
  this.browser = null;
46
46
  this.context = null;
47
47
  this.page = null;
@@ -49,6 +49,32 @@ export class ChromeBrowser {
49
49
  // simple registry
50
50
  this.pages = []; // [{ id, page }]
51
51
  this.currentId = ''; // page id we currently control
52
+
53
+ this.domListLimit = options.domListLimit;
54
+ this.debugEnabled = !!options.debug;
55
+ this._domLimitLogged = false;
56
+
57
+ const defaultResourceGuard = {
58
+ enabled: true,
59
+ hrefIncludes: ['/document/', '/file/', '/download', '/attachment/'],
60
+ dataTypes: ['document', 'file', 'item', 'row'],
61
+ };
62
+ const guardCfg = options.resourceGuard || defaultResourceGuard;
63
+ this.resourceGuard = {
64
+ enabled: guardCfg.enabled !== false,
65
+ hrefIncludes: Array.isArray(guardCfg.hrefIncludes)
66
+ ? guardCfg.hrefIncludes.map(s => s.toLowerCase())
67
+ : defaultResourceGuard.hrefIncludes,
68
+ dataTypes: Array.isArray(guardCfg.dataTypes)
69
+ ? guardCfg.dataTypes.map(s => s.toLowerCase())
70
+ : defaultResourceGuard.dataTypes,
71
+ };
72
+ }
73
+
74
+ _debugLog(...args) {
75
+ if (this.debugEnabled) {
76
+ console.log('[debug]', ...args);
77
+ }
52
78
  }
53
79
 
54
80
  _makePageId(page) {
@@ -791,6 +817,57 @@ export class ChromeBrowser {
791
817
  { limit = 100000, exclude = true, focus = [], loadState = 'networkidle', timeout = 10000 } = {},
792
818
  agentMemory = {}
793
819
  ) {
820
+ const matchesHrefPattern = (href = '') => {
821
+ const lower = (href || '').toLowerCase();
822
+ return this.resourceGuard.hrefIncludes.some(p => lower.includes(p));
823
+ };
824
+
825
+ const extractInitialDocumentList = (rawHtml) => {
826
+ const key = '"initialDocumentList":';
827
+ const idx = rawHtml.indexOf(key);
828
+ if (idx < 0) return [];
829
+ const start = rawHtml.indexOf('[', idx);
830
+ if (start < 0) return [];
831
+ let depth = 0;
832
+ let end = -1;
833
+ for (let i = start; i < rawHtml.length; i++) {
834
+ const ch = rawHtml[i];
835
+ if (ch === '[') depth++;
836
+ else if (ch === ']') depth--;
837
+ if (depth === 0) {
838
+ end = i;
839
+ break;
840
+ }
841
+ }
842
+ if (end === -1) return [];
843
+ const jsonSlice = rawHtml.slice(start, end + 1);
844
+ try {
845
+ const arr = JSON.parse(jsonSlice);
846
+ return Array.isArray(arr) ? arr : [];
847
+ } catch {
848
+ return [];
849
+ }
850
+ };
851
+
852
+ const normalizeListLimit = () => {
853
+ if (this.domListLimit === Infinity) return Infinity;
854
+ const asNum = Number(this.domListLimit);
855
+ if (Number.isFinite(asNum)) {
856
+ return Math.min(100, Math.max(0, asNum));
857
+ }
858
+ return 3; // current default behavior (keep first 3 matching items)
859
+ };
860
+ const listLimit = normalizeListLimit();
861
+ if (this.debugEnabled && !this._domLimitLogged) {
862
+ const pretty = listLimit === Infinity ? 'all' : listLimit;
863
+ this._debugLog(`DOM list limit resolved`, { raw: this.domListLimit, applied: pretty });
864
+ this._domLimitLogged = true;
865
+ }
866
+ if (this.debugEnabled) {
867
+ const pretty = listLimit === Infinity ? 'all' : listLimit;
868
+ this._debugLog(`get_dom invoked`, { limit, exclude, focus, listLimit: pretty });
869
+ }
870
+
794
871
  // --- 1) Ensure we have a live page ---
795
872
  const hasContext = !!this.context;
796
873
 
@@ -865,7 +942,7 @@ export class ChromeBrowser {
865
942
 
866
943
  if (exclude) {
867
944
  $ = await removeNonContentTags($);
868
- $ = await reduceRepeatedDivs($);
945
+ $ = await reduceRepeatedDivs($, listLimit);
869
946
  // If you later re-enable:
870
947
  // $ = await removeObfuscatedClassNames($);
871
948
  // $ = await removeAutoGeneratedClasses($);
@@ -874,6 +951,100 @@ export class ChromeBrowser {
874
951
  // Minify whitespace *after* structural cleanup
875
952
  $ = minifyDomWhitespace($);
876
953
 
954
+ if (this.debugEnabled) {
955
+ const liCount = $('li').length;
956
+ const ulCount = $('ul').length;
957
+ const ulSizes = [];
958
+ $('ul').each((_, el) => {
959
+ ulSizes.push($(el).children('li').length);
960
+ });
961
+ const maxUlSize = ulSizes.length ? Math.max(...ulSizes) : 0;
962
+ const anchorDocs = $('a[href]').filter((_, el) => {
963
+ if (!this.resourceGuard.enabled) return false;
964
+ const href = $(el).attr('href');
965
+ return matchesHrefPattern(href);
966
+ }).map((_, el) => {
967
+ const $el = $(el);
968
+ return {
969
+ id: $el.closest('[data-id]').attr('data-id') || $el.attr('id') || '',
970
+ title: ($el.attr('data-title') || $el.text() || '').trim().slice(0, 160),
971
+ href: $el.attr('href') || '',
972
+ };
973
+ }).get();
974
+ const docCards = $('.document-list-view-documents').map((_, el) => {
975
+ const $el = $(el);
976
+ const anchor = $el.find('a[href*="/document/"]').first();
977
+ return {
978
+ id: $el.attr('data-id') || $el.attr('id') || '',
979
+ title: ($el.attr('data-title') || anchor.text() || '').trim().slice(0, 160),
980
+ href: anchor.attr('href') || '',
981
+ };
982
+ }).get();
983
+ const dataDocs = $('[data-type]').filter((_, el) => {
984
+ if (!this.resourceGuard.enabled) return false;
985
+ const val = ($(el).attr('data-type') || '').toLowerCase();
986
+ return this.resourceGuard.dataTypes.includes(val);
987
+ }).map((_, el) => {
988
+ const $el = $(el);
989
+ const href = $el.find('a[href*="/document/"]').attr('href') || '';
990
+ return {
991
+ id: $el.attr('data-id') || $el.attr('id') || '',
992
+ title: ($el.attr('data-title') || '').trim().slice(0, 160),
993
+ href,
994
+ };
995
+ }).get();
996
+ const mergedDocs = [...anchorDocs, ...dataDocs, ...docCards]
997
+ .filter(d => d.href || d.title)
998
+ .map(d => ({
999
+ id: d.id || '',
1000
+ title: d.title || '',
1001
+ href: d.href || '',
1002
+ }));
1003
+ // dedupe by href+title to avoid spam
1004
+ const seen = new Set();
1005
+ const docItems = [];
1006
+ for (const d of mergedDocs) {
1007
+ const key = `${d.href}|${d.title}`;
1008
+ if (seen.has(key)) continue;
1009
+ seen.add(key);
1010
+ docItems.push(d);
1011
+ }
1012
+ const sampleLi = $('li').slice(0, 5).map((_, el) => $(el).text().trim().slice(0, 80)).get();
1013
+ // Fallback: parse the page's embedded initialDocumentList JSON if present
1014
+ const scriptDocsRaw = extractInitialDocumentList(html);
1015
+ const scriptDocs = scriptDocsRaw.map(d => ({
1016
+ id: d.Id || d.id || '',
1017
+ title: d.Title || d.title || '',
1018
+ href: d.ContentUrl || d.ContentURL || '',
1019
+ }));
1020
+ for (const d of scriptDocs) {
1021
+ const key = `${d.href}|${d.title}`;
1022
+ if (seen.has(key)) continue;
1023
+ seen.add(key);
1024
+ docItems.push(d);
1025
+ }
1026
+ const docLinks = docItems.filter(d => d.href).length;
1027
+ this._debugLog('DOM list stats', {
1028
+ liCount,
1029
+ ulCount,
1030
+ maxUlSize,
1031
+ docLinks,
1032
+ docItems: docItems.length,
1033
+ scriptDocs: scriptDocs.length,
1034
+ anchorDocs: anchorDocs.length,
1035
+ sampleLi,
1036
+ });
1037
+
1038
+ // Provide the agent an explicit, structured list of resources to avoid stopping early.
1039
+ if (this.resourceGuard.enabled && docItems.length) {
1040
+ const summary = [
1041
+ `Testronaut document list (${docItems.length} items):`,
1042
+ ...docItems.map(d => `- [${d.id || 'n/a'}] ${d.title || ''} ${d.href || ''}`.trim()),
1043
+ ].join('\n');
1044
+ $('body').prepend(`<pre data-testronaut-doc-list="true">${summary}</pre>`);
1045
+ }
1046
+ }
1047
+
877
1048
  // (Optional: If you'd like to use `focus`, you could filter here by selectors/keywords.)
878
1049
 
879
1050
  // --- 5) Return trimmed HTML ---
@@ -1060,7 +1231,6 @@ export class ChromeBrowser {
1060
1231
  return JSON.stringify(payload);
1061
1232
  }
1062
1233
 
1063
-
1064
1234
  async download_file({ selector, url, fileName, timeoutMs = 30000 }) {
1065
1235
  ensureDir(FILES_DIR);
1066
1236
 
@@ -1139,6 +1309,30 @@ export class ChromeBrowser {
1139
1309
  return JSON.stringify(payload);
1140
1310
  }
1141
1311
 
1312
+ async list_local_files({ dir = 'missions/files', extensions = [] } = {}) {
1313
+ const resolvedDir = path.resolve(process.cwd(), dir);
1314
+ if (!fs.existsSync(resolvedDir)) {
1315
+ throw new Error(`list_local_files: directory does not exist: ${resolvedDir}`);
1316
+ }
1317
+ const exts = Array.isArray(extensions)
1318
+ ? extensions.map(e => e.toLowerCase())
1319
+ : [];
1320
+ const entries = fs.readdirSync(resolvedDir, { withFileTypes: true });
1321
+ const files = entries
1322
+ .filter(d => d.isFile())
1323
+ .map(d => d.name)
1324
+ .filter(name => {
1325
+ if (!exts.length) return true;
1326
+ const lower = name.toLowerCase();
1327
+ return exts.some(ext => lower.endsWith(ext));
1328
+ });
1329
+ return {
1330
+ dir: resolvedDir,
1331
+ count: files.length,
1332
+ files,
1333
+ };
1334
+ }
1335
+
1142
1336
 
1143
1337
  async close() {
1144
1338
  await this.browser?.close();
@@ -1166,4 +1360,17 @@ export const CHROME_TOOL_MAP = {
1166
1360
  close_current_page: (b, args) => b.close_current_page(args),
1167
1361
  upload_file: (b, args) => b.upload_file(args),
1168
1362
  download_file: (b, args) => b.download_file(args),
1169
- };
1363
+ list_local_files: (b, args) => b.list_local_files(args),
1364
+ resource_progress: (b, args, agentMemory) => {
1365
+ const prog = agentMemory?.docProgress;
1366
+ if (!prog) return JSON.stringify({ enabled: false, total: 0, downloaded: 0, remaining: [] });
1367
+ const remaining = prog.items.filter(i => !prog.downloaded.has(i.id));
1368
+ return JSON.stringify({
1369
+ enabled: true,
1370
+ total: prog.items.length || prog.lastScriptCount || 0,
1371
+ downloaded: prog.downloaded.size || 0,
1372
+ remaining: remaining.map(r => ({ id: r.id, title: r.title, href: r.href })).slice(0, 20),
1373
+ patterns: prog.patterns,
1374
+ });
1375
+ },
1376
+ };
@@ -37,7 +37,9 @@ export const removeNonContentTags = async ($) => {
37
37
  const $el = $(el);
38
38
  const allowedAttrs = [
39
39
  'href', 'src', 'type', 'alt', 'title',
40
- 'placeholder', 'name', 'id', 'class', 'aria-label', 'value'
40
+ 'placeholder', 'name', 'id', 'class', 'aria-label', 'value',
41
+ // keep key data-* attributes that help identify rows/items without bloating too much
42
+ 'data-type', 'data-id', 'data-title', 'data-parentid', 'data-order'
41
43
  ];
42
44
 
43
45
  const attrs = el.attribs;
@@ -155,6 +157,11 @@ export const chunkBySection = async ($, focus) => {
155
157
  * @returns {$} same Cheerio instance for chaining
156
158
  */
157
159
  export function reduceRepeatedDivs($, maxAllowed = 3) {
160
+ let limit = maxAllowed === Infinity ? Infinity : Number(maxAllowed);
161
+ if (limit !== Infinity) {
162
+ limit = Number.isFinite(limit) ? Math.max(0, limit) : 3;
163
+ }
164
+
158
165
  $('body, div').each((_, parentEl) => {
159
166
  const $parent = $(parentEl);
160
167
  const children = $parent.children('div');
@@ -175,9 +182,9 @@ export function reduceRepeatedDivs($, maxAllowed = 3) {
175
182
  // Reduce each group to maxAllowed
176
183
  for (const className in classGroups) {
177
184
  const group = classGroups[className];
178
- if (group.length > maxAllowed) {
185
+ if (limit !== Infinity && group.length > limit) {
179
186
  // Remove excess nodes from the DOM
180
- group.slice(maxAllowed).forEach(el => $(el).remove());
187
+ group.slice(limit).forEach(el => $(el).remove());
181
188
  $parent.append(`<div data-collapsed="true" data-class="${className}">[...truncated]</div>`);
182
189
  }
183
190
  }
@@ -1,3 +1,16 @@
1
+ /**
2
+ * toolsSchema.js
3
+ * --------------
4
+ * Canonical list of tool/function definitions exposed to the LLM.
5
+ * Keep descriptions concise but clear; they are surfaced directly in the
6
+ * function/tool-calling interface for the model.
7
+ *
8
+ * Notes:
9
+ * - When adding a new tool, include a short description and minimally
10
+ * strict parameters schema to avoid over-constraining the model.
11
+ * - Resource helper `resource_progress` is a lightweight introspection tool
12
+ * used to report remaining detected resources (documents/files/rows).
13
+ */
1
14
  const toolsSchema = [
2
15
  {
3
16
  type: 'function',
@@ -286,6 +299,35 @@ const toolsSchema = [
286
299
  }
287
300
  }
288
301
  },
302
+ {
303
+ type: 'function',
304
+ function: {
305
+ name: 'list_local_files',
306
+ description: 'List files from a local directory (defaults to missions/files). Use to discover downloaded PDFs before uploading.',
307
+ parameters: {
308
+ type: 'object',
309
+ properties: {
310
+ dir: { type: 'string', description: 'Directory relative to project root (default: missions/files)' },
311
+ extensions: {
312
+ type: 'array',
313
+ items: { type: 'string' },
314
+ description: 'Optional list of extensions to include (e.g., [".pdf"])'
315
+ }
316
+ }
317
+ }
318
+ }
319
+ },
320
+ {
321
+ type: 'function',
322
+ function: {
323
+ name: 'resource_progress',
324
+ description: 'Report remaining items from the detected resource list (documents/files/rows) to ensure full coverage.',
325
+ parameters: {
326
+ type: 'object',
327
+ properties: {},
328
+ },
329
+ },
330
+ },
289
331
  {
290
332
  type: 'function',
291
333
  function: {
@@ -389,4 +431,4 @@ const toolsSchema = [
389
431
  }
390
432
  ];
391
433
 
392
- export default toolsSchema;
434
+ export default toolsSchema;