@nomadamas/mailcrawl 0.1.5 → 0.1.7
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 +31 -0
- package/dist/archive.d.ts +14 -2
- package/dist/archive.js +37 -16
- package/dist/cli/index.js +148 -27
- package/dist/embedding.d.ts +4 -2
- package/dist/embedding.js +66 -3
- package/dist/redact.js +1 -0
- package/dist/source.d.ts +36 -2
- package/dist/source.js +141 -29
- package/dist/types.d.ts +19 -0
- package/docs/architecture.md +16 -5
- package/package.json +2 -1
- package/skills/mailcrawl/SKILL.md +13 -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,
|
|
@@ -49,6 +50,21 @@ before multilingual search; the command re-analyzes existing messages and
|
|
|
49
50
|
atomically records the new fingerprint. Embedding model changes are independent
|
|
50
51
|
and require a new `mailcrawl index` generation.
|
|
51
52
|
|
|
53
|
+
## Sync read concurrency
|
|
54
|
+
|
|
55
|
+
`mailcrawl sync` reads a page of envelopes through a bounded pool of himalaya
|
|
56
|
+
processes — 4 by default, `--concurrency <n>` to change it — instead of
|
|
57
|
+
spawning one process per envelope. Gmail throttles accounts that open too many
|
|
58
|
+
simultaneous IMAP connections, and an unbounded fan-out made one throttled
|
|
59
|
+
read abort the entire sync. `--page-size` still controls only how many
|
|
60
|
+
envelopes the IMAP window returns.
|
|
61
|
+
|
|
62
|
+
A read that fails is retried with exponential backoff (three attempts by
|
|
63
|
+
default). Messages that stay unreadable are reported in the sync JSON as
|
|
64
|
+
`failures[]` with their `providerKey`, `attempts`, and the redacted himalaya
|
|
65
|
+
error, while the messages that could be read are still synced. The command
|
|
66
|
+
exits non-zero only when nothing could be read.
|
|
67
|
+
|
|
52
68
|
## Installation
|
|
53
69
|
|
|
54
70
|
For the required Node setup, Kiwi model files, Go installation, Japanese and
|
|
@@ -56,6 +72,21 @@ Chinese helper builds, environment variables, smoke tests, and license
|
|
|
56
72
|
requirements, follow [`docs/multilingual-installation.md`](docs/multilingual-installation.md)
|
|
57
73
|
before using multilingual indexing or search.
|
|
58
74
|
|
|
75
|
+
## Shared loopback embedding provider
|
|
76
|
+
|
|
77
|
+
The local in-process model is the default. Opt into a local HTTP runtime with
|
|
78
|
+
`--provider loopback-http`, `--embed-url`, `--embed-model`, and `--embed-dim`
|
|
79
|
+
on `index`, `repair --semantic`, or semantic search. Only HTTP URLs for
|
|
80
|
+
`127.0.0.1`, `localhost`, or `::1` are accepted. Query/passage prefixes and
|
|
81
|
+
timeout are optional and are included in the provider identity. Any provider
|
|
82
|
+
setting change rebuilds vectors, while a failed rebuild preserves the prior
|
|
83
|
+
`CURRENT` generation.
|
|
84
|
+
|
|
85
|
+
The equivalent environment contract is
|
|
86
|
+
`MAILCRAWL_EMBEDDER_PROVIDER`, `MAILCRAWL_EMBED_URL`,
|
|
87
|
+
`MAILCRAWL_EMBED_MODEL`, `MAILCRAWL_EMBED_DIM`, `MAILCRAWL_QUERY_PREFIX`,
|
|
88
|
+
`MAILCRAWL_PASSAGE_PREFIX`, and `MAILCRAWL_EMBED_TIMEOUT`.
|
|
89
|
+
|
|
59
90
|
## Releasing
|
|
60
91
|
|
|
61
92
|
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");
|
|
@@ -16,21 +18,32 @@ program
|
|
|
16
18
|
.option("--mailbox <name>", "mailbox name", "INBOX")
|
|
17
19
|
.option("--backend <name>")
|
|
18
20
|
.option("--page-size <n>", "envelopes per page", "1000")
|
|
21
|
+
.option("--concurrency <n>", "simultaneous message reads (default 4)")
|
|
19
22
|
.option("--himalaya-config <path>")
|
|
20
23
|
.option("--include-category <name>", "include a normally excluded category", collect, [])
|
|
21
24
|
.option("--exclude-category <name>", "exclude a classification category", collect, [])
|
|
22
25
|
.option("--json")
|
|
23
26
|
.action(async (options, command) => {
|
|
24
27
|
const dataDir = command.parent.opts().dataDir;
|
|
28
|
+
if (options.source !== "fixture" && options.source !== "himalaya")
|
|
29
|
+
throw new Error(`unsupported source: ${options.source}`);
|
|
30
|
+
if (options.source === "fixture" && !options.fixture)
|
|
31
|
+
throw new Error("--fixture is required for fixture source");
|
|
32
|
+
if (options.source === "himalaya" && !options.account)
|
|
33
|
+
throw new Error("--account is required for himalaya source");
|
|
25
34
|
await mkdir(dataDir, { recursive: true });
|
|
26
|
-
const archive = new Archive(
|
|
35
|
+
const archive = new Archive(join(dataDir, "archive.sqlite"));
|
|
27
36
|
try {
|
|
28
37
|
const source = options.source === "fixture"
|
|
29
|
-
?
|
|
30
|
-
:
|
|
38
|
+
? fixtureSource(options)
|
|
39
|
+
: himalayaSource(options);
|
|
31
40
|
const excludedCategories = (options.excludeCategory.length ? options.excludeCategory : ["spam", "promotions"])
|
|
32
41
|
.filter((category) => !options.includeCategory.includes(category));
|
|
33
|
-
|
|
42
|
+
const { messages, failures } = await source.collect();
|
|
43
|
+
if (messages.length === 0 && failures.length > 0) {
|
|
44
|
+
throw new Error(`source read failed for all ${failures.length} message(s); no messages could be read: ${failures.slice(0, 3).map((failure) => failure.providerKey).join(", ")}`);
|
|
45
|
+
}
|
|
46
|
+
output({ ...await archive.sync(messages, { excludedCategories }), failures }, options.json);
|
|
34
47
|
}
|
|
35
48
|
finally {
|
|
36
49
|
archive.close();
|
|
@@ -38,12 +51,21 @@ program
|
|
|
38
51
|
});
|
|
39
52
|
program
|
|
40
53
|
.command("index")
|
|
54
|
+
.alias("embed")
|
|
55
|
+
.option("--provider <provider>", "embedding provider", "local")
|
|
56
|
+
.option("--embed-url <url>")
|
|
57
|
+
.option("--embed-model <model>")
|
|
58
|
+
.option("--embed-dim <n>")
|
|
59
|
+
.option("--query-prefix <prefix>")
|
|
60
|
+
.option("--passage-prefix <prefix>")
|
|
61
|
+
.option("--embed-timeout <ms>")
|
|
41
62
|
.option("--json")
|
|
42
63
|
.action(async (options, command) => {
|
|
43
64
|
const dataDir = command.parent.opts().dataDir;
|
|
44
|
-
const
|
|
65
|
+
const config = embedderConfig(options);
|
|
66
|
+
const archive = new Archive(join(dataDir, "archive.sqlite"), config);
|
|
45
67
|
try {
|
|
46
|
-
output({ ...await archive.indexSemanticGeneration(
|
|
68
|
+
output({ ...await archive.indexSemanticGeneration(join(dataDir, "semantic")), embedder: embeddingModelName(config) }, options.json);
|
|
47
69
|
}
|
|
48
70
|
finally {
|
|
49
71
|
archive.close();
|
|
@@ -61,12 +83,19 @@ for (const mode of ["bm25", "keyword", "semantic", "hybrid"]) {
|
|
|
61
83
|
.option("--after <date>")
|
|
62
84
|
.option("--before <date>")
|
|
63
85
|
.option("--limit <n>", "result limit", "10")
|
|
86
|
+
.option("--provider <provider>", "embedding provider", "local")
|
|
87
|
+
.option("--embed-url <url>")
|
|
88
|
+
.option("--embed-model <model>")
|
|
89
|
+
.option("--embed-dim <n>")
|
|
90
|
+
.option("--query-prefix <prefix>")
|
|
91
|
+
.option("--passage-prefix <prefix>")
|
|
92
|
+
.option("--embed-timeout <ms>")
|
|
64
93
|
.option("--json")
|
|
65
94
|
.action(async (query, options, command) => {
|
|
66
95
|
const dataDir = command.parent.opts().dataDir;
|
|
67
|
-
const archive = new Archive(
|
|
96
|
+
const archive = new Archive(join(dataDir, "archive.sqlite"), embedderConfig(options));
|
|
68
97
|
try {
|
|
69
|
-
const result = mode === "bm25"
|
|
98
|
+
const result = mode === "bm25" || mode === "keyword"
|
|
70
99
|
? await archive.searchBm25(query, filters(options), Number(options.limit))
|
|
71
100
|
: mode === "hybrid"
|
|
72
101
|
? await archive.searchHybrid(query, filters(options), Number(options.limit))
|
|
@@ -81,7 +110,7 @@ for (const mode of ["bm25", "keyword", "semantic", "hybrid"]) {
|
|
|
81
110
|
program
|
|
82
111
|
.command("search")
|
|
83
112
|
.argument("<query>")
|
|
84
|
-
.option("--mode <mode>", "keyword, bm25, semantic, hybrid", "bm25")
|
|
113
|
+
.option("--mode <mode>", "fts, keyword, bm25, semantic, hybrid", "bm25")
|
|
85
114
|
.option("--account <id>")
|
|
86
115
|
.option("--mailbox <name>")
|
|
87
116
|
.option("--from <address>")
|
|
@@ -90,13 +119,20 @@ program
|
|
|
90
119
|
.option("--after <date>")
|
|
91
120
|
.option("--before <date>")
|
|
92
121
|
.option("--limit <n>", "result limit", "10")
|
|
122
|
+
.option("--provider <provider>", "embedding provider", "local")
|
|
123
|
+
.option("--embed-url <url>")
|
|
124
|
+
.option("--embed-model <model>")
|
|
125
|
+
.option("--embed-dim <n>")
|
|
126
|
+
.option("--query-prefix <prefix>")
|
|
127
|
+
.option("--passage-prefix <prefix>")
|
|
128
|
+
.option("--embed-timeout <ms>")
|
|
93
129
|
.option("--json")
|
|
94
130
|
.action(async (query, options, command) => {
|
|
95
|
-
const archive = new Archive(
|
|
131
|
+
const archive = new Archive(join(command.parent.opts().dataDir, "archive.sqlite"), embedderConfig(options));
|
|
96
132
|
try {
|
|
97
133
|
const filter = filters(options);
|
|
98
134
|
const limit = Number(options.limit);
|
|
99
|
-
const result = options.mode === "bm25" ? await archive.searchBm25(query, filter, limit)
|
|
135
|
+
const result = options.mode === "bm25" || options.mode === "fts" || options.mode === "keyword" ? await archive.searchBm25(query, filter, limit)
|
|
100
136
|
: options.mode === "semantic" ? await archive.searchSemantic(query, filter, limit)
|
|
101
137
|
: options.mode === "hybrid" ? await archive.searchHybrid(query, filter, limit)
|
|
102
138
|
: (() => { throw new Error(`unsupported search mode: ${options.mode}`); })();
|
|
@@ -111,7 +147,7 @@ program
|
|
|
111
147
|
.command("get <messageId>")
|
|
112
148
|
.option("--json")
|
|
113
149
|
.action(async (messageId, options, command) => {
|
|
114
|
-
const archive = new Archive(
|
|
150
|
+
const archive = new Archive(join(command.parent.parent.opts().dataDir, "archive.sqlite"));
|
|
115
151
|
try {
|
|
116
152
|
output(archive.getMessage(messageId) ?? null, options.json);
|
|
117
153
|
}
|
|
@@ -124,7 +160,7 @@ program
|
|
|
124
160
|
.argument("<chunkId>")
|
|
125
161
|
.option("--json")
|
|
126
162
|
.action(async (chunkId, options, command) => {
|
|
127
|
-
const archive = new Archive(
|
|
163
|
+
const archive = new Archive(join(command.parent.opts().dataDir, "archive.sqlite"));
|
|
128
164
|
try {
|
|
129
165
|
output(archive.getChunkContext(chunkId), options.json);
|
|
130
166
|
}
|
|
@@ -141,7 +177,7 @@ program
|
|
|
141
177
|
.option("--before <date>")
|
|
142
178
|
.option("--json")
|
|
143
179
|
.action(async (threadId, options, command) => {
|
|
144
|
-
const archive = new Archive(
|
|
180
|
+
const archive = new Archive(join(command.parent.parent.opts().dataDir, "archive.sqlite"));
|
|
145
181
|
try {
|
|
146
182
|
output(archive.getThread(threadId, filters(options)), options.json);
|
|
147
183
|
}
|
|
@@ -155,7 +191,7 @@ program
|
|
|
155
191
|
.option("--message <messageId>")
|
|
156
192
|
.option("--json")
|
|
157
193
|
.action(async (threadId, options, command) => {
|
|
158
|
-
const archive = new Archive(
|
|
194
|
+
const archive = new Archive(join(command.parent.opts().dataDir, "archive.sqlite"));
|
|
159
195
|
try {
|
|
160
196
|
output(archive.getThreadContext(threadId, options.message), options.json);
|
|
161
197
|
}
|
|
@@ -168,21 +204,21 @@ program
|
|
|
168
204
|
.option("--json")
|
|
169
205
|
.action(async (options, command) => {
|
|
170
206
|
const dataDir = command.parent.opts().dataDir;
|
|
171
|
-
const archive = new Archive(
|
|
207
|
+
const archive = new Archive(join(dataDir, "archive.sqlite"));
|
|
172
208
|
try {
|
|
173
209
|
let semantic = "missing";
|
|
174
210
|
try {
|
|
175
|
-
semantic = archive.semanticGeneration(
|
|
211
|
+
semantic = semanticStatus(archive, archive.semanticGeneration(join(dataDir, "semantic")));
|
|
176
212
|
}
|
|
177
213
|
catch (error) {
|
|
178
|
-
semantic = redactDiagnostic({ status:
|
|
214
|
+
semantic = redactDiagnostic({ status: semanticErrorStatus(error), error: error instanceof Error ? error.message : String(error) });
|
|
179
215
|
}
|
|
180
216
|
const semanticCommitted = typeof semantic === "object" && semantic !== null && "generation" in semantic;
|
|
181
217
|
output({
|
|
182
218
|
name: "mailcrawl",
|
|
183
|
-
archive:
|
|
184
|
-
archivePresent: existsSync(
|
|
185
|
-
fts:
|
|
219
|
+
archive: join(dataDir, "archive.sqlite"),
|
|
220
|
+
archivePresent: existsSync(join(dataDir, "archive.sqlite")),
|
|
221
|
+
fts: archive.status().fts,
|
|
186
222
|
semantic,
|
|
187
223
|
recommendation: semanticCommitted ? "semantic index is committed" : "run sync, then index before semantic search",
|
|
188
224
|
}, options.json);
|
|
@@ -191,16 +227,65 @@ program
|
|
|
191
227
|
archive.close();
|
|
192
228
|
}
|
|
193
229
|
});
|
|
230
|
+
program
|
|
231
|
+
.command("status")
|
|
232
|
+
.option("--json")
|
|
233
|
+
.action(async (options, command) => {
|
|
234
|
+
const dataDir = command.parent.opts().dataDir;
|
|
235
|
+
const archivePath = join(dataDir, "archive.sqlite");
|
|
236
|
+
if (!existsSync(archivePath)) {
|
|
237
|
+
output({ name: "mailcrawl", archive: archivePath, archivePresent: false, messageCount: 0, chunkCount: 0, embeddingBacklog: 0, fts: { status: "missing", rows: 0 }, semantic: { status: "missing" } }, options.json);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const archive = new Archive(archivePath);
|
|
241
|
+
try {
|
|
242
|
+
let semantic = "missing";
|
|
243
|
+
try {
|
|
244
|
+
semantic = semanticStatus(archive, archive.semanticGeneration(join(dataDir, "semantic")));
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
semantic = redactDiagnostic({ status: semanticErrorStatus(error), error: error instanceof Error ? error.message : String(error) });
|
|
248
|
+
}
|
|
249
|
+
output({ name: "mailcrawl", archive: archivePath, archivePresent: true, ...archive.status(), semantic }, options.json);
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
archive.close();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
194
255
|
program
|
|
195
256
|
.command("repair")
|
|
196
257
|
.option("--fts")
|
|
258
|
+
.option("--semantic")
|
|
259
|
+
.option("--all")
|
|
260
|
+
.option("--provider <provider>", "embedding provider", "local")
|
|
261
|
+
.option("--embed-url <url>")
|
|
262
|
+
.option("--embed-model <model>")
|
|
263
|
+
.option("--embed-dim <n>")
|
|
264
|
+
.option("--query-prefix <prefix>")
|
|
265
|
+
.option("--passage-prefix <prefix>")
|
|
266
|
+
.option("--embed-timeout <ms>")
|
|
197
267
|
.option("--json")
|
|
198
268
|
.action(async (options, command) => {
|
|
199
|
-
const
|
|
269
|
+
const dataDir = command.parent.opts().dataDir;
|
|
270
|
+
const archive = new Archive(join(dataDir, "archive.sqlite"));
|
|
200
271
|
try {
|
|
201
|
-
if (!options.fts)
|
|
202
|
-
throw new Error("pass --fts");
|
|
203
|
-
|
|
272
|
+
if (!options.fts && !options.semantic && !options.all)
|
|
273
|
+
throw new Error("pass --fts, --semantic, or --all");
|
|
274
|
+
const result = {};
|
|
275
|
+
if (options.semantic || options.all) {
|
|
276
|
+
const config = embedderConfig(options);
|
|
277
|
+
const semanticArchive = config ? new Archive(join(dataDir, "archive.sqlite"), config) : archive;
|
|
278
|
+
try {
|
|
279
|
+
result.semantic = await semanticArchive.indexSemanticGeneration(join(dataDir, "semantic"));
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
if (semanticArchive !== archive)
|
|
283
|
+
semanticArchive.close();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (options.fts || options.all)
|
|
287
|
+
result.fts = archive.repairFts();
|
|
288
|
+
output(Object.keys(result).length === 1 ? Object.values(result)[0] : result, options.json);
|
|
204
289
|
}
|
|
205
290
|
finally {
|
|
206
291
|
archive.close();
|
|
@@ -212,7 +297,7 @@ attachments
|
|
|
212
297
|
.option("--message <messageId>")
|
|
213
298
|
.option("--json")
|
|
214
299
|
.action(async (options, command) => {
|
|
215
|
-
const archive = new Archive(
|
|
300
|
+
const archive = new Archive(join(command.parent.parent.opts().dataDir, "archive.sqlite"));
|
|
216
301
|
try {
|
|
217
302
|
output(archive.listAttachments(options.message), options.json);
|
|
218
303
|
}
|
|
@@ -222,12 +307,48 @@ attachments
|
|
|
222
307
|
});
|
|
223
308
|
program.parseAsync().catch((error) => {
|
|
224
309
|
const message = error instanceof Error ? error.message : String(error);
|
|
225
|
-
console.error(JSON.stringify({ error: message }));
|
|
310
|
+
console.error(JSON.stringify(redactDiagnostic({ error: message })));
|
|
226
311
|
process.exitCode = 1;
|
|
227
312
|
});
|
|
228
313
|
function filters(options) {
|
|
229
314
|
return { accountId: options.account, mailbox: options.mailbox, from: options.from, to: options.to, threadId: options.thread, after: options.after, before: options.before };
|
|
230
315
|
}
|
|
316
|
+
function embedderConfig(options) {
|
|
317
|
+
if (options.provider === "local")
|
|
318
|
+
return loopbackConfigFromEnvironment();
|
|
319
|
+
if (options.provider !== "loopback-http")
|
|
320
|
+
throw new Error(`unsupported embedding provider: ${options.provider}`);
|
|
321
|
+
if (!options.embedUrl || !options.embedModel || !options.embedDim)
|
|
322
|
+
throw new Error("--embed-url, --embed-model, and --embed-dim are required for loopback-http");
|
|
323
|
+
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 };
|
|
324
|
+
}
|
|
325
|
+
function fixtureSource(options) {
|
|
326
|
+
if (!options.fixture)
|
|
327
|
+
throw new Error("--fixture is required for selected source");
|
|
328
|
+
return new FixtureSource(options.fixture);
|
|
329
|
+
}
|
|
330
|
+
function himalayaSource(options) {
|
|
331
|
+
if (!options.account)
|
|
332
|
+
throw new Error("--account is required for selected source");
|
|
333
|
+
return new HimalayaSource(options.account, options.mailbox, options.backend, Number(options.pageSize), options.himalayaConfig, {
|
|
334
|
+
concurrency: readConcurrency(options),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
function readConcurrency(options) {
|
|
338
|
+
if (options.concurrency === undefined)
|
|
339
|
+
return undefined;
|
|
340
|
+
const concurrency = Number(options.concurrency);
|
|
341
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
342
|
+
throw new Error(`--concurrency must be a positive integer: ${options.concurrency}`);
|
|
343
|
+
}
|
|
344
|
+
return concurrency;
|
|
345
|
+
}
|
|
346
|
+
function semanticStatus(archive, semantic) {
|
|
347
|
+
return semantic.archiveRevision === archive.status().archiveRevision ? { ...semantic, status: "healthy" } : { ...semantic, status: "stale" };
|
|
348
|
+
}
|
|
349
|
+
function semanticErrorStatus(error) {
|
|
350
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT" ? "missing" : "corrupt";
|
|
351
|
+
}
|
|
231
352
|
function output(value, json) {
|
|
232
353
|
if (json)
|
|
233
354
|
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.d.ts
CHANGED
|
@@ -1,10 +1,34 @@
|
|
|
1
|
-
import type { MailMessage } from "./types.js";
|
|
1
|
+
import type { MailMessage, SourceReadResult } from "./types.js";
|
|
2
|
+
/** Default number of simultaneous `himalaya message read` processes. */
|
|
3
|
+
export declare const DEFAULT_READ_CONCURRENCY = 4;
|
|
4
|
+
/** Default number of attempts per message read, including the first one. */
|
|
5
|
+
export declare const DEFAULT_RETRY_ATTEMPTS = 3;
|
|
6
|
+
/** Default backoff before the second read attempt; doubles per round. */
|
|
7
|
+
export declare const DEFAULT_RETRY_BASE_DELAY_MS = 250;
|
|
8
|
+
/** Executes one himalaya invocation; replaced by tests. */
|
|
9
|
+
export type HimalayaExec = (args: string[], maxBuffer: number) => Promise<{
|
|
10
|
+
stdout: string;
|
|
11
|
+
stderr?: string;
|
|
12
|
+
}>;
|
|
13
|
+
export interface HimalayaReadOptions {
|
|
14
|
+
/** Simultaneous message reads; defaults to DEFAULT_READ_CONCURRENCY. */
|
|
15
|
+
concurrency?: number;
|
|
16
|
+
/** Attempts per message read, including the first one; defaults to DEFAULT_RETRY_ATTEMPTS. */
|
|
17
|
+
retryAttempts?: number;
|
|
18
|
+
/** Backoff before the second attempt, doubling per round; defaults to DEFAULT_RETRY_BASE_DELAY_MS. */
|
|
19
|
+
retryBaseDelayMs?: number;
|
|
20
|
+
exec?: HimalayaExec;
|
|
21
|
+
}
|
|
2
22
|
export interface MailSource {
|
|
23
|
+
/** Messages that could be read, plus one record per message that could not. */
|
|
24
|
+
collect(): Promise<SourceReadResult>;
|
|
25
|
+
/** Strict variant of `collect()`: rejects when any message could not be read. */
|
|
3
26
|
list(): Promise<MailMessage[]>;
|
|
4
27
|
}
|
|
5
28
|
export declare class FixtureSource implements MailSource {
|
|
6
29
|
private readonly path;
|
|
7
30
|
constructor(path: string);
|
|
31
|
+
collect(): Promise<SourceReadResult>;
|
|
8
32
|
list(): Promise<MailMessage[]>;
|
|
9
33
|
}
|
|
10
34
|
export declare class HimalayaSource implements MailSource {
|
|
@@ -13,7 +37,17 @@ export declare class HimalayaSource implements MailSource {
|
|
|
13
37
|
private readonly backend?;
|
|
14
38
|
private readonly pageSize;
|
|
15
39
|
private readonly config?;
|
|
16
|
-
|
|
40
|
+
private readonly readOptions;
|
|
41
|
+
constructor(account: string, mailbox?: string, backend?: string | undefined, pageSize?: number, config?: string | undefined, readOptions?: HimalayaReadOptions);
|
|
42
|
+
collect(): Promise<SourceReadResult>;
|
|
17
43
|
list(): Promise<MailMessage[]>;
|
|
44
|
+
/** Reads a page through a bounded pool, retrying failed reads in later rounds. */
|
|
45
|
+
private readPage;
|
|
46
|
+
private get concurrency();
|
|
47
|
+
private envelopes;
|
|
48
|
+
private baseArgs;
|
|
18
49
|
private read;
|
|
50
|
+
private run;
|
|
19
51
|
}
|
|
52
|
+
/** Runs `worker` over `items` with at most `limit` workers in flight, preserving input order. */
|
|
53
|
+
export declare function mapWithConcurrency<T, R>(items: readonly T[], limit: number, worker: (item: T) => Promise<R>): Promise<R[]>;
|
package/dist/source.js
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
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);
|
|
6
|
+
/** Default number of simultaneous `himalaya message read` processes. */
|
|
7
|
+
export const DEFAULT_READ_CONCURRENCY = 4;
|
|
8
|
+
/** Default number of attempts per message read, including the first one. */
|
|
9
|
+
export const DEFAULT_RETRY_ATTEMPTS = 3;
|
|
10
|
+
/** Default backoff before the second read attempt; doubles per round. */
|
|
11
|
+
export const DEFAULT_RETRY_BASE_DELAY_MS = 250;
|
|
5
12
|
export class FixtureSource {
|
|
6
13
|
path;
|
|
7
14
|
constructor(path) {
|
|
8
15
|
this.path = path;
|
|
9
16
|
}
|
|
17
|
+
async collect() {
|
|
18
|
+
return { messages: await this.list(), failures: [] };
|
|
19
|
+
}
|
|
10
20
|
async list() {
|
|
11
21
|
const raw = await readFile(this.path, "utf8");
|
|
12
22
|
return JSON.parse(raw);
|
|
@@ -18,53 +28,155 @@ export class HimalayaSource {
|
|
|
18
28
|
backend;
|
|
19
29
|
pageSize;
|
|
20
30
|
config;
|
|
21
|
-
|
|
31
|
+
readOptions;
|
|
32
|
+
constructor(account, mailbox = "INBOX", backend, pageSize = 1000, config, readOptions = {}) {
|
|
22
33
|
this.account = account;
|
|
23
34
|
this.mailbox = mailbox;
|
|
24
35
|
this.backend = backend;
|
|
25
36
|
this.pageSize = pageSize;
|
|
26
37
|
this.config = config;
|
|
38
|
+
this.readOptions = readOptions;
|
|
39
|
+
}
|
|
40
|
+
async collect() {
|
|
41
|
+
return this.readPage(await this.envelopes());
|
|
27
42
|
}
|
|
28
43
|
async list() {
|
|
29
|
-
const
|
|
30
|
-
if (
|
|
31
|
-
|
|
44
|
+
const { messages, failures } = await this.collect();
|
|
45
|
+
if (failures.length > 0)
|
|
46
|
+
throw new Error(failures[0].error);
|
|
47
|
+
return messages;
|
|
48
|
+
}
|
|
49
|
+
/** Reads a page through a bounded pool, retrying failed reads in later rounds. */
|
|
50
|
+
async readPage(envelopes) {
|
|
51
|
+
const keys = envelopes.map(envelopeKey);
|
|
52
|
+
const rawMime = new Map();
|
|
53
|
+
const errors = new Map();
|
|
54
|
+
const attempts = Math.max(1, Math.floor(this.readOptions.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS));
|
|
55
|
+
const baseDelayMs = Math.max(0, this.readOptions.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS);
|
|
56
|
+
let pending = envelopes.map((_, index) => index);
|
|
57
|
+
for (let attempt = 1; attempt <= attempts && pending.length > 0; attempt += 1) {
|
|
58
|
+
if (attempt > 1)
|
|
59
|
+
await delay(baseDelayMs * 2 ** (attempt - 2));
|
|
60
|
+
const failed = [];
|
|
61
|
+
await mapWithConcurrency(pending, this.concurrency, async (index) => {
|
|
62
|
+
try {
|
|
63
|
+
rawMime.set(index, await this.read(keys[index]));
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
errors.set(index, error instanceof Error ? error : new Error(String(error)));
|
|
67
|
+
failed.push(index);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
pending = failed;
|
|
71
|
+
}
|
|
72
|
+
const messages = [];
|
|
73
|
+
const failures = [];
|
|
74
|
+
envelopes.forEach((envelope, index) => {
|
|
75
|
+
const raw = rawMime.get(index);
|
|
76
|
+
if (raw !== undefined) {
|
|
77
|
+
messages.push(envelopeMessage(this.account, this.mailbox, envelope, keys[index], raw));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
failures.push({ providerKey: keys[index], attempts, error: (errors.get(index) ?? new Error("message read failed")).message });
|
|
81
|
+
});
|
|
82
|
+
return { messages, failures };
|
|
83
|
+
}
|
|
84
|
+
get concurrency() {
|
|
85
|
+
return Math.max(1, Math.floor(this.readOptions.concurrency ?? DEFAULT_READ_CONCURRENCY));
|
|
86
|
+
}
|
|
87
|
+
async envelopes() {
|
|
88
|
+
const args = this.baseArgs();
|
|
32
89
|
args.push("envelope", "list", "--mailbox", this.mailbox, "--page-size", String(this.pageSize), "--json");
|
|
33
|
-
const { stdout } = await
|
|
90
|
+
const { stdout } = await this.run(args, 16 * 1024 * 1024, "envelope list");
|
|
34
91
|
const payload = JSON.parse(stdout);
|
|
35
|
-
|
|
36
|
-
return Promise.all(envelopes.map(async (envelope) => {
|
|
37
|
-
const providerKey = String(envelope.id ?? envelope.uid ?? envelope["message-id"]);
|
|
38
|
-
const rawMime = await this.read(providerKey);
|
|
39
|
-
return {
|
|
40
|
-
accountId: this.account,
|
|
41
|
-
mailbox: this.mailbox,
|
|
42
|
-
providerKey,
|
|
43
|
-
messageId: envelope["message-id"],
|
|
44
|
-
inReplyTo: envelope["in-reply-to"]?.[0],
|
|
45
|
-
subject: envelope.subject ?? "",
|
|
46
|
-
from: address(envelope.from),
|
|
47
|
-
to: addresses(envelope.to),
|
|
48
|
-
cc: addresses(envelope.cc),
|
|
49
|
-
date: envelope.date ?? new Date(0).toISOString(),
|
|
50
|
-
text: envelope.body ?? envelope.snippet ?? "",
|
|
51
|
-
labels: strings(envelope.labels),
|
|
52
|
-
flags: strings(envelope.flags),
|
|
53
|
-
classifications: strings(envelope.classifications),
|
|
54
|
-
rawMime,
|
|
55
|
-
};
|
|
56
|
-
}));
|
|
92
|
+
return payload.envelopes ?? (Array.isArray(payload) ? payload : []);
|
|
57
93
|
}
|
|
58
|
-
|
|
94
|
+
baseArgs() {
|
|
59
95
|
const args = this.config ? ["-c", this.config, "-a", this.account] : ["-a", this.account];
|
|
60
96
|
if (this.backend)
|
|
61
97
|
args.push("-b", this.backend);
|
|
98
|
+
return args;
|
|
99
|
+
}
|
|
100
|
+
async read(id) {
|
|
101
|
+
const args = this.baseArgs();
|
|
62
102
|
args.push("--json", "message", "read", id, "--raw");
|
|
63
|
-
const { stdout } = await
|
|
103
|
+
const { stdout } = await this.run(args, 32 * 1024 * 1024, "message read");
|
|
64
104
|
const payload = JSON.parse(stdout);
|
|
65
105
|
return payload.message ?? stdout;
|
|
66
106
|
}
|
|
107
|
+
run(args, maxBuffer, operation) {
|
|
108
|
+
return runHimalaya(args, maxBuffer, operation, this.readOptions.exec);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Runs `worker` over `items` with at most `limit` workers in flight, preserving input order. */
|
|
112
|
+
export async function mapWithConcurrency(items, limit, worker) {
|
|
113
|
+
const results = new Array(items.length);
|
|
114
|
+
let cursor = 0;
|
|
115
|
+
let stopped = false;
|
|
116
|
+
const width = Math.max(1, Math.min(Math.floor(limit) || 1, items.length));
|
|
117
|
+
await Promise.all(Array.from({ length: width }, async () => {
|
|
118
|
+
while (!stopped) {
|
|
119
|
+
const index = cursor++;
|
|
120
|
+
if (index >= items.length)
|
|
121
|
+
return;
|
|
122
|
+
try {
|
|
123
|
+
results[index] = await worker(items[index]);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
// A failed page must not keep spawning provider processes for the
|
|
127
|
+
// items that were never read; only the in-flight workers finish.
|
|
128
|
+
stopped = true;
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}));
|
|
133
|
+
return results;
|
|
134
|
+
}
|
|
135
|
+
function envelopeKey(envelope) {
|
|
136
|
+
return String(envelope.id ?? envelope.uid ?? envelope["message-id"]);
|
|
137
|
+
}
|
|
138
|
+
function delay(ms) {
|
|
139
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
140
|
+
}
|
|
141
|
+
function envelopeMessage(account, mailbox, envelope, providerKey, rawMime) {
|
|
142
|
+
return {
|
|
143
|
+
accountId: account,
|
|
144
|
+
mailbox,
|
|
145
|
+
providerKey,
|
|
146
|
+
messageId: envelope["message-id"],
|
|
147
|
+
inReplyTo: envelope["in-reply-to"]?.[0],
|
|
148
|
+
subject: envelope.subject ?? "",
|
|
149
|
+
from: address(envelope.from),
|
|
150
|
+
to: addresses(envelope.to),
|
|
151
|
+
cc: addresses(envelope.cc),
|
|
152
|
+
date: envelope.date ?? new Date(0).toISOString(),
|
|
153
|
+
text: envelope.body ?? envelope.snippet ?? "",
|
|
154
|
+
labels: strings(envelope.labels),
|
|
155
|
+
flags: strings(envelope.flags),
|
|
156
|
+
classifications: strings(envelope.classifications),
|
|
157
|
+
rawMime,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
async function runHimalaya(args, maxBuffer, operation, exec) {
|
|
161
|
+
try {
|
|
162
|
+
return await (exec ?? defaultExec)(args, maxBuffer);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
166
|
+
throw new Error(`himalaya ${operation} failed: ${redactDiagnostic(detail)}${stderrDetail(error)}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Keeps himalaya's own stderr in the surfaced error so throttling is distinguishable from a malformed message. */
|
|
170
|
+
function stderrDetail(error) {
|
|
171
|
+
const stderr = error?.stderr;
|
|
172
|
+
if (typeof stderr !== "string")
|
|
173
|
+
return "";
|
|
174
|
+
const text = stderr.trim();
|
|
175
|
+
if (!text)
|
|
176
|
+
return "";
|
|
177
|
+
return `: ${String(redactDiagnostic(text.length > 2_000 ? `${text.slice(0, 2_000)} [truncated]` : text))}`;
|
|
67
178
|
}
|
|
179
|
+
const defaultExec = (args, maxBuffer) => execFileAsync("himalaya", args, { maxBuffer });
|
|
68
180
|
function address(value) {
|
|
69
181
|
if (typeof value === "string")
|
|
70
182
|
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;
|
|
@@ -86,6 +96,15 @@ export interface SyncReport {
|
|
|
86
96
|
excluded: number;
|
|
87
97
|
excludedByReason: Record<string, number>;
|
|
88
98
|
}
|
|
99
|
+
export interface SourceReadFailure {
|
|
100
|
+
providerKey: string;
|
|
101
|
+
attempts: number;
|
|
102
|
+
error: string;
|
|
103
|
+
}
|
|
104
|
+
export interface SourceReadResult {
|
|
105
|
+
messages: MailMessage[];
|
|
106
|
+
failures: SourceReadFailure[];
|
|
107
|
+
}
|
|
89
108
|
export interface ClassificationPolicy {
|
|
90
109
|
excludedCategories?: string[];
|
|
91
110
|
}
|
package/docs/architecture.md
CHANGED
|
@@ -158,13 +158,19 @@ All commands support `--json` where machine-readable output is useful.
|
|
|
158
158
|
```text
|
|
159
159
|
mailcrawl doctor
|
|
160
160
|
mailcrawl status
|
|
161
|
-
mailcrawl sync
|
|
162
|
-
mailcrawl embed
|
|
163
|
-
mailcrawl search --mode fts|semantic|hybrid [--limit N] [--mailbox NAME] QUERY
|
|
161
|
+
mailcrawl sync [--page-size N] [--concurrency N]
|
|
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
|
+
`--page-size` is the IMAP envelope window and `--concurrency` is the number of
|
|
169
|
+
simultaneous message reads (default 4). Reads run through that bounded pool,
|
|
170
|
+
failures are retried with backoff, and the messages that could be read are
|
|
171
|
+
synced even when some reads keep failing, so a throttled provider no longer
|
|
172
|
+
aborts a whole page.
|
|
173
|
+
|
|
168
174
|
Example sync response:
|
|
169
175
|
|
|
170
176
|
```json
|
|
@@ -175,10 +181,15 @@ Example sync response:
|
|
|
175
181
|
"unchanged": 4821,
|
|
176
182
|
"chunksAdded": 21,
|
|
177
183
|
"chunksDeleted": 6,
|
|
178
|
-
"embeddingBacklog": 21
|
|
184
|
+
"embeddingBacklog": 21,
|
|
185
|
+
"failures": []
|
|
179
186
|
}
|
|
180
187
|
```
|
|
181
188
|
|
|
189
|
+
Each entry of `failures` carries the `providerKey`, the `attempts` spent on it,
|
|
190
|
+
and the redacted himalaya error (including himalaya's own stderr). The command
|
|
191
|
+
exits non-zero only when nothing could be read.
|
|
192
|
+
|
|
182
193
|
Example search hit:
|
|
183
194
|
|
|
184
195
|
```json
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nomadamas/mailcrawl",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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": {
|
|
@@ -63,6 +63,13 @@ command, such as a user-level cron or systemd timer. Run `sync` first and
|
|
|
63
63
|
`index` afterward; inspect JSON exit status before handing results to an
|
|
64
64
|
agent.
|
|
65
65
|
|
|
66
|
+
Message reads use a bounded pool of himalaya processes (4 by default; change
|
|
67
|
+
it with `--concurrency <n>`) because Gmail throttles accounts that open too
|
|
68
|
+
many simultaneous IMAP connections. `--page-size` only sets the envelope
|
|
69
|
+
window. A read that fails is retried with backoff, and messages that stay
|
|
70
|
+
unreadable appear in the sync JSON `failures[]` while the readable messages
|
|
71
|
+
are still synced; the command exits non-zero only when nothing could be read.
|
|
72
|
+
|
|
66
73
|
Use a fixture for deterministic development:
|
|
67
74
|
|
|
68
75
|
```bash
|
|
@@ -71,7 +78,8 @@ mailcrawl sync --source fixture --fixture ./messages.json --json
|
|
|
71
78
|
|
|
72
79
|
## Safe read and maintenance commands
|
|
73
80
|
|
|
74
|
-
Search modes are `bm25`, `keyword`, `semantic`, and `hybrid
|
|
81
|
+
Search modes are `fts`, `bm25`, `keyword`, `semantic`, and `hybrid`; `fts` and
|
|
82
|
+
`keyword` are aliases for BM25 lexical search. Use metadata
|
|
75
83
|
filters such as `--mailbox`, `--from`, `--to`, `--thread`, `--after`, and
|
|
76
84
|
`--before`. Empty queries and unsupported modes fail with a non-zero exit.
|
|
77
85
|
|
|
@@ -89,7 +97,11 @@ Check health and rebuild lexical data when needed:
|
|
|
89
97
|
|
|
90
98
|
```bash
|
|
91
99
|
mailcrawl doctor --json
|
|
100
|
+
mailcrawl status --json
|
|
101
|
+
mailcrawl embed --json
|
|
92
102
|
mailcrawl repair --fts --json
|
|
103
|
+
mailcrawl repair --semantic --json
|
|
104
|
+
mailcrawl repair --all --json
|
|
93
105
|
```
|
|
94
106
|
|
|
95
107
|
`doctor` reports archive, FTS, and semantic-generation state. `repair` is a
|