qwenproxy-cli 1.0.0 → 1.0.2

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 (109) hide show
  1. package/LICENSE +14 -14
  2. package/README.md +906 -906
  3. package/bin/qwenproxy.js +5 -1
  4. package/package.json +77 -78
  5. package/src/api/error-classifier.ts +159 -159
  6. package/src/api/error-helpers.ts +118 -118
  7. package/src/api/models.ts +261 -261
  8. package/src/api/server.ts +860 -859
  9. package/src/cache/memory-cache.ts +385 -385
  10. package/src/clean-cache.ts +204 -204
  11. package/src/core/account-concurrency.ts +671 -671
  12. package/src/core/account-manager.ts +301 -297
  13. package/src/core/account-priority.ts +163 -163
  14. package/src/core/accounts.ts +186 -186
  15. package/src/core/config.ts +383 -383
  16. package/src/core/crypto-utils.ts +79 -79
  17. package/src/core/database.ts +276 -276
  18. package/src/core/errors.ts +118 -118
  19. package/src/core/logger.ts +269 -269
  20. package/src/core/memory-usage.ts +84 -84
  21. package/src/core/metrics.ts +291 -291
  22. package/src/core/model-alias.ts +77 -77
  23. package/src/core/model-registry.ts +544 -544
  24. package/src/core/mutex.ts +119 -119
  25. package/src/core/paths.ts +199 -199
  26. package/src/core/prompt-limits.ts +214 -214
  27. package/src/core/reasoning-effort.ts +102 -102
  28. package/src/core/stream-registry.ts +96 -96
  29. package/src/core/waf-isolation.ts +117 -117
  30. package/src/core/watchdog.ts +195 -195
  31. package/src/delete-chats.ts +23 -23
  32. package/src/index.ts +65 -64
  33. package/src/login.ts +147 -147
  34. package/src/reset-cooldowns.ts +11 -11
  35. package/src/routes/anthropic/index.ts +355 -355
  36. package/src/routes/anthropic/translate.ts +522 -522
  37. package/src/routes/anthropic/types.ts +154 -154
  38. package/src/routes/anthropic/validation.ts +144 -144
  39. package/src/routes/chat/account.ts +1817 -1817
  40. package/src/routes/chat/context.ts +241 -241
  41. package/src/routes/chat/errors.ts +85 -85
  42. package/src/routes/chat/helpers.ts +268 -268
  43. package/src/routes/chat/index.ts +618 -618
  44. package/src/routes/chat/media.ts +285 -285
  45. package/src/routes/chat/retry-policy.ts +754 -754
  46. package/src/routes/chat/stop.ts +98 -98
  47. package/src/routes/chat/streaming.ts +2710 -2710
  48. package/src/routes/chat/validation.ts +526 -526
  49. package/src/routes/chat.ts +2 -2
  50. package/src/routes/completions.ts +290 -290
  51. package/src/routes/images.ts +139 -139
  52. package/src/routes/responses/adapter.ts +503 -503
  53. package/src/routes/responses/index.ts +405 -405
  54. package/src/routes/responses/state.ts +230 -230
  55. package/src/routes/responses/streaming.ts +528 -528
  56. package/src/routes/responses/types.ts +285 -285
  57. package/src/routes/responses/validation.ts +202 -202
  58. package/src/routes/upload.ts +731 -731
  59. package/src/routes/videos.ts +214 -214
  60. package/src/services/auth-playwright.ts +173 -173
  61. package/src/services/captcha-coordinator.ts +161 -161
  62. package/src/services/captcha-solver.ts +553 -553
  63. package/src/services/chat-cleanup.ts +80 -80
  64. package/src/services/context-meter.ts +317 -317
  65. package/src/services/fingerprint.ts +242 -242
  66. package/src/services/human-behavior.ts +173 -173
  67. package/src/services/media-generation.ts +1748 -1748
  68. package/src/services/playwright.ts +2878 -2800
  69. package/src/services/qwen-chat-pool.ts +345 -345
  70. package/src/services/qwen-errors.ts +133 -133
  71. package/src/services/qwen-headers.ts +79 -79
  72. package/src/services/qwen-thread-state.ts +393 -393
  73. package/src/services/qwen-url.ts +19 -19
  74. package/src/services/qwen.ts +3126 -3126
  75. package/src/services/session-keeper.ts +88 -88
  76. package/src/services/token-estimation-metrics.ts +118 -118
  77. package/src/sync/claude-code.ts +75 -75
  78. package/src/sync/codex.ts +123 -123
  79. package/src/sync/index.ts +362 -362
  80. package/src/sync/omp.ts +105 -105
  81. package/src/sync/opencode.ts +214 -214
  82. package/src/sync/types.ts +53 -53
  83. package/src/sync/utils.ts +27 -27
  84. package/src/sync-clients.ts +189 -189
  85. package/src/tools/instructions.ts +137 -137
  86. package/src/tools/manifest.ts +81 -81
  87. package/src/tools/parser.ts +2989 -2989
  88. package/src/tools/toolcall-tags.ts +142 -142
  89. package/src/tui/app.ts +259 -264
  90. package/src/tui/index.ts +61 -61
  91. package/src/tui/markdown.ts +258 -258
  92. package/src/tui/proxy-client.ts +331 -326
  93. package/src/tui/screen.ts +294 -278
  94. package/src/tui/server-manager.ts +270 -270
  95. package/src/tui/theme.ts +432 -432
  96. package/src/tui/types.ts +33 -33
  97. package/src/tui/views/accounts-view.ts +656 -656
  98. package/src/tui/views/chat-view.ts +1018 -823
  99. package/src/tui/views/logs-view.ts +479 -413
  100. package/src/tui/views/status-view.ts +204 -204
  101. package/src/tui/views/storage-view.ts +304 -291
  102. package/src/tui/views/sync-view.ts +409 -409
  103. package/src/types/ali-oss.d.ts +32 -32
  104. package/src/update-cli.ts +121 -0
  105. package/src/utils/context-truncation.ts +84 -84
  106. package/src/utils/json.ts +380 -380
  107. package/src/utils/session-id.ts +37 -37
  108. package/src/utils/tool-call-guard.ts +84 -84
  109. package/src/utils/types.ts +109 -109
