@alisio/core 0.1.0-alpha.21 → 0.1.0-alpha.22

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 (89) hide show
  1. package/dist/analysis/capabilities.d.ts +48 -0
  2. package/dist/analysis/capabilities.js +90 -0
  3. package/dist/analysis/data/csv.d.ts +46 -0
  4. package/dist/analysis/data/csv.js +240 -0
  5. package/dist/analysis/data/datasets.d.ts +132 -0
  6. package/dist/analysis/data/datasets.js +441 -0
  7. package/dist/analysis/data/describe.d.ts +27 -0
  8. package/dist/analysis/data/describe.js +103 -0
  9. package/dist/analysis/data/engine-client.d.ts +58 -0
  10. package/dist/analysis/data/engine-client.js +304 -0
  11. package/dist/analysis/data/engine-entry.d.ts +1 -0
  12. package/dist/analysis/data/engine-entry.js +59 -0
  13. package/dist/analysis/data/engine-source.d.ts +4 -0
  14. package/dist/analysis/data/engine-source.js +6 -0
  15. package/dist/analysis/data/engine-types.d.ts +86 -0
  16. package/dist/analysis/data/engine-types.js +7 -0
  17. package/dist/analysis/data/infer.d.ts +42 -0
  18. package/dist/analysis/data/infer.js +118 -0
  19. package/dist/analysis/data/ingest-worker.d.ts +17 -0
  20. package/dist/analysis/data/ingest-worker.js +365 -0
  21. package/dist/analysis/data/json.d.ts +19 -0
  22. package/dist/analysis/data/json.js +44 -0
  23. package/dist/analysis/data/query-worker.d.ts +12 -0
  24. package/dist/analysis/data/query-worker.js +75 -0
  25. package/dist/analysis/data/rows.d.ts +39 -0
  26. package/dist/analysis/data/rows.js +120 -0
  27. package/dist/analysis/data/sql-guard.d.ts +20 -0
  28. package/dist/analysis/data/sql-guard.js +129 -0
  29. package/dist/analysis/data/stats.d.ts +18 -0
  30. package/dist/analysis/data/stats.js +77 -0
  31. package/dist/analysis/install-hints.d.ts +33 -0
  32. package/dist/analysis/install-hints.js +144 -0
  33. package/dist/analysis/janitor.d.ts +60 -0
  34. package/dist/analysis/janitor.js +264 -0
  35. package/dist/analysis/jobs.d.ts +94 -0
  36. package/dist/analysis/jobs.js +165 -0
  37. package/dist/analysis/oci.d.ts +129 -0
  38. package/dist/analysis/oci.js +330 -0
  39. package/dist/analysis/python/sources.d.ts +2 -0
  40. package/dist/analysis/python/sources.js +10 -0
  41. package/dist/analysis/requirements.d.ts +32 -0
  42. package/dist/analysis/requirements.js +73 -0
  43. package/dist/analysis/rerun.d.ts +40 -0
  44. package/dist/analysis/rerun.js +136 -0
  45. package/dist/analysis/runtime-manager.d.ts +146 -0
  46. package/dist/analysis/runtime-manager.js +472 -0
  47. package/dist/analysis/status.d.ts +21 -0
  48. package/dist/analysis/status.js +52 -0
  49. package/dist/application.d.ts +41 -0
  50. package/dist/application.js +195 -0
  51. package/dist/artifacts/kinds.d.ts +35 -0
  52. package/dist/artifacts/kinds.js +177 -0
  53. package/dist/artifacts/publisher.d.ts +7 -0
  54. package/dist/artifacts/publisher.js +29 -0
  55. package/dist/artifacts/store.d.ts +119 -0
  56. package/dist/artifacts/store.js +581 -0
  57. package/dist/commands/builtins.js +13 -0
  58. package/dist/config.d.ts +102 -0
  59. package/dist/config.js +167 -6
  60. package/dist/core/contracts.d.ts +69 -1
  61. package/dist/core/runner.d.ts +34 -4
  62. package/dist/core/runner.js +388 -130
  63. package/dist/index.d.ts +23 -4
  64. package/dist/index.js +22 -3
  65. package/dist/mcp/rich.js +4 -0
  66. package/dist/plugins/host.js +14 -6
  67. package/dist/runtime/blobs.d.ts +22 -0
  68. package/dist/runtime/blobs.js +84 -1
  69. package/dist/runtime/ids.d.ts +8 -0
  70. package/dist/runtime/ids.js +32 -0
  71. package/dist/runtime/paths.d.ts +5 -0
  72. package/dist/runtime/paths.js +6 -0
  73. package/dist/runtime/process.d.ts +12 -0
  74. package/dist/runtime/process.js +64 -4
  75. package/dist/runtime/sqlite.d.ts +2 -0
  76. package/dist/runtime/sqlite.js +18 -0
  77. package/dist/runtime/store.d.ts +5 -0
  78. package/dist/runtime/store.js +75 -0
  79. package/dist/runtime/zip.d.ts +24 -0
  80. package/dist/runtime/zip.js +126 -0
  81. package/dist/sessions/children.js +4 -0
  82. package/dist/tools/analysis.d.ts +37 -0
  83. package/dist/tools/analysis.js +572 -0
  84. package/dist/tools/artifacts.d.ts +14 -0
  85. package/dist/tools/artifacts.js +143 -0
  86. package/dist/tools/data.d.ts +7 -0
  87. package/dist/tools/data.js +160 -0
  88. package/dist/tools/execute.js +6 -2
  89. package/package.json +2 -2
