@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
@@ -25,10 +25,15 @@ import {
25
25
  userMessage,
26
26
  } from "../providers/provider-send-message.js";
27
27
  import { parseFrontmatterFields } from "../skills/frontmatter.js";
28
+ import type { InlineCommandExpansion } from "../skills/inline-command-expansions.js";
29
+ import { parseInlineCommandExpansions } from "../skills/inline-command-expansions.js";
28
30
  import { parseToolManifestFile } from "../skills/tool-manifest.js";
29
31
  import { computeSkillVersionHash } from "../skills/version-hash.js";
30
32
  import { getLogger } from "../util/logger.js";
31
- import { getWorkspaceSkillsDir } from "../util/platform.js";
33
+ import {
34
+ getWorkspaceDirDisplay,
35
+ getWorkspaceSkillsDir,
36
+ } from "../util/platform.js";
32
37
  import { isAssistantFeatureFlagEnabled } from "./assistant-feature-flags.js";
33
38
  import { getConfig } from "./loader.js";
34
39
 
@@ -80,6 +85,8 @@ export interface SkillSummary {
80
85
  activationHints?: string[];
81
86
  /** Conditions under which this skill should NOT be loaded. */
82
87
  avoidWhen?: string[];
88
+ /** Parsed inline command expansion descriptors (`!\`command\``) found in the skill body. */
89
+ inlineCommandExpansions?: InlineCommandExpansion[];
83
90
  }
84
91
 
85
92
  export interface SkillDefinition extends SkillSummary {
@@ -198,6 +205,7 @@ interface ParsedFrontmatter {
198
205
  featureFlag?: string;
199
206
  activationHints?: string[];
200
207
  avoidWhen?: string[];
208
+ inlineCommandExpansions?: InlineCommandExpansion[];
201
209
  }
202
210
 
203
211
  function normalizeStringArray(raw: unknown): string[] | undefined {
@@ -302,16 +310,29 @@ function parseFrontmatter(
302
310
  const activationHints = normalizeStringArray(vellum?.["activation-hints"]);
303
311
  const avoidWhen = normalizeStringArray(vellum?.["avoid-when"]);
304
312
 
313
+ const strippedBody = stripCommentLines(body);
314
+
315
+ // Parse inline command expansions from the body (after frontmatter/comment stripping)
316
+ const expansionResult = parseInlineCommandExpansions(strippedBody);
317
+ const inlineCommandExpansions =
318
+ expansionResult.expansions.length > 0
319
+ ? expansionResult.expansions
320
+ : undefined;
321
+
322
+ // Fail closed: if there are malformed tokens, log and exclude from parsed expansions
323
+ // (errors are already logged inside parseInlineCommandExpansions)
324
+
305
325
  return {
306
326
  name,
307
327
  displayName,
308
328
  description,
309
- body: stripCommentLines(body),
329
+ body: strippedBody,
310
330
  emoji,
311
331
  includes,
312
332
  featureFlag,
313
333
  activationHints,
314
334
  avoidWhen,
335
+ inlineCommandExpansions,
315
336
  };
316
337
  }
317
338
 
@@ -466,6 +487,7 @@ function readSkillFromDirectory(
466
487
  featureFlag: parsed.featureFlag,
467
488
  activationHints: parsed.activationHints,
468
489
  avoidWhen: parsed.avoidWhen,
490
+ inlineCommandExpansions: parsed.inlineCommandExpansions,
469
491
  };
470
492
  } catch (err) {
471
493
  log.warn({ err, skillFilePath }, "Failed to read skill file");
@@ -516,6 +538,7 @@ function readBundledSkillFromDirectory(
516
538
  featureFlag: parsed.featureFlag,
517
539
  activationHints: parsed.activationHints,
518
540
  avoidWhen: parsed.avoidWhen,
541
+ inlineCommandExpansions: parsed.inlineCommandExpansions,
519
542
  };
520
543
  } catch (err) {
521
544
  log.warn({ err, skillFilePath }, "Failed to read bundled skill file");
@@ -574,6 +597,7 @@ function loadBundledSkills(): SkillSummary[] {
574
597
  featureFlag: skill.featureFlag,
575
598
  activationHints: skill.activationHints,
576
599
  avoidWhen: skill.avoidWhen,
600
+ inlineCommandExpansions: skill.inlineCommandExpansions,
577
601
  });
578
602
  }
