@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.
- package/INTEGRATION.md +209 -3
- package/README.md +23 -0
- package/dist/ai/api.d.ts +120 -0
- package/dist/ai/api.d.ts.map +1 -0
- package/dist/ai/api.js +446 -0
- package/dist/ai/api.js.map +1 -0
- package/dist/ai/card.d.ts +27 -0
- package/dist/ai/card.d.ts.map +1 -0
- package/dist/ai/card.js +84 -0
- package/dist/ai/card.js.map +1 -0
- package/dist/ai/contract.d.ts +364 -0
- package/dist/ai/contract.d.ts.map +1 -0
- package/dist/ai/contract.js +101 -0
- package/dist/ai/contract.js.map +1 -0
- package/dist/ai/display.d.ts +20 -0
- package/dist/ai/display.d.ts.map +1 -0
- package/dist/ai/display.js +35 -0
- package/dist/ai/display.js.map +1 -0
- package/dist/ai/index.d.ts +7 -0
- package/dist/ai/index.d.ts.map +1 -0
- package/dist/ai/index.js +10 -0
- package/dist/ai/index.js.map +1 -0
- package/dist/ai/manifest.d.ts +25 -0
- package/dist/ai/manifest.d.ts.map +1 -0
- package/dist/ai/manifest.js +15 -0
- package/dist/ai/manifest.js.map +1 -0
- package/dist/ai/runner.d.ts +71 -0
- package/dist/ai/runner.d.ts.map +1 -0
- package/dist/ai/runner.js +246 -0
- package/dist/ai/runner.js.map +1 -0
- package/dist/ai/tools.d.ts +42 -0
- package/dist/ai/tools.d.ts.map +1 -0
- package/dist/ai/tools.js +117 -0
- package/dist/ai/tools.js.map +1 -0
- package/dist/audit/csv.d.ts +31 -0
- package/dist/audit/csv.d.ts.map +1 -0
- package/dist/audit/csv.js +39 -0
- package/dist/audit/csv.js.map +1 -0
- package/dist/audit/index.d.ts +5 -0
- package/dist/audit/index.d.ts.map +1 -0
- package/dist/audit/index.js +6 -0
- package/dist/audit/index.js.map +1 -0
- package/dist/convex/functions.d.ts +39 -0
- package/dist/convex/functions.d.ts.map +1 -1
- package/dist/convex/functions.js +38 -6
- package/dist/convex/functions.js.map +1 -1
- package/dist/convex/index.d.ts +1 -1
- package/dist/convex/index.d.ts.map +1 -1
- package/dist/convex/index.js.map +1 -1
- package/dist/core/access.d.ts +14 -0
- package/dist/core/access.d.ts.map +1 -1
- package/dist/core/access.js +62 -0
- package/dist/core/access.js.map +1 -1
- package/dist/core/audit-module.d.ts +5 -0
- package/dist/core/audit-module.d.ts.map +1 -0
- package/dist/core/audit-module.js +25 -0
- package/dist/core/audit-module.js.map +1 -0
- package/dist/core/audit.d.ts +31 -0
- package/dist/core/audit.d.ts.map +1 -1
- package/dist/core/audit.js +46 -2
- package/dist/core/audit.js.map +1 -1
- package/dist/core/catalog.d.ts +41 -0
- package/dist/core/catalog.d.ts.map +1 -1
- package/dist/core/catalog.js +45 -0
- package/dist/core/catalog.js.map +1 -1
- package/dist/core/context.d.ts +8 -2
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +4 -1
- package/dist/core/context.js.map +1 -1
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/notify.d.ts +37 -0
- package/dist/core/notify.d.ts.map +1 -0
- package/dist/core/notify.js +16 -0
- package/dist/core/notify.js.map +1 -0
- package/dist/core/roles.d.ts.map +1 -1
- package/dist/core/roles.js +5 -1
- package/dist/core/roles.js.map +1 -1
- package/dist/core/system-modules.d.ts +4 -0
- package/dist/core/system-modules.d.ts.map +1 -1
- package/dist/core/system-modules.js +11 -0
- package/dist/core/system-modules.js.map +1 -1
- package/dist/finance/ai-tools.d.ts +55 -0
- package/dist/finance/ai-tools.d.ts.map +1 -0
- package/dist/finance/ai-tools.js +65 -0
- package/dist/finance/ai-tools.js.map +1 -0
- package/dist/finance/index.d.ts +87 -1
- package/dist/finance/index.d.ts.map +1 -1
- package/dist/finance/index.js +92 -38
- package/dist/finance/index.js.map +1 -1
- package/dist/finance/search.d.ts +15 -0
- package/dist/finance/search.d.ts.map +1 -0
- package/dist/finance/search.js +51 -0
- package/dist/finance/search.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/index.d.ts +1 -1
- package/dist/modules/index.d.ts.map +1 -1
- package/dist/modules/index.js +1 -1
- package/dist/modules/index.js.map +1 -1
- package/dist/notifications/api.d.ts +130 -0
- package/dist/notifications/api.d.ts.map +1 -0
- package/dist/notifications/api.js +384 -0
- package/dist/notifications/api.js.map +1 -0
- package/dist/notifications/contract.d.ts +132 -0
- package/dist/notifications/contract.d.ts.map +1 -0
- package/dist/notifications/contract.js +59 -0
- package/dist/notifications/contract.js.map +1 -0
- package/dist/notifications/index.d.ts +6 -0
- package/dist/notifications/index.d.ts.map +1 -0
- package/dist/notifications/index.js +9 -0
- package/dist/notifications/index.js.map +1 -0
- package/dist/search/index.d.ts +95 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +151 -0
- package/dist/search/index.js.map +1 -0
- package/package.json +41 -2
- package/src/ai/api.ts +535 -0
- package/src/ai/card.ts +91 -0
- package/src/ai/contract.ts +245 -0
- package/src/ai/display.ts +38 -0
- package/src/ai/index.ts +9 -0
- package/src/ai/manifest.ts +16 -0
- package/src/ai/runner.ts +275 -0
- package/src/ai/tools.ts +128 -0
- package/src/audit/csv.ts +60 -0
- package/src/audit/index.ts +7 -0
- package/src/convex/functions.ts +54 -7
- package/src/convex/index.ts +1 -1
- package/src/core/access.ts +55 -1
- package/src/core/audit-module.ts +26 -0
- package/src/core/audit.ts +68 -2
- package/src/core/catalog.ts +63 -0
- package/src/core/context.ts +13 -2
- package/src/core/index.ts +2 -0
- package/src/core/notify.ts +52 -0
- package/src/core/roles.ts +5 -1
- package/src/core/system-modules.ts +12 -0
- package/src/finance/ai-tools.ts +68 -0
- package/src/finance/index.ts +119 -42
- package/src/finance/search.ts +58 -0
- package/src/index.ts +2 -0
- package/src/modules/index.ts +3 -0
- package/src/notifications/api.ts +477 -0
- package/src/notifications/contract.ts +142 -0
- package/src/notifications/index.ts +8 -0
- package/src/search/index.ts +198 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Shared contract of the AI module (DIG-71). Every part of `@digimuza/system-core/ai`
|
|
2
|
+
// builds on these shapes: tables, views, function names, the tool and agent API.
|
|
3
|
+
// No Mastra or AI SDK imports here: this file is safe in Convex's default runtime
|
|
4
|
+
// and in the browser (types only).
|
|
5
|
+
import { defineTable } from "convex/server"
|
|
6
|
+
import { v, type GenericId, type Infer } from "convex/values"
|
|
7
|
+
import type { z } from "zod"
|
|
8
|
+
import type { Actor, ActorRef } from "../core/access.js"
|
|
9
|
+
import type { SystemContext } from "../core/context.js"
|
|
10
|
+
|
|
11
|
+
// --- Validators & tables -------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export const actorRefValidator = v.union(v.object({ kind: v.literal("user"), userId: v.string() }), v.object({ kind: v.literal("agent"), apiKeyId: v.string() }))
|
|
14
|
+
|
|
15
|
+
export const toolCallState = v.union(
|
|
16
|
+
v.literal("awaiting-approval"), // requiresApproval tool, waiting for the user
|
|
17
|
+
v.literal("approved"), // user approved; the runner executes it next
|
|
18
|
+
v.literal("rejected"), // user rejected; the model is told so
|
|
19
|
+
v.literal("running"),
|
|
20
|
+
v.literal("done"),
|
|
21
|
+
v.literal("error"),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
export const messagePart = v.union(
|
|
25
|
+
v.object({ type: v.literal("text"), text: v.string() }),
|
|
26
|
+
v.object({
|
|
27
|
+
type: v.literal("tool-call"),
|
|
28
|
+
toolCallId: v.string(),
|
|
29
|
+
toolName: v.string(),
|
|
30
|
+
args: v.any(),
|
|
31
|
+
state: toolCallState,
|
|
32
|
+
result: v.optional(v.any()),
|
|
33
|
+
/** Stable system-core error code (FORBIDDEN, VALIDATION, …) or a short message. */
|
|
34
|
+
error: v.optional(v.string()),
|
|
35
|
+
}),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
export const threadStatus = v.union(v.literal("idle"), v.literal("running"), v.literal("waiting-approval"))
|
|
39
|
+
export const messageStatus = v.union(v.literal("streaming"), v.literal("done"), v.literal("error"), v.literal("stopped"))
|
|
40
|
+
export const runStatus = v.union(v.literal("queued"), v.literal("running"), v.literal("waiting-approval"), v.literal("done"), v.literal("error"), v.literal("stopped"))
|
|
41
|
+
|
|
42
|
+
/** Spread into the app schema next to `systemTables`. */
|
|
43
|
+
export const aiTables = {
|
|
44
|
+
aiThreads: defineTable({
|
|
45
|
+
/** system-core user key of the owner. Only the owner can see a thread. */
|
|
46
|
+
ownerId: v.string(),
|
|
47
|
+
title: v.string(),
|
|
48
|
+
agentId: v.string(),
|
|
49
|
+
status: threadStatus,
|
|
50
|
+
archived: v.boolean(),
|
|
51
|
+
createdAt: v.number(),
|
|
52
|
+
updatedAt: v.number(),
|
|
53
|
+
}).index("by_owner", ["ownerId", "archived", "updatedAt"]),
|
|
54
|
+
|
|
55
|
+
aiMessages: defineTable({
|
|
56
|
+
threadId: v.id("aiThreads"),
|
|
57
|
+
role: v.union(v.literal("user"), v.literal("assistant")),
|
|
58
|
+
parts: v.array(messagePart),
|
|
59
|
+
status: messageStatus,
|
|
60
|
+
/** `operationId` is the run's, so support can find the logs of a failed answer. */
|
|
61
|
+
error: v.optional(v.object({ code: v.string(), message: v.string(), operationId: v.optional(v.string()) })),
|
|
62
|
+
runId: v.optional(v.id("aiRuns")),
|
|
63
|
+
createdAt: v.number(),
|
|
64
|
+
}).index("by_thread", ["threadId", "createdAt"]),
|
|
65
|
+
|
|
66
|
+
aiRuns: defineTable({
|
|
67
|
+
threadId: v.id("aiThreads"),
|
|
68
|
+
/** The assistant message this run writes into. */
|
|
69
|
+
messageId: v.id("aiMessages"),
|
|
70
|
+
/** Who the run acts for; re-authorized on every tool call (system-core authorizeJob). */
|
|
71
|
+
actor: actorRefValidator,
|
|
72
|
+
agentId: v.string(),
|
|
73
|
+
model: v.string(),
|
|
74
|
+
status: runStatus,
|
|
75
|
+
stopRequested: v.boolean(),
|
|
76
|
+
operationId: v.string(),
|
|
77
|
+
error: v.optional(v.object({ code: v.string(), message: v.string() })),
|
|
78
|
+
createdAt: v.number(),
|
|
79
|
+
updatedAt: v.number(),
|
|
80
|
+
})
|
|
81
|
+
.index("by_thread", ["threadId", "createdAt"])
|
|
82
|
+
.index("by_message", ["messageId"]),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type MessagePart = Infer<typeof messagePart>
|
|
86
|
+
export type ToolCallState = Infer<typeof toolCallState>
|
|
87
|
+
export type ThreadStatus = Infer<typeof threadStatus>
|
|
88
|
+
export type MessageStatus = Infer<typeof messageStatus>
|
|
89
|
+
export type RunStatus = Infer<typeof runStatus>
|
|
90
|
+
export type AiThreadId = GenericId<"aiThreads">
|
|
91
|
+
export type AiMessageId = GenericId<"aiMessages">
|
|
92
|
+
export type AiRunId = GenericId<"aiRuns">
|
|
93
|
+
|
|
94
|
+
// --- Views returned by public functions ----------------------------------------------
|
|
95
|
+
|
|
96
|
+
export interface ThreadView {
|
|
97
|
+
id: AiThreadId
|
|
98
|
+
title: string
|
|
99
|
+
agentId: string
|
|
100
|
+
status: ThreadStatus
|
|
101
|
+
createdAt: number
|
|
102
|
+
updatedAt: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface MessageView {
|
|
106
|
+
id: AiMessageId
|
|
107
|
+
role: "user" | "assistant"
|
|
108
|
+
parts: MessagePart[]
|
|
109
|
+
status: MessageStatus
|
|
110
|
+
/** Set when the answer failed; `operationId` is the run's (show it so support can trace it). */
|
|
111
|
+
error: { code: string; message: string; operationId?: string } | null
|
|
112
|
+
createdAt: number
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface AgentToolView {
|
|
116
|
+
id: string
|
|
117
|
+
description: string
|
|
118
|
+
requiresApproval: boolean
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface AgentView {
|
|
122
|
+
id: string
|
|
123
|
+
name: string
|
|
124
|
+
description: string
|
|
125
|
+
/** Only the tools the caller holds the permission for right now. */
|
|
126
|
+
tools: AgentToolView[]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- Tools ----------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/** What a tool handler gets. It runs inside an internal mutation, as the run's actor. */
|
|
132
|
+
export interface AiToolContext {
|
|
133
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
134
|
+
db: any
|
|
135
|
+
sys: SystemContext
|
|
136
|
+
/** Re-authorized for `permission` right before the handler runs. */
|
|
137
|
+
actor: Actor
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface AiTool<I extends z.ZodType = z.ZodType, O = unknown> {
|
|
141
|
+
/** Stable, `module.verb_object` style, e.g. `finance.list_invoices`. Shown to the model. */
|
|
142
|
+
id: string
|
|
143
|
+
/** For the model: what it does and when to use it. */
|
|
144
|
+
description: string
|
|
145
|
+
/** Zod schema of the arguments (also sent to the model as JSON schema). */
|
|
146
|
+
input: I
|
|
147
|
+
/** system-core permission required to run it, e.g. `finance:invoice:read`. */
|
|
148
|
+
permission: string
|
|
149
|
+
/** Writes or anything risky: the user approves each call first. */
|
|
150
|
+
requiresApproval?: boolean
|
|
151
|
+
/**
|
|
152
|
+
* Must be JSON-serializable. Throw SystemError for expected failures.
|
|
153
|
+
*
|
|
154
|
+
* Validate everything before the first write: a failed handler does not roll
|
|
155
|
+
* back. `executeAiTool` returns the failure instead of throwing (so the
|
|
156
|
+
* `ai.tool.run` audit survives), therefore writes made before the throw are
|
|
157
|
+
* committed with the surrounding mutation.
|
|
158
|
+
*/
|
|
159
|
+
handler(ctx: AiToolContext, input: z.infer<I>): Promise<O>
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- Agents ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
export interface AiAgentDefinition {
|
|
165
|
+
/** Stable id, e.g. `assistant`. */
|
|
166
|
+
id: string
|
|
167
|
+
name: string
|
|
168
|
+
description: string
|
|
169
|
+
/** System prompt. */
|
|
170
|
+
instructions: string
|
|
171
|
+
/** Model id for this agent; falls back to `defaultModel`. OpenAI only in v1. */
|
|
172
|
+
model?: string
|
|
173
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
174
|
+
tools: readonly AiTool<any, any>[]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface AiOptions {
|
|
178
|
+
agents: readonly AiAgentDefinition[]
|
|
179
|
+
/** Default OpenAI model id when an agent has none (env AI_MODEL wins if set). */
|
|
180
|
+
defaultModel: string
|
|
181
|
+
/**
|
|
182
|
+
* Convex module paths (relative to convex/) where the app exports the AI
|
|
183
|
+
* functions and the Node runner. Defaults: "ai" and "aiRunner".
|
|
184
|
+
*/
|
|
185
|
+
functionsModule?: string
|
|
186
|
+
runnerModule?: string
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --- Function names (the app re-exports these) ---------------------------------------
|
|
190
|
+
|
|
191
|
+
/** Public functions in `functionsModule` (default convex/ai.ts). */
|
|
192
|
+
export const AI_PUBLIC_FUNCTIONS = [
|
|
193
|
+
"listAgents", // () → AgentView[]
|
|
194
|
+
"listThreads", // ({ cursor?, limit? }) → { items: ThreadView[], cursor, done }
|
|
195
|
+
"getThread", // ({ threadId }) → ThreadView (NOT_FOUND unless owner)
|
|
196
|
+
"listMessages", // ({ threadId }) → MessageView[] (live, ascending)
|
|
197
|
+
"createThread", // ({ agentId?, title? }) → { threadId }
|
|
198
|
+
"sendMessage", // ({ threadId, text }) → { messageId, runId }; schedules the runner
|
|
199
|
+
"stopRun", // ({ threadId }) → { stopped }
|
|
200
|
+
"respondToToolCall", // ({ messageId, toolCallId, approve }) → { runId }; resumes the run
|
|
201
|
+
"renameThread", // ({ threadId, title })
|
|
202
|
+
"archiveThread", // ({ threadId })
|
|
203
|
+
] as const
|
|
204
|
+
|
|
205
|
+
/** Internal functions in `functionsModule`, called by the runner. */
|
|
206
|
+
export const AI_INTERNAL_FUNCTIONS = [
|
|
207
|
+
"_startRun", // ({ runId }) → RunContext | null (null: stopped or gone)
|
|
208
|
+
"_appendText", // ({ messageId, text }) append to the last text part (or start one)
|
|
209
|
+
"_addToolCall", // ({ messageId, toolCallId, toolName, args, state })
|
|
210
|
+
"_executeTool", // ({ runId, toolCallId }) → ToolExecutionResult; authorizes and runs the handler
|
|
211
|
+
"_setToolCallState", // ({ messageId, toolCallId, state, result?, error? })
|
|
212
|
+
"_finishRun", // ({ runId, status, error? }) sets run, message and thread status
|
|
213
|
+
"_isStopRequested", // ({ runId }) → boolean
|
|
214
|
+
] as const
|
|
215
|
+
|
|
216
|
+
/** Node runner in `runnerModule` (default convex/aiRunner.ts, "use node"). */
|
|
217
|
+
export const AI_RUNNER_FUNCTION = "runAgent" // internalAction ({ runId }) → void
|
|
218
|
+
|
|
219
|
+
/** What `_startRun` gives the runner. */
|
|
220
|
+
export interface RunContext {
|
|
221
|
+
runId: AiRunId
|
|
222
|
+
threadId: AiThreadId
|
|
223
|
+
messageId: AiMessageId
|
|
224
|
+
agentId: string
|
|
225
|
+
model: string
|
|
226
|
+
actor: ActorRef
|
|
227
|
+
operationId: string
|
|
228
|
+
/** Full history in order, including this run's assistant message so far. */
|
|
229
|
+
history: MessageView[]
|
|
230
|
+
/** Tool ids of the agent the actor may use right now (permission held). */
|
|
231
|
+
allowedToolIds: string[]
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export type ToolExecutionResult = { ok: true; result: unknown } | { ok: false; code: string; message: string }
|
|
235
|
+
|
|
236
|
+
// --- Audit actions ------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
export const AI_AUDIT = {
|
|
239
|
+
threadCreate: "ai.thread.create",
|
|
240
|
+
messageSend: "ai.message.send",
|
|
241
|
+
toolRun: "ai.tool.run",
|
|
242
|
+
toolApprove: "ai.tool.approve",
|
|
243
|
+
toolReject: "ai.tool.reject",
|
|
244
|
+
runStop: "ai.run.stop",
|
|
245
|
+
} as const
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Display tools of the AI module: the assistant shows the user a visual card
|
|
2
|
+
// (summary, comparison, table, simple chart) made of HTML/CSS/inline SVG.
|
|
3
|
+
//
|
|
4
|
+
// The HTML is model output and therefore UNTRUSTED (prompt injection can reach
|
|
5
|
+
// it through tool results). Apps must render it only with `cardDocument()` in an
|
|
6
|
+
// <iframe sandbox="allow-scripts"> (never allow-same-origin): opaque origin, no
|
|
7
|
+
// network, no navigation, no forms, no popups. Nothing leaves the frame except
|
|
8
|
+
// the height message the frame's own resize script posts.
|
|
9
|
+
import { z } from "zod"
|
|
10
|
+
import { AiPermissions } from "./manifest.js"
|
|
11
|
+
import { AI_CARD_HTML_MAX, AI_CARD_TITLE_MAX } from "./card.js"
|
|
12
|
+
import { defineAiTool } from "./tools.js"
|
|
13
|
+
|
|
14
|
+
export * from "./card.js"
|
|
15
|
+
|
|
16
|
+
export const AI_SHOW_CARD = "ai.show_card"
|
|
17
|
+
|
|
18
|
+
/** `ai.show_card`: no data access, no writes, so no approval; needs only `ai:chat:use`. */
|
|
19
|
+
export const showCardTool = defineAiTool({
|
|
20
|
+
id: AI_SHOW_CARD,
|
|
21
|
+
description: [
|
|
22
|
+
"Show the user a visual card rendered from HTML, inside the chat.",
|
|
23
|
+
"Use it when a visual answer is clearly better than text: a summary with key figures, a comparison, a small table, a status overview, a simple bar chart (HTML/CSS or inline SVG).",
|
|
24
|
+
"Rules: a self-contained HTML fragment (no <html>/<head>), inline <style> allowed; no external resources (images, fonts, scripts, links to URLs are blocked); images only as inline SVG or data: URLs.",
|
|
25
|
+
"Style with the CSS variables provided: var(--background), var(--foreground), var(--muted), var(--muted-foreground), var(--card), var(--border), var(--primary), var(--primary-foreground), var(--destructive), var(--radius). They follow the app's light/dark theme; do not hardcode colours.",
|
|
26
|
+
"The chat already draws the card frame (border, background, padding, title): do NOT add an outer border, background, shadow or padding of your own; start with the content. Use var(--destructive) only for errors or overdue items, var(--primary) for the one figure that matters.",
|
|
27
|
+
"Only show data you got from tools or the user. Keep the card compact. Write a short sentence in the chat too.",
|
|
28
|
+
].join(" "),
|
|
29
|
+
input: z.object({
|
|
30
|
+
title: z.string().trim().min(1).max(AI_CARD_TITLE_MAX).optional(),
|
|
31
|
+
html: z.string().min(1).max(AI_CARD_HTML_MAX),
|
|
32
|
+
}),
|
|
33
|
+
permission: AiPermissions.use,
|
|
34
|
+
handler: async (_ctx, input) => ({ shown: true, bytes: input.html.length }),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/** Tools for showing things to the user. Add to an agent: `tools: [...financeTools, ...displayTools]`. */
|
|
38
|
+
export const displayTools = [showCardTool] as const
|
package/src/ai/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// `@digimuza/system-core/ai`: reusable AI chat with permissioned tools.
|
|
2
|
+
// Convex default runtime + browser types. The Mastra runner is in
|
|
3
|
+
// `@digimuza/system-core/ai/runner` (Node only, "use node").
|
|
4
|
+
export * from "./contract.js"
|
|
5
|
+
export * from "./manifest.js"
|
|
6
|
+
export { defineAiTool, defineAiAgent, executeAiTool } from "./tools.js"
|
|
7
|
+
export { defineAi, type AiSystem } from "./api.js"
|
|
8
|
+
export { AI_SHOW_CARD, showCardTool, displayTools } from "./display.js"
|
|
9
|
+
export * from "./card.js"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineModule, type PermissionOf } from "../modules/index.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The AI assistant module. Business module: registers disabled, a Super
|
|
5
|
+
* enables it. `ai:chat:use` lets a person chat; what the assistant may *do* is
|
|
6
|
+
* decided per tool by that tool's own permission (finance:invoice:read, …).
|
|
7
|
+
*/
|
|
8
|
+
export const ai = defineModule({
|
|
9
|
+
id: "ai",
|
|
10
|
+
kind: "business",
|
|
11
|
+
description: "AI asistentas su įrankiais",
|
|
12
|
+
permissions: [{ id: "ai:chat:use", name: "Naudoti asistentą", description: "Kalbėtis su AI asistentu ir matyti savo pokalbius" }],
|
|
13
|
+
initialSubroles: [{ id: "ai:user", name: "Asistento naudotojas", description: "Gali kalbėtis su AI asistentu", permissions: ["ai:chat:use"] }],
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export const AiPermissions = { use: "ai:chat:use" } as const satisfies Record<string, PermissionOf<typeof ai>>
|
package/src/ai/runner.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Node-only Mastra runner of the AI module (DIG-71). Entry `@digimuza/system-core/ai/runner`.
|
|
2
|
+
// All Mastra / AI SDK imports live here, never in contract/api/tools: the app file that
|
|
3
|
+
// re-exports `runAgent` must start with "use node".
|
|
4
|
+
import { createOpenAI } from "@ai-sdk/openai"
|
|
5
|
+
import { Agent } from "@mastra/core/agent"
|
|
6
|
+
import { noopLogger } from "@mastra/core/logger"
|
|
7
|
+
import { createTool } from "@mastra/core/tools"
|
|
8
|
+
import { internalActionGeneric, makeFunctionReference, type GenericActionCtx, type GenericDataModel } from "convex/server"
|
|
9
|
+
import { v } from "convex/values"
|
|
10
|
+
import { createLogger } from "../diagnostics/logging.js"
|
|
11
|
+
import type { AiOptions, AiRunId, AiTool, MessagePart, MessageView, RunContext, RunStatus, ToolCallState, ToolExecutionResult } from "./contract.js"
|
|
12
|
+
|
|
13
|
+
/** Max model steps (calls) per run turn; guards against tool loops. */
|
|
14
|
+
export const AI_MAX_STEPS = 8
|
|
15
|
+
/** Text deltas are written with `_appendText` at most this often … */
|
|
16
|
+
export const AI_FLUSH_MS = 150
|
|
17
|
+
/** … or as soon as this many characters are buffered. */
|
|
18
|
+
export const AI_FLUSH_CHARS = 200
|
|
19
|
+
|
|
20
|
+
// Any AI SDK language model Mastra accepts (V2/V3/V4 spec). Kept loose so the
|
|
21
|
+
// option does not tie apps to one `@ai-sdk/provider` version.
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
23
|
+
export type AiLanguageModel = any
|
|
24
|
+
|
|
25
|
+
export interface AiRunnerOptions extends AiOptions {
|
|
26
|
+
/** OpenAI API key; default `process.env.OPENAI_API_KEY`. Never logged or returned. */
|
|
27
|
+
openAiApiKey?: () => string | undefined
|
|
28
|
+
/**
|
|
29
|
+
* Base URL of an OpenAI-compatible API, e.g. a proxy like 9Router
|
|
30
|
+
* (`https://router.digimuza.ai/v1`). Default `process.env.OPENAI_BASE_URL`,
|
|
31
|
+
* else api.openai.com. Requests go to `<baseURL>/chat/completions`.
|
|
32
|
+
*/
|
|
33
|
+
baseURL?: string
|
|
34
|
+
/** Custom fetch for the provider (tests, request middleware). Default: global fetch. */
|
|
35
|
+
fetch?: typeof globalThis.fetch
|
|
36
|
+
/**
|
|
37
|
+
* Test hook: builds the language model for a model id instead of OpenAI (e.g.
|
|
38
|
+
* `MockLanguageModelV4` from `ai/test`). When set, no API key is needed.
|
|
39
|
+
*/
|
|
40
|
+
model?: (modelId: string) => AiLanguageModel
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type ToolPart = Extract<MessagePart, { type: "tool-call" }>
|
|
44
|
+
type ModelMessage =
|
|
45
|
+
| { role: "user"; content: string }
|
|
46
|
+
| { role: "assistant"; content: Array<{ type: "text"; text: string } | { type: "tool-call"; toolCallId: string; toolName: string; input: unknown }> }
|
|
47
|
+
| { role: "tool"; content: Array<{ type: "tool-result"; toolCallId: string; toolName: string; output: { type: "json"; value: unknown } }> }
|
|
48
|
+
|
|
49
|
+
class StopTurn extends Error {}
|
|
50
|
+
|
|
51
|
+
const UNAVAILABLE = "UNAVAILABLE"
|
|
52
|
+
const FAILED = { code: UNAVAILABLE, message: "The assistant failed. Try again." }
|
|
53
|
+
|
|
54
|
+
/** OpenAI function names allow only `[a-zA-Z0-9_-]`; tool ids are `module.verb_object`. */
|
|
55
|
+
export const modelToolName = (toolId: string) => toolId.replace(/[^a-zA-Z0-9_-]/g, "_")
|
|
56
|
+
|
|
57
|
+
const toolOutput = (p: ToolPart): unknown =>
|
|
58
|
+
p.state === "done" ? { ok: true, result: p.result ?? null } : p.state === "rejected" ? { ok: false, error: "REJECTED", message: "rejected by user" } : { ok: false, error: p.error ?? UNAVAILABLE }
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* History → model messages. User text → user messages; assistant parts in order as
|
|
62
|
+
* text + tool calls, each finished call (done/error/rejected) followed by its result.
|
|
63
|
+
* Calls without an outcome yet are left out (a call without a result is invalid).
|
|
64
|
+
*/
|
|
65
|
+
export function toModelMessages(history: readonly MessageView[]): ModelMessage[] {
|
|
66
|
+
const out: ModelMessage[] = []
|
|
67
|
+
for (const m of history) {
|
|
68
|
+
if (m.role === "user") {
|
|
69
|
+
const text = m.parts.flatMap((p) => (p.type === "text" ? [p.text] : [])).join("\n")
|
|
70
|
+
if (text.trim()) out.push({ role: "user", content: text })
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
let assistant: Extract<ModelMessage, { role: "assistant" }>["content"] = []
|
|
74
|
+
let results: Extract<ModelMessage, { role: "tool" }>["content"] = []
|
|
75
|
+
const flush = () => {
|
|
76
|
+
if (assistant.length) out.push({ role: "assistant", content: assistant })
|
|
77
|
+
if (results.length) out.push({ role: "tool", content: results })
|
|
78
|
+
assistant = []
|
|
79
|
+
results = []
|
|
80
|
+
}
|
|
81
|
+
for (const p of m.parts) {
|
|
82
|
+
if (p.type === "text") {
|
|
83
|
+
if (!p.text) continue
|
|
84
|
+
if (results.length) flush()
|
|
85
|
+
assistant.push({ type: "text", text: p.text })
|
|
86
|
+
} else if (p.state === "done" || p.state === "error" || p.state === "rejected") {
|
|
87
|
+
const toolName = modelToolName(p.toolName)
|
|
88
|
+
assistant.push({ type: "tool-call", toolCallId: p.toolCallId, toolName, input: p.args ?? {} })
|
|
89
|
+
results.push({ type: "tool-result", toolCallId: p.toolCallId, toolName, output: { type: "json", value: toolOutput(p) } })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
flush()
|
|
93
|
+
}
|
|
94
|
+
return out
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* // convex/aiRunner.ts
|
|
99
|
+
* "use node"
|
|
100
|
+
* export const { runAgent } = defineAiRunner(aiOptions)
|
|
101
|
+
*/
|
|
102
|
+
export function defineAiRunner(options: AiRunnerOptions) {
|
|
103
|
+
const fnModule = options.functionsModule ?? "ai"
|
|
104
|
+
const mutation = (name: string) => makeFunctionReference<"mutation">(`${fnModule}:${name}`)
|
|
105
|
+
const query = (name: string) => makeFunctionReference<"query">(`${fnModule}:${name}`)
|
|
106
|
+
const fn = {
|
|
107
|
+
startRun: mutation("_startRun"),
|
|
108
|
+
appendText: mutation("_appendText"),
|
|
109
|
+
addToolCall: mutation("_addToolCall"),
|
|
110
|
+
executeTool: mutation("_executeTool"),
|
|
111
|
+
setToolCallState: mutation("_setToolCallState"),
|
|
112
|
+
finishRun: mutation("_finishRun"),
|
|
113
|
+
isStopRequested: query("_isStopRequested"),
|
|
114
|
+
}
|
|
115
|
+
const logger = createLogger({ moduleId: "ai.runner" })
|
|
116
|
+
|
|
117
|
+
async function run(ctx: GenericActionCtx<GenericDataModel>, runId: AiRunId): Promise<void> {
|
|
118
|
+
const rc = (await ctx.runMutation(fn.startRun, { runId })) as RunContext | null
|
|
119
|
+
if (!rc) return
|
|
120
|
+
|
|
121
|
+
let finished: RunStatus | null = null
|
|
122
|
+
const finish = async (status: RunStatus, error?: { code: string; message: string }) => {
|
|
123
|
+
if (finished) return
|
|
124
|
+
finished = status
|
|
125
|
+
await ctx.runMutation(fn.finishRun, error ? { runId, status, error } : { runId, status })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const agentDef = options.agents.find((a) => a.id === rc.agentId)
|
|
130
|
+
if (!agentDef) return await finish("error", { code: UNAVAILABLE, message: "Unknown assistant" })
|
|
131
|
+
|
|
132
|
+
let modelFor = options.model
|
|
133
|
+
if (!modelFor) {
|
|
134
|
+
const apiKey = options.openAiApiKey?.() ?? process.env.OPENAI_API_KEY
|
|
135
|
+
if (!apiKey) return await finish("error", { code: UNAVAILABLE, message: "AI model is not configured" })
|
|
136
|
+
const baseURL = options.baseURL ?? (process.env.OPENAI_BASE_URL || undefined)
|
|
137
|
+
const openai = createOpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), ...(options.fetch ? { fetch: options.fetch } : {}) })
|
|
138
|
+
// Chat Completions, not the Responses API default: OpenAI-compatible
|
|
139
|
+
// proxies (9Router, LiteLLM, …) implement /v1/chat/completions. Model ids
|
|
140
|
+
// are plain strings (router names like `cx/gpt-5` are not in the SDK's union).
|
|
141
|
+
modelFor = (id: string) => openai.chat(id)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const messageId = rc.messageId
|
|
145
|
+
const executeTool = async (toolCallId: string): Promise<ToolExecutionResult> => {
|
|
146
|
+
let res: ToolExecutionResult
|
|
147
|
+
try {
|
|
148
|
+
res = (await ctx.runMutation(fn.executeTool, { runId, toolCallId })) as ToolExecutionResult
|
|
149
|
+
} catch (e) {
|
|
150
|
+
logger.warn("ai_tool_execute_failed", { errorName: e instanceof Error ? e.name : typeof e })
|
|
151
|
+
res = { ok: false, code: UNAVAILABLE, message: "Tool failed" }
|
|
152
|
+
}
|
|
153
|
+
await ctx.runMutation(
|
|
154
|
+
fn.setToolCallState,
|
|
155
|
+
res.ok ? { messageId, toolCallId, state: "done", result: res.result ?? null } : { messageId, toolCallId, state: "error", error: res.code },
|
|
156
|
+
)
|
|
157
|
+
return res
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 5. Resume: run the tools the user approved since the last turn.
|
|
161
|
+
const history = rc.history.map((m) => (m.id === messageId ? { ...m, parts: m.parts.map((p) => ({ ...p })) } : m))
|
|
162
|
+
const current = history.find((m) => m.id === messageId)
|
|
163
|
+
for (const p of current?.parts ?? []) {
|
|
164
|
+
if (p.type !== "tool-call" || p.state !== "approved") continue
|
|
165
|
+
await ctx.runMutation(fn.setToolCallState, { messageId, toolCallId: p.toolCallId, state: "running" })
|
|
166
|
+
const res = await executeTool(p.toolCallId)
|
|
167
|
+
Object.assign(p, res.ok ? { state: "done" as ToolCallState, result: res.result ?? null } : { state: "error" as ToolCallState, error: res.code })
|
|
168
|
+
}
|
|
169
|
+
// Still waiting on the user for another call: nothing to do until they answer.
|
|
170
|
+
if (current?.parts.some((p) => p.type === "tool-call" && p.state === "awaiting-approval")) return await finish("waiting-approval")
|
|
171
|
+
|
|
172
|
+
// 7. Text batching + stop checks.
|
|
173
|
+
const controller = new AbortController()
|
|
174
|
+
let buffer = ""
|
|
175
|
+
let lastFlush = Date.now()
|
|
176
|
+
let chain: Promise<void> = Promise.resolve()
|
|
177
|
+
const flush = (): Promise<void> =>
|
|
178
|
+
(chain = chain.then(async () => {
|
|
179
|
+
lastFlush = Date.now()
|
|
180
|
+
if (buffer && !finished) {
|
|
181
|
+
const text = buffer
|
|
182
|
+
buffer = ""
|
|
183
|
+
await ctx.runMutation(fn.appendText, { messageId, text })
|
|
184
|
+
}
|
|
185
|
+
if (!finished && (await ctx.runQuery(fn.isStopRequested, { runId }))) {
|
|
186
|
+
controller.abort()
|
|
187
|
+
await finish("stopped")
|
|
188
|
+
}
|
|
189
|
+
}))
|
|
190
|
+
|
|
191
|
+
// 4. Tools: only those the actor may use right now.
|
|
192
|
+
let awaitingApproval = false
|
|
193
|
+
const allowed = new Set(rc.allowedToolIds)
|
|
194
|
+
const tools: Record<string, ReturnType<typeof createTool>> = {}
|
|
195
|
+
for (const tool of agentDef.tools as readonly AiTool[]) {
|
|
196
|
+
if (!allowed.has(tool.id)) continue
|
|
197
|
+
const name = modelToolName(tool.id)
|
|
198
|
+
tools[name] = createTool({
|
|
199
|
+
id: name,
|
|
200
|
+
description: tool.description,
|
|
201
|
+
inputSchema: tool.input,
|
|
202
|
+
execute: async (input: unknown, toolCtx: { agent?: { toolCallId?: string } } | undefined) => {
|
|
203
|
+
const toolCallId = toolCtx?.agent?.toolCallId || `call_${crypto.randomUUID()}`
|
|
204
|
+
await flush()
|
|
205
|
+
if (finished) throw new StopTurn("stopped")
|
|
206
|
+
const args = input ?? {}
|
|
207
|
+
if (tool.requiresApproval) {
|
|
208
|
+
awaitingApproval = true
|
|
209
|
+
await ctx.runMutation(fn.addToolCall, { messageId, toolCallId, toolName: tool.id, args, state: "awaiting-approval" })
|
|
210
|
+
return { ok: false, status: "awaiting-approval", message: "The user must approve this action first. Stop and wait for their answer." }
|
|
211
|
+
}
|
|
212
|
+
await ctx.runMutation(fn.addToolCall, { messageId, toolCallId, toolName: tool.id, args, state: "running" })
|
|
213
|
+
const res = await executeTool(toolCallId)
|
|
214
|
+
return res.ok ? { ok: true, result: res.result ?? null } : { ok: false, error: res.code, message: res.message }
|
|
215
|
+
},
|
|
216
|
+
} as Parameters<typeof createTool>[0]) as ReturnType<typeof createTool>
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const agent = new Agent({
|
|
220
|
+
id: agentDef.id,
|
|
221
|
+
name: agentDef.name,
|
|
222
|
+
instructions: agentDef.instructions,
|
|
223
|
+
model: modelFor(rc.model),
|
|
224
|
+
tools,
|
|
225
|
+
})
|
|
226
|
+
// Mastra logs stream errors with the full error object; upstream API errors carry the
|
|
227
|
+
// request body (prompts, tool results). Silence it; we log only the error name.
|
|
228
|
+
agent.__setLogger(noopLogger)
|
|
229
|
+
|
|
230
|
+
const messages = toModelMessages(history)
|
|
231
|
+
// An approval call ends the turn after its step (other calls of the same step still
|
|
232
|
+
// finish and are recorded); the step cap stops tool loops.
|
|
233
|
+
const stopWhen = [({ steps }: { steps: unknown[] }) => steps.length >= AI_MAX_STEPS, () => awaitingApproval]
|
|
234
|
+
try {
|
|
235
|
+
const output = await agent.stream(messages as never, {
|
|
236
|
+
maxSteps: AI_MAX_STEPS,
|
|
237
|
+
stopWhen: stopWhen as never,
|
|
238
|
+
abortSignal: controller.signal,
|
|
239
|
+
})
|
|
240
|
+
const reader = output.fullStream.getReader()
|
|
241
|
+
for (;;) {
|
|
242
|
+
const { done, value } = await reader.read()
|
|
243
|
+
if (done || finished) break
|
|
244
|
+
const chunk = value as { type: string; payload?: { text?: string; error?: unknown } }
|
|
245
|
+
if (chunk.type === "text-delta" && chunk.payload?.text) {
|
|
246
|
+
buffer += chunk.payload.text
|
|
247
|
+
if (buffer.length >= AI_FLUSH_CHARS || Date.now() - lastFlush >= AI_FLUSH_MS) await flush()
|
|
248
|
+
} else if (chunk.type === "error") {
|
|
249
|
+
throw chunk.payload?.error instanceof Error ? chunk.payload.error : new Error("stream error")
|
|
250
|
+
}
|
|
251
|
+
if (finished) break
|
|
252
|
+
}
|
|
253
|
+
if (finished) await reader.cancel().catch(() => {})
|
|
254
|
+
} catch (e) {
|
|
255
|
+
if (!finished) throw e
|
|
256
|
+
}
|
|
257
|
+
await flush()
|
|
258
|
+
await chain
|
|
259
|
+
// 8. End of turn.
|
|
260
|
+
await finish(awaitingApproval ? "waiting-approval" : "done")
|
|
261
|
+
} catch (e) {
|
|
262
|
+
logger.error("ai_run_failed", { errorName: e instanceof Error ? e.name : typeof e })
|
|
263
|
+
await finish("error", FAILED)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const runAgent = internalActionGeneric({
|
|
268
|
+
args: { runId: v.id("aiRuns") },
|
|
269
|
+
handler: async (ctx, { runId }) => {
|
|
270
|
+
await run(ctx, runId as AiRunId)
|
|
271
|
+
},
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
return { runAgent }
|
|
275
|
+
}
|