@k2b/cloud 0.37.0 → 0.38.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 (64) hide show
  1. package/package.json +4 -2
  2. package/src/_internal/help-catalog.ts +123 -4
  3. package/src/_internal/help.ts +27 -6
  4. package/src/account/Avatar.tsx +20 -2
  5. package/src/account/ui.ts +1 -1
  6. package/src/ai/browser.ts +1 -0
  7. package/src/ai/chart-block.ts +44 -13
  8. package/src/ai/chat/chart-block.tsx +2 -11
  9. package/src/ai/chat/messages.ts +10 -0
  10. package/src/ai/chat/web-tools.tsx +23 -6
  11. package/src/ai/chat-tasks.ts +65 -1
  12. package/src/ai/default-tool-contracts.ts +30 -13
  13. package/src/ai/fetch-file-tool.ts +37 -3
  14. package/src/ai/migrate.ts +19 -2
  15. package/src/ai/readable-html.ts +1057 -0
  16. package/src/api/help.ts +23 -4
  17. package/src/api/mcp.ts +8 -2
  18. package/src/browser/FileChooser.tsx +11 -3
  19. package/src/browser/choose-files.browser-harness.tsx +4 -1
  20. package/src/browser/file-chooser-messages.ts +4 -0
  21. package/src/browser/file-providers.ts +14 -1
  22. package/src/browser/provider-folder.tsx +58 -5
  23. package/src/browser/resource-picker.tsx +5 -1
  24. package/src/cli/admin/workflows.ts +1 -0
  25. package/src/config/env.ts +14 -0
  26. package/src/contracts/file-provider.ts +48 -1
  27. package/src/contracts/outgoing-mail.ts +8 -1
  28. package/src/server/help.ts +136 -10
  29. package/src/server/services/geo.ts +21 -4
  30. package/src/services/allowed-network-fetch.ts +189 -0
  31. package/src/services/help/index.ts +46 -5
  32. package/src/services/help/store.ts +26 -5
  33. package/src/services/help/types.ts +10 -2
  34. package/src/services/index.ts +1 -0
  35. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  36. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  37. package/src/services/outgoing-mail/test-send.ts +18 -3
  38. package/src/services/outgoing-mail/transport.ts +24 -13
  39. package/src/services/settings/index.ts +48 -26
  40. package/src/shared/help.ts +41 -0
  41. package/src/shared/index.ts +9 -1
  42. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  43. package/src/shared/markdown/formula.ts +8 -8
  44. package/src/shared/markdown/index.ts +72 -11
  45. package/src/ssr/Layout.tsx +20 -2
  46. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  47. package/src/ssr/LayoutHelp.tsx +569 -410
  48. package/src/ssr/ShellRuntime.island.tsx +17 -25
  49. package/src/ssr/help-messages.ts +60 -38
  50. package/src/ssr/help.ts +3 -0
  51. package/src/ssr/layout-help-article.ts +41 -0
  52. package/src/ssr/layout-help-navigation.ts +0 -12
  53. package/src/ssr/layout-help-registry.tsx +4 -11
  54. package/src/ssr/layout-help-search.ts +9 -12
  55. package/src/ssr/layout-help-start.ts +68 -0
  56. package/src/ssr/platform-messages.ts +4 -0
  57. package/src/ssr/shell-data.tsx +7 -0
  58. package/src/ssr/shell-shortcuts.ts +8 -0
  59. package/src/styles/effects.css +688 -185
  60. package/src/styles/resource-search.css +25 -0
  61. package/src/workflows/store/events.ts +24 -0
  62. package/src/workflows/store/index.ts +8 -0
  63. package/src/workflows/store/observability.ts +6 -1
  64. package/src/workflows/store/retention.ts +157 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -19,6 +19,7 @@
19
19
  "!src/**/*.test.tsx",
20
20
  "!src/**/*.integration.test.ts",
21
21
  "!src/**/*.test-fixture.ts",
22
+ "!src/**/*.test-fixture.tsx",
22
23
  "!scripts/**/*.test.ts",
23
24
  "!scripts/runtime-recovery-*.ts"
24
25
  ],
@@ -107,7 +108,7 @@
107
108
  "@tailwindcss/typography": "0.5.20",
108
109
  "@k2b/nessi": "0.18.0",
109
110
  "@k2b/ssr": "0.17.0",
110
- "@k2b/ui": "0.27.0",
111
+ "@k2b/ui": "0.28.0",
111
112
  "@k2b/stdlib": "0.29.0",
112
113
  "@k2b/sync": "7.0.0",
113
114
  "@nats-io/jetstream": "3.4.0",
@@ -115,6 +116,7 @@
115
116
  "@modelcontextprotocol/sdk": "1.32.1",
116
117
  "bun-plugin-tailwind": "0.1.2",
117
118
  "hono-openapi": "1.3.3",
119
+ "htmlparser2": "12.0.0",
118
120
  "jose": "6.2.12",
119
121
  "katex": "0.18.9",
120
122
  "liquidjs": "10.29.0",
