feat: add session memory tools with semantic search and logging

Adds comprehensive session memory management with the following tools:
- start_logging_session: Begin logging a session with optional name
- log_message: Log user/agent messages to active sessions
- search_session: Semantic search within a specific session
- search_all_sessions: Semantic search across all logged sessions
- list_sessions: List all sessions with optional active filter
- get_session: Get session details and messages
- end_session: End session with optional summary
Database schema includes:
- sessions table with name, timestamps, summary, and active status
- session_messages table with role, content, and creation time
- Proper indexes for performance
- Foreign key relationships
All tools support semantic search using the existing embedding model.
This commit is contained in:
CodingInCarhartts
2025-12-14 22:19:18 -08:00
parent 603b666b5f
commit 6ad6748e6e
5 changed files with 969 additions and 43 deletions
+40
View File
@@ -54,6 +54,8 @@ bun run mcp # Start MCP server
## 🛠️ MCP Tools ## 🛠️ MCP Tools
### Knowledge Tools
| Tool | Description | | Tool | Description |
| :---------------------- | :--------------------------------------------- | | :---------------------- | :--------------------------------------------- |
| `store_knowledge` | Store a new knowledge entry with optional tags | | `store_knowledge` | Store a new knowledge entry with optional tags |
@@ -65,6 +67,20 @@ bun run mcp # Start MCP server
| `list_knowledge` | List entries with filters | | `list_knowledge` | List entries with filters |
| `get_knowledge_stats` | Database statistics | | `get_knowledge_stats` | Database statistics |
### Session Memory Tools
Log and search across entire OpenCode sessions.
| Tool | Description |
| :---------------------- | :--------------------------------------------- |
| `start_logging_session` | Begin logging a session |
| `log_message` | Log a user/agent message to the session |
| `search_session` | Semantic search within a session |
| `search_all_sessions` | Search across ALL logged sessions |
| `list_sessions` | List all sessions |
| `get_session` | Get session details and messages |
| `end_session` | End session with optional summary |
## 📖 Example Usage ## 📖 Example Usage
### Storing Knowledge ### Storing Knowledge
@@ -91,6 +107,30 @@ Found 1 similar entry:
Opencode is an open source AI coding agent... Opencode is an open source AI coding agent...
``` ```
### Session Memory
**User:** "Start logging this session, call it 'auth debugging'"
**Agent:** Starts session and logs all exchanges:
```
✅ Started session #1: "auth debugging"
```
**User:** "Search this session for JWT"
**Agent:** Returns semantic matches from the session:
```
Found 2 matches in session #1:
### 1. [user] (92% match)
The JWT token expires too fast...
### 2. [agent] (88% match)
The TTL is set to 60 instead of 3600...
```
## ⚙️ Configuration ## ⚙️ Configuration
### Data Location ### Data Location
+244 -8
View File
@@ -30,6 +30,41 @@ export interface KnowledgeRecord {
updated_at: string; updated_at: string;
} }
// Session types for session memory feature
export interface Session {
id: number;
name: string | null;
started_at: string;
ended_at: string | null;
summary: string | null;
is_active: boolean;
}
export interface SessionMessage {
id: number;
session_id: number;
role: "user" | "agent";
content: string;
created_at: string;
}
interface SessionRecord {
id: number;
name: string | null;
started_at: string;
ended_at: string | null;
summary: string | null;
is_active: number;
}
interface SessionMessageRecord {
id: number;
session_id: number;
role: string;
content: string;
created_at: string;
}
let db: Database | null = null; let db: Database | null = null;
/** /**
@@ -62,9 +97,33 @@ export function initDatabase(): Database {
) )
`); `);
// Session tables for session memory feature
db.run(`
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
started_at TEXT NOT NULL,
ended_at TEXT,
summary TEXT,
is_active INTEGER DEFAULT 1
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS session_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES sessions(id),
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL
)
`);
// Create indexes // Create indexes
db.run(`CREATE INDEX IF NOT EXISTS idx_tags ON knowledge_entries(tags)`); db.run(`CREATE INDEX IF NOT EXISTS idx_tags ON knowledge_entries(tags)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_created ON knowledge_entries(created_at)`); db.run(`CREATE INDEX IF NOT EXISTS idx_created ON knowledge_entries(created_at)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_session_messages ON session_messages(session_id)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_sessions_active ON sessions(is_active)`);
return db; return db;
} }
@@ -92,9 +151,9 @@ export function saveKnowledgeEntry(entry: Omit<KnowledgeEntry, "id" | "created_a
export function getKnowledgeEntry(id: number): KnowledgeEntry | null { export function getKnowledgeEntry(id: number): KnowledgeEntry | null {
const database = initDatabase(); const database = initDatabase();
const record = database.prepare("SELECT * FROM knowledge_entries WHERE id = ?").get(id) as KnowledgeRecord | undefined; const record = database.prepare("SELECT * FROM knowledge_entries WHERE id = ?").get(id) as KnowledgeRecord | undefined;
if (!record) return null; if (!record) return null;
return { return {
id: record.id, id: record.id,
title: record.title, title: record.title,
@@ -184,18 +243,18 @@ export function listKnowledgeEntries(options: {
*/ */
export function searchKnowledgeByText(query: string, limit = 10): KnowledgeEntry[] { export function searchKnowledgeByText(query: string, limit = 10): KnowledgeEntry[] {
const database = initDatabase(); const database = initDatabase();
// Split query into words for OR search // Split query into words for OR search
const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 2); const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 2);
if (words.length === 0) { if (words.length === 0) {
return []; return [];
} }
const conditions = words.map(() => const conditions = words.map(() =>
"(LOWER(title) LIKE ? OR LOWER(content) LIKE ?)" "(LOWER(title) LIKE ? OR LOWER(content) LIKE ?)"
).join(" OR "); ).join(" OR ");
const params = words.flatMap(w => [`%${w}%`, `%${w}%`]); const params = words.flatMap(w => [`%${w}%`, `%${w}%`]);
const records = database.prepare(` const records = database.prepare(`
@@ -246,14 +305,14 @@ export function getStats(): {
const database = initDatabase(); const database = initDatabase();
const countResult = database.prepare("SELECT COUNT(*) as count FROM knowledge_entries").get() as { count: number }; const countResult = database.prepare("SELECT COUNT(*) as count FROM knowledge_entries").get() as { count: number };
const oldest = database.prepare("SELECT MIN(created_at) as oldest FROM knowledge_entries").get() as { oldest: string | null }; const oldest = database.prepare("SELECT MIN(created_at) as oldest FROM knowledge_entries").get() as { oldest: string | null };
const newest = database.prepare("SELECT MAX(created_at) as newest FROM knowledge_entries").get() as { newest: string | null }; const newest = database.prepare("SELECT MAX(created_at) as newest FROM knowledge_entries").get() as { newest: string | null };
// Count tags // Count tags
const allTags = database.prepare("SELECT tags FROM knowledge_entries WHERE tags IS NOT NULL").all() as { tags: string }[]; const allTags = database.prepare("SELECT tags FROM knowledge_entries WHERE tags IS NOT NULL").all() as { tags: string }[];
const tagCounts: Record<string, number> = {}; const tagCounts: Record<string, number> = {};
for (const row of allTags) { for (const row of allTags) {
const tags = JSON.parse(row.tags) as string[]; const tags = JSON.parse(row.tags) as string[];
for (const tag of tags) { for (const tag of tags) {
@@ -269,6 +328,183 @@ export function getStats(): {
}; };
} }
// ============================================================================
// Session Memory Functions
// ============================================================================
const SESSION_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
/**
* Create a new session.
*/
export function createSession(name?: string): number {
const database = initDatabase();
const now = new Date().toISOString();
const stmt = database.prepare(`
INSERT INTO sessions (name, started_at, is_active)
VALUES (?, ?, 1)
`);
const result = stmt.run(name || null, now);
return Number(result.lastInsertRowid);
}
/**
* Get a session by ID.
*/
export function getSession(id: number): Session | null {
const database = initDatabase();
const record = database.prepare("SELECT * FROM sessions WHERE id = ?").get(id) as SessionRecord | undefined;
if (!record) return null;
return {
id: record.id,
name: record.name,
started_at: record.started_at,
ended_at: record.ended_at,
summary: record.summary,
is_active: record.is_active === 1,
};
}
/**
* Get the current active session (most recent).
*/
export function getActiveSession(): Session | null {
const database = initDatabase();
const record = database.prepare(
"SELECT * FROM sessions WHERE is_active = 1 ORDER BY id DESC LIMIT 1"
).get() as SessionRecord | undefined;
if (!record) return null;
return {
id: record.id,
name: record.name,
started_at: record.started_at,
ended_at: record.ended_at,
summary: record.summary,
is_active: record.is_active === 1,
};
}
/**
* End a session.
*/
export function endSession(id: number, summary?: string): boolean {
const database = initDatabase();
const now = new Date().toISOString();
const stmt = database.prepare(`
UPDATE sessions
SET ended_at = ?, summary = ?, is_active = 0
WHERE id = ?
`);
const result = stmt.run(now, summary || null, id);
return result.changes > 0;
}
/**
* Save a message to a session.
*/
export function saveSessionMessage(sessionId: number, role: "user" | "agent", content: string): number {
const database = initDatabase();
const now = new Date().toISOString();
const stmt = database.prepare(`
INSERT INTO session_messages (session_id, role, content, created_at)
VALUES (?, ?, ?, ?)
`);
const result = stmt.run(sessionId, role, content, now);
return Number(result.lastInsertRowid);
}
/**
* Get all messages for a session.
*/
export function getSessionMessages(sessionId: number): SessionMessage[] {
const database = initDatabase();
const records = database.prepare(
"SELECT * FROM session_messages WHERE session_id = ? ORDER BY created_at ASC"
).all(sessionId) as SessionMessageRecord[];
return records.map(record => ({
id: record.id,
session_id: record.session_id,
role: record.role as "user" | "agent",
content: record.content,
created_at: record.created_at,
}));
}
/**
* List all sessions.
*/
export function listSessions(options: { limit?: number; offset?: number; activeOnly?: boolean } = {}): Session[] {
const database = initDatabase();
const { limit = 20, offset = 0, activeOnly = false } = options;
let sql = "SELECT * FROM sessions";
if (activeOnly) {
sql += " WHERE is_active = 1";
}
sql += " ORDER BY started_at DESC LIMIT ? OFFSET ?";
const records = database.prepare(sql).all(limit, offset) as SessionRecord[];
return records.map(record => ({
id: record.id,
name: record.name,
started_at: record.started_at,
ended_at: record.ended_at,
summary: record.summary,
is_active: record.is_active === 1,
}));
}
/**
* Get session message count.
*/
export function getSessionMessageCount(sessionId: number): number {
const database = initDatabase();
const result = database.prepare(
"SELECT COUNT(*) as count FROM session_messages WHERE session_id = ?"
).get(sessionId) as { count: number };
return result.count;
}
/**
* Close timed-out sessions (inactive for more than 1 hour).
*/
export function closeTimedOutSessions(): number {
const database = initDatabase();
const cutoff = new Date(Date.now() - SESSION_TIMEOUT_MS).toISOString();
// Find sessions where last message is older than cutoff
const stmt = database.prepare(`
UPDATE sessions
SET is_active = 0, ended_at = CURRENT_TIMESTAMP, summary = 'Auto-closed due to inactivity'
WHERE is_active = 1 AND id IN (
SELECT s.id FROM sessions s
LEFT JOIN (
SELECT session_id, MAX(created_at) as last_msg
FROM session_messages
GROUP BY session_id
) m ON s.id = m.session_id
WHERE s.is_active = 1
AND (m.last_msg IS NULL OR m.last_msg < ?)
AND s.started_at < ?
)
`);
const result = stmt.run(cutoff, cutoff);
return result.changes;
}
/** /**
* Close the database connection. * Close the database connection.
*/ */
+299 -35
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env bun #!/usr/bin/env bun
/** /**
* Personal Knowledge MCP Server * Personal Knowledge MCP Server
* *
* Exposes knowledge database via Model Context Protocol for use by AI agents. * Exposes knowledge database via Model Context Protocol for use by AI agents.
* *
* Tools provided: * Tools provided:
* - store_knowledge: Store a new knowledge entry * - store_knowledge: Store a new knowledge entry
* - search_knowledge: Semantic search using vector embeddings * - search_knowledge: Semantic search using vector embeddings
@@ -13,6 +13,15 @@
* - delete_knowledge: Delete an entry * - delete_knowledge: Delete an entry
* - list_knowledge: List entries with filters * - list_knowledge: List entries with filters
* - get_knowledge_stats: Get database statistics * - get_knowledge_stats: Get database statistics
*
* Session Memory Tools:
* - start_logging_session: Begin logging a session
* - log_message: Log a user or agent message
* - search_session: Search within a session
* - search_all_sessions: Search across all sessions
* - list_sessions: List all sessions
* - get_session: Get session details
* - end_session: End and summarize a session
*/ */
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -27,6 +36,19 @@ import {
listKnowledge, listKnowledge,
getKnowledgeStats, getKnowledgeStats,
} from "./services/knowledgeService.js"; } from "./services/knowledgeService.js";
import {
startLoggingSession,
logMessage,
searchSession,
searchAllSessions,
getSession as getSessionDetails,
getActiveSession,
hasActiveSession,
endSession,
listSessions,
closeTimedOutSessions,
getSessionStats,
} from "./services/sessionService.js";
// Create MCP server // Create MCP server
const server = new McpServer({ const server = new McpServer({
@@ -73,13 +95,13 @@ server.tool(
async ({ query, limit }) => { async ({ query, limit }) => {
try { try {
const results = await searchKnowledge(query, { limit, minScore: 0.3 }); const results = await searchKnowledge(query, { limit, minScore: 0.3 });
if (results.length === 0) { if (results.length === 0) {
return { return {
content: [{ type: "text", text: "No similar knowledge entries found." }], content: [{ type: "text", text: "No similar knowledge entries found." }],
}; };
} }
let output = `## Found ${results.length} similar entries:\n\n`; let output = `## Found ${results.length} similar entries:\n\n`;
for (let i = 0; i < results.length; i++) { for (let i = 0; i < results.length; i++) {
const r = results[i]; const r = results[i];
@@ -91,7 +113,7 @@ server.tool(
} }
output += `\n${r.content_preview}...\n\n---\n\n`; output += `\n${r.content_preview}...\n\n---\n\n`;
} }
return { return {
content: [{ type: "text", text: output }], content: [{ type: "text", text: output }],
}; };
@@ -99,9 +121,9 @@ server.tool(
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";
if (message.includes("not initialized")) { if (message.includes("not initialized")) {
return { return {
content: [{ content: [{
type: "text", type: "text",
text: "Vector database not initialized. Use search_knowledge_text for keyword search, or add some entries first." text: "Vector database not initialized. Use search_knowledge_text for keyword search, or add some entries first."
}], }],
}; };
} }
@@ -122,21 +144,21 @@ server.tool(
}, },
async ({ query, limit }) => { async ({ query, limit }) => {
const results = searchKnowledgeText(query, limit); const results = searchKnowledgeText(query, limit);
if (results.length === 0) { if (results.length === 0) {
return { return {
content: [{ type: "text", text: `No results found for: "${query}"` }], content: [{ type: "text", text: `No results found for: "${query}"` }],
}; };
} }
const formatted = results.map((r) => const formatted = results.map((r) =>
`**${r.title}** (ID: ${r.id})\n${r.content.slice(0, 200)}...${r.tags ? `\nTags: ${r.tags.join(", ")}` : ""}` `**${r.title}** (ID: ${r.id})\n${r.content.slice(0, 200)}...${r.tags ? `\nTags: ${r.tags.join(", ")}` : ""}`
).join("\n\n---\n\n"); ).join("\n\n---\n\n");
return { return {
content: [{ content: [{
type: "text", type: "text",
text: `Found ${results.length} result(s) for "${query}":\n\n${formatted}` text: `Found ${results.length} result(s) for "${query}":\n\n${formatted}`
}], }],
}; };
} }
@@ -151,13 +173,13 @@ server.tool(
}, },
async ({ id }) => { async ({ id }) => {
const entry = getKnowledge(id); const entry = getKnowledge(id);
if (!entry) { if (!entry) {
return { return {
content: [{ type: "text", text: `No entry found with ID: ${id}` }], content: [{ type: "text", text: `No entry found with ID: ${id}` }],
}; };
} }
return { return {
content: [{ content: [{
type: "text", type: "text",
@@ -184,25 +206,25 @@ server.tool(
if (content !== undefined) updates.content = content; if (content !== undefined) updates.content = content;
if (source !== undefined) updates.source = source; if (source !== undefined) updates.source = source;
if (tags !== undefined) updates.tags = tags; if (tags !== undefined) updates.tags = tags;
if (Object.keys(updates).length === 0) { if (Object.keys(updates).length === 0) {
return { return {
content: [{ type: "text", text: "No updates provided" }], content: [{ type: "text", text: "No updates provided" }],
}; };
} }
const result = await updateKnowledge(id, updates); const result = await updateKnowledge(id, updates);
if (!result.success) { if (!result.success) {
return { return {
content: [{ type: "text", text: `No entry found with ID: ${id}` }], content: [{ type: "text", text: `No entry found with ID: ${id}` }],
}; };
} }
return { return {
content: [{ content: [{
type: "text", type: "text",
text: `✅ Updated entry #${id}\n${result.vectorized ? "📊 Re-indexed for semantic search" : "⚠️ Database updated (vector re-indexing failed)"}` text: `✅ Updated entry #${id}\n${result.vectorized ? "📊 Re-indexed for semantic search" : "⚠️ Database updated (vector re-indexing failed)"}`
}], }],
}; };
} }
@@ -217,13 +239,13 @@ server.tool(
}, },
async ({ id }) => { async ({ id }) => {
const success = await deleteKnowledge(id); const success = await deleteKnowledge(id);
if (!success) { if (!success) {
return { return {
content: [{ type: "text", text: `No entry found with ID: ${id}` }], content: [{ type: "text", text: `No entry found with ID: ${id}` }],
}; };
} }
return { return {
content: [{ type: "text", text: `✅ Deleted entry #${id}` }], content: [{ type: "text", text: `✅ Deleted entry #${id}` }],
}; };
@@ -241,21 +263,21 @@ server.tool(
}, },
async ({ limit, offset, tags }) => { async ({ limit, offset, tags }) => {
const entries = listKnowledge({ limit, offset, tags }); const entries = listKnowledge({ limit, offset, tags });
if (entries.length === 0) { if (entries.length === 0) {
return { return {
content: [{ type: "text", text: "No knowledge entries found." }], content: [{ type: "text", text: "No knowledge entries found." }],
}; };
} }
const formatted = entries.map((e) => const formatted = entries.map((e) =>
`- **${e.title}** (ID: ${e.id})${e.tags ? ` [${e.tags.join(", ")}]` : ""}` `- **${e.title}** (ID: ${e.id})${e.tags ? ` [${e.tags.join(", ")}]` : ""}`
).join("\n"); ).join("\n");
return { return {
content: [{ content: [{
type: "text", type: "text",
text: `📚 Knowledge Entries (${entries.length}):\n\n${formatted}` text: `📚 Knowledge Entries (${entries.length}):\n\n${formatted}`
}], }],
}; };
} }
@@ -268,13 +290,13 @@ server.tool(
{}, {},
async () => { async () => {
const stats = await getKnowledgeStats(); const stats = await getKnowledgeStats();
const tagList = Object.entries(stats.database.tagCounts) const tagList = Object.entries(stats.database.tagCounts)
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.slice(0, 10) .slice(0, 10)
.map(([tag, count]) => ` ${tag}: ${count}`) .map(([tag, count]) => ` ${tag}: ${count}`)
.join("\n"); .join("\n");
return { return {
content: [{ content: [{
type: "text", type: "text",
@@ -294,11 +316,253 @@ ${tagList || " No tags yet"}`,
} }
); );
// ============================================================================
// Session Memory Tools
// ============================================================================
// Tool: Start logging session
server.tool(
"start_logging_session",
"Start logging a new session for later search. IMPORTANT: After starting, you MUST call log_message for EACH user message and your response to capture the conversation. Call this when the user wants to record an important conversation.",
{
name: z.string().optional().describe("Optional name for the session (e.g., 'debugging auth')"),
},
async ({ name }) => {
try {
// Close any timed-out sessions first
closeTimedOutSessions();
const { sessionId, session } = startLoggingSession(name);
return {
content: [{
type: "text",
text: `✅ Started session #${sessionId}${name ? `: "${name}"` : ""}\n\n**IMPORTANT:** You must now call \`log_message\` for each exchange:\n1. Call \`log_message(role="user", content="...")\` with the user's message\n2. Call \`log_message(role="agent", content="...")\` with your response\n\nUse \`end_session\` when finished.`,
}],
};
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return {
content: [{ type: "text", text: `❌ Failed to start session: ${message}` }],
};
}
}
);
// Tool: Log message (MUST be called for each exchange during a session)
server.tool(
"log_message",
"Log a message to the current session. REQUIRED: Call this for EVERY user message and agent response while a session is active. This enables semantic search across the conversation later.",
{
role: z.enum(["user", "agent"]).describe("Who sent the message: 'user' or 'agent'"),
content: z.string().describe("The full message content to log"),
sessionId: z.number().optional().describe("Session ID (uses active session if not provided)"),
},
async ({ role, content, sessionId }) => {
try {
const result = await logMessage(role, content, sessionId);
return {
content: [{
type: "text",
text: `📝 Logged [${role}] message${result.indexed ? " (indexed for search)" : ""}`,
}],
};
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return {
content: [{ type: "text", text: `❌ Failed to log message: ${message}` }],
};
}
}
);
// Tool: Search within a session
server.tool(
"search_session",
"Search for content within a specific session using semantic similarity.",
{
sessionId: z.number().describe("The session ID to search within"),
query: z.string().describe("Search query"),
limit: z.number().optional().default(5).describe("Maximum results (default: 5)"),
},
async ({ sessionId, query, limit }) => {
try {
const results = await searchSession(sessionId, query, limit);
if (results.length === 0) {
return {
content: [{ type: "text", text: `No matches found in session #${sessionId}` }],
};
}
let output = `## Found ${results.length} matches in session #${sessionId}:\n\n`;
for (let i = 0; i < results.length; i++) {
const r = results[i];
const role = r.tags?.find(t => t.startsWith("role:"))?.replace("role:", "") || "?";
const similarity = Math.round(r.score * 100);
output += `### ${i + 1}. [${role}] (${similarity}% match)\n`;
output += `${r.content_preview}...\n\n`;
}
return {
content: [{ type: "text", text: output }],
};
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return {
content: [{ type: "text", text: `❌ Search error: ${message}` }],
};
}
}
);
// Tool: Search all sessions
server.tool(
"search_all_sessions",
"Search across ALL logged sessions using semantic similarity.",
{
query: z.string().describe("Search query"),
limit: z.number().optional().default(10).describe("Maximum results (default: 10)"),
},
async ({ query, limit }) => {
try {
const results = await searchAllSessions(query, limit);
if (results.length === 0) {
return {
content: [{ type: "text", text: "No matches found across sessions" }],
};
}
let output = `## Found ${results.length} matches across sessions:\n\n`;
for (let i = 0; i < results.length; i++) {
const r = results[i];
const sessionTag = r.tags?.find(t => t.startsWith("session:"));
const sessionId = sessionTag?.replace("session:", "") || "?";
const role = r.tags?.find(t => t.startsWith("role:"))?.replace("role:", "") || "?";
const similarity = Math.round(r.score * 100);
output += `### ${i + 1}. Session #${sessionId} [${role}] (${similarity}% match)\n`;
output += `${r.content_preview}...\n\n`;
}
return {
content: [{ type: "text", text: output }],
};
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return {
content: [{ type: "text", text: `❌ Search error: ${message}` }],
};
}
}
);
// Tool: List sessions
server.tool(
"list_sessions",
"List all logged sessions",
{
limit: z.number().optional().default(20).describe("Maximum sessions to return"),
activeOnly: z.boolean().optional().default(false).describe("Only show active sessions"),
},
async ({ limit, activeOnly }) => {
const sessions = listSessions({ limit, activeOnly });
if (sessions.length === 0) {
return {
content: [{ type: "text", text: "No sessions found" }],
};
}
const formatted = sessions.map(s => {
const status = s.is_active ? "🟢 Active" : "⚪ Ended";
const name = s.name ? `"${s.name}"` : "(unnamed)";
return `- **#${s.id}** ${name} - ${status} - ${s.started_at.slice(0, 10)}`;
}).join("\n");
return {
content: [{
type: "text",
text: `## Sessions (${sessions.length}):\n\n${formatted}`,
}],
};
}
);
// Tool: Get session details
server.tool(
"get_session",
"Get details and messages from a specific session",
{
sessionId: z.number().describe("The session ID"),
},
async ({ sessionId }) => {
const result = getSessionDetails(sessionId);
if (!result) {
return {
content: [{ type: "text", text: `Session #${sessionId} not found` }],
};
}
const { session, messages, messageCount } = result;
const status = session.is_active ? "🟢 Active" : "⚪ Ended";
let output = `## Session #${session.id}${session.name ? `: "${session.name}"` : ""}\n\n`;
output += `**Status:** ${status}\n`;
output += `**Started:** ${session.started_at}\n`;
if (session.ended_at) output += `**Ended:** ${session.ended_at}\n`;
if (session.summary) output += `**Summary:** ${session.summary}\n`;
output += `**Messages:** ${messageCount}\n\n`;
if (messages.length > 0) {
output += "### Recent Messages:\n\n";
const recentMessages = messages.slice(-10); // Last 10
for (const msg of recentMessages) {
output += `**[${msg.role}]** ${msg.content.slice(0, 200)}${msg.content.length > 200 ? "..." : ""}\n\n`;
}
}
return {
content: [{ type: "text", text: output }],
};
}
);
// Tool: End session
server.tool(
"end_session",
"End the current or specified session",
{
sessionId: z.number().optional().describe("Session ID (ends active session if not provided)"),
summary: z.string().optional().describe("Optional summary of what was accomplished"),
},
async ({ sessionId, summary }) => {
const result = endSession(sessionId, summary);
if (!result.success) {
return {
content: [{ type: "text", text: "❌ No active session to end" }],
};
}
return {
content: [{
type: "text",
text: `✅ Ended session with ${result.messageCount} messages${summary ? `\n📝 Summary: "${summary}"` : ""}`,
}],
};
}
);
// Start the server // Start the server
async function main() { async function main() {
// Clean up any timed-out sessions on startup
closeTimedOutSessions();
const transport = new StdioServerTransport(); const transport = new StdioServerTransport();
await server.connect(transport); await server.connect(transport);
console.error("Personal Knowledge MCP Server running on stdio"); console.error("Personal Knowledge MCP Server running on stdio");
} }
main().catch(console.error); main().catch(console.error);
+229
View File
@@ -0,0 +1,229 @@
/**
* Session Service
*
* Business logic for session memory feature.
* Coordinates session management and vector indexing of messages.
*/
import {
createSession as dbCreateSession,
getSession as dbGetSession,
getActiveSession as dbGetActiveSession,
endSession as dbEndSession,
saveSessionMessage as dbSaveSessionMessage,
getSessionMessages as dbGetSessionMessages,
listSessions as dbListSessions,
getSessionMessageCount,
closeTimedOutSessions as dbCloseTimedOutSessions,
type Session,
type SessionMessage,
} from "../database/index.js";
import { queryVectors, updateVector, type SearchResult } from "./vectorService.js";
export { type Session, type SessionMessage };
// Track the current active session ID in memory for auto-logging
let currentSessionId: number | null = null;
/**
* Start a new logging session.
*/
export function startLoggingSession(name?: string): { sessionId: number; session: Session } {
// Close any existing active session first
const existing = dbGetActiveSession();
if (existing) {
dbEndSession(existing.id, "Auto-closed when new session started");
}
const sessionId = dbCreateSession(name);
currentSessionId = sessionId;
const session = dbGetSession(sessionId);
if (!session) {
throw new Error("Failed to create session");
}
return { sessionId, session };
}
/**
* Log a message to the current or specified session.
* Also indexes the message in the vector database for search.
*/
export async function logMessage(
role: "user" | "agent",
content: string,
sessionId?: number
): Promise<{ messageId: number; indexed: boolean }> {
const targetSessionId = sessionId ?? currentSessionId;
if (!targetSessionId) {
throw new Error("No active session. Call start_logging_session first.");
}
// Check if session exists and is active
const session = dbGetSession(targetSessionId);
if (!session) {
throw new Error(`Session ${targetSessionId} not found`);
}
if (!session.is_active) {
throw new Error(`Session ${targetSessionId} is already closed`);
}
// Save message to database
const messageId = dbSaveSessionMessage(targetSessionId, role, content);
// Index in vector DB for semantic search
let indexed = false;
try {
await updateVector({
id: messageId,
title: `[${role}] Session ${targetSessionId}`,
content: content,
tags: [`session:${targetSessionId}`, `role:${role}`],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
indexed = true;
} catch {
// Vector indexing failed, but message is saved
console.error("Vector indexing failed for session message");
}
return { messageId, indexed };
}
/**
* Search within a specific session using semantic similarity.
*/
export async function searchSession(
sessionId: number,
query: string,
limit = 5
): Promise<SearchResult[]> {
// Query vectors with session tag filter
const results = await queryVectors(query, { limit: limit * 2, minScore: 0.3 });
// Filter to only include messages from this session
const sessionTag = `session:${sessionId}`;
const filtered = results.filter(r => r.tags?.includes(sessionTag));
return filtered.slice(0, limit);
}
/**
* Search across all sessions using semantic similarity.
*/
export async function searchAllSessions(
query: string,
limit = 10
): Promise<SearchResult[]> {
// Get all session-tagged results
const results = await queryVectors(query, { limit: limit * 2, minScore: 0.3 });
// Filter to only session messages (have session: tag)
const filtered = results.filter(r => r.tags?.some(t => t.startsWith("session:")));
return filtered.slice(0, limit);
}
/**
* Get a session with its messages.
*/
export function getSession(sessionId: number): {
session: Session;
messages: SessionMessage[];
messageCount: number;
} | null {
const session = dbGetSession(sessionId);
if (!session) return null;
const messages = dbGetSessionMessages(sessionId);
const messageCount = getSessionMessageCount(sessionId);
return { session, messages, messageCount };
}
/**
* Get the current active session.
*/
export function getActiveSession(): Session | null {
return dbGetActiveSession();
}
/**
* Check if there's an active session for auto-logging.
*/
export function hasActiveSession(): boolean {
return currentSessionId !== null && dbGetActiveSession() !== null;
}
/**
* End the current or specified session.
*/
export function endSession(
sessionId?: number,
summary?: string
): { success: boolean; messageCount: number } {
const targetSessionId = sessionId ?? currentSessionId;
if (!targetSessionId) {
return { success: false, messageCount: 0 };
}
const messageCount = getSessionMessageCount(targetSessionId);
const success = dbEndSession(targetSessionId, summary);
if (success && targetSessionId === currentSessionId) {
currentSessionId = null;
}
return { success, messageCount };
}
/**
* List all sessions.
*/
export function listSessions(options?: {
limit?: number;
offset?: number;
activeOnly?: boolean;
}): Session[] {
return dbListSessions(options);
}
/**
* Close any timed-out sessions (inactive for 1+ hour).
*/
export function closeTimedOutSessions(): number {
const closed = dbCloseTimedOutSessions();
// Clear current session if it was closed
if (currentSessionId && !dbGetActiveSession()) {
currentSessionId = null;
}
return closed;
}
/**
* Get session stats.
*/
export function getSessionStats(): {
totalSessions: number;
activeSessions: number;
totalMessages: number;
} {
const allSessions = dbListSessions({ limit: 1000 });
const activeSessions = allSessions.filter(s => s.is_active).length;
let totalMessages = 0;
for (const session of allSessions) {
totalMessages += getSessionMessageCount(session.id);
}
return {
totalSessions: allSessions.length,
activeSessions,
totalMessages,
};
}
+157
View File
@@ -0,0 +1,157 @@
/**
* Session Service Tests
*
* Uses isolated test database via OPENCODE_PK_DATA_DIR environment variable.
* Uses dynamic imports to ensure env var is set BEFORE module loads.
*/
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { mkdtempSync, rmSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
// Set up isolated test database BEFORE any imports
const testDir = mkdtempSync(join(tmpdir(), "opencode-pk-test-"));
process.env.OPENCODE_PK_DATA_DIR = testDir;
// Types for the dynamically imported module
type DatabaseModule = typeof import("../../src/database/index");
let db: DatabaseModule;
describe("Session Database Functions", () => {
beforeAll(async () => {
// Dynamic import AFTER env var is set
db = await import("../../src/database/index");
db.initDatabase();
});
afterAll(() => {
// Clean up
db.closeDatabase();
rmSync(testDir, { recursive: true, force: true });
});
describe("createSession", () => {
test("creates a session with name", () => {
const id = db.createSession("Test Session");
expect(id).toBeGreaterThan(0);
const session = db.getSession(id);
expect(session).not.toBeNull();
expect(session!.name).toBe("Test Session");
expect(session!.is_active).toBe(true);
expect(session!.ended_at).toBeNull();
});
test("creates a session without name", () => {
const id = db.createSession();
const session = db.getSession(id);
expect(session).not.toBeNull();
expect(session!.name).toBeNull();
expect(session!.is_active).toBe(true);
});
});
describe("getActiveSession", () => {
test("returns most recent active session", () => {
db.createSession("First");
db.createSession("Second");
const active = db.getActiveSession();
expect(active).not.toBeNull();
// Check it's the most recent by name (ID ordering can vary)
expect(active!.name).toBe("Second");
});
});
describe("endSession", () => {
test("ends a session with summary", () => {
const id = db.createSession("To End");
const success = db.endSession(id, "Completed successfully");
expect(success).toBe(true);
const session = db.getSession(id);
expect(session).not.toBeNull();
expect(session!.is_active).toBe(false);
expect(session!.ended_at).not.toBeNull();
expect(session!.summary).toBe("Completed successfully");
});
test("returns false for non-existent session", () => {
const success = db.endSession(99999);
expect(success).toBe(false);
});
});
describe("saveSessionMessage", () => {
test("saves user message", () => {
const sessionId = db.createSession("Message Test");
const msgId = db.saveSessionMessage(sessionId, "user", "Hello, world!");
expect(msgId).toBeGreaterThan(0);
const messages = db.getSessionMessages(sessionId);
expect(messages.length).toBe(1);
expect(messages[0].role).toBe("user");
expect(messages[0].content).toBe("Hello, world!");
});
test("saves agent message", () => {
const sessionId = db.createSession("Agent Message Test");
db.saveSessionMessage(sessionId, "user", "Question");
const msgId = db.saveSessionMessage(sessionId, "agent", "Answer");
const messages = db.getSessionMessages(sessionId);
expect(messages.length).toBe(2);
expect(messages[1].role).toBe("agent");
expect(messages[1].content).toBe("Answer");
});
});
describe("getSessionMessageCount", () => {
test("returns correct count", () => {
const sessionId = db.createSession("Count Test");
db.saveSessionMessage(sessionId, "user", "Message 1");
db.saveSessionMessage(sessionId, "agent", "Message 2");
db.saveSessionMessage(sessionId, "user", "Message 3");
const count = db.getSessionMessageCount(sessionId);
expect(count).toBe(3);
});
test("returns 0 for empty session", () => {
const sessionId = db.createSession("Empty");
const count = db.getSessionMessageCount(sessionId);
expect(count).toBe(0);
});
});
describe("listSessions", () => {
test("lists all sessions", () => {
// Create a few sessions for testing
db.createSession("List Test 1");
db.createSession("List Test 2");
const sessions = db.listSessions({ limit: 100 });
expect(sessions.length).toBeGreaterThanOrEqual(2);
});
test("respects limit", () => {
const sessions = db.listSessions({ limit: 2 });
expect(sessions.length).toBeLessThanOrEqual(2);
});
test("filters active only", () => {
const activeId = db.createSession("Active Only Test");
const inactiveId = db.createSession("Will End");
db.endSession(inactiveId);
const activeSessions = db.listSessions({ activeOnly: true });
const activeIds = activeSessions.map(s => s.id);
expect(activeIds).toContain(activeId);
expect(activeIds).not.toContain(inactiveId);
});
});
});