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 +139 -0
- package/dist/cli.js +281 -10
- package/dist/index.cjs +279 -4
- package/dist/index.d.cts +205 -1
- package/dist/index.d.ts +205 -1
- package/dist/index.js +283 -10
- package/package.json +1 -1
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|