@agent-wechat/agent-wechat 1.0.0-beta.5 → 1.0.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,7 @@ Configure plugin defaults under `plugins.entries.agent-wechat.config` (or the sa
16
16
  "endpoint": "http://127.0.0.1:6174",
17
17
  "token": { "source": "env", "provider": "default", "id": "AGENT_WECHAT_TOKEN" },
18
18
  "consumerStateDir": "/private/openclaw-native-consumer",
19
- "readPolicy": "on_model_dispatch"
19
+ "readPolicy": "on_inbound"
20
20
  }
21
21
  }
22
22
  ```
@@ -50,15 +50,90 @@ Keep `dataDir` and `consumerStateDir` on separate explicit persistent mounts. Th
50
50
 
51
51
  The adapter consumes account-scoped SSE using bearer headers and decimal-string journal cursors. It persists custody before acknowledging a cursor; acknowledgement is independent of unread state. Only explicitly live, incoming, committed resources can enter the model. Supported images, short voice, video, files and eligible stickers pass through bounded media retrieval and hash verification into OpenClaw-managed storage. Pending media retains custody without model dispatch or a read receipt. Expired/unsupported attachments retain an explicit status; raw XML never becomes an attachment. Catch-up/import/raw pending sync batches, unknown origin and outgoing messages do not become automatic model inputs. Group admission uses structured exact self-mention facts; unknown metadata or displayed `@names` never satisfy a mention gate. Contacts and groups use their separate catalogs.
52
52
 
53
- The prepared-run callback records the selected model input before marking its whole thread read. This also clears earlier unread messages in that thread, including messages the model did not receive. The daemon freezes the latest local message boundary, checks the expected version, updates local unread state and submits one native cross-device notification. A lost notification response remains `needs_review` and is never automatically resubmitted. `readPolicy: "never"` disables both local and cross-device marking. Crashes during an unconfirmed dispatch retain `needs_review`; the plugin does not automatically duplicate model turns or uncertain outgoing sends.
53
+ Every ready attachment supplies its original OpenClaw-managed path in the message body and `Attachments` metadata, including images. File references wrap paths in angle brackets, such as `[Image file: <path>]` or `[Local file: <path>]`, so OpenClaw does not automatically load an original or a file with an image extension when scanning prompt text. A separately prepared image preview may occupy `MediaPath` while the original file remains accessible by its own path. Files, voice and video retain explicit local references; an available path does not claim that the model analyzed the file.
54
54
 
55
- OpenClaw **2026.10.2** lacks `onPreparedAgentRunStart`, so its released public hooks provide a bounded compatibility path. Before dispatch, the adapter persists an immutable manifest binding the exact new message ID/version/content hash, policy hash, source and supplied asset hashes, and context roles. Text requires the exact current input and host-created message/account/session facts plus a matching `model_call_started`. Media additionally waits for the same run's `agent_end` snapshot and source-turn identity. Image bytes/MIME/length must match the frozen supplied representation; file/video/voice references must appear in the exact current body. Image count, model selection and reply delivery alone cannot mark a message read. Unknown, omitted, rewritten, merged or ambiguous inputs remain unconfirmed.
55
+ In groups requiring a mention, sender-admitted unmentioned messages are retained as context without retrieval, transcription, a model turn or a read notification. The next admitted non-command message includes that context after checking the current sender policy again. `messages.groupChat.historyLimit` controls the per-group window (default 50, maximum 200; zero disables it). The consumer store retains at most 1,000 buffered group messages overall. Context survives a Gateway restart. At dispatch, the selected context is consumed atomically with the durable turn record, so an uncertain turn is not offered again automatically. Original mention facts remain unchanged; history is recorded separately from the triggering message.
56
56
 
57
- The compatibility path requires the operator's explicit host permission, `plugins.entries.agent-wechat.hooks.allowConversationAccess: true`. It observes only run IDs issued by this adapter and never enables that permission itself. Without the grant, or on an unsupported host, messages remain unread and status reports `handoffUnconfirmed`. The older `onAgentRunStart` also reports direct tools and early lifecycle phases and is never used as proof. `readPolicy: "never"` preserves the handoff receipt while disabling read projection. Late confirmed receipts can mark their thread independently of new SSE traffic. Access and mention gates still precede model dispatch; a rejected message does not itself trigger a read.
57
+ Group catch-up retrieves media with at most three workers, keeps every verified original path and selects only the latest ready image's prepared representation for vision. Pending or unavailable historical attachments remain explicit statuses and do not block the current mention. The triggering attachment, when still pending, continues to hold its own dispatch. Voice transcription runs only after admission and activation. Commands do not drain buffered context.
58
58
 
59
- Images exceeding the host's bounded representation, or formats requiring conversion, receive a separately stored JPEG still preview using the public SDK image processor. The original stays intact; both hashes are recorded and the model input labels the preview. Prepared images fit within 5 MiB and at most 1200 pixels per side (or a smaller configured limit). Files and videos can be supplied as available local references; this does not claim their contents were analyzed. Voice retains its verified file and includes configured transcription output or an explicit unavailable status. Pending, expired, unsupported and failed-integrity media never gains a read receipt from a placeholder.
59
+ By default, `readPolicy: "on_inbound"` marks a whole thread read after its incoming message has been durably stored, passed sender/group/mention checks and completed normal OpenClaw inbound handling. Built-in commands also qualify when no model is called. This policy does not inspect model inputs or require `hooks.allowConversationAccess`. Ignored messages, pending media and failed handling do not initiate a read. Read means handled by the integration; it does not claim that a model analyzed an attachment.
60
60
 
61
- Run `node scripts/native-model-smoke.mjs` and `node scripts/native-media-model-smoke.mjs` after installing workspace dependencies. They use the actual pinned Gateway, a local model and synthetic daemon/files. Checks cover current provider input, image hashes and prepared previews, reference/transcription delivery, whole-thread read requests, lost-response/reopen recovery, duplicate prevention, omitted images on a text-only model, permission, never-read policy and group routing behavior. Transcription uses the real SDK with an explicitly configured local deterministic CLI; it does not test speech recognition accuracy. Concurrent text inputs were serialized into distinct model turns; merged-input refusal has unit coverage. These checks establish the prepared runtime boundary, not external provider acceptance, semantic analysis or consumption.
61
+ A whole-thread read also clears earlier unread messages in that thread, including messages the model did not receive. The daemon freezes the latest local message boundary, checks the expected version, updates local unread state and submits one native read-status notification. Server acceptance alone does not prove that a phone badge changed. A lost notification response remains `needs_review` and is never automatically resubmitted. Pending reads survive a Gateway restart and retry independently of model execution or new SSE traffic. `readPolicy: "never"` disables both local and remote marking, including previously queued reads. Crashes during an unfinished dispatch retain `needs_review`; the plugin does not automatically duplicate model turns or uncertain outgoing sends.
62
+
63
+ The optional `readPolicy: "on_model_dispatch"` instead requires a confirmed model handoff. OpenClaw **2026.10.2** lacks `onPreparedAgentRunStart`, so its released public hooks provide a bounded compatibility path. Before dispatch, the adapter persists an immutable manifest binding the exact new message ID/version/content hash, policy hash, source and supplied asset hashes, and context roles. Text requires the exact current input and host-created message/account/session facts plus a matching `model_call_started`. Media additionally waits for the same run's `agent_end` snapshot and source-turn identity. Image bytes/MIME/length must match the frozen supplied representation; file/video/voice references must appear in the exact current body. Image count, model selection and reply delivery alone cannot confirm a model handoff. Unknown, omitted, rewritten, merged or ambiguous inputs remain unconfirmed. Host/provider history formats can prevent confirmation even when a reply was delivered; use `on_inbound` for ordinary bot read behavior.
64
+
65
+ The model-handoff compatibility path requires the operator's explicit host permission, `plugins.entries.agent-wechat.hooks.allowConversationAccess: true`. It observes only run IDs issued by this adapter and never enables that permission itself. Without the grant, or on an unsupported host, the explicit `on_model_dispatch` policy leaves messages unread and status reports `handoffUnconfirmed`. The older `onAgentRunStart` also reports direct tools and early lifecycle phases and is never used as proof. Access and mention gates still precede model dispatch; a rejected message does not itself trigger a read.
66
+
67
+ Images exceeding the host's bounded representation, or formats requiring conversion, receive a separately stored JPEG still preview using the public SDK image processor. The original stays intact; both hashes are recorded and the model input labels the preview. Prepared images fit within 5 MiB and at most 1200 pixels per side (or a smaller configured limit). Files and videos can be supplied as available local references; this does not claim their contents were analyzed. Voice retains its verified file and includes configured transcription output or an explicit unavailable status. A placeholder does not confirm a model handoff under `on_model_dispatch`; default `on_inbound` can mark a thread after successfully handling an explicit expired/unsupported status.
68
+
69
+ ## Stored-history tools
70
+
71
+ The agent can look beyond the recent inbound buffer using the selected native account's local profile:
72
+
73
+ | Tool | Inputs (`?` means optional) | Output |
74
+ | --- | --- | --- |
75
+ | `wechat_history` | `{ limit?, cursor? }` | Stored messages, newest first, with `nextCursor`, `sourceGeneration` and `coverage` |
76
+ | `wechat_search` | `{ query, limit?, cursor? }` | Same page shape, filtered by case-insensitive substring |
77
+ | `wechat_message` | `{ messageId }` | One stored message |
78
+ | `wechat_media` | `{ messageId, download?, quality? }` | Media status, optional verified file path and image content |
79
+
80
+ A stored message includes `id`, `threadId`, nullable `serverId`, `senderId`, `direction` and `createdAt`, numeric `type`, bounded `body`, and `bodyTruncated`. Its optional `attachment` descriptor contains `kind`, `messageId` and `retrievalTool: "wechat_media"`. Message IDs and server IDs remain strings; use the opaque `id` for exact lookup and media retrieval.
81
+
82
+ A media result includes `messageId`, `threadId`, `status` and `retryable`, with optional `attachment`, actual `variant`, `jobId`, `reason` and display `body`. A ready attachment has `kind`, `filename`, verified local `path` and `mime`; image quality may be `full`, `standard` or `thumbnail`. Unavailable results never claim a local path. OpenClaw wraps structured outputs in `details` and text `content`; a ready image additionally supplies an image content block.
83
+
84
+ For example, the agent can call `wechat_search` with `{"query":"project notes"}` in the current conversation. These four default tools are restricted to the current WeChat account and thread derived from trusted OpenClaw context. Owners and agents with archive access have the same restriction when invoking a default tool. Default tools accept no thread/account selector, and the plugin rejects messages or assets from another conversation or account. Requester and historical sender policy are rechecked. Missing or conflicting trusted conversation context hides the default tools; webchat and other channels cannot use them.
85
+
86
+ History/search return opaque string message IDs, a bounded text preview, coverage metadata and `nextCursor`. Pass the cursor back with the same tool, query and thread to continue; the page limit is 1–50 (default 20). Search queries are limited to 256 UTF-8 bytes. Long message bodies are labeled as truncated. A sender-filtered page may be empty and still have a continuation. Search covers locally stored history and does not fetch missing remote history or maintain an official FTS index. Media messages receive a type/status description; these tools do not return raw transport XML or download attachments. History, search and exact-message lookup are read-only. Their attachment descriptions name `wechat_media` and the exact message ID without automatically retrieving media. None of these tools mark a conversation read, send messages or modify the inbound consumer cursor. Lookup requires the selected account's API `read` scope and remains subject to normal OpenClaw tool policy. With OpenClaw's compact tool catalog, the agent discovers history/search/message lookup through `tool_search` and invokes the returned tool with `tool_call`. The media tool stays directly callable as `wechat_media`: the pinned host's generic wrapper serializes nested image blocks as JSON text, while direct invocation preserves image content for vision.
87
+
88
+ `wechat_media` accepts `{"messageId":"<returned opaque ID>"}`. It verifies message/thread/sender admission before accessing assets, uses cached bytes first, checks identity, hash and size, and saves verified bytes to OpenClaw's managed media store. Files, videos and voice return local paths; images also return a bounded prepared preview as image content for vision. This retrieval does not itself transcribe voice or analyze files/videos. Cached bytes remain usable after CDN expiry.
89
+
90
+ Missing media requests a durable daemon transfer by default, with a two-second bounded wait. A pending result includes its `jobId` when known and no claimed local path; call the same tool with the same message ID later to check. The daemon owns and deduplicates the transfer, so cancelling a tool invocation does not cancel submitted work. Use `download: false` for cached media only. `quality` defaults to `best` (best cached image, normal video or decoded voice); explicit `original`, `standard`, `thumbnail`, `normal` and `encoded` variants are strict and may be unavailable. Results report the actual variant and explicit pending, expired, unsupported, unavailable or too-large status. Never automatically retry `retryable: false` or confirmed expiry. The media tool requires account-scoped API `read` and `media` permission, including for download requests. Raw XML, signed transport URLs and keys are never returned.
91
+
92
+ `node scripts/native-history-model-smoke.mjs` exercises the pinned Gateway with local account/media/model fixtures: durable group context, original file paths, one preview with a verified hash, a PNG sent as a file remaining a reference, direct image delivery, actual tool discovery, two paginated search calls, rejection of another thread's attachment before media access, one requested image download whose exact image bytes reach vision, and a cached file path reaching the provider. CI runs this check on both Linux architectures.
93
+
94
+ When asset metadata identifies an existing download job, retrieval polls that scoped job instead of submitting another transfer. A terminal job's reason and retryability are preserved, including in cache-only mode; this tool does not automatically retry failed jobs. A service rejection alone is not classified as expiry.
95
+
96
+ ### Privileged archive tools
97
+
98
+ Five optional tools read across every thread in the configured native WeChat account:
99
+
100
+ | Tool | Inputs (`?` means optional) | Output |
101
+ | --- | --- | --- |
102
+ | `wechat_archive_threads` | `{ query?, limit?, cursor? }` | Thread page: `id`, `peerId`, `kind`, `displayName`, `unreadCount`, plus pagination and coverage |
103
+ | `wechat_archive_search` | `{ query, threadId?, limit?, cursor? }` | Message page across all threads; optional `threadId` narrows it |
104
+ | `wechat_archive_history` | `{ threadId, limit?, cursor? }` | Message page for any selected thread, newest first |
105
+ | `wechat_archive_message` | `{ messageId }` | One message from any thread |
106
+ | `wechat_archive_media` | `{ messageId, download?, quality? }` | Verified attachment status/path and image content from any thread |
107
+
108
+ Message/page/media output shapes, bounds and download behavior match the default tools above. Archive attachment descriptors name `wechat_archive_media` as their retrieval tool. Thread queries match locally stored IDs and display names. Search uses the daemon's account-wide `GET /v1/accounts/{accountId}/messages?query=...` endpoint; its opaque cursor binds the account, query and thread selection and retains membership/order across local edits, deletions and new arrivals. Search reads the local profile with bounded snapshot capacity; it does not backfill remote history. An older daemon without that endpoint must be upgraded for account-wide search.
109
+
110
+ Configure access in OpenClaw's `~/.openclaw/openclaw.json`, merging into your existing named agent entries:
111
+
112
+ ```json
113
+ {
114
+ "agents": {
115
+ "ownership": "explicit",
116
+ "entries": {
117
+ "main": {
118
+ "tools": { "alsoAllow": ["wechat_archive_*"] }
119
+ },
120
+ "group-bot": {
121
+ "tools": { "deny": ["wechat_archive_*"] }
122
+ }
123
+ }
124
+ }
125
+ }
126
+ ```
127
+
128
+ OpenClaw enforces these policies for tool execution and catalog discovery. The `main` name has no implicit privilege. Use individual tool names to grant a subset. If an agent already has `tools.allow`, add the archive names to that list instead of combining `allow` and `alsoAllow`. A global deny still wins over a per-agent allow. Optional tools are absent without a selecting policy, but `profile: "full"`, `*`, `group:plugins`, or an allow for the entire `agent-wechat` plugin also selects them; add an explicit per-agent deny for agents that should retain only current-thread access.
129
+
130
+ There is no plugin permission list, owner exception or archive sender/thread filter. A host-authorized archive invocation can read the configured account from any channel, including a group invocation. Use OpenClaw's sender policies or route group traffic to an agent without archive tools when those runs should have narrower access. Archive tools stay bound to the configured account and verify message/asset identities, hashes and sizes. They never accept arbitrary file paths or another account selector. Normal account/token scope checks and invocation cancellation fences still apply.
131
+
132
+ History/search/message/thread reads never send messages, mark threads read or change the inbound cursor. Archive media can submit the same bounded, deduplicated download jobs as `wechat_media`; it stays directly callable so actual image blocks reach vision.
133
+
134
+ Run `node scripts/native-archive-tools-smoke.mjs` to check the pinned Gateway with three separately routed agents: no optional-tool grant, a full profile with an explicit deny, and an archive grant. It checks discovery and guessed-name invocation denial, cross-thread search pagination, other-sender history/media, unchanged default-tool scope, verified image bytes in provider input and a cached file path. All messages, account data and model responses in this test are synthetic. CI runs it on Linux x64 and ARM64.
135
+
136
+ Run `node scripts/native-model-smoke.mjs` and `node scripts/native-media-model-smoke.mjs` after installing workspace dependencies. They use the actual pinned Gateway, a local model and synthetic daemon/files. Checks cover default inbound reads without conversation permission, commands without a model call, current provider input, image hashes and prepared previews, reference/transcription delivery, whole-thread read requests, lost-response/reopen recovery, duplicate prevention, omitted images on a text-only model, explicit model-handoff permission, never-read policy and group routing behavior. Transcription uses the real SDK with an explicitly configured local deterministic CLI; it does not test speech recognition accuracy. Concurrent text inputs were serialized into distinct model turns; merged-input refusal has unit coverage for the explicit model policy. These checks establish local runtime behavior, not external provider acceptance, semantic analysis or consumption.
62
137
 
63
138
  Native text/media sends retain daemon intent/job identity and caller idempotency. Text waits for accepted submission; media jobs preserve partial and uncertain outcomes rather than resending. Ordinary audio remains a file; only explicit `audioAsVoice` requests independently encoded voice. Sender/group/mention gates run before retrieval/transcription. Verified voice may use the configured OpenClaw transcription runtime after admission. Native QR login uses a durable daemon operation and authenticated PNG retrieval. Logout commits local inhibition before at most one authenticated native logout, reporting the remote outcome separately; saved-device revocation remains unverified. Explicitly confirmed forgetting removes local auth while retaining history; disconnect retains reusable trust.
64
139
 
package/dist/index.js CHANGED
@@ -322,8 +322,8 @@ var NativeWeChatClient = class {
322
322
  groups(accountId, options) {
323
323
  return this.request(`v1/accounts/${id(accountId)}/groups${listQuery(options)}`);
324
324
  }
325
- thread(accountId, threadId) {
326
- return this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId)}`);
325
+ thread(accountId, threadId2) {
326
+ return this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId2)}`);
327
327
  }
328
328
  contact(accountId, username) {
329
329
  return this.request(`v1/accounts/${id(accountId)}/contacts/${id(username)}`);
@@ -361,12 +361,12 @@ var NativeWeChatClient = class {
361
361
  syncGroups(accountId) {
362
362
  return this.request(`v1/accounts/${id(accountId)}/group-sync`, { method: "POST", body: "{}" });
363
363
  }
364
- async messages(accountId, threadId, options) {
365
- const result = await this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId)}/messages${listQuery(options)}`);
364
+ async messages(accountId, threadId2, options) {
365
+ const result = await this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId2)}/messages${listQuery(options)}`);
366
366
  for (const msg of result.items) {
367
367
  if (isNativeTombstone(msg))
368
368
  continue;
369
- if (typeof msg.id !== "string" || msg.accountId !== accountId || msg.threadId !== threadId || msg.serverId != null && (typeof msg.serverId !== "string" || !/^\d+$/.test(msg.serverId)))
369
+ if (typeof msg.id !== "string" || msg.accountId !== accountId || msg.threadId !== threadId2 || msg.serverId != null && (typeof msg.serverId !== "string" || !/^\d+$/.test(msg.serverId)))
370
370
  throw new Error("Invalid native message identity");
371
371
  }
372
372
  return result;
@@ -377,10 +377,21 @@ var NativeWeChatClient = class {
377
377
  throw new Error("Invalid native message identity");
378
378
  return message;
379
379
  }
380
- sendText(accountId, threadId, text, idempotencyKey) {
380
+ /** Account-wide local search, with the daemon's query-bound opaque cursor. */
381
+ async searchMessages(accountId, options) {
382
+ const result = await this.request(`v1/accounts/${id(accountId)}/messages${listQuery(options)}`);
383
+ for (const message of result.items) {
384
+ if (isNativeTombstone(message))
385
+ continue;
386
+ if (typeof message.id !== "string" || message.accountId !== accountId || typeof message.threadId !== "string" || !message.threadId || message.serverId != null && (typeof message.serverId !== "string" || !/^\d+$/.test(message.serverId)))
387
+ throw new Error("Invalid native archive message identity");
388
+ }
389
+ return result;
390
+ }
391
+ sendText(accountId, threadId2, text, idempotencyKey) {
381
392
  if (!idempotencyKey || /[\r\n]/.test(idempotencyKey))
382
393
  throw new Error("An idempotency key is required");
383
- return this.request(`v1/accounts/${id(accountId)}/sends`, { method: "POST", headers: { "Idempotency-Key": idempotencyKey }, body: JSON.stringify({ threadId, text }) });
394
+ return this.request(`v1/accounts/${id(accountId)}/sends`, { method: "POST", headers: { "Idempotency-Key": idempotencyKey }, body: JSON.stringify({ threadId: threadId2, text }) });
384
395
  }
385
396
  send(accountId, sendId, signal) {
386
397
  return this.request(`v1/accounts/${id(accountId)}/sends/${id(sendId)}`, { signal });
@@ -440,10 +451,10 @@ var NativeWeChatClient = class {
440
451
  }
441
452
  return { bytes: await this.readBounded(response, max), contentType: response.headers.get("content-type") ?? "application/octet-stream", etag: response.headers.get("etag"), contentRange: response.headers.get("content-range"), status: response.status };
442
453
  }
443
- markRead(accountId, threadId, expectedVersion) {
454
+ markRead(accountId, threadId2, expectedVersion) {
444
455
  if (typeof expectedVersion !== "string" || !expectedVersion || expectedVersion.length > 128)
445
456
  throw new Error("Read version is required");
446
- return this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId)}/read-state`, { method: "POST", body: JSON.stringify({ expectedVersion }) });
457
+ return this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId2)}/read-state`, { method: "POST", body: JSON.stringify({ expectedVersion }) });
447
458
  }
448
459
  events(accountId, after = "0", limit = 100) {
449
460
  if (!Number.isInteger(limit) || limit < 1 || limit > 200)
@@ -1024,17 +1035,18 @@ function renderAttachmentBody(originalBody, attachment, exposeNonVisualPath = tr
1024
1035
  if (attachment.kind === "file") {
1025
1036
  const prefix = originalBody.trim() === name ? "" : originalBody;
1026
1037
  return appendReference(prefix, `[File: ${name}]
