@vellumai/assistant 0.10.9-staging.1 → 0.10.9-staging.2

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 (100) hide show
  1. package/AGENTS.md +4 -0
  2. package/knip.json +1 -4
  3. package/openapi.yaml +28 -18
  4. package/package.json +1 -1
  5. package/src/__tests__/always-loaded-tools-guard.test.ts +4 -6
  6. package/src/__tests__/app-routes-csp.test.ts +13 -4
  7. package/src/__tests__/conversation-tool-setup-tools-disabled.test.ts +4 -8
  8. package/src/__tests__/disk-pressure-tools.test.ts +4 -4
  9. package/src/__tests__/file-list-tool.test.ts +22 -12
  10. package/src/__tests__/list-all-apps.test.ts +204 -0
  11. package/src/__tests__/messaging-send-tool.test.ts +67 -0
  12. package/src/__tests__/mtime-cache.test.ts +42 -0
  13. package/src/__tests__/oauth-provider-profiles.test.ts +3 -2
  14. package/src/__tests__/outlook-messaging-provider.test.ts +71 -0
  15. package/src/__tests__/plugin-app-serve-routes.test.ts +163 -0
  16. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
  17. package/src/__tests__/scaffold-managed-skill-tool.test.ts +47 -0
  18. package/src/__tests__/subagent-notify-parent.test.ts +4 -3
  19. package/src/__tests__/subagent-tool-filtering.test.ts +21 -21
  20. package/src/__tests__/subagent-tool-gate-mode.test.ts +6 -6
  21. package/src/apps/app-store.ts +302 -2
  22. package/src/cli/AGENTS.md +17 -0
  23. package/src/cli/commands/__tests__/gateway.test.ts +55 -0
  24. package/src/cli/commands/apps.help.ts +17 -11
  25. package/src/cli/commands/apps.ts +5 -15
  26. package/src/cli/commands/gateway.help.ts +30 -0
  27. package/src/cli/commands/gateway.ts +40 -1
  28. package/src/cli/commands/platform/__tests__/status.test.ts +0 -90
  29. package/src/cli/commands/platform/index.help.ts +3 -2
  30. package/src/cli/commands/platform/index.ts +0 -9
  31. package/src/cli/commands/routes.help.ts +13 -7
  32. package/src/cli/commands/routes.ts +5 -3
  33. package/src/cli/commands/status.help.ts +24 -0
  34. package/src/cli/commands/status.ts +29 -0
  35. package/src/cli/lib/__tests__/global-json-option.test.ts +97 -0
  36. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +1 -0
  37. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +31 -0
  38. package/src/cli/lib/bundled-marketplace.json +187 -173
  39. package/src/cli/lib/global-json-option.ts +41 -0
  40. package/src/cli/lib/plugin-fingerprint.ts +18 -7
  41. package/src/cli/program.ts +5 -0
  42. package/src/config/bundled-skills/messaging/TOOLS.json +2 -2
  43. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +27 -19
  44. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +3 -3
  45. package/src/config/feature-flag-registry.json +1 -9
  46. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +3 -6
  47. package/src/daemon/__tests__/conversation-tool-setup-plugin-scope.test.ts +3 -6
  48. package/src/daemon/__tests__/conversation-tool-setup.test.ts +8 -5
  49. package/src/daemon/conversation-tool-setup.ts +5 -90
  50. package/src/daemon/conversation.ts +2 -2
  51. package/src/messaging/provider-types.ts +9 -0
  52. package/src/messaging/providers/outlook/adapter.ts +17 -2
  53. package/src/messaging/providers/outlook/client.ts +11 -2
  54. package/src/messaging/providers/outlook/types.ts +9 -0
  55. package/src/monitoring/__tests__/plugin-source-watch.test.ts +37 -0
  56. package/src/monitoring/plugin-source-watch.ts +53 -0
  57. package/src/oauth/seed-providers.ts +6 -0
  58. package/src/persistence/embeddings/__tests__/messages-lexical-index.test.ts +3 -0
  59. package/src/persistence/embeddings/messages-lexical-index.ts +11 -0
  60. package/src/plugin-api/conversation-turn.ts +234 -0
  61. package/src/plugin-api/index.ts +12 -0
  62. package/src/plugins/__tests__/source-fingerprint.test.ts +41 -0
  63. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +48 -0
  64. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +46 -5
  65. package/src/plugins/defaults/memory/graph/tool-handlers.ts +2 -2
  66. package/src/plugins/defaults/memory/graph-topology/__tests__/build-memory-graph.test.ts +81 -2
  67. package/src/plugins/defaults/memory/graph-topology/build-memory-graph.ts +80 -33
  68. package/src/plugins/defaults/memory/memory-retrospective-job.ts +40 -1
  69. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +28 -2
  70. package/src/plugins/mtime-cache.ts +26 -13
  71. package/src/plugins/plugin-tree-walk.ts +30 -0
  72. package/src/plugins/source-fingerprint.ts +9 -2
  73. package/src/plugins/surface-import.ts +28 -0
  74. package/src/providers/speech-to-text/__tests__/vellum-managed-realtime.test.ts +1 -1
  75. package/src/providers/speech-to-text/__tests__/vellum-speech-relay-connection.test.ts +4 -1
  76. package/src/providers/speech-to-text/vellum-speech-relay-connection.ts +5 -0
  77. package/src/runtime/routes/__tests__/gateway-status-routes.test.ts +84 -0
  78. package/src/runtime/routes/__tests__/plugins-routes.test.ts +47 -3
  79. package/src/runtime/routes/__tests__/user-routes-cli.test.ts +154 -0
  80. package/src/runtime/routes/app-management-routes.ts +96 -32
  81. package/src/runtime/routes/app-routes.ts +33 -17
  82. package/src/runtime/routes/gateway-status-routes.ts +69 -0
  83. package/src/runtime/routes/index.ts +2 -0
  84. package/src/runtime/routes/platform-routes.ts +4 -14
  85. package/src/runtime/routes/plugins-routes.ts +58 -1
  86. package/src/runtime/routes/settings-routes.ts +8 -6
  87. package/src/runtime/routes/user-route-dispatcher.ts +6 -86
  88. package/src/runtime/routes/user-route-resolution.ts +141 -0
  89. package/src/runtime/routes/user-routes-cli.ts +56 -35
  90. package/src/telemetry/AGENTS.md +15 -0
  91. package/src/telemetry/__tests__/telemetry-event-fixtures.ts +258 -0
  92. package/src/telemetry/telemetry-wire-source.json +1 -1
  93. package/src/telemetry/telemetry-wire-validation.test.ts +157 -0
  94. package/src/telemetry/telemetry-wire-validation.ts +125 -0
  95. package/src/telemetry/telemetry-wire.generated.ts +60 -9
  96. package/src/telemetry/types.test.ts +38 -0
  97. package/src/telemetry/types.ts +183 -91
  98. package/src/telemetry/usage-telemetry-reporter.ts +5 -0
  99. package/src/telemetry/watchdog-direct-emit.ts +6 -0
  100. package/src/tools/skills/scaffold-managed.ts +43 -11
