@vellumai/assistant 0.5.2 → 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 (144) hide show
  1. package/ARCHITECTURE.md +109 -0
  2. package/docs/architecture/memory.md +105 -0
  3. package/docs/skills.md +100 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/archive-recall.test.ts +560 -0
  6. package/src/__tests__/conversation-agent-loop-overflow.test.ts +7 -0
  7. package/src/__tests__/conversation-agent-loop.test.ts +7 -0
  8. package/src/__tests__/conversation-clear-safety.test.ts +259 -0
  9. package/src/__tests__/conversation-memory-dirty-tail.test.ts +150 -0
  10. package/src/__tests__/conversation-provider-retry-repair.test.ts +7 -0
  11. package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
  12. package/src/__tests__/conversation-wipe.test.ts +226 -0
  13. package/src/__tests__/db-memory-archive-migration.test.ts +372 -0
  14. package/src/__tests__/db-memory-brief-state-migration.test.ts +213 -0
  15. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +273 -0
  16. package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
  17. package/src/__tests__/inline-command-runner.test.ts +311 -0
  18. package/src/__tests__/inline-skill-authoring-guard.test.ts +220 -0
  19. package/src/__tests__/inline-skill-load-permissions.test.ts +435 -0
  20. package/src/__tests__/list-messages-attachments.test.ts +96 -0
  21. package/src/__tests__/memory-brief-open-loops.test.ts +530 -0
  22. package/src/__tests__/memory-brief-time.test.ts +285 -0
  23. package/src/__tests__/memory-brief-wrapper.test.ts +311 -0
  24. package/src/__tests__/memory-chunk-archive.test.ts +400 -0
  25. package/src/__tests__/memory-chunk-dual-write.test.ts +453 -0
  26. package/src/__tests__/memory-episode-archive.test.ts +370 -0
  27. package/src/__tests__/memory-episode-dual-write.test.ts +626 -0
  28. package/src/__tests__/memory-observation-archive.test.ts +375 -0
  29. package/src/__tests__/memory-observation-dual-write.test.ts +318 -0
  30. package/src/__tests__/memory-recall-quality.test.ts +2 -2
  31. package/src/__tests__/memory-reducer-job.test.ts +538 -0
  32. package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
  33. package/src/__tests__/memory-reducer-store.test.ts +728 -0
  34. package/src/__tests__/memory-reducer-types.test.ts +707 -0
  35. package/src/__tests__/memory-reducer.test.ts +704 -0
  36. package/src/__tests__/memory-regressions.test.ts +30 -8
  37. package/src/__tests__/memory-simplified-config.test.ts +281 -0
  38. package/src/__tests__/parse-identity-fields.test.ts +129 -0
  39. package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
  40. package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
  41. package/src/__tests__/skill-load-inline-command.test.ts +598 -0
  42. package/src/__tests__/skill-load-inline-includes.test.ts +644 -0
  43. package/src/__tests__/skills-inline-command-expansions.test.ts +301 -0
  44. package/src/__tests__/skills-transitive-hash.test.ts +333 -0
  45. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +320 -0
  46. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +4 -4
  47. package/src/cli/commands/conversations.ts +18 -0
  48. package/src/config/bundled-skills/app-builder/SKILL.md +8 -8
  49. package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
  50. package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
  51. package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
  52. package/src/config/feature-flag-registry.json +16 -0
  53. package/src/config/raw-config-utils.ts +28 -0
  54. package/src/config/schema.ts +12 -0
  55. package/src/config/schemas/memory-simplified.ts +101 -0
  56. package/src/config/schemas/memory.ts +4 -0
  57. package/src/config/skills.ts +50 -4
  58. package/src/daemon/conversation-agent-loop-handlers.ts +8 -3
  59. package/src/daemon/conversation-agent-loop.ts +71 -1
  60. package/src/daemon/conversation-lifecycle.ts +11 -1
  61. package/src/daemon/conversation-memory.ts +117 -0
  62. package/src/daemon/conversation-runtime-assembly.ts +3 -1
  63. package/src/daemon/conversation-surfaces.ts +31 -8
  64. package/src/daemon/conversation.ts +40 -23
  65. package/src/daemon/handlers/config-embeddings.ts +10 -2
  66. package/src/daemon/handlers/config-model.ts +0 -9
  67. package/src/daemon/handlers/conversations.ts +11 -0
  68. package/src/daemon/handlers/identity.ts +12 -1
  69. package/src/daemon/lifecycle.ts +52 -1
  70. package/src/daemon/message-types/conversations.ts +0 -1
  71. package/src/daemon/server.ts +1 -1
  72. package/src/followups/followup-store.ts +47 -1
  73. package/src/memory/archive-recall.ts +516 -0
  74. package/src/memory/archive-store.ts +400 -0
  75. package/src/memory/brief-formatting.ts +33 -0
  76. package/src/memory/brief-open-loops.ts +266 -0
  77. package/src/memory/brief-time.ts +162 -0
  78. package/src/memory/brief.ts +75 -0
  79. package/src/memory/conversation-crud.ts +455 -101
  80. package/src/memory/conversation-key-store.ts +33 -4
  81. package/src/memory/db-init.ts +16 -0
  82. package/src/memory/indexer.ts +106 -15
  83. package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
  84. package/src/memory/job-handlers/conversation-starters.ts +9 -3
  85. package/src/memory/job-handlers/embedding.test.ts +1 -0
  86. package/src/memory/job-handlers/embedding.ts +83 -0
  87. package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
  88. package/src/memory/job-utils.ts +1 -1
  89. package/src/memory/jobs-store.ts +8 -0
  90. package/src/memory/jobs-worker.ts +20 -0
  91. package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
  92. package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
  93. package/src/memory/migrations/141-rename-verification-table.ts +8 -0
  94. package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
  95. package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
  96. package/src/memory/migrations/185-memory-brief-state.ts +52 -0
  97. package/src/memory/migrations/186-memory-archive.ts +109 -0
  98. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +19 -0
  99. package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
  100. package/src/memory/migrations/index.ts +4 -0
  101. package/src/memory/qdrant-client.ts +23 -4
  102. package/src/memory/reducer-scheduler.ts +242 -0
  103. package/src/memory/reducer-store.ts +271 -0
  104. package/src/memory/reducer-types.ts +106 -0
  105. package/src/memory/reducer.ts +467 -0
  106. package/src/memory/schema/conversations.ts +3 -0
  107. package/src/memory/schema/index.ts +2 -0
  108. package/src/memory/schema/infrastructure.ts +1 -0
  109. package/src/memory/schema/memory-archive.ts +121 -0
  110. package/src/memory/schema/memory-brief.ts +55 -0
  111. package/src/memory/search/semantic.ts +17 -4
  112. package/src/oauth/oauth-store.ts +3 -1
  113. package/src/permissions/checker.ts +89 -6
  114. package/src/permissions/defaults.ts +14 -0
  115. package/src/runtime/auth/route-policy.ts +10 -1
  116. package/src/runtime/routes/conversation-management-routes.ts +94 -2
  117. package/src/runtime/routes/conversation-query-routes.ts +7 -0
  118. package/src/runtime/routes/conversation-routes.ts +52 -5
  119. package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
  120. package/src/runtime/routes/identity-routes.ts +2 -35
  121. package/src/runtime/routes/llm-context-normalization.ts +14 -1
  122. package/src/runtime/routes/memory-item-routes.ts +90 -5
  123. package/src/runtime/routes/secret-routes.ts +3 -0
  124. package/src/runtime/routes/surface-action-routes.ts +68 -1
  125. package/src/schedule/schedule-store.ts +28 -0
  126. package/src/schedule/scheduler.ts +6 -2
  127. package/src/skills/inline-command-expansions.ts +204 -0
  128. package/src/skills/inline-command-render.ts +127 -0
  129. package/src/skills/inline-command-runner.ts +242 -0
  130. package/src/skills/transitive-version-hash.ts +88 -0
  131. package/src/tasks/task-store.ts +43 -1
  132. package/src/telemetry/usage-telemetry-reporter.ts +1 -1
  133. package/src/tools/filesystem/edit.ts +6 -1
  134. package/src/tools/filesystem/read.ts +6 -1
  135. package/src/tools/filesystem/write.ts +6 -1
  136. package/src/tools/memory/handlers.ts +129 -1
  137. package/src/tools/permission-checker.ts +8 -1
  138. package/src/tools/schedule/create.ts +3 -0
  139. package/src/tools/schedule/list.ts +5 -1
  140. package/src/tools/schedule/update.ts +6 -0
  141. package/src/tools/skills/load.ts +140 -6
  142. package/src/util/platform.ts +18 -0
  143. package/src/workspace/migrations/{002-backfill-installation-id.ts → 011-backfill-installation-id.ts} +1 -1
  144. package/src/workspace/migrations/registry.ts +1 -1
