omp-wechat 1.0.0 → 1.1.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +12 -4
  3. package/dist/index.js +627 -249
  4. package/package.json +1 -1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -92,6 +92,7 @@ Configuration is loaded from `~/.omp-wechat/config.yml`, falling back to built-i
92
92
  # ~/.omp-wechat/config.yml
93
93
  maxSessions: 50
94
94
  dmPolicy: pairing
95
+ model: "@smol" # default model (role alias or provider/id)
95
96
  systemPrompt: |
96
97
  You are an AI assistant chatting via WeChat.
97
98
  Keep replies concise and in plain text.
@@ -101,8 +102,8 @@ systemPrompt: |
101
102
  |---|---|---|
102
103
  | `maxSessions` | `50` | Session pool cap (LRU eviction) |
103
104
  | `dmPolicy` | `pairing` | Access policy: `pairing` / `allowlist` / `disabled` |
105
+ | `model` | OMP default | Default model: role alias (`@smol`, `@slow`) or `provider/id` |
104
106
  | `systemPrompt` | Built-in | System prompt for WeChat chat sessions |
105
-
106
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.
107
108
 
108
109
  ## Slash Commands
@@ -119,6 +120,14 @@ systemPrompt: |
119
120
  | `/wechat install` | Install boot-time launchd/systemd service |
120
121
  | `/wechat uninstall` | Remove boot-time service |
121
122
 
123
+ ### Chat Commands (via WeChat message)
124
+
125
+ | Command | Description |
126
+ |---|---|
127
+ | `/model` | Show current AI model |
128
+ | `/models` | List all available models |
129
+ | `/model provider/id` | Switch model for this chat (e.g. `/model anthropic/claude-haiku-4-5`) |
130
+
122
131
  ## Access Control
123
132
 
124
133
  | Mode | Behavior |
@@ -174,15 +183,14 @@ OMP-Wechat/
174
183
 
175
184
  - **Reply-only**: iLink requires `context_token` from an inbound message; you cannot initiate conversations
176
185
  - **1:1 only**: iLink Bot API does not support group chats
177
- - **No message history**: WeChat provides no history API; session context is lost on restart (in-memory sessions)
178
186
  - **Single instance**: iLink allows only one bot connection per account
179
187
  - **Text only (Phase 1)**: images/voice/video are represented as `(image)` / `(voice)` placeholders; media support is planned
180
188
 
181
189
  ## Roadmap
182
190
 
183
191
  - [ ] **Phase 2**: Media support (inbound images as base64, voice transcription)
184
- - [ ] **Phase 3**: Persistent sessions (`SessionManager.create()` per chat directory)
185
- - [ ] **Phase 4**: Per-chat model selection (smol for simple questions, slow for complex)
192
+ - [x] **Phase 3**: Persistent sessions `SessionManager.continueRecent()` per chat, context survives restarts
193
+ - [x] **Phase 4**: Per-chat model selection `/model` `/models` chat commands for manual switching
186
194
  - [ ] **Phase 5**: Fine-grained permissions (per-user tool restrictions, bash approval via WeChat)
187
195
 
188
196
  ## License
package/dist/index.js CHANGED
@@ -1070,8 +1070,8 @@ var LEVEL_ORDER = {
1070
1070
  error: 3
1071
1071
  };
1072
1072
  var minLevel = process.env.OMP_WECHAT_LOG ?? "info";
1073
- var LOG_DIR = join(homedir(), ".omp-wechat", "logs");
1074
- var LOG_FILE = join(LOG_DIR, "daemon.log");
1073
+ var LOG_DIR = join(homedir(), ".omp", "logs");
1074
+ var LOG_FILE = join(LOG_DIR, "wechat.log");
1075
1075
  try {
1076
1076
  mkdirSync(LOG_DIR, { recursive: true });
1077
1077
  } catch {}
@@ -1175,12 +1175,12 @@ async function getUpdates(creds, buf) {
1175
1175
  throw err;
1176
1176
  }
1177
1177
  }
