@rei-standard/amsg-shared 0.4.0-next.0 → 0.4.0-next.10

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ReiStandard contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -17,7 +17,7 @@ A single push is described by three independent dimensions:
17
17
  |----------------|-------------------|-------------------------------------------------------|--------------------|
18
18
  | Dispatch | `messageType` | `instant` / `fixed` / `prompted` / `auto` | Package (fixed) |
19
19
  | Business | `messageSubtype` | Any string | Caller (free-form) |
20
- | Content | `messageKind` | `content` / `reasoning` / `tool_request` / `error` | Package (fixed) |
20
+ | Content | `messageKind` | `content` / `reasoning` / `tool_request` / `error` / `result` | Package (fixed) |
21
21
 
22
22
  `messageType` answers **how this push was produced** (one-shot
23
23
  `instant` worker, scheduled `fixed` ping, AI-`prompted` reply, fully
@@ -41,10 +41,24 @@ origin** (`'instant'` for `amsg-instant`, `'scheduled'` for any
41
41
  | `messageType` | `MessageType` | Dispatch axis. |
42
42
  | `source` | `'instant' \| 'scheduled'` | Routing origin. |
43
43
  | `messageId` | `string` | Unique per push. Format owned by the producer. |
44
- | `sessionId` | `string` | **Shared across all pushes from the same LLM round** (reasoning + content), and across all iterations of a single agentic-loop request. |
44
+ | `sessionId` | `string` | **Shared across all pushes from the same LLM round** (reasoning + content), and across all iterations of a single agentic-loop request. Opaque — do not parse it for task identity, read the scheduling fields below. |
45
45
  | `timestamp` | `string` (ISO 8601) | Producer-side wall clock. |
46
46
  | `messageSubtype` | `string?` | Caller's business namespace. Defaults to `'chat'` at producers. |
47
47
  | `metadata` | `object?` | **Caller passthrough.** Packages MUST NOT write here. |
48
+ | `taskId` | `number \| string \| null?` | Scheduled task row id. |
49
+ | `taskUuid` | `string \| null?` | Scheduled task uuid (the id the scheduling side chose). |
50
+ | `recurrenceType` | `'none' \| 'daily' \| 'weekly'?` | Whether that task fires again. |
51
+ | `occurrenceMs` | `number \| null?` | Nominal fire time of this occurrence (epoch ms). |
52
+
53
+ ### Scheduling identity
54
+
55
+ `taskId` / `taskUuid` / `recurrenceType` / `occurrenceMs` are stamped by
56
+ `@rei-standard/amsg-server` on every push that came out of a scheduled task
57
+ row. They tell the client which task this is, whether the task will come
58
+ back, and which nominal fire time produced this burst — so a task the client
59
+ never created (one the character scheduled for itself at fire time) still
60
+ arrives fully identified. Pushes with no task behind them (`amsg-instant`)
61
+ omit all four.
48
62
 
49
63
  ---
50
64
 
@@ -56,7 +70,7 @@ origin** (`'instant'` for `amsg-instant`, `'scheduled'` for any
56
70
 
57
71
  | Field | Type | Notes |
58
72
  |----------------------|-------------------------------------------|-------|
59
- | `show` | `'auto' \| 'always' \| 'when-hidden' \| false` | Display policy. `auto` follows SW defaults. |
73
+ | `show` | `'auto' \| 'always' \| 'when-hidden' \| false` | Display policy — see [选哪个 `show`](#选哪个-show). |
60
74
  | `title` | `string?` | Notification title override. |
61
75
  | `body` | `string?` | Notification body override. |
62
76
  | `icon` | `string?` | Notification icon URL. |
@@ -64,12 +78,56 @@ origin** (`'instant'` for `amsg-instant`, `'scheduled'` for any
64
78
  | `tag` | `string?` | Notification grouping tag. |
65
79
  | `renotify` | `boolean?` | Re-alert when a matching `tag` replaces an existing notification. |
66
80
  | `requireInteraction` | `boolean?` | Keep the notification visible until the user dismisses it. |
67
- | `silent` | `boolean?` | Suppress notification sound and vibration. |
81
+ | `silent` | `(boolean \| 'when-visible')?` | Suppress notification sound and vibration — see [选哪个 `silent`](#选哪个-silent). |
68
82
  | `data` | `Record<string, unknown>?` | Custom data passed to the notification. |
69
83
 
70
84
  Unknown fields are preserved for forward compatibility, but the known
71
85
  fields above are validated by the builders when present.
72
86
 
87
+ ### 选哪个 `show`
88
+
89
+ 订阅是按 `userVisibleOnly: true` 建的,收到 push 却不弹通知就是违约:Chrome 替你弹一条通用横幅,Firefox 有配额、超了退订,iOS 给新订阅几天宽限期(跟条数无关)、过期后一条不弹就吊销订阅。所以口径只有一条,跟机型无关——**要推就一定弹,不想弹就别推。**
90
+
91
+ | 值 | SW 那边 | 什么时候用 |
92
+ |---|---|---|
93
+ | 不配 / `'auto'` | 按 `messageKind` 走默认(`content` / `result` 弹,其余不弹) | 默认,多数 payload 不用管 |
94
+ | `'always'` | 一定弹 | 要推的一律用它。嫌打扰配 `tag` 折叠加 `silent`,而不是不弹 |
95
+ | `false` | 一定不弹 | 明说这条不弹。有收件箱的发送端据此**根本不发这条 push**,内容落收件箱等客户端补拉 |
96
+ | `'when-hidden'` | 有可见窗口就不弹 | 兼容档,新代码不选——应用在前台时它就是一条不弹的 push,那笔账照记 |
97
+
98
+ 所以 `show: false` 在发送端和接收端不是同一件事:有服务端收件箱的发送端(`@rei-standard/amsg-server` 单用户线)见到它压根不发,SW 那边收不到;没有收件箱的发送端才是「推过去、SW 不弹」。
99
+
100
+ 想要「立刻推到、但页面自己渲染」没有专门的档:用 `'always'` + `tag` + `silent`,页面自绘不受影响(`postMessage` 跟弹不弹通知无关)。
101
+
102
+ 完整取舍见 [`@rei-standard/amsg-sw` README 的「不展示通知的代价」](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/sw/README.md#不展示通知的代价)。
103
+
104
+ ### 选哪个 `silent`
105
+
106
+ `silent` 管的是「响不响铃、震不震」,跟弹不弹(`show`)是两件独立的事——通知照样进通知中心,只是安静地进。
107
+
108
+ | 值 | SW 那边 | 什么时候用 |
109
+ |---|---|---|
110
+ | 不配 / `false` | 正常响铃震动 | 默认 |
111
+ | `true` | 一律不响 | 一串连着来的通知配 `tag` 折叠,只想在通知中心留个痕迹 |
112
+ | `'when-visible'` | 有可见窗口就静音,没有就照常响 | 页面自己会把内容画出来的那类消息:用户正看着页面就别再响一声,人切后台了照样叫得动 |
113
+
114
+ `'when-visible'` 只能由 Service Worker 当场算:发送端发推那一刻并不知道用户此刻在不在前台,写死 `silent: true` 的话,用户切到后台收到的那条也不会响。判定用的是跟 `show: 'when-hidden'` 同一套窗口可见性口径,一条 payload 只算一次。
115
+
116
+ ### `notificationIntent(payload)`
117
+
118
+ 把上面这套规则算成一个值,`'always'` / `'when-hidden'` / `'never'`:`notification.show` 说了算,没说才按 `messageKind` 走默认。两端读同一份——SW 拿它决定要不要 `showNotification`,发送端拿它决定这条值不值得占用推送通道(`'never'` 的 payload 推过去不会有任何可见反馈,`@rei-standard/amsg-server` 只把它落进收件箱,等客户端上线 `GET /outbox?since=` 补拉)。
119
+
120
+ ```js
121
+ import { notificationIntent } from '@rei-standard/amsg-shared';
122
+
123
+ notificationIntent({ messageKind: 'content' }); // 'always'
124
+ notificationIntent({ messageKind: 'reasoning' }); // 'never'
125
+ notificationIntent({ messageKind: 'reasoning', notification: { show: 'always' } }); // 'always'
126
+ notificationIntent({ messageKind: 'content', notification: { show: false } }); // 'never'
127
+ ```
128
+
129
+ `'when-hidden'` 单独占一档,因为它到底弹不弹要看当下有没有可见窗口,那只有 SW 知道;发送端把它当「可能会弹」照发。
130
+
73
131
  ---
74
132
 
75
133
  ## Per-kind fields
@@ -85,7 +143,6 @@ fields above are validated by the builders when present.
85
143
  | `title` | `string?` | Notification title. |
86
144
  | `contactName` | `string?` | Sender display name. |
87
145
  | `avatarUrl` | `string \| null?` | Sender avatar URL (`https:` only — `data:` is rejected upstream). |
88
- | `taskId` | `string \| null?` | Scheduled task ID (server only). |
89
146
 
90
147
  ### `ReasoningPush` — LLM meta-thinking
91
148
 
@@ -96,11 +153,17 @@ fields above are validated by the builders when present.
96
153
  | `title` | `string?` | |
97
154
  | `contactName` | `string?` | |
98
155
  | `avatarUrl` | `string \| null?` | |
99
-
100
- **No `messageIndex` / `totalMessages`.** Reasoning is one push per
101
- LLM round, never a split-burst. Those fields are absent at the type
102
- level on purpose — making them optional would leave callers
103
- wondering when they're set.
156
+ | `messageIndex` | `number?` | 1-based part index when a producer sentence-splits reasoning into a burst. Omit for singletons. |
157
+ | `totalMessages` | `number?` | Total parts of that split. Omit for singletons. |
158
+ | `chunkIndex` | `number?` | Transport-only slice index when a single segment exceeds the Web Push payload limit (see `chunkReasoningByUtf8Bytes`). |
159
+ | `totalChunks` | `number?` | Total transport slices. Omit when not sliced. |
160
+
161
+ Both multi-part axes are **omitted from the wire when the part count
162
+ is 1**, so single-shot reasoning stays byte-for-byte identical to
163
+ older payloads. Current producers emit one `ReasoningPush` per LLM
164
+ round and set neither — oversized reasoning rides the generic
165
+ multipart transport instead. The two axes can coexist when a
166
+ sentence-split segment is itself oversized.
104
167
 
105
168
  Emitted **before** the matching `ContentPush` burst when the LLM
106
169
  response carried a non-empty `reasoning_content`.
@@ -116,8 +179,11 @@ response carried a non-empty `reasoning_content`.
116
179
  | `message` | `string?` | Optional human-readable tag for the request. |
117
180
 
118
181
  Emitted by an agentic-loop hook returning
119
- `{ decision: 'tool-request', pushPayload }`. The client is expected
120
- to execute the tool and resume via `/continue`.
182
+ `{ decision: 'tool-request', pushPayloads }`. In the default
183
+ (amsg-instant) flavor the client executes the tools and resumes via
184
+ `/continue`; amsg-server's fire-time loop runs the tools in-process
185
+ instead and may carry a `toolCalls` array directly — see
186
+ `assertValidDecision` below.
121
187
 
122
188
  ### `ErrorPush` — producer-level error
123
189
 
@@ -132,6 +198,22 @@ Replaces the legacy 0.7.0 `{ type: 'error', code: '...' }` envelope.
132
198
  The legacy `type` field is **gone** — do not look for it on
133
199
  `ErrorPush`.
134
200
 
201
+ ### `ResultPush` — 宿主自定义的一条结果
202
+
203
+ | Field | Type | Notes |
204
+ |---------------|------------|----------------------------------------------------------------|
205
+ | `messageKind` | `'result'` | Discriminator. |
206
+ | `resultKind` | `string` | 宿主给这类结果起的名字(`'fire-pack'`、`'ledger-entry'`……),客户端按它分流。 |
207
+ | `title` | `string?` | 通知标题的兜底(`notification.title` 优先)。 |
208
+ | `body` | `string?` | 通知正文的兜底(`notification.body` 优先)。 |
209
+
210
+ 不是聊天内容,而是「这次跑完产出了点什么,客户端拿去自己消化」:整理好的一份数据、一条账目、后台生成的产物。形状由宿主定,`buildResultPush` 是唯一**保留自己不认识的字段**的 builder——白名单式的复制会把内容删掉一半。
211
+
212
+ 两处与别的 kind 不同:
213
+
214
+ - **投递路径**:产出方(`@rei-standard/amsg-server` 的 `ctx.emitResult()`)除了推送,还把它落进服务端收件箱,客户端下次 `GET /outbox?since=` 一定拿得到。
215
+ - **通知**:SW 侧默认弹(与 `content` 同待遇,其余三种是静默送给页面)——结果往往正是「跑完了,回来看看」那句话。不想弹就带 `notification: { show: false }`:这一档 `amsg-server` 不发推送、只落收件箱,客户端补拉时拿到(见[选哪个 `show`](#选哪个-show))。
216
+
135
217
  ---
136
218
 
137
219
  ## Usage
@@ -162,6 +244,9 @@ function dispatch(push: AmsgPush) {
162
244
  case 'error':
163
245
  console.error(push.code, push.message);
164
246
  break;
247
+ case 'result':
248
+ // push.resultKind is `string` — 宿主自己的结果,按它分流
249
+ break;
165
250
  }
166
251
  }
167
252
  ```
@@ -174,6 +259,7 @@ import {
174
259
  buildReasoningPush,
175
260
  buildToolRequestPush,
176
261
  buildErrorPush,
262
+ buildResultPush,
177
263
  } from '@rei-standard/amsg-shared';
178
264
 
179
265
  // One sentence in an N-split burst
@@ -216,12 +302,24 @@ const error = buildErrorPush({
216
302
  message: 'onLLMOutput threw: ...',
217
303
  iteration: 2,
218
304
  });
305
+
306
+ // 宿主自定义的一条结果(认识以外的字段原样保留)
307
+ const result = buildResultPush({
308
+ messageType: 'auto',
309
+ source: 'scheduled',
310
+ messageId: 'msg_task_7@1700000000000_result_0',
311
+ sessionId: 'sess_abc',
312
+ resultKind: 'fire-pack',
313
+ packId: 'pack_42',
314
+ entries: [{ id: 1 }, { id: 2 }],
315
+ notification: { title: '整理好了', body: '点开看看' },
316
+ });
219
317
  ```
220
318
 
221
319
  ### Type guards
222
320
 
223
321
  ```js
224
- import { isContentPush, isReasoningPush, isErrorPush } from '@rei-standard/amsg-shared';
322
+ import { isContentPush, isReasoningPush, isToolRequestPush, isErrorPush, isResultPush } from '@rei-standard/amsg-shared';
225
323
 
226
324
  if (isContentPush(push)) {
227
325
  // push.message is `string`
@@ -239,6 +337,7 @@ MESSAGE_KIND.CONTENT; // 'content'
239
337
  MESSAGE_KIND.REASONING; // 'reasoning'
240
338
  MESSAGE_KIND.TOOL_REQUEST; // 'tool_request'
241
339
  MESSAGE_KIND.ERROR; // 'error'
340
+ MESSAGE_KIND.RESULT; // 'result'
242
341
 
243
342
  MESSAGE_TYPE.INSTANT; // 'instant'
244
343
  MESSAGE_TYPE.FIXED; // 'fixed'
@@ -251,6 +350,68 @@ PUSH_SOURCE.SCHEDULED; // 'scheduled'
251
350
 
252
351
  ---
253
352
 
353
+ ## Shared building blocks
354
+
355
+ Besides the push schema, this package is the single source of truth
356
+ for helpers that `amsg-instant`, `amsg-server`, and `amsg-sw` used to
357
+ each keep a copy of. All are exported from the package root; the
358
+ one-liners below are just a map — see the JSDoc on each export for
359
+ the full contract.
360
+
361
+ ### Bytes / encoding / crypto
362
+
363
+ `toUint8` · `concatBytes` · `utf8` · `utf8Decode` · `bytesToBase64` ·
364
+ `bytesToBase64Url` · `base64UrlToBytes` · `jsonToBase64Url` ·
365
+ `bytesToHex` · `hexToBytes` · `randomBytes` · `hmacSha256` ·
366
+ `timingSafeEqualBytes` — WebCrypto-friendly byte/encoding helpers for
367
+ base64url, hex, HMAC, and constant-time comparison.
368
+
369
+ ### LLM call
370
+
371
+ | Export | What it is |
372
+ |---|---|
373
+ | `callLlm(...)` | Call an OpenAI-compatible chat-completions endpoint with timeout / abort handling (default 300 000 ms, injectable `fetch`). |
374
+ | `buildLlmRequestBody(...)` | Build the request body for prompt mode or `messages` mode. |
375
+ | `normalizeAiApiUrl(apiUrl)` | Normalize a base URL to its chat-completions endpoint without doubling `/v1`; unrecognized paths pass through unchanged. |
376
+ | `validateLlmMessagesShape(...)` | Validate a `messages` array, including assistant `tool_calls` and `role: 'tool'` entries. |
377
+ | `LLM_MESSAGES_ERROR` | Stable error codes emitted by that validation. |
378
+
379
+ ### Web Push
380
+
381
+ | Export | What it is |
382
+ |---|---|
383
+ | `sendWebPush(...)` | RFC 8291 payload encryption + RFC 8292 VAPID auth + POST to the push service (`err.code = 'PUSH_SEND_FAILED'` on failure). |
384
+ | `buildVapidJwt(...)` / `verifyVapidJwt(...)` | Build / verify the ES256 VAPID JWT. |
385
+ | `normalizeVapidSubject(...)` | Normalize the VAPID subject (e.g. add the `mailto:` prefix). |
386
+
387
+ ### Wire-protocol constants
388
+
389
+ | Export | What it is |
390
+ |---|---|
391
+ | `MULTIPART_MESSAGE_KIND` / `MULTIPART_ENCODING` / `MULTIPART_VERSION` | The generic multipart chunk envelope (`'_multipart'`) produced by `amsg-instant` and reassembled by `amsg-sw`. |
392
+ | `DEFAULT_MULTIPART_TTL_MS` / `DEFAULT_MULTIPART_MAX_CHUNKS` / `DEFAULT_MULTIPART_MAX_TOTAL_BYTES` | Multipart reassembly budget defaults. |
393
+ | `REI_AMSG_POSTMESSAGE_TYPE` / `REI_SW_EVENT` / `REI_SW_MESSAGE_TYPE` / `REI_AMSG_DELIVER_MESSAGE_TYPE` | The window ⇄ Service Worker `postMessage` protocol strings — import these instead of hard-coding the literals. |
394
+
395
+ ### Agentic-loop contract
396
+
397
+ | Export | What it is |
398
+ |---|---|
399
+ | `assertValidDecision(decision, options?)` | Runtime-validate an `onLLMOutput` hook decision (`finish` / `tool-request` / `continue` / `skip-push`); `{ inlineToolCalls: true }` enables the amsg-server flavor. |
400
+ | `extractToolCallsFromDecision(...)` | Pull tool calls out of either decision flavor (inline `toolCalls` or tool-request `pushPayloads`). |
401
+ | `buildSessionContext(...)` | Build the frozen, credential-free context object handed to agentic hooks. |
402
+ | `extractAssistantMessage(...)` | Safely read `choices[0].message` off an LLM response (never throws). |
403
+ | `readReasoningContent(...)` | Read `reasoning_content` off an assistant message; empty string means "none". |
404
+ | `stripReasoningTags(...)` | Strip reasoning that leaked into `message.content` so it doesn't ship inside the `ContentPush` burst. |
405
+ | `chunkReasoningByUtf8Bytes(text, maxBytes)` | Split reasoning text on safe UTF-8 edges for payload-limited transports. |
406
+
407
+ ### Validation misc
408
+
409
+ `isValidUrl` · `validateAvatarUrl` · `AVATAR_URL_MAX_LENGTH` — the
410
+ avatar-URL soft-strip rule shared by client / instant / server
411
+ (standards §6.2).
412
+
413
+ ---
414
+
254
415
  ## Invariants
255
416
 
256
417
  1. **`messageKind` is a literal-type discriminator.** Producers must
@@ -260,8 +421,10 @@ PUSH_SOURCE.SCHEDULED; // 'scheduled'
260
421
  `ReasoningPush` and the `ContentPush`(es) it precedes share the
261
422
  same `sessionId`. Agentic-loop multi-iteration runs reuse the
262
423
  same `sessionId` across iterations.
263
- 3. **`ReasoningPush` carries no `messageIndex` / `totalMessages`.**
264
- Those fields belong to the content N-split burst.
424
+ 3. **Multi-part fields are omitted for singletons.** On `ContentPush`
425
+ and `ReasoningPush` alike, `messageIndex` / `totalMessages` (and
426
+ reasoning's `chunkIndex` / `totalChunks`) only appear on genuine
427
+ multi-part bursts — never as a redundant `1 / 1`.
265
428
  4. **`metadata` is caller-owned.** Packages must add protocol-level
266
429
  data as top-level fields, never inside `metadata`.
267
430
  5. **`source` is the routing origin, not the dispatch type.**