@@ -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
 
@@ -8,6 +8,7 @@ import { dirname, join } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
 
10
10
  import { getBaseDataDir } from "../../config/env-registry.js";
11
+ import { parseIdentityFields } from "../../daemon/handlers/identity.js";
11
12
  import { getWorkspacePromptPath, readLockfile } from "../../util/platform.js";
12
13
  import { httpError } from "../http-errors.js";
13
14
  import type { RouteDefinition } from "../http-router.js";
@@ -149,41 +150,7 @@ export function handleGetIdentity(): Response {
149
150
  }
150
151
 
151
152
  const content = readFileSync(identityPath, "utf-8");
152
- const fields: Record<string, string> = {};
153
- for (const line of content.split("\n")) {
154
- const trimmed = line.trim();
155
- const lower = trimmed.toLowerCase();
156
- const extract = (prefix: string): string | null => {
157
- if (!lower.startsWith(prefix)) return null;
158
- return trimmed.split(":**").pop()?.trim() ?? null;
159
- };
160
-
161
- const name = extract("- **name:**");
162
- if (name) {
163
- fields.name = name;
164
- continue;
165
- }
166
- const role = extract("- **role:**");
167
- if (role) {
168
- fields.role = role;
169
- continue;
170
- }
171
- const personality = extract("- **personality:**") ?? extract("- **vibe:**");
172
- if (personality) {
173
- fields.personality = personality;
174
- continue;
175
- }
176
- const emoji = extract("- **emoji:**");
177
- if (emoji) {
178
- fields.emoji = emoji;
179
- continue;
180
- }
181
- const home = extract("- **home:**");
182
- if (home) {
183
- fields.home = home;
184
- continue;
185
- }
186
- }
153
+ const fields = parseIdentityFields(content);
187
154
 
