latticesql 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
  );
@@ -407,12 +417,6 @@ function assertSafeLabel(label) {
407
417
  throw new Error(`Invalid label "${label}": must match [A-Za-z0-9._-]+ and not start with .`);
408
418
  }
409
419
  }
410
- function readToken(label) {
411
- assertSafeLabel(label);
412
- const path2 = join2(ensureKeysDir(), label + TOKEN_EXT);
413
- if (!existsSync2(path2)) return null;
414
- return readFileSync(path2, "utf8").trim();
415
- }
416
420
  function writeToken(label, token) {
417
421
  assertSafeLabel(label);
418
422
  const path2 = join2(ensureKeysDir(), label + TOKEN_EXT);
@@ -1598,6 +1602,19 @@ var SchemaManager = class {
1598
1602
  redefineEntityContext(table, def) {
1599
1603
  this._entityContexts.set(table, def);
1600
1604
  }
1605
+ /**
1606
+ * Remove a table from the registry — the inverse of {@link define}. Clears the
1607
+ * table def, its primary-key, any multi-render, and its entity context. A no-op
1608
+ * if the table was never defined. Used by the GUI's soft table-delete to drop a
1609
+ * runtime-registered table without a full reopen; the physical SQL table is left
1610
+ * intact by the caller so the delete stays revertible.
1611
+ */
1612
+ undefine(table) {
1613
+ this._tables.delete(table);
1614
+ this._tablePK.delete(table);
1615
+ this._multis.delete(table);
1616
+ this._entityContexts.delete(table);
1617
+ }
1601
1618
  getTables() {
1602
1619
  return this._tables;
1603
1620
  }
@@ -4531,6 +4548,21 @@ var Lattice = class _Lattice {
4531
4548
  }
4532
4549
  return this;
4533
4550
  }
4551
+ /**
4552
+ * Remove a runtime-registered table from the live schema registry — the
4553
+ * inverse of {@link defineLate}. The table stops being listed/queryable
4554
+ * WITHOUT a full reopen (which would dispose this instance and invalidate any
4555
+ * captured `db`/feed references mid-operation — see the GUI's soft table
4556
+ * delete). Does NOT drop the physical SQL table or its rows; the caller keeps
4557
+ * them so the delete remains revertible. A no-op if the table isn't
4558
+ * registered.
4559
+ */
4560
+ unregisterTable(table) {
4561
+ this._schema.undefine(table);
4562
+ this._columnCache.delete(table);
4563
+ this._changelogTables.delete(table);
4564
+ return this;
4565
+ }
4534
4566
  _registerTable(table, def) {
4535
4567
  const columns = def.rewardTracking ? { ...def.columns, _reward_total: "REAL DEFAULT 0", _reward_count: "INTEGER DEFAULT 0" } : def.columns;
4536
4568
  const renderTemplateName = _resolveTemplateName(def.render);
@@ -6107,6 +6139,205 @@ async function tryHandler(res, fn) {
6107
6139
  // src/gui/data.ts
6108
6140
  import { existsSync as existsSync14, readFileSync as readFileSync10, statSync as statSync4 } from "fs";
6109
6141
  import { basename as basename3, join as join13, relative, resolve as resolve5, sep as sep2 } from "path";
6142
+
6143
+ // src/framework/native-entities.ts
6144
+ var NATIVE_ENTITY_DEFS = {
6145
+ secrets: {
6146
+ columns: {
6147
+ id: "TEXT PRIMARY KEY",
6148
+ // NOT NULL needs a DEFAULT so ALTER TABLE ADD COLUMN succeeds when this
6149
+ // native shape is merged onto a pre-existing table (the adopt + team
6150
+ // shared-schema sync paths use ADD COLUMN; SQLite + Postgres both reject
6151
+ // a NOT NULL add without a default). Every insert sets `name` explicitly,
6152
+ // so the default is never observed in practice.
6153
+ name: "TEXT NOT NULL DEFAULT ''",
6154
+ kind: "TEXT",
6155
+ value: "TEXT",
6156
+ description: "TEXT",
6157
+ created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6158
+ updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6159
+ deleted_at: "TEXT"
6160
+ },
6161
+ encrypted: { columns: ["value"] },
6162
+ render: () => "",
6163
+ outputFile: ".lattice-native/secrets.md"
6164
+ },
6165
+ files: {
6166
+ columns: {
6167
+ id: "TEXT PRIMARY KEY",
6168
+ // Legacy columns (DEPRECATED v2.0 — retained for back-compat, NOT dropped).
6169
+ // path — superseded by the reference model below (`ref_kind`/`ref_uri`).
6170
+ // New local-file ingestion records a `local_ref` via
6171
+ // `referenceLocalFile()` rather than writing `path`; readers fall
6172
+ // back to `ref_uri`. Do not write `path` in new code.
6173
+ // kind — orphaned: superseded by `mime` (content type) and `ref_kind`
6174
+ // (the blob/local_ref/cloud_ref discriminator). Not read or
6175
+ // written by production code; kept only so existing rows retain
6176
+ // their value.
6177
+ path: "TEXT",
6178
+ kind: "TEXT",
6179
+ // Content-addressed storage. `sha256` is the canonical content
6180
+ // identifier; `blob_path` is the relative path under
6181
+ // `<lattice-root>/data/blobs/` written by attachBlob().
6182
+ original_name: "TEXT",
6183
+ mime: "TEXT",
6184
+ size_bytes: "INTEGER",
6185
+ sha256: "TEXT",
6186
+ blob_path: "TEXT",
6187
+ // Reference mode (v2.0): a row can INDEX data that lives elsewhere
6188
+ // instead of owning a copy. All nullable + additive (back-compat).
6189
+ // ref_kind discriminator: 'blob' | 'local_ref' | 'cloud_ref'
6190
+ // (NULL ⇒ legacy/owned blob)
6191
+ // ref_uri durable pointer: absolute local path or remote URL
6192
+ // ref_provider resolver selector: 'fs' | 'web' | 'gdrive'
6193
+ // source_json provider-specific metadata (etag, availability, …)
6194
+ ref_kind: "TEXT",
6195
+ ref_uri: "TEXT",
6196
+ ref_provider: "TEXT",
6197
+ source_json: "TEXT",
6198
+ extraction_status: "TEXT",
6199
+ extracted_text: "TEXT",
6200
+ description: "TEXT",
6201
+ created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6202
+ updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6203
+ deleted_at: "TEXT"
6204
+ },
6205
+ render: () => "",
6206
+ outputFile: ".lattice-native/files.md"
6207
+ },
6208
+ notes: {
6209
+ // A generic knowledge object: a free-form note with a title and body.
6210
+ // Ordinary, user-editable rows; `source_file_id` optionally points back at
6211
+ // an originating `files` row. Retained as native (1.16.3) because the
6212
+ // reference/source-organizer store uses it as the fallback organizer target.
6213
+ columns: {
6214
+ id: "TEXT PRIMARY KEY",
6215
+ title: "TEXT",
6216
+ body: "TEXT",
6217
+ source_file_id: "TEXT",
6218
+ created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6219
+ updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6220
+ deleted_at: "TEXT"
6221
+ },
6222
+ render: () => "",
6223
+ outputFile: ".lattice-native/notes.md"
6224
+ },
6225
+ chat_threads: {
6226
+ // An assistant conversation. Native so chat history survives across
6227
+ // sessions and is queryable/renderable like any other Lattice entity.
6228
+ columns: {
6229
+ id: "TEXT PRIMARY KEY",
6230
+ title: "TEXT",
6231
+ created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6232
+ updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6233
+ deleted_at: "TEXT"
6234
+ },
6235
+ render: () => "",
6236
+ outputFile: ".lattice-native/chat-threads.md"
6237
+ },
6238
+ chat_messages: {
6239
+ // One turn (or feed entry) within a chat_thread.
6240
+ columns: {
6241
+ id: "TEXT PRIMARY KEY",
6242
+ // Soft reference to chat_threads.id. Kept as a plain column (no FK)
6243
+ // to match the generic, dialect-agnostic native-entity style.
6244
+ thread_id: "TEXT",
6245
+ // user | assistant | tool | feed | system
6246
+ role: "TEXT NOT NULL DEFAULT 'user'",
6247
+ // JSON payload: text, tool_use / tool_result blocks, attachments, or
6248
+ // (for role='feed') the feed-event details.
6249
+ content_json: "TEXT",
6250
+ // ai | gui | cli | ingest — meaningful for role='feed'.
6251
+ source: "TEXT",
6252
+ created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6253
+ deleted_at: "TEXT"
6254
+ },
6255
+ render: () => "",
6256
+ outputFile: ".lattice-native/chat-messages.md"
6257
+ }
6258
+ };
6259
+ var NATIVE_ENTITY_NAMES = new Set(Object.keys(NATIVE_ENTITY_DEFS));
6260
+ function isNativeEntity(name) {
6261
+ return NATIVE_ENTITY_NAMES.has(name);
6262
+ }
6263
+ var NATIVE_INTERNAL_NAMES = /* @__PURE__ */ new Set([
6264
+ "chat_threads",
6265
+ "chat_messages"
6266
+ ]);
6267
+ function isInternalNativeEntity(name) {
6268
+ return NATIVE_INTERNAL_NAMES.has(name);
6269
+ }
6270
+ function registerNativeEntities(db) {
6271
+ const existing = new Set(db.getRegisteredTableNames());
6272
+ for (const [name, def] of Object.entries(NATIVE_ENTITY_DEFS)) {
6273
+ if (existing.has(name)) continue;
6274
+ db.define(name, def);
6275
+ }
6276
+ }
6277
+ var NATIVE_REGISTRY_TABLE = "__lattice_native_entities";
6278
+ var NATIVE_REGISTRY_DEF = {
6279
+ columns: {
6280
+ entity: "TEXT PRIMARY KEY",
6281
+ table_name: "TEXT NOT NULL",
6282
+ adopted_at: "TEXT NOT NULL",
6283
+ origin: "TEXT NOT NULL"
6284
+ },
6285
+ primaryKey: "entity",
6286
+ render: () => "",
6287
+ outputFile: ".lattice-native/native-entities.md"
6288
+ };
6289
+ async function adoptNativeEntities(db, options = {}) {
6290
+ const onConflict = options.onConflict ?? "adopt";
6291
+ await db.defineLate(NATIVE_REGISTRY_TABLE, NATIVE_REGISTRY_DEF);
6292
+ const results = [];
6293
+ for (const [name, def] of Object.entries(NATIVE_ENTITY_DEFS)) {
6294
+ const physicalCols = await db.introspectColumns(name);
6295
+ const exists = physicalCols.length > 0;
6296
+ const registered = new Set(db.getRegisteredTableNames());
6297
+ if (!exists) {
6298
+ if (!registered.has(name)) await db.defineLate(name, def);
6299
+ results.push({ entity: name, tableName: name, origin: "created" });
6300
+ await recordNativeBinding(db, name, "created");
6301
+ continue;
6302
+ }
6303
+ if (onConflict === "error") {
6304
+ throw new Error(
6305
+ `adoptNativeEntities: physical table "${name}" already exists; refusing to adopt with onConflict:'error'`
6306
+ );
6307
+ }
6308
+ const nativeCols = new Set(Object.keys(def.columns));
6309
+ const hadForeignShape = physicalCols.some((c) => !nativeCols.has(c));
6310
+ const rowCount = await db.count(name);
6311
+ let origin = hadForeignShape || rowCount > 0 ? "adopted" : "created";
6312
+ if (onConflict === "skip") {
6313
+ origin = "skipped";
6314
+ } else if (!registered.has(name)) {
6315
+ await db.defineLate(name, def);
6316
+ }
6317
+ results.push({ entity: name, tableName: name, origin });
6318
+ await recordNativeBinding(db, name, origin);
6319
+ }
6320
+ return results;
6321
+ }
6322
+ async function listNativeBindings(db) {
6323
+ if (!db.getRegisteredTableNames().includes(NATIVE_REGISTRY_TABLE)) return [];
6324
+ const rows = await db.query(NATIVE_REGISTRY_TABLE);
6325
+ return rows.map((r) => ({
6326
+ entity: String(r.entity),
6327
+ tableName: String(r.table_name),
6328
+ origin: r.origin
6329
+ }));
6330
+ }
6331
+ async function recordNativeBinding(db, entity, origin) {
6332
+ await db.upsert(NATIVE_REGISTRY_TABLE, {
6333
+ entity,
6334
+ table_name: entity,
6335
+ adopted_at: (/* @__PURE__ */ new Date()).toISOString(),
6336
+ origin
6337
+ });
6338
+ }
6339
+
6340
+ // src/gui/data.ts
6110
6341
  function tableToSummary(name, definition) {
6111
6342
  return {
6112
6343
  name,
@@ -6264,6 +6495,7 @@ function buildGuiGraph(configPath, outputDir, options = {}) {
6264
6495
  const filter = options.visibleFilter;
6265
6496
  data.tables = data.tables.filter((t) => filter(t.name));
6266
6497
  }
6498
+ data.tables = data.tables.filter((t) => !isInternalNativeEntity(t.name));
6267
6499
  const nodes = /* @__PURE__ */ new Map();
6268
6500
  const edges = /* @__PURE__ */ new Map();
6269
6501
  const fileOwners = /* @__PURE__ */ new Map();
@@ -6742,6 +6974,9 @@ var css = `
6742
6974
  padding: 0 12px; margin: 12px 0 6px;
6743
6975
  }
6744
6976
  .section-label:first-child { margin-top: 0; }
6977
+ /* Extra breathing room above the "SYSTEM" heading so it isn't cramped
6978
+ against the object list above it. */
6979
+ #system-section .section-label { margin-top: 20px; }
6745
6980
  nav ul { list-style: none; padding: 0; margin: 0; }
6746
6981
  nav li a {
6747
6982
  display: flex; align-items: center; gap: 10px;
@@ -6845,6 +7080,8 @@ var css = `
6845
7080
  }
6846
7081
  a.chip-link { cursor: pointer; }
6847
7082
  a.chip-link:hover { background: var(--accent); color: white; }
7083
+ /* Inline object-reference pills the assistant emits \u2014 render flush in prose. */
7084
+ a.lattice-ref { text-decoration: none; vertical-align: baseline; }
6848
7085
  .empty-row td {
6849
7086
  color: var(--text-muted); font-style: italic; text-align: center;
6850
7087
  padding: 24px;
@@ -7571,20 +7808,9 @@ var css = `
7571
7808
  padding: 8px; margin: 0 0 8px; overflow-x: auto;
7572
7809
  }
7573
7810
  .chat-bubble.assistant pre code { background: none; border: none; padding: 0; white-space: pre; }
7574
- .chat-tools { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
7575
- .tool-pill {
7576
- display: inline-flex; align-items: center; gap: 5px;
7577
- border-radius: 999px; padding: 2px 9px; font-size: 11px; font-weight: 500;
7578
- background: var(--accent-soft); color: var(--accent);
7579
- box-shadow: var(--glow-accent-soft);
7580
- }
7581
- .tool-pill.done { background: var(--surface-2); color: var(--text-muted); box-shadow: none; }
7582
- .tool-pill.error { background: rgba(251,146,60,0.14); color: var(--warn); box-shadow: none; }
7583
- .tool-pill .spin { display: inline-block; width: 9px; height: 9px;
7584
- border: 1.5px solid currentColor; border-top-color: transparent; border-radius: 50%;
7585
- animation: pillspin 0.7s linear infinite; }
7586
- @keyframes pillspin { to { transform: rotate(360deg); } }
7587
- /* Typing indicator: three pulsing dots shown in an assistant bubble while
7811
+ /* The assistant's data changes render as activity-feed cards (.feed-item) in
7812
+ the rail \u2014 there is no separate inline pill style. Reads emit no card.
7813
+ Typing indicator: three pulsing dots shown in an assistant bubble while
7588
7814
  the model is generating (before the first text delta of a turn). */
7589
7815
  .chat-typing { display: inline-flex; align-items: center; gap: 4px; padding: 1px 0; }
7590
7816
  .chat-typing i {
@@ -7612,6 +7838,9 @@ var css = `
7612
7838
  overflow-wrap: break-word; word-break: break-word;
7613
7839
  }
7614
7840
  .rail-composer textarea:focus { outline: none; border-color: var(--accent); box-shadow: var(--glow-focus); }
7841
+ /* While a voice note is being recorded/transcribed the textarea is read-only
7842
+ (shows a "Listening\u2026" / "Transcribing\u2026" placeholder, not editable). */
7843
+ .rail-composer textarea.recording { opacity: 0.6; cursor: not-allowed; }
7615
7844
  .rail-composer .composer-row { display: flex; gap: 8px; align-items: flex-end; }
7616
7845
  .rail-composer .composer-send {
7617
7846
  flex: 0 0 auto; height: 38px; padding: 0 14px; border: none; border-radius: 8px;
@@ -7719,6 +7948,13 @@ var appJs = `
7719
7948
  return !!(t && t[colName] && t[colName].secret);
7720
7949
  }
7721
7950
  var SECRET_MASK = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'; // \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022
7951
+ // An encrypted-at-rest value (native secrets etc.) is stored with an "enc:"
7952
+ // sentinel prefix (see framework/native-entities decrypt). It is never
7953
+ // plaintext, so the GUI must never render the raw ciphertext \u2014 mask it the
7954
+ // same way an operator-flagged secret column is masked.
7955
+ function looksEncrypted(v) {
7956
+ return typeof v === 'string' && v.slice(0, 4) === 'enc:';
7957
+ }
7722
7958
 
7723
7959
  function displayFor(name) {
7724
7960
  var override = state.iconOverrides[name];
@@ -8295,11 +8531,21 @@ var appJs = `
8295
8531
  } catch (_) { return ''; }
8296
8532
  }
8297
8533
 
8534
+ // Elapsed duration since a start timestamp (ms), for in-progress work like a
8535
+ // running upload \u2014 no "ago" suffix. Mirrors relTime's unit thresholds.
8536
+ function formatElapsed(ms) {
8537
+ var s = Math.max(0, Math.floor(ms / 1000));
8538
+ if (s < 60) return s + 's';
8539
+ if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
8540
+ return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
8541
+ }
8542
+
8298
8543
  // \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.
8544
+ // Search \u2014 the top box hands the query to the AI ASSISTANT (which answers
8545
+ // conversationally using its search/read tools), not a plain full-text
8546
+ // match. hideSearchResults/openSearchHit are retained because the activity
8547
+ // feed still uses openSearchHit to jump to a row.
8300
8548
  // \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
8549
  function hideSearchResults() {
8304
8550
  var box = document.getElementById('search-results');
8305
8551
  if (box) { box.hidden = true; box.innerHTML = ''; }
@@ -8313,66 +8559,30 @@ var appJs = `
8313
8559
  var prefix = advancedMode() ? '#/objects/' : '#/fs/';
8314
8560
  location.hash = prefix + encodeURIComponent(table) + '/' + encodeURIComponent(id);
8315
8561
  }
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 */ });
8562
+ // Route the typed query into the assistant rail as a chat turn. Opens the
8563
+ // rail (a no-op on desktop; opens the mobile drawer) and submits via the
8564
+ // same path as the composer, so the assistant searches + answers.
8565
+ function askAssistant(q) {
8566
+ hideSearchResults();
8567
+ var input = document.getElementById('search-input');
8568
+ if (input) input.value = '';
8569
+ var rail = document.getElementById('assistant-rail');
8570
+ if (rail) rail.classList.add('expanded');
8571
+ var chatInput = document.getElementById('chat-input');
8572
+ if (chatInput) chatInput.focus();
8573
+ sendChat(q);
8353
8574
  }
8354
8575
  function initSearch() {
8355
8576
  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
- });
8577
+ if (!input) return;
8364
8578
  input.addEventListener('keydown', function (e) {
8365
- if (e.key === 'Escape') { hideSearchResults(); input.blur(); }
8579
+ if (e.key === 'Escape') { input.value = ''; input.blur(); }
8366
8580
  else if (e.key === 'Enter') {
8367
- var first = box.querySelector('.search-hit');
8368
- if (first) { e.preventDefault(); first.click(); }
8581
+ e.preventDefault();
8582
+ var q = input.value.trim();
8583
+ if (q) askAssistant(q);
8369
8584
  }
8370
8585
  });
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
8586
  }
8377
8587
 
8378
8588
  /** Reload column meta after a secret-flag change. */
@@ -8576,9 +8786,16 @@ var appJs = `
8576
8786
  headers: { 'content-type': 'application/json' },
8577
8787
  body: JSON.stringify({ id: id }),
8578
8788
  }).then(function () {
8579
- menu.hidden = true;
8789
+ // Keep the menu OPEN with the item's spinner through the reload \u2014
8790
+ // for a CLOUD workspace the slow part (connecting + fetching
8791
+ // against the remote DB) happens here in reloadEverything, AFTER
8792
+ // the switch POST. Hiding the menu now (the old behavior) hid the
8793
+ // only progress signal, so a cloud switch looked unresponsive.
8794
+ // renderWsSwitcher (inside reloadEverything) only re-binds the
8795
+ // toggle + updates the label, so the spinning item survives.
8580
8796
  return reloadEverything();
8581
8797
  }).then(function () {
8798
+ menu.hidden = true;
8582
8799
  // Conversations + activity both live in the workspace DB. Drop
8583
8800
  // the old workspace's thread + activity cards, reconnect the feed
8584
8801
  // to THIS workspace, and reload its thread list (+ latest convo).
@@ -8587,7 +8804,7 @@ var appJs = `
8587
8804
  startFeed();
8588
8805
  refreshThreadList(true);
8589
8806
  showToast('Switched workspace', {});
8590
- }).catch(function (err) { showToast('Switch failed: ' + err.message, {}); });
8807
+ }).catch(function (err) { menu.hidden = true; showToast('Switch failed: ' + err.message, {}); });
8591
8808
  });
8592
8809
  });
8593
8810
  });
@@ -8953,6 +9170,14 @@ var appJs = `
8953
9170
  clearUnseen(tableName);
8954
9171
  var t = tableByName(tableName);
8955
9172
  if (!t) {
9173
+ // Conversation-storage tables (chat_messages/chat_threads) and other
9174
+ // Lattice internals aren't in the Objects list, but are browsable
9175
+ // read-only under "System". If something routed here for one of them,
9176
+ // fall back to the system-table view instead of "Unknown entity".
9177
+ if ((state.systemTables || []).some(function (s) { return s.name === tableName; })) {
9178
+ renderSystemTable(content, tableName);
9179
+ return;
9180
+ }
8956
9181
  content.innerHTML = '<div class="placeholder">Unknown entity: ' + escapeHtml(tableName) + '</div>';
8957
9182
  return;
8958
9183
  }
@@ -8984,7 +9209,7 @@ var appJs = `
8984
9209
  } else {
8985
9210
  bodyRows = rows.map(function (r) {
8986
9211
  var tds = intrinsic.map(function (c) {
8987
- if (isSecretColumn(tableName, c) && r[c] != null && r[c] !== '') {
9212
+ if ((isSecretColumn(tableName, c) || looksEncrypted(r[c])) && r[c] != null && r[c] !== '') {
8988
9213
  return '<td class="muted">' + SECRET_MASK + '</td>';
8989
9214
  }
8990
9215
  return '<td><div class="cell-clip">' + escapeHtml(truncate(r[c], 120)) + '</div></td>';
@@ -9269,7 +9494,7 @@ var appJs = `
9269
9494
  function paint(editing) {
9270
9495
  var rows = [];
9271
9496
  intrinsic.forEach(function (c) {
9272
- var secret = isSecretColumn(tableName, c);
9497
+ var secret = isSecretColumn(tableName, c) || looksEncrypted(row[c]);
9273
9498
  var dd;
9274
9499
  if (editing) {
9275
9500
  dd = fieldFor(c, row[c], t);
@@ -9637,7 +9862,7 @@ var appJs = `
9637
9862
  function fsValInner(table, row, col) {
9638
9863
  var raw = row[col];
9639
9864
  if (raw == null || raw === '') return '<span class="fs-empty-val">\u2014</span>';
9640
- if (isSecretColumn(table, col)) return '<span class="muted">' + SECRET_MASK + '</span>';
9865
+ if (isSecretColumn(table, col) || looksEncrypted(raw)) return '<span class="muted">' + SECRET_MASK + '</span>';
9641
9866
  var s = String(raw);
9642
9867
  if (FS_LONGFORM.indexOf(col) >= 0 || s.indexOf('\\n') >= 0) {
9643
9868
  return '<div class="md-body">' + mdToHtml(s.slice(0, 40000)) + '</div>';
@@ -11639,20 +11864,29 @@ var appJs = `
11639
11864
  '</div>' +
11640
11865
  '</div>';
11641
11866
  }
11867
+ // Only the selected provider's key input is shown (declutter). 'auto'
11868
+ // ("Select provider\u2026") shows no key row until a provider is chosen.
11869
+ function voiceRowHtml(provider) {
11870
+ if (provider === 'openai') {
11871
+ return rowHtml('asst-openai', 'OpenAI Whisper key', !!cfg.hasOpenaiKey, 'sk-\u2026');
11872
+ }
11873
+ if (provider === 'elevenlabs') {
11874
+ return rowHtml('asst-elevenlabs', 'ElevenLabs key', !!cfg.hasElevenlabsKey, 'xi-\u2026');
11875
+ }
11876
+ return '';
11877
+ }
11642
11878
  host.innerHTML =
11643
11879
  '<div class="dbconfig-panel" style="margin-bottom:18px;padding:14px;border:1px solid var(--border);border-radius:8px;background:var(--surface)">' +
11644
11880
  '<h3 style="margin:0 0 10px">Assistant</h3>' +
11645
11881
  '<p class="lead" style="margin:0 0 12px;font-size:12px;color:var(--text-muted)">' +
11646
- 'Keys are stored encrypted in the <code>secrets</code> table \u2014 never shown again once ' +
11647
- 'saved. Environment variables (<code>ANTHROPIC_API_KEY</code>, <code>OPENAI_API_KEY</code>, ' +
11648
- '<code>ELEVENLABS_API_KEY</code>) also work.' +
11882
+ 'Keys are stored encrypted in the <code>secrets</code> table.' +
11649
11883
  '</p>' +
11650
11884
  rowHtml('asst-anthropic', 'Claude API token (chat)', !!cfg.hasAnthropicKey, 'sk-ant-\u2026') +
11651
- '<div style="margin:0 0 12px;font-size:12px;color:var(--text-muted)">' +
11652
- (cfg.oauthEnabled
11653
- ? 'Or <a href="/api/assistant/oauth/start" style="color:var(--accent)">connect your Claude subscription</a>.'
11654
- : 'Subscription login: set the <code>ANTHROPIC_OAUTH_*</code> env vars to enable.') +
11655
- '</div>' +
11885
+ (cfg.oauthEnabled
11886
+ ? '<div style="margin:0 0 12px;font-size:12px;color:var(--text-muted)">' +
11887
+ 'Or <a href="/api/assistant/oauth/start" style="color:var(--accent)">connect your Claude subscription</a>.' +
11888
+ '</div>'
11889
+ : '') +
11656
11890
  '<div style="margin:6px 0 12px">' +
11657
11891
  '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">' +
11658
11892
  '<strong style="font-size:13px">Inference aggressiveness</strong>' +
@@ -11669,17 +11903,16 @@ var appJs = `
11669
11903
  'auto-creates link tables) when you drop in files. Higher extrapolates more.' +
11670
11904
  '</p>' +
11671
11905
  '</div>' +
11672
- '<div style="font-size:11px;color:var(--text-muted);margin:10px 0 8px;text-transform:uppercase;letter-spacing:0.05em">Voice \u2014 speech to text (set either)</div>' +
11673
- rowHtml('asst-openai', 'OpenAI Whisper key', !!cfg.hasOpenaiKey, 'sk-\u2026') +
11674
- rowHtml('asst-elevenlabs', 'ElevenLabs key', !!cfg.hasElevenlabsKey, 'xi-\u2026') +
11675
- '<div style="margin:6px 0 2px;display:flex;align-items:center;gap:8px">' +
11906
+ '<div style="font-size:11px;color:var(--text-muted);margin:10px 0 8px;text-transform:uppercase;letter-spacing:0.05em">Voice \u2014 speech to text</div>' +
11907
+ '<div style="margin:6px 0 8px;display:flex;align-items:center;gap:8px">' +
11676
11908
  '<span style="font-size:12px;color:var(--text-muted)">Use for voice:</span>' +
11677
11909
  '<select id="asst-stt" style="background:var(--surface-2);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:12px;padding:3px 6px">' +
11678
- '<option value="auto">Auto</option>' +
11679
- '<option value="openai">OpenAI Whisper</option>' +
11910
+ '<option value="auto">Select provider\u2026</option>' +
11911
+ '<option value="openai">OpenAI</option>' +
11680
11912
  '<option value="elevenlabs">ElevenLabs</option>' +
11681
11913
  '</select>' +
11682
11914
  '</div>' +
11915
+ '<div id="asst-voice-key">' + voiceRowHtml(cfg.sttPreference || 'auto') + '</div>' +
11683
11916
  '<div id="assistant-msg" style="margin-top:4px;font-size:12px;color:var(--text-muted)"></div>' +
11684
11917
  '</div>';
11685
11918
  var msg = host.querySelector('#assistant-msg');
@@ -11709,12 +11942,18 @@ var appJs = `
11709
11942
  });
11710
11943
  }
11711
11944
  wire('asst-anthropic', 'anthropic');
11712
- wire('asst-openai', 'openai');
11713
- wire('asst-elevenlabs', 'elevenlabs');
11714
11945
  var sttSel = host.querySelector('#asst-stt');
11946
+ var voiceKeyHost = host.querySelector('#asst-voice-key');
11947
+ function wireVoiceKey(provider) {
11948
+ if (provider === 'openai') wire('asst-openai', 'openai');
11949
+ else if (provider === 'elevenlabs') wire('asst-elevenlabs', 'elevenlabs');
11950
+ }
11715
11951
  if (sttSel) {
11716
11952
  sttSel.value = cfg.sttPreference || 'auto';
11953
+ wireVoiceKey(sttSel.value);
11717
11954
  sttSel.addEventListener('change', function () {
11955
+ if (voiceKeyHost) voiceKeyHost.innerHTML = voiceRowHtml(sttSel.value);
11956
+ wireVoiceKey(sttSel.value);
11718
11957
  msg.textContent = 'Saving\u2026';
11719
11958
  fetch('/api/assistant/stt-provider', {
11720
11959
  method: 'PUT',
@@ -12105,9 +12344,10 @@ var appJs = `
12105
12344
 
12106
12345
  // State-machine Database panel (v1.13+). Renders a different body
12107
12346
  // per info.state: local -> Migrate / Connect-existing wizards;
12108
- // cloud-connected -> Upgrade-to-team; team-cloud-creator/member ->
12109
- // team management UI; team-cloud-needs-invite -> join form.
12110
- // Progression is one-way: local -> cloud -> team-cloud.
12347
+ // team-cloud-creator/member -> connection details + members. A connected
12348
+ // cloud workspace is always a member workspace (created or invited), so
12349
+ // there is no in-settings "join via invite" \u2014 that lives in the Join
12350
+ // Workspace flow only.
12111
12351
  function renderDatabasePanel(host) {
12112
12352
  fetchJson('/api/dbconfig').then(function (info) {
12113
12353
  var badge = renderStateBadge(info);
@@ -12143,10 +12383,6 @@ var appJs = `
12143
12383
  label = 'CLOUD \xB7 MEMBER';
12144
12384
  color = 'var(--accent)';
12145
12385
  break;
12146
- case 'team-cloud-needs-invite':
12147
- label = 'CLOUD \xB7 NEEDS INVITE';
12148
- color = 'var(--warn)';
12149
- break;
12150
12386
  default:
12151
12387
  label = String(info.state || 'UNKNOWN').toUpperCase();
12152
12388
  }
@@ -12182,21 +12418,6 @@ var appJs = `
12182
12418
  '<div id="db-members-host" style="margin-top:12px"><div style="font-size:12px;color:var(--text-muted)">Loading members\u2026</div></div>'
12183
12419
  );
12184
12420
  }
12185
- if (info.state === 'team-cloud-needs-invite') {
12186
- return (
12187
- renderConnectionSummary(info) +
12188
- '<p style="margin-top:10px;color:var(--warn);font-size:13px">' +
12189
- 'Not a member of this cloud workspace yet \u2014 paste your invite token to join.' +
12190
- '</p>' +
12191
- '<div style="display:grid;grid-template-columns:1fr;gap:8px;margin-top:6px">' +
12192
- '<div><label class="field-label">Invite token</label>' +
12193
- '<textarea id="db-rejoin-token" placeholder="latinv_..." style="width:100%;height:54px;font-family:JetBrains Mono,monospace"></textarea></div>' +
12194
- '</div>' +
12195
- '<div class="team-actions" style="margin-top:10px">' +
12196
- '<button class="btn primary" data-act="rejoin-with-token">Join workspace \u2192</button>' +
12197
- '</div>'
12198
- );
12199
- }
12200
12421
  return '<p style="color:var(--text-muted)">Unknown database state.</p>';
12201
12422
  }
12202
12423
 
@@ -12268,32 +12489,6 @@ var appJs = `
12268
12489
  }).catch(function () { membersHost.innerHTML = '<div style="font-size:12px;color:var(--text-muted)">Members unavailable.</div>'; });
12269
12490
  }
12270
12491
 
12271
- var rejoinBtn = host.querySelector('[data-act="rejoin-with-token"]');
12272
- if (rejoinBtn) rejoinBtn.addEventListener('click', function () {
12273
- var token = (document.getElementById('db-rejoin-token').value || '').trim();
12274
- if (!token) { setMsg('Invite token required.', false); return; }
12275
- // Without form re-entry the credentials are already saved; we
12276
- // call the connect-existing endpoint with just the invite
12277
- // token. The handler reads credentials from db-credentials.enc
12278
- // via the active configPath's label.
12279
- setMsg('Joining workspace\u2026');
12280
- fetch('/api/dbconfig/connect-existing', {
12281
- method: 'POST', headers: { 'content-type': 'application/json' },
12282
- body: JSON.stringify({
12283
- type: 'postgres',
12284
- label: info.label,
12285
- host: info.host, port: info.port, dbname: info.dbname,
12286
- user: info.user, password: '', // password lives in db-credentials.enc; backend will pull
12287
- invite_token: token,
12288
- }),
12289
- })
12290
- .then(function (r) { return r.json(); })
12291
- .then(function (d) {
12292
- if (d.error) { setMsg('Failed: ' + d.error, false); return; }
12293
- setMsg('Joined.', true); rerender();
12294
- })
12295
- .catch(function (e) { setMsg('Failed: ' + e.message, false); });
12296
- });
12297
12492
  }
12298
12493
 
12299
12494
  // \u2500\u2500 v1.13 wizards \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
@@ -12669,51 +12864,103 @@ var appJs = `
12669
12864
  insert: '\u2795', update: '\u270F\uFE0F', delete: '\u{1F5D1}',
12670
12865
  link: '\u{1F517}', unlink: '\u26D3', undo: '\u21B6', redo: '\u21B7', schema: '\u{1F6E0}',
12671
12866
  };
12672
- // Ops whose consecutive runs collapse into one counted bubble (bulk row work
12673
- // spams N near-identical rows otherwise). Schema/undo/redo stay distinct.
12867
+ // Schema mutations reach the client in two shapes: the LIVE feed publishes the
12868
+ // coarse op:'schema', while the persisted audit log / per-thread replay carry
12869
+ // the fine-grained op:'schema.delete_entity' (etc.). Treat both as schema so
12870
+ // they collapse + pick the \u{1F6E0} icon identically (regression: backfilled schema
12871
+ // ops showed '\u2022' and never grouped).
12872
+ function isSchemaOp(op) { var o = String(op || ''); return o === 'schema' || o.indexOf('schema.') === 0; }
12873
+ function feedIcon(op) { return isSchemaOp(op) ? FEED_ICONS.schema : (FEED_ICONS[op] || '\u2022'); }
12874
+ // Ops whose runs collapse into one counted bubble (bulk row work spams N
12875
+ // near-identical rows otherwise). Undo/redo stay distinct.
12674
12876
  var GROUPABLE_OPS = { insert: 1, update: 1, delete: 1, link: 1, unlink: 1 };
12675
- var lastFeedGroup = null; // { key, count, item, summaryEl, timeEl }
12676
- function groupedSummary(op, table, count) {
12677
- var t = String(table || '');
12678
- switch (op) {
12679
- case 'insert': return 'Added ' + count + ' rows to ' + t;
12680
- case 'update': return 'Updated ' + count + ' rows in ' + t;
12681
- case 'delete': return 'Removed ' + count + ' rows from ' + t;
12682
- case 'link': return 'Linked ' + count + ' rows in ' + t;
12683
- case 'unlink': return 'Unlinked ' + count + ' rows in ' + t;
12684
- default: return String(op || '') + ' ' + t;
12685
- }
12686
- }
12687
- function renderFeedItem(ev) {
12688
- var feedEl = document.getElementById('rail-feed');
12689
- if (!feedEl) return;
12690
- var empty = document.getElementById('rail-empty');
12691
- 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).
12695
- var groupKey = GROUPABLE_OPS[ev.op] && ev.table
12696
- ? String(ev.op) + '|' + String(ev.table) + '|' + String(ev.source || '')
12697
- : 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;
12711
- }
12877
+ var ROW_VERB = { insert: 'Added', update: 'Updated', delete: 'Removed', link: 'Linked', unlink: 'Unlinked' };
12878
+ var ROW_PREP = { insert: 'to', update: 'in', delete: 'from', link: 'in', unlink: 'in' };
12879
+ // Schema events all arrive as op:'schema'; the specific action lives only in
12880
+ // the summary text. Map that text to a stable sub-action so a bulk run of
12881
+ // "Deleted table X" collapses into one "Deleted 19 tables" pill. Each entry
12882
+ // is [verb, singular, plural].
12883
+ var SCHEMA_GROUP = {
12884
+ 'created-table': ['Created', 'table', 'tables'],
12885
+ 'deleted-table': ['Deleted', 'table', 'tables'],
12886
+ 'renamed-table': ['Renamed', 'table', 'tables'],
12887
+ 'added-column': ['Added', 'column', 'columns'],
12888
+ 'renamed-column': ['Renamed', 'column', 'columns'],
12889
+ 'added-link': ['Added', 'link', 'links'],
12890
+ 'deleted-link': ['Deleted', 'link', 'links'],
12891
+ 'created-link': ['Created', 'link table', 'link tables'],
12892
+ };
12893
+ function schemaAction(summary) {
12894
+ var s = String(summary || '');
12895
+ if (/^Created link table/.test(s)) return 'created-link';
12896
+ if (/^Created table/.test(s)) return 'created-table';
12897
+ if (/^Deleted table/.test(s)) return 'deleted-table';
12898
+ if (/^Renamed table/.test(s)) return 'renamed-table';
12899
+ if (/^Added a column/.test(s)) return 'added-column';
12900
+ if (/^Renamed a column/.test(s)) return 'renamed-column';
12901
+ if (/^Added a link/.test(s)) return 'added-link';
12902
+ if (/^Deleted a link/.test(s)) return 'deleted-link';
12903
+ return null; // unknown schema op: keep it ungrouped (stay honest)
12904
+ }
12905
+ // Group identical-TYPE events into one counted pill regardless of which
12906
+ // object they touched, so a bulk run (delete N tables, remove rows across M
12907
+ // tables) shows a single bubble instead of overflowing the rail. Keyed by
12908
+ // op+source (+schema sub-action); the table is intentionally NOT in the key.
12909
+ // A group stays "open" for FEED_GROUP_WINDOW_MS after its last hit; later
12910
+ // activity starts a fresh bubble so unrelated edits aren't merged in.
12911
+ function feedGroupKey(ev) {
12912
+ var src = String(ev.source || '');
12913
+ if (isSchemaOp(ev.op)) {
12914
+ var a = schemaAction(ev.summary);
12915
+ return a ? 'schema|' + a + '|' + src : null;
12916
+ }
12917
+ return GROUPABLE_OPS[ev.op] ? String(ev.op) + '|' + src : null;
12918
+ }
12919
+ var feedGroups = {}; // key -> { op, count, tables, tableCount, schemaKey, firstSummary, item, summaryEl, timeEl, last, startMs, endMs, turnId }
12920
+ var FEED_GROUP_WINDOW_MS = 15000;
12921
+ // Assistant-turn scope for live activity-card grouping + duration. While a
12922
+ // turn is active, its same-type events all collapse into one card (no window
12923
+ // expiry); the card's timer measures from feedTurnStartMs to the last event.
12924
+ var feedTurnId = 0;
12925
+ var feedTurnActive = false;
12926
+ var feedTurnStartMs = 0;
12927
+ function onlyKey(obj) { for (var k in obj) { if (obj.hasOwnProperty(k)) return k; } return ''; }
12928
+ function groupedRowSummary(op, count, tables, tableCount) {
12929
+ var verb = ROW_VERB[op] || String(op || '');
12930
+ var noun = count === 1 ? 'row' : 'rows';
12931
+ var where = '';
12932
+ if (tableCount > 1) { where = ' across ' + tableCount + ' tables'; }
12933
+ else { var only = onlyKey(tables); if (only) where = ' ' + (ROW_PREP[op] || 'in') + ' ' + only; }
12934
+ return verb + ' ' + count + ' ' + noun + where;
12935
+ }
12936
+ function schemaGroupSummary(schemaKey, count, firstSummary) {
12937
+ var g = SCHEMA_GROUP[schemaKey];
12938
+ if (count <= 1 || !g) return firstSummary || '';
12939
+ return g[0] + ' ' + count + ' ' + g[2];
12940
+ }
12941
+ function groupedSummary(g) {
12942
+ return isSchemaOp(g.op)
12943
+ ? schemaGroupSummary(g.schemaKey, g.count, g.firstSummary)
12944
+ : groupedRowSummary(g.op, g.count, g.tables, g.tableCount);
12945
+ }
12946
+ // While a chat turn is streaming, its typing bubble (the not-yet-arrived next
12947
+ // assistant message) must stay last; tool-driven activity cards belong ABOVE
12948
+ // it, not below \u2014 otherwise the "typing\u2026" dots land mid-conversation. Returns
12949
+ // the .chat-msg to insert before, or null when nothing is streaming.
12950
+ function feedTypingAnchor(feedEl) {
12951
+ var typing = feedEl.querySelector('.chat-bubble[data-typing="1"]');
12952
+ var msg = typing && typing.closest ? typing.closest('.chat-msg') : null;
12953
+ return (msg && msg.parentNode === feedEl) ? msg : null;
12954
+ }
12955
+ // Build one activity card (the shared full-width pill shape). Used by BOTH
12956
+ // the live feed and the per-thread replay so they look identical. Returns the
12957
+ // element plus the summary/time nodes a group mutates in place.
12958
+ function makeFeedCard(ev) {
12712
12959
  var item = document.createElement('div');
12713
12960
  item.className = 'feed-item';
12714
12961
  var icon = document.createElement('div');
12715
12962
  icon.className = 'feed-icon';
12716
- icon.textContent = FEED_ICONS[ev.op] || '\u2022';
12963
+ icon.textContent = feedIcon(ev.op);
12717
12964
  var body = document.createElement('div');
12718
12965
  body.className = 'feed-body';
12719
12966
  var summary = document.createElement('div');
@@ -12729,11 +12976,13 @@ var appJs = `
12729
12976
  body.appendChild(meta);
12730
12977
  var time = document.createElement('div');
12731
12978
  time.className = 'feed-time';
12732
- time.textContent = relTime(ev.ts);
12979
+ // Duration ("4s" / "4m 2s") is filled in by the caller once the group's
12980
+ // start/end span is known \u2014 not a relative "ago".
12981
+ time.textContent = '';
12733
12982
  item.appendChild(icon);
12734
12983
  item.appendChild(body);
12735
12984
  item.appendChild(time);
12736
- // Row events (insert/update/delete) carry a rowId \u2014 make the bubble a
12985
+ // Row events (insert/update/delete) carry a rowId \u2014 make the card a
12737
12986
  // shortcut to that object. Link/unlink and schema events have no single
12738
12987
  // row (rowId is null), so they stay non-clickable.
12739
12988
  if (ev.rowId && ev.table) {
@@ -12741,19 +12990,118 @@ var appJs = `
12741
12990
  item.setAttribute('role', 'button');
12742
12991
  item.setAttribute('tabindex', '0');
12743
12992
  item.title = 'Open this ' + String(ev.table);
12744
- // _rowClickOff is set when the bubble becomes a group \u2014 clicks no-op then.
12993
+ // _rowClickOff is set when the card becomes a group \u2014 clicks no-op then.
12745
12994
  var openRow = function () { if (item._rowClickOff) return; openSearchHit(String(ev.table), String(ev.rowId)); };
12746
12995
  item.addEventListener('click', openRow);
12747
12996
  item.addEventListener('keydown', function (e) {
12748
12997
  if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openRow(); }
12749
12998
  });
12750
12999
  }
12751
- feedEl.appendChild(item);
13000
+ return { item: item, summaryEl: summary, timeEl: time };
13001
+ }
13002
+ // Fold another event into an existing group card: bump the count, track the
13003
+ // table, refresh the summary, and drop the single-row affordances (a grouped
13004
+ // card stands for many rows, so it's a status, not a clickable button).
13005
+ // The card timer shows the TASK DURATION (start \u2192 finish), not a relative
13006
+ // "ago": for a single op it's the time that op took; for a grouped run it's
13007
+ // from the first task's start to the last task's finish. startMs is anchored
13008
+ // to the assistant turn's start (so a one-event card still shows real time);
13009
+ // endMs tracks the latest event in the group.
13010
+ function setGroupTime(g) {
13011
+ if (g.timeEl) g.timeEl.textContent = formatElapsed(Math.max(0, g.endMs - g.startMs));
13012
+ }
13013
+ function applyGroupHit(g, ev, endMs) {
13014
+ g.count += 1;
13015
+ if (ev.table && !g.tables[ev.table]) { g.tables[ev.table] = 1; g.tableCount += 1; }
13016
+ if (typeof endMs === 'number' && endMs > g.endMs) g.endMs = endMs;
13017
+ g.summaryEl.textContent = groupedSummary(g);
13018
+ setGroupTime(g);
13019
+ g.item._rowClickOff = true;
13020
+ g.item.classList.remove('feed-clickable');
13021
+ g.item.removeAttribute('tabindex');
13022
+ g.item.removeAttribute('title');
13023
+ g.item.setAttribute('role', 'status');
13024
+ }
13025
+ function newGroup(ev, card, startMs, endMs) {
13026
+ var tbls = {}; var tc = 0;
13027
+ if (ev.table) { tbls[ev.table] = 1; tc = 1; }
13028
+ return {
13029
+ op: ev.op, count: 1, tables: tbls, tableCount: tc,
13030
+ schemaKey: isSchemaOp(ev.op) ? schemaAction(ev.summary) : null,
13031
+ firstSummary: ev.summary || '',
13032
+ item: card.item, summaryEl: card.summaryEl, timeEl: card.timeEl,
13033
+ startMs: startMs, endMs: endMs,
13034
+ };
13035
+ }
13036
+ function renderFeedItem(ev) {
13037
+ var feedEl = document.getElementById('rail-feed');
13038
+ if (!feedEl) return;
13039
+ var empty = document.getElementById('rail-empty');
13040
+ if (empty) empty.remove();
13041
+ // Coalesce same-TYPE events into one counted card within a recency window \u2014
13042
+ // even across different objects (op+source key, table excluded), so a bulk
13043
+ // run collapses to one card ("Removed 49 rows across 9 tables") instead of
13044
+ // spamming the rail. Distinct tables touched are tracked so a single-table
13045
+ // run still reads "\u2026 from <table>".
13046
+ var groupKey = feedGroupKey(ev);
13047
+ var nowMs = Date.now();
13048
+ if (groupKey) {
13049
+ var g = feedGroups[groupKey];
13050
+ // A group stays open to merge while: (a) we're inside the SAME assistant
13051
+ // turn that opened it \u2014 no time limit, so a slow bulk run (deleting many
13052
+ // tables against a remote DB) stays one card instead of splitting when a
13053
+ // 15s window lapses mid-run; or (b) outside a turn (manual edits / another
13054
+ // client), within the rolling window. Cross-turn events never merge.
13055
+ var open = g && g.item.parentNode === feedEl && (
13056
+ feedTurnActive ? (g.turnId === feedTurnId) : ((nowMs - g.last) < FEED_GROUP_WINDOW_MS)
13057
+ );
13058
+ if (open) {
13059
+ applyGroupHit(g, ev, nowMs);
13060
+ g.last = nowMs;
13061
+ feedEl.scrollTop = feedEl.scrollHeight;
13062
+ return;
13063
+ }
13064
+ }
13065
+ var card = makeFeedCard(ev);
13066
+ // Keep a streaming chat turn's typing bubble pinned to the bottom: insert
13067
+ // this card above it rather than appending below (the dots are the next
13068
+ // message, not done yet). No active turn \u2192 append as usual.
13069
+ var anchor = feedTypingAnchor(feedEl);
13070
+ if (anchor) feedEl.insertBefore(card.item, anchor); else feedEl.appendChild(card.item);
12752
13071
  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;
13072
+ // Anchor the card's duration to the turn start (so even a single-op card
13073
+ // shows how long the task took); fall back to now for non-turn activity.
13074
+ var startMs = (feedTurnActive && feedTurnStartMs) ? feedTurnStartMs : nowMs;
13075
+ if (groupKey) {
13076
+ var grp = newGroup(ev, card, startMs, nowMs);
13077
+ grp.turnId = feedTurnId;
13078
+ grp.last = nowMs;
13079
+ feedGroups[groupKey] = grp;
13080
+ setGroupTime(grp);
13081
+ } else {
13082
+ card.timeEl.textContent = formatElapsed(Math.max(0, nowMs - startMs));
13083
+ }
13084
+ }
13085
+ // Replay a persisted assistant turn's data-change events as collapsed activity
13086
+ // cards. Grouping is PER-TURN (self-contained, independent of the live feed's
13087
+ // rolling window) so each turn's bulk run shows one card and stays tied to the
13088
+ // turn that produced it. Reads aren't persisted as events, so only mutations
13089
+ // appear. Appends in order; the caller positions them after the turn's text.
13090
+ function renderTurnEventCards(feedEl, events, startedMs) {
13091
+ if (!feedEl || !events || !events.length) return;
13092
+ var groups = {};
13093
+ for (var i = 0; i < events.length; i++) {
13094
+ var ev = events[i];
13095
+ var evMs = ev.ts ? new Date(ev.ts).getTime() : startedMs;
13096
+ if (typeof evMs !== 'number' || isNaN(evMs)) evMs = startedMs;
13097
+ var startMs = (typeof startedMs === 'number' && !isNaN(startedMs)) ? startedMs : evMs;
13098
+ var key = feedGroupKey(ev);
13099
+ if (key && groups[key]) { applyGroupHit(groups[key], ev, evMs); continue; }
13100
+ var card = makeFeedCard(ev);
13101
+ feedEl.appendChild(card.item);
13102
+ if (key) { var g = newGroup(ev, card, startMs, evMs); groups[key] = g; setGroupTime(g); }
13103
+ else { card.timeEl.textContent = formatElapsed(Math.max(0, evMs - startMs)); }
13104
+ }
12757
13105
  }
12758
13106
  function startFeed() {
12759
13107
  if (feedSource) {
@@ -12766,14 +13114,16 @@ var appJs = `
12766
13114
  var data;
12767
13115
  try { data = JSON.parse(ev.data); } catch (_) { return; /* ignore malformed */ }
12768
13116
  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)))) {
13117
+ // Refresh on ANY data mutation, not just schema/new-table events. The
13118
+ // local feed bus delivers every insert/update/delete/link even when
13119
+ // there's no realtime broker (SQLite/local), so this is what makes the
13120
+ // home dashboard counts AND the open entity view live-update without a
13121
+ // manual reload (previously only schema ops or brand-new tables did).
13122
+ // scheduleRealtimeRefresh is debounced (200ms) so a burst from one
13123
+ // ingest still coalesces into a single refetch \u2014 and on Postgres/cloud
13124
+ // it shares that debounce with the realtime 'change' handler (no double
13125
+ // fetch). See Rule 28: /api/entities uses batched counts, not N queries.
13126
+ if (data && (data.table || data.op === 'schema')) {
12777
13127
  scheduleRealtimeRefresh();
12778
13128
  }
12779
13129
  });
