memory-journal-mcp 3.0.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 (107) hide show
  1. package/.dockerignore +88 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.md +76 -0
  3. package/.github/ISSUE_TEMPLATE/config.yml +11 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.md +89 -0
  5. package/.github/ISSUE_TEMPLATE/question.md +63 -0
  6. package/.github/dependabot.yml +110 -0
  7. package/.github/pull_request_template.md +110 -0
  8. package/.github/workflows/DOCKER_DEPLOYMENT_SETUP.md +346 -0
  9. package/.github/workflows/codeql.yml +45 -0
  10. package/.github/workflows/dependabot-auto-merge.yml +42 -0
  11. package/.github/workflows/docker-publish.yml +277 -0
  12. package/.github/workflows/lint-and-test.yml +58 -0
  13. package/.github/workflows/publish-npm.yml +75 -0
  14. package/.github/workflows/secrets-scanning.yml +32 -0
  15. package/.github/workflows/security-update.yml +99 -0
  16. package/.memory-journal-team.db +0 -0
  17. package/.trivyignore +18 -0
  18. package/CHANGELOG.md +19 -0
  19. package/CODE_OF_CONDUCT.md +128 -0
  20. package/CONTRIBUTING.md +209 -0
  21. package/DOCKER_README.md +377 -0
  22. package/Dockerfile +64 -0
  23. package/LICENSE +21 -0
  24. package/README.md +461 -0
  25. package/SECURITY.md +200 -0
  26. package/VERSION +1 -0
  27. package/dist/cli.d.ts +5 -0
  28. package/dist/cli.d.ts.map +1 -0
  29. package/dist/cli.js +42 -0
  30. package/dist/cli.js.map +1 -0
  31. package/dist/constants/ServerInstructions.d.ts +8 -0
  32. package/dist/constants/ServerInstructions.d.ts.map +1 -0
  33. package/dist/constants/ServerInstructions.js +26 -0
  34. package/dist/constants/ServerInstructions.js.map +1 -0
  35. package/dist/database/SqliteAdapter.d.ts +198 -0
  36. package/dist/database/SqliteAdapter.d.ts.map +1 -0
  37. package/dist/database/SqliteAdapter.js +736 -0
  38. package/dist/database/SqliteAdapter.js.map +1 -0
  39. package/dist/filtering/ToolFilter.d.ts +63 -0
  40. package/dist/filtering/ToolFilter.d.ts.map +1 -0
  41. package/dist/filtering/ToolFilter.js +242 -0
  42. package/dist/filtering/ToolFilter.js.map +1 -0
  43. package/dist/github/GitHubIntegration.d.ts +91 -0
  44. package/dist/github/GitHubIntegration.d.ts.map +1 -0
  45. package/dist/github/GitHubIntegration.js +317 -0
  46. package/dist/github/GitHubIntegration.js.map +1 -0
  47. package/dist/handlers/prompts/index.d.ts +28 -0
  48. package/dist/handlers/prompts/index.d.ts.map +1 -0
  49. package/dist/handlers/prompts/index.js +366 -0
  50. package/dist/handlers/prompts/index.js.map +1 -0
  51. package/dist/handlers/resources/index.d.ts +27 -0
  52. package/dist/handlers/resources/index.d.ts.map +1 -0
  53. package/dist/handlers/resources/index.js +453 -0
  54. package/dist/handlers/resources/index.js.map +1 -0
  55. package/dist/handlers/tools/index.d.ts +26 -0
  56. package/dist/handlers/tools/index.d.ts.map +1 -0
  57. package/dist/handlers/tools/index.js +982 -0
  58. package/dist/handlers/tools/index.js.map +1 -0
  59. package/dist/index.d.ts +11 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +13 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/server/McpServer.d.ts +18 -0
  64. package/dist/server/McpServer.d.ts.map +1 -0
  65. package/dist/server/McpServer.js +171 -0
  66. package/dist/server/McpServer.js.map +1 -0
  67. package/dist/types/index.d.ts +300 -0
  68. package/dist/types/index.d.ts.map +1 -0
  69. package/dist/types/index.js +15 -0
  70. package/dist/types/index.js.map +1 -0
  71. package/dist/utils/McpLogger.d.ts +61 -0
  72. package/dist/utils/McpLogger.d.ts.map +1 -0
  73. package/dist/utils/McpLogger.js +113 -0
  74. package/dist/utils/McpLogger.js.map +1 -0
  75. package/dist/utils/logger.d.ts +30 -0
  76. package/dist/utils/logger.d.ts.map +1 -0
  77. package/dist/utils/logger.js +70 -0
  78. package/dist/utils/logger.js.map +1 -0
  79. package/dist/vector/VectorSearchManager.d.ts +63 -0
  80. package/dist/vector/VectorSearchManager.d.ts.map +1 -0
  81. package/dist/vector/VectorSearchManager.js +235 -0
  82. package/dist/vector/VectorSearchManager.js.map +1 -0
  83. package/docker-compose.yml +37 -0
  84. package/eslint.config.js +86 -0
  85. package/mcp-config-example.json +21 -0
  86. package/package.json +71 -0
  87. package/releases/release-notes-v2.2.0.md +165 -0
  88. package/releases/release-notes.md +214 -0
  89. package/releases/v3.0.0.md +236 -0
  90. package/server.json +42 -0
  91. package/src/cli.ts +52 -0
  92. package/src/constants/ServerInstructions.ts +25 -0
  93. package/src/database/SqliteAdapter.ts +952 -0
  94. package/src/filtering/ToolFilter.ts +271 -0
  95. package/src/github/GitHubIntegration.ts +409 -0
  96. package/src/handlers/prompts/index.ts +420 -0
  97. package/src/handlers/resources/index.ts +529 -0
  98. package/src/handlers/tools/index.ts +1081 -0
  99. package/src/index.ts +53 -0
  100. package/src/server/McpServer.ts +230 -0
  101. package/src/types/index.ts +435 -0
  102. package/src/types/sql.js.d.ts +34 -0
  103. package/src/utils/McpLogger.ts +155 -0
  104. package/src/utils/logger.ts +98 -0
  105. package/src/vector/VectorSearchManager.ts +277 -0
  106. package/tools.json +300 -0
  107. package/tsconfig.json +51 -0