@@ -1,393 +1,393 @@
1
- import { getDatabase } from "../core/database.ts";
2
- import { logger } from "../core/logger.ts";
3
- import { isAuthMockEnabled } from "./auth-playwright.ts";
4
-
5
- interface SessionEntry {
6
- accountId: string;
7
- parentId: string | null;
8
- timestamp: number;
9
- }
10
-
11
- export interface LogicalThreadEntry {
12
- accountId: string;
13
- chatSessionId: string;
14
- parentId: string | null;
15
- instructionsSent: boolean;
16
- timestamp: number;
17
- }
18
-
19
- const sessionStates: Map<string, SessionEntry> =
20
- (globalThis as any)._sessionStates || new Map();
21
- (globalThis as any)._sessionStates = sessionStates;
22
-
23
- // In-memory cache for logical thread states (backed by SQLite)
24
- const logicalThreadStates: Map<string, LogicalThreadEntry> =
25
- (globalThis as any)._logicalThreadStates || new Map();
26
- (globalThis as any)._logicalThreadStates = logicalThreadStates;
27
-
28
- const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
29
-
30
- /**
31
- * Debounce window before dirty logical thread states are upserted to SQLite in
32
- * a single transaction. The tool-call loop calls updateLogicalThreadState on
33
- * every turn (~30+ synchronous writes per conversation otherwise). The
34
- * in-memory cache stays the authoritative source within the process, so reads
35
- * never observe the delay; flushLogicalThreadState() runs on shutdown.
36
- */
37
- const LOGICAL_THREAD_FLUSH_DELAY_MS = 300;
38
-
39
- const logicalThreadDirty: Set<string> =
40
- (globalThis as any)._logicalThreadDirty || new Set();
41
- (globalThis as any)._logicalThreadDirty = logicalThreadDirty;
42
-
43
- // Pending "tool-call cap reached" notices, keyed by logical session id. Set when
44
- // a turn is closed early at the per-turn tool-call cap; consumed by the NEXT
45
- // turn of the same session so the model is told that calls beyond the cap were
46
- // not executed (and it can re-issue them). In-memory only: a restart drops the
47
- // notice, which is acceptable — the model simply continues without the hint.
48
- const toolCapNotices: Map<string, number> =
49
- (globalThis as any)._toolCapNotices || new Map();
50
- (globalThis as any)._toolCapNotices = toolCapNotices;
51
-
52
- /** Record that `logicalSessionId` hit the per-turn tool-call cap this turn. */
53
- export function setToolCapNotice(
54
- logicalSessionId: string | null | undefined,
55
- ): void {
56
- if (!logicalSessionId) return;
57
- toolCapNotices.set(logicalSessionId, Date.now());
58
- }
59
-
60
- /**
61
- * Consume (read + clear) the pending tool-cap notice for `logicalSessionId`.
62
- * Returns true when the previous turn of this session was closed early at the
63
- * cap, so the caller can inject a notice into the current turn's prompt.
64
- */
65
- export function consumeToolCapNotice(
66
- logicalSessionId: string | null | undefined,
67
- ): boolean {
68
- if (!logicalSessionId) return false;
69
- const ts = toolCapNotices.get(logicalSessionId);
70
- if (ts === undefined) return false;
71
- toolCapNotices.delete(logicalSessionId);
72
- return Date.now() - ts <= SESSION_TTL_MS;
73
- }
74
-
75
- let logicalThreadFlushTimer: NodeJS.Timeout | null = null;
76
-
77
- function scheduleLogicalThreadFlush(): void {
78
- if (logicalThreadFlushTimer) return;
79
- logicalThreadFlushTimer = setTimeout(() => {
80
- logicalThreadFlushTimer = null;
81
- flushLogicalThreadState();
82
- }, LOGICAL_THREAD_FLUSH_DELAY_MS);
83
- logicalThreadFlushTimer.unref?.();
84
- }
85
-
86
- /**
87
- * Persist every dirty logical thread state to SQLite in one transaction.
88
- * Exported so the shutdown path can flush before closeDatabase().
89
- */
90
- export function flushLogicalThreadState(): void {
91
- if (logicalThreadFlushTimer) {
92
- clearTimeout(logicalThreadFlushTimer);
93
- logicalThreadFlushTimer = null;
94
- }
95
- if (isAuthMockEnabled()) return;
96
- if (logicalThreadDirty.size === 0) return;
97
-
98
- try {
99
- const db = getDatabase();
100
- const upsert = db.prepare(
101
- `INSERT INTO logical_thread_states (session_id, account_id, chat_session_id, parent_id, instructions_sent, updated_at)
102
- VALUES (?, ?, ?, ?, ?, datetime('now'))
103
- ON CONFLICT(session_id) DO UPDATE SET
104
- account_id = excluded.account_id,
105
- chat_session_id = excluded.chat_session_id,
106
- parent_id = excluded.parent_id,
107
- instructions_sent = excluded.instructions_sent,
108
- updated_at = datetime('now')`,
109
- );
110
- const flush = db.transaction(
111
- (entries: Array<{
112
- sessionId: string;
113
- accountId: string;
114
- chatSessionId: string;
115
- parentId: string | null;
116
- instructionsSent: number;
117
- }>) => {
118
- for (const e of entries) {
119
- upsert.run(
120
- e.sessionId,
121
- e.accountId,
122
- e.chatSessionId,
123
- e.parentId,
124
- e.instructionsSent,
125
- );
126
- }
127
- },
128
- );
129
-
130
- const entries: Array<{
131
- sessionId: string;
132
- accountId: string;
133
- chatSessionId: string;
134
- parentId: string | null;
135
- instructionsSent: number;
136
- }> = [];
137
- for (const sessionId of logicalThreadDirty) {
138
- const entry = logicalThreadStates.get(sessionId);
139
- // Entries evicted from the cache (clearAllSessionsForAccount / TTL) must
140
- // NOT be resurrected — the deletion already happened in the cache.
141
- if (!entry || !entry.chatSessionId) continue;
142
- entries.push({
143
- sessionId,
144
- accountId: entry.accountId,
145
- chatSessionId: entry.chatSessionId,
146
- parentId: entry.parentId ?? null,
147
- instructionsSent: entry.instructionsSent ? 1 : 0,
148
- });
149
- }
150
-
151
- if (entries.length > 0) flush(entries);
152
- logicalThreadDirty.clear();
153
- } catch (err) {
154
- logger.warn(
155
- "[Qwen] Failed to batch-persist logical thread states to SQLite",
156
- {
157
- count: logicalThreadDirty.size,
158
- error: err instanceof Error ? err.message : String(err),
159
- },
160
- );
161
- logicalThreadDirty.clear();
162
- }
163
- }
164
-
165
- function cleanupStaleSessions() {
166
- const now = Date.now();
167
- for (const [key, entry] of sessionStates.entries()) {
168
- if (now - entry.timestamp > SESSION_TTL_MS) {
169
- sessionStates.delete(key);
170
- }
171
- }
172
- // Cleanup stale entries from SQLite
173
- try {
174
- const db = getDatabase();
175
- const cutoff = new Date(now - SESSION_TTL_MS).toISOString();
176
- db.prepare("DELETE FROM logical_thread_states WHERE updated_at < ?").run(
177
- cutoff,
178
- );
179
- } catch (error) {
180
- logger.warn("Failed to clean up stale logical thread states", { error });
181
- }
182
- for (const [key, entry] of logicalThreadStates.entries()) {
183
- if (now - entry.timestamp > SESSION_TTL_MS) {
184
- logicalThreadStates.delete(key);
185
- logicalThreadDirty.delete(key);
186
- }
187
- }
188
- }
189
-
190
- export function getLogicalThreadState(
191
- logicalSessionId: string | null | undefined,
192
- ): LogicalThreadEntry | null {
193
- if (!logicalSessionId) return null;
194
-
195
- // Check in-memory cache first
196
- const cached = logicalThreadStates.get(logicalSessionId);
197
- if (cached && Date.now() - cached.timestamp <= SESSION_TTL_MS) {
198
- return cached;
199
- }
200
- if (cached) {
201
- logicalThreadStates.delete(logicalSessionId);
202
- }
203
-
204
- if (isAuthMockEnabled()) return null;
205
-
206
- // Fallback to SQLite
207
- try {
208
- const db = getDatabase();
209
- const row = db
210
- .prepare(
211
- "SELECT session_id, account_id, chat_session_id, parent_id, instructions_sent, updated_at FROM logical_thread_states WHERE session_id = ?",
212
- )
213
- .get(logicalSessionId) as
214
- | {
215
- session_id: string;
216
- account_id: string;
217
- chat_session_id: string;
218
- parent_id: string | null;
219
- instructions_sent: number;
220
- updated_at: string;
221
- }
222
- | undefined;
223
-
224
- if (!row) return null;
225
-
226
- const timestamp = new Date(row.updated_at).getTime();
227
- if (Date.now() - timestamp > SESSION_TTL_MS) {
228
- db.prepare("DELETE FROM logical_thread_states WHERE session_id = ?").run(
229
- logicalSessionId,
230
- );
231
- return null;
232
- }
233
-
234
- const entry: LogicalThreadEntry = {
235
- accountId: row.account_id,
236
- chatSessionId: row.chat_session_id,
237
- parentId: row.parent_id,
238
- instructionsSent: row.instructions_sent === 1,
239
- timestamp,
240
- };
241
-
242
- // Populate in-memory cache
243
- logicalThreadStates.set(logicalSessionId, entry);
244
- return entry;
245
- } catch (err) {
246
- logger.warn("[Qwen] Failed to read logical thread from SQLite", {
247
- sessionId: logicalSessionId,
248
- error: err instanceof Error ? err.message : String(err),
249
- });
250
- return null;
251
- }
252
- }
253
-
254
- export function updateLogicalThreadState(
255
- logicalSessionId: string,
256
- entry: Omit<LogicalThreadEntry, "timestamp" | "instructionsSent"> & {
257
- instructionsSent?: boolean;
258
- },
259
- ): void {
260
- if (
261
- !logicalSessionId ||
262
- entry.chatSessionId === undefined ||
263
- entry.chatSessionId === null
264
- )
265
- return;
266
- if (logicalThreadStates.size > 10000) cleanupStaleSessions();
267
- const existing = logicalThreadStates.get(logicalSessionId);
268
- const merged = {
269
- ...entry,
270
- instructionsSent:
271
- entry.instructionsSent ?? existing?.instructionsSent ?? false,
272
- timestamp: Date.now(),
273
- };
274
-
275
- // Update in-memory cache
276
- logicalThreadStates.set(logicalSessionId, merged);
277
-
278
- if (isAuthMockEnabled()) return;
279
-
280
- // Persist to SQLite with a short debounce: turns in the tool-call loop
281
- // update this on every stream creation, and coalescing them into one
282
- // transaction removes ~30+ synchronous writes per conversation. Reads stay
283
- // cache-first, so runtime behavior is unchanged; the flush runs on shutdown.
284
- logicalThreadDirty.add(logicalSessionId);
285
- scheduleLogicalThreadFlush();
286
- }
287
-
288
- export function updateLogicalThreadParent(
289
- logicalSessionId: string | null | undefined,
290
- parentId: string | null,
291
- accountId: string,
292
- chatSessionId: string,
293
- ): void {
294
- if (!logicalSessionId || !chatSessionId) return;
295
- updateLogicalThreadState(logicalSessionId, {
296
- accountId,
297
- chatSessionId,
298
- parentId,
299
- instructionsSent: true,
300
- });
301
- }
302
-
303
- export function updateSessionParent(
304
- sessionId: string,
305
- parentId: string | null,
306
- accountId?: string,
307
- ) {
308
- if (!sessionId) return;
309
-
310
- if (sessionStates.size > 10000) {
311
- cleanupStaleSessions();
312
- }
313
-
314
- const existing = sessionStates.get(sessionId);
315
- sessionStates.set(sessionId, {
316
- accountId: accountId || existing?.accountId || "global",
317
- parentId,
318
- timestamp: Date.now(),
319
- });
320
- }
321
-
322
- /**
323
- * Invalidate the stored parent for a logical thread without forgetting the
324
- * upstream chat binding. The next request will see a missing parent and must
325
- * rebuild the upstream chat with full context instead of appending to a
326
- * possibly corrupted parent chain.
327
- */
328
- export function invalidateLogicalThreadParent(
329
- logicalSessionId: string | null | undefined,
330
- ): void {
331
- if (!logicalSessionId) return;
332
-
333
- const existing = getLogicalThreadState(logicalSessionId);
334
- if (!existing) return;
335
-
336
- updateSessionParent(existing.chatSessionId, null, existing.accountId);
337
- updateLogicalThreadState(logicalSessionId, {
338
- accountId: existing.accountId,
339
- chatSessionId: existing.chatSessionId,
340
- parentId: null,
341
- instructionsSent: existing.instructionsSent,
342
- });
343
- }
344
-
345
- export function clearAllSessionsForAccount(accountId: string): void {
346
- let removed = 0;
347
-
348
- for (const [key, entry] of sessionStates.entries()) {
349
- if (entry.accountId === accountId) {
350
- sessionStates.delete(key);
351
- removed++;
352
- }
353
- }
354
-
355
- for (const [key, entry] of logicalThreadStates.entries()) {
356
- if (entry.accountId === accountId) {
357
- logicalThreadStates.delete(key);
358
- // Drop pending writes too, so a later debounce flush cannot resurrect
359
- // the just-deleted row.
360
- logicalThreadDirty.delete(key);
361
- removed++;
362
- }
363
- }
364
-
365
- // Also clear from SQLite
366
- try {
367
- const db = getDatabase();
368
- const result = db
369
- .prepare("DELETE FROM logical_thread_states WHERE account_id = ?")
370
- .run(accountId);
371
- removed += result.changes;
372
- } catch {}
373
-
374
- console.log(
375
- `🧹 [Qwen] Cleared ${removed} session(s) for account ${accountId}`,
376
- );
377
- }
378
-
379
- export function getSessionParent(
380
- sessionId: string,
381
- accountId?: string,
382
- ): string | null | undefined {
383
- const entry = sessionStates.get(sessionId);
384
- if (!entry) return undefined;
385
- if (Date.now() - entry.timestamp > SESSION_TTL_MS) {
386
- sessionStates.delete(sessionId);
387
- return undefined;
388
- }
389
- if (accountId && entry.accountId !== accountId) {
390
- return undefined;
391
- }
392
- return entry.parentId;
393
- }
1
+ import { getDatabase } from "../core/database.ts";
2
+ import { logger } from "../core/logger.ts";
3
+ import { isAuthMockEnabled } from "./auth-playwright.ts";
4
+
5
+ interface SessionEntry {
6
+ accountId: string;
7
+ parentId: string | null;
8
+ timestamp: number;
9
+ }
10
+
11
+ export interface LogicalThreadEntry {
12
+ accountId: string;
13
+ chatSessionId: string;
14
+ parentId: string | null;
15
+ instructionsSent: boolean;
16
+ timestamp: number;
17
+ }
18
+
19
+ const sessionStates: Map<string, SessionEntry> =
20
+ (globalThis as any)._sessionStates || new Map();
21
+ (globalThis as any)._sessionStates = sessionStates;
22
+
23
+ // In-memory cache for logical thread states (backed by SQLite)
24
+ const logicalThreadStates: Map<string, LogicalThreadEntry> =
25
+ (globalThis as any)._logicalThreadStates || new Map();
26
+ (globalThis as any)._logicalThreadStates = logicalThreadStates;
27
+
28
+ const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
29
+
30
+ /**
31
+ * Debounce window before dirty logical thread states are upserted to SQLite in
32
+ * a single transaction. The tool-call loop calls updateLogicalThreadState on
33
+ * every turn (~30+ synchronous writes per conversation otherwise). The
34
+ * in-memory cache stays the authoritative source within the process, so reads
35
+ * never observe the delay; flushLogicalThreadState() runs on shutdown.
36
+ */
37
+ const LOGICAL_THREAD_FLUSH_DELAY_MS = 300;
38
+
39
+ const logicalThreadDirty: Set<string> =
40
+ (globalThis as any)._logicalThreadDirty || new Set();
41
+ (globalThis as any)._logicalThreadDirty = logicalThreadDirty;
42
+
43
+ // Pending "tool-call cap reached" notices, keyed by logical session id. Set when
44
+ // a turn is closed early at the per-turn tool-call cap; consumed by the NEXT
45
+ // turn of the same session so the model is told that calls beyond the cap were
46
+ // not executed (and it can re-issue them). In-memory only: a restart drops the
47
+ // notice, which is acceptable — the model simply continues without the hint.
48
+ const toolCapNotices: Map<string, number> =
49
+ (globalThis as any)._toolCapNotices || new Map();
50
+ (globalThis as any)._toolCapNotices = toolCapNotices;
51
+
52
+ /** Record that `logicalSessionId` hit the per-turn tool-call cap this turn. */
53
+ export function setToolCapNotice(
54
+ logicalSessionId: string | null | undefined,
55
+ ): void {
56
+ if (!logicalSessionId) return;
57
+ toolCapNotices.set(logicalSessionId, Date.now());
58
+ }
59
+
60
+ /**
61
+ * Consume (read + clear) the pending tool-cap notice for `logicalSessionId`.
62
+ * Returns true when the previous turn of this session was closed early at the
63
+ * cap, so the caller can inject a notice into the current turn's prompt.
64
+ */
65
+ export function consumeToolCapNotice(
66
+ logicalSessionId: string | null | undefined,
67
+ ): boolean {
68
+ if (!logicalSessionId) return false;
69
+ const ts = toolCapNotices.get(logicalSessionId);
70
+ if (ts === undefined) return false;
71
+ toolCapNotices.delete(logicalSessionId);
72
+ return Date.now() - ts <= SESSION_TTL_MS;
73
+ }
74
+
75
+ let logicalThreadFlushTimer: NodeJS.Timeout | null = null;
76
+
77
+ function scheduleLogicalThreadFlush(): void {
78
+ if (logicalThreadFlushTimer) return;
79
+ logicalThreadFlushTimer = setTimeout(() => {
80
+ logicalThreadFlushTimer = null;
81
+ flushLogicalThreadState();
82
+ }, LOGICAL_THREAD_FLUSH_DELAY_MS);
83
+ logicalThreadFlushTimer.unref?.();
84
+ }
85
+
86
+ /**
87
+ * Persist every dirty logical thread state to SQLite in one transaction.
88
+ * Exported so the shutdown path can flush before closeDatabase().
89
+ */
90
+ export function flushLogicalThreadState(): void {
91
+ if (logicalThreadFlushTimer) {
92
+ clearTimeout(logicalThreadFlushTimer);
93
+ logicalThreadFlushTimer = null;
94
+ }
95
+ if (isAuthMockEnabled()) return;
96
+ if (logicalThreadDirty.size === 0) return;
97
+
98
+ try {
99
+ const db = getDatabase();
100
+ const upsert = db.prepare(
101
+ `INSERT INTO logical_thread_states (session_id, account_id, chat_session_id, parent_id, instructions_sent, updated_at)
102
+ VALUES (?, ?, ?, ?, ?, datetime('now'))
103
+ ON CONFLICT(session_id) DO UPDATE SET
104
+ account_id = excluded.account_id,
105
+ chat_session_id = excluded.chat_session_id,
106
+ parent_id = excluded.parent_id,
107
+ instructions_sent = excluded.instructions_sent,
108
+ updated_at = datetime('now')`,
109
+ );
110
+ const flush = db.transaction(
111
+ (entries: Array<{
112
+ sessionId: string;
113
+ accountId: string;
114
+ chatSessionId: string;
115
+ parentId: string | null;
116
+ instructionsSent: number;
117
+ }>) => {
118
+ for (const e of entries) {
119
+ upsert.run(
120
+ e.sessionId,
121
+ e.accountId,
122
+ e.chatSessionId,
123
+ e.parentId,
124
+ e.instructionsSent,
125
+ );
126
+ }
127
+ },
128
+ );
129
+
130
+ const entries: Array<{
131
+ sessionId: string;
132
+ accountId: string;
133
+ chatSessionId: string;
134
+ parentId: string | null;
135
+ instructionsSent: number;
136
+ }> = [];
137
+ for (const sessionId of logicalThreadDirty) {
138
+ const entry = logicalThreadStates.get(sessionId);
139
+ // Entries evicted from the cache (clearAllSessionsForAccount / TTL) must
140
+ // NOT be resurrected — the deletion already happened in the cache.
141
+ if (!entry || !entry.chatSessionId) continue;
142
+ entries.push({
143
+ sessionId,
144
+ accountId: entry.accountId,
145
+ chatSessionId: entry.chatSessionId,
146
+ parentId: entry.parentId ?? null,
147
+ instructionsSent: entry.instructionsSent ? 1 : 0,
148
+ });
149
+ }
150
+
151
+ if (entries.length > 0) flush(entries);
152
+ logicalThreadDirty.clear();
153
+ } catch (err) {
154
+ logger.warn(
155
+ "[Qwen] Failed to batch-persist logical thread states to SQLite",
156
+ {
157
+ count: logicalThreadDirty.size,
158
+ error: err instanceof Error ? err.message : String(err),
159
+ },
160
+ );
161
+ logicalThreadDirty.clear();
162
+ }
163
+ }
164
+
165
+ function cleanupStaleSessions() {
166
+ const now = Date.now();
167
+ for (const [key, entry] of sessionStates.entries()) {
168
+ if (now - entry.timestamp > SESSION_TTL_MS) {
169
+ sessionStates.delete(key);
170
+ }
171
+ }
172
+ // Cleanup stale entries from SQLite
173
+ try {
174
+ const db = getDatabase();
175
+ const cutoff = new Date(now - SESSION_TTL_MS).toISOString();
176
+ db.prepare("DELETE FROM logical_thread_states WHERE updated_at < ?").run(
177
+ cutoff,
178
+ );
179
+ } catch (error) {
180
+ logger.warn("Failed to clean up stale logical thread states", { error });
181
+ }
182
+ for (const [key, entry] of logicalThreadStates.entries()) {
183
+ if (now - entry.timestamp > SESSION_TTL_MS) {
184
+ logicalThreadStates.delete(key);
185
+ logicalThreadDirty.delete(key);
186
+ }
187
+ }
188
+ }
189
+
190
+ export function getLogicalThreadState(
191
+ logicalSessionId: string | null | undefined,
192
+ ): LogicalThreadEntry | null {
193
+ if (!logicalSessionId) return null;
194
+
195
+ // Check in-memory cache first
196
+ const cached = logicalThreadStates.get(logicalSessionId);
197
+ if (cached && Date.now() - cached.timestamp <= SESSION_TTL_MS) {
198
+ return cached;
199
+ }
200
+ if (cached) {
201
+ logicalThreadStates.delete(logicalSessionId);
202
+ }
203
+
204
+ if (isAuthMockEnabled()) return null;
205
+
206
+ // Fallback to SQLite
207
+ try {
208
+ const db = getDatabase();
209
+ const row = db
210
+ .prepare(
211
+ "SELECT session_id, account_id, chat_session_id, parent_id, instructions_sent, updated_at FROM logical_thread_states WHERE session_id = ?",
212
+ )
213
+ .get(logicalSessionId) as
214
+ | {
215
+ session_id: string;
216
+ account_id: string;
217
+ chat_session_id: string;
218
+ parent_id: string | null;
219
+ instructions_sent: number;
220
+ updated_at: string;
221
+ }
222
+ | undefined;
223
+
224
+ if (!row) return null;
225
+
226
+ const timestamp = new Date(row.updated_at).getTime();
227
+ if (Date.now() - timestamp > SESSION_TTL_MS) {
228
+ db.prepare("DELETE FROM logical_thread_states WHERE session_id = ?").run(
229
+ logicalSessionId,
230
+ );
231
+ return null;
232
+ }
233
+
234
+ const entry: LogicalThreadEntry = {
235
+ accountId: row.account_id,
236
+ chatSessionId: row.chat_session_id,
237
+ parentId: row.parent_id,
238
+ instructionsSent: row.instructions_sent === 1,
239
+ timestamp,
240
+ };
241
+
242
+ // Populate in-memory cache
243
+ logicalThreadStates.set(logicalSessionId, entry);
244
+ return entry;
245
+ } catch (err) {
246
+ logger.warn("[Qwen] Failed to read logical thread from SQLite", {
247
+ sessionId: logicalSessionId,
248
+ error: err instanceof Error ? err.message : String(err),
249
+ });
250
+ return null;
251
+ }
252
+ }
253
+
254
+ export function updateLogicalThreadState(
255
+ logicalSessionId: string,
256
+ entry: Omit<LogicalThreadEntry, "timestamp" | "instructionsSent"> & {
257
+ instructionsSent?: boolean;
258
+ },
259
+ ): void {
260
+ if (
261
+ !logicalSessionId ||
262
+ entry.chatSessionId === undefined ||
263
+ entry.chatSessionId === null
264
+ )
265
+ return;
266
+ if (logicalThreadStates.size > 10000) cleanupStaleSessions();
267
+ const existing = logicalThreadStates.get(logicalSessionId);
268
+ const merged = {
269
+ ...entry,
270
+ instructionsSent:
271
+ entry.instructionsSent ?? existing?.instructionsSent ?? false,
272
+ timestamp: Date.now(),
273
+ };
274
+
275
+ // Update in-memory cache
276
+ logicalThreadStates.set(logicalSessionId, merged);
277
+
278
+ if (isAuthMockEnabled()) return;
279
+
280
+ // Persist to SQLite with a short debounce: turns in the tool-call loop
281
+ // update this on every stream creation, and coalescing them into one
282
+ // transaction removes ~30+ synchronous writes per conversation. Reads stay
283
+ // cache-first, so runtime behavior is unchanged; the flush runs on shutdown.
284
+ logicalThreadDirty.add(logicalSessionId);
285
+ scheduleLogicalThreadFlush();
286
+ }
287
+
288
+ export function updateLogicalThreadParent(
289
+ logicalSessionId: string | null | undefined,
290
+ parentId: string | null,
291
+ accountId: string,
292
+ chatSessionId: string,
293
+ ): void {
294
+ if (!logicalSessionId || !chatSessionId) return;
295
+ updateLogicalThreadState(logicalSessionId, {
296
+ accountId,
297
+ chatSessionId,
298
+ parentId,
299
+ instructionsSent: true,
300
+ });
301
+ }
302
+
303
+ export function updateSessionParent(
304
+ sessionId: string,
305
+ parentId: string | null,
306
+ accountId?: string,
307
+ ) {
308
+ if (!sessionId) return;
309
+
310
+ if (sessionStates.size > 10000) {
311
+ cleanupStaleSessions();
312
+ }
313
+
314
+ const existing = sessionStates.get(sessionId);
315
+ sessionStates.set(sessionId, {
316
+ accountId: accountId || existing?.accountId || "global",
317
+ parentId,
318
+ timestamp: Date.now(),
319
+ });
320
+ }
321
+
322
+ /**
323
+ * Invalidate the stored parent for a logical thread without forgetting the
324
+ * upstream chat binding. The next request will see a missing parent and must
325
+ * rebuild the upstream chat with full context instead of appending to a
326
+ * possibly corrupted parent chain.
327
+ */
328
+ export function invalidateLogicalThreadParent(
329
+ logicalSessionId: string | null | undefined,
330
+ ): void {
331
+ if (!logicalSessionId) return;
332
+
333
+ const existing = getLogicalThreadState(logicalSessionId);
334
+ if (!existing) return;
335
+
336
+ updateSessionParent(existing.chatSessionId, null, existing.accountId);
337
+ updateLogicalThreadState(logicalSessionId, {
338
+ accountId: existing.accountId,
339
+ chatSessionId: existing.chatSessionId,
340
+ parentId: null,
341
+ instructionsSent: existing.instructionsSent,
342
+ });
343
+ }
344
+
345
+ export function clearAllSessionsForAccount(accountId: string): void {
346
+ let removed = 0;
347
+
348
+ for (const [key, entry] of sessionStates.entries()) {
349
+ if (entry.accountId === accountId) {
350
+ sessionStates.delete(key);
351
+ removed++;
352
+ }
353
+ }
354
+
355
+ for (const [key, entry] of logicalThreadStates.entries()) {
356
+ if (entry.accountId === accountId) {
357
+ logicalThreadStates.delete(key);
358
+ // Drop pending writes too, so a later debounce flush cannot resurrect
359
+ // the just-deleted row.
360
+ logicalThreadDirty.delete(key);
361
+ removed++;
362
+ }
363
+ }
364
+
365
+ // Also clear from SQLite
366
+ try {
367
+ const db = getDatabase();
368
+ const result = db
369
+ .prepare("DELETE FROM logical_thread_states WHERE account_id = ?")
370
+ .run(accountId);
371
+ removed += result.changes;
372
+ } catch {}
373
+
374
+ console.log(
375
+ `🧹 [Qwen] Cleared ${removed} session(s) for account ${accountId}`,
376
+ );
377
+ }
378
+
379
+ export function getSessionParent(
380
+ sessionId: string,
381
+ accountId?: string,
382
+ ): string | null | undefined {
383
+ const entry = sessionStates.get(sessionId);
384
+ if (!entry) return undefined;
385
+ if (Date.now() - entry.timestamp > SESSION_TTL_MS) {
386
+ sessionStates.delete(sessionId);
387
+ return undefined;
388
+ }
389
+ if (accountId && entry.accountId !== accountId) {
390
+ return undefined;
391
+ }
392
+ return entry.parentId;
393
+ }