@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.
- package/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +216 -0
- package/dist/index.d.ts +397 -34
- package/dist/index.js +2228 -130
- package/package.json +2 -2
- package/src/harness.ts +48 -72
- package/src/index.ts +1 -0
- package/src/local-tools.ts +4 -3
- package/src/mcp.ts +9 -6
- package/src/orchestrator/continuation.ts +13 -0
- package/src/orchestrator/history.ts +69 -0
- package/src/orchestrator/index.ts +54 -0
- package/src/orchestrator/orchestrator.ts +1535 -0
- package/src/orchestrator/subagents.ts +27 -0
- package/src/orchestrator/turn.ts +367 -0
- package/src/reminder-store.ts +183 -16
- package/src/reminder-tools.ts +102 -6
- package/src/state.ts +127 -3
- package/src/storage/engine.ts +29 -10
- package/src/storage/memory-engine.ts +74 -10
- package/src/storage/postgres-engine.ts +1 -0
- package/src/storage/schema.ts +21 -0
- package/src/storage/sql-dialect.ts +294 -23
- package/src/storage/sqlite-engine.ts +1 -0
- package/src/storage/store-adapters.ts +17 -11
- package/src/telemetry.ts +10 -7
- package/src/upload-store.ts +7 -4
- 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/orchestrator.test.ts +176 -0
- package/test/reminder-store.test.ts +193 -4
- package/test/state.test.ts +21 -0
- package/test/storage-engine.test.ts +80 -0
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/src/storage/engine.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/storage/schema.ts
CHANGED
|
@@ -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
|
];
|