@@ -157,6 +157,46 @@ export function createSourceWatchState(): SourceWatchState {
157
157
  };
158
158
  }
159
159
 
160
+ /**
161
+ * Compile every multi-file app bundled by a plugin from its `apps/<app>/src`
162
+ * into the sibling `apps/<app>/dist`. Single-file apps (a root `index.html`,
163
+ * no `src/`) need no build and are skipped.
164
+ *
165
+ * Runs here — off the daemon's hot path — because the monitor has just detected
166
+ * the change. The generated `dist/` is excluded from the source fingerprint
167
+ * ({@link isGeneratedAppBuildDir}), so writing it does not re-trigger a pass.
168
+ * The bundler (esbuild) is imported lazily so the monitor only pulls it in when
169
+ * a plugin actually ships a buildable app.
170
+ */
171
+ export async function compilePluginApps(pluginDir: string): Promise<void> {
172
+ const appsDir = join(pluginDir, "apps");
173
+ let appEntries: string[];
174
+ try {
175
+ appEntries = readdirSync(appsDir);
176
+ } catch {
177
+ return; // No apps/ directory — nothing to build.
178
+ }
179
+ const buildable = appEntries.filter((app) => {
180
+ const appDir = join(appsDir, app);
181
+ try {
182
+ return statSync(appDir).isDirectory() && existsSync(join(appDir, "src"));
183
+ } catch {
184
+ return false;
185
+ }
186
+ });
187
+ if (buildable.length === 0) {
188
+ return;
189
+ }
190
+ const { compileApp } = await import("../bundler/app-compiler.js");
191
+ for (const app of buildable) {
192
+ const appDir = join(appsDir, app);
193
+ const result = await compileApp(appDir);
194
+ if (!result.ok) {
195
+ log.warn({ appDir, errors: result.errors }, "plugin app compile failed");
196
+ }
197
+ }
198
+ }
199
+
160
200
  /**
161
201
  * Run one detection pass: walk, compare against the last published state,
162
202
  * and rewrite the sentinel if anything changed. Returns whether a rewrite
@@ -202,6 +242,19 @@ export function runSourceWatchPass(state: SourceWatchState): boolean {
202
242
  { generation: state.generation, changedDirs },
203
243
  "plugin source change published",
204
244
  );
245
+
246
+ // Rebuild multi-file apps for each changed, enabled plugin. Fire-and-forget
247
+ // so a slow build never stalls the watch loop; the generated dist is
248
+ // excluded from the fingerprint, so it does not re-trigger this pass.
249
+ for (const dir of changedDirs) {
250
+ const version = current[dir];
251
+ if (version !== undefined && !version.disabled) {
252
+ void compilePluginApps(dir).catch((err) => {
253
+ log.error({ err, dir }, "plugin app compile pass failed");
254
+ });
255
+ }
256
+ }
257
+
205
258
  return true;
206
259
  } catch (err) {
207
260
  log.error({ err }, "plugin source watch pass failed — will retry");
@@ -131,6 +131,12 @@ export const PROVIDER_SEED_DATA: Record<
131
131
  headerName: "Authorization",
132
132
  valuePrefix: "Bearer ",
133
133
  },
134
+ {
135
+ hostPattern: "docs.googleapis.com",
136
+ injectionType: "header",
137
+ headerName: "Authorization",
138
+ valuePrefix: "Bearer ",
139
+ },
134
140
  ],
135
141
  revokeUrl: "https://oauth2.googleapis.com/revoke",
136
142
  revokeBodyTemplate: { token: "{access_token}" },
@@ -143,6 +143,9 @@ describe("MessagesLexicalIndex", () => {
143
143
  expect(config.vectors).toBeUndefined();
144
144
  expect("vectors" in config).toBe(false);
145
145
 
146
+ // Explicit segment count — never Qdrant's CPU-count auto-detection.
147
+ expect(config.optimizers_config).toEqual({ default_segment_number: 2 });
148
+
146
149
  // Payload indexes on conversation_id (keyword) + created_at (integer).
147
150
  const fields = payloadIndexCalls.map((c) => c.field_name);
148
151
  expect(fields).toContain("conversation_id");
@@ -23,6 +23,14 @@ export const MESSAGES_LEXICAL_COLLECTION = "messages_lexical";
23
23
  */
