@poncho-ai/harness 0.34.1 → 0.36.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.
Files changed (64) hide show
  1. package/.turbo/turbo-build.log +12 -11
  2. package/.turbo/turbo-lint.log +6 -0
  3. package/.turbo/turbo-test.log +27100 -0
  4. package/CHANGELOG.md +37 -0
  5. package/dist/chunk-MCKGQKYU.js +15 -0
  6. package/dist/dist-3KMQR4IO.js +27092 -0
  7. package/dist/index.d.ts +553 -29
  8. package/dist/index.js +3132 -1902
  9. package/dist/isolate-5MISBSUK.js +733 -0
  10. package/dist/isolate-5R6762YA.js +605 -0
  11. package/dist/isolate-KUZ5NOPG.js +727 -0
  12. package/dist/isolate-LOL3T7RA.js +729 -0
  13. package/dist/isolate-N22X4TCE.js +740 -0
  14. package/dist/isolate-T7WXM7IL.js +1490 -0
  15. package/dist/isolate-TCWTUVG4.js +1532 -0
  16. package/dist/isolate-WFOLANOB.js +768 -0
  17. package/package.json +24 -4
  18. package/scripts/migrate-to-engine.mjs +556 -0
  19. package/src/config.ts +112 -1
  20. package/src/harness.ts +282 -91
  21. package/src/index.ts +7 -0
  22. package/src/isolate/bindings.ts +206 -0
  23. package/src/isolate/bundler.ts +179 -0
  24. package/src/isolate/index.ts +10 -0
  25. package/src/isolate/polyfills.ts +796 -0
  26. package/src/isolate/run-code-tool.ts +220 -0
  27. package/src/isolate/runtime.ts +286 -0
  28. package/src/isolate/type-stubs.ts +196 -0
  29. package/src/mcp.ts +140 -9
  30. package/src/memory.ts +142 -191
  31. package/src/reminder-store.ts +7 -235
  32. package/src/reminder-tools.ts +15 -2
  33. package/src/secrets-store.ts +163 -0
  34. package/src/state.ts +22 -1291
  35. package/src/storage/engine.ts +106 -0
  36. package/src/storage/index.ts +59 -0
  37. package/src/storage/memory-engine.ts +588 -0
  38. package/src/storage/postgres-engine.ts +139 -0
  39. package/src/storage/schema.ts +145 -0
  40. package/src/storage/sql-dialect.ts +963 -0
  41. package/src/storage/sqlite-engine.ts +99 -0
  42. package/src/storage/store-adapters.ts +100 -0
  43. package/src/subagent-manager.ts +1 -0
  44. package/src/subagent-tools.ts +1 -0
  45. package/src/telemetry.ts +5 -1
  46. package/src/tenant-token.ts +42 -0
  47. package/src/todo-tools.ts +1 -136
  48. package/src/upload-store.ts +1 -0
  49. package/src/vfs/bash-manager.ts +120 -0
  50. package/src/vfs/bash-tool.ts +59 -0
  51. package/src/vfs/create-bash-fs.ts +32 -0
  52. package/src/vfs/edit-file-tool.ts +72 -0
  53. package/src/vfs/index.ts +5 -0
  54. package/src/vfs/poncho-fs-adapter.ts +267 -0
  55. package/src/vfs/protected-fs.ts +177 -0
  56. package/src/vfs/read-file-tool.ts +103 -0
  57. package/src/vfs/write-file-tool.ts +49 -0
  58. package/test/harness.test.ts +30 -36
  59. package/test/isolate-vfs.test.ts +453 -0
  60. package/test/isolate.test.ts +252 -0
  61. package/test/state.test.ts +4 -27
  62. package/test/storage-engine.test.ts +250 -0
  63. package/test/vfs.test.ts +242 -0
  64. package/src/kv-store.ts +0 -216
package/src/state.ts CHANGED
@@ -1,13 +1,4 @@
1
- import { randomUUID } from "node:crypto";
2
- import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
- import { dirname, resolve } from "node:path";
4
1
  import type { Message } from "@poncho-ai/sdk";
5
- import {
6
- ensureAgentIdentity,
7
- getAgentStoreDirectory,
8
- slugifyStorageComponent,
9
- STORAGE_SCHEMA_VERSION,
10
- } from "./agent-identity.js";
11
2
 
