@poncho-ai/harness 0.31.3 → 0.32.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.31.3",
3
+ "version": "0.32.0",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
package/src/config.ts CHANGED
@@ -134,6 +134,12 @@ export interface PonchoConfig extends McpConfig {
134
134
  lambda?: Record<string, unknown>;
135
135
  fly?: Record<string, unknown>;
136
136
  };
137
+ /** One-off reminders. When enabled, the agent gets set_reminder / list_reminders / cancel_reminder tools. */
138
+ reminders?: {
139
+ enabled?: boolean;
140
+ /** Cron expression controlling how often the reminder poll runs (local and serverless). Default: every 10 minutes. */
141
+ pollSchedule?: string;
142
+ };
137
143
  /** Set to `false` to disable the built-in web UI (headless / API-only mode). */
138
144
  webUi?: false;
139
145
  /** Enable browser automation tools. Set `true` for defaults, or provide config. */
package/src/harness.ts CHANGED
@@ -24,6 +24,8 @@ import {
24
24
  type MemoryStore,
25
25
  } from "./memory.js";
26
26
  import { createTodoStore, createTodoTools, type TodoItem, type TodoStore } from "./todo-tools.js";
27
+ import { createReminderStore, type ReminderStore } from "./reminder-store.js";
28
+ import { createReminderTools } from "./reminder-tools.js";
27
29
  import { LocalMcpBridge } from "./mcp.js";
28
30
  import { createModelProvider, getModelContextWindow, type ModelProviderFactory, type ProviderConfig } from "./model-factory.js";
29
31
  import { buildSkillContextWindow, loadSkillMetadata } from "./skill-context.js";
@@ -638,6 +640,7 @@ export class AgentHarness {
638
640
  private skillContextWindow = "";
639
641
  private memoryStore?: MemoryStore;
640
642
  private todoStore?: TodoStore;
643
+ reminderStore?: ReminderStore;
641
644
  private loadedConfig?: PonchoConfig;
642
645
  private loadedSkills: SkillMetadata[] = [];
643
646
  private skillFingerprint = "";
@@ -1332,6 +1335,15 @@ export class AgentHarness {
1332
1335
  }
1333
1336
  }
1334
1337
 
