@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +72 -3
  3. package/dist/derived.d.ts.map +1 -1
  4. package/dist/derived.js +35 -0
  5. package/dist/derived.js.map +1 -1
  6. package/dist/history/index.d.ts +1 -1
  7. package/dist/history/index.d.ts.map +1 -1
  8. package/dist/history/types.d.ts +74 -1
  9. package/dist/history/types.d.ts.map +1 -1
  10. package/dist/history/types.js.map +1 -1
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/providers/codex/history.d.ts.map +1 -1
  15. package/dist/providers/codex/history.js +3 -1
  16. package/dist/providers/codex/history.js.map +1 -1
  17. package/dist/providers/codex/parse.d.ts.map +1 -1
  18. package/dist/providers/codex/parse.js +15 -5
  19. package/dist/providers/codex/parse.js.map +1 -1
  20. package/dist/providers/codex/session.d.ts +16 -0
  21. package/dist/providers/codex/session.d.ts.map +1 -1
  22. package/dist/providers/codex/session.js +55 -5
  23. package/dist/providers/codex/session.js.map +1 -1
  24. package/dist/providers/codex/transcript-normalize.js +10 -1
  25. package/dist/providers/codex/transcript-normalize.js.map +1 -1
  26. package/dist/providers/opencode/history.d.ts +22 -8
  27. package/dist/providers/opencode/history.d.ts.map +1 -1
  28. package/dist/providers/opencode/history.js +119 -18
  29. package/dist/providers/opencode/history.js.map +1 -1
  30. package/dist/providers/opencode/index.d.ts.map +1 -1
  31. package/dist/providers/opencode/index.js +17 -0
  32. package/dist/providers/opencode/index.js.map +1 -1
  33. package/dist/providers/opencode/probe.d.ts.map +1 -1
  34. package/dist/providers/opencode/probe.js +2 -0
  35. package/dist/providers/opencode/probe.js.map +1 -1
  36. package/dist/providers/opencode/saved-history.d.ts +20 -0
  37. package/dist/providers/opencode/saved-history.d.ts.map +1 -0
  38. package/dist/providers/opencode/saved-history.js +361 -0
  39. package/dist/providers/opencode/saved-history.js.map +1 -0
  40. package/dist/types.d.ts +6 -0
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/types.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/derived.ts +39 -0
  45. package/src/history/index.ts +10 -0
  46. package/src/history/types.ts +90 -1
  47. package/src/index.ts +11 -1
  48. package/src/providers/codex/history.ts +3 -1
  49. package/src/providers/codex/parse.ts +16 -5
  50. package/src/providers/codex/session.ts +55 -5
  51. package/src/providers/codex/transcript-normalize.ts +11 -1
  52. package/src/providers/opencode/history.ts +156 -23
  53. package/src/providers/opencode/index.ts +17 -0
  54. package/src/providers/opencode/probe.ts +4 -0
  55. package/src/providers/opencode/saved-history.ts +467 -0
  56. package/src/types.ts +10 -1
@@ -1,4 +1,93 @@
1
- import type { BaseStreamEventFields, StreamEvent } from "../types.js";
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
- // Local persisted session discovery. Runtime errors are exported from the
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
- return text ? [{ type: "assistant", text, ...base }] : [];
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 (itemType === "command_execution") {
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 (itemType === "command_execution") {
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
- const thread = asObj(params, "thread");
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 = str(event, "thread_id") || 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
- if (!event.eventId && this._threadId && event.turnId && event.messageId) {
1284
- event.eventId = `codex:${this._threadId}:${event.turnId}:${event.messageId}:${event.type}`;
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
- return [{ type: "assistant", text: extractMessageText(payload["content"]) ?? "", ...base }];
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:v1";
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
- if (!messageId || !partId) throw new OpenCodeHistoryCheckpointNotFoundError();
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 checkpoint(messageId: string, partId: string, ordinal: number): HistoryCheckpoint {
74
- return { kind: CHECKPOINT_KIND, value: { messageId, partId, ordinal } };
131
+ function messageRevision(message: MessageEnvelope): string {
132
+ return createHash("sha256")
133
+ .update(JSON.stringify(canonicalJson(message)))
134
+ .digest("hex");
75
135
  }
76
136
 
77
- export function historicalEvents(message: MessageEnvelope, sessionId: string): Array<{ event: StreamEvent; partId: string }> {
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 timestamp = new Date(
82
- typeof rec(info["time"])?.["created"] === "number"
83
- ? rec(info["time"])!["created"] as number
84
- : Date.now(),
85
- ).toISOString();
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: Array<{ event: StreamEvent; partId: string }> = [];
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
- if (messageId && lastPartId) {
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
- ): Promise<Array<{ event: StreamEvent; checkpoint: HistoryCheckpoint; eventId: string | null }>> {
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) return [];
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: Array<{ event: StreamEvent; checkpoint: HistoryCheckpoint; eventId: string | null }> = [];
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 events = historicalEvents(message, sessionId);
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)) yield yielded;
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
  };