@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
@@ -0,0 +1,121 @@
1
+ import {
2
+ index,
3
+ integer,
4
+ sqliteTable,
5
+ text,
6
+ uniqueIndex,
7
+ } from "drizzle-orm/sqlite-core";
8
+
9
+ import { conversations, messages } from "./conversations.js";
10
+
11
+ /**
12
+ * Raw observation records captured from conversation turns. Each observation
13
+ * is a single factual statement extracted from user or assistant messages,
14
+ * annotated with modality and source metadata for downstream recall.
15
+ */
16
+ export const memoryObservations = sqliteTable(
17
+ "memory_observations",
18
+ {
19
+ id: text("id").primaryKey(),
20
+ scopeId: text("scope_id").notNull().default("default"),
21
+ conversationId: text("conversation_id")
22
+ .notNull()
23
+ .references(() => conversations.id, { onDelete: "cascade" }),
24
+ messageId: text("message_id").references(() => messages.id, {
25
+ onDelete: "set null",
26
+ }),
27
+ /** The role that produced the observation (e.g. "user", "assistant"). */
28
+ role: text("role").notNull(),
29
+ /** Free-text statement capturing the observed fact. */
30
+ content: text("content").notNull(),
31
+ /**
32
+ * Modality of the source material: "text", "voice", "image", etc.
33
+ * Enables downstream filters for recall relevance.
34
+ */
35
+ modality: text("modality").notNull().default("text"),
36
+ /**
37
+ * Source channel or interface that produced the observation
38
+ * (e.g. "vellum", "telegram", "phone").
39
+ */
40
+ source: text("source"),
41
+ createdAt: integer("created_at").notNull(),
42
+ },
43
+ (table) => [
44
+ index("idx_memory_observations_scope_id").on(table.scopeId),
45
+ index("idx_memory_observations_conversation_id").on(table.conversationId),
46
+ index("idx_memory_observations_created_at").on(table.createdAt),
47
+ ],
48
+ );
49
+
50
+ /**
51
+ * Deduplicated content chunks derived from observations. Chunks are the unit
52
+ * of embedding and recall — each chunk carries a contentHash for idempotent
53
+ * dual-write safety so the same content is never stored twice.
54
+ */
55
+ export const memoryChunks = sqliteTable(
56
+ "memory_chunks",
57
+ {
58
+ id: text("id").primaryKey(),
59
+ scopeId: text("scope_id").notNull().default("default"),
60
+ observationId: text("observation_id")
61
+ .notNull()
62
+ .references(() => memoryObservations.id, { onDelete: "cascade" }),
63
+ /** The chunk text used for embedding and recall. */
64
+ content: text("content").notNull(),
65
+ /** Token count estimate for context-window budgeting. */
66
+ tokenEstimate: integer("token_estimate").notNull(),
67
+ /**
68
+ * SHA-256 hash of the normalized content, used to skip duplicate inserts
69
+ * during dual-write windows.
70
+ */
71
+ contentHash: text("content_hash").notNull(),
72
+ createdAt: integer("created_at").notNull(),
73
+ },
74
+ (table) => [
75
+ index("idx_memory_chunks_scope_id").on(table.scopeId),
76
+ index("idx_memory_chunks_observation_id").on(table.observationId),
77
+ uniqueIndex("idx_memory_chunks_content_hash").on(
78
+ table.scopeId,
79
+ table.contentHash,
80
+ ),
81
+ index("idx_memory_chunks_created_at").on(table.createdAt),
82
+ ],
83
+ );
84
+
85
+ /**
86
+ * Episode records that group related observations into coherent narrative
87
+ * units. An episode represents a meaningful interaction or topic span,
88
+ * with source-link metadata for provenance tracking.
89
+ */
90
+ export const memoryEpisodes = sqliteTable(
91
+ "memory_episodes",
92
+ {
93
+ id: text("id").primaryKey(),
94
+ scopeId: text("scope_id").notNull().default("default"),
95
+ conversationId: text("conversation_id")
96
+ .notNull()
97
+ .references(() => conversations.id, { onDelete: "cascade" }),
98
+ /** Human-readable title summarizing the episode. */
99
+ title: text("title").notNull(),
100
+ /** Longer narrative summary of the episode content. */
101
+ summary: text("summary").notNull(),
102
+ /** Token count estimate for the summary. */
103
+ tokenEstimate: integer("token_estimate").notNull(),
104
+ /**
105
+ * Source channel or interface that produced the episode
106
+ * (mirrors observation.source for episode-level filtering).
107
+ */
108
+ source: text("source"),
109
+ /** Epoch-ms timestamp of the earliest observation in the episode. */
110
+ startAt: integer("start_at").notNull(),
111
+ /** Epoch-ms timestamp of the latest observation in the episode. */
112
+ endAt: integer("end_at").notNull(),
113
+ createdAt: integer("created_at").notNull(),
114
+ updatedAt: integer("updated_at").notNull(),
115
+ },
116
+ (table) => [
117
+ index("idx_memory_episodes_scope_id").on(table.scopeId),
118
+ index("idx_memory_episodes_conversation_id").on(table.conversationId),
119
+ index("idx_memory_episodes_created_at").on(table.createdAt),
120
+ ],
121
+ );
@@ -0,0 +1,55 @@
1
+ import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
+
3
+ /**
4
+ * Time contexts represent bounded temporal windows that are relevant to the
5
+ * assistant's current awareness — e.g. "user is traveling next week",
6
+ * "quarterly planning period ends Friday". Each row captures one window
7
+ * with an activation range and a human-readable summary the brief can surface.
8
+ */
9
+ export const timeContexts = sqliteTable(
10
+ "time_contexts",
11
+ {
12
+ id: text("id").primaryKey(),
13
+ scopeId: text("scope_id").notNull(),
14
+ summary: text("summary").notNull(),
15
+ source: text("source").notNull(), // e.g. 'conversation', 'schedule', 'manual'
16
+ activeFrom: integer("active_from").notNull(), // epoch ms — window start
17
+ activeUntil: integer("active_until").notNull(), // epoch ms — window end
18
+ createdAt: integer("created_at").notNull(),
19
+ updatedAt: integer("updated_at").notNull(),
20
+ },
21
+ (table) => [
22
+ index("idx_time_contexts_scope_active_until").on(
23
+ table.scopeId,
24
+ table.activeUntil,
25
+ ),
26
+ ],
27
+ );
28
+
29
+ /**
30
+ * Open loops track unresolved items the assistant should follow up on —
31
+ * e.g. "waiting for Bob's reply", "need to file taxes before April 15".
32
+ * Each row carries a status and an optional due date so the brief can
33
+ * prioritise which loops to surface.
34
+ */
35
+ export const openLoops = sqliteTable(
36
+ "open_loops",
37
+ {
38
+ id: text("id").primaryKey(),
39
+ scopeId: text("scope_id").notNull(),
40
+ summary: text("summary").notNull(),
41
+ status: text("status").notNull().default("open"), // 'open' | 'resolved' | 'expired'
42
+ source: text("source").notNull(), // e.g. 'conversation', 'followup', 'manual'
43
+ dueAt: integer("due_at"), // epoch ms — optional deadline
44
+ surfacedAt: integer("surfaced_at"), // epoch ms — last time shown in brief
45
+ createdAt: integer("created_at").notNull(),
46
+ updatedAt: integer("updated_at").notNull(),
47
+ },
48
+ (table) => [
49
+ index("idx_open_loops_scope_status_due").on(
50
+ table.scopeId,
51
+ table.status,
52
+ table.dueAt,
53
+ ),
54
+ ],
55
+ );
@@ -61,6 +61,7 @@ export async function semanticSearch(
61
61
  fetchLimit,
62
62
  ["item", "summary", "segment", "media"],
63
63
  excludedMessageIds,
64
+ scopeIds,
64
65
  ),