@@ -0,0 +1,952 @@
1
+ /**
2
+ * Memory Journal MCP Server - SQLite Database Adapter
3
+ *
4
+ * Manages SQLite database with FTS5 full-text search using sql.js.
5
+ * Note: sql.js is pure JavaScript, no native compilation required.
6
+ */
7
+
8
+ import initSqlJs, { type Database } from 'sql.js';
9
+ import * as fs from 'node:fs';
10
+ import * as path from 'node:path';
11
+ import { logger } from '../utils/logger.js';
12
+ import type {
13
+ JournalEntry,
14
+ Tag,
15
+ Relationship,
16
+ EntryType,
17
+ SignificanceType,
18
+ RelationshipType,
19
+ } from '../types/index.js';
20
+
21
+ // Schema SQL for initialization
22
+ const SCHEMA_SQL = `
23
+ -- Main journal entries table
24
+ CREATE TABLE IF NOT EXISTS memory_journal (
25
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
26
+ entry_type TEXT NOT NULL,
27
+ content TEXT NOT NULL,
28
+ timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
29
+ is_personal INTEGER DEFAULT 1,
30
+ significance_type TEXT,
31
+ auto_context TEXT,
32
+ deleted_at TEXT,
33
+ -- GitHub integration fields
34
+ project_number INTEGER,
35
+ project_owner TEXT,
36
+ issue_number INTEGER,
37
+ issue_url TEXT,
38
+ pr_number INTEGER,
39
+ pr_url TEXT,
40
+ pr_status TEXT,
41
+ workflow_run_id INTEGER,
42
+ workflow_name TEXT,
43
+ workflow_status TEXT
44
+ );
45
+
46
+ -- Tags table
47
+ CREATE TABLE IF NOT EXISTS tags (
48
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
49
+ name TEXT UNIQUE NOT NULL,
50
+ usage_count INTEGER DEFAULT 0
51
+ );
52
+
53
+ -- Junction table for entry-tag relationships
54
+ CREATE TABLE IF NOT EXISTS entry_tags (
55
+ entry_id INTEGER NOT NULL,
56
+ tag_id INTEGER NOT NULL,
57
+ PRIMARY KEY (entry_id, tag_id),
58
+ FOREIGN KEY (entry_id) REFERENCES memory_journal(id) ON DELETE CASCADE,
59
+ FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
60
+ );
61
+
62
+ -- Relationships between entries
63
+ CREATE TABLE IF NOT EXISTS relationships (
64
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
65
+ from_entry_id INTEGER NOT NULL,
66
+ to_entry_id INTEGER NOT NULL,
67
+ relationship_type TEXT NOT NULL,
68
+ description TEXT,
69
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
70
+ FOREIGN KEY (from_entry_id) REFERENCES memory_journal(id) ON DELETE CASCADE,
71
+ FOREIGN KEY (to_entry_id) REFERENCES memory_journal(id) ON DELETE CASCADE
72
+ );
73
+
74
+ -- Embeddings for vector search (stored as JSON for sql.js compatibility)
75
+ CREATE TABLE IF NOT EXISTS embeddings (
76
+ entry_id INTEGER PRIMARY KEY,
77
+ embedding TEXT NOT NULL,
78
+ model_name TEXT NOT NULL,
79
+ FOREIGN KEY (entry_id) REFERENCES memory_journal(id) ON DELETE CASCADE
80
+ );
81
+
82
+ -- Indexes for performance
83
+ CREATE INDEX IF NOT EXISTS idx_memory_journal_timestamp ON memory_journal(timestamp);
84
+ CREATE INDEX IF NOT EXISTS idx_memory_journal_type ON memory_journal(entry_type);
85
+ CREATE INDEX IF NOT EXISTS idx_memory_journal_personal ON memory_journal(is_personal);
86
+ CREATE INDEX IF NOT EXISTS idx_memory_journal_deleted ON memory_journal(deleted_at);
87
+ CREATE INDEX IF NOT EXISTS idx_memory_journal_project ON memory_journal(project_number);
88
+ CREATE INDEX IF NOT EXISTS idx_memory_journal_issue ON memory_journal(issue_number);
89
+ CREATE INDEX IF NOT EXISTS idx_memory_journal_pr ON memory_journal(pr_number);
90
+ CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name);
91
+ CREATE INDEX IF NOT EXISTS idx_entry_tags_entry ON entry_tags(entry_id);
92
+ CREATE INDEX IF NOT EXISTS idx_entry_tags_tag ON entry_tags(tag_id);
93
+ CREATE INDEX IF NOT EXISTS idx_relationships_from ON relationships(from_entry_id);
94
+ CREATE INDEX IF NOT EXISTS idx_relationships_to ON relationships(to_entry_id);
95
+ `;
96
+
97
+ /**
98
+ * Input for creating a new entry
99
+ */
100
+ export interface CreateEntryInput {
101
+ content: string;
102
+ entryType?: EntryType;
103
+ tags?: string[];
104
+ isPersonal?: boolean;
105
+ significanceType?: SignificanceType;
106
+ autoContext?: string;
107
+ projectNumber?: number;
108
+ projectOwner?: string;
109
+ issueNumber?: number;
110
+ issueUrl?: string;
111
+ prNumber?: number;
112
+ prUrl?: string;
113
+ prStatus?: 'draft' | 'open' | 'merged' | 'closed';
114
+ workflowRunId?: number;
115
+ workflowName?: string;
116
+ workflowStatus?: 'queued' | 'in_progress' | 'completed';
117
+ }
118
+
119
+ /**
120
+ * SQLite Database Adapter for Memory Journal using sql.js
121
+ */
122
+ export class SqliteAdapter {
123
+ private db: Database | null = null;
124
+ private readonly dbPath: string;
125
+ private initialized = false;
126
+
127
+ constructor(dbPath: string) {
128
+ this.dbPath = dbPath;
129
+ }
130
+
131
+ /**
132
+ * Initialize the database (must be called before using)
133
+ */
134
+ async initialize(): Promise<void> {
135
+ if (this.initialized) return;
136
+
137
+ const SQL = await initSqlJs();
138
+
139
+ // Try to load existing database
140
+ let dbBuffer: Buffer | null = null;
141
+ if (fs.existsSync(this.dbPath)) {
142
+ try {
143
+ dbBuffer = fs.readFileSync(this.dbPath);
144
+ } catch {
145
+ // File doesn't exist or can't be read, create new
146
+ }
147
+ }
148
+
149
+ if (dbBuffer) {
150
+ this.db = new SQL.Database(dbBuffer);
151
+ } else {
152
+ this.db = new SQL.Database();
153
+ // Ensure directory exists
154
+ const dir = path.dirname(this.dbPath);
155
+ if (dir && !fs.existsSync(dir)) {
156
+ fs.mkdirSync(dir, { recursive: true });
157
+ }
158
+ }
159
+
160
+ // Initialize schema
161
+ this.db.run(SCHEMA_SQL);
162
+ this.initialized = true;
163
+
164
+ logger.info('Database opened', { module: 'SqliteAdapter', dbPath: this.dbPath });
165
+
166
+ // Save after initialization
167
+ this.save();
168
+ }
169
+
170
+ /**
171
+ * Save database to disk
172
+ */
173
+ private save(): void {
174
+ if (!this.db) return;
175
+ const data = this.db.export();
176
+ const buffer = Buffer.from(data);
177
+ fs.writeFileSync(this.dbPath, buffer);
178
+ }
179
+
180
+ /**
181
+ * Close database connection
182
+ */
183
+ close(): void {
184
+ if (this.db) {
185
+ this.save();
186
+ this.db.close();
187
+ this.db = null;
188
+ }
189
+ logger.info('Database closed', { module: 'SqliteAdapter' });
190
+ }
191
+
192
+ /**
193
+ * Ensure database is initialized
194
+ */
195
+ private ensureDb(): Database {
196
+ if (!this.db) {
197
+ throw new Error('Database not initialized. Call initialize() first.');
198
+ }
199
+ return this.db;
200
+ }
201
+
202
+ // =========================================================================
203
+ // Entry Operations
204
+ // =========================================================================
205
+
206
+ /**
207
+ * Create a new journal entry
208
+ */
209
+ createEntry(input: CreateEntryInput): JournalEntry {
210
+ const db = this.ensureDb();
211
+ const {
212
+ content,
213
+ entryType = 'personal_reflection',
214
+ tags = [],
215
+ isPersonal = true,
216
+ significanceType = null,
217
+ autoContext = null,
218
+ projectNumber,
219
+ projectOwner,
220
+ issueNumber,
221
+ issueUrl,
222
+ prNumber,
223
+ prUrl,
224
+ prStatus,
225
+ workflowRunId,
226
+ workflowName,
227
+ workflowStatus,
228
+ } = input;
229
+
230
+ db.run(`
231
+ INSERT INTO memory_journal (
232
+ entry_type, content, is_personal, significance_type, auto_context,
233
+ project_number, project_owner, issue_number, issue_url,
234
+ pr_number, pr_url, pr_status, workflow_run_id, workflow_name, workflow_status
235
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
236
+ `, [
237
+ entryType,
238
+ content,
239
+ isPersonal ? 1 : 0,
240
+ significanceType,
241
+ autoContext,
242
+ projectNumber ?? null,
243
+ projectOwner ?? null,
244
+ issueNumber ?? null,
245
+ issueUrl ?? null,
246
+ prNumber ?? null,
247
+ prUrl ?? null,
248
+ prStatus ?? null,
249
+ workflowRunId ?? null,
250
+ workflowName ?? null,
251
+ workflowStatus ?? null,
252
+ ]);
253
+
254
+ // Get the inserted ID
255
+ const result = db.exec('SELECT last_insert_rowid() as id');
256
+ const entryId = result[0]?.values[0]?.[0] as number;
257
+
258
+ // Create tags and link them
259
+ if (tags.length > 0) {
260
+ this.linkTagsToEntry(entryId, tags);
261
+ }
262
+
263
+ this.save();
264
+
265
+ logger.info('Entry created', {
266
+ module: 'SqliteAdapter',
267
+ operation: 'createEntry',
268
+ entityId: entryId
269
+ });
270
+
271
+ const entry = this.getEntryById(entryId);
272
+ if (!entry) {
273
+ throw new Error(`Failed to retrieve created entry with ID ${entryId}`);
274
+ }
275
+ return entry;
276
+ }
277
+
278
+ /**
279
+ * Get entry by ID
280
+ */
281
+ getEntryById(id: number): JournalEntry | null {
282
+ const db = this.ensureDb();
283
+ const result = db.exec(
284
+ `SELECT * FROM memory_journal WHERE id = ? AND deleted_at IS NULL`,
285
+ [id]
286
+ );
287
+
288
+ if (result.length === 0 || result[0]?.values.length === 0) return null;
289
+
290
+ const columns = result[0]?.columns ?? [];
291
+ const values = result[0]?.values[0] ?? [];
292
+ const row = this.rowToObject(columns, values);
293
+
294
+ return this.rowToEntry(row);
295
+ }
296
+
297
+ /**
298
+ * Get recent entries
299
+ */
300
+ getRecentEntries(limit = 10, isPersonal?: boolean): JournalEntry[] {
301
+ const db = this.ensureDb();
302
+ let sql = `SELECT * FROM memory_journal WHERE deleted_at IS NULL`;
303
+ const params: unknown[] = [];
304
+
305
+ if (isPersonal !== undefined) {
306
+ sql += ` AND is_personal = ?`;
307
+ params.push(isPersonal ? 1 : 0);
308
+ }
309
+
310
+ sql += ` ORDER BY timestamp DESC LIMIT ?`;
311
+ params.push(limit);
312
+
313
+ const result = db.exec(sql, params);
314
+ if (result.length === 0) return [];
315
+
316
+ const columns = result[0]?.columns ?? [];
317
+ return (result[0]?.values ?? []).map(values =>
318
+ this.rowToEntry(this.rowToObject(columns, values))
319
+ );
320
+ }
321
+
322
+ /**
323
+ * Update an entry
324
+ */
325
+ updateEntry(
326
+ id: number,
327
+ updates: {
328
+ content?: string;
329
+ entryType?: EntryType;
330
+ tags?: string[];
331
+ isPersonal?: boolean;
332
+ }
333
+ ): JournalEntry | null {
334
+ const db = this.ensureDb();
335
+ const entry = this.getEntryById(id);
336
+ if (!entry) return null;
337
+
338
+ const setClause: string[] = [];
339
+ const params: unknown[] = [];
340
+
341
+ if (updates.content !== undefined) {
342
+ setClause.push('content = ?');
343
+ params.push(updates.content);
344
+ }
345
+ if (updates.entryType !== undefined) {
346
+ setClause.push('entry_type = ?');
347
+ params.push(updates.entryType);
348
+ }
349
+ if (updates.isPersonal !== undefined) {
350
+ setClause.push('is_personal = ?');
351
+ params.push(updates.isPersonal ? 1 : 0);
352
+ }
353
+
354
+ if (setClause.length > 0) {
355
+ params.push(id);
356
+ db.run(`UPDATE memory_journal SET ${setClause.join(', ')} WHERE id = ?`, params);
357
+ }
358
+
359
+ // Update tags if provided
360
+ if (updates.tags !== undefined) {
361
+ db.run('DELETE FROM entry_tags WHERE entry_id = ?', [id]);
362
+ this.linkTagsToEntry(id, updates.tags);
363
+ }
364
+
365
+ this.save();
366
+
367
+ logger.info('Entry updated', {
368
+ module: 'SqliteAdapter',
369
+ operation: 'updateEntry',
370
+ entityId: id
371
+ });
372
+
373
+ return this.getEntryById(id);
374
+ }
375
+
376
+ /**
377
+ * Soft delete an entry
378
+ */
379
+ deleteEntry(id: number, permanent = false): boolean {
380
+ const db = this.ensureDb();
381
+
382
+ if (permanent) {
383
+ db.run('DELETE FROM memory_journal WHERE id = ?', [id]);
384
+ } else {
385
+ db.run(
386
+ `UPDATE memory_journal SET deleted_at = datetime('now') WHERE id = ?`,
387
+ [id]
388
+ );
389
+ }
390
+
391
+ this.save();
392
+ return true;
393
+ }
394
+
395
+ // =========================================================================
396
+ // Search Operations
397
+ // =========================================================================
398
+
399
+ /**
400
+ * Full-text search entries (using LIKE for sql.js - FTS5 not supported)
401
+ */
402
+ searchEntries(
403
+ query: string,
404
+ options: {
405
+ limit?: number;
406
+ isPersonal?: boolean;
407
+ projectNumber?: number;
408
+ issueNumber?: number;
409
+ prNumber?: number;
410
+ } = {}
411
+ ): JournalEntry[] {
412
+ const db = this.ensureDb();
413
+ const { limit = 10, isPersonal, projectNumber, issueNumber, prNumber } = options;
414
+
415
+ let sql = `
416
+ SELECT * FROM memory_journal
417
+ WHERE deleted_at IS NULL AND content LIKE ?
418
+ `;
419
+ const params: unknown[] = [`%${query}%`];
420
+
421
+ if (isPersonal !== undefined) {
422
+ sql += ` AND is_personal = ?`;
423
+ params.push(isPersonal ? 1 : 0);
424
+ }
425
+ if (projectNumber !== undefined) {
426
+ sql += ` AND project_number = ?`;
427
+ params.push(projectNumber);
428
+ }
429
+ if (issueNumber !== undefined) {
430
+ sql += ` AND issue_number = ?`;
431
+ params.push(issueNumber);
432
+ }
433
+ if (prNumber !== undefined) {
434
+ sql += ` AND pr_number = ?`;
435
+ params.push(prNumber);
436
+ }
437
+
438
+ sql += ` ORDER BY timestamp DESC LIMIT ?`;
439
+ params.push(limit);
440
+
441
+ const result = db.exec(sql, params);
442
+ if (result.length === 0) return [];
443
+
444
+ const columns = result[0]?.columns ?? [];
445
+ return (result[0]?.values ?? []).map(values =>
446
+ this.rowToEntry(this.rowToObject(columns, values))
447
+ );
448
+ }
449
+
450
+ /**
451
+ * Search by date range
452
+ */
453
+ searchByDateRange(
454
+ startDate: string,
455
+ endDate: string,
456
+ options: {
457
+ entryType?: EntryType;
458
+ tags?: string[];
459
+ isPersonal?: boolean;
460
+ projectNumber?: number;
461
+ } = {}
462
+ ): JournalEntry[] {
463
+ const db = this.ensureDb();
464
+ const { entryType, tags, isPersonal, projectNumber } = options;
465
+
466
+ let sql = `
467
+ SELECT DISTINCT m.* FROM memory_journal m
468
+ LEFT JOIN entry_tags et ON m.id = et.entry_id
469
+ LEFT JOIN tags t ON et.tag_id = t.id
470
+ WHERE m.deleted_at IS NULL
471
+ AND m.timestamp >= ? AND m.timestamp <= ?
472
+ `;
473
+ const params: unknown[] = [startDate, endDate + ' 23:59:59'];
474
+
475
+ if (entryType) {
476
+ sql += ` AND m.entry_type = ?`;
477
+ params.push(entryType);
478
+ }
479
+ if (isPersonal !== undefined) {
480
+ sql += ` AND m.is_personal = ?`;
481
+ params.push(isPersonal ? 1 : 0);
482
+ }
483
+ if (projectNumber !== undefined) {
484
+ sql += ` AND m.project_number = ?`;
485
+ params.push(projectNumber);
486
+ }
487
+ if (tags && tags.length > 0) {
488
+ const placeholders = tags.map(() => '?').join(',');
489
+ sql += ` AND t.name IN (${placeholders})`;
490
+ params.push(...tags);
491
+ }
492
+
493
+ sql += ` ORDER BY m.timestamp DESC`;
494
+
495
+ const result = db.exec(sql, params);
496
+ if (result.length === 0) return [];
497
+
498
+ const columns = result[0]?.columns ?? [];
499
+ return (result[0]?.values ?? []).map(values =>
500
+ this.rowToEntry(this.rowToObject(columns, values))
501
+ );
502
+ }
503
+
504
+ // =========================================================================
505
+ // Tag Operations
506
+ // =========================================================================
507
+
508
+ /**
509
+ * Get or create tags and link to entry
510
+ */
511
+ private linkTagsToEntry(entryId: number, tagNames: string[]): void {
512
+ const db = this.ensureDb();
513
+
514
+ for (const tagName of tagNames) {
515
+ // Insert or ignore tag
516
+ db.run('INSERT OR IGNORE INTO tags (name, usage_count) VALUES (?, 0)', [tagName]);
517
+
518
+ // Get tag ID
519
+ const result = db.exec('SELECT id FROM tags WHERE name = ?', [tagName]);
520
+ const tagId = result[0]?.values[0]?.[0] as number | undefined;
521
+
522
+ if (tagId !== undefined) {
523
+ // Link tag to entry
524
+ db.run('INSERT OR IGNORE INTO entry_tags (entry_id, tag_id) VALUES (?, ?)', [entryId, tagId]);
525
+ // Increment usage
526
+ db.run('UPDATE tags SET usage_count = usage_count + 1 WHERE id = ?', [tagId]);
527
+ }
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Get tags for an entry
533
+ */
534
+ getTagsForEntry(entryId: number): string[] {
535
+ const db = this.ensureDb();
536
+ const result = db.exec(`
537
+ SELECT t.name FROM tags t
538
+ JOIN entry_tags et ON t.id = et.tag_id
539
+ WHERE et.entry_id = ?
540
+ `, [entryId]);
541
+
542
+ if (result.length === 0) return [];
543
+ return (result[0]?.values ?? []).map((v: unknown[]) => v[0] as string);
544
+ }
545
+
546
+ /**
547
+ * List all tags
548
+ */
549
+ listTags(): Tag[] {
550
+ const db = this.ensureDb();
551
+ const result = db.exec('SELECT * FROM tags ORDER BY usage_count DESC');
552
+
553
+ if (result.length === 0) return [];
554
+
555
+ return (result[0]?.values ?? []).map(v => ({
556
+ id: v[0] as number,
557
+ name: v[1] as string,
558
+ usageCount: v[2] as number,
559
+ }));
560
+ }
561
+
562
+ // =========================================================================
563
+ // Relationship Operations
564
+ // =========================================================================
565
+
566
+ /**
567
+ * Link two entries
568
+ */
569
+ linkEntries(
570
+ fromEntryId: number,
571
+ toEntryId: number,
572
+ relationshipType: RelationshipType,
573
+ description?: string
574
+ ): Relationship {
575
+ const db = this.ensureDb();
576
+
577
+ db.run(`
578
+ INSERT INTO relationships (from_entry_id, to_entry_id, relationship_type, description)
579
+ VALUES (?, ?, ?, ?)
580
+ `, [fromEntryId, toEntryId, relationshipType, description ?? null]);
581
+
582
+ const result = db.exec('SELECT last_insert_rowid() as id');
583
+ const id = result[0]?.values[0]?.[0] as number;
584
+
585
+ this.save();
586
+
587
+ return {
588
+ id,
589
+ fromEntryId,
590
+ toEntryId,
591
+ relationshipType,
592
+ description: description ?? null,
593
+ createdAt: new Date().toISOString(),
594
+ };
595
+ }
596
+
597
+ /**
598
+ * Get relationships for an entry
599
+ */
600
+ getRelationships(entryId: number): Relationship[] {
601
+ const db = this.ensureDb();
602
+ const result = db.exec(`
603
+ SELECT * FROM relationships
604
+ WHERE from_entry_id = ? OR to_entry_id = ?
605
+ `, [entryId, entryId]);
606
+
607
+ if (result.length === 0) return [];
608
+
609
+ const columns = result[0]?.columns ?? [];
610
+ return (result[0]?.values ?? []).map((values: unknown[]) => {
611
+ const row = this.rowToObject(columns, values);
612
+ return {
613
+ id: row['id'] as number,
614
+ fromEntryId: row['from_entry_id'] as number,
615
+ toEntryId: row['to_entry_id'] as number,
616
+ relationshipType: row['relationship_type'] as RelationshipType,
617
+ description: row['description'] as string | null,
618
+ createdAt: row['created_at'] as string,
619
+ };
620
+ });
621
+ }
622
+
623
+ // =========================================================================
624
+ // Statistics
625
+ // =========================================================================
626
+
627
+ /**
628
+ * Get entry statistics
629
+ */
630
+ getStatistics(groupBy: 'day' | 'week' | 'month' = 'week'): {
631
+ totalEntries: number;
632
+ entriesByType: Record<string, number>;
633
+ entriesByPeriod: { period: string; count: number }[];
634
+ } {
635
+ const db = this.ensureDb();
636
+
637
+ // Total entries
638
+ const totalResult = db.exec(`
639
+ SELECT COUNT(*) as count FROM memory_journal WHERE deleted_at IS NULL
640
+ `);
641
+ const totalEntries = (totalResult[0]?.values[0]?.[0] as number) ?? 0;
642
+
643
+ // By type
644
+ const byTypeResult = db.exec(`
645
+ SELECT entry_type, COUNT(*) as count
646
+ FROM memory_journal
647
+ WHERE deleted_at IS NULL
648
+ GROUP BY entry_type
649
+ `);
650
+ const entriesByType: Record<string, number> = {};
651
+ for (const row of (byTypeResult[0]?.values ?? [])) {
652
+ entriesByType[row[0] as string] = row[1] as number;
653
+ }
654
+
655
+ // By period
656
+ let dateFormat: string;
657
+ switch (groupBy) {
658
+ case 'day':
659
+ dateFormat = '%Y-%m-%d';
660
+ break;
661
+ case 'month':
662
+ dateFormat = '%Y-%m';
663
+ break;
664
+ default:
665
+ dateFormat = '%Y-W%W';
666
+ }
667
+
668
+ const byPeriodResult = db.exec(`
669
+ SELECT strftime('${dateFormat}', timestamp) as period, COUNT(*) as count
670
+ FROM memory_journal
671
+ WHERE deleted_at IS NULL
672
+ GROUP BY period
673
+ ORDER BY period DESC
674
+ LIMIT 52
675
+ `);
676
+
677
+ const entriesByPeriod = (byPeriodResult[0]?.values ?? []).map((v: unknown[]) => ({
678
+ period: v[0] as string,
679
+ count: v[1] as number,
680
+ }));
681
+
682
+ return {
683
+ totalEntries,
684
+ entriesByType,
685
+ entriesByPeriod,
686
+ };
687
+ }
688
+
689
+ // =========================================================================
690
+ // Backup Operations
691
+ // =========================================================================
692
+
693
+ /**
694
+ * Get the backups directory path (relative to database location)
695
+ */
696
+ getBackupsDir(): string {
697
+ return path.join(path.dirname(this.dbPath), 'backups');
698
+ }
699
+
700
+ /**
701
+ * Export database to a backup file
702
+ * @param backupName Optional custom name (default: timestamp-based)
703
+ * @returns Backup file info
704
+ */
705
+ exportToFile(backupName?: string): { filename: string; path: string; sizeBytes: number } {
706
+ const db = this.ensureDb();
707
+ const backupsDir = this.getBackupsDir();
708
+
709
+ // Ensure backups directory exists
710
+ if (!fs.existsSync(backupsDir)) {
711
+ fs.mkdirSync(backupsDir, { recursive: true });
712
+ }
713
+
714
+ // Generate filename with timestamp
715
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
716
+ const sanitizedName = backupName
717
+ ? backupName.replace(/[/\\:*?"<>|]/g, '_').slice(0, 50)
718
+ : `backup_${timestamp}`;
719
+ const filename = `${sanitizedName}.db`;
720
+ const backupPath = path.join(backupsDir, filename);
721
+
722
+ // Export database
723
+ const data = db.export();
724
+ const buffer = Buffer.from(data);
725
+ fs.writeFileSync(backupPath, buffer);
726
+
727
+ const stats = fs.statSync(backupPath);
728
+
729
+ logger.info('Backup created', {
730
+ module: 'SqliteAdapter',
731
+ operation: 'exportToFile',
732
+ context: { backupPath, sizeBytes: stats.size }
733
+ });
734
+
735
+ return {
736
+ filename,
737
+ path: backupPath,
738
+ sizeBytes: stats.size,
739
+ };
740
+ }
741
+
742
+ /**
743
+ * List all available backup files
744
+ * @returns Array of backup file information
745
+ */
746
+ listBackups(): { filename: string; path: string; sizeBytes: number; createdAt: string }[] {
747
+ const backupsDir = this.getBackupsDir();
748
+
749
+ if (!fs.existsSync(backupsDir)) {
750
+ return [];
751
+ }
752
+
753
+ const files = fs.readdirSync(backupsDir);
754
+ const backups: { filename: string; path: string; sizeBytes: number; createdAt: string }[] = [];
755
+
756
+ for (const filename of files) {
757
+ if (!filename.endsWith('.db')) continue;
758
+
759
+ const filePath = path.join(backupsDir, filename);
760
+ try {
761
+ const stats = fs.statSync(filePath);
762
+ if (stats.isFile()) {
763
+ backups.push({
764
+ filename,
765
+ path: filePath,
766
+ sizeBytes: stats.size,
767
+ createdAt: stats.birthtime.toISOString(),
768
+ });
769
+ }
770
+ } catch {
771
+ // Skip files that can't be read
772
+ }
773
+ }
774
+
775
+ // Sort by creation time, newest first
776
+ backups.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
777
+
778
+ return backups;
779
+ }
780
+
781
+ /**
782
+ * Restore database from a backup file
783
+ * @param filename Backup filename to restore from
784
+ * @returns Statistics about the restore operation
785
+ */
786
+ async restoreFromFile(filename: string): Promise<{
787
+ restoredFrom: string;
788
+ previousEntryCount: number;
789
+ newEntryCount: number
790
+ }> {
791
+ // Validate filename (prevent path traversal)
792
+ if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
793
+ throw new Error('Invalid backup filename: path separators not allowed');
794
+ }
795
+
796
+ const backupsDir = this.getBackupsDir();
797
+ const backupPath = path.join(backupsDir, filename);
798
+
799
+ if (!fs.existsSync(backupPath)) {
800
+ throw new Error(`Backup file not found: ${filename}`);
801
+ }
802
+
803
+ // Get current entry count for comparison
804
+ const db = this.ensureDb();
805
+ const currentCountResult = db.exec('SELECT COUNT(*) FROM memory_journal WHERE deleted_at IS NULL');
806
+ const previousEntryCount = (currentCountResult[0]?.values[0]?.[0] as number) ?? 0;
807
+
808
+ // Create auto-backup before restore
809
+ this.exportToFile(`pre_restore_${new Date().toISOString().replace(/[:.]/g, '-')}`);
810
+
811
+ // Close current database
812
+ this.db?.close();
813
+ this.db = null;
814
+ this.initialized = false;
815
+
816
+ // Read backup file
817
+ const backupBuffer = fs.readFileSync(backupPath);
818
+
819
+ // Initialize new database from backup
820
+ const SQL = await import('sql.js').then(m => m.default());
821
+ this.db = new SQL.Database(backupBuffer);
822
+ this.initialized = true;
823
+
824
+ // Get new entry count
825
+ const newCountResult = this.db.exec('SELECT COUNT(*) FROM memory_journal WHERE deleted_at IS NULL');
826
+ const newEntryCount = (newCountResult[0]?.values[0]?.[0] as number) ?? 0;
827
+
828
+ // Save to main database path
829
+ this.save();
830
+
831
+ logger.info('Database restored from backup', {
832
+ module: 'SqliteAdapter',
833
+ operation: 'restoreFromFile',
834
+ context: { backupPath, previousEntryCount, newEntryCount }
835
+ });
836
+
837
+ return {
838
+ restoredFrom: filename,
839
+ previousEntryCount,
840
+ newEntryCount,
841
+ };
842
+ }
843
+
844
+ // =========================================================================
845
+ // Health Status
846
+ // =========================================================================
847
+
848
+ /**
849
+ * Get database health status for diagnostics
850
+ */
851
+ getHealthStatus(): {
852
+ database: {
853
+ path: string;
854
+ sizeBytes: number;
855
+ entryCount: number;
856
+ deletedEntryCount: number;
857
+ relationshipCount: number;
858
+ tagCount: number;
859
+ };
860
+ backups: {
861
+ directory: string;
862
+ count: number;
863
+ lastBackup: { filename: string; createdAt: string; sizeBytes: number } | null;
864
+ };
865
+ } {
866
+ const db = this.ensureDb();
867
+
868
+ // Get file size
869
+ let sizeBytes = 0;
870
+ try {
871
+ const stats = fs.statSync(this.dbPath);
872
+ sizeBytes = stats.size;
873
+ } catch {
874
+ // File may not exist on disk yet
875
+ }
876
+
877
+ // Entry counts
878
+ const entryResult = db.exec('SELECT COUNT(*) FROM memory_journal WHERE deleted_at IS NULL');
879
+ const deletedResult = db.exec('SELECT COUNT(*) FROM memory_journal WHERE deleted_at IS NOT NULL');
880
+ const relResult = db.exec('SELECT COUNT(*) FROM relationships');
881
+ const tagResult = db.exec('SELECT COUNT(*) FROM tags');
882
+
883
+ const entryCount = (entryResult[0]?.values[0]?.[0] as number) ?? 0;
884
+ const deletedEntryCount = (deletedResult[0]?.values[0]?.[0] as number) ?? 0;
885
+ const relationshipCount = (relResult[0]?.values[0]?.[0] as number) ?? 0;
886
+ const tagCount = (tagResult[0]?.values[0]?.[0] as number) ?? 0;
887
+
888
+ // Backup info
889
+ const backups = this.listBackups();
890
+ const lastBackup = backups[0] ?? null;
891
+
892
+ return {
893
+ database: {
894
+ path: this.dbPath,
895
+ sizeBytes,
896
+ entryCount,
897
+ deletedEntryCount,
898
+ relationshipCount,
899
+ tagCount,
900
+ },
901
+ backups: {
902
+ directory: this.getBackupsDir(),
903
+ count: backups.length,
904
+ lastBackup: lastBackup ? {
905
+ filename: lastBackup.filename,
906
+ createdAt: lastBackup.createdAt,
907
+ sizeBytes: lastBackup.sizeBytes,
908
+ } : null,
909
+ },
910
+ };
911
+ }
912
+
913
+ // =========================================================================
914
+ // Helpers
915
+ // =========================================================================
916
+
917
+ /**
918
+ * Convert columns and values to object
919
+ */
920
+ private rowToObject(columns: string[], values: unknown[]): Record<string, unknown> {
921
+ const obj: Record<string, unknown> = {};
922
+ columns.forEach((col, i) => {
923
+ obj[col] = values[i];
924
+ });
925
+ return obj;
926
+ }
927
+
928
+ /**
929
+ * Convert database row to JournalEntry
930
+ */
931
+ private rowToEntry(row: Record<string, unknown>): JournalEntry {
932
+ const id = row['id'] as number;
933
+ return {
934
+ id,
935
+ entryType: row['entry_type'] as EntryType,
936
+ content: row['content'] as string,
937
+ timestamp: row['timestamp'] as string,
938
+ isPersonal: row['is_personal'] === 1,
939
+ significanceType: row['significance_type'] as SignificanceType,
940
+ autoContext: row['auto_context'] as string | null,
941
+ deletedAt: row['deleted_at'] as string | null,
942
+ tags: this.getTagsForEntry(id),
943
+ };
944
+ }
945
+
946
+ /**
947
+ * Get raw database for advanced operations
948
+ */
949
+ getRawDb(): Database {
950
+ return this.ensureDb();
951
+ }
952
+ }