@jskit-ai/assistant-runtime 0.2.9 → 0.2.10

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 (66) hide show
  1. package/README.md +1 -1
  2. package/fixtures/canonical-conversation/main.js +2 -1
  3. package/fixtures/conversation/main.js +2 -1
  4. package/fixtures/conversation/vite.config.mjs +4 -3
  5. package/migrations/assistant_config_initial.cjs +1 -5
  6. package/migrations/assistant_transcripts_initial.cjs +3 -9
  7. package/migrations/assistant_transcripts_summary.cjs +28 -0
  8. package/migrations/assistant_turn_requests.cjs +2 -1
  9. package/migrations/constraints/assistant_users_foreign_keys.cjs +22 -0
  10. package/migrations/constraints/assistant_workspaces_foreign_keys.cjs +26 -0
  11. package/package.json +17 -10
  12. package/patterns/assistant-surface/example/src/ApplicationAiFeature.js +4 -2
  13. package/src/client/components/AssistantConversationClientElement.vue +10 -4
  14. package/src/client/components/AssistantSurfaceClientElement.vue +8 -4
  15. package/src/client/composables/conversationGoal.js +266 -0
  16. package/src/client/composables/conversationHistory.js +215 -0
  17. package/src/client/composables/conversationRuntime.js +624 -0
  18. package/src/client/composables/useAssistantConversation.js +208 -884
  19. package/src/client/composables/useAssistantRuntime.js +60 -45
  20. package/src/client/providers/AssistantClientProvider.js +1 -0
  21. package/src/client/support/assistantRuntimeState.js +6 -34
  22. package/src/client/support/conversationFailures.js +30 -0
  23. package/src/client/support/conversationRestoreSupport.js +13 -5
  24. package/src/client/support/subscribeAssistantConversation.js +32 -9
  25. package/src/client/support/workspaceScopeSupport.js +13 -22
  26. package/src/client/types.js +46 -0
  27. package/src/server/AssistantProvider.js +45 -17
  28. package/src/server/actions.js +162 -201
  29. package/src/server/conversationOperations.js +141 -0
  30. package/src/server/createAssistantRuntime.js +83 -44
  31. package/src/server/createAssistantSettingsActions.js +69 -47
  32. package/src/server/hostedAssistant.js +21 -0
  33. package/src/server/index.js +1 -0
  34. package/src/server/registerConversationSubscriptions.js +34 -11
  35. package/src/server/registerFastifyConversations.js +18 -6
  36. package/src/server/registerRoutes.js +5 -2
  37. package/src/server/registerSettingsRoutes.js +1 -1
  38. package/src/server/repositories/assistantConfigRepository.js +10 -20
  39. package/src/server/repositories/conversationsRepository.js +35 -10
  40. package/src/server/repositories/messagesRepository.js +14 -10
  41. package/src/server/repositories/repositoryPersistenceUtils.js +1 -6
  42. package/src/server/repositories/turnRequestsRepository.js +6 -1
  43. package/src/server/services/assistantConfigService.js +25 -23
  44. package/src/server/services/chatService.js +57 -61
  45. package/src/server/services/transcriptService.js +15 -7
  46. package/src/server/sqlConversationStorage.js +90 -18
  47. package/src/server/support/assistantServerConfig.js +1 -1
  48. package/src/server/support/assistantStreamWriter.js +7 -1
  49. package/src/server/support/createSurfaceAwareToolCatalog.js +35 -17
  50. package/src/server/types.js +38 -0
  51. package/src/shared/assistantSurfaces.js +2 -2
  52. package/test/assistantConversationOperations.test.js +5 -5
  53. package/test/assistantRuntimeState.test.js +1 -16
  54. package/test/chatServiceLifecycle.test.js +62 -154
  55. package/test/createSurfaceAwareToolCatalog.test.js +5 -0
  56. package/test/featureRuntime.test.js +12 -6
  57. package/test/lazyAppConfig.test.js +3 -5
  58. package/test/packageMetadata.test.js +7 -1
  59. package/test/sqlConversationStorage.mysql.test.js +89 -0
  60. package/test/sqlConversationStorage.test.js +161 -16
  61. package/test/support/memoryAssistantRows.js +6 -1
  62. package/test/support/scriptedModel.js +32 -0
  63. package/test/turnRequests.test.js +4 -2
  64. package/test/usersForeignKeysMigration.test.js +46 -0
  65. package/test/workspacesForeignKeysMigration.test.js +66 -0
  66. package/src/client/support/accessDenied.js +0 -6
