@graphorin/embedder-ollama 0.6.1 → 0.7.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @graphorin/embedder-ollama
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#154](https://github.com/o-stepper/graphorin/pull/154) [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04) Thanks [@o-stepper](https://github.com/o-stepper)! - W-072: every export map's `import` condition becomes `default`, and the Node floor rises to `>=22.12.0`.
8
+
9
+ CJS consumers previously hit a bewildering `ERR_PACKAGE_PATH_NOT_EXPORTED` instead of a clear ESM-only signal. With the `default` condition, plain `require('@graphorin/core')` works via Node's stable `require(esm)` - which shipped in 22.12, hence the engines bump across every workspace manifest (packages, examples, benchmarks, docs; enforced by the widened mvp-readiness sweep). No dual-instance hazard: there is no CJS build, `require()` returns the same ESM module instance. ESM consumers are unaffected (`default` serves both paths; `types` stays first). The pack gate now runs attw under the full `node16` profile (was `esm-only`) and adds a runtime `require(esm)` smoke against the packed tarballs. Installs on Node 22.0-22.11 with `engine-strict` will refuse - upgrade Node (see the migration guide).
10
+
11
+ ### Patch Changes
12
+
13
+ - [#164](https://github.com/o-stepper/graphorin/pull/164) [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00) Thanks [@o-stepper](https://github.com/o-stepper)! - Tarballs now ship `src/` so the published `dist/**/*.d.ts.map` files actually work (W-136): the maps referenced `../src/*.ts` that the `files` whitelist excluded, so go-to-definition fell back into `.d.ts` and the shipped maps were dead weight. The pack gate gains a `map-integrity` leg: every source referenced by a shipped map must resolve inside the tarball (or be embedded via `sourcesContent`), with an anti-vacuous guard - a package whose tsdown config emits declaration maps must contain a non-zero number of `.d.ts.map` files, so a cache-restored dist that silently dropped maps fails the gate instead of passing vacuously. `mvp-readiness` now requires `src` in every publishable `files` array.
14
+
15
+ - [#164](https://github.com/o-stepper/graphorin/pull/164) [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00) Thanks [@o-stepper](https://github.com/o-stepper)! - Every published package now declares its tree-shaking contract via `sideEffects` (W-137): 18 packages audited to a pure module scope get `false`, the CLI declares its bin entry (`["./dist/bin/*"]`), and `@graphorin/security` gets an explicit `true` - its secrets subsystem registers built-in resolvers and the SecretValue caller-context provider at import time, so marking it pure would let bundlers drop those registrations. `mvp-readiness` now fails any publishable manifest without a declared `sideEffects`, closing the drift for future packages.
16
+
17
+ - Updated dependencies [[`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`832f22e`](https://github.com/o-stepper/graphorin/commit/832f22e570b8c3175c1adeb4c150070cbd131534), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`4ee256e`](https://github.com/o-stepper/graphorin/commit/4ee256e30fe9190cef6c48dc6785464757707156), [`4ee256e`](https://github.com/o-stepper/graphorin/commit/4ee256e30fe9190cef6c48dc6785464757707156), [`4ee256e`](https://github.com/o-stepper/graphorin/commit/4ee256e30fe9190cef6c48dc6785464757707156), [`4ee256e`](https://github.com/o-stepper/graphorin/commit/4ee256e30fe9190cef6c48dc6785464757707156), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04)]:
18
+ - @graphorin/core@0.7.0
19
+
3
20
  ## 0.6.1
4
21
 
5
22
  ### Patch Changes
package/README.md CHANGED
@@ -75,4 +75,4 @@ MIT © 2026 Oleksiy Stepurenko.
75
75
 
76
76
  ---
77
77
 
78
- **Project Graphorin** · v0.6.1 · MIT License · © 2026 Oleksiy Stepurenko · <https://github.com/o-stepper/graphorin>
78
+ **Project Graphorin** · v0.7.0 · MIT License · © 2026 Oleksiy Stepurenko · <https://github.com/o-stepper/graphorin>
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.6.1";
2
+ var version = "0.7.0";
3
3
 
4
4
  //#endregion
5
5
  export { version };
@@ -1 +1 @@
1
- {"version":3,"file":"package.js","names":[],"sources":["../package.json"],"sourcesContent":["{\n \"name\": \"@graphorin/embedder-ollama\",\n \"version\": \"0.6.1\",\n \"description\": \"First-class opt-in alternative to @graphorin/embedder-transformersjs for the Graphorin framework. Wraps the local Ollama HTTP API to produce dense embeddings via models such as `nomic-embed-text` (768-dim, multilingual default), `mxbai-embed-large` (1024-dim), `snowflake-arctic-embed` (1024-dim), and `bge-m3` (1024-dim). Implements the `EmbedderProvider` contract with a deterministic configHash that includes the model digest reported by `POST /api/show`, so a model upgrade in the same Ollama instance produces a different embedder_id and the multi-table per-embedder vec0 layout in `@graphorin/store-sqlite` triggers a clean migration path.\",\n \"license\": \"MIT\",\n \"author\": \"Oleksiy Stepurenko\",\n \"homepage\": \"https://github.com/o-stepper/graphorin/tree/main/packages/embedder-ollama\",\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+https://github.com/o-stepper/graphorin.git\",\n \"directory\": \"packages/embedder-ollama\"\n },\n \"bugs\": {\n \"url\": \"https://github.com/o-stepper/graphorin/issues\"\n },\n \"keywords\": [\n \"graphorin\",\n \"ai\",\n \"agents\",\n \"framework\",\n \"embeddings\",\n \"ollama\",\n \"nomic-embed-text\",\n \"bge-m3\",\n \"local-first\"\n ],\n \"type\": \"module\",\n \"engines\": {\n \"node\": \">=22.0.0\"\n },\n \"main\": \"./dist/index.js\",\n \"module\": \"./dist/index.js\",\n \"types\": \"./dist/index.d.ts\",\n \"exports\": {\n \".\": {\n \"types\": \"./dist/index.d.ts\",\n \"import\": \"./dist/index.js\"\n },\n \"./package.json\": \"./package.json\"\n },\n \"files\": [\n \"dist\",\n \"README.md\",\n \"CHANGELOG.md\",\n \"LICENSE\"\n ],\n \"scripts\": {\n \"build\": \"tsdown\",\n \"typecheck\": \"tsc --noEmit && tsc -p tsconfig.tests.json\",\n \"test\": \"vitest run\",\n \"lint\": \"biome check .\",\n \"clean\": \"rimraf dist .turbo *.tsbuildinfo\"\n },\n \"dependencies\": {\n \"@graphorin/core\": \"workspace:*\"\n },\n \"publishConfig\": {\n \"access\": \"public\",\n \"provenance\": true\n },\n \"devDependencies\": {\n \"@graphorin/store-sqlite\": \"workspace:*\"\n }\n}\n"],"mappings":";cAEa"}
1
+ {"version":3,"file":"package.js","names":[],"sources":["../package.json"],"sourcesContent":["{\n \"name\": \"@graphorin/embedder-ollama\",\n \"version\": \"0.7.0\",\n \"description\": \"First-class opt-in alternative to @graphorin/embedder-transformersjs for the Graphorin framework. Wraps the local Ollama HTTP API to produce dense embeddings via models such as `nomic-embed-text` (768-dim, multilingual default), `mxbai-embed-large` (1024-dim), `snowflake-arctic-embed` (1024-dim), and `bge-m3` (1024-dim). Implements the `EmbedderProvider` contract with a deterministic configHash that includes the model digest reported by `POST /api/show`, so a model upgrade in the same Ollama instance produces a different embedder_id and the multi-table per-embedder vec0 layout in `@graphorin/store-sqlite` triggers a clean migration path.\",\n \"license\": \"MIT\",\n \"author\": \"Oleksiy Stepurenko\",\n \"homepage\": \"https://github.com/o-stepper/graphorin/tree/main/packages/embedder-ollama\",\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+https://github.com/o-stepper/graphorin.git\",\n \"directory\": \"packages/embedder-ollama\"\n },\n \"bugs\": {\n \"url\": \"https://github.com/o-stepper/graphorin/issues\"\n },\n \"keywords\": [\n \"graphorin\",\n \"ai\",\n \"agents\",\n \"framework\",\n \"embeddings\",\n \"ollama\",\n \"nomic-embed-text\",\n \"bge-m3\",\n \"local-first\"\n ],\n \"type\": \"module\",\n \"sideEffects\": false,\n \"engines\": {\n \"node\": \">=22.12.0\"\n },\n \"main\": \"./dist/index.js\",\n \"module\": \"./dist/index.js\",\n \"types\": \"./dist/index.d.ts\",\n \"exports\": {\n \".\": {\n \"types\": \"./dist/index.d.ts\",\n \"default\": \"./dist/index.js\"\n },\n \"./package.json\": \"./package.json\"\n },\n \"files\": [\n \"dist\",\n \"src\",\n \"README.md\",\n \"CHANGELOG.md\",\n \"LICENSE\"\n ],\n \"scripts\": {\n \"build\": \"tsdown\",\n \"typecheck\": \"tsc --noEmit && tsc -p tsconfig.tests.json\",\n \"test\": \"vitest run\",\n \"lint\": \"biome check .\",\n \"clean\": \"rimraf dist .turbo *.tsbuildinfo\"\n },\n \"dependencies\": {\n \"@graphorin/core\": \"workspace:*\"\n },\n \"publishConfig\": {\n \"access\": \"public\",\n \"provenance\": true\n },\n \"devDependencies\": {\n \"@graphorin/store-sqlite\": \"workspace:*\"\n }\n}\n"],"mappings":";cAEa"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphorin/embedder-ollama",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "First-class opt-in alternative to @graphorin/embedder-transformersjs for the Graphorin framework. Wraps the local Ollama HTTP API to produce dense embeddings via models such as `nomic-embed-text` (768-dim, multilingual default), `mxbai-embed-large` (1024-dim), `snowflake-arctic-embed` (1024-dim), and `bge-m3` (1024-dim). Implements the `EmbedderProvider` contract with a deterministic configHash that includes the model digest reported by `POST /api/show`, so a model upgrade in the same Ollama instance produces a different embedder_id and the multi-table per-embedder vec0 layout in `@graphorin/store-sqlite` triggers a clean migration path.",
5
5
  "license": "MIT",
6
6
  "author": "Oleksiy Stepurenko",
@@ -25,8 +25,9 @@
25
25
  "local-first"
26
26
  ],
27
27
  "type": "module",
28
+ "sideEffects": false,
28
29
  "engines": {
29
- "node": ">=22.0.0"
30
+ "node": ">=22.12.0"
30
31
  },
31
32
  "main": "./dist/index.js",
32
33
  "module": "./dist/index.js",
@@ -34,25 +35,26 @@
34
35
  "exports": {
35
36
  ".": {
36
37
  "types": "./dist/index.d.ts",
37
- "import": "./dist/index.js"
38
+ "default": "./dist/index.js"
38
39
  },
39
40
  "./package.json": "./package.json"
40
41
  },
41
42
  "files": [
42
43
  "dist",
44
+ "src",
43
45
  "README.md",
44
46
  "CHANGELOG.md",
45
47
  "LICENSE"
46
48
  ],
47
49
  "dependencies": {
48
- "@graphorin/core": "0.6.1"
50
+ "@graphorin/core": "0.7.0"
49
51
  },
50
52
  "publishConfig": {
51
53
  "access": "public",
52
54
  "provenance": true
53
55
  },
54
56
  "devDependencies": {
55
- "@graphorin/store-sqlite": "0.6.1"
57
+ "@graphorin/store-sqlite": "0.7.0"
56
58
  },
57
59
  "scripts": {
58
60
  "build": "tsdown",
package/src/index.ts ADDED
@@ -0,0 +1,404 @@
1
+ /**
2
+ * @graphorin/embedder-ollama - first-class opt-in alternative embedder
3
+ * for the Graphorin framework. Wraps the local Ollama HTTP API.
4
+ *
5
+ * @packageDocumentation
6
+ */
7
+
8
+ import { createHash } from 'node:crypto';
9
+ import type { EmbedderProvider, EmbedOptions } from '@graphorin/core/contracts';
10
+
11
+ /** Canonical version constant, derived from `package.json` at build time. */
12
+ import pkg from '../package.json' with { type: 'json' };
13
+
14
+ export const VERSION: string = pkg.version;
15
+
16
+ /**
17
+ * Default Ollama base URL. Operators that run Ollama on a non-default
18
+ * port pass an explicit `baseUrl`.
19
+ *
20
+ * @stable
21
+ */
22
+ export const DEFAULT_OLLAMA_BASE_URL = 'http://127.0.0.1:11434';
23
+
24
+ /**
25
+ * Default Ollama model. Matches the de-facto choice in the Ollama
26
+ * community for general-purpose multilingual embeddings.
27
+ *
28
+ * @stable
29
+ */
30
+ export const DEFAULT_OLLAMA_MODEL = 'nomic-embed-text';
31
+
32
+ /**
33
+ * Options accepted by {@link createOllamaEmbedder}.
34
+ *
35
+ * @stable
36
+ */
37
+ export interface OllamaEmbedderOptions {
38
+ /** Default `'nomic-embed-text'`. */
39
+ readonly model?: string;
40
+ /** Default `'http://127.0.0.1:11434'`. */
41
+ readonly baseUrl?: string;
42
+ /**
43
+ * Optional dimensionality hint. When known up-front, the canonical
44
+ * id is stable from the first `embed()` call instead of being
45
+ * resolved from the response.
46
+ */
47
+ readonly dim?: number;
48
+ /**
49
+ * Override `fetch`. Used by the test suite to inject a mock HTTP
50
+ * fixture. Production callers should leave this unset so the
51
+ * embedder uses the platform's `globalThis.fetch`.
52
+ */
53
+ readonly fetchImpl?: typeof fetch;
54
+ /**
55
+ * If `true`, skip the `POST /api/show` model-digest probe at
56
+ * construction. Used in test fixtures where the digest is
57
+ * pre-populated.
58
+ */
59
+ readonly skipDigestProbe?: boolean;
60
+ /**
61
+ * Optional pre-resolved digest (e.g. from a probe done elsewhere).
62
+ * When set, the embedder uses this value instead of issuing a
63
+ * `POST /api/show` request.
64
+ */
65
+ readonly digest?: string;
66
+ /** Optional API path override (default `'/api/embed'`). */
67
+ readonly embedPath?: string;
68
+ /** Optional API path override (default `'/api/embeddings'`). */
69
+ readonly legacyEmbedPath?: string;
70
+ /** Optional API path override (default `'/api/show'`). */
71
+ readonly showPath?: string;
72
+ /**
73
+ * Per-request hard timeout in milliseconds. Default `30000`. Each
74
+ * HTTP call (`/api/show`, `/api/embed`, legacy `/api/embeddings`) is
75
+ * aborted if the Ollama daemon does not respond in time, so a hung
76
+ * daemon never stalls the caller. A per-call {@link EmbedOptions.signal}
77
+ * is combined with this timeout. Set to `0` to disable.
78
+ */
79
+ readonly timeoutMs?: number;
80
+ }
81
+
82
+ /**
83
+ * Raised when the Ollama HTTP API returns a non-2xx response.
84
+ *
85
+ * @stable
86
+ */
87
+ export class OllamaEmbedderError extends Error {
88
+ override readonly name = 'OllamaEmbedderError';
89
+ constructor(
90
+ message: string,
91
+ public readonly statusCode?: number,
92
+ options?: { cause?: unknown },
93
+ ) {
94
+ super(message, options);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Model -> output-dimension hints used to seed the canonical id before the
100
+ * first `embed()` resolves the real width from a response. Only single-width
101
+ * families are listed; size-variant families (e.g. `qwen3-embedding`, whose
102
+ * dim depends on the `:0.6b` / `:4b` / `:8b` tag) are deliberately omitted so
103
+ * an ambiguous bind fails loudly rather than baking a wrong width (PS-11).
104
+ */
105
+ export const KNOWN_OLLAMA_MODEL_DIMS: ReadonlyMap<string, number> = new Map([
106
+ ['nomic-embed-text', 768],
107
+ ['mxbai-embed-large', 1024],
108
+ ['snowflake-arctic-embed', 1024],
109
+ ['snowflake-arctic-embed2', 1024],
110
+ ['bge-m3', 1024],
111
+ ['bge-large', 1024],
112
+ ['embeddinggemma', 768],
113
+ ['all-minilm', 384],
114
+ ['granite-embedding', 384],
115
+ ['paraphrase-multilingual', 768],
116
+ ]);
117
+
118
+ /**
119
+ * Build an Ollama-backed embedder. The first `embed()` call issues a
120
+ * `POST /api/show` to capture the model digest; subsequent calls hit
121
+ * the embedding endpoint directly.
122
+ *
123
+ * @stable
124
+ */
125
+ export function createOllamaEmbedder(options: OllamaEmbedderOptions = {}): OllamaEmbedder {
126
+ return new OllamaEmbedder(options);
127
+ }
128
+
129
+ /**
130
+ * `EmbedderProvider` implementation that talks to the Ollama HTTP API.
131
+ *
132
+ * @stable
133
+ */
134
+ export class OllamaEmbedder implements EmbedderProvider {
135
+ readonly #model: string;
136
+ readonly #baseUrl: string;
137
+ readonly #fetchImpl: typeof fetch;
138
+ readonly #skipDigestProbe: boolean;
139
+ readonly #embedPath: string;
140
+ readonly #legacyEmbedPath: string;
141
+ readonly #showPath: string;
142
+ readonly #timeoutMs: number;
143
+ #digest: string | null;
144
+ #resolvedDim: number | null;
145
+ #initialized = false;
146
+ #initializing: Promise<void> | null = null;
147
+
148
+ constructor(options: OllamaEmbedderOptions) {
149
+ this.#model = options.model ?? DEFAULT_OLLAMA_MODEL;
150
+ this.#baseUrl = stripTrailingSlashesLocal(options.baseUrl ?? DEFAULT_OLLAMA_BASE_URL);
151
+ this.#fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
152
+ this.#skipDigestProbe = options.skipDigestProbe ?? false;
153
+ this.#embedPath = options.embedPath ?? '/api/embed';
154
+ this.#legacyEmbedPath = options.legacyEmbedPath ?? '/api/embeddings';
155
+ this.#showPath = options.showPath ?? '/api/show';
156
+ this.#timeoutMs = options.timeoutMs ?? 30_000;
157
+ this.#digest = options.digest ?? null;
158
+ this.#resolvedDim = options.dim ?? KNOWN_OLLAMA_MODEL_DIMS.get(this.#model) ?? null;
159
+ }
160
+
161
+ /**
162
+ * Build the `{ signal }` fetch-init fragment, combining the caller's
163
+ * abort signal (if any) with the per-request timeout so a hung Ollama
164
+ * daemon cannot stall the embed call. Returns `{}` when neither
165
+ * applies. Call once per fetch - each call mints a fresh timeout.
166
+ */
167
+ #signalInit(opts: EmbedOptions): { signal?: AbortSignal } {
168
+ const timeoutSignal = this.#timeoutMs > 0 ? AbortSignal.timeout(this.#timeoutMs) : undefined;
169
+ const signal =
170
+ opts.signal !== undefined && timeoutSignal !== undefined
171
+ ? AbortSignal.any([opts.signal, timeoutSignal])
172
+ : (opts.signal ?? timeoutSignal);
173
+ return signal === undefined ? {} : { signal };
174
+ }
175
+
176
+ /** The canonical embedder id - `'ollama:<model>@<dim-or-digest>'`. */
177
+ id(): string {
178
+ const dim = this.dim();
179
+ const digest = this.#digest !== null ? `:${this.#digest.slice(0, 12)}` : '';
180
+ return `ollama:${this.#model}@${dim}${digest}`;
181
+ }
182
+
183
+ /**
184
+ * Output dimension - the explicit `dim` option, the resolved width from the
185
+ * first `embed()`, or a known-family default. PS-11: throws for an unknown
186
+ * model with no `dim` hint instead of returning `0` (which the store would
187
+ * persist and use to create a `float[0]` vec0 table, silently breaking
188
+ * vector search). Pass `dim` for any model not in {@link
189
+ * KNOWN_OLLAMA_MODEL_DIMS}, or call `embed()` once first to resolve it.
190
+ */
191
+ dim(): number {
192
+ if (this.#resolvedDim !== null) return this.#resolvedDim;
193
+ const known = KNOWN_OLLAMA_MODEL_DIMS.get(this.#model);
194
+ if (known !== undefined) return known;
195
+ throw new OllamaEmbedderError(
196
+ `[graphorin/embedder-ollama] unknown output dimension for model '${this.#model}'. ` +
197
+ 'Pass `dim` to ollamaEmbedder({ model, dim }), or call embed() once to resolve it ' +
198
+ 'from a response, before registering the embedder.',
199
+ );
200
+ }
201
+
202
+ /**
203
+ * Deterministic hash over the embedder's full configuration -
204
+ * including the discovered digest. A model upgrade in the same
205
+ * Ollama instance changes the digest (and therefore the hash), so
206
+ * `lock-on-first` correctly fires a migration path instead of
207
+ * silently reusing the same `embedder_id`.
208
+ */
209
+ configHash(): string {
210
+ return canonicalConfigHash({
211
+ adapter: 'ollama',
212
+ model: this.#model,
213
+ baseUrl: this.#baseUrl,
214
+ digest: this.#digest ?? null,
215
+ dim: this.#resolvedDim ?? null,
216
+ });
217
+ }
218
+
219
+ async embed(
220
+ texts: ReadonlyArray<string>,
221
+ opts: EmbedOptions = {},
222
+ ): Promise<ReadonlyArray<Float32Array>> {
223
+ if (texts.length === 0) return [];
224
+ await this.#ensureInitialized(opts);
225
+ return this.#callEmbed(texts, opts);
226
+ }
227
+
228
+ async #ensureInitialized(opts: EmbedOptions): Promise<void> {
229
+ if (this.#initialized) return;
230
+ if (this.#initializing !== null) return this.#initializing;
231
+ const inflight = (async () => {
232
+ try {
233
+ if (!this.#skipDigestProbe && this.#digest === null) {
234
+ await this.#probeDigest(opts);
235
+ }
236
+ this.#initialized = true;
237
+ } finally {
238
+ // Always clear the in-flight slot so a transient failure does
239
+ // not poison every subsequent call with the same rejection.
240
+ this.#initializing = null;
241
+ }
242
+ })();
243
+ this.#initializing = inflight;
244
+ return inflight;
245
+ }
246
+
247
+ async #probeDigest(opts: EmbedOptions): Promise<void> {
248
+ const url = `${this.#baseUrl}${this.#showPath}`;
249
+ let resp: Response;
250
+ try {
251
+ resp = await this.#fetchImpl(url, {
252
+ method: 'POST',
253
+ headers: { 'content-type': 'application/json' },
254
+ body: JSON.stringify({ name: this.#model }),
255
+ ...this.#signalInit(opts),
256
+ });
257
+ } catch (err) {
258
+ throw new OllamaEmbedderError(
259
+ `[graphorin/embedder-ollama] failed to reach Ollama at ${url}`,
260
+ undefined,
261
+ { cause: err },
262
+ );
263
+ }
264
+ if (!resp.ok) {
265
+ throw new OllamaEmbedderError(
266
+ `[graphorin/embedder-ollama] /api/show returned ${resp.status} for model '${this.#model}'`,
267
+ resp.status,
268
+ );
269
+ }
270
+ const json = (await resp.json()) as { digest?: string };
271
+ if (typeof json.digest === 'string' && json.digest.length > 0) {
272
+ this.#digest = json.digest;
273
+ }
274
+ }
275
+
276
+ async #callEmbed(
277
+ texts: ReadonlyArray<string>,
278
+ opts: EmbedOptions,
279
+ ): Promise<ReadonlyArray<Float32Array>> {
280
+ const batchUrl = `${this.#baseUrl}${this.#embedPath}`;
281
+ const batchBody = JSON.stringify({ model: this.#model, input: [...texts] });
282
+ let resp: Response;
283
+ try {
284
+ resp = await this.#fetchImpl(batchUrl, {
285
+ method: 'POST',
286
+ headers: { 'content-type': 'application/json' },
287
+ body: batchBody,
288
+ ...this.#signalInit(opts),
289
+ });
290
+ } catch (err) {
291
+ throw new OllamaEmbedderError(
292
+ `[graphorin/embedder-ollama] failed to reach Ollama at ${batchUrl}`,
293
+ undefined,
294
+ { cause: err },
295
+ );
296
+ }
297
+ if (resp.status === 404) {
298
+ // Older Ollama versions only expose the legacy single-input path.
299
+ return this.#legacyEmbedPerText(texts, opts);
300
+ }
301
+ if (!resp.ok) {
302
+ throw new OllamaEmbedderError(
303
+ `[graphorin/embedder-ollama] /api/embed returned ${resp.status}`,
304
+ resp.status,
305
+ );
306
+ }
307
+ const json = (await resp.json()) as { embeddings?: number[][] };
308
+ const list = json.embeddings;
309
+ if (!Array.isArray(list) || list.length !== texts.length) {
310
+ throw new OllamaEmbedderError(
311
+ `[graphorin/embedder-ollama] /api/embed response had unexpected shape`,
312
+ );
313
+ }
314
+ const out: Float32Array[] = [];
315
+ for (let i = 0; i < list.length; i++) {
316
+ const row = list[i];
317
+ if (!Array.isArray(row)) {
318
+ throw new OllamaEmbedderError(
319
+ `[graphorin/embedder-ollama] embedding row ${i} is not an array`,
320
+ );
321
+ }
322
+ const f32 = Float32Array.from(row);
323
+ if (this.#resolvedDim === null) {
324
+ this.#resolvedDim = f32.length;
325
+ } else if (this.#resolvedDim !== f32.length) {
326
+ throw new OllamaEmbedderError(
327
+ `[graphorin/embedder-ollama] embedding dim drifted: expected ${this.#resolvedDim}, got ${f32.length}`,
328
+ );
329
+ }
330
+ out.push(f32);
331
+ }
332
+ return out;
333
+ }
334
+
335
+ async #legacyEmbedPerText(
336
+ texts: ReadonlyArray<string>,
337
+ opts: EmbedOptions,
338
+ ): Promise<ReadonlyArray<Float32Array>> {
339
+ const url = `${this.#baseUrl}${this.#legacyEmbedPath}`;
340
+ const out: Float32Array[] = [];
341
+ for (const text of texts) {
342
+ const resp = await this.#fetchImpl(url, {
343
+ method: 'POST',
344
+ headers: { 'content-type': 'application/json' },
345
+ body: JSON.stringify({ model: this.#model, prompt: text }),
346
+ ...this.#signalInit(opts),
347
+ });
348
+ if (!resp.ok) {
349
+ throw new OllamaEmbedderError(
350
+ `[graphorin/embedder-ollama] /api/embeddings returned ${resp.status}`,
351
+ resp.status,
352
+ );
353
+ }
354
+ const json = (await resp.json()) as { embedding?: number[] };
355
+ if (!Array.isArray(json.embedding)) {
356
+ throw new OllamaEmbedderError(
357
+ `[graphorin/embedder-ollama] /api/embeddings response has no 'embedding' array`,
358
+ );
359
+ }
360
+ const f32 = Float32Array.from(json.embedding);
361
+ if (this.#resolvedDim === null) this.#resolvedDim = f32.length;
362
+ out.push(f32);
363
+ }
364
+ return out;
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Canonical-JSON deterministic hash over an embedder configuration.
370
+ * Object keys are sorted lexicographically so the resulting hash is
371
+ * stable across `JSON.stringify` reorderings.
372
+ *
373
+ * @stable
374
+ */
375
+ export function canonicalConfigHash(config: unknown): string {
376
+ return createHash('sha256').update(canonicalize(config), 'utf8').digest('hex');
377
+ }
378
+
379
+ function canonicalize(value: unknown): string {
380
+ if (value === null) return 'null';
381
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
382
+ if (typeof value === 'number') return Number.isFinite(value) ? value.toString() : 'null';
383
+ if (typeof value === 'string') return JSON.stringify(value);
384
+ if (Array.isArray(value)) {
385
+ return `[${value.map(canonicalize).join(',')}]`;
386
+ }
387
+ if (typeof value === 'object') {
388
+ const obj = value as Record<string, unknown>;
389
+ const keys = Object.keys(obj).sort();
390
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(obj[k])}`).join(',')}}`;
391
+ }
392
+ return 'null';
393
+ }
394
+
395
+ /**
396
+ * Strip every trailing `/` from a URL string in `O(n)`. Kept regex-free
397
+ * so CodeQL does not flag the (otherwise harmless) `/+` anchored
398
+ * quantifier as a polynomial-redos vector.
399
+ */
400
+ function stripTrailingSlashesLocal(url: string): string {
401
+ let end = url.length;
402
+ while (end > 0 && url.charCodeAt(end - 1) === 0x2f) end -= 1;
403
+ return end === url.length ? url : url.slice(0, end);
404
+ }