@vellumai/assistant 0.5.5 → 0.5.6

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 (102) hide show
  1. package/Dockerfile +3 -4
  2. package/package.json +1 -1
  3. package/src/__tests__/actor-token-service.test.ts +113 -0
  4. package/src/__tests__/config-schema.test.ts +2 -2
  5. package/src/__tests__/context-window-manager.test.ts +78 -0
  6. package/src/__tests__/conversation-title-service.test.ts +30 -1
  7. package/src/__tests__/docker-signing-key-bootstrap.test.ts +207 -0
  8. package/src/__tests__/memory-regressions.test.ts +8 -30
  9. package/src/__tests__/require-fresh-approval.test.ts +4 -0
  10. package/src/__tests__/tool-executor-lifecycle-events.test.ts +4 -0
  11. package/src/__tests__/tool-executor.test.ts +4 -0
  12. package/src/cli/commands/conversations.ts +0 -18
  13. package/src/config/env.ts +8 -2
  14. package/src/config/feature-flag-registry.json +0 -8
  15. package/src/config/schema.ts +0 -12
  16. package/src/config/schemas/memory.ts +0 -4
  17. package/src/config/schemas/platform.ts +1 -1
  18. package/src/config/schemas/security.ts +4 -0
  19. package/src/context/window-manager.ts +53 -2
  20. package/src/daemon/config-watcher.ts +1 -4
  21. package/src/daemon/conversation-agent-loop.ts +0 -60
  22. package/src/daemon/conversation-memory.ts +0 -117
  23. package/src/daemon/conversation-runtime-assembly.ts +0 -2
  24. package/src/daemon/handlers/conversations.ts +0 -11
  25. package/src/daemon/lifecycle.ts +3 -46
  26. package/src/followups/followup-store.ts +5 -2
  27. package/src/memory/conversation-crud.ts +0 -236
  28. package/src/memory/conversation-title-service.ts +26 -10
  29. package/src/memory/db-init.ts +5 -13
  30. package/src/memory/indexer.ts +15 -106
  31. package/src/memory/job-handlers/embedding.ts +0 -79
  32. package/src/memory/job-utils.ts +1 -1
  33. package/src/memory/jobs-store.ts +0 -8
  34. package/src/memory/jobs-worker.ts +0 -20
  35. package/src/memory/migrations/189-drop-simplified-memory.ts +42 -0
  36. package/src/memory/migrations/index.ts +1 -3
  37. package/src/memory/qdrant-client.ts +4 -6
  38. package/src/memory/schema/conversations.ts +0 -3
  39. package/src/memory/schema/index.ts +0 -2
  40. package/src/messaging/draft-store.ts +2 -2
  41. package/src/permissions/defaults.ts +3 -3
  42. package/src/permissions/trust-client.ts +2 -13
  43. package/src/permissions/trust-store.ts +8 -3
  44. package/src/runtime/auth/route-policy.ts +14 -0
  45. package/src/runtime/auth/token-service.ts +133 -0
  46. package/src/runtime/http-server.ts +2 -0
  47. package/src/runtime/routes/conversation-management-routes.ts +0 -36
  48. package/src/runtime/routes/conversation-query-routes.ts +44 -2
  49. package/src/runtime/routes/conversation-routes.ts +2 -1
  50. package/src/runtime/routes/memory-item-routes.test.ts +221 -3
  51. package/src/runtime/routes/memory-item-routes.ts +124 -2
  52. package/src/runtime/routes/upgrade-broadcast-routes.ts +151 -0
  53. package/src/schedule/schedule-store.ts +0 -21
  54. package/src/skills/inline-command-render.ts +5 -1
  55. package/src/skills/inline-command-runner.ts +30 -2
  56. package/src/tools/memory/handlers.ts +1 -129
  57. package/src/tools/permission-checker.ts +18 -0
  58. package/src/tools/skills/load.ts +9 -2
  59. package/src/util/platform.ts +5 -5
  60. package/src/util/xml.ts +8 -0
  61. package/src/workspace/heartbeat-service.ts +5 -24
  62. package/src/__tests__/archive-recall.test.ts +0 -560
  63. package/src/__tests__/conversation-memory-dirty-tail.test.ts +0 -150
  64. package/src/__tests__/conversation-switch-memory-reduction.test.ts +0 -474
  65. package/src/__tests__/db-memory-archive-migration.test.ts +0 -372
  66. package/src/__tests__/db-memory-brief-state-migration.test.ts +0 -213
  67. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +0 -273
  68. package/src/__tests__/memory-brief-open-loops.test.ts +0 -530
  69. package/src/__tests__/memory-brief-time.test.ts +0 -285
  70. package/src/__tests__/memory-brief-wrapper.test.ts +0 -311
  71. package/src/__tests__/memory-chunk-archive.test.ts +0 -400
  72. package/src/__tests__/memory-chunk-dual-write.test.ts +0 -453
  73. package/src/__tests__/memory-episode-archive.test.ts +0 -370
  74. package/src/__tests__/memory-episode-dual-write.test.ts +0 -626
  75. package/src/__tests__/memory-observation-archive.test.ts +0 -375
  76. package/src/__tests__/memory-observation-dual-write.test.ts +0 -318
  77. package/src/__tests__/memory-reducer-job.test.ts +0 -538
  78. package/src/__tests__/memory-reducer-scheduling.test.ts +0 -473
  79. package/src/__tests__/memory-reducer-store.test.ts +0 -728
  80. package/src/__tests__/memory-reducer-types.test.ts +0 -707
  81. package/src/__tests__/memory-reducer.test.ts +0 -704
  82. package/src/__tests__/memory-simplified-config.test.ts +0 -281
  83. package/src/__tests__/simplified-memory-e2e.test.ts +0 -666
  84. package/src/__tests__/simplified-memory-runtime.test.ts +0 -616
  85. package/src/config/schemas/memory-simplified.ts +0 -101
  86. package/src/memory/archive-recall.ts +0 -516
  87. package/src/memory/archive-store.ts +0 -400
  88. package/src/memory/brief-formatting.ts +0 -33
  89. package/src/memory/brief-open-loops.ts +0 -266
  90. package/src/memory/brief-time.ts +0 -162
  91. package/src/memory/brief.ts +0 -75
  92. package/src/memory/job-handlers/backfill-simplified-memory.ts +0 -462
  93. package/src/memory/job-handlers/reduce-conversation-memory.ts +0 -229
  94. package/src/memory/migrations/185-memory-brief-state.ts +0 -52
  95. package/src/memory/migrations/186-memory-archive.ts +0 -109
  96. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +0 -19
  97. package/src/memory/reducer-scheduler.ts +0 -242
  98. package/src/memory/reducer-store.ts +0 -271
  99. package/src/memory/reducer-types.ts +0 -106
  100. package/src/memory/reducer.ts +0 -467
  101. package/src/memory/schema/memory-archive.ts +0 -121
  102. package/src/memory/schema/memory-brief.ts +0 -55
