@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 +27 -8
- package/THIRD_PARTY_NOTICES.md +50 -0
- package/dist/archive.d.ts +12 -7
- package/dist/archive.js +145 -36
- package/dist/cli/index.js +7 -7
- package/dist/embedding.d.ts +6 -0
- package/dist/embedding.js +54 -0
- package/dist/lexical.d.ts +10 -0
- package/dist/lexical.js +151 -0
- package/dist/types.d.ts +1 -0
- package/docs/architecture.md +223 -0
- package/docs/classification-filtering.md +48 -0
- package/docs/multilingual-installation.md +176 -0
- package/package.json +5 -1
- package/skills/mailcrawl/SKILL.md +9 -0
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:
|
|
11
|
-
>
|
|
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
|
-
##
|
|
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
|
-
-
|
|
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
|
|
42
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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:
|
|
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 =
|
|
160
|
-
merged.set(hit.chunkId, prior
|
|
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()]
|
|
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: "
|
|
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,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;
|
package/dist/lexical.js
ADDED
|
@@ -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
|
@@ -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.
|
|
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
|