@xisui-pa/chatserver 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,662 @@
1
- # Temporary Holding Version
1
+ # ChatServer
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A TypeScript bridge between chat apps and LLM models. It filters incoming messages,
4
+ generates short replies, and retains compact conversation memory. The built-in
5
+ WeChat adapter connects to agent-wechat using outbound REST requests and its
6
+ v2.1 server-sent event stream; ChatServer opens no listening ports.
7
+
8
+ ## Quick start: WeChat
9
+
10
+ Requires Node.js **24.15+**, a Responses API compatible model
11
+ provider, and a running, logged-in [agent-wechat server](https://github.com/thisnick/agent-wechat/tree/v2.1.0)
12
+ **v2.1.0 or newer** with a bearer token that grants the `read`, `send`, and `media`
13
+ scopes for its account. Incoming messages arrive over the daemon's authenticated
14
+ `/v1/accounts/{account}/events/stream` SSE endpoint; REST is used for sends, media,
15
+ and name lookups. WeChat login and agent-wechat setup are managed separately.
16
+
17
+ 1. Install and build from the repository root:
18
+
19
+ ```bash
20
+ npm install
21
+ npm run build
22
+ ```
23
+
24
+ 2. Add a model using guided setup:
25
+
26
+ ```bash
27
+ npm run model:add -- --config_dir ./chatserver-config
28
+ ```
29
+
30
+ Choose or create a provider, enter its API base URL and key, then select or
31
+ enter a model ID. For a local server, a URL might be
32
+ `http://llm-host.local:1234/v1`. The command creates the config directory and
33
+ prints a reference such as `local/your-model-id`.
34
+ If your local server ignores API keys, enter a nonempty placeholder such as `local`.
35
+
36
+ 3. Create `chatserver-config/config.json`:
37
+
38
+ ```json
39
+ {
40
+ "chats": [
41
+ {
42
+ "messager": {
43
+ "name": "wechat",
44
+ "replier": "local/your-model-id",
45
+ "localTools": [],
46
+ "apiTools": [],
47
+ "options": {
48
+ "baseUrl": "http://127.0.0.1:6174",
49
+ "accountId": "your-account-id",
50
+ "chatIds": ["wxid_example"]
51
+ }
52
+ },
53
+ "filters": []
54
+ }
55
+ ]
56
+ }
57
+ ```
58
+
59
+ Replace `replier` with the printed reference and `wxid_example` with a thread ID
60
+ from `GET /v1/accounts/<account-id>/threads`: a contact username such as
61
+ `wxid_example`, or a group ID ending in `@chatroom`. Set `baseUrl` to your
62
+ agent-wechat server root; the adapter appends `/v1` itself, and a base URL that
63
+ already ends in `/v1` is accepted. `GET /v1/accounts` lists the daemon account
64
+ IDs with their `ownUsername` and `authenticated` flags; paste an ID into
65
+ `accountId`, or omit it when exactly one authenticated account is visible to the
66
+ token (more than one is rejected as ambiguous). `localTools: []` disables local
67
+ functions; omit it to enable the built-ins. `apiTools` defaults to `[]`.
68
+
69
+ 4. Set the **agent-wechat bearer token** and start ChatServer.
70
+
71
+ macOS / Linux:
72
+
73
+ ```bash
74
+ export AGENT_WECHAT_TOKEN='your-agent-wechat-token'
75
+ npm start -- --config_dir ./chatserver-config
76
+ ```
77
+
78
+ Windows PowerShell:
79
+
80
+ ```powershell
81
+ $env:AGENT_WECHAT_TOKEN = 'your-agent-wechat-token'
82
+ npm start -- --config_dir ./chatserver-config
83
+ ```
84
+
85
+ 5. Send a **new** text message to the bot in the configured conversation.
86
+ Replies are processed sequentially with a random **1–3 minute** delay, including
87
+ before the first reply. With no filters, the reply model decides whether to
88
+ respond. Stop with Ctrl+C.
89
+
90
+ Create the agent-wechat token with the `read`, `send`, and `media` scopes for the
91
+ configured accounts; sending text needs `send`, lazy images and voice need `media`,
92
+ and the event stream needs `read`. The WeChat bearer token and the model provider's API key
93
+ are separate credentials.
94
+ For LAN model servers on Windows, use the full hostname such as `llm-host.local`
95
+ rather than a bare hostname.
96
+
97
+ ## Configuration
98
+
99
+ Use the same config directory for the server and model commands. Select it with
100
+ `--config_dir <directory>` (`-c`) or `CHATSERVER_CONFIG_DIR`. An explicitly selected
101
+ directory must contain `config.json` before starting the server. Without an
102
+ explicit directory, the server creates a default `{ "chats": [] }` on first run.
103
+
104
+ | Platform | Default directory |
105
+ | --- | --- |
106
+ | macOS | `~/Library/Application Support/chatserver` |
107
+ | Linux | `$XDG_CONFIG_HOME/chatserver` or `~/.config/chatserver` |
108
+ | Windows | `%APPDATA%\chatserver` |
109
+
110
+ The directory contains `config.json`, `models.json`, optional ignore/token files,
111
+ optional `<messager_name>_context` files, and automatically managed `conversations/`
112
+ memory and image-cache files. An empty `chats` array
113
+ runs no adapters.
114
+
115
+ Set `messager.memoryTokenLimit` to change the token budget for each conversation
116
+ file belonging to that pipeline, for example `"memoryTokenLimit": 4000`. The
117
+ default is 1,000; valid limits are 100–16,000 tokens.
118
+ Notes and confirmed replies share this budget, including their compact JSON
119
+ metadata. Memory has a separate input allowance from the message snapshot.
120
+ Restart after changing the limit; lowering it removes the oldest entries on
121
+ the next turn for each conversation. Three-day expiry still applies. JSON
122
+ indentation and the file version field are not counted.
123
+
124
+ Each `chats` entry has a `messager` and optional `filters`:
125
+
126
+ | Field | Meaning |
127
+ | --- | --- |
128
+ | `messager.name` | Adapter name; currently `wechat` |
129
+ | `messager.replier` | `<provider>/<model id>` from `models.json`, using the actual ID, not its CLI alias |
130
+ | `messager.memoryTokenLimit` | Tokens per conversation for notes and confirmed replies; defaults to 1,000 |
131
+ | `messager.persona` | Optional replier identity, tone, and conversational style |
132
+ | `messager.mainAccount` | Optional exact sender ID allowed to query `/status` in a direct chat |
133
+ | `messager.localTools` | Local tool names or `{ "type": "name" }` objects; omitted enables all five built-ins, `[]` disables local tools |
134
+ | `messager.apiTools` | Hosted tool type strings or definition objects; omitted or `[]` enables none |
135
+ | `messager.imageGeneration` | Optional boolean; omitted enables generation when `apiTools` includes `image_generation`; `true` adds that tool, `false` removes it |
136
+ | `messager.imageWidthLimit` | Maximum generated image width in pixels; defaults to `1024` |
137
+ | `messager.imageHeightLimit` | Maximum generated image height in pixels; defaults to `1024` |
138
+ | `messager.dailyImageGenLimit` | Daily image generation allowance shared across this messager's conversations; defaults to `10`; `0` disables it |
139
+ | `messager.options` | Adapter settings below |
140
+ | `filters` | Ordered filters; omitted or `[]` skips filtering |
141
+ | `stt` | Optional object with `type: "model"`, required `model`, and optional `audioConversionCmd`; omitted disables voice input |
142
+ | `tts` | Optional `{ "type": "model", "model": "provider/model-id", "options": { ... } }` for explicitly requested voice replies |
143
+
144
+ The former `messager.tools` field has been replaced by `messager.localTools`;
145
+ rename it in existing configurations. The old field is rejected with a migration
146
+ error so that a previous `tools: []` cannot silently enable the default tools.
147
+
148
+ Local and provider-hosted tools can be used together. For example, this enables
149
+ local arithmetic and time plus the provider's hosted web search:
150
+
151
+ ```json
152
+ {
153
+ "localTools": ["calculate", { "type": "current_time" }],
154
+ "apiTools": [
155
+ { "type": "web_search", "search_context_size": "low" }
156
+ ]
157
+ }
158
+ ```
159
+
160
+ Put these fields inside `messager`. Both arrays accept strings, objects, or a mix.
161
+ For example, `"apiTools": ["web_search"]` is equivalent to
162
+ `"apiTools": [{ "type": "web_search" }]`. Local objects contain only `type`,
163
+ which must name a registered local tool; duplicate names are deduplicated across
164
+ both forms. Hosted strings expand to `{ "type": "..." }`; hosted objects require
165
+ a nonempty `type` and preserve their provider-specific options. The resulting
166
+ objects are sent in the Responses API `tools` array alongside local function
167
+ declarations. Use the selected provider/model's
168
+ documented tool configuration; hosted tools are not discovered automatically.
169
+ The provider validates support and tool-specific options, and unsupported requests
170
+ fail rather than silently falling back to local tools. `function` and `custom`
171
+ definitions are rejected here: register application-executed functions as local
172
+ tools instead. Hosted tools must finish at the provider; text and generated image
173
+ results are supported. Client execution, approval exchanges, and other generated
174
+ artifacts are not implemented. Filters and formatting repairs receive no hosted
175
+ tools. Set both fields to `[]` and omit or disable `imageGeneration` to disable
176
+ all tool calling.
177
+
178
+ Each generation attempt allows up to eight local calls and eight hosted calls
179
+ across its model rounds, under the provider's existing timeout. Hosted requests
180
+ send `max_tool_calls` with the remaining hosted budget; providers must support
181
+ this Responses API parameter to enforce the cap during a response. All output
182
+ items, including hosted calls and reasoning, are replayed when local calls need
183
+ another round. Full-generation retries can repeat hosted calls and their charges;
184
+ completed image artifacts are preserved and reused across these retries.
185
+
186
+ Image generation uses the replier's provider-hosted
187
+ [`image_generation` Responses tool](https://developers.openai.com/api/docs/guides/tools-image-generation).
188
+ The selected provider/model must support this tool; there is no separate image
189
+ provider or fallback. Put these settings inside `messager`:
190
+
191
+ ```json
192
+ {
193
+ "imageGeneration": true,
194
+ "imageWidthLimit": 1024,
195
+ "imageHeightLimit": 1024,
196
+ "dailyImageGenLimit": 10
197
+ }
198
+ ```
199
+
200
+ Alternatively, include `"image_generation"` in `apiTools` and omit
201
+ `imageGeneration` to detect support from that selection. A definition object can
202
+ specify provider options such as `quality` or the hosted image `model`. Explicit
203
+ `imageGeneration: false` overrides that tool selection. The reply model generates
204
+ or edits images only for explicit requests. Model filters recognize these requests
205
+ when generation is enabled, and WeChat sends returned bytes as image messages,
206
+ with an optional caption. Image-only responses are supported.
207
+
208
+ `imageWidthLimit` and `imageHeightLimit` are independent integer pixel limits from
209
+ 1 to 16384, each defaulting to 1024. ChatServer combines them into the provider's
210
+ `WIDTHxHEIGHT` size string; the provider must support the requested size. The tool
211
+ receives this size unless its configured concrete size fits within both limits.
212
+ `auto` and larger sizes are replaced by the combined limits. ChatServer requests PNG
213
+ and rejects returned images exceeding either dimension or the existing 5 MiB
214
+ image byte limit. Image bytes stay outside reply JSON and conversation memory;
215
+ confirmed memory records an image-delivery marker, any caption, and `imageIds`
216
+ linking the reply to its generated cache entries. These IDs count toward the
217
+ memory budget and allow `read_image` lookup after older labels leave `cachedImages`.
218
+
219
+ Generated images are saved in the conversation's existing image store as soon as
220
+ their provider output is validated, before later model rounds, formatting repair,
221
+ or delivery, including when `read_image` is disabled. If later model work fails,
222
+ the artifact remains cached; retries reuse the completed image without another
223
+ image generation call. They share the received-image
224
+ limits: three days from caching, at most 30 images, and 64 MiB of cache JSON per
225
+ conversation. Entries have their own generated-image IDs, assistant attribution,
226
+ `origin: "generated"`, and a `deliveryStatus` of `pending`, `sent`, or `failed`.
227
+ Only `sent` confirms delivery; failed or interrupted sends may remain unconfirmed.
228
+ Updating delivery status never extends retention. Cache write failures are logged
229
+ and do not block sending.
230
+
231
+ When `read_image` is enabled, generated image references appear in `cachedImages`
232
+ and their bytes can be inspected in later turns, including after a restart or
233
+ failed upload. Caching does not resend images, and no resend tool is provided.
234
+
235
+ Quota is kept only in memory for each configured messager pipeline, shared across
236
+ its conversations and generation retries. Usage resets on server restart and at
237
+ midnight in the server's local timezone. No quota files are read or written.
238
+ Before each image-capable provider request, ChatServer
239
+ reserves one slot and sets `max_tool_calls: 1` (also limiting other hosted calls
240
+ in that response). An unused slot is released when a response confirms no image
241
+ call occurred. Ambiguous provider failures retain their reservation; failed
242
+ delivery or rejected image bytes do not refund generation charges. Once an image
243
+ is generated, further local-tool rounds and model retries cannot generate another
244
+ for that reply. When quota is exhausted, image generation is removed from the
245
+ request and text replies remain available. WeChat image delivery stages the PNG with
246
+ `POST /v1/accounts/{account}/uploads`, then sends
247
+ `{ "kind": "image", "threadId": ..., "sourceId": ... }` to `POST /sends` with a
248
+ fresh `Idempotency-Key`, following the
249
+ [v2 media contract](https://github.com/thisnick/agent-wechat/blob/0f4ad16e4a245e3f8a57c0b8b800d304cb425c85/docs/src/content/docs/use/media.mdx).
250
+ The v2 API has no combined image/text send, so a caption is delivered as its own
251
+ text message after the images. Delivery is confirmed by the media job's `job.updated`
252
+ event over the adapter's existing event stream; an uncertain send is never
253
+ automatically retried.
254
+
255
+ Provider URL citations are preserved as complete source links in outgoing text,
256
+ including after a formatting repair. Text is shortened to reserve room for links;
257
+ if the citations alone cannot fit the reply budget, delivery fails. Required hosted
258
+ citations are included even when the sender did not ask for sources, as required
259
+ by [OpenAI's hosted web search](https://developers.openai.com/api/docs/guides/tools-web-search).
260
+
261
+ The `messager.persona` setting accepts a nonempty string with one or more sentences.
262
+ For example: `"You are a warm, lightly humorous friend. Give practical photography advice. Keep your tone relaxed."`
263
+ Use `\n` within the JSON string to separate paragraphs; sentence breaks and newlines
264
+ are preserved in the prompt.
265
+ Omitting it keeps the default helpful assistant. It affects only the replier;
266
+ reply-length, citation, tool, JSON, and memory rules still apply. Restart the server
267
+ after changing this setting.
268
+
269
+ For editor completion and validation, add `$schema` pointing to
270
+ [schemas/config.schema.json](schemas/config.schema.json) or
271
+ [schemas/models.schema.json](schemas/models.schema.json) in the corresponding
272
+ file. For example: `"$schema": "file:///absolute/path/to/ChatServer/schemas/config.schema.json"`.
273
+ Encode spaces in file URLs as `%20`.
274
+
275
+ Inspect configuration without starting adapters:
276
+
277
+ ```bash
278
+ npm start -- --config_dir ./chatserver-config --print-config
279
+ ```
280
+
281
+ ### Background context
282
+
283
+ Optionally create `<config_dir>/<messager_name>_context` yourself, for example
284
+ `chatserver-config/wechat_context`. Use plain UTF-8 text describing friends or
285
+ channels, with their IDs to identify which background applies:
286
+
287
+ ```text
288
+ Friend wxid_alice: Alice is learning photography and prefers brief, practical tips.
289
+ Channel 123456@chatroom: A photography club discussing weekend outings.
290
+ ```
291
+
292
+ Model inputs include a sender's WeChat display name when available, preferring a
293
+ group member's chatroom nickname, then the friend remark or contact name, then the
294
+ WeChat-wide nickname. Replies prefer that name when referring to a person. Stable
295
+ wxids still identify speakers and control `mainAccount`, ignore rules, and
296
+ delivery; names are never used for those checks. Names may change or be shared;
297
+ use wxids in background entries to keep them unambiguous.
298
+
299
+ The background is included in model filter and replier inputs. It is shared by
300
+ pipelines with the same messager name and re-read for each processed turn, so
301
+ edits apply without restarting. Up to 2,000 tokens are included separately from
302
+ the message snapshot budget; longer context is marked truncated. Missing or empty
303
+ files add no context, and ChatServer never creates them. Read failures log a warning
304
+ and continue without background. Context is not sent to the STT model or saved as
305
+ conversation memory.
306
+
307
+ ### WeChat options
308
+
309
+ | Option | Default | Meaning |
310
+ | --- | --- | --- |
311
+ | `baseUrl` | `http://127.0.0.1:6174` | agent-wechat server root; a trailing `/v1` is also accepted |
312
+ | `accountId` | single authenticated account | Daemon account ID from `GET /v1/accounts` (not a WeChat username); omit only when exactly one authenticated account is visible |
313
+ | `tokenEnv` | `AGENT_WECHAT_TOKEN` | Environment variable containing the bearer token |
314
+ | `tokenFile` | unset | Token file used when the environment token is absent; relative to the config directory |
315
+ | `chatIds` | all chats | Exact conversation IDs to monitor; `[]` monitors none except `mainAccount`, if configured |
316
+ | `reconnectIntervalMs` | `1000` | Base delay before reconnecting the event stream; consecutive failures back off exponentially up to a bounded cap |
317
+ | `timeoutMs` | `30000` | Per-request API timeout, and the deadline for opening the event stream or waiting on its next chunk or heartbeat |
318
+
319
+ Tokens are read at startup and need the `read`, `send`, and `media` scopes for the
320
+ selected account. Group IDs typically end in `@chatroom`.
321
+ `mainAccount`, when set, is monitored even if omitted from `chatIds`. Its exact
322
+ `/status` command returns local queue, delivery, event-stream, and error information
323
+ without calling a model; it uses the normal reply delay. Other senders and group
324
+ status requests are ignored.
325
+
326
+ ### Filters
327
+
328
+ For example, replace the quick start's empty `filters` with:
329
+
330
+ ```json
331
+ [
332
+ { "type": "ignore" },
333
+ {
334
+ "type": "model",
335
+ "model": "local/your-model-id",
336
+ "options": { "instructions": "Allow questions; deny advertisements; otherwise abstain." }
337
+ }
338
+ ]
339
+ ```
340
+
341
+ `ignore` (alias `ignore-sender`) reads `wechat_ignore` in the config directory:
342
+ one sender ID per line or comma-separated, with `#` comments. Changes reload
343
+ without restarting. Use `options.file` for a different file or `options.senderIds`
344
+ for an inline list. Ignored messages are removed before models see them.
345
+
346
+ Model filters return `allowed`, `denied`, or `optional`. The first decisive result
347
+ wins; if all abstain, the replier decides. Errors fail closed. Filter models have
348
+ thinking disabled (`reasoning.effort: none`); choose a provider that supports that
349
+ setting. When `read_image` is enabled for the pipeline, model filters can use it
350
+ to inspect cached images, but receive no other local or hosted tools.
351
+
352
+ ## Models
353
+
354
+ Provider URLs, model IDs/aliases, and provider-specific `timeoutMs` live in
355
+ `models.json`. Each provider requires an explicit `baseUrl`; requests default to a
356
+ **5-minute** timeout. API keys are stored separately under the provider name:
357
+ macOS Keychain, Linux Secret Service (`secret-tool`), or Windows Credential Manager
358
+ (requires the `CredentialManager` PowerShell module).
359
+
360
+ Each entry in a provider's `models` array can set `supportsImageToolResults`.
361
+ It defaults to `false`; enable it only when that model endpoint accepts native
362
+ image content arrays in `function_call_output`:
363
+
364
+ ```json
365
+ {
366
+ "id": "gpt-6.1-sol",
367
+ "name": "sol",
368
+ "supportsImageToolResults": true
369
+ }
370
+ ```
371
+
372
+ When enabled, the initial chat input contains image IDs and source labels without
373
+ image bytes. The model can call `read_image` when visual inspection is needed;
374
+ the image is returned directly inside that function's result. Keep `read_image`
375
+ enabled in `messager.localTools` for this mode. If image lookup is unavailable,
376
+ filters and repliers fall back to direct attachments. Pending optional images do
377
+ not defer a text-only decision; `read_image` can retry their source media if the
378
+ model asks to inspect them. Existing cached bytes are reused before fetching
379
+ source media again. With the default `false`, up to
380
+ four new images attach directly and older tool-retrieved images attach as a
381
+ separate image message on the next model round. Filter and replier models each
382
+ use their own setting, even when they share a chat snapshot. Restart the server
383
+ after changing model settings.
384
+
385
+ | Command | Purpose |
386
+ | --- | --- |
387
+ | `npm run model:add` | Guided provider/model setup; discovers advertised IDs or accepts manual input |
388
+ | `npm run model:update` | Guided provider/model editor; Enter keeps current values |
389
+ | `npm run model:list` | List configured providers and models; `--json` prints the configuration |
390
+ | `npm run model:test` | Send a test prompt to a configured model |
391
+ | `npm run model:test-stt` | Transcribe a local audio file with a configured STT model |
392
+ | `npm run model:test-tts` | Synthesize speech and save a WAV with the same padding as WeChat replies |
393
+ | `npm run model:remove` | Remove a model and its provider/key if no models remain |
394
+
395
+ Append `-- --config_dir ./chatserver-config` to select the quick start directory.
396
+ All commands support `--help`; add/update also support `--interactive` (`-i`).
397
+ Flags remain available for scripts:
398
+
399
+ ```bash
400
+ npm run model:add -- --config_dir ./chatserver-config --provider local --base-url http://llm-host.local:1234/v1 --model your-model-id
401
+ npm run model:test -- --config_dir ./chatserver-config --provider local --name your-model-id
402
+ npm run model:update -- --config_dir ./chatserver-config --provider local --timeout 300000
403
+ ```
404
+
405
+ Test speech models without sending any WeChat messages:
406
+
407
+ ```bash
408
+ npm run model:test-stt -- --provider local --name your-stt-model --file ./voice.wav
409
+ npm run model:test-tts -- --provider local --name your-tts-model --voice your-provider-voice --text "Hello!" --output ./reply.wav
410
+ ```
411
+
412
+ STT supports `--json` for a JSON transcript and `--audio-conversion-cmd` with
413
+ `{input}`/`{output}` for WAV-only engines, using the same conversion as chat input.
414
+ TTS accepts `--options` with a JSON object matching `tts.options`; `--voice` overrides
415
+ its voice setting. Output defaults to `tts-test.wav`; use `--force` to overwrite.
416
+ Both commands accept `--timeout`, `--config_dir`, and the same provider keys as
417
+ `model:test`. File paths resolve from the current directory.
418
+
419
+ `--name` selects a model's CLI alias (defaults to its ID). Update accepts `--model`
420
+ to change its API ID and `--new-name` to rename its alias. If the API ID changes,
421
+ update corresponding `config.json` references. Add requires overwrite confirmation
422
+ or `--force` for an existing alias. Commands without a terminal require flags.
423
+
424
+ Existing provider keys are reused. For missing keys, add reads `--api-key`, then
425
+ `OPENAI_API_KEY`, then a hidden prompt. Replace a saved key explicitly with
426
+ `--api-key`; `OPENAI_API_KEY` does not overwrite it. At runtime and in model tests,
427
+ `CHATSERVER_MODEL_API_KEY` overrides the saved key. Keys never go in `models.json`.
428
+
429
+ ### Incoming voice
430
+
431
+ Add a speech-to-text model using `model:add`, then set `stt` alongside `messager`
432
+ and `filters` in the chat definition:
433
+
434
+ ```json
435
+ {
436
+ "messager": { "name": "wechat", "replier": "local/your-model-id" },
437
+ "stt": {
438
+ "type": "model",
439
+ "model": "speech-provider/your-stt-model-id",
440
+ "audioConversionCmd": "ffmpeg -nostdin -y -i {input} -ar 16000 -ac 1 -c:a pcm_s16le {output}"
441
+ },
442
+ "filters": []
443
+ }
444
+ ```
445
+
446
+ The STT provider must implement the [OpenAI-compatible transcription endpoint](https://developers.openai.com/api/reference/resources/audio/subresources/transcriptions/methods/create)
447
+ (`POST <baseUrl>/audio/transcriptions`, multipart `file`/`model`, JSON `text`). It
448
+ uses that provider's saved key, timeout, and normal model retries; it does not need
449
+ to support the Responses API. `model:test` sends chat prompts, not audio.
450
+
451
+ `type` is currently `"model"`. An explicit `model` is required and must support
452
+ transcription; it does not default to `messager.replier`. Replace
453
+ old string settings such as `"stt": "speech/model"` with the object above.
454
+
455
+ `audioConversionCmd` is optional. Set it for WAV-only engines such as speech.cpp;
456
+ omit it to upload the original audio. Install the converter (FFmpeg in this example)
457
+ on the **ChatServer machine**, available on PATH or specified by an absolute path.
458
+ The command runs in a temporary directory with `{input}` and `{output}` replaced
459
+ by generated filenames; it must write WAV to `{output}`. Quote executable paths
460
+ containing spaces using your operating system's shell syntax (escape double quotes
461
+ in JSON). Conversion has a 30-second deadline, supports shutdown cancellation, and
462
+ removes temporary files afterwards. Conversion errors stop the turn before any
463
+ STT request; cached successful transcripts do not repeat conversion.
464
+
465
+ Voice messages are transcribed sequentially after the reply delay and sender
466
+ exclusions, before model filters and the replier. Transcripts retain the original
467
+ sender, timestamp, and message ID and are reused across retained snapshots. Raw
468
+ audio is not saved in conversation memory. Configure `tts` separately for voice replies.
469
+
470
+ Voice media stays lazy: receiving events never downloads audio. When a turn needs the
471
+ recording, ChatServer lists `GET /v1/accounts/{account}/messages/{id}/media`, picks
472
+ the best copy, and asks for it with
473
+ `POST /messages/{id}/media-transfers` (`{ "messageId": ..., "assetId": ... }`). While
474
+ the download job runs the asset reports `pending`, and the existing bounded retries
475
+ defer the turn. Once an asset is `available`, its bytes are streamed from
476
+ `GET /v1/accounts/{account}/media/{assetId}/content`; the account-scoped route is
477
+ built locally rather than taken from the response. See the
478
+ [v2 incoming media contract](https://github.com/thisnick/agent-wechat/blob/0f4ad16e4a245e3f8a57c0b8b800d304cb425c85/docs/src/content/docs/use/quality.mdx).
479
+ The daemon's decoded voice copy is WAV, so STT models receive it directly; raw
480
+ `audio/silk` is rejected. Audio is limited to 20 MiB per message. Unavailable or
481
+ unsupported media and recordings with no recognized speech are skipped with a log.
482
+ STT request failures stop the turn rather than generating a reply from missing
483
+ audio. Without `stt`, voice messages are ignored without downloads or model
484
+ requests.
485
+
486
+ ### Voice replies (TTS)
487
+
488
+ Add `tts` to the chat entry, alongside `messager`, `filters`, and optional `stt`:
489
+
490
+ ```json
491
+ "tts": {
492
+ "type": "model",
493
+ "model": "local/your-tts-model-id",
494
+ "options": { "voice": "your-provider-voice", "speed": 1 }
495
+ }
496
+ ```
497
+
498
+ Register the TTS model in `models.json` using the model CLI. Its provider must
499
+ support an OpenAI-compatible `/audio/speech` endpoint returning uncompressed WAV.
500
+ `options` passes speech settings supported by that provider, such as `voice`,
501
+ `speed`, or `instructions`; it defaults to `{}`. ChatServer controls `model`,
502
+ `input`, `response_format` (`wav`), and `stream_format` (`audio`). Provider keys,
503
+ timeouts, and model-request retries work as for other models.
504
+
505
+ The replier selects voice only when the message being answered explicitly asks
506
+ for a voice reply. An incoming voice message alone does not select voice output.
507
+ The same short-reply limits apply to the spoken text. ChatServer adds independent
508
+ random **0.5–2 second** silences before and after the audio, without requiring
509
+ FFmpeg on the ChatServer machine.
510
+
511
+ Voice replies use the normal serial queue and 1–3 minute pacing. The adapter stages
512
+ the padded WAV with `POST /uploads`, sends
513
+ `{ "kind": "voice", "threadId": ..., "sourceId": ... }` to `POST /sends` with a
514
+ fresh `Idempotency-Key`, and waits up to three minutes for the matching
515
+ `job.updated` event on the adapter's event stream; this requires agent-wechat
516
+ v2.1.0 or newer. Failure or uncertain delivery is logged without resending or
517
+ sending an extra text reply.
518
+ Unfinished jobs receive a best-effort `POST /jobs/{id}/cancel` on failure or
519
+ shutdown. Memory records the spoken text only after confirmed delivery; audio is
520
+ not saved.
521
+ Omitting `tts` keeps replies in text. Restart after configuration changes.
522
+
523
+ ## Runtime behavior
524
+
525
+ - **Queue:** One FIFO entry per conversation. Each turn uses messages since the last
526
+ successful reply or replier decision to stay silent, capped at the latest 30 source
527
+ messages. Filter denials retain messages for later turns. New arrivals update
528
+ pending context. Processing and sending are sequential, paced at 1–3 minutes.
529
+ - **Event stream:** `start` performs one
530
+ `GET /v1/accounts/{account}/threads?limit=1` lookup and keeps only its
531
+ `asOfEventCursor`; the returned threads are not paged and no message history is
532
+ fetched. It then opens
533
+ `GET /v1/accounts/{account}/events/stream?after=<cursor>` with a `read`-scoped
534
+ bearer token. Event kinds are inside the JSON payload (`message.committed`,
535
+ `account.updated`, `send.updated`, `job.updated`, ...);
536
+ `message.committed` supplies `data.messageId` and `data.threadId`, and the full
537
+ message is read with `GET /v1/accounts/{account}/messages/{id}`. Each decimal cursor
538
+ is kept as a string (it may exceed JavaScript's safe integer range) and
539
+ checkpointed after the message is read and dispatched, or after an intentional
540
+ filter/skip decision. The daemon needs no consumer acknowledgement. Processing is
541
+ sequential; a failed message read does not advance the checkpoint, so reconnecting
542
+ replays that event. A committed event whose message row the daemon has already
543
+ deleted (`GET` answers 404 or 410) is logged as an intentional skip and remembered
544
+ by message ID, so the checkpoint advances and later events in the same stream are
545
+ still handled; transient or authorization failures stay replayable. Heartbeat
546
+ comments every 15 seconds keep the idle deadline open. Frames use the default SSE
547
+ message event as the daemon sends them, with the kind inside the JSON payload:
548
+ unknown payload kinds need no read and are checkpointed after safe handling. A named
549
+ SSE event other than `message` is ignored without being checkpointed, except
550
+ `event: error`, which is decoded for `event_cursor_expired` and otherwise fails the
551
+ stream for a reconnect.
552
+ - **Live messages:** Only events after startup are delivered, and only that exact
553
+ message route is read — never a message listing. A `message.committed` event is
554
+ handed off as its own turn; the server queue then aggregates each conversation's
555
+ buffered messages into one snapshot per turn, so a burst still arrives as bounded
556
+ context even though every event was delivered individually. The cutoff is the
557
+ message's saved `createdAt`: a message whose source time precedes startup, or that
558
+ the daemon reports without a time, is skipped and remembered so a replay never
559
+ delivers it. Equal source times are never merged or dropped. Self messages provide
560
+ context without triggering replies; manual replies arriving during generation
561
+ invalidate the pending answer. A replayed cursor at or below the checkpoint is
562
+ compared as an integer string (never as a float) and skipped, so the checkpoint never
563
+ regresses. Recently handled message IDs are kept in a bounded in-memory cache — the
564
+ cursor checkpoint is the primary de-duplication — and the cache is cleared on stop,
565
+ reset, or an identity switch.
566
+ The daemon account ID and the signed-in `ownUsername` are captured around every
567
+ awaited event and status read; an identity change stops that reader and
568
+ tail-bootstraps the new login instead of checkpointing or delivering the previous
569
+ login's events, and it settles that login's pending sends.
570
+ If the daemon reports `event_cursor_expired` (HTTP before headers or an in-stream
571
+ `event:error`), the adapter performs one more `threads?limit=1` lookup, rebases the
572
+ checkpoint on that current tail and deliberately skips the missed window rather than
573
+ backfilling history; it never resets to cursor zero. The capped reconnect backoff
574
+ still applies, so repeated expiry against an unchanged tail cannot spin. A reconnect
575
+ that is not an expiry resumes from the last processed cursor, so events the daemon
576
+ still retains replay and are de-duplicated. Startup and expiry bootstrap the stream;
577
+ ChatServer never pages `/threads/{thread}/messages` for history and never backfills.
578
+ - **Media:** Text, images, and optional STT voice input are supported; video,
579
+ stickers, and files are skipped. The latest four new images per snapshot, each at most 5 MiB, are attached directly.
580
+ Models receiving them must support image input. Pending media gets bounded
581
+ retries; unavailable images are labeled rather than guessed.
582
+ With `read_image` enabled, images in retained snapshot messages are also downloaded
583
+ into `<conversation-hash>.images.json` beside the conversation memory file.
584
+ Older images are represented by source labels in `cachedImages`; the tool attaches
585
+ a requested image as native image input on the next model round. Models with
586
+ `supportsImageToolResults: true` receive image IDs only in the initial input and
587
+ receive requested images directly in tool results. Both repliers
588
+ and model filters can use it. Lookups are restricted to the current account and
589
+ conversation and work after restart. Images expire three days after the original
590
+ source timestamp; lookup never refreshes expiry. Cleanup runs at startup and
591
+ hourly, and expired entries cannot be retrieved between cleanups. Each conversation
592
+ keeps at most 30 images and 64 MiB of cache JSON, evicting the oldest first.
593
+ Recent cached-image labels have a 1,000-token prompt allowance; IDs retained in
594
+ memory can still retrieve images omitted from that list. Without `read_image`,
595
+ received images are attached directly without persistent image caching.
596
+ Generated images are always cached before delivery; `read_image` is required
597
+ for model inspection of these cached artifacts.
598
+ - **Replies:** Normally 1–2 short sentences, with a 150-token target and 200-token
599
+ hard cap. Citations are included when explicitly requested or required by hosted tools. Oversized text
600
+ is truncated locally. Malformed reply JSON or memory updates are logged in
601
+ full and receive one formatting-only repair request without tools; a second
602
+ invalid result stops delivery.
603
+ - **Memory:** Compact notes and confirmed replies persist per conversation for
604
+ three days, capped at 1,000 tokens by default (`messager.memoryTokenLimit`). This is summarized context, not a raw
605
+ message archive. Replier memory updates are saved even when it chooses not to reply;
606
+ filter denials skip the replier and its memory updates. Image bytes live in the
607
+ separate image cache and do not count toward the text memory budget.
608
+ - **Tools:** `web_search` (DuckDuckGo), `read_url`, `calculate`, `current_time`, and `read_image`
609
+ are enabled by default. Models need Responses API function calling to use them.
610
+ Page reading supports public HTTP(S) text/HTML/JSON, not PDFs or authenticated
611
+ pages. Set `messager.localTools` to select a subset or disable local tools.
612
+ `messager.apiTools` separately enables provider-hosted tools; its default is empty.
613
+ - **Identity:** The daemon account ID and the signed-in WeChat username are tracked
614
+ separately. Every reply and lazy media load is fenced to both values captured when
615
+ it originated, re-checked after each request, so a re-login on the same daemon
616
+ profile cannot deliver someone else's queued reply or fetch their attachments.
617
+ Conversation queues, memory files, and image caches are scoped by that same pair, so
618
+ a cache written under an account-only identity is never adopted by a named login;
619
+ this is a security fix with no migration of existing account-only caches.
620
+ - **Reconnect and status:** A closed, idle, or failed event stream reconnects with
621
+ capped exponential backoff starting at `reconnectIntervalMs`. Heartbeat comments and
622
+ received chunks reset the idle deadline; the backoff resets only after an event is
623
+ handled, so a stream that connects but keeps failing to read a message backs off
624
+ further. Every retry after the first re-reads the account and revalidates both the
625
+ daemon account ID and `ownUsername`; a switch or re-login clears cursors, resets
626
+ the startup cutoff, and re-bootstraps the stream from the new login's current tail.
627
+ `/status` reports the last dispatched event, last stream activity, consecutive
628
+ stream failures, and the last stream error. Transient model errors retry the full
629
+ generation up to three times, ten seconds apart.
630
+ Outbound confirmation arrives over the same event stream: a nonterminal
631
+ `POST /sends` is settled by the matching `send.updated` (text) or `job.updated`
632
+ (media) payload, with one `GET /sends/{id}` or `GET /jobs/{id}` read after
633
+ registration and after each reconnect to reconcile a submission response or a
634
+ confirmation that arrived while detached. There is no interval polling. A
635
+ submission response that is already terminal is accepted or rejected immediately.
636
+ IDs are opaque strings under `/v1/accounts/{account}/...`, text and media
637
+ confirmations can never settle each other, and failed or uncertain sends are never
638
+ automatically retried because delivery may already have succeeded remotely.
639
+
640
+ Avoid overlapping pipelines for the same conversations, which can produce duplicate
641
+ replies. Reply pacing is per process and does not guarantee protection from account
642
+ bans.
643
+
644
+ ## Development
645
+
646
+ Runtime limits and defaults are grouped and documented in [src/limit.ts](src/limit.ts).
647
+
648
+ ```bash
649
+ npm run dev # Auto-reload
650
+ npm run build # Bundle server and model CLI into dist/
651
+ npm run typecheck
652
+ npm run lint
653
+ ```
654
+
655
+ VS Code debug configurations and build tasks are in `.vscode/`. To use the built
656
+ executables directly, run `npm link`, then `chatserver` or `chatserver-models`.
657
+
658
+ Implementation entry points:
659
+ [server](src/server.ts), [config](src/config.ts),
660
+ [messagers](src/messagers/interface.ts), [filters](src/filters/interface.ts),
661
+ [repliers](src/repliers/interface.ts), and [tools](src/tools/interface.ts).
662
+ Register custom implementations in the corresponding registry before loading config.