24
24
  const MESSAGE_POINT_NAMESPACE = "b6d0d3e2-1f1a-4c3e-9a7c-0e2f5a8d4c11";
25
25
 
26
+ /**
27
+ * Explicit segment count for the collection. Left unset, Qdrant sizes the
28
+ * segment count to the node's CPU count, and each segment carries a fixed
29
+ * storage preallocation — a small sparse-only collection then idles at
30
+ * hundreds of MB. Matches the v2 concept-pages collection.
31
+ */
32
+ const DEFAULT_SEGMENT_NUMBER = 2;
33
+
26
34
  export interface MessagesLexicalIndexConfig {
27
35
  url: string;
28
36
  collection?: string;
@@ -113,6 +121,9 @@ export class MessagesLexicalIndex {
113
121
  // payloads.
114
122
  sparse: { index: { on_disk: this.onDisk } },
115
123
  },
124
+ optimizers_config: {
125
+ default_segment_number: DEFAULT_SEGMENT_NUMBER,
126
+ },
116
127
  on_disk_payload: this.onDisk,
117
128
  });
118
129
  } catch (err) {
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Plugin-facing facade for running a full conversation agent-loop turn.
3
+ *
4
+ * This is the conversation-scoped equivalent of the daemon's
5
+ * `processMessage` path: it persists a user message, runs the agent loop
6
+ * (with all its machinery -- system prompt construction, conversation
7
+ * history, tool use cycles, compaction, injections), and returns the
8
+ * assistant's full content-block response.
9
+ *
10
+ * Plugins that need to drive conversation turns (e.g. meeting-bot
11
+ * flushing a transcript excerpt) should prefer this over the stateless
12
+ * `provider.sendMessage()` call, which has no history, no tools, and no
13
+ * context management.
14
+ */
15
+
16
+ import type { LLMCallSite } from "../config/schemas/llm.js";
17
+ import type { ServerMessage } from "../daemon/message-protocol.js";
18
+ import type { UserMessageAttachment } from "../daemon/message-types/shared.js";
19
+ import type { ContentBlock, MediaSource } from "../providers/types.js";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Public types
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface RunConversationTurnOptions {
26
+ /**
27
+ * Conversation to run the turn in. If omitted, a new conversation is
28
+ * created (its ID is generated with `uuidv7` and returned in the result).
29
+ */
30
+ conversationId?: string;
31
+ /**
32
+ * User message content blocks for this turn. Text blocks become the
33
+ * user message body; image/file blocks are resolved to inline
34
+ * attachments. Other block types (tool_use, tool_result, thinking) are
35
+ * ignored as they are not valid user input.
36
+ */
37
+ content: ContentBlock[];
38
+ /**
39
+ * LLM call-site for inference profile resolution. Defaults to
40
+ * `"mainAgent"` inside the agent loop when omitted.
41
+ */
42
+ callSite?: LLMCallSite;
43
+ /**
44
+ * Abort signal. When aborted, the conversation's internal abort
45
+ * controller fires, terminating the in-flight agent loop.
46
+ */
47
+ signal?: AbortSignal;
48
+ }
49
+
50
+ export interface RunConversationTurnResult {
51
+ /** The assistant's full content blocks for this turn (text, tool_use, etc.). */
52
+ content: ContentBlock[];
53
+ /** The user message row ID assigned by the persistence layer. */
54
+ userMessageId: string;
55
+ /** The conversation this turn ran in. */
56
+ conversationId: string;
57
+ /** True when the message was queued because the conversation was busy. */
58
+ queued?: boolean;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Content conversion
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Extract a plain-text content string and attachment list from
67
+ * {@link ContentBlock} input. Text blocks are concatenated (newline
68
+ * separated); image and file blocks are converted to
69
+ * {@link UserMessageAttachment} entries with their media source resolved
70
+ * to inline base64. Other block types are ignored.
71
+ */
72
+ function extractContentAndAttachments(
73
+ blocks: ContentBlock[],
74
+ resolveMedia: (source: MediaSource) => { data: string; media_type: string } | null,
75
+ ): { text: string; attachments: UserMessageAttachment[] } {
76
+ const textParts: string[] = [];
77
+ const attachments: UserMessageAttachment[] = [];
78
+
79
+ for (const block of blocks) {
80
+ if (block.type === "text") {
81
+ textParts.push(block.text);
82
+ } else if (block.type === "image" || block.type === "file") {
83
+ const source = block.source;
84
+ const resolved = resolveMedia(source);
85
+ if (resolved) {
86
+ attachments.push({
87
+ filename:
88
+ source.filename ?? (block.type === "image" ? "image" : "file"),
89
+ mimeType: resolved.media_type,
90
+ data: resolved.data,
91
+ ...(block.type === "file" && block.extracted_text
92
+ ? { extractedText: block.extracted_text }
93
+ : {}),
94
+ });
95
+ }
96
+ }
97
+ }
98
+
99
+ return { text: textParts.join("\n"), attachments };
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Public API
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /**
107
+ * Run a full conversation agent-loop turn: persist the user message, execute
108
+ * the agent loop (history, tools, compaction, injections), and return the
109
+ * assistant's full content-block response.
110
+ *
111
+ * When `conversationId` is omitted, a new conversation is created and its ID
112
+ * is returned in the result.
113
+ *
114
+ * Events are fanned out to SSE/event-hub subscribers via `broadcastMessage`
115
+ * (the same path the daemon's HTTP message route uses) while also being
116
+ * collected internally so the caller receives the final response content.
117
+ * For streaming use cases, subscribe to the conversation's events via the
118
+ * host event hub (`assistantEventHub`).
119
+ *
120
+ * If the conversation is currently processing another turn, the message is
121
+ * queued via `enqueueMessage` and the result carries `queued: true` with
122
+ * empty content -- the queued turn will execute automatically when the
123
+ * current turn finishes.
124
+ */
125
+ export async function runConversationTurn(
126
+ options: RunConversationTurnOptions,
127
+ ): Promise<RunConversationTurnResult> {
128
+ const { v7: uuidv7 } = await import("uuid");
129
+ const { getOrCreateConversation } = await import(
130
+ "../daemon/conversation-store.js"
131
+ );
132
+ const { broadcastMessage } = await import(
133
+ "../runtime/assistant-event-hub.js"
134
+ );
135
+ const { resolveMediaSourceData } = await import(
136
+ "../providers/media-resolve.js"
137
+ );
138
+ const { getMessageById, getMessages } = await import(
139
+ "../persistence/conversation-crud.js"
140
+ );
141
+
142
+ const conversationId = options.conversationId ?? uuidv7();
143
+ const conversation = await getOrCreateConversation(conversationId);
144
+
145
+ // Wire the external abort signal to the conversation's internal abort
146
+ // controller so aborting the signal terminates the in-flight agent loop.
147
+ if (options.signal) {
148
+ options.signal.addEventListener("abort", () => {
149
+ conversation.abortController?.abort();
150
+ });
151
+ }
152
+
153
+ // Convert ContentBlock[] input to the text + attachments shape the
154
+ // conversation's processMessage path expects.
155
+ const { text, attachments } = extractContentAndAttachments(
156
+ options.content,
157
+ resolveMediaSourceData,
158
+ );
159
+
160
+ // Build the event emitter: fan out to SSE/event-hub subscribers via
161
+ // broadcastMessage, then invoke the collector callback. This mirrors the
162
+ // buildEventEmitter pattern from process-message.ts so plugin-driven
163
+ // turns reach the same subscribers as HTTP-driven turns.
164
+ let assistantMessageId: string | undefined;
165
+ const onEvent = (msg: ServerMessage): void => {
166
+ broadcastMessage(msg, conversationId);
167
+ if (msg.type === "message_complete" && msg.messageId) {
168
+ assistantMessageId = msg.messageId;
169
+ }
170
+ };
171
+
172
+ // When the conversation is busy, enqueue the message instead of rejecting.
173
+ // The queue is drained automatically when the current turn finishes.
174
+ if (conversation.isProcessing()) {
175
+ const requestId = uuidv7();
176
+ const enqueueResult = conversation.enqueueMessage({
177
+ content: text,
178
+ attachments,
179
+ onEvent,
180
+ requestId,
181
+ isInteractive: false,
182
+ });
183
+ if (enqueueResult.rejected) {
184
+ throw new Error(
185
+ "Conversation is busy and its message queue is full. Try again later.",
186
+ );
187
+ }
188
+ return {
189
+ content: [],
190
+ userMessageId: requestId,
191
+ conversationId,
192
+ queued: true,
193
+ };
194
+ }
195
+
196
+ const userMessageId = await conversation.processMessage({
197
+ content: text,
198
+ attachments,
199
+ onEvent,
200
+ isInteractive: false,
201
+ ...(options.callSite ? { callSite: options.callSite } : {}),
202
+ });
203
+
204
+ // Retrieve the assistant's full content blocks from the persisted
205
+ // message row. The message_complete event carries the assistant
206
+ // message ID; if it was captured, use it directly. Otherwise fall back
207
+ // to scanning the conversation's messages for the first assistant
208
+ // message after our user message.
209
+ let assistantContent: ContentBlock[] = [];
210
+ if (assistantMessageId) {
211
+ const assistantRow = getMessageById(assistantMessageId, conversationId);
212
+ if (assistantRow) {
213
+ assistantContent = assistantRow.content;
214
+ }
215
+ }
216
+ if (assistantContent.length === 0) {
217
+ const allMessages = getMessages(conversationId);
218
+ const userIdx = allMessages.findIndex((m) => m.id === userMessageId);
219
+ if (userIdx >= 0) {
220
+ for (let i = allMessages.length - 1; i > userIdx; i--) {
221
+ if (allMessages[i].role === "assistant") {
222
+ assistantContent = allMessages[i].content;
223
+ break;
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ return {
230
+ content: assistantContent,
231
+ userMessageId,
232
+ conversationId,
233
+ };
234
+ }
@@ -244,3 +244,15 @@ export {
244
244
  export type { SynthesizeTextOptions } from "../tts/synthesize-text.js";
245
245
  export { synthesizeText, TtsSynthesisError } from "../tts/synthesize-text.js";
246
246
  export type { TtsSynthesisResult } from "../tts/types.js";
247
+ // Conversation agent-loop turn — run a full conversation turn (persist user
248
+ // message, execute the agent loop with history/tools/compaction/injections,
249
+ // return the assistant's full content-block response). Accepts ContentBlock[]
250
+ // input (text, images, files) and an optional conversationId (creates a new
251
+ // conversation when omitted). Plugins that need to drive conversation turns
252
+ // (e.g. meeting-bot flushing a transcript excerpt) should prefer this over the
253
+ // stateless `provider.sendMessage()` call.
254
+ export type {
255
+ RunConversationTurnOptions,
256
+ RunConversationTurnResult,
257
+ } from "./conversation-turn.js";
258
+ export { runConversationTurn } from "./conversation-turn.js";
@@ -81,6 +81,47 @@ describe("snapshotPluginSource", () => {
81
81
  expect(names).not.toContain("/.disabled");
82
82
  });
83
83
 
84
+ test("excludes generated apps/<app>/dist but tracks app src and top-level dist", () => {
85
+ const dir = makePluginDir();
86
+ // App source is tracked.
87
+ mkdirSync(join(dir, "apps", "dash", "src"), { recursive: true });
88
+ writeStamped(
89
+ join(dir, "apps", "dash", "src", "main.tsx"),
90
+ "export default 1;",
91
+ );
92
+ // Generated app build output is excluded (the watcher writes it).
93
+ mkdirSync(join(dir, "apps", "dash", "dist"), { recursive: true });
94
+ writeStamped(
95
+ join(dir, "apps", "dash", "dist", "main.js"),
96
+ "console.log(1)",
97
+ );
98
+ // A plugin's own top-level dist/ is NOT an app build dir — still tracked.
99
+ mkdirSync(join(dir, "dist"), { recursive: true });
100
+ writeStamped(join(dir, "dist", "bundle.js"), "x");
101
+
102
+ const names = snapshotPluginSource(dir).evictionPaths.map((p) =>
103
+ p.slice(dir.length),
104
+ );
105
+ expect(names).toContain("/apps/dash/src/main.tsx");
106
+ expect(names).toContain("/dist/bundle.js");
107
+ expect(names).not.toContain("/apps/dash/dist/main.js");
108
+
109
+ // Editing the generated dist does not move the fingerprint (no re-trigger)...
110
+ const base = snapshotPluginSource(dir).fingerprint;
111
+ writeStamped(
112
+ join(dir, "apps", "dash", "dist", "main.js"),
113
+ "console.log(2)",
114
+ );
115
+ expect(snapshotPluginSource(dir).fingerprint).toBe(base);
116
+
117
+ // ...but editing the app source does.
118
+ writeStamped(
119
+ join(dir, "apps", "dash", "src", "main.tsx"),
120
+ "export default 2;",
121
+ );
122
+ expect(snapshotPluginSource(dir).fingerprint).not.toBe(base);
123
+ });
124
+
84
125
  test("is stable across identical walks and moves on edit, add, delete, and rename", () => {
85
126
  const dir = makePluginDir();
86
127
  const helper = join(dir, "hooks", "helper.ts");
@@ -92,6 +92,21 @@ let messagesByConversationId: Record<string, StubMessage[]> = {};
92
92
  // the real registry's semantics for conversations not in memory.
93
93
  let loadedConversations: Record<string, { processing: boolean }> = {};
94
94
 
95
+ const watchdogEvents: Array<{
96
+ checkName: string;
97
+ value?: number | null;
98
+ detail?: Record<string, unknown> | null;
99
+ }> = [];
100
+ mock.module("../../../../telemetry/watchdog-events-store.js", () => ({
101
+ recordWatchdogEvent: (record: {
102
+ checkName: string;
103
+ value?: number | null;
104
+ detail?: Record<string, unknown> | null;
105
+ }) => {
106
+ watchdogEvents.push(record);
107
+ },
108
+ }));
109
+
95
110
  mock.module("../../../../daemon/conversation-registry.js", () => ({
96
111
  findConversation: (id: string | undefined) => {
97
112
  if (!id) return undefined;
@@ -360,6 +375,7 @@ function priorRetroMessage(rememberContents: string[]) {
360
375
 
361
376
  describe("memoryRetrospectiveJob", () => {
362
377
  beforeEach(() => {
378
+ watchdogEvents.length = 0;
363
379
  mockState = null;
364
380
  stateUpserts = [];
365
381
  lastRunAtBumps = [];
@@ -416,6 +432,16 @@ describe("memoryRetrospectiveJob", () => {
416
432
  expect(lastRunAtBumps).toHaveLength(0);
417
433
  expect(wakeCalls).toHaveLength(0);
418
434
  expect(forkCalls).toHaveLength(0);
435
+ // Every job run emits exactly one outcome counter for the health chart.
436
+ expect(
437
+ watchdogEvents.filter((e) => e.checkName === "memory_retrospective_run"),
438
+ ).toEqual([
439
+ {
440
+ checkName: "memory_retrospective_run",
441
+ value: 1,
442
+ detail: { outcome: "no_new_messages" },
443
+ },
444
+ ]);
419
445
  });
420
446
 
421
447
  test("a slice whose only row is the retrospective's own skill card is no new work", async () => {
@@ -532,6 +558,17 @@ describe("memoryRetrospectiveJob", () => {
532
558
  const outcome = await memoryRetrospectiveJob(makeJob(), stubConfig);
533
559
 
534
560
  expect(outcome.kind).toBe("wake_failed");
561
+ // The outcome counter carries the wake-failure reason so a fleet-wide
562
+ // spike is attributable (e.g. provider outage vs busy conversations).
563
+ expect(
564
+ watchdogEvents.filter((e) => e.checkName === "memory_retrospective_run"),
565
+ ).toEqual([
566
+ {
567
+ checkName: "memory_retrospective_run",
568
+ value: 1,
569
+ detail: { outcome: "wake_failed", reason: "timeout" },
570
+ },
571
+ ]);
535
572
  if (outcome.kind === "wake_failed") {
536
573
  expect(outcome.reason).toBe("timeout");
537
574
  }
@@ -549,6 +586,17 @@ describe("memoryRetrospectiveJob", () => {
549
586
  expect(stateUpserts).toHaveLength(0);
550
587
  expect(lastRunAtBumps).toHaveLength(1);
551
588
  expect(deletedConversationIds).toEqual(["fork-conv-1"]);
589
+ // A thrown run still records an outcome counter (as "error") before the
590
+ // rethrow — exception-flavored outages must show in the health metric.
591
+ expect(
592
+ watchdogEvents.filter((e) => e.checkName === "memory_retrospective_run"),
593
+ ).toEqual([
594
+ {
595
+ checkName: "memory_retrospective_run",
596
+ value: 1,
597
+ detail: { outcome: "error", reason: "LLM provider 503" },
598
+ },
599
+ ]);
552
600
  });
553
601
 
554
602
  test("missing conversationId payload: no_new_messages, no side effects", async () => {
@@ -35,6 +35,21 @@ type ConversationStub = {
35
35
  } | null;
36
36
  let conversationOverrides: Record<string, ConversationStub> = {};
37
37
 
38
+ const watchdogEvents: Array<{
39
+ checkName: string;
40
+ value?: number | null;
41
+ detail?: Record<string, unknown> | null;
42
+ }> = [];
43
+ mock.module("../../../../telemetry/watchdog-events-store.js", () => ({
44
+ recordWatchdogEvent: (record: {
45
+ checkName: string;
46
+ value?: number | null;
47
+ detail?: Record<string, unknown> | null;
48
+ }) => {
49
+ watchdogEvents.push(record);
50
+ },
51
+ }));
52
+
38
53
  mock.module("../../../../persistence/conversation-crud.js", () => ({
39
54
  getMessagesAfter: (_id: string, _afterId: string | null) => [],
40
55
  getMessages: (_id: string) => [],
@@ -156,6 +171,7 @@ function makeInsertJob(payload: Record<string, unknown>): MemoryJob {
156
171
 
157
172
  describe("memory-retrospective skill card", () => {
158
173
  beforeEach(() => {
174
+ watchdogEvents.length = 0;
159
175
  addMessageCalls = [];
160
176
  addMessageThrowsFor = null;
161
177
  addMessageDeduplicatesFor = null;
@@ -191,7 +207,7 @@ describe("memory-retrospective skill card", () => {
191
207
  type: "ui_surface",
192
208
  surfaceId: "skill-card-run-conv-1",
193
209
  surfaceType: "skill_card",
194
- title: "New skill learned",
210
+ title: "I just learned how to do 2 new things",
195
211
  display: "inline",
196
212
  data: {
197
213
  skills: [
@@ -214,7 +230,7 @@ describe("memory-retrospective skill card", () => {
214
230
  // surface-capable clients skip it via the `_surfaceFallback` flag.
215
231
  {
216
232
  type: "text",
217
- text: "New skill learned: Skill A, Skill B",
233
+ text: "I just learned how to do Skill A, Skill B",
218
234
  _surfaceFallback: true,
219
235
  },
220
236
  ]);
@@ -317,6 +333,31 @@ describe("memory-retrospective skill card", () => {
317
333
  expect(publishedConversationIds).toHaveLength(0);
318
334
  });
319
335
 
336
+ test("a delivered card emits the skill_card_delivered counter; a dedup does not", async () => {
337
+ await insertSkillCardMessage("conv-source", "conv-run-telemetry", [
338
+ { skillId: "s-1", name: "Skill One", description: "d1" },
339
+ { skillId: "s-2", name: "Skill Two", description: "d2" },
340
+ ]);
341
+ expect(
342
+ watchdogEvents.filter((e) => e.checkName === "skill_card_delivered"),
343
+ ).toEqual([
344
+ {
345
+ checkName: "skill_card_delivered",
346
+ value: 2,
347
+ detail: { skill_count: 2 },
348
+ },
349
+ ]);
350
+
351
+ // Retried delivery for the same run dedups on clientMessageId — the
352
+ // counter must not fire again.
353
+ await insertSkillCardMessage("conv-source", "conv-run-telemetry", [
354
+ { skillId: "s-1", name: "Skill One", description: "d1" },
355
+ ]);
356
+ expect(
357
+ watchdogEvents.filter((e) => e.checkName === "skill_card_delivered"),
358
+ ).toHaveLength(1);
359
+ });
360
+
320
361
  test("insert is best-effort: a persistence failure never throws", async () => {
321
362
  addMessageThrowsFor = "src-conv-9";
322
363
 
@@ -359,7 +400,7 @@ describe("memory-retrospective skill card", () => {
359
400
  type: "ui_surface",
360
401
  surfaceId: "skill-card-run-conv-1",
361
402
  surfaceType: "skill_card",
362
- title: "New skill learned",
403
+ title: "I just learned how to do Skill A",
363
404
  display: "inline",
364
405
  data: {
365
406
  skills: [
@@ -374,7 +415,7 @@ describe("memory-retrospective skill card", () => {
374
415
  },
375
416
  {
376
417
  type: "text",
377
- text: "New skill learned: Skill A",
418
+ text: "I just learned how to do Skill A",
378
419
  _surfaceFallback: true,
379
420
  },
380
421
  ]);
@@ -448,7 +489,7 @@ describe("memory-retrospective skill card", () => {
448
489
  ]);
449
490
  expect(blocks[1]).toEqual({
450
491
  type: "text",
451
- text: "New skill learned: Skill A, Skill B",
492
+ text: "I just learned how to do Skill A, Skill B",
452
493
  _surfaceFallback: true,
453
494
  });
454
495
  expect(publishedConversationIds).toEqual(["src-conv-9"]);
@@ -242,7 +242,7 @@ export function handleDeleteMemory(
242
242
  return {
243
243
  success: false,
244
244
  message:
245
- "No memory found matching that content. Use `vellum memory list` to find the exact text first.",
245
+ "No memory found matching that content. Use `assistant memory nodes list` to find the exact text first.",
246
246
  };
247
247
  }
248
248
 
@@ -317,7 +317,7 @@ export function handleUpdateMemory(
317
317
  return {
318
318
  success: false,
319
319
  message:
320
- "No memory found matching old_content. Use `vellum memory list` to find the exact text first.",
320
+ "No memory found matching old_content. Use `assistant memory nodes list` to find the exact text first.",
321
321
  };
322
322
  }
323
323