@vellumai/assistant 0.12.3 → 0.12.4-dev.202609231620.a15f321

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 (209) hide show
  1. package/Dockerfile +7 -0
  2. package/README.md +12 -12
  3. package/docker/mousepad/90-vellum.gschema.override +4 -0
  4. package/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +48 -4
  5. package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +12 -8
  6. package/openapi.yaml +97 -4
  7. package/package.json +1 -1
  8. package/src/__tests__/approval-routes-http.test.ts +4 -2
  9. package/src/__tests__/call-routes-http.test.ts +4 -2
  10. package/src/__tests__/channel-readiness-discord.test.ts +8 -0
  11. package/src/__tests__/channel-readiness-slack-remote.test.ts +8 -0
  12. package/src/__tests__/config-loader-backfill.test.ts +1 -0
  13. package/src/__tests__/conversation-pairing.test.ts +10 -0
  14. package/src/__tests__/conversation-resurface-done.test.ts +289 -0
  15. package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
  16. package/src/__tests__/gemini-provider.test.ts +31 -0
  17. package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
  18. package/src/__tests__/job-handler-registration-guard.test.ts +1 -0
  19. package/src/__tests__/llm-catalog-parity.test.ts +4 -0
  20. package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
  21. package/src/__tests__/openai-responses-prompt-cache.test.ts +3 -1
  22. package/src/__tests__/platform-callback-registration.test.ts +46 -1
  23. package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
  24. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  25. package/src/__tests__/pricing.test.ts +33 -0
  26. package/src/__tests__/provider-error-scenarios.test.ts +46 -0
  27. package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
  28. package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
  29. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  30. package/src/__tests__/scaffold-managed-skill-tool.test.ts +116 -66
  31. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
  32. package/src/__tests__/send-endpoint-busy.test.ts +4 -2
  33. package/src/__tests__/skill-load-inline-command.test.ts +35 -0
  34. package/src/__tests__/skill-load-tool.test.ts +42 -11
  35. package/src/__tests__/skill-tool-factory.test.ts +50 -2
  36. package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
  37. package/src/__tests__/validate-input.test.ts +73 -0
  38. package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
  39. package/src/api/ask-question-tool.ts +50 -0
  40. package/src/api/events/tool-result.ts +11 -0
  41. package/src/api/index.ts +7 -0
  42. package/src/api/requests/dictation.ts +2 -2
  43. package/src/api/responses/home.ts +27 -0
  44. package/src/avatar/notification-avatar.test.ts +42 -34
  45. package/src/avatar/notification-avatar.ts +1 -0
  46. package/src/calls/__tests__/voice-session-bridge.test.ts +63 -1
  47. package/src/calls/voice-escalation-judge.ts +6 -5
  48. package/src/calls/voice-screen-annotation.ts +44 -0
  49. package/src/calls/voice-session-bridge.ts +22 -0
  50. package/src/cli/commands/__tests__/plugins.test.ts +29 -1
  51. package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
  52. package/src/cli/commands/platform/index.help.ts +2 -1
  53. package/src/cli/commands/platform/index.ts +8 -2
  54. package/src/cli/commands/plugins.ts +16 -0
  55. package/src/cli/lib/__tests__/inspect-plugin.test.ts +29 -0
  56. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +92 -2
  57. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +15 -0
  58. package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +66 -2
  59. package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +21 -0
  60. package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +65 -0
  61. package/src/cli/lib/__tests__/plugin-details.test.ts +43 -0
  62. package/src/cli/lib/__tests__/plugins-install-offline.test.ts +26 -0
  63. package/src/cli/lib/bundled-marketplace.json +4 -4
  64. package/src/cli/lib/inspect-plugin.ts +5 -0
  65. package/src/cli/lib/plugin-catalog-cache.ts +41 -4
  66. package/src/cli/lib/plugin-catalog-local.ts +9 -0
  67. package/src/cli/lib/plugin-catalog-platform.ts +48 -9
  68. package/src/cli/lib/plugin-catalog-visibility.ts +41 -0
  69. package/src/cli/lib/plugin-details.ts +10 -7
  70. package/src/cli/lib/search-plugins.ts +2 -0
  71. package/src/config/__tests__/default-profile-catalog.test.ts +61 -1
  72. package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
  73. package/src/config/__tests__/sync-gated-profiles.test.ts +71 -0
  74. package/src/config/bundled-skills/screen-annotation/SKILL.md +6 -3
  75. package/src/config/call-site-defaults.ts +17 -11
  76. package/src/config/default-profile-catalog.ts +118 -19
  77. package/src/config/default-profile-names.ts +69 -0
  78. package/src/config/feature-flag-registry.json +25 -9
  79. package/src/config/inference-profile-validation.ts +7 -6
  80. package/src/config/llm-resolver.ts +16 -0
  81. package/src/config/profile-text-generation.test.ts +9 -0
  82. package/src/config/schemas/call-site-catalog.ts +8 -1
  83. package/src/config/schemas/llm.ts +70 -9
  84. package/src/config/sidebar-done-gate.ts +27 -0
  85. package/src/config/sync-gated-profiles.ts +96 -44
  86. package/src/daemon/__tests__/auto-profile-router.test.ts +228 -0
  87. package/src/daemon/auto-profile-router.ts +220 -0
  88. package/src/daemon/conversation-agent-loop.ts +77 -4
  89. package/src/daemon/conversation-slash.ts +2 -2
  90. package/src/daemon/conversation-turn-finalize.ts +12 -0
  91. package/src/daemon/dictation-text-processing.ts +4 -4
  92. package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
  93. package/src/daemon/handlers/config-channels.ts +3 -1
  94. package/src/daemon/handlers/conversation-history.ts +11 -1
  95. package/src/daemon/handlers/conversations.ts +118 -10
  96. package/src/daemon/pkb-reminder-builder.test.ts +10 -3
  97. package/src/daemon/pkb-reminder-builder.ts +1 -1
  98. package/src/desktop/desktop-browser-endpoint.ts +1 -0
  99. package/src/home/feed-types.ts +2 -0
  100. package/src/inbound/platform-callback-registration.ts +19 -6
  101. package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
  102. package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
  103. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +27 -0
  104. package/src/live-voice/__tests__/protocol.test.ts +22 -0
  105. package/src/live-voice/continuation-judge.ts +1 -1
  106. package/src/live-voice/live-voice-session.ts +7 -6
  107. package/src/live-voice/protocol.ts +16 -5
  108. package/src/messaging/providers/gmail/client.ts +4 -0
  109. package/src/notifications/README.md +11 -3
  110. package/src/notifications/__tests__/home-feed-side-effect.test.ts +87 -3
  111. package/src/notifications/conversation-pairing.ts +23 -8
  112. package/src/notifications/home-feed-mirror.ts +39 -0
  113. package/src/notifications/home-feed-side-effect.ts +46 -19
  114. package/src/oauth/AGENTS.md +7 -1
  115. package/src/permissions/question-prompter.ts +14 -4
  116. package/src/persistence/conversation-crud.ts +114 -3
  117. package/src/persistence/conversation-queries.ts +48 -3
  118. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +228 -32
  119. package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
  120. package/src/persistence/embeddings/embedding-backend.ts +23 -41
  121. package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
  122. package/src/persistence/embeddings/embedding-gemini.ts +3 -1
  123. package/src/persistence/embeddings/embedding-local.ts +72 -17
  124. package/src/persistence/embeddings/embedding-types.ts +0 -19
  125. package/src/persistence/jobs-store.ts +229 -0
  126. package/src/persistence/migrations/383-normalize-opencode-host-connections.ts +44 -0
  127. package/src/persistence/migrations/__tests__/383-normalize-opencode-host-connections.test.ts +102 -0
  128. package/src/persistence/steps.ts +12 -0
  129. package/src/platform/sync-avatar.test.ts +29 -0
  130. package/src/plugin-api/model-profiles.ts +5 -3
  131. package/src/plugins/defaults/memory/AGENTS.md +5 -3
  132. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
  133. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-boundary-guard.test.ts +45 -0
  134. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-job.test.ts +580 -0
  135. package/src/plugins/defaults/memory/graph/tools.ts +9 -5
  136. package/src/plugins/defaults/memory/hooks/conversation-deleted.ts +4 -0
  137. package/src/plugins/defaults/memory/job-handlers.ts +8 -2
  138. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
  139. package/src/plugins/defaults/memory/skill-update-receipt-job.ts +334 -0
  140. package/src/plugins/defaults/memory/substrate/skill-content.ts +1 -1
  141. package/src/plugins/defaults/memory/tools.test.ts +17 -0
  142. package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +3 -0
  143. package/src/providers/__tests__/connection-model-compat.test.ts +2 -0
  144. package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
  145. package/src/providers/anthropic/client.ts +2 -2
  146. package/src/providers/gemini/client.ts +17 -1
  147. package/src/providers/inference/auth.ts +25 -0
  148. package/src/providers/jev/ask.test.ts +66 -0
  149. package/src/{calls/typesafe-noul.ts → providers/jev/ask.ts} +75 -36
  150. package/src/providers/malformed-tool-call.ts +55 -0
  151. package/src/providers/model-catalog.ts +257 -4
  152. package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
  153. package/src/providers/openai/chat-completions-provider.ts +30 -0
  154. package/src/providers/openai/codex-models.ts +2 -0
  155. package/src/providers/platform-proxy/constants.ts +5 -0
  156. package/src/providers/retry.ts +68 -36
  157. package/src/providers/search-provider-catalog.ts +10 -0
  158. package/src/providers/vellum-model-routing.test.ts +1 -0
  159. package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
  160. package/src/runtime/__tests__/question-resolution.test.ts +32 -0
  161. package/src/runtime/agent-wake.ts +9 -2
  162. package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
  163. package/src/runtime/auth/middleware.ts +15 -11
  164. package/src/runtime/middleware/auth.ts +7 -1
  165. package/src/runtime/pending-interactions.ts +8 -8
  166. package/src/runtime/question-resolution.ts +54 -8
  167. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
  168. package/src/runtime/routes/__tests__/dictation-processing.test.ts +174 -0
  169. package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
  170. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +60 -0
  171. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
  172. package/src/runtime/routes/__tests__/plugins-routes.test.ts +8 -0
  173. package/src/runtime/routes/conversation-list-routes.ts +16 -14
  174. package/src/runtime/routes/conversation-query-routes.ts +4 -0
  175. package/src/runtime/routes/conversation-routes.ts +4 -2
  176. package/src/runtime/routes/diagnostics-routes.ts +66 -229
  177. package/src/runtime/routes/global-search-routes.ts +22 -7
  178. package/src/runtime/routes/inference-provider-connection-routes.ts +25 -8
  179. package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
  180. package/src/runtime/routes/platform-routes.ts +6 -1
  181. package/src/runtime/routes/plugins-routes.ts +5 -2
  182. package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
  183. package/src/runtime/sync/resource-sync-events.ts +11 -0
  184. package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
  185. package/src/runtime/sync/worker-daemon-notify.ts +44 -0
  186. package/src/runtime/verification-outbound-actions.ts +2 -2
  187. package/src/skills/validate-input.ts +75 -11
  188. package/src/telegram/__tests__/webhook-health.test.ts +8 -0
  189. package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
  190. package/src/tools/ask-question/ask-question-tool.ts +12 -1
  191. package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +28 -1
  192. package/src/tools/network/__tests__/web-fetch-metadata.test.ts +34 -0
  193. package/src/tools/network/__tests__/web-fetch-tinyfish.test.ts +35 -1
  194. package/src/tools/network/__tests__/web-search.test.ts +96 -0
  195. package/src/tools/network/web-fetch.ts +30 -9
  196. package/src/tools/network/web-search.ts +235 -4
  197. package/src/tools/skills/execute.ts +1 -1
  198. package/src/tools/skills/format-input-schema.ts +15 -0
  199. package/src/tools/skills/load.ts +15 -43
  200. package/src/tools/skills/scaffold-managed.ts +86 -71
  201. package/src/tools/skills/skill-tool-factory.ts +2 -1
  202. package/src/tools/workflows/manage-workflows.ts +4 -2
  203. package/src/util/worker-pipe.test.ts +146 -0
  204. package/src/util/worker-pipe.ts +49 -0
  205. package/src/workspace/migrations/158-repoint-opencode-host-provider-fragments.ts +163 -0
  206. package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
  207. package/src/workspace/migrations/__tests__/158-repoint-opencode-host-provider-fragments.test.ts +188 -0
  208. package/src/workspace/migrations/registry.ts +4 -0
  209. package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
