explorbot 0.4.10 → 0.5.1

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.
Files changed (148) hide show
  1. package/README.md +4 -1
  2. package/bin/explorbot-cli.ts +2 -1
  3. package/bin/mdq.ts +18 -0
  4. package/boat/api-tester/src/ai/chief.ts +72 -0
  5. package/boat/api-tester/src/api-client.ts +37 -0
  6. package/boat/prima/README.md +31 -4
  7. package/boat/prima/bin/prima-cli.ts +2 -1
  8. package/boat/prima/src/prima.ts +41 -2
  9. package/dist/bin/explorbot-cli.js +2 -1
  10. package/dist/bin/mdq.js +19 -0
  11. package/dist/boat/api-tester/src/ai/chief.js +69 -0
  12. package/dist/boat/api-tester/src/api-client.js +26 -0
  13. package/dist/boat/prima/bin/prima-cli.js +2 -1
  14. package/dist/boat/prima/src/prima.js +42 -2
  15. package/dist/package.json +4 -3
  16. package/dist/src/ai/agent.d.ts +3 -1
  17. package/dist/src/ai/judge-provider.d.ts +17 -0
  18. package/dist/src/ai/judge-provider.js +56 -0
  19. package/dist/src/ai/judge-tool.d.ts +2 -0
  20. package/dist/src/ai/judge-tool.js +33 -0
  21. package/dist/src/ai/judge.d.ts +28 -0
  22. package/dist/src/ai/judge.js +71 -0
  23. package/dist/src/ai/navigator.d.ts +3 -0
  24. package/dist/src/ai/navigator.js +13 -4
  25. package/dist/src/ai/pilot.d.ts +4 -0
  26. package/dist/src/ai/pilot.js +57 -5
  27. package/dist/src/ai/planner.js +9 -6
  28. package/dist/src/ai/provider.d.ts +4 -1
  29. package/dist/src/ai/provider.js +53 -8
  30. package/dist/src/ai/researcher/deep-analysis.js +2 -2
  31. package/dist/src/ai/researcher/locators.js +2 -2
  32. package/dist/src/ai/researcher/pagination.js +1 -1
  33. package/dist/src/ai/researcher/research-result.js +2 -2
  34. package/dist/src/ai/researcher.js +1 -1
  35. package/dist/src/ai/rules.js +13 -0
  36. package/dist/src/ai/task-agent.d.ts +2 -0
  37. package/dist/src/ai/task-agent.js +3 -1
  38. package/dist/src/ai/tester.js +19 -15
  39. package/dist/src/ai/tools.d.ts +4 -3
  40. package/dist/src/ai/tools.js +35 -7
  41. package/dist/src/api/request-result.js +2 -1
  42. package/dist/src/command-handler.d.ts +1 -0
  43. package/dist/src/command-handler.js +24 -3
  44. package/dist/src/commands/base-command.d.ts +5 -0
  45. package/dist/src/commands/base-command.js +3 -0
  46. package/dist/src/commands/explore-command.d.ts +2 -1
  47. package/dist/src/commands/explore-command.js +12 -1
  48. package/dist/src/commands/freesail-command.js +8 -2
  49. package/dist/src/commands/init-command.js +1 -1
  50. package/dist/src/commands/navigate-command.d.ts +2 -1
  51. package/dist/src/commands/navigate-command.js +6 -0
  52. package/dist/src/commands/options/decision-model-option.d.ts +6 -0
  53. package/dist/src/commands/options/decision-model-option.js +10 -0
  54. package/dist/src/commands/options/index.d.ts +2 -0
  55. package/dist/src/commands/options/index.js +2 -0
  56. package/dist/src/commands/plan-load-command.d.ts +2 -1
  57. package/dist/src/commands/plan-load-command.js +4 -0
  58. package/dist/src/commands/plans-command.d.ts +3 -9
  59. package/dist/src/commands/plans-command.js +11 -21
  60. package/dist/src/commands/rerun-command.d.ts +2 -1
  61. package/dist/src/commands/rerun-command.js +5 -1
  62. package/dist/src/commands/research-command.d.ts +2 -1
  63. package/dist/src/commands/research-command.js +6 -0
  64. package/dist/src/commands/test-command.d.ts +2 -1
  65. package/dist/src/commands/test-command.js +4 -1
  66. package/dist/src/components/Autocomplete.js +26 -12
  67. package/dist/src/components/InputReadline.js +10 -1
  68. package/dist/src/config.d.ts +7 -0
  69. package/dist/src/config.js +11 -0
  70. package/dist/src/experience-tracker.js +4 -3
  71. package/dist/src/explorbot.d.ts +3 -0
  72. package/dist/src/explorbot.js +8 -0
  73. package/dist/src/knowledge-tracker.js +1 -1
  74. package/dist/src/state-manager.d.ts +2 -0
  75. package/dist/src/state-manager.js +16 -0
  76. package/dist/src/test-plan.d.ts +11 -0
  77. package/dist/src/test-plan.js +54 -2
  78. package/dist/src/utils/aria-ref.js +1 -1
  79. package/dist/src/utils/logger.js +8 -2
  80. package/dist/src/utils/markdown-query.d.ts +1 -48
  81. package/dist/src/utils/markdown-query.js +1 -444
  82. package/dist/src/utils/mdq/cli.d.ts +6 -0
  83. package/dist/src/utils/mdq/cli.js +122 -0
  84. package/dist/src/utils/mdq/edit.d.ts +24 -0
  85. package/dist/src/utils/mdq/edit.js +147 -0
  86. package/dist/src/utils/mdq/query.d.ts +118 -0
  87. package/dist/src/utils/mdq/query.js +451 -0
  88. package/dist/src/utils/strings.d.ts +1 -0
  89. package/dist/src/utils/strings.js +7 -0
  90. package/dist/src/utils/test-files.d.ts +1 -0
  91. package/dist/src/utils/test-files.js +5 -2
  92. package/docs/api-testing/planning.md +1 -1
  93. package/docs/basics/providers.md +37 -0
  94. package/docs/index.json +1 -0
  95. package/docs/reference/commands.md +1 -0
  96. package/docs/reference/configuration.md +2 -0
  97. package/docs/superpowers/plans/2026-09-15-mdq-package.md +130 -94
  98. package/docs/superpowers/specs/2026-09-18-judge-decision-model-design.md +79 -0
  99. package/docs/web-testing/decisions.md +108 -0
  100. package/docs/workflow/agentic-usage.md +1 -0
  101. package/package.json +4 -3
  102. package/src/ai/agent.ts +3 -1
  103. package/src/ai/judge-provider.ts +62 -0
  104. package/src/ai/judge-tool.ts +35 -0
  105. package/src/ai/judge.ts +75 -0
  106. package/src/ai/navigator.ts +15 -4
  107. package/src/ai/pilot.ts +58 -5
  108. package/src/ai/planner.ts +9 -6
  109. package/src/ai/provider.ts +52 -8
  110. package/src/ai/researcher/deep-analysis.ts +2 -2
  111. package/src/ai/researcher/locators.ts +2 -2
  112. package/src/ai/researcher/pagination.ts +1 -1
  113. package/src/ai/researcher/research-result.ts +2 -2
  114. package/src/ai/researcher.ts +1 -1
  115. package/src/ai/rules.ts +13 -0
  116. package/src/ai/task-agent.ts +4 -1
  117. package/src/ai/tester.ts +19 -16
  118. package/src/ai/tools.ts +42 -7
  119. package/src/api/request-result.ts +2 -1
  120. package/src/command-handler.ts +28 -3
  121. package/src/commands/base-command.ts +9 -0
  122. package/src/commands/explore-command.ts +15 -2
  123. package/src/commands/freesail-command.ts +9 -2
  124. package/src/commands/init-command.ts +1 -1
  125. package/src/commands/navigate-command.ts +8 -1
  126. package/src/commands/options/decision-model-option.ts +11 -0
  127. package/src/commands/options/index.ts +2 -0
  128. package/src/commands/plan-load-command.ts +6 -1
  129. package/src/commands/plans-command.ts +13 -29
  130. package/src/commands/rerun-command.ts +7 -2
  131. package/src/commands/research-command.ts +8 -1
  132. package/src/commands/test-command.ts +6 -2
  133. package/src/components/Autocomplete.tsx +39 -10
  134. package/src/components/InputReadline.tsx +10 -1
  135. package/src/config.ts +11 -0
  136. package/src/experience-tracker.ts +4 -3
  137. package/src/explorbot.ts +8 -0
  138. package/src/knowledge-tracker.ts +1 -1
  139. package/src/state-manager.ts +16 -0
  140. package/src/test-plan.ts +67 -2
  141. package/src/utils/aria-ref.ts +1 -1
  142. package/src/utils/logger.ts +6 -1
  143. package/src/utils/markdown-query.ts +1 -519
  144. package/src/utils/mdq/cli.ts +118 -0
  145. package/src/utils/mdq/edit.ts +158 -0
  146. package/src/utils/mdq/query.ts +556 -0
  147. package/src/utils/strings.ts +7 -0
  148. package/src/utils/test-files.ts +5 -2
