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.
- package/README.md +46 -150
- package/docs/0.4/PLAN.md +160 -0
- package/docs/0.4/TASKS.md +146 -0
- package/docs/ROADMAP.md +47 -29
- package/docs/images/memory-architecture.svg +1 -0
- package/docs/images/security-flow.svg +1 -0
- package/docs/images/session-lifecycle.svg +1 -0
- package/docs/images/source-architecture.svg +1 -0
- package/docs/mermaid/memory-architecture.mmd +26 -0
- package/docs/mermaid/security-flow.mmd +22 -0
- package/docs/mermaid/session-lifecycle.mmd +43 -0
- package/docs/mermaid/source-architecture.mmd +42 -0
- 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,42 @@
|
|
|
1
|
+
graph LR
|
|
2
|
+
subgraph "pi-hermes-memory/src/"
|
|
3
|
+
IDX["index.ts<br/><i>Entry point</i>"]
|
|
4
|
+
MS["memory-store.ts<br/><i>Memory CRUD</i>"]
|
|
5
|
+
SS["skill-store.ts<br/><i>Skill CRUD</i>"]
|
|
6
|
+
MT["memory-tool.ts<br/><i>Memory LLM tool</i>"]
|
|
7
|
+
ST["skill-tool.ts<br/><i>Skill LLM tool</i>"]
|
|
8
|
+
CS["content-scanner.ts<br/><i>Security</i>"]
|
|
9
|
+
BR["background-review.ts<br/><i>Learning loop</i>"]
|
|
10
|
+
AC["auto-consolidate.ts<br/><i>Memory merge</i>"]
|
|
11
|
+
CD["correction-detector.ts<br/><i>Instant save</i>"]
|
|
12
|
+
SA["skill-auto-trigger.ts<br/><i>Skill extraction</i>"]
|
|
13
|
+
SF["session-flush.ts<br/><i>Pre-compaction flush</i>"]
|
|
14
|
+
IN["insights.ts<br/><i>/memory-insights</i>"]
|
|
15
|
+
SC["skills-command.ts<br/><i>/memory-skills</i>"]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
IDX --> MS
|
|
19
|
+
IDX --> SS
|
|
20
|
+
IDX --> MT
|
|
21
|
+
IDX --> ST
|
|
22
|
+
IDX --> BR
|
|
23
|
+
IDX --> AC
|
|
24
|
+
IDX --> CD
|
|
25
|
+
IDX --> SA
|
|
26
|
+
IDX --> SF
|
|
27
|
+
IDX --> IN
|
|
28
|
+
IDX --> SC
|
|
29
|
+
MT --> MS
|
|
30
|
+
ST --> SS
|
|
31
|
+
MS --> CS
|
|
32
|
+
SS --> CS
|
|
33
|
+
BR --> MS
|
|
34
|
+
AC --> MS
|
|
35
|
+
CD --> MS
|
|
36
|
+
SA --> MS
|
|
37
|
+
SA --> SS
|
|
38
|
+
|
|
39
|
+
style IDX fill:#e94560,stroke:#fff,color:#fff
|
|
40
|
+
style MS fill:#0f3460,stroke:#fff,color:#fff
|
|
41
|
+
style SS fill:#0f3460,stroke:#fff,color:#fff
|
|
42
|
+
style CS fill:#ff6600,stroke:#fff,color:#fff
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hermes-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Your Pi agent remembers everything across sessions — your preferences, your stack, your corrections, and even how it solved problems. Zero-config install, works immediately. Persistent memory + procedural skills + auto-correction detection + security-first content scanning.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -43,8 +43,12 @@
|
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@mariozechner/pi-ai": "^0.70.0",
|
|
45
45
|
"@mariozechner/pi-coding-agent": "^0.70.0",
|
|
46
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
46
47
|
"tsx": "^4.21.0",
|
|
47
48
|
"typebox": "^1.1.33",
|
|
48
49
|
"typescript": "^6.0.3"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"better-sqlite3": "^12.9.0"
|
|
49
53
|
}
|
|
50
54
|
}
|
package/src/constants.ts
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
export const ENTRY_DELIMITER = "\n§\n";
|
|
9
9
|
|
|
10
10
|
// ─── Character limits (not tokens — model-independent) ───
|
|
11
|
-
export const DEFAULT_MEMORY_CHAR_LIMIT =
|
|
12
|
-
export const DEFAULT_USER_CHAR_LIMIT =
|
|
11
|
+
export const DEFAULT_MEMORY_CHAR_LIMIT = 5000;
|
|
12
|
+
export const DEFAULT_USER_CHAR_LIMIT = 5000;
|
|
13
13
|
|
|
14
14
|
// ─── Learning loop defaults ───
|
|
15
|
-
export const DEFAULT_PROJECT_CHAR_LIMIT =
|
|
15
|
+
export const DEFAULT_PROJECT_CHAR_LIMIT = 5000;
|
|
16
16
|
|
|
17
17
|
export const DEFAULT_NUDGE_INTERVAL = 10;
|
|
18
18
|
export const DEFAULT_FLUSH_MIN_TURNS = 6;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { DatabaseManager } from '../store/db.js';
|
|
4
|
+
import { indexAllSessions, getSessionStats } from '../store/session-indexer.js';
|
|
5
|
+
|
|
6
|
+
const SESSIONS_DIR = path.join(os.homedir(), '.pi', 'agent', 'sessions');
|
|
7
|
+
|
|
8
|
+
export function registerIndexSessionsCommand(ctx: {
|
|
9
|
+
registerCommand: (name: string, handler: (args: string, ctx: unknown) => Promise<void>) => void;
|
|
10
|
+
sendUserMessage: (msg: string) => void;
|
|
11
|
+
}) {
|
|
12
|
+
ctx.registerCommand('memory-index-sessions', async (_args: string, cmdCtx: unknown) => {
|
|
13
|
+
const sendUserMessage = (cmdCtx as { sendUserMessage?: (msg: string) => void }).sendUserMessage
|
|
14
|
+
?? ctx.sendUserMessage;
|
|
15
|
+
|
|
16
|
+
sendUserMessage('🔍 Indexing session history...');
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const memoryDir = path.join(os.homedir(), '.pi', 'agent', 'memory');
|
|
20
|
+
const dbManager = new DatabaseManager(memoryDir);
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const result = indexAllSessions(dbManager, SESSIONS_DIR);
|
|
24
|
+
|
|
25
|
+
const stats = getSessionStats(dbManager);
|
|
26
|
+
|
|
27
|
+
let output = `\n✅ Session indexing complete!\n\n`;
|
|
28
|
+
output += `📊 Results:\n`;
|
|
29
|
+
output += `• Sessions processed: ${result.sessionsProcessed}\n`;
|
|
30
|
+
output += `• Sessions indexed: ${result.sessionsIndexed}\n`;
|
|
31
|
+
output += `• Sessions skipped (already indexed): ${result.sessionsSkipped}\n`;
|
|
32
|
+
output += `• Messages indexed: ${result.messagesIndexed}\n`;
|
|
33
|
+
|
|
34
|
+
if (stats.projects.length > 0) {
|
|
35
|
+
output += `\n📁 Projects:\n`;
|
|
36
|
+
for (const p of stats.projects) {
|
|
37
|
+
output += `• ${p.project}: ${p.sessions} sessions, ${p.messages} messages\n`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (result.errors.length > 0) {
|
|
42
|
+
output += `\n⚠️ Errors (${result.errors.length}):\n`;
|
|
43
|
+
for (const err of result.errors.slice(0, 5)) {
|
|
44
|
+
output += `• ${err}\n`;
|
|
45
|
+
}
|
|
46
|
+
if (result.errors.length > 5) {
|
|
47
|
+
output += `• ... and ${result.errors.length - 5} more\n`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
output += `\n💡 Use the \`session_search\` tool to search across indexed sessions.`;
|
|
52
|
+
|
|
53
|
+
sendUserMessage(output);
|
|
54
|
+
} finally {
|
|
55
|
+
dbManager.close();
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
sendUserMessage(`❌ Session indexing failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -27,8 +27,13 @@ import * as os from "node:os";
|
|
|
27
27
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
28
28
|
import { MemoryStore } from "./store/memory-store.js";
|
|
29
29
|
import { SkillStore } from "./store/skill-store.js";
|
|
30
|
+
import { DatabaseManager } from "./store/db.js";
|
|
31
|
+
import { indexSession } from "./store/session-indexer.js";
|
|
32
|
+
import { parseSessionFile } from "./store/session-parser.js";
|
|
30
33
|
import { registerMemoryTool } from "./tools/memory-tool.js";
|
|
31
34
|
import { registerSkillTool } from "./tools/skill-tool.js";
|
|
35
|
+
import { registerSessionSearchTool } from "./tools/session-search-tool.js";
|
|
36
|
+
import { registerMemorySearchTool } from "./tools/memory-search-tool.js";
|
|
32
37
|
import { setupBackgroundReview } from "./handlers/background-review.js";
|
|
33
38
|
import { setupSessionFlush } from "./handlers/session-flush.js";
|
|
34
39
|
import { registerInsightsCommand } from "./handlers/insights.js";
|
|
@@ -38,6 +43,7 @@ import { setupSkillAutoTrigger } from "./handlers/skill-auto-trigger.js";
|
|
|
38
43
|
import { registerSkillsCommand } from "./handlers/skills-command.js";
|
|
39
44
|
import { registerInterviewCommand } from "./handlers/interview.js";
|
|
40
45
|
import { registerSwitchProjectCommand } from "./handlers/switch-project.js";
|
|
46
|
+
import { registerIndexSessionsCommand } from "./handlers/index-sessions.js";
|
|
41
47
|
import { loadConfig } from "./config.js";
|
|
42
48
|
import { detectProject } from "./project.js";
|
|
43
49
|
|
|
@@ -47,6 +53,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
47
53
|
const globalDir = config.memoryDir ?? path.join(os.homedir(), ".pi", "agent", "memory");
|
|
48
54
|
const store = new MemoryStore(config);
|
|
49
55
|
const skillStore = new SkillStore(path.join(globalDir, "skills"));
|
|
56
|
+
const dbManager = new DatabaseManager(globalDir);
|
|
50
57
|
|
|
51
58
|
// Detect project from cwd using shared helper
|
|
52
59
|
const project = detectProject();
|
|
@@ -111,4 +118,39 @@ export default function (pi: ExtensionAPI) {
|
|
|
111
118
|
registerSkillsCommand(pi, skillStore);
|
|
112
119
|
registerInterviewCommand(pi, store);
|
|
113
120
|
registerSwitchProjectCommand(pi);
|
|
121
|
+
|
|
122
|
+
// ── 11. SQLite session search + extended memory ──
|
|
123
|
+
registerSessionSearchTool(pi, dbManager);
|
|
124
|
+
registerMemorySearchTool(pi, dbManager);
|
|
125
|
+
registerIndexSessionsCommand(pi);
|
|
126
|
+
|
|
127
|
+
// ── 12. Auto-index session on shutdown ──
|
|
128
|
+
let currentSessionId: string | null = null;
|
|
129
|
+
let currentSessionCwd: string | null = null;
|
|
130
|
+
|
|
131
|
+
pi.on("session_start", async (event, _ctx) => {
|
|
132
|
+
// Capture session metadata for indexing
|
|
133
|
+
currentSessionId = (event as Record<string, unknown>).sessionId as string ?? null;
|
|
134
|
+
currentSessionCwd = process.cwd();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
pi.on("session_shutdown", async (_event, _ctx) => {
|
|
138
|
+
// Index the current session to SQLite
|
|
139
|
+
if (currentSessionId && currentSessionCwd) {
|
|
140
|
+
try {
|
|
141
|
+
const sessionsDir = path.join(os.homedir(), ".pi", "agent", "sessions");
|
|
142
|
+
const encodedCwd = currentSessionCwd.replace(/\//g, "-");
|
|
143
|
+
const sessionFiles = require("node:fs").readdirSync(path.join(sessionsDir, encodedCwd))
|
|
144
|
+
.filter((f: string) => f.includes(currentSessionId!) && f.endsWith(".jsonl"));
|
|
145
|
+
if (sessionFiles.length > 0) {
|
|
146
|
+
const sessionData = parseSessionFile(path.join(sessionsDir, encodedCwd, sessionFiles[0]));
|
|
147
|
+
if (sessionData) {
|
|
148
|
+
indexSession(dbManager, sessionData);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// Silent fail — don't block shutdown
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
114
156
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: learn-memory-tool
|
|
3
|
+
description: Learn how to use the pi-hermes-memory extension effectively — when to save memories, how to search, and best practices for persistent memory.
|
|
4
|
+
version: 1
|
|
5
|
+
created: 2026-05-03
|
|
6
|
+
updated: 2026-05-03
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## When to Use
|
|
10
|
+
|
|
11
|
+
When a user asks about the memory system, how to use it, or when they seem confused about what gets remembered. Also useful for onboarding new users to the extension.
|
|
12
|
+
|
|
13
|
+
## Overview
|
|
14
|
+
|
|
15
|
+
Pi Hermes Memory gives your AI agent persistent memory across sessions. Here's what it does:
|
|
16
|
+
|
|
17
|
+
### What Gets Saved
|
|
18
|
+
|
|
19
|
+
| Type | File | What Goes Here | Limit |
|
|
20
|
+
|---|---|---|---|
|
|
21
|
+
| **Memory** | `MEMORY.md` | Facts — env details, project conventions, tool quirks | 5,000 chars |
|
|
22
|
+
| **User Profile** | `USER.md` | Who you are — name, preferences, communication style | 5,000 chars |
|
|
23
|
+
| **Skills** | `skills/*.md` | Procedures — *how* to debug, deploy, test | Unlimited |
|
|
24
|
+
| **Extended Memory** | `sessions.db` | Searchable memories beyond the core limit | Unlimited |
|
|
25
|
+
|
|
26
|
+
### The `memory` Tool
|
|
27
|
+
|
|
28
|
+
The agent has a `memory` tool with these actions:
|
|
29
|
+
|
|
30
|
+
| Action | Target | What It Does |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `add` | `memory` or `user` | Append a new entry |
|
|
33
|
+
| `replace` | `memory` or `user` | Update an existing entry (matched by substring) |
|
|
34
|
+
| `remove` | `memory` or `user` | Delete an entry (matched by substring) |
|
|
35
|
+
|
|
36
|
+
### The `skill` Tool
|
|
37
|
+
|
|
38
|
+
For saving reusable procedures:
|
|
39
|
+
|
|
40
|
+
| Action | What It Does |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `create` | Save a new skill |
|
|
43
|
+
| `view` | Read a skill or list all skills |
|
|
44
|
+
| `patch` | Update one section of a skill |
|
|
45
|
+
| `edit` | Replace description and/or body |
|
|
46
|
+
| `delete` | Remove a skill |
|
|
47
|
+
|
|
48
|
+
### Search Tools
|
|
49
|
+
|
|
50
|
+
| Tool | What It Does |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `session_search` | Search past conversations across all sessions |
|
|
53
|
+
| `memory_search` | Search extended memory store (unlimited capacity) |
|
|
54
|
+
|
|
55
|
+
### Commands
|
|
56
|
+
|
|
57
|
+
| Command | What It Does |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `/memory-insights` | Shows everything stored in memory and user profile |
|
|
60
|
+
| `/memory-skills` | Lists all agent-created skills |
|
|
61
|
+
| `/memory-consolidate` | Manually trigger memory consolidation |
|
|
62
|
+
| `/memory-interview` | Answer questions to pre-fill your user profile |
|
|
63
|
+
| `/memory-switch-project` | List all project memories |
|
|
64
|
+
| `/memory-index-sessions` | Import past sessions for search |
|
|
65
|
+
|
|
66
|
+
## Best Practices
|
|
67
|
+
|
|
68
|
+
### What TO Save
|
|
69
|
+
|
|
70
|
+
- **User preferences**: "prefers pnpm over npm", "uses vim", "likes concise answers"
|
|
71
|
+
- **Environment facts**: "macOS M1", "Node 20", "project uses Prisma"
|
|
72
|
+
- **Corrections**: "don't use npm — use pnpm", "always run tests first"
|
|
73
|
+
- **Project conventions**: "monorepo with turborepo", "conventional commits"
|
|
74
|
+
- **Tool quirks**: "CI needs `--frozen-lockfile`", "deploy script is in scripts/deploy.sh"
|
|
75
|
+
|
|
76
|
+
### What NOT to Save
|
|
77
|
+
|
|
78
|
+
- **Task progress**: "finished implementing auth" — this is temporary
|
|
79
|
+
- **Session outcomes**: "PR #42 was merged" — this belongs in git history
|
|
80
|
+
- **Temporary state**: "currently debugging the test failure" — will be irrelevant soon
|
|
81
|
+
- **Large code blocks**: Use skills instead for procedures
|
|
82
|
+
|
|
83
|
+
### How Memory Flows
|
|
84
|
+
|
|
85
|
+
1. **Session starts**: Core memory (MEMORY.md + USER.md) is injected into the system prompt
|
|
86
|
+
2. **During conversation**: Agent saves memories via the `memory` tool
|
|
87
|
+
3. **Every 10 turns or 15 tool calls**: Background review saves anything noteworthy
|
|
88
|
+
4. **When you correct the agent**: Immediate save — no waiting
|
|
89
|
+
5. **When memory is full**: Auto-consolidation merges and prunes entries
|
|
90
|
+
6. **Session ends**: One last flush before shutdown
|
|
91
|
+
|
|
92
|
+
### Two-Tier Architecture
|
|
93
|
+
|
|
94
|
+
- **Global memory** (`~/.pi/agent/memory/`): Always injected — your name, preferences, tools
|
|
95
|
+
- **Project memory** (`~/.pi/agent/<project>/`): Injected when cwd matches — project-specific facts
|
|
96
|
+
|
|
97
|
+
### Memory Aging
|
|
98
|
+
|
|
99
|
+
Entries carry timestamps. When consolidating, the agent knows which entries are stale (created long ago, never referenced) and which are fresh.
|
|
100
|
+
|
|
101
|
+
### Context Fencing
|
|
102
|
+
|
|
103
|
+
Memory is wrapped in `<memory-context>` XML tags so the LLM never treats stored facts as user instructions. This prevents injection attacks through stored memory.
|
|
104
|
+
|
|
105
|
+
## Troubleshooting
|
|
106
|
+
|
|
107
|
+
### "Memory is full"
|
|
108
|
+
Run `/memory-consolidate` to manually merge entries. Or let auto-consolidation handle it.
|
|
109
|
+
|
|
110
|
+
### "I can't find what I'm looking for"
|
|
111
|
+
Use `memory_search` to search the extended store, or `session_search` to search past conversations.
|
|
112
|
+
|
|
113
|
+
### "The agent forgot something"
|
|
114
|
+
Check `/memory-insights` to see what's stored. If it's not there, the agent may not have saved it yet. You can tell the agent: "remember that X".
|
|
115
|
+
|
|
116
|
+
### "I want to edit memory manually"
|
|
117
|
+
Memory files are plain markdown at `~/.pi/agent/memory/MEMORY.md` and `USER.md`. Edit them directly if you want.
|
|
118
|
+
|
|
119
|
+
## Verification
|
|
120
|
+
|
|
121
|
+
After reading this skill, the user should understand:
|
|
122
|
+
1. What the memory tool does and when to use it
|
|
123
|
+
2. The difference between memory, user profile, skills, and extended memory
|
|
124
|
+
3. How to search across sessions and extended memory
|
|
125
|
+
4. Best practices for what to save and what not to save
|
package/src/store/db.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { SCHEMA_SQL } from './schema.js';
|
|
5
|
+
|
|
6
|
+
export class DatabaseManager {
|
|
7
|
+
private db: Database.Database | null = null;
|
|
8
|
+
private readonly dbPath: string;
|
|
9
|
+
|
|
10
|
+
constructor(memoryDir: string) {
|
|
11
|
+
this.dbPath = path.join(memoryDir, 'sessions.db');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get the database instance. Creates/opens on first call.
|
|
16
|
+
*/
|
|
17
|
+
getDb(): Database.Database {
|
|
18
|
+
if (!this.db) {
|
|
19
|
+
this.db = this.open();
|
|
20
|
+
}
|
|
21
|
+
return this.db;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Open the database and initialize schema.
|
|
26
|
+
*/
|
|
27
|
+
private open(): Database.Database {
|
|
28
|
+
// Ensure directory exists
|
|
29
|
+
const dir = path.dirname(this.dbPath);
|
|
30
|
+
if (!fs.existsSync(dir)) {
|
|
31
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const db = new Database(this.dbPath);
|
|
35
|
+
|
|
36
|
+
// Enable WAL mode for concurrent reads
|
|
37
|
+
db.pragma('journal_mode = WAL');
|
|
38
|
+
db.pragma('foreign_keys = ON');
|
|
39
|
+
|
|
40
|
+
// Create tables and triggers
|
|
41
|
+
db.exec(SCHEMA_SQL);
|
|
42
|
+
|
|
43
|
+
return db;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Close the database connection.
|
|
48
|
+
*/
|
|
49
|
+
close(): void {
|
|
50
|
+
if (this.db) {
|
|
51
|
+
this.db.close();
|
|
52
|
+
this.db = null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get the database file path.
|
|
58
|
+
*/
|
|
59
|
+
getPath(): string {
|
|
60
|
+
return this.dbPath;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check if the database file exists.
|
|
65
|
+
*/
|
|
66
|
+
exists(): boolean {
|
|
67
|
+
return fs.existsSync(this.dbPath);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get stats about the database.
|
|
72
|
+
*/
|
|
73
|
+
getStats(): { sessions: number; messages: number; memories: number } {
|
|
74
|
+
const db = this.getDb();
|
|
75
|
+
const sessions = db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number };
|
|
76
|
+
const messages = db.prepare('SELECT COUNT(*) as count FROM messages').get() as { count: number };
|
|
77
|
+
const memories = db.prepare('SELECT COUNT(*) as count FROM memories').get() as { count: number };
|
|
78
|
+
return {
|
|
79
|
+
sessions: sessions.count,
|
|
80
|
+
messages: messages.count,
|
|
81
|
+
memories: memories.count,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite schema for pi-hermes-memory v0.4
|
|
3
|
+
*
|
|
4
|
+
* Tables:
|
|
5
|
+
* - sessions — Pi session metadata
|
|
6
|
+
* - messages — all conversation messages
|
|
7
|
+
* - message_fts — FTS5 index for full-text search across messages
|
|
8
|
+
* - memories — extended memory entries (unlimited, searchable)
|
|
9
|
+
* - memory_fts — FTS5 index for memory search
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const SCHEMA_SQL = `
|
|
13
|
+
-- Session metadata
|
|
14
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
15
|
+
id TEXT PRIMARY KEY,
|
|
16
|
+
project TEXT NOT NULL,
|
|
17
|
+
cwd TEXT NOT NULL,
|
|
18
|
+
started_at TEXT NOT NULL,
|
|
19
|
+
ended_at TEXT,
|
|
20
|
+
message_count INTEGER DEFAULT 0
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
-- All messages from all sessions
|
|
24
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
25
|
+
id TEXT PRIMARY KEY,
|
|
26
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
27
|
+
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
|
|
28
|
+
content TEXT NOT NULL,
|
|
29
|
+
timestamp TEXT NOT NULL,
|
|
30
|
+
tool_calls TEXT
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
-- FTS5 index for full-text search across messages
|
|
34
|
+
-- content='messages' + content_rowid='rowid' keeps FTS in sync with the content table
|
|
35
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts5(
|
|
36
|
+
content,
|
|
37
|
+
content='messages',
|
|
38
|
+
content_rowid='rowid'
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
-- Triggers to keep message_fts in sync with messages table
|
|
42
|
+
CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
|
|
43
|
+
INSERT INTO message_fts(rowid, content) VALUES (new.rowid, new.content);
|
|
44
|
+
END;
|
|
45
|
+
|
|
46
|
+
CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
|
|
47
|
+
INSERT INTO message_fts(message_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
|
|
48
|
+
END;
|
|
49
|
+
|
|
50
|
+
CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
|
|
51
|
+
INSERT INTO message_fts(message_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
|
|
52
|
+
INSERT INTO message_fts(rowid, content) VALUES (new.rowid, new.content);
|
|
53
|
+
END;
|
|
54
|
+
|
|
55
|
+
-- Extended memory entries (beyond MEMORY.md limit)
|
|
56
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
57
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
58
|
+
project TEXT,
|
|
59
|
+
target TEXT NOT NULL CHECK (target IN ('memory', 'user')),
|
|
60
|
+
content TEXT NOT NULL,
|
|
61
|
+
created DATE NOT NULL,
|
|
62
|
+
last_referenced DATE NOT NULL
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
-- FTS5 index for memory search
|
|
66
|
+
-- content='memories' + content_rowid='id' keeps FTS in sync
|
|
67
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
|
|
68
|
+
content,
|
|
69
|
+
content='memories',
|
|
70
|
+
content_rowid='id'
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
-- Triggers to keep memory_fts in sync with memories table
|
|
74
|
+
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
|
75
|
+
INSERT INTO memory_fts(rowid, content) VALUES (new.id, new.content);
|
|
76
|
+
END;
|
|
77
|
+
|
|
78
|
+
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
|
79
|
+
INSERT INTO memory_fts(memory_fts, rowid, content) VALUES ('delete', old.id, old.content);
|
|
80
|
+
END;
|
|
81
|
+
|
|
82
|
+
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
|
|
83
|
+
INSERT INTO memory_fts(memory_fts, rowid, content) VALUES ('delete', old.id, old.content);
|
|
84
|
+
INSERT INTO memory_fts(rowid, content) VALUES (new.id, new.content);
|
|
85
|
+
END;
|
|
86
|
+
|
|
87
|
+
-- Indexes for common queries
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
|
|
89
|
+
CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project);
|
|
91
|
+
CREATE INDEX IF NOT EXISTS idx_memories_target ON memories(target);
|
|
92
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project);
|
|
93
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at);
|
|
94
|
+
`;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { DatabaseManager } from './db.js';
|
|
2
|
+
import { parseSessionFile, getSessionFiles, type ParsedSession } from './session-parser.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Index result for a single session.
|
|
6
|
+
*/
|
|
7
|
+
export interface IndexResult {
|
|
8
|
+
sessionId: string;
|
|
9
|
+
messagesIndexed: number;
|
|
10
|
+
skipped: boolean; // true if already indexed
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Bulk index result.
|
|
15
|
+
*/
|
|
16
|
+
export interface BulkIndexResult {
|
|
17
|
+
sessionsProcessed: number;
|
|
18
|
+
sessionsIndexed: number;
|
|
19
|
+
sessionsSkipped: number;
|
|
20
|
+
messagesIndexed: number;
|
|
21
|
+
errors: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Index a single session into the database.
|
|
26
|
+
*
|
|
27
|
+
* @returns IndexResult with count of messages indexed
|
|
28
|
+
*/
|
|
29
|
+
export function indexSession(dbManager: DatabaseManager, session: ParsedSession): IndexResult {
|
|
30
|
+
const db = dbManager.getDb();
|
|
31
|
+
|
|
32
|
+
// Check if already indexed
|
|
33
|
+
const existing = db.prepare('SELECT id FROM sessions WHERE id = ?').get(session.id) as { id: string } | undefined;
|
|
34
|
+
if (existing) {
|
|
35
|
+
return { sessionId: session.id, messagesIndexed: 0, skipped: true };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Insert session
|
|
39
|
+
db.prepare(`
|
|
40
|
+
INSERT INTO sessions (id, project, cwd, started_at, ended_at, message_count)
|
|
41
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
42
|
+
`).run(
|
|
43
|
+
session.id,
|
|
44
|
+
session.project,
|
|
45
|
+
session.cwd,
|
|
46
|
+
session.startedAt,
|
|
47
|
+
session.endedAt,
|
|
48
|
+
session.messages.length
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Insert messages in a transaction for performance
|
|
52
|
+
const insertMsg = db.prepare(`
|
|
53
|
+
INSERT INTO messages (id, session_id, role, content, timestamp, tool_calls)
|
|
54
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
55
|
+
`);
|
|
56
|
+
|
|
57
|
+
const insertMany = db.transaction((messages: ParsedSession['messages']) => {
|
|
58
|
+
for (const msg of messages) {
|
|
59
|
+
insertMsg.run(
|
|
60
|
+
msg.id,
|
|
61
|
+
session.id,
|
|
62
|
+
msg.role,
|
|
63
|
+
msg.content,
|
|
64
|
+
msg.timestamp,
|
|
65
|
+
msg.toolCalls ? JSON.stringify(msg.toolCalls) : null
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
insertMany(session.messages);
|
|
71
|
+
|
|
72
|
+
return { sessionId: session.id, messagesIndexed: session.messages.length, skipped: false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Index all sessions from disk.
|
|
77
|
+
*
|
|
78
|
+
* @param dbManager — Database manager instance
|
|
79
|
+
* @param sessionsDir — Path to ~/.pi/agent/sessions/
|
|
80
|
+
* @param projectDir — Optional: specific project directory to index
|
|
81
|
+
* @returns Bulk index result
|
|
82
|
+
*/
|
|
83
|
+
export function indexAllSessions(
|
|
84
|
+
dbManager: DatabaseManager,
|
|
85
|
+
sessionsDir: string,
|
|
86
|
+
projectDir?: string
|
|
87
|
+
): BulkIndexResult {
|
|
88
|
+
const files = getSessionFiles(sessionsDir, projectDir);
|
|
89
|
+
const result: BulkIndexResult = {
|
|
90
|
+
sessionsProcessed: 0,
|
|
91
|
+
sessionsIndexed: 0,
|
|
92
|
+
sessionsSkipped: 0,
|
|
93
|
+
messagesIndexed: 0,
|
|
94
|
+
errors: [],
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
result.sessionsProcessed++;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const session = parseSessionFile(file);
|
|
102
|
+
if (!session) {
|
|
103
|
+
result.errors.push(`Failed to parse: ${file}`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const indexResult = indexSession(dbManager, session);
|
|
108
|
+
if (indexResult.skipped) {
|
|
109
|
+
result.sessionsSkipped++;
|
|
110
|
+
} else {
|
|
111
|
+
result.sessionsIndexed++;
|
|
112
|
+
result.messagesIndexed += indexResult.messagesIndexed;
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
result.errors.push(`Error indexing ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get statistics about indexed sessions.
|
|
124
|
+
*/
|
|
125
|
+
export function getSessionStats(dbManager: DatabaseManager): {
|
|
126
|
+
totalSessions: number;
|
|
127
|
+
totalMessages: number;
|
|
128
|
+
projects: { project: string; sessions: number; messages: number }[];
|
|
129
|
+
} {
|
|
130
|
+
const db = dbManager.getDb();
|
|
131
|
+
|
|
132
|
+
const totals = db.prepare(`
|
|
133
|
+
SELECT
|
|
134
|
+
(SELECT COUNT(*) FROM sessions) as sessions,
|
|
135
|
+
(SELECT COUNT(*) FROM messages) as messages
|
|
136
|
+
`).get() as { sessions: number; messages: number };
|
|
137
|
+
|
|
138
|
+
const projects = db.prepare(`
|
|
139
|
+
SELECT
|
|
140
|
+
project,
|
|
141
|
+
COUNT(*) as sessions,
|
|
142
|
+
(SELECT COUNT(*) FROM messages m WHERE m.session_id IN (SELECT id FROM sessions s2 WHERE s2.project = s.project)) as messages
|
|
143
|
+
FROM sessions s
|
|
144
|
+
GROUP BY project
|
|
145
|
+
ORDER BY sessions DESC
|
|
146
|
+
`).all() as { project: string; sessions: number; messages: number }[];
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
totalSessions: totals.sessions,
|
|
150
|
+
totalMessages: totals.messages,
|
|
151
|
+
projects,
|
|
152
|
+
};
|
|
153
|
+
}
|