@alisio/plugin-semantic 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/LICENSE +21 -0
  2. package/README.es.md +168 -0
  3. package/README.md +160 -2
  4. package/THIRD_PARTY_NOTICES.md +42 -0
  5. package/assets/architecture.svg +1 -0
  6. package/assets/ask-flow.svg +1 -0
  7. package/cover.svg +53 -0
  8. package/dist/apps.d.ts +26 -0
  9. package/dist/apps.d.ts.map +1 -0
  10. package/dist/apps.js +141 -0
  11. package/dist/apps.js.map +1 -0
  12. package/dist/embedded.html +3043 -0
  13. package/dist/engine/download.d.ts +77 -0
  14. package/dist/engine/download.d.ts.map +1 -0
  15. package/dist/engine/download.js +213 -0
  16. package/dist/engine/download.js.map +1 -0
  17. package/dist/engine/embedder.d.ts +55 -0
  18. package/dist/engine/embedder.d.ts.map +1 -0
  19. package/dist/engine/embedder.js +182 -0
  20. package/dist/engine/embedder.js.map +1 -0
  21. package/dist/engine/infer.d.ts +22 -0
  22. package/dist/engine/infer.d.ts.map +1 -0
  23. package/dist/engine/infer.js +53 -0
  24. package/dist/engine/infer.js.map +1 -0
  25. package/dist/engine/models.d.ts +45 -0
  26. package/dist/engine/models.d.ts.map +1 -0
  27. package/dist/engine/models.js +132 -0
  28. package/dist/engine/models.js.map +1 -0
  29. package/dist/engine/runtime.d.ts +46 -0
  30. package/dist/engine/runtime.d.ts.map +1 -0
  31. package/dist/engine/runtime.js +172 -0
  32. package/dist/engine/runtime.js.map +1 -0
  33. package/dist/engine/setup.d.ts +82 -0
  34. package/dist/engine/setup.d.ts.map +1 -0
  35. package/dist/engine/setup.js +183 -0
  36. package/dist/engine/setup.js.map +1 -0
  37. package/dist/engine/store.d.ts +46 -0
  38. package/dist/engine/store.d.ts.map +1 -0
  39. package/dist/engine/store.js +288 -0
  40. package/dist/engine/store.js.map +1 -0
  41. package/dist/engine/tar.d.ts +18 -0
  42. package/dist/engine/tar.d.ts.map +1 -0
  43. package/dist/engine/tar.js +198 -0
  44. package/dist/engine/tar.js.map +1 -0
  45. package/dist/engine/worker.d.ts +2 -0
  46. package/dist/engine/worker.d.ts.map +1 -0
  47. package/dist/engine/worker.js +64 -0
  48. package/dist/engine/worker.js.map +1 -0
  49. package/dist/errors.d.ts +15 -0
  50. package/dist/errors.d.ts.map +1 -0
  51. package/dist/errors.js +59 -0
  52. package/dist/errors.js.map +1 -0
  53. package/dist/index.d.ts +20 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +269 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/messages.d.ts +53 -0
  58. package/dist/messages.d.ts.map +1 -0
  59. package/dist/messages.js +118 -0
  60. package/dist/messages.js.map +1 -0
  61. package/dist/notebook/answer.d.ts +35 -0
  62. package/dist/notebook/answer.d.ts.map +1 -0
  63. package/dist/notebook/answer.js +88 -0
  64. package/dist/notebook/answer.js.map +1 -0
  65. package/dist/notebook/chunk.d.ts +23 -0
  66. package/dist/notebook/chunk.d.ts.map +1 -0
  67. package/dist/notebook/chunk.js +124 -0
  68. package/dist/notebook/chunk.js.map +1 -0
  69. package/dist/notebook/db.d.ts +107 -0
  70. package/dist/notebook/db.d.ts.map +1 -0
  71. package/dist/notebook/db.js +356 -0
  72. package/dist/notebook/db.js.map +1 -0
  73. package/dist/notebook/docx.d.ts +3 -0
  74. package/dist/notebook/docx.d.ts.map +1 -0
  75. package/dist/notebook/docx.js +16 -0
  76. package/dist/notebook/docx.js.map +1 -0
  77. package/dist/notebook/extract.d.ts +11 -0
  78. package/dist/notebook/extract.d.ts.map +1 -0
  79. package/dist/notebook/extract.js +99 -0
  80. package/dist/notebook/extract.js.map +1 -0
  81. package/dist/notebook/jobs.d.ts +31 -0
  82. package/dist/notebook/jobs.d.ts.map +1 -0
  83. package/dist/notebook/jobs.js +84 -0
  84. package/dist/notebook/jobs.js.map +1 -0
  85. package/dist/notebook/ooxml.d.ts +6 -0
  86. package/dist/notebook/ooxml.d.ts.map +1 -0
  87. package/dist/notebook/ooxml.js +131 -0
  88. package/dist/notebook/ooxml.js.map +1 -0
  89. package/dist/notebook/retrieve.d.ts +14 -0
  90. package/dist/notebook/retrieve.d.ts.map +1 -0
  91. package/dist/notebook/retrieve.js +37 -0
  92. package/dist/notebook/retrieve.js.map +1 -0
  93. package/dist/notebook/service.d.ts +92 -0
  94. package/dist/notebook/service.d.ts.map +1 -0
  95. package/dist/notebook/service.js +752 -0
  96. package/dist/notebook/service.js.map +1 -0
  97. package/dist/notebook/workspace.d.ts +17 -0
  98. package/dist/notebook/workspace.d.ts.map +1 -0
  99. package/dist/notebook/workspace.js +75 -0
  100. package/dist/notebook/workspace.js.map +1 -0
  101. package/dist/notebook/zip.d.ts +33 -0
  102. package/dist/notebook/zip.d.ts.map +1 -0
  103. package/dist/notebook/zip.js +154 -0
  104. package/dist/notebook/zip.js.map +1 -0
  105. package/dist/paths.d.ts +17 -0
  106. package/dist/paths.d.ts.map +1 -0
  107. package/dist/paths.js +28 -0
  108. package/dist/paths.js.map +1 -0
  109. package/dist/version.d.ts +2 -0
  110. package/dist/version.d.ts.map +1 -0
  111. package/dist/version.js +3 -0
  112. package/dist/version.js.map +1 -0
  113. package/icon.svg +16 -0
  114. package/package.json +62 -3
