@swarm-agent/sdk 0.1.49 → 0.1.50
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/HEADLESS_UI.md +4 -6
- package/README.md +121 -56
- package/dist/agents.d.ts +114 -0
- package/dist/agents.js +183 -0
- package/dist/auth.d.ts +14 -0
- package/dist/auth.js +16 -0
- package/dist/box.d.ts +68 -0
- package/dist/box.js +13 -0
- package/dist/browser.d.ts +6 -2
- package/dist/browser.js +11 -3
- package/dist/chat.d.ts +41 -7
- package/dist/chat.js +148 -83
- package/dist/client.d.ts +12 -0
- package/dist/client.js +19 -6
- package/dist/copy-tags.d.ts +26 -0
- package/dist/copy-tags.js +133 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +21 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/local-connection.d.ts +39 -0
- package/dist/local-connection.js +56 -0
- package/dist/permission-ui.d.ts +1 -0
- package/dist/permission-ui.js +37 -0
- package/dist/permissions.d.ts +4 -0
- package/dist/permissions.js +7 -0
- package/dist/projects.d.ts +25 -3
- package/dist/projects.js +65 -6
- package/dist/provider-auth.d.ts +5 -3
- package/dist/provider-auth.js +6 -8
- package/dist/realtime.d.ts +40 -6
- package/dist/realtime.js +156 -52
- package/dist/remote.d.ts +59 -0
- package/dist/remote.js +46 -0
- package/dist/sessions.d.ts +31 -1
- package/dist/sessions.js +49 -1
- package/dist/settings.d.ts +3 -0
- package/dist/settings.js +19 -3
- package/dist/signals.d.ts +148 -0
- package/dist/signals.js +97 -0
- package/dist/types.d.ts +70 -2
- package/dist/websocket.d.ts +3 -1
- package/dist/websocket.js +1 -1
- package/dist/workspaces.d.ts +11 -0
- package/dist/workspaces.js +17 -0
- package/package.json +1 -1
package/dist/realtime.d.ts
CHANGED
|
@@ -155,6 +155,19 @@ export interface V3SessionView {
|
|
|
155
155
|
active_plan?: Record<string, unknown>;
|
|
156
156
|
[key: string]: unknown;
|
|
157
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* A resource the daemon left out of (or truncated in) a sync snapshot. With
|
|
160
|
+
* `history.manifest_policy` 'manifest' or 'omit' the recent window is still returned and
|
|
161
|
+
* `next_cursor` points at the older history that was left out.
|
|
162
|
+
*/
|
|
163
|
+
export interface V3SyncOmission {
|
|
164
|
+
session_id?: string;
|
|
165
|
+
resource?: 'messages' | 'events' | string;
|
|
166
|
+
reason?: 'requires_manifest' | string;
|
|
167
|
+
next_cursor?: string;
|
|
168
|
+
manifest_ref?: string;
|
|
169
|
+
[key: string]: unknown;
|
|
170
|
+
}
|
|
158
171
|
export interface V3SyncSnapshot {
|
|
159
172
|
ok: boolean;
|
|
160
173
|
rev: number;
|
|
@@ -169,11 +182,7 @@ export interface V3SyncSnapshot {
|
|
|
169
182
|
stream_path: string;
|
|
170
183
|
resume: V3Resume;
|
|
171
184
|
};
|
|
172
|
-
omissions:
|
|
173
|
-
session_id?: string;
|
|
174
|
-
reason?: string;
|
|
175
|
-
[key: string]: unknown;
|
|
176
|
-
}[];
|
|
185
|
+
omissions: V3SyncOmission[];
|
|
177
186
|
pagination: Record<string, unknown>;
|
|
178
187
|
[key: string]: unknown;
|
|
179
188
|
}
|
|
@@ -198,6 +207,13 @@ export interface SessionWatchState {
|
|
|
198
207
|
text: string;
|
|
199
208
|
}>;
|
|
200
209
|
status: 'syncing' | 'live' | 'reconnecting';
|
|
210
|
+
/**
|
|
211
|
+
* True when older history did not fit the hydrate window (for example after a very large
|
|
212
|
+
* tool result) and was left out. `messages`/events still hold the most recent window; the
|
|
213
|
+
* conversation stays usable. `historyOmissions` names each resource and its `next_cursor`.
|
|
214
|
+
*/
|
|
215
|
+
historyOmitted?: boolean;
|
|
216
|
+
historyOmissions?: V3SyncOmission[];
|
|
201
217
|
}
|
|
202
218
|
export interface SessionWatchOptions {
|
|
203
219
|
signal?: AbortSignal;
|
|
@@ -212,21 +228,39 @@ export interface SessionWatch {
|
|
|
212
228
|
done: Promise<void>;
|
|
213
229
|
dispose(): void;
|
|
214
230
|
}
|
|
215
|
-
/**
|
|
231
|
+
/**
|
|
232
|
+
* Transient assistant text assembled from live patches and durable delta checkpoints.
|
|
233
|
+
* UTF-8 byte offsets (not JavaScript string length) are the only placement authority:
|
|
234
|
+
* duplicate and overlapping ranges are trimmed, and a range beyond the contiguous prefix
|
|
235
|
+
* (joining or reconnecting mid-turn, while durable checkpoints lag live output) is held,
|
|
236
|
+
* bounded, until a durable checkpoint fills the gap. Text is never dropped, repeated or
|
|
237
|
+
* shown with a hole. A committed message retires its stream.
|
|
238
|
+
*/
|
|
216
239
|
export declare class V3LiveText {
|
|
217
240
|
private streams;
|
|
218
241
|
private committed;
|
|
219
242
|
reset(): void;
|
|
220
243
|
reconcile(messages: V3Message[]): void;
|
|
221
244
|
durable(events: V3Event[]): void;
|
|
245
|
+
/** Returns false only for a malformed patch; gaps and duplicates are handled here. */
|
|
222
246
|
accept(p: V3LivePatch): boolean;
|
|
223
247
|
values(): SessionWatchState['live'];
|
|
248
|
+
private commit;
|
|
249
|
+
private append;
|
|
224
250
|
}
|
|
225
251
|
export declare class SwarmRealtimeNamespace {
|
|
226
252
|
private readonly transport;
|
|
227
253
|
constructor(transport: SwarmTransport);
|
|
228
254
|
bootstrap(body: V3SyncRequest, signal?: AbortSignal): Promise<V3SyncSnapshot>;
|
|
229
255
|
hydrate(body: V3SyncRequest, signal?: AbortSignal): Promise<V3SyncSnapshot>;
|
|
256
|
+
/**
|
|
257
|
+
* Hydrates a watch window. The daemon's default manifest policy ('error') refuses the whole
|
|
258
|
+
* snapshot when the recent window cannot hold all history (e.g. one ~280 KB tool result
|
|
259
|
+
* exhausts the 256 KB message budget). Both 'omit' and 'manifest' still return the same
|
|
260
|
+
* newest window plus an omission with `next_cursor`; 'manifest' also returns a descriptor
|
|
261
|
+
* of what was included, so it loses the least. The policy then sticks for this watch.
|
|
262
|
+
*/
|
|
263
|
+
private hydrateWindow;
|
|
230
264
|
/** Bounded durable replay. Keep the identical selector/resources/surface for every cursor. */
|
|
231
265
|
replay(body: V3SyncRequest & {
|
|
232
266
|
endpoint_cursor: string;
|
package/dist/realtime.js
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
|
-
|
|
1
|
+
import { SwarmApiError } from './errors.js';
|
|
2
|
+
const LIVE_MAX_STREAM_BYTES = 4 * 1024 * 1024;
|
|
3
|
+
const LIVE_MAX_STREAMS = 64;
|
|
4
|
+
const LIVE_MAX_COMMITTED = 4096;
|
|
5
|
+
const utf8Encoder = new TextEncoder();
|
|
6
|
+
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
|
7
|
+
const DURABLE_TEXT_DELTA_EVENTS = new Set(['session.assistant.delta', 'session.message.delta']);
|
|
8
|
+
/**
|
|
9
|
+
* Transient assistant text assembled from live patches and durable delta checkpoints.
|
|
10
|
+
* UTF-8 byte offsets (not JavaScript string length) are the only placement authority:
|
|
11
|
+
* duplicate and overlapping ranges are trimmed, and a range beyond the contiguous prefix
|
|
12
|
+
* (joining or reconnecting mid-turn, while durable checkpoints lag live output) is held,
|
|
13
|
+
* bounded, until a durable checkpoint fills the gap. Text is never dropped, repeated or
|
|
14
|
+
* shown with a hole. A committed message retires its stream.
|
|
15
|
+
*/
|
|
2
16
|
export class V3LiveText {
|
|
3
17
|
streams = new Map();
|
|
4
18
|
committed = new Set();
|
|
@@ -7,71 +21,104 @@ export class V3LiveText {
|
|
|
7
21
|
for (const m of messages) {
|
|
8
22
|
if (m.role !== 'assistant')
|
|
9
23
|
continue;
|
|
10
|
-
|
|
11
|
-
const stream = m.metadata?.stream_id;
|
|
12
|
-
if (typeof run === 'string' && typeof stream === 'string') {
|
|
13
|
-
const key = JSON.stringify([run, stream]);
|
|
14
|
-
if (!this.committed.has(key) && this.committed.size >= 4096)
|
|
15
|
-
throw new Error('Live stream history limit reached; reconnect required');
|
|
16
|
-
this.streams.delete(key);
|
|
17
|
-
this.committed.add(key);
|
|
18
|
-
}
|
|
24
|
+
this.commit(m.metadata?.run_id ?? m.metadata?.runId, m.metadata?.stream_id);
|
|
19
25
|
}
|
|
20
26
|
}
|
|
21
27
|
durable(events) {
|
|
22
28
|
for (const event of events) {
|
|
23
|
-
const p = event.payload;
|
|
29
|
+
const p = event.payload ?? {};
|
|
24
30
|
if (event.event_type === 'session.assistant.completed') {
|
|
25
31
|
const message = p.message;
|
|
26
32
|
if (message?.role === 'assistant')
|
|
27
33
|
this.reconcile([message]);
|
|
28
|
-
|
|
29
|
-
const stream = p.stream_id ?? message?.metadata?.stream_id;
|
|
30
|
-
if (typeof run === 'string' && typeof stream === 'string') {
|
|
31
|
-
const key = JSON.stringify([run, stream]);
|
|
32
|
-
if (!this.committed.has(key) && this.committed.size >= 4096)
|
|
33
|
-
throw new Error('Live history limit; reconnect required');
|
|
34
|
-
this.streams.delete(key);
|
|
35
|
-
this.committed.add(key);
|
|
36
|
-
}
|
|
34
|
+
this.commit(p.run_id ?? message?.metadata?.run_id, p.stream_id ?? message?.metadata?.stream_id);
|
|
37
35
|
continue;
|
|
38
36
|
}
|
|
39
|
-
if (!
|
|
37
|
+
if (!DURABLE_TEXT_DELTA_EVENTS.has(event.event_type))
|
|
40
38
|
continue;
|
|
41
|
-
const run = p.run_id, stream = p.stream_id, start = p.offset_start, end = p.offset_end;
|
|
42
39
|
const text = p.delta ?? p.text_delta ?? p.content_delta;
|
|
43
|
-
if (typeof
|
|
44
|
-
|
|
45
|
-
const key = JSON.stringify([run, stream]);
|
|
46
|
-
const old = this.streams.get(key);
|
|
47
|
-
if (this.committed.has(key) || end <= (old?.offset ?? 0))
|
|
48
|
-
continue;
|
|
49
|
-
const offset = old?.offset ?? 0;
|
|
50
|
-
const bytes = new TextEncoder().encode(text);
|
|
51
|
-
if (start < 0 || start > offset || end !== start + bytes.length || end > 4 * 1024 * 1024 || (!old && this.streams.size >= 64))
|
|
40
|
+
if (typeof p.run_id !== 'string' || typeof p.stream_id !== 'string' || typeof p.offset_start !== 'number' ||
|
|
41
|
+
typeof p.offset_end !== 'number' || typeof text !== 'string')
|
|
52
42
|
continue;
|
|
53
|
-
|
|
54
|
-
this.streams.set(key, { runId: run, streamId: stream, text: (old?.text ?? '') + suffix, seq: 0, offset: end });
|
|
43
|
+
this.append(p.run_id, p.stream_id, p.offset_start, p.offset_end, text);
|
|
55
44
|
}
|
|
56
45
|
}
|
|
46
|
+
/** Returns false only for a malformed patch; gaps and duplicates are handled here. */
|
|
57
47
|
accept(p) {
|
|
58
48
|
if (p.stream_kind !== 'assistant_text')
|
|
59
49
|
return true;
|
|
60
|
-
|
|
50
|
+
if (p.operation !== 'append' || typeof p.text !== 'string')
|
|
51
|
+
return false;
|
|
52
|
+
return this.append(p.run_id, p.stream_id, p.offset_start, p.offset_end, p.text);
|
|
53
|
+
}
|
|
54
|
+
values() {
|
|
55
|
+
return [...this.streams.values()].filter(s => s.text).map(({ runId, streamId, text }) => ({ runId, streamId, text }));
|
|
56
|
+
}
|
|
57
|
+
commit(run, stream) {
|
|
58
|
+
if (typeof run !== 'string' || typeof stream !== 'string')
|
|
59
|
+
return;
|
|
60
|
+
const key = JSON.stringify([run, stream]);
|
|
61
|
+
if (!this.committed.has(key) && this.committed.size >= LIVE_MAX_COMMITTED)
|
|
62
|
+
throw new Error('Live stream history limit reached; reconnect required');
|
|
63
|
+
this.streams.delete(key);
|
|
64
|
+
this.committed.add(key);
|
|
65
|
+
}
|
|
66
|
+
append(runId, streamId, start, end, text) {
|
|
67
|
+
const key = JSON.stringify([runId, streamId]);
|
|
61
68
|
if (this.committed.has(key))
|
|
62
69
|
return true;
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
70
|
+
const bytes = utf8Encoder.encode(text);
|
|
71
|
+
if (!Number.isSafeInteger(start) || start < 0 || end !== start + bytes.length || end > LIVE_MAX_STREAM_BYTES)
|
|
72
|
+
return false;
|
|
73
|
+
let stream = this.streams.get(key);
|
|
74
|
+
if (!stream) {
|
|
75
|
+
if (this.streams.size >= LIVE_MAX_STREAMS)
|
|
76
|
+
return false;
|
|
77
|
+
stream = { runId, streamId, text: '', offset: 0 };
|
|
78
|
+
this.streams.set(key, stream);
|
|
79
|
+
}
|
|
80
|
+
if (end <= stream.offset)
|
|
81
|
+
return true;
|
|
82
|
+
if (start > stream.offset) {
|
|
83
|
+
holdLiveRange(stream, start, bytes);
|
|
65
84
|
return true;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
p.offset_end !== p.offset_start + new TextEncoder().encode(p.text).length ||
|
|
69
|
-
p.offset_end > 4 * 1024 * 1024 || (!old && this.streams.size >= 64))
|
|
85
|
+
}
|
|
86
|
+
if (!extendLiveStream(stream, bytes.subarray(stream.offset - start)))
|
|
70
87
|
return false;
|
|
71
|
-
|
|
88
|
+
const held = stream.held;
|
|
89
|
+
if (held && held.start <= stream.offset) {
|
|
90
|
+
stream.held = undefined;
|
|
91
|
+
if (held.start + held.bytes.length > stream.offset && !extendLiveStream(stream, held.bytes.subarray(stream.offset - held.start)))
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
72
94
|
return true;
|
|
73
95
|
}
|
|
74
|
-
|
|
96
|
+
}
|
|
97
|
+
function extendLiveStream(stream, suffix) {
|
|
98
|
+
try {
|
|
99
|
+
stream.text += utf8Decoder.decode(suffix);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
stream.offset += suffix.length;
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
/** Keeps one contiguous range ahead of the prefix; adjoining ranges merge, a later disjoint one replaces it. */
|
|
108
|
+
function holdLiveRange(stream, start, bytes) {
|
|
109
|
+
const held = stream.held;
|
|
110
|
+
const end = start + bytes.length;
|
|
111
|
+
if (held && start <= held.start + held.bytes.length && end >= held.start) {
|
|
112
|
+
const from = Math.min(start, held.start);
|
|
113
|
+
const to = Math.max(end, held.start + held.bytes.length);
|
|
114
|
+
const merged = new Uint8Array(to - from);
|
|
115
|
+
merged.set(held.bytes, held.start - from);
|
|
116
|
+
merged.set(bytes, start - from);
|
|
117
|
+
stream.held = { start: from, bytes: merged };
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!held || end > held.start + held.bytes.length)
|
|
121
|
+
stream.held = { start, bytes: bytes.slice() };
|
|
75
122
|
}
|
|
76
123
|
export class SwarmRealtimeNamespace {
|
|
77
124
|
transport;
|
|
@@ -84,6 +131,24 @@ export class SwarmRealtimeNamespace {
|
|
|
84
131
|
async hydrate(body, signal) {
|
|
85
132
|
return (await this.transport.request('/v3/sync/hydrate', { method: 'POST', body, signal })).data;
|
|
86
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Hydrates a watch window. The daemon's default manifest policy ('error') refuses the whole
|
|
136
|
+
* snapshot when the recent window cannot hold all history (e.g. one ~280 KB tool result
|
|
137
|
+
* exhausts the 256 KB message budget). Both 'omit' and 'manifest' still return the same
|
|
138
|
+
* newest window plus an omission with `next_cursor`; 'manifest' also returns a descriptor
|
|
139
|
+
* of what was included, so it loses the least. The policy then sticks for this watch.
|
|
140
|
+
*/
|
|
141
|
+
async hydrateWindow(body, signal) {
|
|
142
|
+
try {
|
|
143
|
+
return await this.hydrate(body, signal);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (body.history?.manifest_policy || !isRequiresManifestError(error))
|
|
147
|
+
throw error;
|
|
148
|
+
body.history = { ...body.history, manifest_policy: 'manifest' };
|
|
149
|
+
return this.hydrate(body, signal);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
87
152
|
/** Bounded durable replay. Keep the identical selector/resources/surface for every cursor. */
|
|
88
153
|
async replay(body, signal) {
|
|
89
154
|
return (await this.transport.request('/v3/sync/stream', { method: 'POST', body, signal })).data;
|
|
@@ -140,16 +205,19 @@ export class SwarmRealtimeNamespace {
|
|
|
140
205
|
const body = { surface, session_ids: [id], selector: { kind: 'session_ids', session_ids: [id] },
|
|
141
206
|
history: { mode: 'tail', max_messages_per_session: 200, max_events_per_session: 200, include_events: true },
|
|
142
207
|
resources: { messages: true, events: true, current_run_state: true, session_view: true } };
|
|
208
|
+
// The budget bounds consecutive failures only; every replayed connection restores it.
|
|
143
209
|
let attempts = 0;
|
|
210
|
+
let latest;
|
|
211
|
+
const onChange = (state) => { latest = state; options.onChange(state); };
|
|
144
212
|
while (!signal.aborted) {
|
|
145
213
|
// Every reconnect hydrates fresh durable authority before replay; no timer polling.
|
|
146
|
-
const snapshot = await this.
|
|
214
|
+
const snapshot = await this.hydrateWindow(body, signal);
|
|
147
215
|
if (signal.aborted)
|
|
148
216
|
return;
|
|
149
217
|
if (!snapshot.sessions_by_id[id] || !snapshot.realtime?.resume.endpoint_cursor)
|
|
150
218
|
throw new Error('Session omitted or realtime bootstrap unavailable');
|
|
151
219
|
try {
|
|
152
|
-
await this.connection(id, body, snapshot, options, signal, ready);
|
|
220
|
+
await this.connection(id, body, snapshot, latest?.snapshot.events_by_session[id] ?? [], { ...options, onChange }, signal, ready, () => { attempts = 0; });
|
|
153
221
|
return;
|
|
154
222
|
}
|
|
155
223
|
catch (error) {
|
|
@@ -157,12 +225,13 @@ export class SwarmRealtimeNamespace {
|
|
|
157
225
|
return;
|
|
158
226
|
if (error instanceof FatalRealtimeError || attempts >= (options.maxReconnects ?? 6))
|
|
159
227
|
throw error;
|
|
160
|
-
|
|
228
|
+
// Keep the last rendered conversation (including partial text) while reconnecting.
|
|
229
|
+
onChange({ ...(latest ?? { sessionId: id, snapshot, messages: snapshot.messages_by_session[id] ?? [], live: [], ...historyOmissionState(snapshot, id) }), status: 'reconnecting' });
|
|
161
230
|
await delay(Math.min(250 * 2 ** attempts++, 8000), signal);
|
|
162
231
|
}
|
|
163
232
|
}
|
|
164
233
|
}
|
|
165
|
-
async connection(id, body, initial, options, signal, ready) {
|
|
234
|
+
async connection(id, body, initial, seenEvents, options, signal, ready, onReplayed) {
|
|
166
235
|
const socket = await (options.socketFactory ?? ((path) => this.socket(path)))(`/v3/realtime/stream?${new URLSearchParams({ surface: body.surface })}`);
|
|
167
236
|
if (signal.aborted) {
|
|
168
237
|
socket.close();
|
|
@@ -179,13 +248,20 @@ export class SwarmRealtimeNamespace {
|
|
|
179
248
|
let dirty = false;
|
|
180
249
|
let readyPending = false;
|
|
181
250
|
let lastSeq = snapshot.projections_by_session[id]?.last_event_seq ?? 0;
|
|
251
|
+
// Durable events delivered on the socket are merged into the hydrated tail, so a
|
|
252
|
+
// consumer sees every event once and immediately, even before the next hydration.
|
|
253
|
+
let events = mergeSessionEvents(seenEvents, snapshot.events_by_session[id] ?? []);
|
|
182
254
|
const live = new V3LiveText();
|
|
183
255
|
live.reconcile(snapshot.messages_by_session[id] ?? []);
|
|
184
|
-
live.durable(
|
|
256
|
+
live.durable(events);
|
|
185
257
|
const subscription = `sdk:session:${id}`;
|
|
186
258
|
const timer = setTimeout(() => finish(new Error('Realtime handshake timed out')), 30_000);
|
|
187
|
-
const emit = () => {
|
|
188
|
-
|
|
259
|
+
const emit = () => {
|
|
260
|
+
if (ended)
|
|
261
|
+
return;
|
|
262
|
+
const view = { ...snapshot, events_by_session: { ...snapshot.events_by_session, [id]: events } };
|
|
263
|
+
options.onChange({ sessionId: id, snapshot: view, messages: snapshot.messages_by_session[id] ?? [], live: live.values(), status: replayed ? 'live' : 'syncing', ...historyOmissionState(snapshot, id) });
|
|
264
|
+
};
|
|
189
265
|
const finish = (error) => {
|
|
190
266
|
if (ended)
|
|
191
267
|
return;
|
|
@@ -208,13 +284,14 @@ export class SwarmRealtimeNamespace {
|
|
|
208
284
|
try {
|
|
209
285
|
while (dirty && !ended) {
|
|
210
286
|
dirty = false;
|
|
211
|
-
const next = await this.
|
|
287
|
+
const next = await this.hydrateWindow(body, requests.signal);
|
|
212
288
|
if (ended)
|
|
213
289
|
return;
|
|
214
290
|
if (!next.sessions_by_id[id])
|
|
215
291
|
throw new FatalRealtimeError('Session no longer available');
|
|
216
292
|
snapshot = next;
|
|
217
293
|
lastSeq = Math.max(lastSeq, next.projections_by_session[id]?.last_event_seq ?? 0);
|
|
294
|
+
events = mergeSessionEvents(events, next.events_by_session[id] ?? []);
|
|
218
295
|
live.reconcile(next.messages_by_session[id] ?? []);
|
|
219
296
|
live.durable(next.events_by_session[id] ?? []);
|
|
220
297
|
emit();
|
|
@@ -267,6 +344,7 @@ export class SwarmRealtimeNamespace {
|
|
|
267
344
|
return;
|
|
268
345
|
if (frame.kind === 'replay.complete') {
|
|
269
346
|
replayed = true;
|
|
347
|
+
onReplayed();
|
|
270
348
|
clearTimeout(timer);
|
|
271
349
|
readyPending = true;
|
|
272
350
|
void refresh();
|
|
@@ -275,7 +353,7 @@ export class SwarmRealtimeNamespace {
|
|
|
275
353
|
if (!liveNegotiated || !replayed)
|
|
276
354
|
return;
|
|
277
355
|
if (!live.accept(frame.live)) {
|
|
278
|
-
finish(new Error('
|
|
356
|
+
finish(new Error('Malformed live patch; durable rehydration required'));
|
|
279
357
|
}
|
|
280
358
|
else
|
|
281
359
|
emit();
|
|
@@ -284,9 +362,13 @@ export class SwarmRealtimeNamespace {
|
|
|
284
362
|
if (frame.event.seq <= lastSeq)
|
|
285
363
|
return;
|
|
286
364
|
lastSeq = frame.event.seq;
|
|
365
|
+
events = mergeSessionEvents(events, [frame.event]);
|
|
287
366
|
live.durable([frame.event]);
|
|
288
367
|
emit();
|
|
289
|
-
|
|
368
|
+
// Streaming checkpoints change no hydrated authority (messages, run state,
|
|
369
|
+
// permissions); re-hydrating on each one would only add load.
|
|
370
|
+
if (!String(frame.event.event_type).endsWith('.delta'))
|
|
371
|
+
void refresh();
|
|
290
372
|
}
|
|
291
373
|
else if (frame.kind === 'projection.high_watermark') {
|
|
292
374
|
void refresh();
|
|
@@ -299,6 +381,20 @@ export class SwarmRealtimeNamespace {
|
|
|
299
381
|
});
|
|
300
382
|
}
|
|
301
383
|
}
|
|
384
|
+
const MAX_WATCH_EVENTS = 1000;
|
|
385
|
+
/** Union by durable sequence, ascending, bounded to the newest events. */
|
|
386
|
+
function mergeSessionEvents(current, incoming) {
|
|
387
|
+
if (!incoming.length)
|
|
388
|
+
return current;
|
|
389
|
+
const bySeq = new Map();
|
|
390
|
+
for (const event of current)
|
|
391
|
+
bySeq.set(event.seq, event);
|
|
392
|
+
for (const event of incoming)
|
|
393
|
+
if (event && Number.isSafeInteger(event.seq) && !bySeq.has(event.seq))
|
|
394
|
+
bySeq.set(event.seq, event);
|
|
395
|
+
const merged = [...bySeq.values()].sort((a, b) => a.seq - b.seq);
|
|
396
|
+
return merged.length > MAX_WATCH_EVENTS ? merged.slice(merged.length - MAX_WATCH_EVENTS) : merged;
|
|
397
|
+
}
|
|
302
398
|
class FatalRealtimeError extends Error {
|
|
303
399
|
}
|
|
304
400
|
function delay(ms, signal) {
|
|
@@ -310,3 +406,11 @@ function delay(ms, signal) {
|
|
|
310
406
|
finish();
|
|
311
407
|
});
|
|
312
408
|
}
|
|
409
|
+
function isRequiresManifestError(error) {
|
|
410
|
+
return error instanceof SwarmApiError && error.status === 400 && /\brequires_manifest\b/.test(error.message);
|
|
411
|
+
}
|
|
412
|
+
function historyOmissionState(snapshot, id) {
|
|
413
|
+
const historyOmissions = (Array.isArray(snapshot.omissions) ? snapshot.omissions : [])
|
|
414
|
+
.filter((o) => o && o.session_id === id && (o.resource === 'messages' || o.resource === 'events'));
|
|
415
|
+
return { historyOmitted: historyOmissions.length > 0, historyOmissions };
|
|
416
|
+
}
|
package/dist/remote.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { SwarmTransport } from './transport.js';
|
|
2
|
+
/** An AI client's authorization request waiting on this machine's owner. */
|
|
3
|
+
export interface RemoteConsent {
|
|
4
|
+
code: string;
|
|
5
|
+
client_id: string;
|
|
6
|
+
client_name: string;
|
|
7
|
+
client_domain?: string;
|
|
8
|
+
redirect_host: string;
|
|
9
|
+
scopes: string[];
|
|
10
|
+
expires_at: number;
|
|
11
|
+
}
|
|
12
|
+
/** Relay connection state. Never contains the device key or token. */
|
|
13
|
+
export interface RemoteStatus {
|
|
14
|
+
configured: boolean;
|
|
15
|
+
enabled: boolean;
|
|
16
|
+
connected: boolean;
|
|
17
|
+
relay_url?: string;
|
|
18
|
+
device_id?: string;
|
|
19
|
+
device_name?: string;
|
|
20
|
+
public_key?: string;
|
|
21
|
+
allow_write: boolean;
|
|
22
|
+
allow_approve: boolean;
|
|
23
|
+
allow_manage: boolean;
|
|
24
|
+
last_error?: string;
|
|
25
|
+
pending_consents: RemoteConsent[];
|
|
26
|
+
/** Set while the relay waits for an authorized client to pair this machine. */
|
|
27
|
+
pairing_code?: string;
|
|
28
|
+
pairing_expires_at?: number;
|
|
29
|
+
}
|
|
30
|
+
export interface RemoteInitInput {
|
|
31
|
+
relay_url: string;
|
|
32
|
+
device_name: string;
|
|
33
|
+
allow_write?: boolean;
|
|
34
|
+
allow_approve?: boolean;
|
|
35
|
+
allow_manage?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Owner administration of the machine's relay connection (`/v1/remote*`).
|
|
39
|
+
* The daemon refuses scoped tokens here: call it only from a trusted backend
|
|
40
|
+
* on the private local transport, never from a browser.
|
|
41
|
+
*/
|
|
42
|
+
export declare class SwarmRemoteNamespace {
|
|
43
|
+
private readonly transport;
|
|
44
|
+
constructor(transport: SwarmTransport);
|
|
45
|
+
private call;
|
|
46
|
+
status(): Promise<RemoteStatus>;
|
|
47
|
+
/** Creates this machine's device key for one relay. Does not connect. */
|
|
48
|
+
init(input: RemoteInitInput): Promise<RemoteStatus>;
|
|
49
|
+
enable(): Promise<RemoteStatus>;
|
|
50
|
+
disable(): Promise<RemoteStatus>;
|
|
51
|
+
/**
|
|
52
|
+
* Approves or denies an AI client's authorization request shown on the
|
|
53
|
+
* relay's consent page. Approved scopes are clamped to the request and to
|
|
54
|
+
* this machine's ceiling by the daemon.
|
|
55
|
+
*/
|
|
56
|
+
decideConsent(code: string, approve: boolean, scopes?: string[]): Promise<RemoteConsent>;
|
|
57
|
+
/** Disconnects, revokes the device token and deletes the device key. */
|
|
58
|
+
reset(): Promise<RemoteStatus>;
|
|
59
|
+
}
|
package/dist/remote.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner administration of the machine's relay connection (`/v1/remote*`).
|
|
3
|
+
* The daemon refuses scoped tokens here: call it only from a trusted backend
|
|
4
|
+
* on the private local transport, never from a browser.
|
|
5
|
+
*/
|
|
6
|
+
export class SwarmRemoteNamespace {
|
|
7
|
+
transport;
|
|
8
|
+
constructor(transport) {
|
|
9
|
+
this.transport = transport;
|
|
10
|
+
}
|
|
11
|
+
async call(path, method, body) {
|
|
12
|
+
const res = await this.transport.request(`/v1/remote${path}`, { method, ...(method === 'POST' ? { body: body ?? {} } : {}) });
|
|
13
|
+
return res.data.remote;
|
|
14
|
+
}
|
|
15
|
+
status() {
|
|
16
|
+
return this.call('', 'GET');
|
|
17
|
+
}
|
|
18
|
+
/** Creates this machine's device key for one relay. Does not connect. */
|
|
19
|
+
init(input) {
|
|
20
|
+
return this.call('/init', 'POST', {
|
|
21
|
+
relay_url: input.relay_url, device_name: input.device_name,
|
|
22
|
+
allow_write: input.allow_write === true, allow_approve: input.allow_approve === true, allow_manage: input.allow_manage === true,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
enable() {
|
|
26
|
+
return this.call('/enable', 'POST');
|
|
27
|
+
}
|
|
28
|
+
disable() {
|
|
29
|
+
return this.call('/disable', 'POST');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Approves or denies an AI client's authorization request shown on the
|
|
33
|
+
* relay's consent page. Approved scopes are clamped to the request and to
|
|
34
|
+
* this machine's ceiling by the daemon.
|
|
35
|
+
*/
|
|
36
|
+
async decideConsent(code, approve, scopes) {
|
|
37
|
+
const res = await this.transport.request(`/v1/remote/consents/${approve ? 'approve' : 'deny'}`, {
|
|
38
|
+
method: 'POST', body: approve && scopes ? { code, scopes } : { code },
|
|
39
|
+
});
|
|
40
|
+
return res.data.consent;
|
|
41
|
+
}
|
|
42
|
+
/** Disconnects, revokes the device token and deletes the device key. */
|
|
43
|
+
reset() {
|
|
44
|
+
return this.call('/reset', 'POST');
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/sessions.d.ts
CHANGED
|
@@ -35,14 +35,44 @@ export declare class SwarmSessionsNamespace {
|
|
|
35
35
|
* Deletes a session and triggers cascading cancellation of associated runs and worktrees.
|
|
36
36
|
*/
|
|
37
37
|
delete(sessionId: string): Promise<boolean>;
|
|
38
|
+
/**
|
|
39
|
+
* Renames a session or project conversation you own (`POST /v3/sessions/{id}/title`).
|
|
40
|
+
* The title is trimmed and must be 1–200 characters. A renamed conversation keeps its
|
|
41
|
+
* title: Swarm no longer titles it automatically. (Untitled conversations are named
|
|
42
|
+
* from their first message.) Retrying with the same `clientRequestId` is idempotent.
|
|
43
|
+
*/
|
|
44
|
+
rename(sessionId: string, title: string, options?: {
|
|
45
|
+
clientRequestId?: string;
|
|
46
|
+
}): Promise<{
|
|
47
|
+
ok: boolean;
|
|
48
|
+
session_id: string;
|
|
49
|
+
title: string;
|
|
50
|
+
}>;
|
|
38
51
|
/**
|
|
39
52
|
* Appends a message to a session conversation.
|
|
40
53
|
*/
|
|
41
54
|
sendMessage(sessionId: string, params: {
|
|
42
55
|
content: string;
|
|
43
|
-
role?: 'user'
|
|
56
|
+
role?: 'user';
|
|
44
57
|
client_request_id?: string;
|
|
45
58
|
}): Promise<any>;
|
|
59
|
+
/**
|
|
60
|
+
* Starts a fresh model context in the same conversation (`POST /v3/sessions/{id}/context/clear`).
|
|
61
|
+
* History stays visible and the session ID is unchanged; the next turn starts without the
|
|
62
|
+
* earlier provider context. The daemon accepts this only for an idle project conversation
|
|
63
|
+
* without an unresolved plan, and only when `expectedLastEventSeq` equals the session's current
|
|
64
|
+
* durable sequence (`state.snapshot.projections_by_session[id].last_event_seq`); otherwise it
|
|
65
|
+
* rejects with `SwarmConflictError` and changes nothing. A standalone chat session cannot be
|
|
66
|
+
* cleared: create a new session instead. Retrying with the same `clientRequestId` is idempotent.
|
|
67
|
+
*/
|
|
68
|
+
clearContext(sessionId: string, params: {
|
|
69
|
+
expectedLastEventSeq: number;
|
|
70
|
+
clientRequestId?: string;
|
|
71
|
+
}): Promise<{
|
|
72
|
+
ok: boolean;
|
|
73
|
+
session_id: string;
|
|
74
|
+
mutation: Record<string, unknown>;
|
|
75
|
+
}>;
|
|
46
76
|
/**
|
|
47
77
|
* Stops an active execution run for a session.
|
|
48
78
|
*/
|
package/dist/sessions.js
CHANGED
|
@@ -84,10 +84,14 @@ export class SwarmSessionsNamespace {
|
|
|
84
84
|
const data = res.data;
|
|
85
85
|
if (data && typeof data === 'object') {
|
|
86
86
|
const sessionObj = data.session || data;
|
|
87
|
+
// V3 sessions carry their source workspace as the primary grant.
|
|
88
|
+
const primaryGrant = Array.isArray(sessionObj.workspace_grants)
|
|
89
|
+
? sessionObj.workspace_grants.find((grant) => grant?.kind === 'primary')
|
|
90
|
+
: undefined;
|
|
87
91
|
return {
|
|
88
92
|
id: sessionObj.id || sessionObj.session_id || sessionId,
|
|
89
93
|
title: sessionObj.title || '',
|
|
90
|
-
workspace_id: sessionObj.workspace_id,
|
|
94
|
+
workspace_id: sessionObj.workspace_id || primaryGrant?.workspace_id,
|
|
91
95
|
workspace_path: sessionObj.workspace_path,
|
|
92
96
|
state: sessionObj.state || data.projection?.state || 'idle',
|
|
93
97
|
mode: sessionObj.mode,
|
|
@@ -145,6 +149,27 @@ export class SwarmSessionsNamespace {
|
|
|
145
149
|
});
|
|
146
150
|
return res.data?.ok === true;
|
|
147
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Renames a session or project conversation you own (`POST /v3/sessions/{id}/title`).
|
|
154
|
+
* The title is trimmed and must be 1–200 characters. A renamed conversation keeps its
|
|
155
|
+
* title: Swarm no longer titles it automatically. (Untitled conversations are named
|
|
156
|
+
* from their first message.) Retrying with the same `clientRequestId` is idempotent.
|
|
157
|
+
*/
|
|
158
|
+
async rename(sessionId, title, options = {}) {
|
|
159
|
+
const id = sessionId.trim();
|
|
160
|
+
if (!id)
|
|
161
|
+
throw new Error('sessionId is required');
|
|
162
|
+
const trimmed = typeof title === 'string' ? title.trim() : '';
|
|
163
|
+
if (!trimmed)
|
|
164
|
+
throw new Error('title is required');
|
|
165
|
+
if ([...trimmed].length > 200)
|
|
166
|
+
throw new Error('title must be 200 characters or fewer');
|
|
167
|
+
const res = await this.transport.request(`/v3/sessions/${encodeURIComponent(id)}/title`, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
body: { title: trimmed, client_request_id: options.clientRequestId || generateRequestId() },
|
|
170
|
+
});
|
|
171
|
+
return res.data;
|
|
172
|
+
}
|
|
148
173
|
/**
|
|
149
174
|
* Appends a message to a session conversation.
|
|
150
175
|
*/
|
|
@@ -161,6 +186,29 @@ export class SwarmSessionsNamespace {
|
|
|
161
186
|
});
|
|
162
187
|
return res.data;
|
|
163
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Starts a fresh model context in the same conversation (`POST /v3/sessions/{id}/context/clear`).
|
|
191
|
+
* History stays visible and the session ID is unchanged; the next turn starts without the
|
|
192
|
+
* earlier provider context. The daemon accepts this only for an idle project conversation
|
|
193
|
+
* without an unresolved plan, and only when `expectedLastEventSeq` equals the session's current
|
|
194
|
+
* durable sequence (`state.snapshot.projections_by_session[id].last_event_seq`); otherwise it
|
|
195
|
+
* rejects with `SwarmConflictError` and changes nothing. A standalone chat session cannot be
|
|
196
|
+
* cleared: create a new session instead. Retrying with the same `clientRequestId` is idempotent.
|
|
197
|
+
*/
|
|
198
|
+
async clearContext(sessionId, params) {
|
|
199
|
+
if (!Number.isSafeInteger(params.expectedLastEventSeq) || params.expectedLastEventSeq < 0) {
|
|
200
|
+
throw new Error('expectedLastEventSeq must be the current non-negative session event sequence');
|
|
201
|
+
}
|
|
202
|
+
const id = encodeURIComponent(sessionId.trim());
|
|
203
|
+
const res = await this.transport.request(`/v3/sessions/${id}/context/clear`, {
|
|
204
|
+
method: 'POST',
|
|
205
|
+
body: {
|
|
206
|
+
client_request_id: params.clientRequestId || generateRequestId(),
|
|
207
|
+
expected_last_event_seq: params.expectedLastEventSeq,
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
return res.data;
|
|
211
|
+
}
|
|
164
212
|
/**
|
|
165
213
|
* Stops an active execution run for a session.
|
|
166
214
|
*/
|
package/dist/settings.d.ts
CHANGED
|
@@ -98,6 +98,9 @@ export declare class SwarmSettingsNamespace {
|
|
|
98
98
|
/**
|
|
99
99
|
* Restores verified recommended defaults across all system agent and swarm core roles.
|
|
100
100
|
* If expectedUpdatedAt is omitted, automatically reads the current setting's revision first.
|
|
101
|
+
* An account with no model settings yet (a provider is connected but its
|
|
102
|
+
* models were never set up) restores from revision 0: the daemon then sets
|
|
103
|
+
* up the recommended models from a ready provider.
|
|
101
104
|
*/
|
|
102
105
|
restoreDefaults(expectedUpdatedAt?: number): Promise<AgentModelSettingsResponse>;
|
|
103
106
|
/**
|