1178
- async function sendMessage(creds, to, text, contextToken) {
1178
+ async function sendMessage(creds, to, text, contextToken, clientId) {
1179
1179
  await apiFetch(creds, "ilink/bot/sendmessage", {
1180
1180
  msg: {
1181
1181
  from_user_id: "",
1182
1182
  to_user_id: to,
1183
- client_id: `omp-wechat-${Date.now()}-${randomBytes(4).toString("hex")}`,
1183
+ client_id: clientId ?? `omp-wechat-${Date.now()}-${randomBytes(4).toString("hex")}`,
1184
1184
  message_type: 2,
1185
1185
  message_state: 2,
1186
1186
  item_list: [{ type: 1, text_item: { text } }],
@@ -1476,6 +1476,9 @@ async function pollQrStatus(qrcode, baseUrl) {
1476
1476
  }
1477
1477
  }
1478
1478
 
1479
+ // src/bridge.ts
1480
+ import { randomBytes as randomBytes3 } from "crypto";
1481
+
1479
1482
  // src/config.ts
1480
1483
  import { homedir as homedir4 } from "os";
1481
1484
  import { join as join4 } from "path";
@@ -1505,6 +1508,8 @@ function loadConfig() {
1505
1508
  config.dmPolicy = parsed.dmPolicy;
1506
1509
  if (parsed.systemPrompt)
1507
1510
  config.systemPrompt = parsed.systemPrompt;
1511
+ if (parsed.model)
1512
+ config.model = parsed.model;
1508
1513
  }
1509
1514
  } catch (err) {
1510
1515
  logger.warn("Failed to load config.yml, using defaults", err);
@@ -1574,35 +1579,135 @@ function chunkText(text, limit = 2000) {
1574
1579
  return out;
1575
1580
  }
1576
1581
 
1582
+ // src/utils/dedup.ts
1583
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4, renameSync as renameSync3 } from "fs";
1584
+ 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");
1588
+ var MAX_ENTRIES = 500;
1589
+ var DEDUP_TTL_MS = 5 * 60 * 1000;
1590
+ var entries = null;
1591
+ function load() {
1592
+ if (entries)
1593
+ return entries;
1594
+ try {
1595
+ const raw = readFileSync4(DEDUP_FILE, "utf8");
1596
+ const arr = JSON.parse(raw);
1597
+ entries = Array.isArray(arr) ? arr : [];
1598
+ } catch {
1599
+ entries = [];
1600
+ }
1601
+ return entries;
1602
+ }
1603
+ function gc(s) {
1604
+ const now = Date.now();
1605
+ const fresh = s.filter((e) => now - e.ts < DEDUP_TTL_MS);
1606
+ if (fresh.length < s.length) {
1607
+ s.length = 0;
1608
+ s.push(...fresh);
1609
+ }
1610
+ }
1611
+ function persist(s) {
1612
+ let arr = s.slice();
1613
+ if (arr.length > MAX_ENTRIES)
1614
+ arr = arr.slice(arr.length - MAX_ENTRIES);
1615
+ try {
1616
+ mkdirSync4(STATE_DIR3, { recursive: true, mode: 448 });
1617
+ const tmp = DEDUP_FILE + ".tmp";
1618
+ writeFileSync3(tmp, JSON.stringify(arr) + `
1619
+ `, { mode: 384 });
1620
+ renameSync3(tmp, DEDUP_FILE);
1621
+ } catch (err) {
1622
+ logger.debug(`dedup persist failed: ${err}`);
1623
+ }
1624
+ }
1625
+ function makeDedupKey(senderId, createTimeMs, text) {
1626
+ if (!createTimeMs)
1627
+ return null;
1628
+ return `${senderId}|${createTimeMs}|${text.slice(0, 200)}`;
1629
+ }
1630
+ function isDuplicate(key) {
1631
+ const s = load();
1632
+ gc(s);
1633
+ if (s.some((e) => e.key === key))
1634
+ return true;
1635
+ s.push({ key, ts: Date.now() });
1636
+ persist(s);
1637
+ return false;
1638
+ }
1639
+
1577
1640
  // src/engine/session.ts
1578
1641
  import { createAgentSession, SessionManager } from "@oh-my-pi/pi-coding-agent";
1579
- async function createSession(chatId, contextToken, config, onReply) {
1580
- logger.info(`Creating session: ${chatId}`);
1581
- const { session, modelFallbackMessage } = await createAgentSession({
1582
- sessionManager: SessionManager.inMemory(),
1583
- enableMCP: false,
1584
- enableLsp: false,
1585
- systemPrompt: config.systemPrompt
1586
- });
1587
- if (modelFallbackMessage) {
1588
- logger.warn(`Model fallback: ${modelFallbackMessage}`);
1642
+
1643
+ // src/engine/session-store.ts
1644
+ import { join as join6 } from "path";
1645
+ 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");
1649
+ var STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
1650
+ function sanitizeChatId(chatId) {
1651
+ return chatId.replace(/[^a-zA-Z0-9_@.-]/g, "_").slice(0, 200);
1652
+ }
1653
+ function sessionDirFor(chatId) {
1654
+ return join6(SESSIONS_DIR, sanitizeChatId(chatId));
1655
+ }
1656
+ function ensureSessionsDir() {
1657
+ mkdirSync5(SESSIONS_DIR, { recursive: true, mode: 448 });
1658
+ }
1659
+ function cleanupStaleSessions() {
1660
+ if (!existsSync2(SESSIONS_DIR))
1661
+ return 0;
1662
+ const now = Date.now();
1663
+ let removed = 0;
1664
+ for (const entry of readdirSync(SESSIONS_DIR, { withFileTypes: true })) {
1665
+ if (!entry.isDirectory())
1666
+ continue;
1667
+ const dir = join6(SESSIONS_DIR, entry.name);
1668
+ let newestMtime = 0;
1669
+ try {
1670
+ for (const file of readdirSync(dir)) {
1671
+ const mtime = statSync(join6(dir, file)).mtimeMs;
1672
+ if (mtime > newestMtime)
1673
+ newestMtime = mtime;
1674
+ }
1675
+ } catch {
1676
+ continue;
1677
+ }
1678
+ if (newestMtime === 0 || now - newestMtime > STALE_THRESHOLD_MS) {
1679
+ try {
1680
+ rmSync(dir, { recursive: true, force: true });
1681
+ removed++;
1682
+ logger.info(`Cleaned up stale session dir: ${entry.name}`);
1683
+ } catch (err) {
1684
+ logger.warn(`Failed to cleanup session dir ${entry.name}: ${err}`);
1685
+ }
1686
+ }
1589
1687
  }
1590
- session.subscribe((event) => {
1591
- if (event.type !== "message_end")
1592
- return;
1593
- if (event.message.role !== "assistant")
1594
- return;
1595
- const text = extractAssistantText(event.message);
1596
- if (text) {
1597
- onReply(chatId, text);
1688
+ if (removed > 0) {
1689
+ logger.info(`Session cleanup: removed ${removed} stale session(s)`);
1690
+ }
1691
+ return removed;
1692
+ }
1693
+ function clearAllSessions() {
1694
+ if (!existsSync2(SESSIONS_DIR))
1695
+ return 0;
1696
+ let removed = 0;
1697
+ for (const entry of readdirSync(SESSIONS_DIR, { withFileTypes: true })) {
1698
+ if (!entry.isDirectory())
1699
+ continue;
1700
+ try {
1701
+ rmSync(join6(SESSIONS_DIR, entry.name), { recursive: true, force: true });
1702
+ removed++;
1703
+ } catch (err) {
1704
+ logger.warn(`Failed to remove session dir ${entry.name}: ${err}`);
1598
1705
  }
1599
- });
1600
- return {
1601
- session,
1602
- lastActive: Date.now(),
1603
- contextToken
1604
- };
1706
+ }
1707
+ return removed;
1605
1708
  }
1709
+
1710
+ // src/engine/session.ts
1606
1711
  function extractAssistantText(message) {
1607
1712
  const content = message.content;
1608
1713
  if (!Array.isArray(content))
@@ -1617,70 +1722,292 @@ function extractAssistantText(message) {
1617
1722
  `).trim();
1618
1723
  }
1619
1724
 
1725
+ class ChatSession {
1726
+ session;
1727
+ chatId;
1728
+ contextToken;
1729
+ lastActive;
1730
+ replyCount = 0;
1731
+ constructor(session, chatId, contextToken) {
1732
+ this.session = session;
1733
+ this.chatId = chatId;
1734
+ this.contextToken = contextToken;
1735
+ this.lastActive = Date.now();
1736
+ }
1737
+ static async create(chatId, contextToken, config, onReply) {
1738
+ logger.info(`Creating session: ${chatId}`);
1739
+ ensureSessionsDir();
1740
+ const sessionDir = sessionDirFor(chatId);
1741
+ const sessionManager = await SessionManager.continueRecent(process.cwd(), sessionDir);
1742
+ logger.info(`Session dir: ${sessionDir} (resumed=${sessionManager.getSessionFile() !== null})`);
1743
+ const { session, modelFallbackMessage } = await createAgentSession({
1744
+ sessionManager,
1745
+ enableMCP: false,
1746
+ enableLsp: false,
1747
+ systemPrompt: config.systemPrompt,
1748
+ modelPattern: config.model
1749
+ });
1750
+ if (modelFallbackMessage) {
1751
+ logger.warn(`Model fallback: ${modelFallbackMessage}`);
1752
+ }
1753
+ const wrapper = new ChatSession(session, chatId, contextToken);
1754
+ session.subscribe((event) => {
1755
+ if (event.type !== "message_end")
1756
+ return;
1757
+ if (event.message.role !== "assistant")
1758
+ return;
1759
+ wrapper.replyCount++;
1760
+ const text = extractAssistantText(event.message);
1761
+ logger.info(`[${chatId}] message_end #${wrapper.replyCount}: ${text.slice(0, 100)}`);
1762
+ if (text) {
1763
+ onReply(chatId, text);
1764
+ }
1765
+ });
1766
+ return wrapper;
1767
+ }
1768
+ async prompt(text) {
1769
+ this.lastActive = Date.now();
1770
+ await this.session.prompt(text);
1771
+ }
1772
+ setContextToken(token) {
1773
+ this.contextToken = token;
1774
+ }
1775
+ getContextToken() {
1776
+ return this.contextToken;
1777
+ }
1778
+ getLastActive() {
1779
+ return this.lastActive;
1780
+ }
1781
+ async dispose() {
1782
+ await this.session.dispose();
1783
+ }
1784
+ }
1785
+
1620
1786
  // src/engine/pool.ts
1621
- var maxSessions = 50;
1622
- var pool = new Map;
1623
- var replyHandler = () => {};
1624
- function setReplyHandler(fn) {
1625
- replyHandler = fn;
1626
- }
1627
- function setMaxSessions(n) {
1628
- maxSessions = n;
1629
- }
1630
- async function ensureSession(chatId, contextToken, config) {
1631
- let entry = pool.get(chatId);
1632
- if (entry) {
1633
- entry.contextToken = contextToken;
1634
- entry.lastActive = Date.now();
1787
+ class SessionPool {
1788
+ pool = new Map;
1789
+ maxSessions;
1790
+ replyHandler;
1791
+ constructor(maxSessions, replyHandler) {
1792
+ this.maxSessions = maxSessions;
1793
+ this.replyHandler = replyHandler;
1794
+ }
1795
+ setMaxSessions(n) {
1796
+ this.maxSessions = n;
1797
+ }
1798
+ async ensure(chatId, contextToken, config) {
1799
+ let entry = this.pool.get(chatId);
1800
+ if (entry) {
1801
+ entry.setContextToken(contextToken);
1802
+ return entry;
1803
+ }
1804
+ if (this.pool.size >= this.maxSessions) {
1805
+ this.evictOldest();
1806
+ }
1807
+ entry = await ChatSession.create(chatId, contextToken, config, this.replyHandler);
1808
+ this.pool.set(chatId, entry);
1635
1809
  return entry;
1636
1810
  }
1637
- if (pool.size >= maxSessions) {
1638
- evictOldest();
1811
+ async prompt(chatId, contextToken, text, config) {
1812
+ const entry = await this.ensure(chatId, contextToken, config);
1813
+ await entry.prompt(text);
1814
+ }
1815
+ get(chatId) {
1816
+ return this.pool.get(chatId);
1817
+ }
1818
+ getContextToken(chatId) {
1819
+ return this.pool.get(chatId)?.getContextToken() ?? "";
1820
+ }
1821
+ evictOldest() {
1822
+ let oldestId = null;
1823
+ let oldestTime = Infinity;
1824
+ for (const [id, entry] of this.pool) {
1825
+ const t = entry.getLastActive();
1826
+ if (t < oldestTime) {
1827
+ oldestTime = t;
1828
+ oldestId = id;
1829
+ }
1830
+ }
1831
+ if (oldestId) {
1832
+ const entry = this.pool.get(oldestId);
1833
+ if (entry) {
1834
+ entry.dispose().catch((err) => {
1835
+ logger.warn(`Session dispose error (${oldestId}): ${err}`);
1836
+ });
1837
+ }
1838
+ this.pool.delete(oldestId);
1839
+ logger.info(`LRU evicted session: ${oldestId}`);
1840
+ }
1841
+ }
1842
+ getPoolStatus() {
1843
+ const chats = Array.from(this.pool.entries()).map(([chatId, entry]) => ({
1844
+ chatId,
1845
+ lastActive: entry.getLastActive()
1846
+ }));
1847
+ return { count: this.pool.size, max: this.maxSessions, chats };
1848
+ }
1849
+ async disposeAll() {
1850
+ const disposals = Array.from(this.pool.values()).map((entry) => entry.dispose().catch((err) => {
1851
+ logger.warn(`Session dispose error: ${err}`);
1852
+ }));
1853
+ await Promise.all(disposals);
1854
+ this.pool.clear();
1639
1855
  }
1640
- entry = await createSession(chatId, contextToken, config, replyHandler);
1641
- pool.set(chatId, entry);
1642
- return entry;
1643
1856
  }
1644
- async function promptSession(chatId, contextToken, text, config) {
1645
- const entry = await ensureSession(chatId, contextToken, config);
1646
- await entry.session.prompt(text);
1857
+
1858
+ // src/command/registry.ts
1859
+ class CommandRegistry {
1860
+ commands = [];
1861
+ register(cmd) {
1862
+ this.commands.push(cmd);
1863
+ }
1864
+ tryParse(text) {
1865
+ const trimmed = text.trim();
1866
+ if (!trimmed.startsWith("/"))
1867
+ return null;
1868
+ for (const cmd of this.commands) {
1869
+ const inv = cmd.parse(trimmed);
1870
+ if (inv)
1871
+ return inv;
1872
+ }
1873
+ return null;
1874
+ }
1647
1875
  }
1648
- function getContextToken(chatId) {
1649
- return pool.get(chatId)?.contextToken ?? "";
1876
+
1877
+ // src/engine/model-controller.ts
1878
+ function currentModel(session) {
1879
+ const m = session.model;
1880
+ if (!m)
1881
+ return;
1882
+ return {
1883
+ provider: m.provider,
1884
+ id: m.id,
1885
+ name: m.name,
1886
+ contextWindow: m.contextWindow
1887
+ };
1650
1888
  }
1651
- function evictOldest() {
1652
- let oldestId = null;
1653
- let oldestTime = Infinity;
1654
- for (const [id, entry] of pool) {
1655
- if (entry.lastActive < oldestTime) {
1656
- oldestTime = entry.lastActive;
1657
- oldestId = id;
1658
- }
1889
+ function listModels(session) {
1890
+ return session.modelRegistry.getAvailable().map((m) => ({
1891
+ provider: m.provider,
1892
+ id: m.id,
1893
+ name: m.name,
1894
+ contextWindow: m.contextWindow
1895
+ }));
1896
+ }
1897
+ function resolveModel(pattern, session) {
1898
+ const trimmed = pattern.trim();
1899
+ const slash = trimmed.indexOf("/");
1900
+ if (slash <= 0)
1901
+ return null;
1902
+ const provider = trimmed.slice(0, slash);
1903
+ const modelId = trimmed.slice(slash + 1);
1904
+ if (!provider || !modelId)
1905
+ return null;
1906
+ const model = session.modelRegistry.find(provider, modelId);
1907
+ if (!model)
1908
+ return null;
1909
+ return {
1910
+ provider: model.provider,
1911
+ id: model.id,
1912
+ name: model.name,
1913
+ contextWindow: model.contextWindow
1914
+ };
1915
+ }
1916
+ async function switchModel(pattern, session) {
1917
+ const info = resolveModel(pattern, session);
1918
+ if (!info) {
1919
+ return {
1920
+ ok: false,
1921
+ message: `Unknown model: ${pattern}
1922
+ Use /models to see available options (format: provider/model-id).`
1923
+ };
1659
1924
  }
1660
- if (oldestId) {
1661
- const entry = pool.get(oldestId);
1662
- if (entry) {
1663
- entry.session.dispose().catch((err) => {
1664
- logger.warn(`Session dispose error (${oldestId}): ${err}`);
1665
- });
1925
+ const model = session.modelRegistry.find(info.provider, info.id);
1926
+ if (!model) {
1927
+ return { ok: false, message: `Model not found: ${pattern}` };
1928
+ }
1929
+ try {
1930
+ await session.setModelTemporary(model);
1931
+ return {
1932
+ ok: true,
1933
+ message: `Switched to ${info.provider}/${info.id} (${info.name})`
1934
+ };
1935
+ } catch (err) {
1936
+ return {
1937
+ ok: false,
1938
+ message: `Switch failed: ${err instanceof Error ? err.message : String(err)}`
1939
+ };
1940
+ }
1941
+ }
1942
+
1943
+ // src/command/model-command.ts
1944
+ var MODEL_PREFIX = "/model";
1945
+ var MODELS_PREFIX = "/models";
1946
+
1947
+ class ModelQueryInvocation {
1948
+ async execute(ctx) {
1949
+ const entry = ctx.pool.get(ctx.chatId);
1950
+ if (!entry)
1951
+ return "No active session for this chat.";
1952
+ const info = currentModel(entry.session);
1953
+ if (!info)
1954
+ return "No model selected.";
1955
+ return `Current model: ${info.provider}/${info.id} (${info.name})`;
1956
+ }
1957
+ }
1958
+
1959
+ class ModelListInvocation {
1960
+ async execute(ctx) {
1961
+ const entry = ctx.pool.get(ctx.chatId);
1962
+ if (!entry)
1963
+ return "No active session for this chat.";
1964
+ const models = listModels(entry.session);
1965
+ if (models.length === 0)
1966
+ return "No models available.";
1967
+ const cur = currentModel(entry.session);
1968
+ const curKey = cur ? `${cur.provider}/${cur.id}` : "";
1969
+ const lines = [`Available models (${models.length}):`];
1970
+ for (const m of models) {
1971
+ const key = `${m.provider}/${m.id}`;
1972
+ const marker = key === curKey ? " \u2190 current" : "";
1973
+ lines.push(` ${key} (${m.name})${marker}`);
1666
1974
  }
1667
- pool.delete(oldestId);
1668
- logger.info(`LRU evicted session: ${oldestId}`);
1975
+ lines.push("", "Switch with: /model provider/id");
1976
+ return lines.join(`
1977
+ `);
1669
1978
  }
1670
1979
  }
1671
- function getPoolStatus() {
1672
- const chats = Array.from(pool.entries()).map(([chatId, entry]) => ({
1673
- chatId,
1674
- lastActive: entry.lastActive
1675
- }));
1676
- return { count: pool.size, max: maxSessions, chats };
1980
+
1981
+ class ModelSwitchInvocation {
1982
+ pattern;
1983
+ constructor(pattern) {
1984
+ this.pattern = pattern;
1985
+ }
1986
+ async execute(ctx) {
1987
+ const entry = ctx.pool.get(ctx.chatId);
1988
+ if (!entry)
1989
+ return "No active session for this chat.";
1990
+ const result = await switchModel(this.pattern, entry.session);
1991
+ return result.message;
1992
+ }
1677
1993
  }
1678
- async function disposeAll() {
1679
- const disposals = Array.from(pool.values()).map((entry) => entry.session.dispose().catch((err) => {
1680
- logger.warn(`Session dispose error: ${err}`);
1681
- }));
1682
- await Promise.all(disposals);
1683
- pool.clear();
1994
+
1995
+ class ModelCommand {
1996
+ name = "model";
1997
+ parse(text) {
1998
+ if (text === MODELS_PREFIX || text.startsWith(`${MODELS_PREFIX} `)) {
1999
+ return new ModelListInvocation;
2000
+ }
2001
+ if (text === MODEL_PREFIX) {
2002
+ return new ModelQueryInvocation;
2003
+ }
2004
+ if (text.startsWith(`${MODEL_PREFIX} `)) {
2005
+ const pattern = text.slice(MODEL_PREFIX.length + 1).trim();
2006
+ if (pattern)
2007
+ return new ModelSwitchInvocation(pattern);
2008
+ }
2009
+ return null;
2010
+ }
1684
2011
  }
1685
2012
 
1686
2013
  // src/bridge.ts
@@ -1690,171 +2017,207 @@ var RETRY_MS = 2000;
1690
2017
  var MAX_SEND_RETRIES = 2;
1691
2018
  var CHUNK_LIMIT = 2000;
1692
2019
  var LOCK_PORT = 19821;
1693
- var pollActive = false;
1694
- var lockServer = null;
1695
- function acquireLock() {
1696
- try {
1697
- lockServer = Bun.serve({
1698
- port: LOCK_PORT,
1699
- hostname: "127.0.0.1",
1700
- fetch() {
1701
- return new Response("ok");
1702
- }
1703
- });
1704
- return true;
1705
- } catch {
1706
- return false;
2020
+
2021
+ class WeChatBridge {
2022
+ state = null;
2023
+ pollActive = false;
2024
+ lockServer = null;
2025
+ cleanupTimer = null;
2026
+ pool = null;
2027
+ commands = new CommandRegistry;
2028
+ constructor() {
2029
+ this.commands.register(new ModelCommand);
1707
2030
  }
1708
- }
1709
- function releaseLock() {
1710
- if (lockServer) {
1711
- lockServer.stop(true);
1712
- lockServer = null;
2031
+ start() {
2032
+ const config = loadConfig();
2033
+ const creds = getCredentials();
2034
+ if (this.pollActive) {
2035
+ return this.state;
2036
+ }
2037
+ if (!this.acquireLock()) {
2038
+ logger.debug("Another poll loop is running (port lock held), skipping");
2039
+ return { running: false, config, creds, lastError: "another instance running" };
2040
+ }
2041
+ this.pollActive = true;
2042
+ const replyHandler = (chatId, text) => {
2043
+ sendTyping(creds, chatId, 2).catch(() => {});
2044
+ this.sendReply(creds, chatId, text).catch((err) => {
2045
+ logger.error(`[${chatId}] Reply send failed:`, err);
2046
+ });
2047
+ };
2048
+ this.pool = new SessionPool(config.maxSessions, replyHandler);
2049
+ this.state = { running: true, config, creds, lastError: null };
2050
+ logger.info("OMP-Wechat poll loop starting", {
2051
+ maxSessions: config.maxSessions,
2052
+ dmPolicy: config.dmPolicy,
2053
+ model: config.model ?? "(omp default)"
2054
+ });
2055
+ this.pollLoop(creds, this.state).catch((err) => {
2056
+ logger.error("Poll loop crashed:", err);
2057
+ this.state.running = false;
2058
+ this.state.lastError = String(err);
2059
+ this.pollActive = false;
2060
+ this.releaseLock();
2061
+ });
2062
+ cleanupStaleSessions();
2063
+ this.cleanupTimer = setInterval(() => cleanupStaleSessions(), 6 * 60 * 60 * 1000);
2064
+ return this.state;
1713
2065
  }
1714
- }
1715
- function startPollLoop() {
1716
- const config = loadConfig();
1717
- const creds = getCredentials();
1718
- if (pollActive) {
1719
- return { running: true, config, creds, lastError: null };
2066
+ async stop() {
2067
+ this.pollActive = false;
2068
+ if (this.cleanupTimer) {
2069
+ clearInterval(this.cleanupTimer);
2070
+ this.cleanupTimer = null;
2071
+ }
2072
+ this.releaseLock();
2073
+ if (this.pool) {
2074
+ await this.pool.disposeAll();
2075
+ }
2076
+ logger.info("Poll loop stopped");
1720
2077
  }
1721
- if (!acquireLock()) {
1722
- logger.info("Another poll loop is running (port lock held), skipping");
1723
- return { running: false, config, creds, lastError: "another instance running" };
2078
+ getPoolStatus() {
2079
+ return this.pool?.getPoolStatus() ?? { count: 0, max: 0, chats: [] };
1724
2080
  }
1725
- pollActive = true;
1726
- setMaxSessions(config.maxSessions);
1727
- const state = { running: true, config, creds, lastError: null };
1728
- setReplyHandler((chatId, text) => {
1729
- sendTyping(creds, chatId, 2).catch(() => {});
1730
- sendReply(creds, chatId, text).catch((err) => {
1731
- logger.error(`[${chatId}] Reply send failed:`, err);
1732
- });
1733
- });
1734
- logger.info("OMP-Wechat poll loop starting", {
1735
- maxSessions: config.maxSessions,
1736
- dmPolicy: config.dmPolicy
1737
- });
1738
- pollLoop(creds, state).catch((err) => {
1739
- logger.error("Poll loop crashed:", err);
1740
- state.running = false;
1741
- state.lastError = String(err);
1742
- pollActive = false;
1743
- releaseLock();
1744
- });
1745
- return state;
1746
- }
1747
- async function stopPollLoop() {
1748
- pollActive = false;
1749
- releaseLock();
1750
- await disposeAll();
1751
- logger.info("Poll loop stopped");
1752
- }
1753
- async function pollLoop(creds, state) {
1754
- let failures = 0;
1755
- logger.info("Long-poll started");
1756
- while (pollActive) {
2081
+ acquireLock() {
1757
2082
  try {
1758
- const buf = loadSyncBuf();
1759
- const resp = await getUpdates(creds, buf);
1760
- if (resp.ret !== undefined && resp.ret !== 0) {
2083
+ this.lockServer = Bun.serve({
2084
+ port: LOCK_PORT,
2085
+ hostname: "127.0.0.1",
2086
+ fetch() {
2087
+ return new Response("ok");
2088
+ }
2089
+ });
2090
+ return true;
2091
+ } catch {
2092
+ return false;
2093
+ }
2094
+ }
2095
+ releaseLock() {
2096
+ if (this.lockServer) {
2097
+ this.lockServer.stop(true);
2098
+ this.lockServer = null;
2099
+ }
2100
+ }
2101
+ async pollLoop(creds, state) {
2102
+ let failures = 0;
2103
+ logger.info("Long-poll started");
2104
+ while (this.pollActive) {
2105
+ try {
2106
+ const buf = loadSyncBuf();
2107
+ const resp = await getUpdates(creds, buf);
2108
+ if (resp.ret !== undefined && resp.ret !== 0) {
2109
+ failures++;
2110
+ logger.warn(`getupdates error ret=${resp.ret} errmsg=${resp.errmsg ?? ""} (${failures}/${MAX_FAILURES})`);
2111
+ if (failures >= MAX_FAILURES) {
2112
+ failures = 0;
2113
+ await Bun.sleep(BACKOFF_MS);
2114
+ } else {
2115
+ await Bun.sleep(RETRY_MS);
2116
+ }
2117
+ continue;
2118
+ }
2119
+ failures = 0;
2120
+ state.lastError = null;
2121
+ if (resp.get_updates_buf && resp.get_updates_buf !== buf) {
2122
+ saveSyncBuf(resp.get_updates_buf);
2123
+ }
2124
+ const msgs = resp.msgs ?? [];
2125
+ for (const msg of msgs) {
2126
+ await this.handleInbound(creds, msg).catch((err) => {
2127
+ logger.error("Message handler error:", err);
2128
+ });
2129
+ }
2130
+ } catch (err) {
1761
2131
  failures++;
1762
- logger.warn(`getupdates error ret=${resp.ret} errmsg=${resp.errmsg ?? ""} (${failures}/${MAX_FAILURES})`);
2132
+ state.lastError = String(err);
2133
+ logger.error(`Poll error (${failures}/${MAX_FAILURES}):`, err);
1763
2134
  if (failures >= MAX_FAILURES) {
1764
2135
  failures = 0;
1765
2136
  await Bun.sleep(BACKOFF_MS);
1766
2137
  } else {
1767
2138
  await Bun.sleep(RETRY_MS);
1768
2139
  }
1769
- continue;
1770
- }
1771
- failures = 0;
1772
- state.lastError = null;
1773
- if (resp.get_updates_buf && resp.get_updates_buf !== buf) {
1774
- saveSyncBuf(resp.get_updates_buf);
1775
2140
  }
1776
- const msgs = resp.msgs ?? [];
1777
- for (const msg of msgs) {
1778
- await handleInbound(creds, msg).catch((err) => {
1779
- logger.error("Message handler error:", err);
2141
+ }
2142
+ }
2143
+ async handleInbound(creds, msg) {
2144
+ if (msg.message_type !== 1)
2145
+ return;
2146
+ const senderId = msg.from_user_id;
2147
+ if (!senderId)
2148
+ return;
2149
+ const contextToken = msg.context_token ?? "";
2150
+ const result = gate(senderId);
2151
+ if (result.action === "drop")
2152
+ return;
2153
+ if (result.action === "pair") {
2154
+ if (contextToken) {
2155
+ const lead = result.isResend ? "Still waiting for pairing" : "Pairing required";
2156
+ const text2 = `${lead} \u2014 approve in OMP with: /wechat pair ${result.code}`;
2157
+ await sendMessage(creds, senderId, text2, contextToken).catch((err) => {
2158
+ logger.warn("Pairing reply send failed:", err);
1780
2159
  });
1781
2160
  }
1782
- } catch (err) {
1783
- failures++;
1784
- state.lastError = String(err);
1785
- logger.error(`Poll error (${failures}/${MAX_FAILURES}):`, err);
1786
- if (failures >= MAX_FAILURES) {
1787
- failures = 0;
1788
- await Bun.sleep(BACKOFF_MS);
1789
- } else {
1790
- await Bun.sleep(RETRY_MS);
1791
- }
2161
+ return;
1792
2162
  }
1793
- }
1794
- }
1795
- async function handleInbound(creds, msg) {
1796
- if (msg.message_type !== 1)
1797
- return;
1798
- const senderId = msg.from_user_id;
1799
- if (!senderId)
1800
- return;
1801
- const contextToken = msg.context_token ?? "";
1802
- const result = gate(senderId);
1803
- if (result.action === "drop")
1804
- return;
1805
- if (result.action === "pair") {
1806
- if (contextToken) {
1807
- const lead = result.isResend ? "Still waiting for pairing" : "Pairing required";
1808
- const text2 = `${lead} \u2014 approve in OMP with: /wechat pair ${result.code}`;
1809
- await sendMessage(creds, senderId, text2, contextToken).catch((err) => {
1810
- logger.warn("Pairing reply send failed:", err);
2163
+ const text = extractInboundText(msg);
2164
+ if (!text)
2165
+ return;
2166
+ const dedupKey = makeDedupKey(senderId, msg.create_time_ms, text);
2167
+ if (dedupKey && isDuplicate(dedupKey)) {
2168
+ logger.info(`[${senderId}] Skipping duplicate message: ${text.slice(0, 80)}`);
2169
+ return;
2170
+ }
2171
+ const config = loadConfig();
2172
+ logger.info(`[${senderId}] Inbound (ts=${msg.create_time_ms ?? "n/a"}): ${text.slice(0, 80)}`);
2173
+ const invocation = this.commands.tryParse(text);
2174
+ if (invocation) {
2175
+ const reply = await invocation.execute({ pool: this.pool, config, chatId: senderId }).catch((err) => `Command failed: ${err instanceof Error ? err.message : String(err)}`);
2176
+ await sendMessage(creds, senderId, reply, contextToken).catch((err) => {
2177
+ logger.warn(`[${senderId}] Command reply send failed:`, err);
1811
2178
  });
2179
+ return;
2180
+ }
2181
+ await sendTyping(creds, senderId, 1).catch(() => {});
2182
+ try {
2183
+ await this.pool.prompt(senderId, contextToken, text, config);
2184
+ } catch (err) {
2185
+ logger.error(`[${senderId}] prompt failed:`, err);
2186
+ await this.sendReply(creds, senderId, "Processing failed, please try again.");
1812
2187
  }
1813
- return;
1814
- }
1815
- const text = extractInboundText(msg);
1816
- if (!text)
1817
- return;
1818
- const config = loadConfig();
1819
- logger.info(`[${senderId}] Inbound: ${text.slice(0, 80)}`);
1820
- await sendTyping(creds, senderId, 1).catch(() => {});
1821
- try {
1822
- await promptSession(senderId, contextToken, text, config);
1823
- } catch (err) {
1824
- logger.error(`[${senderId}] prompt failed:`, err);
1825
- await sendReply(creds, senderId, "Processing failed, please try again.");
1826
- }
1827
- }
1828
- async function sendReply(creds, chatId, text) {
1829
- const contextToken = getContextToken(chatId);
1830
- if (!contextToken) {
1831
- logger.warn(`[${chatId}] No context_token, cannot reply`);
1832
- return;
1833
2188
  }
1834
- const chunks = chunkText(text, CHUNK_LIMIT);
1835
- for (const chunk of chunks) {
1836
- let retries = 0;
1837
- while (retries <= MAX_SEND_RETRIES) {
1838
- try {
1839
- await sendMessage(creds, chatId, chunk, contextToken);
1840
- break;
1841
- } catch (err) {
1842
- retries++;
1843
- if (retries > MAX_SEND_RETRIES) {
1844
- logger.error(`[${chatId}] Send failed, dropping:`, err);
1845
- return;
2189
+ async sendReply(creds, chatId, text) {
2190
+ const contextToken = this.pool?.getContextToken(chatId) ?? "";
2191
+ if (!contextToken) {
2192
+ logger.warn(`[${chatId}] No context_token, cannot reply`);
2193
+ return;
2194
+ }
2195
+ const chunks = chunkText(text, CHUNK_LIMIT);
2196
+ for (const chunk of chunks) {
2197
+ const clientId = `omp-wechat-${Date.now()}-${randomBytes3(4).toString("hex")}`;
2198
+ let retries = 0;
2199
+ while (retries <= MAX_SEND_RETRIES) {
2200
+ try {
2201
+ await sendMessage(creds, chatId, chunk, contextToken, clientId);
2202
+ break;
2203
+ } catch (err) {
2204
+ retries++;
2205
+ if (retries > MAX_SEND_RETRIES) {
2206
+ logger.error(`[${chatId}] Send failed, dropping:`, err);
2207
+ return;
2208
+ }
2209
+ logger.warn(`[${chatId}] Send retry ${retries}/${MAX_SEND_RETRIES}:`, err);
2210
+ await Bun.sleep(1000 * retries);
1846
2211
  }
1847
- logger.warn(`[${chatId}] Send retry ${retries}/${MAX_SEND_RETRIES}:`, err);
1848
- await Bun.sleep(1000 * retries);
1849
2212
  }
1850
2213
  }
1851
2214
  }
1852
2215
  }
1853
2216
 
1854
2217
  // src/service.ts
1855
- import { platform, homedir as homedir5 } from "os";
1856
- import { join as join5 } from "path";
1857
- import { existsSync as existsSync2, mkdirSync as mkdirSync4, writeFileSync as writeFileSync3, rmSync } from "fs";
2218
+ import { platform, homedir as homedir7 } from "os";
2219
+ import { join as join7 } from "path";
2220
+ import { existsSync as existsSync3, mkdirSync as mkdirSync6, writeFileSync as writeFileSync4, rmSync as rmSync2 } from "fs";
1858
2221
  var PLIST_LABEL = "com.omp-wechat";
1859
2222
  var SERVICE_NAME = "omp-wechat";
1860
2223
  function detectPlatform() {
@@ -1866,13 +2229,13 @@ function detectPlatform() {
1866
2229
  return "other";
1867
2230
  }
1868
2231
  function getLogDir() {
1869
- return join5(homedir5(), ".omp-wechat", "logs");
2232
+ return join7(homedir7(), ".omp", "logs");
1870
2233
  }
1871
2234
  function resolveHostBinary() {
1872
2235
  return process.execPath || "omp";
1873
2236
  }
1874
2237
  function plistPath() {
1875
- return join5(homedir5(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
2238
+ return join7(homedir7(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
1876
2239
  }
1877
2240
  function generatePlist() {
1878
2241
  const omp = resolveHostBinary();
@@ -1914,21 +2277,21 @@ function generatePlist() {
1914
2277
  <key>PATH</key>
1915
2278
  <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
1916
2279
  <key>HOME</key>
1917
- <string>${homedir5()}</string>
2280
+ <string>${homedir7()}</string>
1918
2281
  </dict>
1919
2282
  </dict>
1920
2283
  </plist>
1921
2284
  `;
1922
2285
  }
1923
2286
  function installLaunchd() {
1924
- const dir = join5(homedir5(), "Library", "LaunchAgents");
1925
- mkdirSync4(dir, { recursive: true });
1926
- mkdirSync4(getLogDir(), { recursive: true });
2287
+ const dir = join7(homedir7(), "Library", "LaunchAgents");
2288
+ mkdirSync6(dir, { recursive: true });
2289
+ mkdirSync6(getLogDir(), { recursive: true });
1927
2290
  const plist = plistPath();
1928
- if (existsSync2(plist)) {
2291
+ if (existsSync3(plist)) {
1929
2292
  Bun.spawnSync(["launchctl", "unload", plist], { stderr: "ignore" });
1930
2293
  }
1931
- writeFileSync3(plist, generatePlist());
2294
+ writeFileSync4(plist, generatePlist());
1932
2295
  const result = Bun.spawnSync(["launchctl", "load", plist], { stderr: "inherit" });
1933
2296
  if (result.exitCode !== 0) {
1934
2297
  throw new Error("launchctl load failed");
@@ -1937,11 +2300,11 @@ function installLaunchd() {
1937
2300
  }
1938
2301
  function uninstallLaunchd() {
1939
2302
  const plist = plistPath();
1940
- if (!existsSync2(plist)) {
2303
+ if (!existsSync3(plist)) {
1941
2304
  throw new Error("No launchd service found (may not be installed)");
1942
2305
  }
1943
2306
  Bun.spawnSync(["launchctl", "unload", plist], { stderr: "inherit" });
1944
- rmSync(plist);
2307
+ rmSync2(plist);
1945
2308
  }
1946
2309
  function servicePath() {
1947
2310
  return `/etc/systemd/system/${SERVICE_NAME}.service`;
@@ -1964,7 +2327,7 @@ StandardInput=null
1964
2327
  Restart=always
1965
2328
  RestartSec=10
1966
2329
 
1967
- Environment=HOME=${homedir5()}
2330
+ Environment=HOME=${homedir7()}
1968
2331
  Environment=PATH=/usr/local/bin:/usr/bin:/bin
1969
2332
 
1970
2333
  StandardOutput=append:${logDir}/rpc.log
@@ -1973,7 +2336,7 @@ StandardError=append:${logDir}/rpc.log
1973
2336
  NoNewPrivileges=true
1974
2337
  ProtectSystem=strict
1975
2338
  ProtectHome=read-only
1976
- ReadWritePaths=${logDir} ${join5(homedir5(), ".omp-wechat")} ${join5(homedir5(), ".omp")}
2339
+ ReadWritePaths=${logDir} ${join7(homedir7(), ".omp-wechat")} ${join7(homedir7(), ".omp")}
1977
2340
  PrivateTmp=true
1978
2341
 
1979
2342
  [Install]
@@ -1982,17 +2345,17 @@ WantedBy=multi-user.target
1982
2345
  }
1983
2346
  function installSystemd() {
1984
2347
  const svc = servicePath();
1985
- mkdirSync4(getLogDir(), { recursive: true });
1986
- if (existsSync2(svc)) {
2348
+ mkdirSync6(getLogDir(), { recursive: true });
2349
+ if (existsSync3(svc)) {
1987
2350
  Bun.spawnSync(["sudo", "systemctl", "stop", SERVICE_NAME], { stderr: "inherit" });
1988
2351
  }
1989
2352
  const tmp = `/tmp/${SERVICE_NAME}.service`;
1990
- writeFileSync3(tmp, generateService());
2353
+ writeFileSync4(tmp, generateService());
1991
2354
  let result = Bun.spawnSync(["sudo", "te", tmp, svc], { stderr: "inherit" });
1992
2355
  if (result.exitCode !== 0) {
1993
2356
  result = Bun.spawnSync(["sudo", "cp", tmp, svc], { stderr: "inherit" });
1994
2357
  }
1995
- rmSync(tmp);
2358
+ rmSync2(tmp);
1996
2359
  if (result.exitCode !== 0) {
1997
2360
  throw new Error("Failed to write service file (need sudo)");
1998
2361
  }
@@ -2004,7 +2367,7 @@ function installSystemd() {
2004
2367
  }
2005
2368
  function uninstallSystemd() {
2006
2369
  const svc = servicePath();
2007
- if (!existsSync2(svc)) {
2370
+ if (!existsSync3(svc)) {
2008
2371
  throw new Error("No systemd service found (may not be installed)");
2009
2372
  }
2010
2373
  Bun.spawnSync(["sudo", "systemctl", "stop", SERVICE_NAME], { stderr: "inherit" });
@@ -2042,30 +2405,32 @@ function isServiceInstalled() {
2042
2405
  const p = detectPlatform();
2043
2406
  switch (p) {
2044
2407
  case "darwin":
2045
- return existsSync2(plistPath());
2408
+ return existsSync3(plistPath());
2046
2409
  case "linux":
2047
- return existsSync2(servicePath());
2410
+ return existsSync3(servicePath());
2048
2411
  default:
2049
2412
  return false;
2050
2413
  }
2051
2414
  }
2052
2415
 
2053
2416
  // src/index.ts
2417
+ var bridge = null;
2054
2418
  var daemonState = null;
2055
2419
  function wechatExtension(pi) {
2056
2420
  if (pi.pi && typeof pi.setLabel === "function") {
2057
2421
  pi.setLabel("OMP-Wechat Bridge");
2058
2422
  }
2059
2423
  pi.on("session_start", async (_event, ctx) => {
2060
- daemonState = startPollLoop();
2424
+ bridge = new WeChatBridge;
2425
+ daemonState = bridge.start();
2061
2426
  if (daemonState.running) {
2062
2427
  ctx.ui.notify("WeChat bridge started", "info");
2063
2428
  } else {
2064
- pi.logger?.info("WeChat bridge: another instance holds the lock, starting failover watch");
2429
+ logger.debug("WeChat bridge: another instance holds the lock, starting failover watch");
2065
2430
  ctx.setInterval(() => {
2066
2431
  if (daemonState?.running)
2067
2432
  return;
2068
- daemonState = startPollLoop();
2433
+ daemonState = bridge.start();
2069
2434
  if (daemonState.running) {
2070
2435
  ctx.ui.notify("WeChat bridge: took over from failed instance", "info");
2071
2436
  }
@@ -2073,7 +2438,7 @@ function wechatExtension(pi) {
2073
2438
  }
2074
2439
  });
2075
2440
  pi.registerCommand("wechat", {
2076
- description: "WeChat bridge: login, status, pair, allow, revoke, list, stop, install, uninstall",
2441
+ description: "WeChat bridge: login, status, pair, allow, revoke, list, stop, clear, install, uninstall",
2077
2442
  handler: async (args, ctx) => {
2078
2443
  const parts = args.trim().split(/\s+/);
2079
2444
  const sub = parts[0] ?? "";
@@ -2083,18 +2448,18 @@ function wechatExtension(pi) {
2083
2448
  case "status": {
2084
2449
  const running = daemonState?.running ?? false;
2085
2450
  const svc = isServiceInstalled();
2086
- const pool2 = getPoolStatus();
2451
+ const pool = bridge?.getPoolStatus() ?? { count: 0, max: 0, chats: [] };
2087
2452
  const allowed = listAllowed();
2088
2453
  const lines = [
2089
2454
  `Poll loop: ${running ? "running" : "stopped"}`,
2090
2455
  `Boot service: ${svc ? "installed" : "not installed"}`,
2091
2456
  `Last error: ${daemonState?.lastError ?? "none"}`,
2092
- `Session pool: ${pool2.count}/${pool2.max}`,
2457
+ `Session pool: ${pool.count}/${pool.max}`,
2093
2458
  `Authorized users: ${allowed.length}`
2094
2459
  ];
2095
- if (pool2.chats.length > 0) {
2460
+ if (pool.chats.length > 0) {
2096
2461
  lines.push("Active chats:");
2097
- for (const chat of pool2.chats) {
2462
+ for (const chat of pool.chats) {
2098
2463
  const ago = Math.round((Date.now() - chat.lastActive) / 1000);
2099
2464
  lines.push(` ${chat.chatId} (${ago}s ago)`);
2100
2465
  }
@@ -2146,7 +2511,10 @@ function wechatExtension(pi) {
2146
2511
  break;
2147
2512
  }
2148
2513
  case "stop": {
2149
- await stopPollLoop();
2514
+ if (bridge) {
2515
+ await bridge.stop();
2516
+ bridge = null;
2517
+ }
2150
2518
  daemonState = null;
2151
2519
  ctx.ui.notify("WeChat bridge stopped", "info");
2152
2520
  break;
@@ -2155,7 +2523,7 @@ function wechatExtension(pi) {
2155
2523
  try {
2156
2524
  const r = installService();
2157
2525
  ctx.ui.notify(`Boot service installed (${r.platform}): ${r.path}`, "info");
2158
- pi.logger?.info(`Service installed on ${r.platform} at ${r.path}
2526
+ logger.info(`Service installed on ${r.platform} at ${r.path}
2159
2527
  ` + `OMP will run via launchd/systemd at boot. ` + `Manage: ${r.platform === "darwin" ? "launchctl start|stop com.omp-wechat" : "sudo systemctl start|stop omp-wechat"}`);
2160
2528
  } catch (err) {
2161
2529
  ctx.ui.notify(`Install failed: ${err}`, "error");
@@ -2171,8 +2539,18 @@ function wechatExtension(pi) {
2171
2539
  }
2172
2540
  break;
2173
2541
  }
2542
+ case "clear": {
2543
+ if (bridge) {
2544
+ await bridge.stop();
2545
+ bridge = null;
2546
+ }
2547
+ daemonState = null;
2548
+ const removed = clearAllSessions();
2549
+ ctx.ui.notify(`Cleared ${removed} session(s)`, "info");
2550
+ break;
2551
+ }
2174
2552
  default:
2175
- ctx.ui.notify("Unknown subcommand. Available: login, status, pair, allow, revoke, list, stop, install, uninstall", "warn");
2553
+ ctx.ui.notify("Unknown subcommand. Available: login, status, pair, allow, revoke, list, stop, clear, install, uninstall", "warn");
2176
2554
  }
2177
2555
  }
2178
2556
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-wechat",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",