@oh-my-pi/pi-mnemopi 18.5.0 → 18.6.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
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.5.1] - 2026-10-03
6
+
7
+ ### Changed
8
+
9
+ - Upgraded local embedding support to fastembed 3.0.0. Models now download from Hugging Face into the updated cache layout, with interrupted downloads resuming automatically; existing models are migrated on first use while producing the same vectors.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed enhanced recall returning cached results from an unrelated longer query when answering a shorter query.
14
+ - Fixed local embedding setup on fresh caches and Linux ARM64, including compatibility with current model downloads and ARM64 tokenization support.
15
+
5
16
  ## [18.4.4] - 2026-09-29
6
17
 
7
18
  ### Added
@@ -32,30 +32,11 @@ export type LocalModelInitializer = (options: LocalModelInitOptions) => Promise<
32
32
  */
33
33
  export declare function quarantineCorruptModelFile(message: string, cacheDir?: string): Promise<boolean>;
34
34
  /**
35
- * Recover from a partially-extracted fastembed model cache. An interrupted
36
- * archive download/extraction leaves `<cacheDir>/<model>/` populated with
37
- * sidecars and a truncated `model.onnx_data` but WITHOUT the graph file
38
- * (`model.onnx` / `model_optimized.onnx`), so `FlagEmbedding.init` throws
39
- * `Model file not found at .../model.onnx`. Upstream `retrieveModel`
40
- * short-circuits on the existing model dir and never re-downloads, and
41
- * `downloadFileFromGCS` reuses a leftover partial `<model>.tar.gz` as-is, so
42
- * the cache is stuck broken forever: recall silently dies and the rebuild
43
- * queue grows every session. Delete the incomplete model dir AND the leftover
44
- * partial archive so the retry re-downloads from scratch. The model path is
45
- * error-message CONTENT, so the dir is only removed when it is a direct child
46
- * of the fastembed cache root — never touch a directory a dependency merely
47
- * names. Returns true when a retry is worth attempting (the incomplete cache
48
- * was cleared, or already gone from a concurrent heal).
49
- * @internal exported for tests
50
- */
51
- export declare function clearIncompleteModelCache(message: string, cacheDir?: string): Promise<boolean>;
52
- /**
53
- * Shared local-model initializer: FlagEmbedding.init with BOTH cache heals.
54
- * Missing sidecars (config/tokenizer/tokens map) re-fetch and retry; a
55
- * corrupt model blob (Protobuf parse failure) quarantines the file and
56
- * retries THROUGH the sidecar heal, so a cache that is broken in both ways
57
- * still recovers in one pass. Also the initializer the embed worker uses in
58
- * its subprocess; the in-process seam stays {@link setLocalModelInitializer}.
35
+ * Shared local-model initializer: FlagEmbedding.init with a corrupt-cache heal.
36
+ * A corrupt model blob (Protobuf parse failure) quarantines the file and
37
+ * retries once; fastembed re-downloads any file missing from the model's
38
+ * cache directory. Also the initializer the embed worker uses in its
39
+ * subprocess; the in-process seam stays {@link setLocalModelInitializer}.
59
40
  */
60
41
  export declare function defaultLocalModelInitializer(options: LocalModelInitOptions): Promise<LocalEmbeddingModel>;
61
42
  export declare function embeddingsDisabled(): boolean;