@@ -7,6 +7,8 @@ import {
7
7
  } from "node:fs";
8
8
  import { join } from "node:path";
9
9
 
10
+ import type { Subprocess } from "bun";
11
+
10
12
  import { getIsContainerized } from "../../config/env-registry.js";
11
13
  import { getLogger } from "../../util/logger.js";
12
14
  import {
@@ -22,6 +24,7 @@ import {
22
24
  listWorkerProcesses,
23
25
  pid1OwnsWorkers,
24
26
  } from "../../util/worker-ownership.js";
27
+ import { writeWorkerLine } from "../../util/worker-pipe.js";
25
28
  import { EmbeddingRuntimeManager } from "./embedding-runtime-manager.js";
26
29
  import {
27
30
  type EmbeddingBackend,
@@ -106,9 +109,7 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
106
109
  readonly provider = "local" as const;
107
110
  readonly model: string;
108
111
 
109
- // Subprocess — typed loosely to avoid coupling to Bun's Subprocess generics
110
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
- private workerProc: any = null;
112
+ private workerProc: Subprocess<"pipe", "pipe", "pipe"> | null = null;
112
113
  private stdoutBuffer = "";
113
114
  private requestCounter = 0;
114
115
  private pendingRequests = new Map<
@@ -119,7 +120,10 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
119
120
  >();
120
121
  private stdoutReaderActive = false;
121
122
  private activeEmbeds = 0;
123
+ /** Release the worker once idle. {@link forModel} withdraws the request. */
122
124
  private disposeRequested = false;
125
+ /** Set by {@link shutdown}. The process is exiting, so nothing reopens this. */
126
+ private processExiting = false;
123
127
 
124
128
  private readonly initGuard = new PromiseGuard<void>();
125
129
  private initInFlight: Promise<void> | null = null;
@@ -130,7 +134,67 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
130
134
  /** Overridable so tests can exercise the escalation path without the wait. */
131
135
  private terminateGraceMs = WORKER_TERMINATE_GRACE_MS;
132
136
 
133
- constructor(model: string) {
137
+ /**
138
+ * Every backend this process has handed out, one per model.
139
+ *
140
+ * {@link reclaimOwnedWorkers} terminates a same-model worker parented to this
141
+ * process that the instance holds no handle for. That is sound only while no
142
+ * other instance in this process can hold that worker, so instances come from
143
+ * {@link forModel} and are never constructed by callers.
144
+ *
145
+ * This is also the complete list of workers the process owns. The backend
146
+ * cache is not: it forgets a backend whose disposal is still pending.
147
+ */
148
+ private static readonly byModel = new Map<string, LocalEmbeddingBackend>();
149
+
150
+ /**
151
+ * The backend for `model`. A pending {@link dispose} is withdrawn, so embeds
152
+ * still running on the instance keep their worker.
153
+ */
154
+ static forModel(model: string): LocalEmbeddingBackend {
155
+ let backend = LocalEmbeddingBackend.byModel.get(model);
156
+ if (!backend) {
157
+ backend = new LocalEmbeddingBackend(model);
158
+ LocalEmbeddingBackend.byModel.set(model, backend);
159
+ }
160
+ if (!backend.processExiting) {
161
+ backend.disposeRequested = false;
162
+ }
163
+ return backend;
164
+ }
165
+
166
+ /** {@link shutdown} every backend, then reap any worker still parented here. */
167
+ static async shutdownAll(): Promise<void> {
168
+ await Promise.all(
169
+ [...LocalEmbeddingBackend.byModel.values()].map(async (backend) => {
170
+ try {
171
+ await backend.shutdown();
172
+ await backend.sweepOwnedWorkers();
173
+ } catch (err) {
174
+ log.warn(
175
+ { err, model: backend.model },
176
+ "Failed to shut down local embedding backend",
177
+ );
178
+ }
179
+ }),
180
+ );
181
+ }
182
+
183
+ /** {@link terminateNow} every backend. */
184
+ static terminateAllNow(): void {
185
+ for (const backend of LocalEmbeddingBackend.byModel.values()) {
186
+ try {
187
+ backend.terminateNow();
188
+ } catch (err) {
189
+ log.warn(
190
+ { err, model: backend.model },
191
+ "Failed to terminate local embedding worker",
192
+ );
193
+ }
194
+ }
195
+ }
196
+
197
+ private constructor(model: string) {
134
198
  this.model = model;
135
199
  }
136
200
 
@@ -193,19 +257,9 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
193
257
  }
194
258
  this.pendingRequests.set(id, { resolve });
195
259
 
196
- // Writing to a worker that has already exited raises EPIPE. That must
197
- // surface as an ordinary embed failure the caller can fall back from:
198
- // an escaping EPIPE reaches the daemon's `unhandledRejection` handler,
199
- // which tears down the whole process (JARVIS-1125).
200
- //
201
- // The guard covers `write` as well as `flush`: both are synchronous on
202
- // Bun's FileSink, and `write` is the call that raises the broken pipe.
203
- try {
204
- proc.stdin.write(JSON.stringify({ id, texts }) + "\n");
205
- proc.stdin.flush();
206
- } catch (err) {
207
- this.failPendingRequest(id, err);
208
- }
260
+ writeWorkerLine(proc.stdin, JSON.stringify({ id, texts }), (err) =>
261
+ this.failPendingRequest(id, err),
262
+ );
209
263
  });