65
66
  );
66
67
  }
@@ -277,13 +278,13 @@ export async function semanticSearch(
277
278
  * Build a Qdrant filter for hybrid search. Mirrors the logic in
278
279
  * `searchWithFilter` but as a standalone object for the query API.
279
280
  *
280
- * Scope filtering: items and media store `memory_scope_id` on the Qdrant
281
- * point payload, so we can filter at the Qdrant level. Segments and
282
- * summaries rely on post-query DB filtering (same as dense-only search).
281
+ * Scope filtering: points with a `memory_scope_id` payload field are
282
+ * filtered at the Qdrant level. Legacy points without the field pass
283
+ * through and are caught by post-query DB filtering.
283
284
  */
284
285
  function buildHybridFilter(
285
286
  excludeMessageIds: string[],
286
- _scopeIds?: string[],
287
+ scopeIds?: string[],
287
288
  ): Record<string, unknown> {
288
289
  const mustConditions: Array<Record<string, unknown>> = [
289
290
  {
@@ -310,6 +311,18 @@ function buildHybridFilter(
310
311
  });
311
312
  }
312
313
 
314
+ // Scope filtering: accept points whose memory_scope_id matches one of the
315
+ // allowed scopes, OR points that lack the field entirely (legacy data).
316
+ // Post-query DB filtering remains as defense-in-depth for legacy points.
317
+ if (scopeIds && scopeIds.length > 0) {
318
+ mustConditions.push({
319
+ should: [
320
+ { key: "memory_scope_id", match: { any: scopeIds } },
321
+ { is_empty: { key: "memory_scope_id" } },
322
+ ],
323
+ });
324
+ }
325
+
313
326
  const mustNotConditions: Array<Record<string, unknown>> = [
314
327
  { key: "_meta", match: { value: true } },
315
328
  ];
@@ -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,
@@ -128,7 +128,7 @@ const ACTOR_ENDPOINTS: Array<{ endpoint: string; scopes: Scope[] }> = [
128
128
  { endpoint: "messages:POST", scopes: ["chat.write"] },
129
129
  { endpoint: "btw", scopes: ["chat.write"] },
130
130
  { endpoint: "conversations", scopes: ["chat.read"] },
131
- { endpoint: "conversations:DELETE", scopes: ["chat.write"] },
131
+ { endpoint: "conversations:POST", scopes: ["chat.write"] },
132
132
  { endpoint: "conversations/fork", scopes: ["chat.write"] },
133
133
  { endpoint: "conversations/switch", scopes: ["chat.write"] },
134
134
  { endpoint: "conversations/name", scopes: ["chat.write"] },
@@ -348,6 +348,7 @@ const ACTOR_ENDPOINTS: Array<{ endpoint: string; scopes: Scope[] }> = [
348
348
  { endpoint: "config/embeddings:PUT", scopes: ["settings.write"] },
349
349
 
350
350
  // Conversation management
351
+ { endpoint: "conversations:DELETE", scopes: ["chat.write"] },
351
352
  { endpoint: "conversations/wipe", scopes: ["chat.write"] },
352
353
  { endpoint: "conversations/reorder", scopes: ["chat.write"] },
353
354
 
@@ -470,6 +471,14 @@ for (const { endpoint, scopes } of ACTOR_ENDPOINTS) {
470
471
  });
471
472
  }
472
473
 
474
+ // Clear-all conversations: elevated to settings.write (destructive bulk operation).
475
+ // Uses a distinct key so the single-conversation DELETE (conversations:DELETE)
476
+ // retains the lower chat.write scope.
477
+ registerPolicy("conversations/clear-all", {
478
+ requiredScopes: ["settings.write"],
479
+ allowedPrincipalTypes: ["actor", "svc_gateway", "svc_daemon", "local"],
480
+ });
481
+
473
482
  // Channel inbound: gateway-only
474
483
  registerPolicy("channels/inbound", {
475
484
  requiredScopes: ["ingress.write"],
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Route handlers for conversation management operations.
3
3
  *
4
+ * POST /v1/conversations — create a new conversation
4
5
  * POST /v1/conversations/switch — switch to an existing conversation
5
6
  * POST /v1/conversations/fork — fork an existing conversation
6
7
  * PATCH /v1/conversations/:id/name — rename a conversation
@@ -19,7 +20,9 @@ import {
19
20
  PRIVATE_CONVERSATION_FORK_ERROR,
20
21
  wipeConversation,
21
22
  } from "../../memory/conversation-crud.js";
23
+ import { updateConversationTitle } from "../../memory/conversation-crud.js";
22
24
  import {
25
+ getOrCreateConversation,
23
26
  resolveConversationId,
24
27
  setConversationKeyIfAbsent,
25
28
  } from "../../memory/conversation-key-store.js";
@@ -66,6 +69,44 @@ export function conversationManagementRouteDefinitions(
66
69
  deps: ConversationManagementDeps,
67
70
  ): RouteDefinition[] {
68
71
  return [
72
+ {
73
+ endpoint: "conversations",
74
+ method: "POST",
75
+ policyKey: "conversations",
76
+ handler: async ({ req }) => {
77
+ let body: { conversationKey?: string; conversationType?: string } = {};
78
+ try {
79
+ body = (await req.json()) as typeof body;
80
+ } catch {
81
+ // Empty or malformed body — fall through with defaults.
82
+ }
83
+ const conversationKey = body.conversationKey ?? crypto.randomUUID();
84
+ const requestedType =
85
+ body.conversationType === "private" ? "private" : "standard";
86
+ const result = getOrCreateConversation(conversationKey, {
87
+ conversationType: requestedType,
88
+ });
89
+ if (result.created) {
90
+ updateConversationTitle(result.conversationId, "New Conversation");
91
+ }
92
+ log.info(
93
+ {
94
+ conversationId: result.conversationId,
95
+ conversationKey,
96
+ created: result.created,
97
+ },
98
+ "Created conversation via POST",
99
+ );
100
+ return Response.json(
101
+ {
102
+ id: result.conversationId,
103
+ conversationKey,
104
+ conversationType: result.conversationType,
105
+ },
106
+ { status: result.created ? 201 : 200 },
107
+ );
108
+ },
109
+ },
69
110
  {
70
111
  endpoint: "conversations/fork",
71
112
  method: "POST",
@@ -185,8 +226,17 @@ export function conversationManagementRouteDefinitions(
185
226
  {
186
227
  endpoint: "conversations",
187
228
  method: "DELETE",
188
- policyKey: "conversations",
189
- handler: () => {
229
+ policyKey: "conversations/clear-all",
230
+ handler: ({ req }) => {
231
+ const confirm = req.headers.get("x-confirm-destructive");
232
+ if (confirm !== "clear-all-conversations") {
233
+ return httpError(
234
+ "BAD_REQUEST",
235
+ "DELETE /v1/conversations permanently deletes ALL conversations, messages, and memory. " +
236
+ "To confirm, set header X-Confirm-Destructive: clear-all-conversations",
237
+ 400,
238
+ );
239
+ }
190
240
  deps.clearAllConversations();
191
241
  return new Response(null, { status: 204 });
192
242
  },
@@ -225,6 +275,24 @@ export function conversationManagementRouteDefinitions(
225
275
  targetId: summaryId,
226
276
  });
227
277
  }
278
+ for (const obsId of result.deletedObservationIds) {
279
+ enqueueMemoryJob("delete_qdrant_vectors", {
280
+ targetType: "observation",
281
+ targetId: obsId,
282
+ });
283
+ }
284
+ for (const chunkId of result.deletedChunkIds) {
285
+ enqueueMemoryJob("delete_qdrant_vectors", {
286
+ targetType: "chunk",
287
+ targetId: chunkId,
288
+ });
289
+ }
290
+ for (const episodeId of result.deletedEpisodeIds) {
291
+ enqueueMemoryJob("delete_qdrant_vectors", {
292
+ targetType: "episode",
293
+ targetId: episodeId,
294
+ });
295
+ }
228
296
  log.info(
229
297
  {
230
298
  conversationId: resolvedId,
@@ -275,6 +343,30 @@ export function conversationManagementRouteDefinitions(
275
343
  targetId: itemId,
276
344
  });
277
345
  }
346
+ for (const summaryId of deleted.deletedSummaryIds) {
347
+ enqueueMemoryJob("delete_qdrant_vectors", {
348
+ targetType: "summary",
349
+ targetId: summaryId,
350
+ });
351
+ }
352
+ for (const obsId of deleted.deletedObservationIds) {
353
+ enqueueMemoryJob("delete_qdrant_vectors", {
354
+ targetType: "observation",
355
+ targetId: obsId,
356
+ });
357
+ }
358
+ for (const chunkId of deleted.deletedChunkIds) {
359
+ enqueueMemoryJob("delete_qdrant_vectors", {
360
+ targetType: "chunk",
361
+ targetId: chunkId,
362
+ });
363
+ }
364
+ for (const episodeId of deleted.deletedEpisodeIds) {
365
+ enqueueMemoryJob("delete_qdrant_vectors", {
366
+ targetType: "episode",
367
+ targetId: episodeId,
368
+ });
369
+ }
278
370
  log.info({ conversationId: resolvedId }, "Deleted conversation");
279
371
  return new Response(null, { status: 204 });
280
372
  },
@@ -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 },