@esparkman/pensieve 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,106 @@
1
+ export declare const LIMITS: {
2
+ readonly MAX_DECISIONS: 1000;
3
+ readonly MAX_DISCOVERIES: 500;
4
+ readonly MAX_ENTITIES: 200;
5
+ readonly MAX_QUESTIONS: 100;
6
+ readonly MAX_SESSIONS: 100;
7
+ readonly SESSION_RETENTION_DAYS: 90;
8
+ readonly MAX_FIELD_LENGTH: 10000;
9
+ };
10
+ export interface Decision {
11
+ id?: number;
12
+ topic: string;
13
+ decision: string;
14
+ rationale?: string;
15
+ alternatives?: string;
16
+ decided_at?: string;
17
+ source?: string;
18
+ }
19
+ export interface Preference {
20
+ id?: number;
21
+ category: string;
22
+ key: string;
23
+ value: string;
24
+ notes?: string;
25
+ updated_at?: string;
26
+ }
27
+ export interface Discovery {
28
+ id?: number;
29
+ category: string;
30
+ name: string;
31
+ location?: string;
32
+ description?: string;
33
+ metadata?: string;
34
+ discovered_at?: string;
35
+ confidence?: number;
36
+ }
37
+ export interface Entity {
38
+ id?: number;
39
+ name: string;
40
+ description?: string;
41
+ relationships?: string;
42
+ attributes?: string;
43
+ location?: string;
44
+ updated_at?: string;
45
+ }
46
+ export interface Session {
47
+ id?: number;
48
+ started_at?: string;
49
+ ended_at?: string;
50
+ summary?: string;
51
+ work_in_progress?: string;
52
+ next_steps?: string;
53
+ key_files?: string;
54
+ tags?: string;
55
+ }
56
+ export interface OpenQuestion {
57
+ id?: number;
58
+ question: string;
59
+ context?: string;
60
+ status?: string;
61
+ resolution?: string;
62
+ created_at?: string;
63
+ resolved_at?: string;
64
+ }
65
+ export declare class MemoryDatabase {
66
+ private db;
67
+ private projectPath;
68
+ constructor(projectPath?: string);
69
+ private getDbPath;
70
+ /**
71
+ * Truncate a string to the maximum field length
72
+ */
73
+ private truncateField;
74
+ /**
75
+ * Prune old entries when limits are exceeded
76
+ */
77
+ private pruneIfNeeded;
78
+ private initSchema;
79
+ addDecision(decision: Decision): number;
80
+ searchDecisions(query: string): Decision[];
81
+ getRecentDecisions(limit?: number): Decision[];
82
+ setPreference(pref: Preference): void;
83
+ getPreference(category: string, key: string): Preference | undefined;
84
+ getPreferencesByCategory(category: string): Preference[];
85
+ getAllPreferences(): Preference[];
86
+ addDiscovery(discovery: Discovery): number;
87
+ searchDiscoveries(query: string): Discovery[];
88
+ getDiscoveriesByCategory(category: string): Discovery[];
89
+ upsertEntity(entity: Entity): void;
90
+ getEntity(name: string): Entity | undefined;
91
+ getAllEntities(): Entity[];
92
+ startSession(): number;
93
+ endSession(sessionId: number, summary: string, workInProgress?: string, nextSteps?: string, keyFiles?: string[], tags?: string[]): void;
94
+ getLastSession(): Session | undefined;
95
+ getCurrentSession(): Session | undefined;
96
+ addQuestion(question: string, context?: string): number;
97
+ resolveQuestion(id: number, resolution: string): void;
98
+ getOpenQuestions(): OpenQuestion[];
99
+ search(query: string): {
100
+ decisions: Decision[];
101
+ discoveries: Discovery[];
102
+ entities: Entity[];
103
+ };
104
+ getPath(): string;
105
+ close(): void;
106
+ }
@@ -0,0 +1,344 @@
1
+ import Database from 'better-sqlite3';
2
+ import { existsSync, mkdirSync } from 'fs';
3
+ import { dirname, join } from 'path';
4
+ import { homedir } from 'os';
5
+ // Configuration limits
6
+ export const LIMITS = {
7
+ MAX_DECISIONS: 1000,
8
+ MAX_DISCOVERIES: 500,
9
+ MAX_ENTITIES: 200,
10
+ MAX_QUESTIONS: 100,
11
+ MAX_SESSIONS: 100,
12
+ SESSION_RETENTION_DAYS: 90,
13
+ MAX_FIELD_LENGTH: 10000, // 10KB per field
14
+ };
15
+ export class MemoryDatabase {
16
+ db;
17
+ projectPath;
18
+ constructor(projectPath) {
19
+ // Use provided path, or detect from current directory, or use home
20
+ this.projectPath = projectPath || process.cwd();
21
+ const dbPath = this.getDbPath();
22
+ // Ensure directory exists
23
+ const dbDir = dirname(dbPath);
24
+ if (!existsSync(dbDir)) {
25
+ mkdirSync(dbDir, { recursive: true });
26
+ }
27
+ this.db = new Database(dbPath);
28
+ this.initSchema();
29
+ }
30
+ getDbPath() {
31
+ // Check for explicit environment variable override first
32
+ const envPath = process.env.PENSIEVE_DB_PATH;
33
+ if (envPath) {
34
+ console.error(`[Pensieve] Using database from PENSIEVE_DB_PATH: ${envPath}`);
35
+ return envPath;
36
+ }
37
+ // Try project-local first, then fall back to home directory
38
+ const localPath = join(this.projectPath, '.pensieve', 'memory.sqlite');
39
+ const globalPath = join(homedir(), '.claude-pensieve', 'memory.sqlite');
40
+ // If local .pensieve directory exists or we're in a git repo, use local
41
+ if (existsSync(join(this.projectPath, '.pensieve')) ||
42
+ existsSync(join(this.projectPath, '.git'))) {
43
+ console.error(`[Pensieve] Using project-local database: ${localPath}`);
44
+ return localPath;
45
+ }
46
+ console.error(`[Pensieve] Using global database: ${globalPath}`);
47
+ return globalPath;
48
+ }
49
+ /**
50
+ * Truncate a string to the maximum field length
51
+ */
52
+ truncateField(value, fieldName) {
53
+ if (!value)
54
+ return null;
55
+ if (value.length <= LIMITS.MAX_FIELD_LENGTH)
56
+ return value;
57
+ console.error(`[Pensieve] Warning: Truncating ${fieldName || 'field'} from ${value.length} to ${LIMITS.MAX_FIELD_LENGTH} chars`);
58
+ return value.substring(0, LIMITS.MAX_FIELD_LENGTH) + '... [truncated]';
59
+ }
60
+ /**
61
+ * Prune old entries when limits are exceeded
62
+ */
63
+ pruneIfNeeded() {
64
+ // Prune old sessions beyond retention period
65
+ this.db.prepare(`
66
+ DELETE FROM sessions
67
+ WHERE ended_at IS NOT NULL
68
+ AND datetime(ended_at) < datetime('now', '-${LIMITS.SESSION_RETENTION_DAYS} days')
69
+ `).run();
70
+ // Prune excess decisions (keep most recent)
71
+ const decisionCount = this.db.prepare('SELECT COUNT(*) as count FROM decisions').get().count;
72
+ if (decisionCount > LIMITS.MAX_DECISIONS) {
73
+ const excess = decisionCount - LIMITS.MAX_DECISIONS;
74
+ this.db.prepare(`
75
+ DELETE FROM decisions WHERE id IN (
76
+ SELECT id FROM decisions ORDER BY decided_at ASC LIMIT ?
77
+ )
78
+ `).run(excess);
79
+ console.error(`[Pensieve] Pruned ${excess} old decisions`);
80
+ }
81
+ // Prune excess discoveries
82
+ const discoveryCount = this.db.prepare('SELECT COUNT(*) as count FROM discoveries').get().count;
83
+ if (discoveryCount > LIMITS.MAX_DISCOVERIES) {
84
+ const excess = discoveryCount - LIMITS.MAX_DISCOVERIES;
85
+ this.db.prepare(`
86
+ DELETE FROM discoveries WHERE id IN (
87
+ SELECT id FROM discoveries ORDER BY discovered_at ASC LIMIT ?
88
+ )
89
+ `).run(excess);
90
+ console.error(`[Pensieve] Pruned ${excess} old discoveries`);
91
+ }
92
+ // Prune resolved questions older than 30 days
93
+ this.db.prepare(`
94
+ DELETE FROM open_questions
95
+ WHERE status = 'resolved'
96
+ AND datetime(resolved_at) < datetime('now', '-30 days')
97
+ `).run();
98
+ }
99
+ initSchema() {
100
+ this.db.exec(`
101
+ -- Core discoveries about the codebase
102
+ CREATE TABLE IF NOT EXISTS discoveries (
103
+ id INTEGER PRIMARY KEY,
104
+ category TEXT NOT NULL,
105
+ name TEXT NOT NULL,
106
+ location TEXT,
107
+ description TEXT,
108
+ metadata TEXT,
109
+ discovered_at TEXT DEFAULT (datetime('now')),
110
+ confidence REAL DEFAULT 1.0
111
+ );
112
+
113
+ -- Architectural and design decisions
114
+ CREATE TABLE IF NOT EXISTS decisions (
115
+ id INTEGER PRIMARY KEY,
116
+ topic TEXT NOT NULL,
117
+ decision TEXT NOT NULL,
118
+ rationale TEXT,
119
+ alternatives TEXT,
120
+ decided_at TEXT DEFAULT (datetime('now')),
121
+ source TEXT
122
+ );
123
+
124
+ -- User preferences and conventions
125
+ CREATE TABLE IF NOT EXISTS preferences (
126
+ id INTEGER PRIMARY KEY,
127
+ category TEXT NOT NULL,
128
+ key TEXT NOT NULL,
129
+ value TEXT NOT NULL,
130
+ notes TEXT,
131
+ updated_at TEXT DEFAULT (datetime('now')),
132
+ UNIQUE(category, key)
133
+ );
134
+
135
+ -- Session summaries for continuity
136
+ CREATE TABLE IF NOT EXISTS sessions (
137
+ id INTEGER PRIMARY KEY,
138
+ started_at TEXT DEFAULT (datetime('now')),
139
+ ended_at TEXT,
140
+ summary TEXT,
141
+ work_in_progress TEXT,
142
+ next_steps TEXT,
143
+ key_files TEXT,
144
+ tags TEXT
145
+ );
146
+
147
+ -- Entities/domain model understanding
148
+ CREATE TABLE IF NOT EXISTS entities (
149
+ id INTEGER PRIMARY KEY,
150
+ name TEXT NOT NULL UNIQUE,
151
+ description TEXT,
152
+ relationships TEXT,
153
+ attributes TEXT,
154
+ location TEXT,
155
+ updated_at TEXT DEFAULT (datetime('now'))
156
+ );
157
+
158
+ -- Open questions and blockers
159
+ CREATE TABLE IF NOT EXISTS open_questions (
160
+ id INTEGER PRIMARY KEY,
161
+ question TEXT NOT NULL,
162
+ context TEXT,
163
+ status TEXT DEFAULT 'open',
164
+ resolution TEXT,
165
+ created_at TEXT DEFAULT (datetime('now')),
166
+ resolved_at TEXT
167
+ );
168
+
169
+ -- Indexes for common queries
170
+ CREATE INDEX IF NOT EXISTS idx_discoveries_category ON discoveries(category);
171
+ CREATE INDEX IF NOT EXISTS idx_discoveries_name ON discoveries(name);
172
+ CREATE INDEX IF NOT EXISTS idx_decisions_topic ON decisions(topic);
173
+ CREATE INDEX IF NOT EXISTS idx_preferences_category ON preferences(category);
174
+ CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at);
175
+ CREATE INDEX IF NOT EXISTS idx_open_questions_status ON open_questions(status);
176
+ `);
177
+ }
178
+ // Decision methods
179
+ addDecision(decision) {
180
+ this.pruneIfNeeded();
181
+ const stmt = this.db.prepare(`
182
+ INSERT INTO decisions (topic, decision, rationale, alternatives, source)
183
+ VALUES (?, ?, ?, ?, ?)
184
+ `);
185
+ const result = stmt.run(this.truncateField(decision.topic, 'topic'), this.truncateField(decision.decision, 'decision'), this.truncateField(decision.rationale, 'rationale'), this.truncateField(decision.alternatives, 'alternatives'), decision.source || 'user');
186
+ return result.lastInsertRowid;
187
+ }
188
+ searchDecisions(query) {
189
+ const stmt = this.db.prepare(`
190
+ SELECT * FROM decisions
191
+ WHERE topic LIKE ? OR decision LIKE ? OR rationale LIKE ?
192
+ ORDER BY decided_at DESC
193
+ LIMIT 50
194
+ `);
195
+ const pattern = `%${query}%`;
196
+ return stmt.all(pattern, pattern, pattern);
197
+ }
198
+ getRecentDecisions(limit = 10) {
199
+ const stmt = this.db.prepare(`
200
+ SELECT * FROM decisions
201
+ ORDER BY decided_at DESC
202
+ LIMIT ?
203
+ `);
204
+ return stmt.all(limit);
205
+ }
206
+ // Preference methods
207
+ setPreference(pref) {
208
+ const stmt = this.db.prepare(`
209
+ INSERT OR REPLACE INTO preferences (category, key, value, notes, updated_at)
210
+ VALUES (?, ?, ?, ?, datetime('now'))
211
+ `);
212
+ stmt.run(this.truncateField(pref.category, 'category'), this.truncateField(pref.key, 'key'), this.truncateField(pref.value, 'value'), this.truncateField(pref.notes, 'notes'));
213
+ }
214
+ getPreference(category, key) {
215
+ const stmt = this.db.prepare(`
216
+ SELECT * FROM preferences WHERE category = ? AND key = ?
217
+ `);
218
+ return stmt.get(category, key);
219
+ }
220
+ getPreferencesByCategory(category) {
221
+ const stmt = this.db.prepare(`
222
+ SELECT * FROM preferences WHERE category = ? ORDER BY key
223
+ `);
224
+ return stmt.all(category);
225
+ }
226
+ getAllPreferences() {
227
+ const stmt = this.db.prepare(`
228
+ SELECT * FROM preferences ORDER BY category, key
229
+ `);
230
+ return stmt.all();
231
+ }
232
+ // Discovery methods
233
+ addDiscovery(discovery) {
234
+ this.pruneIfNeeded();
235
+ const stmt = this.db.prepare(`
236
+ INSERT INTO discoveries (category, name, location, description, metadata, confidence)
237
+ VALUES (?, ?, ?, ?, ?, ?)
238
+ `);
239
+ const result = stmt.run(this.truncateField(discovery.category, 'category'), this.truncateField(discovery.name, 'name'), this.truncateField(discovery.location, 'location'), this.truncateField(discovery.description, 'description'), this.truncateField(discovery.metadata, 'metadata'), discovery.confidence || 1.0);
240
+ return result.lastInsertRowid;
241
+ }
242
+ searchDiscoveries(query) {
243
+ const stmt = this.db.prepare(`
244
+ SELECT * FROM discoveries
245
+ WHERE name LIKE ? OR description LIKE ? OR location LIKE ?
246
+ ORDER BY discovered_at DESC
247
+ LIMIT 50
248
+ `);
249
+ const pattern = `%${query}%`;
250
+ return stmt.all(pattern, pattern, pattern);
251
+ }
252
+ getDiscoveriesByCategory(category) {
253
+ const stmt = this.db.prepare(`
254
+ SELECT * FROM discoveries WHERE category = ? ORDER BY name
255
+ `);
256
+ return stmt.all(category);
257
+ }
258
+ // Entity methods
259
+ upsertEntity(entity) {
260
+ const stmt = this.db.prepare(`
261
+ INSERT OR REPLACE INTO entities (name, description, relationships, attributes, location, updated_at)
262
+ VALUES (?, ?, ?, ?, ?, datetime('now'))
263
+ `);
264
+ stmt.run(this.truncateField(entity.name, 'name'), this.truncateField(entity.description, 'description'), this.truncateField(entity.relationships, 'relationships'), this.truncateField(entity.attributes, 'attributes'), this.truncateField(entity.location, 'location'));
265
+ }
266
+ getEntity(name) {
267
+ const stmt = this.db.prepare(`SELECT * FROM entities WHERE name = ?`);
268
+ return stmt.get(name);
269
+ }
270
+ getAllEntities() {
271
+ const stmt = this.db.prepare(`SELECT * FROM entities ORDER BY name`);
272
+ return stmt.all();
273
+ }
274
+ // Session methods
275
+ startSession() {
276
+ const stmt = this.db.prepare(`INSERT INTO sessions (started_at) VALUES (datetime('now'))`);
277
+ const result = stmt.run();
278
+ return result.lastInsertRowid;
279
+ }
280
+ endSession(sessionId, summary, workInProgress, nextSteps, keyFiles, tags) {
281
+ this.pruneIfNeeded();
282
+ const stmt = this.db.prepare(`
283
+ UPDATE sessions
284
+ SET ended_at = datetime('now'),
285
+ summary = ?,
286
+ work_in_progress = ?,
287
+ next_steps = ?,
288
+ key_files = ?,
289
+ tags = ?
290
+ WHERE id = ?
291
+ `);
292
+ stmt.run(this.truncateField(summary, 'summary'), this.truncateField(workInProgress, 'work_in_progress'), this.truncateField(nextSteps, 'next_steps'), keyFiles ? this.truncateField(JSON.stringify(keyFiles), 'key_files') : null, tags ? tags.join(',') : null, sessionId);
293
+ }
294
+ getLastSession() {
295
+ const stmt = this.db.prepare(`
296
+ SELECT * FROM sessions ORDER BY started_at DESC LIMIT 1
297
+ `);
298
+ return stmt.get();
299
+ }
300
+ getCurrentSession() {
301
+ const stmt = this.db.prepare(`
302
+ SELECT * FROM sessions WHERE ended_at IS NULL ORDER BY started_at DESC LIMIT 1
303
+ `);
304
+ return stmt.get();
305
+ }
306
+ // Open questions methods
307
+ addQuestion(question, context) {
308
+ const stmt = this.db.prepare(`
309
+ INSERT INTO open_questions (question, context) VALUES (?, ?)
310
+ `);
311
+ const result = stmt.run(this.truncateField(question, 'question'), this.truncateField(context, 'context'));
312
+ return result.lastInsertRowid;
313
+ }
314
+ resolveQuestion(id, resolution) {
315
+ const stmt = this.db.prepare(`
316
+ UPDATE open_questions
317
+ SET status = 'resolved', resolution = ?, resolved_at = datetime('now')
318
+ WHERE id = ?
319
+ `);
320
+ stmt.run(resolution, id);
321
+ }
322
+ getOpenQuestions() {
323
+ const stmt = this.db.prepare(`
324
+ SELECT * FROM open_questions WHERE status = 'open' ORDER BY created_at DESC
325
+ `);
326
+ return stmt.all();
327
+ }
328
+ // General search
329
+ search(query) {
330
+ return {
331
+ decisions: this.searchDecisions(query),
332
+ discoveries: this.searchDiscoveries(query),
333
+ entities: this.getAllEntities().filter(e => e.name.toLowerCase().includes(query.toLowerCase()) ||
334
+ e.description?.toLowerCase().includes(query.toLowerCase()))
335
+ };
336
+ }
337
+ // Get database path for debugging
338
+ getPath() {
339
+ return this.getDbPath();
340
+ }
341
+ close() {
342
+ this.db.close();
343
+ }
344
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};