@vellumai/assistant 0.5.8 → 0.5.10

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 (35) hide show
  1. package/AGENTS.md +1 -1
  2. package/ARCHITECTURE.md +8 -8
  3. package/README.md +1 -1
  4. package/docs/architecture/integrations.md +4 -4
  5. package/docs/architecture/keychain-broker.md +17 -18
  6. package/docs/architecture/security.md +5 -5
  7. package/eslint.config.mjs +0 -31
  8. package/package.json +1 -1
  9. package/src/__tests__/conversation-runtime-assembly.test.ts +227 -0
  10. package/src/__tests__/credentials-cli.test.ts +3 -3
  11. package/src/__tests__/stt-hints.test.ts +22 -22
  12. package/src/__tests__/voice-quality.test.ts +2 -2
  13. package/src/__tests__/workspace-migration-015-migrate-credentials-to-keychain.test.ts +5 -238
  14. package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +5 -206
  15. package/src/__tests__/workspace-migrations-runner.test.ts +15 -7
  16. package/src/cli/commands/credentials.ts +4 -4
  17. package/src/cli/commands/oauth/apps.ts +3 -3
  18. package/src/daemon/conversation-agent-loop.ts +6 -0
  19. package/src/daemon/conversation-runtime-assembly.ts +61 -1
  20. package/src/daemon/lifecycle.ts +2 -3
  21. package/src/memory/migrations/validate-migration-state.ts +14 -1
  22. package/src/prompts/system-prompt.ts +22 -0
  23. package/src/prompts/templates/NOW.md +26 -0
  24. package/src/prompts/templates/SOUL.md +10 -0
  25. package/src/prompts/update-bulletin-format.ts +0 -2
  26. package/src/runtime/routes/settings-routes.ts +1 -1
  27. package/src/skills/inline-command-expansions.ts +7 -7
  28. package/src/tools/sensitive-output-placeholders.ts +2 -2
  29. package/src/workspace/migrations/015-migrate-credentials-to-keychain.ts +13 -148
  30. package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +7 -145
  31. package/src/workspace/migrations/AGENTS.md +11 -0
  32. package/src/workspace/migrations/runner.ts +16 -6
  33. package/src/workspace/migrations/types.ts +7 -0
  34. package/src/__tests__/keychain-broker-client.test.ts +0 -800
  35. package/src/security/keychain-broker-client.ts +0 -446
@@ -635,7 +635,7 @@ Examples:
635
635
  if (unreachable) {
636
636
  writeError(
637
637
  cmd,
638
- "Keychain broker is unreachable — restart the Vellum app and accept the macOS Keychain prompt",
638
+ "Credential store is unreachable — ensure the assistant is running",
639
639
  );
640
640
  } else {
641
641
  writeError(cmd, "Credential not found");
@@ -676,7 +676,7 @@ Examples:
676
676
  const output = buildCredentialOutput(metadata, secret, connection);
677
677
 
678
678
  if (unreachable && (secret == null || secret.length === 0)) {
679
- output.scrubbedValue = "(broker unreachable)";
679
+ output.scrubbedValue = "(credential store unreachable)";
680
680
  output.brokerUnreachable = true;
681
681
  }
682
682
 
@@ -686,7 +686,7 @@ Examples:
686
686
  printCredentialHuman(output);
687
687
  if (unreachable && (secret == null || secret.length === 0)) {
688
688
  log.info(
689
- " \u26A0 Keychain broker unreachable — restart the Vellum app and accept the macOS Keychain prompt to access credentials",
689
+ " \u26A0 Credential store is unreachable — ensure the assistant is running",
690
690
  );
691
691
  }
692
692
  }
