@vellumai/assistant 0.12.4 → 0.12.5-dev.202609251017.e467a6c

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 (281) hide show
  1. package/docs/architecture/memory.md +9 -1
  2. package/docs/architecture/scheduling.md +13 -5
  3. package/docs/desktop-browser-cli.md +3 -2
  4. package/docs/guardian-request-flow.md +1 -1
  5. package/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +48 -4
  6. package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +12 -8
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/guardian-requests.test.ts +26 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +77 -3
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/guardian-requests.test.ts +26 -0
  10. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +77 -3
  11. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +2 -4
  12. package/node_modules/@vellumai/service-contracts/src/__tests__/guardian-requests.test.ts +26 -0
  13. package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +77 -3
  14. package/openapi.yaml +152 -3
  15. package/package.json +1 -1
  16. package/src/__tests__/agent-loop.test.ts +76 -0
  17. package/src/__tests__/anthropic-error-formatting.test.ts +9 -0
  18. package/src/__tests__/background-shell-bash.test.ts +34 -0
  19. package/src/__tests__/background-shell-host-bash.test.ts +98 -1
  20. package/src/__tests__/background-tool-registry.test.ts +39 -0
  21. package/src/__tests__/channel-readiness-discord.test.ts +8 -0
  22. package/src/__tests__/channel-readiness-slack-remote.test.ts +8 -0
  23. package/src/__tests__/client-os-metadata-persistence.test.ts +4 -20
  24. package/src/__tests__/conversation-agent-loop.test.ts +529 -1
  25. package/src/__tests__/conversation-error.test.ts +18 -0
  26. package/src/__tests__/conversation-pairing.test.ts +69 -0
  27. package/src/__tests__/conversation-queue.test.ts +257 -4
  28. package/src/__tests__/conversation-runtime-assembly.test.ts +27 -0
  29. package/src/__tests__/emit-signal-routing-intent.test.ts +62 -0
  30. package/src/__tests__/evict-conversations-for-reload.test.ts +21 -0
  31. package/src/__tests__/gemini-provider.test.ts +9 -0
  32. package/src/__tests__/host-cu-proxy.test.ts +79 -0
  33. package/src/__tests__/host-cu-routes-targeted.test.ts +14 -5
  34. package/src/__tests__/introduction-card-resolver.test.ts +53 -8
  35. package/src/__tests__/job-handler-registration-guard.test.ts +1 -0
  36. package/src/__tests__/non-member-access-request.test.ts +48 -0
  37. package/src/__tests__/notification-events-source-context-probe.test.ts +48 -1
  38. package/src/__tests__/notification-platform-adapter.test.ts +97 -0
  39. package/src/__tests__/notification-vellum-adapter.test.ts +103 -3
  40. package/src/__tests__/platform-callback-registration.test.ts +46 -1
  41. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  42. package/src/__tests__/scaffold-managed-skill-tool.test.ts +116 -66
  43. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -0
  44. package/src/__tests__/scheduler-result-notification.test.ts +521 -7
  45. package/src/__tests__/scheduler-wake.test.ts +1 -0
  46. package/src/__tests__/skill-load-inline-command.test.ts +35 -0
  47. package/src/__tests__/subagent-cron-run-attribution.test.ts +41 -2
  48. package/src/__tests__/subagent-manager-notify.test.ts +48 -0
  49. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +6 -6
  50. package/src/__tests__/usage-attribution.test.ts +31 -0
  51. package/src/__tests__/wake-schedule-escalation.test.ts +1 -0
  52. package/src/agent/loop.ts +23 -0
  53. package/src/api/ask-question-tool.ts +50 -0
  54. package/src/api/events/generation-handoff.ts +4 -0
  55. package/src/api/events/message-complete.ts +4 -0
  56. package/src/api/events/tool-result.ts +11 -0
  57. package/src/api/index.ts +11 -0
  58. package/src/api/requests/guardian-actions.test.ts +45 -0
  59. package/src/api/requests/guardian-actions.ts +20 -0
  60. package/src/api/responses/conversation-message.ts +4 -0
  61. package/src/api/responses/home.ts +27 -0
  62. package/src/approvals/guardian-decision-primitive.ts +6 -4
  63. package/src/approvals/guardian-request-resolvers.ts +35 -30
  64. package/src/avatar/notification-avatar.test.ts +42 -34
  65. package/src/avatar/notification-avatar.ts +1 -0
  66. package/src/calls/__tests__/call-controller.test.ts +18 -0
  67. package/src/calls/__tests__/voice-control-protocol.test.ts +6 -1
  68. package/src/calls/__tests__/voice-leg-coordinator.test.ts +70 -0
  69. package/src/calls/__tests__/voice-session-bridge.test.ts +116 -4
  70. package/src/calls/__tests__/voice-triage-escalate.test.ts +77 -11
  71. package/src/calls/call-controller.ts +3 -1
  72. package/src/calls/voice-control-protocol.ts +12 -4
  73. package/src/calls/voice-escalation-judge.ts +2 -2
  74. package/src/calls/voice-leg-coordinator.ts +23 -5
  75. package/src/calls/voice-screen-annotation.ts +44 -0
  76. package/src/calls/voice-session-bridge.ts +63 -20
  77. package/src/calls/voice-triage-escalate.ts +82 -27
  78. package/src/cli/commands/__tests__/conversations-clear.test.ts +156 -0
  79. package/src/cli/commands/conversations.help.ts +7 -2
  80. package/src/cli/commands/conversations.ts +36 -30
  81. package/src/cli/commands/oauth/index.help.ts +8 -2
  82. package/src/cli/commands/oauth/request.test.ts +32 -0
  83. package/src/cli/commands/platform/index.help.ts +2 -1
  84. package/src/cli/commands/platform/index.ts +8 -2
  85. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +60 -0
  86. package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +66 -2
  87. package/src/cli/lib/bundled-marketplace.json +35 -0
  88. package/src/cli/lib/bundled-plugin-packages.json +21 -0
  89. package/src/cli/lib/plugin-catalog-cache.ts +17 -2
  90. package/src/cli/lib/plugin-catalog-platform.ts +48 -9
  91. package/src/config/__tests__/default-profile-catalog.test.ts +28 -0
  92. package/src/config/__tests__/platform-identity.test.ts +59 -0
  93. package/src/config/__tests__/profile-order.test.ts +19 -0
  94. package/src/config/__tests__/sync-gated-profiles.test.ts +71 -0
  95. package/src/config/bundled-skills/computer-use/SKILL.md +20 -0
  96. package/src/config/bundled-skills/computer-use/TOOLS.json +11 -11
  97. package/src/config/bundled-skills/screen-annotation/SKILL.md +41 -29
  98. package/src/config/bundled-skills/screen-annotation/TOOLS.json +5 -5
  99. package/src/config/call-site-defaults.ts +6 -0
  100. package/src/config/default-profile-catalog.ts +30 -1
  101. package/src/config/default-profile-names.ts +36 -2
  102. package/src/config/feature-flag-registry.json +9 -1
  103. package/src/config/platform-identity.ts +22 -5
  104. package/src/config/profile-order.ts +7 -1
  105. package/src/config/schemas/call-site-catalog.ts +7 -0
  106. package/src/config/schemas/llm.ts +21 -5
  107. package/src/config/sync-gated-profiles.ts +96 -44
  108. package/src/daemon/__tests__/abort-null-controller.test.ts +135 -1
  109. package/src/daemon/__tests__/auto-profile-router.test.ts +380 -0
  110. package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +16 -0
  111. package/src/daemon/activity-trail.test.ts +206 -0
  112. package/src/daemon/activity-trail.ts +190 -0
  113. package/src/daemon/auto-profile-router.ts +431 -0
  114. package/src/daemon/computer-use-observation.test.ts +81 -0
  115. package/src/daemon/computer-use-observation.ts +23 -0
  116. package/src/daemon/conversation-agent-loop-handlers.ts +11 -0
  117. package/src/daemon/conversation-agent-loop.ts +157 -32
  118. package/src/daemon/conversation-error.ts +19 -0
  119. package/src/daemon/conversation-lifecycle.ts +70 -7
  120. package/src/daemon/conversation-media-retry.ts +1 -2
  121. package/src/daemon/conversation-messaging.ts +31 -2
  122. package/src/daemon/conversation-process.ts +107 -16
  123. package/src/daemon/conversation-queue-manager.ts +7 -0
  124. package/src/daemon/conversation-runtime-assembly.ts +1 -0
  125. package/src/daemon/conversation-turn-finalize.ts +14 -0
  126. package/src/daemon/conversation.ts +25 -6
  127. package/src/daemon/event-loop-watchdog.test.ts +201 -2
  128. package/src/daemon/event-loop-watchdog.ts +182 -6
  129. package/src/daemon/host-cu-proxy.ts +34 -3
  130. package/src/daemon/pkb-reminder-builder.test.ts +10 -3
  131. package/src/daemon/pkb-reminder-builder.ts +1 -1
  132. package/src/daemon/wake-conversation-ops.ts +4 -2
  133. package/src/desktop/desktop-computer-use.test.ts +14 -4
  134. package/src/home/feed-types.ts +2 -0
  135. package/src/inbound/platform-callback-registration.ts +19 -6
  136. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +27 -0
  137. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +83 -0
  138. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +48 -1
  139. package/src/live-voice/__tests__/live-voice-vad.test.ts +34 -2
  140. package/src/live-voice/__tests__/protocol.test.ts +22 -0
  141. package/src/live-voice/continuation-judge.ts +1 -1
  142. package/src/live-voice/live-voice-session.ts +25 -19
  143. package/src/live-voice/protocol.ts +16 -5
  144. package/src/messaging/providers/discord/withdraw.ts +4 -5
  145. package/src/messaging/providers/gmail/client.ts +4 -0
  146. package/src/messaging/providers/slack/withdraw.ts +5 -5
  147. package/src/messaging/providers/telegram-bot/withdraw.ts +4 -5
  148. package/src/monitoring/__tests__/proc-fixtures.ts +28 -0
  149. package/src/monitoring/__tests__/proc-wait-state.test.ts +232 -0
  150. package/src/monitoring/__tests__/process-usage.test.ts +243 -0
  151. package/src/monitoring/proc-wait-state.ts +366 -0
  152. package/src/monitoring/process-memory.ts +4 -3
  153. package/src/monitoring/process-usage.ts +215 -0
  154. package/src/monitoring/resource-sample-types.ts +8 -0
  155. package/src/monitoring/resource-sampler.ts +17 -2
  156. package/src/monitoring/stall-capture.ts +12 -0
  157. package/src/notifications/README.md +88 -9
  158. package/src/notifications/__tests__/assistant-reply-producer.test.ts +390 -119
  159. package/src/notifications/__tests__/background-result-producer.test.ts +1272 -0
  160. package/src/notifications/__tests__/broadcaster.test.ts +292 -1
  161. package/src/notifications/__tests__/completion-policy.test.ts +243 -0
  162. package/src/notifications/__tests__/decision-engine.test.ts +51 -4
  163. package/src/notifications/__tests__/has-pending-background-work.test.ts +281 -0
  164. package/src/notifications/__tests__/home-feed-side-effect.test.ts +161 -3
  165. package/src/notifications/__tests__/resolve-visible-in-source.test.ts +110 -1
  166. package/src/notifications/__tests__/schedule-result-producer.test.ts +11 -4
  167. package/src/notifications/adapters/macos.ts +24 -12
  168. package/src/notifications/adapters/platform.ts +16 -5
  169. package/src/notifications/assistant-reply-producer.ts +96 -71
  170. package/src/notifications/background-result-producer.ts +369 -0
  171. package/src/notifications/broadcaster.ts +28 -8
  172. package/src/notifications/completion-policy.ts +115 -0
  173. package/src/notifications/completion-work.ts +46 -0
  174. package/src/notifications/conversation-pairing.ts +32 -13
  175. package/src/notifications/decision-engine.ts +18 -24
  176. package/src/notifications/emit-signal.ts +16 -1
  177. package/src/notifications/events-store.ts +4 -0
  178. package/src/notifications/has-pending-background-work.ts +91 -0
  179. package/src/notifications/home-feed-mirror.ts +44 -0
  180. package/src/notifications/home-feed-side-effect.ts +54 -28
  181. package/src/notifications/resolve-visible-in-source.ts +56 -2
  182. package/src/notifications/result-output.ts +154 -0
  183. package/src/notifications/schedule-result-producer.ts +6 -187
  184. package/src/notifications/types.ts +2 -0
  185. package/src/oauth/AGENTS.md +9 -1
  186. package/src/oauth/byo-connection.test.ts +28 -0
  187. package/src/persistence/__tests__/recent-conversation-messages.test.ts +41 -0
  188. package/src/persistence/conversation-crud.ts +26 -0
  189. package/src/persistence/conversation-types.ts +15 -37
  190. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +85 -14
  191. package/src/persistence/jobs-store.ts +229 -0
  192. package/src/persistence/migrations/383-normalize-opencode-host-connections.ts +44 -0
  193. package/src/persistence/migrations/__tests__/383-normalize-opencode-host-connections.test.ts +102 -0
  194. package/src/persistence/steps.ts +12 -0
  195. package/src/platform/sync-avatar.test.ts +29 -0
  196. package/src/plugins/defaults/memory/AGENTS.md +5 -3
  197. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-boundary-guard.test.ts +45 -0
  198. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-job.test.ts +580 -0
  199. package/src/plugins/defaults/memory/__tests__/turn-retrieval.test.ts +21 -0
  200. package/src/plugins/defaults/memory/graph/tools.ts +9 -5
  201. package/src/plugins/defaults/memory/hooks/conversation-deleted.ts +4 -0
  202. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +7 -3
  203. package/src/plugins/defaults/memory/job-handlers.ts +8 -2
  204. package/src/plugins/defaults/memory/skill-update-receipt-job.ts +334 -0
  205. package/src/plugins/defaults/memory/tools.test.ts +17 -0
  206. package/src/plugins/defaults/memory/turn-retrieval.ts +8 -0
  207. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +24 -0
  208. package/src/plugins/defaults/memory/v3/injector.ts +4 -4
  209. package/src/plugins/types.ts +2 -0
  210. package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
  211. package/src/providers/anthropic/client.ts +4 -0
  212. package/src/providers/gemini/client.ts +7 -1
  213. package/src/providers/inference/auth.ts +25 -0
  214. package/src/providers/inference/profile-probe.ts +1 -0
  215. package/src/{calls/__tests__/typesafe-noul.test.ts → providers/jev/ask.test.ts} +4 -4
  216. package/src/{calls/typesafe-noul.ts → providers/jev/ask.ts} +67 -34
  217. package/src/providers/openai/__tests__/api-error-normalization.test.ts +12 -0
  218. package/src/providers/openai/api-error-normalization.ts +4 -0
  219. package/src/providers/retry.ts +2 -0
  220. package/src/providers/search-provider-catalog.ts +10 -0
  221. package/src/providers/types.ts +7 -0
  222. package/src/providers/usage-tracking.ts +5 -3
  223. package/src/runtime/__tests__/agent-wake.test.ts +308 -0
  224. package/src/runtime/access-request-helper.ts +16 -9
  225. package/src/runtime/agent-wake-queue.ts +52 -0
  226. package/src/runtime/agent-wake.ts +111 -18
  227. package/src/runtime/channel-approval-types.ts +6 -38
  228. package/src/runtime/channel-approvals.ts +4 -5
  229. package/src/runtime/http-server.ts +4 -1
  230. package/src/runtime/introduction-policy.test.ts +17 -1
  231. package/src/runtime/introduction-policy.ts +25 -7
  232. package/src/runtime/migrations/vbundle-builder.ts +9 -0
  233. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +21 -1
  234. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +60 -0
  235. package/src/runtime/routes/__tests__/oauth-proxy-routes.test.ts +61 -3
  236. package/src/runtime/routes/auto-profile-routes.ts +83 -0
  237. package/src/runtime/routes/conversation-management-routes.ts +1 -3
  238. package/src/runtime/routes/conversation-routes.ts +9 -0
  239. package/src/runtime/routes/guardian-action-routes.ts +8 -23
  240. package/src/runtime/routes/host-cu-routes.ts +3 -1
  241. package/src/runtime/routes/index.ts +2 -0
  242. package/src/runtime/routes/inference-profiles-routes.ts +5 -2
  243. package/src/runtime/routes/inference-provider-connection-routes.ts +25 -8
  244. package/src/runtime/routes/migration-routes.ts +8 -2
  245. package/src/runtime/routes/oauth-proxy-passthrough.test.ts +32 -0
  246. package/src/runtime/routes/platform-routes.ts +6 -1
  247. package/src/runtime/routes/user-route-dispatcher.ts +1 -2
  248. package/src/runtime/web-presence.ts +3 -4
  249. package/src/schedule/schedule-store.ts +10 -0
  250. package/src/schedule/scheduler.ts +92 -24
  251. package/src/subagent/manager.ts +32 -12
  252. package/src/telegram/__tests__/webhook-health.test.ts +8 -0
  253. package/src/telemetry/telemetry-wire-source.json +1 -1
  254. package/src/telemetry/telemetry-wire.generated.ts +1 -0
  255. package/src/tools/background-tool-registry.ts +30 -3
  256. package/src/tools/browser/cdp-client/local-cdp-client.ts +1 -2
  257. package/src/tools/host-terminal/host-shell.ts +7 -0
  258. package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +28 -1
  259. package/src/tools/network/__tests__/web-fetch-metadata.test.ts +34 -0
  260. package/src/tools/network/__tests__/web-fetch-tinyfish.test.ts +35 -1
  261. package/src/tools/network/__tests__/web-search.test.ts +96 -0
  262. package/src/tools/network/web-fetch.ts +30 -9
  263. package/src/tools/network/web-search.ts +235 -4
  264. package/src/tools/skills/load.ts +13 -17
  265. package/src/tools/skills/scaffold-managed.ts +86 -71
  266. package/src/tools/terminal/shell-launch.test.ts +27 -1
  267. package/src/tools/terminal/shell.ts +4 -0
  268. package/src/tools/workspace-tools/loader.ts +1 -2
  269. package/src/usage/attribution.ts +18 -1
  270. package/src/util/abort-reasons.ts +2 -0
  271. package/src/util/errors.ts +1 -0
  272. package/src/util/oauth-request-body.test.ts +5 -1
  273. package/src/util/provider-error-patterns.ts +11 -0
  274. package/src/util/single-flight.test.ts +6 -0
  275. package/src/util/single-flight.ts +2 -0
  276. package/src/util/worker-pipe.test.ts +56 -10
  277. package/src/util/worker-pipe.ts +9 -4
  278. package/src/workspace/git-service.ts +6 -1
  279. package/src/workspace/migrations/158-repoint-opencode-host-provider-fragments.ts +163 -0
  280. package/src/workspace/migrations/__tests__/158-repoint-opencode-host-provider-fragments.test.ts +188 -0
  281. package/src/workspace/migrations/registry.ts +2 -0