1027
- [Local file: ${attachment.path}]`);
1038
+ [Local file: <${attachment.path}>]`);
1028
1039
  }
1029
1040
  if (exposeNonVisualPath) {
1030
1041
  if (attachment.kind === "audio") {
1031
- return appendReference(originalBody || "<media:audio>", `[Voice message file: ${attachment.path}]`);
1042
+ return appendReference(originalBody || "<media:audio>", `[Voice message file: <${attachment.path}>]`);
1032
1043
  }
1033
1044
  if (attachment.kind === "video") {
1034
- return appendReference(originalBody || "<media:video>", `[Video file: ${attachment.path}]`);
1045
+ return appendReference(originalBody || "<media:video>", `[Video file: <${attachment.path}>]`);
1035
1046
  }
1036
1047
  }
1037
- const body = originalBody || (attachment.kind === "image" ? "<media:image>" : originalBody);
1048
+ let body = originalBody || (attachment.kind === "image" ? "<media:image>" : originalBody);
1049
+ if (attachment.kind === "image") body = appendReference(body, `[Image file: <${attachment.path}>]`);
1038
1050
  if (attachment.kind === "image" && attachment.quality === "thumbnail") {
1039
1051
  return appendReference(body, "[Image preview only: thumbnail quality]");
1040
1052
  }