@@ -1,2 +1,8 @@
1
- /** Download missing config/tokenizer sidecars into a fastembed model cache directory. */
2
- export declare function ensureFastembedModelSidecars(model: string, cacheDir?: string): Promise<boolean>;
1
+ /**
2
+ * Whether `FlagEmbedding.init` can load `model` from `cacheDir` without a
3
+ * download. fastembed caches each file under `<cacheDir>/<repo with / → _>/`
4
+ * and fetches only the files that are missing, so a model is cached exactly
5
+ * when every file is present. Unknown models report false. The agent's embed
6
+ * client uses this to decide whether an init shows download activity.
7
+ */
8
+ export declare function isFastembedModelCached(model: string, cacheDir: string): Promise<boolean>;
@@ -31,7 +31,9 @@ export declare function isQueryCacheEnabled(useCache?: boolean, env?: Env, confi
31
31
  *
32
32
  * - Tier 1: exact normalized query match.
33
33
  * - Tiers 2/3: query embedding cosine >= 0.88, or >= 0.78 plus word overlap.
34
- * - Tier 4: normalized word overlap.
34
+ * - Tier 4: shared distinct words cover at least 70% of the new query and half of the
35
+ * cached one, so a reworded or trimmed query hits but a short query never lands
36
+ * inside a long cached prompt that merely contains its words.
35
37
  */
36
38
  export declare class QueryCache<T = QueryCacheResult> {
37
39
  #private;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-mnemopi",
4
- "version": "18.5.0",
4
+ "version": "18.6.0",
5
5
  "description": "Local SQLite memory engine for omp agents",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -42,13 +42,13 @@
42
42
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
43
43
  },
44
44
  "dependencies": {
45
- "@oh-my-pi/pi-ai": "18.5.0",
46
- "@oh-my-pi/pi-catalog": "18.5.0",
47
- "@oh-my-pi/pi-natives": "18.5.0",
48
- "@oh-my-pi/pi-utils": "18.5.0"
45
+ "@oh-my-pi/pi-ai": "18.6.0",
46
+ "@oh-my-pi/pi-catalog": "18.6.0",
47
+ "@oh-my-pi/pi-natives": "18.6.0",
48
+ "@oh-my-pi/pi-utils": "18.6.0"
49
49
  },
50
50
  "peerDependencies": {
51
- "fastembed": "2.1.0",
51
+ "fastembed": "3.0.0",
52
52
  "onnxruntime-node": "1.21.0"
53
53
  },
54
54
  "peerDependenciesMeta": {
@@ -61,7 +61,7 @@
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.3.14",
64
- "fastembed": "2.1.0",
64
+ "fastembed": "3.0.0",
65
65
  "onnxruntime-node": "1.26.0"
66
66
  },
67
67
  "engines": {
@@ -14,7 +14,6 @@ import {
14
14
  } from "@oh-my-pi/pi-utils";
15
15
  import { LRUCache } from "@oh-my-pi/pi-utils/lru";
16
16
  import type { EmbeddingModel } from "fastembed";
17
- import { ensureFastembedModelSidecars } from "./fastembed-model-cache";
18
17
  import { loadFastembed } from "./fastembed-runtime";
19
18
  import {
20
19
  type EmbeddingOutput,
@@ -93,74 +92,22 @@ export async function quarantineCorruptModelFile(message: string, cacheDir?: str
93
92
  }
94
93
 
95
94
  /**
96
- * Recover from a partially-extracted fastembed model cache. An interrupted
97
- * archive download/extraction leaves `<cacheDir>/<model>/` populated with
98
- * sidecars and a truncated `model.onnx_data` but WITHOUT the graph file
99
- * (`model.onnx` / `model_optimized.onnx`), so `FlagEmbedding.init` throws
100
- * `Model file not found at .../model.onnx`. Upstream `retrieveModel`
101
- * short-circuits on the existing model dir and never re-downloads, and
102
- * `downloadFileFromGCS` reuses a leftover partial `<model>.tar.gz` as-is, so
103
- * the cache is stuck broken forever: recall silently dies and the rebuild
104
- * queue grows every session. Delete the incomplete model dir AND the leftover
105
- * partial archive so the retry re-downloads from scratch. The model path is
106
- * error-message CONTENT, so the dir is only removed when it is a direct child
107
- * of the fastembed cache root — never touch a directory a dependency merely
108
- * names. Returns true when a retry is worth attempting (the incomplete cache
109
- * was cleared, or already gone from a concurrent heal).
110
- * @internal exported for tests
111
- */
112
- export async function clearIncompleteModelCache(message: string, cacheDir?: string): Promise<boolean> {
113
- const match = /Model file not found at (.+?\.onnx)\b/i.exec(message);
114
- if (!match) return false;
115
- const modelFile = nodePath.resolve(match[1]);
116
- const modelDir = nodePath.dirname(modelFile);
117
- const cacheRoot = nodePath.resolve(cacheDir ?? getFastembedCacheDir());
118
- // Only a direct child of the cache root: `<cacheRoot>/<model>`.
119
- if (nodePath.dirname(modelDir) !== cacheRoot) return false;
120
- try {
121
- await fsp.rm(modelDir, { recursive: true, force: true });
122
- await fsp.rm(`${modelDir}.tar.gz`, { force: true });
123
- logger.warn("mnemopi: cleared incomplete local embedding model cache; re-downloading", { modelDir });
124
- } catch {
125
- // Concurrent heal or vanished dir: the single retry stays safe.
126
- }
127
- return true;
128
- }
129
-
130
- const SIDECAR_ERROR_RE =
131
- /(?:Config file not found at .*config|Tokenizer file not found at .*tokenizer|Tokens map file not found at .*special_tokens_map)/u;
132
-
133
- /**
134
- * Shared local-model initializer: FlagEmbedding.init with BOTH cache heals.
135
- * Missing sidecars (config/tokenizer/tokens map) re-fetch and retry; a
136
- * corrupt model blob (Protobuf parse failure) quarantines the file and
137
- * retries THROUGH the sidecar heal, so a cache that is broken in both ways
138
- * still recovers in one pass. Also the initializer the embed worker uses in
139
- * its subprocess; the in-process seam stays {@link setLocalModelInitializer}.
95
+ * Shared local-model initializer: FlagEmbedding.init with a corrupt-cache heal.
96
+ * A corrupt model blob (Protobuf parse failure) quarantines the file and
97
+ * retries once; fastembed re-downloads any file missing from the model's
98
+ * cache directory. Also the initializer the embed worker uses in its
99
+ * subprocess; the in-process seam stays {@link setLocalModelInitializer}.
140
100
  */
141
101
  export async function defaultLocalModelInitializer(options: LocalModelInitOptions): Promise<LocalEmbeddingModel> {
142
102
  const cacheDir = options.cacheDir ?? getFastembedCacheDir();
143
103
  const initOptions = options.cacheDir === undefined ? { ...options, cacheDir } : options;
144
104
  const { FlagEmbedding } = await loadFastembed();
145
- const initWithSidecarHeal = async (): Promise<LocalEmbeddingModel> => {
146
- try {
147
- return await FlagEmbedding.init(initOptions);
148
- } catch (error) {
149
- const message = error instanceof Error ? error.message : "";
150
- if (!SIDECAR_ERROR_RE.test(message)) throw error;
151
- if (!(await ensureFastembedModelSidecars(options.model, cacheDir))) throw error;
152
- return FlagEmbedding.init(initOptions);
153
- }
154
- };
155
105
  try {
156
- return await initWithSidecarHeal();
106
+ return await FlagEmbedding.init(initOptions);
157
107
  } catch (error) {
158
108
  const message = error instanceof Error ? error.message : "";
159
109
  if (/Protobuf parsing failed/i.test(message) && (await quarantineCorruptModelFile(message, cacheDir))) {
160
- return initWithSidecarHeal();
161
- }
162
- if (await clearIncompleteModelCache(message, cacheDir)) {
163
- return initWithSidecarHeal();
110
+ return FlagEmbedding.init(initOptions);
164
111
  }
165
112
  throw error;
166
113
  }
@@ -1,39 +1,49 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import * as path from "node:path";
2
3
 
3
- const FASTEMBED_MODEL_SIDECARS = [
4
- "config.json",
4
+ /**
5
+ * Hugging Face repo and weight files fastembed downloads for each built-in
6
+ * model. Mirrors fastembed's private `MODEL_SOURCES` table; fastembed
7
+ * exposes no API for its cache layout.
8
+ */
9
+ const FASTEMBED_MODEL_SOURCES: Record<string, { repo: string; files: readonly string[] }> = {
10
+ "fast-all-MiniLM-L6-v2": { repo: "Qdrant/all-MiniLM-L6-v2-onnx", files: ["model.onnx"] },
11
+ "fast-bge-base-en": { repo: "Qdrant/fast-bge-base-en", files: ["model_optimized.onnx"] },
12
+ "fast-bge-base-en-v1.5": { repo: "Qdrant/bge-base-en-v1.5-onnx-Q", files: ["model_optimized.onnx"] },
13
+ "fast-bge-small-en": { repo: "Qdrant/bge-small-en", files: ["model_optimized.onnx"] },
14
+ "fast-bge-small-en-v1.5": { repo: "Qdrant/bge-small-en-v1.5-onnx-Q", files: ["model_optimized.onnx"] },
15
+ "fast-bge-small-zh-v1.5": { repo: "Qdrant/bge-small-zh-v1.5", files: ["model_optimized.onnx"] },
16
+ "fast-multilingual-e5-large": {
17
+ repo: "Qdrant/multilingual-e5-large-onnx",
18
+ files: ["model.onnx", "model.onnx_data"],
19
+ },
20
+ };
21
+
22
+ const FASTEMBED_TOKENIZER_FILES = [
5
23
  "tokenizer.json",
6
24
  "tokenizer_config.json",
25
+ "config.json",
7
26
  "special_tokens_map.json",
8
27
  ] as const;
9
28
 
10
- const FASTEMBED_HF_REPOS: Record<string, string> = {
11
- "fast-all-MiniLM-L6-v2": "sentence-transformers/all-MiniLM-L6-v2",
12
- "fast-bge-base-en": "BAAI/bge-base-en",
13
- "fast-bge-base-en-v1.5": "BAAI/bge-base-en-v1.5",
14
- "fast-bge-small-en": "BAAI/bge-small-en",
15
- "fast-bge-small-en-v1.5": "BAAI/bge-small-en-v1.5",
16
- "fast-bge-small-zh-v1.5": "BAAI/bge-small-zh-v1.5",
17
- "fast-multilingual-e5-large": "intfloat/multilingual-e5-large",
18
- };
19
-
20
- /** Download missing config/tokenizer sidecars into a fastembed model cache directory. */
21
- export async function ensureFastembedModelSidecars(model: string, cacheDir = "local_cache"): Promise<boolean> {
22
- const repo = FASTEMBED_HF_REPOS[model];
23
- if (repo === undefined) return false;
24
-
25
- const modelDir = path.join(cacheDir, model);
26
- for (const fileName of FASTEMBED_MODEL_SIDECARS) {
27
- const target = path.join(modelDir, fileName);
28
- if (await Bun.file(target).exists()) continue;
29
-
30
- const response = await fetch(`https://huggingface.co/${repo}/resolve/main/${fileName}`);
31
- if (!response.ok) {
32
- throw new Error(
33
- `Failed to download ${model} ${fileName} from ${repo}: ${response.status} ${response.statusText}`,
34
- );
35
- }
36
- await Bun.write(target, await response.arrayBuffer());
37
- }
38
- return true;
29
+ /**
30
+ * Whether `FlagEmbedding.init` can load `model` from `cacheDir` without a
31
+ * download. fastembed caches each file under `<cacheDir>/<repo with / → _>/`
32
+ * and fetches only the files that are missing, so a model is cached exactly
33
+ * when every file is present. Unknown models report false. The agent's embed
34
+ * client uses this to decide whether an init shows download activity.
35
+ */
36
+ export async function isFastembedModelCached(model: string, cacheDir: string): Promise<boolean> {
37
+ const source = FASTEMBED_MODEL_SOURCES[model];
38
+ if (source === undefined) return false;
39
+ const modelDir = path.join(cacheDir, source.repo.replace("/", "_"));
40
+ const present = await Promise.all(
41
+ [...source.files, ...FASTEMBED_TOKENIZER_FILES].map(file =>
42
+ fs.access(path.join(modelDir, file)).then(
43
+ () => true,
44
+ () => false,
45
+ ),
46
+ ),
47
+ );
48
+ return present.every(Boolean);
39
49
  }
@@ -33,15 +33,26 @@ export interface FastembedRuntimeInstallPlan {
33
33
  * manifest is inlined into a bundle. The runtime install deliberately does not
34
34
  * override fastembed's `onnxruntime-node` dependency: the prebuilt native addon
35
35
  * links against that package's bundled ORT dylib/so/dll name.
36
+ *
37
+ * It does override `@anush008/tokenizers`: every fastembed release pins
38
+ * `^0.0.0`, which resolves to a build without linux-arm64 bindings (#14083).
39
+ * {@link TOKENIZERS_SPEC} keeps the identical JS API and adds them.
36
40
  */
37
41
  const FASTEMBED_SPEC = packageManifest.peerDependencies.fastembed;
38
42
 
43
+ /** `@anush008/tokenizers` release with linux-arm64 gnu/musl bindings (added in 0.5.0). */
44
+ const TOKENIZERS_SPEC = "0.6.0";
45
+
39
46
  /** Build the deterministic fastembed runtime install plan used by local embeddings. */
40
47
  export function fastembedRuntimeInstallPlan(): FastembedRuntimeInstallPlan {
41
48
  return {
42
- versionKey: `fastembed-${FASTEMBED_SPEC}_transitive-ort`.replace(/[^A-Za-z0-9._-]/g, "_"),
49
+ versionKey: `fastembed-${FASTEMBED_SPEC}_transitive-ort_tokenizers-${TOKENIZERS_SPEC}`.replace(
50
+ /[^A-Za-z0-9._-]/g,
51
+ "_",
52
+ ),
43
53
  install: {
44
54
  dependencies: { fastembed: FASTEMBED_SPEC },
55
+ overrides: { "@anush008/tokenizers": TOKENIZERS_SPEC },
45
56
  trustedDependencies: ["onnxruntime-node"],
46
57
  },
47
58
  };
@@ -31,6 +31,8 @@ export interface QueryCacheStats {
31
31
  interface CacheEntry<T> {
32
32
  readonly scope: string;
33
33
  readonly normalized: string;
34
+ /** Distinct words of {@link normalized}, for the tier-4 overlap test. */
35
+ readonly words: ReadonlySet<string>;
34
36
  readonly results: readonly T[];
35
37
  readonly embedding: QueryEmbedding | null;
36
38
  }
@@ -66,7 +68,9 @@ export function isQueryCacheEnabled(useCache = true, env: Env = process.env, con
66
68
  *
67
69
  * - Tier 1: exact normalized query match.
68
70
  * - Tiers 2/3: query embedding cosine >= 0.88, or >= 0.78 plus word overlap.
69
- * - Tier 4: normalized word overlap.
71
+ * - Tier 4: shared distinct words cover at least 70% of the new query and half of the
72
+ * cached one, so a reworded or trimmed query hits but a short query never lands
73
+ * inside a long cached prompt that merely contains its words.
70
74
  */
71
75
  export class QueryCache<T = QueryCacheResult> {
72
76
  readonly maxSize: number;
@@ -122,7 +126,13 @@ export class QueryCache<T = QueryCacheResult> {
122
126
  const results = JSON.parse(row.results_json) as T[];
123
127
  const embedding = row.embedding_json === null ? null : (JSON.parse(row.embedding_json) as number[]);
124
128
  const { scope, normalized } = splitEntryKey(row.normalized);
125
- this.#entries.set(row.normalized, { scope, normalized, results, embedding });
129
+ this.#entries.set(row.normalized, {
130
+ scope,
131
+ normalized,
132
+ words: this.#wordSet(normalized),
133
+ results,
134
+ embedding,
135
+ });
126
136
  this.#rememberKey(row.normalized, now);
127
137
  } catch {
128
138
  // Match Python's best-effort persistence loading: corrupt rows are ignored.
@@ -197,11 +207,11 @@ export class QueryCache<T = QueryCacheResult> {
197
207
  for (const [cachedKey, cached] of this.#entries) {
198
208
  if (cached.scope !== scope) continue;
199
209
  if (this.#isExpired(cachedKey, now)) continue;
200
- queryWords ??= new Set(normalized.split(/\s+/));
210
+ queryWords ??= this.#wordSet(normalized);
201
211
  if (queryWords.size === 0) continue;
202
- let overlap = 0;
203
- for (const cachedWord of cached.normalized.split(/\s+/)) if (queryWords.has(cachedWord)) overlap += 1;
204
- if (overlap >= queryWords.size * 0.7 && overlap >= 2) {
212
+ let shared = 0;
213
+ for (const word of queryWords) if (cached.words.has(word)) shared += 1;
214
+ if (shared >= 2 && shared >= queryWords.size * 0.7 && shared >= cached.words.size * 0.5) {
205
215
  this.#touchKey(cachedKey);
206
216
  this.hits += 1;
207
217
  this.tier4Hits += 1;
@@ -222,7 +232,13 @@ export class QueryCache<T = QueryCacheResult> {
222
232
  const storedEmbedding =
223
233
  embedding !== undefined && embedding !== null && embedding.length !== 0 ? embedding : null;
224
234
  this.#entries.delete(key);
225
- this.#entries.set(key, { scope, normalized, results, embedding: storedEmbedding });
235
+ this.#entries.set(key, {
236
+ scope,
237
+ normalized,
238
+ words: this.#wordSet(normalized),
239
+ results,
240
+ embedding: storedEmbedding,
241
+ });
226
242
  this.#rememberKey(key, now);
227
243
  this.#putPersistent(key, results, storedEmbedding);
228
244
  this.#evictIfNeeded();