@nomadamas/mailcrawl 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,8 +7,8 @@ account as its mail transport, then maintains a local normalized archive with
7
7
  incremental synchronization, email-aware chunking, full-text search (FTS5),
8
8
  semantic vector search, and hybrid retrieval.
9
9
 
10
- > Status: concept scaffold. The architecture is documented before the first
11
- > implementation increment.
10
+ > Status: first functional release candidate. Use the installation guide for
11
+ > multilingual analyzer setup before indexing production mail.
12
12
 
13
13
  ## Product goal
14
14
 
@@ -26,20 +26,35 @@ The CLI owns email synchronization and indexes. Consumers such as AutoRAG,
26
26
  OpenClaw, MCP servers, Raycast, and custom scripts consume its stable JSON
27
27
  surface instead of opening the archive database.
28
28
 
29
- ## Planned capabilities
29
+ ## Capabilities
30
30
 
31
31
  - Himalaya-backed IMAP/JMAP/Gmail/Microsoft Graph/Maildir access
32
32
  - Stable account/mailbox/message identity and cursor state
33
33
  - MIME normalization, HTML-to-text conversion, and quoted-reply handling
34
34
  - Email-aware, thread-aware chunking
35
35
  - Incremental archive, FTS5, and embedding updates
36
- - LanceDB vector storage with configurable local embedding providers
36
+ - Local EmbeddingGemma vector storage with Transformers.js and ONNX Runtime
37
37
  - FTS, semantic, and hybrid search modes
38
38
  - JSON output, bounded diagnostics, `status`, `doctor`, and `repair`
39
39
  - No credential values in logs, diagnostics, or indexed metadata by default
40
40
 
41
- See [`docs/architecture.md`](docs/architecture.md) for the proposed design,
42
- data model, CLI contract, and implementation milestones.
41
+ See [`docs/architecture.md`](docs/architecture.md) for the data model and CLI
42
+ contract. Lexical analyzer changes invalidate language-specific FTS fields;
43
+ run `mailcrawl sync` to rebuild them before multilingual search. Semantic model
44
+ changes require `mailcrawl index` to create a new vector generation.
45
+
46
+ When a lexical analyzer or its model changes, the stored analyzer fingerprint
47
+ invalidates all language-specific FTS fields. Run a complete `mailcrawl sync`
48
+ before multilingual search; the command re-analyzes existing messages and
49
+ atomically records the new fingerprint. Embedding model changes are independent
50
+ and require a new `mailcrawl index` generation.
51
+
52
+ ## Installation
53
+
54
+ For the required Node setup, Kiwi model files, Go installation, Japanese and
55
+ Chinese helper builds, environment variables, smoke tests, and license
56
+ requirements, follow [`docs/multilingual-installation.md`](docs/multilingual-installation.md)
57
+ before using multilingual indexing or search.
43
58
 
44
59
  ## Releasing
45
60
 
@@ -54,8 +69,12 @@ One-time setup:
54
69
  - Workflow filename: `release.yml`
55
70
  - Environment: `release`
56
71
 
57
- Then bump the version, push `main`, and publish a GitHub Release whose tag is `v<version>`. The workflow tests, builds, publishes with provenance, and attaches the tarball to the release.
72
+ Then bump the version, push `main`, and publish a GitHub Release whose tag is
73
+ `v<version>`. The workflow tests, builds, publishes with OIDC, and attaches the
74
+ tarball to the release. Public npm provenance is unavailable while this source
75
+ repository remains private.
58
76
 
59
77
  ## License
60
78
 
61
- MIT. This project is not yet a production release.
79
+ MIT. See [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) for runtime
80
+ component licenses.
@@ -0,0 +1,50 @@
1
+ # Third-party notices
2
+
3
+ mailcrawl is MIT-licensed. Runtime components retain their upstream licenses.
4
+
5
+ The application source remains MIT. These notices are for bundled source and
6
+ runtime dependencies; they do not relicense mailcrawl itself.
7
+
8
+ ## Kiwi / kiwi-nlp
9
+
10
+ - Package: https://www.npmjs.com/package/kiwi-nlp
11
+ - Source: https://github.com/bab2min/Kiwi
12
+ - License: LGPL-2.1-or-later
13
+
14
+ The Kiwi package is not modified by mailcrawl.
15
+
16
+ ## Japanese helper
17
+
18
+ `tools/mailcrawl-ja` follows the Kagome helper design used by discrawl PR
19
+ #180 and is built separately from the default Node package.
20
+ Kagome does not currently provide an official Node binding.
21
+
22
+ ## Kagome and IPADIC
23
+
24
+ - Kagome: https://github.com/ikawaha/kagome (MIT)
25
+ - Kagome dictionary: https://github.com/ikawaha/kagome-dict
26
+ (IPADIC/ICOT terms apply)
27
+
28
+ ## GSE
29
+
30
+ - Source: https://github.com/go-ego/gse
31
+ - License: Apache-2.0
32
+
33
+ `tools/mailcrawl-zh` uses the same persistent helper protocol as discrawl PR
34
+ #180 and is built separately from the default Node package.
35
+ The GSE repository mentions `gse-bind`, but no usable npm package was available
36
+ at integration time, so this remains a separate Go helper.
37
+
38
+ ## EmbeddingGemma
39
+
40
+ - Model: https://huggingface.co/onnx-community/embeddinggemma-300m-ONNX
41
+ - Base model: Google EmbeddingGemma
42
+ - Terms: https://ai.google.dev/gemma/terms
43
+
44
+ The model is downloaded and executed locally through Transformers.js and ONNX
45
+ Runtime. Model weights are not redistributed in the mailcrawl package.
46
+
47
+ ## Transformers.js
48
+
49
+ - Source: https://github.com/huggingface/transformers.js
50
+ - License: Apache-2.0
package/dist/archive.d.ts CHANGED
@@ -2,27 +2,30 @@ import Database from "better-sqlite3";
2
2
  import type { ClassificationPolicy, Chunk, MailMessage, NormalizedMessage, SearchFilters, SearchHit, SyncReport } from "./types.js";
