tinywebui 0.1.3 → 0.1.5

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 (82) hide show
  1. package/README.md +20 -13
  2. package/bin/tinywebui.js +69 -63
  3. package/package.json +25 -8
  4. package/public/app.js +40 -14
  5. package/public/chat/composer.js +1 -1
  6. package/public/chat/sidebar.js +8 -5
  7. package/public/index.html +88 -49
  8. package/public/panels/admin.js +40 -1
  9. package/public/panels/model-editor.js +336 -0
  10. package/public/panels/panels.js +37 -12
  11. package/public/panels/settings-home.js +125 -0
  12. package/public/panels/settings.js +124 -36
  13. package/public/shared/attachment_note.js +7 -1
  14. package/public/shared/events.js +1 -0
  15. package/public/shared/features.js +1 -0
  16. package/public/shared/origins.js +1 -0
  17. package/public/shared/usage.js +16 -1
  18. package/public/styles.css +83 -20
  19. package/src/access/auth.js +155 -155
  20. package/src/access/auth_gate.js +39 -38
  21. package/src/access/email.js +17 -0
  22. package/src/access/policy.js +123 -138
  23. package/src/audit.js +1 -1
  24. package/src/automations/automation.js +135 -123
  25. package/src/automations/scheduler.js +138 -127
  26. package/src/chat/compact.js +149 -156
  27. package/src/chat/llm.js +380 -382
  28. package/src/chat/runs.js +162 -151
  29. package/src/chat/tool_executor.js +229 -218
  30. package/src/cli.js +535 -433
  31. package/src/config/approval.js +35 -29
  32. package/src/config/config.js +546 -451
  33. package/src/config/data_dir.js +24 -0
  34. package/src/config/models.js +191 -115
  35. package/src/config/refs.js +32 -0
  36. package/src/config/secrets.js +153 -0
  37. package/src/files/documents.js +57 -68
  38. package/src/files/images.js +30 -31
  39. package/src/harness/agent.js +60 -60
  40. package/src/harness/attribution.js +66 -58
  41. package/src/harness/checkpoint.js +67 -62
  42. package/src/harness/history.js +28 -23
  43. package/src/harness/prompt.js +40 -57
  44. package/src/harness/provider.js +284 -283
  45. package/src/harness/types.js +1 -0
  46. package/src/http.js +98 -66
  47. package/src/log.js +11 -12
  48. package/src/mcp.js +398 -374
  49. package/src/retrieval/embedding.js +228 -240
  50. package/src/retrieval/retrieval.js +182 -173
  51. package/src/routes/admin.js +178 -138
  52. package/src/routes/auth.js +184 -100
  53. package/src/routes/automations.js +99 -82
  54. package/src/routes/chats.js +240 -218
  55. package/src/routes/documents.js +130 -120
  56. package/src/routes/library.js +43 -46
  57. package/src/routes/mcp.js +49 -49
  58. package/src/routes/settings.js +127 -122
  59. package/src/routes/types.js +1 -0
  60. package/src/server.js +428 -392
  61. package/src/store/automations.js +106 -106
  62. package/src/store/chats.js +217 -242
  63. package/src/store/documents.js +181 -185
  64. package/src/store/embeddings.js +52 -54
  65. package/src/store/index.js +95 -49
  66. package/src/store/messages.js +55 -80
  67. package/src/store/schema.js +128 -107
  68. package/src/store/scope.js +18 -17
  69. package/src/store/search.js +25 -27
  70. package/src/store/turns.js +53 -53
  71. package/src/store/usage.js +343 -269
  72. package/src/store/users.js +162 -87
  73. package/src/store/wire.js +50 -41
  74. package/src/tools/ask_tool.js +68 -68
  75. package/src/tools/chat_search_tool.js +38 -44
  76. package/src/tools/context_tool.js +131 -138
  77. package/src/tools/document_tool.js +73 -78
  78. package/src/tools/mcp_tool.js +274 -232
  79. package/src/tools/task_tool.js +53 -50
  80. package/src/tools/types.js +1 -0
  81. package/public/panels/connectors.js +0 -184
  82. package/src/set-password.js +0 -43
