explorbot 0.1.13 → 0.1.16

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 (42) hide show
  1. package/dist/package.json +3 -2
  2. package/dist/src/action.js +3 -2
  3. package/dist/src/ai/conversation.js +20 -4
  4. package/dist/src/ai/historian/utils.js +8 -1
  5. package/dist/src/ai/pilot.js +198 -260
  6. package/dist/src/ai/provider.js +25 -12
  7. package/dist/src/ai/quartermaster.js +2 -2
  8. package/dist/src/ai/researcher/focus.js +51 -10
  9. package/dist/src/ai/researcher/sections.js +8 -4
  10. package/dist/src/ai/researcher.js +9 -24
  11. package/dist/src/ai/rules.js +2 -0
  12. package/dist/src/ai/session-analyst.js +46 -41
  13. package/dist/src/ai/tester.js +63 -22
  14. package/dist/src/ai/tools.js +19 -4
  15. package/dist/src/commands/explore-command.js +8 -2
  16. package/dist/src/components/StatusPane.js +6 -1
  17. package/dist/src/experience-tracker.js +9 -0
  18. package/dist/src/explorer.js +2 -5
  19. package/dist/src/reporter.js +41 -1
  20. package/dist/src/stats.js +2 -1
  21. package/dist/src/test-plan.js +47 -3
  22. package/package.json +3 -2
  23. package/src/action.ts +3 -2
  24. package/src/ai/conversation.ts +21 -4
  25. package/src/ai/historian/utils.ts +8 -1
  26. package/src/ai/pilot.ts +199 -259
  27. package/src/ai/provider.ts +24 -12
  28. package/src/ai/quartermaster.ts +2 -2
  29. package/src/ai/researcher/focus.ts +57 -8
  30. package/src/ai/researcher/sections.ts +7 -3
  31. package/src/ai/researcher.ts +8 -23
  32. package/src/ai/rules.ts +2 -0
  33. package/src/ai/session-analyst.ts +47 -41
  34. package/src/ai/tester.ts +55 -20
  35. package/src/ai/tools.ts +18 -4
  36. package/src/commands/explore-command.ts +9 -2
  37. package/src/components/StatusPane.tsx +6 -3
  38. package/src/experience-tracker.ts +9 -0
  39. package/src/explorer.ts +1 -4
  40. package/src/reporter.ts +44 -1
  41. package/src/stats.ts +3 -1
  42. package/src/test-plan.ts +62 -3
@@ -423,7 +423,7 @@ export function createAgentTools({ explorer, researcher, navigator, experienceTr
423
423
  return failedToolResult('see', 'AI analysis failed to process the screenshot');
424
424
  }
425
425
  return successToolResult('see', {
426
- analysis: analysisResult,
426
+ analysis: cap(analysisResult, ANALYSIS_OUTPUT_CAP),
427
427
  message: `Successfully analyzed screenshot for: ${request}`,
428
428
  suggestion: 'Visual confirmation is valid evidence for test results. Use record() to note the visual findings.',
429
429
  });
@@ -469,8 +469,8 @@ export function createAgentTools({ explorer, researcher, navigator, experienceTr
469
469
  url: currentState.url,
470
470
  title: currentState.title,
471
471
  suggestion: 'If not enough context received, call see() to visually identify elements in page contents',
472
- aria,
473
- html,
472
+ aria: cap(aria, ARIA_OUTPUT_CAP),
473
+ html: cap(html, HTML_OUTPUT_CAP),
474
474
  reminder: 'Context provided. Do not call context() again until you perform actions or suspect page changed.',
475
475
  });
476
476
  }
