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