latticesql 1.2.6 → 1.3.1

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
@@ -18,6 +18,8 @@ Every AI agent session starts cold — no memory of what happened yesterday, wha
18
18
  2. **Watches** for DB changes and re-renders automatically
19
19
  3. **Ingests** agent-written output back into the DB via the writeback pipeline
20
20
  4. **Manages** state with full CRUD, natural-key operations, seeding, and soft-delete
21
+ 5. **Optimizes** context with token budgets, relevance filtering, enrichment pipelines, and reward-scored memory
22
+ 6. **Searches** semantically via bring-your-own embeddings and cosine similarity
21
23
 
22
24
  Lattice has no opinions about your schema, your agents, or your file format. You define the tables. You control the rendering. Lattice runs the sync loop.
23
25
 
@@ -41,6 +43,9 @@ Lattice has no opinions about your schema, your agents, or your file format. You
41
43
  - [Render, sync, watch, and reconcile](#render-sync-watch-and-reconcile)
42
44
  - [Events](#events)
43
45
  - [Raw DB access](#raw-db-access)
46
+ - [Context optimization](#context-optimization-v13)
47
+ - [Semantic search](#semantic-search-v13)
48
+ - [Writeback validation](#writeback-validation-v13)
44
49
  - [Template rendering](#template-rendering)
45
50
  - [Built-in templates](#built-in-templates)
46
51
  - [Lifecycle hooks](#lifecycle-hooks)
@@ -1261,6 +1266,140 @@ const rows = db.db
1261
1266
 
1262
1267
  ---
1263
1268
 
1269
+ ### Context optimization (v1.3+)
1270
+
1271
+ Lattice provides several options on `TableDefinition` to optimize what gets rendered into context files.
1272
+
1273
+ #### Token budget
1274
+
1275
+ Limit the token count of rendered output. When content exceeds the budget, rows are pruned by priority:
1276
+
1277
+ ```typescript
1278
+ db.define('tickets', {
1279
+ columns: { id: 'TEXT PRIMARY KEY', title: 'TEXT', updated_at: 'TEXT' },
1280
+ render: (rows) => rows.map((r) => `- ${r.title}`).join('\n'),
1281
+ outputFile: 'TICKETS.md',
1282
+ tokenBudget: 4000, // max estimated tokens (~4 chars/token)
1283
+ prioritizeBy: 'updated_at', // keep most recent rows when pruning
1284
+ });
1285
+ ```
1286
+
1287
+ A truncation footer is appended: `[truncated: 47 of 123 rows rendered, ~3800 tokens]`
1288
+
1289
+ #### Relevance filtering
1290
+
1291
+ Dynamically filter rows based on a task context string:
1292
+
1293
+ ```typescript
1294
+ db.define('knowledge', {
1295
+ columns: { id: 'TEXT PRIMARY KEY', topic: 'TEXT', body: 'TEXT' },
1296
+ render: (rows) => rows.map((r) => `## ${r.topic}\n${r.body}`).join('\n\n'),
1297
+ outputFile: 'KNOWLEDGE.md',
1298
+ relevanceFilter: (row, ctx) =>
1299
+ ctx ? String(row.body).toLowerCase().includes(ctx.toLowerCase()) : true,
1300
+ });
1301
+
1302
+ // Set the current task context — only matching rows are rendered
1303
+ db.setTaskContext('deployment');
1304
+ await db.render('./context');
1305
+ ```
1306
+
1307
+ #### Enrichment pipeline
1308
+
1309
+ Transform rows between filtering and rendering — add computed fields, cluster, summarize:
1310
+
1311
+ ```typescript
1312
+ db.define('incidents', {
1313
+ columns: { id: 'TEXT PRIMARY KEY', severity: 'TEXT', title: 'TEXT', created_at: 'TEXT' },
1314
+ render: (rows) => JSON.stringify(rows, null, 2),
1315
+ outputFile: 'incidents.json',
1316
+ enrich: [
1317
+ (rows) => rows.map((r) => ({
1318
+ ...r,
1319
+ _age_hours: Math.round((Date.now() - new Date(r.created_at as string).getTime()) / 3600000),
1320
+ })),
1321
+ (rows) => rows.length > 100 ? [{ _summary: `${rows.length} incidents` }] : rows,
1322
+ ],
1323
+ });
1324
+ ```
1325
+
1326
+ #### Reward-scored memory
1327
+
1328
+ Track which data is useful. High-reward rows are prioritized in rendering; low-scoring rows can be auto-pruned:
1329
+
1330
+ ```typescript
1331
+ db.define('tips', {
1332
+ columns: { id: 'TEXT PRIMARY KEY', tip: 'TEXT', deleted_at: 'TEXT' },
1333
+ render: (rows) => rows.map((r) => `- ${r.tip}`).join('\n'),
1334
+ outputFile: 'TIPS.md',
1335
+ rewardTracking: true, // auto-adds _reward_total, _reward_count columns
1336
+ pruneBelow: 0.3, // soft-delete rows with reward < 0.3 (requires deleted_at column)
1337
+ });
1338
+
1339
+ await db.init();
1340
+ const id = await db.insert('tips', { tip: 'Use batch inserts for bulk data' });
1341
+
1342
+ // After the agent confirms this tip was useful:
1343
+ await db.reward('tips', id, { relevance: 0.9, accuracy: 1.0 });
1344
+ ```
1345
+
1346
+ ### Semantic search (v1.3+)
1347
+
1348
+ Enable embedding-based search on any table. Bring your own embedding function:
1349
+
1350
+ ```typescript
1351
+ db.define('docs', {
1352
+ columns: { id: 'TEXT PRIMARY KEY', title: 'TEXT', body: 'TEXT' },
1353
+ render: (rows) => rows.map((r) => `## ${r.title}\n${r.body}`).join('\n\n---\n\n'),
1354
+ outputFile: 'DOCS.md',
1355
+ embeddings: {
1356
+ fields: ['title', 'body'],
1357
+ embed: async (text) => {
1358
+ const res = await openai.embeddings.create({ input: text, model: 'text-embedding-3-small' });
1359
+ return res.data[0].embedding;
1360
+ },
1361
+ },
1362
+ });
1363
+
1364
+ await db.init();
1365
+ await db.insert('docs', { title: 'Deploy guide', body: 'How to deploy to production...' });
1366
+
1367
+ // Search by meaning, not keywords
1368
+ const results = await db.search('docs', 'ship to prod', { topK: 5, minScore: 0.7 });
1369
+ for (const { row, score } of results) {
1370
+ console.log(`${score.toFixed(2)} — ${row.title}`);
1371
+ }
1372
+ ```
1373
+
1374
+ Embeddings are stored in a companion SQLite table and cosine similarity is computed in JS — no external vector database required.
1375
+
1376
+ ### Writeback validation (v1.3+)
1377
+
1378
+ Validate agent-written data before persisting. Reject low-quality or hallucinated writes:
1379
+
1380
+ ```typescript
1381
+ db.defineWriteback({
1382
+ file: './agent-output/*.md',
1383
+ parse: (content, offset) => ({ entries: [content.slice(offset)], nextOffset: content.length }),
1384
+ persist: async (entry) => { /* save to DB */ },
1385
+ validate: async (entry) => {
1386
+ const text = entry as string;
1387
+ const hasRequiredFields = text.includes('## Title') && text.includes('## Body');
1388
+ return {
1389
+ pass: hasRequiredFields,
1390
+ score: hasRequiredFields ? 0.9 : 0.1,
1391
+ reason: hasRequiredFields ? undefined : 'Missing required sections',
1392
+ };
1393
+ },
1394
+ rejectBelow: 0.5,
1395
+ onReject: (entry, result) => {
1396
+ console.warn(`Rejected write: ${result.reason} (score: ${result.score})`);
1397
+ },
1398
+ });
1399
+ ```
1400
+
1401
+ ---
1402
+
1264
1403
  ## Template rendering
1265
1404
 
1266
1405
  ### Built-in templates
package/dist/cli.js CHANGED
@@ -321,7 +321,7 @@ import { join as join3 } from "path";
321
321
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
322
322
 
323
323
  // src/render/writer.ts
324
- import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, renameSync, existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
324
+ import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, renameSync, copyFileSync, unlinkSync, existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
325
325
  import { createHash } from "crypto";
326
326
  import { dirname as dirname3, join as join2 } from "path";
327
327
  import { tmpdir } from "os";
@@ -334,7 +334,16 @@ function atomicWrite(filePath, content) {
334
334
  if (currentHash === newHash) return false;
335
335
  const tmp = join2(tmpdir(), `lattice-${randomBytes(8).toString("hex")}.tmp`);
336
336
  writeFileSync2(tmp, content, "utf8");
337
- renameSync(tmp, filePath);
337
+ try {
338
+ renameSync(tmp, filePath);
339
+ } catch (err) {
340
+ if (err.code === "EXDEV") {
341
+ copyFileSync(tmp, filePath);
342
+ unlinkSync(tmp);
343
+ } else {
344
+ throw err;
345
+ }
346
+ }
338
347
  return true;
339
348
  }
340
349
  function existingHash(filePath) {
@@ -644,7 +653,56 @@ var Sanitizer = class {
644
653
 
645
654
  // src/render/engine.ts
646
655
  import { join as join5, basename, isAbsolute, resolve as resolve3 } from "path";
647
- import { mkdirSync as mkdirSync3, existsSync as existsSync5, copyFileSync } from "fs";
656
+ import { mkdirSync as mkdirSync3, existsSync as existsSync5, copyFileSync as copyFileSync2 } from "fs";
657
+
658
+ // src/render/token-budget.ts
659
+ function estimateTokens(text) {
660
+ return Math.ceil(text.length / 4);
661
+ }
662
+ function applyTokenBudget(rows, renderFn, budget, prioritizeBy) {
663
+ const fullContent = renderFn(rows);
664
+ if (estimateTokens(fullContent) <= budget) return fullContent;
665
+ if (rows.length === 0) return fullContent;
666
+ const prioritized = [...rows];
667
+ if (typeof prioritizeBy === "function") {
668
+ prioritized.sort(prioritizeBy);
669
+ } else if (typeof prioritizeBy === "string") {
670
+ const col = prioritizeBy;
671
+ prioritized.sort((a, b) => {
672
+ const va = a[col];
673
+ const vb = b[col];
674
+ if (va == null && vb == null) return 0;
675
+ if (va == null) return 1;
676
+ if (vb == null) return -1;
677
+ if (va < vb) return 1;
678
+ if (va > vb) return -1;
679
+ return 0;
680
+ });
681
+ }
682
+ let lo = 0;
683
+ let hi = prioritized.length;
684
+ let bestContent = "";
685
+ let bestCount = 0;
686
+ while (lo < hi) {
687
+ const mid = Math.ceil((lo + hi) / 2);
688
+ const content = renderFn(prioritized.slice(0, mid));
689
+ if (estimateTokens(content) <= budget) {
690
+ bestContent = content;
691
+ bestCount = mid;
692
+ lo = mid;
693
+ if (lo === hi) break;
694
+ } else {
695
+ hi = mid - 1;
696
+ }
697
+ }
698
+ if (bestCount === 0) {
699
+ bestContent = renderFn([]);
700
+ }
701
+ const tokens = estimateTokens(bestContent);
702
+ return bestContent + `
703
+
704
+ [truncated: ${bestCount} of ${rows.length} rows rendered, ~${tokens} tokens]`;
705
+ }
648
706
 
649
707
  // src/render/entity-query.ts
650
708
  var SAFE_COL_RE = /^[a-zA-Z0-9_]+$/;
@@ -972,7 +1030,7 @@ ${tmpl.perRow.body(row)}
972
1030
 
973
1031
  // src/lifecycle/cleanup.ts
974
1032
  import { join as join4 } from "path";
975
- import { existsSync as existsSync4, readdirSync, unlinkSync, rmdirSync, statSync } from "fs";
1033
+ import { existsSync as existsSync4, readdirSync, unlinkSync as unlinkSync2, rmdirSync, statSync } from "fs";
976
1034
  function cleanupEntityContexts(outputDir, entityContexts, currentSlugsByTable, manifest, options = {}, newManifest) {
977
1035
  const result = {
978
1036
  directoriesRemoved: [],
@@ -1014,7 +1072,7 @@ function cleanupEntityContexts(outputDir, entityContexts, currentSlugsByTable, m
1014
1072
  if (globalProtected.has(filename)) continue;
1015
1073
  const filePath = join4(entityDir, filename);
1016
1074
  if (!existsSync4(filePath)) continue;
1017
- if (!options.dryRun) unlinkSync(filePath);
1075
+ if (!options.dryRun) unlinkSync2(filePath);
1018
1076
  options.onOrphan?.(filePath, "file");
1019
1077
  result.filesRemoved.push(filePath);
1020
1078
  }
@@ -1059,7 +1117,7 @@ function cleanupEntityContexts(outputDir, entityContexts, currentSlugsByTable, m
1059
1117
  if (globalProtected.has(filename)) continue;
1060
1118
  const filePath = join4(entityDir, filename);
1061
1119
  if (!existsSync4(filePath)) continue;
1062
- if (!options.dryRun) unlinkSync(filePath);
1120
+ if (!options.dryRun) unlinkSync2(filePath);
1063
1121
  options.onOrphan?.(filePath, "file");
1064
1122
  result.filesRemoved.push(filePath);
1065
1123
  }
@@ -1073,9 +1131,11 @@ function cleanupEntityContexts(outputDir, entityContexts, currentSlugsByTable, m
1073
1131
  var RenderEngine = class {
1074
1132
  _schema;
1075
1133
  _adapter;
1076
- constructor(schema, adapter) {
1134
+ _getTaskContext;
1135
+ constructor(schema, adapter, getTaskContext) {
1077
1136
  this._schema = schema;
1078
1137
  this._adapter = adapter;
1138
+ this._getTaskContext = getTaskContext ?? (() => "");
1079
1139
  }
1080
1140
  async render(outputDir) {
1081
1141
  const start = Date.now();
@@ -1083,8 +1143,38 @@ var RenderEngine = class {
1083
1143
  const counters = { skipped: 0 };
1084
1144
  for (const [name, def] of this._schema.getTables()) {
1085
1145
  let rows = this._schema.queryTable(this._adapter, name);
1146
+ if (def.relevanceFilter) {
1147
+ const ctx = this._getTaskContext();
1148
+ rows = rows.filter((row) => def.relevanceFilter(row, ctx));
1149
+ }
1086
1150
  if (def.filter) rows = def.filter(rows);
1087
- const content = def.render(rows);
1151
+ if (def.rewardTracking) {
1152
+ if (def.pruneBelow !== void 0) {
1153
+ const threshold = def.pruneBelow;
1154
+ const toPrune = rows.filter(
1155
+ (r) => r._reward_count > 0 && r._reward_total < threshold
1156
+ );
1157
+ if (toPrune.length > 0) {
1158
+ for (const r of toPrune) {
1159
+ const pkCol = this._schema.getPrimaryKey(name)[0] ?? "id";
1160
+ this._adapter.run(
1161
+ `UPDATE "${name}" SET deleted_at = datetime('now') WHERE "${pkCol}" = ?`,
1162
+ [r[pkCol]]
1163
+ );
1164
+ }
1165
+ rows = rows.filter(
1166
+ (r) => r._reward_count === 0 || r._reward_total >= threshold
1167
+ );
1168
+ }
1169
+ }
1170
+ if (!def.prioritizeBy) {
1171
+ rows.sort((a, b) => (b._reward_total ?? 0) - (a._reward_total ?? 0));
1172
+ }
1173
+ }
1174
+ if (def.enrich) {
1175
+ for (const fn of def.enrich) rows = fn(rows);
1176
+ }
1177
+ const content = def.tokenBudget ? applyTokenBudget(rows, def.render, def.tokenBudget, def.prioritizeBy) : def.render(rows);
1088
1178
  const filePath = join5(outputDir, def.outputFile);
1089
1179
  if (atomicWrite(filePath, content)) {
1090
1180
  filesWritten.push(filePath);
@@ -1203,7 +1293,7 @@ var RenderEngine = class {
1203
1293
  const destPath = join5(entityDir, basename(absPath));
1204
1294
  if (!existsSync5(destPath)) {
1205
1295
  try {
1206
- copyFileSync(absPath, destPath);
1296
+ copyFileSync2(absPath, destPath);
1207
1297
  filesWritten.push(destPath);
1208
1298
  } catch {
1209
1299
  }
@@ -1488,6 +1578,14 @@ var WritebackPipeline = class {
1488
1578
  if (store.isSeen(filePath, key)) continue;
1489
1579
  store.markSeen(filePath, key);
1490
1580
  }
1581
+ if (def.validate) {
1582
+ const result = await def.validate(entry);
1583
+ const threshold = def.rejectBelow ?? 0;
1584
+ if (!result.pass || result.score < threshold) {
1585
+ def.onReject?.(entry, result);
1586
+ continue;
1587
+ }
1588
+ }
1491
1589
  await def.persist(entry, filePath);
1492
1590
  processed++;
1493
1591
  }
@@ -1666,6 +1764,73 @@ function resolveEncryptedColumns(encrypted, allColumns) {
1666
1764
  return new Set(allColumns.filter((c) => !SKIP_COLUMNS.has(c)));
1667
1765
  }
1668
1766
 
1767
+ // src/search/embeddings.ts
1768
+ var EMBEDDINGS_TABLE = "_lattice_embeddings";
1769
+ function ensureEmbeddingsTable(adapter) {
1770
+ adapter.run(`CREATE TABLE IF NOT EXISTS "${EMBEDDINGS_TABLE}" (
1771
+ "table_name" TEXT NOT NULL,
1772
+ "row_pk" TEXT NOT NULL,
1773
+ "embedding" TEXT NOT NULL,
1774
+ PRIMARY KEY ("table_name", "row_pk")
1775
+ )`);
1776
+ }
1777
+ async function storeEmbedding(adapter, table, pk, row, config) {
1778
+ const text = config.fields.map((f) => {
1779
+ const v = row[f];
1780
+ return v == null ? "" : String(v);
1781
+ }).filter((s) => s.length > 0).join(" ");
1782
+ if (text.length === 0) return;
1783
+ const vector = await config.embed(text);
1784
+ adapter.run(
1785
+ `INSERT OR REPLACE INTO "${EMBEDDINGS_TABLE}" ("table_name", "row_pk", "embedding") VALUES (?, ?, ?)`,
1786
+ [table, pk, JSON.stringify(vector)]
1787
+ );
1788
+ }
1789
+ function removeEmbedding(adapter, table, pk) {
1790
+ adapter.run(
1791
+ `DELETE FROM "${EMBEDDINGS_TABLE}" WHERE "table_name" = ? AND "row_pk" = ?`,
1792
+ [table, pk]
1793
+ );
1794
+ }
1795
+ function cosineSimilarity(a, b) {
1796
+ const len = Math.min(a.length, b.length);
1797
+ let dot = 0;
1798
+ let magA = 0;
1799
+ let magB = 0;
1800
+ for (let i = 0; i < len; i++) {
1801
+ dot += a[i] * b[i];
1802
+ magA += a[i] * a[i];
1803
+ magB += b[i] * b[i];
1804
+ }
1805
+ const denom = Math.sqrt(magA) * Math.sqrt(magB);
1806
+ return denom === 0 ? 0 : dot / denom;
1807
+ }
1808
+ async function searchByEmbedding(adapter, table, queryText, config, topK, minScore, pkColumn = "id") {
1809
+ const queryVector = await config.embed(queryText);
1810
+ const stored = adapter.all(
1811
+ `SELECT "row_pk", "embedding" FROM "${EMBEDDINGS_TABLE}" WHERE "table_name" = ?`,
1812
+ [table]
1813
+ );
1814
+ const scored = [];
1815
+ for (const entry of stored) {
1816
+ const vec = JSON.parse(entry.embedding);
1817
+ const score = cosineSimilarity(queryVector, vec);
1818
+ if (score >= minScore) {
1819
+ scored.push({ pk: entry.row_pk, score });
1820
+ }
1821
+ }
1822
+ scored.sort((a, b) => b.score - a.score);
1823
+ const topResults = scored.slice(0, topK);
1824
+ const results = [];
1825
+ for (const { pk, score } of topResults) {
1826
+ const row = adapter.get(`SELECT * FROM "${table}" WHERE "${pkColumn}" = ?`, [pk]);
1827
+ if (row) {
1828
+ results.push({ row, score });
1829
+ }
1830
+ }
1831
+ return results;
1832
+ }
1833
+
1669
1834
  // src/lattice.ts
1670
1835
  var Lattice = class {
1671
1836
  _adapter;
@@ -1684,6 +1849,8 @@ var Lattice = class {
1684
1849
  _encryptedTableColumns = /* @__PURE__ */ new Map();
1685
1850
  /** Raw encryption key passphrase from constructor options. */
1686
1851
  _encryptionKeyRaw;
1852
+ /** Current task context string for relevance filtering. */
1853
+ _taskContext = "";
1687
1854
  _auditHandlers = [];
1688
1855
  _renderHandlers = [];
1689
1856
  _writebackHandlers = [];
@@ -1710,7 +1877,7 @@ var Lattice = class {
1710
1877
  this._adapter = new SQLiteAdapter(dbPath, adapterOpts);
1711
1878
  this._schema = new SchemaManager();
1712
1879
  this._sanitizer = new Sanitizer(options.security);
1713
- this._render = new RenderEngine(this._schema, this._adapter);
1880
+ this._render = new RenderEngine(this._schema, this._adapter, () => this._taskContext);
1714
1881
  this._reverseSync = new ReverseSyncEngine(this._schema, this._adapter);
1715
1882
  this._loop = new SyncLoop(this._render);
1716
1883
  this._writeback = new WritebackPipeline();
@@ -1734,8 +1901,10 @@ var Lattice = class {
1734
1901
  // -------------------------------------------------------------------------
1735
1902
  define(table, def) {
1736
1903
  this._assertNotInit("define");
1904
+ const columns = def.rewardTracking ? { ...def.columns, _reward_total: "REAL DEFAULT 0", _reward_count: "INTEGER DEFAULT 0" } : def.columns;
1737
1905
  const compiledDef = {
1738
1906
  ...def,
1907
+ columns,
1739
1908
  render: def.render ? compileRender(
1740
1909
  def,
1741
1910
  table,
@@ -1781,6 +1950,10 @@ var Lattice = class {
1781
1950
  const rows = this._adapter.all(`PRAGMA table_info("${tableName}")`);
1782
1951
  this._columnCache.set(tableName, new Set(rows.map((r) => r.name)));
1783
1952
  }
1953
+ const hasEmbeddings = [...this._schema.getTables().values()].some((d) => d.embeddings);
1954
+ if (hasEmbeddings) {
1955
+ ensureEmbeddingsTable(this._adapter);
1956
+ }
1784
1957
  this._setupEncryption();
1785
1958
  this._initialized = true;
1786
1959
  return Promise.resolve();
@@ -1810,6 +1983,21 @@ var Lattice = class {
1810
1983
  this._initialized = false;
1811
1984
  }
1812
1985
  // -------------------------------------------------------------------------
1986
+ // Task context (for relevance filtering)
1987
+ // -------------------------------------------------------------------------
1988
+ /**
1989
+ * Set the current task context string. Tables with a `relevanceFilter`
1990
+ * will use this value to filter rows before rendering.
1991
+ */
1992
+ setTaskContext(context) {
1993
+ this._taskContext = context;
1994
+ return this;
1995
+ }
1996
+ /** Return the current task context string. */
1997
+ getTaskContext() {
1998
+ return this._taskContext;
1999
+ }
2000
+ // -------------------------------------------------------------------------
1813
2001
  // Encryption helpers
1814
2002
  // -------------------------------------------------------------------------
1815
2003
  _setupEncryption() {
@@ -1884,6 +2072,7 @@ var Lattice = class {
1884
2072
  const pkValue = rawPk != null ? String(rawPk) : "";
1885
2073
  this._sanitizer.emitAudit(table, "insert", pkValue);
1886
2074
  this._fireWriteHooks(table, "insert", rowWithPk, pkValue);
2075
+ this._syncEmbedding(table, "insert", rowWithPk, pkValue);
1887
2076
  return Promise.resolve(pkValue);
1888
2077
  }
1889
2078
  /**
@@ -1951,6 +2140,11 @@ var Lattice = class {
1951
2140
  const auditId = typeof id === "string" ? id : JSON.stringify(id);
1952
2141
  this._sanitizer.emitAudit(table, "update", auditId);
1953
2142
  this._fireWriteHooks(table, "update", sanitized, auditId, Object.keys(sanitized));
2143
+ const def = this._schema.getTables().get(table);
2144
+ if (def?.embeddings) {
2145
+ const fullRow = this._adapter.get(`SELECT * FROM "${table}" WHERE ${clause}`, pkParams);
2146
+ if (fullRow) this._syncEmbedding(table, "update", fullRow, auditId);
2147
+ }
1954
2148
  return Promise.resolve();
1955
2149
  }
1956
2150
  /**
@@ -1972,6 +2166,7 @@ var Lattice = class {
1972
2166
  const auditId = typeof id === "string" ? id : JSON.stringify(id);
1973
2167
  this._sanitizer.emitAudit(table, "delete", auditId);
1974
2168
  this._fireWriteHooks(table, "delete", { id: auditId }, auditId);
2169
+ this._syncEmbedding(table, "delete", {}, auditId);
1975
2170
  return Promise.resolve();
1976
2171
  }
1977
2172
  get(table, id) {
@@ -2355,6 +2550,65 @@ var Lattice = class {
2355
2550
  const ms = unit === "h" ? num * 36e5 : unit === "d" ? num * 864e5 : num * 6e4;
2356
2551
  return new Date(Date.now() - ms).toISOString();
2357
2552
  }
2553
+ // -------------------------------------------------------------------------
2554
+ // Reward tracking
2555
+ // -------------------------------------------------------------------------
2556
+ /**
2557
+ * Update reward scores for a row. The total reward is recalculated as
2558
+ * the running average across all reward calls. Requires `rewardTracking`
2559
+ * on the table definition.
2560
+ */
2561
+ reward(table, id, scores) {
2562
+ const notInit = this._notInitError();
2563
+ if (notInit) return notInit;
2564
+ const def = this._schema.getTables().get(table);
2565
+ if (!def?.rewardTracking) {
2566
+ return Promise.reject(
2567
+ new Error(`Table "${table}" does not have rewardTracking enabled`)
2568
+ );
2569
+ }
2570
+ const vals = Object.values(scores);
2571
+ if (vals.length === 0) return Promise.resolve();
2572
+ const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
2573
+ const { clause, params: pkParams } = this._pkWhere(table, id);
2574
+ this._adapter.run(
2575
+ `UPDATE "${table}" SET "_reward_total" = ("_reward_total" * "_reward_count" + ?) / ("_reward_count" + 1), "_reward_count" = "_reward_count" + 1 WHERE ${clause}`,
2576
+ [avg, ...pkParams]
2577
+ );
2578
+ return Promise.resolve();
2579
+ }
2580
+ // -------------------------------------------------------------------------
2581
+ // Semantic search
2582
+ // -------------------------------------------------------------------------
2583
+ /**
2584
+ * Search for rows by semantic similarity. Requires `embeddings` config
2585
+ * on the table definition.
2586
+ *
2587
+ * @param table - Table to search
2588
+ * @param query - Natural-language query text
2589
+ * @param opts - Search options (topK, minScore)
2590
+ * @returns Matching rows with similarity scores, sorted best-first.
2591
+ */
2592
+ async search(table, query, opts = {}) {
2593
+ const notInit = this._notInitError();
2594
+ if (notInit) return notInit;
2595
+ const def = this._schema.getTables().get(table);
2596
+ if (!def?.embeddings) {
2597
+ return Promise.reject(
2598
+ new Error(`Table "${table}" does not have embeddings configured`)
2599
+ );
2600
+ }
2601
+ const pkCol = this._schema.getPrimaryKey(table)[0] ?? "id";
2602
+ return searchByEmbedding(
2603
+ this._adapter,
2604
+ table,
2605
+ query,
2606
+ def.embeddings,
2607
+ opts.topK ?? 10,
2608
+ opts.minScore ?? 0,
2609
+ pkCol
2610
+ );
2611
+ }
2358
2612
  query(table, opts = {}) {
2359
2613
  const notInit = this._notInitError();
2360
2614
  if (notInit) return notInit;
@@ -2613,6 +2867,23 @@ var Lattice = class {
2613
2867
  }
2614
2868
  }
2615
2869
  }
2870
+ /**
2871
+ * Update or remove the embedding for a row.
2872
+ * No-op if the table doesn't have `embeddings` configured.
2873
+ */
2874
+ _syncEmbedding(table, op, row, pk) {
2875
+ const def = this._schema.getTables().get(table);
2876
+ if (!def?.embeddings) return;
2877
+ if (op === "delete") {
2878
+ removeEmbedding(this._adapter, table, pk);
2879
+ return;
2880
+ }
2881
+ storeEmbedding(this._adapter, table, pk, row, def.embeddings).catch((err) => {
2882
+ for (const h of this._errorHandlers) {
2883
+ h(err instanceof Error ? err : new Error(String(err)));
2884
+ }
2885
+ });
2886
+ }
2616
2887
  _notInitError() {
2617
2888
  if (!this._initialized) {
2618
2889
  return Promise.reject(