pi-mega-compact 0.7.3 → 0.7.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/dist/extensions/mega-compact.js +2 -0
- package/dist/extensions/mega-config.js +1 -0
- package/dist/extensions/mega-db-cmds.js +85 -0
- package/dist/extensions/mega-events.js +129 -4
- package/dist/extensions/mega-events.test.js +47 -0
- package/dist/extensions/mega-runtime.js +3 -0
- package/dist/src/mirror/dedup.js +44 -0
- package/dist/src/mirror/epoch.js +36 -0
- package/dist/src/mirror/mirror.test.js +185 -0
- package/dist/src/recall.js +37 -0
- package/dist/src/store/sqlite.dbmaint.test.js +158 -0
- package/dist/src/store/sqlite.dbmirror.test.js +175 -0
- package/dist/src/store/sqlite.js +474 -2
- package/extensions/mega-compact.ts +2 -0
- package/extensions/mega-config.ts +8 -0
- package/extensions/mega-db-cmds.ts +107 -0
- package/extensions/mega-events.test.ts +55 -0
- package/extensions/mega-events.ts +136 -4
- package/extensions/mega-runtime.ts +4 -0
- package/package.json +1 -1
- package/src/mirror/dedup.ts +57 -0
- package/src/mirror/epoch.ts +37 -0
- package/src/mirror/mirror.test.ts +240 -0
- package/src/recall.ts +38 -0
- package/src/store/sqlite.dbmaint.test.ts +193 -0
- package/src/store/sqlite.dbmirror.test.ts +219 -0
- package/src/store/sqlite.ts +669 -2
package/src/store/sqlite.ts
CHANGED
|
@@ -18,14 +18,14 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { DatabaseSync } from "node:sqlite";
|
|
21
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
21
|
+
import { existsSync, mkdirSync, statSync } from "node:fs";
|
|
22
22
|
import { homedir, tmpdir } from "node:os";
|
|
23
23
|
import { join } from "node:path";
|
|
24
24
|
import { getStateDir } from "../store.js";
|
|
25
25
|
import type { StoredCheckpoint, SessionState } from "../store.js";
|
|
26
26
|
import { normalizeSessionId } from "../store.js";
|
|
27
27
|
|
|
28
|
-
const SCHEMA_VERSION =
|
|
28
|
+
const SCHEMA_VERSION = 2;
|
|
29
29
|
|
|
30
30
|
/** Encode a float vector as a little-endian Float32 BLOB for cosine scanning. */
|
|
31
31
|
function encodeEmbedding(v: number[]): Buffer {
|
|
@@ -543,6 +543,54 @@ function initSchema(db: DatabaseSync): void {
|
|
|
543
543
|
normalized_text,
|
|
544
544
|
tokenize='trigram'
|
|
545
545
|
);
|
|
546
|
+
|
|
547
|
+
-- S27: durable raw-transcript mirror (MEGACOMPACT_DB_MIRROR). Appended
|
|
548
|
+
-- RAW message bytes per session so a compacted window can be rehydrated
|
|
549
|
+
-- from the local store instead of the pi runtime transcript (which is
|
|
550
|
+
-- trimmed). PK is (content_hash, session_id) — NOT content_hash alone —
|
|
551
|
+
-- so identical content in different sessions never collides. Additive:
|
|
552
|
+
-- CREATE TABLE IF NOT EXISTS leaves existing DBs untouched on open until
|
|
553
|
+
-- the S27 mirror flag is flipped on. All queries parameterized (PREVENT-002).
|
|
554
|
+
CREATE TABLE IF NOT EXISTS raw_transcript (
|
|
555
|
+
content_hash TEXT NOT NULL,
|
|
556
|
+
session_id TEXT NOT NULL,
|
|
557
|
+
seq INTEGER NOT NULL,
|
|
558
|
+
role TEXT NOT NULL,
|
|
559
|
+
content_bytes TEXT NOT NULL,
|
|
560
|
+
tool_name TEXT,
|
|
561
|
+
message_timestamp INTEGER, -- ORIGINAL msg ts at append, NOT served
|
|
562
|
+
checkpoint_epoch TEXT NOT NULL,
|
|
563
|
+
PRIMARY KEY (content_hash, session_id)
|
|
564
|
+
);
|
|
565
|
+
CREATE INDEX IF NOT EXISTS idx_rt_session_seq ON raw_transcript(session_id, seq);
|
|
566
|
+
CREATE INDEX IF NOT EXISTS idx_rt_epoch ON raw_transcript(checkpoint_epoch);
|
|
567
|
+
|
|
568
|
+
-- S27: checkpoint-epoch registry. One row per compaction epoch; the
|
|
569
|
+
-- summary_message_text is the verbatim system message that replaced the
|
|
570
|
+
-- trimmed prefix. Informational bookkeeping (the raw_transcript rows are
|
|
571
|
+
-- authoritative); refresh-safe via ON CONFLICT(epoch_id) DO UPDATE.
|
|
572
|
+
CREATE TABLE IF NOT EXISTS checkpoint_epochs (
|
|
573
|
+
epoch_id TEXT PRIMARY KEY,
|
|
574
|
+
session_id TEXT NOT NULL,
|
|
575
|
+
started_seq INTEGER NOT NULL,
|
|
576
|
+
committed_seq INTEGER NOT NULL,
|
|
577
|
+
summary_message_text TEXT NOT NULL,
|
|
578
|
+
cut_index INTEGER NOT NULL,
|
|
579
|
+
checkpoint_id TEXT NOT NULL,
|
|
580
|
+
created_at INTEGER NOT NULL
|
|
581
|
+
);
|
|
582
|
+
CREATE INDEX IF NOT EXISTS idx_epoch_session ON checkpoint_epochs(session_id, created_at DESC);
|
|
583
|
+
|
|
584
|
+
-- S27 Task 6: dedup_mirror for space-efficient deduplicated storage.
|
|
585
|
+
-- Each unique content_hash stores its bytes ONCE; raw_transcript rows
|
|
586
|
+
-- reference this table via content_ref instead of storing duplicate content_bytes inline.
|
|
587
|
+
CREATE TABLE IF NOT EXISTS dedup_mirror (
|
|
588
|
+
content_hash TEXT PRIMARY KEY,
|
|
589
|
+
content_bytes TEXT NOT NULL,
|
|
590
|
+
ref_count INTEGER NOT NULL DEFAULT 1,
|
|
591
|
+
first_seen_seq INTEGER NOT NULL,
|
|
592
|
+
created_at INTEGER NOT NULL
|
|
593
|
+
);
|
|
546
594
|
`);
|
|
547
595
|
// Idempotent column migrations. `CREATE TABLE IF NOT EXISTS` is a no-op on a
|
|
548
596
|
// pre-existing table, so new columns added to context_chunks after a store was
|
|
@@ -550,6 +598,8 @@ function initSchema(db: DatabaseSync): void {
|
|
|
550
598
|
// databases created by an older version — otherwise repoStats()/upsert crash
|
|
551
599
|
// with "no such column" and the extension fails to load. Additive only.
|
|
552
600
|
ensureColumn(db, "context_chunks", "original_token_estimate", "INTEGER");
|
|
601
|
+
// S27 Task 6: content_ref column in raw_transcript for dedup_mirror references.
|
|
602
|
+
ensureColumn(db, "raw_transcript", "content_ref", "TEXT");
|
|
553
603
|
// S20 memory-RAG extension: additive columns for auto-review ops. Idempotent —
|
|
554
604
|
// only alters DBs created by an older version that lack these columns.
|
|
555
605
|
ensureColumn(db, "memories", "category", "TEXT");
|
|
@@ -1509,3 +1559,620 @@ export function clearRaptorNodes(sessionId: string, stateDir: string = getStateD
|
|
|
1509
1559
|
const db = openStore(stateDir);
|
|
1510
1560
|
db.prepare("DELETE FROM raptor_nodes WHERE session_id = ?").run(normalizeSessionId(sessionId));
|
|
1511
1561
|
}
|
|
1562
|
+
|
|
1563
|
+
// --- S27: durable raw-transcript mirror + checkpoint-epoch registry ---------
|
|
1564
|
+
// ADDITIVE + flag-default-OFF (MEGACOMPACT_DB_MIRROR). No behavior change
|
|
1565
|
+
// until the flag is flipped on; the tables above are created IF NOT EXISTS on
|
|
1566
|
+
// open so existing stores are untouched. These helpers are storage primitives
|
|
1567
|
+
// only — the runtime hook (Task 5) wires them into the compaction flow. All
|
|
1568
|
+
// queries use @-parameterized placeholders (PREVENT-002); no `any` types
|
|
1569
|
+
// (PREVENT-011).
|
|
1570
|
+
|
|
1571
|
+
/** One appended raw-message row in the durable mirror. */
|
|
1572
|
+
export interface RawTranscriptRow {
|
|
1573
|
+
contentHash: string;
|
|
1574
|
+
sessionId: string;
|
|
1575
|
+
seq: number;
|
|
1576
|
+
role: string;
|
|
1577
|
+
contentBytes: string;
|
|
1578
|
+
toolName: string | null;
|
|
1579
|
+
/** ORIGINAL message timestamp captured at append time (NOT a served ts). */
|
|
1580
|
+
messageTimestamp: number | null;
|
|
1581
|
+
checkpointEpoch: string;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/** One checkpoint-epoch bookkeeping row (informational registry). */
|
|
1585
|
+
export interface CheckpointEpoch {
|
|
1586
|
+
epochId: string;
|
|
1587
|
+
sessionId: string;
|
|
1588
|
+
startedSeq: number;
|
|
1589
|
+
committedSeq: number;
|
|
1590
|
+
summaryMessageText: string;
|
|
1591
|
+
cutIndex: number;
|
|
1592
|
+
checkpointId: string;
|
|
1593
|
+
createdAt: number;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
/** DB row shape for raw_transcript (snake_case column names). */
|
|
1597
|
+
interface RawTranscriptDBRow {
|
|
1598
|
+
content_hash: string;
|
|
1599
|
+
session_id: string;
|
|
1600
|
+
seq: number;
|
|
1601
|
+
role: string;
|
|
1602
|
+
content_bytes: string;
|
|
1603
|
+
tool_name: string | null;
|
|
1604
|
+
message_timestamp: number | null;
|
|
1605
|
+
checkpoint_epoch: string;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/** DB row shape for checkpoint_epochs (snake_case column names). */
|
|
1609
|
+
interface CheckpointEpochDBRow {
|
|
1610
|
+
epoch_id: string;
|
|
1611
|
+
session_id: string;
|
|
1612
|
+
started_seq: number;
|
|
1613
|
+
committed_seq: number;
|
|
1614
|
+
summary_message_text: string;
|
|
1615
|
+
cut_index: number;
|
|
1616
|
+
checkpoint_id: string;
|
|
1617
|
+
created_at: number;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
function rowToRawTranscript(row: RawTranscriptDBRow): RawTranscriptRow {
|
|
1621
|
+
return {
|
|
1622
|
+
contentHash: row.content_hash,
|
|
1623
|
+
sessionId: row.session_id,
|
|
1624
|
+
seq: Number(row.seq),
|
|
1625
|
+
role: row.role,
|
|
1626
|
+
contentBytes: row.content_bytes,
|
|
1627
|
+
toolName: row.tool_name ?? null,
|
|
1628
|
+
messageTimestamp:
|
|
1629
|
+
row.message_timestamp == null ? null : Number(row.message_timestamp),
|
|
1630
|
+
checkpointEpoch: row.checkpoint_epoch,
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
function rowToCheckpointEpoch(row: CheckpointEpochDBRow): CheckpointEpoch {
|
|
1635
|
+
return {
|
|
1636
|
+
epochId: row.epoch_id,
|
|
1637
|
+
sessionId: row.session_id,
|
|
1638
|
+
startedSeq: Number(row.started_seq),
|
|
1639
|
+
committedSeq: Number(row.committed_seq),
|
|
1640
|
+
summaryMessageText: row.summary_message_text,
|
|
1641
|
+
cutIndex: Number(row.cut_index),
|
|
1642
|
+
checkpointId: row.checkpoint_id,
|
|
1643
|
+
createdAt: Number(row.created_at),
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
/**
|
|
1648
|
+
* Append one raw-message row to the durable mirror. Idempotent by
|
|
1649
|
+
* (content_hash, session_id) via INSERT OR IGNORE — re-appending the same
|
|
1650
|
+
* content for the same session is a no-op. seq is assigned server-side as
|
|
1651
|
+
* COALESCE(MAX(seq),0)+1 within the session, so callers never need to compute
|
|
1652
|
+
* it. Pass an open store handle (openStore) — matches the other DatabaseSync
|
|
1653
|
+
* helpers. Parameterized (PREVENT-002).
|
|
1654
|
+
*/
|
|
1655
|
+
export function appendRawTranscript(db: DatabaseSync, row: RawTranscriptRow): void {
|
|
1656
|
+
withTx(db, () => {
|
|
1657
|
+
db.prepare(
|
|
1658
|
+
`INSERT OR IGNORE INTO raw_transcript
|
|
1659
|
+
(content_hash, session_id, seq, role, content_bytes, tool_name, message_timestamp, checkpoint_epoch)
|
|
1660
|
+
VALUES (
|
|
1661
|
+
@content_hash, @session_id,
|
|
1662
|
+
COALESCE((SELECT MAX(seq) FROM raw_transcript WHERE session_id = @session_id), 0) + 1,
|
|
1663
|
+
@role, @content_bytes, @tool_name, @message_timestamp, @checkpoint_epoch
|
|
1664
|
+
)`,
|
|
1665
|
+
).run({
|
|
1666
|
+
"@content_hash": row.contentHash,
|
|
1667
|
+
"@session_id": row.sessionId,
|
|
1668
|
+
"@role": row.role,
|
|
1669
|
+
"@content_bytes": row.contentBytes,
|
|
1670
|
+
"@tool_name": row.toolName,
|
|
1671
|
+
"@message_timestamp": row.messageTimestamp,
|
|
1672
|
+
"@checkpoint_epoch": row.checkpointEpoch,
|
|
1673
|
+
});
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
/**
|
|
1678
|
+
* List raw-transcript rows for a session in [fromSeq, toSeq], ordered by seq
|
|
1679
|
+
* ascending. Returns camel-cased RawTranscriptRow[]. Parameterized.
|
|
1680
|
+
*/
|
|
1681
|
+
export function listRawTranscriptRange(
|
|
1682
|
+
db: DatabaseSync,
|
|
1683
|
+
sessionId: string,
|
|
1684
|
+
fromSeq: number,
|
|
1685
|
+
toSeq: number,
|
|
1686
|
+
): RawTranscriptRow[] {
|
|
1687
|
+
const rows = db
|
|
1688
|
+
.prepare(
|
|
1689
|
+
`SELECT content_hash, session_id, seq, role, content_bytes, tool_name, message_timestamp, checkpoint_epoch
|
|
1690
|
+
FROM raw_transcript
|
|
1691
|
+
WHERE session_id = @session_id AND seq >= @from_seq AND seq <= @to_seq
|
|
1692
|
+
ORDER BY seq ASC`,
|
|
1693
|
+
)
|
|
1694
|
+
.all({
|
|
1695
|
+
"@session_id": sessionId,
|
|
1696
|
+
"@from_seq": fromSeq,
|
|
1697
|
+
"@to_seq": toSeq,
|
|
1698
|
+
}) as unknown as RawTranscriptDBRow[];
|
|
1699
|
+
return rows.map(rowToRawTranscript);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* Insert (or refresh) a checkpoint-epoch row. ON CONFLICT(epoch_id) DO UPDATE
|
|
1704
|
+
* so re-running the same compaction epoch is idempotent / refresh-safe.
|
|
1705
|
+
* Parameterized (PREVENT-002).
|
|
1706
|
+
*/
|
|
1707
|
+
export function writeCheckpointEpoch(db: DatabaseSync, epoch: CheckpointEpoch): void {
|
|
1708
|
+
withTx(db, () => {
|
|
1709
|
+
db.prepare(
|
|
1710
|
+
`INSERT INTO checkpoint_epochs
|
|
1711
|
+
(epoch_id, session_id, started_seq, committed_seq, summary_message_text, cut_index, checkpoint_id, created_at)
|
|
1712
|
+
VALUES (@epoch_id, @session_id, @started_seq, @committed_seq, @summary_message_text, @cut_index, @checkpoint_id, @created_at)
|
|
1713
|
+
ON CONFLICT(epoch_id) DO UPDATE SET
|
|
1714
|
+
session_id = excluded.session_id,
|
|
1715
|
+
started_seq = excluded.started_seq,
|
|
1716
|
+
committed_seq = excluded.committed_seq,
|
|
1717
|
+
summary_message_text = excluded.summary_message_text,
|
|
1718
|
+
cut_index = excluded.cut_index,
|
|
1719
|
+
checkpoint_id = excluded.checkpoint_id,
|
|
1720
|
+
created_at = excluded.created_at`,
|
|
1721
|
+
).run({
|
|
1722
|
+
"@epoch_id": epoch.epochId,
|
|
1723
|
+
"@session_id": epoch.sessionId,
|
|
1724
|
+
"@started_seq": epoch.startedSeq,
|
|
1725
|
+
"@committed_seq": epoch.committedSeq,
|
|
1726
|
+
"@summary_message_text": epoch.summaryMessageText,
|
|
1727
|
+
"@cut_index": epoch.cutIndex,
|
|
1728
|
+
"@checkpoint_id": epoch.checkpointId,
|
|
1729
|
+
"@created_at": epoch.createdAt,
|
|
1730
|
+
});
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
/** Read one checkpoint-epoch row by id (or null if absent). Parameterized. */
|
|
1735
|
+
export function readCheckpointEpoch(db: DatabaseSync, epochId: string): CheckpointEpoch | null {
|
|
1736
|
+
const row = db
|
|
1737
|
+
.prepare(
|
|
1738
|
+
`SELECT epoch_id, session_id, started_seq, committed_seq, summary_message_text, cut_index, checkpoint_id, created_at
|
|
1739
|
+
FROM checkpoint_epochs WHERE epoch_id = @epoch_id`,
|
|
1740
|
+
)
|
|
1741
|
+
.get({ "@epoch_id": epochId }) as unknown as CheckpointEpochDBRow | undefined;
|
|
1742
|
+
return row ? rowToCheckpointEpoch(row) : null;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
/**
|
|
1746
|
+
* Latest checkpoint-epoch row for a session (highest created_at), or null if
|
|
1747
|
+
* none. Parameterized (PREVENT-002).
|
|
1748
|
+
*/
|
|
1749
|
+
export function getActiveEpochForSession(db: DatabaseSync, sessionId: string): CheckpointEpoch | null {
|
|
1750
|
+
const row = db
|
|
1751
|
+
.prepare(
|
|
1752
|
+
`SELECT epoch_id, session_id, started_seq, committed_seq, summary_message_text, cut_index, checkpoint_id, created_at
|
|
1753
|
+
FROM checkpoint_epochs
|
|
1754
|
+
WHERE session_id = @session_id
|
|
1755
|
+
ORDER BY created_at DESC
|
|
1756
|
+
LIMIT 1`,
|
|
1757
|
+
)
|
|
1758
|
+
.get({ "@session_id": sessionId }) as unknown as CheckpointEpochDBRow | undefined;
|
|
1759
|
+
return row ? rowToCheckpointEpoch(row) : null;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/** List all checkpoint epochs (diagnostic / test helper). */
|
|
1763
|
+
export function listCheckpointEpochs(db: DatabaseSync): CheckpointEpoch[] {
|
|
1764
|
+
const rows = db
|
|
1765
|
+
.prepare(
|
|
1766
|
+
`SELECT epoch_id, session_id, started_seq, committed_seq, summary_message_text, cut_index, checkpoint_id, created_at
|
|
1767
|
+
FROM checkpoint_epochs
|
|
1768
|
+
ORDER BY created_at DESC`,
|
|
1769
|
+
)
|
|
1770
|
+
.all() as unknown as CheckpointEpochDBRow[];
|
|
1771
|
+
return rows.map(rowToCheckpointEpoch);
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
/** Count raw transcript rows (diagnostic / test helper). */
|
|
1775
|
+
export function countRawTranscript(db: DatabaseSync): number {
|
|
1776
|
+
const row = db.prepare(`SELECT COUNT(*) AS cnt FROM raw_transcript`).get() as { cnt: number };
|
|
1777
|
+
return row.cnt;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
1781
|
+
// S27 Task 6: dedup_mirror functions
|
|
1782
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
1783
|
+
|
|
1784
|
+
/**
|
|
1785
|
+
* Dedup mirror row (DB representation).
|
|
1786
|
+
*/
|
|
1787
|
+
export interface DedupMirrorRowDB {
|
|
1788
|
+
content_hash: string;
|
|
1789
|
+
content_bytes: string;
|
|
1790
|
+
ref_count: number;
|
|
1791
|
+
first_seen_seq: number;
|
|
1792
|
+
created_at: number;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/**
|
|
1796
|
+
* Upsert a row into dedup_mirror. If the hash already exists, increment ref_count.
|
|
1797
|
+
* Returns true if this was a NEW unique content (first insert), false if it was a duplicate.
|
|
1798
|
+
*/
|
|
1799
|
+
export function upsertDedupMirror(
|
|
1800
|
+
db: DatabaseSync,
|
|
1801
|
+
contentHash: string,
|
|
1802
|
+
contentBytes: string,
|
|
1803
|
+
seq: number,
|
|
1804
|
+
): boolean {
|
|
1805
|
+
const now = Date.now();
|
|
1806
|
+
const existing = db
|
|
1807
|
+
.prepare(`SELECT content_hash FROM dedup_mirror WHERE content_hash = @hash`)
|
|
1808
|
+
.get({ "@hash": contentHash }) as { content_hash: string } | undefined;
|
|
1809
|
+
if (existing) {
|
|
1810
|
+
db.prepare(`UPDATE dedup_mirror SET ref_count = ref_count + 1 WHERE content_hash = @hash`).run({
|
|
1811
|
+
"@hash": contentHash,
|
|
1812
|
+
});
|
|
1813
|
+
return false;
|
|
1814
|
+
}
|
|
1815
|
+
db.prepare(
|
|
1816
|
+
`INSERT INTO dedup_mirror (content_hash, content_bytes, ref_count, first_seen_seq, created_at)
|
|
1817
|
+
VALUES (@hash, @bytes, 1, @seq, @now)`,
|
|
1818
|
+
).run({
|
|
1819
|
+
"@hash": contentHash,
|
|
1820
|
+
"@bytes": contentBytes,
|
|
1821
|
+
"@seq": seq,
|
|
1822
|
+
"@now": now,
|
|
1823
|
+
});
|
|
1824
|
+
return true;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/**
|
|
1828
|
+
* Get dedup ratio for a session: total bytes vs unique bytes.
|
|
1829
|
+
*/
|
|
1830
|
+
export function getDedupRatio(
|
|
1831
|
+
db: DatabaseSync,
|
|
1832
|
+
sessionId: string,
|
|
1833
|
+
): { totalBytes: number; uniqueBytes: number; ratio: number } {
|
|
1834
|
+
const totalRow = db
|
|
1835
|
+
.prepare(
|
|
1836
|
+
`SELECT COALESCE(SUM(LENGTH(content_bytes)), 0) AS total
|
|
1837
|
+
FROM raw_transcript
|
|
1838
|
+
WHERE session_id = @session_id`,
|
|
1839
|
+
)
|
|
1840
|
+
.get({ "@session_id": sessionId }) as { total: number };
|
|
1841
|
+
const uniqueRow = db
|
|
1842
|
+
.prepare(
|
|
1843
|
+
`SELECT COALESCE(SUM(LENGTH(content_bytes)), 0) AS unique_bytes
|
|
1844
|
+
FROM dedup_mirror`,
|
|
1845
|
+
)
|
|
1846
|
+
.get() as { unique_bytes: number };
|
|
1847
|
+
const totalBytes = totalRow.total;
|
|
1848
|
+
const uniqueBytes = uniqueRow.unique_bytes;
|
|
1849
|
+
const ratio = uniqueBytes > 0 ? totalBytes / uniqueBytes : 1;
|
|
1850
|
+
return { totalBytes, uniqueBytes, ratio };
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
/**
|
|
1854
|
+
* Get dedup mirror stats (diagnostic / test helper).
|
|
1855
|
+
*/
|
|
1856
|
+
export function getDedupMirrorStats(db: DatabaseSync): {
|
|
1857
|
+
rowCount: number;
|
|
1858
|
+
totalBytes: number;
|
|
1859
|
+
avgRefCount: number;
|
|
1860
|
+
} {
|
|
1861
|
+
const row = db
|
|
1862
|
+
.prepare(
|
|
1863
|
+
`SELECT COUNT(*) AS cnt,
|
|
1864
|
+
COALESCE(SUM(LENGTH(content_bytes)), 0) AS total_bytes,
|
|
1865
|
+
COALESCE(AVG(ref_count), 0) AS avg_ref
|
|
1866
|
+
FROM dedup_mirror`,
|
|
1867
|
+
)
|
|
1868
|
+
.get() as { cnt: number; total_bytes: number; avg_ref: number };
|
|
1869
|
+
return { rowCount: row.cnt, totalBytes: row.total_bytes, avgRefCount: row.avg_ref };
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
/**
|
|
1873
|
+
* Update raw_transcript.content_ref to point to dedup_mirror.
|
|
1874
|
+
*/
|
|
1875
|
+
export function updateRawTranscriptRef(
|
|
1876
|
+
db: DatabaseSync,
|
|
1877
|
+
sessionId: string,
|
|
1878
|
+
seq: number,
|
|
1879
|
+
contentHash: string,
|
|
1880
|
+
): void {
|
|
1881
|
+
db.prepare(
|
|
1882
|
+
`UPDATE raw_transcript SET content_ref = @ref WHERE session_id = @sid AND seq = @seq`,
|
|
1883
|
+
).run({
|
|
1884
|
+
"@ref": contentHash,
|
|
1885
|
+
"@sid": sessionId,
|
|
1886
|
+
"@seq": seq,
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// ---------------------------------------------------------------------------
|
|
1891
|
+
// S27 Task 10 — DB maintenance / housekeeping primitives.
|
|
1892
|
+
// All pi-agnostic, all parameterized (PREVENT-002), all local (PREVENT-PI-004).
|
|
1893
|
+
// Exposed via the /mega-db-* slash commands in extensions/mega-db-cmds.ts.
|
|
1894
|
+
// ---------------------------------------------------------------------------
|
|
1895
|
+
|
|
1896
|
+
/** Per-table row counts + DB file sizes for the /mega-db-stats command. */
|
|
1897
|
+
export interface DbStats {
|
|
1898
|
+
/** Row count per table (keys are table names that exist in this DB). */
|
|
1899
|
+
tableCounts: Record<string, number>;
|
|
1900
|
+
/** Bytes used by the main DB file on disk. */
|
|
1901
|
+
dbBytes: number;
|
|
1902
|
+
/** Bytes used by the -wal sidecar file (0 if absent). */
|
|
1903
|
+
walBytes: number;
|
|
1904
|
+
/** Bytes used by the -shm sidecar file (0 if absent). */
|
|
1905
|
+
shmBytes: number;
|
|
1906
|
+
/** SQLite page size in bytes. */
|
|
1907
|
+
pageSize: number;
|
|
1908
|
+
/** Total pages (freelist + in-use). */
|
|
1909
|
+
pageCount: number;
|
|
1910
|
+
/** Freelist pages (reusable by VACUUM). */
|
|
1911
|
+
freelistPages: number;
|
|
1912
|
+
/** WAL frame count from PRAGMA wal_info (best-effort; 0 if unsupported). */
|
|
1913
|
+
walFrames: number;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
const DB_TABLE_NAMES = [
|
|
1917
|
+
"context_chunks",
|
|
1918
|
+
"session_state",
|
|
1919
|
+
"raw_transcript",
|
|
1920
|
+
"checkpoint_epochs",
|
|
1921
|
+
"dedup_mirror",
|
|
1922
|
+
"memories",
|
|
1923
|
+
"dedup_stats",
|
|
1924
|
+
"daily_log",
|
|
1925
|
+
] as const;
|
|
1926
|
+
|
|
1927
|
+
function fileSizeIfExists(path: string): number {
|
|
1928
|
+
try {
|
|
1929
|
+
const st = statSync(path);
|
|
1930
|
+
return st.size;
|
|
1931
|
+
} catch {
|
|
1932
|
+
return 0;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
/**
|
|
1937
|
+
* Gather DB stats for /mega-db-stats: per-table row counts, disk footprint
|
|
1938
|
+
* (main + WAL + SHM), page count, freelist, WAL frame count.
|
|
1939
|
+
*
|
|
1940
|
+
* Read-only: no PRAGMA writes, no VACUUM. Safe to call any time.
|
|
1941
|
+
*/
|
|
1942
|
+
export function getDbStats(stateDir: string = getStateDir()): DbStats {
|
|
1943
|
+
const db = openStore(stateDir);
|
|
1944
|
+
const tableCounts: Record<string, number> = {};
|
|
1945
|
+
for (const t of DB_TABLE_NAMES) {
|
|
1946
|
+
try {
|
|
1947
|
+
const row = db.prepare(`SELECT COUNT(*) AS c FROM ${t}`).get() as { c: number } | undefined;
|
|
1948
|
+
if (row) tableCounts[t] = row.c;
|
|
1949
|
+
} catch {
|
|
1950
|
+
// Table doesn't exist on this DB (e.g. raw_transcript on a pre-S27 store).
|
|
1951
|
+
// Skip silently — /mega-db-stats lists only tables that exist.
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
const pageStat = db.prepare("PRAGMA page_count").get() as { page_count?: number } | undefined;
|
|
1955
|
+
const freelistStat = db.prepare("PRAGMA freelist_count").get() as { freelist_count?: number } | undefined;
|
|
1956
|
+
const pageSizeStat = db.prepare("PRAGMA page_size").get() as { page_size?: number } | undefined;
|
|
1957
|
+
let walFrames = 0;
|
|
1958
|
+
try {
|
|
1959
|
+
const walInfo = db.prepare("PRAGMA wal_info").get() as { frames?: number } | undefined;
|
|
1960
|
+
walFrames = walInfo?.frames ?? 0;
|
|
1961
|
+
} catch {
|
|
1962
|
+
// node:sqlite may not expose wal_info on all versions; not fatal.
|
|
1963
|
+
}
|
|
1964
|
+
const dbPath = join(stateDir, "sqlite.db");
|
|
1965
|
+
return {
|
|
1966
|
+
tableCounts,
|
|
1967
|
+
dbBytes: fileSizeIfExists(dbPath),
|
|
1968
|
+
walBytes: fileSizeIfExists(`${dbPath}-wal`),
|
|
1969
|
+
shmBytes: fileSizeIfExists(`${dbPath}-shm`),
|
|
1970
|
+
pageSize: pageSizeStat?.page_size ?? 0,
|
|
1971
|
+
pageCount: pageStat?.page_count ?? 0,
|
|
1972
|
+
freelistPages: freelistStat?.freelist_count ?? 0,
|
|
1973
|
+
walFrames,
|
|
1974
|
+
};
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/** Result of a prune / VACUUM / checkpoint operation (reclaimed bytes). */
|
|
1978
|
+
export interface MaintenanceResult {
|
|
1979
|
+
/** Rows deleted (prune) or pages reclaimed (VACUUM / checkpoint). */
|
|
1980
|
+
affected: number;
|
|
1981
|
+
/** Bytes reclaimed on disk (best-effort: post-op size minus pre-op size). */
|
|
1982
|
+
reclaimedBytes: number;
|
|
1983
|
+
/** Human-readable summary line for the command output. */
|
|
1984
|
+
summary: string;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
/**
|
|
1988
|
+
* Prune raw_transcript + checkpoint_epochs rows older than `daysOld`.
|
|
1989
|
+
* Uses `message_timestamp` (raw_transcript) and `created_at` (epochs), both
|
|
1990
|
+
* epoch-ms. Returns the total deleted rows + reclaimed disk bytes.
|
|
1991
|
+
*
|
|
1992
|
+
* PREVENT-002: parameterized. PREVENT-PI-004: local SQLite only.
|
|
1993
|
+
*/
|
|
1994
|
+
export function pruneOldRows(stateDir: string = getStateDir(), daysOld = 30): MaintenanceResult {
|
|
1995
|
+
const db = openStore(stateDir);
|
|
1996
|
+
const cutoff = Date.now() - daysOld * 86_400_000;
|
|
1997
|
+
const beforeBytes = fileSizeIfExists(join(stateDir, "sqlite.db"));
|
|
1998
|
+
// raw_transcript: message_timestamp may be NULL (pre-S27 rows); those use
|
|
1999
|
+
// the row's insertion order implicitly via seq, so we prune NULL-ts rows
|
|
2000
|
+
// only when the whole session is older than the cutoff (join via session_id
|
|
2001
|
+
// to checkpoint_epochs.created_at). Simpler: prune NULL-ts rows older than
|
|
2002
|
+
// cutoff by falling back to the MIN(created_at) of their epoch.
|
|
2003
|
+
// Delete raw_transcript rows whose message_timestamp is older than cutoff,
|
|
2004
|
+
// OR whose message_timestamp is NULL and the session's latest epoch is older.
|
|
2005
|
+
const delRt = db.prepare(
|
|
2006
|
+
`DELETE FROM raw_transcript
|
|
2007
|
+
WHERE message_timestamp IS NOT NULL AND message_timestamp < ?
|
|
2008
|
+
OR (message_timestamp IS NULL
|
|
2009
|
+
AND session_id IN (
|
|
2010
|
+
SELECT session_id FROM checkpoint_epochs
|
|
2011
|
+
GROUP BY session_id HAVING MAX(created_at) < ?
|
|
2012
|
+
))`,
|
|
2013
|
+
).run(cutoff, cutoff) as { changes?: number } | undefined;
|
|
2014
|
+
const rtDeleted = delRt?.changes ?? 0;
|
|
2015
|
+
// checkpoint_epochs: created_at is NOT NULL.
|
|
2016
|
+
const delEp = db.prepare(`DELETE FROM checkpoint_epochs WHERE created_at < ?`).run(cutoff) as {
|
|
2017
|
+
changes?: number;
|
|
2018
|
+
} | undefined;
|
|
2019
|
+
const epDeleted = delEp?.changes ?? 0;
|
|
2020
|
+
// dedup_mirror: cascade-delete orphan rows whose ref_count has dropped to 0
|
|
2021
|
+
// after the raw_transcript deletes. Safe even if FK is off (raw_transcript has
|
|
2022
|
+
// no FK to dedup_mirror; ref_count is maintained by the dedup pipeline).
|
|
2023
|
+
const delDedup = db.prepare(`DELETE FROM dedup_mirror WHERE ref_count <= 0`).run() as {
|
|
2024
|
+
changes?: number;
|
|
2025
|
+
} | undefined;
|
|
2026
|
+
const dedupDeleted = delDedup?.changes ?? 0;
|
|
2027
|
+
const afterBytes = fileSizeIfExists(join(stateDir, "sqlite.db"));
|
|
2028
|
+
const total = rtDeleted + epDeleted + dedupDeleted;
|
|
2029
|
+
return {
|
|
2030
|
+
affected: total,
|
|
2031
|
+
reclaimedBytes: Math.max(0, beforeBytes - afterBytes),
|
|
2032
|
+
summary: `pruned ${rtDeleted} raw_transcript + ${epDeleted} epochs + ${dedupDeleted} dedup_mirror rows older than ${daysOld}d`,
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
/**
|
|
2037
|
+
* Force a WAL checkpoint (TRUNCATE mode) so the -wal sidecar is reclaimed.
|
|
2038
|
+
* Returns the WAL bytes reclaimed (pre-wal size minus post-wal size).
|
|
2039
|
+
*/
|
|
2040
|
+
export function checkpointWal(stateDir: string = getStateDir()): MaintenanceResult {
|
|
2041
|
+
const db = openStore(stateDir);
|
|
2042
|
+
const dbPath = join(stateDir, "sqlite.db");
|
|
2043
|
+
const beforeWal = fileSizeIfExists(`${dbPath}-wal`);
|
|
2044
|
+
// PRAGMA wal_checkpoint(TRUNCATE) blocks until all frames are folded into the
|
|
2045
|
+
// main db and the WAL file is truncated to 0 bytes.
|
|
2046
|
+
const res = db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get() as {
|
|
2047
|
+
busy?: number;
|
|
2048
|
+
log?: number;
|
|
2049
|
+
checkpointed?: number;
|
|
2050
|
+
} | undefined;
|
|
2051
|
+
const afterWal = fileSizeIfExists(`${dbPath}-wal`);
|
|
2052
|
+
const reclaimed = Math.max(0, beforeWal - afterWal);
|
|
2053
|
+
return {
|
|
2054
|
+
affected: res?.checkpointed ?? 0,
|
|
2055
|
+
reclaimedBytes: reclaimed,
|
|
2056
|
+
summary: `wal_checkpoint(TRUNCATE): ${res?.checkpointed ?? 0} frames folded, WAL ${beforeWal}→${afterWal} bytes${res?.busy ? " (busy: " + res.busy + ")" : ""}`,
|
|
2057
|
+
};
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
/**
|
|
2061
|
+
* VACUUM the main DB file (rebuilds pages, reclaims freelist space).
|
|
2062
|
+
* Heavy: briefly doubles disk usage. Run only when freelist is large or the
|
|
2063
|
+
* user explicitly invokes /mega-db-vacuum.
|
|
2064
|
+
*/
|
|
2065
|
+
export function vacuumDb(stateDir: string = getStateDir()): MaintenanceResult {
|
|
2066
|
+
const db = openStore(stateDir);
|
|
2067
|
+
const dbPath = join(stateDir, "sqlite.db");
|
|
2068
|
+
const beforeBytes = fileSizeIfExists(dbPath);
|
|
2069
|
+
db.exec("VACUUM"); // VACUUM cannot be parameterized; it rewrites the whole DB.
|
|
2070
|
+
const afterBytes = fileSizeIfExists(dbPath);
|
|
2071
|
+
const reclaimed = Math.max(0, beforeBytes - afterBytes);
|
|
2072
|
+
return {
|
|
2073
|
+
affected: 0,
|
|
2074
|
+
reclaimedBytes: reclaimed,
|
|
2075
|
+
summary: `VACUUM: db ${beforeBytes}→${afterBytes} bytes (reclaimed ${reclaimed})`,
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
/**
|
|
2080
|
+
* Run `PRAGMA integrity_check` and return the result lines.
|
|
2081
|
+
* Returns ["ok"] when the DB is healthy; otherwise returns the error lines.
|
|
2082
|
+
*/
|
|
2083
|
+
export function integrityCheck(stateDir: string = getStateDir()): string[] {
|
|
2084
|
+
const db = openStore(stateDir);
|
|
2085
|
+
const rows = db.prepare("PRAGMA integrity_check").all() as Array<{ integrity_check: string }> | undefined;
|
|
2086
|
+
return (rows ?? []).map((r) => r.integrity_check);
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
/** Reconcile drift in dedup_mirror.ref_count vs actual raw_transcript refs. */
|
|
2090
|
+
export interface DedupReconcileResult {
|
|
2091
|
+
/** Rows whose ref_count was corrected. */
|
|
2092
|
+
fixedRefCount: number;
|
|
2093
|
+
/** Orphan dedup_mirror rows (content_hash with 0 raw_transcript refs) deleted. */
|
|
2094
|
+
orphansDeleted: number;
|
|
2095
|
+
/** raw_transcript rows whose content_ref was NULL but now set (backfill). */
|
|
2096
|
+
refsBackfilled: number;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
/**
|
|
2100
|
+
* Reconcile dedup_mirror vs raw_transcript after pruning or crashes:
|
|
2101
|
+
* 1. Recompute ref_count = COUNT(raw_transcript rows pointing at this hash).
|
|
2102
|
+
* 2. Delete orphan dedup_mirror rows whose recomputed ref_count is 0.
|
|
2103
|
+
* 3. Backfill raw_transcript.content_ref for rows still storing inline bytes.
|
|
2104
|
+
*
|
|
2105
|
+
* Idempotent. Read-modify-write within a single transaction (withTx).
|
|
2106
|
+
*/
|
|
2107
|
+
export function reconcileDedupMirror(stateDir: string = getStateDir()): DedupReconcileResult {
|
|
2108
|
+
const db = openStore(stateDir);
|
|
2109
|
+
const result: DedupReconcileResult = { fixedRefCount: 0, orphansDeleted: 0, refsBackfilled: 0 };
|
|
2110
|
+
withTx(db, () => {
|
|
2111
|
+
// 1. Recompute ref_count for every dedup_mirror row from the actual
|
|
2112
|
+
// raw_transcript references.
|
|
2113
|
+
const recompute = db.prepare(
|
|
2114
|
+
`UPDATE dedup_mirror AS dm
|
|
2115
|
+
SET ref_count = COALESCE((
|
|
2116
|
+
SELECT COUNT(*) FROM raw_transcript rt WHERE rt.content_ref = dm.content_hash
|
|
2117
|
+
), 0)
|
|
2118
|
+
WHERE dm.ref_count != COALESCE((
|
|
2119
|
+
SELECT COUNT(*) FROM raw_transcript rt WHERE rt.content_ref = dm.content_hash
|
|
2120
|
+
), 0)`,
|
|
2121
|
+
).run() as { changes?: number } | undefined;
|
|
2122
|
+
result.fixedRefCount = recompute?.changes ?? 0;
|
|
2123
|
+
// 2. Delete orphan dedup_mirror rows (no raw_transcript refs).
|
|
2124
|
+
const delOrphans = db.prepare(
|
|
2125
|
+
`DELETE FROM dedup_mirror
|
|
2126
|
+
WHERE content_hash NOT IN (SELECT DISTINCT content_ref FROM raw_transcript WHERE content_ref IS NOT NULL)`,
|
|
2127
|
+
).run() as { changes?: number } | undefined;
|
|
2128
|
+
result.orphansDeleted = delOrphans?.changes ?? 0;
|
|
2129
|
+
// 3. Backfill content_ref for rows still storing inline content_bytes (no
|
|
2130
|
+
// ref yet). Only safe when a matching dedup_mirror row exists; otherwise
|
|
2131
|
+
// we'd need to insert one, which is the dedup pipeline's job, not the
|
|
2132
|
+
// reconciler's.
|
|
2133
|
+
const backfill = db.prepare(
|
|
2134
|
+
`UPDATE raw_transcript AS rt
|
|
2135
|
+
SET content_ref = (
|
|
2136
|
+
SELECT dm.content_hash FROM dedup_mirror dm WHERE dm.content_bytes = rt.content_bytes
|
|
2137
|
+
)
|
|
2138
|
+
WHERE rt.content_ref IS NULL
|
|
2139
|
+
AND EXISTS (SELECT 1 FROM dedup_mirror dm WHERE dm.content_bytes = rt.content_bytes)`,
|
|
2140
|
+
).run() as { changes?: number } | undefined;
|
|
2141
|
+
result.refsBackfilled = backfill?.changes ?? 0;
|
|
2142
|
+
});
|
|
2143
|
+
return result;
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
/**
|
|
2147
|
+
* One-shot auto-maintenance pass for the session_start hook: prune old rows,
|
|
2148
|
+
* checkpoint the WAL if it's grown large, and (only if the DB is huge) VACUUM.
|
|
2149
|
+
* Best-effort: swallows errors so a session never fails to start over a
|
|
2150
|
+
* housekeeping hiccup. Returns a short summary for the diagnostic log.
|
|
2151
|
+
*/
|
|
2152
|
+
export function autoMaintain(stateDir: string = getStateDir()): string {
|
|
2153
|
+
try {
|
|
2154
|
+
const stats = getDbStats(stateDir);
|
|
2155
|
+
const parts: string[] = [];
|
|
2156
|
+
// Prune rows older than 30d (default retention).
|
|
2157
|
+
const prune = pruneOldRows(stateDir, 30);
|
|
2158
|
+
if (prune.affected > 0) parts.push(`pruned ${prune.affected}`);
|
|
2159
|
+
// Checkpoint the WAL if it's over 10 MB (avoid pathological WAL growth).
|
|
2160
|
+
if (stats.walBytes > 10 * 1024 * 1024) {
|
|
2161
|
+
const ck = checkpointWal(stateDir);
|
|
2162
|
+
if (ck.reclaimedBytes > 0) parts.push(`wal -${ck.reclaimedBytes}B`);
|
|
2163
|
+
}
|
|
2164
|
+
// VACUUM only if the DB is over 100 MB AND freelist is >20% of pages.
|
|
2165
|
+
if (
|
|
2166
|
+
stats.dbBytes > 100 * 1024 * 1024 &&
|
|
2167
|
+
stats.pageCount > 0 &&
|
|
2168
|
+
stats.freelistPages / stats.pageCount > 0.2
|
|
2169
|
+
) {
|
|
2170
|
+
const v = vacuumDb(stateDir);
|
|
2171
|
+
if (v.reclaimedBytes > 0) parts.push(`vacuum -${v.reclaimedBytes}B`);
|
|
2172
|
+
}
|
|
2173
|
+
return parts.length ? `auto-maintain: ${parts.join(", ")}` : "auto-maintain: nothing to do";
|
|
2174
|
+
} catch (err) {
|
|
2175
|
+
// Never block session start over housekeeping.
|
|
2176
|
+
return `auto-maintain: skipped (${(err as Error).message})`;
|
|
2177
|
+
}
|
|
2178
|
+
}
|