@@ -12835,12 +13185,13 @@ var appJs = `
12835
13185
  chatHistory = [];
12836
13186
  var feedEl = railFeedEl();
12837
13187
  if (!feedEl) return;
12838
- // Remove only the chat bubbles. The activity cards (.feed-item) are
12839
- // workspace-global, not part of any one conversation \u2014 loading, switching,
12840
- // or starting a conversation must NOT wipe them (they're backfilled once
12841
- // on connect). Otherwise auto-loading a thread on refresh erases the feed.
12842
- var msgs = feedEl.querySelectorAll('.chat-msg');
12843
- for (var i = 0; i < msgs.length; i++) msgs[i].remove();
13188
+ // The rail is conversation-scoped: clearing or switching a conversation
13189
+ // drops both its chat bubbles AND its activity cards (each conversation
13190
+ // replays its own data-change cards from the persisted per-turn events).
13191
+ // Reset the grouping anchors so a freshly loaded thread starts clean.
13192
+ var nodes = feedEl.querySelectorAll('.chat-msg, .feed-item');
13193
+ for (var i = 0; i < nodes.length; i++) nodes[i].remove();
13194
+ feedGroups = {};
12844
13195
  // Restore the empty hint only when the rail is now completely empty.
12845
13196
  if (!feedEl.firstElementChild) {
12846
13197
  feedEl.innerHTML = '<div class="rail-empty" id="rail-empty">No activity yet. Changes you make will appear here.</div>';
@@ -12853,7 +13204,7 @@ var appJs = `
12853
13204
  if (!feedEl) return;
