@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.
- package/ARCHITECTURE.md +109 -0
- package/docs/architecture/memory.md +105 -0
- package/docs/skills.md +100 -0
- package/package.json +1 -1
- package/src/__tests__/archive-recall.test.ts +560 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +7 -0
- package/src/__tests__/conversation-agent-loop.test.ts +7 -0
- package/src/__tests__/conversation-clear-safety.test.ts +259 -0
- package/src/__tests__/conversation-memory-dirty-tail.test.ts +150 -0
- package/src/__tests__/conversation-provider-retry-repair.test.ts +7 -0
- package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
- package/src/__tests__/conversation-wipe.test.ts +226 -0
- package/src/__tests__/db-memory-archive-migration.test.ts +372 -0
- package/src/__tests__/db-memory-brief-state-migration.test.ts +213 -0
- package/src/__tests__/db-memory-reducer-checkpoints.test.ts +273 -0
- package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
- package/src/__tests__/inline-command-runner.test.ts +311 -0
- package/src/__tests__/inline-skill-authoring-guard.test.ts +220 -0
- package/src/__tests__/inline-skill-load-permissions.test.ts +435 -0
- package/src/__tests__/list-messages-attachments.test.ts +96 -0
- package/src/__tests__/memory-brief-open-loops.test.ts +530 -0
- package/src/__tests__/memory-brief-time.test.ts +285 -0
- package/src/__tests__/memory-brief-wrapper.test.ts +311 -0
- package/src/__tests__/memory-chunk-archive.test.ts +400 -0
- package/src/__tests__/memory-chunk-dual-write.test.ts +453 -0
- package/src/__tests__/memory-episode-archive.test.ts +370 -0
- package/src/__tests__/memory-episode-dual-write.test.ts +626 -0
- package/src/__tests__/memory-observation-archive.test.ts +375 -0
- package/src/__tests__/memory-observation-dual-write.test.ts +318 -0
- package/src/__tests__/memory-recall-quality.test.ts +2 -2
- package/src/__tests__/memory-reducer-job.test.ts +538 -0
- package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
- package/src/__tests__/memory-reducer-store.test.ts +728 -0
- package/src/__tests__/memory-reducer-types.test.ts +707 -0
- package/src/__tests__/memory-reducer.test.ts +704 -0
- package/src/__tests__/memory-regressions.test.ts +30 -8
- package/src/__tests__/memory-simplified-config.test.ts +281 -0
- package/src/__tests__/parse-identity-fields.test.ts +129 -0
- package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
- package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
- package/src/__tests__/skill-load-inline-command.test.ts +598 -0
- package/src/__tests__/skill-load-inline-includes.test.ts +644 -0
- package/src/__tests__/skills-inline-command-expansions.test.ts +301 -0
- package/src/__tests__/skills-transitive-hash.test.ts +333 -0
- package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +320 -0
- package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +4 -4
- package/src/cli/commands/conversations.ts +18 -0
- package/src/config/bundled-skills/app-builder/SKILL.md +8 -8
- package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
- package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
- package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
- package/src/config/feature-flag-registry.json +16 -0
- package/src/config/raw-config-utils.ts +28 -0
- package/src/config/schema.ts +12 -0
- package/src/config/schemas/memory-simplified.ts +101 -0
- package/src/config/schemas/memory.ts +4 -0
- package/src/config/skills.ts +50 -4
- package/src/daemon/conversation-agent-loop-handlers.ts +8 -3
- package/src/daemon/conversation-agent-loop.ts +71 -1
- package/src/daemon/conversation-lifecycle.ts +11 -1
- package/src/daemon/conversation-memory.ts +117 -0
- package/src/daemon/conversation-runtime-assembly.ts +3 -1
- package/src/daemon/conversation-surfaces.ts +31 -8
- package/src/daemon/conversation.ts +40 -23
- package/src/daemon/handlers/config-embeddings.ts +10 -2
- package/src/daemon/handlers/config-model.ts +0 -9
- package/src/daemon/handlers/conversations.ts +11 -0
- package/src/daemon/handlers/identity.ts +12 -1
- package/src/daemon/lifecycle.ts +52 -1
- package/src/daemon/message-types/conversations.ts +0 -1
- package/src/daemon/server.ts +1 -1
- package/src/followups/followup-store.ts +47 -1
- package/src/memory/archive-recall.ts +516 -0
- package/src/memory/archive-store.ts +400 -0
- package/src/memory/brief-formatting.ts +33 -0
- package/src/memory/brief-open-loops.ts +266 -0
- package/src/memory/brief-time.ts +162 -0
- package/src/memory/brief.ts +75 -0
- package/src/memory/conversation-crud.ts +455 -101
- package/src/memory/conversation-key-store.ts +33 -4
- package/src/memory/db-init.ts +16 -0
- package/src/memory/indexer.ts +106 -15
- package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
- package/src/memory/job-handlers/conversation-starters.ts +9 -3
- package/src/memory/job-handlers/embedding.test.ts +1 -0
- package/src/memory/job-handlers/embedding.ts +83 -0
- package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
- package/src/memory/job-utils.ts +1 -1
- package/src/memory/jobs-store.ts +8 -0
- package/src/memory/jobs-worker.ts +20 -0
- package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
- package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
- package/src/memory/migrations/141-rename-verification-table.ts +8 -0
- package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
- package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
- package/src/memory/migrations/185-memory-brief-state.ts +52 -0
- package/src/memory/migrations/186-memory-archive.ts +109 -0
- package/src/memory/migrations/187-memory-reducer-checkpoints.ts +19 -0
- package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
- package/src/memory/migrations/index.ts +4 -0
- package/src/memory/qdrant-client.ts +23 -4
- package/src/memory/reducer-scheduler.ts +242 -0
- package/src/memory/reducer-store.ts +271 -0
- package/src/memory/reducer-types.ts +106 -0
- package/src/memory/reducer.ts +467 -0
- package/src/memory/schema/conversations.ts +3 -0
- package/src/memory/schema/index.ts +2 -0
- package/src/memory/schema/infrastructure.ts +1 -0
- package/src/memory/schema/memory-archive.ts +121 -0
- package/src/memory/schema/memory-brief.ts +55 -0
- package/src/memory/search/semantic.ts +17 -4
- package/src/oauth/oauth-store.ts +3 -1
- package/src/permissions/checker.ts +89 -6
- package/src/permissions/defaults.ts +14 -0
- package/src/runtime/auth/route-policy.ts +10 -1
- package/src/runtime/routes/conversation-management-routes.ts +94 -2
- package/src/runtime/routes/conversation-query-routes.ts +7 -0
- package/src/runtime/routes/conversation-routes.ts +52 -5
- package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
- package/src/runtime/routes/identity-routes.ts +2 -35
- package/src/runtime/routes/llm-context-normalization.ts +14 -1
- package/src/runtime/routes/memory-item-routes.ts +90 -5
- package/src/runtime/routes/secret-routes.ts +3 -0
- package/src/runtime/routes/surface-action-routes.ts +68 -1
- package/src/schedule/schedule-store.ts +28 -0
- package/src/schedule/scheduler.ts +6 -2
- package/src/skills/inline-command-expansions.ts +204 -0
- package/src/skills/inline-command-render.ts +127 -0
- package/src/skills/inline-command-runner.ts +242 -0
- package/src/skills/transitive-version-hash.ts +88 -0
- package/src/tasks/task-store.ts +43 -1
- package/src/telemetry/usage-telemetry-reporter.ts +1 -1
- package/src/tools/filesystem/edit.ts +6 -1
- package/src/tools/filesystem/read.ts +6 -1
- package/src/tools/filesystem/write.ts +6 -1
- package/src/tools/memory/handlers.ts +129 -1
- package/src/tools/permission-checker.ts +8 -1
- package/src/tools/schedule/create.ts +3 -0
- package/src/tools/schedule/list.ts +5 -1
- package/src/tools/schedule/update.ts +6 -0
- package/src/tools/skills/load.ts +140 -6
- package/src/util/platform.ts +18 -0
- package/src/workspace/migrations/{002-backfill-installation-id.ts → 011-backfill-installation-id.ts} +1 -1
- package/src/workspace/migrations/registry.ts +1 -1
|
@@ -0,0 +1,462 @@
|
|
|
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
|
+
}
|
|
@@ -221,7 +221,7 @@ Favor what is live over what is merely true. Recent changes matter more than old
|
|
|
221
221
|
Return exactly 4 starters in rank order (best first).
|
|
222
222
|
|
|
223
223
|
Each starter has:
|
|
224
|
-
- label: 3-6 words, max 40 chars, starts with a verb. Should
|
|
224
|
+
- label: 3-6 words, max 40 chars, starts with a verb. Should read as something the user wants to do — these chips send a message as the user, so the label must be in the user's voice. Must sound natural when read aloud.
|
|
225
225
|
- prompt: 1-2 natural sentences, written as the user would actually say them — not templated.
|
|
226
226
|
- category: one of ${CONVERSATION_STARTER_CATEGORIES.join(", ")}
|
|
227
227
|
|
|
@@ -239,6 +239,8 @@ If a label sounds like an issue title, project ticket, or implementation task, r
|
|
|
239
239
|
|
|
240
240
|
Prefer natural, flowing language over mechanical or operational phrasing. "Get Slack messages flowing" is better than "Restore outgoing Slack messages." The label should sound like something a helpful person would say, not a support ticket.
|
|
241
241
|
|
|
242
|
+
Voice: The user clicks these chips to send a message. Every label must make sense as something the user is asking to do, never something the assistant is saying to the user.
|
|
243
|
+
|
|
242
244
|
Before finalizing each label, ask yourself: would this feel good to click? Or does it sound like a backlog item? If it sounds like a backlog item, rewrite it.
|
|
243
245
|
|
|
244
246
|
Examples of bad vs good:
|
|
@@ -249,7 +251,11 @@ Examples of bad vs good:
|
|
|
249
251
|
- BAD: "Restore outgoing Slack messages" → GOOD: "Get Slack messages flowing"
|
|
250
252
|
- BAD: "Set up a playbook for inbox" → GOOD: "Catch the emails that matter"
|
|
251
253
|
|
|
252
|
-
|
|
254
|
+
Assistant-voice vs user-voice:
|
|
255
|
+
- BAD: "You've got a busy week ahead" → GOOD: "Plan my week ahead"
|
|
256
|
+
- BAD: "Let me check your calendar" → GOOD: "Check my Thursday schedule"
|
|
257
|
+
|
|
258
|
+
The good versions emphasize the user's payoff in the user's own voice, not the internal mechanism or the assistant's perspective.`;
|
|
253
259
|
|
|
254
260
|
const { signal, cleanup } = createTimeout(20000);
|
|
255
261
|
try {
|
|
@@ -274,7 +280,7 @@ The good versions emphasize the user's payoff, not the internal mechanism.`;
|
|
|
274
280
|
label: {
|
|
275
281
|
type: "string",
|
|
276
282
|
description:
|
|
277
|
-
"
|
|
283
|
+
"User-voice chip text (2-7 words, max 40 chars, starts with a verb)",
|
|
278
284
|
},
|
|
279
285
|
prompt: {
|
|
280
286
|
type: "string",
|
|
@@ -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
|
+
}
|