donobu 5.20.0 → 5.21.0

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 (47) hide show
  1. package/dist/apis/FlowsAiQueriesApi.d.ts +20 -0
  2. package/dist/apis/FlowsAiQueriesApi.js +27 -0
  3. package/dist/clients/OllamaGptClient.d.ts +1 -1
  4. package/dist/clients/OllamaGptClient.js +19 -10
  5. package/dist/esm/apis/FlowsAiQueriesApi.d.ts +20 -0
  6. package/dist/esm/apis/FlowsAiQueriesApi.js +27 -0
  7. package/dist/esm/clients/OllamaGptClient.d.ts +1 -1
  8. package/dist/esm/clients/OllamaGptClient.js +19 -10
  9. package/dist/esm/exceptions/GptModelCapabilityException.d.ts +12 -0
  10. package/dist/esm/exceptions/GptModelCapabilityException.js +19 -0
  11. package/dist/esm/managers/AdminApiController.js +3 -0
  12. package/dist/esm/managers/DonobuFlow.d.ts +2 -0
  13. package/dist/esm/managers/DonobuFlow.js +39 -2
  14. package/dist/esm/managers/DonobuFlowsManager.d.ts +3 -0
  15. package/dist/esm/managers/DonobuFlowsManager.js +4 -0
  16. package/dist/esm/managers/FlowCatalog.d.ts +2 -0
  17. package/dist/esm/managers/FlowCatalog.js +19 -0
  18. package/dist/esm/models/AiQuery.d.ts +42 -0
  19. package/dist/esm/models/AiQuery.js +3 -0
  20. package/dist/esm/persistence/DonobuSqliteDb.js +17 -0
  21. package/dist/esm/persistence/flows/FlowsPersistence.d.ts +10 -0
  22. package/dist/esm/persistence/flows/FlowsPersistenceDonobuApi.d.ts +3 -0
  23. package/dist/esm/persistence/flows/FlowsPersistenceDonobuApi.js +18 -0
  24. package/dist/esm/persistence/flows/FlowsPersistenceSqlite.d.ts +3 -0
  25. package/dist/esm/persistence/flows/FlowsPersistenceSqlite.js +10 -0
  26. package/dist/esm/persistence/flows/FlowsPersistenceVolatile.d.ts +5 -1
  27. package/dist/esm/persistence/flows/FlowsPersistenceVolatile.js +23 -1
  28. package/dist/exceptions/GptModelCapabilityException.d.ts +12 -0
  29. package/dist/exceptions/GptModelCapabilityException.js +19 -0
  30. package/dist/managers/AdminApiController.js +3 -0
  31. package/dist/managers/DonobuFlow.d.ts +2 -0
  32. package/dist/managers/DonobuFlow.js +39 -2
  33. package/dist/managers/DonobuFlowsManager.d.ts +3 -0
  34. package/dist/managers/DonobuFlowsManager.js +4 -0
  35. package/dist/managers/FlowCatalog.d.ts +2 -0
  36. package/dist/managers/FlowCatalog.js +19 -0
  37. package/dist/models/AiQuery.d.ts +42 -0
  38. package/dist/models/AiQuery.js +3 -0
  39. package/dist/persistence/DonobuSqliteDb.js +17 -0
  40. package/dist/persistence/flows/FlowsPersistence.d.ts +10 -0
  41. package/dist/persistence/flows/FlowsPersistenceDonobuApi.d.ts +3 -0
  42. package/dist/persistence/flows/FlowsPersistenceDonobuApi.js +18 -0
  43. package/dist/persistence/flows/FlowsPersistenceSqlite.d.ts +3 -0
  44. package/dist/persistence/flows/FlowsPersistenceSqlite.js +10 -0
  45. package/dist/persistence/flows/FlowsPersistenceVolatile.d.ts +5 -1
  46. package/dist/persistence/flows/FlowsPersistenceVolatile.js +23 -1
  47. package/package.json +1 -1
@@ -0,0 +1,20 @@
1
+ import type { Request, Response } from 'express';
2
+ import type { DonobuFlowsManager } from '../managers/DonobuFlowsManager';
3
+ /**
4
+ * API controller for retrieving AI query records within Donobu flows.
5
+ *
6
+ * AI queries represent the decision cycles where the AI analyzed the current
7
+ * page state (via clean and annotated screenshots) before deciding on the next
8
+ * action. Each record includes the screenshots the AI was shown, the
9
+ * interactable elements it could choose from, and any error that occurred
10
+ * during the query.
11
+ */
12
+ export declare class FlowsAiQueriesApi {
13
+ private readonly donobuFlowsManager;
14
+ constructor(donobuFlowsManager: DonobuFlowsManager);
15
+ /**
16
+ * Retrieves all AI query records for a specific flow, ordered by query time.
17
+ */
18
+ getAiQueries(req: Request, res: Response): Promise<void>;
19
+ }
20
+ //# sourceMappingURL=FlowsAiQueriesApi.d.ts.map
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FlowsAiQueriesApi = void 0;
4
+ /**
5
+ * API controller for retrieving AI query records within Donobu flows.
6
+ *
7
+ * AI queries represent the decision cycles where the AI analyzed the current
8
+ * page state (via clean and annotated screenshots) before deciding on the next
9
+ * action. Each record includes the screenshots the AI was shown, the
10
+ * interactable elements it could choose from, and any error that occurred
11
+ * during the query.
12
+ */
13
+ class FlowsAiQueriesApi {
14
+ constructor(donobuFlowsManager) {
15
+ this.donobuFlowsManager = donobuFlowsManager;
16
+ }
17
+ /**
18
+ * Retrieves all AI query records for a specific flow, ordered by query time.
19
+ */
20
+ async getAiQueries(req, res) {
21
+ const flowId = String(req.params.flowId);
22
+ const aiQueries = await this.donobuFlowsManager.getAiQueries(flowId);
23
+ res.json(aiQueries);
24
+ }
25
+ }
26
+ exports.FlowsAiQueriesApi = FlowsAiQueriesApi;
27
+ //# sourceMappingURL=FlowsAiQueriesApi.js.map
@@ -1,4 +1,4 @@
1
- import type { ZodType } from 'zod/v4';
1
+ import { type ZodType } from 'zod/v4';
2
2
  import type { OllamaConfig } from '../models/GptConfig';
3
3
  import type { AssistantMessage, GptMessage, ProposedToolCallsMessage, StructuredOutputMessage } from '../models/GptMessage';
4
4
  import type { ToolOption } from './GptClient';
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OllamaGptClient = void 0;
4
+ const v4_1 = require("zod/v4");
5
+ const GptModelCapabilityException_1 = require("../exceptions/GptModelCapabilityException");
4
6
  const GptModelNotFoundException_1 = require("../exceptions/GptModelNotFoundException");
5
7
  const GptPlatformNotReachableException_1 = require("../exceptions/GptPlatformNotReachableException");
6
8
  const Logger_1 = require("../utils/Logger");
@@ -23,9 +25,15 @@ class OllamaGptClient extends GptClient_1.GptClient {
23
25
  }