12854
13205
  var items = feedEl.querySelectorAll('.feed-item');
12855
13206
  for (var i = 0; i < items.length; i++) items[i].remove();
12856
- lastFeedGroup = null;
13207
+ feedGroups = {};
12857
13208
  }
12858
13209
  function newChat() {
12859
13210
  currentThreadId = null;
@@ -12893,11 +13244,12 @@ var appJs = `
12893
13244
  msgs.forEach(function (m) {
12894
13245
  if (m.role === 'user') { appendUserBubble(m.text); chatHistory.push({ role: 'user', text: m.text }); }
12895
13246
  else if (m.role === 'assistant') {
12896
- // Rich replay: the saved per-turn structure (text + tool pills),
12897
- // matching the live stream. Falls back to a plain text bubble for
12898
- // messages saved before turns were persisted.
12899
- if (Array.isArray(m.turns) && m.turns.length > 0) { m.turns.forEach(appendAssistantTurn); }
12900
- else { var c = newAssistantBubble(); setBubbleText(c, m.text); }
13247
+ // Rich replay: the saved per-turn structure (text + the data-change
13248
+ // activity cards it produced), matching the live stream. Falls back to
13249
+ // a plain text bubble for messages saved before turns were persisted.
13250
+ if (Array.isArray(m.turns) && m.turns.length > 0) {
13251
+ m.turns.forEach(function (t) { appendAssistantTurn(t, m.created_at, m.startedAt); });
13252
+ } else { var c = newAssistantBubble(); setBubbleText(c, m.text); }
12901
13253
  chatHistory.push({ role: 'assistant', text: m.text });
12902
13254
  }
12903
13255
  });
@@ -12921,126 +13273,88 @@ var appJs = `
12921
13273
  railEmptyGone();
12922
13274
  var feedEl = railFeedEl();
12923
13275
  var msg = document.createElement('div'); msg.className = 'chat-msg assistant';
12924
- var wrap = document.createElement('div');
12925
- var tools = document.createElement('div'); tools.className = 'chat-tools';
12926
13276
  var b = document.createElement('div'); b.className = 'chat-bubble assistant';
12927
13277
  // Show an animated typing indicator until the first text delta arrives.
12928
13278
  b.innerHTML = '<span class="chat-typing"><i></i><i></i><i></i></span>';
12929
13279
  b.setAttribute('data-typing', '1');
12930
- wrap.appendChild(tools); wrap.appendChild(b);
12931
- msg.appendChild(wrap); feedEl.appendChild(msg); feedEl.scrollTop = feedEl.scrollHeight;
12932
- // lastTool anchors the current run of identical tool calls so consecutive
12933
- // same-name calls coalesce into one counted pill (see addToolPill).
12934
- return { bubble: b, tools: tools, pills: {}, lastTool: null, msg: msg };
13280
+ msg.appendChild(b); feedEl.appendChild(msg); feedEl.scrollTop = feedEl.scrollHeight;
13281
+ return { bubble: b, msg: msg };
12935
13282
  }
