shennian 0.2.76 → 0.2.78

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.
@@ -24,6 +24,27 @@ export function selectDownloadedAttachment(before, after, startedAt, attachment)
24
24
  .sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs)[0] || null
25
25
  }
26
26
 
27
+ export function selectCachedAttachment(candidates, attachment) {
28
+ const expectedName = normalizeReplyText(attachment?.name || '')
29
+ if (expectedName.length < 4) return null
30
+ const expectedExt = path.extname(String(attachment?.name || '')).toLowerCase()
31
+ const expectedHead = expectedName.slice(0, Math.min(expectedName.length, 24))
32
+ const matched = Array.from(candidates.values())
33
+ .filter((file) => isPlausibleDownloadedAttachment(file, attachment))
34
+ .filter((file) => {
35
+ const base = normalizeReplyText(path.basename(file.path))
36
+ if (base === expectedName) return true
37
+ return expectedHead.length >= 8 && base.includes(expectedHead)
38
+ })
39
+ if (!matched.length) return null
40
+ return matched
41
+ .map((file) => ({
42
+ ...file,
43
+ score: (path.extname(file.path).toLowerCase() === expectedExt ? 10 : 0) + file.mtimeMs / 1_000_000_000_000,
44
+ }))
45
+ .sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs)[0] || null
46
+ }
47
+
27
48
  const INTERNAL_EXTENSIONS = new Set([
28
49
  '.db',
29
50
  '.ini',
@@ -151,6 +151,11 @@ export function findTitleConfirmation(observations, targetName) {
151
151
  return rows.find(row => normalizeConversationName(observationText(row)) === target) || null
152
152
  }
153
153
 
154
+ export function isRetryableOcrError(status, body) {
155
+ if ([408, 429, 500, 502, 503, 504].includes(Number(status))) return true
156
+ return /invalid model response|timeout|temporar/i.test(String(body || ''))
157
+ }
158
+
154
159
  export function pointFromObservation(capturePayload, observation, imageSize) {
155
160
  if (!capturePayload?.bounds) throw new Error('capture payload missing bounds')
156
161
  if (!observation?.box) throw new Error('observation missing box')
@@ -286,22 +291,33 @@ async function recognizeScreenshot(capture, options, purpose) {
286
291
  const token = options.token || process.env.WECHAT_RPA_OCR_TOKEN || ''
287
292
  if (!token) throw new Error('Missing OCR bearer token. Pass --token or WECHAT_RPA_OCR_TOKEN.')
288
293
  const imageBase64 = fs.readFileSync(capture.file).toString('base64')
289
- const response = await fetch(options.ocrUrl, {
290
- method: 'POST',
291
- headers: {
292
- 'Content-Type': 'application/json',
293
- Authorization: `Bearer ${token}`,
294
- },
295
- body: JSON.stringify({
296
- imageBase64,
297
- mimeType: 'image/png',
298
- purpose,
299
- conversationName: options.group,
300
- channelId: options.channelId,
301
- }),
302
- signal: AbortSignal.timeout(options.ocrTimeoutMs),
303
- })
304
- if (!response.ok) throw new Error(`OCR request failed: ${response.status} ${await response.text()}`)
294
+ const maxAttempts = Math.max(1, Number(options.ocrRetries || 2) + 1)
295
+ let lastError = null
296
+ let response = null
297
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
298
+ response = await fetch(options.ocrUrl, {
299
+ method: 'POST',
300
+ headers: {
301
+ 'Content-Type': 'application/json',
302
+ Authorization: `Bearer ${token}`,
303
+ },
304
+ body: JSON.stringify({
305
+ imageBase64,
306
+ mimeType: 'image/png',
307
+ purpose,
308
+ conversationName: options.group,
309
+ channelId: options.channelId,
310
+ }),
311
+ signal: AbortSignal.timeout(options.ocrTimeoutMs),
312
+ })
313
+ if (response.ok) break
314
+ const body = await response.text()
315
+ lastError = `OCR request failed: ${response.status} ${body}`
316
+ if (attempt >= maxAttempts || !isRetryableOcrError(response.status, body)) {
317
+ throw new Error(lastError)
318
+ }
319
+ await sleep(400 * attempt)
320
+ }
305
321
  const json = await response.json()
306
322
  const debugPath = capture.file.replace(/\.png$/i, `-${purpose}.ocr.json`)
307
323
  fs.writeFileSync(debugPath, `${JSON.stringify(json, null, 2)}\n`)
@@ -380,6 +396,13 @@ function attachmentTypeFromExt(ext) {
380
396
  return 'file'
381
397
  }
382
398
 
399
+ function postPasteDelayMs(file) {
400
+ const type = classifyOutboundFile(file).type
401
+ if (type === 'image') return 8_000
402
+ if (type === 'video') return 15_000
403
+ return 2_000
404
+ }
405
+
383
406
  const IMAGE_EXTENSIONS = new Set(['.apng', '.avif', '.bmp', '.gif', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.tif', '.tiff', '.webp'])
384
407
  const VIDEO_EXTENSIONS = new Set(['.3g2', '.3gp', '.avi', '.m4v', '.mkv', '.mov', '.mp4', '.mpeg', '.mpg', '.webm', '.wmv'])
385
408
 
@@ -523,6 +546,14 @@ export function missingConfirmedFiles(observations, files) {
523
546
  async function openConversationBySearch(options, artifacts) {
524
547
  const initial = await capture('window', options, 'window-before-search')
525
548
  artifacts.push(initial.file)
549
+ try {
550
+ const initialTitleOcr = await recognizeScreenshot(initial, options, 'title-confirmation')
551
+ if (findTitleConfirmation(initialTitleOcr.observations, options.group)) {
552
+ return initial
553
+ }
554
+ } catch (error) {
555
+ process.stderr.write(`Initial title OCR was skipped: ${error instanceof Error ? error.message : String(error)}\n`)
556
+ }
526
557
 
527
558
  await click(geometryPoint(initial.payload, 'search'), options)
528
559
  await sleep(180)
@@ -583,9 +614,9 @@ export async function runVisualFlow(input) {
583
614
  await click(geometryPoint(opened.payload, 'input'), options)
584
615
  await pasteText(options.replyText, options)
585
616
  await sleep(250)
586
- await click(geometryPoint(opened.payload, 'send'), options)
617
+ await press('{ENTER}', options)
587
618
  await sleep(900)
588
- const confirmCapture = await capture('messages', options, 'after-text-send')
619
+ const confirmCapture = await capture('window', options, 'after-text-send')
589
620
  artifacts.push(confirmCapture.file)
590
621
  const confirmOcr = await recognizeScreenshot(confirmCapture, options, 'send-confirmation')
591
622
  if (!observationsContainText(confirmOcr.observations, options.replyText)) {
@@ -595,25 +626,33 @@ export async function runVisualFlow(input) {
595
626
  }
596
627
 
597
628
  if (!options.dryRun && options.files?.length) {
598
- await click(geometryPoint(opened.payload, 'input'), options)
599
- await pasteFiles(options.files, options)
600
- await sleep(850)
601
- const pendingCapture = await capture('input', options, 'pending-files')
602
- artifacts.push(pendingCapture.file)
603
- await click(geometryPoint(opened.payload, 'send'), options)
604
- await sleep(1200)
605
- const fileConfirmCapture = await capture('messages', options, 'after-file-send')
606
- artifacts.push(fileConfirmCapture.file)
607
- const fileConfirmOcr = await recognizeScreenshot(fileConfirmCapture, options, 'send-confirmation')
608
- const missing = missingConfirmedFiles(fileConfirmOcr.observations, options.files)
609
- if (missing.length > 0) {
610
- throw new Error(`Sent files were not confirmed by OCR: ${missing.map(basenameForAnyPlatform).join(', ')}. See ${fileConfirmCapture.file}`)
629
+ const confirmedFiles = []
630
+ const sentAttachments = []
631
+ const confirmationObservations = []
632
+ for (const [index, file] of options.files.entries()) {
633
+ await click(geometryPoint(opened.payload, 'input'), options)
634
+ await pasteFiles([file], options)
635
+ await sleep(850)
636
+ const pendingCapture = await capture('input', options, `pending-file-${index + 1}`)
637
+ artifacts.push(pendingCapture.file)
638
+ await click(geometryPoint(opened.payload, 'send'), options)
639
+ await sleep(postPasteDelayMs(file))
640
+ const fileConfirmCapture = await capture('window', options, `after-file-${index + 1}-send`)
641
+ artifacts.push(fileConfirmCapture.file)
642
+ const fileConfirmOcr = await recognizeScreenshot(fileConfirmCapture, options, 'send-confirmation')
643
+ const missing = missingConfirmedFiles(fileConfirmOcr.observations, [file])
644
+ if (missing.length > 0) {
645
+ throw new Error(`Sent file was not confirmed by OCR: ${basenameForAnyPlatform(file)}. See ${fileConfirmCapture.file}`)
646
+ }
647
+ confirmedFiles.push(file)
648
+ sentAttachments.push(classifyOutboundFile(file))
649
+ confirmationObservations.push(...summarizeObservations(fileConfirmOcr.observations, options.recentLimit))
611
650
  }
612
651
  sent.push({
613
652
  type: 'files',
614
- files: options.files,
615
- attachments: options.files.map(classifyOutboundFile),
616
- observations: summarizeObservations(fileConfirmOcr.observations, options.recentLimit),
653
+ files: confirmedFiles,
654
+ attachments: sentAttachments,
655
+ observations: confirmationObservations.slice(-options.recentLimit),
617
656
  })
618
657
  }
619
658
 
@@ -31,7 +31,6 @@ function printHelp() {
31
31
  node scripts/wechat-rpa-win.mjs send-text --group <name> --text <message>
32
32
  node scripts/wechat-rpa-win.mjs send-files --group <name> --file <path> [--file <path> ...]
33
33
  node scripts/wechat-rpa-win.mjs listen --group <name> --seconds 60 --poll-ms 1000
34
- node scripts/wechat-rpa-win.mjs prepare-uia
35
34
  node scripts/wechat-rpa-win.mjs capture --region window --output C:\\tmp\\wechat.png
36
35
  node scripts/wechat-rpa-win.mjs click --x 480 --y 470 --no-raise
37
36
  node scripts/wechat-rpa-win.mjs paste-text --text "hello"
@@ -46,8 +45,8 @@ Environment:
46
45
 
47
46
  Diagnostic note:
48
47
  probe does not raise WeChat by default. Use probe --raise only for diagnostics.
49
- restart-wechat-for-uia is intentionally hidden from normal help because it closes
50
- WeChat. It is guarded in the native helper and must never be used by product flows.`)
48
+ prepare-uia and restart-wechat-for-uia are intentionally hidden from normal help.
49
+ They can start accessibility diagnostics or close WeChat, and must never be used by product flows.`)
51
50
  }
52
51
 
53
52
  async function main() {
@@ -64,6 +63,14 @@ async function main() {
64
63
  const allowIndex = argv.indexOf('--allow-non-windows')
65
64
  if (allowIndex >= 0) argv.splice(allowIndex, 1)
66
65
 
66
+ const command = argv[0]
67
+ if (command === 'prepare-uia' && process.env.SHENNIAN_WECHAT_RPA_ALLOW_UIA_DIAGNOSTICS !== '1') {
68
+ throw new Error(
69
+ 'prepare-uia is diagnostic-only because it can start Windows Narrator. ' +
70
+ 'Set SHENNIAN_WECHAT_RPA_ALLOW_UIA_DIAGNOSTICS=1 in a local diagnostic shell to use it.',
71
+ )
72
+ }
73
+
67
74
  const helper = takeOption(argv, '--helper') ?? process.env.WECHAT_RPA_WIN_HELPER ?? defaultHelper
68
75
  if (!fs.existsSync(helper)) {
69
76
  throw new Error(
@@ -36,7 +36,10 @@ export type WeChatRpaNormalizedMessage = {
36
36
  export declare class WeChatRpaDeduper {
37
37
  private seen;
38
38
  private queue;
39
+ constructor(initialMessageIds?: string[]);
39
40
  accept(messageId: string): boolean;
41
+ snapshot(): string[];
42
+ private trim;
40
43
  }
41
44
  export declare function normalizeWeChatRpaMessage(input: WeChatRpaObservedMessage): WeChatRpaNormalizedMessage | null;
42
45
  export declare function weChatRpaConversationId(conversationName: string): string;
@@ -5,17 +5,32 @@ const MAX_DEDUP_KEYS = 500;
5
5
  export class WeChatRpaDeduper {
6
6
  seen = new Set();
7
7
  queue = [];
8
+ constructor(initialMessageIds = []) {
9
+ for (const id of initialMessageIds) {
10
+ if (!id || this.seen.has(id))
11
+ continue;
12
+ this.seen.add(id);
13
+ this.queue.push(id);
14
+ }
15
+ this.trim();
16
+ }
8
17
  accept(messageId) {
9
18
  if (this.seen.has(messageId))
10
19
  return false;
11
20
  this.seen.add(messageId);
12
21
  this.queue.push(messageId);
22
+ this.trim();
23
+ return true;
24
+ }
25
+ snapshot() {
26
+ return [...this.queue];
27
+ }
28
+ trim() {
13
29
  while (this.queue.length > MAX_DEDUP_KEYS) {
14
30
  const old = this.queue.shift();
15
31
  if (old)
16
32
  this.seen.delete(old);
17
33
  }
18
- return true;
19
34
  }
20
35
  }
21
36
  export function normalizeWeChatRpaMessage(input) {
@@ -30,6 +30,7 @@ export class WeChatRpaChannelAdapter {
30
30
  conn.stopped = false;
31
31
  conn.config = config;
32
32
  hydratePendingReplyState(conn, config);
33
+ hydrateMessageState(conn, config);
33
34
  this.seedConfiguredConversations(conn, secret);
34
35
  if (conn.timer)
35
36
  return;
@@ -56,6 +57,7 @@ export class WeChatRpaChannelAdapter {
56
57
  conn.stopped = false;
57
58
  conn.config = config;
58
59
  hydratePendingReplyState(conn, config);
60
+ hydrateMessageState(conn, config);
59
61
  this.seedConfiguredConversations(conn, secret);
60
62
  return this.enqueueOperation(conn, () => this.pollOnce(conn, this.readSecret(conn.config)));
61
63
  }
@@ -69,6 +71,7 @@ export class WeChatRpaChannelAdapter {
69
71
  const conn = this.ensureConnection(config);
70
72
  conn.config = config;
71
73
  hydratePendingReplyState(conn, config);
74
+ hydrateMessageState(conn, config);
72
75
  this.seedConfiguredConversations(conn, secret);
73
76
  const conversationName = this.resolveConversationName(config, secret, reply.conversationId);
74
77
  if (!conversationName)
@@ -201,6 +204,7 @@ export class WeChatRpaChannelAdapter {
201
204
  pendingReplies: new Map(),
202
205
  completedPendingReplyKeys: new Set(),
203
206
  pendingStatePath: undefined,
207
+ messageStatePath: undefined,
204
208
  operation: Promise.resolve(),
205
209
  runtimeState: 'idle_waiting',
206
210
  consecutiveInterruptions: 0,
@@ -248,6 +252,7 @@ export class WeChatRpaChannelAdapter {
248
252
  const message = normalizeWeChatRpaMessage(item);
249
253
  if (!message || !conn.deduper.accept(message.messageId))
250
254
  continue;
255
+ persistMessageState(conn);
251
256
  conn.conversations.set(message.conversationId, message.conversationName);
252
257
  conn.lastMessageAt = message.receivedAt;
253
258
  const event = {
@@ -568,6 +573,38 @@ function pendingReplyStatePath(config) {
568
573
  const id = crypto.createHash('sha256').update(config.id).digest('hex').slice(0, 16);
569
574
  return path.join(config.workDir, '.shennian', 'wechat-rpa-pending-replies', `${id}.json`);
570
575
  }
576
+ function hydrateMessageState(conn, config) {
577
+ const filePath = messageStatePath(config);
578
+ if (conn.messageStatePath === filePath)
579
+ return;
580
+ conn.messageStatePath = filePath;
581
+ const store = readMessageSeenStore(filePath);
582
+ conn.deduper = new WeChatRpaDeduper((store.messageIds ?? []).filter((id) => typeof id === 'string' && id.length > 0));
583
+ }
584
+ function persistMessageState(conn) {
585
+ if (!conn.messageStatePath)
586
+ return;
587
+ try {
588
+ fs.mkdirSync(path.dirname(conn.messageStatePath), { recursive: true });
589
+ fs.writeFileSync(conn.messageStatePath, JSON.stringify({ version: 1, messageIds: conn.deduper.snapshot() }, null, 2));
590
+ }
591
+ catch {
592
+ // Best-effort only; in-memory dedupe still protects the current daemon.
593
+ }
594
+ }
595
+ function readMessageSeenStore(filePath) {
596
+ try {
597
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
598
+ return parsed && parsed.version === 1 ? parsed : { version: 1, messageIds: [] };
599
+ }
600
+ catch {
601
+ return { version: 1, messageIds: [] };
602
+ }
603
+ }
604
+ function messageStatePath(config) {
605
+ const id = crypto.createHash('sha256').update(config.id).digest('hex').slice(0, 16);
606
+ return path.join(config.workDir, '.shennian', 'wechat-rpa-seen-messages', `${id}.json`);
607
+ }
571
608
  function noteInterruption(conn, flow, reason) {
572
609
  conn.lastInterruptedAt = new Date().toISOString();
573
610
  conn.lastError = null;
@@ -54,6 +54,7 @@ export declare function fetchLatestVersion(): Promise<string>;
54
54
  * 'major' — major differs
55
55
  */
56
56
  export declare function compareVersions(current: string, latest: string): 'none' | 'patch' | 'minor' | 'major';
57
+ export declare function copyPackageRuntimeFiles(src: string, dest: string): void;
57
58
  export declare function readUpgradeAttempt(): UpgradeAttempt | null;
58
59
  export declare function writeUpgradeAttempt(attempt: UpgradeAttempt): void;
59
60
  export declare function clearUpgradeAttempt(): void;
@@ -10,6 +10,7 @@ const BACKUP_DIR = resolveShennianPath('backup');
10
10
  const UPGRADE_ATTEMPT_FILE = resolveShennianPath('upgrade-attempt.json');
11
11
  const MAX_CRASH_COUNT = 3;
12
12
  const BACKUP_TTL_DAYS = 7;
13
+ const NPM_REGISTRY_FALLBACK = 'https://registry.npmjs.org';
13
14
  const RETRY_DELAYS_MS = [
14
15
  5 * 60_000,
15
16
  30 * 60_000,
@@ -86,8 +87,9 @@ function getGlobalBinScript() {
86
87
  function backupVersion(version) {
87
88
  const pkgDir = getGlobalPkgDir();
88
89
  const dest = path.join(BACKUP_DIR, version);
90
+ fs.rmSync(dest, { recursive: true, force: true });
89
91
  fs.mkdirSync(dest, { recursive: true });
90
- fs.cpSync(pkgDir, dest, { recursive: true });
92
+ copyPackageRuntimeFiles(pkgDir, dest);
91
93
  }
92
94
  function restoreVersion(version) {
93
95
  const src = path.join(BACKUP_DIR, version);
@@ -96,6 +98,14 @@ function restoreVersion(version) {
96
98
  const pkgDir = getGlobalPkgDir();
97
99
  fs.cpSync(src, pkgDir, { recursive: true, force: true });
98
100
  }
101
+ export function copyPackageRuntimeFiles(src, dest) {
102
+ for (const entry of ['package.json', 'README.md', 'dist']) {
103
+ const from = path.join(src, entry);
104
+ if (!fs.existsSync(from))
105
+ continue;
106
+ fs.cpSync(from, path.join(dest, entry), { recursive: true, force: true });
107
+ }
108
+ }
99
109
  function cleanOldBackups() {
100
110
  try {
101
111
  if (!fs.existsSync(BACKUP_DIR))
@@ -263,7 +273,7 @@ export async function performUpgrade(targetVersion, onProgress, opts = {}) {
263
273
  // Step 3: npm install new version
264
274
  onProgress({ step: 'installing', version: targetVersion });
265
275
  try {
266
- await exec(`npm install -g shennian@${targetVersion}`, { timeout: 120_000, windowsHide: true });
276
+ await installShennianVersion(targetVersion);
267
277
  }
268
278
  catch (err) {
269
279
  // Restore backup and abort
@@ -309,6 +319,24 @@ export async function performUpgrade(targetVersion, onProgress, opts = {}) {
309
319
  onProgress({ step: 'restarting', from: currentVersion, to: targetVersion });
310
320
  return { ok: true, from: currentVersion, to: targetVersion };
311
321
  }
322
+ async function installShennianVersion(targetVersion) {
323
+ try {
324
+ await exec(`npm install -g shennian@${targetVersion}`, { timeout: 120_000, windowsHide: true });
325
+ }
326
+ catch (error) {
327
+ const firstMessage = error instanceof Error ? error.message : String(error);
328
+ try {
329
+ await exec(`npm install -g shennian@${targetVersion} --registry ${NPM_REGISTRY_FALLBACK}`, {
330
+ timeout: 120_000,
331
+ windowsHide: true,
332
+ });
333
+ }
334
+ catch (fallbackError) {
335
+ const secondMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
336
+ throw new Error(`${firstMessage}\n--- npmjs fallback ---\n${secondMessage}`);
337
+ }
338
+ }
339
+ }
312
340
  export async function checkForUpdate(currentVersion) {
313
341
  const current = currentVersion ?? getCurrentVersion();
314
342
  const latest = await fetchLatestVersion();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shennian",
3
- "version": "0.2.76",
3
+ "version": "0.2.78",
4
4
  "description": "Shennian — AI Agent Control Plane CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,16 +33,10 @@
33
33
  "engines": {
34
34
  "node": ">=18"
35
35
  },
36
- "scripts": {
37
- "build": "tsc && node scripts/copy-wechat-rpa-assets.mjs && node -e \"require('node:fs').chmodSync('dist/bin/shennian.js', 0o755)\"",
38
- "build:publish": "node -e \"const fs=require('node:fs'); fs.rmSync('dist', { recursive: true, force: true }); fs.rmSync('.tsbuildinfo.publish', { force: true })\" && tsc -p tsconfig.publish.json && node scripts/copy-wechat-rpa-assets.mjs && node -e \"require('node:fs').chmodSync('dist/bin/shennian.js', 0o755)\"",
39
- "dev": "tsc --watch",
40
- "prepublishOnly": "pnpm build:publish"
41
- },
42
36
  "dependencies": {
43
37
  "@mariozechner/pi-agent-core": "^0.64.0",
44
38
  "@sinclair/typebox": "^0.34.49",
45
- "@shennian/wire": "workspace:*",
39
+ "@shennian/wire": "^0.1.5",
46
40
  "chalk": "^5.4.1",
47
41
  "commander": "^13.1.0",
48
42
  "qrcode-terminal": "^0.12.0",
@@ -54,5 +48,10 @@
54
48
  "@types/ws": "^8.18.1",
55
49
  "tsx": "^4.19.4",
56
50
  "typescript": "^5.9.3"
51
+ },
52
+ "scripts": {
53
+ "build": "tsc && node scripts/copy-wechat-rpa-assets.mjs && node -e \"require('node:fs').chmodSync('dist/bin/shennian.js', 0o755)\"",
54
+ "build:publish": "node -e \"const fs=require('node:fs'); fs.rmSync('dist', { recursive: true, force: true }); fs.rmSync('.tsbuildinfo.publish', { force: true })\" && tsc -p tsconfig.publish.json && node scripts/copy-wechat-rpa-assets.mjs && node -e \"require('node:fs').chmodSync('dist/bin/shennian.js', 0o755)\"",
55
+ "dev": "tsc --watch"
57
56
  }
58
- }
57
+ }