@oortmem/local-models 0.0.0-stage → 0.4.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/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/README.md CHANGED
@@ -1,3 +1,28 @@
1
- # Temporary Holding Version
1
+ # @oortmem/local-models
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Embeddings and reranking for Oort on this machine's CPU, through ONNX Runtime. No API keys, no account, and no network after the one-time download.
4
+
5
+ ```sh
6
+ npm install -g oortmem @oortmem/local-models
7
+ oort models pull # downloads the default models (58 MB) into ~/.oort/models and switches to them
8
+ ```
9
+
10
+ | Role | Default | Size | Licence | Upstream |
11
+ | ------ | ----------------------- | ----- | ---------- | ----------------------------------- |
12
+ | embed | `bge-small-en-v1.5` | 34 MB | MIT | BAAI/bge-small-en-v1.5 |
13
+ | rerank | `ms-marco-minilm-l6-v2` | 23 MB | Apache-2.0 | cross-encoder/ms-marco-MiniLM-L6-v2 |
14
+
15
+ Larger multilingual models (`qwen3-embedding-0.6b`, `bge-reranker-v2-m3`, about 600 MB each) are available with `oort models pull <id>`; they take about a second to load, so they suit `oort mcp` better than per-prompt hooks.
16
+
17
+ Every file is pinned to a Hugging Face commit and its SHA-256, checked before it is kept. Models are never downloaded during a hook or a recall, only by `oort models pull` or `oort setup`.
18
+
19
+ ```ts
20
+ import { localEmbedder, localReranker } from '@oortmem/local-models';
21
+
22
+ const embed = localEmbedder({ home: '/path/to/.oort', dimension: 512 });
23
+ const rerank = localReranker({ home: '/path/to/.oort' });
24
+ ```
25
+
26
+ Runtime dependencies: [onnxruntime-node](https://github.com/microsoft/onnxruntime) (MIT) and [@huggingface/tokenizers](https://github.com/huggingface/tokenizers.js) (Apache-2.0).
27
+
28
+ Part of [Oort](https://github.com/oortmem/oort), open-source memory for AI agents. See the [main README](https://github.com/oortmem/oort#readme) for how the pieces fit. Apache-2.0.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The models Oort can run locally. Each file is pinned to a Hugging Face commit and a SHA-256,
3
+ * so a pull is reproducible and a tampered or truncated download is refused.
4
+ *
5
+ * The defaults are small on purpose: every Claude Code hook is a new process, so a model is
6
+ * loaded from disk once per prompt. Measured on an Apple M1 Max (single thread, CPU time):
7
+ * bge-small loads in about 110 ms (22 ms from the optimised cache) and embeds a query in 6 ms;
8
+ * the MiniLM reranker loads in about 60 ms (13 ms cached) and scores 16 short documents in
9
+ * 110 ms. The large models load in 0.8 to 1 s, need over 1 GB of RAM, and suit the long-lived
10
+ * `oort mcp` server rather than per-prompt hooks.
11
+ */
12
+ export type ModelRole = 'embed' | 'rerank';
13
+ /** One file of a model: where it comes from in the repo, and what it must hash to. */
14
+ export interface ModelFile {
15
+ /** The local file name inside the model's directory. */
16
+ name: 'model.onnx' | 'tokenizer.json' | 'tokenizer_config.json';
17
+ /** Path inside the Hugging Face repository. */
18
+ path: string;
19
+ bytes: number;
20
+ sha256: string;
21
+ }
22
+ interface BaseSpec {
23
+ /** The id `embed.model` / `rerank.model` names, and the directory under `~/.oort/models`. */
24
+ id: string;
25
+ role: ModelRole;
26
+ /** One line for `oort models`. */
27
+ label: string;
28
+ /** `small` loads fast enough for hooks; `large` is for the long-lived MCP server. */
29
+ size: 'small' | 'large';
30
+ /** The ONNX conversion that is downloaded, pinned to a commit. */
31
+ source: {
32
+ repo: string;
33
+ revision: string;
34
+ };
35
+ /** The original model the conversion was made from. */
36
+ upstream: string;
37
+ /** SPDX id of the model weights' licence (all allow commercial use). */
38
+ licence: string;
39
+ files: readonly ModelFile[];
40
+ /** Longest input in tokens, special tokens included. */
41
+ maxTokens: number;
42
+ }
43
+ export interface EmbedModelSpec extends BaseSpec {
44
+ role: 'embed';
45
+ /** The width of the model's own embedding. */
46
+ nativeDimension: number;
47
+ /** `cls`: the first token; `mean`: masked mean; `last`: the last real token. */
48
+ pooling: 'cls' | 'mean' | 'last';
49
+ /** Trained with Matryoshka loss, so a prefix of the embedding is itself an embedding. */
50
+ matryoshka: boolean;
51
+ queryPrefix: string;
52
+ documentPrefix: string;
53
+ /** A decoder export that takes (empty) past key/value inputs. */
54
+ kvCache?: {
55
+ layers: number;
56
+ heads: number;
57
+ headDim: number;
58
+ };
59
+ }
60
+ export interface RerankModelSpec extends BaseSpec {
61
+ role: 'rerank';
62
+ /**
63
+ * The score is `sigmoid(scale * logit + shift)`. MS MARCO cross-encoders emit uncalibrated
64
+ * logits (unrelated pairs sit near -10); this maps them onto the 0 to 1 range that
65
+ * `recall.minRerankScore` (0.35) was set for. Provisional until calibrated on the eval.
66
+ */
67
+ calibration: {
68
+ scale: number;
69
+ shift: number;
70
+ };
71
+ }
72
+ export type ModelSpec = EmbedModelSpec | RerankModelSpec;
73
+ export declare const MODELS: readonly ModelSpec[];
74
+ export declare const DEFAULT_EMBED_MODEL = "bge-small-en-v1.5";
75
+ export declare const DEFAULT_RERANK_MODEL = "ms-marco-minilm-l6-v2";
76
+ /** The catalog entry for `id`, or undefined. */
77
+ export declare function findModel(id: string): ModelSpec | undefined;
78
+ /** Total download size of `spec` in bytes. */
79
+ export declare function downloadBytes(spec: ModelSpec): number;
80
+ export {};
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The models Oort can run locally. Each file is pinned to a Hugging Face commit and a SHA-256,
3
+ * so a pull is reproducible and a tampered or truncated download is refused.
4
+ *
5
+ * The defaults are small on purpose: every Claude Code hook is a new process, so a model is
6
+ * loaded from disk once per prompt. Measured on an Apple M1 Max (single thread, CPU time):
7
+ * bge-small loads in about 110 ms (22 ms from the optimised cache) and embeds a query in 6 ms;
8
+ * the MiniLM reranker loads in about 60 ms (13 ms cached) and scores 16 short documents in
9
+ * 110 ms. The large models load in 0.8 to 1 s, need over 1 GB of RAM, and suit the long-lived
10
+ * `oort mcp` server rather than per-prompt hooks.
11
+ */
12
+ const HF_TOKENIZER_JSON_BERT_UNCASED = {
13
+ name: 'tokenizer.json',
14
+ path: 'tokenizer.json',
15
+ bytes: 711_396,
16
+ sha256: 'd241a60d5e8f04cc1b2b3e9ef7a4921b27bf526d9f6050ab90f9267a1f9e5c66',
17
+ };
18
+ export const MODELS = [
19
+ {
20
+ id: 'bge-small-en-v1.5',
21
+ role: 'embed',
22
+ label: 'BGE small, English (int8, 34 MB)',
23
+ size: 'small',
24
+ source: {
25
+ repo: 'Xenova/bge-small-en-v1.5',
26
+ revision: 'ea104dacec62c0de699686887e3f920caeb4f3e3',
27
+ },
28
+ upstream: 'BAAI/bge-small-en-v1.5',
29
+ licence: 'MIT',
30
+ files: [
31
+ {
32
+ name: 'model.onnx',
33
+ path: 'onnx/model_int8.onnx',
34
+ bytes: 33_760_831,
35
+ sha256: 'bf64d05457cb391fa88d045faf5927a15ea36d96228ddf23ea970087afdc1197',
36
+ },
37
+ HF_TOKENIZER_JSON_BERT_UNCASED,
38
+ {
39
+ name: 'tokenizer_config.json',
40
+ path: 'tokenizer_config.json',
41
+ bytes: 366,
42
+ sha256: '9261e7d79b44c8195c1cada2b453e55b00aeb81e907a6664974b4d7776172ab3',
43
+ },
44
+ ],
45
+ maxTokens: 512,
46
+ nativeDimension: 384,
47
+ pooling: 'cls',
48
+ matryoshka: false,
49
+ queryPrefix: 'Represent this sentence for searching relevant passages: ',
50
+ documentPrefix: '',
51
+ },
52
+ {
53
+ id: 'ms-marco-minilm-l6-v2',
54
+ role: 'rerank',
55
+ label: 'MS MARCO MiniLM-L6 cross-encoder, English (int8, 23 MB)',
56
+ size: 'small',
57
+ source: {
58
+ repo: 'Xenova/ms-marco-MiniLM-L-6-v2',
59
+ revision: 'a09144355adeed5f58c8ed011d209bf8ee5a1fec',
60
+ },
61
+ upstream: 'cross-encoder/ms-marco-MiniLM-L6-v2',
62
+ licence: 'Apache-2.0',
63
+ files: [
64
+ {
65
+ name: 'model.onnx',
66
+ path: 'onnx/model_int8.onnx',
67
+ bytes: 23_012_404,
68
+ sha256: 'a13ec391ca99f49886694e12d3e800521f36d4267d7d448c34421c541a2baf50',
69
+ },
70
+ HF_TOKENIZER_JSON_BERT_UNCASED,
71
+ {
72
+ name: 'tokenizer_config.json',
73
+ path: 'tokenizer_config.json',
74
+ bytes: 1_242,
75
+ sha256: '0b29c7bfc889e53b36d9dd3e686dd4300f6525110eaa98c76a5dafceb2029f53',
76
+ },
77
+ ],
78
+ maxTokens: 512,
79
+ calibration: { scale: 0.5, shift: 2.4 },
80
+ },
81
+ {
82
+ id: 'qwen3-embedding-0.6b',
83
+ role: 'embed',
84
+ label: 'Qwen3 Embedding 0.6B, multilingual (int8, 614 MB; slow to load, for oort mcp)',
85
+ size: 'large',
86
+ source: {
87
+ repo: 'onnx-community/Qwen3-Embedding-0.6B-ONNX',
88
+ revision: 'c25a394dd583836952667c12f008335071b3f43d',
89
+ },
90
+ upstream: 'Qwen/Qwen3-Embedding-0.6B',
91
+ licence: 'Apache-2.0',
92
+ files: [
93
+ {
94
+ name: 'model.onnx',
95
+ path: 'onnx/model_int8.onnx',
96
+ bytes: 613_527_539,
97
+ sha256: '6d0ea863f78b4a84afa3c7fcba1ec341572b5e28121aef77b7092b1dfdf679c7',
98
+ },
99
+ {
100
+ name: 'tokenizer.json',
101
+ path: 'tokenizer.json',
102
+ bytes: 11_423_705,
103
+ sha256: 'def76fb086971c7867b829c23a26261e38d9d74e02139253b38aeb9df8b4b50a',
104
+ },
105
+ {
106
+ name: 'tokenizer_config.json',
107
+ path: 'tokenizer_config.json',
108
+ bytes: 9_731,
109
+ sha256: '977648852447cb6587327ff3205b0a84cf2fc9f05621d6c8e88a497caafab2e1',
110
+ },
111
+ ],
112
+ maxTokens: 1024,
113
+ nativeDimension: 1024,
114
+ pooling: 'last',
115
+ matryoshka: true,
116
+ queryPrefix: 'Instruct: Given a question about a software project, retrieve the note that answers it\nQuery:',
117
+ documentPrefix: '',
118
+ kvCache: { layers: 28, heads: 8, headDim: 128 },
119
+ },
120
+ {
121
+ id: 'bge-reranker-v2-m3',
122
+ role: 'rerank',
123
+ label: 'BGE reranker v2 m3, multilingual (int8, 571 MB; slow, for oort mcp)',
124
+ size: 'large',
125
+ source: {
126
+ repo: 'onnx-community/bge-reranker-v2-m3-ONNX',
127
+ revision: '6f5ff65298512715a1e669753bc754d2bc8f367b',
128
+ },
129
+ upstream: 'BAAI/bge-reranker-v2-m3',
130
+ licence: 'Apache-2.0',
131
+ files: [
132
+ {
133
+ name: 'model.onnx',
134
+ path: 'onnx/model_int8.onnx',
135
+ bytes: 570_727_094,
136
+ sha256: '912fc1215c2dbff6499700534bd8d31253af01573861abbfc43afd1fab6cce5d',
137
+ },
138
+ {
139
+ name: 'tokenizer.json',
140
+ path: 'tokenizer.json',
141
+ bytes: 17_082_900,
142
+ sha256: '8bf8afbfd11306bd872018c53bfdf2e160a56f8edbcf49933324404791c148d3',
143
+ },
144
+ {
145
+ name: 'tokenizer_config.json',
146
+ path: 'tokenizer_config.json',
147
+ bytes: 1_203,
148
+ sha256: 'b87c8703482b0300d3da30e201519aa641f6a450f5eb5bf1e624afbf70c74d80',
149
+ },
150
+ ],
151
+ maxTokens: 512,
152
+ calibration: { scale: 1, shift: 0 },
153
+ },
154
+ ];
155
+ export const DEFAULT_EMBED_MODEL = 'bge-small-en-v1.5';
156
+ export const DEFAULT_RERANK_MODEL = 'ms-marco-minilm-l6-v2';
157
+ /** The catalog entry for `id`, or undefined. */
158
+ export function findModel(id) {
159
+ return MODELS.find((m) => m.id === id);
160
+ }
161
+ /** Total download size of `spec` in bytes. */
162
+ export function downloadBytes(spec) {
163
+ return spec.files.reduce((n, f) => n + f.bytes, 0);
164
+ }
165
+ //# sourceMappingURL=catalog.js.map
@@ -0,0 +1,32 @@
1
+ import type { Embedder } from '@oortmem/core';
2
+ import { type EmbedModelSpec } from './catalog.js';
3
+ import { type Runtime } from './runtime.js';
4
+ export interface LocalEmbedderOptions {
5
+ /** `$OORT_HOME`; models live in `<home>/models/<id>`. */
6
+ home: string;
7
+ /** Catalog id, default `bge-small-en-v1.5`. */
8
+ model?: string;
9
+ /**
10
+ * The store's dimension. A narrower model is zero-padded to it (cosine similarity is
11
+ * unchanged); a wider Matryoshka model is truncated to it; a wider plain model is refused.
12
+ */
13
+ dimension: number;
14
+ /** Default: ONNX Runtime. */
15
+ runtime?: Runtime;
16
+ threads?: number;
17
+ /** Texts per forward pass (default 16). */
18
+ batchSize?: number;
19
+ /** A model outside the catalog (tests, experiments); used when its id is `model`. */
20
+ spec?: EmbedModelSpec;
21
+ }
22
+ export interface LocalEmbedder extends Embedder {
23
+ readonly model: string;
24
+ /** Loads the model now instead of on the first `embed` (a warm process can preload). */
25
+ load(): Promise<void>;
26
+ }
27
+ /**
28
+ * Embeddings on this machine's CPU. Nothing is loaded until the first `embed` (or `load`):
29
+ * constructing one only checks that the model was pulled, so a hook whose lexical search
30
+ * answers on its own pays nothing. Vectors are unit length, then int8 with a per-vector scale.
31
+ */
32
+ export declare function localEmbedder(opts: LocalEmbedderOptions): LocalEmbedder;
package/dist/embed.js ADDED
@@ -0,0 +1,78 @@
1
+ import { DEFAULT_EMBED_MODEL } from './catalog.js';
2
+ import { clipChars, clipTokens, normalize, quantize, toBatch } from './encode.js';
3
+ import { LocalModelError, readyModel } from './errors.js';
4
+ import { lazyLoad, onnxRuntime } from './runtime.js';
5
+ /**
6
+ * Embeddings on this machine's CPU. Nothing is loaded until the first `embed` (or `load`):
7
+ * constructing one only checks that the model was pulled, so a hook whose lexical search
8
+ * answers on its own pays nothing. Vectors are unit length, then int8 with a per-vector scale.
9
+ */
10
+ export function localEmbedder(opts) {
11
+ const id = opts.model ?? DEFAULT_EMBED_MODEL;
12
+ const { spec: s, dir } = readyModel(opts.home, 'embed', id, opts.spec);
13
+ const spec = s;
14
+ const { dimension } = opts;
15
+ if (spec.nativeDimension > dimension && !spec.matryoshka) {
16
+ throw new LocalModelError('dimension', `local model ${id} has ${spec.nativeDimension} dimensions and cannot be truncated to the store's ${dimension}.`);
17
+ }
18
+ const keep = Math.min(spec.nativeDimension, dimension);
19
+ const runtime = opts.runtime ?? onnxRuntime();
20
+ const batchSize = opts.batchSize ?? 16;
21
+ const load = lazyLoad(runtime, dir, opts.threads);
22
+ async function forward(m, texts) {
23
+ const encodings = texts.map((t) => clipTokens(m.tokenizer.encode(clipChars(t, spec.maxTokens)), spec.maxTokens));
24
+ const batch = toBatch(encodings, m.session.inputNames, spec.kvCache);
25
+ const out = (await m.session.run(batch.feeds, ['last_hidden_state']))['last_hidden_state'];
26
+ if (out === undefined || out.dims.length !== 3) {
27
+ throw new Error(`local model ${id}: unexpected output shape`);
28
+ }
29
+ const hidden = out.dims[2];
30
+ if (hidden !== spec.nativeDimension) {
31
+ throw new Error(`local model ${id}: output has ${hidden} dimensions, expected ${spec.nativeDimension}`);
32
+ }
33
+ const L = batch.length;
34
+ const d = out.data;
35
+ return batch.lengths.map((len, i) => {
36
+ const v = new Float32Array(hidden);
37
+ const row = (j) => (i * L + j) * hidden;
38
+ if (spec.pooling === 'cls')
39
+ v.set(d.subarray(row(0), row(0) + hidden));
40
+ else if (spec.pooling === 'last')
41
+ v.set(d.subarray(row(len - 1), row(len - 1) + hidden));
42
+ else {
43
+ for (let j = 0; j < len; j++) {
44
+ const at = row(j);
45
+ for (let h = 0; h < hidden; h++)
46
+ v[h] = v[h] + d[at + h];
47
+ }
48
+ for (let h = 0; h < hidden; h++)
49
+ v[h] = v[h] / len;
50
+ }
51
+ // Matryoshka: normalise the prefix; a narrow model is padded with zeros after it.
52
+ const prefix = normalize(v.slice(0, keep));
53
+ const full = new Float32Array(dimension);
54
+ full.set(prefix);
55
+ return quantize(full);
56
+ });
57
+ }
58
+ return {
59
+ dimension,
60
+ model: id,
61
+ async load() {
62
+ await load();
63
+ },
64
+ async embed(texts, kind) {
65
+ if (texts.length === 0)
66
+ return [];
67
+ const m = await load();
68
+ const prefix = kind === 'query' ? spec.queryPrefix : spec.documentPrefix;
69
+ const out = [];
70
+ for (let i = 0; i < texts.length; i += batchSize) {
71
+ const chunk = texts.slice(i, i + batchSize).map((t) => prefix + t);
72
+ out.push(...(await forward(m, chunk)));
73
+ }
74
+ return out;
75
+ },
76
+ };
77
+ }
78
+ //# sourceMappingURL=embed.js.map
@@ -0,0 +1,31 @@
1
+ import type { Encoding, Feed } from './runtime.js';
2
+ /** Roughly four characters per token: text past this many characters cannot fit anyway. */
3
+ export declare function clipChars(text: string, maxTokens: number): string;
4
+ /**
5
+ * At most `maxTokens` tokens. The tail is cut but the final special token (`[SEP]`, `</s>`,
6
+ * `<|endoftext|>`) is kept, so a cross-encoder pair loses the end of its document, never the
7
+ * structure, and last-token pooling still reads the end-of-text token.
8
+ */
9
+ export declare function clipTokens(e: Encoding, maxTokens: number): Encoding;
10
+ export interface Batch {
11
+ feeds: Record<string, Feed>;
12
+ /** Padded sequence length. */
13
+ length: number;
14
+ /** Real (unpadded) length of each row. */
15
+ lengths: number[];
16
+ }
17
+ /**
18
+ * Right-padded int64 inputs for the names the session declares: `input_ids`, `attention_mask`,
19
+ * and when declared `token_type_ids` and `position_ids`; a decoder export also gets empty past
20
+ * key/value tensors.
21
+ */
22
+ export declare function toBatch(encodings: Encoding[], inputNames: readonly string[], kvCache?: {
23
+ layers: number;
24
+ heads: number;
25
+ headDim: number;
26
+ }): Batch;
27
+ /** Float vector to int8 with a per-vector scale (cosine similarity is scale-free). */
28
+ export declare function quantize(v: Float32Array): Int8Array;
29
+ /** In place: unit length (a zero vector stays zero). */
30
+ export declare function normalize(v: Float32Array): Float32Array;
31
+ export declare const sigmoid: (x: number) => number;
package/dist/encode.js ADDED
@@ -0,0 +1,91 @@
1
+ /** Roughly four characters per token: text past this many characters cannot fit anyway. */
2
+ export function clipChars(text, maxTokens) {
3
+ const max = maxTokens * 6;
4
+ return text.length > max ? text.slice(0, max) : text;
5
+ }
6
+ /**
7
+ * At most `maxTokens` tokens. The tail is cut but the final special token (`[SEP]`, `</s>`,
8
+ * `<|endoftext|>`) is kept, so a cross-encoder pair loses the end of its document, never the
9
+ * structure, and last-token pooling still reads the end-of-text token.
10
+ */
11
+ export function clipTokens(e, maxTokens) {
12
+ if (e.ids.length <= maxTokens)
13
+ return e;
14
+ const last = e.ids.length - 1;
15
+ const out = { ids: [...e.ids.slice(0, maxTokens - 1), e.ids[last]] };
16
+ if (e.token_type_ids !== undefined) {
17
+ const tt = e.token_type_ids;
18
+ out.token_type_ids = [...tt.slice(0, maxTokens - 1), tt[last] ?? 0];
19
+ }
20
+ return out;
21
+ }
22
+ /**
23
+ * Right-padded int64 inputs for the names the session declares: `input_ids`, `attention_mask`,
24
+ * and when declared `token_type_ids` and `position_ids`; a decoder export also gets empty past
25
+ * key/value tensors.
26
+ */
27
+ export function toBatch(encodings, inputNames, kvCache) {
28
+ const n = encodings.length;
29
+ const length = Math.max(1, ...encodings.map((e) => e.ids.length));
30
+ const ids = new BigInt64Array(n * length);
31
+ const mask = new BigInt64Array(n * length);
32
+ const types = new BigInt64Array(n * length);
33
+ const positions = new BigInt64Array(n * length);
34
+ encodings.forEach((e, i) => {
35
+ for (let j = 0; j < e.ids.length; j++) {
36
+ const at = i * length + j;
37
+ ids[at] = BigInt(e.ids[j]);
38
+ mask[at] = 1n;
39
+ types[at] = BigInt(e.token_type_ids?.[j] ?? 0);
40
+ positions[at] = BigInt(j);
41
+ }
42
+ });
43
+ const dims = [n, length];
44
+ const feeds = {
45
+ input_ids: { type: 'int64', data: ids, dims },
46
+ attention_mask: { type: 'int64', data: mask, dims },
47
+ };
48
+ if (inputNames.includes('token_type_ids'))
49
+ feeds['token_type_ids'] = { type: 'int64', data: types, dims };
50
+ if (inputNames.includes('position_ids'))
51
+ feeds['position_ids'] = { type: 'int64', data: positions, dims };
52
+ if (kvCache !== undefined) {
53
+ const empty = [n, kvCache.heads, 0, kvCache.headDim];
54
+ for (let l = 0; l < kvCache.layers; l++) {
55
+ for (const kv of ['key', 'value']) {
56
+ feeds[`past_key_values.${l}.${kv}`] = {
57
+ type: 'float32',
58
+ data: new Float32Array(0),
59
+ dims: empty,
60
+ };
61
+ }
62
+ }
63
+ }
64
+ return { feeds, length, lengths: encodings.map((e) => e.ids.length) };
65
+ }
66
+ /** Float vector to int8 with a per-vector scale (cosine similarity is scale-free). */
67
+ export function quantize(v) {
68
+ let max = 0;
69
+ for (const x of v)
70
+ max = Math.max(max, Math.abs(x));
71
+ const out = new Int8Array(v.length);
72
+ if (max === 0)
73
+ return out;
74
+ const s = 127 / max;
75
+ for (let i = 0; i < v.length; i++)
76
+ out[i] = Math.round(v[i] * s);
77
+ return out;
78
+ }
79
+ /** In place: unit length (a zero vector stays zero). */
80
+ export function normalize(v) {
81
+ let s = 0;
82
+ for (const x of v)
83
+ s += x * x;
84
+ const n = Math.sqrt(s);
85
+ if (n > 0)
86
+ for (let i = 0; i < v.length; i++)
87
+ v[i] = v[i] / n;
88
+ return v;
89
+ }
90
+ export const sigmoid = (x) => 1 / (1 + Math.exp(-x));
91
+ //# sourceMappingURL=encode.js.map
@@ -0,0 +1,12 @@
1
+ import { type ModelRole, type ModelSpec } from './catalog.js';
2
+ export type LocalModelErrorKind = 'unknown-model' | 'wrong-role' | 'not-ready' | 'dimension';
3
+ /** A local model that cannot be used as configured; the message names the fix. */
4
+ export declare class LocalModelError extends Error {
5
+ readonly kind: LocalModelErrorKind;
6
+ constructor(kind: LocalModelErrorKind, message: string);
7
+ }
8
+ /** The spec for `id` in `role`, pulled and complete, or a `LocalModelError` saying how to fix it. */
9
+ export declare function readyModel(home: string, role: ModelRole, id: string, custom?: ModelSpec): {
10
+ spec: ModelSpec;
11
+ dir: string;
12
+ };
package/dist/errors.js ADDED
@@ -0,0 +1,34 @@
1
+ import { findModel, MODELS } from './catalog.js';
2
+ import { modelDir, modelState } from './files.js';
3
+ /** A local model that cannot be used as configured; the message names the fix. */
4
+ export class LocalModelError extends Error {
5
+ kind;
6
+ constructor(kind, message) {
7
+ super(message);
8
+ this.kind = kind;
9
+ this.name = 'LocalModelError';
10
+ }
11
+ }
12
+ const STATE_FIX = {
13
+ missing: 'is not downloaded',
14
+ incomplete: 'is incompletely downloaded',
15
+ outdated: 'was downloaded for another version of Oort',
16
+ };
17
+ /** The spec for `id` in `role`, pulled and complete, or a `LocalModelError` saying how to fix it. */
18
+ export function readyModel(home, role, id, custom) {
19
+ const spec = custom?.id === id ? custom : findModel(id);
20
+ const key = role === 'embed' ? 'embed.model' : 'rerank.model';
21
+ if (spec === undefined) {
22
+ const ids = MODELS.filter((m) => m.role === role).map((m) => m.id);
23
+ throw new LocalModelError('unknown-model', `${key} "${id}" is not a local model; use one of ${ids.join(', ')} (oort models).`);
24
+ }
25
+ if (spec.role !== role) {
26
+ throw new LocalModelError('wrong-role', `${key} "${id}" is a ${spec.role} model.`);
27
+ }
28
+ const state = modelState(home, spec);
29
+ if (state !== 'ready') {
30
+ throw new LocalModelError('not-ready', `local model ${id} ${STATE_FIX[state]}; run \`oort models pull ${id}\`.`);
31
+ }
32
+ return { spec, dir: modelDir(home, id) };
33
+ }
34
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1,64 @@
1
+ import type { ModelFile, ModelSpec } from './catalog.js';
2
+ /** Written last by a successful pull: the model is complete and its checksums matched. */
3
+ export declare const MANIFEST = "oort-model.json";
4
+ /** Where model files are fetched from; `OORT_MODELS_URL` points `oort models pull` at a mirror. */
5
+ export declare const HF_BASE_URL = "https://huggingface.co";
6
+ export interface Manifest {
7
+ id: string;
8
+ revision: string;
9
+ files: {
10
+ name: string;
11
+ bytes: number;
12
+ sha256: string;
13
+ }[];
14
+ pulledAt: string;
15
+ }
16
+ /** `$OORT_HOME/models`. */
17
+ export declare function modelsRoot(home: string): string;
18
+ export declare function modelDir(home: string, id: string): string;
19
+ /**
20
+ * - `ready`: pulled, the manifest matches the catalog, every file has its size.
21
+ * - `missing`: never pulled (or removed).
22
+ * - `incomplete`: a pull did not finish, or a file is gone or has the wrong size.
23
+ * - `outdated`: pulled for another revision or checksum than this version of Oort pins.
24
+ */
25
+ export type ModelState = 'ready' | 'missing' | 'incomplete' | 'outdated';
26
+ /**
27
+ * Cheap check (a manifest read and a stat per file, no hashing), safe on every hook. A full
28
+ * re-hash is `verifyModel`.
29
+ */
30
+ export declare function modelState(home: string, spec: ModelSpec): ModelState;
31
+ /** Re-hashes every file of a pulled model; lists each file that does not match its pin. */
32
+ export declare function verifyModel(home: string, spec: ModelSpec): Promise<{
33
+ ok: boolean;
34
+ bad: string[];
35
+ }>;
36
+ /** A download that does not hash to its pin (or has the wrong size): nothing is kept. */
37
+ export declare class ChecksumError extends Error {
38
+ readonly model: string;
39
+ readonly file: string;
40
+ constructor(model: string, file: string, detail: string);
41
+ }
42
+ export interface PullOptions {
43
+ /** Default: the global fetch. Only `pullModel` touches the network. */
44
+ fetch?: typeof fetch;
45
+ /** Default `HF_BASE_URL`. */
46
+ baseUrl?: string;
47
+ /** Called as bytes arrive, per file. */
48
+ onProgress?: (p: {
49
+ file: string;
50
+ received: number;
51
+ total: number;
52
+ }) => void;
53
+ }
54
+ export declare function fileUrl(spec: ModelSpec, f: ModelFile, baseUrl?: string): string;
55
+ /**
56
+ * Downloads every file of `spec` into `$OORT_HOME/models/<id>` (owner-only), verifying each
57
+ * against its pinned size and SHA-256 before it is moved into place, then writes the manifest.
58
+ * A file already present with the right hash is kept, so an interrupted pull resumes. On any
59
+ * failure the manifest is absent, so the model stays unusable rather than half-trusted.
60
+ * Returns the number of bytes downloaded.
61
+ */
62
+ export declare function pullModel(home: string, spec: ModelSpec, opts?: PullOptions): Promise<number>;
63
+ /** Deletes a model's directory (files, manifest, optimised cache). True when it existed. */
64
+ export declare function removeModel(home: string, id: string): boolean;
package/dist/files.js ADDED
@@ -0,0 +1,167 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { ensurePrivateDir, writePrivateFile } from '@oortmem/core';
5
+ /** Written last by a successful pull: the model is complete and its checksums matched. */
6
+ export const MANIFEST = 'oort-model.json';
7
+ /** Where model files are fetched from; `OORT_MODELS_URL` points `oort models pull` at a mirror. */
8
+ export const HF_BASE_URL = 'https://huggingface.co';
9
+ /** `$OORT_HOME/models`. */
10
+ export function modelsRoot(home) {
11
+ return path.join(home, 'models');
12
+ }
13
+ export function modelDir(home, id) {
14
+ return path.join(modelsRoot(home), id);
15
+ }
16
+ function readManifest(dir) {
17
+ try {
18
+ return JSON.parse(fs.readFileSync(path.join(dir, MANIFEST), 'utf8'));
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ /**
25
+ * Cheap check (a manifest read and a stat per file, no hashing), safe on every hook. A full
26
+ * re-hash is `verifyModel`.
27
+ */
28
+ export function modelState(home, spec) {
29
+ const dir = modelDir(home, spec.id);
30
+ if (!fs.existsSync(dir))
31
+ return 'missing';
32
+ const manifest = readManifest(dir);
33
+ if (manifest === undefined)
34
+ return 'incomplete';
35
+ const pinned = spec.files.every((f) => manifest.files.some((m) => m.name === f.name && m.sha256 === f.sha256 && m.bytes === f.bytes));
36
+ if (manifest.revision !== spec.source.revision || !pinned)
37
+ return 'outdated';
38
+ for (const f of spec.files) {
39
+ let size;
40
+ try {
41
+ size = fs.statSync(path.join(dir, f.name)).size;
42
+ }
43
+ catch {
44
+ return 'incomplete';
45
+ }
46
+ if (size !== f.bytes)
47
+ return 'incomplete';
48
+ }
49
+ return 'ready';
50
+ }
51
+ async function sha256Of(file) {
52
+ const hash = createHash('sha256');
53
+ for await (const chunk of fs.createReadStream(file))
54
+ hash.update(chunk);
55
+ return hash.digest('hex');
56
+ }
57
+ /** Re-hashes every file of a pulled model; lists each file that does not match its pin. */
58
+ export async function verifyModel(home, spec) {
59
+ const dir = modelDir(home, spec.id);
60
+ const bad = [];
61
+ for (const f of spec.files) {
62
+ const file = path.join(dir, f.name);
63
+ if (!fs.existsSync(file) || (await sha256Of(file)) !== f.sha256)
64
+ bad.push(f.name);
65
+ }
66
+ return { ok: bad.length === 0, bad };
67
+ }
68
+ /** A download that does not hash to its pin (or has the wrong size): nothing is kept. */
69
+ export class ChecksumError extends Error {
70
+ model;
71
+ file;
72
+ constructor(model, file, detail) {
73
+ super(`${model}: ${file} failed verification (${detail}); nothing was kept`);
74
+ this.model = model;
75
+ this.file = file;
76
+ this.name = 'ChecksumError';
77
+ }
78
+ }
79
+ export function fileUrl(spec, f, baseUrl = HF_BASE_URL) {
80
+ const root = baseUrl.replace(/\/+$/, '');
81
+ return `${root}/${spec.source.repo}/resolve/${spec.source.revision}/${f.path}`;
82
+ }
83
+ async function download(spec, f, dest, opts) {
84
+ const doFetch = opts.fetch ?? globalThis.fetch;
85
+ const res = await doFetch(fileUrl(spec, f, opts.baseUrl), { redirect: 'follow' });
86
+ if (!res.ok || res.body === null) {
87
+ void res.body?.cancel().catch(() => undefined);
88
+ throw new Error(`${spec.id}: ${f.name} download failed (HTTP ${res.status})`);
89
+ }
90
+ const part = `${dest}.${process.pid}-${randomBytes(4).toString('hex')}.part`;
91
+ const hash = createHash('sha256');
92
+ let received = 0;
93
+ const fd = fs.openSync(part, 'wx', 0o600);
94
+ try {
95
+ try {
96
+ for await (const chunk of res.body) {
97
+ received += chunk.byteLength;
98
+ if (received > f.bytes) {
99
+ throw new ChecksumError(spec.id, f.name, `more than the expected ${f.bytes} bytes`);
100
+ }
101
+ hash.update(chunk);
102
+ fs.writeSync(fd, chunk);
103
+ opts.onProgress?.({ file: f.name, received, total: f.bytes });
104
+ }
105
+ fs.fsyncSync(fd);
106
+ }
107
+ finally {
108
+ fs.closeSync(fd);
109
+ }
110
+ if (received !== f.bytes) {
111
+ throw new ChecksumError(spec.id, f.name, `${received} bytes, expected ${f.bytes}`);
112
+ }
113
+ const digest = hash.digest('hex');
114
+ if (digest !== f.sha256)
115
+ throw new ChecksumError(spec.id, f.name, 'SHA-256 mismatch');
116
+ fs.renameSync(part, dest);
117
+ }
118
+ catch (e) {
119
+ fs.rmSync(part, { force: true });
120
+ throw e;
121
+ }
122
+ }
123
+ /**
124
+ * Downloads every file of `spec` into `$OORT_HOME/models/<id>` (owner-only), verifying each
125
+ * against its pinned size and SHA-256 before it is moved into place, then writes the manifest.
126
+ * A file already present with the right hash is kept, so an interrupted pull resumes. On any
127
+ * failure the manifest is absent, so the model stays unusable rather than half-trusted.
128
+ * Returns the number of bytes downloaded.
129
+ */
130
+ export async function pullModel(home, spec, opts = {}) {
131
+ const dir = modelDir(home, spec.id);
132
+ ensurePrivateDir(modelsRoot(home));
133
+ ensurePrivateDir(dir);
134
+ fs.rmSync(path.join(dir, MANIFEST), { force: true });
135
+ let downloaded = 0;
136
+ for (const f of spec.files) {
137
+ const dest = path.join(dir, f.name);
138
+ if (fs.existsSync(dest) &&
139
+ fs.statSync(dest).size === f.bytes &&
140
+ (await sha256Of(dest)) === f.sha256) {
141
+ continue;
142
+ }
143
+ fs.rmSync(dest, { force: true });
144
+ await download(spec, f, dest, opts);
145
+ downloaded += f.bytes;
146
+ }
147
+ const manifest = {
148
+ id: spec.id,
149
+ revision: spec.source.revision,
150
+ files: spec.files.map((f) => ({ name: f.name, bytes: f.bytes, sha256: f.sha256 })),
151
+ pulledAt: new Date().toISOString(),
152
+ };
153
+ writePrivateFile(path.join(dir, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
154
+ return downloaded;
155
+ }
156
+ /** Deletes a model's directory (files, manifest, optimised cache). True when it existed. */
157
+ export function removeModel(home, id) {
158
+ if (id === '' || path.basename(id) !== id || id.startsWith('.')) {
159
+ throw new Error(`not a model id: ${JSON.stringify(id)}`);
160
+ }
161
+ const dir = modelDir(home, id);
162
+ if (!fs.existsSync(dir))
163
+ return false;
164
+ fs.rmSync(dir, { recursive: true, force: true });
165
+ return true;
166
+ }
167
+ //# sourceMappingURL=files.js.map
@@ -0,0 +1,6 @@
1
+ export { DEFAULT_EMBED_MODEL, DEFAULT_RERANK_MODEL, MODELS, downloadBytes, findModel, type EmbedModelSpec, type ModelFile, type ModelRole, type ModelSpec, type RerankModelSpec, } from './catalog.js';
2
+ export { ChecksumError, HF_BASE_URL, MANIFEST, fileUrl, modelDir, modelState, modelsRoot, pullModel, removeModel, verifyModel, type ModelState, type PullOptions, } from './files.js';
3
+ export { LocalModelError, readyModel, type LocalModelErrorKind } from './errors.js';
4
+ export { localEmbedder, type LocalEmbedder, type LocalEmbedderOptions } from './embed.js';
5
+ export { localReranker, type LocalReranker, type LocalRerankerOptions } from './rerank.js';
6
+ export { defaultThreads, onnxRuntime, type Encoding, type Feed, type LoadedModel, type Output, type Runtime, type Session, type TextTokenizer, } from './runtime.js';
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { DEFAULT_EMBED_MODEL, DEFAULT_RERANK_MODEL, MODELS, downloadBytes, findModel, } from './catalog.js';
2
+ export { ChecksumError, HF_BASE_URL, MANIFEST, fileUrl, modelDir, modelState, modelsRoot, pullModel, removeModel, verifyModel, } from './files.js';
3
+ export { LocalModelError, readyModel } from './errors.js';
4
+ export { localEmbedder } from './embed.js';
5
+ export { localReranker } from './rerank.js';
6
+ export { defaultThreads, onnxRuntime, } from './runtime.js';
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,26 @@
1
+ import type { Reranker } from '@oortmem/core';
2
+ import { type RerankModelSpec } from './catalog.js';
3
+ import { type Runtime } from './runtime.js';
4
+ export interface LocalRerankerOptions {
5
+ /** `$OORT_HOME`; models live in `<home>/models/<id>`. */
6
+ home: string;
7
+ /** Catalog id, default `ms-marco-minilm-l6-v2`. */
8
+ model?: string;
9
+ /** Default: ONNX Runtime. */
10
+ runtime?: Runtime;
11
+ threads?: number;
12
+ /** Pairs per forward pass (default 16). */
13
+ batchSize?: number;
14
+ /** A model outside the catalog (tests, experiments); used when its id is `model`. */
15
+ spec?: RerankModelSpec;
16
+ }
17
+ export interface LocalReranker extends Reranker {
18
+ readonly model: string;
19
+ /** Loads the model now instead of on the first `rerank`. */
20
+ load(): Promise<void>;
21
+ }
22
+ /**
23
+ * A cross-encoder on this machine's CPU: each (query, document) pair is scored jointly, and the
24
+ * logit is mapped to 0..1 by the model's calibration. Loaded on the first `rerank`.
25
+ */
26
+ export declare function localReranker(opts: LocalRerankerOptions): LocalReranker;
package/dist/rerank.js ADDED
@@ -0,0 +1,48 @@
1
+ import { DEFAULT_RERANK_MODEL } from './catalog.js';
2
+ import { clipChars, clipTokens, sigmoid, toBatch } from './encode.js';
3
+ import { readyModel } from './errors.js';
4
+ import { lazyLoad, onnxRuntime } from './runtime.js';
5
+ /**
6
+ * A cross-encoder on this machine's CPU: each (query, document) pair is scored jointly, and the
7
+ * logit is mapped to 0..1 by the model's calibration. Loaded on the first `rerank`.
8
+ */
9
+ export function localReranker(opts) {
10
+ const id = opts.model ?? DEFAULT_RERANK_MODEL;
11
+ const { spec: s, dir } = readyModel(opts.home, 'rerank', id, opts.spec);
12
+ const spec = s;
13
+ const load = lazyLoad(opts.runtime ?? onnxRuntime(), dir, opts.threads);
14
+ const batchSize = opts.batchSize ?? 16;
15
+ const { scale, shift } = spec.calibration;
16
+ return {
17
+ model: id,
18
+ async load() {
19
+ await load();
20
+ },
21
+ async rerank(query, docs, topK) {
22
+ if (docs.length === 0)
23
+ return [];
24
+ const m = await load();
25
+ const q = clipChars(query, spec.maxTokens / 4);
26
+ const scores = [];
27
+ for (let i = 0; i < docs.length; i += batchSize) {
28
+ const encodings = docs
29
+ .slice(i, i + batchSize)
30
+ .map((d) => clipTokens(m.tokenizer.encode(q, clipChars(d.text, spec.maxTokens)), spec.maxTokens));
31
+ const batch = toBatch(encodings, m.session.inputNames);
32
+ const out = (await m.session.run(batch.feeds, ['logits']))['logits'];
33
+ if (out === undefined || out.dims[0] !== encodings.length) {
34
+ throw new Error(`local model ${id}: unexpected output shape`);
35
+ }
36
+ const per = out.dims[1] ?? 1;
37
+ for (let j = 0; j < encodings.length; j++) {
38
+ scores.push(sigmoid(scale * out.data[j * per] + shift));
39
+ }
40
+ }
41
+ return docs
42
+ .map((d, i) => ({ id: d.id, score: scores[i] }))
43
+ .sort((a, b) => b.score - a.score)
44
+ .slice(0, Math.max(0, topK));
45
+ },
46
+ };
47
+ }
48
+ //# sourceMappingURL=rerank.js.map
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The seam between Oort's pooling and scoring code and the inference runtime. The default is
3
+ * ONNX Runtime (onnxruntime-node) with Hugging Face's pure-JS tokenizers, both imported on
4
+ * first use only; tests inject a fake.
5
+ */
6
+ export interface Feed {
7
+ type: 'int64' | 'float32';
8
+ data: BigInt64Array | Float32Array;
9
+ dims: number[];
10
+ }
11
+ export interface Output {
12
+ data: Float32Array;
13
+ dims: readonly number[];
14
+ }
15
+ export interface Session {
16
+ readonly inputNames: readonly string[];
17
+ run(feeds: Record<string, Feed>, outputs: readonly string[]): Promise<Record<string, Output>>;
18
+ }
19
+ export interface Encoding {
20
+ ids: number[];
21
+ token_type_ids?: number[];
22
+ }
23
+ export interface TextTokenizer {
24
+ /** Special tokens included; `pair` is the second segment of a cross-encoder input. */
25
+ encode(text: string, pair?: string): Encoding;
26
+ }
27
+ export interface LoadedModel {
28
+ session: Session;
29
+ tokenizer: TextTokenizer;
30
+ }
31
+ export interface Runtime {
32
+ /** Loads the model in `dir` (`model.onnx` plus tokenizer files), preferring the optimised cache. */
33
+ load(dir: string, opts?: {
34
+ threads?: number;
35
+ }): Promise<LoadedModel>;
36
+ /**
37
+ * Writes the optimised cache beside `model.onnx` (graph optimisations already applied, so a
38
+ * later load skips them: about 5x faster for the default models). Run once after a pull.
39
+ */
40
+ optimize?(dir: string): Promise<void>;
41
+ }
42
+ /** Intra-op threads: up to 4. Small batches gain little beyond that, and hooks share the CPU. */
43
+ export declare function defaultThreads(): number;
44
+ export declare function onnxRuntime(): Runtime;
45
+ /**
46
+ * Loads `dir` on the first call and shares the result; a failed load is retried on the next
47
+ * call rather than remembered.
48
+ */
49
+ export declare function lazyLoad(runtime: Runtime, dir: string, threads: number | undefined): () => Promise<LoadedModel>;
@@ -0,0 +1,116 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ /** Intra-op threads: up to 4. Small batches gain little beyond that, and hooks share the CPU. */
6
+ export function defaultThreads() {
7
+ return Math.max(1, Math.min(4, os.availableParallelism()));
8
+ }
9
+ /** The cache file name carries the runtime version: a runtime upgrade never loads a stale one. */
10
+ function cacheFile(dir, ort) {
11
+ const v = ort.env.versions.node ?? ort.env.versions.common;
12
+ return path.join(dir, `model.ort-${v}.opt.onnx`);
13
+ }
14
+ async function loadTokenizer(dir) {
15
+ const { Tokenizer } = await import('@huggingface/tokenizers');
16
+ const read = (name) => JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
17
+ const t = new Tokenizer(read('tokenizer.json'), read('tokenizer_config.json'));
18
+ return {
19
+ encode(text, pair) {
20
+ const e = t.encode(text, { text_pair: pair ?? null, return_token_type_ids: true });
21
+ return { ids: e.ids, token_type_ids: e.token_type_ids };
22
+ },
23
+ };
24
+ }
25
+ function wrap(ort, s) {
26
+ return {
27
+ inputNames: s.inputNames,
28
+ async run(feeds, outputs) {
29
+ const tensors = {};
30
+ for (const [name, f] of Object.entries(feeds)) {
31
+ tensors[name] = new ort.Tensor(f.type, f.data, f.dims);
32
+ }
33
+ const out = await s.run(tensors, outputs);
34
+ const result = {};
35
+ for (const name of outputs) {
36
+ const t = out[name];
37
+ if (t === undefined || !(t.data instanceof Float32Array)) {
38
+ throw new Error(`local model: output ${name} is missing or not float32`);
39
+ }
40
+ result[name] = { data: t.data, dims: t.dims };
41
+ }
42
+ return result;
43
+ },
44
+ };
45
+ }
46
+ export function onnxRuntime() {
47
+ let ortP;
48
+ const ort = () => (ortP ??= import('onnxruntime-node'));
49
+ // Warnings (unused initialisers and the like) would land on a hook's stderr.
50
+ const quiet = { logSeverityLevel: 3 };
51
+ return {
52
+ async load(dir, opts = {}) {
53
+ const o = await ort();
54
+ const tokenizer = loadTokenizer(dir);
55
+ const threads = { intraOpNumThreads: opts.threads ?? defaultThreads(), interOpNumThreads: 1 };
56
+ const cached = cacheFile(dir, o);
57
+ let session;
58
+ if (fs.existsSync(cached)) {
59
+ try {
60
+ session = await o.InferenceSession.create(cached, {
61
+ ...quiet,
62
+ ...threads,
63
+ graphOptimizationLevel: 'disabled',
64
+ });
65
+ }
66
+ catch {
67
+ session = undefined; // a damaged cache: the original model still loads
68
+ }
69
+ }
70
+ session ??= await o.InferenceSession.create(path.join(dir, 'model.onnx'), {
71
+ ...quiet,
72
+ ...threads,
73
+ graphOptimizationLevel: 'all',
74
+ });
75
+ return { session: wrap(o, session), tokenizer: await tokenizer };
76
+ },
77
+ async optimize(dir) {
78
+ const o = await ort();
79
+ const dest = cacheFile(dir, o);
80
+ const tmp = `${dest}.${process.pid}-${randomBytes(4).toString('hex')}.tmp`;
81
+ try {
82
+ // `extended`, not `all`: the `all` level adds CPU-specific layout transforms.
83
+ const s = await o.InferenceSession.create(path.join(dir, 'model.onnx'), {
84
+ ...quiet,
85
+ graphOptimizationLevel: 'extended',
86
+ optimizedModelFilePath: tmp,
87
+ });
88
+ await s.release();
89
+ fs.chmodSync(tmp, 0o600);
90
+ fs.renameSync(tmp, dest);
91
+ }
92
+ finally {
93
+ fs.rmSync(tmp, { force: true });
94
+ }
95
+ },
96
+ };
97
+ }
98
+ /**
99
+ * Loads `dir` on the first call and shares the result; a failed load is retried on the next
100
+ * call rather than remembered.
101
+ */
102
+ export function lazyLoad(runtime, dir, threads) {
103
+ let loaded;
104
+ return () => {
105
+ if (loaded === undefined) {
106
+ const p = runtime.load(dir, threads === undefined ? {} : { threads });
107
+ p.catch(() => {
108
+ if (loaded === p)
109
+ loaded = undefined;
110
+ });
111
+ loaded = p;
112
+ }
113
+ return loaded;
114
+ };
115
+ }
116
+ //# sourceMappingURL=runtime.js.map
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "@oortmem/local-models",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.4.0",
4
+ "description": "Oort embeddings and reranking on this machine's CPU (ONNX Runtime): no API keys, no account.",
5
+ "keywords": [
6
+ "oort",
7
+ "agent-memory",
8
+ "ai-agents",
9
+ "llm-memory",
10
+ "embeddings",
11
+ "reranker",
12
+ "onnx",
13
+ "offline"
14
+ ],
15
+ "homepage": "https://oortcloud.dev",
16
+ "bugs": {
17
+ "url": "https://github.com/oortmem/oort/issues",
18
+ "email": "hello@oortcloud.dev"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/oortmem/oort.git",
23
+ "directory": "packages/local-models"
24
+ },
25
+ "license": "Apache-2.0",
26
+ "author": "Oort <hello@oortcloud.dev>",
27
+ "type": "module",
28
+ "engines": {
29
+ "node": ">=22.15.0"
30
+ },
31
+ "sideEffects": false,
32
+ "main": "./dist/index.js",
33
+ "types": "./dist/index.d.ts",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "default": "./dist/index.js"
38
+ }
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "!dist/**/*.map",
43
+ "README.md",
44
+ "LICENSE"
45
+ ],
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "dependencies": {
50
+ "@huggingface/tokenizers": "0.2.0",
51
+ "@oortmem/core": "0.4.0",
52
+ "onnxruntime-node": "1.31.1"
53
+ },
54
+ "devDependencies": {
55
+ "vitest": "^5.0.5"
56
+ },
57
+ "scripts": {
58
+ "build": "tsc -p tsconfig.json",
59
+ "typecheck": "tsc -p tsconfig.typecheck.json"
60
+ }
6
61
  }