memorandum-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.
Files changed (52) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +237 -0
  3. package/README.ru.md +237 -0
  4. package/dist/config.d.ts +36 -0
  5. package/dist/config.js +63 -0
  6. package/dist/config.js.map +1 -0
  7. package/dist/document-store.d.ts +145 -0
  8. package/dist/document-store.js +682 -0
  9. package/dist/document-store.js.map +1 -0
  10. package/dist/document-tools.d.ts +10 -0
  11. package/dist/document-tools.js +101 -0
  12. package/dist/document-tools.js.map +1 -0
  13. package/dist/document-types.d.ts +147 -0
  14. package/dist/document-types.js +125 -0
  15. package/dist/document-types.js.map +1 -0
  16. package/dist/embedder.d.ts +55 -0
  17. package/dist/embedder.js +152 -0
  18. package/dist/embedder.js.map +1 -0
  19. package/dist/embedding-queue.d.ts +66 -0
  20. package/dist/embedding-queue.js +152 -0
  21. package/dist/embedding-queue.js.map +1 -0
  22. package/dist/errors.d.ts +26 -0
  23. package/dist/errors.js +46 -0
  24. package/dist/errors.js.map +1 -0
  25. package/dist/index.d.ts +9 -0
  26. package/dist/index.js +147 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/logger.d.ts +12 -0
  29. package/dist/logger.js +22 -0
  30. package/dist/logger.js.map +1 -0
  31. package/dist/semantic-index.d.ts +126 -0
  32. package/dist/semantic-index.js +427 -0
  33. package/dist/semantic-index.js.map +1 -0
  34. package/dist/semantic-tools.d.ts +10 -0
  35. package/dist/semantic-tools.js +80 -0
  36. package/dist/semantic-tools.js.map +1 -0
  37. package/dist/semantic-types.d.ts +161 -0
  38. package/dist/semantic-types.js +101 -0
  39. package/dist/semantic-types.js.map +1 -0
  40. package/dist/store.d.ts +130 -0
  41. package/dist/store.js +389 -0
  42. package/dist/store.js.map +1 -0
  43. package/dist/tools.d.ts +15 -0
  44. package/dist/tools.js +104 -0
  45. package/dist/tools.js.map +1 -0
  46. package/dist/types.d.ts +97 -0
  47. package/dist/types.js +88 -0
  48. package/dist/types.js.map +1 -0
  49. package/dist/vector-store.d.ts +85 -0
  50. package/dist/vector-store.js +241 -0
  51. package/dist/vector-store.js.map +1 -0
  52. package/package.json +50 -0
