@vellumai/assistant 0.5.7 → 0.5.8

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 (197) hide show
  1. package/Dockerfile +2 -1
  2. package/docker-entrypoint.sh +9 -0
  3. package/docs/architecture/memory.md +13 -11
  4. package/node_modules/@vellumai/ces-contracts/src/error.ts +1 -1
  5. package/node_modules/@vellumai/ces-contracts/src/grants.ts +1 -1
  6. package/node_modules/@vellumai/ces-contracts/src/handles.ts +1 -1
  7. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -1
  8. package/node_modules/@vellumai/ces-contracts/src/rpc.ts +1 -1
  9. package/package.json +1 -1
  10. package/src/__tests__/approval-cascade.test.ts +0 -1
  11. package/src/__tests__/browser-fill-credential.test.ts +1 -1
  12. package/src/__tests__/call-controller.test.ts +0 -1
  13. package/src/__tests__/ces-rpc-credential-backend.test.ts +3 -3
  14. package/src/__tests__/ces-startup-timeout.test.ts +40 -0
  15. package/src/__tests__/config-schema-cmd.test.ts +0 -1
  16. package/src/__tests__/config-schema.test.ts +2 -0
  17. package/src/__tests__/conversation-abort-tool-results.test.ts +0 -1
  18. package/src/__tests__/conversation-agent-loop-overflow.test.ts +0 -2
  19. package/src/__tests__/conversation-agent-loop.test.ts +2 -4
  20. package/src/__tests__/conversation-confirmation-signals.test.ts +0 -1
  21. package/src/__tests__/conversation-error.test.ts +15 -1
  22. package/src/__tests__/conversation-messaging-secret-redirect.test.ts +1 -1
  23. package/src/__tests__/conversation-pre-run-repair.test.ts +0 -1
  24. package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -1
  25. package/src/__tests__/conversation-queue.test.ts +0 -1
  26. package/src/__tests__/conversation-slash-queue.test.ts +0 -1
  27. package/src/__tests__/conversation-slash-unknown.test.ts +0 -1
  28. package/src/__tests__/conversation-workspace-injection.test.ts +0 -1
  29. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -1
  30. package/src/__tests__/credential-execution-client.test.ts +5 -2
  31. package/src/__tests__/credential-execution-feature-gates.test.ts +31 -16
  32. package/src/__tests__/credential-execution-managed-contract.test.ts +2 -2
  33. package/src/__tests__/credential-security-e2e.test.ts +1 -1
  34. package/src/__tests__/credential-security-invariants.test.ts +2 -5
  35. package/src/__tests__/credentials-cli.test.ts +4 -3
  36. package/src/__tests__/daemon-credential-client.test.ts +123 -0
  37. package/src/__tests__/deterministic-verification-control-plane.test.ts +1 -0
  38. package/src/__tests__/gateway-client-managed-outbound.test.ts +79 -1
  39. package/src/__tests__/journal-context.test.ts +335 -0
  40. package/src/__tests__/memory-context-benchmark.benchmark.test.ts +0 -3
  41. package/src/__tests__/memory-lifecycle-e2e.test.ts +70 -25
  42. package/src/__tests__/memory-recall-quality.test.ts +48 -17
  43. package/src/__tests__/memory-regressions.test.ts +408 -363
  44. package/src/__tests__/memory-retrieval.benchmark.test.ts +0 -3
  45. package/src/__tests__/non-member-access-request.test.ts +2 -2
  46. package/src/__tests__/notification-decision-strategy.test.ts +71 -0
  47. package/src/__tests__/oauth-cli.test.ts +5 -1
  48. package/src/__tests__/provider-commit-message-generator.test.ts +0 -37
  49. package/src/__tests__/provider-error-scenarios.test.ts +0 -267
  50. package/src/__tests__/provider-streaming.benchmark.test.ts +2 -81
  51. package/src/__tests__/relay-server.test.ts +1 -2
  52. package/src/__tests__/script-proxy-injection-runtime.test.ts +1 -1
  53. package/src/__tests__/secret-onetime-send.test.ts +1 -1
  54. package/src/__tests__/secure-keys.test.ts +18 -15
  55. package/src/__tests__/skill-memory.test.ts +17 -3
  56. package/src/__tests__/stale-approval-dedup.test.ts +171 -0
  57. package/src/__tests__/stt-hints.test.ts +437 -0
  58. package/src/__tests__/task-memory-cleanup.test.ts +14 -0
  59. package/src/__tests__/twilio-routes-twiml.test.ts +139 -1
  60. package/src/__tests__/voice-quality.test.ts +58 -0
  61. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
  62. package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +5 -3
  63. package/src/acp/agent-process.ts +9 -1
  64. package/src/agent/loop.ts +1 -1
  65. package/src/approvals/guardian-request-resolvers.ts +164 -38
  66. package/src/calls/__tests__/tts-text-sanitizer.test.ts +254 -0
  67. package/src/calls/call-controller.ts +9 -5
  68. package/src/calls/fish-audio-client.ts +26 -14
  69. package/src/calls/stt-hints.ts +189 -0
  70. package/src/calls/tts-text-sanitizer.ts +61 -0
  71. package/src/calls/twilio-routes.ts +32 -4
  72. package/src/calls/voice-quality.ts +15 -3
  73. package/src/calls/voice-session-bridge.ts +1 -0
  74. package/src/cli/commands/avatar.ts +2 -2
  75. package/src/cli/commands/credentials.ts +110 -94
  76. package/src/cli/commands/doctor.ts +2 -2
  77. package/src/cli/commands/keys.ts +7 -7
  78. package/src/cli/commands/memory.ts +1 -1
  79. package/src/cli/commands/oauth/connections.ts +11 -29
  80. package/src/cli/commands/oauth/platform.ts +389 -43
  81. package/src/cli/lib/daemon-credential-client.ts +284 -0
  82. package/src/cli.ts +1 -1
  83. package/src/config/bundled-skills/AGENTS.md +34 -0
  84. package/src/config/bundled-skills/acp/SKILL.md +10 -0
  85. package/src/config/bundled-skills/app-builder/SKILL.md +0 -4
  86. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -2
  87. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +1 -0
  88. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +1 -0
  89. package/src/config/bundled-skills/settings/SKILL.md +15 -2
  90. package/src/config/bundled-skills/settings/TOOLS.json +46 -1
  91. package/src/config/bundled-skills/settings/tools/avatar-remove.ts +59 -0
  92. package/src/config/bundled-skills/settings/tools/avatar-update.ts +80 -0
  93. package/src/config/bundled-skills/slack/SKILL.md +1 -1
  94. package/src/config/bundled-tool-registry.ts +4 -0
  95. package/src/config/defaults.ts +0 -2
  96. package/src/config/env-registry.ts +4 -4
  97. package/src/config/env.ts +14 -1
  98. package/src/config/feature-flag-registry.json +1 -1
  99. package/src/config/loader.ts +8 -11
  100. package/src/config/schema.ts +5 -16
  101. package/src/config/schemas/calls.ts +17 -0
  102. package/src/config/schemas/inference.ts +2 -2
  103. package/src/config/schemas/journal.ts +16 -0
  104. package/src/config/schemas/memory-processing.ts +2 -2
  105. package/src/config/types.ts +1 -0
  106. package/src/contacts/contact-store.ts +2 -2
  107. package/src/credential-execution/executable-discovery.ts +1 -1
  108. package/src/credential-execution/startup-timeout.ts +36 -0
  109. package/src/daemon/approval-generators.ts +3 -9
  110. package/src/daemon/conversation-error.ts +13 -1
  111. package/src/daemon/conversation-memory.ts +1 -2
  112. package/src/daemon/conversation-process.ts +18 -1
  113. package/src/daemon/conversation-surfaces.ts +30 -1
  114. package/src/daemon/conversation.ts +20 -9
  115. package/src/daemon/guardian-action-generators.ts +3 -9
  116. package/src/daemon/lifecycle.ts +18 -11
  117. package/src/daemon/message-types/conversations.ts +1 -0
  118. package/src/daemon/server.ts +2 -3
  119. package/src/memory/app-store.ts +31 -0
  120. package/src/memory/db-init.ts +4 -0
  121. package/src/memory/indexer.ts +19 -10
  122. package/src/memory/items-extractor.ts +315 -322
  123. package/src/memory/job-handlers/summarization.ts +26 -16
  124. package/src/memory/jobs-store.ts +33 -1
  125. package/src/memory/journal-memory.ts +214 -0
  126. package/src/memory/migrations/193-add-source-type-columns.ts +81 -0
  127. package/src/memory/migrations/index.ts +1 -0
  128. package/src/memory/migrations/registry.ts +8 -0
  129. package/src/memory/retriever.test.ts +37 -25
  130. package/src/memory/retriever.ts +24 -49
  131. package/src/memory/schema/memory-core.ts +2 -0
  132. package/src/memory/search/formatting.ts +7 -44
  133. package/src/memory/search/staleness.ts +4 -0
  134. package/src/memory/search/tier-classifier.ts +10 -2
  135. package/src/memory/search/types.ts +2 -5
  136. package/src/memory/task-memory-cleanup.ts +4 -3
  137. package/src/notifications/adapters/slack.ts +168 -6
  138. package/src/notifications/broadcaster.ts +1 -0
  139. package/src/notifications/copy-composer.ts +59 -2
  140. package/src/notifications/signal.ts +2 -0
  141. package/src/notifications/types.ts +2 -0
  142. package/src/prompts/journal-context.ts +133 -0
  143. package/src/prompts/persona-resolver.ts +80 -24
  144. package/src/prompts/system-prompt.ts +8 -0
  145. package/src/prompts/templates/SOUL.md +10 -0
  146. package/src/providers/provider-send-message.ts +3 -32
  147. package/src/providers/registry.ts +2 -139
  148. package/src/providers/types.ts +1 -1
  149. package/src/runtime/access-request-helper.ts +4 -0
  150. package/src/runtime/auth/__tests__/guard-tests.test.ts +9 -50
  151. package/src/runtime/auth/route-policy.ts +2 -0
  152. package/src/runtime/gateway-client.ts +47 -4
  153. package/src/runtime/guardian-decision-types.ts +45 -4
  154. package/src/runtime/http-server.ts +5 -2
  155. package/src/runtime/routes/access-request-decision.ts +2 -2
  156. package/src/runtime/routes/app-management-routes.ts +2 -1
  157. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +219 -30
  158. package/src/runtime/routes/approval-strategies/guardian-text-engine-strategy.ts +37 -14
  159. package/src/runtime/routes/channel-readiness-routes.ts +9 -4
  160. package/src/runtime/routes/debug-routes.ts +12 -9
  161. package/src/runtime/routes/guardian-approval-interception.ts +168 -11
  162. package/src/runtime/routes/guardian-approval-prompt.ts +6 -1
  163. package/src/runtime/routes/guardian-approval-reply-helpers.ts +103 -21
  164. package/src/runtime/routes/identity-routes.ts +1 -1
  165. package/src/runtime/routes/inbound-message-handler.ts +31 -1
  166. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +64 -5
  167. package/src/runtime/routes/inbound-stages/background-dispatch.ts +52 -40
  168. package/src/runtime/routes/integrations/twilio.ts +52 -10
  169. package/src/runtime/routes/memory-item-routes.test.ts +3 -3
  170. package/src/runtime/routes/memory-item-routes.ts +25 -11
  171. package/src/runtime/routes/secret-routes.ts +141 -10
  172. package/src/runtime/routes/tts-routes.ts +11 -1
  173. package/src/security/ces-credential-client.ts +18 -9
  174. package/src/security/ces-rpc-credential-backend.ts +4 -3
  175. package/src/security/credential-backend.ts +10 -4
  176. package/src/security/secure-keys.ts +21 -4
  177. package/src/skills/catalog-install.ts +4 -36
  178. package/src/skills/skill-memory.ts +1 -0
  179. package/src/subagent/manager.ts +2 -5
  180. package/src/tools/acp/spawn.ts +78 -1
  181. package/src/tools/credentials/vault.ts +5 -3
  182. package/src/tools/memory/definitions.ts +3 -2
  183. package/src/tools/memory/handlers.ts +10 -7
  184. package/src/tools/terminal/safe-env.ts +1 -0
  185. package/src/util/browser.ts +15 -0
  186. package/src/util/platform.ts +1 -1
  187. package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +4 -4
  188. package/src/workspace/migrations/017-seed-persona-dirs.ts +2 -1
  189. package/src/workspace/migrations/018-rekey-compound-credential-keys.ts +184 -0
  190. package/src/workspace/migrations/019-scope-journal-to-guardian.ts +103 -0
  191. package/src/workspace/migrations/migrate-to-workspace-volume.ts +4 -4
  192. package/src/workspace/migrations/registry.ts +4 -0
  193. package/src/workspace/provider-commit-message-generator.ts +12 -21
  194. package/src/__tests__/provider-fail-open-selection.test.ts +0 -271
  195. package/src/__tests__/provider-failover-actual-provider.test.ts +0 -66
  196. package/src/memory/search/lexical.ts +0 -48
  197. package/src/providers/failover.ts +0 -186