@@ -0,0 +1,441 @@
1
+ /**
2
+ * Datasets of a session (spec §17): one SQLite file per dataset under
3
+ * `<state>/analysis/datasets/<workspaceKey>/<rootSession>/<datasetId>.sqlite`, the `datasets` rows
4
+ * that describe them, ingestion (Node engine for CSV/TSV/JSON/JSONL, the Python standard-library
5
+ * helper for XLSX), read-only queries, and the pages `SpreadsheetView` reads. Files are
6
+ * regenerable derivations: the original (a blob or a workspace/artifact file) is never altered.
7
+ */
8
+ import { createHash } from "node:crypto";
9
+ import { createReadStream } from "node:fs";
10
+ import { chmod, mkdir, rename, rm, stat, utimes, writeFile } from "node:fs/promises";
11
+ import { basename, extname, join, relative } from "node:path";
12
+ import { newId } from "../../runtime/ids.js";
13
+ import { workspaceKey } from "../../runtime/paths.js";
14
+ import { runProcess } from "../../runtime/process.js";
15
+ import { openReadOnlyDatabase } from "../../runtime/sqlite.js";
16
+ import { ALISIO_RUNTIME_FILES } from "../python/sources.js";
17
+ import { describeDataset, promptSummary } from "./describe.js";
18
+ import { DataEngine, DataError } from "./engine-client.js";
19
+ import { buildCountQuery, buildPageQuery, encodeCursor, MAX_ALL_COLUMNS_FILTER_ROWS, MAX_PAGE_ROWS, PageParamError, } from "./rows.js";
20
+ import { guardSql } from "./sql-guard.js";
21
+ import { INGEST_VERSION } from "./stats.js";
22
+ export { DataError, PageParamError };
23
+ export const DEFAULT_DATASET_LIMITS = {
24
+ maxUploadBytes: 200 * 1024 * 1024,
25
+ maxRows: 5_000_000,
26
+ queryTimeoutMs: 5000,
27
+ maxInteractiveRows: 1_000_000,
28
+ };
29
+ const MAX_COLUMNS = 1000;
30
+ const MAX_CELL_CHARS = 1024 * 1024;
31
+ const XLSX_TIMEOUT_MS = 10 * 60_000;
32
+ export const XLSX_REMEDY = "Export the sheet as CSV, or install Python 3.10+.";
33
+ /** Heap soft cap of SQLite in the query process (`PRAGMA hard_heap_limit`). */
34
+ const QUERY_HEAP_BYTES = 512 * 1024 * 1024;
35
+ const FORMATS = {
36
+ ".csv": "csv",
37
+ ".tsv": "tsv",
38
+ ".tab": "tsv",
39
+ ".json": "json",
40
+ ".jsonl": "jsonl",
41
+ ".ndjson": "jsonl",
42
+ ".xlsx": "xlsx",
43
+ };
44
+ /** The format of a file name, or undefined when it is not a supported dataset. */
45
+ export function datasetFormat(name) {
46
+ return FORMATS[extname(name).toLowerCase()];
47
+ }
48
+ const toRecord = (row) => ({
49
+ id: row.id,
50
+ sessionId: row.session,
51
+ rootSessionId: row.root_session,
52
+ name: row.name,
53
+ format: row.format,
54
+ sha256: row.sha256,
55
+ bytes: row.bytes,
56
+ ...(row.blob_hash ? { blobHash: row.blob_hash } : {}),
57
+ ...(row.source_path ? { sourcePath: row.source_path } : {}),
58
+ dbRelPath: row.db_rel_path,
59
+ ingestVersion: row.ingest_version,
60
+ sheets: JSON.parse(row.sheets),
61
+ createdAt: row.created_at,
62
+ });
63
+ export const toDatasetRef = (record) => ({
64
+ id: record.id,
65
+ name: record.name,
66
+ format: record.format,
67
+ bytes: record.bytes,
68
+ sha256: record.sha256,
69
+ sheets: record.sheets.map((s) => ({ ...s })),
70
+ });
71
+ async function sha256Of(path, signal) {
72
+ const hash = createHash("sha256");
73
+ for await (const chunk of createReadStream(path)) {
74
+ signal?.throwIfAborted();
75
+ hash.update(chunk);
76
+ }
77
+ return hash.digest("hex");
78
+ }
79
+ const message = (error) => (error instanceof Error ? error.message : String(error));
80
+ export class DatasetService {
81
+ options;
82
+ limits;
83
+ engine;
84
+ inflight = new Map();
85
+ constructor(options) {
86
+ this.options = options;
87
+ this.limits = { ...DEFAULT_DATASET_LIMITS, ...options.limits };
88
+ this.engine = options.engine ?? new DataEngine({ root: options.root });
89
+ }
90
+ now() {
91
+ return (this.options.now ?? Date.now)();
92
+ }
93
+ /** Absolute path of a dataset file. */
94
+ file(record) {
95
+ return join(this.options.root, record.dbRelPath);
96
+ }
97
+ get(id) {
98
+ const row = this.options.db.prepare("SELECT * FROM datasets WHERE id=?").get(id);
99
+ const record = row ? toRecord(row) : undefined;
100
+ if (record)
101
+ this.touch(record);
102
+ return record;
103
+ }
104
+ touched = new Map();
105
+ /**
106
+ * Records that a dataset was used, for the retention sweep ("unused for `jobsDays`"): the
107
+ * modification time of its SQLite file (nothing else changes it after the ingestion), at most
108
+ * once every ten minutes per dataset. Best effort: a failure never affects the caller.
109
+ */
110
+ touch(record) {
111
+ const now = this.now();
112
+ if (now - (this.touched.get(record.id) ?? 0) < 10 * 60_000)
113
+ return;
114
+ this.touched.set(record.id, now);
115
+ const when = new Date(now);
116
+ void utimes(this.file(record), when, when).catch(() => undefined);
117
+ }
118
+ /** Every dataset (all sessions), for the retention sweep; does not count as a use. */
119
+ all() {
120
+ return this.options.db.prepare("SELECT * FROM datasets").all().map(toRecord);
121
+ }
122
+ /** A dataset of this root session; another session's id is `not_found`. */
123
+ getFor(id, rootSessionId) {
124
+ const record = this.get(id);
125
+ if (!record || record.rootSessionId !== rootSessionId)
126
+ throw new DataError("not_found", `Dataset ${id} not found in this session`);
127
+ return record;
128
+ }
129
+ list(rootSessionId) {
130
+ return this.options.db
131
+ .prepare("SELECT * FROM datasets WHERE root_session=? ORDER BY created_at DESC, id DESC")
132
+ .all(rootSessionId).map(toRecord);
133
+ }
134
+ bySha(rootSessionId, sha256) {
135
+ const row = this.options.db
136
+ .prepare("SELECT * FROM datasets WHERE root_session=? AND sha256=? ORDER BY created_at LIMIT 1")
137
+ .get(rootSessionId, sha256);
138
+ return row ? toRecord(row) : undefined;
139
+ }
140
+ /**
141
+ * Ingests a file into a dataset, or returns the session's existing dataset with the same
142
+ * content (`reused`). Concurrent calls for the same content share one ingestion. A failure
143
+ * leaves no row and no file.
144
+ */
145
+ async ingest(input) {
146
+ const name = input.name ?? basename(input.path);
147
+ const format = datasetFormat(name);
148
+ if (!format)
149
+ throw new DataError("dataset_unsupported", `Unsupported file type ${extname(name) || "(none)"}. Supported: CSV, TSV, JSON, JSONL and XLSX.`);
150
+ const info = await stat(input.path).catch(() => undefined);
151
+ if (!info?.isFile())
152
+ throw new DataError("not_found", `File not found: ${name}`);
153
+ if (info.size > this.limits.maxUploadBytes)
154
+ throw new DataError("ingest_limit", `${name} is ${(info.size / (1024 * 1024)).toFixed(1)} MiB; the limit is ${Math.round(this.limits.maxUploadBytes / (1024 * 1024))} MiB (analysis.data.maxUploadBytes)`);
155
+ if (info.size === 0)
156
+ throw new DataError("ingest_invalid", `${name} is empty`);
157
+ const sha256 = await sha256Of(input.path, input.signal);
158
+ const existing = this.bySha(input.rootSessionId, sha256);
159
+ if (existing)
160
+ return { record: existing, reused: true };
161
+ const key = `${input.rootSessionId}:${sha256}`;
162
+ let pending = this.inflight.get(key);
163
+ if (!pending) {
164
+ pending = this.build(input, name, format, sha256, info.size).finally(() => this.inflight.delete(key));
165
+ this.inflight.set(key, pending);
166
+ }
167
+ return pending;
168
+ }
169
+ async build(input, name, format, sha256, bytes) {
170
+ const id = newId("ds", this.now());
171
+ const dir = join(this.options.root, "analysis", "datasets", workspaceKey(input.workspace), input.rootSessionId);
172
+ await mkdir(dir, { recursive: true, mode: 0o700 });
173
+ const target = join(dir, `${id}.sqlite`);
174
+ const temp = `${target}.tmp`;
175
+ try {
176
+ let result;
177
+ if (format === "xlsx")
178
+ result = await this.ingestXlsx(input, name, sha256, temp);
179
+ else
180
+ result = await this.engine.ingest({
181
+ source: input.path,
182
+ format,
183
+ target: temp,
184
+ sourceName: name,
185
+ sourceSha256: sha256,
186
+ limits: {
187
+ maxRows: this.limits.maxRows,
188
+ maxColumns: MAX_COLUMNS,
189
+ maxCellChars: MAX_CELL_CHARS,
190
+ },
191
+ }, {
192
+ ...(input.signal ? { signal: input.signal } : {}),
193
+ ...(input.onProgress ? { onProgress: input.onProgress } : {}),
194
+ });
195
+ // Windows cannot rename over an existing file; nothing else holds this fresh path.
196
+ await rm(target, { force: true });
197
+ await rename(temp, target);
198
+ await chmod(target, 0o400).catch(() => undefined);
199
+ const record = {
200
+ id,
201
+ sessionId: input.sessionId,
202
+ rootSessionId: input.rootSessionId,
203
+ name,
204
+ format,
205
+ sha256,
206
+ bytes,
207
+ ...(input.blobHash ? { blobHash: input.blobHash } : {}),
208
+ ...(input.sourcePath ? { sourcePath: input.sourcePath } : {}),
209
+ dbRelPath: relative(this.options.root, target),
210
+ ingestVersion: INGEST_VERSION,
211
+ sheets: result.sheets,
212
+ createdAt: this.now(),
213
+ };
214
+ this.options.db
215
+ .prepare(`INSERT INTO datasets(id,session,root_session,name,format,sha256,bytes,blob_hash,source_path,
216
+ db_rel_path,ingest_version,sheets,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`)
217
+ .run(record.id, record.sessionId, record.rootSessionId, record.name, record.format, record.sha256, record.bytes, record.blobHash ?? null, record.sourcePath ?? null, record.dbRelPath, record.ingestVersion, JSON.stringify(record.sheets), record.createdAt);
218
+ return { record, reused: false };
219
+ }
220
+ catch (error) {
221
+ await rm(temp, { force: true }).catch(() => undefined);
222
+ await rm(target, { force: true }).catch(() => undefined);
223
+ throw error;
224
+ }
225
+ }
226
+ /** The XLSX route (spec §17.6, option B): the Python standard-library helper, then statistics. */
227
+ async ingestXlsx(input, name, sha256, temp) {
228
+ const python = this.options.python;
229
+ const signal = input.signal ?? new AbortController().signal;
230
+ const runtime = python ? await python.interpreter(signal) : undefined;
231
+ if (!runtime?.ok)
232
+ throw new DataError("dataset_unsupported", `${name} is an Excel workbook, which needs Python 3.10+ to be read. ${XLSX_REMEDY}`);
233
+ const helper = await this.helperDir();
234
+ await rm(temp, { force: true });
235
+ let run;
236
+ try {
237
+ run = await runProcess(runtime.executable, [
238
+ ...(runtime.prefixArgs ?? []),
239
+ "-E",
240
+ "-s",
241
+ "-B",
242
+ "-u",
243
+ "-X",
244
+ "utf8",
245
+ "-m",
246
+ "alisio_runtime.xlsx_to_sqlite",
247
+ input.path,
248
+ temp,
249
+ "--max-rows",
250
+ String(this.limits.maxRows),
251
+ "--max-columns",
252
+ String(MAX_COLUMNS),
253
+ "--max-cell-chars",
254
+ String(MAX_CELL_CHARS),
255
+ ], { cwd: helper, signal, timeoutMs: XLSX_TIMEOUT_MS, maxBytes: 16 * 1024 });
256
+ }
257
+ catch (error) {
258
+ if (signal.aborted)
259
+ throw new DataError("cancelled", "Cancelled");
260
+ throw new DataError("engine_error", `The XLSX helper failed: ${message(error)}`);
261
+ }
262
+ if (run.exitCode !== 0) {
263
+ const line = run.stderr
264
+ .split(/\r?\n/)
265
+ .reverse()
266
+ .find((l) => l.startsWith("alisio-xlsx:")) ?? "";
267
+ throw new DataError("ingest_invalid", line
268
+ ? line.replace(/^alisio-xlsx:\s*/, "")
269
+ : `The XLSX helper failed (exit ${run.exitCode})`);
270
+ }
271
+ return this.engine.finalize({ target: temp, sourceName: name, sourceSha256: sha256 }, {
272
+ ...(input.signal ? { signal: input.signal } : {}),
273
+ });
274
+ }
275
+ /** The `alisio_runtime` package on disk for `python -m` (content-addressed, written once). */
276
+ async helperDir() {
277
+ const hash = createHash("sha256")
278
+ .update(JSON.stringify(ALISIO_RUNTIME_FILES))
279
+ .digest("hex")
280
+ .slice(0, 12);
281
+ const dir = join(this.options.root, "runtimes", "python", "helper", hash);
282
+ const marker = join(dir, ".complete");
283
+ if (await stat(marker).then(() => true, () => false))
284
+ return dir;
285
+ const package_ = join(dir, "alisio_runtime");
286
+ await mkdir(package_, { recursive: true, mode: 0o700 });
287
+ for (const [file, text] of Object.entries(ALISIO_RUNTIME_FILES))
288
+ await writeFile(join(package_, file), text);
289
+ await writeFile(marker, "");
290
+ return dir;
291
+ }
292
+ /** Schema and statistics of every sheet (`GET /api/datasets/:did`). */
293
+ detail(record) {
294
+ const db = openReadOnlyDatabase(this.file(record));
295
+ try {
296
+ const meta = Object.fromEntries(db.prepare("SELECT key, value FROM _alisio_meta").all().map((r) => [r.key, r.value]));
297
+ const sheets = db
298
+ .prepare("SELECT name, table_name, rows FROM _alisio_sheets ORDER BY ordinal")
299
+ .all();
300
+ const columns = db.prepare(`SELECT name, label, inferred_type, nulls, distinct_count, distinct_exact, min, max, mean,
301
+ text_fallbacks, top_values FROM _alisio_columns WHERE table_name=? ORDER BY ordinal`);
302
+ return {
303
+ ...toDatasetRef(record),
304
+ ingestVersion: record.ingestVersion,
305
+ ...(meta.encoding ? { encoding: meta.encoding } : {}),
306
+ ...(meta.delimiter ? { delimiter: meta.delimiter } : {}),
307
+ maxInteractiveRows: this.limits.maxInteractiveRows,
308
+ sheetDetails: sheets.map((sheet) => ({
309
+ name: sheet.name,
310
+ table: sheet.table_name,
311
+ rows: Number(sheet.rows),
312
+ columns: columns.all(sheet.table_name).map((c) => ({
313
+ name: String(c.name),
314
+ label: String(c.label),
315
+ type: String(c.inferred_type),
316
+ nulls: Number(c.nulls ?? 0),
317
+ distinct: Number(c.distinct_count ?? 0),
318
+ distinctExact: Number(c.distinct_exact ?? 1) === 1,
319
+ ...(c.min !== null && c.min !== undefined ? { min: String(c.min) } : {}),
320
+ ...(c.max !== null && c.max !== undefined ? { max: String(c.max) } : {}),
321
+ ...(c.mean !== null && c.mean !== undefined ? { mean: Number(c.mean) } : {}),
322
+ textFallbacks: Number(c.text_fallbacks ?? 0),
323
+ top: JSON.parse(String(c.top_values ?? "[]")),
324
+ })),
325
+ })),
326
+ };
327
+ }
328
+ finally {
329
+ db.close();
330
+ }
331
+ }
332
+ /** First rows of a table, for `data_inspect` and the prompt summary. */
333
+ async sample(record, table, rows, signal) {
334
+ const result = await this.engine.runQuery({
335
+ db: this.file(record),
336
+ sql: `SELECT * FROM "${table.replaceAll('"', '""')}" LIMIT ${Math.max(0, Math.floor(rows))}`,
337
+ maxRows: rows,
338
+ cellChars: 200,
339
+ guard: false,
340
+ }, { timeoutMs: this.limits.queryTimeoutMs, ...(signal ? { signal } : {}) });
341
+ return { table, columns: result.columns, rows: result.rows };
342
+ }
343
+ /** The description for the model: `data_inspect` text. */
344
+ async describe(record, sampleRows, maxChars, signal) {
345
+ const detail = this.detail(record);
346
+ const samples = new Map();
347
+ for (const sheet of detail.sheetDetails.slice(0, 5))
348
+ samples.set(sheet.table, await this.sample(record, sheet.table, sampleRows, signal));
349
+ return { detail, samples, text: describeDataset(detail, samples, { maxChars }) };
350
+ }
351
+ /** The ≤ 4 KB summary appended to a prompt that attaches the dataset. */
352
+ async summary(record, signal) {
353
+ const detail = this.detail(record);
354
+ const samples = new Map();
355
+ for (const sheet of detail.sheetDetails.slice(0, 3))
356
+ samples.set(sheet.table, await this.sample(record, sheet.table, 5, signal));
357
+ return promptSummary(detail, samples);
358
+ }
359
+ /**
360
+ * Runs a model-written read-only query (`data_query`): lexical guard, then a read-only
361
+ * connection in the engine process, with a row limit and a time limit.
362
+ */
363
+ async query(record, sql, options = {}) {
364
+ const guarded = guardSql(sql);
365
+ if (!guarded.ok)
366
+ throw new DataError("query_rejected", guarded.reason);
367
+ return this.engine.runQuery({
368
+ db: this.file(record),
369
+ sql: guarded.sql,
370
+ maxRows: Math.min(Math.max(1, options.maxRows ?? 200), 1000),
371
+ cellChars: 2048,
372
+ guard: true,
373
+ heapBytes: QUERY_HEAP_BYTES,
374
+ }, {
375
+ timeoutMs: this.limits.queryTimeoutMs,
376
+ ...(options.signal ? { signal: options.signal } : {}),
377
+ });
378
+ }
379
+ /** One page of a sheet for `SpreadsheetView` (spec §17.4). */
380
+ async page(record, request, signal) {
381
+ const detail = this.detail(record);
382
+ const sheet = detail.sheetDetails.find((s) => s.name === request.sheet || s.table === request.sheet) ??
383
+ (request.sheet === undefined ? detail.sheetDetails[0] : undefined);
384
+ if (!sheet)
385
+ throw new PageParamError(`Unknown sheet: ${request.sheet}`);
386
+ const interactive = sheet.rows <= this.limits.maxInteractiveRows;
387
+ const names = sheet.columns.map((c) => c.name);
388
+ const filterText = interactive ? (request.filter ?? "") : "";
389
+ if (filterText && request.column === undefined && sheet.rows > MAX_ALL_COLUMNS_FILTER_ROWS)
390
+ throw new PageParamError(`Choose a column to filter: this sheet has more than ${MAX_ALL_COLUMNS_FILTER_ROWS} rows`);
391
+ const params = {
392
+ table: sheet.table,
393
+ columns: names,
394
+ limit: Math.min(Math.max(1, request.limit ?? 200), MAX_PAGE_ROWS),
395
+ ...(interactive && request.sort
396
+ ? { sort: { column: request.sort, dir: request.dir ?? "asc" } }
397
+ : {}),
398
+ ...(filterText
399
+ ? {
400
+ filter: {
401
+ text: filterText,
402
+ ...(request.column !== undefined ? { column: request.column } : {}),
403
+ },
404
+ }
405
+ : {}),
406
+ ...(request.after ? { after: request.after } : {}),
407
+ ...(request.offset ? { offset: request.offset } : {}),
408
+ };
409
+ const built = buildPageQuery(params);
410
+ const run = (sql, values, maxRows) => this.engine.runQuery({ db: this.file(record), sql, params: values, maxRows, cellChars: 4096, guard: false }, { timeoutMs: Math.max(this.limits.queryTimeoutMs, 30_000), ...(signal ? { signal } : {}) });
411
+ const result = await run(built.sql, built.params, params.limit + 1);
412
+ const hasNext = result.rows.length > params.limit;
413
+ const page = hasNext ? result.rows.slice(0, params.limit) : result.rows;
414
+ const rowids = page.map((r) => Number(r[0]));
415
+ let matched;
416
+ if (filterText && !request.after && !request.offset) {
417
+ const count = buildCountQuery({ table: sheet.table, columns: names, filter: params.filter });
418
+ const counted = await run(count.sql, count.params, 1);
419
+ matched = Number(counted.rows[0]?.[0] ?? 0);
420
+ }
421
+ const last = rowids.at(-1);
422
+ return {
423
+ columns: sheet.columns.map((c) => ({ name: c.name, label: c.label, type: c.type })),
424
+ rows: page.map((r) => r.slice(1)),
425
+ rowids,
426
+ ...(hasNext && last !== undefined ? { next: encodeCursor(last) } : {}),
427
+ total: sheet.rows,
428
+ ...(matched !== undefined ? { matched } : {}),
429
+ interactive,
430
+ };
431
+ }
432
+ /** Deletes a dataset file and row (connections are closed first, for Windows). */
433
+ async remove(record) {
434
+ await this.engine.closeFile(this.file(record));
435
+ await rm(this.file(record), { force: true });
436
+ this.options.db.prepare("DELETE FROM datasets WHERE id=?").run(record.id);
437
+ }
438
+ close() {
439
+ this.engine.close();
440
+ }
441
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Text and table renderings of a dataset for the model and the terminal (spec §12 and the
3
+ * prompt summary of §21 phase 3). Pure: given the dataset detail and sample rows, produces a
4
+ * bounded text (the model never sees more than `maxChars`).
5
+ */
6
+ import type { DatasetDetailWire } from "@alisio/sdk";
7
+ export declare const formatCount: (n: number) => string;
8
+ export declare function formatBytes(bytes: number): string;
9
+ /** One cell as plain, single-line text of at most `max` characters. */
10
+ export declare function cellText(value: string | number | null | undefined, max?: number): string;
11
+ /** `ds_… "sales.csv" · csv · 1.2 MB · 12,480 rows × 6 columns` (one line per dataset). */
12
+ export declare function datasetHeadline(detail: DatasetDetailWire): string;
13
+ export interface SampleRows {
14
+ table: string;
15
+ columns: string[];
16
+ rows: Array<Array<string | number | null>>;
17
+ }
18
+ /**
19
+ * The description a model reads: headline, usage note, and for each sheet its columns with
20
+ * statistics and a few sample rows. Fits `maxChars` by dropping column detail first, then sample
21
+ * columns (never silently: a line says what was left out).
22
+ */
23
+ export declare function describeDataset(detail: DatasetDetailWire, samples: Map<string, SampleRows>, options: {
24
+ maxChars: number;
25
+ }): string;
26
+ /** The summary appended to the user's prompt for an attached dataset (≤ 4 KB). */
27
+ export declare function promptSummary(detail: DatasetDetailWire, samples: Map<string, SampleRows>): string;
@@ -0,0 +1,103 @@
1
+ export const formatCount = (n) => n.toLocaleString("en-US");
2
+ export function formatBytes(bytes) {
3
+ if (bytes < 1024)
4
+ return `${bytes} B`;
5
+ if (bytes < 1024 * 1024)
6
+ return `${Math.round(bytes / 1024)} KB`;
7
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
8
+ }
9
+ /** One cell as plain, single-line text of at most `max` characters. */
10
+ export function cellText(value, max = 40) {
11
+ if (value === null || value === undefined)
12
+ return "";
13
+ const text = String(value).replace(/\r?\n/g, "\\n").replace(/\t/g, " ");
14
+ return text.length > max ? `${text.slice(0, Math.max(1, max - 1))}…` : text;
15
+ }
16
+ function columnLine(column) {
17
+ const parts = [`${column.name} (${column.type})`];
18
+ if (column.label !== column.name)
19
+ parts.push(`header "${cellText(column.label, 30)}"`);
20
+ parts.push(`nulls ${formatCount(column.nulls)}`);
21
+ parts.push(`distinct ${formatCount(column.distinct)}${column.distinctExact ? "" : "~"}`);
22
+ if (column.min !== undefined && column.max !== undefined)
23
+ parts.push(`min ${cellText(column.min, 24)}, max ${cellText(column.max, 24)}`);
24
+ if (column.mean !== undefined)
25
+ parts.push(`mean ${Number(column.mean.toPrecision(6))}`);
26
+ if (column.textFallbacks > 0)
27
+ parts.push(`${formatCount(column.textFallbacks)} cells kept as exact text`);
28
+ if (column.top.length)
29
+ parts.push(`top: ${column.top
30
+ .slice(0, 3)
31
+ .map((t) => `${cellText(t.value, 20)} (${formatCount(t.count)})`)
32
+ .join(", ")}`);
33
+ return `- ${parts.join(" · ")}`;
34
+ }
35
+ /** `ds_… "sales.csv" · csv · 1.2 MB · 12,480 rows × 6 columns` (one line per dataset). */
36
+ export function datasetHeadline(detail) {
37
+ const sheet = detail.sheets[0];
38
+ const shape = detail.sheets.length === 1 && sheet
39
+ ? `${formatCount(sheet.rows)} rows × ${sheet.columns} columns`
40
+ : `${detail.sheets.length} sheets`;
41
+ return `${detail.id} "${detail.name}" · ${detail.format} · ${formatBytes(detail.bytes)} · ${shape}`;
42
+ }
43
+ function sampleLines(sample, columnLimit) {
44
+ const names = sample.columns.slice(0, columnLimit);
45
+ const lines = [names.join(" | ")];
46
+ for (const row of sample.rows)
47
+ lines.push(row
48
+ .slice(0, columnLimit)
49
+ .map((v) => cellText(v, 24))
50
+ .join(" | "));
51
+ if (sample.columns.length > columnLimit)
52
+ lines.push(`… ${sample.columns.length - columnLimit} more columns`);
53
+ return lines;
54
+ }
55
+ /**
56
+ * The description a model reads: headline, usage note, and for each sheet its columns with
57
+ * statistics and a few sample rows. Fits `maxChars` by dropping column detail first, then sample
58
+ * columns (never silently: a line says what was left out).
59
+ */
60
+ export function describeDataset(detail, samples, options) {
61
+ const header = [
62
+ `Dataset ${datasetHeadline(detail)}`,
63
+ "Stored in SQLite: query it with data_query (SELECT/WITH only, quote column names) or pass { datasetId } to python_run (alisio_runtime.datasets.open). Values are exact: numbers are numbers, everything else (e.g. 007, 1,234, N/A) is the original text.",
64
+ ];
65
+ const blocks = detail.sheetDetails.map((sheet) => {
66
+ const lines = [
67
+ `Table "${sheet.table}" (sheet "${sheet.name}"): ${formatCount(sheet.rows)} rows`,
68
+ ];
69
+ return { sheet, lines };
70
+ });
71
+ const build = (columnCap, sampleCols) => {
72
+ const out = [...header];
73
+ for (const { sheet, lines } of blocks) {
74
+ out.push(...lines);
75
+ for (const column of sheet.columns.slice(0, columnCap))
76
+ out.push(columnLine(column));
77
+ if (sheet.columns.length > columnCap)
78
+ out.push(`… ${sheet.columns.length - columnCap} more columns (see the table in data_inspect)`);
79
+ const sample = samples.get(sheet.table);
80
+ if (sample?.rows.length) {
81
+ out.push(`First ${sample.rows.length} rows:`);
82
+ out.push(...sampleLines(sample, sampleCols));
83
+ }
84
+ }
85
+ return out.join("\n");
86
+ };
87
+ for (const [columns, sampleCols] of [
88
+ [60, 12],
89
+ [30, 10],
90
+ [16, 8],
91
+ [8, 6],
92
+ [4, 4],
93
+ ]) {
94
+ const text = build(columns, sampleCols);
95
+ if (text.length <= options.maxChars)
96
+ return text;
97
+ }
98
+ return `${build(2, 3).slice(0, Math.max(0, options.maxChars - 1))}…`;
99
+ }
100
+ /** The summary appended to the user's prompt for an attached dataset (≤ 4 KB). */
101
+ export function promptSummary(detail, samples) {
102
+ return `[Attached ${describeDataset(detail, samples, { maxChars: 3900 })}]`;
103
+ }
@@ -0,0 +1,58 @@
1
+ import type { FinalizeRequest, IngestRequest, IngestResult, QueryRequest, QueryResult } from "./engine-types.ts";
2
+ export type DataErrorCode = "dataset_unsupported" | "query_rejected" | "query_timeout" | "not_found" | "ingest_limit" | "ingest_invalid" | "sql_error" | "engine_error" | "cancelled";
3
+ /** A failure with a stable code that tools and routes map to their own error shapes. */
4
+ export declare class DataError extends Error {
5
+ readonly code: DataErrorCode;
6
+ constructor(code: DataErrorCode, message: string);
7
+ }
8
+ /** The engine script on disk (`<root>/analysis/engine/data-engine-<hash>.mjs`). */
9
+ export declare function ensureEngineScript(root: string): Promise<string>;
10
+ export interface DataEngineOptions {
11
+ /** State folder (`<root>/analysis/engine/…` holds the script). */
12
+ root: string;
13
+ /** Heap limit of the child process (Node only), in MiB. */
14
+ memoryMb?: number;
15
+ /** A persistent query process exits after this much inactivity. */
16
+ idleMs?: number;
17
+ /** Ingestions running at once. */
18
+ concurrency?: number;
19
+ }
20
+ /** Ingestion and query access to dataset files through the engine process. */
21
+ export declare class DataEngine {
22
+ private readonly options;
23
+ private script;
24
+ private query;
25
+ private queryQueue;
26
+ private idle;
27
+ private running;
28
+ private waiting;
29
+ private active;
30
+ private closed;
31
+ constructor(options: DataEngineOptions);
32
+ private scriptPath;
33
+ private memory;
34
+ private slot;
35
+ /** Runs one request in a fresh process (ingestion and finalization). */
36
+ private oneShot;
37
+ ingest(request: Omit<IngestRequest, "id" | "op">, options?: {
38
+ signal?: AbortSignal;
39
+ onProgress?: (rows: number) => void;
40
+ }): Promise<IngestResult>;
41
+ finalize(request: Omit<FinalizeRequest, "id" | "op">, options?: {
42
+ signal?: AbortSignal;
43
+ }): Promise<IngestResult>;
44
+ /**
45
+ * Runs one read-only statement. Requests are served one at a time by a persistent process;
46
+ * exceeding `timeoutMs` kills it (the next request starts a new one) and fails with
47
+ * `query_timeout`.
48
+ */
49
+ runQuery(request: Omit<QueryRequest, "id" | "op">, options: {
50
+ timeoutMs: number;
51
+ signal?: AbortSignal;
52
+ }): Promise<QueryResult>;
53
+ private armIdle;
54
+ /** Closes the engine's connection to a file (before the host deletes or replaces it). */
55
+ closeFile(db?: string): Promise<void>;
56
+ /** Kills every process; later requests fail. */
57
+ close(): void;
58
+ }