@k2b/cloud 0.25.0 → 0.27.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 (93) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/capabilities.ts +12 -0
  3. package/src/_internal/define-app.ts +8 -1
  4. package/src/_internal/process-identity.ts +7 -1
  5. package/src/_internal/registry-validation.ts +3 -0
  6. package/src/_internal/registry.ts +1 -0
  7. package/src/_internal/runtime-context.ts +1 -0
  8. package/src/access/GroupCoverage.tsx +175 -0
  9. package/src/access/PermissionEditor.tsx +119 -99
  10. package/src/access/messages.ts +30 -0
  11. package/src/ai/admin.ts +1 -0
  12. package/src/ai/approval-routes.ts +5 -5
  13. package/src/ai/browser-code-contracts.ts +14 -2
  14. package/src/ai/browser.ts +8 -1
  15. package/src/ai/capabilities.ts +115 -33
  16. package/src/ai/chat/blocks.tsx +86 -179
  17. package/src/ai/chat/builtin-tools.tsx +83 -48
  18. package/src/ai/chat/file-tools.tsx +4 -1
  19. package/src/ai/chat/live-turn.browser-harness.tsx +44 -0
  20. package/src/ai/chat/message-actions.tsx +6 -2
  21. package/src/ai/chat/message-utils.ts +17 -14
  22. package/src/ai/chat/messages.ts +330 -2
  23. package/src/ai/chat/presentation.tsx +278 -104
  24. package/src/ai/chat/tool-groups.ts +55 -35
  25. package/src/ai/chat/turn-layout.ts +141 -0
  26. package/src/ai/chat/turn-view.tsx +644 -0
  27. package/src/ai/client/controller.ts +60 -31
  28. package/src/ai/client/file-source.ts +20 -3
  29. package/src/ai/client/projection.ts +42 -6
  30. package/src/ai/code-mode-skill.ts +27 -27
  31. package/src/ai/code-runtime-tools.ts +10 -1
  32. package/src/ai/code-source-contracts.ts +54 -4
  33. package/src/ai/code-source-tools.ts +10 -3
  34. package/src/ai/credentials.ts +17 -3
  35. package/src/ai/data-analysis-skill.ts +2 -2
  36. package/src/ai/default-tools.ts +2 -2
  37. package/src/ai/executor.ts +177 -80
  38. package/src/ai/file-context.ts +14 -2
  39. package/src/ai/file-tools.ts +17 -3
  40. package/src/ai/files-store.ts +134 -11
  41. package/src/ai/grids-skill.ts +2 -2
  42. package/src/ai/index.ts +7 -0
  43. package/src/ai/memories.ts +14 -0
  44. package/src/ai/migrate.ts +125 -0
  45. package/src/ai/model-request-settings.ts +98 -0
  46. package/src/ai/protocol.ts +26 -4
  47. package/src/ai/provider-fetch.ts +67 -15
  48. package/src/ai/provider-retry.ts +105 -0
  49. package/src/ai/provider.ts +7 -1
  50. package/src/ai/quota-provider.ts +16 -7
  51. package/src/ai/request-headers.ts +117 -0
  52. package/src/ai/routes.ts +34 -6
  53. package/src/ai/runtime.ts +1 -1
  54. package/src/ai/settings.ts +19 -2
  55. package/src/ai/skill-seeds.ts +31 -3
  56. package/src/ai/skills.ts +26 -0
  57. package/src/ai/solid.ts +1 -1
  58. package/src/ai/store.ts +202 -59
  59. package/src/ai/stream.ts +182 -37
  60. package/src/ai/structured.ts +20 -5
  61. package/src/ai/system-prompt.ts +25 -0
  62. package/src/ai/timeline.ts +9 -11
  63. package/src/ai/tool-call-names.ts +45 -0
  64. package/src/ai/turn-policy.ts +247 -0
  65. package/src/ai/turn-timing.ts +31 -3
  66. package/src/ai/types.ts +36 -5
  67. package/src/api/admin-ai-quotas.ts +36 -1
  68. package/src/api/admin-core-settings.ts +16 -23
  69. package/src/api/admin-outgoing-mail.ts +62 -0
  70. package/src/api/index.ts +2 -0
  71. package/src/cli/admin/ai-quotas.ts +70 -1
  72. package/src/cli/admin/index.ts +6 -0
  73. package/src/cli/admin/outgoing-mail.ts +118 -0
  74. package/src/contracts/app.ts +2 -0
  75. package/src/contracts/index.ts +1 -0
  76. package/src/contracts/outgoing-mail.ts +77 -0
  77. package/src/contracts/registry.ts +4 -0
  78. package/src/services/index.ts +3 -0
  79. package/src/services/notifications/email.ts +16 -26
  80. package/src/services/outgoing-mail/index.ts +19 -0
  81. package/src/services/outgoing-mail/store.ts +286 -0
  82. package/src/services/outgoing-mail/test-send.ts +40 -0
  83. package/src/services/outgoing-mail/transport.ts +13 -0
  84. package/src/services/settings/core-settings.ts +1 -38
  85. package/src/services/settings/store.ts +5 -1
  86. package/src/shared/ai-model-request-settings.ts +21 -0
  87. package/src/shared/ai-platform-prompt.ts +1 -1
  88. package/src/shared/ai-request-options.ts +185 -0
  89. package/src/shared/app-presentation.ts +10 -2
  90. package/src/ssr/admin-navigation.ts +1 -1
  91. package/src/ssr/platform-messages.ts +2 -0
  92. package/src/ssr/workspace-navigation.ts +7 -1
  93. package/src/styles/effects.css +69 -0