@@ -1,6 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { CORPUS } from '../store/index.js';
3
-
4
3
  /**
5
4
  * Search for everything the model can look things up in: lexical (FTS5 BM25,
6
5
  * the default), dense (local embeddings), or hybrid (both, fused). One engine,
@@ -34,17 +33,17 @@ import { CORPUS } from '../store/index.js';
34
33
  * Vectors are computed once per unit, stored in SQLite (`embeddings`), and
35
34
  * reused by every query; only the query itself is embedded at search time.
36
35
  */
37
-
38
36
  export const RETRIEVAL_MODES = ['lexical', 'dense', 'hybrid'];
39
-
40
37
  const cosine = (a, b) => {
41
- let dot = 0, na = 0, nb = 0;
42
- for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
43
- return na && nb ? dot / Math.sqrt(na * nb) : 0;
38
+ let dot = 0, na = 0, nb = 0;
39
+ for (let i = 0; i < a.length; i++) {
40
+ dot += a[i] * b[i];
41
+ na += a[i] * a[i];
42
+ nb += b[i] * b[i];
43
+ }
44
+ return na && nb ? dot / Math.sqrt(na * nb) : 0;
44
45
  };
45
-
46
46
  const digestOf = (text) => createHash('sha256').update(text).digest('hex').slice(0, 32);
47
-
48
47
  /**
49
48
  * 1-based competition ranks by descending score: equal scores share a rank
50
49
  * (1, 2, 2, 4). One deliberate departure from TinySearch's _rank_by_score,
@@ -53,81 +52,78 @@ const digestOf = (text) => createHash('sha256').update(text).digest('hex').slice
53
52
  * side that cannot tell candidates apart stay neutral.
54
53
  */
55
54
  function ranksOf(scores) {
56
- const order = scores.map((s, i) => i).sort((a, b) => scores[b] - scores[a]);
57
- const ranks = new Array(scores.length);
58
- order.forEach((idx, r) => {
59
- const prev = order[r - 1];
60
- ranks[idx] = r > 0 && scores[prev] === scores[idx] ? ranks[prev] : r + 1;
61
- });
62
- return ranks;
55
+ const order = scores.map((s, i) => i).sort((a, b) => scores[b] - scores[a]);
56
+ const ranks = new Array(scores.length);
57
+ order.forEach((idx, r) => {
58
+ const prev = order[r - 1];
59
+ ranks[idx] = r > 0 && scores[prev] === scores[idx] ? ranks[prev] : r + 1;
60
+ });
61
+ return ranks;
63
62
  }
64
-
65
63
  /**
66
64
  * Weighted RRF over parallel score lists. Returns candidate indices, best
67
65
  * first, with the scores that decided them.
68
66
  */
69
67
  export function fuse(bm25Scores, denseScores, { denseWeight = 0.5, k = 60 } = {}) {
70
- const sparseWeight = 1 - denseWeight;
71
- const bm25Ranks = ranksOf(bm25Scores);
72
- const denseRanks = ranksOf(denseScores);
73
- const rows = bm25Scores.map((_, i) => ({
74
- index: i,
75
- bm25: bm25Scores[i],
76
- dense: denseScores[i],
77
- rrf: (sparseWeight > 0 ? sparseWeight / (k + bm25Ranks[i]) : 0) + (denseWeight > 0 ? denseWeight / (k + denseRanks[i]) : 0)
78
- }));
79
- rows.sort((a, b) => b.rrf - a.rrf || b.dense - a.dense || b.bm25 - a.bm25 || a.index - b.index);
80
- return rows;
68
+ const sparseWeight = 1 - denseWeight;
69
+ const bm25Ranks = ranksOf(bm25Scores);
70
+ const denseRanks = ranksOf(denseScores);
71
+ const rows = bm25Scores.map((_, i) => ({
72
+ index: i,
73
+ bm25: bm25Scores[i],
74
+ dense: denseScores[i],
75
+ rrf: (sparseWeight > 0 ? sparseWeight / (k + bm25Ranks[i]) : 0) + (denseWeight > 0 ? denseWeight / (k + denseRanks[i]) : 0)
76
+ }));
77
+ rows.sort((a, b) => b.rrf - a.rrf || b.dense - a.dense || b.bm25 - a.bm25 || a.index - b.index);
78
+ return rows;
81
79
  }
