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.
- package/.dockerignore +88 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +76 -0
- package/.github/ISSUE_TEMPLATE/config.yml +11 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +89 -0
- package/.github/ISSUE_TEMPLATE/question.md +63 -0
- package/.github/dependabot.yml +110 -0
- package/.github/pull_request_template.md +110 -0
- package/.github/workflows/DOCKER_DEPLOYMENT_SETUP.md +346 -0
- package/.github/workflows/codeql.yml +45 -0
- package/.github/workflows/dependabot-auto-merge.yml +42 -0
- package/.github/workflows/docker-publish.yml +277 -0
- package/.github/workflows/lint-and-test.yml +58 -0
- package/.github/workflows/publish-npm.yml +75 -0
- package/.github/workflows/secrets-scanning.yml +32 -0
- package/.github/workflows/security-update.yml +99 -0
- package/.memory-journal-team.db +0 -0
- package/.trivyignore +18 -0
- package/CHANGELOG.md +19 -0
- package/CODE_OF_CONDUCT.md +128 -0
- package/CONTRIBUTING.md +209 -0
- package/DOCKER_README.md +377 -0
- package/Dockerfile +64 -0
- package/LICENSE +21 -0
- package/README.md +461 -0
- package/SECURITY.md +200 -0
- package/VERSION +1 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +42 -0
- package/dist/cli.js.map +1 -0
- package/dist/constants/ServerInstructions.d.ts +8 -0
- package/dist/constants/ServerInstructions.d.ts.map +1 -0
- package/dist/constants/ServerInstructions.js +26 -0
- package/dist/constants/ServerInstructions.js.map +1 -0
- package/dist/database/SqliteAdapter.d.ts +198 -0
- package/dist/database/SqliteAdapter.d.ts.map +1 -0
- package/dist/database/SqliteAdapter.js +736 -0
- package/dist/database/SqliteAdapter.js.map +1 -0
- package/dist/filtering/ToolFilter.d.ts +63 -0
- package/dist/filtering/ToolFilter.d.ts.map +1 -0
- package/dist/filtering/ToolFilter.js +242 -0
- package/dist/filtering/ToolFilter.js.map +1 -0
- package/dist/github/GitHubIntegration.d.ts +91 -0
- package/dist/github/GitHubIntegration.d.ts.map +1 -0
- package/dist/github/GitHubIntegration.js +317 -0
- package/dist/github/GitHubIntegration.js.map +1 -0
- package/dist/handlers/prompts/index.d.ts +28 -0
- package/dist/handlers/prompts/index.d.ts.map +1 -0
- package/dist/handlers/prompts/index.js +366 -0
- package/dist/handlers/prompts/index.js.map +1 -0
- package/dist/handlers/resources/index.d.ts +27 -0
- package/dist/handlers/resources/index.d.ts.map +1 -0
- package/dist/handlers/resources/index.js +453 -0
- package/dist/handlers/resources/index.js.map +1 -0
- package/dist/handlers/tools/index.d.ts +26 -0
- package/dist/handlers/tools/index.d.ts.map +1 -0
- package/dist/handlers/tools/index.js +982 -0
- package/dist/handlers/tools/index.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/server/McpServer.d.ts +18 -0
- package/dist/server/McpServer.d.ts.map +1 -0
- package/dist/server/McpServer.js +171 -0
- package/dist/server/McpServer.js.map +1 -0
- package/dist/types/index.d.ts +300 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +15 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/McpLogger.d.ts +61 -0
- package/dist/utils/McpLogger.d.ts.map +1 -0
- package/dist/utils/McpLogger.js +113 -0
- package/dist/utils/McpLogger.js.map +1 -0
- package/dist/utils/logger.d.ts +30 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +70 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/vector/VectorSearchManager.d.ts +63 -0
- package/dist/vector/VectorSearchManager.d.ts.map +1 -0
- package/dist/vector/VectorSearchManager.js +235 -0
- package/dist/vector/VectorSearchManager.js.map +1 -0
- package/docker-compose.yml +37 -0
- package/eslint.config.js +86 -0
- package/mcp-config-example.json +21 -0
- package/package.json +71 -0
- package/releases/release-notes-v2.2.0.md +165 -0
- package/releases/release-notes.md +214 -0
- package/releases/v3.0.0.md +236 -0
- package/server.json +42 -0
- package/src/cli.ts +52 -0
- package/src/constants/ServerInstructions.ts +25 -0
- package/src/database/SqliteAdapter.ts +952 -0
- package/src/filtering/ToolFilter.ts +271 -0
- package/src/github/GitHubIntegration.ts +409 -0
- package/src/handlers/prompts/index.ts +420 -0
- package/src/handlers/resources/index.ts +529 -0
- package/src/handlers/tools/index.ts +1081 -0
- package/src/index.ts +53 -0
- package/src/server/McpServer.ts +230 -0
- package/src/types/index.ts +435 -0
- package/src/types/sql.js.d.ts +34 -0
- package/src/utils/McpLogger.ts +155 -0
- package/src/utils/logger.ts +98 -0
- package/src/vector/VectorSearchManager.ts +277 -0
- package/tools.json +300 -0
- 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
|
+
}
|