@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 +16 -0
- package/dist/archive.d.ts +14 -2
- package/dist/archive.js +37 -16
- package/dist/cli/index.js +131 -26
- package/dist/embedding.d.ts +4 -2
- package/dist/embedding.js +66 -3
- package/dist/redact.js +1 -0
- package/dist/source.js +12 -2
- package/dist/types.d.ts +10 -0
- package/docs/architecture.md +3 -3
- package/package.json +2 -1
- package/skills/mailcrawl/SKILL.md +6 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
236
|
-
const rightTimestamp =
|
|
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(
|
|
34
|
+
const archive = new Archive(join(dataDir, "archive.sqlite"));
|
|
27
35
|
try {
|
|
28
36
|
const source = options.source === "fixture"
|
|
29
|
-
?
|
|
30
|
-
:
|
|
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
|
|
60
|
+
const config = embedderConfig(options);
|
|
61
|
+
const archive = new Archive(join(dataDir, "archive.sqlite"), config);
|
|
45
62
|
try {
|
|
46
|
-
output({ ...await archive.indexSemanticGeneration(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
202
|
+
const archive = new Archive(join(dataDir, "archive.sqlite"));
|
|
172
203
|
try {
|
|
173
204
|
let semantic = "missing";
|
|
174
205
|
try {
|
|
175
|
-
semantic = archive.semanticGeneration(
|
|
206
|
+
semantic = semanticStatus(archive, archive.semanticGeneration(join(dataDir, "semantic")));
|
|
176
207
|
}
|
|
177
208
|
catch (error) {
|
|
178
|
-
semantic = redactDiagnostic({ status:
|
|
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:
|
|
184
|
-
archivePresent: existsSync(
|
|
185
|
-
fts:
|
|
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
|
|
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
|
-
|
|
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(
|
|
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));
|
package/dist/embedding.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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;
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
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|--
|
|
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.
|
|
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
|
|
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
|