@nomadamas/mailcrawl 0.1.5 → 0.1.6

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
@@ -20,6 +20,7 @@ mailcrawl embed --json
20
20
  mailcrawl search --mode hybrid --json "계약 갱신 조건"
21
21
  mailcrawl status --json
22
22
  mailcrawl doctor --json
23
+ mailcrawl repair --all --json
23
24
  ```
24
25
 
25
26
  The CLI owns email synchronization and indexes. Consumers such as AutoRAG,
@@ -56,6 +57,21 @@ Chinese helper builds, environment variables, smoke tests, and license
56
57
  requirements, follow [`docs/multilingual-installation.md`](docs/multilingual-installation.md)
57
58
  before using multilingual indexing or search.
58
59
 
60
+ ## Shared loopback embedding provider
61
+
62
+ The local in-process model is the default. Opt into a local HTTP runtime with
63
+ `--provider loopback-http`, `--embed-url`, `--embed-model`, and `--embed-dim`
64
+ on `index`, `repair --semantic`, or semantic search. Only HTTP URLs for
65
+ `127.0.0.1`, `localhost`, or `::1` are accepted. Query/passage prefixes and
66
+ timeout are optional and are included in the provider identity. Any provider
67
+ setting change rebuilds vectors, while a failed rebuild preserves the prior
68
+ `CURRENT` generation.
69
+
70
+ The equivalent environment contract is
71
+ `MAILCRAWL_EMBEDDER_PROVIDER`, `MAILCRAWL_EMBED_URL`,
72
+ `MAILCRAWL_EMBED_MODEL`, `MAILCRAWL_EMBED_DIM`, `MAILCRAWL_QUERY_PREFIX`,
73
+ `MAILCRAWL_PASSAGE_PREFIX`, and `MAILCRAWL_EMBED_TIMEOUT`.
74
+
59
75
  ## Releasing
60
76
 
61
77
  GitHub Release `vX.Y.Z` (must match `package.json`) publishes `@nomadamas/mailcrawl` to npm with OIDC trusted publishing. No `NPM_TOKEN` is stored in GitHub.
package/dist/archive.d.ts CHANGED
@@ -1,14 +1,25 @@
1
1
  import Database from "better-sqlite3";
2
- import type { ClassificationPolicy, Chunk, MailMessage, NormalizedMessage, SearchFilters, SearchHit, SyncReport } from "./types.js";
2
+ import type { ClassificationPolicy, Chunk, LoopbackHttpConfig, MailMessage, NormalizedMessage, SearchFilters, SearchHit, SyncReport } from "./types.js";
3
3
  export declare class Archive {
4
4
  readonly db: Database.Database;
5
5
  private embedder?;
6
6
  private lexical?;
7
7
  private operationTail;
8
8
  private lexicalRebuildRequired;
9
- constructor(path?: string);
9
+ private readonly embedderConfig?;
10
+ constructor(path?: string, embedderConfig?: LoopbackHttpConfig);
10
11
  close(): void;
11
12
  sync(messages: MailMessage[], policy?: ClassificationPolicy): Promise<SyncReport>;
13
+ status(): {
14
+ messageCount: number;
15
+ chunkCount: number;
16
+ embeddingBacklog: number;
17
+ archiveRevision: string;
18
+ fts: {
19
+ status: "healthy" | "stale";
20
+ rows: number;
21
+ };
22
+ };
12
23
  private syncUnlocked;
13
24
  searchBm25(query: string, filters?: SearchFilters, limit?: number): Promise<SearchHit[]>;
14
25
  indexSemantic(): Promise<{
@@ -24,6 +35,7 @@ export declare class Archive {
24
35
  }>;
25
36
  private indexSemanticGenerationUnlocked;
26
37
  private cleanupSemanticGenerations;
38
+ private generationTimestamp;
27
39
  semanticGeneration(root: string): {
28
40
  generation: string;
29
41
  archiveRevision: string;
package/dist/archive.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import Database from "better-sqlite3";
2
- import { createHash } from "node:crypto";
2
+ import { createHash, randomBytes } from "node:crypto";
3
3
  import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { buildChunks } from "./chunk.js";
@@ -8,14 +8,16 @@ import { scopedId, snippet } from "./util.js";
8
8
  import { createEmbedder, embeddingModelName } from "./embedding.js";
9
9
  import { createLexicalAnalyzers, languagesForText, lexicalFields, LEXICAL_ANALYZER_VERSION } from "./lexical.js";
10
10
  const RETAINED_SEMANTIC_GENERATIONS = 2;
11
- const SEMANTIC_GENERATION_NAME = /^gen-[0-9a-f]{16}-[0-9]+$/;
11
+ const SEMANTIC_GENERATION_NAME = /^gen-[0-9a-f]{16}-[0-9]+(?:-[0-9a-f]+)?$/;
12
12
  export class Archive {
13
13
  db;
14
14
  embedder;
15
15
  lexical;
16
16
  operationTail = Promise.resolve();
17
17
  lexicalRebuildRequired;
18
- constructor(path = ":memory:") {
18
+ embedderConfig;
19
+ constructor(path = ":memory:", embedderConfig) {
20
+ this.embedderConfig = embedderConfig;
19
21
  this.db = new Database(path);
20
22
  this.db.pragma("journal_mode = WAL");
21
23
  this.db.pragma("foreign_keys = ON");
@@ -38,6 +40,19 @@ export class Archive {
38
40
  async sync(messages, policy = {}) {
39
41
  return this.runExclusive(() => this.syncUnlocked(messages, policy));
40
42
  }
43
+ status() {
44
+ const count = (table) => Number(this.db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get().count);
45
+ const ftsRows = Number(this.db.prepare("SELECT COUNT(*) AS count FROM chunks_fts").get().count);
46
+ const metadata = this.db.prepare("SELECT version FROM lexical_index_meta WHERE id = 1").get();
47
+ const chunkCount = count("chunks");
48
+ return {
49
+ messageCount: count("messages"),
50
+ chunkCount,
51
+ embeddingBacklog: Number(this.db.prepare("SELECT COUNT(*) AS count FROM embedding_queue WHERE state = 'pending'").get().count),
52
+ archiveRevision: this.revision(),
53
+ fts: { status: metadata?.version === LEXICAL_ANALYZER_VERSION && ftsRows === chunkCount ? "healthy" : "stale", rows: ftsRows },
54
+ };
55
+ }
41
56
  async syncUnlocked(messages, policy = {}) {
42
57
  for (const message of messages) {
43
58
  if (typeof message.providerKey !== "string" || !message.providerKey.trim())
@@ -160,7 +175,7 @@ export class Archive {
160
175
  const transaction = this.db.transaction(() => {
161
176
  for (const row of rows) {
162
177
  const old = this.db.prepare("SELECT content_hash, model FROM semantic_vectors WHERE chunk_id = ?").get(row.chunk_id);
163
- if (old?.content_hash === row.content_hash && old.model === embeddingModelName()) {
178
+ if (old?.content_hash === row.content_hash && old.model === embeddingModelName(this.embedderConfig)) {
164
179
  completeQueueRow.run(row.chunk_id);
165
180
  reused++;
166
181
  continue;
@@ -175,7 +190,7 @@ export class Archive {
175
190
  const vectors = await embedder.embedDocuments(pending.map((row) => row.text));
176
191
  const write = this.db.transaction(() => {
177
192
  for (const [index, row] of pending.entries()) {
178
- upsert.run(row.chunk_id, row.content_hash, embeddingModelName(), JSON.stringify(vectors[index]));
193
+ upsert.run(row.chunk_id, row.content_hash, embeddingModelName(this.embedderConfig), JSON.stringify(vectors[index]));
179
194
  completeQueueRow.run(row.chunk_id);
180
195
  embedded++;
181
196
  }
@@ -192,20 +207,22 @@ export class Archive {
192
207
  const previousVectors = this.db.prepare("SELECT chunk_id, content_hash, model, vector FROM semantic_vectors ORDER BY chunk_id").all();
193
208
  const previousQueue = this.db.prepare("SELECT chunk_id, content_hash, state, attempts FROM embedding_queue ORDER BY chunk_id").all();
194
209
  mkdirSync(generationRoot, { recursive: true });
195
- const generation = `gen-${this.revision().slice(0, 16)}-${Date.now()}`;
210
+ const generation = `gen-${this.revision().slice(0, 16)}-${Date.now()}-${randomBytes(6).toString("hex")}`;
196
211
  const staging = join(generationRoot, `.${generation}.staging`);
197
212
  const publishedGeneration = join(generationRoot, generation);
213
+ const pointer = join(root, `.CURRENT.${process.pid}`);
198
214
  mkdirSync(staging);
215
+ let published = false;
199
216
  try {
200
217
  const report = await this.indexSemanticUnlocked();
201
218
  const vectors = this.db.prepare(`SELECT v.chunk_id, v.content_hash, v.vector
