@vellumai/assistant 0.5.2 → 0.5.3

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 (108) hide show
  1. package/ARCHITECTURE.md +109 -0
  2. package/docs/skills.md +100 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/conversation-agent-loop-overflow.test.ts +7 -0
  5. package/src/__tests__/conversation-agent-loop.test.ts +7 -0
  6. package/src/__tests__/conversation-memory-dirty-tail.test.ts +150 -0
  7. package/src/__tests__/conversation-provider-retry-repair.test.ts +7 -0
  8. package/src/__tests__/conversation-wipe.test.ts +226 -0
  9. package/src/__tests__/db-memory-archive-migration.test.ts +372 -0
  10. package/src/__tests__/db-memory-brief-state-migration.test.ts +213 -0
  11. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +273 -0
  12. package/src/__tests__/inline-command-runner.test.ts +311 -0
  13. package/src/__tests__/inline-skill-authoring-guard.test.ts +220 -0
  14. package/src/__tests__/inline-skill-load-permissions.test.ts +435 -0
  15. package/src/__tests__/list-messages-attachments.test.ts +96 -0
  16. package/src/__tests__/memory-brief-open-loops.test.ts +530 -0
  17. package/src/__tests__/memory-brief-time.test.ts +285 -0
  18. package/src/__tests__/memory-brief-wrapper.test.ts +311 -0
  19. package/src/__tests__/memory-chunk-archive.test.ts +400 -0
  20. package/src/__tests__/memory-chunk-dual-write.test.ts +453 -0
  21. package/src/__tests__/memory-episode-archive.test.ts +370 -0
  22. package/src/__tests__/memory-episode-dual-write.test.ts +626 -0
  23. package/src/__tests__/memory-observation-archive.test.ts +375 -0
  24. package/src/__tests__/memory-observation-dual-write.test.ts +318 -0
  25. package/src/__tests__/memory-recall-quality.test.ts +2 -2
  26. package/src/__tests__/memory-reducer-store.test.ts +728 -0
  27. package/src/__tests__/memory-reducer-types.test.ts +699 -0
  28. package/src/__tests__/memory-reducer.test.ts +698 -0
  29. package/src/__tests__/memory-regressions.test.ts +6 -4
  30. package/src/__tests__/memory-simplified-config.test.ts +281 -0
  31. package/src/__tests__/parse-identity-fields.test.ts +129 -0
  32. package/src/__tests__/skill-load-inline-command.test.ts +598 -0
  33. package/src/__tests__/skill-load-inline-includes.test.ts +644 -0
  34. package/src/__tests__/skills-inline-command-expansions.test.ts +301 -0
  35. package/src/__tests__/skills-transitive-hash.test.ts +333 -0
  36. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +320 -0
  37. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +4 -4
  38. package/src/config/bundled-skills/app-builder/SKILL.md +8 -8
  39. package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
  40. package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
  41. package/src/config/feature-flag-registry.json +16 -0
  42. package/src/config/loader.ts +1 -0
  43. package/src/config/raw-config-utils.ts +28 -0
  44. package/src/config/schema.ts +12 -0
  45. package/src/config/schemas/memory-simplified.ts +101 -0
  46. package/src/config/schemas/memory.ts +4 -0
  47. package/src/config/skills.ts +50 -4
  48. package/src/daemon/conversation-agent-loop-handlers.ts +8 -3
  49. package/src/daemon/conversation-agent-loop.ts +71 -1
  50. package/src/daemon/conversation-lifecycle.ts +11 -1
  51. package/src/daemon/conversation-runtime-assembly.ts +2 -1
  52. package/src/daemon/conversation-surfaces.ts +31 -8
  53. package/src/daemon/conversation.ts +40 -23
  54. package/src/daemon/handlers/config-embeddings.ts +10 -2
  55. package/src/daemon/handlers/config-model.ts +0 -9
  56. package/src/daemon/handlers/identity.ts +12 -1
  57. package/src/daemon/lifecycle.ts +9 -1
  58. package/src/daemon/message-types/conversations.ts +0 -1
  59. package/src/daemon/server.ts +1 -1
  60. package/src/followups/followup-store.ts +47 -1
  61. package/src/memory/archive-store.ts +400 -0
  62. package/src/memory/brief-formatting.ts +33 -0
  63. package/src/memory/brief-open-loops.ts +266 -0
  64. package/src/memory/brief-time.ts +161 -0
  65. package/src/memory/brief.ts +75 -0
  66. package/src/memory/conversation-crud.ts +245 -101
  67. package/src/memory/db-init.ts +12 -0
  68. package/src/memory/indexer.ts +106 -15
  69. package/src/memory/job-handlers/embedding.test.ts +1 -0
  70. package/src/memory/job-handlers/embedding.ts +83 -0
  71. package/src/memory/job-utils.ts +1 -1
  72. package/src/memory/jobs-store.ts +6 -0
  73. package/src/memory/jobs-worker.ts +12 -0
  74. package/src/memory/migrations/185-memory-brief-state.ts +52 -0
  75. package/src/memory/migrations/186-memory-archive.ts +109 -0
  76. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +19 -0
  77. package/src/memory/migrations/index.ts +3 -0
  78. package/src/memory/qdrant-client.ts +23 -4
  79. package/src/memory/reducer-store.ts +271 -0
  80. package/src/memory/reducer-types.ts +99 -0
  81. package/src/memory/reducer.ts +453 -0
  82. package/src/memory/schema/conversations.ts +3 -0
  83. package/src/memory/schema/index.ts +2 -0
  84. package/src/memory/schema/memory-archive.ts +121 -0
  85. package/src/memory/schema/memory-brief.ts +55 -0
  86. package/src/memory/search/semantic.ts +17 -4
  87. package/src/oauth/oauth-store.ts +3 -1
  88. package/src/permissions/checker.ts +89 -6
  89. package/src/permissions/defaults.ts +14 -0
  90. package/src/runtime/routes/conversation-management-routes.ts +6 -0
  91. package/src/runtime/routes/conversation-query-routes.ts +7 -0
  92. package/src/runtime/routes/conversation-routes.ts +52 -5
  93. package/src/runtime/routes/identity-routes.ts +2 -35
  94. package/src/runtime/routes/llm-context-normalization.ts +14 -1
  95. package/src/runtime/routes/memory-item-routes.ts +90 -5
  96. package/src/runtime/routes/secret-routes.ts +2 -0
  97. package/src/runtime/routes/surface-action-routes.ts +68 -1
  98. package/src/schedule/schedule-store.ts +21 -0
  99. package/src/skills/inline-command-expansions.ts +204 -0
  100. package/src/skills/inline-command-render.ts +127 -0
  101. package/src/skills/inline-command-runner.ts +242 -0
  102. package/src/skills/transitive-version-hash.ts +88 -0
  103. package/src/tasks/task-store.ts +43 -1
  104. package/src/tools/permission-checker.ts +8 -1
  105. package/src/tools/skills/load.ts +140 -6
  106. package/src/util/platform.ts +18 -0
  107. package/src/workspace/migrations/{002-backfill-installation-id.ts → 011-backfill-installation-id.ts} +1 -1
  108. package/src/workspace/migrations/registry.ts +1 -1
