@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
@@ -5,6 +5,7 @@ import { getConfig } from "../config/loader.js";
5
5
  import type { MemoryConfig } from "../config/types.js";
6
6
  import type { TrustClass } from "../runtime/actor-trust-resolver.js";
7
7
  import { getLogger } from "../util/logger.js";
8
+ import { computeChunkContentHash } from "./archive-store.js";
8
9
  import { getDb } from "./db.js";
9
10
  import { selectedBackendSupportsMultimodal } from "./embedding-backend.js";
10
11
  import { enqueueMemoryJob } from "./jobs-store.js";
@@ -12,7 +13,7 @@ import {
12
13
  extractMediaBlockMeta,
13
14
  extractTextFromStoredMessageContent,
14
15
  } from "./message-content.js";
15
- import { memorySegments } from "./schema.js";
16
+ import { memoryChunks, memoryObservations, memorySegments } from "./schema.js";
16
17
  import { segmentText } from "./segmenter.js";
17
18
 
18
19
  const log = getLogger("memory-indexer");
@@ -53,7 +54,12 @@ export async function indexMessageNow(
53
54
  input.provenanceTrustClass === undefined;
54
55
 
55
56
  const text = extractTextFromStoredMessageContent(input.content);
56
- if (text.length === 0) {
57
+ const hasText = text.length > 0;
58
+ const candidateMediaMeta = extractMediaBlockMeta(input.content).filter(
59
+ (b) => b.type === "image",
60
+ );
61
+ const hasMedia = candidateMediaMeta.length > 0;
62
+ if (!hasText && !hasMedia) {
57
63
  enqueueMemoryJob("build_conversation_summary", {
58
64
  conversationId: input.conversationId,
59
65
  });
@@ -62,11 +68,13 @@ export async function indexMessageNow(
62
68
 
63
69
  const db = getDb();
64
70
  const now = Date.now();
65
- const segments = segmentText(
66
- text,
67
- config.segmentation.targetTokens,
68
- config.segmentation.overlapTokens,
69
- );
71
+ const segments = hasText
72
+ ? segmentText(
73
+ text,
74
+ config.segmentation.targetTokens,
75
+ config.segmentation.overlapTokens,
76
+ )
77
+ : [];
70
78
  const shouldExtract =
71
79
  input.role === "user" ||
72
80
  (input.role === "assistant" && config.extraction.extractFromAssistant);
@@ -76,9 +84,6 @@ export async function indexMessageNow(
76
84
  // overhead for messages on non-multimodal backends.
77
85
  // selectedBackendSupportsMultimodal requires async key resolution, so we
78
86
  // skip it entirely for text-only messages.
79
- const candidateMediaMeta = extractMediaBlockMeta(input.content).filter(
80
- (b) => b.type === "image",
81
- );
82
87
  const mediaBlocks =
83
88
  candidateMediaMeta.length > 0 &&
84
89
  (await selectedBackendSupportsMultimodal(getConfig()))
@@ -88,7 +93,10 @@ export async function indexMessageNow(
88
93
  // Wrap all segment inserts and job enqueues in a single transaction so they
89
94
  // either all succeed or all roll back, preventing partial/orphaned state.
90
95
  let skippedEmbedJobs = 0;
96
+ let skippedChunkEmbedJobs = 0;
97
+ const scopeId = input.scopeId ?? "default";
91
98
  db.transaction((tx) => {
99
+ // ── Legacy segment path (kept intact for parallel validation) ───
92
100
  for (const segment of segments) {
93
101
  const segmentId = buildSegmentId(input.messageId, segment.segmentIndex);
94
102
  const hash = createHash("sha256").update(segment.text).digest("hex");
@@ -109,7 +117,7 @@ export async function indexMessageNow(
109
117
  segmentIndex: segment.segmentIndex,
110
118
  text: segment.text,
111
119
  tokenEstimate: segment.tokenEstimate,
112
- scopeId: input.scopeId ?? "default",
120
+ scopeId,
113
121
  contentHash: hash,
114
122
  createdAt: input.createdAt,
115
123
  updatedAt: now,
@@ -119,7 +127,7 @@ export async function indexMessageNow(
119
127
  set: {
120
128
  text: segment.text,
121
129
  tokenEstimate: segment.tokenEstimate,
122
- scopeId: input.scopeId ?? "default",
130
+ scopeId,
123
131
  contentHash: hash,
124
132
  updatedAt: now,
125
133
  },
@@ -133,6 +141,65 @@ export async function indexMessageNow(
133
141
  }
134
142
  }
135
143
 
144
+ // ── Archive chunk dual-write (mirrors segment boundaries) ──────
145
+ // Create a single observation per message, then create one chunk per
146
+ // segment using the same segmentation boundaries. Chunks are
147
+ // deduplicated by (scopeId, contentHash) via onConflictDoNothing so
148
+ // unchanged content does not enqueue duplicate embed_chunk jobs.
149
+ const observationId = buildObservationId(input.messageId);
150
+ tx.insert(memoryObservations)
151
+ .values({
152
+ id: observationId,
153
+ scopeId,
154
+ conversationId: input.conversationId,
155
+ messageId: input.messageId,
156
+ role: input.role,
157
+ content: hasText ? text : input.content,
158
+ modality: hasMedia ? "multimodal" : "text",
159
+ source: null,
160
+ createdAt: input.createdAt,
161
+ })
162
+ .onConflictDoNothing({ target: memoryObservations.id })
163
+ .run();
164
+
165
+ for (const segment of segments) {
166
+ const chunkId = buildChunkId(input.messageId, segment.segmentIndex);
167
+ const chunkHash = computeChunkContentHash(scopeId, segment.text);
168
+
169
+ // Check if this chunk already exists with the same content hash
170
+ const existingChunk = tx
171
+ .select({ contentHash: memoryChunks.contentHash })
172
+ .from(memoryChunks)
173
+ .where(eq(memoryChunks.id, chunkId))
174
+ .get();
175
+
176
+ tx.insert(memoryChunks)
177
+ .values({
178
+ id: chunkId,
179
+ scopeId,
180
+ observationId,
181
+ content: segment.text,
182
+ tokenEstimate: segment.tokenEstimate,
183
+ contentHash: chunkHash,
184
+ createdAt: input.createdAt,
185
+ })
186
+ .onConflictDoUpdate({
187
+ target: memoryChunks.id,
188
+ set: {
189
+ content: segment.text,
190
+ tokenEstimate: segment.tokenEstimate,
191
+ contentHash: chunkHash,
192
+ },
193
+ })
194
+ .run();
195
+
196
+ if (existingChunk?.contentHash === chunkHash) {
197
+ skippedChunkEmbedJobs++;
198
+ } else {
199
+ enqueueMemoryJob("embed_chunk", { chunkId, scopeId }, Date.now(), tx);
200
+ }
201
+ }
202
+
136
203
  // Enqueue embed_attachment jobs for image content blocks when the
137
204
  // embedding provider supports multimodal (Gemini only).
138
205
  for (const block of mediaBlocks) {
@@ -147,7 +214,7 @@ export async function indexMessageNow(
147
214
  if (shouldExtract && isTrustedActor && !input.automated) {
148
215
  enqueueMemoryJob(
149
216
  "extract_items",
150
- { messageId: input.messageId, scopeId: input.scopeId ?? "default" },
217
+ { messageId: input.messageId, scopeId },
151
218
  Date.now(),
152
219
  tx,
153
220
  );
@@ -166,6 +233,12 @@ export async function indexMessageNow(
166
233
  );
167
234
  }
168
235
 
236
+ if (skippedChunkEmbedJobs > 0) {
237
+ log.debug(
238
+ `Skipped ${skippedChunkEmbedJobs}/${segments.length} embed_chunk jobs (content unchanged)`,
239
+ );
240
+ }
241
+
169
242
  if (!isTrustedActor && shouldExtract) {
170
243
  log.info(
171
244
  `Skipping extraction jobs for untrusted actor (trustClass=${input.provenanceTrustClass})`,
@@ -177,9 +250,11 @@ export async function indexMessageNow(
177
250
  }
178
251
 
179
252
  const extractionGated = !isTrustedActor || !!input.automated;
253
+ const segmentEmbedJobs = segments.length - skippedEmbedJobs;
254
+ const chunkEmbedJobs = segments.length - skippedChunkEmbedJobs;
180
255
  const enqueuedJobs =
181
- segments.length -
182
- skippedEmbedJobs +
256
+ segmentEmbedJobs +
257
+ chunkEmbedJobs +
183
258
  mediaBlocks.length +
184
259
  (shouldExtract && !extractionGated ? 2 : 1);
185
260
  return {
@@ -213,3 +288,19 @@ export function getRecentSegmentsForConversation(
213
288
  function buildSegmentId(messageId: string, segmentIndex: number): string {
214
289
  return `${messageId}:${segmentIndex}`;
215
290
  }
291
+
292
+ /**
293
+ * Deterministic observation ID derived from the messageId so repeated
294
+ * indexer runs for the same message converge on the same observation row.
295
+ */
296
+ function buildObservationId(messageId: string): string {
297
+ return `obs:${messageId}`;
298
+ }
299
+
300
+ /**
301
+ * Deterministic chunk ID derived from the messageId and segment index so
302
+ * the dual-write path mirrors the legacy segment identity scheme exactly.
303
+ */
304
+ function buildChunkId(messageId: string, segmentIndex: number): string {
305
+ return `chunk:${messageId}:${segmentIndex}`;
306
+ }
@@ -183,6 +183,7 @@ describe("embedMediaJob", () => {
183
183
  expect(call.extraPayload).toEqual({
184
184
  created_at: now,
185
185
  kind: "image",
186
+ memory_scope_id: "default",
186
187
  subject: "My Screenshot",
187
188
  });
188
189
  });
@@ -11,7 +11,10 @@ import type { MemoryJob } from "../jobs-store.js";
11
11
  import { extractMediaBlocks } from "../message-content.js";
12
12
  import {
13
13
  mediaAssets,
14
+ memoryChunks,
15
+ memoryEpisodes,
14
16
  memoryItems,
17
+ memoryObservations,
15
18
  memorySegments,
16
19
  memorySummaries,
17
20
  messages,
@@ -34,6 +37,7 @@ export async function embedSegmentJob(
34
37
  conversation_id: segment.conversationId,
35
38
  message_id: segment.messageId,
36
39
  created_at: segment.createdAt,
40
+ memory_scope_id: segment.scopeId,
37
41
  });
38
42
  }
39
43
 
@@ -58,6 +62,7 @@ export async function embedItemJob(
58
62
  confidence: item.confidence,
59
63
  created_at: item.firstSeenAt,
60
64
  last_seen_at: item.lastSeenAt,
65
+ memory_scope_id: item.scopeId,
61
66
  });
62
67
  }
63
68
 
@@ -83,10 +88,31 @@ export async function embedSummaryJob(
83
88
  kind: summary.scope,
84
89
  created_at: summary.startAt,
85
90
  last_seen_at: summary.endAt,
91
+ memory_scope_id: summary.scopeId,
86
92
  },
87
93
  );
88
94
  }
89
95
 
96
+ export async function embedChunkJob(
97
+ job: MemoryJob,
98
+ config: AssistantConfig,
99
+ ): Promise<void> {
100
+ const chunkId = asString(job.payload.chunkId);
101
+ if (!chunkId) return;
102
+ const db = getDb();
103
+ const chunk = db
104
+ .select()
105
+ .from(memoryChunks)
106
+ .where(eq(memoryChunks.id, chunkId))
107
+ .get();
108
+ if (!chunk) return;
109
+ await embedAndUpsert(config, "chunk", chunk.id, chunk.content, {
110
+ observation_id: chunk.observationId,
111
+ created_at: chunk.createdAt,
112
+ memory_scope_id: chunk.scopeId,
113
+ });
114
+ }
115
+
90
116
  export async function embedMediaJob(
91
117
  job: MemoryJob,
92
118
  config: AssistantConfig,
@@ -116,6 +142,41 @@ export async function embedMediaJob(
116
142
  created_at: asset.createdAt,
117
143
  kind: asset.mediaType,
118
144
  subject: asset.title,
145
+ memory_scope_id: "default",
146
+ });
147
+ }
148
+
149
+ export async function embedObservationJob(
150
+ job: MemoryJob,
151
+ config: AssistantConfig,
152
+ ): Promise<void> {
153
+ const observationId = asString(job.payload.observationId);
154
+ const chunkId = asString(job.payload.chunkId);
155
+ if (!observationId || !chunkId) return;
156
+
157
+ const db = getDb();
158
+ const observation = db
159
+ .select()
160
+ .from(memoryObservations)
161
+ .where(eq(memoryObservations.id, observationId))
162
+ .get();
163
+ if (!observation) return;
164
+
165
+ const chunk = db
166
+ .select()
167
+ .from(memoryChunks)
168
+ .where(eq(memoryChunks.id, chunkId))
169
+ .get();
170
+ if (!chunk) return;
171
+
172
+ await embedAndUpsert(config, "observation", chunk.id, chunk.content, {
173
+ observation_id: observationId,
174
+ conversation_id: observation.conversationId,
175
+ role: observation.role,
176
+ modality: observation.modality,
177
+ source: observation.source,
178
+ created_at: observation.createdAt,
179
+ memory_scope_id: observation.scopeId,
119
180
  });
120
181
  }
121
182
 
@@ -155,3 +216,25 @@ export async function embedAttachmentJob(
155
216
  memory_scope_id: memoryScopeId,
156
217
  });
157
218
  }
219
+
220
+ export async function embedEpisodeJob(
221
+ job: MemoryJob,
222
+ config: AssistantConfig,
223
+ ): Promise<void> {
224
+ const episodeId = asString(job.payload.episodeId);
225
+ if (!episodeId) return;
226
+ const db = getDb();
227
+ const episode = db
228
+ .select()
229
+ .from(memoryEpisodes)
230
+ .where(eq(memoryEpisodes.id, episodeId))
231
+ .get();
232
+ if (!episode) return;
233
+ const text = `[episode] ${episode.title}: ${episode.summary}`;
234
+ await embedAndUpsert(config, "episode", episode.id, text, {
235
+ conversation_id: episode.conversationId,
236
+ created_at: episode.startAt,
237
+ last_seen_at: episode.endAt,
238
+ memory_scope_id: episode.scopeId,
239
+ });
240
+ }
@@ -142,7 +142,7 @@ export function truncate(text: string, max: number): string {
142
142
 
143
143
  export async function embedAndUpsert(
144
144
  config: AssistantConfig,
145
- targetType: "segment" | "item" | "summary" | "media",
145
+ targetType: "segment" | "item" | "summary" | "observation" | "chunk" | "episode" | "media",
146
146
  targetId: string,
147
147
  input: EmbeddingInput,
148
148
  extraPayload?: Record<string, unknown>,
@@ -12,6 +12,9 @@ export type MemoryJobType =
12
12
  | "embed_segment"
13
13
  | "embed_item"
14
14
  | "embed_summary"
15
+ | "embed_chunk"
16
+ | "embed_episode"
17
+ | "embed_observation"
15
18
  | "extract_items"
16
19
  | "extract_entities"
17
20
  | "cleanup_stale_superseded_items"
@@ -34,6 +37,9 @@ const EMBED_JOB_TYPES: MemoryJobType[] = [
34
37
  "embed_segment",
35
38
  "embed_item",
36
39
  "embed_summary",
40
+ "embed_chunk",
41
+ "embed_episode",
42
+ "embed_observation",
37
43
  "embed_media",
38
44
  "embed_attachment",
39
45
  ];
@@ -11,8 +11,11 @@ import { generateConversationStartersJob } from "./job-handlers/conversation-sta
11
11
  // ── Per-job-type handlers ──────────────────────────────────────────
12
12
  import {
13
13
  embedAttachmentJob,
14
+ embedChunkJob,
15
+ embedEpisodeJob,
14
16
  embedItemJob,
15
17
  embedMediaJob,
18
+ embedObservationJob,
16
19
  embedSegmentJob,
17
20
  embedSummaryJob,
18
21
  } from "./job-handlers/embedding.js";
@@ -267,6 +270,15 @@ async function processJob(
267
270
  case "embed_summary":
268
271
  await embedSummaryJob(job, config);
269
272
  return;
273
+ case "embed_chunk":
274
+ await embedChunkJob(job, config);
275
+ return;
276
+ case "embed_episode":
277
+ await embedEpisodeJob(job, config);
278
+ return;
279
+ case "embed_observation":
280
+ await embedObservationJob(job, config);
281
+ return;
270
282
  case "extract_items":
271
283
  await extractItemsJob(job);
272
284
  return;
@@ -0,0 +1,52 @@
1
+ import type { DrizzleDb } from "../db-connection.js";
2
+ import { getSqliteFrom } from "../db-connection.js";
3
+
4
+ /**
5
+ * Create the memory brief state tables: time_contexts and open_loops.
6
+ *
7
+ * Both tables use CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT EXISTS,
8
+ * making this migration inherently idempotent — safe to re-run on every startup
9
+ * without a checkpoint guard.
10
+ */
11
+ export function migrateMemoryBriefState(database: DrizzleDb): void {
12
+ const raw = getSqliteFrom(database);
13
+
14
+ // -- time_contexts: bounded temporal windows for the brief --
15
+ raw.exec(/*sql*/ `
16
+ CREATE TABLE IF NOT EXISTS time_contexts (
17
+ id TEXT PRIMARY KEY,
18
+ scope_id TEXT NOT NULL,
19
+ summary TEXT NOT NULL,
20
+ source TEXT NOT NULL,
21
+ active_from INTEGER NOT NULL,
22
+ active_until INTEGER NOT NULL,
23
+ created_at INTEGER NOT NULL,
24
+ updated_at INTEGER NOT NULL
25
+ )
26
+ `);
27
+
28
+ raw.exec(/*sql*/ `
29
+ CREATE INDEX IF NOT EXISTS idx_time_contexts_scope_active_until
30
+ ON time_contexts (scope_id, active_until)
31
+ `);
32
+
33
+ // -- open_loops: unresolved items the brief should surface --
34
+ raw.exec(/*sql*/ `
35
+ CREATE TABLE IF NOT EXISTS open_loops (
36
+ id TEXT PRIMARY KEY,
37
+ scope_id TEXT NOT NULL,
38
+ summary TEXT NOT NULL,
39
+ status TEXT NOT NULL DEFAULT 'open',
40
+ source TEXT NOT NULL,
41
+ due_at INTEGER,
42
+ surfaced_at INTEGER,
43
+ created_at INTEGER NOT NULL,
44
+ updated_at INTEGER NOT NULL
45
+ )
46
+ `);
47
+
48
+ raw.exec(/*sql*/ `
49
+ CREATE INDEX IF NOT EXISTS idx_open_loops_scope_status_due
50
+ ON open_loops (scope_id, status, due_at)
51
+ `);
52
+ }
@@ -0,0 +1,109 @@
1
+ import type { DrizzleDb } from "../db-connection.js";
2
+ import { getSqliteFrom } from "../db-connection.js";
3
+
4
+ /**
5
+ * Create the memory archive tables (memory_observations, memory_chunks,
6
+ * memory_episodes) with prefetch indexes on scopeId, conversationId, and
7
+ * createdAt.
8
+ *
9
+ * All statements use IF NOT EXISTS / IF NOT EXISTS guards so the migration
10
+ * is safe to re-run on every startup.
11
+ */
12
+ export function migrateMemoryArchiveTables(database: DrizzleDb): void {
13
+ const raw = getSqliteFrom(database);
14
+
15
+ // -- memory_observations --------------------------------------------------
16
+ raw.exec(/*sql*/ `
17
+ CREATE TABLE IF NOT EXISTS memory_observations (
18
+ id TEXT PRIMARY KEY,
19
+ scope_id TEXT NOT NULL DEFAULT 'default',
20
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
21
+ message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
22
+ role TEXT NOT NULL,
23
+ content TEXT NOT NULL,
24
+ modality TEXT NOT NULL DEFAULT 'text',
25
+ source TEXT,
26
+ created_at INTEGER NOT NULL
27
+ )
28
+ `);
29
+
30
+ raw.exec(/*sql*/ `
31
+ CREATE INDEX IF NOT EXISTS idx_memory_observations_scope_id
32
+ ON memory_observations (scope_id)
33
+ `);
34
+
35
+ raw.exec(/*sql*/ `
36
+ CREATE INDEX IF NOT EXISTS idx_memory_observations_conversation_id
37
+ ON memory_observations (conversation_id)
38
+ `);
39
+
40
+ raw.exec(/*sql*/ `
41
+ CREATE INDEX IF NOT EXISTS idx_memory_observations_created_at
42
+ ON memory_observations (created_at)
43
+ `);
44
+
45
+ // -- memory_chunks --------------------------------------------------------
46
+ raw.exec(/*sql*/ `
47
+ CREATE TABLE IF NOT EXISTS memory_chunks (
48
+ id TEXT PRIMARY KEY,
49
+ scope_id TEXT NOT NULL DEFAULT 'default',
50
+ observation_id TEXT NOT NULL REFERENCES memory_observations(id) ON DELETE CASCADE,
51
+ content TEXT NOT NULL,
52
+ token_estimate INTEGER NOT NULL,
53
+ content_hash TEXT NOT NULL,
54
+ created_at INTEGER NOT NULL
55
+ )
56
+ `);
57
+
58
+ raw.exec(/*sql*/ `
59
+ CREATE INDEX IF NOT EXISTS idx_memory_chunks_scope_id
60
+ ON memory_chunks (scope_id)
61
+ `);
62
+
63
+ raw.exec(/*sql*/ `
64
+ CREATE INDEX IF NOT EXISTS idx_memory_chunks_observation_id
65
+ ON memory_chunks (observation_id)
66
+ `);
67
+
68
+ raw.exec(/*sql*/ `
69
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_chunks_content_hash
70
+ ON memory_chunks (scope_id, content_hash)
71
+ `);
72
+
73
+ raw.exec(/*sql*/ `
74
+ CREATE INDEX IF NOT EXISTS idx_memory_chunks_created_at
75
+ ON memory_chunks (created_at)
76
+ `);
77
+
78
+ // -- memory_episodes ------------------------------------------------------
79
+ raw.exec(/*sql*/ `
80
+ CREATE TABLE IF NOT EXISTS memory_episodes (
81
+ id TEXT PRIMARY KEY,
82
+ scope_id TEXT NOT NULL DEFAULT 'default',
83
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
84
+ title TEXT NOT NULL,
85
+ summary TEXT NOT NULL,
86
+ token_estimate INTEGER NOT NULL,
87
+ source TEXT,
88
+ start_at INTEGER NOT NULL,
89
+ end_at INTEGER NOT NULL,
90
+ created_at INTEGER NOT NULL,
91
+ updated_at INTEGER NOT NULL
92
+ )
93
+ `);
94
+
95
+ raw.exec(/*sql*/ `
96
+ CREATE INDEX IF NOT EXISTS idx_memory_episodes_scope_id
97
+ ON memory_episodes (scope_id)
98
+ `);
99
+
100
+ raw.exec(/*sql*/ `
101
+ CREATE INDEX IF NOT EXISTS idx_memory_episodes_conversation_id
102
+ ON memory_episodes (conversation_id)
103
+ `);
104
+
105
+ raw.exec(/*sql*/ `
106
+ CREATE INDEX IF NOT EXISTS idx_memory_episodes_created_at
107
+ ON memory_episodes (created_at)
108
+ `);
109
+ }
@@ -0,0 +1,19 @@
1
+ import type { DrizzleDb } from "../db-connection.js";
2
+ import { getSqliteFrom } from "../db-connection.js";
3
+
4
+ export function migrateMemoryReducerCheckpoints(database: DrizzleDb): void {
5
+ const raw = getSqliteFrom(database);
6
+ const columns = [
7
+ "memory_reduced_through_message_id TEXT",
8
+ "memory_dirty_tail_since_message_id TEXT",
9
+ "memory_last_reduced_at INTEGER",
10
+ ];
11
+
12
+ for (const column of columns) {
13
+ try {
14
+ raw.exec(`ALTER TABLE conversations ADD COLUMN ${column}`);
15
+ } catch {
16
+ // Column already exists — nothing to do.
17
+ }
18
+ }
19
+ }
@@ -126,6 +126,9 @@ export { migrateRenameThreadStartersCheckpoints } from "./181-rename-thread-star
126
126
  export { migrateOAuthProvidersDisplayMetadata } from "./182-oauth-providers-display-metadata.js";
127
127
  export { migrateConversationForkLineage } from "./183-add-conversation-fork-lineage.js";
128
128
  export { migrateLlmRequestLogProvider } from "./184-llm-request-log-provider.js";
129
+ export { migrateMemoryBriefState } from "./185-memory-brief-state.js";
130
+ export { migrateMemoryArchiveTables } from "./186-memory-archive.js";
131
+ export { migrateMemoryReducerCheckpoints } from "./187-memory-reducer-checkpoints.js";
129
132
  export {
130
133
  MIGRATION_REGISTRY,
131
134
  type MigrationRegistryEntry,
@@ -20,7 +20,7 @@ export interface QdrantClientConfig {
20
20
  }
21
21
 
22
22
  export interface QdrantPointPayload {
23
- target_type: "segment" | "item" | "summary" | "media";
23
+ target_type: "segment" | "item" | "summary" | "observation" | "chunk" | "episode" | "media";
24
24
  target_id: string;
25
25
  text: string;
26
26
  kind?: string;
@@ -230,7 +230,7 @@ export class VellumQdrantClient {
230
230
  }
231
231
 
232
232
  async upsert(
233
- targetType: "segment" | "item" | "summary" | "media",
233
+ targetType: "segment" | "item" | "summary" | "observation" | "chunk" | "episode" | "media",
234
234
  targetId: string,
235
235
  vector: number[],
236
236
  payload: Omit<QdrantPointPayload, "target_type" | "target_id">,
@@ -324,8 +324,11 @@ export class VellumQdrantClient {
324
324
  async searchWithFilter(
325
325
  vector: number[],
326
326
  limit: number,
327
- targetTypes: Array<"segment" | "item" | "summary" | "media">,
327
+ targetTypes: Array<
328
+ "segment" | "item" | "summary" | "media" | "chunk" | "episode"
329
+ >,
328
330
  excludeMessageIds?: string[],
331
+ scopeIds?: string[],
329
332
  ): Promise<QdrantSearchResult[]> {
330
333
  const mustConditions: Array<Record<string, unknown>> = [
331
334
  {
@@ -346,12 +349,24 @@ export class VellumQdrantClient {
346
349
  },
347
350
  {
348
351
  key: "target_type",
349
- match: { any: ["segment", "summary", "media"] },
352
+ match: { any: ["segment", "summary", "media", "chunk"] },
350
353
  },
351
354
  ],
352
355
  });
353
356
  }
354
357
 
358
+ // Scope filtering: accept points whose memory_scope_id matches one of the
359
+ // allowed scopes, OR points that lack the field entirely (legacy data).
360
+ // Post-query DB filtering remains as defense-in-depth for legacy points.
361
+ if (scopeIds && scopeIds.length > 0) {
362
+ mustConditions.push({
363
+ should: [
364
+ { key: "memory_scope_id", match: { any: scopeIds } },
365
+ { is_empty: { key: "memory_scope_id" } },
366
+ ],
367
+ });
368
+ }
369
+
355
370
  const mustNotConditions: Array<Record<string, unknown>> = [
356
371
  { key: "_meta", match: { value: true } },
357
372
  ];
@@ -561,6 +576,10 @@ export class VellumQdrantClient {
561
576
  field_name: "modality",
562
577
  field_schema: "keyword",
563
578
  }),
579
+ this.client.createPayloadIndex(this.collection, {
580
+ field_name: "memory_scope_id",
581
+ field_schema: "keyword",
582
+ }),
564
583
  ]);
565
584
  }
566
585