@oh-my-pi/pi-coding-agent 16.1.2 → 16.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/dist/cli.js +3046 -3047
  3. package/dist/types/config/model-resolver.d.ts +3 -3
  4. package/dist/types/mnemopi/embed-client.d.ts +70 -0
  5. package/dist/types/mnemopi/embed-protocol.d.ts +52 -0
  6. package/dist/types/mnemopi/embed-worker.d.ts +12 -0
  7. package/dist/types/mnemopi/state.d.ts +9 -1
  8. package/dist/types/session/agent-storage.d.ts +2 -0
  9. package/dist/types/session/auth-broker-config.d.ts +3 -2
  10. package/dist/types/session/history-storage.d.ts +1 -1
  11. package/dist/types/tools/image-gen.d.ts +2 -2
  12. package/dist/types/utils/image-loading.d.ts +1 -1
  13. package/dist/types/utils/ipc.d.ts +22 -0
  14. package/dist/types/web/search/providers/perplexity-auth.d.ts +37 -0
  15. package/package.json +12 -12
  16. package/src/cli.ts +8 -0
  17. package/src/commands/token.ts +52 -33
  18. package/src/config/append-only-context-mode.ts +45 -0
  19. package/src/config/model-discovery.ts +3 -0
  20. package/src/config/model-registry.ts +21 -3
  21. package/src/config/model-resolver.ts +31 -8
  22. package/src/discovery/builtin-rules/ts-no-return-type.md +0 -1
  23. package/src/lsp/client.ts +24 -0
  24. package/src/mnemopi/backend.ts +49 -3
  25. package/src/mnemopi/embed-client.ts +401 -0
  26. package/src/mnemopi/embed-protocol.ts +35 -0
  27. package/src/mnemopi/embed-worker.ts +113 -0
  28. package/src/mnemopi/state.ts +29 -1
  29. package/src/modes/components/custom-editor.ts +1 -1
  30. package/src/modes/components/model-selector.ts +2 -2
  31. package/src/modes/components/welcome.ts +1 -1
  32. package/src/modes/controllers/event-controller.ts +8 -0
  33. package/src/modes/controllers/selector-controller.ts +2 -2
  34. package/src/modes/theme/theme.ts +69 -0
  35. package/src/sdk.ts +4 -0
  36. package/src/session/agent-session.ts +8 -0
  37. package/src/session/agent-storage.ts +14 -0
  38. package/src/session/auth-broker-config.ts +2 -1
  39. package/src/session/history-storage.ts +13 -1
  40. package/src/stt/asr-client.ts +2 -7
  41. package/src/tiny/title-client.ts +2 -7
  42. package/src/tools/image-gen.ts +4 -8
  43. package/src/tools/render-utils.ts +4 -1
  44. package/src/tts/tts-client.ts +2 -7
  45. package/src/utils/image-loading.ts +12 -2
  46. package/src/utils/ipc.ts +38 -0
  47. package/src/web/search/providers/perplexity-auth.ts +133 -0
  48. package/src/web/search/providers/perplexity.ts +2 -125
@@ -556,6 +556,27 @@ function isAlias(id: string): boolean {
556
556
  return !datePattern.test(id);
557
557
  }
558
558
 
559
+ function includeSyntheticAllowedModels(available: Model<Api>[], allowedModels: Iterable<Model<Api>>): Model<Api>[] {
560
+ const allowedByKey = new Map<string, Model<Api>>();
561
+ for (const model of allowedModels) {
562
+ const key = formatModelString(model);
563
+ if (!allowedByKey.has(key)) {
564
+ allowedByKey.set(key, model);
565
+ }
566
+ }
567
+ if (allowedByKey.size === 0) return [];
568
+
569
+ const result: Model<Api>[] = [];
570
+ for (const model of available) {
571
+ if (allowedByKey.delete(formatModelString(model))) {
572
+ result.push(model);
573
+ }
574
+ }
575
+
576
+ result.push(...allowedByKey.values());
577
+ return result;
578
+ }
579
+
559
580
  /**
560
581
  * Find an exact explicit provider/model match.
561
582
  * Bare model ids are handled separately so canonical ids can coalesce variants.
@@ -1335,9 +1356,9 @@ export async function resolveModelScope(
1335
1356
  * the result to models matching those patterns.
1336
1357
  *
1337
1358
  * Returns the unfiltered available list when `enabledModels` is empty.
1338
- * Returns an empty list when `enabledModels` is configured but no available
1339
- * model matches any pattern — callers MUST treat this as "no usable model"
1340
- * rather than falling back to the global default (see issue #1022).
1359
+ * Returns an empty list when `enabledModels` is configured but no model matches
1360
+ * any pattern — callers MUST treat this as "no usable model" rather than
1361
+ * falling back to the global default (see issue #1022).
1341
1362
  */
