@vellumai/assistant 0.8.2 → 0.8.3

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 (231) hide show
  1. package/ARCHITECTURE.md +11 -12
  2. package/docker-entrypoint.sh +13 -1
  3. package/docker-init-apt-root.sh +79 -6
  4. package/openapi.yaml +336 -21
  5. package/package.json +1 -1
  6. package/src/__tests__/agent-loop-exit-reason.test.ts +272 -0
  7. package/src/__tests__/agent-loop-provider-error-recording.test.ts +195 -0
  8. package/src/__tests__/compactor-tail-resolution.test.ts +107 -1
  9. package/src/__tests__/config-get-vision-flag.test.ts +136 -0
  10. package/src/__tests__/config-loader-backfill.test.ts +115 -18
  11. package/src/__tests__/context-token-estimator.test.ts +30 -65
  12. package/src/__tests__/conversation-agent-loop.test.ts +57 -1
  13. package/src/__tests__/conversation-media-retry.test.ts +19 -8
  14. package/src/__tests__/conversation-runtime-assembly.test.ts +26 -4
  15. package/src/__tests__/date-context.test.ts +45 -0
  16. package/src/__tests__/external-plugin-loader.test.ts +91 -19
  17. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +0 -1
  18. package/src/__tests__/guardian-dispatch.test.ts +1 -0
  19. package/src/__tests__/heartbeat-service.test.ts +24 -164
  20. package/src/__tests__/helpers/channel-test-adapter.ts +0 -2
  21. package/src/__tests__/host-app-control-proxy.test.ts +241 -0
  22. package/src/__tests__/host-proxy-preactivation.test.ts +200 -13
  23. package/src/__tests__/injector-background-turn.test.ts +153 -0
  24. package/src/__tests__/injector-chain.test.ts +5 -0
  25. package/src/__tests__/lifecycle-memory-v2-seed.test.ts +9 -2
  26. package/src/__tests__/llm-callsite-catalog.test.ts +25 -0
  27. package/src/__tests__/llm-catalog-parity.test.ts +3 -0
  28. package/src/__tests__/llm-request-log-agent-loop-exit-reason.test.ts +116 -0
  29. package/src/__tests__/llm-request-log-error-payload.test.ts +138 -0
  30. package/src/__tests__/llm-request-log-source-clickhouse.test.ts +2 -0
  31. package/src/__tests__/llm-resolver.test.ts +255 -2
  32. package/src/__tests__/managed-profile-guard.test.ts +10 -0
  33. package/src/__tests__/notification-decision-fallback.test.ts +0 -91
  34. package/src/__tests__/notification-decision-strategy.test.ts +14 -31
  35. package/src/__tests__/notification-deep-link.test.ts +15 -0
  36. package/src/__tests__/notification-guardian-path.test.ts +1 -2
  37. package/src/__tests__/notification-platform-adapter.test.ts +5 -4
  38. package/src/__tests__/notification-telegram-adapter.test.ts +1 -0
  39. package/src/__tests__/notification-vellum-adapter.test.ts +113 -0
  40. package/src/__tests__/openai-provider.test.ts +218 -3
  41. package/src/__tests__/openai-responses-cutover-guard.test.ts +3 -3
  42. package/src/__tests__/openrouter-provider-only.test.ts +51 -3
  43. package/src/__tests__/openrouter-token-estimation.test.ts +34 -25
  44. package/src/__tests__/platform-proxy-context.test.ts +6 -1
  45. package/src/__tests__/plugin-tool-contribution.test.ts +3 -3
  46. package/src/__tests__/plugin-types.test.ts +2 -2
  47. package/src/__tests__/provider-catalog-visibility.test.ts +16 -0
  48. package/src/__tests__/provider-platform-proxy-integration.test.ts +27 -25
  49. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -1
  50. package/src/__tests__/system-prompt.test.ts +6 -73
  51. package/src/__tests__/workspace-migration-087-memory-router-balanced-profile.test.ts +228 -0
  52. package/src/a2a/__tests__/agent-card.test.ts +98 -0
  53. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +597 -0
  54. package/src/a2a/__tests__/protocol-helpers.test.ts +113 -0
  55. package/src/a2a/__tests__/task-store.test.ts +246 -0
  56. package/src/a2a/agent-card.ts +58 -0
  57. package/src/a2a/feature-gate.ts +8 -0
  58. package/src/a2a/protocol-constants.ts +21 -0
  59. package/src/a2a/protocol-errors.ts +50 -0
  60. package/src/a2a/protocol-types.ts +162 -0
  61. package/src/a2a/task-store.ts +168 -0
  62. package/src/agent/loop.ts +167 -18
  63. package/src/channels/config.ts +9 -0
  64. package/src/channels/types.ts +14 -0
  65. package/src/cli/{__tests__ → commands/__tests__}/notifications.test.ts +201 -28
  66. package/src/cli/commands/__tests__/schedules.test.ts +469 -0
  67. package/src/cli/commands/notifications.ts +65 -35
  68. package/src/cli/commands/plugins.ts +67 -0
  69. package/src/cli/commands/schedules.ts +297 -5
  70. package/src/cli/lib/__tests__/search-plugins.test.ts +261 -0
  71. package/src/cli/lib/install-from-github.ts +8 -9
  72. package/src/cli/lib/search-plugins.ts +163 -0
  73. package/src/cli/program.ts +14 -0
  74. package/src/config/assistant-feature-flags.ts +24 -54
  75. package/src/config/bundled-skills/app-builder/SKILL.md +117 -1
  76. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
  77. package/src/config/call-site-defaults.ts +105 -0
  78. package/src/config/feature-flag-registry.json +21 -29
  79. package/src/config/llm-resolver.ts +52 -1
  80. package/src/config/schema.ts +2 -0
  81. package/src/config/schemas/__tests__/memory-v2.test.ts +3 -3
  82. package/src/config/schemas/channels.ts +9 -0
  83. package/src/config/schemas/conversations.ts +10 -0
  84. package/src/config/schemas/heartbeat.ts +14 -0
  85. package/src/config/schemas/llm.ts +1 -3
  86. package/src/config/schemas/memory-retrospective.ts +1 -1
  87. package/src/config/schemas/memory-v2.ts +4 -4
  88. package/src/config/schemas/memory.ts +3 -1
  89. package/src/config/seed-inference-profiles.ts +99 -29
  90. package/src/context/compactor.ts +72 -12
  91. package/src/context/token-estimator.ts +32 -34
  92. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +3 -22
  93. package/src/daemon/conversation-agent-loop-handlers.ts +78 -0
  94. package/src/daemon/conversation-agent-loop.ts +29 -2
  95. package/src/daemon/conversation-runtime-assembly.ts +9 -0
  96. package/src/daemon/conversation.ts +0 -7
  97. package/src/daemon/date-context.ts +40 -0
  98. package/src/daemon/guardian-action-generators.ts +1 -125
  99. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +248 -0
  100. package/src/daemon/handlers/__tests__/config-a2a-invite.test.ts +154 -0
  101. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +133 -0
  102. package/src/daemon/handlers/__tests__/config-a2a.test.ts +95 -0
  103. package/src/daemon/handlers/config-a2a.ts +289 -0
  104. package/src/daemon/handlers/conversations.ts +1 -0
  105. package/src/daemon/host-app-control-proxy.ts +69 -18
  106. package/src/daemon/host-proxy-preactivation.ts +85 -18
  107. package/src/daemon/lifecycle.ts +49 -61
  108. package/src/daemon/memory-v2-startup.ts +49 -13
  109. package/src/daemon/message-types/notifications.ts +21 -0
  110. package/src/daemon/pkb-reminder-builder.test.ts +10 -53
  111. package/src/daemon/pkb-reminder-builder.ts +4 -19
  112. package/src/daemon/process-message.ts +3 -0
  113. package/src/daemon/skill-memory-refresh.ts +5 -1
  114. package/src/daemon/wake-target-adapter.ts +2 -0
  115. package/src/export/__tests__/transcript-formatter.test.ts +121 -0
  116. package/src/export/transcript-formatter.ts +54 -20
  117. package/src/heartbeat/__tests__/heartbeat-service.test.ts +44 -0
  118. package/src/heartbeat/heartbeat-service.ts +34 -191
  119. package/src/home/__tests__/feed-types.test.ts +40 -0
  120. package/src/home/feed-types.ts +14 -2
  121. package/src/ipc/cli-client.ts +147 -45
  122. package/src/memory/__tests__/conversation-queries.test.ts +220 -0
  123. package/src/memory/__tests__/memory-retrospective-enqueue.test.ts +2 -50
  124. package/src/memory/__tests__/memory-retrospective-job.test.ts +87 -4
  125. package/src/memory/conversation-queries.ts +87 -1
  126. package/src/memory/conversation-title-service.ts +26 -4
  127. package/src/memory/db-init.ts +6 -0
  128. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +84 -3
  129. package/src/memory/graph/conversation-graph-memory.ts +18 -6
  130. package/src/memory/graph/tools.ts +6 -37
  131. package/src/memory/invite-store.ts +53 -0
  132. package/src/memory/llm-request-log-source-clickhouse.ts +7 -2
  133. package/src/memory/llm-request-log-store.ts +92 -1
  134. package/src/memory/memory-retrospective-enqueue.ts +1 -20
  135. package/src/memory/memory-retrospective-job.ts +33 -6
  136. package/src/memory/migrations/250-provider-connection-base-url-and-models.ts +28 -0
  137. package/src/memory/migrations/251-a2a-tasks.ts +49 -0
  138. package/src/memory/migrations/252-llm-request-log-agent-loop-exit-reason.ts +32 -0
  139. package/src/memory/migrations/index.ts +3 -0
  140. package/src/memory/migrations/registry.ts +8 -0
  141. package/src/memory/schema/a2a.ts +15 -0
  142. package/src/memory/schema/index.ts +1 -0
  143. package/src/memory/schema/inference.ts +2 -0
  144. package/src/memory/schema/infrastructure.ts +1 -0
  145. package/src/memory/v2/__tests__/activation-store.test.ts +25 -23
  146. package/src/memory/v2/__tests__/cli-command-store.test.ts +404 -0
  147. package/src/memory/v2/__tests__/frontmatter-sweep.test.ts +25 -4
  148. package/src/memory/v2/__tests__/injection.test.ts +190 -3
  149. package/src/memory/v2/__tests__/static-context.test.ts +12 -1
  150. package/src/memory/v2/activation-store.ts +14 -16
  151. package/src/memory/v2/cli-command-content.ts +19 -0
  152. package/src/memory/v2/cli-command-store.ts +304 -0
  153. package/src/memory/v2/frontmatter-sweep.ts +7 -1
  154. package/src/memory/v2/injection.ts +49 -20
  155. package/src/memory/v2/page-index.ts +38 -13
  156. package/src/memory/v2/static-context.ts +4 -4
  157. package/src/memory/v2/types.ts +23 -0
  158. package/src/messaging/providers/a2a/__tests__/deliver.test.ts +274 -0
  159. package/src/messaging/providers/a2a/deliver.ts +156 -0
  160. package/src/messaging/providers/gmail/client.ts +9 -2
  161. package/src/messaging/providers/index.ts +11 -2
  162. package/src/notifications/__tests__/broadcaster.test.ts +203 -0
  163. package/src/notifications/__tests__/decision-engine.test.ts +283 -0
  164. package/src/notifications/__tests__/deterministic-checks.test.ts +286 -0
  165. package/src/notifications/__tests__/emit-signal-home-feed.test.ts +1 -0
  166. package/src/notifications/__tests__/home-feed-side-effect.test.ts +430 -7
  167. package/src/notifications/adapters/macos.ts +12 -2
  168. package/src/notifications/broadcaster.ts +29 -4
  169. package/src/notifications/copy-composer.ts +17 -64
  170. package/src/notifications/decision-engine.ts +111 -44
  171. package/src/notifications/deterministic-checks.ts +96 -0
  172. package/src/notifications/emit-signal.ts +1 -0
  173. package/src/notifications/home-feed-side-effect.ts +85 -6
  174. package/src/notifications/signal.ts +0 -4
  175. package/src/notifications/types.ts +8 -0
  176. package/src/oauth/platform-connection.test.ts +43 -3
  177. package/src/oauth/platform-connection.ts +13 -4
  178. package/src/plugins/defaults/injectors.ts +38 -19
  179. package/src/plugins/external-plugin-loader.ts +82 -10
  180. package/src/plugins/types.ts +16 -7
  181. package/src/prompts/__tests__/system-prompt.test.ts +6 -51
  182. package/src/prompts/__tests__/task-progress-hint-section.test.ts +4 -8
  183. package/src/prompts/system-prompt.ts +0 -8
  184. package/src/prompts/templates/BOOTSTRAP.md +5 -5
  185. package/src/prompts/templates/system-sections.ts +0 -9
  186. package/src/providers/__tests__/inference.test.ts +2 -0
  187. package/src/providers/call-site-routing.ts +24 -6
  188. package/src/providers/connection-resolution.ts +63 -13
  189. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +74 -0
  190. package/src/providers/inference/__tests__/connections-openai-compatible.test.ts +175 -0
  191. package/src/providers/inference/__tests__/connections-status-label.test.ts +15 -0
  192. package/src/providers/inference/adapter-factory.ts +9 -20
  193. package/src/providers/inference/auth.ts +12 -0
  194. package/src/providers/inference/backfill.ts +14 -1
  195. package/src/providers/inference/connections.ts +85 -5
  196. package/src/providers/inference/resolve-auth.ts +2 -0
  197. package/src/providers/model-catalog.ts +199 -244
  198. package/src/providers/model-intents.ts +3 -3
  199. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +235 -0
  200. package/src/providers/openai/chat-completions-provider.ts +159 -6
  201. package/src/providers/openrouter/client.ts +42 -4
  202. package/src/providers/platform-proxy/constants.ts +3 -4
  203. package/src/providers/provider-catalog-visibility.ts +3 -1
  204. package/src/providers/provider-send-message.ts +27 -12
  205. package/src/providers/registry.ts +30 -1
  206. package/src/runtime/agent-wake.ts +61 -1
  207. package/src/runtime/auth/route-policy.ts +13 -0
  208. package/src/runtime/http-server.ts +7 -16
  209. package/src/runtime/http-types.ts +0 -47
  210. package/src/runtime/routes/__tests__/consolidation-routes.test.ts +258 -0
  211. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +66 -4
  212. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +275 -44
  213. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +12 -0
  214. package/src/runtime/routes/channel-availability-routes.ts +5 -0
  215. package/src/runtime/routes/consolidation-routes.ts +100 -0
  216. package/src/runtime/routes/conversation-query-routes.ts +70 -11
  217. package/src/runtime/routes/conversation-routes.ts +7 -0
  218. package/src/runtime/routes/index.ts +2 -0
  219. package/src/runtime/routes/inference-provider-connection-routes.ts +134 -1
  220. package/src/runtime/routes/integrations/a2a.ts +235 -0
  221. package/src/runtime/routes/llm-call-sites-routes.ts +11 -1
  222. package/src/runtime/routes/subagents-routes.ts +41 -0
  223. package/src/subagent/manager.ts +2 -0
  224. package/src/tools/memory/register.ts +1 -9
  225. package/src/tools/registry.ts +2 -2
  226. package/src/tools/types.ts +37 -2
  227. package/src/workspace/migrations/087-memory-router-balanced-profile.ts +91 -0
  228. package/src/workspace/migrations/registry.ts +2 -0
  229. package/src/__tests__/guardian-action-conversation-turn.test.ts +0 -441
  230. package/src/memory/graph/__tests__/remember-description.test.ts +0 -55
  231. package/src/runtime/guardian-action-conversation-turn.ts +0 -99