3
3
  export declare class Archive {
4
4
  readonly db: Database.Database;
5
+ private embedder?;
6
+ private lexical?;
7
+ private lexicalRebuildRequired;
5
8
  constructor(path?: string);
6
9
  close(): void;
7
10
  sync(messages: MailMessage[], policy?: ClassificationPolicy): Promise<SyncReport>;
8
- searchBm25(query: string, filters?: SearchFilters, limit?: number): SearchHit[];
9
- indexSemantic(): {
11
+ searchBm25(query: string, filters?: SearchFilters, limit?: number): Promise<SearchHit[]>;
12
+ indexSemantic(): Promise<{
10
13
  embedded: number;
11
14
  reused: number;
12
15
  archiveRevision: string;
13
- };
14
- indexSemanticGeneration(root: string): {
16
+ }>;
17
+ indexSemanticGeneration(root: string): Promise<{
15
18
  generation: string;
16
19
  embedded: number;
17
20
  reused: number;
18
- };
21
+ }>;
19
22
  semanticGeneration(root: string): {
20
23
  generation: string;
21
24
  archiveRevision: string;
22
25
  vectorCount: number;
23
26
  };
24
- searchSemantic(query: string, filters?: SearchFilters, limit?: number): SearchHit[];
25
- searchHybrid(query: string, filters?: SearchFilters, limit?: number): SearchHit[];
27
+ searchSemantic(query: string, filters?: SearchFilters, limit?: number): Promise<SearchHit[]>;
28
+ searchHybrid(query: string, filters?: SearchFilters, limit?: number): Promise<SearchHit[]>;
26
29
  getMessage(messageId: string): NormalizedMessage | undefined;
27
30
  listAttachments(messageId?: string): Array<{
28
31
  attachmentId: string;
@@ -52,4 +55,6 @@ export declare class Archive {
52
55
  private removeMessage;
53
56
  private replaceMessageChunks;
54
57
  private revision;
58
+ private getEmbedder;
59
+ private searchLexicalTable;
55
60
  }
package/dist/archive.js CHANGED
@@ -5,15 +5,30 @@ import { join } from "node:path";
5
5
  import { buildChunks } from "./chunk.js";
6
6
  import { normalizeMessage } from "./normalize.js";
7
7
  import { snippet } from "./util.js";
8
+ import { createEmbedder, embeddingModelName } from "./embedding.js";
9
+ import { createLexicalAnalyzers, languagesForText, lexicalFields, LEXICAL_ANALYZER_VERSION } from "./lexical.js";
8
10
  export class Archive {
9
11
  db;
12
+ embedder;
13
+ lexical;
14
+ lexicalRebuildRequired;
10
15
  constructor(path = ":memory:") {
11
16
  this.db = new Database(path);
12
17
  this.db.pragma("journal_mode = WAL");
13
18
  this.db.pragma("foreign_keys = ON");
14
19
  migrate(this.db);
20
+ this.lexicalRebuildRequired = this.db.prepare("SELECT version FROM lexical_index_meta WHERE id = 1").get() === undefined;
21
+ if (!this.lexicalRebuildRequired) {
22
+ const row = this.db.prepare("SELECT version FROM lexical_index_meta WHERE id = 1").get();
23
+ this.lexicalRebuildRequired = row.version !== LEXICAL_ANALYZER_VERSION;
24
+ }
25
+ if (this.lexicalRebuildRequired) {
26
+ for (const language of lexicalFields())
27
+ this.db.exec(`DELETE FROM chunks_fts_${language}`);
28
+ }
15
29
  }
16
30
  close() {
31
+ void this.lexical?.close();
17
32
  this.db.close();
18
33
  }
19
34
  async sync(messages, policy = {}) {
@@ -33,6 +48,25 @@ export class Archive {
33
48
  let updated = 0;
34
49
  let unchanged = 0;
35
50
  const touched = new Set();
51
+ const rebuildLexical = this.lexicalRebuildRequired;
52
+ const required = [...new Set(included.flatMap((message) => languagesForText(`${message.subject} ${message.text}`)))];
53
+ if (required.length)
54
+ this.lexical = await createLexicalAnalyzers(required);
55
+ const analyzedChunks = new Map();
56
+ for (const message of included) {
57
+ const oldHash = previous.get(message.providerKey);
58
+ if (!this.lexicalRebuildRequired && oldHash === message.normalizedHash)
59
+ continue;
60
+ for (const chunk of buildChunks(message)) {
61
+ const tokens = new Map();
62
+ for (const language of lexicalFields()) {
63
+ tokens.set(language, languagesForText(chunk.text).includes(language)
64
+ ? await this.lexical.tokenize(language, chunk.text)
65
+ : "");
66
+ }
67
+ analyzedChunks.set(chunk.chunkId, tokens);
68
+ }
69
+ }
36
70
  const transaction = this.db.transaction((items) => {
37
71
  for (const message of items) {
38
72
  const oldHash = previous.get(message.providerKey);
@@ -45,11 +79,15 @@ export class Archive {
45
79
  if (oldHash !== message.normalizedHash)
46
80
  touched.add(message.threadId);
47
81
  this.upsertMessage(message);
48
- if (oldHash !== message.normalizedHash)
49
- this.replaceMessageChunks(message);
82
+ if (rebuildLexical || oldHash !== message.normalizedHash)
83
+ this.replaceMessageChunks(message, analyzedChunks);
50
84
  }
51
85
  });
52
86
  transaction(included);
87
+ this.db.prepare(`INSERT INTO lexical_index_meta(id, version, rebuilt_at)
88
+ VALUES (1, ?, CURRENT_TIMESTAMP)
89
+ ON CONFLICT(id) DO UPDATE SET version=excluded.version, rebuilt_at=excluded.rebuilt_at`).run(LEXICAL_ANALYZER_VERSION);
90
+ this.lexicalRebuildRequired = false;
53
91
  for (const message of excluded)
54
92
  this.removeMessage(message);
55
93
  const chunks = Number(this.db.prepare("SELECT COUNT(*) AS count FROM chunks").get().count);
@@ -62,43 +100,62 @@ export class Archive {
62
100
  excludedByReason: countExcluded(excluded, excludedCategories),
63
101
  };
64
102
  }
65
- searchBm25(query, filters = {}, limit = 10) {
103
+ async searchBm25(query, filters = {}, limit = 10) {
66
104
  if (!query.trim())
67
105
  throw new Error("empty query");
68
- const clauses = ["chunks_fts MATCH ?"];
69
- const params = [literalFtsQuery(query)];
70
- addFilters(clauses, params, filters);
71
- const sql = `SELECT c.chunk_id, c.message_id, c.thread_id, c.account_id, c.mailbox,
72
- m.subject, m.from_address, m.to_addresses, m.date, snippet(chunks_fts, 0, '[', ']', '…', 32) AS snippet,
73
- bm25(chunks_fts, 8.0, 5.0, 2.0, 1.0, 1.0, 1.0, 0.5) AS score
74
- FROM chunks_fts JOIN chunks c ON c.rowid = chunks_fts.rowid
75
- JOIN messages m ON m.message_id = c.message_id
76
- WHERE ${clauses.join(" AND ")} ORDER BY score LIMIT ?`;
77
- params.push(limit);
78
- return this.db.prepare(sql).all(...params).map((row) => hydrate(row, "bm25", query));
106
+ if (this.lexicalRebuildRequired && languagesForText(query).length) {
107
+ throw new Error("lexical indexes are stale; run sync before multilingual search");
108
+ }
109
+ const languages = languagesForText(query);
110
+ if (languages.length)
111
+ this.lexical ??= await createLexicalAnalyzers(languages);
112
+ const lists = [this.searchLexicalTable("chunks_fts", query, filters, limit * 2)];
113
+ for (const language of languagesForText(query)) {
114
+ lists.push(this.searchLexicalTable(`chunks_fts_${language}`, await this.lexical.tokenize(language, query), filters, limit * 2));
115
+ }
116
+ const merged = new Map();
117
+ const k = 60;
118
+ for (const list of lists) {
119
+ for (const [index, hit] of list.entries()) {
120
+ const rank = index + 1;
121
+ const prior = merged.get(hit.chunkId);
122
+ merged.set(hit.chunkId, prior ? { hit: prior.hit, score: prior.score + 1 / (k + rank) } : { hit, score: 1 / (k + rank) });
123
+ }
124
+ }
125
+ return [...merged.values()].sort((a, b) => b.score - a.score || a.hit.chunkId.localeCompare(b.hit.chunkId))
126
+ .slice(0, limit).map(({ hit, score }) => ({ ...hit, score }));
79
127
  }
80
- indexSemantic() {
128
+ async indexSemantic() {
81
129
  const rows = this.db.prepare("SELECT chunk_id, text, content_hash FROM chunks ORDER BY chunk_id").all();
82
130
  let embedded = 0;
83
131
  let reused = 0;
84
132
  const upsert = this.db.prepare(`INSERT INTO semantic_vectors
85
- (chunk_id, content_hash, vector) VALUES (?, ?, ?)
86
- ON CONFLICT(chunk_id) DO UPDATE SET content_hash=excluded.content_hash, vector=excluded.vector`);
133
+ (chunk_id, content_hash, model, vector) VALUES (?, ?, ?, ?)
134
+ 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
+ const pending = [];
87
137
  const transaction = this.db.transaction(() => {
88
138
  for (const row of rows) {
89
- const old = this.db.prepare("SELECT content_hash FROM semantic_vectors WHERE chunk_id = ?").get(row.chunk_id);
90
- if (old?.content_hash === row.content_hash) {
139
+ 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()) {
91
141
  reused++;
92
142
  continue;
93
143
  }
94
- upsert.run(row.chunk_id, row.content_hash, JSON.stringify(embed(row.text)));
95
- embedded++;
144
+ pending.push(row);
96
145
  }
97
146
  });
98
147
  transaction();
148
+ const vectors = await embedder.embedDocuments(pending.map((row) => row.text));
149
+ const write = this.db.transaction(() => {
150
+ for (const [index, row] of pending.entries()) {
151
+ upsert.run(row.chunk_id, row.content_hash, embeddingModelName(), JSON.stringify(vectors[index]));
152
+ embedded++;
153
+ }
154
+ });
155
+ write();
99
156
  return { embedded, reused, archiveRevision: this.revision() };
100
157
  }
101
- indexSemanticGeneration(root) {
158
+ async indexSemanticGeneration(root) {
102
159
  const currentPath = join(root, "CURRENT");
103
160
  const generationRoot = join(root, "generations");
104
161
  mkdirSync(generationRoot, { recursive: true });
@@ -106,10 +163,10 @@ export class Archive {
106
163
  const staging = join(generationRoot, `.${generation}.staging`);
107
164
  mkdirSync(staging);
108
165
  try {
109
- const report = this.indexSemantic();
166
+ const report = await this.indexSemantic();
110
167
  const vectors = this.db.prepare("SELECT chunk_id, content_hash, vector FROM semantic_vectors ORDER BY chunk_id").all();
111
168
  writeFileSync(join(staging, "manifest.json"), JSON.stringify({
112
- archiveRevision: this.revision(), vectors, model: "local-hash-v1",
169
+ archiveRevision: this.revision(), vectors, model: embeddingModelName(),
113
170
  }));
114
171
  renameSync(staging, join(generationRoot, generation));
115
172
  const pointer = join(root, `.CURRENT.${process.pid}`);
@@ -127,10 +184,10 @@ export class Archive {
127
184
  const manifest = JSON.parse(readFileSync(join(root, "generations", generation, "manifest.json"), "utf8"));
128
185
  return { generation, archiveRevision: manifest.archiveRevision, vectorCount: manifest.vectors.length };
129
186
  }
130
- searchSemantic(query, filters = {}, limit = 10) {
187
+ async searchSemantic(query, filters = {}, limit = 10) {
131
188
  if (!query.trim())
132
189
  throw new Error("empty query");
133
- const queryVector = embed(query);
190
+ const queryVector = await (await this.getEmbedder()).embedQuery(query);
134
191
  const clauses = ["1 = 1"];
135
192
  const params = [];
136
193
  addFilters(clauses, params, filters);
@@ -148,18 +205,24 @@ export class Archive {
148
205
  snippet: snippet(row.text, query), score, mode: "semantic",
149
206
  }));
150
207
  }
151
- searchHybrid(query, filters = {}, limit = 10) {
152
- const lexical = this.searchBm25(query, filters, limit * 2);
153
- const semantic = this.searchSemantic(query, filters, limit * 2);
208
+ async searchHybrid(query, filters = {}, limit = 10) {
209
+ const lexical = await this.searchBm25(query, filters, limit * 2);
210
+ const semantic = await this.searchSemantic(query, filters, limit * 2);
154
211
  const merged = new Map();
212
+ const k = 60;
155
213
  for (const [index, hit] of lexical.entries())
156
- merged.set(hit.chunkId, { ...hit, score: 0.5 * (1 - index / Math.max(1, lexical.length)) });
214
+ merged.set(hit.chunkId, { hit: { ...hit, mode: "hybrid" }, score: 1 / (k + index + 1) });
157
215
  for (const [index, hit] of semantic.entries()) {
158
216
  const prior = merged.get(hit.chunkId);
159
- const score = 0.5 * (1 - index / Math.max(1, semantic.length));
160
- merged.set(hit.chunkId, prior ? { ...prior, score: prior.score + score, mode: "hybrid" } : { ...hit, score, mode: "hybrid" });
217
+ const score = 1 / (k + index + 1);
218
+ merged.set(hit.chunkId, prior
219
+ ? { hit: { ...prior.hit, mode: "hybrid" }, score: prior.score + score }
220
+ : { hit: { ...hit, mode: "hybrid" }, score });
161
221
  }
162
- return [...merged.values()].sort((a, b) => b.score - a.score || a.chunkId.localeCompare(b.chunkId)).slice(0, limit);
222
+ return [...merged.values()]
223
+ .sort((a, b) => b.score - a.score || a.hit.chunkId.localeCompare(b.hit.chunkId))
224
+ .slice(0, limit)
225
+ .map(({ hit, score }) => ({ ...hit, score }));
163
226
  }
164
227
  getMessage(messageId) {
165
228
  const row = this.db.prepare("SELECT * FROM messages WHERE message_id = ?").get(messageId);
@@ -207,6 +270,8 @@ export class Archive {
207
270
  const rows = this.db.prepare("SELECT rowid, chunk_id, text, message_id FROM chunks").all();
208
271
  const rebuild = this.db.transaction(() => {
209
272
  this.db.exec("DELETE FROM chunks_fts");
273
+ for (const language of lexicalFields())
274
+ this.db.exec(`DELETE FROM chunks_fts_${language}`);
210
275
  for (const row of rows) {
211
276
  const message = this.db.prepare("SELECT * FROM messages WHERE message_id = ?").get(row.message_id);
212
277
  this.db.prepare(`INSERT INTO chunks_fts(rowid, subject, from_address, to_addresses, thread_subject,
@@ -215,6 +280,8 @@ export class Archive {
215
280
  }
216
281
  });
217
282
  rebuild();
283
+ this.db.prepare("DELETE FROM lexical_index_meta WHERE id = 1").run();
284
+ this.lexicalRebuildRequired = true;
218
285
  return { rows: rows.length, status: "repaired" };
219
286
  }
220
287
  upsertMessage(message) {
@@ -252,14 +319,18 @@ export class Archive {
252
319
  const rows = this.db.prepare("SELECT rowid FROM chunks WHERE message_id = ?").all(stored.message_id);
253
320
  for (const row of rows)
254
321
  this.db.prepare("DELETE FROM chunks_fts WHERE rowid = ?").run(row.rowid);
322
+ for (const language of lexicalFields())
323
+ this.db.prepare(`DELETE FROM chunks_fts_${language} WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)`).run(stored.message_id);
255
324
  this.db.prepare("DELETE FROM embedding_queue WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)").run(stored.message_id);
256
325
  this.db.prepare("DELETE FROM semantic_vectors WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)").run(stored.message_id);
257
326
  this.db.prepare("DELETE FROM messages WHERE message_id = ?").run(stored.message_id);
258
327
  }
259
- replaceMessageChunks(message) {
328
+ replaceMessageChunks(message, analyzedChunks) {
260
329
  const old = this.db.prepare("SELECT rowid, chunk_id FROM chunks WHERE message_id = ?").all(message.messageId);
261
330
  for (const row of old)
262
331
  this.db.prepare("DELETE FROM chunks_fts WHERE rowid = ?").run(row.rowid);
332
+ for (const language of lexicalFields())
333
+ this.db.prepare(`DELETE FROM chunks_fts_${language} WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)`).run(message.messageId);
263
334
  this.db.prepare("DELETE FROM chunks WHERE message_id = ?").run(message.messageId);
264
335
  for (const chunk of buildChunks(message)) {
265
336
  const result = this.db.prepare(`INSERT INTO chunks
@@ -268,6 +339,10 @@ export class Archive {
268
339
  this.db.prepare(`INSERT INTO chunks_fts(rowid, subject, from_address, to_addresses, thread_subject,
269
340
  body_latest, body_quoted, forwarded_text, attachment_text)
270
341
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(result.lastInsertRowid, message.subject, message.from, message.to.join(" "), message.normalizedSubject, chunk.section === "latest" ? chunk.text : "", chunk.section === "quoted" ? chunk.text : "", "", chunk.section === "attachment" ? chunk.text : "");
342
+ for (const language of lexicalFields()) {
343
+ this.db.prepare(`INSERT INTO chunks_fts_${language}(chunk_id, text, subject, from_address, to_addresses)
344
+ VALUES (?, ?, ?, ?, ?)`).run(chunk.chunkId, analyzedChunks?.get(chunk.chunkId)?.get(language) ?? "", message.subject, message.from, message.to.join(" "));
345
+ }
271
346
  this.db.prepare(`INSERT INTO embedding_queue(chunk_id, content_hash, state, attempts)
272
347
  VALUES (?, ?, 'pending', 0) ON CONFLICT(chunk_id) DO UPDATE SET content_hash=excluded.content_hash, state='pending'`).run(chunk.chunkId, chunk.contentHash);
273
348
  }
@@ -276,6 +351,26 @@ export class Archive {
276
351
  const rows = this.db.prepare("SELECT chunk_id, content_hash FROM chunks ORDER BY chunk_id").all();
277
352
  return createHash("sha256").update(rows.map((row) => `${row.chunk_id}\0${row.content_hash}`).join("\0")).digest("hex");
278
353
  }
354
+ async getEmbedder() {
355
+ this.embedder ??= await createEmbedder();
356
+ return this.embedder;
357
+ }
358
+ searchLexicalTable(table, query, filters, limit) {
359
+ const clauses = [`${table} MATCH ?`];
360
+ const params = [literalFtsQuery(query)];
361
+ addFilters(clauses, params, filters);
362
+ const join = table === "chunks_fts"
363
+ ? `JOIN chunks c ON c.rowid = ${table}.rowid`
364
+ : `JOIN chunks c ON c.chunk_id = ${table}.chunk_id`;
365
+ const sql = `SELECT c.chunk_id, c.message_id, c.thread_id, c.account_id, c.mailbox,
366
+ m.subject, m.from_address, m.to_addresses, m.date,
367
+ bm25(${table}) AS score
368
+ FROM ${table} ${join}
369
+ JOIN messages m ON m.message_id = c.message_id
370
+ WHERE ${clauses.join(" AND ")} ORDER BY score LIMIT ?`;
371
+ params.push(limit);
372
+ return this.db.prepare(sql).all(...params).map((row) => hydrate(row, "bm25", query));
373
+ }
279
374
  }
280
375
  function migrate(db) {
281
376
  db.exec(`CREATE TABLE IF NOT EXISTS messages (
@@ -298,16 +393,30 @@ function migrate(db) {
298
393
  chunk_id TEXT PRIMARY KEY, content_hash TEXT NOT NULL, state TEXT NOT NULL, attempts INTEGER NOT NULL
299
394
  );
300
395
  CREATE TABLE IF NOT EXISTS semantic_vectors (
301
- chunk_id TEXT PRIMARY KEY, content_hash TEXT NOT NULL, vector TEXT NOT NULL
396
+ chunk_id TEXT PRIMARY KEY, content_hash TEXT NOT NULL, model TEXT NOT NULL DEFAULT '', vector TEXT NOT NULL
302
397
  );
303
398
  CREATE TABLE IF NOT EXISTS attachments (
304
399
  attachment_id TEXT PRIMARY KEY, message_id TEXT NOT NULL REFERENCES messages(message_id) ON DELETE CASCADE,
305
400
  name TEXT NOT NULL, mime_type TEXT NOT NULL, size INTEGER, content_hash TEXT, extracted_text TEXT
306
401
  );
402
+ CREATE TABLE IF NOT EXISTS lexical_index_meta (
403
+ id INTEGER PRIMARY KEY CHECK (id = 1), version TEXT NOT NULL, rebuilt_at TEXT NOT NULL
404
+ );
307
405
  CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
308
406
  subject, from_address, to_addresses, thread_subject, body_latest,
309
- body_quoted, forwarded_text, attachment_text
407
+ body_quoted, forwarded_text, attachment_text,
408
+ tokenize = 'unicode61'
310
409
  );`);
410
+ const vectorColumns = db.prepare("PRAGMA table_info(semantic_vectors)").all();
411
+ if (!vectorColumns.some((column) => column.name === "model"))
412
+ db.exec("ALTER TABLE semantic_vectors ADD COLUMN model TEXT NOT NULL DEFAULT ''");
413
+ for (const language of lexicalFields()) {
414
+ const table = `chunks_fts_${language}`;
415
+ db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS ${table} USING fts5(
416
+ chunk_id UNINDEXED, text, subject, from_address, to_addresses,
417
+ tokenize = 'unicode61'
418
+ );`);
419
+ }
311
420
  const columns = db.prepare("PRAGMA table_info(messages)").all();
312
421
  const existingColumns = new Set(columns.map((column) => column.name));
313
422
  for (const column of ["labels", "flags", "classifications"]) {
package/dist/cli/index.js CHANGED
@@ -43,7 +43,7 @@ program
43
43
  const dataDir = command.parent.opts().dataDir;
44
44
  const archive = new Archive(`${dataDir}/archive.sqlite`);
45
45
  try {
46
- output({ ...archive.indexSemanticGeneration(`${dataDir}/semantic`), embedder: "local-hash-v1" }, options.json);
46
+ output({ ...await archive.indexSemanticGeneration(`${dataDir}/semantic`), embedder: "onnx-community/embeddinggemma-300m-ONNX" }, options.json);
47
47
  }
48
48
  finally {
49
49
  archive.close();
@@ -67,10 +67,10 @@ for (const mode of ["bm25", "keyword", "semantic", "hybrid"]) {
67
67
  const archive = new Archive(`${dataDir}/archive.sqlite`);
68
68
  try {
69
69
  const result = mode === "bm25"
70
- ? archive.searchBm25(query, filters(options), Number(options.limit))
70
+ ? await archive.searchBm25(query, filters(options), Number(options.limit))
71
71
  : mode === "hybrid"
72
- ? archive.searchHybrid(query, filters(options), Number(options.limit))
73
- : archive.searchSemantic(query, filters(options), Number(options.limit));
72
+ ? await archive.searchHybrid(query, filters(options), Number(options.limit))
73
+ : await archive.searchSemantic(query, filters(options), Number(options.limit));
74
74
  output(result, options.json);
75
75
  }
76
76
  finally {
@@ -96,9 +96,9 @@ program
96
96
  try {
97
97
  const filter = filters(options);
98
98
  const limit = Number(options.limit);
99
- const result = options.mode === "bm25" ? archive.searchBm25(query, filter, limit)
100
- : options.mode === "semantic" ? archive.searchSemantic(query, filter, limit)
101
- : options.mode === "hybrid" ? archive.searchHybrid(query, filter, limit)
99
+ const result = options.mode === "bm25" ? await archive.searchBm25(query, filter, limit)
100
+ : options.mode === "semantic" ? await archive.searchSemantic(query, filter, limit)
101
+ : options.mode === "hybrid" ? await archive.searchHybrid(query, filter, limit)
102
102
  : (() => { throw new Error(`unsupported search mode: ${options.mode}`); })();
103
103
  output(result, options.json);
104
104
  }
@@ -0,0 +1,6 @@
1
+ export interface Embedder {
2
+ embedDocuments(texts: string[]): Promise<number[][]>;
3
+ embedQuery(query: string): Promise<number[]>;
4
+ }
5
+ export declare function createEmbedder(): Promise<Embedder>;
6
+ export declare function embeddingModelName(): string;
@@ -0,0 +1,54 @@
1
+ import { pipeline } from "@huggingface/transformers";
2
+ const EMBEDDING_MODEL = "onnx-community/embeddinggemma-300m-ONNX";
3
+ const QUERY_PREFIX = "task: search result | query: ";
4
+ const DOCUMENT_PREFIX = "title: none | text: ";
5
+ class EmbeddingGemma {
6
+ model;
7
+ constructor(model) {
8
+ this.model = model;
9
+ }
10
+ static async create() {
11
+ const model = await pipeline("feature-extraction", EMBEDDING_MODEL, {
12
+ dtype: "q8",
13
+ device: "cpu",
14
+ });
15
+ return new EmbeddingGemma(model);
16
+ }
17
+ async embedDocuments(texts) {
18
+ return this.embed(texts.map((text) => DOCUMENT_PREFIX + text));
19
+ }
20
+ async embedQuery(query) {
21
+ return (await this.embed([QUERY_PREFIX + query.trim()]))[0];
22
+ }
23
+ async embed(texts) {
24
+ const output = await this.model(texts, { pooling: "mean", normalize: true });
25
+ return output.tolist();
26
+ }
27
+ }
28
+ class TestEmbedder {
29
+ async embedDocuments(texts) {
30
+ return texts.map(hashVector);
31
+ }
32
+ async embedQuery(query) {
33
+ return hashVector(query);
34
+ }
35
+ }
36
+ export async function createEmbedder() {
37
+ if (process.env.MAILCRAWL_EMBEDDER === "mock" || process.env.NODE_ENV === "test")
38
+ return new TestEmbedder();
39
+ return EmbeddingGemma.create();
40
+ }
41
+ export function embeddingModelName() {
42
+ return EMBEDDING_MODEL;
43
+ }
44
+ function hashVector(text) {
45
+ const vector = new Array(128).fill(0);
46
+ for (const [index, term] of text.normalize("NFKC").toLocaleLowerCase().split(/\s+/u).entries()) {
47
+ let hash = 2166136261;
48
+ for (const char of term)
49
+ hash = Math.imul(hash ^ char.codePointAt(0), 16777619);
50
+ vector[Math.abs(hash + index) % vector.length] += 1;
51
+ }
52
+ const magnitude = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0)) || 1;
53
+ return vector.map((value) => value / magnitude);
54
+ }
@@ -0,0 +1,10 @@
1
+ import type { LexicalLanguage } from "./types.js";
2
+ export declare const LEXICAL_ANALYZER_VERSION = "kiwi-nlp@0.23.x+kagome-ipa-search+gse-search+arabic-light-v1";
3
+ export declare function lexicalFields(): LexicalLanguage[];
4
+ export declare function languagesForText(text: string): LexicalLanguage[];
5
+ export interface LexicalAnalyzers {
6
+ tokenize(language: LexicalLanguage, text: string): Promise<string>;
7
+ close(): Promise<void>;
8
+ }
9
+ export declare function createLexicalAnalyzers(required?: LexicalLanguage[]): Promise<LexicalAnalyzers>;
10
+ export declare function tokenizeForLanguage(language: LexicalLanguage, text: string): string;
@@ -0,0 +1,151 @@
1
+ import { KiwiBuilder, Match } from "kiwi-nlp";
2
+ import { spawn } from "node:child_process";
3
+ import { createInterface } from "node:readline";
4
+ import { createRequire } from "node:module";
5
+ import { readFileSync, readdirSync } from "node:fs";
6
+ export const LEXICAL_ANALYZER_VERSION = "kiwi-nlp@0.23.x+kagome-ipa-search+gse-search+arabic-light-v1";
7
+ export function lexicalFields() {
8
+ return ["ko", "ja", "zh", "ar"];
9
+ }
10
+ export function languagesForText(text) {
11
+ const languages = [];
12
+ if (/[\uac00-\ud7a3]/u.test(text))
13
+ languages.push("ko");
14
+ if (/[\u3040-\u30ff]/u.test(text))
15
+ languages.push("ja");
16
+ if (/[\u4e00-\u9fff]/u.test(text))
17
+ languages.push("zh");
18
+ if (/[\u0600-\u06ff]/u.test(text))
19
+ languages.push("ar");
20
+ return languages;
21
+ }
22
+ export async function createLexicalAnalyzers(required = lexicalFields()) {
23
+ if (process.env.NODE_ENV === "test" || process.env.MAILCRAWL_LEXICAL_MODE === "mock") {
24
+ return { tokenize: async (language, text) => tokenizeMock(language, text), close: async () => undefined };
25
+ }
26
+ return new ConfiguredAnalyzers(required.includes("ko") ? await KiwiAnalyzer.create() : undefined, required.includes("ja") ? await HelperAnalyzer.create("ja", process.env.MAILCRAWL_JA_HELPER, "mailcrawl-ja") : undefined, required.includes("zh") ? await HelperAnalyzer.create("zh", process.env.MAILCRAWL_ZH_HELPER, "mailcrawl-zh") : undefined);
27
+ }
28
+ class ConfiguredAnalyzers {
29
+ korean;
30
+ japanese;
31
+ chinese;
32
+ constructor(korean, japanese, chinese) {
33
+ this.korean = korean;
34
+ this.japanese = japanese;
35
+ this.chinese = chinese;
36
+ }
37
+ tokenize(language, text) {
38
+ if (language === "ko" && this.korean)
39
+ return this.korean.tokenize(text);
40
+ if (language === "ja" && this.japanese)
41
+ return this.japanese.tokenize(text);
42
+ if (language === "zh" && this.chinese)
43
+ return this.chinese.tokenize(text);
44
+ return Promise.resolve(tokenizeArabic(text));
45
+ }
46
+ async close() {
47
+ await Promise.all([this.korean?.close(), this.japanese?.close(), this.chinese?.close()]);
48
+ }
49
+ }
50
+ class KiwiAnalyzer {
51
+ kiwi;
52
+ constructor(kiwi) {
53
+ this.kiwi = kiwi;
54
+ }
55
+ static async create() {
56
+ const wasmPath = process.env.MAILCRAWL_KIWI_WASM
57
+ ?? createRequire(import.meta.url).resolve("kiwi-nlp/dist/kiwi-wasm.wasm");
58
+ const modelDir = process.env.MAILCRAWL_KIWI_MODEL;
59
+ if (!wasmPath || !modelDir) {
60
+ throw new Error("Korean analyzer requires MAILCRAWL_KIWI_WASM and MAILCRAWL_KIWI_MODEL");
61
+ }
62
+ const builder = await KiwiBuilder.create(wasmPath);
63
+ const modelFiles = Object.fromEntries(readdirSync(modelDir, { withFileTypes: true })
64
+ .filter((entry) => entry.isFile())
65
+ .map((entry) => [entry.name, readFileSync(`${modelDir}/${entry.name}`)]));
66
+ if (!Object.keys(modelFiles).length)
67
+ throw new Error(`Korean Kiwi model directory is empty: ${modelDir}`);
68
+ return new KiwiAnalyzer(await builder.build({ modelFiles, modelType: "cong", loadDefaultDict: true, loadTypoDict: true }));
69
+ }
70
+ async tokenize(text) {
71
+ return this.kiwi.tokenize(text, Match.allWithNormalizing).map((token) => token.str).join(" ");
72
+ }
73
+ async close() { }
74
+ }
75
+ class HelperAnalyzer {
76
+ language;
77
+ process;
78
+ lines;
79
+ constructor(language, process, lines) {
80
+ this.language = language;
81
+ this.process = process;
82
+ this.lines = lines;
83
+ }
84
+ static async create(language, command, fallbackName) {
85
+ const resolved = command || fallbackName;
86
+ const process = spawn(resolved, [], { stdio: ["pipe", "pipe", "pipe"] });
87
+ const lines = createInterface({ input: process.stdout });
88
+ const analyzer = new HelperAnalyzer(language, process, lines);
89
+ const startup = await analyzer.readLine();
90
+ const response = JSON.parse(startup);
91
+ if (response.error)
92
+ throw new Error(`${fallbackName}: ${response.error}`);
93
+ if (!response.ready)
94
+ throw new Error(`${fallbackName} did not report ready`);
95
+ return analyzer;
96
+ }
97
+ async tokenize(text) {
98
+ return this.request(text);
99
+ }
100
+ async close() {
101
+ this.lines.close();
102
+ this.process.kill();
103
+ }
104
+ request(text) {
105
+ return new Promise((resolve, reject) => {
106
+ const onError = (error) => reject(error);
107
+ const onLine = (line) => {
108
+ try {
109
+ const response = JSON.parse(line);
110
+ this.lines.off("line", onLine);
111
+ this.process.off("error", onError);
112
+ if (response.error)
113
+ reject(new Error(`${this.language} analyzer: ${response.error}`));
114
+ else
115
+ resolve(response.ready ? "ready" : response.tokens ?? "");
116
+ }
117
+ catch (error) {
118
+ reject(error);
119
+ }
120
+ };
121
+ this.lines.on("line", onLine);
122
+ this.process.once("error", onError);
123
+ this.process.stdin.write(`${JSON.stringify({ text })}\n`);
124
+ });
125
+ }
126
+ readLine() {
127
+ return new Promise((resolve, reject) => {
128
+ const onLine = (line) => {
129
+ this.lines.off("line", onLine);
130
+ resolve(line);
131
+ };
132
+ this.lines.on("line", onLine);
133
+ this.process.once("error", reject);
134
+ });
135
+ }
136
+ }
137
+ function tokenizeMock(language, text) {
138
+ if (language === "ko")
139
+ return text.normalize("NFKC").toLocaleLowerCase().match(/[\p{L}\p{N}]+/gu)?.join(" ") ?? "";
140
+ if (language === "ja" || language === "zh")
141
+ return text.normalize("NFKC").match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{L}\p{N}]+/gu)?.join(" ") ?? "";
142
+ return tokenizeArabic(text);
143
+ }
144
+ export function tokenizeForLanguage(language, text) {
145
+ return tokenizeMock(language, text);
146
+ }
147
+ function tokenizeArabic(text) {
148
+ return (text.normalize("NFKC").toLocaleLowerCase().match(/\p{Script=Arabic}+/gu) ?? [])
149
+ .flatMap((word) => [word, word.replace(/^(?:وال|فال|بال|كال|ال|لل|و|ف|ب|ك|ل)/u, "")])
150
+ .filter(Boolean).join(" ");
151
+ }
package/dist/types.d.ts CHANGED
@@ -71,6 +71,7 @@ export interface SearchHit {
71
71
  score: number;
72
72
  mode: SearchMode;
73
73
  }
74
+ export type LexicalLanguage = "ko" | "ja" | "zh" | "ar";
74
75
  export interface SyncReport {
75
76
  added: number;
76
77
  updated: number;
@@ -0,0 +1,223 @@
1
+ # mailcrawl architecture
2
+
3
+ ## Decision
4
+
5
+ mailcrawl is an independent CLI and local indexing service. AutoRAG should
6
+ integrate with it through a thin process adapter, in the same way it
7
+ integrates with `katok`, `discrawl`, and `qmd`.
8
+
9
+ The boundary is intentional:
10
+
11
+ ```text
12
+ Himalaya
13
+ -> mailcrawl sync/archive/chunk/FTS/vector
14
+ -> stable JSON CLI contract
15
+ -> AutoRAG datasource adapter
16
+ -> datasource authorization and agent retrieval
17
+ ```
18
+
19
+ mailcrawl must remain useful without AutoRAG. AutoRAG must not read the
20
+ mailcrawl database directly.
21
+
22
+ ## Responsibilities
23
+
24
+ ### mailcrawl owns
25
+
26
+ - Provider synchronization through Himalaya
27
+ - Account, mailbox, message, and thread identity
28
+ - Raw/normalized message archive
29
+ - MIME parsing and text normalization
30
+ - Email-aware chunking
31
+ - SQLite metadata and FTS5/BM25 index
32
+ - Embedding queue and model identity
33
+ - LanceDB vector index
34
+ - FTS, semantic, and hybrid ranking
35
+ - Schema migrations, repair, compaction, and health checks
36
+ - Stable JSON output and bounded, redacted diagnostics
37
+
38
+ ### Consumers own
39
+
40
+ - Authentication policy and trusted configuration handoff
41
+ - Scheduling and polling
42
+ - Access control and scope filtering
43
+ - Mapping results into their own retrieval contracts
44
+ - LLM answering and cross-source result merging
45
+
46
+ ## Storage layout
47
+
48
+ The initial implementation should use two stores, with explicit
49
+ reconciliation:
50
+
51
+ ```text
52
+ <workspace>/
53
+ archive.sqlite
54
+ accounts
55
+ mailboxes
56
+ messages
57
+ threads
58
+ chunks
59
+ sync_cursors
60
+ embedding_queue
61
+ chunks_fts (FTS5)
62
+ vectors/
63
+ LanceDB tables keyed by chunk_id and embedding_model
64
+ ```
65
+
66
+ SQLite is the source of truth for message/chunk metadata, synchronization
67
+ state, and lexical search. LanceDB is a rebuildable semantic index. FTS must
68
+ remain available when embeddings are unavailable or stale.
69
+
70
+ ## Synchronization model
71
+
72
+ The first version uses a safe snapshot-diff algorithm:
73
+
74
+ 1. Ask Himalaya for bounded envelope pages.
75
+ 2. Normalize each envelope into a stable account/mailbox/message key.
76
+ 3. Compare provider identity plus envelope fingerprint with local state.
77
+ 4. Fetch full MIME content only for new or changed messages.
78
+ 5. Mark absent messages deleted only when the complete mailbox snapshot is
79
+ known; never infer deletion from a truncated page.
80
+ 6. Normalize and chunk changed messages.
81
+ 7. Transactionally update SQLite metadata and FTS5.
82
+ 8. Enqueue changed chunks for embedding.
83
+
84
+ Later provider-specific cursors can improve scale without changing the public
85
+ contract:
86
+
87
+ - IMAP UIDVALIDITY + UID, with MODSEQ/QRESYNC where available
88
+ - Gmail history ID
89
+ - JMAP state token
90
+ - Microsoft Graph delta link
91
+ - Maildir file identity and mtime
92
+
93
+ ## Email-aware chunking
94
+
95
+ Naive fixed-size slicing is not sufficient for email. The normalizer should
96
+ separate:
97
+
98
+ ```text
99
+ message
100
+ headers
101
+ latest authored text
102
+ quoted reply history
103
+ forwarded content
104
+ signature
105
+ attachments and extracted text
106
+ ```
107
+
108
+ The first chunking policy should:
109
+
110
+ - preserve subject/from/to/date metadata with every searchable chunk
111
+ - prefer paragraph and reply-boundary cuts
112
+ - remove repeated quoted history when safe
113
+ - keep a stable `chunk_id` derived from message identity, section, and index
114
+ - retain a link from every chunk to its source message and thread
115
+ - version the normalization/chunking policy for controlled reindexing
116
+
117
+ ## Search model
118
+
119
+ ### FTS5/BM25
120
+
121
+ SQLite FTS5 indexes subject, addresses, normalized body, thread subject, and
122
+ attachment text. Field weighting should favor subject and sender matches.
123
+ Search results return stable chunk and message identifiers.
124
+
125
+ ### Semantic
126
+
127
+ Only normalized, non-empty chunks enter the embedding queue. Each vector row
128
+ stores:
129
+
130
+ ```text
131
+ chunk_id
132
+ message_id
133
+ thread_id
134
+ embedding_model
135
+ content_hash
136
+ created_at
137
+ ```
138
+
139
+ Changing the embedding model creates a new semantic generation. A failed
140
+ embedding job must not make FTS unavailable.
141
+
142
+ The default implementation uses Google's `EmbeddingGemma` ONNX model through
143
+ Transformers.js and local ONNX Runtime CPU inference. Query and document
144
+ prefixes follow the model's retrieval instructions. Model identity is written
145
+ to semantic generation manifests, so changing it requires a new generation.
146
+
147
+ ### Hybrid
148
+
149
+ Run lexical and semantic retrieval independently, normalize scores, dedupe by
150
+ chunk/message policy, and merge with a deterministic weighted rank. If the
151
+ vector index is missing or stale, hybrid falls back to FTS and reports a
152
+ bounded warning in JSON diagnostics.
153
+
154
+ ## CLI contract
155
+
156
+ All commands support `--json` where machine-readable output is useful.
157
+
158
+ ```text
159
+ mailcrawl doctor
160
+ mailcrawl status
161
+ mailcrawl sync
162
+ mailcrawl embed [--limit N]
163
+ mailcrawl search --mode fts|semantic|hybrid [--limit N] [--mailbox NAME] QUERY
164
+ mailcrawl message get MESSAGE_ID
165
+ mailcrawl repair [--fts|--vectors|--all]
166
+ ```
167
+
168
+ Example sync response:
169
+
170
+ ```json
171
+ {
172
+ "added": 12,
173
+ "updated": 3,
174
+ "deleted": 1,
175
+ "unchanged": 4821,
176
+ "chunksAdded": 21,
177
+ "chunksDeleted": 6,
178
+ "embeddingBacklog": 21
179
+ }
180
+ ```
181
+
182
+ Example search hit:
183
+
184
+ ```json
185
+ {
186
+ "chunkId": "msg-123:latest:0",
187
+ "messageId": "msg-123",
188
+ "threadId": "thread-42",
189
+ "score": 0.87,
190
+ "content": "계약 갱신 조건은...",
191
+ "subject": "Re: 2026 계약 갱신",
192
+ "mailbox": "INBOX"
193
+ }
194
+ ```
195
+
196
+ ## Security and privacy
197
+
198
+ - Himalaya remains responsible for credential resolution.
199
+ - mailcrawl passes only configured account/backend/mailbox values.
200
+ - Secrets never enter archive metadata or diagnostics.
201
+ - Diagnostics are bounded and redact common email/path/secret patterns.
202
+ - Local embeddings are the default recommendation.
203
+ - Remote embedding providers require explicit configuration.
204
+ - The archive is local and must be treated as sensitive data.
205
+
206
+ ## Implementation milestones
207
+
208
+ 1. Define schema, CLI JSON schemas, and fake Himalaya runner.
209
+ 2. Implement Himalaya doctor/account validation and snapshot sync.
210
+ 3. Implement MIME normalization and email-aware chunking.
211
+ 4. Implement SQLite metadata plus FTS5/BM25 search.
212
+ 5. Add the AutoRAG adapter and verify end-to-end lexical retrieval.
213
+ 6. Add embedding providers, queue draining, and LanceDB storage.
214
+ 7. Add semantic and hybrid retrieval with deterministic fallback.
215
+ 8. Add provider-native cursors, repair, benchmarks, and release packaging.
216
+
217
+ ## Non-goals for the first release
218
+
219
+ - Sending, deleting, or mutating remote mail
220
+ - Replacing Himalaya's mail client surface
221
+ - Cloud-hosted indexing
222
+ - Automatic remote embedding without opt-in
223
+ - LLM-generated summaries as part of the storage engine
@@ -0,0 +1,48 @@
1
+ # Classification filtering
2
+
3
+ mailcrawl preserves provider classification metadata while applying an indexing
4
+ policy at sync time. The default policy excludes `spam` and `promotions` from
5
+ the local archive, FTS5/BM25, and semantic indexes.
6
+
7
+ Classification values are normalized case-insensitively. Provider prefixes such
8
+ as `CATEGORY_` and `LABEL_` are removed, so `CATEGORY_SPAM`, `spam`, and
9
+ `label-spam` all map to `spam`. Values from `classifications`, `labels`, and
10
+ `flags` are considered.
11
+
12
+ ## CLI policy
13
+
14
+ The default is equivalent to:
15
+
16
+ ```bash
17
+ mailcrawl sync --exclude-category spam --exclude-category promotions --json
18
+ ```
19
+
20
+ Use `--include-category` to opt into a normally excluded category:
21
+
22
+ ```bash
23
+ mailcrawl sync --include-category spam --json
24
+ ```
25
+
26
+ Use one or more `--exclude-category` options to replace the default exclusion
27
+ set. An empty exclusion set can be supplied through the library API:
28
+
29
+ ```ts
30
+ await archive.sync(messages, { excludedCategories: [] });
31
+ ```
32
+
33
+ Excluded messages are not treated as remote deletions. They are omitted from
34
+ the local indexing source of truth for that sync and are reported separately:
35
+
36
+ ```json
37
+ {
38
+ "excluded": 2,
39
+ "excludedByReason": {
40
+ "spam": 1,
41
+ "promotions": 1
42
+ }
43
+ }
44
+ ```
45
+
46
+ If a later sync changes a message's classification so that it is no longer
47
+ excluded, the message is indexed normally. This makes classification changes
48
+ safe for incremental sync.
@@ -0,0 +1,176 @@
1
+ # Multilingual analyzer installation
2
+
3
+ This guide covers the runtime components used by mailcrawl's lexical search.
4
+ Korean runs through the `kiwi-nlp` WebAssembly binding. Japanese and Chinese
5
+ use persistent Go helpers because Kagome has no official Node binding and the
6
+ GSE project does not currently publish a usable `gse-bind` npm package. English
7
+ uses SQLite's built-in Unicode tokenizer, and Arabic uses mailcrawl's
8
+ in-process light stemmer.
9
+
10
+ Semantic search uses Google's EmbeddingGemma through Transformers.js and ONNX
11
+ Runtime on the local machine. The model is downloaded to the local
12
+ Transformers cache on first use and is not a hosted service.
13
+
14
+ ## 1. Install Node.js dependencies
15
+
16
+ Use Node.js 24 or newer:
17
+
18
+ ```bash
19
+ node --version
20
+ npm install
21
+ npm run build
22
+ ```
23
+
24
+ The `kiwi-nlp` package is installed by `npm install`. Its WASM binary is
25
+ resolved automatically from the installed package.
26
+
27
+ ## 2. Configure the Korean Kiwi model
28
+
29
+ The npm package contains the Kiwi WASM engine, but the model files are
30
+ distributed separately. Set `MAILCRAWL_KIWI_MODEL` to a directory containing
31
+ the matching files from the same Kiwi release:
32
+
33
+ ```bash
34
+ export MAILCRAWL_KIWI_MODEL="$HOME/.local/share/mailcrawl/kiwi-model"
35
+ ```
36
+
37
+ For Kiwi `v0.23.x`, point to the model variant directory, usually
38
+ `cong/base`. The directory must contain the model assets for that release.
39
+
40
+ ```text
41
+ combiningRule.txt
42
+ default.dict
43
+ extract.mdl
44
+ multi.dict
45
+ cong.mdl
46
+ sj.morph
47
+ typo.dict
48
+ ```
49
+
50
+ To use a different WASM file:
51
+
52
+ ```bash
53
+ export MAILCRAWL_KIWI_WASM="/path/to/kiwi-wasm.wasm"
54
+ ```
55
+
56
+ Older Kiwi releases may use `sj.knlm` and `skipbigram.mdl` instead. Keep the
57
+ WASM and model files from compatible Kiwi releases. A missing model
58
+ configuration is an error for production Korean indexing; mailcrawl does not
59
+ silently substitute its test tokenizer.
60
+
61
+ ## 3. Install Go
62
+
63
+ Go is required only for Japanese and Chinese lexical helpers. Install Go 1.26
64
+ or newer, then verify it:
65
+
66
+ ```bash
67
+ go version
68
+ ```
69
+
70
+ If you do not need Japanese or Chinese search, you can omit this step and
71
+ leave the corresponding helper unset.
72
+
73
+ ## 4. Build the Japanese Kagome helper
74
+
75
+ From the mailcrawl repository:
76
+
77
+ ```bash
78
+ mkdir -p "$HOME/.local/bin"
79
+ (cd tools/mailcrawl-ja && go build -trimpath -o "$HOME/.local/bin/mailcrawl-ja" .)
80
+ export MAILCRAWL_JA_HELPER="$HOME/.local/bin/mailcrawl-ja"
81
+ ```
82
+
83
+ The helper embeds Kagome's IPADIC dictionary and stays alive as a persistent
84
+ JSON-lines process. It reports a `ready` response at startup and accepts
85
+ `{"text":"..."}` requests.
86
+
87
+ Smoke test:
88
+
89
+ ```bash
90
+ printf '{"text":"契約更新を確認"}\n' | "$MAILCRAWL_JA_HELPER"
91
+ ```
92
+
93
+ The output should contain Japanese lexical terms such as `契約` and `更新`.
94
+
95
+ ## 5. Build the Chinese GSE helper
96
+
97
+ Build and configure the Chinese helper:
98
+
99
+ ```bash
100
+ (cd tools/mailcrawl-zh && go build -trimpath -o "$HOME/.local/bin/mailcrawl-zh" .)
101
+ export MAILCRAWL_ZH_HELPER="$HOME/.local/bin/mailcrawl-zh"
102
+ ```
103
+
104
+ Smoke test:
105
+
106
+ ```bash
107
+ printf '{"text":"合同更新确认"}\n' | "$MAILCRAWL_ZH_HELPER"
108
+ ```
109
+
110
+ The output should contain Chinese lexical terms such as `合同` and `更新`.
111
+
112
+ ## 6. Persist the environment
113
+
114
+ Put the exports in the shell startup file used to run mailcrawl, or provide
115
+ them through the service manager that launches it:
116
+
117
+ ```bash
118
+ export MAILCRAWL_KIWI_MODEL="$HOME/.local/share/mailcrawl/kiwi-model"
119
+ export MAILCRAWL_JA_HELPER="$HOME/.local/bin/mailcrawl-ja"
120
+ export MAILCRAWL_ZH_HELPER="$HOME/.local/bin/mailcrawl-zh"
121
+ ```
122
+
123
+ The helper paths may instead be plain executable names when they are on
124
+ `PATH`.
125
+
126
+ ## 7. Verify mailcrawl
127
+
128
+ Run the project checks:
129
+
130
+ ```bash
131
+ npm run typecheck
132
+ npm test
133
+ npm run build
134
+ ```
135
+
136
+ Then synchronize and search:
137
+
138
+ ```bash
139
+ mailcrawl sync --json
140
+ mailcrawl search --mode bm25 --json "계약 갱신"
141
+ mailcrawl search --mode bm25 --json "契約 更新"
142
+ mailcrawl search --mode bm25 --json "合同 更新"
143
+ mailcrawl search --mode bm25 --json "كتاب"
144
+ mailcrawl search --mode bm25 --json "contract renewal"
145
+ mailcrawl index --json
146
+ mailcrawl search --mode semantic --json "contract renewal"
147
+ ```
148
+
149
+ ## 8. Lexical rebuild policy
150
+
151
+ Language-specific FTS fields are derived artifacts. Their version fingerprint
152
+ includes the Kiwi, Kagome, GSE, and Arabic analyzer versions. On a fingerprint
153
+ change, mailcrawl clears those fields and marks them stale. The next complete
154
+ `mailcrawl sync` re-analyzes changed and existing messages from the source and
155
+ commits the new fingerprint atomically. Multilingual search fails clearly until
156
+ that sync completes; the default Unicode FTS remains independent.
157
+
158
+ Semantic vectors are separate derived artifacts. Changing the embedding model
159
+ or its preprocessing requires a new semantic generation; run `mailcrawl index`
160
+ after synchronization. Existing lexical fields remain valid when only the
161
+ embedding model changes.
162
+
163
+ ## Packaging and licenses
164
+
165
+ mailcrawl remains MIT-licensed. `kiwi-nlp` and Kiwi remain
166
+ LGPL-2.1-or-later components. Kagome is MIT-licensed, its IPADIC dictionary
167
+ has separate IPADIC/ICOT terms, and GSE is Apache-2.0. See
168
+ [`THIRD_PARTY_NOTICES.md`](../THIRD_PARTY_NOTICES.md) before redistributing
169
+ the application or helper binaries.
170
+
171
+ ## 9. Semantic model
172
+
173
+ Semantic indexing uses `onnx-community/embeddinggemma-300m-ONNX` with
174
+ Transformers.js and ONNX Runtime on CPU. The first `mailcrawl index` downloads
175
+ the model to the local Transformers cache; later runs reuse that cache. The
176
+ model is not sent to a remote embedding service.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomadamas/mailcrawl",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Local Himalaya-backed incremental email indexing and hybrid search CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,6 +10,8 @@
10
10
  "files": [
11
11
  "dist",
12
12
  "skills",
13
+ "docs",
14
+ "THIRD_PARTY_NOTICES.md",
13
15
  "README.md",
14
16
  "LICENSE"
15
17
  ],
@@ -35,8 +37,10 @@
35
37
  "commit:check": "git diff --check"
36
38
  },
37
39
  "dependencies": {
40
+ "@huggingface/transformers": "^4.2.0",
38
41
  "better-sqlite3": "^12.4.1",
39
42
  "commander": "^14.0.0",
43
+ "kiwi-nlp": "^0.23.0",
40
44
  "mailparser": "^3.9.0",
41
45
  "mime-types": "^3.0.1"
42
46
  },
@@ -27,6 +27,15 @@ Required runtime dependencies are Node.js 24+, the package dependencies, and
27
27
  the Himalaya CLI for live synchronization. Fixture synchronization needs no
28
28
  mail account.
29
29
 
30
+ For Korean lexical search, provide a Kiwi model directory through
31
+ `MAILCRAWL_KIWI_MODEL`; the WASM binary is bundled by `kiwi-nlp`. Kagome has
32
+ no official Node binding, and GSE's referenced `gse-bind` is not available as
33
+ a usable npm package. Therefore Japanese and Chinese lexical search use the
34
+ repository helpers `tools/mailcrawl-ja` and `tools/mailcrawl-zh`; set
35
+ `MAILCRAWL_JA_HELPER` / `MAILCRAWL_ZH_HELPER` when they are not on PATH.
36
+ Production multilingual search fails clearly when its analyzer is not
37
+ configured; it never silently uses the test tokenizer.
38
+
30
39
  ## Data and credential boundaries
31
40
 
32
41
  The archive lives under `.mailcrawl` by default. Set `--data-dir` or