@@ -556,7 +556,7 @@ export function createAgentTools({ explorer, researcher, navigator, experienceTr
556
556
  const researchResult = await researcher.research(currentState, { screenshot: true, data: true });
557
557
  return successToolResult('research', {
558
558
  analysis: researchResult,
559
- aria: ActionResult.fromState(currentState).getInteractiveARIA(),
559
+ aria: cap(ActionResult.fromState(currentState).getInteractiveARIA(), ARIA_OUTPUT_CAP),
560
560
  message: `Successfully researched page: ${currentState.url}.`,
561
561
  suggestion: dedent `
562
562
  You received comprehensive UI map report. Use it to understand the page structure and navigate to the elements.
@@ -859,6 +859,16 @@ export function createAgentTools({ explorer, researcher, navigator, experienceTr
859
859
  return tools;
860
860
  }
861
861
  const PAGE_DIFF_SUGGESTION = 'Analyze page diff. htmlParts shows what changed and WHERE — each part has a container selector. Use the container as context when clicking elements from the diff.';
862
+ const ARIA_OUTPUT_CAP = 4000;
863
+ const HTML_OUTPUT_CAP = 6000;
864
+ const ANALYSIS_OUTPUT_CAP = 2000;
865
+ function cap(text, max) {
866
+ if (!text)
867
+ return '';
868
+ if (text.length <= max)
869
+ return text;
870
+ return `${text.slice(0, max)}\n[...truncated; ${text.length - max} chars omitted...]`;
871
+ }
862
872
  function transformContainsCommand(command) {
863
873
  if (!command.includes(':contains('))
864
874
  return command;
@@ -897,9 +907,14 @@ function successToolResult(action, data, source) {
897
907
  if (data?.pageDiff) {
898
908
  let suggestion = PAGE_DIFF_SUGGESTION;
899
909
  const ariaChanges = data.pageDiff.ariaChanges || '';
910
+ const urlChanged = data.pageDiff.urlChanged === true;
911
+ const hasHtmlParts = Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
900
912
  if (countAriaChanges(ariaChanges) >= 50) {
901
913
  suggestion = `MAJOR PAGE CHANGE. Page entered a different mode. Check htmlParts and iframes in pageDiff before next action. ${suggestion}`;
902
914
  }
915
+ else if (!urlChanged && !ariaChanges && !hasHtmlParts) {
916
+ suggestion = 'Action ran without error but produced no observable change (URL, ARIA and HTML all unchanged). The locator likely matched a non-interactive ancestor or an element outside the intended control. Re-locate via xpathCheck() or verify with see() before treating this as success.';
917
+ }
903
918
  else if (ariaChanges.includes('heading') && ariaChanges.includes('added')) {
904
919
  suggestion += ' WARNING: A new panel or modal may have appeared. If this was not the intended action, close it and try a different element.';
905
920
  }
@@ -1,6 +1,7 @@
1
1
  import figureSet from 'figures';
2
2
  import { getStyles } from '../ai/planner/styles.js';
3
3
  import { outputPath } from '../config.js';
4
+ import { normalizeUrl } from '../state-manager.js';
4
5
  import { Stats } from '../stats.js';
5
6
  import { getCliName } from "../utils/cli-name.js";
6
7
  import { ErrorPageError } from "../utils/error-page.js";
@@ -9,6 +10,7 @@ import { jsonToTable } from '../utils/markdown-parser.js';
9
10
  import { printNextSteps, relativeToCwd } from "../utils/next-steps.js";
10
11
  import { safeFilename } from "../utils/strings.js";
11
12
  import { BaseCommand } from './base-command.js';
13
+ const MAX_SUB_PAGE_ATTEMPTS = 30;
12
14
  export class ExploreCommand extends BaseCommand {
13
15
  name = 'explore';
14
16
  description = 'Start web exploration';
@@ -24,6 +26,7 @@ export class ExploreCommand extends BaseCommand {
24
26
  maxTests;
25
27
  testsRun = 0;
26
28
  completedPlans = [];
29
+ failedSubPages = new Set();
27
30
  async execute(args) {
28
31
  const { opts, args: remaining } = this.parseArgs(args);
29
32
  if (opts.maxTests) {
@@ -40,10 +43,12 @@ export class ExploreCommand extends BaseCommand {
40
43
  this.completedPlans.push(mainPlan);
41
44
  if (!feature && !this.isLimitReached()) {
42
45
  const planner = this.explorBot.agentPlanner();
43
- while (true) {
46
+ let attempts = 0;
47
+ while (attempts < MAX_SUB_PAGE_ATTEMPTS) {
48
+ attempts++;
44
49
  if (this.isLimitReached())
45
50
  break;
46
- const candidates = planner.collectSubPageCandidates(mainPlan, mainUrl || '/');
51
+ const candidates = planner.collectSubPageCandidates(mainPlan, mainUrl || '/').filter((c) => !this.failedSubPages.has(normalizeUrl(c.url)));
47
52
  if (candidates.length === 0)
48
53
  break;
49
54
  const pick = await planner.pickNextSubPage(candidates);
@@ -59,6 +64,7 @@ export class ExploreCommand extends BaseCommand {
59
64
  }
60
65
  }
61
66
  catch (err) {
67
+ this.failedSubPages.add(normalizeUrl(pick.url));
62
68
  tag('warning').log(`Sub-page exploration failed: ${err instanceof Error ? err.message : err}`);
63
69
  }
64
70
  }
@@ -32,5 +32,10 @@ export const StatusPane = ({ onComplete }) => {
32
32
  React.createElement(Box, { marginTop: 1, marginBottom: 1 },
33
33
  React.createElement(Text, { bold: true }, "Usage")),
34
34
  React.createElement(Row, { label: "Time", value: Stats.getElapsedTime() }),
35
- tokenRows.map(([model, tokens]) => (React.createElement(Row, { key: model, label: model, value: `${Stats.humanizeTokens(tokens.total)} tokens` })))))));
35
+ tokenRows.map(([model, tokens]) => {
36
+ const cached = tokens.cached ?? 0;
37
+ const cachePct = tokens.input > 0 ? Math.round((cached / tokens.input) * 100) : 0;
38
+ const suffix = cached > 0 ? ` (${Stats.humanizeTokens(cached)} cached, ${cachePct}%)` : '';
39
+ return React.createElement(Row, { key: model, label: model, value: `${Stats.humanizeTokens(tokens.total)} tokens${suffix}` });
40
+ })))));
36
41
  };
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSy
2
2
  import { basename, dirname, join } from 'node:path';
3
3
  import matter from 'gray-matter';
4
4
  import { marked } from 'marked';
5
+ import { isNonReusableCode } from "./ai/historian/utils.js";
5
6
  import { ConfigParser } from './config.js';
6
7
  import { KnowledgeTracker } from './knowledge-tracker.js';
7
8
  import { createDebug, tag } from './utils/logger.js';
@@ -145,6 +146,10 @@ export class ExperienceTracker {
145
146
  return;
146
147
  if (!action.code?.trim())
147
148
  return;
149
+ if (isNonReusableCode(action.code)) {
150
+ debugLog('Skipping action with non-reusable code: %s', action.code);
151
+ return;
152
+ }
148
153
  this.ensureExperienceFile(state);
149
154
  const stateHash = state.getStateHash();
150
155
  const { content, data } = this.readExperienceFile(stateHash);
@@ -166,6 +171,10 @@ export class ExperienceTracker {
166
171
  return;
167
172
  if (!body?.trim())
168
173
  return;
174
+ if (isNonReusableCode(body)) {
175
+ debugLog('Skipping flow body with non-reusable code');
176
+ return;
177
+ }
169
178
  this.ensureExperienceFile(state);
170
179
  const stateHash = state.getStateHash();
171
180
  const { content, data } = this.readExperienceFile(stateHash);
@@ -10,7 +10,7 @@ import Action from './action.js';
10
10
  import { visuallyAnnotateContainers } from "./ai/researcher/coordinates.js";
11
11
  import { RequestStore } from "./api/request-store.js";
12
12
  import { XhrCapture } from "./api/xhr-capture.js";
13
- import { ConfigParser, outputPath } from './config.js';
13
+ import { ConfigParser } from './config.js';
14
14
  import { KnowledgeTracker } from './knowledge-tracker.js';
15
15
  import { PlaywrightRecorder } from "./playwright-recorder.js";
16
16
  import { Reporter } from "./reporter.js";
@@ -467,10 +467,7 @@ class Explorer {
467
467
  if (!this.stateManager.getCurrentState())
468
468
  return;
469
469
  const lastScreenshot = ActionResult.fromState(this.stateManager.getCurrentState()).screenshotFile;
470
- if (!lastScreenshot)
471
- return;
472
- const screenshotPath = outputPath('states', lastScreenshot);
473
- test.addArtifact(screenshotPath);
470
+ test.setActiveNoteScreenshot(lastScreenshot);
474
471
  };
475
472
  const dialogHandler = (dialog) => {
476
473
  const dialogType = dialog.type();
@@ -87,7 +87,7 @@ export class Reporter {
87
87
  this.client = new Client({ apiKey: process.env.TESTOMATIO || '', title: this.buildTitle() });
88
88
  const timeoutMs = Number(process.env.TESTOMATIO_TIMEOUT_MS || '15000');
89
89
  const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve('timeout'), timeoutMs));
90
- const result = await Promise.race([this.client.createRun().then(() => 'success'), timeoutPromise]);
90
+ const result = await Promise.race([this.client.createRun({ configuration: { exploratory: true } }).then(() => 'success'), timeoutPromise]);
91
91
  if (result === 'timeout') {
92
92
  debugLog('Reporter run creation timed out');
93
93
  return;
@@ -117,6 +117,7 @@ export class Reporter {
117
117
  message: note.message,
118
118
  status: note.status,
119
119
  screenshot: note.screenshot,
120
+ log: note.log,
120
121
  }))
121
122
  .sort((a, b) => a.startTime - b.startTime);
122
123
  const stepEntries = Object.entries(test.steps)
@@ -148,8 +149,16 @@ export class Reporter {
148
149
  if (noteEntry.screenshot) {
149
150
  step.artifacts = [outputPath('states', noteEntry.screenshot)];
150
151
  }
152
+ if (noteEntry.log) {
153
+ step.log = noteEntry.log;
154
+ }
151
155
  steps.push(step);
152
156
  }
157
+ const verificationStep = this.buildVerificationStep(test, lastScreenshotFile);
158
+ if (verificationStep) {
159
+ steps.push(verificationStep);
160
+ return steps;
161
+ }
153
162
  if (lastScreenshotFile && steps.length > 0) {
154
163
  const lastStep = steps[steps.length - 1];
155
164
  const screenshotPath = outputPath('states', lastScreenshotFile);
@@ -162,6 +171,37 @@ export class Reporter {
162
171
  }
163
172
  return steps;
164
173
  }
174
+ buildVerificationStep(test, lastScreenshotFile) {
175
+ const v = test.verification;
176
+ if (!v)
177
+ return undefined;
178
+ const subSteps = [];
179
+ if (v.message)
180
+ subSteps.push({ category: 'framework', title: v.message, duration: 0 });
181
+ if (v.url) {
182
+ subSteps.push({
183
+ category: 'framework',
184
+ title: v.pageLabel ? `Navigated to ${v.pageLabel}` : 'Final page',
185
+ log: v.url,
186
+ duration: 0,
187
+ });
188
+ }
189
+ for (const detail of v.details) {
190
+ subSteps.push({ category: 'framework', title: detail, duration: 0 });
191
+ }
192
+ const screenshotFile = v.screenshot || lastScreenshotFile;
193
+ const step = {
194
+ category: 'user',
195
+ title: 'Verification',
196
+ duration: 0,
197
+ status: v.status || 'none',
198
+ steps: subSteps.length > 0 ? subSteps : undefined,
199
+ };
200
+ if (screenshotFile) {
201
+ step.artifacts = [outputPath('states', screenshotFile)];
202
+ }
203
+ return step;
204
+ }
165
205
  async reportTest(test, meta) {
166
206
  await this.startRun();
167
207
  if (!this.isRunStarted) {
package/dist/src/stats.js CHANGED
@@ -10,11 +10,12 @@ export class Stats {
10
10
  static models = {};
11
11
  static recordTokens(_agent, model, usage) {
12
12
  if (!Stats.models[model]) {
13
- Stats.models[model] = { input: 0, output: 0, total: 0 };
13
+ Stats.models[model] = { input: 0, output: 0, total: 0, cached: 0 };
14
14
  }
15
15
  Stats.models[model].input += usage.input;
16
16
  Stats.models[model].output += usage.output;
17
17
  Stats.models[model].total += usage.total;
18
+ Stats.models[model].cached = (Stats.models[model].cached ?? 0) + (usage.cached ?? 0);
18
19
  }
19
20
  static getElapsedTime() {
20
21
  const elapsed = Date.now() - Stats.startTime;
@@ -17,6 +17,7 @@ export class ActiveNote {
17
17
  message;
18
18
  status;
19
19
  screenshot;
20
+ log;
20
21
  constructor(task, message, status) {
21
22
  this.task = task;
22
23
  this.startTime = performance.now();
@@ -41,6 +42,7 @@ export class Task {
41
42
  steps;
42
43
  states;
43
44
  startUrl;
45
+ verification;
44
46
  timestampCounter = 0;
45
47
  activeNote;
46
48
  constructor(description, startUrl = '') {
@@ -67,6 +69,7 @@ export class Task {
67
69
  startTime: activeNote.getStartTime(),
68
70
  endTime,
69
71
  screenshot: activeNote.screenshot,
72
+ log: activeNote.log,
70
73
  };
71
74
  this.activeNote = undefined;
72
75
  }
@@ -80,13 +83,30 @@ export class Task {
80
83
  .map((n) => `- ${n}`)
81
84
  .join('\n');
82
85
  }
83
- addNote(message, status = null, screenshot) {
84
- const isDuplicate = Object.values(this.notes).some((note) => note.message === message && note.status === status);
86
+ addNote(message, status = null, screenshot, log) {
87
+ const isDuplicate = Object.values(this.notes).some((note) => note.message === message && note.status === status && note.log === log);
85
88
  if (isDuplicate)
86
89
  return;
87
90
  const now = performance.now();
88
91
  const timestamp = `${now}_${this.timestampCounter++}`;
89
- this.notes[timestamp] = { message, status, startTime: now, endTime: now, screenshot };
92
+ this.notes[timestamp] = { message, status, startTime: now, endTime: now, screenshot, log };
93
+ }
94
+ addUrlNote(state, prevState) {
95
+ const fullUrl = state.fullUrl || state.url;
96
+ if (!fullUrl)
97
+ return;
98
+ let label;
99
+ if (state.title && state.title !== prevState?.title)
100
+ label = state.title;
101
+ else if (state.h1 && state.h1 !== prevState?.h1)
102
+ label = state.h1;
103
+ else if (state.h2 && state.h2 !== prevState?.h2)
104
+ label = state.h2;
105
+ else
106
+ label = state.title || state.h1 || state.h2;
107
+ if (!label)
108
+ return;
109
+ this.addNote(`Navigated to ${label}`, TestResult.PASSED, state.screenshotFile, fullUrl);
90
110
  }
91
111
  addState(state) {
92
112
  this.states.push(state);
@@ -95,6 +115,30 @@ export class Task {
95
115
  const timestamp = `${performance.now()}_${this.timestampCounter++}`;
96
116
  this.steps[timestamp] = { text, duration, status, error, log, artifacts, noteStartTime: this.activeNote?.getStartTime() };
97
117
  }
118
+ setActiveNoteScreenshot(screenshotFile) {
119
+ if (!this.activeNote || !screenshotFile)
120
+ return;
121
+ this.activeNote.screenshot = screenshotFile;
122
+ }
123
+ setVerification(message, status, state) {
124
+ this.verification ||= { message: '', status: null, details: [] };
125
+ this.verification.message = message;
126
+ this.verification.status = status;
127
+ if (!state)
128
+ return;
129
+ if (state.screenshotFile)
130
+ this.verification.screenshot = state.screenshotFile;
131
+ const fullUrl = state.fullUrl || state.url;
132
+ if (fullUrl)
133
+ this.verification.url = fullUrl;
134
+ this.verification.pageLabel = state.title || state.h1 || state.h2 || undefined;
135
+ }
136
+ addVerificationDetail(detail) {
137
+ if (!detail)
138
+ return;
139
+ this.verification ||= { message: '', status: null, details: [] };
140
+ this.verification.details.push(detail);
141
+ }
98
142
  getLog() {
99
143
  const merged = {};
100
144
  for (const [key, stepData] of Object.entries(this.steps)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.13",
3
+ "version": "0.1.16",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -67,6 +67,7 @@
67
67
  "@ai-sdk/openai": "^3.0",
68
68
  "@axe-core/playwright": "^4.11.0",
69
69
  "@codeceptjs/reflection": "^0.5.2",
70
+ "@faker-js/faker": "^10.4.0",
70
71
  "@inkjs/ui": "^2.0.0",
71
72
  "@langfuse/otel": "^4.5.1",
72
73
  "@openrouter/ai-sdk-provider": "^2.3.3",
@@ -78,7 +79,7 @@
78
79
  "@opentelemetry/sdk-trace-base": "^2.2.0",
79
80
  "@opentelemetry/semantic-conventions": "^1.38.0",
80
81
  "@scalar/openapi-parser": "^0.25.6",
81
- "@testomatio/reporter": "^2.7.9-beta.2-markdown",
82
+ "@testomatio/reporter": "^2.7.9-beta.3-markdown",
82
83
  "ai": "^6.0.6",
83
84
  "axe-core": "^4.11.1",
84
85
  "bash-tool": "^1.3.15",
package/src/action.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { faker } from '@faker-js/faker';
3
4
  import { context, trace } from '@opentelemetry/api';
4
5
  import { highlight } from 'cli-highlight';
5
6
  import { container, recorder } from 'codeceptjs';
@@ -255,8 +256,8 @@ class Action {
255
256
  await asyncFn(page);
256
257
  await sleep(this.config.action?.delay || 500);
257
258
  } else {
258
- const codeFunction = new Function('I', 'tryTo', 'retryTo', 'within', 'hopeThat', 'step', sanitizedCode);
259
- codeFunction(this.actor, tryTo, retryTo, within, hopeThat, step);
259
+ const codeFunction = new Function('I', 'tryTo', 'retryTo', 'within', 'hopeThat', 'step', 'faker', sanitizedCode);
260
+ codeFunction(this.actor, tryTo, retryTo, within, hopeThat, step, faker);
260
261
  await recorder.add(() => sleep(this.config.action?.delay || 500));
261
262
  await recorder.promise();
262
263
  }
@@ -19,6 +19,7 @@ export class Conversation {
19
19
  messages: ModelMessage[];
20
20
  model: any;
21
21
  telemetryFunctionId?: string;
22
+ protectedPrefixCount = 0;
22
23
  private autoTrimRules: Map<string, number>;
23
24
 
24
25
  constructor(messages: ModelMessage[] = [], model?: any, telemetryFunctionId?: string) {
@@ -29,6 +30,10 @@ export class Conversation {
29
30
  this.autoTrimRules = new Map();
30
31
  }
31
32
 
33
+ protectPrefix(count: number): void {
34
+ this.protectedPrefixCount = count;
35
+ }
36
+
32
37
  addUserText(text: string): void {
33
38
  this.messages.push({
34
39
  role: 'user',
@@ -85,9 +90,11 @@ export class Conversation {
85
90
  const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
86
91
  const regex = new RegExp(`<${escapedTag}>[\\s\\S]*?<\\/${escapedTag}>`, 'g');
87
92
  const replacementText = `<${tagName}>${replacement}</${tagName}>`;
93
+ const start = this.protectedPrefixCount;
88
94
 
89
95
  if (keepLast === 0) {
90
- for (const message of this.messages) {
96
+ for (let i = start; i < this.messages.length; i++) {
97
+ const message = this.messages[i];
91
98
  if (typeof message.content === 'string') {
92
99
  message.content = message.content.replace(regex, replacementText);
93
100
  }
@@ -96,7 +103,7 @@ export class Conversation {
96
103
  }
97
104
 
98
105
  const allMatches: Array<{ messageIndex: number; startIndex: number; endIndex: number }> = [];
99
- for (let i = 0; i < this.messages.length; i++) {
106
+ for (let i = start; i < this.messages.length; i++) {
100
107
  const message = this.messages[i];
101
108
  if (typeof message.content === 'string') {
102
109
  const matches = [...message.content.matchAll(new RegExp(`<${escapedTag}>[\\s\\S]*?<\\/${escapedTag}>`, 'g'))];
@@ -112,7 +119,7 @@ export class Conversation {
112
119
  const keepMatches = allMatches.slice(-keepCount);
113
120
  const keepSet = new Set(keepMatches.map((m) => `${m.messageIndex}:${m.startIndex}`));
114
121
 
115
- for (let i = 0; i < this.messages.length; i++) {
122
+ for (let i = start; i < this.messages.length; i++) {
116
123
  const message = this.messages[i];
117
124
  if (typeof message.content === 'string') {
118
125
  const matches = [...message.content.matchAll(new RegExp(`<${escapedTag}>[\\s\\S]*?<\\/${escapedTag}>`, 'g'))];
@@ -137,7 +144,7 @@ export class Conversation {
137
144
 
138
145
  compactToolResults(keepLastN: number): void {
139
146
  const toolMessageIndexes: number[] = [];
140
- for (let i = 0; i < this.messages.length; i++) {
147
+ for (let i = this.protectedPrefixCount; i < this.messages.length; i++) {
141
148
  if (this.messages[i].role === 'tool') toolMessageIndexes.push(i);
142
149
  }
143
150
  const compactUpTo = toolMessageIndexes.length - Math.max(0, keepLastN);
@@ -169,6 +176,16 @@ export class Conversation {
169
176
  }
170
177
  }
171
178
 
179
+ markLastMessageCacheable(): void {
180
+ const last = this.messages[this.messages.length - 1];
181
+ if (!last) return;
182
+ (last as any).providerOptions = {
183
+ ...(last as any).providerOptions,
184
+ anthropic: { cacheControl: { type: 'ephemeral' } },
185
+ bedrock: { cachePoint: { type: 'default' } },
186
+ };
187
+ }
188
+
172
189
  hasTag(tagName: string, lastN?: number): boolean {
173
190
  const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
174
191
  const regex = new RegExp(`<${escapedTag}>`, 'g');
@@ -1,7 +1,14 @@
1
+ import { isDynamicId } from '../../utils/xpath.ts';
1
2
  import type { ToolExecution } from '../conversation.ts';
2
3
 
3
4
  export function isNonReusableCode(code: string): boolean {
4
- return /\bI\.clickXY\s*\(/.test(code);
5
+ if (/\bI\.clickXY\s*\(/.test(code)) return true;
6
+
7
+ for (const m of code.matchAll(/#([A-Za-z_][\w-]*)/g)) {
8
+ if (isDynamicId(m[1])) return true;
9
+ }
10
+
11
+ return false;
5
12
  }
6
13
 
7
14
  export function escapeString(str: string): string {