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.
- package/README.md +42 -9
- package/docs/0.4/PLAN.md +160 -0
- package/docs/0.4/TASKS.md +113 -0
- package/docs/ROADMAP.md +47 -29
- package/package.json +5 -1
- package/src/constants.ts +3 -3
- package/src/handlers/index-sessions.ts +64 -0
- package/src/index.ts +39 -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 +74 -0
- package/src/tools/session-search-tool.ts +79 -0
- package/src/types.ts +7 -3
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|