pi-hermes-memory 0.3.3 → 0.4.1

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,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,74 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { StringEnum } from "@mariozechner/pi-ai";
4
+ import { DatabaseManager } from '../store/db.js';
5
+ import { searchMemories, getMemoryStats } from '../store/sqlite-memory-store.js';
6
+
7
+ interface SearchResult {
8
+ success: boolean;
9
+ count?: number;
10
+ message?: string;
11
+ output?: string;
12
+ }
13
+
14
+ export function registerMemorySearchTool(pi: ExtensionAPI, dbManager: DatabaseManager): void {
15
+ pi.registerTool({
16
+ name: 'memory_search',
17
+ label: 'Memory Search',
18
+ 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.
19
+
20
+ Use cases:
21
+ - Find memories about a specific topic: "What do I know about auth setup?"
22
+ - Search project-specific memories: "What conventions does project X follow?"
23
+ - Find user preferences: "What are the user's testing preferences?"
24
+
25
+ Returns matching memory entries with project context and dates.`,
26
+ promptSnippet: 'Search extended memory store (unlimited capacity)',
27
+ promptGuidelines: [
28
+ 'Use memory_search when you need context beyond what is in the system prompt.',
29
+ 'Use memory_search to find project-specific memories or user preferences.',
30
+ ],
31
+ parameters: Type.Object({
32
+ query: Type.String({ description: 'Search query. Use natural language or specific terms.' }),
33
+ project: Type.Optional(Type.String({ description: 'Filter by project name. Pass null for global memories only.' })),
34
+ target: Type.Optional(StringEnum(['memory', 'user'] as const, { description: 'Filter by target type (memory or user).' })),
35
+ limit: Type.Optional(Type.Number({ description: 'Maximum results to return (default: 10, max: 20).' })),
36
+ }),
37
+ execute: async (_id: string, args: { query: string; project?: string; target?: string; limit?: number }) => {
38
+ const query = args.query;
39
+ const project = args.project;
40
+ const target = args.target;
41
+ const limit = Math.min(args.limit || 10, 20);
42
+
43
+ if (!query || query.trim().length === 0) {
44
+ const result: SearchResult = { success: false, message: 'query is required' };
45
+ return { content: [{ type: 'text' as const, text: result.message! }], details: result };
46
+ }
47
+
48
+ const stats = getMemoryStats(dbManager);
49
+ if (stats.total === 0) {
50
+ const result: SearchResult = { success: false, message: 'No memories in extended store yet. Use the memory tool with add action to store memories.' };
51
+ return { content: [{ type: 'text' as const, text: result.message! }], details: result };
52
+ }
53
+
54
+ const results = searchMemories(dbManager, query, { project, target, limit });
55
+
56
+ if (results.length === 0) {
57
+ const result: SearchResult = { success: true, count: 0, message: `No memories found matching "${query}". Try a different search term or broader query.` };
58
+ return { content: [{ type: 'text' as const, text: result.message! }], details: result };
59
+ }
60
+
61
+ let output = `Found ${results.length} memories matching "${query}":\n\n`;
62
+
63
+ for (const entry of results) {
64
+ const projectLabel = entry.project ? `[${entry.project}]` : '[global]';
65
+ const targetLabel = entry.target === 'user' ? '👤' : '🧠';
66
+ output += `${targetLabel} ${projectLabel} ${entry.content}\n`;
67
+ output += ` Created: ${entry.created} | Last used: ${entry.lastReferenced}\n\n`;
68
+ }
69
+
70
+ const finalResult: SearchResult = { success: true, count: results.length, output: output.trim() };
71
+ return { content: [{ type: 'text' as const, text: output.trim() }], details: finalResult };
72
+ },
73
+ });
74
+ }
@@ -0,0 +1,79 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { StringEnum } from "@mariozechner/pi-ai";
4
+ import { DatabaseManager } from '../store/db.js';
5
+ import { searchSessions, getIndexedMessageCount } from '../store/session-search.js';
6
+
7
+ interface SearchResult {
8
+ success: boolean;
9
+ count?: number;
10
+ message?: string;
11
+ output?: string;
12
+ }
13
+
14
+ export function registerSessionSearchTool(pi: ExtensionAPI, dbManager: DatabaseManager): void {
15
+ pi.registerTool({
16
+ name: 'session_search',
17
+ label: 'Session Search',
18
+ description: `Search across past Pi coding sessions for relevant conversation context. Use this when the user asks about previous discussions, past work, or when you need context from earlier sessions.
19
+
20
+ Examples:
21
+ - "What did we discuss about auth last week?"
22
+ - "Find the PR where we fixed the test hang"
23
+ - "What approach did we take for the database migration?"
24
+
25
+ Returns conversation snippets with session dates and project context.`,
26
+ promptSnippet: 'Search past conversations for relevant context',
27
+ promptGuidelines: [
28
+ 'Use session_search when the user asks about previous discussions or past work.',
29
+ 'Use session_search when you need context from earlier sessions.',
30
+ ],
31
+ parameters: Type.Object({
32
+ query: Type.String({ description: 'Search query. Use natural language or specific terms.' }),
33
+ project: Type.Optional(Type.String({ description: 'Filter by project name (optional).' })),
34
+ role: Type.Optional(StringEnum(['user', 'assistant'] as const, { description: 'Filter by message role (optional).' })),
35
+ limit: Type.Optional(Type.Number({ description: 'Maximum results to return (default: 10, max: 20).' })),
36
+ }),
37
+ execute: async (_id: string, args: { query: string; project?: string; role?: string; limit?: number }) => {
38
+ const query = args.query;
39
+ const project = args.project;
40
+ const role = args.role;
41
+ const limit = Math.min(args.limit || 10, 20);
42
+
43
+ if (!query || query.trim().length === 0) {
44
+ const result: SearchResult = { success: false, message: 'query is required' };
45
+ return { content: [{ type: 'text' as const, text: result.message! }], details: result };
46
+ }
47
+
48
+ const totalMessages = getIndexedMessageCount(dbManager);
49
+ if (totalMessages === 0) {
50
+ const result: SearchResult = { success: false, message: 'No sessions indexed yet. Run /memory-index-sessions to import past sessions.' };
51
+ return { content: [{ type: 'text' as const, text: result.message! }], details: result };
52
+ }
53
+
54
+ const results = searchSessions(dbManager, query, { project, role, limit });
55
+
56
+ if (results.length === 0) {
57
+ const result: SearchResult = { success: true, count: 0, message: `No results found for "${query}". Try a different search term or broader query.` };
58
+ return { content: [{ type: 'text' as const, text: result.message! }], details: result };
59
+ }
60
+
61
+ let output = `Found ${results.length} results for "${query}":\n\n`;
62
+
63
+ for (const r of results) {
64
+ const date = new Date(r.timestamp).toLocaleDateString('en-US', {
65
+ year: 'numeric',
66
+ month: 'short',
67
+ day: 'numeric',
68
+ });
69
+
70
+ output += `---\n`;
71
+ output += `📅 ${date} | 📁 ${r.project} | ${r.role === 'user' ? '👤 User' : '🤖 Assistant'}\n`;
72
+ output += `${r.snippet}\n\n`;
73
+ }
74
+
75
+ const finalResult: SearchResult = { success: true, count: results.length, output: output.trim() };
76
+ return { content: [{ type: 'text' as const, text: output.trim() }], details: finalResult };
77
+ },
78
+ });
79
+ }
package/src/types.ts CHANGED
@@ -5,11 +5,11 @@
5
5
  import type { TextContent } from "@mariozechner/pi-ai";
6
6
 
7
7
  export interface MemoryConfig {
8
- /** Max chars for MEMORY.md (agent notes). Default: 2200 */
8
+ /** Max chars for MEMORY.md (agent notes). Default: 5000 */
9
9
  memoryCharLimit: number;
10
- /** Max chars for USER.md (user profile). Default: 1375 */
10
+ /** Max chars for USER.md (user profile). Default: 5000 */
11
11
  userCharLimit: number;
12
- /** Max chars for project-level MEMORY.md. Default: 2200 */
12
+ /** Max chars for project-level MEMORY.md. Default: 5000 */
13
13
  projectCharLimit: number;
14
14
  /** Turns between background auto-reviews. Default: 10 */
15
15
  nudgeInterval: number;
@@ -29,6 +29,10 @@ export interface MemoryConfig {
29
29
  correctionDetection: boolean;
30
30
  /** Tool calls before triggering background review (in addition to turn count). Default: 15 */
31
31
  nudgeToolCalls: number;
32
+ /** Enable session history search via SQLite FTS5. Default: true */
33
+ sessionSearchEnabled?: boolean;
34
+ /** Days to retain session history. Default: 90 */
35
+ sessionRetentionDays?: number;
32
36
  }
33
37
 
34
38
  export interface MemoryResult {