@jskit-ai/assistant-runtime 0.1.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 (30) hide show
  1. package/package.descriptor.mjs +136 -0
  2. package/package.json +21 -0
  3. package/src/client/components/AssistantSettingsClientElement.vue +204 -0
  4. package/src/client/components/AssistantSurfaceClientElement.vue +19 -0
  5. package/src/client/composables/useAssistantRuntime.js +759 -0
  6. package/src/client/index.js +4 -0
  7. package/src/client/providers/AssistantClientProvider.js +16 -0
  8. package/src/server/AssistantProvider.js +152 -0
  9. package/src/server/actionIds.js +9 -0
  10. package/src/server/actions.js +151 -0
  11. package/src/server/inputValidators.js +41 -0
  12. package/src/server/registerRoutes.js +450 -0
  13. package/src/server/repositories/assistantConfigRepository.js +148 -0
  14. package/src/server/repositories/conversationsRepository.js +263 -0
  15. package/src/server/repositories/messagesRepository.js +166 -0
  16. package/src/server/services/assistantConfigService.js +132 -0
  17. package/src/server/services/chatService.js +1048 -0
  18. package/src/server/services/transcriptService.js +331 -0
  19. package/src/server/support/assistantServerConfig.js +106 -0
  20. package/src/server/support/createSurfaceAwareToolCatalog.js +64 -0
  21. package/src/shared/assistantRuntimeConfig.js +7 -0
  22. package/src/shared/assistantSurfaces.js +97 -0
  23. package/src/shared/index.js +7 -0
  24. package/templates/migrations/assistant_config_initial.cjs +27 -0
  25. package/templates/migrations/assistant_transcripts_initial.cjs +58 -0
  26. package/test/assistantServerConfig.test.js +72 -0
  27. package/test/assistantSurfaces.test.js +50 -0
  28. package/test/createSurfaceAwareToolCatalog.test.js +77 -0
  29. package/test/lazyAppConfig.test.js +248 -0
  30. package/test/packageDescriptor.test.js +34 -0
