latticesql 0.5.1 → 0.5.3

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 CHANGED
@@ -44,6 +44,7 @@ Lattice has no opinions about your schema, your agents, or your file format. You
44
44
  - [Lifecycle hooks](#lifecycle-hooks)
45
45
  - [Field interpolation](#field-interpolation)
46
46
  - [Entity context directories (v0.5+)](#entity-context-directories-v05)
47
+ - [SESSION.md write pattern](#sessionmd-write-pattern)
47
48
  - [YAML config (v0.4+)](#yaml-config-v04)
48
49
  - [lattice.config.yml reference](#latticeconfigyml-reference)
49
50
  - [Init from config](#init-from-config)
@@ -1000,6 +1001,78 @@ See [docs/entity-context.md](./docs/entity-context.md) for the complete referenc
1000
1001
 
1001
1002
  ---
1002
1003
 
1004
+ ## SESSION.md write pattern
1005
+
1006
+ When agents run in a directory-based context system (e.g., one directory per agent with generated Markdown files), SESSION.md provides a **safe write interface** that enforces a clean read/write separation:
1007
+
1008
+ ```
1009
+ READ: Lattice DB → render() → object MDs (READ ONLY for agents)
1010
+ WRITE: Agent → SESSION.md → processor → validates → Lattice DB
1011
+ ```
1012
+
1013
+ All generated context files carry a read-only header so agents know not to edit them directly. SESSION.md is the only writable file in the directory.
1014
+
1015
+ ### Write entry format
1016
+
1017
+ ```
1018
+ ---
1019
+ id: 2026-03-25T10:30:00Z-agent-abc123
1020
+ type: write
1021
+ timestamp: 2026-03-25T10:30:00Z
1022
+ op: update
1023
+ table: agents
1024
+ target: agent-id-here
1025
+ reason: Updating status after deployment completed.
1026
+ ---
1027
+ status: active
1028
+ last_task: piut-deploy
1029
+ ===
1030
+ ```
1031
+
1032
+ | Header | Required | Description |
1033
+ |--------|----------|-------------|
1034
+ | `type` | Yes | Must be `write` |
1035
+ | `timestamp` | Yes | ISO 8601 |
1036
+ | `op` | Yes | `create`, `update`, or `delete` |
1037
+ | `table` | Yes | Target table name |
1038
+ | `target` | For update/delete | Record primary key |
1039
+ | `reason` | Encouraged | Human-readable reason (audit trail) |
1040
+
1041
+ **Body**: `key: value` pairs — one field per line. Field names are validated against the table schema before any write is applied.
1042
+
1043
+ ### Library support
1044
+
1045
+ `latticesql` exports a parser for the SESSION.md write format:
1046
+
1047
+ ```ts
1048
+ import { parseSessionWrites } from 'latticesql';
1049
+
1050
+ const result = parseSessionWrites(sessionFileContent);
1051
+ // result.entries: SessionWriteEntry[]
1052
+ // result.errors: Array<{ line: number; message: string }>
1053
+
1054
+ for (const entry of result.entries) {
1055
+ console.log(entry.op, entry.table, entry.target, entry.fields);
1056
+ }
1057
+ ```
1058
+
1059
+ **`SessionWriteEntry`:**
1060
+ ```ts
1061
+ interface SessionWriteEntry {
1062
+ id: string; // content-addressed ID
1063
+ timestamp: string; // ISO 8601
1064
+ op: 'create' | 'update' | 'delete';
1065
+ table: string;
1066
+ target?: string; // required for update/delete
1067
+ reason?: string;
1068
+ fields: Record<string, string>; // empty for delete
1069
+ }
1070
+ ```
1071
+
1072
+ The processor is responsible for applying the parsed entries to your DB and validating field names against your schema. The `parseSessionWrites` function is pure — no DB access, no side effects.
1073
+
1074
+ ---
1075
+
1003
1076
  ## YAML config (v0.4+)
1004
1077
 
1005
1078
  Define your entire schema in a YAML file. Lattice reads it at construction time, creates all tables on `init()`, and wires render functions automatically.
package/dist/cli.js CHANGED
@@ -503,12 +503,22 @@ var SchemaManager = class {
503
503
  }
504
504
  }
505
505
  }
506
- /** Query all rows from a registered table */
506
+ /**
507
+ * Query all rows from a table.
508
+ * Registered tables (via `define()`) are queried directly.
509
+ * Tables used only in entity contexts (schema managed externally) fall back
510
+ * to a raw SELECT with optional `deleted_at IS NULL` soft-delete filtering.
511
+ */
507
512
  queryTable(adapter, name) {
508
- if (!this._tables.has(name)) {
509
- throw new Error(`Unknown table: "${name}"`);
513
+ if (this._tables.has(name)) {
514
+ return adapter.all(`SELECT * FROM "${name}"`);
515
+ }
516
+ if (this._entityContexts.has(name)) {
517
+ const cols = adapter.all(`PRAGMA table_info("${name}")`);
518
+ const hasDeletedAt = cols.some((c) => c.name === "deleted_at");
519
+ return adapter.all(`SELECT * FROM "${name}"${hasDeletedAt ? " WHERE deleted_at IS NULL" : ""}`);
510
520
  }
511
- return adapter.all(`SELECT * FROM "${name}"`);
521
+ throw new Error(`Unknown table: "${name}"`);
512
522
  }
513
523
  _ensureTable(adapter, name, columns, tableConstraints) {
514
524
  const colDefs = Object.entries(columns).map(([col, type]) => `"${col}" ${type}`).join(", ");
package/dist/index.cjs CHANGED
@@ -31,10 +31,18 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  Lattice: () => Lattice,
34
+ READ_ONLY_HEADER: () => READ_ONLY_HEADER,
35
+ applyWriteEntry: () => applyWriteEntry,
36
+ generateEntryId: () => generateEntryId,
37
+ generateWriteEntryId: () => generateWriteEntryId,
34
38
  manifestPath: () => manifestPath,
35
39
  parseConfigFile: () => parseConfigFile,
36
40
  parseConfigString: () => parseConfigString,
41
+ parseMarkdownEntries: () => parseMarkdownEntries,
42
+ parseSessionMD: () => parseSessionMD,
43
+ parseSessionWrites: () => parseSessionWrites,
37
44
  readManifest: () => readManifest,
45
+ validateEntryId: () => validateEntryId,
38
46
  writeManifest: () => writeManifest
39
47
  });
40
48
  module.exports = __toCommonJS(index_exports);
@@ -230,12 +238,22 @@ var SchemaManager = class {
230
238
  }
231
239
  }
232
240
  }
233
- /** Query all rows from a registered table */
241
+ /**
242
+ * Query all rows from a table.
243
+ * Registered tables (via `define()`) are queried directly.
244
+ * Tables used only in entity contexts (schema managed externally) fall back
245
+ * to a raw SELECT with optional `deleted_at IS NULL` soft-delete filtering.
246
+ */
234
247
  queryTable(adapter, name) {
235
- if (!this._tables.has(name)) {
236
- throw new Error(`Unknown table: "${name}"`);
248
+ if (this._tables.has(name)) {
249
+ return adapter.all(`SELECT * FROM "${name}"`);
250
+ }
251
+ if (this._entityContexts.has(name)) {
252
+ const cols = adapter.all(`PRAGMA table_info("${name}")`);
253
+ const hasDeletedAt = cols.some((c) => c.name === "deleted_at");
254
+ return adapter.all(`SELECT * FROM "${name}"${hasDeletedAt ? " WHERE deleted_at IS NULL" : ""}`);
237
255
  }
238
- return adapter.all(`SELECT * FROM "${name}"`);
256
+ throw new Error(`Unknown table: "${name}"`);
239
257
  }
240
258
  _ensureTable(adapter, name, columns, tableConstraints) {
241
259
  const colDefs = Object.entries(columns).map(([col, type]) => `"${col}" ${type}`).join(", ");
@@ -1503,12 +1521,401 @@ var Lattice = class {
1503
1521
  }
1504
1522
  }
1505
1523
  };
1524
+
1525
+ // src/session/parser.ts
1526
+ var import_node_crypto3 = require("crypto");
1527
+ function generateWriteEntryId(timestamp, agentName, op, table, target) {
1528
+ const payload = `${op}:${table}:${target ?? ""}:${timestamp}`;
1529
+ const hash = (0, import_node_crypto3.createHash)("sha256").update(payload).digest("hex").slice(0, 6);
1530
+ return `${timestamp}-${agentName}-${hash}`;
1531
+ }
1532
+ function parseSessionWrites(content) {
1533
+ const entries = [];
1534
+ const errors = [];
1535
+ const blocks = splitIntoBlocks(content);
1536
+ for (const block of blocks) {
1537
+ const result = parseBlock(block);
1538
+ if (result === null) continue;
1539
+ if ("error" in result) {
1540
+ errors.push(result.error);
1541
+ } else {
1542
+ entries.push(result.entry);
1543
+ }
1544
+ }
1545
+ return { entries, errors };
1546
+ }
1547
+ function splitIntoBlocks(content) {
1548
+ const lines = content.split("\n");
1549
+ const blocks = [];
1550
+ let i = 0;
1551
+ while (i < lines.length) {
1552
+ while (i < lines.length && lines[i]?.trim() !== "---") {
1553
+ i++;
1554
+ }
1555
+ if (i >= lines.length) break;
1556
+ const startLine = i + 1;
1557
+ i++;
1558
+ const headerLines = [];
1559
+ while (i < lines.length && lines[i]?.trim() !== "---") {
1560
+ headerLines.push(lines[i] ?? "");
1561
+ i++;
1562
+ }
1563
+ if (i >= lines.length) break;
1564
+ i++;
1565
+ const bodyLines = [];
1566
+ while (i < lines.length && lines[i]?.trim() !== "===") {
1567
+ bodyLines.push(lines[i] ?? "");
1568
+ i++;
1569
+ }
1570
+ if (i < lines.length) i++;
1571
+ blocks.push({ startLine, headerLines, bodyLines });
1572
+ }
1573
+ return blocks;
1574
+ }
1575
+ var TABLE_NAME_RE = /^[a-zA-Z0-9_]+$/;
1576
+ var FIELD_NAME_RE = /^[a-zA-Z0-9_]+$/;
1577
+ var KEY_VALUE_RE = /^([^:]+):\s*(.*)$/;
1578
+ function parseBlock(block) {
1579
+ const header = {};
1580
+ for (const line2 of block.headerLines) {
1581
+ const m = KEY_VALUE_RE.exec(line2);
1582
+ if (m) {
1583
+ header[m[1].trim()] = m[2].trim();
1584
+ }
1585
+ }
1586
+ if (header["type"] !== "write") return null;
1587
+ const line = block.startLine;
1588
+ const timestamp = header["timestamp"];
1589
+ if (!timestamp) {
1590
+ return { error: { line, message: "Missing required field: timestamp" } };
1591
+ }
1592
+ const rawOp = header["op"];
1593
+ if (!rawOp) {
1594
+ return { error: { line, message: "Missing required field: op" } };
1595
+ }
1596
+ if (rawOp !== "create" && rawOp !== "update" && rawOp !== "delete") {
1597
+ return { error: { line, message: `Invalid op: "${rawOp}". Must be create, update, or delete` } };
1598
+ }
1599
+ const op = rawOp;
1600
+ const table = header["table"];
1601
+ if (!table) {
1602
+ return { error: { line, message: "Missing required field: table" } };
1603
+ }
1604
+ if (!TABLE_NAME_RE.test(table)) {
1605
+ return { error: { line, message: `Invalid table name: "${table}". Only [a-zA-Z0-9_] allowed` } };
1606
+ }
1607
+ const target = header["target"] || void 0;
1608
+ if ((op === "update" || op === "delete") && !target) {
1609
+ return { error: { line, message: `Field "target" is required for op "${op}"` } };
1610
+ }
1611
+ const reason = header["reason"] || void 0;
1612
+ const fields = {};
1613
+ if (op !== "delete") {
1614
+ for (const line2 of block.bodyLines) {
1615
+ const m = KEY_VALUE_RE.exec(line2);
1616
+ if (!m) continue;
1617
+ const key = m[1].trim();
1618
+ const value = m[2].trim();
1619
+ if (!FIELD_NAME_RE.test(key)) continue;
1620
+ fields[key] = value;
1621
+ }
1622
+ }
1623
+ const id = header["id"] ?? generateWriteEntryId(timestamp, "agent", op, table, target);
1624
+ const entry = {
1625
+ id,
1626
+ timestamp,
1627
+ op,
1628
+ table,
1629
+ fields,
1630
+ ...target !== void 0 ? { target } : {},
1631
+ ...reason !== void 0 ? { reason } : {}
1632
+ };
1633
+ return { entry };
1634
+ }
1635
+
1636
+ // src/session/entries.ts
1637
+ var import_node_crypto4 = require("crypto");
1638
+ var VALID_TYPES = /* @__PURE__ */ new Set([
1639
+ "event",
1640
+ "learning",
1641
+ "status",
1642
+ "correction",
1643
+ "discovery",
1644
+ "metric",
1645
+ "handoff",
1646
+ "write"
1647
+ ]);
1648
+ var TYPE_ALIASES = {
1649
+ task_completion: "event",
1650
+ completion: "event",
1651
+ heartbeat: "status",
1652
+ bug: "discovery",
1653
+ fix: "event",
1654
+ deploy: "event",
1655
+ note: "event"
1656
+ };
1657
+ var FIELD_NAME_RE2 = /^[a-zA-Z0-9_]+$/;
1658
+ function parseSessionMD(content, startOffset = 0) {
1659
+ const entries = [];
1660
+ const errors = [];
1661
+ const text = content.slice(startOffset);
1662
+ const lines = text.split("\n");
1663
+ let i = 0;
1664
+ let currentByteOffset = startOffset;
1665
+ while (i < lines.length) {
1666
+ if (lines[i].trim() !== "---") {
1667
+ currentByteOffset += Buffer.byteLength(lines[i] + "\n", "utf-8");
1668
+ i++;
1669
+ continue;
1670
+ }
1671
+ const entryStartLine = i;
1672
+ currentByteOffset += Buffer.byteLength(lines[i] + "\n", "utf-8");
1673
+ i++;
1674
+ const headers = {};
1675
+ let foundHeaderClose = false;
1676
+ while (i < lines.length) {
1677
+ const line = lines[i];
1678
+ currentByteOffset += Buffer.byteLength(line + "\n", "utf-8");
1679
+ if (line.trim() === "---") {
1680
+ foundHeaderClose = true;
1681
+ i++;
1682
+ break;
1683
+ }
1684
+ const match = line.match(/^(\w+):\s*(.+)$/);
1685
+ if (match) {
1686
+ headers[match[1]] = match[2].trim();
1687
+ }
1688
+ i++;
1689
+ }
1690
+ if (!foundHeaderClose) {
1691
+ errors.push({ line: entryStartLine + 1, message: "Entry header never closed (missing ---)" });
1692
+ break;
1693
+ }
1694
+ const bodyLines = [];
1695
+ while (i < lines.length) {
1696
+ const line = lines[i];
1697
+ if (line.trim() === "===") {
1698
+ currentByteOffset += Buffer.byteLength(line + "\n", "utf-8");
1699
+ i++;
1700
+ break;
1701
+ }
1702
+ if (line.trim() === "---" && bodyLines.length > 0) {
1703
+ const nextLine = lines[i + 1];
1704
+ if (nextLine && /^\w+:\s*.+$/.test(nextLine)) {
1705
+ break;
1706
+ }
1707
+ }
1708
+ bodyLines.push(line);
1709
+ currentByteOffset += Buffer.byteLength(line + "\n", "utf-8");
1710
+ i++;
1711
+ }
1712
+ const body = bodyLines.join("\n").trim();
1713
+ const rawType = headers["type"] ?? "";
1714
+ const resolvedType = normalizeType(rawType);
1715
+ if (!resolvedType) {
1716
+ errors.push({ line: entryStartLine + 1, message: `Unknown entry type: ${rawType}` });
1717
+ continue;
1718
+ }
1719
+ if (!headers["timestamp"]) {
1720
+ errors.push({ line: entryStartLine + 1, message: "Missing required header: timestamp" });
1721
+ continue;
1722
+ }
1723
+ if (!body) {
1724
+ errors.push({ line: entryStartLine + 1, message: "Entry has empty body" });
1725
+ continue;
1726
+ }
1727
+ const entryId = headers["id"] ?? generateEntryId(headers["timestamp"], "agent", body);
1728
+ let tags;
1729
+ if (headers["tags"]) {
1730
+ const tagMatch = headers["tags"].match(/^\[(.+)\]$/);
1731
+ if (tagMatch) {
1732
+ tags = tagMatch[1].split(",").map((t) => t.trim());
1733
+ }
1734
+ }
1735
+ let writeFields;
1736
+ if (resolvedType === "write" && headers["op"] !== "delete") {
1737
+ writeFields = {};
1738
+ for (const line of body.split("\n")) {
1739
+ const m = line.match(/^([^:]+):\s*(.*)$/);
1740
+ if (!m) continue;
1741
+ const key = m[1].trim();
1742
+ if (!FIELD_NAME_RE2.test(key)) continue;
1743
+ writeFields[key] = m[2].trim();
1744
+ }
1745
+ }
1746
+ const newEntry = { id: entryId, type: resolvedType, timestamp: headers["timestamp"], body };
1747
+ if (headers["project"]) newEntry.project = headers["project"];
1748
+ if (headers["task"]) newEntry.task = headers["task"];
1749
+ if (tags) newEntry.tags = tags;
1750
+ if (headers["severity"]) newEntry.severity = headers["severity"];
1751
+ if (headers["target_agent"]) newEntry.target_agent = headers["target_agent"];
1752
+ if (headers["target_table"]) newEntry.target_table = headers["target_table"];
1753
+ if (resolvedType === "write") {
1754
+ if (headers["op"]) newEntry.op = headers["op"];
1755
+ if (headers["table"]) newEntry.table = headers["table"];
1756
+ if (headers["target"]) newEntry.target = headers["target"];
1757
+ if (headers["reason"]) newEntry.reason = headers["reason"];
1758
+ newEntry.fields = writeFields ?? {};
1759
+ }
1760
+ entries.push(newEntry);
1761
+ }
1762
+ return { entries, errors, lastOffset: currentByteOffset };
1763
+ }
1764
+ function parseMarkdownEntries(content, agentName, startOffset = 0) {
1765
+ const entries = [];
1766
+ const errors = [];
1767
+ const text = content.slice(startOffset);
1768
+ const lines = text.split("\n");
1769
+ const headingPattern = /^##\s+([\dT:.Z-]{10,})\s*(?:[—–\-]{1,2}\s*(.+))?$/;
1770
+ let currentByteOffset = startOffset;
1771
+ const entryStarts = [];
1772
+ for (let i = 0; i < lines.length; i++) {
1773
+ const match = lines[i].match(headingPattern);
1774
+ if (match) {
1775
+ entryStarts.push({
1776
+ lineIdx: i,
1777
+ timestamp: match[1],
1778
+ headingType: (match[2] ?? "").trim(),
1779
+ offset: currentByteOffset
1780
+ });
1781
+ }
1782
+ currentByteOffset += Buffer.byteLength(lines[i] + "\n", "utf-8");
1783
+ }
1784
+ for (let e = 0; e < entryStarts.length; e++) {
1785
+ const start = entryStarts[e];
1786
+ const nextStart = entryStarts[e + 1];
1787
+ const bodyStartLine = start.lineIdx + 1;
1788
+ const bodyEndLine = nextStart ? nextStart.lineIdx : lines.length;
1789
+ const bodyLines = lines.slice(bodyStartLine, bodyEndLine);
1790
+ let bodyType = null;
1791
+ const filteredBody = [];
1792
+ for (const line of bodyLines) {
1793
+ const typeMatch = line.match(/^\*\*type:\*\*\s*(.+)/i);
1794
+ if (typeMatch && !bodyType) {
1795
+ bodyType = typeMatch[1].trim();
1796
+ } else {
1797
+ filteredBody.push(line);
1798
+ }
1799
+ }
1800
+ const body = filteredBody.join("\n").trim();
1801
+ if (!body) {
1802
+ errors.push({ line: start.lineIdx + 1, message: "Markdown entry has empty body" });
1803
+ continue;
1804
+ }
1805
+ const rawType = bodyType ?? start.headingType ?? "event";
1806
+ const resolvedType = normalizeType(rawType) ?? "event";
1807
+ const id = generateEntryId(start.timestamp, agentName, body);
1808
+ entries.push({
1809
+ id,
1810
+ type: resolvedType,
1811
+ timestamp: start.timestamp,
1812
+ body
1813
+ });
1814
+ }
1815
+ return { entries, errors, lastOffset: currentByteOffset };
1816
+ }
1817
+ function generateEntryId(timestamp, agentName, body) {
1818
+ const hash = (0, import_node_crypto4.createHash)("sha256").update(body).digest("hex").slice(0, 6);
1819
+ return `${timestamp}-${agentName.toLowerCase()}-${hash}`;
1820
+ }
1821
+ function validateEntryId(id, body) {
1822
+ const parts = id.split("-");
1823
+ if (parts.length < 4) return false;
1824
+ const hash = parts[parts.length - 1];
1825
+ if (hash.length !== 6) return false;
1826
+ const expectedHash = (0, import_node_crypto4.createHash)("sha256").update(body).digest("hex").slice(0, 6);
1827
+ return hash === expectedHash;
1828
+ }
1829
+ function normalizeType(raw) {
1830
+ const lower = raw.toLowerCase().trim();
1831
+ if (VALID_TYPES.has(lower)) return lower;
1832
+ const normalized = lower.replace(/-/g, "_");
1833
+ if (TYPE_ALIASES[normalized]) return TYPE_ALIASES[normalized];
1834
+ for (const alias of Object.keys(TYPE_ALIASES)) {
1835
+ if (normalized.startsWith(alias)) return TYPE_ALIASES[alias];
1836
+ }
1837
+ return null;
1838
+ }
1839
+
1840
+ // src/session/apply.ts
1841
+ var TABLE_NAME_RE2 = /^[a-zA-Z0-9_]+$/;
1842
+ var FIELD_NAME_RE3 = /^[a-zA-Z0-9_]+$/;
1843
+ function applyWriteEntry(db, entry) {
1844
+ const { op, table, target, fields } = entry;
1845
+ if (!TABLE_NAME_RE2.test(table)) {
1846
+ return { ok: false, reason: `Invalid table name: "${table}". Only [a-zA-Z0-9_] allowed` };
1847
+ }
1848
+ const tableExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table);
1849
+ if (!tableExists) {
1850
+ return { ok: false, reason: `Unknown table: "${table}"` };
1851
+ }
1852
+ const columnRows = db.prepare(`PRAGMA table_info("${table}")`).all();
1853
+ const knownColumns = new Set(columnRows.map((r) => r.name));
1854
+ for (const fieldName of Object.keys(fields)) {
1855
+ if (!FIELD_NAME_RE3.test(fieldName)) {
1856
+ return { ok: false, reason: `Invalid field name: "${fieldName}". Only [a-zA-Z0-9_] allowed` };
1857
+ }
1858
+ if (!knownColumns.has(fieldName)) {
1859
+ return { ok: false, reason: `Unknown field "${fieldName}" in table "${table}"` };
1860
+ }
1861
+ }
1862
+ try {
1863
+ let recordId;
1864
+ if (op === "create") {
1865
+ const id = fields["id"] ?? crypto.randomUUID();
1866
+ const allFields = { ...fields, id };
1867
+ const cols = Object.keys(allFields).map((c) => `"${c}"`).join(", ");
1868
+ const placeholders = Object.keys(allFields).map(() => "?").join(", ");
1869
+ db.prepare(`INSERT INTO "${table}" (${cols}) VALUES (${placeholders})`).run(...Object.values(allFields));
1870
+ recordId = id;
1871
+ } else if (op === "update") {
1872
+ if (!target) {
1873
+ return { ok: false, reason: 'Field "target" is required for op "update"' };
1874
+ }
1875
+ const pkCol = columnRows.find((r) => r.name === "id") ? "id" : columnRows[0]?.name ?? "id";
1876
+ const setCols = Object.keys(fields).map((c) => `"${c}" = ?`).join(", ");
1877
+ db.prepare(`UPDATE "${table}" SET ${setCols} WHERE "${pkCol}" = ?`).run(...Object.values(fields), target);
1878
+ recordId = target;
1879
+ } else {
1880
+ if (!target) {
1881
+ return { ok: false, reason: 'Field "target" is required for op "delete"' };
1882
+ }
1883
+ const pkCol = columnRows.find((r) => r.name === "id") ? "id" : columnRows[0]?.name ?? "id";
1884
+ if (knownColumns.has("deleted_at")) {
1885
+ db.prepare(`UPDATE "${table}" SET deleted_at = datetime('now') WHERE "${pkCol}" = ?`).run(target);
1886
+ } else {
1887
+ db.prepare(`DELETE FROM "${table}" WHERE "${pkCol}" = ?`).run(target);
1888
+ }
1889
+ recordId = target;
1890
+ }
1891
+ return { ok: true, table, recordId };
1892
+ } catch (err) {
1893
+ const message = err instanceof Error ? err.message : String(err);
1894
+ return { ok: false, reason: `DB error: ${message}` };
1895
+ }
1896
+ }
1897
+
1898
+ // src/session/constants.ts
1899
+ var READ_ONLY_HEADER = `<!-- READ ONLY \u2014 generated by lattice-sync. Do not edit directly.
1900
+ To update data in Lattice: write entries to SESSION.md in this directory.
1901
+ Format: type: write | op: create/update/delete | table: <name> | target: <id>
1902
+ See agents/shared/SESSION-FORMAT.md for the full spec. -->
1903
+
1904
+ `;
1506
1905
  // Annotate the CommonJS export names for ESM import in node:
1507
1906
  0 && (module.exports = {
1508
1907
  Lattice,
1908
+ READ_ONLY_HEADER,
1909
+ applyWriteEntry,
1910
+ generateEntryId,
1911
+ generateWriteEntryId,
1509
1912
  manifestPath,
1510
1913
  parseConfigFile,
1511
1914
  parseConfigString,
1915
+ parseMarkdownEntries,
1916
+ parseSessionMD,
1917
+ parseSessionWrites,
1512
1918
  readManifest,
1919
+ validateEntryId,
1513
1920
  writeManifest
1514
1921
  });
package/dist/index.d.cts CHANGED
@@ -866,4 +866,134 @@ declare function parseConfigFile(configPath: string): ParsedConfig;
866
866
  */
867
867
  declare function parseConfigString(yamlContent: string, configDir: string): ParsedConfig;
868
868
 
869
- export { type AuditEvent, type BelongsToRelation, type BelongsToSource, type BuiltinTemplateName, type CleanupOptions, type CleanupResult, type CountOptions, type CustomSource, type EntityContextDefinition, type EntityContextManifestEntry, type EntityFileSource, type EntityFileSpec, type Filter, type FilterOp, type HasManyRelation, type HasManySource, type InitOptions, Lattice, type LatticeConfig, type LatticeConfigInput, type LatticeEntityDef, type LatticeEntityRenderSpec, type LatticeFieldDef, type LatticeFieldType, type LatticeManifest, type LatticeOptions, type ManyToManySource, type Migration, type MultiTableDefinition, type ParsedConfig, type PkLookup, type PrimaryKey, type QueryOptions, type ReconcileOptions, type ReconcileResult, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type Row, type SecurityOptions, type SelfSource, type StopFn, type SyncResult, type TableDefinition, type TemplateRenderSpec, type WatchOptions, type WritebackDefinition, manifestPath, parseConfigFile, parseConfigString, readManifest, writeManifest };
869
+ type SessionWriteOp = 'create' | 'update' | 'delete';
870
+ interface SessionWriteEntry {
871
+ id: string;
872
+ timestamp: string;
873
+ op: SessionWriteOp;
874
+ table: string;
875
+ target?: string;
876
+ reason?: string;
877
+ fields: Record<string, string>;
878
+ }
879
+ interface SessionWriteParseResult {
880
+ entries: SessionWriteEntry[];
881
+ errors: Array<{
882
+ line: number;
883
+ message: string;
884
+ }>;
885
+ }
886
+ /**
887
+ * Generate a deterministic entry ID from the given parameters.
888
+ * Uses sha256 of `{op}:{table}:{target ?? ''}:{timestamp}` with the format
889
+ * `{timestamp}-{agentName}-{6-char-hash}`.
890
+ */
891
+ declare function generateWriteEntryId(timestamp: string, agentName: string, op: string, table: string, target?: string): string;
892
+ /**
893
+ * Parse all `type: write` entries from a SESSION.md file.
894
+ * Non-write entries are silently skipped.
895
+ */
896
+ declare function parseSessionWrites(content: string): SessionWriteParseResult;
897
+
898
+ /**
899
+ * A single parsed SESSION.md entry. Covers all entry types:
900
+ * event, learning, status, correction, discovery, metric, handoff, write.
901
+ *
902
+ * When `type === 'write'`, the op/table/target/reason/fields fields are set.
903
+ */
904
+ interface SessionEntry {
905
+ id: string;
906
+ type: string;
907
+ timestamp: string;
908
+ body: string;
909
+ project?: string;
910
+ task?: string;
911
+ tags?: string[];
912
+ severity?: string;
913
+ target_agent?: string;
914
+ target_table?: string;
915
+ /** Only present when type === 'write' */
916
+ op?: 'create' | 'update' | 'delete';
917
+ table?: string;
918
+ target?: string;
919
+ reason?: string;
920
+ fields?: Record<string, string>;
921
+ }
922
+ interface ParseError {
923
+ line: number;
924
+ message: string;
925
+ }
926
+ interface ParseResult {
927
+ entries: SessionEntry[];
928
+ errors: ParseError[];
929
+ /** Byte offset after the last fully parsed entry — used for incremental parsing. */
930
+ lastOffset: number;
931
+ }
932
+ /**
933
+ * Parse SESSION.md YAML-delimited entries starting at `startOffset` bytes.
934
+ *
935
+ * Each entry looks like:
936
+ * ```
937
+ * ---
938
+ * id: 2026-03-12T15:30:42Z-cortex-a1b2c3
939
+ * type: event
940
+ * timestamp: 2026-03-12T15:30:42Z
941
+ * ---
942
+ * Entry body text here.
943
+ * ===
944
+ * ```
945
+ */
946
+ declare function parseSessionMD(content: string, startOffset?: number): ParseResult;
947
+ /**
948
+ * Parse free-form Markdown SESSION.md entries. Agents sometimes write entries
949
+ * as `## {timestamp} — {description}` headings rather than YAML blocks.
950
+ *
951
+ * Runs alongside `parseSessionMD`; the two parsers are merged by caller.
952
+ */
953
+ declare function parseMarkdownEntries(content: string, agentName: string, startOffset?: number): ParseResult;
954
+ /**
955
+ * Generate a content-addressed entry ID.
956
+ * Format: `{timestamp}-{agentName}-{6-char-sha256-prefix}`
957
+ */
958
+ declare function generateEntryId(timestamp: string, agentName: string, body: string): string;
959
+ /**
960
+ * Validate that an entry ID's hash suffix matches its body.
961
+ */
962
+ declare function validateEntryId(id: string, body: string): boolean;
963
+
964
+ type ApplyWriteResult = {
965
+ ok: true;
966
+ table: string;
967
+ recordId: string;
968
+ } | {
969
+ ok: false;
970
+ reason: string;
971
+ };
972
+ /**
973
+ * Apply a SESSION.md write entry to a better-sqlite3 database.
974
+ *
975
+ * Validates:
976
+ * - Table and field names match `[a-zA-Z0-9_]` (SQL injection prevention)
977
+ * - Table exists in `sqlite_master`
978
+ * - All field names are present in the table schema (`PRAGMA table_info`)
979
+ * - `target` is provided for `update` and `delete` ops
980
+ *
981
+ * For `delete`: uses a soft-delete (`deleted_at = datetime('now')`) if the
982
+ * column exists, otherwise performs a hard `DELETE`.
983
+ *
984
+ * Returns `{ ok: true, table, recordId }` on success, or
985
+ * `{ ok: false, reason }` if validation or the DB operation fails.
986
+ * The caller is responsible for logging and audit events.
987
+ */
988
+ declare function applyWriteEntry(db: Database.Database, entry: SessionWriteEntry): ApplyWriteResult;
989
+
990
+ /**
991
+ * Read-only header prepended to all Lattice-generated context files.
992
+ *
993
+ * Directs agents to SESSION.md for writes rather than editing generated files
994
+ * directly. Include at the top of every rendered markdown context file so
995
+ * agents see it at the start of their context window.
996
+ */
997
+ declare const READ_ONLY_HEADER = "<!-- READ ONLY \u2014 generated by lattice-sync. Do not edit directly.\n To update data in Lattice: write entries to SESSION.md in this directory.\n Format: type: write | op: create/update/delete | table: <name> | target: <id>\n See agents/shared/SESSION-FORMAT.md for the full spec. -->\n\n";
998
+
999
+ export { type ApplyWriteResult, type AuditEvent, type BelongsToRelation, type BelongsToSource, type BuiltinTemplateName, type CleanupOptions, type CleanupResult, type CountOptions, type CustomSource, type EntityContextDefinition, type EntityContextManifestEntry, type EntityFileSource, type EntityFileSpec, type Filter, type FilterOp, type HasManyRelation, type HasManySource, type InitOptions, Lattice, type LatticeConfig, type LatticeConfigInput, type LatticeEntityDef, type LatticeEntityRenderSpec, type LatticeFieldDef, type LatticeFieldType, type LatticeManifest, type LatticeOptions, type ManyToManySource, type Migration, type MultiTableDefinition, type ParseError, type ParseResult, type ParsedConfig, type PkLookup, type PrimaryKey, type QueryOptions, READ_ONLY_HEADER, type ReconcileOptions, type ReconcileResult, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type Row, type SecurityOptions, type SelfSource, type SessionEntry, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type StopFn, type SyncResult, type TableDefinition, type TemplateRenderSpec, type WatchOptions, type WritebackDefinition, applyWriteEntry, generateEntryId, generateWriteEntryId, manifestPath, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, readManifest, validateEntryId, writeManifest };
package/dist/index.d.ts CHANGED
@@ -866,4 +866,134 @@ declare function parseConfigFile(configPath: string): ParsedConfig;
866
866
  */
867
867
  declare function parseConfigString(yamlContent: string, configDir: string): ParsedConfig;
868
868
 
869
- export { type AuditEvent, type BelongsToRelation, type BelongsToSource, type BuiltinTemplateName, type CleanupOptions, type CleanupResult, type CountOptions, type CustomSource, type EntityContextDefinition, type EntityContextManifestEntry, type EntityFileSource, type EntityFileSpec, type Filter, type FilterOp, type HasManyRelation, type HasManySource, type InitOptions, Lattice, type LatticeConfig, type LatticeConfigInput, type LatticeEntityDef, type LatticeEntityRenderSpec, type LatticeFieldDef, type LatticeFieldType, type LatticeManifest, type LatticeOptions, type ManyToManySource, type Migration, type MultiTableDefinition, type ParsedConfig, type PkLookup, type PrimaryKey, type QueryOptions, type ReconcileOptions, type ReconcileResult, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type Row, type SecurityOptions, type SelfSource, type StopFn, type SyncResult, type TableDefinition, type TemplateRenderSpec, type WatchOptions, type WritebackDefinition, manifestPath, parseConfigFile, parseConfigString, readManifest, writeManifest };
869
+ type SessionWriteOp = 'create' | 'update' | 'delete';
870
+ interface SessionWriteEntry {
871
+ id: string;
872
+ timestamp: string;
873
+ op: SessionWriteOp;
874
+ table: string;
875
+ target?: string;
876
+ reason?: string;
877
+ fields: Record<string, string>;
878
+ }
879
+ interface SessionWriteParseResult {
880
+ entries: SessionWriteEntry[];
881
+ errors: Array<{
882
+ line: number;
883
+ message: string;
884
+ }>;
885
+ }
886
+ /**
887
+ * Generate a deterministic entry ID from the given parameters.
888
+ * Uses sha256 of `{op}:{table}:{target ?? ''}:{timestamp}` with the format
889
+ * `{timestamp}-{agentName}-{6-char-hash}`.
890
+ */
891
+ declare function generateWriteEntryId(timestamp: string, agentName: string, op: string, table: string, target?: string): string;
892
+ /**
893
+ * Parse all `type: write` entries from a SESSION.md file.
894
+ * Non-write entries are silently skipped.
895
+ */
896
+ declare function parseSessionWrites(content: string): SessionWriteParseResult;
897
+
898
+ /**
899
+ * A single parsed SESSION.md entry. Covers all entry types:
900
+ * event, learning, status, correction, discovery, metric, handoff, write.
901
+ *
902
+ * When `type === 'write'`, the op/table/target/reason/fields fields are set.
903
+ */
904
+ interface SessionEntry {
905
+ id: string;
906
+ type: string;
907
+ timestamp: string;
908
+ body: string;
909
+ project?: string;
910
+ task?: string;
911
+ tags?: string[];
912
+ severity?: string;
913
+ target_agent?: string;
914
+ target_table?: string;
915
+ /** Only present when type === 'write' */
916
+ op?: 'create' | 'update' | 'delete';
917
+ table?: string;
918
+ target?: string;
919
+ reason?: string;
920
+ fields?: Record<string, string>;
921
+ }
922
+ interface ParseError {
923
+ line: number;
924
+ message: string;
925
+ }
926
+ interface ParseResult {
927
+ entries: SessionEntry[];
928
+ errors: ParseError[];
929
+ /** Byte offset after the last fully parsed entry — used for incremental parsing. */
930
+ lastOffset: number;
931
+ }
932
+ /**
933
+ * Parse SESSION.md YAML-delimited entries starting at `startOffset` bytes.
934
+ *
935
+ * Each entry looks like:
936
+ * ```
937
+ * ---
938
+ * id: 2026-03-12T15:30:42Z-cortex-a1b2c3
939
+ * type: event
940
+ * timestamp: 2026-03-12T15:30:42Z
941
+ * ---
942
+ * Entry body text here.
943
+ * ===
944
+ * ```
945
+ */
946
+ declare function parseSessionMD(content: string, startOffset?: number): ParseResult;
947
+ /**
948
+ * Parse free-form Markdown SESSION.md entries. Agents sometimes write entries
949
+ * as `## {timestamp} — {description}` headings rather than YAML blocks.
950
+ *
951
+ * Runs alongside `parseSessionMD`; the two parsers are merged by caller.
952
+ */
953
+ declare function parseMarkdownEntries(content: string, agentName: string, startOffset?: number): ParseResult;
954
+ /**
955
+ * Generate a content-addressed entry ID.
956
+ * Format: `{timestamp}-{agentName}-{6-char-sha256-prefix}`
957
+ */
958
+ declare function generateEntryId(timestamp: string, agentName: string, body: string): string;
959
+ /**
960
+ * Validate that an entry ID's hash suffix matches its body.
961
+ */
962
+ declare function validateEntryId(id: string, body: string): boolean;
963
+
964
+ type ApplyWriteResult = {
965
+ ok: true;
966
+ table: string;
967
+ recordId: string;
968
+ } | {
969
+ ok: false;
970
+ reason: string;
971
+ };
972
+ /**
973
+ * Apply a SESSION.md write entry to a better-sqlite3 database.
974
+ *
975
+ * Validates:
976
+ * - Table and field names match `[a-zA-Z0-9_]` (SQL injection prevention)
977
+ * - Table exists in `sqlite_master`
978
+ * - All field names are present in the table schema (`PRAGMA table_info`)
979
+ * - `target` is provided for `update` and `delete` ops
980
+ *
981
+ * For `delete`: uses a soft-delete (`deleted_at = datetime('now')`) if the
982
+ * column exists, otherwise performs a hard `DELETE`.
983
+ *
984
+ * Returns `{ ok: true, table, recordId }` on success, or
985
+ * `{ ok: false, reason }` if validation or the DB operation fails.
986
+ * The caller is responsible for logging and audit events.
987
+ */
988
+ declare function applyWriteEntry(db: Database.Database, entry: SessionWriteEntry): ApplyWriteResult;
989
+
990
+ /**
991
+ * Read-only header prepended to all Lattice-generated context files.
992
+ *
993
+ * Directs agents to SESSION.md for writes rather than editing generated files
994
+ * directly. Include at the top of every rendered markdown context file so
995
+ * agents see it at the start of their context window.
996
+ */
997
+ declare const READ_ONLY_HEADER = "<!-- READ ONLY \u2014 generated by lattice-sync. Do not edit directly.\n To update data in Lattice: write entries to SESSION.md in this directory.\n Format: type: write | op: create/update/delete | table: <name> | target: <id>\n See agents/shared/SESSION-FORMAT.md for the full spec. -->\n\n";
998
+
999
+ export { type ApplyWriteResult, type AuditEvent, type BelongsToRelation, type BelongsToSource, type BuiltinTemplateName, type CleanupOptions, type CleanupResult, type CountOptions, type CustomSource, type EntityContextDefinition, type EntityContextManifestEntry, type EntityFileSource, type EntityFileSpec, type Filter, type FilterOp, type HasManyRelation, type HasManySource, type InitOptions, Lattice, type LatticeConfig, type LatticeConfigInput, type LatticeEntityDef, type LatticeEntityRenderSpec, type LatticeFieldDef, type LatticeFieldType, type LatticeManifest, type LatticeOptions, type ManyToManySource, type Migration, type MultiTableDefinition, type ParseError, type ParseResult, type ParsedConfig, type PkLookup, type PrimaryKey, type QueryOptions, READ_ONLY_HEADER, type ReconcileOptions, type ReconcileResult, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type Row, type SecurityOptions, type SelfSource, type SessionEntry, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type StopFn, type SyncResult, type TableDefinition, type TemplateRenderSpec, type WatchOptions, type WritebackDefinition, applyWriteEntry, generateEntryId, generateWriteEntryId, manifestPath, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, readManifest, validateEntryId, writeManifest };
package/dist/index.js CHANGED
@@ -189,12 +189,22 @@ var SchemaManager = class {
189
189
  }
190
190
  }
191
191
  }
