@vellumai/assistant 0.5.3 → 0.5.4
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/docs/architecture/memory.md +105 -0
- package/package.json +1 -1
- package/src/__tests__/archive-recall.test.ts +560 -0
- package/src/__tests__/conversation-clear-safety.test.ts +259 -0
- package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
- package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
- package/src/__tests__/memory-reducer-job.test.ts +538 -0
- package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
- package/src/__tests__/memory-reducer-types.test.ts +12 -4
- package/src/__tests__/memory-reducer.test.ts +7 -1
- package/src/__tests__/memory-regressions.test.ts +24 -4
- package/src/__tests__/memory-simplified-config.test.ts +4 -4
- package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
- package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
- package/src/cli/commands/conversations.ts +18 -0
- package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
- package/src/config/loader.ts +0 -1
- package/src/config/schemas/memory-simplified.ts +1 -1
- package/src/daemon/conversation-memory.ts +117 -0
- package/src/daemon/conversation-runtime-assembly.ts +1 -0
- package/src/daemon/handlers/conversations.ts +11 -0
- package/src/daemon/lifecycle.ts +44 -1
- package/src/memory/archive-recall.ts +516 -0
- package/src/memory/brief-time.ts +5 -4
- package/src/memory/conversation-crud.ts +210 -0
- package/src/memory/conversation-key-store.ts +33 -4
- package/src/memory/db-init.ts +4 -0
- package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
- package/src/memory/job-handlers/conversation-starters.ts +9 -3
- package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
- package/src/memory/jobs-store.ts +2 -0
- package/src/memory/jobs-worker.ts +8 -0
- package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
- package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
- package/src/memory/migrations/141-rename-verification-table.ts +8 -0
- package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
- package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
- package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/reducer-scheduler.ts +242 -0
- package/src/memory/reducer-types.ts +9 -2
- package/src/memory/reducer.ts +25 -11
- package/src/memory/schema/infrastructure.ts +1 -0
- package/src/runtime/auth/route-policy.ts +10 -1
- package/src/runtime/routes/conversation-management-routes.ts +88 -2
- package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
- package/src/runtime/routes/secret-routes.ts +1 -0
- package/src/schedule/schedule-store.ts +7 -0
- package/src/schedule/scheduler.ts +6 -2
- package/src/telemetry/usage-telemetry-reporter.ts +1 -1
- package/src/tools/filesystem/edit.ts +6 -1
- package/src/tools/filesystem/read.ts +6 -1
- package/src/tools/filesystem/write.ts +6 -1
- package/src/tools/memory/handlers.ts +129 -1
- package/src/tools/schedule/create.ts +3 -0
- package/src/tools/schedule/list.ts +5 -1
- package/src/tools/schedule/update.ts +6 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reducer scheduler — synchronous pre-switch/create reduction of the most
|
|
3
|
+
* recently updated dirty conversation.
|
|
4
|
+
*
|
|
5
|
+
* When the user switches conversations or starts a new one, we want the
|
|
6
|
+
* *previous* conversation's memory to be reduced before the next memory
|
|
7
|
+
* read. This module exposes {@link reduceBeforeSwitch} which:
|
|
8
|
+
*
|
|
9
|
+
* 1. Finds the single most recently updated dirty conversation (excluding
|
|
10
|
+
* the target conversation).
|
|
11
|
+
* 2. Runs the same reduction pipeline the background job uses (load
|
|
12
|
+
* unreduced messages, call {@link runReducer}, apply via
|
|
13
|
+
* {@link applyReducerResult}).
|
|
14
|
+
* 3. Awaits the result so the caller can proceed knowing memory is fresh.
|
|
15
|
+
*
|
|
16
|
+
* If no eligible dirty conversation exists, the function returns immediately.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { and, asc, desc, eq, gte, isNotNull, ne } from "drizzle-orm";
|
|
20
|
+
|
|
21
|
+
import { getLogger } from "../util/logger.js";
|
|
22
|
+
import { type ConversationRow, getConversation } from "./conversation-crud.js";
|
|
23
|
+
import { getDb } from "./db.js";
|
|
24
|
+
import { type ReducerPromptInput, runReducer } from "./reducer.js";
|
|
25
|
+
import {
|
|
26
|
+
applyReducerResult,
|
|
27
|
+
getActiveOpenLoops,
|
|
28
|
+
getActiveTimeContexts,
|
|
29
|
+
} from "./reducer-store.js";
|
|
30
|
+
import { EMPTY_REDUCER_RESULT } from "./reducer-types.js";
|
|
31
|
+
import { conversations, messages } from "./schema.js";
|
|
32
|
+
|
|
33
|
+
const log = getLogger("reducer-scheduler");
|
|
34
|
+
|
|
35
|
+
// ── Internal helpers ────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
interface MessageRow {
|
|
38
|
+
id: string;
|
|
39
|
+
role: string;
|
|
40
|
+
content: string;
|
|
41
|
+
createdAt: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Find the single most recently updated dirty conversation, excluding
|
|
46
|
+
* the target conversation. Returns the conversation ID or null if none.
|
|
47
|
+
*/
|
|
48
|
+
export function findMostRecentDirtyConversation(
|
|
49
|
+
excludeConversationId: string,
|
|
50
|
+
): string | null {
|
|
51
|
+
const db = getDb();
|
|
52
|
+
const row = db
|
|
53
|
+
.select({ id: conversations.id })
|
|
54
|
+
.from(conversations)
|
|
55
|
+
.where(
|
|
56
|
+
and(
|
|
57
|
+
isNotNull(conversations.memoryDirtyTailSinceMessageId),
|
|
58
|
+
ne(conversations.id, excludeConversationId),
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
.orderBy(desc(conversations.updatedAt))
|
|
62
|
+
.limit(1)
|
|
63
|
+
.get();
|
|
64
|
+
|
|
65
|
+
return row?.id ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Load messages from `dirtyTailMessageId` onward (inclusive), ordered by
|
|
70
|
+
* createdAt ascending.
|
|
71
|
+
*/
|
|
72
|
+
function loadUnreducedMessages(
|
|
73
|
+
conversationId: string,
|
|
74
|
+
dirtyTailMessageId: string,
|
|
75
|
+
): MessageRow[] {
|
|
76
|
+
const db = getDb();
|
|
77
|
+
|
|
78
|
+
const tailMessage = db
|
|
79
|
+
.select({ createdAt: messages.createdAt })
|
|
80
|
+
.from(messages)
|
|
81
|
+
.where(eq(messages.id, dirtyTailMessageId))
|
|
82
|
+
.get();
|
|
83
|
+
|
|
84
|
+
if (!tailMessage) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return db
|
|
89
|
+
.select({
|
|
90
|
+
id: messages.id,
|
|
91
|
+
role: messages.role,
|
|
92
|
+
content: messages.content,
|
|
93
|
+
createdAt: messages.createdAt,
|
|
94
|
+
})
|
|
95
|
+
.from(messages)
|
|
96
|
+
.where(
|
|
97
|
+
and(
|
|
98
|
+
eq(messages.conversationId, conversationId),
|
|
99
|
+
gte(messages.createdAt, tailMessage.createdAt),
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
.orderBy(asc(messages.createdAt))
|
|
103
|
+
.all();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build the `newMessages` array for the reducer input, optionally
|
|
108
|
+
* prepending the conversation's contextSummary as a synthetic system message.
|
|
109
|
+
*/
|
|
110
|
+
function buildNewMessages(
|
|
111
|
+
conversation: ConversationRow,
|
|
112
|
+
unreducedMessages: MessageRow[],
|
|
113
|
+
): Array<{ role: string; content: string }> {
|
|
114
|
+
const result: Array<{ role: string; content: string }> = [];
|
|
115
|
+
|
|
116
|
+
if (conversation.contextSummary) {
|
|
117
|
+
result.push({
|
|
118
|
+
role: "system",
|
|
119
|
+
content: `[Prior context summary] ${conversation.contextSummary}`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const msg of unreducedMessages) {
|
|
124
|
+
result.push({ role: msg.role, content: msg.content });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Public API ──────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Reduce the most recently updated dirty conversation (excluding
|
|
134
|
+
* `targetConversationId`) before a conversation switch or create.
|
|
135
|
+
*
|
|
136
|
+
* This runs the full reduction pipeline synchronously (awaiting the
|
|
137
|
+
* provider call) so the caller can proceed knowing memory is fresh.
|
|
138
|
+
*
|
|
139
|
+
* Returns the conversation ID that was reduced, or null if none were eligible.
|
|
140
|
+
*/
|
|
141
|
+
export async function reduceBeforeSwitch(
|
|
142
|
+
targetConversationId: string,
|
|
143
|
+
): Promise<string | null> {
|
|
144
|
+
const dirtyConversationId =
|
|
145
|
+
findMostRecentDirtyConversation(targetConversationId);
|
|
146
|
+
|
|
147
|
+
if (!dirtyConversationId) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const conversation = getConversation(dirtyConversationId);
|
|
152
|
+
if (!conversation) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const dirtyTailMessageId = conversation.memoryDirtyTailSinceMessageId;
|
|
157
|
+
if (!dirtyTailMessageId) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Load unreduced messages ──────────────────────────────────
|
|
162
|
+
const unreducedMessages = loadUnreducedMessages(
|
|
163
|
+
dirtyConversationId,
|
|
164
|
+
dirtyTailMessageId,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
if (unreducedMessages.length === 0) {
|
|
168
|
+
log.debug(
|
|
169
|
+
{ conversationId: dirtyConversationId, dirtyTailMessageId },
|
|
170
|
+
"No messages found from dirty tail — nothing to reduce on switch",
|
|
171
|
+
);
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Load active brief-state context ──────────────────────────
|
|
176
|
+
const scopeId = conversation.memoryScopeId;
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
|
|
179
|
+
const existingTimeContexts = getActiveTimeContexts(scopeId, now);
|
|
180
|
+
const existingOpenLoops = getActiveOpenLoops(scopeId);
|
|
181
|
+
|
|
182
|
+
// ── Build reducer input ──────────────────────────────────────
|
|
183
|
+
const newMessages = buildNewMessages(conversation, unreducedMessages);
|
|
184
|
+
|
|
185
|
+
const reducerInput: ReducerPromptInput = {
|
|
186
|
+
conversationId: dirtyConversationId,
|
|
187
|
+
newMessages,
|
|
188
|
+
existingTimeContexts: existingTimeContexts.map((tc) => ({
|
|
189
|
+
id: tc.id,
|
|
190
|
+
summary: tc.summary,
|
|
191
|
+
})),
|
|
192
|
+
existingOpenLoops: existingOpenLoops.map((ol) => ({
|
|
193
|
+
id: ol.id,
|
|
194
|
+
summary: ol.summary,
|
|
195
|
+
status: ol.status,
|
|
196
|
+
})),
|
|
197
|
+
nowMs: now,
|
|
198
|
+
scopeId,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// ── Run the reducer ──────────────────────────────────────────
|
|
202
|
+
try {
|
|
203
|
+
const result = await runReducer(reducerInput);
|
|
204
|
+
|
|
205
|
+
if (result === EMPTY_REDUCER_RESULT) {
|
|
206
|
+
log.debug(
|
|
207
|
+
{ conversationId: dirtyConversationId },
|
|
208
|
+
"Reducer returned empty result on switch — not advancing checkpoint",
|
|
209
|
+
);
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Apply result transactionally ───────────────────────────
|
|
214
|
+
const lastMessage = unreducedMessages[unreducedMessages.length - 1];
|
|
215
|
+
applyReducerResult({
|
|
216
|
+
result,
|
|
217
|
+
conversationId: dirtyConversationId,
|
|
218
|
+
scopeId,
|
|
219
|
+
reducedThroughMessageId: lastMessage.id,
|
|
220
|
+
now,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
log.info(
|
|
224
|
+
{
|
|
225
|
+
conversationId: dirtyConversationId,
|
|
226
|
+
reducedThroughMessageId: lastMessage.id,
|
|
227
|
+
messageCount: unreducedMessages.length,
|
|
228
|
+
timeContextOps: result.timeContexts.length,
|
|
229
|
+
openLoopOps: result.openLoops.length,
|
|
230
|
+
},
|
|
231
|
+
"Pre-switch memory reduction completed",
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
return dirtyConversationId;
|
|
235
|
+
} catch (err) {
|
|
236
|
+
log.warn(
|
|
237
|
+
{ err, conversationId: dirtyConversationId },
|
|
238
|
+
"Pre-switch memory reduction failed — continuing with switch",
|
|
239
|
+
);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -86,8 +86,15 @@ export interface ReducerResult {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
89
|
+
* Sentinel empty result returned when the reducer output is **unparseable**
|
|
90
|
+
* (not valid JSON, not a JSON object, provider failure, etc.).
|
|
91
|
+
*
|
|
92
|
+
* Callers use identity comparison (`=== EMPTY_REDUCER_RESULT`) to detect
|
|
93
|
+
* true parse failures and skip checkpoint advancement so the job can retry.
|
|
94
|
+
*
|
|
95
|
+
* A valid-but-empty model response (e.g. `{}`) returns a normal
|
|
96
|
+
* `ReducerResult` with all empty arrays — NOT this sentinel — so the
|
|
97
|
+
* checkpoint advances and the dirty tail is cleared.
|
|
91
98
|
*/
|
|
92
99
|
export const EMPTY_REDUCER_RESULT: Readonly<ReducerResult> = Object.freeze({
|
|
93
100
|
timeContexts: Object.freeze([]) as unknown as TimeContextOp[],
|
package/src/memory/reducer.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 1. ReducerPromptInput — structured input for the provider call
|
|
6
6
|
* 2. runReducer — send the transcript span to the LLM and return a typed result
|
|
7
7
|
* 3. parseReducerOutput — raw string -> validated ReducerResult
|
|
8
|
-
* 4. Fallback to EMPTY_REDUCER_RESULT on
|
|
8
|
+
* 4. Fallback to EMPTY_REDUCER_RESULT on unparseable output (parse failures only)
|
|
9
9
|
*
|
|
10
10
|
* The reducer is intentionally side-effect-free: it never writes to the
|
|
11
11
|
* database. Callers are responsible for applying the returned ReducerResult.
|
|
@@ -373,13 +373,19 @@ function validateArchiveEpisode(raw: unknown): ArchiveEpisodeCandidate | null {
|
|
|
373
373
|
/**
|
|
374
374
|
* Parse raw model output into a validated ReducerResult.
|
|
375
375
|
*
|
|
376
|
-
* On any structural error (non-JSON,
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
376
|
+
* On any structural error (non-JSON, not a JSON object) the function returns
|
|
377
|
+
* {@link EMPTY_REDUCER_RESULT} rather than throwing — callers use identity
|
|
378
|
+
* comparison (`=== EMPTY_REDUCER_RESULT`) to detect true parse failures and
|
|
379
|
+
* skip checkpoint advancement.
|
|
380
380
|
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
381
|
+
* A valid JSON object with no recognized top-level arrays (e.g. `{}`) is
|
|
382
|
+
* treated as a **valid-but-empty** response — the model simply had nothing
|
|
383
|
+
* to extract. In this case a normal `ReducerResult` with all empty arrays
|
|
384
|
+
* is returned so that callers advance the checkpoint and clear the dirty
|
|
385
|
+
* tail, avoiding an infinite retry loop.
|
|
386
|
+
*
|
|
387
|
+
* Individual invalid operations within an otherwise valid structure are
|
|
388
|
+
* silently dropped to preserve the rest of the result.
|
|
383
389
|
*/
|
|
384
390
|
export function parseReducerOutput(raw: string): ReducerResult {
|
|
385
391
|
let parsed: unknown;
|
|
@@ -399,22 +405,30 @@ export function parseReducerOutput(raw: string): ReducerResult {
|
|
|
399
405
|
|
|
400
406
|
const obj = parsed as Record<string, unknown>;
|
|
401
407
|
|
|
402
|
-
// Check
|
|
408
|
+
// Check which top-level array keys are present
|
|
403
409
|
const hasTimeContexts = Array.isArray(obj.timeContexts);
|
|
404
410
|
const hasOpenLoops = Array.isArray(obj.openLoops);
|
|
405
411
|
const hasArchiveObservations = Array.isArray(obj.archiveObservations);
|
|
406
412
|
const hasArchiveEpisodes = Array.isArray(obj.archiveEpisodes);
|
|
407
413
|
|
|
414
|
+
// A valid JSON object with no recognized arrays (e.g. `{}`) means the
|
|
415
|
+
// model had nothing to extract — return a normal (non-sentinel) empty
|
|
416
|
+
// result so the checkpoint advances.
|
|
408
417
|
if (
|
|
409
418
|
!hasTimeContexts &&
|
|
410
419
|
!hasOpenLoops &&
|
|
411
420
|
!hasArchiveObservations &&
|
|
412
421
|
!hasArchiveEpisodes
|
|
413
422
|
) {
|
|
414
|
-
log.
|
|
415
|
-
"reducer output
|
|
423
|
+
log.debug(
|
|
424
|
+
"reducer output is valid JSON with no extractions — advancing with empty result",
|
|
416
425
|
);
|
|
417
|
-
return
|
|
426
|
+
return {
|
|
427
|
+
timeContexts: [],
|
|
428
|
+
openLoops: [],
|
|
429
|
+
archiveObservations: [],
|
|
430
|
+
archiveEpisodes: [],
|
|
431
|
+
};
|
|
418
432
|
}
|
|
419
433
|
|
|
420
434
|
const timeContexts: TimeContextOp[] = [];
|
|
@@ -24,6 +24,7 @@ export const cronJobs = sqliteTable("cron_jobs", {
|
|
|
24
24
|
routingIntent: text("routing_intent").notNull().default("all_channels"), // 'single_channel' | 'multi_channel' | 'all_channels'
|
|
25
25
|
routingHintsJson: text("routing_hints_json").notNull().default("{}"),
|
|
26
26
|
status: text("status").notNull().default("active"), // 'active' | 'firing' | 'fired' | 'cancelled'
|
|
27
|
+
quiet: integer("quiet", { mode: "boolean" }).notNull().default(false), // suppress completion notifications
|
|
27
28
|
createdAt: integer("created_at").notNull(),
|
|
28
29
|
updatedAt: integer("updated_at").notNull(),
|
|
29
30
|
});
|
|
@@ -128,7 +128,7 @@ const ACTOR_ENDPOINTS: Array<{ endpoint: string; scopes: Scope[] }> = [
|
|
|
128
128
|
{ endpoint: "messages:POST", scopes: ["chat.write"] },
|
|
129
129
|
{ endpoint: "btw", scopes: ["chat.write"] },
|
|
130
130
|
{ endpoint: "conversations", scopes: ["chat.read"] },
|
|
131
|
-
{ endpoint: "conversations:
|
|
131
|
+
{ endpoint: "conversations:POST", scopes: ["chat.write"] },
|
|
132
132
|
{ endpoint: "conversations/fork", scopes: ["chat.write"] },
|
|
133
133
|
{ endpoint: "conversations/switch", scopes: ["chat.write"] },
|
|
134
134
|
{ endpoint: "conversations/name", scopes: ["chat.write"] },
|
|
@@ -348,6 +348,7 @@ const ACTOR_ENDPOINTS: Array<{ endpoint: string; scopes: Scope[] }> = [
|
|
|
348
348
|
{ endpoint: "config/embeddings:PUT", scopes: ["settings.write"] },
|
|
349
349
|
|
|
350
350
|
// Conversation management
|
|
351
|
+
{ endpoint: "conversations:DELETE", scopes: ["chat.write"] },
|
|
351
352
|
{ endpoint: "conversations/wipe", scopes: ["chat.write"] },
|
|
352
353
|
{ endpoint: "conversations/reorder", scopes: ["chat.write"] },
|
|
353
354
|
|
|
@@ -470,6 +471,14 @@ for (const { endpoint, scopes } of ACTOR_ENDPOINTS) {
|
|
|
470
471
|
});
|
|
471
472
|
}
|
|
472
473
|
|
|
474
|
+
// Clear-all conversations: elevated to settings.write (destructive bulk operation).
|
|
475
|
+
// Uses a distinct key so the single-conversation DELETE (conversations:DELETE)
|
|
476
|
+
// retains the lower chat.write scope.
|
|
477
|
+
registerPolicy("conversations/clear-all", {
|
|
478
|
+
requiredScopes: ["settings.write"],
|
|
479
|
+
allowedPrincipalTypes: ["actor", "svc_gateway", "svc_daemon", "local"],
|
|
480
|
+
});
|
|
481
|
+
|
|
473
482
|
// Channel inbound: gateway-only
|
|
474
483
|
registerPolicy("channels/inbound", {
|
|
475
484
|
requiredScopes: ["ingress.write"],
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Route handlers for conversation management operations.
|
|
3
3
|
*
|
|
4
|
+
* POST /v1/conversations — create a new conversation
|
|
4
5
|
* POST /v1/conversations/switch — switch to an existing conversation
|
|
5
6
|
* POST /v1/conversations/fork — fork an existing conversation
|
|
6
7
|
* PATCH /v1/conversations/:id/name — rename a conversation
|
|
@@ -19,7 +20,9 @@ import {
|
|
|
19
20
|
PRIVATE_CONVERSATION_FORK_ERROR,
|
|
20
21
|
wipeConversation,
|
|
21
22
|
} from "../../memory/conversation-crud.js";
|
|
23
|
+
import { updateConversationTitle } from "../../memory/conversation-crud.js";
|
|
22
24
|
import {
|
|
25
|
+
getOrCreateConversation,
|
|
23
26
|
resolveConversationId,
|
|
24
27
|
setConversationKeyIfAbsent,
|
|
25
28
|
} from "../../memory/conversation-key-store.js";
|
|
@@ -66,6 +69,44 @@ export function conversationManagementRouteDefinitions(
|
|
|
66
69
|
deps: ConversationManagementDeps,
|
|
67
70
|
): RouteDefinition[] {
|
|
68
71
|
return [
|
|
72
|
+
{
|
|
73
|
+
endpoint: "conversations",
|
|
74
|
+
method: "POST",
|
|
75
|
+
policyKey: "conversations",
|
|
76
|
+
handler: async ({ req }) => {
|
|
77
|
+
let body: { conversationKey?: string; conversationType?: string } = {};
|
|
78
|
+
try {
|
|
79
|
+
body = (await req.json()) as typeof body;
|
|
80
|
+
} catch {
|
|
81
|
+
// Empty or malformed body — fall through with defaults.
|
|
82
|
+
}
|
|
83
|
+
const conversationKey = body.conversationKey ?? crypto.randomUUID();
|
|
84
|
+
const requestedType =
|
|
85
|
+
body.conversationType === "private" ? "private" : "standard";
|
|
86
|
+
const result = getOrCreateConversation(conversationKey, {
|
|
87
|
+
conversationType: requestedType,
|
|
88
|
+
});
|
|
89
|
+
if (result.created) {
|
|
90
|
+
updateConversationTitle(result.conversationId, "New Conversation");
|
|
91
|
+
}
|
|
92
|
+
log.info(
|
|
93
|
+
{
|
|
94
|
+
conversationId: result.conversationId,
|
|
95
|
+
conversationKey,
|
|
96
|
+
created: result.created,
|
|
97
|
+
},
|
|
98
|
+
"Created conversation via POST",
|
|
99
|
+
);
|
|
100
|
+
return Response.json(
|
|
101
|
+
{
|
|
102
|
+
id: result.conversationId,
|
|
103
|
+
conversationKey,
|
|
104
|
+
conversationType: result.conversationType,
|
|
105
|
+
},
|
|
106
|
+
{ status: result.created ? 201 : 200 },
|
|
107
|
+
);
|
|
108
|
+
},
|
|
109
|
+
},
|
|
69
110
|
{
|
|
70
111
|
endpoint: "conversations/fork",
|
|
71
112
|
method: "POST",
|
|
@@ -185,8 +226,17 @@ export function conversationManagementRouteDefinitions(
|
|
|
185
226
|
{
|
|
186
227
|
endpoint: "conversations",
|
|
187
228
|
method: "DELETE",
|
|
188
|
-
policyKey: "conversations",
|
|
189
|
-
handler: () => {
|
|
229
|
+
policyKey: "conversations/clear-all",
|
|
230
|
+
handler: ({ req }) => {
|
|
231
|
+
const confirm = req.headers.get("x-confirm-destructive");
|
|
232
|
+
if (confirm !== "clear-all-conversations") {
|
|
233
|
+
return httpError(
|
|
234
|
+
"BAD_REQUEST",
|
|
235
|
+
"DELETE /v1/conversations permanently deletes ALL conversations, messages, and memory. " +
|
|
236
|
+
"To confirm, set header X-Confirm-Destructive: clear-all-conversations",
|
|
237
|
+
400,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
190
240
|
deps.clearAllConversations();
|
|
191
241
|
return new Response(null, { status: 204 });
|
|
192
242
|
},
|
|
@@ -225,6 +275,24 @@ export function conversationManagementRouteDefinitions(
|
|
|
225
275
|
targetId: summaryId,
|
|
226
276
|
});
|
|
227
277
|
}
|
|
278
|
+
for (const obsId of result.deletedObservationIds) {
|
|
279
|
+
enqueueMemoryJob("delete_qdrant_vectors", {
|
|
280
|
+
targetType: "observation",
|
|
281
|
+
targetId: obsId,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
for (const chunkId of result.deletedChunkIds) {
|
|
285
|
+
enqueueMemoryJob("delete_qdrant_vectors", {
|
|
286
|
+
targetType: "chunk",
|
|
287
|
+
targetId: chunkId,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
for (const episodeId of result.deletedEpisodeIds) {
|
|
291
|
+
enqueueMemoryJob("delete_qdrant_vectors", {
|
|
292
|
+
targetType: "episode",
|
|
293
|
+
targetId: episodeId,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
228
296
|
log.info(
|
|
229
297
|
{
|
|
230
298
|
conversationId: resolvedId,
|
|
@@ -281,6 +349,24 @@ export function conversationManagementRouteDefinitions(
|
|
|
281
349
|
targetId: summaryId,
|
|
282
350
|
});
|
|
283
351
|
}
|
|
352
|
+
for (const obsId of deleted.deletedObservationIds) {
|
|
353
|
+
enqueueMemoryJob("delete_qdrant_vectors", {
|
|
354
|
+
targetType: "observation",
|
|
355
|
+
targetId: obsId,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
for (const chunkId of deleted.deletedChunkIds) {
|
|
359
|
+
enqueueMemoryJob("delete_qdrant_vectors", {
|
|
360
|
+
targetType: "chunk",
|
|
361
|
+
targetId: chunkId,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
for (const episodeId of deleted.deletedEpisodeIds) {
|
|
365
|
+
enqueueMemoryJob("delete_qdrant_vectors", {
|
|
366
|
+
targetType: "episode",
|
|
367
|
+
targetId: episodeId,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
284
370
|
log.info({ conversationId: resolvedId }, "Deleted conversation");
|
|
285
371
|
return new Response(null, { status: 204 });
|
|
286
372
|
},
|
|
@@ -28,6 +28,7 @@ type ServerWithRequestIP = {
|
|
|
28
28
|
): { address: string; family: string; port: number } | null;
|
|
29
29
|
};
|
|
30
30
|
import { isHttpAuthDisabled } from "../../config/env.js";
|
|
31
|
+
import { getIsContainerized } from "../../config/env-registry.js";
|
|
31
32
|
|
|
32
33
|
const log = getLogger("guardian-bootstrap");
|
|
33
34
|
|
|
@@ -86,19 +87,30 @@ export async function handleGuardianBootstrap(
|
|
|
86
87
|
req: Request,
|
|
87
88
|
server: ServerWithRequestIP,
|
|
88
89
|
): Promise<Response> {
|
|
90
|
+
// Reject non-private-network peers (allows loopback, Docker bridge, etc.)
|
|
91
|
+
const peerIp = server.requestIP(req)?.address;
|
|
92
|
+
if ((!peerIp || !isPrivateAddress(peerIp)) && !isHttpAuthDisabled()) {
|
|
93
|
+
return httpError("FORBIDDEN", "Bootstrap endpoint is local-only", 403);
|
|
94
|
+
}
|
|
95
|
+
|
|
89
96
|
// Reject requests forwarded from public networks. The gateway sets
|
|
90
97
|
// x-forwarded-for to the real client IP; if that IP is on a private
|
|
91
98
|
// network (loopback, Docker bridge, RFC 1918) the request is still
|
|
92
99
|
// considered local. Only reject when the forwarded IP is public.
|
|
100
|
+
//
|
|
101
|
+
// Skip this check when running in a container: the peer IP was already
|
|
102
|
+
// validated above (Docker bridge network = private), so the request
|
|
103
|
+
// reached us through a co-located gateway. The x-forwarded-for header
|
|
104
|
+
// reflects the original external client (e.g. platform proxy) and is
|
|
105
|
+
// not meaningful for local-only enforcement in this topology.
|
|
93
106
|
const forwarded = req.headers.get("x-forwarded-for");
|
|
94
107
|
const forwardedIp = forwarded ? forwarded.split(",")[0].trim() : null;
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if ((!peerIp || !isPrivateAddress(peerIp)) && !isHttpAuthDisabled()) {
|
|
108
|
+
if (
|
|
109
|
+
forwardedIp &&
|
|
110
|
+
!isPrivateAddress(forwardedIp) &&
|
|
111
|
+
!isHttpAuthDisabled() &&
|
|
112
|
+
!getIsContainerized()
|
|
113
|
+
) {
|
|
102
114
|
return httpError("FORBIDDEN", "Bootstrap endpoint is local-only", 403);
|
|
103
115
|
}
|
|
104
116
|
|
|
@@ -35,6 +35,7 @@ export interface ScheduleJob {
|
|
|
35
35
|
mode: ScheduleMode;
|
|
36
36
|
routingIntent: RoutingIntent;
|
|
37
37
|
routingHints: Record<string, unknown>;
|
|
38
|
+
quiet: boolean;
|
|
38
39
|
status: ScheduleStatus;
|
|
39
40
|
createdAt: number;
|
|
40
41
|
updatedAt: number;
|
|
@@ -91,6 +92,7 @@ export function createSchedule(params: {
|
|
|
91
92
|
mode?: ScheduleMode;
|
|
92
93
|
routingIntent?: RoutingIntent;
|
|
93
94
|
routingHints?: Record<string, unknown>;
|
|
95
|
+
quiet?: boolean;
|
|
94
96
|
}): ScheduleJob {
|
|
95
97
|
const expression = params.expression ?? params.cronExpression ?? null;
|
|
96
98
|
const isOneShot = expression == null;
|
|
@@ -118,6 +120,7 @@ export function createSchedule(params: {
|
|
|
118
120
|
const mode = params.mode ?? "execute";
|
|
119
121
|
const routingIntent = params.routingIntent ?? "all_channels";
|
|
120
122
|
const routingHints = params.routingHints ?? {};
|
|
123
|
+
const quiet = params.quiet ?? false;
|
|
121
124
|
|
|
122
125
|
let nextRunAt: number;
|
|
123
126
|
if (isOneShot) {
|
|
@@ -144,6 +147,7 @@ export function createSchedule(params: {
|
|
|
144
147
|
mode,
|
|
145
148
|
routingIntent,
|
|
146
149
|
routingHintsJson: JSON.stringify(routingHints),
|
|
150
|
+
quiet,
|
|
147
151
|
status: "active" as ScheduleStatus,
|
|
148
152
|
createdAt: now,
|
|
149
153
|
updatedAt: now,
|
|
@@ -236,6 +240,7 @@ export function updateSchedule(
|
|
|
236
240
|
mode?: ScheduleMode;
|
|
237
241
|
routingIntent?: RoutingIntent;
|
|
238
242
|
routingHints?: Record<string, unknown>;
|
|
243
|
+
quiet?: boolean;
|
|
239
244
|
},
|
|
240
245
|
): ScheduleJob | null {
|
|
241
246
|
const db = getDb();
|
|
@@ -290,6 +295,7 @@ export function updateSchedule(
|
|
|
290
295
|
set.routingIntent = updates.routingIntent;
|
|
291
296
|
if (updates.routingHints !== undefined)
|
|
292
297
|
set.routingHintsJson = JSON.stringify(updates.routingHints);
|
|
298
|
+
if (updates.quiet !== undefined) set.quiet = updates.quiet;
|
|
293
299
|
|
|
294
300
|
// Recompute nextRunAt if schedule timing may have changed (only for recurring)
|
|
295
301
|
if (
|
|
@@ -771,6 +777,7 @@ function parseJobRow(row: typeof scheduleJobs.$inferSelect): ScheduleJob {
|
|
|
771
777
|
mode: (row.mode ?? "execute") as ScheduleMode,
|
|
772
778
|
routingIntent: (row.routingIntent ?? "all_channels") as RoutingIntent,
|
|
773
779
|
routingHints: safeParseJson(row.routingHintsJson),
|
|
780
|
+
quiet: row.quiet ?? false,
|
|
774
781
|
status: (row.status ?? "active") as ScheduleStatus,
|
|
775
782
|
createdAt: row.createdAt,
|
|
776
783
|
updatedAt: row.updatedAt,
|
|
@@ -206,7 +206,9 @@ async function runScheduleOnce(
|
|
|
206
206
|
if (isOneShot) failOneShot(job.id);
|
|
207
207
|
} else {
|
|
208
208
|
completeScheduleRun(runId, { status: "ok" });
|
|
209
|
-
|
|
209
|
+
if (!job.quiet) {
|
|
210
|
+
notifySchedule({ id: job.id, name: job.name });
|
|
211
|
+
}
|
|
210
212
|
if (isOneShot) completeOneShot(job.id);
|
|
211
213
|
}
|
|
212
214
|
processed += 1;
|
|
@@ -278,7 +280,9 @@ async function runScheduleOnce(
|
|
|
278
280
|
trustClass: "guardian",
|
|
279
281
|
});
|
|
280
282
|
completeScheduleRun(runId, { status: "ok" });
|
|
281
|
-
|
|
283
|
+
if (!job.quiet) {
|
|
284
|
+
notifySchedule({ id: job.id, name: job.name });
|
|
285
|
+
}
|
|
282
286
|
if (isOneShot) completeOneShot(job.id);
|
|
283
287
|
processed += 1;
|
|
284
288
|
} catch (err) {
|
|
@@ -193,7 +193,7 @@ export class UsageTelemetryReporter {
|
|
|
193
193
|
const organizationId = getPlatformOrganizationId() || undefined;
|
|
194
194
|
const userId = getPlatformUserId() || undefined;
|
|
195
195
|
const payload = {
|
|
196
|
-
|
|
196
|
+
device_id: getDeviceId(),
|
|
197
197
|
assistant_id: assistantId,
|
|
198
198
|
app_version: APP_VERSION,
|
|
199
199
|
...(organizationId ? { organization_id: organizationId } : {}),
|
|
@@ -38,8 +38,13 @@ class FileEditTool implements Tool {
|
|
|
38
38
|
description:
|
|
39
39
|
"Replace all occurrences of old_string instead of requiring a unique match (default: false)",
|
|
40
40
|
},
|
|
41
|
+
activity: {
|
|
42
|
+
type: "string",
|
|
43
|
+
description:
|
|
44
|
+
"Brief non-technical explanation of what you are doing and why, shown to the user as a status update.",
|
|
45
|
+
},
|
|
41
46
|
},
|
|
42
|
-
required: ["path", "old_string", "new_string"],
|
|
47
|
+
required: ["path", "old_string", "new_string", "activity"],
|
|
43
48
|
},
|
|
44
49
|
};
|
|
45
50
|
}
|
|
@@ -38,8 +38,13 @@ class FileReadTool implements Tool {
|
|
|
38
38
|
type: "number",
|
|
39
39
|
description: "Maximum number of lines to read",
|
|
40
40
|
},
|
|
41
|
+
activity: {
|
|
42
|
+
type: "string",
|
|
43
|
+
description:
|
|
44
|
+
"Brief non-technical explanation of what you are doing and why, shown to the user as a status update.",
|
|
45
|
+
},
|
|
41
46
|
},
|
|
42
|
-
required: ["path"],
|
|
47
|
+
required: ["path", "activity"],
|
|
43
48
|
},
|
|
44
49
|
};
|
|
45
50
|
}
|