@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.37.1",
3
+ "version": "0.38.0",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
package/src/harness.ts CHANGED
@@ -333,6 +333,39 @@ const hasUntruncatedToolResults = (messages: Message[]): boolean => {
333
333
  return false;
334
334
  };
335
335
 
336
+ /**
337
+ * Finds the last ModelMessage index that's safe to place a prompt cache
338
+ * breakpoint at — i.e. the last index before any untruncated tool-result.
339
+ *
340
+ * Untruncated tool-results from a prior run will be truncated on the next
341
+ * run, which would invalidate any cache write covering them. Placing the
342
+ * breakpoint just before them lets us cache only the stable prefix (system
343
+ * prompt + earlier turns) while still reading it back next turn.
344
+ *
345
+ * Returns `messages.length - 1` when there are no untruncated tool-results
346
+ * (normal tail-of-history caching).
347
+ */
348
+ const findLastStableCacheIndex = (messages: ModelMessage[]): number => {
349
+ for (let i = 0; i < messages.length; i += 1) {
350
+ const msg = messages[i]!;
351
+ if (msg.role !== "tool") continue;
352
+ if (!Array.isArray(msg.content)) continue;
353
+ for (const part of msg.content) {
354
+ if (!part || typeof part !== "object") continue;
355
+ const p = part as { type?: string; output?: { type?: string; value?: unknown } };
356
+ if (p.type !== "tool-result" || !p.output) continue;
357
+ // JSON outputs bypass truncation (only text content is truncated).
358
+ if (p.output.type === "json") return i - 1;
359
+ if (p.output.type === "text" && typeof p.output.value === "string") {
360
+ if (!p.output.value.startsWith(TOOL_RESULT_TRUNCATED_PREFIX)) {
361
+ return i - 1;
362
+ }
363
+ }
364
+ }
365
+ }
366
+ return messages.length - 1;
367
+ };
368
+
336
369
  const DEVELOPMENT_MODE_CONTEXT = `## Development Mode Context
337
370
 
338
371
  You are running locally in development mode. Treat this as an editable agent workspace.
@@ -1444,9 +1477,10 @@ export class AgentHarness {
1444
1477
  );
1445
1478
  // Register VFS tools
1446
1479
  this.registerIfMissing(createBashTool(this.bashManager));
1447
- this.registerIfMissing(createReadFileTool(engine));
1448
- this.registerIfMissing(createEditFileTool(engine));
1449
- this.registerIfMissing(createWriteFileTool(engine));
1480
+ const getFs = (tenantId: string) => this.bashManager!.getFs(tenantId);
1481
+ this.registerIfMissing(createReadFileTool(getFs));
1482
+ this.registerIfMissing(createEditFileTool(getFs));
1483
+ this.registerIfMissing(createWriteFileTool(getFs));
1450
1484
 
1451
1485
  // --- Isolate (V8 sandboxed code execution) ---
1452
1486
  if (config?.isolate) {
@@ -1799,16 +1833,15 @@ export class AgentHarness {
1799
1833
  );
1800
1834
  }
1801
1835
  const hasFullToolResults = hasUntruncatedToolResults(messages);
1802
- const enablePromptCache = !hasFullToolResults;
1803
- if (!enablePromptCache) {
1836
+ if (hasFullToolResults) {
1804
1837
  console.info(
1805
- `[poncho][cost] Prompt cache write disabled for run "${runId}" ` +
1806
- `(untruncated tool results present in history).`,
1838
+ `[poncho][cost] Prompt cache breakpoint will be placed before untruncated ` +
1839
+ `tool results for run "${runId}" (stable prefix only).`,
1807
1840
  );
1808
1841
  } else {
1809
1842
  console.info(
1810
- `[poncho][cost] Prompt cache write enabled for run "${runId}" ` +
1811
- `(history has no untruncated tool results).`,
1843
+ `[poncho][cost] Prompt cache breakpoint will be placed at history tail ` +
1844
+ `for run "${runId}" (no untruncated tool results).`,
1812
1845
  );
1813
1846
  }
1814
1847
  const inputMessageCount = messages.length;
@@ -1917,8 +1950,17 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
1917
1950
  const promptWithSkills = this.skillContextWindow
1918
1951
  ? `${agentPrompt}${developmentContext}\n\n${this.skillContextWindow}${browserContext}${fsContext}${isolateContext}`
1919
1952
  : `${agentPrompt}${developmentContext}${browserContext}${fsContext}${isolateContext}`;
1953
+ // Quantize to the hour so the system prompt is stable across runs
1954
+ // within the same hour. Including a per-millisecond timestamp would
1955
+ // invalidate the prompt cache on every run, since the system prompt
1956
+ // is the first block the cache tries to match.
1957
+ const hourlyTime = (() => {
1958
+ const d = new Date();
1959
+ d.setUTCMinutes(0, 0, 0);
1960
+ return d.toISOString();
1961
+ })();
1920
1962
  const timeContext = this.reminderStore
1921
- ? `\n\nCurrent UTC time: ${new Date().toISOString()}`
1963
+ ? `\n\nCurrent UTC time (hour precision): ${hourlyTime}`
1922
1964
  : "";
1923
1965
  return `${promptWithSkills}${memoryContext}${todoContext}${timeContext}`;
1924
1966
  };
@@ -2452,9 +2494,17 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
2452
2494
 
2453
2495
  const temperature = agent.frontmatter.model?.temperature ?? 0.2;
2454
2496
  const maxTokens = agent.frontmatter.model?.maxTokens;
2455
- const cachedMessages = enablePromptCache
2456
- ? addPromptCacheBreakpoints(coreMessages, modelInstance)
2457
- : coreMessages;
2497
+ // Place the breakpoint before any untruncated tool-result so we
2498
+ // cache only the stable prefix when prior-run tool results are
2499
+ // still full-fidelity. Otherwise cache at the history tail.
2500
+ const breakpointIndex = hasFullToolResults
2501
+ ? findLastStableCacheIndex(coreMessages)
2502
+ : coreMessages.length - 1;
2503
+ const cachedMessages = addPromptCacheBreakpoints(
2504
+ coreMessages,
2505
+ modelInstance,
2506
+ breakpointIndex,
2507
+ );
2458
2508
 
2459
2509
  const telemetryEnabled = this.loadedConfig?.telemetry?.enabled !== false;
2460
2510
 
@@ -17,23 +17,32 @@ function isAnthropicModel(model: LanguageModel): boolean {
17
17
  * explicit opt-in (Anthropic). For providers with automatic caching
18
18
  * (OpenAI), messages are returned unchanged.
19
19
  *
20
- * For Anthropic, marks the last message with ephemeral cache control so the
21
- * conversation prefix is incrementally cached across steps.
20
+ * For Anthropic, marks the target message with ephemeral cache control so
21
+ * the conversation prefix is incrementally cached across steps. When
22
+ * `targetIndex` is omitted, the last message is used (default behavior).
23
+ * Callers that want to cache only a stable prefix (e.g. skipping tool
24
+ * results that will be truncated next turn) can pass an earlier index.
22
25
  */
23
26
  export function addPromptCacheBreakpoints(
24
27
  messages: ModelMessage[],
25
28
  model: LanguageModel,
29
+ targetIndex?: number,
26
30
  ): ModelMessage[] {
27
31
  if (messages.length === 0 || !isAnthropicModel(model)) {
28
32
  return messages;
29
33
  }
30
34
 
35
+ const index = targetIndex ?? messages.length - 1;
36
+ if (index < 0 || index >= messages.length) {
37
+ return messages;
38
+ }
39
+
31
40
  const cacheDirective = {
32
41
  anthropic: { cacheControl: { type: "ephemeral" as const } },
33
42
  };
34
43
 
35
- return messages.map((message, index) => {
36
- if (index === messages.length - 1) {
44
+ return messages.map((message, i) => {
45
+ if (i === index) {
37
46
  return {
38
47
  ...message,
39
48
  providerOptions: {
@@ -6,6 +6,22 @@ import type { StateConfig } from "./state.js";
6
6
 
7
7
  export type ReminderStatus = "pending" | "fired" | "cancelled";
8
8
 
9
+ export type RecurrenceType = "daily" | "weekly" | "monthly" | "cron";
10
+
11
+ export interface Recurrence {
12
+ type: RecurrenceType;
13
+ /** Repeat every N units (e.g. every 2 days). Defaults to 1. */
14
+ interval?: number;
15
+ /** For weekly: which days (0=Sun … 6=Sat). */
16
+ daysOfWeek?: number[];
17
+ /** For type "cron": a 5-field cron expression. */
18
+ expression?: string;
19
+ /** Stop recurring after this epoch-ms timestamp. */
20
+ endsAt?: number;
21
+ /** Stop recurring after this many total firings. */
22
+ maxOccurrences?: number;
23
+ }
24
+
9
25
  export interface Reminder {
10
26
  id: string;
11
27
  task: string;
@@ -16,18 +32,24 @@ export interface Reminder {
16
32
  conversationId: string;
17
33
  ownerId?: string;
18
34
  tenantId?: string | null;
35
+ recurrence?: Recurrence | null;
36
+ occurrenceCount?: number;
37
+ }
38
+
39
+ export interface ReminderCreateInput {
40
+ task: string;
41
+ scheduledAt: number;
42
+ timezone?: string;
43
+ conversationId: string;
44
+ ownerId?: string;
45
+ tenantId?: string | null;
46
+ recurrence?: Recurrence | null;
19
47
  }
20
48
 
21
49
  export interface ReminderStore {
22
50
  list(): Promise<Reminder[]>;
23
- create(input: {
24
- task: string;
25
- scheduledAt: number;
26
- timezone?: string;
27
- conversationId: string;
28
- ownerId?: string;
29
- tenantId?: string | null;
30
- }): Promise<Reminder>;
51
+ create(input: ReminderCreateInput): Promise<Reminder>;
52
+ update(id: string, fields: { scheduledAt?: number; occurrenceCount?: number; status?: ReminderStatus }): Promise<Reminder>;
31
53
  cancel(id: string): Promise<Reminder>;
32
54
  delete(id: string): Promise<void>;
33
55
  }
@@ -63,14 +85,7 @@ class InMemoryReminderStore implements ReminderStore {
63
85
  return [...this.reminders];
64
86
  }
65
87
 
66
- async create(input: {
67
- task: string;
68
- scheduledAt: number;
69
- timezone?: string;
70
- conversationId: string;
71
- ownerId?: string;
72
- tenantId?: string | null;
73
- }): Promise<Reminder> {
88
+ async create(input: ReminderCreateInput): Promise<Reminder> {
74
89
  const reminder: Reminder = {
75
90
  id: generateId(),
76
91
  task: input.task,
@@ -81,12 +96,23 @@ class InMemoryReminderStore implements ReminderStore {
81
96
  conversationId: input.conversationId,
82
97
  ownerId: input.ownerId,
83
98
  tenantId: input.tenantId,
99
+ recurrence: input.recurrence ?? null,
100
+ occurrenceCount: 0,
84
101
  };
85
102
  this.reminders = pruneStale(this.reminders);
86
103
  this.reminders.push(reminder);
87
104
  return reminder;
88
105
  }
89
106
 
107
+ async update(id: string, fields: { scheduledAt?: number; occurrenceCount?: number; status?: ReminderStatus }): Promise<Reminder> {
108
+ const reminder = this.reminders.find((r) => r.id === id);
109
+ if (!reminder) throw new Error(`Reminder "${id}" not found`);
110
+ if (fields.scheduledAt !== undefined) reminder.scheduledAt = fields.scheduledAt;
111
+ if (fields.occurrenceCount !== undefined) reminder.occurrenceCount = fields.occurrenceCount;
112
+ if (fields.status !== undefined) reminder.status = fields.status;
113
+ return reminder;
114
+ }
115
+
90
116
  async cancel(id: string): Promise<Reminder> {
91
117
  const reminder = this.reminders.find((r) => r.id === id);
92
118
  if (!reminder) throw new Error(`Reminder "${id}" not found`);
@@ -102,6 +128,147 @@ class InMemoryReminderStore implements ReminderStore {
102
128
  }
103
129
  }
104
130
 
131
+ // ---------------------------------------------------------------------------
132
+ // Recurrence helpers
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /**
136
+ * Given a reminder's current scheduledAt and its recurrence config, compute the
137
+ * next fire time. Returns null if the recurrence is exhausted (maxOccurrences
138
+ * reached or endsAt passed).
139
+ */
140
+ export const computeNextOccurrence = (reminder: Reminder): number | null => {
141
+ const rec = reminder.recurrence;
142
+ if (!rec) return null;
143
+
144
+ const fired = (reminder.occurrenceCount ?? 0) + 1;
145
+ if (rec.maxOccurrences && fired >= rec.maxOccurrences) return null;
146
+
147
+ const interval = rec.interval ?? 1;
148
+ const prev = reminder.scheduledAt;
149
+ let next: number;
150
+
151
+ switch (rec.type) {
152
+ case "daily": {
153
+ next = prev + interval * 24 * 60 * 60 * 1000;
154
+ break;
155
+ }
156
+ case "weekly": {
157
+ if (rec.daysOfWeek && rec.daysOfWeek.length > 0) {
158
+ // Advance to next matching day-of-week
159
+ const d = new Date(prev);
160
+ const days = [...rec.daysOfWeek].sort((a, b) => a - b);
161
+ const currentDay = d.getUTCDay();
162
+ // Find the next day in the list that is strictly after currentDay
163
+ let nextDay = days.find((day) => day > currentDay);
164
+ if (nextDay !== undefined) {
165
+ // Same week
166
+ const delta = nextDay - currentDay;
167
+ next = prev + delta * 24 * 60 * 60 * 1000;
168
+ } else {
169
+ // Wrap to next week (+ interval weeks if interval > 1)
170
+ const delta = (7 * interval) - currentDay + days[0];
171
+ next = prev + delta * 24 * 60 * 60 * 1000;
172
+ }
173
+ } else {
174
+ // No specific days — just advance by N weeks
175
+ next = prev + interval * 7 * 24 * 60 * 60 * 1000;
176
+ }
177
+ break;
178
+ }
179
+ case "monthly": {
180
+ const d = new Date(prev);
181
+ d.setUTCMonth(d.getUTCMonth() + interval);
182
+ next = d.getTime();
183
+ break;
184
+ }
185
+ case "cron": {
186
+ // Minimal cron: parse 5-field expression and find the next matching
187
+ // minute after `prev`. We support basic values, ranges, steps and *.
188
+ if (!rec.expression) return null;
189
+ const parsed = parseCronExpression(rec.expression);
190
+ if (!parsed) return null;
191
+ next = nextCronOccurrence(prev, parsed);
192
+ if (next <= prev) return null; // safety
193
+ break;
194
+ }
195
+ default:
196
+ return null;
197
+ }
198
+
199
+ if (rec.endsAt && next > rec.endsAt) return null;
200
+ return next;
201
+ };
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Minimal cron parser (5-field: min hour dom month dow)
205
+ // ---------------------------------------------------------------------------
206
+
207
+ interface CronFields {
208
+ minutes: Set<number>;
209
+ hours: Set<number>;
210
+ daysOfMonth: Set<number>;
211
+ months: Set<number>;
212
+ daysOfWeek: Set<number>;
213
+ }
214
+
215
+ const expandField = (field: string, min: number, max: number): Set<number> | null => {
216
+ const values = new Set<number>();
217
+ for (const part of field.split(",")) {
218
+ const stepMatch = part.match(/^(.+)\/(\d+)$/);
219
+ const step = stepMatch ? parseInt(stepMatch[2], 10) : 1;
220
+ const range = stepMatch ? stepMatch[1] : part;
221
+
222
+ if (range === "*") {
223
+ for (let i = min; i <= max; i += step) values.add(i);
224
+ } else if (range.includes("-")) {
225
+ const [lo, hi] = range.split("-").map(Number);
226
+ if (isNaN(lo) || isNaN(hi)) return null;
227
+ for (let i = lo; i <= hi; i += step) values.add(i);
228
+ } else {
229
+ const n = parseInt(range, 10);
230
+ if (isNaN(n)) return null;
231
+ values.add(n);
232
+ }
233
+ }
234
+ return values;
235
+ };
236
+
237
+ const parseCronExpression = (expr: string): CronFields | null => {
238
+ const parts = expr.trim().split(/\s+/);
239
+ if (parts.length !== 5) return null;
240
+ const minutes = expandField(parts[0], 0, 59);
241
+ const hours = expandField(parts[1], 0, 23);
242
+ const daysOfMonth = expandField(parts[2], 1, 31);
243
+ const months = expandField(parts[3], 1, 12);
244
+ const daysOfWeek = expandField(parts[4], 0, 6);
245
+ if (!minutes || !hours || !daysOfMonth || !months || !daysOfWeek) return null;
246
+ return { minutes, hours, daysOfMonth, months, daysOfWeek };
247
+ };
248
+
249
+ const nextCronOccurrence = (afterMs: number, fields: CronFields): number => {
250
+ // Start from the minute after `afterMs`
251
+ const d = new Date(afterMs);
252
+ d.setUTCSeconds(0, 0);
253
+ d.setUTCMinutes(d.getUTCMinutes() + 1);
254
+
255
+ // Cap search to ~1 year to avoid infinite loops
256
+ const limit = afterMs + 366 * 24 * 60 * 60 * 1000;
257
+ while (d.getTime() < limit) {
258
+ if (
259
+ fields.months.has(d.getUTCMonth() + 1) &&
260
+ fields.daysOfMonth.has(d.getUTCDate()) &&
261
+ fields.daysOfWeek.has(d.getUTCDay()) &&
262
+ fields.hours.has(d.getUTCHours()) &&
263
+ fields.minutes.has(d.getUTCMinutes())
264
+ ) {
265
+ return d.getTime();
266
+ }
267
+ d.setUTCMinutes(d.getUTCMinutes() + 1);
268
+ }
269
+ return afterMs; // no match within a year — treat as exhausted
270
+ };
271
+
105
272
  // ---------------------------------------------------------------------------
106
273
  // Factory
107
274
  // ---------------------------------------------------------------------------
@@ -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
@@ -77,11 +77,23 @@ export interface Conversation {
77
77
  updatedAt: number;
78
78
  }
79
79
 
80
+ export interface ConversationCreateInit {
81
+ parentConversationId?: string;
82
+ subagentMeta?: Conversation["subagentMeta"];
83
+ messages?: Message[];
84
+ channelMeta?: Conversation["channelMeta"];
85
+ }
86
+
80
87
  export interface ConversationStore {
81
88
  list(ownerId?: string, tenantId?: string | null): Promise<Conversation[]>;
82
89
  listSummaries(ownerId?: string, tenantId?: string | null): Promise<ConversationSummary[]>;
83
90
  get(conversationId: string): Promise<Conversation | undefined>;
84
- create(ownerId?: string, title?: string, tenantId?: string | null): Promise<Conversation>;
91
+ create(
92
+ ownerId?: string,
93
+ title?: string,
94
+ tenantId?: string | null,
95
+ init?: ConversationCreateInit,
96
+ ): Promise<Conversation>;
85
97
  update(conversation: Conversation): Promise<void>;
86
98
  rename(conversationId: string, title: string): Promise<Conversation | undefined>;
87
99
  delete(conversationId: string): Promise<boolean>;
@@ -197,16 +209,30 @@ export class InMemoryConversationStore implements ConversationStore {
197
209
  return this.conversations.get(conversationId);
198
210
  }
199
211
 
200
- async create(ownerId = DEFAULT_OWNER, title?: string, tenantId: string | null = null): Promise<Conversation> {
212
+ async create(
213
+ ownerId = DEFAULT_OWNER,
214
+ title?: string,
215
+ tenantId: string | null = null,
216
+ init?: ConversationCreateInit,
217
+ ): Promise<Conversation> {
201
218
  const now = Date.now();
202
219
  const conversation: Conversation = {
203
220
  conversationId: globalThis.crypto?.randomUUID?.() ?? `${now}-${Math.random()}`,
204
221
  title: normalizeTitle(title),
205
- messages: [],
222
+ messages: init?.messages ?? [],
206
223
  ownerId,
207
224
  tenantId,
208
225
  createdAt: now,
209
226
  updatedAt: now,
227
+ ...(init?.parentConversationId !== undefined
228
+ ? { parentConversationId: init.parentConversationId }
229
+ : {}),
230
+ ...(init?.subagentMeta !== undefined
231
+ ? { subagentMeta: init.subagentMeta }
232
+ : {}),
233
+ ...(init?.channelMeta !== undefined
234
+ ? { channelMeta: init.channelMeta }
235
+ : {}),
210
236
  };
211
237
  this.conversations.set(conversation.conversationId, conversation);
212
238
  return conversation;
@@ -1,11 +1,12 @@
1
1
  import type {
2
2
  Conversation,
3
+ ConversationCreateInit,
3
4
  ConversationSummary,
4
5
  PendingSubagentResult,
5
6
  } from "../state.js";
6
7
  import type { MainMemory } from "../memory.js";
7
8
  import type { TodoItem } from "../todo-tools.js";
8
- import type { Reminder } from "../reminder-store.js";
9
+ import type { Reminder, ReminderCreateInput, ReminderStatus } from "../reminder-store.js";
9
10
 
10
11
  // ---------------------------------------------------------------------------
11
12
  // VFS types
@@ -40,7 +41,12 @@ export interface StorageEngine {
40
41
  conversations: {
41
42
  list(ownerId?: string, tenantId?: string | null): Promise<ConversationSummary[]>;
42
43
  get(conversationId: string): Promise<Conversation | undefined>;
43
- create(ownerId?: string, title?: string, tenantId?: string | null): Promise<Conversation>;
44
+ create(
45
+ ownerId?: string,
46
+ title?: string,
47
+ tenantId?: string | null,
48
+ init?: ConversationCreateInit,
49
+ ): Promise<Conversation>;
44
50
  update(conversation: Conversation): Promise<void>;
45
51
  rename(conversationId: string, title: string): Promise<Conversation | undefined>;
46
52
  delete(conversationId: string): Promise<boolean>;
@@ -67,14 +73,8 @@ export interface StorageEngine {
67
73
  // --- Reminders (replaces ReminderStore) ---
68
74
  reminders: {
69
75
  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>;
76
+ create(input: ReminderCreateInput): Promise<Reminder>;
77
+ update(id: string, fields: { scheduledAt?: number; occurrenceCount?: number; status?: ReminderStatus }): Promise<Reminder>;
78
78
  cancel(id: string): Promise<Reminder>;
79
79
  delete(id: string): Promise<void>;
80
80
  };