@digimuza/system-core 0.2.0-rc.2 → 0.3.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/INTEGRATION.md +209 -3
  2. package/README.md +23 -0
  3. package/dist/ai/api.d.ts +120 -0
  4. package/dist/ai/api.d.ts.map +1 -0
  5. package/dist/ai/api.js +446 -0
  6. package/dist/ai/api.js.map +1 -0
  7. package/dist/ai/card.d.ts +27 -0
  8. package/dist/ai/card.d.ts.map +1 -0
  9. package/dist/ai/card.js +84 -0
  10. package/dist/ai/card.js.map +1 -0
  11. package/dist/ai/contract.d.ts +364 -0
  12. package/dist/ai/contract.d.ts.map +1 -0
  13. package/dist/ai/contract.js +101 -0
  14. package/dist/ai/contract.js.map +1 -0
  15. package/dist/ai/display.d.ts +20 -0
  16. package/dist/ai/display.d.ts.map +1 -0
  17. package/dist/ai/display.js +35 -0
  18. package/dist/ai/display.js.map +1 -0
  19. package/dist/ai/index.d.ts +7 -0
  20. package/dist/ai/index.d.ts.map +1 -0
  21. package/dist/ai/index.js +10 -0
  22. package/dist/ai/index.js.map +1 -0
  23. package/dist/ai/manifest.d.ts +25 -0
  24. package/dist/ai/manifest.d.ts.map +1 -0
  25. package/dist/ai/manifest.js +15 -0
  26. package/dist/ai/manifest.js.map +1 -0
  27. package/dist/ai/runner.d.ts +71 -0
  28. package/dist/ai/runner.d.ts.map +1 -0
  29. package/dist/ai/runner.js +246 -0
  30. package/dist/ai/runner.js.map +1 -0
  31. package/dist/ai/tools.d.ts +42 -0
  32. package/dist/ai/tools.d.ts.map +1 -0
  33. package/dist/ai/tools.js +117 -0
  34. package/dist/ai/tools.js.map +1 -0
  35. package/dist/audit/csv.d.ts +31 -0
  36. package/dist/audit/csv.d.ts.map +1 -0
  37. package/dist/audit/csv.js +39 -0
  38. package/dist/audit/csv.js.map +1 -0
  39. package/dist/audit/index.d.ts +5 -0
  40. package/dist/audit/index.d.ts.map +1 -0
  41. package/dist/audit/index.js +6 -0
  42. package/dist/audit/index.js.map +1 -0
  43. package/dist/convex/functions.d.ts +39 -0
  44. package/dist/convex/functions.d.ts.map +1 -1
  45. package/dist/convex/functions.js +38 -6
  46. package/dist/convex/functions.js.map +1 -1
  47. package/dist/convex/index.d.ts +1 -1
  48. package/dist/convex/index.d.ts.map +1 -1
  49. package/dist/convex/index.js.map +1 -1
  50. package/dist/core/access.d.ts +14 -0
  51. package/dist/core/access.d.ts.map +1 -1
  52. package/dist/core/access.js +62 -0
  53. package/dist/core/access.js.map +1 -1
  54. package/dist/core/audit-module.d.ts +5 -0
  55. package/dist/core/audit-module.d.ts.map +1 -0
  56. package/dist/core/audit-module.js +25 -0
  57. package/dist/core/audit-module.js.map +1 -0
  58. package/dist/core/audit.d.ts +31 -0
  59. package/dist/core/audit.d.ts.map +1 -1
  60. package/dist/core/audit.js +46 -2
  61. package/dist/core/audit.js.map +1 -1
  62. package/dist/core/catalog.d.ts +41 -0
  63. package/dist/core/catalog.d.ts.map +1 -1
  64. package/dist/core/catalog.js +45 -0
  65. package/dist/core/catalog.js.map +1 -1
  66. package/dist/core/context.d.ts +8 -2
  67. package/dist/core/context.d.ts.map +1 -1
  68. package/dist/core/context.js +4 -1
  69. package/dist/core/context.js.map +1 -1
  70. package/dist/core/index.d.ts +2 -0
  71. package/dist/core/index.d.ts.map +1 -1
  72. package/dist/core/index.js +2 -0
  73. package/dist/core/index.js.map +1 -1
  74. package/dist/core/notify.d.ts +37 -0
  75. package/dist/core/notify.d.ts.map +1 -0
  76. package/dist/core/notify.js +16 -0
  77. package/dist/core/notify.js.map +1 -0
  78. package/dist/core/roles.d.ts.map +1 -1
  79. package/dist/core/roles.js +5 -1
  80. package/dist/core/roles.js.map +1 -1
  81. package/dist/core/system-modules.d.ts +4 -0
  82. package/dist/core/system-modules.d.ts.map +1 -1
  83. package/dist/core/system-modules.js +11 -0
  84. package/dist/core/system-modules.js.map +1 -1
  85. package/dist/finance/ai-tools.d.ts +55 -0
  86. package/dist/finance/ai-tools.d.ts.map +1 -0
  87. package/dist/finance/ai-tools.js +65 -0
  88. package/dist/finance/ai-tools.js.map +1 -0
  89. package/dist/finance/index.d.ts +87 -1
  90. package/dist/finance/index.d.ts.map +1 -1
  91. package/dist/finance/index.js +92 -38
  92. package/dist/finance/index.js.map +1 -1
  93. package/dist/finance/search.d.ts +15 -0
  94. package/dist/finance/search.d.ts.map +1 -0
  95. package/dist/finance/search.js +51 -0
  96. package/dist/finance/search.js.map +1 -0
  97. package/dist/index.d.ts +2 -0
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +2 -0
  100. package/dist/index.js.map +1 -1
  101. package/dist/modules/index.d.ts +1 -1
  102. package/dist/modules/index.d.ts.map +1 -1
  103. package/dist/modules/index.js +1 -1
  104. package/dist/modules/index.js.map +1 -1
  105. package/dist/notifications/api.d.ts +130 -0
  106. package/dist/notifications/api.d.ts.map +1 -0
  107. package/dist/notifications/api.js +384 -0
  108. package/dist/notifications/api.js.map +1 -0
  109. package/dist/notifications/contract.d.ts +132 -0
  110. package/dist/notifications/contract.d.ts.map +1 -0
  111. package/dist/notifications/contract.js +59 -0
  112. package/dist/notifications/contract.js.map +1 -0
  113. package/dist/notifications/index.d.ts +6 -0
  114. package/dist/notifications/index.d.ts.map +1 -0
  115. package/dist/notifications/index.js +9 -0
  116. package/dist/notifications/index.js.map +1 -0
  117. package/dist/search/index.d.ts +95 -0
  118. package/dist/search/index.d.ts.map +1 -0
  119. package/dist/search/index.js +151 -0
  120. package/dist/search/index.js.map +1 -0
  121. package/package.json +41 -2
  122. package/src/ai/api.ts +535 -0
  123. package/src/ai/card.ts +91 -0
  124. package/src/ai/contract.ts +245 -0
  125. package/src/ai/display.ts +38 -0
  126. package/src/ai/index.ts +9 -0
  127. package/src/ai/manifest.ts +16 -0
  128. package/src/ai/runner.ts +275 -0
  129. package/src/ai/tools.ts +128 -0
  130. package/src/audit/csv.ts +60 -0
  131. package/src/audit/index.ts +7 -0
  132. package/src/convex/functions.ts +54 -7
  133. package/src/convex/index.ts +1 -1
  134. package/src/core/access.ts +55 -1
  135. package/src/core/audit-module.ts +26 -0
  136. package/src/core/audit.ts +68 -2
  137. package/src/core/catalog.ts +63 -0
  138. package/src/core/context.ts +13 -2
  139. package/src/core/index.ts +2 -0
  140. package/src/core/notify.ts +52 -0
  141. package/src/core/roles.ts +5 -1
  142. package/src/core/system-modules.ts +12 -0
  143. package/src/finance/ai-tools.ts +68 -0
  144. package/src/finance/index.ts +119 -42
  145. package/src/finance/search.ts +58 -0
  146. package/src/index.ts +2 -0
  147. package/src/modules/index.ts +3 -0
  148. package/src/notifications/api.ts +477 -0
  149. package/src/notifications/contract.ts +142 -0
  150. package/src/notifications/index.ts +8 -0
  151. package/src/search/index.ts +198 -0
