@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
@@ -65,10 +65,42 @@ mock.module("../conversation-crud.js", () => ({
65
65
  },
66
66
  }));
67
67
 
68
+ let transcriptFormatterCalls: Array<{
69
+ messageIds: string[];
70
+ timeZone?: string;
71
+ assistantName?: string | null;
72
+ userName?: string | null;
73
+ }> = [];
74
+
68
75
  mock.module("../../export/transcript-formatter.js", () => ({
69
76
  formatMessageSliceForTranscript: (
70
77
  messages: Array<{ id: string; createdAt: number }>,
71
- ) => messages.map((m) => `[msg ${m.id}]`).join("\n"),
78
+ options: {
79
+ timeZone?: string;
80
+ assistantName?: string | null;
81
+ userName?: string | null;
82
+ } = {},
83
+ ) => {
84
+ transcriptFormatterCalls.push({
85
+ messageIds: messages.map((m) => m.id),
86
+ timeZone: options.timeZone,
87
+ assistantName: options.assistantName,
88
+ userName: options.userName,
89
+ });
90
+ return messages.map((m) => `[msg ${m.id}]`).join("\n");
91
+ },
92
+ }));
93
+
94
+ let mockAssistantName: string | null = "Bob";
95
+ let mockUserName: string | null = "Alice";
96
+
97
+ mock.module("../../daemon/identity-helpers.js", () => ({
98
+ getAssistantName: () => mockAssistantName,
99
+ resolveUserName: (_workspaceDir: string) => mockUserName,
100
+ }));
101
+
102
+ mock.module("../../util/platform.js", () => ({
103
+ getWorkspaceDir: () => "/tmp/test-workspace",
72
104
  }));
73
105
 
