@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.
Files changed (38) hide show
  1. package/README.md +26 -6
  2. package/docs/agent-tools.md +16 -5
  3. package/docs/configuration.md +223 -214
  4. package/openclaw.plugin.json +123 -0
  5. package/package.json +1 -1
  6. package/skills/lossless-claw/SKILL.md +3 -2
  7. package/skills/lossless-claw/references/architecture.md +12 -0
  8. package/skills/lossless-claw/references/config.md +135 -3
  9. package/skills/lossless-claw/references/diagnostics.md +13 -0
  10. package/src/assembler.ts +17 -5
  11. package/src/compaction.ts +161 -53
  12. package/src/db/config.ts +102 -4
  13. package/src/db/connection.ts +35 -7
  14. package/src/db/features.ts +24 -5
  15. package/src/db/migration.ts +257 -78
  16. package/src/engine.ts +1007 -110
  17. package/src/estimate-tokens.ts +80 -0
  18. package/src/lcm-log.ts +37 -0
  19. package/src/plugin/index.ts +493 -101
  20. package/src/plugin/lcm-command.ts +288 -7
  21. package/src/plugin/lcm-doctor-apply.ts +1 -3
  22. package/src/plugin/lcm-doctor-cleaners.ts +655 -0
  23. package/src/plugin/shared-init.ts +59 -0
  24. package/src/prune.ts +391 -0
  25. package/src/retrieval.ts +8 -9
  26. package/src/startup-banner-log.ts +1 -0
  27. package/src/store/compaction-telemetry-store.ts +156 -0
  28. package/src/store/conversation-store.ts +6 -1
  29. package/src/store/fts5-sanitize.ts +25 -4
  30. package/src/store/full-text-sort.ts +21 -0
  31. package/src/store/index.ts +8 -0
  32. package/src/store/summary-store.ts +21 -14
  33. package/src/summarize.ts +55 -34
  34. package/src/tools/lcm-describe-tool.ts +9 -4
  35. package/src/tools/lcm-expand-query-tool.ts +609 -200
  36. package/src/tools/lcm-expand-tool.ts +9 -4
  37. package/src/tools/lcm-grep-tool.ts +22 -8
  38. package/src/types.ts +1 -0
@@ -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 normalizePath(dbPath: string): ConnectionKey {
17
- if (isInMemoryPath(dbPath)) {
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 resolve(dbPath);
30
+ return fileBackedDatabasePath;
22
31
  }
23
32
 
24
33
  function ensureDbDirectory(dbPath: string): void {
25
- if (isInMemoryPath(dbPath)) {
34
+ const fileBackedDatabasePath = getFileBackedDatabasePath(dbPath);
35
+ if (!fileBackedDatabasePath) {
26
36
  return;
27
37
  }
28
- mkdirSync(dirname(dbPath), { recursive: true });
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 = configureConnection(new DatabaseSync(dbPath));
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
  }
@@ -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 probeFts5(db: DatabaseSync): boolean {
10
+ function probeVirtualTable(db: DatabaseSync, sql: string): boolean {
10
11
  try {
11
- db.exec("DROP TABLE IF EXISTS temp.__lcm_fts5_probe");
12
- db.exec("CREATE VIRTUAL TABLE temp.__lcm_fts5_probe USING fts5(content)");
13
- db.exec("DROP TABLE temp.__lcm_fts5_probe");
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.__lcm_fts5_probe");
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
  }
@@ -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(db);
597
- ensureSummaryModelColumn(db);
598
- backfillSummaryDepths(db);
599
- backfillSummaryMetadata(db);
600
- backfillToolCallColumns(db);
601
-
602
- const fts5Available = options?.fts5Available ?? getLcmDbFeatures(db).fts5Available;
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
- const hasFts = db
609
- .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='messages_fts'")
610
- .get();
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
- INSERT INTO messages_fts(rowid, content) SELECT message_id, content FROM messages;
628
- `);
629
- }
630
- } else {
631
- db.exec(`
632
- CREATE VIRTUAL TABLE messages_fts USING fts5(
633
- content,
634
- tokenize='porter unicode61'
635
- );
636
- `);
637
- }
638
-
639
- const summariesFtsInfo = db
640
- .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='summaries_fts'")
641
- .get() as { sql?: string } | undefined;
642
- const summariesFtsSql = summariesFtsInfo?.sql ?? "";
643
- const summariesFtsColumns = db.prepare(`PRAGMA table_info(summaries_fts)`).all() as Array<{
644
- name?: string;
645
- }>;
646
- const hasSummaryIdColumn = summariesFtsColumns.some((col) => col.name === "summary_id");
647
- const shouldRecreateSummariesFts =
648
- !summariesFtsInfo ||
649
- !hasSummaryIdColumn ||
650
- summariesFtsSql.includes("content_rowid='summary_id'") ||
651
- summariesFtsSql.includes('content_rowid="summary_id"');
652
- if (shouldRecreateSummariesFts) {
653
- db.exec(`
654
- DROP TABLE IF EXISTS summaries_fts;
655
- CREATE VIRTUAL TABLE summaries_fts USING fts5(
656
- summary_id UNINDEXED,
657
- content,
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
- const cjkTableExists = db
674
- .prepare(
675
- "SELECT 1 FROM sqlite_master WHERE type='table' AND name='summaries_fts_cjk'",
676
- )
677
- .get();
678
- if (!cjkTableExists) {
679
- db.exec(`
680
- CREATE VIRTUAL TABLE summaries_fts_cjk USING fts5(
681
- summary_id UNINDEXED,
682
- content,
683
- tokenize='trigram'
684
- );
685
- INSERT INTO summaries_fts_cjk(summary_id, content)
686
- SELECT summary_id, content FROM summaries;
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
  }