@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.
- package/dist/anthropic-account-identity.js +62 -0
- package/dist/anthropic-account-identity.test.js +38 -0
- package/dist/anthropic-auth-plugin.js +88 -16
- package/dist/anthropic-auth-state.js +28 -3
- package/dist/{anthropic-auth-plugin.test.js → anthropic-auth-state.test.js} +21 -2
- package/dist/cli-parsing.test.js +12 -9
- package/dist/cli.js +23 -10
- package/dist/context-awareness-plugin.js +1 -1
- package/dist/context-awareness-plugin.test.js +2 -2
- package/dist/discord-command-registration.js +2 -2
- package/dist/discord-utils.js +5 -2
- package/dist/kimaki-opencode-plugin.js +1 -0
- package/dist/logger.js +8 -2
- package/dist/session-handler/thread-session-runtime.js +18 -1
- package/dist/system-message.js +1 -1
- package/dist/system-message.test.js +1 -1
- package/dist/system-prompt-drift-plugin.js +251 -0
- package/dist/utils.js +5 -1
- package/dist/worktrees.js +0 -33
- package/package.json +2 -1
- package/src/anthropic-account-identity.test.ts +52 -0
- package/src/anthropic-account-identity.ts +77 -0
- package/src/anthropic-auth-plugin.ts +97 -16
- package/src/{anthropic-auth-plugin.test.ts → anthropic-auth-state.test.ts} +23 -1
- package/src/anthropic-auth-state.ts +36 -3
- package/src/cli-parsing.test.ts +16 -9
- package/src/cli.ts +29 -11
- package/src/context-awareness-plugin.test.ts +2 -2
- package/src/context-awareness-plugin.ts +1 -1
- package/src/discord-command-registration.ts +2 -2
- package/src/discord-utils.ts +19 -17
- package/src/kimaki-opencode-plugin.ts +1 -0
- package/src/logger.ts +9 -2
- package/src/session-handler/thread-session-runtime.ts +21 -1
- package/src/system-message.test.ts +1 -1
- package/src/system-message.ts +1 -1
- package/src/system-prompt-drift-plugin.ts +379 -0
- package/src/utils.ts +5 -1
- package/src/worktrees.test.ts +1 -0
- 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
|
}
|
package/dist/system-message.js
CHANGED
|
@@ -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 \`
|
|
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 \`
|
|
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
|
|
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.
|
|
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
|
+
}
|