202
219
  FROM semantic_vectors v JOIN chunks c ON c.chunk_id = v.chunk_id
203
220
  ORDER BY v.chunk_id`).all();
204
221
  writeFileSync(join(staging, "manifest.json"), JSON.stringify({
205
- archiveRevision: this.revision(), vectors, model: embeddingModelName(),
222
+ archiveRevision: this.revision(), vectors, model: embeddingModelName(this.embedderConfig),
206
223
  }));
207
224
  renameSync(staging, publishedGeneration);
208
- const pointer = join(root, `.CURRENT.${process.pid}`);
225
+ published = true;
209
226
  writeFileSync(pointer, `${generation}\n`);
210
227
  renameSync(pointer, currentPath);
211
228
  this.cleanupSemanticGenerations(generationRoot, generation);
@@ -213,7 +230,9 @@ export class Archive {
213
230
  }
214
231
  catch (error) {
215
232
  rmSync(staging, { recursive: true, force: true });
216
- rmSync(publishedGeneration, { recursive: true, force: true });
233
+ rmSync(pointer, { force: true });
234
+ if (!published)
235
+ rmSync(publishedGeneration, { recursive: true, force: true });
217
236
  const restore = this.db.transaction(() => {
218
237
  this.db.exec("DELETE FROM semantic_vectors; DELETE FROM embedding_queue;");
219
238
  const restoreVector = this.db.prepare("INSERT INTO semantic_vectors(chunk_id, content_hash, model, vector) VALUES (?, ?, ?, ?)");
@@ -232,8 +251,8 @@ export class Archive {
232
251
  .filter((entry) => entry.isDirectory() && SEMANTIC_GENERATION_NAME.test(entry.name))
233
252
  .map((entry) => entry.name)
234
253
  .sort((left, right) => {
235
- const leftTimestamp = Number(left.slice(left.lastIndexOf("-") + 1));
236
- const rightTimestamp = Number(right.slice(right.lastIndexOf("-") + 1));
254
+ const leftTimestamp = this.generationTimestamp(left);
255
+ const rightTimestamp = this.generationTimestamp(right);
237
256
  return rightTimestamp - leftTimestamp || right.localeCompare(left);
238
257
  });
239
258
  const retained = new Set(generations.slice(0, RETAINED_SEMANTIC_GENERATIONS));
@@ -243,8 +262,14 @@ export class Archive {
243
262
  rmSync(join(generationRoot, generation), { recursive: true, force: true });
244
263
  }
245
264
  }
265
+ generationTimestamp(name) {
266
+ const match = /^gen-[0-9a-f]{16}-([0-9]+)(?:-[0-9a-f]+)?$/.exec(name);
267
+ return match ? Number(match[1]) : 0;
268
+ }
246
269
  semanticGeneration(root) {
247
270
  const generation = readFileSync(join(root, "CURRENT"), "utf8").trim();
271
+ if (!SEMANTIC_GENERATION_NAME.test(generation))
272
+ throw new Error("invalid semantic generation pointer");
248
273
  const manifest = JSON.parse(readFileSync(join(root, "generations", generation, "manifest.json"), "utf8"));
249
274
  return { generation, archiveRevision: manifest.archiveRevision, vectorCount: manifest.vectors.length };
250
275
  }
@@ -334,8 +359,6 @@ export class Archive {
334
359
  const rows = this.db.prepare("SELECT rowid, chunk_id, text, message_id FROM chunks").all();
335
360
  const rebuild = this.db.transaction(() => {
336
361
  this.db.exec("DELETE FROM chunks_fts");
337
- for (const language of lexicalFields())
338
- this.db.exec(`DELETE FROM chunks_fts_${language}`);
339
362
  for (const row of rows) {
340
363
  const message = this.db.prepare("SELECT * FROM messages WHERE message_id = ?").get(row.message_id);
341
364
  this.db.prepare(`INSERT INTO chunks_fts(rowid, subject, from_address, to_addresses, thread_subject,
@@ -344,8 +367,6 @@ export class Archive {
344
367
  }
345
368
  });
