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.
- package/.claude/skills/membot.md +3 -0
- package/.cursor/rules/membot.mdc +3 -0
- package/README.md +4 -0
- package/package.json +1 -1
- package/src/cli.ts +11 -0
- package/src/config/schemas.ts +14 -0
- package/src/constants.ts +8 -0
- package/src/context.ts +24 -0
- package/src/ingest/embed-worker.ts +74 -0
- package/src/ingest/embedder-pool.ts +391 -0
- package/src/ingest/embedder.ts +40 -2
- package/src/operations/add.ts +94 -86
- package/src/operations/index.ts +2 -0
- package/src/operations/refresh.ts +28 -20
- package/src/operations/stats.ts +342 -0
- package/src/operations/write.ts +48 -40
- package/src/refresh/scheduler.ts +22 -13
package/src/ingest/embedder.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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({
|
package/src/operations/add.ts
CHANGED
|
@@ -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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
136
|
+
const total = outcomes.reduce((n, o) => ("error" in o ? n + 1 : n + countResolvedEntries(o.resolved)), 0);
|
|
130
137
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
+
const aggregated: IngestResult = {
|
|
139
|
+
ingested: [],
|
|
140
|
+
total: 0,
|
|
141
|
+
ok: 0,
|
|
142
|
+
unchanged: 0,
|
|
143
|
+
failed: 0,
|
|
144
|
+
};
|
|
138
145
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
207
|
+
const summary = formatSummary(aggregated);
|
|
208
|
+
ctx.progress.done(summary);
|
|
209
|
+
return aggregated;
|
|
210
|
+
});
|
|
203
211
|
},
|
|
204
212
|
});
|
|
205
213
|
|
package/src/operations/index.ts
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
});
|