qq-codex-bridge 0.1.2 → 0.1.4

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 (39) hide show
  1. package/.env.example +62 -0
  2. package/README.md +232 -287
  3. package/bin/chatgpt-desktop.js +2 -0
  4. package/bin/qq-codex-weixin-gateway.js +14 -0
  5. package/dist/apps/bridge-daemon/src/bootstrap.js +161 -31
  6. package/dist/apps/bridge-daemon/src/cli.js +5 -1
  7. package/dist/apps/bridge-daemon/src/config.js +168 -37
  8. package/dist/apps/bridge-daemon/src/http-server.js +23 -11
  9. package/dist/apps/bridge-daemon/src/main.js +163 -29
  10. package/dist/apps/bridge-daemon/src/thread-command-handler.js +320 -23
  11. package/dist/apps/chatgpt-desktop-cli/src/cli.js +191 -0
  12. package/dist/apps/weixin-gateway/src/cli.js +446 -0
  13. package/dist/apps/weixin-gateway/src/config.js +135 -0
  14. package/dist/apps/weixin-gateway/src/dev.js +2 -0
  15. package/dist/apps/weixin-gateway/src/message-store.js +50 -0
  16. package/dist/apps/weixin-gateway/src/server.js +216 -0
  17. package/dist/apps/weixin-gateway/src/state.js +163 -0
  18. package/dist/apps/weixin-gateway/src/weixin-client.js +520 -0
  19. package/dist/packages/adapters/chatgpt-desktop/src/ax-client.js +472 -0
  20. package/dist/packages/adapters/chatgpt-desktop/src/bridge-provider.js +82 -0
  21. package/dist/packages/adapters/chatgpt-desktop/src/driver.js +161 -0
  22. package/dist/packages/adapters/chatgpt-desktop/src/image-cache.js +155 -0
  23. package/dist/packages/adapters/chatgpt-desktop/src/session-registry.js +48 -0
  24. package/dist/packages/adapters/chatgpt-desktop/src/types.js +1 -0
  25. package/dist/packages/adapters/codex-desktop/src/codex-app-server-driver.js +810 -0
  26. package/dist/packages/adapters/codex-desktop/src/codex-app-ui-notification-forwarder.js +33 -0
  27. package/dist/packages/adapters/codex-desktop/src/codex-desktop-driver.js +727 -123
  28. package/dist/packages/adapters/codex-desktop/src/codex-local-rollout-reader.js +227 -0
  29. package/dist/packages/adapters/codex-desktop/src/codex-local-submission-reader.js +142 -0
  30. package/dist/packages/adapters/weixin/src/weixin-channel-adapter.js +15 -0
  31. package/dist/packages/adapters/weixin/src/weixin-http-client.js +42 -0
  32. package/dist/packages/adapters/weixin/src/weixin-sender.js +200 -0
  33. package/dist/packages/adapters/weixin/src/weixin-webhook.js +35 -0
  34. package/dist/packages/orchestrator/src/bridge-orchestrator.js +72 -25
  35. package/dist/packages/orchestrator/src/weixin-outbound-format.js +55 -0
  36. package/dist/packages/ports/src/chat.js +1 -0
  37. package/dist/packages/store/src/session-repo.js +16 -3
  38. package/dist/packages/store/src/sqlite.js +3 -0
  39. package/package.json +8 -2