346
369
  rebuild();
347
- this.db.prepare("DELETE FROM lexical_index_meta WHERE id = 1").run();
348
- this.lexicalRebuildRequired = true;
349
370
  return { rows: rows.length, status: "repaired" };
350
371
  }
351
372
  upsertMessage(message) {
@@ -428,7 +449,7 @@ export class Archive {
428
449
  }
429
450
  }
430
451
  async getEmbedder() {
431
- this.embedder ??= await createEmbedder();
452
+ this.embedder ??= await createEmbedder(this.embedderConfig);
432
453
  return this.embedder;
433
454
  }
434
455
  searchLexicalTable(table, query, filters, limit) {
package/dist/cli/index.js CHANGED
@@ -2,9 +2,11 @@
2
2
  import { Command } from "commander";
3
3
  import { mkdir } from "node:fs/promises";
4
4
  import { existsSync } from "node:fs";
5
+ import { join } from "node:path";
5
6
  import { Archive } from "../archive.js";
6
7
  import { FixtureSource, HimalayaSource } from "../source.js";
7
8
  import { redactDiagnostic } from "../redact.js";
9
+ import { embeddingModelName, loopbackConfigFromEnvironment } from "../embedding.js";
8
10
  const program = new Command();
9
11
  program.name("mailcrawl").description("Local privacy-first email indexing CLI");
10
12
  program.option("--data-dir <path>", "archive directory", process.env.MAILCRAWL_DATA_DIR || ".mailcrawl");
@@ -22,12 +24,18 @@ program
22
24
  .option("--json")
23
25
  .action(async (options, command) => {
24
26
  const dataDir = command.parent.opts().dataDir;
27
+ if (options.source !== "fixture" && options.source !== "himalaya")
28
+ throw new Error(`unsupported source: ${options.source}`);
29
+ if (options.source === "fixture" && !options.fixture)
30
+ throw new Error("--fixture is required for fixture source");
31
+ if (options.source === "himalaya" && !options.account)
32
+ throw new Error("--account is required for himalaya source");
25
33
  await mkdir(dataDir, { recursive: true });
26
- const archive = new Archive(`${dataDir}/archive.sqlite`);
34
+ const archive = new Archive(join(dataDir, "archive.sqlite"));
27
35
  try {
28
36
  const source = options.source === "fixture"
29
- ? new FixtureSource(options.fixture)
30
- : new HimalayaSource(options.account, options.mailbox, options.backend, Number(options.pageSize), options.himalayaConfig);
37
+ ? fixtureSource(options)
38
+ : himalayaSource(options);
31
39
  const excludedCategories = (options.excludeCategory.length ? options.excludeCategory : ["spam", "promotions"])
32
40
  .filter((category) => !options.includeCategory.includes(category));
33
41
  output(await archive.sync(await source.list(), { excludedCategories }), options.json);
@@ -38,12 +46,21 @@ program
38
46
  });
39
47
  program
40
48
  .command("index")
49
+ .alias("embed")
50
+ .option("--provider <provider>", "embedding provider", "local")
51
+ .option("--embed-url <url>")
52
+ .option("--embed-model <model>")
53
+ .option("--embed-dim <n>")
54
+ .option("--query-prefix <prefix>")
55
+ .option("--passage-prefix <prefix>")
56
+ .option("--embed-timeout <ms>")
41
57
  .option("--json")
42
58
  .action(async (options, command) => {
43
59
  const dataDir = command.parent.opts().dataDir;
44
- const archive = new Archive(`${dataDir}/archive.sqlite`);
60
+ const config = embedderConfig(options);
61
+ const archive = new Archive(join(dataDir, "archive.sqlite"), config);
45
62
  try {
46
- output({ ...await archive.indexSemanticGeneration(`${dataDir}/semantic`), embedder: "onnx-community/embeddinggemma-300m-ONNX" }, options.json);
63
+ output({ ...await archive.indexSemanticGeneration(join(dataDir, "semantic")), embedder: embeddingModelName(config) }, options.json);
47
64
  }
48
65
  finally {
49
66
  archive.close();
@@ -61,12 +78,19 @@ for (const mode of ["bm25", "keyword", "semantic", "hybrid"]) {
61
78
  .option("--after <date>")
62
79
  .option("--before <date>")
63
80
  .option("--limit <n>", "result limit", "10")
81
+ .option("--provider <provider>", "embedding provider", "local")
82
+ .option("--embed-url <url>")
83
+ .option("--embed-model <model>")
84
+ .option("--embed-dim <n>")
85
+ .option("--query-prefix <prefix>")
86
+ .option("--passage-prefix <prefix>")
87
+ .option("--embed-timeout <ms>")
64
88
  .option("--json")
65
89
  .action(async (query, options, command) => {
66
90
  const dataDir = command.parent.opts().dataDir;
67
- const archive = new Archive(`${dataDir}/archive.sqlite`);
91
+ const archive = new Archive(join(dataDir, "archive.sqlite"), embedderConfig(options));
68
92
  try {
69
- const result = mode === "bm25"
93
+ const result = mode === "bm25" || mode === "keyword"
70
94
  ? await archive.searchBm25(query, filters(options), Number(options.limit))
71
95
  : mode === "hybrid"
72
96
  ? await archive.searchHybrid(query, filters(options), Number(options.limit))
@@ -81,7 +105,7 @@ for (const mode of ["bm25", "keyword", "semantic", "hybrid"]) {
81
105
  program
82
106
  .command("search")
83
107
  .argument("<query>")
84
- .option("--mode <mode>", "keyword, bm25, semantic, hybrid", "bm25")
108
+ .option("--mode <mode>", "fts, keyword, bm25, semantic, hybrid", "bm25")
85
109
  .option("--account <id>")
86
110
  .option("--mailbox <name>")
87
111
  .option("--from <address>")
@@ -90,13 +114,20 @@ program
90
114
  .option("--after <date>")
91
115
  .option("--before <date>")
92
116
  .option("--limit <n>", "result limit", "10")
117
+ .option("--provider <provider>", "embedding provider", "local")
118
+ .option("--embed-url <url>")
119
+ .option("--embed-model <model>")
120
+ .option("--embed-dim <n>")
121
+ .option("--query-prefix <prefix>")
122
+ .option("--passage-prefix <prefix>")
123
+ .option("--embed-timeout <ms>")
93
124
  .option("--json")
94
125
  .action(async (query, options, command) => {
95
- const archive = new Archive(`${command.parent.opts().dataDir}/archive.sqlite`);
126
+ const archive = new Archive(join(command.parent.opts().dataDir, "archive.sqlite"), embedderConfig(options));
96
127
  try {
97
128
  const filter = filters(options);
98
129
  const limit = Number(options.limit);
99
- const result = options.mode === "bm25" ? await archive.searchBm25(query, filter, limit)
130
+ const result = options.mode === "bm25" || options.mode === "fts" || options.mode === "keyword" ? await archive.searchBm25(query, filter, limit)
100
131
  : options.mode === "semantic" ? await archive.searchSemantic(query, filter, limit)
101
132
  : options.mode === "hybrid" ? await archive.searchHybrid(query, filter, limit)
102
133
  : (() => { throw new Error(`unsupported search mode: ${options.mode}`); })();
@@ -111,7 +142,7 @@ program
111
142
  .command("get <messageId>")
112
143
  .option("--json")
113
144
  .action(async (messageId, options, command) => {
114
- const archive = new Archive(`${command.parent.parent.opts().dataDir}/archive.sqlite`);
145
+ const archive = new Archive(join(command.parent.parent.opts().dataDir, "archive.sqlite"));
115
146
  try {
116
147
  output(archive.getMessage(messageId) ?? null, options.json);
117
148
  }
@@ -124,7 +155,7 @@ program
124
155
  .argument("<chunkId>")
125
156
  .option("--json")
126
157
  .action(async (chunkId, options, command) => {
127
- const archive = new Archive(`${command.parent.opts().dataDir}/archive.sqlite`);
158
+ const archive = new Archive(join(command.parent.opts().dataDir, "archive.sqlite"));
128
159
  try {
129
160
  output(archive.getChunkContext(chunkId), options.json);
130
161
  }
@@ -141,7 +172,7 @@ program
141
172
  .option("--before <date>")
142
173
  .option("--json")
143
174
  .action(async (threadId, options, command) => {
144
- const archive = new Archive(`${command.parent.parent.opts().dataDir}/archive.sqlite`);
175
+ const archive = new Archive(join(command.parent.parent.opts().dataDir, "archive.sqlite"));
145
176
  try {
146
177
  output(archive.getThread(threadId, filters(options)), options.json);
147
178
  }
@@ -155,7 +186,7 @@ program
155
186
  .option("--message <messageId>")
156
187
  .option("--json")
157
188
  .action(async (threadId, options, command) => {
158
- const archive = new Archive(`${command.parent.opts().dataDir}/archive.sqlite`);
189
+ const archive = new Archive(join(command.parent.opts().dataDir, "archive.sqlite"));
159
190
  try {
160
191
  output(archive.getThreadContext(threadId, options.message), options.json);
161
192
  }
@@ -168,21 +199,21 @@ program
168
199
  .option("--json")
169
200
  .action(async (options, command) => {
170
201
  const dataDir = command.parent.opts().dataDir;
171
- const archive = new Archive(`${dataDir}/archive.sqlite`);
202
+ const archive = new Archive(join(dataDir, "archive.sqlite"));
172
203
  try {
173
204
  let semantic = "missing";
174
205
  try {
175
- semantic = archive.semanticGeneration(`${dataDir}/semantic`);
206
+ semantic = semanticStatus(archive, archive.semanticGeneration(join(dataDir, "semantic")));
176
207
  }
177
208
  catch (error) {
178
- semantic = redactDiagnostic({ status: "stale", error: error instanceof Error ? error.message : String(error) });
209
+ semantic = redactDiagnostic({ status: semanticErrorStatus(error), error: error instanceof Error ? error.message : String(error) });
179
210
  }
180
211
  const semanticCommitted = typeof semantic === "object" && semantic !== null && "generation" in semantic;
181
212
  output({
182
213
  name: "mailcrawl",
183
- archive: `${dataDir}/archive.sqlite`,
184
- archivePresent: existsSync(`${dataDir}/archive.sqlite`),
185
- fts: "available",
214
+ archive: join(dataDir, "archive.sqlite"),
215
+ archivePresent: existsSync(join(dataDir, "archive.sqlite")),
216
+ fts: archive.status().fts,
186
217
  semantic,
187
218
  recommendation: semanticCommitted ? "semantic index is committed" : "run sync, then index before semantic search",
188
219
  }, options.json);
@@ -191,16 +222,65 @@ program
191
222
  archive.close();
192
223
  }
193
224
  });
225
+ program
226
+ .command("status")
227
+ .option("--json")
228
+ .action(async (options, command) => {
229
+ const dataDir = command.parent.opts().dataDir;
230
+ const archivePath = join(dataDir, "archive.sqlite");
231
+ if (!existsSync(archivePath)) {
232
+ output({ name: "mailcrawl", archive: archivePath, archivePresent: false, messageCount: 0, chunkCount: 0, embeddingBacklog: 0, fts: { status: "missing", rows: 0 }, semantic: { status: "missing" } }, options.json);
233
+ return;
234
+ }
235
+ const archive = new Archive(archivePath);
236
+ try {
237
+ let semantic = "missing";
238
+ try {
239
+ semantic = semanticStatus(archive, archive.semanticGeneration(join(dataDir, "semantic")));
240
+ }
241
+ catch (error) {
242
+ semantic = redactDiagnostic({ status: semanticErrorStatus(error), error: error instanceof Error ? error.message : String(error) });
243
+ }
244
+ output({ name: "mailcrawl", archive: archivePath, archivePresent: true, ...archive.status(), semantic }, options.json);
245
+ }
246
+ finally {
247
+ archive.close();
248
+ }
249
+ });
194
250
  program
195
251
  .command("repair")
196
252
  .option("--fts")
253
+ .option("--semantic")
254
+ .option("--all")
255
+ .option("--provider <provider>", "embedding provider", "local")
256
+ .option("--embed-url <url>")
257
+ .option("--embed-model <model>")
258
+ .option("--embed-dim <n>")
259
+ .option("--query-prefix <prefix>")
260
+ .option("--passage-prefix <prefix>")
261
+ .option("--embed-timeout <ms>")
197
262
  .option("--json")
198
263
  .action(async (options, command) => {
199
- const archive = new Archive(`${command.parent.opts().dataDir}/archive.sqlite`);
264
+ const dataDir = command.parent.opts().dataDir;
265
+ const archive = new Archive(join(dataDir, "archive.sqlite"));
200
266
  try {
201
- if (!options.fts)
202
- throw new Error("pass --fts");
203
- output(archive.repairFts(), options.json);
267
+ if (!options.fts && !options.semantic && !options.all)
268
+ throw new Error("pass --fts, --semantic, or --all");
269
+ const result = {};
270
+ if (options.semantic || options.all) {
271
+ const config = embedderConfig(options);
272
+ const semanticArchive = config ? new Archive(join(dataDir, "archive.sqlite"), config) : archive;
273
+ try {
274
+ result.semantic = await semanticArchive.indexSemanticGeneration(join(dataDir, "semantic"));
275
+ }
276
+ finally {
277
+ if (semanticArchive !== archive)
278
+ semanticArchive.close();
279
+ }
280
+ }
281
+ if (options.fts || options.all)
282
+ result.fts = archive.repairFts();
283
+ output(Object.keys(result).length === 1 ? Object.values(result)[0] : result, options.json);
204
284
  }
205
285
  finally {
206
286
  archive.close();
@@ -212,7 +292,7 @@ attachments
212
292
  .option("--message <messageId>")
213
293
  .option("--json")
214
294
  .action(async (options, command) => {
215
- const archive = new Archive(`${command.parent.parent.opts().dataDir}/archive.sqlite`);
295
+ const archive = new Archive(join(command.parent.parent.opts().dataDir, "archive.sqlite"));
216
296
  try {
217
297
  output(archive.listAttachments(options.message), options.json);
218
298
  }
@@ -222,12 +302,37 @@ attachments
222
302
  });
223
303
  program.parseAsync().catch((error) => {
224
304
  const message = error instanceof Error ? error.message : String(error);
225
- console.error(JSON.stringify({ error: message }));
305
+ console.error(JSON.stringify(redactDiagnostic({ error: message })));
226
306
  process.exitCode = 1;
227
307
  });
228
308
  function filters(options) {
229
309
  return { accountId: options.account, mailbox: options.mailbox, from: options.from, to: options.to, threadId: options.thread, after: options.after, before: options.before };
230
310
  }
311
+ function embedderConfig(options) {
312
+ if (options.provider === "local")
313
+ return loopbackConfigFromEnvironment();
314
+ if (options.provider !== "loopback-http")
315
+ throw new Error(`unsupported embedding provider: ${options.provider}`);
316
+ if (!options.embedUrl || !options.embedModel || !options.embedDim)
317
+ throw new Error("--embed-url, --embed-model, and --embed-dim are required for loopback-http");
318
+ return { provider: "loopback-http", url: options.embedUrl, model: options.embedModel, dimension: Number(options.embedDim), queryPrefix: options.queryPrefix, passagePrefix: options.passagePrefix, timeoutMs: options.embedTimeout ? Number(options.embedTimeout) : undefined };
319
+ }
320
+ function fixtureSource(options) {
321
+ if (!options.fixture)
322
+ throw new Error("--fixture is required for selected source");
323
+ return new FixtureSource(options.fixture);
324
+ }
325
+ function himalayaSource(options) {
326
+ if (!options.account)
327
+ throw new Error("--account is required for selected source");
328
+ return new HimalayaSource(options.account, options.mailbox, options.backend, Number(options.pageSize), options.himalayaConfig);
329
+ }
330
+ function semanticStatus(archive, semantic) {
331
+ return semantic.archiveRevision === archive.status().archiveRevision ? { ...semantic, status: "healthy" } : { ...semantic, status: "stale" };
332
+ }
333
+ function semanticErrorStatus(error) {
334
+ return error instanceof Error && "code" in error && error.code === "ENOENT" ? "missing" : "corrupt";
335
+ }
231
336
  function output(value, json) {
232
337
  if (json)
233
338
  console.log(JSON.stringify(value));
@@ -1,6 +1,8 @@
1
+ import type { LoopbackHttpConfig } from "./types.js";
1
2
  export interface Embedder {
2
3
  embedDocuments(texts: string[]): Promise<number[][]>;
3
4
  embedQuery(query: string): Promise<number[]>;
4
5
  }
5
- export declare function createEmbedder(): Promise<Embedder>;
6
- export declare function embeddingModelName(): string;
6
+ export declare function createEmbedder(config?: LoopbackHttpConfig): Promise<Embedder>;
7
+ export declare function embeddingModelName(config?: LoopbackHttpConfig): string;
8
+ export declare function loopbackConfigFromEnvironment(): LoopbackHttpConfig | undefined;
package/dist/embedding.js CHANGED
@@ -33,13 +33,76 @@ class TestEmbedder {
33
33
  return hashVector(query);
34
34
  }
35
35
  }
36
- export async function createEmbedder() {
36
+ class LoopbackHttpEmbedder {
37
+ config;
38
+ constructor(config) {
39
+ this.config = config;
40
+ const url = new URL(config.url);
41
+ const hostname = url.hostname.startsWith("[") ? url.hostname.slice(1, -1) : url.hostname;
42
+ if (url.protocol !== "http:" || !["127.0.0.1", "localhost", "::1"].includes(hostname)) {
43
+ throw new Error("loopback HTTP embedding URL is required");
44
+ }
45
+ if (!Number.isInteger(config.dimension) || config.dimension <= 0)
46
+ throw new Error("embedding dimension must be positive");
47
+ }
48
+ async embedDocuments(texts) {
49
+ return this.request(texts.map((text) => (this.config.passagePrefix ?? "") + text));
50
+ }
51
+ async embedQuery(query) {
52
+ return (await this.request([(this.config.queryPrefix ?? "") + query.trim()]))[0];
53
+ }
54
+ async request(texts) {
55
+ const controller = new AbortController();
56
+ const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs ?? 30_000);
57
+ try {
58
+ const response = await fetch(this.config.url, {
59
+ method: "POST",
60
+ headers: { "content-type": "application/json" },
61
+ body: JSON.stringify({ model: this.config.model, texts }),
62
+ signal: controller.signal,
63
+ });
64
+ if (!response.ok)
65
+ throw new Error(`embedding provider returned HTTP ${response.status}`);
66
+ const payload = await response.json();
67
+ if (!Array.isArray(payload.embeddings) || payload.embeddings.length !== texts.length)
68
+ throw new Error("embedding provider returned invalid embeddings");
69
+ const vectors = payload.embeddings;
70
+ if (vectors.some((vector) => !Array.isArray(vector) || vector.length !== this.config.dimension || vector.some((value) => typeof value !== "number"))) {
71
+ throw new Error("embedding provider returned invalid vector dimensions");
72
+ }
73
+ return vectors;
74
+ }
75
+ finally {
76
+ clearTimeout(timeout);
77
+ }
78
+ }
79
+ }
80
+ export async function createEmbedder(config) {
81
+ if (config?.provider === "loopback-http")
82
+ return new LoopbackHttpEmbedder(config);
37
83
  if (process.env.MAILCRAWL_EMBEDDER === "mock" || process.env.NODE_ENV === "test")
38
84
  return new TestEmbedder();
39
85
  return EmbeddingGemma.create();
40
86
  }
41
- export function embeddingModelName() {
42
- return EMBEDDING_MODEL;
87
+ export function embeddingModelName(config) {
88
+ return config
89
+ ? `loopback-http:${config.model}:${config.dimension}:${config.url}:${config.queryPrefix ?? ""}:${config.passagePrefix ?? ""}:${config.timeoutMs ?? 30_000}`
90
+ : EMBEDDING_MODEL;
91
+ }
92
+ export function loopbackConfigFromEnvironment() {
93
+ if (process.env.MAILCRAWL_EMBEDDER_PROVIDER !== "loopback-http")
94
+ return undefined;
95
+ const url = process.env.MAILCRAWL_EMBED_URL;
96
+ const model = process.env.MAILCRAWL_EMBED_MODEL;
97
+ const dimension = Number(process.env.MAILCRAWL_EMBED_DIM);
98
+ if (!url || !model || !Number.isInteger(dimension))
99
+ throw new Error("MAILCRAWL_EMBED_URL, MAILCRAWL_EMBED_MODEL, and MAILCRAWL_EMBED_DIM are required");
100
+ return {
101
+ provider: "loopback-http", url, model, dimension,
102
+ queryPrefix: process.env.MAILCRAWL_QUERY_PREFIX,
103
+ passagePrefix: process.env.MAILCRAWL_PASSAGE_PREFIX,
104
+ timeoutMs: process.env.MAILCRAWL_EMBED_TIMEOUT ? Number(process.env.MAILCRAWL_EMBED_TIMEOUT) : undefined,
105
+ };
43
106
  }
44
107
  function hashVector(text) {
45
108
  const vector = new Array(128).fill(0);
package/dist/redact.js CHANGED
@@ -2,6 +2,7 @@ export function redactDiagnostic(value) {
2
2
  if (typeof value === "string") {
3
3
  return value
4
4
  .replace(/([?&](?:token|password|secret|key)=)[^&\s]+/giu, "$1[REDACTED]")
5
+ .replace(/(?<![?&])\b(?:token|password|secret|key|credential)\s*[=:]\s*[^,\s]+/giu, "[REDACTED]")
5
6
  .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu, "[EMAIL]");
6
7
  }
7
8
  if (Array.isArray(value))
package/dist/source.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { readFile } from "node:fs/promises";
4
+ import { redactDiagnostic } from "./redact.js";
4
5
  const execFileAsync = promisify(execFile);
5
6
  export class FixtureSource {
6
7
  path;
@@ -30,7 +31,7 @@ export class HimalayaSource {
30
31
  if (this.backend)
31
32
  args.push("-b", this.backend);
32
33
  args.push("envelope", "list", "--mailbox", this.mailbox, "--page-size", String(this.pageSize), "--json");
33
- const { stdout } = await execFileAsync("himalaya", args, { maxBuffer: 16 * 1024 * 1024 });
34
+ const { stdout } = await runHimalaya(args, 16 * 1024 * 1024, "envelope list");
34
35
  const payload = JSON.parse(stdout);
35
36
  const envelopes = payload.envelopes ?? (Array.isArray(payload) ? payload : []);
36
37
  return Promise.all(envelopes.map(async (envelope) => {
@@ -60,11 +61,20 @@ export class HimalayaSource {
60
61
  if (this.backend)
61
62
  args.push("-b", this.backend);
62
63
  args.push("--json", "message", "read", id, "--raw");
63
- const { stdout } = await execFileAsync("himalaya", args, { maxBuffer: 32 * 1024 * 1024 });
64
+ const { stdout } = await runHimalaya(args, 32 * 1024 * 1024, "message read");
64
65
  const payload = JSON.parse(stdout);
65
66
  return payload.message ?? stdout;
66
67
  }
67
68
  }
69
+ async function runHimalaya(args, maxBuffer, operation) {
70
+ try {
71
+ return await execFileAsync("himalaya", args, { maxBuffer });
72
+ }
73
+ catch (error) {
74
+ const detail = error instanceof Error ? error.message : String(error);
75
+ throw new Error(`himalaya ${operation} failed: ${redactDiagnostic(detail)}`);
76
+ }
77
+ }
68
78
  function address(value) {
69
79
  if (typeof value === "string")
70
80
  return value;
package/dist/types.d.ts CHANGED
@@ -1,4 +1,14 @@
1
1
  export type SearchMode = "keyword" | "bm25" | "semantic" | "hybrid";
2
+ export type EmbedderProvider = "local" | "loopback-http";
3
+ export interface LoopbackHttpConfig {
4
+ provider: "loopback-http";
5
+ url: string;
6
+ model: string;
7
+ dimension: number;
8
+ queryPrefix?: string;
9
+ passagePrefix?: string;
10
+ timeoutMs?: number;
11
+ }
2
12
  export interface MailMessage {
3
13
  accountId: string;
4
14
  mailbox: string;
@@ -159,10 +159,10 @@ All commands support `--json` where machine-readable output is useful.
159
159
  mailcrawl doctor
160
160
  mailcrawl status
161
161
  mailcrawl sync
162
- mailcrawl embed [--limit N]
163
- mailcrawl search --mode fts|semantic|hybrid [--limit N] [--mailbox NAME] QUERY
162
+ mailcrawl embed
163
+ mailcrawl search --mode fts|bm25|keyword|semantic|hybrid [--limit N] [--mailbox NAME] QUERY
164
164
  mailcrawl message get MESSAGE_ID
165
- mailcrawl repair [--fts|--vectors|--all]
165
+ mailcrawl repair [--fts|--semantic|--all]
166
166
  ```
167
167
 
168
168
  Example sync response:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomadamas/mailcrawl",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Local Himalaya-backed incremental email indexing and hybrid search CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -48,6 +48,7 @@
48
48
  "@types/better-sqlite3": "^7.6.13",
49
49
  "@types/node": "^24.3.0",
50
50
  "tsx": "^4.20.5",
51
+ "typescript": "^5.9.3",
51
52
  "vitest": "^3.2.4"
52
53
  },
53
54
  "engines": {
@@ -71,7 +71,8 @@ mailcrawl sync --source fixture --fixture ./messages.json --json
71
71
 
72
72
  ## Safe read and maintenance commands
73
73
 
74
- Search modes are `bm25`, `keyword`, `semantic`, and `hybrid`. Use metadata
74
+ Search modes are `fts`, `bm25`, `keyword`, `semantic`, and `hybrid`; `fts` and
75
+ `keyword` are aliases for BM25 lexical search. Use metadata
75
76
  filters such as `--mailbox`, `--from`, `--to`, `--thread`, `--after`, and
76
77
  `--before`. Empty queries and unsupported modes fail with a non-zero exit.
77
78
 
@@ -89,7 +90,11 @@ Check health and rebuild lexical data when needed:
89
90
 
90
91
  ```bash
91
92
  mailcrawl doctor --json
93
+ mailcrawl status --json
94
+ mailcrawl embed --json
92
95
  mailcrawl repair --fts --json
96
+ mailcrawl repair --semantic --json
97
+ mailcrawl repair --all --json
93
98
  ```
94
99
 
95
100
  `doctor` reports archive, FTS, and semantic-generation state. `repair` is a