@nomadamas/mailcrawl 0.1.4 → 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 +20 -2
- package/dist/archive.js +153 -30
- package/dist/cli/index.js +133 -27
- package/dist/embedding.d.ts +4 -2
- package/dist/embedding.js +66 -3
- package/dist/normalize.js +12 -1
- package/dist/redact.js +1 -0
- package/dist/source.js +12 -2
- package/dist/types.d.ts +10 -0
- package/dist/util.d.ts +1 -0
- package/dist/util.js +3 -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,24 +1,41 @@
|
|
|
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
|
+
private operationTail;
|
|
7
8
|
private lexicalRebuildRequired;
|
|
8
|
-
|
|
9
|
+
private readonly embedderConfig?;
|
|
10
|
+
constructor(path?: string, embedderConfig?: LoopbackHttpConfig);
|
|
9
11
|
close(): void;
|
|
10
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
|
+
};
|
|
23
|
+
private syncUnlocked;
|
|
11
24
|
searchBm25(query: string, filters?: SearchFilters, limit?: number): Promise<SearchHit[]>;
|
|
12
25
|
indexSemantic(): Promise<{
|
|
13
26
|
embedded: number;
|
|
14
27
|
reused: number;
|
|
15
28
|
archiveRevision: string;
|
|
16
29
|
}>;
|
|
30
|
+
private indexSemanticUnlocked;
|
|
17
31
|
indexSemanticGeneration(root: string): Promise<{
|
|
18
32
|
generation: string;
|
|
19
33
|
embedded: number;
|
|
20
34
|
reused: number;
|
|
21
35
|
}>;
|
|
36
|
+
private indexSemanticGenerationUnlocked;
|
|
37
|
+
private cleanupSemanticGenerations;
|
|
38
|
+
private generationTimestamp;
|
|
22
39
|
semanticGeneration(root: string): {
|
|
23
40
|
generation: string;
|
|
24
41
|
archiveRevision: string;
|
|
@@ -55,6 +72,7 @@ export declare class Archive {
|
|
|
55
72
|
private removeMessage;
|
|
56
73
|
private replaceMessageChunks;
|
|
57
74
|
private revision;
|
|
75
|
+
private runExclusive;
|
|
58
76
|
private getEmbedder;
|
|
59
77
|
private searchLexicalTable;
|
|
60
78
|
}
|
package/dist/archive.js
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
import Database from "better-sqlite3";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
|
-
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
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";
|
|
6
6
|
import { normalizeMessage } from "./normalize.js";
|
|
7
|
-
import { snippet } from "./util.js";
|
|
7
|
+
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
|
+
const RETAINED_SEMANTIC_GENERATIONS = 2;
|
|
11
|
+
const SEMANTIC_GENERATION_NAME = /^gen-[0-9a-f]{16}-[0-9]+(?:-[0-9a-f]+)?$/;
|
|
10
12
|
export class Archive {
|
|
11
13
|
db;
|
|
12
14
|
embedder;
|
|
13
15
|
lexical;
|
|
16
|
+
operationTail = Promise.resolve();
|
|
14
17
|
lexicalRebuildRequired;
|
|
15
|
-
|
|
18
|
+
embedderConfig;
|
|
19
|
+
constructor(path = ":memory:", embedderConfig) {
|
|
20
|
+
this.embedderConfig = embedderConfig;
|
|
16
21
|
this.db = new Database(path);
|
|
17
22
|
this.db.pragma("journal_mode = WAL");
|
|
18
23
|
this.db.pragma("foreign_keys = ON");
|
|
19
24
|
migrate(this.db);
|
|
25
|
+
cleanupOrphanedSemanticRows(this.db);
|
|
20
26
|
this.lexicalRebuildRequired = this.db.prepare("SELECT version FROM lexical_index_meta WHERE id = 1").get() === undefined;
|
|
21
27
|
if (!this.lexicalRebuildRequired) {
|
|
22
28
|
const row = this.db.prepare("SELECT version FROM lexical_index_meta WHERE id = 1").get();
|
|
@@ -32,14 +38,36 @@ export class Archive {
|
|
|
32
38
|
this.db.close();
|
|
33
39
|
}
|
|
34
40
|
async sync(messages, policy = {}) {
|
|
41
|
+
return this.runExclusive(() => this.syncUnlocked(messages, policy));
|
|
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
|
+
}
|
|
56
|
+
async syncUnlocked(messages, policy = {}) {
|
|
57
|
+
for (const message of messages) {
|
|
58
|
+
if (typeof message.providerKey !== "string" || !message.providerKey.trim())
|
|
59
|
+
throw new Error("message provider identity is required");
|
|
60
|
+
}
|
|
35
61
|
const excludedCategories = new Set((policy.excludedCategories ?? ["spam", "promotions"]).map(normalizeCategory));
|
|
36
62
|
const normalized = (await Promise.all(messages.map(normalizeMessage))).map((message) => ({
|
|
37
63
|
...message,
|
|
38
|
-
messageId:
|
|
39
|
-
threadId:
|
|
40
|
-
providerKey:
|
|
41
|
-
inReplyTo: message.inReplyTo ?
|
|
64
|
+
messageId: scopedId(message.accountId, message.mailbox, message.messageId),
|
|
65
|
+
threadId: scopedId(message.accountId, message.mailbox, message.threadId),
|
|
66
|
+
providerKey: scopedId(message.accountId, message.mailbox, message.providerKey),
|
|
67
|
+
inReplyTo: message.inReplyTo ? scopedId(message.accountId, message.mailbox, message.inReplyTo) : undefined,
|
|
42
68
|
}));
|
|
69
|
+
validateIdentities(normalized);
|
|
70
|
+
validateStoredIdentities(this.db, normalized);
|
|
43
71
|
const excluded = normalized.filter((message) => message.categories.some((category) => excludedCategories.has(category)));
|
|
44
72
|
const included = normalized.filter((message) => !message.categories.some((category) => excludedCategories.has(category)));
|
|
45
73
|
const existing = this.db.prepare("SELECT provider_key, normalized_hash FROM messages").all();
|
|
@@ -126,18 +154,29 @@ export class Archive {
|
|
|
126
154
|
.slice(0, limit).map(({ hit, score }) => ({ ...hit, score }));
|
|
127
155
|
}
|
|
128
156
|
async indexSemantic() {
|
|
157
|
+
return this.runExclusive(() => this.indexSemanticUnlocked());
|
|
158
|
+
}
|
|
159
|
+
async indexSemanticUnlocked() {
|
|
160
|
+
const completeQueueRow = this.db.prepare("UPDATE embedding_queue SET state = 'complete' WHERE chunk_id = ?");
|
|
161
|
+
const reconcile = this.db.transaction(() => {
|
|
162
|
+
this.db.prepare("DELETE FROM embedding_queue WHERE chunk_id NOT IN (SELECT chunk_id FROM chunks)").run();
|
|
163
|
+
this.db.prepare(`INSERT INTO embedding_queue(chunk_id, content_hash, state, attempts)
|
|
164
|
+
SELECT chunk_id, content_hash, 'pending', 0 FROM chunks
|
|
165
|
+
WHERE chunk_id NOT IN (SELECT chunk_id FROM embedding_queue)`).run();
|
|
166
|
+
});
|
|
167
|
+
reconcile();
|
|
129
168
|
const rows = this.db.prepare("SELECT chunk_id, text, content_hash FROM chunks ORDER BY chunk_id").all();
|
|
130
169
|
let embedded = 0;
|
|
131
170
|
let reused = 0;
|
|
132
171
|
const upsert = this.db.prepare(`INSERT INTO semantic_vectors
|
|
133
172
|
(chunk_id, content_hash, model, vector) VALUES (?, ?, ?, ?)
|
|
134
173
|
ON CONFLICT(chunk_id) DO UPDATE SET content_hash=excluded.content_hash, model=excluded.model, vector=excluded.vector`);
|
|
135
|
-
const embedder = await this.getEmbedder();
|
|
136
174
|
const pending = [];
|
|
137
175
|
const transaction = this.db.transaction(() => {
|
|
138
176
|
for (const row of rows) {
|
|
139
177
|
const old = this.db.prepare("SELECT content_hash, model FROM semantic_vectors WHERE chunk_id = ?").get(row.chunk_id);
|
|
140
|
-
if (old?.content_hash === row.content_hash && old.model === embeddingModelName()) {
|
|
178
|
+
if (old?.content_hash === row.content_hash && old.model === embeddingModelName(this.embedderConfig)) {
|
|
179
|
+
completeQueueRow.run(row.chunk_id);
|
|
141
180
|
reused++;
|
|
142
181
|
continue;
|
|
143
182
|
}
|
|
@@ -147,10 +186,12 @@ export class Archive {
|
|
|
147
186
|
transaction();
|
|
148
187
|
if (!pending.length)
|
|
149
188
|
return { embedded, reused, archiveRevision: this.revision() };
|
|
189
|
+
const embedder = await this.getEmbedder();
|
|
150
190
|
const vectors = await embedder.embedDocuments(pending.map((row) => row.text));
|
|
151
191
|
const write = this.db.transaction(() => {
|
|
152
192
|
for (const [index, row] of pending.entries()) {
|
|
153
|
-
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]));
|
|
194
|
+
completeQueueRow.run(row.chunk_id);
|
|
154
195
|
embedded++;
|
|
155
196
|
}
|
|
156
197
|
});
|
|
@@ -158,31 +199,77 @@ export class Archive {
|
|
|
158
199
|
return { embedded, reused, archiveRevision: this.revision() };
|
|
159
200
|
}
|
|
160
201
|
async indexSemanticGeneration(root) {
|
|
202
|
+
return this.runExclusive(() => this.indexSemanticGenerationUnlocked(root));
|
|
203
|
+
}
|
|
204
|
+
async indexSemanticGenerationUnlocked(root) {
|
|
161
205
|
const currentPath = join(root, "CURRENT");
|
|
162
206
|
const generationRoot = join(root, "generations");
|
|
207
|
+
const previousVectors = this.db.prepare("SELECT chunk_id, content_hash, model, vector FROM semantic_vectors ORDER BY chunk_id").all();
|
|
208
|
+
const previousQueue = this.db.prepare("SELECT chunk_id, content_hash, state, attempts FROM embedding_queue ORDER BY chunk_id").all();
|
|
163
209
|
mkdirSync(generationRoot, { recursive: true });
|
|
164
|
-
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")}`;
|
|
165
211
|
const staging = join(generationRoot, `.${generation}.staging`);
|
|
212
|
+
const publishedGeneration = join(generationRoot, generation);
|
|
213
|
+
const pointer = join(root, `.CURRENT.${process.pid}`);
|
|
166
214
|
mkdirSync(staging);
|
|
215
|
+
let published = false;
|
|
167
216
|
try {
|
|
168
|
-
const report = await this.
|
|
169
|
-
const vectors = this.db.prepare(
|
|
217
|
+
const report = await this.indexSemanticUnlocked();
|
|
218
|
+
const vectors = this.db.prepare(`SELECT v.chunk_id, v.content_hash, v.vector
|
|
219
|
+
FROM semantic_vectors v JOIN chunks c ON c.chunk_id = v.chunk_id
|
|
220
|
+
ORDER BY v.chunk_id`).all();
|
|
170
221
|
writeFileSync(join(staging, "manifest.json"), JSON.stringify({
|
|
171
|
-
archiveRevision: this.revision(), vectors, model: embeddingModelName(),
|
|
222
|
+
archiveRevision: this.revision(), vectors, model: embeddingModelName(this.embedderConfig),
|
|
172
223
|
}));
|
|
173
|
-
renameSync(staging,
|
|
174
|
-
|
|
224
|
+
renameSync(staging, publishedGeneration);
|
|
225
|
+
published = true;
|
|
175
226
|
writeFileSync(pointer, `${generation}\n`);
|
|
176
227
|
renameSync(pointer, currentPath);
|
|
228
|
+
this.cleanupSemanticGenerations(generationRoot, generation);
|
|
177
229
|
return { generation, embedded: report.embedded, reused: report.reused };
|
|
178
230
|
}
|
|
179
231
|
catch (error) {
|
|
180
232
|
rmSync(staging, { recursive: true, force: true });
|
|
233
|
+
rmSync(pointer, { force: true });
|
|
234
|
+
if (!published)
|
|
235
|
+
rmSync(publishedGeneration, { recursive: true, force: true });
|
|
236
|
+
const restore = this.db.transaction(() => {
|
|
237
|
+
this.db.exec("DELETE FROM semantic_vectors; DELETE FROM embedding_queue;");
|
|
238
|
+
const restoreVector = this.db.prepare("INSERT INTO semantic_vectors(chunk_id, content_hash, model, vector) VALUES (?, ?, ?, ?)");
|
|
239
|
+
for (const row of previousVectors)
|
|
240
|
+
restoreVector.run(row.chunk_id, row.content_hash, row.model, row.vector);
|
|
241
|
+
const restoreQueue = this.db.prepare("INSERT INTO embedding_queue(chunk_id, content_hash, state, attempts) VALUES (?, ?, ?, ?)");
|
|
242
|
+
for (const row of previousQueue)
|
|
243
|
+
restoreQueue.run(row.chunk_id, row.content_hash, row.state, row.attempts);
|
|
244
|
+
});
|
|
245
|
+
restore();
|
|
181
246
|
throw error;
|
|
182
247
|
}
|
|
183
248
|
}
|
|
249
|
+
cleanupSemanticGenerations(generationRoot, activeGeneration) {
|
|
250
|
+
const generations = readdirSync(generationRoot, { withFileTypes: true })
|
|
251
|
+
.filter((entry) => entry.isDirectory() && SEMANTIC_GENERATION_NAME.test(entry.name))
|
|
252
|
+
.map((entry) => entry.name)
|
|
253
|
+
.sort((left, right) => {
|
|
254
|
+
const leftTimestamp = this.generationTimestamp(left);
|
|
255
|
+
const rightTimestamp = this.generationTimestamp(right);
|
|
256
|
+
return rightTimestamp - leftTimestamp || right.localeCompare(left);
|
|
257
|
+
});
|
|
258
|
+
const retained = new Set(generations.slice(0, RETAINED_SEMANTIC_GENERATIONS));
|
|
259
|
+
retained.add(activeGeneration);
|
|
260
|
+
for (const generation of generations) {
|
|
261
|
+
if (!retained.has(generation))
|
|
262
|
+
rmSync(join(generationRoot, generation), { recursive: true, force: true });
|
|
263
|
+
}
|
|
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
|
+
}
|
|
184
269
|
semanticGeneration(root) {
|
|
185
270
|
const generation = readFileSync(join(root, "CURRENT"), "utf8").trim();
|
|
271
|
+
if (!SEMANTIC_GENERATION_NAME.test(generation))
|
|
272
|
+
throw new Error("invalid semantic generation pointer");
|
|
186
273
|
const manifest = JSON.parse(readFileSync(join(root, "generations", generation, "manifest.json"), "utf8"));
|
|
187
274
|
return { generation, archiveRevision: manifest.archiveRevision, vectorCount: manifest.vectors.length };
|
|
188
275
|
}
|
|
@@ -272,8 +359,6 @@ export class Archive {
|
|
|
272
359
|
const rows = this.db.prepare("SELECT rowid, chunk_id, text, message_id FROM chunks").all();
|
|
273
360
|
const rebuild = this.db.transaction(() => {
|
|
274
361
|
this.db.exec("DELETE FROM chunks_fts");
|
|
275
|
-
for (const language of lexicalFields())
|
|
276
|
-
this.db.exec(`DELETE FROM chunks_fts_${language}`);
|
|
277
362
|
for (const row of rows) {
|
|
278
363
|
const message = this.db.prepare("SELECT * FROM messages WHERE message_id = ?").get(row.message_id);
|
|
279
364
|
this.db.prepare(`INSERT INTO chunks_fts(rowid, subject, from_address, to_addresses, thread_subject,
|
|
@@ -282,8 +367,6 @@ export class Archive {
|
|
|
282
367
|
}
|
|
283
368
|
});
|
|
284
369
|
rebuild();
|
|
285
|
-
this.db.prepare("DELETE FROM lexical_index_meta WHERE id = 1").run();
|
|
286
|
-
this.lexicalRebuildRequired = true;
|
|
287
370
|
return { rows: rows.length, status: "repaired" };
|
|
288
371
|
}
|
|
289
372
|
upsertMessage(message) {
|
|
@@ -315,17 +398,14 @@ export class Archive {
|
|
|
315
398
|
}
|
|
316
399
|
}
|
|
317
400
|
removeMessage(message) {
|
|
318
|
-
const
|
|
319
|
-
if (!stored)
|
|
320
|
-
return;
|
|
321
|
-
const rows = this.db.prepare("SELECT rowid FROM chunks WHERE message_id = ?").all(stored.message_id);
|
|
401
|
+
const rows = this.db.prepare("SELECT rowid FROM chunks WHERE message_id = (SELECT message_id FROM messages WHERE provider_key = ?)").all(message.providerKey);
|
|
322
402
|
for (const row of rows)
|
|
323
403
|
this.db.prepare("DELETE FROM chunks_fts WHERE rowid = ?").run(row.rowid);
|
|
324
404
|
for (const language of lexicalFields())
|
|
325
|
-
this.db.prepare(`DELETE FROM chunks_fts_${language} WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)`).run(
|
|
326
|
-
this.db.prepare("DELETE FROM embedding_queue WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)").run(
|
|
327
|
-
this.db.prepare("DELETE FROM semantic_vectors WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)").run(
|
|
328
|
-
this.db.prepare("DELETE FROM messages WHERE
|
|
405
|
+
this.db.prepare(`DELETE FROM chunks_fts_${language} WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = (SELECT message_id FROM messages WHERE provider_key = ?))`).run(message.providerKey);
|
|
406
|
+
this.db.prepare("DELETE FROM embedding_queue WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = (SELECT message_id FROM messages WHERE provider_key = ?))").run(message.providerKey);
|
|
407
|
+
this.db.prepare("DELETE FROM semantic_vectors WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = (SELECT message_id FROM messages WHERE provider_key = ?))").run(message.providerKey);
|
|
408
|
+
this.db.prepare("DELETE FROM messages WHERE provider_key = ?").run(message.providerKey);
|
|
329
409
|
}
|
|
330
410
|
replaceMessageChunks(message, analyzedChunks) {
|
|
331
411
|
const old = this.db.prepare("SELECT rowid, chunk_id FROM chunks WHERE message_id = ?").all(message.messageId);
|
|
@@ -333,6 +413,8 @@ export class Archive {
|
|
|
333
413
|
this.db.prepare("DELETE FROM chunks_fts WHERE rowid = ?").run(row.rowid);
|
|
334
414
|
for (const language of lexicalFields())
|
|
335
415
|
this.db.prepare(`DELETE FROM chunks_fts_${language} WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)`).run(message.messageId);
|
|
416
|
+
this.db.prepare("DELETE FROM embedding_queue WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)").run(message.messageId);
|
|
417
|
+
this.db.prepare("DELETE FROM semantic_vectors WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)").run(message.messageId);
|
|
336
418
|
this.db.prepare("DELETE FROM chunks WHERE message_id = ?").run(message.messageId);
|
|
337
419
|
for (const chunk of buildChunks(message)) {
|
|
338
420
|
const result = this.db.prepare(`INSERT INTO chunks
|
|
@@ -353,8 +435,21 @@ export class Archive {
|
|
|
353
435
|
const rows = this.db.prepare("SELECT chunk_id, content_hash FROM chunks ORDER BY chunk_id").all();
|
|
354
436
|
return createHash("sha256").update(rows.map((row) => `${row.chunk_id}\0${row.content_hash}`).join("\0")).digest("hex");
|
|
355
437
|
}
|
|
438
|
+
async runExclusive(operation) {
|
|
439
|
+
const previous = this.operationTail;
|
|
440
|
+
let release = () => { };
|
|
441
|
+
const turn = new Promise((resolve) => { release = resolve; });
|
|
442
|
+
this.operationTail = previous.then(() => turn);
|
|
443
|
+
await previous;
|
|
444
|
+
try {
|
|
445
|
+
return await operation();
|
|
446
|
+
}
|
|
447
|
+
finally {
|
|
448
|
+
release();
|
|
449
|
+
}
|
|
450
|
+
}
|
|
356
451
|
async getEmbedder() {
|
|
357
|
-
this.embedder ??= await createEmbedder();
|
|
452
|
+
this.embedder ??= await createEmbedder(this.embedderConfig);
|
|
358
453
|
return this.embedder;
|
|
359
454
|
}
|
|
360
455
|
searchLexicalTable(table, query, filters, limit) {
|
|
@@ -426,6 +521,12 @@ function migrate(db) {
|
|
|
426
521
|
db.exec(`ALTER TABLE messages ADD COLUMN ${column} TEXT NOT NULL DEFAULT '[]'`);
|
|
427
522
|
}
|
|
428
523
|
}
|
|
524
|
+
function cleanupOrphanedSemanticRows(db) {
|
|
525
|
+
db.prepare(`DELETE FROM embedding_queue
|
|
526
|
+
WHERE NOT EXISTS (SELECT 1 FROM chunks WHERE chunks.chunk_id = embedding_queue.chunk_id)`).run();
|
|
527
|
+
db.prepare(`DELETE FROM semantic_vectors
|
|
528
|
+
WHERE NOT EXISTS (SELECT 1 FROM chunks WHERE chunks.chunk_id = semantic_vectors.chunk_id)`).run();
|
|
529
|
+
}
|
|
429
530
|
function literalFtsQuery(query) {
|
|
430
531
|
return query.trim().split(/\s+/).map((term) => `"${term.replaceAll('"', '""')}"`).join(" ");
|
|
431
532
|
}
|
|
@@ -462,6 +563,28 @@ function addFilters(clauses, params, filters) {
|
|
|
462
563
|
function normalizeCategory(value) {
|
|
463
564
|
return value.trim().toLocaleLowerCase().replace(/^category[_-]/, "").replace(/^label[_-]/, "");
|
|
464
565
|
}
|
|
566
|
+
function validateIdentities(messages) {
|
|
567
|
+
const providerKeys = new Set();
|
|
568
|
+
const messageIds = new Set();
|
|
569
|
+
for (const message of messages) {
|
|
570
|
+
if (!message.providerKey)
|
|
571
|
+
throw new Error("message provider identity is required");
|
|
572
|
+
if (providerKeys.has(message.providerKey))
|
|
573
|
+
throw new Error("duplicate message provider identity");
|
|
574
|
+
providerKeys.add(message.providerKey);
|
|
575
|
+
if (messageIds.has(message.messageId))
|
|
576
|
+
throw new Error("duplicate message identity");
|
|
577
|
+
messageIds.add(message.messageId);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
function validateStoredIdentities(db, messages) {
|
|
581
|
+
const byProviderKey = db.prepare("SELECT message_id FROM messages WHERE provider_key = ?");
|
|
582
|
+
for (const message of messages) {
|
|
583
|
+
const storedProvider = byProviderKey.get(message.providerKey);
|
|
584
|
+
if (storedProvider && storedProvider.message_id !== message.messageId)
|
|
585
|
+
throw new Error("provider identity already belongs to another message identity");
|
|
586
|
+
}
|
|
587
|
+
}
|
|
465
588
|
function countExcluded(messages, excluded) {
|
|
466
589
|
const counts = {};
|
|
467
590
|
for (const message of messages) {
|
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,38 +199,88 @@ 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
|
}
|
|
211
|
+
const semanticCommitted = typeof semantic === "object" && semantic !== null && "generation" in semantic;
|
|
180
212
|
output({
|
|
181
213
|
name: "mailcrawl",
|
|
182
|
-
archive:
|
|
183
|
-
archivePresent: existsSync(
|
|
184
|
-
fts:
|
|
214
|
+
archive: join(dataDir, "archive.sqlite"),
|
|
215
|
+
archivePresent: existsSync(join(dataDir, "archive.sqlite")),
|
|
216
|
+
fts: archive.status().fts,
|
|
185
217
|
semantic,
|
|
186
|
-
recommendation: semantic
|
|
218
|
+
recommendation: semanticCommitted ? "semantic index is committed" : "run sync, then index before semantic search",
|
|
187
219
|
}, options.json);
|
|
188
220
|
}
|
|
189
221
|
finally {
|
|
190
222
|
archive.close();
|
|
191
223
|
}
|
|
192
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
|
+
});
|
|
193
250
|
program
|
|
194
251
|
.command("repair")
|
|
195
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>")
|
|
196
262
|
.option("--json")
|
|
197
263
|
.action(async (options, command) => {
|
|
198
|
-
const
|
|
264
|
+
const dataDir = command.parent.opts().dataDir;
|
|
265
|
+
const archive = new Archive(join(dataDir, "archive.sqlite"));
|
|
199
266
|
try {
|
|
200
|
-
if (!options.fts)
|
|
201
|
-
throw new Error("pass --fts");
|
|
202
|
-
|
|
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);
|
|
203
284
|
}
|
|
204
285
|
finally {
|
|
205
286
|
archive.close();
|
|
@@ -211,7 +292,7 @@ attachments
|
|
|
211
292
|
.option("--message <messageId>")
|
|
212
293
|
.option("--json")
|
|
213
294
|
.action(async (options, command) => {
|
|
214
|
-
const archive = new Archive(
|
|
295
|
+
const archive = new Archive(join(command.parent.parent.opts().dataDir, "archive.sqlite"));
|
|
215
296
|
try {
|
|
216
297
|
output(archive.listAttachments(options.message), options.json);
|
|
217
298
|
}
|
|
@@ -221,12 +302,37 @@ attachments
|
|
|
221
302
|
});
|
|
222
303
|
program.parseAsync().catch((error) => {
|
|
223
304
|
const message = error instanceof Error ? error.message : String(error);
|
|
224
|
-
console.error(JSON.stringify({ error: message }));
|
|
305
|
+
console.error(JSON.stringify(redactDiagnostic({ error: message })));
|
|
225
306
|
process.exitCode = 1;
|
|
226
307
|
});
|
|
227
308
|
function filters(options) {
|
|
228
309
|
return { accountId: options.account, mailbox: options.mailbox, from: options.from, to: options.to, threadId: options.thread, after: options.after, before: options.before };
|
|
229
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
|
+
}
|
|
230
336
|
function output(value, json) {
|
|
231
337
|
if (json)
|
|
232
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/normalize.js
CHANGED
|
@@ -26,7 +26,18 @@ export async function normalizeMessage(input) {
|
|
|
26
26
|
const subject = input.subject.trim();
|
|
27
27
|
const messageId = input.messageId || input.providerKey;
|
|
28
28
|
const threadId = input.threadId || makeId(input.accountId, normalizeSubject(subject));
|
|
29
|
-
const
|
|
29
|
+
const attachmentInputs = (attachments || []).map((attachment) => [
|
|
30
|
+
attachment.name,
|
|
31
|
+
attachment.mimeType,
|
|
32
|
+
attachment.size ?? null,
|
|
33
|
+
attachment.text ?? null,
|
|
34
|
+
attachment.contentHash ?? null,
|
|
35
|
+
]);
|
|
36
|
+
const normalizedHash = hash(JSON.stringify([
|
|
37
|
+
subject, input.from, input.to, input.cc, latest, quoted,
|
|
38
|
+
input.labels, input.flags, input.classifications,
|
|
39
|
+
...(attachmentInputs.length ? [attachmentInputs] : []),
|
|
40
|
+
]));
|
|
30
41
|
const categories = [...new Set([
|
|
31
42
|
...(input.classifications || []),
|
|
32
43
|
...(input.labels || []),
|
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/dist/util.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export declare function hash(value: string): string;
|
|
2
2
|
export declare function normalizeSubject(subject: string): string;
|
|
3
3
|
export declare function makeId(...parts: string[]): string;
|
|
4
|
+
export declare function scopedId(accountId: string, mailbox: string, value: string): string;
|
|
4
5
|
export declare function snippet(text: string, query: string, maxChars?: number): string;
|
package/dist/util.js
CHANGED
|
@@ -8,6 +8,9 @@ export function normalizeSubject(subject) {
|
|
|
8
8
|
export function makeId(...parts) {
|
|
9
9
|
return hash(parts.join("\u0000")).slice(0, 24);
|
|
10
10
|
}
|
|
11
|
+
export function scopedId(accountId, mailbox, value) {
|
|
12
|
+
return makeId(accountId, mailbox, value);
|
|
13
|
+
}
|
|
11
14
|
export function snippet(text, query, maxChars = 240) {
|
|
12
15
|
const clean = text.trim();
|
|
13
16
|
if (clean.length <= maxChars)
|
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
|