192
- /** Query all rows from a registered table */
192
+ /**
193
+ * Query all rows from a table.
194
+ * Registered tables (via `define()`) are queried directly.
195
+ * Tables used only in entity contexts (schema managed externally) fall back
196
+ * to a raw SELECT with optional `deleted_at IS NULL` soft-delete filtering.
197
+ */
193
198
  queryTable(adapter, name) {
194
- if (!this._tables.has(name)) {
195
- throw new Error(`Unknown table: "${name}"`);
199
+ if (this._tables.has(name)) {
200
+ return adapter.all(`SELECT * FROM "${name}"`);
201
+ }
202
+ if (this._entityContexts.has(name)) {
203
+ const cols = adapter.all(`PRAGMA table_info("${name}")`);
204
+ const hasDeletedAt = cols.some((c) => c.name === "deleted_at");
205
+ return adapter.all(`SELECT * FROM "${name}"${hasDeletedAt ? " WHERE deleted_at IS NULL" : ""}`);
196
206
  }
197
- return adapter.all(`SELECT * FROM "${name}"`);
207
+ throw new Error(`Unknown table: "${name}"`);
198
208
  }
199
209
  _ensureTable(adapter, name, columns, tableConstraints) {
200
210
  const colDefs = Object.entries(columns).map(([col, type]) => `"${col}" ${type}`).join(", ");
@@ -1462,11 +1472,400 @@ var Lattice = class {
1462
1472
  }
1463
1473
  }
1464
1474
  };