24
26
  async ping(options) {
25
27
  const signal = options?.signal ?? AbortSignal.timeout(10_000);
28
+ const modelName = this.config.modelName;
26
29
  let resp;
27
30
  try {
28
- resp = await fetch(`${this.apiUrl}/api/tags`, { signal });
31
+ resp = await fetch(`${this.apiUrl}/api/show`, {
32
+ method: 'POST',
33
+ headers: { 'Content-Type': 'application/json' },
34
+ body: JSON.stringify({ model: modelName }),
35
+ signal,
36
+ });
29
37
  }
30
38
  catch (error) {
31
39
  if (error instanceof TypeError) {
@@ -34,18 +42,19 @@ class OllamaGptClient extends GptClient_1.GptClient {
34
42
  }
35
43
  throw error;
36
44
  }
45
+ if (resp.status === 404) {
46
+ throw new GptModelNotFoundException_1.GptModelNotFoundException(this.config.type, modelName);
47
+ }
37
48
  if (!resp.ok) {
38
49
  throw new GptPlatformNotReachableException_1.GptPlatformNotReachableException(this.config.type);
39
50
  }
40
- const data = (await resp.json());
41
- const models = data.models ?? [];
42
- const modelName = this.config.modelName;
43
- const found = models.some((m) => {
44
- const name = m.name ?? '';
45
- return name === modelName || name === `${modelName}:latest`;
46
- });
47
- if (!found) {
48
- throw new GptModelNotFoundException_1.GptModelNotFoundException(this.config.type, modelName);
51
+ const data = v4_1.z
52
+ .object({ capabilities: v4_1.z.array(v4_1.z.string()).default([]) })
53
+ .parse(await resp.json());
54
+ const requiredCapabilities = ['completion', 'tools', 'vision'];
55
+ const missingCapabilities = requiredCapabilities.filter((cap) => !data.capabilities.includes(cap));
56
+ if (missingCapabilities.length > 0) {
57
+ throw new GptModelCapabilityException_1.GptModelCapabilityException(this.config.type, modelName, missingCapabilities);
49
58
  }
50
59
  }
51
60
  async getMessage(messages, options) {
@@ -0,0 +1,20 @@
1
+ import type { Request, Response } from 'express';
2
+ import type { DonobuFlowsManager } from '../managers/DonobuFlowsManager';
3
+ /**
4
+ * API controller for retrieving AI query records within Donobu flows.
5
+ *
6
+ * AI queries represent the decision cycles where the AI analyzed the current
7
+ * page state (via clean and annotated screenshots) before deciding on the next
8
+ * action. Each record includes the screenshots the AI was shown, the
9
+ * interactable elements it could choose from, and any error that occurred
10
+ * during the query.
11
+ */
12
+ export declare class FlowsAiQueriesApi {
13
+ private readonly donobuFlowsManager;
14
+ constructor(donobuFlowsManager: DonobuFlowsManager);
15
+ /**
16
+ * Retrieves all AI query records for a specific flow, ordered by query time.
17
+ */
18
+ getAiQueries(req: Request, res: Response): Promise<void>;
19
+ }
20
+ //# sourceMappingURL=FlowsAiQueriesApi.d.ts.map
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FlowsAiQueriesApi = void 0;
4
+ /**
5
+ * API controller for retrieving AI query records within Donobu flows.
6
+ *
7
+ * AI queries represent the decision cycles where the AI analyzed the current
8
+ * page state (via clean and annotated screenshots) before deciding on the next
9
+ * action. Each record includes the screenshots the AI was shown, the
10
+ * interactable elements it could choose from, and any error that occurred
11
+ * during the query.
12
+ */
13
+ class FlowsAiQueriesApi {
14
+ constructor(donobuFlowsManager) {
15
+ this.donobuFlowsManager = donobuFlowsManager;
16
+ }
17
+ /**
18
+ * Retrieves all AI query records for a specific flow, ordered by query time.
19
+ */
20
+ async getAiQueries(req, res) {
21
+ const flowId = String(req.params.flowId);
22
+ const aiQueries = await this.donobuFlowsManager.getAiQueries(flowId);
23
+ res.json(aiQueries);
24
+ }
25
+ }
26
+ exports.FlowsAiQueriesApi = FlowsAiQueriesApi;
27
+ //# sourceMappingURL=FlowsAiQueriesApi.js.map
@@ -1,4 +1,4 @@
1
- import type { ZodType } from 'zod/v4';
1
+ import { type ZodType } from 'zod/v4';
2
2
  import type { OllamaConfig } from '../models/GptConfig';
3
3
  import type { AssistantMessage, GptMessage, ProposedToolCallsMessage, StructuredOutputMessage } from '../models/GptMessage';
4
4
  import type { ToolOption } from './GptClient';
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OllamaGptClient = void 0;
4
+ const v4_1 = require("zod/v4");
5
+ const GptModelCapabilityException_1 = require("../exceptions/GptModelCapabilityException");
4
6
  const GptModelNotFoundException_1 = require("../exceptions/GptModelNotFoundException");
5
7
  const GptPlatformNotReachableException_1 = require("../exceptions/GptPlatformNotReachableException");
6
8
  const Logger_1 = require("../utils/Logger");
@@ -23,9 +25,15 @@ class OllamaGptClient extends GptClient_1.GptClient {
23
25
  }
24
26
  async ping(options) {
25
27
  const signal = options?.signal ?? AbortSignal.timeout(10_000);
28
+ const modelName = this.config.modelName;
26
29
  let resp;
27
30
  try {
28
- resp = await fetch(`${this.apiUrl}/api/tags`, { signal });
31
+ resp = await fetch(`${this.apiUrl}/api/show`, {
32
+ method: 'POST',
33
+ headers: { 'Content-Type': 'application/json' },
34
+ body: JSON.stringify({ model: modelName }),
35
+ signal,
36
+ });
29
37
  }
30
38
  catch (error) {
31
39
  if (error instanceof TypeError) {
@@ -34,18 +42,19 @@ class OllamaGptClient extends GptClient_1.GptClient {
34
42
  }
35
43
  throw error;
36
44
  }
45
+ if (resp.status === 404) {
46
+ throw new GptModelNotFoundException_1.GptModelNotFoundException(this.config.type, modelName);
47
+ }
37
48
  if (!resp.ok) {
38
49
  throw new GptPlatformNotReachableException_1.GptPlatformNotReachableException(this.config.type);
39
50
  }
40
- const data = (await resp.json());
41
- const models = data.models ?? [];
42
- const modelName = this.config.modelName;
43
- const found = models.some((m) => {
44
- const name = m.name ?? '';
45
- return name === modelName || name === `${modelName}:latest`;
46
- });
47
- if (!found) {
48
- throw new GptModelNotFoundException_1.GptModelNotFoundException(this.config.type, modelName);
51
+ const data = v4_1.z
52
+ .object({ capabilities: v4_1.z.array(v4_1.z.string()).default([]) })
53
+ .parse(await resp.json());
54
+ const requiredCapabilities = ['completion', 'tools', 'vision'];
55
+ const missingCapabilities = requiredCapabilities.filter((cap) => !data.capabilities.includes(cap));
56
+ if (missingCapabilities.length > 0) {
57
+ throw new GptModelCapabilityException_1.GptModelCapabilityException(this.config.type, modelName, missingCapabilities);
49
58
  }
50
59
  }
51
60
  async getMessage(messages, options) {
@@ -0,0 +1,12 @@
1
+ import { DonobuException } from './DonobuException';
2
+ /**
3
+ * Thrown when a model exists but lacks required capabilities
4
+ * (e.g. completion, tools, or vision).
5
+ */
6
+ export declare class GptModelCapabilityException extends DonobuException {
7
+ readonly platform: string;
8
+ readonly gptModel: string;
9
+ readonly missingCapabilities: string[];
10
+ constructor(platform: string, gptModel: string, missingCapabilities: string[]);
11
+ }
12
+ //# sourceMappingURL=GptModelCapabilityException.d.ts.map
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GptModelCapabilityException = void 0;
4
+ const DonobuException_1 = require("./DonobuException");
5
+ /**
6
+ * Thrown when a model exists but lacks required capabilities
7
+ * (e.g. completion, tools, or vision).
8
+ */
9
+ class GptModelCapabilityException extends DonobuException_1.DonobuException {
10
+ constructor(platform, gptModel, missingCapabilities) {
11
+ super(`The model '${gptModel}' on '${platform}' is missing required capabilities: ${missingCapabilities.join(', ')}. ` +
12
+ `Please choose a model that supports completion, tools, and vision.`);
13
+ this.platform = platform;
14
+ this.gptModel = gptModel;
15
+ this.missingCapabilities = missingCapabilities;
16
+ }
17
+ }
18
+ exports.GptModelCapabilityException = GptModelCapabilityException;
19
+ //# sourceMappingURL=GptModelCapabilityException.js.map
@@ -9,6 +9,7 @@ const v4_1 = require("zod/v4");
9
9
  const AgentsApi_1 = require("../apis/AgentsApi");
10
10
  const AskAiApi_1 = require("../apis/AskAiApi");
11
11
  const EnvDataApi_1 = require("../apis/EnvDataApi");
12
+ const FlowsAiQueriesApi_1 = require("../apis/FlowsAiQueriesApi");
12
13
  const FlowsApi_1 = require("../apis/FlowsApi");
13
14
  const FlowsFilesApi_1 = require("../apis/FlowsFilesApi");
14
15
  const FlowsToolCallsApi_1 = require("../apis/FlowsToolCallsApi");
@@ -237,6 +238,7 @@ class AdminApiController {
237
238
  flowsApi: new FlowsApi_1.FlowsApi(donobuStack.flowsManager),
238
239
  flowsFilesApi: new FlowsFilesApi_1.FlowsFilesApi(donobuStack.flowsPersistenceRegistry),
239
240
  flowsToolCallsApi: new FlowsToolCallsApi_1.FlowsToolCallsApi(donobuStack.flowsManager),
241
+ flowsAiQueriesApi: new FlowsAiQueriesApi_1.FlowsAiQueriesApi(donobuStack.flowsManager),
240
242
  pingApi: new PingApi_1.PingApi(),
241
243
  schemaApi: new SchemaApi_1.SchemaApi(),
242
244
  targetsApi: new TargetsApi_1.TargetsApi(donobuStack.targetRuntimePlugins),
@@ -308,6 +310,7 @@ class AdminApiController {
308
310
  app.get('/api/flows/:flowId/video', this.asyncHandler(apis.flowsFilesApi.getFlowVideo.bind(apis.flowsFilesApi)));
309
311
  app.get('/api/flows/:flowId/tool-calls', this.asyncHandler(apis.flowsToolCallsApi.getToolCalls.bind(apis.flowsToolCallsApi)));
310
312
  app.get('/api/flows/:flowId/tool-calls/:toolCallId', this.asyncHandler(apis.flowsToolCallsApi.getToolCall.bind(apis.flowsToolCallsApi)));
313
+ app.get('/api/flows/:flowId/ai-queries', this.asyncHandler(apis.flowsAiQueriesApi.getAiQueries.bind(apis.flowsAiQueriesApi)));
311
314
  app.get('/api/flows/:flowId/logs', this.asyncHandler(apis.flowsApi.getFlowLogs.bind(apis.flowsApi)));
312
315
  }
313
316
  /**
@@ -1,5 +1,6 @@
1
1
  import type { z } from 'zod/v4';
2
2
  import type { GptClient } from '../clients/GptClient';
3
+ import type { AiQuery } from '../models/AiQuery';
3
4
  import type { ControlPanel } from '../models/ControlPanel';
4
5
  import type { FlowMetadata } from '../models/FlowMetadata';
5
6
  import type { GptMessage, StructuredOutputMessage, TextItem } from '../models/GptMessage';
@@ -40,6 +41,7 @@ export declare class DonobuFlow {
40
41
  private static readonly MAIN_MESSAGE_ELEMENT_LIST_MARKER;
41
42
  static readonly USER_INTERRUPT_MARKER = "[User interruption while flow was paused, this MUST be acknowledged]";
42
43
  inProgressToolCall: ToolCall | null;
44
+ readonly aiQueries: AiQuery[];
43
45
  constructor(flowsManager: DonobuFlowsManager, envData: Record<string, string>, persistence: FlowsPersistence, gptClient: GptClient | null, toolManager: ToolManager, interactionVisualizer: InteractionVisualizer, proposedToolCalls: ProposedToolCall[], invokedToolCalls: ToolCall[], gptMessages: GptMessage[], targetInspector: TargetInspector, metadata: FlowMetadata, controlPanel: ControlPanel);
44
46
  /**
45
47
  * Drives the entire Donobu flow state-machine until it reaches a
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DonobuFlow = void 0;
4
4
  exports.extractFromPage = extractFromPage;
5
+ const crypto_1 = require("crypto");
5
6
  const GptPlatformInternalErrorException_1 = require("../exceptions/GptPlatformInternalErrorException");
6
7
  const UserInterruptException_1 = require("../exceptions/UserInterruptException");
7
8
  const FlowMetadata_1 = require("../models/FlowMetadata");
@@ -104,6 +105,7 @@ class DonobuFlow {
104
105
  this.metadata = metadata;
105
106
  this.controlPanel = controlPanel;
106
107
  this.inProgressToolCall = null;
108
+ this.aiQueries = [];
107
109
  }
108
110
  /**
109
111
  * Drives the entire Donobu flow state-machine until it reaches a
@@ -909,18 +911,42 @@ Message: ${dialog.message()}`;
909
911
  }
910
912
  async queryGptForProposedToolCalls() {
911
913
  this.targetInspector.checkConnectedOrThrow();
914
+ // Initialise the AI query record immediately so the error handler always
915
+ // has a record to update — no conditional check needed.
916
+ let aiQuery = {
917
+ id: (0, crypto_1.randomUUID)(),
918
+ cleanScreenshotId: null,
919
+ annotatedScreenshotId: null,
920
+ interactableElements: null,
921
+ error: null,
922
+ startedAt: Date.now(),
923
+ completedAt: null,
924
+ };
925
+ this.aiQueries.push(aiQuery);
912
926
  try {
913
927
  // Discover and mark all interactable elements on the current screen/page.
914
928
  await this.targetInspector.attributeInteractableElements();
915
929
  // Capture clean and annotated screenshots. Each inspector implementation
916
930
  // handles the platform-specific details (DOM injection vs server-side compositing).
917
931
  const screenshotBytes = await this.targetInspector.takeCleanScreenshot();
918
- await this.persistence.saveScreenShot(this.metadata.id, screenshotBytes);
932
+ const cleanScreenshotId = await this.persistence.saveScreenShot(this.metadata.id, screenshotBytes);
919
933
  await this.targetInspector.annotateInteractableElements();
920
934
  const annotatedScreenShotBytes = await this.targetInspector.takeAnnotatedScreenshot();
921
935
  await this.targetInspector.removeAnnotations();
922
- await this.persistence.saveScreenShot(this.metadata.id, annotatedScreenShotBytes);
936
+ const annotatedScreenshotId = await this.persistence.saveScreenShot(this.metadata.id, annotatedScreenShotBytes);
923
937
  const interactableElements = await this.targetInspector.getAttributedInteractableElements();
938
+ // Fill in the remaining fields and persist so the frontend can display
939
+ // the record immediately.
940
+ aiQuery = {
941
+ ...aiQuery,
942
+ cleanScreenshotId,
943
+ annotatedScreenshotId,
944
+ interactableElements,
945
+ };
946
+ this.aiQueries[this.aiQueries.length - 1] = aiQuery;
947
+ await this.persistence
948
+ .setAiQuery(this.metadata.id, aiQuery)
949
+ .catch((err) => Logger_1.appLogger.error('Failed to persist AI query record', err));
924
950
  const mainMessage = DonobuFlow.createMainUserMessage(this.targetInspector, interactableElements);
925
951
  // Give the LLM both the pre and post annotated screenshots. It can
926
952
  // use the clean screenshot to decide what it wants to do, then map it to
@@ -945,9 +971,20 @@ Message: ${dialog.message()}`;
945
971
  }));
946
972
  Logger_1.appLogger.debug('LLM response:', JsonUtils_1.JsonUtils.objectToJson(proposedToolCallsMessage));
947
973
  MiscUtils_1.MiscUtils.updateTokenCounts(proposedToolCallsMessage, this.metadata);
974
+ aiQuery = { ...aiQuery, completedAt: Date.now() };
975
+ this.aiQueries[this.aiQueries.length - 1] = aiQuery;
976
+ await this.persistence
977
+ .setAiQuery(this.metadata.id, aiQuery)
978
+ .catch((err) => Logger_1.appLogger.error('Failed to persist AI query completion', err));
948
979
  return proposedToolCallsMessage;
949
980
  }
950
981
  catch (error) {
982
+ const errorMessage = error instanceof Error ? error.message : String(error);
983
+ aiQuery = { ...aiQuery, error: errorMessage, completedAt: Date.now() };
984
+ this.aiQueries[this.aiQueries.length - 1] = aiQuery;
985
+ await this.persistence
986
+ .setAiQuery(this.metadata.id, aiQuery)
987
+ .catch((err) => Logger_1.appLogger.error('Failed to persist AI query error', err));
951
988
  if (this.targetInspector.isTargetClosedError(error)) {
952
989
  this.targetInspector.checkConnectedOrThrow();
953
990
  }
@@ -5,6 +5,7 @@ import type { GptClient } from '../clients/GptClient';
5
5
  import type { GptClientFactory } from '../clients/GptClientFactory';
6
6
  import type { GeneratedProject } from '../codegen/CodeGenerator';
7
7
  import type { env } from '../envVars';
8
+ import type { AiQuery } from '../models/AiQuery';
8
9
  import type { BrowserStateReference } from '../models/BrowserStateFlowReference';
9
10
  import type { BrowserStorageState } from '../models/BrowserStorageState';
10
11
  import type { CodeGenerationOptions } from '../models/CodeGenerationOptions';
@@ -109,6 +110,8 @@ export declare class DonobuFlowsManager {
109
110
  getFlowByName(flowName: string): Promise<FlowMetadata>;
110
111
  /** Returns all the tool calls made by the given flow by ID. */
111
112
  getToolCalls(flowId: string): Promise<ToolCall[]>;
113
+ /** Returns all AI query records for the given flow by ID. */
114
+ getAiQueries(flowId: string): Promise<AiQuery[]>;
112
115
  /**
113
116
  * Attempts to delete a flow by ID. If the flow is active, then
114
117
  * `CannotDeleteRunningFlowException` is thrown. If the flow is not active,
@@ -374,6 +374,10 @@ class DonobuFlowsManager {
374
374
  async getToolCalls(flowId) {
375
375
  return this.flowCatalog.getToolCalls(flowId);
376
376
  }
377
+ /** Returns all AI query records for the given flow by ID. */
378
+ async getAiQueries(flowId) {
379
+ return this.flowCatalog.getAiQueries(flowId);
380
+ }
377
381
  /**
378
382
  * Attempts to delete a flow by ID. If the flow is active, then
379
383
  * `CannotDeleteRunningFlowException` is thrown. If the flow is not active,
@@ -1,3 +1,4 @@
1
+ import type { AiQuery } from '../models/AiQuery';
1
2
  import type { BrowserStorageState } from '../models/BrowserStorageState';
2
3
  import type { DonobuDeploymentEnvironment } from '../models/DonobuDeploymentEnvironment';
3
4
  import type { FlowMetadata, FlowsQuery } from '../models/FlowMetadata';
@@ -21,6 +22,7 @@ export declare class FlowCatalog {
21
22
  getFlowById(flowId: string): Promise<FlowMetadata>;
22
23
  getFlowByName(flowName: string): Promise<FlowMetadata>;
23
24
  getToolCalls(flowId: string): Promise<ToolCall[]>;
25
+ getAiQueries(flowId: string): Promise<AiQuery[]>;
24
26
  deleteFlow(flowId: string): Promise<void>;
25
27
  getBrowserState(flowId: string): Promise<BrowserStorageState | null>;
26
28
  getFlows(query: FlowsQuery): Promise<PaginatedResult<FlowMetadata>>;
@@ -71,6 +71,25 @@ class FlowCatalog {
71
71
  }
72
72
  throw FlowNotFoundException_1.FlowNotFoundException.forId(flowId);
73
73
  }
74
+ async getAiQueries(flowId) {
75
+ if (this.isLocal()) {
76
+ const activeHandle = this.flowRuntime.get(flowId);
77
+ if (activeHandle) {
78
+ return [...activeHandle.donobuFlow.aiQueries].sort((a, b) => a.startedAt - b.startedAt);
79
+ }
80
+ }
81
+ for (const persistence of await this.flowsPersistenceRegistry.getAll()) {
82
+ try {
83
+ return await persistence.getAiQueries(flowId);
84
+ }
85
+ catch (error) {
86
+ if (!(error instanceof FlowNotFoundException_1.FlowNotFoundException)) {
87
+ throw error;
88
+ }
89
+ }
90
+ }
91
+ throw FlowNotFoundException_1.FlowNotFoundException.forId(flowId);
92
+ }
74
93
  async deleteFlow(flowId) {
75
94
  for (const persistence of await this.flowsPersistenceRegistry.getAll()) {
76
95
  await persistence.deleteFlow(flowId);
@@ -0,0 +1,42 @@
1
+ import type { InteractableElement } from './InteractableElement';
2
+ /**
3
+ * Represents a single AI decision cycle within a flow. Created at the start of
4
+ * a query cycle with just `id` and `queriedAt`, then progressively filled in as
5
+ * screenshots are captured and elements are discovered. The `error` field is
6
+ * populated only if the query fails.
7
+ */
8
+ export type AiQuery = {
9
+ /**
10
+ * Unique identifier for this AI query.
11
+ */
12
+ readonly id: string;
13
+ /**
14
+ * The ID of the clean (un-annotated) screenshot of the page at query time.
15
+ * Null until the screenshot is captured.
16
+ */
17
+ readonly cleanScreenshotId: string | null;
18
+ /**
19
+ * The ID of the annotated screenshot (with numbered element badges) sent to the AI.
20
+ * Null until the screenshot is captured.
21
+ */
22
+ readonly annotatedScreenshotId: string | null;
23
+ /**
24
+ * The interactable elements that were identified and sent to the AI.
25
+ * Null until element discovery completes.
26
+ */
27
+ readonly interactableElements: InteractableElement[] | null;
28
+ /**
29
+ * If the AI query failed, the error message. Null on success.
30
+ */
31
+ readonly error: string | null;
32
+ /**
33
+ * The Unix epoch millisecond timestamp of when the query was initiated.
34
+ */
35
+ readonly startedAt: number;
36
+ /**
37
+ * The Unix epoch millisecond timestamp of when the query completed (success
38
+ * or failure). Null while the query is still in progress.
39
+ */
40
+ readonly completedAt: number | null;
41
+ };
42
+ //# sourceMappingURL=AiQuery.d.ts.map
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ //# sourceMappingURL=AiQuery.js.map
@@ -395,6 +395,23 @@ CREATE INDEX IF NOT EXISTS idx_test_metadata_suite_id ON test_metadata(suite_id)
395
395
 
396
396
  ALTER TABLE flow_metadata ADD COLUMN test_id TEXT NULL REFERENCES test_metadata(id) ON DELETE CASCADE;
397
397
  CREATE INDEX IF NOT EXISTS idx_flow_metadata_test_id ON flow_metadata(test_id);
398
+ `);
399
+ },
400
+ },
401
+ {
402
+ // Create the ai_queries table to store AI decision-cycle records (clean
403
+ // and annotated screenshots, interactable elements, optional error).
404
+ version: 11,
405
+ up: (db) => {
406
+ db.exec(`
407
+ CREATE TABLE IF NOT EXISTS ai_queries (
408
+ id TEXT PRIMARY KEY,
409
+ flow_id TEXT NOT NULL,
410
+ started_at INTEGER NOT NULL,
411
+ ai_query TEXT NOT NULL,
412
+ FOREIGN KEY (flow_id) REFERENCES flow_metadata(id) ON DELETE CASCADE
413
+ );
414
+ CREATE INDEX IF NOT EXISTS idx_ai_queries_flow_id_started_at ON ai_queries(flow_id, started_at);
398
415
  `);
399
416
  },
400
417
  },
@@ -1,3 +1,4 @@
1
+ import type { AiQuery } from '../../models/AiQuery';
1
2
  import type { BrowserStorageState } from '../../models/BrowserStorageState';
2
3
  import type { FlowMetadata, FlowsQuery } from '../../models/FlowMetadata';
3
4
  import type { PaginatedResult } from '../../models/PaginatedResult';
@@ -50,6 +51,15 @@ export interface FlowsPersistence {
50
51
  * Delete a persisted tool call from a specific flow.
51
52
  */
52
53
  deleteToolCall(flowId: string, toolCallId: string): Promise<void>;
54
+ /**
55
+ * Save an AI query record for a specific flow.
56
+ */
57
+ setAiQuery(flowId: string, aiQuery: AiQuery): Promise<void>;
58
+ /**
59
+ * Load all AI query records for a specific flow, ordered by queriedAt.
60
+ * @throws FlowNotFoundException if the flow is not found
61
+ */
62
+ getAiQueries(flowId: string): Promise<AiQuery[]>;
53
63
  /**
54
64
  * Set video data for a specific flow.
55
65
  */
@@ -1,3 +1,4 @@
1
+ import type { AiQuery } from '../../models/AiQuery';
1
2
  import { type BrowserStorageState } from '../../models/BrowserStorageState';
2
3
  import type { FlowMetadata, FlowsQuery } from '../../models/FlowMetadata';
3
4
  import type { PaginatedResult } from '../../models/PaginatedResult';
@@ -28,6 +29,8 @@ export declare class FlowsPersistenceDonobuApi implements FlowsPersistence {
28
29
  setToolCall(flowId: string, toolCall: ToolCall): Promise<void>;
29
30
  getToolCalls(flowId: string): Promise<ToolCall[]>;
30
31
  deleteToolCall(flowId: string, toolCallId: string): Promise<void>;
32
+ setAiQuery(flowId: string, aiQuery: AiQuery): Promise<void>;
33
+ getAiQueries(flowId: string): Promise<AiQuery[]>;
31
34
  saveScreenShot(flowId: string, bytes: Buffer): Promise<string>;
32
35
  getScreenShot(flowId: string, screenShotId: string): Promise<Buffer | null>;
33
36
  setVideo(_flowId: string, _bytes: Buffer): Promise<void>;
@@ -134,6 +134,24 @@ class FlowsPersistenceDonobuApi {
134
134
  throw new Error(`Failed to delete tool call: ${response.status} ${response.statusText}`);
135
135
  }
136
136
  }
137
+ // -- AI Queries ----------------------------------------------------
138
+ async setAiQuery(flowId, aiQuery) {
139
+ const response = await this.jsonRequest(`/v1/flows/${encodeURIComponent(flowId)}/ai-queries/${encodeURIComponent(aiQuery.id)}`, 'PUT', { record: aiQuery });
140
+ if (!response.ok) {
141
+ throw new Error(`Failed to set AI query: ${response.status} ${response.statusText}`);
142
+ }
143
+ }
144
+ async getAiQueries(flowId) {
145
+ const response = await this.jsonRequest(`/v1/flows/${encodeURIComponent(flowId)}/ai-queries`, 'GET');
146
+ if (response.status === 404) {
147
+ throw FlowNotFoundException_1.FlowNotFoundException.forId(flowId);
148
+ }
149
+ if (!response.ok) {
150
+ throw new Error(`Failed to get AI queries: ${response.status} ${response.statusText}`);
151
+ }
152
+ const body = (await response.json());
153
+ return body.items;
154
+ }
137
155
  // -- Screenshots ---------------------------------------------------
138
156
  async saveScreenShot(flowId, bytes) {
139
157
  const imageType = MiscUtils_1.MiscUtils.detectImageType(bytes);
@@ -1,4 +1,5 @@
1
1
  import type Database from 'better-sqlite3';
2
+ import type { AiQuery } from '../../models/AiQuery';
2
3
  import { type BrowserStorageState } from '../../models/BrowserStorageState';
3
4
  import type { FlowMetadata, FlowsQuery } from '../../models/FlowMetadata';
4
5
  import type { PaginatedResult } from '../../models/PaginatedResult';
@@ -40,6 +41,8 @@ export declare class FlowsPersistenceSqlite implements FlowsPersistence {
40
41
  setToolCall(flowId: string, toolCall: ToolCall): Promise<void>;
41
42
  getToolCalls(flowId: string): Promise<ToolCall[]>;
42
43
  deleteToolCall(flowId: string, toolCallId: string): Promise<void>;
44
+ setAiQuery(flowId: string, aiQuery: AiQuery): Promise<void>;
45
+ getAiQueries(flowId: string): Promise<AiQuery[]>;
43
46
  setVideo(flowId: string, bytes: Buffer): Promise<void>;
44
47
  getVideoSegment(flowId: string, startOffset: number, length: number): Promise<VideoSegment | null>;
45
48
  getFlowFile(flowId: string, fileId: string): Promise<Buffer | null>;
@@ -212,6 +212,16 @@ class FlowsPersistenceSqlite {
212
212
  const stmt = this.db.prepare('DELETE FROM tool_calls WHERE flow_id = ? AND id = ?');
213
213
  stmt.run(flowId, toolCallId);
214
214
  }
215
+ async setAiQuery(flowId, aiQuery) {
216
+ const stmt = this.db.prepare('INSERT OR REPLACE INTO ai_queries (id, flow_id, started_at, ai_query) VALUES (?, ?, ?, ?)');
217
+ stmt.run(aiQuery.id, flowId, aiQuery.startedAt, JSON.stringify(aiQuery));
218
+ }
219
+ async getAiQueries(flowId) {
220
+ await this.getFlowMetadataById(flowId);
221
+ const stmt = this.db.prepare('SELECT ai_query FROM ai_queries WHERE flow_id = ? ORDER BY started_at');
222
+ const rows = stmt.all(flowId);
223
+ return rows.map((row) => JSON.parse(row.ai_query));
224
+ }
215
225
  async setVideo(flowId, bytes) {
216
226
  // Ensure flow exists.
217
227
  await this.getFlowMetadataById(flowId);
@@ -1,3 +1,4 @@
1
+ import type { AiQuery } from '../../models/AiQuery';
1
2
  import type { BrowserStorageState } from '../../models/BrowserStorageState';
2
3
  import type { FlowMetadata, FlowsQuery } from '../../models/FlowMetadata';
3
4
  import type { PaginatedResult } from '../../models/PaginatedResult';
@@ -14,7 +15,8 @@ export declare class FlowsPersistenceVolatile implements FlowsPersistence {
14
15
  private readonly videos;
15
16
  private readonly files;
16
17
  private readonly browserStates;
17
- constructor(flows?: Map<string, FlowMetadata>, screenshots?: Map<string, Map<string, Buffer>>, toolCalls?: Map<string, ToolCall[]>, videos?: Map<string, Buffer>, files?: Map<string, Map<string, Buffer>>, browserStates?: Map<string, BrowserStorageState>);
18
+ private readonly aiQueries;
19
+ constructor(flows?: Map<string, FlowMetadata>, screenshots?: Map<string, Map<string, Buffer>>, toolCalls?: Map<string, ToolCall[]>, videos?: Map<string, Buffer>, files?: Map<string, Map<string, Buffer>>, browserStates?: Map<string, BrowserStorageState>, aiQueries?: Map<string, AiQuery[]>);
18
20
  setFlowMetadata(flowMetadata: FlowMetadata): Promise<void>;
19
21
  getFlowMetadataById(flowId: string): Promise<FlowMetadata>;
20
22
  getFlowMetadataByName(flowName: string): Promise<FlowMetadata>;
@@ -27,6 +29,8 @@ export declare class FlowsPersistenceVolatile implements FlowsPersistence {
27
29
  setToolCall(flowId: string, toolCall: ToolCall): Promise<void>;
28
30
  getToolCalls(flowId: string): Promise<ToolCall[]>;
29
31
  deleteToolCall(flowId: string, toolCallId: string): Promise<void>;
32
+ setAiQuery(flowId: string, aiQuery: AiQuery): Promise<void>;
33
+ getAiQueries(flowId: string): Promise<AiQuery[]>;
30
34
  setVideo(flowId: string, bytes: Buffer): Promise<void>;
31
35
  getVideoSegment(flowId: string, startOffset: number, length: number): Promise<VideoSegment | null>;
32
36
  getFlowFile(flowId: string, fileId: string): Promise<Buffer | null>;
@@ -7,13 +7,14 @@ const MiscUtils_1 = require("../../utils/MiscUtils");
7
7
  * A volatile (in-memory) implementation of FlowsPersistence.
8
8
  */
9
9
  class FlowsPersistenceVolatile {
10
- constructor(flows = new Map(), screenshots = new Map(), toolCalls = new Map(), videos = new Map(), files = new Map(), browserStates = new Map()) {
10
+ constructor(flows = new Map(), screenshots = new Map(), toolCalls = new Map(), videos = new Map(), files = new Map(), browserStates = new Map(), aiQueries = new Map()) {
11
11
  this.flows = flows;
12
12
  this.screenshots = screenshots;
13
13
  this.toolCalls = toolCalls;
14
14
  this.videos = videos;
15
15
  this.files = files;
16
16
  this.browserStates = browserStates;
17
+ this.aiQueries = aiQueries;
17
18
  }
18
19
  async setFlowMetadata(flowMetadata) {
19
20
  this.flows.set(flowMetadata.id, { ...flowMetadata });
@@ -126,6 +127,26 @@ class FlowsPersistenceVolatile {
126
127
  this.toolCalls.delete(flowId);
127
128
  }
128
129
  }
130
+ async setAiQuery(flowId, aiQuery) {
131
+ if (!this.aiQueries.has(flowId)) {
132
+ this.aiQueries.set(flowId, []);
133
+ }
134
+ const queries = this.aiQueries.get(flowId);
135
+ const existingIndex = queries.findIndex((q) => q.id === aiQuery.id);
136
+ if (existingIndex >= 0) {
137
+ queries[existingIndex] = { ...aiQuery };
138
+ }
139
+ else {
140
+ queries.push({ ...aiQuery });
141
+ }
142
+ }
143
+ async getAiQueries(flowId) {
144
+ if (!this.flows.has(flowId)) {
145
+ throw FlowNotFoundException_1.FlowNotFoundException.forId(flowId);
146
+ }
147
+ const queries = this.aiQueries.get(flowId) || [];
148
+ return [...queries].sort((a, b) => a.startedAt - b.startedAt);
149
+ }
129
150
  async setVideo(flowId, bytes) {
130
151
  this.videos.set(flowId, Buffer.from(bytes));
131
152
  }
@@ -167,6 +188,7 @@ class FlowsPersistenceVolatile {
167
188
  this.flows.delete(flowId);
168
189
  this.screenshots.delete(flowId);
169
190
  this.toolCalls.delete(flowId);
191
+ this.aiQueries.delete(flowId);
170
192
  this.videos.delete(flowId);
171
193
  this.files.delete(flowId);
172
194
  this.browserStates.delete(flowId);
@@ -0,0 +1,12 @@
1
+ import { DonobuException } from './DonobuException';
2
+ /**
3
+ * Thrown when a model exists but lacks required capabilities
4
+ * (e.g. completion, tools, or vision).
5
+ */
6
+ export declare class GptModelCapabilityException extends DonobuException {
7
+ readonly platform: string;
8
+ readonly gptModel: string;
9
+ readonly missingCapabilities: string[];
10
+ constructor(platform: string, gptModel: string, missingCapabilities: string[]);
11
+ }
12
+ //# sourceMappingURL=GptModelCapabilityException.d.ts.map
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GptModelCapabilityException = void 0;
4
+ const DonobuException_1 = require("./DonobuException");
5
+ /**
6
+ * Thrown when a model exists but lacks required capabilities
7
+ * (e.g. completion, tools, or vision).
8
+ */
9
+ class GptModelCapabilityException extends DonobuException_1.DonobuException {
10
+ constructor(platform, gptModel, missingCapabilities) {
11
+ super(`The model '${gptModel}' on '${platform}' is missing required capabilities: ${missingCapabilities.join(', ')}. ` +
12
+ `Please choose a model that supports completion, tools, and vision.`);
13
+ this.platform = platform;
14
+ this.gptModel = gptModel;
15
+ this.missingCapabilities = missingCapabilities;
16
+ }
17
+ }
18
+ exports.GptModelCapabilityException = GptModelCapabilityException;
19
+ //# sourceMappingURL=GptModelCapabilityException.js.map
@@ -9,6 +9,7 @@ const v4_1 = require("zod/v4");
9
9
  const AgentsApi_1 = require("../apis/AgentsApi");
10
10
  const AskAiApi_1 = require("../apis/AskAiApi");
11
11
  const EnvDataApi_1 = require("../apis/EnvDataApi");
12
+ const FlowsAiQueriesApi_1 = require("../apis/FlowsAiQueriesApi");
12
13
  const FlowsApi_1 = require("../apis/FlowsApi");
13
14
  const FlowsFilesApi_1 = require("../apis/FlowsFilesApi");
14
15
  const FlowsToolCallsApi_1 = require("../apis/FlowsToolCallsApi");
@@ -237,6 +238,7 @@ class AdminApiController {
237
238
  flowsApi: new FlowsApi_1.FlowsApi(donobuStack.flowsManager),
238
239
  flowsFilesApi: new FlowsFilesApi_1.FlowsFilesApi(donobuStack.flowsPersistenceRegistry),
239
240
  flowsToolCallsApi: new FlowsToolCallsApi_1.FlowsToolCallsApi(donobuStack.flowsManager),
241
+ flowsAiQueriesApi: new FlowsAiQueriesApi_1.FlowsAiQueriesApi(donobuStack.flowsManager),
240
242
  pingApi: new PingApi_1.PingApi(),
241
243
  schemaApi: new SchemaApi_1.SchemaApi(),
242
244
  targetsApi: new TargetsApi_1.TargetsApi(donobuStack.targetRuntimePlugins),
@@ -308,6 +310,7 @@ class AdminApiController {
308
310
  app.get('/api/flows/:flowId/video', this.asyncHandler(apis.flowsFilesApi.getFlowVideo.bind(apis.flowsFilesApi)));
309
311
  app.get('/api/flows/:flowId/tool-calls', this.asyncHandler(apis.flowsToolCallsApi.getToolCalls.bind(apis.flowsToolCallsApi)));
310
312
  app.get('/api/flows/:flowId/tool-calls/:toolCallId', this.asyncHandler(apis.flowsToolCallsApi.getToolCall.bind(apis.flowsToolCallsApi)));
313
+ app.get('/api/flows/:flowId/ai-queries', this.asyncHandler(apis.flowsAiQueriesApi.getAiQueries.bind(apis.flowsAiQueriesApi)));
311
314
  app.get('/api/flows/:flowId/logs', this.asyncHandler(apis.flowsApi.getFlowLogs.bind(apis.flowsApi)));
312
315
  }
313
316
  /**
@@ -1,5 +1,6 @@
1
1
  import type { z } from 'zod/v4';
2
2
  import type { GptClient } from '../clients/GptClient';
3
+ import type { AiQuery } from '../models/AiQuery';
3
4
  import type { ControlPanel } from '../models/ControlPanel';
4
5
  import type { FlowMetadata } from '../models/FlowMetadata';
5
6
  import type { GptMessage, StructuredOutputMessage, TextItem } from '../models/GptMessage';
@@ -40,6 +41,7 @@ export declare class DonobuFlow {
40
41
  private static readonly MAIN_MESSAGE_ELEMENT_LIST_MARKER;
41
42
  static readonly USER_INTERRUPT_MARKER = "[User interruption while flow was paused, this MUST be acknowledged]";
42
43
  inProgressToolCall: ToolCall | null;
44
+ readonly aiQueries: AiQuery[];
43
45
  constructor(flowsManager: DonobuFlowsManager, envData: Record<string, string>, persistence: FlowsPersistence, gptClient: GptClient | null, toolManager: ToolManager, interactionVisualizer: InteractionVisualizer, proposedToolCalls: ProposedToolCall[], invokedToolCalls: ToolCall[], gptMessages: GptMessage[], targetInspector: TargetInspector, metadata: FlowMetadata, controlPanel: ControlPanel);
44
46
  /**
45
47
  * Drives the entire Donobu flow state-machine until it reaches a
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DonobuFlow = void 0;
4
4
  exports.extractFromPage = extractFromPage;
5
+ const crypto_1 = require("crypto");
5
6
  const GptPlatformInternalErrorException_1 = require("../exceptions/GptPlatformInternalErrorException");
6
7
  const UserInterruptException_1 = require("../exceptions/UserInterruptException");
7
8
  const FlowMetadata_1 = require("../models/FlowMetadata");
@@ -104,6 +105,7 @@ class DonobuFlow {
104
105
  this.metadata = metadata;
105
106
  this.controlPanel = controlPanel;
106
107
  this.inProgressToolCall = null;
108
+ this.aiQueries = [];
107
109
  }
108
110
  /**
109
111
  * Drives the entire Donobu flow state-machine until it reaches a
@@ -909,18 +911,42 @@ Message: ${dialog.message()}`;
909
911
  }
910
912
  async queryGptForProposedToolCalls() {
911
913
  this.targetInspector.checkConnectedOrThrow();
914
+ // Initialise the AI query record immediately so the error handler always
915
+ // has a record to update — no conditional check needed.
916
+ let aiQuery = {
917
+ id: (0, crypto_1.randomUUID)(),
918
+ cleanScreenshotId: null,
919
+ annotatedScreenshotId: null,
920
+ interactableElements: null,
921
+ error: null,
922
+ startedAt: Date.now(),
923
+ completedAt: null,
924
+ };
925
+ this.aiQueries.push(aiQuery);
912
926
  try {
913
927
  // Discover and mark all interactable elements on the current screen/page.
914
928
  await this.targetInspector.attributeInteractableElements();
915
929
  // Capture clean and annotated screenshots. Each inspector implementation
916
930
  // handles the platform-specific details (DOM injection vs server-side compositing).
917
931
  const screenshotBytes = await this.targetInspector.takeCleanScreenshot();
918
- await this.persistence.saveScreenShot(this.metadata.id, screenshotBytes);
932
+ const cleanScreenshotId = await this.persistence.saveScreenShot(this.metadata.id, screenshotBytes);
919
933
  await this.targetInspector.annotateInteractableElements();
920
934
  const annotatedScreenShotBytes = await this.targetInspector.takeAnnotatedScreenshot();
921
935
  await this.targetInspector.removeAnnotations();
922
- await this.persistence.saveScreenShot(this.metadata.id, annotatedScreenShotBytes);
936
+ const annotatedScreenshotId = await this.persistence.saveScreenShot(this.metadata.id, annotatedScreenShotBytes);
923
937
  const interactableElements = await this.targetInspector.getAttributedInteractableElements();
938
+ // Fill in the remaining fields and persist so the frontend can display
939
+ // the record immediately.
940
+ aiQuery = {
941
+ ...aiQuery,
942
+ cleanScreenshotId,
943
+ annotatedScreenshotId,
944
+ interactableElements,
945
+ };
946
+ this.aiQueries[this.aiQueries.length - 1] = aiQuery;
947
+ await this.persistence
948
+ .setAiQuery(this.metadata.id, aiQuery)
949
+ .catch((err) => Logger_1.appLogger.error('Failed to persist AI query record', err));
924
950
  const mainMessage = DonobuFlow.createMainUserMessage(this.targetInspector, interactableElements);
925
951
  // Give the LLM both the pre and post annotated screenshots. It can
926
952
  // use the clean screenshot to decide what it wants to do, then map it to
@@ -945,9 +971,20 @@ Message: ${dialog.message()}`;
945
971
  }));
946
972
  Logger_1.appLogger.debug('LLM response:', JsonUtils_1.JsonUtils.objectToJson(proposedToolCallsMessage));
947
973
  MiscUtils_1.MiscUtils.updateTokenCounts(proposedToolCallsMessage, this.metadata);
974
+ aiQuery = { ...aiQuery, completedAt: Date.now() };
975
+ this.aiQueries[this.aiQueries.length - 1] = aiQuery;
976
+ await this.persistence
977
+ .setAiQuery(this.metadata.id, aiQuery)
978
+ .catch((err) => Logger_1.appLogger.error('Failed to persist AI query completion', err));
948
979
  return proposedToolCallsMessage;
949
980
  }
950
981
  catch (error) {
982
+ const errorMessage = error instanceof Error ? error.message : String(error);
983
+ aiQuery = { ...aiQuery, error: errorMessage, completedAt: Date.now() };
984
+ this.aiQueries[this.aiQueries.length - 1] = aiQuery;
985
+ await this.persistence
986
+ .setAiQuery(this.metadata.id, aiQuery)
987
+ .catch((err) => Logger_1.appLogger.error('Failed to persist AI query error', err));
951
988
  if (this.targetInspector.isTargetClosedError(error)) {
952
989
  this.targetInspector.checkConnectedOrThrow();
953
990
  }
@@ -5,6 +5,7 @@ import type { GptClient } from '../clients/GptClient';
5
5
  import type { GptClientFactory } from '../clients/GptClientFactory';
6
6
  import type { GeneratedProject } from '../codegen/CodeGenerator';
7
7
  import type { env } from '../envVars';
8
+ import type { AiQuery } from '../models/AiQuery';
8
9
  import type { BrowserStateReference } from '../models/BrowserStateFlowReference';
9
10
  import type { BrowserStorageState } from '../models/BrowserStorageState';
10
11
  import type { CodeGenerationOptions } from '../models/CodeGenerationOptions';
@@ -109,6 +110,8 @@ export declare class DonobuFlowsManager {
109
110
  getFlowByName(flowName: string): Promise<FlowMetadata>;
110
111
  /** Returns all the tool calls made by the given flow by ID. */
111
112
  getToolCalls(flowId: string): Promise<ToolCall[]>;
113
+ /** Returns all AI query records for the given flow by ID. */
114
+ getAiQueries(flowId: string): Promise<AiQuery[]>;
112
115
  /**
113
116
  * Attempts to delete a flow by ID. If the flow is active, then
114
117
  * `CannotDeleteRunningFlowException` is thrown. If the flow is not active,
@@ -374,6 +374,10 @@ class DonobuFlowsManager {
374
374
  async getToolCalls(flowId) {
375
375
  return this.flowCatalog.getToolCalls(flowId);
376
376
  }
377
+ /** Returns all AI query records for the given flow by ID. */
378
+ async getAiQueries(flowId) {
379
+ return this.flowCatalog.getAiQueries(flowId);
380
+ }
377
381
  /**
378
382
  * Attempts to delete a flow by ID. If the flow is active, then
379
383
  * `CannotDeleteRunningFlowException` is thrown. If the flow is not active,
@@ -1,3 +1,4 @@
1
+ import type { AiQuery } from '../models/AiQuery';
1
2
  import type { BrowserStorageState } from '../models/BrowserStorageState';
2
3
  import type { DonobuDeploymentEnvironment } from '../models/DonobuDeploymentEnvironment';
3
4
  import type { FlowMetadata, FlowsQuery } from '../models/FlowMetadata';
@@ -21,6 +22,7 @@ export declare class FlowCatalog {
21
22
  getFlowById(flowId: string): Promise<FlowMetadata>;
22
23
  getFlowByName(flowName: string): Promise<FlowMetadata>;
23
24
  getToolCalls(flowId: string): Promise<ToolCall[]>;
25
+ getAiQueries(flowId: string): Promise<AiQuery[]>;
24
26
  deleteFlow(flowId: string): Promise<void>;
25
27
  getBrowserState(flowId: string): Promise<BrowserStorageState | null>;
26
28
  getFlows(query: FlowsQuery): Promise<PaginatedResult<FlowMetadata>>;
@@ -71,6 +71,25 @@ class FlowCatalog {
71
71
  }
72
72
  throw FlowNotFoundException_1.FlowNotFoundException.forId(flowId);
73
73
  }
74
+ async getAiQueries(flowId) {
75
+ if (this.isLocal()) {
76
+ const activeHandle = this.flowRuntime.get(flowId);
77
+ if (activeHandle) {
78
+ return [...activeHandle.donobuFlow.aiQueries].sort((a, b) => a.startedAt - b.startedAt);
79
+ }
80
+ }
81
+ for (const persistence of await this.flowsPersistenceRegistry.getAll()) {
82
+ try {
83
+ return await persistence.getAiQueries(flowId);
84
+ }
85
+ catch (error) {
86
+ if (!(error instanceof FlowNotFoundException_1.FlowNotFoundException)) {
87
+ throw error;
88
+ }
89
+ }
90
+ }
91
+ throw FlowNotFoundException_1.FlowNotFoundException.forId(flowId);
92
+ }
74
93
  async deleteFlow(flowId) {
75
94
  for (const persistence of await this.flowsPersistenceRegistry.getAll()) {
76
95
  await persistence.deleteFlow(flowId);
@@ -0,0 +1,42 @@
1
+ import type { InteractableElement } from './InteractableElement';
2
+ /**
3
+ * Represents a single AI decision cycle within a flow. Created at the start of
4
+ * a query cycle with just `id` and `queriedAt`, then progressively filled in as
5
+ * screenshots are captured and elements are discovered. The `error` field is
6
+ * populated only if the query fails.
7
+ */
8
+ export type AiQuery = {
9
+ /**
10
+ * Unique identifier for this AI query.
11
+ */
12
+ readonly id: string;
13
+ /**
14
+ * The ID of the clean (un-annotated) screenshot of the page at query time.
15
+ * Null until the screenshot is captured.
16
+ */
17
+ readonly cleanScreenshotId: string | null;
18
+ /**
19
+ * The ID of the annotated screenshot (with numbered element badges) sent to the AI.
20
+ * Null until the screenshot is captured.
21
+ */
22
+ readonly annotatedScreenshotId: string | null;
23
+ /**
24
+ * The interactable elements that were identified and sent to the AI.
25
+ * Null until element discovery completes.
26
+ */
27
+ readonly interactableElements: InteractableElement[] | null;
28
+ /**
29
+ * If the AI query failed, the error message. Null on success.
30
+ */
31
+ readonly error: string | null;
32
+ /**
33
+ * The Unix epoch millisecond timestamp of when the query was initiated.
34
+ */
35
+ readonly startedAt: number;
36
+ /**
37
+ * The Unix epoch millisecond timestamp of when the query completed (success
38
+ * or failure). Null while the query is still in progress.
39
+ */
40
+ readonly completedAt: number | null;
41
+ };
42
+ //# sourceMappingURL=AiQuery.d.ts.map
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ //# sourceMappingURL=AiQuery.js.map
@@ -395,6 +395,23 @@ CREATE INDEX IF NOT EXISTS idx_test_metadata_suite_id ON test_metadata(suite_id)
395
395
 
396
396
  ALTER TABLE flow_metadata ADD COLUMN test_id TEXT NULL REFERENCES test_metadata(id) ON DELETE CASCADE;
397
397
  CREATE INDEX IF NOT EXISTS idx_flow_metadata_test_id ON flow_metadata(test_id);
398
+ `);
399
+ },
400
+ },
401
+ {
402
+ // Create the ai_queries table to store AI decision-cycle records (clean
403
+ // and annotated screenshots, interactable elements, optional error).
404
+ version: 11,
405
+ up: (db) => {
406
+ db.exec(`
407
+ CREATE TABLE IF NOT EXISTS ai_queries (
408
+ id TEXT PRIMARY KEY,
409
+ flow_id TEXT NOT NULL,
410
+ started_at INTEGER NOT NULL,
411
+ ai_query TEXT NOT NULL,
412
+ FOREIGN KEY (flow_id) REFERENCES flow_metadata(id) ON DELETE CASCADE
413
+ );
414
+ CREATE INDEX IF NOT EXISTS idx_ai_queries_flow_id_started_at ON ai_queries(flow_id, started_at);
398
415
  `);
399
416
  },
400
417
  },
@@ -1,3 +1,4 @@
1
+ import type { AiQuery } from '../../models/AiQuery';
1
2
  import type { BrowserStorageState } from '../../models/BrowserStorageState';
2
3
  import type { FlowMetadata, FlowsQuery } from '../../models/FlowMetadata';
3
4
  import type { PaginatedResult } from '../../models/PaginatedResult';
@@ -50,6 +51,15 @@ export interface FlowsPersistence {
50
51
  * Delete a persisted tool call from a specific flow.
51
52
  */
52
53
  deleteToolCall(flowId: string, toolCallId: string): Promise<void>;
54
+ /**
55
+ * Save an AI query record for a specific flow.
56
+ */
57
+ setAiQuery(flowId: string, aiQuery: AiQuery): Promise<void>;
58
+ /**
59
+ * Load all AI query records for a specific flow, ordered by queriedAt.
60
+ * @throws FlowNotFoundException if the flow is not found
61
+ */
62
+ getAiQueries(flowId: string): Promise<AiQuery[]>;
53
63
  /**
54
64
  * Set video data for a specific flow.
55
65
  */
@@ -1,3 +1,4 @@
1
+ import type { AiQuery } from '../../models/AiQuery';
1
2
  import { type BrowserStorageState } from '../../models/BrowserStorageState';
2
3
  import type { FlowMetadata, FlowsQuery } from '../../models/FlowMetadata';
3
4
  import type { PaginatedResult } from '../../models/PaginatedResult';
@@ -28,6 +29,8 @@ export declare class FlowsPersistenceDonobuApi implements FlowsPersistence {
28
29
  setToolCall(flowId: string, toolCall: ToolCall): Promise<void>;
29
30
  getToolCalls(flowId: string): Promise<ToolCall[]>;
30
31
  deleteToolCall(flowId: string, toolCallId: string): Promise<void>;
32
+ setAiQuery(flowId: string, aiQuery: AiQuery): Promise<void>;
33
+ getAiQueries(flowId: string): Promise<AiQuery[]>;
31
34
  saveScreenShot(flowId: string, bytes: Buffer): Promise<string>;
32
35
  getScreenShot(flowId: string, screenShotId: string): Promise<Buffer | null>;
33
36
  setVideo(_flowId: string, _bytes: Buffer): Promise<void>;
@@ -134,6 +134,24 @@ class FlowsPersistenceDonobuApi {
134
134
  throw new Error(`Failed to delete tool call: ${response.status} ${response.statusText}`);
135
135
  }
136
136
  }
137
+ // -- AI Queries ----------------------------------------------------
138
+ async setAiQuery(flowId, aiQuery) {
139
+ const response = await this.jsonRequest(`/v1/flows/${encodeURIComponent(flowId)}/ai-queries/${encodeURIComponent(aiQuery.id)}`, 'PUT', { record: aiQuery });
140
+ if (!response.ok) {
141
+ throw new Error(`Failed to set AI query: ${response.status} ${response.statusText}`);
142
+ }
143
+ }
144
+ async getAiQueries(flowId) {
145
+ const response = await this.jsonRequest(`/v1/flows/${encodeURIComponent(flowId)}/ai-queries`, 'GET');
146
+ if (response.status === 404) {
147
+ throw FlowNotFoundException_1.FlowNotFoundException.forId(flowId);
148
+ }
149
+ if (!response.ok) {
150
+ throw new Error(`Failed to get AI queries: ${response.status} ${response.statusText}`);
151
+ }
152
+ const body = (await response.json());
153
+ return body.items;
154
+ }
137
155
  // -- Screenshots ---------------------------------------------------
138
156
  async saveScreenShot(flowId, bytes) {
139
157
  const imageType = MiscUtils_1.MiscUtils.detectImageType(bytes);
@@ -1,4 +1,5 @@
1
1
  import type Database from 'better-sqlite3';
2
+ import type { AiQuery } from '../../models/AiQuery';
2
3
  import { type BrowserStorageState } from '../../models/BrowserStorageState';
3
4
  import type { FlowMetadata, FlowsQuery } from '../../models/FlowMetadata';
4
5
  import type { PaginatedResult } from '../../models/PaginatedResult';
@@ -40,6 +41,8 @@ export declare class FlowsPersistenceSqlite implements FlowsPersistence {
40
41
  setToolCall(flowId: string, toolCall: ToolCall): Promise<void>;
41
42
  getToolCalls(flowId: string): Promise<ToolCall[]>;
42
43
  deleteToolCall(flowId: string, toolCallId: string): Promise<void>;
44
+ setAiQuery(flowId: string, aiQuery: AiQuery): Promise<void>;
45
+ getAiQueries(flowId: string): Promise<AiQuery[]>;
43
46
  setVideo(flowId: string, bytes: Buffer): Promise<void>;
44
47
  getVideoSegment(flowId: string, startOffset: number, length: number): Promise<VideoSegment | null>;
45
48
  getFlowFile(flowId: string, fileId: string): Promise<Buffer | null>;
@@ -212,6 +212,16 @@ class FlowsPersistenceSqlite {
212
212
  const stmt = this.db.prepare('DELETE FROM tool_calls WHERE flow_id = ? AND id = ?');
213
213
  stmt.run(flowId, toolCallId);
214
214
  }
215
+ async setAiQuery(flowId, aiQuery) {
216
+ const stmt = this.db.prepare('INSERT OR REPLACE INTO ai_queries (id, flow_id, started_at, ai_query) VALUES (?, ?, ?, ?)');
217
+ stmt.run(aiQuery.id, flowId, aiQuery.startedAt, JSON.stringify(aiQuery));
218
+ }
219
+ async getAiQueries(flowId) {
220
+ await this.getFlowMetadataById(flowId);
221
+ const stmt = this.db.prepare('SELECT ai_query FROM ai_queries WHERE flow_id = ? ORDER BY started_at');
222
+ const rows = stmt.all(flowId);
223
+ return rows.map((row) => JSON.parse(row.ai_query));
224
+ }
215
225
  async setVideo(flowId, bytes) {
216
226
  // Ensure flow exists.
217
227
  await this.getFlowMetadataById(flowId);
@@ -1,3 +1,4 @@
1
+ import type { AiQuery } from '../../models/AiQuery';
1
2
  import type { BrowserStorageState } from '../../models/BrowserStorageState';
2
3
  import type { FlowMetadata, FlowsQuery } from '../../models/FlowMetadata';
3
4
  import type { PaginatedResult } from '../../models/PaginatedResult';
@@ -14,7 +15,8 @@ export declare class FlowsPersistenceVolatile implements FlowsPersistence {
14
15
  private readonly videos;
15
16
  private readonly files;
16
17
  private readonly browserStates;
17
- constructor(flows?: Map<string, FlowMetadata>, screenshots?: Map<string, Map<string, Buffer>>, toolCalls?: Map<string, ToolCall[]>, videos?: Map<string, Buffer>, files?: Map<string, Map<string, Buffer>>, browserStates?: Map<string, BrowserStorageState>);
18
+ private readonly aiQueries;
19
+ constructor(flows?: Map<string, FlowMetadata>, screenshots?: Map<string, Map<string, Buffer>>, toolCalls?: Map<string, ToolCall[]>, videos?: Map<string, Buffer>, files?: Map<string, Map<string, Buffer>>, browserStates?: Map<string, BrowserStorageState>, aiQueries?: Map<string, AiQuery[]>);
18
20
  setFlowMetadata(flowMetadata: FlowMetadata): Promise<void>;
19
21
  getFlowMetadataById(flowId: string): Promise<FlowMetadata>;
20
22
  getFlowMetadataByName(flowName: string): Promise<FlowMetadata>;
@@ -27,6 +29,8 @@ export declare class FlowsPersistenceVolatile implements FlowsPersistence {
27
29
  setToolCall(flowId: string, toolCall: ToolCall): Promise<void>;
28
30
  getToolCalls(flowId: string): Promise<ToolCall[]>;
29
31
  deleteToolCall(flowId: string, toolCallId: string): Promise<void>;
32
+ setAiQuery(flowId: string, aiQuery: AiQuery): Promise<void>;
33
+ getAiQueries(flowId: string): Promise<AiQuery[]>;
30
34
  setVideo(flowId: string, bytes: Buffer): Promise<void>;
31
35
  getVideoSegment(flowId: string, startOffset: number, length: number): Promise<VideoSegment | null>;
32
36
  getFlowFile(flowId: string, fileId: string): Promise<Buffer | null>;
@@ -7,13 +7,14 @@ const MiscUtils_1 = require("../../utils/MiscUtils");
7
7
  * A volatile (in-memory) implementation of FlowsPersistence.
8
8
  */
9
9
  class FlowsPersistenceVolatile {
10
- constructor(flows = new Map(), screenshots = new Map(), toolCalls = new Map(), videos = new Map(), files = new Map(), browserStates = new Map()) {
10
+ constructor(flows = new Map(), screenshots = new Map(), toolCalls = new Map(), videos = new Map(), files = new Map(), browserStates = new Map(), aiQueries = new Map()) {
11
11
  this.flows = flows;
12
12
  this.screenshots = screenshots;
13
13
  this.toolCalls = toolCalls;
14
14
  this.videos = videos;
15
15
  this.files = files;
16
16
  this.browserStates = browserStates;
17
+ this.aiQueries = aiQueries;
17
18
  }
18
19
  async setFlowMetadata(flowMetadata) {
19
20
  this.flows.set(flowMetadata.id, { ...flowMetadata });
@@ -126,6 +127,26 @@ class FlowsPersistenceVolatile {
126
127
  this.toolCalls.delete(flowId);
127
128
  }
128
129
  }
130
+ async setAiQuery(flowId, aiQuery) {
131
+ if (!this.aiQueries.has(flowId)) {
132
+ this.aiQueries.set(flowId, []);
133
+ }
134
+ const queries = this.aiQueries.get(flowId);
135
+ const existingIndex = queries.findIndex((q) => q.id === aiQuery.id);
136
+ if (existingIndex >= 0) {
137
+ queries[existingIndex] = { ...aiQuery };
138
+ }
139
+ else {
140
+ queries.push({ ...aiQuery });
141
+ }
142
+ }
143
+ async getAiQueries(flowId) {
144
+ if (!this.flows.has(flowId)) {
145
+ throw FlowNotFoundException_1.FlowNotFoundException.forId(flowId);
146
+ }
147
+ const queries = this.aiQueries.get(flowId) || [];
148
+ return [...queries].sort((a, b) => a.startedAt - b.startedAt);
149
+ }
129
150
  async setVideo(flowId, bytes) {
130
151
  this.videos.set(flowId, Buffer.from(bytes));
131
152
  }
@@ -167,6 +188,7 @@ class FlowsPersistenceVolatile {
167
188
  this.flows.delete(flowId);
168
189
  this.screenshots.delete(flowId);
169
190
  this.toolCalls.delete(flowId);
191
+ this.aiQueries.delete(flowId);
170
192
  this.videos.delete(flowId);
171
193
  this.files.delete(flowId);
172
194
  this.browserStates.delete(flowId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "donobu",
3
- "version": "5.20.0",
3
+ "version": "5.21.0",
4
4
  "description": "Create browser automations with an LLM agent and replay them as Playwright scripts.",
5
5
  "main": "dist/main.js",
6
6
  "module": "dist/esm/main.js",