210
264
  }
211
265
 
@@ -929,6 +983,7 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
929
983
  * the OS confirms the worker is gone.
930
984
  */
931
985
  async shutdown(): Promise<void> {
986
+ this.processExiting = true;
932
987
  this.disposeRequested = true;
933
988
 
934
989
  // An initialization already in flight has not necessarily assigned
@@ -113,25 +113,6 @@ export interface EmbeddingBackend {
113
113
  options?: EmbeddingRequestOptions,
114
114
  ): Promise<number[][]>;
115
115
  dispose?(): void;
116
- /**
117
- * Deterministic teardown for daemon shutdown: release any OS resources the
118
- * backend owns (e.g. a worker subprocess) and resolve only once they are
119
- * confirmed gone. Unlike {@link dispose}, which may defer while embeds are
120
- * in flight, this must not leave a child process behind.
121
- */
122
- shutdown?(): Promise<void>;
123
- /**
124
- * Release owned OS resources synchronously, for a process that must exit this
125
- * tick. Unlike {@link shutdown} it cannot wait for confirmation, so it uses
126
- * the uncatchable signal rather than the graceful one.
127
- */
128
- terminateNow?(): void;
129
- /**
130
- * Reap any owned child still attributable to this process, without needing a
131
- * handle for it. Backstop for a teardown that could not resolve its handle in
132
- * time.
133
- */
134
- sweepOwnedWorkers?(): Promise<void>;
135
116
  }