1342
1363
  export async function resolveAllowedModels(
1343
1364
  modelRegistry: Pick<ModelRegistry, "getAvailable" | "getCanonicalVariants">,
@@ -1353,8 +1374,10 @@ export async function resolveAllowedModels(
1353
1374
  if (scoped.length === 0) {
1354
1375
  return [];
1355
1376
  }
1356
- const allowed = new Set(scoped.map(entry => `${entry.model.provider}/${entry.model.id}`));
1357
- return available.filter(model => allowed.has(`${model.provider}/${model.id}`));
1377
+ return includeSyntheticAllowedModels(
1378
+ available,
1379
+ scoped.map(entry => entry.model),
1380
+ );
1358
1381
  }
1359
1382
 
1360
1383
  /**
@@ -1382,9 +1405,9 @@ export function filterAvailableModelsByEnabledPatterns(
1382
1405
  if (patterns.length === 0) return available;
1383
1406
 
1384
1407
  const context = buildPreferenceContext(available, undefined);
1385
- const allowed = new Set<string>();
1408
+ const allowedModels: Model<Api>[] = [];
1386
1409
  const addAllowed = (model: Model<Api>) => {
1387
- allowed.add(`${model.provider}/${model.id}`);
1410
+ allowedModels.push(model);
1388
1411
  };
1389
1412
 
1390
1413
  for (const pattern of patterns) {
@@ -1409,7 +1432,7 @@ export function filterAvailableModelsByEnabledPatterns(
1409
1432
  }
1410
1433
  }
1411
1434
 
1412
- return allowed.size === 0 ? [] : available.filter(model => allowed.has(`${model.provider}/${model.id}`));
1435
+ return includeSyntheticAllowedModels(available, allowedModels);
1413
1436
  }
1414
1437
 
1415
1438
  export interface ResolveCliModelResult {
@@ -39,7 +39,6 @@ import type { LoadedConfig } from "./config";
39
39
 
40
40
  ## Exceptions
41
41
 
42
- - Timer handles: `ReturnType<typeof setTimeout>` / `setInterval`.
43
42
  - Generic type utilities where the function is a type parameter.
44
43
 
45
44
  Concrete function? Export a concrete type.
package/src/lsp/client.ts CHANGED
@@ -482,6 +482,30 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest
482
482
  await sendResponse(client, message.id, null, message.method);
483
483
  return;
484
484
  }
485
+ if (message.method === "window/showMessageRequest") {
486
+ // Headless: no UI to surface the prompt. Spec says null = "no action selected".
487
+ await sendResponse(client, message.id, null, message.method);
488
+ return;
489
+ }
490
+ if (message.method === "window/showDocument") {
491
+ // Headless: nothing to display. Spec result is `{ success: boolean }`.
492
+ await sendResponse(client, message.id, { success: false }, message.method);
493
+ return;
494
+ }
495
+ if (
496
+ message.method === "workspace/semanticTokens/refresh" ||
497
+ message.method === "workspace/inlayHint/refresh" ||
498
+ message.method === "workspace/codeLens/refresh" ||
499
+ message.method === "workspace/codeAction/refresh" ||
500
+ message.method === "workspace/inlineValue/refresh" ||
501
+ message.method === "workspace/foldingRange/refresh" ||
502
+ message.method === "workspace/diagnostic/refresh"
503
+ ) {
504
+ // Void acknowledgement per spec; servers that stall waiting for a reply
505
+ // (same failure mode as the dynamic-registration hang in #3029) move on.
506
+ await sendResponse(client, message.id, null, message.method);
507
+ return;
508
+ }
485
509
  await sendResponse(client, message.id, null, message.method, {
486
510
  code: -32601,
487
511
  message: `Method not found: ${message.method}`,
@@ -120,6 +120,14 @@ export const mnemopiBackend: MemoryBackend = {
120
120
  const config = previous?.config ?? (session ? loadMnemopiConfig(session.settings, agentDir) : undefined);
121
121
  if (!config) return;
122
122
  await loadMnemopiCore();
123
+ // Close the cached default Mnemopi instance so its SQLite handle doesn't
124
+ // keep the DB files locked on Windows when removeDbFiles tries to delete.
125
+ // Use the core module (already awaited via loadMnemopiCore above):
126
+ // requireMnemopi() throws "module not loaded" when clear() runs before the
127
+ // fire-and-forget start() has awaited loadMnemopi() (autolearn disabled, or
128
+ // taskDepth > 0). resetMemoryForTests is re-exported identically from core.
129
+ requireMnemopiCore().resetMemoryForTests();
130
+ await Bun.sleep(0);
123
131
  await removeDbFiles(getMnemopiScopedDbPaths(config));
124
132
  },
125
133
 
@@ -557,10 +565,48 @@ export function getMnemopiDbDirForTests(session: AgentSession): string | undefin
557
565
  return state ? path.dirname(state.config.dbPath) : undefined;
558
566
  }
559
567
 
568
+ /**
569
+ * Best-effort removal of a SQLite DB file and its WAL/SHM sidecars.
570
+ *
571
+ * Windows keeps `-wal`/`-shm` busy briefly after the DB handle closes, so a
572
+ * single `rm` races with EBUSY/EPERM. Retry a handful of times before giving
573
+ * up; `force: true` already makes "missing" a non-error.
574
+ */
560
575
  async function removeDbFiles(dbPaths: readonly string[]): Promise<void> {
561
576
  for (const dbPath of dbPaths) {
562
- await rm(dbPath, { force: true });
563
- await rm(`${dbPath}-wal`, { force: true });
564
- await rm(`${dbPath}-shm`, { force: true });
577
+ for (const suffix of ["", "-wal", "-shm"]) {
578
+ await removeWithRetries(`${dbPath}${suffix}`).catch(error => {
579
+ // `force: true` already makes ENOENT a non-error; anything else
580
+ // after the full retry window means the DB is genuinely locked and
581
+ // the user's "Memory cleared" message would be misleading. Log so
582
+ // the failure is diagnosable without blocking the clear flow.
583
+ const code = typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
584
+ if (code !== "ENOENT") {
585
+ logger.warn("Mnemopi: failed to remove DB file after retries", { path: `${dbPath}${suffix}`, code });
586
+ }
587
+ });
588
+ }
589
+ }
590
+ }
591
+
592
+ const kRemoveRetries = 40;
593
+ const kRemoveRetryDelayMs = 25;
594
+ const kRetryableRemoveErrorCodes = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]);
595
+
596
+ async function removeWithRetries(target: string): Promise<void> {
597
+ for (let attempt = 0; ; attempt++) {
598
+ try {
599
+ await rm(target, { force: true });
600
+ return;
601
+ } catch (err) {
602
+ const retryable =
603
+ typeof err === "object" &&
604
+ err !== null &&
605
+ "code" in err &&
606
+ typeof err.code === "string" &&
607
+ kRetryableRemoveErrorCodes.has(err.code);
608
+ if (!retryable || attempt >= kRemoveRetries) throw err;
609
+ await Bun.sleep(kRemoveRetryDelayMs);
610
+ }
565
611
  }
566
612
  }