12936
13283
  /** Set an assistant bubble's text, clearing the typing indicator. */
13284
+ // Turn [label](lattice://table/id) object references the assistant emits into
13285
+ // clickable pills that open the row (mode-aware, via openSearchHit). The
13286
+ // links are pulled out into placeholders BEFORE markdown rendering and the
13287
+ // pill HTML is swapped back in AFTER \u2014 so it's independent of mdToHtml's own
13288
+ // link handling and survives HTML-escaping. Labels/ids are re-escaped.
13289
+ function renderAssistantHtml(text) {
13290
+ var pills = [];
13291
+ // U+0002 sentinel survives mdToHtml's escape + inline passes untouched.
13292
+ // Use a unicode-escape string literal for insertion and a REGEX LITERAL for
13293
+ // the swap (one escaping level each) \u2014 a new RegExp('(\\d+)') here would be
13294
+ // double-collapsed by the template literal into a literal "d", silently
13295
+ // breaking the swap (the pill rendered as a bare index).
13296
+ var pre = String(text == null ? '' : text).replace(
13297
+ /\\[([^\\]]+)\\]\\(lattice:\\/\\/([a-zA-Z0-9_]+)\\/([^)\\s]+)\\)/g,
13298
+ function (_, label, table, id) {
13299
+ pills.push({ label: label, table: table, id: id });
13300
+ return '\\u0002' + (pills.length - 1) + '\\u0002';
13301
+ }
13302
+ );
13303
+ var html = mdToHtml(pre);
13304
+ return html.replace(/\\u0002([0-9]+)\\u0002/g, function (_, n) {
13305
+ var p = pills[Number(n)];
13306
+ return '<a class="chip chip-link lattice-ref" data-table="' + escapeHtml(p.table) +
13307
+ '" data-id="' + escapeHtml(p.id) + '" title="Open this ' + escapeHtml(p.table) + '">\u{1F517} ' +
13308
+ escapeHtml(p.label) + '</a>';
13309
+ });
13310
+ }
13311
+ // One delegated click handler on the rail feed: a lattice-ref pill opens its
13312
+ // object through the same mode-aware navigator the activity feed uses.
13313
+ var _latticeRefWired = false;
13314
+ function ensureLatticeRefHandler() {
13315
+ if (_latticeRefWired) return;
13316
+ var feedEl = document.getElementById('rail-feed');
13317
+ if (!feedEl) return;
13318
+ feedEl.addEventListener('click', function (e) {
13319
+ var a = e.target && e.target.closest ? e.target.closest('.lattice-ref') : null;
13320
+ if (!a) return;
13321
+ e.preventDefault();
13322
+ openSearchHit(a.getAttribute('data-table'), a.getAttribute('data-id'));
13323
+ });
13324
+ _latticeRefWired = true;
13325
+ }
12937
13326
  function setBubbleText(ctx, text) {
12938
13327
  if (!ctx || !ctx.bubble) return; // bubble may have been finalized/removed
12939
13328
  ctx.bubble.removeAttribute('data-typing');
12940
13329
  // Assistant turns are Markdown; render (input is HTML-escaped inside
12941
- // mdToHtml first, so this is injection-safe).
12942
- ctx.bubble.innerHTML = mdToHtml(text);
13330
+ // mdToHtml first, so this is injection-safe) + linkify object references.
13331
+ ctx.bubble.innerHTML = renderAssistantHtml(text);
13332
+ ensureLatticeRefHandler();
12943
13333
  }
