@k2b/cloud 0.27.0 → 0.28.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 (79) hide show
  1. package/package.json +2 -2
  2. package/src/ai/browser-code-contracts.ts +33 -63
  3. package/src/ai/browser.ts +1 -0
  4. package/src/ai/chat/blocks.tsx +16 -7
  5. package/src/ai/chat/live-turn.browser-harness.tsx +6 -3
  6. package/src/ai/chat/message-actions.tsx +123 -101
  7. package/src/ai/chat/message-utils.ts +30 -1
  8. package/src/ai/chat/messages.ts +198 -0
  9. package/src/ai/chat/presentation.tsx +66 -20
  10. package/src/ai/chat/tool-groups.ts +1 -1
  11. package/src/ai/chat/turn-error.ts +21 -0
  12. package/src/ai/chat/turn-view.tsx +55 -4
  13. package/src/ai/chat/user-message.tsx +19 -14
  14. package/src/ai/chat/visual-tools.tsx +1 -1
  15. package/src/ai/client/controller.ts +23 -6
  16. package/src/ai/code-mode-skill.ts +21 -25
  17. package/src/ai/code-runtime-tools.ts +5 -1
  18. package/src/ai/code-source-contracts.ts +2 -2
  19. package/src/ai/data-analysis-skill.ts +3 -3
  20. package/src/ai/default-tools.ts +15 -16
  21. package/src/ai/executor.ts +135 -59
  22. package/src/ai/index.ts +2 -0
  23. package/src/ai/open-tool-calls.ts +87 -0
  24. package/src/ai/routes.ts +6 -0
  25. package/src/ai/run-timeout.ts +4 -5
  26. package/src/ai/runtime.ts +8 -0
  27. package/src/ai/skill-seeds.ts +4 -4
  28. package/src/ai/store.ts +46 -14
  29. package/src/ai/system-prompt.ts +4 -21
  30. package/src/ai/turn-failure.ts +100 -0
  31. package/src/ai/turn-policy.ts +2 -1
  32. package/src/ai/types.ts +28 -1
  33. package/src/api/admin-outgoing-mail.ts +185 -5
  34. package/src/browser/FileChooser.tsx +11 -0
  35. package/src/browser/file-chooser-messages.ts +2 -0
  36. package/src/cli/admin/index.ts +3 -1
  37. package/src/cli/admin/notifications.ts +6 -0
  38. package/src/cli/admin/outgoing-mail.ts +104 -7
  39. package/src/contracts/outgoing-mail.ts +189 -1
  40. package/src/services/help/store.ts +2 -1
  41. package/src/services/index.ts +11 -1
  42. package/src/services/notifications/batches.ts +139 -79
  43. package/src/services/notifications/channels.ts +33 -13
  44. package/src/services/notifications/dispatcher.ts +57 -5
  45. package/src/services/notifications/email-frame.fixture.html +51 -0
  46. package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
  47. package/src/services/notifications/email-mail.ts +101 -0
  48. package/src/services/notifications/index.ts +49 -36
  49. package/src/services/notifications/observability.ts +9 -1
  50. package/src/services/notifications/platform.ts +1 -1
  51. package/src/services/notifications/runtime.ts +9 -3
  52. package/src/services/outgoing-mail/admin.ts +84 -0
  53. package/src/services/outgoing-mail/attachments.ts +135 -0
  54. package/src/services/outgoing-mail/bulk.ts +11 -0
  55. package/src/services/outgoing-mail/dispatcher.ts +231 -0
  56. package/src/services/outgoing-mail/drain.ts +60 -0
  57. package/src/services/outgoing-mail/enqueue.ts +180 -0
  58. package/src/services/outgoing-mail/index.ts +103 -10
  59. package/src/services/outgoing-mail/message.ts +14 -0
  60. package/src/services/outgoing-mail/messages.ts +431 -0
  61. package/src/services/outgoing-mail/retention.ts +48 -0
  62. package/src/services/outgoing-mail/runtime.ts +72 -0
  63. package/src/services/outgoing-mail/send.ts +130 -0
  64. package/src/services/outgoing-mail/store.ts +115 -4
  65. package/src/services/outgoing-mail/sync.ts +39 -0
  66. package/src/services/outgoing-mail/transport.ts +5 -1
  67. package/src/services/pdf/markdown.ts +22 -4
  68. package/src/services/postgres.ts +15 -0
  69. package/src/services/settings/core-settings.ts +18 -0
  70. package/src/shared/ai-platform-prompt.ts +48 -4
  71. package/src/shared/markdown/extensions/links.ts +28 -20
  72. package/src/shared/markdown/index.ts +14 -5
  73. package/src/shared/markdown/shared.ts +0 -7
  74. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  75. package/src/ssr/platform-messages.ts +1 -1
  76. package/src/styles/effects.css +15 -17
  77. package/src/styles/tokens.css +2 -0
  78. package/src/styles/utilities-markdown-editor.css +4 -39
  79. package/src/styles/utilities-markdown-table.css +14 -17
@@ -100,7 +100,11 @@ export const runManagedCodeTool =
100
100
  const headers = new Headers({ authorization: `Bearer ${signed.token}`, "content-type": "application/json" });
101
101
  headers.set(CODE_CAPABILITY_TOKEN_HEADER, callback.token);
102
102
  if (context.locale) headers.set(LOCALE_HEADER, context.locale);
