@adhd/sox-embedding-provider 0.5.1 → 0.5.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/README.md CHANGED
@@ -43,6 +43,8 @@ interface EmbeddingProviderConfig {
43
43
  type: string; // 'fastembed' | 'remote'
44
44
  model: string;
45
45
  options?: Record<string, unknown>;
46
+ host?: 'shared' | 'private'; // default 'shared' — see "Host posture" below
47
+ idleGraceMs?: number; // how long an idle shared host lingers (default 30000)
46
48
  }
47
49
  ```
48
50
 
@@ -107,9 +109,48 @@ Built-in models (`modelId` → dimensions):
107
109
  | `bge-m3` | 1024 | 8192 | 100+ languages, long context, ~570M params |
108
110
  | `codexembed-400m` | 1024 | 8192 | code-only, ~1.6GB RAM, long context |
109
111
 
110
- fastembed inference is routed through a single process-wide shared child process rather than a
111
- worker per provider instance, so multiple `FastembedProvider`s constructed in the same Node process
112
- (even concurrently) never race each other for the same native ONNX runtime.
112
+ fastembed inference is routed through **one machine-wide, peer-spawned, self-reaping host
113
+ process** per `(model, execution-provider, cacheDir)` not one ONNX host per consumer process.
114
+ The first consumer to need it peer-spawns the host through the service-proxy's `ensureBackend()`
115
+ singleton spawn-lock; every other consumer on the box dials that same host over a Unix domain
116
+ socket. The host is compute-only (it holds no store connection) and it **reaps itself**: a
117
+ debounced, ref-counted teardown retires it `idleGraceMs` after the last client disconnects and its
118
+ last in-flight request drains. There is no supervised service and no daemon.
119
+
120
+ #### Host posture
121
+
122
+ `EmbeddingProviderConfig.host` is a typed closed union (default `'shared'`), applied
123
+ process-wide before the accessor is constructed and reported in `health().host`:
124
+
125
+ - `'shared'` — funnel through the machine-wide host above. `getSharedFastembedProcess()` returns a
126
+ `FunneledFastembedClient`; its `terminate()` is a **no-op** (a consumer must never kill a host
127
+ other consumers are using).
128
+ - `'private'` — the pre-funnel per-process fork (CI/diagnostics). `getSharedFastembedProcess()`
129
+ returns the private pool directly.
130
+
131
+ `EmbeddingProviderConfig.idleGraceMs` (typed config, default `DEFAULT_EMBED_HOST_IDLE_GRACE_MS` =
132
+ 30 s) sets how long a zero-client host lingers before it reaps itself. A host that fails to come up
133
+ throws a typed `TransientEmbeddingError` naming the socket — it never silently falls back to a
134
+ private host.
135
+
136
+ #### Lifecycle helpers
137
+
138
+ ```typescript
139
+ import {
140
+ getPrivateFastembedProcess, // the PRIVATE (un-funneled) per-process pool
141
+ resetSharedFastembedHost, // heal: ask the live shared host to re-fork its private pool
142
+ getSharedFastembedProcess, // the host-aware accessor (shared by default)
143
+ FunneledFastembedClient, // what getSharedFastembedProcess() returns under 'shared'
144
+ } from '@adhd/sox-embedding-provider';
145
+
146
+ const client = getSharedFastembedProcess(); // FunneledFastembedClient under the default
147
+ const vec = await client.request({ type: 'embed', text: 'hello' });
148
+ console.log(client.started, client.pendingCount, client.hostSocketPath);
149
+ ```
150
+
151
+ `resetSharedFastembedHost()` is the recovery path for a wedged private child: under `'shared'` it
152
+ asks the live host to tear down and re-fork its private ONNX pool (without killing the host other
153
+ consumers share); under `'private'` the accessor's `terminate()` path is unchanged.
113
154
 
114
155
  ### remote — call an HTTP embedding endpoint
115
156
 
@@ -0,0 +1,161 @@
1
+ /**
2
+ * embedHostConfig.ts — config + stable socket/singleton-key resolution for the
3
+ * embedding funnel (SPEC-EMBEDDING-FUNNEL.md §B).
4
+ *
5
+ * The funnel collapses N consumer processes onto ONE peer-spawned, self-reaping
6
+ * ONNX host per `(model, execution-provider, cacheDir)`. That requires a
7
+ * deterministic, machine-wide identity for "the one host for this workload":
8
+ *
9
+ * - {@link embedHostSingletonKey} — the [def:singleton-key] every consumer
10
+ * computes identically, so `ensureBackend()`'s O_EXCL spawn-lock collapses a
11
+ * thundering herd to one spawn.
12
+ * - {@link embedHostSocketPath} — the UDS path derived from that key via
13
+ * `backendSocketPath()` (the SAME derivation the service-proxy uses, so
14
+ * there is never a port-selection problem and never a clash).
15
+ *
16
+ * ── Host selection AND the idle bound are typed config, never env toggles ──────
17
+ *
18
+ * `host` is a closed union (`'shared' | 'private'`) carried on
19
+ * `EmbeddingProviderConfig.host` and reported in `health()`. `idleGraceMs` is the
20
+ * same shape: a typed field on `EmbeddingProviderConfig.idleGraceMs` (applied by
21
+ * `createEmbeddingProvider()`), reported in the host's `embedding.health`, and
22
+ * carried on the resolved `EmbedHostConfig` the SPAWNER reads and forwards to the
23
+ * spawned host. There is deliberately NO `SOX_EMBED_HOST=shared|private`
24
+ * variable: a behavior switch must be visible, validated, and auditable, not an
25
+ * ambient string.
26
+ *
27
+ * `SOX_EMBED_HOST_IDLE_GRACE_MS` still exists, but it is the INTERNAL
28
+ * cross-process transport between the spawner and the spawned host — the spawner
29
+ * writes the resolved `EmbedHostConfig.idleGraceMs` into the child's env and the
30
+ * host consumes it. It is not the public configuration surface; set the typed
31
+ * `idleGraceMs` (or `configureEmbedHostIdleGraceMs()`) instead. (Owner directive:
32
+ * "the time bound should be configurable" — typed config, not an ADR-0013 D3 env
33
+ * tuning constant. This supersedes ADR-0020 D2's env-tuning framing.)
34
+ *
35
+ * The remaining env reads are ADR-0013 D5 shapes:
36
+ *
37
+ * - `SOX_ECOSYSTEM_HOME` — host config (D5): where the socket lives.
38
+ * - `SOX_EMBED_HOST_MAIN` / `SOX_EMBED_HOST_SOCKET` — host-injected transport
39
+ * config (D5) / test seams; they select a path, they never enable a feature.
40
+ *
41
+ * Leaf module — node builtins only (fs, os, path, url, crypto, module) plus
42
+ * `backendSocketPath` from `@adhd/sox-service-proxy`.
43
+ */
44
+ /** The host-selection closed union (ADR-0013). `'shared'` is the default. */
45
+ export type EmbedHostMode = 'shared' | 'private';
46
+ /** Resolved funnel configuration. */
47
+ export interface EmbedHostConfig {
48
+ /** `'shared'` (default): funnel through the peer-spawned host. `'private'`: the pre-funnel per-process fork (CI/diagnostics). */
49
+ host: EmbedHostMode;
50
+ /** The directory the host UDS is created under (ADR-0004: `$SOX_ECOSYSTEM_HOME/run`). */
51
+ socketDir: string;
52
+ /**
53
+ * How long the host lingers with zero clients and zero in-flight work before
54
+ * it reaps itself. Typed config (owner directive: "the time bound should be
55
+ * configurable"): the SPAWNER reads this resolved value and forwards it to the
56
+ * spawned host, which consumes it. Set it via
57
+ * `EmbeddingProviderConfig.idleGraceMs` / `configureEmbedHostIdleGraceMs()`.
58
+ */
59
+ idleGraceMs: number;
60
+ }
61
+ /**
62
+ * Bumped whenever the host's wire protocol or behavior changes incompatibly.
63
+ *
64
+ * Open question #4 in SPEC-EMBEDDING-FUNNEL.md ("a running host is an old
65
+ * build") is resolved with this constant rather than the npm package version.
66
+ * Rationale: the real hazard is a host whose `embedding.*` contract the new
67
+ * consumer cannot speak — a *protocol* change — not every cosmetic patch. Keying
68
+ * on the npm version would spawn a brand-new host on every patch bump (briefly
69
+ * two hosts, defeating the funnel across an upgrade window) while the explicit
70
+ * protocol version forces a new socket exactly when compatibility actually
71
+ * breaks. Bump this with any change to the `embedding.*` method set, the payload
72
+ * shape, or the response shape in `embedHostMain.ts`.
73
+ */
74
+ export declare const EMBED_HOST_PROTOCOL_VERSION = 1;
75
+ /** Default grace before a zero-client host reaps itself. Sized to the short-lived-CLI arrival cadence. */
76
+ export declare const DEFAULT_EMBED_HOST_IDLE_GRACE_MS = 30000;
77
+ /**
78
+ * The internal cross-process transport variable the spawner uses to hand the
79
+ * resolved `EmbedHostConfig.idleGraceMs` to the spawned host. Not a public knob
80
+ * — set the typed `idleGraceMs` instead.
81
+ */
82
+ export declare const EMBED_HOST_IDLE_GRACE_ENV = "SOX_EMBED_HOST_IDLE_GRACE_MS";
83
+ /**
84
+ * Apply the typed `host` field from an `EmbeddingProviderConfig`. Passing
85
+ * `undefined` leaves the current posture unchanged (so a `remote` provider
86
+ * without a `host` field never resets a deliberate `'private'` selection).
87
+ */
88
+ export declare function configureEmbedHostHost(host: EmbedHostMode | undefined): void;
89
+ /**
90
+ * Apply the typed `idleGraceMs` field from an `EmbeddingProviderConfig`. Passing
91
+ * `undefined` leaves the current value unchanged (so a provider without the field
92
+ * never resets a deliberate selection). A non-positive or non-finite value is
93
+ * rejected loudly — never silently treated as "off".
94
+ */
95
+ export declare function configureEmbedHostIdleGraceMs(idleGraceMs: number | undefined): void;
96
+ /** TEST-ONLY: clear the process-wide host + idle-grace overrides back to defaults. */
97
+ export declare function __resetEmbedHostConfigForTests(): void;
98
+ /**
99
+ * Resolve the funnel configuration. `host` defaults to `'shared'`; `idleGraceMs`
100
+ * is typed config (with the transport env / hard default as fallbacks). The
101
+ * SPAWNER reads the resolved `idleGraceMs` and forwards it to the spawned host —
102
+ * see `funnelClient.ts`'s `doEnsure()` — which is what makes this field the
103
+ * consumed surface rather than a dead declaration.
104
+ */
105
+ export declare function resolveEmbedHostConfig(): EmbedHostConfig;
106
+ /**
107
+ * Resolve the idle grace, in priority order:
108
+ *
109
+ * 1. the typed override (`configureEmbedHostIdleGraceMs()` /
110
+ * `EmbeddingProviderConfig.idleGraceMs`) — the public surface;
111
+ * 2. the internal transport env (`SOX_EMBED_HOST_IDLE_GRACE_ENV`) the spawner
112
+ * writes for the spawned host;
113
+ * 3. `DEFAULT_EMBED_HOST_IDLE_GRACE_MS`.
114
+ *
115
+ * A non-positive or non-numeric transport value is rejected loudly (never
116
+ * silently treated as "off").
117
+ */
118
+ export declare function resolveEmbedHostIdleGraceMs(): number;
119
+ /**
120
+ * ADR-0004 D5: the host socket lives under `$SOX_ECOSYSTEM_HOME/run` (the user
121
+ * data root's `run/` dir). Mirrors `@adhd/sox-host-runtime`'s `runDir()` without
122
+ * importing it — this package is a low-tier leaf and must not gain a
123
+ * host-runtime dependency. `SOX_ECOSYSTEM_HOME` defaults to `~/.adhd/sox-ecosystem`
124
+ * exactly as `data-paths.ts`'s `DATA_SUBDIR` does.
125
+ */
126
+ export declare function resolveEmbedHostSocketDir(): string;
127
+ /**
128
+ * The machine-wide [def:singleton-key] for the host serving `(modelId, ep,
129
+ * cacheDir)`. Identical inputs ⇒ identical key ⇒ identical socket ⇒ one host,
130
+ * across every consumer process on the box.
131
+ *
132
+ * `cacheDirDigest` is a short sha256 of the ABSOLUTE cache dir, so two stores
133
+ * pointed at different model caches never collide onto one host. The protocol
134
+ * version is appended (see {@link EMBED_HOST_PROTOCOL_VERSION}).
135
+ */
136
+ export declare function embedHostSingletonKey(modelId: string, ep: string, cacheDir: string): string;
137
+ /** Derive the host UDS path from the resolved config and singleton key. */
138
+ export declare function embedHostSocketPath(cfg: EmbedHostConfig, key: string): string;
139
+ /**
140
+ * Resolve the runnable host entrypoint to spawn.
141
+ *
142
+ * Order:
143
+ * 1. `SOX_EMBED_HOST_MAIN` (D5/test seam) — a path injection, never a feature
144
+ * toggle. Used by the funnel teeth suite to run the host from source via a
145
+ * tsx shim without a prior build.
146
+ * 2. A `__dirname` sibling (`dist/embedHostMain.js`) — the npm AND bundled
147
+ * case. The literal `join(__dirname, 'embedHostMain.js')` is load-bearing:
148
+ * `bundle-extension.cjs`'s `verifySidecarReferences()` scans emitted files
149
+ * for exactly this shape, so the sidecar can never ship missing (BL-259).
150
+ * 3. `src/../dist/embedHostMain.js` — vitest's `src/`-resident `__dirname`.
151
+ * 4. `require.resolve('@adhd/sox-embedding-provider/embed-host')` — the
152
+ * package `exports` subpath, when self-reference is available.
153
+ */
154
+ export declare function resolveEmbedHostMainPath(): string;
155
+ /**
156
+ * Where the detached host's stderr is redirected (its stdout/stdin are severed).
157
+ * Kept beside the socket so a wedged host's startup diagnostics are greppable
158
+ * without inheriting any caller fd ([inv:no-fd-inherit], BL-67).
159
+ */
160
+ export declare function resolveEmbedHostStderrLogPath(cfg: EmbedHostConfig): string;
161
+ //# sourceMappingURL=embedHostConfig.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"embedHostConfig.d.ts","sourceRoot":"","sources":["../src/embedHostConfig.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAYH,6EAA6E;AAC7E,MAAM,MAAM,aAAa,GAAG,QAAQ,GAAG,SAAS,CAAC;AAEjD,qCAAqC;AACrC,MAAM,WAAW,eAAe;IAC9B,iIAAiI;IACjI,IAAI,EAAE,aAAa,CAAC;IACpB,yFAAyF;IACzF,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;OAMG;IACH,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,2BAA2B,IAAI,CAAC;AAE7C,0GAA0G;AAC1G,eAAO,MAAM,gCAAgC,QAAS,CAAC;AAwBvD;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,iCAAiC,CAAC;AAExE;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,aAAa,GAAG,SAAS,GAAG,IAAI,CAM5E;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAQnF;AAED,sFAAsF;AACtF,wBAAgB,8BAA8B,IAAI,IAAI,CAGrD;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,IAAI,eAAe,CAMxD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,2BAA2B,IAAI,MAAM,CAWpD;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,IAAI,MAAM,CAIlD;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAG3F;AAED,2EAA2E;AAC3E,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAE7E;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,wBAAwB,IAAI,MAAM,CAgBjD;AAED;;;;GAIG;AACH,wBAAgB,6BAA6B,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,CAE1E"}
@@ -0,0 +1,234 @@
1
+ /**
2
+ * embedHostConfig.ts — config + stable socket/singleton-key resolution for the
3
+ * embedding funnel (SPEC-EMBEDDING-FUNNEL.md §B).
4
+ *
5
+ * The funnel collapses N consumer processes onto ONE peer-spawned, self-reaping
6
+ * ONNX host per `(model, execution-provider, cacheDir)`. That requires a
7
+ * deterministic, machine-wide identity for "the one host for this workload":
8
+ *
9
+ * - {@link embedHostSingletonKey} — the [def:singleton-key] every consumer
10
+ * computes identically, so `ensureBackend()`'s O_EXCL spawn-lock collapses a
11
+ * thundering herd to one spawn.
12
+ * - {@link embedHostSocketPath} — the UDS path derived from that key via
13
+ * `backendSocketPath()` (the SAME derivation the service-proxy uses, so
14
+ * there is never a port-selection problem and never a clash).
15
+ *
16
+ * ── Host selection AND the idle bound are typed config, never env toggles ──────
17
+ *
18
+ * `host` is a closed union (`'shared' | 'private'`) carried on
19
+ * `EmbeddingProviderConfig.host` and reported in `health()`. `idleGraceMs` is the
20
+ * same shape: a typed field on `EmbeddingProviderConfig.idleGraceMs` (applied by
21
+ * `createEmbeddingProvider()`), reported in the host's `embedding.health`, and
22
+ * carried on the resolved `EmbedHostConfig` the SPAWNER reads and forwards to the
23
+ * spawned host. There is deliberately NO `SOX_EMBED_HOST=shared|private`
24
+ * variable: a behavior switch must be visible, validated, and auditable, not an
25
+ * ambient string.
26
+ *
27
+ * `SOX_EMBED_HOST_IDLE_GRACE_MS` still exists, but it is the INTERNAL
28
+ * cross-process transport between the spawner and the spawned host — the spawner
29
+ * writes the resolved `EmbedHostConfig.idleGraceMs` into the child's env and the
30
+ * host consumes it. It is not the public configuration surface; set the typed
31
+ * `idleGraceMs` (or `configureEmbedHostIdleGraceMs()`) instead. (Owner directive:
32
+ * "the time bound should be configurable" — typed config, not an ADR-0013 D3 env
33
+ * tuning constant. This supersedes ADR-0020 D2's env-tuning framing.)
34
+ *
35
+ * The remaining env reads are ADR-0013 D5 shapes:
36
+ *
37
+ * - `SOX_ECOSYSTEM_HOME` — host config (D5): where the socket lives.
38
+ * - `SOX_EMBED_HOST_MAIN` / `SOX_EMBED_HOST_SOCKET` — host-injected transport
39
+ * config (D5) / test seams; they select a path, they never enable a feature.
40
+ *
41
+ * Leaf module — node builtins only (fs, os, path, url, crypto, module) plus
42
+ * `backendSocketPath` from `@adhd/sox-service-proxy`.
43
+ */
44
+ import { existsSync } from 'node:fs';
45
+ import { createHash } from 'node:crypto';
46
+ import { createRequire } from 'node:module';
47
+ import { homedir } from 'node:os';
48
+ import { dirname, join } from 'node:path';
49
+ import { fileURLToPath } from 'node:url';
50
+ import { backendSocketPath } from '@adhd/sox-service-proxy';
51
+ const __dirname = dirname(fileURLToPath(import.meta.url));
52
+ /**
53
+ * Bumped whenever the host's wire protocol or behavior changes incompatibly.
54
+ *
55
+ * Open question #4 in SPEC-EMBEDDING-FUNNEL.md ("a running host is an old
56
+ * build") is resolved with this constant rather than the npm package version.
57
+ * Rationale: the real hazard is a host whose `embedding.*` contract the new
58
+ * consumer cannot speak — a *protocol* change — not every cosmetic patch. Keying
59
+ * on the npm version would spawn a brand-new host on every patch bump (briefly
60
+ * two hosts, defeating the funnel across an upgrade window) while the explicit
61
+ * protocol version forces a new socket exactly when compatibility actually
62
+ * breaks. Bump this with any change to the `embedding.*` method set, the payload
63
+ * shape, or the response shape in `embedHostMain.ts`.
64
+ */
65
+ export const EMBED_HOST_PROTOCOL_VERSION = 1;
66
+ /** Default grace before a zero-client host reaps itself. Sized to the short-lived-CLI arrival cadence. */
67
+ export const DEFAULT_EMBED_HOST_IDLE_GRACE_MS = 30_000;
68
+ /**
69
+ * Process-wide host-selection override. `null` ⇒ `'shared'`.
70
+ *
71
+ * Deliberately NOT an env var (ADR-0013): the typed
72
+ * `EmbeddingProviderConfig.host` field is applied here by
73
+ * `createEmbeddingProvider()` before the accessor singleton is constructed.
74
+ * Host selection is a process-wide posture — once a consumer has resolved its
75
+ * accessor, changing this does not retroactively re-point it (documented, not
76
+ * hidden).
77
+ */
78
+ let _hostOverride = null;
79
+ /**
80
+ * Process-wide idle-grace override. `null` ⇒ resolve from the transport env (or
81
+ * the default). This is the TYPED public surface for the time bound: the owner
82
+ * directive is that the bound be configurable as config, not as an ambient
83
+ * ADR-0013 D3 env constant. Set via `configureEmbedHostIdleGraceMs()` (which
84
+ * `createEmbeddingProvider()` calls with `EmbeddingProviderConfig.idleGraceMs`)
85
+ * before the host is spawned.
86
+ */
87
+ let _idleGraceOverride = null;
88
+ /**
89
+ * The internal cross-process transport variable the spawner uses to hand the
90
+ * resolved `EmbedHostConfig.idleGraceMs` to the spawned host. Not a public knob
91
+ * — set the typed `idleGraceMs` instead.
92
+ */
93
+ export const EMBED_HOST_IDLE_GRACE_ENV = 'SOX_EMBED_HOST_IDLE_GRACE_MS';
94
+ /**
95
+ * Apply the typed `host` field from an `EmbeddingProviderConfig`. Passing
96
+ * `undefined` leaves the current posture unchanged (so a `remote` provider
97
+ * without a `host` field never resets a deliberate `'private'` selection).
98
+ */
99
+ export function configureEmbedHostHost(host) {
100
+ if (host === undefined)
101
+ return;
102
+ if (host !== 'shared' && host !== 'private') {
103
+ throw new TypeError(`EmbedHostMode must be 'shared' or 'private', got ${String(host)}`);
104
+ }
105
+ _hostOverride = host;
106
+ }
107
+ /**
108
+ * Apply the typed `idleGraceMs` field from an `EmbeddingProviderConfig`. Passing
109
+ * `undefined` leaves the current value unchanged (so a provider without the field
110
+ * never resets a deliberate selection). A non-positive or non-finite value is
111
+ * rejected loudly — never silently treated as "off".
112
+ */
113
+ export function configureEmbedHostIdleGraceMs(idleGraceMs) {
114
+ if (idleGraceMs === undefined)
115
+ return;
116
+ if (!Number.isFinite(idleGraceMs) || idleGraceMs <= 0) {
117
+ throw new TypeError(`EmbedHostConfig.idleGraceMs must be a positive number, got ${String(idleGraceMs)}`);
118
+ }
119
+ _idleGraceOverride = idleGraceMs;
120
+ }
121
+ /** TEST-ONLY: clear the process-wide host + idle-grace overrides back to defaults. */
122
+ export function __resetEmbedHostConfigForTests() {
123
+ _hostOverride = null;
124
+ _idleGraceOverride = null;
125
+ }
126
+ /**
127
+ * Resolve the funnel configuration. `host` defaults to `'shared'`; `idleGraceMs`
128
+ * is typed config (with the transport env / hard default as fallbacks). The
129
+ * SPAWNER reads the resolved `idleGraceMs` and forwards it to the spawned host —
130
+ * see `funnelClient.ts`'s `doEnsure()` — which is what makes this field the
131
+ * consumed surface rather than a dead declaration.
132
+ */
133
+ export function resolveEmbedHostConfig() {
134
+ return {
135
+ host: _hostOverride ?? 'shared',
136
+ socketDir: resolveEmbedHostSocketDir(),
137
+ idleGraceMs: resolveEmbedHostIdleGraceMs(),
138
+ };
139
+ }
140
+ /**
141
+ * Resolve the idle grace, in priority order:
142
+ *
143
+ * 1. the typed override (`configureEmbedHostIdleGraceMs()` /
144
+ * `EmbeddingProviderConfig.idleGraceMs`) — the public surface;
145
+ * 2. the internal transport env (`SOX_EMBED_HOST_IDLE_GRACE_ENV`) the spawner
146
+ * writes for the spawned host;
147
+ * 3. `DEFAULT_EMBED_HOST_IDLE_GRACE_MS`.
148
+ *
149
+ * A non-positive or non-numeric transport value is rejected loudly (never
150
+ * silently treated as "off").
151
+ */
152
+ export function resolveEmbedHostIdleGraceMs() {
153
+ if (_idleGraceOverride !== null)
154
+ return _idleGraceOverride;
155
+ const raw = process.env[EMBED_HOST_IDLE_GRACE_ENV];
156
+ if (raw === undefined || raw === '')
157
+ return DEFAULT_EMBED_HOST_IDLE_GRACE_MS;
158
+ const parsed = Number(raw);
159
+ if (!Number.isFinite(parsed) || parsed <= 0) {
160
+ throw new TypeError(`${EMBED_HOST_IDLE_GRACE_ENV} must be a positive number, got ${JSON.stringify(raw)}`);
161
+ }
162
+ return parsed;
163
+ }
164
+ /**
165
+ * ADR-0004 D5: the host socket lives under `$SOX_ECOSYSTEM_HOME/run` (the user
166
+ * data root's `run/` dir). Mirrors `@adhd/sox-host-runtime`'s `runDir()` without
167
+ * importing it — this package is a low-tier leaf and must not gain a
168
+ * host-runtime dependency. `SOX_ECOSYSTEM_HOME` defaults to `~/.adhd/sox-ecosystem`
169
+ * exactly as `data-paths.ts`'s `DATA_SUBDIR` does.
170
+ */
171
+ export function resolveEmbedHostSocketDir() {
172
+ const home = process.env['SOX_ECOSYSTEM_HOME'];
173
+ const root = home !== undefined && home !== '' ? home : join(homedir(), '.adhd', 'sox-ecosystem');
174
+ return join(root, 'run');
175
+ }
176
+ /**
177
+ * The machine-wide [def:singleton-key] for the host serving `(modelId, ep,
178
+ * cacheDir)`. Identical inputs ⇒ identical key ⇒ identical socket ⇒ one host,
179
+ * across every consumer process on the box.
180
+ *
181
+ * `cacheDirDigest` is a short sha256 of the ABSOLUTE cache dir, so two stores
182
+ * pointed at different model caches never collide onto one host. The protocol
183
+ * version is appended (see {@link EMBED_HOST_PROTOCOL_VERSION}).
184
+ */
185
+ export function embedHostSingletonKey(modelId, ep, cacheDir) {
186
+ const digest = createHash('sha256').update(cacheDir, 'utf8').digest('hex').slice(0, 12);
187
+ return `embedding-host:v${EMBED_HOST_PROTOCOL_VERSION}:${modelId}:${ep}:${digest}`;
188
+ }
189
+ /** Derive the host UDS path from the resolved config and singleton key. */
190
+ export function embedHostSocketPath(cfg, key) {
191
+ return backendSocketPath(cfg.socketDir, key);
192
+ }
193
+ /**
194
+ * Resolve the runnable host entrypoint to spawn.
195
+ *
196
+ * Order:
197
+ * 1. `SOX_EMBED_HOST_MAIN` (D5/test seam) — a path injection, never a feature
198
+ * toggle. Used by the funnel teeth suite to run the host from source via a
199
+ * tsx shim without a prior build.
200
+ * 2. A `__dirname` sibling (`dist/embedHostMain.js`) — the npm AND bundled
201
+ * case. The literal `join(__dirname, 'embedHostMain.js')` is load-bearing:
202
+ * `bundle-extension.cjs`'s `verifySidecarReferences()` scans emitted files
203
+ * for exactly this shape, so the sidecar can never ship missing (BL-259).
204
+ * 3. `src/../dist/embedHostMain.js` — vitest's `src/`-resident `__dirname`.
205
+ * 4. `require.resolve('@adhd/sox-embedding-provider/embed-host')` — the
206
+ * package `exports` subpath, when self-reference is available.
207
+ */
208
+ export function resolveEmbedHostMainPath() {
209
+ const override = process.env['SOX_EMBED_HOST_MAIN'];
210
+ if (override !== undefined && override !== '')
211
+ return override;
212
+ const sibling = join(__dirname, 'embedHostMain.js');
213
+ if (existsSync(sibling))
214
+ return sibling;
215
+ const distSibling = join(__dirname, '..', 'dist', 'embedHostMain.js');
216
+ if (existsSync(distSibling))
217
+ return distSibling;
218
+ try {
219
+ return createRequire(import.meta.url).resolve('@adhd/sox-embedding-provider/embed-host');
220
+ }
221
+ catch {
222
+ // Last resort: name the path that was actually attempted in any later error.
223
+ return sibling;
224
+ }
225
+ }
226
+ /**
227
+ * Where the detached host's stderr is redirected (its stdout/stdin are severed).
228
+ * Kept beside the socket so a wedged host's startup diagnostics are greppable
229
+ * without inheriting any caller fd ([inv:no-fd-inherit], BL-67).
230
+ */
231
+ export function resolveEmbedHostStderrLogPath(cfg) {
232
+ return join(cfg.socketDir, 'logs', 'embed-host.stderr.log');
233
+ }
234
+ //# sourceMappingURL=embedHostConfig.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"embedHostConfig.js","sourceRoot":"","sources":["../src/embedHostConfig.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * embedHostMain.ts — the peer-spawned, self-reaping embedding host process
3
+ * (SPEC-EMBEDDING-FUNNEL.md §C).
4
+ *
5
+ * This is the compute backend of the funnel: a plain, DETACHED Node process,
6
+ * spawned on demand by the first consumer through `ensureBackend()`'s O_EXCL
7
+ * singleton spawn-lock. It is **never supervised** (no launchd/KeepAlive) and it
8
+ * **reaps itself** — a debounced, ref-counted teardown retires it
9
+ * `idleGraceMs` after the last cross-process client disconnects and in-flight
10
+ * work drains.
11
+ *
12
+ * ── Compute-only (ADR-0012) ────────────────────────────────────────────────────
13
+ *
14
+ * The host holds NO store connection. It forwards `embedding.*` payloads 1:1 to
15
+ * its private ONNX pool and never opens a database. It therefore cannot
16
+ * serialize store access and must never be described as single-writer or as a
17
+ * store serialization point; concurrent store writers are unaffected.
18
+ *
19
+ * ── No recursion ───────────────────────────────────────────────────────────────
20
+ *
21
+ * The handler forwards to `getPrivateFastembedProcess()` — the PRIVATE pool — and
22
+ * NEVER to `getSharedFastembedProcess()`, which under `host: 'shared'` would
23
+ * return a `FunneledFastembedClient` and dial this very host (infinite
24
+ * recursion). The import graph is arranged so that mistake is a type error, not
25
+ * a runtime hang.
26
+ *
27
+ * ── Teardown inputs are CROSS-PROCESS ─────────────────────────────────────────
28
+ *
29
+ * - `activeClients` — live UDS client connections, from `serveBackend`'s
30
+ * `onClientCountChange` hook (the cross-process half);
31
+ * - `inFlight` — THIS host's own request depth, incremented synchronously at
32
+ * the top of every handler invocation and decremented in its `finally` (the
33
+ * in-process half). It is deliberately NOT the private pool's
34
+ * `pendingCount`: that counter is incremented only AFTER `ensureProcess()`
35
+ * (the fork) resolves, so it reads 0 during a cold-start fork and would arm
36
+ * a reap over live work. `inFlight` is the synchronous truth.
37
+ *
38
+ * `activeClients === 0 && inFlight === 0` arms the grace timer; a new client
39
+ * or request cancels it. On expiry the private pool is terminated, the listener
40
+ * closed (which unlinks the socket), and the process exits 0. The private pool's
41
+ * ONNX child is forked `detached: false`, so it dies with the host — no orphans.
42
+ *
43
+ * ── The accessor is resolved at EVERY use, never captured ──────────────────────
44
+ *
45
+ * `embedding.reset` terminates AND nulls the private singleton
46
+ * (`resetPrivateFastembedProcess()`); a host that captured the accessor once
47
+ * would keep forwarding through the terminated reference and answer every later
48
+ * request with `shared fastembed process terminated`. Every use
49
+ * (the request handler, `armIfIdle`, `teardown`, `health`) therefore calls
50
+ * `getPrivateFastembedProcess()` fresh.
51
+ */
52
+ /**
53
+ * Start the host: bind the UDS, serve `embedding.*`, and arm the debounced
54
+ * self-reap. Resolves once the listener is up (so a caller/test can await
55
+ * readiness); the process stays alive until the teardown fires or a signal
56
+ * arrives.
57
+ */
58
+ export declare function runEmbedHost(): Promise<void>;
59
+ //# sourceMappingURL=embedHostMain.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"embedHostMain.d.ts","sourceRoot":"","sources":["../src/embedHostMain.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AAyBH;;;;;GAKG;AACH,wBAAsB,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CA4HlD"}