@poncho-ai/harness 0.37.1 → 0.38.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.
@@ -5,12 +5,13 @@
5
5
  import { randomUUID } from "node:crypto";
6
6
  import type {
7
7
  Conversation,
8
+ ConversationCreateInit,
8
9
  ConversationSummary,
9
10
  PendingSubagentResult,
10
11
  } from "../state.js";
11
12
  import type { MainMemory } from "../memory.js";
12
13
  import type { TodoItem } from "../todo-tools.js";
13
- import type { Reminder } from "../reminder-store.js";
14
+ import type { Reminder, ReminderCreateInput, ReminderStatus } from "../reminder-store.js";
14
15
  import type { StorageEngine, VfsDirEntry, VfsStat } from "./engine.js";
15
16
 
16
17
  // ---------------------------------------------------------------------------
@@ -105,16 +106,26 @@ export class InMemoryEngine implements StorageEngine {
105
106
  ownerId?: string,
106
107
  title?: string,
107
108
  tenantId?: string | null,
109
+ init?: ConversationCreateInit,
108
110
  ): Promise<Conversation> => {
109
111
  const now = Date.now();
110
112
  const conv: Conversation = {
111
113
  conversationId: randomUUID(),
112
114
  title: normalizeTitle(title),
113
- messages: [],
115
+ messages: init?.messages ?? [],
114
116
  ownerId: ownerId ?? DEFAULT_OWNER,
115
117
  tenantId: tenantId === undefined ? null : tenantId,
116
118
  createdAt: now,
117
119
  updatedAt: now,
120
+ ...(init?.parentConversationId !== undefined
121
+ ? { parentConversationId: init.parentConversationId }
122
+ : {}),
123
+ ...(init?.subagentMeta !== undefined
124
+ ? { subagentMeta: init.subagentMeta }
125
+ : {}),
126
+ ...(init?.channelMeta !== undefined
127
+ ? { channelMeta: init.channelMeta }
128
+ : {}),
118
129
  };
119
130
  this.convs.set(conv.conversationId, conv);
120
131
  return conv;
@@ -237,14 +248,7 @@ export class InMemoryEngine implements StorageEngine {
237
248
  return results;
238
249
  },
239
250
 
240
- create: async (input: {
241
- task: string;
242
- scheduledAt: number;
243
- timezone?: string;
244
- conversationId: string;
245
- ownerId?: string;
246
- tenantId?: string | null;
247
- }): Promise<Reminder> => {
251
+ create: async (input: ReminderCreateInput): Promise<Reminder> => {
248
252
  const r: Reminder = {
249
253
  id: randomUUID(),
250
254
  task: input.task,
@@ -255,11 +259,22 @@ export class InMemoryEngine implements StorageEngine {
255
259
  conversationId: input.conversationId,
256
260
  ownerId: input.ownerId,
257
261
  tenantId: input.tenantId,
262
+ recurrence: input.recurrence ?? null,
263
+ occurrenceCount: 0,
258
264
  };
259
265
  this.reminderData.set(r.id, r);
260
266
  return r;
261
267
  },
262
268
 
269
+ update: async (id: string, fields: { scheduledAt?: number; occurrenceCount?: number; status?: ReminderStatus }): Promise<Reminder> => {
270
+ const r = this.reminderData.get(id);
271
+ if (!r) throw new Error(`Reminder ${id} not found`);
272
+ if (fields.scheduledAt !== undefined) r.scheduledAt = fields.scheduledAt;
273
+ if (fields.occurrenceCount !== undefined) r.occurrenceCount = fields.occurrenceCount;
274
+ if (fields.status !== undefined) r.status = fields.status;
275
+ return r;
276
+ },
277
+
263
278
  cancel: async (id: string): Promise<Reminder> => {
264
279
  const r = this.reminderData.get(id);
265
280
  if (!r) throw new Error(`Reminder ${id} not found`);
@@ -174,4 +174,15 @@ export const migrations: Migration[] = [
174
174
  ];
175
175
  },
176
176
  },
177
+ {
178
+ version: 5,
179
+ name: "add_reminder_recurrence",
180
+ up: (d) => {
181
+ const jsonType = d === "sqlite" ? "TEXT" : "JSONB";
182
+ return [
183
+ `ALTER TABLE reminders ADD COLUMN recurrence ${jsonType}`,
184
+ `ALTER TABLE reminders ADD COLUMN occurrence_count INTEGER NOT NULL DEFAULT 0`,
185
+ ];
186
+ },
187
+ },
177
188
  ];
@@ -10,12 +10,13 @@
10
10
  import { randomUUID } from "node:crypto";
11
11
  import type {
12
12
  Conversation,
13
+ ConversationCreateInit,
13
14
  ConversationSummary,
14
15
  PendingSubagentResult,
15
16
  } from "../state.js";
16
17
  import type { MainMemory } from "../memory.js";
17
18
  import type { TodoItem } from "../todo-tools.js";
18
- import type { Reminder } from "../reminder-store.js";
19
+ import type { Reminder, ReminderCreateInput, ReminderStatus } from "../reminder-store.js";
19
20
  import type { StorageEngine, VfsDirEntry, VfsStat } from "./engine.js";
20
21
  import { type DialectTag, migrations } from "./schema.js";
21
22
 
@@ -244,19 +245,30 @@ export abstract class SqlStorageEngine implements StorageEngine {
244
245
  ownerId?: string,
245
246
  title?: string,
246
247
  tenantId?: string | null,
248
+ init?: ConversationCreateInit,
247
249
  ): Promise<Conversation> => {
248
250
  const id = randomUUID();
249
251
  const now = Date.now();
250
252
  const conv: Conversation = {
251
253
  conversationId: id,
252
254
  title: normalizeTitle(title),
253
- messages: [],
255
+ messages: init?.messages ?? [],
254
256
  ownerId: ownerId ?? DEFAULT_OWNER,
255
257
  tenantId: tenantId === undefined ? null : tenantId,
256
258
  createdAt: now,
257
259
  updatedAt: now,
260
+ ...(init?.parentConversationId !== undefined
261
+ ? { parentConversationId: init.parentConversationId }
262
+ : {}),
263
+ ...(init?.subagentMeta !== undefined
264
+ ? { subagentMeta: init.subagentMeta }
265
+ : {}),
266
+ ...(init?.channelMeta !== undefined
267
+ ? { channelMeta: init.channelMeta }
268
+ : {}),
258
269
  };
259
270
  const data = JSON.stringify(conv);
271
+ const channelMetaJson = conv.channelMeta ? JSON.stringify(conv.channelMeta) : null;
260
272
  await this.executor.run(
261
273
  rewrite(
262
274
  `INSERT INTO conversations (id, agent_id, tenant_id, owner_id, title, data, message_count, created_at, updated_at,
@@ -271,12 +283,12 @@ export abstract class SqlStorageEngine implements StorageEngine {
271
283
  conv.ownerId,
272
284
  conv.title,
273
285
  data,
274
- 0,
286
+ conv.messages.length,
275
287
  new Date(now).toISOString(),
276
288
  new Date(now).toISOString(),
277
- null,
289
+ conv.parentConversationId ?? null,
278
290
  0,
279
- null,
291
+ channelMetaJson,
280
292
  ],
281
293
  );
282
294
  return conv;
@@ -497,21 +509,15 @@ export abstract class SqlStorageEngine implements StorageEngine {
497
509
  return rows.map((r) => this.rowToReminder(r));
498
510
  },
499
511
 
500
- create: async (input: {
501
- task: string;
502
- scheduledAt: number;
503
- timezone?: string;
504
- conversationId: string;
505
- ownerId?: string;
506
- tenantId?: string | null;
507
- }): Promise<Reminder> => {
512
+ create: async (input: ReminderCreateInput): Promise<Reminder> => {
508
513
  const id = randomUUID();
509
514
  const now = Date.now();
510
515
  const tid = normalizeTenant(input.tenantId);
516
+ const recurrenceJson = input.recurrence ? JSON.stringify(input.recurrence) : null;
511
517
  await this.executor.run(
512
518
  rewrite(
513
- `INSERT INTO reminders (id, agent_id, tenant_id, owner_id, conversation_id, task, status, scheduled_at, timezone, created_at)
514
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
519
+ `INSERT INTO reminders (id, agent_id, tenant_id, owner_id, conversation_id, task, status, scheduled_at, timezone, created_at, recurrence, occurrence_count)
520
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
515
521
  this.dialect,
516
522
  ),
517
523
  [
@@ -525,6 +531,8 @@ export abstract class SqlStorageEngine implements StorageEngine {
525
531
  input.scheduledAt,
526
532
  input.timezone ?? null,
527
533
  new Date(now).toISOString(),
534
+ recurrenceJson,
535
+ 0,
528
536
  ],
529
537
  );
530
538
  return {
@@ -537,9 +545,54 @@ export abstract class SqlStorageEngine implements StorageEngine {
537
545
  conversationId: input.conversationId,
538
546
  ownerId: input.ownerId,
539
547
  tenantId: input.tenantId,
548
+ recurrence: input.recurrence ?? null,
549
+ occurrenceCount: 0,
540
550
  };
541
551
  },
542
552
 
553
+ update: async (id: string, fields: { scheduledAt?: number; occurrenceCount?: number; status?: ReminderStatus }): Promise<Reminder> => {
554
+ const setClauses: string[] = [];
555
+ const params: unknown[] = [];
556
+ let idx = 1;
557
+ if (fields.scheduledAt !== undefined) {
558
+ setClauses.push(`scheduled_at = $${idx++}`);
559
+ params.push(fields.scheduledAt);
560
+ }
561
+ if (fields.occurrenceCount !== undefined) {
562
+ setClauses.push(`occurrence_count = $${idx++}`);
563
+ params.push(fields.occurrenceCount);
564
+ }
565
+ if (fields.status !== undefined) {
566
+ setClauses.push(`status = $${idx++}`);
567
+ params.push(fields.status);
568
+ }
569
+ if (setClauses.length === 0) {
570
+ // Nothing to update — just fetch
571
+ const row = await this.executor.get(
572
+ rewrite("SELECT * FROM reminders WHERE id = $1 AND agent_id = $2", this.dialect),
573
+ [id, this.agentId],
574
+ );
575
+ if (!row) throw new Error(`Reminder ${id} not found`);
576
+ return this.rowToReminder(row);
577
+ }
578
+ params.push(id, this.agentId);
579
+ const idIdx = idx++;
580
+ const agentIdx = idx++;
581
+ await this.executor.run(
582
+ rewrite(
583
+ `UPDATE reminders SET ${setClauses.join(", ")} WHERE id = $${idIdx} AND agent_id = $${agentIdx}`,
584
+ this.dialect,
585
+ ),
586
+ params,
587
+ );
588
+ const row = await this.executor.get(
589
+ rewrite("SELECT * FROM reminders WHERE id = $1 AND agent_id = $2", this.dialect),
590
+ [id, this.agentId],
591
+ );
592
+ if (!row) throw new Error(`Reminder ${id} not found`);
593
+ return this.rowToReminder(row);
594
+ },
595
+
543
596
  cancel: async (id: string): Promise<Reminder> => {
544
597
  await this.executor.run(
545
598
  rewrite(
@@ -918,6 +971,16 @@ export abstract class SqlStorageEngine implements StorageEngine {
918
971
 
919
972
  private rowToReminder(row: QueryRow): Reminder {
920
973
  const tid = row.tenant_id as string;
974
+ let recurrence: Reminder["recurrence"] = null;
975
+ if (row.recurrence) {
976
+ try {
977
+ recurrence = typeof row.recurrence === "string"
978
+ ? JSON.parse(row.recurrence)
979
+ : row.recurrence;
980
+ } catch {
981
+ recurrence = null;
982
+ }
983
+ }
921
984
  return {
922
985
  id: row.id as string,
923
986
  task: row.task as string,
@@ -928,6 +991,8 @@ export abstract class SqlStorageEngine implements StorageEngine {
928
991
  conversationId: row.conversation_id as string,
929
992
  ownerId: (row.owner_id as string) ?? undefined,
930
993
  tenantId: tid === DEFAULT_TENANT ? null : tid,
994
+ recurrence,
995
+ occurrenceCount: (row.occurrence_count as number) ?? 0,
931
996
  };
932
997
  }
933
998
 
@@ -6,13 +6,14 @@
6
6
 
7
7
  import type {
8
8
  Conversation,
9
+ ConversationCreateInit,
9
10
  ConversationStore,
10
11
  ConversationSummary,
11
12
  PendingSubagentResult,
12
13
  } from "../state.js";
13
14
  import type { MainMemory, MemoryStore } from "../memory.js";
14
15
  import type { TodoItem, TodoStore } from "../todo-tools.js";
15
- import type { Reminder, ReminderStore } from "../reminder-store.js";
16
+ import type { Reminder, ReminderCreateInput, ReminderStatus, ReminderStore } from "../reminder-store.js";
16
17
  import type { StorageEngine } from "./engine.js";
17
18
 
18
19
  // ---------------------------------------------------------------------------
@@ -35,8 +36,12 @@ export function createConversationStoreFromEngine(
35
36
  engine.conversations.list(ownerId, tenantId),
36
37
  get: (conversationId: string) =>
37
38
  engine.conversations.get(conversationId),
38
- create: (ownerId?: string, title?: string, tenantId?: string | null) =>
39
- engine.conversations.create(ownerId, title, tenantId),
39
+ create: (
40
+ ownerId?: string,
41
+ title?: string,
42
+ tenantId?: string | null,
43
+ init?: ConversationCreateInit,
44
+ ) => engine.conversations.create(ownerId, title, tenantId, init),
40
45
  update: (conversation: Conversation) =>
41
46
  engine.conversations.update(conversation),
42
47
  rename: (conversationId: string, title: string) =>
@@ -86,14 +91,9 @@ export function createReminderStoreFromEngine(
86
91
  ): ReminderStore {
87
92
  return {
88
93
  list: () => engine.reminders.list(),
89
- create: (input: {
90
- task: string;
91
- scheduledAt: number;
92
- timezone?: string;
93
- conversationId: string;
94
- ownerId?: string;
95
- tenantId?: string | null;
96
- }) => engine.reminders.create(input),
94
+ create: (input: ReminderCreateInput) => engine.reminders.create(input),
95
+ update: (id: string, fields: { scheduledAt?: number; occurrenceCount?: number; status?: ReminderStatus }) =>
96
+ engine.reminders.update(id, fields),
97
97
  cancel: (id: string) => engine.reminders.cancel(id),
98
98
  delete: (id: string) => engine.reminders.delete(id),
99
99
  };
@@ -6,6 +6,7 @@ import { Bash } from "just-bash";
6
6
  import type {
7
7
  BashOptions,
8
8
  CommandName,
9
+ IFileSystem,
9
10
  NetworkConfig as JustBashNetworkConfig,
10
11
  } from "just-bash";
11
12
  import type { StorageEngine } from "../storage/engine.js";
@@ -65,6 +66,7 @@ function toBashOptions(
65
66
 
66
67
  export class BashEnvironmentManager {
67
68
  private environments = new Map<string, Bash>();
69
+ private filesystems = new Map<string, IFileSystem>();
68
70
  private readonly workingDir: string | null;
69
71
  private readonly bashOptions: Partial<BashOptions>;
70
72
 
@@ -79,11 +81,21 @@ export class BashEnvironmentManager {
79
81
  this.bashOptions = toBashOptions(bashConfig, network);
80
82
  }
81
83
 
84
+ /** Return the combined IFileSystem (VFS + optional /project mount) for a tenant. */
85
+ getFs(tenantId: string): IFileSystem {
86
+ let fs = this.filesystems.get(tenantId);
87
+ if (!fs) {
88
+ const adapter = new PonchoFsAdapter(this.engine, tenantId, this.limits);
89
+ fs = createBashFs(adapter, this.workingDir);
90
+ this.filesystems.set(tenantId, fs);
91
+ }
92
+ return fs;
93
+ }
94
+
82
95
  getOrCreate(tenantId: string): Bash {
83
96
  let bash = this.environments.get(tenantId);
84
97
  if (!bash) {
85
- const adapter = new PonchoFsAdapter(this.engine, tenantId, this.limits);
86
- const fs = createBashFs(adapter, this.workingDir);
98
+ const fs = this.getFs(tenantId);
87
99
  bash = new Bash({
88
100
  fs,
89
101
  cwd: "/",
@@ -112,9 +124,11 @@ export class BashEnvironmentManager {
112
124
 
113
125
  destroy(tenantId: string): void {
114
126
  this.environments.delete(tenantId);
127
+ this.filesystems.delete(tenantId);
115
128
  }
116
129
 
117
130
  destroyAll(): void {
118
131
  this.environments.clear();
132
+ this.filesystems.clear();
119
133
  }
120
134
  }
@@ -3,10 +3,10 @@
3
3
  // ---------------------------------------------------------------------------
4
4
 
5
5
  import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
6
- import type { StorageEngine } from "../storage/engine.js";
6
+ import type { IFileSystem } from "just-bash";
7
7
 
8
8
  export const createEditFileTool = (
9
- engine: StorageEngine,
9
+ getFs: (tenantId: string) => IFileSystem,
10
10
  ): ToolDefinition => defineTool({
11
11
  name: "edit_file",
12
12
  description:
@@ -44,12 +44,13 @@ export const createEditFileTool = (
44
44
  if (!oldStr) throw new Error("old_str must not be empty");
45
45
 
46
46
  const tenantId = context.tenantId ?? "__default__";
47
- const stat = await engine.vfs.stat(tenantId, filePath);
48
- if (!stat) throw new Error(`File not found: ${filePath}`);
49
- if (stat.type === "directory") throw new Error(`${filePath} is a directory`);
47
+ const fs = getFs(tenantId);
50
48
 
51
- const buf = await engine.vfs.readFile(tenantId, filePath);
52
- const content = Buffer.from(buf).toString("utf8");
49
+ if (!(await fs.exists(filePath))) throw new Error(`File not found: ${filePath}`);
50
+ const stat = await fs.stat(filePath);
51
+ if (stat.isDirectory) throw new Error(`${filePath} is a directory`);
52
+
53
+ const content = await fs.readFile(filePath);
53
54
 
54
55
  const first = content.indexOf(oldStr);
55
56
  if (first === -1) {
@@ -65,7 +66,7 @@ export const createEditFileTool = (
65
66
  }
66
67
 
67
68
  const updated = content.slice(0, first) + newStr + content.slice(first + oldStr.length);
68
- await engine.vfs.writeFile(tenantId, filePath, new TextEncoder().encode(updated));
69
+ await fs.writeFile(filePath, updated);
69
70
 
70
71
  return { ok: true, path: filePath };
71
72
  },
@@ -1,12 +1,10 @@
1
1
  // ---------------------------------------------------------------------------
2
- // read_file tool – read files from the VFS, returning binary files (images,
3
- // PDFs) as FileContentPart references that the harness resolves lazily at
4
- // model-request time via the vfs:// scheme.
2
+ // read_file tool – read files from the filesystem, returning binary files
3
+ // (images, PDFs) as inline base64 media parts.
5
4
  // ---------------------------------------------------------------------------
6
5
 
7
6
  import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
8
- import type { StorageEngine } from "../storage/engine.js";
9
- import { VFS_SCHEME } from "../upload-store.js";
7
+ import type { IFileSystem } from "just-bash";
10
8
 
11
9
  const MIME_MAP: Record<string, string> = {
12
10
  ".txt": "text/plain",
@@ -48,7 +46,7 @@ const isTextMime = (mime: string): boolean =>
48
46
  mime === "application/x-sh";
49
47
 
50
48
  export const createReadFileTool = (
51
- engine: StorageEngine,
49
+ getFs: (tenantId: string) => IFileSystem,
52
50
  ): ToolDefinition => defineTool({
53
51
  name: "read_file",
54
52
  description:
@@ -73,29 +71,30 @@ export const createReadFileTool = (
73
71
  }
74
72
 
75
73
  const tenantId = context.tenantId ?? "__default__";
76
- const stat = await engine.vfs.stat(tenantId, filePath);
77
- if (!stat) {
74
+ const fs = getFs(tenantId);
75
+
76
+ if (!(await fs.exists(filePath))) {
78
77
  throw new Error(`File not found: ${filePath}`);
79
78
  }
80
- if (stat.type === "directory") {
79
+ const stat = await fs.stat(filePath);
80
+ if (stat.isDirectory) {
81
81
  throw new Error(`${filePath} is a directory, not a file`);
82
82
  }
83
83
 
84
- const mediaType = stat.mimeType ?? mimeFromPath(filePath) ?? "application/octet-stream";
84
+ const mediaType = mimeFromPath(filePath) ?? "application/octet-stream";
85
85
  const filename = filePath.split("/").pop() ?? filePath;
86
86
 
87
87
  // Text files: read and return inline
88
88
  if (isTextMime(mediaType)) {
89
- const buf = await engine.vfs.readFile(tenantId, filePath);
90
- const text = Buffer.from(buf).toString("utf8");
89
+ const text = await fs.readFile(filePath);
91
90
  return { filename, mediaType, content: text };
92
91
  }
93
92
 
94
- // Images and PDFs: return a vfs:// reference that the harness resolves
95
- // lazily at model-request time — the actual bytes never sit in context.
93
+ // Binary files (images, PDFs): read bytes and return as base64
94
+ const buf = await fs.readFileBuffer(filePath);
96
95
  return {
97
96
  type: "file",
98
- data: `${VFS_SCHEME}${filePath}`,
97
+ data: Buffer.from(buf).toString("base64"),
99
98
  mediaType,
100
99
  filename,
101
100
  };
@@ -1,12 +1,12 @@
1
1
  // ---------------------------------------------------------------------------
2
- // write_file tool – create or overwrite a file in the VFS.
2
+ // write_file tool – create or overwrite a file in the filesystem.
3
3
  // ---------------------------------------------------------------------------
4
4
 
5
5
  import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
6
- import type { StorageEngine } from "../storage/engine.js";
6
+ import type { IFileSystem } from "just-bash";
7
7
 
8
8
  export const createWriteFileTool = (
9
- engine: StorageEngine,
9
+ getFs: (tenantId: string) => IFileSystem,
10
10
  ): ToolDefinition => defineTool({
11
11
  name: "write_file",
12
12
  description:
@@ -35,14 +35,15 @@ export const createWriteFileTool = (
35
35
  if (!filePath) throw new Error("path is required");
36
36
 
37
37
  const tenantId = context.tenantId ?? "__default__";
38
+ const fs = getFs(tenantId);
38
39
 
39
40
  // Create parent directories
40
41
  const dir = filePath.slice(0, filePath.lastIndexOf("/"));
41
42
  if (dir) {
42
- await engine.vfs.mkdir(tenantId, dir, true);
43
+ await fs.mkdir(dir, { recursive: true });
43
44
  }
44
45
 
45
- await engine.vfs.writeFile(tenantId, filePath, new TextEncoder().encode(content));
46
+ await fs.writeFile(filePath, content);
46
47
 
47
48
  return { ok: true, path: filePath };
48
49
  },
@@ -617,7 +617,7 @@ description: Safe skill
617
617
  script: "../outside.ts",
618
618
  }, stubContext);
619
619
  expect(result).toMatchObject({
620
- error: expect.stringContaining("must be relative and within the allowed directory"),
620
+ error: expect.stringContaining("Expected a relative path"),
621
621
  });
622
622
  });
623
623