82
-
83
80
  /** Attached documents: one unit per passage. Scope: { docId }. */
84
81
  export function documentsCorpus(store) {
85
- return {
86
- units: (docId) => store.documents.passages(docId).map((p) => ({ id: p.rowid, text: p.body })),
87
- ownersMissing: (key) => store.documents.missingVectors(key),
88
- ownersOf: ({ docId }) => [docId],
89
- candidates: ({ docId }) => store.documents.passages(docId).map((p) => p.rowid),
90
- lexical: ({ docId }, query, { any }) => store.documents.lexicalScores(docId, query, { any }),
91
- hydrate(hits, { docId }) {
92
- const byId = new Map(store.documents.passages(docId).map((p) => [p.rowid, p]));
93
- return hits.map((h) => {
94
- const p = byId.get(h.id);
95
- return { passageIdx: p.passageIdx, charStart: p.charStart, body: p.body, rank: h.rank, dense: h.dense, bm25: h.bm25 };
96
- });
97
- },
98
- maintain(engine, log) {
99
- const { passageSize, passageOverlap } = engine.passageSettings();
100
- const stale = store.documents.withOtherPassages(passageSize, passageOverlap);
101
- for (const id of stale) store.documents.repassage(id, passageSize, passageOverlap);
102
- if (stale.length) log(`re-split ${stale.length} document(s) into ${passageSize}-character passages`);
103
- }
104
- };
82
+ return {
83
+ units: (docId) => store.documents.passages(docId).map((p) => ({ id: p.rowid, text: p.body })),
84
+ ownersMissing: (key) => store.documents.missingVectors(key),
85
+ ownersOf: ({ docId }) => [docId],
86
+ candidates: ({ docId }) => store.documents.passages(docId).map((p) => p.rowid),
87
+ lexical: ({ docId }, query, { any }) => store.documents.lexicalScores(docId, query, { any }),
88
+ hydrate(hits, { docId }) {
89
+ const byId = new Map(store.documents.passages(docId).map((p) => [p.rowid, p]));
90
+ return hits.map((h) => {
91
+ const p = byId.get(h.id);
92
+ return { passageIdx: p.passageIdx, charStart: p.charStart, body: p.body, rank: h.rank, dense: h.dense, bm25: h.bm25 };
93
+ });
94
+ },
95
+ maintain(engine, log) {
96
+ const { passageSize, passageOverlap } = engine.passageSettings();
97
+ const stale = store.documents.withOtherPassages(passageSize, passageOverlap);
98
+ for (const id of stale)
99
+ store.documents.repassage(id, passageSize, passageOverlap);
100
+ if (stale.length)
101
+ log(`re-split ${stale.length} document(s) into ${passageSize}-character passages`);
102
+ }
103
+ };
105
104
  }
106
-
107
105
  // What of a turn gets embedded: the question and the start of the answer.
108
106
  const TURN_QUESTION_CHARS = 2000;
109
107
  const TURN_ANSWER_CHARS = 6000;
110
-
111
108
  /**
112
109
  * Past conversations: one unit per turn -- a question and the answer it
113
110
  * finally got. Scope: { userId, excludeChatId }. A turn's answer can still
114
111
  * change (the run was going when it was last embedded), so it is mutable.
115
112
  */
