omp-wechat 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +6 -2
  2. package/dist/index.js +285 -53
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -86,13 +86,14 @@ To remove: `/wechat uninstall`
86
86
 
87
87
  ## Configuration
88
88
 
89
- Configuration is loaded from `~/.omp-wechat/config.yml`, falling back to built-in defaults. Model, working directory, and tools are inherited from the OMP/Pi session automatically.
89
+ Configuration is loaded from `~/.omp-wechat/config.yml`, falling back to built-in defaults.
90
90
 
91
91
  ```yaml
92
92
  # ~/.omp-wechat/config.yml
93
93
  maxSessions: 50
94
94
  dmPolicy: pairing
95
95
  model: "@smol" # default model (role alias or provider/id)
96
+ cwd: ~/projects/my-app # working directory for AI sessions
96
97
  systemPrompt: |
97
98
  You are an AI assistant chatting via WeChat.
98
99
  Keep replies concise and in plain text.
@@ -103,8 +104,10 @@ systemPrompt: |
103
104
  | `maxSessions` | `50` | Session pool cap (LRU eviction) |
104
105
  | `dmPolicy` | `pairing` | Access policy: `pairing` / `allowlist` / `disabled` |
105
106
  | `model` | OMP default | Default model: role alias (`@smol`, `@slow`) or `provider/id` |
107
+ | `cwd` | `process.cwd()` | Working directory for AI sessions — determines which project context (CLAUDE.md, .omp/) the agent loads |
106
108
  | `systemPrompt` | Built-in | System prompt for WeChat chat sessions |
107
- > **Model, working directory, and tools are managed by OMP/Pi.** `createAgentSession()` automatically calls `discoverAuthStorage()`, reusing your existing `omp login` / `pi login` OAuth, `~/.omp/agent/agent.db` API keys, or `models.yml` config. This project never touches API keys.
109
+
110
+ > **Model and tools are managed by OMP/Pi.** `createAgentSession()` automatically calls `discoverAuthStorage()`, reusing your existing `omp login` / `pi login` OAuth, `~/.omp/agent/agent.db` API keys, or `models.yml` config. This project never touches API keys.
108
111
 
109
112
  ## Slash Commands
110
113
 
@@ -127,6 +130,7 @@ systemPrompt: |
127
130
  | `/model` | Show current AI model |
128
131
  | `/models` | List all available models |
129
132
  | `/model provider/id` | Switch model for this chat (e.g. `/model anthropic/claude-haiku-4-5`) |
133
+ | `/new` | Reset session — clear context and start fresh |
130
134
 
131
135
  ## Access Control
132
136
 
package/dist/index.js CHANGED
@@ -1054,15 +1054,93 @@ import {
1054
1054
  readFileSync,
1055
1055
  writeFileSync,
1056
1056
  mkdirSync as mkdirSync2,
1057
- renameSync
1057
+ renameSync as renameSync2
1058
1058
  } from "fs";
1059
1059
  import { homedir as homedir2 } from "os";
1060
- import { join as join2 } from "path";
1060
+ import { join as join3 } from "path";
1061
1061
 
1062
1062
  // src/utils/logger.ts
1063
1063
  import { homedir } from "os";
1064
- import { join } from "path";
1065
- import { mkdirSync, appendFileSync } from "fs";
1064
+ import { join as join2 } from "path";
1065
+ import { mkdirSync } from "fs";
1066
+
1067
+ // src/utils/rotating-log.ts
1068
+ import { appendFileSync, statSync, renameSync, unlinkSync, readdirSync } from "fs";
1069
+ import { dirname, join, basename } from "path";
1070
+ var DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
1071
+ var DEFAULT_MAX_FILES = 3;
1072
+ var DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
1073
+
1074
+ class RotatingLog {
1075
+ filePath;
1076
+ maxBytes;
1077
+ maxFiles;
1078
+ maxAgeMs;
1079
+ baseName;
1080
+ dir;
1081
+ activeFileName;
1082
+ constructor(opts) {
1083
+ this.filePath = opts.filePath;
1084
+ this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
1085
+ this.maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES;
1086
+ this.maxAgeMs = opts.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
1087
+ this.dir = dirname(this.filePath);
1088
+ this.activeFileName = basename(this.filePath);
1089
+ this.baseName = basename(this.filePath, ".log");
1090
+ }
1091
+ write(line) {
1092
+ try {
1093
+ this.maybeRotate();
1094
+ appendFileSync(this.filePath, line, "utf-8");
1095
+ } catch {}
1096
+ }
1097
+ cleanStale() {
1098
+ const cutoff = Date.now() - this.maxAgeMs;
1099
+ try {
1100
+ for (const name of readdirSync(this.dir)) {
1101
+ if (name === this.activeFileName)
1102
+ continue;
1103
+ if (!name.startsWith(this.baseName) || !name.endsWith(".log"))
1104
+ continue;
1105
+ const fullPath = join(this.dir, name);
1106
+ try {
1107
+ const stat = statSync(fullPath);
1108
+ if (stat.mtimeMs < cutoff)
1109
+ unlinkSync(fullPath);
1110
+ } catch {}
1111
+ }
1112
+ } catch {}
1113
+ }
1114
+ maybeRotate() {
1115
+ let size;
1116
+ try {
1117
+ size = statSync(this.filePath).size;
1118
+ } catch {
1119
+ return;
1120
+ }
1121
+ if (size < this.maxBytes)
1122
+ return;
1123
+ const oldest = this.rotatedPath(this.maxFiles);
1124
+ try {
1125
+ unlinkSync(oldest);
1126
+ } catch {}
1127
+ for (let i = this.maxFiles - 1;i >= 1; i--) {
1128
+ const src = this.rotatedPath(i);
1129
+ const dst = this.rotatedPath(i + 1);
1130
+ try {
1131
+ renameSync(src, dst);
1132
+ } catch {}
1133
+ }
1134
+ try {
1135
+ renameSync(this.filePath, this.rotatedPath(1));
1136
+ } catch {}
1137
+ }
1138
+ rotatedPath(index) {
1139
+ return join(this.dir, `${this.baseName}.${index}.log`);
1140
+ }
1141
+ }
1142
+
1143
+ // src/utils/logger.ts
1066
1144
  var LEVEL_ORDER = {
1067
1145
  debug: 0,
1068
1146
  info: 1,
@@ -1070,11 +1148,18 @@ var LEVEL_ORDER = {
1070
1148
  error: 3
1071
1149
  };
1072
1150
  var minLevel = process.env.OMP_WECHAT_LOG ?? "info";
1073
- var LOG_DIR = join(homedir(), ".omp", "logs");
1074
- var LOG_FILE = join(LOG_DIR, "wechat.log");
1151
+ var LOG_DIR = join2(homedir(), ".omp", "logs");
1152
+ var LOG_FILE = join2(LOG_DIR, "wechat.log");
1075
1153
  try {
1076
1154
  mkdirSync(LOG_DIR, { recursive: true });
1077
1155
  } catch {}
1156
+ var rotatingLog = new RotatingLog({
1157
+ filePath: LOG_FILE,
1158
+ maxBytes: 5 * 1024 * 1024,
1159
+ maxFiles: 3,
1160
+ maxAgeMs: 30 * 24 * 60 * 60 * 1000
1161
+ });
1162
+ rotatingLog.cleanStale();
1078
1163
  function ts() {
1079
1164
  return new Date().toISOString();
1080
1165
  }
@@ -1085,10 +1170,7 @@ function log(level, msg, meta) {
1085
1170
  const line = meta !== undefined ? `${prefix} ${msg} ${JSON.stringify(meta)}