@@ -46,9 +46,10 @@ const memoryManageProperties = {
46
46
  "decision",
47
47
  "constraint",
48
48
  "event",
49
+ "journal",
49
50
  ],
50
51
  description:
51
- 'Category of the memory item (required for save). Use "constraint" for mistakes, gotchas, discoveries, and working solutions - write as advice to your future self.',
52
+ 'Category of the memory item (required for save). Use "constraint" for mistakes, gotchas, discoveries, and working solutions - write as advice to your future self. Use "journal" for journal-style memories — experiential snapshots, upcoming events, things to carry forward.',
52
53
  },
53
54
  subject: {
54
55
  type: "string" as const,
@@ -59,7 +60,7 @@ const memoryManageProperties = {
59
60
  export const memoryManageDefinition: ToolDefinition = {
60
61
  name: "memory_manage",
61
62
  description:
62
- "Save, update, or delete memory items. Memory does not survive session restarts - if you want to remember something, save it now. Use 'save' for new information worth remembering (facts, preferences, mistakes, discoveries, gotchas), 'update' to correct existing items, 'delete' to remove outdated items. When a user says 'remember this', save immediately. For user profile or personality changes, update workspace files (USER.md, SOUL.md) instead.",
63
+ "Save, update, or delete memory items. If you want to remember something, save it now. Use 'save' for new information worth remembering (facts, preferences, mistakes, discoveries, gotchas), 'update' to correct existing items, 'delete' to remove outdated items. When a user says 'remember this', save immediately. Be proactive: if you learn something important that may be useful in the future, always call this tool — don't just say or hope you'll remember it. This is not a substitute for updating workspace files when relevant - do both.",
63
64
  input_schema: {
64
65
  type: "object",
65
66
  properties: memoryManageProperties,
@@ -9,7 +9,6 @@ import { buildMemoryRecall } from "../../memory/retriever.js";
9
9
  import { memoryItems } from "../../memory/schema.js";
10
10
  import type { ScopePolicyOverride } from "../../memory/search/types.js";
11
11
  import { getLogger } from "../../util/logger.js";
12
- import { truncate } from "../../util/truncate.js";
13
12
  import type { ToolExecutionResult } from "../types.js";
14
13
 
15
14
  const log = getLogger("memory-tools");
@@ -39,6 +38,7 @@ export async function handleMemorySave(
39
38
  "decision",
40
39
  "constraint",
41
40
  "event",
41
+ "journal",
42
42
  ]);
43
43
  if (typeof rawKind !== "string") {
44
44
  return {
@@ -60,14 +60,14 @@ export async function handleMemorySave(
60
60
 
61
61
  const subject =
62
62
  typeof args.subject === "string" && args.subject.trim().length > 0
63
- ? truncate(args.subject.trim(), 80, "")
63
+ ? args.subject.trim()
64
64
  : inferSubjectFromStatement(statement.trim());
65
65
 
66
66
  try {
67
67
  const db = getDb();
68
68
  const id = uuid();
69
69
  const now = Date.now();
70
- const trimmedStatement = truncate(statement.trim(), 500, "");
70
+ const trimmedStatement = statement.trim();
71
71
 
72
72
  const fingerprint = computeMemoryFingerprint(
73
73
  scopeId,
@@ -93,6 +93,7 @@ export async function handleMemorySave(
93
93
  status: "active",
94
94
  importance: 0.8,
95
95
  lastSeenAt: now,
96
+ sourceType: "tool",
96
97
  verificationState: "user_confirmed",
97
98
  })
98
99
  .where(eq(memoryItems.id, existing.id))
@@ -115,6 +116,7 @@ export async function handleMemorySave(
115
116
  confidence: 0.95, // explicit saves have high confidence
116
117
  importance: 0.8, // explicit saves are high importance
117
118
  fingerprint,
119
+ sourceType: "tool",
118
120
  verificationState: "user_confirmed",
119
121
  scopeId,
120
122
  firstSeenAt: now,
@@ -187,7 +189,7 @@ export async function handleMemoryUpdate(
187
189
  }
188
190
 
189
191
  const now = Date.now();
190
- const trimmedStatement = truncate(statement.trim(), 500, "");
192
+ const trimmedStatement = statement.trim();
191
193
 
192
194
  const fingerprint = computeMemoryFingerprint(
193
195
  scopeId,
@@ -221,6 +223,7 @@ export async function handleMemoryUpdate(
221
223
  fingerprint,
222
224
  lastSeenAt: now,
223
225
  importance: 0.8,
226
+ sourceType: "tool",
224
227
  verificationState: "user_confirmed",
225
228
  })
226
229
  .where(eq(memoryItems.id, existing.id))
@@ -308,7 +311,7 @@ export async function handleMemoryRecall(
308
311
  items: [],
309
312
  sources: {
310
313
  semantic: recall.semanticHits,
311
- recency: recall.recencyHits,
314
+ recency: 0,
312
315
  },
313
316
  };
314
317
  return {
@@ -328,7 +331,7 @@ export async function handleMemoryRecall(
328
331
  })),
329
332
  sources: {
330
333
  semantic: recall.semanticHits,
331
- recency: recall.recencyHits,
334
+ recency: 0,
332
335
  },
333
336
  };
334
337
 
@@ -416,7 +419,7 @@ export async function handleMemoryDelete(
416
419
  function inferSubjectFromStatement(statement: string): string {
417
420
  // Take first few words as a subject label
418
421
  const words = statement.split(/\s+/).slice(0, 6).join(" ");
419
- return truncate(words, 80, "");
422
+ return words;
420
423
  }
421
424
 
422
425
  /**
@@ -28,6 +28,7 @@ const SAFE_ENV_VARS = [
28
28
  "GPG_TTY",
29
29
  "GNUPGHOME",
30
30
  "VELLUM_DEV",
31
+ "VELLUM_WORKSPACE_DIR",
31
32
  ] as const;
32
33
 
33
34
  export function buildSanitizedEnv(): Record<string, string> {
@@ -0,0 +1,15 @@
1
+ import { isLinux, isMacOS } from "./platform.js";
2
+
3
+ /**
4
+ * Open a URL in the user's default browser, falling back to printing the URL
5
+ * to stderr on unsupported platforms.
6
+ */
7
+ export function openInBrowser(url: string): void {
8
+ if (isMacOS()) {
9
+ Bun.spawn(["open", url], { stdout: "ignore", stderr: "ignore" });
10
+ } else if (isLinux()) {
11
+ Bun.spawn(["xdg-open", url], { stdout: "ignore", stderr: "ignore" });
12
+ } else {
13
+ process.stderr.write(`Open this URL to authorize:\n\n${url}\n`);
14
+ }
15
+ }
@@ -324,7 +324,7 @@ export function getSignalsDir(): string {
324
324
  /**
325
325
  * Returns the workspace root for user-facing state.
326
326
  *
327
- * When the WORKSPACE_DIR env var is set, returns that value (used in
327
+ * When the VELLUM_WORKSPACE_DIR env var is set, returns that value (used in
328
328
  * containerized deployments where the workspace is a separate volume).
329
329
  * Otherwise falls back to ~/.vellum/workspace.
330
330
  *
@@ -105,10 +105,10 @@ export const migrateCredentialsFromKeychainMigration: WorkspaceMigration = {
105
105
  }
106
106
 
107
107
  if (!brokerAvailable) {
108
- // Unlike migration 015, we return silently here. If the broker is not
109
- // available, credentials may already be in the encrypted store from
110
- // before migration 015 ran, or from a non-desktop environment.
111
- return;
108
+ throw new Error(
109
+ "Keychain broker not available after waiting credential migration " +
110
+ "will be retried on next startup",
111
+ );
112
112
  }
113
113
 
114
114
  const { setKey } = await import("../../security/encrypted-store.js");
@@ -8,7 +8,7 @@ import {
8
8
  } from "node:fs";
9
9
  import { join } from "node:path";
10
10
 
11
- import { eq } from "drizzle-orm";
11
+ import { desc, eq } from "drizzle-orm";
12
12
 
13
13
  import { generateUserFileSlug } from "../../contacts/contact-store.js";
14
14
  import { getDb } from "../../memory/db.js";
@@ -68,6 +68,7 @@ export const seedPersonaDirsMigration: WorkspaceMigration = {
68
68
  .select()
69
69
  .from(contacts)
70
70
  .where(eq(contacts.role, "guardian"))
71
+ .orderBy(desc(contacts.createdAt))
71
72
  .limit(1)
72
73
  .get();
73
74
 
@@ -0,0 +1,184 @@
1
+ import { credentialKey } from "../../security/credential-key.js";
2
+ import { getLogger } from "../../util/logger.js";
3
+ import type { WorkspaceMigration } from "./types.js";
4
+
5
+ const log = getLogger("workspace-migrations");
6
+ const CREDENTIAL_PREFIX = "credential/";
7
+
8
+ /**
9
+ * Re-key compound credential storage keys from the old indexOf-based split
10
+ * to the new lastIndexOf-based split.
11
+ *
12
+ * The old code split "integration:google:access_token" at the first colon:
13
+ * service = "integration", field = "google:access_token"
14
+ * → key = "credential/integration/google:access_token"
15
+ *
16
+ * The new code splits at the last colon:
17
+ * service = "integration:google", field = "access_token"
18
+ * → key = "credential/integration:google/access_token"
19
+ *
20
+ * Detection heuristic: if the field portion of a stored key contains a colon,
21
+ * it was stored with the old indexOf logic and needs re-keying. Simple
22
+ * service:field names (single colon) produce the same key with both methods
23
+ * and don't need migration.
24
+ */
25
+ export const rekeyCompoundCredentialKeysMigration: WorkspaceMigration = {
26
+ id: "018-rekey-compound-credential-keys",
27
+ description:
28
+ "Re-key compound credential keys from indexOf to lastIndexOf split format",
29
+
30
+ async run(_workspaceDir: string): Promise<void> {
31
+ const {
32
+ listSecureKeysAsync,
33
+ getSecureKeyAsync,
34
+ setSecureKeyAsync,
35
+ deleteSecureKeyAsync,
36
+ } = await import("../../security/secure-keys.js");
37
+
38
+ const { accounts, unreachable } = await listSecureKeysAsync();
39
+ if (unreachable) {
40
+ throw new Error(
41
+ "Credential store unreachable — migration will be retried on next startup",
42
+ );
43
+ }
44
+
45
+ let migratedCount = 0;
46
+ let failedCount = 0;
47
+
48
+ for (const account of accounts) {
49
+ if (!account.startsWith(CREDENTIAL_PREFIX)) continue;
50
+
51
+ const rest = account.slice(CREDENTIAL_PREFIX.length);
52
+ const slashIdx = rest.indexOf("/");
53
+ if (slashIdx < 1 || slashIdx >= rest.length - 1) continue;
54
+
55
+ const oldService = rest.slice(0, slashIdx);
56
+ const oldField = rest.slice(slashIdx + 1);
57
+
58
+ // Only migrate keys where the field contains a colon — these were
59
+ // stored using the old indexOf(":") split and need re-keying.
60
+ if (!oldField.includes(":")) continue;
61
+
62
+ // Reconstruct the original "service:field" name and re-split with lastIndexOf
63
+ const originalName = `${oldService}:${oldField}`;
64
+ const lastColonIdx = originalName.lastIndexOf(":");
65
+ const newService = originalName.slice(0, lastColonIdx);
66
+ const newField = originalName.slice(lastColonIdx + 1);
67
+ const newKey = credentialKey(newService, newField);
68
+
69
+ // Skip if the key format didn't actually change
70
+ if (account === newKey) continue;
71
+
72
+ // Skip if the new key already exists (idempotent — may have been
73
+ // partially migrated or the user already stored under the new format)
74
+ const existingNewValue = await getSecureKeyAsync(newKey);
75
+ if (existingNewValue !== undefined) {
76
+ // New key exists — just clean up the old orphaned key
77
+ await deleteSecureKeyAsync(account);
78
+ log.info(
79
+ { oldKey: account, newKey },
80
+ "Deleted orphaned old-format credential key (new key already exists)",
81
+ );
82
+ migratedCount++;
83
+ continue;
84
+ }
85
+
86
+ const value = await getSecureKeyAsync(account);
87
+ if (value === undefined) continue;
88
+
89
+ // Write new key first, then delete old key (crash-safe order)
90
+ const stored = await setSecureKeyAsync(newKey, value);
91
+ if (!stored) {
92
+ log.warn(
93
+ { oldKey: account, newKey },
94
+ "Failed to write re-keyed credential — skipping",
95
+ );
96
+ failedCount++;
97
+ continue;
98
+ }
99
+
100
+ await deleteSecureKeyAsync(account);
101
+ migratedCount++;
102
+ log.info({ oldKey: account, newKey }, "Re-keyed compound credential");
103
+ }
104
+
105
+ if (migratedCount > 0 || failedCount > 0) {
106
+ log.info(
107
+ { migratedCount, failedCount },
108
+ "Compound credential key migration complete",
109
+ );
110
+ }
111
+ },
112
+
113
+ async down(_workspaceDir: string): Promise<void> {
114
+ // Reverse: re-key from lastIndexOf format back to indexOf format.
115
+ // Keys where the service contains ":" were migrated from old format.
116
+ const {
117
+ listSecureKeysAsync,
118
+ getSecureKeyAsync,
119
+ setSecureKeyAsync,
120
+ deleteSecureKeyAsync,
121
+ } = await import("../../security/secure-keys.js");
122
+
123
+ const { accounts, unreachable } = await listSecureKeysAsync();
124
+ if (unreachable) {
125
+ throw new Error(
126
+ "Credential store unreachable — rollback will be retried on next startup",
127
+ );
128
+ }
129
+
130
+ let rolledBackCount = 0;
131
+ let failedCount = 0;
132
+
133
+ for (const account of accounts) {
134
+ if (!account.startsWith(CREDENTIAL_PREFIX)) continue;
135
+
136
+ const rest = account.slice(CREDENTIAL_PREFIX.length);
137
+ const slashIdx = rest.indexOf("/");
138
+ if (slashIdx < 1 || slashIdx >= rest.length - 1) continue;
139
+
140
+ const service = rest.slice(0, slashIdx);
141
+ const field = rest.slice(slashIdx + 1);
142
+
143
+ // Only rollback keys where the service contains ":" — these are in
144
+ // the new lastIndexOf format and need reverting to indexOf format.
145
+ if (!service.includes(":")) continue;
146
+
147
+ // Reconstruct the original name and re-split with indexOf (old format)
148
+ const originalName = `${service}:${field}`;
149
+ const firstColonIdx = originalName.indexOf(":");
150
+ const oldService = originalName.slice(0, firstColonIdx);
151
+ const oldField = originalName.slice(firstColonIdx + 1);
152
+ const oldKey = credentialKey(oldService, oldField);
153
+
154
+ if (account === oldKey) continue;
155
+
156
+ const value = await getSecureKeyAsync(account);
157
+ if (value === undefined) continue;
158
+
159
+ const stored = await setSecureKeyAsync(oldKey, value);
160
+ if (!stored) {
161
+ log.warn(
162
+ { newKey: account, oldKey },
163
+ "Failed to rollback re-keyed credential — skipping",
164
+ );
165
+ failedCount++;
166
+ continue;
167
+ }
168
+
169
+ await deleteSecureKeyAsync(account);
170
+ rolledBackCount++;
171
+ log.info(
172
+ { newKey: account, oldKey },
173
+ "Rolled back compound credential key",
174
+ );
175
+ }
176
+
177
+ if (rolledBackCount > 0 || failedCount > 0) {
178
+ log.info(
179
+ { rolledBackCount, failedCount },
180
+ "Compound credential key rollback complete",
181
+ );
182
+ }
183
+ },
184
+ };
@@ -0,0 +1,103 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ renameSync,
6
+ rmdirSync,
7
+ statSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ import { desc, eq } from "drizzle-orm";
12
+
13
+ import { getDb } from "../../memory/db.js";
14
+ import { contacts } from "../../memory/schema/contacts.js";
15
+ import type { WorkspaceMigration } from "./types.js";
16
+
17
+ export const scopeJournalToGuardianMigration: WorkspaceMigration = {
18
+ id: "019-scope-journal-to-guardian",
19
+ description:
20
+ "Move root journal entries into per-user subdirectory for guardian",
21
+
22
+ run(workspaceDir: string): void {
23
+ const journalDir = join(workspaceDir, "journal");
24
+ if (!existsSync(journalDir)) return;
25
+
26
+ // Find .md files in the root journal directory (not in subdirs)
27
+ let entries: string[];
28
+ try {
29
+ entries = readdirSync(journalDir);
30
+ } catch {
31
+ return;
32
+ }
33
+ const mdFiles = entries.filter((f) => {
34
+ if (!f.endsWith(".md") || f.toLowerCase() === "readme.md") return false;
35
+ try {
36
+ return statSync(join(journalDir, f)).isFile();
37
+ } catch {
38
+ return false;
39
+ }
40
+ });
41
+ if (mdFiles.length === 0) return;
42
+
43
+ // Resolve guardian user slug (same pattern as 017-seed-persona-dirs)
44
+ let slug = "guardian";
45
+ try {
46
+ const db = getDb();
47
+ const guardian = db
48
+ .select()
49
+ .from(contacts)
50
+ .where(eq(contacts.role, "guardian"))
51
+ .orderBy(desc(contacts.createdAt))
52
+ .limit(1)
53
+ .get();
54
+ if (guardian?.userFile) {
55
+ slug = guardian.userFile.replace(/\.md$/, "");
56
+ }
57
+ } catch {
58
+ // DB not ready — use fallback "guardian"
59
+ }
60
+
61
+ // Create per-user directory and move files (renameSync preserves birthtimes)
62
+ const destDir = join(journalDir, slug);
63
+ mkdirSync(destDir, { recursive: true });
64
+ for (const f of mdFiles) {
65
+ const src = join(journalDir, f);
66
+ const dest = join(destDir, f);
67
+ if (!existsSync(dest)) {
68
+ renameSync(src, dest);
69
+ }
70
+ }
71
+ },
72
+
73
+ down(workspaceDir: string): void {
74
+ const journalDir = join(workspaceDir, "journal");
75
+ if (!existsSync(journalDir)) return;
76
+ let entries: string[];
77
+ try {
78
+ entries = readdirSync(journalDir);
79
+ } catch {
80
+ return;
81
+ }
82
+ for (const entry of entries) {
83
+ const subdir = join(journalDir, entry);
84
+ try {
85
+ if (!statSync(subdir).isDirectory()) continue;
86
+ } catch {
87
+ continue;
88
+ }
89
+ for (const f of readdirSync(subdir)) {
90
+ if (!f.endsWith(".md")) continue;
91
+ const dest = join(journalDir, f);
92
+ if (!existsSync(dest)) {
93
+ renameSync(join(subdir, f), dest);
94
+ }
95
+ }
96
+ try {
97
+ rmdirSync(subdir);
98
+ } catch {
99
+ // not empty — leave it
100
+ }
101
+ }
102
+ },
103
+ };
@@ -2,13 +2,13 @@
2
2
  * Workspace migration: Migrate workspace data from /data to /workspace volume.
3
3
  *
4
4
  * In the old Docker volume layout, workspace data lived at
5
- * `$BASE_DATA_DIR/.vellum/workspace`. In the new layout, WORKSPACE_DIR points
5
+ * `$BASE_DATA_DIR/.vellum/workspace`. In the new layout, VELLUM_WORKSPACE_DIR points
6
6
  * to a dedicated volume (e.g. `/workspace`). On first boot with the new layout,
7
7
  * this migration copies existing workspace data from the old location to the
8
8
  * new volume so nothing is lost.
9
9
  *
10
10
  * Idempotent:
11
- * - Skips if WORKSPACE_DIR is not set (non-Docker or old layout).
11
+ * - Skips if VELLUM_WORKSPACE_DIR is not set (non-Docker or old layout).
12
12
  * - Skips if the workspace volume already has data (config.json exists).
13
13
  * - Skips if the sentinel file exists (already migrated).
14
14
  * - Skips if the old workspace directory doesn't exist or is empty.
@@ -34,7 +34,7 @@ const SENTINEL_FILENAME = ".workspace-volume-migrated";
34
34
  export const migrateToWorkspaceVolumeMigration: WorkspaceMigration = {
35
35
  id: "014-migrate-to-workspace-volume",
36
36
  description:
37
- "Copy workspace data from old /data/.vellum/workspace to new WORKSPACE_DIR volume on first boot",
37
+ "Copy workspace data from old /data/.vellum/workspace to new VELLUM_WORKSPACE_DIR volume on first boot",
38
38
 
39
39
  down(workspaceDir: string): void {
40
40
  // This migration copies data between volumes. Actually reversing the copy
@@ -55,7 +55,7 @@ export const migrateToWorkspaceVolumeMigration: WorkspaceMigration = {
55
55
  run(workspaceDir: string): void {
56
56
  const workspaceDirOverride = getWorkspaceDirOverride();
57
57
 
58
- // Only relevant when WORKSPACE_DIR is explicitly set (Docker with separate volume)
58
+ // Only relevant when VELLUM_WORKSPACE_DIR is explicitly set (Docker with separate volume)
59
59
  if (!workspaceDirOverride) return;
60
60
 
61
61
  const sentinelPath = join(workspaceDir, SENTINEL_FILENAME);
@@ -14,6 +14,8 @@ import { migrateCredentialsToKeychainMigration } from "./015-migrate-credentials
14
14
  import { extractFeatureFlagsToProtectedMigration } from "./016-extract-feature-flags-to-protected.js";
15
15
  import { migrateCredentialsFromKeychainMigration } from "./016-migrate-credentials-from-keychain.js";
16
16
  import { seedPersonaDirsMigration } from "./017-seed-persona-dirs.js";
17
+ import { rekeyCompoundCredentialKeysMigration } from "./018-rekey-compound-credential-keys.js";
18
+ import { scopeJournalToGuardianMigration } from "./019-scope-journal-to-guardian.js";
17
19
  import { migrateToWorkspaceVolumeMigration } from "./migrate-to-workspace-volume.js";
18
20
  import type { WorkspaceMigration } from "./types.js";
19
21
 
@@ -39,4 +41,6 @@ export const WORKSPACE_MIGRATIONS: WorkspaceMigration[] = [
39
41
  migrateCredentialsFromKeychainMigration,
40
42
  seedPersonaDirsMigration,
41
43
  extractFeatureFlagsToProtectedMigration,
44
+ rekeyCompoundCredentialKeysMigration,
45
+ scopeJournalToGuardianMigration,
42
46
  ];
@@ -52,15 +52,7 @@ const KEYLESS_PROVIDERS = new Set(["ollama"]);
52
52
  const deterministicProvider = new DefaultCommitMessageProvider();
53
53
 
54
54
  function getProviderCandidates(config: ReturnType<typeof getConfig>): string[] {
55
- const order = Array.isArray(config.providerOrder) ? config.providerOrder : [];
56
- const seen = new Set<string>();
57
- const out: string[] = [];
58
- for (const name of [config.services.inference.provider, ...order]) {
59
- if (seen.has(name)) continue;
60
- seen.add(name);
61
- out.push(name);
62
- }
63
- return out;
55
+ return [config.services.inference.provider];
64
56
  }
65
57
 
66
58
  function buildDeterministicResult(
@@ -121,7 +113,7 @@ export class ProviderCommitMessageGenerator {
121
113
 
122
114
  // ── Fallback check order (canonical) ──────────────────────────────
123
115
  // 1. disabled
124
- // 2. resolve configured provider via fail-open selection:
116
+ // 2. resolve configured provider:
125
117
  // - missing_provider_api_key OR provider_not_initialized
126
118
  // 3. selected-provider API key preflight (except keyless providers)
127
119
  // 4. breaker_open
@@ -138,7 +130,7 @@ export class ProviderCommitMessageGenerator {
138
130
  return buildDeterministicResult(context, "disabled");
139
131
  }
140
132
 
141
- // Step 2: Resolve configured provider using fail-open semantics.
133
+ // Step 2: Resolve configured provider.
142
134
  // If nothing is resolvable, differentiate likely missing-key cases from
143
135
  // true registry/init failures.
144
136
  const resolved = await resolveConfiguredProvider();
@@ -168,18 +160,17 @@ export class ProviderCommitMessageGenerator {
168
160
  }
169
161
 
170
162
  const provider = resolved.provider;
171
- const selectedProviderName = resolved.selectedProviderName;
163
+ const providerName = resolved.configuredProviderName;
172
164
 
173
- // Step 2b: API key preflight for the selected provider (skip keyless).
174
- if (!KEYLESS_PROVIDERS.has(selectedProviderName)) {
175
- const providerApiKey = await getSecureKeyAsync(selectedProviderName);
165
+ // Step 2b: API key preflight for the configured provider (skip keyless).
166
+ if (!KEYLESS_PROVIDERS.has(providerName)) {
167
+ const providerApiKey = await getSecureKeyAsync(providerName);
176
168
  if (!providerApiKey) {
177
169
  log.debug(
178
170
  {
179
- selectedProvider: selectedProviderName,
180
- configuredProvider: config.services.inference.provider,
171
+ provider: providerName,
181
172
  },
182
- "Selected provider API key missing; falling back to deterministic",
173
+ "Provider API key missing; falling back to deterministic",
183
174
  );
184
175
  return buildDeterministicResult(context, "missing_provider_api_key");
185
176
  }
@@ -211,13 +202,13 @@ export class ProviderCommitMessageGenerator {
211
202
 
212
203
  // Step 5: Fast model preflight — resolve before any provider call
213
204
  const fastModel =
214
- llmConfig.providerFastModelOverrides[selectedProviderName] ??
215
- PROVIDER_DEFAULT_FAST_MODELS[selectedProviderName];
205
+ llmConfig.providerFastModelOverrides[providerName] ??
206
+ PROVIDER_DEFAULT_FAST_MODELS[providerName];
216
207
 
217
208
  if (!fastModel) {
218
209
  log.debug(
219
210
  {
220
- provider: selectedProviderName,
211
+ provider: providerName,
221
212
  configuredProvider: config.services.inference.provider,
222
213
  },
223
214
  "No fast model resolvable for provider; falling back to deterministic",