@vellumai/assistant 0.10.3 → 0.10.4-staging.1

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 (239) hide show
  1. package/openapi.yaml +73 -56
  2. package/package.json +1 -1
  3. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +83 -31
  4. package/src/__tests__/assistant-stream-state.test.ts +3 -76
  5. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -2
  6. package/src/__tests__/channel-approval-routes.test.ts +21 -26
  7. package/src/__tests__/channel-delivery-store.test.ts +28 -0
  8. package/src/__tests__/channel-guardian.test.ts +82 -32
  9. package/src/__tests__/channel-inbound-disk-pressure.test.ts +11 -19
  10. package/src/__tests__/channel-reply-delivery.test.ts +6 -2
  11. package/src/__tests__/compaction-ledger-store.test.ts +128 -0
  12. package/src/__tests__/config-loader-backfill.test.ts +148 -0
  13. package/src/__tests__/consult-deadline.test.ts +60 -0
  14. package/src/__tests__/contact-store-interaction-info.test.ts +156 -0
  15. package/src/__tests__/contact-store-user-file.test.ts +7 -10
  16. package/src/__tests__/contacts-relay-reads.test.ts +6 -9
  17. package/src/__tests__/contacts-write.test.ts +0 -2
  18. package/src/__tests__/conversation-agent-loop-overflow.test.ts +4 -2
  19. package/src/__tests__/conversation-agent-loop.test.ts +98 -7
  20. package/src/__tests__/conversation-attention-telegram.test.ts +9 -11
  21. package/src/__tests__/conversation-error.test.ts +18 -0
  22. package/src/__tests__/conversation-fork-crud.test.ts +354 -24
  23. package/src/__tests__/conversation-title-service.test.ts +222 -201
  24. package/src/__tests__/db-compaction-events-migration.test.ts +129 -0
  25. package/src/__tests__/delete-propagation.test.ts +5 -3
  26. package/src/__tests__/dm-backfill.test.ts +6 -4
  27. package/src/__tests__/emit-signal-routing-intent.test.ts +2 -6
  28. package/src/__tests__/guardian-binding-drift-heal.test.ts +43 -23
  29. package/src/__tests__/guardian-dispatch.test.ts +50 -5
  30. package/src/__tests__/guardian-routing-state.test.ts +6 -10
  31. package/src/__tests__/helpers/channel-test-adapter.ts +45 -12
  32. package/src/__tests__/helpers/create-guardian-binding.ts +15 -23
  33. package/src/__tests__/helpers/mock-logger.ts +1 -0
  34. package/src/__tests__/helpers/seed-contact-channel.ts +96 -0
  35. package/src/__tests__/inbound-invite-redemption.test.ts +87 -10
  36. package/src/__tests__/invite-redemption-service.test.ts +273 -53
  37. package/src/__tests__/invite-routes-http.test.ts +34 -0
  38. package/src/__tests__/invite-service-ipc.test.ts +65 -2
  39. package/src/__tests__/list-messages-page-latest.test.ts +173 -4
  40. package/src/__tests__/mcp-config-secret-boundary.test.ts +3 -0
  41. package/src/__tests__/non-member-access-request.test.ts +15 -13
  42. package/src/__tests__/onboarding-persona-write.test.ts +52 -22
  43. package/src/__tests__/persist-onboarding-artifacts.test.ts +1 -0
  44. package/src/__tests__/persona-resolver.test.ts +75 -45
  45. package/src/__tests__/plugin-bootstrap.test.ts +13 -5
  46. package/src/__tests__/plugin-disabled-state.test.ts +190 -0
  47. package/src/__tests__/provider-usage-tracking.test.ts +1 -1
  48. package/src/__tests__/reaction-intercept-cold-cache-warm.test.ts +135 -0
  49. package/src/__tests__/reaction-intercept-member-verdict-warm.test.ts +158 -0
  50. package/src/__tests__/reaction-persistence.test.ts +51 -4
  51. package/src/__tests__/relay-server.test.ts +88 -31
  52. package/src/__tests__/runtime-attachment-metadata.test.ts +9 -11
  53. package/src/__tests__/settings-routes.test.ts +32 -0
  54. package/src/__tests__/slack-block-formatting.test.ts +1 -38
  55. package/src/__tests__/sse-actor-principal-guardian-source.test.ts +13 -36
  56. package/src/__tests__/stt-hints.test.ts +6 -3
  57. package/src/__tests__/subagent-fork-prompt-role.test.ts +195 -0
  58. package/src/__tests__/subagent-fork-spawn.test.ts +6 -7
  59. package/src/__tests__/subagent-role-registry.test.ts +17 -4
  60. package/src/__tests__/subagent-spawn-and-await.test.ts +546 -0
  61. package/src/__tests__/subagent-tools.test.ts +398 -3
  62. package/src/__tests__/thread-backfill.test.ts +3 -3
  63. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -10
  64. package/src/__tests__/tool-start-timestamp.test.ts +4 -3
  65. package/src/__tests__/trusted-contact-approval-notifier.test.ts +37 -51
  66. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -2
  67. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +9 -7
  68. package/src/__tests__/trusted-contact-multichannel.test.ts +16 -7
  69. package/src/__tests__/trusted-contact-verification.test.ts +79 -54
  70. package/src/__tests__/voice-guardian-cold-cache-warm.test.ts +137 -0
  71. package/src/__tests__/voice-invite-redemption.test.ts +183 -20
  72. package/src/__tests__/workspace-migration-102-preserve-heartbeat-enabled-for-existing-workspaces.test.ts +3 -3
  73. package/src/__tests__/workspace-migration-111-prune-seeded-callsite-defaults.test.ts +2 -2
  74. package/src/__tests__/workspace-migration-112-remove-advisor-callsite-override.test.ts +170 -0
  75. package/src/__tests__/workspace-migration-drop-user-md.test.ts +196 -238
  76. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +35 -47
  77. package/src/agent/loop-exclusive-tool.test.ts +19 -15
  78. package/src/agent/loop-native-web-search.test.ts +200 -0
  79. package/src/agent/loop.ts +108 -1
  80. package/src/api/responses/conversation-message.ts +9 -0
  81. package/src/approvals/guardian-request-resolvers.ts +16 -4
  82. package/src/calls/__tests__/relay-setup-router.test.ts +10 -18
  83. package/src/calls/guardian-dispatch.ts +14 -11
  84. package/src/calls/inbound-trust-reader.ts +7 -1
  85. package/src/calls/relay-access-wait.ts +6 -6
  86. package/src/calls/relay-server.ts +22 -2
  87. package/src/calls/relay-setup-router.ts +10 -10
  88. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -0
  89. package/src/cli/commands/contacts.ts +10 -7
  90. package/src/cli/commands/memory/__tests__/worker.test.ts +147 -17
  91. package/src/cli/commands/memory/worker.ts +97 -30
  92. package/src/cli/commands/plugins.ts +3 -146
  93. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +17 -17
  94. package/src/cli/lib/__tests__/publish-plugin.test.ts +98 -0
  95. package/src/cli/lib/publish-plugin.ts +231 -1
  96. package/src/config/__tests__/sync-gated-profiles.test.ts +5 -7
  97. package/src/config/bundled-skills/subagent/SKILL.md +16 -1
  98. package/src/config/bundled-skills/subagent/TOOLS.json +5 -4
  99. package/src/config/call-site-defaults.ts +0 -6
  100. package/src/config/llm-resolver.ts +0 -3
  101. package/src/config/schemas/call-site-catalog.ts +0 -7
  102. package/src/config/schemas/heartbeat.ts +2 -5
  103. package/src/config/schemas/llm.ts +3 -12
  104. package/src/config/schemas/memory-lifecycle.ts +1 -1
  105. package/src/config/seed-inference-profiles.ts +76 -35
  106. package/src/config/sync-gated-profiles.ts +0 -3
  107. package/src/contacts/__tests__/contacts-write-revoke-relay.test.ts +7 -8
  108. package/src/contacts/__tests__/member-write-relay.test.ts +35 -11
  109. package/src/contacts/contact-store.ts +27 -237
  110. package/src/contacts/contacts-write.ts +18 -58
  111. package/src/contacts/gateway-channel-read.ts +51 -0
  112. package/src/contacts/member-write-relay.ts +25 -31
  113. package/src/contacts/types.ts +3 -15
  114. package/src/daemon/__tests__/conversation-tool-setup.test.ts +0 -44
  115. package/src/daemon/conversation-agent-loop-handlers.ts +29 -10
  116. package/src/daemon/conversation-agent-loop.ts +68 -61
  117. package/src/daemon/conversation-error.ts +7 -10
  118. package/src/daemon/conversation-tool-setup.ts +0 -10
  119. package/src/daemon/conversation.ts +10 -0
  120. package/src/daemon/external-plugins-bootstrap.ts +8 -2
  121. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +0 -1
  122. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +0 -2
  123. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +0 -2
  124. package/src/daemon/handlers/__tests__/config-channels.test.ts +9 -14
  125. package/src/daemon/handlers/config-channels.ts +14 -29
  126. package/src/daemon/lifecycle.ts +16 -4
  127. package/src/daemon/message-types/surfaces.ts +2 -0
  128. package/src/heartbeat/heartbeat-service.ts +5 -0
  129. package/src/home/relationship-state-writer.ts +5 -0
  130. package/src/memory/__tests__/embedding-cache.test.ts +136 -0
  131. package/src/memory/compaction-ledger-store.ts +107 -0
  132. package/src/memory/conversation-crud.ts +136 -61
  133. package/src/memory/conversation-title-service.ts +173 -24
  134. package/src/memory/embedding-backend.ts +8 -1
  135. package/src/memory/embedding-cache.ts +139 -0
  136. package/src/memory/jobs-worker.ts +75 -29
  137. package/src/memory/memory-retrospective-job.ts +5 -0
  138. package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +27 -5
  139. package/src/memory/migrations/302-create-compaction-events.ts +107 -0
  140. package/src/memory/migrations/303-add-conversation-creation-seq.ts +33 -0
  141. package/src/memory/migrations/__tests__/209-strip-thinking-from-consolidated.test.ts +79 -6
  142. package/src/memory/schema/contacts.ts +6 -2
  143. package/src/memory/schema/conversations.ts +39 -0
  144. package/src/memory/steps.ts +1090 -367
  145. package/src/memory/worker-control.ts +104 -18
  146. package/src/memory/worker-process.ts +17 -0
  147. package/src/messaging/channel-binding-metadata.ts +31 -0
  148. package/src/messaging/channel-binding-schema.ts +51 -0
  149. package/src/messaging/providers/__tests__/callback-routing.test.ts +45 -0
  150. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +195 -0
  151. package/src/messaging/providers/a2a/__tests__/deliver.test.ts +11 -0
  152. package/src/messaging/providers/a2a/deliver.ts +5 -1
  153. package/src/messaging/providers/a2a/transport.ts +10 -0
  154. package/src/messaging/providers/callback-routing.ts +48 -0
  155. package/src/messaging/providers/channel-transport.ts +55 -0
  156. package/src/messaging/providers/index.ts +65 -241
  157. package/src/messaging/providers/slack/binding-metadata.ts +62 -0
  158. package/src/messaging/providers/slack/transport.ts +92 -0
  159. package/src/messaging/providers/telegram-bot/transport.ts +51 -0
  160. package/src/messaging/providers/whatsapp/transport.ts +38 -0
  161. package/src/notifications/__tests__/broadcaster.test.ts +0 -8
  162. package/src/notifications/__tests__/connected-channels.test.ts +8 -36
  163. package/src/notifications/__tests__/destination-resolver.test.ts +12 -117
  164. package/src/notifications/destination-resolver.ts +7 -23
  165. package/src/notifications/emit-signal.ts +5 -11
  166. package/src/plugins/defaults/index.ts +0 -35
  167. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +11 -0
  168. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +243 -2
  169. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +167 -14
  170. package/src/plugins/disabled-state.ts +31 -0
  171. package/src/plugins/registry.ts +55 -12
  172. package/src/prompts/persona-resolver.ts +43 -11
  173. package/src/providers/call-site-routing.ts +41 -0
  174. package/src/providers/provider-send-message.ts +6 -0
  175. package/src/providers/ratelimit.ts +6 -0
  176. package/src/providers/registry.ts +1 -1
  177. package/src/providers/retry.ts +6 -0
  178. package/src/providers/types.ts +13 -0
  179. package/src/providers/usage-tracking.ts +6 -0
  180. package/src/runtime/__tests__/guardian-vellum-migration.test.ts +30 -27
  181. package/src/runtime/__tests__/local-principal-trust.test.ts +16 -18
  182. package/src/runtime/__tests__/member-verdict-cache.test.ts +119 -0
  183. package/src/runtime/__tests__/trust-verdict-consumer.test.ts +115 -168
  184. package/src/runtime/access-request-helper.ts +1 -2
  185. package/src/runtime/actor-trust-resolver.ts +44 -17
  186. package/src/runtime/anchored-guardian.test.ts +7 -54
  187. package/src/runtime/anchored-guardian.ts +4 -53
  188. package/src/runtime/assistant-stream-state.ts +12 -74
  189. package/src/runtime/channel-reply-delivery.ts +3 -8
  190. package/src/runtime/guardian-vellum-migration.ts +18 -16
  191. package/src/runtime/invite-redemption-service.ts +25 -10
  192. package/src/runtime/local-actor-identity.test.ts +108 -0
  193. package/src/runtime/local-actor-identity.ts +27 -20
  194. package/src/runtime/member-verdict-cache.ts +0 -0
  195. package/src/runtime/routes/__tests__/contact-routes.test.ts +100 -7
  196. package/src/runtime/routes/__tests__/global-search-routes.test.ts +1 -2
  197. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -1
  198. package/src/runtime/routes/contact-routes.ts +40 -25
  199. package/src/runtime/routes/conversation-list-routes.ts +1 -29
  200. package/src/runtime/routes/conversation-routes.ts +27 -7
  201. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -10
  202. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -8
  203. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +19 -0
  204. package/src/runtime/routes/settings-routes.ts +8 -3
  205. package/src/runtime/services/conversation-serializer.ts +6 -49
  206. package/src/runtime/slack-block-formatting.ts +0 -15
  207. package/src/runtime/trust-verdict-consumer.ts +36 -41
  208. package/src/subagent/__tests__/consult-prompt.test.ts +35 -0
  209. package/src/{plugins/defaults/advisor/__tests__/transcript.test.ts → subagent/__tests__/consult-transcript.test.ts} +47 -10
  210. package/src/{plugins/defaults/advisor/steering.ts → subagent/consult-prompt.ts} +17 -39
  211. package/src/{plugins/defaults/advisor/transcript.ts → subagent/consult-transcript.ts} +18 -8
  212. package/src/subagent/index.ts +1 -1
  213. package/src/subagent/manager.ts +245 -33
  214. package/src/subagent/types.ts +8 -1
  215. package/src/tools/registry.ts +10 -3
  216. package/src/tools/subagent/consult-deadline.ts +49 -0
  217. package/src/tools/subagent/spawn.ts +234 -5
  218. package/src/util/logger.ts +9 -0
  219. package/src/util/platform.ts +14 -0
  220. package/src/workspace/migrations/031-drop-user-md.ts +232 -148
  221. package/src/workspace/migrations/112-remove-advisor-callsite-override.ts +64 -0
  222. package/src/workspace/migrations/registry.ts +2 -0
  223. package/src/plugins/defaults/advisor/__tests__/advisor-gate.test.ts +0 -56
  224. package/src/plugins/defaults/advisor/__tests__/advisor-state-store.test.ts +0 -43
  225. package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +0 -137
  226. package/src/plugins/defaults/advisor/__tests__/consult.test.ts +0 -314
  227. package/src/plugins/defaults/advisor/__tests__/context-pack-gating.test.ts +0 -106
  228. package/src/plugins/defaults/advisor/__tests__/context-pack.test.ts +0 -60
  229. package/src/plugins/defaults/advisor/__tests__/hooks.test.ts +0 -138
  230. package/src/plugins/defaults/advisor/advisor-gate.ts +0 -29
  231. package/src/plugins/defaults/advisor/advisor-state-store.ts +0 -94
  232. package/src/plugins/defaults/advisor/config.ts +0 -21
  233. package/src/plugins/defaults/advisor/consult.ts +0 -197
  234. package/src/plugins/defaults/advisor/context-pack.ts +0 -288
  235. package/src/plugins/defaults/advisor/hooks/post-model-call.ts +0 -34
  236. package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +0 -30
  237. package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +0 -19
  238. package/src/plugins/defaults/advisor/package.json +0 -14
  239. package/src/plugins/defaults/advisor/tools/advisor.ts +0 -92