1338
+ if (config?.reminders?.enabled) {
1339
+ this.reminderStore = createReminderStore(agentId, stateConfig, { workingDir: this.workingDir });
1340
+ for (const tool of createReminderTools(this.reminderStore)) {
1341
+ if (this.isToolEnabled(tool.name)) {
1342
+ this.registerIfMissing(tool);
1343
+ }
1344
+ }
1345
+ }
1346
+
1335
1347
  if (config?.browser) {
1336
1348
  await this.initBrowserTools(config)
1337
1349
  .catch((e) => {
@@ -1839,7 +1851,10 @@ ${boundedMainMemory.trim()}`
1839
1851
  const promptWithSkills = this.skillContextWindow
1840
1852
  ? `${agentPrompt}${developmentContext}\n\n${this.skillContextWindow}${browserContext}`
1841
1853
  : `${agentPrompt}${developmentContext}${browserContext}`;
1842
- return `${promptWithSkills}${memoryContext}
1854
+ const timeContext = this.reminderStore
1855
+ ? `\n\nCurrent UTC time: ${new Date().toISOString()}`
1856
+ : "";
1857
+ return `${promptWithSkills}${memoryContext}${timeContext}
1843
1858
 
1844
1859
  ## Execution Integrity
1845
1860
 
package/src/index.ts CHANGED
@@ -13,6 +13,8 @@ export * from "./schema-converter.js";
13
13
  export * from "./search-tools.js";
14
14
  export * from "./skill-context.js";
15
15
  export * from "./skill-tools.js";
16
+ export * from "./reminder-store.js";
17
+ export * from "./reminder-tools.js";
16
18
  export * from "./state.js";
17
19
  export * from "./upload-store.js";
18
20
  export * from "./telemetry.js";
@@ -0,0 +1,334 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import type { StateConfig } from "./state.js";
4
+ import {
5
+ ensureAgentIdentity,
6
+ getAgentStoreDirectory,
7
+ slugifyStorageComponent,
8
+ STORAGE_SCHEMA_VERSION,
9
+ } from "./agent-identity.js";
10
+ import { createRawKVStore, type RawKVStore } from "./kv-store.js";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Data model
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export type ReminderStatus = "pending" | "fired" | "cancelled";
17
+
18
+ export interface Reminder {
19
+ id: string;
20
+ task: string;
21
+ scheduledAt: number;
22
+ timezone?: string;
23
+ status: ReminderStatus;
24
+ createdAt: number;
25
+ conversationId: string;
26
+ ownerId?: string;
27
+ }
28
+
29
+ export interface ReminderStore {
30
+ list(): Promise<Reminder[]>;
31
+ create(input: {
32
+ task: string;
33
+ scheduledAt: number;
34
+ timezone?: string;
35
+ conversationId: string;
36
+ ownerId?: string;
37
+ }): Promise<Reminder>;
38
+ cancel(id: string): Promise<Reminder>;
39
+ delete(id: string): Promise<void>;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Helpers
44
+ // ---------------------------------------------------------------------------
45
+
46
+ const REMINDERS_FILE = "reminders.json";
47
+ const STALE_CANCELLED_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
48
+
49
+ const writeJsonAtomic = async (filePath: string, payload: unknown): Promise<void> => {
50
+ await mkdir(dirname(filePath), { recursive: true });
51
+ const tmpPath = `${filePath}.tmp`;
52
+ await writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8");
53
+ await rename(tmpPath, filePath);
54
+ };
55
+
56
+ const isValidReminder = (item: unknown): item is Reminder =>
57
+ typeof item === "object" &&
58
+ item !== null &&
59
+ typeof (item as Record<string, unknown>).id === "string" &&
60
+ typeof (item as Record<string, unknown>).task === "string" &&
61
+ typeof (item as Record<string, unknown>).scheduledAt === "number" &&
62
+ typeof (item as Record<string, unknown>).status === "string";
63
+
64
+ const parseReminderList = (raw: unknown): Reminder[] => {
65
+ if (!Array.isArray(raw)) return [];
66
+ return raw.filter(isValidReminder);
67
+ };
68
+
69
+ /** Remove cancelled reminders older than 7 days. Fired reminders are deleted immediately on fire. */
70
+ const pruneStale = (reminders: Reminder[]): Reminder[] => {
71
+ const cutoff = Date.now() - STALE_CANCELLED_MS;
72
+ return reminders.filter(
73
+ (r) => r.status === "pending" || r.createdAt > cutoff,
74
+ );
75
+ };
76
+
77
+ const generateId = (): string =>
78
+ (globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`).slice(0, 8);
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // InMemoryReminderStore
82
+ // ---------------------------------------------------------------------------
83
+
84
+ class InMemoryReminderStore implements ReminderStore {
85
+ private reminders: Reminder[] = [];
86
+
87
+ async list(): Promise<Reminder[]> {
88
+ return [...this.reminders];
89
+ }
90
+
91
+ async create(input: {
92
+ task: string;
93
+ scheduledAt: number;
94
+ timezone?: string;
95
+ conversationId: string;
96
+ ownerId?: string;
97
+ }): Promise<Reminder> {
98
+ const reminder: Reminder = {
99
+ id: generateId(),
100
+ task: input.task,
101
+ scheduledAt: input.scheduledAt,
102
+ timezone: input.timezone,
103
+ status: "pending",
104
+ createdAt: Date.now(),
105
+ conversationId: input.conversationId,
106
+ ownerId: input.ownerId,
107
+ };
108
+ this.reminders = pruneStale(this.reminders);
109
+ this.reminders.push(reminder);
110
+ return reminder;
111
+ }
112
+
113
+ async cancel(id: string): Promise<Reminder> {
114
+ const reminder = this.reminders.find((r) => r.id === id);
115
+ if (!reminder) throw new Error(`Reminder "${id}" not found`);
116
+ if (reminder.status !== "pending") {
117
+ throw new Error(`Reminder "${id}" is already ${reminder.status}`);
118
+ }
119
+ reminder.status = "cancelled";
120
+ return reminder;
121
+ }
122
+
123
+ async delete(id: string): Promise<void> {
124
+ this.reminders = this.reminders.filter((r) => r.id !== id);
125
+ }
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // FileReminderStore — single JSON file for all reminders
130
+ // ---------------------------------------------------------------------------
131
+
132
+ class FileReminderStore implements ReminderStore {
133
+ private readonly workingDir: string;
134
+ private filePath = "";
135
+
136
+ constructor(workingDir: string) {
137
+ this.workingDir = workingDir;
138
+ }
139
+
140
+ private async ensureFilePath(): Promise<string> {
141
+ if (this.filePath) return this.filePath;
142
+ const identity = await ensureAgentIdentity(this.workingDir);
143
+ this.filePath = resolve(getAgentStoreDirectory(identity), REMINDERS_FILE);
144
+ return this.filePath;
145
+ }
146
+
147
+ private async readAll(): Promise<Reminder[]> {
148
+ try {
149
+ const fp = await this.ensureFilePath();
150
+ const raw = await readFile(fp, "utf8");
151
+ return parseReminderList(JSON.parse(raw));
152
+ } catch {
153
+ return [];
154
+ }
155
+ }
156
+
157
+ private async writeAll(reminders: Reminder[]): Promise<void> {
158
+ const fp = await this.ensureFilePath();
159
+ await writeJsonAtomic(fp, reminders);
160
+ }
161
+
162
+ async list(): Promise<Reminder[]> {
163
+ return this.readAll();
164
+ }
165
+
166
+ async create(input: {
167
+ task: string;
168
+ scheduledAt: number;
169
+ timezone?: string;
170
+ conversationId: string;
171
+ ownerId?: string;
172
+ }): Promise<Reminder> {
173
+ const reminder: Reminder = {
174
+ id: generateId(),
175
+ task: input.task,
176
+ scheduledAt: input.scheduledAt,
177
+ timezone: input.timezone,
178
+ status: "pending",
179
+ createdAt: Date.now(),
180
+ conversationId: input.conversationId,
181
+ ownerId: input.ownerId,
182
+ };
183
+ let reminders = await this.readAll();
184
+ reminders = pruneStale(reminders);
185
+ reminders.push(reminder);
186
+ await this.writeAll(reminders);
187
+ return reminder;
188
+ }
189
+
190
+ async cancel(id: string): Promise<Reminder> {
191
+ const reminders = await this.readAll();
192
+ const reminder = reminders.find((r) => r.id === id);
193
+ if (!reminder) throw new Error(`Reminder "${id}" not found`);
194
+ if (reminder.status !== "pending") {
195
+ throw new Error(`Reminder "${id}" is already ${reminder.status}`);
196
+ }
197
+ reminder.status = "cancelled";
198
+ await this.writeAll(reminders);
199
+ return reminder;
200
+ }
201
+
202
+ async delete(id: string): Promise<void> {
203
+ const reminders = await this.readAll();
204
+ await this.writeAll(reminders.filter((r) => r.id !== id));
205
+ }
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // KVBackedReminderStore — wraps any RawKVStore (Upstash, Redis, DynamoDB)
210
+ // ---------------------------------------------------------------------------
211
+
212
+ class KVBackedReminderStore implements ReminderStore {
213
+ private readonly kv: RawKVStore;
214
+ private readonly key: string;
215
+ private readonly ttl?: number;
216
+ private readonly memoryFallback = new InMemoryReminderStore();
217
+
218
+ constructor(kv: RawKVStore, key: string, ttl?: number) {
219
+ this.kv = kv;
220
+ this.key = key;
221
+ this.ttl = ttl;
222
+ }
223
+
224
+ private async readAll(): Promise<Reminder[]> {
225
+ try {
226
+ const raw = await this.kv.get(this.key);
227
+ if (!raw) return [];
228
+ return parseReminderList(JSON.parse(raw));
229
+ } catch {
230
+ return this.memoryFallback.list();
231
+ }
232
+ }
233
+
234
+ private async writeAll(reminders: Reminder[]): Promise<void> {
235
+ try {
236
+ const serialized = JSON.stringify(reminders);
237
+ if (typeof this.ttl === "number") {
238
+ await this.kv.setWithTtl(this.key, serialized, Math.max(1, this.ttl));
239
+ } else {
240
+ await this.kv.set(this.key, serialized);
241
+ }
242
+ } catch {
243
+ // KV write failed; operations already applied in-memory via caller
244
+ }
245
+ }
246
+
247
+ async list(): Promise<Reminder[]> {
248
+ return this.readAll();
249
+ }
250
+
251
+ async create(input: {
252
+ task: string;
253
+ scheduledAt: number;
254
+ timezone?: string;
255
+ conversationId: string;
256
+ ownerId?: string;
257
+ }): Promise<Reminder> {
258
+ let reminders: Reminder[];
259
+ try {
260
+ reminders = await this.readAll();
261
+ } catch {
262
+ return this.memoryFallback.create(input);
263
+ }
264
+ const reminder: Reminder = {
265
+ id: generateId(),
266
+ task: input.task,
267
+ scheduledAt: input.scheduledAt,
268
+ timezone: input.timezone,
269
+ status: "pending",
270
+ createdAt: Date.now(),
271
+ conversationId: input.conversationId,
272
+ ownerId: input.ownerId,
273
+ };
274
+ reminders = pruneStale(reminders);
275
+ reminders.push(reminder);
276
+ await this.writeAll(reminders);
277
+ return reminder;
278
+ }
279
+
280
+ async cancel(id: string): Promise<Reminder> {
281
+ let reminders: Reminder[];
282
+ try {
283
+ reminders = await this.readAll();
284
+ } catch {
285
+ return this.memoryFallback.cancel(id);
286
+ }
287
+ const reminder = reminders.find((r) => r.id === id);
288
+ if (!reminder) throw new Error(`Reminder "${id}" not found`);
289
+ if (reminder.status !== "pending") {
290
+ throw new Error(`Reminder "${id}" is already ${reminder.status}`);
291
+ }
292
+ reminder.status = "cancelled";
293
+ await this.writeAll(reminders);
294
+ return reminder;
295
+ }
296
+
297
+ async delete(id: string): Promise<void> {
298
+ let reminders: Reminder[];
299
+ try {
300
+ reminders = await this.readAll();
301
+ } catch {
302
+ return this.memoryFallback.delete(id);
303
+ }
304
+ await this.writeAll(reminders.filter((r) => r.id !== id));
305
+ }
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Factory
310
+ // ---------------------------------------------------------------------------
311
+
312
+ export const createReminderStore = (
313
+ agentId: string,
314
+ config?: StateConfig,
315
+ options?: { workingDir?: string },
316
+ ): ReminderStore => {
317
+ const provider = config?.provider ?? "local";
318
+ const ttl = config?.ttl;
319
+ const workingDir = options?.workingDir ?? process.cwd();
320
+
321
+ if (provider === "local") {
322
+ return new FileReminderStore(workingDir);
323
+ }
324
+ if (provider === "memory") {
325
+ return new InMemoryReminderStore();
326
+ }
327
+
328
+ const kv = createRawKVStore(config);
329
+ if (kv) {
330
+ const key = `poncho:${STORAGE_SCHEMA_VERSION}:${slugifyStorageComponent(agentId)}:reminders`;
331
+ return new KVBackedReminderStore(kv, key, ttl);
332
+ }
333
+ return new InMemoryReminderStore();
334
+ };
@@ -0,0 +1,168 @@
1
+ import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
2
+ import type { ReminderStore, ReminderStatus } from "./reminder-store.js";
3
+
4
+ const VALID_STATUSES: ReminderStatus[] = ["pending", "cancelled"];
5
+
6
+ export const createReminderTools = (store: ReminderStore): ToolDefinition[] => [
7
+ defineTool({
8
+ name: "set_reminder",
9
+ description:
10
+ "Set a one-time reminder that will fire at the specified date and time. " +
11
+ "Use this when the user asks to be reminded about something. " +
12
+ "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
+ inputSchema: {
15
+ type: "object",
16
+ properties: {
17
+ task: {
18
+ type: "string",
19
+ description: "What to remind about",
20
+ },
21
+ datetime: {
22
+ type: "string",
23
+ description:
24
+ "ISO 8601 datetime for when the reminder should fire (e.g. '2026-03-23T09:00:00Z')",
25
+ },
26
+ timezone: {
27
+ type: "string",
28
+ description:
29
+ "IANA timezone for interpreting the datetime if it lacks an offset (e.g. 'America/New_York'). Defaults to UTC.",
30
+ },
31
+ },
32
+ required: ["task", "datetime"],
33
+ additionalProperties: false,
34
+ },
35
+ handler: async (input, context) => {
36
+ const task = typeof input.task === "string" ? input.task.trim() : "";
37
+ if (!task) throw new Error("task is required");
38
+
39
+ const datetimeStr = typeof input.datetime === "string" ? input.datetime.trim() : "";
40
+ if (!datetimeStr) throw new Error("datetime is required");
41
+
42
+ const timezone = typeof input.timezone === "string" ? input.timezone.trim() : undefined;
43
+
44
+ let scheduledAt: number;
45
+ if (timezone && !datetimeStr.includes("Z") && !/[+-]\d{2}:\d{2}$/.test(datetimeStr)) {
46
+ try {
47
+ const formatted = new Intl.DateTimeFormat("en-US", {
48
+ timeZone: timezone,
49
+ year: "numeric",
50
+ month: "2-digit",
51
+ day: "2-digit",
52
+ hour: "2-digit",
53
+ minute: "2-digit",
54
+ second: "2-digit",
55
+ hour12: false,
56
+ }).format(new Date());
57
+ void formatted;
58
+ } catch {
59
+ throw new Error(`Invalid timezone: "${timezone}"`);
60
+ }
61
+ const baseDate = new Date(datetimeStr);
62
+ if (isNaN(baseDate.getTime())) {
63
+ throw new Error(`Invalid datetime: "${datetimeStr}"`);
64
+ }
65
+ const utcStr = baseDate.toLocaleString("en-US", { timeZone: "UTC" });
66
+ const tzStr = baseDate.toLocaleString("en-US", { timeZone: timezone });
67
+ const offsetMs = new Date(utcStr).getTime() - new Date(tzStr).getTime();
68
+ scheduledAt = baseDate.getTime() + offsetMs;
69
+ } else {
70
+ const parsed = new Date(datetimeStr);
71
+ if (isNaN(parsed.getTime())) {
72
+ throw new Error(`Invalid datetime: "${datetimeStr}"`);
73
+ }
74
+ scheduledAt = parsed.getTime();
75
+ }
76
+
77
+ if (scheduledAt <= Date.now()) {
78
+ throw new Error("Reminder datetime must be in the future");
79
+ }
80
+
81
+ const conversationId = context.conversationId || context.runId;
82
+ const reminder = await store.create({
83
+ task,
84
+ scheduledAt,
85
+ timezone,
86
+ conversationId,
87
+ });
88
+
89
+ return {
90
+ ok: true,
91
+ reminder: {
92
+ id: reminder.id,
93
+ task: reminder.task,
94
+ scheduledAt: new Date(reminder.scheduledAt).toISOString(),
95
+ timezone: reminder.timezone ?? "UTC",
96
+ status: reminder.status,
97
+ },
98
+ };
99
+ },
100
+ }),
101
+
102
+ defineTool({
103
+ name: "list_reminders",
104
+ description:
105
+ "List reminders for this agent. Returns all reminders by default; " +
106
+ "use the status filter to show only pending or cancelled ones. " +
107
+ "Fired reminders are automatically deleted after delivery.",
108
+ inputSchema: {
109
+ type: "object",
110
+ properties: {
111
+ status: {
112
+ type: "string",
113
+ enum: VALID_STATUSES,
114
+ description: "Filter by status (omit to list all)",
115
+ },
116
+ },
117
+ additionalProperties: false,
118
+ },
119
+ handler: async (input) => {
120
+ let reminders = await store.list();
121
+ const status = typeof input.status === "string" ? input.status : undefined;
122
+ if (status && VALID_STATUSES.includes(status as ReminderStatus)) {
123
+ reminders = reminders.filter((r) => r.status === status);
124
+ }
125
+ return {
126
+ reminders: reminders.map((r) => ({
127
+ id: r.id,
128
+ task: r.task,
129
+ scheduledAt: new Date(r.scheduledAt).toISOString(),
130
+ timezone: r.timezone ?? "UTC",
131
+ status: r.status,
132
+ createdAt: new Date(r.createdAt).toISOString(),
133
+ })),
134
+ count: reminders.length,
135
+ };
136
+ },
137
+ }),
138
+
139
+ defineTool({
140
+ name: "cancel_reminder",
141
+ description: "Cancel a pending reminder by its ID.",
142
+ inputSchema: {
143
+ type: "object",
144
+ properties: {
145
+ id: {
146
+ type: "string",
147
+ description: "ID of the reminder to cancel",
148
+ },
149
+ },
150
+ required: ["id"],
151
+ additionalProperties: false,
152
+ },
153
+ handler: async (input) => {
154
+ const id = typeof input.id === "string" ? input.id.trim() : "";
155
+ if (!id) throw new Error("id is required");
156
+ const cancelled = await store.cancel(id);
157
+ return {
158
+ ok: true,
159
+ reminder: {
160
+ id: cancelled.id,
161
+ task: cancelled.task,
162
+ scheduledAt: new Date(cancelled.scheduledAt).toISOString(),
163
+ status: cancelled.status,
164
+ },
165
+ };
166
+ },
167
+ }),
168
+ ];