@agentex/agent 0.0.30 → 0.0.31

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 (40) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +71 -2
  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/opencode/history.d.ts +22 -8
  15. package/dist/providers/opencode/history.d.ts.map +1 -1
  16. package/dist/providers/opencode/history.js +119 -18
  17. package/dist/providers/opencode/history.js.map +1 -1
  18. package/dist/providers/opencode/index.d.ts.map +1 -1
  19. package/dist/providers/opencode/index.js +17 -0
  20. package/dist/providers/opencode/index.js.map +1 -1
  21. package/dist/providers/opencode/probe.d.ts.map +1 -1
  22. package/dist/providers/opencode/probe.js +2 -0
  23. package/dist/providers/opencode/probe.js.map +1 -1
  24. package/dist/providers/opencode/saved-history.d.ts +20 -0
  25. package/dist/providers/opencode/saved-history.d.ts.map +1 -0
  26. package/dist/providers/opencode/saved-history.js +361 -0
  27. package/dist/providers/opencode/saved-history.js.map +1 -0
  28. package/dist/types.d.ts +4 -0
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/types.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/derived.ts +39 -0
  33. package/src/history/index.ts +10 -0
  34. package/src/history/types.ts +90 -1
  35. package/src/index.ts +11 -1
  36. package/src/providers/opencode/history.ts +156 -23
  37. package/src/providers/opencode/index.ts +17 -0
  38. package/src/providers/opencode/probe.ts +4 -0
  39. package/src/providers/opencode/saved-history.ts +467 -0
  40. package/src/types.ts +4 -0