@@ -274,10 +274,12 @@ export async function upsertApp(
274
274
  // can detect that a concurrent caller has claimed this row. Without
275
275
  // this, a concurrent inserter's rollback DELETE would still match on
276
276
  // the original updatedAt and delete the row we just validated.
277
+ const newUpdatedAt = Date.now();
277
278
  db.update(oauthApps)
278
- .set({ updatedAt: Date.now() })
279
+ .set({ updatedAt: newUpdatedAt })
279
280
  .where(eq(oauthApps.id, existingRow.id))
280
281
  .run();
282
+ return { ...existingRow, updatedAt: newUpdatedAt };
281
283
  }
282
284
  if (clientSecretCredentialPath) {
283
285
  db.update(oauthApps)
@@ -2,12 +2,15 @@ import { createHash } from "node:crypto";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, resolve } from "node:path";
4
4
 
5
+ import { isAssistantFeatureFlagEnabled } from "../config/assistant-feature-flags.js";
5
6
  import { getConfig } from "../config/loader.js";
6
- import { resolveSkillSelector } from "../config/skills.js";
7
+ import { loadSkillCatalog, resolveSkillSelector } from "../config/skills.js";
8
+ import { indexCatalogById } from "../skills/include-graph.js";
7
9
  import {
8
10
  isSkillSourcePath,
9
11
  normalizeFilePath,
10
12
  } from "../skills/path-classifier.js";