116
113
  export function chatsCorpus(store) {
117
- const turnText = (t) => `${t.question.slice(0, TURN_QUESTION_CHARS)}\n\n${t.answer.slice(0, TURN_ANSWER_CHARS)}`.trim();
118
- return {
119
- mutable: true,
120
- units: (chatId) => store.turns.forChat(chatId).map((t) => ({ id: t.id, text: turnText(t) })).filter((u) => u.text),
121
- ownersMissing: (key) => store.turns.chatsMissingVectors(key),
122
- candidates: ({ userId, excludeChatId }) => store.turns.ids(userId, { excludeChatId }),
123
- lexical: ({ userId, excludeChatId }, query, { any }) => store.turns.lexicalScores(userId, query, { excludeChatId, any }),
124
- hydrate(hits) {
125
- const turns = store.turns.byIds(hits.map((h) => h.id));
126
- return hits.filter((h) => turns.has(h.id)).map((h) => ({ ...turns.get(h.id), rank: h.rank, dense: h.dense, bm25: h.bm25 }));
127
- }
128
- };
114
+ const turnText = (t) => `${t.question.slice(0, TURN_QUESTION_CHARS)}\n\n${t.answer.slice(0, TURN_ANSWER_CHARS)}`.trim();
115
+ return {
116
+ mutable: true,
117
+ units: (chatId) => store.turns.forChat(chatId).map((t) => ({ id: t.id, text: turnText(t) })).filter((u) => u.text),
118
+ ownersMissing: (key) => store.turns.chatsMissingVectors(key),
119
+ candidates: ({ userId, excludeChatId }) => store.turns.ids(userId, { excludeChatId }),
120
+ lexical: ({ userId, excludeChatId }, query, { any }) => store.turns.lexicalScores(userId, query, { excludeChatId, any }),
121
+ hydrate(hits) {
122
+ const turns = store.turns.byIds(hits.map((h) => h.id));
123
+ return hits.filter((h) => turns.has(h.id)).map((h) => ({ ...turns.get(h.id), rank: h.rank, dense: h.dense, bm25: h.bm25 }));
124
+ }
125
+ };
129
126
  }
130
-
131
127
  /**
132
128
  * The retrieval engine for one running instance. `embedder` is null in
133
129
  * lexical mode; otherwise it is what loadEmbedder() returned (or a stand-in
@@ -135,116 +131,129 @@ export function chatsCorpus(store) {
135
131
  * Documents and chats are registered; `register` adds more.
136
132
  */
