@martian-engineering/lossless-claw 0.6.3 → 0.8.0
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/README.md +26 -6
- package/docs/agent-tools.md +16 -5
- package/docs/configuration.md +223 -214
- package/openclaw.plugin.json +123 -0
- package/package.json +1 -1
- package/skills/lossless-claw/SKILL.md +3 -2
- package/skills/lossless-claw/references/architecture.md +12 -0
- package/skills/lossless-claw/references/config.md +135 -3
- package/skills/lossless-claw/references/diagnostics.md +13 -0
- package/src/assembler.ts +17 -5
- package/src/compaction.ts +161 -53
- package/src/db/config.ts +102 -4
- package/src/db/connection.ts +35 -7
- package/src/db/features.ts +24 -5
- package/src/db/migration.ts +257 -78
- package/src/engine.ts +1007 -110
- package/src/estimate-tokens.ts +80 -0
- package/src/lcm-log.ts +37 -0
- package/src/plugin/index.ts +493 -101
- package/src/plugin/lcm-command.ts +288 -7
- package/src/plugin/lcm-doctor-apply.ts +1 -3
- package/src/plugin/lcm-doctor-cleaners.ts +655 -0
- package/src/plugin/shared-init.ts +59 -0
- package/src/prune.ts +391 -0
- package/src/retrieval.ts +8 -9
- package/src/startup-banner-log.ts +1 -0
- package/src/store/compaction-telemetry-store.ts +156 -0
- package/src/store/conversation-store.ts +6 -1
- package/src/store/fts5-sanitize.ts +25 -4
- package/src/store/full-text-sort.ts +21 -0
- package/src/store/index.ts +8 -0
- package/src/store/summary-store.ts +21 -14
- package/src/summarize.ts +55 -34
- package/src/tools/lcm-describe-tool.ts +9 -4
- package/src/tools/lcm-expand-query-tool.ts +609 -200
- package/src/tools/lcm-expand-tool.ts +9 -4
- package/src/tools/lcm-grep-tool.ts +22 -8
- package/src/types.ts +1 -0
package/src/db/connection.ts
CHANGED
|
@@ -8,30 +8,49 @@ const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
|
8
8
|
const connectionsByPath = new Map<ConnectionKey, Set<DatabaseSync>>();
|
|
9
9
|
const connectionIndex = new Map<DatabaseSync, ConnectionKey>();
|
|
10
10
|
|
|
11
|
-
function isInMemoryPath(dbPath: string): boolean {
|
|
11
|
+
export function isInMemoryPath(dbPath: string): boolean {
|
|
12
12
|
const normalized = dbPath.trim();
|
|
13
13
|
return normalized === ":memory:" || normalized.startsWith("file::memory:");
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
function
|
|
17
|
-
|
|
16
|
+
export function getFileBackedDatabasePath(dbPath: string): string | null {
|
|
17
|
+
const trimmed = dbPath.trim();
|
|
18
|
+
if (!trimmed || isInMemoryPath(trimmed)) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
return resolve(trimmed);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizePath(dbPath: string): ConnectionKey {
|
|
25
|
+
const fileBackedDatabasePath = getFileBackedDatabasePath(dbPath);
|
|
26
|
+
if (!fileBackedDatabasePath) {
|
|
18
27
|
const trimmed = dbPath.trim();
|
|
19
28
|
return trimmed.length > 0 ? trimmed : ":memory:";
|
|
20
29
|
}
|
|
21
|
-
return
|
|
30
|
+
return fileBackedDatabasePath;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
function ensureDbDirectory(dbPath: string): void {
|
|
25
|
-
|
|
34
|
+
const fileBackedDatabasePath = getFileBackedDatabasePath(dbPath);
|
|
35
|
+
if (!fileBackedDatabasePath) {
|
|
26
36
|
return;
|
|
27
37
|
}
|
|
28
|
-
mkdirSync(dirname(
|
|
38
|
+
mkdirSync(dirname(fileBackedDatabasePath), { recursive: true });
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
function configureConnection(db: DatabaseSync): DatabaseSync {
|
|
32
42
|
db.exec("PRAGMA journal_mode = WAL");
|
|
33
43
|
db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
|
|
34
44
|
db.exec("PRAGMA foreign_keys = ON");
|
|
45
|
+
// 64MB page cache (default 2MB is severely undersized for multi-GB databases
|
|
46
|
+
// with concurrent agents). Memory is demand-allocated, released on close.
|
|
47
|
+
db.exec("PRAGMA cache_size = -65536");
|
|
48
|
+
// NORMAL is officially recommended for WAL mode — crash-safe for app crashes,
|
|
49
|
+
// only risks data loss on power failure (OS/kernel crash). The bootstrap
|
|
50
|
+
// process re-ingests any lost transactions from session files.
|
|
51
|
+
db.exec("PRAGMA synchronous = NORMAL");
|
|
52
|
+
// Keep temp tables/indexes in RAM (helps ordinal resequencing).
|
|
53
|
+
db.exec("PRAGMA temp_store = MEMORY");
|
|
35
54
|
return db;
|
|
36
55
|
}
|
|
37
56
|
|
|
@@ -66,6 +85,9 @@ function closeDatabase(db: DatabaseSync | undefined): void {
|
|
|
66
85
|
return;
|
|
67
86
|
}
|
|
68
87
|
try {
|
|
88
|
+
// Update query planner statistics for tables that changed since last optimize.
|
|
89
|
+
// Separate try so a SQLITE_BUSY/SQLITE_READONLY from optimize doesn't skip close.
|
|
90
|
+
try { db.exec("PRAGMA optimize"); } catch { /* best-effort */ }
|
|
69
91
|
db.close();
|
|
70
92
|
} catch {
|
|
71
93
|
// Ignore close failures; callers are shutting down anyway.
|
|
@@ -81,7 +103,13 @@ function closeDatabase(db: DatabaseSync | undefined): void {
|
|
|
81
103
|
*/
|
|
82
104
|
export function createLcmDatabaseConnection(dbPath: string): DatabaseSync {
|
|
83
105
|
ensureDbDirectory(dbPath);
|
|
84
|
-
const db =
|
|
106
|
+
const db = new DatabaseSync(dbPath);
|
|
107
|
+
try {
|
|
108
|
+
configureConnection(db);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
try { db.close(); } catch { /* ignore cleanup failure */ }
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
85
113
|
trackConnection(dbPath, db);
|
|
86
114
|
return db;
|
|
87
115
|
}
|
package/src/db/features.ts
CHANGED
|
@@ -2,19 +2,20 @@ import type { DatabaseSync } from "node:sqlite";
|
|
|
2
2
|
|
|
3
3
|
export type LcmDbFeatures = {
|
|
4
4
|
fts5Available: boolean;
|
|
5
|
+
trigramTokenizerAvailable: boolean;
|
|
5
6
|
};
|
|
6
7
|
|
|
7
8
|
const featureCache = new WeakMap<DatabaseSync, LcmDbFeatures>();
|
|
8
9
|
|
|
9
|
-
function
|
|
10
|
+
function probeVirtualTable(db: DatabaseSync, sql: string): boolean {
|
|
10
11
|
try {
|
|
11
|
-
db.exec("DROP TABLE IF EXISTS temp.
|
|
12
|
-
db.exec(
|
|
13
|
-
db.exec("DROP TABLE temp.
|
|
12
|
+
db.exec("DROP TABLE IF EXISTS temp.__lcm_virtual_table_probe");
|
|
13
|
+
db.exec(sql);
|
|
14
|
+
db.exec("DROP TABLE temp.__lcm_virtual_table_probe");
|
|
14
15
|
return true;
|
|
15
16
|
} catch {
|
|
16
17
|
try {
|
|
17
|
-
db.exec("DROP TABLE IF EXISTS temp.
|
|
18
|
+
db.exec("DROP TABLE IF EXISTS temp.__lcm_virtual_table_probe");
|
|
18
19
|
} catch {
|
|
19
20
|
// Ignore cleanup failures after a failed probe.
|
|
20
21
|
}
|
|
@@ -22,6 +23,20 @@ function probeFts5(db: DatabaseSync): boolean {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
function probeFts5(db: DatabaseSync): boolean {
|
|
27
|
+
return probeVirtualTable(
|
|
28
|
+
db,
|
|
29
|
+
"CREATE VIRTUAL TABLE temp.__lcm_virtual_table_probe USING fts5(content)",
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function probeTrigramTokenizer(db: DatabaseSync): boolean {
|
|
34
|
+
return probeVirtualTable(
|
|
35
|
+
db,
|
|
36
|
+
"CREATE VIRTUAL TABLE temp.__lcm_virtual_table_probe USING fts5(content, tokenize='trigram')",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
/**
|
|
26
41
|
* Detect SQLite features exposed by the current Node runtime.
|
|
27
42
|
*
|
|
@@ -36,7 +51,11 @@ export function getLcmDbFeatures(db: DatabaseSync): LcmDbFeatures {
|
|
|
36
51
|
|
|
37
52
|
const detected: LcmDbFeatures = {
|
|
38
53
|
fts5Available: probeFts5(db),
|
|
54
|
+
trigramTokenizerAvailable: false,
|
|
39
55
|
};
|
|
56
|
+
if (detected.fts5Available) {
|
|
57
|
+
detected.trigramTokenizerAvailable = probeTrigramTokenizer(db);
|
|
58
|
+
}
|
|
40
59
|
featureCache.set(db, detected);
|
|
41
60
|
return detected;
|
|
42
61
|
}
|
package/src/db/migration.ts
CHANGED
|
@@ -2,6 +2,10 @@ import type { DatabaseSync } from "node:sqlite";
|
|
|
2
2
|
import { getLcmDbFeatures } from "./features.js";
|
|
3
3
|
import { parseUtcTimestampOrNull } from "../store/parse-utc-timestamp.js";
|
|
4
4
|
|
|
5
|
+
type MigrationLogger = {
|
|
6
|
+
info?: (message: string) => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
5
9
|
type SummaryColumnInfo = {
|
|
6
10
|
name?: string;
|
|
7
11
|
};
|
|
@@ -27,6 +31,18 @@ type SummaryParentEdgeRow = {
|
|
|
27
31
|
parent_summary_id: string;
|
|
28
32
|
};
|
|
29
33
|
|
|
34
|
+
type TableNameRow = {
|
|
35
|
+
name?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type FtsTableSpec = {
|
|
39
|
+
tableName: string;
|
|
40
|
+
createSql: string;
|
|
41
|
+
seedSql: string;
|
|
42
|
+
expectedColumns: string[];
|
|
43
|
+
staleSchemaPatterns?: string[];
|
|
44
|
+
};
|
|
45
|
+
|
|
30
46
|
function ensureSummaryDepthColumn(db: DatabaseSync): void {
|
|
31
47
|
const summaryColumns = db.prepare(`PRAGMA table_info(summaries)`).all() as SummaryColumnInfo[];
|
|
32
48
|
const hasDepth = summaryColumns.some((col) => col.name === "depth");
|
|
@@ -78,6 +94,58 @@ function ensureSummaryModelColumn(db: DatabaseSync): void {
|
|
|
78
94
|
}
|
|
79
95
|
}
|
|
80
96
|
|
|
97
|
+
function ensureCompactionTelemetryColumns(db: DatabaseSync): void {
|
|
98
|
+
const telemetryColumns = db.prepare(`PRAGMA table_info(conversation_compaction_telemetry)`).all() as SummaryColumnInfo[];
|
|
99
|
+
const hasLastLeafCompactionAt = telemetryColumns.some((col) => col.name === "last_leaf_compaction_at");
|
|
100
|
+
const hasTurnsSinceLeafCompaction = telemetryColumns.some((col) => col.name === "turns_since_leaf_compaction");
|
|
101
|
+
const hasTokensAccumulatedSinceLeafCompaction = telemetryColumns.some(
|
|
102
|
+
(col) => col.name === "tokens_accumulated_since_leaf_compaction",
|
|
103
|
+
);
|
|
104
|
+
const hasLastActivityBand = telemetryColumns.some((col) => col.name === "last_activity_band");
|
|
105
|
+
|
|
106
|
+
if (!hasLastLeafCompactionAt) {
|
|
107
|
+
db.exec(`ALTER TABLE conversation_compaction_telemetry ADD COLUMN last_leaf_compaction_at TEXT`);
|
|
108
|
+
}
|
|
109
|
+
if (!hasTurnsSinceLeafCompaction) {
|
|
110
|
+
db.exec(
|
|
111
|
+
`ALTER TABLE conversation_compaction_telemetry ADD COLUMN turns_since_leaf_compaction INTEGER NOT NULL DEFAULT 0`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (!hasTokensAccumulatedSinceLeafCompaction) {
|
|
115
|
+
db.exec(
|
|
116
|
+
`ALTER TABLE conversation_compaction_telemetry ADD COLUMN tokens_accumulated_since_leaf_compaction INTEGER NOT NULL DEFAULT 0`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (!hasLastActivityBand) {
|
|
120
|
+
db.exec(
|
|
121
|
+
`ALTER TABLE conversation_compaction_telemetry ADD COLUMN last_activity_band TEXT NOT NULL DEFAULT 'low' CHECK (last_activity_band IN ('low', 'medium', 'high'))`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function describeMigrationError(error: unknown): string {
|
|
127
|
+
return error instanceof Error ? error.message : String(error);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function runMigrationStep(
|
|
131
|
+
name: string,
|
|
132
|
+
log: MigrationLogger | undefined,
|
|
133
|
+
step: () => void,
|
|
134
|
+
): void {
|
|
135
|
+
const startedAt = Date.now();
|
|
136
|
+
try {
|
|
137
|
+
step();
|
|
138
|
+
log?.info?.(
|
|
139
|
+
`[lcm] migration step complete: step=${name} durationMs=${Date.now() - startedAt}`,
|
|
140
|
+
);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
log?.info?.(
|
|
143
|
+
`[lcm] migration step failed: step=${name} durationMs=${Date.now() - startedAt} error=${describeMigrationError(error)}`,
|
|
144
|
+
);
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
81
149
|
function backfillSummaryDepths(db: DatabaseSync): void {
|
|
82
150
|
// Leaves are always depth 0, even if legacy rows had malformed values.
|
|
83
151
|
db.exec(`UPDATE summaries SET depth = 0 WHERE kind = 'leaf'`);
|
|
@@ -415,10 +483,89 @@ function backfillToolCallColumns(db: DatabaseSync): void {
|
|
|
415
483
|
);
|
|
416
484
|
}
|
|
417
485
|
|
|
486
|
+
function getExistingTableNames(db: DatabaseSync, names: string[]): Set<string> {
|
|
487
|
+
if (names.length === 0) {
|
|
488
|
+
return new Set();
|
|
489
|
+
}
|
|
490
|
+
const placeholders = names.map(() => "?").join(", ");
|
|
491
|
+
const rows = db
|
|
492
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${placeholders})`)
|
|
493
|
+
.all(...names) as TableNameRow[];
|
|
494
|
+
return new Set(
|
|
495
|
+
rows
|
|
496
|
+
.map((row) => row.name)
|
|
497
|
+
.filter((name): name is string => typeof name === "string" && name.length > 0),
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function getFtsShadowTableNames(tableName: string): string[] {
|
|
502
|
+
return [
|
|
503
|
+
`${tableName}_data`,
|
|
504
|
+
`${tableName}_idx`,
|
|
505
|
+
`${tableName}_content`,
|
|
506
|
+
`${tableName}_docsize`,
|
|
507
|
+
`${tableName}_config`,
|
|
508
|
+
];
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function quoteSqlIdentifier(identifier: string): string {
|
|
512
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
|
|
513
|
+
throw new Error(`Invalid SQL identifier: ${identifier}`);
|
|
514
|
+
}
|
|
515
|
+
return `"${identifier.replaceAll(`"`, `""`)}"`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function shouldRecreateStandaloneFtsTable(db: DatabaseSync, spec: FtsTableSpec): boolean {
|
|
519
|
+
const shadowTables = getFtsShadowTableNames(spec.tableName);
|
|
520
|
+
const existingTables = getExistingTableNames(db, [spec.tableName, ...shadowTables]);
|
|
521
|
+
if (!existingTables.has(spec.tableName)) {
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
524
|
+
if (shadowTables.some((name) => !existingTables.has(name))) {
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
const info = db
|
|
530
|
+
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name = ?")
|
|
531
|
+
.get(spec.tableName) as { sql?: string } | undefined;
|
|
532
|
+
const sql = info?.sql ?? "";
|
|
533
|
+
if (spec.staleSchemaPatterns?.some((pattern) => sql.includes(pattern))) {
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const columns = db
|
|
538
|
+
.prepare(`PRAGMA table_info(${quoteSqlIdentifier(spec.tableName)})`)
|
|
539
|
+
.all() as SummaryColumnInfo[];
|
|
540
|
+
const columnNames = new Set(
|
|
541
|
+
columns
|
|
542
|
+
.map((col) => col.name)
|
|
543
|
+
.filter((name): name is string => typeof name === "string" && name.length > 0),
|
|
544
|
+
);
|
|
545
|
+
return spec.expectedColumns.some((column) => !columnNames.has(column));
|
|
546
|
+
} catch {
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function ensureStandaloneFtsTable(db: DatabaseSync, spec: FtsTableSpec): void {
|
|
552
|
+
if (!shouldRecreateStandaloneFtsTable(db, spec)) {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
db.exec(`DROP TABLE IF EXISTS ${quoteSqlIdentifier(spec.tableName)}`);
|
|
557
|
+
for (const shadowTableName of getFtsShadowTableNames(spec.tableName)) {
|
|
558
|
+
db.exec(`DROP TABLE IF EXISTS ${quoteSqlIdentifier(shadowTableName)}`);
|
|
559
|
+
}
|
|
560
|
+
db.exec(spec.createSql);
|
|
561
|
+
db.exec(spec.seedSql);
|
|
562
|
+
}
|
|
563
|
+
|
|
418
564
|
export function runLcmMigrations(
|
|
419
565
|
db: DatabaseSync,
|
|
420
|
-
options?: { fts5Available?: boolean },
|
|
566
|
+
options?: { fts5Available?: boolean; log?: MigrationLogger },
|
|
421
567
|
): void {
|
|
568
|
+
const log = options?.log;
|
|
422
569
|
db.exec(`
|
|
423
570
|
CREATE TABLE IF NOT EXISTS conversations (
|
|
424
571
|
conversation_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -546,15 +693,39 @@ export function runLcmMigrations(
|
|
|
546
693
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
547
694
|
);
|
|
548
695
|
|
|
696
|
+
CREATE TABLE IF NOT EXISTS conversation_compaction_telemetry (
|
|
697
|
+
conversation_id INTEGER PRIMARY KEY REFERENCES conversations(conversation_id) ON DELETE CASCADE,
|
|
698
|
+
last_observed_cache_read INTEGER,
|
|
699
|
+
last_observed_cache_write INTEGER,
|
|
700
|
+
last_observed_cache_hit_at TEXT,
|
|
701
|
+
last_observed_cache_break_at TEXT,
|
|
702
|
+
cache_state TEXT NOT NULL DEFAULT 'unknown'
|
|
703
|
+
CHECK (cache_state IN ('hot', 'cold', 'unknown')),
|
|
704
|
+
retention TEXT,
|
|
705
|
+
last_leaf_compaction_at TEXT,
|
|
706
|
+
turns_since_leaf_compaction INTEGER NOT NULL DEFAULT 0,
|
|
707
|
+
tokens_accumulated_since_leaf_compaction INTEGER NOT NULL DEFAULT 0,
|
|
708
|
+
last_activity_band TEXT NOT NULL DEFAULT 'low'
|
|
709
|
+
CHECK (last_activity_band IN ('low', 'medium', 'high')),
|
|
710
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
711
|
+
);
|
|
712
|
+
|
|
549
713
|
-- Indexes
|
|
550
714
|
CREATE INDEX IF NOT EXISTS messages_conv_seq_idx ON messages (conversation_id, seq);
|
|
551
715
|
CREATE INDEX IF NOT EXISTS summaries_conv_created_idx ON summaries (conversation_id, created_at);
|
|
716
|
+
CREATE INDEX IF NOT EXISTS summary_messages_message_idx ON summary_messages (message_id);
|
|
717
|
+
CREATE INDEX IF NOT EXISTS summary_parents_parent_summary_idx ON summary_parents (parent_summary_id);
|
|
552
718
|
CREATE INDEX IF NOT EXISTS message_parts_message_idx ON message_parts (message_id);
|
|
553
719
|
CREATE INDEX IF NOT EXISTS message_parts_type_idx ON message_parts (part_type);
|
|
554
720
|
CREATE INDEX IF NOT EXISTS context_items_conv_idx ON context_items (conversation_id, ordinal);
|
|
555
721
|
CREATE INDEX IF NOT EXISTS large_files_conv_idx ON large_files (conversation_id, created_at);
|
|
556
722
|
CREATE INDEX IF NOT EXISTS bootstrap_state_path_idx
|
|
557
723
|
ON conversation_bootstrap_state (session_file_path, updated_at);
|
|
724
|
+
CREATE INDEX IF NOT EXISTS compaction_telemetry_state_idx
|
|
725
|
+
ON conversation_compaction_telemetry (cache_state, updated_at);
|
|
726
|
+
|
|
727
|
+
-- Speed up summary_messages lookups by message_id (PK is summary_id,message_id)
|
|
728
|
+
CREATE INDEX IF NOT EXISTS summary_messages_message_idx ON summary_messages (message_id);
|
|
558
729
|
`);
|
|
559
730
|
|
|
560
731
|
// Forward-compatible conversations migration for existing DBs.
|
|
@@ -592,75 +763,80 @@ export function runLcmMigrations(
|
|
|
592
763
|
ON conversations (session_key, active, created_at)
|
|
593
764
|
`);
|
|
594
765
|
db.exec(`DROP INDEX IF EXISTS conversations_session_key_idx`);
|
|
595
|
-
ensureSummaryDepthColumn(db);
|
|
596
|
-
ensureSummaryMetadataColumns(
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
766
|
+
runMigrationStep("ensureSummaryDepthColumn", log, () => ensureSummaryDepthColumn(db));
|
|
767
|
+
runMigrationStep("ensureSummaryMetadataColumns", log, () =>
|
|
768
|
+
ensureSummaryMetadataColumns(db),
|
|
769
|
+
);
|
|
770
|
+
runMigrationStep("ensureSummaryModelColumn", log, () => ensureSummaryModelColumn(db));
|
|
771
|
+
runMigrationStep("ensureCompactionTelemetryColumns", log, () =>
|
|
772
|
+
ensureCompactionTelemetryColumns(db),
|
|
773
|
+
);
|
|
774
|
+
runMigrationStep("backfillSummaryDepths", log, () => backfillSummaryDepths(db));
|
|
775
|
+
// Index on depth — created AFTER backfillSummaryDepths to avoid index
|
|
776
|
+
// maintenance overhead during bulk depth updates on large existing DBs.
|
|
777
|
+
runMigrationStep("createSummariesDepthIndex", log, () =>
|
|
778
|
+
db.exec(
|
|
779
|
+
`CREATE INDEX IF NOT EXISTS summaries_conv_depth_kind_idx ON summaries (conversation_id, depth, kind)`,
|
|
780
|
+
),
|
|
781
|
+
);
|
|
782
|
+
runMigrationStep("backfillSummaryMetadata", log, () => backfillSummaryMetadata(db));
|
|
783
|
+
runMigrationStep("backfillToolCallColumns", log, () => backfillToolCallColumns(db));
|
|
784
|
+
|
|
785
|
+
const detectedFeatures = options?.fts5Available === false ? null : getLcmDbFeatures(db);
|
|
786
|
+
const fts5Available = options?.fts5Available ?? detectedFeatures?.fts5Available ?? false;
|
|
603
787
|
if (!fts5Available) {
|
|
604
788
|
return;
|
|
605
789
|
}
|
|
606
790
|
|
|
791
|
+
const trigramTokenizerAvailable = detectedFeatures?.trigramTokenizerAvailable ?? false;
|
|
792
|
+
if (!trigramTokenizerAvailable) {
|
|
793
|
+
try {
|
|
794
|
+
db.exec(`DROP TABLE IF EXISTS summaries_fts_cjk`);
|
|
795
|
+
} catch {
|
|
796
|
+
// Best effort only. A stale virtual table should not block core migration.
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
607
800
|
// FTS5 virtual tables for full-text search (cannot use IF NOT EXISTS, so check manually)
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
if (hasFts) {
|
|
613
|
-
// Check for stale schema: external-content FTS tables with content_rowid cause errors.
|
|
614
|
-
// Drop and recreate as standalone FTS if the old schema is detected.
|
|
615
|
-
const ftsSchema = (
|
|
616
|
-
db
|
|
617
|
-
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'")
|
|
618
|
-
.get() as { sql: string } | undefined
|
|
619
|
-
)?.sql;
|
|
620
|
-
if (ftsSchema && ftsSchema.includes("content_rowid")) {
|
|
621
|
-
db.exec("DROP TABLE messages_fts");
|
|
622
|
-
db.exec(`
|
|
801
|
+
runMigrationStep("ensureMessagesFts", log, () => {
|
|
802
|
+
ensureStandaloneFtsTable(db, {
|
|
803
|
+
tableName: "messages_fts",
|
|
804
|
+
createSql: `
|
|
623
805
|
CREATE VIRTUAL TABLE messages_fts USING fts5(
|
|
624
806
|
content,
|
|
625
807
|
tokenize='porter unicode61'
|
|
626
|
-
)
|
|
627
|
-
|
|
628
|
-
`
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
tokenize='porter unicode61'
|
|
659
|
-
);
|
|
660
|
-
INSERT INTO summaries_fts(summary_id, content)
|
|
661
|
-
SELECT summary_id, content FROM summaries;
|
|
662
|
-
`);
|
|
663
|
-
}
|
|
808
|
+
)
|
|
809
|
+
`,
|
|
810
|
+
seedSql: `
|
|
811
|
+
INSERT INTO messages_fts(rowid, content)
|
|
812
|
+
SELECT message_id, content FROM messages
|
|
813
|
+
`,
|
|
814
|
+
expectedColumns: ["content"],
|
|
815
|
+
staleSchemaPatterns: ["content_rowid"],
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
runMigrationStep("ensureSummariesFts", log, () => {
|
|
820
|
+
ensureStandaloneFtsTable(db, {
|
|
821
|
+
tableName: "summaries_fts",
|
|
822
|
+
createSql: `
|
|
823
|
+
CREATE VIRTUAL TABLE summaries_fts USING fts5(
|
|
824
|
+
summary_id UNINDEXED,
|
|
825
|
+
content,
|
|
826
|
+
tokenize='porter unicode61'
|
|
827
|
+
)
|
|
828
|
+
`,
|
|
829
|
+
seedSql: `
|
|
830
|
+
INSERT INTO summaries_fts(summary_id, content)
|
|
831
|
+
SELECT summary_id, content FROM summaries
|
|
832
|
+
`,
|
|
833
|
+
expectedColumns: ["summary_id", "content"],
|
|
834
|
+
staleSchemaPatterns: [
|
|
835
|
+
"content_rowid='summary_id'",
|
|
836
|
+
'content_rowid="summary_id"',
|
|
837
|
+
],
|
|
838
|
+
});
|
|
839
|
+
});
|
|
664
840
|
|
|
665
841
|
// ── CJK trigram FTS table ────────────────────────────────────────────────
|
|
666
842
|
// FTS5 unicode61 (porter) tokenizer cannot segment CJK ideographs, so CJK
|
|
@@ -670,20 +846,23 @@ export function runLcmMigrations(
|
|
|
670
846
|
//
|
|
671
847
|
// A trigram-tokenized table indexes every 3-character substring, enabling
|
|
672
848
|
// native CJK substring matching via FTS5 MATCH with OR semantics.
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
849
|
+
runMigrationStep("ensureSummariesFtsCjk", log, () => {
|
|
850
|
+
if (trigramTokenizerAvailable) {
|
|
851
|
+
ensureStandaloneFtsTable(db, {
|
|
852
|
+
tableName: "summaries_fts_cjk",
|
|
853
|
+
createSql: `
|
|
854
|
+
CREATE VIRTUAL TABLE summaries_fts_cjk USING fts5(
|
|
855
|
+
summary_id UNINDEXED,
|
|
856
|
+
content,
|
|
857
|
+
tokenize='trigram'
|
|
858
|
+
)
|
|
859
|
+
`,
|
|
860
|
+
seedSql: `
|
|
861
|
+
INSERT INTO summaries_fts_cjk(summary_id, content)
|
|
862
|
+
SELECT summary_id, content FROM summaries
|
|
863
|
+
`,
|
|
864
|
+
expectedColumns: ["summary_id", "content"],
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
});
|
|
689
868
|
}
|