@neta-art/cohub 1.2.0 → 1.2.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/README.md CHANGED
@@ -96,8 +96,23 @@ stop();
96
96
  You can also listen with business-oriented event names:
97
97
 
98
98
  ```ts
99
+ session.on("turn.patch", (event) => {
100
+ // Streaming state-machine patch: { turnId, seq, baseSeq, ops }.
101
+ // Consecutive append ops may be compacted to { v }.
102
+ console.log(event.payload.ops);
103
+ });
104
+
105
+ session.subscribe({
106
+ patchState: (result) => {
107
+ if (result.applied) {
108
+ console.log(result.state.contentBlocks);
109
+ }
110
+ },
111
+ });
112
+
99
113
  session.on("turn.final", (event) => {
100
- console.log(event.payload);
114
+ // Fired when an assistant_final message.persisted event arrives.
115
+ console.log(event.payload.message);
101
116
  });
102
117
 
103
118
  space.on("message.persisted", (event) => {
@@ -107,7 +122,8 @@ space.on("message.persisted", (event) => {
107
122
 
108
123
  Supported business event names:
109
124
 
110
- - `turn.progress`
125
+ - `turn.patch`
126
+ - `turn.progress` (legacy compatibility)
111
127
  - `turn.final`
112
128
  - `turn.error`
113
129
  - `message.persisted`
@@ -1,15 +1,18 @@
1
1
  import type { WebsocketClient, WebsocketEventPayload } from "../websocket.js";
2
2
  import type { HttpTransport, Fetch } from "../transport.js";
3
+ import { type SessionPatchApplyResult } from "../session-patch-reducer.js";
3
4
  import type { CheckpointRecord, ContentBlock, SessionMessageResponse, SessionMessagesPaginatedResponse, SessionMessagesResponse, SessionRecord, SpaceAccessPolicy, SpaceBootstrapSource, SpaceChannelBindingInput, SpaceCheckpointDetailResponse, SpaceCreateResponse, SpaceEnvInput, SpaceFsFileResponse, SpaceFsMoveInput, SpaceFsTreeResponse, SpaceFsUploadResponse, SpaceUsageResponse, SpaceFsWriteFileInput, SpaceMember, SpaceRecord, SpaceRole, SpaceSessionsResponse } from "../types.js";
4
5
  import { SpaceInvitationsApi } from "./invitations.js";
5
6
  export type SessionSubscriptionHandlers = {
7
+ patch?: (event: WebsocketEventPayload) => void;
8
+ patchState?: (result: SessionPatchApplyResult) => void;
6
9
  progress?: (event: WebsocketEventPayload) => void;
7
10
  final?: (event: WebsocketEventPayload) => void;
8
11
  error?: (event: WebsocketEventPayload) => void;
9
12
  persisted?: (event: WebsocketEventPayload) => void;
10
13
  event?: (event: WebsocketEventPayload) => void;
11
14
  };
12
- export type SessionEventName = "turn.progress" | "turn.final" | "turn.error" | "message.persisted";
15
+ export type SessionEventName = "turn.patch" | "turn.progress" | "turn.final" | "turn.error" | "message.persisted";
13
16
  export type SpaceEventName = SessionEventName | "event";
14
17
  type SessionSendMessageInput = {
15
18
  content: ContentBlock[];
@@ -86,6 +89,7 @@ declare class SessionRealtimeClient {
86
89
  private readonly websocketClient;
87
90
  private readonly spaceId;
88
91
  private readonly sessionId;
92
+ private readonly patchReducer;
89
93
  constructor(websocketClient: WebsocketClient | null, spaceId: string, sessionId: string);
90
94
  subscribe(handlers: SessionSubscriptionHandlers): () => void;
91
95
  on(type: SessionEventName, handler: (event: WebsocketEventPayload) => void): () => void;
@@ -1,12 +1,13 @@
1
1
  import { ensureRealtimeConnected } from "../realtime.js";
2
+ import { SessionPatchReducer, } from "../session-patch-reducer.js";
2
3
  import { SpaceInvitationsApi } from "./invitations.js";
3
4
  const DEFAULT_DEDUP_WINDOW_MS = 2000;
4
5
  const toSessionEventName = (type) => {
5
6
  switch (type) {
7
+ case "session.turn.patch":
8
+ return "turn.patch";
6
9
  case "session.turn.progress":
7
10
  return "turn.progress";
8
- case "session.turn.final":
9
- return "turn.final";
10
11
  case "session.turn.error":
11
12
  return "turn.error";
12
13
  case "session.message.persisted":
@@ -15,6 +16,24 @@ const toSessionEventName = (type) => {
15
16
  return null;
16
17
  }
17
18
  };
19
+ const isAssistantFinalPersistedEvent = (event) => {
20
+ if (event.type !== "session.message.persisted")
21
+ return false;
22
+ const message = event.payload.message;
23
+ if (!message || typeof message !== "object")
24
+ return false;
25
+ const record = message;
26
+ return record.role === "assistant" && record.meta?.messageKind === "assistant_final";
27
+ };
28
+ const getPersistedMessageTurnId = (event) => {
29
+ if (event.type !== "session.message.persisted")
30
+ return null;
31
+ const message = event.payload.message;
32
+ if (!message || typeof message !== "object")
33
+ return null;
34
+ const meta = message.meta;
35
+ return typeof meta?.turnId === "string" ? meta.turnId : null;
36
+ };
18
37
  export class SpacesApi {
19
38
  transport;
20
39
  constructor(transport) {
@@ -173,6 +192,7 @@ class SessionRealtimeClient {
173
192
  websocketClient;
174
193
  spaceId;
175
194
  sessionId;
195
+ patchReducer = new SessionPatchReducer();
176
196
  constructor(websocketClient, spaceId, sessionId) {
177
197
  this.websocketClient = websocketClient;
178
198
  this.spaceId = spaceId;
@@ -188,20 +208,56 @@ class SessionRealtimeClient {
188
208
  return;
189
209
  handlers.event?.(event);
190
210
  const eventName = toSessionEventName(event.type);
211
+ if (eventName === "turn.patch") {
212
+ handlers.patch?.(event);
213
+ if (event.type === "session.turn.patch") {
214
+ const payload = event.payload;
215
+ if (typeof payload.seq === "number" &&
216
+ typeof payload.baseSeq === "number" &&
217
+ Array.isArray(payload.ops)) {
218
+ handlers.patchState?.(this.patchReducer.applyPatch({
219
+ spaceId: this.spaceId,
220
+ sessionId: this.sessionId,
221
+ turnId: typeof payload.turnId === "string" ? payload.turnId : null,
222
+ seq: payload.seq,
223
+ baseSeq: payload.baseSeq,
224
+ ops: payload.ops,
225
+ anchorUserMessageId: typeof payload.anchorUserMessageId === "string"
226
+ ? payload.anchorUserMessageId
227
+ : null,
228
+ }));
229
+ }
230
+ }
231
+ }
191
232
  if (eventName === "turn.progress")
192
233
  handlers.progress?.(event);
193
- if (eventName === "turn.final")
194
- handlers.final?.(event);
195
- if (eventName === "turn.error")
234
+ if (eventName === "turn.error") {
235
+ this.patchReducer.fail({
236
+ spaceId: this.spaceId,
237
+ sessionId: this.sessionId,
238
+ });
196
239
  handlers.error?.(event);
240
+ }
197
241
  if (eventName === "message.persisted")
198
242
  handlers.persisted?.(event);
243
+ if (isAssistantFinalPersistedEvent(event)) {
244
+ this.patchReducer.complete({
245
+ spaceId: this.spaceId,
246
+ sessionId: this.sessionId,
247
+ turnId: getPersistedMessageTurnId(event),
248
+ });
249
+ handlers.final?.(event);
250
+ }
199
251
  });
200
252
  return () => unsubscribe();
201
253
  }
202
254
  on(type, handler) {
203
255
  return this.subscribe({
204
256
  event: (event) => {
257
+ if (type === "turn.final" && isAssistantFinalPersistedEvent(event)) {
258
+ handler(event);
259
+ return;
260
+ }
205
261
  if (toSessionEventName(event.type) === type)
206
262
  handler(event);
207
263
  },
@@ -299,6 +355,10 @@ export class SpaceEventsApi {
299
355
  handler(event);
300
356
  return;
301
357
  }
358
+ if (type === "turn.final" && isAssistantFinalPersistedEvent(event)) {
359
+ handler(event);
360
+ return;
361
+ }
302
362
  if (toSessionEventName(event.type) === type)
303
363
  handler(event);
304
364
  });
package/dist/index.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  export { CohubHttpClient, createHttpClient } from "./http.js";
2
2
  export { CohubClient, createCohubClient } from "./client.js";
3
3
  export { WebsocketClient, createWebsocketClient } from "./websocket.js";
4
+ export { SessionPatchReducer, createSessionPatchReducer } from "./session-patch-reducer.js";
4
5
  export { HttpError } from "./transport.js";
5
6
  export { COHUB_ENVIRONMENTS, normalizeBaseUrl, normalizeWebsocketUrl, resolveApiBaseUrl, resolveCohubEnvironment, resolveWebsocketUrl, } from "./environment.js";
6
7
  export type { CohubClientOptions, Fetch } from "./transport.js";
7
8
  export type { CohubEnvironment } from "./environment.js";
9
+ export type { SessionPatchApplyInput, SessionPatchApplyResult, SessionPatchState, SessionPatchStatus, } from "./session-patch-reducer.js";
8
10
  export * from "./types.js";
9
11
  export type { SessionEventName, SessionSubscriptionHandlers, SpaceChannelBindingRecord, SpaceEventName, WebSocketConnectionState } from "./apis/spaces.js";
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { CohubHttpClient, createHttpClient } from "./http.js";
2
2
  export { CohubClient, createCohubClient } from "./client.js";
3
3
  export { WebsocketClient, createWebsocketClient } from "./websocket.js";
4
+ export { SessionPatchReducer, createSessionPatchReducer } from "./session-patch-reducer.js";
4
5
  export { HttpError } from "./transport.js";
5
6
  export { COHUB_ENVIRONMENTS, normalizeBaseUrl, normalizeWebsocketUrl, resolveApiBaseUrl, resolveCohubEnvironment, resolveWebsocketUrl, } from "./environment.js";
6
7
  export * from "./types.js";
@@ -0,0 +1,49 @@
1
+ import type { ContentBlock } from "@neta-art/cohub-protocol/core";
2
+ import type { RealtimePatchOperation, SessionTurnPatchEvent } from "@neta-art/cohub-protocol/realtime";
3
+ export type SessionPatchStatus = "idle" | "pending" | "streaming" | "completed" | "failed";
4
+ export type SessionPatchState = {
5
+ spaceId: string | null;
6
+ sessionId: string;
7
+ status: SessionPatchStatus;
8
+ contentBlocks: ContentBlock[];
9
+ anchorUserMessageId: string | null;
10
+ patchSeq: number;
11
+ turnId: string | null;
12
+ appendPath: string | null;
13
+ };
14
+ export type SessionPatchApplyInput = {
15
+ spaceId?: string | null;
16
+ sessionId: string;
17
+ turnId?: string | null;
18
+ seq: number;
19
+ baseSeq: number;
20
+ ops: RealtimePatchOperation[];
21
+ anchorUserMessageId?: string | null;
22
+ };
23
+ export type SessionPatchApplyResult = {
24
+ applied: true;
25
+ state: SessionPatchState;
26
+ } | {
27
+ applied: false;
28
+ reason: "duplicate" | "version_mismatch" | "invalid";
29
+ state: SessionPatchState;
30
+ };
31
+ type SessionPatchKeyInput = {
32
+ spaceId?: string | null;
33
+ sessionId: string;
34
+ turnId?: string | null;
35
+ };
36
+ export declare class SessionPatchReducer {
37
+ private readonly states;
38
+ private key;
39
+ get(input: SessionPatchKeyInput): SessionPatchState;
40
+ start(input: SessionPatchKeyInput): SessionPatchState;
41
+ complete(input: SessionPatchKeyInput): SessionPatchState;
42
+ fail(input: SessionPatchKeyInput): SessionPatchState;
43
+ reset(input: SessionPatchKeyInput): void;
44
+ resetAll(): void;
45
+ applyEvent(event: SessionTurnPatchEvent): SessionPatchApplyResult;
46
+ applyPatch(input: SessionPatchApplyInput): SessionPatchApplyResult;
47
+ }
48
+ export declare const createSessionPatchReducer: () => SessionPatchReducer;
49
+ export {};
@@ -0,0 +1,273 @@
1
+ const blockTextPathPattern = /^\/message\/content\/blocks\/(\d+)\/(text|thinking)$/;
2
+ const blockPathPattern = /^\/message\/content\/blocks\/(\d+)$/;
3
+ const blockMetaPathPattern = /^\/message\/content\/blocks\/(\d+)\/_meta$/;
4
+ const blockSignaturePathPattern = /^\/message\/content\/blocks\/(\d+)\/signature$/;
5
+ const createIdleState = (input) => ({
6
+ spaceId: input.spaceId ?? null,
7
+ sessionId: input.sessionId,
8
+ status: "idle",
9
+ contentBlocks: [],
10
+ anchorUserMessageId: null,
11
+ patchSeq: 0,
12
+ turnId: null,
13
+ appendPath: null,
14
+ });
15
+ function cloneBlock(block) {
16
+ return structuredClone(block);
17
+ }
18
+ function getStreamIndex(block) {
19
+ const value = block._meta?.streamIndex;
20
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
21
+ }
22
+ function findBlockByStreamIndex(blocks, streamIndex) {
23
+ return blocks.findIndex((block) => getStreamIndex(block) === streamIndex);
24
+ }
25
+ function sortBlocksByStreamIndex(blocks) {
26
+ return [...blocks].sort((a, b) => {
27
+ const aIndex = getStreamIndex(a);
28
+ const bIndex = getStreamIndex(b);
29
+ if (aIndex == null && bIndex == null)
30
+ return 0;
31
+ if (aIndex == null)
32
+ return 1;
33
+ if (bIndex == null)
34
+ return -1;
35
+ return aIndex - bIndex;
36
+ });
37
+ }
38
+ function isContentBlock(value) {
39
+ return Boolean(value && typeof value === "object" && "type" in value);
40
+ }
41
+ function ensureTextLikeBlock(blocks, streamIndex, field) {
42
+ const existingIndex = findBlockByStreamIndex(blocks, streamIndex);
43
+ const existing = existingIndex >= 0 ? blocks[existingIndex] : undefined;
44
+ if (field === "text") {
45
+ if (existing?.type === "text")
46
+ return existing;
47
+ const block = {
48
+ type: "text",
49
+ text: "",
50
+ _meta: { streamIndex },
51
+ };
52
+ blocks.push(block);
53
+ return block;
54
+ }
55
+ if (existing?.type === "thinking")
56
+ return existing;
57
+ const block = {
58
+ type: "thinking",
59
+ thinking: "",
60
+ _meta: { streamIndex },
61
+ };
62
+ blocks.push(block);
63
+ return block;
64
+ }
65
+ function appendTextLikeValue(blocks, path, value) {
66
+ const match = path.match(blockTextPathPattern);
67
+ if (!match || typeof value !== "string")
68
+ return false;
69
+ const streamIndex = Number(match[1]);
70
+ const field = match[2];
71
+ const block = ensureTextLikeBlock(blocks, streamIndex, field);
72
+ if (field === "text" && block.type === "text") {
73
+ block.text += value;
74
+ }
75
+ if (field === "thinking" && block.type === "thinking") {
76
+ block.thinking += value;
77
+ }
78
+ return true;
79
+ }
80
+ function applyPatchOpsToBlocks(current, ops, initialAppendPath) {
81
+ const next = current.map(cloneBlock);
82
+ let anchorUserMessageId;
83
+ let appendPath = initialAppendPath;
84
+ let failed = false;
85
+ for (const op of ops) {
86
+ if (!op.o && !op.p) {
87
+ if (!appendPath || !appendTextLikeValue(next, appendPath, op.v)) {
88
+ failed = true;
89
+ break;
90
+ }
91
+ continue;
92
+ }
93
+ if (op.o === "merge" && op.p === "/message/metadata") {
94
+ const anchor = op.v.anchorUserMessageId;
95
+ if (typeof anchor === "string" && anchor.trim()) {
96
+ anchorUserMessageId = anchor;
97
+ }
98
+ continue;
99
+ }
100
+ if (op.o === "append") {
101
+ if (!appendTextLikeValue(next, op.p, op.v)) {
102
+ failed = true;
103
+ break;
104
+ }
105
+ appendPath = op.p;
106
+ continue;
107
+ }
108
+ if (op.o === "merge") {
109
+ const match = op.p.match(blockMetaPathPattern);
110
+ if (!match)
111
+ continue;
112
+ const streamIndex = Number(match[1]);
113
+ const blockIndex = findBlockByStreamIndex(next, streamIndex);
114
+ const block = blockIndex >= 0 ? next[blockIndex] : undefined;
115
+ if (!block)
116
+ continue;
117
+ block._meta = { ...(block._meta ?? {}), ...op.v };
118
+ continue;
119
+ }
120
+ if (op.o === "replace") {
121
+ const match = op.p.match(blockSignaturePathPattern);
122
+ if (match) {
123
+ if (typeof op.v !== "string")
124
+ continue;
125
+ const blockIndex = findBlockByStreamIndex(next, Number(match[1]));
126
+ const block = blockIndex >= 0 ? next[blockIndex] : undefined;
127
+ if (block?.type === "thinking")
128
+ block.signature = op.v;
129
+ continue;
130
+ }
131
+ }
132
+ if (op.o === "replace" || op.o === "add") {
133
+ const match = op.p.match(blockPathPattern);
134
+ if (!match || !isContentBlock(op.v))
135
+ continue;
136
+ const streamIndex = Number(match[1]);
137
+ const block = cloneBlock(op.v);
138
+ block._meta = { ...(block._meta ?? {}), streamIndex };
139
+ const blockIndex = findBlockByStreamIndex(next, streamIndex);
140
+ if (blockIndex >= 0) {
141
+ next[blockIndex] = block;
142
+ }
143
+ else {
144
+ next.push(block);
145
+ }
146
+ continue;
147
+ }
148
+ if (op.o === "remove") {
149
+ const match = op.p.match(blockPathPattern);
150
+ if (!match)
151
+ continue;
152
+ const blockIndex = findBlockByStreamIndex(next, Number(match[1]));
153
+ if (blockIndex >= 0)
154
+ next.splice(blockIndex, 1);
155
+ }
156
+ }
157
+ return {
158
+ failed,
159
+ contentBlocks: sortBlocksByStreamIndex(next),
160
+ anchorUserMessageId,
161
+ appendPath,
162
+ };
163
+ }
164
+ export class SessionPatchReducer {
165
+ states = new Map();
166
+ key(input) {
167
+ return `${input.spaceId ?? ""}:${input.sessionId}`;
168
+ }
169
+ get(input) {
170
+ const key = this.key(input);
171
+ return this.states.get(key) ?? createIdleState(input);
172
+ }
173
+ start(input) {
174
+ const state = {
175
+ ...this.get(input),
176
+ status: "pending",
177
+ contentBlocks: [],
178
+ anchorUserMessageId: null,
179
+ patchSeq: 0,
180
+ turnId: null,
181
+ appendPath: null,
182
+ };
183
+ this.states.set(this.key(input), state);
184
+ return state;
185
+ }
186
+ complete(input) {
187
+ const current = this.get(input);
188
+ const state = {
189
+ ...current,
190
+ turnId: input.turnId ?? current.turnId,
191
+ status: "completed",
192
+ contentBlocks: [],
193
+ anchorUserMessageId: null,
194
+ };
195
+ this.states.set(this.key(input), state);
196
+ return state;
197
+ }
198
+ fail(input) {
199
+ const current = this.get(input);
200
+ const state = {
201
+ ...current,
202
+ turnId: input.turnId ?? current.turnId,
203
+ status: "failed",
204
+ contentBlocks: [],
205
+ anchorUserMessageId: null,
206
+ };
207
+ this.states.set(this.key(input), state);
208
+ return state;
209
+ }
210
+ reset(input) {
211
+ this.states.delete(this.key(input));
212
+ }
213
+ resetAll() {
214
+ this.states.clear();
215
+ }
216
+ applyEvent(event) {
217
+ return this.applyPatch({
218
+ spaceId: event.spaceId,
219
+ sessionId: event.sessionId,
220
+ turnId: event.payload.turnId,
221
+ seq: event.payload.seq,
222
+ baseSeq: event.payload.baseSeq,
223
+ ops: event.payload.ops,
224
+ anchorUserMessageId: event.payload.anchorUserMessageId,
225
+ });
226
+ }
227
+ applyPatch(input) {
228
+ const current = this.get(input);
229
+ const currentTurnId = current.turnId;
230
+ const inputTurnId = input.turnId ?? null;
231
+ const isDifferentKnownTurn = Boolean(currentTurnId && inputTurnId && currentTurnId !== inputTurnId);
232
+ const isFreshKnownTurn = isDifferentKnownTurn && input.baseSeq === 0;
233
+ const currentSeq = isFreshKnownTurn ? 0 : current.patchSeq;
234
+ const isTerminalSameTurn = (current.status === "completed" || current.status === "failed") &&
235
+ Boolean(currentTurnId) &&
236
+ currentTurnId === inputTurnId;
237
+ if (isTerminalSameTurn) {
238
+ return { applied: false, reason: "duplicate", state: current };
239
+ }
240
+ if (isDifferentKnownTurn && !isFreshKnownTurn) {
241
+ return { applied: false, reason: "version_mismatch", state: current };
242
+ }
243
+ if (input.seq <= currentSeq) {
244
+ return { applied: false, reason: "duplicate", state: current };
245
+ }
246
+ if (input.baseSeq !== currentSeq) {
247
+ return { applied: false, reason: "version_mismatch", state: current };
248
+ }
249
+ const startingFresh = input.baseSeq === 0 || isFreshKnownTurn;
250
+ const baseBlocks = startingFresh ? [] : current.contentBlocks;
251
+ const patched = applyPatchOpsToBlocks(baseBlocks, input.ops, startingFresh ? null : current.appendPath);
252
+ if (patched.failed) {
253
+ return { applied: false, reason: "version_mismatch", state: current };
254
+ }
255
+ const next = {
256
+ ...current,
257
+ spaceId: input.spaceId ?? current.spaceId ?? null,
258
+ sessionId: input.sessionId,
259
+ status: "streaming",
260
+ contentBlocks: patched.contentBlocks,
261
+ anchorUserMessageId: patched.anchorUserMessageId ??
262
+ input.anchorUserMessageId ??
263
+ current.anchorUserMessageId ??
264
+ null,
265
+ patchSeq: input.seq,
266
+ turnId: input.turnId ?? current.turnId ?? null,
267
+ appendPath: patched.appendPath,
268
+ };
269
+ this.states.set(this.key(next), next);
270
+ return { applied: true, state: next };
271
+ }
272
+ }
273
+ export const createSessionPatchReducer = () => new SessionPatchReducer();
package/dist/types.d.ts CHANGED
@@ -252,6 +252,10 @@ export type SpaceUsageHourlyStat = {
252
252
  outputTokens: number;
253
253
  cacheReadTokens: number;
254
254
  cacheWriteTokens: number;
255
+ costInput: number;
256
+ costOutput: number;
257
+ costCacheRead: number;
258
+ costCacheWrite: number;
255
259
  costTotal: number;
256
260
  requestCount: number;
257
261
  successCount: number;
@@ -264,6 +268,10 @@ export type SpaceUsageSummary = {
264
268
  outputTokens: number;
265
269
  cacheReadTokens: number;
266
270
  cacheWriteTokens: number;
271
+ costInput: number;
272
+ costOutput: number;
273
+ costCacheRead: number;
274
+ costCacheWrite: number;
267
275
  costTotal: number;
268
276
  requestCount: number;
269
277
  successCount: number;
@@ -96,6 +96,7 @@ export declare class WebsocketClient {
96
96
  private awaitingPong;
97
97
  private lastPingRequestId;
98
98
  private pongDeadlineAt;
99
+ private readonly compactStreamContexts;
99
100
  state: WebsocketClientState;
100
101
  connectionId: string | null;
101
102
  private readonly listeners;
@@ -124,6 +125,8 @@ export declare class WebsocketClient {
124
125
  private resolveAuthWaiter;
125
126
  private rejectAuthWaiter;
126
127
  private handleMessage;
128
+ private rememberCompactStreamContext;
129
+ private handleCompactFrame;
127
130
  private startPingLoop;
128
131
  private stopPingLoop;
129
132
  private clearReconnectTimer;
package/dist/websocket.js CHANGED
@@ -1,4 +1,4 @@
1
- import { realtimeEnvelopeSchema, } from "@neta-art/cohub-protocol/realtime";
1
+ import { realtimeCompactFrameSchema, realtimeEnvelopeSchema, WS_COMPACT_STREAM_CAPABILITY, } from "@neta-art/cohub-protocol/realtime";
2
2
  import { resolveWebsocketUrl } from "./environment.js";
3
3
  const createEventMap = () => ({
4
4
  connecting: new Set(),
@@ -32,6 +32,28 @@ const isRetryableCloseCode = (code) => {
32
32
  return true;
33
33
  };
34
34
  const AUTH_CLOSE_REASON = "authentication failed";
35
+ const isRecord = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
36
+ const compactFrameToPatchOperation = (frame) => {
37
+ if (frame.t === "d")
38
+ return { v: frame.v };
39
+ if (frame.o === "remove")
40
+ return { o: "remove", p: frame.p };
41
+ if (frame.o === "merge") {
42
+ return isRecord(frame.v) ? { o: "merge", p: frame.p, v: frame.v } : null;
43
+ }
44
+ if (!("v" in frame))
45
+ return null;
46
+ switch (frame.o) {
47
+ case "append":
48
+ return { o: "append", p: frame.p, v: frame.v };
49
+ case "replace":
50
+ return { o: "replace", p: frame.p, v: frame.v };
51
+ case "add":
52
+ return { o: "add", p: frame.p, v: frame.v };
53
+ default:
54
+ return null;
55
+ }
56
+ };
35
57
  class WebsocketAuthError extends Error {
36
58
  constructor(message) {
37
59
  super(message);
@@ -59,6 +81,7 @@ export class WebsocketClient {
59
81
  awaitingPong = false;
60
82
  lastPingRequestId = null;
61
83
  pongDeadlineAt = 0;
84
+ compactStreamContexts = new Map();
62
85
  state = "idle";
63
86
  connectionId = null;
64
87
  listeners = createEventMap();
@@ -221,7 +244,10 @@ export class WebsocketClient {
221
244
  if (!token)
222
245
  throw new WebsocketAuthError("missing access token");
223
246
  const waiter = this.createAuthWaiter();
224
- this.send({ type: "auth", payload: { token } });
247
+ this.send({
248
+ type: "auth",
249
+ payload: { token, capabilities: [WS_COMPACT_STREAM_CAPABILITY] },
250
+ });
225
251
  await waiter.promise;
226
252
  }
227
253
  createAuthWaiter() {
@@ -256,12 +282,18 @@ export class WebsocketClient {
256
282
  this.emit("error", { error: new Error("invalid websocket payload"), recoverable: true });
257
283
  return;
258
284
  }
285
+ const compactResult = realtimeCompactFrameSchema.safeParse(parsed);
286
+ if (compactResult.success) {
287
+ this.handleCompactFrame(compactResult.data);
288
+ return;
289
+ }
259
290
  const result = realtimeEnvelopeSchema.safeParse(parsed);
260
291
  if (!result.success) {
261
292
  this.emit("error", { error: new Error("invalid realtime envelope"), recoverable: true });
262
293
  return;
263
294
  }
264
295
  const envelope = result.data;
296
+ this.rememberCompactStreamContext(envelope);
265
297
  switch (envelope.type) {
266
298
  case "system.ready": {
267
299
  const connectionId = typeof envelope.payload.connectionId === "string"
@@ -351,6 +383,79 @@ export class WebsocketClient {
351
383
  }
352
384
  }
353
385
  }
386
+ rememberCompactStreamContext(envelope) {
387
+ if (envelope.type === "session.turn.patch") {
388
+ const payload = envelope.payload;
389
+ const turnId = typeof payload.turnId === "string" ? payload.turnId : null;
390
+ const messageId = typeof payload.messageId === "string" ? payload.messageId : null;
391
+ const realtimeMeta = payload._rt && typeof payload._rt === "object"
392
+ ? payload._rt
393
+ : null;
394
+ const sid = typeof realtimeMeta?.sid === "string" && realtimeMeta.sid.trim()
395
+ ? realtimeMeta.sid
396
+ : turnId ?? messageId;
397
+ if (!sid)
398
+ return;
399
+ this.compactStreamContexts.set(sid, {
400
+ spaceId: envelope.spaceId ?? null,
401
+ sessionId: envelope.sessionId ?? null,
402
+ turnId,
403
+ messageId,
404
+ anchorUserMessageId: typeof payload.anchorUserMessageId === "string"
405
+ ? payload.anchorUserMessageId
406
+ : null,
407
+ });
408
+ return;
409
+ }
410
+ if (envelope.type !== "session.message.persisted")
411
+ return;
412
+ const message = envelope.payload.message;
413
+ if (!message || typeof message !== "object")
414
+ return;
415
+ const meta = message.meta;
416
+ const turnId = typeof meta?.turnId === "string" ? meta.turnId : null;
417
+ if (!turnId)
418
+ return;
419
+ for (const [sid, context] of this.compactStreamContexts.entries()) {
420
+ if (context.turnId === turnId)
421
+ this.compactStreamContexts.delete(sid);
422
+ }
423
+ }
424
+ handleCompactFrame(frame) {
425
+ const context = this.compactStreamContexts.get(frame.sid);
426
+ if (!context?.sessionId) {
427
+ this.emit("error", {
428
+ error: new Error(`unknown compact stream: ${frame.sid}`),
429
+ recoverable: true,
430
+ });
431
+ return;
432
+ }
433
+ const op = compactFrameToPatchOperation(frame);
434
+ if (!op) {
435
+ this.emit("error", {
436
+ error: new Error(`invalid compact stream frame: ${frame.sid}`),
437
+ recoverable: true,
438
+ });
439
+ return;
440
+ }
441
+ const envelope = {
442
+ id: `compact:${frame.sid}:${frame.s}`,
443
+ timestamp: Date.now(),
444
+ domain: "session",
445
+ type: "session.turn.patch",
446
+ spaceId: context.spaceId,
447
+ sessionId: context.sessionId,
448
+ payload: {
449
+ turnId: context.turnId,
450
+ messageId: context.messageId,
451
+ anchorUserMessageId: context.anchorUserMessageId,
452
+ seq: frame.s,
453
+ baseSeq: frame.b,
454
+ ops: [op],
455
+ },
456
+ };
457
+ this.emit("event", envelope);
458
+ }
354
459
  startPingLoop() {
355
460
  this.stopPingLoop();
356
461
  this.pingTimer = setInterval(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Cohub SDK for spaces, sessions, checkpoints, and realtime agent collaboration.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -44,7 +44,7 @@
44
44
  "README.md"
45
45
  ],
46
46
  "dependencies": {
47
- "@neta-art/cohub-protocol": "1.2.0"
47
+ "@neta-art/cohub-protocol": "1.2.1"
48
48
  },
49
49
  "devDependencies": {
50
50
  "typescript": "^6.0.3"