@@ -8,16 +8,8 @@ import {
8
8
  writeFileSync,
9
9
  } from "node:fs";
10
10
  import { basename, join } from "node:path";
11
+ import { Database } from "bun:sqlite";
11
12
 
12
- import { eq } from "drizzle-orm";
13
-
14
- import {
15
- findGuardianForChannel,
16
- generateUserFileSlug,
17
- listGuardianChannels,
18
- } from "../../contacts/contact-store.js";
19
- import { getDb } from "../../memory/db-connection.js";
20
- import { contacts } from "../../memory/schema/contacts.js";
21
13
  import { getLogger } from "../../util/logger.js";
22
14
  import type { WorkspaceMigration } from "./types.js";
23
15
 
@@ -25,11 +17,11 @@ const log = getLogger("workspace-migration-031-drop-user-md");
25
17
 
26
18
  // ── Inlined helpers ───────────────────────────────────────────────
27
19
  //
28
- // Per AGENTS.md, migrations should minimize cross-module imports so
29
- // they remain stable as code around them evolves. The helpers below
30
- // are duplicated inline (rather than imported from
31
- // `util/strip-comment-lines.js` and `prompts/system-prompt.js`) so
32
- // this migration does not regress if those modules change later.
20
+ // AGENTS.md requires each migration to be self-contained: it may import
21
+ // only `./types.js`, `./utils.js`, the logger, and runtime built-ins.
22
+ // The helpers below (including the guardian DB read and the userFile
23
+ // slug) are inlined so this migration does not regress if the modules
24
+ // they originate from change later.
33
25
 
