latticesql 0.5.5 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -606,6 +606,75 @@ import { join as join5 } from "path";
606
606
  import { mkdirSync as mkdirSync3 } from "fs";
607
607
 
608
608
  // src/render/entity-query.ts
609
+ var SAFE_COL_RE = /^[a-zA-Z0-9_]+$/;
610
+ function effectiveFilters(opts) {
611
+ const filters = opts.filters ? [...opts.filters] : [];
612
+ if (opts.softDelete && !filters.some((f) => f.col === "deleted_at")) {
613
+ filters.unshift({ col: "deleted_at", op: "isNull" });
614
+ }
615
+ return filters;
616
+ }
617
+ function appendQueryOptions(baseSql, params, opts, tableAlias) {
618
+ let sql = baseSql;
619
+ const prefix = tableAlias ? `${tableAlias}.` : "";
620
+ for (const f of effectiveFilters(opts)) {
621
+ if (!SAFE_COL_RE.test(f.col)) continue;
622
+ switch (f.op) {
623
+ case "eq":
624
+ sql += ` AND ${prefix}"${f.col}" = ?`;
625
+ params.push(f.val);
626
+ break;
627
+ case "ne":
628
+ sql += ` AND ${prefix}"${f.col}" != ?`;
629
+ params.push(f.val);
630
+ break;
631
+ case "gt":
632
+ sql += ` AND ${prefix}"${f.col}" > ?`;
633
+ params.push(f.val);
634
+ break;
635
+ case "gte":
636
+ sql += ` AND ${prefix}"${f.col}" >= ?`;
637
+ params.push(f.val);
638
+ break;
639
+ case "lt":
640
+ sql += ` AND ${prefix}"${f.col}" < ?`;
641
+ params.push(f.val);
642
+ break;
643
+ case "lte":
644
+ sql += ` AND ${prefix}"${f.col}" <= ?`;
645
+ params.push(f.val);
646
+ break;
647
+ case "like":
648
+ sql += ` AND ${prefix}"${f.col}" LIKE ?`;
649
+ params.push(f.val);
650
+ break;
651
+ case "in": {
652
+ const arr = f.val;
653
+ if (arr.length === 0) {
654
+ sql += " AND 0";
655
+ } else {
656
+ sql += ` AND ${prefix}"${f.col}" IN (${arr.map(() => "?").join(", ")})`;
657
+ params.push(...arr);
658
+ }
659
+ break;
660
+ }
661
+ case "isNull":
662
+ sql += ` AND ${prefix}"${f.col}" IS NULL`;
663
+ break;
664
+ case "isNotNull":
665
+ sql += ` AND ${prefix}"${f.col}" IS NOT NULL`;
666
+ break;
667
+ }
668
+ }
669
+ if (opts.orderBy && SAFE_COL_RE.test(opts.orderBy)) {
670
+ const dir = opts.orderDir === "desc" ? "DESC" : "ASC";
671
+ sql += ` ORDER BY ${prefix}"${opts.orderBy}" ${dir}`;
672
+ }
673
+ if (opts.limit !== void 0 && opts.limit > 0) {
674
+ sql += ` LIMIT ${Math.floor(opts.limit)}`;
675
+ }
676
+ return sql;
677
+ }
609
678
  function resolveEntitySource(source, entityRow, entityPk, adapter) {
610
679
  switch (source.type) {
611
680
  case "self":
@@ -613,32 +682,53 @@ function resolveEntitySource(source, entityRow, entityPk, adapter) {
613
682
  case "hasMany": {
614
683
  const ref = source.references ?? entityPk;
615
684
  const pkVal = entityRow[ref];
616
- return adapter.all(
617
- `SELECT * FROM "${source.table}" WHERE "${source.foreignKey}" = ?`,
618
- [pkVal]
619
- );
685
+ const params = [pkVal];
686
+ let sql = `SELECT * FROM "${source.table}" WHERE "${source.foreignKey}" = ?`;
687
+ sql = appendQueryOptions(sql, params, source);
688
+ return adapter.all(sql, params);
620
689
  }
621
690
  case "manyToMany": {
622
691
  const pkVal = entityRow[entityPk];
623
692
  const remotePk = source.references ?? "id";
624
- return adapter.all(
625
- `SELECT r.* FROM "${source.remoteTable}" r
693
+ const params = [pkVal];
694
+ let sql = `SELECT r.* FROM "${source.remoteTable}" r
626
695
  JOIN "${source.junctionTable}" j ON j."${source.remoteKey}" = r."${remotePk}"
627
- WHERE j."${source.localKey}" = ?`,
628
- [pkVal]
629
- );
696
+ WHERE j."${source.localKey}" = ?`;
697
+ sql = appendQueryOptions(sql, params, source, "r");
698
+ return adapter.all(sql, params);
630
699
  }
631
700
  case "belongsTo": {
632
701
  const fkVal = entityRow[source.foreignKey];
633
702
  if (fkVal == null) return [];
634
- const related = adapter.get(
635
- `SELECT * FROM "${source.table}" WHERE "${source.references ?? "id"}" = ?`,
636
- [fkVal]
637
- );
638
- return related ? [related] : [];
703
+ const hasOptions = source.filters?.length || source.softDelete || source.orderBy || source.limit;
704
+ if (!hasOptions) {
705
+ const related = adapter.get(
706
+ `SELECT * FROM "${source.table}" WHERE "${source.references ?? "id"}" = ?`,
707
+ [fkVal]
708
+ );
709
+ return related ? [related] : [];
710
+ }
711
+ const params = [fkVal];
712
+ let sql = `SELECT * FROM "${source.table}" WHERE "${source.references ?? "id"}" = ?`;
713
+ sql = appendQueryOptions(sql, params, source);
714
+ const rows = adapter.all(sql, params);
715
+ return rows.length > 0 ? [rows[0]] : [];
639
716
  }
640
717
  case "custom":
641
718
  return source.query(entityRow, adapter);
719
+ case "enriched": {
720
+ const enriched = { ...entityRow };
721
+ for (const [key, lookup] of Object.entries(source.include)) {
722
+ const fieldName = `_${key}`;
723
+ if (lookup.type === "custom") {
724
+ enriched[fieldName] = JSON.stringify(lookup.query(entityRow, adapter));
725
+ } else {
726
+ const resolved = resolveEntitySource(lookup, entityRow, entityPk, adapter);
727
+ enriched[fieldName] = JSON.stringify(resolved);
728
+ }
729
+ }
730
+ return [enriched];
731
+ }
642
732
  }
643
733
  }
644
734
  function truncateContent(content, budget) {
@@ -851,7 +941,9 @@ var RenderEngine = class {
851
941
  mkdirSync3(entityDir, { recursive: true });
852
942
  const renderedFiles = /* @__PURE__ */ new Map();
853
943
  for (const [filename, spec] of Object.entries(def.files)) {
854
- const rows = resolveEntitySource(spec.source, entityRow, entityPk, this._adapter);
944
+ const mergeDefaults = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" && spec.source.type !== "enriched";
945
+ const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
946
+ const rows = resolveEntitySource(source, entityRow, entityPk, this._adapter);
855
947
  if (spec.omitIfEmpty && rows.length === 0) continue;
856
948
  const content = truncateContent(spec.render(rows), spec.budget);
857
949
  renderedFiles.set(filename, content);
package/dist/index.cjs CHANGED
@@ -36,15 +36,19 @@ __export(index_exports, {
36
36
  READ_ONLY_HEADER: () => READ_ONLY_HEADER,
37
37
  applyWriteEntry: () => applyWriteEntry,
38
38
  createReadOnlyHeader: () => createReadOnlyHeader,
39
+ frontmatter: () => frontmatter,
39
40
  generateEntryId: () => generateEntryId,
40
41
  generateWriteEntryId: () => generateWriteEntryId,
41
42
  manifestPath: () => manifestPath,
43
+ markdownTable: () => markdownTable,
42
44
  parseConfigFile: () => parseConfigFile,
43
45
  parseConfigString: () => parseConfigString,
44
46
  parseMarkdownEntries: () => parseMarkdownEntries,
45
47
  parseSessionMD: () => parseSessionMD,
46
48
  parseSessionWrites: () => parseSessionWrites,
47
49
  readManifest: () => readManifest,
50
+ slugify: () => slugify,
51
+ truncate: () => truncate,
48
52
  validateEntryId: () => validateEntryId,
49
53
  writeManifest: () => writeManifest
50
54
  });
@@ -344,6 +348,75 @@ var import_node_path4 = require("path");
344
348
  var import_node_fs4 = require("fs");
345
349
 
346
350
  // src/render/entity-query.ts
351
+ var SAFE_COL_RE = /^[a-zA-Z0-9_]+$/;
352
+ function effectiveFilters(opts) {
353
+ const filters = opts.filters ? [...opts.filters] : [];
354
+ if (opts.softDelete && !filters.some((f) => f.col === "deleted_at")) {
355
+ filters.unshift({ col: "deleted_at", op: "isNull" });
356
+ }
357
+ return filters;
358
+ }
359
+ function appendQueryOptions(baseSql, params, opts, tableAlias) {
360
+ let sql = baseSql;
361
+ const prefix = tableAlias ? `${tableAlias}.` : "";
362
+ for (const f of effectiveFilters(opts)) {
363
+ if (!SAFE_COL_RE.test(f.col)) continue;
364
+ switch (f.op) {
365
+ case "eq":
366
+ sql += ` AND ${prefix}"${f.col}" = ?`;
367
+ params.push(f.val);
368
+ break;
369
+ case "ne":
370
+ sql += ` AND ${prefix}"${f.col}" != ?`;
371
+ params.push(f.val);
372
+ break;
373
+ case "gt":
374
+ sql += ` AND ${prefix}"${f.col}" > ?`;
375
+ params.push(f.val);
376
+ break;
377
+ case "gte":
378
+ sql += ` AND ${prefix}"${f.col}" >= ?`;
379
+ params.push(f.val);
380
+ break;
381
+ case "lt":
382
+ sql += ` AND ${prefix}"${f.col}" < ?`;
383
+ params.push(f.val);
384
+ break;
385
+ case "lte":
386
+ sql += ` AND ${prefix}"${f.col}" <= ?`;
387
+ params.push(f.val);
388
+ break;
389
+ case "like":
390
+ sql += ` AND ${prefix}"${f.col}" LIKE ?`;
391
+ params.push(f.val);
392
+ break;
393
+ case "in": {
394
+ const arr = f.val;
395
+ if (arr.length === 0) {
396
+ sql += " AND 0";
397
+ } else {
398
+ sql += ` AND ${prefix}"${f.col}" IN (${arr.map(() => "?").join(", ")})`;
399
+ params.push(...arr);
400
+ }
401
+ break;
402
+ }
403
+ case "isNull":
404
+ sql += ` AND ${prefix}"${f.col}" IS NULL`;
405
+ break;
406
+ case "isNotNull":
407
+ sql += ` AND ${prefix}"${f.col}" IS NOT NULL`;
408
+ break;
409
+ }
410
+ }
411
+ if (opts.orderBy && SAFE_COL_RE.test(opts.orderBy)) {
412
+ const dir = opts.orderDir === "desc" ? "DESC" : "ASC";
413
+ sql += ` ORDER BY ${prefix}"${opts.orderBy}" ${dir}`;
414
+ }
415
+ if (opts.limit !== void 0 && opts.limit > 0) {
416
+ sql += ` LIMIT ${Math.floor(opts.limit)}`;
417
+ }
418
+ return sql;
419
+ }
347
420
  function resolveEntitySource(source, entityRow, entityPk, adapter) {
348
421
  switch (source.type) {
349
422
  case "self":
@@ -351,32 +424,53 @@ function resolveEntitySource(source, entityRow, entityPk, adapter) {
351
424
  case "hasMany": {
352
425
  const ref = source.references ?? entityPk;
353
426
  const pkVal = entityRow[ref];
354
- return adapter.all(
355
- `SELECT * FROM "${source.table}" WHERE "${source.foreignKey}" = ?`,
356
- [pkVal]
357
- );
427
+ const params = [pkVal];
428
+ let sql = `SELECT * FROM "${source.table}" WHERE "${source.foreignKey}" = ?`;
429
+ sql = appendQueryOptions(sql, params, source);
430
+ return adapter.all(sql, params);
358
431
  }
359
432
  case "manyToMany": {
360
433
  const pkVal = entityRow[entityPk];
361
434
  const remotePk = source.references ?? "id";
362
- return adapter.all(
363
- `SELECT r.* FROM "${source.remoteTable}" r
435
+ const params = [pkVal];
436
+ let sql = `SELECT r.* FROM "${source.remoteTable}" r
364
437
  JOIN "${source.junctionTable}" j ON j."${source.remoteKey}" = r."${remotePk}"
365
- WHERE j."${source.localKey}" = ?`,
366
- [pkVal]
367
- );
438
+ WHERE j."${source.localKey}" = ?`;
439
+ sql = appendQueryOptions(sql, params, source, "r");
440
+ return adapter.all(sql, params);
368
441
  }
369
442
  case "belongsTo": {
370
443
  const fkVal = entityRow[source.foreignKey];
371
444
  if (fkVal == null) return [];
372
- const related = adapter.get(
373
- `SELECT * FROM "${source.table}" WHERE "${source.references ?? "id"}" = ?`,
374
- [fkVal]
375
- );
376
- return related ? [related] : [];
445
+ const hasOptions = source.filters?.length || source.softDelete || source.orderBy || source.limit;
446
+ if (!hasOptions) {
447
+ const related = adapter.get(
448
+ `SELECT * FROM "${source.table}" WHERE "${source.references ?? "id"}" = ?`,
449
+ [fkVal]
450
+ );
451
+ return related ? [related] : [];
452
+ }
453
+ const params = [fkVal];
454
+ let sql = `SELECT * FROM "${source.table}" WHERE "${source.references ?? "id"}" = ?`;
455
+ sql = appendQueryOptions(sql, params, source);
456
+ const rows = adapter.all(sql, params);
457
+ return rows.length > 0 ? [rows[0]] : [];
377
458
  }
378
459
  case "custom":
379
460
  return source.query(entityRow, adapter);
461
+ case "enriched": {
462
+ const enriched = { ...entityRow };
463
+ for (const [key, lookup] of Object.entries(source.include)) {
464
+ const fieldName = `_${key}`;
465
+ if (lookup.type === "custom") {
466
+ enriched[fieldName] = JSON.stringify(lookup.query(entityRow, adapter));
467
+ } else {
468
+ const resolved = resolveEntitySource(lookup, entityRow, entityPk, adapter);
469
+ enriched[fieldName] = JSON.stringify(resolved);
470
+ }
471
+ }
472
+ return [enriched];
473
+ }
380
474
  }
381
475
  }
382
476
  function truncateContent(content, budget) {
@@ -589,7 +683,9 @@ var RenderEngine = class {
589
683
  (0, import_node_fs4.mkdirSync)(entityDir, { recursive: true });
590
684
  const renderedFiles = /* @__PURE__ */ new Map();
591
685
  for (const [filename, spec] of Object.entries(def.files)) {
592
- const rows = resolveEntitySource(spec.source, entityRow, entityPk, this._adapter);
686
+ const mergeDefaults = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" && spec.source.type !== "enriched";
687
+ const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
688
+ const rows = resolveEntitySource(source, entityRow, entityPk, this._adapter);
593
689
  if (spec.omitIfEmpty && rows.length === 0) continue;
594
690
  const content = truncateContent(spec.render(rows), spec.budget);
595
691
  renderedFiles.set(filename, content);
@@ -1524,6 +1620,39 @@ var Lattice = class {
1524
1620
  }
1525
1621
  };
1526
1622
 
1623
+ // src/render/markdown.ts
1624
+ function frontmatter(fields) {
1625
+ const lines = [`generated_at: "${(/* @__PURE__ */ new Date()).toISOString()}"`];
1626
+ for (const [key, val] of Object.entries(fields)) {
1627
+ lines.push(typeof val === "string" ? `${key}: "${val}"` : `${key}: ${String(val)}`);
1628
+ }
1629
+ return `---
1630
+ ${lines.join("\n")}
1631
+ ---
1632
+
1633
+ `;
1634
+ }
1635
+ function markdownTable(rows, columns) {
1636
+ if (rows.length === 0 || columns.length === 0) return "";
1637
+ const header = "| " + columns.map((c) => c.header).join(" | ") + " |";
1638
+ const separator = "| " + columns.map(() => "---").join(" | ") + " |";
1639
+ const body = rows.map((row) => {
1640
+ const cells = columns.map((col) => {
1641
+ const raw = row[col.key];
1642
+ return col.format ? col.format(raw, row) : String(raw ?? "");
1643
+ });
1644
+ return "| " + cells.join(" | ") + " |";
1645
+ });
1646
+ return [header, separator, ...body].join("\n") + "\n";
1647
+ }
1648
+ function slugify(name) {
1649
+ return name.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/\u0131/g, "i").replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
1650
+ }
1651
+ function truncate(content, maxChars, notice = "\n\n*[truncated \u2014 context budget exceeded]*") {
1652
+ if (content.length <= maxChars) return content;
1653
+ return content.slice(0, maxChars) + notice;
1654
+ }
1655
+
1527
1656
  // src/session/parser.ts
1528
1657
  var import_node_crypto3 = require("crypto");
1529
1658
  function generateWriteEntryId(timestamp, agentName, op, table, target) {
@@ -1929,15 +2058,19 @@ var READ_ONLY_HEADER = createReadOnlyHeader();
1929
2058
  READ_ONLY_HEADER,
1930
2059
  applyWriteEntry,
1931
2060
  createReadOnlyHeader,
2061
+ frontmatter,
1932
2062
  generateEntryId,
1933
2063
  generateWriteEntryId,
1934
2064
  manifestPath,
2065
+ markdownTable,
1935
2066
  parseConfigFile,
1936
2067
  parseConfigString,
1937
2068
  parseMarkdownEntries,
1938
2069
  parseSessionMD,
1939
2070
  parseSessionWrites,
1940
2071
  readManifest,
2072
+ slugify,
2073
+ truncate,
1941
2074
  validateEntryId,
1942
2075
  writeManifest
1943
2076
  });
package/dist/index.d.cts CHANGED
@@ -41,6 +41,32 @@ interface PreparedStatement {
41
41
  all(...params: unknown[]): Row[];
42
42
  }
43
43
 
44
+ /**
45
+ * Optional query refinements shared by `hasMany`, `manyToMany`, and
46
+ * `belongsTo` sources. All fields are additive — omitting them preserves
47
+ * the v0.5 behaviour (bare `SELECT *`).
48
+ */
49
+ interface SourceQueryOptions {
50
+ /**
51
+ * Additional WHERE clauses applied after the relationship join condition.
52
+ * Uses the existing {@link Filter} type.
53
+ *
54
+ * @example `filters: [{ col: 'status', op: 'eq', val: 'active' }]`
55
+ */
56
+ filters?: Filter[];
57
+ /**
58
+ * Shorthand for `filters: [{ col: 'deleted_at', op: 'isNull' }]`.
59
+ * When `true`, soft-deleted rows are excluded. If both `softDelete`
60
+ * and an explicit `deleted_at` filter are present, the explicit filter wins.
61
+ */
62
+ softDelete?: boolean;
63
+ /** Column to ORDER BY. Validated against `[a-zA-Z0-9_]`. */
64
+ orderBy?: string;
65
+ /** Sort direction. Defaults to `'asc'`. */
66
+ orderDir?: 'asc' | 'desc';
67
+ /** Maximum number of rows to return. */
68
+ limit?: number;
69
+ }
44
70
  /**
45
71
  * Yield the entity row itself as a single-element array.
46
72
  * Use for the primary entity file (e.g. `AGENT.md`).
@@ -57,7 +83,7 @@ interface SelfSource {
57
83
  * source: { type: 'hasMany', table: 'tasks', foreignKey: 'agent_id' }
58
84
  * ```
59
85
  */
60
- interface HasManySource {
86
+ interface HasManySource extends SourceQueryOptions {
61
87
  type: 'hasMany';
62
88
  /** The related table to query */
63
89
  table: string;
@@ -84,7 +110,7 @@ interface HasManySource {
84
110
  * }
85
111
  * ```
86
112
  */
87
- interface ManyToManySource {
113
+ interface ManyToManySource extends SourceQueryOptions {
88
114
  type: 'manyToMany';
89
115
  /** The junction / association table */
90
116
  junctionTable: string;
@@ -111,7 +137,7 @@ interface ManyToManySource {
111
137
  * source: { type: 'belongsTo', table: 'teams', foreignKey: 'team_id' }
112
138
  * ```
113
139
  */
114
- interface BelongsToSource {
140
+ interface BelongsToSource extends SourceQueryOptions {
115
141
  type: 'belongsTo';
116
142
  /** The related table to look up */
117
143
  table: string;
@@ -141,8 +167,57 @@ interface CustomSource {
141
167
  type: 'custom';
142
168
  query: (row: Row, adapter: StorageAdapter) => Row[];
143
169
  }
170
+ /**
171
+ * Sub-lookup definition for an {@link EnrichedSource}.
172
+ * Each lookup resolves related rows and attaches them to the entity row
173
+ * as a `_key` JSON string field.
174
+ *
175
+ * Declarative lookups reuse the same types as file sources (with all
176
+ * query options). Custom lookups provide full control.
177
+ */
178
+ type EnrichmentLookup = ({
179
+ as?: string;
180
+ } & Omit<HasManySource, 'type'> & {
181
+ type: 'hasMany';
182
+ }) | ({
183
+ as?: string;
184
+ } & Omit<ManyToManySource, 'type'> & {
185
+ type: 'manyToMany';
186
+ }) | ({
187
+ as?: string;
188
+ } & Omit<BelongsToSource, 'type'> & {
189
+ type: 'belongsTo';
190
+ }) | {
191
+ type: 'custom';
192
+ query: (row: Row, adapter: StorageAdapter) => Row[];
193
+ };
194
+ /**
195
+ * Start with the entity's own row (like `self`) and attach related data
196
+ * as JSON string fields. Each key in `include` becomes a `_key` field
197
+ * on the returned row, containing `JSON.stringify(resolvedRows)`.
198
+ *
199
+ * Use when a single file needs the entity's own data plus related lists
200
+ * (e.g. an org profile that includes its agents and projects).
201
+ *
202
+ * @example
203
+ * ```ts
204
+ * source: {
205
+ * type: 'enriched',
206
+ * include: {
207
+ * agents: { type: 'hasMany', table: 'agents', foreignKey: 'org_id', softDelete: true },
208
+ * projects: { type: 'hasMany', table: 'projects', foreignKey: 'org_id', softDelete: true },
209
+ * },
210
+ * }
211
+ * // Result: [{ ...entityRow, _agents: '[...]', _projects: '[...]' }]
212
+ * ```
213
+ */
214
+ interface EnrichedSource {
215
+ type: 'enriched';
216
+ /** Named lookups whose results are attached as `_key` JSON fields. */
217
+ include: Record<string, EnrichmentLookup>;
218
+ }
144
219
  /** Union of all supported source types for {@link EntityFileSpec}. */
145
- type EntityFileSource = SelfSource | HasManySource | ManyToManySource | BelongsToSource | CustomSource;
220
+ type EntityFileSource = SelfSource | HasManySource | ManyToManySource | BelongsToSource | CustomSource | EnrichedSource;
146
221
  /**
147
222
  * Specification for a single file generated inside an entity's directory.
148
223
  *
@@ -250,6 +325,17 @@ interface EntityContextDefinition {
250
325
  * Defaults to `[]`.
251
326
  */
252
327
  protectedFiles?: string[];
328
+ /**
329
+ * Default query options merged into every `hasMany`, `manyToMany`, and
330
+ * `belongsTo` source in this context. Per-file source options override
331
+ * these defaults. `custom` and `self` sources are unaffected.
332
+ *
333
+ * @example
334
+ * ```ts
335
+ * sourceDefaults: { softDelete: true } // exclude soft-deleted rows everywhere
336
+ * ```
337
+ */
338
+ sourceDefaults?: SourceQueryOptions;
253
339
  }
254
340
 
255
341
  interface CleanupOptions {
@@ -866,6 +952,69 @@ declare function parseConfigFile(configPath: string): ParsedConfig;
866
952
  */
867
953
  declare function parseConfigString(yamlContent: string, configDir: string): ParsedConfig;
868
954
 
955
+ /**
956
+ * Column definition for {@link markdownTable}.
957
+ */
958
+ interface MarkdownTableColumn {
959
+ /** Row property to read (e.g. `'name'`, `'status'`). */
960
+ key: string;
961
+ /** Column header text displayed in the table. */
962
+ header: string;
963
+ /**
964
+ * Optional per-cell formatter. Receives the raw cell value and the full
965
+ * row so formatters can derive display values from multiple fields.
966
+ *
967
+ * @example `(val) => String(val ?? '—')`
968
+ * @example `(_, row) => \`[\${row.name}](\${row.slug}/DETAIL.md)\``
969
+ */
970
+ format?: (val: unknown, row: Row) => string;
971
+ }
972
+ /**
973
+ * Generate a YAML-style frontmatter block.
974
+ * Automatically includes `generated_at` with the current ISO timestamp.
975
+ *
976
+ * @example
977
+ * ```ts
978
+ * frontmatter({ agent: 'Alice', skill_count: 5 })
979
+ * // "---\ngenerated_at: 2026-03-27T...\nagent: Alice\nskill_count: 5\n---\n\n"
980
+ * ```
981
+ */
982
+ declare function frontmatter(fields: Record<string, string | number | boolean>): string;
983
+ /**
984
+ * Generate a GitHub-Flavoured Markdown table from rows with explicit column
985
+ * configuration. Returns an empty string when `rows` is empty.
986
+ *
987
+ * @example
988
+ * ```ts
989
+ * markdownTable(rows, [
990
+ * { key: 'name', header: 'Name' },
991
+ * { key: 'status', header: 'Status', format: (v) => String(v ?? '—') },
992
+ * ])
993
+ * ```
994
+ */
995
+ declare function markdownTable(rows: Row[], columns: MarkdownTableColumn[]): string;
996
+ /**
997
+ * Generate a URL-safe slug from a display name.
998
+ *
999
+ * - Lowercases, strips diacritics, replaces non-alphanumeric runs with `-`,
1000
+ * and trims leading/trailing hyphens.
1001
+ *
1002
+ * @example `slugify('My Agent Name') // 'my-agent-name'`
1003
+ * @example `slugify('José García') // 'jose-garcia'`
1004
+ */
1005
+ declare function slugify(name: string): string;
1006
+ /**
1007
+ * Truncate content at a character budget.
1008
+ *
1009
+ * When `content.length > maxChars`, slices to `maxChars` and appends `notice`.
1010
+ * Returns `content` unchanged when the budget is not exceeded.
1011
+ *
1012
+ * @param content - The rendered content to truncate
1013
+ * @param maxChars - Maximum character count
1014
+ * @param notice - Appended after truncation (default: standard budget notice)
1015
+ */
1016
+ declare function truncate(content: string, maxChars: number, notice?: string): string;
1017
+
869
1018
  type SessionWriteOp = 'create' | 'update' | 'delete';
870
1019
  interface SessionWriteEntry {
871
1020
  id: string;
@@ -1047,4 +1196,4 @@ declare function createReadOnlyHeader(options?: ReadOnlyHeaderOptions): string;
1047
1196
  */
1048
1197
  declare const READ_ONLY_HEADER: string;
1049
1198
 
1050
- export { type ApplyWriteResult, type AuditEvent, type BelongsToRelation, type BelongsToSource, type BuiltinTemplateName, type CleanupOptions, type CleanupResult, type CountOptions, type CustomSource, DEFAULT_ENTRY_TYPES, DEFAULT_TYPE_ALIASES, 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 ReadOnlyHeaderOptions, type ReconcileOptions, type ReconcileResult, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type Row, type SecurityOptions, type SelfSource, type SessionEntry, type SessionParseOptions, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type StopFn, type SyncResult, type TableDefinition, type TemplateRenderSpec, type WatchOptions, type WritebackDefinition, applyWriteEntry, createReadOnlyHeader, generateEntryId, generateWriteEntryId, manifestPath, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, readManifest, validateEntryId, writeManifest };
1199
+ export { type ApplyWriteResult, type AuditEvent, type BelongsToRelation, type BelongsToSource, type BuiltinTemplateName, type CleanupOptions, type CleanupResult, type CountOptions, type CustomSource, DEFAULT_ENTRY_TYPES, DEFAULT_TYPE_ALIASES, type EnrichedSource, type EnrichmentLookup, 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 MarkdownTableColumn, type Migration, type MultiTableDefinition, type ParseError, type ParseResult, type ParsedConfig, type PkLookup, type PrimaryKey, type QueryOptions, READ_ONLY_HEADER, type ReadOnlyHeaderOptions, type ReconcileOptions, type ReconcileResult, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type Row, type SecurityOptions, type SelfSource, type SessionEntry, type SessionParseOptions, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type SourceQueryOptions, type StopFn, type SyncResult, type TableDefinition, type TemplateRenderSpec, type WatchOptions, type WritebackDefinition, applyWriteEntry, createReadOnlyHeader, frontmatter, generateEntryId, generateWriteEntryId, manifestPath, markdownTable, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, readManifest, slugify, truncate, validateEntryId, writeManifest };
package/dist/index.d.ts CHANGED
@@ -41,6 +41,32 @@ interface PreparedStatement {
41
41
  all(...params: unknown[]): Row[];
42
42
  }
43
43
 
44
+ /**
45
+ * Optional query refinements shared by `hasMany`, `manyToMany`, and
46
+ * `belongsTo` sources. All fields are additive — omitting them preserves
47
+ * the v0.5 behaviour (bare `SELECT *`).
48
+ */
49
+ interface SourceQueryOptions {
50
+ /**
51
+ * Additional WHERE clauses applied after the relationship join condition.
52
+ * Uses the existing {@link Filter} type.
53
+ *
54
+ * @example `filters: [{ col: 'status', op: 'eq', val: 'active' }]`
55
+ */
56
+ filters?: Filter[];
57
+ /**
58
+ * Shorthand for `filters: [{ col: 'deleted_at', op: 'isNull' }]`.
59
+ * When `true`, soft-deleted rows are excluded. If both `softDelete`
60
+ * and an explicit `deleted_at` filter are present, the explicit filter wins.
61
+ */
62
+ softDelete?: boolean;
63
+ /** Column to ORDER BY. Validated against `[a-zA-Z0-9_]`. */
64
+ orderBy?: string;
65
+ /** Sort direction. Defaults to `'asc'`. */
66
+ orderDir?: 'asc' | 'desc';
67
+ /** Maximum number of rows to return. */
68
+ limit?: number;
69
+ }
44
70
  /**
45
71
  * Yield the entity row itself as a single-element array.
46
72
  * Use for the primary entity file (e.g. `AGENT.md`).
@@ -57,7 +83,7 @@ interface SelfSource {
57
83
  * source: { type: 'hasMany', table: 'tasks', foreignKey: 'agent_id' }
58
84
  * ```
59
85
  */
60
- interface HasManySource {
86
+ interface HasManySource extends SourceQueryOptions {
61
87
  type: 'hasMany';
62
88
  /** The related table to query */
63
89
  table: string;
@@ -84,7 +110,7 @@ interface HasManySource {
84
110
  * }
85
111
  * ```
86
112
  */
87
- interface ManyToManySource {
113
+ interface ManyToManySource extends SourceQueryOptions {
88
114
  type: 'manyToMany';
89
115
  /** The junction / association table */
90
116
  junctionTable: string;
@@ -111,7 +137,7 @@ interface ManyToManySource {
111
137
  * source: { type: 'belongsTo', table: 'teams', foreignKey: 'team_id' }
112
138
  * ```
113
139
  */
114
- interface BelongsToSource {
140
+ interface BelongsToSource extends SourceQueryOptions {
115
141
  type: 'belongsTo';
116
142
  /** The related table to look up */
117
143
  table: string;
@@ -141,8 +167,57 @@ interface CustomSource {
141
167
  type: 'custom';
142
168
  query: (row: Row, adapter: StorageAdapter) => Row[];
143
169
  }
170
+ /**
171
+ * Sub-lookup definition for an {@link EnrichedSource}.
172
+ * Each lookup resolves related rows and attaches them to the entity row
173
+ * as a `_key` JSON string field.
174
+ *
175
+ * Declarative lookups reuse the same types as file sources (with all
176
+ * query options). Custom lookups provide full control.
177
+ */
178
+ type EnrichmentLookup = ({
179
+ as?: string;
180
+ } & Omit<HasManySource, 'type'> & {
181
+ type: 'hasMany';
182
+ }) | ({
183
+ as?: string;
184
+ } & Omit<ManyToManySource, 'type'> & {
185
+ type: 'manyToMany';
186
+ }) | ({
187
+ as?: string;
188
+ } & Omit<BelongsToSource, 'type'> & {
189
+ type: 'belongsTo';
190
+ }) | {
191
+ type: 'custom';
192
+ query: (row: Row, adapter: StorageAdapter) => Row[];
193
+ };
194
+ /**
195
+ * Start with the entity's own row (like `self`) and attach related data
196
+ * as JSON string fields. Each key in `include` becomes a `_key` field
197
+ * on the returned row, containing `JSON.stringify(resolvedRows)`.
198
+ *
199
+ * Use when a single file needs the entity's own data plus related lists
200
+ * (e.g. an org profile that includes its agents and projects).
201
+ *
202
+ * @example
203
+ * ```ts
204
+ * source: {
205
+ * type: 'enriched',
206
+ * include: {
207
+ * agents: { type: 'hasMany', table: 'agents', foreignKey: 'org_id', softDelete: true },
208
+ * projects: { type: 'hasMany', table: 'projects', foreignKey: 'org_id', softDelete: true },
209
+ * },
210
+ * }
211
+ * // Result: [{ ...entityRow, _agents: '[...]', _projects: '[...]' }]
212
+ * ```
213
+ */
214
+ interface EnrichedSource {
215
+ type: 'enriched';
216
+ /** Named lookups whose results are attached as `_key` JSON fields. */
217
+ include: Record<string, EnrichmentLookup>;
218
+ }
144
219
  /** Union of all supported source types for {@link EntityFileSpec}. */
145
- type EntityFileSource = SelfSource | HasManySource | ManyToManySource | BelongsToSource | CustomSource;
220
+ type EntityFileSource = SelfSource | HasManySource | ManyToManySource | BelongsToSource | CustomSource | EnrichedSource;
146
221
  /**
147
222
  * Specification for a single file generated inside an entity's directory.
148
223
  *
@@ -250,6 +325,17 @@ interface EntityContextDefinition {
250
325
  * Defaults to `[]`.
251
326
  */
252
327
  protectedFiles?: string[];
328
+ /**
329
+ * Default query options merged into every `hasMany`, `manyToMany`, and
330
+ * `belongsTo` source in this context. Per-file source options override
331
+ * these defaults. `custom` and `self` sources are unaffected.
332
+ *
333
+ * @example
334
+ * ```ts
335
+ * sourceDefaults: { softDelete: true } // exclude soft-deleted rows everywhere
336
+ * ```
337
+ */
338
+ sourceDefaults?: SourceQueryOptions;
253
339
  }
254
340
 
255
341
  interface CleanupOptions {
@@ -866,6 +952,69 @@ declare function parseConfigFile(configPath: string): ParsedConfig;
866
952
  */
867
953
  declare function parseConfigString(yamlContent: string, configDir: string): ParsedConfig;
868
954
 
955
+ /**
956
+ * Column definition for {@link markdownTable}.
957
+ */
958
+ interface MarkdownTableColumn {
959
+ /** Row property to read (e.g. `'name'`, `'status'`). */
960
+ key: string;
961
+ /** Column header text displayed in the table. */
962
+ header: string;
963
+ /**
964
+ * Optional per-cell formatter. Receives the raw cell value and the full
965
+ * row so formatters can derive display values from multiple fields.
966
+ *
967
+ * @example `(val) => String(val ?? '—')`
968
+ * @example `(_, row) => \`[\${row.name}](\${row.slug}/DETAIL.md)\``
969
+ */
970
+ format?: (val: unknown, row: Row) => string;
971
+ }
972
+ /**
973
+ * Generate a YAML-style frontmatter block.
974
+ * Automatically includes `generated_at` with the current ISO timestamp.
975
+ *
976
+ * @example
977
+ * ```ts
978
+ * frontmatter({ agent: 'Alice', skill_count: 5 })
979
+ * // "---\ngenerated_at: 2026-03-27T...\nagent: Alice\nskill_count: 5\n---\n\n"
980
+ * ```
981
+ */
982
+ declare function frontmatter(fields: Record<string, string | number | boolean>): string;
983
+ /**
984
+ * Generate a GitHub-Flavoured Markdown table from rows with explicit column
985
+ * configuration. Returns an empty string when `rows` is empty.
986
+ *
987
+ * @example
988
+ * ```ts
989
+ * markdownTable(rows, [
990
+ * { key: 'name', header: 'Name' },
991
+ * { key: 'status', header: 'Status', format: (v) => String(v ?? '—') },
992
+ * ])
993
+ * ```
994
+ */
995
+ declare function markdownTable(rows: Row[], columns: MarkdownTableColumn[]): string;
996
+ /**
997
+ * Generate a URL-safe slug from a display name.
998
+ *
999
+ * - Lowercases, strips diacritics, replaces non-alphanumeric runs with `-`,
1000
+ * and trims leading/trailing hyphens.
1001
+ *
1002
+ * @example `slugify('My Agent Name') // 'my-agent-name'`
1003
+ * @example `slugify('José García') // 'jose-garcia'`
1004
+ */
1005
+ declare function slugify(name: string): string;
1006
+ /**
1007
+ * Truncate content at a character budget.
1008
+ *
1009
+ * When `content.length > maxChars`, slices to `maxChars` and appends `notice`.
1010
+ * Returns `content` unchanged when the budget is not exceeded.
1011
+ *
1012
+ * @param content - The rendered content to truncate
1013
+ * @param maxChars - Maximum character count
1014
+ * @param notice - Appended after truncation (default: standard budget notice)
1015
+ */
1016
+ declare function truncate(content: string, maxChars: number, notice?: string): string;
1017
+
869
1018
  type SessionWriteOp = 'create' | 'update' | 'delete';
870
1019
  interface SessionWriteEntry {
871
1020
  id: string;
@@ -1047,4 +1196,4 @@ declare function createReadOnlyHeader(options?: ReadOnlyHeaderOptions): string;
1047
1196
  */
1048
1197
  declare const READ_ONLY_HEADER: string;
1049
1198
 
1050
- export { type ApplyWriteResult, type AuditEvent, type BelongsToRelation, type BelongsToSource, type BuiltinTemplateName, type CleanupOptions, type CleanupResult, type CountOptions, type CustomSource, DEFAULT_ENTRY_TYPES, DEFAULT_TYPE_ALIASES, 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 ReadOnlyHeaderOptions, type ReconcileOptions, type ReconcileResult, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type Row, type SecurityOptions, type SelfSource, type SessionEntry, type SessionParseOptions, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type StopFn, type SyncResult, type TableDefinition, type TemplateRenderSpec, type WatchOptions, type WritebackDefinition, applyWriteEntry, createReadOnlyHeader, generateEntryId, generateWriteEntryId, manifestPath, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, readManifest, validateEntryId, writeManifest };
1199
+ export { type ApplyWriteResult, type AuditEvent, type BelongsToRelation, type BelongsToSource, type BuiltinTemplateName, type CleanupOptions, type CleanupResult, type CountOptions, type CustomSource, DEFAULT_ENTRY_TYPES, DEFAULT_TYPE_ALIASES, type EnrichedSource, type EnrichmentLookup, 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 MarkdownTableColumn, type Migration, type MultiTableDefinition, type ParseError, type ParseResult, type ParsedConfig, type PkLookup, type PrimaryKey, type QueryOptions, READ_ONLY_HEADER, type ReadOnlyHeaderOptions, type ReconcileOptions, type ReconcileResult, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type Row, type SecurityOptions, type SelfSource, type SessionEntry, type SessionParseOptions, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type SourceQueryOptions, type StopFn, type SyncResult, type TableDefinition, type TemplateRenderSpec, type WatchOptions, type WritebackDefinition, applyWriteEntry, createReadOnlyHeader, frontmatter, generateEntryId, generateWriteEntryId, manifestPath, markdownTable, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, readManifest, slugify, truncate, validateEntryId, writeManifest };
package/dist/index.js CHANGED
@@ -292,6 +292,75 @@ import { join as join4 } from "path";
292
292
  import { mkdirSync as mkdirSync2 } from "fs";
293
293
 
294
294
  // src/render/entity-query.ts
295
+ var SAFE_COL_RE = /^[a-zA-Z0-9_]+$/;
296
+ function effectiveFilters(opts) {
297
+ const filters = opts.filters ? [...opts.filters] : [];
298
+ if (opts.softDelete && !filters.some((f) => f.col === "deleted_at")) {
299
+ filters.unshift({ col: "deleted_at", op: "isNull" });
300
+ }
301
+ return filters;
302
+ }
303
+ function appendQueryOptions(baseSql, params, opts, tableAlias) {
304
+ let sql = baseSql;
305
+ const prefix = tableAlias ? `${tableAlias}.` : "";
306
+ for (const f of effectiveFilters(opts)) {
307
+ if (!SAFE_COL_RE.test(f.col)) continue;
308
+ switch (f.op) {
309
+ case "eq":
310
+ sql += ` AND ${prefix}"${f.col}" = ?`;
311
+ params.push(f.val);
312
+ break;
313
+ case "ne":
314
+ sql += ` AND ${prefix}"${f.col}" != ?`;
315
+ params.push(f.val);
316
+ break;
317
+ case "gt":
318
+ sql += ` AND ${prefix}"${f.col}" > ?`;
319
+ params.push(f.val);
320
+ break;
321
+ case "gte":
322
+ sql += ` AND ${prefix}"${f.col}" >= ?`;
323
+ params.push(f.val);
324
+ break;
325
+ case "lt":
326
+ sql += ` AND ${prefix}"${f.col}" < ?`;
327
+ params.push(f.val);
328
+ break;
329
+ case "lte":
330
+ sql += ` AND ${prefix}"${f.col}" <= ?`;
331
+ params.push(f.val);
332
+ break;
333
+ case "like":
334
+ sql += ` AND ${prefix}"${f.col}" LIKE ?`;
335
+ params.push(f.val);
336
+ break;
337
+ case "in": {
338
+ const arr = f.val;
339
+ if (arr.length === 0) {
340
+ sql += " AND 0";
341
+ } else {
342
+ sql += ` AND ${prefix}"${f.col}" IN (${arr.map(() => "?").join(", ")})`;
343
+ params.push(...arr);
344
+ }
345
+ break;
346
+ }
347
+ case "isNull":
348
+ sql += ` AND ${prefix}"${f.col}" IS NULL`;
349
+ break;
350
+ case "isNotNull":
351
+ sql += ` AND ${prefix}"${f.col}" IS NOT NULL`;
352
+ break;
353
+ }
354
+ }
355
+ if (opts.orderBy && SAFE_COL_RE.test(opts.orderBy)) {
356
+ const dir = opts.orderDir === "desc" ? "DESC" : "ASC";
357
+ sql += ` ORDER BY ${prefix}"${opts.orderBy}" ${dir}`;
358
+ }
359
+ if (opts.limit !== void 0 && opts.limit > 0) {
360
+ sql += ` LIMIT ${Math.floor(opts.limit)}`;
361
+ }
362
+ return sql;
363
+ }
295
364
  function resolveEntitySource(source, entityRow, entityPk, adapter) {
296
365
  switch (source.type) {
297
366
  case "self":
@@ -299,32 +368,53 @@ function resolveEntitySource(source, entityRow, entityPk, adapter) {
299
368
  case "hasMany": {
300
369
  const ref = source.references ?? entityPk;
301
370
  const pkVal = entityRow[ref];
302
- return adapter.all(
303
- `SELECT * FROM "${source.table}" WHERE "${source.foreignKey}" = ?`,
304
- [pkVal]
305
- );
371
+ const params = [pkVal];
372
+ let sql = `SELECT * FROM "${source.table}" WHERE "${source.foreignKey}" = ?`;
373
+ sql = appendQueryOptions(sql, params, source);
374
+ return adapter.all(sql, params);
306
375
  }
307
376
  case "manyToMany": {
308
377
  const pkVal = entityRow[entityPk];
309
378
  const remotePk = source.references ?? "id";
310
- return adapter.all(
311
- `SELECT r.* FROM "${source.remoteTable}" r
379
+ const params = [pkVal];
380
+ let sql = `SELECT r.* FROM "${source.remoteTable}" r
312
381
  JOIN "${source.junctionTable}" j ON j."${source.remoteKey}" = r."${remotePk}"
313
- WHERE j."${source.localKey}" = ?`,
314
- [pkVal]
315
- );
382
+ WHERE j."${source.localKey}" = ?`;
383
+ sql = appendQueryOptions(sql, params, source, "r");
384
+ return adapter.all(sql, params);
316
385
  }
317
386
  case "belongsTo": {
318
387
  const fkVal = entityRow[source.foreignKey];
319
388
  if (fkVal == null) return [];
320
- const related = adapter.get(
321
- `SELECT * FROM "${source.table}" WHERE "${source.references ?? "id"}" = ?`,
322
- [fkVal]
323
- );
324
- return related ? [related] : [];
389
+ const hasOptions = source.filters?.length || source.softDelete || source.orderBy || source.limit;
390
+ if (!hasOptions) {
391
+ const related = adapter.get(
392
+ `SELECT * FROM "${source.table}" WHERE "${source.references ?? "id"}" = ?`,
393
+ [fkVal]
394
+ );
395
+ return related ? [related] : [];
396
+ }
397
+ const params = [fkVal];
398
+ let sql = `SELECT * FROM "${source.table}" WHERE "${source.references ?? "id"}" = ?`;
399
+ sql = appendQueryOptions(sql, params, source);
400
+ const rows = adapter.all(sql, params);
401
+ return rows.length > 0 ? [rows[0]] : [];
325
402
  }
326
403
  case "custom":
327
404
  return source.query(entityRow, adapter);
405
+ case "enriched": {
406
+ const enriched = { ...entityRow };
407
+ for (const [key, lookup] of Object.entries(source.include)) {
408
+ const fieldName = `_${key}`;
409
+ if (lookup.type === "custom") {
410
+ enriched[fieldName] = JSON.stringify(lookup.query(entityRow, adapter));
411
+ } else {
412
+ const resolved = resolveEntitySource(lookup, entityRow, entityPk, adapter);
413
+ enriched[fieldName] = JSON.stringify(resolved);
414
+ }
415
+ }
416
+ return [enriched];
417
+ }
328
418
  }
329
419
  }
330
420
  function truncateContent(content, budget) {
@@ -537,7 +627,9 @@ var RenderEngine = class {
537
627
  mkdirSync2(entityDir, { recursive: true });
538
628
  const renderedFiles = /* @__PURE__ */ new Map();
539
629
  for (const [filename, spec] of Object.entries(def.files)) {
540
- const rows = resolveEntitySource(spec.source, entityRow, entityPk, this._adapter);
630
+ const mergeDefaults = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" && spec.source.type !== "enriched";
631
+ const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
632
+ const rows = resolveEntitySource(source, entityRow, entityPk, this._adapter);
541
633
  if (spec.omitIfEmpty && rows.length === 0) continue;
542
634
  const content = truncateContent(spec.render(rows), spec.budget);
543
635
  renderedFiles.set(filename, content);
@@ -1472,6 +1564,39 @@ var Lattice = class {
1472
1564
  }
1473
1565
  };
1474
1566
 
1567
+ // src/render/markdown.ts
1568
+ function frontmatter(fields) {
1569
+ const lines = [`generated_at: "${(/* @__PURE__ */ new Date()).toISOString()}"`];
1570
+ for (const [key, val] of Object.entries(fields)) {
1571
+ lines.push(typeof val === "string" ? `${key}: "${val}"` : `${key}: ${String(val)}`);
1572
+ }
1573
+ return `---
1574
+ ${lines.join("\n")}
1575
+ ---
1576
+
1577
+ `;
1578
+ }
1579
+ function markdownTable(rows, columns) {
1580
+ if (rows.length === 0 || columns.length === 0) return "";
1581
+ const header = "| " + columns.map((c) => c.header).join(" | ") + " |";
1582
+ const separator = "| " + columns.map(() => "---").join(" | ") + " |";
1583
+ const body = rows.map((row) => {
1584
+ const cells = columns.map((col) => {
1585
+ const raw = row[col.key];
1586
+ return col.format ? col.format(raw, row) : String(raw ?? "");
1587
+ });
1588
+ return "| " + cells.join(" | ") + " |";
1589
+ });
1590
+ return [header, separator, ...body].join("\n") + "\n";
1591
+ }
1592
+ function slugify(name) {
1593
+ return name.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/\u0131/g, "i").replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
1594
+ }
1595
+ function truncate(content, maxChars, notice = "\n\n*[truncated \u2014 context budget exceeded]*") {
1596
+ if (content.length <= maxChars) return content;
1597
+ return content.slice(0, maxChars) + notice;
1598
+ }
1599
+
1475
1600
  // src/session/parser.ts
1476
1601
  import { createHash as createHash2 } from "crypto";
1477
1602
  function generateWriteEntryId(timestamp, agentName, op, table, target) {
@@ -1876,15 +2001,19 @@ export {
1876
2001
  READ_ONLY_HEADER,
1877
2002
  applyWriteEntry,
1878
2003
  createReadOnlyHeader,
2004
+ frontmatter,
1879
2005
  generateEntryId,
1880
2006
  generateWriteEntryId,
1881
2007
  manifestPath,
2008
+ markdownTable,
1882
2009
  parseConfigFile,
1883
2010
  parseConfigString,
1884
2011
  parseMarkdownEntries,
1885
2012
  parseSessionMD,
1886
2013
  parseSessionWrites,
1887
2014
  readManifest,
2015
+ slugify,
2016
+ truncate,
1888
2017
  validateEntryId,
1889
2018
  writeManifest
1890
2019
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latticesql",
3
- "version": "0.5.5",
3
+ "version": "0.7.0",
4
4
  "description": "Persistent structured memory for AI agent systems — SQLite ↔ LLM context bridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",