latticesql 2.1.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.
Files changed (3) hide show
  1. package/README.md +7 -3
  2. package/dist/cli.js +385 -314
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2119,7 +2119,11 @@ Postgres URL with no root.
2119
2119
  ### Assistant sidebar (v2.0+)
2120
2120
 
2121
2121
  The GUI has a fixed right sidebar with a live **activity feed** — every change
2122
- (yours, the assistant's, or an ingest) streams in as it happens.
2122
+ (yours, the assistant's, or an ingest) streams in as it happens, collapsed by
2123
+ type so a bulk run shows a single card ("Deleted 19 tables", "Removed 49 rows
2124
+ across 9 tables") instead of a wall of near-identical rows. The feed is scoped to
2125
+ the open conversation: the assistant's data changes are saved with each turn and
2126
+ replayed as those cards when you reopen the chat. When the assistant references a record, it emits an inline object-link pill — a clickable chip that opens that row in the mode-aware navigator.
2123
2127
 
2124
2128
  Add a Claude API token in **User Settings → Assistant** (or set
2125
2129
  `ANTHROPIC_API_KEY`) to enable the **AI assistant**: ask questions about your
@@ -2372,7 +2376,7 @@ The full architecture, schema, and HTTP surface live in [docs/teams.md](./docs/t
2372
2376
  Lattice Teams + the GUI's Database panel now flow through a state machine:
2373
2377
 
2374
2378
  ```
2375
- LOCAL → CLOUD WORKSPACE (owner | member | needs-invite)
2379
+ LOCAL → CLOUD WORKSPACE (owner | member)
2376
2380
  (migrate / connect)
2377
2381
  ```
2378
2382
 
@@ -2438,7 +2442,7 @@ HTTP surface (all under `/api/dbconfig/*`, localhost-only, same auth model as th
2438
2442
  | POST | `/api/dbconfig/connect-existing` | `TeamsClient.connectToExistingCloud` |
2439
2443
  | POST | `/api/dbconfig/save` / `connect` / `test` | unchanged from v1.12 |
2440
2444
 
2441
- The `state` field on `GET /api/dbconfig` is one of: `local`, `team-cloud-creator`, `team-cloud-member`, `team-cloud-needs-invite` (the `cloud-connected` state was removed in 1.16.3). The SPA badge color-codes them (labeled "CLOUD · OWNER / MEMBER / NEEDS INVITE"); the routes use them only for response shape.
2445
+ The `state` field on `GET /api/dbconfig` is one of: `local`, `team-cloud-creator`, `team-cloud-member` (the `cloud-connected` state was removed in 1.16.3; the `team-cloud-needs-invite` state was removed in 2.1.1 — a connected cloud is always a member workspace). The SPA badge color-codes them (labeled "CLOUD · OWNER / MEMBER"); the routes use them only for response shape.
2442
2446
 
2443
2447
  ---
2444
2448
 
package/dist/cli.js CHANGED
@@ -417,12 +417,6 @@ function assertSafeLabel(label) {
417
417
  throw new Error(`Invalid label "${label}": must match [A-Za-z0-9._-]+ and not start with .`);
418
418
  }
419
419
  }
420
- function readToken(label) {
421
- assertSafeLabel(label);
422
- const path2 = join2(ensureKeysDir(), label + TOKEN_EXT);
423
- if (!existsSync2(path2)) return null;
424
- return readFileSync(path2, "utf8").trim();
425
- }
426
420
  function writeToken(label, token) {
427
421
  assertSafeLabel(label);
428
422
  const path2 = join2(ensureKeysDir(), label + TOKEN_EXT);
@@ -7086,6 +7080,8 @@ var css = `
7086
7080
  }
7087
7081
  a.chip-link { cursor: pointer; }
7088
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; }
7089
7085
  .empty-row td {
7090
7086
  color: var(--text-muted); font-style: italic; text-align: center;
7091
7087
  padding: 24px;
@@ -7812,20 +7808,9 @@ var css = `
7812
7808
  padding: 8px; margin: 0 0 8px; overflow-x: auto;
7813
7809
  }
7814
7810
  .chat-bubble.assistant pre code { background: none; border: none; padding: 0; white-space: pre; }
7815
- .chat-tools { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
7816
- .tool-pill {
7817
- display: inline-flex; align-items: center; gap: 5px;
7818
- border-radius: 999px; padding: 2px 9px; font-size: 11px; font-weight: 500;
7819
- background: var(--accent-soft); color: var(--accent);
7820
- box-shadow: var(--glow-accent-soft);
7821
- }
7822
- .tool-pill.done { background: var(--surface-2); color: var(--text-muted); box-shadow: none; }
7823
- .tool-pill.error { background: rgba(251,146,60,0.14); color: var(--warn); box-shadow: none; }
7824
- .tool-pill .spin { display: inline-block; width: 9px; height: 9px;
7825
- border: 1.5px solid currentColor; border-top-color: transparent; border-radius: 50%;
7826
- animation: pillspin 0.7s linear infinite; }
7827
- @keyframes pillspin { to { transform: rotate(360deg); } }
7828
- /* 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
7829
7814
  the model is generating (before the first text delta of a turn). */
7830
7815
  .chat-typing { display: inline-flex; align-items: center; gap: 4px; padding: 1px 0; }
7831
7816
  .chat-typing i {
@@ -7963,6 +7948,13 @@ var appJs = `
7963
7948
  return !!(t && t[colName] && t[colName].secret);
7964
7949
  }
7965
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
+ }
7966
7958
 
7967
7959
  function displayFor(name) {
7968
7960
  var override = state.iconOverrides[name];
@@ -8794,9 +8786,16 @@ var appJs = `
8794
8786
  headers: { 'content-type': 'application/json' },
8795
8787
  body: JSON.stringify({ id: id }),
8796
8788
  }).then(function () {
8797
- 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.
8798
8796
  return reloadEverything();
8799
8797
  }).then(function () {
8798
+ menu.hidden = true;
8800
8799
  // Conversations + activity both live in the workspace DB. Drop
8801
8800
  // the old workspace's thread + activity cards, reconnect the feed
8802
8801
  // to THIS workspace, and reload its thread list (+ latest convo).
@@ -8805,7 +8804,7 @@ var appJs = `
8805
8804
  startFeed();
8806
8805
  refreshThreadList(true);
8807
8806
  showToast('Switched workspace', {});
8808
- }).catch(function (err) { showToast('Switch failed: ' + err.message, {}); });
8807
+ }).catch(function (err) { menu.hidden = true; showToast('Switch failed: ' + err.message, {}); });
8809
8808
  });
8810
8809
  });
8811
8810
  });
@@ -9210,7 +9209,7 @@ var appJs = `
9210
9209
  } else {
9211
9210
  bodyRows = rows.map(function (r) {
9212
9211
  var tds = intrinsic.map(function (c) {
9213
- if (isSecretColumn(tableName, c) && r[c] != null && r[c] !== '') {
9212
+ if ((isSecretColumn(tableName, c) || looksEncrypted(r[c])) && r[c] != null && r[c] !== '') {
9214
9213
  return '<td class="muted">' + SECRET_MASK + '</td>';
9215
9214
  }
9216
9215
  return '<td><div class="cell-clip">' + escapeHtml(truncate(r[c], 120)) + '</div></td>';
@@ -9495,7 +9494,7 @@ var appJs = `
9495
9494
  function paint(editing) {
9496
9495
  var rows = [];
9497
9496
  intrinsic.forEach(function (c) {
9498
- var secret = isSecretColumn(tableName, c);
9497
+ var secret = isSecretColumn(tableName, c) || looksEncrypted(row[c]);
9499
9498
  var dd;
9500
9499
  if (editing) {
9501
9500
  dd = fieldFor(c, row[c], t);
@@ -9863,7 +9862,7 @@ var appJs = `
9863
9862
  function fsValInner(table, row, col) {
9864
9863
  var raw = row[col];
9865
9864
  if (raw == null || raw === '') return '<span class="fs-empty-val">\u2014</span>';
9866
- 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>';
9867
9866
  var s = String(raw);
9868
9867
  if (FS_LONGFORM.indexOf(col) >= 0 || s.indexOf('\\n') >= 0) {
9869
9868
  return '<div class="md-body">' + mdToHtml(s.slice(0, 40000)) + '</div>';
@@ -11865,20 +11864,29 @@ var appJs = `
11865
11864
  '</div>' +
11866
11865
  '</div>';
11867
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
+ }
11868
11878
  host.innerHTML =
11869
11879
  '<div class="dbconfig-panel" style="margin-bottom:18px;padding:14px;border:1px solid var(--border);border-radius:8px;background:var(--surface)">' +
11870
11880
  '<h3 style="margin:0 0 10px">Assistant</h3>' +
11871
11881
  '<p class="lead" style="margin:0 0 12px;font-size:12px;color:var(--text-muted)">' +
11872
- 'Keys are stored encrypted in the <code>secrets</code> table \u2014 never shown again once ' +
11873
- 'saved. Environment variables (<code>ANTHROPIC_API_KEY</code>, <code>OPENAI_API_KEY</code>, ' +
11874
- '<code>ELEVENLABS_API_KEY</code>) also work.' +
11882
+ 'Keys are stored encrypted in the <code>secrets</code> table.' +
11875
11883
  '</p>' +
11876
11884
  rowHtml('asst-anthropic', 'Claude API token (chat)', !!cfg.hasAnthropicKey, 'sk-ant-\u2026') +
11877
- '<div style="margin:0 0 12px;font-size:12px;color:var(--text-muted)">' +
11878
- (cfg.oauthEnabled
11879
- ? 'Or <a href="/api/assistant/oauth/start" style="color:var(--accent)">connect your Claude subscription</a>.'
11880
- : 'Subscription login: set the <code>ANTHROPIC_OAUTH_*</code> env vars to enable.') +
11881
- '</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
+ : '') +
11882
11890
  '<div style="margin:6px 0 12px">' +
11883
11891
  '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">' +
11884
11892
  '<strong style="font-size:13px">Inference aggressiveness</strong>' +
@@ -11895,17 +11903,16 @@ var appJs = `
11895
11903
  'auto-creates link tables) when you drop in files. Higher extrapolates more.' +
11896
11904
  '</p>' +
11897
11905
  '</div>' +
11898
- '<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>' +
11899
- rowHtml('asst-openai', 'OpenAI Whisper key', !!cfg.hasOpenaiKey, 'sk-\u2026') +
11900
- rowHtml('asst-elevenlabs', 'ElevenLabs key', !!cfg.hasElevenlabsKey, 'xi-\u2026') +
11901
- '<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">' +
11902
11908
  '<span style="font-size:12px;color:var(--text-muted)">Use for voice:</span>' +
11903
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">' +
11904
- '<option value="auto">Auto</option>' +
11905
- '<option value="openai">OpenAI Whisper</option>' +
11910
+ '<option value="auto">Select provider\u2026</option>' +
11911
+ '<option value="openai">OpenAI</option>' +
11906
11912
  '<option value="elevenlabs">ElevenLabs</option>' +
11907
11913
  '</select>' +
11908
11914
  '</div>' +
11915
+ '<div id="asst-voice-key">' + voiceRowHtml(cfg.sttPreference || 'auto') + '</div>' +
11909
11916
  '<div id="assistant-msg" style="margin-top:4px;font-size:12px;color:var(--text-muted)"></div>' +
11910
11917
  '</div>';
11911
11918
  var msg = host.querySelector('#assistant-msg');
@@ -11935,12 +11942,18 @@ var appJs = `
11935
11942
  });
11936
11943
  }
11937
11944
  wire('asst-anthropic', 'anthropic');
11938
- wire('asst-openai', 'openai');
11939
- wire('asst-elevenlabs', 'elevenlabs');
11940
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
+ }
11941
11951
  if (sttSel) {
11942
11952
  sttSel.value = cfg.sttPreference || 'auto';
11953
+ wireVoiceKey(sttSel.value);
11943
11954
  sttSel.addEventListener('change', function () {
11955
+ if (voiceKeyHost) voiceKeyHost.innerHTML = voiceRowHtml(sttSel.value);
11956
+ wireVoiceKey(sttSel.value);
11944
11957
  msg.textContent = 'Saving\u2026';
11945
11958
  fetch('/api/assistant/stt-provider', {
11946
11959
  method: 'PUT',
@@ -12331,9 +12344,10 @@ var appJs = `
12331
12344
 
12332
12345
  // State-machine Database panel (v1.13+). Renders a different body
12333
12346
  // per info.state: local -> Migrate / Connect-existing wizards;
12334
- // cloud-connected -> Upgrade-to-team; team-cloud-creator/member ->
12335
- // team management UI; team-cloud-needs-invite -> join form.
12336
- // 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.
12337
12351
  function renderDatabasePanel(host) {
12338
12352
  fetchJson('/api/dbconfig').then(function (info) {
12339
12353
  var badge = renderStateBadge(info);
@@ -12369,10 +12383,6 @@ var appJs = `
12369
12383
  label = 'CLOUD \xB7 MEMBER';
12370
12384
  color = 'var(--accent)';
12371
12385
  break;
12372
- case 'team-cloud-needs-invite':
12373
- label = 'CLOUD \xB7 NEEDS INVITE';
12374
- color = 'var(--warn)';
12375
- break;
12376
12386
  default:
12377
12387
  label = String(info.state || 'UNKNOWN').toUpperCase();
12378
12388
  }
@@ -12408,21 +12418,6 @@ var appJs = `
12408
12418
  '<div id="db-members-host" style="margin-top:12px"><div style="font-size:12px;color:var(--text-muted)">Loading members\u2026</div></div>'
12409
12419
  );
12410
12420
  }
12411
- if (info.state === 'team-cloud-needs-invite') {
12412
- return (
12413
- renderConnectionSummary(info) +
12414
- '<p style="margin-top:10px;color:var(--warn);font-size:13px">' +
12415
- 'Not a member of this cloud workspace yet \u2014 paste your invite token to join.' +
12416
- '</p>' +
12417
- '<div style="display:grid;grid-template-columns:1fr;gap:8px;margin-top:6px">' +
12418
- '<div><label class="field-label">Invite token</label>' +
12419
- '<textarea id="db-rejoin-token" placeholder="latinv_..." style="width:100%;height:54px;font-family:JetBrains Mono,monospace"></textarea></div>' +
12420
- '</div>' +
12421
- '<div class="team-actions" style="margin-top:10px">' +
12422
- '<button class="btn primary" data-act="rejoin-with-token">Join workspace \u2192</button>' +
12423
- '</div>'
12424
- );
12425
- }
12426
12421
  return '<p style="color:var(--text-muted)">Unknown database state.</p>';
12427
12422
  }
12428
12423
 
@@ -12494,32 +12489,6 @@ var appJs = `
12494
12489
  }).catch(function () { membersHost.innerHTML = '<div style="font-size:12px;color:var(--text-muted)">Members unavailable.</div>'; });
12495
12490
  }
12496
12491
 
12497
- var rejoinBtn = host.querySelector('[data-act="rejoin-with-token"]');
12498
- if (rejoinBtn) rejoinBtn.addEventListener('click', function () {
12499
- var token = (document.getElementById('db-rejoin-token').value || '').trim();
12500
- if (!token) { setMsg('Invite token required.', false); return; }
12501
- // Without form re-entry the credentials are already saved; we
12502
- // call the connect-existing endpoint with just the invite
12503
- // token. The handler reads credentials from db-credentials.enc
12504
- // via the active configPath's label.
12505
- setMsg('Joining workspace\u2026');
12506
- fetch('/api/dbconfig/connect-existing', {
12507
- method: 'POST', headers: { 'content-type': 'application/json' },
12508
- body: JSON.stringify({
12509
- type: 'postgres',
12510
- label: info.label,
12511
- host: info.host, port: info.port, dbname: info.dbname,
12512
- user: info.user, password: '', // password lives in db-credentials.enc; backend will pull
12513
- invite_token: token,
12514
- }),
12515
- })
12516
- .then(function (r) { return r.json(); })
12517
- .then(function (d) {
12518
- if (d.error) { setMsg('Failed: ' + d.error, false); return; }
12519
- setMsg('Joined.', true); rerender();
12520
- })
12521
- .catch(function (e) { setMsg('Failed: ' + e.message, false); });
12522
- });
12523
12492
  }
12524
12493
 
12525
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
@@ -12895,64 +12864,103 @@ var appJs = `
12895
12864
  insert: '\u2795', update: '\u270F\uFE0F', delete: '\u{1F5D1}',
12896
12865
  link: '\u{1F517}', unlink: '\u26D3', undo: '\u21B6', redo: '\u21B7', schema: '\u{1F6E0}',
12897
12866
  };
12898
- // Ops whose consecutive runs collapse into one counted bubble (bulk row work
12899
- // 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.
12900
12876
  var GROUPABLE_OPS = { insert: 1, update: 1, delete: 1, link: 1, unlink: 1 };
12901
- // Active group bubbles keyed by op|table|source so a burst of identical
12902
- // events coalesces into ONE counted bubble even when other events interleave
12903
- // (a bulk ingest emits create/link/update across several tables at once \u2014
12904
- // consecutive-only grouping let those interleaved runs spam the feed). A
12905
- // group stays "open" for FEED_GROUP_WINDOW_MS after its last hit; later
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
12906
12910
  // activity starts a fresh bubble so unrelated edits aren't merged in.
12907
- var feedGroups = {}; // key -> { count, item, summaryEl, timeEl, last }
12908
- var FEED_GROUP_WINDOW_MS = 15000;
12909
- function groupedSummary(op, table, count) {
12910
- var t = String(table || '');
12911
- switch (op) {
12912
- case 'insert': return 'Added ' + count + ' rows to ' + t;
12913
- case 'update': return 'Updated ' + count + ' rows in ' + t;
12914
- case 'delete': return 'Removed ' + count + ' rows from ' + t;
12915
- case 'link': return 'Linked ' + count + ' rows in ' + t;
12916
- case 'unlink': return 'Unlinked ' + count + ' rows in ' + t;
12917
- default: return String(op || '') + ' ' + t;
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;
12918
12916
  }
12917
+ return GROUPABLE_OPS[ev.op] ? String(ev.op) + '|' + src : null;
12919
12918
  }
12920
- function renderFeedItem(ev) {
12921
- var feedEl = document.getElementById('rail-feed');
12922
- if (!feedEl) return;
12923
- var empty = document.getElementById('rail-empty');
12924
- if (empty) empty.remove();
12925
- // Coalesce identical events (same op + table + source) into one counted
12926
- // bubble. Unlike the old consecutive-only rule, the bubble is found by key
12927
- // within a recency window, so interleaved bursts still merge instead of
12928
- // spamming a pill per event.
12929
- var groupKey = GROUPABLE_OPS[ev.op] && ev.table
12930
- ? String(ev.op) + '|' + String(ev.table) + '|' + String(ev.source || '')
12931
- : null;
12932
- var nowMs = Date.now();
12933
- if (groupKey) {
12934
- var g = feedGroups[groupKey];
12935
- if (g && g.item.parentNode === feedEl && (nowMs - g.last) < FEED_GROUP_WINDOW_MS) {
12936
- g.count += 1;
12937
- g.last = nowMs;
12938
- g.summaryEl.textContent = groupedSummary(ev.op, ev.table, g.count);
12939
- g.timeEl.textContent = relTime(ev.ts);
12940
- // A grouped bubble stands for many rows \u2014 disable the single-row click,
12941
- // and expose it to assistive tech as a live status, not a button.
12942
- g.item._rowClickOff = true;
12943
- g.item.classList.remove('feed-clickable');
12944
- g.item.removeAttribute('tabindex');
12945
- g.item.removeAttribute('title');
12946
- g.item.setAttribute('role', 'status');
12947
- feedEl.scrollTop = feedEl.scrollHeight;
12948
- return;
12949
- }
12950
- }
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) {
12951
12959
  var item = document.createElement('div');
12952
12960
  item.className = 'feed-item';
12953
12961
  var icon = document.createElement('div');
12954
12962
  icon.className = 'feed-icon';
12955
- icon.textContent = FEED_ICONS[ev.op] || '\u2022';
12963
+ icon.textContent = feedIcon(ev.op);
12956
12964
  var body = document.createElement('div');
12957
12965
  body.className = 'feed-body';
12958
12966
  var summary = document.createElement('div');
@@ -12968,11 +12976,13 @@ var appJs = `
12968
12976
  body.appendChild(meta);
12969
12977
  var time = document.createElement('div');
12970
12978
  time.className = 'feed-time';
12971
- 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 = '';
12972
12982
  item.appendChild(icon);
12973
12983
  item.appendChild(body);
12974
12984
  item.appendChild(time);
12975
- // 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
12976
12986
  // shortcut to that object. Link/unlink and schema events have no single
12977
12987
  // row (rowId is null), so they stay non-clickable.
12978
12988
  if (ev.rowId && ev.table) {
@@ -12980,18 +12990,117 @@ var appJs = `
12980
12990
  item.setAttribute('role', 'button');
12981
12991
  item.setAttribute('tabindex', '0');
12982
12992
  item.title = 'Open this ' + String(ev.table);
12983
- // _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.
12984
12994
  var openRow = function () { if (item._rowClickOff) return; openSearchHit(String(ev.table), String(ev.rowId)); };
12985
12995
  item.addEventListener('click', openRow);
12986
12996
  item.addEventListener('keydown', function (e) {
12987
12997
  if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openRow(); }
12988
12998
  });
12989
12999
  }
12990
- 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);
12991
13071
  feedEl.scrollTop = feedEl.scrollHeight;
12992
- // Register/refresh the group anchored on this bubble (groupable ops only).
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;
12993
13075
  if (groupKey) {
12994
- feedGroups[groupKey] = { count: 1, item: item, summaryEl: summary, timeEl: time, last: nowMs };
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)); }
12995
13104
  }
12996
13105
  }
12997
13106
  function startFeed() {
@@ -13076,12 +13185,13 @@ var appJs = `
13076
13185
  chatHistory = [];
13077
13186
  var feedEl = railFeedEl();
13078
13187
  if (!feedEl) return;
13079
- // Remove only the chat bubbles. The activity cards (.feed-item) are
13080
- // workspace-global, not part of any one conversation \u2014 loading, switching,
13081
- // or starting a conversation must NOT wipe them (they're backfilled once
13082
- // on connect). Otherwise auto-loading a thread on refresh erases the feed.
13083
- var msgs = feedEl.querySelectorAll('.chat-msg');
13084
- 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 = {};
13085
13195
  // Restore the empty hint only when the rail is now completely empty.
13086
13196
  if (!feedEl.firstElementChild) {
13087
13197
  feedEl.innerHTML = '<div class="rail-empty" id="rail-empty">No activity yet. Changes you make will appear here.</div>';
@@ -13134,11 +13244,12 @@ var appJs = `
13134
13244
  msgs.forEach(function (m) {
13135
13245
  if (m.role === 'user') { appendUserBubble(m.text); chatHistory.push({ role: 'user', text: m.text }); }
13136
13246
  else if (m.role === 'assistant') {
13137
- // Rich replay: the saved per-turn structure (text + tool pills),
13138
- // matching the live stream. Falls back to a plain text bubble for
13139
- // messages saved before turns were persisted.
13140
- if (Array.isArray(m.turns) && m.turns.length > 0) { m.turns.forEach(appendAssistantTurn); }
13141
- 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); }
13142
13253
  chatHistory.push({ role: 'assistant', text: m.text });
13143
13254
  }
13144
13255
  });
@@ -13162,126 +13273,88 @@ var appJs = `
13162
13273
  railEmptyGone();
13163
13274
  var feedEl = railFeedEl();
13164
13275
  var msg = document.createElement('div'); msg.className = 'chat-msg assistant';
13165
- var wrap = document.createElement('div');
13166
- var tools = document.createElement('div'); tools.className = 'chat-tools';
13167
13276
  var b = document.createElement('div'); b.className = 'chat-bubble assistant';
13168
13277
  // Show an animated typing indicator until the first text delta arrives.
13169
13278
  b.innerHTML = '<span class="chat-typing"><i></i><i></i><i></i></span>';
13170
13279
  b.setAttribute('data-typing', '1');
13171
- wrap.appendChild(tools); wrap.appendChild(b);
13172
- msg.appendChild(wrap); feedEl.appendChild(msg); feedEl.scrollTop = feedEl.scrollHeight;
13173
- // lastTool anchors the current run of identical tool calls so consecutive
13174
- // same-name calls coalesce into one counted pill (see addToolPill).
13175
- 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 };
13176
13282
  }
13177
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
+ }
13178
13326
  function setBubbleText(ctx, text) {
13179
13327
  if (!ctx || !ctx.bubble) return; // bubble may have been finalized/removed
13180
13328
  ctx.bubble.removeAttribute('data-typing');
13181
13329
  // Assistant turns are Markdown; render (input is HTML-escaped inside
13182
- // mdToHtml first, so this is injection-safe).
13183
- ctx.bubble.innerHTML = mdToHtml(text);
13330
+ // mdToHtml first, so this is injection-safe) + linkify object references.
13331
+ ctx.bubble.innerHTML = renderAssistantHtml(text);
13332
+ ensureLatticeRefHandler();
13184
13333
  }
13185
13334
  /**
13186
- * A turn ended. If its bubble never got text (still showing the typing
13187
- * indicator), drop the empty bubble \u2014 keeping any tool pills it fired, or
13188
- * removing the whole message when there were none. Stops a dangling
13189
- * "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.
13190
13338
  */
13191
13339
  function finalizeBubble(ctx) {
13192
13340
  if (!ctx || !ctx.bubble || !ctx.bubble.getAttribute('data-typing')) return;
13193
- if (ctx.tools && ctx.tools.children.length > 0) ctx.bubble.remove();
13194
- else if (ctx.msg) ctx.msg.remove();
13195
- }
13196
- var TOOL_VERBS = {
13197
- create_row: ['Creating row', 'Row created', 'Could not create row'],
13198
- update_row: ['Updating row', 'Row updated', 'Could not update row'],
13199
- delete_row: ['Deleting row', 'Row deleted', 'Could not delete row'],
13200
- list_rows: ['Listing rows', 'Listed rows', 'Could not list rows'],
13201
- get_row: ['Fetching row', 'Fetched row', 'Could not fetch row'],
13202
- list_entities: ['Listing tables', 'Listed tables', 'Could not list tables']
13203
- };
13204
- // Grouped (count > 1) [gerund, past, noun] so a run of identical tool calls
13205
- // collapses into ONE counted pill \u2014 "Listed 5 rows" \u2014 instead of N identical
13206
- // "Listed rows" pills. Mirrors the activity feed's groupedSummary() coalescing.
13207
- var TOOL_GROUP = {
13208
- create_row: ['Creating', 'Created', 'rows'],
13209
- update_row: ['Updating', 'Updated', 'rows'],
13210
- delete_row: ['Deleting', 'Deleted', 'rows'],
13211
- list_rows: ['Listing', 'Listed', 'rows'],
13212
- get_row: ['Fetching', 'Fetched', 'rows'],
13213
- list_entities: ['Listing', 'Listed', 'tables']
13214
- };
13215
- function toolLabel(name, state) {
13216
- var v = TOOL_VERBS[name] || [name, name, name];
13217
- return state === 'pending' ? v[0] + '\u2026' : (state === 'error' ? v[2] : v[1]);
13218
- }
13219
- // Label for a run of "count" identical calls. count <= 1 falls back to the
13220
- // single-call label so a lone pill reads exactly as before.
13221
- function toolGroupLabel(name, count, state) {
13222
- if (count <= 1) return toolLabel(name, state);
13223
- var g = TOOL_GROUP[name];
13224
- if (!g) return toolLabel(name, state) + ' \xD7' + count; // unknown tool: stay honest
13225
- var verb = state === 'pending' ? g[0] : g[1];
13226
- return verb + ' ' + count + ' ' + g[2] + (state === 'pending' ? '\u2026' : '');
13227
- }
13228
- // Paint a (possibly grouped) pill from its live counts: spinner while any call
13229
- // is still running, then \u2713 (or \u26A0 if any errored) once every call resolves.
13230
- function paintToolPill(g) {
13231
- var pending = g.pending > 0;
13232
- var err = !pending && g.error > 0;
13233
- if (pending) {
13234
- g.el.className = 'tool-pill';
13235
- g.el.innerHTML = '<span class="spin"></span>' + escapeHtml(toolGroupLabel(g.name, g.total, 'pending'));
13236
- } else {
13237
- g.el.className = 'tool-pill ' + (err ? 'error' : 'done');
13238
- g.el.textContent = (err ? '\u26A0 ' : '\u2713 ') + toolGroupLabel(g.name, g.total, err ? 'error' : 'done');
13239
- }
13240
- }
13241
- function addToolPill(ctx, id, name) {
13242
- // Coalesce a run of the same tool within this turn's pill row into one
13243
- // counted pill (the model emits several list_rows in a single turn).
13244
- var g = ctx.lastTool;
13245
- if (g && g.name === name) {
13246
- g.total += 1; g.pending += 1;
13247
- } else {
13248
- var pill = document.createElement('span'); pill.className = 'tool-pill';
13249
- ctx.tools.appendChild(pill);
13250
- g = { name: name, el: pill, total: 1, pending: 1, error: 0 };
13251
- ctx.lastTool = g;
13252
- }
13253
- ctx.pills[id] = g; // resolveToolPill maps the tool-use id back to its group
13254
- paintToolPill(g);
13255
- }
13256
- function resolveToolPill(ctx, id, isError) {
13257
- var g = ctx.pills[id]; if (!g) return;
13258
- if (g.pending > 0) g.pending -= 1;
13259
- if (isError) g.error += 1;
13260
- paintToolPill(g);
13341
+ if (ctx.msg) ctx.msg.remove();
13261
13342
  }
13262
- /**
13263
- * Append already-resolved pills for a replayed turn, collapsing consecutive
13264
- * identical tools into one counted pill (matching the live grouping above).
13265
- */
13266
- function renderResolvedPills(ctx, tools) {
13267
- var i = 0;
13268
- while (i < tools.length) {
13269
- var name = tools[i].name, j = i, errors = 0;
13270
- while (j < tools.length && tools[j].name === name) { if (tools[j].isError) errors += 1; j++; }
13271
- var count = j - i, err = errors > 0;
13272
- var pill = document.createElement('span');
13273
- pill.className = 'tool-pill ' + (err ? 'error' : 'done');
13274
- pill.textContent = (err ? '\u26A0 ' : '\u2713 ') + toolGroupLabel(name, count, err ? 'error' : 'done');
13275
- ctx.tools.appendChild(pill);
13276
- i = j;
13277
- }
13278
- }
13279
- /** Replay one persisted assistant turn: its tool pills + text bubble. */
13280
- 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) {
13281
13348
  var ctx = newAssistantBubble();
13282
- renderResolvedPills(ctx, turn.tools || []);
13283
13349
  if (turn.text) setBubbleText(ctx, turn.text);
13284
- 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);
13285
13358
  }
13286
13359
  function parseSse(buffer, onEvent) {
13287
13360
  var sep;
@@ -13297,6 +13370,11 @@ var appJs = `
13297
13370
  function sendChat(text) {
13298
13371
  if (chatBusy || !text) return;
13299
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;
13300
13378
  appendUserBubble(text);
13301
13379
  var historyToSend = chatHistory.slice();
13302
13380
  chatHistory.push({ role: 'user', text: text });
@@ -13323,8 +13401,10 @@ var appJs = `
13323
13401
  buf = parseSse(buf, function (ev) {
13324
13402
  if (ev.type === 'assistant_message_start') { finalizeBubble(actx); actx = newAssistantBubble(); assembled = ''; }
13325
13403
  else if (ev.type === 'text_delta' && actx) { assembled += ev.delta; setBubbleText(actx, assembled); railFeedEl().scrollTop = railFeedEl().scrollHeight; }
13326
- else if (ev.type === 'tool_use' && actx) { addToolPill(actx, ev.id, ev.name); }
13327
- 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).
13328
13408
  else if (ev.type === 'warn') { finalizeBubble(actx); var wb = newAssistantBubble(); setBubbleText(wb, '\u26A0 ' + ev.message); actx = null; }
13329
13409
  else if (ev.type === 'error') { if (!actx) actx = newAssistantBubble(); setBubbleText(actx, (assembled ? assembled + '\\n' : '') + '\u26A0 ' + ev.message); }
13330
13410
  });
@@ -13341,6 +13421,9 @@ var appJs = `
13341
13421
  var c = newAssistantBubble(); setBubbleText(c, '\u26A0 ' + e.message);
13342
13422
  }).finally(function () {
13343
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;
13344
13427
  var sb = document.getElementById('chat-send'); if (sb) sb.disabled = false;
13345
13428
  var inp = document.getElementById('chat-input'); if (inp) inp.focus();
13346
13429
  });
@@ -13476,7 +13559,10 @@ var appJs = `
13476
13559
  '<div class="feed-icon"><span class="feed-spinner"></span></div>' +
13477
13560
  '<div class="feed-body"><div class="feed-summary">Analyzing ' + escapeHtml(label) + '\u2026</div></div>' +
13478
13561
  '<div class="feed-time">0s</div>';
13479
- feedEl.appendChild(item);
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);
13480
13566
  feedEl.scrollTop = feedEl.scrollHeight;
13481
13567
  // Live elapsed-time counter while the upload + server-side extraction run.
13482
13568
  // Previously the time element was left empty (rendered as a stuck "0s")
@@ -18449,19 +18535,8 @@ async function getCreatorEmail(db) {
18449
18535
  return null;
18450
18536
  }
18451
18537
  }
18452
- function computeState(type, teamEnabled, label, creatorEmail) {
18538
+ function computeState(type, creatorEmail) {
18453
18539
  if (type === "sqlite") return "local";
18454
- if (!teamEnabled) return "team-cloud-needs-invite";
18455
- if (!label) {
18456
- return "team-cloud-needs-invite";
18457
- }
18458
- let token = null;
18459
- try {
18460
- token = readToken(label);
18461
- } catch {
18462
- token = null;
18463
- }
18464
- if (!token) return "team-cloud-needs-invite";
18465
18540
  const identity = readIdentity();
18466
18541
  if (creatorEmail !== null && identity.email.length > 0 && creatorEmail.toLowerCase() === identity.email.toLowerCase()) {
18467
18542
  return "team-cloud-creator";
@@ -18469,8 +18544,9 @@ function computeState(type, teamEnabled, label, creatorEmail) {
18469
18544
  return "team-cloud-member";
18470
18545
  }
18471
18546
  function applyTeamMembershipState(info, membership) {
18472
- if (!membership || info.type !== "postgres" || !info.teamEnabled) return info.state;
18473
- 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;
18474
18550
  }
18475
18551
  async function describeCurrent(configPath, db) {
18476
18552
  const rawYaml = readFileSync14(configPath, "utf8");
@@ -18492,7 +18568,7 @@ async function describeCurrent(configPath, db) {
18492
18568
  if (labelMatch) {
18493
18569
  const label = labelMatch[1] ?? "";
18494
18570
  const url = getDbCredential(label);
18495
- const state = computeState("postgres", teamEnabled, label, creatorEmail);
18571
+ const state = computeState("postgres", creatorEmail);
18496
18572
  if (url) {
18497
18573
  const parsed = parsePostgresUrl(url);
18498
18574
  if (parsed) {
@@ -18519,7 +18595,7 @@ async function describeCurrent(configPath, db) {
18519
18595
  }
18520
18596
  if (/^postgres(ql)?:\/\//i.test(dbLine)) {
18521
18597
  const parsed = parsePostgresUrl(dbLine);
18522
- const state = computeState("postgres", teamEnabled, void 0, creatorEmail);
18598
+ const state = computeState("postgres", creatorEmail);
18523
18599
  return parsed ? {
18524
18600
  type: "postgres",
18525
18601
  state,
@@ -20001,6 +20077,7 @@ var BASE_SYSTEM_PROMPT = [
20001
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`.",
20002
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.",
20003
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.',
20004
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.",
20005
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.',
20006
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.",
@@ -20496,15 +20573,14 @@ var REHYDRATE_MAX_BYTES = 24e3;
20496
20573
  function rehydrateEnabled() {
20497
20574
  return process.env.LATTICE_CHAT_REHYDRATE !== "false";
20498
20575
  }
20499
- 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;
20500
20579
  await db.insert("chat_messages", {
20501
20580
  id: crypto.randomUUID(),
20502
20581
  thread_id: threadId,
20503
20582
  role,
20504
- // `text` stays for backward-compat (old clients + the model-history replay);
20505
- // `turns` carries the rich structure so a reloaded conversation shows the
20506
- // same text bubbles + tool pills as the live stream, not one text wall.
20507
- content_json: JSON.stringify(turns && turns.length > 0 ? { text, turns } : { text }),
20583
+ content_json: JSON.stringify(payload),
20508
20584
  source: role === "user" ? "gui" : "ai"
20509
20585
  });
20510
20586
  }
@@ -20526,11 +20602,17 @@ async function dispatchChatRoute(req, res, ctx) {
20526
20602
  const messages = rows.filter((r) => r.thread_id === threadId2 && !r.deleted_at).map((r) => {
20527
20603
  let text = "";
20528
20604
  let turns2;
20605
+ let startedAt;
20529
20606
  try {
20530
20607
  const parsed = JSON.parse(asStr(r.content_json, "{}"));
20531
20608
  text = parsed.text ?? "";
20609
+ if (typeof parsed.startedAt === "string") startedAt = parsed.startedAt;
20532
20610
  if (Array.isArray(parsed.turns)) {
20533
- 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
+ }));
20534
20616
  }
20535
20617
  } catch {
20536
20618
  }
@@ -20538,6 +20620,7 @@ async function dispatchChatRoute(req, res, ctx) {
20538
20620
  role: asStr(r.role),
20539
20621
  text,
20540
20622
  ...turns2 ? { turns: turns2 } : {},
20623
+ ...startedAt ? { startedAt } : {},
20541
20624
  created_at: asStr(r.created_at)
20542
20625
  };
20543
20626
  }).sort((a, b) => a.created_at.localeCompare(b.created_at));
@@ -20594,8 +20677,21 @@ async function dispatchChatRoute(req, res, ctx) {
20594
20677
  ...ctx.createJunction ? { createJunction: ctx.createJunction } : {},
20595
20678
  ...ctx.deleteEntity ? { deleteEntity: ctx.deleteEntity } : {}
20596
20679
  };
20680
+ const turnStartedAt = (/* @__PURE__ */ new Date()).toISOString();
20597
20681
  let assistantText = "";
20598
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
+ });
20599
20695
  try {
20600
20696
  const client = createAnthropicClient(auth);
20601
20697
  const temperature = aggressivenessToTemperature(getAggressiveness());
@@ -20611,7 +20707,7 @@ async function dispatchChatRoute(req, res, ctx) {
20611
20707
  }
20612
20708
  })) {
20613
20709
  if (ev.type === "assistant_message_start") {
20614
- turns.push({ text: "", tools: [], toolCalls: [] });
20710
+ turns.push({ text: "", tools: [], events: [], toolCalls: [] });
20615
20711
  } else if (ev.type === "text_delta") {
20616
20712
  assistantText += ev.delta;
20617
20713
  const cur = turns[turns.length - 1];
@@ -20634,16 +20730,19 @@ async function dispatchChatRoute(req, res, ctx) {
20634
20730
  res.write(formatSseFrame({ type: "done" }));
20635
20731
  } catch {
20636
20732
  }
20733
+ } finally {
20734
+ unsubscribeFeed();
20637
20735
  }
20638
20736
  res.end();
20639
20737
  if (threadId) {
20640
20738
  const cleanTurns = turns.map((t) => ({
20641
20739
  text: t.text,
20642
20740
  tools: t.tools.map((x) => ({ name: x.name, isError: x.isError })),
20741
+ ...t.events.length > 0 ? { events: t.events } : {},
20643
20742
  ...t.toolCalls.length > 0 ? { toolCalls: t.toolCalls } : {}
20644
- })).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);
20645
20744
  try {
20646
- await persistMessage(ctx.db, threadId, "assistant", assistantText, cleanTurns);
20745
+ await persistMessage(ctx.db, threadId, "assistant", assistantText, cleanTurns, turnStartedAt);
20647
20746
  } catch (e) {
20648
20747
  console.warn("[chat] persist assistant message failed:", e.message);
20649
20748
  }
@@ -22462,34 +22561,6 @@ data: ${JSON.stringify(data)}
22462
22561
  } catch {
22463
22562
  }
22464
22563
  };
22465
- try {
22466
- const auditBackfill = await active.db.query("_lattice_gui_audit", {
22467
- orderBy: "ts",
22468
- orderDir: "desc",
22469
- limit: 20
22470
- });
22471
- for (const a of auditBackfill.reverse()) {
22472
- const json = a.operation === "delete" ? a.before_json : a.after_json;
22473
- let labelRow;
22474
- if (json) {
22475
- try {
22476
- labelRow = JSON.parse(json);
22477
- } catch {
22478
- labelRow = void 0;
22479
- }
22480
- }
22481
- writeFeed({
22482
- seq: 0,
22483
- table: a.table_name,
22484
- op: a.operation,
22485
- rowId: a.row_id,
22486
- source: "gui",
22487
- ts: a.ts,
22488
- summary: feedSummary(a.operation, a.table_name, labelRow)
22489
- });
22490
- }
22491
- } catch {
22492
- }
22493
22564
  const keepalive = setInterval(() => {
22494
22565
  try {
22495
22566
  res.write(`: keepalive
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latticesql",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "Persistent structured memory for AI agent systems — pluggable SQLite or Postgres backend, LLM context bridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",