@@ -0,0 +1,135 @@
1
+ import { z } from "zod";
2
+ const weixinGatewayAccountConfigSchema = z.object({
3
+ accountId: z.string().min(1),
4
+ bridgeWebhookPath: z.string().startsWith("/"),
5
+ baseUrl: z.string().url(),
6
+ token: z.string().min(1).nullable(),
7
+ messageStorePath: z.string().min(1)
8
+ });
9
+ export const weixinGatewayConfigSchema = z.object({
10
+ listenHost: z.string().min(1),
11
+ listenPort: z.number().int().positive(),
12
+ bridgeBaseUrl: z.string().url(),
13
+ bridgeWebhookPath: z.string().startsWith("/"),
14
+ expectedBearerToken: z.string().min(1).nullable(),
15
+ messageStorePath: z.string().min(1),
16
+ recentMessageLimit: z.number().int().positive(),
17
+ enabled: z.boolean(),
18
+ accountId: z.string().min(1),
19
+ baseUrl: z.string().url(),
20
+ token: z.string().min(1).nullable(),
21
+ longPollTimeoutMs: z.number().int().positive(),
22
+ apiTimeoutMs: z.number().int().positive(),
23
+ stateFilePath: z.string().min(1),
24
+ loginBaseUrl: z.string().url(),
25
+ loginBotType: z.string().min(1),
26
+ qrFetchTimeoutMs: z.number().int().positive(),
27
+ qrPollTimeoutMs: z.number().int().positive(),
28
+ qrTotalTimeoutMs: z.number().int().positive(),
29
+ stateWatchIntervalMs: z.number().int().positive(),
30
+ accounts: z.array(weixinGatewayAccountConfigSchema).min(1)
31
+ });
32
+ export function loadWeixinGatewayConfigFromEnv(env) {
33
+ const fallbackAccount = {
34
+ accountId: env.WEIXIN_ACCOUNT_ID ?? "default",
35
+ bridgeWebhookPath: env.WEIXIN_GATEWAY_BRIDGE_WEBHOOK_PATH
36
+ ?? env.WEIXIN_WEBHOOK_PATH
37
+ ?? "/webhooks/weixin",
38
+ baseUrl: env.WEIXIN_BASE_URL ?? "https://ilinkai.weixin.qq.com",
39
+ token: env.WEIXIN_TOKEN ?? null,
40
+ messageStorePath: env.WEIXIN_GATEWAY_MESSAGE_STORE_PATH
41
+ ?? "runtime/weixin-gateway-messages.ndjson"
42
+ };
43
+ return weixinGatewayConfigSchema.parse({
44
+ listenHost: env.WEIXIN_GATEWAY_LISTEN_HOST ?? "127.0.0.1",
45
+ listenPort: Number(env.WEIXIN_GATEWAY_LISTEN_PORT ?? "3200"),
46
+ bridgeBaseUrl: env.WEIXIN_GATEWAY_BRIDGE_BASE_URL
47
+ ?? `http://${env.QQ_CODEX_LISTEN_HOST ?? "127.0.0.1"}:${env.QQ_CODEX_LISTEN_PORT ?? "3100"}`,
48
+ bridgeWebhookPath: env.WEIXIN_GATEWAY_BRIDGE_WEBHOOK_PATH
49
+ ?? env.WEIXIN_WEBHOOK_PATH
50
+ ?? "/webhooks/weixin",
51
+ expectedBearerToken: env.WEIXIN_GATEWAY_EXPECTED_TOKEN ?? env.WEIXIN_EGRESS_TOKEN ?? null,
52
+ messageStorePath: env.WEIXIN_GATEWAY_MESSAGE_STORE_PATH
53
+ ?? "runtime/weixin-gateway-messages.ndjson",
54
+ recentMessageLimit: Number(env.WEIXIN_GATEWAY_RECENT_MESSAGE_LIMIT ?? "100"),
55
+ enabled: env.WEIXIN_ENABLED !== "false",
56
+ accountId: env.WEIXIN_ACCOUNT_ID ?? "default",
57
+ baseUrl: env.WEIXIN_BASE_URL ?? "https://ilinkai.weixin.qq.com",
58
+ token: env.WEIXIN_TOKEN ?? null,
59
+ longPollTimeoutMs: Number(env.WEIXIN_LONG_POLL_TIMEOUT_MS ?? "35000"),
60
+ apiTimeoutMs: Number(env.WEIXIN_API_TIMEOUT_MS ?? "15000"),
61
+ stateFilePath: env.WEIXIN_GATEWAY_STATE_FILE_PATH ?? "runtime/weixin-gateway-state.json",
62
+ loginBaseUrl: env.WEIXIN_LOGIN_BASE_URL ?? "https://ilinkai.weixin.qq.com",
63
+ loginBotType: env.WEIXIN_BOT_TYPE ?? "3",
64
+ qrFetchTimeoutMs: Number(env.WEIXIN_QR_FETCH_TIMEOUT_MS ?? "10000"),
65
+ qrPollTimeoutMs: Number(env.WEIXIN_QR_POLL_TIMEOUT_MS ?? "35000"),
66
+ qrTotalTimeoutMs: Number(env.WEIXIN_QR_TOTAL_TIMEOUT_MS ?? String(8 * 60 * 1000)),
67
+ stateWatchIntervalMs: Number(env.WEIXIN_GATEWAY_STATE_WATCH_INTERVAL_MS ?? "1000"),
68
+ accounts: resolveGatewayAccounts(env, fallbackAccount)
69
+ });
70
+ }
71
+ function resolveGatewayAccounts(env, fallback) {
72
+ const jsonAccounts = parseJsonArray(env.WEIXIN_GATEWAY_ACCOUNTS_JSON ?? env.WEIXIN_ACCOUNTS_JSON);
73
+ if (jsonAccounts) {
74
+ return jsonAccounts.map((item, index) => {
75
+ const record = asRecord(item);
76
+ const accountId = stringValue(record.accountId ?? record.id, `account${index + 1}`);
77
+ return {
78
+ accountId,
79
+ bridgeWebhookPath: stringValue(record.bridgeWebhookPath ?? record.webhookPath, `/webhooks/weixin/${accountId}`),
80
+ baseUrl: stringValue(record.baseUrl, fallback.baseUrl),
81
+ token: nullableString(record.token ?? record.weixinToken),
82
+ messageStorePath: stringValue(record.messageStorePath, `runtime/weixin-gateway-messages-${safePathSegment(accountId)}.ndjson`)
83
+ };
84
+ });
85
+ }
86
+ const accountIds = splitList(env.WEIXIN_GATEWAY_ACCOUNT_IDS ?? env.WEIXIN_ACCOUNT_IDS);
87
+ if (accountIds.length > 0) {
88
+ return accountIds.map((accountId, index) => {
89
+ const suffix = envNameSuffix(accountId);
90
+ return {
91
+ accountId,
92
+ bridgeWebhookPath: env[`WEIXIN_GATEWAY_${suffix}_BRIDGE_WEBHOOK_PATH`]
93
+ ?? env[`WEIXIN_${suffix}_WEBHOOK_PATH`]
94
+ ?? `/webhooks/weixin/${accountId}`,
95
+ baseUrl: env[`WEIXIN_${suffix}_BASE_URL`] ?? fallback.baseUrl,
96
+ token: env[`WEIXIN_${suffix}_TOKEN`] ?? (index === 0 ? fallback.token : null),
97
+ messageStorePath: env[`WEIXIN_GATEWAY_${suffix}_MESSAGE_STORE_PATH`]
98
+ ?? `runtime/weixin-gateway-messages-${safePathSegment(accountId)}.ndjson`
99
+ };
100
+ });
101
+ }
102
+ return [fallback];
103
+ }
104
+ function parseJsonArray(value) {
105
+ if (!value?.trim()) {
106
+ return null;
107
+ }
108
+ const parsed = JSON.parse(value);
109
+ return Array.isArray(parsed) ? parsed : null;
110
+ }
111
+ function asRecord(value) {
112
+ return value && typeof value === "object" && !Array.isArray(value)
113
+ ? value
114
+ : {};
115
+ }
116
+ function stringValue(value, fallback) {
117
+ const text = String(value ?? "").trim();
118
+ return text || fallback;
119
+ }
120
+ function nullableString(value) {
121
+ const text = String(value ?? "").trim();
122
+ return text || null;
123
+ }
124
+ function splitList(value) {
125
+ return (value ?? "")
126
+ .split(",")
127
+ .map((item) => item.trim())
128
+ .filter(Boolean);
129
+ }
130
+ function envNameSuffix(value) {
131
+ return value.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase();
132
+ }
133
+ function safePathSegment(value) {
134
+ return value.replace(/[^a-zA-Z0-9._-]/g, "_") || "default";
135
+ }
@@ -0,0 +1,2 @@
1
+ import { runCliFromProcess } from "./cli.js";
2
+ void runCliFromProcess();
@@ -0,0 +1,50 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export class WeixinGatewayMessageStore {
4
+ filePath;
5
+ limit;
6
+ messages = [];
7
+ constructor(filePath, limit) {
8
+ this.filePath = filePath;
9
+ this.limit = limit;
10
+ this.loadFromDisk();
11
+ }
12
+ append(message) {
13
+ this.messages.push(message);
14
+ this.trimToLimit();
15
+ this.persistLine(message);
16
+ }
17
+ listRecent() {
18
+ return [...this.messages].reverse();
19
+ }
20
+ loadFromDisk() {
21
+ if (!fs.existsSync(this.filePath)) {
22
+ return;
23
+ }
24
+ const content = fs.readFileSync(this.filePath, "utf8");
25
+ for (const line of content.split("\n")) {
26
+ const trimmed = line.trim();
27
+ if (!trimmed) {
28
+ continue;
29
+ }
30
+ try {
31
+ const parsed = JSON.parse(trimmed);
32
+ this.messages.push(parsed);
33
+ }
34
+ catch {
35
+ // ignore malformed historical lines
36
+ }
37
+ }
38
+ this.trimToLimit();
39
+ }
40
+ persistLine(message) {
41
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
42
+ fs.appendFileSync(this.filePath, `${JSON.stringify(message)}\n`, "utf8");
43
+ }
44
+ trimToLimit() {
45
+ if (this.messages.length <= this.limit) {
46
+ return;
47
+ }
48
+ this.messages.splice(0, this.messages.length - this.limit);
49
+ }
50
+ }
@@ -0,0 +1,216 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { z } from "zod";
4
+ import { WeixinGatewayMessageStore } from "./message-store.js";
5
+ import { MediaArtifactKind } from "../../../packages/domain/src/message.js";
6
+ const inboundTextPayloadSchema = z.object({
7
+ accountKey: z.string().min(1).optional(),
8
+ chatType: z.enum(["c2c", "group"]).optional(),
9
+ senderId: z.string().min(1),
10
+ peerId: z.string().min(1).optional(),
11
+ messageId: z.string().min(1),
12
+ text: z.string().min(1),
13
+ receivedAt: z.string().optional()
14
+ });
15
+ const mediaArtifactSchema = z.object({
16
+ kind: z.nativeEnum(MediaArtifactKind),
17
+ sourceUrl: z.string().min(1),
18
+ localPath: z.string().min(1),
19
+ mimeType: z.string().min(1),
20
+ fileSize: z.number().nonnegative(),
21
+ originalName: z.string().min(1),
22
+ transcript: z.string().nullable().optional(),
23
+ transcriptSource: z.enum(["stt", "asr", "fallback"]).nullable().optional(),
24
+ extractedText: z.string().nullable().optional()
25
+ });
26
+ const outboundMessagePayloadSchema = z.object({
27
+ accountKey: z.string().min(1).optional(),
28
+ accountId: z.string().min(1).optional(),
29
+ peerId: z.string().min(1),
30
+ chatType: z.enum(["c2c", "group"]),
31
+ content: z.string().min(1).optional(),
32
+ mediaArtifacts: z.array(mediaArtifactSchema).min(1).optional(),
33
+ replyToMessageId: z.string().min(1).optional()
34
+ }).superRefine((payload, ctx) => {
35
+ if (!payload.content && !(payload.mediaArtifacts?.length)) {
36
+ ctx.addIssue({
37
+ code: z.ZodIssueCode.custom,
38
+ message: "content or mediaArtifacts is required",
39
+ path: ["content"]
40
+ });
41
+ }
42
+ });
43
+ export function createWeixinGatewayServer(deps) {
44
+ const messageStore = deps.messageStore
45
+ ?? new WeixinGatewayMessageStore(deps.config.messageStorePath, deps.config.recentMessageLimit);
46
+ const fetchFn = deps.fetchFn ?? fetch;
47
+ return createServer(async (request, response) => {
48
+ try {
49
+ if (request.method === "GET" && request.url === "/health") {
50
+ writeJson(response, 200, { ok: true });
51
+ return;
52
+ }
53
+ if (request.method === "GET" && request.url === "/messages") {
54
+ writeJson(response, 200, {
55
+ items: messageStore.listRecent()
56
+ });
57
+ return;
58
+ }
59
+ if (request.method !== "POST") {
60
+ writePlain(response, 405, "method not allowed");
61
+ return;
62
+ }
63
+ if (request.url === "/inbound/text") {
64
+ const payload = inboundTextPayloadSchema.parse(await readJsonBody(request));
65
+ const bridgePayload = {
66
+ accountKey: payload.accountKey,
67
+ chatType: payload.chatType ?? "c2c",
68
+ senderId: payload.senderId,
69
+ peerId: payload.peerId ?? payload.senderId,
70
+ messageId: payload.messageId,
71
+ text: payload.text,
72
+ receivedAt: payload.receivedAt
73
+ };
74
+ const bridgeResponse = await fetchFn(`${deps.config.bridgeBaseUrl}${deps.config.bridgeWebhookPath}`, {
75
+ method: "POST",
76
+ headers: {
77
+ "content-type": "application/json"
78
+ },
79
+ body: JSON.stringify(bridgePayload)
80
+ });
81
+ if (!bridgeResponse.ok) {
82
+ const bridgeText = await bridgeResponse.text().catch(() => "");
83
+ throw new Error(`bridge webhook failed: ${bridgeResponse.status}${bridgeText ? ` ${bridgeText}` : ""}`);
84
+ }
85
+ writeJson(response, 202, {
86
+ accepted: true
87
+ });
88
+ return;
89
+ }
90
+ if (request.url === "/messages") {
91
+ assertBearerToken(request, deps.config.expectedBearerToken);
92
+ const payload = outboundMessagePayloadSchema.parse(await readJsonBody(request));
93
+ const duplicate = findRecentDuplicateOutbound(messageStore.listRecent(), payload);
94
+ if (duplicate) {
95
+ writeJson(response, 200, { id: duplicate.id, deduped: true });
96
+ return;
97
+ }
98
+ const message = {
99
+ id: randomUUID(),
100
+ ...(payload.accountKey ? { accountKey: payload.accountKey } : {}),
101
+ ...(payload.accountId ? { accountId: payload.accountId } : {}),
102
+ peerId: payload.peerId,
103
+ chatType: payload.chatType,
104
+ ...(payload.content ? { content: payload.content } : {}),
105
+ ...(payload.mediaArtifacts?.length ? { mediaArtifacts: payload.mediaArtifacts } : {}),
106
+ ...(payload.replyToMessageId ? { replyToMessageId: payload.replyToMessageId } : {}),
107
+ createdAt: new Date().toISOString()
108
+ };
109
+ messageStore.append(message);
110
+ if (deps.outboundSender) {
111
+ if (payload.mediaArtifacts?.length && deps.outboundSender.sendMessage) {
112
+ await deps.outboundSender.sendMessage({
113
+ ...(payload.accountKey ? { accountKey: payload.accountKey } : {}),
114
+ ...(payload.accountId ? { accountId: payload.accountId } : {}),
115
+ peerId: payload.peerId,
116
+ chatType: payload.chatType,
117
+ ...(payload.content ? { content: payload.content } : {}),
118
+ mediaArtifacts: payload.mediaArtifacts,
119
+ ...(payload.replyToMessageId ? { replyToMessageId: payload.replyToMessageId } : {})
120
+ });
121
+ }
122
+ else {
123
+ await deps.outboundSender.sendTextMessage({
124
+ ...(payload.accountKey ? { accountKey: payload.accountKey } : {}),
125
+ ...(payload.accountId ? { accountId: payload.accountId } : {}),
126
+ peerId: payload.peerId,
127
+ chatType: payload.chatType,
128
+ text: payload.content ?? "",
129
+ ...(payload.replyToMessageId ? { replyToMessageId: payload.replyToMessageId } : {})
130
+ });
131
+ }
132
+ }
133
+ console.log("[weixin-gateway] outbound message", {
134
+ id: message.id,
135
+ chatType: message.chatType,
136
+ peerId: message.peerId,
137
+ preview: clipPreview(message.content ?? ""),
138
+ mediaCount: message.mediaArtifacts?.length ?? 0
139
+ });
140
+ writeJson(response, 200, { id: message.id });
141
+ return;
142
+ }
143
+ writePlain(response, 404, "not found");
144
+ }
145
+ catch (error) {
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ const statusCode = error instanceof z.ZodError ? 400 : 500;
148
+ writeJson(response, statusCode, {
149
+ error: message
150
+ });
151
+ }
152
+ });
153
+ }
154
+ async function readJsonBody(request) {
155
+ const chunks = [];
156
+ for await (const chunk of request) {
157
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
158
+ }
159
+ const raw = Buffer.concat(chunks).toString("utf8");
160
+ return raw ? JSON.parse(raw) : {};
161
+ }
162
+ function assertBearerToken(request, expectedToken) {
163
+ if (!expectedToken) {
164
+ return;
165
+ }
166
+ const authHeader = request.headers.authorization;
167
+ if (authHeader !== `Bearer ${expectedToken}`) {
168
+ throw new Error("unauthorized");
169
+ }
170
+ }
171
+ function clipPreview(text) {
172
+ const normalized = text.replace(/\s+/g, " ").trim();
173
+ return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
174
+ }
175
+ function findRecentDuplicateOutbound(recentMessages, payload) {
176
+ const fingerprint = buildOutboundFingerprint(payload);
177
+ if (!fingerprint) {
178
+ return null;
179
+ }
180
+ const now = Date.now();
181
+ for (const message of recentMessages) {
182
+ const createdAtMs = Date.parse(message.createdAt);
183
+ if (!Number.isFinite(createdAtMs) || now - createdAtMs > 120_000) {
184
+ continue;
185
+ }
186
+ if (buildOutboundFingerprint(message) === fingerprint) {
187
+ return message;
188
+ }
189
+ }
190
+ return null;
191
+ }
192
+ function buildOutboundFingerprint(message) {
193
+ const peerId = String(message.peerId ?? "").trim();
194
+ const chatType = String(message.chatType ?? "").trim();
195
+ const replyToMessageId = String(message.replyToMessageId ?? "").trim();
196
+ const content = String(message.content ?? "").replace(/\s+/g, " ").trim();
197
+ const mediaFingerprint = (message.mediaArtifacts ?? [])
198
+ .map((artifact) => [
199
+ artifact.kind,
200
+ artifact.localPath || "",
201
+ artifact.sourceUrl || "",
202
+ artifact.originalName || ""
203
+ ].join("::"))
204
+ .join("|");
205
+ return [peerId, chatType, replyToMessageId, content, mediaFingerprint].join("||");
206
+ }
207
+ function writeJson(response, statusCode, payload) {
208
+ response.statusCode = statusCode;
209
+ response.setHeader("content-type", "application/json; charset=utf-8");
210
+ response.end(JSON.stringify(payload));
211
+ }
212
+ function writePlain(response, statusCode, payload) {
213
+ response.statusCode = statusCode;
214
+ response.setHeader("content-type", "text/plain; charset=utf-8");
215
+ response.end(payload);
216
+ }
@@ -0,0 +1,163 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const EMPTY_STATE = {
4
+ syncCursor: "",
5
+ contextTokens: {},
6
+ accounts: {},
7
+ defaultAccountId: "default"
8
+ };
9
+ export class WeixinGatewayStateStore {
10
+ filePath;
11
+ state = structuredClone(EMPTY_STATE);
12
+ constructor(filePath) {
13
+ this.filePath = filePath;
14
+ this.reload();
15
+ }
16
+ reload() {
17
+ this.state = loadStateFromDisk(this.filePath);
18
+ }
19
+ getSyncCursor() {
20
+ return this.state.syncCursor;
21
+ }
22
+ setSyncCursor(cursor) {
23
+ this.state.syncCursor = sanitizeText(cursor);
24
+ this.persist();
25
+ }
26
+ getContextToken(accountId, peerId) {
27
+ return this.state.contextTokens[buildContextTokenKey(accountId, peerId)] ?? "";
28
+ }
29
+ setContextToken(accountId, peerId, token) {
30
+ const key = buildContextTokenKey(accountId, peerId);
31
+ const normalizedToken = sanitizeText(token);
32
+ if (!key || !normalizedToken) {
33
+ return;
34
+ }
35
+ this.state.contextTokens[key] = normalizedToken;
36
+ this.persist();
37
+ }
38
+ getStoredAccount(accountId) {
39
+ const normalizedAccountId = sanitizeText(accountId) || this.state.defaultAccountId;
40
+ return this.state.accounts[normalizedAccountId] ?? null;
41
+ }
42
+ setStoredAccount(account) {
43
+ const normalizedAccountId = sanitizeText(account.accountId) || "default";
44
+ this.state.accounts[normalizedAccountId] = {
45
+ accountId: normalizedAccountId,
46
+ token: sanitizeText(account.token),
47
+ baseUrl: sanitizeText(account.baseUrl),
48
+ ...(sanitizeText(account.userId) ? { userId: sanitizeText(account.userId) } : {})
49
+ };
50
+ this.state.defaultAccountId = normalizedAccountId;
51
+ this.persist();
52
+ }
53
+ clearStoredAccount(accountId) {
54
+ const normalizedAccountId = sanitizeText(accountId) || this.state.defaultAccountId;
55
+ delete this.state.accounts[normalizedAccountId];
56
+ for (const key of Object.keys(this.state.contextTokens)) {
57
+ if (key.startsWith(`${normalizedAccountId}:`)) {
58
+ delete this.state.contextTokens[key];
59
+ }
60
+ }
61
+ if (this.state.defaultAccountId === normalizedAccountId) {
62
+ this.state.defaultAccountId = Object.keys(this.state.accounts)[0] ?? "default";
63
+ }
64
+ this.persist();
65
+ }
66
+ resolveRuntimeAccount(preferredAccountId, envOverride) {
67
+ const normalizedPreferred = sanitizeText(preferredAccountId) || this.state.defaultAccountId;
68
+ const stored = this.state.accounts[normalizedPreferred]
69
+ ?? this.state.accounts[this.state.defaultAccountId]
70
+ ?? null;
71
+ const token = sanitizeText(envOverride.token) || sanitizeText(stored?.token);
72
+ const baseUrl = sanitizeText(envOverride.baseUrl) || sanitizeText(stored?.baseUrl);
73
+ if (!token || !baseUrl) {
74
+ return null;
75
+ }
76
+ return {
77
+ accountId: sanitizeText(stored?.accountId) || normalizedPreferred || "default",
78
+ token,
79
+ baseUrl,
80
+ ...(sanitizeText(stored?.userId) ? { userId: sanitizeText(stored?.userId) } : {})
81
+ };
82
+ }
83
+ persist() {
84
+ persistStateToDisk(this.filePath, this.state);
85
+ }
86
+ }
87
+ function loadStateFromDisk(filePath) {
88
+ let raw = "";
89
+ try {
90
+ raw = fs.readFileSync(filePath, "utf8");
91
+ }
92
+ catch (error) {
93
+ if (error?.code === "ENOENT") {
94
+ return structuredClone(EMPTY_STATE);
95
+ }
96
+ throw error;
97
+ }
98
+ try {
99
+ const parsed = JSON.parse(raw);
100
+ return {
101
+ syncCursor: sanitizeText(parsed.syncCursor),
102
+ contextTokens: normalizeContextTokens(parsed.contextTokens),
103
+ accounts: normalizeAccounts(parsed.accounts),
104
+ defaultAccountId: sanitizeText(parsed.defaultAccountId) || "default"
105
+ };
106
+ }
107
+ catch {
108
+ return structuredClone(EMPTY_STATE);
109
+ }
110
+ }
111
+ function persistStateToDisk(filePath, state) {
112
+ const tempFile = `${filePath}.tmp`;
113
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
114
+ fs.writeFileSync(tempFile, JSON.stringify(state, null, 2), "utf8");
115
+ fs.renameSync(tempFile, filePath);
116
+ }
117
+ function normalizeContextTokens(input) {
118
+ const result = {};
119
+ if (!input || typeof input !== "object") {
120
+ return result;
121
+ }
122
+ for (const [key, value] of Object.entries(input)) {
123
+ const normalizedKey = sanitizeText(key);
124
+ const normalizedValue = sanitizeText(value);
125
+ if (!normalizedKey || !normalizedValue) {
126
+ continue;
127
+ }
128
+ result[normalizedKey] = normalizedValue;
129
+ }
130
+ return result;
131
+ }
132
+ function normalizeAccounts(input) {
133
+ const result = {};
134
+ if (!input || typeof input !== "object") {
135
+ return result;
136
+ }
137
+ for (const [key, value] of Object.entries(input)) {
138
+ const accountId = sanitizeText(key);
139
+ const token = sanitizeText(value?.token);
140
+ const baseUrl = sanitizeText(value?.baseUrl);
141
+ if (!accountId || !token || !baseUrl) {
142
+ continue;
143
+ }
144
+ result[accountId] = {
145
+ accountId,
146
+ token,
147
+ baseUrl,
148
+ ...(sanitizeText(value?.userId) ? { userId: sanitizeText(value?.userId) } : {})
149
+ };
150
+ }
151
+ return result;
152
+ }
153
+ function buildContextTokenKey(accountId, peerId) {
154
+ const normalizedAccountId = sanitizeText(accountId);
155
+ const normalizedPeerId = sanitizeText(peerId);
156
+ if (!normalizedAccountId || !normalizedPeerId) {
157
+ return "";
158
+ }
159
+ return `${normalizedAccountId}:${normalizedPeerId}`;
160
+ }
161
+ function sanitizeText(value) {
162
+ return String(value ?? "").trim();
163
+ }