@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.
Files changed (57) hide show
  1. package/docs/architecture/memory.md +105 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/archive-recall.test.ts +560 -0
  4. package/src/__tests__/conversation-clear-safety.test.ts +259 -0
  5. package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
  6. package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
  7. package/src/__tests__/memory-reducer-job.test.ts +538 -0
  8. package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
  9. package/src/__tests__/memory-reducer-types.test.ts +12 -4
  10. package/src/__tests__/memory-reducer.test.ts +7 -1
  11. package/src/__tests__/memory-regressions.test.ts +24 -4
  12. package/src/__tests__/memory-simplified-config.test.ts +4 -4
  13. package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
  14. package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
  15. package/src/cli/commands/conversations.ts +18 -0
  16. package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
  17. package/src/config/loader.ts +0 -1
  18. package/src/config/schemas/memory-simplified.ts +1 -1
  19. package/src/daemon/conversation-memory.ts +117 -0
  20. package/src/daemon/conversation-runtime-assembly.ts +1 -0
  21. package/src/daemon/handlers/conversations.ts +11 -0
  22. package/src/daemon/lifecycle.ts +44 -1
  23. package/src/memory/archive-recall.ts +516 -0
  24. package/src/memory/brief-time.ts +5 -4
  25. package/src/memory/conversation-crud.ts +210 -0
  26. package/src/memory/conversation-key-store.ts +33 -4
  27. package/src/memory/db-init.ts +4 -0
  28. package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
  29. package/src/memory/job-handlers/conversation-starters.ts +9 -3
  30. package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
  31. package/src/memory/jobs-store.ts +2 -0
  32. package/src/memory/jobs-worker.ts +8 -0
  33. package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
  34. package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
  35. package/src/memory/migrations/141-rename-verification-table.ts +8 -0
  36. package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
  37. package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
  38. package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
  39. package/src/memory/migrations/index.ts +1 -0
  40. package/src/memory/reducer-scheduler.ts +242 -0
  41. package/src/memory/reducer-types.ts +9 -2
  42. package/src/memory/reducer.ts +25 -11
  43. package/src/memory/schema/infrastructure.ts +1 -0
  44. package/src/runtime/auth/route-policy.ts +10 -1
  45. package/src/runtime/routes/conversation-management-routes.ts +88 -2
  46. package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
  47. package/src/runtime/routes/secret-routes.ts +1 -0
  48. package/src/schedule/schedule-store.ts +7 -0
  49. package/src/schedule/scheduler.ts +6 -2
  50. package/src/telemetry/usage-telemetry-reporter.ts +1 -1
  51. package/src/tools/filesystem/edit.ts +6 -1
  52. package/src/tools/filesystem/read.ts +6 -1
  53. package/src/tools/filesystem/write.ts +6 -1
  54. package/src/tools/memory/handlers.ts +129 -1
  55. package/src/tools/schedule/create.ts +3 -0
  56. package/src/tools/schedule/list.ts +5 -1
  57. 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
- * An empty result used as fallback when the reducer output is invalid or
90
- * unparseable. Guarantees no side-effects on the DB.
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[],
@@ -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 any invalid output
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, missing top-level keys, wrong types)
377
- * the function returns EMPTY_REDUCER_RESULT rather than throwing. Individual
378
- * invalid operations within an otherwise valid structure are silently dropped
379
- * to preserve the rest of the result.
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
- * However, if **all four** top-level arrays are absent or not arrays, the
382
- * entire output is treated as invalid and returns the empty result.
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 that at least one top-level array key exists
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.warn(
415
- "reducer output has no recognized top-level arraysfalling back to empty result",
423
+ log.debug(
424
+ "reducer output is valid JSON with no extractions advancing with empty result",
416
425
  );
417
- return EMPTY_REDUCER_RESULT;
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:DELETE", scopes: ["chat.write"] },
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 (forwardedIp && !isPrivateAddress(forwardedIp) && !isHttpAuthDisabled()) {
96
- return httpError("FORBIDDEN", "Bootstrap endpoint is local-only", 403);
97
- }
98
-
99
- // Reject non-private-network peers (allows loopback, Docker bridge, etc.)
100
- const peerIp = server.requestIP(req)?.address;
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
 
@@ -182,6 +182,7 @@ export async function handleAddSecret(
182
182
  500,
183
183
  );
184
184
  }
185
+ clearEmbeddingBackendCache();
185
186
  invalidateConfigCache();
186
187
  await initializeProviders(getConfig());
187
188
  log.info({ provider: name }, "API key updated via HTTP");
@@ -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
- notifySchedule({ id: job.id, name: job.name });
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
- notifySchedule({ id: job.id, name: job.name });
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
- installation_id: getDeviceId(),
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
  }