@@ -0,0 +1,451 @@
1
+ import { marked } from 'marked';
2
+ import { MarkdownEditor } from "./edit.js";
3
+ export { MarkdownEditor };
4
+ export class MarkdownQuery {
5
+ source;
6
+ matches;
7
+ constructor(source, matches) {
8
+ this.source = source;
9
+ this.matches = matches || buildTokenIndex(source);
10
+ }
11
+ query(selector, matcher) {
12
+ const segments = parseQuery(selector);
13
+ if (matcher !== undefined && segments.length > 0)
14
+ segments[segments.length - 1].textMatch = this.valueMatcher(matcher);
15
+ return new MarkdownQuery(this.source, this.run(this.expandSections(this.matches), segments));
16
+ }
17
+ section(matcher, options) {
18
+ return this.query(`section${options?.depth || ''}`, matcher);
19
+ }
20
+ heading(matcher, options) {
21
+ if (options?.depth)
22
+ return this.query(`h${options.depth}`, matcher);
23
+ return this.query('heading', matcher);
24
+ }
25
+ paragraph(matcher) {
26
+ return this.query('paragraph', matcher);
27
+ }
28
+ table(matcher) {
29
+ return this.query('table', matcher);
30
+ }
31
+ list(matcher) {
32
+ return this.query('list', matcher);
33
+ }
34
+ item(matcher) {
35
+ return this.query('item', matcher);
36
+ }
37
+ code(matcher) {
38
+ return this.query('code', matcher);
39
+ }
40
+ blockquote(matcher) {
41
+ return this.query('blockquote', matcher);
42
+ }
43
+ comment(matcher) {
44
+ return this.query('comment', matcher);
45
+ }
46
+ html(matcher) {
47
+ return this.query('html', matcher);
48
+ }
49
+ hr() {
50
+ return this.query('hr');
51
+ }
52
+ text() {
53
+ return this.matches.map((range) => this.source.slice(range.start, range.start + range.length)).join('');
54
+ }
55
+ nodes() {
56
+ return this.matches.map((range) => {
57
+ const token = range.token;
58
+ if (token.type !== 'heading')
59
+ return { type: token.type, depth: null, text: this.tokenText(range.token) };
60
+ return { type: token.type, depth: token.depth, text: this.tokenText(range.token) };
61
+ });
62
+ }
63
+ rows() {
64
+ const results = [];
65
+ for (const range of this.matches) {
66
+ if (range.token.type !== 'table')
67
+ continue;
68
+ const table = range.token;
69
+ const headers = table.header.map((cell) => cell.text);
70
+ for (const row of table.rows) {
71
+ const entry = {};
72
+ for (let i = 0; i < headers.length; i++) {
73
+ entry[headers[i]] = row[i]?.text ?? '';
74
+ }
75
+ results.push(entry);
76
+ }
77
+ }
78
+ return results;
79
+ }
80
+ entries() {
81
+ const entries = {};
82
+ for (const range of this.matches) {
83
+ for (const line of this.tokenText(range.token).split('\n')) {
84
+ const separator = line.indexOf(':');
85
+ if (separator < 1)
86
+ continue;
87
+ const value = line.slice(separator + 1).trim();
88
+ if (value)
89
+ entries[line.slice(0, separator).trim().toLowerCase()] = value;
90
+ }
91
+ }
92
+ return entries;
93
+ }
94
+ frontmatter() {
95
+ return this.editor().frontmatter();
96
+ }
97
+ count() {
98
+ return this.matches.length;
99
+ }
100
+ exists() {
101
+ return this.matches.length > 0;
102
+ }
103
+ at(index) {
104
+ let resolved = index;
105
+ if (resolved < 0)
106
+ resolved = this.matches.length + resolved;
107
+ if (resolved < 0 || resolved >= this.matches.length)
108
+ return new MarkdownQuery(this.source, []);
109
+ return new MarkdownQuery(this.source, [this.matches[resolved]]);
110
+ }
111
+ slice(from, to) {
112
+ return new MarkdownQuery(this.source, this.matches.slice(from, to));
113
+ }
114
+ first() {
115
+ return this.slice(0, 1);
116
+ }
117
+ last() {
118
+ return this.slice(-1);
119
+ }
120
+ each() {
121
+ return this.matches.map((range) => new MarkdownQuery(this.source, [range]));
122
+ }
123
+ preceding() {
124
+ if (this.matches.length === 0)
125
+ return new MarkdownQuery(this.source, []);
126
+ const cutoff = this.matches[0].start;
127
+ return new MarkdownQuery(this.source, buildTokenIndex(this.source).filter((range) => range.start + range.length <= cutoff));
128
+ }
129
+ following() {
130
+ if (this.matches.length === 0)
131
+ return new MarkdownQuery(this.source, []);
132
+ const last = this.matches[this.matches.length - 1];
133
+ const cutoff = last.start + last.length;
134
+ return new MarkdownQuery(this.source, buildTokenIndex(this.source).filter((range) => range.start >= cutoff));
135
+ }
136
+ replace(content) {
137
+ return this.replaceEach(() => content);
138
+ }
139
+ replaceEach(replacer) {
140
+ return new MarkdownQuery(this.editor().replace(this.matches, (range, index) => String(replacer(new MarkdownQuery(this.source, [range]), index))));
141
+ }
142
+ remove() {
143
+ return new MarkdownQuery(this.editor().remove(this.matches));
144
+ }
145
+ insertBefore(markdown) {
146
+ return this.insertAll((range) => range.start, markdown);
147
+ }
148
+ insertAfter(markdown) {
149
+ return this.insertAll((range) => MarkdownEditor.blockEnd(range), markdown);
150
+ }
151
+ prepend(markdown) {
152
+ return this.insertAll((range) => this.containerStart(range), markdown);
153
+ }
154
+ append(markdown) {
155
+ return this.insertAll((range) => this.containerEnd(range), markdown);
156
+ }
157
+ addRow(row) {
158
+ return new MarkdownQuery(this.editor().replace(this.matches, (range) => {
159
+ if (range.token.type !== 'table')
160
+ throw new MdqOperationError(`addRow needs a table, got ${range.token.type}`);
161
+ const table = range.token;
162
+ const headers = table.header.map((cell) => cell.text);
163
+ const existing = table.rows.map((cells) => headers.map((_, index) => cells[index]?.text || ''));
164
+ return MarkdownEditor.table(headers, [...existing, headers.map((header) => row[header] || '')], table.align);
165
+ }));
166
+ }
167
+ addItem(text) {
168
+ return new MarkdownQuery(this.editor().replace(this.matches, (range) => {
169
+ if (range.token.type !== 'list')
170
+ throw new MdqOperationError(`addItem needs a list, got ${range.token.type}`);
171
+ const raw = (range.token.raw || '').trimEnd();
172
+ return `${raw}\n${MarkdownEditor.item(raw, text)}\n`;
173
+ }));
174
+ }
175
+ setEntry(key, value) {
176
+ return this.replaceEach((match) => {
177
+ const token = match.matches[0].token;
178
+ return MarkdownEditor.entries(this.tokenText(token), key, value, token.type === 'blockquote');
179
+ });
180
+ }
181
+ setFrontmatter(key, value) {
182
+ return new MarkdownQuery(this.editor().setFrontmatter(key, value));
183
+ }
184
+ toString() {
185
+ return this.source;
186
+ }
187
+ valueOf() {
188
+ return this.source;
189
+ }
190
+ /** Compatibility aliases. Each delegates to the canonical name above; prefer those. */
191
+ get() {
192
+ return this.text();
193
+ }
194
+ meta() {
195
+ return this.nodes();
196
+ }
197
+ toJson() {
198
+ return this.rows();
199
+ }
200
+ keyValue() {
201
+ return this.entries();
202
+ }
203
+ setKeyValue(key, value) {
204
+ return this.setEntry(key, value);
205
+ }
206
+ before() {
207
+ return this.preceding();
208
+ }
209
+ after() {
210
+ return this.following();
211
+ }
212
+ editor() {
213
+ return new MarkdownEditor(this.source);
214
+ }
215
+ run(candidates, segments) {
216
+ if (segments.length === 0)
217
+ return candidates;
218
+ const segment = segments[0];
219
+ const remaining = segments.slice(1);
220
+ if (/^section[1-6]?$/.test(segment.selector)) {
221
+ const sections = this.narrow(this.sectionsOf(candidates, segment), segment);
222
+ if (remaining.length === 0)
223
+ return sections;
224
+ const results = [];
225
+ for (const section of sections)
226
+ results.push(...this.run(section.innerTokens || [], remaining));
227
+ return results;
228
+ }
229
+ if (segment.selector === 'comment') {
230
+ const comments = candidates.filter((range) => this.isComment(range.token));
231
+ return this.run(this.narrow(this.filterByText(comments, segment), segment), remaining);
232
+ }
233
+ if (segment.selector === 'item') {
234
+ return this.run(this.narrow(this.filterByText(this.itemsOf(candidates), segment), segment), remaining);
235
+ }
236
+ const depth = segment.selector.match(/^h([1-6])$/);
237
+ let type = TOKEN_ALIASES[segment.selector] || segment.selector;
238
+ if (depth)
239
+ type = 'heading';
240
+ let matches = candidates.filter((range) => range.token.type === type);
241
+ if (depth)
242
+ matches = matches.filter((range) => range.token.depth === Number.parseInt(depth[1], 10));
243
+ return this.run(this.narrow(this.filterByText(matches, segment), segment), remaining);
244
+ }
245
+ sectionsOf(candidates, segment) {
246
+ const wanted = segment.selector.match(/^section([1-6])$/);
247
+ const sections = [];
248
+ for (let i = 0; i < candidates.length; i++) {
249
+ const range = candidates[i];
250
+ if (range.token.type !== 'heading')
251
+ continue;
252
+ const heading = range.token;
253
+ if (wanted && heading.depth !== Number.parseInt(wanted[1], 10))
254
+ continue;
255
+ if (segment.textMatch && !this.matchText(heading.text, segment.textMatch))
256
+ continue;
257
+ const innerTokens = [];
258
+ let end = range.start + range.length;
259
+ for (let j = i + 1; j < candidates.length; j++) {
260
+ const next = candidates[j];
261
+ if (next.token.type === 'heading' && next.token.depth <= heading.depth)
262
+ break;
263
+ innerTokens.push(next);
264
+ end = MarkdownEditor.blockEnd(next);
265
+ }
266
+ sections.push({ token: range.token, start: range.start, length: end - range.start, innerTokens });
267
+ }
268
+ return sections;
269
+ }
270
+ itemsOf(candidates) {
271
+ const items = [];
272
+ for (const range of candidates) {
273
+ if (range.token.type !== 'list')
274
+ continue;
275
+ const raw = range.token.raw;
276
+ let cursor = 0;
277
+ for (const item of range.token.items) {
278
+ const itemRaw = item.raw;
279
+ const at = raw.indexOf(itemRaw, cursor);
280
+ if (at === -1)
281
+ continue;
282
+ items.push({ token: item, start: range.start + at, length: itemRaw.length });
283
+ cursor = at + itemRaw.length;
284
+ }
285
+ }
286
+ return items;
287
+ }
288
+ expandSections(matches) {
289
+ if (!matches.some((range) => range.innerTokens))
290
+ return matches;
291
+ const expanded = [];
292
+ for (const range of matches) {
293
+ if (!range.innerTokens) {
294
+ expanded.push(range);
295
+ continue;
296
+ }
297
+ expanded.push({ token: range.token, start: range.start, length: (range.token.raw || '').length });
298
+ expanded.push(...range.innerTokens);
299
+ }
300
+ return expanded;
301
+ }
302
+ narrow(matches, segment) {
303
+ if (segment.index !== null) {
304
+ let index = segment.index;
305
+ if (index < 0)
306
+ index = matches.length + index;
307
+ if (index < 0 || index >= matches.length)
308
+ return [];
309
+ return [matches[index]];
310
+ }
311
+ if (segment.slice)
312
+ return matches.slice(segment.slice.from, segment.slice.to);
313
+ return matches;
314
+ }
315
+ filterByText(matches, segment) {
316
+ if (!segment.textMatch)
317
+ return matches;
318
+ return matches.filter((range) => this.matchText(this.tokenText(range.token), segment.textMatch));
319
+ }
320
+ matchText(text, matcher) {
321
+ let result = false;
322
+ if (matcher.mode === 'exact')
323
+ result = text === matcher.value;
324
+ if (matcher.mode === 'contains')
325
+ result = text.includes(matcher.value);
326
+ if (matcher.mode === 'regex')
327
+ result = new RegExp(matcher.value, matcher.flags || '').test(text);
328
+ if (matcher.mode === 'predicate')
329
+ result = matcher.predicate(text);
330
+ if (matcher.negated)
331
+ return !result;
332
+ return result;
333
+ }
334
+ valueMatcher(matcher) {
335
+ if (typeof matcher === 'function')
336
+ return { mode: 'predicate', value: '', negated: false, predicate: matcher };
337
+ if (matcher instanceof RegExp)
338
+ return { mode: 'regex', value: matcher.source, negated: false, flags: matcher.flags };
339
+ return { mode: 'exact', value: matcher, negated: false };
340
+ }
341
+ tokenText(token) {
342
+ const value = token;
343
+ if (this.isComment(token))
344
+ return value.raw.trim().replace(/^<!--/, '').replace(/-->$/, '').trim();
345
+ if (token.type === 'html')
346
+ return value.raw || '';
347
+ if (token.type === 'table')
348
+ return [...(value.header || []).map((cell) => cell.text), ...(value.rows || []).flatMap((row) => row.map((cell) => cell.text))].join(', ');
349
+ return value.text || '';
350
+ }
351
+ isComment(token) {
352
+ if (token.type !== 'html')
353
+ return false;
354
+ return (token.raw || '').trimStart().startsWith('<!--');
355
+ }
356
+ insertAll(offsetOf, markdown) {
357
+ return new MarkdownQuery(this.editor().insert(this.matches.map(offsetOf), String(markdown)));
358
+ }
359
+ containerStart(range) {
360
+ if (!range.innerTokens)
361
+ throw new MdqOperationError(`prepend needs a section or list, got ${range.token.type}`);
362
+ return range.start + (range.token.raw || '').length;
363
+ }
364
+ containerEnd(range) {
365
+ if (!range.innerTokens)
366
+ throw new MdqOperationError(`append needs a section or list, got ${range.token.type}`);
367
+ const last = range.innerTokens[range.innerTokens.length - 1];
368
+ if (!last)
369
+ return this.containerStart(range);
370
+ return MarkdownEditor.blockEnd(last);
371
+ }
372
+ }
373
+ export class MdqError extends Error {
374
+ }
375
+ export class MdqSelectorError extends MdqError {
376
+ index;
377
+ constructor(message, index) {
378
+ super(message);
379
+ this.name = 'MdqSelectorError';
380
+ this.index = index;
381
+ }
382
+ }
383
+ export class MdqOperationError extends MdqError {
384
+ constructor(message) {
385
+ super(message);
386
+ this.name = 'MdqOperationError';
387
+ }
388
+ }
389
+ export function mdq(source) {
390
+ return new MarkdownQuery(String(source));
391
+ }
392
+ export function parseQuery(input) {
393
+ const segments = [];
394
+ SEGMENT.lastIndex = 0;
395
+ while (SEGMENT.lastIndex < input.length) {
396
+ const at = SEGMENT.lastIndex;
397
+ const match = SEGMENT.exec(input);
398
+ if (!match)
399
+ throw new MdqSelectorError(`Unexpected character "${input[at]}" in selector`, at);
400
+ const [, prefix, name, negated, contains, quoted, pattern, flags, brackets] = match;
401
+ if (!SELECTOR_NAMES.has(name) && !/^(?:h|section)[1-6]$/.test(name))
402
+ throw new MdqSelectorError(`Unknown selector "${name}"`, at + prefix.length);
403
+ const segment = { selector: name, index: null, slice: null };
404
+ if (quoted !== undefined) {
405
+ let mode = 'exact';
406
+ if (contains)
407
+ mode = 'contains';
408
+ segment.textMatch = { mode, value: quoted.replace(/\\(.)/g, '$1'), negated: Boolean(negated) };
409
+ }
410
+ if (pattern !== undefined) {
411
+ segment.textMatch = { mode: 'regex', value: pattern, negated: Boolean(negated), flags };
412
+ }
413
+ for (const [, bounds] of brackets.matchAll(/\[([^\]]*)\]/g)) {
414
+ if (!bounds || !/^-?\d*(?::-?\d*)?$/.test(bounds))
415
+ continue;
416
+ if (!bounds.includes(':')) {
417
+ segment.index = Number.parseInt(bounds, 10);
418
+ continue;
419
+ }
420
+ const [from, to] = bounds.split(':');
421
+ segment.slice = {};
422
+ if (from)
423
+ segment.slice.from = Number.parseInt(from, 10);
424
+ if (to)
425
+ segment.slice.to = Number.parseInt(to, 10);
426
+ }
427
+ segments.push(segment);
428
+ }
429
+ return segments;
430
+ }
431
+ export function buildTokenIndex(source) {
432
+ const { body, offset } = MarkdownEditor.splitFrontmatter(source);
433
+ const ranges = [];
434
+ let cursor = offset;
435
+ for (const token of marked.lexer(body)) {
436
+ const raw = token.raw || '';
437
+ if (token.type === 'space') {
438
+ const previous = ranges[ranges.length - 1];
439
+ if (previous)
440
+ previous.trailing = { start: cursor, length: raw.length };
441
+ cursor += raw.length;
442
+ continue;
443
+ }
444
+ ranges.push({ token, start: cursor, length: raw.length });
445
+ cursor += raw.length;
446
+ }
447
+ return ranges;
448
+ }
449
+ const SELECTOR_NAMES = new Set(['section', 'heading', 'paragraph', 'table', 'list', 'item', 'code', 'blockquote', 'hr', 'html', 'comment']);
450
+ const TOKEN_ALIASES = { item: 'list_item' };
451
+ const SEGMENT = /(\s*\.?)([A-Za-z]\w*)(?:\((!?)(~?)(?:"((?:[^"\\]|\\.)*)"|\/((?:[^/\\]|\\.)*)\/([a-z]*))\))?((?:\[[^\]]*\])*)\s*/y;
@@ -4,4 +4,5 @@ export declare function normalizeInlineText(text: string): string;
4
4
  export declare function sanitizeFilename(name: string): string;