137
133
  export class Retrieval {
138
- constructor(store, retrieval, embedder = null) {
139
- this.store = store;
140
- this.cfg = retrieval;
141
- // 'auto' means whatever the embedder allows (resolveRetrieval decides at startup).
142
- const mode = retrieval.mode || 'lexical';
143
- this.mode = mode === 'auto' ? (embedder ? 'hybrid' : 'lexical') : mode;
144
- this.embedder = this.mode === 'lexical' ? null : embedder;
145
- if (this.mode !== 'lexical' && !this.embedder) throw new Error(`retrieval.mode '${this.mode}' needs an embedding model`);
146
- this.corpora = new Map();
147
- this.pending = new Map(); // "corpus:owner" -> in-flight ingestion, so a query can wait for it
148
- this.register(CORPUS.DOCUMENTS, documentsCorpus(store));
149
- this.register(CORPUS.CHATS, chatsCorpus(store));
150
- }
151
-
152
- register(name, corpus) {
153
- this.corpora.set(name, corpus);
154
- return this;
155
- }
156
-
157
- #corpus(name) {
158
- const c = this.corpora.get(name);
159
- if (!c) throw new Error(`retrieval: no corpus "${name}"`);
160
- return c;
161
- }
162
-
163
- /**
164
- * Embeds whatever of one owner's units has no vectors for the current model
165
- * (or, for a mutable corpus, vectors of text that has since changed), and
166
- * drops vectors of units the owner no longer has. Idempotent: an owner that
167
- * is up to date costs nothing to ingest again.
168
- */
169
- ingest(name, ownerId) {
170
- if (!this.embedder) return Promise.resolve(0);
171
- const corpus = this.#corpus(name);
172
- const tag = `${name}:${ownerId}`;
173
- if (this.pending.has(tag)) return this.pending.get(tag);
174
- const job = (async () => {
175
- const key = this.embedder.key;
176
- const units = corpus.units(ownerId).map((u) => ({ ...u, digest: corpus.mutable ? digestOf(u.text) : '' }));
177
- const have = this.store.embeddings.digests(name, ownerId, key);
178
- if ([...have.keys()].some((id) => !units.some((u) => u.id === id))) this.store.embeddings.dropStale(name, ownerId, units.map((u) => u.id));
179
- const missing = units.filter((u) => have.get(u.id) !== u.digest);
180
- if (!missing.length) return 0;
181
- const prefix = this.cfg.documentPrefix || '';
182
- const texts = missing.map((u) => prefix + u.text);
183
- const vecs = this.embedder.embedPassages
184
- ? await this.embedder.embedPassages(texts)
185
- : (await this.embedder.embed(texts)).map((v) => [v]);
186
- // The owner may have been deleted while it was being embedded.
187
- const still = new Set(corpus.units(ownerId).map((u) => u.id));
188
- const rows = missing.map((u, i) => ({ unitId: u.id, digest: u.digest, vecs: vecs[i] })).filter((r) => still.has(r.unitId));
189
- if (rows.length) this.store.embeddings.put(name, ownerId, key, rows);
190
- return rows.length;
191
- })().finally(() => this.pending.delete(tag));
192
- this.pending.set(tag, job);
193
- return job;
194
- }
195
-
196
- /**
197
- * Brings every corpus up to the current settings: each corpus's own upkeep
198
- * (every mode), then embeds whatever the current model has no vectors for,
199
- * dropping vectors from old models.
200
- */
201
- async backfill(log = () => {}) {
202
- for (const corpus of this.corpora.values()) corpus.maintain?.(this, log);
203
- if (!this.embedder) return 0;
204
- const pruned = this.store.embeddings.prune(this.embedder.key);
205
- if (pruned) log(`dropped ${pruned} chunk vector(s) from a previous model or chunking`);
206
- let total = 0;
207
- for (const [name, corpus] of this.corpora) {
208
- let n = 0;
209
- for (const owner of corpus.ownersMissing(this.embedder.key)) n += await this.ingest(name, owner);
210
- if (n) log(`embedded ${n} ${name} unit(s) for ${this.mode} retrieval`);
211
- total += n;
134
+ store;
135
+ cfg;
136
+ mode;
137
+ embedder;
138
+ corpora;
139
+ pending;
140
+ constructor(store, retrieval, embedder = null) {
141
+ this.store = store;
142
+ this.cfg = retrieval;
143
+ // 'auto' means whatever the embedder allows (resolveRetrieval decides at startup).
144
+ const mode = retrieval.mode || 'lexical';
145
+ this.mode = mode === 'auto' ? (embedder ? 'hybrid' : 'lexical') : mode;
146
+ this.embedder = this.mode === 'lexical' ? null : embedder;
147
+ if (this.mode !== 'lexical' && !this.embedder)
148
+ throw new Error(`retrieval.mode '${this.mode}' needs an embedding model`);
149
+ this.corpora = new Map();
150
+ this.pending = new Map(); // "corpus:owner" -> in-flight ingestion, so a query can wait for it
151
+ this.register(CORPUS.DOCUMENTS, documentsCorpus(store));
152
+ this.register(CORPUS.CHATS, chatsCorpus(store));
153
+ }
154
+ register(name, corpus) {
155
+ this.corpora.set(name, corpus);
156
+ return this;
157
+ }
158
+ #corpus(name) {
159
+ const c = this.corpora.get(name);
160
+ if (!c)
161
+ throw new Error(`retrieval: no corpus "${name}"`);
162
+ return c;
163
+ }
164
+ /**
165
+ * Embeds whatever of one owner's units has no vectors for the current model
166
+ * (or, for a mutable corpus, vectors of text that has since changed), and
167
+ * drops vectors of units the owner no longer has. Idempotent: an owner that
168
+ * is up to date costs nothing to ingest again.
169
+ */
170
+ ingest(name, ownerId) {
171
+ const { embedder } = this;
172
+ if (!embedder)
173
+ return Promise.resolve(0);
174
+ const corpus = this.#corpus(name);
175
+ const tag = `${name}:${ownerId}`;
176
+ if (this.pending.has(tag))
177
+ return this.pending.get(tag);
178
+ const job = (async () => {
179
+ const key = embedder.key;
180
+ const units = corpus.units(ownerId).map((u) => ({ ...u, digest: corpus.mutable ? digestOf(u.text) : '' }));
181
+ const have = this.store.embeddings.digests(name, ownerId, key);
182
+ if ([...have.keys()].some((id) => !units.some((u) => u.id === id)))
183
+ this.store.embeddings.dropStale(name, ownerId, units.map((u) => u.id));
184
+ const missing = units.filter((u) => have.get(u.id) !== u.digest);
185
+ if (!missing.length)
186
+ return 0;
187
+ const prefix = this.cfg.documentPrefix || '';
188
+ const texts = missing.map((u) => prefix + u.text);
189
+ const vecs = embedder.embedPassages
190
+ ? await embedder.embedPassages(texts)
191
+ : (await embedder.embed(texts)).map((v) => [v]);
192
+ // The owner may have been deleted while it was being embedded.
193
+ const still = new Set(corpus.units(ownerId).map((u) => u.id));
194
+ const rows = missing.map((u, i) => ({ unitId: u.id, digest: u.digest, vecs: vecs[i] })).filter((r) => still.has(r.unitId));
195
+ if (rows.length)
196
+ this.store.embeddings.put(name, ownerId, key, rows);
197
+ return rows.length;
198
+ })().finally(() => this.pending.delete(tag));
199
+ this.pending.set(tag, job);
200
+ return job;
201
+ }
202
+ /**
203
+ * Brings every corpus up to the current settings: each corpus's own upkeep
204
+ * (every mode), then embeds whatever the current model has no vectors for,
205
+ * dropping vectors from old models.
206
+ */
207
+ async backfill(log = () => { }) {
208
+ for (const corpus of this.corpora.values())
209
+ corpus.maintain?.(this, log);
210
+ if (!this.embedder)
211
+ return 0;
212
+ const pruned = this.store.embeddings.prune(this.embedder.key);
213
+ if (pruned)
214
+ log(`dropped ${pruned} chunk vector(s) from a previous model or chunking`);
215
+ let total = 0;
216
+ for (const [name, corpus] of this.corpora) {
217
+ let n = 0;
218
+ for (const owner of corpus.ownersMissing(this.embedder.key))
219
+ n += await this.ingest(name, owner);
220
+ if (n)
221
+ log(`embedded ${n} ${name} unit(s) for ${this.mode} retrieval`);
222
+ total += n;
223
+ }
224
+ return total;
225
+ }
226
+ /** The passage split new documents get. */
227
+ passageSettings() {
228
+ return { passageSize: this.cfg.passageSize ?? 1800, passageOverlap: this.cfg.passageOverlap ?? 200 };
212
229
  }
213
- return total;
214
- }
215
-
216
- /** The passage split new documents get. */
217
- passageSettings() {
218
- return { passageSize: this.cfg.passageSize ?? 1800, passageOverlap: this.cfg.passageOverlap ?? 200 };
219
- }
220
-
221
- /**
222
- * The best units of a corpus for a query, as that corpus hydrates them,
223
- * each with the scores that ranked it. Same call in every mode.
224
- */
225
- async search(name, scope, query, limit = 5) {
226
- const corpus = this.#corpus(name);
227
- if (!String(query ?? '').trim()) return [];
228
- if (this.mode === 'lexical') {
229
- // Every query word must match, like a search box; best BM25 first.
230
- const hits = [...corpus.lexical(scope, query, { any: false })]
231
- .sort((a, b) => b[1] - a[1])
232
- .slice(0, limit)
233
- .map(([id, s]) => ({ id, rank: s, dense: null, bm25: s }));
234
- return corpus.hydrate(hits, scope);
230
+ async search(name, scope, query, limit = 5) {
231
+ const corpus = this.#corpus(name);
232
+ if (!String(query ?? '').trim())
233
+ return [];
234
+ if (this.mode === 'lexical') {
235
+ // Every query word must match, like a search box; best BM25 first.
236
+ const hits = [...corpus.lexical(scope, query, { any: false })]
237
+ .sort((a, b) => b[1] - a[1])
238
+ .slice(0, limit)
239
+ .map(([id, s]) => ({ id, rank: s, dense: null, bm25: s }));
240
+ return corpus.hydrate(hits, scope);
241
+ }
242
+ for (const owner of corpus.ownersOf?.(scope) || [])
243
+ await this.ingest(name, owner);
244
+ const ids = corpus.candidates(scope);
245
+ if (!ids.length)
246
+ return [];
247
+ const embedder = this.embedder;
248
+ const vectors = this.store.embeddings.vectors(name, embedder.key, ids);
249
+ const [q] = await embedder.embed([(this.cfg.queryPrefix || '') + query]);
250
+ const dense = ids.map((id) => (vectors.has(id) ? Math.max(...vectors.get(id).map((v) => cosine(q, v))) : -1));
251
+ const lexical = corpus.lexical(scope, query, { any: true });
252
+ const bm25 = ids.map((id) => lexical.get(id) ?? 0);
253
+ const denseWeight = this.mode === 'dense' ? 1 : (this.cfg.denseWeight ?? 0.5);
254
+ const hits = fuse(bm25, dense, { denseWeight, k: this.cfg.rrfK ?? 60 })
255
+ .slice(0, limit)
256
+ .map(({ index, rrf, dense: d, bm25: b }) => ({ id: ids[index], rank: rrf, dense: d, bm25: b }));
257
+ return corpus.hydrate(hits, scope);
235
258
  }
236
- for (const owner of corpus.ownersOf?.(scope) || []) await this.ingest(name, owner);
237
- const ids = corpus.candidates(scope);
238
- if (!ids.length) return [];
239
- const vectors = this.store.embeddings.vectors(name, this.embedder.key, ids);
240
- const [q] = await this.embedder.embed([(this.cfg.queryPrefix || '') + query]);
241
- const dense = ids.map((id) => (vectors.has(id) ? Math.max(...vectors.get(id).map((v) => cosine(q, v))) : -1));
242
- const lexical = corpus.lexical(scope, query, { any: true });
243
- const bm25 = ids.map((id) => lexical.get(id) ?? 0);
244
- const denseWeight = this.mode === 'dense' ? 1 : (this.cfg.denseWeight ?? 0.5);
245
- const hits = fuse(bm25, dense, { denseWeight, k: this.cfg.rrfK ?? 60 })
246
- .slice(0, limit)
247
- .map(({ index, rrf, dense: d, bm25: b }) => ({ id: ids[index], rank: rrf, dense: d, bm25: b }));
248
- return corpus.hydrate(hits, scope);
249
- }
250
259
  }