@decocms/mesh-sdk 1.2.1 → 1.2.2

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.
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Usage utilities for extracting cost and token stats from AI provider metadata.
3
+ *
4
+ * Supports provider-specific cost extraction (e.g., OpenRouter)
5
+ * and aggregation of usage across messages or streaming steps.
6
+ */
7
+
8
+ // ============================================================================
9
+ // Types
10
+ // ============================================================================
11
+
12
+ export interface UsageData {
13
+ inputTokens?: number;
14
+ outputTokens?: number;
15
+ reasoningTokens?: number;
16
+ totalTokens?: number;
17
+ providerMetadata?: {
18
+ [key: string]: unknown;
19
+ };
20
+ }
21
+
22
+ export interface UsageStats {
23
+ inputTokens: number;
24
+ outputTokens: number;
25
+ reasoningTokens: number;
26
+ totalTokens: number;
27
+ cost: number;
28
+ }
29
+
30
+ type ProviderCostExtractor = (
31
+ providerMetadata: NonNullable<UsageData["providerMetadata"]>,
32
+ ) => number | null;
33
+
34
+ // ============================================================================
35
+ // Provider-specific cost extractors
36
+ // ============================================================================
37
+
38
+ /**
39
+ * Registry of provider-specific cost extractors.
40
+ * Each extractor attempts to get the cost from provider metadata.
41
+ */
42
+ const PROVIDER_COST_EXTRACTORS: Record<string, ProviderCostExtractor> = {
43
+ openrouter: (providerMetadata) => {
44
+ const openrouter = providerMetadata?.openrouter;
45
+ if (
46
+ typeof openrouter === "object" &&
47
+ openrouter !== null &&
48
+ "usage" in openrouter &&
49
+ typeof openrouter.usage === "object" &&
50
+ openrouter.usage !== null &&
51
+ "cost" in openrouter.usage &&
52
+ typeof openrouter.usage.cost === "number"
53
+ ) {
54
+ return openrouter.usage.cost;
55
+ }
56
+ return null;
57
+ },
58
+ };
59
+
60
+ // ============================================================================
61
+ // Cost extraction
62
+ // ============================================================================
63
+
64
+ /**
65
+ * Extract cost from usage metadata by checking all known provider formats.
66
+ */
67
+ export function getCostFromUsage(usage: UsageData | null | undefined): number {
68
+ if (!usage?.providerMetadata) {
69
+ return 0;
70
+ }
71
+
72
+ for (const extractor of Object.values(PROVIDER_COST_EXTRACTORS)) {
73
+ const cost = extractor(usage.providerMetadata);
74
+ if (cost !== null) {
75
+ return cost;
76
+ }
77
+ }
78
+
79
+ return 0;
80
+ }
81
+
82
+ // ============================================================================
83
+ // Provider metadata sanitization
84
+ // ============================================================================
85
+
86
+ const ALLOWED_PROVIDER_FIELDS = ["usage", "cost", "model"] as const;
87
+
88
+ /**
89
+ * Sanitize provider metadata to prevent leaking sensitive data.
90
+ * Only allows whitelisted fields: usage, cost, model.
91
+ */
92
+ export function sanitizeProviderMetadata(
93
+ metadata: Record<string, unknown> | undefined,
94
+ ): Record<string, unknown> | undefined {
95
+ if (!metadata) return undefined;
96
+
97
+ const sanitized: Record<string, unknown> = {};
98
+ for (const provider in metadata) {
99
+ const providerData = metadata[provider];
100
+ if (typeof providerData === "object" && providerData !== null) {
101
+ const safeData: Record<string, unknown> = {};
102
+ for (const field of ALLOWED_PROVIDER_FIELDS) {
103
+ if (field in providerData) {
104
+ safeData[field] = (providerData as Record<string, unknown>)[field];
105
+ }
106
+ }
107
+ sanitized[provider] = safeData;
108
+ }
109
+ }
110
+ return Object.keys(sanitized).length > 0 ? sanitized : undefined;
111
+ }
112
+
113
+ // ============================================================================
114
+ // Usage accumulation
115
+ // ============================================================================
116
+
117
+ /**
118
+ * Create an empty UsageStats object.
119
+ */
120
+ export function emptyUsageStats(): UsageStats {
121
+ return {
122
+ inputTokens: 0,
123
+ outputTokens: 0,
124
+ reasoningTokens: 0,
125
+ totalTokens: 0,
126
+ cost: 0,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Accumulate a step's usage into an existing UsageStats total.
132
+ * Returns a new UsageStats object (immutable).
133
+ */
134
+ export function addUsage(
135
+ accumulated: UsageStats,
136
+ stepUsage: UsageData | null | undefined,
137
+ ): UsageStats {
138
+ if (!stepUsage) return accumulated;
139
+
140
+ return {
141
+ inputTokens: accumulated.inputTokens + (stepUsage.inputTokens ?? 0),
142
+ outputTokens: accumulated.outputTokens + (stepUsage.outputTokens ?? 0),
143
+ reasoningTokens:
144
+ accumulated.reasoningTokens + (stepUsage.reasoningTokens ?? 0),
145
+ totalTokens: accumulated.totalTokens + (stepUsage.totalTokens ?? 0),
146
+ cost: accumulated.cost + getCostFromUsage(stepUsage),
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Calculate aggregated usage stats from an array of messages.
152
+ * Each message is expected to have an optional `metadata.usage` field.
153
+ */
154
+ export function calculateUsageStats(
155
+ messages: Array<{ metadata?: { usage?: UsageData } }>,
156
+ ): UsageStats {
157
+ return messages.reduce<UsageStats>(
158
+ (acc, message) => addUsage(acc, message.metadata?.usage),
159
+ emptyUsageStats(),
160
+ );
161
+ }
@@ -0,0 +1,15 @@
1
+ // Plugin context provider and hook
2
+ export {
3
+ PluginContextProvider,
4
+ usePluginContext,
5
+ type PluginContextProviderProps,
6
+ type UsePluginContextOptions,
7
+ } from "./plugin-context-provider";
8
+
9
+ // Topbar portal system
10
+ export {
11
+ TopbarPortal,
12
+ TopbarPortalProvider,
13
+ useTopbarPortalTargets,
14
+ type TopbarSide,
15
+ } from "./topbar-portal";
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Plugin Context Provider
3
+ *
4
+ * React context provider and hook for accessing plugin context.
5
+ * Moved from @decocms/bindings to @decocms/mesh-sdk to consolidate
6
+ * all plugin-facing React components in one package.
7
+ */
8
+
9
+ import { createContext, useContext, type ReactNode } from "react";
10
+ import type {
11
+ Binder,
12
+ PluginContext,
13
+ PluginContextPartial,
14
+ } from "@decocms/bindings";
15
+
16
+ // Internal context stores the partial version (nullable connection fields)
17
+ // The hook return type depends on the options passed
18
+ const PluginContextInternal = createContext<PluginContextPartial | null>(null);
19
+
20
+ export interface PluginContextProviderProps<TBinding extends Binder> {
21
+ value: PluginContext<TBinding> | PluginContextPartial<TBinding>;
22
+ children: ReactNode;
23
+ }
24
+
25
+ /**
26
+ * Provider component for plugin context.
27
+ * Used by the mesh app layout to provide context to plugin routes.
28
+ */
29
+ export function PluginContextProvider<TBinding extends Binder>({
30
+ value,
31
+ children,
32
+ }: PluginContextProviderProps<TBinding>) {
33
+ return (
34
+ <PluginContextInternal.Provider value={value as PluginContextPartial}>
35
+ {children}
36
+ </PluginContextInternal.Provider>
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Options for usePluginContext hook.
42
+ */
43
+ export interface UsePluginContextOptions {
44
+ /**
45
+ * Set to true when calling from an empty state component.
46
+ * This returns nullable connection fields since no valid connection exists.
47
+ */
48
+ partial?: boolean;
49
+ }
50
+
51
+ /**
52
+ * Hook to access the plugin context with typed tool caller.
53
+ *
54
+ * @template TBinding - The binding type for typed tool calls
55
+ * @param options - Optional settings
56
+ * @param options.partial - Set to true in empty state components where connection may not exist
57
+ * @throws Error if used outside of PluginContextProvider
58
+ * @throws Error if connection is null but partial option is not set
59
+ *
60
+ * @example
61
+ * ```tsx
62
+ * // In route component (connection guaranteed by layout)
63
+ * const { toolCaller, connection } = usePluginContext<typeof REGISTRY_APP_BINDING>();
64
+ * const result = await toolCaller("COLLECTION_REGISTRY_APP_LIST", { limit: 20 });
65
+ *
66
+ * // In empty state component (no connection available)
67
+ * const { session, org } = usePluginContext<typeof REGISTRY_APP_BINDING>({ partial: true });
68
+ * ```
69
+ */
70
+ export function usePluginContext<TBinding extends Binder = Binder>(options: {
71
+ partial: true;
72
+ }): PluginContextPartial<TBinding>;
73
+ export function usePluginContext<
74
+ TBinding extends Binder = Binder,
75
+ >(): PluginContext<TBinding>;
76
+ export function usePluginContext<TBinding extends Binder = Binder>(
77
+ options?: UsePluginContextOptions,
78
+ ): PluginContext<TBinding> | PluginContextPartial<TBinding> {
79
+ const context = useContext(PluginContextInternal);
80
+ if (!context) {
81
+ throw new Error(
82
+ "usePluginContext must be used within a PluginContextProvider",
83
+ );
84
+ }
85
+
86
+ // If partial mode, return as-is with nullable fields
87
+ if (options?.partial) {
88
+ return context as PluginContextPartial<TBinding>;
89
+ }
90
+
91
+ // Otherwise, assert that connection exists (routes should always have one)
92
+ if (!context.connectionId || !context.connection || !context.toolCaller) {
93
+ throw new Error(
94
+ "usePluginContext requires a valid connection. Use { partial: true } in empty state components.",
95
+ );
96
+ }
97
+
98
+ return context as PluginContext<TBinding>;
99
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Topbar Portal
3
+ *
4
+ * A React portal-based system for rendering content into the project topbar
5
+ * from anywhere in the component tree (including plugin routes).
6
+ *
7
+ * Uses createPortal so that portaled content preserves the source tree's
8
+ * React context -- plugin context, query client, etc. all work naturally.
9
+ *
10
+ * Usage:
11
+ *
12
+ * 1. The app wraps its layout with <TopbarPortalProvider>
13
+ * 2. ProjectTopbar calls useTopbarPortalTargets() and attaches callback refs to slot divs
14
+ * 3. Plugin components render <TopbarPortal side="right">...</TopbarPortal>
15
+ */
16
+
17
+ import { createContext, useContext, useState, type ReactNode } from "react";
18
+ import { createPortal } from "react-dom";
19
+
20
+ export type TopbarSide = "left" | "center" | "right";
21
+
22
+ interface TopbarPortalContextValue {
23
+ /** Current DOM elements for each slot (null until ProjectTopbar mounts) */
24
+ leftEl: HTMLDivElement | null;
25
+ centerEl: HTMLDivElement | null;
26
+ rightEl: HTMLDivElement | null;
27
+ /** Callback refs for ProjectTopbar to register slot elements */
28
+ setLeftEl: (el: HTMLDivElement | null) => void;
29
+ setCenterEl: (el: HTMLDivElement | null) => void;
30
+ setRightEl: (el: HTMLDivElement | null) => void;
31
+ }
32
+
33
+ const TopbarPortalContext = createContext<TopbarPortalContextValue | null>(
34
+ null,
35
+ );
36
+
37
+ /**
38
+ * Provider that manages the portal target DOM elements for the three topbar slots.
39
+ * Place this in the layout tree so it wraps both the topbar and the content area.
40
+ */
41
+ export function TopbarPortalProvider({ children }: { children: ReactNode }) {
42
+ const [leftEl, setLeftEl] = useState<HTMLDivElement | null>(null);
43
+ const [centerEl, setCenterEl] = useState<HTMLDivElement | null>(null);
44
+ const [rightEl, setRightEl] = useState<HTMLDivElement | null>(null);
45
+
46
+ return (
47
+ <TopbarPortalContext.Provider
48
+ value={{ leftEl, centerEl, rightEl, setLeftEl, setCenterEl, setRightEl }}
49
+ >
50
+ {children}
51
+ </TopbarPortalContext.Provider>
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Hook used by the ProjectTopbar component to get callback refs for the portal target divs.
57
+ * Returns stable callback refs that register/unregister the DOM elements in the provider.
58
+ */
59
+ export function useTopbarPortalTargets() {
60
+ const ctx = useContext(TopbarPortalContext);
61
+
62
+ const leftRef = (el: HTMLDivElement | null) => ctx?.setLeftEl(el);
63
+ const centerRef = (el: HTMLDivElement | null) => ctx?.setCenterEl(el);
64
+ const rightRef = (el: HTMLDivElement | null) => ctx?.setRightEl(el);
65
+
66
+ if (!ctx) return null;
67
+
68
+ return { leftRef, centerRef, rightRef };
69
+ }
70
+
71
+ /**
72
+ * Portal component that renders children into one of the topbar slots.
73
+ *
74
+ * Because this uses React createPortal, the children maintain the React context
75
+ * of the component that renders <TopbarPortal> -- not the topbar's context.
76
+ * This means plugin context (connection, toolCaller, etc.) is available.
77
+ *
78
+ * @example
79
+ * ```tsx
80
+ * import { TopbarPortal, usePluginContext } from "@decocms/mesh-sdk/plugins";
81
+ *
82
+ * function MyPluginPage() {
83
+ * const { toolCaller } = usePluginContext<typeof MY_BINDING>();
84
+ *
85
+ * return (
86
+ * <>
87
+ * <TopbarPortal side="right">
88
+ * <Button onClick={() => toolCaller("SOME_TOOL", {})}>
89
+ * Action
90
+ * </Button>
91
+ * </TopbarPortal>
92
+ * <div>Page content...</div>
93
+ * </>
94
+ * );
95
+ * }
96
+ * ```
97
+ */
98
+ export function TopbarPortal({
99
+ side,
100
+ children,
101
+ }: {
102
+ side: TopbarSide;
103
+ children: ReactNode;
104
+ }) {
105
+ const ctx = useContext(TopbarPortalContext);
106
+ if (!ctx) return null;
107
+
108
+ const el =
109
+ side === "left"
110
+ ? ctx.leftEl
111
+ : side === "center"
112
+ ? ctx.centerEl
113
+ : ctx.rightEl;
114
+
115
+ if (!el) return null;
116
+
117
+ return createPortal(children, el);
118
+ }
@@ -0,0 +1,68 @@
1
+ // ============================================================================
2
+ // AI Provider Types — shared between server tool output and client hooks
3
+ // ============================================================================
4
+
5
+ export const PROVIDER_IDS = [
6
+ "deco",
7
+ "anthropic",
8
+ "openrouter",
9
+ "google",
10
+ "claude-code",
11
+ ] as const;
12
+
13
+ export type ProviderId = (typeof PROVIDER_IDS)[number];
14
+
15
+ /** All known model capability tokens. Sourced from OpenRouter modality strings. */
16
+ export const MODEL_CAPABILITIES = [
17
+ "text",
18
+ "image",
19
+ "vision",
20
+ "audio",
21
+ "video",
22
+ "file",
23
+ "reasoning",
24
+ ] as const;
25
+
26
+ export type ModelCapability = (typeof MODEL_CAPABILITIES)[number];
27
+
28
+ export interface AiProviderModelLimits {
29
+ contextWindow: number;
30
+ /** Null means the provider does not advertise a specific cap. */
31
+ maxOutputTokens: number | null;
32
+ }
33
+
34
+ export interface AiProviderModelCosts {
35
+ input: number;
36
+ output: number;
37
+ }
38
+
39
+ export interface AiProviderModel {
40
+ providerId: ProviderId;
41
+ modelId: string;
42
+ title: string;
43
+ description: string | null;
44
+ logo: string | null;
45
+ capabilities: ModelCapability[];
46
+ limits: AiProviderModelLimits | null;
47
+ costs: AiProviderModelCosts | null;
48
+ /** Client-side only — the credential key ID used to fetch this model. */
49
+ keyId?: string;
50
+ }
51
+
52
+ export interface AiProviderKey {
53
+ id: string;
54
+ providerId: ProviderId;
55
+ label: string;
56
+ createdBy: string;
57
+ createdAt: string;
58
+ }
59
+
60
+ export interface AiProviderInfo {
61
+ id: ProviderId;
62
+ name: string;
63
+ description: string;
64
+ logo?: string | null;
65
+ supportedMethods: ("api-key" | "oauth-pkce")[];
66
+ supportsTopUp?: boolean;
67
+ supportsCredits?: boolean;
68
+ }
@@ -5,6 +5,7 @@
5
5
  * Uses snake_case field names matching the database schema directly.
6
6
  */
7
7
 
8
+ import { ToolSchema } from "@modelcontextprotocol/sdk/types.js";
8
9
  import { z } from "zod";
9
10
 
10
11
  /**
@@ -23,13 +24,26 @@ const OAuthConfigSchema = z.object({
23
24
  export type OAuthConfig = z.infer<typeof OAuthConfigSchema>;
24
25
 
25
26
  /**
26
- * Tool definition schema from MCP discovery
27
+ * JSON-Schema-safe JSON Schema object.
28
+ *
29
+ * The MCP SDK's ToolSchema uses z.custom() (AssertObjectSchema) for property
30
+ * values inside inputSchema/outputSchema. Zod 4's toJSONSchema() cannot
31
+ * represent z.custom(), throwing "Custom types cannot be represented in JSON
32
+ * Schema". We override only these two fields with a safe equivalent so that
33
+ * all other ToolSchema fields (name, annotations, execution, icons, _meta, …)
34
+ * still flow through automatically when the MCP SDK is updated.
27
35
  */
28
- const ToolDefinitionSchema = z.object({
29
- name: z.string(),
30
- description: z.string().optional(),
31
- inputSchema: z.record(z.string(), z.unknown()),
32
- outputSchema: z.record(z.string(), z.unknown()).optional(),
36
+ const JsonSchemaObjectSchema = z
37
+ .object({
38
+ type: z.literal("object"),
39
+ properties: z.record(z.string(), z.unknown()).optional(),
40
+ required: z.array(z.string()).optional(),
41
+ })
42
+ .catchall(z.unknown());
43
+
44
+ const ToolDefinitionSchema = ToolSchema.extend({
45
+ inputSchema: JsonSchemaObjectSchema,
46
+ outputSchema: JsonSchemaObjectSchema.optional(),
33
47
  });
34
48
 
35
49
  export type ToolDefinition = z.infer<typeof ToolDefinitionSchema>;
@@ -152,20 +166,24 @@ export const ConnectionCreateDataSchema = ConnectionEntitySchema.omit({
152
166
  tools: true,
153
167
  bindings: true,
154
168
  status: true,
155
- }).partial({
156
- id: true,
157
- description: true,
158
- icon: true,
159
- app_name: true,
160
- app_id: true,
161
- connection_url: true,
162
- connection_token: true,
163
- connection_headers: true,
164
- oauth_config: true,
165
- configuration_state: true,
166
- configuration_scopes: true,
167
- metadata: true,
168
- });
169
+ })
170
+ .partial({
171
+ id: true,
172
+ description: true,
173
+ icon: true,
174
+ app_name: true,
175
+ app_id: true,
176
+ connection_url: true,
177
+ connection_token: true,
178
+ connection_headers: true,
179
+ oauth_config: true,
180
+ configuration_state: true,
181
+ configuration_scopes: true,
182
+ metadata: true,
183
+ })
184
+ .extend({
185
+ icon: z.string().nullish(),
186
+ });
169
187
 
170
188
  export type ConnectionCreateData = z.infer<typeof ConnectionCreateDataSchema>;
171
189
 
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Decopilot SSE Event Types
3
+ *
4
+ * Canonical type definitions for thread statuses and decopilot SSE events.
5
+ * Shared between server (emitter) and client (consumer) for full type safety.
6
+ */
7
+
8
+ // ============================================================================
9
+ // Thread Status
10
+ // ============================================================================
11
+
12
+ /** Persisted thread statuses (written to DB). */
13
+ export const THREAD_STATUSES = [
14
+ "in_progress",
15
+ "requires_action",
16
+ "failed",
17
+ "completed",
18
+ ] as const;
19
+ export type ThreadStatus = (typeof THREAD_STATUSES)[number];
20
+
21
+ /**
22
+ * Display statuses include "expired" — a virtual status computed at read time
23
+ * for threads stuck in "in_progress" beyond a timeout threshold.
24
+ * Never persisted to DB, but appears in API responses and UI.
25
+ */
26
+ export const THREAD_DISPLAY_STATUSES = [...THREAD_STATUSES, "expired"] as const;
27
+ export type ThreadDisplayStatus = (typeof THREAD_DISPLAY_STATUSES)[number];
28
+
29
+ // ============================================================================
30
+ // SSE Event Type Constants
31
+ // ============================================================================
32
+
33
+ export const DECOPILOT_EVENTS = {
34
+ STEP: "decopilot.step",
35
+ FINISH: "decopilot.finish",
36
+ THREAD_STATUS: "decopilot.thread.status",
37
+ } as const;
38
+
39
+ export type DecopilotEventType =
40
+ (typeof DECOPILOT_EVENTS)[keyof typeof DECOPILOT_EVENTS];
41
+
42
+ export const ALL_DECOPILOT_EVENT_TYPES: DecopilotEventType[] =
43
+ Object.values(DECOPILOT_EVENTS);
44
+
45
+ // ============================================================================
46
+ // Event Payloads (discriminated union on `type`)
47
+ // ============================================================================
48
+
49
+ interface BaseDecopilotEvent {
50
+ id: string;
51
+ source: "decopilot";
52
+ /** Thread ID this event relates to */
53
+ subject: string;
54
+ time: string;
55
+ }
56
+
57
+ export interface DecopilotStepEvent extends BaseDecopilotEvent {
58
+ type: typeof DECOPILOT_EVENTS.STEP;
59
+ data: { stepCount: number };
60
+ }
61
+
62
+ export interface DecopilotFinishEvent extends BaseDecopilotEvent {
63
+ type: typeof DECOPILOT_EVENTS.FINISH;
64
+ data: { status: ThreadStatus };
65
+ }
66
+
67
+ export interface DecopilotThreadStatusEvent extends BaseDecopilotEvent {
68
+ type: typeof DECOPILOT_EVENTS.THREAD_STATUS;
69
+ data: { status: ThreadStatus };
70
+ }
71
+
72
+ export type DecopilotSSEEvent =
73
+ | DecopilotStepEvent
74
+ | DecopilotFinishEvent
75
+ | DecopilotThreadStatusEvent;
76
+
77
+ /** Map from event type string → typed payload (useful for generic handlers) */
78
+ export interface DecopilotEventMap {
79
+ [DECOPILOT_EVENTS.STEP]: DecopilotStepEvent;
80
+ [DECOPILOT_EVENTS.FINISH]: DecopilotFinishEvent;
81
+ [DECOPILOT_EVENTS.THREAD_STATUS]: DecopilotThreadStatusEvent;
82
+ }
83
+
84
+ // ============================================================================
85
+ // Server-side Factories (create typed events for SSEHub.emit)
86
+ // ============================================================================
87
+
88
+ export function createDecopilotStepEvent(
89
+ threadId: string,
90
+ stepCount: number,
91
+ ): DecopilotStepEvent {
92
+ return {
93
+ id: crypto.randomUUID(),
94
+ type: DECOPILOT_EVENTS.STEP,
95
+ source: "decopilot",
96
+ subject: threadId,
97
+ data: { stepCount },
98
+ time: new Date().toISOString(),
99
+ };
100
+ }
101
+
102
+ export function createDecopilotFinishEvent(
103
+ threadId: string,
104
+ status: ThreadStatus,
105
+ ): DecopilotFinishEvent {
106
+ return {
107
+ id: crypto.randomUUID(),
108
+ type: DECOPILOT_EVENTS.FINISH,
109
+ source: "decopilot",
110
+ subject: threadId,
111
+ data: { status },
112
+ time: new Date().toISOString(),
113
+ };
114
+ }
115
+
116
+ export function createDecopilotThreadStatusEvent(
117
+ threadId: string,
118
+ status: ThreadStatus,
119
+ ): DecopilotThreadStatusEvent {
120
+ return {
121
+ id: crypto.randomUUID(),
122
+ type: DECOPILOT_EVENTS.THREAD_STATUS,
123
+ source: "decopilot",
124
+ subject: threadId,
125
+ data: { status },
126
+ time: new Date().toISOString(),
127
+ };
128
+ }