@k2b/cloud 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/_internal/define-app.ts +8 -1
- package/src/_internal/process-identity.ts +7 -1
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/ai/admin.ts +1 -0
- package/src/ai/browser-code-contracts.ts +46 -64
- package/src/ai/browser.ts +9 -1
- package/src/ai/capabilities.ts +82 -22
- package/src/ai/chat/blocks.tsx +16 -7
- package/src/ai/chat/builtin-tools.tsx +31 -23
- package/src/ai/chat/file-tools.tsx +4 -1
- package/src/ai/chat/live-turn.browser-harness.tsx +15 -5
- package/src/ai/chat/message-actions.tsx +123 -101
- package/src/ai/chat/message-utils.ts +30 -1
- package/src/ai/chat/messages.ts +206 -2
- package/src/ai/chat/presentation.tsx +94 -28
- package/src/ai/chat/tool-groups.ts +1 -1
- package/src/ai/chat/turn-error.ts +21 -0
- package/src/ai/chat/turn-view.tsx +102 -16
- package/src/ai/chat/user-message.tsx +19 -14
- package/src/ai/chat/visual-tools.tsx +1 -1
- package/src/ai/client/controller.ts +83 -37
- package/src/ai/client/file-source.ts +20 -3
- package/src/ai/client/projection.ts +7 -2
- package/src/ai/code-mode-skill.ts +32 -36
- package/src/ai/code-runtime-tools.ts +14 -1
- package/src/ai/code-source-contracts.ts +56 -6
- package/src/ai/code-source-tools.ts +10 -3
- package/src/ai/credentials.ts +17 -3
- package/src/ai/data-analysis-skill.ts +3 -3
- package/src/ai/default-tools.ts +15 -16
- package/src/ai/executor.ts +281 -141
- package/src/ai/file-context.ts +14 -2
- package/src/ai/file-tools.ts +17 -3
- package/src/ai/files-store.ts +134 -11
- package/src/ai/grids-skill.ts +2 -2
- package/src/ai/index.ts +9 -0
- package/src/ai/memories.ts +14 -0
- package/src/ai/migrate.ts +125 -0
- package/src/ai/model-request-settings.ts +98 -0
- package/src/ai/open-tool-calls.ts +87 -0
- package/src/ai/protocol.ts +6 -0
- package/src/ai/provider.ts +7 -1
- package/src/ai/quota-provider.ts +2 -2
- package/src/ai/request-headers.ts +117 -0
- package/src/ai/routes.ts +40 -6
- package/src/ai/run-timeout.ts +4 -5
- package/src/ai/runtime.ts +9 -1
- package/src/ai/settings.ts +19 -2
- package/src/ai/skill-seeds.ts +35 -7
- package/src/ai/skills.ts +26 -0
- package/src/ai/solid.ts +1 -1
- package/src/ai/store.ts +155 -71
- package/src/ai/stream.ts +180 -37
- package/src/ai/structured.ts +20 -5
- package/src/ai/system-prompt.ts +8 -0
- package/src/ai/tool-call-names.ts +45 -0
- package/src/ai/turn-failure.ts +100 -0
- package/src/ai/turn-policy.ts +248 -0
- package/src/ai/types.ts +52 -4
- package/src/api/admin-ai-quotas.ts +36 -1
- package/src/api/admin-core-settings.ts +16 -23
- package/src/api/admin-outgoing-mail.ts +242 -0
- package/src/api/index.ts +2 -0
- package/src/browser/FileChooser.tsx +11 -0
- package/src/browser/file-chooser-messages.ts +2 -0
- package/src/cli/admin/ai-quotas.ts +70 -1
- package/src/cli/admin/index.ts +8 -0
- package/src/cli/admin/notifications.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +215 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/outgoing-mail.ts +265 -0
- package/src/contracts/registry.ts +2 -0
- package/src/services/help/store.ts +2 -1
- package/src/services/index.ts +13 -0
- package/src/services/notifications/batches.ts +139 -79
- package/src/services/notifications/channels.ts +33 -13
- package/src/services/notifications/dispatcher.ts +57 -5
- package/src/services/notifications/email-frame.fixture.html +51 -0
- package/src/services/notifications/{email.ts → email-frame.ts} +12 -45
- package/src/services/notifications/email-mail.ts +101 -0
- package/src/services/notifications/index.ts +49 -36
- package/src/services/notifications/observability.ts +9 -1
- package/src/services/notifications/platform.ts +1 -1
- package/src/services/notifications/runtime.ts +9 -3
- package/src/services/outgoing-mail/admin.ts +84 -0
- package/src/services/outgoing-mail/attachments.ts +135 -0
- package/src/services/outgoing-mail/bulk.ts +11 -0
- package/src/services/outgoing-mail/dispatcher.ts +231 -0
- package/src/services/outgoing-mail/drain.ts +60 -0
- package/src/services/outgoing-mail/enqueue.ts +180 -0
- package/src/services/outgoing-mail/index.ts +112 -0
- package/src/services/outgoing-mail/message.ts +14 -0
- package/src/services/outgoing-mail/messages.ts +431 -0
- package/src/services/outgoing-mail/retention.ts +48 -0
- package/src/services/outgoing-mail/runtime.ts +72 -0
- package/src/services/outgoing-mail/send.ts +130 -0
- package/src/services/outgoing-mail/store.ts +397 -0
- package/src/services/outgoing-mail/sync.ts +39 -0
- package/src/services/outgoing-mail/test-send.ts +40 -0
- package/src/services/outgoing-mail/transport.ts +17 -0
- package/src/services/pdf/markdown.ts +22 -4
- package/src/services/postgres.ts +15 -0
- package/src/services/settings/core-settings.ts +19 -38
- package/src/services/settings/store.ts +5 -1
- package/src/shared/ai-model-request-settings.ts +21 -0
- package/src/shared/ai-platform-prompt.ts +49 -5
- package/src/shared/ai-request-options.ts +185 -0
- package/src/shared/markdown/extensions/links.ts +28 -20
- package/src/shared/markdown/index.ts +14 -5
- package/src/shared/markdown/shared.ts +0 -7
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/admin-navigation.ts +1 -1
- package/src/ssr/platform-messages.ts +3 -1
- package/src/ssr/workspace-navigation.ts +7 -1
- package/src/styles/effects.css +15 -17
- package/src/styles/tokens.css +2 -0
- package/src/styles/utilities-markdown-editor.css +4 -39
- package/src/styles/utilities-markdown-table.css +14 -17
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Message, Provider } from "@k2b/nessi";
|
|
2
|
+
|
|
3
|
+
/** What the model reads for a call of an earlier turn that never returned. */
|
|
4
|
+
export const AI_OPEN_TOOL_CALL_RESULT =
|
|
5
|
+
"No result: the turn ended before this call returned, so it may or may not have run. Check its effect before you repeat it.";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* nessi answers a call without a directly following result with this text before it calls the provider. It does not
|
|
9
|
+
* say whether the call ran, and it leaves a result stored after another message apart from its call. nessi does not
|
|
10
|
+
* export the text; the tests that run a request through nessi fail when it changes.
|
|
11
|
+
*/
|
|
12
|
+
const NESSI_INTERRUPTED_RESULT = "Tool call was interrupted before it returned a result.";
|
|
13
|
+
|
|
14
|
+
const isNessiAnswer = (message: Message): boolean =>
|
|
15
|
+
message.role === "tool_result" && message.isError === true && message.result === NESSI_INTERRUPTED_RESULT;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Where each call of the model message at `index` has its result: the first result for it before the next user
|
|
19
|
+
* message, or before a later model message with a call of the same ID, since some providers reuse call IDs from turn to
|
|
20
|
+
* turn.
|
|
21
|
+
*/
|
|
22
|
+
const resultsOf = (messages: readonly Message[], index: number, ids: ReadonlySet<string>): Map<string, number> => {
|
|
23
|
+
const found = new Map<string, number>();
|
|
24
|
+
for (let next = index + 1; next < messages.length && found.size < ids.size; next++) {
|
|
25
|
+
const later = messages[next]!;
|
|
26
|
+
if (later.role === "user") break;
|
|
27
|
+
if (later.role === "assistant" && later.content.some((block) => block.type === "tool_call" && ids.has(block.id))) break;
|
|
28
|
+
if (later.role === "tool_result" && ids.has(later.callId) && !found.has(later.callId)) found.set(later.callId, next);
|
|
29
|
+
}
|
|
30
|
+
return found;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Gives every call its result right after the message that made it. A call without one gets an error result; a result
|
|
35
|
+
* stored after another model message, such as a scheduled result delivered while the call ran, moves up to its call.
|
|
36
|
+
*/
|
|
37
|
+
const answerOpenCalls = (request: Message[]): Message[] => {
|
|
38
|
+
// nessi's own answers make way for the result stored apart from the call, or for a clearer answer.
|
|
39
|
+
const messages = request.some(isNessiAnswer) ? request.filter((message) => !isNessiAnswer(message)) : request;
|
|
40
|
+
let out: Message[] | null = null;
|
|
41
|
+
const moved = new Set<number>();
|
|
42
|
+
for (let index = 0; index < messages.length; index++) {
|
|
43
|
+
if (moved.has(index)) continue;
|
|
44
|
+
const message = messages[index]!;
|
|
45
|
+
out?.push(message);
|
|
46
|
+
if (message.role !== "assistant") continue;
|
|
47
|
+
const calls = message.content.flatMap((block) => (block.type === "tool_call" ? [block] : []));
|
|
48
|
+
if (calls.length === 0) continue;
|
|
49
|
+
const results = resultsOf(messages, index, new Set(calls.map((call) => call.id)));
|
|
50
|
+
const late = (at: number) => messages.slice(index + 1, at).some((later) => later.role === "assistant");
|
|
51
|
+
const misplaced = calls.filter((call) => {
|
|
52
|
+
const at = results.get(call.id);
|
|
53
|
+
return at === undefined || late(at);
|
|
54
|
+
});
|
|
55
|
+
if (misplaced.length === 0) continue;
|
|
56
|
+
out ??= messages.slice(0, index + 1);
|
|
57
|
+
for (const call of misplaced) {
|
|
58
|
+
const at = results.get(call.id);
|
|
59
|
+
if (at === undefined) {
|
|
60
|
+
out.push({ role: "tool_result", callId: call.id, name: call.name, result: AI_OPEN_TOOL_CALL_RESULT, isError: true });
|
|
61
|
+
} else {
|
|
62
|
+
out.push(messages[at]!);
|
|
63
|
+
moved.add(at);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out ?? messages;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A turn that was stopped or failed while a call ran or waited for approval leaves that call without a result, and a
|
|
72
|
+
* scheduled result delivered while a call ran is stored between the call and its result. Providers reject such a
|
|
73
|
+
* history, so every later turn of the chat would fail, and the model could not tell whether the call ran. The request
|
|
74
|
+
* answers each open call as not returned and puts each result next to its call; the stored history keeps everything
|
|
75
|
+
* as it was, so the chat still shows an open call as not run. A running turn does not see scheduled results that
|
|
76
|
+
* arrived during it, and nessi answers every call before the next model call, so only earlier turns need this. nessi
|
|
77
|
+
* also answers an open call itself, but neither says that it may have run nor moves a result up to its call.
|
|
78
|
+
*/
|
|
79
|
+
export const answerOpenToolCalls = (provider: Provider): Provider => ({
|
|
80
|
+
name: provider.name,
|
|
81
|
+
family: provider.family,
|
|
82
|
+
model: provider.model,
|
|
83
|
+
contextWindow: provider.contextWindow,
|
|
84
|
+
capabilities: provider.capabilities,
|
|
85
|
+
complete: (request) => provider.complete({ ...request, messages: answerOpenCalls(request.messages) }),
|
|
86
|
+
stream: (request) => provider.stream({ ...request, messages: answerOpenCalls(request.messages) }),
|
|
87
|
+
});
|
package/src/ai/protocol.ts
CHANGED
|
@@ -19,6 +19,12 @@ import type { AiConversation, AiFrontendToolMode, AiStoredMessage, AiToolPresent
|
|
|
19
19
|
|
|
20
20
|
export const AI_WIRE_VERSION = 1;
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Lease of a turn worker. A running turn whose worker is gone is claimed again after one lease, so a running turn
|
|
24
|
+
* that sends nothing for this long either works silently or lost an update; a client then reloads the turn's state.
|
|
25
|
+
*/
|
|
26
|
+
export const AI_TURN_LEASE_MS = 45_000;
|
|
27
|
+
|
|
22
28
|
export type AiToolBlockStatus = "running" | "awaiting_approval" | "awaiting_client" | "completed" | "failed" | "rejected";
|
|
23
29
|
|
|
24
30
|
export type AiTurnBlock =
|
package/src/ai/provider.ts
CHANGED
|
@@ -11,9 +11,10 @@ const commonOptions = (profile: AiModelProfile, apiKey?: string) => ({
|
|
|
11
11
|
contextWindow: profile.contextWindow,
|
|
12
12
|
temperature: profile.temperature,
|
|
13
13
|
timeouts: PROVIDER_TIMEOUTS,
|
|
14
|
+
extraBody: profile.extraBody,
|
|
14
15
|
});
|
|
15
16
|
|
|
16
|
-
export const createAiProvider = (profile: AiModelProfile, apiKey?: string): Provider => {
|
|
17
|
+
export const createAiProvider = (profile: AiModelProfile, apiKey?: string, headers?: Record<string, string>): Provider => {
|
|
17
18
|
if (profile.capabilities.includes("transcription")) throw new Error("Transcription profiles cannot be used for chat generation.");
|
|
18
19
|
switch (profile.provider) {
|
|
19
20
|
case "openai":
|
|
@@ -32,6 +33,7 @@ export const createAiProvider = (profile: AiModelProfile, apiKey?: string): Prov
|
|
|
32
33
|
contextWindow: profile.contextWindow,
|
|
33
34
|
temperature: profile.temperature,
|
|
34
35
|
timeouts: PROVIDER_TIMEOUTS,
|
|
36
|
+
extraBody: profile.extraBody,
|
|
35
37
|
});
|
|
36
38
|
case "vllm":
|
|
37
39
|
return openAICompatible({
|
|
@@ -39,9 +41,11 @@ export const createAiProvider = (profile: AiModelProfile, apiKey?: string): Prov
|
|
|
39
41
|
model: profile.model,
|
|
40
42
|
baseURL: profile.baseURL ?? "http://localhost:8000/v1",
|
|
41
43
|
apiKey,
|
|
44
|
+
headers,
|
|
42
45
|
contextWindow: profile.contextWindow,
|
|
43
46
|
temperature: profile.temperature,
|
|
44
47
|
timeouts: PROVIDER_TIMEOUTS,
|
|
48
|
+
extraBody: profile.extraBody,
|
|
45
49
|
compat: {
|
|
46
50
|
toolCallIdPolicy: "passthrough",
|
|
47
51
|
supportsUsageInStreaming: true,
|
|
@@ -58,9 +62,11 @@ export const createAiProvider = (profile: AiModelProfile, apiKey?: string): Prov
|
|
|
58
62
|
model: profile.model,
|
|
59
63
|
baseURL: profile.baseURL,
|
|
60
64
|
apiKey,
|
|
65
|
+
headers,
|
|
61
66
|
contextWindow: profile.contextWindow,
|
|
62
67
|
temperature: profile.temperature,
|
|
63
68
|
timeouts: PROVIDER_TIMEOUTS,
|
|
69
|
+
extraBody: profile.extraBody,
|
|
64
70
|
});
|
|
65
71
|
}
|
|
66
72
|
};
|
package/src/ai/quota-provider.ts
CHANGED
|
@@ -54,8 +54,8 @@ export function inferenceProvider(
|
|
|
54
54
|
ctx,
|
|
55
55
|
inputTokens,
|
|
56
56
|
request.maxOutputTokens ?? profile.maxOutputTokens,
|
|
57
|
-
// Nessi's Anthropic adapter defaults to
|
|
58
|
-
provider.family === "anthropic" ?
|
|
57
|
+
// Nessi's Anthropic adapter defaults to 8192; other adapters use the model default.
|
|
58
|
+
provider.family === "anthropic" ? 8192 : provider.contextWindow,
|
|
59
59
|
);
|
|
60
60
|
} catch (error) {
|
|
61
61
|
if (!(error instanceof AiBackgroundAdmissionError) || !error.retryable || Date.now() >= deadline) throw error;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { toPgTextArray } from "../services/postgres";
|
|
3
|
+
import { decryptValue, encryptValue } from "../services/settings/crypto";
|
|
4
|
+
import {
|
|
5
|
+
AI_REQUEST_HEADERS_PROVIDER_ERROR,
|
|
6
|
+
AiRequestHeadersSchema,
|
|
7
|
+
patchAiRequestHeaders,
|
|
8
|
+
providerSupportsRequestHeaders,
|
|
9
|
+
} from "../shared/ai-request-options";
|
|
10
|
+
import type { AiModelProfile } from "./types";
|
|
11
|
+
|
|
12
|
+
type SqlClient = typeof sql;
|
|
13
|
+
export type AiRequestHeaderPatch = { profileId: string; patch: unknown };
|
|
14
|
+
|
|
15
|
+
const decryptHeaders = async (profileId: string, secret: string): Promise<Record<string, string>> => {
|
|
16
|
+
try {
|
|
17
|
+
const parsed = AiRequestHeadersSchema.safeParse(await decryptValue(secret));
|
|
18
|
+
if (parsed.success && Object.values(parsed.data).every((value) => typeof value === "string"))
|
|
19
|
+
return patchAiRequestHeaders({}, parsed.data);
|
|
20
|
+
} catch {}
|
|
21
|
+
console.warn(`[ai] ignoring unreadable request headers for profile ${JSON.stringify(profileId)}`);
|
|
22
|
+
return {};
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Secrets stay server-side, in a separate encrypted row like provider API keys. */
|
|
26
|
+
export const getAiRequestHeaders = async (profileId: string, db: SqlClient = sql): Promise<Record<string, string>> => {
|
|
27
|
+
const [row] = await db<{ secret: string }[]>`SELECT secret FROM ai.model_request_headers WHERE profile_id = ${profileId}`;
|
|
28
|
+
return row ? decryptHeaders(profileId, row.secret) : {};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const setAiRequestHeaders = async (profileId: string, headers: Record<string, string>, db: SqlClient = sql): Promise<void> => {
|
|
32
|
+
const normalized = patchAiRequestHeaders({}, headers);
|
|
33
|
+
if (!Object.keys(normalized).length) {
|
|
34
|
+
await db`DELETE FROM ai.model_request_headers WHERE profile_id = ${profileId}`;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const encrypted = await encryptValue(normalized);
|
|
38
|
+
await db`INSERT INTO ai.model_request_headers (profile_id, secret) VALUES (${profileId}, ${encrypted})
|
|
39
|
+
ON CONFLICT (profile_id) DO UPDATE SET secret = EXCLUDED.secret, updated_at = now()`;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Admin reads contain names only. Never serialize header values into profiles. */
|
|
43
|
+
export const listAiRequestHeaderNames = async (db: SqlClient = sql): Promise<Record<string, string[]>> => {
|
|
44
|
+
const rows = await db<{ profile_id: string; secret: string }[]>`SELECT profile_id, secret FROM ai.model_request_headers`;
|
|
45
|
+
const names: Record<string, string[]> = {};
|
|
46
|
+
for (const row of rows)
|
|
47
|
+
Object.defineProperty(names, row.profile_id, {
|
|
48
|
+
value: Object.keys(await decryptHeaders(row.profile_id, row.secret)).sort(),
|
|
49
|
+
enumerable: true,
|
|
50
|
+
});
|
|
51
|
+
return names;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const pruneAiRequestHeaders = async (keepProfileIds: readonly string[], db: SqlClient = sql): Promise<void> => {
|
|
55
|
+
if (!keepProfileIds.length) await db`DELETE FROM ai.model_request_headers`;
|
|
56
|
+
else await db`DELETE FROM ai.model_request_headers WHERE profile_id <> ALL(${toPgTextArray([...keepProfileIds])}::text[])`;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Follow credentials: changing provider discards stored secrets; omission preserves them on the same provider. */
|
|
60
|
+
export const planAiProfileRequestHeaders = (input: {
|
|
61
|
+
currentProfiles: readonly AiModelProfile[];
|
|
62
|
+
nextProfiles: readonly AiModelProfile[];
|
|
63
|
+
existingNames: Record<string, string[]>;
|
|
64
|
+
submitted: readonly AiRequestHeaderPatch[];
|
|
65
|
+
}): { keepHeaderProfileIds: string[]; patches: { profileId: string; patch: Record<string, string | null> }[]; error?: string } => {
|
|
66
|
+
const current = new Map(input.currentProfiles.map((profile) => [profile.id, profile]));
|
|
67
|
+
const keepHeaderProfileIds = input.nextProfiles
|
|
68
|
+
.filter(
|
|
69
|
+
(profile) =>
|
|
70
|
+
providerSupportsRequestHeaders(profile.provider) &&
|
|
71
|
+
current.get(profile.id)?.provider === profile.provider &&
|
|
72
|
+
!profile.capabilities.includes("transcription"),
|
|
73
|
+
)
|
|
74
|
+
.map((profile) => profile.id);
|
|
75
|
+
const patches: { profileId: string; patch: Record<string, string | null> }[] = [];
|
|
76
|
+
for (const submitted of input.submitted) {
|
|
77
|
+
const profile = input.nextProfiles.find((profile) => profile.id === submitted.profileId);
|
|
78
|
+
const parsed = AiRequestHeadersSchema.safeParse(submitted.patch);
|
|
79
|
+
if (!parsed.success)
|
|
80
|
+
return { keepHeaderProfileIds: [], patches: [], error: `requestHeaders: ${zodHeaderMessage(parsed.error.issues)}` };
|
|
81
|
+
if (!profile || !providerSupportsRequestHeaders(profile.provider))
|
|
82
|
+
return { keepHeaderProfileIds: [], patches: [], error: AI_REQUEST_HEADERS_PROVIDER_ERROR };
|
|
83
|
+
if (profile.capabilities.includes("transcription"))
|
|
84
|
+
return {
|
|
85
|
+
keepHeaderProfileIds: [],
|
|
86
|
+
patches: [],
|
|
87
|
+
error: "requestHeaders: Request settings are not supported on transcription profiles.",
|
|
88
|
+
};
|
|
89
|
+
const oldNames =
|
|
90
|
+
keepHeaderProfileIds.includes(profile.id) && Object.hasOwn(input.existingNames, profile.id) ? input.existingNames[profile.id]! : [];
|
|
91
|
+
try {
|
|
92
|
+
patchAiRequestHeaders(Object.fromEntries(oldNames.map((name) => [name, ""])), parsed.data);
|
|
93
|
+
} catch {
|
|
94
|
+
return {
|
|
95
|
+
keepHeaderProfileIds: [],
|
|
96
|
+
patches: [],
|
|
97
|
+
error: "requestHeaders: At most 32 extra headers are allowed after applying the patch.",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
patches.push({ profileId: profile.id, patch: parsed.data });
|
|
101
|
+
}
|
|
102
|
+
return { keepHeaderProfileIds, patches };
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const zodHeaderMessage = (issues: readonly { path: readonly PropertyKey[]; message: string }[]) =>
|
|
106
|
+
issues.map((issue) => `${issue.path.map(String).join(".") || "headers"}: ${issue.message}`).join("; ");
|
|
107
|
+
|
|
108
|
+
/** Apply after pruning so a provider change cannot inherit old secrets. Call inside the settings transaction. */
|
|
109
|
+
export const storeAiRequestHeaderPlan = async (
|
|
110
|
+
plan: ReturnType<typeof planAiProfileRequestHeaders>,
|
|
111
|
+
db: SqlClient = sql,
|
|
112
|
+
): Promise<void> => {
|
|
113
|
+
if (plan.error) throw new Error(plan.error);
|
|
114
|
+
await pruneAiRequestHeaders(plan.keepHeaderProfileIds, db);
|
|
115
|
+
for (const { profileId, patch } of plan.patches)
|
|
116
|
+
await setAiRequestHeaders(profileId, patchAiRequestHeaders(await getAiRequestHeaders(profileId, db), patch), db);
|
|
117
|
+
};
|
package/src/ai/routes.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
err,
|
|
11
11
|
fail,
|
|
12
12
|
getLocale,
|
|
13
|
+
getTimeZone,
|
|
13
14
|
ok,
|
|
14
15
|
type RequestActor,
|
|
15
16
|
rateLimit,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
import { isRequestCredentialCurrent } from "../server/middleware/auth";
|
|
20
21
|
import { logger } from "../services/logging";
|
|
21
22
|
import { coreSettings } from "../services/settings/api";
|
|
23
|
+
import { readThemeFromCookieHeader } from "../shared/theme";
|
|
22
24
|
import type { AiToolApprovalContext } from "./approvals";
|
|
23
25
|
import { assistantAiSettingsState, listAssistantAiModels, selectAssistantAiModelId } from "./assistant-models";
|
|
24
26
|
import { AI_AUDIO_MAX_BYTES } from "./audio-format";
|
|
@@ -168,10 +170,26 @@ const resourcesQuerySchema = (scope: "conversation" | "user") =>
|
|
|
168
170
|
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
169
171
|
});
|
|
170
172
|
const ConversationResourcesQuerySchema = resourcesQuerySchema("conversation");
|
|
173
|
+
const ConversationSourcesQuerySchema = ConversationResourcesQuerySchema.extend({
|
|
174
|
+
/** Kinds separated by commas, for example `web,activity,resource`. */
|
|
175
|
+
kind: z
|
|
176
|
+
.string()
|
|
177
|
+
.max(64)
|
|
178
|
+
.transform((value) => value.split(","))
|
|
179
|
+
.pipe(z.array(z.enum(["result", "web", "file", "resource", "activity"])).min(1))
|
|
180
|
+
.optional(),
|
|
181
|
+
observed: z.enum(["true", "false"]).optional(),
|
|
182
|
+
});
|
|
171
183
|
const UserResourcesQuerySchema = resourcesQuerySchema("user");
|
|
172
184
|
|
|
173
|
-
const FilesListQuerySchema = z.object({
|
|
185
|
+
const FilesListQuerySchema = z.object({
|
|
186
|
+
prefix: z.string().optional(),
|
|
187
|
+
/** Pages by path: pass the last path of a page as `after`. Without `limit`, every file comes newest first. */
|
|
188
|
+
limit: z.coerce.number().int().min(1).max(1000).optional(),
|
|
189
|
+
after: z.string().max(4096).optional(),
|
|
190
|
+
});
|
|
174
191
|
const FilePathQuerySchema = z.object({ path: z.string().min(1) });
|
|
192
|
+
const FileDeleteQuerySchema = FilePathQuerySchema.extend({ recursive: z.enum(["true", "false"]).optional() });
|
|
175
193
|
const FileWriteSchema = z.object({
|
|
176
194
|
path: z.string().min(1),
|
|
177
195
|
content: z.string().max(12_000_000),
|
|
@@ -425,6 +443,7 @@ export const aiRoutes = (() => {
|
|
|
425
443
|
memory: memory?.text,
|
|
426
444
|
timeZone,
|
|
427
445
|
locale: promptLocale,
|
|
446
|
+
skillCreatorAvailable: availableSkills.some((skill) => skill.name === "skill-creator"),
|
|
428
447
|
});
|
|
429
448
|
return respond(c, ok({ prompt, renderedAt: new Date().toISOString() }));
|
|
430
449
|
})
|
|
@@ -629,7 +648,7 @@ export const aiRoutes = (() => {
|
|
|
629
648
|
),
|
|
630
649
|
);
|
|
631
650
|
})
|
|
632
|
-
.get("/conversations/:conversationId/sources", v("query",
|
|
651
|
+
.get("/conversations/:conversationId/sources", v("query", ConversationSourcesQuerySchema), async (c) => {
|
|
633
652
|
const ctx = await resolveContext(c);
|
|
634
653
|
if (ctx instanceof Response) return ctx;
|
|
635
654
|
const conversation = await loadConversation(c, ctx);
|
|
@@ -643,6 +662,8 @@ export const aiRoutes = (() => {
|
|
|
643
662
|
search: query.q,
|
|
644
663
|
before: query.cursor,
|
|
645
664
|
limit: query.limit,
|
|
665
|
+
kinds: query.kind,
|
|
666
|
+
observed: query.observed === "true",
|
|
646
667
|
}),
|
|
647
668
|
),
|
|
648
669
|
);
|
|
@@ -865,6 +886,8 @@ export const aiRoutes = (() => {
|
|
|
865
886
|
userMessage: message,
|
|
866
887
|
actor: ctx.actor,
|
|
867
888
|
locale: getLocale(c),
|
|
889
|
+
theme: readThemeFromCookieHeader(c.req.header("cookie")),
|
|
890
|
+
timeZone: getTimeZone(c),
|
|
868
891
|
requestedModelId: body.modelProfileId ?? project?.defaultModelProfileId ?? undefined,
|
|
869
892
|
modelPolicy: ctx.modelPolicy,
|
|
870
893
|
project: project ?? undefined,
|
|
@@ -972,6 +995,8 @@ export const aiRoutes = (() => {
|
|
|
972
995
|
userMessage: message,
|
|
973
996
|
actor: ctx.actor,
|
|
974
997
|
locale: getLocale(c),
|
|
998
|
+
theme: readThemeFromCookieHeader(c.req.header("cookie")),
|
|
999
|
+
timeZone: getTimeZone(c),
|
|
975
1000
|
requestedModelId,
|
|
976
1001
|
modelPolicy: ctx.modelPolicy,
|
|
977
1002
|
systemPrompt,
|
|
@@ -1127,7 +1152,12 @@ export const aiRoutes = (() => {
|
|
|
1127
1152
|
if (ctx instanceof Response) return ctx;
|
|
1128
1153
|
const conversation = await loadConversation(c, ctx);
|
|
1129
1154
|
if (!conversation) return notFound(c);
|
|
1130
|
-
const
|
|
1155
|
+
const query = c.req.valid("query");
|
|
1156
|
+
const files = await aiFileStore.list({
|
|
1157
|
+
conversationId: conversation.id,
|
|
1158
|
+
prefix: query.prefix ?? "/",
|
|
1159
|
+
...(query.limit ? { limit: query.limit, after: query.after } : {}),
|
|
1160
|
+
});
|
|
1131
1161
|
return respond(c, ok({ files, totalBytes: await aiFileStore.totalBytes(conversation.id) }));
|
|
1132
1162
|
})
|
|
1133
1163
|
.post("/conversations/:conversationId/dictations", bodyLimit({ maxSize: AI_AUDIO_MAX_BYTES + 65_536 }), async (c) => {
|
|
@@ -1307,14 +1337,18 @@ export const aiRoutes = (() => {
|
|
|
1307
1337
|
"Cache-Control": "private, no-store",
|
|
1308
1338
|
});
|
|
1309
1339
|
})
|
|
1310
|
-
.delete("/conversations/:conversationId/files", v("query",
|
|
1340
|
+
.delete("/conversations/:conversationId/files", v("query", FileDeleteQuerySchema), async (c) => {
|
|
1311
1341
|
const ctx = await resolveContext(c);
|
|
1312
1342
|
if (ctx instanceof Response) return ctx;
|
|
1313
1343
|
const conversation = await loadConversation(c, ctx);
|
|
1314
1344
|
if (!conversation) return notFound(c);
|
|
1315
|
-
const
|
|
1345
|
+
const query = c.req.valid("query");
|
|
1346
|
+
const path = normalizeAiFilePath(query.path);
|
|
1316
1347
|
if (!path) return fileNotFound(c);
|
|
1317
|
-
|
|
1348
|
+
// A folder goes with everything below it, never the whole chat at once.
|
|
1349
|
+
const recursive = query.recursive === "true";
|
|
1350
|
+
if (recursive && path === "/") return respond(c, fail(err.badInput("Choose a folder below /.")));
|
|
1351
|
+
const removed = await aiFileStore.remove({ conversationId: conversation.id, path, recursive });
|
|
1318
1352
|
if (removed === 0) return fileNotFound(c);
|
|
1319
1353
|
return respond(c, ok({ deleted: true }));
|
|
1320
1354
|
})
|
package/src/ai/run-timeout.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
+
import type { AiTurnError } from "./types";
|
|
2
|
+
|
|
1
3
|
/** Preserve why execution stopped instead of reporting an operator deadline as a user abort. */
|
|
2
4
|
export class AiRunTimeout extends Error {
|
|
3
5
|
constructor(readonly budgetMs: number | null) {
|
|
4
6
|
super("AI_RUN_TIMEOUT");
|
|
5
7
|
}
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return locale?.startsWith("de")
|
|
9
|
-
? `Laufzeitlimit${minutes ? ` von ${minutes} Minuten` : ""} erreicht. Du kannst die Aufgabe mit einer neuen Nachricht fortsetzen.`
|
|
10
|
-
: `Run time limit${minutes ? ` of ${minutes} minutes` : ""} reached. You can continue the task with a new message.`;
|
|
8
|
+
turnError(): AiTurnError {
|
|
9
|
+
return this.budgetMs ? { code: "time_limit", limitMinutes: Math.round(this.budgetMs / 60_000) } : { code: "time_limit" };
|
|
11
10
|
}
|
|
12
11
|
}
|
package/src/ai/runtime.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { startAiDictationRuntime } from "./dictation-runtime";
|
|
|
13
13
|
import { AiTurnExecutor } from "./executor";
|
|
14
14
|
import { canonicalizeAiConversationAttachments, snapshotAiConversationFiles } from "./file-context";
|
|
15
15
|
import { drainQueuedMessages } from "./message-queue";
|
|
16
|
+
import { AI_TURN_LEASE_MS } from "./protocol";
|
|
16
17
|
import { aiQuotas } from "./quotas";
|
|
17
18
|
import { parseAiResourceMarker } from "./resource-markers";
|
|
18
19
|
import { isAiVisionModelConfigured } from "./settings";
|
|
@@ -43,7 +44,6 @@ export { isAiSettingsError, validateAiTurnRequest } from "./validate";
|
|
|
43
44
|
const log = logger("ai:runtime");
|
|
44
45
|
|
|
45
46
|
const AI_WORKER_ID = `worker-${crypto.randomUUID()}`;
|
|
46
|
-
const AI_TURN_LEASE_MS = 45_000;
|
|
47
47
|
const AI_TURN_HEARTBEAT_MS = 3_000;
|
|
48
48
|
const AI_TURN_WORKER_CONCURRENCY = 8;
|
|
49
49
|
const AI_TURN_MAX_ATTEMPTS = 5;
|
|
@@ -94,6 +94,8 @@ export type SubmitAiChatTurnInput = {
|
|
|
94
94
|
userMessage: Message;
|
|
95
95
|
actor?: RequestActor;
|
|
96
96
|
locale?: string;
|
|
97
|
+
theme?: "light" | "dark";
|
|
98
|
+
timeZone?: string;
|
|
97
99
|
modelPolicy?: AiModelPolicy;
|
|
98
100
|
requestedModelId?: string;
|
|
99
101
|
/** Optional instructions that apply only to this turn. */
|
|
@@ -155,6 +157,8 @@ export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
|
|
|
155
157
|
chatId: input.chatId,
|
|
156
158
|
actor: input.actor,
|
|
157
159
|
...(input.locale ? { locale: input.locale } : {}),
|
|
160
|
+
...(input.theme ? { theme: input.theme } : {}),
|
|
161
|
+
...(input.timeZone ? { timeZone: input.timeZone } : {}),
|
|
158
162
|
modelPolicy: input.modelPolicy,
|
|
159
163
|
requestedModelId,
|
|
160
164
|
systemPrompt: input.systemPrompt,
|
|
@@ -190,6 +194,8 @@ export const deliverAiInterChatMessage = async (input: {
|
|
|
190
194
|
chatId?: string;
|
|
191
195
|
actor: RequestActor;
|
|
192
196
|
locale?: string;
|
|
197
|
+
theme?: "light" | "dark";
|
|
198
|
+
timeZone?: string;
|
|
193
199
|
modelPolicy?: AiModelPolicy;
|
|
194
200
|
systemPrompt?: string;
|
|
195
201
|
project?: AiChatTurnRunConfig["project"];
|
|
@@ -209,6 +215,8 @@ export const deliverAiInterChatMessage = async (input: {
|
|
|
209
215
|
chatId: input.chatId,
|
|
210
216
|
actor: input.actor,
|
|
211
217
|
...(input.locale ? { locale: input.locale } : {}),
|
|
218
|
+
...(input.theme ? { theme: input.theme } : {}),
|
|
219
|
+
...(input.timeZone ? { timeZone: input.timeZone } : {}),
|
|
212
220
|
modelPolicy: input.modelPolicy,
|
|
213
221
|
systemPrompt: input.systemPrompt,
|
|
214
222
|
project: input.project,
|
package/src/ai/settings.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { coreSettings } from "../services";
|
|
3
3
|
import { AiModelPricingSchema } from "../shared/ai-costs";
|
|
4
|
+
import {
|
|
5
|
+
AI_REQUEST_HEADERS_PROVIDER_ERROR,
|
|
6
|
+
AiExtraBodySchema,
|
|
7
|
+
AiReasoningEffortSchema,
|
|
8
|
+
AiRequestHeadersSchema,
|
|
9
|
+
providerSupportsRequestHeaders,
|
|
10
|
+
} from "../shared/ai-request-options";
|
|
4
11
|
import { getAiCredential, listAiCredentialProfileIds } from "./credentials";
|
|
5
12
|
import { AI_FIRECRAWL_API_KEY_SETTING_KEY } from "./firecrawl-tools";
|
|
6
13
|
import { createAiProvider } from "./provider";
|
|
14
|
+
import { getAiRequestHeaders } from "./request-headers";
|
|
7
15
|
import {
|
|
8
16
|
AI_DATA_BOUNDARIES,
|
|
9
17
|
AI_MODEL_CAPABILITIES,
|
|
@@ -93,12 +101,20 @@ const ModelProfileSchema = z
|
|
|
93
101
|
contextWindow: z.number().int().positive().optional(),
|
|
94
102
|
temperature: z.number().min(0).max(2).optional(),
|
|
95
103
|
maxOutputTokens: z.number().int().positive().optional(),
|
|
104
|
+
reasoningEffort: AiReasoningEffortSchema,
|
|
105
|
+
extraBody: AiExtraBodySchema.optional(),
|
|
106
|
+
requestHeaders: AiRequestHeadersSchema.optional(),
|
|
96
107
|
maxLoadedTools: z.number().int().optional(),
|
|
97
108
|
maxToolRounds: z.number().int().optional(),
|
|
98
109
|
pricing: AiModelPricingSchema.optional(),
|
|
99
110
|
})
|
|
100
111
|
.superRefine((profile, ctx) => {
|
|
112
|
+
if (profile.requestHeaders !== undefined && !providerSupportsRequestHeaders(profile.provider))
|
|
113
|
+
ctx.addIssue({ code: "custom", path: ["requestHeaders"], message: AI_REQUEST_HEADERS_PROVIDER_ERROR });
|
|
101
114
|
if (profile.capabilities?.includes("transcription")) {
|
|
115
|
+
for (const key of ["reasoningEffort", "extraBody", "requestHeaders"] as const)
|
|
116
|
+
if (profile[key] !== undefined)
|
|
117
|
+
ctx.addIssue({ code: "custom", path: [key], message: "Request settings are not supported on transcription profiles." });
|
|
102
118
|
if (profile.pricing) ctx.addIssue({ code: "custom", path: ["pricing"], message: "Audio pricing is not supported." });
|
|
103
119
|
if (profile.capabilities.some((capability) => capability !== "transcription")) {
|
|
104
120
|
ctx.addIssue({ code: "custom", path: ["capabilities"], message: "Audio transcription cannot be combined with chat capabilities." });
|
|
@@ -128,7 +144,7 @@ const profileToPublic = (profile: AiModelProfile): AiPublicModelProfile => ({
|
|
|
128
144
|
});
|
|
129
145
|
|
|
130
146
|
const normalizeProfile = (raw: z.infer<typeof ModelProfileSchema>): AiModelProfile => {
|
|
131
|
-
const { capabilities, dataBoundary, dataPolicy: legacyDataPolicy, tags: _legacyTags, ...profile } = raw;
|
|
147
|
+
const { requestHeaders: _requestHeaders, capabilities, dataBoundary, dataPolicy: legacyDataPolicy, tags: _legacyTags, ...profile } = raw;
|
|
132
148
|
return {
|
|
133
149
|
...profile,
|
|
134
150
|
capabilities: normalizeCapabilities(capabilities),
|
|
@@ -522,7 +538,8 @@ export const resolveAiModelFromState = async (
|
|
|
522
538
|
});
|
|
523
539
|
}
|
|
524
540
|
|
|
525
|
-
|
|
541
|
+
const headers = providerSupportsRequestHeaders(profile.provider) ? await getAiRequestHeaders(profile.id) : {};
|
|
542
|
+
return { profile, provider: createAiProvider(profile, credential?.trim() || undefined, headers) };
|
|
526
543
|
};
|
|
527
544
|
|
|
528
545
|
const isUsableProfile = (profile: AiModelProfile, credentialProfileIds: ReadonlySet<string>): boolean =>
|
package/src/ai/skill-seeds.ts
CHANGED
|
@@ -14,6 +14,29 @@ Help the user turn a recurring workflow, specialist knowledge, or a set of instr
|
|
|
14
14
|
- Ask only about a missing choice that would materially change discovery, behavior, or output. If the request is already clear, draft the Skill directly.
|
|
15
15
|
- Preserve the user's scope. Do not turn one example, preference, or past failure into a universal rule unless the user clearly intends that.
|
|
16
16
|
|
|
17
|
+
## Choose the right place
|
|
18
|
+
|
|
19
|
+
Recommend the lightest place that does the job:
|
|
20
|
+
|
|
21
|
+
- one lasting fact or preference, such as reports always as PDF: personalization memory;
|
|
22
|
+
- always using one mailbox, notebook, or Space for a kind of request: a personalization workflow default, which Assistant learns from the user's stated rule or repeated use;
|
|
23
|
+
- rules for one Project's shared work: Project instructions;
|
|
24
|
+
- a recurring procedure, judgment, or output format used across chats: a Skill;
|
|
25
|
+
- work that should run at a set time: a scheduled task, which can load the Skill;
|
|
26
|
+
- a repeated calculation or an interactive tool: a Studio App that the Skill references.
|
|
27
|
+
|
|
28
|
+
## Draft from the conversation
|
|
29
|
+
|
|
30
|
+
When the Skill comes from work in this conversation, capture what made it succeed:
|
|
31
|
+
|
|
32
|
+
- when to load it, as narrowly as the user's real requests: name the subject, such as the team or the report, not only the output format;
|
|
33
|
+
- the steps and the exact capability IDs that worked, so later runs can call load_tools without searching;
|
|
34
|
+
- every correction the user made, rewritten as a positive rule; corrections are the most valuable content;
|
|
35
|
+
- the output shape, as a compact template when layout matters;
|
|
36
|
+
- inputs by role and title, such as "the Space Sales"; when a personalization workflow default already routes this kind of request, refer to it instead of repeating it. Never copy IDs of mailboxes, Spaces, notebooks, records, or other resources from the chat; the exact ID of a Studio App the Skill calls is the one exception.
|
|
37
|
+
|
|
38
|
+
Keep only procedure, format, and stable references. Leave out content from attachments, mails, web pages, or other quoted data; names of people or customers, amounts, and example records from this chat; one-off values; and failed attempts. Before the create review, tell the user in one or two sentences what the Skill will do and when it will load.
|
|
39
|
+
|
|
17
40
|
## Design the Skill
|
|
18
41
|
|
|
19
42
|
### Name
|
|
@@ -59,9 +82,10 @@ Read the draft once as if you were a different Assistant receiving it later. Che
|
|
|
59
82
|
- the workflow can run without private conversation context;
|
|
60
83
|
- instructions are direct, non-repetitive, and compatible with available capabilities;
|
|
61
84
|
- examples generalize beyond the original case;
|
|
85
|
+
- it holds no names of people or customers, amounts, records, or resource IDs from this conversation, except the exact ID of a Studio App it calls;
|
|
62
86
|
- no surprising mutation, permission expansion, or external side effect is implied.
|
|
63
87
|
|
|
64
|
-
For an existing Skill, read it first and preserve fields the user did not ask to change. Prefer a narrow correction over accumulating rules for every observed example.
|
|
88
|
+
For an existing Skill, read it first and preserve fields the user did not ask to change. Prefer a narrow correction over accumulating rules for every observed example. Built-in Skills and Skills shared with others change for everyone who uses them, so change one only when you can edit it and the user wants the change for everyone. \`core.ai.skill.read\` shows your permission, and \`core.ai.skill.access.read\` shows who else can use a Skill you manage. Otherwise leave the Skill unchanged. When personalization memory is on, a correction meant only for the user can become a preference; memory saves only the user's own words, so ask the user to state the rule, such as "always write mails formally".
|
|
65
89
|
|
|
66
90
|
## Use Cloud Skill capabilities
|
|
67
91
|
|
|
@@ -82,6 +106,10 @@ Pass the exact current revision to mutations and read again after a revision con
|
|
|
82
106
|
|
|
83
107
|
Creating, changing, and deleting Skills are reviewed mutations. Prepare the concrete content the user requested, then use the Capability review instead of asking for an extra confirmation that duplicates it.
|
|
84
108
|
|
|
109
|
+
## After saving
|
|
110
|
+
|
|
111
|
+
Say that the Skill now loads for matching requests and can also be selected with /skill. When a later run needs a correction, update the Skill narrowly instead of creating another one. Share it only when the user asks.
|
|
112
|
+
|
|
85
113
|
## Optional App-backed workflows
|
|
86
114
|
|
|
87
115
|
A Skill can reference a reusable Studio App and its published actions instead of duplicating code. Load \`assistant-code-mode\` only when building, changing, or discovering such an App is useful. Apps may be stateless scripts, agent-only services with shared data, dashboards, or a combination. Many Skills need no code and many Apps need no Skill.
|
|
@@ -195,6 +223,7 @@ Use these defaults unless the user asks otherwise or a more specific loaded Skil
|
|
|
195
223
|
- For text lookup across Spaces, use \`spaces.item.search\`. For overdue, assigned or inactive tasks use \`spaces.task.focus\`; filter at the server instead of enumerating every Space.
|
|
196
224
|
- For a date-bounded calendar use \`spaces.event.agenda\`. Follow every cursor, including after an empty page: pagination groups series with their overrides. Collect all pages and sort by startsAt for a chronological agenda. Use returned occurrences; do not expand recurrence rules yourself or equate a series anchor with the next occurrence.
|
|
197
225
|
- Select a writable Space through \`spaces.space.browse\`. A known Space can be listed directly with \`spaces.task.list\` or \`spaces.event.list\`. Read \`spaces.space.read\` only when column/tag IDs or configuration are needed.
|
|
226
|
+
- List entries show at most three assignees and tags: when \`assigneeCount\` is larger than the number of \`assignees\`, state the total (for example "3 of 11") or read the item before naming everyone. \`relationsTruncated\` signals that a relation preview is partial.
|
|
198
227
|
- Read a selected \`spaces.item.read\` before changing its content or deleting it. Use a task for work and an event only with explicit valid start and end. Select assignees from \`spaces.space.assignee.list\`, never inferred names or invented IDs.
|
|
199
228
|
|
|
200
229
|
## Make focused changes
|
|
@@ -438,7 +467,7 @@ const BUILTIN_CLOUD_AI_SKILLS: AiSkillTemplate[] = [
|
|
|
438
467
|
ASSISTANT_CODE_MODE_SKILL,
|
|
439
468
|
ASSISTANT_DATA_ANALYSIS_SKILL,
|
|
440
469
|
{
|
|
441
|
-
version:
|
|
470
|
+
version: 7,
|
|
442
471
|
key: "grids:cloud-grids",
|
|
443
472
|
name: "cloud-grids",
|
|
444
473
|
description:
|
|
@@ -447,11 +476,10 @@ const BUILTIN_CLOUD_AI_SKILLS: AiSkillTemplate[] = [
|
|
|
447
476
|
references: [{ path: "references/query-tasks.md", content: CLOUD_GRIDS_QUERY_REFERENCE }],
|
|
448
477
|
},
|
|
449
478
|
{
|
|
450
|
-
version:
|
|
479
|
+
version: 3,
|
|
451
480
|
key: "assistant:cloud-assistant",
|
|
452
481
|
name: "cloud-assistant",
|
|
453
|
-
description:
|
|
454
|
-
"Use for work involving Cloud Assistant itself: finding or reading earlier conversations, recovering resources used in chats, messaging another conversation, or creating and managing reminders and recurring scheduled chat work.",
|
|
482
|
+
description: `Use for work involving Cloud Assistant itself: finding or reading earlier conversations, recovering resources used in chats, messaging another conversation, or creating and managing reminders and recurring scheduled chat work. Also use when a request refers to earlier work, such as "like last time", "as last week", or "the report you made me".`,
|
|
455
483
|
instructions: CLOUD_ASSISTANT_INSTRUCTIONS,
|
|
456
484
|
},
|
|
457
485
|
{
|
|
@@ -463,7 +491,7 @@ const BUILTIN_CLOUD_AI_SKILLS: AiSkillTemplate[] = [
|
|
|
463
491
|
instructions: SCHEDULED_TASKS_INSTRUCTIONS,
|
|
464
492
|
},
|
|
465
493
|
{
|
|
466
|
-
version:
|
|
494
|
+
version: 3,
|
|
467
495
|
key: "core:skill-creator",
|
|
468
496
|
name: "skill-creator",
|
|
469
497
|
description:
|
|
@@ -496,7 +524,7 @@ const BUILTIN_CLOUD_AI_SKILLS: AiSkillTemplate[] = [
|
|
|
496
524
|
instructions: CLOUD_CONTACTS_INSTRUCTIONS,
|
|
497
525
|
},
|
|
498
526
|
{
|
|
499
|
-
version:
|
|
527
|
+
version: 2,
|
|
500
528
|
key: "spaces:cloud-spaces",
|
|
501
529
|
name: "cloud-spaces",
|
|
502
530
|
description:
|
package/src/ai/skills.ts
CHANGED
|
@@ -562,7 +562,33 @@ async function linkedSkillProjects(skillId: string, subject: AccessSubject | nul
|
|
|
562
562
|
return result;
|
|
563
563
|
}
|
|
564
564
|
|
|
565
|
+
export type AiConversationSkillUse = { name: string; description: string; turns: number; lastLoadedAt: string };
|
|
566
|
+
|
|
565
567
|
export const aiSkills = {
|
|
568
|
+
/**
|
|
569
|
+
* Skills a chat loaded, from the revision snapshots its turns pinned, most recently loaded first. The caller resolves
|
|
570
|
+
* the chat for its owner first; this is the chat's own history and does not recheck current Skill access.
|
|
571
|
+
*/
|
|
572
|
+
async listConversationUses(conversationId: string, limit = 50): Promise<AiConversationSkillUse[]> {
|
|
573
|
+
const rows = await sql<{ name: string; description: string; turns: number | string; last_loaded_at: Date | string }[]>`
|
|
574
|
+
SELECT snapshot.skill_name AS name,
|
|
575
|
+
(array_agg(snapshot.description ORDER BY snapshot.loaded_at DESC))[1] AS description,
|
|
576
|
+
count(*) AS turns, max(snapshot.loaded_at) AS last_loaded_at
|
|
577
|
+
FROM ai.turn_skill_snapshots snapshot
|
|
578
|
+
JOIN ai.turns turn ON turn.id = snapshot.turn_id
|
|
579
|
+
WHERE turn.conversation_id = ${conversationId}::uuid
|
|
580
|
+
GROUP BY snapshot.skill_name
|
|
581
|
+
ORDER BY max(snapshot.loaded_at) DESC, snapshot.skill_name ASC
|
|
582
|
+
LIMIT ${Math.min(Math.max(Math.floor(limit), 1), 100)}
|
|
583
|
+
`;
|
|
584
|
+
return rows.map((row) => ({
|
|
585
|
+
name: row.name,
|
|
586
|
+
description: row.description,
|
|
587
|
+
turns: Number(row.turns),
|
|
588
|
+
lastLoadedAt: (row.last_loaded_at instanceof Date ? row.last_loaded_at : new Date(row.last_loaded_at)).toISOString(),
|
|
589
|
+
}));
|
|
590
|
+
},
|
|
591
|
+
|
|
566
592
|
async seedOnce(template: AiSkillTemplate): Promise<void> {
|
|
567
593
|
const { fields, hash } = validateTemplate(template);
|
|
568
594
|
await sql.begin(async (tx) => {
|