pi-hermes-memory 0.3.2 → 0.4.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,214 @@
1
+ import fs from 'node:fs';
2
+
3
+ /**
4
+ * Parsed session data from a JSONL file.
5
+ */
6
+ export interface ParsedSession {
7
+ id: string;
8
+ project: string;
9
+ cwd: string;
10
+ startedAt: string;
11
+ endedAt: string | null;
12
+ messages: ParsedMessage[];
13
+ }
14
+
15
+ /**
16
+ * A single parsed message from a session.
17
+ */
18
+ export interface ParsedMessage {
19
+ id: string;
20
+ role: 'user' | 'assistant' | 'system';
21
+ content: string;
22
+ timestamp: string;
23
+ toolCalls?: string[];
24
+ }
25
+
26
+ /**
27
+ * Raw JSONL entry types.
28
+ */
29
+ interface JsonlEntry {
30
+ type: string;
31
+ id?: string;
32
+ parentId?: string | null;
33
+ timestamp?: string;
34
+ cwd?: string;
35
+ message?: {
36
+ role?: string;
37
+ content?: unknown;
38
+ timestamp?: number;
39
+ };
40
+ customType?: string;
41
+ [key: string]: unknown;
42
+ }
43
+
44
+ /**
45
+ * Extract text content from a message's content array.
46
+ */
47
+ function extractTextContent(content: unknown): string {
48
+ if (typeof content === 'string') return content;
49
+ if (!Array.isArray(content)) return '';
50
+
51
+ const parts: string[] = [];
52
+ for (const block of content) {
53
+ if (!block || typeof block !== 'object') continue;
54
+ const b = block as Record<string, unknown>;
55
+
56
+ switch (b.type) {
57
+ case 'text':
58
+ if (typeof b.text === 'string') parts.push(b.text);
59
+ break;
60
+ case 'thinking':
61
+ // Skip thinking blocks — they're internal reasoning
62
+ break;
63
+ case 'tool_use':
64
+ // Skip tool_use blocks — we track tool calls separately
65
+ break;
66
+ case 'tool_result':
67
+ // Include tool result text if present
68
+ if (typeof b.content === 'string') {
69
+ parts.push(b.content);
70
+ } else if (Array.isArray(b.content)) {
71
+ for (const item of b.content) {
72
+ if (item && typeof item === 'object' && (item as Record<string, unknown>).type === 'text') {
73
+ parts.push((item as Record<string, unknown>).text as string);
74
+ }
75
+ }
76
+ }
77
+ break;
78
+ }
79
+ }
80
+ return parts.join('\n').trim();
81
+ }
82
+
83
+ /**
84
+ * Extract tool call names from a message's content array.
85
+ */
86
+ function extractToolCalls(content: unknown): string[] | undefined {
87
+ if (!Array.isArray(content)) return undefined;
88
+
89
+ const toolNames: string[] = [];
90
+ for (const block of content) {
91
+ if (!block || typeof block !== 'object') continue;
92
+ const b = block as Record<string, unknown>;
93
+ if (b.type === 'tool_use' && typeof b.name === 'string') {
94
+ toolNames.push(b.name);
95
+ }
96
+ }
97
+ return toolNames.length > 0 ? toolNames : undefined;
98
+ }
99
+
100
+ /**
101
+ * Parse a Pi session JSONL file.
102
+ *
103
+ * @param filePath — Path to the .jsonl file
104
+ * @returns Parsed session data, or null if the file is invalid
105
+ */
106
+ export function parseSessionFile(filePath: string): ParsedSession | null {
107
+ const content = fs.readFileSync(filePath, 'utf-8');
108
+ const lines = content.split('\n').filter(line => line.trim());
109
+
110
+ if (lines.length === 0) return null;
111
+
112
+ let sessionId: string | null = null;
113
+ let sessionCwd: string | null = null;
114
+ let sessionTimestamp: string | null = null;
115
+ const messages: ParsedMessage[] = [];
116
+
117
+ for (const line of lines) {
118
+ let entry: JsonlEntry;
119
+ try {
120
+ entry = JSON.parse(line);
121
+ } catch {
122
+ continue; // Skip malformed lines
123
+ }
124
+
125
+ switch (entry.type) {
126
+ case 'session':
127
+ sessionId = entry.id ?? null;
128
+ sessionCwd = entry.cwd ?? null;
129
+ sessionTimestamp = entry.timestamp ?? null;
130
+ break;
131
+
132
+ case 'message': {
133
+ if (!entry.message || !entry.id || !entry.timestamp) break;
134
+
135
+ const role = entry.message.role;
136
+ if (role !== 'user' && role !== 'assistant' && role !== 'system') break;
137
+
138
+ const textContent = extractTextContent(entry.message.content);
139
+ if (!textContent) break; // Skip empty messages
140
+
141
+ const toolCalls = role === 'assistant' ? extractToolCalls(entry.message.content) : undefined;
142
+
143
+ messages.push({
144
+ id: entry.id,
145
+ role,
146
+ content: textContent,
147
+ timestamp: entry.timestamp,
148
+ toolCalls,
149
+ });
150
+ break;
151
+ }
152
+ // Skip other entry types (model_change, thinking_level_change, custom, etc.)
153
+ }
154
+ }
155
+
156
+ if (!sessionId || !sessionCwd || !sessionTimestamp) return null;
157
+
158
+ // Decode project name from cwd-encoded directory name
159
+ // The directory is named like "--Users-chandrateja-Documents-pi-hermes-memory--"
160
+ // We extract the last segment as the project name
161
+ const project = sessionCwd.split('/').pop() ?? sessionCwd;
162
+
163
+ return {
164
+ id: sessionId,
165
+ project,
166
+ cwd: sessionCwd,
167
+ startedAt: sessionTimestamp,
168
+ endedAt: null, // We don't know when it ended from the JSONL
169
+ messages,
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Get all session JSONL files for a project (or all projects).
175
+ *
176
+ * @param sessionsDir — Path to ~/.pi/agent/sessions/
177
+ * @param projectDir — Optional: specific project directory name (e.g., "--Users-...--")
178
+ * @returns Array of file paths
179
+ */
180
+ export function getSessionFiles(sessionsDir: string, projectDir?: string): string[] {
181
+ if (projectDir) {
182
+ const dir = `${sessionsDir}/${projectDir}`;
183
+ if (!fs.existsSync(dir)) return [];
184
+ return fs.readdirSync(dir)
185
+ .filter(f => f.endsWith('.jsonl'))
186
+ .map(f => `${dir}/${f}`);
187
+ }
188
+
189
+ // All projects
190
+ if (!fs.existsSync(sessionsDir)) return [];
191
+ const files: string[] = [];
192
+ for (const dir of fs.readdirSync(sessionsDir)) {
193
+ const dirPath = `${sessionsDir}/${dir}`;
194
+ if (!fs.statSync(dirPath).isDirectory()) continue;
195
+ for (const f of fs.readdirSync(dirPath)) {
196
+ if (f.endsWith('.jsonl')) {
197
+ files.push(`${dirPath}/${f}`);
198
+ }
199
+ }
200
+ }
201
+ return files;
202
+ }
203
+
204
+ /**
205
+ * Decode a project directory name to a human-readable project name.
206
+ * "--Users-chandrateja-Documents-pi-hermes-memory--" → "pi-hermes-memory"
207
+ */
208
+ export function decodeProjectDir(dirName: string): string {
209
+ // Remove leading/trailing dashes
210
+ const cleaned = dirName.replace(/^-+|-+$/g, '');
211
+ // Split by dash and take the last segment (project name)
212
+ const segments = cleaned.split('-');
213
+ return segments[segments.length - 1] ?? cleaned;
214
+ }
@@ -0,0 +1,134 @@
1
+ import { DatabaseManager } from './db.js';
2
+
3
+ /**
4
+ * Escape a string for FTS5 query syntax.
5
+ * Wraps the query in double quotes to treat it as a literal phrase.
6
+ */
7
+ function escapeFts5Query(query: string): string {
8
+ // If the query already contains FTS5 operators (OR, AND, NOT, NEAR), leave it as-is
9
+ if (/\b(OR|AND|NOT|NEAR)\b/.test(query)) {
10
+ return query;
11
+ }
12
+ // Otherwise, wrap in double quotes to treat as literal phrase
13
+ return `"${query.replace(/"/g, '""')}"`;
14
+ }
15
+
16
+ /**
17
+ * Search result from session history.
18
+ */
19
+ export interface SessionSearchResult {
20
+ sessionId: string;
21
+ project: string;
22
+ role: string;
23
+ content: string;
24
+ timestamp: string;
25
+ snippet: string;
26
+ }
27
+
28
+ /**
29
+ * Search options for session search.
30
+ */
31
+ export interface SessionSearchOptions {
32
+ /** Maximum number of results (default: 10) */
33
+ limit?: number;
34
+ /** Filter by project name */
35
+ project?: string;
36
+ /** Filter by role: 'user', 'assistant', 'system' */
37
+ role?: string;
38
+ /** Only return messages after this date (ISO string) */
39
+ since?: string;
40
+ }
41
+
42
+ /**
43
+ * Search across indexed session messages using FTS5.
44
+ *
45
+ * @param dbManager — Database manager instance
46
+ * @param query — FTS5 search query
47
+ * @param options — Search options
48
+ * @returns Array of search results with snippets
49
+ */
50
+ export function searchSessions(
51
+ dbManager: DatabaseManager,
52
+ query: string,
53
+ options: SessionSearchOptions = {}
54
+ ): SessionSearchResult[] {
55
+ const db = dbManager.getDb();
56
+ const { limit = 10, project, role, since } = options;
57
+
58
+ // Build the query dynamically based on filters
59
+ const conditions: string[] = [];
60
+ const params: unknown[] = [];
61
+
62
+ // FTS5 match condition — use subquery for reliable rowid matching
63
+ conditions.push('m.rowid IN (SELECT rowid FROM message_fts WHERE message_fts MATCH ?)');
64
+ params.push(escapeFts5Query(query));
65
+
66
+ // Project filter
67
+ if (project) {
68
+ conditions.push('s.project = ?');
69
+ params.push(project);
70
+ }
71
+
72
+ // Role filter
73
+ if (role) {
74
+ conditions.push('m.role = ?');
75
+ params.push(role);
76
+ }
77
+
78
+ // Date filter
79
+ if (since) {
80
+ conditions.push('m.timestamp >= ?');
81
+ params.push(since);
82
+ }
83
+
84
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
85
+
86
+ const sql = `
87
+ SELECT
88
+ m.session_id,
89
+ s.project,
90
+ m.role,
91
+ m.content,
92
+ m.timestamp,
93
+ m.content as snippet
94
+ FROM messages m
95
+ JOIN sessions s ON s.id = m.session_id
96
+ ${whereClause}
97
+ ORDER BY m.timestamp DESC
98
+ LIMIT ?
99
+ `;
100
+ params.push(limit);
101
+
102
+ try {
103
+ const rows = db.prepare(sql).all(...params) as Array<{
104
+ session_id: string;
105
+ project: string;
106
+ role: string;
107
+ content: string;
108
+ timestamp: string;
109
+ snippet: string;
110
+ }>;
111
+
112
+ // Map snake_case column names to camelCase
113
+ return rows.map(row => ({
114
+ sessionId: row.session_id,
115
+ project: row.project,
116
+ role: row.role,
117
+ content: row.content,
118
+ timestamp: row.timestamp,
119
+ snippet: row.snippet,
120
+ }));
121
+ } catch (err) {
122
+ // FTS5 can throw on malformed queries — return empty results
123
+ return [];
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Get the total number of indexed messages.
129
+ */
130
+ export function getIndexedMessageCount(dbManager: DatabaseManager): number {
131
+ const db = dbManager.getDb();
132
+ const result = db.prepare('SELECT COUNT(*) as count FROM messages').get() as { count: number };
133
+ return result.count;
134
+ }
@@ -0,0 +1,215 @@
1
+ import { DatabaseManager } from './db.js';
2
+
3
+ /**
4
+ * A memory entry stored in SQLite.
5
+ */
6
+ export interface SqliteMemoryEntry {
7
+ id: number;
8
+ project: string | null;
9
+ target: 'memory' | 'user';
10
+ content: string;
11
+ created: string;
12
+ lastReferenced: string;
13
+ }
14
+
15
+ /**
16
+ * Add a memory entry to the SQLite store.
17
+ */
18
+ export function addMemory(
19
+ dbManager: DatabaseManager,
20
+ content: string,
21
+ target: 'memory' | 'user' = 'memory',
22
+ project: string | null = null
23
+ ): SqliteMemoryEntry {
24
+ const db = dbManager.getDb();
25
+ const today = new Date().toISOString().split('T')[0];
26
+
27
+ const result = db.prepare(`
28
+ INSERT INTO memories (project, target, content, created, last_referenced)
29
+ VALUES (?, ?, ?, ?, ?)
30
+ `).run(project, target, content, today, today);
31
+
32
+ return {
33
+ id: Number(result.lastInsertRowid),
34
+ project,
35
+ target,
36
+ content,
37
+ created: today,
38
+ lastReferenced: today,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Escape a string for FTS5 query syntax.
44
+ * Wraps the query in double quotes to treat it as a literal phrase.
45
+ */
46
+ function escapeFts5Query(query: string): string {
47
+ // If the query already contains FTS5 operators (OR, AND, NOT, NEAR), leave it as-is
48
+ if (/\b(OR|AND|NOT|NEAR)\b/.test(query)) {
49
+ return query;
50
+ }
51
+ // Otherwise, wrap in double quotes to treat as literal phrase
52
+ return `"${query.replace(/"/g, '""')}"`;
53
+ }
54
+
55
+ /**
56
+ * Search memories using FTS5.
57
+ */
58
+ export function searchMemories(
59
+ dbManager: DatabaseManager,
60
+ query: string,
61
+ options: { project?: string; target?: string; limit?: number } = {}
62
+ ): SqliteMemoryEntry[] {
63
+ const db = dbManager.getDb();
64
+ const { project, target, limit = 10 } = options;
65
+
66
+ const conditions: string[] = [];
67
+ const params: unknown[] = [];
68
+
69
+ // FTS5 match via subquery with escaped query
70
+ conditions.push('m.id IN (SELECT rowid FROM memory_fts WHERE memory_fts MATCH ?)');
71
+ params.push(escapeFts5Query(query));
72
+
73
+ if (project !== undefined) {
74
+ if (project === null) {
75
+ conditions.push('m.project IS NULL');
76
+ } else {
77
+ conditions.push('m.project = ?');
78
+ params.push(project);
79
+ }
80
+ }
81
+
82
+ if (target) {
83
+ conditions.push('m.target = ?');
84
+ params.push(target);
85
+ }
86
+
87
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
88
+
89
+ const sql = `
90
+ SELECT id, project, target, content, created, last_referenced
91
+ FROM memories m
92
+ ${whereClause}
93
+ ORDER BY m.last_referenced DESC
94
+ LIMIT ?
95
+ `;
96
+ params.push(limit);
97
+
98
+ const rows = db.prepare(sql).all(...params) as Array<{
99
+ id: number;
100
+ project: string | null;
101
+ target: string;
102
+ content: string;
103
+ created: string;
104
+ last_referenced: string;
105
+ }>;
106
+
107
+ return rows.map(row => ({
108
+ id: row.id,
109
+ project: row.project,
110
+ target: row.target as 'memory' | 'user',
111
+ content: row.content,
112
+ created: row.created,
113
+ lastReferenced: row.last_referenced,
114
+ }));
115
+ }
116
+
117
+ /**
118
+ * Get all memories, optionally filtered.
119
+ */
120
+ export function getMemories(
121
+ dbManager: DatabaseManager,
122
+ options: { project?: string | null; target?: string } = {}
123
+ ): SqliteMemoryEntry[] {
124
+ const db = dbManager.getDb();
125
+ const { project, target } = options;
126
+
127
+ const conditions: string[] = [];
128
+ const params: unknown[] = [];
129
+
130
+ if (project !== undefined) {
131
+ if (project === null) {
132
+ conditions.push('project IS NULL');
133
+ } else {
134
+ conditions.push('project = ?');
135
+ params.push(project);
136
+ }
137
+ }
138
+
139
+ if (target) {
140
+ conditions.push('target = ?');
141
+ params.push(target);
142
+ }
143
+
144
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
145
+
146
+ const rows = db.prepare(`
147
+ SELECT id, project, target, content, created, last_referenced
148
+ FROM memories
149
+ ${whereClause}
150
+ ORDER BY last_referenced DESC
151
+ `).all(...params) as Array<{
152
+ id: number;
153
+ project: string | null;
154
+ target: string;
155
+ content: string;
156
+ created: string;
157
+ last_referenced: string;
158
+ }>;
159
+
160
+ return rows.map(row => ({
161
+ id: row.id,
162
+ project: row.project,
163
+ target: row.target as 'memory' | 'user',
164
+ content: row.content,
165
+ created: row.created,
166
+ lastReferenced: row.last_referenced,
167
+ }));
168
+ }
169
+
170
+ /**
171
+ * Remove a memory by ID.
172
+ */
173
+ export function removeMemory(dbManager: DatabaseManager, id: number): boolean {
174
+ const db = dbManager.getDb();
175
+ const result = db.prepare('DELETE FROM memories WHERE id = ?').run(id);
176
+ return result.changes > 0;
177
+ }
178
+
179
+ /**
180
+ * Update a memory's last_referenced date.
181
+ */
182
+ export function touchMemory(dbManager: DatabaseManager, id: number): void {
183
+ const db = dbManager.getDb();
184
+ const today = new Date().toISOString().split('T')[0];
185
+ db.prepare('UPDATE memories SET last_referenced = ? WHERE id = ?').run(today, id);
186
+ }
187
+
188
+ /**
189
+ * Get memory statistics.
190
+ */
191
+ export function getMemoryStats(dbManager: DatabaseManager): {
192
+ total: number;
193
+ byProject: { project: string | null; count: number }[];
194
+ byTarget: { target: string; count: number }[];
195
+ } {
196
+ const db = dbManager.getDb();
197
+
198
+ const total = (db.prepare('SELECT COUNT(*) as count FROM memories').get() as { count: number }).count;
199
+
200
+ const byProject = db.prepare(`
201
+ SELECT project, COUNT(*) as count
202
+ FROM memories
203
+ GROUP BY project
204
+ ORDER BY count DESC
205
+ `).all() as { project: string | null; count: number }[];
206
+
207
+ const byTarget = db.prepare(`
208
+ SELECT target, COUNT(*) as count
209
+ FROM memories
210
+ GROUP BY target
211
+ ORDER BY count DESC
212
+ `).all() as { target: string; count: number }[];
213
+
214
+ return { total, byProject, byTarget };
215
+ }
@@ -0,0 +1,78 @@
1
+ import { DatabaseManager } from '../store/db.js';
2
+ import { searchMemories, getMemoryStats } from '../store/sqlite-memory-store.js';
3
+
4
+ export function registerMemorySearchTool(ctx: {
5
+ registerTool: (tool: {
6
+ name: string;
7
+ description: string;
8
+ parameters: Record<string, unknown>;
9
+ handler: (args: Record<string, unknown>) => Promise<string>;
10
+ }) => void;
11
+ }, dbManager: DatabaseManager) {
12
+ ctx.registerTool({
13
+ name: 'memory_search',
14
+ description: `Search extended memory store for relevant entries. Use this when you need context beyond what's in the system prompt — the extended store has unlimited capacity and is searchable.
15
+
16
+ Use cases:
17
+ - Find memories about a specific topic: "What do I know about auth setup?"
18
+ - Search project-specific memories: "What conventions does project X follow?"
19
+ - Find user preferences: "What are the user's testing preferences?"
20
+
21
+ Returns matching memory entries with project context and dates.`,
22
+ parameters: {
23
+ type: 'object',
24
+ properties: {
25
+ query: {
26
+ type: 'string',
27
+ description: 'Search query. Use natural language or specific terms.',
28
+ },
29
+ project: {
30
+ type: 'string',
31
+ description: 'Filter by project name. Pass null for global memories only.',
32
+ },
33
+ target: {
34
+ type: 'string',
35
+ enum: ['memory', 'user'],
36
+ description: 'Filter by target type (memory or user).',
37
+ },
38
+ limit: {
39
+ type: 'number',
40
+ description: 'Maximum results to return (default: 10, max: 20).',
41
+ },
42
+ },
43
+ required: ['query'],
44
+ },
45
+ handler: async (args: Record<string, unknown>) => {
46
+ const query = args.query as string;
47
+ const project = args.project as string | undefined;
48
+ const target = args.target as string | undefined;
49
+ const limit = Math.min((args.limit as number) || 10, 20);
50
+
51
+ if (!query || query.trim().length === 0) {
52
+ return 'Error: query is required';
53
+ }
54
+
55
+ const stats = getMemoryStats(dbManager);
56
+ if (stats.total === 0) {
57
+ return 'No memories in extended store yet. Use the memory tool with add action to store memories.';
58
+ }
59
+
60
+ const results = searchMemories(dbManager, query, { project, target, limit });
61
+
62
+ if (results.length === 0) {
63
+ return `No memories found matching "${query}". Try a different search term or broader query.`;
64
+ }
65
+
66
+ let output = `Found ${results.length} memories matching "${query}":\n\n`;
67
+
68
+ for (const entry of results) {
69
+ const projectLabel = entry.project ? `[${entry.project}]` : '[global]';
70
+ const targetLabel = entry.target === 'user' ? '👤' : '🧠';
71
+ output += `${targetLabel} ${projectLabel} ${entry.content}\n`;
72
+ output += ` Created: ${entry.created} | Last used: ${entry.lastReferenced}\n\n`;
73
+ }
74
+
75
+ return output.trim();
76
+ },
77
+ });
78
+ }