@@ -1046,26 +1058,30 @@ function kind(type) {
1046
1058
  return type === 3 || type === 47 ? "image" : type === 34 ? "audio" : type === 43 ? "video" : type === 49 ? "file" : void 0;
1047
1059
  }
1048
1060
  var terminalJobs = /* @__PURE__ */ new Set(["accepted", "completed", "succeeded", "available", "failed", "cancelled", "expired", "needs_review"]);
1049
- async function waitNativeMediaJob(client, accountId, initial, timeoutMs = 65e3) {
1061
+ async function waitNativeMediaJob(client, accountId, initial, timeoutMs = 65e3, signal, assertCurrent) {
1050
1062
  const deadline = Date.now() + timeoutMs;
1051
1063
  let job = initial;
1052
1064
  let delay = 250;
1053
1065
  while (!terminalJobs.has(job.state) && Date.now() < deadline) {
1066
+ signal?.throwIfAborted();
1067
+ assertCurrent?.();
1054
1068
  await new Promise((resolve) => setTimeout(resolve, Math.min(delay, Math.max(0, deadline - Date.now()))));
1055
1069
  const remaining = deadline - Date.now();
1056
1070
  if (remaining <= 0) break;
1057
- job = await client.job(accountId, job.id, AbortSignal.timeout(remaining));
1071
+ signal?.throwIfAborted();
1072
+ assertCurrent?.();
1073
+ job = await client.job(accountId, job.id, signal ? AbortSignal.any([signal, AbortSignal.timeout(remaining)]) : AbortSignal.timeout(remaining));
1058
1074
  delay = Math.min(delay * 2, 1e3);
1059
1075
  }
1060
1076
  return job;
1061
1077
  }
1062
- function select(assets, type) {
1078
+ function select(assets, type, quality = "best") {
1063
1079
  const preferred = type === 34 ? ["normal"] : type === 43 ? ["normal", "original"] : ["original", "standard", "thumbnail", "encoded"];
1064
1080
  const rank = (asset) => {
1065
1081
  const index = preferred.indexOf(asset.actualVariant ?? asset.variant);
1066
1082
  return index < 0 ? preferred.length : index;
1067
1083
  };
1068
- return [...assets].sort((a, b) => rank(a) - rank(b)).find((asset) => asset.state === "available" && (type !== 34 || (asset.actualVariant ?? asset.variant) === "normal"));
1084
+ return [...assets].sort((a, b) => rank(a) - rank(b)).find((asset) => asset.state === "available" && (quality === "best" ? type !== 34 || (asset.actualVariant ?? asset.variant) === "normal" : (asset.actualVariant ?? asset.variant) === quality));
1069
1085
  }
1070
1086
  async function prepareImageRepresentation(bytes, mime, cfg) {
1071
1087
  const configured = cfg?.agents?.defaults?.imageMaxDimensionPx;
@@ -1082,42 +1098,93 @@ async function prepareImageRepresentation(bytes, mime, cfg) {
1082
1098
  async function prepareNativeAttachment(params) {
1083
1099
  const { client, account, message, core } = params;
1084
1100
  const attachmentKind = kind(message.type);
1101
+ const guard = () => {
1102
+ params.signal?.throwIfAborted();
1103
+ params.assertCurrent?.();
1104
+ };
1105
+ guard();
1106
+ const authorize = async () => {
1107
+ guard();
1108
+ await params.beforeMediaAccess?.();
1109
+ guard();
1110
+ };
1085
1111
  if (!attachmentKind) return { body: message.type === 1 ? message.body ?? "" : `[Unsupported WeChat message type ${message.type}]`, pending: false };
1086
1112
  const accountId = account.native.accountId;
1087
1113
  const maxBytes = Math.min(account.mediaMaxMb * 1024 * 1024, 128 * 1024 * 1024);
1088
1114
  const scopedAssets = async () => {
1115
+ await authorize();
1089
1116
  const items = (await client.mediaAssets(accountId, message.id)).items;
1117
+ guard();
1090
1118
  if (items.some((asset2) => asset2.accountId !== accountId || asset2.messageId !== message.id)) throw Error("Native attachment identity does not match its message");
1091
1119
  return items;
1092
1120
  };
1121
+ const quality = params.quality ?? "best";
1122
+ const transferQuality = quality === "best" && (message.type === 34 || message.type === 43) ? "normal" : quality;
1093
1123
  let assets = await scopedAssets();
1094
- let asset = select(assets, message.type);
1095
- if (!asset && !assets.some((value) => ["expired", "unsupported"].includes(value.state))) {
1124
+ let asset = select(assets, message.type, quality);
1125
+ const relevant = () => assets.filter((value) => transferQuality === "best" || value.variant === transferQuality);
1126
+ const transferCandidate = () => {
1127
+ const order = ["original", "normal", "standard", "thumbnail", "encoded"];
1128
+ return [...relevant()].sort((a, b) => order.indexOf(a.variant) - order.indexOf(b.variant))[0];
1129
+ };
1130
+ const initialCandidate = transferCandidate();
1131
+ if (!asset && initialCandidate?.byteLength != null && initialCandidate.byteLength > maxBytes) {
1132
+ const attachment2 = { kind: attachmentKind, filename: basename(initialCandidate.filename ?? "attachment"), status: "too_large" };
1133
+ return { body: renderAttachmentBody("", attachment2), attachment: attachment2, pending: false, retryable: false };
1134
+ }
1135
+ let job;
1136
+ let transferError;
1137
+ if (!asset && initialCandidate?.jobId) {
1138
+ await authorize();
1139
+ job = await client.job(accountId, initialCandidate.jobId, params.signal);
1140
+ guard();
1141
+ if (job.accountId !== accountId || job.kind !== "download" || job.assetId != null && job.assetId !== initialCandidate.id) throw Error("Native download job identity does not match its attachment");
1142
+ assets = await scopedAssets();
1143
+ asset = select(assets, message.type, quality);
1144
+ if (!asset && ["queued", "running"].includes(job.state)) return { body: `[${attachmentKind} pending]`, pending: true, jobId: job.id, retryable: true };
1145
+ }
1146
+ if (!asset && !job && params.download !== false && !["expired", "unsupported"].includes(transferCandidate()?.state ?? "")) {
1096
1147
  try {
1097
- const job = await client.transferMedia(accountId, { messageId: message.id, assetId: null, quality: message.type === 34 || message.type === 43 ? "normal" : "best" });
1098
- const completed = await waitNativeMediaJob(client, accountId, job, params.waitMs ?? 2e4);
1099
- if (["queued", "running"].includes(completed.state)) return { body: `[${attachmentKind} pending]`, pending: true };
1100
- assets = await scopedAssets();
1101
- asset = select(assets, message.type);
1102
- } catch {
1103
- return { body: `[${attachmentKind} pending: retrieval is unavailable]`, pending: true };
1148
+ await authorize();
1149
+ job = await client.transferMedia(accountId, { messageId: message.id, assetId: null, quality: transferQuality });
1150
+ guard();
1151
+ job = await waitNativeMediaJob(client, accountId, job, params.waitMs ?? 2e4, params.signal, guard);
1152
+ guard();
1153
+ } catch (error) {
1154
+ guard();
1155
+ if (!(error instanceof NativeApiError) || error.retryable) return { body: `[${attachmentKind} pending: retrieval is unavailable]`, pending: true, jobId: job?.id, retryable: true };
1156
+ transferError = error;
1104
1157
  }
1158
+ assets = await scopedAssets();
1159
+ asset = select(assets, message.type, quality);
1160
+ if (!asset && job && ["queued", "running"].includes(job.state)) return { body: `[${attachmentKind} pending]`, pending: true, jobId: job.id, retryable: true };
1105
1161
  }
1106
- const filename = basename(asset?.filename ?? `message-${createHash4("sha256").update(message.id).digest("hex").slice(0, 12)}.bin`).replace(/[\r\n\0]/g, "_");
1162
+ const candidate = transferCandidate();
1163
+ const filename = basename(asset?.filename ?? candidate?.filename ?? `message-${createHash4("sha256").update(message.id).digest("hex").slice(0, 12)}.bin`).replace(/[\r\n\0]/g, "_");
1107
1164
  if (!asset) {
1108
- const state = assets.find((value) => value.state === "expired") ? "expired" : assets.find((value) => value.state === "unsupported") ? "unsupported" : "unavailable";
1109
- const attachment2 = { kind: attachmentKind, filename, status: state };
1110
- return { body: renderAttachmentBody("", attachment2), attachment: attachment2, pending: false };
1165
+ const state = candidate?.state === "expired" ? "expired" : candidate?.state === "unsupported" ? "unsupported" : "unavailable";
1166
+ const attachment2 = { kind: attachmentKind, filename, status: state, expiresAt: candidate?.expiresAt ?? void 0 };
1167
+ return {
1168
+ body: renderAttachmentBody("", attachment2),
1169
+ attachment: attachment2,
1170
+ pending: false,
1171
+ jobId: job?.id,
1172
+ retryable: state === "expired" || state === "unsupported" ? false : transferError ? false : job?.retryable ?? params.download === false,
1173
+ reason: transferError?.code ?? (job?.reason && /^[a-z0-9_]{1,80}$/.test(job.reason) ? job.reason : void 0)
1174
+ };
1111
1175
  }
1112
1176
  if (asset.byteLength != null && asset.byteLength > maxBytes) {
1113
1177
  const attachment2 = { kind: attachmentKind, filename, status: "too_large" };
1114
1178
  return { body: renderAttachmentBody("", attachment2), attachment: attachment2, pending: false };
1115
1179
  }
1116
- const content = await client.mediaContent(accountId, asset.id, { maxBytes });
1180
+ await authorize();
1181
+ const content = await client.mediaContent(accountId, asset.id, { maxBytes, signal: params.signal });
1182
+ guard();
1117
1183
  const mime = content.contentType.split(";", 1)[0].trim().toLowerCase();
1118
1184
  if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(mime)) throw Error("Native attachment MIME type is unsupported");
1119
1185
  const hash = createHash4("sha256").update(content.bytes).digest("hex");
1120
1186
  if (!asset.sha256 || hash !== asset.sha256 || asset.byteLength != null && asset.byteLength !== content.bytes.length) throw new Error("Native attachment integrity did not verify");
1187
+ await authorize();
1121
1188
  const saved = await saveManagedAttachment({ data: Buffer.from(content.bytes).toString("base64"), mime, filename, maxBytes, saveMediaBuffer: core.channel.media.saveMediaBuffer });
1122
1189
  const actual = asset.actualVariant ?? asset.variant;
1123
1190
  const attachment = {
@@ -1148,9 +1215,13 @@ async function prepareNativeAttachment(params) {
1148
1215
  [Model image: prepared still preview; original retained separately]`;
1149
1216
  }
1150
1217
  }
1218
+ guard();
1151
1219
  return {
1152
1220
  body,
1153
1221
  attachment,
1222
+ jobId: job?.id,
1223
+ retryable: false,
1224
+ variant: actual,
1154
1225
  suppliedAssets: [{
1155
1226
  id: asset.id,
1156
1227
  sha256: suppliedHash,
@@ -1171,7 +1242,7 @@ async function prepareNativeAttachment(params) {
1171
1242
  pending: false
1172
1243
  };
1173
1244
  }
1174
- async function sendNativeAttachment(client, account, threadId, mediaUrl, audioAsVoice = false, key = randomUUID()) {
1245
+ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioAsVoice = false, key = randomUUID()) {
1175
1246
  const max = Math.min(account.mediaMaxMb * 1024 * 1024, NATIVE_MAX_SOURCE_BYTES);
1176
1247
  let bytes;
1177
1248
  let filename;
@@ -1237,15 +1308,15 @@ async function sendNativeAttachment(client, account, threadId, mediaUrl, audioAs
1237
1308
  if (audioAsVoice && !mime.startsWith("audio/")) throw new Error("Voice sending requires an explicit audio attachment");
1238
1309
  const accountId = account.native.accountId;
1239
1310
  const source = await client.upload(accountId, bytes, filename, mime);
1240
- const input = { threadId, kind: audioAsVoice ? "voice" : mime.startsWith("image/") ? "image" : mime.startsWith("video/") ? "video" : "file", sourceId: source.id, standardSourceId: null, thumbnailSourceId: null, playSeconds: null, durationMs: null };
1311
+ const input = { threadId: threadId2, kind: audioAsVoice ? "voice" : mime.startsWith("image/") ? "image" : mime.startsWith("video/") ? "video" : "file", sourceId: source.id, standardSourceId: null, thumbnailSourceId: null, playSeconds: null, durationMs: null };
1241
1312
  const job = await waitNativeMediaJob(client, accountId, await client.sendMedia(accountId, input, key), audioAsVoice ? 25 * 6e4 : 65e3);
1242
1313
  if (!["accepted", "completed", "succeeded"].includes(job.state)) throw new Error(`Native media job ${job.id} requires inspection (${job.state}); retain completed items and do not automatically resend`);
1243
1314
  return `agent-wechat-native:${accountId}:${job.id}`;
1244
1315
  }
1245
1316
 
1246
1317
  // src/native-send.ts
1247
- async function submitNativeText(client, accountId, threadId, text, key, options = {}) {
1248
- let intent = await client.sendText(accountId, threadId, text, key);
1318
+ async function submitNativeText(client, accountId, threadId2, text, key, options = {}) {
1319
+ let intent = await client.sendText(accountId, threadId2, text, key);
1249
1320
  const deadline = Date.now() + Math.max(1, Math.min(options.timeoutMs ?? 65e3, 65e3));
1250
1321
  let delay = Math.max(1, Math.min(options.initialDelayMs ?? 250, 1e3));
1251
1322
  while (["queued", "submitting"].includes(intent.state) && Date.now() < deadline) {
@@ -1298,6 +1369,8 @@ var NativeConsumerState = class {
1298
1369
  if (parsed.schemaVersion !== 1 || parsed.serverId !== serverId || parsed.accountId !== accountId || !/^\d+$/.test(parsed.cursor) || !parsed.items || typeof parsed.items !== "object") throw new Error("Native consumer identity or schema mismatch; state was preserved");
1299
1370
  for (const [id2, item] of Object.entries(parsed.items)) {
1300
1371
  if (item.message?.id !== id2 || item.message.accountId !== accountId || typeof item.message.threadId !== "string" || !["received", "dispatching", "handed_off", "completed", "ignored", "needs_review"].includes(item.state)) throw new Error("Invalid native custody record; state was preserved");
1372
+ if (item.cursor != null && !/^\d+$/.test(item.cursor) || item.groupContext != null && typeof item.groupContext !== "boolean") throw new Error("Invalid native group context record; state was preserved");
1373
+ if (item.groupContext && (!item.cursor || item.origin !== "live" || item.state !== "ignored" || item.message.direction !== "incoming" || !item.message.threadId.endsWith("@chatroom"))) throw new Error("Invalid native buffered group context; state was preserved");
1301
1374
  if (item.state === "dispatching") item.state = "needs_review";
1302
1375
  }
1303
1376
  this.value = parsed;
@@ -1358,10 +1431,10 @@ var NativeConsumerState = class {
1358
1431
  if (message && (!message.id || message.accountId !== this.value.accountId)) throw new Error("Native journal message crosses account boundary");
1359
1432
  this.mutate(() => {
1360
1433
  if (message && !this.value.items[message.id]) {
1361
- const completed = Object.entries(this.value.items).filter(([, item]) => !item.readPending && item.phoneReadState !== "needs_review" && item.phoneReadState !== "rejected" && ["completed", "ignored"].includes(item.state));
1434
+ const completed = Object.entries(this.value.items).filter(([, item]) => !item.readPending && !item.groupContext && item.phoneReadState !== "needs_review" && item.phoneReadState !== "rejected" && ["completed", "ignored"].includes(item.state));
1362
1435
  for (const [key] of completed.slice(0, Math.max(0, completed.length - 1e3))) delete this.value.items[key];
1363
1436
  if (Object.keys(this.value.items).length >= 5e3) throw new Error("Native custody queue is full; operator review required");
1364
- this.value.items[message.id] = { message, origin, state: "received" };
1437
+ this.value.items[message.id] = { message, origin, state: "received", cursor };
1365
1438
  }
1366
1439
  this.value.cursor = cursor;
1367
1440
  });
@@ -1371,6 +1444,30 @@ var NativeConsumerState = class {
1371
1444
  if (!item) throw new Error("Unknown native custody message");
1372
1445
  this.mutate(() => Object.assign(this.value.items[messageId], update));
1373
1446
  }
1447
+ groupHistory(threadId2, beforeId, limit) {
1448
+ const before = this.value.items[beforeId]?.cursor;
1449
+ if (!before || limit <= 0) return [];
1450
+ return this.items().filter((item) => item.groupContext && item.state === "ignored" && item.origin === "live" && item.message.threadId === threadId2 && item.cursor && BigInt(item.cursor) < BigInt(before)).sort((a, b) => BigInt(a.cursor) < BigInt(b.cursor) ? -1 : BigInt(a.cursor) > BigInt(b.cursor) ? 1 : 0).slice(-limit);
1451
+ }
1452
+ bufferGroupMessage(messageId, limit) {
1453
+ const item = this.value.items[messageId];
1454
+ if (!item || item.state !== "received" || item.origin !== "live" || item.message.direction !== "incoming" || !item.cursor || !item.message.threadId.endsWith("@chatroom") || !Number.isInteger(limit) || limit < 0 || limit > 200) throw new Error("Invalid native group history admission");
1455
+ this.mutate(() => {
1456
+ Object.assign(this.value.items[messageId], { state: "ignored", groupContext: limit > 0 });
1457
+ const history = this.items().filter((value) => value.groupContext).sort((a, b) => BigInt(a.cursor) < BigInt(b.cursor) ? -1 : 1);
1458
+ const thread = history.filter((value) => value.message.threadId === item.message.threadId);
1459
+ for (const value of thread.slice(0, Math.max(0, thread.length - limit))) value.groupContext = false;
1460
+ const retained = history.filter((value) => value.groupContext);
1461
+ for (const value of retained.slice(0, Math.max(0, retained.length - 1e3))) value.groupContext = false;
1462
+ });
1463
+ }
1464
+ beginDispatch(messageId, dispatchManifest, historyIds = []) {
1465
+ if (this.value.items[messageId]?.state !== "received" || historyIds.some((id2) => !this.value.items[id2]?.groupContext || this.value.items[id2].message.threadId !== this.value.items[messageId].message.threadId)) throw new Error("Invalid native context dispatch custody");
1466
+ this.mutate(() => {
1467
+ Object.assign(this.value.items[messageId], { state: "dispatching", dispatchManifest });
1468
+ for (const id2 of historyIds) this.value.items[id2].groupContext = false;
1469
+ });
1470
+ }
1374
1471
  /** Synchronous bounded receipt at the SDK's prepared-run callback boundary. */
1375
1472
  preparedCallback(messageIds, readPolicy, previous) {
1376
1473
  if (!messageIds.length || messageIds.length > 1e3) throw new Error("Invalid native prepared input manifest");
@@ -1411,8 +1508,10 @@ function prune() {
1411
1508
  for (const [id2, item] of pending) if (item.expires < Date.now()) pending.delete(id2);
1412
1509
  }
1413
1510
  function nativeDispatchManifest(params) {
1511
+ const history = params.history ?? [];
1512
+ if (history.length > 200 || history.some((message) => message.accountId !== params.message.accountId || message.threadId !== params.message.threadId || message.id === params.message.id)) throw Error("Invalid native history manifest");
1414
1513
  const assets = Object.freeze((params.assets ?? []).map((asset) => {
1415
- if (typeof asset.id !== "string" || !asset.id || asset.id.length > 2048 || !/^[a-f0-9]{64}$/.test(asset.sha256) || !Number.isSafeInteger(asset.byteLength) || asset.byteLength < 1 || asset.byteLength > 128 * 1024 * 1024 || typeof asset.path !== "string" || !isAbsolute3(asset.path) || asset.path.length > 4096 || typeof asset.mime !== "string" || asset.mime.length > 128 || !/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(asset.mime) || !["image", "audio", "video", "file"].includes(asset.kind) || !["model_image", "file_reference"].includes(asset.role) || asset.role === "model_image" && (asset.kind !== "image" || !asset.mime.startsWith("image/") || !asset.runtimeMedia) || asset.contextRole !== "trigger") throw Error("Invalid native supplied asset manifest");
1514
+ if (typeof asset.id !== "string" || !asset.id || asset.id.length > 2048 || !/^[a-f0-9]{64}$/.test(asset.sha256) || !Number.isSafeInteger(asset.byteLength) || asset.byteLength < 1 || asset.byteLength > 128 * 1024 * 1024 || typeof asset.path !== "string" || !isAbsolute3(asset.path) || asset.path.length > 4096 || typeof asset.mime !== "string" || asset.mime.length > 128 || !/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(asset.mime) || !["image", "audio", "video", "file"].includes(asset.kind) || !["model_image", "file_reference"].includes(asset.role) || asset.role === "model_image" && (asset.kind !== "image" || !asset.mime.startsWith("image/") || !asset.runtimeMedia) || !["trigger", "history"].includes(asset.contextRole) || asset.contextRole === "history" && !history.some((message) => message.id === asset.messageId)) throw Error("Invalid native supplied asset manifest");
1416
1515
  return Object.freeze({ ...asset });
1417
1516
  }));
1418
1517
  const messageSid = `wechat-native:${params.message.accountId}:${params.message.id}`;
@@ -1442,6 +1541,12 @@ function nativeDispatchManifest(params) {
1442
1541
  ])),
1443
1542
  role: "trigger"
1444
1543
  })]),
1544
+ ...history.length ? { historyInputs: Object.freeze(history.map((message) => Object.freeze({
1545
+ id: message.id,
1546
+ version: message.version ?? "",
1547
+ contentSha256: digest(JSON.stringify([message.id, message.version, message.senderId, message.type, message.body, message.mentions, message.serverId])),
1548
+ role: "history"
1549
+ }))) } : {},
1445
1550
  assets,
1446
1551
  policySha256: digest(JSON.stringify(params.policy ?? null)),
1447
1552
  // Exact released 2026.10.2 source-turn identity. Requiring it in the later
@@ -1568,6 +1673,93 @@ function registerNativePreparedBoundary(api) {
1568
1673
  };
1569
1674
  }
1570
1675
 
1676
+ // src/native-history.ts
1677
+ import { buildHistoryContextFromEntries, buildInboundHistoryFromEntries, DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history";
1678
+ function nativeGroupHistoryLimit(cfg) {
1679
+ const limit = cfg?.messages?.groupChat?.historyLimit;
1680
+ return Number.isInteger(limit) && limit >= 0 ? Math.min(limit, 200) : DEFAULT_GROUP_HISTORY_LIMIT;
1681
+ }
1682
+ async function transcribeNativeAttachment(prepared, core, cfg) {
1683
+ const attachment = prepared.attachment;
1684
+ if (attachment?.kind !== "audio" || attachment.status !== "ready" || !attachment.path) return prepared;
1685
+ let transcript = "";
1686
+ try {
1687
+ transcript = (await core.mediaUnderstanding.transcribeAudioFile({ filePath: attachment.path, cfg, mime: attachment.mime })).text?.trim() ?? "";
1688
+ } catch {
1689
+ }
1690
+ return { ...prepared, body: `${prepared.body}
1691
+ ${transcript ? `[Voice transcript: ${transcript.slice(0, 1e4)}]` : "[Voice transcription unavailable]"}` };
1692
+ }
1693
+ async function prepareNativeHistory(messages, prepare) {
1694
+ const result = new Array(messages.length);
1695
+ let next = 0;
1696
+ await Promise.all(Array.from({ length: Math.min(3, messages.length) }, async () => {
1697
+ for (let index = next++; index < messages.length; index = next++) {
1698
+ const message = messages[index];
1699
+ let prepared;
1700
+ try {
1701
+ prepared = await prepare(message);
1702
+ } catch {
1703
+ prepared = { body: `[Attachment unavailable for message ${message.id}: retrieval or integrity verification failed]`, pending: false };
1704
+ }
1705
+ if (message.type === 1 && prepared.body.length > 4096) prepared = { ...prepared, body: `${prepared.body.slice(0, 4096)}
1706
+ [Message truncated; use wechat_history or wechat_search for the stored message]` };
1707
+ result[index] = { message, prepared };
1708
+ }
1709
+ }));
1710
+ return result;
1711
+ }
1712
+ function buildNativeContext(history, message, prepared) {
1713
+ const all = [...history, { message, prepared }];
1714
+ const image = history.length ? [...all].reverse().find((item) => item.prepared.attachment?.kind === "image" && item.prepared.attachment.status === "ready" && item.prepared.mediaPath) : void 0;
1715
+ const selected = history.length ? image : all.at(-1);
1716
+ const assets = all.flatMap((item) => (item.prepared.suppliedAssets ?? []).map((asset) => {
1717
+ const contextRole = item.message.id === message.id ? "trigger" : "history";
1718
+ if (item === selected) return { ...asset, contextRole, messageId: item.message.id };
1719
+ return {
1720
+ ...asset,
1721
+ contextRole,
1722
+ messageId: item.message.id,
1723
+ runtimeMedia: false,
1724
+ role: "file_reference",
1725
+ path: asset.sourcePath ?? asset.path,
1726
+ sha256: asset.sourceSha256 ?? asset.sha256,
1727
+ byteLength: asset.sourceByteLength ?? asset.byteLength,
1728
+ mime: asset.sourceMime ?? asset.mime
1729
+ };
1730
+ }));
1731
+ const entries = history.map((item) => ({
1732
+ sender: item.message.senderId,
1733
+ body: item.prepared.body,
1734
+ timestamp: item.message.createdAt ? Date.parse(item.message.createdAt) : void 0,
1735
+ messageId: item.message.id
1736
+ }));
1737
+ const inputBody = entries.length ? buildHistoryContextFromEntries({
1738
+ entries,
1739
+ currentMessage: prepared.body,
1740
+ excludeLast: false,
1741
+ formatEntry: (entry) => `${entry.sender}: ${entry.body}`
1742
+ }) : prepared.body;
1743
+ const attachments = all.flatMap((item) => {
1744
+ const attachment = item.prepared.attachment;
1745
+ return attachment?.status === "ready" && attachment.path ? [{
1746
+ path: attachment.path,
1747
+ filename: attachment.filename,
1748
+ kind: attachment.kind,
1749
+ messageId: item.message.id,
1750
+ sender: item.message.senderId
1751
+ }] : [];
1752
+ });
1753
+ return {
1754
+ inputBody,
1755
+ attachments,
1756
+ assets,
1757
+ history: buildInboundHistoryFromEntries({ entries, limit: entries.length }),
1758
+ mediaPath: selected?.prepared.mediaPath,
1759
+ mediaMime: selected?.prepared.mediaMime
1760
+ };
1761
+ }
1762
+
1571
1763
  // src/native-monitor.ts
1572
1764
  function nativeWasMentioned(message) {
1573
1765
  return message.mentions?.state === "known" && message.mentions.self === true;
@@ -1575,14 +1767,14 @@ function nativeWasMentioned(message) {
1575
1767
  async function projectNativeRead(client, accountId, state) {
1576
1768
  const pending2 = state.items().filter((value) => value.readPending);
1577
1769
  const threads = [...new Set(pending2.map((item) => item.message.threadId))];
1578
- for (const threadId of threads) {
1770
+ for (const threadId2 of threads) {
1579
1771
  for (let attempt = 0; attempt < 2; attempt++) {
1580
1772
  let cursor;
1581
1773
  let thread;
1582
1774
  const seen = /* @__PURE__ */ new Set();
1583
1775
  for (let pageIndex = 0; pageIndex < 100; pageIndex++) {
1584
1776
  const page = await client.threads(accountId, cursor);
1585
- thread = page.items.find((value) => !isNativeTombstone(value) && value.id === threadId);
1777
+ thread = page.items.find((value) => !isNativeTombstone(value) && value.id === threadId2);
1586
1778
  if (thread || !page.nextCursor) break;
1587
1779
  if (seen.has(page.nextCursor)) throw new Error("Native thread pagination did not advance");
1588
1780
  seen.add(page.nextCursor);
@@ -1593,7 +1785,7 @@ async function projectNativeRead(client, accountId, state) {
1593
1785
  const receipt = await client.markRead(accountId, thread.id, thread.readStateVersion);
1594
1786
  const notification = receipt?.phoneNotification?.state;
1595
1787
  const phoneReadState = notification === "accepted" || notification === "rejected" || notification === "needs_review" ? notification : void 0;
1596
- for (const item of pending2.filter((item2) => item2.message.threadId === threadId)) state.update(item.message.id, { readPending: false, phoneReadState });
1788
+ for (const item of pending2.filter((item2) => item2.message.threadId === threadId2)) state.update(item.message.id, { readPending: false, phoneReadState });
1597
1789
  break;
1598
1790
  } catch (error) {
1599
1791
  if (error instanceof NativeApiError && error.status === 409 && attempt === 0) continue;
@@ -1616,8 +1808,20 @@ async function dispatchNativeMessage(params) {
1616
1808
  const policy = resolveWeChatPolicyContext({ account, cfg, chatId: message.threadId });
1617
1809
  const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg, surface: "agent-wechat" });
1618
1810
  const commandBody = normalizeWeChatCommandBody(message.type === 1 ? message.body ?? "" : "", { isGroup, wasMentioned });
1811
+ const hasControlCommand = allowTextCommands && core.channel.commands.isControlCommandMessage(commandBody, cfg);
1812
+ const resolve = core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress;
1813
+ const senderAdmission = (senderId) => resolveWeChatIngress({
1814
+ resolve,
1815
+ cfg,
1816
+ accountId: account.accountId,
1817
+ policy,
1818
+ isGroup,
1819
+ chatId: message.threadId,
1820
+ senderId,
1821
+ readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account.accountId, env: process.env }).catch(() => [])
1822
+ });
1619
1823
  const ingress = await resolveWeChatIngress({
1620
- resolve: core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress,
1824
+ resolve,
1621
1825
  cfg,
1622
1826
  accountId: account.accountId,
1623
1827
  policy,
@@ -1625,11 +1829,15 @@ async function dispatchNativeMessage(params) {
1625
1829
  chatId: message.threadId,
1626
1830
  senderId: message.senderId,
1627
1831
  readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account.accountId, env: process.env }).catch(() => []),
1628
- command: { allowTextCommands, hasControlCommand: allowTextCommands && core.channel.commands.isControlCommandMessage(commandBody, cfg) },
1832
+ command: { allowTextCommands, hasControlCommand },
1629
1833
  mention: { wasMentioned, allowTextCommands }
1630
1834
  });
1631
1835
  const unauthorizedDirectCommand = !isGroup && ingress.ingress.reasonCode === "control_command_unauthorized";
1632
1836
  if (ingress.ingress.admission !== "dispatch" && !unauthorizedDirectCommand) {
1837
+ if (isGroup && policy.requireMention && !wasMentioned && !hasControlCommand && (await senderAdmission(message.senderId)).ingress.admission === "dispatch") {
1838
+ state.bufferGroupMessage(message.id, nativeGroupHistoryLimit(cfg));
1839
+ return "ignored";
1840
+ }
1633
1841
  state.update(message.id, { state: "ignored" });
1634
1842
  return "ignored";
1635
1843
  }
@@ -1641,17 +1849,19 @@ async function dispatchNativeMessage(params) {
1641
1849
  return "needs_review";
1642
1850
  }
1643
1851
  if (preparedMedia.pending) return "pending";
1644
- let inputBody = preparedMedia.body;
1645
- if (preparedMedia.attachment?.kind === "audio" && preparedMedia.attachment.status === "ready" && preparedMedia.attachment.path) {
1646
- try {
1647
- const transcript = await core.mediaUnderstanding.transcribeAudioFile({ filePath: preparedMedia.attachment.path, cfg, mime: preparedMedia.attachment.mime });
1648
- if (transcript.text?.trim()) inputBody += `
1649
- [Voice transcript: ${transcript.text.trim().slice(0, 1e4)}]`;
1650
- else inputBody += "\n[Voice transcription unavailable]";
1651
- } catch {
1652
- inputBody += "\n[Voice transcription unavailable]";
1653
- }
1654
- }
1852
+ preparedMedia = await transcribeNativeAttachment(preparedMedia, core, cfg);
1853
+ const buffered = isGroup && !hasControlCommand ? state.groupHistory(message.threadId, message.id, nativeGroupHistoryLimit(cfg)) : [];
1854
+ const admitted = [];
1855
+ for (const item of buffered) {
1856
+ if (item.message.senderId && (await senderAdmission(item.message.senderId)).ingress.admission === "dispatch") admitted.push(item.message);
1857
+ }
1858
+ const history = await prepareNativeHistory(admitted, async (historical) => transcribeNativeAttachment(
1859
+ await (params.prepareAttachment ?? prepareNativeAttachment)({ client, account, message: historical, core, cfg }),
1860
+ core,
1861
+ cfg
1862
+ ));
1863
+ const context = buildNativeContext(history, message, preparedMedia);
1864
+ const inputBody = context.inputBody;
1655
1865
  const route = core.channel.routing.resolveAgentRoute({
1656
1866
  cfg,
1657
1867
  channel: "agent-wechat",
@@ -1681,10 +1891,12 @@ async function dispatchNativeMessage(params) {
1681
1891
  Surface: "agent-wechat",
1682
1892
  MessageSid: `wechat-native:${account.native.accountId}:${message.id}`,
1683
1893
  WasMentioned: isGroup ? wasMentioned : void 0,
1894
+ Attachments: context.attachments,
1895
+ InboundHistory: context.history,
1684
1896
  CommandAuthorized: core.channel.commands.shouldComputeCommandAuthorized(commandBody, cfg) ? ingress.commandAccess.authorized : void 0,
1685
1897
  OriginatingChannel: "agent-wechat",
1686
1898
  OriginatingTo: `wechat:${message.threadId}`,
1687
- ...preparedMedia.mediaPath ? { MediaPath: preparedMedia.mediaPath, MediaUrl: preparedMedia.mediaPath, MediaType: preparedMedia.mediaMime } : {}
1899
+ ...context.mediaPath ? { MediaPath: context.mediaPath, MediaUrl: context.mediaPath, MediaType: context.mediaMime } : {}
1688
1900
  });
1689
1901
  await core.channel.session.recordInboundSession({ storePath, sessionKey: route.sessionKey, ctx });
1690
1902
  const pipeline = (params.createPipeline ?? createChannelMessageReplyPipeline)({ cfg, agentId: route.agentId, channel: "agent-wechat", accountId: account.accountId });
@@ -1696,9 +1908,10 @@ async function dispatchNativeMessage(params) {
1696
1908
  agentId: route.agentId,
1697
1909
  channelAccountId: account.accountId,
1698
1910
  policy,
1699
- assets: preparedMedia.suppliedAssets ?? []
1911
+ assets: context.assets,
1912
+ history: admitted
1700
1913
  });
1701
- state.update(message.id, { state: "dispatching", dispatchManifest });
1914
+ state.beginDispatch(message.id, dispatchManifest, buffered.map((item) => item.message.id));
1702
1915
  const callback = readPolicy !== "on_inbound" && (message.type === 1 || preparedMedia.attachment?.status === "ready" && preparedMedia.suppliedAssets?.length) ? state.preparedCallback([message.id], readPolicy) : void 0;
1703
1916
  if (callback) trackNativePreparedInput(dispatchManifest, callback);
1704
1917
  let failed = false;
@@ -1903,10 +2116,10 @@ async function nativeLoginWait(accountId, timeoutMs = 3e4, stopAtQr = false) {
1903
2116
  }
1904
2117
  return { connected: false, message: "Native login remains pending; wait on the same operation rather than starting a replacement" };
1905
2118
  }
1906
- async function sendNativeText(account, threadId, text, key = randomUUID5()) {
2119
+ async function sendNativeText(account, threadId2, text, key = randomUUID5()) {
1907
2120
  const native = account.native;
1908
2121
  const { client } = await ensureNativeHost(native);
1909
- const send = await submitNativeText(client, native.accountId, threadId, text, key);
2122
+ const send = await submitNativeText(client, native.accountId, threadId2, text, key);
1910
2123
  return `agent-wechat-native:${native.accountId}:${send.id}`;
1911
2124
  }
1912
2125
  function createNativeLoginTool(account) {
@@ -2357,6 +2570,236 @@ function registerNativeFacade(api, config) {
2357
2570
  return handler.stop;
2358
2571
  }
2359
2572
 
2573
+ // src/native-history-tools.ts
2574
+ import { resolveStableChannelMessageIngress as resolveStableChannelMessageIngress2 } from "openclaw/plugin-sdk/channel-ingress-runtime";
2575
+ import { imageResultFromFile } from "openclaw/plugin-sdk/channel-actions";
2576
+ var pageSchema = { limit: { type: "integer", minimum: 1, maximum: 50, default: 20 }, cursor: { type: "string", maxLength: 8192, description: "Opaque nextCursor from the same tool and query; do not invent one." } };
2577
+ var names = ["wechat_history", "wechat_search", "wechat_message", "wechat_media"];
2578
+ var archiveNames = ["wechat_archive_threads", "wechat_archive_search", "wechat_archive_history", "wechat_archive_message", "wechat_archive_media"];
2579
+ var messageIdSchema = { type: "string", minLength: 1, maxLength: 4096 };
2580
+ var qualities = ["best", "original", "standard", "thumbnail", "normal", "encoded"];
2581
+ function object(value) {
2582
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("Expected tool arguments");
2583
+ return value;
2584
+ }
2585
+ function string(value, name, max = 4096) {
2586
+ if (typeof value !== "string" || !value.trim() || value.length > max || /[\u0000-\u001f\u007f]/.test(value)) throw Error(`Invalid ${name}`);
2587
+ return value.trim();
2588
+ }
2589
+ function threadId(value) {
2590
+ return string(string(value, "thread ID").replace(/^(?:agent-)?wechat:(?:group:)?/i, ""), "thread ID");
2591
+ }
2592
+ function messageResult(message, retrievalTool = "wechat_media") {
2593
+ const kind2 = message.type === 3 ? "image" : message.type === 47 ? "sticker" : message.type === 34 ? "voice" : message.type === 43 ? "video" : message.type === 49 ? "file" : "unsupported";
2594
+ const body = message.type === 1 ? message.body ?? "" : `[WeChat ${kind2} message; attachment content has not been retrieved by this tool]`;
2595
+ return {
2596
+ id: message.id,
2597
+ threadId: message.threadId,
2598
+ serverId: message.serverId,
2599
+ senderId: message.senderId,
2600
+ direction: message.direction,
2601
+ type: message.type,
2602
+ createdAt: message.createdAt,
2603
+ body: body.slice(0, 4e3),
2604
+ bodyTruncated: body.length > 4e3,
2605
+ attachment: message.type !== 1 && kind2 !== "unsupported" ? { kind: kind2, retrievalTool, messageId: message.id } : void 0
2606
+ };
2607
+ }
2608
+ function createNativeHistoryTools(context, dependencies = {}) {
2609
+ return createLookupTools(context, dependencies, false);
2610
+ }
2611
+ function createNativeArchiveTools(context, dependencies = {}) {
2612
+ return createLookupTools(context, dependencies, true);
2613
+ }
2614
+ function createLookupTools(context, dependencies, archive) {
2615
+ const currentConfig = () => context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config;
2616
+ if (!currentConfig()) return [];
2617
+ const selected = resolveWeChatAccount(currentConfig());
2618
+ if (!selected?.native || !selected.enabled) return [];
2619
+ if (!archive && context.messageChannel !== "agent-wechat") return [];
2620
+ if (!archive && context.agentAccountId && context.agentAccountId !== selected.accountId) return [];
2621
+ if (!archive && context.deliveryContext?.channel === "agent-wechat" && context.deliveryContext.accountId && context.deliveryContext.accountId !== selected.accountId) return [];
2622
+ let currentThread;
2623
+ let requesterSenderId;
2624
+ const target = context.deliveryContext?.channel === "agent-wechat" ? context.deliveryContext.to : void 0;
2625
+ if (!archive) {
2626
+ try {
2627
+ if (context.nativeChannelId) currentThread = threadId(context.nativeChannelId);
2628
+ if (target) {
2629
+ const deliveryThread = threadId(target);
2630
+ if (currentThread && currentThread !== deliveryThread) return [];
2631
+ currentThread ??= deliveryThread;
2632
+ }
2633
+ requesterSenderId = string(context.requesterSenderId, "requester sender ID");
2634
+ } catch {
2635
+ return [];
2636
+ }
2637
+ if (!currentThread) return [];
2638
+ }
2639
+ const selectedId = selected.native.accountId;
2640
+ const tool = (name, action, description, properties, required = []) => ({
2641
+ name,
2642
+ label: name.replaceAll("_", " "),
2643
+ description,
2644
+ // The pinned host's tool_call wrapper JSON-serializes image blocks. Its
2645
+ // public direct-only surface preserves actual image content for vision.
2646
+ ...action === "media" ? { catalogMode: "direct-only" } : {},
2647
+ parameters: { type: "object", additionalProperties: false, properties, required },
2648
+ execute: async (_callId, input, signal) => {
2649
+ const args = object(input);
2650
+ const guard = () => {
2651
+ signal?.throwIfAborted();
2652
+ context.assertInvocationCurrent?.();
2653
+ const cfg2 = currentConfig();
2654
+ const account2 = resolveWeChatAccount(cfg2);
2655
+ if (!account2?.enabled || !account2.native || account2.native.accountId !== selectedId || account2.accountId !== selected.accountId) throw Error("WeChat account configuration changed; start a new turn");
2656
+ return { cfg: cfg2, account: account2, native: account2.native };
2657
+ };
2658
+ const { cfg, account, native } = guard();
2659
+ if (Object.keys(args).some((key) => !Object.hasOwn(properties, key))) throw Error(archive ? "Unknown archive lookup argument" : "Unknown lookup argument; WeChat history is restricted to the current conversation");
2660
+ const limit = args.limit ?? 20;
2661
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw Error("limit must be 1\u201350");
2662
+ const cursor = args.cursor === void 0 ? void 0 : string(args.cursor, "cursor", 8192);
2663
+ const query = args.query === void 0 ? void 0 : string(args.query, "query", 256);
2664
+ if (query && Buffer.byteLength(query) > 256) throw Error("Search query exceeds 256 UTF-8 bytes");
2665
+ if (action === "search" && !query) throw Error("A non-empty search query is required");
2666
+ const conversation = archive ? args.threadId === void 0 ? void 0 : threadId(args.threadId) : currentThread;
2667
+ if (action === "history" && !conversation) throw Error("A thread ID is required");
2668
+ const messageId = Object.hasOwn(properties, "messageId") ? string(args.messageId, "message ID") : void 0;
2669
+ if (args.download !== void 0 && typeof args.download !== "boolean") throw Error("download must be a boolean");
2670
+ const quality = args.quality ?? "best";
2671
+ if (!qualities.includes(quality)) throw Error("Invalid media quality");
2672
+ const core = dependencies.core ?? getWeChatRuntime();
2673
+ const allowed = async (sender) => {
2674
+ if (archive) return true;
2675
+ const { cfg: cfg2, account: account2 } = guard();
2676
+ const result2 = await resolveWeChatIngress({
2677
+ resolve: core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress2,
2678
+ cfg: cfg2,
2679
+ accountId: account2.accountId,
2680
+ policy: resolveWeChatPolicyContext({ account: account2, cfg: cfg2, chatId: conversation }),
2681
+ isGroup: conversation.endsWith("@chatroom"),
2682
+ chatId: conversation,
2683
+ senderId: sender,
2684
+ readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account2.accountId, env: process.env }).catch(() => [])
2685
+ });
2686
+ return result2.ingress.admission === "dispatch";
2687
+ };
2688
+ if (!await allowed(requesterSenderId)) throw Error("WeChat conversation access is no longer allowed");
2689
+ guard();
2690
+ const { client } = await (dependencies.host ?? ensureNativeHost)(native);
2691
+ guard();
2692
+ const accountId = native.accountId;
2693
+ let result;
2694
+ if (action === "threads") {
2695
+ const page = await client.threads(accountId, { limit, cursor, ...query ? { query } : {} });
2696
+ const items = [];
2697
+ for (const thread of page.items) {
2698
+ if (isNativeTombstone(thread)) continue;
2699
+ if (thread.accountId !== accountId) throw Error("Thread response crosses an account boundary");
2700
+ items.push({ id: thread.id, peerId: thread.peerId, kind: thread.kind, displayName: thread.displayName, unreadCount: thread.unreadCount });
2701
+ }
2702
+ result = { ...page, items, scope: "local_profile", note: "Locally stored threads; coverage may be partial. This lookup does not mark threads read." };
2703
+ } else if (action === "message" || action === "media") {
2704
+ const message = await client.message(accountId, messageId);
2705
+ guard();
2706
+ if (!await allowed(requesterSenderId)) throw Error("WeChat conversation access is no longer allowed");
2707
+ if (message.id !== messageId || message.accountId !== accountId || !archive && message.threadId !== conversation || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("Message is outside the permitted conversation or sender policy");
2708
+ result = messageResult(message, archive ? "wechat_archive_media" : "wechat_media");
2709
+ if (action === "media") {
2710
+ let prepared;
2711
+ try {
2712
+ prepared = await prepareNativeAttachment({
2713
+ client,
2714
+ account,
2715
+ message,
2716
+ core,
2717
+ cfg,
2718
+ quality,
2719
+ download: args.download !== false,
2720
+ waitMs: 2e3,
2721
+ signal,
2722
+ assertCurrent: guard,
2723
+ beforeMediaAccess: async () => {
2724
+ if (!await allowed(requesterSenderId) || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("WeChat conversation access is no longer allowed");
2725
+ }
2726
+ });
2727
+ } catch (error) {
2728
+ guard();
2729
+ if (!(error instanceof NativeApiError)) throw error;
2730
+ const details2 = {
2731
+ messageId: message.id,
2732
+ threadId: message.threadId,
2733
+ status: error.retryable ? "pending" : ["unsupported_profile", "unsupported_media", "unsupported_media_variant"].includes(error.code) ? "unsupported" : "unavailable",
2734
+ retryable: error.retryable,
2735
+ reason: error.code
2736
+ };
2737
+ return { content: [{ type: "text", text: JSON.stringify(details2) }], details: details2 };
2738
+ }
2739
+ guard();
2740
+ if (!await allowed(requesterSenderId) || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("WeChat conversation access is no longer allowed");
2741
+ const details = {
2742
+ messageId: message.id,
2743
+ threadId: message.threadId,
2744
+ status: prepared.pending ? "pending" : prepared.attachment?.status ?? "unsupported",
2745
+ attachment: prepared.attachment,
2746
+ variant: prepared.variant,
2747
+ jobId: prepared.jobId,
2748
+ retryable: prepared.retryable ?? false,
2749
+ reason: prepared.reason,
2750
+ body: prepared.body
2751
+ };
2752
+ const image = prepared.suppliedAssets?.find((asset) => asset.role === "model_image");
2753
+ const output = image ? await imageResultFromFile({ label: "WeChat attachment", path: image.path, extraText: JSON.stringify(details), details }) : { content: [{ type: "text", text: JSON.stringify(details) }], details };
2754
+ guard();
2755
+ return output;
2756
+ }
2757
+ } else {
2758
+ const options = { limit, cursor, ...action === "search" ? { query } : {} };
2759
+ const page = conversation ? await client.messages(accountId, conversation, options) : await client.searchMessages(accountId, options);
2760
+ const items = [];
2761
+ for (const message of page.items) {
2762
+ if (isNativeTombstone(message)) continue;
2763
+ if (conversation && message.threadId !== conversation || message.accountId !== accountId) throw Error("WeChat history response crosses a conversation boundary");
2764
+ if (message.direction === "outgoing" || await allowed(message.senderId ?? void 0)) items.push(messageResult(message, archive ? "wechat_archive_media" : "wechat_media"));
2765
+ }
2766
+ result = {
2767
+ ...page,
2768
+ items,
2769
+ scope: "local_profile",
2770
+ searchMode: action === "search" ? "case_insensitive_substring" : void 0,
2771
+ note: archive ? "Account-wide local history may be partial. These reads do not mark threads read or download attachments." : "Local history may be partial. A filtered page can be empty with a nextCursor. These reads do not mark a conversation read or download attachments."
2772
+ };
2773
+ }
2774
+ guard();
2775
+ return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
2776
+ }
2777
+ });
2778
+ const querySchema = { type: "string", minLength: 1, maxLength: 256 };
2779
+ const mediaSchema = {
2780
+ messageId: messageIdSchema,
2781
+ download: { type: "boolean", default: true, description: "Set false to use cached media only without requesting a WeChat transfer." },
2782
+ quality: { type: "string", enum: qualities, default: "best", description: "best uses the best cached image, normal video or decoded voice. Explicit variants are strict and may be unavailable." }
2783
+ };
2784
+ if (archive) return [
2785
+ tool(archiveNames[0], "threads", "List or find locally stored threads across the configured WeChat account. Optional case-insensitive query matches thread IDs and display names. Includes groups and peers, with opaque pagination; no read marking.", { query: querySchema, ...pageSchema }),
2786
+ tool(archiveNames[1], "search", "Search locally stored messages across every thread in the configured WeChat account, including other senders. Optional threadId narrows the search. Follow nextCursor with the same query and thread selection; no remote backfill or read marking.", { query: querySchema, threadId: messageIdSchema, ...pageSchema }, ["query"]),
2787
+ tool(archiveNames[2], "history", "Read the locally stored history of any thread in the configured WeChat account, newest first, without sender filtering or read marking. Use wechat_archive_threads to find thread IDs.", { threadId: messageIdSchema, ...pageSchema }, ["threadId"]),
2788
+ tool(archiveNames[3], "message", "Look up a stored message in any thread of the configured WeChat account by its exact opaque message ID. Use wechat_archive_media to retrieve its attachment.", { messageId: messageIdSchema }, ["messageId"]),
2789
+ tool(archiveNames[4], "media", "Retrieve an attachment from any thread in the configured WeChat account by its message ID. Returns verified managed file paths and an image preview for vision. Cached bytes first; missing media requests a bounded server-owned download unless download=false. Preserve pending/expired/unavailable results and never automatically retry retryable=false. No sends or read marking.", mediaSchema, ["messageId"])
2790
+ ];
2791
+ return [
2792
+ tool(names[0], "history", "Read the current WeChat conversation's locally stored messages, newest first, beyond the recent inbound buffer. Follow nextCursor for older history; no remote history backfill or read marking.", { ...pageSchema }),
2793
+ tool(names[1], "search", "Search the current WeChat conversation's locally stored message text by case-insensitive substring, beyond buffered messages. Follow nextCursor with the same query in this conversation.", { query: querySchema, ...pageSchema }, ["query"]),
2794
+ tool(names[2], "message", "Look up a stored message by its exact opaque message ID in the current WeChat conversation. Media is described without downloading it; use wechat_media to retrieve its attachment. Long text is bounded.", { messageId: messageIdSchema }, ["messageId"]),
2795
+ tool(names[3], "media", "Retrieve a message attachment from the current WeChat conversation. Returns a verified local file path and an image preview for vision when applicable. Uses cached bytes first; download defaults to true and requests a bounded server-owned transfer if missing. Pending results may be checked again; never automatically retry confirmed expiry or retryable=false. Does not send messages or mark the thread read.", mediaSchema, ["messageId"])
2796
+ ];
2797
+ }
2798
+ function registerNativeHistoryTools(api) {
2799
+ api.registerTool({ contextVersion: 2, create: (context) => createNativeHistoryTools(context) }, { names });
2800
+ api.registerTool({ contextVersion: 2, create: (context) => createNativeArchiveTools(context) }, { names: archiveNames, optional: true });
2801
+ }
2802
+
2360
2803
  // index.ts
2361
2804
  var plugin = {
2362
2805
  id: "agent-wechat",
@@ -2367,6 +2810,7 @@ var plugin = {
2367
2810
  setWeChatRuntime(api.runtime);
2368
2811
  const selected = resolveWeChatAccount(api.config);
2369
2812
  api.registerChannel({ plugin: wechatPlugin });
2813
+ registerNativeHistoryTools(api);
2370
2814
  const stopFacade = selected?.native ? registerNativeFacade(api, selected.native) : () => {
2371
2815
  };
2372
2816
  const stopPreparedBoundary = selected?.native?.readPolicy && selected.native.readPolicy !== "on_inbound" ? registerNativePreparedBoundary(api) : () => {
@@ -1,5 +1,15 @@
1
1
  {
2
2
  "id": "agent-wechat",
3
+ "contracts": {
4
+ "tools": ["wechat_history", "wechat_search", "wechat_message", "wechat_media", "wechat_archive_threads", "wechat_archive_search", "wechat_archive_history", "wechat_archive_message", "wechat_archive_media"]
5
+ },
6
+ "toolMetadata": {
7
+ "wechat_archive_threads": { "optional": true },
8
+ "wechat_archive_search": { "optional": true },
9
+ "wechat_archive_history": { "optional": true },
10
+ "wechat_archive_message": { "optional": true },
11
+ "wechat_archive_media": { "optional": true }
12
+ },
3
13
  "legacyPluginIds": [
4
14
  "wechat"
5
15
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-wechat/agent-wechat",
3
- "version": "1.0.0-beta.5",
3
+ "version": "1.0.0-beta.6",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",