74
106
  mock.module("../conversation-bootstrap.js", () => ({
@@ -102,9 +134,19 @@ mock.module("../jobs-store.js", () => ({
102
134
  import type { MemoryJob } from "../jobs-store.js";
103
135
  import { memoryRetrospectiveJob } from "../memory-retrospective-job.js";
104
136
 
105
- const stubConfig = {
106
- memory: { v2: { enabled: true } },
107
- } as unknown as Parameters<typeof memoryRetrospectiveJob>[1];
137
+ function makeConfig(
138
+ overrides: { userTimezone?: string; detectedTimezone?: string } = {},
139
+ ): Parameters<typeof memoryRetrospectiveJob>[1] {
140
+ return {
141
+ memory: { v2: { enabled: true } },
142
+ ui: {
143
+ userTimezone: overrides.userTimezone,
144
+ detectedTimezone: overrides.detectedTimezone,
145
+ },
146
+ } as unknown as Parameters<typeof memoryRetrospectiveJob>[1];
147
+ }
148
+
149
+ const stubConfig = makeConfig();
108
150
 
109
151
  function makeJob(conversationId = "src-conv-1"): MemoryJob<{
110
152
  conversationId?: string;
@@ -155,6 +197,9 @@ describe("memoryRetrospectiveJob", () => {
155
197
  bootstrappedConversationId = "bg-conv-new";
156
198
  bootstrapCalls = [];
157
199
  deletedConversationIds = [];
200
+ transcriptFormatterCalls = [];
201
+ mockAssistantName = "Bob";
202
+ mockUserName = "Alice";
158
203
  });
159
204
 
160
205
  test("first-run happy path: no state row, no prior retrospective, both pointer fields set on success", async () => {
@@ -274,6 +319,44 @@ describe("memoryRetrospectiveJob", () => {
274
319
  expect(hint).toContain("- a real save");
275
320
  });
276
321
 
322
+ test("transcript is formatted in the configured user timezone and the prompt discloses it", async () => {
323
+ const config = makeConfig({ userTimezone: "America/Los_Angeles" });
324
+ await memoryRetrospectiveJob(makeJob(), config);
325
+
326
+ expect(transcriptFormatterCalls).toHaveLength(1);
327
+ expect(transcriptFormatterCalls[0]!.timeZone).toBe("America/Los_Angeles");
328
+
329
+ const hint = wakeCalls[0]!.hint;
330
+ expect(hint).toContain("Timestamps are in America/Los_Angeles.");
331
+ });
332
+
333
+ test("detected timezone is used when no manual override is set", async () => {
334
+ const config = makeConfig({ detectedTimezone: "Europe/Berlin" });
335
+ await memoryRetrospectiveJob(makeJob(), config);
336
+
337
+ expect(transcriptFormatterCalls[0]!.timeZone).toBe("Europe/Berlin");
338
+
339
+ const hint = wakeCalls[0]!.hint;
340
+ expect(hint).toContain("Timestamps are in Europe/Berlin.");
341
+ });
342
+
343
+ test("resolved assistant and user display names are passed to the transcript formatter", async () => {
344
+ await memoryRetrospectiveJob(makeJob(), stubConfig);
345
+
346
+ expect(transcriptFormatterCalls).toHaveLength(1);
347
+ expect(transcriptFormatterCalls[0]!.assistantName).toBe("Bob");
348
+ expect(transcriptFormatterCalls[0]!.userName).toBe("Alice");
349
+ });
350
+
351
+ test("formatter receives null names when identity files are missing — formatter handles fallback", async () => {
352
+ mockAssistantName = null;
353
+ mockUserName = null;
354
+ await memoryRetrospectiveJob(makeJob(), stubConfig);
355
+
356
+ expect(transcriptFormatterCalls[0]!.assistantName).toBeNull();
357
+ expect(transcriptFormatterCalls[0]!.userName).toBeNull();
358
+ });
359
+
277
360
  test("non-remember tool_use blocks in the prior retro are ignored", async () => {
278
361
  priorRetroId = "prior-retro-conv-1";
279
362
  priorRetroMessages = [
@@ -1,4 +1,4 @@
1
- import { and, count, desc, eq, sql } from "drizzle-orm";
1
+ import { and, count, desc, eq, inArray, isNull, sql } from "drizzle-orm";
2
2
 
3
3
  import {
4
4
  parseExternalContentEnvelope,
@@ -86,6 +86,92 @@ export function listConversations(
86
86
  return query.all().map(parseConversation);
87
87
  }
88
88
 
89
+ /**
90
+ * List conversations matching an exact `source` value, ordered by `createdAt`
91
+ * descending. The surgical filter for "find every background run produced by
92
+ * job X" — heartbeat, memory_v2_consolidation, watcher-engine, etc. — since
93
+ * `source` is the canonical job-class distinguisher across the background
94
+ * bucket. `conversationType` + `group_id` only narrow to "background vs
95
+ * scheduled vs standard"; neither identifies which job produced the row.
96
+ *
97
+ * Filter is exact (no `LIKE`, no implicit exclusions): the route layer is
98
+ * responsible for knowing which source constants exist and passing one. The
99
+ * defensive `source != 'subagent'` carve-out applied by `listConversations`
100
+ * is deliberately NOT replicated here — a caller asking for an exact source
101
+ * gets exactly that source.
102
+ *
103
+ * @param source Exact match against `conversations.source`. Pass the
104
+ * canonical constant (e.g. `MEMORY_V2_CONSOLIDATION_SOURCE`).
105
+ * @param limit Maximum rows to return (default 20).
106
+ * @param opts.includeArchived Include rows with non-null `archivedAt`.
107
+ * Defaults to `true` so callers that want a full
108
+ * run history get one; pass `false` for views
109
+ * that hide archived rows.
110
+ */
111
+ export function listConversationsBySource(
112
+ source: string,
113
+ limit = 20,
114
+ opts?: { includeArchived?: boolean },
115
+ ): ConversationRow[] {
116
+ const db = getDb();
117
+ const includeArchived = opts?.includeArchived ?? true;
118
+ const where = includeArchived
119
+ ? eq(conversations.source, source)
120
+ : and(eq(conversations.source, source), isNull(conversations.archivedAt));
121
+ const rows = db
122
+ .select()
123
+ .from(conversations)
124
+ .where(where)
125
+ .orderBy(desc(conversations.createdAt))
126
+ .limit(limit)
127
+ .all();
128
+ return rows.map(parseConversation);
129
+ }
130
+
131
+ /**
132
+ * Per-conversation aggregate of messages with a specific role. Powers
133
+ * heartbeat-shaped run endpoints (e.g. `consolidation/runs`) that need a
134
+ * "did the agent emit any output?" signal stronger than
135
+ * `conversations.lastMessageAt` — which is bumped by the kickoff user
136
+ * prompt and so cannot distinguish "agent ran" from "agent dispatched but
137
+ * crashed before responding".
138
+ *
139
+ * Single batched aggregate query (no N+1). Conversations with zero matching
140
+ * messages are NOT present in the returned map — callers should treat a
141
+ * missing key as `{ count: 0, lastAt: null }`.
142
+ *
143
+ * @param conversationIds Conversation ids to look up (empty → empty map).
144
+ * @param role Message role to count (default `"assistant"`).
145
+ */
146
+ export function getMessageRoleStatsByConversation(
147
+ conversationIds: string[],
148
+ role: string = "assistant",
149
+ ): Map<string, { count: number; lastAt: number }> {
150
+ if (conversationIds.length === 0) return new Map();
151
+ const db = getDb();
152
+ const rows = db
153
+ .select({
154
+ conversationId: messages.conversationId,
155
+ count: sql<number>`COUNT(*)`.as("count"),
156
+ lastAt: sql<number>`MAX(${messages.createdAt})`.as("last_at"),
157
+ })
158
+ .from(messages)
159
+ .where(
160
+ and(
161
+ inArray(messages.conversationId, conversationIds),
162
+ eq(messages.role, role),
163
+ ),
164
+ )
165
+ .groupBy(messages.conversationId)
166
+ .all();
167
+ return new Map(
168
+ rows.map((r) => [
169
+ r.conversationId,
170
+ { count: Number(r.count), lastAt: Number(r.lastAt) },
171
+ ]),
172
+ );
173
+ }
174
+
89
175
  export function listPinnedConversations(): ConversationRow[] {
90
176
  ensureDisplayOrderMigration();
91
177
  ensureGroupMigration();
@@ -298,9 +298,11 @@ function buildTitleSystemPrompt(): string {
298
298
  "You generate ultra-concise conversation titles. Output ONLY the title text — no explanation, no quotes, no markdown, no preamble.",
299
299
  "",
300
300
  "Rules:",
301
- "- 2–6 words. Titles longer than 6 words are unacceptable — ruthlessly compress",
302
- "- Summarize the TOPIC, not the request or instructions",
303
- "- Noun phrases are ideal (e.g. 'Auth Middleware Rewrite', 'Docker Volume Mounts')",
301
+ "- 2–5 words maximum. Titles longer than 5 words are unacceptable — ruthlessly compress to a short noun phrase",
302
+ "- 40 characters absolute maximum if your title exceeds 40 characters it will be truncated and look broken",
303
+ "- Summarize only the TOPIC, not the request or instructions",
304
+ "- Noun phrases are ideal (e.g. 'Auth Middleware Rewrite', 'Docker Volume Mounts', 'Onboarding Flow')",
305
+ "- Think: what would make a scannable sidebar label?",
304
306
  "- Do NOT echo back what the user asked you to do",
305
307
  "- Do NOT respond to the conversation content",
306
308
  "- Do NOT assess feasibility or comment on capabilities",
@@ -353,13 +355,33 @@ const META_FAILURE_TITLES = new Set([
353
355
  "no content",
354
356
  ]);
355
357
 
358
+ const MAX_TITLE_LENGTH = 40;
359
+ const MAX_TITLE_WORDS = 7;
360
+
361
+ function truncateTitle(title: string): string {
362
+ if (title.length <= MAX_TITLE_LENGTH) return title;
363
+ const words = title.split(/\s+/);
364
+ if (words.length <= MAX_TITLE_WORDS) {
365
+ // Long words but few of them — truncate to char limit at word boundary
366
+ let result = "";
367
+ for (const word of words) {
368
+ const candidate = result ? result + " " + word : word;
369
+ if (candidate.length > MAX_TITLE_LENGTH) break;
370
+ result = candidate;
371
+ }
372
+ return result || title.slice(0, MAX_TITLE_LENGTH);
373
+ }
374
+ // Too many words — trim to 5 words
375
+ return words.slice(0, 5).join(" ");
376
+ }
377
+
356
378
  function normalizeTitle(raw: string): string {
357
379
  let title = raw.trim().replace(/^["']|["']$/g, "");
358
380
  title = stripMarkdown(title);
359
381
  if (META_FAILURE_TITLES.has(title.toLowerCase())) {
360
382
  return "";
361
383
  }
362
- return title;
384
+ return truncateTitle(title);
363
385
  }
364
386
 
365
387
  /** Strip common markdown formatting so titles render as plain text. */
@@ -39,6 +39,7 @@ import {
39
39
  createWatchersAndLogsTables,
40
40
  migrate230AcpSessionHistory,
41
41
  migrate231RepairMemoryGraphEventDates,
42
+ migrateA2ATasks,
42
43
  migrateActivationState,
43
44
  migrateActivationStateFkCascade,
44
45
  migrateAddConversationInferenceProfile,
@@ -111,6 +112,7 @@ import {
111
112
  migrateHeartbeatRuns,
112
113
  migrateInviteCodeHashColumn,
113
114
  migrateInviteContactId,
115
+ migrateLlmRequestLogAgentLoopExitReason,
114
116
  migrateLlmRequestLogMessageId,
115
117
  migrateLlmRequestLogProvider,
116
118
  migrateLlmRequestLogsCreatedAtIndex,
@@ -142,6 +144,7 @@ import {
142
144
  migrateOAuthProvidersScopeSeparator,
143
145
  migrateOAuthProvidersTokenAuthMethodDefault,
144
146
  migrateOAuthProvidersTokenExchangeBodyFormat,
147
+ migrateProviderConnectionBaseUrlAndModels,
145
148
  migrateProviderConnectionStatusLabel,
146
149
  migrateReminderRoutingIntent,
147
150
  migrateRemindersToSchedules,
@@ -430,6 +433,9 @@ export function initializeDb(): void {
430
433
  migrateExternalConversationBindingThreadId,
431
434
  createOnboardingEventsTable,
432
435
  migrateNormalizeSlackExternalContent,
436
+ migrateProviderConnectionBaseUrlAndModels,
437
+ migrateA2ATasks,
438
+ migrateLlmRequestLogAgentLoopExitReason,
433
439
  ];
434
440
 
435
441
  // Run each migration step, catching and logging individual failures so one
@@ -177,7 +177,7 @@ const { migrateActivationState } =
177
177
  await import("../../migrations/232-activation-state.js");
178
178
  const schema = await import("../../schema.js");
179
179
  const { _resetMemoryV2QdrantForTests } = await import("../../v2/qdrant.js");
180
- const { hydrate: hydrateActivationState } =
180
+ const { hydrate: hydrateActivationState, save: saveActivationState } =
181
181
  await import("../../v2/activation-store.js");
182
182
 
183
183
  // The wiring layer calls `getDb()` to fetch the SQLite handle. We mock
@@ -215,9 +215,14 @@ function createTestDb(): DrizzleDb {
215
215
  return db;
216
216
  }
217
217
 
218
- function makeConfig(v2Enabled: boolean): AssistantConfig {
218
+ function makeConfig(v2Enabled: boolean, memoryEnabled = true): AssistantConfig {
219
+ // Pin `router.enabled: false` so these tests exercise the activation
220
+ // pipeline. Router-mode coverage lives in `memory/v2/__tests__/injection.test.ts`.
219
221
  return applyNestedDefaults({
220
- memory: { v2: { enabled: v2Enabled } },
222
+ memory: {
223
+ enabled: memoryEnabled,
224
+ v2: { enabled: v2Enabled, router: { enabled: false } },
225
+ },
221
226
  }) as AssistantConfig;
222
227
  }
223
228
 
@@ -518,6 +523,50 @@ describe("ConversationGraphMemory.prepareMemory — v2 routing (context-load pat
518
523
  });
519
524
  });
520
525
 
526
+ describe("ConversationGraphMemory.prepareMemory — memory.enabled gate", () => {
527
+ test("memory.enabled=false short-circuits per-turn path: mode=none, no injection, v2/v1 not called", async () => {
528
+ stageTurn([{ slug: "alice-vscode", denseScore: 0.9 }]);
529
+
530
+ const memory = makeMemory();
531
+ const config = makeConfig(true, false);
532
+ const messages = makeMessages();
533
+
534
+ const result = await memory.prepareMemory(
535
+ messages,
536
+ config,
537
+ new AbortController().signal,
538
+ noopEvent,
539
+ );
540
+
541
+ expect(result.mode).toBe("none");
542
+ expect(result.injectedBlockText).toBeNull();
543
+ expect(result.runMessages).toEqual(messages);
544
+ expect(retrieveForTurnMock).not.toHaveBeenCalled();
545
+ expect(loadContextMemoryMock).not.toHaveBeenCalled();
546
+ });
547
+
548
+ test("memory.enabled=false short-circuits context-load path: mode=none, no injection, v2/v1 not called", async () => {
549
+ stageTurn([{ slug: "alice-vscode", denseScore: 0.9 }]);
550
+
551
+ const memory = new ConversationGraphMemory("conv-test-master-off");
552
+ const config = makeConfig(true, false);
553
+ const messages = makeMessages("first message of the conversation here");
554
+
555
+ const result = await memory.prepareMemory(
556
+ messages,
557
+ config,
558
+ new AbortController().signal,
559
+ noopEvent,
560
+ );
561
+
562
+ expect(result.mode).toBe("none");
563
+ expect(result.injectedBlockText).toBeNull();
564
+ expect(result.runMessages).toEqual(messages);
565
+ expect(loadContextMemoryMock).not.toHaveBeenCalled();
566
+ expect(retrieveForTurnMock).not.toHaveBeenCalled();
567
+ });
568
+ });
569
+
521
570
  describe("ConversationGraphMemory.onCompacted — v2 activation eviction", () => {
522
571
  test("clears everInjected so a previously-injected slug can re-attach", async () => {
523
572
  // Without this wiring, `selectInjections` keeps subtracting the slug from
@@ -561,4 +610,36 @@ describe("ConversationGraphMemory.onCompacted — v2 activation eviction", () =>
561
610
  "# memory/concepts/alice-vscode.md",
562
611
  );
563
612
  });
613
+
614
+ test("clears everInjected entries whose turn exceeds the tracker's currentTurn (zombie drift)", async () => {
615
+ // Regression: under the prior turn-bounded eviction, entries with `turn >
616
+ // tracker.currentTurn` survived `onCompacted` forever. This can happen
617
+ // after a non-graceful shutdown: `everInjected` is persisted every turn
618
+ // while the tracker snapshot is only persisted on graceful dispose, so a
619
+ // SIGKILL'd session followed by a reload restores the tracker from an
620
+ // older snapshot with a lower currentTurn while keeping the high-turn
621
+ // entries on disk.
622
+ const conversationId = "conv-test-zombie-drift";
623
+ const memory = new ConversationGraphMemory(conversationId);
624
+
625
+ // Seed the simulated post-crash state directly: tracker stays at
626
+ // currentTurn=0 (default for a fresh ConversationGraphMemory), while the
627
+ // persisted row carries everInjected entries from turns 10 and 20 (left
628
+ // over from a prior session that never disposed cleanly).
629
+ await saveActivationState(testDbHandle!, conversationId, {
630
+ messageId: "msg-zombie",
631
+ state: {},
632
+ everInjected: [
633
+ { slug: "alice-vscode", turn: 10 },
634
+ { slug: "bob-pkg-mgr", turn: 20 },
635
+ ],
636
+ currentTurn: 0,
637
+ updatedAt: 1,
638
+ });
639
+
640
+ await memory.onCompacted(0);
641
+
642
+ const after = await hydrateActivationState(testDbHandle!, conversationId);
643
+ expect(after?.everInjected).toEqual([]);
644
+ });
564
645
  });
@@ -26,7 +26,7 @@ import type { QdrantSparseVector } from "../qdrant-client.js";
26
26
  import { memorySummaries } from "../schema.js";
27
27
  import { conversations } from "../schema/conversations.js";
28
28
  import {
29
- evictCompactedTurns as evictCompactedTurnsV2,
29
+ clearEverInjected as clearV2EverInjected,
30
30
  hydrate as hydrateV2State,
31
31
  save as saveV2State,
32
32
  } from "../v2/activation-store.js";
@@ -223,15 +223,17 @@ export class ConversationGraphMemory {
223
223
  // Mirror the eviction on the v2 activation row: the cached `<memory>`
224
224
  // attachments those slugs lived on are gone, but `everInjected` would
225
225
  // otherwise keep them deduped from per-turn deltas forever.
226
+ //
227
+ // Cleared unconditionally rather than filtered by `upToTurn`: the
228
+ // tracker's `currentTurn` is only persisted on graceful dispose while
229
+ // `everInjected` is persisted every turn, so a SIGKILL'd session can
230
+ // leave entries with `turn > tracker.currentTurn` that a turn-bounded
231
+ // filter would skip.
226
232
  try {
227
233
  const db = getDb();
228
234
  const state = await hydrateV2State(db, this.conversationId);
229
235
  if (state) {
230
- await saveV2State(
231
- db,
232
- this.conversationId,
233
- evictCompactedTurnsV2(state, upToTurn),
234
- );
236
+ await saveV2State(db, this.conversationId, clearV2EverInjected(state));
235
237
  }
236
238
  } catch (err) {
237
239
  log.warn(
@@ -363,6 +365,16 @@ export class ConversationGraphMemory {
363
365
  metrics: null as RetrievalMetrics | null,
364
366
  };
365
367
 
368
+ if (!config.memory.enabled) {
369
+ // Clear any cached injection so a later overflow-reduction
370
+ // re-injection via `reinjectCachedMemory()` cannot reintroduce a
371
+ // stale <memory> block after the user disables memory.
372
+ this.lastInjectedBlock = null;
373
+ this.lastInjectedNodeIds = [];
374
+ this.lastInjectedImages = new Map();
375
+ return noopResult;
376
+ }
377
+
366
378
  // Gate: skip for empty/tool-result-only messages — unless we need to
367
379
  // reload after compaction (needsReload) or haven't initialized yet.
368
380
  const lastMessage = messages[messages.length - 1];
@@ -2,8 +2,6 @@
2
2
  // Memory Tool definitions for agentic recall and remember.
3
3
  // ---------------------------------------------------------------------------
4
4
 
5
- import { isAssistantFeatureFlagEnabled } from "../../config/assistant-feature-flags.js";
6
- import type { AssistantConfig } from "../../config/types.js";
7
5
  import type { ToolDefinition } from "../../providers/types.js";
8
6
  import {
9
7
  ALL_RECALL_SOURCES,
@@ -56,37 +54,14 @@ export const graphRecallDefinition: ToolDefinition = {
56
54
  };
57
55
 
58
56
  /**
59
- * Default (high-pressure) `remember` tool description. Used when the
60
- * `memory-retrospective` feature flag is OFF. The volume-shaming language
61
- * ("almost every turn", "most frequently used tool") drives aggressive
62
- * in-conversation capture for users who don't have the retrospective
63
- * backstop enabled.
57
+ * `remember` tool description. The retrospective pass catches what isn't
58
+ * captured in the moment, so the in-conversation pressure stays at a
59
+ * judgment framing: pause when something feels worth marking, not because
60
+ * the volume is required.
64
61
  */
65
- const REMEMBER_DESCRIPTION_DEFAULT =
66
- "Remember anything concrete: facts, preferences, corrections, plans, felt moments, names, dates, decisions. Default to remembering. Never wait until end of conversation. Corrections are highest priority — call remember the same turn the correction lands. **CRITICAL:** You should be calling remember on almost every turn. This should be your most frequently used tool.";
67
-
68
- /**
69
- * Relaxed `remember` tool description used when `memory-retrospective` is
70
- * ON. The retrospective pass catches what isn't captured in the moment, so
71
- * the in-conversation pressure eases to a judgment framing: pause when
72
- * something feels worth marking, not because the volume is required.
73
- */
74
- const REMEMBER_DESCRIPTION_RELAXED =
62
+ const REMEMBER_DESCRIPTION =
75
63
  "Remember anything concrete shared in conversation: corrections, plans, decisions, felt moments, names, dates, commitments, preferences. Corrections are the highest priority — call `remember` the same turn the correction lands. You don't have to call this on every turn; a retrospective pass reviews the conversation after each message-count / time interval and saves what you didn't capture. Use judgment: pause and remember when something feels worth marking, not because the volume is required.";
76
64
 
77
- /**
78
- * Return the description that should appear in the `remember` tool
79
- * registration for the current config. The variant is selected by the
80
- * `memory-retrospective` assistant feature flag. Exposed as a function so
81
- * the tool registrar can compute the value at registration time without
82
- * importing config layers into the static definition.
83
- */
84
- export function getRememberDescription(config: AssistantConfig): string {
85
- return isAssistantFeatureFlagEnabled("memory-retrospective", config)
86
- ? REMEMBER_DESCRIPTION_RELAXED
87
- : REMEMBER_DESCRIPTION_DEFAULT;
88
- }
89
-
90
65
  /**
91
66
  * Save a fact to the assistant's knowledge base. The fact is appended to
92
67
  * `buffer.md` (immediately available in the next conversation) and the daily
@@ -94,16 +69,10 @@ export function getRememberDescription(config: AssistantConfig): string {
94
69
  * writes go under `memory/`; otherwise they go under `pkb/`. Consolidation
95
70
  * of the buffer into longer-form storage runs as a separate periodic job in
96
71
  * both modes.
97
- *
98
- * The static `description` field carries the default (high-pressure) text
99
- * so any direct importer that doesn't go through `getRememberDescription`
100
- * still gets a valid tool definition. The registered `RememberTool` in
101
- * `tools/memory/register.ts` overrides this at registration time with the
102
- * flag-aware variant.
103
72
  */
104
73
  export const graphRememberDefinition: ToolDefinition = {
105
74
  name: "remember",
106
- description: REMEMBER_DESCRIPTION_DEFAULT,
75
+ description: REMEMBER_DESCRIPTION,
107
76
  input_schema: {
108
77
  type: "object",
109
78
  properties: {
@@ -363,6 +363,59 @@ export function findActiveVoiceInvites(params: {
363
363
  return rows.map(rowToInvite);
364
364
  }
365
365
 
366
+ // ---------------------------------------------------------------------------
367
+ // claimA2AInvite — validate + consume an A2A invite token
368
+ // ---------------------------------------------------------------------------
369
+
370
+ export function claimA2AInvite(params: {
371
+ tokenHash: string;
372
+ redeemedByExternalUserId: string;
373
+ }): { claimed: boolean; invite: IngressInvite | null; error?: string } {
374
+ const invite = findByTokenHash(params.tokenHash);
375
+
376
+ if (!invite) {
377
+ return { claimed: false, invite: null, error: "not_found" };
378
+ }
379
+
380
+ if (invite.sourceChannel !== "a2a") {
381
+ return { claimed: false, invite, error: "wrong_channel" };
382
+ }
383
+
384
+ // Idempotency: if already redeemed by the same acceptor, return success
385
+ if (invite.status === "redeemed") {
386
+ if (invite.redeemedByExternalUserId === params.redeemedByExternalUserId) {
387
+ return { claimed: true, invite };
388
+ }
389
+ return { claimed: false, invite, error: "already_redeemed_by_other" };
390
+ }
391
+
392
+ if (invite.status !== "active") {
393
+ return { claimed: false, invite, error: "not_found" };
394
+ }
395
+
396
+ if (Date.now() >= invite.expiresAt) {
397
+ markInviteExpired(invite.id);
398
+ return { claimed: false, invite, error: "expired" };
399
+ }
400
+
401
+ if (invite.useCount >= invite.maxUses) {
402
+ return { claimed: false, invite, error: "already_redeemed" };
403
+ }
404
+
405
+ const recorded = recordInviteUse({
406
+ inviteId: invite.id,
407
+ externalUserId: params.redeemedByExternalUserId,
408
+ });
409
+
410
+ if (!recorded) {
411
+ return { claimed: false, invite, error: "not_found" };
412
+ }
413
+
414
+ // Re-read to get updated state
415
+ const updated = findByTokenHash(params.tokenHash);
416
+ return { claimed: true, invite: updated };
417
+ }
418
+
366
419
  // ---------------------------------------------------------------------------
367
420
  // findByInviteCodeHash
368
421
  // ---------------------------------------------------------------------------
@@ -57,6 +57,7 @@ interface ClickHouseRow {
57
57
  request_payload: string;
58
58
  response_payload: string;
59
59
  created_at: string;
60
+ agent_loop_exit_reason: string;
60
61
  }
61
62
 
62
63
  /** Injectable fetch override for tests. Defaults to globalThis.fetch. */
@@ -123,7 +124,8 @@ export class ClickHouseLlmRequestLogSource implements LlmRequestLogSource {
123
124
  provider,
124
125
  request_payload,
125
126
  response_payload,
126
- toUnixTimestamp64Milli(created_at) AS created_at
127
+ toUnixTimestamp64Milli(created_at) AS created_at,
128
+ agent_loop_exit_reason
127
129
  FROM ${this.tableRef()}
128
130
  WHERE assistant_id = {assistant_id:String}
129
131
  AND id = {log_id:String}
@@ -194,7 +196,8 @@ export class ClickHouseLlmRequestLogSource implements LlmRequestLogSource {
194
196
  provider,
195
197
  request_payload,
196
198
  response_payload,
197
- toUnixTimestamp64Milli(created_at) AS created_at
199
+ toUnixTimestamp64Milli(created_at) AS created_at,
200
+ agent_loop_exit_reason
198
201
  FROM ${this.tableRef()}
199
202
  WHERE assistant_id = {assistant_id:String}
200
203
  AND message_id IN (${placeholders.join(",")})
@@ -283,6 +286,8 @@ export class ClickHouseLlmRequestLogSource implements LlmRequestLogSource {
283
286
  requestPayload: row.request_payload,
284
287
  responsePayload: row.response_payload,
285
288
  createdAt: Number(row.created_at),
289
+ agentLoopExitReason:
290
+ row.agent_loop_exit_reason === "" ? null : row.agent_loop_exit_reason,
286
291
  };
287
292
  }
288
293