@poncho-ai/harness 0.35.0 → 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 (57) hide show
  1. package/.turbo/turbo-build.log +6 -5
  2. package/.turbo/turbo-test.log +15169 -0
  3. package/CHANGELOG.md +18 -0
  4. package/dist/chunk-MCKGQKYU.js +15 -0
  5. package/dist/dist-3KMQR4IO.js +27092 -0
  6. package/dist/index.d.ts +485 -29
  7. package/dist/index.js +2839 -2114
  8. package/dist/isolate-5MISBSUK.js +733 -0
  9. package/dist/isolate-5R6762YA.js +605 -0
  10. package/dist/isolate-KUZ5NOPG.js +727 -0
  11. package/dist/isolate-LOL3T7RA.js +729 -0
  12. package/dist/isolate-N22X4TCE.js +740 -0
  13. package/dist/isolate-T7WXM7IL.js +1490 -0
  14. package/dist/isolate-TCWTUVG4.js +1532 -0
  15. package/dist/isolate-WFOLANOB.js +768 -0
  16. package/package.json +22 -3
  17. package/scripts/migrate-to-engine.mjs +556 -0
  18. package/src/config.ts +106 -1
  19. package/src/harness.ts +226 -91
  20. package/src/index.ts +5 -0
  21. package/src/isolate/bindings.ts +206 -0
  22. package/src/isolate/bundler.ts +179 -0
  23. package/src/isolate/index.ts +10 -0
  24. package/src/isolate/polyfills.ts +796 -0
  25. package/src/isolate/run-code-tool.ts +220 -0
  26. package/src/isolate/runtime.ts +286 -0
  27. package/src/isolate/type-stubs.ts +196 -0
  28. package/src/memory.ts +129 -198
  29. package/src/reminder-store.ts +3 -237
  30. package/src/secrets-store.ts +2 -91
  31. package/src/state.ts +11 -1302
  32. package/src/storage/engine.ts +106 -0
  33. package/src/storage/index.ts +59 -0
  34. package/src/storage/memory-engine.ts +588 -0
  35. package/src/storage/postgres-engine.ts +139 -0
  36. package/src/storage/schema.ts +145 -0
  37. package/src/storage/sql-dialect.ts +963 -0
  38. package/src/storage/sqlite-engine.ts +99 -0
  39. package/src/storage/store-adapters.ts +100 -0
  40. package/src/todo-tools.ts +1 -136
  41. package/src/upload-store.ts +1 -0
  42. package/src/vfs/bash-manager.ts +120 -0
  43. package/src/vfs/bash-tool.ts +59 -0
  44. package/src/vfs/create-bash-fs.ts +32 -0
  45. package/src/vfs/edit-file-tool.ts +72 -0
  46. package/src/vfs/index.ts +5 -0
  47. package/src/vfs/poncho-fs-adapter.ts +267 -0
  48. package/src/vfs/protected-fs.ts +177 -0
  49. package/src/vfs/read-file-tool.ts +103 -0
  50. package/src/vfs/write-file-tool.ts +49 -0
  51. package/test/harness.test.ts +30 -36
  52. package/test/isolate-vfs.test.ts +453 -0
  53. package/test/isolate.test.ts +252 -0
  54. package/test/state.test.ts +4 -27
  55. package/test/storage-engine.test.ts +250 -0
  56. package/test/vfs.test.ts +242 -0
  57. package/src/kv-store.ts +0 -216
