@3sln/trove 0.0.2
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 +1227 -0
- package/package.json +75 -0
- package/packages/core/src/collections/index.js +249 -0
- package/packages/core/src/errors.js +186 -0
- package/packages/core/src/identity/discovery.js +210 -0
- package/packages/core/src/identity/index.js +188 -0
- package/packages/core/src/identity/jwt.js +199 -0
- package/packages/core/src/index.js +104 -0
- package/packages/core/src/indexers/contribution.js +115 -0
- package/packages/core/src/indexers/registry.js +162 -0
- package/packages/core/src/indexing.js +340 -0
- package/packages/core/src/issues.js +150 -0
- package/packages/core/src/kv.js +0 -0
- package/packages/core/src/links.js +141 -0
- package/packages/core/src/metadata/cursor.js +73 -0
- package/packages/core/src/metadata/interface.js +244 -0
- package/packages/core/src/metadata/memory.js +270 -0
- package/packages/core/src/metadata/sqlite.js +412 -0
- package/packages/core/src/notifications/index.js +139 -0
- package/packages/core/src/notifications/webpush.js +217 -0
- package/packages/core/src/plugins/contributions.js +177 -0
- package/packages/core/src/plugins/identity.js +98 -0
- package/packages/core/src/plugins/index.js +225 -0
- package/packages/core/src/plugins/indexers.js +142 -0
- package/packages/core/src/plugins/installStore.js +134 -0
- package/packages/core/src/plugins/package.js +102 -0
- package/packages/core/src/plugins/packageStore.js +61 -0
- package/packages/core/src/plugins/runtime.js +101 -0
- package/packages/core/src/plugins/sql.js +52 -0
- package/packages/core/src/retry.js +74 -0
- package/packages/core/src/scan.js +302 -0
- package/packages/core/src/search/embeddings.js +128 -0
- package/packages/core/src/search/index.js +200 -0
- package/packages/core/src/search/keywordStore.js +107 -0
- package/packages/core/src/search/sqliteStores.js +455 -0
- package/packages/core/src/search/tagMatch.js +59 -0
- package/packages/core/src/search/transformer.js +195 -0
- package/packages/core/src/search/vectorStore.js +274 -0
- package/packages/core/src/search/vectorize.js +249 -0
- package/packages/core/src/sidecar/document.js +213 -0
- package/packages/core/src/sidecar/index.js +174 -0
- package/packages/core/src/sidecar/manager.js +239 -0
- package/packages/core/src/sidecar/store.js +46 -0
- package/packages/core/src/signedUrls.js +170 -0
- package/packages/core/src/sqlite-d1.js +162 -0
- package/packages/core/src/sqlite-driver.js +42 -0
- package/packages/core/src/sqlite.js +162 -0
- package/packages/core/src/storage/filesystem.js +283 -0
- package/packages/core/src/storage/interface.js +222 -0
- package/packages/core/src/storage/memory.js +113 -0
- package/packages/core/src/storage/prefixed.js +75 -0
- package/packages/core/src/storage/s3.js +316 -0
- package/packages/core/src/storage/s3sigv4.js +185 -0
- package/packages/core/src/tasks.js +228 -0
- package/packages/core/src/uploads.js +386 -0
- package/packages/core/src/util.js +125 -0
- package/packages/core/src/vfs.js +666 -0
- package/packages/plugin-sdk/src/browser.js +316 -0
- package/packages/plugin-sdk/src/index.js +32 -0
- package/packages/plugin-sdk/src/protocol.js +59 -0
- package/packages/plugin-sdk/src/rpc.js +95 -0
- package/packages/server/src/adapters/bun.js +78 -0
- package/packages/server/src/adapters/node.js +115 -0
- package/packages/server/src/adapters/staticAssets.js +123 -0
- package/packages/server/src/adapters/webDist.js +70 -0
- package/packages/server/src/adapters/worker-tasks.js +206 -0
- package/packages/server/src/adapters/worker.js +159 -0
- package/packages/server/src/cachePolicy.js +34 -0
- package/packages/server/src/engine/README.md +88 -0
- package/packages/server/src/engine/actions/scanCollection.js +114 -0
- package/packages/server/src/engine/index.js +95 -0
- package/packages/server/src/engine/lazy.js +25 -0
- package/packages/server/src/engine/providers/access.js +363 -0
- package/packages/server/src/engine/providers/core.js +405 -0
- package/packages/server/src/engine/providers/scan.js +67 -0
- package/packages/server/src/index.js +698 -0
- package/packages/server/src/manifest.js +98 -0
- package/packages/server/src/mcp/auth.js +40 -0
- package/packages/server/src/mcp/index.js +213 -0
- package/packages/server/src/mcp/protocol.js +181 -0
- package/packages/server/src/mcp/tools.js +351 -0
- package/packages/server/src/router.js +229 -0
- package/packages/server/src/routes.js +1066 -0
- package/packages/server/src/scope.js +43 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
- package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
- package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
- package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
- package/packages/web/dist/icon.svg +11 -0
- package/packages/web/dist/index.html +16 -0
- package/packages/web/dist/sql-wasm.wasm +0 -0
- package/packages/web/dist/sw.js +186 -0
- package/packages/web/src/bl/actions.js +410 -0
- package/packages/web/src/bl/activity.js +306 -0
- package/packages/web/src/bl/commands.js +274 -0
- package/packages/web/src/bl/fileType.js +49 -0
- package/packages/web/src/bl/index.js +70 -0
- package/packages/web/src/bl/links.js +54 -0
- package/packages/web/src/bl/offline.js +268 -0
- package/packages/web/src/bl/openers.js +71 -0
- package/packages/web/src/bl/pluginInstall.js +59 -0
- package/packages/web/src/bl/services.js +143 -0
- package/packages/web/src/bl/social.js +234 -0
- package/packages/web/src/bl/tagQuery.js +44 -0
- package/packages/web/src/main.js +10 -0
- package/packages/web/src/platform/api.js +529 -0
- package/packages/web/src/platform/commands.js +89 -0
- package/packages/web/src/platform/context.js +77 -0
- package/packages/web/src/platform/contributions.js +156 -0
- package/packages/web/src/platform/index.js +150 -0
- package/packages/web/src/platform/keybindings.js +199 -0
- package/packages/web/src/platform/mediaUrls.js +137 -0
- package/packages/web/src/platform/navigation.js +131 -0
- package/packages/web/src/platform/notifications.js +50 -0
- package/packages/web/src/platform/overlay.js +81 -0
- package/packages/web/src/platform/pluginClientDb.js +132 -0
- package/packages/web/src/platform/pluginDock.js +141 -0
- package/packages/web/src/platform/pluginFrames.js +194 -0
- package/packages/web/src/platform/pluginHost.js +648 -0
- package/packages/web/src/platform/pluginMedia.js +62 -0
- package/packages/web/src/platform/pluginModules.js +90 -0
- package/packages/web/src/platform/pluginNet.js +71 -0
- package/packages/web/src/platform/pluginPackage.js +247 -0
- package/packages/web/src/platform/pluginRpc.js +377 -0
- package/packages/web/src/platform/pluginSigning.js +168 -0
- package/packages/web/src/platform/pluginStore.js +67 -0
- package/packages/web/src/platform/settings.js +101 -0
- package/packages/web/src/platform/spatialNav.js +286 -0
- package/packages/web/src/platform/viewport.js +123 -0
- package/packages/web/src/platform/voice.js +133 -0
- package/packages/web/src/platform/voiceSearch.js +155 -0
- package/packages/web/src/platform/whenclause.js +162 -0
- package/packages/web/src/platform/workbench.js +156 -0
- package/packages/web/src/runtime.js +73 -0
- package/packages/web/src/styles.css +1382 -0
- package/packages/web/src/ui/components/activityBar.js +35 -0
- package/packages/web/src/ui/components/activityPanel.js +132 -0
- package/packages/web/src/ui/components/commandPalette.js +154 -0
- package/packages/web/src/ui/components/editorArea.js +75 -0
- package/packages/web/src/ui/components/launcher.js +392 -0
- package/packages/web/src/ui/components/openers/index.js +212 -0
- package/packages/web/src/ui/components/openers/markdown.js +222 -0
- package/packages/web/src/ui/components/overlays.js +255 -0
- package/packages/web/src/ui/components/phoneChrome.js +188 -0
- package/packages/web/src/ui/components/pluginReview.js +151 -0
- package/packages/web/src/ui/components/pluginsView.js +120 -0
- package/packages/web/src/ui/components/settingsView.js +258 -0
- package/packages/web/src/ui/components/social.js +290 -0
- package/packages/web/src/ui/components/statusBar.js +198 -0
- package/packages/web/src/ui/components/views/grid.js +115 -0
- package/packages/web/src/ui/components/views/index.js +155 -0
- package/packages/web/src/ui/components/views/list.js +50 -0
- package/packages/web/src/ui/components/views/parts.js +58 -0
- package/packages/web/src/ui/compositions/workbench.js +125 -0
- package/packages/web/src/ui/format.js +33 -0
- package/packages/web/src/ui/icon.js +81 -0
- package/packages/web/src/ui/media.js +114 -0
- package/packages/web/src/ui/sanitize.js +86 -0
- package/packages/web/src/workbench.js +205 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// SearchTransformer — turns a user's raw search string into the structured query we
|
|
2
|
+
// actually dispatch: { semanticText, tagFilters }. The default just parses the
|
|
3
|
+
// `#tag` / `#key:op:value` grammar out of the string (deterministic, offline-safe).
|
|
4
|
+
// Inject a smarter one (e.g. an LLM that maps "photos from my trip last summer" to
|
|
5
|
+
// semantic text + tag/date filters) via config.searchTransformer.
|
|
6
|
+
//
|
|
7
|
+
// Whatever a transformer returns is what the search runs on, and the server reports
|
|
8
|
+
// it back to the client so the UI can honestly show what was searched.
|
|
9
|
+
//
|
|
10
|
+
// It may also say how the results want to be LOOKED AT. "photos from the trip last
|
|
11
|
+
// summer" is a request for a gallery as much as it is a query, and the transformer is
|
|
12
|
+
// the one thing in the stack that has read the sentence — by the time results come back
|
|
13
|
+
// all anyone can do is guess from content types. So `view` rides along on the resolved
|
|
14
|
+
// query, and the client treats it as the suggestion it is.
|
|
15
|
+
|
|
16
|
+
import { TroveError } from '../errors.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} ResolvedQuery
|
|
20
|
+
* @property {string} semanticText what to search for
|
|
21
|
+
* @property {Array<object>} tagFilters structured filters to narrow it by
|
|
22
|
+
* @property {string} [source] how it was resolved — 'parse' or 'llm'
|
|
23
|
+
* @property {string} [note] why, when it didn't go as planned
|
|
24
|
+
* @property {string|null} [view] the id of a view the results would suit, from
|
|
25
|
+
* `ctx.views`. A suggestion: the client ignores an id it doesn't have, and never
|
|
26
|
+
* applies it over a view the user picked for themselves.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {object} SearchPrompt
|
|
31
|
+
* @property {string} placeholder what to show in an empty search box
|
|
32
|
+
* @property {string} [short] a phone-width version of the same
|
|
33
|
+
* @property {string} [hint] one line explaining what the box accepts
|
|
34
|
+
* @property {Array<{query: string, label?: string}>} [examples] queries worth trying
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export class SearchTransformer {
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} rawQuery the user's input
|
|
40
|
+
* @param {{ tagKeys?: string[], views?: Array<{id: string, title?: string}> }} [ctx]
|
|
41
|
+
* what the caller can tell us: the tag keys in use, and the views this client can
|
|
42
|
+
* draw with (so a `view` hint names one that exists rather than one we invented).
|
|
43
|
+
* @returns {Promise<ResolvedQuery>}
|
|
44
|
+
*/
|
|
45
|
+
async transform(rawQuery, ctx) {
|
|
46
|
+
throw TroveError.unsupported('SearchTransformer.transform');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* What to tell the user this search box accepts.
|
|
51
|
+
*
|
|
52
|
+
* The transformer defines the grammar, so the transformer has to define the prompt.
|
|
53
|
+
* A hardcoded "# filter by tag" is a lie the moment an LLM transformer is plugged in
|
|
54
|
+
* and the right thing to type becomes a sentence — and it is the specific kind of lie
|
|
55
|
+
* that teaches people the search doesn't work, because they type what the box told
|
|
56
|
+
* them to and get nothing.
|
|
57
|
+
*
|
|
58
|
+
* @returns {SearchPrompt}
|
|
59
|
+
*/
|
|
60
|
+
describe() {
|
|
61
|
+
return { placeholder: 'Search files', short: 'Search' };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The `#tag` grammar (mirrors the client parser in web/src/bl/tagQuery.js):
|
|
66
|
+
// #tag present · #key:=v #key:!=v #key:<v #key:<=v #key:>v #key:>=v · #key:v (= shorthand)
|
|
67
|
+
const TOKEN = /#([\w.-]+)(?::(<=|>=|!=|=|<|>)?("[^"]*"|[^#\s]+)?)?/g;
|
|
68
|
+
|
|
69
|
+
/** Parse `#tag`/`#key:op:value` filters out of `query`, returning the residual text. */
|
|
70
|
+
export function parseTagFilters(query) {
|
|
71
|
+
const filters = [];
|
|
72
|
+
let text = query || '';
|
|
73
|
+
for (const m of (query || '').matchAll(TOKEN)) {
|
|
74
|
+
const [, key, op, rawValue] = m;
|
|
75
|
+
const value = rawValue && rawValue.startsWith('"') ? rawValue.slice(1, -1) : rawValue;
|
|
76
|
+
filters.push(value == null || value === ''
|
|
77
|
+
? { key, present: true }
|
|
78
|
+
: { key, op: op || '=', value, present: false });
|
|
79
|
+
text = text.replace(m[0], ' ');
|
|
80
|
+
}
|
|
81
|
+
return { text: text.replace(/\s+/g, ' ').trim(), filters };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Re-exported so existing importers keep working; the semantics live in tagMatch.js,
|
|
85
|
+
// which the sqlite store's SQL is also written to agree with.
|
|
86
|
+
export { matchTagFilters, matchesFilter, tagProps } from './tagMatch.js';
|
|
87
|
+
|
|
88
|
+
/** The default: deterministic `#tag` parsing, no external calls. */
|
|
89
|
+
export class ParsingSearchTransformer extends SearchTransformer {
|
|
90
|
+
async transform(rawQuery) {
|
|
91
|
+
const { text, filters } = parseTagFilters(rawQuery);
|
|
92
|
+
return { semanticText: text, tagFilters: filters, source: 'parse' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe() {
|
|
96
|
+
return {
|
|
97
|
+
placeholder: 'Search files · # filter by tag',
|
|
98
|
+
short: 'Search files',
|
|
99
|
+
hint: 'Words match content and meaning. #tag narrows to items carrying that tag, '
|
|
100
|
+
+ 'and #key:value compares one — with =, !=, <, <=, > or >=.',
|
|
101
|
+
examples: [
|
|
102
|
+
{ query: '#draft', label: 'everything tagged draft' },
|
|
103
|
+
{ query: '#year:>2023', label: 'a comparison on a tag value' },
|
|
104
|
+
{ query: 'sailing #draft', label: 'meaning and a tag together' },
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The views a client offered, cleaned up enough to put in a prompt.
|
|
112
|
+
*
|
|
113
|
+
* This list arrives from the client, so it is bounded before it becomes part of a system
|
|
114
|
+
* prompt — not because a long one is dangerous, but because it is the caller's text in
|
|
115
|
+
* our instructions and an unbounded field there is somebody else's budget.
|
|
116
|
+
*/
|
|
117
|
+
function viewChoices(views) {
|
|
118
|
+
if (!Array.isArray(views)) return [];
|
|
119
|
+
return views
|
|
120
|
+
.filter((v) => v && typeof v.id === 'string' && v.id)
|
|
121
|
+
.slice(0, 12)
|
|
122
|
+
.map((v) => ({
|
|
123
|
+
id: v.id.slice(0, 80),
|
|
124
|
+
title: (typeof v.title === 'string' && v.title ? v.title : v.id).slice(0, 40),
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// An LLM-assisted transformer for Cloudflare Workers AI (or any compatible runner).
|
|
129
|
+
// `run(model, { messages })` should return the model's text (Workers AI returns
|
|
130
|
+
// `{ response }`). It asks a cheap chat model to convert free text into
|
|
131
|
+
// { semanticText, tagFilters } given the known tag keys, and falls back to plain
|
|
132
|
+
// parsing if the model is unavailable or returns unusable output — so search never
|
|
133
|
+
// breaks because the LLM hiccuped.
|
|
134
|
+
export class WorkersAiSearchTransformer extends SearchTransformer {
|
|
135
|
+
constructor({ ai, model = '@cf/meta/llama-3.1-8b-instruct', run } = {}) {
|
|
136
|
+
super();
|
|
137
|
+
// `ai` is a Workers AI binding ({ run(model, input) }); `run` overrides it.
|
|
138
|
+
this._run = run || (ai ? (m, input) => ai.run(m, input) : null);
|
|
139
|
+
this.model = model;
|
|
140
|
+
this._fallback = new ParsingSearchTransformer();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
describe() {
|
|
144
|
+
// With a model in the loop the right thing to type is a sentence, so that is what
|
|
145
|
+
// the box should ask for. The `#tag` grammar still works — explicit filters are
|
|
146
|
+
// passed through untouched below — so it is mentioned rather than dropped.
|
|
147
|
+
return {
|
|
148
|
+
placeholder: 'Describe what you\'re looking for',
|
|
149
|
+
short: 'Describe what you want',
|
|
150
|
+
hint: 'Plain language works — "invoices from last spring". Explicit #tag filters '
|
|
151
|
+
+ 'are still applied exactly as written.',
|
|
152
|
+
examples: [
|
|
153
|
+
{ query: 'photos from the trip last summer' },
|
|
154
|
+
{ query: 'contracts I haven\'t signed #draft', label: 'a sentence plus an exact filter' },
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async transform(rawQuery, ctx = {}) {
|
|
160
|
+
// Explicit `#tag` filters are already structured — respect them and only let the
|
|
161
|
+
// model reinterpret the residual free text.
|
|
162
|
+
const parsed = parseTagFilters(rawQuery);
|
|
163
|
+
if (!this._run || !parsed.text) return { semanticText: parsed.text, tagFilters: parsed.filters, source: 'parse' };
|
|
164
|
+
try {
|
|
165
|
+
const keys = (ctx.tagKeys || []).slice(0, 60);
|
|
166
|
+
const views = viewChoices(ctx.views);
|
|
167
|
+
const sys = 'You convert a user\'s file-search request into JSON: '
|
|
168
|
+
+ '{"semanticText": string, "tagFilters": [{"key":string,"op":"="|"!="|"<"|"<="|">"|">=","value":string}|{"key":string,"present":true}]'
|
|
169
|
+
+ (views.length ? ', "view": string|null' : '') + '}. '
|
|
170
|
+
+ 'Only use tag keys from this list: ' + JSON.stringify(keys) + '. '
|
|
171
|
+
+ (views.length
|
|
172
|
+
// Asking for null explicitly, and saying when: a model given a list of options
|
|
173
|
+
// and no way to decline will pick one every time, and a gallery forced onto a
|
|
174
|
+
// search for meeting notes is worse than not having asked.
|
|
175
|
+
? 'If the request clearly implies how the results should be displayed, set "view" to one of '
|
|
176
|
+
+ JSON.stringify(views.map((v) => v.id)) + ' — ' + views.map((v) => `${v.id} is ${v.title}`).join(', ')
|
|
177
|
+
+ '. Otherwise set "view" to null. '
|
|
178
|
+
: '')
|
|
179
|
+
+ 'Put the natural-language description of the content into semanticText. Output JSON only.';
|
|
180
|
+
const out = await this._run(this.model, { messages: [{ role: 'system', content: sys }, { role: 'user', content: parsed.text }] });
|
|
181
|
+
const textOut = typeof out === 'string' ? out : (out?.response || out?.result?.response || '');
|
|
182
|
+
const json = JSON.parse((textOut.match(/\{[\s\S]*\}/) || [textOut])[0]);
|
|
183
|
+
const semanticText = typeof json.semanticText === 'string' ? json.semanticText : parsed.text;
|
|
184
|
+
const modelFilters = Array.isArray(json.tagFilters) ? json.tagFilters.filter((f) => f && f.key) : [];
|
|
185
|
+
// Merge explicit user filters with the model's; explicit ones win.
|
|
186
|
+
const tagFilters = [...parsed.filters, ...modelFilters];
|
|
187
|
+
// Only a view we offered. A model that invents an id, or reaches for one this
|
|
188
|
+
// client never mentioned, has said nothing.
|
|
189
|
+
const view = views.some((v) => v.id === json.view) ? json.view : null;
|
|
190
|
+
return { semanticText, tagFilters, source: 'llm', view };
|
|
191
|
+
} catch (err) {
|
|
192
|
+
return { semanticText: parsed.text, tagFilters: parsed.filters, source: 'parse', note: 'llm-unavailable' };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// VectorStore — the pluggable nearest-neighbour index behind semantic search.
|
|
2
|
+
// The contract is fully ASYNC so an external vector database (pgvector, Qdrant,
|
|
3
|
+
// Pinecone, Milvos, LanceDB…) fits behind the same interface as the built-in
|
|
4
|
+
// in-memory store. You construct a concrete VectorStore and pass it into the
|
|
5
|
+
// server (or SearchService) — core never hardcodes one, and stays platform
|
|
6
|
+
// agnostic (the bundled adapters use only fetch + Web Crypto).
|
|
7
|
+
//
|
|
8
|
+
// A "doc" is one embedded chunk:
|
|
9
|
+
// { id, nodeId, indexerId, vector: number[], fields?: object }
|
|
10
|
+
// `id` is stable per (indexer, node, chunk); `nodeId`/`indexerId` let the store
|
|
11
|
+
// delete a file's or an indexer's vectors wholesale.
|
|
12
|
+
|
|
13
|
+
import { TroveError, wrapError } from '../errors.js';
|
|
14
|
+
import { withRetry } from '../retry.js';
|
|
15
|
+
|
|
16
|
+
export class VectorStore {
|
|
17
|
+
/** Vector dimensionality this store expects (must match the embeddings). */
|
|
18
|
+
get dimensions() {
|
|
19
|
+
return this._dimensions ?? 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Upsert documents. @param {Array} docs */
|
|
23
|
+
async add(docs) {
|
|
24
|
+
throw TroveError.unsupported('add not implemented');
|
|
25
|
+
}
|
|
26
|
+
/** Remove one document by id. */
|
|
27
|
+
async remove(docId) {
|
|
28
|
+
throw TroveError.unsupported('remove not implemented');
|
|
29
|
+
}
|
|
30
|
+
/** Remove every document belonging to a node. */
|
|
31
|
+
async removeByNode(nodeId) {
|
|
32
|
+
throw TroveError.unsupported('removeByNode not implemented');
|
|
33
|
+
}
|
|
34
|
+
/** Remove every document contributed by an indexer namespace. */
|
|
35
|
+
async removeByIndexer(indexerId) {
|
|
36
|
+
throw TroveError.unsupported('removeByIndexer not implemented');
|
|
37
|
+
}
|
|
38
|
+
/** Remove documents matching both a node and an indexer (re-index a file). */
|
|
39
|
+
async removeByNodeIndexer(nodeId, indexerId) {
|
|
40
|
+
throw TroveError.unsupported('removeByNodeIndexer not implemented');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Nearest neighbours by cosine similarity.
|
|
44
|
+
* @param {number[]} vector normalised query vector
|
|
45
|
+
* @param {{limit?: number, indexers?: string[]}} [opts]
|
|
46
|
+
* @returns {Promise<Array<{docId, nodeId, indexerId, score, fields}>>}
|
|
47
|
+
*/
|
|
48
|
+
async query(vector, opts) {
|
|
49
|
+
throw TroveError.unsupported('query not implemented');
|
|
50
|
+
}
|
|
51
|
+
/** Optional: number of stored vectors (diagnostics). */
|
|
52
|
+
async count() {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// MemoryVectorStore — exact, brute-force, dependency-free. Correct up to ~10^5
|
|
59
|
+
// vectors, and EPHEMERAL: everything in it is gone on restart. That makes it a test
|
|
60
|
+
// double and a last-resort fallback, not a default for a drive with files in it —
|
|
61
|
+
// SqliteVectorStore (search/sqliteStores.js) is the durable local answer.
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
export class MemoryVectorStore extends VectorStore {
|
|
65
|
+
constructor({ dimensions } = {}) {
|
|
66
|
+
super();
|
|
67
|
+
this._dimensions = dimensions;
|
|
68
|
+
this.docs = new Map(); // docId -> { id, nodeId, indexerId, vector: Float32Array, fields }
|
|
69
|
+
this.byNode = new Map();
|
|
70
|
+
this.byIndexer = new Map();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async add(docs) {
|
|
74
|
+
for (const doc of docs) {
|
|
75
|
+
if (this._dimensions && doc.vector.length !== this._dimensions) {
|
|
76
|
+
throw TroveError.invalid(`Vector dim ${doc.vector.length} != index dim ${this._dimensions}`);
|
|
77
|
+
}
|
|
78
|
+
await this.remove(doc.id);
|
|
79
|
+
const rec = {
|
|
80
|
+
id: doc.id, nodeId: doc.nodeId, indexerId: doc.indexerId,
|
|
81
|
+
vector: doc.vector instanceof Float32Array ? doc.vector : Float32Array.from(doc.vector),
|
|
82
|
+
fields: doc.fields || {},
|
|
83
|
+
};
|
|
84
|
+
this.docs.set(rec.id, rec);
|
|
85
|
+
index(this.byNode, rec.nodeId, rec.id);
|
|
86
|
+
index(this.byIndexer, rec.indexerId, rec.id);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async remove(docId) {
|
|
91
|
+
const rec = this.docs.get(docId);
|
|
92
|
+
if (!rec) return;
|
|
93
|
+
this.docs.delete(docId);
|
|
94
|
+
this.byNode.get(rec.nodeId)?.delete(docId);
|
|
95
|
+
this.byIndexer.get(rec.indexerId)?.delete(docId);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Both of these dropped the docs and their OWN index entry, but left the docs' ids
|
|
99
|
+
// sitting in the SIBLING index forever — so a drive that re-indexes repeatedly grew a
|
|
100
|
+
// per-indexer (or per-node) id list without bound. Queries stayed correct, which is
|
|
101
|
+
// why it never showed up as anything but memory.
|
|
102
|
+
async removeByNode(nodeId) {
|
|
103
|
+
for (const id of [...(this.byNode.get(nodeId) || [])]) this.#drop(id);
|
|
104
|
+
this.byNode.delete(nodeId);
|
|
105
|
+
}
|
|
106
|
+
async removeByIndexer(indexerId) {
|
|
107
|
+
for (const id of [...(this.byIndexer.get(indexerId) || [])]) this.#drop(id);
|
|
108
|
+
this.byIndexer.delete(indexerId);
|
|
109
|
+
}
|
|
110
|
+
/** Forget one doc from `docs` and from BOTH indexes. */
|
|
111
|
+
#drop(docId) {
|
|
112
|
+
const rec = this.docs.get(docId);
|
|
113
|
+
if (!rec) return;
|
|
114
|
+
this.docs.delete(docId);
|
|
115
|
+
const byNode = this.byNode.get(rec.nodeId);
|
|
116
|
+
if (byNode) { byNode.delete(docId); if (!byNode.size) this.byNode.delete(rec.nodeId); }
|
|
117
|
+
const byIndexer = this.byIndexer.get(rec.indexerId);
|
|
118
|
+
if (byIndexer) { byIndexer.delete(docId); if (!byIndexer.size) this.byIndexer.delete(rec.indexerId); }
|
|
119
|
+
}
|
|
120
|
+
async removeByNodeIndexer(nodeId, indexerId) {
|
|
121
|
+
for (const id of this.byNode.get(nodeId) || []) {
|
|
122
|
+
const rec = this.docs.get(id);
|
|
123
|
+
if (rec && rec.indexerId === indexerId) await this.remove(id);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async query(vector, opts = {}) {
|
|
128
|
+
const limit = opts.limit ?? 20;
|
|
129
|
+
const allow = opts.indexers ? new Set(opts.indexers) : null;
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const rec of this.docs.values()) {
|
|
132
|
+
if (allow && !allow.has(rec.indexerId)) continue;
|
|
133
|
+
out.push({ docId: rec.id, nodeId: rec.nodeId, indexerId: rec.indexerId, score: dot(vector, rec.vector), fields: rec.fields });
|
|
134
|
+
}
|
|
135
|
+
out.sort((a, b) => b.score - a.score);
|
|
136
|
+
return out.slice(0, limit);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async count() {
|
|
140
|
+
return this.docs.size;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// QdrantVectorStore — a real external adapter, proving the interface fits an
|
|
146
|
+
// async network DB. Uses Qdrant's REST API over fetch (works on Node, Bun,
|
|
147
|
+
// Workers). Point ids are deterministic UUIDs derived from the docId (Qdrant
|
|
148
|
+
// requires uint/uuid ids); the original docId + routing keys live in the
|
|
149
|
+
// payload so node/indexer deletes are filter-based.
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
export class QdrantVectorStore extends VectorStore {
|
|
153
|
+
/**
|
|
154
|
+
* @param {object} cfg
|
|
155
|
+
* @param {string} cfg.url e.g. http://localhost:6333
|
|
156
|
+
* @param {string} cfg.collection
|
|
157
|
+
* @param {number} cfg.dimensions
|
|
158
|
+
* @param {string} [cfg.apiKey]
|
|
159
|
+
* @param {string} [cfg.distance] Cosine (default) | Dot | Euclid
|
|
160
|
+
*/
|
|
161
|
+
constructor(cfg) {
|
|
162
|
+
super();
|
|
163
|
+
for (const k of ['url', 'collection', 'dimensions']) {
|
|
164
|
+
if (!cfg?.[k]) throw TroveError.invalid(`QdrantVectorStore requires "${k}"`);
|
|
165
|
+
}
|
|
166
|
+
this.cfg = cfg;
|
|
167
|
+
this._dimensions = cfg.dimensions;
|
|
168
|
+
this._ready = null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async #req(method, path, body) {
|
|
172
|
+
return withRetry(
|
|
173
|
+
async () => {
|
|
174
|
+
let res;
|
|
175
|
+
try {
|
|
176
|
+
res = await fetch(this.cfg.url.replace(/\/$/, '') + path, {
|
|
177
|
+
method,
|
|
178
|
+
headers: { 'content-type': 'application/json', ...(this.cfg.apiKey ? { 'api-key': this.cfg.apiKey } : {}) },
|
|
179
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
180
|
+
});
|
|
181
|
+
} catch (err) {
|
|
182
|
+
throw wrapError(err);
|
|
183
|
+
}
|
|
184
|
+
if (res.status === 429 || res.status >= 500) throw TroveError.transient(`Qdrant ${res.status}`);
|
|
185
|
+
const json = await res.json().catch(() => ({}));
|
|
186
|
+
if (!res.ok) throw TroveError.internal(`Qdrant ${method} ${path} failed: ${json?.status?.error || res.status}`);
|
|
187
|
+
return json;
|
|
188
|
+
},
|
|
189
|
+
{ retries: 3 },
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Create the collection on first use (idempotent). */
|
|
194
|
+
async #ensure() {
|
|
195
|
+
if (!this._ready) {
|
|
196
|
+
this._ready = (async () => {
|
|
197
|
+
const existing = await this.#req('GET', `/collections/${this.cfg.collection}/exists`).catch(() => null);
|
|
198
|
+
if (existing?.result?.exists) return;
|
|
199
|
+
await this.#req('PUT', `/collections/${this.cfg.collection}`, {
|
|
200
|
+
vectors: { size: this.cfg.dimensions, distance: this.cfg.distance || 'Cosine' },
|
|
201
|
+
}).catch((e) => {
|
|
202
|
+
// Tolerate a concurrent create (409-ish) but surface real errors.
|
|
203
|
+
if (!/already exists/i.test(e.message)) throw e;
|
|
204
|
+
});
|
|
205
|
+
})();
|
|
206
|
+
}
|
|
207
|
+
return this._ready;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async add(docs) {
|
|
211
|
+
if (!docs.length) return;
|
|
212
|
+
await this.#ensure();
|
|
213
|
+
const points = await Promise.all(
|
|
214
|
+
docs.map(async (d) => ({
|
|
215
|
+
id: await docUuid(d.id),
|
|
216
|
+
vector: Array.from(d.vector),
|
|
217
|
+
payload: { docId: d.id, nodeId: d.nodeId, indexerId: d.indexerId, fields: d.fields || {} },
|
|
218
|
+
})),
|
|
219
|
+
);
|
|
220
|
+
await this.#req('PUT', `/collections/${this.cfg.collection}/points?wait=true`, { points });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async remove(docId) {
|
|
224
|
+
await this.#deleteByFilter({ must: [{ key: 'docId', match: { value: docId } }] });
|
|
225
|
+
}
|
|
226
|
+
async removeByNode(nodeId) {
|
|
227
|
+
await this.#deleteByFilter({ must: [{ key: 'nodeId', match: { value: nodeId } }] });
|
|
228
|
+
}
|
|
229
|
+
async removeByIndexer(indexerId) {
|
|
230
|
+
await this.#deleteByFilter({ must: [{ key: 'indexerId', match: { value: indexerId } }] });
|
|
231
|
+
}
|
|
232
|
+
async removeByNodeIndexer(nodeId, indexerId) {
|
|
233
|
+
await this.#deleteByFilter({ must: [{ key: 'nodeId', match: { value: nodeId } }, { key: 'indexerId', match: { value: indexerId } }] });
|
|
234
|
+
}
|
|
235
|
+
async #deleteByFilter(filter) {
|
|
236
|
+
await this.#ensure();
|
|
237
|
+
await this.#req('POST', `/collections/${this.cfg.collection}/points/delete?wait=true`, { filter });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async query(vector, opts = {}) {
|
|
241
|
+
await this.#ensure();
|
|
242
|
+
const body = { vector: Array.from(vector), limit: opts.limit ?? 20, with_payload: true };
|
|
243
|
+
if (opts.indexers?.length) body.filter = { must: [{ key: 'indexerId', match: { any: opts.indexers } }] };
|
|
244
|
+
const res = await this.#req('POST', `/collections/${this.cfg.collection}/points/search`, body);
|
|
245
|
+
return (res.result || []).map((r) => ({
|
|
246
|
+
docId: r.payload?.docId, nodeId: r.payload?.nodeId, indexerId: r.payload?.indexerId,
|
|
247
|
+
score: r.score, fields: r.payload?.fields || {},
|
|
248
|
+
}));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async count() {
|
|
252
|
+
await this.#ensure();
|
|
253
|
+
const res = await this.#req('POST', `/collections/${this.cfg.collection}/points/count`, { exact: true });
|
|
254
|
+
return res.result?.count ?? null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Deterministic UUIDv5-style id from a string (SHA-256 → formatted as a UUID).
|
|
259
|
+
async function docUuid(str) {
|
|
260
|
+
const buf = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)));
|
|
261
|
+
const h = [...buf.slice(0, 16)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
262
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-5${h.slice(13, 16)}-${((parseInt(h[16], 16) & 0x3) | 0x8).toString(16)}${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function index(map, key, id) {
|
|
266
|
+
let set = map.get(key);
|
|
267
|
+
if (!set) map.set(key, (set = new Set()));
|
|
268
|
+
set.add(id);
|
|
269
|
+
}
|
|
270
|
+
function dot(a, b) {
|
|
271
|
+
let s = 0;
|
|
272
|
+
for (let i = 0; i < a.length; i++) s += a[i] * b[i];
|
|
273
|
+
return s;
|
|
274
|
+
}
|