@kin-tio/cli 0.6.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 (66) hide show
  1. package/.env.example +46 -0
  2. package/CHANGELOG.md +95 -0
  3. package/LICENSE +202 -0
  4. package/README.md +150 -0
  5. package/README.zh-CN.md +79 -0
  6. package/THIRD_PARTY_NOTICES +31 -0
  7. package/assets/ilink-login-card.png +0 -0
  8. package/bin/kintio.js +3 -0
  9. package/codex-workspace/.agents/skills/wechat-kf-reply-sop/SKILL.md +58 -0
  10. package/dist/cli.js +3 -0
  11. package/dist/daemon.js +28 -0
  12. package/dist/index.js +70 -0
  13. package/dist/mcp-relay.js +11 -0
  14. package/dist/src/agent/runtime.js +1 -0
  15. package/dist/src/app.js +34 -0
  16. package/dist/src/cli.js +578 -0
  17. package/dist/src/config.js +237 -0
  18. package/dist/src/domain/message.js +23 -0
  19. package/dist/src/domain/send-contract.js +205 -0
  20. package/dist/src/domain/wecom-message.js +281 -0
  21. package/dist/src/ilink/executor.js +306 -0
  22. package/dist/src/ilink/inbound-image.js +310 -0
  23. package/dist/src/ilink/listener.js +306 -0
  24. package/dist/src/ilink/login-manager.js +198 -0
  25. package/dist/src/ilink/login-store.js +197 -0
  26. package/dist/src/ilink/media-gateway.js +83 -0
  27. package/dist/src/ilink/media.js +267 -0
  28. package/dist/src/ilink/message.js +247 -0
  29. package/dist/src/ilink/protocol/client.js +464 -0
  30. package/dist/src/ilink/protocol/types.js +35 -0
  31. package/dist/src/ilink/qr.js +109 -0
  32. package/dist/src/ilink/secret-box.js +143 -0
  33. package/dist/src/ilink/sqlite-store.js +1194 -0
  34. package/dist/src/ilink/store-types.js +63 -0
  35. package/dist/src/lib/image-format.js +23 -0
  36. package/dist/src/lib/path-identity.js +38 -0
  37. package/dist/src/lib/private-directory.js +51 -0
  38. package/dist/src/lib/text.js +19 -0
  39. package/dist/src/lib/wecom-crypto.js +74 -0
  40. package/dist/src/lib/xml.js +8 -0
  41. package/dist/src/mcp/conversation-memory-server.js +179 -0
  42. package/dist/src/mcp/ilink-server.js +158 -0
  43. package/dist/src/mcp/ipc-host.js +275 -0
  44. package/dist/src/mcp/ipc-protocol.js +226 -0
  45. package/dist/src/mcp/stdio-relay.js +122 -0
  46. package/dist/src/mcp/wechat-kf-executor.js +295 -0
  47. package/dist/src/mcp/wechat-kf-server.js +208 -0
  48. package/dist/src/routes/wecom.js +89 -0
  49. package/dist/src/runtime/daemon-protocol.js +202 -0
  50. package/dist/src/runtime/managed-skill.js +49 -0
  51. package/dist/src/runtime/native-daemon.js +325 -0
  52. package/dist/src/runtime/single-instance-lock.js +167 -0
  53. package/dist/src/runtime.js +503 -0
  54. package/dist/src/services/codex-agent.js +542 -0
  55. package/dist/src/services/codex-app-server.js +436 -0
  56. package/dist/src/services/conversation-processor.js +762 -0
  57. package/dist/src/services/image-stager.js +49 -0
  58. package/dist/src/services/media-gateway.js +83 -0
  59. package/dist/src/services/wecom-api.js +311 -0
  60. package/dist/src/services/wecom-sync.js +316 -0
  61. package/dist/src/state/persistence.js +124 -0
  62. package/dist/src/state/sqlite-store.js +3102 -0
  63. package/dist/src/supervisor.js +212 -0
  64. package/dist/src/types.js +1 -0
  65. package/dist/src/version.js +1 -0
  66. package/package.json +72 -0