1086
1171
  ` : `${prefix} ${msg}
1087
1172
  `;
1088
- process.stderr.write(line);
1089
- try {
1090
- appendFileSync(LOG_FILE, line);
1091
- } catch {}
1173
+ rotatingLog.write(line);
1092
1174
  }
1093
1175
  var logger = {
1094
1176
  debug: (msg, meta) => log("debug", msg, meta),
@@ -1098,9 +1180,9 @@ var logger = {
1098
1180
  };
1099
1181
 
1100
1182
  // src/ilink/client.ts
1101
- var STATE_DIR = join2(homedir2(), ".omp-wechat");
1102
- var CREDENTIALS_FILE = join2(STATE_DIR, "credentials.json");
1103
- var SYNC_BUF_FILE = join2(STATE_DIR, "sync_buf.txt");
1183
+ var STATE_DIR = join3(homedir2(), ".omp-wechat");
1184
+ var CREDENTIALS_FILE = join3(STATE_DIR, "credentials.json");
1185
+ var SYNC_BUF_FILE = join3(STATE_DIR, "sync_buf.txt");
1104
1186
  function loadCredentials() {
1105
1187
  try {
1106
1188
  return JSON.parse(readFileSync(CREDENTIALS_FILE, "utf8"));
@@ -1113,7 +1195,7 @@ function saveCredentials(creds) {
1113
1195
  const tmp = CREDENTIALS_FILE + ".tmp";
1114
1196
  writeFileSync(tmp, JSON.stringify(creds, null, 2) + `
