@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
@@ -35,6 +35,7 @@ import type {
35
35
  ToolDefinition,
36
36
  ToolExecutionResult,
37
37
  } from "../types.js";
38
+ import { formatSkillInputSchema } from "./format-input-schema.js";
38
39
 
39
40
  /** Skill sources eligible for inline command expansion in v1. */
40
41
  const INLINE_COMMAND_ELIGIBLE_SOURCES = new Set([
@@ -123,32 +124,7 @@ function formatToolSchemas(
123
124
  tool.description.replaceAll("{workspaceDir}", getWorkspaceDirDisplay()),
124
125
  );
125
126
 
126
- const schema = tool.input_schema;
127
- const properties = schema.properties as
128
- | Record<string, Record<string, unknown>>
129
- | undefined;
130
- if (properties && Object.keys(properties).length > 0) {
131
- const requiredSet = new Set<string>(
132
- Array.isArray(schema.required) ? (schema.required as string[]) : [],
133
- );
134
-
135
- lines.push("Parameters:");
136
- for (const [paramName, paramDef] of Object.entries(properties)) {
137
- const paramType =
138
- typeof paramDef.type === "string" ? paramDef.type : "any";
139
- const requiredLabel = requiredSet.has(paramName)
140
- ? "required"
141
- : "optional";
142
- const descPart =
143
- typeof paramDef.description === "string"
144
- ? `: ${paramDef.description.replaceAll("{workspaceDir}", getWorkspaceDirDisplay())}`
145
- : "";
146
- lines.push(
147
- `- ${paramName} (${paramType}, ${requiredLabel})${descPart}`,
148
- );
149
- }
150
- }
151
-
127
+ lines.push(formatSkillInputSchema(tool.input_schema));
152
128
  lines.push("");
153
129
  }
154
130
 
@@ -181,6 +157,7 @@ export const skillLoadTool = {
181
157
  async execute(
182
158
  input: Record<string, unknown>,
183
159
  context: ToolContext,
160
+ options?: { readOnly?: boolean },
184
161
  ): Promise<ToolExecutionResult> {
185
162
  const selector = input.skill;
186
163
  if (typeof selector !== "string" || selector.trim().length === 0) {
@@ -195,13 +172,17 @@ export const skillLoadTool = {
195
172
  // command execution. Instructions are still returned so the assistant can
196
173
  // load the system-storage-cleanup skill (and any already-installed skill).
197
174
  const cleanupMode = context.diskPressureCleanupModeActive === true;
175
+ const readOnly = cleanupMode || options?.readOnly === true;
176
+ const inlineCommandStub = cleanupMode
177
+ ? INLINE_COMMAND_CLEANUP_STUB
178
+ : "[inline command not executed]";
198
179
 
199
180
  let loaded = loadSkillBySelector(selector);
200
181
 
201
182
  // Auto-install from catalog if the skill isn't found locally
202
183
  if (
203
184
  !loaded.skill &&
204
- !cleanupMode &&
185
+ !readOnly &&
205
186
  (loaded.errorCode === "not_found" || loaded.errorCode === "empty_catalog")
206
187
  ) {
207
188
  try {
@@ -297,9 +278,8 @@ export const skillLoadTool = {
297
278
  break;
298
279
  }
299
280
 
300
- // Under the disk-pressure lock, never auto-install missing includes
301
- // (that writes to the workspace). Leave them advisory ("not loaded").
302
- if (cleanupMode) {
281
+ // Read-only loads leave missing includes advisory ("not loaded").
282
+ if (readOnly) {
303
283
  break;
304
284
  }
305
285
 
@@ -373,17 +353,11 @@ export const skillLoadTool = {
373
353
  const hasInlineCommands =
374
354
  skill.inlineCommandExpansions && skill.inlineCommandExpansions.length > 0;
375
355
 
376
- if (hasInlineCommands && cleanupMode) {
377
- // Under the disk-pressure lock, loading a skill must not execute shell.
378
- // Strip inline command tokens instead of rendering them; the rest of the
379
- // instructions are still returned.
380
- body = body.replace(
381
- INLINE_COMMAND_TOKEN_PATTERN,
382
- INLINE_COMMAND_CLEANUP_STUB,
383
- );
356
+ if (hasInlineCommands && readOnly) {
357
+ body = body.replace(INLINE_COMMAND_TOKEN_PATTERN, inlineCommandStub);
384
358
  log.info(
385
359
  { skillId: skill.id },
386
- "Skipped inline command expansion during disk pressure cleanup mode",
360
+ "Skipped inline command expansion during read-only skill load",
387
361
  );
388
362
  } else if (hasInlineCommands) {
389
363
  if (skill.source === "extra" || skill.source === "plugin") {
@@ -475,12 +449,10 @@ export const skillLoadTool = {
475
449
  childLoaded.skill.inlineCommandExpansions &&
476
450
  childLoaded.skill.inlineCommandExpansions.length > 0;
477
451
 
478
- if (childHasInlineCommands && cleanupMode) {
479
- // No shell execution under the disk-pressure lock — strip the
480
- // child's inline command tokens rather than rendering them.
452
+ if (childHasInlineCommands && readOnly) {
481
453
  childBody = childBody.replace(
482
454
  INLINE_COMMAND_TOKEN_PATTERN,
483
- INLINE_COMMAND_CLEANUP_STUB,
455
+ inlineCommandStub,
484
456
  );
485
457
  } else if (childHasInlineCommands) {
486
458
  if (
@@ -1,14 +1,16 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
 
4
+ import { v4 as uuid } from "uuid";
5
+
4
6
  import type { SkillSource } from "../../config/skills.js";
5
7
  import { loadSkillCatalog } from "../../config/skills.js";
6
8
  import { refreshSkillCapabilityMemories } from "../../daemon/skill-memory-refresh.js";
7
- import { emitNotificationSignal } from "../../notifications/emit-signal.js";
8
9
  import { sanitizeMultilineMessagePreview } from "../../notifications/notification-utils.js";
9
10
  import { getConversation } from "../../persistence/conversation-crud.js";
10
11
  import { upsertSkillCardInsertJob } from "../../persistence/jobs-store.js";
11
12
  import { MEMORY_RETROSPECTIVE_ORIGIN } from "../../plugins/defaults/memory/memory-retrospective-constants.js";
13
+ import { recordSkillUpdate } from "../../plugins/defaults/memory/skill-update-receipt-job.js";
12
14
  import { readInstallMeta } from "../../skills/install-meta.js";
13
15
  import {
14
16
  createManagedSkill,
@@ -90,78 +92,90 @@ function normalizeOptionalStringArray(
90
92
  return { value: normalized };
91
93
  }
92
94
 
95
+ /** Watchdog check_name counted when a receipt entry could not be recorded. */
96
+ const SKILL_UPDATE_RECEIPT_RECORD_FAILED_CHECK_NAME =
97
+ "skill_update_receipt_record_failed";
98
+
93
99
  /**
94
- * Tell the user that a background pass changed a skill they already have.
100
+ * Record that a background pass changed a skill the user already has.
95
101
  *
96
102
  * A newly authored skill announces itself in the conversation through the
97
- * `skill_card` surface; an update announces itself here instead. The pass
98
- * runs in a background fork and rewrites the body of a skill the user may be
99
- * relying on, and `createManagedSkill` writes through an atomic rename
100
- * keeping no prior version, so the notification pipeline is what puts the
101
- * change in the background feed alongside the other unattended work (sweeps,
102
- * scheduled jobs, heartbeat), where a user already looks to see what the
103
- * assistant did on its own.
103
+ * `skill_card` surface; an update is announced through a skill-update
104
+ * receipt instead. The pass runs in a background fork and rewrites the body
105
+ * of a skill the user may be relying on, and `createManagedSkill` writes
106
+ * through an atomic rename keeping no prior version, so the receipt is what
107
+ * puts the change in the background feed alongside the other unattended
108
+ * work (sweeps, scheduled jobs, heartbeat), where a user already looks to
109
+ * see what the assistant did on its own. One pass can rewrite several skills
110
+ * within minutes, so the rewrites accumulate on one pending receipt job
111
+ * (`plugins/defaults/memory/skill-update-receipt-job.ts`), which announces
112
+ * the whole burst once it has settled rather than one notification per
113
+ * rewrite.
104
114
  *
105
115
  * `changeSummary` is the pass's own account of what it changed and is the
106
- * notice's body: the feed item is the only place the change surfaces, and a
107
- * reader triaging it there has no diff to look at, so a notice that only
116
+ * entry's body: the receipt is the only place the change surfaces, and a
117
+ * reader triaging it there has no diff to look at, so an entry that only
108
118
  * names the skill sends them into the skill to find out what happened. The
109
119
  * executor refuses the overwrite without one, so this never runs without it.
110
120
  *
111
- * `sourceContextId` is a conversation id so the feed item's "Go to Convo"
112
- * target resolves (see `home-feed-side-effect.ts`, which looks it up via
113
- * `getConversation`): the source conversation when lineage resolved, else the
114
- * run's own conversation. Deduped per skill per day, so a pass that refines
115
- * the same skill repeatedly cannot flood the feed. Best-effort and
116
- * non-blocking: the skill is already written, and a notification failure must
117
- * never turn that into a tool error.
121
+ * `sourceConversationId` is the conversation the receipt's link resolves to
122
+ * for this entry (see `home-updates-list.tsx`): the source conversation when
123
+ * lineage resolved, else none. The run's own conversation is never used in
124
+ * its place: it is an ephemeral fork the next successful retrospective
125
+ * garbage-collects, and the receipt job leaves out any entry whose source
126
+ * is gone by the time it announces, so naming the fork would drop the
127
+ * entry rather than link it. `entryId` is the producing tool call's id, so a
128
+ * re-executed call records nothing new. A context with no tool call id (a
129
+ * direct caller) has no re-execution to guard against, so it gets a fresh
130
+ * id: a stable key on the run and skill would drop a second rewrite of the
131
+ * same skill in the same run.
132
+ *
133
+ * Best-effort and non-blocking: the skill is already written, and a receipt
134
+ * that cannot be recorded is a receipt the user will not get, not a reason
135
+ * to fail or retry a write that already landed. The failure is logged with
136
+ * the ids and counted, and the tool-invocation history keeps the summary as
137
+ * audit evidence.
118
138
  */
119
- function notifyBackgroundSkillUpdate(args: {
139
+ function recordBackgroundSkillUpdate(args: {
120
140
  skillId: string;
121
141
  name: string;
122
- conversationId: string;
123
142
  changeSummary: string;
143
+ runConversationId: string;
144
+ sourceConversationId: string | undefined;
145
+ toolUseId: string | undefined;
124
146
  }): void {
125
- const day = new Date().toISOString().slice(0, 10);
126
- void emitNotificationSignal({
127
- // This emit is a tool executor's, not the scheduler's. The channel is
128
- // provenance the pipeline reads: `home-feed-side-effect` derives
129
- // `fromAssistant` from it, so labelling this "scheduler" would drop the
130
- // item from the feed's assistant-initiated filter despite being exactly
131
- // that.
132
- sourceChannel: "assistant_tool",
133
- sourceContextId: args.conversationId,
134
- sourceEventName: "activity.complete",
135
- dedupeKey: `skill-updated:${args.skillId}:${day}`,
136
- contextPayload: {
137
- // `summary` feeds the copy composer and `title` is the headline both
138
- // it and the home feed keep; `title`/`body` are also the feed's
139
- // fallback when no channel copy was rendered. Without them a fully
140
- // suppressed delivery (the intended shape for this signal: low urgency,
141
- // background, no interruption) leaves the feed writer with no summary
142
- // and it skips the item entirely, so the quiet case would surface
143
- // nothing at all.
144
- summary: args.changeSummary,
145
- // Named, not just "Skill updated": the feed sits several rows deep and
146
- // a generic title is unscannable next to entries that name their
147
- // subject (`Background job failed: memory.v2.sweep`). The word "Skill"
148
- // stays because a bare skill name does not always read as one.
149
- title: `Skill updated: ${args.name}`,
150
- body: args.changeSummary,
147
+ const entryId = `${args.runConversationId}:${args.toolUseId ?? uuid()}`;
148
+ try {
149
+ recordSkillUpdate({
150
+ entryId,
151
151
  skillId: args.skillId,
152
- },
153
- attentionHints: {
154
- requiresAction: false,
155
- urgency: "low",
156
- isAsyncBackground: true,
157
- visibleInSourceNow: false,
158
- },
159
- }).catch((err: unknown) => {
152
+ name: args.name,
153
+ changeSummary: args.changeSummary,
154
+ runConversationId: args.runConversationId,
155
+ ...(args.sourceConversationId
156
+ ? { sourceConversationId: args.sourceConversationId }
157
+ : {}),
158
+ });
159
+ } catch (err) {
160
160
  log.warn(
161
- { err, skillId: args.skillId },
162
- "skill update notification failed; the skill write stands",
161
+ {
162
+ err,
163
+ skillId: args.skillId,
164
+ runConversationId: args.runConversationId,
165
+ entryId,
166
+ },
167
+ "skill update receipt recording failed; the skill write stands and no receipt will name this update",
163
168
  );
164
- });
169
+ try {
170
+ recordWatchdogEvent({
171
+ checkName: SKILL_UPDATE_RECEIPT_RECORD_FAILED_CHECK_NAME,
172
+ value: 1,
173
+ });
174
+ } catch {
175
+ // recordWatchdogEvent already no-ops on opt-out and a missing
176
+ // telemetry DB; anything past that is not worth surfacing here.
177
+ }
178
+ }
165
179
  }
166
180
 
167
181
  /**
@@ -333,10 +347,11 @@ export async function executeScaffoldManagedSkill(
333
347
  category = input.category.trim().toLowerCase();
334
348
  }
335
349
 
336
- // The update notice's body. Model-authored text bound for a notification
350
+ // The receipt entry's body. Model-authored text bound for a notification
337
351
  // surface, so it gets the same control-character strip and preview clamp as
338
352
  // any other producer-supplied body; blank collapses to absent so the
339
- // requirement below treats it as missing rather than posting an empty notice.
353
+ // requirement below treats it as missing rather than recording an empty
354
+ // entry.
340
355
  let changeSummary: string | undefined;
341
356
  if (input.change_summary !== undefined) {
342
357
  if (typeof input.change_summary !== "string") {
@@ -404,9 +419,9 @@ export async function executeScaffoldManagedSkill(
404
419
  }
405
420
  }
406
421
 
407
- // A background overwrite announces itself through the notice built in
408
- // notifyBackgroundSkillUpdate, whose body is this summary, so the write is
409
- // refused without one. The error returns to the pass in the same turn and
422
+ // A background overwrite announces itself through the receipt entry
423
+ // recorded in recordBackgroundSkillUpdate, whose body is this summary, so
424
+ // the write is refused without one. The error returns to the pass in the same turn and
410
425
  // it retries with the field, the way a missing activation_hints does.
411
426
  // Checked after the ownership backstop so a pass that may not touch the
412
427
  // skill hears that first, and only for a call that asked to overwrite: a
@@ -500,23 +515,23 @@ export async function executeScaffoldManagedSkill(
500
515
  }
501
516
 
502
517
  // A background pass changed a skill that already existed. Creates announce
503
- // themselves through the skill card below; updates announce themselves in
504
- // the background activity feed. Covers an explicit `overwrite: true`
505
- // refinement as well as any other background write onto a pre-existing
506
- // skill.
507
- const notifyConversationId =
508
- sourceConversationId ?? retrospectiveConversationId;
518
+ // themselves through the skill card below; updates land on the skill-update
519
+ // receipt that announces the whole burst. Covers an explicit
520
+ // `overwrite: true` refinement as well as any other background write onto
521
+ // a pre-existing skill.
509
522
  if (
510
523
  fromRetrospective &&
511
524
  managedSkillExistedBefore &&
512
- notifyConversationId &&
525
+ retrospectiveConversationId &&
513
526
  changeSummary
514
527
  ) {
515
- notifyBackgroundSkillUpdate({
528
+ recordBackgroundSkillUpdate({
516
529
  skillId: id,
517
530
  name: normalizedName,
518
- conversationId: notifyConversationId,
519
531
  changeSummary,
532
+ runConversationId: retrospectiveConversationId,
533
+ sourceConversationId,
534
+ toolUseId: context.toolUseId,
520
535
  });
521
536
  }
522
537
 
@@ -15,6 +15,7 @@ import { bundledToolInputMisuseMessage } from "../shared/input-misuse.js";
15
15
  import { bundledToolInputRepairs } from "../shared/input-repairs.js";
16
16
  import type { ExecutionTarget } from "../tool-types.js";
17
17
  import type { Tool, ToolContext, ToolExecutionResult } from "../types.js";
18
+ import { formatSkillInputSchema } from "./format-input-schema.js";
18
19
  import { runSkillToolScript } from "./skill-script-runner.js";
19
20
 
20
21
  const riskMap: Record<SkillToolEntry["risk"], RiskLevel> = {
@@ -94,7 +95,7 @@ export function createSkillTool(
94
95
  return {
95
96
  content:
96
97
  misuse ??
97
- `Invalid input for tool "${entry.name}": ${validation.errors.join("; ")}. Fix the arguments and retry.`,
98
+ `Invalid input for tool "${entry.name}": ${validation.errors.join("; ")}. If skill_load is available, load the owning skill for its current instructions. Retry with arguments matching this schema.\n\n${formatSkillInputSchema(entry.input_schema)}`,
98
99
  isError: true,
99
100
  };
100
101
  }
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { z } from "zod";
17
17
 
18
- import { getUserSelectableProfilesForProvider } from "../../config/default-profile-catalog.js";
18
+ import { getConversationProfilesForProvider } from "../../config/default-profile-catalog.js";
19
19
  import { loadConfig } from "../../config/loader.js";
20
20
  import { callerOwnsWorkflowRun } from "../../workflows/capabilities.js";
21
21
  import type { WorkflowRun } from "../../workflows/journal-store.js";
@@ -199,7 +199,9 @@ export async function executeManageWorkflows(
199
199
  // workspace-wide active profile. Read-only — leaves use this to pick a
200
200
  // valid `profile` for `run_workflow` (an unknown profile throws).
201
201
  const { llm } = loadConfig();
202
- const profiles = getUserSelectableProfilesForProvider(
202
+ // A workflow leaf runs chat turns, so this is the conversation view,
203
+ // which leaves out the managed Jev profile.
204
+ const profiles = getConversationProfilesForProvider(
203
205
  llm?.profiles,
204
206
  llm?.defaultProvider ?? null,
205
207
  );
@@ -0,0 +1,146 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+
3
+ import { writeWorkerLine } from "./worker-pipe.js";
4
+
5
+ /** Comfortably larger than any OS pipe buffer, so the write cannot complete. */
6
+ const OVERSIZED_LINE = "x".repeat(4 * 1024 * 1024);
7
+
8
+ const spawned: { kill(signal?: NodeJS.Signals): void }[] = [];
9
+
10
+ afterEach(() => {
11
+ for (const proc of spawned.splice(0)) {
12
+ try {
13
+ proc.kill("SIGKILL");
14
+ } catch {
15
+ /* already gone */
16
+ }
17
+ }
18
+ });
19
+
20
+ /** A live child that never reads stdin, the way a worker busy in ONNX does not. */
21
+ function spawnBusyWorker() {
22
+ const proc = Bun.spawn({
23
+ cmd: [process.execPath, "-e", "setTimeout(() => {}, 60_000)"],
24
+ windowsHide: true,
25
+ stdin: "pipe",
26
+ stdout: "ignore",
27
+ stderr: "ignore",
28
+ });
29
+ spawned.push(proc);
30
+ return proc;
31
+ }
32
+
33
+ describe("writeWorkerLine", () => {
34
+ /**
35
+ * The contract this module rests on, against the real Bun API: a payload
36
+ * that outruns the pipe buffer comes back as a `Promise`, and killing the
37
+ * worker settles it. Which way it settles is Bun's to choose. It rejects
38
+ * with EPIPE on nearly every kill and, rarely on Linux, resolves with a
39
+ * short count instead, so this asserts only that nothing escapes either
40
+ * way. Demanding the rejection is what made this case flaky on CI.
41
+ */
42
+ test("observes a real pending write however Bun settles it", async () => {
43
+ const proc = spawnBusyWorker();
44
+ const failures: unknown[] = [];
45
+ const settling: Promise<number>[] = [];
46
+ const record = (result: number | Promise<number>) => {
47
+ if (result instanceof Promise) {
48
+ settling.push(result);
49
+ }
50
+ return result;
51
+ };
52
+
53
+ writeWorkerLine(
54
+ {
55
+ write: (chunk) => record(proc.stdin.write(chunk)),
56
+ flush: () => record(proc.stdin.flush()),
57
+ },
58
+ OVERSIZED_LINE,
59
+ (err) => failures.push(err),
60
+ );
61
+ proc.kill("SIGKILL");
62
+ const outcomes = await Promise.race([
63
+ Promise.allSettled(settling),
64
+ Bun.sleep(10_000).then(() => null),
65
+ ]);
66
+
67
+ expect(settling.length).toBeGreaterThan(0);
68
+ expect(outcomes).not.toBeNull();
69
+ expect(failures).toHaveLength(
70
+ outcomes!.some((o) => o.status === "rejected") ? 1 : 0,
71
+ );
72
+ });
73
+
74
+ /**
75
+ * The production failure: the payload outruns the pipe buffer, Bun hands
76
+ * `write` and `flush` one pending promise, and the worker dies before
77
+ * draining it. Bun rejects that promise from its own event loop, after this
78
+ * call has returned. Unobserved, the rejection is an `unhandledRejection`,
79
+ * which `bun test` reports as a failure of this test.
80
+ */
81
+ test("reports a write still pending when the worker dies", async () => {
82
+ const failure = Promise.withResolvers<unknown>();
83
+ const epipe = Object.assign(new Error("EPIPE: broken pipe, write"), {
84
+ code: "EPIPE",
85
+ });
86
+ const pending = Promise.withResolvers<number>();
87
+ const stdin = {
88
+ write: () => pending.promise,
89
+ flush: () => pending.promise,
90
+ };
91
+
92
+ writeWorkerLine(stdin, "line", failure.resolve);
93
+ pending.reject(epipe);
94
+
95
+ expect(await failure.promise).toBe(epipe);
96
+ });
97
+
98
+ test("reports a synchronous throw", () => {
99
+ const failures: unknown[] = [];
100
+ const epipe = Object.assign(new Error("EPIPE: broken pipe, write"), {
101
+ code: "EPIPE",
102
+ });
103
+ const stdin = {
104
+ write() {
105
+ throw epipe;
106
+ },
107
+ flush: () => 0,
108
+ };
109
+
110
+ writeWorkerLine(stdin, "line", (err) => failures.push(err));
111
+
112
+ expect(failures).toEqual([epipe]);
113
+ });
114
+
115
+ /** `write` and `flush` both report the same broken pipe. */
116
+ test("reports one failure when write and flush both reject", async () => {
117
+ const failures: unknown[] = [];
118
+ const epipe = new Error("EPIPE: broken pipe, send");
119
+ const stdin = {
120
+ write: () => Promise.reject(epipe),
121
+ flush: () => Promise.reject(epipe),
122
+ };
123
+
124
+ writeWorkerLine(stdin, "line", (err) => failures.push(err));
125
+ await Bun.sleep(0);
126
+
127
+ expect(failures).toEqual([epipe]);
128
+ });
129
+
130
+ test("stays silent when the line is delivered", async () => {
131
+ const failures: unknown[] = [];
132
+ const proc = Bun.spawn({
133
+ cmd: [process.execPath, "-e", "process.stdin.on('data', () => {})"],
134
+ windowsHide: true,
135
+ stdin: "pipe",
136
+ stdout: "ignore",
137
+ stderr: "ignore",
138
+ });
139
+ spawned.push(proc);
140
+
141
+ writeWorkerLine(proc.stdin, "hello", (err) => failures.push(err));
142
+ await Bun.sleep(50);
143
+
144
+ expect(failures).toEqual([]);
145
+ });
146
+ });
@@ -0,0 +1,49 @@
1
+ import type { FileSink } from "bun";
2
+
3
+ /**
4
+ * Write one line to a worker subprocess's stdin, reporting a broken pipe to
5
+ * `onFailure` however Bun surfaces it.
6
+ *
7
+ * `FileSink.write` and `FileSink.flush` return `number | Promise<number>`.
8
+ * While the pipe has room they complete synchronously. Once a payload outruns
9
+ * the pipe buffer, which is what happens when the worker is busy and not
10
+ * draining stdin, they return a pending promise instead, and that promise
11
+ * rejects with EPIPE once the worker dies, from Bun's event loop, after this
12
+ * call has returned. A `try/catch` sees only the synchronous half: the
13
+ * rejection escapes it, reaches the process's `unhandledRejection` handler,
14
+ * and in the daemon that handler shuts the whole process down.
15
+ *
16
+ * On Linux, Bun occasionally treats a zero-byte write to the dead worker's
17
+ * pipe as end-of-file and resolves that promise with the bytes written so
18
+ * far instead. `onFailure` then never runs, and the caller learns of the
19
+ * death only when the worker's stdout ends.
20
+ *
21
+ * `onFailure` runs at most once per call, even though `write` and `flush` can
22
+ * each report the same broken pipe.
23
+ */
24
+ export function writeWorkerLine(
25
+ stdin: Pick<FileSink, "write" | "flush">,
26
+ line: string,
27
+ onFailure: (err: unknown) => void,
28
+ ): void {
29
+ let failed = false;
30
+ const fail = (err: unknown): void => {
31
+ if (failed) {
32
+ return;
33
+ }
34
+ failed = true;
35
+ onFailure(err);
36
+ };
37
+ const observe = (result: number | Promise<number>): void => {
38
+ if (result instanceof Promise) {
39
+ result.catch(fail);
40
+ }
41
+ };
42
+
43
+ try {
44
+ observe(stdin.write(line + "\n"));
45
+ observe(stdin.flush());
46
+ } catch (err) {
47
+ fail(err);
48
+ }
49
+ }