@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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Helpers for extracting and normalizing Anthropic OAuth account identity.
|
|
2
|
+
const identityHintKeys = new Set(['user', 'profile', 'account', 'viewer']);
|
|
3
|
+
const idKeys = ['user_id', 'userId', 'account_id', 'accountId', 'id', 'sub'];
|
|
4
|
+
export function normalizeAnthropicAccountIdentity(identity) {
|
|
5
|
+
const email = typeof identity?.email === 'string' && identity.email.trim()
|
|
6
|
+
? identity.email.trim().toLowerCase()
|
|
7
|
+
: undefined;
|
|
8
|
+
const accountId = typeof identity?.accountId === 'string' && identity.accountId.trim()
|
|
9
|
+
? identity.accountId.trim()
|
|
10
|
+
: undefined;
|
|
11
|
+
if (!email && !accountId)
|
|
12
|
+
return undefined;
|
|
13
|
+
return {
|
|
14
|
+
...(email ? { email } : {}),
|
|
15
|
+
...(accountId ? { accountId } : {}),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function getCandidateFromRecord(record, path) {
|
|
19
|
+
const email = typeof record.email === 'string' ? record.email : undefined;
|
|
20
|
+
const accountId = idKeys
|
|
21
|
+
.map((key) => {
|
|
22
|
+
const value = record[key];
|
|
23
|
+
return typeof value === 'string' ? value : undefined;
|
|
24
|
+
})
|
|
25
|
+
.find((value) => {
|
|
26
|
+
return Boolean(value);
|
|
27
|
+
});
|
|
28
|
+
const normalized = normalizeAnthropicAccountIdentity({ email, accountId });
|
|
29
|
+
if (!normalized)
|
|
30
|
+
return undefined;
|
|
31
|
+
const hasIdentityHint = path.some((segment) => {
|
|
32
|
+
return identityHintKeys.has(segment);
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
...normalized,
|
|
36
|
+
score: (normalized.email ? 4 : 0) + (normalized.accountId ? 2 : 0) + (hasIdentityHint ? 2 : 0),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function collectIdentityCandidates(value, path = []) {
|
|
40
|
+
if (!value || typeof value !== 'object')
|
|
41
|
+
return [];
|
|
42
|
+
if (Array.isArray(value)) {
|
|
43
|
+
return value.flatMap((entry) => {
|
|
44
|
+
return collectIdentityCandidates(entry, path);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const record = value;
|
|
48
|
+
const nested = Object.entries(record).flatMap(([key, entry]) => {
|
|
49
|
+
return collectIdentityCandidates(entry, [...path, key]);
|
|
50
|
+
});
|
|
51
|
+
const current = getCandidateFromRecord(record, path);
|
|
52
|
+
return current ? [current, ...nested] : nested;
|
|
53
|
+
}
|
|
54
|
+
export function extractAnthropicAccountIdentity(value) {
|
|
55
|
+
const candidates = collectIdentityCandidates(value);
|
|
56
|
+
const best = candidates.sort((a, b) => {
|
|
57
|
+
return b.score - a.score;
|
|
58
|
+
})[0];
|
|
59
|
+
if (!best)
|
|
60
|
+
return undefined;
|
|
61
|
+
return normalizeAnthropicAccountIdentity(best);
|
|
62
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Tests Anthropic OAuth account identity parsing and normalization.
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { extractAnthropicAccountIdentity, normalizeAnthropicAccountIdentity, } from './anthropic-account-identity.js';
|
|
4
|
+
describe('normalizeAnthropicAccountIdentity', () => {
|
|
5
|
+
test('normalizes email casing and drops empty values', () => {
|
|
6
|
+
expect(normalizeAnthropicAccountIdentity({
|
|
7
|
+
email: ' User@Example.com ',
|
|
8
|
+
accountId: ' user_123 ',
|
|
9
|
+
})).toEqual({
|
|
10
|
+
email: 'user@example.com',
|
|
11
|
+
accountId: 'user_123',
|
|
12
|
+
});
|
|
13
|
+
expect(normalizeAnthropicAccountIdentity({ email: ' ' })).toBeUndefined();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe('extractAnthropicAccountIdentity', () => {
|
|
17
|
+
test('prefers nested user profile identity from client_data responses', () => {
|
|
18
|
+
expect(extractAnthropicAccountIdentity({
|
|
19
|
+
organizations: [{ id: 'org_123', name: 'Workspace' }],
|
|
20
|
+
user: {
|
|
21
|
+
id: 'usr_123',
|
|
22
|
+
email: 'User@Example.com',
|
|
23
|
+
},
|
|
24
|
+
})).toEqual({
|
|
25
|
+
accountId: 'usr_123',
|
|
26
|
+
email: 'user@example.com',
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
test('falls back to profile-style payloads without email', () => {
|
|
30
|
+
expect(extractAnthropicAccountIdentity({
|
|
31
|
+
profile: {
|
|
32
|
+
user_id: 'usr_456',
|
|
33
|
+
},
|
|
34
|
+
})).toEqual({
|
|
35
|
+
accountId: 'usr_456',
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* - https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/anthropic.ts
|
|
24
24
|
*/
|
|
25
25
|
import { loadAccountStore, rememberAnthropicOAuth, rotateAnthropicAccount, saveAccountStore, setAnthropicAuth, shouldRotateAuth, upsertAccount, withAuthStateLock, } from './anthropic-auth-state.js';
|
|
26
|
+
import { extractAnthropicAccountIdentity, } from './anthropic-account-identity.js';
|
|
26
27
|
// PKCE (Proof Key for Code Exchange) using Web Crypto API.
|
|
27
28
|
// Reference: https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/oauth/pkce.ts
|
|
28
29
|
function base64urlEncode(bytes) {
|
|
@@ -52,6 +53,8 @@ const CLIENT_ID = (() => {
|
|
|
52
53
|
})();
|
|
53
54
|
const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
|
|
54
55
|
const CREATE_API_KEY_URL = 'https://api.anthropic.com/api/oauth/claude_cli/create_api_key';
|
|
56
|
+
const CLIENT_DATA_URL = 'https://api.anthropic.com/api/oauth/claude_cli/client_data';
|
|
57
|
+
const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
|
|
55
58
|
const CALLBACK_PORT = 53692;
|
|
56
59
|
const CALLBACK_PATH = '/callback';
|
|
57
60
|
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
|
@@ -64,6 +67,7 @@ const CLAUDE_CODE_BETA = 'claude-code-20250219';
|
|
|
64
67
|
const OAUTH_BETA = 'oauth-2025-04-20';
|
|
65
68
|
const FINE_GRAINED_TOOL_STREAMING_BETA = 'fine-grained-tool-streaming-2025-05-14';
|
|
66
69
|
const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14';
|
|
70
|
+
const TOAST_SESSION_HEADER = 'x-kimaki-session-id';
|
|
67
71
|
const ANTHROPIC_HOSTS = new Set([
|
|
68
72
|
'api.anthropic.com',
|
|
69
73
|
'claude.ai',
|
|
@@ -227,6 +231,29 @@ async function createApiKey(accessToken) {
|
|
|
227
231
|
const json = JSON.parse(responseText);
|
|
228
232
|
return { type: 'success', key: json.raw_key };
|
|
229
233
|
}
|
|
234
|
+
async function fetchAnthropicAccountIdentity(accessToken) {
|
|
235
|
+
const urls = [CLIENT_DATA_URL, PROFILE_URL];
|
|
236
|
+
for (const url of urls) {
|
|
237
|
+
const responseText = await requestText(url, {
|
|
238
|
+
method: 'GET',
|
|
239
|
+
headers: {
|
|
240
|
+
Accept: 'application/json',
|
|
241
|
+
authorization: `Bearer ${accessToken}`,
|
|
242
|
+
'user-agent': process.env.OPENCODE_ANTHROPIC_USER_AGENT || `claude-cli/${CLAUDE_CODE_VERSION}`,
|
|
243
|
+
'x-app': 'cli',
|
|
244
|
+
},
|
|
245
|
+
}).catch(() => {
|
|
246
|
+
return undefined;
|
|
247
|
+
});
|
|
248
|
+
if (!responseText)
|
|
249
|
+
continue;
|
|
250
|
+
const parsed = JSON.parse(responseText);
|
|
251
|
+
const identity = extractAnthropicAccountIdentity(parsed);
|
|
252
|
+
if (identity)
|
|
253
|
+
return identity;
|
|
254
|
+
}
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
230
257
|
async function startCallbackServer(expectedState) {
|
|
231
258
|
return new Promise((resolve, reject) => {
|
|
232
259
|
let settle;
|
|
@@ -376,12 +403,13 @@ function buildAuthorizeHandler(mode) {
|
|
|
376
403
|
if (mode === 'apikey') {
|
|
377
404
|
return createApiKey(creds.access);
|
|
378
405
|
}
|
|
406
|
+
const identity = await fetchAnthropicAccountIdentity(creds.access);
|
|
379
407
|
await rememberAnthropicOAuth({
|
|
380
408
|
type: 'oauth',
|
|
381
409
|
refresh: creds.refresh,
|
|
382
410
|
access: creds.access,
|
|
383
411
|
expires: creds.expires,
|
|
384
|
-
});
|
|
412
|
+
}, identity);
|
|
385
413
|
return creds;
|
|
386
414
|
};
|
|
387
415
|
if (!isRemote) {
|
|
@@ -395,8 +423,7 @@ function buildAuthorizeHandler(mode) {
|
|
|
395
423
|
const result = await waitForCallback(auth.callbackServer);
|
|
396
424
|
return await finalize(result);
|
|
397
425
|
}
|
|
398
|
-
catch
|
|
399
|
-
console.error(`[anthropic-auth] ${error}`);
|
|
426
|
+
catch {
|
|
400
427
|
return { type: 'failed' };
|
|
401
428
|
}
|
|
402
429
|
})();
|
|
@@ -414,8 +441,7 @@ function buildAuthorizeHandler(mode) {
|
|
|
414
441
|
const result = await waitForCallback(auth.callbackServer, input);
|
|
415
442
|
return await finalize(result);
|
|
416
443
|
}
|
|
417
|
-
catch
|
|
418
|
-
console.error(`[anthropic-auth] ${error}`);
|
|
444
|
+
catch {
|
|
419
445
|
return { type: 'failed' };
|
|
420
446
|
}
|
|
421
447
|
})();
|
|
@@ -430,15 +456,25 @@ function buildAuthorizeHandler(mode) {
|
|
|
430
456
|
function toClaudeCodeToolName(name) {
|
|
431
457
|
return OPENCODE_TO_CLAUDE_CODE_TOOL_NAME[name.toLowerCase()] ?? name;
|
|
432
458
|
}
|
|
433
|
-
function sanitizeSystemText(text) {
|
|
434
|
-
|
|
459
|
+
function sanitizeSystemText(text, onError) {
|
|
460
|
+
const startIdx = text.indexOf(OPENCODE_IDENTITY);
|
|
461
|
+
if (startIdx === -1)
|
|
462
|
+
return text;
|
|
463
|
+
const codeRefsMarker = '# Code References';
|
|
464
|
+
const endIdx = text.indexOf(codeRefsMarker, startIdx);
|
|
465
|
+
if (endIdx === -1) {
|
|
466
|
+
onError?.(`sanitizeSystemText: could not find '# Code References' after OpenCode identity`);
|
|
467
|
+
return text;
|
|
468
|
+
}
|
|
469
|
+
// Remove everything from the OpenCode identity up to (but not including) '# Code References'
|
|
470
|
+
return text.slice(0, startIdx) + text.slice(endIdx);
|
|
435
471
|
}
|
|
436
|
-
function prependClaudeCodeIdentity(system) {
|
|
472
|
+
function prependClaudeCodeIdentity(system, onError) {
|
|
437
473
|
const identityBlock = { type: 'text', text: CLAUDE_CODE_IDENTITY };
|
|
438
474
|
if (typeof system === 'undefined')
|
|
439
475
|
return [identityBlock];
|
|
440
476
|
if (typeof system === 'string') {
|
|
441
|
-
const sanitized = sanitizeSystemText(system);
|
|
477
|
+
const sanitized = sanitizeSystemText(system, onError);
|
|
442
478
|
if (sanitized === CLAUDE_CODE_IDENTITY)
|
|
443
479
|
return [identityBlock];
|
|
444
480
|
return [identityBlock, { type: 'text', text: sanitized }];
|
|
@@ -447,11 +483,11 @@ function prependClaudeCodeIdentity(system) {
|
|
|
447
483
|
return [identityBlock, system];
|
|
448
484
|
const sanitized = system.map((item) => {
|
|
449
485
|
if (typeof item === 'string')
|
|
450
|
-
return { type: 'text', text: sanitizeSystemText(item) };
|
|
486
|
+
return { type: 'text', text: sanitizeSystemText(item, onError) };
|
|
451
487
|
if (item && typeof item === 'object' && item.type === 'text') {
|
|
452
488
|
const text = item.text;
|
|
453
489
|
if (typeof text === 'string') {
|
|
454
|
-
return { ...item, text: sanitizeSystemText(text) };
|
|
490
|
+
return { ...item, text: sanitizeSystemText(text, onError) };
|
|
455
491
|
}
|
|
456
492
|
}
|
|
457
493
|
return item;
|
|
@@ -465,7 +501,7 @@ function prependClaudeCodeIdentity(system) {
|
|
|
465
501
|
}
|
|
466
502
|
return [identityBlock, ...sanitized];
|
|
467
503
|
}
|
|
468
|
-
function rewriteRequestPayload(body) {
|
|
504
|
+
function rewriteRequestPayload(body, onError) {
|
|
469
505
|
if (!body)
|
|
470
506
|
return { body, modelId: undefined, reverseToolNameMap: new Map() };
|
|
471
507
|
try {
|
|
@@ -486,7 +522,7 @@ function rewriteRequestPayload(body) {
|
|
|
486
522
|
});
|
|
487
523
|
}
|
|
488
524
|
// Rename system prompt
|
|
489
|
-
payload.system = prependClaudeCodeIdentity(payload.system);
|
|
525
|
+
payload.system = prependClaudeCodeIdentity(payload.system, onError);
|
|
490
526
|
// Rename tool_choice
|
|
491
527
|
if (payload.tool_choice &&
|
|
492
528
|
typeof payload.tool_choice === 'object' &&
|
|
@@ -567,6 +603,12 @@ function wrapResponseStream(response, reverseToolNameMap) {
|
|
|
567
603
|
headers: response.headers,
|
|
568
604
|
});
|
|
569
605
|
}
|
|
606
|
+
function appendToastSessionMarker({ message, sessionId, }) {
|
|
607
|
+
if (!sessionId) {
|
|
608
|
+
return message;
|
|
609
|
+
}
|
|
610
|
+
return `${message} ${sessionId}`;
|
|
611
|
+
}
|
|
570
612
|
// --- Beta headers ---
|
|
571
613
|
function getRequiredBetas(modelId) {
|
|
572
614
|
const betas = [CLAUDE_CODE_BETA, OAUTH_BETA, FINE_GRAINED_TOOL_STREAMING_BETA];
|
|
@@ -614,7 +656,19 @@ async function getFreshOAuth(getAuth, client) {
|
|
|
614
656
|
await setAnthropicAuth(refreshed, client);
|
|
615
657
|
const store = await loadAccountStore();
|
|
616
658
|
if (store.accounts.length > 0) {
|
|
617
|
-
|
|
659
|
+
const identity = (() => {
|
|
660
|
+
const currentIndex = store.accounts.findIndex((account) => {
|
|
661
|
+
return account.refresh === latest.refresh || account.access === latest.access;
|
|
662
|
+
});
|
|
663
|
+
const current = currentIndex >= 0 ? store.accounts[currentIndex] : undefined;
|
|
664
|
+
if (!current)
|
|
665
|
+
return undefined;
|
|
666
|
+
return {
|
|
667
|
+
...(current.email ? { email: current.email } : {}),
|
|
668
|
+
...(current.accountId ? { accountId: current.accountId } : {}),
|
|
669
|
+
};
|
|
670
|
+
})();
|
|
671
|
+
upsertAccount(store, { ...refreshed, ...identity });
|
|
618
672
|
await saveAccountStore(store);
|
|
619
673
|
}
|
|
620
674
|
return refreshed;
|
|
@@ -627,6 +681,12 @@ async function getFreshOAuth(getAuth, client) {
|
|
|
627
681
|
// --- Plugin export ---
|
|
628
682
|
const AnthropicAuthPlugin = async ({ client }) => {
|
|
629
683
|
return {
|
|
684
|
+
'chat.headers': async (input, output) => {
|
|
685
|
+
if (input.model.providerID !== 'anthropic') {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
output.headers[TOAST_SESSION_HEADER] = input.sessionID;
|
|
689
|
+
},
|
|
630
690
|
auth: {
|
|
631
691
|
provider: 'anthropic',
|
|
632
692
|
async loader(getAuth, provider) {
|
|
@@ -658,7 +718,6 @@ const AnthropicAuthPlugin = async ({ client }) => {
|
|
|
658
718
|
.text()
|
|
659
719
|
.catch(() => undefined)
|
|
660
720
|
: undefined;
|
|
661
|
-
const rewritten = rewriteRequestPayload(originalBody);
|
|
662
721
|
const headers = new Headers(init?.headers);
|
|
663
722
|
if (input instanceof Request) {
|
|
664
723
|
input.headers.forEach((v, k) => {
|
|
@@ -666,9 +725,19 @@ const AnthropicAuthPlugin = async ({ client }) => {
|
|
|
666
725
|
headers.set(k, v);
|
|
667
726
|
});
|
|
668
727
|
}
|
|
728
|
+
const sessionId = headers.get(TOAST_SESSION_HEADER) ?? undefined;
|
|
729
|
+
const rewritten = rewriteRequestPayload(originalBody, (msg) => {
|
|
730
|
+
client.tui.showToast({
|
|
731
|
+
body: {
|
|
732
|
+
message: appendToastSessionMarker({ message: msg, sessionId }),
|
|
733
|
+
variant: 'error',
|
|
734
|
+
},
|
|
735
|
+
}).catch(() => { });
|
|
736
|
+
});
|
|
669
737
|
const betas = getRequiredBetas(rewritten.modelId);
|
|
670
738
|
const runRequest = async (auth) => {
|
|
671
739
|
const requestHeaders = new Headers(headers);
|
|
740
|
+
requestHeaders.delete(TOAST_SESSION_HEADER);
|
|
672
741
|
requestHeaders.set('accept', 'application/json');
|
|
673
742
|
requestHeaders.set('anthropic-beta', mergeBetas(requestHeaders.get('anthropic-beta'), betas));
|
|
674
743
|
requestHeaders.set('anthropic-dangerous-direct-browser-access', 'true');
|
|
@@ -697,7 +766,10 @@ const AnthropicAuthPlugin = async ({ client }) => {
|
|
|
697
766
|
// Show toast notification so Discord thread shows the rotation
|
|
698
767
|
client.tui.showToast({
|
|
699
768
|
body: {
|
|
700
|
-
message:
|
|
769
|
+
message: appendToastSessionMarker({
|
|
770
|
+
message: `Switching from account ${rotated.fromLabel} to account ${rotated.toLabel}`,
|
|
771
|
+
sessionId,
|
|
772
|
+
}),
|
|
701
773
|
variant: 'info',
|
|
702
774
|
},
|
|
703
775
|
}).catch(() => { });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { normalizeAnthropicAccountIdentity, } from './anthropic-account-identity.js';
|
|
4
5
|
const AUTH_LOCK_STALE_MS = 30_000;
|
|
5
6
|
const AUTH_LOCK_RETRY_MS = 100;
|
|
6
7
|
async function readJson(filePath, fallback) {
|
|
@@ -80,6 +81,8 @@ export function normalizeAccountStore(input) {
|
|
|
80
81
|
typeof account.refresh === 'string' &&
|
|
81
82
|
typeof account.access === 'string' &&
|
|
82
83
|
typeof account.expires === 'number' &&
|
|
84
|
+
(typeof account.email === 'undefined' || typeof account.email === 'string') &&
|
|
85
|
+
(typeof account.accountId === 'undefined' || typeof account.accountId === 'string') &&
|
|
83
86
|
typeof account.addedAt === 'number' &&
|
|
84
87
|
typeof account.lastUsed === 'number')
|
|
85
88
|
: [];
|
|
@@ -96,8 +99,13 @@ export async function saveAccountStore(store) {
|
|
|
96
99
|
}
|
|
97
100
|
/** Short label for an account: first 8 + last 4 chars of refresh token. */
|
|
98
101
|
export function accountLabel(account, index) {
|
|
102
|
+
const accountWithIdentity = account;
|
|
103
|
+
const identity = accountWithIdentity.email || accountWithIdentity.accountId;
|
|
99
104
|
const r = account.refresh;
|
|
100
105
|
const short = r.length > 12 ? `${r.slice(0, 8)}...${r.slice(-4)}` : r;
|
|
106
|
+
if (identity) {
|
|
107
|
+
return index !== undefined ? `#${index + 1} (${identity})` : identity;
|
|
108
|
+
}
|
|
101
109
|
return index !== undefined ? `#${index + 1} (${short})` : short;
|
|
102
110
|
}
|
|
103
111
|
function findCurrentAccountIndex(store, auth) {
|
|
@@ -116,14 +124,29 @@ function findCurrentAccountIndex(store, auth) {
|
|
|
116
124
|
return store.activeIndex;
|
|
117
125
|
}
|
|
118
126
|
export function upsertAccount(store, auth, now = Date.now()) {
|
|
127
|
+
const authWithIdentity = auth;
|
|
128
|
+
const identity = normalizeAnthropicAccountIdentity({
|
|
129
|
+
email: authWithIdentity.email,
|
|
130
|
+
accountId: authWithIdentity.accountId,
|
|
131
|
+
});
|
|
119
132
|
const index = store.accounts.findIndex((account) => {
|
|
120
|
-
|
|
133
|
+
if (account.refresh === auth.refresh || account.access === auth.access) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (identity?.accountId && account.accountId === identity.accountId) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
if (identity?.email && account.email === identity.email) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
121
143
|
});
|
|
122
144
|
const nextAccount = {
|
|
123
145
|
type: 'oauth',
|
|
124
146
|
refresh: auth.refresh,
|
|
125
147
|
access: auth.access,
|
|
126
148
|
expires: auth.expires,
|
|
149
|
+
...identity,
|
|
127
150
|
addedAt: now,
|
|
128
151
|
lastUsed: now,
|
|
129
152
|
};
|
|
@@ -139,14 +162,16 @@ export function upsertAccount(store, auth, now = Date.now()) {
|
|
|
139
162
|
...existing,
|
|
140
163
|
...nextAccount,
|
|
141
164
|
addedAt: existing.addedAt,
|
|
165
|
+
email: nextAccount.email || existing.email,
|
|
166
|
+
accountId: nextAccount.accountId || existing.accountId,
|
|
142
167
|
};
|
|
143
168
|
store.activeIndex = index;
|
|
144
169
|
return index;
|
|
145
170
|
}
|
|
146
|
-
export async function rememberAnthropicOAuth(auth) {
|
|
171
|
+
export async function rememberAnthropicOAuth(auth, identity) {
|
|
147
172
|
await withAuthStateLock(async () => {
|
|
148
173
|
const store = await loadAccountStore();
|
|
149
|
-
upsertAccount(store, auth);
|
|
174
|
+
upsertAccount(store, { ...auth, ...normalizeAnthropicAccountIdentity(identity) });
|
|
150
175
|
await saveAccountStore(store);
|
|
151
176
|
});
|
|
152
177
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Tests
|
|
1
|
+
// Tests Anthropic OAuth account persistence, deduplication, and rotation.
|
|
2
2
|
import { mkdtemp, readFile, rm, mkdir, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
|
6
|
-
import { authFilePath, loadAccountStore, rememberAnthropicOAuth, removeAccount, rotateAnthropicAccount, saveAccountStore, shouldRotateAuth, } from './anthropic-auth-state.js';
|
|
6
|
+
import { accountLabel, authFilePath, loadAccountStore, rememberAnthropicOAuth, removeAccount, rotateAnthropicAccount, saveAccountStore, shouldRotateAuth, } from './anthropic-auth-state.js';
|
|
7
7
|
const firstAccount = {
|
|
8
8
|
type: 'oauth',
|
|
9
9
|
refresh: 'refresh-first',
|
|
@@ -45,6 +45,25 @@ describe('rememberAnthropicOAuth', () => {
|
|
|
45
45
|
expires: 3,
|
|
46
46
|
});
|
|
47
47
|
});
|
|
48
|
+
test('deduplicates new tokens by email or account ID', async () => {
|
|
49
|
+
await rememberAnthropicOAuth(firstAccount, {
|
|
50
|
+
email: 'user@example.com',
|
|
51
|
+
accountId: 'usr_123',
|
|
52
|
+
});
|
|
53
|
+
await rememberAnthropicOAuth(secondAccount, {
|
|
54
|
+
email: 'User@example.com',
|
|
55
|
+
accountId: 'usr_123',
|
|
56
|
+
});
|
|
57
|
+
const store = await loadAccountStore();
|
|
58
|
+
expect(store.accounts).toHaveLength(1);
|
|
59
|
+
expect(store.accounts[0]).toMatchObject({
|
|
60
|
+
refresh: 'refresh-second',
|
|
61
|
+
access: 'access-second',
|
|
62
|
+
email: 'user@example.com',
|
|
63
|
+
accountId: 'usr_123',
|
|
64
|
+
});
|
|
65
|
+
expect(accountLabel(store.accounts[0])).toBe('user@example.com');
|
|
66
|
+
});
|
|
48
67
|
});
|
|
49
68
|
describe('rotateAnthropicAccount', () => {
|
|
50
69
|
test('rotates to the next stored account and syncs auth state', async () => {
|
package/dist/cli-parsing.test.js
CHANGED
|
@@ -22,8 +22,8 @@ function createCliForIdParsing() {
|
|
|
22
22
|
.command('add-project', 'Add a project')
|
|
23
23
|
.option('-g, --guild <guildId>', 'Discord guild/server ID');
|
|
24
24
|
cli.command('task delete <id>', 'Delete task');
|
|
25
|
-
cli.command('anthropic-accounts list', 'List stored Anthropic accounts')
|
|
26
|
-
cli.command('anthropic-accounts remove <
|
|
25
|
+
cli.command('anthropic-accounts list', 'List stored Anthropic accounts');
|
|
26
|
+
cli.command('anthropic-accounts remove <indexOrEmail>', 'Remove stored Anthropic account');
|
|
27
27
|
return cli;
|
|
28
28
|
}
|
|
29
29
|
describe('goke CLI ID parsing', () => {
|
|
@@ -113,13 +113,16 @@ describe('goke CLI ID parsing', () => {
|
|
|
113
113
|
expect(result.args[0]).toBe(taskId);
|
|
114
114
|
expect(typeof result.args[0]).toBe('string');
|
|
115
115
|
});
|
|
116
|
-
test('
|
|
116
|
+
test('anthropic account remove parses index and email as strings', () => {
|
|
117
117
|
const cli = createCliForIdParsing();
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
expect(
|
|
118
|
+
const indexResult = cli.parse(['node', 'kimaki', 'anthropic-accounts', 'remove', '2'], { run: false });
|
|
119
|
+
const emailResult = cli.parse(['node', 'kimaki', 'anthropic-accounts', 'remove', 'user@example.com'], { run: false });
|
|
120
|
+
expect(indexResult.args[0]).toBe('2');
|
|
121
|
+
expect(typeof indexResult.args[0]).toBe('string');
|
|
122
|
+
expect(emailResult.args[0]).toBe('user@example.com');
|
|
123
|
+
expect(typeof emailResult.args[0]).toBe('string');
|
|
121
124
|
});
|
|
122
|
-
test('
|
|
125
|
+
test('anthropic account commands are included in help output', () => {
|
|
123
126
|
const stdout = {
|
|
124
127
|
text: '',
|
|
125
128
|
write(data) {
|
|
@@ -128,10 +131,10 @@ describe('goke CLI ID parsing', () => {
|
|
|
128
131
|
};
|
|
129
132
|
const cli = goke('kimaki', { stdout: stdout });
|
|
130
133
|
cli.command('send', 'Send a message');
|
|
131
|
-
cli.command('anthropic-accounts list', 'List stored Anthropic accounts')
|
|
134
|
+
cli.command('anthropic-accounts list', 'List stored Anthropic accounts');
|
|
132
135
|
cli.help();
|
|
133
136
|
cli.parse(['node', 'kimaki', '--help'], { run: false });
|
|
134
137
|
expect(stdout.text).toContain('send');
|
|
135
|
-
expect(stdout.text).
|
|
138
|
+
expect(stdout.text).toContain('anthropic-accounts');
|
|
136
139
|
});
|
|
137
140
|
});
|
package/dist/cli.js
CHANGED
|
@@ -662,7 +662,8 @@ async function resolveCredentials({ forceRestartOnboarding, forceGateway, gatewa
|
|
|
662
662
|
options: [
|
|
663
663
|
{
|
|
664
664
|
value: 'gateway',
|
|
665
|
-
|
|
665
|
+
disabled: true,
|
|
666
|
+
label: 'Gateway (pre-built Kimaki bot, currently disabled because of Discord verification process. will be re-enabled soon)',
|
|
666
667
|
},
|
|
667
668
|
{
|
|
668
669
|
value: 'self_hosted',
|
|
@@ -2224,7 +2225,6 @@ cli
|
|
|
2224
2225
|
});
|
|
2225
2226
|
cli
|
|
2226
2227
|
.command('anthropic-accounts list', 'List stored Anthropic OAuth accounts used for automatic rotation')
|
|
2227
|
-
.hidden()
|
|
2228
2228
|
.action(async () => {
|
|
2229
2229
|
const store = await loadAccountStore();
|
|
2230
2230
|
console.log(`Store: ${accountsFilePath()}`);
|
|
@@ -2239,16 +2239,29 @@ cli
|
|
|
2239
2239
|
process.exit(0);
|
|
2240
2240
|
});
|
|
2241
2241
|
cli
|
|
2242
|
-
.command('anthropic-accounts remove <
|
|
2243
|
-
.
|
|
2244
|
-
|
|
2245
|
-
const
|
|
2246
|
-
|
|
2247
|
-
|
|
2242
|
+
.command('anthropic-accounts remove <indexOrEmail>', 'Remove a stored Anthropic OAuth account from the rotation pool by index or email')
|
|
2243
|
+
.action(async (indexOrEmail) => {
|
|
2244
|
+
const value = Number(indexOrEmail);
|
|
2245
|
+
const store = await loadAccountStore();
|
|
2246
|
+
const resolvedIndex = (() => {
|
|
2247
|
+
if (Number.isInteger(value) && value >= 1) {
|
|
2248
|
+
return value - 1;
|
|
2249
|
+
}
|
|
2250
|
+
const email = indexOrEmail.trim().toLowerCase();
|
|
2251
|
+
if (!email) {
|
|
2252
|
+
return -1;
|
|
2253
|
+
}
|
|
2254
|
+
return store.accounts.findIndex((account) => {
|
|
2255
|
+
return account.email?.toLowerCase() === email;
|
|
2256
|
+
});
|
|
2257
|
+
})();
|
|
2258
|
+
if (resolvedIndex < 0) {
|
|
2259
|
+
cliLogger.error('Usage: kimaki anthropic-accounts remove <index-or-email>');
|
|
2248
2260
|
process.exit(EXIT_NO_RESTART);
|
|
2249
2261
|
}
|
|
2250
|
-
|
|
2251
|
-
|
|
2262
|
+
const removed = store.accounts[resolvedIndex];
|
|
2263
|
+
await removeAccount(resolvedIndex);
|
|
2264
|
+
cliLogger.log(`Removed Anthropic account ${removed ? accountLabel(removed, resolvedIndex) : indexOrEmail}`);
|
|
2252
2265
|
process.exit(0);
|
|
2253
2266
|
});
|
|
2254
2267
|
cli
|
|
@@ -61,7 +61,7 @@ export function shouldInjectPwd({ currentDir, previousDir, announcedDir, }) {
|
|
|
61
61
|
inject: true,
|
|
62
62
|
text: `\n[working directory changed. Previous working directory: ${priorDirectory}. ` +
|
|
63
63
|
`Current working directory: ${currentDir}. ` +
|
|
64
|
-
`You
|
|
64
|
+
`You should read, write, and edit files under ${currentDir}. ` +
|
|
65
65
|
`Do NOT read, write, or edit files under ${priorDirectory}.]`,
|
|
66
66
|
};
|
|
67
67
|
}
|
|
@@ -36,7 +36,7 @@ describe('shouldInjectPwd', () => {
|
|
|
36
36
|
{
|
|
37
37
|
"inject": true,
|
|
38
38
|
"text": "
|
|
39
|
-
[working directory changed. Previous working directory: /repo/main. Current working directory: /repo/worktree. You
|
|
39
|
+
[working directory changed. Previous working directory: /repo/main. Current working directory: /repo/worktree. You should read, write, and edit files under /repo/worktree. Do NOT read, write, or edit files under /repo/main.]",
|
|
40
40
|
}
|
|
41
41
|
`);
|
|
42
42
|
});
|
|
@@ -50,7 +50,7 @@ describe('shouldInjectPwd', () => {
|
|
|
50
50
|
{
|
|
51
51
|
"inject": true,
|
|
52
52
|
"text": "
|
|
53
|
-
[working directory changed. Previous working directory: /repo/worktree-a. Current working directory: /repo/worktree-b. You
|
|
53
|
+
[working directory changed. Previous working directory: /repo/worktree-a. Current working directory: /repo/worktree-b. You should read, write, and edit files under /repo/worktree-b. Do NOT read, write, or edit files under /repo/worktree-a.]",
|
|
54
54
|
}
|
|
55
55
|
`);
|
|
56
56
|
});
|
|
@@ -110,7 +110,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
110
110
|
.toJSON(),
|
|
111
111
|
new SlashCommandBuilder()
|
|
112
112
|
.setName('new-worktree')
|
|
113
|
-
.setDescription(truncateCommandDescription('Create a git worktree branch from
|
|
113
|
+
.setDescription(truncateCommandDescription('Create a git worktree branch from HEAD by default. Optionally pick a base branch.'))
|
|
114
114
|
.addStringOption((option) => {
|
|
115
115
|
option
|
|
116
116
|
.setName('name')
|
|
@@ -121,7 +121,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
121
121
|
.addStringOption((option) => {
|
|
122
122
|
option
|
|
123
123
|
.setName('base-branch')
|
|
124
|
-
.setDescription(truncateCommandDescription('Branch to create the worktree from (default:
|
|
124
|
+
.setDescription(truncateCommandDescription('Branch to create the worktree from (default: HEAD)'))
|
|
125
125
|
.setRequired(false)
|
|
126
126
|
.setAutocomplete(true);
|
|
127
127
|
return option;
|
package/dist/discord-utils.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// Discord-specific utility functions.
|
|
2
2
|
// Handles markdown splitting for Discord's 2000-char limit, code block escaping,
|
|
3
3
|
// thread message sending, and channel metadata extraction from topic tags.
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
// Use namespace import for CJS interop — discord.js is CJS and its named
|
|
5
|
+
// exports aren't detectable by all ESM loaders (e.g. tsx/esbuild) because
|
|
6
|
+
// discord.js uses tslib's __exportStar which is opaque to static analysis.
|
|
7
|
+
import * as discord from 'discord.js';
|
|
8
|
+
const { ChannelType, GuildMember, MessageFlags, PermissionsBitField, REST, Routes } = discord;
|
|
6
9
|
import { discordApiUrl } from './discord-urls.js';
|
|
7
10
|
import { Lexer } from 'marked';
|
|
8
11
|
import { splitTablesFromMarkdown } from './format-tables.js';
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
export { ipcToolsPlugin } from './ipc-tools-plugin.js';
|
|
12
12
|
export { contextAwarenessPlugin } from './context-awareness-plugin.js';
|
|
13
13
|
export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js';
|
|
14
|
+
export { systemPromptDriftPlugin } from './system-prompt-drift-plugin.js';
|
|
14
15
|
export { anthropicAuthPlugin } from './anthropic-auth-plugin.js';
|
|
15
16
|
export { imageOptimizerPlugin } from './image-optimizer-plugin.js';
|
|
16
17
|
export { kittyGraphicsPlugin } from 'kitty-graphics-agent';
|
package/dist/logger.js
CHANGED
|
@@ -80,12 +80,18 @@ export function setLogFilePath(dataDir) {
|
|
|
80
80
|
export function getLogFilePath() {
|
|
81
81
|
return logFilePath;
|
|
82
82
|
}
|
|
83
|
+
const MAX_LOG_ARG_LENGTH = 1000;
|
|
84
|
+
function truncate(str, max) {
|
|
85
|
+
if (str.length <= max)
|
|
86
|
+
return str;
|
|
87
|
+
return str.slice(0, max) + `… [truncated ${str.length - max} chars]`;
|
|
88
|
+
}
|
|
83
89
|
function formatArg(arg) {
|
|
84
90
|
if (typeof arg === 'string') {
|
|
85
|
-
return sanitizeSensitiveText(arg, { redactPaths: false });
|
|
91
|
+
return truncate(sanitizeSensitiveText(arg, { redactPaths: false }), MAX_LOG_ARG_LENGTH);
|
|
86
92
|
}
|
|
87
93
|
const safeArg = sanitizeUnknownValue(arg, { redactPaths: false });
|
|
88
|
-
return util.inspect(safeArg, { colors: true, depth: 4 });
|
|
94
|
+
return truncate(util.inspect(safeArg, { colors: true, depth: 4 }), MAX_LOG_ARG_LENGTH);
|
|
89
95
|
}
|
|
90
96
|
export function formatErrorWithStack(error) {
|
|
91
97
|
if (error instanceof Error) {
|