@@ -0,0 +1,752 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { basename } from "node:path";
3
+ import { errorCode, SemanticPluginError } from "../errors.js";
4
+ import { buildPrompt, NOT_FOUND, parseCitations } from "./answer.js";
5
+ import { chunkSegments } from "./chunk.js";
6
+ import { extractBytes, extractPdfPages, formatOf } from "./extract.js";
7
+ import { JobRunner } from "./jobs.js";
8
+ import { ftsQuery, RETRIEVE_EACH, RETRIEVE_KEEP, reciprocalRankFusion } from "./retrieve.js";
9
+ import { listDirectory, MAX_FILE_BYTES, readInside, resolveInside } from "./workspace.js";
10
+ export const LIMITS = {
11
+ sourcesPerNotebook: 50,
12
+ charsPerSource: 2_000_000,
13
+ chunksPerNotebook: 20_000,
14
+ questionChars: 4000,
15
+ titleChars: 200,
16
+ fileBytes: MAX_FILE_BYTES,
17
+ historyMessages: 50,
18
+ indexBatch: 64,
19
+ };
20
+ const ID = /^[a-z2-7]{12}$/;
21
+ const READS = new Set([
22
+ "models.list",
23
+ "notebooks.list",
24
+ "notebooks.get",
25
+ "workspace.list",
26
+ "sources.read",
27
+ "sources.passage",
28
+ "chat.history",
29
+ "jobs.get",
30
+ ]);
31
+ const ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
32
+ /** 12 lowercase base32 characters (D16). */
33
+ export function newId() {
34
+ const bytes = randomBytes(12);
35
+ let id = "";
36
+ for (const byte of bytes)
37
+ id += ALPHABET[byte % 32];
38
+ return id;
39
+ }
40
+ export const collectionOf = (notebookId) => `nb-${notebookId}`;
41
+ const vectorId = (sourceId, ordinal) => `${sourceId}:${ordinal}`;
42
+ const invalid = () => {
43
+ throw new SemanticPluginError("invalid_input");
44
+ };
45
+ function text(input, key, max, optional = false) {
46
+ const value = input[key];
47
+ if (value === undefined && optional)
48
+ return "";
49
+ if (typeof value !== "string" || value.length > max)
50
+ return invalid();
51
+ return value;
52
+ }
53
+ function id(input, key) {
54
+ const value = input[key];
55
+ return typeof value === "string" && ID.test(value) ? value : invalid();
56
+ }
57
+ function ids(input, key) {
58
+ const value = input[key];
59
+ if (value === undefined)
60
+ return undefined;
61
+ if (!Array.isArray(value) || value.length > LIMITS.sourcesPerNotebook)
62
+ return invalid();
63
+ return value.map((v) => (typeof v === "string" && ID.test(v) ? v : invalid()));
64
+ }
65
+ const localeOf = (input) => (input.locale === "es" ? "es" : "en");
66
+ const GUIDE_INSTRUCTION = `Task: write a short overview of these sources for someone who has not read them (one paragraph, with [n] citations), then exactly three questions the sources can answer.
67
+ Format:
68
+ Summary: <overview>
69
+ Q: <question 1>
70
+ Q: <question 2>
71
+ Q: <question 3>`;
72
+ const SUMMARY_INSTRUCTION = "Task: summarize this source in one short paragraph of plain words, citing passages as [n].";
73
+ /** The Notebook app's actions, testable without a host (§7.7). */
74
+ export class NotebookService {
75
+ db;
76
+ deps;
77
+ jobs;
78
+ idempotent = new Map();
79
+ constructor(deps) {
80
+ this.deps = deps;
81
+ this.db = deps.db;
82
+ this.jobs = new JobRunner(deps.db);
83
+ }
84
+ busy() {
85
+ return this.jobs.busy();
86
+ }
87
+ dispose() {
88
+ this.jobs.dispose();
89
+ }
90
+ idle() {
91
+ return this.jobs.idle();
92
+ }
93
+ semantic() {
94
+ const semantic = this.deps.semantic();
95
+ try {
96
+ return semantic?.available() ? semantic : undefined;
97
+ }
98
+ catch {
99
+ return undefined;
100
+ }
101
+ }
102
+ async invoke(action, input, ctx) {
103
+ ctx.signal.throwIfAborted();
104
+ // Only mutations are deduplicated: a read must always see the current state.
105
+ const key = ctx.idempotencyKey && !READS.has(action) ? `${action}\0${ctx.idempotencyKey}` : undefined;
106
+ if (key && this.idempotent.has(key))
107
+ return this.idempotent.get(key);
108
+ const value = await this.dispatch(action, input ?? {}, ctx);
109
+ if (key) {
110
+ this.idempotent.set(key, value);
111
+ if (this.idempotent.size > 500)
112
+ this.idempotent.delete(this.idempotent.keys().next().value);
113
+ }
114
+ return value;
115
+ }
116
+ notebook(notebookId) {
117
+ const row = this.db.notebook(notebookId);
118
+ if (!row)
119
+ throw new SemanticPluginError("not_found");
120
+ return row;
121
+ }
122
+ source(sourceId) {
123
+ const row = this.db.source(sourceId);
124
+ if (!row)
125
+ throw new SemanticPluginError("not_found");
126
+ return row;
127
+ }
128
+ async dispatch(action, input, ctx) {
129
+ switch (action) {
130
+ case "notebooks.list":
131
+ return this.db.notebooks();
132
+ case "notebooks.get": {
133
+ const row = this.notebook(id(input, "id"));
134
+ const sources = this.db.sources(row.id);
135
+ const current = this.sourceSet(row.id);
136
+ const guide = row.guide
137
+ ? { ...row.guide, outdated: row.guide.sources !== current }
138
+ : null;
139
+ return { ...row, guide, sources };
140
+ }
141
+ case "notebooks.setScope": {
142
+ const row = this.notebook(id(input, "id"));
143
+ const known = new Set(this.db.sources(row.id).map((s) => s.id));
144
+ const excluded = (ids(input, "excluded") ?? []).filter((x) => known.has(x));
145
+ this.db.setExcluded(row.id, [...new Set(excluded)]);
146
+ return { ok: true, excluded };
147
+ }
148
+ case "notebooks.create": {
149
+ const title = text(input, "title", LIMITS.titleChars).trim() || "Untitled";
150
+ const notebookId = newId();
151
+ this.db.createNotebook(notebookId, title);
152
+ return { id: notebookId };
153
+ }
154
+ case "notebooks.rename": {
155
+ const row = this.notebook(id(input, "id"));
156
+ this.db.renameNotebook(row.id, text(input, "title", LIMITS.titleChars).trim() || row.title);
157
+ return { ok: true };
158
+ }
159
+ case "notebooks.delete": {
160
+ const row = this.notebook(id(input, "id"));
161
+ for (const job of this.db.activeJobs(row.id))
162
+ this.jobs.cancel(job.id);
163
+ await this.semantic()
164
+ ?.drop(collectionOf(row.id), { signal: ctx.signal })
165
+ .catch(() => { });
166
+ this.db.deleteNotebook(row.id);
167
+ return { ok: true };
168
+ }
169
+ case "models.list":
170
+ return this.modelList(ctx);
171
+ case "notebooks.setModel": {
172
+ const row = this.notebook(id(input, "id"));
173
+ const model = input.model;
174
+ if (model === null || model === "") {
175
+ this.db.setModel(row.id, null);
176
+ return { ok: true, model: null };
177
+ }
178
+ if (typeof model !== "string" || model.length > 300)
179
+ invalid();
180
+ if (!this.deps.models)
181
+ throw new SemanticPluginError("no_model");
182
+ try {
183
+ await this.deps.models.resolve(model, ctx.signal);
184
+ }
185
+ catch {
186
+ throw new SemanticPluginError("not_found");
187
+ }
188
+ this.db.setModel(row.id, model);
189
+ return { ok: true, model };
190
+ }
191
+ case "notice.ack":
192
+ this.db.ackNotice(this.notebook(id(input, "notebookId")).id);
193
+ return { ok: true };
194
+ case "sources.upload":
195
+ return this.upload(input);
196
+ case "sources.addFromWorkspace":
197
+ return this.addFromWorkspace(input, ctx);
198
+ case "workspace.list":
199
+ return listDirectory(ctx.workspace, text(input, "dir", 1024, true));
200
+ case "sources.read": {
201
+ const path = text(input, "path", 1024);
202
+ await resolveInside(ctx.workspace, path);
203
+ if (formatOf(path) !== "pdf")
204
+ throw new SemanticPluginError("unsupported_format");
205
+ return { base64: (await readInside(ctx.workspace, path)).toString("base64") };
206
+ }
207
+ case "sources.delete": {
208
+ const source = this.source(id(input, "sourceId"));
209
+ await this.removeVectors(source).catch(() => { });
210
+ this.db.deleteSource(source.id);
211
+ const owner = this.db.notebook(source.notebookId);
212
+ if (owner?.excluded.includes(source.id))
213
+ this.db.setExcluded(owner.id, owner.excluded.filter((x) => x !== source.id));
214
+ return { ok: true };
215
+ }
216
+ case "sources.passage": {
217
+ const source = this.source(id(input, "sourceId"));
218
+ const ordinal = input.ordinal;
219
+ if (!Number.isInteger(ordinal) || ordinal < 0)
220
+ invalid();
221
+ const chunk = this.db.chunk(source.id, ordinal);
222
+ if (!chunk)
223
+ throw new SemanticPluginError("not_found");
224
+ return {
225
+ sourceId: source.id,
226
+ sourceName: source.name,
227
+ ordinal: chunk.ordinal,
228
+ text: chunk.text,
229
+ page: chunk.page,
230
+ ...this.pageLabel(source, chunk.page),
231
+ charStart: chunk.charStart,
232
+ charEnd: chunk.charEnd,
233
+ };
234
+ }
235
+ case "chat.ask":
236
+ return this.ask(input, ctx);
237
+ case "chat.history": {
238
+ const row = this.notebook(id(input, "notebookId"));
239
+ const before = input.before;
240
+ if (before !== undefined && !Number.isInteger(before))
241
+ invalid();
242
+ return this.db
243
+ .messages(row.id, LIMITS.historyMessages, before)
244
+ .map((m) => ({
245
+ id: m.id,
246
+ role: m.role,
247
+ text: m.text,
248
+ createdAt: m.createdAt,
249
+ citations: m.meta.citations ?? [],
250
+ grounded: m.meta.grounded ?? true,
251
+ keywordOnly: m.meta.keywordOnly ?? false,
252
+ notFound: m.meta.notFound ?? false,
253
+ modelFallback: m.meta.modelFallback ?? false,
254
+ }));
255
+ }
256
+ case "chat.clear":
257
+ this.db.clearMessages(this.notebook(id(input, "notebookId")).id);
258
+ return { ok: true };
259
+ case "notebook.guide":
260
+ return this.guide(input, ctx);
261
+ case "sources.summary":
262
+ return this.summary(input, ctx);
263
+ case "index.rebuild":
264
+ return this.rebuild(input);
265
+ case "jobs.get": {
266
+ const jobId = text(input, "jobId", 80);
267
+ const job = this.db.job(jobId);
268
+ if (!job)
269
+ throw new SemanticPluginError("not_found");
270
+ return {
271
+ id: job.id,
272
+ kind: job.kind,
273
+ state: job.state,
274
+ progress: job.progress,
275
+ error: job.error,
276
+ result: job.result,
277
+ };
278
+ }
279
+ case "jobs.cancel": {
280
+ const jobId = text(input, "jobId", 80);
281
+ if (!this.db.job(jobId))
282
+ throw new SemanticPluginError("not_found");
283
+ return { cancelled: this.jobs.cancel(jobId) };
284
+ }
285
+ default:
286
+ throw new SemanticPluginError("not_found");
287
+ }
288
+ }
289
+ /** How the frame labels `page`: a PDF page, a slide, or a sheet with its name (O10). */
290
+ pageLabel(source, page) {
291
+ if (page === null)
292
+ return { pageKind: null };
293
+ if (source.kind === "pptx")
294
+ return { pageKind: "slide" };
295
+ if (source.kind !== "xlsx")
296
+ return { pageKind: "page" };
297
+ // Each sheet's text starts with "Sheet: <name>", so its first chunk carries the name.
298
+ const first = this.db.chunks(source.id).find((c) => c.page === page);
299
+ return {
300
+ pageKind: "sheet",
301
+ pageName: /^Sheet: (.*)$/m.exec(first?.text ?? "")?.[1] ?? String(page),
302
+ };
303
+ }
304
+ // ---- Sources -------------------------------------------------------------------------------
305
+ checkSourceLimit(notebookId) {
306
+ if (this.db.sources(notebookId).length >= LIMITS.sourcesPerNotebook)
307
+ throw new SemanticPluginError("limit_exceeded");
308
+ }
309
+ upload(input) {
310
+ const notebook = this.notebook(id(input, "notebookId"));
311
+ const name = basename(text(input, "name", 255).replaceAll("\\", "/")).trim();
312
+ if (!name)
313
+ invalid();
314
+ let bytes;
315
+ let segments;
316
+ let kind;
317
+ let mime;
318
+ if (Array.isArray(input.pages)) {
319
+ const pages = input.pages;
320
+ if (pages.length > 5000 || !pages.every((p) => typeof p === "string"))
321
+ invalid();
322
+ if (formatOf(name) !== "pdf")
323
+ invalid();
324
+ const strings = pages;
325
+ bytes = Buffer.from(strings.join("\f"));
326
+ if (bytes.length > LIMITS.fileBytes * 2)
327
+ throw new SemanticPluginError("file_too_large");
328
+ kind = "pdf";
329
+ mime = "application/pdf";
330
+ segments = async () => extractPdfPages(strings);
331
+ }
332
+ else {
333
+ const raw = input.base64;
334
+ if (typeof raw === "string" && raw.length > Math.ceil(LIMITS.fileBytes / 3) * 4 + 4)
335
+ throw new SemanticPluginError("file_too_large");
336
+ const base64 = text(input, "base64", 36_000_000);
337
+ bytes = Buffer.from(base64, "base64");
338
+ if (bytes.length > LIMITS.fileBytes)
339
+ throw new SemanticPluginError("file_too_large");
340
+ const format = formatOf(name);
341
+ if (!format || format === "pdf")
342
+ throw new SemanticPluginError("unsupported_format");
343
+ kind = format;
344
+ mime = text(input, "mime", 120, true) || "application/octet-stream";
345
+ segments = () => extractBytes(name, bytes);
346
+ }
347
+ return this.addSource(notebook.id, name, kind, mime, bytes, segments);
348
+ }
349
+ async addFromWorkspace(input, ctx) {
350
+ const notebook = this.notebook(id(input, "notebookId"));
351
+ const path = text(input, "path", 1024);
352
+ const format = formatOf(path);
353
+ // Workspace PDFs go through the frame (sources.read + PDF.js + sources.upload with pages).
354
+ if (!format || format === "pdf") {
355
+ await resolveInside(ctx.workspace, path);
356
+ throw new SemanticPluginError("unsupported_format");
357
+ }
358
+ const bytes = await readInside(ctx.workspace, path);
359
+ const name = basename(path.replaceAll("\\", "/"));
360
+ return this.addSource(notebook.id, name, format, "application/octet-stream", bytes, () => extractBytes(name, bytes));
361
+ }
362
+ addSource(notebookId, name, kind, mime, bytes, segments) {
363
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
364
+ const existing = this.db.sourceBySha(notebookId, sha256);
365
+ if (existing && existing.status !== "failed")
366
+ return { sourceId: existing.id, jobId: null, duplicate: true };
367
+ if (existing)
368
+ this.db.deleteSource(existing.id);
369
+ this.checkSourceLimit(notebookId);
370
+ const sourceId = newId();
371
+ this.db.insertSource({
372
+ id: sourceId,
373
+ notebookId,
374
+ name,
375
+ kind,
376
+ mime,
377
+ sha256,
378
+ bytes: bytes.length,
379
+ status: "indexing",
380
+ });
381
+ this.db.touchNotebook(notebookId);
382
+ const jobId = this.jobs.start(notebookId, "index", sourceId, async ({ signal, progress }) => {
383
+ const extracted = await segments();
384
+ const characters = extracted.reduce((sum, s) => sum + s.text.length, 0);
385
+ if (characters > LIMITS.charsPerSource)
386
+ throw new SemanticPluginError("limit_exceeded");
387
+ const { chunks } = chunkSegments(extracted);
388
+ if (chunks.length === 0)
389
+ throw new SemanticPluginError("no_text");
390
+ if (this.db.notebookChunkCount(notebookId) + chunks.length > LIMITS.chunksPerNotebook)
391
+ throw new SemanticPluginError("limit_exceeded");
392
+ signal.throwIfAborted();
393
+ this.db.replaceChunks(sourceId, chunks);
394
+ progress(0.2);
395
+ return this.indexSource(this.source(sourceId), signal, progress);
396
+ }, {
397
+ onCancel: async () => {
398
+ const source = this.db.source(sourceId);
399
+ if (!source)
400
+ return;
401
+ await this.removeVectors(source).catch(() => { });
402
+ this.db.clearChunks(sourceId);
403
+ this.db.setSourceStatus(sourceId, "failed", "cancelled");
404
+ },
405
+ onFail: (code) => {
406
+ if (!this.db.source(sourceId))
407
+ return;
408
+ this.db.clearChunks(sourceId);
409
+ this.db.setSourceStatus(sourceId, "failed", code);
410
+ },
411
+ });
412
+ return { sourceId, jobId };
413
+ }
414
+ /** Embeds a source's stored chunks (D10: text lives in SQLite, `keepText: false`). */
415
+ async indexSource(source, signal, progress) {
416
+ const semantic = this.semantic();
417
+ if (!semantic) {
418
+ this.db.setSourceStatus(source.id, "ready_keyword");
419
+ return { sourceId: source.id, status: "ready_keyword" };
420
+ }
421
+ const chunks = this.db.chunks(source.id);
422
+ try {
423
+ for (let i = 0; i < chunks.length; i += LIMITS.indexBatch) {
424
+ signal.throwIfAborted();
425
+ await semantic.index(collectionOf(source.notebookId), chunks.slice(i, i + LIMITS.indexBatch).map((c) => ({
426
+ id: vectorId(source.id, c.ordinal),
427
+ text: c.text,
428
+ metadata: { source: source.id, ordinal: c.ordinal },
429
+ keepText: false,
430
+ })), { signal });
431
+ progress(0.2 + (0.8 * Math.min(chunks.length, i + LIMITS.indexBatch)) / chunks.length);
432
+ }
433
+ }
434
+ catch (error) {
435
+ const code = errorCode(error);
436
+ if (code === "cancelled" || signal.aborted)
437
+ throw error;
438
+ // The keyword index is complete; vectors are missing (engine down or model changed).
439
+ this.db.setSourceStatus(source.id, "ready_keyword", code === "model_mismatch" ? "model_mismatch" : "engine_unavailable");
440
+ return { sourceId: source.id, status: "ready_keyword" };
441
+ }
442
+ this.db.setSourceStatus(source.id, "ready");
443
+ return { sourceId: source.id, status: "ready" };
444
+ }
445
+ async removeVectors(source) {
446
+ const semantic = this.semantic();
447
+ if (!semantic)
448
+ return;
449
+ const ids = this.db.chunks(source.id).map((c) => vectorId(source.id, c.ordinal));
450
+ for (let i = 0; i < ids.length; i += 500)
451
+ await semantic.remove(collectionOf(source.notebookId), ids.slice(i, i + 500));
452
+ }
453
+ rebuild(input) {
454
+ const notebook = this.notebook(id(input, "notebookId"));
455
+ if (!this.semantic())
456
+ throw new SemanticPluginError("engine_unavailable");
457
+ const jobId = this.jobs.start(notebook.id, "rebuild", null, async ({ signal, progress }) => {
458
+ const semantic = this.semantic();
459
+ if (!semantic)
460
+ throw new SemanticPluginError("engine_unavailable");
461
+ await semantic.drop(collectionOf(notebook.id), { signal });
462
+ const sources = this.db.sources(notebook.id).filter((s) => s.chunkCount > 0);
463
+ let done = 0;
464
+ for (const source of sources) {
465
+ const result = await this.indexSource(source, signal, () => { });
466
+ if (result.status !== "ready")
467
+ throw new SemanticPluginError("engine_unavailable");
468
+ progress(++done / Math.max(1, sources.length));
469
+ }
470
+ return { sources: sources.length };
471
+ });
472
+ return { jobId };
473
+ }
474
+ // ---- Retrieval and answers -----------------------------------------------------------------
475
+ /** Hybrid retrieval (D9): vector + FTS5, RRF, top 8; keyword-only when vectors are unavailable. */
476
+ async retrieve(notebookId, query, options) {
477
+ const scope = options.sourceIds?.length ? new Set(options.sourceIds) : undefined;
478
+ let vector = [];
479
+ let keywordOnly = true;
480
+ const semantic = this.semantic();
481
+ if (semantic) {
482
+ const single = scope?.size === 1 ? [...scope][0] : undefined;
483
+ const matches = await semantic.trySearch(collectionOf(notebookId), query.slice(0, 32_000), {
484
+ topK: scope && !single ? 100 : RETRIEVE_EACH,
485
+ ...(single ? { filter: { source: single } } : {}),
486
+ signal: options.signal,
487
+ });
488
+ if (matches) {
489
+ keywordOnly = false;
490
+ vector = matches
491
+ .map((m) => m.id)
492
+ .filter((key) => !scope || scope.has(key.split(":")[0]))
493
+ .slice(0, RETRIEVE_EACH);
494
+ }
495
+ }
496
+ const keyword = this.db.keywordSearch(notebookId, ftsQuery(query), RETRIEVE_EACH, options.sourceIds);
497
+ const fused = reciprocalRankFusion([vector, keyword], { top: options.keep ?? RETRIEVE_KEEP });
498
+ const names = new Map(this.db.sources(notebookId).map((s) => [s.id, s.name]));
499
+ const passages = [];
500
+ for (const key of fused) {
501
+ const [sourceId, ordinal] = key.split(":");
502
+ const chunk = sourceId ? this.db.chunk(sourceId, Number(ordinal)) : undefined;
503
+ if (!chunk || !names.has(chunk.sourceId))
504
+ continue;
505
+ passages.push({
506
+ n: passages.length + 1,
507
+ sourceId: chunk.sourceId,
508
+ sourceName: names.get(chunk.sourceId),
509
+ ordinal: chunk.ordinal,
510
+ page: chunk.page,
511
+ charStart: chunk.charStart,
512
+ charEnd: chunk.charEnd,
513
+ text: chunk.text,
514
+ });
515
+ }
516
+ return { passages, keywordOnly };
517
+ }
518
+ /** Configured models (credential-free) and Alisio's active one; empty when none is connected. */
519
+ async modelList(ctx) {
520
+ const active = (() => {
521
+ try {
522
+ return this.deps.activeModel?.(ctx.sessionId) || null;
523
+ }
524
+ catch {
525
+ return null;
526
+ }
527
+ })();
528
+ if (!this.deps.models)
529
+ return { models: [], active, available: true };
530
+ let list = [];
531
+ try {
532
+ list = await this.deps.models.list(ctx.signal);
533
+ }
534
+ catch {
535
+ list = [];
536
+ }
537
+ const models = list.slice(0, 1000).map((m) => ({
538
+ reference: m.reference,
539
+ name: m.model?.name || m.model?.id || m.reference,
540
+ provider: m.providerName || m.provider,
541
+ }));
542
+ return { models, active, available: models.length > 0 };
543
+ }
544
+ /** Identity of the indexed sources a guide was built from: a new or removed source outdates it. */
545
+ sourceSet(notebookId) {
546
+ return this.db
547
+ .sources(notebookId)
548
+ .filter((s) => s.chunkCount > 0)
549
+ .map((s) => s.id)
550
+ .sort()
551
+ .join(",");
552
+ }
553
+ /** The model that actually answers: the notebook's choice, else Alisio's active model. */
554
+ modelName(model, ctx) {
555
+ if (model)
556
+ return model;
557
+ try {
558
+ return this.deps.activeModel?.(ctx.sessionId) ?? null;
559
+ }
560
+ catch {
561
+ return null;
562
+ }
563
+ }
564
+ /** WHEN no model is configured, asking is refused before any job starts (O12). */
565
+ async requireModel(ctx) {
566
+ if (!this.deps.models)
567
+ return;
568
+ const { available } = await this.modelList(ctx);
569
+ if (!available)
570
+ throw new SemanticPluginError("no_model");
571
+ }
572
+ /**
573
+ * The notebook's chosen model, resolved before each call. A model that no longer exists falls
574
+ * back to Alisio's active model (the choice is cleared) and the answer says so.
575
+ */
576
+ async modelFor(notebookId, signal) {
577
+ const chosen = this.db.notebook(notebookId)?.model;
578
+ if (!chosen || !this.deps.models)
579
+ return { model: undefined, fallback: false };
580
+ try {
581
+ await this.deps.models.resolve(chosen, signal);
582
+ return { model: chosen, fallback: false };
583
+ }
584
+ catch {
585
+ if (signal.aborted)
586
+ throw new SemanticPluginError("cancelled");
587
+ this.db.setModel(notebookId, null);
588
+ return { model: undefined, fallback: true };
589
+ }
590
+ }
591
+ async ask(input, ctx) {
592
+ const notebook = this.notebook(id(input, "notebookId"));
593
+ const question = text(input, "question", LIMITS.questionChars).trim();
594
+ if (!question)
595
+ invalid();
596
+ const sourceIds = ids(input, "sourceIds");
597
+ const locale = localeOf(input);
598
+ await this.requireModel(ctx);
599
+ const jobId = this.jobs.start(notebook.id, "ask", null, async ({ signal, progress }) => {
600
+ const history = this.db
601
+ .messages(notebook.id, 6)
602
+ .map((m) => ({ role: m.role, text: m.text }));
603
+ const { passages, keywordOnly } = await this.retrieve(notebook.id, question, {
604
+ sourceIds,
605
+ signal,
606
+ });
607
+ progress(0.3);
608
+ this.db.addMessage(notebook.id, "user", question);
609
+ if (passages.length === 0) {
610
+ const messageId = this.db.addMessage(notebook.id, "assistant", NOT_FOUND[locale], {
611
+ citations: [],
612
+ grounded: false,
613
+ keywordOnly,
614
+ notFound: true,
615
+ });
616
+ return { messageId, keywordOnly, grounded: false, notFound: true };
617
+ }
618
+ const prompt = buildPrompt(question, passages, history, locale);
619
+ const { model, fallback } = await this.modelFor(notebook.id, signal);
620
+ let answer;
621
+ try {
622
+ answer = await this.deps.complete({
623
+ ...prompt,
624
+ maxTokens: 1500,
625
+ sessionId: ctx.sessionId,
626
+ ...(model ? { model } : {}),
627
+ signal,
628
+ });
629
+ }
630
+ catch (error) {
631
+ if (signal.aborted)
632
+ throw error;
633
+ throw new SemanticPluginError("answer_failed");
634
+ }
635
+ const parsed = parseCitations(answer, passages);
636
+ const messageId = this.db.addMessage(notebook.id, "assistant", parsed.text, {
637
+ citations: parsed.citations,
638
+ grounded: parsed.grounded,
639
+ keywordOnly,
640
+ ...(fallback ? { modelFallback: true } : {}),
641
+ });
642
+ this.db.touchNotebook(notebook.id);
643
+ return { messageId, keywordOnly, grounded: parsed.grounded, modelFallback: fallback };
644
+ });
645
+ return { jobId };
646
+ }
647
+ /** Round-robin first passages of each source: an overview needs breadth, not one query. */
648
+ overviewPassages(sources, keep) {
649
+ const lists = sources.map((s) => this.db
650
+ .chunks(s.id)
651
+ .slice(0, keep)
652
+ .map((c) => ({ s, c })));
653
+ const picked = [];
654
+ for (let round = 0; picked.length < keep; round++) {
655
+ let any = false;
656
+ for (const list of lists) {
657
+ const item = list[round];
658
+ if (!item || picked.length >= keep)
659
+ continue;
660
+ any = true;
661
+ picked.push({
662
+ n: picked.length + 1,
663
+ sourceId: item.s.id,
664
+ sourceName: item.s.name,
665
+ ordinal: item.c.ordinal,
666
+ page: item.c.page,
667
+ charStart: item.c.charStart,
668
+ charEnd: item.c.charEnd,
669
+ text: item.c.text,
670
+ });
671
+ }
672
+ if (!any)
673
+ break;
674
+ }
675
+ return picked;
676
+ }
677
+ async completeWith(passages, instruction, ctx, signal, locale, notebookId) {
678
+ const prompt = buildPrompt("", passages, [], locale, instruction);
679
+ const { model } = await this.modelFor(notebookId, signal);
680
+ try {
681
+ const answer = await this.deps.complete({
682
+ ...prompt,
683
+ maxTokens: 1500,
684
+ sessionId: ctx.sessionId,
685
+ ...(model ? { model } : {}),
686
+ signal,
687
+ });
688
+ return { answer, model: this.modelName(model, ctx) };
689
+ }
690
+ catch (error) {
691
+ if (signal.aborted)
692
+ throw error;
693
+ throw new SemanticPluginError("answer_failed");
694
+ }
695
+ }
696
+ async guide(input, ctx) {
697
+ const notebook = this.notebook(id(input, "notebookId"));
698
+ const locale = localeOf(input);
699
+ await this.requireModel(ctx);
700
+ const jobId = this.jobs.start(notebook.id, "guide", null, async ({ signal }) => {
701
+ const passages = this.overviewPassages(this.db.sources(notebook.id).filter((s) => s.chunkCount > 0), RETRIEVE_KEEP);
702
+ if (passages.length === 0)
703
+ return { summary: NOT_FOUND[locale], questions: [], citations: [], grounded: false };
704
+ const built = this.sourceSet(notebook.id);
705
+ const { answer, model } = await this.completeWith(passages, GUIDE_INSTRUCTION, ctx, signal, locale, notebook.id);
706
+ const questions = [...answer.matchAll(/^\s*Q:\s*(.+)$/gm)]
707
+ .map((m) => m[1].trim())
708
+ .slice(0, 3);
709
+ const summaryText = answer
710
+ .replace(/^\s*Q:.*$/gm, "")
711
+ .replace(/^\s*Summary:\s*/im, "")
712
+ .trim();
713
+ const parsed = parseCitations(summaryText, passages);
714
+ const guide = {
715
+ summary: parsed.text,
716
+ questions,
717
+ citations: parsed.citations,
718
+ grounded: parsed.grounded,
719
+ model,
720
+ createdAt: Date.now(),
721
+ sources: built,
722
+ };
723
+ // Persisted (O15): shown again on re-entry and after restarts; never regenerated by itself.
724
+ this.db.setGuide(notebook.id, guide);
725
+ return { ...guide, outdated: false };
726
+ });
727
+ return { jobId };
728
+ }
729
+ async summary(input, ctx) {
730
+ const source = this.source(id(input, "sourceId"));
731
+ const locale = localeOf(input);
732
+ await this.requireModel(ctx);
733
+ const jobId = this.jobs.start(source.notebookId, "summary", source.id, async ({ signal }) => {
734
+ const passages = this.overviewPassages([source], RETRIEVE_KEEP);
735
+ if (passages.length === 0)
736
+ throw new SemanticPluginError("no_text");
737
+ const { answer, model } = await this.completeWith(passages, SUMMARY_INSTRUCTION, ctx, signal, locale, source.notebookId);
738
+ const parsed = parseCitations(answer, passages);
739
+ const summary = {
740
+ summary: parsed.text,
741
+ citations: parsed.citations,
742
+ grounded: parsed.grounded,
743
+ model,
744
+ createdAt: Date.now(),
745
+ };
746
+ this.db.setSourceSummary(source.id, summary);
747
+ return { sourceId: source.id, ...summary };
748
+ });
749
+ return { jobId };
750
+ }
751
+ }
752
+ //# sourceMappingURL=service.js.map