@@ -38,6 +38,10 @@ const text = (value: unknown): string => (typeof value === "string" ? value : ""
38
38
  const number = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) ? value : 0);
39
39
  const records = (value: unknown): Record<string, unknown>[] => (Array.isArray(value) ? value.filter(isRecord) : []);
40
40
  const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1) || path;
41
+ const useMessages = () => {
42
+ const locale = useLocale();
43
+ return () => aiChatMessages(locale());
44
+ };
41
45
 
42
46
  function DetailSurface(props: { children: JSX.Element }) {
43
47
  return (
@@ -101,11 +105,12 @@ function SearchToolsView(props: { block: ToolBlock }) {
101
105
  const args = () => (isRecord(props.block.args) ? props.block.args : {});
102
106
  const result = () => (isRecord(props.block.result) ? props.block.result : {});
103
107
  const tools = () => records(result().tools);
104
- const query = () => text(args().query) || "Tools";
108
+ const t = useMessages();
109
+ const query = () => text(args().query) || t().tools;
105
110
  return (
106
- <CompletedActivity block={props.block} label={`Search tools: ${query()}`} description={`${tools().length} found`}>
111
+ <CompletedActivity block={props.block} label={t().searchTools({ query: query() })} description={t().found({ count: tools().length })}>
107
112
  <ResultList>
108
- <Show when={tools().length > 0} fallback={<EmptyRow>No tools found.</EmptyRow>}>
113
+ <Show when={tools().length > 0} fallback={<EmptyRow>{t().noTools}</EmptyRow>}>
109
114
  <For each={tools()}>
110
115
  {(tool) => (
111
116
  <ResultRow
@@ -128,10 +133,11 @@ function ListAppsView(props: { block: ToolBlock }) {
128
133
  const value = result().apps;
129
134
  return isRecord(value) ? Object.entries(value) : [];
130
135
  };
136
+ const t = useMessages();
131
137
  return (
132
- <CompletedActivity block={props.block} label="List apps" description={`${apps().length} available`}>
138
+ <CompletedActivity block={props.block} label={t().listApps} description={t().available({ count: apps().length })}>
133
139
  <ResultList>
134
- <Show when={apps().length > 0} fallback={<EmptyRow>No capability apps available.</EmptyRow>}>
140
+ <Show when={apps().length > 0} fallback={<EmptyRow>{t().noApps}</EmptyRow>}>
135
141
  <For each={apps()}>{([appId, description]) => <ResultRow icon="ti-apps" title={appId} description={text(description)} />}</For>
136
142
  </Show>
137
143
  </ResultList>
@@ -139,37 +145,46 @@ function ListAppsView(props: { block: ToolBlock }) {
139
145
  );
140
146
  }
141
147
 
148
+ /** Entries are `{name}` records; turns stored before #528 hold plain names and a `missing` list. */
149
+ const toolNames = (value: unknown): string[] =>
150
+ Array.isArray(value)
151
+ ? value.flatMap((item) => (typeof item === "string" ? [item] : isRecord(item) && typeof item.name === "string" ? [item.name] : []))
152
+ : [];
153
+
142
154
  function LoadToolsView(props: { block: ToolBlock }) {
143
155
  const result = () => (isRecord(props.block.result) ? props.block.result : {});
144
156
  const titles = (): Record<string, unknown> => {
145
157
  const value = result().titles;
146
158
  return isRecord(value) ? value : {};
147
159
  };
148
- const groups = () =>
149
- (
150
- [
151
- ["Loaded", result().loaded],
152
- ["Already loaded", result().alreadyLoaded],
153
- ["Missing", result().missing],
154
- ["Evicted", result().evicted],
155
- ] as const
156
- ).flatMap(([label, value]) =>
157
- Array.isArray(value) && value.length > 0 ? [{ label, names: value.filter((name): name is string => typeof name === "string") }] : [],
158
- );
159
- const loadedCount = () => {
160
- const value = result().loaded;
161
- return Array.isArray(value) ? value.length : 0;
162
- };
160
+ const t = useMessages();
161
+ const unavailableLabel = (reason: unknown) =>
162
+ reason === "not_offered_in_turn"
163
+ ? t().toolNotOffered
164
+ : reason === "not_allowed"
165
+ ? t().toolNotAllowed
166
+ : reason === "app_offline"
167
+ ? t().toolAppOffline
168
+ : t().toolUnknown;
169
+ const rows = () => [
170
+ ...toolNames(result().loaded).map((name) => ({ name, label: t().toolsLoaded })),
171
+ ...toolNames(result().alreadyLoaded).map((name) => ({ name, label: t().toolsAlreadyLoaded })),
172
+ ...records(result().unavailable).flatMap((item) =>
173
+ typeof item.name === "string" ? [{ name: item.name, label: unavailableLabel(item.reason) }] : [],
174
+ ),
175
+ ...toolNames(result().missing).map((name) => ({ name, label: t().toolUnknown })),
176
+ ...toolNames(result().evicted).map((name) => ({ name, label: t().toolsEvicted })),
177
+ ];
163
178
  return (
164
- <CompletedActivity block={props.block} label="Load tools" description={`${loadedCount()} loaded`}>
165
- <Show when={groups().length > 0}>
179
+ <CompletedActivity
180
+ block={props.block}
181
+ label={t().loadTools}
182
+ description={t().loadedCount({ count: toolNames(result().loaded).length })}
183
+ >
184
+ <Show when={rows().length > 0}>
166
185
  <ResultList>
167
- <For each={groups()}>
168
- {(group) => (
169
- <For each={group.names}>
170
- {(name) => <ResultRow icon={aiToolIcon(name)} title={text(titles()[name]) || name} meta={group.label} />}
171
- </For>
172
- )}
186
+ <For each={rows()}>
187
+ {(row) => <ResultRow icon={aiToolIcon(row.name)} title={text(titles()[row.name]) || row.name} meta={row.label} />}
173
188
  </For>
174
189
  </ResultList>
175
190
  </Show>
@@ -179,8 +194,9 @@ function LoadToolsView(props: { block: ToolBlock }) {
179
194
 
180
195
  function LoadSkillView(props: { block: ToolBlock }) {
181
196
  const result = () => (isRecord(props.block.result) ? props.block.result : {});
197
+ const t = useMessages();
182
198
  return (
183
- <CompletedActivity block={props.block} label={`Loaded skill ${text(result().name)}`}>
199
+ <CompletedActivity block={props.block} label={t().loadedSkill({ name: text(result().name) })}>
184
200
  <DetailSurface>
185
201
  <p class="whitespace-pre-wrap px-2 py-1.5 leading-5 text-secondary">{text(result().description)}</p>
186
202
  </DetailSurface>
@@ -192,11 +208,16 @@ function SearchHelpView(props: { block: ToolBlock }) {
192
208
  const args = () => (isRecord(props.block.args) ? props.block.args : {});
193
209
  const result = () => (isRecord(props.block.result) ? props.block.result : {});
194
210
  const documents = () => records(result().documents);
195
- const query = () => text(args().query) || "Help";
211
+ const t = useMessages();
212
+ const query = () => text(args().query) || t().help;
196
213
  return (
197
- <CompletedActivity block={props.block} label={`Search help: ${query()}`} description={`${documents().length} found`}>
214
+ <CompletedActivity
215
+ block={props.block}
216
+ label={t().searchHelp({ query: query() })}
217
+ description={t().found({ count: documents().length })}
218
+ >
198
219
  <ResultList>
199
- <Show when={documents().length > 0} fallback={<EmptyRow>No Help articles found.</EmptyRow>}>
220
+ <Show when={documents().length > 0} fallback={<EmptyRow>{t().noHelp}</EmptyRow>}>
200
221
  <For each={documents()}>
201
222
  {(document) => (
202
223
  <ResultRow
@@ -219,11 +240,12 @@ function ReadHelpView(props: { block: ToolBlock }) {
219
240
  const value = result().document;
220
241
  return isRecord(value) ? value : null;
221
242
  };
243
+ const t = useMessages();
222
244
  return (
223
245
  <CompletedActivity
224
246
  block={props.block}
225
- label={document() ? `Read help: ${text(document()!.title)}` : "Read help"}
226
- description={document() ? text(document()!.appName) || text(document()!.appId) : "Article not found"}
247
+ label={document() ? t().readHelpArticle({ title: text(document()!.title) }) : t().readHelp}
248
+ description={document() ? text(document()!.appName) || text(document()!.appId) : t().articleNotFound}
227
249
  />
228
250
  );
229
251
  }
@@ -233,14 +255,15 @@ function SearchProjectView(props: { block: ToolBlock }) {
233
255
  const result = () => (isRecord(props.block.result) ? props.block.result : {});
234
256
  const items = () => records(result().items);
235
257
  const query = () => text(args().query);
258
+ const t = useMessages();
236
259
  return (
237
260
  <CompletedActivity
238
261
  block={props.block}
239
- label={query() ? `Search Project: ${query()}` : "List Project sources"}
240
- description={`${items().length} found${result().truncated === true ? "+" : ""}`}
262
+ label={query() ? t().searchProject({ query: query() }) : t().listProjectSources}
263
+ description={t().found({ count: items().length, more: result().truncated === true })}
241
264
  >
242
265
  <ResultList>
243
- <Show when={items().length > 0} fallback={<EmptyRow>No Project sources found.</EmptyRow>}>
266
+ <Show when={items().length > 0} fallback={<EmptyRow>{t().noProjectSources}</EmptyRow>}>
244
267
  <For each={items()}>
245
268
  {(item) => {
246
269
  const kind = text(item.kind);
@@ -270,21 +293,23 @@ function SearchProjectView(props: { block: ToolBlock }) {
270
293
 
271
294
  function ReadProjectKnowledgeView(props: { block: ToolBlock }) {
272
295
  const result = () => (isRecord(props.block.result) ? props.block.result : {});
273
- return <CompletedActivity block={props.block} label={`Read Project knowledge: ${text(result().title) || "Entry"}`} />;
296
+ const t = useMessages();
297
+ return <CompletedActivity block={props.block} label={t().readProjectKnowledge({ title: text(result().title) || t().projectEntry })} />;
274
298
  }
275
299
 
276
300
  function ListFilesView(props: { block: ToolBlock }) {
277
301
  const args = () => (isRecord(props.block.args) ? props.block.args : {});
278
302
  const result = () => (isRecord(props.block.result) ? props.block.result : {});
279
303
  const files = () => records(result().files);
304
+ const t = useMessages();
280
305
  return (
281
306
  <CompletedActivity
282
307
  block={props.block}
283
- label={`List files: ${text(args().path) || "/"}`}
284
- description={`${files().length} found${result().truncated === true ? "+" : ""}`}
308
+ label={t().listFiles({ path: text(args().path) || "/" })}
309
+ description={t().found({ count: files().length, more: result().truncated === true })}
285
310
  >
286
311
  <ResultList>
287
- <Show when={files().length > 0} fallback={<EmptyRow>No files found.</EmptyRow>}>
312
+ <Show when={files().length > 0} fallback={<EmptyRow>{t().noFiles}</EmptyRow>}>
288
313
  <For each={files()}>
289
314
  {(file) => {
290
315
  const path = text(file.path);
@@ -309,10 +334,11 @@ function FileOperationView(props: { block: ToolBlock; verb: string; resultPath?:
309
334
  const result = () => (isRecord(props.block.result) ? props.block.result : {});
310
335
  const path = () => props.resultPath || text(result().path) || text(args().path);
311
336
  const size = () => number(result().size);
337
+ const t = useMessages();
312
338
  return (
313
339
  <CompletedActivity
314
340
  block={props.block}
315
- label={`${props.verb}: ${basename(path()) || "File"}`}
341
+ label={`${props.verb}: ${basename(path()) || t().file}`}
316
342
  description={[path(), size() > 0 ? formatAiFileSize(size()) : ""].filter(Boolean).join(" · ")}
317
343
  />
318
344
  );
@@ -334,11 +360,12 @@ function ReadFileView(props: { block: ToolBlock }) {
334
360
  const t = aiChatMessages(locale());
335
361
  return end > start ? t.byteRange({ start: start.toLocaleString(locale()), end: end.toLocaleString(locale()) }) : t.read;
336
362
  };
363
+ const t = () => aiChatMessages(locale());
337
364
  return (
338
365
  <CompletedActivity
339
366
  block={props.block}
340
- label={`Read file: ${basename(path())}`}
341
- description={`${range()}${result().eof === true ? " · complete" : " · more available"}`}
367
+ label={t().readFile({ name: basename(path()) })}
368
+ description={`${range()} · ${result().eof === true ? t().fileComplete : t().fileMoreAvailable}`}
342
369
  />
343
370
  );
344
371
  }
@@ -346,15 +373,17 @@ function ReadFileView(props: { block: ToolBlock }) {
346
373
  function CalculateView(props: { block: ToolBlock }) {
347
374
  const args = () => (isRecord(props.block.args) ? props.block.args : {});
348
375
  const result = () => (isRecord(props.block.result) ? props.block.result : {});
349
- return <CompletedActivity block={props.block} label={text(args().expression) || "Calculate"} description={text(result().result)} />;
376
+ const t = useMessages();
377
+ return <CompletedActivity block={props.block} label={text(args().expression) || t().calculate} description={text(result().result)} />;
350
378
  }
351
379
 
352
380
  function ViewImageView(props: { block: ToolBlock }) {
353
381
  const args = () => (isRecord(props.block.args) ? props.block.args : {});
354
382
  const result = () => (isRecord(props.block.result) ? props.block.result : {});
355
383
  const path = () => text(result().path) || text(args().path);
384
+ const t = useMessages();
356
385
  return (
357
- <CompletedActivity block={props.block} label={`Inspect image: ${basename(path())}`} defaultOpen>
386
+ <CompletedActivity block={props.block} label={t().inspectImage({ name: basename(path()) })} defaultOpen>
358
387
  <DetailSurface>
359
388
  <p class="whitespace-pre-wrap px-2 py-1.5 leading-5 text-secondary">{text(result().description)}</p>
360
389
  </DetailSurface>
@@ -382,15 +411,21 @@ function CloudResourceView(props: { block: ToolBlock }) {
382
411
  const args = () => (isRecord(props.block.args) ? props.block.args : {});
383
412
  const result = () => (isRecord(props.block.result) ? props.block.result : {});
384
413
  const summary = () => text(result().summary);
414
+ const t = useMessages();
385
415
  return (
386
416
  <CompletedActivity
387
417
  block={props.block}
388
- label={`Read resource: ${text(args().type)}:${text(args().id)}`}
418
+ label={t().readResource({ ref: `${text(args().type)}:${text(args().id)}` })}
389
419
  description={summary() || undefined}
390
420
  />
391
421
  );
392
422
  }
393
423
 
424
+ function WriteFileView(props: { block: ToolBlock }) {
425
+ const t = useMessages();
426
+ return <FileOperationView block={props.block} verb={t().wroteFile} />;
427
+ }
428
+
394
429
  export function SpecializedBuiltinToolBlock(props: { block: ToolBlock }) {
395
430
  switch (props.block.name) {
396
431
  case "search_tools":
@@ -414,7 +449,7 @@ export function SpecializedBuiltinToolBlock(props: { block: ToolBlock }) {
414
449
  case "read_file":
415
450
  return <ReadFileView block={props.block} />;
416
451
  case "write_file":
417
- return <FileOperationView block={props.block} verb="Wrote file" />;
452
+ return <WriteFileView block={props.block} />;
418
453
  case "calculate":
419
454
  return <CalculateView block={props.block} />;
420
455
  case "view_image":
@@ -40,7 +40,10 @@ export function PresentToolBlock(props: { block: ToolBlock }) {
40
40
  </Show>
41
41
  }
42
42
  >
43
- <div class="inline-flex min-h-7 max-w-full items-center gap-1.5 py-1 text-xs leading-none text-dimmed">
43
+ <div
44
+ data-call-id={props.block.callId}
45
+ class="inline-flex min-h-7 max-w-full items-center gap-1.5 py-1 text-xs leading-none text-dimmed"
46
+ >
44
47
  <i class={`ti ${icon()} shrink-0 text-base leading-none`} aria-hidden="true" />
45
48
  <Show
46
49
  when={actions.onOpenFile}
@@ -0,0 +1,44 @@
1
+ import { Chat } from "@k2b/ui";
2
+ import { createMemo, createSignal } from "solid-js";
3
+ import { createStore, reconcile } from "solid-js/store";
4
+ import { render } from "solid-js/web";
5
+ import { emptyProjection, reduceProjection, visibleMessages } from "../client/projection";
6
+ import type { AiTurnBlock, AiWireEvent } from "../protocol";
7
+ import { AiChatActionsProvider, createAiChatTimeline } from "./presentation";
8
+
9
+ /**
10
+ * The live chat timeline for a browser test, folded from wire events like the
11
+ * controller does. `window.emit(event)` applies one event,
12
+ * `window.steer(text)` appends a pending steer as the controller does, and
13
+ * `window.reconnect(on)` sets whether the stream reconnects.
14
+ */
15
+ const [state, setState] = createStore(emptyProjection());
16
+ const [reconnecting, setReconnecting] = createSignal(false);
17
+ Object.assign(window, {
18
+ reconnect: (on: boolean) => setReconnecting(on),
19
+ emit: (event: AiWireEvent) => setState(reconcile(reduceProjection(state, event), { key: "id", merge: true })),
20
+ steer: (text: string) =>
21
+ setState("activeTurn", "blocks", (blocks): AiTurnBlock[] => [
22
+ ...blocks,
23
+ { id: `steer-request-${blocks.length}`, kind: "steer_message", steerId: String(blocks.length), text, status: "pending" },
24
+ ]),
25
+ });
26
+
27
+ const root = document.createElement("main");
28
+ root.style.cssText = "display: flex; flex-direction: column; height: 100dvh; max-width: 48rem; margin: 0 auto;";
29
+ document.body.append(root);
30
+ render(
31
+ () => (
32
+ <AiChatActionsProvider actions={{}}>
33
+ {(() => {
34
+ const items = createAiChatTimeline({
35
+ messages: createMemo(() => visibleMessages(state)),
36
+ activeTurn: () => state.activeTurn,
37
+ reconnecting,
38
+ });
39
+ return <Chat.Timeline items={items()} />;
40
+ })()}
41
+ </AiChatActionsProvider>
42
+ ),
43
+ root,
44
+ );
@@ -28,8 +28,12 @@ type RetryMessageHandler = (entry: AiStoredMessage, input?: AiRetryMessageInput)
28
28
  type RetrySteerHandler = (block: Extract<AiTurnBlock, { kind: "steer_message" }>) => void | Promise<void>;
29
29
 
30
30
  export type AiChatActions = {
31
- /** Host-owned visualizations, persisted independently of the execution session. */
32
- renderCodePresentation?: (result: unknown) => JSX.Element;
31
+ /**
32
+ * Host-owned visualizations, persisted independently of the execution session. Called once per `code_present`
33
+ * call, from its first event: `result()` is undefined while the call runs and the saved result afterwards, so
34
+ * the host reserves the frame early and keeps its state when the turn ends.
35
+ */
36
+ renderCodePresentation?: (result: () => unknown) => JSX.Element;
33
37
  /** Prevents turn-continuation actions while the current turn is stopping. */
34
38
  actionDisabled?: () => boolean;
35
39
  onApproval?: ApprovalHandler;
@@ -8,6 +8,7 @@ import { type AiResourceMarker, parseAiResourceMarker } from "../resource-marker
8
8
  import { assistantVisibleTextFromMessage } from "../timeline";
9
9
  import type { AiStoredMessage, AiUserContentPart } from "../types";
10
10
  import { AI_IMAGE_MEDIA_TYPES, isAiImageMediaType } from "../types";
11
+ import { aiChatMessages } from "./messages";
11
12
 
12
13
  type AssistantToolResultMessage = Extract<Message, { role: "tool_result" }>;
13
14
 
@@ -401,20 +402,23 @@ const boundedErrorText = (value: string): string => {
401
402
  return compact.length > 500 ? `${compact.slice(0, 497)}...` : compact;
402
403
  };
403
404
 
404
- /** Canonical capability failure text for the compact chat row. Raw input and output remain in the audit trail. */
405
+ /**
406
+ * Canonical capability failure text for the compact chat row, or "" when the result carries none and the row
407
+ * shows its localized fallback. Raw input and output remain in the audit trail.
408
+ */
405
409
  export const capabilityErrorDescription = (result: unknown): string => {
406
- if (typeof result === "string") return boundedErrorText(result) || "The action could not be completed.";
410
+ if (typeof result === "string") return boundedErrorText(result);
407
411
  if (isRecord(result)) {
408
412
  const message = typeof result.message === "string" ? result.message : typeof result.error === "string" ? result.error : "";
409
413
  const code = typeof result.code === "string" ? result.code : "";
410
414
  const text = code && message && !message.startsWith(code) ? `${code}: ${message}` : message || code;
411
415
  if (text) return boundedErrorText(text);
412
416
  }
413
- return "The action could not be completed.";
417
+ return "";
414
418
  };
415
419
 
416
420
  /** Compact user-facing copy for fetch_file failures; the full response remains in the disclosure and audit trail. */
417
- export const fetchFileErrorPresentation = (result: unknown): FetchFileErrorPresentation => {
421
+ export const fetchFileErrorPresentation = (result: unknown, locale = "en"): FetchFileErrorPresentation => {
418
422
  const message =
419
423
  typeof result === "string"
420
424
  ? result.trim()
@@ -425,25 +429,24 @@ export const fetchFileErrorPresentation = (result: unknown): FetchFileErrorPrese
425
429
  if (separator > 0) {
426
430
  return { label: message.slice(0, separator), description: message.slice(separator + 3) };
427
431
  }
428
- return {
429
- label: "File download failed",
430
- description: message || "The linked file could not be imported into this chat.",
431
- };
432
+ const t = aiChatMessages(locale);
433
+ return { label: t.fileDownloadFailed, description: message || t.fileNotImported };
432
434
  };
433
435
 
434
436
  /** End-user copy for memory updates; full tool input/result remains available in persisted audit data. */
435
- export const memoryToolPresentation = (args: unknown, result: unknown): MemoryToolPresentation | null => {
437
+ export const memoryToolPresentation = (args: unknown, result: unknown, locale = "en"): MemoryToolPresentation | null => {
436
438
  if (!isRecord(args) || !["list", "search", "add", "update", "delete"].includes(String(args.action))) return null;
437
439
  if (!isRecord(result) || typeof result.ok !== "boolean" || typeof result.message !== "string") return null;
440
+ const t = aiChatMessages(locale);
438
441
 
439
- if (!result.ok) return { label: "Memory not updated", description: result.message, failed: true };
442
+ if (!result.ok) return { label: t.memoryNotUpdated, description: result.message, failed: true };
440
443
  if (args.action === "add")
441
- return { label: "Remembered", description: typeof args.content === "string" ? args.content.trim() : result.message, failed: false };
444
+ return { label: t.remembered, description: typeof args.content === "string" ? args.content.trim() : result.message, failed: false };
442
445
  if (args.action === "update")
443
- return { label: "Updated memory", description: result.message.replace(/^Updated memory:\s*/, ""), failed: false };
446
+ return { label: t.memoryUpdated, description: result.message.replace(/^Updated memory:\s*/, ""), failed: false };
444
447
  if (args.action === "delete")
445
- return { label: "Forgot memory", description: result.message.replace(/^Forgot memory:\s*/, ""), failed: false };
446
- return { label: args.action === "search" ? "Searched memories" : "Listed memories", description: result.message, failed: false };
448
+ return { label: t.memoryForgotten, description: result.message.replace(/^Forgot memory:\s*/, ""), failed: false };
449
+ return { label: args.action === "search" ? t.memoriesSearched : t.memoriesListed, description: result.message, failed: false };
447
450
  };
448
451
 
449
452
  export const toolResultSummary = (message: AssistantToolResultMessage | null | undefined): string => {