12944
13334
  /**
12945
- * A turn ended. If its bubble never got text (still showing the typing
12946
- * indicator), drop the empty bubble \u2014 keeping any tool pills it fired, or
12947
- * removing the whole message when there were none. Stops a dangling
12948
- * "typing\u2026" bubble after the stream completes.
13335
+ * A turn ended still showing the typing indicator (no text streamed) \u2014 drop
13336
+ * the empty bubble. The turn's data-change activity cards live in the rail
13337
+ * feed independently (not inside the message), so they remain.
12949
13338
  */
12950
13339
  function finalizeBubble(ctx) {
12951
13340
  if (!ctx || !ctx.bubble || !ctx.bubble.getAttribute('data-typing')) return;
12952
- if (ctx.tools && ctx.tools.children.length > 0) ctx.bubble.remove();
12953
- else if (ctx.msg) ctx.msg.remove();
12954
- }
12955
- var TOOL_VERBS = {
12956
- create_row: ['Creating row', 'Row created', 'Could not create row'],
12957
- update_row: ['Updating row', 'Row updated', 'Could not update row'],
12958
- delete_row: ['Deleting row', 'Row deleted', 'Could not delete row'],
12959
- list_rows: ['Listing rows', 'Listed rows', 'Could not list rows'],
12960
- get_row: ['Fetching row', 'Fetched row', 'Could not fetch row'],
12961
- list_entities: ['Listing tables', 'Listed tables', 'Could not list tables']
12962
- };
12963
- // Grouped (count > 1) [gerund, past, noun] so a run of identical tool calls
12964
- // collapses into ONE counted pill \u2014 "Listed 5 rows" \u2014 instead of N identical
12965
- // "Listed rows" pills. Mirrors the activity feed's groupedSummary() coalescing.
12966
- var TOOL_GROUP = {
12967
- create_row: ['Creating', 'Created', 'rows'],
12968
- update_row: ['Updating', 'Updated', 'rows'],
12969
- delete_row: ['Deleting', 'Deleted', 'rows'],
12970
- list_rows: ['Listing', 'Listed', 'rows'],
12971
- get_row: ['Fetching', 'Fetched', 'rows'],
12972
- list_entities: ['Listing', 'Listed', 'tables']
12973
- };
12974
- function toolLabel(name, state) {
12975
- var v = TOOL_VERBS[name] || [name, name, name];
12976
- return state === 'pending' ? v[0] + '\u2026' : (state === 'error' ? v[2] : v[1]);
12977
- }
12978
- // Label for a run of "count" identical calls. count <= 1 falls back to the
12979
- // single-call label so a lone pill reads exactly as before.
12980
- function toolGroupLabel(name, count, state) {
12981
- if (count <= 1) return toolLabel(name, state);
12982
- var g = TOOL_GROUP[name];
12983
- if (!g) return toolLabel(name, state) + ' \xD7' + count; // unknown tool: stay honest
12984
- var verb = state === 'pending' ? g[0] : g[1];
12985
- return verb + ' ' + count + ' ' + g[2] + (state === 'pending' ? '\u2026' : '');
12986
- }
12987
- // Paint a (possibly grouped) pill from its live counts: spinner while any call
12988
- // is still running, then \u2713 (or \u26A0 if any errored) once every call resolves.
12989
- function paintToolPill(g) {
12990
- var pending = g.pending > 0;
12991
- var err = !pending && g.error > 0;
12992
- if (pending) {
12993
- g.el.className = 'tool-pill';
12994
- g.el.innerHTML = '<span class="spin"></span>' + escapeHtml(toolGroupLabel(g.name, g.total, 'pending'));
12995
- } else {
12996
- g.el.className = 'tool-pill ' + (err ? 'error' : 'done');
12997
- g.el.textContent = (err ? '\u26A0 ' : '\u2713 ') + toolGroupLabel(g.name, g.total, err ? 'error' : 'done');
12998
- }
12999
- }
13000
- function addToolPill(ctx, id, name) {
13001
- // Coalesce a run of the same tool within this turn's pill row into one
13002
- // counted pill (the model emits several list_rows in a single turn).
13003
- var g = ctx.lastTool;
13004
- if (g && g.name === name) {
13005
- g.total += 1; g.pending += 1;
13006
- } else {
13007
- var pill = document.createElement('span'); pill.className = 'tool-pill';
13008
- ctx.tools.appendChild(pill);
13009
- g = { name: name, el: pill, total: 1, pending: 1, error: 0 };
13010
- ctx.lastTool = g;
13011
- }
13012
- ctx.pills[id] = g; // resolveToolPill maps the tool-use id back to its group
13013
- paintToolPill(g);
13341
+ if (ctx.msg) ctx.msg.remove();
13014
13342
  }