5
5
  export declare function safeFilename(name: string, ext?: string, maxBytes?: number): string;
6
6
  export declare function truncate(text: string, max: number): string;
7
+ export declare function truncateMiddle(text: string, max: number): string;
7
8
  export declare function compactErrorMessage(error: unknown): string;
@@ -42,6 +42,13 @@ export function truncate(text, max) {
42
42
  return text;
43
43
  return `${text.slice(0, max - 3)}...`;
44
44
  }
45
+ export function truncateMiddle(text, max) {
46
+ if (text.length <= max)
47
+ return text;
48
+ const head = Math.ceil((max - 3) / 2);
49
+ const tail = max - 3 - head;
50
+ return `${text.slice(0, head)}...${text.slice(text.length - tail)}`;
51
+ }
45
52
  const MAX_COMPACT_ERROR = 400;
46
53
  export function compactErrorMessage(error) {
47
54
  let text = stripAnsi(String(error));
@@ -1,3 +1,4 @@
1
+ export declare function listTestFiles(testsDir: string): string[];
1
2
  export declare function loadTestSuites(testsDir: string): any[];
2
3
  export declare function printTestList(suites: any[]): void;
3
4
  export declare function dryRunTestFile(filePath: string): Promise<void>;
@@ -8,12 +8,15 @@ import storeListener from 'codeceptjs/lib/listener/store';
8
8
  import store from 'codeceptjs/lib/store';
9
9
  import figureSet from 'figures';
10
10
  import { ConfigParser } from "../config.js";
11
- export function loadTestSuites(testsDir) {
11
+ export function listTestFiles(testsDir) {
12
12
  if (!existsSync(testsDir))
13
13
  return [];
14
- const jsFiles = readdirSync(testsDir)
14
+ return readdirSync(testsDir)
15
15
  .filter((f) => f.endsWith('.js'))
16
16
  .map((f) => path.resolve(testsDir, f));
17
+ }
18
+ export function loadTestSuites(testsDir) {
19
+ const jsFiles = listTestFiles(testsDir);
17
20
  if (jsFiles.length === 0)
18
21
  return [];
19
22
  codeceptjs.container.createMocha();
@@ -21,7 +21,7 @@ endpoint: "/users"
21
21
  CRUD for users. Admin role required for writes. IDs are UUIDs.
22
22
  ```
23
23
 
24
- Chief loads knowledge matching the endpoint it's planning, and Curler loads it again for the endpoint it's testing, so auth headers and payload rules reach the requests themselves. Running `know` again on the same endpoint appends to the file. See [knowledge](../workflow/knowledge.md) for how matching and files work.
24
+ Chief loads knowledge matching the endpoint it's planning, and Curler loads it again for the endpoint it's testing. When `plan` or `explore` runs, Chief first picks out the headers, query parameters and body fields the knowledge says to send, and every request whose path matches the file's `endpoint:` pattern carries them, the sample data fetch included. Headers from `api.headers` or `-H` take precedence over knowledge. Running `know` again on the same endpoint appends to the file. See [knowledge](../workflow/knowledge.md) for how matching and files work.
25
25
 
26
26
  For a fact that should not be stored — a token, a one-off fixture — pass `--knowledge` instead. It applies to the run only:
27
27
 
@@ -22,6 +22,8 @@ Explorbot uses three roles:
22
22
 
23
23
  Pick a fast, cheap model for the first two and a stronger one for the third. When a provider has no recommended model for one of these roles, combine it with another provider for that role.
24
24
 
25
+ An optional [decision model](#decision-model) can answer narrow yes/no and pick-one questions so the other models are called less often.
26
+
25
27
  ### OpenRouter
26
28
 
27
29
  Start with OpenRouter. One key reaches [many providers and models](https://openrouter.ai/models).
@@ -308,6 +310,40 @@ Laguna XS is an agentic coding model — fast, cheap, and reliable at tool calli
308
310
 
309
311
  Keep `agenticModel` on another provider. Poolside's endpoint accepts `response_format: json_schema` but does not enforce it, so structured-output calls depend on the model volunteering valid JSON. Laguna XS usually does; Laguna S answers in prose instead, which makes it unusable for the Planner, Pilot, and Captain. Laguna S is also slow enough under page-sized prompts to hit Explorbot's request timeouts, so it is not a substitute for Laguna XS in the `model` role either.
310
312
 
313
+ ## Decision Model
314
+
315
+ A decision model is an optional fourth role. It does not generate text or call tools. It receives the current state and one closed question, and returns an answer with a probability. Explorbot uses it for narrow choices that would otherwise take a call to a larger model or a guess.
316
+
317
+ Explorbot supports TypeSafe's Jev, reached through OpenRouter or through TypeSafe's API directly:
318
+
319
+ ```javascript
320
+ export default {
321
+ ai: {
322
+ // ...your model, visionModel, agenticModel...
323
+ decisionModel: { provider: 'openrouter', model: 'typesafe/jev-1.13' },
324
+ // or: decisionModel: { provider: 'typesafe', model: 'jev-latest' },
325
+ },
326
+ };
327
+ ```
328
+
329
+ | Option | Type | Description |
330
+ |--------|------|-------------|
331
+ | `provider` | `'openrouter' \| 'typesafe'` | Authenticates with `OPENROUTER_API_KEY` or `TYPESAFE_API_KEY` |
332
+ | `model` | `string` | Model ID at that provider |
333
+ | `tool` | `boolean` | Give Tester and Pilot a `judge` tool to ask their own questions. Default: `true` |
334
+ | `direct` | `boolean` | Consult the model at Explorbot's built-in decision points. Default: `true` |
335
+
336
+ Without a config file, or to turn it on for one run, set `EXPLORBOT_DECISION_MODEL` or pass `--decision-model` as `provider/model-id`. It overrides `provider` and `model` in the config and keeps `tool` and `direct`:
337
+
338
+ ```bash
339
+ EXPLORBOT_DECISION_MODEL=openrouter/typesafe/jev-1.13 npx explorbot explore
340
+ npx prima-cli go "Settings" --decision-model typesafe/jev-latest
341
+ ```
342
+
343
+ Leave `decisionModel` unset and Explorbot never calls it. An unknown provider or a missing API key stops Explorbot at startup.
344
+
345
+ Explorbot acts on an answer only when it is more than 70% sure, and otherwise does what it would have done without the decision model. See [Decisions](../web-testing/decisions.md) for where it is consulted, the `judge` tool, and what happens when an answer is rejected.
346
+
311
347
  ## Multi-Provider Configuration
312
348
 
313
349
  Mix clients the same way you assign `model`, `visionModel`, and `agenticModel`. Each field can use a different provider instance — a fast provider does the token-heavy reading while a stronger one makes the decisions:
@@ -359,4 +395,5 @@ export OPENAI_API_KEY=your-key-here
359
395
  export ANTHROPIC_API_KEY=your-key-here
360
396
  export GOOGLE_API_KEY=your-key-here
361
397
  export MISTRAL_API_KEY=your-key-here
398
+ export TYPESAFE_API_KEY=your-key-here
362
399
  ```
package/docs/index.json CHANGED
@@ -22,6 +22,7 @@
22
22
  { "title": "Planner", "file": "web-testing/planner.md", "description": "Tune what gets tested" },
23
23
  { "title": "Researcher", "file": "web-testing/researcher.md", "description": "How pages are analyzed, and how to tune it" },
24
24
  { "title": "Page interaction", "file": "web-testing/page-interaction.md", "description": "How agents read and act on a page" },
25
+ { "title": "Decisions", "file": "web-testing/decisions.md", "description": "The optional decision model: built-in decision points, the judge tool, fallbacks" },
25
26
  { "title": "Automated tests", "file": "web-testing/automated-tests.md", "description": "The runnable test files you keep" },
26
27
  { "title": "Rerun", "file": "web-testing/rerun.md", "description": "Re-run generated tests with AI healing" },
27
28
  { "title": "Agents", "file": "web-testing/agents.md", "description": "What each agent does under the hood" },
@@ -112,6 +112,7 @@ EXPLORBOT_AI_PROVIDER=openrouter \
112
112
  | `EXPLORBOT_URL` | Base URL to test; the API boat reads it as the base endpoint |
113
113
  | `EXPLORBOT_VISION_MODEL` | Screenshot analysis; overrides the provider recommendation |
114
114
  | `EXPLORBOT_AGENTIC_MODEL` | Captain and Pilot decisions; overrides the provider recommendation |
115
+ | `EXPLORBOT_DECISION_MODEL` | Turns on the decision model, as provider/model-id (openrouter/typesafe/jev-1.13 or typesafe/jev-latest); overrides ai.decisionModel |
115
116
  | `EXPLORBOT_OUTPUT` | Output root for states, plans, research, and reports. Defaults to the site dir under ~/.explorbot/sites |
116
117
  | `EXPLORBOT_EPHEMERAL` | Keep no state between runs — output goes to a fresh temp directory instead of the site dir |
117
118
  | `EXPLORBOT_KNOWLEDGE` | Inline knowledge text, applied to every page |
@@ -537,6 +537,7 @@ export default {
537
537
  ai: {
538
538
  model: groq('openai/gpt-oss-20b'), // Default model instance (required)
539
539
  visionModel: groq('meta-llama/llama-4-scout-17b-16e-instruct'), // Model for screenshot analysis; setting it enables vision features
540
+ decisionModel: { provider: 'openrouter', model: 'typesafe/jev-1.13' }, // Optional; see providers docs
540
541
  config: {}, // Additional provider config
541
542
  langfuse: { // Observability settings
542
543
  enabled: true,
@@ -624,6 +625,7 @@ export default {
624
625
 
625
626
  - [API testing](../api-testing/basics.md) — API testing setup and commands
626
627
  - [AI providers](../basics/providers.md) — provider setup examples
628
+ - [Decisions](../web-testing/decisions.md) — the optional decision model and its fallbacks
627
629
  - [Agents](../web-testing/agents.md) — agent descriptions and workflows
628
630
  - [Agent hooks](../web-testing/hooks.md) — custom code before and after an agent runs
629
631
  - [Researcher agent](../web-testing/researcher.md) — Researcher configuration and usage