@k2b/cloud 0.31.0 → 0.33.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.
- package/package.json +8 -6
- package/src/_internal/define-app.ts +20 -9
- package/src/_internal/page-responses.ts +5 -1
- package/src/ai/app-skills.ts +1 -7
- package/src/ai/audio-tool.ts +2 -1
- package/src/ai/browser.ts +109 -0
- package/src/ai/capabilities.ts +6 -2
- package/src/ai/chart-block.ts +431 -81
- package/src/ai/chat/action-sentences.ts +13 -7
- package/src/ai/chat/blocks.tsx +11 -7
- package/src/ai/chat/builtin-tools.tsx +9 -7
- package/src/ai/chat/chart-block.tsx +218 -44
- package/src/ai/chat/explorer-block.tsx +276 -0
- package/src/ai/chat/file-tools.tsx +40 -1
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/message-utils.ts +7 -0
- package/src/ai/chat/messages.ts +16 -0
- package/src/ai/chat/presentation.tsx +13 -3
- package/src/ai/chat/reference-text.tsx +34 -0
- package/src/ai/chat/tool-groups.ts +1 -0
- package/src/ai/chat/tool-names.ts +14 -0
- package/src/ai/chat/turn-view.tsx +17 -7
- package/src/ai/chat/view-block.browser-harness.tsx +35 -0
- package/src/ai/chat/view-block.tsx +417 -0
- package/src/ai/chat/visual-tools.tsx +32 -53
- package/src/ai/client-tool-results.ts +9 -0
- package/src/ai/code-source-contracts.ts +17 -2
- package/src/ai/code-source-tools.ts +1 -0
- package/src/ai/default-tool-contracts.ts +334 -91
- package/src/ai/default-tools.ts +89 -13
- package/src/ai/executor.ts +173 -31
- package/src/ai/file-error.ts +2 -0
- package/src/ai/file-tools.ts +27 -7
- package/src/ai/files-store.ts +60 -0
- package/src/ai/html-pdf-tool.ts +3 -2
- package/src/ai/http.ts +37 -12
- package/src/ai/index.ts +90 -0
- package/src/ai/local-tool-contracts.ts +244 -0
- package/src/ai/malformed-tool-call-repair.ts +144 -0
- package/src/ai/markdown-pdf-tool.ts +2 -1
- package/src/ai/memories.ts +48 -1
- package/src/ai/memory-learning.ts +17 -6
- package/src/ai/memory-tool.ts +1 -0
- package/src/ai/migrate.ts +15 -0
- package/src/ai/openai-api-messages.ts +53 -0
- package/src/ai/openai-api-slots.ts +40 -0
- package/src/ai/openai-api.ts +383 -0
- package/src/ai/protocol.ts +6 -2
- package/src/ai/provider.ts +17 -4
- package/src/ai/request-context.ts +33 -0
- package/src/ai/routes.ts +96 -57
- package/src/ai/runtime.ts +48 -12
- package/src/ai/settings.ts +5 -0
- package/src/ai/skill-catalog.ts +20 -5
- package/src/ai/skill-format.ts +17 -7
- package/src/ai/skill-tool.ts +15 -5
- package/src/ai/skills-routes.ts +20 -6
- package/src/ai/skills.ts +26 -7
- package/src/ai/store.ts +25 -0
- package/src/ai/system-prompt.ts +69 -10
- package/src/ai/timeline.ts +3 -1
- package/src/ai/tools.ts +3 -0
- package/src/ai/types.ts +9 -0
- package/src/ai/view-block.ts +941 -0
- package/src/ai/vision-tool.ts +2 -1
- package/src/api/admin-identity.ts +6 -1
- package/src/api/app-approval-actions.ts +36 -0
- package/src/api/app-approval.ts +10 -2
- package/src/api/approvals-loader.ts +15 -0
- package/src/api/approvals.ts +120 -0
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +12 -3
- package/src/approvals/Approvals.browser-harness.tsx +16 -0
- package/src/approvals/Approvals.tsx +621 -0
- package/src/approvals/ApprovalsNavStatus.tsx +20 -0
- package/src/approvals/messages.ts +95 -0
- package/src/approvals/ui.ts +8 -0
- package/src/browser/app-approval.ts +32 -0
- package/src/browser/choose-files.tsx +26 -13
- package/src/browser/commands.ts +13 -4
- package/src/browser/file-provider-error.ts +11 -0
- package/src/browser/file-providers.ts +3 -12
- package/src/browser/files.tsx +1 -2
- package/src/browser/live-connection.ts +2 -3
- package/src/browser/navigation-search.ts +4 -1
- package/src/browser/provider-list.ts +2 -1
- package/src/browser/save-files.tsx +13 -2
- package/src/cli/account.ts +21 -2
- package/src/cli/commands.ts +4 -2
- package/src/contracts/app-approval.ts +120 -0
- package/src/contracts/approval-paths.ts +4 -0
- package/src/contracts/approvals.ts +81 -0
- package/src/contracts/command-path.ts +17 -0
- package/src/contracts/commands.ts +5 -15
- package/src/contracts/shared.ts +5 -0
- package/src/events/live-protocol.ts +2 -1
- package/src/events/live-server-message.ts +40 -0
- package/src/events/testing.ts +88 -0
- package/src/server/index.ts +2 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/request-timeout.ts +22 -0
- package/src/server/services/access.ts +4 -3
- package/src/services/app-approval.ts +103 -15
- package/src/services/approvals/cursor.ts +15 -0
- package/src/services/approvals/device.ts +112 -0
- package/src/services/approvals/index.ts +27 -11
- package/src/services/approvals/list.ts +120 -0
- package/src/services/approvals/migrate.ts +9 -1
- package/src/services/identity/models-only-auth.ts +25 -0
- package/src/services/notifications/email-frame.ts +16 -3
- package/src/services/outbox.ts +13 -6
- package/src/services/service-account-credentials.ts +22 -10
- package/src/services/settings/core-settings.ts +27 -0
- package/src/shared/ai-platform-prompt.ts +29 -3
- package/src/shared/email-html.ts +4 -0
- package/src/shared/index.ts +6 -1
- package/src/shared/markdown/extensions/code.ts +14 -0
- package/src/shared/markdown/index.ts +4 -4
- package/src/shared/theme.ts +41 -20
- package/src/ssr/AnnouncementBanners.tsx +30 -0
- package/src/ssr/AppBadge.tsx +4 -4
- package/src/ssr/AppLaunchpadPanel.tsx +6 -6
- package/src/ssr/CloudShell.island.tsx +91 -0
- package/src/ssr/GlobalSearchTrigger.tsx +51 -0
- package/src/ssr/Layout.tsx +56 -85
- package/src/ssr/LayoutHeader.tsx +31 -77
- package/src/ssr/LayoutHelp.tsx +2 -57
- package/src/ssr/LayoutHelpTrigger.tsx +51 -0
- package/src/ssr/{LayoutPreferences.island.tsx → LayoutPreferences.tsx} +2 -2
- package/src/ssr/LayoutRail.tsx +15 -69
- package/src/ssr/MinimalLayout.tsx +2 -3
- package/src/ssr/MinimalLayoutPreferences.island.tsx +12 -15
- package/src/ssr/MobileNavigation.tsx +13 -8
- package/src/ssr/MobileProfileActions.tsx +10 -7
- package/src/ssr/PageTrailRow.tsx +14 -0
- package/src/ssr/{ProfilePreferences.island.tsx → ProfilePreferences.tsx} +12 -7
- package/src/ssr/PwaLayout.tsx +0 -2
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/{RailApps.island.tsx → RailApps.tsx} +2 -2
- package/src/ssr/ShellRuntime.island.tsx +170 -0
- package/src/ssr/TimezoneCookie.island.tsx +3 -19
- package/src/ssr/WorkspaceNavigationProvider.tsx +20 -1
- package/src/ssr/announcements-dialog.tsx +66 -0
- package/src/ssr/app-badges.ts +50 -6
- package/src/ssr/{AppLaunchpad.island.tsx → app-launchpad.tsx} +42 -62
- package/src/ssr/busy-action.ts +19 -0
- package/src/ssr/islands/index.ts +9 -6
- package/src/ssr/launchpad-surface.tsx +15 -0
- package/src/ssr/layout-help-registry.tsx +60 -0
- package/src/ssr/mobile-navigation.browser-harness.tsx +20 -0
- package/src/ssr/page-trail.browser-harness.tsx +52 -3
- package/src/ssr/preference-controller.ts +30 -21
- package/src/ssr/profile-actions.ts +26 -15
- package/src/ssr/profile-preferences-messages.ts +8 -4
- package/src/ssr/rail-context.ts +8 -12
- package/src/ssr/rail-navigation.ts +5 -3
- package/src/ssr/runtime.ts +6 -1
- package/src/ssr/shell-data.tsx +48 -0
- package/src/ssr/timezone-cookie.ts +22 -0
- package/src/ssr/workspace-navigation.ts +25 -2
- package/src/styles/approvals.css +390 -0
- package/src/styles/global.css +22 -7
- package/src/styles/utilities-navigation.css +13 -0
- package/src/workflows/language/compiler.ts +1 -1
- package/src/ssr/AppWorkspaceController.island.tsx +0 -21
- package/src/ssr/BrowserPushRegistration.island.tsx +0 -12
- package/src/ssr/GlobalAnnouncements.island.tsx +0 -117
- package/src/ssr/GlobalSearchTrigger.island.tsx +0 -75
- package/src/ssr/LayoutHelpTrigger.island.tsx +0 -61
- package/src/ssr/PageTrailRow.island.tsx +0 -20
- package/src/ssr/PageTrailRuntime.island.tsx +0 -26
- package/src/ssr/RegisteredHelpDocuments.island.tsx +0 -3
- /package/src/ssr/{LayoutBreadcrumbs.island.tsx → LayoutBreadcrumbs.tsx} +0 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { NessiIssue, Provider, SessionStore, ToolCallBlock, ToolStreamIssue } from "@k2b/nessi";
|
|
2
|
+
import { AiTurnFailure } from "./turn-failure";
|
|
3
|
+
import type { AiStoredMessage } from "./types";
|
|
4
|
+
|
|
5
|
+
/** At most two automatic repairs per chat turn; user steering does not reset this budget. */
|
|
6
|
+
export const AI_MALFORMED_TOOL_CALL_REPAIR_ATTEMPTS = 2;
|
|
7
|
+
|
|
8
|
+
const excerpt = (text: string, maxChars: number): string => (text.length <= maxChars ? text : `${text.slice(0, maxChars - 1)}…`);
|
|
9
|
+
|
|
10
|
+
/** Never include argsText: code and other tool arguments can be very large. */
|
|
11
|
+
export const malformedToolCallMessage = (issue: ToolStreamIssue): string =>
|
|
12
|
+
`The tool call${issue.name ? ` to ${excerpt(issue.name, 120)}` : ""} could not be used: ${excerpt(issue.message, 512)} (${issue.reason}).`;
|
|
13
|
+
|
|
14
|
+
const REPAIR_REQUEST = "Re-send the call with its exact tool name and complete, valid, properly escaped JSON arguments.";
|
|
15
|
+
|
|
16
|
+
const buildStoredMalformedToolCallRepairText = (name: string): string => `The tool call to ${name} could not be used. ${REPAIR_REQUEST}`;
|
|
17
|
+
|
|
18
|
+
export const buildMalformedToolCallRepairText = (issue: ToolStreamIssue): string => `${malformedToolCallMessage(issue)} ${REPAIR_REQUEST}`;
|
|
19
|
+
|
|
20
|
+
// The prefix lets resumed turns count repairs independently of ordinary tool calls. The id is 34 characters of [a-z0-9_],
|
|
21
|
+
// within OpenAI's 40- and Anthropic's 64-character limits; stricter providers (Mistral's nine alphanumeric characters)
|
|
22
|
+
// map ids at the transport boundary (see provider.ts).
|
|
23
|
+
export const newMalformedToolCallId = (): string => `malformed_${crypto.randomUUID().replaceAll("-", "").slice(0, 24)}`;
|
|
24
|
+
|
|
25
|
+
export const isMalformedToolCallId = (id: string): boolean => /^malformed_[0-9a-f]{24}$/.test(id);
|
|
26
|
+
|
|
27
|
+
export const malformedToolCallName = (issue: ToolStreamIssue): string =>
|
|
28
|
+
issue.name && /^[A-Za-z0-9_-]{1,64}$/.test(issue.name) ? issue.name : "unknown_tool";
|
|
29
|
+
|
|
30
|
+
// Partial responses (error/interrupted) count too: their stored error result requests the repair on resume, so each
|
|
31
|
+
// saved response with a malformed call spends one repair, as in the live run.
|
|
32
|
+
export const countMalformedToolCallRepairs = (messages: readonly Pick<AiStoredMessage, "message">[]): number =>
|
|
33
|
+
messages.filter(
|
|
34
|
+
({ message }) =>
|
|
35
|
+
message.role === "assistant" && message.content.some((block) => block.type === "tool_call" && isMalformedToolCallId(block.id)),
|
|
36
|
+
).length;
|
|
37
|
+
|
|
38
|
+
// Since 0.17.1, nessi also repairs malformed calls with its own user message and counter. Cloud's session store
|
|
39
|
+
// drops user messages nessi appends, and applying Cloud's repair through steering resets nessi's counter.
|
|
40
|
+
// Only Cloud's repair reaches the model and user. Executor unit tests pin Cloud's exhaustion; live integration tests
|
|
41
|
+
// pin the repair results sent to the model, absence of nessi's message in storage and requests, and Cloud's logged failure.
|
|
42
|
+
export const createMalformedToolCallRepair = (previousAttempts: number) => {
|
|
43
|
+
let attempts = previousAttempts;
|
|
44
|
+
let lastIssue: ToolStreamIssue | undefined;
|
|
45
|
+
// Malformed calls of the provider response in flight; pending marks a stored response that still awaits its repair.
|
|
46
|
+
let issues: (ToolStreamIssue & { callId: string })[] = [];
|
|
47
|
+
// A stored response beyond the budget means the run stopped before it could fail the turn.
|
|
48
|
+
let pending = previousAttempts > AI_MALFORMED_TOOL_CALL_REPAIR_ATTEMPTS;
|
|
49
|
+
return {
|
|
50
|
+
startProviderTurn: () => {
|
|
51
|
+
issues = [];
|
|
52
|
+
},
|
|
53
|
+
noteIssue: (issue: NessiIssue): NessiIssue => {
|
|
54
|
+
if (issue.kind !== "malformed_tool_call") return issue;
|
|
55
|
+
const rewritten = { ...issue, callId: newMalformedToolCallId() };
|
|
56
|
+
issues.push(rewritten);
|
|
57
|
+
lastIssue = rewritten;
|
|
58
|
+
return rewritten;
|
|
59
|
+
},
|
|
60
|
+
/** Hands the response's malformed calls to the store once and marks the repair as due. */
|
|
61
|
+
takeResponseIssues: () => {
|
|
62
|
+
const taken = issues;
|
|
63
|
+
issues = [];
|
|
64
|
+
if (taken.length > 0) pending = true;
|
|
65
|
+
return taken;
|
|
66
|
+
},
|
|
67
|
+
takeSteering: (): string | undefined => {
|
|
68
|
+
if (!pending) return undefined;
|
|
69
|
+
pending = false;
|
|
70
|
+
if (attempts >= AI_MALFORMED_TOOL_CALL_REPAIR_ATTEMPTS || !lastIssue) {
|
|
71
|
+
throw new AiTurnFailure(
|
|
72
|
+
"failed",
|
|
73
|
+
lastIssue
|
|
74
|
+
? malformedToolCallMessage(lastIssue)
|
|
75
|
+
: "A stored response held a tool call that could not be used after the turn's repairs were spent.",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
attempts++;
|
|
79
|
+
return buildMalformedToolCallRepairText(lastIssue);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const withMalformedToolCallRepair = (provider: Provider, repair: ReturnType<typeof createMalformedToolCallRepair>): Provider => ({
|
|
85
|
+
...provider,
|
|
86
|
+
stream: async function* (request) {
|
|
87
|
+
repair.startProviderTurn();
|
|
88
|
+
for await (const event of provider.stream(request)) {
|
|
89
|
+
yield event.type === "issue" ? { ...event, issue: repair.noteIssue(event.issue) } : event;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export const withMalformedToolCallResults = (
|
|
95
|
+
store: SessionStore,
|
|
96
|
+
repair: ReturnType<typeof createMalformedToolCallRepair>,
|
|
97
|
+
): SessionStore => ({
|
|
98
|
+
...store,
|
|
99
|
+
load: async () => {
|
|
100
|
+
const entries = await store.load();
|
|
101
|
+
const assistantIndex = entries.findLastIndex(({ message, kind }) => message.role === "assistant" && kind === "message");
|
|
102
|
+
const assistant = entries[assistantIndex]?.message;
|
|
103
|
+
const following = entries.slice(assistantIndex + 1);
|
|
104
|
+
if (assistant?.role !== "assistant" || following.some(({ message }) => message.role === "user")) return entries;
|
|
105
|
+
const resolved = new Set(following.flatMap(({ message }) => (message.role === "tool_result" ? [message.callId] : [])));
|
|
106
|
+
const missing = assistant.content.filter(
|
|
107
|
+
(block): block is ToolCallBlock => block.type === "tool_call" && isMalformedToolCallId(block.id) && !resolved.has(block.id),
|
|
108
|
+
);
|
|
109
|
+
if (missing.length === 0) return entries;
|
|
110
|
+
for (const call of missing) {
|
|
111
|
+
await store.append({
|
|
112
|
+
role: "tool_result",
|
|
113
|
+
callId: call.id,
|
|
114
|
+
name: call.name,
|
|
115
|
+
isError: true,
|
|
116
|
+
result: buildStoredMalformedToolCallRepairText(call.name),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return store.load();
|
|
120
|
+
},
|
|
121
|
+
append: async (message, opts) => {
|
|
122
|
+
const issues = message.role === "assistant" && opts?.kind !== "summary" ? repair.takeResponseIssues() : [];
|
|
123
|
+
if (message.role !== "assistant" || issues.length === 0) {
|
|
124
|
+
await store.append(message, opts);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const calls: ToolCallBlock[] = issues.map((issue) => ({
|
|
128
|
+
type: "tool_call",
|
|
129
|
+
id: issue.callId,
|
|
130
|
+
name: malformedToolCallName(issue),
|
|
131
|
+
args: {},
|
|
132
|
+
}));
|
|
133
|
+
await store.append({ ...message, content: [...message.content, ...calls] }, opts);
|
|
134
|
+
for (const issue of issues) {
|
|
135
|
+
await store.append({
|
|
136
|
+
role: "tool_result",
|
|
137
|
+
callId: issue.callId,
|
|
138
|
+
name: malformedToolCallName(issue),
|
|
139
|
+
result: buildMalformedToolCallRepairText(issue),
|
|
140
|
+
isError: true,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
});
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type RenderMarkdownToPdfOptions,
|
|
8
8
|
renderMarkdownToPdf,
|
|
9
9
|
} from "../services/pdf";
|
|
10
|
+
import { aiMissingChatFileError } from "./file-error";
|
|
10
11
|
import { aiProjectFilePathFromMount } from "./file-mount";
|
|
11
12
|
import { type AiFileContent, aiFileStore, normalizeAiFilePath } from "./files-store";
|
|
12
13
|
import { withAiPdfConversionSlot } from "./pdf-conversions";
|
|
@@ -65,7 +66,7 @@ export const createCloudAiMarkdownToPdfTool = (dependencies: MarkdownPdfToolDepe
|
|
|
65
66
|
if (!ctx.conversationId) throw new Error("The markdown_to_pdf tool needs a conversation context.");
|
|
66
67
|
const sourcePath = conversationPath(input.path);
|
|
67
68
|
const source = await read({ conversationId: ctx.conversationId, path: sourcePath });
|
|
68
|
-
if (!source) throw
|
|
69
|
+
if (!source) throw aiMissingChatFileError(sourcePath, ctx.localToolsEnabled);
|
|
69
70
|
if (source.origin !== "assistant") {
|
|
70
71
|
throw new Error(`Create an assistant-owned Markdown file with write_file before converting ${sourcePath}.`);
|
|
71
72
|
}
|
package/src/ai/memories.ts
CHANGED
|
@@ -7,6 +7,10 @@ export const AI_MEMORY_CONTENT_MAX_CHARS = 500;
|
|
|
7
7
|
export const AI_MEMORY_HOT_MAX_ITEMS = 20;
|
|
8
8
|
export const AI_MEMORY_HOT_MAX_CHARS = 6_000;
|
|
9
9
|
|
|
10
|
+
// PostgreSQL stores at most 16,383 word positions per tsvector; a parsed token takes at least
|
|
11
|
+
// one character. 12,000 characters keep every position distinct for the stop-word pairing below.
|
|
12
|
+
const AI_MEMORY_RELEVANCE_MAX_CHARS = 12_000;
|
|
13
|
+
|
|
10
14
|
export type AiMemoryKind = "fact" | "preference" | "workflow";
|
|
11
15
|
export type AiMemoryPriority = "normal" | "pinned";
|
|
12
16
|
export type AiMemorySource = "user" | "agent" | "background";
|
|
@@ -131,6 +135,49 @@ const listActive = async (userId: string, limit: number): Promise<AiMemory[]> =>
|
|
|
131
135
|
return rows.map(toMemory);
|
|
132
136
|
};
|
|
133
137
|
|
|
138
|
+
const selectRelevant = async (input: { userId: string; text: string; total: number; limit: number }): Promise<AiMemory[]> => {
|
|
139
|
+
const text = (
|
|
140
|
+
input.text.length > AI_MEMORY_RELEVANCE_MAX_CHARS
|
|
141
|
+
? `${input.text.slice(0, AI_MEMORY_RELEVANCE_MAX_CHARS / 2)}\n${input.text.slice(-AI_MEMORY_RELEVANCE_MAX_CHARS / 2)}`
|
|
142
|
+
: input.text
|
|
143
|
+
).trim();
|
|
144
|
+
if (!text) return [];
|
|
145
|
+
|
|
146
|
+
const rows = await sql<MemoryRow[]>`
|
|
147
|
+
WITH english AS (
|
|
148
|
+
SELECT position, token.lexeme
|
|
149
|
+
FROM unnest(to_tsvector('english', ${text}::text)) AS token, unnest(token.positions) AS position
|
|
150
|
+
), german AS (
|
|
151
|
+
SELECT position, token.lexeme
|
|
152
|
+
FROM unnest(to_tsvector('german', ${text}::text)) AS token, unnest(token.positions) AS position
|
|
153
|
+
), words AS (
|
|
154
|
+
-- Both configurations parse the same tokens, so a position missing in one holds that language's stop word.
|
|
155
|
+
SELECT row_number() OVER () AS word, english, german
|
|
156
|
+
FROM (SELECT DISTINCT english.lexeme AS english, german.lexeme AS german FROM english JOIN german USING (position)) AS pairs
|
|
157
|
+
), lexemes AS (
|
|
158
|
+
SELECT DISTINCT words.word, lexeme FROM words, unnest(ARRAY[words.english, words.german]) AS lexeme
|
|
159
|
+
), matches AS (
|
|
160
|
+
SELECT DISTINCT memory.id, lexemes.word
|
|
161
|
+
FROM ai.memories AS memory
|
|
162
|
+
CROSS JOIN LATERAL unnest(tsvector_to_array(memory.stem_document)) AS document(lexeme)
|
|
163
|
+
JOIN lexemes ON lexemes.lexeme = document.lexeme
|
|
164
|
+
WHERE memory.user_id = ${input.userId}::uuid AND memory.deleted_at IS NULL AND memory.superseded_by_id IS NULL
|
|
165
|
+
), scores AS (
|
|
166
|
+
-- A word found in fewer of the user's memories says more about relevance.
|
|
167
|
+
SELECT id, sum(ln((${input.total} + 1)::float8 / matching)) AS score
|
|
168
|
+
FROM (SELECT id, count(*) OVER (PARTITION BY word) AS matching FROM matches) AS weighted
|
|
169
|
+
GROUP BY id
|
|
170
|
+
)
|
|
171
|
+
SELECT memory.id, memory.short_id, memory.user_id, memory.kind, memory.content, memory.priority, memory.source,
|
|
172
|
+
memory.source_conversation_id, memory.source_message_id, memory.resource_type, memory.resource_id,
|
|
173
|
+
memory.created_at, memory.updated_at
|
|
174
|
+
FROM ai.memories AS memory JOIN scores ON scores.id = memory.id
|
|
175
|
+
ORDER BY scores.score DESC, memory.updated_at DESC, memory.id ASC
|
|
176
|
+
LIMIT ${input.limit}
|
|
177
|
+
`;
|
|
178
|
+
return rows.map(toMemory);
|
|
179
|
+
};
|
|
180
|
+
|
|
134
181
|
const searchRows = async (input: { userId: string; query: string; limit: number; backend: SearchBackend }): Promise<AiMemory[]> => {
|
|
135
182
|
const query = input.query.trim();
|
|
136
183
|
if (!query) return listActive(input.userId, input.limit);
|
|
@@ -609,7 +656,7 @@ export const aiMemories = {
|
|
|
609
656
|
|
|
610
657
|
const [pinned, relevant, recent] = await Promise.all([
|
|
611
658
|
listActive(userId, 50).then((items) => items.filter((item) => item.priority === "pinned")),
|
|
612
|
-
|
|
659
|
+
selectRelevant({ userId, text: query, total: count, limit: 40 }),
|
|
613
660
|
listActive(userId, 40),
|
|
614
661
|
]);
|
|
615
662
|
const seen = new Set<string>();
|
|
@@ -57,6 +57,7 @@ type Candidate = {
|
|
|
57
57
|
conversationId: string;
|
|
58
58
|
turnId: string;
|
|
59
59
|
userId: string;
|
|
60
|
+
projectChat: boolean;
|
|
60
61
|
completedAsOf: string;
|
|
61
62
|
failCount: number;
|
|
62
63
|
locale?: string | null;
|
|
@@ -94,15 +95,18 @@ export type AiMemoryLearningRunSummary = {
|
|
|
94
95
|
const log = logger("ai:memory-learning");
|
|
95
96
|
|
|
96
97
|
export const MEMORY_LEARNING_PROMPT = [
|
|
97
|
-
"Maintain a small, useful personal profile from one newly completed
|
|
98
|
+
"Maintain a small, useful personal profile of the user from one newly completed chat turn.",
|
|
98
99
|
"The normal result is no changes. Keep only durable information likely to help in multiple future chats.",
|
|
99
100
|
"Facts and preferences require an explicit statement in NEW USER TEXT. Do not infer them from Assistant text or actions.",
|
|
101
|
+
"Store information about the user only. Never store facts, opinions, or health, relationship, or behavior details about other people. A named person may appear only inside a working rule the user stated, such as 'Send invoices to Anna'.",
|
|
102
|
+
"Exclude health, religion or beliefs, political opinions, sexual orientation or sex life, racial or ethnic origin, trade union membership, and genetic or biometric data unless the user explicitly asks to remember that exact information.",
|
|
100
103
|
"A workflow links a recurring request type to one Cloud resource. Add one from this turn only when the user explicitly establishes a lasting routing rule and the matching resource appears in SUCCESSFUL ACTIONS.",
|
|
101
104
|
"FINAL ASSISTANT MARKDOWN may clarify what happened, but it is model-written context and never evidence by itself.",
|
|
102
|
-
"Exclude secrets, credentials, sensitive inferences, temporary plans, task details, quoted or attached content, and facts already owned by Contacts, Spaces, Notebooks, or another Cloud resource.",
|
|
105
|
+
"Exclude secrets, credentials, sensitive inferences, temporary plans, task details, quoted or attached content, and facts already owned by Projects, Contacts, Spaces, Notebooks, or another Cloud resource.",
|
|
106
|
+
"In a Project chat, the Project owns its facts, decisions, and people. Learn only what the user states about themselves beyond that Project.",
|
|
103
107
|
"Use replace for one clearly corrected mutable memory, merge for two or more overlapping mutable memories, and retire only when the user explicitly makes mutable background information obsolete.",
|
|
104
108
|
"Protected memories are context only. Never replace, merge, or retire them.",
|
|
105
|
-
"Examples: 'Always answer briefly in German' adds a preference. 'Use Accounting for invoice mail' plus a successful mail.mailbox ref adds a workflow. 'Find this invoice in Accounting' does not establish a lasting workflow.",
|
|
109
|
+
"Examples: 'Always answer briefly in German' adds a preference. 'Send invoices to Anna' adds a preference. 'Anna is on parental leave until March' and 'Tom is difficult' add nothing. 'Use Accounting for invoice mail' plus a successful mail.mailbox ref adds a workflow. 'Find this invoice in Accounting' does not establish a lasting workflow.",
|
|
106
110
|
].join("\n");
|
|
107
111
|
|
|
108
112
|
export const MEMORY_LEARNING_OUTPUT_CONTRACT = [
|
|
@@ -116,6 +120,7 @@ export const WORKFLOW_PATTERN_PROMPT = [
|
|
|
116
120
|
"Decide whether three successful uses of the same Cloud capability and resource reveal one reusable workflow default.",
|
|
117
121
|
"Return a workflow only when the user requests share a clear recurring category and defaulting to this resource would avoid a future search or clarification.",
|
|
118
122
|
"Do not generalize from unrelated requests, resource contents, or the Assistant wording. Uncertainty returns null.",
|
|
123
|
+
"Name the recurring category neutrally: never put details about other people or health, religion or beliefs, political opinions, sexual orientation or sex life, racial or ethnic origin, trade union membership, or genetic or biometric data in the rule. When examples come from Project chats, return a workflow only if it clearly applies beyond that Project; otherwise return null.",
|
|
119
124
|
"If an existing mutable workflow is corrected, return its id. Return multiple ids only when one new workflow genuinely consolidates them. Protected memories cannot be changed.",
|
|
120
125
|
].join("\n");
|
|
121
126
|
|
|
@@ -141,6 +146,7 @@ export const listAiMemoryLearningCandidates = async (limit: number, monthlyToken
|
|
|
141
146
|
conversation.id AS conversation_id,
|
|
142
147
|
turn.id AS turn_id,
|
|
143
148
|
conversation.created_by_user_id AS user_id,
|
|
149
|
+
conversation.project_id IS NOT NULL AS project_chat,
|
|
144
150
|
turn.completed_at,
|
|
145
151
|
turn.memory_learn_fail_count,
|
|
146
152
|
turn.run_config->>'locale' AS locale,
|
|
@@ -168,6 +174,7 @@ export const listAiMemoryLearningCandidates = async (limit: number, monthlyToken
|
|
|
168
174
|
conversation_id AS "conversationId",
|
|
169
175
|
turn_id AS "turnId",
|
|
170
176
|
user_id AS "userId",
|
|
177
|
+
project_chat AS "projectChat",
|
|
171
178
|
completed_at::text AS "completedAsOf",
|
|
172
179
|
memory_learn_fail_count AS "failCount",
|
|
173
180
|
locale
|
|
@@ -292,6 +299,8 @@ const actionLines = (actions: AiMemoryWorkflowEvidence[]): string =>
|
|
|
292
299
|
|
|
293
300
|
const learningInput = (evidence: TurnEvidence, memories: Awaited<ReturnType<typeof aiMemories.list>>): string =>
|
|
294
301
|
[
|
|
302
|
+
evidence.candidate.projectChat ? "CHAT: Project chat" : "CHAT: chat outside Projects",
|
|
303
|
+
"",
|
|
295
304
|
"NEW USER TEXT:",
|
|
296
305
|
evidence.userText || "(none)",
|
|
297
306
|
"",
|
|
@@ -361,8 +370,9 @@ const workflowPatternInput = async (
|
|
|
361
370
|
if (!(await isAiWorkflowPatternEligible(pattern))) return { input: "", source: null, mutableMemoryIds: new Set() };
|
|
362
371
|
const sources = await Promise.all(
|
|
363
372
|
pattern.turnIds.map(async (turnId) => {
|
|
364
|
-
const [row] = await sql<{ conversation_id: string; completed_as_of: string; fail_count: number }[]>`
|
|
365
|
-
SELECT turn.conversation_id,
|
|
373
|
+
const [row] = await sql<{ conversation_id: string; project_chat: boolean; completed_as_of: string; fail_count: number }[]>`
|
|
374
|
+
SELECT turn.conversation_id, conversation.project_id IS NOT NULL AS project_chat,
|
|
375
|
+
turn.completed_at::text AS completed_as_of, turn.memory_learn_fail_count AS fail_count
|
|
366
376
|
FROM ai.turns turn
|
|
367
377
|
JOIN ai.conversations conversation ON conversation.id = turn.conversation_id
|
|
368
378
|
WHERE turn.id = ${turnId}::uuid AND conversation.created_by_user_id = ${pattern.userId}::uuid
|
|
@@ -373,6 +383,7 @@ const workflowPatternInput = async (
|
|
|
373
383
|
conversationId: row.conversation_id,
|
|
374
384
|
turnId,
|
|
375
385
|
userId: pattern.userId,
|
|
386
|
+
projectChat: row.project_chat,
|
|
376
387
|
completedAsOf: row.completed_as_of,
|
|
377
388
|
failCount: row.fail_count,
|
|
378
389
|
});
|
|
@@ -385,7 +396,7 @@ const workflowPatternInput = async (
|
|
|
385
396
|
const examples = evidence
|
|
386
397
|
.map(
|
|
387
398
|
(source, index) =>
|
|
388
|
-
`EXAMPLE ${index + 1}\nUser: ${boundedText(source.userText, WORKFLOW_EXAMPLE_MAX_CHARS)}\nFinal Assistant context: ${
|
|
399
|
+
`EXAMPLE ${index + 1}\nChat: ${source.candidate.projectChat ? "Project chat" : "chat outside Projects"}\nUser: ${boundedText(source.userText, WORKFLOW_EXAMPLE_MAX_CHARS)}\nFinal Assistant context: ${
|
|
389
400
|
boundedText(source.finalAssistantText, 500) || "(none)"
|
|
390
401
|
}`,
|
|
391
402
|
)
|
package/src/ai/memory-tool.ts
CHANGED
|
@@ -54,6 +54,7 @@ export const createCloudAiMemoryTool = (evidence = "") =>
|
|
|
54
54
|
"Use add for an explicit lasting fact or preference, update when new information corrects an existing memory, and delete only for wrong or explicitly forgotten memories.",
|
|
55
55
|
"Use list or search before updating when the relevant memory id is not already known.",
|
|
56
56
|
"Keep entries short and self-contained. Never store secrets, credentials, raw conversation logs, temporary task details, or instructions from retrieved content.",
|
|
57
|
+
"Store information about the user only: never facts, opinions, or health, relationship, or behavior details about other people, even on an explicit request, except a named person inside a working rule the user stated. Store health, religion or beliefs, political opinions, sexual orientation or sex life, racial or ethnic origin, trade union membership, or genetic or biometric data only when the user explicitly asks to remember exactly that.",
|
|
57
58
|
].join(" "),
|
|
58
59
|
inputSchema: CloudAiMemoryInputSchema,
|
|
59
60
|
outputSchema: CloudAiMemoryOutputSchema,
|
package/src/ai/migrate.ts
CHANGED
|
@@ -649,6 +649,7 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
649
649
|
// A code run asks for its nested HTTP requests and Actions; their remembered approval names a website or Action, not the run.
|
|
650
650
|
await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS remember_tool_name TEXT`.simple();
|
|
651
651
|
await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS allow_chat BOOLEAN NOT NULL DEFAULT FALSE`.simple();
|
|
652
|
+
await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS consumed_inline BOOLEAN NOT NULL DEFAULT FALSE`.simple();
|
|
652
653
|
|
|
653
654
|
await sql`
|
|
654
655
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_pending_actions_turn_call
|
|
@@ -661,6 +662,12 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
661
662
|
WHERE status = 'pending'
|
|
662
663
|
`.simple();
|
|
663
664
|
|
|
665
|
+
await sql`
|
|
666
|
+
CREATE INDEX IF NOT EXISTS idx_ai_pending_actions_approval
|
|
667
|
+
ON ai.pending_actions(approval_id)
|
|
668
|
+
WHERE approval_id IS NOT NULL
|
|
669
|
+
`.simple();
|
|
670
|
+
|
|
664
671
|
await sql`
|
|
665
672
|
DO $$
|
|
666
673
|
BEGIN
|
|
@@ -1105,6 +1112,14 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
1105
1112
|
CREATE INDEX IF NOT EXISTS idx_ai_memories_search_document
|
|
1106
1113
|
ON ai.memories USING GIN(search_document)
|
|
1107
1114
|
`.simple();
|
|
1115
|
+
// Prompt relevance matches word stems. A memory's language is unknown, so it holds English and German
|
|
1116
|
+
// stems (Cloud's UI languages, as in help search); search_document stays for explicit memory search.
|
|
1117
|
+
// No GIN index: relevance is ranked within one user's memories, which idx_ai_memories_user_active
|
|
1118
|
+
// bounds, while a global index would scan every user's matches for common words.
|
|
1119
|
+
await sql`
|
|
1120
|
+
ALTER TABLE ai.memories ADD COLUMN IF NOT EXISTS stem_document TSVECTOR
|
|
1121
|
+
GENERATED ALWAYS AS (to_tsvector('english', content) || to_tsvector('german', content)) STORED
|
|
1122
|
+
`.simple();
|
|
1108
1123
|
await sql`
|
|
1109
1124
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_memories_user_content_active
|
|
1110
1125
|
ON ai.memories(user_id, lower(content))
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { i18n } from "@k2b/stdlib";
|
|
2
|
+
|
|
3
|
+
const catalog = i18n.define({
|
|
4
|
+
baseLocale: "en",
|
|
5
|
+
messages: {
|
|
6
|
+
en: {
|
|
7
|
+
openai_api_disabled: "The OpenAI-compatible API is turned off. Contact an administrator.",
|
|
8
|
+
invalid_api_key: "Use an active API key with the access “AI models only”. Create one in your Cloud profile.",
|
|
9
|
+
model_not_found: "This model is unavailable or you do not have access to it.",
|
|
10
|
+
invalid_request: "The request does not match the supported chat completion format.",
|
|
11
|
+
unsupported_content:
|
|
12
|
+
"Only text messages with system, developer, user or assistant roles are supported. Tool calls are not supported.",
|
|
13
|
+
unsupported_parameter: "This parameter is not supported. This API gives access to the models only.",
|
|
14
|
+
context_length_exceeded: "Your messages exceed this model's context window. Shorten them and try again.",
|
|
15
|
+
request_too_large: "The request body exceeds the limit derived from your available models' context windows.",
|
|
16
|
+
ai_disabled: "AI is turned off. Contact an administrator.",
|
|
17
|
+
model_access_denied: "You do not have access to this model. Contact an administrator.",
|
|
18
|
+
missing_provider_credential: "This model's provider is not configured. Contact an administrator.",
|
|
19
|
+
insufficient_quota:
|
|
20
|
+
"Your Assistant usage limit is reached, or your usage could not be measured. Wait for the reset or contact an administrator.",
|
|
21
|
+
too_many_concurrent_requests: "You have too many simultaneous requests. Wait for a request to finish and try again.",
|
|
22
|
+
rate_limit_exceeded: "Too many requests. Wait and try again.",
|
|
23
|
+
upstream_error: "The model provider could not complete your request. Try again later.",
|
|
24
|
+
internal_error: "Cloud could not process your request. Try again later.",
|
|
25
|
+
not_found: "This OpenAI-compatible API route does not exist.",
|
|
26
|
+
},
|
|
27
|
+
de: {
|
|
28
|
+
openai_api_disabled: "Die OpenAI-kompatible API ist ausgeschaltet. Wende dich an einen Administrator.",
|
|
29
|
+
invalid_api_key: "Verwende einen aktiven API-Schlüssel mit dem Zugriff „Nur KI-Modelle“. Du erstellst ihn in deinem Cloud-Profil.",
|
|
30
|
+
model_not_found: "Dieses Modell ist nicht verfügbar oder nicht für dich freigegeben.",
|
|
31
|
+
invalid_request: "Deine Anfrage entspricht nicht dem unterstützten Format für Chat-Vervollständigungen.",
|
|
32
|
+
unsupported_content:
|
|
33
|
+
"Nur Textnachrichten mit den Rollen system, developer, user oder assistant werden unterstützt. Werkzeugaufrufe werden nicht unterstützt.",
|
|
34
|
+
unsupported_parameter: "Dieser Parameter wird nicht unterstützt. Diese API gibt nur Zugriff auf die Modelle.",
|
|
35
|
+
context_length_exceeded: "Deine Nachrichten überschreiten das Kontextfenster dieses Modells. Kürze sie und versuche es erneut.",
|
|
36
|
+
request_too_large: "Deine Anfrage überschreitet die aus den Kontextfenstern deiner verfügbaren Modelle abgeleitete Größenbegrenzung.",
|
|
37
|
+
ai_disabled: "KI ist ausgeschaltet. Wende dich an einen Administrator.",
|
|
38
|
+
model_access_denied: "Dieses Modell ist nicht für dich freigegeben. Wende dich an einen Administrator.",
|
|
39
|
+
missing_provider_credential: "Der Anbieter dieses Modells ist nicht eingerichtet. Wende dich an einen Administrator.",
|
|
40
|
+
insufficient_quota:
|
|
41
|
+
"Dein Nutzungslimit im Assistenten ist erreicht, oder deine Nutzung konnte nicht gemessen werden. Warte auf das Zurücksetzen oder wende dich an einen Administrator.",
|
|
42
|
+
too_many_concurrent_requests: "Du hast zu viele gleichzeitige Anfragen. Warte, bis eine Anfrage beendet ist, und versuche es erneut.",
|
|
43
|
+
rate_limit_exceeded: "Zu viele Anfragen. Warte kurz und versuche es erneut.",
|
|
44
|
+
upstream_error: "Der Modellanbieter konnte deine Anfrage nicht abschließen. Versuche es später erneut.",
|
|
45
|
+
internal_error: "Cloud konnte deine Anfrage nicht verarbeiten. Versuche es später erneut.",
|
|
46
|
+
not_found: "Diese Route der OpenAI-kompatiblen API existiert nicht.",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export type OpenAiErrorCode = keyof ReturnType<typeof catalog.resolve>["t"];
|
|
52
|
+
export const openAiApiMessages = (locale: string) => catalog.resolve([locale]).t;
|
|
53
|
+
export const checkOpenAiApiMessages = () => catalog.check();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { redis } from "bun";
|
|
2
|
+
import { logger } from "../services/logging";
|
|
3
|
+
|
|
4
|
+
const LEASE_MS = 120_000;
|
|
5
|
+
const log = logger("ai:openai-api");
|
|
6
|
+
const SLOT_SCRIPT = `
|
|
7
|
+
local time = redis.call("TIME")
|
|
8
|
+
local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
|
|
9
|
+
local key = KEYS[1]
|
|
10
|
+
local slot = ARGV[1]
|
|
11
|
+
local leaseMs = tonumber(ARGV[2])
|
|
12
|
+
redis.call("ZREMRANGEBYSCORE", key, "-inf", now)
|
|
13
|
+
if ARGV[3] == "refresh" then
|
|
14
|
+
if not redis.call("ZSCORE", key, slot) then return 0 end
|
|
15
|
+
elseif redis.call("ZCARD", key) >= tonumber(ARGV[3]) then
|
|
16
|
+
return 0
|
|
17
|
+
end
|
|
18
|
+
redis.call("ZADD", key, now + leaseMs, slot)
|
|
19
|
+
redis.call("PEXPIRE", key, leaseMs)
|
|
20
|
+
return 1
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
export const acquire = async ({ userId, limit }: { userId: string; limit: number }): Promise<{ release(): Promise<void> } | null> => {
|
|
24
|
+
const key = `cloud:ai:openai-api:slots:${userId}`;
|
|
25
|
+
const slot = crypto.randomUUID();
|
|
26
|
+
const granted = await redis.send("EVAL", [SLOT_SCRIPT, "1", key, slot, String(LEASE_MS), String(limit)]);
|
|
27
|
+
if (granted !== 1) return null;
|
|
28
|
+
const heartbeat = setInterval(() => {
|
|
29
|
+
void redis
|
|
30
|
+
.send("EVAL", [SLOT_SCRIPT, "1", key, slot, String(LEASE_MS), "refresh"])
|
|
31
|
+
.catch(() => log.warn("OpenAI API slot heartbeat failed"));
|
|
32
|
+
}, 30_000);
|
|
33
|
+
heartbeat.unref();
|
|
34
|
+
return {
|
|
35
|
+
async release() {
|
|
36
|
+
clearInterval(heartbeat);
|
|
37
|
+
await redis.send("ZREM", [key, slot]);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
};
|