@@ -1,6 +1,7 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
2
  import { normalizeObject, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
3
  import { AppError } from "@jskit-ai/kernel/server/runtime";
4
+ import { resolveActor } from "@jskit-ai/kernel/server/actions/actionContributorHelpers";
4
5
  import { createAiClient, createAiConnectionClient } from "@jskit-ai/assistant-core/server";
5
6
  import { createConversationRuntime } from "@jskit-ai/assistant-core/server/conversation";
6
7
  import { createSqlConversationStorage } from "./sqlConversationStorage.js";
@@ -10,60 +11,89 @@ import { createTranscriptService } from "./services/transcriptService.js";
10
11
  import { resolveAssistantAiConfig, resolveAssistantServerConfig } from "./support/assistantServerConfig.js";
11
12
  import { createSurfaceAwareToolCatalog } from "./support/createSurfaceAwareToolCatalog.js";
12
13
 
14
+ /** @typedef {import("./types.js").AssistantAttachments} AssistantAttachments */
15
+ /** @typedef {import("./types.js").AssistantAiClientFactory} AssistantAiClientFactory */
16
+ /** @typedef {import("./types.js").AssistantConfigRepository} AssistantConfigRepository */
17
+ /** @typedef {import("./types.js").ConversationsRepository} ConversationsRepository */
18
+ /** @typedef {import("./types.js").MessagesRepository} MessagesRepository */
19
+ /** @typedef {import("./types.js").TurnRequestsRepository} TurnRequestsRepository */
20
+ /** @typedef {import("./types.js").SurfaceAwareToolCatalog} SurfaceAwareToolCatalog */
21
+ /** @typedef {import("@jskit-ai/connectors-catalog/server/types").AiConnectionResolver} AiConnectionResolver */
22
+
23
+ /**
24
+ * The AI client of each assistant surface: the authorized connection its
25
+ * configuration names, or one client per surface built from its configuration.
26
+ * @param {{ appConfig?: Record<string, any>, env?: Record<string, string | undefined>, aiConnections?: AiConnectionResolver | null }} config
27
+ */
13
28
  function createAssistantAiClientFactory(config = {}) {
14
29
  const appConfig = normalizeObject(config.appConfig);
15
30
  const env = normalizeObject(config.env);
16
31
  const cache = new Map();
17
32
 
18
- return Object.freeze({
19
- async resolveClient(targetSurfaceId = "", { context, integrationId } = {}) {
20
- const normalizedTargetSurfaceId = normalizeText(targetSurfaceId).toLowerCase();
21
- if (!normalizedTargetSurfaceId) {
22
- throw new Error("assistant.ai.client.factory.resolveClient requires targetSurfaceId.");
23
- }
24
-
25
- const surfaceConfig = resolveAssistantServerConfig(appConfig, normalizedTargetSurfaceId);
26
- if (surfaceConfig.aiIntegrationId) {
27
- const selected = integrationId || surfaceConfig.aiIntegrationId;
28
- if (![surfaceConfig.aiIntegrationId, ...surfaceConfig.aiIntegrationIds].includes(selected)) {
29
- throw new AppError(403, "This AI connection is not available in this assistant.", { code: "assistant.runtime.connection.unavailable" });
30
- }
31
- if (!config.aiConnections?.resolve) throw new Error("The assistant requires an authorized AI connection resolver.");
32
- const connection = await config.aiConnections.resolve({ context, integrationId: selected });
33
- return createAiConnectionClient(connection, { timeoutMs: surfaceConfig.timeoutMs });
34
- }
35
- if (integrationId) throw new AppError(403, "This assistant does not allow selecting an AI connection.", {
36
- code: "assistant.runtime.connection.selectionNotAllowed"
37
- });
33
+ async function resolveClient(targetSurfaceId = "", { context, integrationId } = {}) {
34
+ const normalizedTargetSurfaceId = normalizeText(targetSurfaceId).toLowerCase();
35
+ if (!normalizedTargetSurfaceId) {
36
+ throw new Error("assistant.ai.client.factory.resolveClient requires targetSurfaceId.");
37
+ }
38
38
 
39
- if (cache.has(normalizedTargetSurfaceId)) {
40
- return cache.get(normalizedTargetSurfaceId);
39
+ const surfaceConfig = resolveAssistantServerConfig(appConfig, normalizedTargetSurfaceId);
40
+ if (surfaceConfig.aiIntegrationId) {
41
+ const selected = integrationId || surfaceConfig.aiIntegrationId;
42
+ if (![surfaceConfig.aiIntegrationId, ...surfaceConfig.aiIntegrationIds].includes(selected)) {
43
+ throw new AppError(403, "This AI connection is not available in this assistant.", { code: "assistant.runtime.connection.unavailable" });
41
44
  }
45
+ if (!config.aiConnections?.resolve) throw new Error("The assistant requires an authorized AI connection resolver.");
46
+ const connection = await config.aiConnections.resolve({ context, integrationId: selected });
47
+ return createAiConnectionClient(connection, { timeoutMs: surfaceConfig.timeoutMs });
48
+ }
49
+ if (integrationId) throw new AppError(403, "This assistant does not allow selecting an AI connection.", {
50
+ code: "assistant.runtime.connection.selectionNotAllowed"
51
+ });
42
52
 
43
- const assistantAiConfig = resolveAssistantAiConfig(
44
- {
45
- appConfig,
46
- env
47
- },
48
- normalizedTargetSurfaceId
49
- );
50
- const client = createAiClient(assistantAiConfig.ai);
51
- cache.set(normalizedTargetSurfaceId, client);
52
- return client;
53
+ if (cache.has(normalizedTargetSurfaceId)) {
54
+ return cache.get(normalizedTargetSurfaceId);
53
55
  }
56
+
57
+ const assistantAiConfig = resolveAssistantAiConfig(
58
+ {
59
+ appConfig,
60
+ env
61
+ },
62
+ normalizedTargetSurfaceId
63
+ );
64
+ const client = createAiClient(assistantAiConfig.ai);
65
+ cache.set(normalizedTargetSurfaceId, client);
66
+ return client;
67
+ }
68
+
69
+ return Object.freeze({
70
+ resolveClient
54
71
  });
55
72
  }
56
73
 
74
+ /**
75
+ * The assistant over the package's own SQL tables: its repositories, the chat,
76
+ * settings and transcript services, the AI client factory and the tool catalogue.
77
+ * @param {object} dependencies
78
+ * @param {import("@jskit-ai/kernel/server/types").ActionCatalogue} dependencies.actionCatalogue
79
+ * @param {import("@jskit-ai/kernel/server/types").RuntimeConfig} dependencies.config
80
+ * @param {import("@jskit-ai/console-core/server/types").ConsoleCore | null} dependencies.consoleRuntime
81
+ * @param {{ config: AssistantConfigRepository, conversations: ConversationsRepository, messages: MessagesRepository, turnRequests: TurnRequestsRepository }} dependencies.repositories
82
+ * @param {import("@jskit-ai/kernel/server/types").RuntimeEnv} dependencies.env
83
+ * @param {AiConnectionResolver | null} dependencies.aiConnections
84
+ * @param {AssistantAttachments | null} dependencies.attachments
85
+ * @param {import("@jskit-ai/workspaces-core/server/types").WorkspacesCore | null} dependencies.workspaces
86
+ */
57
87
  function createAssistantRuntime({
58
88
  actionCatalogue,
59
89
  config,
60
90
  consoleRuntime,
61
- repositories = {},
91
+ repositories,
62
92
  env,
63
93
  aiConnections,
64
94
  attachments,
65
95
  workspaces
66
- } = {}) {
96
+ }) {
67
97
  const {
68
98
  config: assistantConfigRepository,
69
99
  conversations: conversationsRepository,
@@ -108,8 +138,16 @@ function createAssistantRuntime({
108
138
  });
109
139
  }
110
140
 
111
- // Internal SQL composition: the existing facade supplies the authenticated action
112
- // context and normalized request history, never another browser context channel.
141
+ /**
142
+ * Internal SQL composition: the facade supplies the authenticated action
143
+ * context; the model's history is the conversation's saved history, never the browser's.
144
+ * @param {object} dependencies
145
+ * @param {ConversationsRepository} dependencies.conversationsRepository
146
+ * @param {MessagesRepository} dependencies.messagesRepository
147
+ * @param {AssistantAiClientFactory} dependencies.aiClientFactory
148
+ * @param {SurfaceAwareToolCatalog} dependencies.toolCatalog
149
+ * @param {AssistantAttachments | null} [dependencies.attachments]
150
+ */
113
151
  function createAssistantConversationRuntime({ conversationsRepository, messagesRepository, aiClientFactory, toolCatalog, attachments }) {
114
152
  return createConversationRuntime({
115
153
  storage: createSqlConversationStorage({ conversationsRepository, messagesRepository }),
@@ -117,21 +155,22 @@ function createAssistantConversationRuntime({ conversationsRepository, messagesR
117
155
  const request = context?.assistantRequest;
118
156
  if (!request || request.conversation.id !== conversationId) return false;
119
157
  return Boolean(await conversationsRepository.findByIdForActorScope(conversationId, {
120
- actorUserId: normalizeRecordId(context.actor?.id, { fallback: null }), surfaceId: context.surface,
158
+ actorUserId: normalizeRecordId(resolveActor(context)?.id, { fallback: null }), surfaceId: context.surface,
159
+ // readable-check: allow no-dual-shape - data: the workspace scope resolves the workspace from the action input or context, which may carry the record or its id
121
160
  workspaceId: normalizeRecordId(request.workspace?.id || request.workspace, { fallback: null })
122
161
  }));
123
162
  },
124
- apiClientFactory: { preserveWhitespace: false, resolveClient: ({ context, configuration }) => aiClientFactory.resolveClient(context.surface,
163
+ apiClientFactory: { resolveClient: ({ context, configuration }) => aiClientFactory.resolveClient(context.surface,
125
164
  { context, integrationId: configuration.integrationId }) },
126
- apiHistory: ({ context }) => context.assistantRequest.history,
127
165
  toolCatalog,
128
166
  ...(attachments ? { attachments: {
167
+ // The facade authorized the new message's files before admission; files saved
168
+ // with earlier turns of this conversation are authorized again as they are read.
129
169
  resolve({ attachmentIds, context }) {
130
- const prepared = context.assistantRequest.resolvedAttachments.shift();
131
- if (!prepared || !isDeepStrictEqual(prepared.attachmentIds, attachmentIds)) {
132
- throw new Error("The API attachment request differs from its authorized request context.");
133
- }
134
- return prepared.files;
170
+ const { assistantRequest: request, ...assistantContext } = context;
171
+ const prepared = request.resolvedAttachments[0];
172
+ if (prepared && isDeepStrictEqual(prepared.attachmentIds, attachmentIds)) return request.resolvedAttachments.shift().files;
173
+ return attachments.resolve({ attachmentIds, context: assistantContext, workspace: request.workspace, conversation: request.conversation });
135
174
  }
136
175
  } } : {})
137
176
  });
@@ -6,7 +6,9 @@ import { assistantConfigResource } from "@jskit-ai/assistant-core/shared";
6
6
  import { actionIds } from "./actionIds.js";
7
7
  import { assistantTargetSurfaceInputValidator } from "./inputSchemas.js";
8
8
 
9
- const settingsReadInputValidator = assistantTargetSurfaceInputValidator;
9
+ /** @typedef {import("./types.js").AssistantConfigService} AssistantConfigService */
10
+
11
+ const settingsReadInputValidator = deepFreeze({ schema: assistantTargetSurfaceInputValidator.schema });
10
12
 
11
13
  const settingsUpdatePatchInputValidator = deepFreeze({
12
14
  schema: createSchema({
@@ -27,54 +29,74 @@ const settingsUpdateInputValidator = composeSchemaDefinitions(
27
29
  }
28
30
  );
29
31
 
32
+ /**
33
+ * The JSON:API document of a surface's assistant settings.
34
+ * @param {AssistantConfigService} assistantConfigService
35
+ * @param {object} input
36
+ * @param {object} context
37
+ */
38
+ async function readSettingsDocument(assistantConfigService, input, context) {
39
+ return returnJsonApiData(await assistantConfigService.getSettings(input, {
40
+ context
41
+ }));
42
+ }
43
+
44
+ /**
45
+ * The JSON:API document of a surface's assistant settings after the input's patch.
46
+ * @param {AssistantConfigService} assistantConfigService
47
+ * @param {object} input
48
+ * @param {object} context
49
+ */
50
+ async function updateSettingsDocument(assistantConfigService, input, context) {
51
+ return returnJsonApiData(await assistantConfigService.updateSettings(input, input.patch, {
52
+ context
53
+ }));
54
+ }
55
+
56
+ /**
57
+ * The settings actions of the assistant surfaces' settings screens.
58
+ * @param {object} dependencies
59
+ * @param {AssistantConfigService} dependencies.assistantConfigService
60
+ * @param {string[]} dependencies.settingsSurfaces
61
+ */
30
62
  function createAssistantSettingsActions({ assistantConfigService, settingsSurfaces }) {
31
63
  return Object.freeze([
32
- {
33
- id: actionIds.settingsRead,
34
- version: 1,
35
- kind: "query",
36
- channels: ["api", "automation", "internal"],
37
- surfaces: settingsSurfaces,
38
- permission: {
39
- require: "authenticated"
40
- },
41
- input: settingsReadInputValidator,
42
- output: null,
43
- extensions: {
44
- assistant: {
45
- exclude: "Assistant self-configuration is disabled; use the authenticated assistant settings screen."
46
- }
47
- },
48
- idempotency: "none",
49
- async execute(input, context) {
50
- return returnJsonApiData(await assistantConfigService.getSettings(input, {
51
- context
52
- }));
53
- }
54
- },
55
- {
56
- id: actionIds.settingsUpdate,
57
- version: 1,
58
- kind: "command",
59
- channels: ["api", "automation", "internal"],
60
- surfaces: settingsSurfaces,
61
- permission: {
62
- require: "authenticated"
63
- },
64
- input: settingsUpdateInputValidator,
65
- output: null,
66
- extensions: {
67
- assistant: {
68
- exclude: "Assistant self-configuration is disabled; use the authenticated assistant settings screen."
69
- }
70
- },
71
- idempotency: "optional",
72
- async execute(input, context) {
73
- return returnJsonApiData(await assistantConfigService.updateSettings(input, input.patch, {
74
- context
75
- }));
76
- }
77
- }
64
+ Object.freeze({
65
+ id: actionIds.settingsRead,
66
+ version: 1,
67
+ kind: "query",
68
+ channels: ["api", "automation", "internal"],
69
+ surfaces: settingsSurfaces,
70
+ permission: {
71
+ require: "authenticated"
72
+ },
73
+ input: settingsReadInputValidator,
74
+ output: null,
75
+ extensions: {
76
+ assistant: {
77
+ exclude: "Assistant self-configuration is disabled; use the authenticated assistant settings screen."
78
+ }
79
+ },
80
+ execute: (input, context) => readSettingsDocument(assistantConfigService, input, context)
81
+ }),
82
+ Object.freeze({
83
+ id: actionIds.settingsUpdate,
84
+ version: 1,
85
+ kind: "command",
86
+ channels: ["api", "automation", "internal"],
87
+ surfaces: settingsSurfaces,
88
+ permission: {
89
+ require: "authenticated"
90
+ },
91
+ input: settingsUpdateInputValidator,
92
+ output: null,
93
+ extensions: {
94
+ assistant: {
95
+ exclude: "Assistant self-configuration is disabled; use the authenticated assistant settings screen."
96
+ }
97
+ },
98
+ execute: (input, context) => updateSettingsDocument(assistantConfigService, input, context)
99
+ })
78
100
  ]);
79
101
  }
80
102
 
@@ -0,0 +1,21 @@
1
+ /** @typedef {import("./types.js").ConversationRuntime} ConversationRuntime */
2
+ /** @typedef {import("./types.js").AssistantConfigService} AssistantConfigService */
3
+
4
+ /**
5
+ * The assistant over a conversation runtime the application supplies: its
6
+ * conversations, and the settings service when the application supplies one.
7
+ * @param {object} dependencies
8
+ * @param {ConversationRuntime} dependencies.conversationRuntime
9
+ * @param {AssistantConfigService | null} dependencies.assistantConfigService
10
+ */
11
+ function createHostedAssistant({ conversationRuntime, assistantConfigService }) {
12
+ return Object.freeze({
13
+ conversationRuntime,
14
+ services: Object.freeze({
15
+ conversations: conversationRuntime,
16
+ config: assistantConfigService
17
+ })
18
+ });
19
+ }
20
+
21
+ export { createHostedAssistant };
@@ -4,3 +4,4 @@ export { assistantConversationGoalBodyValidator } from "./inputSchemas.js";
4
4
  export { registerRoutes as registerAssistantRoutes } from "./registerRoutes.js";
5
5
  export { registerConversationSubscriptions } from "./registerConversationSubscriptions.js";
6
6
  export { registerFastifyConversations } from "./registerFastifyConversations.js";
7
+ export { createSurfaceAwareToolCatalog } from "./support/createSurfaceAwareToolCatalog.js";
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { resolveActorId } from "@jskit-ai/kernel/server/actions/actionContributorHelpers";
2
3
  import { attachDirectRequestActionExecutor } from "@jskit-ai/kernel/server/http";
3
4
  import { AppError } from "@jskit-ai/kernel/server/runtime";
5
+ import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
4
6
  import { actionIds } from "./actionIds.js";
5
7
  import { resolveRouteRequestState } from "./support/assistantRouteContext.js";
6
8
  import { errorMessageFields, errorStatus } from "./support/assistantErrors.js";
@@ -32,14 +34,34 @@ function subscriptionError(error) {
32
34
  };
33
35
  }
34
36
 
35
- // Register one conversation subscription owner per shared realtime runtime.
36
- // A host may select its fixed action/request policy at composition; clients
37
- // never choose either. The selected action remains the authorization boundary.
38
- function registerConversationSubscriptions({ realtime, events, actions, config = {}, logger = null, workspaceScopeSupport = null,
37
+ /** A subscription without diagnostics has none to release. */
38
+ function releaseNoDiagnostics() {}
39
+
40
+ /** A subscription whose observer is not attached yet has nothing to detach. */
41
+ function detachNoObserver() {}
42
+
43
+ /**
44
+ * Register one conversation subscription owner per shared realtime runtime.
45
+ * A host may select its fixed action/request policy at composition; clients
46
+ * never choose either. The selected action remains the authorization boundary.
47
+ * @param {object} dependencies
48
+ * @param {import("@jskit-ai/realtime/server/types").RuntimeRealtime} dependencies.realtime
49
+ * @param {import("@jskit-ai/kernel/server/types").RuntimeEvents} dependencies.events
50
+ * @param {import("@jskit-ai/kernel/server/types").ActionCatalogue} dependencies.actions
51
+ * @param {Record<string, any>} [dependencies.config]
52
+ * @param {import("@jskit-ai/kernel/server/types").RuntimeLogger} [dependencies.logger]
53
+ * @param {import("./types.js").WorkspaceScopeSupport | null} [dependencies.workspaceScopeSupport]
54
+ * @param {string} [dependencies.subscribeActionId]
55
+ * @param {"authenticated" | "host"} [dependencies.requestPolicy]
56
+ * @param {boolean} [dependencies.diagnostics]
57
+ */
58
+ function registerConversationSubscriptions({ realtime, events, actions, config = {}, logger = silentLogger, workspaceScopeSupport = null,
39
59
  subscribeActionId = actionIds.conversationSubscribe, requestPolicy = "authenticated", diagnostics = false } = {}) {
60
+ // readable-check: allow method-probe - input validation: an application hands over its realtime connection and event runtime
40
61
  if (typeof realtime?.onConnection !== "function" || typeof events?.publish !== "function") {
41
62
  throw new TypeError("Conversation subscriptions require the shared realtime connection and event runtime.");
42
63
  }
64
+ // readable-check: allow method-probe - input validation: an application hands over its action catalogue
43
65
  if (typeof actions?.execute !== "function" || typeof actions?.getDefinition !== "function") {
44
66
  throw new TypeError("Conversation subscriptions require the assistant action catalogue.");
45
67
  }
@@ -59,10 +81,10 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
59
81
  const subscription = subscriptions.get(subscriptionId);
60
82
  if (!subscription) return;
61
83
  subscriptions.delete(subscriptionId);
62
- subscription.diagnosticRelease?.(reason);
84
+ subscription.diagnosticRelease(reason);
63
85
  clearTimeout(subscription.timer);
64
86
  subscription.pending.clear();
65
- subscription.release?.();
87
+ subscription.release();
66
88
  }
67
89
 
68
90
  async function subscribe(input, acknowledge) {
@@ -83,7 +105,7 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
83
105
  if (!diagnostics) return;
84
106
  const now = performance.now();
85
107
  try {
86
- logger?.warn?.(stageRecord(stageName, fields, now), "Assistant conversation subscription diagnostic.");
108
+ logger.warn(stageRecord(stageName, fields, now), "Assistant conversation subscription diagnostic.");
87
109
  } catch { /* Diagnostics never change admission or cleanup. */ }
88
110
  }
89
111
  function onStage(next, fields = {}) {
@@ -93,7 +115,7 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
93
115
  try {
94
116
  const slowOutcome = now - startedAt >= 5_000 && ["acknowledgement", "abandoned"].includes(next);
95
117
  const level = diagnostics || next === "failed" || slowOutcome ? "warn" : "info";
96
- logger?.[level]?.(stageRecord(next, fields, now), "Assistant conversation subscription stage");
118
+ logger[level](stageRecord(next, fields, now), "Assistant conversation subscription stage");
97
119
  } catch { /* Diagnostics must not change conversation admission or cleanup. */ }
98
120
  stage = next;
99
121
  stageStartedAt = now;
@@ -101,7 +123,8 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
101
123
  try {
102
124
  subscriptionId = requireSubscriptionId(input);
103
125
  release(subscriptionId, "replacement");
104
- subscription = { pending: new Map(), revision: 0, epoch: randomUUID(), queue: Promise.resolve() };
126
+ subscription = { pending: new Map(), revision: 0, epoch: randomUUID(), queue: Promise.resolve(),
127
+ diagnosticRelease: releaseNoDiagnostics, release: detachNoObserver };
105
128
  subscriptions.set(subscriptionId, subscription);
106
129
  if (diagnostics) {
107
130
  subscription.diagnosticRelease = reason => {
@@ -109,7 +132,7 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
109
132
  diagnostic("released", { reason });
110
133
  };
111
134
  diagnosticTimer = setTimeout(() => diagnostic("pending", { acknowledgementBudgetMs: 10_000 }), 10_000);
112
- diagnosticTimer.unref?.();
135
+ diagnosticTimer.unref();
113
136
  }
114
137
  const current = () => !closed && socket.connected && subscriptions.get(subscriptionId) === subscription;
115
138
  onStage("authentication");
@@ -128,7 +151,7 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
128
151
  resolveCurrentAppConfig: () => config, requiresWorkspace, workspaceScopeSupport
129
152
  });
130
153
  attachDirectRequestActionExecutor({ actions, request });
131
- const actorId = request.user?.id;
154
+ const actorId = resolveActorId(null, request);
132
155
 
133
156
  function publish(event, revision) {
134
157
  subscription.queue = subscription.queue.then(async () => {
@@ -1,22 +1,30 @@
1
- import { createActionProvider } from "@jskit-ai/kernel/server/actions";
1
+ import { ActionProvider } from "@jskit-ai/kernel/server/actions";
2
2
  import { HttpProvider } from "@jskit-ai/kernel/server/http";
3
3
  import { AppError, BootstrapProvider, EventProvider } from "@jskit-ai/kernel/server/runtime";
4
4
  import { createCapabilityRuntime, defineProvider } from "@jskit-ai/kernel/shared/capabilities";
5
5
  import { AssistantFeature } from "./AssistantProvider.js";
6
6
 
7
7
  /**
8
- * Configure the existing conversation feature in a plain Fastify application.
8
+ * Hosts the conversation feature in a plain Fastify application.
9
9
  * The caller owns runtime creation/storage and authentication. This registration
10
10
  * owns its HTTP/realtime hosting and shuts it down before Fastify closes.
11
- * JSKIT applications register AssistantFeature on their existing host instead.
11
+ * JSKIT applications register AssistantFeature on their own host instead.
12
+ * @param {import("@jskit-ai/kernel/server/types").RuntimeFastify} app
13
+ * @param {object} options
14
+ * @param {import("./types.js").ConversationRuntime} options.runtime
15
+ * @param {Record<string, any>} options.config
16
+ * @param {(request: object) => Promise<object | null> | object | null} options.authenticate
17
+ * @param {(bootstrap: { request: object, context: object }) => object} [options.bootstrap]
18
+ * @param {import("@jskit-ai/kernel/server/types").RuntimeEnv} options.env The application's environment, usually process.env.
12
19
  */
13
20
  async function registerFastifyConversations(app, {
14
21
  runtime,
15
22
  config,
16
23
  authenticate,
17
24
  bootstrap,
18
- env = process.env
25
+ env
19
26
  } = {}) {
27
+ // readable-check: allow method-probe - input validation: an application hands over its conversation runtime and authentication
20
28
  if (typeof runtime?.open !== "function" || typeof authenticate !== "function" ||
21
29
  !config || typeof config !== "object" || Array.isArray(config)) {
22
30
  throw new TypeError("Fastify conversations require runtime, config and authenticate(request).");
@@ -30,7 +38,11 @@ async function registerFastifyConversations(app, {
30
38
  }
31
39
  const AccessProvider = defineProvider({
32
40
  id: "assistant.fastify.access",
33
- requires: { http: "runtime.http", bootstrap: "runtime.bootstrap", actions: "runtime.actions" },
41
+ requires: {
42
+ http: "runtime.http",
43
+ bootstrap: "runtime.bootstrap",
44
+ actions: "runtime.actions"
45
+ },
34
46
  provides: { access: "assistant.conversation.access" },
35
47
  setup({ http, bootstrap: bootstrapRuntime, actions }) {
36
48
  actions.registerContextContributor({
@@ -62,7 +74,7 @@ async function registerFastifyConversations(app, {
62
74
  // Realtime remains optional for consumers using the feature on their own host.
63
75
  const { RealtimeProvider } = await import("@jskit-ai/realtime/server/RealtimeProvider");
64
76
  const host = createCapabilityRuntime({
65
- providers: [EventProvider, createActionProvider(), HttpProvider, BootstrapProvider,
77
+ providers: [EventProvider, ActionProvider, HttpProvider, BootstrapProvider,
66
78
  RealtimeProvider, AccessProvider, AssistantFeature],
67
79
  inputs: { "runtime.config": config, "runtime.env": env, "runtime.fastify": app,
68
80
  "runtime.logger": app.log, "assistant.conversations": runtime }
@@ -77,7 +77,7 @@ function buildChatStreamActionInput(routeInput = {}, requestBody = {}) {
77
77
  input: requestBody.input
78
78
  };
79
79
 
80
- for (const key of ["conversationId", "history", "clientContext"]) {
80
+ for (const key of ["conversationId", "clientContext"]) {
81
81
  if (Object.prototype.hasOwnProperty.call(requestBody, key)) {
82
82
  actionInput[key] = requestBody[key];
83
83
  }
@@ -86,7 +86,7 @@ function buildChatStreamActionInput(routeInput = {}, requestBody = {}) {
86
86
  return actionInput;
87
87
  }
88
88
 
89
- function resolveAssistantConversationMessagesRecordId(record = {}) {
89
+ function resolveAssistantConversationMessagesRecordId(record) {
90
90
  const conversationId = String(record?.conversation?.id || "").trim();
91
91
  if (!conversationId) {
92
92
  throw new Error("Assistant conversation messages JSON:API response requires conversation.id.");
@@ -154,6 +154,7 @@ function registerRuntimeRoutes(
154
154
  setNdjsonHeaders(reply);
155
155
  reply.code(200);
156
156
  reply.hijack();
157
+ // readable-check: allow method-probe - platform object: a Fastify reply's raw response may lack flushHeaders()
157
158
  if (typeof reply.raw.flushHeaders === "function") {
158
159
  reply.raw.flushHeaders();
159
160
  }
@@ -496,9 +497,11 @@ function registerRoutes(router, {
496
497
  conversationReplacementSchema = null,
497
498
  assistantConfigService = null
498
499
  } = {}) {
500
+ // readable-check: allow method-probe - input validation: an application hands over the router its routes register on
499
501
  if (!router || typeof router.register !== "function") {
500
502
  throw new Error("registerRoutes requires an HTTP router.");
501
503
  }
504
+ // readable-check: allow method-probe - input validation: an application hands over its conversation runtime
502
505
  if (conversationRuntime && typeof conversationRuntime.open !== "function") {
503
506
  throw new TypeError("Assistant conversation routes require conversationRuntime.open().");
504
507
  }
@@ -9,7 +9,7 @@ import { actionIds } from "./actionIds.js";
9
9
  import { assistantSurfaceRouteParamsValidator } from "./inputSchemas.js";
10
10
  import { requireWorkspaceAssistantRouteParams, resolveRouteRequestState } from "./support/assistantRouteContext.js";
11
11
 
12
- function resolveAssistantSettingsRecordId(record = {}) {
12
+ function resolveAssistantSettingsRecordId(record) {
13
13
  const scopeKey = String(record?.scopeKey || "").trim();
14
14
  if (!scopeKey) {
15
15
  throw new Error("Assistant settings JSON:API response requires scopeKey.");
@@ -1,27 +1,19 @@
1
- import { normalizeDbRecordId, createWithTransaction } from "@jskit-ai/database-runtime/shared";
1
+ import { createWithTransaction } from "@jskit-ai/database-runtime/shared";
2
2
  import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
3
- import { normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
4
- import { resolveInsertedId } from "./repositoryPersistenceUtils.js";
3
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
4
+ import { normalizeDbWorkspaceId, normalizeInputRecordId, resolveInsertedId } from "./repositoryPersistenceUtils.js";
5
5
  import { assistantRuntimeConfig } from "../../shared/assistantRuntimeConfig.js";
6
6
 
7
7
  function normalizeTargetSurfaceId(value = "") {
8
8
  return normalizeSurfaceId(value);
9
9
  }
10
10
 
11
- function normalizeWorkspaceId(value) {
12
- return normalizeRecordId(value, { fallback: null });
13
- }
14
-
15
- function normalizeWorkspaceDbId(value) {
16
- return normalizeDbRecordId(value, { fallback: null });
17
- }
18
-
19
11
  function buildScopeKey(targetSurfaceId, workspaceId = null) {
20
12
  const normalizedTargetSurfaceId = normalizeTargetSurfaceId(targetSurfaceId);
21
13
  if (!normalizedTargetSurfaceId) {
22
14
  throw new TypeError("assistantConfigRepository requires targetSurfaceId.");
23
15
  }
24
- const normalizedWorkspaceId = normalizeWorkspaceId(workspaceId);
16
+ const normalizedWorkspaceId = normalizeInputRecordId(workspaceId);
25
17
  if (normalizedWorkspaceId) {
26
18
  return `${normalizedTargetSurfaceId}:workspace:${normalizedWorkspaceId}`;
27
19
  }
@@ -33,7 +25,7 @@ function mapConfigRow(row = {}) {
33
25
  return {
34
26
  targetSurfaceId: normalizeTargetSurfaceId(row.target_surface_id),
35
27
  scopeKey: normalizeText(row.scope_key),
36
- workspaceId: normalizeWorkspaceDbId(row.workspace_id),
28
+ workspaceId: normalizeDbWorkspaceId(row.workspace_id),
37
29
  settings: {
38
30
  systemPrompt: String(row.system_prompt || "")
39
31
  }
@@ -45,7 +37,7 @@ function createDefaultRecord({ targetSurfaceId = "", workspaceId = null } = {})
45
37
  if (!normalizedTargetSurfaceId) {
46
38
  throw new TypeError("assistantConfigRepository requires targetSurfaceId.");
47
39
  }
48
- const normalizedWorkspaceId = normalizeWorkspaceId(workspaceId);
40
+ const normalizedWorkspaceId = normalizeInputRecordId(workspaceId);
49
41
 
50
42
  return {
51
43
  targetSurfaceId: normalizedTargetSurfaceId,
@@ -57,14 +49,12 @@ function createDefaultRecord({ targetSurfaceId = "", workspaceId = null } = {})
57
49
  };
58
50
  }
59
51
 
52
+ /** @param {import("@jskit-ai/database-runtime/server/types").RuntimeKnex} knex */
60
53
  function createRepository(knex) {
61
- if (!knex || typeof knex !== "function") {
62
- throw new Error("createAssistantConfigRepository requires knex client.");
63
- }
64
54
  const withTransaction = createWithTransaction(knex);
65
55
 
66
56
  async function findByScope({ targetSurfaceId = "", workspaceId = null } = {}, options = {}) {
67
- const client = options?.trx || knex;
57
+ const client = options.trx || knex;
68
58
  const defaultRecord = createDefaultRecord({
69
59
  targetSurfaceId,
70
60
  workspaceId
@@ -80,7 +70,7 @@ function createRepository(knex) {
80
70
  }
81
71
 
82
72
  async function upsertByScope({ targetSurfaceId = "", workspaceId = null, patch = {} } = {}, options = {}) {
83
- const client = options?.trx || knex;
73
+ const client = options.trx || knex;
84
74
  const defaultRecord = createDefaultRecord({
85
75
  targetSurfaceId,
86
76
  workspaceId
@@ -88,7 +78,7 @@ function createRepository(knex) {
88
78
  const existing = await findByScope(defaultRecord, {
89
79
  trx: client
90
80
  });
91
- const nextSystemPrompt = Object.hasOwn(patch || {}, "systemPrompt")
81
+ const nextSystemPrompt = Object.hasOwn(patch, "systemPrompt")
92
82
  ? String(patch.systemPrompt || "")
93
83
  : String(existing?.settings?.systemPrompt || defaultRecord.settings.systemPrompt);
94
84
  const now = new Date();