103
- if (context.timeZone) headers.set("cookie", `${TIMEZONE_COOKIE}=${encodeURIComponent(context.timeZone)}`);
103
+ const timeZone = runConfig.timeZone ?? context.timeZone;
104
+ headers.set(
105
+ "cookie",
106
+ [`theme=${runConfig.theme ?? "light"}`, ...(timeZone ? [`${TIMEZONE_COOKIE}=${encodeURIComponent(timeZone)}`] : [])].join("; "),
107
+ );
104
108
  const response = await fetch(new URL(`/_internal/assistant/tools/${name}`, app.baseUrl), {
105
109
  method: "POST",
106
110
  headers,
@@ -41,7 +41,7 @@ const DatabaseSql = z.object({
41
41
 
42
42
  const CodeWriteInput = Id.extend({
43
43
  expectedRevision: z.number().int().positive(),
44
- entry: ArtifactPath.optional(),
44
+ entry: ArtifactPath.optional().describe("index.html for an app with an interface, or the script that code_run executes."),
45
45
  files: z
46
46
  .array(z.union([ArtifactFile, z.object({ path: ArtifactPath, fromFile: AiFileReference }).strict()]))
47
47
  .min(1)
@@ -302,7 +302,7 @@ export const CODE_SOURCE_TOOLS = {
302
302
  },
303
303
  code_create: {
304
304
  description:
305
- "Create a private reusable App with an optional UI, published actions and optional persistence. For one-off analysis, use code_run with code instead. Returns id and entry path; write source with code_write. Optional icon uses the complete class, e.g. ti ti-chart-bar; omit it when unsure. Does not run or share anything.",
305
+ "Create a private reusable App: an index.html interface with optional style.css and app.js, published actions and optional persistence. Starts with a minimal index.html. For one-off analysis, use code_run with code instead. Returns id and entry path; write source with code_write. Optional icon uses the complete class, e.g. ti ti-chart-bar; omit it when unsure. Does not run or share anything.",
306
306
  input: ArtifactCreate.pick({ title: true, description: true, icon: true }),
307
307
  },
308
308
  code_write: {
@@ -2,9 +2,9 @@
2
2
  import type { AiSkillTemplate } from "./skills";
3
3
  export const ASSISTANT_DATA_ANALYSIS_SKILL = {
4
4
  "key": "assistant:data-analysis",
5
- "version": 7,
5
+ "version": 8,
6
6
  "name": "assistant-data-analysis",
7
- "description": "Analyze source data, explain metrics and comparisons, and build evidence-backed reports or interactive dashboards in Assistant Code Mode. Use for multi-step analysis, data exploration, and dashboards; a simple chart only needs the Code Mode charts reference.",
8
- "instructions": "# Analyze data and deliver an inspectable result\n\nStart with the question the reader needs to answer. Choose a direct answer,\nan interactive visualization in this chat, an exported file, or a reusable Studio app accordingly.\nFor a one-time visual analysis, prefer a chat visualization; filters and buttons\ndo not by themselves require a Studio App.\nLoad `assistant-code-mode` for execution and read its `references/analytics.md`\nfor the built-in UI. Loading this skill does not install a library or grant access.\n\n## Keep a working plan\n\nFor work with several real steps, use `todo_write` to keep a short chat plan.\nReplace the full `todos` list each time; give each item a stable `id`, actionable\n`content`, and `status` (`pending`, `in_progress`, `completed`, or `cancelled`).\nAt most one step is active. Update as work changes, including user corrections;\nmark a step completed only after doing and checking it. Preserve exact commands\nwhen they matter. Skip this tool for a simple calculation or conversational reply.\n\nFor analysis, useful steps are reconcile source data, build the analysis, and\nverify totals, filters and conclusions. A rendered dashboard alone is not proof\nthat its numbers are correct. Keep blocked source work open and explain why.\n\n## Establish the data\n\nIdentify the actual source, unit of observation, time window, timezone, and\nlatest complete period. Read files or discover the relevant Cloud capabilities\nbefore selecting fields. External APIs use `cloud.http.fetch`; credentials are entered\nonly through the trusted `code_secret` dialog.\n\nInspect a bounded sample, missing values, duplicate keys, types, and coverage.\nReconcile totals and join cardinalities before drawing conclusions. Keep raw\nnumbers separate from display formatting. Distinguish zero from unavailable data;\nstate exclusions and denominator choices. Do not substitute fixture data for a\nblocked source unless the user requested a mockup.\n\nKeep the source query or transformation in the saved source or an accompanying\nfile so another run can reproduce the result. Preserve the source identity and\nretrieval timestamp, without credentials or credential-bearing URLs.\n\n## Build one consistent analysis\n\nDerive charts, metrics, and tables from the same reviewed data. Aggregate large\ninputs before crossing the UI bridge. Chart Explorer rows identify selectable\nentities with stable string keys; derived bins and groups need explicit mark\nmappings. Shared keys across charts mean the same entity, not equal values.\n\nChoose the simplest chart that answers the question. Use tables for exact\nrecords, bars for category comparisons, lines for ordered trends, and distributions\nfor spread. State units, comparison windows, and whether a percent is a fraction\nor an already scaled number. Do not imply causation from correlation.\n\nLead reports with the finding, then supporting evidence and limitations.\nLead dashboards with the important measurements, then trends and diagnostic\nbreakdowns. Use shared filters only when they affect all claimed views. Keep the\ninitial view useful without interaction. Avoid unrelated metrics added merely to\nfill a grid.\n\nAttach source context to Explorer data: `mode`, `asOf`, `sources`, and relevant\n`status`/`note`. A timestamp records when the data was retrieved; it does not prove\nthat the upstream source is complete. Label partial or fixture data visibly.\n\n## Validate before delivery\n\nRun the actual source with `code_run`. Inspect the result; use `code_interact`\nwith structured UI events to test filters, chart/table switching, selection,\nempty results, and recovery from a failed load. Reconcile displayed values with\nthe reviewed totals and check that filters describe the data actually displayed.\nA successful schema validation does not establish analytical correctness.\n\nValidate the actual presented source (the saved revision for Apps), rather than pasting its formulas into a\nsecond test script. Keep an independent expectation from the input data: row\ncounts, unmatched joins, totals and representative boundary cases. For targets,\nstate their grain (for example month × region) and aggregate each target once;\nmultiple selected regions must sum their distinct targets. Compare inspected raw\nKPI values and plotted series with independent expectations. A formatted value\nmatching after rounding does not validate the raw ratio. Never round fractions\nbefore passing them to percent-formatted controls. Preserve precision\nuntil display formatting. Test reset, one/multiple/all selections, empty results,\nand complete versus partial periods. A newly generated timestamp is not source\nfreshness: keep the real retrieval or file-snapshot timestamp stable.\n\nFor a Studio App data snapshot, export the validated dataset with `cloud.download` and\n`code_export`, then copy its exact path/version into the resource with\n`code_write({id,expectedRevision,files:[{path:\"data.json\",fromFile:reference}]})`.\nObtain the exact reference with `code_file_stat`; importing private files into\nApp source receives fresh review.\nImport that file in the app. Never rebuild a truncated dataset by copying tool\noutput. Keep transformations and source identity alongside the snapshot.\n\nHuman approval and uncertain HTTP outcomes follow the Code Mode HTTP contract.\nDo not replay an external mutation to refresh a chart. Separate local filtering\nfrom external loading; an Apply button can avoid a request for every slider move.\n\n## Save, share, and hand off\n\nFor chat visualizations, test `code_run` and relevant `code_interact` controls,\nthen deliver with `code_present({runId,title})`. A successful run alone is not\nvisible to the user. Retain source identity, reviewed input data and their real\nretrieval timestamp. Put writes and external reloads behind explicit buttons;\nopening an old result must not repeat earlier actions. The user can download the\ncurrent view as PDF/HTML or an individual chart as SVG.\n\nFor reusable Studio Apps, reuse one Cloud resource for later revisions of the same report or dashboard.\nSave its source, test that revision, and use the normal Code Mode publication\nworkflow when publication is requested. Publishing a version and granting reader\naccess are separate operations. Preserve existing access; a dashboard request\ndoes not authorize broadening it. Personal secrets are never copied to readers.\n\nA published source version is not automatically a frozen data snapshot. A frozen\nreport must retain the reviewed data explicitly in its authorized resource or\noutput file. A live app must implement its loader and display the retrieval time;\nloading once is not continuous monitoring. No background refresh exists unless\nimplemented through an appropriate supported workflow.\n\nPresent the chat visualization, resource link or exported file, the data timestamp, and material\ncoverage limitations. Say whether it is a snapshot or reloads from its sources.\nIf publication fails, retain the tested resource and report the failed stage;\ndo not claim success or create a different public destination. Sites-specific\nhosting, access policies, and editor storage are not part of this Cloud workflow.",
7
+ "description": "Analyze source data, explain metrics and comparisons, and build evidence-backed reports or HTML dashboards in Assistant Code Mode. Use for multi-step analysis, data exploration, and dashboards; a simple chart only needs the Code Mode charts reference.",
8
+ "instructions": "# Analyze data and deliver an inspectable result\n\nStart with the question the reader needs to answer. Choose a direct answer,\nan app shown in this chat, an exported file, or a reusable Studio app accordingly.\nFor a one-time visual analysis, prefer a chat app; filters and buttons do not by\nthemselves require a Studio App. Load `assistant-code-mode` for execution and read\nits `references/apps.md` and `references/charts.md` for interfaces and charts.\nLoading this skill does not install a library or grant access.\n\n## Keep a working plan\n\nFor work with several real steps, use `todo_write` to keep a short chat plan.\nReplace the full `todos` list each time; give each item a stable `id`, actionable\n`content`, and `status` (`pending`, `in_progress`, `completed`, or `cancelled`).\nAt most one step is active. Update as work changes, including user corrections;\nmark a step completed only after doing and checking it. Preserve exact commands\nwhen they matter. Skip this tool for a simple calculation or conversational reply.\n\nFor analysis, useful steps are reconcile source data, build the analysis, and\nverify totals, filters and conclusions. A rendered dashboard alone is not proof\nthat its numbers are correct. Keep blocked source work open and explain why.\n\n## Establish the data\n\nIdentify the actual source, unit of observation, time window, timezone, and\nlatest complete period. Read files or discover the relevant Cloud capabilities\nbefore selecting fields. External APIs use `cloud.http.fetch`; credentials are entered\nonly through the trusted `code_secret` dialog.\n\nInspect a bounded sample, missing values, duplicate keys, types, and coverage.\nReconcile totals and join cardinalities before drawing conclusions. Keep raw\nnumbers separate from display formatting. Distinguish zero from unavailable data;\nstate exclusions and denominator choices. Do not substitute fixture data for a\nblocked source unless the user requested a mockup.\n\nKeep the source query or transformation in the saved source or an accompanying\nfile so another run can reproduce the result. Preserve the source identity and\nretrieval timestamp, without credentials or credential-bearing URLs.\n\n## Build one consistent analysis\n\nDerive charts, metrics, and tables from the same reviewed data. Aggregate large\ninputs in a script before they reach an app; an app shows aggregates, not raw\nexports. Give exact values a table, for example in a `<details>` element under\nthe chart.\n\nChoose the simplest chart that answers the question. Use tables for exact\nrecords, bars for category comparisons, lines for ordered trends, and distributions\nfor spread. State units, comparison windows, and whether a percent is a fraction\nor an already scaled number. Do not imply causation from correlation.\n\nLead reports with the finding, then supporting evidence and limitations.\nLead dashboards with the important measurements, then trends and diagnostic\nbreakdowns. Use shared filters only when they affect all claimed views. Keep the\ninitial view useful without interaction. Avoid unrelated metrics added merely to\nfill a grid.\n\nShow the source context in the app: whether it is a snapshot, when the data was\nretrieved, its sources, and relevant notes. A timestamp records when the data\nwas retrieved; it does not prove that the upstream source is complete. Label\npartial or fixture data visibly.\n\n## Validate before delivery\n\nCompute in a script with `code_run` and inspect the result. Put the filter and\naggregation logic of an app into a module the script can import too\n(`lib/totals.js`), and test it there with filters, empty results and failed\nloads. Reconcile the values the app will show with the reviewed totals and check\nthat filters describe the data actually displayed. A successful schema\nvalidation does not establish analytical correctness. Apps are not rendered in\na test yet; read the static findings of `code_write` and `code_present`, and say\nwhat the person should check.\n\nValidate the logic the app actually uses, rather than pasting its formulas into a\nsecond test script. Keep an independent expectation from the input data: row\ncounts, unmatched joins, totals and representative boundary cases. For targets,\nstate their grain (for example month × region) and aggregate each target once;\nmultiple selected regions must sum their distinct targets. Compare inspected raw\nKPI values and plotted series with independent expectations. A formatted value\nmatching after rounding does not validate the raw ratio. Never round fractions before formatting them as percent. Preserve precision\nuntil display formatting. Test reset, one/multiple/all selections, empty results,\nand complete versus partial periods. A newly generated timestamp is not source\nfreshness: keep the real retrieval or file-snapshot timestamp stable.\n\nFor a Studio App data snapshot, export the validated dataset with `cloud.download` and\n`code_export`, then copy its exact path/version into the App's files with\n`code_file_copy` (see the Code Mode file transfers reference); importing private\nfiles into an App receives fresh review. Read it in the app with\n`JSON.parse(await (await cloud.files.read(\"data.json\")).text())`. Never rebuild a\ntruncated dataset by copying tool output. Keep transformations and source\nidentity alongside the snapshot.\n\nHuman approval and uncertain HTTP outcomes follow the Code Mode HTTP contract.\nDo not replay an external mutation to refresh a chart. Separate local filtering\nfrom external loading; an Apply button can avoid a request for every slider move.\n\n## Save, share, and hand off\n\nFor chat apps, test the numbers with `code_run`, then deliver with\n`code_present({title, files})`. A successful run alone is not visible to the\nuser. Retain source identity, reviewed input data and their real retrieval\ntimestamp. Put writes and external reloads behind explicit buttons; opening an\nold result must not repeat earlier actions. The user can download the current\nview as PDF or static HTML.\n\nFor reusable Studio Apps, reuse one Cloud resource for later revisions of the same report or dashboard.\nSave its source, test that revision, and use the normal Code Mode publication\nworkflow when publication is requested. Publishing a version and granting reader\naccess are separate operations. Preserve existing access; a dashboard request\ndoes not authorize broadening it. Personal secrets are never copied to readers.\n\nA published source version is not automatically a frozen data snapshot. A frozen\nreport must retain the reviewed data explicitly in its authorized resource or\noutput file. A live app must implement its loader and display the retrieval time;\nloading once is not continuous monitoring. No background refresh exists unless\nimplemented through an appropriate supported workflow.\n\nPresent the chat app, resource link or exported file, the data timestamp, and material\ncoverage limitations. Say whether it is a snapshot or reloads from its sources.\nIf publication fails, retain the tested resource and report the failed stage;\ndo not claim success or create a different public destination. Sites-specific\nhosting, access policies, and editor storage are not part of this Cloud workflow.",
9
9
  "extraFrontmatter": {}
10
10
  } satisfies AiSkillTemplate;
@@ -3,9 +3,9 @@ import { createCloudAiTranscribeAudioTool } from "./audio-tool";
3
3
  import {
4
4
  CODE_RUNTIME_TOOL_NAMES,
5
5
  CodeActionInput,
6
+ CodeCheckInput,
6
7
  CodeExportInput,
7
8
  CodeInspectInput,
8
- CodeInteractInput,
9
9
  CodeOpenInput,
10
10
  CodePresentInput,
11
11
  CodeRunInput,
@@ -110,7 +110,7 @@ export const createCloudAiCodeTools = () => [
110
110
  defineAiTool({
111
111
  name: "code_secret",
112
112
  description:
113
- "Open a trusted Secret input dialog. The user enters the value directly into encrypted Assistant storage; only configured/name returns. Never ask for a credential in chat, survey, app controls or code_interact. Personal secrets are scoped to this chat or resource and bound to the exact HTTPS origin, header and prefix. Use cloud.http.secret(name,{prefix}) in cloud.http.fetch headers; load assistant-code-mode for HTTP details. Web UI required for secret entry; stored secrets also work from CLI.",
113
+ "Open a trusted Secret input dialog. The user enters the value directly into encrypted Assistant storage; only configured/name returns. Never ask for a credential in chat, survey or app fields. Personal secrets are scoped to this chat or resource and bound to the exact HTTPS origin, header and prefix. Use cloud.http.secret(name,{prefix}) in cloud.http.fetch headers; load assistant-code-mode for HTTP details. Web UI required for secret entry; stored secrets also work from CLI.",
114
114
  inputSchema: CodeSecretInput,
115
115
  outputSchema: z.object({ configured: z.boolean(), name: z.string() }),
116
116
  approval: "never",
@@ -128,27 +128,18 @@ export const createCloudAiCodeTools = () => [
128
128
  promptHint:
129
129
  "For file analysis, data transformations or combining Cloud data, run a short one-off script with code_run. Load assistant-code-mode for its runtime APIs; no saved app is required.",
130
130
  description:
131
- "Run a saved resource id OR one-off code in the isolated worker. Optional resourceId binds one-off code to existing app data (Manage required), without editing its source. Includes cloud.capabilities.run(name,input) to chain Cloud capabilities in JavaScript; load assistant-code-mode for its runtime APIs. Scripts accept current chat inputPaths; app test runs use them only as explicit picker fixtures. Returns output, logs and UI state for agent inspection only; use code_present to show a one-off visualization to the user. Test runs use the app’s real shared and personal data; database writes and capability effects keep normal permissions and approvals.",
131
+ "Run one-off script code OR a saved script resource in the isolated worker. Optional resourceId binds one-off code to existing app data (Manage required), without editing its source. Includes cloud.capabilities.run(name,input) to chain Cloud capabilities in JavaScript; load assistant-code-mode for its runtime APIs. Scripts read explicit chat inputPaths. Returns output, logs and captured files for agent inspection only. Apps with an index.html interface do not run here: show them with code_open or code_present. Runs use real shared and personal data; database writes and capability effects keep normal permissions and approvals.",
132
132
  inputSchema: CodeRunInput,
133
133
  outputSchema: z.json(),
134
134
  approval: "never",
135
135
  }).server(runManagedCodeTool("code_run")),
136
136
  defineAiTool({
137
137
  name: "code_inspect",
138
- description:
139
- "Inspect a test run: errors, logs, output, controls and pending modal. Use nodeId to inspect table rows or list items; follow pagination only as needed.",
138
+ description: "Inspect a script run: status, progress, errors, logs, output and captured files. Use waitMs to wait for background work.",
140
139
  inputSchema: CodeInspectInput,
141
140
  outputSchema: z.json(),
142
141
  approval: "never",
143
142
  }).server(runManagedCodeTool("code_inspect")),
144
- defineAiTool({
145
- name: "code_interact",
146
- description:
147
- "Operate a control or answer a pending modal using its exact snapshot ID. Buttons need only id; controls use event:{type:change,value:...} or event:{type:select,key:...}. Use answer for modal replies, or answer:null to cancel. Copy an interactions example from code_inspect; never send JSON as a string. Use steps:[{id,event?},...] for up to three known sequential interactions; stops on errors, modals or background work. Returns one resulting state with completedSteps and nextStep.",
148
- inputSchema: CodeInteractInput,
149
- outputSchema: z.json(),
150
- approval: "never",
151
- }).server(runManagedCodeTool("code_interact")),
152
143
  defineAiTool({
153
144
  name: "code_stop",
154
145
  description: "Stop and release an isolated test run.",
@@ -158,15 +149,24 @@ export const createCloudAiCodeTools = () => [
158
149
  }).server(runManagedCodeTool("code_stop")),
159
150
  defineAiTool({
160
151
  name: "code_open",
161
- description: "Show the app beside the chat without starting it. For one-off calculations, return the result instead of opening an app.",
152
+ description:
153
+ "Show the app beside the chat without starting it; HTML apps require a passing code_check for the current files and tables. For one-off calculations, return the result instead of opening an app.",
162
154
  inputSchema: CodeOpenInput,
163
155
  outputSchema: z.json(),
164
156
  approval: "never",
165
157
  }).client(),
158
+ defineAiTool({
159
+ name: "code_check",
160
+ description:
161
+ "Mandatory HTML app self-test: write files and steps.json (at most 20 main-flow steps), call code_check({id} OR {files}), look at EVERY screenshot with view_image, fix and check again, then code_open/code_present/code_publish. Runs desktop 1280×800 and phone 390×844 in opposite themes on separate throwaway copies of database, shared KV/files and only your own KV.user. Steps run in both views; reload keeps the copy. Match accessible role/name, label or text exactly, then case-insensitively, then by substring; ambiguous targets fail with candidates, placeholders are never names. Fill numbers with a dot. Upload file is an app-relative source path or chat file path/name. AI runs for real; HTTP, capability effects and approvals are unavailable; read-only granted capabilities run except in background turns. Returns passed, content hash (files, steps and tables), height, issues, calls, captured downloads, three PNG chat paths and an untrusted aria tree (4 KiB). Passing means not broken: inspect screenshots for cut-off, red, tight or doubled content. Self-test is a workflow guard, not a security mechanism. Aborting closes pages and discards copies. Bounded to 45 seconds, 1000 copied rows/16 MiB (schema only beyond), 64 downloads/250 MiB total/50 MiB per file.",
162
+ inputSchema: CodeCheckInput,
163
+ outputSchema: z.json(),
164
+ approval: "never",
165
+ }).server(runManagedCodeTool("code_check")),
166
166
  defineAiTool({
167
167
  name: "code_present",
168
168
  description:
169
- "Present a successful one-off run as a persistent interactive visualization in this chat. Requires code_run with code, without resourceId or saved app id. Saves source, selected inputs and UI preview. Users can activate controls and download the current view. Test runs are not visible until this succeeds. For reusable apps use code_open instead.",
169
+ "Show an HTML app as a card in this chat after a passing code_check for exactly these files and table definitions. Pass files (index.html plus optional style.css and app.js) for a one-off app without saved data, or the id of a saved app to run it live with its data. The person starts the card with a click. Static problems such as CDN scripts, inline handlers or a missing index.html are rejected before anything is saved. For a reusable app beside the chat use code_open.",
170
170
  inputSchema: CodePresentInput,
171
171
  outputSchema: z.json(),
172
172
  approval: "never",
@@ -193,7 +193,6 @@ export const CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES = new Set<string>([
193
193
  "markdown_to_pdf",
194
194
  "html_to_pdf",
195
195
  "present",
196
- "calculate",
197
196
  "read_cloud_resource",
198
197
  ]);
199
198
 
@@ -18,6 +18,8 @@ import { isAssistantChatTurn } from "./assistant-models";
18
18
  import { CODE_RUNTIME_TOOL_NAMES } from "./browser-code-contracts";
19
19
  import { createAiToolResolver, createRunToolStore } from "./capabilities";
20
20
  import { AiCapabilityExecutionError, executeAiCapability, resolveAiCapabilityActor, reviewAiCapability } from "./capability-execution";
21
+ import { aiChatMessages } from "./chat/messages";
22
+ import { aiTurnErrorText } from "./chat/turn-error";
21
23
  import { aiChatTasks } from "./chat-tasks";
22
24
  import { createCloudCompactFn } from "./compaction";
23
25
  import { createCloudAiCodeTools, createCloudAiLocalBashTool, createConfiguredDefaultCloudAiTools } from "./default-tools";
@@ -26,6 +28,7 @@ import { aiMemories } from "./memories";
26
28
  import { createCloudAiMemoryTool } from "./memory-tool";
27
29
  import { recordAiMemoryWorkflowEvidence } from "./memory-workflow-evidence";
28
30
  import { aiModelAccess } from "./model-access";
31
+ import { answerOpenToolCalls } from "./open-tool-calls";
29
32
  import { type AiUserPrefs, aiActorUser, aiUserPrefs } from "./prefs";
30
33
  import { createCloudAiReadProjectKnowledgeTool, createCloudAiSearchProjectTool } from "./project-tool";
31
34
  import { aiProjects } from "./projects";
@@ -57,6 +60,14 @@ import { aiToolAudit } from "./tool-audit";
57
60
  import { acceptCanonicalToolNames } from "./tool-call-names";
58
61
  import { resolveAiToolResultMaxChars } from "./tool-result-budget";
59
62
  import { aiToolPromptHints, type PreparedAiTools, prepareAiTools } from "./tools";
63
+ import {
64
+ AiTurnFailure,
65
+ type AiTurnFailureInfo,
66
+ type AiTurnFailureReason,
67
+ aiTurnFailureFromThrown,
68
+ aiTurnReasonFromThrown,
69
+ rememberProviderErrors,
70
+ } from "./turn-failure";
60
71
  import { type AiTurnPolicyToolCall, applyAiTurnPolicy } from "./turn-policy";
61
72
  import { createTurnTimingRecorder, withDurableTurnTiming } from "./turn-timing";
62
73
  import type {
@@ -564,7 +575,7 @@ const materializeChatConfig = async (config: AiChatTurnRunConfig, signal: AbortS
564
575
  // ---------------------------------------------------------------------------
565
576
 
566
577
  type AttemptOutcome =
567
- | { kind: "finished"; status: "completed" | "failed" | "aborted"; error: string | null; timing?: LoopAggregate["timing"] }
578
+ | { kind: "finished"; status: "completed" | "failed" | "aborted"; failure: AiTurnFailureInfo | null; timing?: LoopAggregate["timing"] }
568
579
  | { kind: "suspended" };
569
580
 
570
581
  export class AiTurnExecutor {
@@ -587,7 +598,14 @@ export class AiTurnExecutor {
587
598
  pipeline.seedBaseline(claim.liveBlocks ?? []);
588
599
  await pipeline.emitTurnStarted(claim.turn.modelProfileId ?? "");
589
600
  await pipeline.emitBaseline();
590
- await this.finalize(conversationId, turnId, pipeline, "failed", "AI turn is missing its run configuration.", null);
601
+ await this.finalize(
602
+ conversationId,
603
+ turnId,
604
+ pipeline,
605
+ "failed",
606
+ { error: { code: "failed" }, detail: "AI turn is missing its run configuration." },
607
+ null,
608
+ );
591
609
  return;
592
610
  }
593
611
 
@@ -611,8 +629,9 @@ export class AiTurnExecutor {
611
629
  turnId,
612
630
  pipeline,
613
631
  "failed",
614
- error instanceof Error ? error.message : "AI turn state could not be loaded.",
632
+ aiTurnFailureFromThrown(error, "AI turn state could not be loaded."),
615
633
  "chat",
634
+ claim.runConfig?.kind === "chat" ? claim.runConfig.locale : undefined,
616
635
  );
617
636
  return;
618
637
  }
@@ -622,16 +641,39 @@ export class AiTurnExecutor {
622
641
  await this.runChat(conversationId, turnId, claim, runConfig, pipeline, signal, false, attemptState);
623
642
  }
624
643
 
644
+ /**
645
+ * Ends the turn. A failure stores its reason twice: as a code on the turn's last message, which the chat words in
646
+ * the reader's language, and as text in the turn's language for readers without the chat view. The raw cause, such
647
+ * as a provider's own message, goes only to the log. A failed compaction left the chat as it was, so its text names
648
+ * only the reason.
649
+ */
625
650
  private async finalize(
626
651
  conversationId: string,
627
652
  turnId: string,
628
653
  pipeline: StreamPipeline,
629
654
  status: "completed" | "failed" | "aborted",
630
- error: string | null,
655
+ failure: AiTurnFailureInfo | null,
631
656
  kind: AiTurnRunConfig["kind"] | null,
657
+ locale?: string,
632
658
  ) {
633
- if (status === "failed" && error) log.error("AI turn failed", { conversationId, turnId, error });
634
- const finalized = await aiConversations.completeTurn({ conversationId, turnId, status, error, leaseOwner: this.config.leaseOwner });
659
+ const failed = status === "failed" ? (failure ?? { error: { code: "failed" as const }, detail: "AI turn failed" }) : null;
660
+ let error: string | null = null;
661
+ if (failed) {
662
+ log.error("AI turn failed", { conversationId, turnId, code: failed.error.code, error: failed.detail });
663
+ error = failed.message ?? null;
664
+ if (!error) {
665
+ const textLocale = normalizeLocale(locale ?? (await coreSettings.get<string>("app.locale")));
666
+ error = kind === "compact" ? aiChatMessages(textLocale).turnErrorReason(failed.error) : aiTurnErrorText(failed.error, textLocale);
667
+ }
668
+ }
669
+ const finalized = await aiConversations.completeTurn({
670
+ conversationId,
671
+ turnId,
672
+ status,
673
+ error,
674
+ turnError: failed?.error ?? null,
675
+ leaseOwner: this.config.leaseOwner,
676
+ });
635
677
  if (finalized === "completed") await pipeline.emitTurnFinished(status, error);
636
678
  await pipeline.flush().catch(() => undefined);
637
679
  if (finalized === "completed" && this.config.onTurnFinalized) {
@@ -686,7 +728,7 @@ export class AiTurnExecutor {
686
728
  const subject = accessSubjectForActor(material.actor);
687
729
  const project = subject ? await aiProjects.getByShortId(config.project.id, subject, "read") : null;
688
730
  if (!project) {
689
- throw new Error("Project access is no longer available.");
731
+ throw new AiTurnFailure("not_allowed", "Project access is no longer available.");
690
732
  }
691
733
  resolvedProjectId = project.id;
692
734
  }
@@ -709,7 +751,15 @@ export class AiTurnExecutor {
709
751
  }
710
752
  } catch (error) {
711
753
  signal.removeEventListener("abort", onSignal);
712
- await this.finalize(conversationId, turnId, pipeline, "failed", error instanceof Error ? error.message : "AI turn failed", "chat");
754
+ await this.finalize(
755
+ conversationId,
756
+ turnId,
757
+ pipeline,
758
+ "failed",
759
+ aiTurnFailureFromThrown(error, "AI turn failed"),
760
+ "chat",
761
+ config.locale,
762
+ );
713
763
  return;
714
764
  }
715
765
  const { settings, resolved } = validated;
@@ -732,8 +782,12 @@ export class AiTurnExecutor {
732
782
  turnId,
733
783
  pipeline,
734
784
  "failed",
735
- error instanceof Error ? error.message : "Cloud capability actor resolution failed",
785
+ {
786
+ error: { code: "not_allowed" },
787
+ detail: error instanceof Error ? error.message : "Cloud capability actor resolution failed",
788
+ },
736
789
  "chat",
790
+ config.locale,
737
791
  );
738
792
  return;
739
793
  }
@@ -865,8 +919,9 @@ export class AiTurnExecutor {
865
919
  turnId,
866
920
  pipeline,
867
921
  "failed",
868
- error instanceof Error ? error.message : "Image preparation failed",
922
+ aiTurnFailureFromThrown(error, "Image preparation failed"),
869
923
  "chat",
924
+ promptLocale,
870
925
  );
871
926
  return;
872
927
  }
@@ -1088,10 +1143,18 @@ export class AiTurnExecutor {
1088
1143
  const priorToolRounds = toolRoundState(turnMessages);
1089
1144
  const quotaSubject = accessSubjectForActor(material.actor);
1090
1145
  const deadline = claim.turn.deadline ? Date.parse(claim.turn.deadline) : null;
1146
+ // The reason a failed turn names: the one the current model call or the turn's own checks gave.
1147
+ const failureReason: { current: AiTurnFailureReason | null } = { current: null };
1148
+ const remember = (reason: AiTurnFailureReason | null) => {
1149
+ failureReason.current = reason;
1150
+ };
1091
1151
  const turnPolicy = applyAiTurnPolicy({
1092
1152
  provider: acceptCanonicalToolNames(
1093
1153
  retryTransientProviderErrors(
1094
- assistantQuotaProvider(resolved.provider, config, quotaSubject, resolved.profile, turnId, conversationId),
1154
+ rememberProviderErrors(
1155
+ answerOpenToolCalls(assistantQuotaProvider(resolved.provider, config, quotaSubject, resolved.profile, turnId, conversationId)),
1156
+ remember,
1157
+ ),
1095
1158
  {
1096
1159
  deadline,
1097
1160
  delaysMs: this.config.providerRetryDelaysMs,
@@ -1136,7 +1199,14 @@ export class AiTurnExecutor {
1136
1199
  turnPolicy.noteSteering();
1137
1200
  return steers.map((steer) => steer.text);
1138
1201
  },
1139
- tools: turnPolicy.tools,
1202
+ tools: async () => {
1203
+ try {
1204
+ return await turnPolicy.tools();
1205
+ } catch (error) {
1206
+ remember(aiTurnReasonFromThrown(error));
1207
+ throw error;
1208
+ }
1209
+ },
1140
1210
  ...(turnPolicy.maxTurns === undefined ? {} : { maxTurns: turnPolicy.maxTurns }),
1141
1211
  temperature: resolved.profile.temperature,
1142
1212
  maxOutputTokens: resolved.profile.maxOutputTokens,
@@ -1178,6 +1248,7 @@ export class AiTurnExecutor {
1178
1248
  appliedSteers,
1179
1249
  noteToolRound: turnPolicy.noteToolRound,
1180
1250
  noteToolCall: turnPolicy.noteToolCall,
1251
+ failureReason,
1181
1252
  onBackgroundBlocked: (message) => {
1182
1253
  backgroundError = message;
1183
1254
  },
@@ -1202,15 +1273,17 @@ export class AiTurnExecutor {
1202
1273
  : null;
1203
1274
  if (timeout) {
1204
1275
  outcome.status = "failed";
1205
- outcome.error = timeout.messageFor(promptLocale);
1276
+ outcome.failure = { error: timeout.turnError(), detail: "Run time limit reached." };
1206
1277
  }
1207
1278
  const finalized = await this.finalize(
1208
1279
  conversationId,
1209
1280
  turnId,
1210
1281
  pipeline,
1211
1282
  backgroundError ? "failed" : outcome.status,
1212
- backgroundError ?? outcome.error,
1283
+ // A background run keeps Cloud's own words for what its mandate blocked.
1284
+ backgroundError ? { error: { code: "not_allowed" }, detail: backgroundError, message: backgroundError } : outcome.failure,
1213
1285
  "chat",
1286
+ promptLocale,
1214
1287
  );
1215
1288
  if (finalized === "pending_steering" && outcome.status === "completed" && !signal.aborted) {
1216
1289
  await this.runChat(conversationId, turnId, claim, config, pipeline, signal, true);
@@ -1244,6 +1317,12 @@ export class AiTurnExecutor {
1244
1317
  appliedSteers: AiTurnSteer[];
1245
1318
  noteToolRound: () => void;
1246
1319
  noteToolCall: (call: AiTurnPolicyToolCall) => void;
1320
+ /**
1321
+ * Why the turn would fail now, as a model call or the turn's own checks said. A new model call starts without one,
1322
+ * so a context overflow that compaction resolved never names a later failure. Only the model calls and checks set
1323
+ * it, in the order they run; the events here arrive later than that.
1324
+ */
1325
+ failureReason: { current: AiTurnFailureReason | null };
1247
1326
  onBackgroundBlocked?: (message: string) => void;
1248
1327
  }): Promise<AttemptOutcome> {
1249
1328
  const {
@@ -1260,6 +1339,7 @@ export class AiTurnExecutor {
1260
1339
  appliedSteers,
1261
1340
  noteToolRound,
1262
1341
  noteToolCall,
1342
+ failureReason,
1263
1343
  } = input;
1264
1344
  const stopHeartbeat = this.startHeartbeat(conversationId, turnId, abortController);
1265
1345
  let lastIssueMessage: string | null = null;
@@ -1346,25 +1426,24 @@ export class AiTurnExecutor {
1346
1426
  .catch(() => undefined);
1347
1427
  }
1348
1428
  const timing = aggregate.timing;
1349
- if (event.reason === "aborted") return { kind: "finished", status: "aborted", error: null, timing };
1350
- if (event.reason === "stop") return { kind: "finished", status: "completed", error: null, timing };
1351
- if (event.reason === "max_turns") {
1352
- return {
1353
- kind: "finished",
1354
- status: "failed",
1355
- error: "The model did not produce a final answer within its tool-round limit.",
1356
- timing,
1357
- };
1358
- }
1359
- return { kind: "finished", status: "failed", error: lastIssueMessage ?? `AI turn ended: ${event.reason}`, timing };
1429
+ if (event.reason === "aborted") return { kind: "finished", status: "aborted", failure: null, timing };
1430
+ if (event.reason === "stop") return { kind: "finished", status: "completed", failure: null, timing };
1431
+ const detail = lastIssueMessage ?? `AI turn ended: ${event.reason}`;
1432
+ const reason: AiTurnFailureReason =
1433
+ event.reason === "max_turns"
1434
+ ? { error: { code: "step_limit" } }
1435
+ : event.reason === "context_overflow"
1436
+ ? { error: { code: "context_full" } }
1437
+ : event.reason === "no_credits"
1438
+ ? { error: { code: "quota_exhausted" } }
1439
+ : (failureReason.current ?? { error: { code: "failed" } });
1440
+ return { kind: "finished", status: "failed", failure: { ...reason, detail }, timing };
1360
1441
  }
1361
1442
  }
1362
- return { kind: "finished", status: abortController.signal.aborted ? "aborted" : "completed", error: null };
1443
+ return { kind: "finished", status: abortController.signal.aborted ? "aborted" : "completed", failure: null };
1363
1444
  } catch (error) {
1364
- if (abortController.signal.aborted) return { kind: "finished", status: "aborted", error: null };
1365
- const message = error instanceof Error ? error.message : "AI turn failed";
1366
- await pipeline.emitError(message).catch(() => undefined);
1367
- return { kind: "finished", status: "failed", error: message };
1445
+ if (abortController.signal.aborted) return { kind: "finished", status: "aborted", failure: null };
1446
+ return { kind: "finished", status: "failed", failure: aiTurnFailureFromThrown(error, "AI turn failed") };
1368
1447
  } finally {
1369
1448
  stopHeartbeat();
1370
1449
  await pipeline.flush().catch(() => undefined);
@@ -1556,14 +1635,7 @@ export class AiTurnExecutor {
1556
1635
  });
1557
1636
  } catch (error) {
1558
1637
  signal.removeEventListener("abort", onSignal);
1559
- await this.finalize(
1560
- conversationId,
1561
- turnId,
1562
- pipeline,
1563
- "failed",
1564
- error instanceof Error ? error.message : "AI compaction failed",
1565
- "compact",
1566
- );
1638
+ await this.finalize(conversationId, turnId, pipeline, "failed", aiTurnFailureFromThrown(error, "AI compaction failed"), "compact");
1567
1639
  return;
1568
1640
  }
1569
1641
  const { settings, resolved } = validated;
@@ -1574,17 +1646,23 @@ export class AiTurnExecutor {
1574
1646
  turnId,
1575
1647
  leaseOwner: this.config.leaseOwner,
1576
1648
  });
1649
+ const reason: { current: AiTurnFailureReason | null } = { current: null };
1577
1650
  const loop = compact({
1578
1651
  agentId: "cloud",
1579
1652
  loopId: turnId,
1580
1653
  store,
1581
- provider: inferenceProvider(resolved.provider, resolved.profile, {
1582
- kind: "background",
1583
- task: "chat-compaction",
1584
- conversationId,
1585
- turnId,
1586
- appId: "core",
1587
- }),
1654
+ provider: rememberProviderErrors(
1655
+ inferenceProvider(resolved.provider, resolved.profile, {
1656
+ kind: "background",
1657
+ task: "chat-compaction",
1658
+ conversationId,
1659
+ turnId,
1660
+ appId: "core",
1661
+ }),
1662
+ (remembered) => {
1663
+ reason.current = remembered;
1664
+ },
1665
+ ),
1588
1666
  force: true,
1589
1667
  signal: abortController.signal,
1590
1668
  compact: createCloudCompactFn({
@@ -1603,15 +1681,21 @@ export class AiTurnExecutor {
1603
1681
 
1604
1682
  const stopHeartbeat = this.startHeartbeat(conversationId, turnId, abortController);
1605
1683
  let status: "completed" | "failed" | "aborted" = "failed";
1606
- let error: string | null = null;
1684
+ let failure: AiTurnFailureInfo | null = null;
1685
+ let issueMessage: string | null = null;
1607
1686
  try {
1608
1687
  for await (const event of loop as AsyncIterable<CompactEvent>) {
1609
1688
  if (event.type === "compaction_start") await pipeline.applyCompaction("running");
1610
1689
  else if (event.type === "compaction_end") await pipeline.applyCompaction("completed");
1611
- else if (event.type === "issue") error = event.issue.message;
1690
+ else if (event.type === "issue") issueMessage = event.issue.message;
1612
1691
  else if (event.type === "loop_end") {
1613
1692
  status = event.reason === "stop" ? "completed" : event.reason === "aborted" ? "aborted" : "failed";
1614
1693
  await pipeline.applyCompaction(status === "failed" ? "failed" : "completed", event.result);
1694
+ if (status === "failed")
1695
+ failure = {
1696
+ ...(reason.current ?? { error: { code: "failed" } }),
1697
+ detail: issueMessage ?? `AI compaction ended: ${event.reason}`,
1698
+ };
1615
1699
  }
1616
1700
  }
1617
1701
  } catch (err) {
@@ -1619,8 +1703,8 @@ export class AiTurnExecutor {
1619
1703
  status = "aborted";
1620
1704
  } else {
1621
1705
  status = "failed";
1622
- error = err instanceof Error ? err.message : "AI compaction failed";
1623
- await pipeline.emitError(error).catch(() => undefined);
1706
+ const thrown = aiTurnFailureFromThrown(err, "AI compaction failed");
1707
+ failure = reason.current ? { ...reason.current, detail: thrown.detail } : thrown;
1624
1708
  }
1625
1709
  } finally {
1626
1710
  stopHeartbeat();
@@ -1629,9 +1713,9 @@ export class AiTurnExecutor {
1629
1713
 
1630
1714
  if (abortController.signal.reason instanceof AiRunTimeout) {
1631
1715
  status = "failed";
1632
- error = abortController.signal.reason.messageFor();
1716
+ failure = { error: abortController.signal.reason.turnError(), detail: "Run time limit reached." };
1633
1717
  }
1634
- await this.finalize(conversationId, turnId, pipeline, status, error, "compact");
1718
+ await this.finalize(conversationId, turnId, pipeline, status, failure, "compact");
1635
1719
  }
1636
1720
  }
1637
1721
 
@@ -1838,14 +1922,6 @@ class StreamPipeline {
1838
1922
  await this.saving;
1839
1923
  }
1840
1924
 
1841
- async emitError(message: string): Promise<void> {
1842
- const seq = this.nextSeq();
1843
- const block: AiTurnBlock = { id: `error-${this.attempt}-${seq}`, kind: "text", text: `⚠️ ${message}` };
1844
- const event = this.envelope({ type: "block_set" as const, seq, block }) as AiWireEvent;
1845
- this.blocks = applyWireEventToBlocks(this.blocks, event);
1846
- await this.publish(event);
1847
- }
1848
-
1849
1925
  /** Transient: says a model call is waiting to be retried. Snapshots never carry it. */
1850
1926
  async emitProviderRetry(): Promise<void> {
1851
1927
  const seq = this.nextSeq();
package/src/ai/index.ts CHANGED
@@ -345,6 +345,8 @@ export type {
345
345
  AiToolPresentation,
346
346
  AiToolRuntime,
347
347
  AiTurn,
348
+ AiTurnError,
349
+ AiTurnErrorCode,
348
350
  AiTurnFinalizedEvent,
349
351
  AiTurnStatus,
350
352
  AiTurnToolSource,