1115
1197
  `, { mode: 384 });
1116
- renameSync(tmp, CREDENTIALS_FILE);
1198
+ renameSync2(tmp, CREDENTIALS_FILE);
1117
1199
  }
1118
1200
  function getCredentials() {
1119
1201
  const creds = loadCredentials();
@@ -1243,6 +1325,7 @@ function saveSyncBuf(buf) {
1243
1325
  function extractInboundText(msg) {
1244
1326
  const items = msg.item_list ?? [];
1245
1327
  const parts = [];
1328
+ let imgCount = 0;
1246
1329
  for (const item of items) {
1247
1330
  switch (item.type) {
1248
1331
  case 1:
@@ -1250,7 +1333,7 @@ function extractInboundText(msg) {
1250
1333
  parts.push(item.text_item.text);
1251
1334
  break;
1252
1335
  case 2:
1253
- parts.push("(image)");
1336
+ imgCount++;
1254
1337
  break;
1255
1338
  case 3:
1256
1339
  parts.push(item.voice_item?.text ?? "(voice)");
@@ -1263,9 +1346,17 @@ function extractInboundText(msg) {
1263
1346
  break;
1264
1347
  }
1265
1348
  }
1349
+ if (imgCount > 0 && parts.length === 0) {
1350
+ parts.push(`(user sent ${imgCount} image${imgCount > 1 ? "s" : ""})`);
1351
+ } else if (imgCount > 0) {
1352
+ parts.push(`(+${imgCount} image${imgCount > 1 ? "s" : ""})`);
1353
+ }
1266
1354
  return parts.join(`
1267
1355
  `) || "";
1268
1356
  }
1357
+ function extractInboundImages(msg) {
1358
+ return (msg.item_list ?? []).filter((item) => item.type === 2);
1359
+ }
1269
1360
 
1270
1361
  // src/access/control.ts
1271
1362
  import { randomBytes as randomBytes2 } from "crypto";
@@ -1273,12 +1364,12 @@ import {
1273
1364
  readFileSync as readFileSync2,
1274
1365
  writeFileSync as writeFileSync2,
1275
1366
  mkdirSync as mkdirSync3,
1276
- renameSync as renameSync2
1367
+ renameSync as renameSync3
1277
1368
  } from "fs";
1278
1369
  import { homedir as homedir3 } from "os";
1279
- import { join as join3 } from "path";
1280
- var STATE_DIR2 = join3(homedir3(), ".omp-wechat");
1281
- var ACCESS_FILE = join3(STATE_DIR2, "access.json");
1370
+ import { join as join4 } from "path";
1371
+ var STATE_DIR2 = join4(homedir3(), ".omp-wechat");
1372
+ var ACCESS_FILE = join4(STATE_DIR2, "access.json");
1282
1373
  function defaultAccess() {
1283
1374
  return { dmPolicy: "pairing", allowFrom: [], pending: {} };
1284
1375
  }
@@ -1295,7 +1386,7 @@ function readAccessFile() {
1295
1386
  if (err.code === "ENOENT")
1296
1387
  return defaultAccess();
1297
1388
  try {
1298
- renameSync2(ACCESS_FILE, `${ACCESS_FILE}.corrupt-${Date.now()}`);
1389
+ renameSync3(ACCESS_FILE, `${ACCESS_FILE}.corrupt-${Date.now()}`);
1299
1390
  } catch {}
1300
1391
  logger.warn("access.json is corrupt, moved aside, using defaults");
1301
1392
  return defaultAccess();
@@ -1306,7 +1397,7 @@ function saveAccess(a) {
1306
1397
  const tmp = ACCESS_FILE + ".tmp";
1307
1398
  writeFileSync2(tmp, JSON.stringify(a, null, 2) + `