136
117
 
137
118
  /**
@@ -72,6 +72,7 @@ export type MemoryJobType =
72
72
  | "delete_message_lexical"
73
73
  | "backfill_lexical_index"
74
74
  | "skill_card_insert"
75
+ | "skill_update_receipt"
75
76
  | "memory_retrospective"
76
77
  | "memory_retrospective_sweep"
77
78
  // Retired/legacy — no live handler; persisted rows drop via LEGACY_JOB_TYPES.
@@ -379,6 +380,234 @@ function mergeSkillCardEntries(
379
380
  return merged;
380
381
  }
381
382
 
383
+ /**
384
+ * One rewrite of a managed skill by a background pass, as the receipt job's
385
+ * payload carries it. `entryId` is the producing tool call's, so a replayed
386
+ * call merges as a no-op; the conversation ids are what the receipt links
387
+ * to and what a conversation delete purges by.
388
+ */
389
+ export interface SkillUpdateReceiptEntry {
390
+ entryId: string;
391
+ skillId: string;
392
+ name: string;
393
+ changeSummary: string;
394
+ sourceConversationId?: string;
395
+ runConversationId: string;
396
+ createdAt: number;
397
+ }
398
+
399
+ /** Payload of a `skill_update_receipt` job: one burst's rewrites so far. */
400
+ export interface SkillUpdateReceiptJobPayload {
401
+ /** When the burst's earliest rewrite landed; the cap is measured from it. */
402
+ firstEntryAt: number;
403
+ /** When its latest rewrite landed; the quiet window is measured from it. */
404
+ lastEntryAt: number;
405
+ entries: SkillUpdateReceiptEntry[];
406
+ }
407
+
408
+ /**
409
+ * Upsert a pending `skill_update_receipt` job: the one open receipt for the
410
+ * burst of background skill rewrites in progress. Every append merges into
411
+ * the pending row rather than creating a duplicate; the receipt handler
412
+ * re-upserts the same payload when it defers, and merges into a sibling
413
+ * that opened while it evaluated (see `skill-update-receipt-job.ts`).
414
+ *
415
+ * Merge rules, each load-bearing: entries dedupe by `entryId`, with the
416
+ * pending entry winning so a replay changes nothing; `firstEntryAt` is the
417
+ * EARLIEST of the two, so a continuous stream of rewrites cannot ratchet the
418
+ * cap forward and hold the burst's first entries past their own cap;
419
+ * `lastEntryAt` is the latest; and `runAfter` is the earliest, mirroring
420
+ * `upsertMemoryRetrospectiveJob`. A running row never matches (its payload
421
+ * is frozen at claim), so an append during evaluation opens the sibling the
422
+ * handler merges into.
423
+ */
424
+ export function upsertSkillUpdateReceiptJob(
425
+ payload: SkillUpdateReceiptJobPayload,
426
+ runAfter: number = Date.now(),
427
+ ): void {
428
+ const db = memoryDb();
429
+ const existing = db
430
+ .select()
431
+ .from(memoryJobs)
432
+ .where(
433
+ and(
434
+ eq(memoryJobs.type, "skill_update_receipt"),
435
+ eq(memoryJobs.status, "pending"),
436
+ ),
437
+ )
438
+ .orderBy(asc(memoryJobs.createdAt))
439
+ .get();
440
+ if (existing) {
441
+ const existingPayload = parseSkillUpdateReceiptPayload(existing.payload);
442
+ const merged: SkillUpdateReceiptJobPayload = {
443
+ firstEntryAt: Math.min(
444
+ existingPayload?.firstEntryAt ?? payload.firstEntryAt,
445
+ payload.firstEntryAt,
446
+ ),
447
+ lastEntryAt: Math.max(
448
+ existingPayload?.lastEntryAt ?? payload.lastEntryAt,
449
+ payload.lastEntryAt,
450
+ ),
451
+ entries: mergeSkillUpdateReceiptEntries(
452
+ existingPayload?.entries ?? [],
453
+ payload.entries,
454
+ ),
455
+ };
456
+ db.update(memoryJobs)
457
+ .set({
458
+ payload: JSON.stringify(merged),
459
+ runAfter: Math.min(existing.runAfter, runAfter),
460
+ updatedAt: Date.now(),
461
+ })
462
+ .where(eq(memoryJobs.id, existing.id))
463
+ .run();
464
+ return;
465
+ }
466
+ enqueueMemoryJob(
467
+ "skill_update_receipt",
468
+ payload as unknown as Record<string, unknown>,
469
+ runAfter,
470
+ );
471
+ }
472
+
473
+ /**
474
+ * Validate a `skill_update_receipt` payload back into its typed shape, or
475
+ * null for a corrupted or foreign row. Entries missing a usable id, skill,
476
+ * name, summary, or run are dropped rather than failing the whole payload.
477
+ */
478
+ export function parseSkillUpdateReceiptPayload(
479
+ raw: unknown,
480
+ ): SkillUpdateReceiptJobPayload | null {
481
+ let payload: unknown = raw;
482
+ if (typeof raw === "string") {
483
+ try {
484
+ payload = JSON.parse(raw);
485
+ } catch {
486
+ return null;
487
+ }
488
+ }
489
+ if (!payload || typeof payload !== "object") {
490
+ return null;
491
+ }
492
+ const rec = payload as Record<string, unknown>;
493
+ if (
494
+ typeof rec.firstEntryAt !== "number" ||
495
+ typeof rec.lastEntryAt !== "number" ||
496
+ !Array.isArray(rec.entries)
497
+ ) {
498
+ return null;
499
+ }
500
+ return {
501
+ firstEntryAt: rec.firstEntryAt,
502
+ lastEntryAt: rec.lastEntryAt,
503
+ entries: mergeSkillUpdateReceiptEntries([], rec.entries),
504
+ };
505
+ }
506
+
507
+ function mergeSkillUpdateReceiptEntries(
508
+ existing: unknown,
509
+ incoming: unknown,
510
+ ): SkillUpdateReceiptEntry[] {
511
+ const merged: SkillUpdateReceiptEntry[] = [];
512
+ const seen = new Set<string>();
513
+ const candidates = [
514
+ ...(Array.isArray(existing) ? existing : []),
515
+ ...(Array.isArray(incoming) ? incoming : []),
516
+ ];
517
+ for (const entry of candidates) {
518
+ if (!entry || typeof entry !== "object") {
519
+ continue;
520
+ }
521
+ const rec = entry as Record<string, unknown>;
522
+ if (
523
+ typeof rec.entryId !== "string" ||
524
+ rec.entryId.length === 0 ||
525
+ typeof rec.skillId !== "string" ||
526
+ typeof rec.name !== "string" ||
527
+ typeof rec.changeSummary !== "string" ||
528
+ typeof rec.runConversationId !== "string" ||
529
+ typeof rec.createdAt !== "number" ||
530
+ seen.has(rec.entryId)
531
+ ) {
532
+ continue;
533
+ }
534
+ seen.add(rec.entryId);
535
+ merged.push({
536
+ entryId: rec.entryId,
537
+ skillId: rec.skillId,
538
+ name: rec.name,
539
+ changeSummary: rec.changeSummary,
540
+ runConversationId: rec.runConversationId,
541
+ createdAt: rec.createdAt,
542
+ ...(typeof rec.sourceConversationId === "string"
543
+ ? { sourceConversationId: rec.sourceConversationId }
544
+ : {}),
545
+ });
546
+ }
547
+ // Rewrite order is the receipt's order, and a merge into a sibling puts
548
+ // the older payload behind the newer one; the stamp restores it, with
549
+ // the id as a deterministic tie-break.
550
+ return merged.sort(
551
+ (a, b) => a.createdAt - b.createdAt || a.entryId.localeCompare(b.entryId),
552
+ );
553
+ }
554
+
555
+ /**
556
+ * Drop a deleted source conversation's entries from every pending
557
+ * `skill_update_receipt` row, deleting a row left with none. The plugin's
558
+ * `conversation-deleted` hook calls this beside its other per-conversation
559
+ * purges; the entries carry that conversation's id and a summary distilled
560
+ * from it. The run conversation is never a purge key: a run is an ephemeral
561
+ * fork that the next successful retrospective garbage-collects, and the
562
+ * handler reads a missing run as finished precisely so its entries still
563
+ * get announced. The burst's bounds are recomputed from what remains, so a
564
+ * dropped edge entry neither holds the rest past their quiet window nor
565
+ * caps them early.
566
+ */
567
+ export function removeSkillUpdateReceiptEntriesForConversation(
568
+ conversationId: string,
569
+ ): void {
570
+ const db = memoryDb();
571
+ const rows = db
572
+ .select()
573
+ .from(memoryJobs)
574
+ .where(
575
+ and(
576
+ eq(memoryJobs.type, "skill_update_receipt"),
577
+ eq(memoryJobs.status, "pending"),
578
+ ),
579
+ )
580
+ .all();
581
+ for (const row of rows) {
582
+ const payload = parseSkillUpdateReceiptPayload(row.payload);
583
+ if (!payload) {
584
+ continue;
585
+ }
586
+ const kept = payload.entries.filter(
587
+ (entry) => entry.sourceConversationId !== conversationId,
588
+ );
589
+ if (kept.length === payload.entries.length) {
590
+ continue;
591
+ }
592
+ if (kept.length === 0) {
593
+ db.delete(memoryJobs).where(eq(memoryJobs.id, row.id)).run();
594
+ continue;
595
+ }
596
+ const stamps = kept.map((entry) => entry.createdAt);
597
+ db.update(memoryJobs)
598
+ .set({
599
+ payload: JSON.stringify({
600
+ firstEntryAt: Math.min(...stamps),
601
+ lastEntryAt: Math.max(...stamps),
602
+ entries: kept,
603
+ } satisfies SkillUpdateReceiptJobPayload),
604
+ updatedAt: Date.now(),
605
+ })
606
+ .where(eq(memoryJobs.id, row.id))
607
+ .run();
608
+ }
609
+ }
610
+
382
611
  /**
383
612
  * Source-conversation ids of every PENDING `memory_retrospective` job. The
384
613
  * retrospective sweep skips these up front: a pending row already covers the
@@ -0,0 +1,44 @@
1
+ import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
2
+
3
+ /**
4
+ * Restamp openai-compatible rows whose endpoint host is opencode.ai (or a
5
+ * subdomain) as provider "opencode". The generic adapter never sends the
6
+ * session headers zen/go requires, so such rows fail every request; the
7
+ * create route applies the same normalization on write. Hosts are judged
8
+ * by URL parsing, not pattern matching, so a path that merely mentions
9
+ * opencode.ai is left alone. Idempotent: a restamped row no longer matches.
10
+ * Workspace migration 158 repoints legacy-shape profile fragments bound to
11
+ * these rows.
12
+ */
13
+ export function migrateNormalizeOpencodeHostConnections(
14
+ database: DrizzleDb,
15
+ ): void {
16
+ const raw = getSqliteFrom(database);
17
+
18
+ const rows = raw
19
+ .query(
20
+ `SELECT name, base_url FROM provider_connections
21
+ WHERE provider = 'openai-compatible' AND base_url IS NOT NULL`,
22
+ )
23
+ .all() as Array<{ name: string; base_url: string }>;
24
+
25
+ const restamp = raw.prepare(
26
+ `UPDATE provider_connections SET provider = 'opencode', updated_at = ? WHERE name = ?`,
27
+ );
28
+ const now = Date.now();
29
+ for (const row of rows) {
30
+ if (isOpenCodeHost(row.base_url)) {
31
+ restamp.run(now, row.name);
32
+ }
33
+ }
34
+ }
35
+
36
+ function isOpenCodeHost(baseUrl: string): boolean {
37
+ let host: string;
38
+ try {
39
+ host = new URL(baseUrl).hostname.toLowerCase();
40
+ } catch {
41
+ return false;
42
+ }
43
+ return host === "opencode.ai" || host.endsWith(".opencode.ai");
44
+ }
@@ -0,0 +1,102 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { describe, expect, test } from "bun:test";
3
+
4
+ import { drizzle } from "drizzle-orm/bun-sqlite";
5
+
6
+ import * as schema from "../../schema.js";
7
+ import { migrateNormalizeOpencodeHostConnections } from "../383-normalize-opencode-host-connections.js";
8
+
9
+ function createTestDb() {
10
+ const sqlite = new Database(":memory:");
11
+ sqlite.exec(/*sql*/ `
12
+ CREATE TABLE provider_connections (
13
+ name TEXT PRIMARY KEY,
14
+ provider TEXT NOT NULL,
15
+ auth TEXT NOT NULL,
16
+ label TEXT,
17
+ base_url TEXT,
18
+ models TEXT,
19
+ created_at INTEGER NOT NULL,
20
+ updated_at INTEGER NOT NULL
21
+ );
22
+ `);
23
+ return { sqlite, db: drizzle(sqlite, { schema }) };
24
+ }
25
+
26
+ function insertRow(
27
+ sqlite: Database,
28
+ name: string,
29
+ provider: string,
30
+ baseUrl: string | null,
31
+ ): void {
32
+ sqlite
33
+ .query(
34
+ /*sql*/ `INSERT INTO provider_connections (name, provider, auth, base_url, created_at, updated_at)
35
+ VALUES (?, ?, '{"type":"api_key","credential":"cred"}', ?, 1, 1)`,
36
+ )
37
+ .run(name, provider, baseUrl);
38
+ }
39
+
40
+ function readProvider(sqlite: Database, name: string): string | undefined {
41
+ const row = sqlite
42
+ .query(`SELECT provider FROM provider_connections WHERE name = ?`)
43
+ .get(name) as { provider: string } | null;
44
+ return row?.provider;
45
+ }
46
+
47
+ describe("migration 383: normalize opencode-host connections", () => {
48
+ test("restamps openai-compatible rows whose host is opencode.ai", () => {
49
+ const { sqlite, db } = createTestDb();
50
+ insertRow(
51
+ sqlite,
52
+ "go",
53
+ "openai-compatible",
54
+ "https://opencode.ai/zen/go/v1",
55
+ );
56
+ insertRow(sqlite, "zen", "openai-compatible", "https://opencode.ai/zen/v1");
57
+ insertRow(sqlite, "sub", "openai-compatible", "https://api.opencode.ai/v1");
58
+ insertRow(sqlite, "port", "openai-compatible", "https://opencode.ai:443");
59
+ insertRow(sqlite, "vllm", "openai-compatible", "http://localhost:8080/v1");
60
+ insertRow(sqlite, "no-url", "openai-compatible", null);
61
+ insertRow(
62
+ sqlite,
63
+ "lookalike",
64
+ "openai-compatible",
65
+ "https://opencode.ai.evil.example/v1",
66
+ );
67
+ insertRow(
68
+ sqlite,
69
+ "path-only",
70
+ "openai-compatible",
71
+ "https://proxy.example/cache/api.opencode.ai/v1",
72
+ );
73
+ insertRow(sqlite, "garbage", "openai-compatible", "not a url");
74
+ insertRow(sqlite, "ollama", "ollama", "https://opencode.ai/v1");
75
+
76
+ migrateNormalizeOpencodeHostConnections(db);
77
+
78
+ expect(readProvider(sqlite, "go")).toBe("opencode");
79
+ expect(readProvider(sqlite, "zen")).toBe("opencode");
80
+ expect(readProvider(sqlite, "sub")).toBe("opencode");
81
+ expect(readProvider(sqlite, "port")).toBe("opencode");
82
+ expect(readProvider(sqlite, "vllm")).toBe("openai-compatible");
83
+ expect(readProvider(sqlite, "no-url")).toBe("openai-compatible");
84
+ expect(readProvider(sqlite, "lookalike")).toBe("openai-compatible");
85
+ expect(readProvider(sqlite, "path-only")).toBe("openai-compatible");
86
+ expect(readProvider(sqlite, "garbage")).toBe("openai-compatible");
87
+ expect(readProvider(sqlite, "ollama")).toBe("ollama");
88
+ });
89
+
90
+ test("is idempotent", () => {
91
+ const { sqlite, db } = createTestDb();
92
+ insertRow(
93
+ sqlite,
94
+ "go",
95
+ "openai-compatible",
96
+ "https://opencode.ai/zen/go/v1",
97
+ );
98
+ migrateNormalizeOpencodeHostConnections(db);
99
+ migrateNormalizeOpencodeHostConnections(db);
100
+ expect(readProvider(sqlite, "go")).toBe("opencode");
101
+ });
102
+ });
@@ -493,6 +493,7 @@ import {
493
493
  migrateCreateConversationModeSessions,
494
494
  } from "./migrations/381-create-conversation-mode-sessions.js";