@@ -0,0 +1,963 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Shared SQL dialect abstraction + SqlStorageEngine base class.
3
+ //
4
+ // SQLite and PostgreSQL engines extend this base, providing only:
5
+ // - a Dialect (placeholder style, types, now(), etc.)
6
+ // - a query executor
7
+ // The base class contains all query logic + migration runner.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ import { randomUUID } from "node:crypto";
11
+ import type {
12
+ Conversation,
13
+ ConversationSummary,
14
+ PendingSubagentResult,
15
+ } from "../state.js";
16
+ import type { MainMemory } from "../memory.js";
17
+ import type { TodoItem } from "../todo-tools.js";
18
+ import type { Reminder } from "../reminder-store.js";
19
+ import type { StorageEngine, VfsDirEntry, VfsStat } from "./engine.js";
20
+ import { type DialectTag, migrations } from "./schema.js";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Dialect
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export interface Dialect {
27
+ tag: DialectTag;
28
+ /** Return a positional parameter placeholder. 1-indexed. */
29
+ param(index: number): string;
30
+ /** BLOB type name */
31
+ blob: string;
32
+ /** JSON type name */
33
+ json: string;
34
+ /** Current-timestamp expression */
35
+ now(): string;
36
+ /** UPSERT conflict clause for a given PK column list */
37
+ upsert(pkCols: string[]): string;
38
+ }
39
+
40
+ export const sqliteDialect: Dialect = {
41
+ tag: "sqlite",
42
+ param: () => "?",
43
+ blob: "BLOB",
44
+ json: "TEXT",
45
+ now: () => "datetime('now')",
46
+ upsert: (cols) => `ON CONFLICT(${cols.join(", ")}) DO UPDATE SET`,
47
+ };
48
+
49
+ export const postgresDialect: Dialect = {
50
+ tag: "postgresql",
51
+ param: (i) => `$${i}`,
52
+ blob: "BYTEA",
53
+ json: "JSONB",
54
+ now: () => "NOW()",
55
+ upsert: (cols) => `ON CONFLICT(${cols.join(", ")}) DO UPDATE SET`,
56
+ };
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Query executor interface (provided by each engine subclass)
60
+ // ---------------------------------------------------------------------------
61
+
62
+ export interface QueryRow {
63
+ [key: string]: unknown;
64
+ }
65
+
66
+ export interface QueryExecutor {
67
+ run(sql: string, params?: unknown[]): Promise<void>;
68
+ get<T extends QueryRow = QueryRow>(sql: string, params?: unknown[]): Promise<T | undefined>;
69
+ all<T extends QueryRow = QueryRow>(sql: string, params?: unknown[]): Promise<T[]>;
70
+ /** Execute raw SQL (for migrations). */
71
+ exec(sql: string): Promise<void>;
72
+ /** Run multiple statements in a transaction. */
73
+ transaction(fn: () => Promise<void>): Promise<void>;
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Helpers
78
+ // ---------------------------------------------------------------------------
79
+
80
+ const DEFAULT_TENANT = "__default__";
81
+ const DEFAULT_OWNER = "local-owner";
82
+
83
+ const normalizeTenant = (tenantId?: string | null): string =>
84
+ tenantId ?? DEFAULT_TENANT;
85
+
86
+ const normalizeTitle = (title?: string): string =>
87
+ title && title.trim().length > 0 ? title.trim() : "New conversation";
88
+
89
+ const parentOf = (p: string): string => {
90
+ const idx = p.lastIndexOf("/");
91
+ return idx <= 0 ? "/" : p.slice(0, idx);
92
+ };
93
+
94
+ /** Parameterize a query for the dialect: replaces $1..$N with dialect placeholders. */
95
+ const rewrite = (sql: string, dialect: Dialect): string => {
96
+ if (dialect.tag === "sqlite") {
97
+ // Replace $1, $2, … with ?
98
+ return sql.replace(/\$\d+/g, "?");
99
+ }
100
+ return sql;
101
+ };
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // SqlStorageEngine base class
105
+ // ---------------------------------------------------------------------------
106
+
107
+ export abstract class SqlStorageEngine implements StorageEngine {
108
+ protected readonly dialect: Dialect;
109
+ protected readonly agentId: string;
110
+ protected abstract readonly executor: QueryExecutor;
111
+
112
+ constructor(dialect: Dialect, agentId: string) {
113
+ this.dialect = dialect;
114
+ this.agentId = agentId;
115
+ }
116
+
117
+ // -----------------------------------------------------------------------
118
+ // Lifecycle
119
+ // -----------------------------------------------------------------------
120
+
121
+ async initialize(): Promise<void> {
122
+ await this.onBeforeInit();
123
+ await this.runMigrations();
124
+ }
125
+
126
+ abstract close(): Promise<void>;
127
+
128
+ /** Hook for subclass-specific setup (e.g. WAL mode). */
129
+ protected async onBeforeInit(): Promise<void> {}
130
+
131
+ // -----------------------------------------------------------------------
132
+ // Migration runner
133
+ // -----------------------------------------------------------------------
134
+
135
+ private async runMigrations(): Promise<void> {
136
+ const e = this.executor;
137
+
138
+ // Fast path: if we know the latest migration version, just check
139
+ // with a single lightweight query instead of CREATE TABLE + SELECT.
140
+ const latestVersion = migrations[migrations.length - 1]?.version ?? 0;
141
+ try {
142
+ const row = await e.get<{ max_v: number | null }>(
143
+ "SELECT MAX(version) as max_v FROM _migrations",
144
+ );
145
+ if (row && (row.max_v ?? 0) >= latestVersion) return; // all up to date
146
+ } catch {
147
+ // _migrations table doesn't exist yet — fall through to create it
148
+ }
149
+
150
+ await e.exec(
151
+ `CREATE TABLE IF NOT EXISTS _migrations (
152
+ version INTEGER PRIMARY KEY,
153
+ name TEXT NOT NULL,
154
+ applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
155
+ )`,
156
+ );
157
+
158
+ const row = await e.get<{ max_v: number | null }>(
159
+ "SELECT MAX(version) as max_v FROM _migrations",
160
+ );
161
+ const applied = row?.max_v ?? 0;
162
+
163
+ for (const m of migrations) {
164
+ if (m.version <= applied) continue;
165
+ const stmts = m.up(this.dialect.tag);
166
+ await e.transaction(async () => {
167
+ for (const sql of stmts) {
168
+ await e.exec(sql);
169
+ }
170
+ await e.run(
171
+ rewrite("INSERT INTO _migrations (version, name) VALUES ($1, $2)", this.dialect),
172
+ [m.version, m.name],
173
+ );
174
+ });
175
+ }
176
+ }
177
+
178
+ // -----------------------------------------------------------------------
179
+ // Conversations
180
+ // -----------------------------------------------------------------------
181
+
182
+ conversations = {
183
+ list: async (
184
+ ownerId?: string,
185
+ tenantId?: string | null,
186
+ ): Promise<ConversationSummary[]> => {
187
+ const tid = normalizeTenant(tenantId);
188
+ // When tenantId is undefined (admin), don't filter by tenant
189
+ const filterTenant = tenantId !== undefined;
190
+ const params: unknown[] = [this.agentId];
191
+ let sql = `SELECT id, title, updated_at, created_at, owner_id, tenant_id,
192
+ message_count, data
193
+ FROM conversations WHERE agent_id = $1`;
194
+ if (filterTenant) {
195
+ sql += ` AND tenant_id = $2`;
196
+ params.push(tid);
197
+ }
198
+ if (ownerId) {
199
+ sql += ` AND owner_id = $${params.length + 1}`;
200
+ params.push(ownerId);
201
+ }
202
+ sql += ` ORDER BY updated_at DESC`;
203
+
204
+ const rows = await this.executor.all(rewrite(sql, this.dialect), params);
205
+ return rows.map((r) => this.rowToSummary(r));
206
+ },
207
+
208
+ get: async (conversationId: string): Promise<Conversation | undefined> => {
209
+ const row = await this.executor.get<{
210
+ data: unknown;
211
+ tool_result_archive: unknown;
212
+ harness_messages: unknown;
213
+ continuation_messages: unknown;
214
+ }>(
215
+ rewrite("SELECT data, tool_result_archive, harness_messages, continuation_messages FROM conversations WHERE id = $1 AND agent_id = $2", this.dialect),
216
+ [conversationId, this.agentId],
217
+ );
218
+ if (!row) return undefined;
219
+ const conv = this.parseConversation(row.data);
220
+ // Rehydrate heavy fields from separate columns
221
+ if (row.tool_result_archive) {
222
+ conv._toolResultArchive =
223
+ typeof row.tool_result_archive === "string"
224
+ ? JSON.parse(row.tool_result_archive)
225
+ : row.tool_result_archive;
226
+ }
227
+ if (row.harness_messages) {
228
+ conv._harnessMessages =
229
+ typeof row.harness_messages === "string"
230
+ ? JSON.parse(row.harness_messages)
231
+ : row.harness_messages;
232
+ }
233
+ if (row.continuation_messages) {
234
+ conv._continuationMessages =
235
+ typeof row.continuation_messages === "string"
236
+ ? JSON.parse(row.continuation_messages)
237
+ : row.continuation_messages;
238
+ }
239
+ return conv;
240
+ },
241
+
242
+ create: async (
243
+ ownerId?: string,
244
+ title?: string,
245
+ tenantId?: string | null,
246
+ ): Promise<Conversation> => {
247
+ const id = randomUUID();
248
+ const now = Date.now();
249
+ const conv: Conversation = {
250
+ conversationId: id,
251
+ title: normalizeTitle(title),
252
+ messages: [],
253
+ ownerId: ownerId ?? DEFAULT_OWNER,
254
+ tenantId: tenantId === undefined ? null : tenantId,
255
+ createdAt: now,
256
+ updatedAt: now,
257
+ };
258
+ const data = JSON.stringify(conv);
259
+ await this.executor.run(
260
+ rewrite(
261
+ `INSERT INTO conversations (id, agent_id, tenant_id, owner_id, title, data, message_count, created_at, updated_at)
262
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
263
+ this.dialect,
264
+ ),
265
+ [
266
+ id,
267
+ this.agentId,
268
+ normalizeTenant(tenantId),
269
+ conv.ownerId,
270
+ conv.title,
271
+ data,
272
+ 0,
273
+ new Date(now).toISOString(),
274
+ new Date(now).toISOString(),
275
+ ],
276
+ );
277
+ return conv;
278
+ },
279
+
280
+ update: async (conversation: Conversation): Promise<void> => {
281
+ conversation.updatedAt = Date.now();
282
+ // Strip heavy internal fields from the data blob — stored in separate columns
283
+ const archive = conversation._toolResultArchive;
284
+ const harnessMessages = conversation._harnessMessages;
285
+ const continuationMessages = conversation._continuationMessages;
286
+ const stripped = { ...conversation };
287
+ delete stripped._toolResultArchive;
288
+ delete stripped._harnessMessages;
289
+ delete stripped._continuationMessages;
290
+ const data = JSON.stringify(stripped);
291
+ const archiveJson = archive ? JSON.stringify(archive) : null;
292
+ const harnessJson = harnessMessages ? JSON.stringify(harnessMessages) : null;
293
+ const continuationJson = continuationMessages ? JSON.stringify(continuationMessages) : null;
294
+ const msgCount = conversation.messages?.length ?? 0;
295
+ await this.executor.run(
296
+ rewrite(
297
+ `UPDATE conversations
298
+ SET data = $1, title = $2, message_count = $3, updated_at = $4, tenant_id = $5, owner_id = $6,
299
+ tool_result_archive = $7, harness_messages = $8, continuation_messages = $9
300
+ WHERE id = $10 AND agent_id = $11`,
301
+ this.dialect,
302
+ ),
303
+ [
304
+ data,
305
+ conversation.title,
306
+ msgCount,
307
+ new Date(conversation.updatedAt).toISOString(),
308
+ normalizeTenant(conversation.tenantId),
309
+ conversation.ownerId,
310
+ archiveJson,
311
+ harnessJson,
312
+ continuationJson,
313
+ conversation.conversationId,
314
+ this.agentId,
315
+ ],
316
+ );
317
+ },
318
+
319
+ rename: async (
320
+ conversationId: string,
321
+ title: string,
322
+ ): Promise<Conversation | undefined> => {
323
+ const conv = await this.conversations.get(conversationId);
324
+ if (!conv) return undefined;
325
+ conv.title = normalizeTitle(title);
326
+ await this.conversations.update(conv);
327
+ return conv;
328
+ },
329
+
330
+ delete: async (conversationId: string): Promise<boolean> => {
331
+ const row = await this.executor.get(
332
+ rewrite("SELECT id FROM conversations WHERE id = $1 AND agent_id = $2", this.dialect),
333
+ [conversationId, this.agentId],
334
+ );
335
+ if (!row) return false;
336
+ await this.executor.run(
337
+ rewrite("DELETE FROM conversations WHERE id = $1 AND agent_id = $2", this.dialect),
338
+ [conversationId, this.agentId],
339
+ );
340
+ return true;
341
+ },
342
+
343
+ search: async (
344
+ query: string,
345
+ tenantId?: string | null,
346
+ ): Promise<ConversationSummary[]> => {
347
+ const tid = normalizeTenant(tenantId);
348
+ const filterTenant = tenantId !== undefined;
349
+ const pattern = `%${query}%`;
350
+ // SQLite uses positional ? so we can't reuse $2, need separate params
351
+ const params: unknown[] = [this.agentId, pattern, pattern];
352
+ let sql = `SELECT id, title, updated_at, created_at, owner_id, tenant_id,
353
+ message_count, data
354
+ FROM conversations
355
+ WHERE agent_id = $1 AND (title LIKE $2 OR data LIKE $3)`;
356
+ if (filterTenant) {
357
+ sql += ` AND tenant_id = $4`;
358
+ params.push(tid);
359
+ }
360
+ sql += ` ORDER BY updated_at DESC`;
361
+ const rows = await this.executor.all(rewrite(sql, this.dialect), params);
362
+ return rows.map((r) => this.rowToSummary(r));
363
+ },
364
+
365
+ appendSubagentResult: async (
366
+ conversationId: string,
367
+ result: PendingSubagentResult,
368
+ ): Promise<void> => {
369
+ const conv = await this.conversations.get(conversationId);
370
+ if (!conv) return;
371
+ conv.pendingSubagentResults = [...(conv.pendingSubagentResults ?? []), result];
372
+ await this.conversations.update(conv);
373
+ },
374
+
375
+ clearCallbackLock: async (
376
+ conversationId: string,
377
+ ): Promise<Conversation | undefined> => {
378
+ const conv = await this.conversations.get(conversationId);
379
+ if (!conv) return undefined;
380
+ conv.runningCallbackSince = undefined;
381
+ await this.conversations.update(conv);
382
+ return conv;
383
+ },
384
+ };
385
+
386
+ // -----------------------------------------------------------------------
387
+ // Memory
388
+ // -----------------------------------------------------------------------
389
+
390
+ memory = {
391
+ get: async (tenantId?: string | null): Promise<MainMemory> => {
392
+ const tid = normalizeTenant(tenantId);
393
+ const row = await this.executor.get<{ content: string; updated_at: string }>(
394
+ rewrite(
395
+ "SELECT content, updated_at FROM memory WHERE agent_id = $1 AND tenant_id = $2",
396
+ this.dialect,
397
+ ),
398
+ [this.agentId, tid],
399
+ );
400
+ if (!row) return { content: "", updatedAt: 0 };
401
+ return {
402
+ content: row.content,
403
+ updatedAt: new Date(row.updated_at).getTime(),
404
+ };
405
+ },
406
+
407
+ update: async (content: string, tenantId?: string | null): Promise<MainMemory> => {
408
+ const tid = normalizeTenant(tenantId);
409
+ const now = new Date().toISOString();
410
+ await this.executor.run(
411
+ rewrite(
412
+ `INSERT INTO memory (agent_id, tenant_id, content, updated_at)
413
+ VALUES ($1, $2, $3, $4)
414
+ ${this.dialect.upsert(["agent_id", "tenant_id"])}
415
+ content = excluded.content, updated_at = excluded.updated_at`,
416
+ this.dialect,
417
+ ),
418
+ [this.agentId, tid, content, now],
419
+ );
420
+ return { content, updatedAt: new Date(now).getTime() };
421
+ },
422
+ };
423
+
424
+ // -----------------------------------------------------------------------
425
+ // Todos
426
+ // -----------------------------------------------------------------------
427
+
428
+ todos = {
429
+ get: async (conversationId: string): Promise<TodoItem[]> => {
430
+ const row = await this.executor.get<{ data: string }>(
431
+ rewrite(
432
+ "SELECT data FROM todos WHERE agent_id = $1 AND conversation_id = $2",
433
+ this.dialect,
434
+ ),
435
+ [this.agentId, conversationId],
436
+ );
437
+ if (!row) return [];
438
+ return typeof row.data === "string" ? JSON.parse(row.data) : row.data;
439
+ },
440
+
441
+ set: async (conversationId: string, todos: TodoItem[]): Promise<void> => {
442
+ const data = JSON.stringify(todos);
443
+ await this.executor.run(
444
+ rewrite(
445
+ `INSERT INTO todos (agent_id, conversation_id, data)
446
+ VALUES ($1, $2, $3)
447
+ ${this.dialect.upsert(["agent_id", "conversation_id"])}
448
+ data = excluded.data`,
449
+ this.dialect,
450
+ ),
451
+ [this.agentId, conversationId, data],
452
+ );
453
+ },
454
+ };
455
+
456
+ // -----------------------------------------------------------------------
457
+ // Reminders
458
+ // -----------------------------------------------------------------------
459
+
460
+ reminders = {
461
+ list: async (tenantId?: string | null): Promise<Reminder[]> => {
462
+ const tid = normalizeTenant(tenantId);
463
+ const filterTenant = tenantId !== undefined;
464
+ const params: unknown[] = [this.agentId];
465
+ let sql = "SELECT * FROM reminders WHERE agent_id = $1";
466
+ if (filterTenant) {
467
+ sql += " AND tenant_id = $2";
468
+ params.push(tid);
469
+ }
470
+ sql += " ORDER BY scheduled_at ASC";
471
+ const rows = await this.executor.all(rewrite(sql, this.dialect), params);
472
+ return rows.map((r) => this.rowToReminder(r));
473
+ },
474
+
475
+ create: async (input: {
476
+ task: string;
477
+ scheduledAt: number;
478
+ timezone?: string;
479
+ conversationId: string;
480
+ ownerId?: string;
481
+ tenantId?: string | null;
482
+ }): Promise<Reminder> => {
483
+ const id = randomUUID();
484
+ const now = Date.now();
485
+ const tid = normalizeTenant(input.tenantId);
486
+ await this.executor.run(
487
+ rewrite(
488
+ `INSERT INTO reminders (id, agent_id, tenant_id, owner_id, conversation_id, task, status, scheduled_at, timezone, created_at)
489
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
490
+ this.dialect,
491
+ ),
492
+ [
493
+ id,
494
+ this.agentId,
495
+ tid,
496
+ input.ownerId ?? null,
497
+ input.conversationId,
498
+ input.task,
499
+ "pending",
500
+ input.scheduledAt,
501
+ input.timezone ?? null,
502
+ new Date(now).toISOString(),
503
+ ],
504
+ );
505
+ return {
506
+ id,
507
+ task: input.task,
508
+ scheduledAt: input.scheduledAt,
509
+ timezone: input.timezone,
510
+ status: "pending",
511
+ createdAt: now,
512
+ conversationId: input.conversationId,
513
+ ownerId: input.ownerId,
514
+ tenantId: input.tenantId,
515
+ };
516
+ },
517
+
518
+ cancel: async (id: string): Promise<Reminder> => {
519
+ await this.executor.run(
520
+ rewrite(
521
+ "UPDATE reminders SET status = 'cancelled' WHERE id = $1 AND agent_id = $2",
522
+ this.dialect,
523
+ ),
524
+ [id, this.agentId],
525
+ );
526
+ const row = await this.executor.get(
527
+ rewrite("SELECT * FROM reminders WHERE id = $1 AND agent_id = $2", this.dialect),
528
+ [id, this.agentId],
529
+ );
530
+ if (!row) throw new Error(`Reminder ${id} not found`);
531
+ return this.rowToReminder(row);
532
+ },
533
+
534
+ delete: async (id: string): Promise<void> => {
535
+ await this.executor.run(
536
+ rewrite("DELETE FROM reminders WHERE id = $1 AND agent_id = $2", this.dialect),
537
+ [id, this.agentId],
538
+ );
539
+ },
540
+ };
541
+
542
+ // -----------------------------------------------------------------------
543
+ // VFS
544
+ // -----------------------------------------------------------------------
545
+
546
+ vfs = {
547
+ readFile: async (tenantId: string, path: string): Promise<Uint8Array> => {
548
+ const row = await this.executor.get<{ content: unknown; type: string }>(
549
+ rewrite(
550
+ "SELECT content, type FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND path = $3",
551
+ this.dialect,
552
+ ),
553
+ [this.agentId, tenantId, path],
554
+ );
555
+ if (!row) throw new Error(`ENOENT: no such file or directory, open '${path}'`);
556
+ if (row.type === "directory") throw new Error(`EISDIR: illegal operation on a directory, read '${path}'`);
557
+ if (row.type === "symlink") {
558
+ // Follow symlink
559
+ const target = await this.resolveSymlink(tenantId, path);
560
+ return this.vfs.readFile(tenantId, target);
561
+ }
562
+ return this.toUint8Array(row.content);
563
+ },
564
+
565
+ writeFile: async (
566
+ tenantId: string,
567
+ path: string,
568
+ content: Uint8Array,
569
+ mimeType?: string,
570
+ ): Promise<void> => {
571
+ // Ensure parent directories exist
572
+ await this.ensureParentDirs(tenantId, path);
573
+ const pp = parentOf(path);
574
+ const now = new Date().toISOString();
575
+ await this.executor.run(
576
+ rewrite(
577
+ `INSERT INTO vfs_entries (agent_id, tenant_id, path, parent_path, type, content, mime_type, size, mode, created_at, updated_at)
578
+ VALUES ($1, $2, $3, $4, 'file', $5, $6, $7, 438, $8, $9)
579
+ ${this.dialect.upsert(["agent_id", "tenant_id", "path"])}
580
+ content = excluded.content, mime_type = excluded.mime_type, size = excluded.size, updated_at = excluded.updated_at, type = 'file'`,
581
+ this.dialect,
582
+ ),
583
+ [this.agentId, tenantId, path, pp, content, mimeType ?? null, content.byteLength, now, now],
584
+ );
585
+ },
586
+
587
+ appendFile: async (
588
+ tenantId: string,
589
+ path: string,
590
+ content: Uint8Array,
591
+ ): Promise<void> => {
592
+ const existing = await this.executor.get<{ content: unknown }>(
593
+ rewrite(
594
+ "SELECT content FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND path = $3 AND type = 'file'",
595
+ this.dialect,
596
+ ),
597
+ [this.agentId, tenantId, path],
598
+ );
599
+ if (existing) {
600
+ const prev = this.toUint8Array(existing.content);
601
+ const merged = new Uint8Array(prev.byteLength + content.byteLength);
602
+ merged.set(prev);
603
+ merged.set(content, prev.byteLength);
604
+ await this.vfs.writeFile(tenantId, path, merged);
605
+ } else {
606
+ await this.vfs.writeFile(tenantId, path, content);
607
+ }
608
+ },
609
+
610
+ deleteFile: async (tenantId: string, path: string): Promise<void> => {
611
+ const stat = await this.vfs.stat(tenantId, path);
612
+ if (!stat) throw new Error(`ENOENT: no such file or directory, unlink '${path}'`);
613
+ if (stat.type === "directory") throw new Error(`EISDIR: illegal operation on a directory, unlink '${path}'`);
614
+ await this.executor.run(
615
+ rewrite(
616
+ "DELETE FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND path = $3",
617
+ this.dialect,
618
+ ),
619
+ [this.agentId, tenantId, path],
620
+ );
621
+ },
622
+
623
+ deleteDir: async (
624
+ tenantId: string,
625
+ path: string,
626
+ recursive?: boolean,
627
+ ): Promise<void> => {
628
+ if (recursive) {
629
+ // Delete all entries under this path
630
+ await this.executor.run(
631
+ rewrite(
632
+ "DELETE FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND (path = $3 OR path LIKE $4)",
633
+ this.dialect,
634
+ ),
635
+ [this.agentId, tenantId, path, `${path}/%`],
636
+ );
637
+ } else {
638
+ // Check if directory is empty
639
+ const children = await this.vfs.readdir(tenantId, path);
640
+ if (children.length > 0) {
641
+ throw new Error(`ENOTEMPTY: directory not empty, rmdir '${path}'`);
642
+ }
643
+ await this.executor.run(
644
+ rewrite(
645
+ "DELETE FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND path = $3",
646
+ this.dialect,
647
+ ),
648
+ [this.agentId, tenantId, path],
649
+ );
650
+ }
651
+ },
652
+
653
+ stat: async (tenantId: string, path: string): Promise<VfsStat | undefined> => {
654
+ if (path === "/") {
655
+ return {
656
+ type: "directory",
657
+ size: 0,
658
+ mode: 0o755,
659
+ createdAt: 0,
660
+ updatedAt: 0,
661
+ };
662
+ }
663
+ const row = await this.executor.get<{
664
+ type: string;
665
+ size: number;
666
+ mode: number;
667
+ mime_type: string | null;
668
+ symlink_target: string | null;
669
+ created_at: string;
670
+ updated_at: string;
671
+ }>(
672
+ rewrite(
673
+ "SELECT type, size, mode, mime_type, symlink_target, created_at, updated_at FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND path = $3",
674
+ this.dialect,
675
+ ),
676
+ [this.agentId, tenantId, path],
677
+ );
678
+ if (!row) return undefined;
679
+ return {
680
+ type: row.type as VfsStat["type"],
681
+ size: row.size,
682
+ mode: row.mode,
683
+ mimeType: row.mime_type ?? undefined,
684
+ symlinkTarget: row.symlink_target ?? undefined,
685
+ createdAt: new Date(row.created_at).getTime(),
686
+ updatedAt: new Date(row.updated_at).getTime(),
687
+ };
688
+ },
689
+
690
+ readdir: async (tenantId: string, path: string): Promise<VfsDirEntry[]> => {
691
+ const normalizedPath = path === "/" ? "/" : path;
692
+ const rows = await this.executor.all<{ path: string; type: string }>(
693
+ rewrite(
694
+ "SELECT path, type FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND parent_path = $3",
695
+ this.dialect,
696
+ ),
697
+ [this.agentId, tenantId, normalizedPath],
698
+ );
699
+ return rows.map((r) => ({
700
+ name: r.path.slice(r.path.lastIndexOf("/") + 1),
701
+ type: r.type as VfsDirEntry["type"],
702
+ }));
703
+ },
704
+
705
+ mkdir: async (
706
+ tenantId: string,
707
+ path: string,
708
+ recursive?: boolean,
709
+ ): Promise<void> => {
710
+ if (recursive) {
711
+ const parts = path.split("/").filter(Boolean);
712
+ let current = "";
713
+ for (const part of parts) {
714
+ current += `/${part}`;
715
+ await this.mkdirSingle(tenantId, current);
716
+ }
717
+ } else {
718
+ // Check parent exists
719
+ const pp = parentOf(path);
720
+ if (pp !== "/") {
721
+ const parentStat = await this.vfs.stat(tenantId, pp);
722
+ if (!parentStat) {
723
+ throw new Error(`ENOENT: no such file or directory, mkdir '${path}'`);
724
+ }
725
+ }
726
+ await this.mkdirSingle(tenantId, path);
727
+ }
728
+ },
729
+
730
+ rename: async (
731
+ tenantId: string,
732
+ oldPath: string,
733
+ newPath: string,
734
+ ): Promise<void> => {
735
+ await this.ensureParentDirs(tenantId, newPath);
736
+ const newParent = parentOf(newPath);
737
+ // Rename the entry itself
738
+ await this.executor.run(
739
+ rewrite(
740
+ "UPDATE vfs_entries SET path = $1, parent_path = $2, updated_at = $3 WHERE agent_id = $4 AND tenant_id = $5 AND path = $6",
741
+ this.dialect,
742
+ ),
743
+ [newPath, newParent, new Date().toISOString(), this.agentId, tenantId, oldPath],
744
+ );
745
+ // Rename children (for directories)
746
+ const prefix = `${oldPath}/`;
747
+ const rows = await this.executor.all<{ path: string }>(
748
+ rewrite(
749
+ "SELECT path FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND path LIKE $3",
750
+ this.dialect,
751
+ ),
752
+ [this.agentId, tenantId, `${prefix}%`],
753
+ );
754
+ for (const row of rows) {
755
+ const childNewPath = newPath + row.path.slice(oldPath.length);
756
+ const childNewParent = parentOf(childNewPath);
757
+ await this.executor.run(
758
+ rewrite(
759
+ "UPDATE vfs_entries SET path = $1, parent_path = $2 WHERE agent_id = $3 AND tenant_id = $4 AND path = $5",
760
+ this.dialect,
761
+ ),
762
+ [childNewPath, childNewParent, this.agentId, tenantId, row.path],
763
+ );
764
+ }
765
+ },
766
+
767
+ chmod: async (tenantId: string, path: string, mode: number): Promise<void> => {
768
+ await this.executor.run(
769
+ rewrite(
770
+ "UPDATE vfs_entries SET mode = $1, updated_at = $2 WHERE agent_id = $3 AND tenant_id = $4 AND path = $5",
771
+ this.dialect,
772
+ ),
773
+ [mode, new Date().toISOString(), this.agentId, tenantId, path],
774
+ );
775
+ },
776
+
777
+ utimes: async (tenantId: string, path: string, mtime: Date): Promise<void> => {
778
+ await this.executor.run(
779
+ rewrite(
780
+ "UPDATE vfs_entries SET updated_at = $1 WHERE agent_id = $2 AND tenant_id = $3 AND path = $4",
781
+ this.dialect,
782
+ ),
783
+ [mtime.toISOString(), this.agentId, tenantId, path],
784
+ );
785
+ },
786
+
787
+ symlink: async (tenantId: string, target: string, linkPath: string): Promise<void> => {
788
+ await this.ensureParentDirs(tenantId, linkPath);
789
+ const pp = parentOf(linkPath);
790
+ const now = new Date().toISOString();
791
+ await this.executor.run(
792
+ rewrite(
793
+ `INSERT INTO vfs_entries (agent_id, tenant_id, path, parent_path, type, symlink_target, size, mode, created_at, updated_at)
794
+ VALUES ($1, $2, $3, $4, 'symlink', $5, 0, 511, $6, $7)`,
795
+ this.dialect,
796
+ ),
797
+ [this.agentId, tenantId, linkPath, pp, target, now, now],
798
+ );
799
+ },
800
+
801
+ readlink: async (tenantId: string, path: string): Promise<string> => {
802
+ const row = await this.executor.get<{ symlink_target: string | null; type: string }>(
803
+ rewrite(
804
+ "SELECT symlink_target, type FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND path = $3",
805
+ this.dialect,
806
+ ),
807
+ [this.agentId, tenantId, path],
808
+ );
809
+ if (!row || row.type !== "symlink" || !row.symlink_target) {
810
+ throw new Error(`EINVAL: invalid argument, readlink '${path}'`);
811
+ }
812
+ return row.symlink_target;
813
+ },
814
+
815
+ lstat: async (tenantId: string, path: string): Promise<VfsStat | undefined> => {
816
+ // Same as stat but doesn't follow symlinks
817
+ if (path === "/") {
818
+ return { type: "directory", size: 0, mode: 0o755, createdAt: 0, updatedAt: 0 };
819
+ }
820
+ const row = await this.executor.get<{
821
+ type: string;
822
+ size: number;
823
+ mode: number;
824
+ mime_type: string | null;
825
+ symlink_target: string | null;
826
+ created_at: string;
827
+ updated_at: string;
828
+ }>(
829
+ rewrite(
830
+ "SELECT type, size, mode, mime_type, symlink_target, created_at, updated_at FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND path = $3",
831
+ this.dialect,
832
+ ),
833
+ [this.agentId, tenantId, path],
834
+ );
835
+ if (!row) return undefined;
836
+ return {
837
+ type: row.type as VfsStat["type"],
838
+ size: row.size,
839
+ mode: row.mode,
840
+ mimeType: row.mime_type ?? undefined,
841
+ symlinkTarget: row.symlink_target ?? undefined,
842
+ createdAt: new Date(row.created_at).getTime(),
843
+ updatedAt: new Date(row.updated_at).getTime(),
844
+ };
845
+ },
846
+
847
+ listAllPaths: (_tenantId: string): string[] => {
848
+ // Default: return empty. Overridden by SQLite (sync query) and PostgreSQL (cache).
849
+ return [];
850
+ },
851
+
852
+ getUsage: async (
853
+ tenantId: string,
854
+ ): Promise<{ fileCount: number; totalBytes: number }> => {
855
+ const row = await this.executor.get<{ cnt: number; total: number }>(
856
+ rewrite(
857
+ "SELECT COUNT(*) as cnt, COALESCE(SUM(size), 0) as total FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND type = 'file'",
858
+ this.dialect,
859
+ ),
860
+ [this.agentId, tenantId],
861
+ );
862
+ return { fileCount: Number(row?.cnt ?? 0), totalBytes: Number(row?.total ?? 0) };
863
+ },
864
+ };
865
+
866
+ // -----------------------------------------------------------------------
867
+ // Private helpers
868
+ // -----------------------------------------------------------------------
869
+
870
+ private parseConversation(data: unknown): Conversation {
871
+ if (typeof data === "string") return JSON.parse(data);
872
+ return data as Conversation;
873
+ }
874
+
875
+ private rowToSummary(row: QueryRow): ConversationSummary {
876
+ const data = this.parseConversation(row.data);
877
+ const tid = row.tenant_id as string;
878
+ return {
879
+ conversationId: row.id as string,
880
+ title: row.title as string,
881
+ updatedAt: new Date(row.updated_at as string).getTime(),
882
+ createdAt: row.created_at ? new Date(row.created_at as string).getTime() : undefined,
883
+ ownerId: row.owner_id as string,
884
+ tenantId: tid === DEFAULT_TENANT ? null : tid,
885
+ messageCount: row.message_count as number,
886
+ hasPendingApprovals: (data.pendingApprovals?.length ?? 0) > 0,
887
+ parentConversationId: data.parentConversationId,
888
+ channelMeta: data.channelMeta,
889
+ };
890
+ }
891
+
892
+ private rowToReminder(row: QueryRow): Reminder {
893
+ const tid = row.tenant_id as string;
894
+ return {
895
+ id: row.id as string,
896
+ task: row.task as string,
897
+ scheduledAt: row.scheduled_at as number,
898
+ timezone: (row.timezone as string) ?? undefined,
899
+ status: row.status as Reminder["status"],
900
+ createdAt: new Date(row.created_at as string).getTime(),
901
+ conversationId: row.conversation_id as string,
902
+ ownerId: (row.owner_id as string) ?? undefined,
903
+ tenantId: tid === DEFAULT_TENANT ? null : tid,
904
+ };
905
+ }
906
+
907
+ protected toUint8Array(value: unknown): Uint8Array {
908
+ if (value instanceof Uint8Array) return value;
909
+ if (value instanceof Buffer) return new Uint8Array(value);
910
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
911
+ if (typeof value === "string") return new TextEncoder().encode(value);
912
+ return new Uint8Array();
913
+ }
914
+
915
+ private async ensureParentDirs(tenantId: string, path: string): Promise<void> {
916
+ const parts = path.split("/").filter(Boolean);
917
+ // Don't create the file/dir itself, only parents
918
+ parts.pop();
919
+ let current = "";
920
+ for (const part of parts) {
921
+ current += `/${part}`;
922
+ const exists = await this.vfs.stat(tenantId, current);
923
+ if (!exists) {
924
+ await this.mkdirSingle(tenantId, current);
925
+ }
926
+ }
927
+ }
928
+
929
+ private async mkdirSingle(tenantId: string, path: string): Promise<void> {
930
+ const pp = parentOf(path);
931
+ const now = new Date().toISOString();
932
+ await this.executor.run(
933
+ rewrite(
934
+ `INSERT INTO vfs_entries (agent_id, tenant_id, path, parent_path, type, size, mode, created_at, updated_at)
935
+ VALUES ($1, $2, $3, $4, 'directory', 0, 493, $5, $6)
936
+ ${this.dialect.upsert(["agent_id", "tenant_id", "path"])}
937
+ updated_at = excluded.updated_at`,
938
+ this.dialect,
939
+ ),
940
+ [this.agentId, tenantId, path, pp, now, now],
941
+ );
942
+ }
943
+
944
+ private async resolveSymlink(
945
+ tenantId: string,
946
+ path: string,
947
+ depth = 0,
948
+ ): Promise<string> {
949
+ if (depth > 20) throw new Error(`ELOOP: too many levels of symbolic links, open '${path}'`);
950
+ const row = await this.executor.get<{ symlink_target: string | null; type: string }>(
951
+ rewrite(
952
+ "SELECT symlink_target, type FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND path = $3",
953
+ this.dialect,
954
+ ),
955
+ [this.agentId, tenantId, path],
956
+ );
957
+ if (!row || row.type !== "symlink" || !row.symlink_target) return path;
958
+ const target = row.symlink_target.startsWith("/")
959
+ ? row.symlink_target
960
+ : `${parentOf(path)}/${row.symlink_target}`;
961
+ return this.resolveSymlink(tenantId, target, depth + 1);
962
+ }
963
+ }