13015
- function resolveToolPill(ctx, id, isError) {
13016
- var g = ctx.pills[id]; if (!g) return;
13017
- if (g.pending > 0) g.pending -= 1;
13018
- if (isError) g.error += 1;
13019
- paintToolPill(g);
13020
- }
13021
- /**
13022
- * Append already-resolved pills for a replayed turn, collapsing consecutive
13023
- * identical tools into one counted pill (matching the live grouping above).
13024
- */
13025
- function renderResolvedPills(ctx, tools) {
13026
- var i = 0;
13027
- while (i < tools.length) {
13028
- var name = tools[i].name, j = i, errors = 0;
13029
- while (j < tools.length && tools[j].name === name) { if (tools[j].isError) errors += 1; j++; }
13030
- var count = j - i, err = errors > 0;
13031
- var pill = document.createElement('span');
13032
- pill.className = 'tool-pill ' + (err ? 'error' : 'done');
13033
- pill.textContent = (err ? '\u26A0 ' : '\u2713 ') + toolGroupLabel(name, count, err ? 'error' : 'done');
13034
- ctx.tools.appendChild(pill);
13035
- i = j;
13036
- }
13037
- }
13038
- /** Replay one persisted assistant turn: its tool pills + text bubble. */
13039
- function appendAssistantTurn(turn) {
13343
+ /** Replay one persisted assistant turn: its text bubble + the data-change
13344
+ * activity cards it produced (collapsed, per-turn). Reads aren't persisted
13345
+ * as events, so a read-only turn with no text renders nothing. createdAt
13346
+ * stamps the cards' relative time (events carry no ts of their own). */
13347
+ function appendAssistantTurn(turn, createdAt, startedAt) {
13040
13348
  var ctx = newAssistantBubble();
13041
- renderResolvedPills(ctx, turn.tools || []);
13042
13349
  if (turn.text) setBubbleText(ctx, turn.text);
13043
- else finalizeBubble(ctx); // tool-only turn: drop the empty bubble, keep pills
13350
+ else finalizeBubble(ctx); // no text \u2192 drop the empty typing bubble
13351
+ var events = (turn.events || []).map(function (e) {
13352
+ return e.ts ? e : { op: e.op, table: e.table, rowId: e.rowId, summary: e.summary, source: e.source || 'ai', ts: createdAt };
13353
+ });
13354
+ // Task start for the duration timer: the persisted turn-start, else the
13355
+ // message time. Per-event ts (above) gives the run's finish.
13356
+ var startedMs = new Date(startedAt || createdAt || 0).getTime();
13357
+ renderTurnEventCards(railFeedEl(), events, startedMs);
13044
13358
  }
13045
13359
  function parseSse(buffer, onEvent) {
13046
13360
  var sep;
@@ -13056,6 +13370,11 @@ var appJs = `
13056
13370
  function sendChat(text) {
13057
13371
  if (chatBusy || !text) return;
13058
13372
  chatBusy = true;
13373
+ // Open a fresh turn scope: this turn's activity cards group together (no
13374
+ // window expiry) and their timers measure from now.
13375
+ feedTurnId += 1;
13376
+ feedTurnStartMs = Date.now();
13377
+ feedTurnActive = true;
13059
13378
  appendUserBubble(text);
13060
13379
  var historyToSend = chatHistory.slice();
13061
13380
  chatHistory.push({ role: 'user', text: text });
@@ -13082,8 +13401,10 @@ var appJs = `
13082
13401
  buf = parseSse(buf, function (ev) {
13083
13402
  if (ev.type === 'assistant_message_start') { finalizeBubble(actx); actx = newAssistantBubble(); assembled = ''; }
13084
13403
  else if (ev.type === 'text_delta' && actx) { assembled += ev.delta; setBubbleText(actx, assembled); railFeedEl().scrollTop = railFeedEl().scrollHeight; }
13085
- else if (ev.type === 'tool_use' && actx) { addToolPill(actx, ev.id, ev.name); }
13086
- else if (ev.type === 'tool_result' && actx) { resolveToolPill(actx, ev.toolUseId, ev.isError); }
13404
+ // tool_use / tool_result are no longer painted as inline pills \u2014 the
13405
+ // assistant's data changes stream in as activity cards over the feed
13406
+ // SSE (renderFeedItem), which sit above the typing bubble. Reads emit
13407
+ // no card by design (only data changes show).
13087
13408
  else if (ev.type === 'warn') { finalizeBubble(actx); var wb = newAssistantBubble(); setBubbleText(wb, '\u26A0 ' + ev.message); actx = null; }
13088
13409
  else if (ev.type === 'error') { if (!actx) actx = newAssistantBubble(); setBubbleText(actx, (assembled ? assembled + '\\n' : '') + '\u26A0 ' + ev.message); }
13089
13410
  });
@@ -13100,6 +13421,9 @@ var appJs = `
13100
13421
  var c = newAssistantBubble(); setBubbleText(c, '\u26A0 ' + e.message);
13101
13422
  }).finally(function () {
13102
13423
  chatBusy = false;
13424
+ // Close the turn scope: later activity starts fresh cards (the next turn,
13425
+ // or manual edits via the rolling window).
13426
+ feedTurnActive = false;
13103
13427
  var sb = document.getElementById('chat-send'); if (sb) sb.disabled = false;
13104
13428
  var inp = document.getElementById('chat-input'); if (inp) inp.focus();
13105
13429
  });
@@ -13109,6 +13433,32 @@ var appJs = `
13109
13433
  var audioChunks = [];
13110
13434
  function setMicState(btn, state) {
13111
13435
  recState = state;
13436
+ // Mirror the recording lifecycle onto the composer. While recording or
13437
+ // transcribing, the textarea is read-only (it shows a status placeholder,
13438
+ // not editable text) and the Send button is disabled \u2014 you can't send a
13439
+ // half-captured voice note. Returning to idle restores both, then the
13440
+ // transcript is dropped in (see rec.onstop).
13441
+ var inp = document.getElementById('chat-input');
13442
+ var snd = document.getElementById('chat-send');
13443
+ var busy = state === 'recording' || state === 'transcribing';
13444
+ if (inp) {
13445
+ if (busy) {
13446
+ if (inp._restorePlaceholder == null) {
13447
+ inp._restorePlaceholder = inp.getAttribute('placeholder') || '';
13448
+ }
13449
+ inp.setAttribute('readonly', 'readonly');
13450
+ inp.classList.add('recording');
13451
+ inp.setAttribute('placeholder', state === 'recording' ? 'Listening\u2026' : 'Transcribing\u2026');
13452
+ } else {
13453
+ inp.removeAttribute('readonly');
13454
+ inp.classList.remove('recording');
13455
+ if (inp._restorePlaceholder != null) {
13456
+ inp.setAttribute('placeholder', inp._restorePlaceholder);
13457
+ inp._restorePlaceholder = null;
13458
+ }
13459
+ }
13460
+ }
13461
+ if (snd) snd.disabled = busy;
13112
13462
  if (!btn) return;
13113
13463
  btn.classList.remove('recording', 'transcribing');
13114
13464
  if (state === 'recording') { btn.classList.add('recording'); btn.textContent = '\u23F9'; btn.title = 'Stop recording'; btn.disabled = false; }
@@ -13208,10 +13558,26 @@ var appJs = `
13208
13558
  item.innerHTML =
13209
13559
  '<div class="feed-icon"><span class="feed-spinner"></span></div>' +
13210
13560
  '<div class="feed-body"><div class="feed-summary">Analyzing ' + escapeHtml(label) + '\u2026</div></div>' +
13211
- '<div class="feed-time"></div>';
13212
- feedEl.appendChild(item);
13561
+ '<div class="feed-time">0s</div>';
13562
+ // Same bottom-pin rule as renderFeedItem: don't bury a streaming chat
13563
+ // turn's typing bubble beneath this card.
13564
+ var anchor = feedTypingAnchor(feedEl);
13565
+ if (anchor) feedEl.insertBefore(item, anchor); else feedEl.appendChild(item);
13213
13566
  feedEl.scrollTop = feedEl.scrollHeight;
13214
- return function () { if (item.parentNode) item.parentNode.removeChild(item); };
13567
+ // Live elapsed-time counter while the upload + server-side extraction run.
13568
+ // Previously the time element was left empty (rendered as a stuck "0s")
13569
+ // because nothing tracked or updated it. Tick once a second; the cleanup
13570
+ // returned below clears the interval (and self-clears if the node is gone).
13571
+ var started = Date.now();
13572
+ var timeEl = item.querySelector('.feed-time');
13573
+ var tick = setInterval(function () {
13574
+ if (!item.parentNode || !timeEl) { clearInterval(tick); return; }
13575
+ timeEl.textContent = formatElapsed(Date.now() - started);
13576
+ }, 1000);
13577
+ return function () {
13578
+ clearInterval(tick);
13579
+ if (item.parentNode) item.parentNode.removeChild(item);
13580
+ };
13215
13581
  }
13216
13582
  function uploadFile(file) {
13217
13583
  var done = pendingIngestItem(file.name || 'file');
@@ -13386,7 +13752,7 @@ var guiAppHtml = `<!doctype html>
13386
13752
  </div>
13387
13753
  <div class="topsearch" id="topsearch">
13388
13754
  <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" />
13755
+ <input type="search" id="search-input" placeholder="Ask the assistant\u2026" autocomplete="off" spellcheck="false" aria-label="Ask the assistant" />
13390
13756
  <div class="search-results" id="search-results" hidden></div>
13391
13757
  </div>
13392
13758
  <div class="history-controls">
@@ -15190,203 +15556,6 @@ function saveConfigDoc(configPath, doc) {
15190
15556
  writeFileSync6(configPath, doc.toString(), "utf8");
15191
15557
  }
15192
15558
 
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
15559
  // src/gui/schema-ops.ts
15391
15560
  async function listPhysicalUserTables(active) {
15392
15561
  const adapter = active.db._adapter;
@@ -15569,6 +15738,130 @@ async function createUserEntity(active, name, columns, sessionId, opts) {
15569
15738
  );
15570
15739
  return entity;
15571
15740
  }
15741
+ async function softDeleteUserEntity(active, name, sessionId, summary) {
15742
+ const doc = loadConfigDoc(active.configPath);
15743
+ const entityDef = doc.toJS().entities?.[name];
15744
+ doc.deleteIn(["entities", name]);
15745
+ saveConfigDoc(active.configPath, doc);
15746
+ active.db.unregisterTable(name);
15747
+ active.validTables.delete(name);
15748
+ active.junctionTables.delete(name);
15749
+ active.softDeletable.delete(name);
15750
+ active.entityContextByTable.delete(name);
15751
+ syncCanonicalContexts(active);
15752
+ await recordSchemaOp(
15753
+ active,
15754
+ "schema.delete_entity",
15755
+ name,
15756
+ { entity: name, entityDef },
15757
+ null,
15758
+ summary ?? `Deleted table ${name}`,
15759
+ sessionId
15760
+ );
15761
+ }
15762
+ var AI_DELETE_ROW_CAP = 1e3;
15763
+ async function aiDeleteEntity(active, name, resolution, sessionId) {
15764
+ if (!active.validTables.has(name)) return { ok: false, error: `Unknown table: ${name}` };
15765
+ if (isNativeEntity(name)) {
15766
+ return { ok: false, error: `"${name}" is a built-in table and cannot be deleted.` };
15767
+ }
15768
+ const tc = active.teamContext;
15769
+ if (tc && tc.owners.get(name) !== tc.myUserId) {
15770
+ return { ok: false, error: `Only the table's owner can delete "${name}".` };
15771
+ }
15772
+ const inbound = [];
15773
+ for (const t of getGuiEntities(active.configPath, active.outputDir).tables) {
15774
+ if (t.name === name) continue;
15775
+ for (const rel of Object.values(t.relations)) {
15776
+ if (rel.type === "belongsTo" && rel.table === name) {
15777
+ inbound.push(`${t.name}.${rel.foreignKey}`);
15778
+ }
15779
+ }
15780
+ }
15781
+ if (inbound.length > 0) {
15782
+ return {
15783
+ ok: false,
15784
+ error: `Cannot delete "${name}" \u2014 these links point at it: ${inbound.join(", ")}. Remove those links first.`
15785
+ };
15786
+ }
15787
+ const mctx = {
15788
+ db: active.db,
15789
+ feed: active.feed,
15790
+ softDeletable: active.softDeletable,
15791
+ source: "ai"
15792
+ };
15793
+ const softDeletable = active.softDeletable.has(name);
15794
+ const rowCount = softDeletable ? await active.db.count(name, { filters: [{ col: "deleted_at", op: "isNull" }] }) : await active.db.count(name);
15795
+ if (rowCount === 0) {
15796
+ await softDeleteUserEntity(active, name, sessionId);
15797
+ return { ok: true, deleted: name };
15798
+ }
15799
+ if (resolution === void 0) {
15800
+ return {
15801
+ needsResolution: true,
15802
+ rowCount,
15803
+ 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>"}.`
15804
+ };
15805
+ }
15806
+ if (resolution === "delete_data") {
15807
+ if (!softDeletable) {
15808
+ return {
15809
+ ok: false,
15810
+ error: `"${name}" rows can't be soft-deleted (no deleted_at column) \u2014 clear them manually first.`
15811
+ };
15812
+ }
15813
+ if (rowCount > AI_DELETE_ROW_CAP) {
15814
+ return {
15815
+ ok: false,
15816
+ error: `"${name}" has ${String(rowCount)} rows \u2014 too many to auto-delete safely (cap ${String(AI_DELETE_ROW_CAP)}). Trim it first.`
15817
+ };
15818
+ }
15819
+ const rows2 = await active.db.query(name, {
15820
+ filters: [{ col: "deleted_at", op: "isNull" }],
15821
+ limit: AI_DELETE_ROW_CAP
15822
+ });
15823
+ let deletedRows = 0;
15824
+ for (const r of rows2) {
15825
+ await deleteRow(mctx, name, String(r.id), false);
15826
+ deletedRows++;
15827
+ }
15828
+ await softDeleteUserEntity(active, name, sessionId);
15829
+ return { ok: true, deleted: name, deletedRows };
15830
+ }
15831
+ const target = resolution.move_to;
15832
+ if (!active.validTables.has(target)) {
15833
+ return { ok: false, error: `move_to target "${target}" is not a known table.` };
15834
+ }
15835
+ if (target === name) return { ok: false, error: "move_to target must be a different table." };
15836
+ if (active.junctionTables.has(target) || isNativeEntity(target)) {
15837
+ return { ok: false, error: `Cannot move rows into "${target}".` };
15838
+ }
15839
+ if (rowCount > AI_DELETE_ROW_CAP) {
15840
+ return {
15841
+ ok: false,
15842
+ error: `"${name}" has ${String(rowCount)} rows \u2014 too many to auto-move (cap ${String(AI_DELETE_ROW_CAP)}).`
15843
+ };
15844
+ }
15845
+ const targetCols = active.db.getRegisteredColumns(target);
15846
+ if (!targetCols) return { ok: false, error: `Could not read the columns of "${target}".` };
15847
+ const rows = await active.db.query(
15848
+ name,
15849
+ softDeletable ? { filters: [{ col: "deleted_at", op: "isNull" }], limit: AI_DELETE_ROW_CAP } : { limit: AI_DELETE_ROW_CAP }
15850
+ );
15851
+ const SKIP = /* @__PURE__ */ new Set(["id", "deleted_at", "created_at", "updated_at"]);
15852
+ let movedRows = 0;
15853
+ for (const r of rows) {
15854
+ const mapped = {};
15855
+ for (const [k, v] of Object.entries(r)) {
15856
+ if (!SKIP.has(k) && k in targetCols) mapped[k] = v;
15857
+ }
15858
+ await createRow(mctx, target, mapped);
15859
+ if (softDeletable) await deleteRow(mctx, name, String(r.id), false);
15860
+ movedRows++;
15861
+ }
15862
+ await softDeleteUserEntity(active, name, sessionId);
15863
+ return { ok: true, deleted: name, movedRows };
15864
+ }
15572
15865
 
15573
15866
  // src/teams/server/routes.ts
15574
15867
  var UNAUTHENTICATED_TEAM_PATHS = /* @__PURE__ */ new Set([
@@ -18078,6 +18371,9 @@ async function dispatchUserConfigRoute(req, res, ctx) {
18078
18371
  const body = await readJson(req);
18079
18372
  const current = readPreferences();
18080
18373
  const next = {
18374
+ // Preserve keys this endpoint doesn't manage (voice_provider,
18375
+ // aggressiveness — set via the assistant config routes).
18376
+ ...current,
18081
18377
  show_system_tables: typeof body.show_system_tables === "boolean" ? body.show_system_tables : current.show_system_tables,
18082
18378
  analytics: typeof body.analytics === "boolean" ? body.analytics : current.analytics
18083
18379
  };
@@ -18239,19 +18535,8 @@ async function getCreatorEmail(db) {
18239
18535
  return null;
18240
18536
  }
18241
18537
  }
18242
- function computeState(type, teamEnabled, label, creatorEmail) {
18538
+ function computeState(type, creatorEmail) {
18243
18539
  if (type === "sqlite") return "local";
18244
- if (!teamEnabled) return "team-cloud-needs-invite";
18245
- if (!label) {
18246
- return "team-cloud-needs-invite";
18247
- }
18248
- let token = null;
18249
- try {
18250
- token = readToken(label);
18251
- } catch {
18252
- token = null;
18253
- }
18254
- if (!token) return "team-cloud-needs-invite";
18255
18540
  const identity = readIdentity();
18256
18541
  if (creatorEmail !== null && identity.email.length > 0 && creatorEmail.toLowerCase() === identity.email.toLowerCase()) {
18257
18542
  return "team-cloud-creator";
@@ -18259,8 +18544,9 @@ function computeState(type, teamEnabled, label, creatorEmail) {
18259
18544
  return "team-cloud-member";
18260
18545
  }
18261
18546
  function applyTeamMembershipState(info, membership) {
18262
- if (!membership || info.type !== "postgres" || !info.teamEnabled) return info.state;
18263
- return membership.joined ? membership.isCreator ? "team-cloud-creator" : "team-cloud-member" : "team-cloud-needs-invite";
18547
+ if (info.type !== "postgres") return info.state;
18548
+ if (membership) return membership.isCreator ? "team-cloud-creator" : "team-cloud-member";
18549
+ return info.state;
18264
18550
  }
18265
18551
  async function describeCurrent(configPath, db) {
18266
18552
  const rawYaml = readFileSync14(configPath, "utf8");
@@ -18282,7 +18568,7 @@ async function describeCurrent(configPath, db) {
18282
18568
  if (labelMatch) {
18283
18569
  const label = labelMatch[1] ?? "";
18284
18570
  const url = getDbCredential(label);
18285
- const state = computeState("postgres", teamEnabled, label, creatorEmail);
18571
+ const state = computeState("postgres", creatorEmail);
18286
18572
  if (url) {
18287
18573
  const parsed = parsePostgresUrl(url);
18288
18574
  if (parsed) {
@@ -18309,7 +18595,7 @@ async function describeCurrent(configPath, db) {
18309
18595
  }
18310
18596
  if (/^postgres(ql)?:\/\//i.test(dbLine)) {
18311
18597
  const parsed = parsePostgresUrl(dbLine);
18312
- const state = computeState("postgres", teamEnabled, void 0, creatorEmail);
18598
+ const state = computeState("postgres", creatorEmail);
18313
18599
  return parsed ? {
18314
18600
  type: "postgres",
18315
18601
  state,
@@ -18932,22 +19218,6 @@ function parseCookies(req) {
18932
19218
  }
18933
19219
  return out;
18934
19220
  }
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
19221
  async function liveSecretsOfKind(db, kind) {
18952
19222
  const rows = await db.query("secrets", {
18953
19223
  filters: [{ col: "kind", op: "eq", val: kind }]
@@ -18975,19 +19245,29 @@ async function readMachineCredential(db, kind) {
18975
19245
  var STT_PROVIDER_KIND = "stt_provider";
18976
19246
  var AGGRESSIVENESS_KIND = "assistant_aggressiveness";
18977
19247
  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);
19248
+ function getAggressiveness() {
19249
+ const n = readPreferences().aggressiveness;
18981
19250
  if (!Number.isFinite(n)) return DEFAULT_AGGRESSIVENESS;
18982
19251
  return Math.min(1, Math.max(0, n));
18983
19252
  }
19253
+ async function retireLegacyPreferenceSecrets(db) {
19254
+ for (const kind of [STT_PROVIDER_KIND, AGGRESSIVENESS_KIND]) {
19255
+ try {
19256
+ for (const row of await liveSecretsOfKind(db, kind)) {
19257
+ await db.update("secrets", row.id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
19258
+ }
19259
+ } catch (e) {
19260
+ console.warn(`[assistant] could not retire legacy ${kind} secret:`, e.message);
19261
+ }
19262
+ }
19263
+ }
18984
19264
  function aggressivenessToTemperature(aggressiveness) {
18985
19265
  return Math.min(1, Math.max(0, aggressiveness));
18986
19266
  }
18987
19267
  async function getVoiceCredential(db) {
18988
19268
  const openai = await readMachineCredential(db, CREDENTIALS.openai.kind) ?? process.env.OPENAI_API_KEY ?? null;
18989
19269
  const eleven = await readMachineCredential(db, CREDENTIALS.elevenlabs.kind) ?? process.env.ELEVENLABS_API_KEY ?? null;
18990
- const pref = await secretValue(db, STT_PROVIDER_KIND);
19270
+ const pref = readPreferences().voice_provider;
18991
19271
  if (pref === "elevenlabs" && eleven) return { provider: "elevenlabs", apiKey: eleven };
18992
19272
  if (pref === "openai" && openai) return { provider: "openai", apiKey: openai };
18993
19273
  if (openai) return { provider: "openai", apiKey: openai };
@@ -19039,8 +19319,8 @@ async function dispatchAssistantRoute(req, res, ctx) {
19039
19319
  hasClaudeAuth: await hasClaudeAuth(db),
19040
19320
  hasVoiceKey: voice !== null,
19041
19321
  sttProvider: voice?.provider ?? null,
19042
- sttPreference: await secretValue(db, STT_PROVIDER_KIND) ?? "auto",
19043
- aggressiveness: await getAggressiveness(db),
19322
+ sttPreference: readPreferences().voice_provider,
19323
+ aggressiveness: getAggressiveness(),
19044
19324
  oauthEnabled: oauthConfigured()
19045
19325
  });
19046
19326
  return true;
@@ -19058,7 +19338,7 @@ async function dispatchAssistantRoute(req, res, ctx) {
19058
19338
  sendJson3(res, { error: "value must be a number in [0, 1]" }, 400);
19059
19339
  return true;
19060
19340
  }
19061
- await storeSecret(db, AGGRESSIVENESS_KIND, "Inference aggressiveness", String(value));
19341
+ writePreferences({ ...readPreferences(), aggressiveness: value });
19062
19342
  sendJson3(res, { ok: true, value });
19063
19343
  return true;
19064
19344
  }
@@ -19071,17 +19351,11 @@ async function dispatchAssistantRoute(req, res, ctx) {
19071
19351
  return true;
19072
19352
  }
19073
19353
  const provider = typeof body.provider === "string" ? body.provider : "auto";
19074
- if (!["auto", "openai", "elevenlabs"].includes(provider)) {
19354
+ if (provider !== "auto" && provider !== "openai" && provider !== "elevenlabs") {
19075
19355
  sendJson3(res, { error: `unknown provider: ${provider}` }, 400);
19076
19356
  return true;
19077
19357
  }
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
- }
19358
+ writePreferences({ ...readPreferences(), voice_provider: provider });
19085
19359
  sendJson3(res, { ok: true });
19086
19360
  return true;
19087
19361
  }
@@ -19264,6 +19538,24 @@ var REGISTRY = [
19264
19538
  category: "read",
19265
19539
  args: obj({ table: str("Table name."), id: str("Primary key of the row.") }, ["table", "id"])
19266
19540
  },
19541
+ {
19542
+ name: "search",
19543
+ 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.",
19544
+ mutates: false,
19545
+ category: "read",
19546
+ args: obj(
19547
+ {
19548
+ query: str("Text to search for."),
19549
+ tables: {
19550
+ type: "array",
19551
+ description: "Restrict to these tables (optional; defaults to all searchable tables).",
19552
+ items: { type: "string" }
19553
+ },
19554
+ limit: { type: "integer", description: "Max hits per table (optional, default 8)." }
19555
+ },
19556
+ ["query"]
19557
+ )
19558
+ },
19267
19559
  {
19268
19560
  name: "get_history",
19269
19561
  description: "Fetch recent audit-log entries, optionally filtered to one table.",
@@ -19385,6 +19677,26 @@ var REGISTRY = [
19385
19677
  ["table_a", "table_b"]
19386
19678
  )
19387
19679
  },
19680
+ {
19681
+ name: "delete_entity",
19682
+ 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.",
19683
+ mutates: true,
19684
+ category: "schema",
19685
+ args: obj(
19686
+ {
19687
+ name: str("Table to delete."),
19688
+ resolution: {
19689
+ type: "string",
19690
+ enum: ["delete_data"],
19691
+ description: 'For a NON-empty table: "delete_data" soft-deletes its rows too (reversible). Omit to be told the row count and asked first.'
19692
+ },
19693
+ move_to: str(
19694
+ "For a NON-empty table: move its rows into this existing table, then delete the emptied table."
19695
+ )
19696
+ },
19697
+ ["name"]
19698
+ )
19699
+ },
19388
19700
  {
19389
19701
  name: "rename_entity",
19390
19702
  description: "Rename an existing entity (table).",
@@ -19501,6 +19813,7 @@ var DISPATCHABLE = /* @__PURE__ */ new Set([
19501
19813
  "list_entities",
19502
19814
  "list_rows",
19503
19815
  "get_row",
19816
+ "search",
19504
19817
  "get_history",
19505
19818
  "create_row",
19506
19819
  "update_row",
@@ -19509,6 +19822,7 @@ var DISPATCHABLE = /* @__PURE__ */ new Set([
19509
19822
  "unlink",
19510
19823
  "create_entity",
19511
19824
  "create_relationship",
19825
+ "delete_entity",
19512
19826
  "undo",
19513
19827
  "redo",
19514
19828
  "revert"
@@ -19590,6 +19904,20 @@ async function executeFunction(ctx, name, args) {
19590
19904
  if (row === null) return { ok: false, error: "Row not found" };
19591
19905
  return { ok: true, result: redactRow(row, await secretColumnsFor(ctx.db, table)) };
19592
19906
  }
19907
+ case "search": {
19908
+ const query = requireString3(args.query, "query");
19909
+ let tables = [...ctx.validTables];
19910
+ if (Array.isArray(args.tables)) {
19911
+ const want = new Set(args.tables.filter((t) => typeof t === "string"));
19912
+ tables = tables.filter((t) => want.has(t));
19913
+ }
19914
+ const limit = typeof args.limit === "number" ? args.limit : 8;
19915
+ const result = await fullTextSearch(ctx.db.adapter, tables, {
19916
+ query,
19917
+ limitPerTable: limit
19918
+ });
19919
+ return { ok: true, result };
19920
+ }
19593
19921
  case "create_row": {
19594
19922
  const table = requireTable(args.table, ctx.validTables);
19595
19923
  if (!args.values || typeof args.values !== "object") {
@@ -19663,6 +19991,23 @@ async function executeFunction(ctx, name, args) {
19663
19991
  }
19664
19992
  };
19665
19993
  }
19994
+ case "delete_entity": {
19995
+ if (!ctx.deleteEntity) {
19996
+ return { ok: false, error: "Deleting tables is not available in this context" };
19997
+ }
19998
+ const target = requireString3(args.name, "name");
19999
+ let resolution;
20000
+ if (args.resolution === "delete_data") resolution = "delete_data";
20001
+ else if (typeof args.move_to === "string" && args.move_to) {
20002
+ resolution = { move_to: args.move_to };
20003
+ }
20004
+ const outcome = await ctx.deleteEntity(target, resolution);
20005
+ if ("needsResolution" in outcome) return { ok: true, result: outcome };
20006
+ if (!outcome.ok) return { ok: false, error: outcome.error };
20007
+ ctx.validTables.delete(target);
20008
+ ctx.junctionTables.delete(target);
20009
+ return { ok: true, result: outcome };
20010
+ }
19666
20011
  case "get_history": {
19667
20012
  const limit = typeof args.limit === "number" ? args.limit : 50;
19668
20013
  const rows = await ctx.db.query("_lattice_gui_audit", { limit });
@@ -19732,6 +20077,7 @@ var BASE_SYSTEM_PROMPT = [
19732
20077
  "- To relate two tables (link their rows), call create_relationship(table_a, table_b) to get a junction + its two foreign-key columns, then `link` each pair using those columns. If the junction already exists, just `link`.",
19733
20078
  "- Use the exact table names from the schema (or one you just created) \u2014 never guess a name for a table that should already exist.",
19734
20079
  "- Prefer reading (list_rows, get_row) before writing.",
20080
+ '- When you point the user at a specific row/object \u2014 especially if they ask you to "link", "open", or "show" it \u2014 make it clickable with an INLINE link in this exact form: [short label](lattice://<table>/<id>), using the real table name and the row id from your tool results (e.g. [the offer contract](lattice://contracts/9b7c60f0-fbc2-4f87-a550-c59e3c5d761f)). It renders as a pill that opens that object in the GUI. Only link ids you actually retrieved \u2014 never invent one \u2014 and prefer the user-facing record (the contract/person/etc. row) over an internal `files` id.',
19735
20081
  "- Attached files are rows in the `files` table; a file's full text content (CSV, document, etc.) is in its `extracted_text` column. To work from an attached file, read the relevant `files` row(s) and parse `extracted_text` \u2014 never guess a file's contents.",
19736
20082
  '- A tool result that contains "error" means the call FAILED. Do NOT claim success or proceed as if it returned data \u2014 read the error, correct your arguments, and retry.',
19737
20083
  "- For bulk work, emit several tool calls in one turn instead of one at a time. Every change is recorded in version history and can be undone.",
@@ -19878,9 +20224,193 @@ function createAnthropicClient(auth) {
19878
20224
  toolUses.push({ id: tu.id, name: tu.name, input: tu.input });
19879
20225
  }
19880
20226
  }
19881
- return { stopReason: final.stop_reason ?? "end_turn", text, toolUses };
19882
- }
19883
- };
20227
+ return { stopReason: final.stop_reason ?? "end_turn", text, toolUses };
20228
+ }
20229
+ };
20230
+ }
20231
+
20232
+ // src/ai/llm-client.ts
20233
+ import { createRequire as createRequire4 } from "module";
20234
+ var DEFAULT_MODEL2 = "claude-haiku-4-5";
20235
+
20236
+ // src/ai/summarize.ts
20237
+ function parseMatches(raw, catalog) {
20238
+ const fence = /```json\s*([\s\S]*?)```/i.exec(raw);
20239
+ const body = fence ? fence[1] : raw;
20240
+ let parsed;
20241
+ try {
20242
+ parsed = JSON.parse((body ?? "").trim());
20243
+ } catch {
20244
+ return [];
20245
+ }
20246
+ if (!Array.isArray(parsed)) return [];
20247
+ const valid = new Map(catalog.map((e) => [e.table, new Set(e.records.map((r) => r.id))]));
20248
+ const out = [];
20249
+ for (const item of parsed) {
20250
+ if (!item || typeof item !== "object") continue;
20251
+ const table = item.table;
20252
+ const id = item.id;
20253
+ if (typeof table === "string" && typeof id === "string" && valid.get(table)?.has(id)) {
20254
+ out.push({ table, id });
20255
+ }
20256
+ }
20257
+ return out;
20258
+ }
20259
+ var ID_RE = /^[a-z][a-z0-9_]*$/;
20260
+ var RESERVED_COLS = /* @__PURE__ */ new Set(["id", "deleted_at", "created_at", "updated_at"]);
20261
+ function parseObjects(raw) {
20262
+ const fence = /```json\s*([\s\S]*?)```/i.exec(raw);
20263
+ let parsed;
20264
+ try {
20265
+ parsed = JSON.parse((fence ? fence[1] : raw)?.trim() ?? "");
20266
+ } catch {
20267
+ return [];
20268
+ }
20269
+ if (!Array.isArray(parsed)) return [];
20270
+ const out = [];
20271
+ for (const item of parsed) {
20272
+ if (!item || typeof item !== "object") continue;
20273
+ const o = item;
20274
+ const entity = typeof o.entity === "string" ? o.entity.trim().toLowerCase() : "";
20275
+ const label = typeof o.label === "string" ? o.label.trim() : "";
20276
+ if (!ID_RE.test(entity) || !label) continue;
20277
+ let valuesRaw = {};
20278
+ if (Array.isArray(o.values) && Array.isArray(o.columns)) {
20279
+ o.columns.forEach((c, i) => {
20280
+ valuesRaw[String(c)] = o.values[i];
20281
+ });
20282
+ } else if (o.values && typeof o.values === "object") {
20283
+ valuesRaw = o.values;
20284
+ }
20285
+ const values = {};
20286
+ for (const [k, v] of Object.entries(valuesRaw)) {
20287
+ const col = k.trim().toLowerCase();
20288
+ if (ID_RE.test(col) && !RESERVED_COLS.has(col) && (typeof v === "string" || typeof v === "number")) {
20289
+ values[col] = String(v).slice(0, 2e3);
20290
+ }
20291
+ }
20292
+ if (Object.keys(values).length === 0) continue;
20293
+ const cols = Array.isArray(o.columns) ? o.columns.map((c) => String(c).trim().toLowerCase()).filter((c) => ID_RE.test(c) && !RESERVED_COLS.has(c)) : [];
20294
+ const columns = Array.from(/* @__PURE__ */ new Set([...cols, ...Object.keys(values)])).slice(0, 8);
20295
+ out.push({ entity, isNew: o.isNew === true, columns, values, label });
20296
+ if (out.length >= 3) break;
20297
+ }
20298
+ return out;
20299
+ }
20300
+
20301
+ // src/gui/ai/summarize.ts
20302
+ 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.';
20303
+ async function summarizeText(client, text, name, temperature) {
20304
+ const turn = await client.runTurn({
20305
+ model: DEFAULT_MODEL,
20306
+ system: SUMMARY_SYSTEM,
20307
+ messages: [
20308
+ {
20309
+ role: "user",
20310
+ content: `File name: ${name}
20311
+
20312
+ Content:
20313
+ ${text.slice(0, 12e3)}
20314
+
20315
+ Describe it in 1-2 sentences.`
20316
+ }
20317
+ ],
20318
+ tools: [],
20319
+ ...temperature !== void 0 ? { temperature } : {},
20320
+ onText: () => void 0
20321
+ });
20322
+ return turn.text.trim();
20323
+ }
20324
+ 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.';
20325
+ async function generateThreadTitle(client, userMessage, assistantReply) {
20326
+ const turn = await client.runTurn({
20327
+ model: DEFAULT_MODEL,
20328
+ system: TITLE_SYSTEM,
20329
+ messages: [
20330
+ {
20331
+ role: "user",
20332
+ content: `First user message:
20333
+ ${userMessage.slice(0, 2e3)}
20334
+
20335
+ Assistant reply:
20336
+ ${assistantReply.slice(0, 2e3)}
20337
+
20338
+ Title (3-5 words):`
20339
+ }
20340
+ ],
20341
+ tools: [],
20342
+ onText: () => void 0
20343
+ });
20344
+ return turn.text.trim().replace(/^["'`]+|["'`]+$/g, "").replace(/[.\s]+$/, "").slice(0, 60);
20345
+ }
20346
+ 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.';
20347
+ function buildCatalogBlock(catalog) {
20348
+ return catalog.map((e) => {
20349
+ const head = `## ${e.table}${e.description ? ` \u2014 ${e.description}` : ""}`;
20350
+ const rows = e.records.map((r) => `- id=${r.id} | ${r.label}`).join("\n");
20351
+ return `${head}
20352
+ ${rows || "- (no records)"}`;
20353
+ }).join("\n\n");
20354
+ }
20355
+ async function classifyLinks(client, text, name, catalog, temperature) {
20356
+ if (catalog.length === 0 || text.trim().length === 0) return [];
20357
+ let captured = "";
20358
+ 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." : "";
20359
+ const turn = await client.runTurn({
20360
+ model: DEFAULT_MODEL,
20361
+ system: CLASSIFY_SYSTEM,
20362
+ messages: [
20363
+ {
20364
+ role: "user",
20365
+ content: `# Catalog
20366
+ ${buildCatalogBlock(catalog)}
20367
+
20368
+ # Document: ${name}
20369
+
20370
+ ${text.slice(0, 12e3)}
20371
+
20372
+ # Task
20373
+ Return the JSON array of matching {table,id}.${liberal}`
20374
+ }
20375
+ ],
20376
+ tools: [],
20377
+ ...temperature !== void 0 ? { temperature } : {},
20378
+ onText: (d) => {
20379
+ captured += d;
20380
+ }
20381
+ });
20382
+ return parseMatches(turn.text || captured, catalog);
20383
+ }
20384
+ 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.';
20385
+ function buildSchemaBlock(existing) {
20386
+ if (existing.length === 0) return "(no entities yet \u2014 propose new ones)";
20387
+ return existing.map((e) => `## ${e.table}
20388
+ columns: ${e.columns.join(", ")}`).join("\n\n");
20389
+ }
20390
+ async function extractObjects(client, text, name, existing, temperature) {
20391
+ if (text.trim().length === 0) return [];
20392
+ const turn = await client.runTurn({
20393
+ model: DEFAULT_MODEL,
20394
+ system: EXTRACT_SYSTEM,
20395
+ messages: [
20396
+ {
20397
+ role: "user",
20398
+ content: `# Existing entities
20399
+ ${buildSchemaBlock(existing)}
20400
+
20401
+ # Document: ${name}
20402
+
20403
+ ${text.slice(0, 12e3)}
20404
+
20405
+ # Task
20406
+ Return the JSON array of objects to create.`
20407
+ }
20408
+ ],
20409
+ tools: [],
20410
+ ...temperature !== void 0 ? { temperature } : {},
20411
+ onText: () => void 0
20412
+ });
20413
+ return parseObjects(turn.text);
19884
20414
  }
19885
20415
 
19886
20416
  // src/gui/ai/sse.ts
@@ -20043,15 +20573,14 @@ var REHYDRATE_MAX_BYTES = 24e3;
20043
20573
  function rehydrateEnabled() {
20044
20574
  return process.env.LATTICE_CHAT_REHYDRATE !== "false";
20045
20575
  }
20046
- async function persistMessage(db, threadId, role, text, turns) {
20576
+ async function persistMessage(db, threadId, role, text, turns, startedAt) {
20577
+ const payload = turns && turns.length > 0 ? { text, turns } : { text };
20578
+ if (startedAt) payload.startedAt = startedAt;
20047
20579
  await db.insert("chat_messages", {
20048
20580
  id: crypto.randomUUID(),
20049
20581
  thread_id: threadId,
20050
20582
  role,
20051
- // `text` stays for backward-compat (old clients + the model-history replay);
20052
- // `turns` carries the rich structure so a reloaded conversation shows the
20053
- // same text bubbles + tool pills as the live stream, not one text wall.
20054
- content_json: JSON.stringify(turns && turns.length > 0 ? { text, turns } : { text }),
20583
+ content_json: JSON.stringify(payload),
20055
20584
  source: role === "user" ? "gui" : "ai"
20056
20585
  });
20057
20586
  }
@@ -20073,11 +20602,17 @@ async function dispatchChatRoute(req, res, ctx) {
20073
20602
  const messages = rows.filter((r) => r.thread_id === threadId2 && !r.deleted_at).map((r) => {
20074
20603
  let text = "";
20075
20604
  let turns2;
20605
+ let startedAt;
20076
20606
  try {
20077
20607
  const parsed = JSON.parse(asStr(r.content_json, "{}"));
20078
20608
  text = parsed.text ?? "";
20609
+ if (typeof parsed.startedAt === "string") startedAt = parsed.startedAt;
20079
20610
  if (Array.isArray(parsed.turns)) {
20080
- turns2 = parsed.turns.map((t) => ({ text: t.text, tools: t.tools }));
20611
+ turns2 = parsed.turns.map((t) => ({
20612
+ text: t.text,
20613
+ tools: t.tools,
20614
+ ...t.events ? { events: t.events } : {}
20615
+ }));
20081
20616
  }
20082
20617
  } catch {
20083
20618
  }
@@ -20085,6 +20620,7 @@ async function dispatchChatRoute(req, res, ctx) {
20085
20620
  role: asStr(r.role),
20086
20621
  text,
20087
20622
  ...turns2 ? { turns: turns2 } : {},
20623
+ ...startedAt ? { startedAt } : {},
20088
20624
  created_at: asStr(r.created_at)
20089
20625
  };
20090
20626
  }).sort((a, b) => a.created_at.localeCompare(b.created_at));
@@ -20138,13 +20674,27 @@ async function dispatchChatRoute(req, res, ctx) {
20138
20674
  junctionTables: new Set([...ctx.junctionTables].filter((t) => !ASSISTANT_HIDDEN_TABLES.has(t))),
20139
20675
  softDeletable: ctx.softDeletable,
20140
20676
  ...ctx.createEntity ? { createEntity: ctx.createEntity } : {},
20141
- ...ctx.createJunction ? { createJunction: ctx.createJunction } : {}
20677
+ ...ctx.createJunction ? { createJunction: ctx.createJunction } : {},
20678
+ ...ctx.deleteEntity ? { deleteEntity: ctx.deleteEntity } : {}
20142
20679
  };
20680
+ const turnStartedAt = (/* @__PURE__ */ new Date()).toISOString();
20143
20681
  let assistantText = "";
20144
20682
  const turns = [];
20683
+ const unsubscribeFeed = ctx.feed.subscribe((fe) => {
20684
+ if (fe.source !== "ai") return;
20685
+ const cur = turns[turns.length - 1];
20686
+ if (cur)
20687
+ cur.events.push({
20688
+ op: fe.op,
20689
+ table: fe.table,
20690
+ rowId: fe.rowId,
20691
+ summary: fe.summary ?? "",
20692
+ ts: fe.ts
20693
+ });
20694
+ });
20145
20695
  try {
20146
20696
  const client = createAnthropicClient(auth);
20147
- const temperature = aggressivenessToTemperature(await getAggressiveness(ctx.db));
20697
+ const temperature = aggressivenessToTemperature(getAggressiveness());
20148
20698
  for await (const ev of runChat({
20149
20699
  client,
20150
20700
  dispatch,
@@ -20157,7 +20707,7 @@ async function dispatchChatRoute(req, res, ctx) {
20157
20707
  }
20158
20708
  })) {
20159
20709
  if (ev.type === "assistant_message_start") {
20160
- turns.push({ text: "", tools: [], toolCalls: [] });
20710
+ turns.push({ text: "", tools: [], events: [], toolCalls: [] });
20161
20711
  } else if (ev.type === "text_delta") {
20162
20712
  assistantText += ev.delta;
20163
20713
  const cur = turns[turns.length - 1];
@@ -20180,19 +20730,39 @@ async function dispatchChatRoute(req, res, ctx) {
20180
20730
  res.write(formatSseFrame({ type: "done" }));
20181
20731
  } catch {
20182
20732
  }
20733
+ } finally {
20734
+ unsubscribeFeed();
20183
20735
  }
20184
20736
  res.end();
20185
20737
  if (threadId) {
20186
20738
  const cleanTurns = turns.map((t) => ({
20187
20739
  text: t.text,
20188
20740
  tools: t.tools.map((x) => ({ name: x.name, isError: x.isError })),
20741
+ ...t.events.length > 0 ? { events: t.events } : {},
20189
20742
  ...t.toolCalls.length > 0 ? { toolCalls: t.toolCalls } : {}
20190
- })).filter((t) => t.text.length > 0 || t.tools.length > 0);
20743
+ })).filter((t) => t.text.length > 0 || t.tools.length > 0 || (t.events?.length ?? 0) > 0);
20191
20744
  try {
20192
- await persistMessage(ctx.db, threadId, "assistant", assistantText, cleanTurns);
20745
+ await persistMessage(ctx.db, threadId, "assistant", assistantText, cleanTurns, turnStartedAt);
20193
20746
  } catch (e) {
20194
20747
  console.warn("[chat] persist assistant message failed:", e.message);
20195
20748
  }
20749
+ const createdNew = threadId !== requestedThread;
20750
+ if (createdNew && assistantText.trim()) {
20751
+ try {
20752
+ const placeholder = message.slice(0, 60) || "Chat";
20753
+ const cur = await ctx.db.get("chat_threads", threadId);
20754
+ if (cur && (cur.title ?? "") === placeholder) {
20755
+ const title = await generateThreadTitle(
20756
+ createAnthropicClient(auth),
20757
+ message,
20758
+ assistantText
20759
+ );
20760
+ if (title) await ctx.db.update("chat_threads", threadId, { title });
20761
+ }
20762
+ } catch (e) {
20763
+ console.warn("[chat] thread title generation failed:", e.message);
20764
+ }
20765
+ }
20196
20766
  }
20197
20767
  return true;
20198
20768
  }
@@ -20344,12 +20914,6 @@ function describe(text, mime, name) {
20344
20914
  // src/ai/vision.ts
20345
20915
  import { createRequire as createRequire5 } from "module";
20346
20916
  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
20917
  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
20918
  var MAX_DIM = 1568;
20355
20919
  async function describeImage(auth, path2, opts = {}) {
@@ -20709,164 +21273,6 @@ async function attachBlob(srcPath, latticeRoot) {
20709
21273
  };
20710
21274
  }
20711
21275
 
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
21276
  // src/gui/ingest-routes.ts
20871
21277
  var MIME_BY_EXT = {
20872
21278
  ".pdf": "application/pdf",
@@ -21107,6 +21513,37 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
21107
21513
  return [];
21108
21514
  }
21109
21515
  }
21516
+ async function enrichOrFail(mctx, db, fileId, text, name, ctx, res) {
21517
+ try {
21518
+ return await enrichWithLlm(
21519
+ mctx,
21520
+ db,
21521
+ fileId,
21522
+ text,
21523
+ name,
21524
+ ctx.fileJunctions,
21525
+ ctx.entityDescriptions,
21526
+ ctx.createJunction,
21527
+ ctx.aggressiveness,
21528
+ ctx.createEntity
21529
+ );
21530
+ } catch (e) {
21531
+ const err = e;
21532
+ console.error(
21533
+ `[ingest] enrichment failed for file ${fileId}: ${err.message}
21534
+ ${err.stack ?? ""}`
21535
+ );
21536
+ await updateRow(mctx, "files", fileId, { extraction_status: "enrichment_failed" }).catch(
21537
+ (e2) => {
21538
+ console.error(
21539
+ `[ingest] could not mark enrichment_failed on ${fileId}: ${e2.message}`
21540
+ );
21541
+ }
21542
+ );
21543
+ sendJson5(res, { id: fileId, extraction_status: "enrichment_failed", error: err.message }, 201);
21544
+ return null;
21545
+ }
21546
+ }
21110
21547
  async function extractImage(db, path2, mime) {
21111
21548
  if (!mime.startsWith("image/")) return null;
21112
21549
  const auth = await resolveClaudeAuth(db);
@@ -21214,18 +21651,12 @@ async function dispatchIngestRoute(req, res, ctx) {
21214
21651
  extraction_status: result.skip ? "skipped" : "extracted",
21215
21652
  ...blob ? { ref_kind: "blob", blob_path: blob.blob_path, sha256: blob.sha256 } : {}
21216
21653
  });
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
- );
21654
+ let suggestedLinks = [];
21655
+ if (!result.skip) {
21656
+ const links = await enrichOrFail(mctx, ctx.db, id2, result.text, name2, ctx, res);
21657
+ if (links === null) return true;
21658
+ suggestedLinks = links;
21659
+ }
21229
21660
  sendJson5(
21230
21661
  res,
21231
21662
  { id: id2, extraction_status: result.skip ? "skipped" : "extracted", suggestedLinks },
@@ -21272,18 +21703,8 @@ async function dispatchIngestRoute(req, res, ctx) {
21272
21703
  extraction_status: "extracted",
21273
21704
  ...sourceUrl ? { ref_kind: "cloud_ref", ref_uri: sourceUrl, ref_provider: "web" } : {}
21274
21705
  });
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
- );
21706
+ const suggestedLinks = await enrichOrFail(mctx, ctx.db, id2, content, title, ctx, res);
21707
+ if (suggestedLinks === null) return true;
21287
21708
  sendJson5(res, { id: id2, extraction_status: "extracted", suggestedLinks }, 201);
21288
21709
  return true;
21289
21710
  }
@@ -21340,11 +21761,16 @@ async function dispatchIngestRoute(req, res, ctx) {
21340
21761
  201
21341
21762
  );
21342
21763
  } catch (e) {
21764
+ const err = e;
21765
+ console.error(
21766
+ `[ingest] extraction/enrichment failed for file ${id}: ${err.message}
21767
+ ${err.stack ?? ""}`
21768
+ );
21343
21769
  await updateRow(mctx, "files", id, {
21344
21770
  extraction_status: "failed",
21345
- description: `Extraction failed: ${e.message}`
21771
+ description: `Extraction failed: ${err.message}`
21346
21772
  });
21347
- sendJson5(res, { id, extraction_status: "failed", error: e.message }, 201);
21773
+ sendJson5(res, { id, extraction_status: "failed", error: err.message }, 201);
21348
21774
  }
21349
21775
  return true;
21350
21776
  }
@@ -21649,6 +22075,7 @@ async function openConfig(configPath, outputDir, autoRender = false) {
21649
22075
  await db.init();
21650
22076
  await syncUserIdentityRow(db);
21651
22077
  await adoptNativeEntities(db);
22078
+ await retireLegacyPreferenceSecrets(db);
21652
22079
  const validTables = new Set(parsed.tables.map((t) => t.name));
21653
22080
  for (const name of db.getRegisteredTableNames()) {
21654
22081
  if (name.startsWith("__lattice_") || name.startsWith("_lattice_")) continue;
@@ -22134,34 +22561,6 @@ data: ${JSON.stringify(data)}
22134
22561
  } catch {
22135
22562
  }
22136
22563
  };
22137
- try {
22138
- const auditBackfill = await active.db.query("_lattice_gui_audit", {
22139
- orderBy: "ts",
22140
- orderDir: "desc",
22141
- limit: 20
22142
- });
22143
- for (const a of auditBackfill.reverse()) {
22144
- const json = a.operation === "delete" ? a.before_json : a.after_json;
22145
- let labelRow;
22146
- if (json) {
22147
- try {
22148
- labelRow = JSON.parse(json);
22149
- } catch {
22150
- labelRow = void 0;
22151
- }
22152
- }
22153
- writeFeed({
22154
- seq: 0,
22155
- table: a.table_name,
22156
- op: a.operation,
22157
- rowId: a.row_id,
22158
- source: "gui",
22159
- ts: a.ts,
22160
- summary: feedSummary(a.operation, a.table_name, labelRow)
22161
- });
22162
- }
22163
- } catch {
22164
- }
22165
22564
  const keepalive = setInterval(() => {
22166
22565
  try {
22167
22566
  res.write(`: keepalive
@@ -22236,7 +22635,7 @@ data: ${JSON.stringify(data)}
22236
22635
  }
22237
22636
  const limit = Math.min(50, Math.max(1, Number(url2.searchParams.get("limit") ?? "8")));
22238
22637
  const requested = url2.searchParams.get("tables");
22239
- let tables = [...active.validTables];
22638
+ let tables = [...active.validTables].filter((t) => !ASSISTANT_HIDDEN_TABLES.has(t));
22240
22639
  if (requested) {
22241
22640
  const want = new Set(
22242
22641
  requested.split(",").map((t) => t.trim()).filter(Boolean)
@@ -22420,20 +22819,7 @@ data: ${JSON.stringify(data)}
22420
22819
  );
22421
22820
  return;
22422
22821
  }
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
- );
22822
+ await softDeleteUserEntity(active, name, sessionId);
22437
22823
  sendJson(res, { ok: true });
22438
22824
  return;
22439
22825
  }
@@ -23097,6 +23483,11 @@ data: ${JSON.stringify(data)}
23097
23483
  ) : `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '\\_%' ESCAPE '\\' ORDER BY name`;
23098
23484
  rows = await adapter.allAsync(listSql);
23099
23485
  }
23486
+ for (const n of NATIVE_INTERNAL_NAMES) {
23487
+ if (active.validTables.has(n) && !rows.some((r) => r.name === n)) {
23488
+ rows.push({ name: n });
23489
+ }
23490
+ }
23100
23491
  const tables = [];
23101
23492
  for (const r of rows) {
23102
23493
  const cols = await active.db.introspectColumns(r.name);
@@ -23109,7 +23500,7 @@ data: ${JSON.stringify(data)}
23109
23500
  if (method === "GET" && /^\/api\/system-tables\/[^/]+\/rows$/.test(pathname)) {
23110
23501
  const parts = pathname.split("/");
23111
23502
  const sysTable = decodeURIComponent(parts[3] ?? "");
23112
- if (!/^_+[a-zA-Z0-9_]+$/.test(sysTable)) {
23503
+ if (!/^_+[a-zA-Z0-9_]+$/.test(sysTable) && !isInternalNativeEntity(sysTable)) {
23113
23504
  sendJson(res, { error: "Not a system table" }, 400);
23114
23505
  return;
23115
23506
  }
@@ -23757,6 +24148,9 @@ data: ${JSON.stringify(data)}
23757
24148
  // audited, no-reopen primitives the Context Constructor uses.
23758
24149
  createEntity: (name, columns) => createUserEntity(active, name, columns, sessionId),
23759
24150
  createJunction: (a, b) => createUserJunction(active, a, b, sessionId),
24151
+ // Guarded, reversible table delete — empty tables go immediately;
24152
+ // non-empty ones come back as `needsResolution` so the assistant asks.
24153
+ deleteEntity: (name, resolution) => aiDeleteEntity(active, name, resolution, sessionId),
23760
24154
  pathname,
23761
24155
  method
23762
24156
  });
@@ -23771,7 +24165,7 @@ data: ${JSON.stringify(data)}
23771
24165
  entityDescriptions: entityDescriptions(active.configPath, active.outputDir),
23772
24166
  createJunction: (otherTable) => createFileJunction(active, otherTable, sessionId),
23773
24167
  createEntity: (entity, columns) => createUserEntity(active, entity, columns, sessionId),
23774
- aggressiveness: await getAggressiveness(active.db),
24168
+ aggressiveness: getAggressiveness(),
23775
24169
  latticeRoot: dirname11(active.configPath),
23776
24170
  pathname,
23777
24171
  method
@@ -23809,7 +24203,12 @@ data: ${JSON.stringify(data)}
23809
24203
  }
23810
24204
  sendJson(res, { error: "Not found" }, 404);
23811
24205
  } catch (err) {
23812
- sendJson(res, { error: err.message }, 500);
24206
+ const e = err;
24207
+ console.error(
24208
+ `[gui] ${req.method ?? "?"} ${req.url ?? "?"} failed: ${e.message}
24209
+ ${e.stack ?? ""}`
24210
+ );
24211
+ sendJson(res, { error: e.message }, 500);
23813
24212
  }
23814
24213
  })();
23815
24214
  });