@@ -0,0 +1,682 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, readdirSync, statSync, accessSync, constants as fsConstants, } from 'node:fs';
2
+ import { join, dirname, resolve, basename, parse as pathParse } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
5
+ import { isInlineContentType, RESERVED_FIELDS, DEFAULT_LIST_LIMIT, mimeTypeFromExtension } from './document-types.js';
6
+ import { MemorandumError } from './errors.js';
7
+ function emptyIndex() {
8
+ return { version: 1, next_id: 1, documents: [] };
9
+ }
10
+ /**
11
+ * Manages document storage, indexing, and CRUD operations.
12
+ * Supports inline (markdown with frontmatter) and binary (blob with YAML sidecar) documents.
13
+ */
14
+ export class DocumentStore {
15
+ config;
16
+ logger;
17
+ documentsPath;
18
+ blobsPath;
19
+ indexPath;
20
+ index;
21
+ semanticIndex = null;
22
+ dirty = false;
23
+ constructor(config, paths, logger) {
24
+ this.config = config;
25
+ this.logger = logger;
26
+ this.documentsPath = paths.documentsPath;
27
+ this.blobsPath = join(paths.documentsPath, 'blobs');
28
+ this.indexPath = join(paths.documentsPath, '_index.yaml');
29
+ this.index = emptyIndex();
30
+ }
31
+ /** Assigns the semantic index used for embedding-based search. */
32
+ setSemanticIndex(index) {
33
+ this.semanticIndex = index;
34
+ }
35
+ // ============================================================================
36
+ // Directory Management
37
+ // ============================================================================
38
+ /** Creates the documents and blobs directories if they do not already exist. */
39
+ ensureDirectories() {
40
+ if (!existsSync(this.documentsPath)) {
41
+ mkdirSync(this.documentsPath, { recursive: true });
42
+ }
43
+ if (!existsSync(this.blobsPath)) {
44
+ mkdirSync(this.blobsPath, { recursive: true });
45
+ }
46
+ }
47
+ // ============================================================================
48
+ // Index Load / Save / Rebuild
49
+ // ============================================================================
50
+ /** Loads the document index from disk, rebuilding it if the file is missing or corrupt. */
51
+ loadIndex() {
52
+ this.ensureDirectories();
53
+ if (!existsSync(this.indexPath)) {
54
+ this.logger.info({ path: this.indexPath }, 'Index file not found, rebuilding');
55
+ this.rebuildIndex();
56
+ return;
57
+ }
58
+ try {
59
+ const raw = readFileSync(this.indexPath, 'utf-8');
60
+ const parsed = yamlParse(raw);
61
+ if (typeof parsed !== 'object' || parsed === null ||
62
+ typeof parsed.version !== 'number' ||
63
+ typeof parsed.next_id !== 'number' ||
64
+ !Array.isArray(parsed.documents)) {
65
+ throw new Error('Invalid index structure');
66
+ }
67
+ this.index = parsed;
68
+ this.dirty = false;
69
+ this.logger.debug({ path: this.indexPath, documents: this.index.documents.length }, 'Index loaded');
70
+ }
71
+ catch (err) {
72
+ this.logger.warn({ path: this.indexPath, error: err instanceof Error ? err.message : String(err) }, 'Failed to parse index file, rebuilding');
73
+ this.rebuildIndex();
74
+ }
75
+ }
76
+ /**
77
+ * Persists the in-memory index to disk if it has been modified.
78
+ * @returns `true` if the index was written, `false` if no save was needed.
79
+ */
80
+ saveIndex() {
81
+ if (!this.dirty)
82
+ return false;
83
+ if (this.index.documents.length === 0 && this.index.next_id <= 1 && existsSync(this.indexPath)) {
84
+ try {
85
+ const diskRaw = readFileSync(this.indexPath, 'utf-8');
86
+ const diskIndex = yamlParse(diskRaw);
87
+ if (diskIndex && typeof diskIndex === 'object' && diskIndex.next_id > 1) {
88
+ this.logger.warn('Skipping index save: disk has newer data');
89
+ return false;
90
+ }
91
+ }
92
+ catch { /* proceed */ }
93
+ }
94
+ const yaml = yamlStringify(this.index);
95
+ this.atomicWriteFile(this.indexPath, yaml);
96
+ this.dirty = false;
97
+ return true;
98
+ }
99
+ /** Reconstructs the index by scanning all document files on disk. */
100
+ rebuildIndex() {
101
+ this.logger.warn({ path: this.documentsPath }, 'Rebuilding document index from files');
102
+ const newIndex = { version: 1, next_id: 1, documents: [] };
103
+ if (!existsSync(this.documentsPath)) {
104
+ this.index = newIndex;
105
+ return;
106
+ }
107
+ const files = readdirSync(this.documentsPath);
108
+ let maxId = 0;
109
+ const SKIP_ENTRIES = new Set(['_index.yaml', 'blobs']);
110
+ for (const file of files) {
111
+ if (SKIP_ENTRIES.has(file) || file.endsWith('.tmp'))
112
+ continue;
113
+ const filePath = join(this.documentsPath, file);
114
+ try {
115
+ if (statSync(filePath).isDirectory())
116
+ continue;
117
+ }
118
+ catch {
119
+ continue;
120
+ }
121
+ try {
122
+ if (file.endsWith('.md')) {
123
+ const content = readFileSync(filePath, 'utf-8');
124
+ const { metadata } = this.parseFrontmatter(content);
125
+ if (metadata.id && typeof metadata.id === 'string') {
126
+ const doc = {
127
+ id: metadata.id,
128
+ title: metadata.title ?? file,
129
+ content_type: metadata.content_type ?? 'text/plain',
130
+ tags: Array.isArray(metadata.tags) ? metadata.tags : [],
131
+ created_at: metadata.created_at ?? new Date().toISOString(),
132
+ updated_at: metadata.updated_at ?? new Date().toISOString(),
133
+ };
134
+ for (const [key, value] of Object.entries(metadata)) {
135
+ if (!(key in doc))
136
+ doc[key] = value;
137
+ }
138
+ newIndex.documents.push(doc);
139
+ const numPart = parseInt(doc.id.replace('doc-', ''), 10);
140
+ if (!isNaN(numPart) && numPart > maxId)
141
+ maxId = numPart;
142
+ }
143
+ }
144
+ else if (file.endsWith('.yaml') && file !== '_index.yaml') {
145
+ const content = readFileSync(filePath, 'utf-8');
146
+ const metadata = yamlParse(content);
147
+ if (metadata && typeof metadata === 'object' && metadata.id && typeof metadata.id === 'string') {
148
+ const doc = {
149
+ id: metadata.id,
150
+ title: metadata.title ?? file,
151
+ content_type: metadata.content_type ?? 'application/octet-stream',
152
+ tags: Array.isArray(metadata.tags) ? metadata.tags : [],
153
+ created_at: metadata.created_at ?? new Date().toISOString(),
154
+ updated_at: metadata.updated_at ?? new Date().toISOString(),
155
+ };
156
+ for (const [key, value] of Object.entries(metadata)) {
157
+ if (!(key in doc))
158
+ doc[key] = value;
159
+ }
160
+ newIndex.documents.push(doc);
161
+ const numPart = parseInt(doc.id.replace('doc-', ''), 10);
162
+ if (!isNaN(numPart) && numPart > maxId)
163
+ maxId = numPart;
164
+ }
165
+ }
166
+ }
167
+ catch (err) {
168
+ this.logger.warn({ file, error: err instanceof Error ? err.message : String(err) }, 'Failed to parse document during rebuild, skipping');
169
+ }
170
+ }
171
+ newIndex.next_id = maxId + 1;
172
+ this.index = newIndex;
173
+ this.dirty = true;
174
+ this.saveIndex();
175
+ this.logger.info({ documents: newIndex.documents.length, next_id: newIndex.next_id }, 'Index rebuilt');
176
+ }
177
+ // ============================================================================
178
+ // Frontmatter Helpers
179
+ // ============================================================================
180
+ /**
181
+ * Extracts YAML frontmatter and body from a markdown string.
182
+ * @param content - Raw file content with optional `---` delimited frontmatter.
183
+ * @returns Parsed metadata object and the remaining body text.
184
+ */
185
+ parseFrontmatter(content) {
186
+ if (!content.startsWith('---\n'))
187
+ return { metadata: {}, body: content };
188
+ const closingIndex = content.indexOf('\n---\n', 4);
189
+ if (closingIndex === -1)
190
+ return { metadata: {}, body: content };
191
+ const yamlBlock = content.slice(4, closingIndex);
192
+ const body = content.slice(closingIndex + 5);
193
+ try {
194
+ const parsed = yamlParse(yamlBlock);
195
+ return { metadata: typeof parsed === 'object' && parsed !== null ? parsed : {}, body };
196
+ }
197
+ catch {
198
+ return { metadata: {}, body: content };
199
+ }
200
+ }
201
+ /**
202
+ * Combines metadata and body into a markdown string with YAML frontmatter.
203
+ * @param metadata - Key-value pairs to serialize as frontmatter.
204
+ * @param body - The document body text.
205
+ * @returns Formatted string with `---` delimited frontmatter followed by the body.
206
+ */
207
+ serializeFrontmatter(metadata, body) {
208
+ const yaml = yamlStringify(metadata);
209
+ return `---\n${yaml}---\n${body}`;
210
+ }
211
+ // ============================================================================
212
+ // Atomic File Write
213
+ // ============================================================================
214
+ /**
215
+ * Writes a file atomically by writing to a temporary file first, then renaming.
216
+ * @param filePath - Destination file path.
217
+ * @param content - String or Buffer content to write.
218
+ */
219
+ atomicWriteFile(filePath, content) {
220
+ const dir = dirname(filePath);
221
+ if (!existsSync(dir))
222
+ mkdirSync(dir, { recursive: true });
223
+ const tmpPath = `${filePath}.${process.pid}-${Date.now()}.tmp`;
224
+ if (typeof content === 'string') {
225
+ writeFileSync(tmpPath, content, 'utf-8');
226
+ }
227
+ else {
228
+ writeFileSync(tmpPath, content);
229
+ }
230
+ renameSync(tmpPath, filePath);
231
+ }
232
+ // ============================================================================
233
+ // ID Generation
234
+ // ============================================================================
235
+ /**
236
+ * Generates and returns the next unique document ID (e.g., `doc-001`).
237
+ * Increments the internal counter and marks the index as dirty.
238
+ */
239
+ getNextId() {
240
+ const id = this.index.next_id;
241
+ this.index.next_id += 1;
242
+ this.dirty = true;
243
+ return `doc-${String(id).padStart(3, '0')}`;
244
+ }
245
+ // ============================================================================
246
+ // Index Access
247
+ // ============================================================================
248
+ /** Returns the current in-memory index. */
249
+ getIndex() { return this.index; }
250
+ /** Replaces the in-memory index with the provided one. */
251
+ setIndex(index) { this.index = index; }
252
+ /** Absolute path to the documents directory. */
253
+ get documentsDir() { return this.documentsPath; }
254
+ /** Absolute path to the blobs subdirectory. */
255
+ get blobsDir() { return this.blobsPath; }
256
+ /** Maximum allowed document size in bytes. */
257
+ get maxDocumentSize() { return this.config.max_document_size; }
258
+ // ============================================================================
259
+ // Validation Helpers
260
+ // ============================================================================
261
+ validateDocumentId(id) {
262
+ if (!/^doc-\d{3,}$/.test(id)) {
263
+ throw new MemorandumError('validation_error', `Invalid document ID format: '${id}'. Expected: doc-NNN`, { id });
264
+ }
265
+ }
266
+ validateBodySize(body) {
267
+ const bodySize = Buffer.byteLength(body, 'utf-8');
268
+ if (bodySize > this.config.max_document_size) {
269
+ throw new MemorandumError('document_too_large', `Document body size (${bodySize} bytes) exceeds limit of ${this.config.max_document_size} bytes.`);
270
+ }
271
+ }
272
+ validateMetadataKeys(metadata) {
273
+ const conflicting = Object.keys(metadata).filter((k) => RESERVED_FIELDS.includes(k));
274
+ if (conflicting.length > 0) {
275
+ throw new MemorandumError('validation_error', `Custom metadata keys conflict with reserved fields: ${conflicting.join(', ')}`, { conflicting });
276
+ }
277
+ }
278
+ // ============================================================================
279
+ // File Import Helpers
280
+ // ============================================================================
281
+ resolveFilePath(filePath, explicitContentType) {
282
+ const resolvedPath = resolve(filePath);
283
+ let stat;
284
+ try {
285
+ stat = statSync(resolvedPath);
286
+ }
287
+ catch {
288
+ throw new MemorandumError('file_not_found', `File not found: '${resolvedPath}'`);
289
+ }
290
+ if (stat.isDirectory()) {
291
+ throw new MemorandumError('file_is_directory', `Path is a directory: '${resolvedPath}'`);
292
+ }
293
+ try {
294
+ accessSync(resolvedPath, fsConstants.R_OK);
295
+ }
296
+ catch {
297
+ throw new MemorandumError('file_access_denied', `No read permission: '${resolvedPath}'`);
298
+ }
299
+ if (stat.size > this.config.max_document_size) {
300
+ throw new MemorandumError('document_too_large', `File size (${stat.size} bytes) exceeds limit`);
301
+ }
302
+ const fileBasename = basename(resolvedPath);
303
+ const parsed = pathParse(resolvedPath);
304
+ return {
305
+ resolvedPath,
306
+ size: stat.size,
307
+ contentType: explicitContentType ?? mimeTypeFromExtension(parsed.ext),
308
+ basename: fileBasename,
309
+ nameWithoutExt: parsed.name,
310
+ };
311
+ }
312
+ injectFileContent(input, logMessage) {
313
+ if (input.file_path === undefined)
314
+ return undefined;
315
+ const fileInfo = this.resolveFilePath(input.file_path, input.content_type);
316
+ input.content_type = fileInfo.contentType;
317
+ if (isInlineContentType(fileInfo.contentType)) {
318
+ input.body = readFileSync(fileInfo.resolvedPath, 'utf-8');
319
+ }
320
+ else {
321
+ input.body = readFileSync(fileInfo.resolvedPath).toString('base64');
322
+ }
323
+ input.metadata = { ...input.metadata, source_file: fileInfo.basename };
324
+ this.logger.debug({ path: fileInfo.resolvedPath, contentType: fileInfo.contentType, size: fileInfo.size }, logMessage);
325
+ return fileInfo;
326
+ }
327
+ // ============================================================================
328
+ // CRUD Methods
329
+ // ============================================================================
330
+ /**
331
+ * Creates or updates a document based on the `_mode` field.
332
+ * Handles both inline (text) and binary content, including file imports.
333
+ * @returns The document ID and whether it was newly created.
334
+ */
335
+ write(input) {
336
+ return input._mode === 'update' ? this.writeUpdate(input) : this.writeCreate(input);
337
+ }
338
+ writeCreate(input) {
339
+ const fileInfo = this.injectFileContent(input, 'File imported for document creation');
340
+ if (fileInfo && !input.title)
341
+ input.title = fileInfo.nameWithoutExt;
342
+ if (!input.title)
343
+ throw new MemorandumError('validation_error', 'title is required for document creation');
344
+ if (input.body !== undefined)
345
+ this.validateBodySize(input.body);
346
+ if (input.metadata)
347
+ this.validateMetadataKeys(input.metadata);
348
+ const id = this.getNextId();
349
+ const normalizedTags = (input.tags ?? []).map((t) => t.trim().toLowerCase());
350
+ const now = new Date().toISOString();
351
+ const metadata = {
352
+ id, title: input.title,
353
+ content_type: input.content_type ?? 'text/plain',
354
+ tags: normalizedTags, created_at: now, updated_at: now,
355
+ };
356
+ if (input.topic)
357
+ metadata.topic = input.topic;
358
+ if (input.description)
359
+ metadata.description = input.description;
360
+ if (input.metadata) {
361
+ for (const [key, value] of Object.entries(input.metadata)) {
362
+ if (!RESERVED_FIELDS.includes(key))
363
+ metadata[key] = value;
364
+ }
365
+ }
366
+ if (isInlineContentType(metadata.content_type)) {
367
+ const filePath = join(this.documentsPath, `${id}.md`);
368
+ this.atomicWriteFile(filePath, this.serializeFrontmatter(metadata, input.body ?? ''));
369
+ }
370
+ else {
371
+ const bodyData = input.body ? Buffer.from(input.body, 'base64') : Buffer.alloc(0);
372
+ const ext = metadata.content_type.split('/').pop() ?? 'bin';
373
+ const blobFileName = `${id}.${ext}`;
374
+ this.atomicWriteFile(join(this.blobsDir, blobFileName), bodyData);
375
+ const sha256 = createHash('sha256').update(bodyData).digest('hex');
376
+ metadata.blob_path = `blobs/${blobFileName}`;
377
+ metadata.blob_size = bodyData.length;
378
+ metadata.blob_sha256 = sha256;
379
+ this.atomicWriteFile(join(this.documentsDir, `${id}.yaml`), yamlStringify(metadata));
380
+ }
381
+ this.index.documents.push(metadata);
382
+ this.dirty = true;
383
+ this.saveIndex();
384
+ if (this.semanticIndex) {
385
+ this.semanticIndex.enqueueDocument(id, {
386
+ title: metadata.title, topic: metadata.topic,
387
+ tags: metadata.tags, contentType: metadata.content_type,
388
+ description: typeof metadata.description === 'string' ? metadata.description : undefined,
389
+ }, input.body);
390
+ }
391
+ return { id, created: true };
392
+ }
393
+ writeUpdate(input) {
394
+ this.injectFileContent(input, 'File imported for document update');
395
+ this.validateDocumentId(input.id);
396
+ const existingIdx = this.index.documents.findIndex((d) => d.id === input.id);
397
+ if (existingIdx === -1) {
398
+ throw new MemorandumError('document_not_found', `Document '${input.id}' not found`, { id: input.id });
399
+ }
400
+ const existing = this.index.documents[existingIdx];
401
+ if (input.body !== undefined)
402
+ this.validateBodySize(input.body);
403
+ if (input.metadata)
404
+ this.validateMetadataKeys(input.metadata);
405
+ const wasInline = isInlineContentType(existing.content_type);
406
+ if (input.title !== undefined)
407
+ existing.title = input.title;
408
+ if (input.content_type !== undefined)
409
+ existing.content_type = input.content_type;
410
+ if (input.topic !== undefined)
411
+ existing.topic = input.topic;
412
+ if (input.description !== undefined)
413
+ existing.description = input.description;
414
+ if (input.tags !== undefined)
415
+ existing.tags = input.tags.map((t) => t.trim().toLowerCase());
416
+ existing.updated_at = new Date().toISOString();
417
+ if (input.metadata) {
418
+ for (const [key, value] of Object.entries(input.metadata)) {
419
+ if (!RESERVED_FIELDS.includes(key))
420
+ existing[key] = value;
421
+ }
422
+ }
423
+ const isInline = isInlineContentType(existing.content_type);
424
+ if (wasInline !== isInline) {
425
+ if (wasInline) {
426
+ const oldMdPath = join(this.documentsPath, `${existing.id}.md`);
427
+ if (existsSync(oldMdPath))
428
+ unlinkSync(oldMdPath);
429
+ }
430
+ else {
431
+ const oldSidecarPath = join(this.documentsPath, `${existing.id}.yaml`);
432
+ if (existsSync(oldSidecarPath))
433
+ unlinkSync(oldSidecarPath);
434
+ if (existing.blob_path) {
435
+ const oldBlobPath = join(this.documentsPath, existing.blob_path);
436
+ if (existsSync(oldBlobPath))
437
+ unlinkSync(oldBlobPath);
438
+ }
439
+ delete existing.blob_path;
440
+ delete existing.blob_size;
441
+ delete existing.blob_sha256;
442
+ }
443
+ }
444
+ if (isInline) {
445
+ let bodyContent = input.body;
446
+ if (bodyContent === undefined) {
447
+ const filePath = join(this.documentsPath, `${existing.id}.md`);
448
+ if (existsSync(filePath)) {
449
+ bodyContent = this.parseFrontmatter(readFileSync(filePath, 'utf-8')).body;
450
+ }
451
+ else {
452
+ bodyContent = '';
453
+ }
454
+ }
455
+ this.atomicWriteFile(join(this.documentsPath, `${existing.id}.md`), this.serializeFrontmatter(existing, bodyContent ?? ''));
456
+ }
457
+ else {
458
+ if (input.body !== undefined) {
459
+ if (existing.blob_path) {
460
+ const oldBlobPath = join(this.documentsPath, existing.blob_path);
461
+ const newExt = existing.content_type.split('/').pop() ?? 'bin';
462
+ const newBlobFileName = `${existing.id}.${newExt}`;
463
+ if (existing.blob_path !== `blobs/${newBlobFileName}` && existsSync(oldBlobPath)) {
464
+ unlinkSync(oldBlobPath);
465
+ }
466
+ }
467
+ const bodyData = Buffer.from(input.body, 'base64');
468
+ const ext = existing.content_type.split('/').pop() ?? 'bin';
469
+ const blobFileName = `${existing.id}.${ext}`;
470
+ this.atomicWriteFile(join(this.blobsDir, blobFileName), bodyData);
471
+ const sha256 = createHash('sha256').update(bodyData).digest('hex');
472
+ existing.blob_path = `blobs/${blobFileName}`;
473
+ existing.blob_size = bodyData.length;
474
+ existing.blob_sha256 = sha256;
475
+ }
476
+ this.atomicWriteFile(join(this.documentsDir, `${existing.id}.yaml`), yamlStringify(existing));
477
+ }
478
+ this.index.documents[existingIdx] = existing;
479
+ this.dirty = true;
480
+ this.saveIndex();
481
+ if (this.semanticIndex) {
482
+ let bodyForIndex;
483
+ if (isInlineContentType(existing.content_type)) {
484
+ const filePath = join(this.documentsPath, `${existing.id}.md`);
485
+ if (existsSync(filePath)) {
486
+ bodyForIndex = this.parseFrontmatter(readFileSync(filePath, 'utf-8')).body;
487
+ }
488
+ }
489
+ this.semanticIndex.enqueueDocument(existing.id, {
490
+ title: existing.title, topic: existing.topic,
491
+ tags: existing.tags, contentType: existing.content_type,
492
+ description: typeof existing.description === 'string' ? existing.description : undefined,
493
+ }, bodyForIndex);
494
+ }
495
+ return { id: input.id, created: false };
496
+ }
497
+ /**
498
+ * Reads a document by ID, returning its metadata and optionally its body content.
499
+ * @param input.id - Document ID (e.g., `doc-001`).
500
+ * @param input.include_body - Whether to include the body content (defaults to `true`).
501
+ * @returns Document metadata and body. Binary bodies are base64-encoded.
502
+ */
503
+ read(input) {
504
+ this.validateDocumentId(input.id);
505
+ const doc = this.index.documents.find((d) => d.id === input.id);
506
+ if (!doc)
507
+ throw new MemorandumError('document_not_found', `Document '${input.id}' not found`, { id: input.id });
508
+ const result = { ...doc };
509
+ if (input.include_body !== false) {
510
+ if (isInlineContentType(doc.content_type)) {
511
+ const filePath = join(this.documentsPath, `${doc.id}.md`);
512
+ try {
513
+ const { body } = this.parseFrontmatter(readFileSync(filePath, 'utf-8'));
514
+ result['body'] = body;
515
+ }
516
+ catch (err) {
517
+ if (err.code === 'ENOENT') {
518
+ this.logger.warn({ id: doc.id }, 'Document file missing on disk');
519
+ }
520
+ else {
521
+ throw err;
522
+ }
523
+ }
524
+ }
525
+ else if (doc.blob_path) {
526
+ const blobPath = join(this.documentsPath, doc.blob_path);
527
+ if (existsSync(blobPath)) {
528
+ const blobData = readFileSync(blobPath);
529
+ result['body'] = blobData.toString('base64');
530
+ const sha256 = createHash('sha256').update(blobData).digest('hex');
531
+ result['integrity_ok'] = sha256 === doc.blob_sha256;
532
+ }
533
+ }
534
+ }
535
+ return result;
536
+ }
537
+ /**
538
+ * Lists documents matching the given filters.
539
+ * Supports filtering by tag, topic, content type, text search, and custom metadata.
540
+ * @returns Matching documents (up to `limit`) and the total count.
541
+ */
542
+ list(input) {
543
+ const limit = input.limit ?? DEFAULT_LIST_LIMIT;
544
+ let filtered = [...this.index.documents];
545
+ if (input.tag !== undefined)
546
+ filtered = filtered.filter((d) => d.tags.includes(input.tag));
547
+ if (input.topic !== undefined)
548
+ filtered = filtered.filter((d) => d.topic === input.topic);
549
+ if (input.content_type !== undefined)
550
+ filtered = filtered.filter((d) => d.content_type === input.content_type);
551
+ if (input.search !== undefined) {
552
+ const needle = input.search.toLowerCase();
553
+ filtered = filtered.filter((d) => d.title.toLowerCase().includes(needle) ||
554
+ (typeof d.description === 'string' && d.description.toLowerCase().includes(needle)));
555
+ }
556
+ if (input.metadata !== undefined) {
557
+ for (const [key, value] of Object.entries(input.metadata)) {
558
+ filtered = filtered.filter((d) => {
559
+ const docValue = d[key];
560
+ if (typeof value !== 'object' || value === null)
561
+ return docValue === value;
562
+ return JSON.stringify(docValue) === JSON.stringify(value);
563
+ });
564
+ }
565
+ }
566
+ const total = filtered.length;
567
+ return { documents: filtered.slice(0, limit).map((d) => ({ ...d })), total };
568
+ }
569
+ /**
570
+ * Deletes a document by ID, removing its files from disk and the index entry.
571
+ * @param input.id - Document ID to delete.
572
+ * @returns `{ deleted: true }` if found and removed, `{ deleted: false }` if not found.
573
+ */
574
+ delete(input) {
575
+ this.validateDocumentId(input.id);
576
+ const idx = this.index.documents.findIndex((d) => d.id === input.id);
577
+ if (idx === -1)
578
+ return { deleted: false };
579
+ const doc = this.index.documents[idx];
580
+ if (isInlineContentType(doc.content_type)) {
581
+ const filePath = join(this.documentsPath, `${doc.id}.md`);
582
+ if (existsSync(filePath))
583
+ unlinkSync(filePath);
584
+ }
585
+ else {
586
+ const sidecarPath = join(this.documentsPath, `${doc.id}.yaml`);
587
+ if (existsSync(sidecarPath))
588
+ unlinkSync(sidecarPath);
589
+ if (doc.blob_path) {
590
+ const blobPath = join(this.documentsPath, doc.blob_path);
591
+ if (existsSync(blobPath))
592
+ unlinkSync(blobPath);
593
+ }
594
+ }
595
+ this.index.documents.splice(idx, 1);
596
+ this.dirty = true;
597
+ this.saveIndex();
598
+ if (this.semanticIndex)
599
+ this.semanticIndex.removeDocument(doc.id);
600
+ return { deleted: true };
601
+ }
602
+ // ============================================================================
603
+ // Restore (extract document to disk)
604
+ // ============================================================================
605
+ /**
606
+ * Restores a document from the store to a file on disk.
607
+ * Verifies SHA256 integrity for binary documents. Refuses to overwrite
608
+ * existing files unless force=true.
609
+ *
610
+ * @param input - Restore parameters: id, target_path, force
611
+ * @returns Result with success status, path, and optional integrity info
612
+ */
613
+ restore(input) {
614
+ this.validateDocumentId(input.id);
615
+ const doc = this.index.documents.find((d) => d.id === input.id);
616
+ if (!doc) {
617
+ throw new MemorandumError('document_not_found', `Document '${input.id}' not found`, { id: input.id });
618
+ }
619
+ const resolvedPath = resolve(input.target_path);
620
+ if (existsSync(resolvedPath) && !input.force) {
621
+ throw new MemorandumError('file_already_exists', `File already exists at '${resolvedPath}'. Use force=true to overwrite.`, { path: resolvedPath });
622
+ }
623
+ const parentDir = dirname(resolvedPath);
624
+ if (!existsSync(parentDir)) {
625
+ mkdirSync(parentDir, { recursive: true });
626
+ }
627
+ const result = {
628
+ success: true,
629
+ id: input.id,
630
+ target_path: resolvedPath,
631
+ };
632
+ if (isInlineContentType(doc.content_type)) {
633
+ const filePath = join(this.documentsPath, `${doc.id}.md`);
634
+ let bodyContent = '';
635
+ try {
636
+ const content = readFileSync(filePath, 'utf-8');
637
+ const { body } = this.parseFrontmatter(content);
638
+ bodyContent = body;
639
+ }
640
+ catch (err) {
641
+ if (err.code === 'ENOENT') {
642
+ throw new MemorandumError('document_not_found', `Document file missing on disk for '${input.id}'`, { id: input.id, path: filePath });
643
+ }
644
+ throw err;
645
+ }
646
+ writeFileSync(resolvedPath, bodyContent, 'utf-8');
647
+ }
648
+ else {
649
+ if (!doc.blob_path) {
650
+ throw new MemorandumError('document_not_found', `Blob path missing for document '${input.id}'`, { id: input.id });
651
+ }
652
+ const blobPath = join(this.documentsPath, doc.blob_path);
653
+ let blobData;
654
+ try {
655
+ blobData = readFileSync(blobPath);
656
+ }
657
+ catch (err) {
658
+ if (err.code === 'ENOENT') {
659
+ throw new MemorandumError('document_not_found', `Blob file missing on disk for '${input.id}'`, { id: input.id, path: blobPath });
660
+ }
661
+ throw err;
662
+ }
663
+ if (doc.blob_sha256) {
664
+ const actual = createHash('sha256').update(blobData).digest('hex');
665
+ if (actual !== doc.blob_sha256) {
666
+ if (!input.force) {
667
+ throw new MemorandumError('integrity_error', `SHA256 mismatch for '${input.id}': expected ${doc.blob_sha256}, got ${actual}. Use force=true to write anyway.`, { id: input.id, expected: doc.blob_sha256, actual });
668
+ }
669
+ result.integrity_ok = false;
670
+ result.warning = `SHA256 mismatch: expected ${doc.blob_sha256}, got ${actual}`;
671
+ }
672
+ else {
673
+ result.integrity_ok = true;
674
+ }
675
+ }
676
+ writeFileSync(resolvedPath, blobData);
677
+ }
678
+ this.logger.debug({ id: input.id, path: resolvedPath }, 'Document restored to disk');
679
+ return result;
680
+ }
681
+ }
682
+ //# sourceMappingURL=document-store.js.map