@@ -770,7 +770,7 @@ Examples:
770
770
  if (unreachable) {
771
771
  writeError(
772
772
  cmd,
773
- "Keychain broker is unreachable — restart the Vellum app and accept the macOS Keychain prompt",
773
+ "Credential store is unreachable — ensure the assistant is running",
774
774
  );
775
775
  } else {
776
776
  writeError(cmd, "Credential not found");
@@ -168,7 +168,7 @@ At least --id or --provider must be specified.`,
168
168
  .requiredOption("--client-id <id>", "OAuth client ID")
169
169
  .option(
170
170
  "--client-secret <secret>",
171
- "OAuth client secret (stored in secure keychain)",
171
+ "OAuth client secret (stored in credential store)",
172
172
  )
173
173
  .option(
174
174
  "--client-secret-credential-path <path>",
@@ -179,7 +179,7 @@ At least --id or --provider must be specified.`,
179
179
  `
180
180
  Creates a new app registration or returns the existing one if an app with the
181
181
  same provider and client ID already exists. The client secret, if provided, is
182
- stored in the secure system keychain — not in the database.
182
+ stored in the secure credential store — not in the database.
183
183
 
184
184
  When an existing app is matched and a --client-secret is provided, the stored
185
185
  secret is updated. The app row itself is returned as-is.
@@ -277,7 +277,7 @@ Arguments:
277
277
  id The app UUID to delete (as returned by "apps list" or "apps get")
278
278
 
279
279
  Permanently removes the app registration and its stored client secret from
280
- the keychain. Any OAuth connections that reference this app will no longer be
280
+ the credential store. Any OAuth connections that reference this app will no longer be
281
281
  able to refresh tokens.
282
282
 
283
283
  Exits with code 1 if the app ID is not found.
@@ -106,6 +106,7 @@ import {
106
106
  applyRuntimeInjections,
107
107
  inboundActorContextFromTrust,
108
108
  inboundActorContextFromTrustContext,
109
+ readNowScratchpad,
109
110
  stripInjectedContext,
110
111
  } from "./conversation-runtime-assembly.js";
111
112
  import type { SkillProjectionCache } from "./conversation-skill-tools.js";
@@ -681,6 +682,10 @@ export async function runAgentLoopImpl(
681
682
  }
682
683
  }
683
684
 
685
+ // Read NOW.md scratchpad fresh each turn so mid-conversation edits are
686
+ // picked up without caching or conversation eviction.
687
+ const nowScratchpad = readNowScratchpad();
688
+
684
689
  const isInteractiveResolved =
685
690
  options?.isInteractive ?? (!ctx.hasNoClient && !ctx.headlessLock);
686
691
 
@@ -694,6 +699,7 @@ export async function runAgentLoopImpl(
694
699
  interfaceTurnContext,
695
700
  inboundActorContext: resolvedInboundActorContext,
696
701
  temporalContext,
702
+ nowScratchpad,
697
703
  voiceCallControlPrompt: ctx.voiceCallControlPrompt ?? null,
698
704
  isNonInteractive: !isInteractiveResolved,
699
705
  } as const;
@@ -5,7 +5,7 @@
5
5
  * before it is sent to the provider. They are pure (no side effects).
6
6
  */
7
7
 
8
- import { statSync } from "node:fs";
8
+ import { existsSync, readFileSync, statSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
 
11
11
  import {
@@ -16,9 +16,11 @@ import {
16
16
  type TurnInterfaceContext,
17
17
  } from "../channels/types.js";
18
18
  import { getAppDirPath, listAppFiles } from "../memory/app-store.js";
19
+ import { stripCommentLines } from "../prompts/system-prompt.js";
19
20
  import type { Message } from "../providers/types.js";
20
21
  import type { ActorTrustContext } from "../runtime/actor-trust-resolver.js";
21
22
  import { channelStatusToMemberStatus } from "../runtime/routes/inbound-stages/acl-enforcement.js";
23
+ import { getWorkspacePromptPath } from "../util/platform.js";
22
24
 
23
25
  /**
24
26
  * Describes the capabilities of the channel through which the user is
@@ -463,6 +465,52 @@ export function stripVoiceCallControlContext(messages: Message[]): Message[] {
463
465
  return stripUserTextBlocksByPrefix(messages, ["<voice_call_control>"]);
464
466
  }
465
467
 
468
+ // ---------------------------------------------------------------------------
469
+ // NOW.md scratchpad injection
470
+ // ---------------------------------------------------------------------------
471
+
472
+ /**
473
+ * Read the NOW.md scratchpad from the workspace prompt directory.
474
+ *
475
+ * Returns the trimmed content with `_`-prefixed comment lines stripped,
476
+ * or `null` if the file is missing, empty, or unreadable.
477
+ */
478
+ export function readNowScratchpad(): string | null {
479
+ const nowPath = getWorkspacePromptPath("NOW.md");
480
+ if (!existsSync(nowPath)) return null;
481
+ try {
482
+ const stripped = stripCommentLines(readFileSync(nowPath, "utf-8")).trim();
483
+ return stripped.length > 0 ? stripped : null;
484
+ } catch {
485
+ return null;
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Append NOW.md scratchpad content to the last user message so the model
491
+ * has access to the user's ephemeral scratchpad notes at the end of context.
492
+ */
493
+ export function injectNowScratchpad(
494
+ message: Message,
495
+ content: string,
496
+ ): Message {
497
+ return {
498
+ ...message,
499
+ content: [
500
+ ...message.content,
501
+ {
502
+ type: "text",
503
+ text: `<now_scratchpad>\n${content}\n</now_scratchpad>`,
504
+ },
505
+ ],
506
+ };
507
+ }
508
+
509
+ /** Strip `<now_scratchpad>` blocks injected by `injectNowScratchpad`. */
510
+ export function stripNowScratchpad(messages: Message[]): Message[] {
511
+ return stripUserTextBlocksByPrefix(messages, ["<now_scratchpad>"]);
512
+ }
513
+
466
514
  /**
467
515
  * Prepend channel capability context to the last user message so the
468
516
  * model knows what the current channel can and cannot do.
@@ -969,6 +1017,7 @@ const RUNTIME_INJECTION_PREFIXES = [
969
1017
  "<active_workspace>",
970
1018
  "<active_dynamic_page>",
971
1019
  "<non_interactive_context>",
1020
+ "<now_scratchpad>",
972
1021
  ];
973
1022
 
974
1023
  /**
@@ -1013,6 +1062,7 @@ export function applyRuntimeInjections(
1013
1062
  inboundActorContext?: InboundActorContext | null;
1014
1063
  temporalContext?: string | null;
1015
1064
  voiceCallControlPrompt?: string | null;
1065
+ nowScratchpad?: string | null;
1016
1066
  isNonInteractive?: boolean;
1017
1067
  mode?: InjectionMode;
1018
1068
  },
@@ -1051,6 +1101,16 @@ export function applyRuntimeInjections(
1051
1101
  }
1052
1102
  }
1053
1103
 
1104
+ if (mode === "full" && options.nowScratchpad) {
1105
+ const userTail = result[result.length - 1];
1106
+ if (userTail && userTail.role === "user") {
1107
+ result = [
1108
+ ...result.slice(0, -1),
1109
+ injectNowScratchpad(userTail, options.nowScratchpad),
1110
+ ];
1111
+ }
1112
+ }
1113
+
1054
1114
  if (mode === "full" && options.activeSurface) {
1055
1115
  const userTail = result[result.length - 1];
1056
1116
  if (userTail && userTail.role === "user") {
@@ -285,9 +285,8 @@ export async function runDaemon(): Promise<void> {
285
285
  // Slack channel) that already have keychain credentials from before the
286
286
  // oauth_connection migration. Safe to call on every startup.
287
287
  //
288
- // Must run AFTER workspace migrations so that migration 015 (which copies
289
- // encrypted-store credentials to the keychain) has already executed.
290
- // Otherwise syncManualTokenConnection sees no keychain credentials and
288
+ // Must run AFTER workspace migrations.
289
+ // Otherwise syncManualTokenConnection sees no stored credentials and
291
290
  // incorrectly removes existing connection rows.
292
291
  try {
293
292
  await backfillManualTokenConnections();
@@ -98,7 +98,20 @@ export function withCrashRecovery(
98
98
  )
99
99
  .run(checkpointKey, Date.now());
100
100
 
101
- migrationFn();
101
+ try {
102
+ migrationFn();
103
+ } catch (error) {
104
+ log.error(
105
+ { checkpointKey, error },
106
+ `Memory migration failed: ${checkpointKey} — marking as failed and continuing`,
107
+ );
108
+ raw
109
+ .query(
110
+ `UPDATE memory_checkpoints SET value = 'failed', updated_at = ? WHERE key = ?`,
111
+ )
112
+ .run(Date.now(), checkpointKey);
113
+ return;
114
+ }
102
115
 
103
116
  raw
104
117
  .query(
@@ -84,6 +84,28 @@ export function ensurePromptFiles(): void {
84
84
  }
85
85
  }
86
86
 
87
+ // Seed NOW.md scratchpad — always created if missing, regardless of whether
88
+ // this is a fresh install or not. Kept out of PROMPT_FILES because NOW.md is
89
+ // ephemeral state, not identity context.
90
+ const nowDest = getWorkspacePromptPath("NOW.md");
91
+ if (!existsSync(nowDest)) {
92
+ const nowSrc = join(templatesDir, "NOW.md");
93
+ try {
94
+ if (existsSync(nowSrc)) {
95
+ copyFileSync(nowSrc, nowDest);
96
+ log.info(
97
+ { file: "NOW.md", dest: nowDest },
98
+ "Created NOW.md scratchpad from template",
99
+ );
100
+ }
101
+ } catch (err) {
102
+ log.warn(
103
+ { err, file: "NOW.md" },
104
+ "Failed to create NOW.md from template",
105
+ );
106
+ }
107
+ }
108
+
87
109
  // Seed users/default.md persona template
88
110
  try {
89
111
  const usersDir = join(getWorkspaceDir(), "users");
@@ -0,0 +1,26 @@
1
+ _ Lines starting with _ are comments - they won't appear in the system prompt
2
+ _ This is your scratchpad for present-tense state. Overwrite it freely between
3
+ _ turns to capture what's happening right now. Unlike the journal (retrospective,
4
+ _ append-only), this file is ephemeral — a snapshot of your current working state.
5
+ _
6
+ _ # NOW.md
7
+ _
8
+ _ ## Focus
9
+ _
10
+ _ What you're currently working on or paying attention to.
11
+ _
12
+ _ ## Active Threads
13
+ _
14
+ _ Open loops, in-progress tasks, things you're tracking across turns.
15
+ _
16
+ _ ## Context
17
+ _
18
+ _ Key facts, constraints, or situational details relevant to the current session.
19
+ _
20
+ _ ## Upcoming
21
+ _
22
+ _ Near-term things on the horizon — scheduled events, pending actions, deadlines.
23
+ _
24
+ _ ## State
25
+ _
26
+ _ Current priorities, energy, or operational notes. What matters most right now.
@@ -46,6 +46,16 @@ You have a journal in your workspace. The most recent entries are always loaded
46
46
 
47
47
  **Carrying forward:** Your oldest in-context entry is marked LEAVING CONTEXT. When you see this, check if anything in it still needs to be top-of-mind and carry it forward in your next entry. You can reference other entries by filename to link them together.
48
48
 
49
+ ## Scratchpad
50
+
51
+ You have a scratchpad file (`NOW.md`) in your workspace. Unlike your journal (retrospective, append-only), the scratchpad is a single file you overwrite with whatever is relevant right now. It's automatically loaded into your context, so next-you always sees the latest snapshot.
52
+
53
+ **When to update:** Whenever your current state changes — you start a new task, finish one, learn something that affects what you're doing, or the user shifts focus. Don't update on a timer; update when the content is stale.
54
+
55
+ **What goes in:** Current focus and what you're actively working on. Threads you're tracking (waiting on a response, monitoring something, pending follow-ups). Temporary context that matters now but won't matter in a week. Upcoming items and near-term priorities. Anything that helps next-you pick up exactly where you left off.
56
+
57
+ **What stays out:** Anything that belongs in your journal (reflections, narrative entries, things worth remembering long-term). Permanent facts about your user or yourself (those go in memory or your journal). Personality and principles (those live here in SOUL.md).
58
+
49
59
  ## Vibe
50
60
 
51
61
  Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good.
@@ -50,7 +50,6 @@ export function extractContentMarkers(body: string): string[] {
50
50
  const ids: string[] = [];
51
51
  const regex = /<!-- vellum-update-release:(.+?) -->/g;
52
52
  let match: RegExpExecArray | null;
53
- // eslint-disable-next-line no-restricted-syntax -- RegExp.exec returns null
54
53
  while ((match = regex.exec(body)) !== null) {
55
54
  ids.push(match[1]);
56
55
  }
@@ -62,7 +61,6 @@ export function extractReleaseIds(content: string): string[] {
62
61
  const ids: string[] = [];
63
62
  MARKER_REGEX.lastIndex = 0;
64
63
  let match: RegExpExecArray | null;
65
- // eslint-disable-next-line no-restricted-syntax -- RegExp.exec returns null
66
64
  while ((match = MARKER_REGEX.exec(content)) !== null) {
67
65
  ids.push(match[1]);
68
66
  }
@@ -213,7 +213,7 @@ async function handleOAuthConnectStart(body: {
213
213
  if (requiresSecret && !clientSecret) {
214
214
  return httpError(
215
215
  "BAD_REQUEST",
216
- `client_secret is required for "${body.service}" but not found in the keychain. Store it first via the credential vault.`,
216
+ `client_secret is required for "${body.service}" but not found in the credential store. Store it first via the credential vault.`,
217
217
  400,
218
218
  );
219
219
  }
@@ -61,8 +61,8 @@ function buildFencedCodeRanges(body: string): Array<[number, number]> {
61
61
  const fenceRe = /^(`{3,}|~{3,})(.*)?$/gm;