34
26
  /**
35
27
  * Strip lines starting with `_` (comment convention for prompt .md files)
@@ -123,13 +115,59 @@ function isValidSlug(slug: string): boolean {
123
115
  return basename(slug) === slug && slug !== "." && slug !== "..";
124
116
  }
125
117
 
118
+ /**
119
+ * Strip LIKE metacharacters so the prefix match runs literally. SQLite has
120
+ * no default LIKE escape character, so strip rather than escape. Inlined
121
+ * from `contacts/contact-store.ts`.
122
+ */
123
+ function escapeLike(value: string): string {
124
+ return value.replace(/%/g, "").replace(/_/g, "");
125
+ }
126
+
127
+ /** Pure slug transform applied to a display name. */
128
+ function computeUserFileBaseSlug(displayName: string): string {
129
+ return (
130
+ displayName
131
+ .toLowerCase()
132
+ .replace(/[^a-z0-9]+/g, "-")
133
+ .replace(/^-+|-+$/g, "")
134
+ .slice(0, 100) || "user"
135
+ );
136
+ }
137
+
138
+ /**
139
+ * Generate a collision-free `users/<slug>.md` filename for a display name,
140
+ * inlined verbatim from `contacts/contact-store.ts` to keep this migration
141
+ * self-contained. Produces "alice.md", "alice-2.md", etc.
142
+ */
143
+ function generateUserFileSlug(db: Database, displayName: string): string {
144
+ const slug = computeUserFileBaseSlug(displayName);
145
+
146
+ const rows = db
147
+ .query<{ user_file: string | null }, [string]>(
148
+ `SELECT user_file FROM contacts WHERE user_file LIKE ?`,
149
+ )
150
+ .all(`${escapeLike(slug)}%`);
151
+
152
+ const taken = new Set(rows.map((r) => r.user_file?.toLowerCase()));
153
+
154
+ const base = `${slug}.md`;
155
+ if (!taken.has(base)) return base;
156
+
157
+ for (let i = 2; ; i++) {
158
+ const candidate = `${slug}-${i}.md`;
159
+ if (!taken.has(candidate)) return candidate;
160
+ }
161
+ }
162
+
126
163
  /**
127
164
  * Delete the legacy `USER.md` at the workspace root after migrating
128
165
  * any customized content into `users/<slug>.md`.
129
166
  *
130
- * Handles four relevant states:
131
- * 1. Fresh install, no guardian → no-op (nothing to migrate yet;
132
- * `USER.md` is no longer seeded post PR 11).
167
+ * Handles these relevant states:
168
+ * 1. No local guardian → preserve a customized `USER.md` (the local
169
+ * mirror can be stale, so a customized profile may still belong to a
170
+ * real guardian); delete only the unmodified template/empty file.
133
171
  * 2. Pre-017 customized `USER.md`, guardian has no `userFile` →
134
172
  * backfill the slug, copy `USER.md` → `users/<slug>.md`, delete `USER.md`.
135
173
  * 3. Post-017 state where `users/<slug>.md` already has content →
@@ -137,6 +175,12 @@ function isValidSlug(slug: string): boolean {
137
175
  * 4. Missing `users/<slug>.md` after guardian is resolved → seed a bare
138
176
  * `GUARDIAN_PERSONA_TEMPLATE` scaffold so downstream readers have a file.
139
177
  */