1308
1399
  `, { mode: 384 });
1309
- renameSync2(tmp, ACCESS_FILE);
1400
+ renameSync3(tmp, ACCESS_FILE);
1310
1401
  }
1311
1402
  function loadAccess() {
1312
1403
  return readAccessFile();
@@ -1481,7 +1572,7 @@ import { randomBytes as randomBytes3 } from "crypto";
1481
1572
 
1482
1573
  // src/config.ts
1483
1574
  import { homedir as homedir4 } from "os";
1484
- import { join as join4 } from "path";
1575
+ import { join as join5 } from "path";
1485
1576
  import { existsSync, readFileSync as readFileSync3 } from "fs";
1486
1577
  var DEFAULT_SYSTEM_PROMPT = `You are an AI assistant chatting with users via WeChat.
1487
1578
 
@@ -1490,8 +1581,8 @@ Constraints:
1490
1581
  - Keep replies concise; WeChat has a ~2000 character limit per message
1491
1582
  - If you need to write code or long documents, summarize the key points; the user will review on their computer
1492
1583
  - Users may send short or incomplete messages from their phone; proactively understand their intent`;
1493
- var CONFIG_DIR = join4(homedir4(), ".omp-wechat");
1494
- var CONFIG_FILE = join4(CONFIG_DIR, "config.yml");
1584
+ var CONFIG_DIR = join5(homedir4(), ".omp-wechat");
1585
+ var CONFIG_FILE = join5(CONFIG_DIR, "config.yml");
1495
1586
  function loadConfig() {
1496
1587
  const config = {
1497
1588
  maxSessions: 50,
@@ -1506,16 +1597,25 @@ function loadConfig() {
1506
1597
  config.maxSessions = parseInt(parsed.maxSessions, 10);
1507
1598
  if (parsed.dmPolicy)
1508
1599
  config.dmPolicy = parsed.dmPolicy;
1509
- if (parsed.systemPrompt)
1510
- config.systemPrompt = parsed.systemPrompt;
1511
1600
  if (parsed.model)
1512
1601
  config.model = parsed.model;
1602
+ if (parsed.cwd)
1603
+ config.cwd = expandTilde(parsed.cwd);
1604
+ if (parsed.systemPrompt)
1605
+ config.systemPrompt = parsed.systemPrompt;
1513
1606
  }
1514
1607
  } catch (err) {
1515
1608
  logger.warn("Failed to load config.yml, using defaults", err);
1516
1609
  }
1517
1610
  return config;
1518
1611
  }
1612
+ function expandTilde(p) {
1613
+ if (p.startsWith("~/"))
1614
+ return join5(homedir4(), p.slice(2));
1615
+ if (p === "~")
1616
+ return homedir4();
1617
+ return p;
1618
+ }
1519
1619
  function parseSimpleYaml(yaml) {
1520
1620
  const result = {};
1521
1621
  let inMultiline = false;
@@ -1580,11 +1680,11 @@ function chunkText(text, limit = 2000) {
1580
1680
  }
1581
1681
 
1582
1682
  // src/utils/dedup.ts
1583
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4, renameSync as renameSync3 } from "fs";
1683
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4, renameSync as renameSync4 } from "fs";
1584
1684
  import { homedir as homedir5 } from "os";
1585
- import { join as join5 } from "path";
1586
- var STATE_DIR3 = join5(homedir5(), ".omp-wechat");
1587
- var DEDUP_FILE = join5(STATE_DIR3, "seen_msgs.json");
1685
+ import { join as join6 } from "path";
1686
+ var STATE_DIR3 = join6(homedir5(), ".omp-wechat");
1687
+ var DEDUP_FILE = join6(STATE_DIR3, "seen_msgs.json");
1588
1688
  var MAX_ENTRIES = 500;
1589
1689
  var DEDUP_TTL_MS = 5 * 60 * 1000;
1590
1690
  var entries = null;
@@ -1617,7 +1717,7 @@ function persist(s) {
1617
1717
  const tmp = DEDUP_FILE + ".tmp";
1618
1718
  writeFileSync3(tmp, JSON.stringify(arr) + `
