@otto-assistant/bridge 0.4.93 → 0.4.97

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 (40) hide show
  1. package/dist/anthropic-account-identity.js +62 -0
  2. package/dist/anthropic-account-identity.test.js +38 -0
  3. package/dist/anthropic-auth-plugin.js +88 -16
  4. package/dist/anthropic-auth-state.js +28 -3
  5. package/dist/{anthropic-auth-plugin.test.js → anthropic-auth-state.test.js} +21 -2
  6. package/dist/cli-parsing.test.js +12 -9
  7. package/dist/cli.js +23 -10
  8. package/dist/context-awareness-plugin.js +1 -1
  9. package/dist/context-awareness-plugin.test.js +2 -2
  10. package/dist/discord-command-registration.js +2 -2
  11. package/dist/discord-utils.js +5 -2
  12. package/dist/kimaki-opencode-plugin.js +1 -0
  13. package/dist/logger.js +8 -2
  14. package/dist/session-handler/thread-session-runtime.js +18 -1
  15. package/dist/system-message.js +1 -1
  16. package/dist/system-message.test.js +1 -1
  17. package/dist/system-prompt-drift-plugin.js +251 -0
  18. package/dist/utils.js +5 -1
  19. package/dist/worktrees.js +0 -33
  20. package/package.json +2 -1
  21. package/src/anthropic-account-identity.test.ts +52 -0
  22. package/src/anthropic-account-identity.ts +77 -0
  23. package/src/anthropic-auth-plugin.ts +97 -16
  24. package/src/{anthropic-auth-plugin.test.ts → anthropic-auth-state.test.ts} +23 -1
  25. package/src/anthropic-auth-state.ts +36 -3
  26. package/src/cli-parsing.test.ts +16 -9
  27. package/src/cli.ts +29 -11
  28. package/src/context-awareness-plugin.test.ts +2 -2
  29. package/src/context-awareness-plugin.ts +1 -1
  30. package/src/discord-command-registration.ts +2 -2
  31. package/src/discord-utils.ts +19 -17
  32. package/src/kimaki-opencode-plugin.ts +1 -0
  33. package/src/logger.ts +9 -2
  34. package/src/session-handler/thread-session-runtime.ts +21 -1
  35. package/src/system-message.test.ts +1 -1
  36. package/src/system-message.ts +1 -1
  37. package/src/system-prompt-drift-plugin.ts +379 -0
  38. package/src/utils.ts +5 -1
  39. package/src/worktrees.test.ts +1 -0
  40. package/src/worktrees.ts +1 -47
@@ -40,6 +40,14 @@ import { extractLeadingOpencodeCommand } from '../opencode-command-detection.js'
40
40
  const logger = createLogger(LogPrefix.SESSION);
41
41
  const discordLogger = createLogger(LogPrefix.DISCORD);
42
42
  const DETERMINISTIC_CONTEXT_LIMIT = 100_000;
43
+ const TOAST_SESSION_ID_REGEX = /\b(ses_[A-Za-z0-9]+)\b\s*$/u;
44
+ function extractToastSessionId({ message }) {
45
+ const match = message.match(TOAST_SESSION_ID_REGEX);
46
+ return match?.[1];
47
+ }
48
+ function stripToastSessionId({ message }) {
49
+ return message.replace(TOAST_SESSION_ID_REGEX, '').trimEnd();
50
+ }
43
51
  const shouldLogSessionEvents = process.env['KIMAKI_LOG_SESSION_EVENTS'] === '1' ||
44
52
  process.env['KIMAKI_VITEST'] === '1';
45
53
  // ── Registry ─────────────────────────────────────────────────────
@@ -943,6 +951,9 @@ export class ThreadSessionRuntime {
943
951
  }
944
952
  const sessionId = this.state?.sessionId;
945
953
  const eventSessionId = getOpencodeEventSessionId(event);