@@ -54,6 +54,62 @@ function brokenPipeProc(pid = 4242) {
54
54
  };
55
55
  }
56
56
 
57
+ /**
58
+ * A stand-in for a Bun subprocess whose stdin write outruns the pipe buffer:
59
+ * `write` and `flush` hand back one pending promise, and killing the worker
60
+ * rejects it with EPIPE, the way Bun settles a pending write once the child
61
+ * is reaped.
62
+ */
63
+ function pendingWriteProc(pid = 4244) {
64
+ const pending = Promise.withResolvers<number>();
65
+ return {
66
+ pid,
67
+ killed: false,
68
+ exited: Promise.resolve(0),
69
+ kill() {
70
+ this.killed = true;
71
+ pending.reject(
72
+ Object.assign(new Error("EPIPE: broken pipe, write"), {
73
+ code: "EPIPE",
74
+ }),
75
+ );
76
+ },
77
+ stdin: {
78
+ write: () => pending.promise,
79
+ flush: () => pending.promise,
80
+ },
81
+ };
82
+ }
83
+
84
+ /**
85
+ * A subprocess whose stdout the test closes, to drive the reader loop that
86
+ * settles requests the pipe never reported a failure for.
87
+ */
88
+ function stdoutProc(pid = 4245) {
89
+ let closeStdout: () => void = () => {};
90
+ const stdout = new ReadableStream<Uint8Array>({
91
+ start(controller) {
92
+ closeStdout = () => controller.close();
93
+ },
94
+ });
95
+ let resolveExit: (code: number) => void = () => {};
96
+ const exited = new Promise<number>((r) => {
97
+ resolveExit = r;
98
+ });
99
+ return {
100
+ pid,
101
+ killed: false,
102
+ exited,
103
+ stdout,
104
+ closeStdout: () => closeStdout(),
105
+ kill() {
106
+ this.killed = true;
107
+ resolveExit(0);
108
+ },
109
+ stdin: { write: () => 0, flush: () => 0 },
110
+ };
111
+ }
112
+
57
113
  /** A subprocess that stays "alive" until killed, so exit can be observed. */
