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.
- package/README.md +20 -13
- package/bin/tinywebui.js +69 -63
- package/package.json +25 -8
- package/public/app.js +40 -14
- package/public/chat/composer.js +1 -1
- package/public/chat/sidebar.js +8 -5
- package/public/index.html +88 -49
- package/public/panels/admin.js +40 -1
- package/public/panels/model-editor.js +336 -0
- package/public/panels/panels.js +37 -12
- package/public/panels/settings-home.js +125 -0
- package/public/panels/settings.js +124 -36
- package/public/shared/attachment_note.js +7 -1
- package/public/shared/events.js +1 -0
- package/public/shared/features.js +1 -0
- package/public/shared/origins.js +1 -0
- package/public/shared/usage.js +16 -1
- package/public/styles.css +83 -20
- package/src/access/auth.js +155 -155
- package/src/access/auth_gate.js +39 -38
- package/src/access/email.js +17 -0
- package/src/access/policy.js +123 -138
- package/src/audit.js +1 -1
- package/src/automations/automation.js +135 -123
- package/src/automations/scheduler.js +138 -127
- package/src/chat/compact.js +149 -156
- package/src/chat/llm.js +380 -382
- package/src/chat/runs.js +162 -151
- package/src/chat/tool_executor.js +229 -218
- package/src/cli.js +535 -433
- package/src/config/approval.js +35 -29
- package/src/config/config.js +546 -451
- package/src/config/data_dir.js +24 -0
- package/src/config/models.js +191 -115
- package/src/config/refs.js +32 -0
- package/src/config/secrets.js +153 -0
- package/src/files/documents.js +57 -68
- package/src/files/images.js +30 -31
- package/src/harness/agent.js +60 -60
- package/src/harness/attribution.js +66 -58
- package/src/harness/checkpoint.js +67 -62
- package/src/harness/history.js +28 -23
- package/src/harness/prompt.js +40 -57
- package/src/harness/provider.js +284 -283
- package/src/harness/types.js +1 -0
- package/src/http.js +98 -66
- package/src/log.js +11 -12
- package/src/mcp.js +398 -374
- package/src/retrieval/embedding.js +228 -240
- package/src/retrieval/retrieval.js +182 -173
- package/src/routes/admin.js +178 -138
- package/src/routes/auth.js +184 -100
- package/src/routes/automations.js +99 -82
- package/src/routes/chats.js +240 -218
- package/src/routes/documents.js +130 -120
- package/src/routes/library.js +43 -46
- package/src/routes/mcp.js +49 -49
- package/src/routes/settings.js +127 -122
- package/src/routes/types.js +1 -0
- package/src/server.js +428 -392
- package/src/store/automations.js +106 -106
- package/src/store/chats.js +217 -242
- package/src/store/documents.js +181 -185
- package/src/store/embeddings.js +52 -54
- package/src/store/index.js +95 -49
- package/src/store/messages.js +55 -80
- package/src/store/schema.js +128 -107
- package/src/store/scope.js +18 -17
- package/src/store/search.js +25 -27
- package/src/store/turns.js +53 -53
- package/src/store/usage.js +343 -269
- package/src/store/users.js +162 -87
- package/src/store/wire.js +50 -41
- package/src/tools/ask_tool.js +68 -68
- package/src/tools/chat_search_tool.js +38 -44
- package/src/tools/context_tool.js +131 -138
- package/src/tools/document_tool.js +73 -78
- package/src/tools/mcp_tool.js +274 -232
- package/src/tools/task_tool.js +53 -50
- package/src/tools/types.js +1 -0
- package/public/panels/connectors.js +0 -184
- 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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
.
|
|
232
|
-
.
|
|
233
|
-
.map((
|
|
234
|
-
|
|
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
|
}
|