latticesql 2.0.0 → 2.1.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
@@ -230,7 +230,9 @@ function writeIdentity(identity) {
230
230
  var PREFERENCES_FILENAME = "preferences.json";
231
231
  var DEFAULT_PREFERENCES = {
232
232
  show_system_tables: false,
233
- analytics: true
233
+ analytics: true,
234
+ voice_provider: "auto",
235
+ aggressiveness: 0.5
234
236
  };
235
237
  function readPreferences() {
236
238
  const dir = ensureConfigDir();
@@ -238,9 +240,12 @@ function readPreferences() {
238
240
  if (!existsSync2(path2)) return { ...DEFAULT_PREFERENCES };
239
241
  try {
240
242
  const parsed = JSON.parse(readFileSync(path2, "utf8"));
243
+ const agg = typeof parsed.aggressiveness === "number" ? parsed.aggressiveness : NaN;
241
244
  return {
242
245
  show_system_tables: typeof parsed.show_system_tables === "boolean" ? parsed.show_system_tables : DEFAULT_PREFERENCES.show_system_tables,
243
- analytics: typeof parsed.analytics === "boolean" ? parsed.analytics : DEFAULT_PREFERENCES.analytics
246
+ analytics: typeof parsed.analytics === "boolean" ? parsed.analytics : DEFAULT_PREFERENCES.analytics,
247
+ voice_provider: parsed.voice_provider === "openai" || parsed.voice_provider === "elevenlabs" || parsed.voice_provider === "auto" ? parsed.voice_provider : DEFAULT_PREFERENCES.voice_provider,
248
+ aggressiveness: Number.isFinite(agg) ? Math.min(1, Math.max(0, agg)) : DEFAULT_PREFERENCES.aggressiveness
244
249
  };
245
250
  } catch {
246
251
  return { ...DEFAULT_PREFERENCES };
@@ -250,7 +255,12 @@ function writePreferences(prefs) {
250
255
  const dir = ensureConfigDir();
251
256
  const path2 = join2(dir, PREFERENCES_FILENAME);
252
257
  const body = JSON.stringify(
253
- { show_system_tables: prefs.show_system_tables, analytics: prefs.analytics },
258
+ {
259
+ show_system_tables: prefs.show_system_tables,
260
+ analytics: prefs.analytics,
261
+ voice_provider: prefs.voice_provider,
262
+ aggressiveness: prefs.aggressiveness
263
+ },
254
264
  null,
255
265
  2
256
266
  );
@@ -1598,6 +1608,19 @@ var SchemaManager = class {
1598
1608
  redefineEntityContext(table, def) {
1599
1609
  this._entityContexts.set(table, def);
1600
1610
  }
1611
+ /**
1612
+ * Remove a table from the registry — the inverse of {@link define}. Clears the
1613
+ * table def, its primary-key, any multi-render, and its entity context. A no-op
1614
+ * if the table was never defined. Used by the GUI's soft table-delete to drop a
1615
+ * runtime-registered table without a full reopen; the physical SQL table is left
1616
+ * intact by the caller so the delete stays revertible.
1617
+ */
1618
+ undefine(table) {
1619
+ this._tables.delete(table);
1620
+ this._tablePK.delete(table);
1621
+ this._multis.delete(table);
1622
+ this._entityContexts.delete(table);
1623
+ }
1601
1624
  getTables() {
1602
1625
  return this._tables;
1603
1626
  }
@@ -4531,6 +4554,21 @@ var Lattice = class _Lattice {
4531
4554
  }
4532
4555
  return this;
4533
4556
  }
4557
+ /**
4558
+ * Remove a runtime-registered table from the live schema registry — the
4559
+ * inverse of {@link defineLate}. The table stops being listed/queryable
4560
+ * WITHOUT a full reopen (which would dispose this instance and invalidate any
4561
+ * captured `db`/feed references mid-operation — see the GUI's soft table
4562
+ * delete). Does NOT drop the physical SQL table or its rows; the caller keeps
4563
+ * them so the delete remains revertible. A no-op if the table isn't
4564
+ * registered.
4565
+ */
4566
+ unregisterTable(table) {
4567
+ this._schema.undefine(table);
4568
+ this._columnCache.delete(table);
4569
+ this._changelogTables.delete(table);
4570
+ return this;
4571
+ }
4534
4572
  _registerTable(table, def) {
4535
4573
  const columns = def.rewardTracking ? { ...def.columns, _reward_total: "REAL DEFAULT 0", _reward_count: "INTEGER DEFAULT 0" } : def.columns;
4536
4574
  const renderTemplateName = _resolveTemplateName(def.render);
@@ -6107,6 +6145,205 @@ async function tryHandler(res, fn) {
6107
6145
  // src/gui/data.ts
6108
6146
  import { existsSync as existsSync14, readFileSync as readFileSync10, statSync as statSync4 } from "fs";
6109
6147
  import { basename as basename3, join as join13, relative, resolve as resolve5, sep as sep2 } from "path";
6148
+
6149
+ // src/framework/native-entities.ts
6150
+ var NATIVE_ENTITY_DEFS = {
6151
+ secrets: {
6152
+ columns: {
6153
+ id: "TEXT PRIMARY KEY",
6154
+ // NOT NULL needs a DEFAULT so ALTER TABLE ADD COLUMN succeeds when this
6155
+ // native shape is merged onto a pre-existing table (the adopt + team
6156
+ // shared-schema sync paths use ADD COLUMN; SQLite + Postgres both reject
6157
+ // a NOT NULL add without a default). Every insert sets `name` explicitly,
6158
+ // so the default is never observed in practice.
6159
+ name: "TEXT NOT NULL DEFAULT ''",
6160
+ kind: "TEXT",
6161
+ value: "TEXT",
6162
+ description: "TEXT",
6163
+ created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6164
+ updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6165
+ deleted_at: "TEXT"
6166
+ },
6167
+ encrypted: { columns: ["value"] },
6168
+ render: () => "",
6169
+ outputFile: ".lattice-native/secrets.md"
6170
+ },
6171
+ files: {
6172
+ columns: {
6173
+ id: "TEXT PRIMARY KEY",
6174
+ // Legacy columns (DEPRECATED v2.0 — retained for back-compat, NOT dropped).
6175
+ // path — superseded by the reference model below (`ref_kind`/`ref_uri`).
6176
+ // New local-file ingestion records a `local_ref` via
6177
+ // `referenceLocalFile()` rather than writing `path`; readers fall
6178
+ // back to `ref_uri`. Do not write `path` in new code.
6179
+ // kind — orphaned: superseded by `mime` (content type) and `ref_kind`
6180
+ // (the blob/local_ref/cloud_ref discriminator). Not read or
6181
+ // written by production code; kept only so existing rows retain
6182
+ // their value.
6183
+ path: "TEXT",
6184
+ kind: "TEXT",
6185
+ // Content-addressed storage. `sha256` is the canonical content
6186
+ // identifier; `blob_path` is the relative path under
6187
+ // `<lattice-root>/data/blobs/` written by attachBlob().
6188
+ original_name: "TEXT",
6189
+ mime: "TEXT",
6190
+ size_bytes: "INTEGER",
6191
+ sha256: "TEXT",
6192
+ blob_path: "TEXT",
6193
+ // Reference mode (v2.0): a row can INDEX data that lives elsewhere
6194
+ // instead of owning a copy. All nullable + additive (back-compat).
6195
+ // ref_kind discriminator: 'blob' | 'local_ref' | 'cloud_ref'
6196
+ // (NULL ⇒ legacy/owned blob)
6197
+ // ref_uri durable pointer: absolute local path or remote URL
6198
+ // ref_provider resolver selector: 'fs' | 'web' | 'gdrive'
6199
+ // source_json provider-specific metadata (etag, availability, …)
6200
+ ref_kind: "TEXT",
6201
+ ref_uri: "TEXT",
6202
+ ref_provider: "TEXT",
6203
+ source_json: "TEXT",
6204
+ extraction_status: "TEXT",
6205
+ extracted_text: "TEXT",
6206
+ description: "TEXT",
6207
+ created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6208
+ updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6209
+ deleted_at: "TEXT"
6210
+ },
6211
+ render: () => "",
6212
+ outputFile: ".lattice-native/files.md"
6213
+ },
6214
+ notes: {
6215
+ // A generic knowledge object: a free-form note with a title and body.
6216
+ // Ordinary, user-editable rows; `source_file_id` optionally points back at
6217
+ // an originating `files` row. Retained as native (1.16.3) because the
6218
+ // reference/source-organizer store uses it as the fallback organizer target.
6219
+ columns: {
6220
+ id: "TEXT PRIMARY KEY",
6221
+ title: "TEXT",
6222
+ body: "TEXT",
6223
+ source_file_id: "TEXT",
6224
+ created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6225
+ updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6226
+ deleted_at: "TEXT"
6227
+ },
6228
+ render: () => "",
6229
+ outputFile: ".lattice-native/notes.md"
6230
+ },
6231
+ chat_threads: {
6232
+ // An assistant conversation. Native so chat history survives across
6233
+ // sessions and is queryable/renderable like any other Lattice entity.
6234
+ columns: {
6235
+ id: "TEXT PRIMARY KEY",
6236
+ title: "TEXT",
6237
+ created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6238
+ updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6239
+ deleted_at: "TEXT"
6240
+ },
6241
+ render: () => "",
6242
+ outputFile: ".lattice-native/chat-threads.md"
6243
+ },
6244
+ chat_messages: {
6245
+ // One turn (or feed entry) within a chat_thread.
6246
+ columns: {
6247
+ id: "TEXT PRIMARY KEY",
6248
+ // Soft reference to chat_threads.id. Kept as a plain column (no FK)
6249
+ // to match the generic, dialect-agnostic native-entity style.
6250
+ thread_id: "TEXT",
6251
+ // user | assistant | tool | feed | system
6252
+ role: "TEXT NOT NULL DEFAULT 'user'",
6253
+ // JSON payload: text, tool_use / tool_result blocks, attachments, or
6254
+ // (for role='feed') the feed-event details.
6255
+ content_json: "TEXT",
6256
+ // ai | gui | cli | ingest — meaningful for role='feed'.
6257
+ source: "TEXT",
6258
+ created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6259
+ deleted_at: "TEXT"
6260
+ },
6261
+ render: () => "",
6262
+ outputFile: ".lattice-native/chat-messages.md"
6263
+ }
6264
+ };
6265
+ var NATIVE_ENTITY_NAMES = new Set(Object.keys(NATIVE_ENTITY_DEFS));
6266
+ function isNativeEntity(name) {
6267
+ return NATIVE_ENTITY_NAMES.has(name);
6268
+ }
6269
+ var NATIVE_INTERNAL_NAMES = /* @__PURE__ */ new Set([
6270
+ "chat_threads",
6271
+ "chat_messages"
6272
+ ]);
6273
+ function isInternalNativeEntity(name) {
6274
+ return NATIVE_INTERNAL_NAMES.has(name);
6275
+ }
6276
+ function registerNativeEntities(db) {
6277
+ const existing = new Set(db.getRegisteredTableNames());
6278
+ for (const [name, def] of Object.entries(NATIVE_ENTITY_DEFS)) {
6279
+ if (existing.has(name)) continue;
6280
+ db.define(name, def);
6281
+ }
6282
+ }
6283
+ var NATIVE_REGISTRY_TABLE = "__lattice_native_entities";
6284
+ var NATIVE_REGISTRY_DEF = {
6285
+ columns: {
6286
+ entity: "TEXT PRIMARY KEY",
6287
+ table_name: "TEXT NOT NULL",
6288
+ adopted_at: "TEXT NOT NULL",
6289
+ origin: "TEXT NOT NULL"
6290
+ },
6291
+ primaryKey: "entity",
6292
+ render: () => "",
6293
+ outputFile: ".lattice-native/native-entities.md"
6294
+ };
6295
+ async function adoptNativeEntities(db, options = {}) {
6296
+ const onConflict = options.onConflict ?? "adopt";
6297
+ await db.defineLate(NATIVE_REGISTRY_TABLE, NATIVE_REGISTRY_DEF);
6298
+ const results = [];
6299
+ for (const [name, def] of Object.entries(NATIVE_ENTITY_DEFS)) {
6300
+ const physicalCols = await db.introspectColumns(name);
6301
+ const exists = physicalCols.length > 0;
6302
+ const registered = new Set(db.getRegisteredTableNames());
6303
+ if (!exists) {
6304
+ if (!registered.has(name)) await db.defineLate(name, def);
6305
+ results.push({ entity: name, tableName: name, origin: "created" });
6306
+ await recordNativeBinding(db, name, "created");
6307
+ continue;
6308
+ }
6309
+ if (onConflict === "error") {
6310
+ throw new Error(
6311
+ `adoptNativeEntities: physical table "${name}" already exists; refusing to adopt with onConflict:'error'`
6312
+ );
6313
+ }
6314
+ const nativeCols = new Set(Object.keys(def.columns));
6315
+ const hadForeignShape = physicalCols.some((c) => !nativeCols.has(c));
6316
+ const rowCount = await db.count(name);
6317
+ let origin = hadForeignShape || rowCount > 0 ? "adopted" : "created";
6318
+ if (onConflict === "skip") {
6319
+ origin = "skipped";
6320
+ } else if (!registered.has(name)) {
6321
+ await db.defineLate(name, def);
6322
+ }
6323
+ results.push({ entity: name, tableName: name, origin });
6324
+ await recordNativeBinding(db, name, origin);
6325
+ }
6326
+ return results;
6327
+ }
6328
+ async function listNativeBindings(db) {
6329
+ if (!db.getRegisteredTableNames().includes(NATIVE_REGISTRY_TABLE)) return [];
6330
+ const rows = await db.query(NATIVE_REGISTRY_TABLE);
6331
+ return rows.map((r) => ({
6332
+ entity: String(r.entity),
6333
+ tableName: String(r.table_name),
6334
+ origin: r.origin
6335
+ }));
6336
+ }
6337
+ async function recordNativeBinding(db, entity, origin) {
6338
+ await db.upsert(NATIVE_REGISTRY_TABLE, {
6339
+ entity,
6340
+ table_name: entity,
6341
+ adopted_at: (/* @__PURE__ */ new Date()).toISOString(),
6342
+ origin
6343
+ });
6344
+ }
6345
+
6346
+ // src/gui/data.ts
6110
6347
  function tableToSummary(name, definition) {
6111
6348
  return {
6112
6349
  name,
@@ -6264,6 +6501,7 @@ function buildGuiGraph(configPath, outputDir, options = {}) {
6264
6501
  const filter = options.visibleFilter;
6265
6502
  data.tables = data.tables.filter((t) => filter(t.name));
6266
6503
  }
6504
+ data.tables = data.tables.filter((t) => !isInternalNativeEntity(t.name));
6267
6505
  const nodes = /* @__PURE__ */ new Map();
6268
6506
  const edges = /* @__PURE__ */ new Map();
6269
6507
  const fileOwners = /* @__PURE__ */ new Map();
@@ -6742,6 +6980,9 @@ var css = `
6742
6980
  padding: 0 12px; margin: 12px 0 6px;
6743
6981
  }
6744
6982
  .section-label:first-child { margin-top: 0; }
6983
+ /* Extra breathing room above the "SYSTEM" heading so it isn't cramped
6984
+ against the object list above it. */
6985
+ #system-section .section-label { margin-top: 20px; }
6745
6986
  nav ul { list-style: none; padding: 0; margin: 0; }
6746
6987
  nav li a {
6747
6988
  display: flex; align-items: center; gap: 10px;
@@ -7612,6 +7853,9 @@ var css = `
7612
7853
  overflow-wrap: break-word; word-break: break-word;
7613
7854
  }
7614
7855
  .rail-composer textarea:focus { outline: none; border-color: var(--accent); box-shadow: var(--glow-focus); }
7856
+ /* While a voice note is being recorded/transcribed the textarea is read-only
7857
+ (shows a "Listening\u2026" / "Transcribing\u2026" placeholder, not editable). */
7858
+ .rail-composer textarea.recording { opacity: 0.6; cursor: not-allowed; }
7615
7859
  .rail-composer .composer-row { display: flex; gap: 8px; align-items: flex-end; }
7616
7860
  .rail-composer .composer-send {
7617
7861
  flex: 0 0 auto; height: 38px; padding: 0 14px; border: none; border-radius: 8px;
@@ -8295,11 +8539,21 @@ var appJs = `
8295
8539
  } catch (_) { return ''; }
8296
8540
  }
8297
8541
 
8542
+ // Elapsed duration since a start timestamp (ms), for in-progress work like a
8543
+ // running upload \u2014 no "ago" suffix. Mirrors relTime's unit thresholds.
8544
+ function formatElapsed(ms) {
8545
+ var s = Math.max(0, Math.floor(ms / 1000));
8546
+ if (s < 60) return s + 's';
8547
+ if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
8548
+ return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
8549
+ }
8550
+
8298
8551
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
8299
- // Full-text search \u2014 GET /api/search, grouped dropdown, click to open.
8552
+ // Search \u2014 the top box hands the query to the AI ASSISTANT (which answers
8553
+ // conversationally using its search/read tools), not a plain full-text
8554
+ // match. hideSearchResults/openSearchHit are retained because the activity
8555
+ // feed still uses openSearchHit to jump to a row.
8300
8556
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
8301
- var searchTimer = null;
8302
- var searchSeq = 0;
8303
8557
  function hideSearchResults() {
8304
8558
  var box = document.getElementById('search-results');
8305
8559
  if (box) { box.hidden = true; box.innerHTML = ''; }
@@ -8313,66 +8567,30 @@ var appJs = `
8313
8567
  var prefix = advancedMode() ? '#/objects/' : '#/fs/';
8314
8568
  location.hash = prefix + encodeURIComponent(table) + '/' + encodeURIComponent(id);
8315
8569
  }
8316
- function renderSearchResults(result) {
8317
- var box = document.getElementById('search-results');
8318
- if (!box) return;
8319
- var groups = (result && result.groups) || [];
8320
- if (!groups.length) {
8321
- box.innerHTML = '<div class="search-empty">No matches</div>';
8322
- box.hidden = false;
8323
- return;
8324
- }
8325
- var html = '';
8326
- groups.forEach(function (g) {
8327
- var disp = displayFor(g.table);
8328
- html += '<div class="search-group">' +
8329
- '<div class="search-group-head"><span class="search-group-icon">' + disp.icon +
8330
- '</span>' + escapeHtml(disp.label) +
8331
- (g.more ? ' <span class="search-more">' + g.count + '+</span>' : '') + '</div>';
8332
- g.hits.forEach(function (h) {
8333
- html += '<button type="button" class="search-hit" data-table="' + escapeHtml(g.table) +
8334
- '" data-id="' + escapeHtml(h.id) + '">' +
8335
- '<span class="search-snippet">' + escapeHtml(h.snippet || h.id) + '</span></button>';
8336
- });
8337
- html += '</div>';
8338
- });
8339
- box.innerHTML = html;
8340
- box.hidden = false;
8341
- box.querySelectorAll('.search-hit').forEach(function (btn) {
8342
- btn.addEventListener('click', function () {
8343
- openSearchHit(btn.getAttribute('data-table'), btn.getAttribute('data-id'));
8344
- });
8345
- });
8346
- }
8347
- function runSearch(q) {
8348
- var seq = ++searchSeq;
8349
- fetchJson('/api/search?q=' + encodeURIComponent(q) + '&limit=6').then(function (result) {
8350
- if (seq !== searchSeq) return; // a newer query superseded this one
8351
- renderSearchResults(result);
8352
- }).catch(function () { /* transient \u2014 ignore */ });
8570
+ // Route the typed query into the assistant rail as a chat turn. Opens the
8571
+ // rail (a no-op on desktop; opens the mobile drawer) and submits via the
8572
+ // same path as the composer, so the assistant searches + answers.
8573
+ function askAssistant(q) {
8574
+ hideSearchResults();
8575
+ var input = document.getElementById('search-input');
8576
+ if (input) input.value = '';
8577
+ var rail = document.getElementById('assistant-rail');
8578
+ if (rail) rail.classList.add('expanded');
8579
+ var chatInput = document.getElementById('chat-input');
8580
+ if (chatInput) chatInput.focus();
8581
+ sendChat(q);
8353
8582
  }
8354
8583
  function initSearch() {
8355
8584
  var input = document.getElementById('search-input');
8356
- var box = document.getElementById('search-results');
8357
- if (!input || !box) return;
8358
- input.addEventListener('input', function () {
8359
- var q = input.value.trim();
8360
- if (searchTimer) clearTimeout(searchTimer);
8361
- if (q.length < 2) { hideSearchResults(); return; }
8362
- searchTimer = setTimeout(function () { runSearch(q); }, 180);
8363
- });
8585
+ if (!input) return;
8364
8586
  input.addEventListener('keydown', function (e) {
8365
- if (e.key === 'Escape') { hideSearchResults(); input.blur(); }
8587
+ if (e.key === 'Escape') { input.value = ''; input.blur(); }
8366
8588
  else if (e.key === 'Enter') {
8367
- var first = box.querySelector('.search-hit');
8368
- if (first) { e.preventDefault(); first.click(); }
8589
+ e.preventDefault();
8590
+ var q = input.value.trim();
8591
+ if (q) askAssistant(q);
8369
8592
  }
8370
8593
  });
8371
- // Dismiss when a click lands outside the search box.
8372
- document.addEventListener('click', function (e) {
8373
- var host = document.getElementById('topsearch');
8374
- if (host && !host.contains(e.target)) hideSearchResults();
8375
- });
8376
8594
  }
8377
8595
 
8378
8596
  /** Reload column meta after a secret-flag change. */
@@ -8953,6 +9171,14 @@ var appJs = `
8953
9171
  clearUnseen(tableName);
8954
9172
  var t = tableByName(tableName);
8955
9173
  if (!t) {
9174
+ // Conversation-storage tables (chat_messages/chat_threads) and other
9175
+ // Lattice internals aren't in the Objects list, but are browsable
9176
+ // read-only under "System". If something routed here for one of them,
9177
+ // fall back to the system-table view instead of "Unknown entity".
9178
+ if ((state.systemTables || []).some(function (s) { return s.name === tableName; })) {
9179
+ renderSystemTable(content, tableName);
9180
+ return;
9181
+ }
8956
9182
  content.innerHTML = '<div class="placeholder">Unknown entity: ' + escapeHtml(tableName) + '</div>';
8957
9183
  return;
8958
9184
  }
@@ -12672,7 +12898,14 @@ var appJs = `
12672
12898
  // Ops whose consecutive runs collapse into one counted bubble (bulk row work
12673
12899
  // spams N near-identical rows otherwise). Schema/undo/redo stay distinct.
12674
12900
  var GROUPABLE_OPS = { insert: 1, update: 1, delete: 1, link: 1, unlink: 1 };
12675
- var lastFeedGroup = null; // { key, count, item, summaryEl, timeEl }
12901
+ // Active group bubbles keyed by op|table|source so a burst of identical
12902
+ // events coalesces into ONE counted bubble even when other events interleave
12903
+ // (a bulk ingest emits create/link/update across several tables at once \u2014
12904
+ // consecutive-only grouping let those interleaved runs spam the feed). A
12905
+ // group stays "open" for FEED_GROUP_WINDOW_MS after its last hit; later
12906
+ // activity starts a fresh bubble so unrelated edits aren't merged in.
12907
+ var feedGroups = {}; // key -> { count, item, summaryEl, timeEl, last }
12908
+ var FEED_GROUP_WINDOW_MS = 15000;
12676
12909
  function groupedSummary(op, table, count) {
12677
12910
  var t = String(table || '');
12678
12911
  switch (op) {
@@ -12689,25 +12922,31 @@ var appJs = `
12689
12922
  if (!feedEl) return;
12690
12923
  var empty = document.getElementById('rail-empty');
12691
12924
  if (empty) empty.remove();
12692
- // Coalesce a run of identical events (same op + table + source) into the
12693
- // previous bubble with a count \u2014 but only while that bubble is still the
12694
- // last thing in the feed (a chat bubble or a different event breaks the run).
12925
+ // Coalesce identical events (same op + table + source) into one counted
12926
+ // bubble. Unlike the old consecutive-only rule, the bubble is found by key
12927
+ // within a recency window, so interleaved bursts still merge instead of
12928
+ // spamming a pill per event.
12695
12929
  var groupKey = GROUPABLE_OPS[ev.op] && ev.table
12696
12930
  ? String(ev.op) + '|' + String(ev.table) + '|' + String(ev.source || '')
12697
12931
  : null;
12698
- if (groupKey && lastFeedGroup && lastFeedGroup.key === groupKey &&
12699
- feedEl.lastElementChild === lastFeedGroup.item) {
12700
- lastFeedGroup.count += 1;
12701
- lastFeedGroup.summaryEl.textContent = groupedSummary(ev.op, ev.table, lastFeedGroup.count);
12702
- lastFeedGroup.timeEl.textContent = relTime(ev.ts);
12703
- // A grouped bubble stands for many rows \u2014 disable the single-row click.
12704
- lastFeedGroup.item._rowClickOff = true;
12705
- lastFeedGroup.item.classList.remove('feed-clickable');
12706
- lastFeedGroup.item.removeAttribute('role');
12707
- lastFeedGroup.item.removeAttribute('tabindex');
12708
- lastFeedGroup.item.removeAttribute('title');
12709
- feedEl.scrollTop = feedEl.scrollHeight;
12710
- return;
12932
+ var nowMs = Date.now();
12933
+ if (groupKey) {
12934
+ var g = feedGroups[groupKey];
12935
+ if (g && g.item.parentNode === feedEl && (nowMs - g.last) < FEED_GROUP_WINDOW_MS) {
12936
+ g.count += 1;
12937
+ g.last = nowMs;
12938
+ g.summaryEl.textContent = groupedSummary(ev.op, ev.table, g.count);
12939
+ g.timeEl.textContent = relTime(ev.ts);
12940
+ // A grouped bubble stands for many rows \u2014 disable the single-row click,
12941
+ // and expose it to assistive tech as a live status, not a button.
12942
+ g.item._rowClickOff = true;
12943
+ g.item.classList.remove('feed-clickable');
12944
+ g.item.removeAttribute('tabindex');
12945
+ g.item.removeAttribute('title');
12946
+ g.item.setAttribute('role', 'status');
12947
+ feedEl.scrollTop = feedEl.scrollHeight;
12948
+ return;
12949
+ }
12711
12950
  }
12712
12951
  var item = document.createElement('div');
12713
12952
  item.className = 'feed-item';
@@ -12750,10 +12989,10 @@ var appJs = `
12750
12989
  }
12751
12990
  feedEl.appendChild(item);
12752
12991
  feedEl.scrollTop = feedEl.scrollHeight;
12753
- // Start a new group anchored on this bubble (groupable ops only).
12754
- lastFeedGroup = groupKey
12755
- ? { key: groupKey, count: 1, item: item, summaryEl: summary, timeEl: time }
12756
- : null;
12992
+ // Register/refresh the group anchored on this bubble (groupable ops only).
12993
+ if (groupKey) {
12994
+ feedGroups[groupKey] = { count: 1, item: item, summaryEl: summary, timeEl: time, last: nowMs };
12995
+ }
12757
12996
  }
12758
12997
  function startFeed() {
12759
12998
  if (feedSource) {
@@ -12766,14 +13005,16 @@ var appJs = `
12766
13005
  var data;
12767
13006
  try { data = JSON.parse(ev.data); } catch (_) { return; /* ignore malformed */ }
12768
13007
  try { renderFeedItem(data); } catch (_) { /* render best-effort */ }
12769
- // A server-side mutation (e.g. the Context Constructor ingesting a file)
12770
- // can create a brand-new entity or junction the client hasn't loaded.
12771
- // The local feed bus delivers these even when there's no realtime
12772
- // broker (SQLite), so refresh the entity list + sidebar live \u2014 otherwise
12773
- // the new object is missing from the nav and routing to it shows
12774
- // "Unknown entity" until a manual page reload. Debounced so a burst of
12775
- // schema events from one ingest coalesces into a single refetch.
12776
- if (data && (data.op === 'schema' || (data.table && !tableByName(data.table)))) {
13008
+ // Refresh on ANY data mutation, not just schema/new-table events. The
13009
+ // local feed bus delivers every insert/update/delete/link even when
13010
+ // there's no realtime broker (SQLite/local), so this is what makes the
13011
+ // home dashboard counts AND the open entity view live-update without a
13012
+ // manual reload (previously only schema ops or brand-new tables did).
13013
+ // scheduleRealtimeRefresh is debounced (200ms) so a burst from one
13014
+ // ingest still coalesces into a single refetch \u2014 and on Postgres/cloud
13015
+ // it shares that debounce with the realtime 'change' handler (no double
13016
+ // fetch). See Rule 28: /api/entities uses batched counts, not N queries.
13017
+ if (data && (data.table || data.op === 'schema')) {
12777
13018
  scheduleRealtimeRefresh();
12778
13019
  }
12779
13020
  });
@@ -12853,7 +13094,7 @@ var appJs = `
12853
13094
  if (!feedEl) return;
12854
13095
  var items = feedEl.querySelectorAll('.feed-item');
12855
13096
  for (var i = 0; i < items.length; i++) items[i].remove();
12856
- lastFeedGroup = null;
13097
+ feedGroups = {};
12857
13098
  }
12858
13099
  function newChat() {
12859
13100
  currentThreadId = null;
@@ -13109,6 +13350,32 @@ var appJs = `
13109
13350
  var audioChunks = [];
13110
13351
  function setMicState(btn, state) {
13111
13352
  recState = state;
13353
+ // Mirror the recording lifecycle onto the composer. While recording or
13354
+ // transcribing, the textarea is read-only (it shows a status placeholder,
13355
+ // not editable text) and the Send button is disabled \u2014 you can't send a
13356
+ // half-captured voice note. Returning to idle restores both, then the
13357
+ // transcript is dropped in (see rec.onstop).
13358
+ var inp = document.getElementById('chat-input');
13359
+ var snd = document.getElementById('chat-send');
13360
+ var busy = state === 'recording' || state === 'transcribing';
13361
+ if (inp) {
13362
+ if (busy) {
13363
+ if (inp._restorePlaceholder == null) {
13364
+ inp._restorePlaceholder = inp.getAttribute('placeholder') || '';
13365
+ }
13366
+ inp.setAttribute('readonly', 'readonly');
13367
+ inp.classList.add('recording');
13368
+ inp.setAttribute('placeholder', state === 'recording' ? 'Listening\u2026' : 'Transcribing\u2026');
13369
+ } else {
13370
+ inp.removeAttribute('readonly');
13371
+ inp.classList.remove('recording');
13372
+ if (inp._restorePlaceholder != null) {
13373
+ inp.setAttribute('placeholder', inp._restorePlaceholder);
13374
+ inp._restorePlaceholder = null;
13375
+ }
13376
+ }
13377
+ }
13378
+ if (snd) snd.disabled = busy;
13112
13379
  if (!btn) return;
13113
13380
  btn.classList.remove('recording', 'transcribing');
13114
13381
  if (state === 'recording') { btn.classList.add('recording'); btn.textContent = '\u23F9'; btn.title = 'Stop recording'; btn.disabled = false; }
@@ -13208,10 +13475,23 @@ var appJs = `
13208
13475
  item.innerHTML =
13209
13476
  '<div class="feed-icon"><span class="feed-spinner"></span></div>' +
13210
13477
  '<div class="feed-body"><div class="feed-summary">Analyzing ' + escapeHtml(label) + '\u2026</div></div>' +
13211
- '<div class="feed-time"></div>';
13478
+ '<div class="feed-time">0s</div>';
13212
13479
  feedEl.appendChild(item);
13213
13480
  feedEl.scrollTop = feedEl.scrollHeight;
13214
- return function () { if (item.parentNode) item.parentNode.removeChild(item); };
13481
+ // Live elapsed-time counter while the upload + server-side extraction run.
13482
+ // Previously the time element was left empty (rendered as a stuck "0s")
13483
+ // because nothing tracked or updated it. Tick once a second; the cleanup
13484
+ // returned below clears the interval (and self-clears if the node is gone).
13485
+ var started = Date.now();
13486
+ var timeEl = item.querySelector('.feed-time');
13487
+ var tick = setInterval(function () {
13488
+ if (!item.parentNode || !timeEl) { clearInterval(tick); return; }
13489
+ timeEl.textContent = formatElapsed(Date.now() - started);
13490
+ }, 1000);
13491
+ return function () {
13492
+ clearInterval(tick);
13493
+ if (item.parentNode) item.parentNode.removeChild(item);
13494
+ };
13215
13495
  }
13216
13496
  function uploadFile(file) {
13217
13497
  var done = pendingIngestItem(file.name || 'file');
@@ -13386,7 +13666,7 @@ var guiAppHtml = `<!doctype html>
13386
13666
  </div>
13387
13667
  <div class="topsearch" id="topsearch">
13388
13668
  <span class="topsearch-icon" aria-hidden="true">\u{1F50D}</span>
13389
- <input type="search" id="search-input" placeholder="Search all tables\u2026" autocomplete="off" spellcheck="false" aria-label="Full-text search" />
13669
+ <input type="search" id="search-input" placeholder="Ask the assistant\u2026" autocomplete="off" spellcheck="false" aria-label="Ask the assistant" />
13390
13670
  <div class="search-results" id="search-results" hidden></div>
13391
13671
  </div>
13392
13672
  <div class="history-controls">
@@ -15190,203 +15470,6 @@ function saveConfigDoc(configPath, doc) {
15190
15470
  writeFileSync6(configPath, doc.toString(), "utf8");
15191
15471
  }
15192
15472
 
15193
- // src/framework/native-entities.ts
15194
- var NATIVE_ENTITY_DEFS = {
15195
- secrets: {
15196
- columns: {
15197
- id: "TEXT PRIMARY KEY",
15198
- // NOT NULL needs a DEFAULT so ALTER TABLE ADD COLUMN succeeds when this
15199
- // native shape is merged onto a pre-existing table (the adopt + team
15200
- // shared-schema sync paths use ADD COLUMN; SQLite + Postgres both reject
15201
- // a NOT NULL add without a default). Every insert sets `name` explicitly,
15202
- // so the default is never observed in practice.
15203
- name: "TEXT NOT NULL DEFAULT ''",
15204
- kind: "TEXT",
15205
- value: "TEXT",
15206
- description: "TEXT",
15207
- created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
15208
- updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
15209
- deleted_at: "TEXT"
15210
- },
15211
- encrypted: { columns: ["value"] },
15212
- render: () => "",
15213
- outputFile: ".lattice-native/secrets.md"
15214
- },
15215
- files: {
15216
- columns: {
15217
- id: "TEXT PRIMARY KEY",
15218
- // Legacy columns (DEPRECATED v2.0 — retained for back-compat, NOT dropped).
15219
- // path — superseded by the reference model below (`ref_kind`/`ref_uri`).
15220
- // New local-file ingestion records a `local_ref` via
15221
- // `referenceLocalFile()` rather than writing `path`; readers fall
15222
- // back to `ref_uri`. Do not write `path` in new code.
15223
- // kind — orphaned: superseded by `mime` (content type) and `ref_kind`
15224
- // (the blob/local_ref/cloud_ref discriminator). Not read or
15225
- // written by production code; kept only so existing rows retain
15226
- // their value.
15227
- path: "TEXT",
15228
- kind: "TEXT",
15229
- // Content-addressed storage. `sha256` is the canonical content
15230
- // identifier; `blob_path` is the relative path under
15231
- // `<lattice-root>/data/blobs/` written by attachBlob().
15232
- original_name: "TEXT",
15233
- mime: "TEXT",
15234
- size_bytes: "INTEGER",
15235
- sha256: "TEXT",
15236
- blob_path: "TEXT",
15237
- // Reference mode (v2.0): a row can INDEX data that lives elsewhere
15238
- // instead of owning a copy. All nullable + additive (back-compat).
15239
- // ref_kind discriminator: 'blob' | 'local_ref' | 'cloud_ref'
15240
- // (NULL ⇒ legacy/owned blob)
15241
- // ref_uri durable pointer: absolute local path or remote URL
15242
- // ref_provider resolver selector: 'fs' | 'web' | 'gdrive'
15243
- // source_json provider-specific metadata (etag, availability, …)
15244
- ref_kind: "TEXT",
15245
- ref_uri: "TEXT",
15246
- ref_provider: "TEXT",
15247
- source_json: "TEXT",
15248
- extraction_status: "TEXT",
15249
- extracted_text: "TEXT",
15250
- description: "TEXT",
15251
- created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
15252
- updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
15253
- deleted_at: "TEXT"
15254
- },
15255
- render: () => "",
15256
- outputFile: ".lattice-native/files.md"
15257
- },
15258
- notes: {
15259
- // A generic knowledge object: a free-form note with a title and body.
15260
- // Ordinary, user-editable rows; `source_file_id` optionally points back at
15261
- // an originating `files` row. Retained as native (1.16.3) because the
15262
- // reference/source-organizer store uses it as the fallback organizer target.
15263
- columns: {
15264
- id: "TEXT PRIMARY KEY",
15265
- title: "TEXT",
15266
- body: "TEXT",
15267
- source_file_id: "TEXT",
15268
- created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
15269
- updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
15270
- deleted_at: "TEXT"
15271
- },
15272
- render: () => "",
15273
- outputFile: ".lattice-native/notes.md"
15274
- },
15275
- chat_threads: {
15276
- // An assistant conversation. Native so chat history survives across
15277
- // sessions and is queryable/renderable like any other Lattice entity.
15278
- columns: {
15279
- id: "TEXT PRIMARY KEY",
15280
- title: "TEXT",
15281
- created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
15282
- updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
15283
- deleted_at: "TEXT"
15284
- },
15285
- render: () => "",
15286
- outputFile: ".lattice-native/chat-threads.md"
15287
- },
15288
- chat_messages: {
15289
- // One turn (or feed entry) within a chat_thread.
15290
- columns: {
15291
- id: "TEXT PRIMARY KEY",
15292
- // Soft reference to chat_threads.id. Kept as a plain column (no FK)
15293
- // to match the generic, dialect-agnostic native-entity style.
15294
- thread_id: "TEXT",
15295
- // user | assistant | tool | feed | system
15296
- role: "TEXT NOT NULL DEFAULT 'user'",
15297
- // JSON payload: text, tool_use / tool_result blocks, attachments, or
15298
- // (for role='feed') the feed-event details.
15299
- content_json: "TEXT",
15300
- // ai | gui | cli | ingest — meaningful for role='feed'.
15301
- source: "TEXT",
15302
- created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
15303
- deleted_at: "TEXT"
15304
- },
15305
- render: () => "",
15306
- outputFile: ".lattice-native/chat-messages.md"
15307
- }
15308
- };
15309
- var NATIVE_ENTITY_NAMES = new Set(Object.keys(NATIVE_ENTITY_DEFS));
15310
- function isNativeEntity(name) {
15311
- return NATIVE_ENTITY_NAMES.has(name);
15312
- }
15313
- var NATIVE_INTERNAL_NAMES = /* @__PURE__ */ new Set([
15314
- "chat_threads",
15315
- "chat_messages"
15316
- ]);
15317
- function isInternalNativeEntity(name) {
15318
- return NATIVE_INTERNAL_NAMES.has(name);
15319
- }
15320
- function registerNativeEntities(db) {
15321
- const existing = new Set(db.getRegisteredTableNames());
15322
- for (const [name, def] of Object.entries(NATIVE_ENTITY_DEFS)) {
15323
- if (existing.has(name)) continue;
15324
- db.define(name, def);
15325
- }
15326
- }
15327
- var NATIVE_REGISTRY_TABLE = "__lattice_native_entities";
15328
- var NATIVE_REGISTRY_DEF = {
15329
- columns: {
15330
- entity: "TEXT PRIMARY KEY",
15331
- table_name: "TEXT NOT NULL",
15332
- adopted_at: "TEXT NOT NULL",
15333
- origin: "TEXT NOT NULL"
15334
- },
15335
- primaryKey: "entity",
15336
- render: () => "",
15337
- outputFile: ".lattice-native/native-entities.md"
15338
- };
15339
- async function adoptNativeEntities(db, options = {}) {
15340
- const onConflict = options.onConflict ?? "adopt";
15341
- await db.defineLate(NATIVE_REGISTRY_TABLE, NATIVE_REGISTRY_DEF);
15342
- const results = [];
15343
- for (const [name, def] of Object.entries(NATIVE_ENTITY_DEFS)) {
15344
- const physicalCols = await db.introspectColumns(name);
15345
- const exists = physicalCols.length > 0;
15346
- const registered = new Set(db.getRegisteredTableNames());
15347
- if (!exists) {
15348
- if (!registered.has(name)) await db.defineLate(name, def);
15349
- results.push({ entity: name, tableName: name, origin: "created" });
15350
- await recordNativeBinding(db, name, "created");
15351
- continue;
15352
- }
15353
- if (onConflict === "error") {
15354
- throw new Error(
15355
- `adoptNativeEntities: physical table "${name}" already exists; refusing to adopt with onConflict:'error'`
15356
- );
15357
- }
15358
- const nativeCols = new Set(Object.keys(def.columns));
15359
- const hadForeignShape = physicalCols.some((c) => !nativeCols.has(c));
15360
- const rowCount = await db.count(name);
15361
- let origin = hadForeignShape || rowCount > 0 ? "adopted" : "created";
15362
- if (onConflict === "skip") {
15363
- origin = "skipped";
15364
- } else if (!registered.has(name)) {
15365
- await db.defineLate(name, def);
15366
- }
15367
- results.push({ entity: name, tableName: name, origin });
15368
- await recordNativeBinding(db, name, origin);
15369
- }
15370
- return results;
15371
- }
15372
- async function listNativeBindings(db) {
15373
- if (!db.getRegisteredTableNames().includes(NATIVE_REGISTRY_TABLE)) return [];
15374
- const rows = await db.query(NATIVE_REGISTRY_TABLE);
15375
- return rows.map((r) => ({
15376
- entity: String(r.entity),
15377
- tableName: String(r.table_name),
15378
- origin: r.origin
15379
- }));
15380
- }
15381
- async function recordNativeBinding(db, entity, origin) {
15382
- await db.upsert(NATIVE_REGISTRY_TABLE, {
15383
- entity,
15384
- table_name: entity,
15385
- adopted_at: (/* @__PURE__ */ new Date()).toISOString(),
15386
- origin
15387
- });
15388
- }
15389
-
15390
15473
  // src/gui/schema-ops.ts
15391
15474
  async function listPhysicalUserTables(active) {
15392
15475
  const adapter = active.db._adapter;
@@ -15569,6 +15652,130 @@ async function createUserEntity(active, name, columns, sessionId, opts) {
15569
15652
  );
15570
15653
  return entity;
15571
15654
  }
15655
+ async function softDeleteUserEntity(active, name, sessionId, summary) {
15656
+ const doc = loadConfigDoc(active.configPath);
15657
+ const entityDef = doc.toJS().entities?.[name];
15658
+ doc.deleteIn(["entities", name]);
15659
+ saveConfigDoc(active.configPath, doc);
15660
+ active.db.unregisterTable(name);
15661
+ active.validTables.delete(name);
15662
+ active.junctionTables.delete(name);
15663
+ active.softDeletable.delete(name);
15664
+ active.entityContextByTable.delete(name);
15665
+ syncCanonicalContexts(active);
15666
+ await recordSchemaOp(
15667
+ active,
15668
+ "schema.delete_entity",
15669
+ name,
15670
+ { entity: name, entityDef },
15671
+ null,
15672
+ summary ?? `Deleted table ${name}`,
15673
+ sessionId
15674
+ );
15675
+ }
15676
+ var AI_DELETE_ROW_CAP = 1e3;
15677
+ async function aiDeleteEntity(active, name, resolution, sessionId) {
15678
+ if (!active.validTables.has(name)) return { ok: false, error: `Unknown table: ${name}` };
15679
+ if (isNativeEntity(name)) {
15680
+ return { ok: false, error: `"${name}" is a built-in table and cannot be deleted.` };
15681
+ }
15682
+ const tc = active.teamContext;
15683
+ if (tc && tc.owners.get(name) !== tc.myUserId) {
15684
+ return { ok: false, error: `Only the table's owner can delete "${name}".` };
15685
+ }
15686
+ const inbound = [];
15687
+ for (const t of getGuiEntities(active.configPath, active.outputDir).tables) {
15688
+ if (t.name === name) continue;
15689
+ for (const rel of Object.values(t.relations)) {
15690
+ if (rel.type === "belongsTo" && rel.table === name) {
15691
+ inbound.push(`${t.name}.${rel.foreignKey}`);
15692
+ }
15693
+ }
15694
+ }
15695
+ if (inbound.length > 0) {
15696
+ return {
15697
+ ok: false,
15698
+ error: `Cannot delete "${name}" \u2014 these links point at it: ${inbound.join(", ")}. Remove those links first.`
15699
+ };
15700
+ }
15701
+ const mctx = {
15702
+ db: active.db,
15703
+ feed: active.feed,
15704
+ softDeletable: active.softDeletable,
15705
+ source: "ai"
15706
+ };
15707
+ const softDeletable = active.softDeletable.has(name);
15708
+ const rowCount = softDeletable ? await active.db.count(name, { filters: [{ col: "deleted_at", op: "isNull" }] }) : await active.db.count(name);
15709
+ if (rowCount === 0) {
15710
+ await softDeleteUserEntity(active, name, sessionId);
15711
+ return { ok: true, deleted: name };
15712
+ }
15713
+ if (resolution === void 0) {
15714
+ return {
15715
+ needsResolution: true,
15716
+ rowCount,
15717
+ message: `"${name}" still has ${String(rowCount)} row${rowCount === 1 ? "" : "s"}. Deleting a table with data needs a decision first \u2014 ask the user whether to delete the rows too (reversible), move them into another table, or cancel. Then call delete_entity again with resolution="delete_data" or resolution={"move_to":"<table>"}.`
15718
+ };
15719
+ }
15720
+ if (resolution === "delete_data") {
15721
+ if (!softDeletable) {
15722
+ return {
15723
+ ok: false,
15724
+ error: `"${name}" rows can't be soft-deleted (no deleted_at column) \u2014 clear them manually first.`
15725
+ };
15726
+ }
15727
+ if (rowCount > AI_DELETE_ROW_CAP) {
15728
+ return {
15729
+ ok: false,
15730
+ error: `"${name}" has ${String(rowCount)} rows \u2014 too many to auto-delete safely (cap ${String(AI_DELETE_ROW_CAP)}). Trim it first.`
15731
+ };
15732
+ }
15733
+ const rows2 = await active.db.query(name, {
15734
+ filters: [{ col: "deleted_at", op: "isNull" }],
15735
+ limit: AI_DELETE_ROW_CAP
15736
+ });
15737
+ let deletedRows = 0;
15738
+ for (const r of rows2) {
15739
+ await deleteRow(mctx, name, String(r.id), false);
15740
+ deletedRows++;
15741
+ }
15742
+ await softDeleteUserEntity(active, name, sessionId);
15743
+ return { ok: true, deleted: name, deletedRows };
15744
+ }
15745
+ const target = resolution.move_to;
15746
+ if (!active.validTables.has(target)) {
15747
+ return { ok: false, error: `move_to target "${target}" is not a known table.` };
15748
+ }
15749
+ if (target === name) return { ok: false, error: "move_to target must be a different table." };
15750
+ if (active.junctionTables.has(target) || isNativeEntity(target)) {
15751
+ return { ok: false, error: `Cannot move rows into "${target}".` };
15752
+ }
15753
+ if (rowCount > AI_DELETE_ROW_CAP) {
15754
+ return {
15755
+ ok: false,
15756
+ error: `"${name}" has ${String(rowCount)} rows \u2014 too many to auto-move (cap ${String(AI_DELETE_ROW_CAP)}).`
15757
+ };
15758
+ }
15759
+ const targetCols = active.db.getRegisteredColumns(target);
15760
+ if (!targetCols) return { ok: false, error: `Could not read the columns of "${target}".` };
15761
+ const rows = await active.db.query(
15762
+ name,
15763
+ softDeletable ? { filters: [{ col: "deleted_at", op: "isNull" }], limit: AI_DELETE_ROW_CAP } : { limit: AI_DELETE_ROW_CAP }
15764
+ );
15765
+ const SKIP = /* @__PURE__ */ new Set(["id", "deleted_at", "created_at", "updated_at"]);
15766
+ let movedRows = 0;
15767
+ for (const r of rows) {
15768
+ const mapped = {};
15769
+ for (const [k, v] of Object.entries(r)) {
15770
+ if (!SKIP.has(k) && k in targetCols) mapped[k] = v;
15771
+ }
15772
+ await createRow(mctx, target, mapped);
15773
+ if (softDeletable) await deleteRow(mctx, name, String(r.id), false);
15774
+ movedRows++;
15775
+ }
15776
+ await softDeleteUserEntity(active, name, sessionId);
15777
+ return { ok: true, deleted: name, movedRows };
15778
+ }
15572
15779
 
15573
15780
  // src/teams/server/routes.ts
15574
15781
  var UNAUTHENTICATED_TEAM_PATHS = /* @__PURE__ */ new Set([
@@ -18078,6 +18285,9 @@ async function dispatchUserConfigRoute(req, res, ctx) {
18078
18285
  const body = await readJson(req);
18079
18286
  const current = readPreferences();
18080
18287
  const next = {
18288
+ // Preserve keys this endpoint doesn't manage (voice_provider,
18289
+ // aggressiveness — set via the assistant config routes).
18290
+ ...current,
18081
18291
  show_system_tables: typeof body.show_system_tables === "boolean" ? body.show_system_tables : current.show_system_tables,
18082
18292
  analytics: typeof body.analytics === "boolean" ? body.analytics : current.analytics
18083
18293
  };
@@ -18932,22 +19142,6 @@ function parseCookies(req) {
18932
19142
  }
18933
19143
  return out;
18934
19144
  }
18935
- async function storeSecret(db, kind, name, value) {
18936
- const [first, ...extras] = await liveSecretsOfKind(db, kind);
18937
- if (first) {
18938
- await db.update("secrets", first.id, { value, name });
18939
- for (const extra of extras)
18940
- await db.update("secrets", extra.id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
18941
- } else {
18942
- await db.insert("secrets", {
18943
- id: crypto.randomUUID(),
18944
- name,
18945
- kind,
18946
- value,
18947
- description: `${name} (assistant).`
18948
- });
18949
- }
18950
- }
18951
19145
  async function liveSecretsOfKind(db, kind) {
18952
19146
  const rows = await db.query("secrets", {
18953
19147
  filters: [{ col: "kind", op: "eq", val: kind }]
@@ -18975,19 +19169,29 @@ async function readMachineCredential(db, kind) {
18975
19169
  var STT_PROVIDER_KIND = "stt_provider";
18976
19170
  var AGGRESSIVENESS_KIND = "assistant_aggressiveness";
18977
19171
  var DEFAULT_AGGRESSIVENESS = 0.5;
18978
- async function getAggressiveness(db) {
18979
- const raw = await secretValue(db, AGGRESSIVENESS_KIND);
18980
- const n = raw === null ? NaN : Number(raw);
19172
+ function getAggressiveness() {
19173
+ const n = readPreferences().aggressiveness;
18981
19174
  if (!Number.isFinite(n)) return DEFAULT_AGGRESSIVENESS;
18982
19175
  return Math.min(1, Math.max(0, n));
18983
19176
  }
19177
+ async function retireLegacyPreferenceSecrets(db) {
19178
+ for (const kind of [STT_PROVIDER_KIND, AGGRESSIVENESS_KIND]) {
19179
+ try {
19180
+ for (const row of await liveSecretsOfKind(db, kind)) {
19181
+ await db.update("secrets", row.id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
19182
+ }
19183
+ } catch (e) {
19184
+ console.warn(`[assistant] could not retire legacy ${kind} secret:`, e.message);
19185
+ }
19186
+ }
19187
+ }
18984
19188
  function aggressivenessToTemperature(aggressiveness) {
18985
19189
  return Math.min(1, Math.max(0, aggressiveness));
18986
19190
  }
18987
19191
  async function getVoiceCredential(db) {
18988
19192
  const openai = await readMachineCredential(db, CREDENTIALS.openai.kind) ?? process.env.OPENAI_API_KEY ?? null;
18989
19193
  const eleven = await readMachineCredential(db, CREDENTIALS.elevenlabs.kind) ?? process.env.ELEVENLABS_API_KEY ?? null;
18990
- const pref = await secretValue(db, STT_PROVIDER_KIND);
19194
+ const pref = readPreferences().voice_provider;
18991
19195
  if (pref === "elevenlabs" && eleven) return { provider: "elevenlabs", apiKey: eleven };
18992
19196
  if (pref === "openai" && openai) return { provider: "openai", apiKey: openai };
18993
19197
  if (openai) return { provider: "openai", apiKey: openai };
@@ -19039,8 +19243,8 @@ async function dispatchAssistantRoute(req, res, ctx) {
19039
19243
  hasClaudeAuth: await hasClaudeAuth(db),
19040
19244
  hasVoiceKey: voice !== null,
19041
19245
  sttProvider: voice?.provider ?? null,
19042
- sttPreference: await secretValue(db, STT_PROVIDER_KIND) ?? "auto",
19043
- aggressiveness: await getAggressiveness(db),
19246
+ sttPreference: readPreferences().voice_provider,
19247
+ aggressiveness: getAggressiveness(),
19044
19248
  oauthEnabled: oauthConfigured()
19045
19249
  });
19046
19250
  return true;
@@ -19058,7 +19262,7 @@ async function dispatchAssistantRoute(req, res, ctx) {
19058
19262
  sendJson3(res, { error: "value must be a number in [0, 1]" }, 400);
19059
19263
  return true;
19060
19264
  }
19061
- await storeSecret(db, AGGRESSIVENESS_KIND, "Inference aggressiveness", String(value));
19265
+ writePreferences({ ...readPreferences(), aggressiveness: value });
19062
19266
  sendJson3(res, { ok: true, value });
19063
19267
  return true;
19064
19268
  }
@@ -19071,17 +19275,11 @@ async function dispatchAssistantRoute(req, res, ctx) {
19071
19275
  return true;
19072
19276
  }
19073
19277
  const provider = typeof body.provider === "string" ? body.provider : "auto";
19074
- if (!["auto", "openai", "elevenlabs"].includes(provider)) {
19278
+ if (provider !== "auto" && provider !== "openai" && provider !== "elevenlabs") {
19075
19279
  sendJson3(res, { error: `unknown provider: ${provider}` }, 400);
19076
19280
  return true;
19077
19281
  }
19078
- if (provider === "auto") {
19079
- for (const row of await liveSecretsOfKind(db, STT_PROVIDER_KIND)) {
19080
- await db.update("secrets", row.id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
19081
- }
19082
- } else {
19083
- await storeSecret(db, STT_PROVIDER_KIND, "Voice provider preference", provider);
19084
- }
19282
+ writePreferences({ ...readPreferences(), voice_provider: provider });
19085
19283
  sendJson3(res, { ok: true });
19086
19284
  return true;
19087
19285
  }
@@ -19265,18 +19463,36 @@ var REGISTRY = [
19265
19463
  args: obj({ table: str("Table name."), id: str("Primary key of the row.") }, ["table", "id"])
19266
19464
  },
19267
19465
  {
19268
- name: "get_history",
19269
- description: "Fetch recent audit-log entries, optionally filtered to one table.",
19466
+ name: "search",
19467
+ description: "Full-text search across the user tables for a query string. Returns matching rows grouped by table (id + snippet). Use this to find records by their content when you do not know the table or id up front.",
19270
19468
  mutates: false,
19271
19469
  category: "read",
19272
- args: obj({
19273
- table: str("Restrict to this table (optional)."),
19274
- limit: { type: "integer", description: "Maximum entries to return." }
19275
- })
19276
- },
19277
- {
19278
- name: "list_system_tables",
19279
- description: "List the internal Lattice bookkeeping tables and their schema.",
19470
+ args: obj(
19471
+ {
19472
+ query: str("Text to search for."),
19473
+ tables: {
19474
+ type: "array",
19475
+ description: "Restrict to these tables (optional; defaults to all searchable tables).",
19476
+ items: { type: "string" }
19477
+ },
19478
+ limit: { type: "integer", description: "Max hits per table (optional, default 8)." }
19479
+ },
19480
+ ["query"]
19481
+ )
19482
+ },
19483
+ {
19484
+ name: "get_history",
19485
+ description: "Fetch recent audit-log entries, optionally filtered to one table.",
19486
+ mutates: false,
19487
+ category: "read",
19488
+ args: obj({
19489
+ table: str("Restrict to this table (optional)."),
19490
+ limit: { type: "integer", description: "Maximum entries to return." }
19491
+ })
19492
+ },
19493
+ {
19494
+ name: "list_system_tables",
19495
+ description: "List the internal Lattice bookkeeping tables and their schema.",
19280
19496
  mutates: false,
19281
19497
  category: "read",
19282
19498
  args: obj({})
@@ -19385,6 +19601,26 @@ var REGISTRY = [
19385
19601
  ["table_a", "table_b"]
19386
19602
  )
19387
19603
  },
19604
+ {
19605
+ name: "delete_entity",
19606
+ description: "Soft-delete a user table (reversible \u2014 the rows are kept and it can be restored from history). Guarded: an EMPTY table is removed immediately; a NON-EMPTY table is NOT deleted until you say what to do with its data \u2014 the tool returns the row count and you must ask the user, then call again with resolution='delete_data' (soft-delete the rows too) or move_to=<table> (move the rows into another table first). Never deletes built-in tables.",
19607
+ mutates: true,
19608
+ category: "schema",
19609
+ args: obj(
19610
+ {
19611
+ name: str("Table to delete."),
19612
+ resolution: {
19613
+ type: "string",
19614
+ enum: ["delete_data"],
19615
+ description: 'For a NON-empty table: "delete_data" soft-deletes its rows too (reversible). Omit to be told the row count and asked first.'
19616
+ },
19617
+ move_to: str(
19618
+ "For a NON-empty table: move its rows into this existing table, then delete the emptied table."
19619
+ )
19620
+ },
19621
+ ["name"]
19622
+ )
19623
+ },
19388
19624
  {
19389
19625
  name: "rename_entity",
19390
19626
  description: "Rename an existing entity (table).",
@@ -19501,6 +19737,7 @@ var DISPATCHABLE = /* @__PURE__ */ new Set([
19501
19737
  "list_entities",
19502
19738
  "list_rows",
19503
19739
  "get_row",
19740
+ "search",
19504
19741
  "get_history",
19505
19742
  "create_row",
19506
19743
  "update_row",
@@ -19509,6 +19746,7 @@ var DISPATCHABLE = /* @__PURE__ */ new Set([
19509
19746
  "unlink",
19510
19747
  "create_entity",
19511
19748
  "create_relationship",
19749
+ "delete_entity",
19512
19750
  "undo",
19513
19751
  "redo",
19514
19752
  "revert"
@@ -19590,6 +19828,20 @@ async function executeFunction(ctx, name, args) {
19590
19828
  if (row === null) return { ok: false, error: "Row not found" };
19591
19829
  return { ok: true, result: redactRow(row, await secretColumnsFor(ctx.db, table)) };
19592
19830
  }
19831
+ case "search": {
19832
+ const query = requireString3(args.query, "query");
19833
+ let tables = [...ctx.validTables];
19834
+ if (Array.isArray(args.tables)) {
19835
+ const want = new Set(args.tables.filter((t) => typeof t === "string"));
19836
+ tables = tables.filter((t) => want.has(t));
19837
+ }
19838
+ const limit = typeof args.limit === "number" ? args.limit : 8;
19839
+ const result = await fullTextSearch(ctx.db.adapter, tables, {
19840
+ query,
19841
+ limitPerTable: limit
19842
+ });
19843
+ return { ok: true, result };
19844
+ }
19593
19845
  case "create_row": {
19594
19846
  const table = requireTable(args.table, ctx.validTables);
19595
19847
  if (!args.values || typeof args.values !== "object") {
@@ -19663,6 +19915,23 @@ async function executeFunction(ctx, name, args) {
19663
19915
  }
19664
19916
  };
19665
19917
  }
19918
+ case "delete_entity": {
19919
+ if (!ctx.deleteEntity) {
19920
+ return { ok: false, error: "Deleting tables is not available in this context" };
19921
+ }
19922
+ const target = requireString3(args.name, "name");
19923
+ let resolution;
19924
+ if (args.resolution === "delete_data") resolution = "delete_data";
19925
+ else if (typeof args.move_to === "string" && args.move_to) {
19926
+ resolution = { move_to: args.move_to };
19927
+ }
19928
+ const outcome = await ctx.deleteEntity(target, resolution);
19929
+ if ("needsResolution" in outcome) return { ok: true, result: outcome };
19930
+ if (!outcome.ok) return { ok: false, error: outcome.error };
19931
+ ctx.validTables.delete(target);
19932
+ ctx.junctionTables.delete(target);
19933
+ return { ok: true, result: outcome };
19934
+ }
19666
19935
  case "get_history": {
19667
19936
  const limit = typeof args.limit === "number" ? args.limit : 50;
19668
19937
  const rows = await ctx.db.query("_lattice_gui_audit", { limit });
@@ -19883,6 +20152,190 @@ function createAnthropicClient(auth) {
19883
20152
  };
19884
20153
  }
19885
20154
 
20155
+ // src/ai/llm-client.ts
20156
+ import { createRequire as createRequire4 } from "module";
20157
+ var DEFAULT_MODEL2 = "claude-haiku-4-5";
20158
+
20159
+ // src/ai/summarize.ts
20160
+ function parseMatches(raw, catalog) {
20161
+ const fence = /```json\s*([\s\S]*?)```/i.exec(raw);
20162
+ const body = fence ? fence[1] : raw;
20163
+ let parsed;
20164
+ try {
20165
+ parsed = JSON.parse((body ?? "").trim());
20166
+ } catch {
20167
+ return [];
20168
+ }
20169
+ if (!Array.isArray(parsed)) return [];
20170
+ const valid = new Map(catalog.map((e) => [e.table, new Set(e.records.map((r) => r.id))]));
20171
+ const out = [];
20172
+ for (const item of parsed) {
20173
+ if (!item || typeof item !== "object") continue;
20174
+ const table = item.table;
20175
+ const id = item.id;
20176
+ if (typeof table === "string" && typeof id === "string" && valid.get(table)?.has(id)) {
20177
+ out.push({ table, id });
20178
+ }
20179
+ }
20180
+ return out;
20181
+ }
20182
+ var ID_RE = /^[a-z][a-z0-9_]*$/;
20183
+ var RESERVED_COLS = /* @__PURE__ */ new Set(["id", "deleted_at", "created_at", "updated_at"]);
20184
+ function parseObjects(raw) {
20185
+ const fence = /```json\s*([\s\S]*?)```/i.exec(raw);
20186
+ let parsed;
20187
+ try {
20188
+ parsed = JSON.parse((fence ? fence[1] : raw)?.trim() ?? "");
20189
+ } catch {
20190
+ return [];
20191
+ }
20192
+ if (!Array.isArray(parsed)) return [];
20193
+ const out = [];
20194
+ for (const item of parsed) {
20195
+ if (!item || typeof item !== "object") continue;
20196
+ const o = item;
20197
+ const entity = typeof o.entity === "string" ? o.entity.trim().toLowerCase() : "";
20198
+ const label = typeof o.label === "string" ? o.label.trim() : "";
20199
+ if (!ID_RE.test(entity) || !label) continue;
20200
+ let valuesRaw = {};
20201
+ if (Array.isArray(o.values) && Array.isArray(o.columns)) {
20202
+ o.columns.forEach((c, i) => {
20203
+ valuesRaw[String(c)] = o.values[i];
20204
+ });
20205
+ } else if (o.values && typeof o.values === "object") {
20206
+ valuesRaw = o.values;
20207
+ }
20208
+ const values = {};
20209
+ for (const [k, v] of Object.entries(valuesRaw)) {
20210
+ const col = k.trim().toLowerCase();
20211
+ if (ID_RE.test(col) && !RESERVED_COLS.has(col) && (typeof v === "string" || typeof v === "number")) {
20212
+ values[col] = String(v).slice(0, 2e3);
20213
+ }
20214
+ }
20215
+ if (Object.keys(values).length === 0) continue;
20216
+ const cols = Array.isArray(o.columns) ? o.columns.map((c) => String(c).trim().toLowerCase()).filter((c) => ID_RE.test(c) && !RESERVED_COLS.has(c)) : [];
20217
+ const columns = Array.from(/* @__PURE__ */ new Set([...cols, ...Object.keys(values)])).slice(0, 8);
20218
+ out.push({ entity, isNew: o.isNew === true, columns, values, label });
20219
+ if (out.length >= 3) break;
20220
+ }
20221
+ return out;
20222
+ }
20223
+
20224
+ // src/gui/ai/summarize.ts
20225
+ var SUMMARY_SYSTEM = 'You write a one or two sentence factual description of a document for a knowledge base, focused on what it is and what it contains. No preamble, no "This document". Plain text only.';
20226
+ async function summarizeText(client, text, name, temperature) {
20227
+ const turn = await client.runTurn({
20228
+ model: DEFAULT_MODEL,
20229
+ system: SUMMARY_SYSTEM,
20230
+ messages: [
20231
+ {
20232
+ role: "user",
20233
+ content: `File name: ${name}
20234
+
20235
+ Content:
20236
+ ${text.slice(0, 12e3)}
20237
+
20238
+ Describe it in 1-2 sentences.`
20239
+ }
20240
+ ],
20241
+ tools: [],
20242
+ ...temperature !== void 0 ? { temperature } : {},
20243
+ onText: () => void 0
20244
+ });
20245
+ return turn.text.trim();
20246
+ }
20247
+ var TITLE_SYSTEM = 'You write a short, specific title (3-5 words, Title Case) for a chat conversation based on its opening exchange \u2014 capture the concrete topic, e.g. "Adding New Notes About Cheese" or "Q3 Invoice Cleanup". No quotes, no trailing punctuation, no preamble. Plain text only.';
20248
+ async function generateThreadTitle(client, userMessage, assistantReply) {
20249
+ const turn = await client.runTurn({
20250
+ model: DEFAULT_MODEL,
20251
+ system: TITLE_SYSTEM,
20252
+ messages: [
20253
+ {
20254
+ role: "user",
20255
+ content: `First user message:
20256
+ ${userMessage.slice(0, 2e3)}
20257
+
20258
+ Assistant reply:
20259
+ ${assistantReply.slice(0, 2e3)}
20260
+
20261
+ Title (3-5 words):`
20262
+ }
20263
+ ],
20264
+ tools: [],
20265
+ onText: () => void 0
20266
+ });
20267
+ return turn.text.trim().replace(/^["'`]+|["'`]+$/g, "").replace(/[.\s]+$/, "").slice(0, 60);
20268
+ }
20269
+ var CLASSIFY_SYSTEM = 'You decide which existing records a newly added document relates to. You are given a catalog of record types (with descriptions) and their records. Return ONLY a JSON array of {"table","id"} objects for records the document clearly relates to \u2014 an empty array if none. Output the JSON in a ```json fenced block and nothing else.';
20270
+ function buildCatalogBlock(catalog) {
20271
+ return catalog.map((e) => {
20272
+ const head = `## ${e.table}${e.description ? ` \u2014 ${e.description}` : ""}`;
20273
+ const rows = e.records.map((r) => `- id=${r.id} | ${r.label}`).join("\n");
20274
+ return `${head}
20275
+ ${rows || "- (no records)"}`;
20276
+ }).join("\n\n");
20277
+ }
20278
+ async function classifyLinks(client, text, name, catalog, temperature) {
20279
+ if (catalog.length === 0 || text.trim().length === 0) return [];
20280
+ let captured = "";
20281
+ const liberal = temperature !== void 0 && temperature >= 0.66 ? " Include records that are strongly implied even without an exact name match." : temperature !== void 0 && temperature <= 0.33 ? " Only include records that are explicitly and unambiguously named." : "";
20282
+ const turn = await client.runTurn({
20283
+ model: DEFAULT_MODEL,
20284
+ system: CLASSIFY_SYSTEM,
20285
+ messages: [
20286
+ {
20287
+ role: "user",
20288
+ content: `# Catalog
20289
+ ${buildCatalogBlock(catalog)}
20290
+
20291
+ # Document: ${name}
20292
+
20293
+ ${text.slice(0, 12e3)}
20294
+
20295
+ # Task
20296
+ Return the JSON array of matching {table,id}.${liberal}`
20297
+ }
20298
+ ],
20299
+ tools: [],
20300
+ ...temperature !== void 0 ? { temperature } : {},
20301
+ onText: (d) => {
20302
+ captured += d;
20303
+ }
20304
+ });
20305
+ return parseMatches(turn.text || captured, catalog);
20306
+ }
20307
+ var EXTRACT_SYSTEM = 'You build a knowledge base by extracting the key structured objects a document is ABOUT \u2014 e.g. an invoice, a person, a project, a contract, a meeting. You are given the existing entity types (tables) and their columns. For each salient object: reuse an existing entity when one clearly fits; otherwise propose a NEW entity with a short snake_case PLURAL name and 2-6 simple snake_case columns. Extract only objects the document is genuinely about \u2014 prefer 1-3, never more than 3, and never invent data not in the document. Return ONLY a JSON array of objects {"entity","isNew","columns","values","label"}, where "values" is an OBJECT mapping each column name to its value \u2014 e.g. {"invoice_number":"INV-114","total":"6400"} \u2014 in a ```json fenced block.';
20308
+ function buildSchemaBlock(existing) {
20309
+ if (existing.length === 0) return "(no entities yet \u2014 propose new ones)";
20310
+ return existing.map((e) => `## ${e.table}
20311
+ columns: ${e.columns.join(", ")}`).join("\n\n");
20312
+ }
20313
+ async function extractObjects(client, text, name, existing, temperature) {
20314
+ if (text.trim().length === 0) return [];
20315
+ const turn = await client.runTurn({
20316
+ model: DEFAULT_MODEL,
20317
+ system: EXTRACT_SYSTEM,
20318
+ messages: [
20319
+ {
20320
+ role: "user",
20321
+ content: `# Existing entities
20322
+ ${buildSchemaBlock(existing)}
20323
+
20324
+ # Document: ${name}
20325
+
20326
+ ${text.slice(0, 12e3)}
20327
+
20328
+ # Task
20329
+ Return the JSON array of objects to create.`
20330
+ }
20331
+ ],
20332
+ tools: [],
20333
+ ...temperature !== void 0 ? { temperature } : {},
20334
+ onText: () => void 0
20335
+ });
20336
+ return parseObjects(turn.text);
20337
+ }
20338
+
19886
20339
  // src/gui/ai/sse.ts
19887
20340
  function formatSseFrame(event) {
19888
20341
  return `data: ${JSON.stringify(event)}
@@ -20138,13 +20591,14 @@ async function dispatchChatRoute(req, res, ctx) {
20138
20591
  junctionTables: new Set([...ctx.junctionTables].filter((t) => !ASSISTANT_HIDDEN_TABLES.has(t))),
20139
20592
  softDeletable: ctx.softDeletable,
20140
20593
  ...ctx.createEntity ? { createEntity: ctx.createEntity } : {},
20141
- ...ctx.createJunction ? { createJunction: ctx.createJunction } : {}
20594
+ ...ctx.createJunction ? { createJunction: ctx.createJunction } : {},
20595
+ ...ctx.deleteEntity ? { deleteEntity: ctx.deleteEntity } : {}
20142
20596
  };
20143
20597
  let assistantText = "";
20144
20598
  const turns = [];
20145
20599
  try {
20146
20600
  const client = createAnthropicClient(auth);
20147
- const temperature = aggressivenessToTemperature(await getAggressiveness(ctx.db));
20601
+ const temperature = aggressivenessToTemperature(getAggressiveness());
20148
20602
  for await (const ev of runChat({
20149
20603
  client,
20150
20604
  dispatch,
@@ -20193,6 +20647,23 @@ async function dispatchChatRoute(req, res, ctx) {
20193
20647
  } catch (e) {
20194
20648
  console.warn("[chat] persist assistant message failed:", e.message);
20195
20649
  }
20650
+ const createdNew = threadId !== requestedThread;
20651
+ if (createdNew && assistantText.trim()) {
20652
+ try {
20653
+ const placeholder = message.slice(0, 60) || "Chat";
20654
+ const cur = await ctx.db.get("chat_threads", threadId);
20655
+ if (cur && (cur.title ?? "") === placeholder) {
20656
+ const title = await generateThreadTitle(
20657
+ createAnthropicClient(auth),
20658
+ message,
20659
+ assistantText
20660
+ );
20661
+ if (title) await ctx.db.update("chat_threads", threadId, { title });
20662
+ }
20663
+ } catch (e) {
20664
+ console.warn("[chat] thread title generation failed:", e.message);
20665
+ }
20666
+ }
20196
20667
  }
20197
20668
  return true;
20198
20669
  }
@@ -20344,12 +20815,6 @@ function describe(text, mime, name) {
20344
20815
  // src/ai/vision.ts
20345
20816
  import { createRequire as createRequire5 } from "module";
20346
20817
  import { readFile as readFile2 } from "fs/promises";
20347
-
20348
- // src/ai/llm-client.ts
20349
- import { createRequire as createRequire4 } from "module";
20350
- var DEFAULT_MODEL2 = "claude-haiku-4-5";
20351
-
20352
- // src/ai/vision.ts
20353
20818
  var DEFAULT_PROMPT = "Describe this image for a knowledge base in 2-4 factual sentences: what it shows, any visible text, and notable details. No preamble.";
20354
20819
  var MAX_DIM = 1568;
20355
20820
  async function describeImage(auth, path2, opts = {}) {
@@ -20709,164 +21174,6 @@ async function attachBlob(srcPath, latticeRoot) {
20709
21174
  };
20710
21175
  }
20711
21176
 
20712
- // src/ai/summarize.ts
20713
- function parseMatches(raw, catalog) {
20714
- const fence = /```json\s*([\s\S]*?)```/i.exec(raw);
20715
- const body = fence ? fence[1] : raw;
20716
- let parsed;
20717
- try {
20718
- parsed = JSON.parse((body ?? "").trim());
20719
- } catch {
20720
- return [];
20721
- }
20722
- if (!Array.isArray(parsed)) return [];
20723
- const valid = new Map(catalog.map((e) => [e.table, new Set(e.records.map((r) => r.id))]));
20724
- const out = [];
20725
- for (const item of parsed) {
20726
- if (!item || typeof item !== "object") continue;
20727
- const table = item.table;
20728
- const id = item.id;
20729
- if (typeof table === "string" && typeof id === "string" && valid.get(table)?.has(id)) {
20730
- out.push({ table, id });
20731
- }
20732
- }
20733
- return out;
20734
- }
20735
- var ID_RE = /^[a-z][a-z0-9_]*$/;
20736
- var RESERVED_COLS = /* @__PURE__ */ new Set(["id", "deleted_at", "created_at", "updated_at"]);
20737
- function parseObjects(raw) {
20738
- const fence = /```json\s*([\s\S]*?)```/i.exec(raw);
20739
- let parsed;
20740
- try {
20741
- parsed = JSON.parse((fence ? fence[1] : raw)?.trim() ?? "");
20742
- } catch {
20743
- return [];
20744
- }
20745
- if (!Array.isArray(parsed)) return [];
20746
- const out = [];
20747
- for (const item of parsed) {
20748
- if (!item || typeof item !== "object") continue;
20749
- const o = item;
20750
- const entity = typeof o.entity === "string" ? o.entity.trim().toLowerCase() : "";
20751
- const label = typeof o.label === "string" ? o.label.trim() : "";
20752
- if (!ID_RE.test(entity) || !label) continue;
20753
- let valuesRaw = {};
20754
- if (Array.isArray(o.values) && Array.isArray(o.columns)) {
20755
- o.columns.forEach((c, i) => {
20756
- valuesRaw[String(c)] = o.values[i];
20757
- });
20758
- } else if (o.values && typeof o.values === "object") {
20759
- valuesRaw = o.values;
20760
- }
20761
- const values = {};
20762
- for (const [k, v] of Object.entries(valuesRaw)) {
20763
- const col = k.trim().toLowerCase();
20764
- if (ID_RE.test(col) && !RESERVED_COLS.has(col) && (typeof v === "string" || typeof v === "number")) {
20765
- values[col] = String(v).slice(0, 2e3);
20766
- }
20767
- }
20768
- if (Object.keys(values).length === 0) continue;
20769
- const cols = Array.isArray(o.columns) ? o.columns.map((c) => String(c).trim().toLowerCase()).filter((c) => ID_RE.test(c) && !RESERVED_COLS.has(c)) : [];
20770
- const columns = Array.from(/* @__PURE__ */ new Set([...cols, ...Object.keys(values)])).slice(0, 8);
20771
- out.push({ entity, isNew: o.isNew === true, columns, values, label });
20772
- if (out.length >= 3) break;
20773
- }
20774
- return out;
20775
- }
20776
-
20777
- // src/gui/ai/summarize.ts
20778
- var SUMMARY_SYSTEM = 'You write a one or two sentence factual description of a document for a knowledge base, focused on what it is and what it contains. No preamble, no "This document". Plain text only.';
20779
- async function summarizeText(client, text, name, temperature) {
20780
- const turn = await client.runTurn({
20781
- model: DEFAULT_MODEL,
20782
- system: SUMMARY_SYSTEM,
20783
- messages: [
20784
- {
20785
- role: "user",
20786
- content: `File name: ${name}
20787
-
20788
- Content:
20789
- ${text.slice(0, 12e3)}
20790
-
20791
- Describe it in 1-2 sentences.`
20792
- }
20793
- ],
20794
- tools: [],
20795
- ...temperature !== void 0 ? { temperature } : {},
20796
- onText: () => void 0
20797
- });
20798
- return turn.text.trim();
20799
- }
20800
- var CLASSIFY_SYSTEM = 'You decide which existing records a newly added document relates to. You are given a catalog of record types (with descriptions) and their records. Return ONLY a JSON array of {"table","id"} objects for records the document clearly relates to \u2014 an empty array if none. Output the JSON in a ```json fenced block and nothing else.';
20801
- function buildCatalogBlock(catalog) {
20802
- return catalog.map((e) => {
20803
- const head = `## ${e.table}${e.description ? ` \u2014 ${e.description}` : ""}`;
20804
- const rows = e.records.map((r) => `- id=${r.id} | ${r.label}`).join("\n");
20805
- return `${head}
20806
- ${rows || "- (no records)"}`;
20807
- }).join("\n\n");
20808
- }
20809
- async function classifyLinks(client, text, name, catalog, temperature) {
20810
- if (catalog.length === 0 || text.trim().length === 0) return [];
20811
- let captured = "";
20812
- const liberal = temperature !== void 0 && temperature >= 0.66 ? " Include records that are strongly implied even without an exact name match." : temperature !== void 0 && temperature <= 0.33 ? " Only include records that are explicitly and unambiguously named." : "";
20813
- const turn = await client.runTurn({
20814
- model: DEFAULT_MODEL,
20815
- system: CLASSIFY_SYSTEM,
20816
- messages: [
20817
- {
20818
- role: "user",
20819
- content: `# Catalog
20820
- ${buildCatalogBlock(catalog)}
20821
-
20822
- # Document: ${name}
20823
-
20824
- ${text.slice(0, 12e3)}
20825
-
20826
- # Task
20827
- Return the JSON array of matching {table,id}.${liberal}`
20828
- }
20829
- ],
20830
- tools: [],
20831
- ...temperature !== void 0 ? { temperature } : {},
20832
- onText: (d) => {
20833
- captured += d;
20834
- }
20835
- });
20836
- return parseMatches(turn.text || captured, catalog);
20837
- }
20838
- var EXTRACT_SYSTEM = 'You build a knowledge base by extracting the key structured objects a document is ABOUT \u2014 e.g. an invoice, a person, a project, a contract, a meeting. You are given the existing entity types (tables) and their columns. For each salient object: reuse an existing entity when one clearly fits; otherwise propose a NEW entity with a short snake_case PLURAL name and 2-6 simple snake_case columns. Extract only objects the document is genuinely about \u2014 prefer 1-3, never more than 3, and never invent data not in the document. Return ONLY a JSON array of objects {"entity","isNew","columns","values","label"}, where "values" is an OBJECT mapping each column name to its value \u2014 e.g. {"invoice_number":"INV-114","total":"6400"} \u2014 in a ```json fenced block.';
20839
- function buildSchemaBlock(existing) {
20840
- if (existing.length === 0) return "(no entities yet \u2014 propose new ones)";
20841
- return existing.map((e) => `## ${e.table}
20842
- columns: ${e.columns.join(", ")}`).join("\n\n");
20843
- }
20844
- async function extractObjects(client, text, name, existing, temperature) {
20845
- if (text.trim().length === 0) return [];
20846
- const turn = await client.runTurn({
20847
- model: DEFAULT_MODEL,
20848
- system: EXTRACT_SYSTEM,
20849
- messages: [
20850
- {
20851
- role: "user",
20852
- content: `# Existing entities
20853
- ${buildSchemaBlock(existing)}
20854
-
20855
- # Document: ${name}
20856
-
20857
- ${text.slice(0, 12e3)}
20858
-
20859
- # Task
20860
- Return the JSON array of objects to create.`
20861
- }
20862
- ],
20863
- tools: [],
20864
- ...temperature !== void 0 ? { temperature } : {},
20865
- onText: () => void 0
20866
- });
20867
- return parseObjects(turn.text);
20868
- }
20869
-
20870
21177
  // src/gui/ingest-routes.ts
20871
21178
  var MIME_BY_EXT = {
20872
21179
  ".pdf": "application/pdf",
@@ -21107,6 +21414,37 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
21107
21414
  return [];
21108
21415
  }
21109
21416
  }
21417
+ async function enrichOrFail(mctx, db, fileId, text, name, ctx, res) {
21418
+ try {
21419
+ return await enrichWithLlm(
21420
+ mctx,
21421
+ db,
21422
+ fileId,
21423
+ text,
21424
+ name,
21425
+ ctx.fileJunctions,
21426
+ ctx.entityDescriptions,
21427
+ ctx.createJunction,
21428
+ ctx.aggressiveness,
21429
+ ctx.createEntity
21430
+ );
21431
+ } catch (e) {
21432
+ const err = e;
21433
+ console.error(
21434
+ `[ingest] enrichment failed for file ${fileId}: ${err.message}
21435
+ ${err.stack ?? ""}`
21436
+ );
21437
+ await updateRow(mctx, "files", fileId, { extraction_status: "enrichment_failed" }).catch(
21438
+ (e2) => {
21439
+ console.error(
21440
+ `[ingest] could not mark enrichment_failed on ${fileId}: ${e2.message}`
21441
+ );
21442
+ }
21443
+ );
21444
+ sendJson5(res, { id: fileId, extraction_status: "enrichment_failed", error: err.message }, 201);
21445
+ return null;
21446
+ }
21447
+ }
21110
21448
  async function extractImage(db, path2, mime) {
21111
21449
  if (!mime.startsWith("image/")) return null;
21112
21450
  const auth = await resolveClaudeAuth(db);
@@ -21214,18 +21552,12 @@ async function dispatchIngestRoute(req, res, ctx) {
21214
21552
  extraction_status: result.skip ? "skipped" : "extracted",
21215
21553
  ...blob ? { ref_kind: "blob", blob_path: blob.blob_path, sha256: blob.sha256 } : {}
21216
21554
  });
21217
- const suggestedLinks = result.skip ? [] : await enrichWithLlm(
21218
- mctx,
21219
- ctx.db,
21220
- id2,
21221
- result.text,
21222
- name2,
21223
- ctx.fileJunctions,
21224
- ctx.entityDescriptions,
21225
- ctx.createJunction,
21226
- ctx.aggressiveness,
21227
- ctx.createEntity
21228
- );
21555
+ let suggestedLinks = [];
21556
+ if (!result.skip) {
21557
+ const links = await enrichOrFail(mctx, ctx.db, id2, result.text, name2, ctx, res);
21558
+ if (links === null) return true;
21559
+ suggestedLinks = links;
21560
+ }
21229
21561
  sendJson5(
21230
21562
  res,
21231
21563
  { id: id2, extraction_status: result.skip ? "skipped" : "extracted", suggestedLinks },
@@ -21272,18 +21604,8 @@ async function dispatchIngestRoute(req, res, ctx) {
21272
21604
  extraction_status: "extracted",
21273
21605
  ...sourceUrl ? { ref_kind: "cloud_ref", ref_uri: sourceUrl, ref_provider: "web" } : {}
21274
21606
  });
21275
- const suggestedLinks = await enrichWithLlm(
21276
- mctx,
21277
- ctx.db,
21278
- id2,
21279
- content,
21280
- title,
21281
- ctx.fileJunctions,
21282
- ctx.entityDescriptions,
21283
- ctx.createJunction,
21284
- ctx.aggressiveness,
21285
- ctx.createEntity
21286
- );
21607
+ const suggestedLinks = await enrichOrFail(mctx, ctx.db, id2, content, title, ctx, res);
21608
+ if (suggestedLinks === null) return true;
21287
21609
  sendJson5(res, { id: id2, extraction_status: "extracted", suggestedLinks }, 201);
21288
21610
  return true;
21289
21611
  }
@@ -21340,11 +21662,16 @@ async function dispatchIngestRoute(req, res, ctx) {
21340
21662
  201
21341
21663
  );
21342
21664
  } catch (e) {
21665
+ const err = e;
21666
+ console.error(
21667
+ `[ingest] extraction/enrichment failed for file ${id}: ${err.message}
21668
+ ${err.stack ?? ""}`
21669
+ );
21343
21670
  await updateRow(mctx, "files", id, {
21344
21671
  extraction_status: "failed",
21345
- description: `Extraction failed: ${e.message}`
21672
+ description: `Extraction failed: ${err.message}`
21346
21673
  });
21347
- sendJson5(res, { id, extraction_status: "failed", error: e.message }, 201);
21674
+ sendJson5(res, { id, extraction_status: "failed", error: err.message }, 201);
21348
21675
  }
21349
21676
  return true;
21350
21677
  }
@@ -21649,6 +21976,7 @@ async function openConfig(configPath, outputDir, autoRender = false) {
21649
21976
  await db.init();
21650
21977
  await syncUserIdentityRow(db);
21651
21978
  await adoptNativeEntities(db);
21979
+ await retireLegacyPreferenceSecrets(db);
21652
21980
  const validTables = new Set(parsed.tables.map((t) => t.name));
21653
21981
  for (const name of db.getRegisteredTableNames()) {
21654
21982
  if (name.startsWith("__lattice_") || name.startsWith("_lattice_")) continue;
@@ -22236,7 +22564,7 @@ data: ${JSON.stringify(data)}
22236
22564
  }
22237
22565
  const limit = Math.min(50, Math.max(1, Number(url2.searchParams.get("limit") ?? "8")));
22238
22566
  const requested = url2.searchParams.get("tables");
22239
- let tables = [...active.validTables];
22567
+ let tables = [...active.validTables].filter((t) => !ASSISTANT_HIDDEN_TABLES.has(t));
22240
22568
  if (requested) {
22241
22569
  const want = new Set(
22242
22570
  requested.split(",").map((t) => t.trim()).filter(Boolean)
@@ -22420,20 +22748,7 @@ data: ${JSON.stringify(data)}
22420
22748
  );
22421
22749
  return;
22422
22750
  }
22423
- const doc = loadConfigDoc(active.configPath);
22424
- const deletedEntityDef = doc.toJS().entities?.[name];
22425
- doc.deleteIn(["entities", name]);
22426
- saveConfigDoc(active.configPath, doc);
22427
- active = await reopenSameConfig(active, autoRender);
22428
- await recordSchemaOp(
22429
- active,
22430
- "schema.delete_entity",
22431
- name,
22432
- { entity: name, entityDef: deletedEntityDef },
22433
- null,
22434
- `Deleted table ${name}`,
22435
- sessionId
22436
- );
22751
+ await softDeleteUserEntity(active, name, sessionId);
22437
22752
  sendJson(res, { ok: true });
22438
22753
  return;
22439
22754
  }
@@ -23097,6 +23412,11 @@ data: ${JSON.stringify(data)}
23097
23412
  ) : `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '\\_%' ESCAPE '\\' ORDER BY name`;
23098
23413
  rows = await adapter.allAsync(listSql);
23099
23414
  }
23415
+ for (const n of NATIVE_INTERNAL_NAMES) {
23416
+ if (active.validTables.has(n) && !rows.some((r) => r.name === n)) {
23417
+ rows.push({ name: n });
23418
+ }
23419
+ }
23100
23420
  const tables = [];
23101
23421
  for (const r of rows) {
23102
23422
  const cols = await active.db.introspectColumns(r.name);
@@ -23109,7 +23429,7 @@ data: ${JSON.stringify(data)}
23109
23429
  if (method === "GET" && /^\/api\/system-tables\/[^/]+\/rows$/.test(pathname)) {
23110
23430
  const parts = pathname.split("/");
23111
23431
  const sysTable = decodeURIComponent(parts[3] ?? "");
23112
- if (!/^_+[a-zA-Z0-9_]+$/.test(sysTable)) {
23432
+ if (!/^_+[a-zA-Z0-9_]+$/.test(sysTable) && !isInternalNativeEntity(sysTable)) {
23113
23433
  sendJson(res, { error: "Not a system table" }, 400);
23114
23434
  return;
23115
23435
  }
@@ -23757,6 +24077,9 @@ data: ${JSON.stringify(data)}
23757
24077
  // audited, no-reopen primitives the Context Constructor uses.
23758
24078
  createEntity: (name, columns) => createUserEntity(active, name, columns, sessionId),
23759
24079
  createJunction: (a, b) => createUserJunction(active, a, b, sessionId),
24080
+ // Guarded, reversible table delete — empty tables go immediately;
24081
+ // non-empty ones come back as `needsResolution` so the assistant asks.
24082
+ deleteEntity: (name, resolution) => aiDeleteEntity(active, name, resolution, sessionId),
23760
24083
  pathname,
23761
24084
  method
23762
24085
  });
@@ -23771,7 +24094,7 @@ data: ${JSON.stringify(data)}
23771
24094
  entityDescriptions: entityDescriptions(active.configPath, active.outputDir),
23772
24095
  createJunction: (otherTable) => createFileJunction(active, otherTable, sessionId),
23773
24096
  createEntity: (entity, columns) => createUserEntity(active, entity, columns, sessionId),
23774
- aggressiveness: await getAggressiveness(active.db),
24097
+ aggressiveness: getAggressiveness(),
23775
24098
  latticeRoot: dirname11(active.configPath),
23776
24099
  pathname,
23777
24100
  method
@@ -23809,7 +24132,12 @@ data: ${JSON.stringify(data)}
23809
24132
  }
23810
24133
  sendJson(res, { error: "Not found" }, 404);
23811
24134
  } catch (err) {
23812
- sendJson(res, { error: err.message }, 500);
24135
+ const e = err;
24136
+ console.error(
24137
+ `[gui] ${req.method ?? "?"} ${req.url ?? "?"} failed: ${e.message}
24138
+ ${e.stack ?? ""}`
24139
+ );
24140
+ sendJson(res, { error: e.message }, 500);
23813
24141
  }
23814
24142
  })();
23815
24143
  });