pi-mega-compact 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,7 +25,7 @@ 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 = 1;
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,330 @@ 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
+ }