@letta-ai/letta-agent-sdk 0.6.2 → 0.7.0
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/AGENTS.md +42 -0
- package/README.md +101 -1
- package/dist/app-server-session.d.ts.map +1 -1
- package/dist/client-base.d.ts +7 -0
- package/dist/client-base.d.ts.map +1 -1
- package/dist/client-entry.d.ts +3 -0
- package/dist/client-entry.d.ts.map +1 -1
- package/dist/client-entry.js +816 -138
- package/dist/client-entry.js.map +16 -14
- package/dist/cloud-session.d.ts +8 -1
- package/dist/cloud-session.d.ts.map +1 -1
- package/dist/computers.d.ts +67 -0
- package/dist/computers.d.ts.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +817 -139
- package/dist/index.js.map +17 -15
- package/dist/remote-client-session-core.d.ts +13 -16
- package/dist/remote-client-session-core.d.ts.map +1 -1
- package/dist/remote-session-protocol.d.ts +11 -0
- package/dist/remote-session-protocol.d.ts.map +1 -1
- package/dist/remote-turn-coordinator.d.ts +6 -1
- package/dist/remote-turn-coordinator.d.ts.map +1 -1
- package/dist/remote.d.ts +6 -1
- package/dist/remote.d.ts.map +1 -1
- package/dist/transcript-accumulator.d.ts +133 -0
- package/dist/transcript-accumulator.d.ts.map +1 -0
- package/dist/types.d.ts +41 -21
- package/dist/types.d.ts.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/app-server-session.ts +9 -0
- package/src/client-base.ts +66 -19
- package/src/client-entry.ts +23 -0
- package/src/cloud-session.ts +112 -45
- package/src/computers.ts +132 -0
- package/src/index.ts +24 -0
- package/src/remote-client-session-core.ts +103 -27
- package/src/remote-session-protocol.ts +24 -0
- package/src/remote-turn-coordinator.ts +11 -2
- package/src/remote.ts +41 -12
- package/src/transcript-accumulator.ts +823 -0
- package/src/types.ts +43 -18
- package/src/validation.ts +16 -0
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript accumulator
|
|
3
|
+
*
|
|
4
|
+
* Turns an `SDKMessage` stream into stable, render-ready rows so consumers stop
|
|
5
|
+
* hand-rolling stream reconciliation. It owns the four reconciliation rules the
|
|
6
|
+
* wire protocol requires:
|
|
7
|
+
*
|
|
8
|
+
* 1. Typed-by-family accumulation. Text slices are keyed on
|
|
9
|
+
* `message family + otid`, falling back to `uuid` *within the same family*.
|
|
10
|
+
* A bare `otid`/`uuid` key would collapse an assistant slice into a
|
|
11
|
+
* reasoning slice whenever a provider reuses an identifier across kinds.
|
|
12
|
+
* 2. Per-`runId` `seqId` replay suppression. Each run keeps its own high-water
|
|
13
|
+
* mark, so a resumed stream that replays positions is dropped while a new
|
|
14
|
+
* run starts from a clean threshold.
|
|
15
|
+
* 3. `toolCallId`-keyed merging. Tool argument fragments and the eventual tool
|
|
16
|
+
* result merge into one row keyed on the payload identity (`toolCallId`),
|
|
17
|
+
* while the envelope identities (the `uuid` of the `tool_call` message and
|
|
18
|
+
* of the `tool_result` message) stay separately visible.
|
|
19
|
+
* 4. `rebase()` for mid-run backfill. A history page is merged in place with
|
|
20
|
+
* replace semantics, reordered ahead of live-only rows, and raises the
|
|
21
|
+
* replay thresholds it proves.
|
|
22
|
+
*
|
|
23
|
+
* The accumulator is pure and portable: no I/O, no timers, no Node built-ins,
|
|
24
|
+
* so it is exported from both the package root and `/client`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { Message as LettaMessage } from "@letta-ai/letta-client/resources/agents/messages";
|
|
28
|
+
import {
|
|
29
|
+
extractTextFromContent,
|
|
30
|
+
firstToolCall,
|
|
31
|
+
firstToolReturn,
|
|
32
|
+
} from "./remote-session-protocol.js";
|
|
33
|
+
import { extractStreamTextDelta } from "./stream-events.js";
|
|
34
|
+
import type { SDKMessage, SDKStreamEventMessage } from "./types.js";
|
|
35
|
+
|
|
36
|
+
// ═══════════════════════════════════════════════════════════════
|
|
37
|
+
// PUBLIC TYPES
|
|
38
|
+
// ═══════════════════════════════════════════════════════════════
|
|
39
|
+
|
|
40
|
+
/** Message families the accumulator projects into rows. */
|
|
41
|
+
export type TranscriptRowKind = "user" | "assistant" | "reasoning" | "tool_call";
|
|
42
|
+
|
|
43
|
+
/** Text families. Rows in different families never share a key. */
|
|
44
|
+
export type TranscriptTextKind = "user" | "assistant" | "reasoning";
|
|
45
|
+
|
|
46
|
+
export interface TranscriptRowIdentity {
|
|
47
|
+
/**
|
|
48
|
+
* Stable render key. Namespaced by message family, so a provider that reuses
|
|
49
|
+
* an `otid` or a message id across kinds still produces separate rows.
|
|
50
|
+
*/
|
|
51
|
+
key: string;
|
|
52
|
+
/** Envelope id of the message that opened this row, when known. */
|
|
53
|
+
uuid?: string;
|
|
54
|
+
/** Lineage key for this typed slice, when the stream supplied one. */
|
|
55
|
+
otid?: string;
|
|
56
|
+
/** Run that most recently contributed to this row. */
|
|
57
|
+
runId?: string;
|
|
58
|
+
/** Highest replay cursor observed for this row. */
|
|
59
|
+
seqId?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface TranscriptTextRow extends TranscriptRowIdentity {
|
|
63
|
+
kind: TranscriptTextKind;
|
|
64
|
+
/** Accumulated text for this slice. */
|
|
65
|
+
text: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface TranscriptToolResult {
|
|
69
|
+
content: string;
|
|
70
|
+
isError: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Envelope id of the `tool_result` message. Deliberately distinct from the
|
|
73
|
+
* row's `uuid`, which identifies the `tool_call` envelope.
|
|
74
|
+
*/
|
|
75
|
+
uuid?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Lifecycle of a tool row.
|
|
80
|
+
*
|
|
81
|
+
* - `streaming`: argument fragments are still arriving and have not parsed.
|
|
82
|
+
* - `ready`: arguments parsed; the result has not arrived.
|
|
83
|
+
* - `complete`: a tool result merged into the row.
|
|
84
|
+
*/
|
|
85
|
+
export type TranscriptToolCallStatus = "streaming" | "ready" | "complete";
|
|
86
|
+
|
|
87
|
+
export interface TranscriptToolCallRow extends TranscriptRowIdentity {
|
|
88
|
+
kind: "tool_call";
|
|
89
|
+
/** Payload identity. This is what the row is keyed on. */
|
|
90
|
+
toolCallId: string;
|
|
91
|
+
toolName: string;
|
|
92
|
+
/**
|
|
93
|
+
* Best known parsed arguments. Never the transitional `{ raw }` wrapper the
|
|
94
|
+
* protocol layer emits for an argument fragment that does not parse.
|
|
95
|
+
*/
|
|
96
|
+
toolInput: Record<string, unknown>;
|
|
97
|
+
/** Argument fragments concatenated in arrival order, when the wire sent any. */
|
|
98
|
+
rawArguments?: string;
|
|
99
|
+
/** Whether {@link toolInput} reflects fully parsed arguments. */
|
|
100
|
+
argumentsComplete: boolean;
|
|
101
|
+
result?: TranscriptToolResult;
|
|
102
|
+
status: TranscriptToolCallStatus;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type TranscriptRow = TranscriptTextRow | TranscriptToolCallRow;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A history page accepted by {@link TranscriptAccumulator.rebase}. Covers
|
|
109
|
+
* `session.listMessages()`, `session.bootstrapState()`, and a bare array of
|
|
110
|
+
* Letta API messages.
|
|
111
|
+
*/
|
|
112
|
+
export type TranscriptHistoryPage =
|
|
113
|
+
| { messages: readonly LettaMessage[] }
|
|
114
|
+
| readonly LettaMessage[];
|
|
115
|
+
|
|
116
|
+
export interface TranscriptRebaseOptions {
|
|
117
|
+
/**
|
|
118
|
+
* Order of the supplied page. Omitted means auto-detect from `seq_id`/`date`;
|
|
119
|
+
* `listMessages()` defaults to `"desc"` (newest first).
|
|
120
|
+
*/
|
|
121
|
+
order?: "asc" | "desc";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface TranscriptAccumulator {
|
|
125
|
+
/**
|
|
126
|
+
* Fold one streamed message into the transcript and return the current rows.
|
|
127
|
+
*
|
|
128
|
+
* The returned array is referentially stable when the message changed
|
|
129
|
+
* nothing (a replayed position, or a message family the accumulator ignores),
|
|
130
|
+
* so it can be handed straight to a memoizing renderer.
|
|
131
|
+
*/
|
|
132
|
+
apply(message: SDKMessage): readonly TranscriptRow[];
|
|
133
|
+
/** Merge a history page into the transcript. Safe to call mid-run. */
|
|
134
|
+
rebase(
|
|
135
|
+
page: TranscriptHistoryPage,
|
|
136
|
+
options?: TranscriptRebaseOptions,
|
|
137
|
+
): readonly TranscriptRow[];
|
|
138
|
+
/** Current rows in transcript order. */
|
|
139
|
+
rows(): readonly TranscriptRow[];
|
|
140
|
+
/** Drop all rows and replay state. */
|
|
141
|
+
reset(): void;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ═══════════════════════════════════════════════════════════════
|
|
145
|
+
// INTERNALS
|
|
146
|
+
// ═══════════════════════════════════════════════════════════════
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Key segment separator. Every key carries a family segment and an identifier
|
|
150
|
+
* kind segment ("otid"/"uuid"), so no wire identifier can produce a key that
|
|
151
|
+
* collides with a key from another family or another identifier kind.
|
|
152
|
+
*/
|
|
153
|
+
const SEP = ":";
|
|
154
|
+
|
|
155
|
+
/** Bound on tracked replay thresholds so a long session cannot grow forever. */
|
|
156
|
+
const MAX_TRACKED_RUNS = 64;
|
|
157
|
+
|
|
158
|
+
/** Bucket used for streams that do not carry a `runId`. */
|
|
159
|
+
const ANONYMOUS_RUN = "";
|
|
160
|
+
|
|
161
|
+
interface TextSlice {
|
|
162
|
+
kind: TranscriptTextKind;
|
|
163
|
+
text: string;
|
|
164
|
+
uuid?: string;
|
|
165
|
+
otid?: string;
|
|
166
|
+
runId?: string;
|
|
167
|
+
seqId?: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function familyOtidAlias(kind: TranscriptTextKind, otid: string): string {
|
|
171
|
+
return `${kind}${SEP}otid${SEP}${otid}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function familyUuidAlias(kind: TranscriptTextKind, uuid: string): string {
|
|
175
|
+
return `${kind}${SEP}uuid${SEP}${uuid}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function toolRowKey(toolCallId: string): string {
|
|
179
|
+
return `tool_call${SEP}id${SEP}${toolCallId}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
183
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
184
|
+
? (value as Record<string, unknown>)
|
|
185
|
+
: undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function readString(
|
|
189
|
+
record: Record<string, unknown>,
|
|
190
|
+
field: string,
|
|
191
|
+
): string | undefined {
|
|
192
|
+
const value = record[field];
|
|
193
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function readNumber(
|
|
197
|
+
record: Record<string, unknown>,
|
|
198
|
+
field: string,
|
|
199
|
+
): number | undefined {
|
|
200
|
+
const value = record[field];
|
|
201
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Parse a JSON object, returning undefined for partial or non-object JSON. */
|
|
205
|
+
function parseJsonObject(raw: string): Record<string, unknown> | undefined {
|
|
206
|
+
const trimmed = raw.trim();
|
|
207
|
+
if (trimmed.length === 0) return undefined;
|
|
208
|
+
try {
|
|
209
|
+
return asRecord(JSON.parse(trimmed) as unknown);
|
|
210
|
+
} catch {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Detect the protocol layer's transitional `{ raw }` wrapper.
|
|
217
|
+
*
|
|
218
|
+
* `toolInputFromArguments()` wraps an unparseable argument fragment as
|
|
219
|
+
* `{ raw: "<fragment>" }`. That wrapper is a parse failure, not arguments, and
|
|
220
|
+
* must never overwrite previously parsed input.
|
|
221
|
+
*/
|
|
222
|
+
function isRawArgumentsWrapper(
|
|
223
|
+
input: Record<string, unknown> | undefined,
|
|
224
|
+
rawArguments: string | undefined,
|
|
225
|
+
): boolean {
|
|
226
|
+
if (!input) return false;
|
|
227
|
+
const keys = Object.keys(input);
|
|
228
|
+
if (keys.length !== 1 || keys[0] !== "raw") return false;
|
|
229
|
+
const wrapped = input.raw;
|
|
230
|
+
if (typeof wrapped !== "string") return false;
|
|
231
|
+
return rawArguments === undefined || wrapped === rawArguments;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function toolStatus(row: {
|
|
235
|
+
argumentsComplete: boolean;
|
|
236
|
+
result?: TranscriptToolResult;
|
|
237
|
+
}): TranscriptToolCallStatus {
|
|
238
|
+
if (row.result) return "complete";
|
|
239
|
+
return row.argumentsComplete ? "ready" : "streaming";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
interface ToolCallMerge {
|
|
243
|
+
toolCallId: string;
|
|
244
|
+
toolName?: string;
|
|
245
|
+
toolInput?: Record<string, unknown>;
|
|
246
|
+
rawArguments?: string;
|
|
247
|
+
uuid?: string;
|
|
248
|
+
runId?: string;
|
|
249
|
+
seqId?: number;
|
|
250
|
+
/**
|
|
251
|
+
* `fragment` appends streamed argument text; `whole` treats the arguments as
|
|
252
|
+
* an authoritative complete value (history backfill).
|
|
253
|
+
*/
|
|
254
|
+
mode: "fragment" | "whole";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
interface ToolResultMerge {
|
|
258
|
+
toolCallId: string;
|
|
259
|
+
content: string;
|
|
260
|
+
isError: boolean;
|
|
261
|
+
uuid?: string;
|
|
262
|
+
runId?: string;
|
|
263
|
+
seqId?: number;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
interface ToolArguments {
|
|
267
|
+
rawArguments?: string;
|
|
268
|
+
toolInput: Record<string, unknown>;
|
|
269
|
+
argumentsComplete: boolean;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Fold one argument delivery into the arguments known so far.
|
|
274
|
+
*
|
|
275
|
+
* The wire can deliver arguments three ways for the same call: streamed JSON
|
|
276
|
+
* fragments, one complete JSON string, or an already-decoded object. Only a
|
|
277
|
+
* successful parse is allowed to change `toolInput`.
|
|
278
|
+
*/
|
|
279
|
+
function mergeToolArguments(
|
|
280
|
+
previous: ToolArguments,
|
|
281
|
+
merge: ToolCallMerge,
|
|
282
|
+
): ToolArguments {
|
|
283
|
+
let { rawArguments, toolInput, argumentsComplete } = previous;
|
|
284
|
+
const fragment = merge.rawArguments;
|
|
285
|
+
const wrapped = isRawArgumentsWrapper(merge.toolInput, fragment);
|
|
286
|
+
|
|
287
|
+
if (fragment !== undefined && fragment.length > 0) {
|
|
288
|
+
const whole = parseJsonObject(fragment);
|
|
289
|
+
if (whole) {
|
|
290
|
+
// A delivery that parses on its own is the complete argument value: a
|
|
291
|
+
// final non-chunked `tool_call_message`, or a backfilled history row.
|
|
292
|
+
// Replace rather than append so a repeated terminal message cannot
|
|
293
|
+
// corrupt the accumulation.
|
|
294
|
+
return { rawArguments: fragment, toolInput: whole, argumentsComplete: true };
|
|
295
|
+
}
|
|
296
|
+
if (argumentsComplete) {
|
|
297
|
+
// Arguments already parsed; a trailing partial (a replayed fragment after
|
|
298
|
+
// backfill) must not corrupt them.
|
|
299
|
+
return previous;
|
|
300
|
+
}
|
|
301
|
+
if (merge.mode === "whole") {
|
|
302
|
+
return { rawArguments: rawArguments ?? fragment, toolInput, argumentsComplete };
|
|
303
|
+
}
|
|
304
|
+
rawArguments = (rawArguments ?? "") + fragment;
|
|
305
|
+
const parsed = parseJsonObject(rawArguments);
|
|
306
|
+
if (parsed) {
|
|
307
|
+
return { rawArguments, toolInput: parsed, argumentsComplete: true };
|
|
308
|
+
}
|
|
309
|
+
// Keep the previous parse. Never promote the `{ raw }` wrapper.
|
|
310
|
+
return { rawArguments, toolInput, argumentsComplete: false };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!wrapped && merge.toolInput) {
|
|
314
|
+
const keys = Object.keys(merge.toolInput);
|
|
315
|
+
if (keys.length > 0) {
|
|
316
|
+
return { rawArguments, toolInput: merge.toolInput, argumentsComplete: true };
|
|
317
|
+
}
|
|
318
|
+
if (rawArguments === undefined) {
|
|
319
|
+
// Genuinely argument-free call: `{}` with no streamed fragments.
|
|
320
|
+
return { rawArguments, toolInput, argumentsComplete: true };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return previous;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
class TranscriptAccumulatorImpl implements TranscriptAccumulator {
|
|
328
|
+
/** Row key -> row. Map insertion order is the transcript order. */
|
|
329
|
+
private byKey = new Map<string, TranscriptRow>();
|
|
330
|
+
|
|
331
|
+
/** `family + otid` -> row key. */
|
|
332
|
+
private aliasByOtid = new Map<string, string>();
|
|
333
|
+
|
|
334
|
+
/** `family + uuid` -> row key. */
|
|
335
|
+
private aliasByUuid = new Map<string, string>();
|
|
336
|
+
|
|
337
|
+
/** `runId` -> highest accepted `seqId` for that run. */
|
|
338
|
+
private seqThresholds = new Map<string, number>();
|
|
339
|
+
|
|
340
|
+
private snapshot: readonly TranscriptRow[] | null = null;
|
|
341
|
+
|
|
342
|
+
private anonymousCounter = 0;
|
|
343
|
+
|
|
344
|
+
apply(message: SDKMessage): readonly TranscriptRow[] {
|
|
345
|
+
switch (message.type) {
|
|
346
|
+
case "assistant":
|
|
347
|
+
case "reasoning":
|
|
348
|
+
this.applyText({
|
|
349
|
+
kind: message.type,
|
|
350
|
+
text: message.content,
|
|
351
|
+
uuid: message.uuid,
|
|
352
|
+
otid: typeof message.otid === "string" ? message.otid : undefined,
|
|
353
|
+
runId: message.runId,
|
|
354
|
+
seqId: message.seqId,
|
|
355
|
+
});
|
|
356
|
+
break;
|
|
357
|
+
case "tool_call":
|
|
358
|
+
this.mergeToolCall({
|
|
359
|
+
toolCallId: message.toolCallId,
|
|
360
|
+
toolName: message.toolName,
|
|
361
|
+
toolInput: message.toolInput,
|
|
362
|
+
rawArguments: message.rawArguments,
|
|
363
|
+
uuid: message.uuid,
|
|
364
|
+
runId: message.runId,
|
|
365
|
+
mode: "fragment",
|
|
366
|
+
});
|
|
367
|
+
break;
|
|
368
|
+
case "tool_result":
|
|
369
|
+
this.mergeToolResult({
|
|
370
|
+
toolCallId: message.toolCallId,
|
|
371
|
+
content: message.content,
|
|
372
|
+
isError: message.isError,
|
|
373
|
+
uuid: message.uuid,
|
|
374
|
+
runId: message.runId,
|
|
375
|
+
});
|
|
376
|
+
break;
|
|
377
|
+
case "stream_event":
|
|
378
|
+
this.applyStreamEvent(message);
|
|
379
|
+
break;
|
|
380
|
+
default:
|
|
381
|
+
// init/result/error/retry/queue_update/loop_status are turn-level
|
|
382
|
+
// signals rather than transcript content; consumers handle them
|
|
383
|
+
// directly.
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
return this.rows();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
rebase(
|
|
390
|
+
page: TranscriptHistoryPage,
|
|
391
|
+
options?: TranscriptRebaseOptions,
|
|
392
|
+
): readonly TranscriptRow[] {
|
|
393
|
+
const messages = normalizeHistoryPage(page, options?.order);
|
|
394
|
+
if (messages.length === 0) return this.rows();
|
|
395
|
+
|
|
396
|
+
const historyKeys: string[] = [];
|
|
397
|
+
const seen = new Set<string>();
|
|
398
|
+
for (const message of messages) {
|
|
399
|
+
const key = this.applyHistoryMessage(message);
|
|
400
|
+
if (!key || seen.has(key)) continue;
|
|
401
|
+
seen.add(key);
|
|
402
|
+
historyKeys.push(key);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
this.reorder(historyKeys);
|
|
406
|
+
return this.rows();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
rows(): readonly TranscriptRow[] {
|
|
410
|
+
if (!this.snapshot) {
|
|
411
|
+
this.snapshot = Array.from(this.byKey.values());
|
|
412
|
+
}
|
|
413
|
+
return this.snapshot;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
reset(): void {
|
|
417
|
+
this.byKey.clear();
|
|
418
|
+
this.aliasByOtid.clear();
|
|
419
|
+
this.aliasByUuid.clear();
|
|
420
|
+
this.seqThresholds.clear();
|
|
421
|
+
this.anonymousCounter = 0;
|
|
422
|
+
this.snapshot = null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── replay suppression ────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Per-run replay guard.
|
|
429
|
+
*
|
|
430
|
+
* Thresholds are bucketed by `runId`, so a brand new run starts with no
|
|
431
|
+
* threshold (a natural reset) while a resumed run keeps suppressing the
|
|
432
|
+
* positions it already delivered. Messages without a `seqId` are never
|
|
433
|
+
* suppressed here: their families are deduplicated by identity instead.
|
|
434
|
+
*/
|
|
435
|
+
private isReplay(
|
|
436
|
+
runId: string | undefined,
|
|
437
|
+
seqId: number | undefined,
|
|
438
|
+
): boolean {
|
|
439
|
+
if (seqId === undefined) return false;
|
|
440
|
+
const bucket = runId ?? ANONYMOUS_RUN;
|
|
441
|
+
const threshold = this.seqThresholds.get(bucket);
|
|
442
|
+
if (threshold !== undefined && seqId <= threshold) return true;
|
|
443
|
+
this.rememberSeq(bucket, seqId);
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private rememberSeq(bucket: string, seqId: number): void {
|
|
448
|
+
const threshold = this.seqThresholds.get(bucket);
|
|
449
|
+
if (threshold !== undefined && threshold >= seqId) return;
|
|
450
|
+
this.seqThresholds.set(bucket, seqId);
|
|
451
|
+
while (this.seqThresholds.size > MAX_TRACKED_RUNS) {
|
|
452
|
+
const oldest = this.seqThresholds.keys().next();
|
|
453
|
+
if (oldest.done) break;
|
|
454
|
+
this.seqThresholds.delete(oldest.value);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ── text families ─────────────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
private applyText(slice: TextSlice): void {
|
|
461
|
+
if (this.isReplay(slice.runId, slice.seqId)) return;
|
|
462
|
+
this.writeText(slice, "append");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private writeText(slice: TextSlice, write: "append" | "replace"): string {
|
|
466
|
+
const key = this.resolveTextKey(slice.kind, slice.uuid, slice.otid);
|
|
467
|
+
const existing = this.byKey.get(key);
|
|
468
|
+
const previous =
|
|
469
|
+
existing && existing.kind === slice.kind ? existing : undefined;
|
|
470
|
+
this.setRow(key, {
|
|
471
|
+
kind: slice.kind,
|
|
472
|
+
key,
|
|
473
|
+
text: write === "append" ? (previous?.text ?? "") + slice.text : slice.text,
|
|
474
|
+
uuid: previous?.uuid ?? slice.uuid,
|
|
475
|
+
otid: slice.otid ?? previous?.otid,
|
|
476
|
+
runId: slice.runId ?? previous?.runId,
|
|
477
|
+
seqId: maxDefined(slice.seqId, previous?.seqId),
|
|
478
|
+
});
|
|
479
|
+
return key;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Resolve the row key for a text slice.
|
|
484
|
+
*
|
|
485
|
+
* `otid` is the lineage key when present, but a stream can transition: early
|
|
486
|
+
* fragments may carry only the message id and later fragments add an `otid`.
|
|
487
|
+
* Both identifiers are aliased to one row so the transition does not split
|
|
488
|
+
* it. When a message id is reused by a *second* slice carrying a different
|
|
489
|
+
* `otid` (a provider emitting `[text, thinking, text]` under one message id),
|
|
490
|
+
* the new `otid` opens its own row instead of appending to the previous one.
|
|
491
|
+
*/
|
|
492
|
+
private resolveTextKey(
|
|
493
|
+
kind: TranscriptTextKind,
|
|
494
|
+
uuid: string | undefined,
|
|
495
|
+
otid: string | undefined,
|
|
496
|
+
): string {
|
|
497
|
+
const otidAlias = otid ? familyOtidAlias(kind, otid) : undefined;
|
|
498
|
+
const uuidAlias = uuid ? familyUuidAlias(kind, uuid) : undefined;
|
|
499
|
+
const fromOtid = otidAlias ? this.aliasByOtid.get(otidAlias) : undefined;
|
|
500
|
+
const fromUuid = uuidAlias ? this.aliasByUuid.get(uuidAlias) : undefined;
|
|
501
|
+
|
|
502
|
+
let key: string | undefined = fromOtid ?? fromUuid;
|
|
503
|
+
|
|
504
|
+
if (key && !fromOtid && otid) {
|
|
505
|
+
const existing = this.byKey.get(key);
|
|
506
|
+
if (existing && existing.otid !== undefined && existing.otid !== otid) {
|
|
507
|
+
// The envelope already committed to a different lineage: this is a new
|
|
508
|
+
// slice sharing a message id, not a continuation of the old one.
|
|
509
|
+
key = undefined;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (!key) {
|
|
514
|
+
key =
|
|
515
|
+
otidAlias ??
|
|
516
|
+
uuidAlias ??
|
|
517
|
+
`${kind}${SEP}auto${SEP}${++this.anonymousCounter}`;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (otidAlias) this.aliasByOtid.set(otidAlias, key);
|
|
521
|
+
// The newest slice owns the envelope, so later id-only fragments continue
|
|
522
|
+
// it rather than the slice that closed before it.
|
|
523
|
+
if (uuidAlias) this.aliasByUuid.set(uuidAlias, key);
|
|
524
|
+
|
|
525
|
+
return key;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ── tool families ─────────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
private mergeToolCall(merge: ToolCallMerge): string {
|
|
531
|
+
const key = toolRowKey(merge.toolCallId);
|
|
532
|
+
const existing = this.byKey.get(key);
|
|
533
|
+
const previous =
|
|
534
|
+
existing && existing.kind === "tool_call" ? existing : undefined;
|
|
535
|
+
|
|
536
|
+
const args = mergeToolArguments(
|
|
537
|
+
{
|
|
538
|
+
rawArguments: previous?.rawArguments,
|
|
539
|
+
toolInput: previous?.toolInput ?? {},
|
|
540
|
+
argumentsComplete: previous?.argumentsComplete ?? false,
|
|
541
|
+
},
|
|
542
|
+
merge,
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
const toolName =
|
|
546
|
+
merge.toolName && merge.toolName !== "?"
|
|
547
|
+
? merge.toolName
|
|
548
|
+
: (previous?.toolName ?? merge.toolName ?? "?");
|
|
549
|
+
|
|
550
|
+
const next: TranscriptToolCallRow = {
|
|
551
|
+
kind: "tool_call",
|
|
552
|
+
key,
|
|
553
|
+
toolCallId: merge.toolCallId,
|
|
554
|
+
toolName,
|
|
555
|
+
toolInput: args.toolInput,
|
|
556
|
+
...(args.rawArguments !== undefined
|
|
557
|
+
? { rawArguments: args.rawArguments }
|
|
558
|
+
: {}),
|
|
559
|
+
argumentsComplete: args.argumentsComplete,
|
|
560
|
+
...(previous?.result ? { result: previous.result } : {}),
|
|
561
|
+
status: "streaming",
|
|
562
|
+
// Envelope identity stays pinned to the `tool_call` message that opened
|
|
563
|
+
// the row; the payload identity is `toolCallId`.
|
|
564
|
+
uuid: previous?.uuid ?? merge.uuid,
|
|
565
|
+
runId: merge.runId ?? previous?.runId,
|
|
566
|
+
seqId: maxDefined(merge.seqId, previous?.seqId),
|
|
567
|
+
};
|
|
568
|
+
next.status = toolStatus(next);
|
|
569
|
+
this.setRow(key, next);
|
|
570
|
+
return key;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private mergeToolResult(merge: ToolResultMerge): string {
|
|
574
|
+
const key = toolRowKey(merge.toolCallId);
|
|
575
|
+
const existing = this.byKey.get(key);
|
|
576
|
+
const previous =
|
|
577
|
+
existing && existing.kind === "tool_call" ? existing : undefined;
|
|
578
|
+
|
|
579
|
+
this.setRow(key, {
|
|
580
|
+
kind: "tool_call",
|
|
581
|
+
key,
|
|
582
|
+
toolCallId: merge.toolCallId,
|
|
583
|
+
toolName: previous?.toolName ?? "?",
|
|
584
|
+
toolInput: previous?.toolInput ?? {},
|
|
585
|
+
...(previous?.rawArguments !== undefined
|
|
586
|
+
? { rawArguments: previous.rawArguments }
|
|
587
|
+
: {}),
|
|
588
|
+
argumentsComplete: previous?.argumentsComplete ?? false,
|
|
589
|
+
result: {
|
|
590
|
+
content: merge.content,
|
|
591
|
+
isError: merge.isError,
|
|
592
|
+
// The result envelope is a different message than the call envelope,
|
|
593
|
+
// so it is recorded beside the row's `uuid`, not over it.
|
|
594
|
+
...(merge.uuid ? { uuid: merge.uuid } : {}),
|
|
595
|
+
},
|
|
596
|
+
status: "complete",
|
|
597
|
+
uuid: previous?.uuid,
|
|
598
|
+
runId: merge.runId ?? previous?.runId,
|
|
599
|
+
seqId: maxDefined(merge.seqId, previous?.seqId),
|
|
600
|
+
});
|
|
601
|
+
return key;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ── raw stream events ─────────────────────────────────────────
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Compose with {@link extractStreamTextDelta} for payloads the session layer
|
|
608
|
+
* passes through uncooked.
|
|
609
|
+
*
|
|
610
|
+
* Identity comes from the payload when it has any (`id`, `otid`, `seq_id`,
|
|
611
|
+
* `run_id`). Content-block deltas carry none, so they fold into a single live
|
|
612
|
+
* row per family — the most an anonymous delta stream can support.
|
|
613
|
+
*/
|
|
614
|
+
private applyStreamEvent(message: SDKStreamEventMessage): void {
|
|
615
|
+
const delta = extractStreamTextDelta(message.event);
|
|
616
|
+
if (!delta) return;
|
|
617
|
+
const payload = asRecord(message.event);
|
|
618
|
+
const otid = payload ? readString(payload, "otid") : undefined;
|
|
619
|
+
const payloadId = payload ? readString(payload, "id") : undefined;
|
|
620
|
+
const identified = otid !== undefined || payloadId !== undefined;
|
|
621
|
+
|
|
622
|
+
this.applyText({
|
|
623
|
+
kind: delta.kind,
|
|
624
|
+
text: delta.text,
|
|
625
|
+
uuid: identified ? payloadId : `${delta.kind}${SEP}live`,
|
|
626
|
+
otid,
|
|
627
|
+
runId: payload ? readString(payload, "run_id") : undefined,
|
|
628
|
+
seqId: payload ? readNumber(payload, "seq_id") : undefined,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ── history backfill ──────────────────────────────────────────
|
|
633
|
+
|
|
634
|
+
private applyHistoryMessage(message: LettaMessage): string | undefined {
|
|
635
|
+
const record = asRecord(message);
|
|
636
|
+
if (!record) return undefined;
|
|
637
|
+
const messageType = readString(record, "message_type");
|
|
638
|
+
if (!messageType) return undefined;
|
|
639
|
+
|
|
640
|
+
const uuid = readString(record, "id");
|
|
641
|
+
const otid = readString(record, "otid");
|
|
642
|
+
const runId = readString(record, "run_id");
|
|
643
|
+
const seqId = readNumber(record, "seq_id");
|
|
644
|
+
|
|
645
|
+
// A history page proves every position up to its own cursor for that run,
|
|
646
|
+
// so replayed deltas at or below it are suppressed after the merge.
|
|
647
|
+
if (seqId !== undefined) {
|
|
648
|
+
this.rememberSeq(runId ?? ANONYMOUS_RUN, seqId);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
switch (messageType) {
|
|
652
|
+
case "user_message":
|
|
653
|
+
case "assistant_message": {
|
|
654
|
+
const text = extractTextFromContent(record.content);
|
|
655
|
+
if (text === null) return undefined;
|
|
656
|
+
return this.writeText(
|
|
657
|
+
{
|
|
658
|
+
kind: messageType === "user_message" ? "user" : "assistant",
|
|
659
|
+
text,
|
|
660
|
+
uuid,
|
|
661
|
+
otid,
|
|
662
|
+
runId,
|
|
663
|
+
seqId,
|
|
664
|
+
},
|
|
665
|
+
"replace",
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
case "reasoning_message": {
|
|
669
|
+
const text =
|
|
670
|
+
typeof record.reasoning === "string"
|
|
671
|
+
? record.reasoning
|
|
672
|
+
: extractTextFromContent(record.content);
|
|
673
|
+
if (text === null || text === undefined) return undefined;
|
|
674
|
+
return this.writeText(
|
|
675
|
+
{ kind: "reasoning", text, uuid, otid, runId, seqId },
|
|
676
|
+
"replace",
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
case "tool_call_message":
|
|
680
|
+
case "approval_request_message": {
|
|
681
|
+
const toolCall = firstToolCall(record);
|
|
682
|
+
if (!toolCall) return undefined;
|
|
683
|
+
const fn = asRecord(toolCall.function);
|
|
684
|
+
const toolCallId =
|
|
685
|
+
(typeof toolCall.tool_call_id === "string"
|
|
686
|
+
? toolCall.tool_call_id
|
|
687
|
+
: undefined) ??
|
|
688
|
+
(typeof toolCall.id === "string" ? toolCall.id : undefined);
|
|
689
|
+
if (!toolCallId) return undefined;
|
|
690
|
+
const args = toolCall.arguments ?? fn?.arguments;
|
|
691
|
+
return this.mergeToolCall({
|
|
692
|
+
toolCallId,
|
|
693
|
+
toolName:
|
|
694
|
+
(typeof toolCall.name === "string" ? toolCall.name : undefined) ??
|
|
695
|
+
(typeof fn?.name === "string" ? fn.name : undefined),
|
|
696
|
+
toolInput: asRecord(args),
|
|
697
|
+
rawArguments: typeof args === "string" ? args : undefined,
|
|
698
|
+
uuid,
|
|
699
|
+
runId,
|
|
700
|
+
seqId,
|
|
701
|
+
mode: "whole",
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
case "tool_return_message": {
|
|
705
|
+
const toolReturn = firstToolReturn(record) ?? record;
|
|
706
|
+
const toolCallId =
|
|
707
|
+
readString(record, "tool_call_id") ??
|
|
708
|
+
(typeof toolReturn.tool_call_id === "string"
|
|
709
|
+
? toolReturn.tool_call_id
|
|
710
|
+
: undefined);
|
|
711
|
+
if (!toolCallId) return undefined;
|
|
712
|
+
const content =
|
|
713
|
+
extractTextFromContent(
|
|
714
|
+
record.tool_return ?? toolReturn.tool_return ?? toolReturn.content,
|
|
715
|
+
) ?? "";
|
|
716
|
+
const status =
|
|
717
|
+
readString(record, "status") ??
|
|
718
|
+
(typeof toolReturn.status === "string"
|
|
719
|
+
? toolReturn.status
|
|
720
|
+
: undefined);
|
|
721
|
+
return this.mergeToolResult({
|
|
722
|
+
toolCallId,
|
|
723
|
+
content,
|
|
724
|
+
isError: status === "error",
|
|
725
|
+
uuid,
|
|
726
|
+
runId,
|
|
727
|
+
seqId,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
default:
|
|
731
|
+
// system/summary/event/hidden-reasoning/approval-response messages are
|
|
732
|
+
// not transcript content this accumulator claims to own.
|
|
733
|
+
return undefined;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ── row bookkeeping ───────────────────────────────────────────
|
|
738
|
+
|
|
739
|
+
private setRow(key: string, row: TranscriptRow): void {
|
|
740
|
+
this.byKey.set(key, row);
|
|
741
|
+
this.snapshot = null;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Move backfilled rows ahead of rows that only exist in the live stream. */
|
|
745
|
+
private reorder(historyKeys: readonly string[]): void {
|
|
746
|
+
if (historyKeys.length === 0) return;
|
|
747
|
+
const historySet = new Set(historyKeys);
|
|
748
|
+
const reordered = new Map<string, TranscriptRow>();
|
|
749
|
+
for (const key of historyKeys) {
|
|
750
|
+
const row = this.byKey.get(key);
|
|
751
|
+
if (row) reordered.set(key, row);
|
|
752
|
+
}
|
|
753
|
+
for (const [key, row] of this.byKey) {
|
|
754
|
+
if (historySet.has(key)) continue;
|
|
755
|
+
reordered.set(key, row);
|
|
756
|
+
}
|
|
757
|
+
this.byKey = reordered;
|
|
758
|
+
this.snapshot = null;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function maxDefined(
|
|
763
|
+
next: number | undefined,
|
|
764
|
+
previous: number | undefined,
|
|
765
|
+
): number | undefined {
|
|
766
|
+
if (next === undefined) return previous;
|
|
767
|
+
if (previous === undefined) return next;
|
|
768
|
+
return Math.max(next, previous);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function normalizeHistoryPage(
|
|
772
|
+
page: TranscriptHistoryPage,
|
|
773
|
+
order: "asc" | "desc" | undefined,
|
|
774
|
+
): readonly LettaMessage[] {
|
|
775
|
+
const messages = Array.isArray(page)
|
|
776
|
+
? (page as readonly LettaMessage[])
|
|
777
|
+
: ((page as { messages?: readonly LettaMessage[] }).messages ?? []);
|
|
778
|
+
if (messages.length < 2) return messages;
|
|
779
|
+
const descending = order ? order === "desc" : detectDescending(messages);
|
|
780
|
+
return descending ? [...messages].reverse() : messages;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* `listMessages()` defaults to newest-first. Detect that from the page itself
|
|
785
|
+
* so callers do not have to restate the order they requested.
|
|
786
|
+
*/
|
|
787
|
+
function detectDescending(messages: readonly LettaMessage[]): boolean {
|
|
788
|
+
const seqIds: number[] = [];
|
|
789
|
+
const dates: number[] = [];
|
|
790
|
+
for (const message of messages) {
|
|
791
|
+
const record = asRecord(message);
|
|
792
|
+
if (!record) continue;
|
|
793
|
+
const seqId = readNumber(record, "seq_id");
|
|
794
|
+
if (seqId !== undefined) seqIds.push(seqId);
|
|
795
|
+
const date = readString(record, "date");
|
|
796
|
+
if (!date) continue;
|
|
797
|
+
const parsed = Date.parse(date);
|
|
798
|
+
if (!Number.isNaN(parsed)) dates.push(parsed);
|
|
799
|
+
}
|
|
800
|
+
const ordered = seqIds.length >= 2 ? seqIds : dates;
|
|
801
|
+
if (ordered.length < 2) return false;
|
|
802
|
+
const first = ordered[0] as number;
|
|
803
|
+
const last = ordered[ordered.length - 1] as number;
|
|
804
|
+
return last < first;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Create a transcript accumulator.
|
|
809
|
+
*
|
|
810
|
+
* @example
|
|
811
|
+
* ```typescript
|
|
812
|
+
* const acc = createTranscriptAccumulator();
|
|
813
|
+
* for await (const message of session.stream()) {
|
|
814
|
+
* render(acc.apply(message));
|
|
815
|
+
* }
|
|
816
|
+
*
|
|
817
|
+
* // Safe mid-run: merges older history without duplicating live rows.
|
|
818
|
+
* acc.rebase(await session.listMessages({ limit: 50 }));
|
|
819
|
+
* ```
|
|
820
|
+
*/
|
|
821
|
+
export function createTranscriptAccumulator(): TranscriptAccumulator {
|
|
822
|
+
return new TranscriptAccumulatorImpl();
|
|
823
|
+
}
|