1619
1719
  `, { mode: 384 });
1620
- renameSync3(tmp, DEDUP_FILE);
1720
+ renameSync4(tmp, DEDUP_FILE);
1621
1721
  } catch (err) {
1622
1722
  logger.debug(`dedup persist failed: ${err}`);
1623
1723
  }
@@ -1637,38 +1737,107 @@ function isDuplicate(key) {
1637
1737
  return false;
1638
1738
  }
1639
1739
 
1740
+ // src/ilink/cdn.ts
1741
+ import { createDecipheriv } from "crypto";
1742
+ var CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
1743
+ function parseAesKey(aeskeyHex, aesKeyBase64) {
1744
+ if (aeskeyHex && /^[0-9a-fA-F]{32}$/.test(aeskeyHex)) {
1745
+ return Buffer.from(aeskeyHex, "hex");
1746
+ }
1747
+ if (!aesKeyBase64)
1748
+ return null;
1749
+ const decoded = Buffer.from(aesKeyBase64, "base64");
1750
+ if (decoded.length === 16)
1751
+ return decoded;
1752
+ if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString("ascii"))) {
1753
+ return Buffer.from(decoded.toString("ascii"), "hex");
1754
+ }
1755
+ return null;
1756
+ }
1757
+ function decryptAesEcb(ciphertext, key) {
1758
+ const decipher = createDecipheriv("aes-128-ecb", key, null);
1759
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
1760
+ }
1761
+ function buildCdnUrl(fileUrl, fullUrl) {
1762
+ if (fullUrl)
1763
+ return fullUrl;
1764
+ if (!fileUrl)
1765
+ return null;
1766
+ if (fileUrl.startsWith("http"))
1767
+ return fileUrl;
1768
+ return `${CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(fileUrl)}`;
1769
+ }
1770
+ async function downloadAndDecrypt(fileUrl, fullUrl, aeskeyHex, aesKeyBase64, label = "media") {
1771
+ const url = buildCdnUrl(fileUrl, fullUrl);
1772
+ if (!url) {
1773
+ logger.warn(`[${label}] No CDN URL available`);
1774
+ return null;
1775
+ }
1776
+ const key = parseAesKey(aeskeyHex, aesKeyBase64);
1777
+ if (!key) {
1778
+ logger.warn(`[${label}] Could not parse AES key`);
1779
+ return null;
1780
+ }
1781
+ try {
1782
+ const resp = await fetch(url);
1783
+ if (!resp.ok) {
1784
+ logger.warn(`[${label}] CDN download failed: ${resp.status} ${resp.statusText}`);
1785
+ return null;
1786
+ }
1787
+ const encrypted = Buffer.from(await resp.arrayBuffer());
1788
+ const plaintext = decryptAesEcb(encrypted, key);
1789
+ logger.info(`[${label}] Downloaded + decrypted: ${encrypted.length} \u2192 ${plaintext.length} bytes`);
1790
+ return plaintext;
1791
+ } catch (err) {
1792
+ logger.error(`[${label}] CDN download/decrypt error:`, err);
1793
+ return null;
1794
+ }
1795
+ }
1796
+
1640
1797
  // src/engine/session.ts
1641
1798
  import { createAgentSession, SessionManager } from "@oh-my-pi/pi-coding-agent";
1642
1799
 
1643
1800
  // src/engine/session-store.ts
1644
- import { join as join6 } from "path";
1801
+ import { join as join7 } from "path";
1645
1802
  import { homedir as homedir6 } from "os";
1646
- import { existsSync as existsSync2, mkdirSync as mkdirSync5, readdirSync, statSync, rmSync } from "fs";
1647
- var STATE_DIR4 = join6(homedir6(), ".omp-wechat");
1648
- var SESSIONS_DIR = join6(STATE_DIR4, "sessions");
1803
+ import { existsSync as existsSync2, mkdirSync as mkdirSync5, readdirSync as readdirSync2, statSync as statSync2, rmSync } from "fs";
1804
+ var STATE_DIR4 = join7(homedir6(), ".omp-wechat");
1805
+ var SESSIONS_DIR = join7(STATE_DIR4, "sessions");
1649
1806
  var STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
1650
1807
  function sanitizeChatId(chatId) {
1651
1808
  return chatId.replace(/[^a-zA-Z0-9_@.-]/g, "_").slice(0, 200);
1652
1809
  }
1653
1810
  function sessionDirFor(chatId) {
1654
- return join6(SESSIONS_DIR, sanitizeChatId(chatId));
1811
+ return join7(SESSIONS_DIR, sanitizeChatId(chatId));
1655
1812
  }
1656
1813
  function ensureSessionsDir() {
1657
1814
  mkdirSync5(SESSIONS_DIR, { recursive: true, mode: 448 });
1658
1815
  }
1816
+ function removeSessionDir(chatId) {
1817
+ const dir = sessionDirFor(chatId);
1818
+ if (!existsSync2(dir))
1819
+ return false;
1820
+ try {
1821
+ rmSync(dir, { recursive: true, force: true });
1822
+ return true;
1823
+ } catch (err) {
1824
+ logger.warn(`Failed to remove session dir ${dir}: ${err}`);
1825
+ return false;
1826
+ }
1827
+ }
1659
1828
  function cleanupStaleSessions() {
1660
1829
  if (!existsSync2(SESSIONS_DIR))
1661
1830
  return 0;
1662
1831
  const now = Date.now();
1663
1832
  let removed = 0;
1664
- for (const entry of readdirSync(SESSIONS_DIR, { withFileTypes: true })) {
1833
+ for (const entry of readdirSync2(SESSIONS_DIR, { withFileTypes: true })) {
1665
1834
  if (!entry.isDirectory())
1666
1835
  continue;
1667
- const dir = join6(SESSIONS_DIR, entry.name);
1836
+ const dir = join7(SESSIONS_DIR, entry.name);
1668
1837
  let newestMtime = 0;
1669
1838
  try {
1670
- for (const file of readdirSync(dir)) {
1671
- const mtime = statSync(join6(dir, file)).mtimeMs;
1839
+ for (const file of readdirSync2(dir)) {
1840
+ const mtime = statSync2(join7(dir, file)).mtimeMs;
1672
1841
  if (mtime > newestMtime)
1673
1842
  newestMtime = mtime;
1674
1843
  }
@@ -1694,11 +1863,11 @@ function clearAllSessions() {
1694
1863
  if (!existsSync2(SESSIONS_DIR))
1695
1864
  return 0;
1696
1865
  let removed = 0;
1697
- for (const entry of readdirSync(SESSIONS_DIR, { withFileTypes: true })) {
1866
+ for (const entry of readdirSync2(SESSIONS_DIR, { withFileTypes: true })) {
1698
1867
  if (!entry.isDirectory())
1699
1868
  continue;
1700
1869
  try {
1701
- rmSync(join6(SESSIONS_DIR, entry.name), { recursive: true, force: true });
1870
+ rmSync(join7(SESSIONS_DIR, entry.name), { recursive: true, force: true });
1702
1871
  removed++;
1703
1872
  } catch (err) {
1704
1873
  logger.warn(`Failed to remove session dir ${entry.name}: ${err}`);
@@ -1738,7 +1907,7 @@ class ChatSession {
1738
1907
  logger.info(`Creating session: ${chatId}`);
1739
1908
  ensureSessionsDir();
1740
1909
  const sessionDir = sessionDirFor(chatId);
1741
- const sessionManager = await SessionManager.continueRecent(process.cwd(), sessionDir);
1910
+ const sessionManager = await SessionManager.continueRecent(config.cwd || process.cwd(), sessionDir);
1742
1911
  logger.info(`Session dir: ${sessionDir} (resumed=${sessionManager.getSessionFile() !== null})`);
1743
1912
  const { session, modelFallbackMessage } = await createAgentSession({
1744
1913
  sessionManager,
@@ -1751,6 +1920,8 @@ class ChatSession {
1751
1920
  logger.warn(`Model fallback: ${modelFallbackMessage}`);
1752
1921
  }
1753
1922
  const wrapper = new ChatSession(session, chatId, contextToken);
1923
+ const visionRole = session.settings.getModelRole("vision");
1924
+ logger.info(`[${chatId}] Model: ${session.model?.id ?? "unknown"}, vision role: ${visionRole ?? "(not configured)"}`);
1754
1925
  session.subscribe((event) => {
1755
1926
  if (event.type !== "message_end")
1756
1927
  return;
@@ -1765,9 +1936,12 @@ class ChatSession {
1765
1936
  });
1766
1937
  return wrapper;
1767
1938
  }
1768
- async prompt(text) {
1939
+ async prompt(text, images) {
1769
1940
  this.lastActive = Date.now();
1770
- await this.session.prompt(text);
1941
+ await this.session.prompt(text, images?.length ? { images } : undefined);
1942
+ }
1943
+ supportsVision() {
1944
+ return this.session.settings.getModelRole("vision") !== undefined;
1771
1945
  }
1772
1946
  setContextToken(token) {
1773
1947
  this.contextToken = token;
@@ -1808,16 +1982,27 @@ class SessionPool {
1808
1982
  this.pool.set(chatId, entry);
1809
1983
  return entry;
1810
1984
  }
1811
- async prompt(chatId, contextToken, text, config) {
1985
+ async prompt(chatId, contextToken, text, config, images) {
1812
1986
  const entry = await this.ensure(chatId, contextToken, config);
1813
- await entry.prompt(text);
1987
+ await entry.prompt(text, images);
1814
1988
  }
1815
1989
  get(chatId) {
1816
1990
  return this.pool.get(chatId);
1817
1991
  }
1992
+ async resetSession(chatId) {
1993
+ const entry = this.pool.get(chatId);
1994
+ if (entry) {
1995
+ await entry.dispose();
1996
+ this.pool.delete(chatId);
1997
+ }
1998
+ removeSessionDir(chatId);
1999
+ }
1818
2000
  getContextToken(chatId) {
1819
2001
  return this.pool.get(chatId)?.getContextToken() ?? "";
1820
2002
  }
2003
+ supportsVision(chatId) {
2004
+ return this.pool.get(chatId)?.supportsVision() ?? false;
2005
+ }
1821
2006
  evictOldest() {
1822
2007
  let oldestId = null;
1823
2008
  let oldestTime = Infinity;
@@ -2010,6 +2195,24 @@ class ModelCommand {
2010
2195
  }
2011
2196
  }
2012
2197
 
2198
+ // src/command/new-session-command.ts
2199
+ class NewSessionInvocation {
2200
+ async execute(ctx) {
2201
+ await ctx.pool.resetSession(ctx.chatId);
2202
+ return "Session reset. Your next message starts a fresh conversation.";
2203
+ }
2204
+ }
2205
+
2206
+ class NewSessionCommand {
2207
+ name = "new";
2208
+ parse(text) {
2209
+ if (text === "/new" || text === "/reset") {
2210
+ return new NewSessionInvocation;
2211
+ }
2212
+ return null;
2213
+ }
2214
+ }
2215
+
2013
2216
  // src/bridge.ts
2014
2217
  var MAX_FAILURES = 3;
2015
2218
  var BACKOFF_MS = 30000;
@@ -2027,6 +2230,7 @@ class WeChatBridge {
2027
2230
  commands = new CommandRegistry;
2028
2231
  constructor() {
2029
2232
  this.commands.register(new ModelCommand);
2233
+ this.commands.register(new NewSessionCommand);
2030
2234
  }
2031
2235
  start() {
2032
2236
  const config = loadConfig();
@@ -2180,12 +2384,40 @@ class WeChatBridge {
2180
2384
  }
2181
2385
  await sendTyping(creds, senderId, 1).catch(() => {});
2182
2386
  try {
2183
- await this.pool.prompt(senderId, contextToken, text, config);
2387
+ const session = await this.pool.ensure(senderId, contextToken, config);
2388
+ const images = await this.downloadImages(creds, msg, senderId);
2389
+ await session.prompt(text, images);
2184
2390
  } catch (err) {
2185
2391
  logger.error(`[${senderId}] prompt failed:`, err);
2186
2392
  await this.sendReply(creds, senderId, "Processing failed, please try again.");
2187
2393
  }
2188
2394
  }
2395
+ async downloadImages(_creds, msg, chatId) {
2396
+ const imageItems = extractInboundImages(msg);
2397
+ if (imageItems.length === 0)
2398
+ return [];
2399
+ if (!this.pool?.supportsVision(chatId)) {
2400
+ logger.info(`[${chatId}] Skipping image download \u2014 model does not support vision`);
2401
+ return [];
2402
+ }
2403
+ const results = [];
2404
+ for (const item of imageItems) {
2405
+ const img = item.image_item;
2406
+ const buf = await downloadAndDecrypt(img.file_url, img.full_url, img.aeskey, img.aes_key ?? img.media?.aes_key, `image[${chatId}]`);
2407
+ if (buf) {
2408
+ const mimeType = buf.length > 4 && buf[0] === 137 && buf[1] === 80 ? "image/png" : "image/jpeg";
2409
+ results.push({
2410
+ type: "image",
2411
+ data: buf.toString("base64"),
2412
+ mimeType
2413
+ });
2414
+ }
2415
+ }
2416
+ if (results.length > 0) {
2417
+ logger.info(`[${chatId}] Downloaded ${results.length}/${imageItems.length} images for AI`);
2418
+ }
2419
+ return results;
2420
+ }
2189
2421
  async sendReply(creds, chatId, text) {
2190
2422
  const contextToken = this.pool?.getContextToken(chatId) ?? "";
2191
2423
  if (!contextToken) {
@@ -2216,7 +2448,7 @@ class WeChatBridge {
2216
2448
 
2217
2449
  // src/service.ts
2218
2450
  import { platform, homedir as homedir7 } from "os";
2219
- import { join as join7 } from "path";
2451
+ import { join as join8 } from "path";
2220
2452
  import { existsSync as existsSync3, mkdirSync as mkdirSync6, writeFileSync as writeFileSync4, rmSync as rmSync2 } from "fs";
2221
2453
  var PLIST_LABEL = "com.omp-wechat";
2222
2454
  var SERVICE_NAME = "omp-wechat";
@@ -2229,13 +2461,13 @@ function detectPlatform() {
2229
2461
  return "other";
2230
2462
  }
2231
2463
  function getLogDir() {
2232
- return join7(homedir7(), ".omp", "logs");
2464
+ return join8(homedir7(), ".omp", "logs");
2233
2465
  }
2234
2466
  function resolveHostBinary() {
2235
2467
  return process.execPath || "omp";
2236
2468
  }
2237
2469
  function plistPath() {
2238
- return join7(homedir7(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
2470
+ return join8(homedir7(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
2239
2471
  }
2240
2472
  function generatePlist() {
2241
2473
  const omp = resolveHostBinary();
@@ -2284,7 +2516,7 @@ function generatePlist() {
2284
2516
  `;
2285
2517
  }
2286
2518
  function installLaunchd() {
2287
- const dir = join7(homedir7(), "Library", "LaunchAgents");
2519
+ const dir = join8(homedir7(), "Library", "LaunchAgents");
2288
2520
  mkdirSync6(dir, { recursive: true });
2289
2521
  mkdirSync6(getLogDir(), { recursive: true });
2290
2522
  const plist = plistPath();
@@ -2336,7 +2568,7 @@ StandardError=append:${logDir}/rpc.log
2336
2568
  NoNewPrivileges=true
2337
2569
  ProtectSystem=strict
2338
2570
  ProtectHome=read-only
2339
- ReadWritePaths=${logDir} ${join7(homedir7(), ".omp-wechat")} ${join7(homedir7(), ".omp")}
2571
+ ReadWritePaths=${logDir} ${join8(homedir7(), ".omp-wechat")} ${join8(homedir7(), ".omp")}
2340
2572
  PrivateTmp=true
2341
2573
 
2342
2574
  [Install]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-wechat",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "OMP/Pi extension: bridge WeChat messages to OMP's AI engine via the iLink Bot API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",