@@ -1,5 +1,124 @@
1
+ import { i18n } from "@k2b/stdlib";
2
+ import { formatShortcut } from "@k2b/ui";
3
+ import type { Token } from "marked";
1
4
  import type { HelpArticle } from "../services/help/types";
2
- import { markdownToPlainText } from "../shared/markdown";
5
+ import { parseHelpReference } from "../shared/help";
6
+ import { lexHelpMarkdown, markdownToPlainText } from "../shared/markdown";
7
+
8
+ const helpAgentMessages = i18n.define({
9
+ baseLocale: "en",
10
+ messages: {
11
+ en: { apple: ({ shortcut }: { shortcut: string }) => `${shortcut} on Mac` },
12
+ de: { apple: ({ shortcut }) => `${shortcut} auf dem Mac` },
13
+ },
14
+ });
15
+
16
+ const helpLinkLabel = (source: string, fallback: string): string => {
17
+ // Index backtick runs once so unmatched runs cannot repeatedly scan the remaining source.
18
+ const backticks = new Map<number, { positions: number[]; next: number }>();
19
+ for (const run of source.matchAll(/`+/g)) {
20
+ const length = run[0].length;
21
+ if (!backticks.has(length)) backticks.set(length, { positions: [], next: 0 });
22
+ backticks.get(length)!.positions.push(run.index);
23
+ }
24
+ let depth = 0;
25
+ for (let index = 1; index < source.length; index++) {
26
+ const char = source[index];
27
+ if (char === "\\") index++;
28
+ else if (char === "`") {
29
+ let end = index + 1;
30
+ while (source[end] === "`") end++;
31
+ const length = end - index;
32
+ const runs = backticks.get(length);
33
+ if (runs) {
34
+ while (runs.next < runs.positions.length && runs.positions[runs.next]! <= index) runs.next++;
35
+ }
36
+ const closing = runs?.positions[runs.next];
37
+ index = closing === undefined ? end - 1 : closing + length - 1;
38
+ } else if (char === "[") depth++;
39
+ else if (char === "]") {
40
+ if (depth === 0) return source.slice(1, index);
41
+ depth--;
42
+ }
43
+ }
44
+ return fallback;
45
+ };
46
+
47
+ /** Portable source for agents and Copy as Markdown; code is kept verbatim. */
48
+ export const helpAgentMarkdown = (
49
+ article: Pick<HelpArticle, "markdown" | "locale" | "appId" | "links">,
50
+ tokens: readonly Token[] = lexHelpMarkdown(article.markdown, article.locale),
51
+ ): string => {
52
+ const { t } = helpAgentMessages.resolve([article.locale]);
53
+ const rewriteChildren = (raw: string, children: readonly Token[]): string => {
54
+ let offset = 0;
55
+ let result = "";
56
+ for (const child of children) {
57
+ let start = raw.indexOf(child.raw, offset);
58
+ let source = child.raw;
59
+ if (start < 0 && child.raw.includes("|")) {
60
+ source = child.raw.replaceAll("|", "\\|");
61
+ start = raw.indexOf(source, offset);
62
+ }
63
+ // Marked removes quote markers and list indentation from child block sources.
64
+ if (start < 0 && child.raw.includes("\n")) {
65
+ const pattern = child.raw
66
+ .split("\n")
67
+ .map((line, index) => (index > 0 ? line.replace(/^[ \t]+/, "") : line).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
68
+ .join("\\r?\\n[ \\t>]*");
69
+ const match = new RegExp(pattern).exec(raw.slice(offset));
70
+ if (match) {
71
+ start = offset + match.index;
72
+ source = match[0];
73
+ }
74
+ }
75
+ if (start < 0) break;
76
+ result += raw.slice(offset, start) + rewrite(child, source);
77
+ offset = start + source.length;
78
+ }
79
+ return result + raw.slice(offset);
80
+ };
81
+ const rewrite = (token: Token, source: string): string => {
82
+ if (token.type === "code" || token.type === "codespan") return source;
83
+ if (token.type === "helpKbd") {
84
+ const keys = String(token.keys);
85
+ const other = formatShortcut(keys, { platform: "other", locale: article.locale });
86
+ const apple = formatShortcut(keys, { platform: "apple", locale: article.locale }).label;
87
+ // Key labels such as | or ` stay text in a table cell or a sentence.
88
+ const escape = (text: string) => text.replace(/[\\`*_[\]|<>~]/g, "\\$&");
89
+ // A key without a platform modifier reads by its name: "Enter", not "↵".
90
+ return other.label === apple ? escape(other.name) : `${escape(other.label)} (${t.apple({ shortcut: escape(apple) })})`;
91
+ }
92
+ if (token.type === "helpDetails") {
93
+ const newline = source.includes("\r\n") ? "\r\n" : "\n";
94
+ const bodyStart = source.indexOf("\n") + 1;
95
+ const closing = [...source.matchAll(/^[ \t>]*:::[ \t]*(?:\r?\n|$)/gm)].at(-1)!;
96
+ const body = source.slice(bodyStart, closing.index);
97
+ const prefix = body.match(/^[ \t>]+/)?.[0] ?? "";
98
+ const trailing = source.slice(closing.index + closing[0].length);
99
+ return `**${rewriteChildren(String(token.title), token.titleTokens ?? [])}**${newline}${prefix}${newline}${rewriteChildren(body, token.tokens ?? [])}${trailing}`;
100
+ }
101
+ if (token.type === "helpLabel") return rewriteChildren(source.replace(/\{icon="[a-z0-9-]+"\}$/, ""), token.tokens ?? []);
102
+ if (token.type === "link" && token.href.startsWith("help:")) {
103
+ const ref = parseHelpReference(token.href.slice(5));
104
+ const target = ref && article.links[`${ref.appId ?? article.appId}/${ref.documentId}`];
105
+ const label = rewriteChildren(helpLinkLabel(source, token.text), token.tokens ?? []);
106
+ return target ? `[${label}](${target.url}${ref.fragment})` : label;
107
+ }
108
+ const children =
109
+ token.type === "list"
110
+ ? token.items
111
+ : token.type === "table"
112
+ ? [...token.header, ...token.rows.flat()].flatMap((cell) => cell.tokens)
113
+ : "tokens" in token
114
+ ? (token.tokens ?? [])
115
+ : [];
116
+ const raw = rewriteChildren(source, children);
117
+ return token.type === "heading" ? raw.replace(/[ \t]+\{icon="[a-z0-9-]+"\}(?=[ \t]*\r?\n?$)/i, "") : raw;
118
+ };
119
+ return rewriteChildren(article.markdown, tokens);
120
+ };
121
+
3
122
  export const HELP_SEARCH_MAX_LIMIT = 25;
4
123
  export const HELP_READ_MAX_CHARS = 7_000;
5
124
  const normalizeSearchText = (value: string): string =>
@@ -33,7 +152,7 @@ const boundedExcerpt = (markdown: string, terms: readonly string[]): string => {
33
152
  return `${start > 0 ? `${marker}\n\n` : ""}${body}${start + budget < markdown.length ? `\n\n${marker}` : ""}`;
34
153
  };
35
154
 
36
- export const selectHelpMarkdown = (markdown: string, query?: string): { markdown: string; truncated: boolean } => {
155
+ export const selectHelpMarkdown = (markdown: string, query?: string, locale?: string): { markdown: string; truncated: boolean } => {
37
156
  // An exact heading identifies one complete reference section, even in short articles.
38
157
  const headingQuery = query?.trim().toLocaleLowerCase();
39
158
  const exact =
@@ -53,7 +172,7 @@ export const selectHelpMarkdown = (markdown: string, query?: string): { markdown
53
172
  const phrase = normalizeSearchText(query ?? "");
54
173
  const ranked = splitSections(markdown)
55
174
  .map((section, index) => {
56
- const text = normalizeSearchText(markdownToPlainText(section));
175
+ const text = normalizeSearchText(markdownToPlainText(section, locale));
57
176
  const score = (phrase && text.includes(phrase) ? 10 : 0) + terms.filter((term) => text.includes(term)).length;
58
177
  return { section, index, score };
59
178
  })
@@ -79,7 +198,7 @@ export const readHelpArticle = (document: HelpArticle, query?: string) => ({
79
198
  documentId: document.documentId,
80
199
  title: document.title,
81
200
  description: document.description,
82
- ...selectHelpMarkdown(document.markdown, query),
201
+ ...selectHelpMarkdown(helpAgentMarkdown(document), query, document.locale),
83
202
  });
84
203
 
85
204
  export const helpResourceUri = (appId: string, documentId: string): string =>
@@ -2,28 +2,49 @@ import { createHash } from "node:crypto";
2
2
  import type { AppRegistryHelpSummary } from "../contracts/registry";
3
3
  import type { HelpDefinition } from "../server/help";
4
4
  import type { HelpCorpus, HelpDocument } from "../services/help/types";
5
+ import { parseHelpReference } from "../shared/help";
6
+ import { lexHelpMarkdown, walkHelpTokens } from "../shared/markdown";
5
7
 
6
8
  export const HELP_DOCUMENT_MAX_BYTES = 128 * 1024;
9
+ // defineHelp documents are frozen; retain only their validated targets, not the token trees.
10
+ export const helpLinkTargets = new WeakMap<object, readonly string[]>();
7
11
  export type CompiledHelp = { summary: AppRegistryHelpSummary; corpus: HelpCorpus };
8
12
 
9
13
  export const compileHelp = (input: { appId: string; basePath?: string; definition: HelpDefinition }): CompiledHelp => {
10
- const compileDocuments = (source: HelpDefinition["documents"]): HelpDocument[] =>
11
- source.map(({ id, title, icon, description, order, markdown, searchText }) => {
14
+ const baseIds = new Set(input.definition.documents.map((document) => document.id));
15
+ const compileDocuments = (source: HelpDefinition["documents"], locale: string): HelpDocument[] =>
16
+ source.map((document) => {
17
+ const { id, title, icon, description, order, kind, needs, next, related, markdown, searchText } = document;
12
18
  if (Buffer.byteLength(markdown) > HELP_DOCUMENT_MAX_BYTES) {
13
19
  throw new Error(`Help document "${id}" exceeds the ${HELP_DOCUMENT_MAX_BYTES}-byte limit`);
14
20
  }
15
- return { id, title, icon, description, order, markdown, searchText };
21
+ const validate = (reference: string) => {
22
+ const ref = parseHelpReference(reference);
23
+ if (ref?.appId === input.appId && !baseIds.has(ref.documentId))
24
+ throw new Error(`Help locale "${locale}" document "${id}" has unknown reference "${reference}"`);
25
+ };
26
+ for (const reference of related) validate(reference);
27
+ const targets = helpLinkTargets.get(document);
28
+ if (targets) {
29
+ for (const target of targets) validate(target);
30
+ } else {
31
+ walkHelpTokens(lexHelpMarkdown(markdown, locale), (token) => {
32
+ if (token.type === "link" && token.href.startsWith("help:")) validate(token.href.slice(5));
33
+ });
34
+ }
35
+ return { id, title, icon, description, order, kind, needs, next, related, markdown, searchText };
16
36
  });
17
37
  const baseLocale = input.definition.baseLocale ?? "en";
18
- const documents = compileDocuments(input.definition.documents);
38
+ const documents = compileDocuments(input.definition.documents, baseLocale);
19
39
  const documentsByLocale = Object.fromEntries(
20
40
  Object.entries(input.definition.documentsByLocale ?? {})
21
41
  .filter(([locale]) => locale !== baseLocale)
22
42
  .sort(([a], [b]) => a.localeCompare(b))
23
- .map(([locale, docs]) => [locale, compileDocuments(docs)]),
43
+ .map(([locale, docs]) => [locale, compileDocuments(docs, locale)]),
24
44
  );
45
+ // A revision bump republishes every corpus, here to fill the new metadata columns.
25
46
  const manifestHash = createHash("sha256")
26
- .update(JSON.stringify({ revision: 1, appId: input.appId, baseLocale, documents, documentsByLocale }))
47
+ .update(JSON.stringify({ revision: 2, appId: input.appId, baseLocale, documents, documentsByLocale }))
27
48
  .digest("hex");
28
49
  return {
29
50
  summary: { manifestHash, pageBase: `${input.basePath?.replace(/\/$/, "") ?? ""}/help`, baseLocale },
@@ -1,10 +1,14 @@
1
1
  import { Avatar, type AvatarProps } from "@k2b/ui";
2
2
  import type { JSX } from "solid-js";
3
3
 
4
- export type CloudAvatarProps = Omit<AvatarProps, "name" | "src"> & {
4
+ /** Who an avatar stands for; agents and service accounts are square, people round. */
5
+ export type CloudAvatarKind = "user" | "service_account" | "system";
6
+
7
+ export type CloudAvatarProps = Omit<AvatarProps, "name" | "src" | "shape" | "icon"> & {
5
8
  username: string;
6
9
  userId?: string | null;
7
10
  avatarHash?: string | null;
11
+ kind?: CloudAvatarKind;
8
12
  };
9
13
 
10
14
  const avatarSource = (userId?: string | null, avatarHash?: string | null): string | undefined => {
@@ -12,14 +16,28 @@ const avatarSource = (userId?: string | null, avatarHash?: string | null): strin
12
16
  return `/api/accounts/users/${encodeURIComponent(userId)}/avatar?rev=${encodeURIComponent(avatarHash)}`;
13
17
  };
14
18
 
19
+ /** The URL of a person's uploaded picture, or undefined when there is none. */
20
+ export const cloudAvatarSource = avatarSource;
21
+
22
+ const KIND_ICON: Record<CloudAvatarKind, string | undefined> = {
23
+ user: undefined,
24
+ service_account: "ti ti-robot",
25
+ system: "ti ti-settings-automation",
26
+ };
27
+
28
+ /** One avatar for every Cloud surface: the uploaded picture when there is one, otherwise initials. */
15
29
  export function CloudAvatar(props: CloudAvatarProps): JSX.Element {
30
+ const kind = () => props.kind ?? "user";
16
31
  return (
17
32
  <Avatar
18
33
  name={props.username}
19
- src={avatarSource(props.userId, props.avatarHash)}
34
+ src={kind() === "user" ? avatarSource(props.userId, props.avatarHash) : undefined}
35
+ icon={KIND_ICON[kind()]}
36
+ shape={kind() === "user" ? "circle" : "square"}
20
37
  alt={props.alt}
21
38
  fallback={props.fallback}
22
39
  size={props.size}
40
+ ring={props.ring}
23
41
  loading={props.loading}
24
42
  class={props.class}
25
43
  style={props.style}
package/src/account/ui.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { CloudAvatar, type CloudAvatarProps } from "./Avatar";
1
+ export { CloudAvatar, type CloudAvatarKind, type CloudAvatarProps, cloudAvatarSource } from "./Avatar";
2
2
  export { type AvatarUploadDialogOptions, openAvatarUploadDialog } from "./avatar-upload-dialog";
3
3
  export { default as EntitySearch, type EntitySearchPrincipal } from "./EntitySearch";
package/src/ai/browser.ts CHANGED
@@ -25,6 +25,7 @@ export {
25
25
  cloudAiChartRenderOptions,
26
26
  cloudAiChartRows,
27
27
  cloudAiChartTable,
28
+ cloudAiChartTooltip,
28
29
  cloudAiChartXValueFormat,
29
30
  cloudAiNumberFormatters,
30
31
  } from "./chart-block";
@@ -527,15 +527,38 @@ export type CloudAiChartColumn = { id: string; label: string; numeric: boolean }
527
527
  export type CloudAiChartRow = { key: string; cells: Record<string, string>; values: Record<string, string | number> };
528
528
  export type CloudAiChartTable = { columns: CloudAiChartColumn[]; rows: CloudAiChartRow[] };
529
529
 
530
+ /** The first text column titles the tooltip; the other columns become rows, matching the table in every host. */
531
+ export function cloudAiChartTooltip(
532
+ columns: CloudAiChartColumn[],
533
+ row: CloudAiChartRow,
534
+ ): { title?: string; rows: { label: string; value: string }[] } {
535
+ const [first, ...rest] = columns;
536
+ const named = first && !first.numeric;
537
+ return {
538
+ title: named ? row.cells[first.id] : undefined,
539
+ rows: (named ? rest : columns)
540
+ .filter((column) => column.id in row.cells)
541
+ .map((column) => ({ label: column.label, value: row.cells[column.id] ?? "" })),
542
+ };
543
+ }
544
+
530
545
  /**
531
546
  * Builds the data table mark by mark, in the order the renderer emits them, so the chat block fills it from the same
532
547
  * drawing that shows the chart.
533
548
  */
534
- export function cloudAiChartRows(input: CloudAiChartInput, locale: string) {
549
+ export function cloudAiChartRows(
550
+ input: CloudAiChartInput,
551
+ locale: string,
552
+ options: { formats?: { x?: (v: number) => string; y?: (v: number) => string; value?: (v: number) => string } } = {},
553
+ ) {
535
554
  const t = labels.resolve([locale]).t;
536
555
  const format = formats(input, locale);
556
+ const valueFormat = input.tooltip?.format
557
+ ? cloudAiNumberFormatters(input.tooltip.format, locale).value
558
+ : (options.formats?.value ?? (input.kind === "histogram" ? undefined : options.formats?.y) ?? format.value);
559
+ const xFormat = options.formats?.x ?? format.x.value;
537
560
  const series = seriesOf(input);
538
- const countFormat = cloudAiNumberFormatters(undefined, locale).value;
561
+ const countFormat = (input.kind === "histogram" ? options.formats?.y : undefined) ?? cloudAiNumberFormatters(undefined, locale).value;
539
562
  const percent = new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 });
540
563
  const unit = "unit" in input && input.unit ? ` ${input.unit}` : "";
541
564
  const columns = new Map<string, CloudAiChartColumn>();
@@ -575,26 +598,34 @@ export function cloudAiChartRows(input: CloudAiChartInput, locale: string) {
575
598
  "x",
576
599
  input.kind === "sparkline" || input.kind === "stat" ? "#" : header,
577
600
  value,
578
- input.kind === "sparkline" || input.kind === "stat" ? String(value + 1) : format.x.value(value),
601
+ input.kind === "sparkline" || input.kind === "stat" ? String(value + 1) : xFormat(value),
579
602
  );
580
603
  } else if (field.key === "y") {
581
604
  const header =
605
+ input.tooltip?.label ??
582
606
  (input.kind === "line" || input.kind === "scatter" ? input.yAxis?.label : undefined) ??
583
607
  (input.kind === "sparkline" || input.kind === "stat" ? t.value : "Y");
584
- cell("y", header, value, format.value(value));
608
+ cell("y", header, value, valueFormat(value));
585
609
  } else if (field.key === "value")
586
- cell("value", (input.kind === "bar" ? input.yAxis?.label : undefined) ?? t.value, value, `${format.value(value)}${unit}`);
587
- else if (field.key === "delta") cell("delta", t.delta, value, `${value > 0 ? "+" : ""}${format.value(value)}`);
588
- else if (field.key === "q1" || field.key === "q2" || field.key === "q3" || field.key === "whiskerLow" || field.key === "whiskerHigh")
589
- cell(field.key, t[field.key], value, format.value(value));
590
- else if (field.key === "percent") cell("percent", t.share, value, percent.format(value / 100));
591
- else if (field.key === "from" || field.key === "to" || field.key === "count" || field.key === "min" || field.key === "max")
592
610
  cell(
593
- field.key,
594
- t[field.key],
611
+ "value",
612
+ input.tooltip?.label ?? (input.kind === "bar" ? input.yAxis?.label : undefined) ?? t.value,
595
613
  value,
596
- `${field.key === "count" ? countFormat(value) : format.value(value)}${field.key === "min" || field.key === "max" ? unit : ""}`,
614
+ `${valueFormat(value)}${unit}`,
597
615
  );
616
+ else if (field.key === "delta") cell("delta", t.delta, value, `${value > 0 ? "+" : ""}${valueFormat(value)}`);
617
+ else if (field.key === "q1" || field.key === "q2" || field.key === "q3" || field.key === "whiskerLow" || field.key === "whiskerHigh")
618
+ cell(field.key, t[field.key], value, valueFormat(value));
619
+ else if (field.key === "percent") cell("percent", t.share, value, percent.format(value / 100));
620
+ else if (field.key === "from" || field.key === "to" || field.key === "count" || field.key === "min" || field.key === "max") {
621
+ const fieldFormat =
622
+ field.key === "count"
623
+ ? countFormat
624
+ : input.kind === "histogram" && (field.key === "from" || field.key === "to") && options.formats?.x
625
+ ? xFormat
626
+ : valueFormat;
627
+ cell(field.key, t[field.key], value, `${fieldFormat(value)}${field.key === "min" || field.key === "max" ? unit : ""}`);
628
+ }
598
629
  }
599
630
  const row = { key: cloudAiChartMarkKey(datum), cells, values };
600
631
  rows.push(row);
@@ -9,7 +9,6 @@ import {
9
9
  } from "@k2b/ui";
10
10
  import { createMemo, createUniqueId, Index, type JSX, Show } from "solid-js";
11
11
  import {
12
- type CloudAiChartColumn,
13
12
  type CloudAiChartRow,
14
13
  type CloudAiChartTable,
15
14
  cloudAiChartLegend,
@@ -17,21 +16,13 @@ import {
17
16
  cloudAiChartReferenceMark,
18
17
  cloudAiChartRenderOptions,
19
18
  cloudAiChartRows,
19
+ cloudAiChartTooltip,
20
20
  cloudAiChartXValueFormat,
21
21
  } from "../chart-block";
22
22
  import type { CloudAiChartInput } from "../default-tool-contracts";
23
23
  import { type CloudAiGroupView, parseCloudAiChartToolView } from "../view-block";
24
24
  import { aiChatMessages } from "./messages";
25
25
 
26
- const tooltip = (columns: CloudAiChartColumn[], row: CloudAiChartRow) => {
27
- const [first, ...rest] = columns;
28
- const named = first && !first.numeric;
29
- return {
30
- title: named ? row.cells[first.id] : undefined,
31
- rows: (named ? rest : columns).map((column) => ({ label: column.label, value: row.cells[column.id] ?? "" })),
32
- };
33
- };
34
-
35
26
  type Kind = CloudAiChartInput["kind"];
36
27
  type DrawnChart = { input: CloudAiChartInput; table: CloudAiChartTable; snapshot: ChartSnapshot; legend: ChartLegendEntry[] };
37
28
 
@@ -44,7 +35,7 @@ const drawChart = (input: CloudAiChartInput, locale: string): DrawnChart | null
44
35
  key: ({ datum }) => cloudAiChartMarkKey(datum),
45
36
  tooltip: ({ datum }) => {
46
37
  const row = rows.add(datum);
47
- return tooltip(rows.columns(), row);
38
+ return cloudAiChartTooltip(rows.columns(), row);
48
39
  },
49
40
  reference: ({ datum }) => cloudAiChartReferenceMark(input, datum),
50
41
  });
@@ -137,6 +137,11 @@ const messages = i18n.define({
137
137
  revokeFailed: "Could not revoke. Try again.",
138
138
  revokeWebsiteFor: ({ host }: { host: string }) => `Revoke the approval for ${host}`,
139
139
  websiteRevoked: "Revoked. The next request asks again.",
140
+ fetchingFile: ({ name }: { name: string }) => `Fetching ${name}`,
141
+ fetchedFile: ({ name }: { name: string }) => `Fetched ${name}`,
142
+ fetchSource: "Source",
143
+ fetchSavedAs: "Saved as",
144
+ fetchReadableCopy: "Readable copy",
140
145
  details: "Details",
141
146
  hideDetails: "Hide details",
142
147
  moreOptions: ({ title }: { title: string }) => `More options for ${title}`,
@@ -446,6 +451,11 @@ const messages = i18n.define({
446
451
  revokeFailed: "Konnte nicht entzogen werden. Versuche es erneut.",
447
452
  revokeWebsiteFor: ({ host }: { host: string }) => `Freigabe für ${host} entziehen`,
448
453
  websiteRevoked: "Entzogen. Der nächste Abruf fragt wieder.",
454
+ fetchingFile: ({ name }) => `Lädt herunter: ${name}`,
455
+ fetchedFile: ({ name }) => `Heruntergeladen: ${name}`,
456
+ fetchSource: "Quelle",
457
+ fetchSavedAs: "Gespeichert als",
458
+ fetchReadableCopy: "Lesbare Kopie",
449
459
  details: "Details",
450
460
  hideDetails: "Details ausblenden",
451
461
  moreOptions: ({ title }) => `Weitere Optionen für ${title}`,
@@ -136,15 +136,22 @@ export function WebExtractToolBlock(props: { block: ToolBlock }) {
136
136
  );
137
137
  }
138
138
 
139
- type FetchFileResult = { path: string; size: number; mediaType: string; url: string };
139
+ type FetchFileResult = { path: string; size: number; mediaType: string; url: string; readablePath?: string };
140
140
 
141
+ /** Results stored before readable copies existed have no `readable` field and still render. */
141
142
  const fetchFileResult = (value: unknown): FetchFileResult | null =>
142
143
  isRecord(value) &&
143
144
  typeof value.path === "string" &&
144
145
  typeof value.size === "number" &&
145
146
  typeof value.mediaType === "string" &&
146
147
  typeof value.url === "string"
147
- ? { path: value.path, size: value.size, mediaType: value.mediaType, url: value.url }
148
+ ? {
149
+ path: value.path,
150
+ size: value.size,
151
+ mediaType: value.mediaType,
152
+ url: value.url,
153
+ ...(isRecord(value.readable) && typeof value.readable.path === "string" ? { readablePath: value.readable.path } : {}),
154
+ }
148
155
  : null;
149
156
 
150
157
  const filenameOf = (value: string): string => {
@@ -158,6 +165,8 @@ const filenameOf = (value: string): string => {
158
165
 
159
166
  /** Imported web file: source identity, useful metadata, and chat path without raw tool JSON. */
160
167
  export function FetchFileToolBlock(props: { block: ToolBlock }) {
168
+ const locale = useLocale();
169
+ const t = () => aiChatMessages(locale());
161
170
  const sourceUrl = () => (isRecord(props.block.args) && typeof props.block.args.url === "string" ? props.block.args.url : "");
162
171
  const result = () => fetchFileResult(props.block.result);
163
172
  const displayName = () => filenameOf(result()?.path ?? sourceUrl()) || "file";
@@ -172,7 +181,7 @@ export function FetchFileToolBlock(props: { block: ToolBlock }) {
172
181
  when={props.block.status === "completed" && result()}
173
182
  fallback={
174
183
  <Chat.Activity
175
- label={`Fetching ${displayName()}`}
184
+ label={t().fetchingFile({ name: displayName() })}
176
185
  description={domainOf(sourceUrl())}
177
186
  leading={<Favicon url={sourceUrl()} fallbackIcon="ti ti-world-download" />}
178
187
  busy
@@ -183,19 +192,27 @@ export function FetchFileToolBlock(props: { block: ToolBlock }) {
183
192
  <AiToolActivity
184
193
  blockId={props.block.id}
185
194
  leading={<Favicon url={sourceUrl() || file().url} fallbackIcon="ti ti-world-download" />}
186
- label={`Fetched ${displayName()}`}
195
+ label={t().fetchedFile({ name: displayName() })}
187
196
  description={description()}
188
197
  bodyInset={false}
189
198
  >
190
199
  <dl class="grid w-full min-w-0 grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-1 rounded-md bg-zinc-100/70 px-2 py-1.5 text-xs [box-shadow:var(--ui-control-recess)] dark:bg-zinc-950/70">
191
- <dt class="font-medium text-dimmed">Source</dt>
200
+ <dt class="font-medium text-dimmed">{t().fetchSource}</dt>
192
201
  <dd class="min-w-0 truncate">
193
202
  <a href={file().url} target="_blank" rel="noreferrer noopener" class="text-secondary hover:text-primary">
194
203
  {file().url}
195
204
  </a>
196
205
  </dd>
197
- <dt class="font-medium text-dimmed">Saved as</dt>
206
+ <dt class="font-medium text-dimmed">{t().fetchSavedAs}</dt>
198
207
  <dd class="min-w-0 truncate text-secondary">{file().path}</dd>
208
+ <Show when={file().readablePath}>
209
+ {(readablePath) => (
210
+ <>
211
+ <dt class="font-medium text-dimmed">{t().fetchReadableCopy}</dt>
212
+ <dd class="min-w-0 truncate text-secondary">{readablePath()}</dd>
213
+ </>
214
+ )}
215
+ </Show>
199
216
  </dl>
200
217
  </AiToolActivity>
201
218
  )}
@@ -16,11 +16,15 @@ import {
16
16
  updateMandatePolicy,
17
17
  validateMandateIssueAuthority,
18
18
  } from "../services/mandates";
19
- import { parsePgJsonValue } from "../services/postgres";
19
+ import { parsePgJsonValue, toPgUuidArray } from "../services/postgres";
20
20
  import { withAiShortIdForDb } from "./short-id";
21
21
  import { aiConversations } from "./store";
22
22
  import type { AiChatTurnRunConfig, AiStoredMessage, AiTurnStatus } from "./types";
23
23
 
24
+ /** Run history and transcripts are operational evidence; 90 days matches capability execution history.
25
+ * Fixed platform constant, not configurable. */
26
+ export const AI_CHAT_TASK_RUN_RETENTION_DAYS = 90;
27
+
24
28
  export type AiChatTaskState = "active" | "paused" | "completed" | "needs_attention";
25
29
  export type AiChatTaskSchedule = { kind: "once"; runAt: string } | { kind: "cron"; cron: string };
26
30
  export type AiChatTask = {
@@ -914,6 +918,66 @@ export const aiChatTasks = {
914
918
  return occurrences;
915
919
  },
916
920
 
921
+ /** Removes expired runs and their evidence, preserving current once slot rows as dedupe guards. */
922
+ pruneExpiredRuns: async (input: { now?: Date; limit?: number } = {}): Promise<number> => {
923
+ const cutoff = new Date((input.now ?? new Date()).getTime() - AI_CHAT_TASK_RUN_RETENTION_DAYS * 86_400_000);
924
+ const limit = Math.min(Math.max(input.limit ?? 100, 1), 500);
925
+ return sql.begin(async (tx) => {
926
+ // Keep the current once slot row as a dedupe guard while its turn and transcript expire.
927
+ const occurrences = await tx<{ id: string; turn_id: string | null; keep: boolean }[]>`
928
+ SELECT occurrence.id, occurrence.turn_id, EXISTS (
929
+ SELECT 1 FROM ai.chat_tasks task WHERE task.id = occurrence.task_id
930
+ AND task.schedule_kind = 'once' AND task.run_at = occurrence.scheduled_for
931
+ ) AS keep
932
+ FROM ai.chat_task_occurrences occurrence
933
+ WHERE occurrence.state IN ('completed', 'failed') AND occurrence.completed_at < ${cutoff}
934
+ AND (occurrence.turn_id IS NOT NULL OR NOT EXISTS (
935
+ SELECT 1 FROM ai.chat_tasks task WHERE task.id = occurrence.task_id
936
+ AND task.schedule_kind = 'once' AND task.run_at = occurrence.scheduled_for
937
+ ))
938
+ AND NOT EXISTS (
939
+ SELECT 1 FROM ai.turns turn WHERE turn.id = occurrence.turn_id
940
+ AND turn.status NOT IN ('completed', 'failed', 'aborted')
941
+ )
942
+ ORDER BY occurrence.completed_at, occurrence.id
943
+ LIMIT ${limit}
944
+ FOR UPDATE OF occurrence SKIP LOCKED
945
+ `;
946
+ if (occurrences.length > 0) {
947
+ const occurrenceIds = toPgUuidArray(occurrences.filter((occurrence) => !occurrence.keep).map((occurrence) => occurrence.id));
948
+ const turnIds = toPgUuidArray(occurrences.flatMap((occurrence) => (occurrence.turn_id ? [occurrence.turn_id] : [])));
949
+ await tx`DELETE FROM ai.task_messages WHERE loop_id = ANY(${turnIds}::text[])`;
950
+ await tx`
951
+ DELETE FROM ai.turns WHERE id = ANY(${turnIds}::uuid[])
952
+ AND status IN ('completed', 'failed', 'aborted') AND run_config ? 'background'
953
+ `;
954
+ await tx`DELETE FROM ai.chat_task_occurrences WHERE id = ANY(${occurrenceIds}::uuid[])`;
955
+ }
956
+ const remaining = limit - occurrences.length;
957
+ if (remaining === 0) return occurrences.length;
958
+ const orphanedTurns = await tx<{ id: string }[]>`
959
+ SELECT turn.id FROM ai.turns turn
960
+ WHERE turn.run_config ? 'background' AND turn.status IN ('completed', 'failed', 'aborted')
961
+ AND turn.completed_at < ${cutoff}
962
+ AND NOT EXISTS (
963
+ SELECT 1 FROM ai.chat_task_occurrences occurrence WHERE occurrence.turn_id = turn.id
964
+ )
965
+ ORDER BY turn.completed_at, turn.id
966
+ LIMIT ${remaining}
967
+ FOR UPDATE OF turn SKIP LOCKED
968
+ `;
969
+ if (orphanedTurns.length === 0) return occurrences.length;
970
+ const orphanedTurnIds = toPgUuidArray(orphanedTurns.map((turn) => turn.id));
971
+ await tx`DELETE FROM ai.task_messages WHERE loop_id = ANY(${orphanedTurnIds}::text[])`;
972
+ const removedTurns = await tx<{ id: string }[]>`
973
+ DELETE FROM ai.turns WHERE id = ANY(${orphanedTurnIds}::uuid[])
974
+ AND status IN ('completed', 'failed', 'aborted') AND run_config ? 'background'
975
+ RETURNING id
976
+ `;
977
+ return occurrences.length + removedTurns.length;
978
+ });
979
+ },
980
+
917
981
  listQueuedOccurrences: async (limit = 100): Promise<Array<{ occurrence: AiChatTaskOccurrence; task: AiChatTask }>> => {
918
982
  const rows = await sql<(OccurrenceRow & TaskRow)[]>`
919
983
  SELECT occurrence.id, occurrence.short_id, occurrence.task_id, occurrence.scheduled_for, occurrence.trigger,