@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.
@@ -1,16 +1,18 @@
1
1
  import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
2
- import type { ReminderStore, ReminderStatus } from "./reminder-store.js";
2
+ import type { ReminderStore, ReminderStatus, Recurrence, RecurrenceType } from "./reminder-store.js";
3
3
 
4
4
  const VALID_STATUSES: ReminderStatus[] = ["pending", "cancelled"];
5
+ const VALID_RECURRENCE_TYPES: RecurrenceType[] = ["daily", "weekly", "monthly", "cron"];
5
6
 
6
7
  export const createReminderTools = (store: ReminderStore): ToolDefinition[] => [
7
8
  defineTool({
8
9
  name: "set_reminder",
9
10
  description:
10
- "Set a one-time reminder that will fire at the specified date and time. " +
11
+ "Set a reminder that will fire at the specified date and time. " +
11
12
  "Use this when the user asks to be reminded about something. " +
12
13
  "The datetime must be an ISO 8601 string in the future. " +
13
- "When the reminder fires, the task message will be delivered to the user.",
14
+ "When the reminder fires, the task message will be delivered to the user. " +
15
+ "Supports optional recurrence for recurring reminders (daily, weekly, monthly, or cron).",
14
16
  inputSchema: {
15
17
  type: "object",
16
18
  properties: {
@@ -21,13 +23,54 @@ export const createReminderTools = (store: ReminderStore): ToolDefinition[] => [
21
23
  datetime: {
22
24
  type: "string",
23
25
  description:
24
- "ISO 8601 datetime for when the reminder should fire (e.g. '2026-03-23T09:00:00Z')",
26
+ "ISO 8601 datetime for when the reminder should first fire (e.g. '2026-03-23T09:00:00Z')",
25
27
  },
26
28
  timezone: {
27
29
  type: "string",
28
30
  description:
29
31
  "IANA timezone for interpreting the datetime if it lacks an offset (e.g. 'America/New_York'). Defaults to UTC.",
30
32
  },
33
+ recurrence: {
34
+ type: "object",
35
+ description:
36
+ "Optional. Set this to make the reminder repeat. Omit for a one-time reminder.",
37
+ properties: {
38
+ type: {
39
+ type: "string",
40
+ enum: VALID_RECURRENCE_TYPES,
41
+ description:
42
+ "How often to repeat: 'daily', 'weekly', 'monthly', or 'cron'.",
43
+ },
44
+ interval: {
45
+ type: "number",
46
+ description:
47
+ "Repeat every N units (e.g. 2 = every 2 days/weeks/months). Defaults to 1.",
48
+ },
49
+ daysOfWeek: {
50
+ type: "array",
51
+ items: { type: "number" },
52
+ description:
53
+ "For weekly: which days to fire (0=Sunday, 1=Monday, ..., 6=Saturday).",
54
+ },
55
+ expression: {
56
+ type: "string",
57
+ description:
58
+ "For type 'cron': a 5-field cron expression (e.g. '0 9 * * 1-5' for weekdays at 9am).",
59
+ },
60
+ endsAt: {
61
+ type: "string",
62
+ description:
63
+ "ISO 8601 datetime after which the recurrence should stop.",
64
+ },
65
+ maxOccurrences: {
66
+ type: "number",
67
+ description:
68
+ "Maximum number of times the reminder should fire before stopping.",
69
+ },
70
+ },
71
+ required: ["type"],
72
+ additionalProperties: false,
73
+ },
31
74
  },
32
75
  required: ["task", "datetime"],
33
76
  additionalProperties: false,
@@ -78,6 +121,50 @@ export const createReminderTools = (store: ReminderStore): ToolDefinition[] => [
78
121
  throw new Error("Reminder datetime must be in the future");
79
122
  }
80
123
 
124
+ // Parse recurrence if provided
125
+ let recurrence: Recurrence | null = null;
126
+ if (input.recurrence && typeof input.recurrence === "object") {
127
+ const rec = input.recurrence as Record<string, unknown>;
128
+ const recType = rec.type as string;
129
+ if (!VALID_RECURRENCE_TYPES.includes(recType as RecurrenceType)) {
130
+ throw new Error(`Invalid recurrence type: "${recType}". Must be one of: ${VALID_RECURRENCE_TYPES.join(", ")}`);
131
+ }
132
+ recurrence = { type: recType as RecurrenceType };
133
+ if (rec.interval !== undefined) {
134
+ const interval = Number(rec.interval);
135
+ if (!Number.isInteger(interval) || interval < 1) {
136
+ throw new Error("recurrence.interval must be a positive integer");
137
+ }
138
+ recurrence.interval = interval;
139
+ }
140
+ if (rec.daysOfWeek !== undefined) {
141
+ if (!Array.isArray(rec.daysOfWeek)) throw new Error("recurrence.daysOfWeek must be an array");
142
+ const days = (rec.daysOfWeek as unknown[]).map(Number);
143
+ if (days.some((d) => !Number.isInteger(d) || d < 0 || d > 6)) {
144
+ throw new Error("recurrence.daysOfWeek values must be integers 0-6");
145
+ }
146
+ recurrence.daysOfWeek = days;
147
+ }
148
+ if (rec.expression !== undefined) {
149
+ if (typeof rec.expression !== "string") throw new Error("recurrence.expression must be a string");
150
+ recurrence.expression = rec.expression;
151
+ }
152
+ if (rec.endsAt !== undefined) {
153
+ const endsAtDate = new Date(rec.endsAt as string);
154
+ if (isNaN(endsAtDate.getTime())) {
155
+ throw new Error(`Invalid recurrence.endsAt: "${rec.endsAt}"`);
156
+ }
157
+ recurrence.endsAt = endsAtDate.getTime();
158
+ }
159
+ if (rec.maxOccurrences !== undefined) {
160
+ const max = Number(rec.maxOccurrences);
161
+ if (!Number.isInteger(max) || max < 1) {
162
+ throw new Error("recurrence.maxOccurrences must be a positive integer");
163
+ }
164
+ recurrence.maxOccurrences = max;
165
+ }
166
+ }
167
+
81
168
  const conversationId = context.conversationId || context.runId;
82
169
  const reminder = await store.create({
83
170
  task,
@@ -85,6 +172,7 @@ export const createReminderTools = (store: ReminderStore): ToolDefinition[] => [
85
172
  timezone,
86
173
  conversationId,
87
174
  tenantId: context.tenantId,
175
+ recurrence,
88
176
  });
89
177
 
90
178
  return {
@@ -95,6 +183,8 @@ export const createReminderTools = (store: ReminderStore): ToolDefinition[] => [
95
183
  scheduledAt: new Date(reminder.scheduledAt).toISOString(),
96
184
  timezone: reminder.timezone ?? "UTC",
97
185
  status: reminder.status,
186
+ recurrence: reminder.recurrence ?? undefined,
187
+ occurrenceCount: reminder.occurrenceCount ?? 0,
98
188
  },
99
189
  };
100
190
  },
@@ -105,7 +195,8 @@ export const createReminderTools = (store: ReminderStore): ToolDefinition[] => [
105
195
  description:
106
196
  "List reminders for this agent. Returns all reminders by default; " +
107
197
  "use the status filter to show only pending or cancelled ones. " +
108
- "Fired reminders are automatically deleted after delivery.",
198
+ "Fired one-time reminders are automatically deleted after delivery. " +
199
+ "Recurring reminders stay active and show their recurrence config and fire count.",
109
200
  inputSchema: {
110
201
  type: "object",
111
202
  properties: {
@@ -135,6 +226,8 @@ export const createReminderTools = (store: ReminderStore): ToolDefinition[] => [
135
226
  timezone: r.timezone ?? "UTC",
136
227
  status: r.status,
137
228
  createdAt: new Date(r.createdAt).toISOString(),
229
+ recurrence: r.recurrence ?? undefined,
230
+ occurrenceCount: r.occurrenceCount ?? 0,
138
231
  })),
139
232
  count: reminders.length,
140
233
  };
@@ -143,7 +236,10 @@ export const createReminderTools = (store: ReminderStore): ToolDefinition[] => [
143
236
 
144
237
  defineTool({
145
238
  name: "cancel_reminder",
146
- description: "Cancel a pending reminder by its ID.",
239
+ description:
240
+ "Cancel a pending reminder by its ID. " +
241
+ "This works for both one-time and recurring reminders — " +
242
+ "cancelling a recurring reminder stops all future occurrences.",
147
243
  inputSchema: {
148
244
  type: "object",
149
245
  properties: {
package/src/state.ts CHANGED
@@ -54,6 +54,13 @@ export interface Conversation {
54
54
  contextTokens?: number;
55
55
  contextWindow?: number;
56
56
  parentConversationId?: string;
57
+ parentMessageId?: string;
58
+ threadMeta?: {
59
+ /** First ~200 chars of the anchor message text, cached for cheap rendering. */
60
+ parentMessageSummary?: string;
61
+ /** Length of the snapshot at fork time. messages[snapshotLength-1] is the anchor. */
62
+ snapshotLength: number;
63
+ };
57
64
  subagentMeta?: {
58
65
  task: string;
59
66
  status: "running" | "completed" | "error" | "stopped";
@@ -77,16 +84,48 @@ export interface Conversation {
77
84
  updatedAt: number;
78
85
  }
79
86
 
87
+ export interface ConversationCreateInit {
88
+ parentConversationId?: string;
89
+ parentMessageId?: string;
90
+ threadMeta?: Conversation["threadMeta"];
91
+ subagentMeta?: Conversation["subagentMeta"];
92
+ messages?: Message[];
93
+ channelMeta?: Conversation["channelMeta"];
94
+ }
95
+
80
96
  export interface ConversationStore {
81
97
  list(ownerId?: string, tenantId?: string | null): Promise<Conversation[]>;
82
98
  listSummaries(ownerId?: string, tenantId?: string | null): Promise<ConversationSummary[]>;
99
+ /**
100
+ * Cheap column-level fetch — returns summary fields only, no data blob.
101
+ * Use this on hot polling paths where the caller just needs to know
102
+ * whether the conversation has changed since last fetch.
103
+ */
104
+ getStatusSnapshot(conversationId: string): Promise<ConversationStatusSnapshot | undefined>;
105
+ /**
106
+ * Load a conversation WITHOUT the tool_result_archive blob. Default for
107
+ * read paths — archive can grow unboundedly and most callers don't need it.
108
+ */
83
109
  get(conversationId: string): Promise<Conversation | undefined>;
84
- create(ownerId?: string, title?: string, tenantId?: string | null): Promise<Conversation>;
110
+ /**
111
+ * Load a conversation WITH the tool_result_archive. Use this only on
112
+ * run-entry paths that reseed the harness (via withToolResultArchiveParam
113
+ * or by passing the archive to runCronAgent).
114
+ */
115
+ getWithArchive(conversationId: string): Promise<Conversation | undefined>;
116
+ create(
117
+ ownerId?: string,
118
+ title?: string,
119
+ tenantId?: string | null,
120
+ init?: ConversationCreateInit,
121
+ ): Promise<Conversation>;
85
122
  update(conversation: Conversation): Promise<void>;
86
123
  rename(conversationId: string, title: string): Promise<Conversation | undefined>;
87
124
  delete(conversationId: string): Promise<boolean>;
88
125
  appendSubagentResult(conversationId: string, result: PendingSubagentResult): Promise<void>;
89
126
  clearCallbackLock(conversationId: string): Promise<Conversation | undefined>;
127
+ /** List thread conversations anchored under `parentConversationId`. */
128
+ listThreads(parentConversationId: string): Promise<ConversationSummary[]>;
90
129
  }
91
130
 
92
131
  export type StateProviderName =
@@ -186,6 +225,7 @@ export class InMemoryConversationStore implements ConversationStore {
186
225
  ownerId: c.ownerId,
187
226
  tenantId: c.tenantId,
188
227
  parentConversationId: c.parentConversationId,
228
+ parentMessageId: c.parentMessageId,
189
229
  messageCount: c.messages.length,
190
230
  hasPendingApprovals: Array.isArray(c.pendingApprovals) && c.pendingApprovals.length > 0,
191
231
  channelMeta: c.channelMeta,
@@ -197,16 +237,58 @@ export class InMemoryConversationStore implements ConversationStore {
197
237
  return this.conversations.get(conversationId);
198
238
  }
199
239
 
200
- async create(ownerId = DEFAULT_OWNER, title?: string, tenantId: string | null = null): Promise<Conversation> {
240
+ // In-memory stores already hold the full conversation object, so there's
241
+ // no separate archive blob to load. Both variants return the same data.
242
+ async getWithArchive(conversationId: string): Promise<Conversation | undefined> {
243
+ return this.get(conversationId);
244
+ }
245
+
246
+ async getStatusSnapshot(conversationId: string): Promise<ConversationStatusSnapshot | undefined> {
247
+ const c = await this.get(conversationId);
248
+ if (!c) return undefined;
249
+ return {
250
+ conversationId: c.conversationId,
251
+ updatedAt: c.updatedAt,
252
+ messageCount: c.messages.length,
253
+ hasPendingApprovals: Array.isArray(c.pendingApprovals) && c.pendingApprovals.length > 0,
254
+ hasContinuationMessages: Array.isArray(c._continuationMessages) && c._continuationMessages.length > 0,
255
+ parentConversationId: c.parentConversationId ?? null,
256
+ ownerId: c.ownerId,
257
+ tenantId: c.tenantId,
258
+ runStatus: c.runStatus ?? null,
259
+ };
260
+ }
261
+
262
+ async create(
263
+ ownerId = DEFAULT_OWNER,
264
+ title?: string,
265
+ tenantId: string | null = null,
266
+ init?: ConversationCreateInit,
267
+ ): Promise<Conversation> {
201
268
  const now = Date.now();
202
269
  const conversation: Conversation = {
203
270
  conversationId: globalThis.crypto?.randomUUID?.() ?? `${now}-${Math.random()}`,
204
271
  title: normalizeTitle(title),
205
- messages: [],
272
+ messages: init?.messages ?? [],
206
273
  ownerId,
207
274
  tenantId,
208
275
  createdAt: now,
209
276
  updatedAt: now,
277
+ ...(init?.parentConversationId !== undefined
278
+ ? { parentConversationId: init.parentConversationId }
279
+ : {}),
280
+ ...(init?.parentMessageId !== undefined
281
+ ? { parentMessageId: init.parentMessageId }
282
+ : {}),
283
+ ...(init?.threadMeta !== undefined
284
+ ? { threadMeta: init.threadMeta }
285
+ : {}),
286
+ ...(init?.subagentMeta !== undefined
287
+ ? { subagentMeta: init.subagentMeta }
288
+ : {}),
289
+ ...(init?.channelMeta !== undefined
290
+ ? { channelMeta: init.channelMeta }
291
+ : {}),
210
292
  };
211
293
  this.conversations.set(conversation.conversationId, conversation);
212
294
  return conversation;
@@ -250,6 +332,30 @@ export class InMemoryConversationStore implements ConversationStore {
250
332
  conversation.updatedAt = Date.now();
251
333
  return conversation;
252
334
  }
335
+
336
+ async listThreads(parentConversationId: string): Promise<ConversationSummary[]> {
337
+ this.purgeExpired();
338
+ return Array.from(this.conversations.values())
339
+ .filter(
340
+ (c) =>
341
+ c.parentConversationId === parentConversationId &&
342
+ typeof c.parentMessageId === "string",
343
+ )
344
+ .sort((a, b) => b.updatedAt - a.updatedAt)
345
+ .map((c) => ({
346
+ conversationId: c.conversationId,
347
+ title: c.title,
348
+ updatedAt: c.updatedAt,
349
+ createdAt: c.createdAt,
350
+ ownerId: c.ownerId,
351
+ tenantId: c.tenantId,
352
+ parentConversationId: c.parentConversationId,
353
+ parentMessageId: c.parentMessageId,
354
+ messageCount: c.messages.length,
355
+ hasPendingApprovals: Array.isArray(c.pendingApprovals) && c.pendingApprovals.length > 0,
356
+ channelMeta: c.channelMeta,
357
+ }));
358
+ }
253
359
  }
254
360
 
255
361
  export type ConversationSummary = {
@@ -260,6 +366,7 @@ export type ConversationSummary = {
260
366
  ownerId: string;
261
367
  tenantId?: string | null;
262
368
  parentConversationId?: string;
369
+ parentMessageId?: string;
263
370
  messageCount?: number;
264
371
  hasPendingApprovals?: boolean;
265
372
  channelMeta?: {
@@ -269,6 +376,23 @@ export type ConversationSummary = {
269
376
  };
270
377
  };
271
378
 
379
+ /**
380
+ * Lightweight status snapshot — column-level reads only, no data blob.
381
+ * Used by cheap polling endpoints that just need to know "has anything
382
+ * changed?" without paying to deserialize the full conversation.
383
+ */
384
+ export type ConversationStatusSnapshot = {
385
+ conversationId: string;
386
+ updatedAt: number;
387
+ messageCount: number;
388
+ hasPendingApprovals: boolean;
389
+ hasContinuationMessages: boolean;
390
+ parentConversationId: string | null;
391
+ ownerId: string;
392
+ tenantId: string | null;
393
+ runStatus: "running" | "idle" | null;
394
+ };
395
+
272
396
  // ---------------------------------------------------------------------------
273
397
  // Legacy factories — return InMemory stores. The harness now uses
274
398
  // engine-backed stores via storage/store-adapters.ts. These factories
@@ -1,11 +1,13 @@
1
1
  import type {
2
2
  Conversation,
3
+ ConversationCreateInit,
4
+ ConversationStatusSnapshot,
3
5
  ConversationSummary,
4
6
  PendingSubagentResult,
5
7
  } from "../state.js";
6
8
  import type { MainMemory } from "../memory.js";
7
9
  import type { TodoItem } from "../todo-tools.js";
8
- import type { Reminder } from "../reminder-store.js";
10
+ import type { Reminder, ReminderCreateInput, ReminderStatus } from "../reminder-store.js";
9
11
 
10
12
  // ---------------------------------------------------------------------------
11
13
  // VFS types
@@ -39,8 +41,29 @@ export interface StorageEngine {
39
41
  // --- Conversations (replaces ConversationStore) ---
40
42
  conversations: {
41
43
  list(ownerId?: string, tenantId?: string | null): Promise<ConversationSummary[]>;
44
+ /**
45
+ * Load a conversation WITHOUT the tool_result_archive blob. Use this on
46
+ * read paths (UI loads, existence checks, etc.) — the archive can grow
47
+ * unboundedly as tool calls accumulate, and most callers never touch it.
48
+ */
42
49
  get(conversationId: string): Promise<Conversation | undefined>;
43
- create(ownerId?: string, title?: string, tenantId?: string | null): Promise<Conversation>;
50
+ /**
51
+ * Load a conversation WITH the tool_result_archive. Only use this when
52
+ * starting/resuming a harness run — the archive needs to be reseeded so
53
+ * the agent can retrieve previously-archived tool results by id.
54
+ */
55
+ getWithArchive(conversationId: string): Promise<Conversation | undefined>;
56
+ /**
57
+ * Cheap column-level snapshot — no data blob, no heavy columns. For hot
58
+ * polling paths. Returns undefined if the conversation doesn't exist.
59
+ */
60
+ getStatusSnapshot(conversationId: string): Promise<ConversationStatusSnapshot | undefined>;
61
+ create(
62
+ ownerId?: string,
63
+ title?: string,
64
+ tenantId?: string | null,
65
+ init?: ConversationCreateInit,
66
+ ): Promise<Conversation>;
44
67
  update(conversation: Conversation): Promise<void>;
45
68
  rename(conversationId: string, title: string): Promise<Conversation | undefined>;
46
69
  delete(conversationId: string): Promise<boolean>;
@@ -50,6 +73,8 @@ export interface StorageEngine {
50
73
  result: PendingSubagentResult,
51
74
  ): Promise<void>;
52
75
  clearCallbackLock(conversationId: string): Promise<Conversation | undefined>;
76
+ /** List thread conversations anchored under `parentConversationId`. */
77
+ listThreads(parentConversationId: string): Promise<ConversationSummary[]>;
53
78
  };
54
79
 
55
80
  // --- Memory (replaces MemoryStore) ---
@@ -67,14 +92,8 @@ export interface StorageEngine {
67
92
  // --- Reminders (replaces ReminderStore) ---
68
93
  reminders: {
69
94
  list(tenantId?: string | null): Promise<Reminder[]>;
70
- create(input: {
71
- task: string;
72
- scheduledAt: number;
73
- timezone?: string;
74
- conversationId: string;
75
- ownerId?: string;
76
- tenantId?: string | null;
77
- }): Promise<Reminder>;
95
+ create(input: ReminderCreateInput): Promise<Reminder>;
96
+ update(id: string, fields: { scheduledAt?: number; occurrenceCount?: number; status?: ReminderStatus }): Promise<Reminder>;
78
97
  cancel(id: string): Promise<Reminder>;
79
98
  delete(id: string): Promise<void>;
80
99
  };
@@ -5,12 +5,14 @@
5
5
  import { randomUUID } from "node:crypto";
6
6
  import type {
7
7
  Conversation,
8
+ ConversationCreateInit,
9
+ ConversationStatusSnapshot,
8
10
  ConversationSummary,
9
11
  PendingSubagentResult,
10
12
  } from "../state.js";
11
13
  import type { MainMemory } from "../memory.js";
12
14
  import type { TodoItem } from "../todo-tools.js";
13
- import type { Reminder } from "../reminder-store.js";
15
+ import type { Reminder, ReminderCreateInput, ReminderStatus } from "../reminder-store.js";
14
16
  import type { StorageEngine, VfsDirEntry, VfsStat } from "./engine.js";
15
17
 
16
18
  // ---------------------------------------------------------------------------
@@ -101,20 +103,61 @@ export class InMemoryEngine implements StorageEngine {
101
103
  return this.convs.get(conversationId);
102
104
  },
103
105
 
106
+ // In-memory storage has no separate archive blob, so both variants
107
+ // return the same conversation object.
108
+ getWithArchive: async (conversationId: string): Promise<Conversation | undefined> => {
109
+ return this.convs.get(conversationId);
110
+ },
111
+
112
+ getStatusSnapshot: async (
113
+ conversationId: string,
114
+ ): Promise<ConversationStatusSnapshot | undefined> => {
115
+ const c = this.convs.get(conversationId);
116
+ if (!c) return undefined;
117
+ return {
118
+ conversationId: c.conversationId,
119
+ updatedAt: c.updatedAt,
120
+ messageCount: c.messages.length,
121
+ hasPendingApprovals: Array.isArray(c.pendingApprovals) && c.pendingApprovals.length > 0,
122
+ hasContinuationMessages:
123
+ Array.isArray(c._continuationMessages) && c._continuationMessages.length > 0,
124
+ parentConversationId: c.parentConversationId ?? null,
125
+ ownerId: c.ownerId,
126
+ tenantId: c.tenantId,
127
+ runStatus: c.runStatus ?? null,
128
+ };
129
+ },
130
+
104
131
  create: async (
105
132
  ownerId?: string,
106
133
  title?: string,
107
134
  tenantId?: string | null,
135
+ init?: ConversationCreateInit,
108
136
  ): Promise<Conversation> => {
109
137
  const now = Date.now();
110
138
  const conv: Conversation = {
111
139
  conversationId: randomUUID(),
112
140
  title: normalizeTitle(title),
113
- messages: [],
141
+ messages: init?.messages ?? [],
114
142
  ownerId: ownerId ?? DEFAULT_OWNER,
115
143
  tenantId: tenantId === undefined ? null : tenantId,
116
144
  createdAt: now,
117
145
  updatedAt: now,
146
+ ...(init?.parentConversationId !== undefined
147
+ ? { parentConversationId: init.parentConversationId }
148
+ : {}),
149
+ ...(init?.parentMessageId !== undefined
150
+ ? { parentMessageId: init.parentMessageId }
151
+ : {}),
152
+ ...(init?.threadMeta !== undefined
153
+ ? { threadMeta: init.threadMeta }
154
+ : {}),
155
+ ...(init?.subagentMeta !== undefined
156
+ ? { subagentMeta: init.subagentMeta }
157
+ : {}),
158
+ ...(init?.channelMeta !== undefined
159
+ ? { channelMeta: init.channelMeta }
160
+ : {}),
118
161
  };
119
162
  this.convs.set(conv.conversationId, conv);
120
163
  return conv;
@@ -180,6 +223,22 @@ export class InMemoryEngine implements StorageEngine {
180
223
  conv.runningCallbackSince = undefined;
181
224
  return conv;
182
225
  },
226
+
227
+ listThreads: async (
228
+ parentConversationId: string,
229
+ ): Promise<ConversationSummary[]> => {
230
+ const results: ConversationSummary[] = [];
231
+ for (const c of this.convs.values()) {
232
+ if (
233
+ c.parentConversationId === parentConversationId &&
234
+ typeof c.parentMessageId === "string"
235
+ ) {
236
+ results.push(this.toSummary(c));
237
+ }
238
+ }
239
+ results.sort((a, b) => b.updatedAt - a.updatedAt);
240
+ return results;
241
+ },
183
242
  };
184
243
 
185
244
  // -----------------------------------------------------------------------
@@ -237,14 +296,7 @@ export class InMemoryEngine implements StorageEngine {
237
296
  return results;
238
297
  },
239
298
 
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> => {
299
+ create: async (input: ReminderCreateInput): Promise<Reminder> => {
248
300
  const r: Reminder = {
249
301
  id: randomUUID(),
250
302
  task: input.task,
@@ -255,11 +307,22 @@ export class InMemoryEngine implements StorageEngine {
255
307
  conversationId: input.conversationId,
256
308
  ownerId: input.ownerId,
257
309
  tenantId: input.tenantId,
310
+ recurrence: input.recurrence ?? null,
311
+ occurrenceCount: 0,
258
312
  };
259
313
  this.reminderData.set(r.id, r);
260
314
  return r;
261
315
  },
262
316
 
317
+ update: async (id: string, fields: { scheduledAt?: number; occurrenceCount?: number; status?: ReminderStatus }): Promise<Reminder> => {
318
+ const r = this.reminderData.get(id);
319
+ if (!r) throw new Error(`Reminder ${id} not found`);
320
+ if (fields.scheduledAt !== undefined) r.scheduledAt = fields.scheduledAt;
321
+ if (fields.occurrenceCount !== undefined) r.occurrenceCount = fields.occurrenceCount;
322
+ if (fields.status !== undefined) r.status = fields.status;
323
+ return r;
324
+ },
325
+
263
326
  cancel: async (id: string): Promise<Reminder> => {
264
327
  const r = this.reminderData.get(id);
265
328
  if (!r) throw new Error(`Reminder ${id} not found`);
@@ -544,6 +607,7 @@ export class InMemoryEngine implements StorageEngine {
544
607
  messageCount: c.messages.length,
545
608
  hasPendingApprovals: (c.pendingApprovals?.length ?? 0) > 0,
546
609
  parentConversationId: c.parentConversationId,
610
+ parentMessageId: c.parentMessageId,
547
611
  channelMeta: c.channelMeta,
548
612
  };
549
613
  }
@@ -66,6 +66,7 @@ export class PostgresEngine extends SqlStorageEngine {
66
66
  }
67
67
 
68
68
  async close(): Promise<void> {
69
+ this.flushEgressStats();
69
70
  await this.sql?.end();
70
71
  }
71
72
 
@@ -174,4 +174,25 @@ 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
+ },
188
+ {
189
+ version: 6,
190
+ name: "add_thread_anchor",
191
+ up: () => [
192
+ `ALTER TABLE conversations ADD COLUMN parent_message_id TEXT`,
193
+ `CREATE INDEX IF NOT EXISTS idx_conversations_thread_anchor
194
+ ON conversations (parent_conversation_id, parent_message_id)
195
+ WHERE parent_message_id IS NOT NULL`,
196
+ ],
197
+ },
177
198
  ];