pi-hermes-memory 0.3.3 → 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.
- package/README.md +42 -9
- package/docs/0.4/PLAN.md +160 -0
- package/docs/0.4/TASKS.md +146 -0
- package/docs/ROADMAP.md +47 -29
- package/package.json +5 -1
- package/src/constants.ts +3 -3
- package/src/handlers/index-sessions.ts +61 -0
- package/src/index.ts +42 -0
- package/src/skills/learn-memory-tool/SKILL.md +125 -0
- package/src/store/db.ts +84 -0
- package/src/store/schema.ts +94 -0
- package/src/store/session-indexer.ts +153 -0
- package/src/store/session-parser.ts +214 -0
- package/src/store/session-search.ts +134 -0
- package/src/store/sqlite-memory-store.ts +215 -0
- package/src/tools/memory-search-tool.ts +78 -0
- package/src/tools/session-search-tool.ts +83 -0
- package/src/types.ts +7 -3
|
@@ -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
|
+
}
|