@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 +18 -2
- package/dist/apis/spaces.d.ts +5 -1
- package/dist/apis/spaces.js +65 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/session-patch-reducer.d.ts +49 -0
- package/dist/session-patch-reducer.js +273 -0
- package/dist/types.d.ts +8 -0
- package/dist/websocket.d.ts +3 -0
- package/dist/websocket.js +107 -2
- package/package.json +2 -2
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
|
-
|
|
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.
|
|
125
|
+
- `turn.patch`
|
|
126
|
+
- `turn.progress` (legacy compatibility)
|
|
111
127
|
- `turn.final`
|
|
112
128
|
- `turn.error`
|
|
113
129
|
- `message.persisted`
|
package/dist/apis/spaces.d.ts
CHANGED
|
@@ -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;
|
package/dist/apis/spaces.js
CHANGED
|
@@ -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.
|
|
194
|
-
|
|
195
|
-
|
|
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;
|
package/dist/websocket.d.ts
CHANGED
|
@@ -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({
|
|
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.
|
|
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.
|
|
47
|
+
"@neta-art/cohub-protocol": "1.2.1"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"typescript": "^6.0.3"
|