@vellumai/assistant 0.3.3 → 0.3.5
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/Dockerfile +2 -0
- package/README.md +45 -18
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +13 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
- package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
- package/src/__tests__/approval-message-composer.test.ts +253 -0
- package/src/__tests__/call-domain.test.ts +12 -2
- package/src/__tests__/call-orchestrator.test.ts +391 -1
- package/src/__tests__/call-routes-http.test.ts +27 -2
- package/src/__tests__/channel-approval-routes.test.ts +397 -135
- package/src/__tests__/channel-approvals.test.ts +99 -3
- package/src/__tests__/channel-delivery-store.test.ts +30 -4
- package/src/__tests__/channel-guardian.test.ts +261 -22
- package/src/__tests__/channel-readiness-service.test.ts +257 -0
- package/src/__tests__/config-schema.test.ts +2 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-lifecycle.test.ts +636 -0
- package/src/__tests__/dictation-mode-detection.test.ts +63 -0
- package/src/__tests__/entity-search.test.ts +615 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
- package/src/__tests__/handlers-twilio-config.test.ts +480 -0
- package/src/__tests__/ipc-snapshot.test.ts +63 -0
- package/src/__tests__/messaging-send-tool.test.ts +65 -0
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
- package/src/__tests__/run-orchestrator.test.ts +22 -0
- package/src/__tests__/secret-scanner.test.ts +223 -0
- package/src/__tests__/session-runtime-assembly.test.ts +85 -1
- package/src/__tests__/shell-parser-property.test.ts +357 -2
- package/src/__tests__/sms-messaging-provider.test.ts +125 -0
- package/src/__tests__/system-prompt.test.ts +25 -1
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
- package/src/__tests__/twilio-routes.test.ts +39 -3
- package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
- package/src/__tests__/user-reference.test.ts +68 -0
- package/src/__tests__/web-search.test.ts +1 -1
- package/src/__tests__/work-item-output.test.ts +110 -0
- package/src/calls/call-domain.ts +8 -5
- package/src/calls/call-orchestrator.ts +85 -22
- package/src/calls/twilio-config.ts +17 -11
- package/src/calls/twilio-rest.ts +276 -0
- package/src/calls/twilio-routes.ts +39 -1
- package/src/cli/map.ts +6 -0
- package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
- package/src/commands/cc-command-registry.ts +14 -1
- package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
- package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
- package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
- package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
- package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
- package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
- package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
- package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
- package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
- package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
- package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
- package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
- package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
- package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
- package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
- package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
- package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
- package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
- package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
- package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
- package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
- package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
- package/src/config/bundled-skills/messaging/SKILL.md +24 -5
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/twitter/SKILL.md +19 -3
- package/src/config/defaults.ts +2 -1
- package/src/config/schema.ts +9 -3
- package/src/config/skills.ts +5 -32
- package/src/config/system-prompt.ts +40 -0
- package/src/config/templates/IDENTITY.md +2 -2
- package/src/config/user-reference.ts +29 -0
- package/src/config/vellum-skills/catalog.json +58 -0
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
- package/src/config/vellum-skills/twilio-setup/SKILL.md +76 -6
- package/src/daemon/auth-manager.ts +103 -0
- package/src/daemon/computer-use-session.ts +8 -1
- package/src/daemon/config-watcher.ts +253 -0
- package/src/daemon/handlers/config.ts +819 -22
- package/src/daemon/handlers/dictation.ts +182 -0
- package/src/daemon/handlers/identity.ts +14 -23
- package/src/daemon/handlers/index.ts +2 -0
- package/src/daemon/handlers/sessions.ts +2 -0
- package/src/daemon/handlers/shared.ts +3 -0
- package/src/daemon/handlers/skills.ts +6 -7
- package/src/daemon/handlers/work-items.ts +15 -7
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +114 -4
- package/src/daemon/ipc-handler.ts +87 -0
- package/src/daemon/lifecycle.ts +18 -4
- package/src/daemon/ride-shotgun-handler.ts +11 -1
- package/src/daemon/server.ts +111 -504
- package/src/daemon/session-agent-loop.ts +10 -15
- package/src/daemon/session-runtime-assembly.ts +115 -44
- package/src/daemon/session-tool-setup.ts +2 -0
- package/src/daemon/session.ts +19 -2
- package/src/inbound/public-ingress-urls.ts +3 -3
- package/src/memory/channel-guardian-store.ts +2 -1
- package/src/memory/db-connection.ts +28 -0
- package/src/memory/db-init.ts +1163 -0
- package/src/memory/db.ts +2 -2007
- package/src/memory/embedding-backend.ts +79 -11
- package/src/memory/indexer.ts +2 -0
- package/src/memory/job-handlers/media-processing.ts +100 -0
- package/src/memory/job-utils.ts +64 -4
- package/src/memory/jobs-store.ts +2 -1
- package/src/memory/jobs-worker.ts +11 -1
- package/src/memory/media-store.ts +759 -0
- package/src/memory/recall-cache.ts +107 -0
- package/src/memory/retriever.ts +36 -2
- package/src/memory/schema-migration.ts +984 -0
- package/src/memory/schema.ts +99 -0
- package/src/memory/search/entity.ts +208 -25
- package/src/memory/search/ranking.ts +6 -1
- package/src/memory/search/types.ts +26 -0
- package/src/messaging/provider-types.ts +2 -0
- package/src/messaging/providers/sms/adapter.ts +204 -0
- package/src/messaging/providers/sms/client.ts +93 -0
- package/src/messaging/providers/sms/types.ts +7 -0
- package/src/permissions/checker.ts +16 -2
- package/src/permissions/prompter.ts +14 -3
- package/src/permissions/trust-store.ts +7 -0
- package/src/runtime/approval-message-composer.ts +143 -0
- package/src/runtime/channel-approvals.ts +29 -7
- package/src/runtime/channel-guardian-service.ts +44 -18
- package/src/runtime/channel-readiness-service.ts +292 -0
- package/src/runtime/channel-readiness-types.ts +29 -0
- package/src/runtime/gateway-client.ts +2 -1
- package/src/runtime/http-server.ts +65 -28
- package/src/runtime/http-types.ts +3 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/channel-routes.ts +237 -103
- package/src/runtime/routes/run-routes.ts +7 -1
- package/src/runtime/run-orchestrator.ts +43 -3
- package/src/security/secret-scanner.ts +218 -0
- package/src/skills/frontmatter.ts +63 -0
- package/src/skills/slash-commands.ts +23 -0
- package/src/skills/vellum-catalog-remote.ts +107 -0
- package/src/tools/assets/materialize.ts +2 -2
- package/src/tools/browser/auto-navigate.ts +132 -24
- package/src/tools/browser/browser-manager.ts +67 -61
- package/src/tools/calls/call-start.ts +1 -0
- package/src/tools/claude-code/claude-code.ts +55 -3
- package/src/tools/credentials/vault.ts +1 -1
- package/src/tools/execution-target.ts +11 -1
- package/src/tools/executor.ts +10 -2
- package/src/tools/network/web-search.ts +1 -1
- package/src/tools/skills/vellum-catalog.ts +61 -156
- package/src/tools/terminal/parser.ts +21 -5
- package/src/tools/types.ts +2 -0
- package/src/twitter/router.ts +1 -1
- package/src/util/platform.ts +43 -1
- package/src/util/retry.ts +4 -4
|
@@ -0,0 +1,984 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
|
3
|
+
import { computeMemoryFingerprint } from './fingerprint.js';
|
|
4
|
+
import type * as schema from './schema.js';
|
|
5
|
+
import { getLogger } from '../util/logger.js';
|
|
6
|
+
|
|
7
|
+
const log = getLogger('memory-db');
|
|
8
|
+
|
|
9
|
+
type Db = ReturnType<typeof drizzle<typeof schema>>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One-shot migration: reconcile old deferral history into the new `deferrals` column.
|
|
13
|
+
*
|
|
14
|
+
* Before the `deferrals` column was added, `deferMemoryJob` incremented `attempts`.
|
|
15
|
+
* After the column is added with DEFAULT 0, those legacy jobs still carry the old
|
|
16
|
+
* attempt count (which was really a deferral count) while `deferrals` is 0. This
|
|
17
|
+
* moves the attempt count into `deferrals` and resets `attempts` to 0.
|
|
18
|
+
*
|
|
19
|
+
* This migration MUST run only once. On subsequent startups, post-migration jobs
|
|
20
|
+
* that genuinely failed via `failMemoryJob` (attempts > 0, deferrals = 0, non-null
|
|
21
|
+
* last_error) must NOT be touched — resetting their attempts would let them bypass
|
|
22
|
+
* the configured maxAttempts budget across restarts.
|
|
23
|
+
*
|
|
24
|
+
* We use a `memory_checkpoints` row to ensure the migration runs exactly once.
|
|
25
|
+
*/
|
|
26
|
+
export function migrateJobDeferrals(database: Db): void {
|
|
27
|
+
const raw = (database as unknown as { $client: Database }).$client;
|
|
28
|
+
const checkpoint = raw.query(
|
|
29
|
+
`SELECT 1 FROM memory_checkpoints WHERE key = 'migration_job_deferrals'`
|
|
30
|
+
).get();
|
|
31
|
+
if (checkpoint) return;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
raw.exec(/*sql*/ `
|
|
35
|
+
BEGIN;
|
|
36
|
+
UPDATE memory_jobs
|
|
37
|
+
SET deferrals = attempts,
|
|
38
|
+
attempts = 0,
|
|
39
|
+
last_error = NULL,
|
|
40
|
+
updated_at = ${Date.now()}
|
|
41
|
+
WHERE status = 'pending'
|
|
42
|
+
AND attempts > 0
|
|
43
|
+
AND deferrals = 0
|
|
44
|
+
AND type IN ('embed_segment', 'embed_item', 'embed_summary');
|
|
45
|
+
INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at)
|
|
46
|
+
VALUES ('migration_job_deferrals', '1', ${Date.now()});
|
|
47
|
+
COMMIT;
|
|
48
|
+
`);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
|
|
51
|
+
throw e;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Migrate existing tool_invocations table to add FK constraint with ON DELETE CASCADE.
|
|
57
|
+
* SQLite doesn't support ALTER TABLE ADD CONSTRAINT, so we rebuild the table.
|
|
58
|
+
* This is idempotent: it checks whether the FK already exists before migrating.
|
|
59
|
+
*/
|
|
60
|
+
export function migrateToolInvocationsFk(database: Db): void {
|
|
61
|
+
const raw = (database as unknown as { $client: Database }).$client;
|
|
62
|
+
const row = raw.query(`SELECT sql FROM sqlite_master WHERE type='table' AND name='tool_invocations'`).get() as { sql: string } | null;
|
|
63
|
+
if (!row) return; // table doesn't exist yet (will be created above)
|
|
64
|
+
|
|
65
|
+
// If the DDL already contains REFERENCES, the FK is in place
|
|
66
|
+
if (row.sql.includes('REFERENCES')) return;
|
|
67
|
+
|
|
68
|
+
raw.exec('PRAGMA foreign_keys = OFF');
|
|
69
|
+
try {
|
|
70
|
+
raw.exec(/*sql*/ `
|
|
71
|
+
BEGIN;
|
|
72
|
+
CREATE TABLE tool_invocations_new (
|
|
73
|
+
id TEXT PRIMARY KEY,
|
|
74
|
+
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
75
|
+
tool_name TEXT NOT NULL,
|
|
76
|
+
input TEXT NOT NULL,
|
|
77
|
+
result TEXT NOT NULL,
|
|
78
|
+
decision TEXT NOT NULL,
|
|
79
|
+
risk_level TEXT NOT NULL,
|
|
80
|
+
duration_ms INTEGER NOT NULL,
|
|
81
|
+
created_at INTEGER NOT NULL
|
|
82
|
+
);
|
|
83
|
+
INSERT INTO tool_invocations_new SELECT t.* FROM tool_invocations t
|
|
84
|
+
WHERE EXISTS (SELECT 1 FROM conversations c WHERE c.id = t.conversation_id);
|
|
85
|
+
DROP TABLE tool_invocations;
|
|
86
|
+
ALTER TABLE tool_invocations_new RENAME TO tool_invocations;
|
|
87
|
+
COMMIT;
|
|
88
|
+
`);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
|
|
91
|
+
throw e;
|
|
92
|
+
} finally {
|
|
93
|
+
raw.exec('PRAGMA foreign_keys = ON');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Backfill FTS rows for existing memory_segments records when upgrading from a
|
|
99
|
+
* version that may not have had trigger-managed FTS.
|
|
100
|
+
*/
|
|
101
|
+
export function migrateMemoryFtsBackfill(database: Db): void {
|
|
102
|
+
const raw = (database as unknown as { $client: Database }).$client;
|
|
103
|
+
const ftsCountRow = raw.query(`SELECT COUNT(*) AS c FROM memory_segment_fts`).get() as { c: number } | null;
|
|
104
|
+
const ftsCount = ftsCountRow?.c ?? 0;
|
|
105
|
+
if (ftsCount > 0) return;
|
|
106
|
+
|
|
107
|
+
raw.exec(/*sql*/ `
|
|
108
|
+
INSERT INTO memory_segment_fts(segment_id, text)
|
|
109
|
+
SELECT id, text FROM memory_segments
|
|
110
|
+
`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* One-shot migration: merge duplicate relation edges so uniqueness can be
|
|
115
|
+
* enforced on (source_entity_id, target_entity_id, relation).
|
|
116
|
+
*/
|
|
117
|
+
export function migrateMemoryEntityRelationDedup(database: Db): void {
|
|
118
|
+
const raw = (database as unknown as { $client: Database }).$client;
|
|
119
|
+
const checkpointKey = 'migration_memory_entity_relations_dedup_v1';
|
|
120
|
+
const checkpoint = raw.query(
|
|
121
|
+
`SELECT 1 FROM memory_checkpoints WHERE key = ?`,
|
|
122
|
+
).get(checkpointKey);
|
|
123
|
+
if (checkpoint) return;
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
raw.exec('BEGIN');
|
|
127
|
+
|
|
128
|
+
raw.exec(/*sql*/ `
|
|
129
|
+
CREATE TEMP TABLE memory_entity_relation_merge AS
|
|
130
|
+
WITH ranked AS (
|
|
131
|
+
SELECT
|
|
132
|
+
source_entity_id,
|
|
133
|
+
target_entity_id,
|
|
134
|
+
relation,
|
|
135
|
+
first_seen_at,
|
|
136
|
+
last_seen_at,
|
|
137
|
+
evidence,
|
|
138
|
+
ROW_NUMBER() OVER (
|
|
139
|
+
PARTITION BY source_entity_id, target_entity_id, relation
|
|
140
|
+
ORDER BY last_seen_at DESC, first_seen_at DESC, id DESC
|
|
141
|
+
) AS rank_latest
|
|
142
|
+
FROM memory_entity_relations
|
|
143
|
+
)
|
|
144
|
+
SELECT
|
|
145
|
+
source_entity_id,
|
|
146
|
+
target_entity_id,
|
|
147
|
+
relation,
|
|
148
|
+
MIN(first_seen_at) AS merged_first_seen_at,
|
|
149
|
+
MAX(last_seen_at) AS merged_last_seen_at,
|
|
150
|
+
MAX(CASE WHEN rank_latest = 1 THEN evidence ELSE NULL END) AS merged_evidence
|
|
151
|
+
FROM ranked
|
|
152
|
+
GROUP BY source_entity_id, target_entity_id, relation
|
|
153
|
+
`);
|
|
154
|
+
|
|
155
|
+
raw.exec(/*sql*/ `DELETE FROM memory_entity_relations`);
|
|
156
|
+
|
|
157
|
+
raw.exec(/*sql*/ `
|
|
158
|
+
INSERT INTO memory_entity_relations (
|
|
159
|
+
id,
|
|
160
|
+
source_entity_id,
|
|
161
|
+
target_entity_id,
|
|
162
|
+
relation,
|
|
163
|
+
evidence,
|
|
164
|
+
first_seen_at,
|
|
165
|
+
last_seen_at
|
|
166
|
+
)
|
|
167
|
+
SELECT
|
|
168
|
+
lower(hex(randomblob(16))),
|
|
169
|
+
source_entity_id,
|
|
170
|
+
target_entity_id,
|
|
171
|
+
relation,
|
|
172
|
+
merged_evidence,
|
|
173
|
+
merged_first_seen_at,
|
|
174
|
+
merged_last_seen_at
|
|
175
|
+
FROM memory_entity_relation_merge
|
|
176
|
+
`);
|
|
177
|
+
|
|
178
|
+
raw.exec(/*sql*/ `DROP TABLE memory_entity_relation_merge`);
|
|
179
|
+
|
|
180
|
+
raw.query(
|
|
181
|
+
`INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
|
|
182
|
+
).run(checkpointKey, Date.now());
|
|
183
|
+
|
|
184
|
+
raw.exec('COMMIT');
|
|
185
|
+
} catch (e) {
|
|
186
|
+
try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
|
|
187
|
+
throw e;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Migrate from a column-level UNIQUE on fingerprint to a compound unique
|
|
193
|
+
* index on (fingerprint, scope_id) so that the same item can exist in
|
|
194
|
+
* different scopes independently.
|
|
195
|
+
*/
|
|
196
|
+
export function migrateMemoryItemsFingerprintScopeUnique(database: Db): void {
|
|
197
|
+
const raw = (database as unknown as { $client: Database }).$client;
|
|
198
|
+
const checkpointKey = 'migration_memory_items_fingerprint_scope_unique_v1';
|
|
199
|
+
const checkpoint = raw.query(
|
|
200
|
+
`SELECT 1 FROM memory_checkpoints WHERE key = ?`,
|
|
201
|
+
).get(checkpointKey);
|
|
202
|
+
if (checkpoint) return;
|
|
203
|
+
|
|
204
|
+
// Check if the old column-level UNIQUE constraint still exists by inspecting
|
|
205
|
+
// the CREATE TABLE DDL for the word UNIQUE (the PK also creates an autoindex,
|
|
206
|
+
// so we cannot rely on sqlite_autoindex_* presence alone).
|
|
207
|
+
const tableDdl = raw.query(
|
|
208
|
+
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'memory_items'`,
|
|
209
|
+
).get() as { sql: string } | null;
|
|
210
|
+
if (!tableDdl || !tableDdl.sql.match(/fingerprint\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i)) {
|
|
211
|
+
// No column-level UNIQUE on fingerprint — either fresh DB or already migrated.
|
|
212
|
+
raw.query(
|
|
213
|
+
`INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
|
|
214
|
+
).run(checkpointKey, Date.now());
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Rebuild the table without the column-level UNIQUE constraint.
|
|
219
|
+
raw.exec('PRAGMA foreign_keys = OFF');
|
|
220
|
+
try {
|
|
221
|
+
raw.exec('BEGIN');
|
|
222
|
+
|
|
223
|
+
// Create new table without UNIQUE on fingerprint — all other columns
|
|
224
|
+
// match the latest schema (including migration-added columns).
|
|
225
|
+
raw.exec(/*sql*/ `
|
|
226
|
+
CREATE TABLE memory_items_new (
|
|
227
|
+
id TEXT PRIMARY KEY,
|
|
228
|
+
kind TEXT NOT NULL,
|
|
229
|
+
subject TEXT NOT NULL,
|
|
230
|
+
statement TEXT NOT NULL,
|
|
231
|
+
status TEXT NOT NULL,
|
|
232
|
+
confidence REAL NOT NULL,
|
|
233
|
+
fingerprint TEXT NOT NULL,
|
|
234
|
+
first_seen_at INTEGER NOT NULL,
|
|
235
|
+
last_seen_at INTEGER NOT NULL,
|
|
236
|
+
last_used_at INTEGER,
|
|
237
|
+
importance REAL,
|
|
238
|
+
access_count INTEGER NOT NULL DEFAULT 0,
|
|
239
|
+
valid_from INTEGER,
|
|
240
|
+
invalid_at INTEGER,
|
|
241
|
+
verification_state TEXT NOT NULL DEFAULT 'assistant_inferred',
|
|
242
|
+
scope_id TEXT NOT NULL DEFAULT 'default'
|
|
243
|
+
)
|
|
244
|
+
`);
|
|
245
|
+
|
|
246
|
+
raw.exec(/*sql*/ `
|
|
247
|
+
INSERT INTO memory_items_new
|
|
248
|
+
SELECT id, kind, subject, statement, status, confidence, fingerprint,
|
|
249
|
+
first_seen_at, last_seen_at, last_used_at, importance, access_count,
|
|
250
|
+
valid_from, invalid_at, verification_state, scope_id
|
|
251
|
+
FROM memory_items
|
|
252
|
+
`);
|
|
253
|
+
|
|
254
|
+
raw.exec(/*sql*/ `DROP TABLE memory_items`);
|
|
255
|
+
raw.exec(/*sql*/ `ALTER TABLE memory_items_new RENAME TO memory_items`);
|
|
256
|
+
|
|
257
|
+
raw.query(
|
|
258
|
+
`INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
|
|
259
|
+
).run(checkpointKey, Date.now());
|
|
260
|
+
|
|
261
|
+
raw.exec('COMMIT');
|
|
262
|
+
} catch (e) {
|
|
263
|
+
try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
|
|
264
|
+
throw e;
|
|
265
|
+
} finally {
|
|
266
|
+
raw.exec('PRAGMA foreign_keys = ON');
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* One-shot migration: recompute fingerprints for existing memory items to
|
|
272
|
+
* include the scope_id prefix introduced in the scope-salted fingerprint PR.
|
|
273
|
+
*
|
|
274
|
+
* Old format: sha256(`${kind}|${subject.toLowerCase()}|${statement.toLowerCase()}`)
|
|
275
|
+
* New format: sha256(`${scopeId}|${kind}|${subject.toLowerCase()}|${statement.toLowerCase()}`)
|
|
276
|
+
*
|
|
277
|
+
* Without this migration, pre-upgrade items would never match on re-extraction,
|
|
278
|
+
* causing duplicates and broken deduplication.
|
|
279
|
+
*/
|
|
280
|
+
export function migrateMemoryItemsScopeSaltedFingerprints(database: Db): void {
|
|
281
|
+
const raw = (database as unknown as { $client: Database }).$client;
|
|
282
|
+
const checkpointKey = 'migration_memory_items_scope_salted_fingerprints_v1';
|
|
283
|
+
const checkpoint = raw.query(
|
|
284
|
+
`SELECT 1 FROM memory_checkpoints WHERE key = ?`,
|
|
285
|
+
).get(checkpointKey);
|
|
286
|
+
if (checkpoint) return;
|
|
287
|
+
|
|
288
|
+
interface ItemRow {
|
|
289
|
+
id: string;
|
|
290
|
+
kind: string;
|
|
291
|
+
subject: string;
|
|
292
|
+
statement: string;
|
|
293
|
+
scope_id: string;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const items = raw.query(
|
|
297
|
+
`SELECT id, kind, subject, statement, scope_id FROM memory_items`,
|
|
298
|
+
).all() as ItemRow[];
|
|
299
|
+
|
|
300
|
+
if (items.length === 0) {
|
|
301
|
+
raw.query(
|
|
302
|
+
`INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
|
|
303
|
+
).run(checkpointKey, Date.now());
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
raw.exec('BEGIN');
|
|
309
|
+
|
|
310
|
+
const updateStmt = raw.prepare(
|
|
311
|
+
`UPDATE memory_items SET fingerprint = ? WHERE id = ?`,
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
for (const item of items) {
|
|
315
|
+
const fingerprint = computeMemoryFingerprint(item.scope_id, item.kind, item.subject, item.statement);
|
|
316
|
+
updateStmt.run(fingerprint, item.id);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
raw.query(
|
|
320
|
+
`INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
|
|
321
|
+
).run(checkpointKey, Date.now());
|
|
322
|
+
|
|
323
|
+
raw.exec('COMMIT');
|
|
324
|
+
} catch (e) {
|
|
325
|
+
try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
|
|
326
|
+
throw e;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* One-shot migration: normalize all assistant_id values in assistant-scoped tables
|
|
332
|
+
* to "self" so they are visible after the daemon switched to the implicit single-tenant
|
|
333
|
+
* identity.
|
|
334
|
+
*
|
|
335
|
+
* Before this change, rows were keyed by the real assistantId string passed via the
|
|
336
|
+
* HTTP route. After the route change, all lookups use the constant "self". Without this
|
|
337
|
+
* migration an upgraded daemon would see empty history / attachment lists for existing
|
|
338
|
+
* data that was stored under the old assistantId.
|
|
339
|
+
*
|
|
340
|
+
* Affected tables:
|
|
341
|
+
* - conversation_keys UNIQUE (assistant_id, conversation_key)
|
|
342
|
+
* - attachments UNIQUE (assistant_id, content_hash) WHERE content_hash IS NOT NULL
|
|
343
|
+
* - channel_inbound_events UNIQUE (assistant_id, source_channel, external_chat_id, external_message_id)
|
|
344
|
+
* - message_runs no unique constraint on assistant_id
|
|
345
|
+
*
|
|
346
|
+
* Data-safety guarantees:
|
|
347
|
+
* - conversation_keys: when a key exists under both 'self' and a real assistantId, the
|
|
348
|
+
* 'self' row is updated to point to the real-assistantId conversation (which holds the
|
|
349
|
+
* historical message thread). The 'self' conversation may be orphaned but is not deleted.
|
|
350
|
+
* - attachments: message_attachments links are remapped to the surviving attachment before
|
|
351
|
+
* any duplicate row is deleted, so no message loses its attachment metadata.
|
|
352
|
+
* - channel_inbound_events: only delivery-tracking metadata, not user content; dedup
|
|
353
|
+
* keeps one row per unique (channel, chat, message) tuple.
|
|
354
|
+
* - All conversations and messages remain untouched — only assistant_id index columns
|
|
355
|
+
* and key-lookup rows are modified.
|
|
356
|
+
*/
|
|
357
|
+
export function migrateAssistantIdToSelf(database: Db): void {
|
|
358
|
+
const raw = (database as unknown as { $client: Database }).$client;
|
|
359
|
+
const checkpointKey = 'migration_normalize_assistant_id_to_self_v1';
|
|
360
|
+
const checkpoint = raw.query(
|
|
361
|
+
`SELECT 1 FROM memory_checkpoints WHERE key = ?`,
|
|
362
|
+
).get(checkpointKey);
|
|
363
|
+
if (checkpoint) return;
|
|
364
|
+
|
|
365
|
+
// On fresh installs the tables are created without assistant_id (PR 7+). Skip the
|
|
366
|
+
// migration if NONE of the four affected tables have the column — pre-seed the
|
|
367
|
+
// checkpoint so subsequent startups are also skipped. Checking all four (not just
|
|
368
|
+
// conversation_keys) avoids a false negative on very old installs where
|
|
369
|
+
// conversation_keys may not exist yet but other tables still carry assistant_id data.
|
|
370
|
+
const affectedTables = ['conversation_keys', 'attachments', 'channel_inbound_events', 'message_runs'];
|
|
371
|
+
const anyHasAssistantId = affectedTables.some((tbl) => {
|
|
372
|
+
const ddl = raw.query(
|
|
373
|
+
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?`,
|
|
374
|
+
).get(tbl) as { sql: string } | null;
|
|
375
|
+
return ddl?.sql.includes('assistant_id') ?? false;
|
|
376
|
+
});
|
|
377
|
+
if (!anyHasAssistantId) {
|
|
378
|
+
raw.query(
|
|
379
|
+
`INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
|
|
380
|
+
).run(checkpointKey, Date.now());
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Helper: returns true if the given table's current DDL contains 'assistant_id'.
|
|
385
|
+
const tableHasAssistantId = (tbl: string): boolean => {
|
|
386
|
+
const ddl = raw.query(
|
|
387
|
+
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?`,
|
|
388
|
+
).get(tbl) as { sql: string } | null;
|
|
389
|
+
return ddl?.sql.includes('assistant_id') ?? false;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
raw.exec('BEGIN');
|
|
394
|
+
|
|
395
|
+
// Each section is guarded so that SQL referencing assistant_id is only executed
|
|
396
|
+
// when the column still exists in that table. This handles mixed-schema states
|
|
397
|
+
// (e.g., very old installs where some tables may already lack the column).
|
|
398
|
+
|
|
399
|
+
// conversation_keys: UNIQUE (assistant_id, conversation_key)
|
|
400
|
+
if (tableHasAssistantId('conversation_keys')) {
|
|
401
|
+
// Step 1: Among non-self rows, keep only one per conversation_key so the
|
|
402
|
+
// bulk UPDATE cannot hit a (non-self-A, key) + (non-self-B, key) collision.
|
|
403
|
+
raw.exec(/*sql*/ `
|
|
404
|
+
DELETE FROM conversation_keys
|
|
405
|
+
WHERE assistant_id != 'self'
|
|
406
|
+
AND rowid NOT IN (
|
|
407
|
+
SELECT MIN(rowid) FROM conversation_keys
|
|
408
|
+
WHERE assistant_id != 'self'
|
|
409
|
+
GROUP BY conversation_key
|
|
410
|
+
)
|
|
411
|
+
`);
|
|
412
|
+
// Step 2: For 'self' rows that have a non-self counterpart with the same
|
|
413
|
+
// conversation_key, update the 'self' row to use the non-self row's
|
|
414
|
+
// conversation_id. This preserves the historical conversation (which
|
|
415
|
+
// has the message history from before the route change) rather than
|
|
416
|
+
// discarding it in favour of a potentially-empty 'self' conversation.
|
|
417
|
+
raw.exec(/*sql*/ `
|
|
418
|
+
UPDATE conversation_keys
|
|
419
|
+
SET conversation_id = (
|
|
420
|
+
SELECT ck_ns.conversation_id
|
|
421
|
+
FROM conversation_keys ck_ns
|
|
422
|
+
WHERE ck_ns.assistant_id != 'self'
|
|
423
|
+
AND ck_ns.conversation_key = conversation_keys.conversation_key
|
|
424
|
+
ORDER BY ck_ns.rowid
|
|
425
|
+
LIMIT 1
|
|
426
|
+
)
|
|
427
|
+
WHERE assistant_id = 'self'
|
|
428
|
+
AND EXISTS (
|
|
429
|
+
SELECT 1 FROM conversation_keys ck_ns
|
|
430
|
+
WHERE ck_ns.assistant_id != 'self'
|
|
431
|
+
AND ck_ns.conversation_key = conversation_keys.conversation_key
|
|
432
|
+
)
|
|
433
|
+
`);
|
|
434
|
+
// Step 3: Delete the now-redundant non-self rows (their conversation_ids
|
|
435
|
+
// have been preserved in the 'self' rows above).
|
|
436
|
+
raw.exec(/*sql*/ `
|
|
437
|
+
DELETE FROM conversation_keys
|
|
438
|
+
WHERE assistant_id != 'self'
|
|
439
|
+
AND EXISTS (
|
|
440
|
+
SELECT 1 FROM conversation_keys ck2
|
|
441
|
+
WHERE ck2.assistant_id = 'self'
|
|
442
|
+
AND ck2.conversation_key = conversation_keys.conversation_key
|
|
443
|
+
)
|
|
444
|
+
`);
|
|
445
|
+
// Step 4: Remaining non-self rows have no 'self' counterpart — safe to bulk-update.
|
|
446
|
+
raw.exec(/*sql*/ `
|
|
447
|
+
UPDATE conversation_keys SET assistant_id = 'self' WHERE assistant_id != 'self'
|
|
448
|
+
`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// attachments: UNIQUE (assistant_id, content_hash) WHERE content_hash IS NOT NULL
|
|
452
|
+
//
|
|
453
|
+
// message_attachments rows reference attachment IDs with ON DELETE CASCADE, so we
|
|
454
|
+
// must remap links to the surviving row BEFORE deleting duplicates to avoid
|
|
455
|
+
// silently dropping attachment metadata from messages.
|
|
456
|
+
if (tableHasAssistantId('attachments')) {
|
|
457
|
+
// Step 1: Remap message_attachments from non-self duplicates to their survivor
|
|
458
|
+
// (MIN rowid per content_hash group), then delete the duplicates.
|
|
459
|
+
raw.exec(/*sql*/ `
|
|
460
|
+
UPDATE message_attachments
|
|
461
|
+
SET attachment_id = (
|
|
462
|
+
SELECT a_survivor.id
|
|
463
|
+
FROM attachments a_survivor
|
|
464
|
+
WHERE a_survivor.assistant_id != 'self'
|
|
465
|
+
AND a_survivor.content_hash = (
|
|
466
|
+
SELECT a_dup.content_hash FROM attachments a_dup
|
|
467
|
+
WHERE a_dup.id = message_attachments.attachment_id
|
|
468
|
+
)
|
|
469
|
+
ORDER BY a_survivor.rowid
|
|
470
|
+
LIMIT 1
|
|
471
|
+
)
|
|
472
|
+
WHERE attachment_id IN (
|
|
473
|
+
SELECT id FROM attachments
|
|
474
|
+
WHERE assistant_id != 'self'
|
|
475
|
+
AND content_hash IS NOT NULL
|
|
476
|
+
AND rowid NOT IN (
|
|
477
|
+
SELECT MIN(rowid) FROM attachments
|
|
478
|
+
WHERE assistant_id != 'self' AND content_hash IS NOT NULL
|
|
479
|
+
GROUP BY content_hash
|
|
480
|
+
)
|
|
481
|
+
)
|
|
482
|
+
`);
|
|
483
|
+
raw.exec(/*sql*/ `
|
|
484
|
+
DELETE FROM attachments
|
|
485
|
+
WHERE assistant_id != 'self'
|
|
486
|
+
AND content_hash IS NOT NULL
|
|
487
|
+
AND rowid NOT IN (
|
|
488
|
+
SELECT MIN(rowid) FROM attachments
|
|
489
|
+
WHERE assistant_id != 'self'
|
|
490
|
+
AND content_hash IS NOT NULL
|
|
491
|
+
GROUP BY content_hash
|
|
492
|
+
)
|
|
493
|
+
`);
|
|
494
|
+
// Step 2: Remap message_attachments from non-self rows conflicting with a 'self'
|
|
495
|
+
// row to the 'self' row, then delete the now-unlinked non-self rows.
|
|
496
|
+
raw.exec(/*sql*/ `
|
|
497
|
+
UPDATE message_attachments
|
|
498
|
+
SET attachment_id = (
|
|
499
|
+
SELECT a_self.id
|
|
500
|
+
FROM attachments a_self
|
|
501
|
+
WHERE a_self.assistant_id = 'self'
|
|
502
|
+
AND a_self.content_hash = (
|
|
503
|
+
SELECT a_ns.content_hash FROM attachments a_ns
|
|
504
|
+
WHERE a_ns.id = message_attachments.attachment_id
|
|
505
|
+
)
|
|
506
|
+
LIMIT 1
|
|
507
|
+
)
|
|
508
|
+
WHERE attachment_id IN (
|
|
509
|
+
SELECT id FROM attachments
|
|
510
|
+
WHERE assistant_id != 'self'
|
|
511
|
+
AND content_hash IS NOT NULL
|
|
512
|
+
AND EXISTS (
|
|
513
|
+
SELECT 1 FROM attachments a2
|
|
514
|
+
WHERE a2.assistant_id = 'self'
|
|
515
|
+
AND a2.content_hash = attachments.content_hash
|
|
516
|
+
)
|
|
517
|
+
)
|
|
518
|
+
`);
|
|
519
|
+
raw.exec(/*sql*/ `
|
|
520
|
+
DELETE FROM attachments
|
|
521
|
+
WHERE assistant_id != 'self'
|
|
522
|
+
AND content_hash IS NOT NULL
|
|
523
|
+
AND EXISTS (
|
|
524
|
+
SELECT 1 FROM attachments a2
|
|
525
|
+
WHERE a2.assistant_id = 'self'
|
|
526
|
+
AND a2.content_hash = attachments.content_hash
|
|
527
|
+
)
|
|
528
|
+
`);
|
|
529
|
+
// Step 3: Bulk-update remaining non-self rows.
|
|
530
|
+
raw.exec(/*sql*/ `
|
|
531
|
+
UPDATE attachments SET assistant_id = 'self' WHERE assistant_id != 'self'
|
|
532
|
+
`);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// channel_inbound_events: UNIQUE (assistant_id, source_channel, external_chat_id, external_message_id)
|
|
536
|
+
if (tableHasAssistantId('channel_inbound_events')) {
|
|
537
|
+
// Step 1: Dedup non-self rows sharing the same (source_channel, external_chat_id, external_message_id).
|
|
538
|
+
raw.exec(/*sql*/ `
|
|
539
|
+
DELETE FROM channel_inbound_events
|
|
540
|
+
WHERE assistant_id != 'self'
|
|
541
|
+
AND rowid NOT IN (
|
|
542
|
+
SELECT MIN(rowid) FROM channel_inbound_events
|
|
543
|
+
WHERE assistant_id != 'self'
|
|
544
|
+
GROUP BY source_channel, external_chat_id, external_message_id
|
|
545
|
+
)
|
|
546
|
+
`);
|
|
547
|
+
// Step 2: Delete non-self rows conflicting with existing 'self' rows.
|
|
548
|
+
raw.exec(/*sql*/ `
|
|
549
|
+
DELETE FROM channel_inbound_events
|
|
550
|
+
WHERE assistant_id != 'self'
|
|
551
|
+
AND EXISTS (
|
|
552
|
+
SELECT 1 FROM channel_inbound_events e2
|
|
553
|
+
WHERE e2.assistant_id = 'self'
|
|
554
|
+
AND e2.source_channel = channel_inbound_events.source_channel
|
|
555
|
+
AND e2.external_chat_id = channel_inbound_events.external_chat_id
|
|
556
|
+
AND e2.external_message_id = channel_inbound_events.external_message_id
|
|
557
|
+
)
|
|
558
|
+
`);
|
|
559
|
+
// Step 3: Bulk-update remaining non-self rows.
|
|
560
|
+
raw.exec(/*sql*/ `
|
|
561
|
+
UPDATE channel_inbound_events SET assistant_id = 'self' WHERE assistant_id != 'self'
|
|
562
|
+
`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// message_runs: no unique constraint on assistant_id — simple bulk update
|
|
566
|
+
if (tableHasAssistantId('message_runs')) {
|
|
567
|
+
raw.exec(/*sql*/ `
|
|
568
|
+
UPDATE message_runs SET assistant_id = 'self' WHERE assistant_id != 'self'
|
|
569
|
+
`);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
raw.query(
|
|
573
|
+
`INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
|
|
574
|
+
).run(checkpointKey, Date.now());
|
|
575
|
+
|
|
576
|
+
raw.exec('COMMIT');
|
|
577
|
+
} catch (e) {
|
|
578
|
+
try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
|
|
579
|
+
throw e;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* One-shot migration: rebuild tables that previously stored assistant_id to remove
|
|
585
|
+
* that column now that all rows are keyed to the implicit single-tenant identity ("self").
|
|
586
|
+
*
|
|
587
|
+
* Must run AFTER migrateAssistantIdToSelf (which normalises all values to "self")
|
|
588
|
+
* so there are no constraint violations when recreating the tables without the
|
|
589
|
+
* assistant_id dimension.
|
|
590
|
+
*
|
|
591
|
+
* Each table section is guarded by a DDL check so this is safe on fresh installs
|
|
592
|
+
* where the column was never created in the first place.
|
|
593
|
+
*
|
|
594
|
+
* Tables rebuilt:
|
|
595
|
+
* - conversation_keys UNIQUE (conversation_key)
|
|
596
|
+
* - attachments no structural unique; content-dedup index updated
|
|
597
|
+
* - channel_inbound_events UNIQUE (source_channel, external_chat_id, external_message_id)
|
|
598
|
+
* - message_runs no unique constraint on assistant_id
|
|
599
|
+
* - llm_usage_events nullable column with no constraint
|
|
600
|
+
*/
|
|
601
|
+
export function migrateRemoveAssistantIdColumns(database: Db): void {
|
|
602
|
+
const raw = (database as unknown as { $client: Database }).$client;
|
|
603
|
+
const checkpointKey = 'migration_remove_assistant_id_columns_v1';
|
|
604
|
+
const checkpoint = raw.query(
|
|
605
|
+
`SELECT 1 FROM memory_checkpoints WHERE key = ?`,
|
|
606
|
+
).get(checkpointKey);
|
|
607
|
+
if (checkpoint) return;
|
|
608
|
+
|
|
609
|
+
raw.exec('PRAGMA foreign_keys = OFF');
|
|
610
|
+
try {
|
|
611
|
+
raw.exec('BEGIN');
|
|
612
|
+
|
|
613
|
+
// --- conversation_keys ---
|
|
614
|
+
const ckDdl = raw.query(
|
|
615
|
+
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'conversation_keys'`,
|
|
616
|
+
).get() as { sql: string } | null;
|
|
617
|
+
if (ckDdl?.sql.includes('assistant_id')) {
|
|
618
|
+
raw.exec(/*sql*/ `
|
|
619
|
+
CREATE TABLE conversation_keys_new (
|
|
620
|
+
id TEXT PRIMARY KEY,
|
|
621
|
+
conversation_key TEXT NOT NULL UNIQUE,
|
|
622
|
+
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
623
|
+
created_at INTEGER NOT NULL
|
|
624
|
+
)
|
|
625
|
+
`);
|
|
626
|
+
raw.exec(/*sql*/ `
|
|
627
|
+
INSERT INTO conversation_keys_new (id, conversation_key, conversation_id, created_at)
|
|
628
|
+
SELECT id, conversation_key, conversation_id, created_at FROM conversation_keys
|
|
629
|
+
`);
|
|
630
|
+
raw.exec(/*sql*/ `DROP TABLE conversation_keys`);
|
|
631
|
+
raw.exec(/*sql*/ `ALTER TABLE conversation_keys_new RENAME TO conversation_keys`);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// --- attachments ---
|
|
635
|
+
const attDdl = raw.query(
|
|
636
|
+
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'attachments'`,
|
|
637
|
+
).get() as { sql: string } | null;
|
|
638
|
+
if (attDdl?.sql.includes('assistant_id')) {
|
|
639
|
+
raw.exec(/*sql*/ `
|
|
640
|
+
CREATE TABLE attachments_new (
|
|
641
|
+
id TEXT PRIMARY KEY,
|
|
642
|
+
original_filename TEXT NOT NULL,
|
|
643
|
+
mime_type TEXT NOT NULL,
|
|
644
|
+
size_bytes INTEGER NOT NULL,
|
|
645
|
+
kind TEXT NOT NULL,
|
|
646
|
+
data_base64 TEXT NOT NULL,
|
|
647
|
+
content_hash TEXT,
|
|
648
|
+
thumbnail_base64 TEXT,
|
|
649
|
+
created_at INTEGER NOT NULL
|
|
650
|
+
)
|
|
651
|
+
`);
|
|
652
|
+
raw.exec(/*sql*/ `
|
|
653
|
+
INSERT INTO attachments_new (id, original_filename, mime_type, size_bytes, kind, data_base64, content_hash, thumbnail_base64, created_at)
|
|
654
|
+
SELECT id, original_filename, mime_type, size_bytes, kind, data_base64, content_hash, thumbnail_base64, created_at FROM attachments
|
|
655
|
+
`);
|
|
656
|
+
raw.exec(/*sql*/ `DROP TABLE attachments`);
|
|
657
|
+
raw.exec(/*sql*/ `ALTER TABLE attachments_new RENAME TO attachments`);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// --- channel_inbound_events ---
|
|
661
|
+
const cieDdl = raw.query(
|
|
662
|
+
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'channel_inbound_events'`,
|
|
663
|
+
).get() as { sql: string } | null;
|
|
664
|
+
if (cieDdl?.sql.includes('assistant_id')) {
|
|
665
|
+
raw.exec(/*sql*/ `
|
|
666
|
+
CREATE TABLE channel_inbound_events_new (
|
|
667
|
+
id TEXT PRIMARY KEY,
|
|
668
|
+
source_channel TEXT NOT NULL,
|
|
669
|
+
external_chat_id TEXT NOT NULL,
|
|
670
|
+
external_message_id TEXT NOT NULL,
|
|
671
|
+
source_message_id TEXT,
|
|
672
|
+
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
673
|
+
message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
|
|
674
|
+
delivery_status TEXT NOT NULL DEFAULT 'pending',
|
|
675
|
+
processing_status TEXT NOT NULL DEFAULT 'pending',
|
|
676
|
+
processing_attempts INTEGER NOT NULL DEFAULT 0,
|
|
677
|
+
last_processing_error TEXT,
|
|
678
|
+
retry_after INTEGER,
|
|
679
|
+
raw_payload TEXT,
|
|
680
|
+
created_at INTEGER NOT NULL,
|
|
681
|
+
updated_at INTEGER NOT NULL,
|
|
682
|
+
UNIQUE (source_channel, external_chat_id, external_message_id)
|
|
683
|
+
)
|
|
684
|
+
`);
|
|
685
|
+
raw.exec(/*sql*/ `
|
|
686
|
+
INSERT INTO channel_inbound_events_new (
|
|
687
|
+
id, source_channel, external_chat_id, external_message_id, source_message_id,
|
|
688
|
+
conversation_id, message_id, delivery_status, processing_status,
|
|
689
|
+
processing_attempts, last_processing_error, retry_after, raw_payload,
|
|
690
|
+
created_at, updated_at
|
|
691
|
+
)
|
|
692
|
+
SELECT
|
|
693
|
+
id, source_channel, external_chat_id, external_message_id, source_message_id,
|
|
694
|
+
conversation_id, message_id, delivery_status, processing_status,
|
|
695
|
+
processing_attempts, last_processing_error, retry_after, raw_payload,
|
|
696
|
+
created_at, updated_at
|
|
697
|
+
FROM channel_inbound_events
|
|
698
|
+
`);
|
|
699
|
+
raw.exec(/*sql*/ `DROP TABLE channel_inbound_events`);
|
|
700
|
+
raw.exec(/*sql*/ `ALTER TABLE channel_inbound_events_new RENAME TO channel_inbound_events`);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// --- message_runs ---
|
|
704
|
+
const mrDdl = raw.query(
|
|
705
|
+
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'message_runs'`,
|
|
706
|
+
).get() as { sql: string } | null;
|
|
707
|
+
if (mrDdl?.sql.includes('assistant_id')) {
|
|
708
|
+
raw.exec(/*sql*/ `
|
|
709
|
+
CREATE TABLE message_runs_new (
|
|
710
|
+
id TEXT PRIMARY KEY,
|
|
711
|
+
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
712
|
+
message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
|
|
713
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
714
|
+
pending_confirmation TEXT,
|
|
715
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
716
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
717
|
+
estimated_cost REAL NOT NULL DEFAULT 0,
|
|
718
|
+
error TEXT,
|
|
719
|
+
created_at INTEGER NOT NULL,
|
|
720
|
+
updated_at INTEGER NOT NULL
|
|
721
|
+
)
|
|
722
|
+
`);
|
|
723
|
+
raw.exec(/*sql*/ `
|
|
724
|
+
INSERT INTO message_runs_new (
|
|
725
|
+
id, conversation_id, message_id, status, pending_confirmation,
|
|
726
|
+
input_tokens, output_tokens, estimated_cost, error, created_at, updated_at
|
|
727
|
+
)
|
|
728
|
+
SELECT
|
|
729
|
+
id, conversation_id, message_id, status, pending_confirmation,
|
|
730
|
+
input_tokens, output_tokens, estimated_cost, error, created_at, updated_at
|
|
731
|
+
FROM message_runs
|
|
732
|
+
`);
|
|
733
|
+
raw.exec(/*sql*/ `DROP TABLE message_runs`);
|
|
734
|
+
raw.exec(/*sql*/ `ALTER TABLE message_runs_new RENAME TO message_runs`);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// --- llm_usage_events ---
|
|
738
|
+
const lueDdl = raw.query(
|
|
739
|
+
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'llm_usage_events'`,
|
|
740
|
+
).get() as { sql: string } | null;
|
|
741
|
+
if (lueDdl?.sql.includes('assistant_id')) {
|
|
742
|
+
raw.exec(/*sql*/ `
|
|
743
|
+
CREATE TABLE llm_usage_events_new (
|
|
744
|
+
id TEXT PRIMARY KEY,
|
|
745
|
+
created_at INTEGER NOT NULL,
|
|
746
|
+
conversation_id TEXT,
|
|
747
|
+
run_id TEXT,
|
|
748
|
+
request_id TEXT,
|
|
749
|
+
actor TEXT NOT NULL,
|
|
750
|
+
provider TEXT NOT NULL,
|
|
751
|
+
model TEXT NOT NULL,
|
|
752
|
+
input_tokens INTEGER NOT NULL,
|
|
753
|
+
output_tokens INTEGER NOT NULL,
|
|
754
|
+
cache_creation_input_tokens INTEGER,
|
|
755
|
+
cache_read_input_tokens INTEGER,
|
|
756
|
+
estimated_cost_usd REAL,
|
|
757
|
+
pricing_status TEXT NOT NULL,
|
|
758
|
+
metadata_json TEXT
|
|
759
|
+
)
|
|
760
|
+
`);
|
|
761
|
+
raw.exec(/*sql*/ `
|
|
762
|
+
INSERT INTO llm_usage_events_new (
|
|
763
|
+
id, created_at, conversation_id, run_id, request_id, actor, provider, model,
|
|
764
|
+
input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens,
|
|
765
|
+
estimated_cost_usd, pricing_status, metadata_json
|
|
766
|
+
)
|
|
767
|
+
SELECT
|
|
768
|
+
id, created_at, conversation_id, run_id, request_id, actor, provider, model,
|
|
769
|
+
input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens,
|
|
770
|
+
estimated_cost_usd, pricing_status, metadata_json
|
|
771
|
+
FROM llm_usage_events
|
|
772
|
+
`);
|
|
773
|
+
raw.exec(/*sql*/ `DROP TABLE llm_usage_events`);
|
|
774
|
+
raw.exec(/*sql*/ `ALTER TABLE llm_usage_events_new RENAME TO llm_usage_events`);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
raw.query(
|
|
778
|
+
`INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
|
|
779
|
+
).run(checkpointKey, Date.now());
|
|
780
|
+
|
|
781
|
+
raw.exec('COMMIT');
|
|
782
|
+
} catch (e) {
|
|
783
|
+
try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
|
|
784
|
+
throw e;
|
|
785
|
+
} finally {
|
|
786
|
+
raw.exec('PRAGMA foreign_keys = ON');
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* One-shot migration: rebuild llm_usage_events to drop the assistant_id column.
|
|
792
|
+
*
|
|
793
|
+
* This is a SEPARATE migration from migrateRemoveAssistantIdColumns so that installs
|
|
794
|
+
* where the 4-table version of that migration already ran (checkpoint already set)
|
|
795
|
+
* still get the llm_usage_events column removed. Without a separate checkpoint key,
|
|
796
|
+
* those installs would skip the llm_usage_events rebuild entirely.
|
|
797
|
+
*
|
|
798
|
+
* Safe on fresh installs (DDL guard exits early) and idempotent via checkpoint.
|
|
799
|
+
*/
|
|
800
|
+
export function migrateLlmUsageEventsDropAssistantId(database: Db): void {
|
|
801
|
+
const raw = (database as unknown as { $client: Database }).$client;
|
|
802
|
+
const checkpointKey = 'migration_remove_assistant_id_lue_v1';
|
|
803
|
+
const checkpoint = raw.query(
|
|
804
|
+
`SELECT 1 FROM memory_checkpoints WHERE key = ?`,
|
|
805
|
+
).get(checkpointKey);
|
|
806
|
+
if (checkpoint) return;
|
|
807
|
+
|
|
808
|
+
// DDL guard: if the column was already removed (fresh install or migrateRemoveAssistantIdColumns
|
|
809
|
+
// ran with the llm_usage_events block), just record the checkpoint and exit.
|
|
810
|
+
const lueDdl = raw.query(
|
|
811
|
+
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'llm_usage_events'`,
|
|
812
|
+
).get() as { sql: string } | null;
|
|
813
|
+
|
|
814
|
+
if (!lueDdl?.sql.includes('assistant_id')) {
|
|
815
|
+
raw.query(
|
|
816
|
+
`INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
|
|
817
|
+
).run(checkpointKey, Date.now());
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
raw.exec('PRAGMA foreign_keys = OFF');
|
|
822
|
+
try {
|
|
823
|
+
raw.exec('BEGIN');
|
|
824
|
+
|
|
825
|
+
raw.exec(/*sql*/ `
|
|
826
|
+
CREATE TABLE llm_usage_events_new (
|
|
827
|
+
id TEXT PRIMARY KEY,
|
|
828
|
+
created_at INTEGER NOT NULL,
|
|
829
|
+
conversation_id TEXT,
|
|
830
|
+
run_id TEXT,
|
|
831
|
+
request_id TEXT,
|
|
832
|
+
actor TEXT NOT NULL,
|
|
833
|
+
provider TEXT NOT NULL,
|
|
834
|
+
model TEXT NOT NULL,
|
|
835
|
+
input_tokens INTEGER NOT NULL,
|
|
836
|
+
output_tokens INTEGER NOT NULL,
|
|
837
|
+
cache_creation_input_tokens INTEGER,
|
|
838
|
+
cache_read_input_tokens INTEGER,
|
|
839
|
+
estimated_cost_usd REAL,
|
|
840
|
+
pricing_status TEXT NOT NULL,
|
|
841
|
+
metadata_json TEXT
|
|
842
|
+
)
|
|
843
|
+
`);
|
|
844
|
+
raw.exec(/*sql*/ `
|
|
845
|
+
INSERT INTO llm_usage_events_new (
|
|
846
|
+
id, created_at, conversation_id, run_id, request_id, actor, provider, model,
|
|
847
|
+
input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens,
|
|
848
|
+
estimated_cost_usd, pricing_status, metadata_json
|
|
849
|
+
)
|
|
850
|
+
SELECT
|
|
851
|
+
id, created_at, conversation_id, run_id, request_id, actor, provider, model,
|
|
852
|
+
input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens,
|
|
853
|
+
estimated_cost_usd, pricing_status, metadata_json
|
|
854
|
+
FROM llm_usage_events
|
|
855
|
+
`);
|
|
856
|
+
raw.exec(/*sql*/ `DROP TABLE llm_usage_events`);
|
|
857
|
+
raw.exec(/*sql*/ `ALTER TABLE llm_usage_events_new RENAME TO llm_usage_events`);
|
|
858
|
+
|
|
859
|
+
raw.query(
|
|
860
|
+
`INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
|
|
861
|
+
).run(checkpointKey, Date.now());
|
|
862
|
+
|
|
863
|
+
raw.exec('COMMIT');
|
|
864
|
+
} catch (e) {
|
|
865
|
+
try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
|
|
866
|
+
throw e;
|
|
867
|
+
} finally {
|
|
868
|
+
raw.exec('PRAGMA foreign_keys = ON');
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* One-shot migration: deduplicate external_conversation_bindings rows that
|
|
874
|
+
* share the same (source_channel, external_chat_id), then create a unique
|
|
875
|
+
* index to enforce the invariant at DB level.
|
|
876
|
+
*
|
|
877
|
+
* For each duplicate group, the binding with the newest updatedAt (then
|
|
878
|
+
* createdAt) is kept; older duplicates are deleted.
|
|
879
|
+
*/
|
|
880
|
+
export function migrateExtConvBindingsChannelChatUnique(database: Db): void {
|
|
881
|
+
const raw = (database as unknown as { $client: Database }).$client;
|
|
882
|
+
|
|
883
|
+
// If the unique index already exists, nothing to do.
|
|
884
|
+
const idxExists = raw.query(
|
|
885
|
+
`SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_ext_conv_bindings_channel_chat_unique'`,
|
|
886
|
+
).get();
|
|
887
|
+
if (idxExists) return;
|
|
888
|
+
|
|
889
|
+
// Check if the table exists (first boot edge case).
|
|
890
|
+
const tableExists = raw.query(
|
|
891
|
+
`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'external_conversation_bindings'`,
|
|
892
|
+
).get();
|
|
893
|
+
if (!tableExists) return;
|
|
894
|
+
|
|
895
|
+
// Remove duplicates: keep the row with the newest updatedAt, then createdAt.
|
|
896
|
+
// Since conversation_id is the PK (rowid alias), we use it for ordering ties.
|
|
897
|
+
try {
|
|
898
|
+
raw.exec('BEGIN');
|
|
899
|
+
|
|
900
|
+
raw.exec(/*sql*/ `
|
|
901
|
+
DELETE FROM external_conversation_bindings
|
|
902
|
+
WHERE rowid NOT IN (
|
|
903
|
+
SELECT rowid FROM (
|
|
904
|
+
SELECT rowid,
|
|
905
|
+
ROW_NUMBER() OVER (
|
|
906
|
+
PARTITION BY source_channel, external_chat_id
|
|
907
|
+
ORDER BY updated_at DESC, created_at DESC, rowid DESC
|
|
908
|
+
) AS rn
|
|
909
|
+
FROM external_conversation_bindings
|
|
910
|
+
)
|
|
911
|
+
WHERE rn = 1
|
|
912
|
+
)
|
|
913
|
+
`);
|
|
914
|
+
|
|
915
|
+
raw.exec(/*sql*/ `
|
|
916
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ext_conv_bindings_channel_chat_unique
|
|
917
|
+
ON external_conversation_bindings(source_channel, external_chat_id)
|
|
918
|
+
`);
|
|
919
|
+
|
|
920
|
+
raw.exec('COMMIT');
|
|
921
|
+
} catch (e) {
|
|
922
|
+
try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
|
|
923
|
+
throw e;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* One-shot migration: remove duplicate (provider, provider_call_sid) rows from
|
|
929
|
+
* call_sessions so that the unique index can be created safely on upgraded databases
|
|
930
|
+
* that pre-date the constraint.
|
|
931
|
+
*
|
|
932
|
+
* For each set of duplicates, the most recently updated row is kept.
|
|
933
|
+
*/
|
|
934
|
+
export function migrateCallSessionsProviderSidDedup(database: Db): void {
|
|
935
|
+
const raw = (database as unknown as { $client: Database }).$client;
|
|
936
|
+
|
|
937
|
+
// Quick check: if the unique index already exists, no dedup is needed.
|
|
938
|
+
const idxExists = raw.query(
|
|
939
|
+
`SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_call_sessions_provider_sid_unique'`,
|
|
940
|
+
).get();
|
|
941
|
+
if (idxExists) return;
|
|
942
|
+
|
|
943
|
+
// Check if the table even exists yet (first boot).
|
|
944
|
+
const tableExists = raw.query(
|
|
945
|
+
`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'call_sessions'`,
|
|
946
|
+
).get();
|
|
947
|
+
if (!tableExists) return;
|
|
948
|
+
|
|
949
|
+
// Count duplicates before doing any work.
|
|
950
|
+
const dupCount = raw.query(/*sql*/ `
|
|
951
|
+
SELECT COUNT(*) AS c FROM (
|
|
952
|
+
SELECT provider, provider_call_sid
|
|
953
|
+
FROM call_sessions
|
|
954
|
+
WHERE provider_call_sid IS NOT NULL
|
|
955
|
+
GROUP BY provider, provider_call_sid
|
|
956
|
+
HAVING COUNT(*) > 1
|
|
957
|
+
)
|
|
958
|
+
`).get() as { c: number } | null;
|
|
959
|
+
|
|
960
|
+
if (!dupCount || dupCount.c === 0) return;
|
|
961
|
+
|
|
962
|
+
log.warn({ duplicateGroups: dupCount.c }, 'Deduplicating call_sessions with duplicate provider_call_sid before creating unique index');
|
|
963
|
+
|
|
964
|
+
try {
|
|
965
|
+
raw.exec('BEGIN');
|
|
966
|
+
|
|
967
|
+
// Keep the most recently updated row per (provider, provider_call_sid);
|
|
968
|
+
// delete the rest.
|
|
969
|
+
raw.exec(/*sql*/ `
|
|
970
|
+
DELETE FROM call_sessions
|
|
971
|
+
WHERE provider_call_sid IS NOT NULL
|
|
972
|
+
AND rowid NOT IN (
|
|
973
|
+
SELECT MAX(rowid) FROM call_sessions
|
|
974
|
+
WHERE provider_call_sid IS NOT NULL
|
|
975
|
+
GROUP BY provider, provider_call_sid
|
|
976
|
+
)
|
|
977
|
+
`);
|
|
978
|
+
|
|
979
|
+
raw.exec('COMMIT');
|
|
980
|
+
} catch (e) {
|
|
981
|
+
try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
|
|
982
|
+
throw e;
|
|
983
|
+
}
|
|
984
|
+
}
|