954
+ const toastSessionId = event.type === 'tui.toast.show'
955
+ ? extractToastSessionId({ message: event.properties.message })
956
+ : undefined;
946
957
  if (shouldLogSessionEvents) {
947
958
  const eventDetails = (() => {
948
959
  if (event.type === 'session.error') {
@@ -970,6 +981,7 @@ export class ThreadSessionRuntime {
970
981
  logger.log(`[EVENT] type=${event.type} eventSessionId=${eventSessionId || 'none'} activeSessionId=${sessionId || 'none'} ${this.formatRunStateForLog()}${eventDetails}`);
971
982
  }
972
983
  const isGlobalEvent = event.type === 'tui.toast.show';
984
+ const isScopedToastEvent = Boolean(toastSessionId);
973
985
  // Drop events that don't match current session (stale events from
974
986
  // previous sessions), unless it's a global event or a subtask session.
975
987
  if (!isGlobalEvent && eventSessionId && eventSessionId !== sessionId) {
@@ -977,6 +989,11 @@ export class ThreadSessionRuntime {
977
989
  return; // stale event from previous session
978
990
  }
979
991
  }
992
+ if (isScopedToastEvent && toastSessionId !== sessionId) {
993
+ if (!this.getSubtaskInfoForSession(toastSessionId)) {
994
+ return;
995
+ }
996
+ }
980
997
  if (isOpencodeSessionEventLogEnabled()) {
981
998
  const eventLogResult = await appendOpencodeSessionEventLog({
982
999
  threadId: this.threadId,
@@ -2023,7 +2040,7 @@ export class ThreadSessionRuntime {
2023
2040
  if (properties.variant === 'warning') {
2024
2041
  return;
2025
2042
  }
2026
- const toastMessage = properties.message.trim();
2043
+ const toastMessage = stripToastSessionId({ message: properties.message }).trim();
2027
2044
  if (!toastMessage) {
2028
2045
  return;
2029
2046
  }
@@ -450,7 +450,7 @@ kimaki send --channel ${channelId} --prompt "your task description" --worktree w
450
450
 
451
451
  This creates a new Discord thread with an isolated git worktree and starts a session in it. The worktree name should be kebab-case and descriptive of the task.
452
452
 
453
- By default, worktrees are created from \`origin/HEAD\` (the remote's default branch). To change the base branch for a project, the user can run \`git remote set-head origin <branch>\` in the project directory. For example, \`git remote set-head origin dev\` makes all new worktrees branch off \`origin/dev\` instead of \`origin/main\`.
453
+ By default, worktrees are created from \`HEAD\`, which means whatever commit or branch the current checkout is on. If you want a different base, pass \`--base-branch\` or use the slash command option explicitly.
454
454
 
455
455
  Critical recursion guard:
456
456
  - If you already are in a worktree thread, do not create another worktree unless the user explicitly asks for a nested worktree.
@@ -217,7 +217,7 @@ describe('system-message', () => {
217
217
 
218
218
  This creates a new Discord thread with an isolated git worktree and starts a session in it. The worktree name should be kebab-case and descriptive of the task.
219
219
 
220
- By default, worktrees are created from \`origin/HEAD\` (the remote's default branch). To change the base branch for a project, the user can run \`git remote set-head origin <branch>\` in the project directory. For example, \`git remote set-head origin dev\` makes all new worktrees branch off \`origin/dev\` instead of \`origin/main\`.
220
+ By default, worktrees are created from \`HEAD\`, which means whatever commit or branch the current checkout is on. If you want a different base, pass \`--base-branch\` or use the slash command option explicitly.
221
221
 
222
222
  Critical recursion guard:
223
223
  - If you already are in a worktree thread, do not create another worktree unless the user explicitly asks for a nested worktree.
@@ -0,0 +1,251 @@
1
+ // OpenCode plugin that detects per-session system prompt drift across turns.
2
+ // When the effective system prompt changes after the first user message, it
3
+ // writes a debug diff file and shows a toast because prompt-cache invalidation
4
+ // increases rate-limit usage and usually means another plugin is mutating the
5
+ // system prompt unexpectedly.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { createPatch, diffLines } from 'diff';
9
+ import * as errore from 'errore';
10
+ import { createPluginLogger, formatPluginErrorWithStack, setPluginLogFilePath } from './plugin-logger.js';
11
+ import { initSentry, notifyError } from './sentry.js';
12
+ import { abbreviatePath } from './utils.js';
13
+ const logger = createPluginLogger('OPENCODE');
14
+ const TOAST_SESSION_MARKER_SEPARATOR = ' ';
15
+ function getSystemPromptDiffDir({ dataDir }) {
16
+ return path.join(dataDir, 'system-prompt-diffs');
17
+ }
18
+ function normalizeSystemPrompt({ system }) {
19
+ return system.join('\n');
20
+ }
21
+ function appendToastSessionMarker({ message, sessionId, }) {
22
+ return `${message}${TOAST_SESSION_MARKER_SEPARATOR}${sessionId}`;
23
+ }
24
+ function buildTurnContext({ input, directory, }) {
25
+ const model = input.model
26
+ ? `${input.model.providerID}/${input.model.modelID}${input.variant ? `:${input.variant}` : ''}`
27
+ : undefined;
28
+ return {
29
+ agent: input.agent,
30
+ model,
31
+ directory,
32
+ };
33
+ }
34
+ function shouldSuppressDiffNotice({ previousContext, currentContext, }) {
35
+ if (!previousContext || !currentContext) {
36
+ return false;
37
+ }
38
+ return (previousContext.agent !== currentContext.agent
39
+ || previousContext.model !== currentContext.model
40
+ || previousContext.directory !== currentContext.directory);
41
+ }
42
+ function buildPatch({ beforeText, afterText, beforeLabel, afterLabel, }) {
43
+ const changes = diffLines(beforeText, afterText);
44
+ const additions = changes.reduce((count, change) => {
45
+ if (!change.added) {
46
+ return count;
47
+ }
48
+ return count + change.count;
49
+ }, 0);
50
+ const deletions = changes.reduce((count, change) => {
51
+ if (!change.removed) {
52
+ return count;
53
+ }
54
+ return count + change.count;
55
+ }, 0);
56
+ const patch = createPatch(afterLabel, beforeText, afterText, beforeLabel, afterLabel);
57
+ return {
58
+ additions,
59
+ deletions,
60
+ patch,
61
+ };
62
+ }
63
+ function writeSystemPromptDiffFile({ dataDir, sessionId, beforePrompt, afterPrompt, }) {
64
+ const diff = buildPatch({
65
+ beforeText: beforePrompt,
66
+ afterText: afterPrompt,
67
+ beforeLabel: 'system-before.txt',
68
+ afterLabel: 'system-after.txt',
69
+ });
70
+ const timestamp = new Date().toISOString().replaceAll(':', '-');
71
+ const sessionDir = path.join(getSystemPromptDiffDir({ dataDir }), sessionId);
72
+ const filePath = path.join(sessionDir, `${timestamp}.diff`);
73
+ const latestPromptPath = path.join(sessionDir, `${timestamp}.md`);
74
+ const fileContent = [
75
+ `Session: ${sessionId}`,
76
+ `Created: ${new Date().toISOString()}`,
77
+ `Additions: ${diff.additions}`,
78
+ `Deletions: ${diff.deletions}`,
79
+ '',
80
+ diff.patch,
81
+ ].join('\n');
82
+ return errore.try({
83
+ try: () => {
84
+ fs.mkdirSync(sessionDir, { recursive: true });
85
+ fs.writeFileSync(filePath, fileContent);
86
+ // fs.writeFileSync(latestPromptPath, afterPrompt)
87
+ return {
88
+ additions: diff.additions,
89
+ deletions: diff.deletions,
90
+ filePath,
91
+ latestPromptPath,
92
+ };
93
+ },
94
+ catch: (error) => {
95
+ return new Error('Failed to write system prompt diff file', { cause: error });
96
+ },
97
+ });
98
+ }
99
+ function getOrCreateSessionState({ sessions, sessionId, }) {
100
+ const existing = sessions.get(sessionId);
101
+ if (existing) {
102
+ return existing;
103
+ }
104
+ const state = {
105
+ userTurnCount: 0,
106
+ previousTurnPrompt: undefined,
107
+ latestTurnPrompt: undefined,
108
+ latestTurnPromptTurn: 0,
109
+ comparedTurn: 0,
110
+ previousTurnContext: undefined,
111
+ currentTurnContext: undefined,
112
+ };
113
+ sessions.set(sessionId, state);
114
+ return state;
115
+ }
116
+ async function handleSystemTransform({ input, output, sessions, dataDir, client, }) {
117
+ const sessionId = input.sessionID;
118
+ if (!sessionId) {
119
+ return;
120
+ }
121
+ const currentPrompt = normalizeSystemPrompt({ system: output.system });
122
+ const state = getOrCreateSessionState({
123
+ sessions,
124
+ sessionId,
125
+ });
126
+ const currentTurn = state.userTurnCount;
127
+ state.latestTurnPrompt = currentPrompt;
128
+ state.latestTurnPromptTurn = currentTurn;
129
+ if (currentTurn <= 1) {
130
+ return;
131
+ }
132
+ if (state.comparedTurn === currentTurn) {
133
+ return;
134
+ }
135
+ const previousPrompt = state.previousTurnPrompt;
136
+ state.comparedTurn = currentTurn;
137
+ if (!previousPrompt || previousPrompt === currentPrompt) {
138
+ return;
139
+ }
140
+ if (shouldSuppressDiffNotice({
141
+ previousContext: state.previousTurnContext,
142
+ currentContext: state.currentTurnContext,
143
+ })) {
144
+ return;
145
+ }
146
+ if (!dataDir) {
147
+ return;
148
+ }
149
+ const diffFileResult = writeSystemPromptDiffFile({
150
+ dataDir,
151
+ sessionId,
152
+ beforePrompt: previousPrompt,
153
+ afterPrompt: currentPrompt,
154
+ });
155
+ if (diffFileResult instanceof Error) {
156
+ throw diffFileResult;
157
+ }
158
+ await client.tui.showToast({
159
+ body: {
160
+ variant: 'info',
161
+ title: 'Context cache discarded',
162
+ message: appendToastSessionMarker({
163
+ sessionId,
164
+ message: `system prompt changed since the previous message (+${diffFileResult.additions} / -${diffFileResult.deletions}). ` +
165
+ `Diff: \`${abbreviatePath(diffFileResult.filePath)}\`. ` +
166
+ `Latest prompt: \`${abbreviatePath(diffFileResult.latestPromptPath)}\``,
167
+ }),
168
+ },
169
+ });
170
+ }
171
+ const systemPromptDriftPlugin = async ({ client, directory }) => {
172
+ initSentry();
173
+ const dataDir = process.env.KIMAKI_DATA_DIR;
174
+ if (dataDir) {
175
+ setPluginLogFilePath(dataDir);
176
+ }
177
+ const sessions = new Map();
178
+ return {
179
+ 'chat.message': async (input) => {
180
+ const sessionId = input.sessionID;
181
+ if (!sessionId) {
182
+ return;
183
+ }
184
+ const state = getOrCreateSessionState({ sessions, sessionId });
185
+ if (state.userTurnCount > 0
186
+ && state.latestTurnPromptTurn === state.userTurnCount) {
187
+ state.previousTurnPrompt = state.latestTurnPrompt;
188
+ state.previousTurnContext = state.currentTurnContext;
189
+ }
190
+ state.currentTurnContext = buildTurnContext({ input, directory });
191
+ state.userTurnCount += 1;
192
+ },
193
+ 'experimental.chat.system.transform': async (input, output) => {
194
+ const result = await errore.tryAsync({
195
+ try: async () => {
196
+ await handleSystemTransform({
197
+ input,
198
+ output,
199
+ sessions,
200
+ dataDir,
201
+ client,
202
+ });
203
+ },
204
+ catch: (error) => {
205
+ return new Error('system prompt drift transform hook failed', {
206
+ cause: error,
207
+ });
208
+ },
209
+ });
210
+ if (result instanceof Error) {
211
+ logger.warn(`[system-prompt-drift-plugin] ${formatPluginErrorWithStack(result)}`);
212
+ void notifyError(result, 'system prompt drift plugin transform hook failed');
213
+ }
214
+ },
215
+ event: async ({ event }) => {
216
+ const result = await errore.tryAsync({
217
+ try: async () => {
218
+ if (event.type !== 'session.deleted') {
219
+ return;
220
+ }
221
+ const deletedSessionId = getDeletedSessionId({ event });
222
+ if (!deletedSessionId) {
223
+ return;
224
+ }
225
+ sessions.delete(deletedSessionId);
226
+ },
227
+ catch: (error) => {
228
+ return new Error('system prompt drift event hook failed', {
229
+ cause: error,
230
+ });
231
+ },
232
+ });
233
+ if (result instanceof Error) {
234
+ logger.warn(`[system-prompt-drift-plugin] ${formatPluginErrorWithStack(result)}`);
235
+ void notifyError(result, 'system prompt drift plugin event hook failed');
236
+ }
237
+ },
238
+ };
239
+ };
240
+ function getDeletedSessionId({ event }) {
241
+ if (event.type !== 'session.deleted') {
242
+ return undefined;
243
+ }
244
+ const sessionInfo = event.properties?.info;
245
+ if (!sessionInfo || typeof sessionInfo !== 'object') {
246
+ return undefined;
247
+ }
248
+ const id = 'id' in sessionInfo ? sessionInfo.id : undefined;
249
+ return typeof id === 'string' ? id : undefined;
250
+ }
251
+ export { systemPromptDriftPlugin };
package/dist/utils.js CHANGED
@@ -2,7 +2,11 @@
2
2
  // Includes Discord OAuth URL generation, array deduplication,
3
3
  // abort error detection, and date/time formatting helpers.
4
4
  import os from 'node:os';
5
- import { PermissionsBitField } from 'discord.js';
5
+ // Use namespace import for CJS interop discord.js is CJS and its named
6
+ // exports aren't detectable by all ESM loaders (e.g. tsx/esbuild) because
7
+ // discord.js uses tslib's __exportStar which is opaque to static analysis.
8
+ import * as discord from 'discord.js';
9
+ const { PermissionsBitField } = discord;
6
10
  import * as errore from 'errore';
7
11
  export function generateBotInstallUrl({ clientId, permissions = [
8
12
  PermissionsBitField.Flags.ViewChannel,
package/dist/worktrees.js CHANGED
@@ -394,39 +394,6 @@ async function validateSubmodulePointers(directory) {
394
394
  return new Error(`Submodule validation failed: ${validationIssues.join('; ')}`);
395
395
  }
396
396
  async function resolveDefaultWorktreeTarget(directory) {
397
- const remoteHead = await execAsync('git symbolic-ref refs/remotes/origin/HEAD', {
398
- cwd: directory,
399
- }).catch(() => {
400
- return null;
401
- });
402
- const remoteRef = remoteHead?.stdout.trim();
403
- if (remoteRef?.startsWith('refs/remotes/')) {
404
- return remoteRef.replace('refs/remotes/', '');
405
- }
406
- const hasMain = await execAsync('git show-ref --verify --quiet refs/heads/main', {
407
- cwd: directory,
408
- })
409
- .then(() => {
410
- return true;
411
- })
412
- .catch(() => {
413
- return false;
414
- });
415
- if (hasMain) {
416
- return 'main';
417
- }
418
- const hasMaster = await execAsync('git show-ref --verify --quiet refs/heads/master', {
419
- cwd: directory,
420
- })
421
- .then(() => {
422
- return true;
423
- })
424
- .catch(() => {
425
- return false;
426
- });
427
- if (hasMaster) {
428
- return 'master';
429
- }
430
397
  return 'HEAD';
431
398
  }
432
399
  function getManagedWorktreeDirectory({ directory, name, }) {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@otto-assistant/bridge",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.93",
5
+ "version": "0.4.97",
6
6
  "scripts": {
7
7
  "dev": "tsx src/bin.ts",
8
8
  "prepublishOnly": "pnpm generate && pnpm tsc",
@@ -66,6 +66,7 @@
66
66
  "@prisma/client": "7.4.2",
67
67
  "@purinton/resampler": "^1.0.4",
68
68
  "cron-parser": "^5.5.0",
69
+ "diff": "^8.0.4",
69
70
  "discord.js": "^14.25.1",
70
71
  "domhandler": "^6.0.1",
71
72
  "errore": "workspace:^",
@@ -0,0 +1,52 @@
1
+ // Tests Anthropic OAuth account identity parsing and normalization.
2
+
3
+ import { describe, expect, test } from 'vitest'
4
+ import {
5
+ extractAnthropicAccountIdentity,
6
+ normalizeAnthropicAccountIdentity,
7
+ } from './anthropic-account-identity.js'
8
+
9
+ describe('normalizeAnthropicAccountIdentity', () => {
10
+ test('normalizes email casing and drops empty values', () => {
11
+ expect(
12
+ normalizeAnthropicAccountIdentity({
13
+ email: ' User@Example.com ',
14
+ accountId: ' user_123 ',
15
+ }),
16
+ ).toEqual({
17
+ email: 'user@example.com',
18
+ accountId: 'user_123',
19
+ })
20
+
21
+ expect(normalizeAnthropicAccountIdentity({ email: ' ' })).toBeUndefined()
22
+ })
23
+ })
24
+
25
+ describe('extractAnthropicAccountIdentity', () => {
26
+ test('prefers nested user profile identity from client_data responses', () => {
27
+ expect(
28
+ extractAnthropicAccountIdentity({
29
+ organizations: [{ id: 'org_123', name: 'Workspace' }],
30
+ user: {
31
+ id: 'usr_123',
32
+ email: 'User@Example.com',
33
+ },
34
+ }),
35
+ ).toEqual({
36
+ accountId: 'usr_123',
37
+ email: 'user@example.com',
38
+ })
39
+ })
40
+
41
+ test('falls back to profile-style payloads without email', () => {
42
+ expect(
43
+ extractAnthropicAccountIdentity({
44
+ profile: {
45
+ user_id: 'usr_456',
46
+ },
47
+ }),
48
+ ).toEqual({
49
+ accountId: 'usr_456',
50
+ })
51
+ })
52
+ })
@@ -0,0 +1,77 @@
1
+ // Helpers for extracting and normalizing Anthropic OAuth account identity.
2
+
3
+ export type AnthropicAccountIdentity = {
4
+ email?: string
5
+ accountId?: string
6
+ }
7
+
8
+ type IdentityCandidate = AnthropicAccountIdentity & {
9
+ score: number
10
+ }
11
+
12
+ const identityHintKeys = new Set(['user', 'profile', 'account', 'viewer'])
13
+ const idKeys = ['user_id', 'userId', 'account_id', 'accountId', 'id', 'sub']
14
+
15
+ export function normalizeAnthropicAccountIdentity(
16
+ identity: AnthropicAccountIdentity | null | undefined,
17
+ ) {
18
+ const email =
19
+ typeof identity?.email === 'string' && identity.email.trim()
20
+ ? identity.email.trim().toLowerCase()
21
+ : undefined
22
+ const accountId =
23
+ typeof identity?.accountId === 'string' && identity.accountId.trim()
24
+ ? identity.accountId.trim()
25
+ : undefined
26
+ if (!email && !accountId) return undefined
27
+ return {
28
+ ...(email ? { email } : {}),
29
+ ...(accountId ? { accountId } : {}),
30
+ }
31
+ }
32
+
33
+ function getCandidateFromRecord(record: Record<string, unknown>, path: string[]) {
34
+ const email = typeof record.email === 'string' ? record.email : undefined
35
+ const accountId = idKeys
36
+ .map((key) => {
37
+ const value = record[key]
38
+ return typeof value === 'string' ? value : undefined
39
+ })
40
+ .find((value) => {
41
+ return Boolean(value)
42
+ })
43
+ const normalized = normalizeAnthropicAccountIdentity({ email, accountId })
44
+ if (!normalized) return undefined
45
+ const hasIdentityHint = path.some((segment) => {
46
+ return identityHintKeys.has(segment)
47
+ })
48
+ return {
49
+ ...normalized,
50
+ score: (normalized.email ? 4 : 0) + (normalized.accountId ? 2 : 0) + (hasIdentityHint ? 2 : 0),
51
+ } satisfies IdentityCandidate
52
+ }
53
+
54
+ function collectIdentityCandidates(value: unknown, path: string[] = []): IdentityCandidate[] {
55
+ if (!value || typeof value !== 'object') return []
56
+ if (Array.isArray(value)) {
57
+ return value.flatMap((entry) => {
58
+ return collectIdentityCandidates(entry, path)
59
+ })
60
+ }
61
+
62
+ const record = value as Record<string, unknown>
63
+ const nested = Object.entries(record).flatMap(([key, entry]) => {
64
+ return collectIdentityCandidates(entry, [...path, key])
65
+ })
66
+ const current = getCandidateFromRecord(record, path)
67
+ return current ? [current, ...nested] : nested
68
+ }
69
+
70
+ export function extractAnthropicAccountIdentity(value: unknown) {
71
+ const candidates = collectIdentityCandidates(value)
72
+ const best = candidates.sort((a, b) => {
73
+ return b.score - a.score
74
+ })[0]
75
+ if (!best) return undefined
76
+ return normalizeAnthropicAccountIdentity(best)
77
+ }