@llmnesia/mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +153 -0
- package/THIRD_PARTY_NOTICES +205 -0
- package/dist/chunk-OBJD6H4A.js +883 -0
- package/dist/chunk-OBJD6H4A.js.map +1 -0
- package/dist/cli.js +2812 -0
- package/dist/cli.js.map +1 -0
- package/dist/llmnesia-claude-host.mjs +251 -0
- package/dist/mcp.js +21537 -0
- package/dist/mcp.js.map +1 -0
- package/package.json +67 -0
- package/server.json +18 -0
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __commonJS = (cb, mod) => function __require() {
|
|
9
|
+
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
10
|
+
};
|
|
11
|
+
var __export = (target, all) => {
|
|
12
|
+
for (var name in all)
|
|
13
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
14
|
+
};
|
|
15
|
+
var __copyProps = (to, from, except, desc) => {
|
|
16
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
17
|
+
for (let key of __getOwnPropNames(from))
|
|
18
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
19
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
20
|
+
}
|
|
21
|
+
return to;
|
|
22
|
+
};
|
|
23
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
24
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
25
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
26
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
27
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
28
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
29
|
+
mod
|
|
30
|
+
));
|
|
31
|
+
|
|
32
|
+
// src/config.ts
|
|
33
|
+
import { readFileSync } from "fs";
|
|
34
|
+
import { homedir } from "os";
|
|
35
|
+
import { join, resolve } from "path";
|
|
36
|
+
var CORPUS_FORMAT_VERSION = 1;
|
|
37
|
+
function nativeHostConfigPath() {
|
|
38
|
+
return join(homedir(), ".llmnesia", "config.json");
|
|
39
|
+
}
|
|
40
|
+
function readNativeHostCorpusRoot() {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(readFileSync(nativeHostConfigPath(), "utf8"));
|
|
43
|
+
return typeof parsed.corpusRoot === "string" ? parsed.corpusRoot : void 0;
|
|
44
|
+
} catch {
|
|
45
|
+
return void 0;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function resolveCorpusDir(explicit) {
|
|
49
|
+
const raw = explicit ?? process.env.LLMNESIA_CORPUS_DIR ?? readNativeHostCorpusRoot() ?? "~/.llmnesia/corpus";
|
|
50
|
+
return expandHome(raw);
|
|
51
|
+
}
|
|
52
|
+
function expandHome(p) {
|
|
53
|
+
if (p === "~") {
|
|
54
|
+
return homedir();
|
|
55
|
+
}
|
|
56
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) {
|
|
57
|
+
return resolve(homedir(), p.slice(2));
|
|
58
|
+
}
|
|
59
|
+
return resolve(p);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/corpus.ts
|
|
63
|
+
import { promises as fs } from "fs";
|
|
64
|
+
import { dirname, join as join3 } from "path";
|
|
65
|
+
|
|
66
|
+
// src/paths.ts
|
|
67
|
+
import { join as join2 } from "path";
|
|
68
|
+
import { createHash } from "crypto";
|
|
69
|
+
var CorpusPaths = class {
|
|
70
|
+
constructor(root) {
|
|
71
|
+
this.root = root;
|
|
72
|
+
}
|
|
73
|
+
root;
|
|
74
|
+
get metaFile() {
|
|
75
|
+
return join2(this.root, "meta.json");
|
|
76
|
+
}
|
|
77
|
+
get inboxDir() {
|
|
78
|
+
return join2(this.root, "inbox");
|
|
79
|
+
}
|
|
80
|
+
get processedDir() {
|
|
81
|
+
return join2(this.inboxDir, "processed");
|
|
82
|
+
}
|
|
83
|
+
get conversationsDir() {
|
|
84
|
+
return join2(this.root, "conversations");
|
|
85
|
+
}
|
|
86
|
+
get indexDir() {
|
|
87
|
+
return join2(this.root, "index");
|
|
88
|
+
}
|
|
89
|
+
get indexDbFile() {
|
|
90
|
+
return join2(this.indexDir, "llmnesia.db");
|
|
91
|
+
}
|
|
92
|
+
/** Directory holding a platform's conversation JSON files. */
|
|
93
|
+
platformDir(platform) {
|
|
94
|
+
return join2(this.conversationsDir, safeSegment(platform));
|
|
95
|
+
}
|
|
96
|
+
/** Absolute path to the JSON file backing a given docId. */
|
|
97
|
+
conversationFile(platform, docId) {
|
|
98
|
+
return join2(this.platformDir(platform), `${slugifyDocId(docId)}.json`);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
var SLUG_MAX = 120;
|
|
102
|
+
function slugifyDocId(docId) {
|
|
103
|
+
const lossless = docId.length > 0 && docId.length <= SLUG_MAX && /^[a-zA-Z0-9._-]+$/.test(docId);
|
|
104
|
+
if (lossless) {
|
|
105
|
+
return docId;
|
|
106
|
+
}
|
|
107
|
+
const hash = createHash("sha1").update(docId).digest("hex").slice(0, 12);
|
|
108
|
+
const readable = docId.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, SLUG_MAX - 13);
|
|
109
|
+
return readable.length > 0 ? `${readable}-${hash}` : hash;
|
|
110
|
+
}
|
|
111
|
+
function safeSegment(value) {
|
|
112
|
+
const cleaned = value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^[.-]+|[.-]+$/g, "");
|
|
113
|
+
return cleaned.length > 0 ? cleaned : "unknown";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/serialize.ts
|
|
117
|
+
function canonicalizeConversation(record) {
|
|
118
|
+
return {
|
|
119
|
+
conversation: canonicalConversationDoc(record.conversation),
|
|
120
|
+
messages: [...record.messages].sort((a, b) => a.msgIndex - b.msgIndex).map(canonicalMessageDoc)
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function canonicalConversationDoc(c) {
|
|
124
|
+
const out = {
|
|
125
|
+
docId: c.docId,
|
|
126
|
+
platform: c.platform,
|
|
127
|
+
title: c.title,
|
|
128
|
+
url: c.url,
|
|
129
|
+
createdAt: c.createdAt,
|
|
130
|
+
contentUpdatedAt: c.contentUpdatedAt,
|
|
131
|
+
indexedAt: c.indexedAt,
|
|
132
|
+
updatedAt: c.updatedAt,
|
|
133
|
+
lastSeenAt: c.lastSeenAt,
|
|
134
|
+
messageCount: c.messageCount,
|
|
135
|
+
preview: c.preview,
|
|
136
|
+
hasImages: c.hasImages,
|
|
137
|
+
pinned: c.pinned,
|
|
138
|
+
indexLevel: c.indexLevel,
|
|
139
|
+
estimatedBytes: c.estimatedBytes
|
|
140
|
+
};
|
|
141
|
+
assignIfDefined(out, "searchDocLength", c.searchDocLength);
|
|
142
|
+
assignIfDefined(out, "contentHash", c.contentHash);
|
|
143
|
+
assignIfDefined(out, "truncated", c.truncated);
|
|
144
|
+
assignIfDefined(out, "kind", c.kind);
|
|
145
|
+
assignIfDefined(out, "sourceId", c.sourceId);
|
|
146
|
+
assignIfDefined(out, "baseUrl", c.baseUrl);
|
|
147
|
+
assignIfDefined(out, "isDeeplinkable", c.isDeeplinkable);
|
|
148
|
+
assignIfDefined(out, "origin", c.origin);
|
|
149
|
+
assignIfDefined(out, "accountId", c.accountId);
|
|
150
|
+
assignIfDefined(out, "accountLabel", c.accountLabel);
|
|
151
|
+
if (c.summary) {
|
|
152
|
+
out.summary = {
|
|
153
|
+
text: c.summary.text,
|
|
154
|
+
recipe: c.summary.recipe,
|
|
155
|
+
method: c.summary.method,
|
|
156
|
+
createdAt: c.summary.createdAt
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
function canonicalMessageDoc(m) {
|
|
162
|
+
const out = {
|
|
163
|
+
msgKey: m.msgKey,
|
|
164
|
+
docId: m.docId,
|
|
165
|
+
msgIndex: m.msgIndex,
|
|
166
|
+
role: m.role,
|
|
167
|
+
text: m.text
|
|
168
|
+
};
|
|
169
|
+
assignIfDefined(out, "formattedText", m.formattedText);
|
|
170
|
+
out.createdAt = m.createdAt;
|
|
171
|
+
out.updatedAt = m.updatedAt;
|
|
172
|
+
out.contentHash = m.contentHash;
|
|
173
|
+
assignIfDefined(out, "platformMessageId", m.platformMessageId);
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
function assignIfDefined(target, key, value) {
|
|
177
|
+
if (value !== void 0) {
|
|
178
|
+
target[key] = value;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/corpus.ts
|
|
183
|
+
var DIR_MODE = 448;
|
|
184
|
+
var FILE_MODE = 384;
|
|
185
|
+
var Corpus = class {
|
|
186
|
+
paths;
|
|
187
|
+
// Serialize writes within this process so concurrent upserts (inbox drain +
|
|
188
|
+
// MCP save_conversation) don't interleave. Cross-process safety relies on the
|
|
189
|
+
// attended, single-companion v1 model (see PLAN-MCP.md open question 3).
|
|
190
|
+
writeChain = Promise.resolve();
|
|
191
|
+
constructor(root) {
|
|
192
|
+
this.paths = new CorpusPaths(root);
|
|
193
|
+
}
|
|
194
|
+
/** Create the directory skeleton and meta.json if absent. Idempotent. */
|
|
195
|
+
async init() {
|
|
196
|
+
await fs.mkdir(this.paths.conversationsDir, { recursive: true, mode: DIR_MODE });
|
|
197
|
+
await fs.mkdir(this.paths.processedDir, { recursive: true, mode: DIR_MODE });
|
|
198
|
+
await fs.mkdir(this.paths.indexDir, { recursive: true, mode: DIR_MODE });
|
|
199
|
+
await hardenDir(this.paths.root);
|
|
200
|
+
await hardenDir(this.paths.conversationsDir);
|
|
201
|
+
await hardenDir(this.paths.inboxDir);
|
|
202
|
+
await hardenDir(this.paths.processedDir);
|
|
203
|
+
await hardenDir(this.paths.indexDir);
|
|
204
|
+
try {
|
|
205
|
+
await fs.access(this.paths.metaFile);
|
|
206
|
+
} catch {
|
|
207
|
+
const meta = {
|
|
208
|
+
formatVersion: CORPUS_FORMAT_VERSION,
|
|
209
|
+
createdAt: Date.now(),
|
|
210
|
+
lastIngestAt: null,
|
|
211
|
+
conversationCount: 0
|
|
212
|
+
};
|
|
213
|
+
await writeFileAtomic(this.paths.metaFile, `${JSON.stringify(meta, null, 2)}
|
|
214
|
+
`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async readMeta() {
|
|
218
|
+
try {
|
|
219
|
+
const raw = await fs.readFile(this.paths.metaFile, "utf8");
|
|
220
|
+
return JSON.parse(raw);
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async writeMeta(patch) {
|
|
226
|
+
const current = await this.readMeta() ?? {
|
|
227
|
+
formatVersion: CORPUS_FORMAT_VERSION,
|
|
228
|
+
createdAt: Date.now(),
|
|
229
|
+
lastIngestAt: null,
|
|
230
|
+
conversationCount: 0
|
|
231
|
+
};
|
|
232
|
+
const next = { ...current, ...patch };
|
|
233
|
+
await writeFileAtomic(this.paths.metaFile, `${JSON.stringify(next, null, 2)}
|
|
234
|
+
`);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Insert or replace a conversation, keyed on docId with last-write-wins by
|
|
238
|
+
* `conversation.updatedAt`. A stale write (older updatedAt than what's on
|
|
239
|
+
* disk) is a no-op. Re-writing identical content produces byte-identical
|
|
240
|
+
* output, so replaying a delta is safe.
|
|
241
|
+
*/
|
|
242
|
+
async upsert(record) {
|
|
243
|
+
return this.enqueueWrite(async () => {
|
|
244
|
+
const canonical = canonicalizeConversation(record);
|
|
245
|
+
const { conversation } = canonical;
|
|
246
|
+
const file = this.paths.conversationFile(conversation.platform, conversation.docId);
|
|
247
|
+
const existing = await readJsonIfExists(file);
|
|
248
|
+
if (existing) {
|
|
249
|
+
const existingUpdatedAt = existing.conversation.updatedAt ?? 0;
|
|
250
|
+
const incomingUpdatedAt = conversation.updatedAt ?? 0;
|
|
251
|
+
if (incomingUpdatedAt < existingUpdatedAt) {
|
|
252
|
+
return { docId: conversation.docId, outcome: "skipped" };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const serialized = `${JSON.stringify(canonical, null, 2)}
|
|
256
|
+
`;
|
|
257
|
+
if (existing) {
|
|
258
|
+
const existingSerialized = `${JSON.stringify(canonicalizeConversation(existing), null, 2)}
|
|
259
|
+
`;
|
|
260
|
+
if (existingSerialized === serialized) {
|
|
261
|
+
return { docId: conversation.docId, outcome: "skipped" };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
await fs.mkdir(dirname(file), { recursive: true, mode: DIR_MODE });
|
|
265
|
+
await writeFileAtomic(file, serialized);
|
|
266
|
+
return { docId: conversation.docId, outcome: existing ? "updated" : "created" };
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/** Read a conversation by docId, scanning platform dirs for the slug file. */
|
|
270
|
+
async get(docId) {
|
|
271
|
+
const filename = `${slugifyDocId(docId)}.json`;
|
|
272
|
+
for (const platform of await this.listPlatforms()) {
|
|
273
|
+
const candidate = join3(this.paths.platformDir(platform), filename);
|
|
274
|
+
const record = await readJsonIfExists(candidate);
|
|
275
|
+
if (record && record.conversation.docId === docId) {
|
|
276
|
+
return record;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
/** Platform subdirectory names under conversations/. */
|
|
282
|
+
async listPlatforms() {
|
|
283
|
+
try {
|
|
284
|
+
const entries = await fs.readdir(this.paths.conversationsDir, { withFileTypes: true });
|
|
285
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
286
|
+
} catch {
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/** Async-iterate every stored conversation (used by reindex + stats). */
|
|
291
|
+
async *iterate() {
|
|
292
|
+
for (const platform of await this.listPlatforms()) {
|
|
293
|
+
const dir = this.paths.platformDir(platform);
|
|
294
|
+
let files;
|
|
295
|
+
try {
|
|
296
|
+
files = await fs.readdir(dir);
|
|
297
|
+
} catch {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
for (const name of files) {
|
|
301
|
+
if (!name.endsWith(".json")) {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const record = await readJsonIfExists(join3(dir, name));
|
|
305
|
+
if (record) {
|
|
306
|
+
yield record;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async stats() {
|
|
312
|
+
const byPlatform = {};
|
|
313
|
+
let conversationCount = 0;
|
|
314
|
+
let messageCount = 0;
|
|
315
|
+
for await (const record of this.iterate()) {
|
|
316
|
+
conversationCount += 1;
|
|
317
|
+
messageCount += record.messages.length;
|
|
318
|
+
const platform = safeSegment(record.conversation.platform);
|
|
319
|
+
byPlatform[platform] = (byPlatform[platform] ?? 0) + 1;
|
|
320
|
+
}
|
|
321
|
+
return { conversationCount, messageCount, byPlatform };
|
|
322
|
+
}
|
|
323
|
+
/** Refresh meta.json's conversationCount / lastIngestAt after an ingest run. */
|
|
324
|
+
async touchIngestMeta() {
|
|
325
|
+
const { conversationCount } = await this.stats();
|
|
326
|
+
await this.writeMeta({ lastIngestAt: Date.now(), conversationCount });
|
|
327
|
+
}
|
|
328
|
+
enqueueWrite(fn) {
|
|
329
|
+
const run = this.writeChain.then(fn, fn);
|
|
330
|
+
this.writeChain = run.then(
|
|
331
|
+
() => void 0,
|
|
332
|
+
() => void 0
|
|
333
|
+
);
|
|
334
|
+
return run;
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
async function readJsonIfExists(file) {
|
|
338
|
+
try {
|
|
339
|
+
const raw = await fs.readFile(file, "utf8");
|
|
340
|
+
return JSON.parse(raw);
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
async function writeFileAtomic(file, contents) {
|
|
346
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
347
|
+
await fs.writeFile(tmp, contents, { encoding: "utf8", mode: FILE_MODE });
|
|
348
|
+
await fs.rename(tmp, file);
|
|
349
|
+
}
|
|
350
|
+
async function hardenDir(dir) {
|
|
351
|
+
try {
|
|
352
|
+
await fs.chmod(dir, DIR_MODE);
|
|
353
|
+
} catch {
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/ingest.ts
|
|
358
|
+
import { promises as fs2, createReadStream } from "fs";
|
|
359
|
+
import { join as join4 } from "path";
|
|
360
|
+
import { createInterface } from "readline";
|
|
361
|
+
import { createGunzip } from "zlib";
|
|
362
|
+
var EMPTY_REPORT = {
|
|
363
|
+
conversations: 0,
|
|
364
|
+
created: 0,
|
|
365
|
+
updated: 0,
|
|
366
|
+
skipped: 0,
|
|
367
|
+
messages: 0,
|
|
368
|
+
malformedLines: 0
|
|
369
|
+
};
|
|
370
|
+
async function ingestFile(corpus, file, index) {
|
|
371
|
+
const report = { ...EMPTY_REPORT };
|
|
372
|
+
let current = null;
|
|
373
|
+
let buffered = [];
|
|
374
|
+
const flush = async () => {
|
|
375
|
+
if (!current) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const record = { conversation: current, messages: buffered };
|
|
379
|
+
const result = await corpus.upsert(record);
|
|
380
|
+
tally(report, result);
|
|
381
|
+
if (index && result.outcome !== "skipped") {
|
|
382
|
+
index.upsertConversation(record);
|
|
383
|
+
}
|
|
384
|
+
report.conversations += 1;
|
|
385
|
+
report.messages += buffered.length;
|
|
386
|
+
current = null;
|
|
387
|
+
buffered = [];
|
|
388
|
+
};
|
|
389
|
+
const rl = createInterface({ input: openLineSource(file), crlfDelay: Infinity });
|
|
390
|
+
for await (const raw of rl) {
|
|
391
|
+
const line = raw.trim();
|
|
392
|
+
if (!line) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
let parsed;
|
|
396
|
+
try {
|
|
397
|
+
parsed = JSON.parse(line);
|
|
398
|
+
} catch {
|
|
399
|
+
report.malformedLines += 1;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
403
|
+
report.malformedLines += 1;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const record = parsed;
|
|
407
|
+
const type = record.type;
|
|
408
|
+
if (type === "meta") {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (type === "conversation") {
|
|
412
|
+
await flush();
|
|
413
|
+
current = coerceConversation(record.conversation);
|
|
414
|
+
buffered = [];
|
|
415
|
+
if (!current) {
|
|
416
|
+
report.malformedLines += 1;
|
|
417
|
+
}
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (type === "message" && current) {
|
|
421
|
+
const docId = typeof record.docId === "string" ? record.docId : "";
|
|
422
|
+
if (docId !== current.docId) {
|
|
423
|
+
report.malformedLines += 1;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const message = coerceMessage(current.docId, record.message);
|
|
427
|
+
if (message) {
|
|
428
|
+
buffered.push(message);
|
|
429
|
+
} else {
|
|
430
|
+
report.malformedLines += 1;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
await flush();
|
|
435
|
+
return report;
|
|
436
|
+
}
|
|
437
|
+
async function pendingInboxFiles(corpus) {
|
|
438
|
+
try {
|
|
439
|
+
return (await fs2.readdir(corpus.paths.inboxDir, { withFileTypes: true })).filter((e) => e.isFile() && isNdjson(e.name)).map((e) => e.name).sort();
|
|
440
|
+
} catch {
|
|
441
|
+
return [];
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
async function drainInbox(corpus, index, options = {}) {
|
|
445
|
+
await corpus.init();
|
|
446
|
+
const inbox = corpus.paths.inboxDir;
|
|
447
|
+
const entries = await pendingInboxFiles(corpus);
|
|
448
|
+
const total = { ...EMPTY_REPORT, files: 0, failed: 0 };
|
|
449
|
+
for (const name of entries) {
|
|
450
|
+
const src = join4(inbox, name);
|
|
451
|
+
try {
|
|
452
|
+
const report = await ingestFile(corpus, src, index);
|
|
453
|
+
accumulate(total, report);
|
|
454
|
+
total.files += 1;
|
|
455
|
+
await moveToProcessed(corpus, src, name);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if (await stillPending(src)) {
|
|
458
|
+
total.failed += 1;
|
|
459
|
+
options.onFileError?.(name, error);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (total.files > 0) {
|
|
464
|
+
await corpus.touchIngestMeta();
|
|
465
|
+
}
|
|
466
|
+
return total;
|
|
467
|
+
}
|
|
468
|
+
async function stillPending(src) {
|
|
469
|
+
try {
|
|
470
|
+
await fs2.access(src);
|
|
471
|
+
return true;
|
|
472
|
+
} catch {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
async function moveToProcessed(corpus, src, name) {
|
|
477
|
+
const dest = join4(corpus.paths.processedDir, name);
|
|
478
|
+
try {
|
|
479
|
+
await fs2.rename(src, dest);
|
|
480
|
+
} catch {
|
|
481
|
+
const alt = join4(corpus.paths.processedDir, `${Date.now()}-${name}`);
|
|
482
|
+
await fs2.copyFile(src, alt);
|
|
483
|
+
await fs2.unlink(src);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
function isNdjson(name) {
|
|
487
|
+
const lower = name.toLowerCase();
|
|
488
|
+
return lower.endsWith(".jsonl") || lower.endsWith(".ndjson") || lower.endsWith(".jsonl.gz") || lower.endsWith(".ndjson.gz");
|
|
489
|
+
}
|
|
490
|
+
function openLineSource(file) {
|
|
491
|
+
const stream = createReadStream(file);
|
|
492
|
+
if (file.toLowerCase().endsWith(".gz")) {
|
|
493
|
+
return stream.pipe(createGunzip());
|
|
494
|
+
}
|
|
495
|
+
return stream;
|
|
496
|
+
}
|
|
497
|
+
function coerceConversation(value) {
|
|
498
|
+
if (!value || typeof value !== "object") {
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
const c = value;
|
|
502
|
+
if (typeof c.docId !== "string" || !c.docId) {
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
const conversation = { ...c };
|
|
506
|
+
if (typeof conversation.updatedAt !== "number") {
|
|
507
|
+
conversation.updatedAt = typeof conversation.contentUpdatedAt === "number" ? conversation.contentUpdatedAt : 0;
|
|
508
|
+
}
|
|
509
|
+
return conversation;
|
|
510
|
+
}
|
|
511
|
+
function coerceMessage(docId, value) {
|
|
512
|
+
if (!value || typeof value !== "object") {
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
const m = value;
|
|
516
|
+
const text = typeof m.text === "string" ? m.text : "";
|
|
517
|
+
if (!text.trim()) {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
const msgIndex = typeof m.msgIndex === "number" ? m.msgIndex : 0;
|
|
521
|
+
const message = {
|
|
522
|
+
// The backup line omits msgKey; reconstruct the extension's convention.
|
|
523
|
+
msgKey: `${docId}:${msgIndex}`,
|
|
524
|
+
docId,
|
|
525
|
+
msgIndex,
|
|
526
|
+
role: coerceRole(m.role),
|
|
527
|
+
text,
|
|
528
|
+
createdAt: typeof m.createdAt === "number" ? m.createdAt : 0,
|
|
529
|
+
updatedAt: typeof m.updatedAt === "number" ? m.updatedAt : 0,
|
|
530
|
+
contentHash: typeof m.contentHash === "string" ? m.contentHash : ""
|
|
531
|
+
};
|
|
532
|
+
if (typeof m.formattedText === "string") {
|
|
533
|
+
message.formattedText = m.formattedText;
|
|
534
|
+
}
|
|
535
|
+
if (typeof m.platformMessageId === "string") {
|
|
536
|
+
message.platformMessageId = m.platformMessageId;
|
|
537
|
+
} else if (m.platformMessageId === null) {
|
|
538
|
+
message.platformMessageId = null;
|
|
539
|
+
}
|
|
540
|
+
return message;
|
|
541
|
+
}
|
|
542
|
+
function coerceRole(value) {
|
|
543
|
+
return value === "assistant" || value === "user" || value === "system" || value === "unknown" ? value : "unknown";
|
|
544
|
+
}
|
|
545
|
+
function tally(report, result) {
|
|
546
|
+
if (result.outcome === "created") {
|
|
547
|
+
report.created += 1;
|
|
548
|
+
} else if (result.outcome === "updated") {
|
|
549
|
+
report.updated += 1;
|
|
550
|
+
} else {
|
|
551
|
+
report.skipped += 1;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
function accumulate(total, report) {
|
|
555
|
+
total.conversations += report.conversations;
|
|
556
|
+
total.created += report.created;
|
|
557
|
+
total.updated += report.updated;
|
|
558
|
+
total.skipped += report.skipped;
|
|
559
|
+
total.messages += report.messages;
|
|
560
|
+
total.malformedLines += report.malformedLines;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// src/searchIndex.ts
|
|
564
|
+
import { promises as fs3, chmodSync, closeSync, openSync } from "fs";
|
|
565
|
+
import { createRequire } from "module";
|
|
566
|
+
|
|
567
|
+
// src/sqliteWarning.ts
|
|
568
|
+
var INSTALLED = /* @__PURE__ */ Symbol.for("llmnesia.sqliteWarningFilterInstalled");
|
|
569
|
+
function isSqliteExperimentalWarning(warning) {
|
|
570
|
+
return warning.name === "ExperimentalWarning" && /\bSQLite\b/i.test(warning.message);
|
|
571
|
+
}
|
|
572
|
+
function install() {
|
|
573
|
+
const flagged = globalThis;
|
|
574
|
+
if (flagged[INSTALLED]) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
flagged[INSTALLED] = true;
|
|
578
|
+
const previous = process.listeners("warning");
|
|
579
|
+
process.removeAllListeners("warning");
|
|
580
|
+
process.on("warning", (warning) => {
|
|
581
|
+
if (isSqliteExperimentalWarning(warning)) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
for (const listener of previous) {
|
|
585
|
+
listener.call(process, warning);
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
install();
|
|
590
|
+
|
|
591
|
+
// src/searchIndex.ts
|
|
592
|
+
var nodeRequire = createRequire(import.meta.url);
|
|
593
|
+
var sqlite;
|
|
594
|
+
function loadSqlite() {
|
|
595
|
+
return sqlite ??= nodeRequire("node:sqlite");
|
|
596
|
+
}
|
|
597
|
+
var DB_FILE_MODE = 384;
|
|
598
|
+
function hardenDbFile(dbPath) {
|
|
599
|
+
try {
|
|
600
|
+
closeSync(openSync(dbPath, "a", DB_FILE_MODE));
|
|
601
|
+
chmodSync(dbPath, DB_FILE_MODE);
|
|
602
|
+
} catch {
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
var INDEX_FORMAT_VERSION = 1;
|
|
606
|
+
var TITLE_ROW_INDEX = -1;
|
|
607
|
+
var TITLE_ROLE = "title";
|
|
608
|
+
var DEFAULT_LIMIT = 10;
|
|
609
|
+
var SNIPPET_TOKENS = 12;
|
|
610
|
+
var SCHEMA_SQL = `
|
|
611
|
+
CREATE TABLE IF NOT EXISTS index_meta (
|
|
612
|
+
key TEXT PRIMARY KEY,
|
|
613
|
+
value TEXT NOT NULL
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
CREATE TABLE IF NOT EXISTS conversations (
|
|
617
|
+
docId TEXT PRIMARY KEY,
|
|
618
|
+
platform TEXT NOT NULL,
|
|
619
|
+
title TEXT NOT NULL,
|
|
620
|
+
url TEXT NOT NULL,
|
|
621
|
+
createdAt INTEGER NOT NULL,
|
|
622
|
+
updatedAt INTEGER NOT NULL,
|
|
623
|
+
messageCount INTEGER NOT NULL,
|
|
624
|
+
preview TEXT NOT NULL,
|
|
625
|
+
pinned INTEGER NOT NULL
|
|
626
|
+
);
|
|
627
|
+
CREATE INDEX IF NOT EXISTS idx_conversations_platform ON conversations(platform);
|
|
628
|
+
CREATE INDEX IF NOT EXISTS idx_conversations_createdAt ON conversations(createdAt);
|
|
629
|
+
|
|
630
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
631
|
+
docId UNINDEXED,
|
|
632
|
+
msgIndex UNINDEXED,
|
|
633
|
+
role UNINDEXED,
|
|
634
|
+
text,
|
|
635
|
+
tokenize = 'porter unicode61'
|
|
636
|
+
);
|
|
637
|
+
`;
|
|
638
|
+
var SearchIndex = class {
|
|
639
|
+
db;
|
|
640
|
+
stmts;
|
|
641
|
+
constructor(dbPath) {
|
|
642
|
+
hardenDbFile(dbPath);
|
|
643
|
+
this.db = new (loadSqlite()).DatabaseSync(dbPath);
|
|
644
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
645
|
+
this.db.exec("PRAGMA foreign_keys = OFF");
|
|
646
|
+
this.db.exec(SCHEMA_SQL);
|
|
647
|
+
this.setMeta("format_version", String(INDEX_FORMAT_VERSION));
|
|
648
|
+
this.stmts = {
|
|
649
|
+
deleteFts: this.db.prepare("DELETE FROM messages_fts WHERE docId = ?"),
|
|
650
|
+
insertFts: this.db.prepare(
|
|
651
|
+
"INSERT INTO messages_fts (docId, msgIndex, role, text) VALUES (?, ?, ?, ?)"
|
|
652
|
+
),
|
|
653
|
+
deleteConversation: this.db.prepare("DELETE FROM conversations WHERE docId = ?"),
|
|
654
|
+
insertConversation: this.db.prepare(
|
|
655
|
+
`INSERT INTO conversations
|
|
656
|
+
(docId, platform, title, url, createdAt, updatedAt, messageCount, preview, pinned)
|
|
657
|
+
VALUES (@docId, @platform, @title, @url, @createdAt, @updatedAt, @messageCount, @preview, @pinned)`
|
|
658
|
+
)
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
/** True when the stored format version is missing or stale — caller rebuilds. */
|
|
662
|
+
needsRebuild() {
|
|
663
|
+
const stored = this.getMeta("format_version");
|
|
664
|
+
return stored !== String(INDEX_FORMAT_VERSION);
|
|
665
|
+
}
|
|
666
|
+
/** Number of indexed conversations. */
|
|
667
|
+
count() {
|
|
668
|
+
const row = this.db.prepare("SELECT COUNT(*) AS n FROM conversations").get();
|
|
669
|
+
return row.n;
|
|
670
|
+
}
|
|
671
|
+
/** Most-recently-updated conversations (metadata only), newest first. */
|
|
672
|
+
listRecent(n) {
|
|
673
|
+
const limit = Math.max(1, n);
|
|
674
|
+
const rows = this.db.prepare(
|
|
675
|
+
`SELECT docId, platform, title, url, createdAt, updatedAt, messageCount, preview, pinned
|
|
676
|
+
FROM conversations ORDER BY updatedAt DESC, createdAt DESC LIMIT ?`
|
|
677
|
+
).all(limit);
|
|
678
|
+
return rows.map((r) => ({ ...r, pinned: r.pinned === 1 }));
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Run `fn` inside a transaction, rolling back if it throws. node:sqlite has
|
|
682
|
+
* no transaction() wrapper, so this is the one place BEGIN/COMMIT/ROLLBACK
|
|
683
|
+
* lives for synchronous writes; {@link rebuildFrom} spells it out separately
|
|
684
|
+
* because its body is async and cannot be expressed as a sync callback.
|
|
685
|
+
*/
|
|
686
|
+
transaction(fn) {
|
|
687
|
+
this.db.exec("BEGIN");
|
|
688
|
+
try {
|
|
689
|
+
const result = fn();
|
|
690
|
+
this.db.exec("COMMIT");
|
|
691
|
+
return result;
|
|
692
|
+
} catch (err) {
|
|
693
|
+
this.db.exec("ROLLBACK");
|
|
694
|
+
throw err;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
upsertConversation(record) {
|
|
698
|
+
this.transaction(() => this.writeRecord(record));
|
|
699
|
+
}
|
|
700
|
+
removeConversation(docId) {
|
|
701
|
+
this.transaction(() => {
|
|
702
|
+
this.stmts.deleteFts.run(docId);
|
|
703
|
+
this.stmts.deleteConversation.run(docId);
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Clear the index and repopulate it from the corpus — proving the corpus is
|
|
708
|
+
* the source of truth. Uses one manual transaction spanning the async file
|
|
709
|
+
* reads, which the sync {@link transaction} helper cannot hold.
|
|
710
|
+
*/
|
|
711
|
+
async rebuildFrom(corpus) {
|
|
712
|
+
this.db.exec("DELETE FROM messages_fts; DELETE FROM conversations;");
|
|
713
|
+
let conversations = 0;
|
|
714
|
+
this.db.exec("BEGIN");
|
|
715
|
+
try {
|
|
716
|
+
for await (const record of corpus.iterate()) {
|
|
717
|
+
this.writeRecord(record);
|
|
718
|
+
conversations += 1;
|
|
719
|
+
}
|
|
720
|
+
this.db.exec("COMMIT");
|
|
721
|
+
} catch (err) {
|
|
722
|
+
this.db.exec("ROLLBACK");
|
|
723
|
+
throw err;
|
|
724
|
+
}
|
|
725
|
+
return { conversations };
|
|
726
|
+
}
|
|
727
|
+
// Replace a conversation's index rows. Caller supplies the transaction (the
|
|
728
|
+
// transaction() helper for single upserts, or rebuildFrom's manual one).
|
|
729
|
+
writeRecord(rec) {
|
|
730
|
+
const { conversation, messages } = rec;
|
|
731
|
+
this.stmts.deleteFts.run(conversation.docId);
|
|
732
|
+
this.stmts.deleteConversation.run(conversation.docId);
|
|
733
|
+
const titleText = [conversation.title, conversation.preview].filter(Boolean).join("\n");
|
|
734
|
+
if (titleText.trim()) {
|
|
735
|
+
this.stmts.insertFts.run(conversation.docId, TITLE_ROW_INDEX, TITLE_ROLE, titleText);
|
|
736
|
+
}
|
|
737
|
+
for (const message of messages) {
|
|
738
|
+
if (message.text.trim()) {
|
|
739
|
+
this.stmts.insertFts.run(conversation.docId, message.msgIndex, message.role, message.text);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
this.stmts.insertConversation.run({
|
|
743
|
+
docId: conversation.docId,
|
|
744
|
+
platform: conversation.platform,
|
|
745
|
+
title: conversation.title,
|
|
746
|
+
url: conversation.url,
|
|
747
|
+
createdAt: conversation.createdAt,
|
|
748
|
+
updatedAt: conversation.updatedAt ?? conversation.contentUpdatedAt ?? 0,
|
|
749
|
+
messageCount: conversation.messageCount ?? messages.length,
|
|
750
|
+
preview: conversation.preview ?? "",
|
|
751
|
+
pinned: conversation.pinned ? 1 : 0
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
search(query, filters = {}) {
|
|
755
|
+
const match = toMatchQuery(query, filters.matchMode);
|
|
756
|
+
if (!match) {
|
|
757
|
+
return [];
|
|
758
|
+
}
|
|
759
|
+
const limit = Math.max(1, filters.limit ?? DEFAULT_LIMIT);
|
|
760
|
+
const clauses = ["messages_fts MATCH @match"];
|
|
761
|
+
const params = { match };
|
|
762
|
+
if (filters.platform) {
|
|
763
|
+
clauses.push("c.platform = @platform");
|
|
764
|
+
params.platform = filters.platform;
|
|
765
|
+
}
|
|
766
|
+
if (filters.title) {
|
|
767
|
+
clauses.push("c.title LIKE @title ESCAPE '\\'");
|
|
768
|
+
params.title = `%${escapeLike(filters.title)}%`;
|
|
769
|
+
}
|
|
770
|
+
if (typeof filters.dateFrom === "number") {
|
|
771
|
+
clauses.push("c.createdAt >= @dateFrom");
|
|
772
|
+
params.dateFrom = filters.dateFrom;
|
|
773
|
+
}
|
|
774
|
+
if (typeof filters.dateTo === "number") {
|
|
775
|
+
clauses.push("c.createdAt <= @dateTo");
|
|
776
|
+
params.dateTo = filters.dateTo;
|
|
777
|
+
}
|
|
778
|
+
const scanCap = Math.min(2e3, Math.max(200, limit * 40));
|
|
779
|
+
const sql = `
|
|
780
|
+
SELECT
|
|
781
|
+
c.docId, c.platform, c.title, c.url, c.createdAt, c.updatedAt,
|
|
782
|
+
c.messageCount, c.preview, c.pinned,
|
|
783
|
+
m.msgIndex AS msgIndex, m.role AS role,
|
|
784
|
+
snippet(messages_fts, 3, '[', ']', '\u2026', ${SNIPPET_TOKENS}) AS snippet,
|
|
785
|
+
bm25(messages_fts) AS bm25
|
|
786
|
+
FROM messages_fts m
|
|
787
|
+
JOIN conversations c ON c.docId = m.docId
|
|
788
|
+
WHERE ${clauses.join(" AND ")}
|
|
789
|
+
ORDER BY bm25
|
|
790
|
+
LIMIT @scanCap
|
|
791
|
+
`;
|
|
792
|
+
const rows = this.db.prepare(sql).all({ ...params, scanCap });
|
|
793
|
+
const seen = /* @__PURE__ */ new Set();
|
|
794
|
+
const hits = [];
|
|
795
|
+
for (const row of rows) {
|
|
796
|
+
if (seen.has(row.docId)) {
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
seen.add(row.docId);
|
|
800
|
+
hits.push({
|
|
801
|
+
docId: row.docId,
|
|
802
|
+
platform: row.platform,
|
|
803
|
+
title: row.title,
|
|
804
|
+
url: row.url,
|
|
805
|
+
createdAt: row.createdAt,
|
|
806
|
+
updatedAt: row.updatedAt,
|
|
807
|
+
messageCount: row.messageCount,
|
|
808
|
+
preview: row.preview,
|
|
809
|
+
pinned: row.pinned === 1,
|
|
810
|
+
// BM25 is negative (more negative = better); expose a positive relevance.
|
|
811
|
+
score: -row.bm25,
|
|
812
|
+
snippet: row.snippet,
|
|
813
|
+
matchRole: normalizeMatchRole(row.role),
|
|
814
|
+
matchMsgIndex: row.msgIndex
|
|
815
|
+
});
|
|
816
|
+
if (hits.length >= limit) {
|
|
817
|
+
break;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return hits;
|
|
821
|
+
}
|
|
822
|
+
close() {
|
|
823
|
+
this.db.close();
|
|
824
|
+
}
|
|
825
|
+
getMeta(key) {
|
|
826
|
+
const row = this.db.prepare("SELECT value FROM index_meta WHERE key = ?").get(key);
|
|
827
|
+
return row ? row.value : null;
|
|
828
|
+
}
|
|
829
|
+
setMeta(key, value) {
|
|
830
|
+
this.db.prepare("INSERT INTO index_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
async function openSearchIndex(corpus, options = {}) {
|
|
834
|
+
await corpus.init();
|
|
835
|
+
const index = new SearchIndex(corpus.paths.indexDbFile);
|
|
836
|
+
if (options.autoBuild !== false && (index.needsRebuild() || index.count() === 0)) {
|
|
837
|
+
await index.rebuildFrom(corpus);
|
|
838
|
+
}
|
|
839
|
+
return index;
|
|
840
|
+
}
|
|
841
|
+
async function deleteIndexDb(dbFile) {
|
|
842
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
843
|
+
await fs3.rm(`${dbFile}${suffix}`, { force: true });
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
function toMatchQuery(query, mode = "any") {
|
|
847
|
+
const terms = query.split(/[^\p{L}\p{N}]+/u).filter((t) => t.length > 0);
|
|
848
|
+
if (terms.length === 0) {
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
if (mode === "phrase") {
|
|
852
|
+
return `"${terms.join(" ")}"`;
|
|
853
|
+
}
|
|
854
|
+
return terms.map((t) => `"${t}"`).join(mode === "all" ? " AND " : " OR ");
|
|
855
|
+
}
|
|
856
|
+
function escapeLike(value) {
|
|
857
|
+
return value.replace(/[\\%_]/g, "\\$&");
|
|
858
|
+
}
|
|
859
|
+
function normalizeMatchRole(role) {
|
|
860
|
+
if (role === "title") {
|
|
861
|
+
return "title";
|
|
862
|
+
}
|
|
863
|
+
return role === "assistant" || role === "user" || role === "system" ? role : "unknown";
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// src/version.ts
|
|
867
|
+
var VERSION = true ? "0.1.0" : "0.0.0-dev";
|
|
868
|
+
|
|
869
|
+
export {
|
|
870
|
+
__commonJS,
|
|
871
|
+
__export,
|
|
872
|
+
__toESM,
|
|
873
|
+
resolveCorpusDir,
|
|
874
|
+
Corpus,
|
|
875
|
+
ingestFile,
|
|
876
|
+
pendingInboxFiles,
|
|
877
|
+
drainInbox,
|
|
878
|
+
SearchIndex,
|
|
879
|
+
openSearchIndex,
|
|
880
|
+
deleteIndexDb,
|
|
881
|
+
VERSION
|
|
882
|
+
};
|
|
883
|
+
//# sourceMappingURL=chunk-OBJD6H4A.js.map
|