@agentex/agent 0.0.30 → 0.0.32
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/CHANGELOG.md +69 -0
- package/README.md +72 -3
- package/dist/derived.d.ts.map +1 -1
- package/dist/derived.js +35 -0
- package/dist/derived.js.map +1 -1
- package/dist/history/index.d.ts +1 -1
- package/dist/history/index.d.ts.map +1 -1
- package/dist/history/types.d.ts +74 -1
- package/dist/history/types.d.ts.map +1 -1
- package/dist/history/types.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/providers/codex/history.d.ts.map +1 -1
- package/dist/providers/codex/history.js +3 -1
- package/dist/providers/codex/history.js.map +1 -1
- package/dist/providers/codex/parse.d.ts.map +1 -1
- package/dist/providers/codex/parse.js +15 -5
- package/dist/providers/codex/parse.js.map +1 -1
- package/dist/providers/codex/session.d.ts +16 -0
- package/dist/providers/codex/session.d.ts.map +1 -1
- package/dist/providers/codex/session.js +55 -5
- package/dist/providers/codex/session.js.map +1 -1
- package/dist/providers/codex/transcript-normalize.js +10 -1
- package/dist/providers/codex/transcript-normalize.js.map +1 -1
- package/dist/providers/opencode/history.d.ts +22 -8
- package/dist/providers/opencode/history.d.ts.map +1 -1
- package/dist/providers/opencode/history.js +119 -18
- package/dist/providers/opencode/history.js.map +1 -1
- package/dist/providers/opencode/index.d.ts.map +1 -1
- package/dist/providers/opencode/index.js +17 -0
- package/dist/providers/opencode/index.js.map +1 -1
- package/dist/providers/opencode/probe.d.ts.map +1 -1
- package/dist/providers/opencode/probe.js +2 -0
- package/dist/providers/opencode/probe.js.map +1 -1
- package/dist/providers/opencode/saved-history.d.ts +20 -0
- package/dist/providers/opencode/saved-history.d.ts.map +1 -0
- package/dist/providers/opencode/saved-history.js +361 -0
- package/dist/providers/opencode/saved-history.js.map +1 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/derived.ts +39 -0
- package/src/history/index.ts +10 -0
- package/src/history/types.ts +90 -1
- package/src/index.ts +11 -1
- package/src/providers/codex/history.ts +3 -1
- package/src/providers/codex/parse.ts +16 -5
- package/src/providers/codex/session.ts +55 -5
- package/src/providers/codex/transcript-normalize.ts +11 -1
- package/src/providers/opencode/history.ts +156 -23
- package/src/providers/opencode/index.ts +17 -0
- package/src/providers/opencode/probe.ts +4 -0
- package/src/providers/opencode/saved-history.ts +467 -0
- package/src/types.ts +10 -1
package/src/history/types.ts
CHANGED
|
@@ -1,4 +1,93 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
BaseStreamEventFields,
|
|
3
|
+
HistoryCheckpoint,
|
|
4
|
+
ProviderRuntimeContext,
|
|
5
|
+
StreamEvent,
|
|
6
|
+
} from "../types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Provider-neutral discovery options for persisted sessions. Unlike
|
|
10
|
+
* `LocalHistoryDiscoverOptions`, these options do not assume a filesystem
|
|
11
|
+
* source. Providers may satisfy them through a local authenticated service.
|
|
12
|
+
*/
|
|
13
|
+
export interface SavedHistoryDiscoverOptions extends ProviderRuntimeContext {
|
|
14
|
+
/** Optional provider-session directory filter. `cwd` is runtime context only. */
|
|
15
|
+
directory?: string;
|
|
16
|
+
includeArchived?: boolean;
|
|
17
|
+
mainSessionsOnly?: boolean;
|
|
18
|
+
requireUserMessage?: boolean;
|
|
19
|
+
limit?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SavedHistoryProbeOptions extends ProviderRuntimeContext {
|
|
23
|
+
limit?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SavedHistoryProbeResult {
|
|
27
|
+
providerType: string;
|
|
28
|
+
/** The provider-owned history source could be reached. */
|
|
29
|
+
sourceAvailable: boolean;
|
|
30
|
+
historyAvailable: boolean;
|
|
31
|
+
/** A bounded source count, not necessarily an eligible-session count. */
|
|
32
|
+
approximateCount?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type SavedHistoryArchiveState = "active" | "archived" | "unknown";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Serializable metadata for a provider-owned persisted session. Storage
|
|
39
|
+
* details such as transcript paths, byte offsets, and database layouts are
|
|
40
|
+
* deliberately absent.
|
|
41
|
+
*/
|
|
42
|
+
export interface SavedHistorySession {
|
|
43
|
+
version: 1;
|
|
44
|
+
providerType: string;
|
|
45
|
+
externalSessionId: string;
|
|
46
|
+
cwd: string | null;
|
|
47
|
+
title: string | null;
|
|
48
|
+
startedAt: string | null;
|
|
49
|
+
updatedAt: string;
|
|
50
|
+
branch: string | null;
|
|
51
|
+
gitOriginUrl: string | null;
|
|
52
|
+
archiveState: SavedHistoryArchiveState;
|
|
53
|
+
hasUserMessage: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type SavedHistoryUserEvent = {
|
|
57
|
+
type: "user";
|
|
58
|
+
text: string;
|
|
59
|
+
} & BaseStreamEventFields;
|
|
60
|
+
|
|
61
|
+
export type SavedHistoryEvent = StreamEvent | SavedHistoryUserEvent;
|
|
62
|
+
|
|
63
|
+
export interface SavedHistoryYield {
|
|
64
|
+
event: SavedHistoryEvent & { eventId: string };
|
|
65
|
+
/** Opaque provider-owned bookmark. Persist only after the event commits. */
|
|
66
|
+
checkpoint: HistoryCheckpoint;
|
|
67
|
+
eventId: string;
|
|
68
|
+
/** Disambiguates normalized events that share one provider source part. */
|
|
69
|
+
partIndex: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SavedHistoryReadOptions extends ProviderRuntimeContext {
|
|
73
|
+
after?: HistoryCheckpoint;
|
|
74
|
+
mode?: "incremental" | "bounded_full_resync";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Discover and read provider-owned saved sessions when their ids are not yet
|
|
79
|
+
* known. This is the provider-neutral import/synchronization surface. It is
|
|
80
|
+
* separate from `attachHistory`, which starts from a known `SessionRecord`,
|
|
81
|
+
* and from the file-specific `LocalHistoryOps` compatibility API.
|
|
82
|
+
*/
|
|
83
|
+
export interface SavedHistoryOps {
|
|
84
|
+
probe(options?: SavedHistoryProbeOptions): Promise<SavedHistoryProbeResult>;
|
|
85
|
+
discover(options?: SavedHistoryDiscoverOptions): AsyncIterable<SavedHistorySession>;
|
|
86
|
+
read(
|
|
87
|
+
session: SavedHistorySession,
|
|
88
|
+
options?: SavedHistoryReadOptions,
|
|
89
|
+
): AsyncIterable<SavedHistoryYield>;
|
|
90
|
+
}
|
|
2
91
|
|
|
3
92
|
export interface LocalHistoryDiscoverOptions {
|
|
4
93
|
includeArchived?: boolean;
|
package/src/index.ts
CHANGED
|
@@ -206,9 +206,19 @@ export type {
|
|
|
206
206
|
GoalSentinelContext,
|
|
207
207
|
} from "./types.js";
|
|
208
208
|
|
|
209
|
-
//
|
|
209
|
+
// Persisted session discovery. Runtime errors are exported from the
|
|
210
210
|
// `@agentex/agent/history` subpath to keep the root barrel lazy.
|
|
211
211
|
export type {
|
|
212
|
+
SavedHistoryArchiveState,
|
|
213
|
+
SavedHistoryDiscoverOptions,
|
|
214
|
+
SavedHistoryEvent,
|
|
215
|
+
SavedHistoryOps,
|
|
216
|
+
SavedHistoryProbeOptions,
|
|
217
|
+
SavedHistoryProbeResult,
|
|
218
|
+
SavedHistoryReadOptions,
|
|
219
|
+
SavedHistorySession,
|
|
220
|
+
SavedHistoryUserEvent,
|
|
221
|
+
SavedHistoryYield,
|
|
212
222
|
LocalHistoryArchiveState,
|
|
213
223
|
LocalHistoryDiscoverOptions,
|
|
214
224
|
LocalHistoryErrorCode,
|
|
@@ -246,7 +246,9 @@ function legacyCodexEvents(
|
|
|
246
246
|
const base = baseFields(session, line, eventId);
|
|
247
247
|
if (line.type === "message" && line.raw["role"] === "assistant") {
|
|
248
248
|
const text = textFromContent(line.raw["content"], new Set(["text", "output_text"]));
|
|
249
|
-
|
|
249
|
+
const rawPhase = line.raw["phase"];
|
|
250
|
+
const phase = rawPhase === "commentary" || rawPhase === "final_answer" ? rawPhase : undefined;
|
|
251
|
+
return text ? [{ type: "assistant", text, ...(phase ? { phase } : {}), ...base }] : [];
|
|
250
252
|
}
|
|
251
253
|
if (line.type === "reasoning") {
|
|
252
254
|
const text = textFromContent(line.raw["summary"] ?? line.raw["content"], new Set(["text", "summary_text"]));
|
|
@@ -53,6 +53,10 @@ function asNullableNumber(value: unknown): number | null {
|
|
|
53
53
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
function asMessagePhase(value: unknown): "commentary" | "final_answer" | undefined {
|
|
57
|
+
return value === "commentary" || value === "final_answer" ? value : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
56
60
|
function parseObject(value: unknown): Record<string, unknown> {
|
|
57
61
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
58
62
|
return value as Record<string, unknown>;
|
|
@@ -333,10 +337,11 @@ function parseV2Notification(event: Record<string, unknown>): StreamEvent | null
|
|
|
333
337
|
asNullableString(item["id"]) ??
|
|
334
338
|
asNullableString(item["call_id"]);
|
|
335
339
|
const base = makeBase(itemId);
|
|
340
|
+
const isCommandExecution = itemType === "command_execution" || itemType === "commandExecution";
|
|
336
341
|
|
|
337
342
|
// Tool starts — emit tool_call on item/started only.
|
|
338
343
|
if (method === "item/started") {
|
|
339
|
-
if (
|
|
344
|
+
if (isCommandExecution) {
|
|
340
345
|
return {
|
|
341
346
|
type: "tool_call",
|
|
342
347
|
toolCallId: itemId,
|
|
@@ -359,14 +364,15 @@ function parseV2Notification(event: Record<string, unknown>): StreamEvent | null
|
|
|
359
364
|
}
|
|
360
365
|
|
|
361
366
|
// item/completed — emit the terminal event for each item type.
|
|
362
|
-
if (
|
|
363
|
-
const exitCode = asNullableNumber(item["exit_code"]);
|
|
367
|
+
if (isCommandExecution) {
|
|
368
|
+
const exitCode = asNullableNumber(item["exit_code"] ?? item["exitCode"]);
|
|
369
|
+
const status = asString(item["status"], "");
|
|
364
370
|
return {
|
|
365
371
|
type: "tool_result",
|
|
366
372
|
toolCallId: itemId,
|
|
367
373
|
toolName: "command_execution",
|
|
368
|
-
content: asString(item["aggregated_output"], ""),
|
|
369
|
-
isError: exitCode !== null && exitCode !== 0,
|
|
374
|
+
content: asString(item["aggregated_output"], asString(item["aggregatedOutput"], "")),
|
|
375
|
+
isError: status === "failed" || status === "declined" || (exitCode !== null && exitCode !== 0),
|
|
370
376
|
exitCode,
|
|
371
377
|
...base,
|
|
372
378
|
};
|
|
@@ -386,9 +392,11 @@ function parseV2Notification(event: Record<string, unknown>): StreamEvent | null
|
|
|
386
392
|
if (itemType === "agentMessage") {
|
|
387
393
|
const directText = asString(item["text"], "");
|
|
388
394
|
if (directText || directText === "") {
|
|
395
|
+
const phase = asMessagePhase(item["phase"]);
|
|
389
396
|
return {
|
|
390
397
|
type: "assistant",
|
|
391
398
|
text: directText,
|
|
399
|
+
...(phase ? { phase } : {}),
|
|
392
400
|
...base,
|
|
393
401
|
};
|
|
394
402
|
}
|
|
@@ -575,11 +583,13 @@ function parseNdjsonEvent(
|
|
|
575
583
|
};
|
|
576
584
|
}
|
|
577
585
|
if (itemType === "agent_message") {
|
|
586
|
+
const phase = asMessagePhase(item["phase"]);
|
|
578
587
|
const directText = asString(item["text"], "");
|
|
579
588
|
if (directText) {
|
|
580
589
|
return {
|
|
581
590
|
type: "assistant",
|
|
582
591
|
text: directText,
|
|
592
|
+
...(phase ? { phase } : {}),
|
|
583
593
|
...base,
|
|
584
594
|
};
|
|
585
595
|
}
|
|
@@ -591,6 +601,7 @@ function parseNdjsonEvent(
|
|
|
591
601
|
return {
|
|
592
602
|
type: "assistant",
|
|
593
603
|
text: asString(block["text"], ""),
|
|
604
|
+
...(phase ? { phase } : {}),
|
|
594
605
|
...base,
|
|
595
606
|
};
|
|
596
607
|
}
|
|
@@ -307,9 +307,16 @@ export { codexGoalCapability } from "./goal-capability.js";
|
|
|
307
307
|
|
|
308
308
|
export class CodexSessionImpl implements AgentSession {
|
|
309
309
|
private _state: SessionState = "idle";
|
|
310
|
+
/**
|
|
311
|
+
* The root thread represented by this AgentSession. Codex app-server also
|
|
312
|
+
* reports child-agent threads on the same stdout connection, so this id is
|
|
313
|
+
* pinned once discovered and must never be promoted to a child thread.
|
|
314
|
+
*/
|
|
310
315
|
private _threadId: string | null = null;
|
|
311
316
|
/** Thread id to resume (from ctx.sessionParams); null starts a fresh thread. */
|
|
312
317
|
private readonly _resumeThreadId: string | null;
|
|
318
|
+
/** Expected root during handshake, cleared when resume falls back to fresh. */
|
|
319
|
+
private _expectedThreadId: string | null;
|
|
313
320
|
private _lineBuffer = "";
|
|
314
321
|
private _nextId = 1;
|
|
315
322
|
|
|
@@ -366,6 +373,7 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
366
373
|
private readonly instructions: string | null,
|
|
367
374
|
) {
|
|
368
375
|
this._resumeThreadId = readCodexResumeId(ctx.sessionParams);
|
|
376
|
+
this._expectedThreadId = this._resumeThreadId;
|
|
369
377
|
|
|
370
378
|
this._goals = new GoalController({
|
|
371
379
|
providerType: "codex",
|
|
@@ -527,12 +535,18 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
527
535
|
// thread/resume may echo the thread back or return {}; fall back to the
|
|
528
536
|
// id we resumed with so `sessionId` is always populated.
|
|
529
537
|
this._threadId = str(thread, "id") || str(thread, "sessionId") || this._resumeThreadId;
|
|
538
|
+
this._expectedThreadId = this._threadId;
|
|
530
539
|
// Rehydrate a durable Codex goal so getGoal() reflects it immediately
|
|
531
540
|
// (goals live in SQLite, not the transcript, so a resumed thread would
|
|
532
541
|
// otherwise report null until the next goal notification).
|
|
533
542
|
await this.hydrateGoalFromThread();
|
|
534
543
|
return;
|
|
535
544
|
} catch (err) {
|
|
545
|
+
// A failed resume can emit thread/started before its error response.
|
|
546
|
+
// Clear that provisional identity so the fresh thread's init event is
|
|
547
|
+
// accepted instead of being mistaken for a foreign child thread.
|
|
548
|
+
if (this._threadId === this._expectedThreadId) this._threadId = null;
|
|
549
|
+
this._expectedThreadId = null;
|
|
536
550
|
// The thread is unknown to this codex install (different machine, pruned
|
|
537
551
|
// history). Don't fail the whole session — fall back to a fresh thread
|
|
538
552
|
// and surface the downgrade on stderr. The new id flows back out via
|
|
@@ -586,6 +600,7 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
586
600
|
// codex-cli 0.130.0+ shape: { thread: { id, sessionId, ... }, model, ... }
|
|
587
601
|
const thread = asObj(res, "thread");
|
|
588
602
|
this._threadId = str(thread, "id") || str(thread, "sessionId") || null;
|
|
603
|
+
this._expectedThreadId = this._threadId;
|
|
589
604
|
}
|
|
590
605
|
|
|
591
606
|
// -------------------------------------------------------------------------
|
|
@@ -971,6 +986,23 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
971
986
|
// Notification handling (v2 format)
|
|
972
987
|
// -------------------------------------------------------------------------
|
|
973
988
|
|
|
989
|
+
/** Extract the thread scope carried by a v2 app-server notification. */
|
|
990
|
+
private notificationThreadId(params: Record<string, unknown>): string | null {
|
|
991
|
+
const thread = asObj(params, "thread");
|
|
992
|
+
return str(params, "threadId") || str(thread, "id") || str(thread, "sessionId") || null;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Whether an explicitly-scoped event belongs to another app-server thread.
|
|
997
|
+
* `_expectedThreadId` protects the resume handshake window before `_threadId`
|
|
998
|
+
* has been populated and is cleared if resume falls back to a fresh thread.
|
|
999
|
+
* Unscoped global notifications remain eligible.
|
|
1000
|
+
*/
|
|
1001
|
+
private isForeignThread(threadId: string | null): boolean {
|
|
1002
|
+
const rootThreadId = this._threadId ?? this._expectedThreadId;
|
|
1003
|
+
return !!threadId && !!rootThreadId && threadId !== rootThreadId;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
974
1006
|
private handleNotification(method: string, params: Record<string, unknown>, rawLine: string): void {
|
|
975
1007
|
// codex/event — legacy wrapper
|
|
976
1008
|
if (method === "codex/event") {
|
|
@@ -983,11 +1015,17 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
983
1015
|
return;
|
|
984
1016
|
}
|
|
985
1017
|
|
|
1018
|
+
// One Codex app-server connection multiplexes notifications for the root
|
|
1019
|
+
// thread and any child agents it spawns. An AgentSession represents only
|
|
1020
|
+
// its root thread, so foreign items must not change root state/summary and,
|
|
1021
|
+
// most importantly, a child turn/completed must not resolve the root send.
|
|
1022
|
+
const notificationThreadId = this.notificationThreadId(params);
|
|
1023
|
+
if (this.isForeignThread(notificationThreadId)) return;
|
|
1024
|
+
|
|
986
1025
|
// Map v2 notification methods to processing
|
|
987
1026
|
if (method === "thread/started") {
|
|
988
1027
|
// codex-cli 0.130.0+ shape: { thread: { id, sessionId, ... } }
|
|
989
|
-
|
|
990
|
-
this._threadId = str(thread, "id") || str(thread, "sessionId") || this._threadId;
|
|
1028
|
+
if (!this._threadId) this._threadId = notificationThreadId;
|
|
991
1029
|
this.emitStreamEvent(rawLine);
|
|
992
1030
|
return;
|
|
993
1031
|
}
|
|
@@ -1041,9 +1079,15 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1041
1079
|
|
|
1042
1080
|
private handleLegacyEvent(event: Record<string, unknown>, rawLine: string): void {
|
|
1043
1081
|
const type = str(event, "type");
|
|
1082
|
+
const eventThreadId =
|
|
1083
|
+
str(event, "thread_id") || str(event, "threadId") || str(event, "session_id") || null;
|
|
1084
|
+
|
|
1085
|
+
// Older NDJSON-shaped events can also carry explicit thread scope. Keep
|
|
1086
|
+
// the same root-only invariant when that scope is available.
|
|
1087
|
+
if (this.isForeignThread(eventThreadId)) return;
|
|
1044
1088
|
|
|
1045
1089
|
if (type === "thread.started") {
|
|
1046
|
-
this._threadId
|
|
1090
|
+
if (!this._threadId) this._threadId = eventThreadId;
|
|
1047
1091
|
this.emitStreamEvent(rawLine);
|
|
1048
1092
|
return;
|
|
1049
1093
|
}
|
|
@@ -1093,6 +1137,11 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1093
1137
|
const itemType = str(item, "type");
|
|
1094
1138
|
if (itemType !== "agent_message" && itemType !== "agentMessage") return;
|
|
1095
1139
|
|
|
1140
|
+
// Commentary is progress, not the terminal answer. Keep phase-absent
|
|
1141
|
+
// legacy events as a compatibility fallback, while known final_answer
|
|
1142
|
+
// items remain eligible for TurnResult.summary.
|
|
1143
|
+
if (str(item, "phase") === "commentary") return;
|
|
1144
|
+
|
|
1096
1145
|
// Direct text (Codex 0.30+)
|
|
1097
1146
|
const directText = str(item, "text");
|
|
1098
1147
|
if (directText) {
|
|
@@ -1280,8 +1329,9 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1280
1329
|
// share an id; the last write wins. It also does NOT match the transcript
|
|
1281
1330
|
// reader's `codex:<sessionId>:<offset>` scheme (different wire vocabulary
|
|
1282
1331
|
// on disk) — cross-shape dedup remains a host concern.
|
|
1283
|
-
|
|
1284
|
-
|
|
1332
|
+
const eventThreadId = event.sessionId ?? this._threadId;
|
|
1333
|
+
if (!event.eventId && eventThreadId && event.turnId && event.messageId) {
|
|
1334
|
+
event.eventId = `codex:${eventThreadId}:${event.turnId}:${event.messageId}:${event.type}`;
|
|
1285
1335
|
}
|
|
1286
1336
|
// Enrich synchronously (in stream order) so tool_result events carry the
|
|
1287
1337
|
// name of the tool_call they answer.
|
|
@@ -61,7 +61,13 @@ function mapLine(line: CodexTranscriptLine, sessionId: string | null): StreamEve
|
|
|
61
61
|
// Only assistant messages surface; developer/user messages are
|
|
62
62
|
// system-prompt material we don't replay.
|
|
63
63
|
if (payload["role"] !== "assistant") return [];
|
|
64
|
-
|
|
64
|
+
const phase = messagePhase(payload["phase"]);
|
|
65
|
+
return [{
|
|
66
|
+
type: "assistant",
|
|
67
|
+
text: extractMessageText(payload["content"]) ?? "",
|
|
68
|
+
...(phase ? { phase } : {}),
|
|
69
|
+
...base,
|
|
70
|
+
}];
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
if (innerType === "reasoning") {
|
|
@@ -129,6 +135,10 @@ function str(v: unknown): string | null {
|
|
|
129
135
|
return typeof v === "string" && v.length > 0 ? v : null;
|
|
130
136
|
}
|
|
131
137
|
|
|
138
|
+
function messagePhase(v: unknown): "commentary" | "final_answer" | undefined {
|
|
139
|
+
return v === "commentary" || v === "final_answer" ? v : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
132
142
|
/**
|
|
133
143
|
* `response_item/message.content` is an array of typed parts (`output_text`
|
|
134
144
|
* for assistant replies). Concat the text parts with a blank-line separator.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
1
3
|
import type {
|
|
2
4
|
AttachOptions,
|
|
3
5
|
HistoryAttachment,
|
|
@@ -6,8 +8,9 @@ import type {
|
|
|
6
8
|
HistoryCheckpoint,
|
|
7
9
|
LastTurnStatus,
|
|
8
10
|
SessionRecord,
|
|
9
|
-
StreamEvent,
|
|
10
11
|
} from "../../types.js";
|
|
12
|
+
import type { SavedHistoryEvent, SavedHistoryUserEvent } from "../../history/types.js";
|
|
13
|
+
import { meaningfulHumanText } from "../../history/fs.js";
|
|
11
14
|
import { assertSessionRecord, createSessionRecord, MalformedSessionRecordError } from "../../sessions/record.js";
|
|
12
15
|
import { acquireOpenCodeRuntime } from "./runtime.js";
|
|
13
16
|
import { opencodeSessionCodec } from "./codec.js";
|
|
@@ -18,7 +21,7 @@ const PAGE_SIZE = 100;
|
|
|
18
21
|
const MAX_PAGES = 100;
|
|
19
22
|
const MAX_MESSAGES = 10_000;
|
|
20
23
|
const MAX_BYTES = 25 * 1024 * 1024;
|
|
21
|
-
const CHECKPOINT_KIND = "opencode:message-part:
|
|
24
|
+
const CHECKPOINT_KIND = "opencode:message-part:v2";
|
|
22
25
|
|
|
23
26
|
export class OpenCodeHistoryCheckpointNotFoundError extends Error {
|
|
24
27
|
readonly code = "history_checkpoint_not_found";
|
|
@@ -36,17 +39,39 @@ export class OpenCodeHistoryResyncLimitError extends Error {
|
|
|
36
39
|
}
|
|
37
40
|
}
|
|
38
41
|
|
|
42
|
+
export class OpenCodeHistorySourceMissingError extends Error {
|
|
43
|
+
readonly code = "source_missing";
|
|
44
|
+
|
|
45
|
+
constructor(sessionId: string) {
|
|
46
|
+
super(`OpenCode saved session ${JSON.stringify(sessionId)} no longer exists`);
|
|
47
|
+
this.name = "OpenCodeHistorySourceMissingError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
interface DecodedCheckpoint {
|
|
40
52
|
messageId: string;
|
|
41
53
|
partId: string;
|
|
42
54
|
ordinal: number | null;
|
|
55
|
+
messageRevision: string;
|
|
43
56
|
}
|
|
44
57
|
|
|
45
|
-
export interface MessageEnvelope {
|
|
58
|
+
export interface MessageEnvelope extends Record<string, unknown> {
|
|
46
59
|
info?: Record<string, unknown>;
|
|
47
60
|
parts?: unknown[];
|
|
48
61
|
}
|
|
49
62
|
|
|
63
|
+
interface HistoricalEvent {
|
|
64
|
+
event: SavedHistoryEvent;
|
|
65
|
+
partId: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CollectedHistoryEvent {
|
|
69
|
+
event: SavedHistoryEvent;
|
|
70
|
+
checkpoint: HistoryCheckpoint;
|
|
71
|
+
eventId: string | null;
|
|
72
|
+
partIndex: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
50
75
|
function rec(value: unknown): Record<string, unknown> | null {
|
|
51
76
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
52
77
|
? value as Record<string, unknown>
|
|
@@ -57,35 +82,118 @@ function string(value: unknown): string | null {
|
|
|
57
82
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
58
83
|
}
|
|
59
84
|
|
|
85
|
+
function historicalTimestamp(info: Record<string, unknown>): string {
|
|
86
|
+
const created = rec(info["time"])?.["created"];
|
|
87
|
+
if (typeof created === "number" && Number.isFinite(created)) {
|
|
88
|
+
const timestamp = new Date(created);
|
|
89
|
+
if (!Number.isNaN(timestamp.getTime())) return timestamp.toISOString();
|
|
90
|
+
}
|
|
91
|
+
return new Date(0).toISOString();
|
|
92
|
+
}
|
|
93
|
+
|
|
60
94
|
function decodeCheckpoint(checkpoint: HistoryCheckpoint | undefined): DecodedCheckpoint | null {
|
|
61
95
|
if (!checkpoint) return null;
|
|
62
96
|
if (checkpoint.kind !== CHECKPOINT_KIND) throw new OpenCodeHistoryCheckpointNotFoundError();
|
|
63
97
|
const value = rec(checkpoint.value);
|
|
64
98
|
const messageId = string(value?.["messageId"]);
|
|
65
99
|
const partId = string(value?.["partId"]);
|
|
66
|
-
|
|
100
|
+
const messageRevision = string(value?.["messageRevision"]);
|
|
101
|
+
if (!messageId || !partId || !messageRevision) throw new OpenCodeHistoryCheckpointNotFoundError();
|
|
67
102
|
const ordinal = typeof value?.["ordinal"] === "number" && Number.isInteger(value["ordinal"])
|
|
68
103
|
? value["ordinal"] as number
|
|
69
104
|
: null;
|
|
70
|
-
return { messageId, partId, ordinal };
|
|
105
|
+
return { messageId, partId, ordinal, messageRevision };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function checkpoint(
|
|
109
|
+
messageId: string,
|
|
110
|
+
partId: string,
|
|
111
|
+
ordinal: number,
|
|
112
|
+
messageRevision: string,
|
|
113
|
+
): HistoryCheckpoint {
|
|
114
|
+
return {
|
|
115
|
+
kind: CHECKPOINT_KIND,
|
|
116
|
+
value: { messageId, partId, ordinal, messageRevision },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function canonicalJson(value: unknown): unknown {
|
|
121
|
+
if (Array.isArray(value)) return value.map(canonicalJson);
|
|
122
|
+
const record = rec(value);
|
|
123
|
+
if (record) {
|
|
124
|
+
return Object.fromEntries(
|
|
125
|
+
Object.keys(record).sort().map((key) => [key, canonicalJson(record[key])]),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return value;
|
|
71
129
|
}
|
|
72
130
|
|
|
73
|
-
function
|
|
74
|
-
return
|
|
131
|
+
function messageRevision(message: MessageEnvelope): string {
|
|
132
|
+
return createHash("sha256")
|
|
133
|
+
.update(JSON.stringify(canonicalJson(message)))
|
|
134
|
+
.digest("hex");
|
|
75
135
|
}
|
|
76
136
|
|
|
77
|
-
|
|
137
|
+
function userTextPart(part: Record<string, unknown>): string | null {
|
|
138
|
+
if (part["synthetic"] === true || part["ignored"] === true) return null;
|
|
139
|
+
if (part["type"] === "text") return string(part["text"]);
|
|
140
|
+
if (part["type"] === "subtask") return string(part["prompt"]);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function historicalUserEvent(
|
|
145
|
+
message: MessageEnvelope,
|
|
146
|
+
sessionId: string,
|
|
147
|
+
timestamp: string,
|
|
148
|
+
): HistoricalEvent | null {
|
|
78
149
|
const info = message.info ?? {};
|
|
79
|
-
if (info["role"] !== "assistant") return [];
|
|
80
150
|
const messageId = string(info["id"]);
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
151
|
+
const texts: string[] = [];
|
|
152
|
+
let partId: string | null = null;
|
|
153
|
+
for (const rawPart of Array.isArray(message.parts) ? message.parts : []) {
|
|
154
|
+
const part = rec(rawPart);
|
|
155
|
+
const text = part ? userTextPart(part) : null;
|
|
156
|
+
if (!part || !text) continue;
|
|
157
|
+
const candidatePartId = string(part["id"]);
|
|
158
|
+
if (!candidatePartId) continue;
|
|
159
|
+
texts.push(text);
|
|
160
|
+
partId = candidatePartId;
|
|
161
|
+
}
|
|
162
|
+
const text = meaningfulHumanText(texts.join("\n\n"));
|
|
163
|
+
if (!text || !partId) return null;
|
|
164
|
+
const eventId = messageId ?? partId;
|
|
165
|
+
const event: SavedHistoryUserEvent = {
|
|
166
|
+
type: "user",
|
|
167
|
+
text,
|
|
168
|
+
timestamp,
|
|
169
|
+
providerType: "opencode",
|
|
170
|
+
sessionId,
|
|
171
|
+
messageId,
|
|
172
|
+
eventId,
|
|
173
|
+
turnId: null,
|
|
174
|
+
parentToolCallId: null,
|
|
175
|
+
raw: message,
|
|
176
|
+
};
|
|
177
|
+
return { event, partId };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function historicalEvents(
|
|
181
|
+
message: MessageEnvelope,
|
|
182
|
+
sessionId: string,
|
|
183
|
+
options: { includeUserMessages?: boolean } = {},
|
|
184
|
+
): HistoricalEvent[] {
|
|
185
|
+
const info = rec(message.info) ?? {};
|
|
186
|
+
const messageId = string(info["id"]);
|
|
187
|
+
const timestamp = historicalTimestamp(info);
|
|
188
|
+
if (info["role"] === "user") {
|
|
189
|
+
if (!options.includeUserMessages) return [];
|
|
190
|
+
const user = historicalUserEvent(message, sessionId, timestamp);
|
|
191
|
+
return user ? [user] : [];
|
|
192
|
+
}
|
|
193
|
+
if (info["role"] !== "assistant") return [];
|
|
86
194
|
const base: OcBaseInfo = { provider: "opencode", sessionId, timestamp };
|
|
87
|
-
const events:
|
|
88
|
-
for (const rawPart of message.parts
|
|
195
|
+
const events: HistoricalEvent[] = [];
|
|
196
|
+
for (const rawPart of Array.isArray(message.parts) ? message.parts : []) {
|
|
89
197
|
const part = rec(rawPart);
|
|
90
198
|
const partId = string(part?.["id"]);
|
|
91
199
|
if (!part || !partId) continue;
|
|
@@ -99,7 +207,8 @@ export function historicalEvents(message: MessageEnvelope, sessionId: string): A
|
|
|
99
207
|
if (event) events.push({ event, partId });
|
|
100
208
|
}
|
|
101
209
|
const lastPartId = events.at(-1)?.partId;
|
|
102
|
-
|
|
210
|
+
const finished = string(info["finish"]) !== null || info["error"] != null;
|
|
211
|
+
if (messageId && lastPartId && finished) {
|
|
103
212
|
const error = info["error"] != null;
|
|
104
213
|
events.push({
|
|
105
214
|
partId: lastPartId,
|
|
@@ -130,7 +239,11 @@ export async function collectHistory(
|
|
|
130
239
|
client: import("./client.js").OpenCodeClient,
|
|
131
240
|
sessionId: string,
|
|
132
241
|
options: HistoryCatchUpOptions | undefined,
|
|
133
|
-
|
|
242
|
+
mapping: {
|
|
243
|
+
includeUserMessages?: boolean;
|
|
244
|
+
missingSession?: "empty" | "error";
|
|
245
|
+
} = {},
|
|
246
|
+
): Promise<CollectedHistoryEvent[]> {
|
|
134
247
|
const after = options?.mode === "bounded_full_resync" ? null : decodeCheckpoint(options?.after);
|
|
135
248
|
const pages: MessageEnvelope[][] = [];
|
|
136
249
|
let before: string | null = null;
|
|
@@ -142,7 +255,12 @@ export async function collectHistory(
|
|
|
142
255
|
const query = new URLSearchParams({ limit: String(PAGE_SIZE) });
|
|
143
256
|
if (before) query.set("before", before);
|
|
144
257
|
const response = await client.request(`/session/${encodeURIComponent(sessionId)}/message?${query}`);
|
|
145
|
-
if (response.status === 404)
|
|
258
|
+
if (response.status === 404) {
|
|
259
|
+
if (mapping.missingSession === "error") {
|
|
260
|
+
throw new OpenCodeHistorySourceMissingError(sessionId);
|
|
261
|
+
}
|
|
262
|
+
return [];
|
|
263
|
+
}
|
|
146
264
|
if (!response.ok) throw new Error(`OpenCode history request failed (${response.status})`);
|
|
147
265
|
const text = await response.text();
|
|
148
266
|
collectedBytes += Buffer.byteLength(text);
|
|
@@ -171,13 +289,21 @@ export async function collectHistory(
|
|
|
171
289
|
}
|
|
172
290
|
|
|
173
291
|
const messages = pages.reverse().flat();
|
|
174
|
-
const output:
|
|
292
|
+
const output: CollectedHistoryEvent[] = [];
|
|
175
293
|
let passedCheckpoint = after === null;
|
|
176
294
|
let foundPart = after === null;
|
|
177
295
|
for (const message of messages) {
|
|
178
296
|
const messageId = string(message.info?.["id"]);
|
|
179
297
|
if (!messageId) continue;
|
|
180
|
-
const
|
|
298
|
+
const revision = messageRevision(message);
|
|
299
|
+
if (
|
|
300
|
+
after
|
|
301
|
+
&& messageId === after.messageId
|
|
302
|
+
&& revision !== after.messageRevision
|
|
303
|
+
) {
|
|
304
|
+
throw new OpenCodeHistoryCheckpointNotFoundError();
|
|
305
|
+
}
|
|
306
|
+
const events = historicalEvents(message, sessionId, mapping);
|
|
181
307
|
const partOrdinals = new Map<string, number>();
|
|
182
308
|
for (const item of events) {
|
|
183
309
|
const ordinal = partOrdinals.get(item.partId) ?? 0;
|
|
@@ -204,8 +330,9 @@ export async function collectHistory(
|
|
|
204
330
|
}
|
|
205
331
|
output.push({
|
|
206
332
|
event: item.event,
|
|
207
|
-
checkpoint: checkpoint(messageId, item.partId, ordinal),
|
|
333
|
+
checkpoint: checkpoint(messageId, item.partId, ordinal, revision),
|
|
208
334
|
eventId: item.event.eventId,
|
|
335
|
+
partIndex: ordinal,
|
|
209
336
|
});
|
|
210
337
|
}
|
|
211
338
|
if (foundPart && messageId === after?.messageId) passedCheckpoint = true;
|
|
@@ -256,7 +383,13 @@ export async function attachOpenCodeHistory(
|
|
|
256
383
|
catchUp(options) {
|
|
257
384
|
return {
|
|
258
385
|
async *[Symbol.asyncIterator](): AsyncIterator<HistoryCatchUpYield> {
|
|
259
|
-
for (const yielded of await collectHistory(runtime.server.client, sessionId, options))
|
|
386
|
+
for (const yielded of await collectHistory(runtime.server.client, sessionId, options)) {
|
|
387
|
+
// Known-session catch-up deliberately excludes user prompts. The
|
|
388
|
+
// host already owns them. `savedHistory.read()` opts in to users
|
|
389
|
+
// when importing a conversation the host did not create.
|
|
390
|
+
const event = yielded.event;
|
|
391
|
+
if (event.type !== "user") yield { ...yielded, event };
|
|
392
|
+
}
|
|
260
393
|
},
|
|
261
394
|
};
|
|
262
395
|
},
|
|
@@ -20,6 +20,7 @@ export const opencodeProvider: ProviderModule = {
|
|
|
20
20
|
modes: true,
|
|
21
21
|
goals: EMULATED_GOAL_CAPABILITY,
|
|
22
22
|
durableHistory: true,
|
|
23
|
+
savedHistory: true,
|
|
23
24
|
resume: true,
|
|
24
25
|
modelVariants: true,
|
|
25
26
|
permissionRequests: true,
|
|
@@ -50,4 +51,20 @@ export const opencodeProvider: ProviderModule = {
|
|
|
50
51
|
disconnect: (providerId, ctx) => import("./manager.js").then((m) => m.openCodeUpstreamProviders.disconnect(providerId, ctx)),
|
|
51
52
|
},
|
|
52
53
|
attachHistory: (record, options) => import("./history.js").then((m) => m.attachOpenCodeHistory(record, options)),
|
|
54
|
+
savedHistory: {
|
|
55
|
+
probe: (options) => import("./saved-history.js")
|
|
56
|
+
.then((module) => module.openCodeSavedHistory.probe(options)),
|
|
57
|
+
discover: (options) => ({
|
|
58
|
+
async *[Symbol.asyncIterator]() {
|
|
59
|
+
const module = await import("./saved-history.js");
|
|
60
|
+
yield* module.openCodeSavedHistory.discover(options);
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
read: (session, options) => ({
|
|
64
|
+
async *[Symbol.asyncIterator]() {
|
|
65
|
+
const module = await import("./saved-history.js");
|
|
66
|
+
yield* module.openCodeSavedHistory.read(session, options);
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
},
|
|
53
70
|
};
|