mirror of
https://github.com/NocturnLabs/opencode-personal-knowledge.git
synced 2026-06-02 06:03:47 +02:00
✨ 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:
@@ -54,6 +54,8 @@ bun run mcp # Start MCP server
|
||||
|
||||
## 🛠️ MCP Tools
|
||||
|
||||
### Knowledge Tools
|
||||
|
||||
| Tool | Description |
|
||||
| :---------------------- | :--------------------------------------------- |
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
### Storing Knowledge
|
||||
@@ -91,6 +107,30 @@ Found 1 similar entry:
|
||||
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
|
||||
|
||||
### Data Location
|
||||
|
||||
+244
-8
@@ -30,6 +30,41 @@ export interface KnowledgeRecord {
|
||||
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;
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
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_session_messages ON session_messages(session_id)`);
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_sessions_active ON sessions(is_active)`);
|
||||
|
||||
return db;
|
||||
}
|
||||
@@ -92,9 +151,9 @@ export function saveKnowledgeEntry(entry: Omit<KnowledgeEntry, "id" | "created_a
|
||||
export function getKnowledgeEntry(id: number): KnowledgeEntry | null {
|
||||
const database = initDatabase();
|
||||
const record = database.prepare("SELECT * FROM knowledge_entries WHERE id = ?").get(id) as KnowledgeRecord | undefined;
|
||||
|
||||
|
||||
if (!record) return null;
|
||||
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
@@ -184,18 +243,18 @@ export function listKnowledgeEntries(options: {
|
||||
*/
|
||||
export function searchKnowledgeByText(query: string, limit = 10): KnowledgeEntry[] {
|
||||
const database = initDatabase();
|
||||
|
||||
|
||||
// Split query into words for OR search
|
||||
const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 2);
|
||||
|
||||
|
||||
if (words.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const conditions = words.map(() =>
|
||||
const conditions = words.map(() =>
|
||||
"(LOWER(title) LIKE ? OR LOWER(content) LIKE ?)"
|
||||
).join(" OR ");
|
||||
|
||||
|
||||
const params = words.flatMap(w => [`%${w}%`, `%${w}%`]);
|
||||
|
||||
const records = database.prepare(`
|
||||
@@ -246,14 +305,14 @@ export function getStats(): {
|
||||
const database = initDatabase();
|
||||
|
||||
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 newest = database.prepare("SELECT MAX(created_at) as newest FROM knowledge_entries").get() as { newest: string | null };
|
||||
|
||||
// Count tags
|
||||
const allTags = database.prepare("SELECT tags FROM knowledge_entries WHERE tags IS NOT NULL").all() as { tags: string }[];
|
||||
const tagCounts: Record<string, number> = {};
|
||||
|
||||
|
||||
for (const row of allTags) {
|
||||
const tags = JSON.parse(row.tags) as string[];
|
||||
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.
|
||||
*/
|
||||
|
||||
+299
-35
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Personal Knowledge MCP Server
|
||||
*
|
||||
*
|
||||
* Exposes knowledge database via Model Context Protocol for use by AI agents.
|
||||
*
|
||||
*
|
||||
* Tools provided:
|
||||
* - store_knowledge: Store a new knowledge entry
|
||||
* - search_knowledge: Semantic search using vector embeddings
|
||||
@@ -13,6 +13,15 @@
|
||||
* - delete_knowledge: Delete an entry
|
||||
* - list_knowledge: List entries with filters
|
||||
* - 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 { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
@@ -27,6 +36,19 @@ import {
|
||||
listKnowledge,
|
||||
getKnowledgeStats,
|
||||
} 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
|
||||
const server = new McpServer({
|
||||
@@ -73,13 +95,13 @@ server.tool(
|
||||
async ({ query, limit }) => {
|
||||
try {
|
||||
const results = await searchKnowledge(query, { limit, minScore: 0.3 });
|
||||
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No similar knowledge entries found." }],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
let output = `## Found ${results.length} similar entries:\n\n`;
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const r = results[i];
|
||||
@@ -91,7 +113,7 @@ server.tool(
|
||||
}
|
||||
output += `\n${r.content_preview}...\n\n---\n\n`;
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: output }],
|
||||
};
|
||||
@@ -99,9 +121,9 @@ server.tool(
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
if (message.includes("not initialized")) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: "Vector database not initialized. Use search_knowledge_text for keyword search, or add some entries first."
|
||||
content: [{
|
||||
type: "text",
|
||||
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 }) => {
|
||||
const results = searchKnowledgeText(query, limit);
|
||||
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
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(", ")}` : ""}`
|
||||
).join("\n\n---\n\n");
|
||||
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Found ${results.length} result(s) for "${query}":\n\n${formatted}`
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Found ${results.length} result(s) for "${query}":\n\n${formatted}`
|
||||
}],
|
||||
};
|
||||
}
|
||||
@@ -151,13 +173,13 @@ server.tool(
|
||||
},
|
||||
async ({ id }) => {
|
||||
const entry = getKnowledge(id);
|
||||
|
||||
|
||||
if (!entry) {
|
||||
return {
|
||||
content: [{ type: "text", text: `No entry found with ID: ${id}` }],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
@@ -184,25 +206,25 @@ server.tool(
|
||||
if (content !== undefined) updates.content = content;
|
||||
if (source !== undefined) updates.source = source;
|
||||
if (tags !== undefined) updates.tags = tags;
|
||||
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No updates provided" }],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const result = await updateKnowledge(id, updates);
|
||||
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
content: [{ type: "text", text: `No entry found with ID: ${id}` }],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `✅ Updated entry #${id}\n${result.vectorized ? "📊 Re-indexed for semantic search" : "⚠️ Database updated (vector re-indexing failed)"}`
|
||||
content: [{
|
||||
type: "text",
|
||||
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 }) => {
|
||||
const success = await deleteKnowledge(id);
|
||||
|
||||
|
||||
if (!success) {
|
||||
return {
|
||||
content: [{ type: "text", text: `No entry found with ID: ${id}` }],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `✅ Deleted entry #${id}` }],
|
||||
};
|
||||
@@ -241,21 +263,21 @@ server.tool(
|
||||
},
|
||||
async ({ limit, offset, tags }) => {
|
||||
const entries = listKnowledge({ limit, offset, tags });
|
||||
|
||||
|
||||
if (entries.length === 0) {
|
||||
return {
|
||||
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(", ")}]` : ""}`
|
||||
).join("\n");
|
||||
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `📚 Knowledge Entries (${entries.length}):\n\n${formatted}`
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `📚 Knowledge Entries (${entries.length}):\n\n${formatted}`
|
||||
}],
|
||||
};
|
||||
}
|
||||
@@ -268,13 +290,13 @@ server.tool(
|
||||
{},
|
||||
async () => {
|
||||
const stats = await getKnowledgeStats();
|
||||
|
||||
|
||||
const tagList = Object.entries(stats.database.tagCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([tag, count]) => ` ${tag}: ${count}`)
|
||||
.join("\n");
|
||||
|
||||
|
||||
return {
|
||||
content: [{
|
||||
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
|
||||
async function main() {
|
||||
// Clean up any timed-out sessions on startup
|
||||
closeTimedOutSessions();
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error("Personal Knowledge MCP Server running on stdio");
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user