@@ -0,0 +1,401 @@
1
+ import * as path from "node:path";
2
+ import { $env, isBunTestRuntime, isCompiledBinary, logger, workerHostEntry } from "@oh-my-pi/pi-utils";
3
+ import type { Subprocess } from "bun";
4
+ import type { MnemopiEmbedModelId, MnemopiEmbedWorkerInbound, MnemopiEmbedWorkerOutbound } from "./embed-protocol";
5
+
6
+ /**
7
+ * Abstraction over the mnemopi embeddings subprocess. The runtime
8
+ * implementation is a Bun child process so `onnxruntime-node`'s NAPI
9
+ * constructor + finalizer never run inside the main agent address space —
10
+ * those destructors segfault Bun on Windows when mnemopi's local embedding
11
+ * provider loads fastembed in the main process (issue #3031; the mnemopi
12
+ * sibling of the tiny-model fix from #1606 / #1607).
13
+ */
14
+ export interface MnemopiEmbedWorkerHandle {
15
+ send(message: MnemopiEmbedWorkerInbound): void;
16
+ onMessage(handler: (message: MnemopiEmbedWorkerOutbound) => void): () => void;
17
+ onError(handler: (error: Error) => void): () => void;
18
+ terminate(): Promise<void>;
19
+ }
20
+
21
+ type PendingRequest =
22
+ | { kind: "init"; model: MnemopiEmbedModelId; resolve: (ok: boolean) => void }
23
+ | { kind: "embed"; model: MnemopiEmbedModelId; resolve: (vectors: number[][] | Error) => void };
24
+
25
+ // Cold-starting the worker from a compiled binary (decompress + module graph load)
26
+ // is slow on contended CI runners; the probe only proves the worker spawns and
27
+ // ponges, so a generous bound removes flakes without weakening the check.
28
+ const SMOKE_TEST_TIMEOUT_MS = 30_000;
29
+
30
+ /**
31
+ * Hidden subcommand on the main CLI that boots the mnemopi embeddings worker
32
+ * in the spawned subprocess. Kept in sync with the dispatch in `cli.ts`.
33
+ */
34
+ export const MNEMOPI_EMBED_WORKER_ARG = "__omp_worker_mnemopi_embed";
35
+
36
+ /**
37
+ * Env handed to the embeddings subprocess. The child inherits the parent's
38
+ * environment verbatim — fastembed honours `HF_HUB_*`, `HTTPS_PROXY`, etc.,
39
+ * and our `loadFastembed()` reads the same `OMP_*` runtime-install knobs the
40
+ * parent uses. `process.env` carries `undefined` slots that Bun.spawn rejects;
41
+ * filter them out.
42
+ */
43
+ function mnemopiEmbedWorkerEnv(): Record<string, string> {
44
+ const base = $env as Record<string, string | undefined>;
45
+ const merged: Record<string, string> = {};
46
+ for (const key in base) {
47
+ const value = base[key];
48
+ if (typeof value === "string") merged[key] = value;
49
+ }
50
+ return merged;
51
+ }
52
+
53
+ interface MnemopiEmbedWorkerSpawnCommand {
54
+ cmd: string[];
55
+ cwd?: string;
56
+ }
57
+
58
+ /**
59
+ * Resolve the command used to relaunch the agent CLI into mnemopi-embed-worker
60
+ * mode. In a compiled binary the entry point is the binary itself; otherwise
61
+ * re-enter the declared worker-host entry (cwd-relative for reliable Bun IPC),
62
+ * falling back to this package's own `src/cli.ts` when no host entry is
63
+ * declared (bun test, SDK embedding).
64
+ */
65
+ function mnemopiEmbedWorkerSpawnCmd(): MnemopiEmbedWorkerSpawnCommand {
66
+ if (isCompiledBinary()) return { cmd: [process.execPath, MNEMOPI_EMBED_WORKER_ARG] };
67
+ const hostEntry = workerHostEntry();
68
+ if (hostEntry) {
69
+ return {
70
+ cmd: [process.execPath, path.basename(hostEntry), MNEMOPI_EMBED_WORKER_ARG],
71
+ cwd: path.dirname(hostEntry),
72
+ };
73
+ }
74
+ const packageRoot = path.resolve(import.meta.dir, "..", "..");
75
+ return { cmd: [process.execPath, "src/cli.ts", MNEMOPI_EMBED_WORKER_ARG], cwd: packageRoot };
76
+ }
77
+
78
+ interface SpawnedSubprocess {
79
+ proc: Subprocess<"ignore", "ignore", "ignore">;
80
+ inbound: Set<(message: MnemopiEmbedWorkerOutbound) => void>;
81
+ errors: Set<(error: Error) => void>;
82
+ /**
83
+ * Flipped to `true` right before the deliberate SIGKILL so `onExit` can
84
+ * distinguish the expected hard-kill from a crash (SIGSEGV from a native
85
+ * fault, OOM SIGKILL, operator `kill -9`). Only the latter surfaces as a
86
+ * worker error so callers don't await forever.
87
+ */
88
+ intentionalExit: { value: boolean };
89
+ }
90
+
91
+ /**
92
+ * Spawn the mnemopi embeddings worker as a subprocess. Exported for tests and
93
+ * the smoke probe; production callers go through {@link spawnMnemopiEmbedWorker}.
94
+ */
95
+ export function createMnemopiEmbedSubprocess(): SpawnedSubprocess {
96
+ const inbound = new Set<(message: MnemopiEmbedWorkerOutbound) => void>();
97
+ const errors = new Set<(error: Error) => void>();
98
+ const intentionalExit = { value: false };
99
+ const spawnCommand = mnemopiEmbedWorkerSpawnCmd();
100
+ const proc = Bun.spawn({
101
+ cmd: spawnCommand.cmd,
102
+ cwd: spawnCommand.cwd,
103
+ env: mnemopiEmbedWorkerEnv(),
104
+ stdin: "ignore",
105
+ stdout: "ignore",
106
+ stderr: "ignore",
107
+ serialization: "advanced",
108
+ windowsHide: true,
109
+ ipc(message) {
110
+ for (const handler of inbound) handler(message as MnemopiEmbedWorkerOutbound);
111
+ },
112
+ onExit(_proc, exitCode, signalCode) {
113
+ if (exitCode === 0) return;
114
+ if (exitCode === null && intentionalExit.value) return;
115
+ const reason = exitCode !== null ? `code ${exitCode}` : `signal ${signalCode ?? "unknown"}`;
116
+ const err = new Error(`mnemopi embed subprocess exited with ${reason}`);
117
+ for (const handler of errors) handler(err);
118
+ },
119
+ });
120
+ // Don't keep the parent event loop alive on an idle worker; the agent
121
+ // dispose path calls `terminate()` explicitly. Bun's test runner starves
122
+ // IPC for unref'd subprocesses, so keep it referenced only under tests.
123
+ if (!isBunTestRuntime()) proc.unref();
124
+ return { proc, inbound, errors, intentionalExit };
125
+ }
126
+
127
+ function wrapSubprocess({ proc, inbound, errors, intentionalExit }: SpawnedSubprocess): MnemopiEmbedWorkerHandle {
128
+ return {
129
+ send(message) {
130
+ try {
131
+ proc.send(message);
132
+ } catch (error) {
133
+ logger.debug("mnemopi-embed: send to subprocess failed", {
134
+ error: error instanceof Error ? error.message : String(error),
135
+ });
136
+ }
137
+ },
138
+ onMessage(handler) {
139
+ inbound.add(handler);
140
+ return () => inbound.delete(handler);
141
+ },
142
+ onError(handler) {
143
+ errors.add(handler);
144
+ return () => errors.delete(handler);
145
+ },
146
+ async terminate() {
147
+ // SIGKILL: the point of subprocess isolation is that the parent
148
+ // never runs `onnxruntime-node`'s NAPI finalizer (it crashes Bun
149
+ // on Windows). Hard-kill instead; the OS reclaims the model
150
+ // memory. Flip the intentional-exit flag *before* killing so
151
+ // `onExit` can tell this apart from a native crash.
152
+ intentionalExit.value = true;
153
+ try {
154
+ proc.kill("SIGKILL");
155
+ } catch {
156
+ // Already gone.
157
+ }
158
+ },
159
+ };
160
+ }
161
+
162
+ function spawnInlineUnavailableWorker(error: unknown): MnemopiEmbedWorkerHandle {
163
+ const listeners = new Set<(message: MnemopiEmbedWorkerOutbound) => void>();
164
+ const errorMessage = error instanceof Error ? error.message : String(error);
165
+ const emit = (message: MnemopiEmbedWorkerOutbound): void => {
166
+ for (const listener of listeners) listener(message);
167
+ };
168
+ return {
169
+ send(message) {
170
+ queueMicrotask(() => {
171
+ if (message.type === "ping") {
172
+ emit({ type: "pong", id: message.id });
173
+ return;
174
+ }
175
+ emit({ type: "error", id: message.id, error: errorMessage });
176
+ });
177
+ },
178
+ onMessage(handler) {
179
+ listeners.add(handler);
180
+ return () => listeners.delete(handler);
181
+ },
182
+ onError() {
183
+ return () => {};
184
+ },
185
+ async terminate() {
186
+ listeners.clear();
187
+ },
188
+ };
189
+ }
190
+
191
+ function spawnMnemopiEmbedWorker(): MnemopiEmbedWorkerHandle {
192
+ try {
193
+ return wrapSubprocess(createMnemopiEmbedSubprocess());
194
+ } catch (error) {
195
+ logger.warn("mnemopi embed worker spawn failed; local embeddings disabled", {
196
+ error: error instanceof Error ? error.message : String(error),
197
+ });
198
+ return spawnInlineUnavailableWorker(error);
199
+ }
200
+ }
201
+
202
+ function logWorkerMessage(message: Extract<MnemopiEmbedWorkerOutbound, { type: "log" }>): void {
203
+ if (message.level === "debug") logger.debug(message.msg, message.meta);
204
+ else if (message.level === "warn") logger.warn(message.msg, message.meta);
205
+ else logger.error(message.msg, message.meta);
206
+ }
207
+
208
+ /**
209
+ * Per-model wrapper produced by {@link MnemopiEmbedClient.initialize}.
210
+ * `embed()` round-trips one batch of texts through the worker subprocess and
211
+ * yields the resulting vectors in a single asynchronous batch — fastembed's
212
+ * own iterator was emitting batches that we collect on the child side anyway,
213
+ * and serializing per-batch over IPC would not improve throughput.
214
+ */
215
+ export interface MnemopiSubprocessEmbeddingModel {
216
+ embed(texts: string[], batchSize?: number): AsyncIterable<number[][]>;
217
+ }
218
+
219
+ export class MnemopiEmbedClient {
220
+ #worker: MnemopiEmbedWorkerHandle | null = null;
221
+ #unsubscribeMessage: (() => void) | null = null;
222
+ #unsubscribeError: (() => void) | null = null;
223
+ #pending = new Map<string, PendingRequest>();
224
+ #nextRequestId = 0;
225
+ #spawnWorker: () => MnemopiEmbedWorkerHandle;
226
+
227
+ constructor(spawnWorker: () => MnemopiEmbedWorkerHandle = spawnMnemopiEmbedWorker) {
228
+ this.#spawnWorker = spawnWorker;
229
+ }
230
+
231
+ /**
232
+ * Load the named fastembed model inside the subprocess. Resolves to a
233
+ * thin wrapper whose `embed()` round-trips through the same worker, or
234
+ * `null` when the worker cannot init the model (missing peer, native
235
+ * load failure, etc.). Multiple calls with the same model reuse the
236
+ * single in-flight worker; calling with a different model loads it on
237
+ * the child without restarting the process.
238
+ */
239
+ async initialize(
240
+ model: MnemopiEmbedModelId,
241
+ cacheDir: string | undefined,
242
+ ): Promise<MnemopiSubprocessEmbeddingModel | null> {
243
+ try {
244
+ const worker = this.#ensureWorker();
245
+ const id = String(++this.#nextRequestId);
246
+ const { promise, resolve } = Promise.withResolvers<boolean>();
247
+ this.#pending.set(id, { kind: "init", model, resolve });
248
+ try {
249
+ worker.send({ type: "init", id, model, cacheDir });
250
+ const ok = await promise;
251
+ if (!ok) return null;
252
+ } finally {
253
+ this.#pending.delete(id);
254
+ }
255
+ } catch (error) {
256
+ logger.debug("mnemopi-embed: init failed", {
257
+ model,
258
+ error: error instanceof Error ? error.message : String(error),
259
+ });
260
+ return null;
261
+ }
262
+ return { embed: (texts, batchSize) => this.#streamEmbed(model, cacheDir, texts, batchSize) };
263
+ }
264
+
265
+ async terminate(): Promise<void> {
266
+ const worker = this.#worker;
267
+ this.#worker = null;
268
+ this.#unsubscribeMessage?.();
269
+ this.#unsubscribeMessage = null;
270
+ this.#unsubscribeError?.();
271
+ this.#unsubscribeError = null;
272
+ for (const pending of this.#pending.values()) {
273
+ if (pending.kind === "init") pending.resolve(false);
274
+ else pending.resolve(new Error("mnemopi embed worker terminated"));
275
+ }
276
+ this.#pending.clear();
277
+ try {
278
+ await worker?.terminate();
279
+ } catch {
280
+ // Already gone.
281
+ }
282
+ }
283
+
284
+ async #embed(
285
+ model: MnemopiEmbedModelId,
286
+ cacheDir: string | undefined,
287
+ texts: string[],
288
+ batchSize: number | undefined,
289
+ ): Promise<number[][]> {
290
+ const worker = this.#ensureWorker();
291
+ const id = String(++this.#nextRequestId);
292
+ const { promise, resolve } = Promise.withResolvers<number[][] | Error>();
293
+ this.#pending.set(id, { kind: "embed", model, resolve });
294
+ try {
295
+ // Carry the (model, cacheDir) the wrapper was bound to in every
296
+ // embed message: dispose + respawn between two embeds on the same
297
+ // `LocalEmbeddingModel` handle would otherwise hit a fresh
298
+ // worker's "embed before init" guard. Worker `ensureLoaded` is
299
+ // idempotent so steady-state embeds pay no extra cost.
300
+ worker.send({ type: "embed", id, model, cacheDir, texts, batchSize });
301
+ const result = await promise;
302
+ if (result instanceof Error) throw result;
303
+ return result;
304
+ } finally {
305
+ this.#pending.delete(id);
306
+ }
307
+ }
308
+
309
+ async *#streamEmbed(
310
+ model: MnemopiEmbedModelId,
311
+ cacheDir: string | undefined,
312
+ texts: string[],
313
+ batchSize: number | undefined,
314
+ ): AsyncIterable<number[][]> {
315
+ const vectors = await this.#embed(model, cacheDir, texts, batchSize);
316
+ // Mnemopi's `collectMatrix` re-batches via async iteration anyway; yield
317
+ // a single batch carrying the full result so the caller's drain loop
318
+ // behaves identically to the in-process fastembed iterator (one yield
319
+ // per `embed()` call) without paying extra IPC round-trips.
320
+ yield vectors;
321
+ }
322
+
323
+ #ensureWorker(): MnemopiEmbedWorkerHandle {
324
+ if (this.#worker) return this.#worker;
325
+ const worker = this.#spawnWorker();
326
+ this.#worker = worker;
327
+ this.#unsubscribeMessage = worker.onMessage(message => this.#handleMessage(message));
328
+ this.#unsubscribeError = worker.onError(error => this.#handleWorkerError(error));
329
+ return worker;
330
+ }
331
+
332
+ #handleMessage(message: MnemopiEmbedWorkerOutbound): void {
333
+ if (message.type === "log") {
334
+ logWorkerMessage(message);
335
+ return;
336
+ }
337
+ if (message.type === "pong") return;
338
+
339
+ const pending = this.#pending.get(message.id);
340
+ if (!pending) return;
341
+ this.#pending.delete(message.id);
342
+ if (message.type === "ready") {
343
+ if (pending.kind === "init") pending.resolve(true);
344
+ return;
345
+ }
346
+ if (message.type === "vectors") {
347
+ if (pending.kind === "embed") pending.resolve(message.vectors);
348
+ return;
349
+ }
350
+ logger.debug("mnemopi-embed: worker returned error", { error: message.error });
351
+ if (pending.kind === "init") pending.resolve(false);
352
+ else pending.resolve(new Error(message.error));
353
+ }
354
+
355
+ #handleWorkerError(error: Error): void {
356
+ logger.warn("mnemopi-embed: worker error", { error: error.message });
357
+ for (const pending of this.#pending.values()) {
358
+ if (pending.kind === "init") pending.resolve(false);
359
+ else pending.resolve(error);
360
+ }
361
+ this.#pending.clear();
362
+ void this.terminate();
363
+ }
364
+ }
365
+
366
+ export const mnemopiEmbedClient = new MnemopiEmbedClient();
367
+
368
+ export async function shutdownMnemopiEmbedClient(): Promise<void> {
369
+ await mnemopiEmbedClient.terminate();
370
+ }
371
+
372
+ export async function smokeTestMnemopiEmbedWorker({
373
+ timeoutMs = SMOKE_TEST_TIMEOUT_MS,
374
+ }: {
375
+ timeoutMs?: number;
376
+ } = {}): Promise<void> {
377
+ const handle = wrapSubprocess(createMnemopiEmbedSubprocess());
378
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
379
+ const timer = setTimeout(
380
+ () => reject(new Error(`mnemopi embed worker did not pong within ${timeoutMs}ms`)),
381
+ timeoutMs,
382
+ );
383
+ const unsubscribeMessage = handle.onMessage(message => {
384
+ if (message.type === "pong") {
385
+ resolve();
386
+ return;
387
+ }
388
+ if (message.type === "log") return;
389
+ reject(new Error(`mnemopi embed worker: expected pong, got ${JSON.stringify(message)}`));
390
+ });
391
+ const unsubscribeError = handle.onError(reject);
392
+ try {
393
+ handle.send({ type: "ping", id: "smoke" } satisfies MnemopiEmbedWorkerInbound);
394
+ await promise;
395
+ } finally {
396
+ clearTimeout(timer);
397
+ unsubscribeMessage();
398
+ unsubscribeError();
399
+ await handle.terminate();
400
+ }
401
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Wire types between the parent (`MnemopiEmbedClient`) and the local
3
+ * embeddings subprocess. The parent owns the subprocess lifecycle (graceful
4
+ * work, hard `SIGKILL` on shutdown); the protocol carries no explicit close
5
+ * handshake — once the parent decides to terminate, it signals the OS to reap
6
+ * the child so `onnxruntime-node`'s NAPI finalizer never runs in the main
7
+ * agent address space (it crashes Bun on Windows shutdown — issue #3031, the
8
+ * mnemopi sibling of the tiny-model fix from #1606/#1607). See
9
+ * `embed-client.ts` for the spawn/kill glue.
10
+ */
11
+
12
+ /** Identifier of the fastembed model the worker should load (e.g. `fast-bge-base-en-v1.5`). */
13
+ export type MnemopiEmbedModelId = string;
14
+
15
+ export type MnemopiEmbedWorkerInbound =
16
+ | { type: "ping"; id: string }
17
+ | { type: "init"; id: string; model: MnemopiEmbedModelId; cacheDir?: string }
18
+ // `embed` always carries the same `model` / `cacheDir` the wrapper was
19
+ // initialized with so a fresh subprocess (after the parent SIGKILLed the
20
+ // previous one but mnemopi still holds the cached `LocalEmbeddingModel`)
21
+ // can lazily reload the model on demand instead of returning
22
+ // "embed before init".
23
+ | { type: "embed"; id: string; model: MnemopiEmbedModelId; cacheDir?: string; texts: string[]; batchSize?: number };
24
+
25
+ export type MnemopiEmbedWorkerOutbound =
26
+ | { type: "pong"; id: string }
27
+ | { type: "ready"; id: string }
28
+ | { type: "vectors"; id: string; vectors: number[][] }
29
+ | { type: "error"; id: string; error: string }
30
+ | { type: "log"; level: "debug" | "warn" | "error"; msg: string; meta?: Record<string, unknown> };
31
+
32
+ export interface MnemopiEmbedTransport {
33
+ send(message: MnemopiEmbedWorkerOutbound): void;
34
+ onMessage(handler: (message: MnemopiEmbedWorkerInbound) => void): () => void;
35
+ }