package/src/ai/api.ts ADDED
@@ -0,0 +1,535 @@
1
+ // The AI module's core data & API layer: threads, messages and runs in Convex.
2
+ // Public functions guard themselves (permission, module enabled, role view,
3
+ // human session, ownership) in the same transaction as the change. Internal
4
+ // functions are called only by the runner (convex/aiRunner.ts).
5
+ import { internalMutationGeneric, internalQueryGeneric, makeFunctionReference, mutationGeneric, queryGeneric } from "convex/server"
6
+ import { v } from "convex/values"
7
+ import { decide, requireHuman, toActorRef, type ActorRef, type UserActor } from "../core/access.js"
8
+ import { recordAudit } from "../core/audit.js"
9
+ import type { SystemContext } from "../core/context.js"
10
+ import { SystemError } from "../core/errors.js"
11
+ import { authorizeJob } from "../core/jobs.js"
12
+ import { toConvexError, type ConvexSystem, type DbCtx } from "../convex/functions.js"
13
+ import {
14
+ AI_AUDIT,
15
+ type AgentView,
16
+ type AiAgentDefinition,
17
+ type AiMessageId,
18
+ type AiOptions,
19
+ type AiRunId,
20
+ type AiThreadId,
21
+ type MessagePart,
22
+ type MessageStatus,
23
+ type MessageView,
24
+ type RunContext,
25
+ type RunStatus,
26
+ type ThreadStatus,
27
+ type ThreadView,
28
+ type ToolCallState,
29
+ type ToolExecutionResult,
30
+ toolCallState,
31
+ } from "./contract.js"
32
+ import { AiPermissions } from "./manifest.js"
33
+ import { executeAiTool } from "./tools.js"
34
+
35
+ export const AI_MESSAGE_MAX = 8000
36
+ export const AI_TITLE_MAX = 120
37
+ const AUTO_TITLE_LENGTH = 60
38
+ const PAGE_DEFAULT = 30
39
+ const PAGE_MAX = 100
40
+
41
+ // --- Rows & loosely typed db (the app schema contains `aiTables`) ---------------------
42
+
43
+ type ErrorInfo = { code: string; message: string }
44
+ type MessageErrorInfo = ErrorInfo & { operationId?: string }
45
+
46
+ interface ThreadDoc {
47
+ _id: AiThreadId
48
+ _creationTime: number
49
+ ownerId: string
50
+ title: string
51
+ agentId: string
52
+ status: ThreadStatus
53
+ archived: boolean
54
+ createdAt: number
55
+ updatedAt: number
56
+ }
57
+
58
+ interface MessageDoc {
59
+ _id: AiMessageId
60
+ _creationTime: number
61
+ threadId: AiThreadId
62
+ role: "user" | "assistant"
63
+ parts: MessagePart[]
64
+ status: MessageStatus
65
+ error?: MessageErrorInfo
66
+ runId?: AiRunId
67
+ createdAt: number
68
+ }
69
+
70
+ interface RunDoc {
71
+ _id: AiRunId
72
+ _creationTime: number
73
+ threadId: AiThreadId
74
+ messageId: AiMessageId
75
+ actor: ActorRef
76
+ agentId: string
77
+ model: string
78
+ status: RunStatus
79
+ stopRequested: boolean
80
+ operationId: string
81
+ error?: ErrorInfo
82
+ createdAt: number
83
+ updatedAt: number
84
+ }
85
+
86
+ type Docs = { aiThreads: ThreadDoc; aiMessages: MessageDoc; aiRuns: RunDoc }
87
+ type Table = keyof Docs
88
+ type IndexQ = { eq(field: string, value: unknown): IndexQ }
89
+ interface Ordered<D> {
90
+ take(n: number): Promise<D[]>
91
+ first(): Promise<D | null>
92
+ collect(): Promise<D[]>
93
+ paginate(o: { cursor: string | null; numItems: number }): Promise<{ page: D[]; continueCursor: string; isDone: boolean }>
94
+ }
95
+ interface Query<D> extends Ordered<D> {
96
+ order(o: "asc" | "desc"): Ordered<D>
97
+ }
98
+ interface Db {
99
+ query<T extends Table>(t: T): { withIndex(i: string, f: (q: IndexQ) => unknown): Query<Docs[T]> }
100
+ get<T extends Table>(t: T, id: string): Promise<Docs[T] | null>
101
+ insert<T extends Table>(t: T, doc: Omit<Docs[T], "_id" | "_creationTime">): Promise<Docs[T]["_id"]>
102
+ patch<T extends Table>(t: T, id: string, doc: Partial<Omit<Docs[T], "_id" | "_creationTime">>): Promise<void>
103
+ }
104
+ type Scheduler = { runAfter(ms: number, ref: unknown, args: Record<string, unknown>): Promise<unknown> }
105
+ const db = (ctx: { db: unknown }) => ctx.db as Db
106
+
107
+ // --- Views -------------------------------------------------------------------------------
108
+
109
+ const threadView = (t: ThreadDoc): ThreadView => ({ id: t._id, title: t.title, agentId: t.agentId, status: t.status, createdAt: t.createdAt, updatedAt: t.updatedAt })
110
+
111
+ const messageView = (m: MessageDoc): MessageView => ({
112
+ id: m._id,
113
+ role: m.role,
114
+ parts: m.parts,
115
+ status: m.status,
116
+ error: m.error ? { code: m.error.code, message: m.error.message, ...(m.error.operationId ? { operationId: m.error.operationId } : {}) } : null,
117
+ createdAt: m.createdAt,
118
+ })
119
+
120
+ const agentView = (a: AiAgentDefinition, tools: AiAgentDefinition["tools"]): AgentView => ({
121
+ id: a.id,
122
+ name: a.name,
123
+ description: a.description,
124
+ tools: tools.map((t) => ({ id: t.id, description: t.description, requiresApproval: t.requiresApproval ?? false })),
125
+ })
126
+
127
+ // --- Helpers -----------------------------------------------------------------------------
128
+
129
+ type FailCode = "NOT_FOUND" | "CONFLICT" | "VALIDATION" | "FORBIDDEN"
130
+
131
+ function fail(code: FailCode, reason: string, operationId: string): never {
132
+ throw toConvexError(new SystemError(code, undefined, { reason, operationId }), operationId)
133
+ }
134
+
135
+ const actorArgs = { apiKey: v.optional(v.string()), operationId: v.optional(v.string()) }
136
+ const ACTIVE_RUN: ReadonlySet<RunStatus> = new Set(["queued", "running", "waiting-approval"])
137
+ const finishStatus = v.union(v.literal("done"), v.literal("error"), v.literal("stopped"), v.literal("waiting-approval"))
138
+ const errorInfo = v.object({ code: v.string(), message: v.string() })
139
+
140
+ /** First line-ish of the first message, whitespace collapsed. */
141
+ const autoTitle = (text: string) => {
142
+ const flat = text.replace(/\s+/g, " ").trim()
143
+ return flat.length <= AUTO_TITLE_LENGTH ? flat : `${flat.slice(0, AUTO_TITLE_LENGTH - 1).trimEnd()}…`
144
+ }
145
+
146
+ /**
147
+ * The AI module's Convex functions, bound to the app's system:
148
+ * // convex/ai.ts
149
+ * export const { listAgents, listThreads, …, _startRun, … } = defineAi(system, aiOptions).functions
150
+ * Names: AI_PUBLIC_FUNCTIONS + AI_INTERNAL_FUNCTIONS in contract.ts.
151
+ */
152
+ export function defineAi(system: ConvexSystem, options: AiOptions) {
153
+ if (!options.agents.length) throw new Error("defineAi: at least one agent is required")
154
+ const agents = new Map(options.agents.map((a) => [a.id, a]))
155
+ const runnerRef = makeFunctionReference<"action">(`${options.runnerModule ?? "aiRunner"}:runAgent`)
156
+
157
+ /** Permission, module, role view, then human-only. */
158
+ async function guard(ctx: DbCtx, a: { apiKey?: string | undefined; operationId?: string | undefined }): Promise<{ sys: SystemContext; actor: UserActor }> {
159
+ const { sys, actor } = await system.requirePermission(ctx, AiPermissions.use, { apiKey: a.apiKey, operationId: a.operationId })
160
+ try {
161
+ return { sys, actor: requireHuman(sys, actor) }
162
+ } catch (e) {
163
+ throw toConvexError(e, sys.operationId)
164
+ }
165
+ }
166
+
167
+ /** A thread the caller owns. Missing and foreign threads look the same. */
168
+ async function ownThread(ctx: { db: unknown }, sys: SystemContext, actor: UserActor, threadId: string): Promise<ThreadDoc> {
169
+ const thread = await db(ctx).get("aiThreads", threadId)
170
+ if (!thread || thread.ownerId !== actor.userId) fail("NOT_FOUND", "thread_not_found", sys.operationId)
171
+ return thread!
172
+ }
173
+
174
+ function schedule(ctx: { scheduler: unknown }, runId: AiRunId) {
175
+ return (ctx.scheduler as Scheduler).runAfter(0, runnerRef, { runId })
176
+ }
177
+
178
+ /** Closes a run: run, its assistant message and the thread. */
179
+ async function finish(ctx: { db: unknown }, run: RunDoc, status: "done" | "error" | "stopped" | "waiting-approval", error?: ErrorInfo) {
180
+ const now = Date.now()
181
+ await db(ctx).patch("aiRuns", run._id, { status, updatedAt: now, ...(error ? { error } : {}) })
182
+ const message = await db(ctx).get("aiMessages", run.messageId)
183
+ if (message) {
184
+ // Waiting for approval: the message is complete for now; its parts hold the pending call.
185
+ const msgStatus: MessageStatus = status === "waiting-approval" ? "done" : status
186
+ const patch: Partial<MessageDoc> = { status: msgStatus }
187
+ // The message carries the run's operationId so the UI can show it next to the error.
188
+ if (error) patch.error = { code: error.code, message: error.message, operationId: run.operationId }
189
+ if (status === "stopped") {
190
+ // Nobody can approve a stopped run's calls any more.
191
+ patch.parts = message.parts.map((p) => (p.type === "tool-call" && p.state === "awaiting-approval" ? { ...p, state: "rejected", error: "stopped" } : p))
192
+ }
193
+ await db(ctx).patch("aiMessages", message._id, patch)
194
+ }
195
+ await db(ctx).patch("aiThreads", run.threadId, { status: status === "waiting-approval" ? "waiting-approval" : "idle", updatedAt: now })
196
+ }
197
+
198
+ /** Puts a run back in the queue and schedules the runner. */
199
+ async function resume(ctx: { db: unknown; scheduler: unknown }, run: RunDoc) {
200
+ const now = Date.now()
201
+ await db(ctx).patch("aiRuns", run._id, { status: "queued", updatedAt: now })
202
+ await db(ctx).patch("aiMessages", run.messageId, { status: "streaming" })
203
+ await db(ctx).patch("aiThreads", run.threadId, { status: "running", updatedAt: now })
204
+ await schedule(ctx, run._id)
205
+ }
206
+
207
+ async function bindRun(ctx: DbCtx, run: RunDoc) {
208
+ return (await system.context(ctx, { operationId: run.operationId })).sys
209
+ }
210
+
211
+ async function updateMessageParts(ctx: { db: unknown }, messageId: string, fn: (parts: MessagePart[]) => MessagePart[] | null) {
212
+ const message = await db(ctx).get("aiMessages", messageId)
213
+ if (!message) throw toConvexError(new SystemError("NOT_FOUND", undefined, { reason: "message_not_found" }))
214
+ const parts = fn(message.parts)
215
+ if (parts) await db(ctx).patch("aiMessages", messageId, { parts })
216
+ }
217
+
218
+ const functions = {
219
+ // --- Public ---------------------------------------------------------------------------
220
+
221
+ listAgents: queryGeneric({
222
+ args: { ...actorArgs },
223
+ handler: async (ctx, a): Promise<AgentView[]> => {
224
+ const { sys, actor } = await guard(ctx, a)
225
+ const out: AgentView[] = []
226
+ for (const agent of options.agents) {
227
+ const tools = []
228
+ for (const tool of agent.tools) if ((await decide(sys, actor, tool.permission)).allowed) tools.push(tool)
229
+ out.push(agentView(agent, tools))
230
+ }
231
+ return out
232
+ },
233
+ }),
234
+
235
+ listThreads: queryGeneric({
236
+ args: { ...actorArgs, cursor: v.optional(v.union(v.string(), v.null())), limit: v.optional(v.number()) },
237
+ handler: async (ctx, a): Promise<{ items: ThreadView[]; cursor: string | null; done: boolean }> => {
238
+ const { actor } = await guard(ctx, a)
239
+ const numItems = Math.min(Math.max(1, Math.floor(a.limit ?? PAGE_DEFAULT)), PAGE_MAX)
240
+ const res = await db(ctx)
241
+ .query("aiThreads")
242
+ .withIndex("by_owner", (q) => q.eq("ownerId", actor.userId).eq("archived", false))
243
+ .order("desc")
244
+ .paginate({ cursor: a.cursor ?? null, numItems })
245
+ return { items: res.page.map(threadView), cursor: res.isDone ? null : res.continueCursor, done: res.isDone }
246
+ },
247
+ }),
248
+
249
+ getThread: queryGeneric({
250
+ args: { ...actorArgs, threadId: v.id("aiThreads") },
251
+ handler: async (ctx, a): Promise<ThreadView> => {
252
+ const { sys, actor } = await guard(ctx, a)
253
+ return threadView(await ownThread(ctx, sys, actor, a.threadId))
254
+ },
255
+ }),
256
+
257
+ listMessages: queryGeneric({
258
+ args: { ...actorArgs, threadId: v.id("aiThreads") },
259
+ handler: async (ctx, a): Promise<MessageView[]> => {
260
+ const { sys, actor } = await guard(ctx, a)
261
+ await ownThread(ctx, sys, actor, a.threadId)
262
+ const rows = await db(ctx)
263
+ .query("aiMessages")
264
+ .withIndex("by_thread", (q) => q.eq("threadId", a.threadId))
265
+ .order("asc")
266
+ .collect()
267
+ return rows.map(messageView)
268
+ },
269
+ }),
270
+
271
+ createThread: mutationGeneric({
272
+ args: { ...actorArgs, agentId: v.optional(v.string()), title: v.optional(v.string()) },
273
+ handler: async (ctx, a): Promise<{ threadId: AiThreadId }> => {
274
+ const { sys, actor } = await guard(ctx, a)
275
+ const agentId = a.agentId ?? options.agents[0]!.id
276
+ if (!agents.has(agentId)) fail("VALIDATION", "unknown_agent", sys.operationId)
277
+ const title = (a.title ?? "").trim()
278
+ if (title.length > AI_TITLE_MAX) fail("VALIDATION", "title_too_long", sys.operationId)
279
+ const now = Date.now()
280
+ const threadId = await db(ctx).insert("aiThreads", { ownerId: actor.userId, title, agentId, status: "idle", archived: false, createdAt: now, updatedAt: now })
281
+ await recordAudit(sys, actor, { action: AI_AUDIT.threadCreate, targetType: "aiThread", targetId: threadId, changes: { agentId } })
282
+ return { threadId }
283
+ },
284
+ }),
285
+
286
+ sendMessage: mutationGeneric({
287
+ args: { ...actorArgs, threadId: v.id("aiThreads"), text: v.string() },
288
+ handler: async (ctx, a): Promise<{ messageId: AiMessageId; runId: AiRunId }> => {
289
+ const { sys, actor } = await guard(ctx, a)
290
+ const thread = await ownThread(ctx, sys, actor, a.threadId)
291
+ const text = a.text.trim()
292
+ if (!text) fail("VALIDATION", "empty_message", sys.operationId)
293
+ if (text.length > AI_MESSAGE_MAX) fail("VALIDATION", "message_too_long", sys.operationId)
294
+ if (thread.status !== "idle") fail("CONFLICT", "thread_busy", sys.operationId)
295
+ const agent = agents.get(thread.agentId)
296
+ if (!agent) fail("VALIDATION", "unknown_agent", sys.operationId)
297
+
298
+ const now = Date.now()
299
+ const messageId = await db(ctx).insert("aiMessages", { threadId: thread._id, role: "user", parts: [{ type: "text", text }], status: "done", createdAt: now })
300
+ const assistantId = await db(ctx).insert("aiMessages", { threadId: thread._id, role: "assistant", parts: [], status: "streaming", createdAt: now + 1 })
301
+ const runId = await db(ctx).insert("aiRuns", {
302
+ threadId: thread._id,
303
+ messageId: assistantId,
304
+ actor: toActorRef(actor),
305
+ agentId: agent!.id,
306
+ model: agent!.model ?? process.env.AI_MODEL ?? options.defaultModel,
307
+ status: "queued",
308
+ stopRequested: false,
309
+ operationId: sys.operationId,
310
+ createdAt: now,
311
+ updatedAt: now,
312
+ })
313
+ await db(ctx).patch("aiMessages", assistantId, { runId })
314
+ await db(ctx).patch("aiThreads", thread._id, { status: "running", updatedAt: now, ...(thread.title ? {} : { title: autoTitle(text) }) })
315
+ await schedule(ctx, runId)
316
+ await recordAudit(sys, actor, { action: AI_AUDIT.messageSend, targetType: "aiThread", targetId: thread._id, changes: { messageId, runId, length: text.length } })
317
+ return { messageId, runId }
318
+ },
319
+ }),
320
+
321
+ stopRun: mutationGeneric({
322
+ args: { ...actorArgs, threadId: v.id("aiThreads") },
323
+ handler: async (ctx, a): Promise<{ stopped: boolean }> => {
324
+ const { sys, actor } = await guard(ctx, a)
325
+ const thread = await ownThread(ctx, sys, actor, a.threadId)
326
+ const run = await db(ctx)
327
+ .query("aiRuns")
328
+ .withIndex("by_thread", (q) => q.eq("threadId", thread._id))
329
+ .order("desc")
330
+ .first()
331
+ if (!run || !ACTIVE_RUN.has(run.status)) return { stopped: false }
332
+ await db(ctx).patch("aiRuns", run._id, { stopRequested: true, updatedAt: Date.now() })
333
+ // Nothing is executing a queued or approval-waiting run: close it now.
334
+ // A running one is closed by the runner when it sees `stopRequested`.
335
+ if (run.status !== "running") await finish(ctx, run, "stopped")
336
+ await recordAudit(sys, actor, { action: AI_AUDIT.runStop, targetType: "aiRun", targetId: run._id, changes: { threadId: thread._id, status: run.status } })
337
+ return { stopped: true }
338
+ },
339
+ }),
340
+
341
+ respondToToolCall: mutationGeneric({
342
+ args: { ...actorArgs, messageId: v.id("aiMessages"), toolCallId: v.string(), approve: v.boolean() },
343
+ handler: async (ctx, a): Promise<{ runId: AiRunId }> => {
344
+ const { sys, actor } = await guard(ctx, a)
345
+ const message = await db(ctx).get("aiMessages", a.messageId)
346
+ if (!message) fail("NOT_FOUND", "message_not_found", sys.operationId)
347
+ await ownThread(ctx, sys, actor, message!.threadId)
348
+ const run = message!.runId ? await db(ctx).get("aiRuns", message!.runId) : null
349
+ if (!run) fail("NOT_FOUND", "run_not_found", sys.operationId)
350
+ const part = message!.parts.find((p) => p.type === "tool-call" && p.toolCallId === a.toolCallId)
351
+ if (part?.type !== "tool-call") fail("NOT_FOUND", "tool_call_not_found", sys.operationId)
352
+ if (part!.state !== "awaiting-approval" || run!.stopRequested) fail("CONFLICT", "tool_call_not_awaiting_approval", sys.operationId)
353
+
354
+ const state: ToolCallState = a.approve ? "approved" : "rejected"
355
+ const parts = message!.parts.map((p) => (p.type === "tool-call" && p.toolCallId === a.toolCallId ? { ...p, state } : p))
356
+ await db(ctx).patch("aiMessages", message!._id, { parts })
357
+ await recordAudit(sys, actor, {
358
+ action: a.approve ? AI_AUDIT.toolApprove : AI_AUDIT.toolReject,
359
+ targetType: "aiToolCall",
360
+ targetId: a.toolCallId,
361
+ changes: { runId: run!._id, toolName: (part as { toolName: string }).toolName },
362
+ })
363
+ const pending = parts.some((p) => p.type === "tool-call" && p.state === "awaiting-approval")
364
+ // Resume only once the runner has parked the run; if it is still
365
+ // finishing, `_finishRun("waiting-approval")` resumes it instead.
366
+ if (!pending && run!.status === "waiting-approval") await resume(ctx, run!)
367
+ return { runId: run!._id }
368
+ },
369
+ }),
370
+
371
+ renameThread: mutationGeneric({
372
+ args: { ...actorArgs, threadId: v.id("aiThreads"), title: v.string() },
373
+ handler: async (ctx, a): Promise<null> => {
374
+ const { sys, actor } = await guard(ctx, a)
375
+ const thread = await ownThread(ctx, sys, actor, a.threadId)
376
+ const title = a.title.trim()
377
+ if (!title || title.length > AI_TITLE_MAX) fail("VALIDATION", "invalid_title", sys.operationId)
378
+ await db(ctx).patch("aiThreads", thread._id, { title, updatedAt: Date.now() })
379
+ return null
380
+ },
381
+ }),
382
+
383
+ archiveThread: mutationGeneric({
384
+ args: { ...actorArgs, threadId: v.id("aiThreads") },
385
+ handler: async (ctx, a): Promise<null> => {
386
+ const { sys, actor } = await guard(ctx, a)
387
+ const thread = await ownThread(ctx, sys, actor, a.threadId)
388
+ await db(ctx).patch("aiThreads", thread._id, { archived: true, updatedAt: Date.now() })
389
+ return null
390
+ },
391
+ }),
392
+
393
+ // --- Internal (runner only) -------------------------------------------------------------
394
+
395
+ _startRun: internalMutationGeneric({
396
+ args: { runId: v.id("aiRuns") },
397
+ handler: async (ctx, a): Promise<RunContext | null> => {
398
+ const run = await db(ctx).get("aiRuns", a.runId)
399
+ if (!run) return null
400
+ if (run.stopRequested) {
401
+ if (ACTIVE_RUN.has(run.status)) await finish(ctx, run, "stopped")
402
+ return null
403
+ }
404
+ // Only a queued run starts: a duplicate schedule is a no-op.
405
+ if (run.status !== "queued") return null
406
+ const sys = await bindRun(ctx, run)
407
+ const agent = agents.get(run.agentId)
408
+ if (!agent) {
409
+ await finish(ctx, run, "error", { code: "VALIDATION", message: "unknown_agent" })
410
+ return null
411
+ }
412
+ // The actor may have lost the chat itself since the message was sent.
413
+ if (!(await authorizeJob(sys, run.actor, AiPermissions.use))) {
414
+ await finish(ctx, run, "error", { code: "FORBIDDEN", message: "chat_not_allowed" })
415
+ return null
416
+ }
417
+ const allowedToolIds: string[] = []
418
+ for (const tool of agent.tools) if (await authorizeJob(sys, run.actor, tool.permission)) allowedToolIds.push(tool.id)
419
+ const now = Date.now()
420
+ await db(ctx).patch("aiRuns", run._id, { status: "running", updatedAt: now })
421
+ await db(ctx).patch("aiThreads", run.threadId, { status: "running", updatedAt: now })
422
+ const history = await db(ctx)
423
+ .query("aiMessages")
424
+ .withIndex("by_thread", (q) => q.eq("threadId", run.threadId))
425
+ .order("asc")
426
+ .collect()
427
+ return {
428
+ runId: run._id,
429
+ threadId: run.threadId,
430
+ messageId: run.messageId,
431
+ agentId: run.agentId,
432
+ model: run.model,
433
+ actor: run.actor,
434
+ operationId: run.operationId,
435
+ history: history.map(messageView),
436
+ allowedToolIds,
437
+ }
438
+ },
439
+ }),
440
+
441
+ _appendText: internalMutationGeneric({
442
+ args: { messageId: v.id("aiMessages"), text: v.string() },
443
+ handler: async (ctx, a): Promise<null> => {
444
+ if (!a.text) return null
445
+ await updateMessageParts(ctx, a.messageId, (parts) => {
446
+ const last = parts.at(-1)
447
+ if (last?.type === "text") return [...parts.slice(0, -1), { type: "text", text: last.text + a.text }]
448
+ return [...parts, { type: "text", text: a.text }]
449
+ })
450
+ return null
451
+ },
452
+ }),
453
+
454
+ _addToolCall: internalMutationGeneric({
455
+ args: { messageId: v.id("aiMessages"), toolCallId: v.string(), toolName: v.string(), args: v.any(), state: toolCallState },
456
+ handler: async (ctx, a): Promise<null> => {
457
+ await updateMessageParts(ctx, a.messageId, (parts) => {
458
+ // Idempotent: a retried runner step must not duplicate the call.
459
+ if (parts.some((p) => p.type === "tool-call" && p.toolCallId === a.toolCallId)) return null
460
+ return [...parts, { type: "tool-call", toolCallId: a.toolCallId, toolName: a.toolName, args: a.args, state: a.state }]
461
+ })
462
+ return null
463
+ },
464
+ }),
465
+
466
+ _setToolCallState: internalMutationGeneric({
467
+ args: { messageId: v.id("aiMessages"), toolCallId: v.string(), state: toolCallState, result: v.optional(v.any()), error: v.optional(v.string()) },
468
+ handler: async (ctx, a): Promise<null> => {
469
+ await updateMessageParts(ctx, a.messageId, (parts) => {
470
+ if (!parts.some((p) => p.type === "tool-call" && p.toolCallId === a.toolCallId)) {
471
+ throw toConvexError(new SystemError("NOT_FOUND", undefined, { reason: "tool_call_not_found" }))
472
+ }
473
+ return parts.map((p) => {
474
+ if (p.type !== "tool-call" || p.toolCallId !== a.toolCallId) return p
475
+ const next = { ...p, state: a.state }
476
+ if (a.result !== undefined) next.result = a.result
477
+ if (a.error !== undefined) next.error = a.error
478
+ return next
479
+ })
480
+ })
481
+ return null
482
+ },
483
+ }),
484
+
485
+ _executeTool: internalMutationGeneric({
486
+ args: { runId: v.id("aiRuns"), toolCallId: v.string() },
487
+ handler: async (ctx, a): Promise<ToolExecutionResult> => {
488
+ const run = await db(ctx).get("aiRuns", a.runId)
489
+ if (!run) return { ok: false, code: "NOT_FOUND", message: "run_not_found" }
490
+ const message = await db(ctx).get("aiMessages", run.messageId)
491
+ const part = message?.parts.find((p) => p.type === "tool-call" && p.toolCallId === a.toolCallId)
492
+ if (part?.type !== "tool-call") return { ok: false, code: "NOT_FOUND", message: "tool_call_not_found" }
493
+ const tool = agents.get(run.agentId)?.tools.find((t) => t.id === part.toolName)
494
+ if (!tool) return { ok: false, code: "VALIDATION", message: "unknown_tool" }
495
+ // Defence in depth: an approval-gated tool never runs before the user approved it.
496
+ if (tool.requiresApproval && part.state !== "approved" && part.state !== "running") return { ok: false, code: "FORBIDDEN", message: "approval_required" }
497
+ const sys = await bindRun(ctx, run)
498
+ return executeAiTool({ db: ctx.db, sys, actorRef: run.actor, tool, args: part.args, toolCallId: a.toolCallId })
499
+ },
500
+ }),
501
+
502
+ _finishRun: internalMutationGeneric({
503
+ args: { runId: v.id("aiRuns"), status: finishStatus, error: v.optional(errorInfo) },
504
+ handler: async (ctx, a): Promise<null> => {
505
+ const run = await db(ctx).get("aiRuns", a.runId)
506
+ if (!run) return null
507
+ // Stop wins over whatever the runner reports, except an error.
508
+ const status = run.stopRequested && a.status !== "error" ? "stopped" : a.status
509
+ if (status === "waiting-approval") {
510
+ const message = await db(ctx).get("aiMessages", run.messageId)
511
+ const pending = message?.parts.some((p) => p.type === "tool-call" && p.state === "awaiting-approval") ?? false
512
+ // The user already answered every call while the runner was finishing: go on.
513
+ if (!pending) {
514
+ await resume(ctx, run)
515
+ return null
516
+ }
517
+ }
518
+ await finish(ctx, run, status, a.error)
519
+ return null
520
+ },
521
+ }),
522
+
523
+ _isStopRequested: internalQueryGeneric({
524
+ args: { runId: v.id("aiRuns") },
525
+ handler: async (ctx, a): Promise<boolean> => {
526
+ const run = await db(ctx).get("aiRuns", a.runId)
527
+ return !run || run.stopRequested
528
+ },
529
+ }),
530
+ }
531
+
532
+ return { functions }
533
+ }
534
+
535
+ export type AiSystem = ReturnType<typeof defineAi>
package/src/ai/card.ts ADDED
@@ -0,0 +1,91 @@
1
+ // `@digimuza/system-core/ai/card`: the browser side of `ai.show_card`. No
2
+ // dependencies, safe for client bundles. See display.ts for the security model:
3
+ // render ONLY in <iframe sandbox="allow-scripts" srcDoc={cardDocument(...)}>.
4
+
5
+ export const AI_CARD_HTML_MAX = 60_000
6
+ export const AI_CARD_TITLE_MAX = 120
7
+ /** `postMessage` type the card's resize script sends to the parent. */
8
+ export const AI_CARD_RESIZE_MESSAGE = "ai-card:height"
9
+
10
+ /** Theme passed into the card: CSS custom properties (name without `--`) → value. */
11
+ export type CardTheme = Record<string, string>
12
+
13
+ /** Theme variables a card may use (the app copies their computed values). */
14
+ export const AI_CARD_THEME_VARS = [
15
+ "background",
16
+ "foreground",
17
+ "card",
18
+ "card-foreground",
19
+ "muted",
20
+ "muted-foreground",
21
+ "border",
22
+ "primary",
23
+ "primary-foreground",
24
+ "secondary",
25
+ "secondary-foreground",
26
+ "accent",
27
+ "accent-foreground",
28
+ "destructive",
29
+ "radius",
30
+ "chart-1",
31
+ "chart-2",
32
+ "chart-3",
33
+ "chart-4",
34
+ "chart-5",
35
+ ] as const
36
+
37
+ /**
38
+ * The CSP of every card: inline styles and scripts only, images from data: only,
39
+ * no network (connect/fetch/fonts/frames/forms), no base-uri tricks.
40
+ */
41
+ export const AI_CARD_CSP = [
42
+ "default-src 'none'",
43
+ "style-src 'unsafe-inline'",
44
+ "script-src 'unsafe-inline'",
45
+ "img-src data:",
46
+ "font-src 'none'",
47
+ "connect-src 'none'",
48
+ "media-src 'none'",
49
+ "frame-src 'none'",
50
+ "form-action 'none'",
51
+ "base-uri 'none'",
52
+ ].join("; ")
53
+
54
+ const safeCssValue = (v: string) => v.replace(/[<>{};\\]/g, "")
55
+ const safeVarName = (k: string) => /^[a-z][a-z0-9-]*$/.test(k)
56
+
57
+ /**
58
+ * The full `srcdoc` for a card iframe: CSP meta first, theme variables, base
59
+ * styles, the model's HTML, and a resize script that posts the content height
60
+ * to the parent (`{ type: AI_CARD_RESIZE_MESSAGE, id, height }`).
61
+ * Render it ONLY in `<iframe sandbox="allow-scripts" srcDoc={...}>`.
62
+ */
63
+ export function cardDocument(options: { html: string; id: string; theme?: CardTheme; dark?: boolean; fontFamily?: string }): string {
64
+ const vars = Object.entries(options.theme ?? {})
65
+ .filter(([k]) => safeVarName(k))
66
+ .map(([k, v]) => `--${k}:${safeCssValue(v)};`)
67
+ .join("")
68
+ const font = safeCssValue(options.fontFamily ?? 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif')
69
+ const id = JSON.stringify(String(options.id).replace(/[^A-Za-z0-9_-]/g, "").slice(0, 100))
70
+ return `<!doctype html><html${options.dark ? ' class="dark"' : ""}><head><meta charset="utf-8">
71
+ <meta http-equiv="Content-Security-Policy" content="${AI_CARD_CSP}">
72
+ <meta name="viewport" content="width=device-width, initial-scale=1">
73
+ <style>
74
+ :root{${vars}color-scheme:${options.dark ? "dark" : "light"};}
75
+ *,*::before,*::after{box-sizing:border-box;}
76
+ html,body{margin:0;padding:0;background:transparent;color:var(--foreground);font-family:${font};font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;}
77
+ body{padding:16px;overflow-wrap:anywhere;}
78
+ h1,h2,h3,h4{margin:0 0 .5em;line-height:1.25;font-weight:600;text-wrap:balance;}
79
+ h1{font-size:1.25rem;}h2{font-size:1.125rem;}h3{font-size:1rem;}
80
+ p{margin:0 0 .75em;}p:last-child{margin-bottom:0;}
81
+ table{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums;}
82
+ th,td{padding:6px 8px;border-bottom:1px solid var(--border);text-align:left;}
83
+ th{color:var(--muted-foreground);font-weight:500;font-size:.85em;}
84
+ a{color:var(--primary);pointer-events:none;}
85
+ svg{max-width:100%;height:auto;}
86
+ img{max-width:100%;}
87
+ </style></head><body>${options.html}
88
+ <script>(function(){var id=${id};var last=0;function send(){var h=Math.ceil(document.documentElement.getBoundingClientRect().height);if(h!==last){last=h;parent.postMessage({type:${JSON.stringify(AI_CARD_RESIZE_MESSAGE)},id:id,height:h},"*");}}
89
+ new ResizeObserver(send).observe(document.documentElement);window.addEventListener("load",send);send();})();</script>
90
+ </body></html>`
91
+ }