495
495
  import { migrateCreateClientConnectionEvents } from "./migrations/382-create-client-connection-events.js";
496
+ import { migrateNormalizeOpencodeHostConnections } from "./migrations/383-normalize-opencode-host-connections.js";
496
497
  import type { MigrationStep } from "./migrations/run-migrations.js";
497
498
 
498
499
  export const migrationSteps: MigrationStep[] = [
@@ -1649,4 +1650,15 @@ export const migrationSteps: MigrationStep[] = [
1649
1650
  ],
1650
1651
  },
1651
1652
  migrateCreateClientConnectionEvents,
1653
+ {
1654
+ name: "migrateNormalizeOpencodeHostConnections",
1655
+ run: migrateNormalizeOpencodeHostConnections,
1656
+ // Reads and rewrites provider_connections.base_url; the table and the
1657
+ // column come from these steps, so a database where either failed must
1658
+ // not checkpoint this one against nothing.
1659
+ dependsOn: [
1660
+ "migrateCreateProviderConnections",
1661
+ "migrateProviderConnectionBaseUrlAndModels",
1662
+ ],
1663
+ },
1652
1664
  ];
@@ -709,6 +709,35 @@ describe("syncAvatarToPlatform", () => {
709
709
  expect(patches).toHaveLength(1);
710
710
  });