12
3
  export interface ConversationState {
13
4
  runId: string;
@@ -78,44 +69,31 @@ export interface Conversation {
78
69
  subagentCallbackCount?: number;
79
70
  runningCallbackSince?: number;
80
71
  lastActivityAt?: number;
81
- /** Harness-internal message chain preserved across continuation runs.
82
- * Cleared when a run completes without continuation. */
83
72
  _continuationMessages?: Message[];
84
- /** Number of continuation pickups for the current multi-step run.
85
- * Reset when a run completes without continuation. Used to enforce
86
- * a maximum continuation count across all entry points. */
87
73
  _continuationCount?: number;
88
- /** Full structured message chain from the last harness run, including
89
- * tool-call and tool-result messages the model needs for context.
90
- * Unlike `_continuationMessages`, this is always set after a run
91
- * and does NOT signal that a continuation is pending. */
92
74
  _harnessMessages?: Message[];
93
- /** Archived full-fidelity tool results keyed by toolResultId. */
94
75
  _toolResultArchive?: Record<string, ArchivedToolResult>;
95
76
  createdAt: number;
96
77
  updatedAt: number;
97
78
  }
98
79
 
99
80
  export interface ConversationStore {
100
- list(ownerId?: string): Promise<Conversation[]>;
101
- listSummaries(ownerId?: string): Promise<ConversationSummary[]>;
81
+ list(ownerId?: string, tenantId?: string | null): Promise<Conversation[]>;
82
+ listSummaries(ownerId?: string, tenantId?: string | null): Promise<ConversationSummary[]>;
102
83
  get(conversationId: string): Promise<Conversation | undefined>;
103
- create(ownerId?: string, title?: string): Promise<Conversation>;
84
+ create(ownerId?: string, title?: string, tenantId?: string | null): Promise<Conversation>;
104
85
  update(conversation: Conversation): Promise<void>;
105
86
  rename(conversationId: string, title: string): Promise<Conversation | undefined>;
106
87
  delete(conversationId: string): Promise<boolean>;
107
88
  appendSubagentResult(conversationId: string, result: PendingSubagentResult): Promise<void>;
108
- /**
109
- * Atomically clear `runningCallbackSince` without clobbering other fields.
110
- * Returns the conversation as it exists after the clear (with current
111
- * `pendingSubagentResults`).
112
- */
113
89
  clearCallbackLock(conversationId: string): Promise<Conversation | undefined>;
114
90
  }
115
91
 
116
92
  export type StateProviderName =
117
93
  | "local"
118
94
  | "memory"
95
+ | "sqlite"
96
+ | "postgresql"
119
97
  | "redis"
120
98
  | "upstash"
121
99
  | "dynamodb";
@@ -130,38 +108,6 @@ export interface StateConfig {
130
108
  }
131
109
 
132
110
  const DEFAULT_OWNER = "local-owner";
133
- const LOCAL_STATE_FILE = "state.json";
134
- const CONVERSATIONS_DIRECTORY = "conversations";
135
- const LOCAL_CONVERSATION_INDEX_FILE = "index.json";
136
-
137
- type StoreIdentityOptions = {
138
- workingDir: string;
139
- agentId?: string;
140
- };
141
-
142
- const toStoreIdentity = async ({
143
- workingDir,
144
- agentId,
145
- }: StoreIdentityOptions): Promise<{ name: string; id: string }> => {
146
- const ensured = await ensureAgentIdentity(workingDir);
147
- if (!agentId) {
148
- return ensured;
149
- }
150
- return { name: ensured.name, id: agentId };
151
- };
152
-
153
- const writeJsonAtomic = async (filePath: string, payload: unknown): Promise<void> => {
154
- await mkdir(dirname(filePath), { recursive: true });
155
- const tmpPath = `${filePath}.tmp`;
156
- await writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8");
157
- await rename(tmpPath, filePath);
158
- };
159
-
160
- const formatUtcTimestamp = (value: number): string =>
161
- new Date(value)
162
- .toISOString()
163
- .replace(/[-:]/g, "")
164
- .replace(/\.\d{3}Z$/, "Z");
165
111
 
166
112
  const normalizeTitle = (title?: string): string => {
167
113
  return title && title.trim().length > 0 ? title.trim() : "New conversation";
@@ -181,9 +127,7 @@ export class InMemoryStateStore implements StateStore {
181
127
 
182
128
  async get(runId: string): Promise<ConversationState | undefined> {
183
129
  const state = this.store.get(runId);
184
- if (!state) {
185
- return undefined;
186
- }
130
+ if (!state) return undefined;
187
131
  if (this.isExpired(state)) {
188
132
  this.store.delete(runId);
189
133
  return undefined;
@@ -200,61 +144,6 @@ export class InMemoryStateStore implements StateStore {
200
144
  }
201
145
  }
202
146
 
203
- class UpstashStateStore implements StateStore {
204
- private readonly baseUrl: string;
205
- private readonly token: string;
206
- private readonly ttl?: number;
207
-
208
- constructor(baseUrl: string, token: string, ttl?: number) {
209
- this.baseUrl = baseUrl.replace(/\/+$/, "");
210
- this.token = token;
211
- this.ttl = ttl;
212
- }
213
-
214
- private headers(): HeadersInit {
215
- return {
216
- Authorization: `Bearer ${this.token}`,
217
- "Content-Type": "application/json",
218
- };
219
- }
220
-
221
- async get(runId: string): Promise<ConversationState | undefined> {
222
- const response = await fetch(`${this.baseUrl}/get/${encodeURIComponent(runId)}`, {
223
- method: "POST",
224
- headers: this.headers(),
225
- });
226
- if (!response.ok) {
227
- return undefined;
228
- }
229
- const payload = (await response.json()) as { result?: string | null };
230
- if (!payload.result) {
231
- return undefined;
232
- }
233
- return JSON.parse(payload.result) as ConversationState;
234
- }
235
-
236
- async set(state: ConversationState): Promise<void> {
237
- const serialized = JSON.stringify({ ...state, updatedAt: Date.now() });
238
- const path =
239
- typeof this.ttl === "number"
240
- ? `${this.baseUrl}/setex/${encodeURIComponent(state.runId)}/${Math.max(
241
- 1,
242
- this.ttl,
243
- )}/${encodeURIComponent(serialized)}`
244
- : `${this.baseUrl}/set/${encodeURIComponent(state.runId)}/${encodeURIComponent(
245
- serialized,
246
- )}`;
247
- await fetch(path, { method: "POST", headers: this.headers() });
248
- }
249
-
250
- async delete(runId: string): Promise<void> {
251
- await fetch(`${this.baseUrl}/del/${encodeURIComponent(runId)}`, {
252
- method: "POST",
253
- headers: this.headers(),
254
- });
255
- }
256
- }
257
-
258
147
  export class InMemoryConversationStore implements ConversationStore {
259
148
  private readonly conversations = new Map<string, Conversation>();
260
149
  private readonly ttlMs?: number;
@@ -275,17 +164,19 @@ export class InMemoryConversationStore implements ConversationStore {
275
164
  }
276
165
  }
277
166
 
278
- async list(ownerId?: string): Promise<Conversation[]> {
167
+ async list(ownerId?: string, tenantId?: string | null): Promise<Conversation[]> {
279
168
  this.purgeExpired();
280
169
  return Array.from(this.conversations.values())
281
170
  .filter((conversation) => !ownerId || conversation.ownerId === ownerId)
171
+ .filter((c) => tenantId === undefined || c.tenantId === tenantId)
282
172
  .sort((a, b) => b.updatedAt - a.updatedAt);
283
173
  }
284
174
 
285
- async listSummaries(ownerId?: string): Promise<ConversationSummary[]> {
175
+ async listSummaries(ownerId?: string, tenantId?: string | null): Promise<ConversationSummary[]> {
286
176
  this.purgeExpired();
287
177
  return Array.from(this.conversations.values())
288
178
  .filter((c) => !ownerId || c.ownerId === ownerId)
179
+ .filter((c) => tenantId === undefined || c.tenantId === tenantId)
289
180
  .sort((a, b) => b.updatedAt - a.updatedAt)
290
181
  .map((c) => ({
291
182
  conversationId: c.conversationId,
@@ -293,6 +184,7 @@ export class InMemoryConversationStore implements ConversationStore {
293
184
  updatedAt: c.updatedAt,
294
185
  createdAt: c.createdAt,
295
186
  ownerId: c.ownerId,
187
+ tenantId: c.tenantId,
296
188
  parentConversationId: c.parentConversationId,
297
189
  messageCount: c.messages.length,
298
190
  hasPendingApprovals: Array.isArray(c.pendingApprovals) && c.pendingApprovals.length > 0,
@@ -305,14 +197,14 @@ export class InMemoryConversationStore implements ConversationStore {
305
197
  return this.conversations.get(conversationId);
306
198
  }
307
199
 
308
- async create(ownerId = DEFAULT_OWNER, title?: string): Promise<Conversation> {
200
+ async create(ownerId = DEFAULT_OWNER, title?: string, tenantId: string | null = null): Promise<Conversation> {
309
201
  const now = Date.now();
310
202
  const conversation: Conversation = {
311
203
  conversationId: globalThis.crypto?.randomUUID?.() ?? `${now}-${Math.random()}`,
312
204
  title: normalizeTitle(title),
313
205
  messages: [],
314
206
  ownerId,
315
- tenantId: null,
207
+ tenantId,
316
208
  createdAt: now,
317
209
  updatedAt: now,
318
210
  };
@@ -329,9 +221,7 @@ export class InMemoryConversationStore implements ConversationStore {
329
221
 
330
222
  async rename(conversationId: string, title: string): Promise<Conversation | undefined> {
331
223
  const existing = await this.get(conversationId);
332
- if (!existing) {
333
- return undefined;
334
- }
224
+ if (!existing) return undefined;
335
225
  const updated: Conversation = {
336
226
  ...existing,
337
227
  title: normalizeTitle(title || existing.title),
@@ -368,6 +258,7 @@ export type ConversationSummary = {
368
258
  updatedAt: number;
369
259
  createdAt?: number;
370
260
  ownerId: string;
261
+ tenantId?: string | null;
371
262
  parentConversationId?: string;
372
263
  messageCount?: number;
373
264
  hasPendingApprovals?: boolean;
@@ -378,1184 +269,24 @@ export type ConversationSummary = {
378
269
  };
379
270
  };
380
271
 
381
- type ConversationStoreFile = {
382
- schemaVersion: string;
383
- conversations: Array<{
384
- conversationId: string;
385
- title: string;
386
- updatedAt: number;
387
- createdAt?: number;
388
- ownerId: string;
389
- fileName: string;
390
- parentConversationId?: string;
391
- messageCount?: number;
392
- hasPendingApprovals?: boolean;
393
- channelMeta?: {
394
- platform: string;
395
- channelId: string;
396
- platformThreadId: string;
397
- };
398
- }>;
399
- };
400
-
401
- class FileConversationStore implements ConversationStore {
402
- private readonly workingDir: string;
403
- private readonly agentId?: string;
404
- private readonly conversations = new Map<string, ConversationStoreFile["conversations"][number]>();
405
- private loaded = false;
406
- private writing = Promise.resolve();
407
- private paths?: { conversationsDir: string; indexPath: string };
408
-
409
- constructor(workingDir: string, agentId?: string) {
410
- this.workingDir = workingDir;
411
- this.agentId = agentId;
412
- }
413
-
414
- private async resolvePaths(): Promise<{ conversationsDir: string; indexPath: string }> {
415
- if (this.paths) {
416
- return this.paths;
417
- }
418
- const identity = await toStoreIdentity({
419
- workingDir: this.workingDir,
420
- agentId: this.agentId,
421
- });
422
- const agentDir = getAgentStoreDirectory(identity);
423
- const conversationsDir = resolve(agentDir, CONVERSATIONS_DIRECTORY);
424
- const indexPath = resolve(conversationsDir, LOCAL_CONVERSATION_INDEX_FILE);
425
- this.paths = { conversationsDir, indexPath };
426
- return this.paths;
427
- }
428
-
429
- private async writeIndex(): Promise<void> {
430
- const { indexPath } = await this.resolvePaths();
431
- const payload: ConversationStoreFile = {
432
- schemaVersion: STORAGE_SCHEMA_VERSION,
433
- conversations: Array.from(this.conversations.values()).sort((a, b) => b.updatedAt - a.updatedAt),
434
- };
435
- await writeJsonAtomic(indexPath, payload);
436
- }
437
-
438
- private async readConversationFile(fileName: string): Promise<Conversation | undefined> {
439
- const { conversationsDir } = await this.resolvePaths();
440
- const filePath = resolve(conversationsDir, fileName);
441
- try {
442
- const raw = await readFile(filePath, "utf8");
443
- return JSON.parse(raw) as Conversation;
444
- } catch {
445
- return undefined;
446
- }
447
- }
448
-
449
- private async rebuildIndexFromFiles(): Promise<void> {
450
- const { conversationsDir } = await this.resolvePaths();
451
- this.conversations.clear();
452
- try {
453
- const entries = await readdir(conversationsDir, { withFileTypes: true });
454
- for (const entry of entries) {
455
- if (!entry.isFile() || entry.name === LOCAL_CONVERSATION_INDEX_FILE) {
456
- continue;
457
- }
458
- const conversation = await this.readConversationFile(entry.name);
459
- if (!conversation) {
460
- continue;
461
- }
462
- this.conversations.set(conversation.conversationId, {
463
- conversationId: conversation.conversationId,
464
- title: conversation.title,
465
- updatedAt: conversation.updatedAt,
466
- createdAt: conversation.createdAt,
467
- ownerId: conversation.ownerId,
468
- fileName: entry.name,
469
- parentConversationId: conversation.parentConversationId,
470
- messageCount: conversation.messages.length,
471
- hasPendingApprovals: Array.isArray(conversation.pendingApprovals) && conversation.pendingApprovals.length > 0,
472
- });
473
- }
474
- } catch {
475
- // Missing directory should behave like empty.
476
- }
477
- await this.writeIndex();
478
- }
479
-
480
- private resolveConversationFileName(conversation: Conversation): string {
481
- const ts = formatUtcTimestamp(conversation.createdAt || Date.now());
482
- return `${ts}--${slugifyStorageComponent(conversation.conversationId)}.json`;
483
- }
484
-
485
- private async ensureLoaded(): Promise<void> {
486
- if (this.loaded) {
487
- return;
488
- }
489
- this.loaded = true;
490
- const { indexPath } = await this.resolvePaths();
491
- try {
492
- const raw = await readFile(indexPath, "utf8");
493
- const parsed = JSON.parse(raw) as ConversationStoreFile;
494
- for (const conversation of parsed.conversations ?? []) {
495
- this.conversations.set(conversation.conversationId, conversation);
496
- }
497
- // Rebuild if any entry is from an older index format (missing messageCount)
498
- let needsRebuild = false;
499
- for (const entry of this.conversations.values()) {
500
- if (entry.messageCount === undefined) {
501
- needsRebuild = true;
502
- break;
503
- }
504
- }
505
- if (needsRebuild) {
506
- await this.rebuildIndexFromFiles();
507
- }
508
- } catch {
509
- await this.rebuildIndexFromFiles();
510
- }
511
- }
512
-
513
- private async persistConversation(conversation: Conversation): Promise<void> {
514
- const { conversationsDir } = await this.resolvePaths();
515
- const existing = this.conversations.get(conversation.conversationId);
516
- const fileName = existing?.fileName ?? this.resolveConversationFileName(conversation);
517
- const filePath = resolve(conversationsDir, fileName);
518
- this.writing = this.writing.then(async () => {
519
- await writeJsonAtomic(filePath, conversation);
520
- this.conversations.set(conversation.conversationId, {
521
- conversationId: conversation.conversationId,
522
- title: conversation.title,
523
- updatedAt: conversation.updatedAt,
524
- createdAt: conversation.createdAt,
525
- ownerId: conversation.ownerId,
526
- fileName,
527
- parentConversationId: conversation.parentConversationId,
528
- messageCount: conversation.messages.length,
529
- hasPendingApprovals: Array.isArray(conversation.pendingApprovals) && conversation.pendingApprovals.length > 0,
530
- channelMeta: conversation.channelMeta,
531
- });
532
- await this.writeIndex();
533
- });
534
- await this.writing;
535
- }
536
-
537
- async list(ownerId?: string): Promise<Conversation[]> {
538
- await this.ensureLoaded();
539
- const summaries = Array.from(this.conversations.values())
540
- .filter((conversation) => !ownerId || conversation.ownerId === ownerId)
541
- .sort((a, b) => b.updatedAt - a.updatedAt);
542
- const conversations: Conversation[] = [];
543
- for (const summary of summaries) {
544
- const loaded = await this.readConversationFile(summary.fileName);
545
- if (loaded) {
546
- conversations.push(loaded);
547
- }
548
- }
549
- return conversations;
550
- }
551
-
552
- async listSummaries(ownerId?: string): Promise<ConversationSummary[]> {
553
- await this.ensureLoaded();
554
- return Array.from(this.conversations.values())
555
- .filter((c) => !ownerId || c.ownerId === ownerId)
556
- .sort((a, b) => b.updatedAt - a.updatedAt)
557
- .map((c) => ({
558
- conversationId: c.conversationId,
559
- title: c.title,
560
- updatedAt: c.updatedAt,
561
- createdAt: c.createdAt,
562
- ownerId: c.ownerId,
563
- parentConversationId: c.parentConversationId,
564
- messageCount: c.messageCount,
565
- hasPendingApprovals: c.hasPendingApprovals,
566
- channelMeta: c.channelMeta,
567
- }));
568
- }
569
-
570
- async get(conversationId: string): Promise<Conversation | undefined> {
571
- await this.ensureLoaded();
572
- const summary = this.conversations.get(conversationId);
573
- if (!summary) {
574
- return undefined;
575
- }
576
- return await this.readConversationFile(summary.fileName);
577
- }
578
-
579
- async create(ownerId = DEFAULT_OWNER, title?: string): Promise<Conversation> {
580
- await this.ensureLoaded();
581
- const now = Date.now();
582
- const conversation: Conversation = {
583
- conversationId: randomUUID(),
584
- title: normalizeTitle(title),
585
- messages: [],
586
- ownerId,
587
- tenantId: null,
588
- createdAt: now,
589
- updatedAt: now,
590
- };
591
- await this.persistConversation(conversation);
592
- return conversation;
593
- }
594
-
595
- async update(conversation: Conversation): Promise<void> {
596
- await this.ensureLoaded();
597
- const next = {
598
- ...conversation,
599
- updatedAt: Date.now(),
600
- };
601
- await this.persistConversation(next);
602
- }
603
-
604
- async rename(conversationId: string, title: string): Promise<Conversation | undefined> {
605
- await this.ensureLoaded();
606
- const existing = await this.get(conversationId);
607
- if (!existing) {
608
- return undefined;
609
- }
610
- const updated: Conversation = {
611
- ...existing,
612
- title: normalizeTitle(title || existing.title),
613
- updatedAt: Date.now(),
614
- };
615
- await this.persistConversation(updated);
616
- return updated;
617
- }
618
-
619
- async delete(conversationId: string): Promise<boolean> {
620
- await this.ensureLoaded();
621
- const { conversationsDir } = await this.resolvePaths();
622
- const existing = this.conversations.get(conversationId);
623
- const removed = this.conversations.delete(conversationId);
624
- if (removed) {
625
- this.writing = this.writing.then(async () => {
626
- if (existing) {
627
- await rm(resolve(conversationsDir, existing.fileName), { force: true });
628
- }
629
- await this.writeIndex();
630
- });
631
- await this.writing;
632
- }
633
- return removed;
634
- }
635
-
636
- async appendSubagentResult(conversationId: string, result: PendingSubagentResult): Promise<void> {
637
- await this.ensureLoaded();
638
- const conversation = await this.get(conversationId);
639
- if (!conversation) return;
640
- if (!conversation.pendingSubagentResults) conversation.pendingSubagentResults = [];
641
- conversation.pendingSubagentResults.push(result);
642
- conversation.updatedAt = Date.now();
643
- await this.update(conversation);
644
- }
645
-
646
- async clearCallbackLock(conversationId: string): Promise<Conversation | undefined> {
647
- await this.ensureLoaded();
648
- const summary = this.conversations.get(conversationId);
649
- if (!summary) return undefined;
650
- const { conversationsDir } = await this.resolvePaths();
651
- const filePath = resolve(conversationsDir, summary.fileName);
652
- let result: Conversation | undefined;
653
- // Read inside the writing chain so we see the latest state after any
654
- // pending appendSubagentResult writes have flushed.
655
- this.writing = this.writing.then(async () => {
656
- const conv = await this.readConversationFile(summary.fileName);
657
- if (!conv) return;
658
- conv.runningCallbackSince = undefined;
659
- conv.updatedAt = Date.now();
660
- await writeJsonAtomic(filePath, conv);
661
- this.conversations.set(conversationId, {
662
- ...summary,
663
- updatedAt: conv.updatedAt,
664
- });
665
- result = conv;
666
- });
667
- await this.writing;
668
- return result;
669
- }
670
- }
671
-
672
- type LocalStateFile = {
673
- states: ConversationState[];
674
- };
675
-
676
- class FileStateStore implements StateStore {
677
- private readonly workingDir: string;
678
- private readonly agentId?: string;
679
- private filePath = "";
680
- private readonly states = new Map<string, ConversationState>();
681
- private readonly ttlMs?: number;
682
- private loaded = false;
683
- private writing = Promise.resolve();
684
-
685
- constructor(workingDir: string, ttlSeconds?: number, agentId?: string) {
686
- this.workingDir = workingDir;
687
- this.agentId = agentId;
688
- this.ttlMs = typeof ttlSeconds === "number" ? ttlSeconds * 1000 : undefined;
689
- }
690
-
691
- private async ensureFilePath(): Promise<void> {
692
- if (this.filePath) {
693
- return;
694
- }
695
- const identity = await toStoreIdentity({
696
- workingDir: this.workingDir,
697
- agentId: this.agentId,
698
- });
699
- this.filePath = resolve(getAgentStoreDirectory(identity), LOCAL_STATE_FILE);
700
- }
701
-
702
- private isExpired(state: ConversationState): boolean {
703
- return typeof this.ttlMs === "number" && Date.now() - state.updatedAt > this.ttlMs;
704
- }
705
-
706
- private async ensureLoaded(): Promise<void> {
707
- await this.ensureFilePath();
708
- if (this.loaded) {
709
- return;
710
- }
711
- this.loaded = true;
712
- try {
713
- const raw = await readFile(this.filePath, "utf8");
714
- const parsed = JSON.parse(raw) as LocalStateFile;
715
- for (const state of parsed.states ?? []) {
716
- this.states.set(state.runId, state);
717
- }
718
- } catch {
719
- // Missing/invalid file should not crash local mode.
720
- }
721
- }
722
-
723
- private async persist(): Promise<void> {
724
- const payload: LocalStateFile = {
725
- states: Array.from(this.states.values()),
726
- };
727
- this.writing = this.writing.then(async () => {
728
- await writeJsonAtomic(this.filePath, payload);
729
- });
730
- await this.writing;
731
- }
732
-
733
- async get(runId: string): Promise<ConversationState | undefined> {
734
- await this.ensureLoaded();
735
- const state = this.states.get(runId);
736
- if (!state) {
737
- return undefined;
738
- }
739
- if (this.isExpired(state)) {
740
- this.states.delete(runId);
741
- await this.persist();
742
- return undefined;
743
- }
744
- return state;
745
- }
746
-
747
- async set(state: ConversationState): Promise<void> {
748
- await this.ensureLoaded();
749
- this.states.set(state.runId, { ...state, updatedAt: Date.now() });
750
- await this.persist();
751
- }
752
-
753
- async delete(runId: string): Promise<void> {
754
- await this.ensureLoaded();
755
- this.states.delete(runId);
756
- await this.persist();
757
- }
758
- }
759
-
760
- interface RawKeyValueClient {
761
- get(key: string): Promise<string | undefined>;
762
- mget(keys: string[]): Promise<(string | undefined)[]>;
763
- set(key: string, value: string, ttl?: number): Promise<void>;
764
- del(key: string): Promise<void>;
765
- }
766
-
767
- type ConversationMeta = {
768
- conversationId: string;
769
- title: string;
770
- updatedAt: number;
771
- createdAt?: number;
772
- ownerId: string;
773
- parentConversationId?: string;
774
- messageCount?: number;
775
- hasPendingApprovals?: boolean;
776
- channelMeta?: {
777
- platform: string;
778
- channelId: string;
779
- platformThreadId: string;
780
- };
781
- };
782
-
783
- abstract class KeyValueConversationStoreBase implements ConversationStore {
784
- protected readonly ttl?: number;
785
- private readonly agentIdPromise: Promise<string>;
786
- private readonly ownerLocks = new Map<string, Promise<void>>();
787
- private readonly appendLocks = new Map<string, Promise<void>>();
788
- protected readonly memoryFallback: InMemoryConversationStore;
789
-
790
- constructor(ttl: number | undefined, workingDir: string, agentId?: string) {
791
- this.ttl = ttl;
792
- this.memoryFallback = new InMemoryConversationStore(ttl);
793
- this.agentIdPromise = toStoreIdentity({ workingDir, agentId }).then((identity) => identity.id);
794
- }
795
-
796
- protected abstract client(): Promise<RawKeyValueClient | undefined>;
797
-
798
- private async withOwnerLock(ownerId: string, task: () => Promise<void>): Promise<void> {
799
- const prev = this.ownerLocks.get(ownerId) ?? Promise.resolve();
800
- const next = prev.then(task, task);
801
- this.ownerLocks.set(ownerId, next);
802
- try {
803
- await next;
804
- } finally {
805
- if (this.ownerLocks.get(ownerId) === next) {
806
- this.ownerLocks.delete(ownerId);
807
- }
808
- }
809
- }
810
-
811
- private async withAppendLock(conversationId: string, task: () => Promise<void>): Promise<void> {
812
- const prev = this.appendLocks.get(conversationId) ?? Promise.resolve();
813
- const next = prev.then(task, task);
814
- this.appendLocks.set(conversationId, next);
815
- try {
816
- await next;
817
- } finally {
818
- if (this.appendLocks.get(conversationId) === next) {
819
- this.appendLocks.delete(conversationId);
820
- }
821
- }
822
- }
823
-
824
- private async namespace(): Promise<string> {
825
- const agentId = await this.agentIdPromise;
826
- return `poncho:${STORAGE_SCHEMA_VERSION}:${slugifyStorageComponent(agentId)}`;
827
- }
828
-
829
- private async conversationKey(conversationId: string): Promise<string> {
830
- return `${await this.namespace()}:conv:${conversationId}`;
831
- }
832
-
833
- private async conversationMetaKey(conversationId: string): Promise<string> {
834
- return `${await this.namespace()}:convmeta:${conversationId}`;
835
- }
836
-
837
- private async ownerIndexKey(ownerId: string): Promise<string> {
838
- return `${await this.namespace()}:owner:${slugifyStorageComponent(ownerId)}:conversations`;
839
- }
840
-
841
- private async getOwnerConversationIds(ownerId: string): Promise<string[]> {
842
- const kv = await this.client();
843
- if (!kv) {
844
- return [];
845
- }
846
- try {
847
- const raw = await kv.get(await this.ownerIndexKey(ownerId));
848
- if (!raw) {
849
- return [];
850
- }
851
- const parsed = JSON.parse(raw) as { ids?: string[] };
852
- return Array.isArray(parsed.ids)
853
- ? parsed.ids.filter((value): value is string => typeof value === "string")
854
- : [];
855
- } catch {
856
- return [];
857
- }
858
- }
859
-
860
- private async setOwnerConversationIds(ownerId: string, ids: string[]): Promise<void> {
861
- const kv = await this.client();
862
- if (!kv) {
863
- return;
864
- }
865
- const key = await this.ownerIndexKey(ownerId);
866
- const payload = JSON.stringify({ schemaVersion: STORAGE_SCHEMA_VERSION, ids });
867
- if (ids.length === 0) {
868
- await kv.del(key);
869
- return;
870
- }
871
- await kv.set(key, payload, this.ttl);
872
- }
873
-
874
- private async getConversationMeta(conversationId: string): Promise<ConversationMeta | undefined> {
875
- const kv = await this.client();
876
- if (!kv) {
877
- return undefined;
878
- }
879
- try {
880
- const raw = await kv.get(await this.conversationMetaKey(conversationId));
881
- if (!raw) {
882
- return undefined;
883
- }
884
- return JSON.parse(raw) as ConversationMeta;
885
- } catch {
886
- return undefined;
887
- }
888
- }
889
-
890
- async list(ownerId?: string): Promise<Conversation[]> {
891
- const kv = await this.client();
892
- if (!kv) {
893
- return await this.memoryFallback.list(ownerId);
894
- }
895
- if (!ownerId) {
896
- return [];
897
- }
898
- const ids = await this.getOwnerConversationIds(ownerId);
899
- if (ids.length === 0) return [];
900
- const convKeys = await Promise.all(ids.map((id) => this.conversationKey(id)));
901
- const rawValues = await kv.mget(convKeys);
902
- const conversations: Conversation[] = [];
903
- for (const raw of rawValues) {
904
- if (!raw) continue;
905
- try {
906
- conversations.push(JSON.parse(raw) as Conversation);
907
- } catch { /* skip invalid records */ }
908
- }
909
- return conversations.sort((a, b) => b.updatedAt - a.updatedAt);
910
- }
911
-
912
- async listSummaries(ownerId?: string): Promise<ConversationSummary[]> {
913
- const kv = await this.client();
914
- if (!kv) {
915
- return await this.memoryFallback.listSummaries(ownerId);
916
- }
917
- if (!ownerId) {
918
- return [];
919
- }
920
- const ids = await this.getOwnerConversationIds(ownerId);
921
- if (ids.length === 0) return [];
922
- const metaKeys = await Promise.all(ids.map((id) => this.conversationMetaKey(id)));
923
- const rawValues = await kv.mget(metaKeys);
924
- const summaries: ConversationSummary[] = [];
925
- for (const raw of rawValues) {
926
- if (!raw) continue;
927
- try {
928
- const meta = JSON.parse(raw) as ConversationMeta;
929
- if (meta.ownerId === ownerId) {
930
- summaries.push({
931
- conversationId: meta.conversationId,
932
- title: meta.title,
933
- updatedAt: meta.updatedAt,
934
- createdAt: meta.createdAt,
935
- ownerId: meta.ownerId,
936
- parentConversationId: meta.parentConversationId,
937
- messageCount: meta.messageCount,
938
- hasPendingApprovals: meta.hasPendingApprovals,
939
- channelMeta: meta.channelMeta,
940
- });
941
- }
942
- } catch { /* skip invalid records */ }
943
- }
944
- return summaries.sort((a, b) => b.updatedAt - a.updatedAt);
945
- }
946
-
947
- async get(conversationId: string): Promise<Conversation | undefined> {
948
- const kv = await this.client();
949
- if (!kv) {
950
- return await this.memoryFallback.get(conversationId);
951
- }
952
- const raw = await kv.get(await this.conversationKey(conversationId));
953
- if (!raw) {
954
- return undefined;
955
- }
956
- try {
957
- return JSON.parse(raw) as Conversation;
958
- } catch {
959
- return undefined;
960
- }
961
- }
962
-
963
- async create(ownerId = DEFAULT_OWNER, title?: string): Promise<Conversation> {
964
- const now = Date.now();
965
- const conversation: Conversation = {
966
- conversationId: globalThis.crypto?.randomUUID?.() ?? `${now}-${Math.random()}`,
967
- title: normalizeTitle(title),
968
- messages: [],
969
- ownerId,
970
- tenantId: null,
971
- createdAt: now,
972
- updatedAt: now,
973
- };
974
- await this.update(conversation);
975
- return conversation;
976
- }
977
-
978
- async update(conversation: Conversation): Promise<void> {
979
- const kv = await this.client();
980
- if (!kv) {
981
- await this.memoryFallback.update(conversation);
982
- return;
983
- }
984
- const existing = await this.get(conversation.conversationId);
985
- const nextConversation: Conversation = {
986
- ...conversation,
987
- updatedAt: Date.now(),
988
- };
989
- const convKey = await this.conversationKey(nextConversation.conversationId);
990
- const metaKey = await this.conversationMetaKey(nextConversation.conversationId);
991
- await kv.set(convKey, JSON.stringify(nextConversation), this.ttl);
992
- await kv.set(
993
- metaKey,
994
- JSON.stringify({
995
- conversationId: nextConversation.conversationId,
996
- title: nextConversation.title,
997
- updatedAt: nextConversation.updatedAt,
998
- createdAt: nextConversation.createdAt,
999
- ownerId: nextConversation.ownerId,
1000
- parentConversationId: nextConversation.parentConversationId,
1001
- messageCount: nextConversation.messages.length,
1002
- hasPendingApprovals: Array.isArray(nextConversation.pendingApprovals) && nextConversation.pendingApprovals.length > 0,
1003
- channelMeta: nextConversation.channelMeta,
1004
- } satisfies ConversationMeta),
1005
- this.ttl,
1006
- );
1007
- if (existing && existing.ownerId !== nextConversation.ownerId) {
1008
- await this.withOwnerLock(existing.ownerId, async () => {
1009
- const ids = await this.getOwnerConversationIds(existing.ownerId);
1010
- await this.setOwnerConversationIds(
1011
- existing.ownerId,
1012
- ids.filter((id) => id !== nextConversation.conversationId),
1013
- );
1014
- });
1015
- }
1016
- await this.withOwnerLock(nextConversation.ownerId, async () => {
1017
- const ids = await this.getOwnerConversationIds(nextConversation.ownerId);
1018
- const deduped = [nextConversation.conversationId, ...ids.filter((id) => id !== nextConversation.conversationId)];
1019
- await this.setOwnerConversationIds(nextConversation.ownerId, deduped);
1020
- });
1021
- }
1022
-
1023
- async rename(conversationId: string, title: string): Promise<Conversation | undefined> {
1024
- const existing = await this.get(conversationId);
1025
- if (!existing) {
1026
- return undefined;
1027
- }
1028
- const updated: Conversation = {
1029
- ...existing,
1030
- title: normalizeTitle(title || existing.title),
1031
- updatedAt: Date.now(),
1032
- };
1033
- await this.update(updated);
1034
- return updated;
1035
- }
1036
-
1037
- async delete(conversationId: string): Promise<boolean> {
1038
- const kv = await this.client();
1039
- if (!kv) {
1040
- return await this.memoryFallback.delete(conversationId);
1041
- }
1042
- const existing = await this.get(conversationId);
1043
- if (!existing) {
1044
- return false;
1045
- }
1046
- await kv.del(await this.conversationKey(conversationId));
1047
- await kv.del(await this.conversationMetaKey(conversationId));
1048
- await this.withOwnerLock(existing.ownerId, async () => {
1049
- const ids = await this.getOwnerConversationIds(existing.ownerId);
1050
- await this.setOwnerConversationIds(
1051
- existing.ownerId,
1052
- ids.filter((id) => id !== conversationId),
1053
- );
1054
- });
1055
- return true;
1056
- }
1057
-
1058
- async appendSubagentResult(conversationId: string, result: PendingSubagentResult): Promise<void> {
1059
- await this.withAppendLock(conversationId, async () => {
1060
- const conversation = await this.get(conversationId);
1061
- if (!conversation) return;
1062
- if (!conversation.pendingSubagentResults) conversation.pendingSubagentResults = [];
1063
- conversation.pendingSubagentResults.push(result);
1064
- conversation.updatedAt = Date.now();
1065
- await this.update(conversation);
1066
- });
1067
- }
1068
-
1069
- async clearCallbackLock(conversationId: string): Promise<Conversation | undefined> {
1070
- let result: Conversation | undefined;
1071
- await this.withAppendLock(conversationId, async () => {
1072
- const conversation = await this.get(conversationId);
1073
- if (!conversation) return;
1074
- conversation.runningCallbackSince = undefined;
1075
- conversation.updatedAt = Date.now();
1076
- await this.update(conversation);
1077
- result = conversation;
1078
- });
1079
- return result;
1080
- }
1081
- }
1082
-
1083
- class UpstashConversationStore extends KeyValueConversationStoreBase {
1084
- private readonly baseUrl: string;
1085
- private readonly token: string;
1086
-
1087
- constructor(baseUrl: string, token: string, workingDir: string, ttl?: number, agentId?: string) {
1088
- super(ttl, workingDir, agentId);
1089
- this.baseUrl = baseUrl.replace(/\/+$/, "");
1090
- this.token = token;
1091
- }
1092
-
1093
- private headers(): HeadersInit {
1094
- return {
1095
- Authorization: `Bearer ${this.token}`,
1096
- "Content-Type": "application/json",
1097
- };
1098
- }
1099
-
1100
- protected async client(): Promise<RawKeyValueClient | undefined> {
1101
- return {
1102
- get: async (key: string) => {
1103
- const response = await fetch(`${this.baseUrl}/get/${encodeURIComponent(key)}`, {
1104
- method: "POST",
1105
- headers: this.headers(),
1106
- });
1107
- if (!response.ok) {
1108
- return undefined;
1109
- }
1110
- const payload = (await response.json()) as { result?: string | null };
1111
- return payload.result ?? undefined;
1112
- },
1113
- mget: async (keys: string[]) => {
1114
- if (keys.length === 0) return [];
1115
- const path = keys.map((k) => encodeURIComponent(k)).join("/");
1116
- const response = await fetch(`${this.baseUrl}/mget/${path}`, {
1117
- method: "POST",
1118
- headers: this.headers(),
1119
- });
1120
- if (!response.ok) {
1121
- return keys.map(() => undefined);
1122
- }
1123
- const payload = (await response.json()) as { result?: (string | null)[] };
1124
- return (payload.result ?? []).map((v) => v ?? undefined);
1125
- },
1126
- set: async (key: string, value: string, ttl?: number) => {
1127
- const command = typeof ttl === "number"
1128
- ? ["SETEX", key, Math.max(1, ttl), value]
1129
- : ["SET", key, value];
1130
- const response = await fetch(this.baseUrl, {
1131
- method: "POST",
1132
- headers: this.headers(),
1133
- body: JSON.stringify(command),
1134
- });
1135
- if (!response.ok) {
1136
- const text = await response.text().catch(() => "");
1137
- console.error(`[store][upstash] SET failed (${response.status}): ${text.slice(0, 200)}`);
1138
- }
1139
- },
1140
- del: async (key: string) => {
1141
- const response = await fetch(`${this.baseUrl}/del/${encodeURIComponent(key)}`, {
1142
- method: "POST",
1143
- headers: this.headers(),
1144
- });
1145
- if (!response.ok) {
1146
- const text = await response.text().catch(() => "");
1147
- console.error(`[store][upstash] DEL failed (${response.status}): ${text.slice(0, 200)}`);
1148
- }
1149
- },
1150
- };
1151
- }
1152
- }
1153
-
1154
- class RedisLikeStateStore implements StateStore {
1155
- private readonly memoryFallback: InMemoryStateStore;
1156
- private readonly ttl?: number;
1157
- private readonly clientPromise: Promise<
1158
- | {
1159
- get: (key: string) => Promise<string | null>;
1160
- set: (key: string, value: string, options?: { EX?: number }) => Promise<unknown>;
1161
- del: (key: string) => Promise<unknown>;
1162
- }
1163
- | undefined
1164
- >;
1165
-
1166
- constructor(url: string, ttl?: number) {
1167
- this.ttl = ttl;
1168
- this.memoryFallback = new InMemoryStateStore(ttl);
1169
- this.clientPromise = (async () => {
1170
- try {
1171
- const redisModule = (await import("redis")) as unknown as {
1172
- createClient: (options: { url: string }) => {
1173
- connect: () => Promise<unknown>;
1174
- get: (key: string) => Promise<string | null>;
1175
- set: (key: string, value: string, options?: { EX?: number }) => Promise<unknown>;
1176
- del: (key: string) => Promise<unknown>;
1177
- };
1178
- };
1179
- const client = redisModule.createClient({ url });
1180
- await client.connect();
1181
- return client;
1182
- } catch {
1183
- return undefined;
1184
- }
1185
- })();
1186
- }
1187
-
1188
- async get(runId: string): Promise<ConversationState | undefined> {
1189
- const client = await this.clientPromise;
1190
- if (!client) {
1191
- return await this.memoryFallback.get(runId);
1192
- }
1193
- const raw = await client.get(runId);
1194
- return raw ? (JSON.parse(raw) as ConversationState) : undefined;
1195
- }
1196
-
1197
- async set(state: ConversationState): Promise<void> {
1198
- const client = await this.clientPromise;
1199
- if (!client) {
1200
- await this.memoryFallback.set(state);
1201
- return;
1202
- }
1203
- const serialized = JSON.stringify({ ...state, updatedAt: Date.now() });
1204
- if (typeof this.ttl === "number") {
1205
- await client.set(state.runId, serialized, { EX: Math.max(1, this.ttl) });
1206
- return;
1207
- }
1208
- await client.set(state.runId, serialized);
1209
- }
1210
-
1211
- async delete(runId: string): Promise<void> {
1212
- const client = await this.clientPromise;
1213
- if (!client) {
1214
- await this.memoryFallback.delete(runId);
1215
- return;
1216
- }
1217
- await client.del(runId);
1218
- }
1219
- }
1220
-
1221
- class RedisLikeConversationStore extends KeyValueConversationStoreBase {
1222
- private readonly clientPromise: Promise<
1223
- | {
1224
- get: (key: string) => Promise<string | null>;
1225
- mGet: (keys: readonly string[]) => Promise<(string | null)[]>;
1226
- set: (key: string, value: string, options?: { EX?: number }) => Promise<unknown>;
1227
- del: (key: string) => Promise<unknown>;
1228
- }
1229
- | undefined
1230
- >;
1231
-
1232
- constructor(url: string, workingDir: string, ttl?: number, agentId?: string) {
1233
- super(ttl, workingDir, agentId);
1234
- this.clientPromise = (async () => {
1235
- try {
1236
- const redisModule = (await import("redis")) as unknown as {
1237
- createClient: (options: { url: string }) => {
1238
- connect: () => Promise<unknown>;
1239
- get: (key: string) => Promise<string | null>;
1240
- mGet: (keys: readonly string[]) => Promise<(string | null)[]>;
1241
- set: (key: string, value: string, options?: { EX?: number }) => Promise<unknown>;
1242
- del: (key: string) => Promise<unknown>;
1243
- };
1244
- };
1245
- const client = redisModule.createClient({ url });
1246
- await client.connect();
1247
- return client;
1248
- } catch {
1249
- return undefined;
1250
- }
1251
- })();
1252
- }
1253
-
1254
- protected async client(): Promise<RawKeyValueClient | undefined> {
1255
- const client = await this.clientPromise;
1256
- if (!client) {
1257
- return undefined;
1258
- }
1259
- return {
1260
- get: async (key: string) => {
1261
- const value = await client.get(key);
1262
- return value ?? undefined;
1263
- },
1264
- mget: async (keys: string[]) => {
1265
- if (keys.length === 0) return [];
1266
- const values = await client.mGet(keys);
1267
- return values.map((v: string | null) => v ?? undefined);
1268
- },
1269
- set: async (key: string, value: string, ttl?: number) => {
1270
- if (typeof ttl === "number") {
1271
- await client.set(key, value, { EX: Math.max(1, ttl) });
1272
- return;
1273
- }
1274
- await client.set(key, value);
1275
- },
1276
- del: async (key: string) => {
1277
- await client.del(key);
1278
- },
1279
- };
1280
- }
1281
- }
1282
-
1283
- class DynamoDbStateStore implements StateStore {
1284
- private readonly memoryFallback: InMemoryStateStore;
1285
- private readonly table: string;
1286
- private readonly ttl?: number;
1287
- private readonly clientPromise: Promise<
1288
- | {
1289
- send: (command: unknown) => Promise<unknown>;
1290
- GetItemCommand: new (input: unknown) => unknown;
1291
- PutItemCommand: new (input: unknown) => unknown;
1292
- DeleteItemCommand: new (input: unknown) => unknown;
1293
- }
1294
- | undefined
1295
- >;
1296
-
1297
- constructor(table: string, region?: string, ttl?: number) {
1298
- this.table = table;
1299
- this.ttl = ttl;
1300
- this.memoryFallback = new InMemoryStateStore(ttl);
1301
- this.clientPromise = (async () => {
1302
- try {
1303
- const module = (await import("@aws-sdk/client-dynamodb")) as {
1304
- DynamoDBClient: new (input: { region?: string }) => { send: (command: unknown) => Promise<unknown> };
1305
- GetItemCommand: new (input: unknown) => unknown;
1306
- PutItemCommand: new (input: unknown) => unknown;
1307
- DeleteItemCommand: new (input: unknown) => unknown;
1308
- };
1309
- return {
1310
- send: module.DynamoDBClient
1311
- ? new module.DynamoDBClient({ region }).send.bind(
1312
- new module.DynamoDBClient({ region }),
1313
- )
1314
- : async () => ({}),
1315
- GetItemCommand: module.GetItemCommand,
1316
- PutItemCommand: module.PutItemCommand,
1317
- DeleteItemCommand: module.DeleteItemCommand,
1318
- };
1319
- } catch {
1320
- return undefined;
1321
- }
1322
- })();
1323
- }
1324
-
1325
- async get(runId: string): Promise<ConversationState | undefined> {
1326
- const client = await this.clientPromise;
1327
- if (!client) {
1328
- return await this.memoryFallback.get(runId);
1329
- }
1330
- const result = (await client.send(
1331
- new client.GetItemCommand({
1332
- TableName: this.table,
1333
- Key: { runId: { S: runId } },
1334
- }),
1335
- )) as {
1336
- Item?: {
1337
- value?: { S?: string };
1338
- };
1339
- };
1340
- const raw = result.Item?.value?.S;
1341
- return raw ? (JSON.parse(raw) as ConversationState) : undefined;
1342
- }
1343
-
1344
- async set(state: ConversationState): Promise<void> {
1345
- const client = await this.clientPromise;
1346
- if (!client) {
1347
- await this.memoryFallback.set(state);
1348
- return;
1349
- }
1350
- const updatedState = { ...state, updatedAt: Date.now() };
1351
- const ttlEpoch =
1352
- typeof this.ttl === "number"
1353
- ? Math.floor(Date.now() / 1000) + Math.max(1, this.ttl)
1354
- : undefined;
1355
- await client.send(
1356
- new client.PutItemCommand({
1357
- TableName: this.table,
1358
- Item: {
1359
- runId: { S: state.runId },
1360
- value: { S: JSON.stringify(updatedState) },
1361
- ...(typeof ttlEpoch === "number" ? { ttl: { N: String(ttlEpoch) } } : {}),
1362
- },
1363
- }),
1364
- );
1365
- }
1366
-
1367
- async delete(runId: string): Promise<void> {
1368
- const client = await this.clientPromise;
1369
- if (!client) {
1370
- await this.memoryFallback.delete(runId);
1371
- return;
1372
- }
1373
- await client.send(
1374
- new client.DeleteItemCommand({
1375
- TableName: this.table,
1376
- Key: { runId: { S: runId } },
1377
- }),
1378
- );
1379
- }
1380
- }
1381
-
1382
- class DynamoDbConversationStore extends KeyValueConversationStoreBase {
1383
- private readonly table: string;
1384
- private readonly clientPromise: Promise<
1385
- | {
1386
- send: (command: unknown) => Promise<unknown>;
1387
- GetItemCommand: new (input: unknown) => unknown;
1388
- PutItemCommand: new (input: unknown) => unknown;
1389
- DeleteItemCommand: new (input: unknown) => unknown;
1390
- }
1391
- | undefined
1392
- >;
1393
-
1394
- constructor(table: string, workingDir: string, region?: string, ttl?: number, agentId?: string) {
1395
- super(ttl, workingDir, agentId);
1396
- this.table = table;
1397
- this.clientPromise = (async () => {
1398
- try {
1399
- const module = (await import("@aws-sdk/client-dynamodb")) as {
1400
- DynamoDBClient: new (input: { region?: string }) => { send: (command: unknown) => Promise<unknown> };
1401
- GetItemCommand: new (input: unknown) => unknown;
1402
- PutItemCommand: new (input: unknown) => unknown;
1403
- DeleteItemCommand: new (input: unknown) => unknown;
1404
- };
1405
- const client = new module.DynamoDBClient({ region });
1406
- return {
1407
- send: client.send.bind(client),
1408
- GetItemCommand: module.GetItemCommand,
1409
- PutItemCommand: module.PutItemCommand,
1410
- DeleteItemCommand: module.DeleteItemCommand,
1411
- };
1412
- } catch {
1413
- return undefined;
1414
- }
1415
- })();
1416
- }
1417
-
1418
- protected async client(): Promise<RawKeyValueClient | undefined> {
1419
- const client = await this.clientPromise;
1420
- if (!client) {
1421
- return undefined;
1422
- }
1423
- return {
1424
- get: async (key: string) => {
1425
- const result = (await client.send(
1426
- new client.GetItemCommand({
1427
- TableName: this.table,
1428
- Key: { runId: { S: key } },
1429
- }),
1430
- )) as {
1431
- Item?: {
1432
- value?: { S?: string };
1433
- };
1434
- };
1435
- return result.Item?.value?.S;
1436
- },
1437
- set: async (key: string, value: string, ttl?: number) => {
1438
- const ttlEpoch =
1439
- typeof ttl === "number" ? Math.floor(Date.now() / 1000) + Math.max(1, ttl) : undefined;
1440
- await client.send(
1441
- new client.PutItemCommand({
1442
- TableName: this.table,
1443
- Item: {
1444
- runId: { S: key },
1445
- value: { S: value },
1446
- ...(typeof ttlEpoch === "number" ? { ttl: { N: String(ttlEpoch) } } : {}),
1447
- },
1448
- }),
1449
- );
1450
- },
1451
- mget: async (keys: string[]) => {
1452
- if (keys.length === 0) return [];
1453
- return Promise.all(keys.map(async (key) => {
1454
- const result = (await client.send(
1455
- new client.GetItemCommand({
1456
- TableName: this.table,
1457
- Key: { runId: { S: key } },
1458
- }),
1459
- )) as { Item?: { value?: { S?: string } } };
1460
- return result.Item?.value?.S;
1461
- }));
1462
- },
1463
- del: async (key: string) => {
1464
- await client.send(
1465
- new client.DeleteItemCommand({
1466
- TableName: this.table,
1467
- Key: { runId: { S: key } },
1468
- }),
1469
- );
1470
- },
1471
- };
1472
- }
1473
- }
272
+ // ---------------------------------------------------------------------------
273
+ // Legacy factories — return InMemory stores. The harness now uses
274
+ // engine-backed stores via storage/store-adapters.ts. These factories
275
+ // exist only for backward compatibility with external callers and tests.
276
+ // ---------------------------------------------------------------------------
1474
277
 
1475
278
  export const createStateStore = (
1476
279
  config?: StateConfig,
1477
- options?: { workingDir?: string; agentId?: string },
280
+ _options?: { workingDir?: string; agentId?: string },
1478
281
  ): StateStore => {
1479
- const provider = config?.provider ?? "local";
1480
282
  const ttl = config?.ttl;
1481
- const workingDir = options?.workingDir ?? process.cwd();
1482
- if (provider === "local") {
1483
- return new FileStateStore(workingDir, ttl, options?.agentId);
1484
- }
1485
- if (provider === "memory") {
1486
- return new InMemoryStateStore(ttl);
1487
- }
1488
- if (provider === "upstash") {
1489
- const urlEnv = config?.urlEnv ?? (process.env.UPSTASH_REDIS_REST_URL ? "UPSTASH_REDIS_REST_URL" : "KV_REST_API_URL");
1490
- const tokenEnv = config?.tokenEnv ?? (process.env.UPSTASH_REDIS_REST_TOKEN ? "UPSTASH_REDIS_REST_TOKEN" : "KV_REST_API_TOKEN");
1491
- const url = process.env[urlEnv] ?? "";
1492
- const token = process.env[tokenEnv] ?? "";
1493
- if (url && token) {
1494
- return new UpstashStateStore(url, token, ttl);
1495
- }
1496
- return new InMemoryStateStore(ttl);
1497
- }
1498
- if (provider === "redis") {
1499
- const urlEnv = config?.urlEnv ?? "REDIS_URL";
1500
- const url = process.env[urlEnv] ?? "";
1501
- if (url) {
1502
- return new RedisLikeStateStore(url, ttl);
1503
- }
1504
- return new InMemoryStateStore(ttl);
1505
- }
1506
- if (provider === "dynamodb") {
1507
- const table = config?.table ?? process.env.PONCHO_DYNAMODB_TABLE ?? "";
1508
- if (table) {
1509
- return new DynamoDbStateStore(table, config?.region as string | undefined, ttl);
1510
- }
1511
- return new InMemoryStateStore(ttl);
1512
- }
1513
283
  return new InMemoryStateStore(ttl);
1514
284
  };
1515
285
 
1516
286
  export const createConversationStore = (
1517
287
  config?: StateConfig,
1518
- options?: { workingDir?: string; agentId?: string },
288
+ _options?: { workingDir?: string; agentId?: string },
1519
289
  ): ConversationStore => {
1520
- const provider = config?.provider ?? "local";
1521
290
  const ttl = config?.ttl;
1522
- const workingDir = options?.workingDir ?? process.cwd();
1523
- if (provider === "local") {
1524
- return new FileConversationStore(workingDir, options?.agentId);
1525
- }
1526
- if (provider === "memory") {
1527
- return new InMemoryConversationStore(ttl);
1528
- }
1529
- if (provider === "upstash") {
1530
- const urlEnv = config?.urlEnv ?? (process.env.UPSTASH_REDIS_REST_URL ? "UPSTASH_REDIS_REST_URL" : "KV_REST_API_URL");
1531
- const tokenEnv = config?.tokenEnv ?? (process.env.UPSTASH_REDIS_REST_TOKEN ? "UPSTASH_REDIS_REST_TOKEN" : "KV_REST_API_TOKEN");
1532
- const url = process.env[urlEnv] ?? "";
1533
- const token = process.env[tokenEnv] ?? "";
1534
- if (url && token) {
1535
- return new UpstashConversationStore(url, token, workingDir, ttl, options?.agentId);
1536
- }
1537
- return new InMemoryConversationStore(ttl);
1538
- }
1539
- if (provider === "redis") {
1540
- const urlEnv = config?.urlEnv ?? "REDIS_URL";
1541
- const url = process.env[urlEnv] ?? "";
1542
- if (url) {
1543
- return new RedisLikeConversationStore(url, workingDir, ttl, options?.agentId);
1544
- }
1545
- return new InMemoryConversationStore(ttl);
1546
- }
1547
- if (provider === "dynamodb") {
1548
- const table = config?.table ?? process.env.PONCHO_DYNAMODB_TABLE ?? "";
1549
- if (table) {
1550
- return new DynamoDbConversationStore(
1551
- table,
1552
- workingDir,
1553
- config?.region as string | undefined,
1554
- ttl,
1555
- options?.agentId,
1556
- );
1557
- }
1558
- return new InMemoryConversationStore(ttl);
1559
- }
1560
291
  return new InMemoryConversationStore(ttl);
1561
292
  };