membot 0.7.0 → 0.8.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.
@@ -68,9 +68,40 @@ async function getPipeline(model: string): Promise<FeatureExtractionPipeline> {
68
68
  * with `(done, total)` chunk counts so callers can drive a spinner / progress
69
69
  * bar — ONNX WASM holds the JS thread for hundreds of ms per batch and would
70
70
  * otherwise leave nanospinner's setInterval starved between updates.
71
+ *
72
+ * `directOnly` bypasses any registered EmbedderPool and runs the embed call
73
+ * inline in the current process. Use it for query-time single-text embedding
74
+ * where IPC overhead would dominate.
71
75
  */
72
76
  export interface EmbedOptions {
73
77
  onProgress?: (done: number, total: number) => void;
78
+ directOnly?: boolean;
79
+ }
80
+
81
+ /**
82
+ * The minimal surface the embedder needs from a worker pool. Defined as an
83
+ * interface (not an `import type`) so we don't take a hard dependency on
84
+ * `embedder-pool.ts` from this hot path — the pool is plugged in via
85
+ * `setEmbedderPool()` from outside.
86
+ */
87
+ export interface PooledEmbedder {
88
+ embed(texts: string[], model?: string, opts?: EmbedOptions): Promise<number[][]>;
89
+ }
90
+
91
+ let pool: PooledEmbedder | null = null;
92
+
93
+ /**
94
+ * Register a worker pool to handle bulk embed calls. After this is set, every
95
+ * `embed()` call (without `directOnly`) is dispatched through the pool.
96
+ * Called once during `buildContext()` when `config.embedding.workers > 1`.
97
+ */
98
+ export function setEmbedderPool(p: PooledEmbedder | null): void {
99
+ pool = p;
100
+ }
101
+
102
+ /** Read the currently registered pool, or `null` when running single-process. */
103
+ export function getEmbedderPool(): PooledEmbedder | null {
104
+ return pool;
74
105
  }
75
106
 
76
107
  /**
@@ -92,6 +123,9 @@ export async function embed(
92
123
  opts: EmbedOptions = {},
93
124
  ): Promise<number[][]> {
94
125
  if (texts.length === 0) return [];
126
+ if (pool && !opts.directOnly) {
127
+ return pool.embed(texts, model, opts);
128
+ }
95
129
  const extractor = await getPipeline(model);
96
130
  const out: number[][] = [];
97
131
  for (let i = 0; i < texts.length; i += EMBEDDING_BATCH_SIZE) {
@@ -114,9 +148,13 @@ export async function embed(
114
148
  return out;
115
149
  }
116
150
 
117
- /** Embed a single text — convenience wrapper for query-time embedding. */
151
+ /**
152
+ * Embed a single text — convenience wrapper for query-time embedding. Always
153
+ * runs in-process (`directOnly: true`) so search latency isn't paying the IPC
154
+ * round-trip through the worker pool for one vector.
155
+ */
118
156
  export async function embedSingle(text: string, model: string = EMBEDDING_MODEL): Promise<number[]> {
119
- const all = await embed([text], model);
157
+ const all = await embed([text], model, { directOnly: true });
120
158
  const vec = all[0];
121
159
  if (!vec) {
122
160
  throw new HelpfulError({
@@ -1,4 +1,6 @@
1
1
  import { z } from "zod";
2
+ import { resolveEmbeddingWorkers } from "../context.ts";
3
+ import { withEmbedderPool } from "../ingest/embedder-pool.ts";
2
4
  import {
3
5
  countResolvedEntries,
4
6
  type IngestCallbacks,
@@ -105,101 +107,107 @@ Pass \`logical_path\` to override. For a multi-source / directory / glob walk it
105
107
  return `${lines.join("\n")}\n${parts.join(", ")}`;
106
108
  },
107
109
  handler: async (input, ctx) => {
108
- const { sources, ...rest } = input;
109
- const followSymlinks = rest.follow_symlinks ?? true;
110
+ // Spin up an ephemeral embedder pool for the whole `add` command —
111
+ // `withEmbedderPool` handles the workers=1 short-circuit and disposes
112
+ // the children when the closure returns (see embedder-pool.ts).
113
+ const workers = resolveEmbeddingWorkers(ctx.config.embedding.workers);
114
+ return withEmbedderPool(workers, ctx.config.embedding_model, async () => {
115
+ const { sources, ...rest } = input;
116
+ const followSymlinks = rest.follow_symlinks ?? true;
110
117
 
111
- // Phase 1: resolve every source upfront so the shared progress bar
112
- // knows its total. A resolve failure (bad path, glob with no base) is
113
- // captured per-source so one bad arg doesn't abort the whole batch.
114
- type ResolveOutcome = { source: string; resolved: ResolvedSource } | { source: string; error: Error };
115
- const outcomes: ResolveOutcome[] = [];
116
- for (const source of sources) {
117
- try {
118
- const resolved = await resolveSource(source, {
119
- include: rest.include,
120
- exclude: rest.exclude,
121
- followSymlinks,
122
- });
123
- outcomes.push({ source, resolved });
124
- } catch (err) {
125
- outcomes.push({ source, error: err instanceof Error ? err : new Error(String(err)) });
118
+ // Phase 1: resolve every source upfront so the shared progress bar
119
+ // knows its total. A resolve failure (bad path, glob with no base) is
120
+ // captured per-source so one bad arg doesn't abort the whole batch.
121
+ type ResolveOutcome = { source: string; resolved: ResolvedSource } | { source: string; error: Error };
122
+ const outcomes: ResolveOutcome[] = [];
123
+ for (const source of sources) {
124
+ try {
125
+ const resolved = await resolveSource(source, {
126
+ include: rest.include,
127
+ exclude: rest.exclude,
128
+ followSymlinks,
129
+ });
130
+ outcomes.push({ source, resolved });
131
+ } catch (err) {
132
+ outcomes.push({ source, error: err instanceof Error ? err : new Error(String(err)) });
133
+ }
126
134
  }
127
- }
128
135
 
129
- const total = outcomes.reduce((n, o) => ("error" in o ? n + 1 : n + countResolvedEntries(o.resolved)), 0);
136
+ const total = outcomes.reduce((n, o) => ("error" in o ? n + 1 : n + countResolvedEntries(o.resolved)), 0);
130
137
 
131
- const aggregated: IngestResult = {
132
- ingested: [],
133
- total: 0,
134
- ok: 0,
135
- unchanged: 0,
136
- failed: 0,
137
- };
138
+ const aggregated: IngestResult = {
139
+ ingested: [],
140
+ total: 0,
141
+ ok: 0,
142
+ unchanged: 0,
143
+ failed: 0,
144
+ };
138
145
 
139
- ctx.progress.start(total, "ingest");
140
- const callbacks: IngestCallbacks = {
141
- onEntryStart: (label) => ctx.progress.tick(label),
142
- onEntryComplete: (entry) => ctx.progress.entry(formatEntryLine(entry)),
143
- onEntryProgress: (_label, sublabel) => ctx.progress.update(sublabel),
144
- };
146
+ ctx.progress.start(total, "ingest");
147
+ const callbacks: IngestCallbacks = {
148
+ onEntryStart: (label) => ctx.progress.tick(label),
149
+ onEntryComplete: (entry) => ctx.progress.entry(formatEntryLine(entry)),
150
+ onEntryProgress: (_label, sublabel) => ctx.progress.update(sublabel),
151
+ };
145
152
 
146
- for (const outcome of outcomes) {
147
- if ("error" in outcome) {
148
- const failed: IngestEntryResult = {
149
- source_path: outcome.source,
150
- logical_path: outcome.source,
151
- version_id: null,
152
- status: "failed",
153
- error: outcome.error.message,
154
- mime_type: null,
155
- size_bytes: 0,
156
- fetcher: "local",
157
- source_sha256: "",
158
- };
159
- callbacks.onEntryStart?.(outcome.source);
160
- callbacks.onEntryComplete?.(failed);
161
- aggregated.ingested.push(failed);
162
- aggregated.total += 1;
163
- aggregated.failed += 1;
164
- continue;
165
- }
153
+ for (const outcome of outcomes) {
154
+ if ("error" in outcome) {
155
+ const failed: IngestEntryResult = {
156
+ source_path: outcome.source,
157
+ logical_path: outcome.source,
158
+ version_id: null,
159
+ status: "failed",
160
+ error: outcome.error.message,
161
+ mime_type: null,
162
+ size_bytes: 0,
163
+ fetcher: "local",
164
+ source_sha256: "",
165
+ };
166
+ callbacks.onEntryStart?.(outcome.source);
167
+ callbacks.onEntryComplete?.(failed);
168
+ aggregated.ingested.push(failed);
169
+ aggregated.total += 1;
170
+ aggregated.failed += 1;
171
+ continue;
172
+ }
166
173
 
167
- try {
168
- const r = await ingestResolved(outcome.resolved, { ...rest, source: outcome.source }, ctx, callbacks);
169
- aggregated.ingested.push(...r.ingested);
170
- aggregated.total += r.total;
171
- aggregated.ok += r.ok;
172
- aggregated.unchanged += r.unchanged;
173
- aggregated.failed += r.failed;
174
- } catch (err) {
175
- const message = err instanceof Error ? err.message : String(err);
176
- const failed: IngestEntryResult = {
177
- source_path: outcome.source,
178
- logical_path: outcome.source,
179
- version_id: null,
180
- status: "failed",
181
- error: message,
182
- mime_type: null,
183
- size_bytes: 0,
184
- fetcher: "local",
185
- source_sha256: "",
186
- };
187
- callbacks.onEntryStart?.(outcome.source);
188
- callbacks.onEntryComplete?.(failed);
189
- aggregated.ingested.push(failed);
190
- aggregated.total += 1;
191
- aggregated.failed += 1;
192
- } finally {
193
- // Release the DB lock between sources so other consumers (a
194
- // concurrent CLI call, the daemon, or a separate MCP server)
195
- // can wedge in. The next source's first DB call reopens.
196
- await ctx.db.release();
174
+ try {
175
+ const r = await ingestResolved(outcome.resolved, { ...rest, source: outcome.source }, ctx, callbacks);
176
+ aggregated.ingested.push(...r.ingested);
177
+ aggregated.total += r.total;
178
+ aggregated.ok += r.ok;
179
+ aggregated.unchanged += r.unchanged;
180
+ aggregated.failed += r.failed;
181
+ } catch (err) {
182
+ const message = err instanceof Error ? err.message : String(err);
183
+ const failed: IngestEntryResult = {
184
+ source_path: outcome.source,
185
+ logical_path: outcome.source,
186
+ version_id: null,
187
+ status: "failed",
188
+ error: message,
189
+ mime_type: null,
190
+ size_bytes: 0,
191
+ fetcher: "local",
192
+ source_sha256: "",
193
+ };
194
+ callbacks.onEntryStart?.(outcome.source);
195
+ callbacks.onEntryComplete?.(failed);
196
+ aggregated.ingested.push(failed);
197
+ aggregated.total += 1;
198
+ aggregated.failed += 1;
199
+ } finally {
200
+ // Release the DB lock between sources so other consumers (a
201
+ // concurrent CLI call, the daemon, or a separate MCP server)
202
+ // can wedge in. The next source's first DB call reopens.
203
+ await ctx.db.release();
204
+ }
197
205
  }
198
- }
199
206
 
200
- const summary = formatSummary(aggregated);
201
- ctx.progress.done(summary);
202
- return aggregated;
207
+ const summary = formatSummary(aggregated);
208
+ ctx.progress.done(summary);
209
+ return aggregated;
210
+ });
203
211
  },
204
212
  });
205
213
 
@@ -8,6 +8,7 @@ import { readOperation } from "./read.ts";
8
8
  import { refreshOperation } from "./refresh.ts";
9
9
  import { removeOperation } from "./remove.ts";
10
10
  import { searchOperation } from "./search.ts";
11
+ import { statsOperation } from "./stats.ts";
11
12
  import { treeOperation } from "./tree.ts";
12
13
  import type { Operation } from "./types.ts";
13
14
  import { versionsOperation } from "./versions.ts";
@@ -28,6 +29,7 @@ export const OPERATIONS: Operation<any, any>[] = [
28
29
  readOperation,
29
30
  searchOperation,
30
31
  infoOperation,
32
+ statsOperation,
31
33
  versionsOperation,
32
34
  diffOperation,
33
35
  writeOperation,
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
+ import { resolveEmbeddingWorkers } from "../context.ts";
2
3
  import { listDueRefreshes } from "../db/files.ts";
4
+ import { withEmbedderPool } from "../ingest/embedder-pool.ts";
3
5
  import { colors } from "../output/formatter.ts";
4
6
  import { refreshOne } from "../refresh/runner.ts";
5
7
  import { defineOperation } from "./types.ts";
@@ -47,26 +49,32 @@ export const refreshOperation = defineOperation({
47
49
  return `${lines.join("\n")}\n${parts.join(", ")}`;
48
50
  },
49
51
  handler: async (input, ctx) => {
50
- const targets = input.logical_path
51
- ? [input.logical_path]
52
- : (await listDueRefreshes(ctx.db)).map((r) => r.logical_path);
53
- const out: Array<{
54
- logical_path: string;
55
- status: "ok" | "unchanged" | "failed";
56
- new_version_id?: string;
57
- error?: string;
58
- }> = [];
59
- ctx.progress.start(targets.length, "refresh");
60
- for (const path of targets) {
61
- ctx.progress.tick(path);
62
- try {
63
- const r = await refreshOne(ctx, path, input.force, (sublabel) => ctx.progress.update(sublabel));
64
- out.push(r);
65
- } catch (err) {
66
- out.push({ logical_path: path, status: "failed", error: err instanceof Error ? err.message : String(err) });
52
+ // Per-command embedder pool: workers come up at the start of the
53
+ // refresh sweep and are killed before we return, so a manual
54
+ // `membot refresh` doesn't leave subprocesses around.
55
+ const workers = resolveEmbeddingWorkers(ctx.config.embedding.workers);
56
+ return withEmbedderPool(workers, ctx.config.embedding_model, async () => {
57
+ const targets = input.logical_path
58
+ ? [input.logical_path]
59
+ : (await listDueRefreshes(ctx.db)).map((r) => r.logical_path);
60
+ const out: Array<{
61
+ logical_path: string;
62
+ status: "ok" | "unchanged" | "failed";
63
+ new_version_id?: string;
64
+ error?: string;
65
+ }> = [];
66
+ ctx.progress.start(targets.length, "refresh");
67
+ for (const path of targets) {
68
+ ctx.progress.tick(path);
69
+ try {
70
+ const r = await refreshOne(ctx, path, input.force, (sublabel) => ctx.progress.update(sublabel));
71
+ out.push(r);
72
+ } catch (err) {
73
+ out.push({ logical_path: path, status: "failed", error: err instanceof Error ? err.message : String(err) });
74
+ }
67
75
  }
68
- }
69
- ctx.progress.done(`refresh: ${out.filter((r) => r.status === "ok").length}/${out.length} updated`);
70
- return { processed: out, count: out.length };
76
+ ctx.progress.done(`refresh: ${out.filter((r) => r.status === "ok").length}/${out.length} updated`);
77
+ return { processed: out, count: out.length };
78
+ });
71
79
  },
72
80
  });