@poncho-ai/harness 0.37.2 → 0.39.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.
@@ -8,14 +8,19 @@
8
8
  // ---------------------------------------------------------------------------
9
9
 
10
10
  import { randomUUID } from "node:crypto";
11
+ import { createLogger } from "@poncho-ai/sdk";
11
12
  import type {
12
13
  Conversation,
14
+ ConversationCreateInit,
15
+ ConversationStatusSnapshot,
13
16
  ConversationSummary,
14
17
  PendingSubagentResult,
15
18
  } from "../state.js";
19
+
20
+ const egressLog = createLogger("egress");
16
21
  import type { MainMemory } from "../memory.js";
17
22
  import type { TodoItem } from "../todo-tools.js";
18
- import type { Reminder } from "../reminder-store.js";
23
+ import type { Reminder, ReminderCreateInput, ReminderStatus } from "../reminder-store.js";
19
24
  import type { StorageEngine, VfsDirEntry, VfsStat } from "./engine.js";
20
25
  import { type DialectTag, migrations } from "./schema.js";
21
26
 
@@ -104,10 +109,91 @@ const rewrite = (sql: string, dialect: Dialect): string => {
104
109
  // SqlStorageEngine base class
105
110
  // ---------------------------------------------------------------------------
106
111
 
112
+ // ---------------------------------------------------------------------------
113
+ // Conversation egress meter — tracks estimated bytes read from / written to
114
+ // the conversations table per method. Enable periodic logging by setting
115
+ // PONCHO_LOG_EGRESS=1 in the environment.
116
+ // ---------------------------------------------------------------------------
117
+
118
+ interface EgressBucket {
119
+ calls: number;
120
+ bytes: number;
121
+ }
122
+
123
+ class ConversationEgressMeter {
124
+ readonly read: Record<string, EgressBucket> = {};
125
+ readonly write: Record<string, EgressBucket> = {};
126
+ private lastLogAt = Date.now();
127
+ private readonly logIntervalMs: number;
128
+ private readonly enabled: boolean;
129
+
130
+ constructor(logIntervalMs = 60_000) {
131
+ this.logIntervalMs = logIntervalMs;
132
+ this.enabled = process.env.PONCHO_LOG_EGRESS === "1";
133
+ }
134
+
135
+ trackRead(method: string, bytes: number): void {
136
+ const b = (this.read[method] ??= { calls: 0, bytes: 0 });
137
+ b.calls += 1;
138
+ b.bytes += bytes;
139
+ this.maybeLog();
140
+ }
141
+
142
+ trackWrite(method: string, bytes: number): void {
143
+ const b = (this.write[method] ??= { calls: 0, bytes: 0 });
144
+ b.calls += 1;
145
+ b.bytes += bytes;
146
+ this.maybeLog();
147
+ }
148
+
149
+ private maybeLog(): void {
150
+ if (!this.enabled) return;
151
+ const now = Date.now();
152
+ if (now - this.lastLogAt < this.logIntervalMs) return;
153
+ this.lastLogAt = now;
154
+ this.flush();
155
+ }
156
+
157
+ flush(): void {
158
+ if (!this.enabled) return;
159
+ const fmt = (buckets: Record<string, EgressBucket>) =>
160
+ Object.entries(buckets)
161
+ .filter(([, b]) => b.calls > 0)
162
+ .map(([m, b]) => `${m}=${b.calls}calls/${fmtBytes(b.bytes)}`)
163
+ .join(", ");
164
+ const r = fmt(this.read);
165
+ const w = fmt(this.write);
166
+ if (r || w) {
167
+ egressLog.debug(`read: ${r || "(none)"} | write: ${w || "(none)"}`);
168
+ }
169
+ // Reset after logging.
170
+ for (const b of Object.values(this.read)) { b.calls = 0; b.bytes = 0; }
171
+ for (const b of Object.values(this.write)) { b.calls = 0; b.bytes = 0; }
172
+ }
173
+ }
174
+
175
+ const fmtBytes = (n: number): string => {
176
+ if (n < 1024) return `${n}B`;
177
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
178
+ return `${(n / (1024 * 1024)).toFixed(2)}MB`;
179
+ };
180
+
181
+ /** Estimate the byte size of a string-ish value (JSON column from the DB). */
182
+ const colBytes = (v: unknown): number => {
183
+ if (v == null) return 0;
184
+ if (typeof v === "string") return v.length;
185
+ if (Buffer.isBuffer(v)) return v.byteLength;
186
+ // JSONB columns in node-postgres come back as parsed objects — estimate
187
+ // by re-stringifying. This is only called on the measured hot paths, so
188
+ // the overhead is acceptable.
189
+ return JSON.stringify(v).length;
190
+ };
191
+
107
192
  export abstract class SqlStorageEngine implements StorageEngine {
108
193
  protected readonly dialect: Dialect;
109
194
  protected readonly agentId: string;
110
195
  protected abstract readonly executor: QueryExecutor;
196
+ protected readonly egressMeter = new ConversationEgressMeter();
111
197
 
112
198
  constructor(dialect: Dialect, agentId: string) {
113
199
  this.dialect = dialect;
@@ -125,6 +211,11 @@ export abstract class SqlStorageEngine implements StorageEngine {
125
211
 
126
212
  abstract close(): Promise<void>;
127
213
 
214
+ /** Flush egress stats before shutdown. Subclasses should call super. */
215
+ protected flushEgressStats(): void {
216
+ this.egressMeter.flush();
217
+ }
218
+
128
219
  /** Hook for subclass-specific setup (e.g. WAL mode). */
129
220
  protected async onBeforeInit(): Promise<void> {}
130
221
 
@@ -189,7 +280,7 @@ export abstract class SqlStorageEngine implements StorageEngine {
189
280
  const filterTenant = tenantId !== undefined;
190
281
  const params: unknown[] = [this.agentId];
191
282
  let sql = `SELECT id, title, updated_at, created_at, owner_id, tenant_id,
192
- message_count, parent_conversation_id,
283
+ message_count, parent_conversation_id, parent_message_id,
193
284
  has_pending_approvals, channel_meta
194
285
  FROM conversations WHERE agent_id = $1`;
195
286
  if (filterTenant) {
@@ -207,6 +298,83 @@ export abstract class SqlStorageEngine implements StorageEngine {
207
298
  },
208
299
 
209
300
  get: async (conversationId: string): Promise<Conversation | undefined> => {
301
+ // Skip tool_result_archive — unbounded blob, only needed on run-entry
302
+ // paths which must use getWithArchive() instead.
303
+ const row = await this.executor.get<{
304
+ data: unknown;
305
+ harness_messages: unknown;
306
+ continuation_messages: unknown;
307
+ }>(
308
+ rewrite("SELECT data, harness_messages, continuation_messages FROM conversations WHERE id = $1 AND agent_id = $2", this.dialect),
309
+ [conversationId, this.agentId],
310
+ );
311
+ if (!row) return undefined;
312
+ this.egressMeter.trackRead("get",
313
+ colBytes(row.data) + colBytes(row.harness_messages) + colBytes(row.continuation_messages));
314
+ const conv = this.parseConversation(row.data);
315
+ if (row.harness_messages) {
316
+ conv._harnessMessages =
317
+ typeof row.harness_messages === "string"
318
+ ? JSON.parse(row.harness_messages)
319
+ : row.harness_messages;
320
+ }
321
+ if (row.continuation_messages) {
322
+ conv._continuationMessages =
323
+ typeof row.continuation_messages === "string"
324
+ ? JSON.parse(row.continuation_messages)
325
+ : row.continuation_messages;
326
+ }
327
+ return conv;
328
+ },
329
+
330
+ getStatusSnapshot: async (
331
+ conversationId: string,
332
+ ): Promise<ConversationStatusSnapshot | undefined> => {
333
+ // Column-only read. runStatus lives inside the `data` JSON; extract it
334
+ // with a dialect-specific JSON path expression rather than pulling the
335
+ // whole blob.
336
+ const runStatusExpr = this.dialect.tag === "sqlite"
337
+ ? "json_extract(data, '$.runStatus')"
338
+ : "data->>'runStatus'";
339
+ const row = await this.executor.get<{
340
+ updated_at: string;
341
+ message_count: number;
342
+ has_pending_approvals: number | boolean;
343
+ parent_conversation_id: string | null;
344
+ owner_id: string;
345
+ tenant_id: string | null;
346
+ has_continuation_messages: number | boolean;
347
+ run_status: string | null;
348
+ }>(
349
+ rewrite(
350
+ `SELECT updated_at, message_count, has_pending_approvals,
351
+ parent_conversation_id, owner_id, tenant_id,
352
+ (continuation_messages IS NOT NULL) AS has_continuation_messages,
353
+ ${runStatusExpr} AS run_status
354
+ FROM conversations WHERE id = $1 AND agent_id = $2`,
355
+ this.dialect,
356
+ ),
357
+ [conversationId, this.agentId],
358
+ );
359
+ if (!row) return undefined;
360
+ this.egressMeter.trackRead("getStatusSnapshot", 200); // fixed-size column read
361
+ const runStatus = row.run_status === "running" || row.run_status === "idle"
362
+ ? row.run_status
363
+ : null;
364
+ return {
365
+ conversationId,
366
+ updatedAt: new Date(row.updated_at).getTime(),
367
+ messageCount: row.message_count ?? 0,
368
+ hasPendingApprovals: !!row.has_pending_approvals,
369
+ hasContinuationMessages: !!row.has_continuation_messages,
370
+ parentConversationId: row.parent_conversation_id,
371
+ ownerId: row.owner_id,
372
+ tenantId: row.tenant_id === "__default__" ? null : row.tenant_id,
373
+ runStatus,
374
+ };
375
+ },
376
+
377
+ getWithArchive: async (conversationId: string): Promise<Conversation | undefined> => {
210
378
  const row = await this.executor.get<{
211
379
  data: unknown;
212
380
  tool_result_archive: unknown;
@@ -217,8 +385,10 @@ export abstract class SqlStorageEngine implements StorageEngine {
217
385
  [conversationId, this.agentId],
218
386
  );
219
387
  if (!row) return undefined;
388
+ this.egressMeter.trackRead("getWithArchive",
389
+ colBytes(row.data) + colBytes(row.tool_result_archive) +
390
+ colBytes(row.harness_messages) + colBytes(row.continuation_messages));
220
391
  const conv = this.parseConversation(row.data);
221
- // Rehydrate heavy fields from separate columns
222
392
  if (row.tool_result_archive) {
223
393
  conv._toolResultArchive =
224
394
  typeof row.tool_result_archive === "string"
@@ -244,24 +414,41 @@ export abstract class SqlStorageEngine implements StorageEngine {
244
414
  ownerId?: string,
245
415
  title?: string,
246
416
  tenantId?: string | null,
417
+ init?: ConversationCreateInit,
247
418
  ): Promise<Conversation> => {
248
419
  const id = randomUUID();
249
420
  const now = Date.now();
250
421
  const conv: Conversation = {
251
422
  conversationId: id,
252
423
  title: normalizeTitle(title),
253
- messages: [],
424
+ messages: init?.messages ?? [],
254
425
  ownerId: ownerId ?? DEFAULT_OWNER,
255
426
  tenantId: tenantId === undefined ? null : tenantId,
256
427
  createdAt: now,
257
428
  updatedAt: now,
429
+ ...(init?.parentConversationId !== undefined
430
+ ? { parentConversationId: init.parentConversationId }
431
+ : {}),
432
+ ...(init?.parentMessageId !== undefined
433
+ ? { parentMessageId: init.parentMessageId }
434
+ : {}),
435
+ ...(init?.subagentMeta !== undefined
436
+ ? { subagentMeta: init.subagentMeta }
437
+ : {}),
438
+ ...(init?.threadMeta !== undefined
439
+ ? { threadMeta: init.threadMeta }
440
+ : {}),
441
+ ...(init?.channelMeta !== undefined
442
+ ? { channelMeta: init.channelMeta }
443
+ : {}),
258
444
  };
259
445
  const data = JSON.stringify(conv);
446
+ const channelMetaJson = conv.channelMeta ? JSON.stringify(conv.channelMeta) : null;
260
447
  await this.executor.run(
261
448
  rewrite(
262
449
  `INSERT INTO conversations (id, agent_id, tenant_id, owner_id, title, data, message_count, created_at, updated_at,
263
- parent_conversation_id, has_pending_approvals, channel_meta)
264
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
450
+ parent_conversation_id, parent_message_id, has_pending_approvals, channel_meta)
451
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
265
452
  this.dialect,
266
453
  ),
267
454
  [
@@ -271,12 +458,13 @@ export abstract class SqlStorageEngine implements StorageEngine {
271
458
  conv.ownerId,
272
459
  conv.title,
273
460
  data,
274
- 0,
461
+ conv.messages.length,
275
462
  new Date(now).toISOString(),
276
463
  new Date(now).toISOString(),
277
- null,
464
+ conv.parentConversationId ?? null,
465
+ conv.parentMessageId ?? null,
278
466
  0,
279
- null,
467
+ channelMetaJson,
280
468
  ],
281
469
  );
282
470
  return conv;
@@ -297,25 +485,35 @@ export abstract class SqlStorageEngine implements StorageEngine {
297
485
  const archiveJson = archive ? JSON.stringify(archive) : null;
298
486
  const harnessJson = harnessMessages ? JSON.stringify(harnessMessages) : null;
299
487
  const continuationJson = continuationMessages ? JSON.stringify(continuationMessages) : null;
488
+ this.egressMeter.trackWrite("update",
489
+ data.length + (archiveJson?.length ?? 0) +
490
+ (harnessJson?.length ?? 0) + (continuationJson?.length ?? 0));
300
491
  const msgCount = conversation.messages?.length ?? 0;
301
492
  const tid = normalizeTenant(conversation.tenantId);
302
493
  const now = new Date(conversation.updatedAt).toISOString();
303
494
  const created = new Date(conversation.createdAt).toISOString();
304
495
  const parentConvId = conversation.parentConversationId ?? null;
496
+ const parentMsgId = conversation.parentMessageId ?? null;
305
497
  const hasPendingApprovals = (conversation.pendingApprovals?.length ?? 0) > 0 ? 1 : 0;
306
498
  const channelMetaJson = conversation.channelMeta ? JSON.stringify(conversation.channelMeta) : null;
307
499
  await this.executor.run(
308
500
  rewrite(
309
501
  `INSERT INTO conversations (id, agent_id, tenant_id, owner_id, title, data, message_count, created_at, updated_at,
310
502
  tool_result_archive, harness_messages, continuation_messages,
311
- parent_conversation_id, has_pending_approvals, channel_meta)
312
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
503
+ parent_conversation_id, parent_message_id, has_pending_approvals, channel_meta)
504
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
313
505
  ${this.dialect.upsert(["id"])}
314
506
  data = excluded.data, title = excluded.title, message_count = excluded.message_count,
315
507
  updated_at = excluded.updated_at, tenant_id = excluded.tenant_id, owner_id = excluded.owner_id,
316
- tool_result_archive = excluded.tool_result_archive, harness_messages = excluded.harness_messages,
508
+ -- tool_result_archive is append-only from the caller's side; if the
509
+ -- in-memory conversation was loaded via the light get() variant it
510
+ -- won't have _toolResultArchive attached, so we must preserve the
511
+ -- existing column instead of clobbering it with NULL.
512
+ tool_result_archive = COALESCE(excluded.tool_result_archive, conversations.tool_result_archive),
513
+ harness_messages = excluded.harness_messages,
317
514
  continuation_messages = excluded.continuation_messages,
318
515
  parent_conversation_id = excluded.parent_conversation_id,
516
+ parent_message_id = excluded.parent_message_id,
319
517
  has_pending_approvals = excluded.has_pending_approvals,
320
518
  channel_meta = excluded.channel_meta`,
321
519
  this.dialect,
@@ -334,6 +532,7 @@ export abstract class SqlStorageEngine implements StorageEngine {
334
532
  harnessJson,
335
533
  continuationJson,
336
534
  parentConvId,
535
+ parentMsgId,
337
536
  hasPendingApprovals,
338
537
  channelMetaJson,
339
538
  ],
@@ -374,7 +573,7 @@ export abstract class SqlStorageEngine implements StorageEngine {
374
573
  // SQLite uses positional ? so we can't reuse $2, need separate params
375
574
  const params: unknown[] = [this.agentId, pattern, pattern];
376
575
  let sql = `SELECT id, title, updated_at, created_at, owner_id, tenant_id,
377
- message_count, parent_conversation_id,
576
+ message_count, parent_conversation_id, parent_message_id,
378
577
  has_pending_approvals, channel_meta
379
578
  FROM conversations
380
579
  WHERE agent_id = $1 AND (title LIKE $2 OR data LIKE $3)`;
@@ -406,6 +605,24 @@ export abstract class SqlStorageEngine implements StorageEngine {
406
605
  await this.conversations.update(conv);
407
606
  return conv;
408
607
  },
608
+
609
+ listThreads: async (
610
+ parentConversationId: string,
611
+ ): Promise<ConversationSummary[]> => {
612
+ const sql = `SELECT id, title, updated_at, created_at, owner_id, tenant_id,
613
+ message_count, parent_conversation_id, parent_message_id,
614
+ has_pending_approvals, channel_meta
615
+ FROM conversations
616
+ WHERE agent_id = $1
617
+ AND parent_conversation_id = $2
618
+ AND parent_message_id IS NOT NULL
619
+ ORDER BY updated_at DESC`;
620
+ const rows = await this.executor.all(rewrite(sql, this.dialect), [
621
+ this.agentId,
622
+ parentConversationId,
623
+ ]);
624
+ return rows.map((r) => this.rowToSummary(r));
625
+ },
409
626
  };
410
627
 
411
628
  // -----------------------------------------------------------------------
@@ -497,21 +714,15 @@ export abstract class SqlStorageEngine implements StorageEngine {
497
714
  return rows.map((r) => this.rowToReminder(r));
498
715
  },
499
716
 
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> => {
717
+ create: async (input: ReminderCreateInput): Promise<Reminder> => {
508
718
  const id = randomUUID();
509
719
  const now = Date.now();
510
720
  const tid = normalizeTenant(input.tenantId);
721
+ const recurrenceJson = input.recurrence ? JSON.stringify(input.recurrence) : null;
511
722
  await this.executor.run(
512
723
  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)`,
724
+ `INSERT INTO reminders (id, agent_id, tenant_id, owner_id, conversation_id, task, status, scheduled_at, timezone, created_at, recurrence, occurrence_count)
725
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
515
726
  this.dialect,
516
727
  ),
517
728
  [
@@ -525,6 +736,8 @@ export abstract class SqlStorageEngine implements StorageEngine {
525
736
  input.scheduledAt,
526
737
  input.timezone ?? null,
527
738
  new Date(now).toISOString(),
739
+ recurrenceJson,
740
+ 0,
528
741
  ],
529
742
  );
530
743
  return {
@@ -537,9 +750,54 @@ export abstract class SqlStorageEngine implements StorageEngine {
537
750
  conversationId: input.conversationId,
538
751
  ownerId: input.ownerId,
539
752
  tenantId: input.tenantId,
753
+ recurrence: input.recurrence ?? null,
754
+ occurrenceCount: 0,
540
755
  };
541
756
  },
542
757
 
758
+ update: async (id: string, fields: { scheduledAt?: number; occurrenceCount?: number; status?: ReminderStatus }): Promise<Reminder> => {
759
+ const setClauses: string[] = [];
760
+ const params: unknown[] = [];
761
+ let idx = 1;
762
+ if (fields.scheduledAt !== undefined) {
763
+ setClauses.push(`scheduled_at = $${idx++}`);
764
+ params.push(fields.scheduledAt);
765
+ }
766
+ if (fields.occurrenceCount !== undefined) {
767
+ setClauses.push(`occurrence_count = $${idx++}`);
768
+ params.push(fields.occurrenceCount);
769
+ }
770
+ if (fields.status !== undefined) {
771
+ setClauses.push(`status = $${idx++}`);
772
+ params.push(fields.status);
773
+ }
774
+ if (setClauses.length === 0) {
775
+ // Nothing to update — just fetch
776
+ const row = await this.executor.get(
777
+ rewrite("SELECT * FROM reminders WHERE id = $1 AND agent_id = $2", this.dialect),
778
+ [id, this.agentId],
779
+ );
780
+ if (!row) throw new Error(`Reminder ${id} not found`);
781
+ return this.rowToReminder(row);
782
+ }
783
+ params.push(id, this.agentId);
784
+ const idIdx = idx++;
785
+ const agentIdx = idx++;
786
+ await this.executor.run(
787
+ rewrite(
788
+ `UPDATE reminders SET ${setClauses.join(", ")} WHERE id = $${idIdx} AND agent_id = $${agentIdx}`,
789
+ this.dialect,
790
+ ),
791
+ params,
792
+ );
793
+ const row = await this.executor.get(
794
+ rewrite("SELECT * FROM reminders WHERE id = $1 AND agent_id = $2", this.dialect),
795
+ [id, this.agentId],
796
+ );
797
+ if (!row) throw new Error(`Reminder ${id} not found`);
798
+ return this.rowToReminder(row);
799
+ },
800
+
543
801
  cancel: async (id: string): Promise<Reminder> => {
544
802
  await this.executor.run(
545
803
  rewrite(
@@ -910,6 +1168,7 @@ export abstract class SqlStorageEngine implements StorageEngine {
910
1168
  messageCount: row.message_count as number,
911
1169
  hasPendingApprovals: !!(row.has_pending_approvals),
912
1170
  parentConversationId: (row.parent_conversation_id as string) || undefined,
1171
+ parentMessageId: (row.parent_message_id as string) || undefined,
913
1172
  channelMeta: rawChannelMeta
914
1173
  ? (typeof rawChannelMeta === "string" ? JSON.parse(rawChannelMeta) : rawChannelMeta)
915
1174
  : undefined,
@@ -918,6 +1177,16 @@ export abstract class SqlStorageEngine implements StorageEngine {
918
1177
 
919
1178
  private rowToReminder(row: QueryRow): Reminder {
920
1179
  const tid = row.tenant_id as string;
1180
+ let recurrence: Reminder["recurrence"] = null;
1181
+ if (row.recurrence) {
1182
+ try {
1183
+ recurrence = typeof row.recurrence === "string"
1184
+ ? JSON.parse(row.recurrence)
1185
+ : row.recurrence;
1186
+ } catch {
1187
+ recurrence = null;
1188
+ }
1189
+ }
921
1190
  return {
922
1191
  id: row.id as string,
923
1192
  task: row.task as string,
@@ -928,6 +1197,8 @@ export abstract class SqlStorageEngine implements StorageEngine {
928
1197
  conversationId: row.conversation_id as string,
929
1198
  ownerId: (row.owner_id as string) ?? undefined,
930
1199
  tenantId: tid === DEFAULT_TENANT ? null : tid,
1200
+ recurrence,
1201
+ occurrenceCount: (row.occurrence_count as number) ?? 0,
931
1202
  };
932
1203
  }
933
1204
 
@@ -94,6 +94,7 @@ export class SqliteEngine extends SqlStorageEngine {
94
94
  }
95
95
 
96
96
  async close(): Promise<void> {
97
+ this.flushEgressStats();
97
98
  this.db?.close();
98
99
  }
99
100
  }
@@ -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,16 @@ 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
+ getWithArchive: (conversationId: string) =>
40
+ engine.conversations.getWithArchive(conversationId),
41
+ getStatusSnapshot: (conversationId: string) =>
42
+ engine.conversations.getStatusSnapshot(conversationId),
43
+ create: (
44
+ ownerId?: string,
45
+ title?: string,
46
+ tenantId?: string | null,
47
+ init?: ConversationCreateInit,
48
+ ) => engine.conversations.create(ownerId, title, tenantId, init),
40
49
  update: (conversation: Conversation) =>
41
50
  engine.conversations.update(conversation),
42
51
  rename: (conversationId: string, title: string) =>
@@ -47,6 +56,8 @@ export function createConversationStoreFromEngine(
47
56
  engine.conversations.appendSubagentResult(conversationId, result),
48
57
  clearCallbackLock: (conversationId: string) =>
49
58
  engine.conversations.clearCallbackLock(conversationId),
59
+ listThreads: (parentConversationId: string) =>
60
+ engine.conversations.listThreads(parentConversationId),
50
61
  };
51
62
  }
52
63
 
@@ -86,14 +97,9 @@ export function createReminderStoreFromEngine(
86
97
  ): ReminderStore {
87
98
  return {
88
99
  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),
100
+ create: (input: ReminderCreateInput) => engine.reminders.create(input),
101
+ update: (id: string, fields: { scheduledAt?: number; occurrenceCount?: number; status?: ReminderStatus }) =>
102
+ engine.reminders.update(id, fields),
97
103
  cancel: (id: string) => engine.reminders.cancel(id),
98
104
  delete: (id: string) => engine.reminders.delete(id),
99
105
  };
package/src/telemetry.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  import type { AgentEvent } from "@poncho-ai/sdk";
2
+ import { createLogger } from "@poncho-ai/sdk";
3
+
4
+ const eventLog = createLogger("event");
5
+ const telemetryLog = createLogger("telemetry");
2
6
 
3
7
  const MAX_FIELD_LENGTH = 200;
4
8
  const OMIT_FROM_LOG = new Set(["continuationMessages", "_harnessMessages", "messages", "compactedHistory"]);
@@ -70,12 +74,13 @@ export class TelemetryEmitter {
70
74
  await this.sendOtlp(event, otlp);
71
75
  }
72
76
  // Default behavior in local dev: print concise structured logs.
73
- // Skip per-token stream logs to keep console output readable.
74
- if (event.type === "model:chunk") {
77
+ // Skip per-token stream logs and browser screencast frames — both are
78
+ // high-volume and would drown the log.
79
+ if (event.type === "model:chunk" || event.type === "browser:frame") {
75
80
  return;
76
81
  }
77
82
  // Strip large binary payloads (e.g. base64 images) to keep logs readable.
78
- process.stdout.write(`[event] ${event.type} ${sanitizeEventForLog(event)}\n`);
83
+ eventLog.debug(`${event.type} ${sanitizeEventForLog(event)}`);
79
84
  }
80
85
 
81
86
  private async sendOtlp(event: AgentEvent, otlp: OtlpConfig): Promise<void> {
@@ -108,10 +113,8 @@ export class TelemetryEmitter {
108
113
  }),
109
114
  });
110
115
  } catch (err) {
111
- console.warn(
112
- `[poncho][telemetry] OTLP log delivery failed: ${
113
- err instanceof Error ? err.message : String(err)
114
- }`,
116
+ telemetryLog.warn(
117
+ `OTLP log delivery failed: ${err instanceof Error ? err.message : String(err)}`,
115
118
  );
116
119
  }
117
120
  }
@@ -3,8 +3,11 @@ import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
3
3
  import { createRequire } from "node:module";
4
4
  import { homedir } from "node:os";
5
5
  import { resolve } from "node:path";
6
+ import { createLogger } from "@poncho-ai/sdk";
6
7
  import type { UploadsConfig } from "./config.js";
7
8
 
9
+ const uploadLog = createLogger("upload");
10
+
8
11
  /**
9
12
  * Try to dynamically import a module, first from the harness's own
10
13
  * node_modules, then from the user's project directory. This handles
@@ -62,7 +65,7 @@ class CachedUploadStore implements UploadStore {
62
65
 
63
66
  // Fire off the real upload in the background — don't block the caller.
64
67
  this.inner.put(key, data, mediaType).catch((err) => {
65
- console.error("[poncho] background upload failed:", err instanceof Error ? err.message : err);
68
+ uploadLog.error(`background upload failed: ${err instanceof Error ? err.message : err}`);
66
69
  });
67
70
 
68
71
  return ref;
@@ -343,7 +346,7 @@ export class S3UploadStore implements UploadStore {
343
346
  // ---------------------------------------------------------------------------
344
347
 
345
348
  const warn = (msg: string) => {
346
- console.warn(`[poncho] ⚠ ${msg}`);
349
+ uploadLog.warn(msg);
347
350
  };
348
351
 
349
352
  export const createUploadStore = async (
@@ -363,7 +366,7 @@ export const createUploadStore = async (
363
366
  const store = new VercelBlobUploadStore(workingDir, config?.access ?? "public");
364
367
  try {
365
368
  await store.loadSdk();
366
- console.log("[poncho] uploads: using vercel-blob store");
369
+ uploadLog.item("using vercel-blob store");
367
370
  return new CachedUploadStore(store);
368
371
  } catch {
369
372
  warn(
@@ -393,7 +396,7 @@ export const createUploadStore = async (
393
396
  );
394
397
  try {
395
398
  await store.ensureClient();
396
- console.log(`[poncho] uploads: using s3 store (bucket: ${bucket})`);
399
+ uploadLog.item(`using s3 store (bucket: ${bucket})`);
397
400
  return new CachedUploadStore(store);
398
401
  } catch {
399
402
  warn(