@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/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +32 -0
- package/dist/index.d.ts +66 -34
- package/dist/index.js +382 -59
- package/package.json +1 -1
- package/src/harness.ts +63 -13
- package/src/prompt-cache.ts +13 -4
- package/src/reminder-store.ts +183 -16
- package/src/reminder-tools.ts +102 -6
- package/src/state.ts +29 -3
- package/src/storage/engine.ts +10 -10
- package/src/storage/memory-engine.ts +25 -10
- package/src/storage/schema.ts +11 -0
- package/src/storage/sql-dialect.ts +80 -15
- package/src/storage/store-adapters.ts +11 -11
- package/src/vfs/bash-manager.ts +16 -2
- package/src/vfs/edit-file-tool.ts +9 -8
- package/src/vfs/read-file-tool.ts +14 -15
- package/src/vfs/write-file-tool.ts +6 -5
- package/test/harness.test.ts +1 -1
- package/test/reminder-store.test.ts +193 -4
- package/test/storage-engine.test.ts +25 -0
package/package.json
CHANGED
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.
|
|
1448
|
-
this.registerIfMissing(
|
|
1449
|
-
this.registerIfMissing(
|
|
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
|
-
|
|
1803
|
-
if (!enablePromptCache) {
|
|
1836
|
+
if (hasFullToolResults) {
|
|
1804
1837
|
console.info(
|
|
1805
|
-
`[poncho][cost] Prompt cache
|
|
1806
|
-
`
|
|
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
|
|
1811
|
-
`
|
|
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: ${
|
|
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
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
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
|
|
package/src/prompt-cache.ts
CHANGED
|
@@ -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
|
|
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,
|
|
36
|
-
if (
|
|
44
|
+
return messages.map((message, i) => {
|
|
45
|
+
if (i === index) {
|
|
37
46
|
return {
|
|
38
47
|
...message,
|
|
39
48
|
providerOptions: {
|
package/src/reminder-store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/reminder-tools.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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(
|
|
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(
|
|
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;
|
package/src/storage/engine.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
};
|