Replace search skill with mem-search (#91)
* feat: add mem-search skill with progressive disclosure architecture Add comprehensive mem-search skill for accessing claude-mem's persistent cross-session memory database. Implements progressive disclosure workflow and token-efficient search patterns. Features: - 12 search operations (observations, sessions, prompts, by-type, by-concept, by-file, timelines, etc.) - Progressive disclosure principles to minimize token usage - Anti-patterns documentation to guide LLM behavior - HTTP API integration for all search functionality - Common workflows with composition examples Structure: - SKILL.md: Entry point with temporal trigger patterns - principles/: Progressive disclosure + anti-patterns - operations/: 12 search operation files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * docs: add CHANGELOG entry for mem-search skill Document mem-search skill addition in Unreleased section with: - 100% effectiveness compliance metrics - Comparison to previous search skill implementation - Progressive disclosure architecture details - Reference to audit report documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * docs: add mem-search skill audit report Add comprehensive audit report validating mem-search skill against Anthropic's official skill-creator documentation. Report includes: - Effectiveness metrics comparison (search vs mem-search) - Critical issues analysis for production readiness - Compliance validation across 6 key dimensions - Reference implementation guidance Result: mem-search achieves 100% compliance vs search's 67% 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: Add comprehensive search architecture analysis document - Document current state of dual search architectures (HTTP API and MCP) - Analyze HTTP endpoints and MCP search server architectures - Identify DRY violations across search implementations - Evaluate the use of curl as the optimal approach for search - Provide architectural recommendations for immediate and long-term improvements - Outline action plan for cleanup, feature parity, DRY refactoring * refactor: Remove deprecated search skill documentation and operations * refactor: Reorganize documentation into public and context directories Changes: - Created docs/public/ for Mintlify documentation (.mdx files) - Created docs/context/ for internal planning and implementation docs - Moved all .mdx files and assets to docs/public/ - Moved all internal .md files to docs/context/ - Added CLAUDE.md to both directories explaining their purpose - Updated docs.json paths to work with new structure Benefits: - Clear separation between user-facing and internal documentation - Easier to maintain Mintlify docs in dedicated directory - Internal context files organized separately 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Enhance session management and continuity in hooks - Updated new-hook.ts to clarify session_id threading and idempotent session creation. - Modified prompts.ts to require claudeSessionId for continuation prompts, ensuring session context is maintained. - Improved SessionStore.ts documentation on createSDKSession to emphasize idempotent behavior and session connection. - Refined SDKAgent.ts to detail continuation prompt logic and its reliance on session.claudeSessionId for unified session handling. --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Alex Newman <thedotmack@gmail.com>
This commit is contained in:
+35
-3
@@ -1,6 +1,36 @@
|
||||
/**
|
||||
* New Hook - UserPromptSubmit
|
||||
* Consolidated entry point + logic
|
||||
*
|
||||
* DUAL PURPOSE HOOK: Handles BOTH session initialization AND continuation
|
||||
* ==========================================================================
|
||||
*
|
||||
* CRITICAL ARCHITECTURE FACTS (NEVER FORGET):
|
||||
*
|
||||
* 1. SESSION ID THREADING - The Single Source of Truth
|
||||
* - Claude Code assigns ONE session_id per conversation
|
||||
* - ALL hooks in that conversation receive the SAME session_id
|
||||
* - We ALWAYS use this session_id - NEVER generate our own
|
||||
* - This is how NEW hook, SAVE hook, and SUMMARY hook stay connected
|
||||
*
|
||||
* 2. NO EXISTENCE CHECKS NEEDED
|
||||
* - createSDKSession is idempotent (INSERT OR IGNORE)
|
||||
* - Prompt #1: Creates new database row, returns new ID
|
||||
* - Prompt #2+: Row exists, returns existing ID
|
||||
* - We NEVER need to check "does session exist?" - just use the session_id
|
||||
*
|
||||
* 3. CONTINUATION LOGIC LOCATION
|
||||
* - This hook does NOT contain continuation prompt logic
|
||||
* - That lives in SDKAgent.ts (lines 125-127)
|
||||
* - SDKAgent checks promptNumber to choose init vs continuation prompt
|
||||
* - BOTH prompts receive the SAME session_id from this hook
|
||||
*
|
||||
* 4. UNIFIED WITH SAVE HOOK
|
||||
* - SAVE hook uses: db.createSDKSession(session_id, '', '')
|
||||
* - NEW hook uses: db.createSDKSession(session_id, project, prompt)
|
||||
* - Both use session_id from hook context - this keeps everything connected
|
||||
*
|
||||
* This is KISS in action: Use the session_id we're given, trust idempotent
|
||||
* database operations, and let SDKAgent handle init vs continuation logic.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
@@ -32,7 +62,9 @@ async function newHook(input?: UserPromptSubmitInput): Promise<void> {
|
||||
|
||||
const db = new SessionStore();
|
||||
|
||||
// Save session_id for indexing
|
||||
// CRITICAL: Use session_id from hook as THE source of truth
|
||||
// createSDKSession is idempotent - creates new or returns existing
|
||||
// This is how ALL hooks stay connected to the same session
|
||||
const sessionDbId = db.createSDKSession(session_id, project, prompt);
|
||||
const promptNumber = db.incrementPromptCounter(sessionDbId);
|
||||
|
||||
@@ -50,7 +82,7 @@ async function newHook(input?: UserPromptSubmitInput): Promise<void> {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/sessions/${sessionDbId}/init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ project, userPrompt: prompt }),
|
||||
body: JSON.stringify({ project, userPrompt: prompt, promptNumber }),
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
|
||||
+20
-2
@@ -184,9 +184,27 @@ IMPORTANT! DO NOT do any work other than generate the PROGRESS SUMMARY - and re
|
||||
|
||||
/**
|
||||
* Build prompt for continuation of existing session
|
||||
*
|
||||
* CRITICAL: Why claudeSessionId Parameter is Required
|
||||
* ====================================================
|
||||
* This function receives claudeSessionId from SDKAgent.ts, which comes from:
|
||||
* - SessionManager.initializeSession (fetched from database)
|
||||
* - SessionStore.createSDKSession (stored by new-hook.ts)
|
||||
* - new-hook.ts receives it from Claude Code's hook context
|
||||
*
|
||||
* The claudeSessionId is the SAME session_id used by:
|
||||
* - NEW hook (to create/fetch session)
|
||||
* - SAVE hook (to store observations)
|
||||
* - This continuation prompt (to maintain session context)
|
||||
*
|
||||
* This is how everything stays connected - ONE session_id threading through
|
||||
* all hooks and prompts in the same conversation.
|
||||
*
|
||||
* Called when: promptNumber > 1 (see SDKAgent.ts line 150)
|
||||
* First prompt: Uses buildInitPrompt instead (promptNumber === 1)
|
||||
*/
|
||||
export function buildContinuationPrompt(userPrompt: string, promptNumber: number): string {
|
||||
return `This is the next prompt from the user for the session you're observing.
|
||||
export function buildContinuationPrompt(userPrompt: string, promptNumber: number, claudeSessionId: string): string {
|
||||
return `This is continuation prompt #${promptNumber} for session ${claudeSessionId} that you're observing.
|
||||
|
||||
CRITICAL: Record what was LEARNED/BUILT/FIXED/DEPLOYED/CONFIGURED, not what you (the observer) are doing.
|
||||
|
||||
|
||||
@@ -934,14 +934,37 @@ export class SessionStore {
|
||||
|
||||
/**
|
||||
* Create a new SDK session (idempotent - returns existing session ID if already exists)
|
||||
* Sets both claude_session_id and sdk_session_id to the same value
|
||||
*
|
||||
* CRITICAL ARCHITECTURE: Session ID Threading
|
||||
* ============================================
|
||||
* This function is the KEY to how claude-mem stays unified across hooks:
|
||||
*
|
||||
* - NEW hook calls: createSDKSession(session_id, project, prompt)
|
||||
* - SAVE hook calls: createSDKSession(session_id, '', '')
|
||||
* - Both use the SAME session_id from Claude Code's hook context
|
||||
*
|
||||
* IDEMPOTENT BEHAVIOR (INSERT OR IGNORE):
|
||||
* - Prompt #1: session_id not in database → INSERT creates new row
|
||||
* - Prompt #2+: session_id exists → INSERT ignored, fetch existing ID
|
||||
* - Result: Same database ID returned for all prompts in conversation
|
||||
*
|
||||
* WHY THIS MATTERS:
|
||||
* - NO "does session exist?" checks needed anywhere
|
||||
* - NO risk of creating duplicate sessions
|
||||
* - ALL hooks automatically connected via session_id
|
||||
* - SAVE hook observations go to correct session (same session_id)
|
||||
* - SDKAgent continuation prompt has correct context (same session_id)
|
||||
*
|
||||
* This is KISS in action: Trust the database UNIQUE constraint and
|
||||
* INSERT OR IGNORE to handle both creation and lookup elegantly.
|
||||
*/
|
||||
createSDKSession(claudeSessionId: string, project: string, userPrompt: string): number {
|
||||
const now = new Date();
|
||||
const nowEpoch = now.getTime();
|
||||
|
||||
// Try to insert - will be ignored if session already exists
|
||||
// claude_session_id and sdk_session_id are the same value
|
||||
// CRITICAL: INSERT OR IGNORE makes this idempotent
|
||||
// First call (prompt #1): Creates new row
|
||||
// Subsequent calls (prompt #2+): Ignored, returns existing ID
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT OR IGNORE INTO sdk_sessions
|
||||
(claude_session_id, sdk_session_id, project, user_prompt, started_at, started_at_epoch, status)
|
||||
|
||||
+73
-327
@@ -19,6 +19,8 @@ import { homedir } from 'os';
|
||||
import { getPackageRoot } from '../shared/paths.js';
|
||||
import { getWorkerPort } from '../shared/worker-utils.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
|
||||
// Import composed services
|
||||
import { DatabaseManager } from './worker/DatabaseManager.js';
|
||||
@@ -32,6 +34,7 @@ export class WorkerService {
|
||||
private app: express.Application;
|
||||
private server: http.Server | null = null;
|
||||
private startTime: number = Date.now();
|
||||
private mcpClient: Client;
|
||||
|
||||
// Composed services
|
||||
private dbManager: DatabaseManager;
|
||||
@@ -55,6 +58,11 @@ export class WorkerService {
|
||||
this.paginationHelper = new PaginationHelper(this.dbManager);
|
||||
this.settingsManager = new SettingsManager(this.dbManager);
|
||||
|
||||
this.mcpClient = new Client({
|
||||
name: 'worker-search-proxy',
|
||||
version: '1.0.0'
|
||||
}, { capabilities: {} });
|
||||
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
}
|
||||
@@ -177,6 +185,17 @@ export class WorkerService {
|
||||
// Initialize database (once, stays open)
|
||||
await this.dbManager.initialize();
|
||||
|
||||
// Connect to MCP search server
|
||||
const searchServerPath = path.join(__dirname, '..', '..', 'plugin', 'scripts', 'search-server.mjs');
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: [searchServerPath],
|
||||
env: process.env
|
||||
});
|
||||
|
||||
await this.mcpClient.connect(transport);
|
||||
logger.success('WORKER', 'Connected to MCP search server');
|
||||
|
||||
// Start HTTP server
|
||||
const port = getWorkerPort();
|
||||
this.server = await new Promise<http.Server>((resolve, reject) => {
|
||||
@@ -828,37 +847,15 @@ export class WorkerService {
|
||||
* Search observations
|
||||
* GET /api/search/observations?query=...&format=index&limit=20&project=...
|
||||
*/
|
||||
private handleSearchObservations(req: Request, res: Response): void {
|
||||
private async handleSearchObservations(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const query = req.query.query as string;
|
||||
const format = (req.query.format as string) || 'full';
|
||||
const limit = parseInt(req.query.limit as string, 10) || 20;
|
||||
const project = req.query.project as string | undefined;
|
||||
|
||||
if (!query) {
|
||||
res.status(400).json({ error: 'Missing required parameter: query' });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionSearch = this.dbManager.getSessionSearch();
|
||||
const results = sessionSearch.searchObservations(query, { limit, project });
|
||||
|
||||
res.json({
|
||||
query,
|
||||
count: results.length,
|
||||
format,
|
||||
results: format === 'index' ? results.map(r => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
title: r.title,
|
||||
subtitle: r.subtitle,
|
||||
created_at_epoch: r.created_at_epoch,
|
||||
project: r.project,
|
||||
score: r.score
|
||||
})) : results
|
||||
const result = await this.mcpClient.callTool({
|
||||
name: 'search_observations',
|
||||
arguments: req.query
|
||||
});
|
||||
res.json(result.content);
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Search observations failed', {}, error as Error);
|
||||
logger.failure('WORKER', 'Search failed', {}, error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -867,35 +864,15 @@ export class WorkerService {
|
||||
* Search session summaries
|
||||
* GET /api/search/sessions?query=...&format=index&limit=20
|
||||
*/
|
||||
private handleSearchSessions(req: Request, res: Response): void {
|
||||
private async handleSearchSessions(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const query = req.query.query as string;
|
||||
const format = (req.query.format as string) || 'full';
|
||||
const limit = parseInt(req.query.limit as string, 10) || 20;
|
||||
|
||||
if (!query) {
|
||||
res.status(400).json({ error: 'Missing required parameter: query' });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionSearch = this.dbManager.getSessionSearch();
|
||||
const results = sessionSearch.searchSessions(query, { limit });
|
||||
|
||||
res.json({
|
||||
query,
|
||||
count: results.length,
|
||||
format,
|
||||
results: format === 'index' ? results.map(r => ({
|
||||
id: r.id,
|
||||
request: r.request,
|
||||
completed: r.completed,
|
||||
created_at_epoch: r.created_at_epoch,
|
||||
project: r.project,
|
||||
score: r.score
|
||||
})) : results
|
||||
const result = await this.mcpClient.callTool({
|
||||
name: 'search_sessions',
|
||||
arguments: req.query
|
||||
});
|
||||
res.json(result.content);
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Search sessions failed', {}, error as Error);
|
||||
logger.failure('WORKER', 'Search failed', {}, error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -904,36 +881,15 @@ export class WorkerService {
|
||||
* Search user prompts
|
||||
* GET /api/search/prompts?query=...&format=index&limit=20
|
||||
*/
|
||||
private handleSearchPrompts(req: Request, res: Response): void {
|
||||
private async handleSearchPrompts(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const query = req.query.query as string;
|
||||
const format = (req.query.format as string) || 'full';
|
||||
const limit = parseInt(req.query.limit as string, 10) || 20;
|
||||
const project = req.query.project as string | undefined;
|
||||
|
||||
if (!query) {
|
||||
res.status(400).json({ error: 'Missing required parameter: query' });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionSearch = this.dbManager.getSessionSearch();
|
||||
const results = sessionSearch.searchUserPrompts(query, { limit, project });
|
||||
|
||||
res.json({
|
||||
query,
|
||||
count: results.length,
|
||||
format,
|
||||
results: format === 'index' ? results.map(r => ({
|
||||
id: r.id,
|
||||
claude_session_id: r.claude_session_id,
|
||||
prompt_number: r.prompt_number,
|
||||
prompt_text: r.prompt_text,
|
||||
created_at_epoch: r.created_at_epoch,
|
||||
score: r.score
|
||||
})) : results
|
||||
const result = await this.mcpClient.callTool({
|
||||
name: 'search_user_prompts',
|
||||
arguments: req.query
|
||||
});
|
||||
res.json(result.content);
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Search prompts failed', {}, error as Error);
|
||||
logger.failure('WORKER', 'Search failed', {}, error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -942,37 +898,15 @@ export class WorkerService {
|
||||
* Search observations by concept
|
||||
* GET /api/search/by-concept?concept=discovery&format=index&limit=5
|
||||
*/
|
||||
private handleSearchByConcept(req: Request, res: Response): void {
|
||||
private async handleSearchByConcept(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const concept = req.query.concept as string;
|
||||
const format = (req.query.format as string) || 'full';
|
||||
const limit = parseInt(req.query.limit as string, 10) || 10;
|
||||
const project = req.query.project as string | undefined;
|
||||
|
||||
if (!concept) {
|
||||
res.status(400).json({ error: 'Missing required parameter: concept' });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionSearch = this.dbManager.getSessionSearch();
|
||||
const results = sessionSearch.findByConcept(concept, { limit, project });
|
||||
|
||||
res.json({
|
||||
concept,
|
||||
count: results.length,
|
||||
format,
|
||||
results: format === 'index' ? results.map(r => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
title: r.title,
|
||||
subtitle: r.subtitle,
|
||||
created_at_epoch: r.created_at_epoch,
|
||||
project: r.project,
|
||||
concepts: r.concepts
|
||||
})) : results
|
||||
const result = await this.mcpClient.callTool({
|
||||
name: 'find_by_concept',
|
||||
arguments: req.query
|
||||
});
|
||||
res.json(result.content);
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Search by concept failed', {}, error as Error);
|
||||
logger.failure('WORKER', 'Search failed', {}, error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -981,45 +915,15 @@ export class WorkerService {
|
||||
* Search by file path
|
||||
* GET /api/search/by-file?filePath=...&format=index&limit=10
|
||||
*/
|
||||
private handleSearchByFile(req: Request, res: Response): void {
|
||||
private async handleSearchByFile(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const filePath = req.query.filePath as string;
|
||||
const format = (req.query.format as string) || 'full';
|
||||
const limit = parseInt(req.query.limit as string, 10) || 10;
|
||||
const project = req.query.project as string | undefined;
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ error: 'Missing required parameter: filePath' });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionSearch = this.dbManager.getSessionSearch();
|
||||
const results = sessionSearch.findByFile(filePath, { limit, project });
|
||||
|
||||
res.json({
|
||||
filePath,
|
||||
count: results.observations.length + results.sessions.length,
|
||||
format,
|
||||
results: {
|
||||
observations: format === 'index' ? results.observations.map(r => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
title: r.title,
|
||||
subtitle: r.subtitle,
|
||||
created_at_epoch: r.created_at_epoch,
|
||||
project: r.project
|
||||
})) : results.observations,
|
||||
sessions: format === 'index' ? results.sessions.map(r => ({
|
||||
id: r.id,
|
||||
request: r.request,
|
||||
completed: r.completed,
|
||||
created_at_epoch: r.created_at_epoch,
|
||||
project: r.project
|
||||
})) : results.sessions
|
||||
}
|
||||
const result = await this.mcpClient.callTool({
|
||||
name: 'find_by_file',
|
||||
arguments: req.query
|
||||
});
|
||||
res.json(result.content);
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Search by file failed', {}, error as Error);
|
||||
logger.failure('WORKER', 'Search failed', {}, error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -1028,36 +932,15 @@ export class WorkerService {
|
||||
* Search observations by type
|
||||
* GET /api/search/by-type?type=bugfix&format=index&limit=10
|
||||
*/
|
||||
private handleSearchByType(req: Request, res: Response): void {
|
||||
private async handleSearchByType(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const type = req.query.type as string;
|
||||
const format = (req.query.format as string) || 'full';
|
||||
const limit = parseInt(req.query.limit as string, 10) || 10;
|
||||
const project = req.query.project as string | undefined;
|
||||
|
||||
if (!type) {
|
||||
res.status(400).json({ error: 'Missing required parameter: type' });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionSearch = this.dbManager.getSessionSearch();
|
||||
const results = sessionSearch.findByType(type as any, { limit, project });
|
||||
|
||||
res.json({
|
||||
type,
|
||||
count: results.length,
|
||||
format,
|
||||
results: format === 'index' ? results.map(r => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
title: r.title,
|
||||
subtitle: r.subtitle,
|
||||
created_at_epoch: r.created_at_epoch,
|
||||
project: r.project
|
||||
})) : results
|
||||
const result = await this.mcpClient.callTool({
|
||||
name: 'find_by_type',
|
||||
arguments: req.query
|
||||
});
|
||||
res.json(result.content);
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Search by type failed', {}, error as Error);
|
||||
logger.failure('WORKER', 'Search failed', {}, error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -1066,49 +949,15 @@ export class WorkerService {
|
||||
* Get recent context (summaries and observations for a project)
|
||||
* GET /api/context/recent?project=...&limit=3
|
||||
*/
|
||||
private handleGetRecentContext(req: Request, res: Response): void {
|
||||
private async handleGetRecentContext(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const project = (req.query.project as string) || path.basename(process.cwd());
|
||||
const limit = parseInt(req.query.limit as string, 10) || 3;
|
||||
|
||||
const sessionStore = this.dbManager.getSessionStore();
|
||||
const sessions = sessionStore.getRecentSessionsWithStatus(project, limit);
|
||||
|
||||
const contextData = sessions.map(session => {
|
||||
const summary = session.has_summary && session.sdk_session_id
|
||||
? sessionStore.getSummaryForSession(session.sdk_session_id)
|
||||
: null;
|
||||
|
||||
const observations = session.sdk_session_id
|
||||
? sessionStore.getObservationsForSession(session.sdk_session_id)
|
||||
: [];
|
||||
|
||||
return {
|
||||
session_id: session.id,
|
||||
sdk_session_id: session.sdk_session_id,
|
||||
project: session.project,
|
||||
status: session.status,
|
||||
has_summary: session.has_summary,
|
||||
summary,
|
||||
observations: observations.map(o => ({
|
||||
id: o.id,
|
||||
type: o.type,
|
||||
title: o.title,
|
||||
subtitle: o.subtitle,
|
||||
created_at_epoch: o.created_at_epoch
|
||||
})),
|
||||
created_at_epoch: session.started_at_epoch
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
project,
|
||||
limit,
|
||||
count: contextData.length,
|
||||
sessions: contextData
|
||||
const result = await this.mcpClient.callTool({
|
||||
name: 'get_recent_context',
|
||||
arguments: req.query
|
||||
});
|
||||
res.json(result.content);
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Get recent context failed', {}, error as Error);
|
||||
logger.failure('WORKER', 'Search failed', {}, error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -1117,59 +966,15 @@ export class WorkerService {
|
||||
* Get context timeline around an anchor point
|
||||
* GET /api/context/timeline?anchor=123&depth_before=10&depth_after=10&project=...
|
||||
*/
|
||||
private handleGetContextTimeline(req: Request, res: Response): void {
|
||||
private async handleGetContextTimeline(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const anchor = req.query.anchor as string;
|
||||
const depthBefore = parseInt(req.query.depth_before as string, 10) || 10;
|
||||
const depthAfter = parseInt(req.query.depth_after as string, 10) || 10;
|
||||
const project = req.query.project as string | undefined;
|
||||
|
||||
if (!anchor) {
|
||||
res.status(400).json({ error: 'Missing required parameter: anchor' });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionStore = this.dbManager.getSessionStore();
|
||||
let timeline;
|
||||
|
||||
// Check if anchor is a number (observation ID)
|
||||
if (/^\d+$/.test(anchor)) {
|
||||
const obsId = parseInt(anchor, 10);
|
||||
const obs = sessionStore.getObservationById(obsId);
|
||||
if (!obs) {
|
||||
res.status(404).json({ error: `Observation #${obsId} not found` });
|
||||
return;
|
||||
}
|
||||
timeline = sessionStore.getTimelineAroundObservation(obsId, obs.created_at_epoch, depthBefore, depthAfter, project);
|
||||
} else if (anchor.startsWith('S') || anchor.startsWith('#S')) {
|
||||
// Session ID
|
||||
const sessionId = anchor.replace(/^#?S/, '');
|
||||
const sessionNum = parseInt(sessionId, 10);
|
||||
const sessions = sessionStore.getSessionSummariesByIds([sessionNum]);
|
||||
if (sessions.length === 0) {
|
||||
res.status(404).json({ error: `Session #${sessionNum} not found` });
|
||||
return;
|
||||
}
|
||||
timeline = sessionStore.getTimelineAroundTimestamp(sessions[0].created_at_epoch, depthBefore, depthAfter, project);
|
||||
} else {
|
||||
// ISO timestamp
|
||||
const date = new Date(anchor);
|
||||
if (isNaN(date.getTime())) {
|
||||
res.status(400).json({ error: `Invalid timestamp: ${anchor}` });
|
||||
return;
|
||||
}
|
||||
timeline = sessionStore.getTimelineAroundTimestamp(date.getTime(), depthBefore, depthAfter, project);
|
||||
}
|
||||
|
||||
res.json({
|
||||
anchor,
|
||||
depth_before: depthBefore,
|
||||
depth_after: depthAfter,
|
||||
project,
|
||||
timeline
|
||||
const result = await this.mcpClient.callTool({
|
||||
name: 'get_context_timeline',
|
||||
arguments: req.query
|
||||
});
|
||||
res.json(result.content);
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Get context timeline failed', {}, error as Error);
|
||||
logger.failure('WORKER', 'Search failed', {}, error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -1178,74 +983,15 @@ export class WorkerService {
|
||||
* Get timeline by query (search first, then get timeline around best match)
|
||||
* GET /api/timeline/by-query?query=...&mode=auto&depth_before=10&depth_after=10
|
||||
*/
|
||||
private handleGetTimelineByQuery(req: Request, res: Response): void {
|
||||
private async handleGetTimelineByQuery(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const query = req.query.query as string;
|
||||
const mode = (req.query.mode as string) || 'auto';
|
||||
const depthBefore = parseInt(req.query.depth_before as string, 10) || 10;
|
||||
const depthAfter = parseInt(req.query.depth_after as string, 10) || 10;
|
||||
const project = req.query.project as string | undefined;
|
||||
|
||||
if (!query) {
|
||||
res.status(400).json({ error: 'Missing required parameter: query' });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionSearch = this.dbManager.getSessionSearch();
|
||||
const sessionStore = this.dbManager.getSessionStore();
|
||||
|
||||
// Search based on mode
|
||||
let bestMatch: any = null;
|
||||
let searchResults: any = null;
|
||||
|
||||
if (mode === 'observations' || mode === 'auto') {
|
||||
const obsResults = sessionSearch.searchObservations(query, { limit: 1, project });
|
||||
if (obsResults.length > 0) {
|
||||
bestMatch = obsResults[0];
|
||||
searchResults = { type: 'observation', results: obsResults };
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestMatch && (mode === 'sessions' || mode === 'auto')) {
|
||||
const sessionResults = sessionSearch.searchSessions(query, { limit: 1 });
|
||||
if (sessionResults.length > 0) {
|
||||
bestMatch = sessionResults[0];
|
||||
searchResults = { type: 'session', results: sessionResults };
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestMatch) {
|
||||
res.json({
|
||||
query,
|
||||
mode,
|
||||
match: null,
|
||||
timeline: null,
|
||||
message: 'No matches found for query'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get timeline around best match
|
||||
const timeline = searchResults.type === 'observation'
|
||||
? sessionStore.getTimelineAroundObservation(bestMatch.id, bestMatch.created_at_epoch, depthBefore, depthAfter, project)
|
||||
: sessionStore.getTimelineAroundTimestamp(bestMatch.created_at_epoch, depthBefore, depthAfter, project);
|
||||
|
||||
res.json({
|
||||
query,
|
||||
mode,
|
||||
match: {
|
||||
type: searchResults.type,
|
||||
id: bestMatch.id,
|
||||
title: bestMatch.title || bestMatch.request,
|
||||
score: bestMatch.score,
|
||||
created_at_epoch: bestMatch.created_at_epoch
|
||||
},
|
||||
depth_before: depthBefore,
|
||||
depth_after: depthAfter,
|
||||
timeline
|
||||
const result = await this.mcpClient.callTool({
|
||||
name: 'get_timeline_by_query',
|
||||
arguments: req.query
|
||||
});
|
||||
res.json(result.content);
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Get timeline by query failed', {}, error as Error);
|
||||
logger.failure('WORKER', 'Search failed', {}, error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,16 +115,39 @@ export class SDKAgent {
|
||||
|
||||
/**
|
||||
* Create event-driven message generator (yields messages from SessionManager)
|
||||
*
|
||||
* CRITICAL: CONTINUATION PROMPT LOGIC
|
||||
* ====================================
|
||||
* This is where NEW hook's dual-purpose nature comes together:
|
||||
*
|
||||
* - Prompt #1 (lastPromptNumber === 1): buildInitPrompt
|
||||
* - Full initialization prompt with instructions
|
||||
* - Sets up the SDK agent's context
|
||||
*
|
||||
* - Prompt #2+ (lastPromptNumber > 1): buildContinuationPrompt
|
||||
* - Continuation prompt for same session
|
||||
* - Includes session context and prompt number
|
||||
*
|
||||
* BOTH prompts receive session.claudeSessionId:
|
||||
* - This comes from the hook's session_id (see new-hook.ts)
|
||||
* - Same session_id used by SAVE hook to store observations
|
||||
* - This is how everything stays connected in one unified session
|
||||
*
|
||||
* NO SESSION EXISTENCE CHECKS NEEDED:
|
||||
* - SessionManager.initializeSession already fetched this from database
|
||||
* - Database row was created by new-hook's createSDKSession call
|
||||
* - We just use the session_id we're given - simple and reliable
|
||||
*/
|
||||
private async *createMessageGenerator(session: ActiveSession): AsyncIterableIterator<SDKUserMessage> {
|
||||
// Yield initial user prompt with context (or continuation if prompt #2+)
|
||||
// CRITICAL: Both paths use session.claudeSessionId from the hook
|
||||
yield {
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: session.lastPromptNumber === 1
|
||||
? buildInitPrompt(session.project, session.claudeSessionId, session.userPrompt)
|
||||
: buildContinuationPrompt(session.userPrompt, session.lastPromptNumber)
|
||||
: buildContinuationPrompt(session.userPrompt, session.lastPromptNumber, session.claudeSessionId)
|
||||
},
|
||||
session_id: session.claudeSessionId,
|
||||
parent_tool_use_id: null,
|
||||
|
||||
Reference in New Issue
Block a user