579
603
 
@@ -710,6 +734,7 @@ function skillSummaryFromDefinition(
710
734
  featureFlag: skill.featureFlag,
711
735
  activationHints: skill.activationHints,
712
736
  avoidWhen: skill.avoidWhen,
737
+ inlineCommandExpansions: skill.inlineCommandExpansions,
713
738
  };
714
739
  }
715
740
 
@@ -760,6 +785,7 @@ export function loadSkillCatalog(
760
785
  toolManifest: detectToolManifest(directory),
761
786
  includes: parsed.includes,
762
787
  featureFlag: parsed.featureFlag,
788
+ inlineCommandExpansions: parsed.inlineCommandExpansions,
763
789
  });
764
790
  } catch (err) {
765
791
  log.warn({ err, directory }, "Failed to read skill from extraDirs");
@@ -854,6 +880,7 @@ export function loadSkillCatalog(
854
880
  toolManifest: detectToolManifest(directory),
855
881
  includes: parsed.includes,
856
882
  featureFlag: parsed.featureFlag,
883
+ inlineCommandExpansions: parsed.inlineCommandExpansions,
857
884
  };
858
885
 
859
886
  if (seenIds.has(id)) {
@@ -1001,8 +1028,28 @@ function loadSkillDefinition(skill: SkillSummary): SkillLookupResult {
1001
1028
  }
1002
1029
  // Replace {baseDir} placeholders with the actual skill directory path
1003
1030
  loaded.body = loaded.body.replaceAll("{baseDir}", loaded.directoryPath);
1031
+ // Replace {workspaceDir} placeholders with the runtime workspace display path
1032
+ loaded.body = loaded.body.replaceAll(
1033
+ "{workspaceDir}",
1034
+ getWorkspaceDirDisplay(),
1035
+ );
1004
1036
  // Strip feature-gated sections based on assistant feature flags
1005
1037
  loaded.body = applyFeatureGatedSections(loaded.body);
1038
+
1039
+ // Re-parse inline command expansions after placeholder substitution.
1040
+ // The initial parse (during SKILL.md parsing) produces byte offsets against
1041
+ // the pre-substitution body. Since {baseDir} and {workspaceDir} replacements
1042
+ // change the body length, those offsets become stale. Re-parsing ensures the
1043
+ // offsets match the final body that renderInlineCommands will operate on.
1044
+ if (
1045
+ loaded.inlineCommandExpansions &&
1046
+ loaded.inlineCommandExpansions.length > 0
1047
+ ) {
1048
+ const reparse = parseInlineCommandExpansions(loaded.body);
1049
+ loaded.inlineCommandExpansions =
1050
+ reparse.expansions.length > 0 ? reparse.expansions : undefined;
1051
+ }
1052
+
1006
1053
  return { skill: loaded };
1007
1054
  }
1008
1055
 
@@ -1021,8 +1068,7 @@ export function resolveSkillSelector(
1021
1068
  const catalog = loadSkillCatalog(workspaceSkillsDir);
1022
1069
  if (catalog.length === 0) {
1023
1070
  return {
1024
- error:
1025
- "No skills are available. Configure ~/.vellum/workspace/skills/SKILLS.md or add skill directories.",
1071
+ error: `No skills are available. Configure ${getWorkspaceDirDisplay()}/skills/SKILLS.md or add skill directories.`,
1026
1072
  errorCode: "empty_catalog",
1027
1073
  };
1028
1074
  }
@@ -279,7 +279,12 @@ export function handleToolUse(
279
279
  state.toolCallTimestamps.set(event.id, { startedAt: Date.now() });
280
280
  state.currentToolUseId = event.id;
281
281
  state.currentTurnToolUseIds.push(event.id);
282
- const statusText = `Running ${friendlyToolName(event.name)}`;
282
+ const statusText =
283
+ event.name === "skill_execute" &&
284
+ typeof event.input.activity === "string" &&
285
+ event.input.activity.length > 0
286
+ ? event.input.activity
287
+ : `Running ${friendlyToolName(event.name)}`;
283
288
  deps.ctx.emitActivityState(
284
289
  "tool_running",
285
290
  "tool_use_start",
@@ -398,8 +403,8 @@ export function handleInputJsonDelta(
398
403
  event: Extract<AgentEvent, { type: "input_json_delta" }>,
399
404
  ): void {
400
405
  // Only forward input deltas for app tools — the client only uses this
401
- // stream for app_create/app_update code previews. Non-app tools would
402
- // send large cumulative JSON on every delta with no benefit.
406
+ // stream for app_create code previews. Non-app tools would send large
407
+ // cumulative JSON on every delta with no benefit.
403
408
  if (!APP_TOOL_NAMES.has(event.toolName)) return;
404
409
  deps.onEvent({
405
410
  type: "tool_input_delta",
@@ -33,6 +33,7 @@ import {
33
33
  } from "../instrument.js";
34
34
  import { commitAppTurnChanges } from "../memory/app-git-service.js";
35
35
  import { getApp, listAppFiles, resolveAppDir } from "../memory/app-store.js";
36
+ import { insertCompactionEpisode } from "../memory/archive-store.js";
36
37
  import {
37
38
  addMessage,
38
39
  deleteMessageById,
@@ -208,7 +209,17 @@ export interface AgentLoopConversationContext {
208
209
  currentPage?: string;
209
210
  readonly surfaceState: Map<
210
211
  string,
211
- { surfaceType: SurfaceType; data: SurfaceData; title?: string }
212
+ {
213
+ surfaceType: SurfaceType;
214
+ data: SurfaceData;
215
+ title?: string;
216
+ actions?: Array<{
217
+ id: string;
218
+ label: string;
219
+ style?: string;
220
+ data?: Record<string, unknown>;
221
+ }>;
222
+ }
212
223
  >;
213
224
  pendingSurfaceActions: Map<string, { surfaceType: SurfaceType }>;
214
225
  surfaceActionRequestIds: Set<string>;
@@ -503,6 +514,12 @@ export async function runAgentLoopImpl(
503
514
  compacted.summaryText,
504
515
  ctx.contextCompactedMessageCount,
505
516
  );
517
+ dualWriteCompactionEpisode(
518
+ ctx.conversationId,
519
+ ctx.memoryPolicy.scopeId,
520
+ compacted.summaryText,
521
+ compacted.summaryOutputTokens,
522
+ );
506
523
  onEvent({
507
524
  type: "context_compacted",
508
525
  previousEstimatedInputTokens: compacted.previousEstimatedInputTokens,
@@ -770,6 +787,12 @@ export async function runAgentLoopImpl(
770
787
  step.compactionResult.summaryText,
771
788
  ctx.contextCompactedMessageCount,
772
789
  );
790
+ dualWriteCompactionEpisode(
791
+ ctx.conversationId,
792
+ ctx.memoryPolicy.scopeId,
793
+ step.compactionResult.summaryText,
794
+ step.compactionResult.summaryOutputTokens,
795
+ );
773
796
  onEvent({
774
797
  type: "context_compacted",
775
798
  previousEstimatedInputTokens:
@@ -954,6 +977,12 @@ export async function runAgentLoopImpl(
954
977
  midLoopCompact.summaryText,
955
978
  ctx.contextCompactedMessageCount,
956
979
  );
980
+ dualWriteCompactionEpisode(
981
+ ctx.conversationId,
982
+ ctx.memoryPolicy.scopeId,
983
+ midLoopCompact.summaryText,
984
+ midLoopCompact.summaryOutputTokens,
985
+ );
957
986
  onEvent({
958
987
  type: "context_compacted",
959
988
  previousEstimatedInputTokens:
@@ -1150,6 +1179,12 @@ export async function runAgentLoopImpl(
1150
1179
  step.compactionResult.summaryText,
1151
1180
  ctx.contextCompactedMessageCount,
1152
1181
  );
1182
+ dualWriteCompactionEpisode(
1183
+ ctx.conversationId,
1184
+ ctx.memoryPolicy.scopeId,
1185
+ step.compactionResult.summaryText,
1186
+ step.compactionResult.summaryOutputTokens,
1187
+ );
1153
1188
  onEvent({
1154
1189
  type: "context_compacted",
1155
1190
  previousEstimatedInputTokens:
@@ -1257,6 +1292,12 @@ export async function runAgentLoopImpl(
1257
1292
  emergencyCompact.summaryText,
1258
1293
  ctx.contextCompactedMessageCount,
1259
1294
  );
1295
+ dualWriteCompactionEpisode(
1296
+ ctx.conversationId,
1297
+ ctx.memoryPolicy.scopeId,
1298
+ emergencyCompact.summaryText,
1299
+ emergencyCompact.summaryOutputTokens,
1300
+ );
1260
1301
  onEvent({
1261
1302
  type: "context_compacted",
1262
1303
  previousEstimatedInputTokens:
@@ -1361,6 +1402,12 @@ export async function runAgentLoopImpl(
1361
1402
  emergencyCompact.summaryText,
1362
1403
  ctx.contextCompactedMessageCount,
1363
1404
  );
1405
+ dualWriteCompactionEpisode(
1406
+ ctx.conversationId,
1407
+ ctx.memoryPolicy.scopeId,
1408
+ emergencyCompact.summaryText,
1409
+ emergencyCompact.summaryOutputTokens,
1410
+ );
1364
1411
  onEvent({
1365
1412
  type: "context_compacted",
1366
1413
  previousEstimatedInputTokens:
@@ -1826,3 +1873,26 @@ function collapseRawResponses(rawResponses?: unknown[]): unknown | undefined {
1826
1873
  if (!rawResponses || rawResponses.length === 0) return undefined;
1827
1874
  return rawResponses.length === 1 ? rawResponses[0] : rawResponses;
1828
1875
  }
1876
+
1877
+ /**
1878
+ * Dual-write a compaction summary as an archive episode so it becomes
1879
+ * searchable via vector recall. Called after each successful compaction
1880
+ * that produces a new summary.
1881
+ */
1882
+ function dualWriteCompactionEpisode(
1883
+ conversationId: string,
1884
+ scopeId: string,
1885
+ summaryText: string,
1886
+ summaryOutputTokens: number,
1887
+ ): void {
1888
+ const now = Date.now();
1889
+ insertCompactionEpisode({
1890
+ conversationId,
1891
+ scopeId,
1892
+ title: truncate(summaryText, 120, ""),
1893
+ summary: summaryText,
1894
+ tokenEstimate: summaryOutputTokens,
1895
+ startAt: now,
1896
+ endAt: now,
1897
+ });
1898
+ }
@@ -123,7 +123,17 @@ export interface AbortContext {
123
123
  surfaceActionRequestIds: Set<string>;
124
124
  surfaceState: Map<
125
125
  string,
126
- { surfaceType: SurfaceType; data: SurfaceData; title?: string }
126
+ {
127
+ surfaceType: SurfaceType;
128
+ data: SurfaceData;
129
+ title?: string;
130
+ actions?: Array<{
131
+ id: string;
132
+ label: string;
133
+ style?: string;
134
+ data?: Record<string, unknown>;
135
+ }>;
136
+ }
127
137
  >;
128
138
  accumulatedSurfaceState: Map<string, Record<string, unknown>>;
129
139
  readonly queue: MessageQueue;
@@ -306,7 +306,7 @@ export function injectActiveSurfaceContext(
306
306
  'PREREQUISITE: If `app_refresh` is not yet available, call `skill_load` with `id: "app-builder"` first to load it.',
307
307
  "",
308
308
  "RULES FOR WORKSPACE MODIFICATION:",
309
- `1. Use \`file_edit\` to make surgical changes to app files. The file path is \`~/.vellum/workspace/data/apps/${slug}/<path>\`.`,
309
+ `1. Use \`file_edit\` to make surgical changes to app files. The file path is \`${getAppDirPath(ctx.appId)}/<path>\`.`,
310
310
  "2. Use `file_write` to create new files or rewrite files.",
311
311
  "3. Use `file_read` to read any file with line numbers before editing.",
312
312
  "4. Use `bash ls` to see all files in the app directory.",
@@ -961,6 +961,7 @@ const RUNTIME_INJECTION_PREFIXES = [
961
961
  "<inbound_actor_context>",
962
962
  "<interface_turn_context>",
963
963
  "<turn_context>",
964
+ "<memory_brief>",
964
965
  "<memory_context __injected>",
965
966
  "<memory_context>", // backward-compat: strip legacy blocks from pre-__injected history
966
967
  "<voice_call_control>",
@@ -172,6 +172,7 @@ export interface SurfaceConversationContext {
172
172
  emit(type: string, message: string, meta?: Record<string, unknown>): void;
173
173
  };
174
174
  sendToClient(msg: ServerMessage): void;
175
+ broadcastToAllClients?(msg: ServerMessage): void;
175
176
  pendingSurfaceActions: Map<string, { surfaceType: SurfaceType }>;
176
177
  lastSurfaceAction: Map<
177
178
  string,
@@ -617,10 +618,18 @@ export function handleSurfaceAction(
617
618
  const prompt =
618
619
  isRelay && typeof data?.prompt === "string" ? data.prompt.trim() : "";
619
620
 
621
+ // Read accumulated state once — used by both relay and custom action paths.
622
+ const accState = ctx.accumulatedSurfaceState.get(surfaceId);
623
+ const hasAccState = accState && Object.keys(accState).length > 0;
624
+
620
625
  let content: string;
621
626
  let displayContent: string | undefined;
622
627
  if (prompt) {
623
628
  content = prompt;
629
+ // Re-append accumulated state so the LLM sees it, matching the pending path.
630
+ if (hasAccState) {
631
+ content += `\n\nAccumulated surface state: ${JSON.stringify(accState)}`;
632
+ }
624
633
  } else {
625
634
  // Custom action from an app (e.g. sendAction('answer_selected', {...}))
626
635
  const summary = actionId
@@ -630,17 +639,20 @@ export function handleSurfaceAction(
630
639
  if (data && Object.keys(data).length > 0) {
631
640
  content += `\n\nAction data: ${JSON.stringify(data)}`;
632
641
  }
633
- const accState = ctx.accumulatedSurfaceState.get(surfaceId);
634
- if (accState && Object.keys(accState).length > 0) {
642
+ if (hasAccState) {
635
643
  content += `\n\nAccumulated surface state: ${JSON.stringify(accState)}`;
636
- ctx.accumulatedSurfaceState.delete(surfaceId);
637
644
  }
638
645
  displayContent = summary;
639
646
  }
640
647
 
641
648
  const requestId = uuid();
642
649
  ctx.surfaceActionRequestIds.add(requestId);
643
- const onEvent = (msg: ServerMessage) => ctx.sendToClient(msg);
650
+ // Use broadcastToAllClients (publishes to the SSE event hub) instead of
651
+ // sendToClient, which is reset to a no-op between HTTP requests. Without
652
+ // this, surface action responses are persisted to DB but never reach the
653
+ // client's SSE stream.
654
+ const emit = ctx.broadcastToAllClients ?? ctx.sendToClient.bind(ctx);
655
+ const onEvent = (msg: ServerMessage) => emit(msg);
644
656
 
645
657
  ctx.traceEmitter.emit("request_received", "Surface action received", {
646
658
  requestId,
@@ -665,10 +677,16 @@ export function handleSurfaceAction(
665
677
  return;
666
678
  }
667
679
 
680
+ // One-shot: clear accumulated state now that the message has been accepted.
681
+ // Deferred until after rejection check so state is preserved for retry on rejection.
682
+ if (hasAccState) {
683
+ ctx.accumulatedSurfaceState.delete(surfaceId);
684
+ }
685
+
668
686
  // Echo the prompt to the client so it appears in the chat UI.
669
687
  // Deferred until after rejection check to avoid ghost messages.
670
688
  if (prompt) {
671
- ctx.sendToClient({
689
+ emit({
672
690
  type: "user_message_echo",
673
691
  text: prompt,
674
692
  conversationId: ctx.conversationId,
@@ -768,11 +786,16 @@ export function handleSurfaceAction(
768
786
  surfaceData,
769
787
  );
770
788
 
789
+ // Use broadcastToAllClients so events reach the SSE hub — sendToClient is
790
+ // reset to a no-op between HTTP requests (see history-restored path for
791
+ // full rationale).
792
+ const emit = ctx.broadcastToAllClients ?? ctx.sendToClient.bind(ctx);
793
+
771
794
  // Forms are one-shot surfaces — auto-complete immediately so the client
772
795
  // transitions from the "Submitting…" spinner to a completion chip without
773
796
  // requiring the LLM to call ui_dismiss.
774
797
  if (pending.surfaceType === "form") {
775
- ctx.sendToClient({
798
+ emit({
776
799
  type: "ui_surface_complete",
777
800
  conversationId: ctx.conversationId,
778
801
  surfaceId,
@@ -833,7 +856,7 @@ export function handleSurfaceAction(
833
856
 
834
857
  const requestId = uuid();
835
858
  ctx.surfaceActionRequestIds.add(requestId);
836
- const onEvent = (msg: ServerMessage) => ctx.sendToClient(msg);
859
+ const onEvent = (msg: ServerMessage) => emit(msg);
837
860
 
838
861
  ctx.traceEmitter.emit("request_received", "Surface action received", {
839
862
  requestId,
@@ -866,7 +889,7 @@ export function handleSurfaceAction(
866
889
  // Echo the user's prompt to the client so it appears in the chat UI.
867
890
  // Deferred until after rejection check to avoid ghost messages.
868
891
  if (shouldRelayPrompt && prompt) {
869
- ctx.sendToClient({
892
+ emit({
870
893
  type: "user_message_echo",
871
894
  text: prompt,
872
895
  conversationId: ctx.conversationId,
@@ -38,7 +38,6 @@ import {
38
38
  import { registerToolTraceListener } from "../events/tool-trace-listener.js";
39
39
  import { getHookManager } from "../hooks/manager.js";
40
40
  import { resolveCanonicalGuardianRequest } from "../memory/canonical-guardian-store.js";
41
- import { getMessages } from "../memory/conversation-crud.js";
42
41
  import { PermissionPrompter } from "../permissions/prompter.js";
43
42
  import { SecretPrompter } from "../permissions/secret-prompter.js";
44
43
  import { patternMatchesCandidate } from "../permissions/trust-store.js";
@@ -196,13 +195,24 @@ export class Conversation {
196
195
  >();
197
196
  /** @internal */ surfaceState = new Map<
198
197
  string,
199
- { surfaceType: SurfaceType; data: SurfaceData; title?: string }
198
+ {
199
+ surfaceType: SurfaceType;
200
+ data: SurfaceData;
201
+ title?: string;
202
+ actions?: Array<{
203
+ id: string;
204
+ label: string;
205
+ style?: string;
206
+ data?: Record<string, unknown>;
207
+ }>;
208
+ }
200
209
  >();
201
210
  /** @internal */ surfaceUndoStacks = new Map<string, string[]>();
202
211
  /** @internal */ accumulatedSurfaceState = new Map<
203
212
  string,
204
213
  Record<string, unknown>
205
214
  >();
215
+ /** @internal */ broadcastToAllClients?: (msg: ServerMessage) => void;
206
216
  /** @internal */ withSurface = createSurfaceMutex();
207
217
  /** @internal */ currentTurnSurfaces: Array<{
208
218
  surfaceId: string;
@@ -249,6 +259,7 @@ export class Conversation {
249
259
  this.provider = provider;
250
260
  this.workingDir = workingDir;
251
261
  this.sendToClient = sendToClient;
262
+ this.broadcastToAllClients = broadcastToAllClients;
252
263
  this.memoryPolicy = memoryPolicy
253
264
  ? { ...memoryPolicy }
254
265
  : { ...DEFAULT_MEMORY_POLICY };
@@ -386,30 +397,36 @@ export class Conversation {
386
397
  }
387
398
 
388
399
  /**
389
- * Scan ALL persisted messages (including compacted ones) for ui_surface
390
- * content blocks and populate surfaceState so findConversationBySurfaceId
391
- * works for surfaces restored from history (e.g. after daemon restart).
400
+ * Scan loaded conversation history for ui_surface content blocks and
401
+ * populate surfaceState so that findConversationBySurfaceId works for
402
+ * surfaces restored from history (e.g. after daemon restart).
403
+ *
404
+ * Only scans live (non-compacted) messages in this.messages — not all DB
405
+ * rows — because surface IDs are not globally unique and restoring stale
406
+ * compacted surfaces would let findConversationBySurfaceId route actions
407
+ * to the wrong conversation.
392
408
  */
393
409
  private restoreSurfaceStateFromHistory(): void {
394
- const dbMessages = getMessages(this.conversationId);
395
- for (const row of dbMessages) {
396
- try {
397
- const content = JSON.parse(row.content);
398
- if (!Array.isArray(content)) continue;
399
- for (const block of content) {
400
- if (
401
- block.type === "ui_surface" &&
402
- typeof block.surfaceId === "string"
403
- ) {
404
- this.surfaceState.set(block.surfaceId, {
405
- surfaceType: (block.surfaceType ?? "dynamic_page") as SurfaceType,
406
- data: (block.data ?? {}) as SurfaceData,
407
- title: block.title as string | undefined,
408
- });
409
- }
410
+ this.surfaceState.clear();
411
+ for (const msg of this.messages) {
412
+ if (!Array.isArray(msg.content)) continue;
413
+ for (const block of msg.content) {
414
+ const b = block as unknown as Record<string, unknown>;
415
+ if (b.type === "ui_surface" && typeof b.surfaceId === "string") {
416
+ this.surfaceState.set(b.surfaceId, {
417
+ surfaceType: (b.surfaceType ?? "dynamic_page") as SurfaceType,
418
+ data: (b.data ?? {}) as SurfaceData,
419
+ title: b.title as string | undefined,
420
+ actions: Array.isArray(b.actions)
421
+ ? (b.actions as Array<{
422
+ id: string;
423
+ label: string;
424
+ style?: string;
425
+ data?: Record<string, unknown>;
426
+ }>)
427
+ : undefined,
428
+ });
410
429
  }
411
- } catch {
412
- // Content isn't valid JSON — skip
413
430
  }
414
431
  }
415
432
  }
@@ -3,7 +3,10 @@ import {
3
3
  loadRawConfig,
4
4
  saveRawConfig,
5
5
  } from "../../config/loader.js";
6
- import { setMemoryEmbeddingField } from "../../config/raw-config-utils.js";
6
+ import {
7
+ deleteMemoryEmbeddingField,
8
+ setMemoryEmbeddingField,
9
+ } from "../../config/raw-config-utils.js";
7
10
  import { VALID_MEMORY_EMBEDDING_PROVIDERS } from "../../config/schemas/memory-storage.js";
8
11
  import {
9
12
  clearEmbeddingBackendCache,
@@ -118,7 +121,12 @@ export async function setEmbeddingConfig(
118
121
  if (model !== undefined) {
119
122
  const fieldName = PROVIDER_MODEL_FIELD[provider];
120
123
  if (fieldName) {
121
- setMemoryEmbeddingField(raw, fieldName, model);
124
+ if (model === "") {
125
+ // Empty string means "clear override — use schema default"
126
+ deleteMemoryEmbeddingField(raw, fieldName);
127
+ } else {
128
+ setMemoryEmbeddingField(raw, fieldName, model);
129
+ }
122
130
  }
123
131
  }
124
132
 
@@ -16,7 +16,6 @@ import {
16
16
  isProviderAvailable,
17
17
  } from "../../providers/provider-availability.js";
18
18
  import { initializeProviders } from "../../providers/registry.js";
19
- import { getMaskedProviderKey } from "../../security/secure-keys.js";
20
19
  import type {
21
20
  ImageGenModelSetRequest,
22
21
  ModelSetRequest,
@@ -44,7 +43,6 @@ export interface ModelInfo {
44
43
  configuredProviders?: string[];
45
44
  availableModels?: Array<{ id: string; displayName: string }>;
46
45
  allProviders?: ProviderCatalogEntry[];
47
- maskedKeys?: Record<string, string>;
48
46
  }
49
47
 
50
48
  /** Return current model configuration. */
@@ -52,19 +50,12 @@ export async function getModelInfo(): Promise<ModelInfo> {
52
50
  const config = getConfig();
53
51
  const provider = config.services.inference.provider;
54
52
 
55
- const maskedKeys: Record<string, string> = {};
56
- for (const p of VALID_INFERENCE_PROVIDERS) {
57
- const masked = await getMaskedProviderKey(p);
58
- if (masked) maskedKeys[p] = masked;
59
- }
60
-
61
53
  return {
62
54
  model: config.services.inference.model,
63
55
  provider,
64
56
  configuredProviders: await getConfiguredProviders(),
65
57
  availableModels: PROVIDER_CATALOG.find((p) => p.id === provider)?.models,
66
58
  allProviders: PROVIDER_CATALOG,
67
- maskedKeys,
68
59
  };
69
60
  }
70
61
 
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Returns true when the value is a template placeholder that should be treated
3
+ * as empty/unset. Placeholders follow the pattern `_(…)_`, e.g.
4
+ * `_(not yet chosen)_` or `_(not yet established)_`.
5
+ */
6
+ export function isTemplatePlaceholder(value: string): boolean {
7
+ return value.startsWith("_(") && value.endsWith(")_");
8
+ }
9
+
1
10
  export interface IdentityFields {
2
11
  name: string;
3
12
  role: string;
@@ -14,7 +23,9 @@ export function parseIdentityFields(content: string): IdentityFields {
14
23
  const lower = trimmed.toLowerCase();
15
24
  const extract = (prefix: string): string | null => {
16
25
  if (!lower.startsWith(prefix)) return null;
17
- return trimmed.split(":**").pop()?.trim() ?? null;
26
+ const value = trimmed.split(":**").pop()?.trim() ?? null;
27
+ if (value && isTemplatePlaceholder(value)) return null;
28
+ return value;
18
29
  };
19
30
 
20
31
  const name = extract("- **name:**");
@@ -200,14 +200,22 @@ export async function runDaemon(): Promise<void> {
200
200
  targetId: itemId,
201
201
  });
202
202
  }
203
+ for (const summaryId of deletedMemory.deletedSummaryIds) {
204
+ enqueueMemoryJob("delete_qdrant_vectors", {
205
+ targetType: "summary",
206
+ targetId: summaryId,
207
+ });
208
+ }
203
209
  if (
204
210
  deletedMemory.segmentIds.length > 0 ||
205
- deletedMemory.orphanedItemIds.length > 0
211
+ deletedMemory.orphanedItemIds.length > 0 ||
212
+ deletedMemory.deletedSummaryIds.length > 0
206
213
  ) {
207
214
  log.info(
208
215
  {
209
216
  segments: deletedMemory.segmentIds.length,
210
217
  orphanedItems: deletedMemory.orphanedItemIds.length,
218
+ deletedSummaries: deletedMemory.deletedSummaryIds.length,
211
219
  },
212
220
  "Enqueued Qdrant vector cleanup jobs for purged private conversations",
213
221
  );
@@ -258,7 +258,6 @@ export interface ModelInfo {
258
258
  apiKeyUrl?: string;
259
259
  apiKeyPlaceholder?: string;
260
260
  }>;
261
- maskedKeys?: Record<string, string>;
262
261
  }
263
262
 
264
263
  export interface HistoryResponseToolCall {
@@ -1262,7 +1262,7 @@ export class DaemonServer {
1262
1262
  const appId = surfaceId.slice(appOpenPrefix.length);
1263
1263
  for (const c of this.conversations.values()) {
1264
1264
  for (const [, state] of c.surfaceState.entries()) {
1265
- const data = state.data as Record<string, unknown>;
1265
+ const data = state.data as unknown as Record<string, unknown>;
1266
1266
  if (data?.appId === appId) {
1267
1267
  // Register this surfaceId so subsequent lookups are O(1)
1268
1268
  c.surfaceState.set(surfaceId, state);