188
155
  const version = getPackageVersion();
189
156
 
@@ -86,7 +86,8 @@ export function normalizeLlmContextPayloads(
86
86
  }
87
87
 
88
88
  if (requestCandidate) {
89
- return requestCandidate as LlmContextNormalizationResult;
89
+ const { summary, requestSections, responseSections } = requestCandidate;
90
+ return { summary, requestSections, responseSections };
90
91
  }
91
92
 
92
93
  if (responseCandidate) {
@@ -123,6 +124,7 @@ function normalizeOpenAiRequestPayload(
123
124
 
124
125
  const requestToolNames = extractOpenAiRequestToolNames(request.tools);
125
126
  const hasOpenAiSignal =
127
+ hasOpenAiModelPrefix(asString(request.model)) ||
126
128
  requestToolNames.length > 0 ||
127
129
  asString(request.tool_choice) !== undefined ||
128
130
  (request.parallel_tool_calls !== undefined &&
@@ -291,6 +293,7 @@ function normalizeAnthropicRequestPayload(
291
293
  }),
292
294
  );
293
295
  const hasAnthropicSignal =
296
+ hasAnthropicModelPrefix(asString(request.model)) ||
294
297
  request.system !== undefined ||
295
298
  requestToolNames.length > 0 ||
296
299
  isAnthropicToolChoice(request.tool_choice) ||
@@ -1171,6 +1174,16 @@ function normalizeCompatibleRequestPayload(
1171
1174
  }
1172
1175
  }
