@openchambery/web 1.19.7-beta.9 → 1.19.8-beta.1
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/dist/assets/AgentAvatar-CX0a1rKE.js +1 -0
- package/dist/assets/AssistantView-DkXW9Fzq.js +2 -0
- package/dist/assets/DiagramView-C8MZY0fA.js +1 -0
- package/dist/assets/FadeInOnReveal-CK4asqVR.js +1 -0
- package/dist/assets/Icon-BYM02QyU.js +1 -0
- package/dist/assets/MarkdownLoadingSkeleton-DWs4Jg7b.js +3 -0
- package/dist/assets/MarkdownRenderer-cxYw8-8G.js +2 -0
- package/dist/assets/MarkdownRendererImpl-B39nwiZX.js +7 -0
- package/dist/assets/MarkstreamRendererImpl-CdYqdejg.js +1 -0
- package/dist/assets/MarkstreamRendererImpl-CsNwy1T5.css +1 -0
- package/dist/assets/MobileDetailNavigation-D6ri-ePj.js +1 -0
- package/dist/assets/MobileShareBridge-HRTN71wg.js +4 -0
- package/dist/assets/MobileSurface-D2yHjnZw.js +2 -0
- package/dist/assets/{MultiRunWindow-Ohj6UbWj.js → MultiRunWindow-CTRgfYQD.js} +1 -1
- package/dist/assets/OnboardingScreen-C3-wzeSX.js +1 -0
- package/dist/assets/OverlayScrollbar-KUpvYHdv.js +2 -0
- package/dist/assets/SettingsView-ImI02YSp.js +25 -0
- package/dist/assets/{SettingsWindow-B4lzhAbO.js → SettingsWindow-DWo2lUaN.js} +1 -1
- package/dist/assets/TerminalView-BseZ5dR-.js +8 -0
- package/dist/assets/ToolOutputDialog-BIK3cPqo.js +10 -0
- package/dist/assets/appThemeRegistry-4_bwYAWt.js +1 -0
- package/dist/assets/{button-z0GOBtWi.js → button-C0cth9tl.js} +1 -1
- package/dist/assets/clipboard-DJ4nQEuY.js +1 -0
- package/dist/assets/{collapsible-D9zJRpeB.js → collapsible-DFT9cE80.js} +1 -1
- package/dist/assets/context-menu-CAp74NfV.js +1 -0
- package/dist/assets/desktopHosts-CWxex1c3.js +1 -0
- package/dist/assets/desktopRelayRestore-9EbBYQ9s.js +1 -0
- package/dist/assets/{diagram-CoEqmReY.js → diagram-BryzMuta.js} +1 -1
- package/dist/assets/{dialog-x2hirWBd.js → dialog-CgbV5ljg.js} +1 -1
- package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +1 -0
- package/dist/assets/{es-C8Sqjsia.js → es-BHqnSjoZ.js} +1 -1
- package/dist/assets/fileQueries-BY-9OWzp.js +1 -0
- package/dist/assets/fileReferenceActions-BJJtX_S5.js +1 -0
- package/dist/assets/{fr-p-Zwm_j2.js → fr-BfrlJY9i.js} +1 -1
- package/dist/assets/{gitApi-CtS4kWoo.js → gitApi-m09MWmzE.js} +3 -3
- package/dist/assets/{input-BG3VWTIF.js → input-DFLpqjGR.js} +1 -1
- package/dist/assets/insertionBoundaries-B0fzEL22.js +4 -0
- package/dist/assets/{ja-B6REu1qP.js → ja-DP-7qt-x.js} +1 -1
- package/dist/assets/{ko-CJBNH9IZ.js → ko-E2B5vGQP.js} +1 -1
- package/dist/assets/main-BYX7_XVg.js +169 -0
- package/dist/assets/main-BpbNOMNf.js +2 -0
- package/dist/assets/markdown-worker-BuveaH3O.js +1 -0
- package/dist/assets/markdownTheme-CzZm3riB.js +2 -0
- package/dist/assets/miniChat-CFBgCVoZ.js +2 -0
- package/dist/assets/mobile-DcZkxRBz.js +2 -0
- package/dist/assets/multirun-CBqJQgS2.js +4 -0
- package/dist/assets/outsideFileGrants-DnplRxr-.js +1 -0
- package/dist/assets/path-utils-jGozTg9d.js +1 -0
- package/dist/assets/persistence-DdUac14I.js +3 -0
- package/dist/assets/{pl-B2cBI_7K.js → pl-r8X5gRM5.js} +1 -1
- package/dist/assets/platform-B4KKLnZ5.js +2 -0
- package/dist/assets/projectMeta-Ct1qHZhU.js +3 -0
- package/dist/assets/{pt-BR-CrCc9hmt.js → pt-BR-DcpdXCf5.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-DxmxYdiE.js +2 -0
- package/dist/assets/renderMobileApp-XXw7Fnrr.js +28 -0
- package/dist/assets/runtimeConfig-CcIqxMFB.js +2 -0
- package/dist/assets/runtimeEndpointReset-CU1HhJfC.js +147 -0
- package/dist/assets/sessionChangeSummary-DOq8EN_H.js +1 -0
- package/dist/assets/sessionLookup-CNMNR-yh.js +1 -0
- package/dist/assets/sortable-tabs-strip-C8_9kHqu.js +1 -0
- package/dist/assets/src-DasvfkEg.css +2 -0
- package/dist/assets/{store-CL3WP48T.js → store-BDFdnk6l.js} +2 -2
- package/dist/assets/terminalApi-947l7Ra3.js +1 -0
- package/dist/assets/textarea-Cgo_nWUU.js +1 -0
- package/dist/assets/toolHelpers-DEvVP9U0.js +2 -0
- package/dist/assets/{tooltip-CtH61mQh.js → tooltip-BifLJ8o0.js} +1 -1
- package/dist/assets/{uk-C8YKAz0x.js → uk-Di0vl9V-.js} +1 -1
- package/dist/assets/url-Pk6tQ1EZ.js +1 -0
- package/dist/assets/{ChatContainer-CDExXwgn.js → useAppFontEffects-CBLEH9it.js} +44 -39
- package/dist/assets/useAssistantUIStore-me6s63b2.js +1 -0
- package/dist/assets/useDesktopWindowControlsLayout-DMCmF610.js +1 -0
- package/dist/assets/useEffectiveDirectory-CMvw1bLA.js +1 -0
- package/dist/assets/useFontPreferences-BCOBlaAd.js +1 -0
- package/dist/assets/useMobileNavigationStore-Di9IWKfu.js +413 -0
- package/dist/assets/useSessionAutoCleanup-DMyMP_xI.js +1 -0
- package/dist/assets/useUIStore-CuaA8-Hp.js +1 -0
- package/dist/assets/useWorkerHighlightedLines-uNzY5rmU.js +7 -0
- package/dist/assets/utils-BiK3tBGM.js +1 -0
- package/dist/assets/{vendor-markstream-react-CwvbEg9R.js → vendor-markstream-react-C3K8YH0d.js} +4 -4
- package/dist/assets/{zh-CN-CCYwUnmB.js → zh-CN-coFCfeQE.js} +1 -1
- package/dist/assets/{zh-TW-D8Y6py08.js → zh-TW-oDRZ1-yb.js} +1 -1
- package/dist/index.html +9 -11
- package/dist/mini-chat.html +9 -11
- package/dist/mobile.html +15 -17
- package/package.json +2 -1
- package/server/index.js +2 -0
- package/server/lib/assistant-delivery-parts.js +3 -1
- package/server/lib/assistant-delivery-parts.test.js +11 -0
- package/server/lib/assistants/DOCUMENTATION.md +77 -4
- package/server/lib/assistants/assign.js +225 -0
- package/server/lib/assistants/assign.test.js +118 -0
- package/server/lib/assistants/bubbles.js +45 -0
- package/server/lib/assistants/bubbles.test.js +36 -0
- package/server/lib/assistants/cards.js +148 -0
- package/server/lib/assistants/cards.test.js +116 -0
- package/server/lib/assistants/contact-store.js +284 -0
- package/server/lib/assistants/contact-store.test.js +131 -0
- package/server/lib/assistants/contact-tools.js +764 -0
- package/server/lib/assistants/contact-tools.test.js +337 -0
- package/server/lib/assistants/contracts.js +20 -1
- package/server/lib/assistants/harness.js +575 -0
- package/server/lib/assistants/harness.test.js +697 -0
- package/server/lib/assistants/pi-tools.js +118 -0
- package/server/lib/assistants/pi-tools.test.js +114 -0
- package/server/lib/assistants/routes.js +44 -4
- package/server/lib/assistants/service.js +930 -17
- package/server/lib/assistants/service.test.js +1163 -45
- package/server/lib/event-stream/DOCUMENTATION.md +12 -1
- package/server/lib/event-stream/directory-ws-bridge.js +11 -2
- package/server/lib/event-stream/global-ws-bridge.js +19 -3
- package/server/lib/event-stream/protocol.js +11 -1
- package/server/lib/event-stream/reasoning-projection.js +425 -0
- package/server/lib/event-stream/reasoning-projection.test.js +300 -0
- package/server/lib/event-stream/runtime.js +10 -0
- package/server/lib/event-stream/runtime.test.js +53 -0
- package/server/lib/llm/DOCUMENTATION.md +84 -0
- package/server/lib/llm/catalog.js +100 -0
- package/server/lib/llm/catalog.test.js +78 -0
- package/server/lib/llm/completions.js +151 -0
- package/server/lib/llm/completions.test.js +127 -0
- package/server/lib/llm/generate.js +570 -0
- package/server/lib/llm/generate.test.js +459 -0
- package/server/lib/llm/routes.js +47 -0
- package/server/lib/llm/temp-directory.js +37 -0
- package/server/lib/notifications/APNS.md +19 -13
- package/server/lib/notifications/DOCUMENTATION.md +4 -3
- package/server/lib/notifications/runtime.js +148 -37
- package/server/lib/notifications/runtime.test.js +100 -0
- package/server/lib/opencode/DOCUMENTATION.md +2 -1
- package/server/lib/opencode/feature-routes-runtime.js +30 -0
- package/server/lib/opencode/feature-routes-runtime.test.js +20 -0
- package/server/lib/opencode/proxy.js +103 -9
- package/server/lib/opencode/proxy.test.js +177 -1
- package/server/lib/opencode/settings-runtime.js +2 -2
- package/server/lib/relay/reasoning-projection.e2e.test.ts +529 -0
- package/server/lib/session-goal/DOCUMENTATION.md +8 -1
- package/server/lib/session-goal/runtime.js +20 -0
- package/server/lib/session-index/DOCUMENTATION.md +3 -1
- package/server/lib/session-index/service.js +10 -4
- package/server/lib/session-index/service.test.js +37 -0
- package/server/lib/session-title/runtime.js +1 -0
- package/server/lib/session-title/runtime.test.js +11 -0
- package/server/lib/session-turn-pages/DOCUMENTATION.md +2 -1
- package/server/lib/session-turn-pages/routes.js +22 -3
- package/server/lib/session-turn-pages/routes.test.js +43 -0
- package/server/lib/transcript-cache/DOCUMENTATION.md +2 -2
- package/server/lib/transcript-cache/routes.js +9 -2
- package/server/lib/transcript-cache/routes.test.js +32 -0
- package/dist/assets/AgentAvatar-Di8F4QJh.js +0 -1
- package/dist/assets/AssistantView-DSxVAAb_.js +0 -1
- package/dist/assets/ContextPanel-DgwEIdqR.js +0 -285
- package/dist/assets/DiagramView-BKQO61y8.js +0 -1
- package/dist/assets/FadeInOnReveal-CV2s6YN8.js +0 -1
- package/dist/assets/Icon-CLpn1mIz.js +0 -1
- package/dist/assets/MarkdownRendererImpl-CTgmghSh.js +0 -7
- package/dist/assets/MarkstreamRendererImpl-BKsc-avF.js +0 -1
- package/dist/assets/MarkstreamRendererImpl-CxeWpDCK.css +0 -1
- package/dist/assets/MobileDetailNavigation-BcA6rd8Q.js +0 -1
- package/dist/assets/MobileShareBridge-w6T2ZrsW.js +0 -4
- package/dist/assets/MobileSurface-CG1n7v0h.js +0 -2
- package/dist/assets/OnboardingScreen-D6rtE7rj.js +0 -1
- package/dist/assets/OverlayScrollbar-DlNZho3q.js +0 -2
- package/dist/assets/SettingsView-CiF3r83e.js +0 -25
- package/dist/assets/TerminalView-Ctx_Jt_b.js +0 -8
- package/dist/assets/ToolOutputDialog-Dv9_fUYl.js +0 -10
- package/dist/assets/appThemeRegistry-DnwUIQB9.js +0 -2
- package/dist/assets/clipboard-D35XSwID.js +0 -1
- package/dist/assets/desktop-CfLlmafK.js +0 -1
- package/dist/assets/desktopHosts-C1Ns3DUu.js +0 -1
- package/dist/assets/desktopRelayRestore-8pvI_dSc.js +0 -1
- package/dist/assets/fileQueries-DGHZqZHc.js +0 -1
- package/dist/assets/insertionBoundaries-hK4mQj4W.js +0 -4
- package/dist/assets/main-BmbZFoO4.js +0 -29
- package/dist/assets/main-BwNhn8ml.js +0 -2
- package/dist/assets/markdown-worker-y3ewaqAK.js +0 -4
- package/dist/assets/markdownTheme-SPe4jOwX.js +0 -2
- package/dist/assets/miniChat-BN89siC3.js +0 -2
- package/dist/assets/mobile-DE9rVo5D.js +0 -2
- package/dist/assets/multirun-BiEuyDq3.js +0 -4
- package/dist/assets/persistence-w-AiX2i4.js +0 -3
- package/dist/assets/platform-4eCmsdol.js +0 -2
- package/dist/assets/projectMeta-DSLKcH1a.js +0 -3
- package/dist/assets/radio-HD5AB2Qf.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-f_UxpFjv.js +0 -2
- package/dist/assets/renderMobileApp-BAhCai6d.js +0 -28
- package/dist/assets/runtime-switch-BkpTTbEi.js +0 -1
- package/dist/assets/runtimeConfig-qG_oNBoa.js +0 -2
- package/dist/assets/runtimeEndpointReset-BDeKD3EM.js +0 -4
- package/dist/assets/shikiHighlight-BphLZnac.js +0 -1
- package/dist/assets/sortable-tabs-strip-CNDgyX0B.js +0 -1
- package/dist/assets/src-DZt1nWf5.css +0 -2
- package/dist/assets/terminalApi-CGtOtc8I.js +0 -1
- package/dist/assets/url-KeihbE0d.js +0 -1
- package/dist/assets/useAppFontEffects--0dwVTSm.js +0 -7
- package/dist/assets/useAssistantUIStore-I0VWrB5V.js +0 -1
- package/dist/assets/useDesktopWindowControlsLayout-DsbeBLvv.js +0 -1
- package/dist/assets/useEffectiveDirectory-CHvvohcG.js +0 -1
- package/dist/assets/useFontPreferences-BLZ3y6mN.js +0 -1
- package/dist/assets/useMobileNavigationStore-peTDuC00.js +0 -412
- package/dist/assets/useSessionAutoCleanup-D_ZC9E5g.js +0 -1
- package/dist/assets/useUIStore-CNLmCoPU.js +0 -1
- package/dist/assets/useWorkerHighlightedLines-B1YzuMYR.js +0 -7
- package/dist/assets/utils-CTL16t2f.js +0 -1
|
@@ -10,9 +10,11 @@ This module contains the OpenChamber message-stream WebSocket protocol and runti
|
|
|
10
10
|
- `packages/web/server/lib/event-stream/directory-ws-bridge.js`: browser-facing per-directory WS bridge that owns one scoped upstream reader per connection.
|
|
11
11
|
- `packages/web/server/lib/event-stream/protocol.js`: path constants, SSE envelope parsing, and WebSocket frame serialization helpers.
|
|
12
12
|
- `packages/web/server/lib/event-stream/diff-summary.js`: pure outbound FileDiff helpers. L1 message/session `summary.diffs` keep a slim file list `{ file, status?, additions, deletions }` plus additive `diffCount`/`hasDiffs` (never patch/before/after/from/to). `session.diff` event frames still keep file/status/additions/deletions and drop patch bodies. `summarizeFileDiff(s)` remains for explicit L2-style file lists.
|
|
13
|
+
- `packages/web/server/lib/event-stream/reasoning-projection.js`: OpenChamber outbound reasoning projection (`includeReasoning=false`). Shared pure snapshot helpers + per-connection stateful stream filter for HTTP parts, WS send/replay, and SSE proxy parse-filter paths. Never mutates hub/replay objects; never forwards the query param to OpenCode.
|
|
13
14
|
- `packages/web/server/lib/event-stream/upstream-reader.js`: reusable upstream SSE reader with event-id tracking, stall recovery, and reconnect handling.
|
|
14
15
|
- `packages/web/server/lib/event-stream/runtime.js`: thin WebSocket server runtime for upgrade handling and path dispatch to the global/directory bridges.
|
|
15
16
|
- `packages/web/server/lib/event-stream/protocol.test.js`: unit tests for protocol helpers.
|
|
17
|
+
- `packages/web/server/lib/event-stream/reasoning-projection.test.js`: unit + 10k-delta performance guards for reasoning projection.
|
|
16
18
|
- `packages/web/server/lib/event-stream/upstream-reader.test.js`: unit tests for upstream SSE reader behavior.
|
|
17
19
|
- `packages/web/server/lib/event-stream/runtime.test.js`: unit tests for runtime-side broadcaster behavior.
|
|
18
20
|
|
|
@@ -26,7 +28,14 @@ The following APIs are exported by their owning modules. `event-stream/index.js`
|
|
|
26
28
|
- `MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS`: heartbeat interval for browser-facing WS connections.
|
|
27
29
|
- `parseSseEventEnvelope(block)`: parses an SSE block into `{ eventId, directory, payload }`.
|
|
28
30
|
- `sendMessageStreamWsFrame(socket, payload)`: serializes and sends a JSON WS frame.
|
|
29
|
-
- `sendMessageStreamWsEvent(socket, payload, options)`: sends an event frame with optional `eventId` and `
|
|
31
|
+
- `sendMessageStreamWsEvent(socket, payload, options)`: sends an event frame with optional `eventId`, `directory`, and per-connection `reasoningFilter`. Outbound `session.diff` keeps file/status/additions/deletions only (no full patch bodies). Message/session `summary.diffs` are L1-projected to a slim file list plus additive `diffCount`/`hasDiffs` (patch bodies never leave the host). When `reasoningFilter.projectEvent` returns `null`, the frame is dropped (still counts as a successful send so slow-client tracking is unchanged).
|
|
32
|
+
|
|
33
|
+
### Reasoning projection helpers (`reasoning-projection.js`)
|
|
34
|
+
Import from the module file (not the event-stream barrel). Public surface:
|
|
35
|
+
- `shouldIncludeReasoning` / `readIncludeReasoningQuery` / `readIncludeReasoningFromUrl` / `stripIncludeReasoningParam`
|
|
36
|
+
- `projectMessagesPayloadForReasoning` / `projectMessagesPayloadWithoutReasoning` — array / `{ records }` / `{ record }` / `{ parts }` only
|
|
37
|
+
- `createReasoningOutboundFilter` — per-connection stream filter (version-suffix-aware type match; preserves original `type` on kept events)
|
|
38
|
+
- `filterSseBlock` / `createSseBlockSplitter` — SSE disabled path (CR-safe chunk boundaries)
|
|
30
39
|
|
|
31
40
|
### Runtime helpers
|
|
32
41
|
- `createGlobalMessageStreamHub(...)`: creates a shared `/global/event` upstream SSE hub with event/status subscribers and bounded event-id replay.
|
|
@@ -45,6 +54,7 @@ The following APIs are exported by their owning modules. `event-stream/index.js`
|
|
|
45
54
|
- The web server creates one shared global message-stream hub. OpenCode watcher side effects and global WS clients subscribe to that hub, so there is one upstream `/global/event` SSE reader for both server-side processing and browser fan-out.
|
|
46
55
|
- The global hub keeps a bounded replay buffer keyed by SSE `eventId` so reconnecting browser clients can receive buffered events after their requested `Last-Event-ID`.
|
|
47
56
|
- Outbound FileDiff payloads are summarized before hub fan-out/replay and again at WS send, so Relay never carries full patch frames larger than 64KB.
|
|
57
|
+
- **Reasoning projection (`includeReasoning=false`)**: per HTTP request or WS/SSE connection. The shared hub and internal subscribers always see full upstream events. Filtering runs only at the browser send boundary (live fan-out **and** replay) and on the direct SSE proxy parse path. HTTP turn-page / reconcile / exact message / official `session.messages` list / transcript-cache reads apply the same part strip. `tokens.reasoning` totals stay. The query param is OpenChamber-only and is stripped before OpenCode upstream calls.
|
|
48
58
|
- Global WS reconnect protocol is **replay events → ready barrier**. On initial connect and on each first-ready for a client, the bridge synchronously sends any `replayAfter(lastEventId)` frames first, then emits `{ type: 'ready', scope: 'global' }`. Clients must not start HTTP compensation until they observe ready, so buffered history can merge first.
|
|
49
59
|
- Directory WS clients still attach one upstream `/event?directory=...` SSE reader per connection because directory streams are scoped.
|
|
50
60
|
- If an upstream SSE connection delays response headers or an attached stream stalls, the reader aborts that upstream fetch and reconnects upstream with `Last-Event-ID`, keeping the browser WS alive when recovery is fast.
|
|
@@ -66,6 +76,7 @@ The following APIs are exported by their owning modules. `event-stream/index.js`
|
|
|
66
76
|
|
|
67
77
|
## Testing
|
|
68
78
|
- Run `vitest run packages/web/server/lib/event-stream/protocol.test.js`
|
|
79
|
+
- Run `vitest run packages/web/server/lib/event-stream/reasoning-projection.test.js`
|
|
69
80
|
- Run `vitest run packages/web/server/lib/event-stream/upstream-reader.test.js`
|
|
70
81
|
- Run `vitest run packages/web/server/lib/event-stream/runtime.test.js`
|
|
71
82
|
- Run repo validation before finalizing: `bun run type-check`, `bun run lint`, `bun run build`
|
|
@@ -17,6 +17,7 @@ export function acceptDirectoryMessageStreamWsConnection({
|
|
|
17
17
|
socket,
|
|
18
18
|
requestedLastEventId,
|
|
19
19
|
requestedDirectory,
|
|
20
|
+
reasoningFilter = null,
|
|
20
21
|
buildOpenCodeUrl,
|
|
21
22
|
getOpenCodeAuthHeaders,
|
|
22
23
|
processForwardedEventPayload,
|
|
@@ -37,6 +38,7 @@ export function acceptDirectoryMessageStreamWsConnection({
|
|
|
37
38
|
controller.abort();
|
|
38
39
|
}
|
|
39
40
|
reader?.stop();
|
|
41
|
+
reasoningFilter?.dispose?.();
|
|
40
42
|
wsClients.delete(socket);
|
|
41
43
|
};
|
|
42
44
|
|
|
@@ -56,7 +58,10 @@ export function acceptDirectoryMessageStreamWsConnection({
|
|
|
56
58
|
return;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
sendMessageStreamWsEvent(socket, { type: 'openchamber:heartbeat', timestamp: Date.now() }, {
|
|
61
|
+
sendMessageStreamWsEvent(socket, { type: 'openchamber:heartbeat', timestamp: Date.now() }, {
|
|
62
|
+
directory: 'global',
|
|
63
|
+
reasoningFilter,
|
|
64
|
+
});
|
|
60
65
|
}, heartbeatIntervalMs);
|
|
61
66
|
|
|
62
67
|
socket.on('close', () => {
|
|
@@ -77,10 +82,14 @@ export function acceptDirectoryMessageStreamWsConnection({
|
|
|
77
82
|
sendMessageStreamWsEvent(socket, payload, {
|
|
78
83
|
directory,
|
|
79
84
|
eventId: typeof envelope?.eventId === 'string' && envelope.eventId.length > 0 ? envelope.eventId : undefined,
|
|
85
|
+
reasoningFilter,
|
|
80
86
|
});
|
|
81
87
|
|
|
82
88
|
processForwardedEventPayload(payload, (syntheticPayload) => {
|
|
83
|
-
sendMessageStreamWsEvent(socket, syntheticPayload, {
|
|
89
|
+
sendMessageStreamWsEvent(socket, syntheticPayload, {
|
|
90
|
+
directory: 'global',
|
|
91
|
+
reasoningFilter,
|
|
92
|
+
});
|
|
84
93
|
});
|
|
85
94
|
};
|
|
86
95
|
|
|
@@ -22,9 +22,13 @@ export function createGlobalMessageStreamWsBridge({
|
|
|
22
22
|
}) {
|
|
23
23
|
const clients = new Set();
|
|
24
24
|
const clientLastEventIds = new Map();
|
|
25
|
+
const clientReasoningFilters = new Map();
|
|
25
26
|
const readyClients = new Set();
|
|
26
27
|
|
|
27
28
|
const removeClient = (socket) => {
|
|
29
|
+
const filter = clientReasoningFilters.get(socket);
|
|
30
|
+
filter?.dispose?.();
|
|
31
|
+
clientReasoningFilters.delete(socket);
|
|
28
32
|
clients.delete(socket);
|
|
29
33
|
clientLastEventIds.delete(socket);
|
|
30
34
|
readyClients.delete(socket);
|
|
@@ -32,10 +36,12 @@ export function createGlobalMessageStreamWsBridge({
|
|
|
32
36
|
};
|
|
33
37
|
|
|
34
38
|
const replayEvents = (socket, requestedLastEventId) => {
|
|
39
|
+
const reasoningFilter = clientReasoningFilters.get(socket);
|
|
35
40
|
for (const entry of globalHub.replayAfter(requestedLastEventId)) {
|
|
36
41
|
const sent = sendMessageStreamWsEvent(socket, entry.payload, {
|
|
37
42
|
directory: entry.directory,
|
|
38
43
|
eventId: entry.eventId,
|
|
44
|
+
reasoningFilter,
|
|
39
45
|
});
|
|
40
46
|
if (!sent) {
|
|
41
47
|
removeClient(socket);
|
|
@@ -104,6 +110,7 @@ export function createGlobalMessageStreamWsBridge({
|
|
|
104
110
|
const sent = sendMessageStreamWsEvent(socket, payload, {
|
|
105
111
|
directory,
|
|
106
112
|
eventId,
|
|
113
|
+
reasoningFilter: clientReasoningFilters.get(socket),
|
|
107
114
|
});
|
|
108
115
|
if (!sent) {
|
|
109
116
|
removeClient(socket);
|
|
@@ -115,7 +122,10 @@ export function createGlobalMessageStreamWsBridge({
|
|
|
115
122
|
if (!readyClients.has(socket)) {
|
|
116
123
|
continue;
|
|
117
124
|
}
|
|
118
|
-
const sent = sendMessageStreamWsEvent(socket, syntheticPayload, {
|
|
125
|
+
const sent = sendMessageStreamWsEvent(socket, syntheticPayload, {
|
|
126
|
+
directory: 'global',
|
|
127
|
+
reasoningFilter: clientReasoningFilters.get(socket),
|
|
128
|
+
});
|
|
119
129
|
if (!sent) {
|
|
120
130
|
removeClient(socket);
|
|
121
131
|
}
|
|
@@ -168,7 +178,7 @@ export function createGlobalMessageStreamWsBridge({
|
|
|
168
178
|
}
|
|
169
179
|
});
|
|
170
180
|
|
|
171
|
-
const accept = (socket, { requestedLastEventId = '' } = {}) => {
|
|
181
|
+
const accept = (socket, { requestedLastEventId = '', reasoningFilter = null } = {}) => {
|
|
172
182
|
const pingInterval = setInterval(() => {
|
|
173
183
|
if (socket.readyState !== 1) {
|
|
174
184
|
return;
|
|
@@ -185,7 +195,10 @@ export function createGlobalMessageStreamWsBridge({
|
|
|
185
195
|
return;
|
|
186
196
|
}
|
|
187
197
|
|
|
188
|
-
sendMessageStreamWsEvent(socket, { type: 'openchamber:heartbeat', timestamp: Date.now() }, {
|
|
198
|
+
sendMessageStreamWsEvent(socket, { type: 'openchamber:heartbeat', timestamp: Date.now() }, {
|
|
199
|
+
directory: 'global',
|
|
200
|
+
reasoningFilter: clientReasoningFilters.get(socket),
|
|
201
|
+
});
|
|
189
202
|
}, heartbeatIntervalMs);
|
|
190
203
|
|
|
191
204
|
socket.on('close', () => {
|
|
@@ -201,6 +214,9 @@ export function createGlobalMessageStreamWsBridge({
|
|
|
201
214
|
|
|
202
215
|
clients.add(socket);
|
|
203
216
|
clientLastEventIds.set(socket, requestedLastEventId);
|
|
217
|
+
if (reasoningFilter) {
|
|
218
|
+
clientReasoningFilters.set(socket, reasoningFilter);
|
|
219
|
+
}
|
|
204
220
|
globalHub.start();
|
|
205
221
|
if (globalHub.isConnected()) {
|
|
206
222
|
markReady(socket, requestedLastEventId);
|
|
@@ -126,9 +126,19 @@ export function sendMessageStreamWsFrame(socket, payload) {
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
export function sendMessageStreamWsEvent(socket, payload, options = {}) {
|
|
129
|
+
// Optional per-connection reasoning projector (includeReasoning=false).
|
|
130
|
+
// null from projectEvent means drop this frame entirely.
|
|
131
|
+
let candidate = payload;
|
|
132
|
+
if (options.reasoningFilter && typeof options.reasoningFilter.projectEvent === 'function') {
|
|
133
|
+
candidate = options.reasoningFilter.projectEvent(payload);
|
|
134
|
+
if (candidate == null) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
129
139
|
// Summarize FileDiff bodies before fan-out so Relay/WS never carries full
|
|
130
140
|
// patch frames. Identity is preserved for non-diff and already-summary events.
|
|
131
|
-
const outbound = summarizeOutboundEventPayload(
|
|
141
|
+
const outbound = summarizeOutboundEventPayload(candidate);
|
|
132
142
|
return sendMessageStreamWsFrame(socket, {
|
|
133
143
|
type: 'event',
|
|
134
144
|
payload: outbound,
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenChamber outbound reasoning projection.
|
|
3
|
+
*
|
|
4
|
+
* `includeReasoning=false` (strict string only) strips reasoning from Host→client
|
|
5
|
+
* HTTP/SSE/WS payloads. Missing/other values keep current behavior. OpenChamber
|
|
6
|
+
* projection only — never forward the param to OpenCode. Hub/upstream stay full.
|
|
7
|
+
*
|
|
8
|
+
* Shared by HTTP snapshots and stateful stream filters:
|
|
9
|
+
* drop type=reasoning parts, their deltas, session.next.reasoning.* events.
|
|
10
|
+
* Keep tokens.reasoning totals, tools, text, status, permissions/errors.
|
|
11
|
+
* Never mutate hub/replay objects.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const REASONING_DELTA_FIELDS = new Set([
|
|
15
|
+
'reasoning',
|
|
16
|
+
'reasoning_content',
|
|
17
|
+
'reasoning_details',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/** Match UI opencode-event-normalizer: strip terminal `.N` version suffix for dispatch. */
|
|
21
|
+
const VERSIONED_TYPE = /^(.*)\.(\d+)$/;
|
|
22
|
+
|
|
23
|
+
const PART_TYPE_CACHE_LIMIT = 8192;
|
|
24
|
+
|
|
25
|
+
const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
26
|
+
|
|
27
|
+
/** @param {string} type */
|
|
28
|
+
const baseEventType = (type) => {
|
|
29
|
+
const match = VERSIONED_TYPE.exec(type);
|
|
30
|
+
return match?.[1] ?? type;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Strict: only string `'false'` disables. Missing / other keep include.
|
|
35
|
+
* @param {unknown} value
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
export function shouldIncludeReasoning(value) {
|
|
39
|
+
if (Array.isArray(value) && value.length > 0) return value[0] !== 'false';
|
|
40
|
+
return value !== 'false';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @param {unknown} query */
|
|
44
|
+
export function readIncludeReasoningQuery(query) {
|
|
45
|
+
if (!query || typeof query !== 'object') return true;
|
|
46
|
+
return shouldIncludeReasoning(/** @type {Record<string, unknown>} */ (query).includeReasoning);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @param {string} rawUrl */
|
|
50
|
+
export function readIncludeReasoningFromUrl(rawUrl) {
|
|
51
|
+
if (typeof rawUrl !== 'string' || !rawUrl.includes('includeReasoning=')) return true;
|
|
52
|
+
try {
|
|
53
|
+
return shouldIncludeReasoning(new URL(rawUrl, 'http://openchamber.local').searchParams.get('includeReasoning'));
|
|
54
|
+
} catch {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Strip OpenChamber-only `includeReasoning` before OpenCode upstream.
|
|
61
|
+
* @param {string} requestUrl
|
|
62
|
+
*/
|
|
63
|
+
export function stripIncludeReasoningParam(requestUrl) {
|
|
64
|
+
if (typeof requestUrl !== 'string' || !requestUrl.includes('includeReasoning=')) return requestUrl;
|
|
65
|
+
try {
|
|
66
|
+
const url = new URL(requestUrl, 'http://openchamber.local');
|
|
67
|
+
if (!url.searchParams.has('includeReasoning')) return requestUrl;
|
|
68
|
+
url.searchParams.delete('includeReasoning');
|
|
69
|
+
const search = url.searchParams.toString();
|
|
70
|
+
if (requestUrl.startsWith('http://') || requestUrl.startsWith('https://')) {
|
|
71
|
+
return `${url.origin}${url.pathname}${search ? `?${search}` : ''}${url.hash || ''}`;
|
|
72
|
+
}
|
|
73
|
+
return `${url.pathname}${search ? `?${search}` : ''}${url.hash || ''}`;
|
|
74
|
+
} catch {
|
|
75
|
+
return requestUrl;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @param {unknown} parts */
|
|
80
|
+
function projectParts(parts) {
|
|
81
|
+
if (!Array.isArray(parts) || parts.length === 0) return parts;
|
|
82
|
+
let changed = false;
|
|
83
|
+
const next = [];
|
|
84
|
+
for (const part of parts) {
|
|
85
|
+
if (isPlainObject(part) && part.type === 'reasoning') {
|
|
86
|
+
changed = true;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
next.push(part);
|
|
90
|
+
}
|
|
91
|
+
return changed ? next : parts;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* One message record: `{ info, parts }` (turn-page / exact / cache / session.messages).
|
|
96
|
+
* @param {unknown} record
|
|
97
|
+
*/
|
|
98
|
+
function projectRecord(record) {
|
|
99
|
+
if (!isPlainObject(record) || !Array.isArray(record.parts)) return record;
|
|
100
|
+
const parts = projectParts(record.parts);
|
|
101
|
+
return parts === record.parts ? record : { ...record, parts };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Real Host payload shapes only:
|
|
106
|
+
* - bare array of records
|
|
107
|
+
* - `{ records: [...] }` (turn-page / reconcile / cache session)
|
|
108
|
+
* - `{ record }` (cache message)
|
|
109
|
+
* - `{ info, parts }` (exact message)
|
|
110
|
+
*
|
|
111
|
+
* @param {unknown} payload
|
|
112
|
+
*/
|
|
113
|
+
export function projectMessagesPayloadWithoutReasoning(payload) {
|
|
114
|
+
if (Array.isArray(payload)) {
|
|
115
|
+
let changed = false;
|
|
116
|
+
const next = payload.map((entry) => {
|
|
117
|
+
const projected = projectRecord(entry);
|
|
118
|
+
if (projected !== entry) changed = true;
|
|
119
|
+
return projected;
|
|
120
|
+
});
|
|
121
|
+
return changed ? next : payload;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!isPlainObject(payload)) return payload;
|
|
125
|
+
|
|
126
|
+
let next = payload;
|
|
127
|
+
let changed = false;
|
|
128
|
+
|
|
129
|
+
if (Array.isArray(payload.records)) {
|
|
130
|
+
const records = projectMessagesPayloadWithoutReasoning(payload.records);
|
|
131
|
+
if (records !== payload.records) {
|
|
132
|
+
next = { ...next, records };
|
|
133
|
+
changed = true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (isPlainObject(payload.record)) {
|
|
138
|
+
const record = projectRecord(payload.record);
|
|
139
|
+
if (record !== payload.record) {
|
|
140
|
+
next = { ...next, record };
|
|
141
|
+
changed = true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Exact message / single record body: top-level parts.
|
|
146
|
+
if (Array.isArray(payload.parts)) {
|
|
147
|
+
const projected = projectRecord(next);
|
|
148
|
+
if (projected !== next) {
|
|
149
|
+
next = projected;
|
|
150
|
+
changed = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return changed ? next : payload;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {unknown} payload
|
|
159
|
+
* @param {boolean} includeReasoning
|
|
160
|
+
*/
|
|
161
|
+
export function projectMessagesPayloadForReasoning(payload, includeReasoning) {
|
|
162
|
+
if (includeReasoning) return payload;
|
|
163
|
+
return projectMessagesPayloadWithoutReasoning(payload);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const eventBodyKeys = /** @type {const} */ (['properties', 'data']);
|
|
167
|
+
|
|
168
|
+
/** @param {unknown} payload */
|
|
169
|
+
function eventBodyOf(payload) {
|
|
170
|
+
if (!isPlainObject(payload)) return null;
|
|
171
|
+
for (const key of eventBodyKeys) {
|
|
172
|
+
const body = payload[key];
|
|
173
|
+
if (isPlainObject(body)) return body;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Stateful per-connection outbound filter. Classification map is bounded.
|
|
180
|
+
* @returns {{ projectEvent: (payload: unknown) => unknown | null, knownPartCount: () => number, dispose: () => void }}
|
|
181
|
+
*/
|
|
182
|
+
export function createReasoningOutboundFilter() {
|
|
183
|
+
/** @type {Map<string, string>} */
|
|
184
|
+
const partTypes = new Map();
|
|
185
|
+
|
|
186
|
+
const partKey = (messageID, partID) => `${messageID}\0${partID}`;
|
|
187
|
+
|
|
188
|
+
const rememberPartType = (messageID, partID, type) => {
|
|
189
|
+
if (typeof messageID !== 'string' || !messageID) return;
|
|
190
|
+
if (typeof partID !== 'string' || !partID) return;
|
|
191
|
+
if (typeof type !== 'string' || !type) return;
|
|
192
|
+
const key = partKey(messageID, partID);
|
|
193
|
+
if (partTypes.size >= PART_TYPE_CACHE_LIMIT && !partTypes.has(key)) {
|
|
194
|
+
const oldest = partTypes.keys().next().value;
|
|
195
|
+
if (oldest !== undefined) partTypes.delete(oldest);
|
|
196
|
+
}
|
|
197
|
+
partTypes.set(key, type);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const forgetPart = (messageID, partID) => {
|
|
201
|
+
if (typeof messageID === 'string' && typeof partID === 'string') {
|
|
202
|
+
partTypes.delete(partKey(messageID, partID));
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const forgetMessage = (messageID) => {
|
|
207
|
+
if (typeof messageID !== 'string' || !messageID) return;
|
|
208
|
+
const prefix = `${messageID}\0`;
|
|
209
|
+
for (const key of Array.from(partTypes.keys())) {
|
|
210
|
+
if (key.startsWith(prefix)) partTypes.delete(key);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @param {unknown} payload
|
|
216
|
+
* @returns {unknown | null} null = drop; otherwise original or shallow-cloned payload (type string preserved)
|
|
217
|
+
*/
|
|
218
|
+
const projectEvent = (payload) => {
|
|
219
|
+
if (!isPlainObject(payload)) return payload;
|
|
220
|
+
|
|
221
|
+
const rawType = typeof payload.type === 'string' ? payload.type : '';
|
|
222
|
+
const type = baseEventType(rawType);
|
|
223
|
+
|
|
224
|
+
// session.next.reasoning.* (incl. versioned …reasoning.delta.1)
|
|
225
|
+
if (type.startsWith('session.next.reasoning.')) return null;
|
|
226
|
+
|
|
227
|
+
if (type === 'message.part.updated') {
|
|
228
|
+
const body = eventBodyOf(payload);
|
|
229
|
+
const part = body && isPlainObject(body.part) ? body.part : null;
|
|
230
|
+
if (part) {
|
|
231
|
+
const messageID =
|
|
232
|
+
(typeof part.messageID === 'string' && part.messageID)
|
|
233
|
+
|| (typeof body.messageID === 'string' && body.messageID)
|
|
234
|
+
|| '';
|
|
235
|
+
const partID = typeof part.id === 'string' ? part.id : '';
|
|
236
|
+
if (typeof part.type === 'string') rememberPartType(messageID, partID, part.type);
|
|
237
|
+
if (part.type === 'reasoning') return null;
|
|
238
|
+
}
|
|
239
|
+
return payload;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (type === 'message.part.delta') {
|
|
243
|
+
const body = eventBodyOf(payload);
|
|
244
|
+
if (!body) return null;
|
|
245
|
+
|
|
246
|
+
const messageID = typeof body.messageID === 'string' ? body.messageID : '';
|
|
247
|
+
const partID = typeof body.partID === 'string' ? body.partID : '';
|
|
248
|
+
const field = typeof body.field === 'string' ? body.field : '';
|
|
249
|
+
|
|
250
|
+
if (REASONING_DELTA_FIELDS.has(field)) return null;
|
|
251
|
+
|
|
252
|
+
const learned = messageID && partID ? partTypes.get(partKey(messageID, partID)) : undefined;
|
|
253
|
+
if (learned === 'reasoning' || learned === undefined) return null;
|
|
254
|
+
return payload;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (type === 'message.part.removed') {
|
|
258
|
+
const body = eventBodyOf(payload);
|
|
259
|
+
if (body) {
|
|
260
|
+
forgetPart(
|
|
261
|
+
typeof body.messageID === 'string' ? body.messageID : '',
|
|
262
|
+
typeof body.partID === 'string' ? body.partID : '',
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return payload;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (type === 'message.removed') {
|
|
269
|
+
const body = eventBodyOf(payload);
|
|
270
|
+
if (body) {
|
|
271
|
+
const messageID =
|
|
272
|
+
(typeof body.messageID === 'string' && body.messageID)
|
|
273
|
+
|| (isPlainObject(body.info) && typeof body.info.id === 'string' && body.info.id)
|
|
274
|
+
|| '';
|
|
275
|
+
forgetMessage(messageID);
|
|
276
|
+
}
|
|
277
|
+
return payload;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// message.updated / message.created may embed parts on properties or data.
|
|
281
|
+
if (type === 'message.updated' || type === 'message.created') {
|
|
282
|
+
let next = payload;
|
|
283
|
+
let changed = false;
|
|
284
|
+
for (const key of eventBodyKeys) {
|
|
285
|
+
const body = next[key];
|
|
286
|
+
if (!isPlainObject(body) || !Array.isArray(body.parts)) continue;
|
|
287
|
+
const parts = projectParts(body.parts);
|
|
288
|
+
if (parts === body.parts) continue;
|
|
289
|
+
next = { ...next, [key]: { ...body, parts } };
|
|
290
|
+
changed = true;
|
|
291
|
+
}
|
|
292
|
+
return changed ? next : payload;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return payload;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
projectEvent,
|
|
300
|
+
knownPartCount: () => partTypes.size,
|
|
301
|
+
dispose: () => {
|
|
302
|
+
partTypes.clear();
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Project SSE data JSON; preserve GlobalEvent `{ directory, payload }` wrap.
|
|
309
|
+
* @param {unknown} dataJson
|
|
310
|
+
* @param {{ projectEvent: (payload: unknown) => unknown | null }} filter
|
|
311
|
+
*/
|
|
312
|
+
function projectSseDataJson(dataJson, filter) {
|
|
313
|
+
if (!isPlainObject(dataJson)) {
|
|
314
|
+
return { action: 'keep', value: dataJson, changed: false };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (isPlainObject(dataJson.payload) && typeof dataJson.payload.type === 'string') {
|
|
318
|
+
const projected = filter.projectEvent(dataJson.payload);
|
|
319
|
+
if (projected == null) return { action: 'drop' };
|
|
320
|
+
if (projected === dataJson.payload) return { action: 'keep', value: dataJson, changed: false };
|
|
321
|
+
return { action: 'keep', value: { ...dataJson, payload: projected }, changed: true };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const projected = filter.projectEvent(dataJson);
|
|
325
|
+
if (projected == null) return { action: 'drop' };
|
|
326
|
+
if (projected === dataJson) return { action: 'keep', value: dataJson, changed: false };
|
|
327
|
+
return { action: 'keep', value: projected, changed: true };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Filter one SSE block (no trailing separator). null = drop.
|
|
332
|
+
* @param {string} block
|
|
333
|
+
* @param {{ projectEvent: (payload: unknown) => unknown | null }} filter
|
|
334
|
+
* @returns {string | null}
|
|
335
|
+
*/
|
|
336
|
+
export function filterSseBlock(block, filter) {
|
|
337
|
+
if (typeof block !== 'string' || block.trim().length === 0) return null;
|
|
338
|
+
if (!block.includes('data:')) return `${block}\n\n`;
|
|
339
|
+
|
|
340
|
+
const lines = block.split('\n');
|
|
341
|
+
const dataLines = [];
|
|
342
|
+
const otherLines = [];
|
|
343
|
+
for (const line of lines) {
|
|
344
|
+
if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^\s/, ''));
|
|
345
|
+
else otherLines.push(line);
|
|
346
|
+
}
|
|
347
|
+
if (dataLines.length === 0) return `${block}\n\n`;
|
|
348
|
+
|
|
349
|
+
const payloadText = dataLines.join('\n').trim();
|
|
350
|
+
if (!payloadText) return `${block}\n\n`;
|
|
351
|
+
|
|
352
|
+
let parsed;
|
|
353
|
+
try {
|
|
354
|
+
parsed = JSON.parse(payloadText);
|
|
355
|
+
} catch {
|
|
356
|
+
return `${block}\n\n`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const result = projectSseDataJson(parsed, filter);
|
|
360
|
+
if (result.action === 'drop') return null;
|
|
361
|
+
if (!result.changed) return `${block}\n\n`;
|
|
362
|
+
return [...otherLines, `data: ${JSON.stringify(result.value)}`, '', ''].join('\n');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Incremental SSE block splitter. Holds a trailing CR so `\r` + `\n` across
|
|
367
|
+
* chunks never invents a false blank line (which would split one event in two).
|
|
368
|
+
*/
|
|
369
|
+
export function createSseBlockSplitter() {
|
|
370
|
+
const decoder = new TextDecoder();
|
|
371
|
+
let buffer = '';
|
|
372
|
+
/** @type {string} trailing CR held until the next chunk clarifies `\r\n` vs lone `\r` */
|
|
373
|
+
let carryCr = '';
|
|
374
|
+
|
|
375
|
+
const appendNormalized = (text) => {
|
|
376
|
+
if (!text && !carryCr) return;
|
|
377
|
+
let input = carryCr + (text || '');
|
|
378
|
+
carryCr = '';
|
|
379
|
+
if (input.endsWith('\r')) {
|
|
380
|
+
carryCr = '\r';
|
|
381
|
+
input = input.slice(0, -1);
|
|
382
|
+
}
|
|
383
|
+
// Complete CRLF pairs first, then any remaining lone CR → LF.
|
|
384
|
+
buffer += input.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const drainBlocks = () => {
|
|
388
|
+
const blocks = [];
|
|
389
|
+
while (true) {
|
|
390
|
+
const idx = buffer.indexOf('\n\n');
|
|
391
|
+
if (idx === -1) break;
|
|
392
|
+
const block = buffer.slice(0, idx);
|
|
393
|
+
buffer = buffer.slice(idx + 2);
|
|
394
|
+
if (block.trim().length > 0) blocks.push(block);
|
|
395
|
+
}
|
|
396
|
+
return blocks;
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
/**
|
|
401
|
+
* @param {Uint8Array | string} chunk
|
|
402
|
+
* @returns {string[]}
|
|
403
|
+
*/
|
|
404
|
+
push(chunk) {
|
|
405
|
+
const text = typeof chunk === 'string'
|
|
406
|
+
? chunk
|
|
407
|
+
: decoder.decode(chunk, { stream: true });
|
|
408
|
+
if (!text) return [];
|
|
409
|
+
appendNormalized(text);
|
|
410
|
+
return drainBlocks();
|
|
411
|
+
},
|
|
412
|
+
/** @returns {string[]} */
|
|
413
|
+
finish() {
|
|
414
|
+
const tail = decoder.decode();
|
|
415
|
+
if (tail) appendNormalized(tail);
|
|
416
|
+
if (carryCr) {
|
|
417
|
+
buffer += '\n';
|
|
418
|
+
carryCr = '';
|
|
419
|
+
}
|
|
420
|
+
const remaining = buffer;
|
|
421
|
+
buffer = '';
|
|
422
|
+
return remaining.trim().length > 0 ? [remaining] : [];
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
}
|