178
+ interface GuardianRow {
179
+ id: string;
180
+ display_name: string;
181
+ user_file: string | null;
182
+ }
183
+
140
184
  export const dropUserMdMigration: WorkspaceMigration = {
141
185
  id: "031-drop-user-md",
142
186
  description:
@@ -145,163 +189,203 @@ export const dropUserMdMigration: WorkspaceMigration = {
145
189
  run(workspaceDir: string): void {
146
190
  const userMdPath = join(workspaceDir, "USER.md");
147
191
 
148
- // Resolve the guardian contact. Prefer the vellum-channel binding
149
- // (the canonical local/native guardian); fall back to whichever
150
- // guardian has the most recently verified active channel.
151
- let guardian: { id: string; displayName: string; userFile: string | null };
192
+ const dbPath = join(workspaceDir, "data", "db", "assistant.db");
193
+ if (!existsSync(dbPath)) return; // DB not created yet — defer cleanup.
194
+
195
+ let db: Database;
152
196
  try {
153
- const vellumGuardian = findGuardianForChannel("vellum");
154
- if (vellumGuardian) {
155
- guardian = {
156
- id: vellumGuardian.contact.id,
157
- displayName: vellumGuardian.contact.displayName,
158
- userFile: vellumGuardian.contact.userFile ?? null,
159
- };
160
- } else {
161
- const anyGuardian = listGuardianChannels();
162
- if (!anyGuardian) {
163
- // Fresh install or pre-onboarding. If a stale USER.md somehow
164
- // remains on disk (e.g. leftover from an older build), best-
165
- // effort remove it so future first runs are clean.
166
- if (existsSync(userMdPath)) {
167
- try {
168
- unlinkSync(userMdPath);
169
- log.info(
170
- { path: userMdPath },
171
- "Deleted stale pre-onboarding USER.md with no guardian",
172
- );
173
- } catch (err) {
174
- log.warn(
175
- { err, path: userMdPath },
176
- "Failed to delete pre-onboarding USER.md; leaving in place",
177
- );
178
- }
179
- }
180
- return;
181
- }
182
- guardian = {
183
- id: anyGuardian.contact.id,
184
- displayName: anyGuardian.contact.displayName,
185
- userFile: anyGuardian.contact.userFile ?? null,
186
- };
187
- }
197
+ db = new Database(dbPath);
188
198
  } catch (err) {
189
- // DB not ready or query failed leave USER.md alone. The next
190
- // startup after DB init will try again.
191
- log.warn(
192
- { err },
193
- "Failed to resolve guardian contact; deferring USER.md cleanup",
194
- );
199
+ log.warn({ err }, "Cannot open assistant DB; deferring USER.md cleanup");
195
200
  return;
196
201
  }
197
202
 
198
- // Backfill a userFile slug on the guardian contact if one isn't set.
199
- if (!guardian.userFile) {
203
+ try {
204
+ // Resolve the guardian contact from the local DB. Prefer the
205
+ // vellum-channel binding (the canonical native guardian); fall back to
206
+ // whichever guardian has the most recently verified active channel.
207
+ let guardianRow: GuardianRow | null;
200
208
  try {
201
- const slug = generateUserFileSlug(guardian.displayName);
202
- const db = getDb();
203
- db.update(contacts)
204
- .set({ userFile: slug })
205
- .where(eq(contacts.id, guardian.id))
206
- .run();
207
- guardian.userFile = slug;
208
- log.info(
209
- { contactId: guardian.id, slug },
210
- "Backfilled missing guardian.userFile",
211
- );
209
+ guardianRow =
210
+ db
211
+ .query<GuardianRow, []>(
212
+ `SELECT c.id AS id, c.display_name AS display_name, c.user_file AS user_file
213
+ FROM contacts c JOIN contact_channels cc ON cc.contact_id = c.id
214
+ WHERE c.role = 'guardian' AND cc.status = 'active'
215
+ ORDER BY (cc.type = 'vellum') DESC, cc.verified_at DESC
216
+ LIMIT 1`,
217
+ )
218
+ .get() ?? null;
212
219
  } catch (err) {
220
+ // DB not ready or query failed — leave USER.md alone. The next
221
+ // startup after DB init tries again.
213
222
  log.warn(
214
- { err, contactId: guardian.id },
215
- "Failed to backfill guardian.userFile; deferring USER.md cleanup",
223
+ { err },
224
+ "Failed to resolve guardian contact; deferring USER.md cleanup",
216
225
  );
217
226
  return;
218
227
  }
219
- }
220
228
 
221
- const userFile = guardian.userFile;
222
- if (!userFile || !isValidSlug(userFile)) {
223
- log.warn(
224
- { userFile },
225
- "Guardian userFile is missing or not a safe basename; deferring USER.md cleanup",
226
- );
227
- return;
228
- }
229
+ if (!guardianRow) {
230
+ // No local guardian. The mirror can be stale (the gateway mirrors
231
+ // best-effort), so a customized USER.md may still belong to a real
232
+ // guardian — preserve it. Only delete the unmodified template/empty
233
+ // stale file.
234
+ if (existsSync(userMdPath)) {
235
+ let isStaleTemplate = false;
236
+ try {
237
+ isStaleTemplate =
238
+ !statSync(userMdPath).isFile() ||
239
+ isLegacyTemplateContent(readFileSync(userMdPath, "utf-8"));
240
+ } catch (err) {
241
+ log.warn(
242
+ { err, path: userMdPath },
243
+ "Cannot read USER.md with no guardian; leaving in place",
244
+ );
245
+ return;
246
+ }
247
+
248
+ if (!isStaleTemplate) {
249
+ log.info(
250
+ { path: userMdPath },
251
+ "Preserving customized USER.md with no local guardian",
252
+ );
253
+ return;
254
+ }
229
255
 
230
- const usersDir = join(workspaceDir, "users");
231
- mkdirSync(usersDir, { recursive: true });
256
+ try {
257
+ unlinkSync(userMdPath);
258
+ log.info(
259
+ { path: userMdPath },
260
+ "Deleting stale template USER.md with no guardian",
261
+ );
262
+ } catch (err) {
263
+ log.warn(
264
+ { err, path: userMdPath },
265
+ "Failed to delete stale USER.md; leaving in place",
266
+ );
267
+ }
268
+ }
269
+ return;
270
+ }
232
271
 
233
- const destPath = join(usersDir, userFile);
272
+ const guardian = {
273
+ id: guardianRow.id,
274
+ displayName: guardianRow.display_name,
275
+ userFile: guardianRow.user_file ?? null,
276
+ };
234
277
 
235
- // Read USER.md if it exists and classify its content.
236
- let userMdRaw: string | null = null;
237
- let userMdIsCustomized = false;
238
- if (existsSync(userMdPath)) {
239
- try {
240
- // Guard against USER.md being a directory (hostile state).
241
- if (statSync(userMdPath).isFile()) {
242
- userMdRaw = readFileSync(userMdPath, "utf-8");
243
- userMdIsCustomized = !isLegacyTemplateContent(userMdRaw);
278
+ // Backfill a userFile slug on the guardian contact if one isn't set.
279
+ if (!guardian.userFile) {
280
+ try {
281
+ const slug = generateUserFileSlug(db, guardian.displayName);
282
+ db.run("UPDATE contacts SET user_file = ? WHERE id = ?", [
283
+ slug,
284
+ guardian.id,
285
+ ]);
286
+ guardian.userFile = slug;
287
+ log.info(
288
+ { contactId: guardian.id, slug },
289
+ "Backfilled missing guardian.userFile",
290
+ );
291
+ } catch (err) {
292
+ log.warn(
293
+ { err, contactId: guardian.id },
294
+ "Failed to backfill guardian.userFile; deferring USER.md cleanup",
295
+ );
296
+ return;
244
297
  }
245
- } catch (err) {
246
- log.warn(
247
- { err, path: userMdPath },
248
- "Failed to read USER.md; treating as unreadable",
249
- );
250
298
  }
251
- }
252
299
 
253
- // Copy customized USER.md content into users/<slug>.md when the
254
- // destination is missing or effectively empty. Post-017 installs
255
- // that already populated users/<slug>.md are left untouched.
256
- if (
257
- userMdIsCustomized &&
258
- userMdRaw !== null &&
259
- destFileIsMissingOrEmpty(destPath)
260
- ) {
261
- try {
262
- copyFileSync(userMdPath, destPath);
263
- log.info(
264
- { src: userMdPath, dest: destPath },
265
- "Copied customized USER.md content into users/<slug>.md",
266
- );
267
- } catch (err) {
300
+ const userFile = guardian.userFile;
301
+ if (!userFile || !isValidSlug(userFile)) {
268
302
  log.warn(
269
- { err, src: userMdPath, dest: destPath },
270
- "Failed to copy USER.md; deferring USER.md cleanup",
303
+ { userFile },
304
+ "Guardian userFile is missing or not a safe basename; deferring USER.md cleanup",
271
305
  );
272
306
  return;
273
307
  }
274
- }
275
308
 
276
- // Seed the guardian persona scaffold when the destination file
277
- // still doesn't exist (e.g. no USER.md and no post-017 content).
278
- // This keeps parity with `ensureGuardianPersonaFile` for new
279
- // installs so downstream readers always find a file.
280
- if (!existsSync(destPath)) {
281
- try {
282
- writeFileSync(destPath, GUARDIAN_PERSONA_TEMPLATE, "utf-8");
283
- log.info(
284
- { dest: destPath },
285
- "Seeded guardian persona scaffold at users/<slug>.md",
286
- );
287
- } catch (err) {
288
- log.warn(
289
- { err, dest: destPath },
290
- "Failed to seed guardian persona scaffold; continuing with USER.md deletion",
291
- );
309
+ const usersDir = join(workspaceDir, "users");
310
+ mkdirSync(usersDir, { recursive: true });
311
+
312
+ const destPath = join(usersDir, userFile);
313
+
314
+ // Read USER.md if it exists and classify its content.
315
+ let userMdRaw: string | null = null;
316
+ let userMdIsCustomized = false;
317
+ if (existsSync(userMdPath)) {
318
+ try {
319
+ // Guard against USER.md being a directory (hostile state).
320
+ if (statSync(userMdPath).isFile()) {
321
+ userMdRaw = readFileSync(userMdPath, "utf-8");
322
+ userMdIsCustomized = !isLegacyTemplateContent(userMdRaw);
323
+ }
324
+ } catch (err) {
325
+ log.warn(
326
+ { err, path: userMdPath },
327
+ "Failed to read USER.md; treating as unreadable",
328
+ );
329
+ }
292
330
  }
293
- }
294
331
 
295
- // Finally, delete the legacy USER.md if it still exists. Template
296
- // or customized, it has no remaining consumer now that PR 11
297
- // dropped the seed/fallback read path.
298
- if (existsSync(userMdPath)) {
299
- try {
300
- unlinkSync(userMdPath);
301
- log.info({ path: userMdPath }, "Deleted legacy workspace USER.md");
302
- } catch (err) {
303
- log.warn({ err, path: userMdPath }, "Failed to delete legacy USER.md");
332
+ // Copy customized USER.md content into users/<slug>.md when the
333
+ // destination is missing or effectively empty. Post-017 installs
334
+ // that already populated users/<slug>.md are left untouched.
335
+ if (
336
+ userMdIsCustomized &&
337
+ userMdRaw !== null &&
338
+ destFileIsMissingOrEmpty(destPath)
339
+ ) {
340
+ try {
341
+ copyFileSync(userMdPath, destPath);
342
+ log.info(
343
+ { src: userMdPath, dest: destPath },
344
+ "Copied customized USER.md content into users/<slug>.md",
345
+ );
346
+ } catch (err) {
347
+ log.warn(
348
+ { err, src: userMdPath, dest: destPath },
349
+ "Failed to copy USER.md; deferring USER.md cleanup",
350
+ );
351
+ return;
352
+ }
353
+ }
354
+
355
+ // Seed the guardian persona scaffold when the destination file
356
+ // still doesn't exist (e.g. no USER.md and no post-017 content).
357
+ // This keeps parity with `ensureGuardianPersonaFile` for new
358
+ // installs so downstream readers always find a file.
359
+ if (!existsSync(destPath)) {
360
+ try {
361
+ writeFileSync(destPath, GUARDIAN_PERSONA_TEMPLATE, "utf-8");
362
+ log.info(
363
+ { dest: destPath },
364
+ "Seeded guardian persona scaffold at users/<slug>.md",
365
+ );
366
+ } catch (err) {
367
+ log.warn(
368
+ { err, dest: destPath },
369
+ "Failed to seed guardian persona scaffold; continuing with USER.md deletion",
370
+ );
371
+ }
372
+ }
373
+
374
+ // Finally, delete the legacy USER.md if it still exists — template or
375
+ // customized, it has no remaining consumer.
376
+ if (existsSync(userMdPath)) {
377
+ try {
378
+ unlinkSync(userMdPath);
379
+ log.info({ path: userMdPath }, "Deleted legacy workspace USER.md");
380
+ } catch (err) {
381
+ log.warn(
382
+ { err, path: userMdPath },
383
+ "Failed to delete legacy USER.md",
384
+ );
385
+ }
304
386
  }
387
+ } finally {
388
+ db.close();
305
389
  }
306
390
  },
307
391
 
@@ -0,0 +1,64 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import type { WorkspaceMigration } from "./types.js";
5
+
6
+ /**
7
+ * Strip a persisted `llm.callSites.advisor` entry from existing config files.
8
+ *
9
+ * `advisor` is not a valid `LLMCallSiteEnum` key, so a saved
10
+ * `llm.callSites.advisor.profile` is rejected on parse by the
11
+ * `z.partialRecord(LLMCallSiteEnum, ...)` schema. The loader recovers (logs
12
+ * `Invalid config at "llm.callSites.advisor"...`, deletes the key, re-parses),
13
+ * so it is not a crash — but the warning is logged on every boot, and because
14
+ * `GET /config` serves the raw file the web "Overrides" badge keeps counting
15
+ * the invalid key with no reset path.
16
+ *
17
+ * This migration strips the key once. The now-empty `llm.callSites` object is
18
+ * pruned if `advisor` was its only key (the schema defaults `callSites` to
19
+ * `{}`, so an absent key is equivalent to an empty map). Other call-site keys
20
+ * and the unrelated top-level `llm.advisorProfile` selection are left intact.
21
+ *
22
+ * No-op for configs that never had the key. Idempotent.
23
+ */
24
+ export const removeAdvisorCallsiteOverrideMigration: WorkspaceMigration = {
25
+ id: "112-remove-advisor-callsite-override",
26
+ description:
27
+ "Remove the stale advisor entry from llm.callSites (advisor call site removed)",
28
+ run(workspaceDir: string): void {
29
+ const configPath = join(workspaceDir, "config.json");
30
+ if (!existsSync(configPath)) return;
31
+
32
+ let config: Record<string, unknown>;
33
+ try {
34
+ const raw = JSON.parse(readFileSync(configPath, "utf-8"));
35
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
36
+ config = raw as Record<string, unknown>;
37
+ } catch {
38
+ return;
39
+ }
40
+
41
+ const llm = config.llm;
42
+ if (!llm || typeof llm !== "object" || Array.isArray(llm)) return;
43
+
44
+ const callSites = (llm as Record<string, unknown>).callSites;
45
+ if (!callSites || typeof callSites !== "object" || Array.isArray(callSites))
46
+ return;
47
+
48
+ const sites = callSites as Record<string, unknown>;
49
+ if (!("advisor" in sites)) return;
50
+
51
+ delete sites.advisor;
52
+
53
+ // Prune the now-empty callSites map; an absent key is equivalent to the
54
+ // schema's `{}` default.
55
+ if (Object.keys(sites).length === 0) {
56
+ delete (llm as Record<string, unknown>).callSites;
57
+ }
58
+
59
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
60
+ },
61
+ down(_workspaceDir: string): void {
62
+ // Forward-only — the advisor call site no longer exists.
63
+ },
64
+ };
@@ -109,6 +109,7 @@ import { dropBalancedEconomyProfileMigration } from "./108-drop-balanced-economy
109
109
  import { swapQualityProfileToGlm52Migration } from "./109-swap-quality-profile-to-glm-5p2.js";
110
110
  import { flipBalancedProfileToTogetherMigration } from "./110-flip-balanced-profile-to-together.js";
111
111
  import { pruneSeededCallsiteDefaultsMigration } from "./111-prune-seeded-callsite-defaults.js";
112
+ import { removeAdvisorCallsiteOverrideMigration } from "./112-remove-advisor-callsite-override.js";
112
113
  import { migrateToWorkspaceVolumeMigration } from "./migrate-to-workspace-volume.js";
113
114
  import type { WorkspaceMigration } from "./types.js";
114
115
 
@@ -229,4 +230,5 @@ export const WORKSPACE_MIGRATIONS: WorkspaceMigration[] = [
229
230
  swapQualityProfileToGlm52Migration,
230
231
  flipBalancedProfileToTogetherMigration,
231
232
  pruneSeededCallsiteDefaultsMigration,
233
+ removeAdvisorCallsiteOverrideMigration,
232
234
  ];
@@ -1,56 +0,0 @@
1
- import { describe, expect, mock, test } from "bun:test";
2
-
3
- // Drive the gate off a controllable llm config + a stubbed default-profile
4
- // resolver, so we can assert the default-on semantics precisely.
5
- let mockLlm: {
6
- profiles: Record<string, { advisorEnabled?: boolean | null }>;
7
- activeProfile?: string;
8
- } = { profiles: {} };
9
-
10
- mock.module("../../../../config/loader.js", () => ({
11
- getConfig: () => ({ llm: mockLlm }),
12
- }));
13
- mock.module("../../../../config/llm-resolver.js", () => ({
14
- resolveDefaultProfileKey: () => "balanced",
15
- }));
16
-
17
- const { advisorEnabledForProfile } = await import("../advisor-gate.js");
18
-
19
- describe("advisorEnabledForProfile", () => {
20
- test("default-on when the profile omits the flag", () => {
21
- mockLlm = { profiles: { p: {} }, activeProfile: "p" };
22
- expect(advisorEnabledForProfile("p")).toBe(true);
23
- });
24
-
25
- test("default-on when the flag is null", () => {
26
- mockLlm = { profiles: { p: { advisorEnabled: null } }, activeProfile: "p" };
27
- expect(advisorEnabledForProfile("p")).toBe(true);
28
- });
29
-
30
- test("disabled only on an explicit false", () => {
31
- mockLlm = {
32
- profiles: { p: { advisorEnabled: false } },
33
- activeProfile: "p",
34
- };
35
- expect(advisorEnabledForProfile("p")).toBe(false);
36
- });
37
-
38
- test("enabled on an explicit true", () => {
39
- mockLlm = { profiles: { p: { advisorEnabled: true } }, activeProfile: "p" };
40
- expect(advisorEnabledForProfile("p")).toBe(true);
41
- });
42
-
43
- test("falls back to the active profile when modelProfile is null", () => {
44
- mockLlm = {
45
- profiles: { a: { advisorEnabled: false } },
46
- activeProfile: "a",
47
- };
48
- expect(advisorEnabledForProfile(null)).toBe(false);
49
- });
50
-
51
- test("falls back to the call-site default profile when neither is set", () => {
52
- // resolveDefaultProfileKey is stubbed to "balanced".
53
- mockLlm = { profiles: { balanced: { advisorEnabled: false } } };
54
- expect(advisorEnabledForProfile(null)).toBe(false);
55
- });
56
- });
@@ -1,43 +0,0 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
2
-
3
- import type { Message } from "../../../../providers/types.js";
4
- import {
5
- getCapture,
6
- recordMessages,
7
- recordSystemPrompt,
8
- resetAdvisorStateForTests,
9
- seedCapture,
10
- } from "../advisor-state-store.js";
11
-
12
- const userMsg = (t: string): Message => ({
13
- role: "user",
14
- content: [{ type: "text", text: t }],
15
- });
16
-
17
- afterEach(() => {
18
- resetAdvisorStateForTests();
19
- });
20
-
21
- describe("advisor state store", () => {
22
- test("records system prompt and messages independently per conversation", () => {
23
- recordSystemPrompt("c1", "system A");
24
- recordMessages("c1", [userMsg("hello")]);
25
- recordSystemPrompt("c2", "system B");
26
-
27
- expect(getCapture("c1")?.systemPrompt).toBe("system A");
28
- expect(getCapture("c1")?.messages).toEqual([userMsg("hello")]);
29
- expect(getCapture("c2")?.systemPrompt).toBe("system B");
30
- expect(getCapture("c2")?.messages).toEqual([]);
31
- });
32
-
33
- test("seedCapture snapshots (copies) the array", () => {
34
- const live: Message[] = [userMsg("one")];
35
- seedCapture("c1", live);
36
- live.push(userMsg("two"));
37
- expect(getCapture("c1")?.messages).toEqual([userMsg("one")]);
38
- });
39
-
40
- test("getCapture returns undefined for an unseen conversation", () => {
41
- expect(getCapture("nope")).toBeUndefined();
42
- });
43
- });