@esparkman/pensieve 0.1.4 → 0.2.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/dist/__tests__/database.test.js +6 -6
- package/dist/database.d.ts +20 -6
- package/dist/database.js +195 -151
- package/dist/index.js +61 -2
- package/package.json +3 -3
|
@@ -17,12 +17,12 @@ describe('Database: Limits Configuration', () => {
|
|
|
17
17
|
describe('Database: Field Truncation', () => {
|
|
18
18
|
let db;
|
|
19
19
|
let testDir;
|
|
20
|
-
beforeEach(() => {
|
|
20
|
+
beforeEach(async () => {
|
|
21
21
|
// Create a temporary directory for tests
|
|
22
22
|
testDir = join(tmpdir(), `pensieve-test-${Date.now()}`);
|
|
23
23
|
mkdirSync(testDir, { recursive: true });
|
|
24
24
|
mkdirSync(join(testDir, '.pensieve'), { recursive: true });
|
|
25
|
-
db =
|
|
25
|
+
db = await MemoryDatabase.create(testDir);
|
|
26
26
|
});
|
|
27
27
|
afterEach(() => {
|
|
28
28
|
db.close();
|
|
@@ -139,11 +139,11 @@ describe('Database: Field Truncation', () => {
|
|
|
139
139
|
describe('Database: Storage Limits and Pruning', () => {
|
|
140
140
|
let db;
|
|
141
141
|
let testDir;
|
|
142
|
-
beforeEach(() => {
|
|
142
|
+
beforeEach(async () => {
|
|
143
143
|
testDir = join(tmpdir(), `pensieve-test-${Date.now()}`);
|
|
144
144
|
mkdirSync(testDir, { recursive: true });
|
|
145
145
|
mkdirSync(join(testDir, '.pensieve'), { recursive: true });
|
|
146
|
-
db =
|
|
146
|
+
db = await MemoryDatabase.create(testDir);
|
|
147
147
|
});
|
|
148
148
|
afterEach(() => {
|
|
149
149
|
db.close();
|
|
@@ -190,13 +190,13 @@ describe('Database: Storage Limits and Pruning', () => {
|
|
|
190
190
|
});
|
|
191
191
|
});
|
|
192
192
|
describe('Database: Path Resolution', () => {
|
|
193
|
-
it('uses PENSIEVE_DB_PATH environment variable when set', () => {
|
|
193
|
+
it('uses PENSIEVE_DB_PATH environment variable when set', async () => {
|
|
194
194
|
const testPath = join(tmpdir(), `pensieve-env-test-${Date.now()}`);
|
|
195
195
|
mkdirSync(testPath, { recursive: true });
|
|
196
196
|
const customDbPath = join(testPath, 'custom.sqlite');
|
|
197
197
|
process.env.PENSIEVE_DB_PATH = customDbPath;
|
|
198
198
|
try {
|
|
199
|
-
const db =
|
|
199
|
+
const db = await MemoryDatabase.create();
|
|
200
200
|
db.addDecision({ topic: 'test', decision: 'test' });
|
|
201
201
|
// Verify the database was created at the custom path
|
|
202
202
|
expect(existsSync(customDbPath)).toBe(true);
|
package/dist/database.d.ts
CHANGED
|
@@ -64,15 +64,17 @@ export interface OpenQuestion {
|
|
|
64
64
|
}
|
|
65
65
|
export declare class MemoryDatabase {
|
|
66
66
|
private db;
|
|
67
|
-
private projectPath;
|
|
68
67
|
private dbPath;
|
|
69
|
-
constructor(
|
|
70
|
-
private openDatabase;
|
|
68
|
+
private constructor();
|
|
71
69
|
/**
|
|
72
|
-
*
|
|
70
|
+
* Create a new MemoryDatabase instance (async factory)
|
|
73
71
|
*/
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
static create(projectPath?: string): Promise<MemoryDatabase>;
|
|
73
|
+
/**
|
|
74
|
+
* Save database to disk
|
|
75
|
+
*/
|
|
76
|
+
private save;
|
|
77
|
+
private static getDbPath;
|
|
76
78
|
/**
|
|
77
79
|
* Truncate a string to the maximum field length
|
|
78
80
|
*/
|
|
@@ -82,6 +84,18 @@ export declare class MemoryDatabase {
|
|
|
82
84
|
*/
|
|
83
85
|
private pruneIfNeeded;
|
|
84
86
|
private initSchema;
|
|
87
|
+
/**
|
|
88
|
+
* Get last inserted row ID
|
|
89
|
+
*/
|
|
90
|
+
private getLastInsertRowId;
|
|
91
|
+
/**
|
|
92
|
+
* Execute a query and return all rows as objects
|
|
93
|
+
*/
|
|
94
|
+
private queryAll;
|
|
95
|
+
/**
|
|
96
|
+
* Execute a query and return the first row as an object
|
|
97
|
+
*/
|
|
98
|
+
private queryOne;
|
|
85
99
|
addDecision(decision: Decision): number;
|
|
86
100
|
searchDecisions(query: string): Decision[];
|
|
87
101
|
getRecentDecisions(limit?: number): Decision[];
|
package/dist/database.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { existsSync, mkdirSync } from 'fs';
|
|
1
|
+
import initSqlJs from 'sql.js';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
3
3
|
import { dirname, join } from 'path';
|
|
4
4
|
import { homedir } from 'os';
|
|
5
5
|
// Configuration limits
|
|
@@ -14,60 +14,59 @@ export const LIMITS = {
|
|
|
14
14
|
};
|
|
15
15
|
export class MemoryDatabase {
|
|
16
16
|
db;
|
|
17
|
-
projectPath;
|
|
18
17
|
dbPath;
|
|
19
|
-
constructor(
|
|
20
|
-
|
|
21
|
-
this.
|
|
22
|
-
|
|
18
|
+
constructor(db, dbPath) {
|
|
19
|
+
this.db = db;
|
|
20
|
+
this.dbPath = dbPath;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Create a new MemoryDatabase instance (async factory)
|
|
24
|
+
*/
|
|
25
|
+
static async create(projectPath) {
|
|
26
|
+
const dbPath = MemoryDatabase.getDbPath(projectPath || process.cwd());
|
|
23
27
|
// Ensure directory exists
|
|
24
|
-
const dbDir = dirname(
|
|
28
|
+
const dbDir = dirname(dbPath);
|
|
25
29
|
if (!existsSync(dbDir)) {
|
|
26
30
|
mkdirSync(dbDir, { recursive: true });
|
|
27
31
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
// Initialize sql.js
|
|
33
|
+
const SQL = await initSqlJs();
|
|
34
|
+
// Load existing database or create new one
|
|
35
|
+
let db;
|
|
36
|
+
if (existsSync(dbPath)) {
|
|
37
|
+
try {
|
|
38
|
+
const fileBuffer = readFileSync(dbPath);
|
|
39
|
+
db = new SQL.Database(fileBuffer);
|
|
40
|
+
console.error(`[Pensieve] Loaded existing database: ${dbPath}`);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
console.error(`[Pensieve] Failed to load database, creating new: ${error}`);
|
|
44
|
+
db = new SQL.Database();
|
|
45
|
+
}
|
|
36
46
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
else {
|
|
48
|
+
db = new SQL.Database();
|
|
49
|
+
console.error(`[Pensieve] Created new database: ${dbPath}`);
|
|
40
50
|
}
|
|
51
|
+
const instance = new MemoryDatabase(db, dbPath);
|
|
52
|
+
instance.initSchema();
|
|
53
|
+
instance.save(); // Ensure schema is persisted
|
|
54
|
+
return instance;
|
|
41
55
|
}
|
|
42
56
|
/**
|
|
43
|
-
*
|
|
57
|
+
* Save database to disk
|
|
44
58
|
*/
|
|
45
|
-
|
|
59
|
+
save() {
|
|
46
60
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
this.
|
|
50
|
-
return true;
|
|
61
|
+
const data = this.db.export();
|
|
62
|
+
const buffer = Buffer.from(data);
|
|
63
|
+
writeFileSync(this.dbPath, buffer);
|
|
51
64
|
}
|
|
52
65
|
catch (error) {
|
|
53
|
-
|
|
54
|
-
if (errorMessage.includes('readonly')) {
|
|
55
|
-
console.error('[Pensieve] Database is read-only, attempting reconnection...');
|
|
56
|
-
try {
|
|
57
|
-
this.db.close();
|
|
58
|
-
this.db = this.openDatabase();
|
|
59
|
-
console.error('[Pensieve] Reconnected successfully');
|
|
60
|
-
return true;
|
|
61
|
-
}
|
|
62
|
-
catch (reconnectError) {
|
|
63
|
-
console.error(`[Pensieve] Reconnection failed: ${reconnectError}`);
|
|
64
|
-
return false;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return false;
|
|
66
|
+
console.error(`[Pensieve] Failed to save database: ${error}`);
|
|
68
67
|
}
|
|
69
68
|
}
|
|
70
|
-
getDbPath() {
|
|
69
|
+
static getDbPath(projectPath) {
|
|
71
70
|
// Check for explicit database path override
|
|
72
71
|
const envPath = process.env.PENSIEVE_DB_PATH;
|
|
73
72
|
if (envPath) {
|
|
@@ -75,20 +74,18 @@ export class MemoryDatabase {
|
|
|
75
74
|
return envPath;
|
|
76
75
|
}
|
|
77
76
|
// Check for explicit project path (recommended for MCP server usage)
|
|
78
|
-
// This should be set in the MCP server config to ensure deterministic behavior
|
|
79
77
|
const projectDir = process.env.PENSIEVE_PROJECT_DIR;
|
|
80
78
|
if (projectDir) {
|
|
81
|
-
const
|
|
82
|
-
console.error(`[Pensieve] Using project database from PENSIEVE_PROJECT_DIR: ${
|
|
83
|
-
return
|
|
79
|
+
const projectDbPath = join(projectDir, '.pensieve', 'memory.sqlite');
|
|
80
|
+
console.error(`[Pensieve] Using project database from PENSIEVE_PROJECT_DIR: ${projectDbPath}`);
|
|
81
|
+
return projectDbPath;
|
|
84
82
|
}
|
|
85
83
|
// Fallback: Try project-local first, then fall back to home directory
|
|
86
|
-
|
|
87
|
-
const localPath = join(this.projectPath, '.pensieve', 'memory.sqlite');
|
|
84
|
+
const localPath = join(projectPath, '.pensieve', 'memory.sqlite');
|
|
88
85
|
const globalPath = join(homedir(), '.claude-pensieve', 'memory.sqlite');
|
|
89
86
|
// If local .pensieve directory exists or we're in a git repo, use local
|
|
90
|
-
if (existsSync(join(
|
|
91
|
-
existsSync(join(
|
|
87
|
+
if (existsSync(join(projectPath, '.pensieve')) ||
|
|
88
|
+
existsSync(join(projectPath, '.git'))) {
|
|
92
89
|
console.error(`[Pensieve] WARNING: Using cwd-based path (unreliable): ${localPath}`);
|
|
93
90
|
console.error(`[Pensieve] Set PENSIEVE_PROJECT_DIR for deterministic behavior`);
|
|
94
91
|
return localPath;
|
|
@@ -112,42 +109,44 @@ export class MemoryDatabase {
|
|
|
112
109
|
*/
|
|
113
110
|
pruneIfNeeded() {
|
|
114
111
|
// Prune old sessions beyond retention period
|
|
115
|
-
this.db.
|
|
112
|
+
this.db.run(`
|
|
116
113
|
DELETE FROM sessions
|
|
117
114
|
WHERE ended_at IS NOT NULL
|
|
118
115
|
AND datetime(ended_at) < datetime('now', '-${LIMITS.SESSION_RETENTION_DAYS} days')
|
|
119
|
-
`)
|
|
116
|
+
`);
|
|
120
117
|
// Prune excess decisions (keep most recent)
|
|
121
|
-
const
|
|
118
|
+
const decisionResult = this.db.exec('SELECT COUNT(*) as count FROM decisions');
|
|
119
|
+
const decisionCount = decisionResult.length > 0 ? decisionResult[0].values[0][0] : 0;
|
|
122
120
|
if (decisionCount > LIMITS.MAX_DECISIONS) {
|
|
123
121
|
const excess = decisionCount - LIMITS.MAX_DECISIONS;
|
|
124
|
-
this.db.
|
|
122
|
+
this.db.run(`
|
|
125
123
|
DELETE FROM decisions WHERE id IN (
|
|
126
124
|
SELECT id FROM decisions ORDER BY decided_at ASC LIMIT ?
|
|
127
125
|
)
|
|
128
|
-
|
|
126
|
+
`, [excess]);
|
|
129
127
|
console.error(`[Pensieve] Pruned ${excess} old decisions`);
|
|
130
128
|
}
|
|
131
129
|
// Prune excess discoveries
|
|
132
|
-
const
|
|
130
|
+
const discoveryResult = this.db.exec('SELECT COUNT(*) as count FROM discoveries');
|
|
131
|
+
const discoveryCount = discoveryResult.length > 0 ? discoveryResult[0].values[0][0] : 0;
|
|
133
132
|
if (discoveryCount > LIMITS.MAX_DISCOVERIES) {
|
|
134
133
|
const excess = discoveryCount - LIMITS.MAX_DISCOVERIES;
|
|
135
|
-
this.db.
|
|
134
|
+
this.db.run(`
|
|
136
135
|
DELETE FROM discoveries WHERE id IN (
|
|
137
136
|
SELECT id FROM discoveries ORDER BY discovered_at ASC LIMIT ?
|
|
138
137
|
)
|
|
139
|
-
|
|
138
|
+
`, [excess]);
|
|
140
139
|
console.error(`[Pensieve] Pruned ${excess} old discoveries`);
|
|
141
140
|
}
|
|
142
141
|
// Prune resolved questions older than 30 days
|
|
143
|
-
this.db.
|
|
142
|
+
this.db.run(`
|
|
144
143
|
DELETE FROM open_questions
|
|
145
144
|
WHERE status = 'resolved'
|
|
146
145
|
AND datetime(resolved_at) < datetime('now', '-30 days')
|
|
147
|
-
`)
|
|
146
|
+
`);
|
|
148
147
|
}
|
|
149
148
|
initSchema() {
|
|
150
|
-
this.db.
|
|
149
|
+
this.db.run(`
|
|
151
150
|
-- Core discoveries about the codebase
|
|
152
151
|
CREATE TABLE IF NOT EXISTS discoveries (
|
|
153
152
|
id INTEGER PRIMARY KEY,
|
|
@@ -158,9 +157,9 @@ export class MemoryDatabase {
|
|
|
158
157
|
metadata TEXT,
|
|
159
158
|
discovered_at TEXT DEFAULT (datetime('now')),
|
|
160
159
|
confidence REAL DEFAULT 1.0
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
|
|
160
|
+
)
|
|
161
|
+
`);
|
|
162
|
+
this.db.run(`
|
|
164
163
|
CREATE TABLE IF NOT EXISTS decisions (
|
|
165
164
|
id INTEGER PRIMARY KEY,
|
|
166
165
|
topic TEXT NOT NULL,
|
|
@@ -169,9 +168,9 @@ export class MemoryDatabase {
|
|
|
169
168
|
alternatives TEXT,
|
|
170
169
|
decided_at TEXT DEFAULT (datetime('now')),
|
|
171
170
|
source TEXT
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
|
|
171
|
+
)
|
|
172
|
+
`);
|
|
173
|
+
this.db.run(`
|
|
175
174
|
CREATE TABLE IF NOT EXISTS preferences (
|
|
176
175
|
id INTEGER PRIMARY KEY,
|
|
177
176
|
category TEXT NOT NULL,
|
|
@@ -180,9 +179,9 @@ export class MemoryDatabase {
|
|
|
180
179
|
notes TEXT,
|
|
181
180
|
updated_at TEXT DEFAULT (datetime('now')),
|
|
182
181
|
UNIQUE(category, key)
|
|
183
|
-
)
|
|
184
|
-
|
|
185
|
-
|
|
182
|
+
)
|
|
183
|
+
`);
|
|
184
|
+
this.db.run(`
|
|
186
185
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
187
186
|
id INTEGER PRIMARY KEY,
|
|
188
187
|
started_at TEXT DEFAULT (datetime('now')),
|
|
@@ -192,9 +191,9 @@ export class MemoryDatabase {
|
|
|
192
191
|
next_steps TEXT,
|
|
193
192
|
key_files TEXT,
|
|
194
193
|
tags TEXT
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
|
|
194
|
+
)
|
|
195
|
+
`);
|
|
196
|
+
this.db.run(`
|
|
198
197
|
CREATE TABLE IF NOT EXISTS entities (
|
|
199
198
|
id INTEGER PRIMARY KEY,
|
|
200
199
|
name TEXT NOT NULL UNIQUE,
|
|
@@ -203,9 +202,9 @@ export class MemoryDatabase {
|
|
|
203
202
|
attributes TEXT,
|
|
204
203
|
location TEXT,
|
|
205
204
|
updated_at TEXT DEFAULT (datetime('now'))
|
|
206
|
-
)
|
|
207
|
-
|
|
208
|
-
|
|
205
|
+
)
|
|
206
|
+
`);
|
|
207
|
+
this.db.run(`
|
|
209
208
|
CREATE TABLE IF NOT EXISTS open_questions (
|
|
210
209
|
id INTEGER PRIMARY KEY,
|
|
211
210
|
question TEXT NOT NULL,
|
|
@@ -214,128 +213,166 @@ export class MemoryDatabase {
|
|
|
214
213
|
resolution TEXT,
|
|
215
214
|
created_at TEXT DEFAULT (datetime('now')),
|
|
216
215
|
resolved_at TEXT
|
|
217
|
-
)
|
|
218
|
-
|
|
219
|
-
-- Indexes for common queries
|
|
220
|
-
CREATE INDEX IF NOT EXISTS idx_discoveries_category ON discoveries(category);
|
|
221
|
-
CREATE INDEX IF NOT EXISTS idx_discoveries_name ON discoveries(name);
|
|
222
|
-
CREATE INDEX IF NOT EXISTS idx_decisions_topic ON decisions(topic);
|
|
223
|
-
CREATE INDEX IF NOT EXISTS idx_preferences_category ON preferences(category);
|
|
224
|
-
CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at);
|
|
225
|
-
CREATE INDEX IF NOT EXISTS idx_open_questions_status ON open_questions(status);
|
|
216
|
+
)
|
|
226
217
|
`);
|
|
218
|
+
// Create indexes
|
|
219
|
+
this.db.run('CREATE INDEX IF NOT EXISTS idx_discoveries_category ON discoveries(category)');
|
|
220
|
+
this.db.run('CREATE INDEX IF NOT EXISTS idx_discoveries_name ON discoveries(name)');
|
|
221
|
+
this.db.run('CREATE INDEX IF NOT EXISTS idx_decisions_topic ON decisions(topic)');
|
|
222
|
+
this.db.run('CREATE INDEX IF NOT EXISTS idx_preferences_category ON preferences(category)');
|
|
223
|
+
this.db.run('CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at)');
|
|
224
|
+
this.db.run('CREATE INDEX IF NOT EXISTS idx_open_questions_status ON open_questions(status)');
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Get last inserted row ID
|
|
228
|
+
*/
|
|
229
|
+
getLastInsertRowId() {
|
|
230
|
+
const result = this.db.exec('SELECT last_insert_rowid()');
|
|
231
|
+
return result.length > 0 ? result[0].values[0][0] : 0;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Execute a query and return all rows as objects
|
|
235
|
+
*/
|
|
236
|
+
queryAll(sql, params = []) {
|
|
237
|
+
const stmt = this.db.prepare(sql);
|
|
238
|
+
stmt.bind(params);
|
|
239
|
+
const results = [];
|
|
240
|
+
while (stmt.step()) {
|
|
241
|
+
results.push(stmt.getAsObject());
|
|
242
|
+
}
|
|
243
|
+
stmt.free();
|
|
244
|
+
return results;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Execute a query and return the first row as an object
|
|
248
|
+
*/
|
|
249
|
+
queryOne(sql, params = []) {
|
|
250
|
+
const results = this.queryAll(sql, params);
|
|
251
|
+
return results.length > 0 ? results[0] : undefined;
|
|
227
252
|
}
|
|
228
253
|
// Decision methods
|
|
229
254
|
addDecision(decision) {
|
|
230
|
-
this.ensureWritable();
|
|
231
255
|
this.pruneIfNeeded();
|
|
232
|
-
|
|
256
|
+
this.db.run(`
|
|
233
257
|
INSERT INTO decisions (topic, decision, rationale, alternatives, source)
|
|
234
258
|
VALUES (?, ?, ?, ?, ?)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
259
|
+
`, [
|
|
260
|
+
this.truncateField(decision.topic, 'topic'),
|
|
261
|
+
this.truncateField(decision.decision, 'decision'),
|
|
262
|
+
this.truncateField(decision.rationale, 'rationale'),
|
|
263
|
+
this.truncateField(decision.alternatives, 'alternatives'),
|
|
264
|
+
decision.source || 'user'
|
|
265
|
+
]);
|
|
266
|
+
const id = this.getLastInsertRowId();
|
|
267
|
+
this.save();
|
|
268
|
+
return id;
|
|
238
269
|
}
|
|
239
270
|
searchDecisions(query) {
|
|
240
|
-
const
|
|
271
|
+
const pattern = `%${query}%`;
|
|
272
|
+
return this.queryAll(`
|
|
241
273
|
SELECT * FROM decisions
|
|
242
274
|
WHERE topic LIKE ? OR decision LIKE ? OR rationale LIKE ?
|
|
243
275
|
ORDER BY decided_at DESC
|
|
244
276
|
LIMIT 50
|
|
245
|
-
|
|
246
|
-
const pattern = `%${query}%`;
|
|
247
|
-
return stmt.all(pattern, pattern, pattern);
|
|
277
|
+
`, [pattern, pattern, pattern]);
|
|
248
278
|
}
|
|
249
279
|
getRecentDecisions(limit = 10) {
|
|
250
|
-
|
|
280
|
+
return this.queryAll(`
|
|
251
281
|
SELECT * FROM decisions
|
|
252
282
|
ORDER BY decided_at DESC
|
|
253
283
|
LIMIT ?
|
|
254
|
-
|
|
255
|
-
return stmt.all(limit);
|
|
284
|
+
`, [limit]);
|
|
256
285
|
}
|
|
257
286
|
// Preference methods
|
|
258
287
|
setPreference(pref) {
|
|
259
|
-
this.
|
|
260
|
-
const stmt = this.db.prepare(`
|
|
288
|
+
this.db.run(`
|
|
261
289
|
INSERT OR REPLACE INTO preferences (category, key, value, notes, updated_at)
|
|
262
290
|
VALUES (?, ?, ?, ?, datetime('now'))
|
|
263
|
-
|
|
264
|
-
|
|
291
|
+
`, [
|
|
292
|
+
this.truncateField(pref.category, 'category'),
|
|
293
|
+
this.truncateField(pref.key, 'key'),
|
|
294
|
+
this.truncateField(pref.value, 'value'),
|
|
295
|
+
this.truncateField(pref.notes, 'notes')
|
|
296
|
+
]);
|
|
297
|
+
this.save();
|
|
265
298
|
}
|
|
266
299
|
getPreference(category, key) {
|
|
267
|
-
|
|
300
|
+
return this.queryOne(`
|
|
268
301
|
SELECT * FROM preferences WHERE category = ? AND key = ?
|
|
269
|
-
|
|
270
|
-
return stmt.get(category, key);
|
|
302
|
+
`, [category, key]);
|
|
271
303
|
}
|
|
272
304
|
getPreferencesByCategory(category) {
|
|
273
|
-
|
|
305
|
+
return this.queryAll(`
|
|
274
306
|
SELECT * FROM preferences WHERE category = ? ORDER BY key
|
|
275
|
-
|
|
276
|
-
return stmt.all(category);
|
|
307
|
+
`, [category]);
|
|
277
308
|
}
|
|
278
309
|
getAllPreferences() {
|
|
279
|
-
|
|
310
|
+
return this.queryAll(`
|
|
280
311
|
SELECT * FROM preferences ORDER BY category, key
|
|
281
312
|
`);
|
|
282
|
-
return stmt.all();
|
|
283
313
|
}
|
|
284
314
|
// Discovery methods
|
|
285
315
|
addDiscovery(discovery) {
|
|
286
|
-
this.ensureWritable();
|
|
287
316
|
this.pruneIfNeeded();
|
|
288
|
-
|
|
317
|
+
this.db.run(`
|
|
289
318
|
INSERT INTO discoveries (category, name, location, description, metadata, confidence)
|
|
290
319
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
320
|
+
`, [
|
|
321
|
+
this.truncateField(discovery.category, 'category'),
|
|
322
|
+
this.truncateField(discovery.name, 'name'),
|
|
323
|
+
this.truncateField(discovery.location, 'location'),
|
|
324
|
+
this.truncateField(discovery.description, 'description'),
|
|
325
|
+
this.truncateField(discovery.metadata, 'metadata'),
|
|
326
|
+
discovery.confidence || 1.0
|
|
327
|
+
]);
|
|
328
|
+
const id = this.getLastInsertRowId();
|
|
329
|
+
this.save();
|
|
330
|
+
return id;
|
|
294
331
|
}
|
|
295
332
|
searchDiscoveries(query) {
|
|
296
|
-
const
|
|
333
|
+
const pattern = `%${query}%`;
|
|
334
|
+
return this.queryAll(`
|
|
297
335
|
SELECT * FROM discoveries
|
|
298
336
|
WHERE name LIKE ? OR description LIKE ? OR location LIKE ?
|
|
299
337
|
ORDER BY discovered_at DESC
|
|
300
338
|
LIMIT 50
|
|
301
|
-
|
|
302
|
-
const pattern = `%${query}%`;
|
|
303
|
-
return stmt.all(pattern, pattern, pattern);
|
|
339
|
+
`, [pattern, pattern, pattern]);
|
|
304
340
|
}
|
|
305
341
|
getDiscoveriesByCategory(category) {
|
|
306
|
-
|
|
342
|
+
return this.queryAll(`
|
|
307
343
|
SELECT * FROM discoveries WHERE category = ? ORDER BY name
|
|
308
|
-
|
|
309
|
-
return stmt.all(category);
|
|
344
|
+
`, [category]);
|
|
310
345
|
}
|
|
311
346
|
// Entity methods
|
|
312
347
|
upsertEntity(entity) {
|
|
313
|
-
this.
|
|
314
|
-
const stmt = this.db.prepare(`
|
|
348
|
+
this.db.run(`
|
|
315
349
|
INSERT OR REPLACE INTO entities (name, description, relationships, attributes, location, updated_at)
|
|
316
350
|
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
|
317
|
-
|
|
318
|
-
|
|
351
|
+
`, [
|
|
352
|
+
this.truncateField(entity.name, 'name'),
|
|
353
|
+
this.truncateField(entity.description, 'description'),
|
|
354
|
+
this.truncateField(entity.relationships, 'relationships'),
|
|
355
|
+
this.truncateField(entity.attributes, 'attributes'),
|
|
356
|
+
this.truncateField(entity.location, 'location')
|
|
357
|
+
]);
|
|
358
|
+
this.save();
|
|
319
359
|
}
|
|
320
360
|
getEntity(name) {
|
|
321
|
-
|
|
322
|
-
return stmt.get(name);
|
|
361
|
+
return this.queryOne(`SELECT * FROM entities WHERE name = ?`, [name]);
|
|
323
362
|
}
|
|
324
363
|
getAllEntities() {
|
|
325
|
-
|
|
326
|
-
return stmt.all();
|
|
364
|
+
return this.queryAll(`SELECT * FROM entities ORDER BY name`);
|
|
327
365
|
}
|
|
328
366
|
// Session methods
|
|
329
367
|
startSession() {
|
|
330
|
-
this.
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
return
|
|
368
|
+
this.db.run(`INSERT INTO sessions (started_at) VALUES (datetime('now'))`);
|
|
369
|
+
const id = this.getLastInsertRowId();
|
|
370
|
+
this.save();
|
|
371
|
+
return id;
|
|
334
372
|
}
|
|
335
373
|
endSession(sessionId, summary, workInProgress, nextSteps, keyFiles, tags) {
|
|
336
|
-
this.ensureWritable();
|
|
337
374
|
this.pruneIfNeeded();
|
|
338
|
-
|
|
375
|
+
this.db.run(`
|
|
339
376
|
UPDATE sessions
|
|
340
377
|
SET ended_at = datetime('now'),
|
|
341
378
|
summary = ?,
|
|
@@ -344,44 +381,50 @@ export class MemoryDatabase {
|
|
|
344
381
|
key_files = ?,
|
|
345
382
|
tags = ?
|
|
346
383
|
WHERE id = ?
|
|
347
|
-
|
|
348
|
-
|
|
384
|
+
`, [
|
|
385
|
+
this.truncateField(summary, 'summary'),
|
|
386
|
+
this.truncateField(workInProgress, 'work_in_progress'),
|
|
387
|
+
this.truncateField(nextSteps, 'next_steps'),
|
|
388
|
+
keyFiles ? this.truncateField(JSON.stringify(keyFiles), 'key_files') : null,
|
|
389
|
+
tags ? tags.join(',') : null,
|
|
390
|
+
sessionId
|
|
391
|
+
]);
|
|
392
|
+
this.save();
|
|
349
393
|
}
|
|
350
394
|
getLastSession() {
|
|
351
|
-
|
|
395
|
+
return this.queryOne(`
|
|
352
396
|
SELECT * FROM sessions ORDER BY started_at DESC LIMIT 1
|
|
353
397
|
`);
|
|
354
|
-
return stmt.get();
|
|
355
398
|
}
|
|
356
399
|
getCurrentSession() {
|
|
357
|
-
|
|
400
|
+
return this.queryOne(`
|
|
358
401
|
SELECT * FROM sessions WHERE ended_at IS NULL ORDER BY started_at DESC LIMIT 1
|
|
359
402
|
`);
|
|
360
|
-
return stmt.get();
|
|
361
403
|
}
|
|
362
404
|
// Open questions methods
|
|
363
405
|
addQuestion(question, context) {
|
|
364
|
-
this.
|
|
365
|
-
const stmt = this.db.prepare(`
|
|
406
|
+
this.db.run(`
|
|
366
407
|
INSERT INTO open_questions (question, context) VALUES (?, ?)
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
408
|
+
`, [
|
|
409
|
+
this.truncateField(question, 'question'),
|
|
410
|
+
this.truncateField(context, 'context')
|
|
411
|
+
]);
|
|
412
|
+
const id = this.getLastInsertRowId();
|
|
413
|
+
this.save();
|
|
414
|
+
return id;
|
|
370
415
|
}
|
|
371
416
|
resolveQuestion(id, resolution) {
|
|
372
|
-
this.
|
|
373
|
-
const stmt = this.db.prepare(`
|
|
417
|
+
this.db.run(`
|
|
374
418
|
UPDATE open_questions
|
|
375
419
|
SET status = 'resolved', resolution = ?, resolved_at = datetime('now')
|
|
376
420
|
WHERE id = ?
|
|
377
|
-
|
|
378
|
-
|
|
421
|
+
`, [resolution, id]);
|
|
422
|
+
this.save();
|
|
379
423
|
}
|
|
380
424
|
getOpenQuestions() {
|
|
381
|
-
|
|
425
|
+
return this.queryAll(`
|
|
382
426
|
SELECT * FROM open_questions WHERE status = 'open' ORDER BY created_at DESC
|
|
383
427
|
`);
|
|
384
|
-
return stmt.all();
|
|
385
428
|
}
|
|
386
429
|
// General search
|
|
387
430
|
search(query) {
|
|
@@ -397,6 +440,7 @@ export class MemoryDatabase {
|
|
|
397
440
|
return this.dbPath;
|
|
398
441
|
}
|
|
399
442
|
close() {
|
|
443
|
+
this.save();
|
|
400
444
|
this.db.close();
|
|
401
445
|
}
|
|
402
446
|
}
|
package/dist/index.js
CHANGED
|
@@ -4,8 +4,12 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
5
5
|
import { MemoryDatabase } from './database.js';
|
|
6
6
|
import { checkFieldsForSecrets, formatSecretWarning } from './security.js';
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import { dirname, join } from 'path';
|
|
9
|
+
import { existsSync, mkdirSync, copyFileSync, readdirSync, readFileSync } from 'fs';
|
|
10
|
+
import { homedir } from 'os';
|
|
11
|
+
// Database instance (initialized async in main)
|
|
12
|
+
let db;
|
|
9
13
|
// Create MCP server
|
|
10
14
|
const server = new Server({
|
|
11
15
|
name: 'pensieve',
|
|
@@ -515,6 +519,57 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
515
519
|
};
|
|
516
520
|
}
|
|
517
521
|
});
|
|
522
|
+
// Install slash commands to user's ~/.claude/commands/ directory
|
|
523
|
+
function installCommands() {
|
|
524
|
+
try {
|
|
525
|
+
// Find the package's .claude/commands directory
|
|
526
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
527
|
+
const __dirname = dirname(__filename);
|
|
528
|
+
// Go up from dist/ to package root, then into .claude/commands
|
|
529
|
+
const packageCommandsDir = join(__dirname, '..', '.claude', 'commands');
|
|
530
|
+
if (!existsSync(packageCommandsDir)) {
|
|
531
|
+
// Commands directory not found in package - this is fine for dev mode
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
// Target directory
|
|
535
|
+
const userCommandsDir = join(homedir(), '.claude', 'commands');
|
|
536
|
+
// Create target directory if it doesn't exist
|
|
537
|
+
if (!existsSync(userCommandsDir)) {
|
|
538
|
+
mkdirSync(userCommandsDir, { recursive: true });
|
|
539
|
+
}
|
|
540
|
+
// Get all command files from package
|
|
541
|
+
const commandFiles = readdirSync(packageCommandsDir).filter(f => f.endsWith('.md'));
|
|
542
|
+
let installed = 0;
|
|
543
|
+
let updated = 0;
|
|
544
|
+
for (const file of commandFiles) {
|
|
545
|
+
const sourcePath = join(packageCommandsDir, file);
|
|
546
|
+
const targetPath = join(userCommandsDir, file);
|
|
547
|
+
// Read source content
|
|
548
|
+
const sourceContent = readFileSync(sourcePath, 'utf-8');
|
|
549
|
+
// Check if target exists and compare content
|
|
550
|
+
if (existsSync(targetPath)) {
|
|
551
|
+
const targetContent = readFileSync(targetPath, 'utf-8');
|
|
552
|
+
if (sourceContent !== targetContent) {
|
|
553
|
+
// Update if different
|
|
554
|
+
copyFileSync(sourcePath, targetPath);
|
|
555
|
+
updated++;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
// Install if missing
|
|
560
|
+
copyFileSync(sourcePath, targetPath);
|
|
561
|
+
installed++;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (installed > 0 || updated > 0) {
|
|
565
|
+
console.error(`[Pensieve] Commands: ${installed} installed, ${updated} updated in ~/.claude/commands/`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
// Non-fatal - just log and continue
|
|
570
|
+
console.error(`[Pensieve] Warning: Could not install commands: ${error instanceof Error ? error.message : String(error)}`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
518
573
|
// Output prior context on startup
|
|
519
574
|
function outputPriorContext() {
|
|
520
575
|
const lastSession = db.getLastSession();
|
|
@@ -574,6 +629,10 @@ function outputPriorContext() {
|
|
|
574
629
|
}
|
|
575
630
|
// Start server
|
|
576
631
|
async function main() {
|
|
632
|
+
// Initialize database (async for sql.js WASM loading)
|
|
633
|
+
db = await MemoryDatabase.create();
|
|
634
|
+
// Install slash commands to ~/.claude/commands/
|
|
635
|
+
installCommands();
|
|
577
636
|
const transport = new StdioServerTransport();
|
|
578
637
|
await server.connect(transport);
|
|
579
638
|
// Output prior context so Claude sees it automatically
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esparkman/pensieve",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Pensieve - persistent memory for Claude Code. Remember decisions, preferences, and context across sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
|
30
|
-
"
|
|
30
|
+
"sql.js": "^1.12.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@types/
|
|
33
|
+
"@types/sql.js": "^1.4.9",
|
|
34
34
|
"@types/node": "^25.0.3",
|
|
35
35
|
"tsx": "^4.21.0",
|
|
36
36
|
"typescript": "^5.9.3",
|