1475
+
1476
+ // src/session/parser.ts
1477
+ import { createHash as createHash2 } from "crypto";
1478
+ function generateWriteEntryId(timestamp, agentName, op, table, target) {
1479
+ const payload = `${op}:${table}:${target ?? ""}:${timestamp}`;
1480
+ const hash = createHash2("sha256").update(payload).digest("hex").slice(0, 6);
1481
+ return `${timestamp}-${agentName}-${hash}`;
1482
+ }
1483
+ function parseSessionWrites(content) {
1484
+ const entries = [];
1485
+ const errors = [];
1486
+ const blocks = splitIntoBlocks(content);
1487
+ for (const block of blocks) {
1488
+ const result = parseBlock(block);
1489
+ if (result === null) continue;
1490
+ if ("error" in result) {
1491
+ errors.push(result.error);
1492
+ } else {
1493
+ entries.push(result.entry);
1494
+ }
1495
+ }
1496
+ return { entries, errors };
1497
+ }
1498
+ function splitIntoBlocks(content) {
1499
+ const lines = content.split("\n");
1500
+ const blocks = [];
1501
+ let i = 0;
1502
+ while (i < lines.length) {
1503
+ while (i < lines.length && lines[i]?.trim() !== "---") {
1504
+ i++;
1505
+ }
1506
+ if (i >= lines.length) break;
1507
+ const startLine = i + 1;
1508
+ i++;
1509
+ const headerLines = [];
1510
+ while (i < lines.length && lines[i]?.trim() !== "---") {
1511
+ headerLines.push(lines[i] ?? "");
1512
+ i++;
1513
+ }
1514
+ if (i >= lines.length) break;
1515
+ i++;
1516
+ const bodyLines = [];
1517
+ while (i < lines.length && lines[i]?.trim() !== "===") {
1518
+ bodyLines.push(lines[i] ?? "");
1519
+ i++;
1520
+ }
1521
+ if (i < lines.length) i++;
1522
+ blocks.push({ startLine, headerLines, bodyLines });
1523
+ }
1524
+ return blocks;
1525
+ }
1526
+ var TABLE_NAME_RE = /^[a-zA-Z0-9_]+$/;
1527
+ var FIELD_NAME_RE = /^[a-zA-Z0-9_]+$/;
1528
+ var KEY_VALUE_RE = /^([^:]+):\s*(.*)$/;
1529
+ function parseBlock(block) {
1530
+ const header = {};
1531
+ for (const line2 of block.headerLines) {
1532
+ const m = KEY_VALUE_RE.exec(line2);
1533
+ if (m) {
1534
+ header[m[1].trim()] = m[2].trim();
1535
+ }
1536
+ }
1537
+ if (header["type"] !== "write") return null;
1538
+ const line = block.startLine;
1539
+ const timestamp = header["timestamp"];
1540
+ if (!timestamp) {
1541
+ return { error: { line, message: "Missing required field: timestamp" } };
1542
+ }
1543
+ const rawOp = header["op"];
1544
+ if (!rawOp) {
1545
+ return { error: { line, message: "Missing required field: op" } };
1546
+ }
1547
+ if (rawOp !== "create" && rawOp !== "update" && rawOp !== "delete") {
1548
+ return { error: { line, message: `Invalid op: "${rawOp}". Must be create, update, or delete` } };
1549
+ }
1550
+ const op = rawOp;
1551
+ const table = header["table"];
1552
+ if (!table) {
1553
+ return { error: { line, message: "Missing required field: table" } };
1554
+ }
1555
+ if (!TABLE_NAME_RE.test(table)) {
1556
+ return { error: { line, message: `Invalid table name: "${table}". Only [a-zA-Z0-9_] allowed` } };
1557
+ }
1558
+ const target = header["target"] || void 0;
1559
+ if ((op === "update" || op === "delete") && !target) {
1560
+ return { error: { line, message: `Field "target" is required for op "${op}"` } };
1561
+ }
1562
+ const reason = header["reason"] || void 0;
1563
+ const fields = {};
1564
+ if (op !== "delete") {
1565
+ for (const line2 of block.bodyLines) {
1566
+ const m = KEY_VALUE_RE.exec(line2);
1567
+ if (!m) continue;
1568
+ const key = m[1].trim();
1569
+ const value = m[2].trim();
1570
+ if (!FIELD_NAME_RE.test(key)) continue;
1571
+ fields[key] = value;
1572
+ }
1573
+ }
1574
+ const id = header["id"] ?? generateWriteEntryId(timestamp, "agent", op, table, target);
1575
+ const entry = {
1576
+ id,
1577
+ timestamp,
1578
+ op,
1579
+ table,
1580
+ fields,
1581
+ ...target !== void 0 ? { target } : {},
1582
+ ...reason !== void 0 ? { reason } : {}
1583
+ };
1584
+ return { entry };
1585
+ }
1586
+
1587
+ // src/session/entries.ts
1588
+ import { createHash as createHash3 } from "crypto";
1589
+ var VALID_TYPES = /* @__PURE__ */ new Set([
1590
+ "event",
1591
+ "learning",
1592
+ "status",
1593
+ "correction",
1594
+ "discovery",
1595
+ "metric",
1596
+ "handoff",
1597
+ "write"
1598
+ ]);
1599
+ var TYPE_ALIASES = {
1600
+ task_completion: "event",
1601
+ completion: "event",
1602
+ heartbeat: "status",
1603
+ bug: "discovery",
1604
+ fix: "event",
1605
+ deploy: "event",
1606
+ note: "event"
1607
+ };
1608
+ var FIELD_NAME_RE2 = /^[a-zA-Z0-9_]+$/;
1609
+ function parseSessionMD(content, startOffset = 0) {
1610
+ const entries = [];
1611
+ const errors = [];
1612
+ const text = content.slice(startOffset);
1613
+ const lines = text.split("\n");
1614
+ let i = 0;
1615
+ let currentByteOffset = startOffset;
1616
+ while (i < lines.length) {
1617
+ if (lines[i].trim() !== "---") {
1618
+ currentByteOffset += Buffer.byteLength(lines[i] + "\n", "utf-8");
1619
+ i++;
1620
+ continue;
1621
+ }
1622
+ const entryStartLine = i;
1623
+ currentByteOffset += Buffer.byteLength(lines[i] + "\n", "utf-8");
1624
+ i++;
1625
+ const headers = {};
1626
+ let foundHeaderClose = false;
1627
+ while (i < lines.length) {
1628
+ const line = lines[i];
1629
+ currentByteOffset += Buffer.byteLength(line + "\n", "utf-8");
1630
+ if (line.trim() === "---") {
1631
+ foundHeaderClose = true;
1632
+ i++;
1633
+ break;
1634
+ }
1635
+ const match = line.match(/^(\w+):\s*(.+)$/);
1636
+ if (match) {
1637
+ headers[match[1]] = match[2].trim();
1638
+ }
1639
+ i++;
1640
+ }
1641
+ if (!foundHeaderClose) {
1642
+ errors.push({ line: entryStartLine + 1, message: "Entry header never closed (missing ---)" });
1643
+ break;
1644
+ }
1645
+ const bodyLines = [];
1646
+ while (i < lines.length) {
1647
+ const line = lines[i];
1648
+ if (line.trim() === "===") {
1649
+ currentByteOffset += Buffer.byteLength(line + "\n", "utf-8");
1650
+ i++;
1651
+ break;
1652
+ }
1653
+ if (line.trim() === "---" && bodyLines.length > 0) {
1654
+ const nextLine = lines[i + 1];
1655
+ if (nextLine && /^\w+:\s*.+$/.test(nextLine)) {
1656
+ break;
1657
+ }
1658
+ }
1659
+ bodyLines.push(line);
1660
+ currentByteOffset += Buffer.byteLength(line + "\n", "utf-8");
1661
+ i++;
1662
+ }
1663
+ const body = bodyLines.join("\n").trim();
1664
+ const rawType = headers["type"] ?? "";
1665
+ const resolvedType = normalizeType(rawType);
1666
+ if (!resolvedType) {
1667
+ errors.push({ line: entryStartLine + 1, message: `Unknown entry type: ${rawType}` });
1668
+ continue;
1669
+ }
1670
+ if (!headers["timestamp"]) {
1671
+ errors.push({ line: entryStartLine + 1, message: "Missing required header: timestamp" });
1672
+ continue;
1673
+ }
1674
+ if (!body) {
1675
+ errors.push({ line: entryStartLine + 1, message: "Entry has empty body" });
1676
+ continue;
1677
+ }
1678
+ const entryId = headers["id"] ?? generateEntryId(headers["timestamp"], "agent", body);
1679
+ let tags;
1680
+ if (headers["tags"]) {
1681
+ const tagMatch = headers["tags"].match(/^\[(.+)\]$/);
1682
+ if (tagMatch) {
1683
+ tags = tagMatch[1].split(",").map((t) => t.trim());
1684
+ }
1685
+ }
1686
+ let writeFields;
1687
+ if (resolvedType === "write" && headers["op"] !== "delete") {
1688
+ writeFields = {};
1689
+ for (const line of body.split("\n")) {
1690
+ const m = line.match(/^([^:]+):\s*(.*)$/);
1691
+ if (!m) continue;
1692
+ const key = m[1].trim();
1693
+ if (!FIELD_NAME_RE2.test(key)) continue;
1694
+ writeFields[key] = m[2].trim();
1695
+ }
1696
+ }
1697
+ const newEntry = { id: entryId, type: resolvedType, timestamp: headers["timestamp"], body };
1698
+ if (headers["project"]) newEntry.project = headers["project"];
1699
+ if (headers["task"]) newEntry.task = headers["task"];
1700
+ if (tags) newEntry.tags = tags;
1701
+ if (headers["severity"]) newEntry.severity = headers["severity"];
1702
+ if (headers["target_agent"]) newEntry.target_agent = headers["target_agent"];
1703
+ if (headers["target_table"]) newEntry.target_table = headers["target_table"];
1704
+ if (resolvedType === "write") {
1705
+ if (headers["op"]) newEntry.op = headers["op"];
1706
+ if (headers["table"]) newEntry.table = headers["table"];
1707
+ if (headers["target"]) newEntry.target = headers["target"];
1708
+ if (headers["reason"]) newEntry.reason = headers["reason"];
1709
+ newEntry.fields = writeFields ?? {};
1710
+ }
1711
+ entries.push(newEntry);
1712
+ }
1713
+ return { entries, errors, lastOffset: currentByteOffset };
1714
+ }
1715
+ function parseMarkdownEntries(content, agentName, startOffset = 0) {
1716
+ const entries = [];
1717
+ const errors = [];
1718
+ const text = content.slice(startOffset);
1719
+ const lines = text.split("\n");
1720
+ const headingPattern = /^##\s+([\dT:.Z-]{10,})\s*(?:[—–\-]{1,2}\s*(.+))?$/;
1721
+ let currentByteOffset = startOffset;
1722
+ const entryStarts = [];
1723
+ for (let i = 0; i < lines.length; i++) {
1724
+ const match = lines[i].match(headingPattern);
1725
+ if (match) {
1726
+ entryStarts.push({
1727
+ lineIdx: i,
1728
+ timestamp: match[1],
1729
+ headingType: (match[2] ?? "").trim(),
1730
+ offset: currentByteOffset
1731
+ });
1732
+ }
1733
+ currentByteOffset += Buffer.byteLength(lines[i] + "\n", "utf-8");
1734
+ }
1735
+ for (let e = 0; e < entryStarts.length; e++) {
1736
+ const start = entryStarts[e];
1737
+ const nextStart = entryStarts[e + 1];
1738
+ const bodyStartLine = start.lineIdx + 1;
1739
+ const bodyEndLine = nextStart ? nextStart.lineIdx : lines.length;
1740
+ const bodyLines = lines.slice(bodyStartLine, bodyEndLine);
1741
+ let bodyType = null;
1742
+ const filteredBody = [];
1743
+ for (const line of bodyLines) {
1744
+ const typeMatch = line.match(/^\*\*type:\*\*\s*(.+)/i);
1745
+ if (typeMatch && !bodyType) {
1746
+ bodyType = typeMatch[1].trim();
1747
+ } else {
1748
+ filteredBody.push(line);
1749
+ }
1750
+ }
1751
+ const body = filteredBody.join("\n").trim();
1752
+ if (!body) {
1753
+ errors.push({ line: start.lineIdx + 1, message: "Markdown entry has empty body" });
1754
+ continue;
1755
+ }
1756
+ const rawType = bodyType ?? start.headingType ?? "event";
1757
+ const resolvedType = normalizeType(rawType) ?? "event";
1758
+ const id = generateEntryId(start.timestamp, agentName, body);
1759
+ entries.push({
1760
+ id,
1761
+ type: resolvedType,
1762
+ timestamp: start.timestamp,
1763
+ body
1764
+ });
1765
+ }
1766
+ return { entries, errors, lastOffset: currentByteOffset };
1767
+ }
1768
+ function generateEntryId(timestamp, agentName, body) {
1769
+ const hash = createHash3("sha256").update(body).digest("hex").slice(0, 6);
1770
+ return `${timestamp}-${agentName.toLowerCase()}-${hash}`;
1771
+ }
1772
+ function validateEntryId(id, body) {
1773
+ const parts = id.split("-");
1774
+ if (parts.length < 4) return false;
1775
+ const hash = parts[parts.length - 1];
1776
+ if (hash.length !== 6) return false;
1777
+ const expectedHash = createHash3("sha256").update(body).digest("hex").slice(0, 6);
1778
+ return hash === expectedHash;
1779
+ }
1780
+ function normalizeType(raw) {
1781
+ const lower = raw.toLowerCase().trim();
1782
+ if (VALID_TYPES.has(lower)) return lower;
1783
+ const normalized = lower.replace(/-/g, "_");
1784
+ if (TYPE_ALIASES[normalized]) return TYPE_ALIASES[normalized];
1785
+ for (const alias of Object.keys(TYPE_ALIASES)) {
1786
+ if (normalized.startsWith(alias)) return TYPE_ALIASES[alias];
1787
+ }
1788
+ return null;
1789
+ }
1790
+
1791
+ // src/session/apply.ts
1792
+ var TABLE_NAME_RE2 = /^[a-zA-Z0-9_]+$/;
1793
+ var FIELD_NAME_RE3 = /^[a-zA-Z0-9_]+$/;
1794
+ function applyWriteEntry(db, entry) {
1795
+ const { op, table, target, fields } = entry;
1796
+ if (!TABLE_NAME_RE2.test(table)) {
1797
+ return { ok: false, reason: `Invalid table name: "${table}". Only [a-zA-Z0-9_] allowed` };
1798
+ }
1799
+ const tableExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table);
1800
+ if (!tableExists) {
1801
+ return { ok: false, reason: `Unknown table: "${table}"` };
1802
+ }
1803
+ const columnRows = db.prepare(`PRAGMA table_info("${table}")`).all();
1804
+ const knownColumns = new Set(columnRows.map((r) => r.name));
1805
+ for (const fieldName of Object.keys(fields)) {
1806
+ if (!FIELD_NAME_RE3.test(fieldName)) {
1807
+ return { ok: false, reason: `Invalid field name: "${fieldName}". Only [a-zA-Z0-9_] allowed` };
1808
+ }
1809
+ if (!knownColumns.has(fieldName)) {
1810
+ return { ok: false, reason: `Unknown field "${fieldName}" in table "${table}"` };
1811
+ }
1812
+ }
1813
+ try {
1814
+ let recordId;
1815
+ if (op === "create") {
1816
+ const id = fields["id"] ?? crypto.randomUUID();
1817
+ const allFields = { ...fields, id };
1818
+ const cols = Object.keys(allFields).map((c) => `"${c}"`).join(", ");
1819
+ const placeholders = Object.keys(allFields).map(() => "?").join(", ");
1820
+ db.prepare(`INSERT INTO "${table}" (${cols}) VALUES (${placeholders})`).run(...Object.values(allFields));
1821
+ recordId = id;
1822
+ } else if (op === "update") {
1823
+ if (!target) {
1824
+ return { ok: false, reason: 'Field "target" is required for op "update"' };
1825
+ }
1826
+ const pkCol = columnRows.find((r) => r.name === "id") ? "id" : columnRows[0]?.name ?? "id";
1827
+ const setCols = Object.keys(fields).map((c) => `"${c}" = ?`).join(", ");
1828
+ db.prepare(`UPDATE "${table}" SET ${setCols} WHERE "${pkCol}" = ?`).run(...Object.values(fields), target);
1829
+ recordId = target;
1830
+ } else {
1831
+ if (!target) {
1832
+ return { ok: false, reason: 'Field "target" is required for op "delete"' };
1833
+ }
1834
+ const pkCol = columnRows.find((r) => r.name === "id") ? "id" : columnRows[0]?.name ?? "id";
1835
+ if (knownColumns.has("deleted_at")) {
1836
+ db.prepare(`UPDATE "${table}" SET deleted_at = datetime('now') WHERE "${pkCol}" = ?`).run(target);
1837
+ } else {
1838
+ db.prepare(`DELETE FROM "${table}" WHERE "${pkCol}" = ?`).run(target);
1839
+ }
1840
+ recordId = target;
1841
+ }
1842
+ return { ok: true, table, recordId };
1843
+ } catch (err) {
1844
+ const message = err instanceof Error ? err.message : String(err);
1845
+ return { ok: false, reason: `DB error: ${message}` };
1846
+ }
1847
+ }
1848
+
1849
+ // src/session/constants.ts
1850
+ var READ_ONLY_HEADER = `<!-- READ ONLY \u2014 generated by lattice-sync. Do not edit directly.
1851
+ To update data in Lattice: write entries to SESSION.md in this directory.
1852
+ Format: type: write | op: create/update/delete | table: <name> | target: <id>
1853
+ See agents/shared/SESSION-FORMAT.md for the full spec. -->
1854
+
1855
+ `;
1465
1856
  export {
1466
1857
  Lattice,
1858
+ READ_ONLY_HEADER,
1859
+ applyWriteEntry,
1860
+ generateEntryId,
1861
+ generateWriteEntryId,
1467
1862
  manifestPath,
1468
1863
  parseConfigFile,
1469
1864
  parseConfigString,
1865
+ parseMarkdownEntries,
1866
+ parseSessionMD,
1867
+ parseSessionWrites,
1470
1868
  readManifest,
1869
+ validateEntryId,
1471
1870
  writeManifest
1472
1871
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latticesql",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Persistent structured memory for AI agent systems — SQLite ↔ LLM context bridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",