58
114
  function liveProc(pid = 4243) {
59
115
  let resolveExit: (code: number) => void = () => {};
@@ -263,31 +319,46 @@ describe("broken worker pipe", () => {
263
319
  });
264
320
 
265
321
  /**
266
- * A real pipe, because the failure lives in Bun's behaviour rather than in
267
- * ours: a batch larger than the pipe buffer, sent to a worker too busy to
268
- * drain stdin, leaves the write pending, and the pending write rejects when
269
- * the worker dies. A synchronous guard never sees that rejection.
322
+ * A batch larger than the pipe buffer, sent to a worker too busy to drain
323
+ * stdin, leaves the write pending, and the pending write rejects after
324
+ * `sendRequest` has returned, once the worker dies. A synchronous guard
325
+ * never sees that rejection.
270
326
  */
271
327
  test("a write still pending when the worker dies resolves the request as an error", async () => {
272
328
  const backend = newBackend();
273
- const proc = Bun.spawn({
274
- cmd: [process.execPath, "-e", "setTimeout(() => {}, 60_000)"],
275
- windowsHide: true,
276
- stdin: "pipe",
277
- stdout: "ignore",
278
- stderr: "ignore",
279
- });
280
- spawned.push(proc);
329
+ const proc = pendingWriteProc();
281
330
  backend.workerProc = proc;
282
331
 
283
- const request = backend.sendRequest(["x".repeat(4 * 1024 * 1024)]);
284
- proc.kill("SIGKILL");
332
+ const request = backend.sendRequest(["hello"]);
333
+ proc.kill();
285
334
  const response = await request;
286
335
 
287
336
  expect(response.error).toContain("worker pipe write failed");
288
337
  expect(backend.pendingRequests.size).toBe(0);
289
338
  });
290
339
 
340
+ /**
341
+ * Bun does not always report a dead worker through the write: on Linux it
342
+ * occasionally resolves the pending write with a short count instead of
343
+ * rejecting it, and `writeWorkerLine` then stays silent. Nothing times a
344
+ * request out, so the stdout reader ending is the only thing that settles
345
+ * it.
346
+ */
347
+ test("stdout ending settles a request the pipe never reported a failure for", async () => {
348
+ const backend = newBackend();
349
+ const proc = stdoutProc();
350
+ backend.workerProc = proc;
351
+ backend.startStdoutReader();
352
+
353
+ const inFlight = backend.sendRequest(["hello"]);
354
+ proc.closeStdout();
355
+
356
+ await expect(inFlight).resolves.toMatchObject({
357
+ error: expect.stringContaining("exited unexpectedly"),
358
+ });
359
+ expect(backend.pendingRequests.size).toBe(0);
360
+ });
361
+
291
362
  test("a failed write does not leak the pending request", async () => {
292
363
  const backend = newBackend();
293
364
  backend.workerProc = brokenPipeProc();
@@ -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();
@@ -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`,
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The receipt job announces a receipt through the notification pipeline and
3
+ * reads what the pipeline did; it never writes the home feed itself and
4
+ * never releases a dedupe key the pipeline kept. A verdict the pipeline
5
+ * reached (a block, a suppression, a no-notify decision) is the pipeline's to
6
+ * make, and a home-feed row written from here would be a notification the
7
+ * routing declined. This guard pins the import surface that keeps that so.
8
+ */
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { describe, expect, test } from "bun:test";
13
+
14
+ const MODULE_DIR = join(import.meta.dir, "..");
15
+
16
+ function importedNames(file: string): string[] {
17
+ const source = readFileSync(join(MODULE_DIR, file), "utf-8");
18
+ const names: string[] = [];
19
+ for (const match of source.matchAll(
20
+ /import\s*(?:type\s*)?\{([^}]*)\}\s*from/g,
21
+ )) {
22
+ for (const raw of match[1]!.split(",")) {
23
+ const name = raw
24
+ .replace(/^\s*type\s+/, "")
25
+ .split(/\s+as\s+/)[0]!
26
+ .trim();
27
+ if (name) {
28
+ names.push(name);
29
+ }
30
+ }
31
+ }
32
+ return names;
33
+ }
34
+
35
+ describe("skill-update receipt boundary", () => {
36
+ for (const file of ["skill-update-receipt-job.ts"]) {
37
+ test(`${file} neither writes the home feed nor releases a dedupe key`, () => {
38
+ const names = importedNames(file);
39
+ expect(names).not.toContain("appendFeedItem");
40
+ expect(names).not.toContain("patchFeedItemContent");
41
+ expect(names).not.toContain("writeHomeFeedItemForSignal");
42
+ expect(names).not.toContain("setEventDedupeKey");
43
+ });
44
+ }
45
+ });