@@ -0,0 +1,467 @@
1
+ import type {
2
+ SavedHistoryDiscoverOptions,
3
+ SavedHistoryOps,
4
+ SavedHistoryProbeOptions,
5
+ SavedHistoryReadOptions,
6
+ SavedHistorySession,
7
+ SavedHistoryYield,
8
+ } from "../../history/types.js";
9
+ import { cleanTitle } from "../../history/fs.js";
10
+ import type { OpenCodeClient } from "./client.js";
11
+ import { collectHistory, historicalEvents, type MessageEnvelope } from "./history.js";
12
+ import { acquireOpenCodeRuntime } from "./runtime.js";
13
+
14
+ const SESSION_PAGE_SIZE = 100;
15
+ const MAX_SESSION_PAGES = 100;
16
+ const MAX_SESSIONS = 10_000;
17
+ const MAX_SESSION_LIST_BYTES = 25 * 1024 * 1024;
18
+ const MESSAGE_SCAN_PAGE_SIZE = 100;
19
+ const MAX_MESSAGE_SCAN_PAGES = 100;
20
+ const MAX_MESSAGE_SCAN_BYTES = 25 * 1024 * 1024;
21
+ const MAX_DISCOVERY_INSPECTION_BYTES = 25 * 1024 * 1024;
22
+ const MAX_DISCOVERY_INSPECTION_MESSAGES = 10_000;
23
+ const INSPECTION_CONCURRENCY = 8;
24
+
25
+ type SessionListEndpoint = "experimental" | "legacy";
26
+
27
+ interface OpenCodeSessionEnvelope extends Record<string, unknown> {
28
+ id?: unknown;
29
+ directory?: unknown;
30
+ title?: unknown;
31
+ parentID?: unknown;
32
+ time?: unknown;
33
+ }
34
+
35
+ interface SessionPage {
36
+ endpoint: SessionListEndpoint;
37
+ sessions: OpenCodeSessionEnvelope[];
38
+ nextCursor: string | null;
39
+ bytes: number;
40
+ }
41
+
42
+ interface InspectionBudget {
43
+ bytes: number;
44
+ messages: number;
45
+ }
46
+
47
+ export class OpenCodeSavedHistoryDiscoveryLimitError extends Error {
48
+ readonly code = "history_discovery_limit";
49
+
50
+ constructor() {
51
+ super("OpenCode saved-session discovery exceeded the bounded import limit");
52
+ this.name = "OpenCodeSavedHistoryDiscoveryLimitError";
53
+ }
54
+ }
55
+
56
+ export class OpenCodeSavedHistoryInvalidSessionError extends Error {
57
+ readonly code = "invalid_session";
58
+
59
+ constructor(message = "Invalid OpenCode saved-history session") {
60
+ super(message);
61
+ this.name = "OpenCodeSavedHistoryInvalidSessionError";
62
+ }
63
+ }
64
+
65
+ export class OpenCodeSavedHistoryProtocolError extends Error {
66
+ readonly code = "history_protocol_error";
67
+
68
+ constructor(message: string) {
69
+ super(message);
70
+ this.name = "OpenCodeSavedHistoryProtocolError";
71
+ }
72
+ }
73
+
74
+ function rec(value: unknown): Record<string, unknown> | null {
75
+ return value && typeof value === "object" && !Array.isArray(value)
76
+ ? value as Record<string, unknown>
77
+ : null;
78
+ }
79
+
80
+ function string(value: unknown): string | null {
81
+ return typeof value === "string" && value.trim().length > 0 ? value : null;
82
+ }
83
+
84
+ function millis(value: unknown): number | null {
85
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
86
+ }
87
+
88
+ function isoMillis(value: unknown): string | null {
89
+ const timestamp = millis(value);
90
+ if (timestamp === null) return null;
91
+ const date = new Date(timestamp);
92
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
93
+ }
94
+
95
+ function normalizedLimit(limit: number | undefined): number | null {
96
+ if (limit === undefined) return null;
97
+ if (!Number.isFinite(limit)) return null;
98
+ return Math.max(0, Math.floor(limit));
99
+ }
100
+
101
+ function parseResponseArray(text: string, description: string): unknown[] {
102
+ let parsed: unknown;
103
+ try {
104
+ parsed = JSON.parse(text) as unknown;
105
+ } catch {
106
+ throw new OpenCodeSavedHistoryProtocolError(
107
+ `OpenCode ${description} response is not valid JSON`,
108
+ );
109
+ }
110
+ if (!Array.isArray(parsed)) {
111
+ throw new OpenCodeSavedHistoryProtocolError(
112
+ `OpenCode ${description} response is not an array`,
113
+ );
114
+ }
115
+ return parsed;
116
+ }
117
+
118
+ function sessionQuery(
119
+ options: SavedHistoryDiscoverOptions | SavedHistoryProbeOptions,
120
+ limit: number,
121
+ endpoint: SessionListEndpoint,
122
+ cursor?: string,
123
+ discovery = false,
124
+ ): URLSearchParams {
125
+ const query = new URLSearchParams({ limit: String(limit) });
126
+ const discover = options as SavedHistoryDiscoverOptions;
127
+ if (discovery && (discover.mainSessionsOnly ?? true)) {
128
+ query.set("roots", "true");
129
+ }
130
+ if (discovery && discover.directory) query.set("directory", discover.directory);
131
+ if (discovery && endpoint === "experimental" && (discover.includeArchived ?? true)) {
132
+ query.set("archived", "true");
133
+ }
134
+ if (endpoint === "experimental" && cursor) query.set("cursor", cursor);
135
+ return query;
136
+ }
137
+
138
+ async function parseSessionPage(
139
+ response: Response,
140
+ endpoint: SessionListEndpoint,
141
+ ): Promise<SessionPage> {
142
+ if (!response.ok) {
143
+ throw new Error(`OpenCode saved-session request failed (${response.status})`);
144
+ }
145
+ const text = await response.text();
146
+ const parsed = parseResponseArray(text, "saved-session");
147
+ return {
148
+ endpoint,
149
+ sessions: parsed.map((value) => rec(value)).filter((value): value is OpenCodeSessionEnvelope => !!value),
150
+ nextCursor: endpoint === "experimental" ? response.headers.get("x-next-cursor") : null,
151
+ bytes: Buffer.byteLength(text),
152
+ };
153
+ }
154
+
155
+ async function firstSessionPage(
156
+ client: OpenCodeClient,
157
+ options: SavedHistoryDiscoverOptions | SavedHistoryProbeOptions,
158
+ limit: number,
159
+ discovery = false,
160
+ ): Promise<SessionPage> {
161
+ const experimentalQuery = sessionQuery(options, limit, "experimental", undefined, discovery);
162
+ const experimental = await client.request(`/experimental/session?${experimentalQuery}`);
163
+ if (experimental.status !== 404) return parseSessionPage(experimental, "experimental");
164
+
165
+ // OpenCode versions before the global endpoint exposed GET /session as a
166
+ // global list. It has no cursor or archived-session switch, but remains a
167
+ // useful compatibility path for active sessions.
168
+ const legacyQuery = sessionQuery(
169
+ options,
170
+ discovery ? MAX_SESSIONS : limit,
171
+ "legacy",
172
+ undefined,
173
+ discovery,
174
+ );
175
+ return parseSessionPage(await client.request(`/session?${legacyQuery}`), "legacy");
176
+ }
177
+
178
+ async function nextSessionPage(
179
+ client: OpenCodeClient,
180
+ options: SavedHistoryDiscoverOptions,
181
+ endpoint: SessionListEndpoint,
182
+ cursor: string,
183
+ ): Promise<SessionPage> {
184
+ const query = sessionQuery(options, SESSION_PAGE_SIZE, endpoint, cursor, true);
185
+ return parseSessionPage(
186
+ await client.request(`/${endpoint === "experimental" ? "experimental/" : ""}session?${query}`),
187
+ endpoint,
188
+ );
189
+ }
190
+
191
+ async function findUserText(
192
+ client: OpenCodeClient,
193
+ sessionId: string,
194
+ budget: InspectionBudget,
195
+ ): Promise<string | null> {
196
+ let before: string | null = null;
197
+ let sessionBytes = 0;
198
+ for (let pageIndex = 0; pageIndex < MAX_MESSAGE_SCAN_PAGES; pageIndex += 1) {
199
+ const query = new URLSearchParams({ limit: String(MESSAGE_SCAN_PAGE_SIZE) });
200
+ if (before) query.set("before", before);
201
+ const response = await client.request(
202
+ `/session/${encodeURIComponent(sessionId)}/message?${query}`,
203
+ );
204
+ if (response.status === 404) return null;
205
+ if (!response.ok) {
206
+ throw new Error(`OpenCode saved-session message request failed (${response.status})`);
207
+ }
208
+ const read = await readInspectionText(response, sessionBytes, budget);
209
+ const text = read.text;
210
+ sessionBytes = read.sessionBytes;
211
+ const parsed = parseResponseArray(text, "saved-session message");
212
+ const messages = parsed
213
+ .map((value) => rec(value) as MessageEnvelope)
214
+ .filter(Boolean);
215
+ budget.messages += messages.length;
216
+ if (budget.messages > MAX_DISCOVERY_INSPECTION_MESSAGES) {
217
+ throw new OpenCodeSavedHistoryDiscoveryLimitError();
218
+ }
219
+ for (const message of messages) {
220
+ const user = historicalEvents(message, sessionId, { includeUserMessages: true })
221
+ .find((item) => item.event.type === "user");
222
+ if (user?.event.type === "user") return user.event.text;
223
+ }
224
+ if (messages.length < MESSAGE_SCAN_PAGE_SIZE) return null;
225
+ const next = response.headers.get("x-next-cursor");
226
+ if (!next || next === before) return null;
227
+ before = next;
228
+ }
229
+ throw new OpenCodeSavedHistoryDiscoveryLimitError();
230
+ }
231
+
232
+ async function readInspectionText(
233
+ response: Response,
234
+ priorSessionBytes: number,
235
+ budget: InspectionBudget,
236
+ ): Promise<{ text: string; sessionBytes: number }> {
237
+ const reader = response.body?.getReader();
238
+ if (!reader) {
239
+ const text = await response.text();
240
+ const size = Buffer.byteLength(text);
241
+ const sessionBytes = priorSessionBytes + size;
242
+ budget.bytes += size;
243
+ if (
244
+ sessionBytes > MAX_MESSAGE_SCAN_BYTES
245
+ || budget.bytes > MAX_DISCOVERY_INSPECTION_BYTES
246
+ ) {
247
+ throw new OpenCodeSavedHistoryDiscoveryLimitError();
248
+ }
249
+ return { text, sessionBytes };
250
+ }
251
+
252
+ const chunks: Uint8Array[] = [];
253
+ let total = 0;
254
+ let sessionBytes = priorSessionBytes;
255
+ while (true) {
256
+ const { done, value } = await reader.read();
257
+ if (done) break;
258
+ const size = value.byteLength;
259
+ total += size;
260
+ sessionBytes += size;
261
+ budget.bytes += size;
262
+ if (
263
+ sessionBytes > MAX_MESSAGE_SCAN_BYTES
264
+ || budget.bytes > MAX_DISCOVERY_INSPECTION_BYTES
265
+ ) {
266
+ await reader.cancel().catch(() => undefined);
267
+ throw new OpenCodeSavedHistoryDiscoveryLimitError();
268
+ }
269
+ chunks.push(value);
270
+ }
271
+ return {
272
+ text: Buffer.concat(chunks, total).toString("utf8"),
273
+ sessionBytes,
274
+ };
275
+ }
276
+
277
+ async function inspectSession(
278
+ client: OpenCodeClient,
279
+ raw: OpenCodeSessionEnvelope,
280
+ options: SavedHistoryDiscoverOptions,
281
+ budget: InspectionBudget,
282
+ ): Promise<SavedHistorySession | null> {
283
+ const externalSessionId = string(raw.id);
284
+ const time = rec(raw.time);
285
+ const updatedAt = isoMillis(time?.["updated"]);
286
+ if (!externalSessionId || !updatedAt) return null;
287
+ if ((options.mainSessionsOnly ?? true) && string(raw.parentID)) return null;
288
+
289
+ const userText = await findUserText(client, externalSessionId, budget);
290
+ const hasUserMessage = userText !== null;
291
+ if ((options.requireUserMessage ?? true) && !hasUserMessage) return null;
292
+ const archived = millis(time?.["archived"]);
293
+ return {
294
+ version: 1,
295
+ providerType: "opencode",
296
+ externalSessionId,
297
+ cwd: string(raw.directory),
298
+ title: cleanTitle(string(raw.title)) ?? cleanTitle(userText),
299
+ startedAt: isoMillis(time?.["created"]),
300
+ updatedAt,
301
+ branch: null,
302
+ gitOriginUrl: null,
303
+ archiveState: archived === null ? "active" : "archived",
304
+ hasUserMessage,
305
+ };
306
+ }
307
+
308
+ async function inspectBatch(
309
+ client: OpenCodeClient,
310
+ sessions: OpenCodeSessionEnvelope[],
311
+ options: SavedHistoryDiscoverOptions,
312
+ budget: InspectionBudget,
313
+ ): Promise<Array<SavedHistorySession | null>> {
314
+ const output = new Array<SavedHistorySession | null>(sessions.length).fill(null);
315
+ const failures: unknown[] = [];
316
+ let next = 0;
317
+ const workers = Array.from(
318
+ { length: Math.min(INSPECTION_CONCURRENCY, sessions.length) },
319
+ async () => {
320
+ while (true) {
321
+ if (failures.length > 0) return;
322
+ const index = next;
323
+ next += 1;
324
+ if (index >= sessions.length) return;
325
+ const session = sessions[index];
326
+ if (!session) continue;
327
+ try {
328
+ output[index] = await inspectSession(client, session, options, budget);
329
+ } catch (error) {
330
+ // A 404 is converted to null inside findUserText because that one
331
+ // candidate disappeared. Any thrown error is systemic or makes the
332
+ // scan incomplete, so discovery must not report a partial catalog
333
+ // as authoritative to a synchronizing host.
334
+ failures.push(error);
335
+ return;
336
+ }
337
+ }
338
+ },
339
+ );
340
+ await Promise.all(workers);
341
+ if (failures.length > 0) throw failures[0];
342
+ return output;
343
+ }
344
+
345
+ /** @internal Exported for deterministic provider contract tests. */
346
+ export async function* discoverOpenCodeSavedSessions(
347
+ client: OpenCodeClient,
348
+ options: SavedHistoryDiscoverOptions = {},
349
+ ): AsyncIterable<SavedHistorySession> {
350
+ const requestedLimit = normalizedLimit(options.limit);
351
+ if (requestedLimit === 0) return;
352
+
353
+ let page = await firstSessionPage(client, options, SESSION_PAGE_SIZE, true);
354
+ let totalSessions = 0;
355
+ let totalBytes = 0;
356
+ let yielded = 0;
357
+ let pageCount = 0;
358
+ let previousCursor: string | null = null;
359
+ const inspectionBudget: InspectionBudget = { bytes: 0, messages: 0 };
360
+ while (true) {
361
+ pageCount += 1;
362
+ totalSessions += page.sessions.length;
363
+ totalBytes += page.bytes;
364
+ if (
365
+ totalSessions > MAX_SESSIONS
366
+ || totalBytes > MAX_SESSION_LIST_BYTES
367
+ || pageCount > MAX_SESSION_PAGES
368
+ ) {
369
+ throw new OpenCodeSavedHistoryDiscoveryLimitError();
370
+ }
371
+
372
+ const inspected = await inspectBatch(client, page.sessions, options, inspectionBudget);
373
+ for (const session of inspected) {
374
+ if (!session) continue;
375
+ yield session;
376
+ yielded += 1;
377
+ if (requestedLimit !== null && yielded >= requestedLimit) return;
378
+ }
379
+
380
+ if (page.endpoint === "legacy") {
381
+ if (
382
+ page.sessions.length >= MAX_SESSIONS
383
+ && (requestedLimit === null || yielded < requestedLimit)
384
+ ) {
385
+ throw new OpenCodeSavedHistoryDiscoveryLimitError();
386
+ }
387
+ return;
388
+ }
389
+ if (!page.nextCursor) return;
390
+ if (page.nextCursor === previousCursor) throw new OpenCodeSavedHistoryDiscoveryLimitError();
391
+ previousCursor = page.nextCursor;
392
+ page = await nextSessionPage(client, options, page.endpoint, page.nextCursor);
393
+ }
394
+ }
395
+
396
+ /** @internal Exported for deterministic provider contract tests. */
397
+ export async function* readOpenCodeSavedSession(
398
+ client: OpenCodeClient,
399
+ session: SavedHistorySession,
400
+ options: SavedHistoryReadOptions = {},
401
+ ): AsyncIterable<SavedHistoryYield> {
402
+ if (session.providerType !== "opencode" || !session.externalSessionId) {
403
+ throw new OpenCodeSavedHistoryInvalidSessionError();
404
+ }
405
+ const collected = await collectHistory(
406
+ client,
407
+ session.externalSessionId,
408
+ { after: options.after, mode: options.mode },
409
+ { includeUserMessages: true, missingSession: "error" },
410
+ );
411
+ for (const item of collected) {
412
+ const eventId = item.eventId;
413
+ if (!eventId) throw new OpenCodeSavedHistoryInvalidSessionError("OpenCode history event has no stable id");
414
+ yield {
415
+ event: { ...item.event, eventId },
416
+ checkpoint: item.checkpoint,
417
+ eventId,
418
+ partIndex: item.partIndex,
419
+ };
420
+ }
421
+ }
422
+
423
+ export const openCodeSavedHistory: SavedHistoryOps = {
424
+ async probe(options: SavedHistoryProbeOptions = {}) {
425
+ let runtime: Awaited<ReturnType<typeof acquireOpenCodeRuntime>> | null = null;
426
+ try {
427
+ runtime = await acquireOpenCodeRuntime(options);
428
+ const limit = Math.max(1, normalizedLimit(options.limit) ?? SESSION_PAGE_SIZE);
429
+ const page = await firstSessionPage(runtime.server.client, options, limit);
430
+ return {
431
+ providerType: "opencode",
432
+ sourceAvailable: true,
433
+ historyAvailable: page.sessions.length > 0,
434
+ approximateCount: page.sessions.length,
435
+ };
436
+ } catch {
437
+ return {
438
+ providerType: "opencode",
439
+ sourceAvailable: false,
440
+ historyAvailable: false,
441
+ };
442
+ } finally {
443
+ runtime?.server.release();
444
+ }
445
+ },
446
+
447
+ async *discover(options: SavedHistoryDiscoverOptions = {}) {
448
+ const runtime = await acquireOpenCodeRuntime(options);
449
+ try {
450
+ yield* discoverOpenCodeSavedSessions(runtime.server.client, options);
451
+ } finally {
452
+ runtime.server.release();
453
+ }
454
+ },
455
+
456
+ async *read(session, options: SavedHistoryReadOptions = {}) {
457
+ const runtime = await acquireOpenCodeRuntime({
458
+ ...options,
459
+ cwd: session.cwd ?? options.cwd,
460
+ });
461
+ try {
462
+ yield* readOpenCodeSavedSession(runtime.server.client, session, options);
463
+ } finally {
464
+ runtime.server.release();
465
+ }
466
+ },
467
+ };
package/src/types.ts CHANGED
@@ -97,6 +97,8 @@ export interface ProviderCapabilities {
97
97
  durableHistory?: boolean;
98
98
  /** Read-only discovery and import of locally persisted sessions. */
99
99
  localHistory?: boolean;
100
+ /** Provider-neutral discovery and synchronization of saved sessions. */
101
+ savedHistory?: boolean;
100
102
  /** A persisted provider session can be resumed. */
101
103
  resume?: boolean;
102
104
  /** Models expose provider-native variants independently from effort. */
@@ -372,6 +374,8 @@ export interface ProviderModule {
372
374
  attachHistory?(record: SessionRecord, opts?: AttachOptions): Promise<HistoryAttachment>;
373
375
  /** Discover and read local sessions when their ids are not already known. */
374
376
  localHistory?: import("./history/types.js").LocalHistoryOps;
377
+ /** Discover and read provider-owned saved sessions without storage coupling. */
378
+ savedHistory?: import("./history/types.js").SavedHistoryOps;
375
379
  }
376
380
 
377
381
  // Result of looking up a transcript for a given session.