@@ -0,0 +1,316 @@
1
+ import { normalizeWecomMessage } from '../domain/wecom-message.js';
2
+ import { WecomApiError } from './wecom-api.js';
3
+ const MAX_SYNC_PAGES = 100;
4
+ const CALLBACK_TOKEN_LIFETIME_MS = 9 * 60 * 1_000;
5
+ const RETRY_BASE_MS = 250;
6
+ const RETRY_MAX_MS = 30_000;
7
+ const TOKENLESS_RETRY_MS = 5 * 60_000;
8
+ const PROVIDER_ERROR_RETRY_MS = 5 * 60_000;
9
+ const MAX_CONCURRENT_SYNC_REQUESTS = 4;
10
+ function jitteredRetry(baseMs, key) {
11
+ let hash = 0;
12
+ for (const character of key) {
13
+ hash = ((hash * 31) + (character.codePointAt(0) || 0)) >>> 0;
14
+ }
15
+ return baseMs + Math.floor(baseMs * (hash % 21) / 100);
16
+ }
17
+ export class WecomSync {
18
+ apiClient;
19
+ store;
20
+ processor;
21
+ logger;
22
+ onDeferredReady;
23
+ workers = new Map();
24
+ pending = new Map();
25
+ liveRequested = new Set();
26
+ preemptedConversations = new Map();
27
+ callbackTokens = new Map();
28
+ retries = new Map();
29
+ syncWaiters = [];
30
+ activeSyncRequests = 0;
31
+ accepting = true;
32
+ consuming = false;
33
+ constructor({ apiClient, store, processor, logger = console, startPaused = false, onDeferredReady = () => { }, }) {
34
+ this.apiClient = apiClient;
35
+ this.store = store;
36
+ this.processor = processor;
37
+ this.logger = logger;
38
+ this.onDeferredReady = onDeferredReady;
39
+ this.consuming = !startPaused;
40
+ }
41
+ enqueue({ callbackToken, openKfId, }) {
42
+ if (!this.accepting)
43
+ return false;
44
+ this.store.registerSyncAccountKey(openKfId);
45
+ this.callbackTokens.set(openKfId, {
46
+ value: callbackToken,
47
+ expiresAt: Date.now() + CALLBACK_TOKEN_LIFETIME_MS,
48
+ });
49
+ void this.#requestSync(openKfId, false, 0);
50
+ return true;
51
+ }
52
+ catchUp() {
53
+ if (!this.accepting)
54
+ return Promise.resolve();
55
+ if (this.consuming) {
56
+ throw new Error('startup catch-up requires paused consumption');
57
+ }
58
+ const openKfIds = this.store.listSyncAccountKeys();
59
+ if (openKfIds.length) {
60
+ this.logger.info?.(`[wecom] startup catch-up accounts=${openKfIds.length}`);
61
+ }
62
+ return Promise.all(openKfIds.map((openKfId) => this.#requestSync(openKfId, true, 0))).then(() => undefined);
63
+ }
64
+ #requestSync(openKfId, deferred, attempt) {
65
+ const current = this.pending.get(openKfId);
66
+ this.pending.set(openKfId, {
67
+ live: Boolean(current?.live || !deferred),
68
+ deferred: Boolean(current?.deferred || deferred),
69
+ attempt: current ? Math.min(current.attempt, attempt) : attempt,
70
+ });
71
+ if (!deferred)
72
+ this.liveRequested.add(openKfId);
73
+ this.#cancelRetry(openKfId);
74
+ return this.#startWorker(openKfId);
75
+ }
76
+ #startWorker(openKfId) {
77
+ const current = this.workers.get(openKfId);
78
+ if (current)
79
+ return current;
80
+ const worker = this.#runWorker(openKfId).catch((error) => {
81
+ this.logger.error?.(`[wecom] sync worker failed: ${error instanceof Error ? error.message : String(error)}`);
82
+ }).finally(() => {
83
+ if (this.workers.get(openKfId) !== worker)
84
+ return;
85
+ this.workers.delete(openKfId);
86
+ if (this.accepting
87
+ && this.pending.has(openKfId)
88
+ && !this.retries.has(openKfId))
89
+ void this.#startWorker(openKfId);
90
+ });
91
+ this.workers.set(openKfId, worker);
92
+ return worker;
93
+ }
94
+ async #runWorker(openKfId) {
95
+ while (this.accepting) {
96
+ const request = this.pending.get(openKfId);
97
+ if (!request)
98
+ return;
99
+ this.pending.delete(openKfId);
100
+ if (request.live)
101
+ this.liveRequested.delete(openKfId);
102
+ const deferred = !request.live && request.deferred;
103
+ try {
104
+ const completed = await this.#drain(this.#callbackToken(openKfId), openKfId, deferred);
105
+ if (deferred && completed)
106
+ this.onDeferredReady();
107
+ }
108
+ catch (error) {
109
+ if (!this.accepting)
110
+ return;
111
+ const current = this.pending.get(openKfId);
112
+ this.pending.set(openKfId, {
113
+ live: Boolean(current?.live || request.live),
114
+ deferred: Boolean(current?.deferred || request.deferred),
115
+ attempt: current
116
+ ? Math.min(current.attempt, request.attempt + 1)
117
+ : request.attempt + 1,
118
+ });
119
+ this.#scheduleRetry(openKfId, error);
120
+ return;
121
+ }
122
+ }
123
+ }
124
+ #callbackToken(openKfId) {
125
+ const current = this.callbackTokens.get(openKfId);
126
+ if (!current)
127
+ return '';
128
+ if (current.expiresAt > Date.now())
129
+ return current.value;
130
+ this.callbackTokens.delete(openKfId);
131
+ return '';
132
+ }
133
+ #scheduleRetry(openKfId, error) {
134
+ this.#cancelRetry(openKfId);
135
+ const attempt = this.pending.get(openKfId)?.attempt || 1;
136
+ const hasCallbackToken = Boolean(this.#callbackToken(openKfId));
137
+ const providerRejected = error instanceof WecomApiError
138
+ && error.code !== undefined;
139
+ const retryBase = !hasCallbackToken
140
+ ? TOKENLESS_RETRY_MS
141
+ : providerRejected
142
+ ? PROVIDER_ERROR_RETRY_MS
143
+ : Math.min(RETRY_BASE_MS * (2 ** Math.min(Math.max(0, attempt - 1), 16)), RETRY_MAX_MS);
144
+ const retryMs = retryBase >= PROVIDER_ERROR_RETRY_MS
145
+ ? jitteredRetry(retryBase, `${openKfId}:${attempt}`)
146
+ : retryBase;
147
+ this.logger.error?.(`[wecom] sync failed: ${error instanceof Error ? error.message : String(error)}; retry_ms=${retryMs}`);
148
+ const timer = setTimeout(() => {
149
+ if (this.retries.get(openKfId)?.timer !== timer)
150
+ return;
151
+ this.retries.delete(openKfId);
152
+ if (this.accepting)
153
+ void this.#startWorker(openKfId);
154
+ }, retryMs);
155
+ timer.unref?.();
156
+ this.retries.set(openKfId, { timer });
157
+ }
158
+ #cancelRetry(openKfId) {
159
+ const retry = this.retries.get(openKfId);
160
+ if (!retry)
161
+ return;
162
+ clearTimeout(retry.timer);
163
+ this.retries.delete(openKfId);
164
+ }
165
+ #cancelRetries() {
166
+ for (const { timer } of this.retries.values()) {
167
+ clearTimeout(timer);
168
+ }
169
+ this.retries.clear();
170
+ }
171
+ #acquireSyncSlot() {
172
+ if (!this.accepting)
173
+ return Promise.resolve(false);
174
+ if (this.activeSyncRequests < MAX_CONCURRENT_SYNC_REQUESTS) {
175
+ this.activeSyncRequests += 1;
176
+ return Promise.resolve(true);
177
+ }
178
+ return new Promise((resolve) => this.syncWaiters.push(resolve));
179
+ }
180
+ #releaseSyncSlot() {
181
+ this.activeSyncRequests -= 1;
182
+ while (this.syncWaiters.length) {
183
+ const waiter = this.syncWaiters.shift();
184
+ if (!waiter)
185
+ continue;
186
+ if (!this.accepting) {
187
+ waiter(false);
188
+ continue;
189
+ }
190
+ this.activeSyncRequests += 1;
191
+ waiter(true);
192
+ return;
193
+ }
194
+ }
195
+ #cancelSyncWaiters() {
196
+ for (const waiter of this.syncWaiters.splice(0))
197
+ waiter(false);
198
+ }
199
+ async #drain(callbackToken, openKfId, deferred) {
200
+ let cursor = this.store.getCursor(openKfId);
201
+ const liveConversationPages = [];
202
+ let liveDrainCompleted = false;
203
+ try {
204
+ for (let page = 0; page < MAX_SYNC_PAGES; page += 1) {
205
+ const acquired = await this.#acquireSyncSlot();
206
+ if (!acquired)
207
+ return false;
208
+ let result;
209
+ try {
210
+ if (!this.accepting)
211
+ return false;
212
+ result = await this.apiClient.syncMessages({
213
+ cursor,
214
+ callbackToken,
215
+ openKfId,
216
+ });
217
+ }
218
+ finally {
219
+ this.#releaseSyncSlot();
220
+ }
221
+ if (!this.accepting)
222
+ return false;
223
+ const nextCursor = String(result.next_cursor || cursor);
224
+ if (result.has_more === 1 && nextCursor === cursor) {
225
+ throw new Error('sync_msg returned has_more=1 without a new cursor');
226
+ }
227
+ const messages = result.msg_list.map((raw, index) => normalizeWecomMessage(raw, openKfId, { cursor, index }));
228
+ const ingested = this.store.ingestSyncPage({
229
+ accountKey: openKfId,
230
+ expectedCursor: cursor,
231
+ nextCursor,
232
+ messages,
233
+ deferred,
234
+ });
235
+ cursor = ingested.cursor;
236
+ const interruptedByLive = deferred && this.liveRequested.has(openKfId);
237
+ if (interruptedByLive) {
238
+ const pending = this.preemptedConversations.get(openKfId) || new Set();
239
+ for (const message of messages) {
240
+ pending.add(`wechat_kf\0${message.conversation.accountKey}\0${message.conversation.peerId}`);
241
+ }
242
+ this.preemptedConversations.set(openKfId, pending);
243
+ return false;
244
+ }
245
+ if (!deferred) {
246
+ const liveConversations = new Set(messages.map((message) => `wechat_kf\0${message.conversation.accountKey}\0${message.conversation.peerId}`));
247
+ liveConversationPages.push([...liveConversations]);
248
+ }
249
+ if (result.has_more !== 1) {
250
+ liveDrainCompleted = true;
251
+ return true;
252
+ }
253
+ }
254
+ throw new Error(`sync_msg exceeded ${MAX_SYNC_PAGES} pages`);
255
+ }
256
+ finally {
257
+ if (this.accepting
258
+ && !deferred
259
+ && (liveDrainCompleted || liveConversationPages.some((page) => page.length > 0))) {
260
+ this.#enqueueLiveConversations(openKfId, liveConversationPages);
261
+ }
262
+ }
263
+ }
264
+ #enqueueLiveConversations(openKfId, pages) {
265
+ const latestFirst = new Set();
266
+ for (const page of [...pages].reverse()) {
267
+ for (const conversation of page)
268
+ latestFirst.add(conversation);
269
+ }
270
+ const conversations = latestFirst.size
271
+ ? latestFirst
272
+ : this.preemptedConversations.get(openKfId) || new Set();
273
+ this.preemptedConversations.delete(openKfId);
274
+ if (!this.consuming)
275
+ return;
276
+ const seen = new Set();
277
+ for (const conversation of conversations) {
278
+ const [channel = '', accountKey = '', peerId = ''] = conversation.split('\0');
279
+ if (channel !== 'wechat_kf')
280
+ continue;
281
+ for (const record of this.store.promoteDeferredConversation({
282
+ channel,
283
+ accountKey,
284
+ peerId,
285
+ })) {
286
+ if (seen.has(record.messageKey))
287
+ continue;
288
+ seen.add(record.messageKey);
289
+ void this.processor.enqueue(record.messageKey).catch((error) => {
290
+ this.logger.error?.(`[wecom] live enqueue failed: ${error instanceof Error ? error.message : String(error)}`);
291
+ });
292
+ }
293
+ }
294
+ }
295
+ async waitForIdle() {
296
+ while (this.workers.size) {
297
+ await Promise.allSettled([...this.workers.values()]);
298
+ }
299
+ }
300
+ startConsuming() {
301
+ this.consuming = true;
302
+ }
303
+ stopAccepting() {
304
+ if (!this.accepting)
305
+ return;
306
+ this.accepting = false;
307
+ this.callbackTokens.clear();
308
+ this.pending.clear();
309
+ this.#cancelRetries();
310
+ this.#cancelSyncWaiters();
311
+ }
312
+ async close() {
313
+ this.stopAccepting();
314
+ await this.waitForIdle();
315
+ }
316
+ }
@@ -0,0 +1,124 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { IlinkLoginStore } from '../ilink/login-store.js';
5
+ import { IlinkSqliteStore } from '../ilink/sqlite-store.js';
6
+ import { ensurePrivateDirectory } from '../lib/private-directory.js';
7
+ import { secureSqliteFiles, SqliteStore, } from './sqlite-store.js';
8
+ export class StatePersistenceUnclosedError extends AggregateError {
9
+ constructor(errors, message) {
10
+ super(errors, message);
11
+ this.name = 'StatePersistenceUnclosedError';
12
+ }
13
+ }
14
+ export class StatePersistence {
15
+ #database;
16
+ core;
17
+ #closed = false;
18
+ get closed() {
19
+ return this.#closed;
20
+ }
21
+ constructor(options) {
22
+ if (!options.filePath)
23
+ throw new Error('SQLite filePath is required');
24
+ if (options.journalMode !== undefined &&
25
+ !['WAL', 'DELETE'].includes(options.journalMode)) {
26
+ throw new Error(`Unsupported SQLite journal mode: ${options.journalMode}`);
27
+ }
28
+ const filePath = path.resolve(options.filePath);
29
+ ensurePrivateDirectory(path.dirname(filePath));
30
+ this.#database = new DatabaseSync(filePath);
31
+ try {
32
+ this.core = new SqliteStore({
33
+ filePath,
34
+ ...(options.clock ? { clock: options.clock } : {}),
35
+ ...(options.journalMode ? { journalMode: options.journalMode } : {}),
36
+ }, {
37
+ database: this.#database,
38
+ });
39
+ }
40
+ catch (error) {
41
+ try {
42
+ this.#database.close();
43
+ }
44
+ catch (closeError) {
45
+ throw new StatePersistenceUnclosedError([error, closeError], 'SQLite initialization and cleanup both failed');
46
+ }
47
+ throw error;
48
+ }
49
+ }
50
+ static hasActiveWriter(filePath) {
51
+ try {
52
+ fs.statSync(filePath);
53
+ }
54
+ catch (error) {
55
+ if (error.code === 'ENOENT')
56
+ return false;
57
+ throw error;
58
+ }
59
+ let database;
60
+ try {
61
+ database = new DatabaseSync(filePath);
62
+ database.exec('PRAGMA busy_timeout = 0');
63
+ database.exec('BEGIN IMMEDIATE');
64
+ database.exec('ROLLBACK');
65
+ return false;
66
+ }
67
+ catch (error) {
68
+ if (error instanceof Error &&
69
+ 'code' in error &&
70
+ String(error.code) === 'ERR_SQLITE_ERROR' &&
71
+ /busy|locked/iu.test(error.message)) {
72
+ return true;
73
+ }
74
+ throw error;
75
+ }
76
+ finally {
77
+ database?.close();
78
+ }
79
+ }
80
+ createIlinkStore(options = {}) {
81
+ this.#assertOpen();
82
+ return new IlinkSqliteStore({
83
+ database: this.#database,
84
+ inbox: this.core,
85
+ ...(options.clock ? { clock: options.clock } : {}),
86
+ });
87
+ }
88
+ createIlinkLoginStore(options) {
89
+ this.#assertOpen();
90
+ return new IlinkLoginStore({
91
+ store: this.core,
92
+ database: this.#database,
93
+ secretBox: options.secretBox,
94
+ ...(options.clock ? { clock: options.clock } : {}),
95
+ });
96
+ }
97
+ close() {
98
+ if (this.#closed)
99
+ return;
100
+ let secureError;
101
+ try {
102
+ secureSqliteFiles(this.core.filePath);
103
+ }
104
+ catch (error) {
105
+ secureError = error;
106
+ }
107
+ try {
108
+ this.#database.close();
109
+ }
110
+ catch (closeError) {
111
+ if (secureError !== undefined) {
112
+ throw new StatePersistenceUnclosedError([secureError, closeError], 'SQLite file hardening and close both failed');
113
+ }
114
+ throw closeError;
115
+ }
116
+ this.#closed = true;
117
+ if (secureError !== undefined)
118
+ throw secureError;
119
+ }
120
+ #assertOpen() {
121
+ if (this.#closed)
122
+ throw new Error('State persistence is closed');
123
+ }
124
+ }