dsclaw 0.1.4 → 0.1.6

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/cli/index.js CHANGED
@@ -1,24 +1,12 @@
1
1
  #!/usr/bin/env node
2
-
3
- // src/cli/index.ts
4
- import { Command } from "commander";
5
-
6
- // src/cli/init.ts
7
- import inquirer from "inquirer";
8
-
9
- // src/gateway/config.ts
10
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
11
- import { join } from "path";
12
- import { homedir } from "os";
13
- import { z } from "zod";
14
-
15
- // src/shared/logger.ts
16
- import pino from "pino";
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __esm = (fn, res) => function __init() {
4
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
+ };
17
6
 
18
7
  // src/shared/tracer.ts
19
8
  import { AsyncLocalStorage } from "async_hooks";
20
9
  import { nanoid } from "nanoid";
21
- var storage = new AsyncLocalStorage();
22
10
  function runWithTrace(ctx, fn) {
23
11
  const traceId = ctx.traceId ?? nanoid(12);
24
12
  return storage.run({ traceId, ...ctx }, fn);
@@ -29,26 +17,52 @@ function getTraceContext() {
29
17
  function getTraceId() {
30
18
  return getTraceContext().traceId;
31
19
  }
32
-
33
- // src/shared/logger.ts
34
- var rootLogger = pino({
35
- level: process.env["LOG_LEVEL"] ?? "info",
36
- transport: process.env["NODE_ENV"] !== "production" ? { target: "pino-pretty", options: { colorize: true } } : void 0,
37
- mixin() {
38
- const ctx = getTraceContext();
39
- return {
40
- traceId: ctx.traceId,
41
- ...ctx.userId ? { userId: ctx.userId } : {},
42
- ...ctx.channelId ? { channelId: ctx.channelId } : {},
43
- ...ctx.agentId ? { agentId: ctx.agentId } : {}
44
- };
20
+ var storage;
21
+ var init_tracer = __esm({
22
+ "src/shared/tracer.ts"() {
23
+ "use strict";
24
+ storage = new AsyncLocalStorage();
45
25
  }
46
26
  });
27
+
28
+ // src/shared/logger.ts
29
+ import pino from "pino";
47
30
  function createLogger(module) {
48
31
  return rootLogger.child({ module });
49
32
  }
33
+ var rootLogger;
34
+ var init_logger = __esm({
35
+ "src/shared/logger.ts"() {
36
+ "use strict";
37
+ init_tracer();
38
+ rootLogger = pino({
39
+ level: process.env["LOG_LEVEL"] ?? "info",
40
+ transport: process.env["NODE_ENV"] !== "production" ? { target: "pino-pretty", options: { colorize: true } } : void 0,
41
+ mixin() {
42
+ const ctx = getTraceContext();
43
+ return {
44
+ traceId: ctx.traceId,
45
+ ...ctx.userId ? { userId: ctx.userId } : {},
46
+ ...ctx.channelId ? { channelId: ctx.channelId } : {},
47
+ ...ctx.agentId ? { agentId: ctx.agentId } : {}
48
+ };
49
+ }
50
+ });
51
+ }
52
+ });
53
+
54
+ // src/cli/index.ts
55
+ import { Command } from "commander";
56
+
57
+ // src/cli/init.ts
58
+ import inquirer from "inquirer";
50
59
 
51
60
  // src/gateway/config.ts
61
+ init_logger();
62
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
63
+ import { join } from "path";
64
+ import { homedir } from "os";
65
+ import { z } from "zod";
52
66
  var log = createLogger("config");
53
67
  var CONFIG_DIR = join(homedir(), ".dsclaw");
54
68
  var CONFIG_PATH = join(CONFIG_DIR, "config.json");
@@ -150,20 +164,49 @@ import {
150
164
  } from "fs";
151
165
  import { join as join2 } from "path";
152
166
  import { hostname, userInfo } from "os";
167
+ init_logger();
153
168
  var log2 = createLogger("user-session");
154
169
  var ALG = "aes-256-gcm";
155
170
  var IV_LEN = 12;
156
171
  var TAG_LEN = 16;
157
172
  var KEY_SEED = "dsclaw-v1";
158
173
  var SESSIONS_DIR = join2(CONFIG_DIR, "sessions");
159
- function deriveKey() {
174
+ var SALT_FILE = join2(CONFIG_DIR, ".salt");
175
+ function getOrCreateSalt() {
176
+ try {
177
+ if (existsSync2(SALT_FILE)) {
178
+ return readFileSync2(SALT_FILE, "utf-8").trim();
179
+ }
180
+ } catch {
181
+ }
182
+ const salt = randomBytes(32).toString("hex");
183
+ try {
184
+ mkdirSync2(CONFIG_DIR, { recursive: true });
185
+ writeFileSync2(SALT_FILE, salt, { mode: 384 });
186
+ } catch (err) {
187
+ log2.warn({ err }, "Failed to persist salt file");
188
+ }
189
+ return salt;
190
+ }
191
+ var cachedSalt = null;
192
+ function getSalt() {
193
+ if (!cachedSalt) cachedSalt = getOrCreateSalt();
194
+ return cachedSalt;
195
+ }
196
+ function deriveKeyBase(extra) {
160
197
  let user = "";
161
198
  try {
162
199
  user = userInfo().username;
163
200
  } catch {
164
201
  user = process.env["USER"] ?? process.env["USERNAME"] ?? "default";
165
202
  }
166
- return createHash("sha256").update(`${KEY_SEED}:${hostname()}:${user}`).digest();
203
+ return createHash("sha256").update(`${KEY_SEED}:${hostname()}:${user}${extra}`).digest();
204
+ }
205
+ function deriveKey() {
206
+ return deriveKeyBase(`:${getSalt()}`);
207
+ }
208
+ function deriveKeyLegacy() {
209
+ return deriveKeyBase("");
167
210
  }
168
211
  function encrypt(data) {
169
212
  const key = deriveKey();
@@ -173,9 +216,8 @@ function encrypt(data) {
173
216
  const tag = cipher.getAuthTag();
174
217
  return Buffer.concat([iv, tag, ct]).toString("base64");
175
218
  }
176
- function decrypt(encoded) {
219
+ function decryptWithKey(encoded, key) {
177
220
  try {
178
- const key = deriveKey();
179
221
  const buf = Buffer.from(encoded, "base64");
180
222
  if (buf.length < IV_LEN + TAG_LEN + 1) return null;
181
223
  const iv = buf.subarray(0, IV_LEN);
@@ -183,9 +225,7 @@ function decrypt(encoded) {
183
225
  const ct = buf.subarray(IV_LEN + TAG_LEN);
184
226
  const decipher = createDecipheriv(ALG, key, iv, { authTagLength: TAG_LEN });
185
227
  decipher.setAuthTag(tag);
186
- return Buffer.concat([decipher.update(ct), decipher.final()]).toString(
187
- "utf8"
188
- );
228
+ return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
189
229
  } catch {
190
230
  return null;
191
231
  }
@@ -206,12 +246,22 @@ function loadSession(userId) {
206
246
  }
207
247
  try {
208
248
  const encrypted = readFileSync2(p, "utf-8").trim();
209
- const json = decrypt(encrypted);
249
+ let json = decryptWithKey(encrypted, deriveKey());
250
+ let needsMigration = false;
251
+ if (!json) {
252
+ json = decryptWithKey(encrypted, deriveKeyLegacy());
253
+ needsMigration = !!json;
254
+ }
210
255
  if (!json) {
211
256
  log2.warn({ userId }, "Failed to decrypt session \u2014 starting fresh");
212
257
  return { state: "new" };
213
258
  }
214
- return JSON.parse(json);
259
+ const session = JSON.parse(json);
260
+ if (needsMigration) {
261
+ log2.info({ userId }, "Migrating session to salted key");
262
+ saveSession(userId, session);
263
+ }
264
+ return session;
215
265
  } catch {
216
266
  return { state: "new" };
217
267
  }
@@ -228,6 +278,7 @@ function isOnboarding(state) {
228
278
  }
229
279
 
230
280
  // src/dsers/auth.ts
281
+ init_logger();
231
282
  import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, existsSync as existsSync3, chmodSync } from "fs";
232
283
  import { dirname } from "path";
233
284
 
@@ -353,6 +404,60 @@ var DSersAuth = class {
353
404
  this.fetchedAt = 0;
354
405
  log3.debug("Session invalidated");
355
406
  }
407
+ /** Fast session validity check — calls DSers account info endpoint. */
408
+ async checkHealth() {
409
+ if (!this.sessionId) {
410
+ return { valid: false, reason: "\u672A\u6388\u6743\uFF08\u65E0 session\uFF09" };
411
+ }
412
+ if (Date.now() - this.fetchedAt > SESSION_TTL) {
413
+ return { valid: false, reason: "session \u5DF2\u8FC7\u671F\uFF08\u8D85\u8FC7 6 \u5C0F\u65F6\uFF09" };
414
+ }
415
+ try {
416
+ const resp = await fetch(
417
+ `${this.config.baseUrl}/account-user-bff/v1/users/current`,
418
+ {
419
+ headers: {
420
+ Cookie: `sessionid=${this.sessionId}; djdt=hide; state=${this.state}`
421
+ }
422
+ }
423
+ );
424
+ if (resp.status === 401 || resp.status === 403) {
425
+ this.invalidate();
426
+ return { valid: false, reason: `DSers \u8FD4\u56DE ${resp.status}\uFF08token \u5931\u6548\uFF09\uFF0C\u8BF7\u91CD\u65B0\u6388\u6743` };
427
+ }
428
+ if (!resp.ok) {
429
+ return { valid: false, reason: `DSers \u670D\u52A1\u5F02\u5E38\uFF08HTTP ${resp.status}\uFF09` };
430
+ }
431
+ return { valid: true };
432
+ } catch (err) {
433
+ const reason = err instanceof Error ? err.message : "unknown";
434
+ return { valid: false, reason: `\u7F51\u7EDC\u9519\u8BEF\uFF08${reason}\uFF09\uFF0C\u8BF7\u68C0\u67E5\u7F51\u7EDC\u8FDE\u63A5` };
435
+ }
436
+ }
437
+ /**
438
+ * Attempt session renewal for session-only users (no email/password).
439
+ * Returns success if the session is still valid; fails with guidance otherwise.
440
+ */
441
+ async tryRenew() {
442
+ const health = await this.checkHealth();
443
+ if (health.valid) {
444
+ this.fetchedAt = Date.now();
445
+ return { renewed: true };
446
+ }
447
+ if (this.config.email && this.config.password) {
448
+ try {
449
+ await this.login();
450
+ return { renewed: true };
451
+ } catch (err) {
452
+ const reason = err instanceof Error ? err.message : "\u767B\u5F55\u5931\u8D25";
453
+ return { renewed: false, reason };
454
+ }
455
+ }
456
+ return {
457
+ renewed: false,
458
+ reason: `${health.reason}\u3002\u5F53\u524D\u4E3A session \u6A21\u5F0F\uFF0C\u9700\u8981\u901A\u8FC7\u6D4F\u89C8\u5668\u91CD\u65B0\u6388\u6743\u3002`
459
+ };
460
+ }
356
461
  readCache() {
357
462
  try {
358
463
  const p = this.config.sessionFile;
@@ -385,6 +490,10 @@ var DSersAuth = class {
385
490
  }
386
491
  };
387
492
 
493
+ // src/dsers/client.ts
494
+ init_logger();
495
+ init_tracer();
496
+
388
497
  // src/shared/utils.ts
389
498
  import { createHash as createHash2 } from "crypto";
390
499
  function sleep(ms) {
@@ -400,6 +509,7 @@ function parseRetryAfter(value) {
400
509
  }
401
510
 
402
511
  // src/resilience/rate-limiter.ts
512
+ init_logger();
403
513
  import pLimit from "p-limit";
404
514
  var log4 = createLogger("rate-limiter");
405
515
  var DEFAULT_OUTBOUND_LIMITS = {
@@ -417,20 +527,40 @@ function getOutboundLimiter(service, customLimits) {
417
527
  }
418
528
  return outboundLimiters.get(service);
419
529
  }
420
- var userQueues = /* @__PURE__ */ new Map();
530
+ var userLocks = /* @__PURE__ */ new Map();
531
+ var lockVersion = 0;
421
532
  var debounceTimers = /* @__PURE__ */ new Map();
422
- async function acquireUserLock(userId) {
423
- while (userQueues.has(userId)) {
424
- await userQueues.get(userId);
425
- }
426
- let release;
427
- const lock = new Promise((resolve) => {
428
- release = () => {
429
- userQueues.delete(userId);
430
- resolve();
431
- };
533
+ async function acquireUserLock(userId, timeoutMs = 18e4) {
534
+ const deadline = Date.now() + timeoutMs;
535
+ while (userLocks.has(userId)) {
536
+ const remaining = deadline - Date.now();
537
+ if (remaining <= 0) {
538
+ log4.warn({ userId }, "User lock wait timed out \u2014 forcing entry");
539
+ const stale = userLocks.get(userId);
540
+ if (stale) {
541
+ stale.resolve();
542
+ }
543
+ userLocks.delete(userId);
544
+ break;
545
+ }
546
+ await Promise.race([
547
+ userLocks.get(userId).promise,
548
+ new Promise((r) => setTimeout(r, remaining))
549
+ ]);
550
+ }
551
+ const myVersion = ++lockVersion;
552
+ let resolve;
553
+ const promise = new Promise((r) => {
554
+ resolve = r;
432
555
  });
433
- userQueues.set(userId, lock);
556
+ userLocks.set(userId, { promise, resolve, version: myVersion });
557
+ const release = () => {
558
+ const current = userLocks.get(userId);
559
+ if (current && current.version === myVersion) {
560
+ userLocks.delete(userId);
561
+ resolve();
562
+ }
563
+ };
434
564
  return release;
435
565
  }
436
566
  function debounceUser(userId, delayMs = 500) {
@@ -450,7 +580,7 @@ var inboundWindows = /* @__PURE__ */ new Map();
450
580
  function checkInboundLimit(sourceId, maxRequests = 30, windowMs = 6e4) {
451
581
  const now = Date.now();
452
582
  const entry = inboundWindows.get(sourceId) ?? { timestamps: [] };
453
- entry.timestamps = entry.timestamps.filter((t) => now - t < windowMs);
583
+ entry.timestamps = entry.timestamps.filter((t2) => now - t2 < windowMs);
454
584
  if (entry.timestamps.length >= maxRequests) {
455
585
  log4.warn({ sourceId, count: entry.timestamps.length }, "Inbound rate limit hit");
456
586
  return false;
@@ -566,7 +696,298 @@ function createDSersConfig(email, password, baseUrl) {
566
696
  };
567
697
  }
568
698
 
699
+ // src/dsers/browser-auth.ts
700
+ import { spawn } from "child_process";
701
+ import { mkdtempSync, rmSync } from "fs";
702
+ import { join as join4 } from "path";
703
+ import { tmpdir } from "os";
704
+ import { WebSocket } from "ws";
705
+
706
+ // src/dsers/browser-finder.ts
707
+ import { existsSync as existsSync4 } from "fs";
708
+ var CANDIDATES = [
709
+ {
710
+ name: "Google Chrome",
711
+ paths: {
712
+ darwin: ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
713
+ win32: [
714
+ `${process.env["PROGRAMFILES"]}\\Google\\Chrome\\Application\\chrome.exe`,
715
+ `${process.env["PROGRAMFILES(X86)"]}\\Google\\Chrome\\Application\\chrome.exe`,
716
+ `${process.env["LOCALAPPDATA"]}\\Google\\Chrome\\Application\\chrome.exe`
717
+ ],
718
+ linux: ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable"]
719
+ }
720
+ },
721
+ {
722
+ name: "Microsoft Edge",
723
+ paths: {
724
+ darwin: ["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
725
+ win32: [
726
+ `${process.env["PROGRAMFILES(X86)"]}\\Microsoft\\Edge\\Application\\msedge.exe`,
727
+ `${process.env["PROGRAMFILES"]}\\Microsoft\\Edge\\Application\\msedge.exe`
728
+ ],
729
+ linux: ["/usr/bin/microsoft-edge", "/usr/bin/microsoft-edge-stable"]
730
+ }
731
+ },
732
+ {
733
+ name: "Brave",
734
+ paths: {
735
+ darwin: ["/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"],
736
+ win32: [
737
+ `${process.env["PROGRAMFILES"]}\\BraveSoftware\\Brave-Browser\\Application\\brave.exe`,
738
+ `${process.env["LOCALAPPDATA"]}\\BraveSoftware\\Brave-Browser\\Application\\brave.exe`
739
+ ],
740
+ linux: ["/usr/bin/brave-browser"]
741
+ }
742
+ },
743
+ {
744
+ name: "Arc",
745
+ paths: {
746
+ darwin: ["/Applications/Arc.app/Contents/MacOS/Arc"]
747
+ }
748
+ },
749
+ {
750
+ name: "Chromium",
751
+ paths: {
752
+ darwin: ["/Applications/Chromium.app/Contents/MacOS/Chromium"],
753
+ linux: ["/usr/bin/chromium", "/usr/bin/chromium-browser"]
754
+ }
755
+ },
756
+ {
757
+ name: "Opera",
758
+ paths: {
759
+ darwin: ["/Applications/Opera.app/Contents/MacOS/Opera"],
760
+ win32: [
761
+ `${process.env["LOCALAPPDATA"]}\\Programs\\Opera\\opera.exe`
762
+ ],
763
+ linux: ["/usr/bin/opera"]
764
+ }
765
+ },
766
+ {
767
+ name: "Vivaldi",
768
+ paths: {
769
+ darwin: ["/Applications/Vivaldi.app/Contents/MacOS/Vivaldi"],
770
+ win32: [
771
+ `${process.env["LOCALAPPDATA"]}\\Vivaldi\\Application\\vivaldi.exe`
772
+ ],
773
+ linux: ["/usr/bin/vivaldi"]
774
+ }
775
+ }
776
+ ];
777
+ function findChromium() {
778
+ const platform = process.platform;
779
+ for (const candidate of CANDIDATES) {
780
+ const platformPaths = candidate.paths[platform];
781
+ if (!platformPaths) continue;
782
+ for (const p of platformPaths) {
783
+ if (existsSync4(p)) {
784
+ return { name: candidate.name, executablePath: p };
785
+ }
786
+ }
787
+ }
788
+ return null;
789
+ }
790
+
791
+ // src/dsers/browser-auth.ts
792
+ init_logger();
793
+ var log6 = createLogger("dsers:browser-auth");
794
+ var DSERS_LOGIN_URL = "https://accounts.dsers.com/accounts/login";
795
+ var LOGIN_API_PATTERN = "/account-user-bff/v1/users/login";
796
+ var TIMEOUT = 18e4;
797
+ var activeProcess = null;
798
+ var activeTmpDir = null;
799
+ function isAuthInProgress() {
800
+ return activeProcess !== null && !activeProcess.killed;
801
+ }
802
+ function cancelAuth() {
803
+ cleanup();
804
+ }
805
+ function cleanup() {
806
+ if (activeProcess && !activeProcess.killed) {
807
+ try {
808
+ activeProcess.kill();
809
+ } catch {
810
+ }
811
+ }
812
+ activeProcess = null;
813
+ if (activeTmpDir) {
814
+ try {
815
+ rmSync(activeTmpDir, { recursive: true, force: true });
816
+ } catch {
817
+ }
818
+ activeTmpDir = null;
819
+ }
820
+ }
821
+ function parseWsUrl(stderrChunk) {
822
+ const match = stderrChunk.match(/DevTools listening on (ws:\/\/[^\s]+)/);
823
+ return match?.[1] ?? null;
824
+ }
825
+ function launchBrowser() {
826
+ const browser = findChromium();
827
+ if (!browser) {
828
+ throw new Error("No Chromium-based browser found. Please install Chrome, Edge, or Brave.");
829
+ }
830
+ const tmpDir = mkdtempSync(join4(tmpdir(), "dsclaw-auth-"));
831
+ activeTmpDir = tmpDir;
832
+ log6.info({ browser: browser.name }, "Launching browser for DSers auth");
833
+ const proc = spawn(browser.executablePath, [
834
+ `--app=${DSERS_LOGIN_URL}`,
835
+ "--remote-debugging-port=0",
836
+ `--user-data-dir=${tmpDir}`,
837
+ "--no-first-run",
838
+ "--no-default-browser-check",
839
+ "--disable-extensions",
840
+ "--mute-audio",
841
+ "--window-size=480,700"
842
+ ], {
843
+ stdio: ["ignore", "ignore", "pipe"],
844
+ detached: false
845
+ });
846
+ activeProcess = proc;
847
+ const wsUrlPromise = new Promise((resolve, reject) => {
848
+ let stderr = "";
849
+ const timer = setTimeout(() => {
850
+ reject(new Error("Timed out waiting for Chrome DevTools"));
851
+ }, 15e3);
852
+ proc.stderr.on("data", (chunk) => {
853
+ stderr += chunk.toString();
854
+ const url = parseWsUrl(stderr);
855
+ if (url) {
856
+ clearTimeout(timer);
857
+ resolve(url);
858
+ }
859
+ });
860
+ proc.on("error", (err) => {
861
+ clearTimeout(timer);
862
+ reject(err);
863
+ });
864
+ proc.on("exit", () => {
865
+ clearTimeout(timer);
866
+ reject(new Error("Browser exited before DevTools was ready"));
867
+ });
868
+ });
869
+ return { proc, wsUrlPromise };
870
+ }
871
+ function watchLoginResponse(wsUrl) {
872
+ return new Promise((resolve, reject) => {
873
+ const ws = new WebSocket(wsUrl);
874
+ let msgId = 1;
875
+ let timeoutTimer = null;
876
+ let settled = false;
877
+ const pending = /* @__PURE__ */ new Map();
878
+ const requestMap = /* @__PURE__ */ new Map();
879
+ function cdpSend(method, params = {}) {
880
+ return new Promise((res) => {
881
+ const id = msgId++;
882
+ pending.set(id, res);
883
+ ws.send(JSON.stringify({ id, method, params }));
884
+ });
885
+ }
886
+ function finish(result, err) {
887
+ if (settled) return;
888
+ settled = true;
889
+ if (timeoutTimer) clearTimeout(timeoutTimer);
890
+ try {
891
+ ws.close();
892
+ } catch {
893
+ }
894
+ if (result) resolve(result);
895
+ else reject(err ?? new Error("Unknown error"));
896
+ }
897
+ ws.on("open", async () => {
898
+ log6.info("CDP connected, intercepting login API responses");
899
+ await cdpSend("Network.enable");
900
+ timeoutTimer = setTimeout(() => {
901
+ finish(void 0, new Error("Login timed out (3 minutes). Please try again."));
902
+ cleanup();
903
+ }, TIMEOUT);
904
+ });
905
+ ws.on("message", async (raw) => {
906
+ try {
907
+ const msg = JSON.parse(raw.toString());
908
+ if (msg.id && pending.has(msg.id)) {
909
+ pending.get(msg.id)(msg.result);
910
+ pending.delete(msg.id);
911
+ return;
912
+ }
913
+ if (msg.method === "Network.requestWillBeSent") {
914
+ const p = msg.params;
915
+ if (p.request.url.includes(LOGIN_API_PATTERN) && p.request.method === "POST") {
916
+ requestMap.set(p.requestId, p.request.url);
917
+ log6.info("Detected DSers login request");
918
+ }
919
+ return;
920
+ }
921
+ if (msg.method === "Network.responseReceived") {
922
+ const p = msg.params;
923
+ if (!requestMap.has(p.requestId)) return;
924
+ log6.info({ status: p.response.status }, "DSers login response received");
925
+ if (p.response.status !== 200) {
926
+ log6.warn("Login response was not 200, waiting for retry...");
927
+ requestMap.delete(p.requestId);
928
+ return;
929
+ }
930
+ await new Promise((r) => setTimeout(r, 500));
931
+ try {
932
+ const bodyResp = await cdpSend("Network.getResponseBody", {
933
+ requestId: p.requestId
934
+ });
935
+ const body = bodyResp.base64Encoded ? Buffer.from(bodyResp.body, "base64").toString() : bodyResp.body;
936
+ const data = JSON.parse(body);
937
+ const sessionId = data?.data?.sessionId;
938
+ const state = data?.data?.state ?? "";
939
+ if (sessionId) {
940
+ log6.info("DSers session captured from login API response");
941
+ finish({ sessionId, state });
942
+ } else {
943
+ log6.warn({ responseData: body.slice(0, 300) }, "Login response missing sessionId");
944
+ }
945
+ } catch (e) {
946
+ log6.warn({ error: e instanceof Error ? e.message : String(e) }, "Failed to read login response body");
947
+ }
948
+ requestMap.delete(p.requestId);
949
+ }
950
+ } catch {
951
+ }
952
+ });
953
+ ws.on("error", () => {
954
+ finish(void 0, new Error("CDP connection lost"));
955
+ });
956
+ ws.on("close", () => {
957
+ finish(void 0, new Error("Browser was closed before login completed"));
958
+ });
959
+ });
960
+ }
961
+ async function loginViaCDP() {
962
+ if (isAuthInProgress()) {
963
+ throw new Error("Auth already in progress");
964
+ }
965
+ try {
966
+ const { proc, wsUrlPromise } = launchBrowser();
967
+ proc.on("exit", () => {
968
+ activeProcess = null;
969
+ });
970
+ const wsUrl = await wsUrlPromise;
971
+ log6.info({ wsUrl }, "DevTools endpoint acquired");
972
+ const httpUrl = wsUrl.replace("ws://", "http://").replace(/\/devtools\/browser\/.*/, "/json");
973
+ const resp = await fetch(httpUrl);
974
+ const tabs = await resp.json();
975
+ const dsersTab = tabs.find((t2) => t2.url.includes("dsers.com")) ?? tabs[0];
976
+ if (!dsersTab?.webSocketDebuggerUrl) {
977
+ throw new Error("Could not find browser tab");
978
+ }
979
+ log6.info({ tabUrl: dsersTab.url }, "Watching tab for login API response");
980
+ const result = await watchLoginResponse(dsersTab.webSocketDebuggerUrl);
981
+ cleanup();
982
+ return result;
983
+ } catch (error) {
984
+ cleanup();
985
+ throw error;
986
+ }
987
+ }
988
+
569
989
  // src/agents/core-agent.ts
990
+ init_logger();
570
991
  import { generateText, streamText, tool as aiTool, stepCountIs } from "ai";
571
992
  import { createOpenAI } from "@ai-sdk/openai";
572
993
  import { createAnthropic } from "@ai-sdk/anthropic";
@@ -574,11 +995,12 @@ import { createGoogleGenerativeAI } from "@ai-sdk/google";
574
995
  import { z as z2 } from "zod";
575
996
 
576
997
  // src/shared/audit.ts
577
- import { appendFileSync, mkdirSync as mkdirSync4, existsSync as existsSync4 } from "fs";
578
- import { join as join4 } from "path";
579
- var AUDIT_DIR = join4(CONFIG_DIR, "audit");
998
+ import { appendFileSync, mkdirSync as mkdirSync4, existsSync as existsSync5 } from "fs";
999
+ import { join as join5 } from "path";
1000
+ init_tracer();
1001
+ var AUDIT_DIR = join5(CONFIG_DIR, "audit");
580
1002
  function ensureAuditDir() {
581
- if (!existsSync4(AUDIT_DIR)) {
1003
+ if (!existsSync5(AUDIT_DIR)) {
582
1004
  mkdirSync4(AUDIT_DIR, { recursive: true });
583
1005
  }
584
1006
  }
@@ -590,7 +1012,7 @@ function writeAuditLog(entry) {
590
1012
  traceId: getTraceId()
591
1013
  };
592
1014
  const date = full.timestamp.slice(0, 10);
593
- const file = join4(AUDIT_DIR, `${date}.jsonl`);
1015
+ const file = join5(AUDIT_DIR, `${date}.jsonl`);
594
1016
  try {
595
1017
  appendFileSync(file, JSON.stringify(full) + "\n");
596
1018
  } catch {
@@ -602,49 +1024,195 @@ async function getImportList(client, params) {
602
1024
  const filtered = params ? Object.fromEntries(Object.entries(params).filter(([, v]) => v != null)) : {};
603
1025
  return client.get("/dsers-product-bff/import-list", filtered);
604
1026
  }
605
- async function importByProductId(client, body) {
606
- return client.post("/dsers-product-bff/import-list/product-id", body);
607
- }
608
- async function pushToStore(client, payload) {
609
- return client.post("/dsers-product-bff/import-list/push", {
610
- data: payload
611
- });
1027
+ async function getImportListItem(client, id) {
1028
+ return client.get(`/dsers-product-bff/import-list/${id}`);
612
1029
  }
613
1030
  async function getMyProducts(client, params) {
614
1031
  return client.get("/dsers-product-bff/my-product", params);
615
1032
  }
616
1033
 
617
- // src/dsers/account.ts
618
- async function listStores(client) {
619
- return client.post("/account-user-bff/v1/stores/user/list");
1034
+ // src/agents/core-agent.ts
1035
+ import { configFromToken } from "@lofder/dsers-mcp-product/dist/dsers/config.js";
1036
+ import { buildProvider } from "@lofder/dsers-mcp-product/dist/provider.js";
1037
+ import { ImportFlowService } from "@lofder/dsers-mcp-product/dist/service.js";
1038
+ import { MemoryJobStore } from "@lofder/dsers-mcp-product/dist/job-store-memory.js";
1039
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync4, mkdirSync as mkdirSync5, renameSync, existsSync as existsSync6 } from "fs";
1040
+ import { join as join6 } from "path";
1041
+ import { homedir as homedir2 } from "os";
1042
+ var log7 = createLogger("agent:core");
1043
+ var JOB_ID_MAP_DIR = join6(homedir2(), ".dsclaw");
1044
+ var JOB_ID_MAP_FILE = join6(JOB_ID_MAP_DIR, "job-id-map.json");
1045
+ var JOB_ID_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
1046
+ function loadJobIdMap() {
1047
+ const map = /* @__PURE__ */ new Map();
1048
+ try {
1049
+ if (!existsSync6(JOB_ID_MAP_FILE)) return map;
1050
+ const raw = JSON.parse(readFileSync4(JOB_ID_MAP_FILE, "utf-8"));
1051
+ const now = Date.now();
1052
+ for (const [short, entry] of Object.entries(raw)) {
1053
+ if (now - entry.ts < JOB_ID_TTL_MS) {
1054
+ map.set(short, entry.full);
1055
+ }
1056
+ }
1057
+ } catch (err) {
1058
+ log7.warn({ err }, "Failed to load job ID map from disk");
1059
+ }
1060
+ return map;
620
1061
  }
621
- async function getUserInfo(client) {
622
- return client.get("/account-user-bff/v1/users/info");
1062
+ function persistJobIdMap(map) {
1063
+ try {
1064
+ mkdirSync5(JOB_ID_MAP_DIR, { recursive: true });
1065
+ const obj = {};
1066
+ const now = Date.now();
1067
+ for (const [short, full] of map) {
1068
+ obj[short] = { full, ts: now };
1069
+ }
1070
+ const tmp = JOB_ID_MAP_FILE + ".tmp";
1071
+ writeFileSync4(tmp, JSON.stringify(obj));
1072
+ renameSync(tmp, JOB_ID_MAP_FILE);
1073
+ } catch (err) {
1074
+ log7.warn({ err }, "Failed to persist job ID map");
1075
+ }
623
1076
  }
624
-
625
- // src/dsers/settings.ts
626
- async function getGlobalSettings(client) {
627
- return client.get("/infra-setting-bff/setting/list");
1077
+ function normalizeBaseUrl(url) {
1078
+ if (!url) return void 0;
1079
+ let u = url.trim();
1080
+ if (!/^https?:\/\//i.test(u)) u = `https://${u}`;
1081
+ u = u.replace(/\/+$/, "");
1082
+ if (!/\/v\d/.test(u)) u += "/v1";
1083
+ return u;
628
1084
  }
629
- async function getPricingRules(client, storeId) {
630
- return client.get("/dsers-settings-bff/product/pricing-rule", { storeId });
1085
+ function withTimeout(promise, ms, label) {
1086
+ return Promise.race([
1087
+ promise,
1088
+ new Promise((_, reject) => {
1089
+ const id = setTimeout(() => reject(new Error(`${label} timed out after ${Math.round(ms / 1e3)}s`)), ms);
1090
+ if (typeof id === "object" && "unref" in id) id.unref();
1091
+ })
1092
+ ]);
631
1093
  }
632
- async function getCurrentPlan(client) {
633
- return client.get("/dsers-plan-bff/plan");
1094
+ function withToolAwareTimeout(promise, baseMs, isToolActive, label) {
1095
+ let timerId = null;
1096
+ const cleanup2 = () => {
1097
+ if (timerId !== null) {
1098
+ clearTimeout(timerId);
1099
+ timerId = null;
1100
+ }
1101
+ };
1102
+ const timeoutPromise = new Promise((_, reject) => {
1103
+ let deadline = Date.now() + baseMs;
1104
+ const tick = () => {
1105
+ if (isToolActive()) {
1106
+ deadline = Date.now() + baseMs;
1107
+ timerId = setTimeout(tick, 1e4);
1108
+ } else if (Date.now() >= deadline) {
1109
+ reject(new Error(`${label} timed out after ${Math.round(baseMs / 1e3)}s`));
1110
+ } else {
1111
+ timerId = setTimeout(tick, Math.min(deadline - Date.now(), 1e4));
1112
+ }
1113
+ };
1114
+ timerId = setTimeout(tick, baseMs);
1115
+ });
1116
+ return Promise.race([promise.finally(cleanup2), timeoutPromise]);
1117
+ }
1118
+ var MCP_DROP_KEYS = /* @__PURE__ */ new Set([
1119
+ "image_urls",
1120
+ "description_html_snippet",
1121
+ "effective_rules_snapshot",
1122
+ "requested_rules",
1123
+ "original_draft",
1124
+ "resolved_source_url",
1125
+ "resolver_mode",
1126
+ "tags_before",
1127
+ "tags_after"
1128
+ ]);
1129
+ function compactToolResult(result, jobIdMap) {
1130
+ if (!result || typeof result !== "object") return result;
1131
+ const isMcpPreview = "job_id" in result && ("title_after" in result || "status" in result);
1132
+ if (isMcpPreview) {
1133
+ const out2 = {};
1134
+ for (const [k, v] of Object.entries(result)) {
1135
+ if (MCP_DROP_KEYS.has(k)) continue;
1136
+ out2[k] = v;
1137
+ }
1138
+ if (out2.job_id && typeof out2.job_id === "string") {
1139
+ const dotIdx = out2.job_id.indexOf(".");
1140
+ if (dotIdx > 0) {
1141
+ const short = out2.job_id.slice(0, dotIdx);
1142
+ if (jobIdMap) jobIdMap.set(short, out2.job_id);
1143
+ out2.job_id = short;
1144
+ }
1145
+ }
1146
+ if (out2.skus && Array.isArray(out2.skus) && out2.skus.length > 1) {
1147
+ const [header, ...rows] = out2.skus;
1148
+ out2.variant_summary = rows.slice(0, 5).map((row) => {
1149
+ const obj = {};
1150
+ header.forEach((key, i) => {
1151
+ obj[key] = row[i];
1152
+ });
1153
+ return obj;
1154
+ });
1155
+ if (rows.length > 5) {
1156
+ out2.variant_summary_note = `Showing 5 of ${rows.length} variants`;
1157
+ }
1158
+ delete out2.skus;
1159
+ }
1160
+ if (out2.title_after) {
1161
+ out2._title_modified = true;
1162
+ out2._display_title = out2.title_after;
1163
+ }
1164
+ if (out2.desc_changed) {
1165
+ out2._description_modified = true;
1166
+ }
1167
+ if (out2.warnings?.length > 5) out2.warnings = out2.warnings.slice(0, 5);
1168
+ if (out2.stores) {
1169
+ out2.stores = out2.stores.map((s) => ({
1170
+ store_ref: s.store_ref,
1171
+ display_name: s.display_name,
1172
+ platform: s.platform
1173
+ }));
1174
+ }
1175
+ if (out2.account_info) {
1176
+ const ai = out2.account_info;
1177
+ out2.account_info = { plan: ai.plan, limits: ai.limits, aliexpress_auth: ai.aliexpress_auth };
1178
+ }
1179
+ return out2;
1180
+ }
1181
+ const json = JSON.stringify(result);
1182
+ if (json.length <= 4e3) return result;
1183
+ const out = {};
1184
+ for (const [k, v] of Object.entries(result)) {
1185
+ if (MCP_DROP_KEYS.has(k)) continue;
1186
+ if (Array.isArray(v) && v.length > 20) {
1187
+ out[k] = v.slice(0, 20);
1188
+ out[`_${k}_truncated`] = `${v.length} total, showing first 20`;
1189
+ } else {
1190
+ out[k] = v;
1191
+ }
1192
+ }
1193
+ const outJson = JSON.stringify(out);
1194
+ if (outJson.length > 8e3) {
1195
+ return { _truncated: true, _original_size: json.length, summary: outJson.slice(0, 6e3) + "..." };
1196
+ }
1197
+ return out;
634
1198
  }
635
-
636
- // src/agents/core-agent.ts
637
- var log6 = createLogger("agent:core");
638
1199
  function buildModel(llm) {
1200
+ const baseURL = normalizeBaseUrl(llm.baseUrl);
1201
+ if (baseURL || llm.provider === "other") {
1202
+ return createOpenAI({
1203
+ apiKey: llm.apiKey,
1204
+ baseURL: baseURL ?? "http://localhost:11434/v1"
1205
+ }).chat(llm.model);
1206
+ }
639
1207
  switch (llm.provider) {
640
1208
  case "openai":
641
- return createOpenAI({ apiKey: llm.apiKey })(llm.model);
1209
+ return createOpenAI({ apiKey: llm.apiKey }).chat(llm.model);
642
1210
  case "anthropic":
643
1211
  return createAnthropic({ apiKey: llm.apiKey })(llm.model);
644
1212
  case "google":
645
1213
  return createGoogleGenerativeAI({ apiKey: llm.apiKey })(llm.model);
646
1214
  default:
647
- return createOpenAI({ apiKey: llm.apiKey })(llm.model);
1215
+ return createOpenAI({ apiKey: llm.apiKey }).chat(llm.model);
648
1216
  }
649
1217
  }
650
1218
  function getDefaultModel(provider) {
@@ -655,36 +1223,86 @@ function getDefaultModel(provider) {
655
1223
  return "claude-sonnet-4-20250514";
656
1224
  case "google":
657
1225
  return "gemini-2.0-flash";
1226
+ case "other":
1227
+ return "gpt-3.5-turbo";
658
1228
  default:
659
1229
  return "gpt-4o";
660
1230
  }
661
1231
  }
662
- var SYSTEM_PROMPT = `You are DSClaw, a friendly AI assistant that manages dropshipping stores through DSers.
663
- Your user has ZERO technical background \u2014 they are a small business owner or solo entrepreneur.
1232
+ var TOOL_LABELS = {
1233
+ dsers_store_discover: "Discovering stores & capabilities...",
1234
+ dsers_product_import: "Importing product...",
1235
+ dsers_product_preview: "Loading preview...",
1236
+ dsers_product_delete: "Deleting product...",
1237
+ dsers_product_visibility: "Setting visibility...",
1238
+ dsers_store_push: "Pushing to store...",
1239
+ dsers_rules_validate: "Validating rules...",
1240
+ dsers_job_status: "Checking job status...",
1241
+ dsers_import_list_search: "Searching import list...",
1242
+ dsers_my_products: "Loading your products..."
1243
+ };
1244
+ var SYSTEM_PROMPT = `You are DSClaw, a friendly dropshipping AI for DSers. User has zero technical background.
1245
+ {{LANGUAGE_RULE}}
1246
+ Be warm, clear. Confirm before push/delete/bulk ops.
664
1247
 
665
- COMMUNICATION RULES:
666
- - Always respond in the SAME LANGUAGE the user writes in
667
- - Use simple, warm, conversational language \u2014 no jargon
668
- - When showing data (products, stores, orders), format it clearly with bullet points or numbered lists
669
- - Always confirm before executing write operations (push, delete, update pricing)
670
- - If something fails, explain what happened in plain language and suggest what to do next
671
- - Proactively offer helpful next steps after completing a task
1248
+ PRICES \u2014 always show separately:
1249
+ - cost (\u91C7\u8D2D\u4EF7): supplier price. Field: "cost"
1250
+ - sell_price (\u552E\u4EF7): store price. Field: "sell_price"
1251
+ - compare_at_price: strikethrough price
1252
+ - no_markup=true \u2192 warn user to set pricing rules
1253
+ - From dsers_import_list_search: use "cost_range" and "sell_price_range"
1254
+ When in Chinese: Format: \u91C7\u8D2D\u4EF7\uFF1A$2.51\u2013$4.00 | \u552E\u4EF7\uFF1A$5.02\u2013$8.00
1255
+ When in English: Format: Cost: $2.51\u2013$4.00 | Sell price: $5.02\u2013$8.00
672
1256
 
673
- YOUR CAPABILITIES:
674
- - View connected stores and store details
675
- - Search and browse the import list (products from suppliers)
676
- - Import new products from AliExpress, Temu, or 1688
677
- - Push products to connected stores (Shopify, WooCommerce, etc.)
678
- - View products already in stores ("My Products")
679
- - Check and update pricing rules
680
- - View account plan and billing info
681
- - Check global settings and shipping configuration
1257
+ WORKFLOWS:
1258
+ Import: dsers_store_discover \u2192 dsers_product_import(url) \u2192 (rules) \u2192 dsers_store_push(confirm first)
1259
+ Batch: source_urls_json / job_ids_json / target_stores_json
1260
+ Delete: dsers_product_delete without confirm \u2192 show details \u2192 dsers_product_delete with confirm=true
682
1261
 
683
- GUIDELINES:
684
- - When users describe products vaguely, search first and present options
685
- - For bulk operations, show a preview and ask for confirmation
686
- - Use simple summaries: "You have 15 products waiting to be pushed" instead of raw JSON
687
- - If the user seems confused, offer guided suggestions like "Would you like me to show your stores first?"`;
1262
+ TOOL SELECTION \u2014 ALL modifications go through dsers_product_import:
1263
+ - dsers_import_list_search returns each item's "source_url". Use it for re-import.
1264
+ - Title/description/price changes on existing items:
1265
+ 1. dsers_product_import(source_url from dsers_import_list_search, rules_json with changes)
1266
+ 2. This re-imports and applies rules in one call, persisting changes via MCP.
1267
+ - PRICING (pick mode by user intent):
1268
+ | "set price $9.99" / "all $9.99" \u2192 fixed_price: {"pricing":{"mode":"fixed_price","fixed_price":9.99}}
1269
+ | "double the price" / "3x" \u2192 multiplier: {"pricing":{"mode":"multiplier","multiplier":2.0}}
1270
+ | "add $5 markup" \u2192 fixed_markup: {"pricing":{"mode":"fixed_markup","fixed_markup":5.00}}
1271
+ | "Red $9.99, Blue $12.99" \u2192 variant_overrides: {"variant_overrides":[{"match":"Red","sell_price":9.99},{"match":"Blue","sell_price":12.99}]}
1272
+ All modes accept optional round_digits (0-10).
1273
+ - CONTENT: title_override, title_prefix, title_suffix, description_override_html, description_append_html, tags_add (array)
1274
+ - IMAGES: drop_indexes, reorder, add_urls, keep_first_n. Only URLs, no base64.
1275
+ - VARIANT_OVERRIDES: array of {match, sell_price, compare_at_price, stock, title, image_url}. Applied AFTER global pricing.
1276
+ - OPTION_EDITS: array of {action, option_name, value_name?, new_name?}. Actions: rename_option, rename_value, remove_value (DESTRUCTIVE), remove_option.
1277
+ - Check response: title_after (changed title), _title_modified, sell_price, cost, desc_changed
1278
+ - Existing job_id: dsers_product_import(job_id + rules_json) to re-apply rules.
1279
+
1280
+ PUSH CONFIRMATION: Before calling dsers_store_push, ALWAYS show a confirmation checklist and wait for user approval:
1281
+ - Product: title, variant count
1282
+ - Price: sell vs cost range
1283
+ - Store: name
1284
+ - Visibility: Draft or Published (warn if Published)
1285
+ - Shipping profile: selected profile name, or "NOT SELECTED" with available options listed
1286
+ - Pricing Rule: MCP pricing or store rule (warn if conflict)
1287
+ If required info is missing (e.g. no shipping profile selected), show warning with available options and ask user to choose.
1288
+ ONLY call dsers_store_push after explicit user confirmation (e.g. "push", "go", "confirmed", "\u63A8\u9001", "\u786E\u8BA4").
1289
+ Exception: if user's ORIGINAL message already contains explicit push instruction ("push it", "\u76F4\u63A5\u63A8\u9001", "\u63A8\u9001\u5230\u5E97\u94FA"), treat as pre-confirmed \u2014 show checklist as summary, not blocker.
1290
+
1291
+ SHIPPING PROFILES: Shopify stores may have multiple shipping profiles.
1292
+ - Single profile \u2192 auto-selected, no action needed.
1293
+ - Multiple profiles + none specified \u2192 push BLOCKED with available_profiles list.
1294
+ - Use shipping_profile_name in push_options_json to select one.
1295
+ - Do NOT rely on "default" profiles \u2014 always show user available options when blocked.
1296
+
1297
+ ERROR RECOVERY:
1298
+ - Expired job_id \u2192 re-import with source_url
1299
+ - blocked array \u2192 hard stop, fix before retry
1300
+ - warnings array \u2192 soft alert, push proceeds
1301
+ - push_blocked_by_missing_shipping_profile \u2192 show available profiles, ask user to pick, retry with shipping_profile_name
1302
+ - Auth/401/session errors \u2192 call dsers_authorize to open browser login
1303
+ - After dsers_authorize succeeds: tell user authorization succeeded and ask them to resend their request. Do NOT retry in the same turn (session will refresh on next message).
1304
+ - If DSers session expired: tell user to go to Settings \u2192 Disconnect DSers \u2192 Reconnect. NEVER mention terminal commands (npx, npm, node) or environment variables (DSERS_TOKEN).
1305
+ - NEVER ask user to run terminal commands or npx`;
688
1306
  var DSClawCoreAgent = class {
689
1307
  id = "dsclaw-core";
690
1308
  name = "DSClaw Core Agent";
@@ -692,13 +1310,44 @@ var DSClawCoreAgent = class {
692
1310
  memory;
693
1311
  llm;
694
1312
  customTools = /* @__PURE__ */ new Map();
695
- constructor(dsers, memory, llm) {
1313
+ importService = null;
1314
+ jobIdMap = loadJobIdMap();
1315
+ mcpAvailable = false;
1316
+ authCallback;
1317
+ constructor(dsers, memory, llm, dsersSession, authCallback, jobStore) {
696
1318
  this.dsers = dsers;
697
1319
  this.memory = memory;
698
1320
  this.llm = llm;
1321
+ this.authCallback = authCallback;
1322
+ if (dsersSession?.sessionId) {
1323
+ try {
1324
+ const mcpConfig = configFromToken(dsersSession.sessionId, dsersSession.state);
1325
+ const provider = buildProvider(mcpConfig);
1326
+ this.importService = new ImportFlowService(provider, jobStore ?? new MemoryJobStore());
1327
+ this.mcpAvailable = true;
1328
+ log7.info("MCP ImportFlowService initialized");
1329
+ } catch (err) {
1330
+ log7.warn({ err }, "Failed to init MCP ImportFlowService \u2014 product tools unavailable");
1331
+ }
1332
+ }
1333
+ }
1334
+ getSystemPrompt() {
1335
+ const rule = "ALWAYS respond in the same language as the user's most recent message. If they write Chinese, respond in Chinese. If English, respond in English. Never switch languages unless the user does.";
1336
+ return SYSTEM_PROMPT.replace("{{LANGUAGE_RULE}}", `Language rule: ${rule}`);
1337
+ }
1338
+ shortenJobId(fullId) {
1339
+ const dotIdx = fullId.indexOf(".");
1340
+ if (dotIdx <= 0) return fullId;
1341
+ const short = fullId.slice(0, dotIdx);
1342
+ this.jobIdMap.set(short, fullId);
1343
+ persistJobIdMap(this.jobIdMap);
1344
+ return short;
699
1345
  }
700
- registerTool(t) {
701
- this.customTools.set(t.name, t);
1346
+ resolveJobId(maybeShort) {
1347
+ return this.jobIdMap.get(maybeShort) ?? maybeShort;
1348
+ }
1349
+ registerTool(t2) {
1350
+ this.customTools.set(t2.name, t2);
702
1351
  }
703
1352
  removeTool(name) {
704
1353
  this.customTools.delete(name);
@@ -712,9 +1361,9 @@ var DSClawCoreAgent = class {
712
1361
  const memoryContext = memories.length > 0 ? `
713
1362
 
714
1363
  Relevant memories:
715
- ${memories.map((m) => `- ${m.content}`).join("\n")}` : "";
1364
+ ${memories.map((m2) => `- ${m2.content}`).join("\n")}` : "";
716
1365
  const messages = [
717
- { role: "system", content: SYSTEM_PROMPT + memoryContext },
1366
+ { role: "system", content: this.getSystemPrompt() + memoryContext },
718
1367
  ...context.history.map(
719
1368
  (h) => ({
720
1369
  role: h.role,
@@ -724,17 +1373,21 @@ ${memories.map((m) => `- ${m.content}`).join("\n")}` : "";
724
1373
  { role: "user", content: message }
725
1374
  ];
726
1375
  const tools = this.buildAITools(context);
727
- log6.info(
1376
+ log7.info(
728
1377
  { userId: context.userId, messageLen: message.length, toolCount: Object.keys(tools).length },
729
1378
  "Processing message"
730
1379
  );
731
1380
  try {
1381
+ const abort = new AbortController();
1382
+ const timer = setTimeout(() => abort.abort(), 12e4);
732
1383
  const result = await generateText({
733
1384
  model,
734
1385
  messages,
735
1386
  tools,
736
- stopWhen: stepCountIs(5)
1387
+ stopWhen: stepCountIs(10),
1388
+ abortSignal: abort.signal
737
1389
  });
1390
+ clearTimeout(timer);
738
1391
  const toolCalls = result.steps?.flatMap(
739
1392
  (step) => step.toolCalls?.map((tc) => ({
740
1393
  tool: tc.toolName,
@@ -747,20 +1400,12 @@ ${memories.map((m) => `- ${m.content}`).join("\n")}` : "";
747
1400
  }
748
1401
  }))
749
1402
  ).filter(Boolean) ?? [];
750
- if (result.text.length > 20) {
751
- this.memory.add(`User: "${message.slice(0, 100)}" -> ${toolCalls.length} tools used`, {
752
- userId: context.userId,
753
- category: "pattern",
754
- agentId: this.id
755
- }).catch(() => {
756
- });
757
- }
758
1403
  return {
759
1404
  text: result.text,
760
1405
  toolCalls
761
1406
  };
762
1407
  } catch (error) {
763
- log6.error({ error, userId: context.userId }, "Agent processing failed");
1408
+ log7.error({ error, userId: context.userId }, "Agent processing failed");
764
1409
  writeAuditLog({
765
1410
  userId: context.userId,
766
1411
  agentId: this.id,
@@ -774,9 +1419,9 @@ ${memories.map((m) => `- ${m.content}`).join("\n")}` : "";
774
1419
  }
775
1420
  /**
776
1421
  * Streaming version of process() — yields text chunks for real-time display.
777
- * Returns textStream (async iterable) + response promise (resolves after completion).
1422
+ * Calls onStatus when tool calls happen so the UI can show activity.
778
1423
  */
779
- processStream(message, context) {
1424
+ processStream(message, context, onStatus, callbacks, attachments) {
780
1425
  const model = buildModel(this.llm);
781
1426
  const memories = this.memory.search(message, { userId: context.userId, limit: 5 }).catch(() => []);
782
1427
  const tools = this.buildAITools(context);
@@ -785,38 +1430,112 @@ ${memories.map((m) => `- ${m.content}`).join("\n")}` : "";
785
1430
  const memoryContext = mems.length > 0 ? `
786
1431
 
787
1432
  Relevant memories:
788
- ${mems.map((m) => `- ${m.content}`).join("\n")}` : "";
1433
+ ${mems.map((m2) => `- ${m2.content}`).join("\n")}` : "";
1434
+ const userContent = attachments?.length ? [
1435
+ ...attachments.map((a) => ({
1436
+ type: "image",
1437
+ image: a.data ?? a.url,
1438
+ mimeType: a.mimeType
1439
+ })),
1440
+ { type: "text", text: message }
1441
+ ] : message;
789
1442
  const messages = [
790
- { role: "system", content: SYSTEM_PROMPT + memoryContext },
1443
+ { role: "system", content: this.getSystemPrompt() + memoryContext },
791
1444
  ...context.history.map(
792
1445
  (h) => ({
793
1446
  role: h.role,
794
1447
  content: h.content
795
1448
  })
796
1449
  ),
797
- { role: "user", content: message }
1450
+ { role: "user", content: userContent }
798
1451
  ];
799
- log6.info(
800
- { userId: context.userId, messageLen: message.length, streaming: true },
1452
+ const totalMsgChars = messages.reduce((sum, m2) => sum + (typeof m2.content === "string" ? m2.content.length : JSON.stringify(m2.content).length), 0);
1453
+ const toolNames = Object.keys(tools);
1454
+ const toolSchemaChars = JSON.stringify(Object.fromEntries(
1455
+ Object.entries(tools).map(([k, v]) => [k, v.description ?? ""])
1456
+ )).length;
1457
+ log7.info(
1458
+ {
1459
+ userId: context.userId,
1460
+ messageLen: message.length,
1461
+ streaming: true,
1462
+ llmProvider: self.llm.provider,
1463
+ llmModel: self.llm.model,
1464
+ llmBaseUrl: self.llm.baseUrl,
1465
+ historyTurns: context.history.length,
1466
+ totalMsgChars,
1467
+ toolCount: toolNames.length,
1468
+ toolSchemaChars,
1469
+ approxTokens: Math.ceil(totalMsgChars / 3.5)
1470
+ },
801
1471
  "Processing message (stream)"
802
1472
  );
803
1473
  return streamText({
804
1474
  model,
805
1475
  messages,
806
1476
  tools,
807
- stopWhen: stepCountIs(5)
1477
+ stopWhen: stepCountIs(10),
1478
+ onChunk({ chunk }) {
1479
+ if (chunk.type === "tool-call") {
1480
+ toolActive = true;
1481
+ const name = chunk.toolName ?? "";
1482
+ const args = chunk.args ?? chunk.input ?? {};
1483
+ log7.info({ tool: name, args: JSON.stringify(args).slice(0, 500) }, "Tool call");
1484
+ if (onStatus) {
1485
+ let label = TOOL_LABELS[name] ?? name;
1486
+ if (name === "dsers_product_import") {
1487
+ if (args.job_id && !args.source_url && !args.source_urls_json) {
1488
+ label = args.rules_json ? "Applying rules..." : "Refreshing preview...";
1489
+ }
1490
+ }
1491
+ onStatus(label ?? "Working...");
1492
+ }
1493
+ if (callbacks?.onToolCall && chunk.toolCallId) {
1494
+ toolStartTimes.set(chunk.toolCallId, Date.now());
1495
+ callbacks.onToolCall(chunk.toolCallId, name, args);
1496
+ }
1497
+ }
1498
+ if (chunk.type === "tool-result") {
1499
+ toolActive = false;
1500
+ const name = chunk.toolName ?? "";
1501
+ const output = chunk.output ?? chunk.result;
1502
+ const resultStr = JSON.stringify(output ?? "").slice(0, 500);
1503
+ log7.info({ tool: name, resultSnippet: resultStr }, "Tool result");
1504
+ if (callbacks?.onToolResult && chunk.toolCallId) {
1505
+ const startTime = toolStartTimes.get(chunk.toolCallId) ?? Date.now();
1506
+ const durationMs = Date.now() - startTime;
1507
+ toolStartTimes.delete(chunk.toolCallId);
1508
+ callbacks.onToolResult(chunk.toolCallId, name, chunk.result, void 0, durationMs);
1509
+ }
1510
+ if (onStatus) onStatus("Thinking...");
1511
+ }
1512
+ }
808
1513
  });
809
1514
  });
1515
+ let toolActive = false;
1516
+ const toolStartTimes = /* @__PURE__ */ new Map();
810
1517
  const textStream = {
811
1518
  [Symbol.asyncIterator]() {
812
1519
  let innerIterator = null;
1520
+ let gotFirstToken = false;
813
1521
  return {
814
1522
  async next() {
815
- if (!innerIterator) {
816
- const result = await streamPromise;
817
- innerIterator = result.textStream[Symbol.asyncIterator]();
1523
+ try {
1524
+ if (!innerIterator) {
1525
+ const result = await withTimeout(streamPromise, 6e4, "LLM init");
1526
+ innerIterator = result.textStream[Symbol.asyncIterator]();
1527
+ }
1528
+ const timeout = gotFirstToken ? 6e4 : 12e4;
1529
+ const res = await withToolAwareTimeout(innerIterator.next(), timeout, () => toolActive, "LLM response");
1530
+ if (!res.done && res.value) {
1531
+ gotFirstToken = true;
1532
+ toolActive = false;
1533
+ }
1534
+ return res;
1535
+ } catch (err) {
1536
+ log7.error({ err, toolActive, gotFirstToken }, "Stream iterator error");
1537
+ throw err;
818
1538
  }
819
- return innerIterator.next();
820
1539
  }
821
1540
  };
822
1541
  }
@@ -836,14 +1555,6 @@ ${mems.map((m) => `- ${m.content}`).join("\n")}` : "";
836
1555
  }
837
1556
  }))
838
1557
  ).filter(Boolean) ?? [];
839
- if (text.length > 20) {
840
- self.memory.add(`User: "${message.slice(0, 100)}" -> ${toolCalls.length} tools used`, {
841
- userId: context.userId,
842
- category: "pattern",
843
- agentId: self.id
844
- }).catch(() => {
845
- });
846
- }
847
1558
  return {
848
1559
  text,
849
1560
  toolCalls
@@ -853,207 +1564,525 @@ ${mems.map((m) => `- ${m.content}`).join("\n")}` : "";
853
1564
  }
854
1565
  buildAITools(context) {
855
1566
  const dsers = this.dsers;
1567
+ const svc = this.importService;
1568
+ const jmap = this.jobIdMap;
1569
+ const resolveJid = (id) => this.resolveJobId(id);
856
1570
  const audit = (action, target, params, result = "pending") => writeAuditLog({ userId: context.userId, agentId: this.id, action, target, params, result });
857
- return {
858
- listStores: aiTool({
859
- description: "List all connected stores (Shopify, WooCommerce, etc.) with their IDs and status.",
860
- inputSchema: z2.object({}),
861
- execute: async () => {
862
- audit("listStores", "dsers:account");
863
- const result = await listStores(dsers);
864
- audit("listStores", "dsers:account", void 0, "success");
865
- return result;
866
- }
867
- }),
868
- getUserInfo: aiTool({
869
- description: "Get the user's DSers account info (email, name, plan).",
870
- inputSchema: z2.object({}),
871
- execute: async () => {
872
- audit("getUserInfo", "dsers:account");
873
- const result = await getUserInfo(dsers);
874
- audit("getUserInfo", "dsers:account", void 0, "success");
875
- return result;
876
- }
877
- }),
878
- searchImportList: aiTool({
879
- description: "Search the import list \u2014 products imported from suppliers but not yet pushed to stores.",
880
- inputSchema: z2.object({
881
- page: z2.number().optional().describe("Page number (default 1)"),
882
- pageSize: z2.number().optional().describe("Items per page (default 20)"),
883
- keyword: z2.string().optional().describe("Search keyword")
884
- }),
885
- execute: async (input) => {
886
- audit("searchImportList", "dsers:product", input);
887
- const result = await getImportList(dsers, input);
888
- audit("searchImportList", "dsers:product", void 0, "success");
889
- return result;
890
- }
891
- }),
892
- importProduct: aiTool({
893
- description: "Import a product from a supplier (AliExpress/Temu/1688) into the import list. Needs the supplier product ID and platform (1=AliExpress, 2=Temu, 3=1688).",
1571
+ const tools = {};
1572
+ if (svc) {
1573
+ tools.dsers_store_discover = aiTool({
1574
+ description: "Get connected stores, account info (plan/limits), and rule capabilities. Call first before import/push.",
894
1575
  inputSchema: z2.object({
895
- supplyProductId: z2.string().describe("Supplier product ID"),
896
- supplyAppId: z2.number().describe("1=AliExpress, 2=Temu, 3=1688"),
897
- country: z2.string().default("US").describe("Target country")
1576
+ target_store: z2.string().optional().describe("Filter by store ID or name")
898
1577
  }),
899
1578
  execute: async (input) => {
900
- audit("importProduct", "dsers:product", input);
901
- const result = await importByProductId(dsers, input);
902
- audit("importProduct", "dsers:product", void 0, "success");
903
- return result;
1579
+ audit("dsers_store_discover", "mcp");
1580
+ const result = await withTimeout(svc.getRuleCapabilities(input), 6e4, "Store discovery");
1581
+ audit("dsers_store_discover", "mcp", void 0, "success");
1582
+ return compactToolResult(result, jmap);
904
1583
  }
905
- }),
906
- pushToStore: aiTool({
907
- description: "Push products from import list to a connected store. ALWAYS confirm with user first.",
1584
+ });
1585
+ tools.dsers_product_import = aiTool({
1586
+ description: "Import product by URL or re-apply rules to existing job. Modes: (1) source_url for new import, (2) job_id+rules_json to update rules, (3) job_id alone to refresh. Expired job_id \u2192 re-import with source_url. Returns: job_id, title (or title_before+title_after), sell_price, cost, variants, blocked, warnings.",
908
1587
  inputSchema: z2.object({
909
- importListIds: z2.array(z2.string()).describe("Import list item IDs"),
910
- storeIds: z2.array(z2.string()).describe("Target store IDs"),
911
- pushStatus: z2.enum(["ACTIVE", "DRAFT"]).default("DRAFT")
1588
+ source_url: z2.string().optional().describe("Supplier product URL"),
1589
+ source_urls_json: z2.string().optional().describe("Batch: JSON array of URLs"),
1590
+ job_id: z2.string().optional().describe("Existing job ID for rule updates"),
1591
+ source_hint: z2.string().optional().describe("auto|aliexpress|alibaba|accio"),
1592
+ country: z2.string().default("US").describe("Country code"),
1593
+ target_store: z2.string().optional().describe("Store ID or name"),
1594
+ visibility_mode: z2.string().optional().describe("backend_only|sell_immediately"),
1595
+ rules_json: z2.string().optional().describe(
1596
+ "JSON rules. Pricing: fixed_price({fixed_price:9.99}), multiplier({multiplier:2}), fixed_markup({fixed_markup:5}). Content: title_override, description_override_html. Images: keep_first_n, drop_indexes, add_urls. variant_overrides: [{match,sell_price,compare_at_price,stock}]. option_edits: [{action,option_name,value_name?,new_name?}]."
1597
+ )
912
1598
  }),
913
1599
  execute: async (input) => {
914
- audit("pushToStore", "dsers:product", input);
915
- const result = await pushToStore(dsers, input);
916
- audit("pushToStore", "dsers:product", void 0, "success");
917
- return result;
1600
+ audit("dsers_product_import", "mcp", input);
1601
+ const payload = {};
1602
+ if (input.job_id && !input.source_url && !input.source_urls_json) {
1603
+ payload.job_id = resolveJid(input.job_id);
1604
+ if (input.rules_json) {
1605
+ try {
1606
+ payload.rules = JSON.parse(input.rules_json);
1607
+ } catch {
1608
+ throw new Error(
1609
+ 'Invalid JSON in rules_json. Expected: {"pricing":{"mode":"fixed_price","fixed_price":9.99}} or {"content":{"title_override":"New Title"}}'
1610
+ );
1611
+ }
1612
+ } else {
1613
+ payload._keep_existing_rules = true;
1614
+ }
1615
+ if (input.target_store) payload.target_store = input.target_store;
1616
+ if (input.visibility_mode) payload.visibility_mode = input.visibility_mode;
1617
+ const result2 = await withTimeout(svc.reapplyRules(payload), 12e4, "Rule reapply");
1618
+ audit("dsers_product_import", "mcp", void 0, "success");
1619
+ return compactToolResult(result2, jmap);
1620
+ }
1621
+ if (input.source_url) payload.source_url = input.source_url;
1622
+ if (input.source_urls_json) {
1623
+ try {
1624
+ payload.source_urls = JSON.parse(input.source_urls_json);
1625
+ } catch {
1626
+ throw new Error("Invalid JSON in source_urls_json.");
1627
+ }
1628
+ }
1629
+ if (input.source_hint) payload.source_hint = input.source_hint;
1630
+ if (input.country) payload.country = input.country;
1631
+ if (input.target_store) payload.target_store = input.target_store;
1632
+ payload.visibility_mode = input.visibility_mode || "backend_only";
1633
+ if (input.rules_json) {
1634
+ try {
1635
+ payload.rules = JSON.parse(input.rules_json);
1636
+ } catch {
1637
+ throw new Error("Invalid JSON in rules_json.");
1638
+ }
1639
+ }
1640
+ const result = await withTimeout(svc.prepareImportCandidate(payload), 12e4, "Product import");
1641
+ audit("dsers_product_import", "mcp", void 0, "success");
1642
+ return compactToolResult(result, jmap);
918
1643
  }
919
- }),
920
- getMyProducts: aiTool({
921
- description: "Get products already pushed to stores. Check status, inventory, or find products to update.",
1644
+ });
1645
+ tools.dsers_product_preview = aiTool({
1646
+ description: "Reload preview for an imported job. Returns prices, variants, images.",
922
1647
  inputSchema: z2.object({
923
- page: z2.number().optional(),
924
- pageSize: z2.number().optional(),
925
- keyword: z2.string().optional(),
926
- storeId: z2.string().optional()
1648
+ job_id: z2.string().describe("Job ID from dsers_product_import"),
1649
+ variant_offset: z2.number().optional().describe("Start index for variant pagination"),
1650
+ variant_limit: z2.number().optional().describe("Max variants to return")
927
1651
  }),
928
1652
  execute: async (input) => {
929
- audit("getMyProducts", "dsers:product", input);
930
- const result = await getMyProducts(dsers, input);
931
- audit("getMyProducts", "dsers:product", void 0, "success");
932
- return result;
1653
+ audit("dsers_product_preview", "mcp", input);
1654
+ const result = await withTimeout(svc.getImportPreview({ ...input, job_id: resolveJid(input.job_id) }), 3e4, "Preview");
1655
+ audit("dsers_product_preview", "mcp", void 0, "success");
1656
+ return compactToolResult(result, jmap);
933
1657
  }
934
- }),
935
- getPricingRules: aiTool({
936
- description: "Get pricing rules for a store (markup, rounding, etc.).",
1658
+ });
1659
+ tools.dsers_store_push = aiTool({
1660
+ description: "Push product(s) to store(s). Show confirmation checklist and get user approval first. May return blocked (shipping_profile, pricing_rule) or warnings. Use push_options_json with shipping_profile_name when store has multiple shipping profiles.",
937
1661
  inputSchema: z2.object({
938
- storeId: z2.string().describe("Store ID to get pricing rules for")
1662
+ job_id: z2.string().optional().describe("Job ID"),
1663
+ job_ids_json: z2.string().optional().describe("Batch: JSON array of job IDs"),
1664
+ target_store: z2.string().optional().describe("Store ID or name from dsers_store_discover"),
1665
+ target_stores_json: z2.string().optional().describe("Multi-store: JSON array of store names"),
1666
+ visibility_mode: z2.string().optional().describe("backend_only|sell_immediately"),
1667
+ force_push: z2.boolean().optional().describe("Override safety checks"),
1668
+ push_options_json: z2.string().optional().describe(
1669
+ 'Push config JSON. Include shipping_profile_name when multiple profiles exist. E.g. {"shipping_profile_name":"General Profile","pricing_rule_behavior":"apply_store_pricing_rule"}'
1670
+ )
939
1671
  }),
940
1672
  execute: async (input) => {
941
- audit("getPricingRules", "dsers:settings", input);
942
- const result = await getPricingRules(dsers, input.storeId);
943
- audit("getPricingRules", "dsers:settings", void 0, "success");
944
- return result;
1673
+ audit("dsers_store_push", "mcp", input);
1674
+ const payload = {};
1675
+ if (input.job_ids_json) {
1676
+ try {
1677
+ const ids = JSON.parse(input.job_ids_json);
1678
+ payload.job_ids = ids.map(resolveJid);
1679
+ } catch {
1680
+ throw new Error("Invalid JSON in job_ids_json");
1681
+ }
1682
+ } else if (input.job_id) {
1683
+ payload.job_id = resolveJid(input.job_id);
1684
+ }
1685
+ if (input.target_store) payload.target_store = input.target_store;
1686
+ if (input.target_stores_json) {
1687
+ try {
1688
+ payload.target_stores = JSON.parse(input.target_stores_json);
1689
+ } catch {
1690
+ throw new Error("Invalid JSON in target_stores_json");
1691
+ }
1692
+ }
1693
+ if (input.visibility_mode) payload.visibility_mode = input.visibility_mode;
1694
+ if (input.force_push) payload.force_push = true;
1695
+ if (input.push_options_json) {
1696
+ try {
1697
+ payload.push_options = JSON.parse(input.push_options_json);
1698
+ } catch {
1699
+ throw new Error("Invalid JSON in push_options_json");
1700
+ }
1701
+ }
1702
+ const result = await withTimeout(svc.confirmPushToStore(payload), 18e4, "Push to store");
1703
+ audit("dsers_store_push", "mcp", void 0, "success");
1704
+ return compactToolResult(result, jmap);
945
1705
  }
946
- }),
947
- getGlobalSettings: aiTool({
948
- description: "Get global DSers settings (notifications, sync preferences, etc.).",
1706
+ });
1707
+ tools.dsers_rules_validate = aiTool({
1708
+ description: "Validate pricing/content/image rules before importing.",
1709
+ inputSchema: z2.object({
1710
+ rules_json: z2.string().describe("Rules as JSON string"),
1711
+ target_store: z2.string().optional().describe("Store ID or name")
1712
+ }),
1713
+ execute: async (input) => {
1714
+ audit("dsers_rules_validate", "mcp");
1715
+ let rules;
1716
+ try {
1717
+ rules = JSON.parse(input.rules_json);
1718
+ } catch {
1719
+ throw new Error("Invalid JSON in rules_json");
1720
+ }
1721
+ const result = await withTimeout(svc.validateRules({ rules, target_store: input.target_store ?? null }), 3e4, "Rule validation");
1722
+ audit("dsers_rules_validate", "mcp", void 0, "success");
1723
+ return compactToolResult(result, jmap);
1724
+ }
1725
+ });
1726
+ tools.dsers_job_status = aiTool({
1727
+ description: "Check job status: preview_ready \u2192 push_requested \u2192 completed/failed.",
1728
+ inputSchema: z2.object({
1729
+ job_id: z2.string().describe("Job ID")
1730
+ }),
1731
+ execute: async (input) => {
1732
+ audit("dsers_job_status", "mcp", input);
1733
+ const result = await withTimeout(svc.getJobStatus({ ...input, job_id: resolveJid(input.job_id) }), 3e4, "Job status");
1734
+ audit("dsers_job_status", "mcp", void 0, "success");
1735
+ return compactToolResult(result, jmap);
1736
+ }
1737
+ });
1738
+ tools.dsers_product_visibility = aiTool({
1739
+ description: "Set job visibility: backend_only (draft) or sell_immediately (live).",
1740
+ inputSchema: z2.object({
1741
+ job_id: z2.string().describe("Job ID"),
1742
+ visibility_mode: z2.string().describe("backend_only|sell_immediately")
1743
+ }),
1744
+ execute: async (input) => {
1745
+ audit("dsers_product_visibility", "mcp", input);
1746
+ const result = await withTimeout(svc.setProductVisibility({ ...input, job_id: resolveJid(input.job_id) }), 3e4, "Set visibility");
1747
+ audit("dsers_product_visibility", "mcp", void 0, "success");
1748
+ return compactToolResult(result, jmap);
1749
+ }
1750
+ });
1751
+ tools.dsers_product_delete = aiTool({
1752
+ description: "Delete product from import list. Call without confirm first, then with confirm=true after user approves.",
1753
+ inputSchema: z2.object({
1754
+ import_item_id: z2.string().describe("Item ID from dsers_import_list_search"),
1755
+ confirm: z2.boolean().optional().describe("true only after user approves")
1756
+ }),
1757
+ execute: async (input) => {
1758
+ audit("dsers_product_delete", "mcp", input);
1759
+ const result = await withTimeout(svc.deleteImportItem(input), 3e4, "Delete import item");
1760
+ audit("dsers_product_delete", "mcp", void 0, "success");
1761
+ return compactToolResult(result, jmap);
1762
+ }
1763
+ });
1764
+ } else {
1765
+ log7.warn("MCP ImportFlowService not available \u2014 no MCP tools registered");
1766
+ }
1767
+ if (this.authCallback) {
1768
+ tools.dsers_authorize = aiTool({
1769
+ description: "Open browser for DSers login. Call when DSers ops fail with auth/401/session errors.",
949
1770
  inputSchema: z2.object({}),
950
1771
  execute: async () => {
951
- audit("getGlobalSettings", "dsers:settings");
952
- const result = await getGlobalSettings(dsers);
953
- audit("getGlobalSettings", "dsers:settings", void 0, "success");
1772
+ const result = await withTimeout(this.authCallback(), 2e5, "DSers authorization");
954
1773
  return result;
955
1774
  }
1775
+ });
1776
+ }
1777
+ tools.dsers_import_list_search = aiTool({
1778
+ description: "Search import list. Returns items with id, source_url, cost_range, sell_price_range. Use source_url with dsers_product_import for modifications.",
1779
+ inputSchema: z2.object({
1780
+ page: z2.number().optional().describe("Page number (default 1)"),
1781
+ pageSize: z2.number().optional().describe("Items per page (default 20)"),
1782
+ keyword: z2.string().optional().describe("Search keyword")
956
1783
  }),
957
- getCurrentPlan: aiTool({
958
- description: "Check the user's current DSers subscription plan and limits.",
959
- inputSchema: z2.object({}),
960
- execute: async () => {
961
- audit("getCurrentPlan", "dsers:settings");
962
- const result = await getCurrentPlan(dsers);
963
- audit("getCurrentPlan", "dsers:settings", void 0, "success");
964
- return result;
1784
+ execute: async (input) => {
1785
+ audit("dsers_import_list_search", "dsers:product", input);
1786
+ const result = await withTimeout(getImportList(dsers, input), 3e4, "Search import list");
1787
+ audit("dsers_import_list_search", "dsers:product", void 0, "success");
1788
+ const items = result?.data?.list ?? result?.data ?? [];
1789
+ if (Array.isArray(items) && items.length > 0) {
1790
+ const enriched = await Promise.allSettled(
1791
+ items.slice(0, 10).map(async (item) => {
1792
+ const itemId = item.id ?? item.importListId;
1793
+ if (!itemId) return item;
1794
+ try {
1795
+ const detail = await withTimeout(
1796
+ getImportListItem(dsers, String(itemId)),
1797
+ 15e3,
1798
+ "Item detail"
1799
+ );
1800
+ const detailData = detail?.data ?? detail;
1801
+ const variants = detailData?.variants ?? detailData?.skuList ?? detailData?.variantList ?? [];
1802
+ if (!Array.isArray(variants) || !variants.length) return item;
1803
+ const sellPrices = [];
1804
+ const costPrices = [];
1805
+ for (const v of variants) {
1806
+ const rawSell = Number(v.sellPrice ?? v.salePrice ?? v.price);
1807
+ const rawCost = Number(v.supplierPrice ?? v.buyPrice ?? v.cost);
1808
+ const sell = rawSell > 100 ? rawSell / 100 : rawSell;
1809
+ const cost = rawCost > 100 ? rawCost / 100 : rawCost;
1810
+ if (Number.isFinite(sell) && sell > 0) sellPrices.push(sell);
1811
+ if (Number.isFinite(cost) && cost > 0) costPrices.push(cost);
1812
+ }
1813
+ const enrichedItem = { ...item };
1814
+ const supplyProductId = detailData?.supplyProductId ?? item?.supplyProductId;
1815
+ const supplyAppId = Number(detailData?.supplyAppId ?? item?.supplyAppId ?? 1);
1816
+ if (supplyProductId) {
1817
+ if (supplyAppId === 2) {
1818
+ enrichedItem.source_url = `https://www.temu.com/product/${supplyProductId}.html`;
1819
+ } else if (supplyAppId === 3) {
1820
+ enrichedItem.source_url = `https://detail.1688.com/offer/${supplyProductId}.html`;
1821
+ } else {
1822
+ enrichedItem.source_url = `https://www.aliexpress.com/item/${supplyProductId}.html`;
1823
+ }
1824
+ }
1825
+ if (costPrices.length) {
1826
+ enrichedItem.cost_range = `$${Math.min(...costPrices).toFixed(2)} \u2013 $${Math.max(...costPrices).toFixed(2)}`;
1827
+ }
1828
+ if (sellPrices.length) {
1829
+ enrichedItem.sell_price_range = `$${Math.min(...sellPrices).toFixed(2)} \u2013 $${Math.max(...sellPrices).toFixed(2)}`;
1830
+ enrichedItem.no_markup = sellPrices.every((s, i) => Math.abs(s - (costPrices[i] ?? s)) < 0.01);
1831
+ }
1832
+ enrichedItem.variant_count = variants.length;
1833
+ let totalStock = 0;
1834
+ const lowStockVariants = [];
1835
+ for (const v of variants) {
1836
+ const stock = Number(v.stock ?? v.quantity ?? v.inventory ?? 0);
1837
+ const safeStock = Number.isFinite(stock) ? stock : 0;
1838
+ totalStock += safeStock;
1839
+ if (safeStock <= 5) {
1840
+ lowStockVariants.push({
1841
+ id: String(v.id ?? v.variantId ?? v.skuId ?? ""),
1842
+ sku: String(v.sku ?? v.skuAttr ?? v.optionName ?? v.title ?? ""),
1843
+ stock: safeStock
1844
+ });
1845
+ }
1846
+ }
1847
+ enrichedItem.total_stock = totalStock;
1848
+ if (lowStockVariants.length > 0) {
1849
+ enrichedItem.low_stock_variants = lowStockVariants;
1850
+ enrichedItem.low_stock_warning = `${lowStockVariants.length} of ${variants.length} variants have stock \u2264 5`;
1851
+ }
1852
+ return enrichedItem;
1853
+ } catch (err) {
1854
+ log7.warn({ err, itemId }, "Failed to enrich import list item");
1855
+ return item;
1856
+ }
1857
+ })
1858
+ );
1859
+ const enrichedItems = enriched.map((r) => r.status === "fulfilled" ? r.value : items[0]);
1860
+ const cleanItems = enrichedItems.map((item) => ({
1861
+ id: item.id ?? item.importListId,
1862
+ title: item.title ?? "(untitled)",
1863
+ source_url: item.source_url,
1864
+ cost_range: item.cost_range,
1865
+ sell_price_range: item.sell_price_range,
1866
+ no_markup: item.no_markup,
1867
+ variant_count: item.variant_count,
1868
+ total_stock: item.total_stock,
1869
+ low_stock_warning: item.low_stock_warning,
1870
+ low_stock_variants: item.low_stock_variants
1871
+ }));
1872
+ return { items: cleanItems, total: result?.data?.total ?? cleanItems.length };
965
1873
  }
966
- })
967
- };
1874
+ const rawItems = result?.data ?? [];
1875
+ if (Array.isArray(rawItems)) {
1876
+ return { items: rawItems.map((item) => ({
1877
+ id: item.id ?? item.importListId,
1878
+ title: item.title ?? "(untitled)"
1879
+ })), total: rawItems.length };
1880
+ }
1881
+ return compactToolResult(result);
1882
+ }
1883
+ });
1884
+ tools.dsers_my_products = aiTool({
1885
+ description: "Get products already pushed to stores.",
1886
+ inputSchema: z2.object({
1887
+ page: z2.number().optional(),
1888
+ pageSize: z2.number().optional(),
1889
+ keyword: z2.string().optional(),
1890
+ storeId: z2.string().optional()
1891
+ }),
1892
+ execute: async (input) => {
1893
+ audit("dsers_my_products", "dsers:product", input);
1894
+ const result = await withTimeout(getMyProducts(dsers, input), 3e4, "My products");
1895
+ audit("dsers_my_products", "dsers:product", void 0, "success");
1896
+ return compactToolResult(result);
1897
+ }
1898
+ });
1899
+ return tools;
968
1900
  }
969
1901
  };
970
1902
 
1903
+ // src/gateway/gateway.ts
1904
+ import { MemoryJobStore as MemoryJobStore2 } from "@lofder/dsers-mcp-product/dist/job-store-memory.js";
1905
+
971
1906
  // src/web/server.ts
1907
+ init_logger();
972
1908
  import { createServer } from "http";
973
- import { readFileSync as readFileSync4, existsSync as existsSync5 } from "fs";
974
- import { join as join5, dirname as dirname2 } from "path";
1909
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync5, mkdirSync as mkdirSync6, existsSync as existsSync7, statSync, readdirSync } from "fs";
1910
+ import { join as join7, dirname as dirname2, extname } from "path";
975
1911
  import { fileURLToPath } from "url";
976
- import { WebSocketServer, WebSocket } from "ws";
977
- import { v4 as uuid } from "uuid";
978
- var log7 = createLogger("channel:web");
979
- function findChatHtml() {
1912
+ import { homedir as homedir3 } from "os";
1913
+ import { WebSocketServer, WebSocket as WebSocket2 } from "ws";
1914
+ import { nanoid as nanoid2 } from "nanoid";
1915
+ var log8 = createLogger("channel:web");
1916
+ var DEFAULT_WEB_USER = "web-default";
1917
+ var MIME_TYPES = {
1918
+ ".html": "text/html; charset=utf-8",
1919
+ ".js": "application/javascript; charset=utf-8",
1920
+ ".css": "text/css; charset=utf-8",
1921
+ ".json": "application/json",
1922
+ ".svg": "image/svg+xml",
1923
+ ".png": "image/png",
1924
+ ".ico": "image/x-icon",
1925
+ ".woff2": "font/woff2",
1926
+ ".woff": "font/woff"
1927
+ };
1928
+ function findWebDir() {
980
1929
  const thisDir = dirname2(fileURLToPath(import.meta.url));
981
1930
  const candidates = [
982
- join5(thisDir, "chat.html"),
983
- join5(thisDir, "web", "chat.html"),
984
- join5(thisDir, "..", "web", "chat.html"),
985
- join5(thisDir, "..", "src", "web", "chat.html"),
986
- join5(thisDir, "..", "..", "src", "web", "chat.html")
1931
+ join7(thisDir, "web"),
1932
+ join7(thisDir),
1933
+ join7(thisDir, "..", "web"),
1934
+ join7(thisDir, "..", "dist", "web"),
1935
+ join7(thisDir, "..", "..", "dist", "web")
987
1936
  ];
988
- for (const p of candidates) {
989
- if (existsSync5(p)) {
990
- return readFileSync4(p, "utf-8");
991
- }
1937
+ for (const d of candidates) {
1938
+ if (existsSync7(join7(d, "index.html"))) return d;
992
1939
  }
993
1940
  throw new Error(
994
- "chat.html not found. Looked in:\n" + candidates.join("\n")
1941
+ `Web build not found (index.html). Looked in:
1942
+ ` + candidates.join("\n")
995
1943
  );
996
1944
  }
997
- var WebChatServer = class {
1945
+ var WebChatServer = class _WebChatServer {
998
1946
  name = "web";
999
1947
  server = null;
1000
1948
  wss = null;
1001
1949
  handler = null;
1002
1950
  connectHandler = null;
1951
+ settingsHandler = null;
1003
1952
  connections = /* @__PURE__ */ new Map();
1004
1953
  _connected = false;
1005
1954
  port = 3e3;
1006
1955
  get connected() {
1007
1956
  return this._connected;
1008
1957
  }
1958
+ get actualPort() {
1959
+ return this.port;
1960
+ }
1009
1961
  async connect(config) {
1010
- this.port = config["port"] ?? 3e3;
1011
- const chatHtml = findChatHtml();
1012
- this.server = createServer((req, res) => {
1013
- if (req.url === "/" || req.url === "/index.html") {
1014
- res.writeHead(200, {
1015
- "Content-Type": "text/html; charset=utf-8",
1016
- "Cache-Control": "no-cache"
1017
- });
1018
- res.end(chatHtml);
1019
- return;
1962
+ const basePort = config["port"] ?? 3e3;
1963
+ const webDir = findWebDir();
1964
+ const maxAttempts = 10;
1965
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1966
+ const tryPort = basePort + attempt;
1967
+ const ok = await this.tryListen(tryPort, webDir);
1968
+ if (ok) return;
1969
+ if (attempt < maxAttempts - 1) {
1970
+ log8.warn({ port: tryPort }, "Port in use, trying next");
1020
1971
  }
1021
- if (req.url === "/health") {
1022
- res.writeHead(200, { "Content-Type": "application/json" });
1023
- res.end(JSON.stringify({ ok: true, clients: this.connections.size }));
1024
- return;
1025
- }
1026
- res.writeHead(404);
1027
- res.end("Not Found");
1972
+ }
1973
+ throw new Error(`No available port found (tried ${basePort}-${basePort + maxAttempts - 1})`);
1974
+ }
1975
+ tryListen(tryPort, webDir) {
1976
+ return new Promise((resolve) => {
1977
+ const indexHtml = readFileSync5(join7(webDir, "index.html"), "utf-8");
1978
+ const srv = createServer((req, res) => {
1979
+ if (req.url === "/health") {
1980
+ res.writeHead(200, { "Content-Type": "application/json" });
1981
+ res.end(JSON.stringify({ ok: true, clients: this.connections.size }));
1982
+ return;
1983
+ }
1984
+ const urlPath = req.url?.split("?")[0] ?? "/";
1985
+ if (urlPath.startsWith("/uploads/")) {
1986
+ const uploadsDir = join7(homedir3(), ".dsclaw", "uploads");
1987
+ const fileName = urlPath.slice("/uploads/".length);
1988
+ if (/^[a-zA-Z0-9._-]+$/.test(fileName)) {
1989
+ const filePath2 = join7(uploadsDir, fileName);
1990
+ if (existsSync7(filePath2) && statSync(filePath2).isFile()) {
1991
+ const ext = extname(filePath2);
1992
+ const mime = MIME_TYPES[ext] ?? "application/octet-stream";
1993
+ res.writeHead(200, { "Content-Type": mime, "Cache-Control": "public, max-age=86400" });
1994
+ res.end(readFileSync5(filePath2));
1995
+ return;
1996
+ }
1997
+ }
1998
+ res.writeHead(404);
1999
+ res.end("Not found");
2000
+ return;
2001
+ }
2002
+ if (urlPath !== "/" && urlPath !== "/index.html") {
2003
+ const filePath2 = join7(webDir, urlPath);
2004
+ if (existsSync7(filePath2) && statSync(filePath2).isFile()) {
2005
+ const ext = extname(filePath2);
2006
+ const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
2007
+ const cacheHeader = urlPath.includes("/assets/") ? "public, max-age=31536000, immutable" : "no-cache";
2008
+ res.writeHead(200, { "Content-Type": contentType, "Cache-Control": cacheHeader });
2009
+ res.end(readFileSync5(filePath2));
2010
+ return;
2011
+ }
2012
+ }
2013
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
2014
+ res.end(indexHtml);
2015
+ });
2016
+ srv.once("error", (err) => {
2017
+ if (err.code === "EADDRINUSE") {
2018
+ resolve(false);
2019
+ } else {
2020
+ throw err;
2021
+ }
2022
+ });
2023
+ srv.listen(tryPort, () => {
2024
+ this.server = srv;
2025
+ this.port = tryPort;
2026
+ this._connected = true;
2027
+ this.setupWebSocket();
2028
+ log8.info({ port: tryPort }, "Web chat server started");
2029
+ resolve(true);
2030
+ });
1028
2031
  });
2032
+ }
2033
+ setupWebSocket() {
1029
2034
  this.wss = new WebSocketServer({ server: this.server, path: "/ws" });
1030
- this.wss.on("connection", (ws, req) => {
1031
- const url = new URL(req.url ?? "/", `http://localhost:${this.port}`);
1032
- let clientId = url.searchParams.get("userId") || uuid();
2035
+ this.wss.on("connection", (ws, _req) => {
2036
+ let clientId = DEFAULT_WEB_USER;
1033
2037
  this.wsSend(ws, { type: "session", userId: clientId });
1034
2038
  this.connections.set(clientId, ws);
1035
- log7.info({ userId: clientId }, "Web client connected");
2039
+ log8.info({ userId: clientId }, "Web client connected");
1036
2040
  if (this.connectHandler) {
1037
2041
  try {
1038
2042
  this.connectHandler(clientId);
1039
2043
  } catch (err) {
1040
- log7.error({ error: err, userId: clientId }, "Connect handler failed");
2044
+ log8.error({ error: err, userId: clientId }, "Connect handler failed");
1041
2045
  }
1042
2046
  }
2047
+ try {
2048
+ const files = this.buildFileTree();
2049
+ this.wsSend(ws, { type: "file_list", files });
2050
+ } catch (err) {
2051
+ log8.warn({ error: err }, "Failed to push initial file_list");
2052
+ }
1043
2053
  ws.on("message", async (raw) => {
1044
2054
  try {
1045
2055
  const msg = JSON.parse(raw.toString());
1046
- if (msg["userId"]) clientId = msg["userId"];
1047
2056
  this.connections.set(clientId, ws);
1048
- if (!this.handler) return;
1049
2057
  const msgType = msg["type"];
2058
+ if (msgType === "get_settings" || msgType === "start_dsers_auth" || msgType === "cancel_dsers_auth" || msgType === "save_dsers_session" || msgType === "save_llm" || msgType === "disconnect_dsers" || msgType === "set_language") {
2059
+ log8.info({ userId: clientId, action: msgType }, "Settings message received");
2060
+ if (this.settingsHandler) {
2061
+ await this.settingsHandler(clientId, msgType, msg);
2062
+ }
2063
+ return;
2064
+ }
2065
+ if (msgType === "list_files") {
2066
+ const files = this.buildFileTree();
2067
+ this.wsSend(ws, { type: "file_list", files });
2068
+ return;
2069
+ }
2070
+ if (msgType === "read_file") {
2071
+ const filePath2 = msg["path"];
2072
+ this.handleReadFile(ws, filePath2);
2073
+ return;
2074
+ }
2075
+ if (!this.handler) return;
1050
2076
  if (msgType === "message" || msgType === "callback") {
2077
+ const rawAttachments = msg["attachments"];
2078
+ const attachments = rawAttachments?.length ? this.saveAttachments(rawAttachments) : void 0;
1051
2079
  const normalized = {
1052
- traceId: uuid(),
2080
+ traceId: nanoid2(),
1053
2081
  channelId: "web",
1054
2082
  channelName: "Web Chat",
1055
2083
  userId: clientId,
1056
2084
  text: msgType === "callback" ? msg["data"] : msg["text"],
2085
+ attachments,
1057
2086
  timestamp: /* @__PURE__ */ new Date(),
1058
2087
  metadata: {
1059
2088
  isCallback: msgType === "callback"
@@ -1062,24 +2091,16 @@ var WebChatServer = class {
1062
2091
  await this.handler(normalized);
1063
2092
  }
1064
2093
  } catch (error) {
1065
- log7.error({ error }, "WS message handler failed");
2094
+ log8.error({ error }, "WS message handler failed");
1066
2095
  }
1067
2096
  });
1068
2097
  ws.on("close", () => {
1069
2098
  this.connections.delete(clientId);
1070
- log7.debug({ userId: clientId }, "Web client disconnected");
2099
+ log8.debug({ userId: clientId }, "Web client disconnected");
1071
2100
  });
1072
2101
  ws.on("error", (err) => {
1073
- log7.warn({ error: err.message, userId: clientId }, "WS error");
1074
- });
1075
- });
1076
- return new Promise((resolve, reject) => {
1077
- this.server.listen(this.port, () => {
1078
- this._connected = true;
1079
- log7.info({ port: this.port }, "Web chat server started");
1080
- resolve();
2102
+ log8.warn({ error: err.message, userId: clientId }, "WS error");
1081
2103
  });
1082
- this.server.on("error", reject);
1083
2104
  });
1084
2105
  }
1085
2106
  async disconnect() {
@@ -1093,7 +2114,7 @@ var WebChatServer = class {
1093
2114
  else resolve();
1094
2115
  });
1095
2116
  this._connected = false;
1096
- log7.info("Web chat server stopped");
2117
+ log8.info("Web chat server stopped");
1097
2118
  }
1098
2119
  async reconnect() {
1099
2120
  await this.disconnect();
@@ -1105,6 +2126,9 @@ var WebChatServer = class {
1105
2126
  onConnect(handler) {
1106
2127
  this.connectHandler = handler;
1107
2128
  }
2129
+ onSettings(handler) {
2130
+ this.settingsHandler = handler;
2131
+ }
1108
2132
  async send(targetUserId, message) {
1109
2133
  if (message.card) {
1110
2134
  await this.sendCard(targetUserId, message.card);
@@ -1114,6 +2138,9 @@ var WebChatServer = class {
1114
2138
  this.wsSendTo(targetUserId, { type: "message", text: message.text });
1115
2139
  }
1116
2140
  }
2141
+ sendToast(targetUserId, text, level = "info", id) {
2142
+ this.wsSendTo(targetUserId, { type: "toast", text, level, ...id && { id } });
2143
+ }
1117
2144
  async sendCard(targetUserId, card) {
1118
2145
  this.wsSendTo(targetUserId, {
1119
2146
  type: "card",
@@ -1138,28 +2165,154 @@ var WebChatServer = class {
1138
2165
  sendTyping(userId, active) {
1139
2166
  this.wsSendTo(userId, { type: "typing", active });
1140
2167
  }
2168
+ sendStatus(userId, status) {
2169
+ this.wsSendTo(userId, { type: "status", status });
2170
+ }
2171
+ sendReset(userId) {
2172
+ this.wsSendTo(userId, { type: "reset" });
2173
+ }
1141
2174
  sendClearInput(userId) {
1142
2175
  this.wsSendTo(userId, { type: "clear_input" });
1143
2176
  }
1144
2177
  sendSetInputType(userId, inputType) {
1145
2178
  this.wsSendTo(userId, { type: "set_input_type", inputType });
1146
2179
  }
2180
+ sendSettingsState(userId, state) {
2181
+ this.wsSendTo(userId, { type: "settings_state", ...state });
2182
+ }
2183
+ sendSettingsResult(userId, result) {
2184
+ this.wsSendTo(userId, { type: "settings_result", ...result });
2185
+ }
2186
+ // ─── Tool Call Events ────────────────────────────────────────
2187
+ sendToolCallStart(userId, id, name, args) {
2188
+ this.wsSendTo(userId, { type: "tool_call_start", id, name, args });
2189
+ }
2190
+ sendToolCallEnd(userId, id, name, result, error, durationMs) {
2191
+ this.wsSendTo(userId, { type: "tool_call_end", id, name, result, error, durationMs });
2192
+ }
2193
+ // ─── Log & Init Progress ────────────────────────────────────
2194
+ sendLog(userId, entry) {
2195
+ this.wsSendTo(userId, { type: "log", ...entry });
2196
+ }
2197
+ sendInitProgress(userId, step) {
2198
+ this.wsSendTo(userId, { type: "init_progress", ...step });
2199
+ }
2200
+ // ─── Attachment Storage ────────────────────────────────────────
2201
+ saveAttachments(rawAttachments) {
2202
+ const uploadsDir = join7(homedir3(), ".dsclaw", "uploads");
2203
+ mkdirSync6(uploadsDir, { recursive: true });
2204
+ return rawAttachments.map((a) => {
2205
+ const ext = a.mimeType.split("/")[1]?.replace("jpeg", "jpg") ?? "png";
2206
+ const fileName = `${nanoid2()}.${ext}`;
2207
+ const filePath2 = join7(uploadsDir, fileName);
2208
+ const buffer = Buffer.from(a.data, "base64");
2209
+ writeFileSync5(filePath2, buffer);
2210
+ log8.info({ fileName, size: buffer.length }, "Saved attachment");
2211
+ return {
2212
+ type: "image",
2213
+ url: `/uploads/${fileName}`,
2214
+ data: buffer,
2215
+ mimeType: a.mimeType,
2216
+ fileName: a.name
2217
+ };
2218
+ });
2219
+ }
2220
+ // ─── File Browsing ───────────────────────────────────────────
2221
+ buildFileTree() {
2222
+ const configDir = join7(homedir3(), ".dsclaw");
2223
+ if (!existsSync7(configDir)) return [];
2224
+ const scanDir = (dirPath, depth = 0) => {
2225
+ if (depth > 5) return [];
2226
+ try {
2227
+ const entries = readdirSync(dirPath, { withFileTypes: true });
2228
+ return entries.filter((e) => !e.name.startsWith(".")).sort((a, b) => {
2229
+ if (a.isDirectory() && !b.isDirectory()) return -1;
2230
+ if (!a.isDirectory() && b.isDirectory()) return 1;
2231
+ return a.name.localeCompare(b.name);
2232
+ }).map((entry) => {
2233
+ const fullPath = join7(dirPath, entry.name);
2234
+ if (entry.isDirectory()) {
2235
+ return {
2236
+ name: entry.name,
2237
+ path: fullPath,
2238
+ type: "dir",
2239
+ children: scanDir(fullPath, depth + 1)
2240
+ };
2241
+ }
2242
+ let size;
2243
+ try {
2244
+ size = statSync(fullPath).size;
2245
+ } catch {
2246
+ }
2247
+ return { name: entry.name, path: fullPath, type: "file", size };
2248
+ });
2249
+ } catch {
2250
+ return [];
2251
+ }
2252
+ };
2253
+ return scanDir(configDir);
2254
+ }
2255
+ static IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
2256
+ static IMAGE_MIME = {
2257
+ ".png": "image/png",
2258
+ ".jpg": "image/jpeg",
2259
+ ".jpeg": "image/jpeg",
2260
+ ".gif": "image/gif",
2261
+ ".webp": "image/webp",
2262
+ ".svg": "image/svg+xml"
2263
+ };
2264
+ handleReadFile(ws, filePath2) {
2265
+ const configDir = join7(homedir3(), ".dsclaw");
2266
+ if (!filePath2.startsWith(configDir)) {
2267
+ this.wsSend(ws, { type: "file_content", path: filePath2, content: "[\u8BBF\u95EE\u88AB\u62D2\u7EDD\uFF1A\u53EA\u80FD\u8BFB\u53D6 ~/.dsclaw/ \u4E0B\u7684\u6587\u4EF6]" });
2268
+ return;
2269
+ }
2270
+ try {
2271
+ if (!existsSync7(filePath2) || !statSync(filePath2).isFile()) {
2272
+ this.wsSend(ws, { type: "file_content", path: filePath2, content: "[\u6587\u4EF6\u4E0D\u5B58\u5728]" });
2273
+ return;
2274
+ }
2275
+ const size = statSync(filePath2).size;
2276
+ if (size > 2 * 1024 * 1024) {
2277
+ this.wsSend(ws, { type: "file_content", path: filePath2, content: `[\u6587\u4EF6\u8FC7\u5927\uFF1A${(size / 1024).toFixed(0)}KB\uFF0C\u6700\u5927 2MB]` });
2278
+ return;
2279
+ }
2280
+ const ext = extname(filePath2).toLowerCase();
2281
+ if (_WebChatServer.IMAGE_EXTS.has(ext)) {
2282
+ const buf = readFileSync5(filePath2);
2283
+ const mime = _WebChatServer.IMAGE_MIME[ext] ?? "application/octet-stream";
2284
+ const dataUrl = `data:${mime};base64,${buf.toString("base64")}`;
2285
+ this.wsSend(ws, { type: "file_content", path: filePath2, content: dataUrl, isImage: true });
2286
+ return;
2287
+ }
2288
+ const content = readFileSync5(filePath2, "utf-8");
2289
+ this.wsSend(ws, { type: "file_content", path: filePath2, content });
2290
+ } catch (err) {
2291
+ const reason = err instanceof Error ? err.message : "unknown";
2292
+ this.wsSend(ws, { type: "file_content", path: filePath2, content: `[\u8BFB\u53D6\u5931\u8D25\uFF1A${reason}]` });
2293
+ }
2294
+ }
1147
2295
  // ─── Internal ──────────────────────────────────────────────
1148
2296
  wsSendTo(userId, data) {
1149
2297
  const ws = this.connections.get(userId);
1150
- if (ws && ws.readyState === WebSocket.OPEN) {
2298
+ if (ws && ws.readyState === WebSocket2.OPEN) {
1151
2299
  this.wsSend(ws, data);
1152
2300
  }
1153
2301
  }
1154
2302
  wsSend(ws, data) {
1155
- ws.send(JSON.stringify(data));
2303
+ try {
2304
+ ws.send(JSON.stringify(data));
2305
+ } catch (err) {
2306
+ log8.warn({ error: err instanceof Error ? err.message : String(err) }, "wsSend failed");
2307
+ }
1156
2308
  }
1157
2309
  };
1158
2310
 
1159
2311
  // src/channels/telegram.ts
2312
+ init_logger();
1160
2313
  import { Bot, InlineKeyboard } from "grammy";
1161
- import { v4 as uuid2 } from "uuid";
1162
- var log8 = createLogger("channel:telegram");
2314
+ import { nanoid as nanoid3 } from "nanoid";
2315
+ var log9 = createLogger("channel:telegram");
1163
2316
  var TelegramAdapter = class {
1164
2317
  name = "telegram";
1165
2318
  bot = null;
@@ -1176,7 +2329,7 @@ var TelegramAdapter = class {
1176
2329
  this.bot.on("message:text", async (ctx) => {
1177
2330
  if (!this.handler) return;
1178
2331
  const normalized = {
1179
- traceId: uuid2(),
2332
+ traceId: nanoid3(),
1180
2333
  channelId: "telegram",
1181
2334
  channelName: "Telegram",
1182
2335
  userId: String(ctx.from.id),
@@ -1191,7 +2344,7 @@ var TelegramAdapter = class {
1191
2344
  try {
1192
2345
  await this.handler(normalized);
1193
2346
  } catch (error) {
1194
- log8.error({ error, userId: normalized.userId }, "Message handler error");
2347
+ log9.error({ error, userId: normalized.userId }, "Message handler error");
1195
2348
  await ctx.reply("Sorry, something went wrong. Please try again.");
1196
2349
  }
1197
2350
  });
@@ -1199,7 +2352,7 @@ var TelegramAdapter = class {
1199
2352
  if (!this.handler) return;
1200
2353
  await ctx.answerCallbackQuery();
1201
2354
  const normalized = {
1202
- traceId: uuid2(),
2355
+ traceId: nanoid3(),
1203
2356
  channelId: "telegram",
1204
2357
  channelName: "Telegram",
1205
2358
  userId: String(ctx.from.id),
@@ -1214,30 +2367,30 @@ var TelegramAdapter = class {
1214
2367
  try {
1215
2368
  await this.handler(normalized);
1216
2369
  } catch (error) {
1217
- log8.error({ error }, "Callback handler error");
2370
+ log9.error({ error }, "Callback handler error");
1218
2371
  }
1219
2372
  });
1220
2373
  this.bot.catch((err) => {
1221
- log8.error({ error: err.message }, "Bot error \u2014 reconnecting...");
2374
+ log9.error({ error: err.message }, "Bot error \u2014 reconnecting...");
1222
2375
  this._connected = false;
1223
2376
  setTimeout(() => this.reconnect(), 5e3);
1224
2377
  });
1225
2378
  await this.bot.init();
1226
- log8.info({ botName: this.bot.botInfo.username }, "Telegram bot initialized");
2379
+ log9.info({ botName: this.bot.botInfo.username }, "Telegram bot initialized");
1227
2380
  this.bot.start({
1228
2381
  onStart: () => {
1229
2382
  this._connected = true;
1230
- log8.info("Telegram bot polling started");
2383
+ log9.info("Telegram bot polling started");
1231
2384
  }
1232
2385
  });
1233
2386
  }
1234
2387
  async disconnect() {
1235
2388
  this.bot?.stop();
1236
2389
  this._connected = false;
1237
- log8.info("Telegram bot disconnected");
2390
+ log9.info("Telegram bot disconnected");
1238
2391
  }
1239
2392
  async reconnect() {
1240
- log8.info("Reconnecting Telegram bot...");
2393
+ log9.info("Reconnecting Telegram bot...");
1241
2394
  await this.disconnect();
1242
2395
  await this.connect({ botToken: this.botToken });
1243
2396
  }
@@ -1275,30 +2428,31 @@ ${card.summary}`;
1275
2428
  try {
1276
2429
  await this.bot.api.deleteMessage(chatId, messageId);
1277
2430
  } catch (error) {
1278
- log8.warn({ chatId, messageId, error }, "Failed to delete message");
2431
+ log9.warn({ chatId, messageId, error }, "Failed to delete message");
1279
2432
  }
1280
2433
  }
1281
2434
  };
1282
2435
 
1283
2436
  // src/memory/file-provider.ts
1284
2437
  import {
1285
- readFileSync as readFileSync5,
1286
- writeFileSync as writeFileSync4,
2438
+ readFileSync as readFileSync6,
2439
+ writeFileSync as writeFileSync6,
1287
2440
  appendFileSync as appendFileSync2,
1288
- existsSync as existsSync6,
1289
- mkdirSync as mkdirSync5,
1290
- readdirSync
2441
+ existsSync as existsSync8,
2442
+ mkdirSync as mkdirSync7,
2443
+ readdirSync as readdirSync2
1291
2444
  } from "fs";
1292
- import { join as join6 } from "path";
2445
+ import { join as join8 } from "path";
1293
2446
  import { randomUUID } from "crypto";
1294
- var log9 = createLogger("memory:file");
1295
- var MEMORY_DIR = join6(CONFIG_DIR, "memories");
2447
+ init_logger();
2448
+ var log10 = createLogger("memory:file");
2449
+ var MEMORY_DIR = join8(CONFIG_DIR, "memories");
1296
2450
  var FileMemoryProvider = class {
1297
2451
  name = "file";
1298
2452
  degraded = false;
1299
2453
  constructor() {
1300
- if (!existsSync6(MEMORY_DIR)) {
1301
- mkdirSync5(MEMORY_DIR, { recursive: true });
2454
+ if (!existsSync8(MEMORY_DIR)) {
2455
+ mkdirSync7(MEMORY_DIR, { recursive: true });
1302
2456
  }
1303
2457
  }
1304
2458
  async add(content, metadata) {
@@ -1313,7 +2467,7 @@ var FileMemoryProvider = class {
1313
2467
  };
1314
2468
  const file = this.userFile(metadata.userId);
1315
2469
  appendFileSync2(file, JSON.stringify(entry) + "\n");
1316
- log9.debug({ id, userId: metadata.userId }, "Memory added");
2470
+ log10.debug({ id, userId: metadata.userId }, "Memory added");
1317
2471
  return id;
1318
2472
  }
1319
2473
  async search(query, filters) {
@@ -1322,25 +2476,25 @@ var FileMemoryProvider = class {
1322
2476
  if (keywords.length === 0) {
1323
2477
  return all.slice(0, filters.limit ?? 10);
1324
2478
  }
1325
- const scored = all.map((m) => {
1326
- const text = m.content.toLowerCase();
2479
+ const scored = all.map((m2) => {
2480
+ const text = m2.content.toLowerCase();
1327
2481
  const matchCount = keywords.filter((k) => text.includes(k)).length;
1328
- return { memory: m, score: matchCount / keywords.length };
2482
+ return { memory: m2, score: matchCount / keywords.length };
1329
2483
  }).filter((s) => s.score > 0).sort((a, b) => b.score - a.score).slice(0, filters.limit ?? 10);
1330
2484
  return scored.map((s) => ({ ...s.memory, score: s.score }));
1331
2485
  }
1332
2486
  async getAll(filters) {
1333
- const files = filters.userId ? [this.userFile(filters.userId)] : readdirSync(MEMORY_DIR).filter((f) => f.endsWith(".jsonl")).map((f) => join6(MEMORY_DIR, f));
2487
+ const files = filters.userId ? [this.userFile(filters.userId)] : readdirSync2(MEMORY_DIR).filter((f) => f.endsWith(".jsonl")).map((f) => join8(MEMORY_DIR, f));
1334
2488
  const results = [];
1335
2489
  for (const file of files) {
1336
- if (!existsSync6(file)) continue;
1337
- const lines = readFileSync5(file, "utf-8").split("\n").filter((l) => l.trim());
2490
+ if (!existsSync8(file)) continue;
2491
+ const lines = readFileSync6(file, "utf-8").split("\n").filter((l) => l.trim());
1338
2492
  for (const line of lines) {
1339
2493
  try {
1340
2494
  const stored = JSON.parse(line);
1341
2495
  if (filters.category && stored.metadata.category !== filters.category) continue;
1342
2496
  if (filters.sessionId && stored.metadata.sessionId !== filters.sessionId) continue;
1343
- if (filters.tags && filters.tags.length > 0 && !filters.tags.some((t) => stored.metadata.tags?.includes(t))) continue;
2497
+ if (filters.tags && filters.tags.length > 0 && !filters.tags.some((t2) => stored.metadata.tags?.includes(t2))) continue;
1344
2498
  results.push({
1345
2499
  id: stored.id,
1346
2500
  content: stored.content,
@@ -1355,10 +2509,10 @@ var FileMemoryProvider = class {
1355
2509
  return results.slice(0, filters.limit ?? 100);
1356
2510
  }
1357
2511
  async update(id, content) {
1358
- const files = readdirSync(MEMORY_DIR).filter((f) => f.endsWith(".jsonl")).map((f) => join6(MEMORY_DIR, f));
2512
+ const files = readdirSync2(MEMORY_DIR).filter((f) => f.endsWith(".jsonl")).map((f) => join8(MEMORY_DIR, f));
1359
2513
  for (const file of files) {
1360
- if (!existsSync6(file)) continue;
1361
- const lines = readFileSync5(file, "utf-8").split("\n").filter((l) => l.trim());
2514
+ if (!existsSync8(file)) continue;
2515
+ const lines = readFileSync6(file, "utf-8").split("\n").filter((l) => l.trim());
1362
2516
  const updated = lines.map((line) => {
1363
2517
  try {
1364
2518
  const stored = JSON.parse(line);
@@ -1371,14 +2525,14 @@ var FileMemoryProvider = class {
1371
2525
  return line;
1372
2526
  }
1373
2527
  });
1374
- writeFileSync4(file, updated.join("\n") + "\n");
2528
+ writeFileSync6(file, updated.join("\n") + "\n");
1375
2529
  }
1376
2530
  }
1377
2531
  async delete(id) {
1378
- const files = readdirSync(MEMORY_DIR).filter((f) => f.endsWith(".jsonl")).map((f) => join6(MEMORY_DIR, f));
2532
+ const files = readdirSync2(MEMORY_DIR).filter((f) => f.endsWith(".jsonl")).map((f) => join8(MEMORY_DIR, f));
1379
2533
  for (const file of files) {
1380
- if (!existsSync6(file)) continue;
1381
- const lines = readFileSync5(file, "utf-8").split("\n").filter((l) => l.trim());
2534
+ if (!existsSync8(file)) continue;
2535
+ const lines = readFileSync6(file, "utf-8").split("\n").filter((l) => l.trim());
1382
2536
  const filtered = lines.filter((line) => {
1383
2537
  try {
1384
2538
  const stored = JSON.parse(line);
@@ -1387,17 +2541,22 @@ var FileMemoryProvider = class {
1387
2541
  return true;
1388
2542
  }
1389
2543
  });
1390
- writeFileSync4(file, filtered.join("\n") + (filtered.length > 0 ? "\n" : ""));
2544
+ writeFileSync6(file, filtered.join("\n") + (filtered.length > 0 ? "\n" : ""));
1391
2545
  }
1392
2546
  }
1393
2547
  userFile(userId) {
1394
2548
  const safe = userId.replace(/[^a-zA-Z0-9_-]/g, "_");
1395
- return join6(MEMORY_DIR, `${safe}.jsonl`);
2549
+ return join8(MEMORY_DIR, `${safe}.jsonl`);
1396
2550
  }
1397
2551
  };
1398
2552
 
2553
+ // src/gateway/gateway.ts
2554
+ init_logger();
2555
+ init_tracer();
2556
+
1399
2557
  // src/resilience/degradation.ts
1400
- var log10 = createLogger("degradation");
2558
+ init_logger();
2559
+ var log11 = createLogger("degradation");
1401
2560
  var states = /* @__PURE__ */ new Map();
1402
2561
  function isDegraded(service) {
1403
2562
  return states.get(service)?.degraded ?? false;
@@ -1413,10 +2572,468 @@ function degradationMessage(service) {
1413
2572
  }
1414
2573
  }
1415
2574
 
2575
+ // src/context/token-counter.ts
2576
+ init_logger();
2577
+ var log12 = createLogger("context:token");
2578
+ var encode = null;
2579
+ function countTokens(text) {
2580
+ try {
2581
+ if (encode) return encode(text).length;
2582
+ } catch {
2583
+ }
2584
+ return Math.ceil(text.length / 4);
2585
+ }
2586
+
2587
+ // src/context/context-budget.ts
2588
+ init_logger();
2589
+ var log13 = createLogger("context:budget");
2590
+ var FALLBACK_MAX_MESSAGES = 20;
2591
+ var DEFAULT_BUDGET = {
2592
+ contextWindow: 128e3,
2593
+ systemPromptReserve: 8e3,
2594
+ memoriesMax: 1e3,
2595
+ toolResultsMax: 6e3,
2596
+ compactionThreshold: 0.75
2597
+ };
2598
+ function allocateBudget(systemPrompt, memories, history, config = {}) {
2599
+ const cfg = { ...DEFAULT_BUDGET, ...config };
2600
+ try {
2601
+ const systemTokens = systemPrompt ? countTokens(systemPrompt) : cfg.systemPromptReserve;
2602
+ const memoriesTokens = memories ? Math.min(countTokens(memories), cfg.memoriesMax) : 0;
2603
+ const historyBudget = cfg.contextWindow - systemTokens - memoriesTokens - cfg.toolResultsMax;
2604
+ if (historyBudget <= 0) {
2605
+ log13.warn({ systemTokens, memoriesTokens }, "No budget left for history");
2606
+ return {
2607
+ messages: [],
2608
+ historyTokens: 0,
2609
+ totalTokens: systemTokens + memoriesTokens,
2610
+ remainingTokens: 0,
2611
+ shouldCompact: true,
2612
+ fallbackMode: false
2613
+ };
2614
+ }
2615
+ const included = [];
2616
+ let historyTokens = 0;
2617
+ for (let i = history.length - 1; i >= 0; i--) {
2618
+ const msgTokens = countTokens(history[i].content) + 4;
2619
+ if (historyTokens + msgTokens > historyBudget) break;
2620
+ included.unshift(history[i]);
2621
+ historyTokens += msgTokens;
2622
+ }
2623
+ const totalTokens = systemTokens + memoriesTokens + historyTokens;
2624
+ const threshold = cfg.contextWindow * cfg.compactionThreshold;
2625
+ return {
2626
+ messages: included,
2627
+ historyTokens,
2628
+ totalTokens,
2629
+ remainingTokens: cfg.contextWindow - totalTokens,
2630
+ shouldCompact: totalTokens >= threshold,
2631
+ fallbackMode: false
2632
+ };
2633
+ } catch (err) {
2634
+ log13.warn({ err }, "Budget allocation failed, using message-count fallback");
2635
+ return {
2636
+ messages: history.slice(-FALLBACK_MAX_MESSAGES),
2637
+ historyTokens: 0,
2638
+ totalTokens: 0,
2639
+ remainingTokens: 0,
2640
+ shouldCompact: false,
2641
+ fallbackMode: true
2642
+ };
2643
+ }
2644
+ }
2645
+
2646
+ // src/context/compaction.ts
2647
+ init_logger();
2648
+ var log14 = createLogger("context:compact");
2649
+ async function compactWithFlush(allMessages, keepCount, memory, userId) {
2650
+ if (allMessages.length <= keepCount) {
2651
+ return { messages: allMessages, removedCount: 0, factsExtracted: [] };
2652
+ }
2653
+ const evicted = allMessages.slice(0, allMessages.length - keepCount);
2654
+ const kept = allMessages.slice(-keepCount);
2655
+ const factsExtracted = [];
2656
+ const facts = extractKeyFacts(evicted);
2657
+ for (const fact of facts) {
2658
+ try {
2659
+ await memory.add(fact, {
2660
+ userId,
2661
+ category: "fact"
2662
+ });
2663
+ factsExtracted.push(fact);
2664
+ } catch (err) {
2665
+ log14.warn({ err, fact }, "Failed to save fact to memory");
2666
+ }
2667
+ }
2668
+ log14.info(
2669
+ {
2670
+ userId,
2671
+ evictedMessages: evicted.length,
2672
+ keptMessages: kept.length,
2673
+ factsExtracted: factsExtracted.length
2674
+ },
2675
+ "Pre-compaction flush complete"
2676
+ );
2677
+ return {
2678
+ messages: kept,
2679
+ removedCount: evicted.length,
2680
+ factsExtracted
2681
+ };
2682
+ }
2683
+ function extractKeyFacts(messages) {
2684
+ const facts = [];
2685
+ const fullText = messages.map((m2) => m2.content).join("\n");
2686
+ const hasChinese = /[\u4e00-\u9fff]/.test(fullText);
2687
+ const urlMatches = fullText.match(
2688
+ /(?:aliexpress|alibaba|1688|accio|temu)\.com\S*/gi
2689
+ );
2690
+ if (urlMatches) {
2691
+ const unique = [...new Set(urlMatches)].slice(0, 5);
2692
+ facts.push(
2693
+ hasChinese ? `\u7528\u6237\u66FE\u5BFC\u5165\u5546\u54C1\u94FE\u63A5\uFF1A${unique.join(", ")}` : `Product URLs imported: ${unique.join(", ")}`
2694
+ );
2695
+ }
2696
+ const pricePatterns = fullText.match(
2697
+ /(?:售价|定价|sell.*?price|pricing|set.*?price|markup|fixed.*?price).*?(?:\$[\d.]+|¥[\d.]+|[\d.]+\s*(?:元|美元|dollars?))/gi
2698
+ );
2699
+ if (pricePatterns) {
2700
+ const unique = [...new Set(pricePatterns)].slice(0, 3);
2701
+ facts.push(
2702
+ hasChinese ? `\u5B9A\u4EF7\u76F8\u5173\uFF1A${unique.join("; ")}` : `Pricing decisions: ${unique.join("; ")}`
2703
+ );
2704
+ }
2705
+ const storeMatches = fullText.match(
2706
+ /(?:店铺|store|shop)\s*[::]?\s*[\w\s-]{3,30}/gi
2707
+ );
2708
+ if (storeMatches) {
2709
+ const unique = [...new Set(storeMatches)].slice(0, 3);
2710
+ facts.push(
2711
+ hasChinese ? `\u6D89\u53CA\u5E97\u94FA\uFF1A${unique.join(", ")}` : `Stores referenced: ${unique.join(", ")}`
2712
+ );
2713
+ }
2714
+ const decisions = messages.filter((m2) => m2.role === "user").map((m2) => m2.content.trim()).filter(
2715
+ (t2) => /^(好|确认|执行|推送|是|yes|ok|confirm|push|no|不|取消|算了|cancel|skip)/i.test(t2) && t2.length < 50
2716
+ );
2717
+ if (decisions.length > 0) {
2718
+ facts.push(
2719
+ hasChinese ? `\u7528\u6237\u51B3\u7B56\u8BB0\u5F55\uFF1A${decisions.slice(0, 5).join("; ")}` : `User decisions: ${decisions.slice(0, 5).join("; ")}`
2720
+ );
2721
+ }
2722
+ for (const m2 of messages) {
2723
+ if (m2.role !== "assistant") continue;
2724
+ const summary = m2.content.match(
2725
+ /(?:已导入|已推送|已修改|已更新|已删除|Imported|Pushed|Updated|Modified|Deleted|Set price|Changed title)\s*.{10,80}/
2726
+ );
2727
+ if (summary) {
2728
+ facts.push(summary[0]);
2729
+ }
2730
+ }
2731
+ return [...new Set(facts)].slice(0, 8);
2732
+ }
2733
+
2734
+ // src/context/history-store.ts
2735
+ import {
2736
+ readFileSync as readFileSync7,
2737
+ writeFileSync as writeFileSync7,
2738
+ existsSync as existsSync9,
2739
+ mkdirSync as mkdirSync8,
2740
+ renameSync as renameSync2
2741
+ } from "fs";
2742
+ import { join as join9 } from "path";
2743
+ import {
2744
+ randomBytes as randomBytes2,
2745
+ createCipheriv as createCipheriv2,
2746
+ createDecipheriv as createDecipheriv2,
2747
+ createHash as createHash3
2748
+ } from "crypto";
2749
+ import { hostname as hostname2, userInfo as userInfo2 } from "os";
2750
+ init_logger();
2751
+ var log15 = createLogger("context:history");
2752
+ var HISTORY_DIR = join9(CONFIG_DIR, "history");
2753
+ var MAX_LINES = 200;
2754
+ var KEEP_AFTER_ROTATE = 100;
2755
+ var ALG2 = "aes-256-gcm";
2756
+ var IV_LEN2 = 12;
2757
+ var TAG_LEN2 = 16;
2758
+ var KEY_SEED2 = "dsclaw-history-v1";
2759
+ function deriveKey2() {
2760
+ let user = "";
2761
+ try {
2762
+ user = userInfo2().username;
2763
+ } catch {
2764
+ user = process.env["USER"] ?? process.env["USERNAME"] ?? "default";
2765
+ }
2766
+ return createHash3("sha256").update(`${KEY_SEED2}:${hostname2()}:${user}`).digest();
2767
+ }
2768
+ function encryptStr(data) {
2769
+ const key = deriveKey2();
2770
+ const iv = randomBytes2(IV_LEN2);
2771
+ const cipher = createCipheriv2(ALG2, key, iv, { authTagLength: TAG_LEN2 });
2772
+ const ct = Buffer.concat([cipher.update(data, "utf8"), cipher.final()]);
2773
+ const tag = cipher.getAuthTag();
2774
+ return Buffer.concat([iv, tag, ct]).toString("base64");
2775
+ }
2776
+ var cache = /* @__PURE__ */ new Map();
2777
+ function ensureDir() {
2778
+ if (!existsSync9(HISTORY_DIR)) {
2779
+ mkdirSync8(HISTORY_DIR, { recursive: true });
2780
+ }
2781
+ }
2782
+ function filePath(sessionKey) {
2783
+ const safe = sessionKey.replace(/[^a-zA-Z0-9_:-]/g, "_");
2784
+ return join9(HISTORY_DIR, `${safe}.enc`);
2785
+ }
2786
+ function writeEncryptedFile(fp, messages) {
2787
+ const json = JSON.stringify(messages);
2788
+ const encrypted = encryptStr(json);
2789
+ const tmp = fp + ".tmp";
2790
+ writeFileSync7(tmp, encrypted);
2791
+ renameSync2(tmp, fp);
2792
+ }
2793
+ function appendHistory(sessionKey, ...messages) {
2794
+ const history = cache.get(sessionKey) ?? [];
2795
+ for (const m2 of messages) {
2796
+ history.push({ ...m2, ts: m2.ts ?? (/* @__PURE__ */ new Date()).toISOString() });
2797
+ }
2798
+ cache.set(sessionKey, history);
2799
+ Promise.resolve().then(() => {
2800
+ try {
2801
+ ensureDir();
2802
+ writeEncryptedFile(filePath(sessionKey), history);
2803
+ if (history.length > MAX_LINES) {
2804
+ rotateHistory(sessionKey);
2805
+ }
2806
+ } catch (err) {
2807
+ log15.warn({ sessionKey, err }, "Failed to write history to disk");
2808
+ }
2809
+ });
2810
+ }
2811
+ function clearHistory(sessionKeyOrUserId) {
2812
+ for (const key of cache.keys()) {
2813
+ if (key === sessionKeyOrUserId || key.endsWith(`:${sessionKeyOrUserId}`)) {
2814
+ cache.delete(key);
2815
+ }
2816
+ }
2817
+ }
2818
+ function rotateHistory(sessionKey) {
2819
+ const history = cache.get(sessionKey);
2820
+ if (!history || history.length <= MAX_LINES) return;
2821
+ const trimmed = history.slice(-KEEP_AFTER_ROTATE);
2822
+ cache.set(sessionKey, trimmed);
2823
+ try {
2824
+ writeEncryptedFile(filePath(sessionKey), trimmed);
2825
+ log15.info(
2826
+ { sessionKey, before: history.length, after: trimmed.length },
2827
+ "History rotated"
2828
+ );
2829
+ } catch (err) {
2830
+ log15.warn({ sessionKey, err }, "Failed to rotate history file");
2831
+ }
2832
+ }
2833
+
2834
+ // src/gateway/i18n.ts
2835
+ var m = {
2836
+ // Rate limit
2837
+ "gateway.rateLimit": {
2838
+ en: "Too many messages. Please wait a moment.",
2839
+ zh: "\u6D88\u606F\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u7B49\u7247\u523B\u3002"
2840
+ },
2841
+ // No API key (BUG-009)
2842
+ "gateway.noApiKey.web": {
2843
+ en: "Please configure your AI model API Key in \u2699\uFE0F Settings (top-right) before chatting.",
2844
+ zh: "\u8BF7\u5148\u5728\u53F3\u4E0A\u89D2 \u2699\uFE0F \u8BBE\u7F6E\u4E2D\u914D\u7F6E AI \u6A21\u578B\u7684 API Key\uFF0C\u624D\u80FD\u5F00\u59CB\u5BF9\u8BDD\u3002"
2845
+ },
2846
+ "gateway.noApiKey.telegram": {
2847
+ en: "Please configure your AI API key first. Send /reset to restart setup.",
2848
+ zh: "\u8BF7\u5148\u914D\u7F6E AI API Key\u3002\u53D1\u9001 /reset \u91CD\u65B0\u8BBE\u7F6E\u3002"
2849
+ },
2850
+ // Welcome (Web)
2851
+ "gateway.welcome.web": {
2852
+ en: "Welcome to **DSClaw**! I'm your AI dropshipping assistant.\n\nClick the \u2699\uFE0F **Settings** button (top-right) to connect your **DSers account** and **AI provider**.\n\nOnce set up, I can help you:\n- Import products from AliExpress, Temu, 1688\n- Push to your Shopify / WooCommerce store\n- Manage orders, inventory, and pricing rules\n- ...all through this chat!",
2853
+ zh: "\u6B22\u8FCE\u4F7F\u7528 **DSClaw**\uFF01\u6211\u662F\u4F60\u7684 AI \u4EE3\u53D1\u52A9\u624B\u3002\n\n\u70B9\u51FB\u53F3\u4E0A\u89D2 \u2699\uFE0F **\u8BBE\u7F6E** \u6309\u94AE\uFF0C\u8FDE\u63A5\u4F60\u7684 **DSers \u8D26\u53F7**\u548C **AI \u6A21\u578B**\u3002\n\n\u8BBE\u7F6E\u5B8C\u6210\u540E\uFF0C\u6211\u53EF\u4EE5\u5E2E\u4F60\uFF1A\n- \u4ECE AliExpress\u3001Temu\u30011688 \u5BFC\u5165\u5546\u54C1\n- \u63A8\u9001\u5230\u4F60\u7684 Shopify / WooCommerce \u5E97\u94FA\n- \u7BA1\u7406\u8BA2\u5355\u3001\u5E93\u5B58\u548C\u5B9A\u4EF7\u89C4\u5219\n- ...\u5168\u90E8\u901A\u8FC7\u5BF9\u8BDD\u5B8C\u6210\uFF01"
2854
+ },
2855
+ // Welcome (Telegram)
2856
+ "gateway.welcome.telegram": {
2857
+ en: "Welcome to *DSClaw*! I'm your AI assistant for dropshipping.\n\nI can help you:\n- Import products from AliExpress, Temu, 1688\n- Push products to your Shopify/WooCommerce store\n- Check inventory, pricing rules, and orders\n\nLet's get you set up. It takes about 1 minute.\n\n*Step 1/3:* What is your DSers account email?",
2858
+ zh: "\u6B22\u8FCE\u4F7F\u7528 *DSClaw*\uFF01\u6211\u662F\u4F60\u7684 AI \u4EE3\u53D1\u52A9\u624B\u3002\n\n\u6211\u53EF\u4EE5\u5E2E\u4F60\uFF1A\n- \u4ECE AliExpress\u3001Temu\u30011688 \u5BFC\u5165\u5546\u54C1\n- \u63A8\u9001\u5230\u4F60\u7684 Shopify/WooCommerce \u5E97\u94FA\n- \u67E5\u8BE2\u5E93\u5B58\u3001\u5B9A\u4EF7\u89C4\u5219\u548C\u8BA2\u5355\n\n\u5F00\u59CB\u8BBE\u7F6E\u5427\uFF0C\u5927\u7EA6 1 \u5206\u949F\u3002\n\n*\u6B65\u9AA4 1/3\uFF1A* \u8BF7\u8F93\u5165\u4F60\u7684 DSers \u8D26\u53F7\u90AE\u7BB1\uFF1A"
2859
+ },
2860
+ // Ready — short toast version
2861
+ "gateway.ready.toast": {
2862
+ en: "All set! DSClaw is ready.",
2863
+ zh: "\u8BBE\u7F6E\u5B8C\u6210\uFF01DSClaw \u5DF2\u51C6\u5907\u5C31\u7EEA\u3002"
2864
+ },
2865
+ // Ready (BUG-010)
2866
+ "gateway.ready.web": {
2867
+ en: `All set! DSClaw is ready.
2868
+
2869
+ Try asking me:
2870
+ - "Show me my stores"
2871
+ - "What's in my import list?"
2872
+ - "Search for phone cases on AliExpress"
2873
+
2874
+ Just type naturally!`,
2875
+ zh: '\u8BBE\u7F6E\u5B8C\u6210\uFF01DSClaw \u5DF2\u51C6\u5907\u5C31\u7EEA\u3002\n\n\u8BD5\u8BD5\u5BF9\u6211\u8BF4\uFF1A\n- "\u5E2E\u6211\u770B\u770B\u6211\u7684\u5E97\u94FA"\n- "\u6211\u7684\u5BFC\u5165\u5217\u8868\u91CC\u6709\u4EC0\u4E48"\n- "\u4ECE\u901F\u5356\u901A\u641C\u7D22\u624B\u673A\u58F3"\n\n\u76F4\u63A5\u8F93\u5165\u5C31\u597D\uFF01'
2876
+ },
2877
+ "gateway.ready.telegram": {
2878
+ en: `All set! DSClaw is ready.
2879
+
2880
+ Try these:
2881
+ - "Show me my stores"
2882
+ - "What's in my import list?"
2883
+ - "Search for phone cases on AliExpress"
2884
+ - "Check my pricing rules"
2885
+
2886
+ Just type naturally \u2014 I understand everyday language!`,
2887
+ zh: '\u8BBE\u7F6E\u5B8C\u6210\uFF01DSClaw \u5DF2\u51C6\u5907\u5C31\u7EEA\u3002\n\n\u8BD5\u8BD5\u8FD9\u4E9B\uFF1A\n- "\u5E2E\u6211\u770B\u770B\u6211\u7684\u5E97\u94FA"\n- "\u6211\u7684\u5BFC\u5165\u5217\u8868\u91CC\u6709\u4EC0\u4E48"\n- "\u4ECE\u901F\u5356\u901A\u641C\u7D22\u624B\u673A\u58F3"\n- "\u67E5\u770B\u5B9A\u4EF7\u89C4\u5219"\n\n\u76F4\u63A5\u7528\u81EA\u7136\u8BED\u8A00\u8F93\u5165\u5C31\u597D\uFF01'
2888
+ },
2889
+ // Commands
2890
+ "gateway.cmd.reset": {
2891
+ en: "Conversation reset. DSers connection and AI config preserved.",
2892
+ zh: "\u5BF9\u8BDD\u5DF2\u91CD\u7F6E\uFF0CDSers \u8FDE\u63A5\u548C AI \u914D\u7F6E\u5DF2\u4FDD\u7559\u3002"
2893
+ },
2894
+ "gateway.cmd.logout": {
2895
+ en: "Logged out. All configuration cleared. Send any message to start over.",
2896
+ zh: "\u5DF2\u5B8C\u5168\u767B\u51FA\uFF0C\u6240\u6709\u914D\u7F6E\u5DF2\u6E05\u9664\u3002\u53D1\u9001\u4EFB\u610F\u6D88\u606F\u91CD\u65B0\u5F00\u59CB\u3002"
2897
+ },
2898
+ "gateway.cmd.retryEmpty": {
2899
+ en: "No message to retry.",
2900
+ zh: "\u6CA1\u6709\u53EF\u91CD\u8BD5\u7684\u6D88\u606F\u3002"
2901
+ },
2902
+ // Errors
2903
+ "gateway.error.generic": {
2904
+ en: "Something went wrong, please try again.",
2905
+ zh: "\u51FA\u4E86\u70B9\u95EE\u9898\uFF0C\u8BF7\u91CD\u8BD5\u3002"
2906
+ },
2907
+ "gateway.error.timeout": {
2908
+ en: "Model response timed out, please try again later.",
2909
+ zh: "\u6A21\u578B\u54CD\u5E94\u8D85\u65F6\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002"
2910
+ },
2911
+ "gateway.error.apiKey": {
2912
+ en: "API key invalid or expired. Send /reset to reconfigure.",
2913
+ zh: "API \u5BC6\u94A5\u65E0\u6548\u6216\u8FC7\u671F\uFF0C\u8BF7\u53D1\u9001 /reset \u91CD\u65B0\u914D\u7F6E\u3002"
2914
+ },
2915
+ "gateway.error.rateLimitLlm": {
2916
+ en: "Too many requests, please wait a moment and try again.",
2917
+ zh: "\u8BF7\u6C42\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u7B49\u7247\u523B\u518D\u8BD5\u3002"
2918
+ },
2919
+ "gateway.error.dsers": {
2920
+ en: "DSers connection error, please send /reset to reconnect.",
2921
+ zh: "DSers \u8FDE\u63A5\u5F02\u5E38\uFF0C\u8BF7\u53D1\u9001 /reset \u91CD\u65B0\u767B\u5F55\u3002"
2922
+ },
2923
+ // Stream errors
2924
+ "gateway.stream.stageTimeout": {
2925
+ en: "{{stage}} timed out \u2014 LLM may be slow or unreachable. Type /retry to retry.",
2926
+ zh: "{{stage}}\u8D85\u65F6 \u2014 LLM \u670D\u52A1\u53EF\u80FD\u8F83\u6162\u6216\u4E0D\u53EF\u8FBE\u3002\u8F93\u5165 /retry \u91CD\u8BD5\u4E0A\u4E00\u6761\u6D88\u606F\u3002"
2927
+ },
2928
+ "gateway.stream.badApiKey": {
2929
+ en: "API key may be invalid or expired. Send /reset to reconfigure.",
2930
+ zh: "API key \u53EF\u80FD\u65E0\u6548\u6216\u5DF2\u8FC7\u671F\uFF0C\u8F93\u5165 /reset \u91CD\u65B0\u914D\u7F6E\u3002"
2931
+ },
2932
+ "gateway.stream.processFailed": {
2933
+ en: "Processing error, please try again. Type /retry to retry.",
2934
+ zh: "\u5904\u7406\u51FA\u9519\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002\u8F93\u5165 /retry \u91CD\u8BD5\u4E0A\u4E00\u6761\u6D88\u606F\u3002"
2935
+ },
2936
+ "gateway.stream.incomplete": {
2937
+ en: "\u26A0\uFE0F Response incomplete, please retry.",
2938
+ zh: "\u26A0\uFE0F \u54CD\u5E94\u672A\u5B8C\u6210\uFF0C\u8BF7\u91CD\u8BD5\u3002"
2939
+ },
2940
+ "gateway.stream.noReply": {
2941
+ en: "\u26A0\uFE0F No model reply received, please retry.",
2942
+ zh: "\u26A0\uFE0F \u672A\u6536\u5230\u6A21\u578B\u56DE\u590D\uFF0C\u8BF7\u91CD\u8BD5\u3002"
2943
+ },
2944
+ "gateway.stream.toolsNoText": {
2945
+ en: "Completed {{count}} operations ({{names}}), but the model did not generate a summary. Let me know if you'd like to see the results.",
2946
+ zh: "\u5DF2\u5B8C\u6210 {{count}} \u9879\u64CD\u4F5C\uFF08{{names}}\uFF09\uFF0C\u4F46\u6A21\u578B\u672A\u751F\u6210\u6587\u5B57\u603B\u7ED3\u3002\u5982\u9700\u67E5\u770B\u7ED3\u679C\u8BF7\u544A\u8BC9\u6211\u3002"
2947
+ },
2948
+ // Timeout stage names
2949
+ "gateway.stage.llmConnection": { en: "LLM connection", zh: "LLM \u8FDE\u63A5" },
2950
+ "gateway.stage.llmResponse": { en: "LLM response", zh: "LLM \u54CD\u5E94" },
2951
+ "gateway.stage.rulesApply": { en: "Rules application", zh: "\u89C4\u5219\u5E94\u7528" },
2952
+ "gateway.stage.productImport": { en: "Product import", zh: "\u5546\u54C1\u5BFC\u5165" },
2953
+ "gateway.stage.processing": { en: "Processing", zh: "\u5904\u7406" },
2954
+ // MCP
2955
+ "gateway.mcp.unavailable": {
2956
+ en: "\u26A0\uFE0F DSers connection issue: only basic search is available. Import/push/delete operations are unavailable. Try sending /reset to reconnect.",
2957
+ zh: "\u26A0\uFE0F DSers \u8FDE\u63A5\u5F02\u5E38\uFF1A\u4EC5\u57FA\u7840\u641C\u7D22\u53EF\u7528\uFF0C\u5BFC\u5165/\u63A8\u9001/\u5220\u9664\u7B49\u64CD\u4F5C\u6682\u4E0D\u53EF\u7528\u3002\u8BF7\u5C1D\u8BD5\u53D1\u9001 /reset \u91CD\u65B0\u8FDE\u63A5\u3002"
2958
+ },
2959
+ // Onboarding
2960
+ "gateway.onboard.invalidEmail": {
2961
+ en: "That doesn't look like an email address. Please enter your DSers login email:",
2962
+ zh: "\u8FD9\u4E0D\u50CF\u4E00\u4E2A\u90AE\u7BB1\u5730\u5740\u3002\u8BF7\u8F93\u5165\u4F60\u7684 DSers \u767B\u5F55\u90AE\u7BB1\uFF1A"
2963
+ },
2964
+ "gateway.onboard.passwordPrompt": {
2965
+ en: "Got it!\n\n*Step 2/3:* Now enter your DSers password.",
2966
+ zh: "\u6536\u5230\uFF01\n\n*\u6B65\u9AA4 2/3\uFF1A* \u8BF7\u8F93\u5165\u4F60\u7684 DSers \u5BC6\u7801\u3002"
2967
+ },
2968
+ "gateway.onboard.passwordPromptNote": {
2969
+ en: "\n_(Your password is encrypted locally and never sent to any third party.)_",
2970
+ zh: "\n_\uFF08\u5BC6\u7801\u52A0\u5BC6\u5B58\u50A8\u5728\u672C\u5730\uFF0C\u4E0D\u4F1A\u53D1\u9001\u7ED9\u7B2C\u4E09\u65B9\u3002\uFF09_"
2971
+ },
2972
+ "gateway.onboard.emptyPassword": {
2973
+ en: "Password cannot be empty. Please try again:",
2974
+ zh: "\u5BC6\u7801\u4E0D\u80FD\u4E3A\u7A7A\uFF0C\u8BF7\u91CD\u8BD5\uFF1A"
2975
+ },
2976
+ "gateway.onboard.verifying": {
2977
+ en: "Verifying with DSers...",
2978
+ zh: "\u6B63\u5728\u9A8C\u8BC1 DSers \u8D26\u53F7..."
2979
+ },
2980
+ "gateway.onboard.loginFailed": {
2981
+ en: "Wrong email or password. Please re-enter your password:",
2982
+ zh: "\u90AE\u7BB1\u6216\u5BC6\u7801\u9519\u8BEF\u3002\u8BF7\u91CD\u65B0\u8F93\u5165\u5BC6\u7801\uFF1A"
2983
+ },
2984
+ "gateway.onboard.loginFailedGeneric": {
2985
+ en: "Login failed: {{error}}. Try again:",
2986
+ zh: "\u767B\u5F55\u5931\u8D25\uFF1A{{error}}\u3002\u8BF7\u91CD\u8BD5\uFF1A"
2987
+ },
2988
+ "gateway.onboard.chooseProvider": {
2989
+ en: "You chose *{{provider}}*. Now paste your API key below.",
2990
+ zh: "\u4F60\u9009\u62E9\u4E86 *{{provider}}*\u3002\u8BF7\u5728\u4E0B\u65B9\u7C98\u8D34 API Key\u3002"
2991
+ },
2992
+ "gateway.onboard.invalidApiKey": {
2993
+ en: "That doesn't look like a valid API key. Please paste your key:",
2994
+ zh: "\u8FD9\u4E0D\u50CF\u4E00\u4E2A\u6709\u6548\u7684 API Key\u3002\u8BF7\u7C98\u8D34\u4F60\u7684 Key\uFF1A"
2995
+ }
2996
+ };
2997
+ function t(key, lang, vars) {
2998
+ const l = lang === "zh" ? "zh" : "en";
2999
+ const tpl = m[key]?.[l] ?? m[key]?.en ?? key;
3000
+ if (!vars) return tpl;
3001
+ return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => String(vars[k] ?? ""));
3002
+ }
3003
+
1416
3004
  // src/gateway/gateway.ts
1417
- var log11 = createLogger("gateway");
3005
+ var log16 = createLogger("gateway");
3006
+ var SESSION_MAX_SIZE = 500;
3007
+ var SESSION_TTL_MS = 2 * 60 * 60 * 1e3;
1418
3008
  var sessionHistories = /* @__PURE__ */ new Map();
3009
+ var sessionLastAccess = /* @__PURE__ */ new Map();
1419
3010
  var MAX_HISTORY = 20;
3011
+ function touchSession(key) {
3012
+ sessionLastAccess.set(key, Date.now());
3013
+ if (sessionHistories.size > SESSION_MAX_SIZE) {
3014
+ let oldest = "";
3015
+ let oldestTs = Infinity;
3016
+ for (const [k, ts] of sessionLastAccess) {
3017
+ if (ts < oldestTs) {
3018
+ oldest = k;
3019
+ oldestTs = ts;
3020
+ }
3021
+ }
3022
+ if (oldest) {
3023
+ sessionHistories.delete(oldest);
3024
+ sessionLastAccess.delete(oldest);
3025
+ }
3026
+ }
3027
+ }
3028
+ setInterval(() => {
3029
+ const now = Date.now();
3030
+ for (const [key, ts] of sessionLastAccess) {
3031
+ if (now - ts > SESSION_TTL_MS) {
3032
+ sessionHistories.delete(key);
3033
+ sessionLastAccess.delete(key);
3034
+ }
3035
+ }
3036
+ }, 10 * 60 * 1e3).unref();
1420
3037
  function detectProviderFromKey(key) {
1421
3038
  if (key.startsWith("sk-ant-")) return "anthropic";
1422
3039
  if (key.startsWith("sk-")) return "openai";
@@ -1429,34 +3046,41 @@ var DSClawGateway = class {
1429
3046
  webChannel = null;
1430
3047
  memory;
1431
3048
  agents = /* @__PURE__ */ new Map();
3049
+ jobStores = /* @__PURE__ */ new Map();
1432
3050
  dsersClients = /* @__PURE__ */ new Map();
3051
+ lastUserMessages = /* @__PURE__ */ new Map();
1433
3052
  running = false;
1434
3053
  constructor(config) {
1435
3054
  this.config = config;
1436
3055
  this.memory = new FileMemoryProvider();
1437
3056
  }
3057
+ getLang(session, channel) {
3058
+ if (session.language === "zh" || session.language === "en") return session.language;
3059
+ return channel instanceof WebChatServer ? "en" : "en";
3060
+ }
1438
3061
  async start() {
1439
- log11.info("Starting DSClaw Gateway...");
3062
+ log16.info("Starting DSClaw Gateway...");
1440
3063
  const web = new WebChatServer();
1441
3064
  web.onMessage((msg) => this.handleMessage(web, msg));
1442
3065
  web.onConnect((userId) => this.handleWebConnect(web, userId));
3066
+ web.onSettings((userId, action, data) => this.handleSettingsMessage(web, userId, action, data));
1443
3067
  await web.connect({ port: this.config.port });
1444
3068
  this.webChannel = web;
1445
3069
  this.channels.push(web);
1446
- log11.info({ port: this.config.port }, "Web chat channel connected");
3070
+ log16.info({ port: this.config.port }, "Web chat channel connected");
1447
3071
  if (this.config.telegramBotToken) {
1448
3072
  try {
1449
3073
  const tg = new TelegramAdapter();
1450
3074
  tg.onMessage((msg) => this.handleMessage(tg, msg));
1451
3075
  await tg.connect({ botToken: this.config.telegramBotToken });
1452
3076
  this.channels.push(tg);
1453
- log11.info("Telegram channel connected");
3077
+ log16.info("Telegram channel connected");
1454
3078
  } catch (error) {
1455
- log11.warn({ error }, "Telegram failed to connect \u2014 web chat still available");
3079
+ log16.warn({ error }, "Telegram failed to connect \u2014 web chat still available");
1456
3080
  }
1457
3081
  }
1458
3082
  this.running = true;
1459
- log11.info("DSClaw Gateway started");
3083
+ log16.info("DSClaw Gateway started");
1460
3084
  }
1461
3085
  async handleMessage(channel, message) {
1462
3086
  await runWithTrace(
@@ -1468,14 +3092,14 @@ var DSClawGateway = class {
1468
3092
  async () => {
1469
3093
  const userId = message.userId;
1470
3094
  if (!checkInboundLimit(userId)) {
1471
- log11.warn({ userId }, "Rate limit exceeded");
1472
- await channel.send(userId, { text: "Too many messages. Please wait a moment." });
3095
+ log16.warn({ userId }, "Rate limit exceeded");
3096
+ await channel.send(userId, { text: t("gateway.rateLimit", loadSession(userId).language) });
1473
3097
  return;
1474
3098
  }
1475
3099
  await debounceUser(userId);
1476
3100
  const release = await acquireUserLock(userId);
3101
+ const session = loadSession(userId);
1477
3102
  try {
1478
- const session = loadSession(userId);
1479
3103
  if (isOnboarding(session.state)) {
1480
3104
  await this.handleOnboarding(channel, message, session);
1481
3105
  } else {
@@ -1486,10 +3110,21 @@ var DSClawGateway = class {
1486
3110
  await this.handleReady(channel, message, session);
1487
3111
  }
1488
3112
  } catch (error) {
1489
- log11.error({ error, userId }, "Failed to handle message");
1490
- await channel.send(userId, {
1491
- text: "Something went wrong. Please try again."
1492
- });
3113
+ log16.error({ error, userId }, "Failed to handle message");
3114
+ let userMsg = t("gateway.error.generic", session.language);
3115
+ if (error instanceof Error) {
3116
+ const msg = error.message.toLowerCase();
3117
+ if (msg.includes("timeout") || msg.includes("timed out")) {
3118
+ userMsg = t("gateway.error.timeout", session.language);
3119
+ } else if (msg.includes("401") || msg.includes("unauthorized") || msg.includes("api key")) {
3120
+ userMsg = t("gateway.error.apiKey", session.language);
3121
+ } else if (msg.includes("429") || msg.includes("rate limit")) {
3122
+ userMsg = t("gateway.error.rateLimitLlm", session.language);
3123
+ } else if (msg.includes("session") || msg.includes("dsers")) {
3124
+ userMsg = t("gateway.error.dsers", session.language);
3125
+ }
3126
+ }
3127
+ await channel.send(userId, { text: userMsg });
1493
3128
  } finally {
1494
3129
  release();
1495
3130
  }
@@ -1499,12 +3134,207 @@ var DSClawGateway = class {
1499
3134
  // ─── Auto-welcome on WebSocket connect ──────────────────────────
1500
3135
  handleWebConnect(channel, userId) {
1501
3136
  const session = loadSession(userId);
3137
+ this.pushSettingsState(channel, userId, session);
1502
3138
  if (session.state === "new") {
1503
3139
  this.onboardWelcome(channel, userId, session).catch((err) => {
1504
- log11.error({ error: err, userId }, "Auto-welcome failed");
3140
+ log16.error({ error: err, userId }, "Auto-welcome failed");
1505
3141
  });
1506
3142
  }
1507
3143
  }
3144
+ // ─── Settings Panel (Web Only) ─────────────────────────────────
3145
+ isDsersConnected(session) {
3146
+ return !!session.dspiSessionId;
3147
+ }
3148
+ pushSettingsState(channel, userId, session) {
3149
+ const dsersOk = this.isDsersConnected(session);
3150
+ channel.sendSettingsState(userId, {
3151
+ dsers: { configured: dsersOk, email: dsersOk ? session.dspiEmail : void 0 },
3152
+ llm: { configured: !!session.llmApiKey, provider: session.llmProvider, baseUrl: session.llmBaseUrl },
3153
+ ready: session.state === "ready"
3154
+ });
3155
+ }
3156
+ async applyDSersSession(channel, userId, session, sessionId, sessionState) {
3157
+ const dsersConfig = createDSersConfig(session.dspiEmail ?? "browser");
3158
+ dsersConfig.sessionId = sessionId;
3159
+ dsersConfig.sessionState = sessionState;
3160
+ const client = new DSersClient(dsersConfig);
3161
+ const info = await client.get("/account-user-bff/v1/users/info");
3162
+ const data = info["data"];
3163
+ const email = data?.["email"] ?? session.dspiEmail ?? "connected";
3164
+ session.dspiEmail = email;
3165
+ session.dspiSessionId = sessionId;
3166
+ session.dspiSessionState = sessionState;
3167
+ session.dspiPassword = void 0;
3168
+ this.dsersClients.set(userId, client);
3169
+ if (session.llmApiKey) {
3170
+ session.state = "ready";
3171
+ } else if (!["onboard_llm", "ready"].includes(session.state)) {
3172
+ session.state = "onboard_llm";
3173
+ }
3174
+ saveSession(userId, session);
3175
+ channel.sendSettingsResult(userId, { section: "dsers", success: true });
3176
+ this.pushSettingsState(channel, userId, session);
3177
+ if (session.state === "ready") {
3178
+ await this.sendReadyMessage(channel, userId);
3179
+ }
3180
+ return true;
3181
+ }
3182
+ async triggerAuth(userId) {
3183
+ if (isAuthInProgress()) {
3184
+ return { success: false, reason: "in_progress" };
3185
+ }
3186
+ try {
3187
+ const result = await loginViaCDP();
3188
+ const session = loadSession(userId);
3189
+ const dsersConfig = createDSersConfig(session.dspiEmail ?? "browser");
3190
+ dsersConfig.sessionId = result.sessionId;
3191
+ dsersConfig.sessionState = result.state;
3192
+ const client = new DSersClient(dsersConfig);
3193
+ const info = await client.get("/account-user-bff/v1/users/info");
3194
+ const data = info?.["data"];
3195
+ const email = data?.["email"] ?? "connected";
3196
+ session.dspiEmail = email;
3197
+ session.dspiSessionId = result.sessionId;
3198
+ session.dspiSessionState = result.state;
3199
+ session.dspiPassword = void 0;
3200
+ if (session.llmApiKey) session.state = "ready";
3201
+ saveSession(userId, session);
3202
+ this.dsersClients.set(userId, client);
3203
+ this.agents.delete(userId);
3204
+ log16.info({ userId, email }, "triggerAuth succeeded \u2014 agent will be recreated on next message");
3205
+ return { success: true, email };
3206
+ } catch (err) {
3207
+ const msg = err instanceof Error ? err.message : String(err);
3208
+ log16.warn({ error: msg, userId }, "triggerAuth failed");
3209
+ if (msg.includes("cancelled") || msg.includes("closed")) {
3210
+ return { success: false, reason: "cancelled" };
3211
+ }
3212
+ if (msg.includes("timed out") || msg.includes("Timed out")) {
3213
+ return { success: false, reason: "timeout" };
3214
+ }
3215
+ if (msg.includes("No Chromium") || msg.includes("browser found")) {
3216
+ return { success: false, reason: "no_browser" };
3217
+ }
3218
+ return { success: false, reason: msg };
3219
+ }
3220
+ }
3221
+ async handleSettingsMessage(channel, userId, action, data) {
3222
+ const session = loadSession(userId);
3223
+ switch (action) {
3224
+ case "set_language": {
3225
+ const lang = data["lang"];
3226
+ if (lang === "en" || lang === "zh") {
3227
+ session.language = lang;
3228
+ saveSession(userId, session);
3229
+ }
3230
+ break;
3231
+ }
3232
+ case "get_settings":
3233
+ this.pushSettingsState(channel, userId, session);
3234
+ break;
3235
+ case "start_dsers_auth": {
3236
+ if (isAuthInProgress()) {
3237
+ channel.sendSettingsResult(userId, { section: "dsers", success: false, error: "Auth already in progress." });
3238
+ return;
3239
+ }
3240
+ channel.sendSettingsResult(userId, { section: "dsers_auth_started", success: true });
3241
+ loginViaCDP().then(async (result) => {
3242
+ const freshSession = loadSession(userId);
3243
+ await this.applyDSersSession(channel, userId, freshSession, result.sessionId, result.state);
3244
+ }).catch((error) => {
3245
+ const msg = error instanceof Error ? error.message : "unknown error";
3246
+ log16.warn({ error: msg, userId }, "Browser auth failed");
3247
+ channel.sendSettingsResult(userId, { section: "dsers", success: false, error: msg });
3248
+ });
3249
+ break;
3250
+ }
3251
+ case "cancel_dsers_auth": {
3252
+ cancelAuth();
3253
+ channel.sendSettingsResult(userId, { section: "dsers_cancelled", success: true });
3254
+ break;
3255
+ }
3256
+ case "save_dsers_session": {
3257
+ const sessionId = data["sessionId"]?.trim();
3258
+ const sessionState = data["state"]?.trim() ?? "";
3259
+ if (!sessionId || sessionId.length < 10) {
3260
+ channel.sendSettingsResult(userId, { section: "dsers", success: false, error: "Invalid session token." });
3261
+ return;
3262
+ }
3263
+ try {
3264
+ await this.applyDSersSession(channel, userId, session, sessionId, sessionState);
3265
+ } catch {
3266
+ channel.sendSettingsResult(userId, { section: "dsers", success: false, error: "Session token invalid or expired." });
3267
+ }
3268
+ break;
3269
+ }
3270
+ case "disconnect_dsers": {
3271
+ session.dspiEmail = void 0;
3272
+ session.dspiSessionId = void 0;
3273
+ session.dspiSessionState = void 0;
3274
+ session.state = "new";
3275
+ saveSession(userId, session);
3276
+ this.agents.delete(userId);
3277
+ this.pushSettingsState(channel, userId, session);
3278
+ break;
3279
+ }
3280
+ case "save_llm": {
3281
+ let provider = data["provider"]?.trim();
3282
+ const apiKey = data["apiKey"]?.trim();
3283
+ let baseUrl = data["baseUrl"]?.trim() || void 0;
3284
+ const model = data["model"]?.trim() || void 0;
3285
+ if (baseUrl) {
3286
+ if (!/^https?:\/\//i.test(baseUrl)) baseUrl = `https://${baseUrl}`;
3287
+ baseUrl = baseUrl.replace(/\/+$/, "");
3288
+ if (!/\/v\d/.test(baseUrl)) baseUrl += "/v1";
3289
+ }
3290
+ if (!apiKey || apiKey.length < 3) {
3291
+ channel.sendSettingsResult(userId, { section: "llm", success: false, error: "Please enter a valid API key." });
3292
+ return;
3293
+ }
3294
+ if (provider === "other" && !baseUrl) {
3295
+ channel.sendSettingsResult(userId, { section: "llm", success: false, error: "Please enter the API base URL." });
3296
+ return;
3297
+ }
3298
+ if (!provider) {
3299
+ provider = detectProviderFromKey(apiKey) ?? "";
3300
+ }
3301
+ if (!provider) {
3302
+ channel.sendSettingsResult(userId, { section: "llm", success: false, error: "Cannot detect provider. Please select one." });
3303
+ return;
3304
+ }
3305
+ session.llmProvider = provider;
3306
+ session.llmApiKey = apiKey;
3307
+ session.llmBaseUrl = baseUrl;
3308
+ session.llmModel = model || getDefaultModel(provider);
3309
+ if (this.isDsersConnected(session)) {
3310
+ session.state = "ready";
3311
+ }
3312
+ saveSession(userId, session);
3313
+ this.agents.delete(userId);
3314
+ channel.sendSettingsResult(userId, { section: "llm", success: true });
3315
+ this.pushSettingsState(channel, userId, session);
3316
+ if (session.state === "ready") {
3317
+ await this.sendReadyMessage(channel, userId);
3318
+ }
3319
+ break;
3320
+ }
3321
+ }
3322
+ }
3323
+ async sendReadyMessage(channel, userId) {
3324
+ const session = loadSession(userId);
3325
+ if (channel instanceof WebChatServer) {
3326
+ channel.sendToast(userId, t("gateway.ready.toast", session.language), "info", "sys-ready");
3327
+ } else {
3328
+ await channel.send(userId, { text: t("gateway.ready.telegram", session.language) });
3329
+ }
3330
+ writeAuditLog({
3331
+ userId,
3332
+ agentId: "gateway",
3333
+ action: "onboarding_complete",
3334
+ target: "user-session",
3335
+ result: "success"
3336
+ });
3337
+ }
1508
3338
  // ─── Onboarding State Machine ─────────────────────────────────────
1509
3339
  async handleOnboarding(channel, message, session) {
1510
3340
  const userId = message.userId;
@@ -1528,15 +3358,16 @@ var DSClawGateway = class {
1528
3358
  }
1529
3359
  }
1530
3360
  async onboardWelcome(channel, userId, session) {
1531
- const welcome = "Welcome to *DSClaw*! I'm your AI assistant for dropshipping.\n\nI can help you:\n- Import products from AliExpress, Temu, 1688\n- Push products to your Shopify/WooCommerce store\n- Check inventory, pricing rules, and orders\n- ...all through this chat!\n\nLet's get you set up. It takes about 1 minute.\n\n*Step 1/3:* What is your DSers account email?";
3361
+ const isWeb = channel instanceof WebChatServer;
3362
+ const welcome = t(isWeb ? "gateway.welcome.web" : "gateway.welcome.telegram", session.language);
1532
3363
  await channel.send(userId, { text: welcome });
1533
- session.state = "onboard_dsers_email";
3364
+ session.state = isWeb ? "ready" : "onboard_dsers_email";
1534
3365
  saveSession(userId, session);
1535
3366
  }
1536
3367
  async onboardEmail(channel, userId, text, session) {
1537
3368
  if (!text.includes("@") || !text.includes(".")) {
1538
3369
  await channel.send(userId, {
1539
- text: "That doesn't look like an email address. Please enter your DSers login email:"
3370
+ text: t("gateway.onboard.invalidEmail", session.language)
1540
3371
  });
1541
3372
  return;
1542
3373
  }
@@ -1547,7 +3378,7 @@ var DSClawGateway = class {
1547
3378
  channel.sendSetInputType(userId, "password");
1548
3379
  }
1549
3380
  await channel.send(userId, {
1550
- text: "Got it!\n\n*Step 2/3:* Now enter your DSers password." + (channel instanceof WebChatServer ? "" : "\n_I will delete your message immediately for security._")
3381
+ text: t("gateway.onboard.passwordPrompt", session.language) + (channel instanceof WebChatServer ? "" : t("gateway.onboard.passwordPromptNote", session.language))
1551
3382
  });
1552
3383
  }
1553
3384
  async onboardPassword(channel, message, session, isWeb) {
@@ -1563,19 +3394,20 @@ var DSClawGateway = class {
1563
3394
  );
1564
3395
  }
1565
3396
  if (password.length < 1) {
1566
- await channel.send(userId, { text: "Password cannot be empty. Please try again:" });
3397
+ await channel.send(userId, { text: t("gateway.onboard.emptyPassword", session.language) });
1567
3398
  if (isWeb) channel.sendSetInputType(userId, "password");
1568
3399
  return;
1569
3400
  }
1570
3401
  session.dspiPassword = password;
1571
3402
  saveSession(userId, session);
1572
- await channel.send(userId, { text: "Verifying with DSers..." });
3403
+ await channel.send(userId, { text: t("gateway.onboard.verifying", session.language) });
1573
3404
  try {
1574
3405
  const dsersConfig = createDSersConfig(session.dspiEmail, password);
1575
3406
  const client = new DSersClient(dsersConfig);
1576
3407
  await client.get("/account-user-bff/v1/users/info");
1577
3408
  this.dsersClients.set(userId, client);
1578
3409
  session.state = "onboard_llm";
3410
+ session.dspiPassword = void 0;
1579
3411
  saveSession(userId, session);
1580
3412
  await channel.send(userId, {
1581
3413
  card: {
@@ -1593,7 +3425,7 @@ var DSClawGateway = class {
1593
3425
  session.state = "onboard_dsers_password";
1594
3426
  saveSession(userId, session);
1595
3427
  if (isWeb) channel.sendSetInputType(userId, "password");
1596
- const msg = error instanceof Error && error.message.includes("401") ? "Wrong email or password. Please re-enter your password:" : `Login failed: ${error instanceof Error ? error.message : "unknown error"}. Please try again:`;
3428
+ const msg = error instanceof Error && error.message.includes("401") ? t("gateway.onboard.loginFailed", session.language) : t("gateway.onboard.loginFailedGeneric", session.language, { error: error instanceof Error ? error.message : "unknown error" });
1597
3429
  await channel.send(userId, { text: msg });
1598
3430
  }
1599
3431
  }
@@ -1607,7 +3439,7 @@ var DSClawGateway = class {
1607
3439
  const providerName = provider === "openai" ? "OpenAI" : provider === "anthropic" ? "Anthropic" : "Google";
1608
3440
  if (isWeb) channel.sendSetInputType(userId, "password");
1609
3441
  await channel.send(userId, {
1610
- text: `You chose *${providerName}*. Now paste your API key below.` + (isWeb ? "" : "\n_I will delete your message immediately._")
3442
+ text: t("gateway.onboard.chooseProvider", session.language, { provider: providerName }) + (isWeb ? "" : "\n_I will delete your message immediately._")
1611
3443
  });
1612
3444
  return;
1613
3445
  }
@@ -1641,7 +3473,7 @@ var DSClawGateway = class {
1641
3473
  );
1642
3474
  }
1643
3475
  if (text.length < 10) {
1644
- await channel.send(userId, { text: "That doesn't look like a valid API key. Please paste your key:" });
3476
+ await channel.send(userId, { text: t("gateway.onboard.invalidApiKey", session.language) });
1645
3477
  if (isWeb) channel.sendSetInputType(userId, "password");
1646
3478
  return;
1647
3479
  }
@@ -1650,15 +3482,7 @@ var DSClawGateway = class {
1650
3482
  session.state = "ready";
1651
3483
  saveSession(userId, session);
1652
3484
  await channel.send(userId, {
1653
- text: `All set! DSClaw is ready.
1654
-
1655
- Try these:
1656
- - "Show me my stores"
1657
- - "What's in my import list?"
1658
- - "Search for phone cases on AliExpress"
1659
- - "Check my pricing rules"
1660
-
1661
- Just type naturally \u2014 I understand everyday language!`
3485
+ text: t("gateway.ready.telegram", session.language)
1662
3486
  });
1663
3487
  writeAuditLog({
1664
3488
  userId,
@@ -1671,22 +3495,94 @@ Just type naturally \u2014 I understand everyday language!`
1671
3495
  // ─── Ready State: Route to AI Agent ───────────────────────────────
1672
3496
  async handleReady(channel, message, session) {
1673
3497
  const userId = message.userId;
1674
- if (message.text.trim().toLowerCase() === "/reset") {
3498
+ const cmd = message.text.trim().toLowerCase();
3499
+ if (cmd === "/reset") {
3500
+ this.agents.delete(userId);
3501
+ this.lastUserMessages.delete(userId);
3502
+ for (const key of sessionHistories.keys()) {
3503
+ if (key.endsWith(`:${userId}`)) sessionHistories.delete(key);
3504
+ }
3505
+ clearHistory(userId);
3506
+ const preserved = {
3507
+ state: "ready",
3508
+ dspiEmail: session.dspiEmail,
3509
+ dspiSessionId: session.dspiSessionId,
3510
+ dspiSessionState: session.dspiSessionState,
3511
+ llmProvider: session.llmProvider,
3512
+ llmApiKey: session.llmApiKey,
3513
+ llmModel: session.llmModel,
3514
+ llmBaseUrl: session.llmBaseUrl,
3515
+ language: session.language
3516
+ };
3517
+ saveSession(userId, preserved);
3518
+ if (channel instanceof WebChatServer) {
3519
+ channel.sendReset(userId);
3520
+ this.pushSettingsState(channel, userId, preserved);
3521
+ channel.sendToast(userId, t("gateway.cmd.reset", session.language), "info", "sys-reset");
3522
+ } else {
3523
+ await channel.send(userId, { text: t("gateway.cmd.reset", session.language) });
3524
+ }
3525
+ return;
3526
+ }
3527
+ if (cmd === "/logout") {
1675
3528
  this.agents.delete(userId);
3529
+ this.jobStores.delete(userId);
1676
3530
  this.dsersClients.delete(userId);
1677
- sessionHistories.delete(userId);
3531
+ this.lastUserMessages.delete(userId);
3532
+ for (const key of sessionHistories.keys()) {
3533
+ if (key.endsWith(`:${userId}`)) sessionHistories.delete(key);
3534
+ }
3535
+ clearHistory(userId);
1678
3536
  saveSession(userId, { state: "new" });
1679
- await channel.send(userId, { text: "Account reset. Send any message to start over." });
3537
+ if (channel instanceof WebChatServer) {
3538
+ channel.sendReset(userId);
3539
+ this.pushSettingsState(channel, userId, { state: "new" });
3540
+ channel.sendToast(userId, t("gateway.cmd.logout", session.language), "info", "sys-logout");
3541
+ } else {
3542
+ await channel.send(userId, { text: t("gateway.cmd.logout", session.language) });
3543
+ }
3544
+ return;
3545
+ }
3546
+ if (cmd === "/retry") {
3547
+ const lastMsg = this.lastUserMessages.get(userId);
3548
+ if (!lastMsg) {
3549
+ if (channel instanceof WebChatServer) {
3550
+ channel.sendToast(userId, t("gateway.cmd.retryEmpty", session.language), "warn", "sys-retry-empty");
3551
+ } else {
3552
+ await channel.send(userId, { text: t("gateway.cmd.retryEmpty", session.language) });
3553
+ }
3554
+ return;
3555
+ }
3556
+ log16.info({ userId, retryText: lastMsg.slice(0, 80) }, "Retrying last message");
3557
+ message = { ...message, text: lastMsg };
3558
+ } else {
3559
+ this.lastUserMessages.set(userId, message.text);
3560
+ }
3561
+ if (!session.llmApiKey) {
3562
+ if (channel instanceof WebChatServer) {
3563
+ channel.sendToast(userId, t("gateway.noApiKey.web", session.language), "warn", "sys-no-apikey");
3564
+ } else {
3565
+ await channel.send(userId, { text: t("gateway.noApiKey.telegram", session.language) });
3566
+ }
1680
3567
  return;
1681
3568
  }
1682
3569
  const agent = this.getOrCreateAgent(userId, session);
1683
3570
  const sessionKey = `${channel.name}:${userId}`;
3571
+ touchSession(sessionKey);
1684
3572
  const history = sessionHistories.get(sessionKey) ?? [];
3573
+ const budget = allocateBudget("", "", history, {});
3574
+ const budgetedHistory = budget.fallbackMode ? history.slice(-MAX_HISTORY) : budget.messages;
3575
+ if (budget.shouldCompact && !budget.fallbackMode) {
3576
+ log16.info(
3577
+ { sessionKey, totalTokens: budget.totalTokens, remaining: budget.remainingTokens },
3578
+ "Context nearing limit \u2014 consider compaction"
3579
+ );
3580
+ }
1685
3581
  const context = {
1686
3582
  userId,
1687
3583
  sessionId: sessionKey,
1688
3584
  channelId: channel.name,
1689
- history: history.slice(-MAX_HISTORY)
3585
+ history: budgetedHistory
1690
3586
  };
1691
3587
  writeAuditLog({
1692
3588
  userId,
@@ -1698,12 +3594,12 @@ Just type naturally \u2014 I understand everyday language!`
1698
3594
  });
1699
3595
  try {
1700
3596
  if (channel instanceof WebChatServer) {
1701
- await this.handleReadyStreaming(channel, userId, message.text, agent, context, history, sessionKey);
3597
+ await this.handleReadyStreaming(channel, userId, message.text, agent, context, history, sessionKey, session, message.attachments);
1702
3598
  } else {
1703
3599
  await this.handleReadyBatch(channel, userId, message.text, agent, context, history, sessionKey);
1704
3600
  }
1705
3601
  } catch (error) {
1706
- log11.error({ error, userId }, "Agent processing failed");
3602
+ log16.error({ error, userId }, "Agent processing failed");
1707
3603
  writeAuditLog({
1708
3604
  userId,
1709
3605
  agentId: agent.id,
@@ -1712,31 +3608,164 @@ Just type naturally \u2014 I understand everyday language!`
1712
3608
  result: "failed",
1713
3609
  error: error instanceof Error ? error.message : String(error)
1714
3610
  });
3611
+ if (channel instanceof WebChatServer) {
3612
+ channel.sendTyping(userId, false);
3613
+ channel.sendStatus(userId, "");
3614
+ channel.sendLog(userId, {
3615
+ level: "error",
3616
+ module: "gateway",
3617
+ msg: `Error: ${error instanceof Error ? error.message : String(error)}`,
3618
+ ts: (/* @__PURE__ */ new Date()).toISOString()
3619
+ });
3620
+ }
1715
3621
  const errMsg = error instanceof Error ? error.message : "";
1716
- if (errMsg.includes("401") || errMsg.includes("API key")) {
3622
+ if (errMsg.includes("timed out")) {
3623
+ const stage = errMsg.includes("LLM init") ? t("gateway.stage.llmConnection", session.language) : errMsg.includes("LLM response") ? t("gateway.stage.llmResponse", session.language) : errMsg.includes("Rule reapply") ? t("gateway.stage.rulesApply", session.language) : errMsg.includes("Product import") ? t("gateway.stage.productImport", session.language) : t("gateway.stage.processing", session.language);
3624
+ await channel.send(userId, {
3625
+ text: t("gateway.stream.stageTimeout", session.language, { stage })
3626
+ });
3627
+ } else if (errMsg.includes("401") || errMsg.includes("API key")) {
1717
3628
  await channel.send(userId, {
1718
- text: "Your API key may be invalid or expired. Send /reset to reconfigure."
3629
+ text: t("gateway.stream.badApiKey", session.language)
1719
3630
  });
1720
3631
  } else {
1721
3632
  await channel.send(userId, {
1722
- text: "I'm having trouble right now. Please try again in a moment."
3633
+ text: t("gateway.stream.processFailed", session.language)
1723
3634
  });
1724
3635
  }
1725
3636
  }
1726
3637
  }
1727
- async handleReadyStreaming(channel, userId, text, agent, context, history, sessionKey) {
3638
+ async handleReadyStreaming(channel, userId, text, agent, context, history, sessionKey, session, attachments) {
1728
3639
  channel.sendTyping(userId, true);
1729
- const { textStream, response } = agent.processStream(text, context);
1730
- channel.sendTyping(userId, false);
1731
- channel.sendStreamStart(userId);
1732
- for await (const delta of textStream) {
1733
- channel.sendStreamDelta(userId, delta);
1734
- }
1735
- channel.sendStreamEnd(userId);
1736
- const result = await response;
3640
+ channel.sendStatus(userId, "Thinking...");
3641
+ const ts = () => (/* @__PURE__ */ new Date()).toISOString();
3642
+ channel.sendLog(userId, { level: "info", module: "gateway", msg: `Processing: "${text.slice(0, 80)}"`, ts: ts() });
3643
+ const imageAttachments = attachments?.filter((a) => a.type === "image").map((a) => ({ url: a.url ?? "", data: a.data, mimeType: a.mimeType ?? "image/png" }));
3644
+ const runningTools = /* @__PURE__ */ new Map();
3645
+ let resolveToolsSettled = null;
3646
+ const toolsSettled = new Promise((r) => {
3647
+ resolveToolsSettled = r;
3648
+ });
3649
+ const { textStream, response } = agent.processStream(
3650
+ text,
3651
+ context,
3652
+ (status) => {
3653
+ channel.sendStatus(userId, status);
3654
+ },
3655
+ {
3656
+ onToolCall: (id, name, args) => {
3657
+ runningTools.set(id, { name, startedAt: Date.now() });
3658
+ channel.sendToolCallStart(userId, id, name, args);
3659
+ channel.sendLog(userId, { level: "info", module: "agent", msg: `Tool call: ${name}`, ts: ts() });
3660
+ },
3661
+ onToolResult: (id, name, result2, error, durationMs) => {
3662
+ runningTools.delete(id);
3663
+ channel.sendToolCallEnd(userId, id, name, result2, error, durationMs);
3664
+ channel.sendLog(userId, {
3665
+ level: error ? "error" : "info",
3666
+ module: "agent",
3667
+ msg: `Tool ${name} ${error ? "failed" : "done"} (${durationMs}ms)`,
3668
+ ts: ts()
3669
+ });
3670
+ if (runningTools.size === 0 && resolveToolsSettled) resolveToolsSettled();
3671
+ }
3672
+ },
3673
+ imageAttachments?.length ? imageAttachments : void 0
3674
+ );
3675
+ let streamStarted = false;
3676
+ try {
3677
+ for await (const chunk of textStream) {
3678
+ if (!streamStarted) {
3679
+ channel.sendTyping(userId, false);
3680
+ channel.sendStatus(userId, "");
3681
+ channel.sendStreamStart(userId);
3682
+ streamStarted = true;
3683
+ }
3684
+ channel.sendStreamDelta(userId, chunk);
3685
+ }
3686
+ } catch (streamErr) {
3687
+ log16.error({ error: streamErr, userId }, "Stream iteration failed");
3688
+ if (streamStarted) {
3689
+ const errMsg = streamErr instanceof Error ? streamErr.message : "Unknown error";
3690
+ channel.sendStreamDelta(userId, `
3691
+
3692
+ \u26A0\uFE0F ${errMsg}`);
3693
+ } else {
3694
+ response.catch(() => {
3695
+ });
3696
+ throw streamErr;
3697
+ }
3698
+ } finally {
3699
+ if (runningTools.size > 0) {
3700
+ try {
3701
+ await Promise.race([toolsSettled, new Promise((r) => setTimeout(r, 15e3))]);
3702
+ } catch {
3703
+ }
3704
+ }
3705
+ for (const [id, info] of runningTools) {
3706
+ const elapsed = Date.now() - info.startedAt;
3707
+ channel.sendToolCallEnd(userId, id, info.name, void 0, "Aborted (stream terminated)", elapsed);
3708
+ }
3709
+ runningTools.clear();
3710
+ if (!streamStarted) {
3711
+ channel.sendTyping(userId, false);
3712
+ channel.sendStatus(userId, "");
3713
+ }
3714
+ channel.sendStreamEnd(userId);
3715
+ }
3716
+ log16.info({ userId, streamStarted }, "Stream loop finished, awaiting response finalization");
3717
+ let result;
3718
+ try {
3719
+ result = await Promise.race([
3720
+ response,
3721
+ new Promise(
3722
+ (_, reject) => setTimeout(() => reject(new Error("Response finalization timed out")), 6e4)
3723
+ )
3724
+ ]);
3725
+ } catch (finalErr) {
3726
+ log16.warn({ error: finalErr, userId }, "Response finalization failed \u2014 saving partial history");
3727
+ history.push({ role: "user", content: text });
3728
+ history.push({ role: "assistant", content: "(response incomplete)" });
3729
+ sessionHistories.set(sessionKey, history.slice(-MAX_HISTORY * 2));
3730
+ if (!streamStarted && channel instanceof WebChatServer) {
3731
+ await channel.send(userId, { text: t("gateway.stream.incomplete", session.language) });
3732
+ }
3733
+ return;
3734
+ }
3735
+ const textLen = result.text?.length ?? 0;
3736
+ const toolCallCount = result.toolCalls?.length ?? 0;
3737
+ log16.info({ userId, textLen, toolCallCount }, "Response finalized");
3738
+ if (!streamStarted && channel instanceof WebChatServer) {
3739
+ if (result.text) {
3740
+ await channel.send(userId, { text: result.text });
3741
+ } else if (toolCallCount > 0) {
3742
+ const names = result.toolCalls.map((tc) => tc.tool).join(", ");
3743
+ await channel.send(userId, {
3744
+ text: t("gateway.stream.toolsNoText", session.language, { count: toolCallCount, names })
3745
+ });
3746
+ } else {
3747
+ await channel.send(userId, { text: t("gateway.stream.noReply", session.language) });
3748
+ }
3749
+ }
1737
3750
  history.push({ role: "user", content: text });
1738
- history.push({ role: "assistant", content: result.text });
1739
- sessionHistories.set(sessionKey, history.slice(-MAX_HISTORY * 2));
3751
+ history.push({ role: "assistant", content: result.text || "(tool calls completed)" });
3752
+ const postBudget = allocateBudget("", "", history, {});
3753
+ if (postBudget.shouldCompact && !postBudget.fallbackMode) {
3754
+ const compacted = await compactWithFlush(
3755
+ history,
3756
+ MAX_HISTORY * 2,
3757
+ this.memory,
3758
+ userId
3759
+ );
3760
+ sessionHistories.set(sessionKey, compacted.messages);
3761
+ } else {
3762
+ sessionHistories.set(sessionKey, history.slice(-MAX_HISTORY * 2));
3763
+ }
3764
+ appendHistory(
3765
+ sessionKey,
3766
+ { role: "user", content: text },
3767
+ { role: "assistant", content: result.text }
3768
+ );
1740
3769
  writeAuditLog({
1741
3770
  userId,
1742
3771
  agentId: agent.id,
@@ -1754,7 +3783,23 @@ Just type naturally \u2014 I understand everyday language!`
1754
3783
  const result = await agent.process(text, context);
1755
3784
  history.push({ role: "user", content: text });
1756
3785
  history.push({ role: "assistant", content: result.text });
1757
- sessionHistories.set(sessionKey, history.slice(-MAX_HISTORY * 2));
3786
+ const postBudget = allocateBudget("", "", history, {});
3787
+ if (postBudget.shouldCompact && !postBudget.fallbackMode) {
3788
+ const compacted = await compactWithFlush(
3789
+ history,
3790
+ MAX_HISTORY * 2,
3791
+ this.memory,
3792
+ userId
3793
+ );
3794
+ sessionHistories.set(sessionKey, compacted.messages);
3795
+ } else {
3796
+ sessionHistories.set(sessionKey, history.slice(-MAX_HISTORY * 2));
3797
+ }
3798
+ appendHistory(
3799
+ sessionKey,
3800
+ { role: "user", content: text },
3801
+ { role: "assistant", content: result.text }
3802
+ );
1758
3803
  if (result.text) {
1759
3804
  await channel.send(userId, { text: result.text });
1760
3805
  }
@@ -1773,68 +3818,196 @@ Just type naturally \u2014 I understand everyday language!`
1773
3818
  getOrCreateAgent(userId, session) {
1774
3819
  let agent = this.agents.get(userId);
1775
3820
  if (agent) return agent;
1776
- const dsersConfig = createDSersConfig(
1777
- session.dspiEmail,
1778
- session.dspiPassword
1779
- );
3821
+ const dsersConfig = createDSersConfig(session.dspiEmail ?? "unknown");
3822
+ if (session.dspiSessionId) {
3823
+ dsersConfig.sessionId = session.dspiSessionId;
3824
+ dsersConfig.sessionState = session.dspiSessionState ?? "";
3825
+ }
1780
3826
  const dsersClient = new DSersClient(dsersConfig);
1781
3827
  this.dsersClients.set(userId, dsersClient);
1782
3828
  const llm = {
1783
3829
  provider: session.llmProvider,
1784
3830
  apiKey: session.llmApiKey,
1785
- model: session.llmModel ?? getDefaultModel(session.llmProvider)
3831
+ model: session.llmModel ?? getDefaultModel(session.llmProvider),
3832
+ baseUrl: session.llmBaseUrl
1786
3833
  };
1787
- agent = new DSClawCoreAgent(dsersClient, this.memory, llm);
3834
+ const dsersSession = session.dspiSessionId ? { sessionId: session.dspiSessionId, state: session.dspiSessionState ?? "" } : void 0;
3835
+ const authCb = () => this.triggerAuth(userId);
3836
+ let jobStore = this.jobStores.get(userId);
3837
+ if (!jobStore) {
3838
+ jobStore = new MemoryJobStore2();
3839
+ this.jobStores.set(userId, jobStore);
3840
+ }
3841
+ agent = new DSClawCoreAgent(dsersClient, this.memory, llm, dsersSession, authCb, jobStore);
1788
3842
  this.agents.set(userId, agent);
3843
+ if (!agent.mcpAvailable) {
3844
+ log16.warn({ userId }, "MCP unavailable for agent \u2014 notifying user");
3845
+ for (const ch of this.channels) {
3846
+ ch.send(userId, {
3847
+ text: t("gateway.mcp.unavailable", session.language)
3848
+ }).catch(() => {
3849
+ });
3850
+ }
3851
+ }
1789
3852
  return agent;
1790
3853
  }
1791
3854
  async stop() {
1792
- log11.info("Stopping DSClaw Gateway...");
3855
+ log16.info("Stopping DSClaw Gateway...");
1793
3856
  for (const ch of this.channels) {
1794
3857
  await ch.disconnect();
1795
3858
  }
1796
3859
  this.running = false;
1797
- log11.info("DSClaw Gateway stopped");
3860
+ log16.info("DSClaw Gateway stopped");
1798
3861
  }
1799
3862
  isRunning() {
1800
3863
  return this.running;
1801
3864
  }
3865
+ get actualPort() {
3866
+ return this.webChannel?.actualPort ?? this.config.port;
3867
+ }
1802
3868
  };
1803
3869
 
1804
- // src/cli/start.ts
1805
- var log12 = createLogger("cli:start");
3870
+ // src/cli/pid.ts
3871
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync8, unlinkSync as unlinkSync2, existsSync as existsSync10 } from "fs";
3872
+ import { join as join10 } from "path";
3873
+ var PID_PATH = join10(CONFIG_DIR, "dsclaw.pid");
3874
+ function writePid(port) {
3875
+ ensureConfigDir();
3876
+ const info = {
3877
+ pid: process.pid,
3878
+ port,
3879
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
3880
+ };
3881
+ writeFileSync8(PID_PATH, JSON.stringify(info, null, 2), { mode: 384 });
3882
+ }
3883
+ function readPid() {
3884
+ if (!existsSync10(PID_PATH)) return null;
3885
+ try {
3886
+ const raw = readFileSync8(PID_PATH, "utf-8");
3887
+ return JSON.parse(raw);
3888
+ } catch {
3889
+ return null;
3890
+ }
3891
+ }
3892
+ function removePid() {
3893
+ try {
3894
+ if (existsSync10(PID_PATH)) unlinkSync2(PID_PATH);
3895
+ } catch {
3896
+ }
3897
+ }
3898
+ function isProcessAlive(pid) {
3899
+ try {
3900
+ process.kill(pid, 0);
3901
+ return true;
3902
+ } catch {
3903
+ return false;
3904
+ }
3905
+ }
3906
+ function getRunningInstance() {
3907
+ const info = readPid();
3908
+ if (!info) return null;
3909
+ if (isProcessAlive(info.pid)) return info;
3910
+ removePid();
3911
+ return null;
3912
+ }
3913
+
3914
+ // src/shared/open-browser.ts
3915
+ var BROWSERS_PRIORITY = [
3916
+ "Google Chrome",
3917
+ "Arc",
3918
+ "Firefox",
3919
+ "Brave Browser",
3920
+ "Microsoft Edge",
3921
+ "Opera",
3922
+ "Vivaldi",
3923
+ "Safari"
3924
+ ];
1806
3925
  async function openBrowser(url) {
1807
3926
  const { exec } = await import("child_process");
1808
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
1809
- exec(`${cmd} ${url}`, () => {
1810
- });
3927
+ if (process.platform === "darwin") {
3928
+ exec(
3929
+ `osascript -e 'tell application "System Events" to get name of every application process whose visible is true'`,
3930
+ (err, stdout) => {
3931
+ if (err || !stdout) {
3932
+ exec(`open "${url}"`, () => {
3933
+ });
3934
+ return;
3935
+ }
3936
+ const apps = stdout.trim();
3937
+ const found = BROWSERS_PRIORITY.find((b) => apps.includes(b));
3938
+ if (found) {
3939
+ const script = `tell application "${found}"
3940
+ open location "${url}"
3941
+ activate
3942
+ end tell`;
3943
+ exec(`osascript -e '${script}'`, () => {
3944
+ });
3945
+ } else {
3946
+ exec(`open "${url}"`, () => {
3947
+ });
3948
+ }
3949
+ }
3950
+ );
3951
+ } else if (process.platform === "win32") {
3952
+ exec(`start "" "${url}"`, () => {
3953
+ });
3954
+ } else {
3955
+ exec(`xdg-open "${url}"`, () => {
3956
+ });
3957
+ }
1811
3958
  }
3959
+
3960
+ // src/cli/start.ts
3961
+ init_logger();
3962
+ var log17 = createLogger("cli:start");
1812
3963
  async function startCommand(opts) {
1813
3964
  try {
3965
+ const existing = getRunningInstance();
3966
+ if (existing) {
3967
+ const url2 = `http://localhost:${existing.port}`;
3968
+ console.log(`
3969
+ DSClaw is already running (PID ${existing.pid}, port ${existing.port})`);
3970
+ console.log(` Opening: ${url2}
3971
+ `);
3972
+ if (opts.open !== false) await openBrowser(url2);
3973
+ return;
3974
+ }
1814
3975
  const config = loadConfig(opts.config);
1815
- const url = `http://localhost:${config.port}`;
1816
- console.log("\n DSClaw");
1817
- console.log(` Web Chat: ${url}`);
3976
+ console.log("\n DSClaw starting...\n");
3977
+ const gateway = new DSClawGateway(config);
3978
+ await gateway.start();
3979
+ const port = gateway.actualPort;
3980
+ const url = `http://localhost:${port}`;
3981
+ writePid(port);
1818
3982
  if (config.telegramBotToken) {
1819
3983
  console.log(" Telegram: enabled");
1820
3984
  }
1821
- console.log();
1822
- const gateway = new DSClawGateway(config);
1823
- await gateway.start();
3985
+ console.log(`
3986
+ DSClaw is running!
3987
+ `);
3988
+ console.log(` \u279C ${url}
3989
+ `);
3990
+ console.log(` Stop with: dsclaw stop
3991
+ `);
1824
3992
  if (opts.open !== false) {
1825
3993
  await openBrowser(url);
1826
3994
  }
1827
- console.log(` DSClaw is running \u2014 chat at ${url}
1828
- `);
3995
+ let shuttingDown = false;
1829
3996
  const shutdown = async () => {
1830
- log12.info("Shutting down gracefully...");
3997
+ if (shuttingDown) return;
3998
+ shuttingDown = true;
3999
+ console.log("\n Shutting down...");
4000
+ log17.info("Shutting down gracefully...");
4001
+ removePid();
1831
4002
  await gateway.stop();
1832
4003
  process.exit(0);
1833
4004
  };
1834
4005
  process.on("SIGINT", shutdown);
1835
4006
  process.on("SIGTERM", shutdown);
4007
+ process.on("exit", () => removePid());
1836
4008
  } catch (error) {
1837
- log12.fatal({ error }, "Failed to start");
4009
+ removePid();
4010
+ log17.fatal({ error }, "Failed to start");
1838
4011
  console.error(
1839
4012
  `
1840
4013
  Failed to start: ${error instanceof Error ? error.message : error}`
@@ -1843,6 +4016,108 @@ async function startCommand(opts) {
1843
4016
  }
1844
4017
  }
1845
4018
 
4019
+ // src/cli/stop.ts
4020
+ function sleep2(ms) {
4021
+ return new Promise((r) => setTimeout(r, ms));
4022
+ }
4023
+ async function stopCommand() {
4024
+ const info = getRunningInstance();
4025
+ if (!info) {
4026
+ console.log("\n DSClaw is not running.\n");
4027
+ return;
4028
+ }
4029
+ console.log(`
4030
+ Stopping DSClaw (PID ${info.pid}, port ${info.port})...`);
4031
+ try {
4032
+ process.kill(info.pid, "SIGTERM");
4033
+ } catch {
4034
+ console.log(" Process already gone \u2014 cleaning up PID file.");
4035
+ removePid();
4036
+ return;
4037
+ }
4038
+ for (let i = 0; i < 10; i++) {
4039
+ await sleep2(500);
4040
+ if (!isProcessAlive(info.pid)) {
4041
+ removePid();
4042
+ console.log(" Stopped.\n");
4043
+ return;
4044
+ }
4045
+ }
4046
+ try {
4047
+ process.kill(info.pid, "SIGKILL");
4048
+ } catch {
4049
+ }
4050
+ removePid();
4051
+ console.log(" Force-killed.\n");
4052
+ }
4053
+
4054
+ // src/cli/status.ts
4055
+ function statusCommand() {
4056
+ const info = getRunningInstance();
4057
+ if (!info) {
4058
+ console.log("\n DSClaw is not running.");
4059
+ console.log(" Start with: dsclaw start\n");
4060
+ return;
4061
+ }
4062
+ const uptime = Date.now() - new Date(info.startedAt).getTime();
4063
+ const mins = Math.floor(uptime / 6e4);
4064
+ const hrs = Math.floor(mins / 60);
4065
+ const uptimeStr = hrs > 0 ? `${hrs}h ${mins % 60}m` : mins > 0 ? `${mins}m` : "<1m";
4066
+ console.log("\n DSClaw is running");
4067
+ console.log(` PID: ${info.pid}`);
4068
+ console.log(` Port: ${info.port}`);
4069
+ console.log(` Uptime: ${uptimeStr}`);
4070
+ console.log(` Chat: http://localhost:${info.port}`);
4071
+ console.log(`
4072
+ Stop with: dsclaw stop
4073
+ `);
4074
+ }
4075
+
4076
+ // src/cli/reset.ts
4077
+ import { existsSync as existsSync11, readdirSync as readdirSync3, unlinkSync as unlinkSync3 } from "fs";
4078
+ import { join as join11 } from "path";
4079
+ function resetCommand(opts) {
4080
+ const running = getRunningInstance();
4081
+ if (running) {
4082
+ console.log(`
4083
+ DSClaw is still running (PID ${running.pid}).`);
4084
+ console.log(" Please run `dsclaw stop` first.\n");
4085
+ return;
4086
+ }
4087
+ const sessionsDir = join11(CONFIG_DIR, "sessions");
4088
+ let cleared = 0;
4089
+ if (existsSync11(sessionsDir)) {
4090
+ const files = readdirSync3(sessionsDir);
4091
+ for (const f of files) {
4092
+ try {
4093
+ unlinkSync3(join11(sessionsDir, f));
4094
+ cleared++;
4095
+ } catch {
4096
+ }
4097
+ }
4098
+ }
4099
+ console.log(`
4100
+ Cleared ${cleared} session(s).`);
4101
+ if (opts.hard) {
4102
+ if (existsSync11(CONFIG_PATH)) {
4103
+ try {
4104
+ unlinkSync3(CONFIG_PATH);
4105
+ console.log(" Deleted config file.");
4106
+ } catch {
4107
+ console.log(" Failed to delete config file.");
4108
+ }
4109
+ }
4110
+ const pidPath = join11(CONFIG_DIR, "dsclaw.pid");
4111
+ if (existsSync11(pidPath)) {
4112
+ try {
4113
+ unlinkSync3(pidPath);
4114
+ } catch {
4115
+ }
4116
+ }
4117
+ }
4118
+ console.log(" Next run will start fresh onboarding.\n");
4119
+ }
4120
+
1846
4121
  // src/cli/doctor.ts
1847
4122
  async function doctorCommand() {
1848
4123
  console.log("\n DSClaw Doctor\n");
@@ -1909,26 +4184,13 @@ async function doctorCommand() {
1909
4184
 
1910
4185
  // src/cli/index.ts
1911
4186
  var program = new Command();
1912
- program.name("dsclaw").description("AI-powered dropshipping agent \u2014 chat with your DSers store.").version("0.1.4");
4187
+ program.name("dsclaw").description("AI-powered dropshipping agent \u2014 chat with your DSers store.").version("0.1.6");
1913
4188
  program.action(() => startCommand({}));
1914
4189
  program.command("init").description("Setup wizard \u2014 configure Telegram (optional)").action(initCommand);
1915
4190
  program.command("start").description("Start the DSClaw bot").option("-c, --config <path>", "Path to config file").option("--no-open", "Don't auto-open browser").action(startCommand);
1916
- program.command("stop").description("Stop the DSClaw bot (PM2)").action(async () => {
1917
- const { execSync } = await import("child_process");
1918
- try {
1919
- execSync("pm2 stop dsclaw", { stdio: "inherit" });
1920
- } catch {
1921
- console.log(" Bot is not running or PM2 is not installed.");
1922
- }
1923
- });
1924
- program.command("status").description("Show bot status").action(async () => {
1925
- const { execSync } = await import("child_process");
1926
- try {
1927
- execSync("pm2 describe dsclaw", { stdio: "inherit" });
1928
- } catch {
1929
- console.log(" Bot is not running. Start with: dsclaw start");
1930
- }
1931
- });
4191
+ program.command("stop").description("Stop the running DSClaw instance").action(stopCommand);
4192
+ program.command("status").description("Show whether DSClaw is running").action(statusCommand);
4193
+ program.command("reset").description("Clear all session data (re-do onboarding)").option("--hard", "Also delete config file").action(resetCommand);
1932
4194
  program.command("doctor").description("Verify configuration").action(doctorCommand);
1933
4195
  program.parse();
1934
4196
  //# sourceMappingURL=index.js.map