@k2b/cloud 0.32.0 → 0.34.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 +5 -5
- package/src/_internal/app-presentation.ts +40 -11
- package/src/_internal/define-app.ts +18 -8
- package/src/_internal/page-responses.ts +5 -1
- package/src/_internal/registry.ts +10 -1
- package/src/_internal/widget-declarations.ts +16 -3
- package/src/_internal/widget-settings.ts +93 -0
- package/src/ai/capabilities.ts +8 -5
- package/src/ai/code-source-contracts.ts +17 -2
- package/src/ai/code-source-tools.ts +1 -0
- package/src/ai/default-tools.ts +4 -4
- 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 +8 -0
- package/src/ai/provider.ts +4 -3
- package/src/ai/resource-refs.ts +35 -3
- package/src/ai/system-prompt.ts +3 -2
- package/src/api/index.ts +2 -0
- package/src/api/mcp.ts +48 -17
- package/src/api/pwa.ts +7 -2
- package/src/api/resource-links.ts +43 -0
- package/src/api/widgets.ts +163 -82
- package/src/browser/CloudResourceSearch.tsx +6 -6
- package/src/browser/command-shortcuts.ts +2 -11
- package/src/browser/navigation-search.ts +4 -1
- package/src/browser/resource-picker.tsx +1 -0
- package/src/browser/resource-search.ts +7 -2
- package/src/browser/widgets.ts +23 -5
- package/src/capabilities/testing.ts +13 -1
- package/src/cli/capabilities.ts +61 -17
- package/src/cli/index.ts +2 -0
- package/src/cli/resource-links.ts +13 -0
- package/src/contracts/app.ts +7 -1
- package/src/contracts/capabilities.ts +25 -5
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/resource-href.ts +7 -0
- package/src/contracts/widgets.ts +80 -6
- package/src/desktop/index.ts +3 -2
- package/src/desktop/solid.tsx +7 -5
- package/src/server/widget-request.ts +34 -5
- package/src/services/app-approval.ts +4 -2
- package/src/services/approvals/index.ts +40 -11
- package/src/services/approvals/list.ts +1 -0
- package/src/services/approvals/migrate.ts +10 -0
- package/src/services/outbox.ts +13 -6
- package/src/services/pdf/markdown.ts +11 -7
- package/src/services/pwa-devices.ts +68 -15
- package/src/shared/ai-platform-prompt.ts +3 -2
- package/src/shared/app-presentation.ts +49 -1
- package/src/shared/index.ts +1 -0
- package/src/shared/markdown/callout-sanitizer.ts +37 -0
- package/src/shared/markdown/index.ts +8 -2
- package/src/shared/theme.ts +41 -20
- package/src/ssr/AnnouncementBanners.tsx +30 -0
- package/src/ssr/AppLaunchpadPanel.tsx +2 -2
- package/src/ssr/CloudShell.island.tsx +91 -0
- package/src/ssr/GlobalSearchTrigger.tsx +51 -0
- package/src/ssr/Layout.tsx +58 -99
- package/src/ssr/LayoutHeader.tsx +31 -77
- package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
- package/src/ssr/LayoutHelp.tsx +319 -238
- package/src/ssr/LayoutHelpTrigger.tsx +49 -0
- package/src/ssr/LayoutPreferences.tsx +31 -0
- package/src/ssr/LayoutRail.tsx +15 -69
- package/src/ssr/MinimalLayout.tsx +2 -3
- package/src/ssr/MinimalLayoutPreferences.island.tsx +6 -15
- package/src/ssr/MobileNavigation.tsx +13 -8
- package/src/ssr/MobileProfileActions.tsx +4 -6
- package/src/ssr/PageTrailRow.tsx +14 -0
- package/src/ssr/{ProfilePreferences.island.tsx → ProfilePreferences.tsx} +8 -5
- package/src/ssr/PwaLayout.tsx +0 -2
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/ShellRuntime.island.tsx +166 -0
- package/src/ssr/ThemeCycleButton.tsx +46 -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-appearance.ts +6 -1
- 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/layout-help-url.ts +39 -0
- package/src/ssr/mobile-navigation.browser-harness.tsx +20 -0
- package/src/ssr/platform-messages.ts +0 -8
- package/src/ssr/preference-controller.ts +62 -19
- package/src/ssr/profile-actions.ts +12 -5
- package/src/ssr/profile-preferences-messages.ts +10 -4
- package/src/ssr/rail-context.ts +8 -12
- package/src/ssr/rail-navigation.ts +3 -0
- 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/effects.css +13 -3
- package/src/styles/global.css +43 -5
- package/src/styles/input.css +0 -8
- package/src/styles/tokens.css +0 -2
- package/src/styles/utilities-navigation.css +10 -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/GlobalSearchHelpDialog.tsx +0 -104
- package/src/ssr/GlobalSearchTrigger.island.tsx +0 -75
- package/src/ssr/LayoutHelpTrigger.island.tsx +0 -61
- package/src/ssr/LayoutPreferences.island.tsx +0 -21
- 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
- /package/src/ssr/{RailApps.island.tsx → RailApps.tsx} +0 -0
|
@@ -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
|
@@ -1112,6 +1112,14 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
1112
1112
|
CREATE INDEX IF NOT EXISTS idx_ai_memories_search_document
|
|
1113
1113
|
ON ai.memories USING GIN(search_document)
|
|
1114
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();
|
|
1115
1123
|
await sql`
|
|
1116
1124
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_memories_user_content_active
|
|
1117
1125
|
ON ai.memories(user_id, lower(content))
|
package/src/ai/provider.ts
CHANGED
|
@@ -2,11 +2,12 @@ import type { Provider, ReasoningEffort } from "@k2b/nessi/ai";
|
|
|
2
2
|
import { anthropic, completeFromStream, gemini, mistral, ollama, openAICompatible, openai, openrouter } from "@k2b/nessi/ai";
|
|
3
3
|
import type { AiModelProfile } from "./types";
|
|
4
4
|
|
|
5
|
-
/** Bound the first
|
|
5
|
+
/** Bound the first response chunk and every gap between chunks to 60s, for chat and complete() calls. */
|
|
6
6
|
export const PROVIDER_TIMEOUTS = { firstByteMs: 60_000, idleMs: 60_000 } as const;
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
8
|
+
// Native complete() receives headers only after the whole answer, so until then Nessi can bound it only by total time
|
|
9
|
+
// (totalMs), which cuts off slow healthy answers or detects a stall late. Streaming bounds the first response chunk and
|
|
10
|
+
// every gap between chunks, as for chat. Any body chunk counts, including SSE keep-alive comments.
|
|
10
11
|
export const completeThroughStream = (provider: Provider): Provider => ({
|
|
11
12
|
...provider,
|
|
12
13
|
complete: (request) => completeFromStream(provider, request),
|
package/src/ai/resource-refs.ts
CHANGED
|
@@ -1,12 +1,43 @@
|
|
|
1
1
|
import {
|
|
2
|
+
CloudResourceHrefSchema,
|
|
2
3
|
type CloudResourceRef,
|
|
3
|
-
type CloudResourceReference,
|
|
4
4
|
CloudResourceReferenceSchema,
|
|
5
5
|
type CloudResourceView,
|
|
6
6
|
CloudResourceViewSchema,
|
|
7
|
+
cloudResourceHref,
|
|
8
|
+
cloudResourceRefAppId,
|
|
9
|
+
resolveCapabilityResourceReader,
|
|
7
10
|
} from "../contracts/capabilities";
|
|
11
|
+
import type { CapabilityRegistryEntry } from "../contracts/registry";
|
|
8
12
|
import type { AiConversationResourceObservation } from "./types";
|
|
9
13
|
|
|
14
|
+
const LinkedResourceReferenceSchema = CloudResourceReferenceSchema.extend({
|
|
15
|
+
href: CloudResourceHrefSchema.optional(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const addCloudResourceHrefs = <T>(
|
|
19
|
+
value: T,
|
|
20
|
+
apps: readonly Pick<CapabilityRegistryEntry, "appId" | "manifest">[],
|
|
21
|
+
origin?: string,
|
|
22
|
+
) => {
|
|
23
|
+
if (!value || typeof value !== "object" || !("refs" in value) || !Array.isArray(value.refs)) return value;
|
|
24
|
+
return {
|
|
25
|
+
...value,
|
|
26
|
+
refs: value.refs.map((reference: unknown) => {
|
|
27
|
+
if (reference && typeof reference === "object" && "href" in reference) return reference;
|
|
28
|
+
const parsed = CloudResourceReferenceSchema.safeParse(reference);
|
|
29
|
+
if (!parsed.success) return reference;
|
|
30
|
+
const ref = parsed.data;
|
|
31
|
+
if (!ref.id.isWellFormed()) return reference;
|
|
32
|
+
const app = apps.find((candidate) => candidate.appId === cloudResourceRefAppId(ref));
|
|
33
|
+
if (!app || !resolveCapabilityResourceReader(app.manifest, ref)) return reference;
|
|
34
|
+
const href = CloudResourceHrefSchema.safeParse(cloudResourceHref(ref));
|
|
35
|
+
if (!href.success) return reference;
|
|
36
|
+
return { ...ref, href: origin ? new URL(href.data, origin).href : href.data };
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
10
41
|
const observationKey = (ref: CloudResourceRef): string => `${ref.type}\0${ref.id}`;
|
|
11
42
|
|
|
12
43
|
export const isConversationResourceCursor = (value: string, scope: "conversation" | "user"): boolean => {
|
|
@@ -47,9 +78,9 @@ export const collectConversationResourceObservations = (...values: unknown[]): A
|
|
|
47
78
|
href: resource.links.find((link) => link.rel === "open")?.href,
|
|
48
79
|
});
|
|
49
80
|
} else {
|
|
50
|
-
const reference =
|
|
81
|
+
const reference = LinkedResourceReferenceSchema.safeParse(value);
|
|
51
82
|
if (reference.success) {
|
|
52
|
-
const resource
|
|
83
|
+
const resource = reference.data;
|
|
53
84
|
const ref: CloudResourceRef = { type: resource.type, id: resource.id };
|
|
54
85
|
const previous = observations.get(observationKey(ref));
|
|
55
86
|
observations.set(observationKey(ref), {
|
|
@@ -58,6 +89,7 @@ export const collectConversationResourceObservations = (...values: unknown[]): A
|
|
|
58
89
|
...(resource.title !== undefined ? { title: resource.title } : {}),
|
|
59
90
|
...(resource.preview !== undefined ? { preview: resource.preview } : {}),
|
|
60
91
|
...(resource.icon !== undefined ? { icon: resource.icon } : {}),
|
|
92
|
+
...(resource.href !== undefined ? { href: resource.href } : {}),
|
|
61
93
|
});
|
|
62
94
|
}
|
|
63
95
|
}
|
package/src/ai/system-prompt.ts
CHANGED
|
@@ -171,6 +171,7 @@ export const composeAiSystemPrompt = (input: AiSystemPromptInput): string => {
|
|
|
171
171
|
? [
|
|
172
172
|
"# Local client",
|
|
173
173
|
'This turn comes from a local client. Where this section describes what the user sees, it replaces "What the user sees" above; it cannot override the other platform rules.',
|
|
174
|
+
"Offer the supplied link for every Cloud resource you create, change, or name, because cld and editors show these links as full Cloud addresses.",
|
|
174
175
|
`Client: ${input.localClient.client.kind === "cli" ? "terminal (cld)" : `editor via ACP${input.localClient.client.name ? ` (${input.localClient.client.name})` : ""}`}`,
|
|
175
176
|
input.localClient.cloudUrl.trim() ? `Cloud: ${withoutTrailingSlashes(input.localClient.cloudUrl.trim())}` : undefined,
|
|
176
177
|
`Workspace: ${input.localClient.client.cwd}`,
|
|
@@ -244,8 +245,8 @@ export const composeAiSystemPrompt = (input: AiSystemPromptInput): string => {
|
|
|
244
245
|
].join("\n"),
|
|
245
246
|
[
|
|
246
247
|
"# Cloud resource links",
|
|
247
|
-
"When the answer mentions a Cloud resource
|
|
248
|
-
"Example: [Urgent invoice review 001](/
|
|
248
|
+
"Cloud adds an href to resource refs it can open. When the answer mentions a Cloud resource, use its ref's href or the result's open or edit link to make the resource's human-readable title a Markdown link using that exact href. Use the title exactly as supplied, even when answering in another language. Apply this to every mentioned resource, including list items and headings. Prefer open over edit. Without a supplied href, use plain text. Never construct a Cloud URL.",
|
|
249
|
+
"Example: [Urgent invoice review 001](/open/mail.draft/~AbC123) — payment deadline approaching.",
|
|
249
250
|
].join("\n"),
|
|
250
251
|
];
|
|
251
252
|
|
package/src/api/index.ts
CHANGED
package/src/api/mcp.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { z } from "zod";
|
|
|
17
17
|
import { readBoundedJson } from "../_internal/bounded-json";
|
|
18
18
|
import { HELP_SEARCH_MAX_LIMIT, helpResourceUri, parseHelpResourceUri, readHelpArticle } from "../_internal/help-catalog";
|
|
19
19
|
import { getCapability, listApps } from "../_internal/registry";
|
|
20
|
+
import { addCloudResourceHrefs } from "../ai/resource-refs";
|
|
20
21
|
import { capabilityValueMeta, recordCapabilityExecution } from "../capabilities/executions";
|
|
21
22
|
import {
|
|
22
23
|
CAPABILITY_FRAMEWORK_ERROR_CODES,
|
|
@@ -26,9 +27,11 @@ import {
|
|
|
26
27
|
type CapabilityActionManifest,
|
|
27
28
|
type CapabilityQueryManifest,
|
|
28
29
|
CapabilitySemanticLinkSchema,
|
|
29
|
-
|
|
30
|
+
type CloudResourceRef,
|
|
30
31
|
capabilityResultJsonSchema,
|
|
32
|
+
capabilityResultSchema,
|
|
31
33
|
cloudResourceRefAppId,
|
|
34
|
+
LinkedCloudResourceReferenceSchema,
|
|
32
35
|
resolveCapabilityResourceReader,
|
|
33
36
|
} from "../contracts/capabilities";
|
|
34
37
|
import type { AppRegistryEntry, CapabilityRegistryEntry } from "../contracts/registry";
|
|
@@ -121,7 +124,7 @@ const platformTools: Tool[] = [
|
|
|
121
124
|
title: "Read Cloud resource",
|
|
122
125
|
description:
|
|
123
126
|
"Read one Cloud resource through its current canonical reader. Pass a typed ref returned by search, list, lookup, or another capability unchanged; never guess a reader name or substitute an id from another resource type.",
|
|
124
|
-
inputSchema: structuredClone(z.toJSONSchema(
|
|
127
|
+
inputSchema: structuredClone(z.toJSONSchema(LinkedCloudResourceReferenceSchema)) as Tool["inputSchema"],
|
|
125
128
|
annotations: { title: "Read Cloud resource", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
126
129
|
_meta: { "cloud/kind": "resource-read" },
|
|
127
130
|
},
|
|
@@ -206,7 +209,7 @@ const projectTool = (
|
|
|
206
209
|
title: `${app.appName}: ${operation.title}`,
|
|
207
210
|
description: operation.description,
|
|
208
211
|
inputSchema: action ? actionInputSchema(action) : cloneObjectSchema(operation.inputSchema),
|
|
209
|
-
outputSchema: cloneObjectSchema(capabilityResultJsonSchema(operation.dataSchema)),
|
|
212
|
+
outputSchema: cloneObjectSchema(capabilityResultJsonSchema(operation.dataSchema, { consumer: true })),
|
|
210
213
|
annotations: {
|
|
211
214
|
title: operation.title,
|
|
212
215
|
readOnlyHint: !isAction,
|
|
@@ -300,7 +303,7 @@ const findCapabilityTool = async (
|
|
|
300
303
|
};
|
|
301
304
|
|
|
302
305
|
const findResourceReaderTool = async (
|
|
303
|
-
ref:
|
|
306
|
+
ref: CloudResourceRef,
|
|
304
307
|
lookup: (appId: string) => Promise<CapabilityRegistryEntry | null>,
|
|
305
308
|
registry: () => Promise<AppRegistryEntry[]>,
|
|
306
309
|
): Promise<CapabilityTool | null> => {
|
|
@@ -333,15 +336,20 @@ const responseTooLargeToolResult = (): CallToolResult => {
|
|
|
333
336
|
return { isError: true, structuredContent: error, content: [{ type: "text", text: JSON.stringify(error) }] };
|
|
334
337
|
};
|
|
335
338
|
|
|
339
|
+
const toolResult = (body: Record<string, unknown>, isError: boolean): CallToolResult => ({
|
|
340
|
+
...(isError ? { isError: true } : {}),
|
|
341
|
+
structuredContent: body,
|
|
342
|
+
content: [{ type: "text", text: JSON.stringify(body) }],
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const fitsToolResult = (result: CallToolResult): boolean =>
|
|
346
|
+
new TextEncoder().encode(JSON.stringify(result)).byteLength <= CAPABILITY_MAX_RESULT_BYTES;
|
|
347
|
+
|
|
336
348
|
const ensureBoundedToolResult = (result: CallToolResult): CallToolResult =>
|
|
337
|
-
|
|
349
|
+
fitsToolResult(result) ? result : responseTooLargeToolResult();
|
|
338
350
|
|
|
339
351
|
const boundedToolResult = (body: Record<string, unknown>, isError: boolean): CallToolResult =>
|
|
340
|
-
ensureBoundedToolResult(
|
|
341
|
-
...(isError ? { isError: true } : {}),
|
|
342
|
-
structuredContent: body,
|
|
343
|
-
content: [{ type: "text", text: JSON.stringify(body) }],
|
|
344
|
-
});
|
|
352
|
+
ensureBoundedToolResult(toolResult(body, isError));
|
|
345
353
|
|
|
346
354
|
const semanticResourceLinks = (body: Record<string, unknown>, origin: string): CallToolResult["content"] => {
|
|
347
355
|
const found = new Map<string, z.infer<typeof CapabilitySemanticLinkSchema>>();
|
|
@@ -573,14 +581,14 @@ const createMcpServer = (
|
|
|
573
581
|
let args: Record<string, unknown>;
|
|
574
582
|
try {
|
|
575
583
|
if (message.params.name === RESOURCE_READ_TOOL) {
|
|
576
|
-
const parsed =
|
|
584
|
+
const parsed = LinkedCloudResourceReferenceSchema.safeParse(message.params.arguments);
|
|
577
585
|
if (!parsed.success) {
|
|
578
586
|
const issues = parsed.error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; ");
|
|
579
587
|
return validationToolError(
|
|
580
588
|
`cloud__resource__read requires one exact typed ref returned by a current Cloud capability (${issues})`,
|
|
581
589
|
);
|
|
582
590
|
}
|
|
583
|
-
selected = await findResourceReaderTool(parsed.data, capabilityLookup, registry);
|
|
591
|
+
selected = await findResourceReaderTool({ type: parsed.data.type, id: parsed.data.id }, capabilityLookup, registry);
|
|
584
592
|
args = { id: parsed.data.id };
|
|
585
593
|
if (!selected) {
|
|
586
594
|
return boundedToolResult(
|
|
@@ -646,12 +654,35 @@ const createMcpServer = (
|
|
|
646
654
|
authority,
|
|
647
655
|
dependencies,
|
|
648
656
|
});
|
|
649
|
-
const
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
657
|
+
const providerBody = await parseResponse(response);
|
|
658
|
+
let body = providerBody;
|
|
659
|
+
const origin = response.ok ? publicCloudOrigin(await (dependencies.getAppUrl ?? defaultAppUrl)()) : undefined;
|
|
660
|
+
if (origin) {
|
|
661
|
+
const parsed = capabilityResultSchema(z.unknown()).safeParse(body);
|
|
662
|
+
const appIds = new Set(parsed.success ? parsed.data.refs?.map(cloudResourceRefAppId) : []);
|
|
663
|
+
const apps = appIds.size ? await registry().catch(() => []) : [];
|
|
664
|
+
const capabilities = await Promise.all(
|
|
665
|
+
apps
|
|
666
|
+
.filter((app) => appIds.has(app.id) && app.capabilities?.protocolVersion === CAPABILITY_PROTOCOL_VERSION)
|
|
667
|
+
.map(async (app) => {
|
|
668
|
+
const entry = await capabilityLookup(app.id).catch(() => null);
|
|
669
|
+
return entry?.appId === app.id && entry.manifest.manifestHash === app.capabilities?.manifestHash ? entry : null;
|
|
670
|
+
}),
|
|
671
|
+
);
|
|
672
|
+
body = addCloudResourceHrefs(
|
|
673
|
+
body,
|
|
674
|
+
capabilities.filter((entry) => entry !== null),
|
|
675
|
+
origin,
|
|
676
|
+
);
|
|
653
677
|
}
|
|
654
|
-
|
|
678
|
+
const resultFor = (value: Record<string, unknown>): CallToolResult => {
|
|
679
|
+
const result = toolResult(value, !response.ok);
|
|
680
|
+
if (origin) result.content.push(...semanticResourceLinks(value, origin));
|
|
681
|
+
return result;
|
|
682
|
+
};
|
|
683
|
+
// Added hrefs are optional: they never turn a result that fits into RESPONSE_TOO_LARGE.
|
|
684
|
+
const result = resultFor(body);
|
|
685
|
+
return ensureBoundedToolResult(fitsToolResult(result) || body === providerBody ? result : resultFor(providerBody));
|
|
655
686
|
});
|
|
656
687
|
|
|
657
688
|
return server;
|
package/src/api/pwa.ts
CHANGED
|
@@ -134,11 +134,16 @@ export const createPwaRoutes = (options: PwaRouteOptions = {}) => {
|
|
|
134
134
|
describeRoute({
|
|
135
135
|
tags,
|
|
136
136
|
summary: "Read a pairing",
|
|
137
|
-
description:
|
|
137
|
+
description:
|
|
138
|
+
"Only for the web session that started it. Never returns the code. A pending read is held for up to one poll interval and answers as soon as the phone claims the link.",
|
|
138
139
|
responses: { 200: jsonResponse(PwaPairingStatusSchema, "Pairing state") },
|
|
139
140
|
}),
|
|
140
141
|
v("param", IdParamSchema, pwaInvalidRequest),
|
|
141
|
-
async (c) =>
|
|
142
|
+
async (c) => {
|
|
143
|
+
const actor = await webActor(c);
|
|
144
|
+
const hold = { ms: PWA_LIMITS.pollSeconds * 1000, signal: c.req.raw.signal };
|
|
145
|
+
return c.json(await service.inspectPairing(actor, c.req.valid("param").id, hold));
|
|
146
|
+
},
|
|
142
147
|
)
|
|
143
148
|
.post(
|
|
144
149
|
"/pairings/:id/confirm",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getCapability } from "../_internal/registry";
|
|
3
|
+
import {
|
|
4
|
+
CapabilitySemanticLinkSchema,
|
|
5
|
+
type CloudResourceRef,
|
|
6
|
+
CloudResourceRefSchema,
|
|
7
|
+
capabilityResultSchema,
|
|
8
|
+
cloudResourceRefAppId,
|
|
9
|
+
resolveCapabilityResourceReader,
|
|
10
|
+
} from "../contracts/capabilities";
|
|
11
|
+
import type { RequestAuthority } from "../server";
|
|
12
|
+
import { dispatchCapability } from "./capabilities";
|
|
13
|
+
|
|
14
|
+
export const resolveCloudResourceHref = async (
|
|
15
|
+
params: { ref: CloudResourceRef; request: Request; authority: RequestAuthority; locale?: string },
|
|
16
|
+
dependencies: { getCapability?: typeof getCapability; dispatch?: typeof dispatchCapability } = {},
|
|
17
|
+
): Promise<string | null> => {
|
|
18
|
+
const parsed = CloudResourceRefSchema.safeParse(params.ref);
|
|
19
|
+
if (!parsed.success) return null;
|
|
20
|
+
const ref = parsed.data;
|
|
21
|
+
const appId = cloudResourceRefAppId(ref);
|
|
22
|
+
const app = await (dependencies.getCapability ?? getCapability)(appId);
|
|
23
|
+
if (!app || app.appId !== appId) return null;
|
|
24
|
+
const reader = resolveCapabilityResourceReader(app.manifest, ref);
|
|
25
|
+
if (!reader) return null;
|
|
26
|
+
const response = await (dependencies.dispatch ?? dispatchCapability)({
|
|
27
|
+
request: params.request,
|
|
28
|
+
authority: params.authority,
|
|
29
|
+
locale: params.locale,
|
|
30
|
+
origin: "http",
|
|
31
|
+
kind: "queries",
|
|
32
|
+
appId,
|
|
33
|
+
capabilityId: reader.localId,
|
|
34
|
+
input: { id: ref.id },
|
|
35
|
+
});
|
|
36
|
+
if (!response.ok) return null;
|
|
37
|
+
const result = capabilityResultSchema(z.unknown(), { consumer: true }).safeParse(await response.json());
|
|
38
|
+
if (!result.success) return null;
|
|
39
|
+
const link = CapabilitySemanticLinkSchema.safeParse(
|
|
40
|
+
result.data.links?.find((link) => link.rel === "open") ?? result.data.links?.find((link) => link.rel === "edit"),
|
|
41
|
+
);
|
|
42
|
+
return link.success ? link.data.href : null;
|
|
43
|
+
};
|