@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.
- package/CHANGELOG.md +30 -1
- package/dist/cli.js +3046 -3047
- package/dist/types/config/model-resolver.d.ts +3 -3
- package/dist/types/mnemopi/embed-client.d.ts +70 -0
- package/dist/types/mnemopi/embed-protocol.d.ts +52 -0
- package/dist/types/mnemopi/embed-worker.d.ts +12 -0
- package/dist/types/mnemopi/state.d.ts +9 -1
- package/dist/types/session/agent-storage.d.ts +2 -0
- package/dist/types/session/auth-broker-config.d.ts +3 -2
- package/dist/types/session/history-storage.d.ts +1 -1
- package/dist/types/tools/image-gen.d.ts +2 -2
- package/dist/types/utils/image-loading.d.ts +1 -1
- package/dist/types/utils/ipc.d.ts +22 -0
- package/dist/types/web/search/providers/perplexity-auth.d.ts +37 -0
- package/package.json +12 -12
- package/src/cli.ts +8 -0
- package/src/commands/token.ts +52 -33
- package/src/config/append-only-context-mode.ts +45 -0
- package/src/config/model-discovery.ts +3 -0
- package/src/config/model-registry.ts +21 -3
- package/src/config/model-resolver.ts +31 -8
- package/src/discovery/builtin-rules/ts-no-return-type.md +0 -1
- package/src/lsp/client.ts +24 -0
- package/src/mnemopi/backend.ts +49 -3
- package/src/mnemopi/embed-client.ts +401 -0
- package/src/mnemopi/embed-protocol.ts +35 -0
- package/src/mnemopi/embed-worker.ts +113 -0
- package/src/mnemopi/state.ts +29 -1
- package/src/modes/components/custom-editor.ts +1 -1
- package/src/modes/components/model-selector.ts +2 -2
- package/src/modes/components/welcome.ts +1 -1
- package/src/modes/controllers/event-controller.ts +8 -0
- package/src/modes/controllers/selector-controller.ts +2 -2
- package/src/modes/theme/theme.ts +69 -0
- package/src/sdk.ts +4 -0
- package/src/session/agent-session.ts +8 -0
- package/src/session/agent-storage.ts +14 -0
- package/src/session/auth-broker-config.ts +2 -1
- package/src/session/history-storage.ts +13 -1
- package/src/stt/asr-client.ts +2 -7
- package/src/tiny/title-client.ts +2 -7
- package/src/tools/image-gen.ts +4 -8
- package/src/tools/render-utils.ts +4 -1
- package/src/tts/tts-client.ts +2 -7
- package/src/utils/image-loading.ts +12 -2
- package/src/utils/ipc.ts +38 -0
- package/src/web/search/providers/perplexity-auth.ts +133 -0
- 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
|
|
1339
|
-
*
|
|
1340
|
-
*
|
|
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
|
-
|
|
1357
|
-
|
|
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
|
|
1408
|
+
const allowedModels: Model<Api>[] = [];
|
|
1386
1409
|
const addAllowed = (model: Model<Api>) => {
|
|
1387
|
-
|
|
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
|
|
1435
|
+
return includeSyntheticAllowedModels(available, allowedModels);
|
|
1413
1436
|
}
|
|
1414
1437
|
|
|
1415
1438
|
export interface ResolveCliModelResult {
|
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}`,
|
package/src/mnemopi/backend.ts
CHANGED
|
@@ -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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
+
}
|