@@ -135,19 +135,26 @@ export async function cliIpcCall<T = unknown>(
135
135
 
136
136
  const reqId = crypto.randomUUID();
137
137
 
138
- opts?.signal?.addEventListener("abort", () => {
139
- finish({ ok: false, error: "Request aborted" });
140
- }, { once: true });
138
+ opts?.signal?.addEventListener(
139
+ "abort",
140
+ () => {
141
+ finish({ ok: false, error: "Request aborted" });
142
+ },
143
+ { once: true },
144
+ );
141
145
 
142
146
  const reader = new IpcFrameReader(
143
147
  (envelope) => {
144
148
  if (envelope.id !== reqId) return;
145
149
  const msg = envelope as IpcResponse;
146
150
  if (msg.error) {
147
- finish({ ok: false, error: msg.error,
151
+ finish({
152
+ ok: false,
153
+ error: msg.error,
148
154
  ...(msg.statusCode != null && { statusCode: msg.statusCode }),
149
155
  ...(msg.errorCode != null && { errorCode: msg.errorCode }),
150
- ...(msg.errorDetails != null && { errorDetails: msg.errorDetails }) });
156
+ ...(msg.errorDetails != null && { errorDetails: msg.errorDetails }),
157
+ });
151
158
  } else {
152
159
  finish({ ok: true, result: msg.result as T });
153
160
  }
@@ -199,7 +206,13 @@ export async function cliIpcCallBinary(
199
206
  opts?: { timeoutMs?: number; signal?: AbortSignal },
200
207
  ): Promise<
201
208
  | { ok: true; headers: Record<string, string>; bytes: Uint8Array }
202
- | { ok: false; error: string; statusCode?: number; errorCode?: string; errorDetails?: unknown }
209
+ | {
210
+ ok: false;
211
+ error: string;
212
+ statusCode?: number;
213
+ errorCode?: string;
214
+ errorDetails?: unknown;
215
+ }
203
216
  > {
204
217
  if (opts?.signal?.aborted) {
205
218
  throw opts.signal.reason ?? new DOMException("Aborted", "AbortError");
@@ -215,7 +228,13 @@ export async function cliIpcCallBinary(
215
228
  const finish = (
216
229
  result:
217
230
  | { ok: true; headers: Record<string, string>; bytes: Uint8Array }
218
- | { ok: false; error: string; statusCode?: number; errorCode?: string; errorDetails?: unknown },
231
+ | {
232
+ ok: false;
233
+ error: string;
234
+ statusCode?: number;
235
+ errorCode?: string;
236
+ errorDetails?: unknown;
237
+ },
219
238
  ) => {
220
239
  if (settled) return;
221
240
  settled = true;
@@ -226,8 +245,14 @@ export async function cliIpcCallBinary(
226
245
  };
227
246
 
228
247
  const connectTimer = setTimeout(() => {
229
- log.debug({ method, socketPath, timeoutMs: CONNECT_TIMEOUT_MS }, "CLI IPC binary connect timed out");
230
- finish({ ok: false, error: `Could not connect to the assistant at ${socketPath}.\nRun \`assistant status\` to check, or \`assistant gateway start\` to start it.` });
248
+ log.debug(
249
+ { method, socketPath, timeoutMs: CONNECT_TIMEOUT_MS },
250
+ "CLI IPC binary connect timed out",
251
+ );
252
+ finish({
253
+ ok: false,
254
+ error: `Could not connect to the assistant at ${socketPath}.\nRun \`assistant status\` to check, or \`assistant gateway start\` to start it.`,
255
+ });
231
256
  }, CONNECT_TIMEOUT_MS);
232
257
 
233
258
  const socket = new Socket();
@@ -235,7 +260,10 @@ export async function cliIpcCallBinary(
235
260
 
236
261
  socket.on("error", (err) => {
237
262
  const code = (err as NodeJS.ErrnoException).code;
238
- log.debug({ err, code, method, socketPath }, "CLI IPC binary socket error");
263
+ log.debug(
264
+ { err, code, method, socketPath },
265
+ "CLI IPC binary socket error",
266
+ );
239
267
  finish({
240
268
  ok: false,
241
269
  error:
@@ -258,23 +286,37 @@ export async function cliIpcCallBinary(
258
286
 
259
287
  const reqId = crypto.randomUUID();
260
288
 
261
- opts?.signal?.addEventListener("abort", () => {
262
- finish({ ok: false, error: "Request aborted" });
263
- }, { once: true });
289
+ opts?.signal?.addEventListener(
290
+ "abort",
291
+ () => {
292
+ finish({ ok: false, error: "Request aborted" });
293
+ },
294
+ { once: true },
295
+ );
264
296
 
265
297
  const reader = new IpcFrameReader(
266
298
  (envelope, binary) => {
267
299
  if (envelope.id !== reqId) return;
268
300
  const msg = envelope as IpcResponse;
269
301
  if (msg.error) {
270
- finish({ ok: false, error: msg.error,
302
+ finish({
303
+ ok: false,
304
+ error: msg.error,
271
305
  ...(msg.statusCode != null && { statusCode: msg.statusCode }),
272
306
  ...(msg.errorCode != null && { errorCode: msg.errorCode }),
273
- ...(msg.errorDetails != null && { errorDetails: msg.errorDetails }) });
307
+ ...(msg.errorDetails != null && { errorDetails: msg.errorDetails }),
308
+ });
274
309
  } else if (binary === undefined) {
275
- finish({ ok: false, error: "Expected binary frame but received JSON-only response" });
310
+ finish({
311
+ ok: false,
312
+ error: "Expected binary frame but received JSON-only response",
313
+ });
276
314
  } else {
277
- finish({ ok: true, headers: (envelope.headers ?? {}) as Record<string, string>, bytes: binary });
315
+ finish({
316
+ ok: true,
317
+ headers: (envelope.headers ?? {}) as Record<string, string>,
318
+ bytes: binary,
319
+ });
278
320
  }
279
321
  },
280
322
  (err) => finish({ ok: false, error: err.message }),
@@ -285,7 +327,10 @@ export async function cliIpcCallBinary(
285
327
  writeMessage(socket, { id: reqId, method, params });
286
328
 
287
329
  callTimer = setTimeout(() => {
288
- log.debug({ method, socketPath, timeoutMs: callTimeoutMs }, "CLI IPC binary call timed out");
330
+ log.debug(
331
+ { method, socketPath, timeoutMs: callTimeoutMs },
332
+ "CLI IPC binary call timed out",
333
+ );
289
334
  finish({ ok: false, error: "Request timed out" });
290
335
  }, callTimeoutMs);
291
336
 
@@ -323,24 +368,42 @@ export async function cliIpcCallStream(
323
368
  params?: Record<string, unknown>,
324
369
  opts?: { firstByteTimeoutMs?: number; signal?: AbortSignal },
325
370
  ): Promise<
326
- | { ok: true; headers: Record<string, string>; body: ReadableStream<Uint8Array>; abort: () => void }
327
- | { ok: false; error: string; statusCode?: number; errorCode?: string; errorDetails?: unknown }
371
+ | {
372
+ ok: true;
373
+ headers: Record<string, string>;
374
+ body: ReadableStream<Uint8Array>;
375
+ abort: () => void;
376
+ }
377
+ | {
378
+ ok: false;
379
+ error: string;
380
+ statusCode?: number;
381
+ errorCode?: string;
382
+ errorDetails?: unknown;
383
+ }
328
384
  > {
329
385
  if (opts?.signal?.aborted) {
330
386
  throw opts.signal.reason ?? new DOMException("Aborted", "AbortError");
331
387
  }
332
388
 
333
389
  const socketPath = getAssistantSocketPath();
334
- const firstByteTimeoutMs = opts?.firstByteTimeoutMs ?? DEFAULT_FIRST_BYTE_TIMEOUT_MS;
390
+ const firstByteTimeoutMs =
391
+ opts?.firstByteTimeoutMs ?? DEFAULT_FIRST_BYTE_TIMEOUT_MS;
335
392
 
336
393
  return new Promise((resolve) => {
337
394
  let settled = false;
338
395
  let firstByteTimer: ReturnType<typeof setTimeout> | undefined;
339
- let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
340
-
341
- const finishError = (
342
- result: { ok: false; error: string; statusCode?: number; errorCode?: string; errorDetails?: unknown },
343
- ) => {
396
+ let streamController:
397
+ | ReadableStreamDefaultController<Uint8Array>
398
+ | undefined;
399
+
400
+ const finishError = (result: {
401
+ ok: false;
402
+ error: string;
403
+ statusCode?: number;
404
+ errorCode?: string;
405
+ errorDetails?: unknown;
406
+ }) => {
344
407
  if (settled) return;
345
408
  settled = true;
346
409
  clearTimeout(connectTimer);
@@ -366,8 +429,14 @@ export async function cliIpcCallStream(
366
429
  };
367
430
 
368
431
  const connectTimer = setTimeout(() => {
369
- log.debug({ method, socketPath, timeoutMs: CONNECT_TIMEOUT_MS }, "CLI IPC stream connect timed out");
370
- finishError({ ok: false, error: `Could not connect to the assistant at ${socketPath}.\nRun \`assistant status\` to check, or \`assistant gateway start\` to start it.` });
432
+ log.debug(
433
+ { method, socketPath, timeoutMs: CONNECT_TIMEOUT_MS },
434
+ "CLI IPC stream connect timed out",
435
+ );
436
+ finishError({
437
+ ok: false,
438
+ error: `Could not connect to the assistant at ${socketPath}.\nRun \`assistant status\` to check, or \`assistant gateway start\` to start it.`,
439
+ });
371
440
  }, CONNECT_TIMEOUT_MS);
372
441
 
373
442
  const socket = new Socket();
@@ -375,7 +444,10 @@ export async function cliIpcCallStream(
375
444
 
376
445
  socket.on("error", (err) => {
377
446
  const code = (err as NodeJS.ErrnoException).code;
378
- log.debug({ err, code, method, socketPath }, "CLI IPC stream socket error");
447
+ log.debug(
448
+ { err, code, method, socketPath },
449
+ "CLI IPC stream socket error",
450
+ );
379
451
  if (!settled) {
380
452
  finishError({
381
453
  ok: false,
@@ -399,24 +471,35 @@ export async function cliIpcCallStream(
399
471
  : "Connection closed before response",
400
472
  });
401
473
  } else if (streamController) {
402
- streamController.error(new Error("Connection closed before stream ended"));
474
+ streamController.error(
475
+ new Error("Connection closed before stream ended"),
476
+ );
403
477
  streamController = undefined;
404
478
  }
405
479
  });
406
480
 
407
481
  const reqId = crypto.randomUUID();
408
482
 
409
- opts?.signal?.addEventListener("abort", () => { abort(); }, { once: true });
483
+ opts?.signal?.addEventListener(
484
+ "abort",
485
+ () => {
486
+ abort();
487
+ },
488
+ { once: true },
489
+ );
410
490
 
411
491
  const reader = new IpcFrameReader(
412
492
  (envelope) => {
413
493
  // Non-streaming envelope with error (e.g. method not found, auth failure)
414
494
  if (envelope.id !== reqId) return;
415
495
  const msg = envelope as IpcResponse;
416
- finishError({ ok: false, error: msg.error ?? "Unexpected non-streaming response",
496
+ finishError({
497
+ ok: false,
498
+ error: msg.error ?? "Unexpected non-streaming response",
417
499
  ...(msg.statusCode != null && { statusCode: msg.statusCode }),
418
500
  ...(msg.errorCode != null && { errorCode: msg.errorCode }),
419
- ...(msg.errorDetails != null && { errorDetails: msg.errorDetails }) });
501
+ ...(msg.errorDetails != null && { errorDetails: msg.errorDetails }),
502
+ });
420
503
  },
421
504
  (err) => finishError({ ok: false, error: err.message }),
422
505
  {
@@ -437,7 +520,12 @@ export async function cliIpcCallStream(
437
520
  });
438
521
  settled = true;
439
522
  clearTimeout(connectTimer);
440
- resolve({ ok: true, headers: (envelope.headers ?? {}) as Record<string, string>, body, abort });
523
+ resolve({
524
+ ok: true,
525
+ headers: (envelope.headers ?? {}) as Record<string, string>,
526
+ body,
527
+ abort,
528
+ });
441
529
  },
442
530
  onStreamChunk: (chunk) => {
443
531
  streamController?.enqueue(chunk);
@@ -455,8 +543,14 @@ export async function cliIpcCallStream(
455
543
  writeMessage(socket, { id: reqId, method, params });
456
544
 
457
545
  firstByteTimer = setTimeout(() => {
458
- log.debug({ method, socketPath, timeoutMs: firstByteTimeoutMs }, "CLI IPC stream first-byte timeout");
459
- finishError({ ok: false, error: "Stream timed out waiting for first byte" });
546
+ log.debug(
547
+ { method, socketPath, timeoutMs: firstByteTimeoutMs },
548
+ "CLI IPC stream first-byte timeout",
549
+ );
550
+ finishError({
551
+ ok: false,
552
+ error: "Stream timed out waiting for first byte",
553
+ });
460
554
  }, firstByteTimeoutMs);
461
555
 
462
556
  socket.on("data", (chunk) => {
@@ -491,13 +585,21 @@ export function exitFromIpcResult(
491
585
  _cmd?: unknown,
492
586
  ): never {
493
587
  process.stderr.write((r.error ?? "Unknown error") + "\n");
494
- if (r.statusCode === undefined) {
495
- process.exit(10);
496
- } else if (r.statusCode >= 500) {
497
- process.exit(3);
498
- } else if (r.statusCode >= 400) {
499
- process.exit(2);
500
- } else {
501
- process.exit(1);
502
- }
588
+ process.exit(exitCodeFromIpcResult(r));
589
+ }
590
+
591
+ /**
592
+ * Map an IPC error result to its CLI process exit code without exiting.
593
+ *
594
+ * Use this when callers want to emit a structured response (e.g. a JSON
595
+ * error envelope in `--json` mode) before terminating with the same status
596
+ * code that {@link exitFromIpcResult} would produce.
597
+ *
598
+ * Exit code matrix matches {@link exitFromIpcResult}.
599
+ */
600
+ export function exitCodeFromIpcResult(r: { statusCode?: number }): number {
601
+ if (r.statusCode === undefined) return 10;
602
+ if (r.statusCode >= 500) return 3;
603
+ if (r.statusCode >= 400) return 2;
604
+ return 1;
503
605
  }
@@ -14,7 +14,9 @@ import {
14
14
  buildExcerpt,
15
15
  buildRecallEvidenceExcerpt,
16
16
  countConversations,
17
+ getMessageRoleStatsByConversation,
17
18
  listConversations,
19
+ listConversationsBySource,
18
20
  } from "../conversation-queries.js";
19
21
  import { getDb } from "../db-connection.js";
20
22
  import { initializeDb } from "../db-init.js";
@@ -261,3 +263,221 @@ describe("listConversations", () => {
261
263
  expect(fgList).toHaveLength(0);
262
264
  });
263
265
  });
266
+
267
+ describe("listConversationsBySource", () => {
268
+ beforeEach(() => {
269
+ resetTables();
270
+ });
271
+
272
+ test("returns only conversations whose source matches exactly", () => {
273
+ createConversation({
274
+ title: "consol-1",
275
+ source: "memory_v2_consolidation",
276
+ });
277
+ createConversation({
278
+ title: "consol-2",
279
+ source: "memory_v2_consolidation",
280
+ });
281
+ createConversation({ title: "heartbeat-1", source: "heartbeat" });
282
+ createConversation({ title: "user-1", source: "user" });
283
+
284
+ const results = listConversationsBySource("memory_v2_consolidation");
285
+
286
+ expect(results).toHaveLength(2);
287
+ expect(results.map((r) => r.title).sort()).toEqual([
288
+ "consol-1",
289
+ "consol-2",
290
+ ]);
291
+ });
292
+
293
+ test("orders by createdAt descending", () => {
294
+ const a = createConversation({
295
+ title: "a",
296
+ source: "memory_v2_consolidation",
297
+ });
298
+ const b = createConversation({
299
+ title: "b",
300
+ source: "memory_v2_consolidation",
301
+ });
302
+ const c = createConversation({
303
+ title: "c",
304
+ source: "memory_v2_consolidation",
305
+ });
306
+ // Force distinct createdAt regardless of ms-clock granularity.
307
+ rawRun("UPDATE conversations SET created_at = ? WHERE id = ?", 1000, a.id);
308
+ rawRun("UPDATE conversations SET created_at = ? WHERE id = ?", 3000, b.id);
309
+ rawRun("UPDATE conversations SET created_at = ? WHERE id = ?", 2000, c.id);
310
+
311
+ const results = listConversationsBySource("memory_v2_consolidation");
312
+
313
+ expect(results.map((r) => r.id)).toEqual([b.id, c.id, a.id]);
314
+ });
315
+
316
+ test("honors the limit parameter", () => {
317
+ for (let i = 0; i < 5; i++) {
318
+ createConversation({
319
+ title: `consol-${i}`,
320
+ source: "memory_v2_consolidation",
321
+ });
322
+ }
323
+
324
+ const results = listConversationsBySource("memory_v2_consolidation", 3);
325
+
326
+ expect(results).toHaveLength(3);
327
+ });
328
+
329
+ test("includes archived rows by default", () => {
330
+ const conv = createConversation({
331
+ title: "archived",
332
+ source: "memory_v2_consolidation",
333
+ });
334
+ rawRun(
335
+ "UPDATE conversations SET archived_at = ? WHERE id = ?",
336
+ Date.now(),
337
+ conv.id,
338
+ );
339
+
340
+ const results = listConversationsBySource("memory_v2_consolidation");
341
+
342
+ expect(results).toHaveLength(1);
343
+ expect(results[0]!.archivedAt).not.toBeNull();
344
+ });
345
+
346
+ test("excludes archived rows when includeArchived is false", () => {
347
+ const archived = createConversation({
348
+ title: "archived",
349
+ source: "memory_v2_consolidation",
350
+ });
351
+ createConversation({ title: "live", source: "memory_v2_consolidation" });
352
+ rawRun(
353
+ "UPDATE conversations SET archived_at = ? WHERE id = ?",
354
+ Date.now(),
355
+ archived.id,
356
+ );
357
+
358
+ const results = listConversationsBySource("memory_v2_consolidation", 20, {
359
+ includeArchived: false,
360
+ });
361
+
362
+ expect(results).toHaveLength(1);
363
+ expect(results[0]!.title).toBe("live");
364
+ });
365
+
366
+ test("does not apply the subagent-exclusion that listConversations does", () => {
367
+ // The defensive `source != 'subagent'` carve-out in listConversations is
368
+ // a foreground/background bucketing concern. A caller asking for the
369
+ // exact `subagent` source via this query gets exactly that.
370
+ createConversation({ title: "sub-1", source: "subagent" });
371
+
372
+ const results = listConversationsBySource("subagent");
373
+
374
+ expect(results).toHaveLength(1);
375
+ expect(results[0]!.title).toBe("sub-1");
376
+ });
377
+ });
378
+
379
+ describe("getMessageRoleStatsByConversation", () => {
380
+ beforeEach(() => {
381
+ resetTables();
382
+ });
383
+
384
+ function insertMessage(
385
+ conversationId: string,
386
+ role: string,
387
+ createdAt: number,
388
+ ): void {
389
+ rawRun(
390
+ "INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)",
391
+ `msg-${conversationId}-${role}-${createdAt}`,
392
+ conversationId,
393
+ role,
394
+ "x",
395
+ createdAt,
396
+ );
397
+ }
398
+
399
+ test("returns empty map for empty input", () => {
400
+ const result = getMessageRoleStatsByConversation([]);
401
+ expect(result.size).toBe(0);
402
+ });
403
+
404
+ test("returns empty map when conversations exist but no matching role", () => {
405
+ const a = createConversation("a");
406
+ insertMessage(a.id, "user", 1000);
407
+
408
+ const result = getMessageRoleStatsByConversation([a.id], "assistant");
409
+
410
+ expect(result.size).toBe(0);
411
+ });
412
+
413
+ test("counts assistant messages and returns max createdAt", () => {
414
+ const a = createConversation("a");
415
+ insertMessage(a.id, "user", 1000);
416
+ insertMessage(a.id, "assistant", 1500);
417
+ insertMessage(a.id, "assistant", 2500);
418
+ insertMessage(a.id, "assistant", 2000);
419
+
420
+ const result = getMessageRoleStatsByConversation([a.id], "assistant");
421
+
422
+ expect(result.size).toBe(1);
423
+ expect(result.get(a.id)).toEqual({ count: 3, lastAt: 2500 });
424
+ });
425
+
426
+ test("does not count messages from other roles", () => {
427
+ const a = createConversation("a");
428
+ insertMessage(a.id, "user", 1000);
429
+ insertMessage(a.id, "user", 2000);
430
+ insertMessage(a.id, "system", 1500);
431
+ insertMessage(a.id, "assistant", 3000);
432
+
433
+ const result = getMessageRoleStatsByConversation([a.id], "assistant");
434
+
435
+ expect(result.get(a.id)).toEqual({ count: 1, lastAt: 3000 });
436
+ });
437
+
438
+ test("scopes to the supplied conversation ids", () => {
439
+ const a = createConversation("a");
440
+ const b = createConversation("b");
441
+ insertMessage(a.id, "assistant", 1000);
442
+ insertMessage(b.id, "assistant", 2000);
443
+
444
+ const result = getMessageRoleStatsByConversation([a.id], "assistant");
445
+
446
+ expect(result.size).toBe(1);
447
+ expect(result.has(a.id)).toBe(true);
448
+ expect(result.has(b.id)).toBe(false);
449
+ });
450
+
451
+ test("aggregates per-conversation across many ids in a single query", () => {
452
+ const a = createConversation("a");
453
+ const b = createConversation("b");
454
+ const c = createConversation("c");
455
+ insertMessage(a.id, "assistant", 1000);
456
+ insertMessage(a.id, "assistant", 1500);
457
+ insertMessage(b.id, "assistant", 2000);
458
+ // c has no assistant messages → absent from the result.
459
+
460
+ const result = getMessageRoleStatsByConversation(
461
+ [a.id, b.id, c.id],
462
+ "assistant",
463
+ );
464
+
465
+ expect(result.size).toBe(2);
466
+ expect(result.get(a.id)).toEqual({ count: 2, lastAt: 1500 });
467
+ expect(result.get(b.id)).toEqual({ count: 1, lastAt: 2000 });
468
+ expect(result.has(c.id)).toBe(false);
469
+ });
470
+
471
+ test("role parameter selects the counted role (defaults to assistant)", () => {
472
+ const a = createConversation("a");
473
+ insertMessage(a.id, "user", 1000);
474
+ insertMessage(a.id, "user", 2000);
475
+ insertMessage(a.id, "assistant", 1500);
476
+
477
+ const assistants = getMessageRoleStatsByConversation([a.id]);
478
+ expect(assistants.get(a.id)).toEqual({ count: 1, lastAt: 1500 });
479
+
480
+ const users = getMessageRoleStatsByConversation([a.id], "user");
481
+ expect(users.get(a.id)).toEqual({ count: 2, lastAt: 2000 });
482
+ });
483
+ });
@@ -11,26 +11,12 @@ mock.module("../../util/logger.js", () => ({
11
11
  // Mock state — reset between tests.
12
12
  // ---------------------------------------------------------------------------
13
13
 
14
- let flagEnabled = true;
15
14
  let sourceTag: string | null = null;
16
- let getConfigThrows = false;
17
15
  const upsertCalls: Array<{
18
16
  payload: { conversationId: string };
19
17
  runAfter: number;
20
18
  }> = [];
21
19
 
22
- mock.module("../../config/loader.js", () => ({
23
- getConfig: () => {
24
- if (getConfigThrows) throw new Error("boom");
25
- return {};
26
- },
27
- }));
28
-
29
- mock.module("../../config/assistant-feature-flags.js", () => ({
30
- isAssistantFeatureFlagEnabled: (_key: string, _config: unknown) =>
31
- flagEnabled,
32
- }));
33
-
34
20
  mock.module("../conversation-crud.js", () => ({
35
21
  getConversationSource: (_id: string) => sourceTag,
36
22
  }));
@@ -59,26 +45,11 @@ import {
59
45
 
60
46
  describe("enqueueMemoryRetrospectiveIfEnabled", () => {
61
47
  beforeEach(() => {
62
- flagEnabled = true;
63
48
  sourceTag = null;
64
- getConfigThrows = false;
65
49
  upsertCalls.length = 0;
66
50
  });
67
51
 
68
- test("flag offno upsert for any trigger", () => {
69
- flagEnabled = false;
70
- for (const trigger of [
71
- "interval",
72
- "message_count",
73
- "compaction",
74
- "lifecycle",
75
- ] as const) {
76
- enqueueMemoryRetrospectiveIfEnabled({ conversationId: "c1", trigger });
77
- }
78
- expect(upsertCalls).toHaveLength(0);
79
- });
80
-
81
- test("flag on, standard source — interval trigger enqueues with runAfter ≈ now", () => {
52
+ test("standard sourceinterval trigger enqueues with runAfter ≈ now", () => {
82
53
  const before = Date.now();
83
54
  enqueueMemoryRetrospectiveIfEnabled({
84
55
  conversationId: "c1",
@@ -113,17 +84,6 @@ describe("enqueueMemoryRetrospectiveIfEnabled", () => {
113
84
  });
114
85
  expect(upsertCalls).toHaveLength(0);
115
86
  });
116
-
117
- test("getConfig throws — no enqueue, no propagation", () => {
118
- getConfigThrows = true;
119
- expect(() =>
120
- enqueueMemoryRetrospectiveIfEnabled({
121
- conversationId: "c1",
122
- trigger: "interval",
123
- }),
124
- ).not.toThrow();
125
- expect(upsertCalls).toHaveLength(0);
126
- });
127
87
  });
128
88
 
129
89
  describe("isMemoryRetrospectiveConversation", () => {
@@ -149,9 +109,7 @@ describe("isMemoryRetrospectiveConversation", () => {
149
109
 
150
110
  describe("enqueueMemoryRetrospectiveOnCompaction", () => {
151
111
  beforeEach(() => {
152
- flagEnabled = true;
153
112
  sourceTag = null;
154
- getConfigThrows = false;
155
113
  upsertCalls.length = 0;
156
114
  });
157
115
 
@@ -162,16 +120,10 @@ describe("enqueueMemoryRetrospectiveOnCompaction", () => {
162
120
  expect(upsertCalls).toHaveLength(0);
163
121
  });
164
122
 
165
- test("guardian trust + flag on — enqueues with compaction debounce", () => {
123
+ test("guardian trust — enqueues with compaction debounce", () => {
166
124
  const before = Date.now();
167
125
  enqueueMemoryRetrospectiveOnCompaction("c1", "guardian");
168
126
  expect(upsertCalls).toHaveLength(1);
169
127
  expect(upsertCalls[0]!.runAfter).toBeGreaterThan(before + 100);
170
128
  });
171
-
172
- test("guardian trust + flag off — no enqueue", () => {
173
- flagEnabled = false;
174
- enqueueMemoryRetrospectiveOnCompaction("c1", "guardian");
175
- expect(upsertCalls).toHaveLength(0);
176
- });
177
129
  });