13
+ import { computeTransitiveSkillVersionHash } from "../skills/transitive-version-hash.js";
11
14
  import { computeSkillVersionHash } from "../skills/version-hash.js";
12
15
  import type { ManifestOverride } from "../tools/execution-target.js";
13
16
  import {
@@ -352,6 +355,34 @@ function resolveSkillIdAndHash(
352
355
  }
353
356
  }
354
357
 
358
+ /**
359
+ * Check whether a skill (by id) has parsed inline command expansions.
360
+ * Returns false when the skill is not found in the catalog.
361
+ */
362
+ function hasInlineExpansions(skillId: string): boolean {
363
+ const catalog = loadSkillCatalog();
364
+ const skill = catalog.find((s) => s.id === skillId);
365
+ return (
366
+ skill?.inlineCommandExpansions != null &&
367
+ skill.inlineCommandExpansions.length > 0
368
+ );
369
+ }
370
+
371
+ /**
372
+ * Compute the transitive version hash for a skill, returning `undefined`
373
+ * when computation fails (missing includes, cycle, etc.). The permission
374
+ * layer falls back to the any-version candidate in that case.
375
+ */
376
+ function computeTransitiveHashSafe(skillId: string): string | undefined {
377
+ try {
378
+ const catalog = loadSkillCatalog();
379
+ const index = indexCatalogById(catalog);
380
+ return computeTransitiveSkillVersionHash(skillId, index);
381
+ } catch {
382
+ return undefined;
383
+ }
384
+ }
385
+
355
386
  function canonicalizeWebFetchUrl(parsed: URL): URL {
356
387
  parsed.hash = "";
357
388
  parsed.username = "";
@@ -433,13 +464,39 @@ async function buildCommandCandidates(
433
464
  targets.push("");
434
465
  } else {
435
466
  const resolved = resolveSkillIdAndHash(rawSelector);
436
- if (resolved && resolved.versionHash) {
437
- // Version-specific candidate lets rules pin to an exact skill version
438
- targets.push(`${resolved.id}@${resolved.versionHash}`);
467
+
468
+ // When the resolved skill contains inline command expansions and the
469
+ // feature flag is on, emit skill_load_dynamic: candidates so the
470
+ // higher-priority default ask rule catches them instead of falling
471
+ // through to the permissive skill_load:* allow rule.
472
+ const config = getConfig();
473
+ const inlineEnabled = isAssistantFeatureFlagEnabled(
474
+ "feature_flags.inline-skill-commands.enabled",
475
+ config,
476
+ );
477
+
478
+ if (resolved && inlineEnabled && hasInlineExpansions(resolved.id)) {
479
+ const transitiveHash = computeTransitiveHashSafe(resolved.id);
480
+ if (transitiveHash) {
481
+ targets.push(`skill_load_dynamic:${resolved.id}@${transitiveHash}`);
482
+ }
483
+ targets.push(`skill_load_dynamic:${resolved.id}`);
484
+ // Don't fall through to skill_load:* — dynamic skills use their own
485
+ // candidate namespace so the default ask rule applies.
486
+ } else {
487
+ if (resolved && resolved.versionHash) {
488
+ // Version-specific candidate lets rules pin to an exact skill version
489
+ targets.push(`${resolved.id}@${resolved.versionHash}`);
490
+ }
491
+ targets.push(rawSelector);
439
492
  }
440
- targets.push(rawSelector);
441
493
  }
442
- return [...new Set(targets)].map((target) => `${toolName}:${target}`);
494
+
495
+ // Dynamic candidates use skill_load_dynamic: prefix; normal ones use skill_load:
496
+ return [...new Set(targets)].map((target) => {
497
+ if (target.startsWith("skill_load_dynamic:")) return target;
498
+ return `${toolName}:${target}`;
499
+ });
443
500
  }
444
501
 
445
502
  if (
@@ -1084,6 +1141,32 @@ function skillLoadAllowlistStrategy(
1084
1141
 
1085
1142
  if (rawSelector) {
1086
1143
  const resolved = resolveSkillIdAndHash(rawSelector);
1144
+
1145
+ // Check whether this is a dynamic (inline-command) skill load
1146
+ const config = getConfig();
1147
+ const inlineEnabled = isAssistantFeatureFlagEnabled(
1148
+ "feature_flags.inline-skill-commands.enabled",
1149
+ config,
1150
+ );
1151
+
1152
+ if (resolved && inlineEnabled && hasInlineExpansions(resolved.id)) {
1153
+ const transitiveHash = computeTransitiveHashSafe(resolved.id);
1154
+ const options: AllowlistOption[] = [];
1155
+ if (transitiveHash) {
1156
+ options.push({
1157
+ label: `${resolved.id}@${transitiveHash}`,
1158
+ description: "This exact version (pinned)",
1159
+ pattern: `skill_load_dynamic:${resolved.id}@${transitiveHash}`,
1160
+ });
1161
+ }
1162
+ options.push({
1163
+ label: resolved.id,
1164
+ description: "This skill (any version)",
1165
+ pattern: `skill_load_dynamic:${resolved.id}`,
1166
+ });
1167
+ return options;
1168
+ }
1169
+
1087
1170
  if (resolved && resolved.versionHash) {
1088
1171
  return [
1089
1172
  {
@@ -198,6 +198,19 @@ export function getDefaultRuleTemplates(): DefaultRuleTemplate[] {
198
198
  })),
199
199
  );
200
200
 
201
+ // Inline-command skill loads use a distinct candidate namespace
202
+ // (skill_load_dynamic:*) so they prompt by default instead of falling
203
+ // through to the permissive skill_load:* allow rule below. The higher
204
+ // priority ensures this rule wins when both could match.
205
+ const skillLoadDynamicRule: DefaultRuleTemplate = {
206
+ id: "default:ask-skill_load_dynamic-global",
207
+ tool: "skill_load",
208
+ pattern: "skill_load_dynamic:*",
209
+ scope: "everywhere",
210
+ decision: "ask",
211
+ priority: 200,
212
+ };
213
+
201
214
  const skillLoadRule: DefaultRuleTemplate = {
202
215
  id: "default:allow-skill_load-global",
203
216
  tool: "skill_load",
@@ -294,6 +307,7 @@ export function getDefaultRuleTemplates(): DefaultRuleTemplate[] {
294
307
  bootstrapDeleteRule,
295
308
  updatesDeleteRule,
296
309
  ...skillSourceMutationRules,
310
+ skillLoadDynamicRule,
297
311
  skillLoadRule,
298
312
  skillExecuteRule,
299
313
  browserNavigateRule,
@@ -275,6 +275,12 @@ export function conversationManagementRouteDefinitions(
275
275
  targetId: itemId,
276
276
  });
277
277
  }
278
+ for (const summaryId of deleted.deletedSummaryIds) {
279
+ enqueueMemoryJob("delete_qdrant_vectors", {
280
+ targetType: "summary",
281
+ targetId: summaryId,
282
+ });
283
+ }
278
284
  log.info({ conversationId: resolvedId }, "Deleted conversation");
279
285
  return new Response(null, { status: 204 });
280
286
  },
@@ -225,6 +225,13 @@ export function conversationQueryRouteDefinitions(
225
225
  400,
226
226
  );
227
227
  }
228
+ if (body.model !== undefined && typeof body.model !== "string") {
229
+ return httpError(
230
+ "BAD_REQUEST",
231
+ "Field 'model' must be a string",
232
+ 400,
233
+ );
234
+ }
228
235
  try {
229
236
  const info = await setEmbeddingConfig(
230
237
  body.provider,
@@ -81,6 +81,9 @@ import {
81
81
 
82
82
  const log = getLogger("conversation-routes");
83
83
 
84
+ /** Matches the `<no_response/>` sentinel used by channel delivery suppression. */
85
+ const NO_RESPONSE_INLINE_RE = /<no_response\s*\/?>/g;
86
+
84
87
  const SUGGESTION_CACHE_MAX = 100;
85
88
 
86
89
  function collectCanonicalGuardianRequestHintIds(
@@ -363,6 +366,48 @@ export function handleListMessages(
363
366
  content = msg.content;
364
367
  }
365
368
  const rendered = renderHistoryContent(content);
369
+
370
+ // Strip <no_response/> markers from assistant messages so web/API
371
+ // clients never see the raw sentinel. Only assistant messages produce
372
+ // this marker; user messages are left untouched.
373
+ if (msg.role === "assistant") {
374
+ const originalSegments = rendered.textSegments;
375
+ const keepIndices: number[] = [];
376
+ const filteredSegments: string[] = [];
377
+ for (let i = 0; i < originalSegments.length; i++) {
378
+ const cleaned = originalSegments[i]
379
+ .replace(NO_RESPONSE_INLINE_RE, "")
380
+ .trim();
381
+ if (cleaned.length > 0) {
382
+ keepIndices.push(i);
383
+ filteredSegments.push(cleaned);
384
+ }
385
+ }
386
+ // Remap contentOrder text:N indices to account for removed segments
387
+ const indexMap = new Map<number, number>();
388
+ keepIndices.forEach((oldIdx, newIdx) => indexMap.set(oldIdx, newIdx));
389
+ const filteredContentOrder = rendered.contentOrder
390
+ .map((entry) => {
391
+ const m = entry.match(/^text:(\d+)$/);
392
+ if (!m) return entry;
393
+ const newIdx = indexMap.get(Number(m[1]));
394
+ return newIdx !== undefined ? `text:${newIdx}` : undefined;
395
+ })
396
+ .filter((e): e is string => e !== undefined);
397
+
398
+ return {
399
+ role: msg.role,
400
+ text: rendered.text.replace(NO_RESPONSE_INLINE_RE, "").trim(),
401
+ timestamp: msg.createdAt,
402
+ toolCalls: rendered.toolCalls,
403
+ toolCallsBeforeText: rendered.toolCallsBeforeText,
404
+ textSegments: filteredSegments,
405
+ contentOrder: filteredContentOrder,
406
+ surfaces: rendered.surfaces,
407
+ id: msg.id,
408
+ };
409
+ }
410
+
366
411
  return {
367
412
  role: msg.role,
368
413
  text: rendered.text,
@@ -1240,11 +1285,13 @@ async function generateLlmSuggestion(
1240
1285
  return null;
1241
1286
  }
1242
1287
  if (firstLine.length <= 50) return firstLine;
1243
- // Truncate at last word boundary within 50 chars
1244
- const wordTruncated = firstLine
1245
- .slice(0, 50)
1246
- .replace(/\s+\S*$/, "")
1247
- .trim();
1288
+ // Truncate at last word boundary within 50 chars.
1289
+ // Only strip the trailing partial word if the slice actually cut mid-word;
1290
+ // if the character right after the cut is whitespace, the slice is already clean.
1291
+ const sliced = firstLine.slice(0, 50);
1292
+ const wordTruncated = (
1293
+ /\s/.test(firstLine[50]) ? sliced : sliced.replace(/\s+\S*$/, "")
1294
+ ).trim();
1248
1295
  if (wordTruncated.length < 15) {
1249
1296
  log.debug(
1250
1297
  { rawLength: firstLine.length, truncatedLength: wordTruncated.length },
@@ -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";
@@ -346,6 +347,7 @@ export async function handleDeleteSecret(req: Request): Promise<Response> {
346
347
  500,
347
348
  );
348
349
  }
350
+ clearEmbeddingBackendCache();
349
351
  invalidateConfigCache();
350
352
  await initializeProviders(getConfig());
351
353
  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
  },
@@ -202,6 +202,27 @@ export function listSchedules(options?: {
202
202
  return rows.map(parseJobRow);
203
203
  }
204
204
 
205
+ /**
206
+ * Return enabled schedules whose next run falls within a time window.
207
+ * Used by the memory brief compiler to surface due-soon schedule entries.
208
+ */
209
+ export function getDueSoonSchedules(
210
+ now: number,
211
+ horizonMs: number,
212
+ ): ScheduleJob[] {
213
+ const db = getDb();
214
+ const cutoff = now + horizonMs;
215
+ const rows = db
216
+ .select()
217
+ .from(scheduleJobs)
218
+ .where(
219
+ and(eq(scheduleJobs.enabled, true), lte(scheduleJobs.nextRunAt, cutoff)),
220
+ )
221
+ .orderBy(asc(scheduleJobs.nextRunAt))
222
+ .all();
223
+ return rows.map(parseJobRow);
224
+ }
225
+
205
226
  export function updateSchedule(
206
227
  id: string,
207
228
  updates: {