@@ -1,162 +0,0 @@
1
- /**
2
- * Deterministic compiler for the "Time-Relevant Context" section of the
3
- * memory brief. Reads active `time_contexts` rows plus due-soon live
4
- * schedule jobs, sorts them by urgency bucket, and caps the output.
5
- */
6
-
7
- import { and, eq, gte, lte } from "drizzle-orm";
8
-
9
- import { getDueSoonSchedules } from "../schedule/schedule-store.js";
10
- import type { BriefEntry } from "./brief-formatting.js";
11
- import { renderBriefSection } from "./brief-formatting.js";
12
- import type { DrizzleDb } from "./db-connection.js";
13
- import { timeContexts } from "./schema/memory-brief.js";
14
-
15
- const MAX_ENTRIES = 3;
16
- const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
17
- const ONE_DAY_MS = 24 * 60 * 60 * 1000;
18
-
19
- /** Urgency buckets — lower number = higher priority. */
20
- const enum Bucket {
21
- HappeningNow = 0,
22
- Overdue = 1,
23
- Within24h = 2,
24
- Within7d = 3,
25
- }
26
-
27
- interface Candidate {
28
- bucket: Bucket;
29
- /** Epoch ms timestamp used for secondary sort within a bucket. */
30
- sortKey: number;
31
- entry: BriefEntry;
32
- }
33
-
34
- // ────────────────────────────────────────────────────────────────────
35
- // Public API
36
- // ────────────────────────────────────────────────────────────────────
37
-
38
- /**
39
- * Compile the time-relevant brief section.
40
- *
41
- * @param db Drizzle database instance
42
- * @param now Current epoch-ms timestamp (injectable for deterministic tests)
43
- * @returns Markdown string for the section, or `null` if nothing qualifies
44
- */
45
- export function compileTimeBrief(
46
- db: DrizzleDb,
47
- scopeId: string,
48
- now: number,
49
- ): string | null {
50
- const candidates: Candidate[] = [];
51
-
52
- collectTimeContexts(db, scopeId, now, candidates);
53
- collectDueSoonSchedules(now, candidates);
54
-
55
- // Sort: primary = bucket ascending, secondary = sortKey ascending (sooner first)
56
- candidates.sort((a, b) => a.bucket - b.bucket || a.sortKey - b.sortKey);
57
-
58
- const entries = candidates.slice(0, MAX_ENTRIES).map((c) => c.entry);
59
- return renderBriefSection("Time-Relevant Context", entries, MAX_ENTRIES);
60
- }
61
-
62
- // ────────────────────────────────────────────────────────────────────
63
- // Internal collectors
64
- // ────────────────────────────────────────────────────────────────────
65
-
66
- function collectTimeContexts(
67
- db: DrizzleDb,
68
- scopeId: string,
69
- now: number,
70
- out: Candidate[],
71
- ): void {
72
- // Active time contexts: scopeId match AND activeFrom <= now AND activeUntil >= now
73
- // Uses idx_time_contexts_scope_active_until composite index
74
- const rows = db
75
- .select()
76
- .from(timeContexts)
77
- .where(
78
- and(
79
- eq(timeContexts.scopeId, scopeId),
80
- lte(timeContexts.activeFrom, now),
81
- gte(timeContexts.activeUntil, now),
82
- ),
83
- )
84
- .all();
85
-
86
- for (const row of rows) {
87
- const remaining = row.activeUntil - now;
88
- let bucket: Bucket;
89
-
90
- if (row.activeFrom <= now && row.activeUntil >= now) {
91
- // Currently active — classify by how much time remains
92
- if (remaining <= ONE_DAY_MS) {
93
- bucket = Bucket.HappeningNow;
94
- } else if (remaining <= SEVEN_DAYS_MS) {
95
- bucket = Bucket.Within24h;
96
- } else {
97
- bucket = Bucket.Within7d;
98
- }
99
- } else {
100
- bucket = Bucket.Within7d;
101
- }
102
-
103
- out.push({
104
- bucket,
105
- sortKey: row.activeUntil,
106
- entry: { text: row.summary },
107
- });
108
- }
109
- }
110
-
111
- function collectDueSoonSchedules(now: number, out: Candidate[]): void {
112
- const jobs = getDueSoonSchedules(now, SEVEN_DAYS_MS);
113
-
114
- for (const job of jobs) {
115
- const delta = job.nextRunAt - now;
116
- let bucket: Bucket;
117
-
118
- if (delta <= 0) {
119
- bucket = Bucket.Overdue;
120
- } else if (delta <= ONE_DAY_MS) {
121
- bucket = Bucket.Within24h;
122
- } else {
123
- bucket = Bucket.Within7d;
124
- }
125
-
126
- const label = formatScheduleLabel(job.name, job.nextRunAt, now);
127
- out.push({
128
- bucket,
129
- sortKey: job.nextRunAt,
130
- entry: { text: label },
131
- });
132
- }
133
- }
134
-
135
- // ────────────────────────────────────────────────────────────────────
136
- // Formatting
137
- // ────────────────────────────────────────────────────────────────────
138
-
139
- function formatScheduleLabel(
140
- name: string,
141
- nextRunAt: number,
142
- now: number,
143
- ): string {
144
- const delta = nextRunAt - now;
145
-
146
- if (delta <= 0) {
147
- return `Scheduled: "${name}" — overdue`;
148
- }
149
-
150
- const minutes = Math.round(delta / 60_000);
151
- if (minutes < 60) {
152
- return `Scheduled: "${name}" — in ${minutes} minute${minutes === 1 ? "" : "s"}`;
153
- }
154
-
155
- const hours = Math.round(delta / 3_600_000);
156
- if (hours < 24) {
157
- return `Scheduled: "${name}" — in ${hours} hour${hours === 1 ? "" : "s"}`;
158
- }
159
-
160
- const days = Math.round(delta / 86_400_000);
161
- return `Scheduled: "${name}" — in ${days} day${days === 1 ? "" : "s"}`;
162
- }
@@ -1,75 +0,0 @@
1
- /**
2
- * Top-level memory brief composer.
3
- *
4
- * Composes the "Time-Relevant Context" and "Open Loops" sections into a
5
- * single `<memory_brief>` XML-wrapped block. Omits empty sections and
6
- * returns an empty string when neither section has content.
7
- */
8
-
9
- import { renderBriefSection } from "./brief-formatting.js";
10
- import type { OpenLoopBriefResult } from "./brief-open-loops.js";
11
- import { compileOpenLoopBrief } from "./brief-open-loops.js";
12
- import { compileTimeBrief } from "./brief-time.js";
13
- import type { DrizzleDb } from "./db-connection.js";
14
-
15
- /** Maximum number of open-loop bullets to include in the brief. */
16
- const MAX_OPEN_LOOP_ENTRIES = 5;
17
-
18
- export interface MemoryBriefResult {
19
- /** Rendered `<memory_brief>` block, or empty string if nothing to show. */
20
- text: string;
21
- /** Forwarded from `compileOpenLoopBrief` for downstream tracking. */
22
- resurfacedLoopId: string | null;
23
- }
24
-
25
- /**
26
- * Compile the full memory brief block.
27
- *
28
- * @param db Drizzle database instance
29
- * @param scopeId Memory scope (e.g. assistant instance ID)
30
- * @param userMessageId Current user message ID — used for deterministic
31
- * open-loop resurfacing
32
- * @param now Current epoch-ms timestamp (injectable for tests)
33
- * @returns `{ text, resurfacedLoopId }` — `text` is the
34
- * rendered `<memory_brief>` block or empty string
35
- */
36
- export function compileMemoryBrief(
37
- db: DrizzleDb,
38
- scopeId: string,
39
- userMessageId: string,
40
- now: number = Date.now(),
41
- ): MemoryBriefResult {
42
- // Compile individual sections
43
- const timeSection = compileTimeBrief(db, scopeId, now);
44
-
45
- const openLoopResult: OpenLoopBriefResult = compileOpenLoopBrief(
46
- scopeId,
47
- userMessageId,
48
- now,
49
- );
50
-
51
- // Convert open-loop bullets to a rendered section via the shared helper
52
- const openLoopEntries = openLoopResult.bullets.map((b) => ({
53
- text: b.summary,
54
- }));
55
- const openLoopSection = renderBriefSection(
56
- "Open Loops",
57
- openLoopEntries,
58
- MAX_OPEN_LOOP_ENTRIES,
59
- );
60
-
61
- // Collect non-empty sections
62
- const sections: string[] = [];
63
- if (timeSection) sections.push(timeSection);
64
- if (openLoopSection) sections.push(openLoopSection);
65
-
66
- // If no sections have content, return empty
67
- if (sections.length === 0) {
68
- return { text: "", resurfacedLoopId: openLoopResult.resurfacedLoopId };
69
- }
70
-
71
- const body = sections.join("\n\n");
72
- const text = `<memory_brief>\n${body}\n</memory_brief>`;
73
-
74
- return { text, resurfacedLoopId: openLoopResult.resurfacedLoopId };
75
- }
@@ -1,462 +0,0 @@
1
- /**
2
- * Backfill job handler: migrates legacy memory rows into the simplified memory
3
- * system without deleting the old tables.
4
- *
5
- * Migration mapping:
6
- * - `memory_segments` -> `memory_chunks` (via `memory_observations`)
7
- * - `memory_summaries` -> `memory_episodes`
8
- * - Active/high-confidence `memory_items` -> `memory_observations`,
9
- * plus `time_contexts` or `open_loops` when the mapping is unambiguous.
10
- *
11
- * The handler is idempotent: content-hash deduplication on chunks and
12
- * checkpoint tracking prevent double-writes on re-runs.
13
- */
14
-
15
- import { eq } from "drizzle-orm";
16
- import { v4 as uuid } from "uuid";
17
-
18
- import { estimateTextTokens } from "../../context/token-estimator.js";
19
- import { getLogger } from "../../util/logger.js";
20
- import {
21
- computeChunkContentHash,
22
- insertObservation,
23
- } from "../archive-store.js";
24
- import { getMemoryCheckpoint, setMemoryCheckpoint } from "../checkpoints.js";
25
- import { getDb, rawAll } from "../db.js";
26
- import type { MemoryJob } from "../jobs-store.js";
27
- import { enqueueMemoryJob } from "../jobs-store.js";
28
- import {
29
- conversations,
30
- memoryChunks,
31
- memoryEpisodes,
32
- memoryObservations,
33
- openLoops,
34
- timeContexts,
35
- } from "../schema.js";
36
-
37
- const log = getLogger("backfill-simplified-memory");
38
-
39
- /** Checkpoint keys for tracking backfill progress. */
40
- const CHECKPOINT_SEGMENTS = "simplified_backfill:segments:last_id";
41
- const CHECKPOINT_SUMMARIES = "simplified_backfill:summaries:last_id";
42
- const CHECKPOINT_ITEMS = "simplified_backfill:items:last_id";
43
- const CHECKPOINT_COMPLETE = "simplified_backfill:complete";
44
-
45
- /** Batch size for each migration pass. */
46
- const BATCH_SIZE = 200;
47
-
48
- // ── Legacy row types ──────────────────────────────────────────────────
49
-
50
- interface LegacySegment {
51
- id: string;
52
- message_id: string;
53
- conversation_id: string;
54
- role: string;
55
- text: string;
56
- token_estimate: number;
57
- scope_id: string;
58
- content_hash: string | null;
59
- created_at: number;
60
- }
61
-
62
- interface LegacySummary {
63
- id: string;
64
- scope: string;
65
- scope_key: string;
66
- summary: string;
67
- token_estimate: number;
68
- scope_id: string;
69
- start_at: number;
70
- end_at: number;
71
- created_at: number;
72
- }
73
-
74
- interface LegacyItem {
75
- id: string;
76
- kind: string;
77
- subject: string;
78
- statement: string;
79
- status: string;
80
- confidence: number;
81
- scope_id: string;
82
- first_seen_at: number;
83
- last_seen_at: number;
84
- valid_from: number | null;
85
- invalid_at: number | null;
86
- }
87
-
88
- // ── Entry point ───────────────────────────────────────────────────────
89
-
90
- export async function backfillSimplifiedMemoryJob(
91
- job: MemoryJob,
92
- ): Promise<void> {
93
- const force = job.payload.force === true;
94
-
95
- if (!force) {
96
- const complete = getMemoryCheckpoint(CHECKPOINT_COMPLETE);
97
- if (complete === "true") {
98
- log.debug("Simplified memory backfill already complete, skipping");
99
- return;
100
- }
101
- }
102
-
103
- if (force) {
104
- // Reset all checkpoints so the backfill restarts from scratch
105
- setMemoryCheckpoint(CHECKPOINT_SEGMENTS, "");
106
- setMemoryCheckpoint(CHECKPOINT_SUMMARIES, "");
107
- setMemoryCheckpoint(CHECKPOINT_ITEMS, "");
108
- setMemoryCheckpoint(CHECKPOINT_COMPLETE, "false");
109
- }
110
-
111
- let hasMore = false;
112
-
113
- // ── Phase 1: memory_segments -> memory_observations + memory_chunks
114
- hasMore = migrateSegments();
115
- if (hasMore) {
116
- enqueueMemoryJob("backfill_simplified_memory", {});
117
- return;
118
- }
119
-
120
- // ── Phase 2: memory_summaries -> memory_episodes
121
- hasMore = migrateSummaries();
122
- if (hasMore) {
123
- enqueueMemoryJob("backfill_simplified_memory", {});
124
- return;
125
- }
126
-
127
- // ── Phase 3: active memory_items -> memory_observations (+ brief-state)
128
- hasMore = migrateItems();
129
- if (hasMore) {
130
- enqueueMemoryJob("backfill_simplified_memory", {});
131
- return;
132
- }
133
-
134
- // All phases complete
135
- setMemoryCheckpoint(CHECKPOINT_COMPLETE, "true");
136
- log.info("Simplified memory backfill completed");
137
- }
138
-
139
- // ── Phase 1: Segments ─────────────────────────────────────────────────
140
-
141
- function migrateSegments(): boolean {
142
- const lastId = getMemoryCheckpoint(CHECKPOINT_SEGMENTS) ?? "";
143
-
144
- const segments = rawAll<LegacySegment>(
145
- `SELECT id, message_id, conversation_id, role, text, token_estimate,
146
- scope_id, content_hash, created_at
147
- FROM memory_segments
148
- WHERE id > ?
149
- ORDER BY id ASC
150
- LIMIT ?`,
151
- lastId,
152
- BATCH_SIZE,
153
- );
154
-
155
- if (segments.length === 0) return false;
156
-
157
- for (const seg of segments) {
158
- try {
159
- // Insert as an observation — insertObservation handles chunk dedup
160
- insertObservation({
161
- conversationId: seg.conversation_id,
162
- messageId: seg.message_id,
163
- role: seg.role,
164
- content: seg.text,
165
- scopeId: seg.scope_id,
166
- modality: "text",
167
- source: "backfill:segment",
168
- });
169
- } catch (err) {
170
- // Log and continue — individual failures should not block the batch
171
- log.warn(
172
- { err, segmentId: seg.id },
173
- "Failed to migrate segment, skipping",
174
- );
175
- }
176
- }
177
-
178
- const lastSegment = segments[segments.length - 1];
179
- setMemoryCheckpoint(CHECKPOINT_SEGMENTS, lastSegment.id);
180
-
181
- log.debug(
182
- { migrated: segments.length, lastId: lastSegment.id },
183
- "Migrated segment batch",
184
- );
185
-
186
- return segments.length === BATCH_SIZE;
187
- }
188
-
189
- // ── Phase 2: Summaries ────────────────────────────────────────────────
190
-
191
- function migrateSummaries(): boolean {
192
- const lastId = getMemoryCheckpoint(CHECKPOINT_SUMMARIES) ?? "";
193
-
194
- const summaries = rawAll<LegacySummary>(
195
- `SELECT id, scope, scope_key, summary, token_estimate, scope_id,
196
- start_at, end_at, created_at
197
- FROM memory_summaries
198
- WHERE id > ?
199
- ORDER BY id ASC
200
- LIMIT ?`,
201
- lastId,
202
- BATCH_SIZE,
203
- );
204
-
205
- if (summaries.length === 0) return false;
206
-
207
- const db = getDb();
208
- const now = Date.now();
209
-
210
- for (const sum of summaries) {
211
- try {
212
- // Derive a conversation ID from the scope_key if it looks like a conversation summary.
213
- // scope_key format: "conversation:<conversationId>" or "<scope>:<key>"
214
- const conversationId = extractConversationId(sum.scope, sum.scope_key);
215
- if (!conversationId) {
216
- log.debug(
217
- { summaryId: sum.id, scope: sum.scope, scopeKey: sum.scope_key },
218
- "Skipping non-conversation summary",
219
- );
220
- continue;
221
- }
222
-
223
- const episodeId = uuid();
224
- const title = buildEpisodeTitle(sum.scope, sum.scope_key);
225
-
226
- db.insert(memoryEpisodes)
227
- .values({
228
- id: episodeId,
229
- scopeId: sum.scope_id,
230
- conversationId,
231
- title,
232
- summary: sum.summary,
233
- tokenEstimate: sum.token_estimate,
234
- source: "backfill:summary",
235
- startAt: sum.start_at,
236
- endAt: sum.end_at,
237
- createdAt: now,
238
- updatedAt: now,
239
- })
240
- .onConflictDoNothing()
241
- .run();
242
-
243
- // Enqueue embedding for the new episode
244
- enqueueMemoryJob("embed_episode", { episodeId });
245
- } catch (err) {
246
- log.warn(
247
- { err, summaryId: sum.id },
248
- "Failed to migrate summary, skipping",
249
- );
250
- }
251
- }
252
-
253
- const lastSummary = summaries[summaries.length - 1];
254
- setMemoryCheckpoint(CHECKPOINT_SUMMARIES, lastSummary.id);
255
-
256
- log.debug(
257
- { migrated: summaries.length, lastId: lastSummary.id },
258
- "Migrated summary batch",
259
- );
260
-
261
- return summaries.length === BATCH_SIZE;
262
- }
263
-
264
- // ── Phase 3: Items ────────────────────────────────────────────────────
265
-
266
- /** Sentinel conversation ID for legacy items that have no conversation linkage. */
267
- const LEGACY_SENTINEL_CONVERSATION_ID = "__legacy_backfill__";
268
-
269
- /**
270
- * Ensure the legacy sentinel conversation row exists. This is needed because
271
- * memory_observations has a FK constraint on conversation_id.
272
- */
273
- function ensureLegacySentinelConversation(): void {
274
- const db = getDb();
275
- const existing = db
276
- .select({ id: conversations.id })
277
- .from(conversations)
278
- .where(eq(conversations.id, LEGACY_SENTINEL_CONVERSATION_ID))
279
- .get();
280
- if (existing) return;
281
-
282
- const now = Date.now();
283
- db.insert(conversations)
284
- .values({
285
- id: LEGACY_SENTINEL_CONVERSATION_ID,
286
- title: "[Legacy Memory Backfill]",
287
- createdAt: now,
288
- updatedAt: now,
289
- })
290
- .run();
291
- }
292
-
293
- function migrateItems(): boolean {
294
- const lastId = getMemoryCheckpoint(CHECKPOINT_ITEMS) ?? "";
295
-
296
- const items = rawAll<LegacyItem>(
297
- `SELECT id, kind, subject, statement, status, confidence, scope_id,
298
- first_seen_at, last_seen_at, valid_from, invalid_at
299
- FROM memory_items
300
- WHERE id > ?
301
- AND status = 'active'
302
- AND confidence >= 0.5
303
- AND invalid_at IS NULL
304
- ORDER BY id ASC
305
- LIMIT ?`,
306
- lastId,
307
- BATCH_SIZE,
308
- );
309
-
310
- if (items.length === 0) return false;
311
-
312
- // Ensure the sentinel conversation exists for items without conversation linkage
313
- ensureLegacySentinelConversation();
314
-
315
- const db = getDb();
316
- const now = Date.now();
317
-
318
- for (const item of items) {
319
- try {
320
- // Every active item becomes an observation
321
- const observationId = uuid();
322
- const observationContent = `[${item.kind}] ${item.subject}: ${item.statement}`;
323
-
324
- db.insert(memoryObservations)
325
- .values({
326
- id: observationId,
327
- scopeId: item.scope_id,
328
- conversationId: LEGACY_SENTINEL_CONVERSATION_ID,
329
- role: "user",
330
- content: observationContent,
331
- modality: "text",
332
- source: "backfill:item",
333
- createdAt: now,
334
- })
335
- .run();
336
-
337
- // Create a chunk for the observation (with dedup)
338
- const contentHash = computeChunkContentHash(
339
- item.scope_id,
340
- observationContent,
341
- );
342
- const chunkId = uuid();
343
- const tokenEstimate = estimateTextTokens(observationContent);
344
-
345
- db.insert(memoryChunks)
346
- .values({
347
- id: chunkId,
348
- scopeId: item.scope_id,
349
- observationId,
350
- content: observationContent,
351
- tokenEstimate,
352
- contentHash,
353
- createdAt: now,
354
- })
355
- .onConflictDoNothing({
356
- target: [memoryChunks.scopeId, memoryChunks.contentHash],
357
- })
358
- .run();
359
-
360
- // Enqueue embedding for the observation's chunk
361
- enqueueMemoryJob("embed_chunk", { chunkId, scopeId: item.scope_id });
362
-
363
- // ── Brief-state: map unambiguous items to time_contexts or open_loops
364
- mapItemToBriefState(item, now);
365
- } catch (err) {
366
- log.warn({ err, itemId: item.id }, "Failed to migrate item, skipping");
367
- }
368
- }
369
-
370
- const lastItem = items[items.length - 1];
371
- setMemoryCheckpoint(CHECKPOINT_ITEMS, lastItem.id);
372
-
373
- log.debug(
374
- { migrated: items.length, lastId: lastItem.id },
375
- "Migrated item batch",
376
- );
377
-
378
- return items.length === BATCH_SIZE;
379
- }
380
-
381
- // ── Brief-state mapping ───────────────────────────────────────────────
382
-
383
- /**
384
- * Map a legacy memory item to `time_contexts` or `open_loops` when the
385
- * mapping is unambiguous.
386
- *
387
- * - Items with `valid_from` and a future `invalid_at` -> time_context
388
- * - `event` kind items with future timestamps -> open_loop
389
- */
390
- function mapItemToBriefState(item: LegacyItem, now: number): void {
391
- const db = getDb();
392
-
393
- // Time-bounded items -> time_contexts
394
- if (
395
- item.valid_from != null &&
396
- item.invalid_at != null &&
397
- item.invalid_at > now
398
- ) {
399
- db.insert(timeContexts)
400
- .values({
401
- id: uuid(),
402
- scopeId: item.scope_id,
403
- summary: `${item.subject}: ${item.statement}`,
404
- source: "backfill:item",
405
- activeFrom: item.valid_from,
406
- activeUntil: item.invalid_at,
407
- createdAt: now,
408
- updatedAt: now,
409
- })
410
- .run();
411
- return;
412
- }
413
-
414
- // Event items with future last_seen_at -> open_loops
415
- if (item.kind === "event" && item.last_seen_at > now) {
416
- db.insert(openLoops)
417
- .values({
418
- id: uuid(),
419
- scopeId: item.scope_id,
420
- summary: `${item.subject}: ${item.statement}`,
421
- source: "backfill:item",
422
- status: "open",
423
- dueAt: item.last_seen_at,
424
- createdAt: now,
425
- updatedAt: now,
426
- })
427
- .run();
428
- }
429
- }
430
-
431
- // ── Helpers ───────────────────────────────────────────────────────────
432
-
433
- /**
434
- * Extract a conversation ID from the summary's scope and scope_key.
435
- * Returns null for non-conversation summaries.
436
- */
437
- function extractConversationId(scope: string, scopeKey: string): string | null {
438
- // Conversation summaries use scope "conversation" with scope_key as the ID
439
- if (scope === "conversation") return scopeKey;
440
-
441
- // Some summaries use "conversation:<id>" as scope_key
442
- const match = scopeKey.match(/^conversation:(.+)$/);
443
- if (match) return match[1];
444
-
445
- return null;
446
- }
447
-
448
- /**
449
- * Build a human-readable episode title from the summary's scope metadata.
450
- */
451
- function buildEpisodeTitle(scope: string, scopeKey: string): string {
452
- if (scope === "conversation") {
453
- return `Conversation summary`;
454
- }
455
- if (scope === "weekly") {
456
- return `Weekly summary (${scopeKey})`;
457
- }
458
- if (scope === "monthly") {
459
- return `Monthly summary (${scopeKey})`;
460
- }
461
- return `${scope} summary`;
462
- }