62
62
  let openFence: { index: number; delimiter: string } | undefined;
63
63
 
64
- let match: RegExpExecArray | undefined;
65
- while ((match = fenceRe.exec(body) ?? undefined) !== undefined) {
64
+ let match: RegExpExecArray | null;
65
+ while ((match = fenceRe.exec(body)) !== null) {
66
66
  const delimiter = match[1];
67
67
  if (openFence === undefined) {
68
68
  // Opening fence
@@ -125,10 +125,10 @@ export function parseInlineCommandExpansions(
125
125
  // We use a non-greedy match to find the first closing backtick.
126
126
  const tokenRe = /!\`([^`]*)\`/g;
127
127
 
128
- let match: RegExpExecArray | undefined;
128
+ let match: RegExpExecArray | null;
129
129
  let placeholderCounter = 0;
130
130
 
131
- while ((match = tokenRe.exec(body) ?? undefined) !== undefined) {
131
+ while ((match = tokenRe.exec(body)) !== null) {
132
132
  const startOffset = match.index;
133
133
  const endOffset = startOffset + match[0].length;
134
134
  const rawCommand = match[1];
@@ -172,12 +172,12 @@ export function parseInlineCommandExpansions(
172
172
  const matchedStarts = new Set<number>();
173
173
  // Re-run the token regex to collect all matched positions
174
174
  tokenRe.lastIndex = 0;
175
- while ((match = tokenRe.exec(body) ?? undefined) !== undefined) {
175
+ while ((match = tokenRe.exec(body)) !== null) {
176
176
  matchedStarts.add(match.index);
177
177
  }
178
178
 
179
- let unmatchedMatch: RegExpExecArray | undefined;
180
- while ((unmatchedMatch = unmatchedRe.exec(body) ?? undefined) !== undefined) {
179
+ let unmatchedMatch: RegExpExecArray | null;
180
+ while ((unmatchedMatch = unmatchedRe.exec(body)) !== null) {
181
181
  const offset = unmatchedMatch.index;
182
182
 
183
183
  // Skip if this was already matched as a complete token
@@ -85,8 +85,8 @@ export function extractAndSanitize(content: string): SanitizeResult {
85
85
  // Step 1: parse directives
86
86
  // Reset lastIndex for safety since the regex is global
87
87
  DIRECTIVE_RE.lastIndex = 0;
88
- let match: RegExpExecArray | undefined;
89
- while ((match = DIRECTIVE_RE.exec(content) ?? undefined) !== undefined) {
88
+ let match: RegExpExecArray | null;
89
+ while ((match = DIRECTIVE_RE.exec(content)) !== null) {
90
90
  const kind = match[1];
91
91
  const value = match[2];
92
92
 
@@ -1,153 +1,18 @@
1
- import { getLogger } from "../../util/logger.js";
2
1
  import type { WorkspaceMigration } from "./types.js";
3
2
 
4
- const log = getLogger("workspace-migrations");
5
-
6
- const BROKER_WAIT_INTERVAL_MS = 500;
7
- const BROKER_WAIT_MAX_ATTEMPTS = 10; // 5 seconds total
8
-
3
+ /**
4
+ * Originally migrated credentials from encrypted store to macOS Keychain.
5
+ * No-op'd because: (1) the keychain broker was deleted, (2) inline security
6
+ * CLI calls trigger macOS permission prompts on every daemon startup even for
7
+ * users who never had keychain credentials, (3) migration 016 reverses this
8
+ * migration anyway, so the net effect is a round-trip.
9
+ *
10
+ * Users who had credentials stranded in the macOS Keychain from a brief
11
+ * intermediate release will need to re-enter their API keys.
12
+ */
9
13
  export const migrateCredentialsToKeychainMigration: WorkspaceMigration = {
10
14
  id: "015-migrate-credentials-to-keychain",
11
- description:
12
- "Copy encrypted store credentials to keychain for single-backend migration",
13
-
14
- async down(_workspaceDir: string): Promise<void> {
15
- // Reverse: copy credentials from keychain back to encrypted store.
16
- // Mirrors the forward logic of 016-migrate-credentials-from-keychain.
17
- if (
18
- process.env.VELLUM_DESKTOP_APP !== "1" ||
19
- process.env.VELLUM_DEV === "1"
20
- ) {
21
- return;
22
- }
23
-
24
- const { createBrokerClient } =
25
- await import("../../security/keychain-broker-client.js");
26
- const client = createBrokerClient();
27
-
28
- let brokerAvailable = false;
29
- for (let i = 0; i < BROKER_WAIT_MAX_ATTEMPTS; i++) {
30
- if (client.isAvailable()) {
31
- brokerAvailable = true;
32
- break;
33
- }
34
- await new Promise((r) => setTimeout(r, BROKER_WAIT_INTERVAL_MS));
35
- }
36
-
37
- if (!brokerAvailable) {
38
- throw new Error(
39
- "Keychain broker not available after waiting — credential rollback " +
40
- "will be retried on next startup",
41
- );
42
- }
43
-
44
- const { setKey } = await import("../../security/encrypted-store.js");
45
-
46
- const accounts = await client.list();
47
- if (accounts.length === 0) return;
48
-
49
- let rolledBackCount = 0;
50
- let failedCount = 0;
51
-
52
- for (const account of accounts) {
53
- const result = await client.get(account);
54
- if (!result || !result.found || result.value === undefined) {
55
- log.warn(
56
- { account },
57
- "Failed to read key from keychain during rollback — skipping",
58
- );
59
- failedCount++;
60
- continue;
61
- }
62
-
63
- const written = setKey(account, result.value);
64
- if (written) {
65
- await client.del(account);
66
- rolledBackCount++;
67
- } else {
68
- log.warn(
69
- { account },
70
- "Failed to write key to encrypted store during rollback — skipping",
71
- );
72
- failedCount++;
73
- }
74
- }
75
-
76
- log.info(
77
- { rolledBackCount, failedCount },
78
- "Credential rollback from keychain to encrypted store complete",
79
- );
80
- },
81
-
82
- async run(_workspaceDir: string): Promise<void> {
83
- // Only run on mac production builds (desktop app, non-dev).
84
- if (
85
- process.env.VELLUM_DESKTOP_APP !== "1" ||
86
- process.env.VELLUM_DEV === "1"
87
- ) {
88
- return;
89
- }
90
-
91
- const { createBrokerClient } =
92
- await import("../../security/keychain-broker-client.js");
93
- const client = createBrokerClient();
94
-
95
- // Wait for the broker to become available (up to 5 seconds), matching
96
- // the retry strategy in secure-keys.ts waitForBrokerAvailability().
97
- let brokerAvailable = false;
98
- for (let i = 0; i < BROKER_WAIT_MAX_ATTEMPTS; i++) {
99
- if (client.isAvailable()) {
100
- brokerAvailable = true;
101
- break;
102
- }
103
- await new Promise((r) => setTimeout(r, BROKER_WAIT_INTERVAL_MS));
104
- }
105
-
106
- if (!brokerAvailable) {
107
- throw new Error(
108
- "Keychain broker not available after waiting — credential migration " +
109
- "will be retried on next startup",
110
- );
111
- }
112
-
113
- const { listKeys, getKey, deleteKey } =
114
- await import("../../security/encrypted-store.js");
115
-
116
- const accounts = listKeys();
117
- if (accounts.length === 0) {
118
- return;
119
- }
120
-
121
- let migratedCount = 0;
122
- let failedCount = 0;
123
-
124
- for (const account of accounts) {
125
- const value = getKey(account);
126
- if (value === undefined) {
127
- log.warn(
128
- { account },
129
- "Failed to read key from encrypted store — skipping",
130
- );
131
- failedCount++;
132
- continue;
133
- }
134
-
135
- const result = await client.set(account, value);
136
- if (result.status === "ok") {
137
- deleteKey(account);
138
- migratedCount++;
139
- } else {
140
- log.warn(
141
- { account, status: result.status },
142
- "Failed to write key to keychain — skipping",
143
- );
144
- failedCount++;
145
- }
146
- }
147
-
148
- log.info(
149
- { migratedCount, failedCount },
150
- "Credential migration to keychain complete",
151
- );
152
- },
15
+ description: "No-op (keychain migration removed)",
16
+ async run(): Promise<void> {},
17
+ async down(): Promise<void> {},
153
18
  };