@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.
Files changed (162) hide show
  1. package/README.md +1227 -0
  2. package/package.json +75 -0
  3. package/packages/core/src/collections/index.js +249 -0
  4. package/packages/core/src/errors.js +186 -0
  5. package/packages/core/src/identity/discovery.js +210 -0
  6. package/packages/core/src/identity/index.js +188 -0
  7. package/packages/core/src/identity/jwt.js +199 -0
  8. package/packages/core/src/index.js +104 -0
  9. package/packages/core/src/indexers/contribution.js +115 -0
  10. package/packages/core/src/indexers/registry.js +162 -0
  11. package/packages/core/src/indexing.js +340 -0
  12. package/packages/core/src/issues.js +150 -0
  13. package/packages/core/src/kv.js +0 -0
  14. package/packages/core/src/links.js +141 -0
  15. package/packages/core/src/metadata/cursor.js +73 -0
  16. package/packages/core/src/metadata/interface.js +244 -0
  17. package/packages/core/src/metadata/memory.js +270 -0
  18. package/packages/core/src/metadata/sqlite.js +412 -0
  19. package/packages/core/src/notifications/index.js +139 -0
  20. package/packages/core/src/notifications/webpush.js +217 -0
  21. package/packages/core/src/plugins/contributions.js +177 -0
  22. package/packages/core/src/plugins/identity.js +98 -0
  23. package/packages/core/src/plugins/index.js +225 -0
  24. package/packages/core/src/plugins/indexers.js +142 -0
  25. package/packages/core/src/plugins/installStore.js +134 -0
  26. package/packages/core/src/plugins/package.js +102 -0
  27. package/packages/core/src/plugins/packageStore.js +61 -0
  28. package/packages/core/src/plugins/runtime.js +101 -0
  29. package/packages/core/src/plugins/sql.js +52 -0
  30. package/packages/core/src/retry.js +74 -0
  31. package/packages/core/src/scan.js +302 -0
  32. package/packages/core/src/search/embeddings.js +128 -0
  33. package/packages/core/src/search/index.js +200 -0
  34. package/packages/core/src/search/keywordStore.js +107 -0
  35. package/packages/core/src/search/sqliteStores.js +455 -0
  36. package/packages/core/src/search/tagMatch.js +59 -0
  37. package/packages/core/src/search/transformer.js +195 -0
  38. package/packages/core/src/search/vectorStore.js +274 -0
  39. package/packages/core/src/search/vectorize.js +249 -0
  40. package/packages/core/src/sidecar/document.js +213 -0
  41. package/packages/core/src/sidecar/index.js +174 -0
  42. package/packages/core/src/sidecar/manager.js +239 -0
  43. package/packages/core/src/sidecar/store.js +46 -0
  44. package/packages/core/src/signedUrls.js +170 -0
  45. package/packages/core/src/sqlite-d1.js +162 -0
  46. package/packages/core/src/sqlite-driver.js +42 -0
  47. package/packages/core/src/sqlite.js +162 -0
  48. package/packages/core/src/storage/filesystem.js +283 -0
  49. package/packages/core/src/storage/interface.js +222 -0
  50. package/packages/core/src/storage/memory.js +113 -0
  51. package/packages/core/src/storage/prefixed.js +75 -0
  52. package/packages/core/src/storage/s3.js +316 -0
  53. package/packages/core/src/storage/s3sigv4.js +185 -0
  54. package/packages/core/src/tasks.js +228 -0
  55. package/packages/core/src/uploads.js +386 -0
  56. package/packages/core/src/util.js +125 -0
  57. package/packages/core/src/vfs.js +666 -0
  58. package/packages/plugin-sdk/src/browser.js +316 -0
  59. package/packages/plugin-sdk/src/index.js +32 -0
  60. package/packages/plugin-sdk/src/protocol.js +59 -0
  61. package/packages/plugin-sdk/src/rpc.js +95 -0
  62. package/packages/server/src/adapters/bun.js +78 -0
  63. package/packages/server/src/adapters/node.js +115 -0
  64. package/packages/server/src/adapters/staticAssets.js +123 -0
  65. package/packages/server/src/adapters/webDist.js +70 -0
  66. package/packages/server/src/adapters/worker-tasks.js +206 -0
  67. package/packages/server/src/adapters/worker.js +159 -0
  68. package/packages/server/src/cachePolicy.js +34 -0
  69. package/packages/server/src/engine/README.md +88 -0
  70. package/packages/server/src/engine/actions/scanCollection.js +114 -0
  71. package/packages/server/src/engine/index.js +95 -0
  72. package/packages/server/src/engine/lazy.js +25 -0
  73. package/packages/server/src/engine/providers/access.js +363 -0
  74. package/packages/server/src/engine/providers/core.js +405 -0
  75. package/packages/server/src/engine/providers/scan.js +67 -0
  76. package/packages/server/src/index.js +698 -0
  77. package/packages/server/src/manifest.js +98 -0
  78. package/packages/server/src/mcp/auth.js +40 -0
  79. package/packages/server/src/mcp/index.js +213 -0
  80. package/packages/server/src/mcp/protocol.js +181 -0
  81. package/packages/server/src/mcp/tools.js +351 -0
  82. package/packages/server/src/router.js +229 -0
  83. package/packages/server/src/routes.js +1066 -0
  84. package/packages/server/src/scope.js +43 -0
  85. package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
  86. package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
  87. package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
  88. package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
  89. package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
  90. package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
  91. package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
  92. package/packages/web/dist/icon.svg +11 -0
  93. package/packages/web/dist/index.html +16 -0
  94. package/packages/web/dist/sql-wasm.wasm +0 -0
  95. package/packages/web/dist/sw.js +186 -0
  96. package/packages/web/src/bl/actions.js +410 -0
  97. package/packages/web/src/bl/activity.js +306 -0
  98. package/packages/web/src/bl/commands.js +274 -0
  99. package/packages/web/src/bl/fileType.js +49 -0
  100. package/packages/web/src/bl/index.js +70 -0
  101. package/packages/web/src/bl/links.js +54 -0
  102. package/packages/web/src/bl/offline.js +268 -0
  103. package/packages/web/src/bl/openers.js +71 -0
  104. package/packages/web/src/bl/pluginInstall.js +59 -0
  105. package/packages/web/src/bl/services.js +143 -0
  106. package/packages/web/src/bl/social.js +234 -0
  107. package/packages/web/src/bl/tagQuery.js +44 -0
  108. package/packages/web/src/main.js +10 -0
  109. package/packages/web/src/platform/api.js +529 -0
  110. package/packages/web/src/platform/commands.js +89 -0
  111. package/packages/web/src/platform/context.js +77 -0
  112. package/packages/web/src/platform/contributions.js +156 -0
  113. package/packages/web/src/platform/index.js +150 -0
  114. package/packages/web/src/platform/keybindings.js +199 -0
  115. package/packages/web/src/platform/mediaUrls.js +137 -0
  116. package/packages/web/src/platform/navigation.js +131 -0
  117. package/packages/web/src/platform/notifications.js +50 -0
  118. package/packages/web/src/platform/overlay.js +81 -0
  119. package/packages/web/src/platform/pluginClientDb.js +132 -0
  120. package/packages/web/src/platform/pluginDock.js +141 -0
  121. package/packages/web/src/platform/pluginFrames.js +194 -0
  122. package/packages/web/src/platform/pluginHost.js +648 -0
  123. package/packages/web/src/platform/pluginMedia.js +62 -0
  124. package/packages/web/src/platform/pluginModules.js +90 -0
  125. package/packages/web/src/platform/pluginNet.js +71 -0
  126. package/packages/web/src/platform/pluginPackage.js +247 -0
  127. package/packages/web/src/platform/pluginRpc.js +377 -0
  128. package/packages/web/src/platform/pluginSigning.js +168 -0
  129. package/packages/web/src/platform/pluginStore.js +67 -0
  130. package/packages/web/src/platform/settings.js +101 -0
  131. package/packages/web/src/platform/spatialNav.js +286 -0
  132. package/packages/web/src/platform/viewport.js +123 -0
  133. package/packages/web/src/platform/voice.js +133 -0
  134. package/packages/web/src/platform/voiceSearch.js +155 -0
  135. package/packages/web/src/platform/whenclause.js +162 -0
  136. package/packages/web/src/platform/workbench.js +156 -0
  137. package/packages/web/src/runtime.js +73 -0
  138. package/packages/web/src/styles.css +1382 -0
  139. package/packages/web/src/ui/components/activityBar.js +35 -0
  140. package/packages/web/src/ui/components/activityPanel.js +132 -0
  141. package/packages/web/src/ui/components/commandPalette.js +154 -0
  142. package/packages/web/src/ui/components/editorArea.js +75 -0
  143. package/packages/web/src/ui/components/launcher.js +392 -0
  144. package/packages/web/src/ui/components/openers/index.js +212 -0
  145. package/packages/web/src/ui/components/openers/markdown.js +222 -0
  146. package/packages/web/src/ui/components/overlays.js +255 -0
  147. package/packages/web/src/ui/components/phoneChrome.js +188 -0
  148. package/packages/web/src/ui/components/pluginReview.js +151 -0
  149. package/packages/web/src/ui/components/pluginsView.js +120 -0
  150. package/packages/web/src/ui/components/settingsView.js +258 -0
  151. package/packages/web/src/ui/components/social.js +290 -0
  152. package/packages/web/src/ui/components/statusBar.js +198 -0
  153. package/packages/web/src/ui/components/views/grid.js +115 -0
  154. package/packages/web/src/ui/components/views/index.js +155 -0
  155. package/packages/web/src/ui/components/views/list.js +50 -0
  156. package/packages/web/src/ui/components/views/parts.js +58 -0
  157. package/packages/web/src/ui/compositions/workbench.js +125 -0
  158. package/packages/web/src/ui/format.js +33 -0
  159. package/packages/web/src/ui/icon.js +81 -0
  160. package/packages/web/src/ui/media.js +114 -0
  161. package/packages/web/src/ui/sanitize.js +86 -0
  162. 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
+ }