711
711
 
712
+ test("an older rendering spec re-uploads the same avatar once", async () => {
713
+ mockResvgAvailable = true;
714
+ syncAvatarToPlatform();
715
+ await settle();
716
+ const current = JSON.parse(readFileSync(syncStatePath, "utf-8"));
717
+ const previous = {
718
+ ...current,
719
+ key: current.key.replace(
720
+ `:${NOTIFICATION_AVATAR_SPEC_VERSION}:`,
721
+ `:${NOTIFICATION_AVATAR_SPEC_VERSION - 1}:`,
722
+ ),
723
+ };
724
+ expect(previous.key).not.toBe(current.key);
725
+ writeFileSync(syncStatePath, JSON.stringify(previous));
726
+
727
+ _resetSyncAvatarStateForTests();
728
+ syncAvatarToPlatform();
729
+ await settle();
730
+ _resetSyncAvatarStateForTests();
731
+ syncAvatarToPlatform();
732
+ await settle();
733
+
734
+ expect(patches).toHaveLength(2);
735
+ expect(patches[1].body.notification_avatar_base64).toBeDefined();
736
+ expect(JSON.parse(readFileSync(syncStatePath, "utf-8")).key).toBe(
737
+ current.key,
738
+ );
739
+ });
740
+
712
741
  test("a restart re-uploads when the destination or raster changed", async () => {
713
742
  syncAvatarToPlatform();
714
743
  await settle();
@@ -1,4 +1,4 @@
1
- import { getUserSelectableProfilesForProvider } from "../config/default-profile-catalog.js";
1
+ import { getConversationProfilesForProvider } from "../config/default-profile-catalog.js";
2
2
  import { getConfig } from "../config/loader.js";
3
3
  import { isDispatchableProfile } from "../config/profile-dispatchability.js";
4
4
  import { orderProfileKeys } from "../config/profile-order.js";
@@ -7,7 +7,9 @@ import type { ModelProfileInfo } from "./types.js";
7
7
  /**
8
8
  * List the workspace inference profiles a plugin can route to, in the order a
9
9
  * user-facing model picker presents them (`llm.profileOrder` first, then the
10
- * rest alphabetically). Managed backup routes are internal and omitted.
10
+ * rest alphabetically). Managed backup routes and the managed Jev profile
11
+ * are omitted with the rest of the conversation view: a plugin routes
12
+ * conversation turns, and a decision model cannot answer them.
11
13
  * Metadata-only entries without a provider, model, or mix are not routing
12
14
  * targets, so plugins never see them. Disabled profiles are included and flagged via
13
15
  * {@link ModelProfileInfo.isDisabled}; weighted "mix" profiles are included and
@@ -20,7 +22,7 @@ import type { ModelProfileInfo } from "./types.js";
20
22
  export function getModelProfiles(): ModelProfileInfo[] {
21
23
  const { llm } = getConfig();
22
24
  const { activeProfile } = llm;
23
- const profiles = getUserSelectableProfilesForProvider(
25
+ const profiles = getConversationProfilesForProvider(
24
26
  llm.profiles,
25
27
  llm.defaultProvider ?? null,
26
28
  );
@@ -82,8 +82,10 @@ Everything else under the plugin root is **spine**:
82
82
  `conversation-memory-orphan-sweep`, `fork-conversation-memory`,
83
83
  `find-most-recent-retrospective-for`.
84
84
  - The `memory-retrospective-*` family — tier-agnostic, gated on
85
- `memory.enabled` alone. The one exception is
86
- `memory-retrospective-skill-card.ts`, whose upstream enqueue only fires when
85
+ `memory.enabled` alone. The exceptions are
86
+ `memory-retrospective-skill-card.ts` and `skill-update-receipt-job.ts` (one
87
+ pending job collects a burst of background skill rewrites and announces it
88
+ once it settles), whose upstream producers only fire when
87
89
  `isV3TierActive()` holds (memory on and v3 live).
88
90
  - The rest of the spine: `startup.ts`, `jobs-worker.ts`, `job-handlers.ts`,
89
91
  `job-handler-registration.ts`, `indexer.ts`, `injectors.ts`, `tools.ts`,
@@ -499,7 +501,7 @@ Persisted in job rows; unknown types either throw or get silently drained.
499
501
  - v2 engine: `memory_v2_migrate`, `memory_v2_activation_recompute`
500
502
  - v3: `memory_v3_maintain`
501
503
  - Retrospective: `memory_retrospective`, `memory_retrospective_sweep`,
502
- `skill_card_insert`
504
+ `skill_card_insert`, `skill_update_receipt`
503
505
  - v1: `graph_extract`, `graph_decay`, `graph_consolidate`, `graph_pattern_scan`,
504
506
  `graph_narrative_refine`, `graph_bootstrap`, `embed_pkb_file`, `pkb_filing`,
505
507
  `pkb_compaction`, `rebuild_index`, `backfill`, `delete_qdrant_vectors`,
@@ -238,6 +238,7 @@ describe("memory-retrospective skill card", () => {
238
238
  metadata: { kind: "skill-authored-card", automated: true },
239
239
  skipIndexing: true,
240
240
  clientMessageId: "skill-card-run-conv-1",
241
+ skipResurface: true,
241
242
  });
242
243
  // Disk view sync mirrors the wake-persist path, then clients are told to
243
244
  // refetch the message list.
@@ -423,6 +424,7 @@ describe("memory-retrospective skill card", () => {
423
424
  metadata: { kind: "skill-authored-card", automated: true },
424
425
  skipIndexing: true,
425
426
  clientMessageId: "skill-card-run-conv-1",
427
+ skipResurface: true,
426
428
  });
427
429
  expect(syncedToDisk).toHaveLength(1);
428
430
  expect(publishedConversationIds).toEqual(["src-conv-9"]);