1173
1176
 
1177
+ function hasOpenAiModelPrefix(model: string | undefined): boolean {
1178
+ if (!model) return false;
1179
+ return /^(gpt-|chatgpt-|ft:|o[1-9]\d*(-|$))/.test(model);
1180
+ }
1181
+
1182
+ function hasAnthropicModelPrefix(model: string | undefined): boolean {
1183
+ if (!model) return false;
1184
+ return model.startsWith("claude-");
1185
+ }
1186
+
1174
1187
  function asRecord(value: unknown): Record<string, unknown> | null {
1175
1188
  if (typeof value !== "object" || value == null || Array.isArray(value)) {
1176
1189
  return null;
@@ -8,13 +8,17 @@
8
8
  * DELETE /v1/memory-items/:id — delete a memory item and its embeddings
9
9
  */
10
10
 
11
- import { and, asc, count, desc, eq, like, ne, or } from "drizzle-orm";
11
+ import { and, asc, count, desc, eq, inArray, like, ne, or } from "drizzle-orm";
12
12
  import { v4 as uuid } from "uuid";
13
13
 
14
14
  import { getDb } from "../../memory/db.js";
15
15
  import { computeMemoryFingerprint } from "../../memory/fingerprint.js";
16
16
  import { enqueueMemoryJob } from "../../memory/jobs-store.js";
17
- import { memoryEmbeddings, memoryItems } from "../../memory/schema.js";
17
+ import {
18
+ conversations,
19
+ memoryEmbeddings,
20
+ memoryItems,
21
+ } from "../../memory/schema.js";
18
22
  import { truncate } from "../../util/truncate.js";
19
23
  import { httpError } from "../http-errors.js";
20
24
  import type { RouteContext, RouteDefinition } from "../http-router.js";
@@ -64,6 +68,54 @@ function isValidSortField(value: string): value is SortField {
64
68
  return (VALID_SORT_FIELDS as readonly string[]).includes(value);
65
69
  }
66
70
 
71
+ /**
72
+ * Resolve a `scopeLabel` for a memory item based on its `scopeId`.
73
+ *
74
+ * - `"default"` → `null`
75
+ * - `"private:<conversationId>"` → `"Private · <title>"` when the conversation
76
+ * has a title, or `"Private"` when it doesn't (or the conversation was deleted).
77
+ */
78
+ function resolveScopeLabel(
79
+ scopeId: string,
80
+ titleMap: Map<string, string | null>,
81
+ ): string | null {
82
+ if (scopeId === "default") return null;
83
+ if (scopeId.startsWith("private:")) {
84
+ const conversationId = scopeId.slice("private:".length);
85
+ const title = titleMap.get(conversationId);
86
+ return title ? `Private · ${title}` : "Private";
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Batch-fetch conversation titles for a set of private-scoped memory items.
93
+ * Returns a Map from conversation ID → title (or null).
94
+ */
95
+ function buildConversationTitleMap(
96
+ db: ReturnType<typeof getDb>,
97
+ scopeIds: string[],
98
+ ): Map<string, string | null> {
99
+ const conversationIds = scopeIds
100
+ .filter((s) => s.startsWith("private:"))
101
+ .map((s) => s.slice("private:".length));
102
+
103
+ const uniqueIds = [...new Set(conversationIds)];
104
+ if (uniqueIds.length === 0) return new Map();
105
+
106
+ const rows = db
107
+ .select({ id: conversations.id, title: conversations.title })
108
+ .from(conversations)
109
+ .where(inArray(conversations.id, uniqueIds))
110
+ .all();
111
+
112
+ const map = new Map<string, string | null>();
113
+ for (const row of rows) {
114
+ map.set(row.id, row.title);
115
+ }
116
+ return map;
117
+ }
118
+
67
119
  // ---------------------------------------------------------------------------
68
120
  // GET /v1/memory-items
69
121
  // ---------------------------------------------------------------------------
@@ -145,7 +197,17 @@ export function handleListMemoryItems(url: URL): Response {
145
197
  .offset(offsetParam)
146
198
  .all();
147
199
 
148
- return Response.json({ items, total });
200
+ // Resolve scope labels for private-scoped items
201
+ const titleMap = buildConversationTitleMap(
202
+ db,
203
+ items.map((i) => i.scopeId),
204
+ );
205
+ const enrichedItems = items.map((item) => ({
206
+ ...item,
207
+ scopeLabel: resolveScopeLabel(item.scopeId, titleMap),
208
+ }));
209
+
210
+ return Response.json({ items: enrichedItems, total });
149
211
  }
150
212
 
151
213
  // ---------------------------------------------------------------------------
@@ -187,9 +249,14 @@ export function handleGetMemoryItem(ctx: RouteContext): Response {
187
249
  supersededBySubject = superseding?.subject;
188
250
  }
189
251
 
252
+ // Resolve scope label
253
+ const titleMap = buildConversationTitleMap(db, [item.scopeId]);
254
+ const scopeLabel = resolveScopeLabel(item.scopeId, titleMap);
255
+
190
256
  return Response.json({
191
257
  item: {
192
258
  ...item,
259
+ scopeLabel,
193
260
  ...(supersedesSubject !== undefined ? { supersedesSubject } : {}),
194
261
  ...(supersededBySubject !== undefined ? { supersededBySubject } : {}),
195
262
  },
@@ -303,7 +370,14 @@ export async function handleCreateMemoryItem(
303
370
  .where(eq(memoryItems.id, id))
304
371
  .get();
305
372
 
306
- return Response.json({ item: insertedRow }, { status: 201 });
373
+ // Enrich with scopeLabel for API consistency
374
+ const titleMap = buildConversationTitleMap(db, [scopeId]);
375
+ const scopeLabel = resolveScopeLabel(scopeId, titleMap);
376
+
377
+ return Response.json(
378
+ { item: { ...insertedRow, scopeLabel } },
379
+ { status: 201 },
380
+ );
307
381
  }
308
382
 
309
383
  // ---------------------------------------------------------------------------
@@ -430,7 +504,18 @@ export async function handleUpdateMemoryItem(
430
504
  .where(eq(memoryItems.id, id))
431
505
  .get();
432
506
 
433
- return Response.json({ item: updatedRow });
507
+ // Enrich with scopeLabel for API consistency
508
+ const patchTitleMap = buildConversationTitleMap(db, [
509
+ updatedRow?.scopeId ?? existing.scopeId,
510
+ ]);
511
+ const patchScopeLabel = resolveScopeLabel(
512
+ updatedRow?.scopeId ?? existing.scopeId,
513
+ patchTitleMap,
514
+ );
515
+
516
+ return Response.json({
517
+ item: { ...updatedRow, scopeLabel: patchScopeLabel },
518
+ });
434
519
  }
435
520
 
436
521
  // ---------------------------------------------------------------------------
@@ -11,6 +11,7 @@ import {
11
11
  } from "../../config/loader.js";
12
12
  import type { CesClient } from "../../credential-execution/client.js";
13
13
  import { setSentryOrganizationId } from "../../instrument.js";
14
+ import { clearEmbeddingBackendCache } from "../../memory/embedding-backend.js";
14
15
  import { syncManualTokenConnection } from "../../oauth/manual-token-connection.js";
15
16
  import { validateAnthropicApiKey } from "../../providers/anthropic/client.js";
16
17
  import { validateGeminiApiKey } from "../../providers/gemini/client.js";
@@ -181,6 +182,7 @@ export async function handleAddSecret(
181
182
  500,
182
183
  );
183
184
  }
185
+ clearEmbeddingBackendCache();
184
186
  invalidateConfigCache();
185
187
  await initializeProviders(getConfig());
186
188
  log.info({ provider: name }, "API key updated via HTTP");
@@ -346,6 +348,7 @@ export async function handleDeleteSecret(req: Request): Promise<Response> {
346
348
  500,
347
349
  );
348
350
  }
351
+ clearEmbeddingBackendCache();
349
352
  invalidateConfigCache();
350
353
  await initializeProviders(getConfig());
351
354
  log.info({ provider: name }, "API key deleted via HTTP");
@@ -5,8 +5,15 @@
5
5
  * Requires the conversation to already exist (does not create new conversations).
6
6
  */
7
7
  import { getLogger } from "../../util/logger.js";
8
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from "../assistant-scope.js";
9
+ import type { AuthContext } from "../auth/types.js";
10
+ import { healGuardianBindingDrift } from "../guardian-vellum-migration.js";
8
11
  import { httpError } from "../http-errors.js";
9
12
  import type { RouteDefinition } from "../http-router.js";
13
+ import {
14
+ resolveTrustContext,
15
+ withSourceChannel,
16
+ } from "../trust-context-resolver.js";
10
17
 
11
18
  const log = getLogger("surface-action-routes");
12
19
 
@@ -18,6 +25,11 @@ interface SurfaceActionTarget {
18
25
  data?: Record<string, unknown>,
19
26
  ): void;
20
27
  handleSurfaceUndo?(surfaceId: string): void;
28
+ setTrustContext?(ctx: {
29
+ trustClass: "guardian" | "trusted_contact" | "unknown";
30
+ sourceChannel: string;
31
+ }): void;
32
+ trustContext?: { trustClass: string } | null;
21
33
  }
22
34
 
23
35
  export type ConversationLookup = (
@@ -28,6 +40,53 @@ export type ConversationLookupBySurfaceId = (
28
40
  surfaceId: string,
29
41
  ) => SurfaceActionTarget | undefined;
30
42
 
43
+ /**
44
+ * Resolve trust context from the request's auth context and set it on the
45
+ * conversation, following the same pattern as POST /v1/messages. This ensures
46
+ * surface actions inherit the correct trust class (guardian vs trusted_contact)
47
+ * rather than defaulting to unknown.
48
+ */
49
+ function applyTrustContext(
50
+ conversation: SurfaceActionTarget,
51
+ authContext: AuthContext,
52
+ ): void {
53
+ if (!conversation.setTrustContext) return;
54
+
55
+ const sourceChannel = "vellum";
56
+
57
+ if (authContext.actorPrincipalId) {
58
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
59
+ let trustCtx = resolveTrustContext({
60
+ assistantId,
61
+ sourceChannel,
62
+ conversationExternalId: "local",
63
+ actorExternalId: authContext.actorPrincipalId,
64
+ });
65
+ if (trustCtx.trustClass === "unknown") {
66
+ const healed = healGuardianBindingDrift(authContext.actorPrincipalId);
67
+ if (healed) {
68
+ trustCtx = resolveTrustContext({
69
+ assistantId,
70
+ sourceChannel,
71
+ conversationExternalId: "local",
72
+ actorExternalId: authContext.actorPrincipalId,
73
+ });
74
+ log.info(
75
+ {
76
+ actorPrincipalId: authContext.actorPrincipalId,
77
+ trustClass: trustCtx.trustClass,
78
+ },
79
+ "Trust re-resolved after guardian binding drift heal (surface action)",
80
+ );
81
+ }
82
+ }
83
+ conversation.setTrustContext(withSourceChannel(sourceChannel, trustCtx));
84
+ } else {
85
+ // Service principals or tokens without an actor ID get guardian context.
86
+ conversation.setTrustContext({ trustClass: "guardian", sourceChannel });
87
+ }
88
+ }
89
+
31
90
  /**
32
91
  * POST /v1/surface-actions — handle a UI surface action.
33
92
  *
@@ -37,6 +96,7 @@ export async function handleSurfaceAction(
37
96
  req: Request,
38
97
  findConversation: ConversationLookup,
39
98
  findConversationBySurfaceId?: ConversationLookupBySurfaceId,
99
+ authContext?: AuthContext,
40
100
  ): Promise<Response> {
41
101
  const body = (await req.json()) as {
42
102
  conversationId?: string | null;
@@ -65,6 +125,12 @@ export async function handleSurfaceAction(
65
125
  return httpError("NOT_FOUND", "No active conversation found", 404);
66
126
  }
67
127
 
128
+ // Resolve trust context from the request's auth headers so the conversation
129
+ // has the correct trust class for tool approval decisions.
130
+ if (authContext) {
131
+ applyTrustContext(conversation, authContext);
132
+ }
133
+
68
134
  try {
69
135
  conversation.handleSurfaceAction(surfaceId, actionId, data);
70
136
  log.info(
@@ -143,7 +209,7 @@ export function surfaceActionRouteDefinitions(deps: {
143
209
  {
144
210
  endpoint: "surface-actions",
145
211
  method: "POST",
146
- handler: async ({ req }) => {
212
+ handler: async ({ req, authContext }) => {
147
213
  if (!deps.findConversation) {
148
214
  return httpError(
149
215
  "NOT_IMPLEMENTED",
@@ -155,6 +221,7 @@ export function surfaceActionRouteDefinitions(deps: {
155
221
  req,
156
222
  deps.findConversation,
157
223
  deps.findConversationBySurfaceId,
224
+ authContext,
158
225
  );
159
226
  },
160
227
  },
@@ -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,
@@ -202,6 +206,27 @@ export function listSchedules(options?: {
202
206
  return rows.map(parseJobRow);
203
207
  }
204
208
 
209
+ /**
210
+ * Return enabled schedules whose next run falls within a time window.
211
+ * Used by the memory brief compiler to surface due-soon schedule entries.
212
+ */
213
+ export function getDueSoonSchedules(
214
+ now: number,
215
+ horizonMs: number,
216
+ ): ScheduleJob[] {
217
+ const db = getDb();
218
+ const cutoff = now + horizonMs;
219
+ const rows = db
220
+ .select()
221
+ .from(scheduleJobs)
222
+ .where(
223
+ and(eq(scheduleJobs.enabled, true), lte(scheduleJobs.nextRunAt, cutoff)),
224
+ )
225
+ .orderBy(asc(scheduleJobs.nextRunAt))
226
+ .all();
227
+ return rows.map(parseJobRow);
228
+ }
229
+
205
230
  export function updateSchedule(
206
231
  id: string,
207
232
  updates: {
@@ -215,6 +240,7 @@ export function updateSchedule(
215
240
  mode?: ScheduleMode;
216
241
  routingIntent?: RoutingIntent;
217
242
  routingHints?: Record<string, unknown>;
243
+ quiet?: boolean;
218
244
  },
219
245
  ): ScheduleJob | null {
220
246
  const db = getDb();
@@ -269,6 +295,7 @@ export function updateSchedule(
269
295
  set.routingIntent = updates.routingIntent;
270
296
  if (updates.routingHints !== undefined)
271
297
  set.routingHintsJson = JSON.stringify(updates.routingHints);
298
+ if (updates.quiet !== undefined) set.quiet = updates.quiet;
272
299
 
273
300
  // Recompute nextRunAt if schedule timing may have changed (only for recurring)
274
301
  if (
@@ -750,6 +777,7 @@ function parseJobRow(row: typeof scheduleJobs.$inferSelect): ScheduleJob {
750
777
  mode: (row.mode ?? "execute") as ScheduleMode,
751
778
  routingIntent: (row.routingIntent ?? "all_channels") as RoutingIntent,
752
779
  routingHints: safeParseJson(row.routingHintsJson),
780
+ quiet: row.quiet ?? false,
753
781
  status: (row.status ?? "active") as ScheduleStatus,
754
782
  createdAt: row.createdAt,
755
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) {