@@ -0,0 +1,58 @@
1
+ exports.up = async function up(knex) {
2
+ const hasWorkspacesTable = await knex.schema.hasTable("workspaces");
3
+ const hasConversationsTable = await knex.schema.hasTable("assistant_conversations");
4
+ if (!hasConversationsTable) {
5
+ await knex.schema.createTable("assistant_conversations", (table) => {
6
+ table.increments("id").unsigned().primary();
7
+ table.integer("workspace_id").unsigned().nullable();
8
+ if (hasWorkspacesTable) {
9
+ table.foreign("workspace_id").references("id").inTable("workspaces").onDelete("CASCADE");
10
+ }
11
+ table.integer("created_by_user_id").unsigned().nullable().references("id").inTable("users").onDelete("SET NULL").index();
12
+ table.string("title", 160).notNullable().defaultTo("New conversation");
13
+ table.string("status", 32).notNullable().defaultTo("active");
14
+ table.string("provider", 64).notNullable().defaultTo("");
15
+ table.string("model", 128).notNullable().defaultTo("");
16
+ table.string("surface_id", 32).notNullable();
17
+ table.integer("message_count").unsigned().notNullable().defaultTo(0);
18
+ table.text("metadata_json").nullable();
19
+ table.timestamp("started_at").notNullable().defaultTo(knex.fn.now());
20
+ table.timestamp("ended_at").nullable();
21
+ table.timestamp("created_at").notNullable().defaultTo(knex.fn.now());
22
+ table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now());
23
+
24
+ table.index(["surface_id", "started_at"], "idx_assistant_conversations_surface_started_at");
25
+ table.index(["workspace_id", "started_at"], "idx_assistant_conversations_workspace_started_at");
26
+ table.index(["created_by_user_id", "started_at"], "idx_assistant_conversations_creator_started_at");
27
+ });
28
+ }
29
+
30
+ const hasMessagesTable = await knex.schema.hasTable("assistant_messages");
31
+ if (!hasMessagesTable) {
32
+ await knex.schema.createTable("assistant_messages", (table) => {
33
+ table.increments("id").unsigned().primary();
34
+ table.integer("conversation_id").unsigned().notNullable().references("id").inTable("assistant_conversations").onDelete("CASCADE");
35
+ table.integer("workspace_id").unsigned().nullable();
36
+ if (hasWorkspacesTable) {
37
+ table.foreign("workspace_id").references("id").inTable("workspaces").onDelete("CASCADE");
38
+ }
39
+ table.integer("seq").unsigned().notNullable();
40
+ table.string("role", 32).notNullable();
41
+ table.string("kind", 32).notNullable().defaultTo("chat");
42
+ table.string("client_message_sid", 128).notNullable().defaultTo("");
43
+ table.integer("actor_user_id").unsigned().nullable().references("id").inTable("users").onDelete("SET NULL").index();
44
+ table.text("content_text").nullable();
45
+ table.text("metadata_json").nullable();
46
+ table.timestamp("created_at").notNullable().defaultTo(knex.fn.now());
47
+
48
+ table.unique(["conversation_id", "seq"], "uq_assistant_messages_conversation_seq");
49
+ table.index(["conversation_id", "created_at"], "idx_assistant_messages_conversation_created_at");
50
+ table.index(["workspace_id"], "idx_assistant_messages_workspace");
51
+ });
52
+ }
53
+ };
54
+
55
+ exports.down = async function down(knex) {
56
+ await knex.schema.dropTableIfExists("assistant_messages");
57
+ await knex.schema.dropTableIfExists("assistant_conversations");
58
+ };
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ resolveAssistantAiConfig,
5
+ resolveAssistantServerConfig
6
+ } from "../src/server/support/assistantServerConfig.js";
7
+
8
+ test("assistant server config resolves per-surface AI config without legacy global fallback", () => {
9
+ const appConfig = {
10
+ assistantServer: {
11
+ admin: {
12
+ aiConfigPrefix: "ADMIN_ASSISTANT",
13
+ provider: "anthropic",
14
+ apiKey: "config-key",
15
+ baseUrl: "https://config.example.test",
16
+ model: "claude-config",
17
+ timeoutMs: 45_000,
18
+ barredActionIds: ["demo.admin.secret"],
19
+ toolSkipActionPrefixes: ["demo.hidden."]
20
+ }
21
+ },
22
+ assistant: {
23
+ provider: "openai",
24
+ apiKey: "legacy-key"
25
+ }
26
+ };
27
+ const env = {
28
+ ADMIN_ASSISTANT_AI_PROVIDER: "deepseek",
29
+ ADMIN_ASSISTANT_AI_API_KEY: "surface-key",
30
+ AI_PROVIDER: "openai",
31
+ AI_API_KEY: "legacy-env-key"
32
+ };
33
+
34
+ assert.deepEqual(resolveAssistantServerConfig(appConfig, "admin"), {
35
+ aiConfigPrefix: "ADMIN_ASSISTANT",
36
+ provider: "anthropic",
37
+ apiKey: "config-key",
38
+ baseUrl: "https://config.example.test",
39
+ model: "claude-config",
40
+ timeoutMs: 45_000,
41
+ barredActionIds: ["demo.admin.secret"],
42
+ toolSkipActionPrefixes: ["demo.hidden."]
43
+ });
44
+ assert.deepEqual(resolveAssistantAiConfig({ appConfig, env }, "admin"), {
45
+ aiConfigPrefix: "ADMIN_ASSISTANT",
46
+ ai: {
47
+ enabled: true,
48
+ provider: "deepseek",
49
+ apiKey: "surface-key",
50
+ baseUrl: "https://config.example.test",
51
+ model: "claude-config",
52
+ timeoutMs: 45_000
53
+ }
54
+ });
55
+ });
56
+
57
+ test("assistant server config requires explicit aiConfigPrefix for each surface", () => {
58
+ assert.throws(
59
+ () =>
60
+ resolveAssistantAiConfig(
61
+ {
62
+ appConfig: {},
63
+ env: {
64
+ AI_PROVIDER: "anthropic",
65
+ AI_API_KEY: "legacy-env-key"
66
+ }
67
+ },
68
+ "admin"
69
+ ),
70
+ /requires assistantServer\.admin\.aiConfigPrefix/
71
+ );
72
+ });
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ normalizeAssistantConfigScope,
5
+ resolveAssistantSurfaceConfig
6
+ } from "../src/shared/assistantSurfaces.js";
7
+
8
+ test("assistant surface config resolves runtime/settings surface requirements from app config", () => {
9
+ const appConfig = {
10
+ surfaceDefinitions: {
11
+ admin: { id: "admin", enabled: true, requiresWorkspace: true, accessPolicyId: "workspace_member" },
12
+ console: { id: "console", enabled: true, requiresWorkspace: false, accessPolicyId: "console_owner" }
13
+ },
14
+ assistantSurfaces: {
15
+ admin: {
16
+ settingsSurfaceId: "console",
17
+ configScope: "global"
18
+ }
19
+ }
20
+ };
21
+
22
+ const assistantSurface = resolveAssistantSurfaceConfig(appConfig, "admin");
23
+ assert.deepEqual(assistantSurface, {
24
+ targetSurfaceId: "admin",
25
+ settingsSurfaceId: "console",
26
+ configScope: "global",
27
+ runtimeSurfaceRequiresWorkspace: true,
28
+ settingsSurfaceRequiresWorkspace: false,
29
+ settingsSurfaceRequiresConsoleOwner: true
30
+ });
31
+ });
32
+
33
+ test("assistant surface config rejects invalid workspace-scoped assistant combinations", () => {
34
+ const appConfig = {
35
+ surfaceDefinitions: {
36
+ home: { id: "home", enabled: true, requiresWorkspace: false, accessPolicyId: "public" },
37
+ console: { id: "console", enabled: true, requiresWorkspace: false, accessPolicyId: "console_owner" }
38
+ },
39
+ assistantSurfaces: {
40
+ home: {
41
+ settingsSurfaceId: "console",
42
+ configScope: "workspace"
43
+ }
44
+ }
45
+ };
46
+
47
+ assert.equal(resolveAssistantSurfaceConfig(appConfig, "home"), null);
48
+ assert.equal(normalizeAssistantConfigScope("workspace"), "workspace");
49
+ assert.equal(normalizeAssistantConfigScope("weird"), "global");
50
+ });
@@ -0,0 +1,77 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createSurfaceAwareToolCatalog } from "../src/server/support/createSurfaceAwareToolCatalog.js";
4
+
5
+ test("surface-aware tool catalog applies per-surface skip and barred configuration", async () => {
6
+ const observed = [];
7
+ const catalog = createSurfaceAwareToolCatalog(
8
+ {},
9
+ {
10
+ appConfig: {
11
+ assistantServer: {
12
+ admin: {
13
+ barredActionIds: ["demo.admin.secret"],
14
+ toolSkipActionPrefixes: ["demo.admin."]
15
+ },
16
+ console: {
17
+ barredActionIds: ["demo.console.secret"]
18
+ }
19
+ }
20
+ },
21
+ createCatalog(_scope, options = {}) {
22
+ observed.push(options);
23
+ return {
24
+ resolveToolSet(context = {}) {
25
+ return {
26
+ tools: [
27
+ {
28
+ name: `tool:${String(context.surface || "default")}`
29
+ }
30
+ ],
31
+ byName: new Map()
32
+ };
33
+ },
34
+ toOpenAiToolSchema(tool) {
35
+ return {
36
+ tool
37
+ };
38
+ },
39
+ async executeToolCall(payload = {}) {
40
+ return {
41
+ ok: true,
42
+ payload,
43
+ options
44
+ };
45
+ }
46
+ };
47
+ }
48
+ }
49
+ );
50
+
51
+ assert.deepEqual(catalog.resolveToolSet({ surface: "admin" }).tools, [{ name: "tool:admin" }]);
52
+ assert.deepEqual(catalog.resolveToolSet({ surface: "console" }).tools, [{ name: "tool:console" }]);
53
+ assert.throws(() => catalog.resolveToolSet({}), /requires context\.surface/);
54
+
55
+ const execution = await catalog.executeToolCall({
56
+ toolName: "demo",
57
+ context: {
58
+ surface: "admin"
59
+ }
60
+ });
61
+ assert.throws(() => catalog.executeToolCall({ toolName: "demo", context: {} }), /requires context\.surface/);
62
+
63
+ assert.deepEqual(observed, [
64
+ {
65
+ barredActionIds: ["demo.admin.secret"],
66
+ skipActionPrefixes: ["assistant.", "demo.admin."]
67
+ },
68
+ {
69
+ barredActionIds: ["demo.console.secret"],
70
+ skipActionPrefixes: ["assistant."]
71
+ }
72
+ ]);
73
+ assert.deepEqual(execution.options, {
74
+ barredActionIds: ["demo.admin.secret"],
75
+ skipActionPrefixes: ["assistant.", "demo.admin."]
76
+ });
77
+ });
@@ -0,0 +1,248 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { AppError } from "@jskit-ai/kernel/server/runtime";
4
+ import { registerRoutes } from "../src/server/registerRoutes.js";
5
+ import { createChatService } from "../src/server/services/chatService.js";
6
+
7
+ function createAssistantAppConfig() {
8
+ return {
9
+ surfaceDefinitions: {
10
+ admin: {
11
+ id: "admin",
12
+ enabled: true,
13
+ requiresWorkspace: true,
14
+ accessPolicyId: "workspace_member"
15
+ }
16
+ },
17
+ assistantSurfaces: {
18
+ admin: {
19
+ settingsSurfaceId: "admin",
20
+ configScope: "workspace"
21
+ }
22
+ }
23
+ };
24
+ }
25
+
26
+ test("registerRoutes resolves appConfig lazily when handlers run", async () => {
27
+ const routes = [];
28
+ let currentAppConfig = null;
29
+
30
+ const router = {
31
+ register(method, path, options, handler) {
32
+ routes.push({ method, path, options, handler });
33
+ }
34
+ };
35
+
36
+ const app = {
37
+ make(token) {
38
+ if (token === "jskit.http.router") {
39
+ return router;
40
+ }
41
+ if (token === "appConfig") {
42
+ return currentAppConfig;
43
+ }
44
+ throw new Error(`Unexpected token: ${token}`);
45
+ },
46
+ has(token) {
47
+ return token === "appConfig" ? Boolean(currentAppConfig) : token === "jskit.http.router";
48
+ }
49
+ };
50
+
51
+ registerRoutes(app);
52
+ currentAppConfig = createAssistantAppConfig();
53
+
54
+ const route = routes.find(
55
+ (entry) =>
56
+ entry.method === "GET" &&
57
+ entry.path === "/api/w/:workspaceSlug/assistant/:surfaceId/conversations"
58
+ );
59
+
60
+ assert.ok(route, "Expected workspace assistant conversations route to be registered.");
61
+
62
+ let capturedInput = null;
63
+ const reply = {
64
+ statusCode: 0,
65
+ payload: null,
66
+ code(statusCode) {
67
+ this.statusCode = statusCode;
68
+ return this;
69
+ },
70
+ send(payload) {
71
+ this.payload = payload;
72
+ return this;
73
+ }
74
+ };
75
+
76
+ await route.handler(
77
+ {
78
+ headers: {
79
+ "x-jskit-surface": "admin"
80
+ },
81
+ input: {
82
+ params: {
83
+ workspaceSlug: "dogandgroom",
84
+ surfaceId: "admin"
85
+ },
86
+ query: {
87
+ limit: 20
88
+ }
89
+ },
90
+ executeAction: async ({ input }) => {
91
+ capturedInput = input;
92
+ return {
93
+ entries: [],
94
+ pagination: {
95
+ cursor: null,
96
+ nextCursor: null,
97
+ limit: 20,
98
+ hasMore: false
99
+ }
100
+ };
101
+ }
102
+ },
103
+ reply
104
+ );
105
+
106
+ assert.equal(reply.statusCode, 200);
107
+ assert.equal(capturedInput?.targetSurfaceId, "admin");
108
+ assert.equal(capturedInput?.workspaceSlug, "dogandgroom");
109
+ });
110
+
111
+ test("registerRoutes returns clear AppError payload for pre-stream assistant failures", async () => {
112
+ const routes = [];
113
+ let currentAppConfig = null;
114
+
115
+ const router = {
116
+ register(method, path, options, handler) {
117
+ routes.push({ method, path, options, handler });
118
+ }
119
+ };
120
+
121
+ const app = {
122
+ make(token) {
123
+ if (token === "jskit.http.router") {
124
+ return router;
125
+ }
126
+ if (token === "appConfig") {
127
+ return currentAppConfig;
128
+ }
129
+ throw new Error(`Unexpected token: ${token}`);
130
+ },
131
+ has(token) {
132
+ return token === "appConfig" ? Boolean(currentAppConfig) : token === "jskit.http.router";
133
+ }
134
+ };
135
+
136
+ registerRoutes(app);
137
+ currentAppConfig = createAssistantAppConfig();
138
+
139
+ const route = routes.find(
140
+ (entry) =>
141
+ entry.method === "POST" &&
142
+ entry.path === "/api/w/:workspaceSlug/assistant/:surfaceId/chat/stream"
143
+ );
144
+
145
+ assert.ok(route, "Expected workspace assistant chat stream route to be registered.");
146
+
147
+ const reply = {
148
+ statusCode: 0,
149
+ payload: null,
150
+ code(statusCode) {
151
+ this.statusCode = statusCode;
152
+ return this;
153
+ },
154
+ send(payload) {
155
+ this.payload = payload;
156
+ return this;
157
+ },
158
+ header() {
159
+ return this;
160
+ }
161
+ };
162
+
163
+ await route.handler(
164
+ {
165
+ raw: {
166
+ on() {},
167
+ off() {}
168
+ },
169
+ headers: {
170
+ "x-jskit-surface": "admin"
171
+ },
172
+ input: {
173
+ params: {
174
+ workspaceSlug: "dogandgroom",
175
+ surfaceId: "admin"
176
+ },
177
+ body: {
178
+ messageId: "msg_1",
179
+ input: "hello",
180
+ history: []
181
+ }
182
+ },
183
+ executeAction: async () => {
184
+ throw new AppError(503, "Assistant provider is not configured.");
185
+ }
186
+ },
187
+ reply
188
+ );
189
+
190
+ assert.equal(reply.statusCode, 503);
191
+ assert.deepEqual(reply.payload, {
192
+ error: "Assistant provider is not configured.",
193
+ code: "APP_ERROR"
194
+ });
195
+ });
196
+
197
+ test("chat service resolves appConfig lazily when conversations are listed", async () => {
198
+ let currentAppConfig = {};
199
+
200
+ const chatService = createChatService({
201
+ aiClientFactory: {
202
+ resolveClient() {
203
+ throw new Error("resolveClient should not be called when listing conversations.");
204
+ }
205
+ },
206
+ transcriptService: {
207
+ async listConversationsForUser(assistantSurface, workspace, actor, query, options = {}) {
208
+ return {
209
+ assistantSurface,
210
+ workspace,
211
+ actor,
212
+ query,
213
+ options
214
+ };
215
+ }
216
+ },
217
+ serviceToolCatalog: {},
218
+ assistantConfigService: {},
219
+ resolveAppConfig: () => currentAppConfig
220
+ });
221
+
222
+ currentAppConfig = createAssistantAppConfig();
223
+
224
+ const response = await chatService.listConversations(
225
+ {
226
+ limit: 20
227
+ },
228
+ {
229
+ input: {
230
+ targetSurfaceId: "admin",
231
+ workspaceSlug: "dogandgroom"
232
+ },
233
+ context: {
234
+ actor: {
235
+ authenticated: true,
236
+ userId: 42
237
+ },
238
+ workspace: {
239
+ id: 7,
240
+ slug: "dogandgroom"
241
+ }
242
+ }
243
+ }
244
+ );
245
+
246
+ assert.equal(response.assistantSurface.targetSurfaceId, "admin");
247
+ assert.equal(response.workspace.slug, "dogandgroom");
248
+ });
@@ -0,0 +1,34 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import descriptor from "../package.descriptor.mjs";
4
+
5
+ function findTextMutation(id) {
6
+ const mutations = Array.isArray(descriptor?.mutations?.text) ? descriptor.mutations.text : [];
7
+ return mutations.find((entry) => String(entry?.id || "") === id) || null;
8
+ }
9
+
10
+ function findFileMutation(id) {
11
+ const mutations = Array.isArray(descriptor?.mutations?.files) ? descriptor.mutations.files : [];
12
+ return mutations.find((entry) => String(entry?.id || "") === id) || null;
13
+ }
14
+
15
+ test("assistant-runtime descriptor registers runtime providers and initializes assistant config roots", () => {
16
+ assert.equal(descriptor.kind, "runtime");
17
+ assert.equal(descriptor.packageId, "@jskit-ai/assistant-runtime");
18
+ assert.equal(descriptor.runtime?.server?.providers?.[0]?.entrypoint, "src/server/AssistantProvider.js");
19
+ assert.equal(descriptor.runtime?.client?.providers?.[0]?.entrypoint, "src/client/providers/AssistantClientProvider.js");
20
+
21
+ const publicInit = findTextMutation("assistant-runtime-public-surface-registry-init");
22
+ const serverInit = findTextMutation("assistant-runtime-server-surface-registry-init");
23
+
24
+ assert.match(String(publicInit?.value || ""), /config\.assistantSurfaces \|\|= \{\};/);
25
+ assert.match(String(serverInit?.value || ""), /config\.assistantServer \|\|= \{\};/);
26
+ });
27
+
28
+ test("assistant-runtime descriptor ships common assistant migrations", () => {
29
+ const configMigration = findFileMutation("assistant-runtime-config-initial-schema");
30
+ const transcriptMigration = findFileMutation("assistant-runtime-transcripts-initial-schema");
31
+
32
+ assert.equal(configMigration?.from, "templates/migrations/assistant_config_initial.cjs");
33
+ assert.equal(transcriptMigration?.from, "templates/migrations/assistant_transcripts_initial.cjs");
34
+ });