Skip to main content
Glama
client.ts64.4 kB
import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import type { Config } from './config.js'; import { request } from './http.js'; import { readFilesFromDirectory, readAllFilesInBatches } from './files.js'; import { resolveWorkspace, readLocalConfig, writeLocalConfig, addGlobalMapping, type WorkspaceConfig } from './workspace-config.js'; import { globalCache, CacheKeys, CacheTTL } from './cache.js'; const uuidSchema = z.string().uuid(); export class ContextStreamClient { constructor(private config: Config) {} private withDefaults<T extends { workspace_id?: string; project_id?: string }>( input: T ): T { const { defaultWorkspaceId, defaultProjectId } = this.config; return { ...input, workspace_id: input.workspace_id || defaultWorkspaceId, project_id: input.project_id || defaultProjectId, } as T; } // Auth me() { return request(this.config, '/auth/me'); } // Workspaces & Projects listWorkspaces(params?: { page?: number; page_size?: number }) { const query = new URLSearchParams(); if (params?.page) query.set('page', String(params.page)); if (params?.page_size) query.set('page_size', String(params.page_size)); const suffix = query.toString() ? `?${query.toString()}` : ''; return request(this.config, `/workspaces${suffix}`); } createWorkspace(input: { name: string; description?: string; visibility?: string }) { return request(this.config, '/workspaces', { body: input }); } updateWorkspace(workspaceId: string, input: { name?: string; description?: string; visibility?: string }) { uuidSchema.parse(workspaceId); return request(this.config, `/workspaces/${workspaceId}`, { method: 'PUT', body: input }); } deleteWorkspace(workspaceId: string) { uuidSchema.parse(workspaceId); return request(this.config, `/workspaces/${workspaceId}`, { method: 'DELETE' }); } listProjects(params?: { workspace_id?: string; page?: number; page_size?: number }) { const withDefaults = this.withDefaults(params || {}); const query = new URLSearchParams(); if (withDefaults.workspace_id) query.set('workspace_id', withDefaults.workspace_id); if (params?.page) query.set('page', String(params.page)); if (params?.page_size) query.set('page_size', String(params.page_size)); const suffix = query.toString() ? `?${query.toString()}` : ''; return request(this.config, `/projects${suffix}`); } createProject(input: { name: string; description?: string; workspace_id?: string }) { const payload = this.withDefaults(input); return request(this.config, '/projects', { body: payload }); } updateProject(projectId: string, input: { name?: string; description?: string }) { uuidSchema.parse(projectId); return request(this.config, `/projects/${projectId}`, { method: 'PUT', body: input }); } deleteProject(projectId: string) { uuidSchema.parse(projectId); return request(this.config, `/projects/${projectId}`, { method: 'DELETE' }); } indexProject(projectId: string) { uuidSchema.parse(projectId); return request(this.config, `/projects/${projectId}/index`, { body: {} }); } // Search - each method adds required search_type and filters fields searchSemantic(body: { query: string; workspace_id?: string; project_id?: string; limit?: number }) { return request(this.config, '/search/semantic', { body: { ...this.withDefaults(body), search_type: 'semantic', filters: body.workspace_id ? {} : { file_types: [], languages: [], file_paths: [], exclude_paths: [], content_types: [], tags: [] } } }); } searchHybrid(body: { query: string; workspace_id?: string; project_id?: string; limit?: number }) { return request(this.config, '/search/hybrid', { body: { ...this.withDefaults(body), search_type: 'hybrid', filters: body.workspace_id ? {} : { file_types: [], languages: [], file_paths: [], exclude_paths: [], content_types: [], tags: [] } } }); } searchKeyword(body: { query: string; workspace_id?: string; project_id?: string; limit?: number }) { return request(this.config, '/search/keyword', { body: { ...this.withDefaults(body), search_type: 'keyword', filters: body.workspace_id ? {} : { file_types: [], languages: [], file_paths: [], exclude_paths: [], content_types: [], tags: [] } } }); } searchPattern(body: { query: string; workspace_id?: string; project_id?: string; limit?: number }) { return request(this.config, '/search/pattern', { body: { ...this.withDefaults(body), search_type: 'pattern', filters: body.workspace_id ? {} : { file_types: [], languages: [], file_paths: [], exclude_paths: [], content_types: [], tags: [] } } }); } // Memory / Knowledge createMemoryEvent(body: { workspace_id?: string; project_id?: string; event_type: string; title: string; content: string; metadata?: Record<string, unknown>; }) { const withDefaults = this.withDefaults(body); // Validate required fields if (!withDefaults.workspace_id) { throw new Error('workspace_id is required for creating memory events. Set defaultWorkspaceId in config or provide workspace_id.'); } // Ensure content is not empty if (!body.content || body.content.trim().length === 0) { throw new Error('content is required and cannot be empty'); } return request(this.config, '/memory/events', { body: withDefaults }); } bulkIngestEvents(body: { workspace_id?: string; project_id?: string; events: any[] }) { return request(this.config, '/memory/events/ingest', { body: this.withDefaults(body) }); } listMemoryEvents(params?: { workspace_id?: string; project_id?: string; limit?: number }) { const withDefaults = this.withDefaults(params || {}); if (!withDefaults.workspace_id) { throw new Error('workspace_id is required for listing memory events'); } const query = new URLSearchParams(); if (params?.limit) query.set('limit', String(params.limit)); if (withDefaults.project_id) query.set('project_id', withDefaults.project_id); const suffix = query.toString() ? `?${query.toString()}` : ''; return request(this.config, `/memory/events/workspace/${withDefaults.workspace_id}${suffix}`, { method: 'GET' }); } createKnowledgeNode(body: { workspace_id?: string; project_id?: string; node_type: string; title: string; content: string; relations?: Array<{ type: string; target_id: string }>; }) { return request(this.config, '/memory/nodes', { body: this.withDefaults(body) }); } listKnowledgeNodes(params?: { workspace_id?: string; project_id?: string; limit?: number }) { const withDefaults = this.withDefaults(params || {}); if (!withDefaults.workspace_id) { throw new Error('workspace_id is required for listing knowledge nodes'); } const query = new URLSearchParams(); if (params?.limit) query.set('limit', String(params.limit)); if (withDefaults.project_id) query.set('project_id', withDefaults.project_id); const suffix = query.toString() ? `?${query.toString()}` : ''; return request(this.config, `/memory/nodes/workspace/${withDefaults.workspace_id}${suffix}`, { method: 'GET' }); } memorySearch(body: { query: string; workspace_id?: string; project_id?: string; limit?: number }) { return request(this.config, '/memory/search', { body: this.withDefaults(body) }); } memoryDecisions(params?: { workspace_id?: string; project_id?: string; limit?: number }) { const query = new URLSearchParams(); const withDefaults = this.withDefaults(params || {}); if (withDefaults.workspace_id) query.set('workspace_id', withDefaults.workspace_id); if (withDefaults.project_id) query.set('project_id', withDefaults.project_id); if (params?.limit) query.set('limit', String(params.limit)); const suffix = query.toString() ? `?${query.toString()}` : ''; return request(this.config, `/memory/search/decisions${suffix}`, { method: 'GET' }); } // Graph graphRelated(body: { workspace_id?: string; project_id?: string; node_id: string; limit?: number }) { return request(this.config, '/graph/knowledge/related', { body: this.withDefaults(body) }); } graphPath(body: { workspace_id?: string; project_id?: string; source_id: string; target_id: string }) { return request(this.config, '/graph/knowledge/path', { body: this.withDefaults(body) }); } graphDecisions(body?: { workspace_id?: string; project_id?: string; limit?: number }) { return request(this.config, '/graph/knowledge/decisions', { body: this.withDefaults(body || {}) }); } graphDependencies(body: { target: { type: string; id: string }; max_depth?: number; include_transitive?: boolean }) { return request(this.config, '/graph/dependencies', { body }); } graphCallPath(body: { source: { type: string; id: string }; target: { type: string; id: string }; max_depth?: number }) { return request(this.config, '/graph/call-paths', { body }); } graphImpact(body: { target: { type: string; id: string }; max_depth?: number }) { return request(this.config, '/graph/impact-analysis', { body }); } // AI aiContext(body: { query: string; workspace_id?: string; project_id?: string; include_code?: boolean; include_docs?: boolean; include_memory?: boolean; limit?: number; }) { return request(this.config, '/ai/context', { body: this.withDefaults(body) }); } aiEmbeddings(body: { text: string }) { return request(this.config, '/ai/embeddings', { body }); } aiPlan(body: { description: string; project_id?: string; complexity?: string }) { return request(this.config, '/ai/plan/generate', { body: this.withDefaults(body) }); } aiTasks(body: { plan_id?: string; description?: string; project_id?: string; granularity?: string }) { return request(this.config, '/ai/tasks/generate', { body: this.withDefaults(body) }); } aiEnhancedContext(body: { query: string; workspace_id?: string; project_id?: string; include_code?: boolean; include_docs?: boolean; include_memory?: boolean; limit?: number; }) { return request(this.config, '/ai/context/enhanced', { body: this.withDefaults(body) }); } // Project extended operations (with caching) async getProject(projectId: string) { uuidSchema.parse(projectId); const cacheKey = CacheKeys.project(projectId); const cached = globalCache.get(cacheKey); if (cached) return cached; const result = await request(this.config, `/projects/${projectId}`, { method: 'GET' }); globalCache.set(cacheKey, result, CacheTTL.PROJECT); return result; } async projectOverview(projectId: string) { uuidSchema.parse(projectId); const cacheKey = `project_overview:${projectId}`; const cached = globalCache.get(cacheKey); if (cached) return cached; const result = await request(this.config, `/projects/${projectId}/overview`, { method: 'GET' }); globalCache.set(cacheKey, result, CacheTTL.PROJECT); return result; } projectStatistics(projectId: string) { uuidSchema.parse(projectId); return request(this.config, `/projects/${projectId}/statistics`, { method: 'GET' }); } projectFiles(projectId: string) { uuidSchema.parse(projectId); return request(this.config, `/projects/${projectId}/files`, { method: 'GET' }); } projectIndexStatus(projectId: string) { uuidSchema.parse(projectId); return request(this.config, `/projects/${projectId}/index/status`, { method: 'GET' }); } /** * Ingest files for indexing * This uploads files to the API for indexing */ ingestFiles(projectId: string, files: Array<{ path: string; content: string; language?: string }>) { uuidSchema.parse(projectId); return request(this.config, `/projects/${projectId}/files/ingest`, { body: { files }, }); } // Workspace extended operations (with caching) async getWorkspace(workspaceId: string) { uuidSchema.parse(workspaceId); const cacheKey = CacheKeys.workspace(workspaceId); const cached = globalCache.get(cacheKey); if (cached) return cached; const result = await request(this.config, `/workspaces/${workspaceId}`, { method: 'GET' }); globalCache.set(cacheKey, result, CacheTTL.WORKSPACE); return result; } async workspaceOverview(workspaceId: string) { uuidSchema.parse(workspaceId); const cacheKey = `workspace_overview:${workspaceId}`; const cached = globalCache.get(cacheKey); if (cached) return cached; const result = await request(this.config, `/workspaces/${workspaceId}/overview`, { method: 'GET' }); globalCache.set(cacheKey, result, CacheTTL.WORKSPACE); return result; } workspaceAnalytics(workspaceId: string) { uuidSchema.parse(workspaceId); return request(this.config, `/workspaces/${workspaceId}/analytics`, { method: 'GET' }); } workspaceContent(workspaceId: string) { uuidSchema.parse(workspaceId); return request(this.config, `/workspaces/${workspaceId}/content`, { method: 'GET' }); } // Memory extended operations getMemoryEvent(eventId: string) { uuidSchema.parse(eventId); return request(this.config, `/memory/events/${eventId}`, { method: 'GET' }); } updateMemoryEvent(eventId: string, body: { title?: string; content?: string; metadata?: Record<string, any> }) { uuidSchema.parse(eventId); return request(this.config, `/memory/events/${eventId}`, { method: 'PUT', body }); } deleteMemoryEvent(eventId: string) { uuidSchema.parse(eventId); return request(this.config, `/memory/events/${eventId}`, { method: 'DELETE' }); } distillMemoryEvent(eventId: string) { uuidSchema.parse(eventId); return request(this.config, `/memory/events/${eventId}/distill`, { body: {} }); } getKnowledgeNode(nodeId: string) { uuidSchema.parse(nodeId); return request(this.config, `/memory/nodes/${nodeId}`, { method: 'GET' }); } updateKnowledgeNode(nodeId: string, body: { title?: string; content?: string; relations?: Array<{ type: string; target_id: string }> }) { uuidSchema.parse(nodeId); return request(this.config, `/memory/nodes/${nodeId}`, { method: 'PUT', body }); } deleteKnowledgeNode(nodeId: string) { uuidSchema.parse(nodeId); return request(this.config, `/memory/nodes/${nodeId}`, { method: 'DELETE' }); } supersedeKnowledgeNode(nodeId: string, body: { new_content: string; reason?: string }) { uuidSchema.parse(nodeId); return request(this.config, `/memory/nodes/${nodeId}/supersede`, { body }); } memoryTimeline(workspaceId: string) { uuidSchema.parse(workspaceId); return request(this.config, `/memory/search/timeline/${workspaceId}`, { method: 'GET' }); } memorySummary(workspaceId: string) { uuidSchema.parse(workspaceId); return request(this.config, `/memory/search/summary/${workspaceId}`, { method: 'GET' }); } // Graph extended operations findCircularDependencies(projectId: string) { uuidSchema.parse(projectId); return request(this.config, `/graph/circular-dependencies/${projectId}`, { method: 'GET' }); } findUnusedCode(projectId: string) { uuidSchema.parse(projectId); return request(this.config, `/graph/unused-code/${projectId}`, { method: 'GET' }); } findContradictions(nodeId: string) { uuidSchema.parse(nodeId); return request(this.config, `/graph/knowledge/contradictions/${nodeId}`, { method: 'GET' }); } // Search suggestions searchSuggestions(body: { query: string; workspace_id?: string; project_id?: string }) { return request(this.config, '/search/suggest', { body: this.withDefaults(body) }); } // ============================================ // Session & Auto-Context Initialization // ============================================ /** * Initialize a conversation session and retrieve relevant context automatically. * This is the key tool for AI assistants to get context at the start of a conversation. * * Discovery chain: * 1. Check local .contextstream/config.json in repo root * 2. Check parent folder heuristic mappings (~/.contextstream-mappings.json) * 3. If ambiguous, return workspace candidates for user/agent selection * * Once workspace is resolved, loads WORKSPACE-LEVEL context (not just project), * ensuring cross-project decisions and memory are available. */ async initSession(params: { workspace_id?: string; project_id?: string; session_id?: string; context_hint?: string; include_recent_memory?: boolean; include_decisions?: boolean; include_user_preferences?: boolean; auto_index?: boolean; }, ideRoots: string[] = []) { let workspaceId = params.workspace_id || this.config.defaultWorkspaceId; let projectId = params.project_id || this.config.defaultProjectId; let workspaceName: string | undefined; // Build comprehensive initial context const context: Record<string, unknown> = { session_id: params.session_id || randomUUID(), initialized_at: new Date().toISOString(), }; const rootPath = ideRoots.length > 0 ? ideRoots[0] : undefined; // ======================================== // STEP 1: Workspace Discovery Chain // ======================================== if (!workspaceId && rootPath) { // Try local config and parent mappings first const resolved = resolveWorkspace(rootPath); if (resolved.config) { workspaceId = resolved.config.workspace_id; workspaceName = resolved.config.workspace_name; projectId = resolved.config.project_id || projectId; context.workspace_source = resolved.source; context.workspace_resolved_from = resolved.source === 'local_config' ? `${rootPath}/.contextstream/config.json` : 'parent_folder_mapping'; } else { // No local config - try to find matching workspace by name or project const folderName = rootPath?.split('/').pop()?.toLowerCase() || ''; try { const workspaces = await this.listWorkspaces({ page_size: 50 }) as { items?: Array<{ id: string; name: string; description?: string }> }; if (workspaces.items && workspaces.items.length > 0) { // Try to find a workspace with a matching or similar name let matchedWorkspace: { id: string; name: string; description?: string } | undefined; let matchSource: string | undefined; // 1. Exact name match (case-insensitive) matchedWorkspace = workspaces.items.find( w => w.name.toLowerCase() === folderName ); if (matchedWorkspace) { matchSource = 'workspace_name_exact'; } // 2. Workspace name contains folder name or vice versa if (!matchedWorkspace) { matchedWorkspace = workspaces.items.find( w => w.name.toLowerCase().includes(folderName) || folderName.includes(w.name.toLowerCase()) ); if (matchedWorkspace) { matchSource = 'workspace_name_partial'; } } // 3. Check if any workspace has a project with matching name if (!matchedWorkspace) { for (const ws of workspaces.items) { try { const projects = await this.listProjects({ workspace_id: ws.id, page_size: 50 }) as { items?: Array<{ id: string; name: string }> }; const matchingProject = projects.items?.find( p => p.name.toLowerCase() === folderName || p.name.toLowerCase().includes(folderName) || folderName.includes(p.name.toLowerCase()) ); if (matchingProject) { matchedWorkspace = ws; matchSource = 'project_name_match'; projectId = matchingProject.id; context.project_source = 'matched_existing'; break; } } catch { /* continue checking other workspaces */ } } } if (matchedWorkspace) { // Found a matching workspace - use it workspaceId = matchedWorkspace.id; workspaceName = matchedWorkspace.name; context.workspace_source = matchSource; context.workspace_auto_matched = true; // Save to local config for next time writeLocalConfig(rootPath, { workspace_id: matchedWorkspace.id, workspace_name: matchedWorkspace.name, associated_at: new Date().toISOString(), }); } else { // No match found - need user selection context.status = 'requires_workspace_selection'; context.workspace_candidates = workspaces.items.map(w => ({ id: w.id, name: w.name, description: w.description, })); context.message = `New folder detected: "${rootPath?.split('/').pop()}". Please select which workspace this belongs to, or create a new one.`; context.ide_roots = ideRoots; context.folder_name = rootPath?.split('/').pop(); // Return early - agent needs to ask user return context; } } else { // No workspaces exist - create one with folder name const newWorkspace = await this.createWorkspace({ name: folderName || 'My Workspace', description: `Workspace created for ${rootPath}`, visibility: 'private', }) as { id?: string; name?: string }; if (newWorkspace.id) { workspaceId = newWorkspace.id; workspaceName = newWorkspace.name; context.workspace_source = 'auto_created'; context.workspace_created = true; // Save to local config for next time writeLocalConfig(rootPath, { workspace_id: newWorkspace.id, workspace_name: newWorkspace.name, associated_at: new Date().toISOString(), }); } } } catch (e) { context.workspace_error = String(e); } } } // Fallback: if still no workspace and no IDE roots, pick first available if (!workspaceId && !rootPath) { try { const workspaces = await this.listWorkspaces({ page_size: 1 }) as { items?: Array<{ id: string; name: string }> }; if (workspaces.items && workspaces.items.length > 0) { workspaceId = workspaces.items[0].id; workspaceName = workspaces.items[0].name; context.workspace_source = 'fallback_first'; } } catch (e) { context.workspace_error = String(e); } } // ======================================== // STEP 2: Project Discovery // ======================================== if (!projectId && workspaceId && rootPath && params.auto_index !== false) { const projectName = rootPath.split('/').pop() || 'My Project'; try { // Check if a project with this name (or similar) already exists in this workspace const projects = await this.listProjects({ workspace_id: workspaceId }) as { items?: Array<{ id: string; name: string }> }; const projectNameLower = projectName.toLowerCase(); // Try exact match first, then partial match let existingProject = projects.items?.find(p => p.name.toLowerCase() === projectNameLower); if (existingProject) { context.project_match_type = 'exact'; } else { existingProject = projects.items?.find( p => p.name.toLowerCase().includes(projectNameLower) || projectNameLower.includes(p.name.toLowerCase()) ); if (existingProject) { context.project_match_type = 'partial'; } } if (existingProject) { projectId = existingProject.id; context.project_source = 'existing'; context.matched_project_name = existingProject.name; } else { // Create project from IDE root const newProject = await this.createProject({ name: projectName, description: `Auto-created from ${rootPath}`, workspace_id: workspaceId, }) as { id?: string }; if (newProject.id) { projectId = newProject.id; context.project_source = 'auto_created'; context.project_created = true; context.project_path = rootPath; } } // Update local config with project info if (projectId) { const existingConfig = readLocalConfig(rootPath); if (existingConfig || workspaceId) { writeLocalConfig(rootPath, { workspace_id: workspaceId!, workspace_name: workspaceName, project_id: projectId, project_name: projectName, associated_at: existingConfig?.associated_at || new Date().toISOString(), }); } } // Ingest files if auto_index is enabled (default: true) // Runs in BACKGROUND - does not block session_init if (projectId && (params.auto_index === undefined || params.auto_index === true)) { context.indexing_status = 'started'; // Fire-and-forget: start indexing in background const projectIdCopy = projectId; const rootPathCopy = rootPath; (async () => { try { for await (const batch of readAllFilesInBatches(rootPathCopy, { batchSize: 50 })) { await this.ingestFiles(projectIdCopy, batch); } console.error(`[ContextStream] Background indexing completed for ${rootPathCopy}`); } catch (e) { console.error(`[ContextStream] Background indexing failed:`, e); } })(); } } catch (e) { context.project_error = String(e); } } context.status = 'connected'; context.workspace_id = workspaceId; context.workspace_name = workspaceName; context.project_id = projectId; context.ide_roots = ideRoots; // ======================================== // STEP 3: Load Context via Batched Endpoint // Single API call instead of 5-6 separate calls // ======================================== if (workspaceId) { try { const batchedContext = await this._fetchSessionContextBatched({ workspace_id: workspaceId, project_id: projectId, session_id: context.session_id as string, context_hint: params.context_hint, include_recent_memory: params.include_recent_memory !== false, include_decisions: params.include_decisions !== false, }); // Merge batched response into context if (batchedContext.workspace) { context.workspace = batchedContext.workspace; } if (batchedContext.project) { context.project = batchedContext.project; } if (batchedContext.recent_memory) { context.recent_memory = { items: batchedContext.recent_memory }; } if (batchedContext.recent_decisions) { context.recent_decisions = { items: batchedContext.recent_decisions }; } if (batchedContext.relevant_context) { context.relevant_context = batchedContext.relevant_context; } // Load high-priority lessons (critical/high severity) try { const lessons = await this.getHighPriorityLessons({ workspace_id: workspaceId, project_id: projectId, context_hint: params.context_hint, limit: 5, }); if (lessons.length > 0) { context.lessons = lessons; context.lessons_warning = `⚠️ ${lessons.length} lesson(s) from past mistakes. Review before making changes.`; } } catch { /* optional */ } } catch (e) { // Fallback to individual calls if batched endpoint fails console.error('[ContextStream] Batched endpoint failed, falling back to individual calls:', e); await this._fetchSessionContextFallback(context, workspaceId, projectId, params); } } return context; } /** * Fetch session context using the batched /session/init endpoint. * This is much faster than making 5-6 individual API calls. */ private async _fetchSessionContextBatched(params: { workspace_id: string; project_id?: string; session_id?: string; context_hint?: string; include_recent_memory?: boolean; include_decisions?: boolean; }): Promise<{ workspace?: { id: string; name: string; description?: string }; project?: { id: string; name: string; description?: string }; recent_memory?: unknown[]; recent_decisions?: unknown[]; relevant_context?: unknown; }> { interface SessionContextData { workspace?: { id: string; name: string; description?: string }; project?: { id: string; name: string; description?: string }; recent_memory?: unknown[]; recent_decisions?: unknown[]; relevant_context?: unknown; } // Check cache first const cacheKey = CacheKeys.sessionInit(params.workspace_id, params.project_id); const cached = globalCache.get<SessionContextData>(cacheKey); if (cached) { console.error('[ContextStream] Session context cache HIT'); return cached; } // Call batched endpoint const result = await request(this.config, '/session/init', { body: { workspace_id: params.workspace_id, project_id: params.project_id, session_id: params.session_id, include_recent_memory: params.include_recent_memory ?? true, include_decisions: params.include_decisions ?? true, }, }) as { data?: SessionContextData } | SessionContextData; // Handle both wrapped {data: ...} and direct response formats const contextData: SessionContextData = 'data' in result && result.data ? result.data : result as SessionContextData; // Cache the result globalCache.set(cacheKey, contextData, CacheTTL.SESSION_INIT); return contextData; } /** * Fallback to individual API calls if batched endpoint is unavailable. */ private async _fetchSessionContextFallback( context: Record<string, unknown>, workspaceId: string, projectId: string | undefined, params: { include_recent_memory?: boolean; include_decisions?: boolean; context_hint?: string } ): Promise<void> { // Individual calls (slower but more compatible) try { context.workspace = await this.workspaceOverview(workspaceId); } catch { /* optional */ } if (projectId) { try { context.project = await this.projectOverview(projectId); } catch { /* optional */ } } if (params.include_recent_memory !== false) { try { context.recent_memory = await this.listMemoryEvents({ workspace_id: workspaceId, limit: 10, }); } catch { /* optional */ } } if (params.include_decisions !== false) { try { context.recent_decisions = await this.memoryDecisions({ workspace_id: workspaceId, limit: 5, }); } catch { /* optional */ } } if (params.context_hint) { try { context.relevant_context = await this.memorySearch({ query: params.context_hint, workspace_id: workspaceId, limit: 5, }); } catch { /* optional */ } } // Load high-priority lessons (critical/high severity) try { const lessons = await this.getHighPriorityLessons({ workspace_id: workspaceId, project_id: projectId, context_hint: params.context_hint, limit: 5, }); if (lessons.length > 0) { context.lessons = lessons; context.lessons_warning = `⚠️ ${lessons.length} lesson(s) from past mistakes. Review before making changes.`; } } catch { /* optional */ } } /** * Associate a folder with a workspace (called after user selects from candidates). * Persists the selection to .contextstream/config.json for future sessions. */ async associateWorkspace(params: { folder_path: string; workspace_id: string; workspace_name?: string; create_parent_mapping?: boolean; // Also create a parent folder mapping }) { const { folder_path, workspace_id, workspace_name, create_parent_mapping } = params; // Save local config const saved = writeLocalConfig(folder_path, { workspace_id, workspace_name, associated_at: new Date().toISOString(), }); // Optionally create parent folder mapping (e.g., /home/user/dev/company/* -> workspace) if (create_parent_mapping) { const parentDir = folder_path.split('/').slice(0, -1).join('/'); addGlobalMapping({ pattern: `${parentDir}/*`, workspace_id, workspace_name: workspace_name || 'Unknown', }); } return { success: saved, config_path: `${folder_path}/.contextstream/config.json`, workspace_id, workspace_name, parent_mapping_created: create_parent_mapping || false, }; } /** * Get user preferences and persona from memory. * Useful for AI to understand user's coding style, preferences, etc. */ async getUserContext(params: { workspace_id?: string }) { const withDefaults = this.withDefaults(params); if (!withDefaults.workspace_id) { throw new Error('workspace_id is required for getUserContext'); } const context: Record<string, unknown> = {}; // Search for user preferences try { context.preferences = await this.memorySearch({ query: 'user preferences coding style settings', workspace_id: withDefaults.workspace_id, limit: 10, }); } catch { /* optional */ } // Get memory summary for overall context try { context.summary = await this.memorySummary(withDefaults.workspace_id); } catch { /* optional */ } return context; } /** * Capture and store conversation context automatically. * Call this to persist important context from the current conversation. */ async captureContext(params: { workspace_id?: string; project_id?: string; session_id?: string; event_type: 'conversation' | 'decision' | 'insight' | 'preference' | 'task' | 'bug' | 'feature' | 'correction' | 'lesson' | 'warning' | 'frustration'; title: string; content: string; tags?: string[]; importance?: 'low' | 'medium' | 'high' | 'critical'; }) { const withDefaults = this.withDefaults(params); // Map high-level types to API EventType let apiEventType = 'manual_note'; const tags = params.tags || []; switch (params.event_type) { case 'conversation': apiEventType = 'chat'; break; case 'task': apiEventType = 'task_created'; break; case 'bug': case 'feature': apiEventType = 'ticket'; tags.push(params.event_type); break; case 'decision': case 'insight': case 'preference': apiEventType = 'manual_note'; tags.push(params.event_type); break; // Lesson system types - all stored as manual_note with specific tags case 'correction': case 'lesson': case 'warning': case 'frustration': apiEventType = 'manual_note'; tags.push(params.event_type); // Add lesson-related tag for easier filtering if (!tags.includes('lesson_system')) { tags.push('lesson_system'); } break; default: apiEventType = 'manual_note'; tags.push(params.event_type); } return this.createMemoryEvent({ workspace_id: withDefaults.workspace_id, project_id: withDefaults.project_id, event_type: apiEventType, title: params.title, content: params.content, metadata: { original_type: params.event_type, session_id: params.session_id, tags: tags, importance: params.importance || 'medium', captured_at: new Date().toISOString(), source: 'mcp_auto_capture', }, }); } /** * Search memory with automatic context enrichment. * Returns both direct matches and related context. */ async smartSearch(params: { query: string; workspace_id?: string; project_id?: string; include_related?: boolean; include_decisions?: boolean; }) { const withDefaults = this.withDefaults(params); const results: Record<string, unknown> = {}; // Primary memory search try { results.memory_results = await this.memorySearch({ query: params.query, workspace_id: withDefaults.workspace_id, project_id: withDefaults.project_id, limit: 10, }); } catch { /* optional */ } // Semantic code search if project specified if (withDefaults.project_id) { try { results.code_results = await this.searchSemantic({ query: params.query, workspace_id: withDefaults.workspace_id, project_id: withDefaults.project_id, limit: 5, }); } catch { /* optional */ } } // Include related decisions if (params.include_decisions !== false && withDefaults.workspace_id) { try { results.related_decisions = await this.memoryDecisions({ workspace_id: withDefaults.workspace_id, project_id: withDefaults.project_id, limit: 3, }); } catch { /* optional */ } } return results; } // ============================================ // Token-Saving Context Tools // ============================================ /** * Get a compact, token-efficient summary of workspace context. * Designed to be included in every AI prompt without consuming many tokens. * * Target: ~500 tokens max * * This replaces loading full chat history - AI can call session_recall * for specific details when needed. */ async getContextSummary(params: { workspace_id?: string; project_id?: string; max_tokens?: number; }): Promise<{ summary: string; workspace_name?: string; project_name?: string; decision_count: number; memory_count: number; }> { const withDefaults = this.withDefaults(params); const maxTokens = params.max_tokens || 500; if (!withDefaults.workspace_id) { return { summary: 'No workspace context loaded. Call session_init first.', decision_count: 0, memory_count: 0, }; } const parts: string[] = []; let workspaceName: string | undefined; let projectName: string | undefined; let decisionCount = 0; let memoryCount = 0; // Get workspace info (cached) try { const ws = await this.getWorkspace(withDefaults.workspace_id) as { name?: string }; workspaceName = ws?.name; if (workspaceName) { parts.push(`📁 Workspace: ${workspaceName}`); } } catch { /* optional */ } // Get project info if specified (cached) if (withDefaults.project_id) { try { const proj = await this.getProject(withDefaults.project_id) as { name?: string }; projectName = proj?.name; if (projectName) { parts.push(`📂 Project: ${projectName}`); } } catch { /* optional */ } } // Get recent decisions (titles only for token efficiency) try { const decisions = await this.memoryDecisions({ workspace_id: withDefaults.workspace_id, project_id: withDefaults.project_id, limit: 5, }) as { items?: Array<{ title?: string }> }; if (decisions.items && decisions.items.length > 0) { decisionCount = decisions.items.length; parts.push(''); parts.push('📋 Recent Decisions:'); decisions.items.slice(0, 3).forEach((d, i) => { parts.push(` ${i + 1}. ${d.title || 'Untitled'}`); }); if (decisions.items.length > 3) { parts.push(` (+${decisions.items.length - 3} more)`); } } } catch { /* optional */ } // Get preferences count and sample try { const prefs = await this.memorySearch({ query: 'user preferences coding style settings', workspace_id: withDefaults.workspace_id, limit: 5, }) as { results?: Array<{ title?: string }> }; if (prefs.results && prefs.results.length > 0) { parts.push(''); parts.push('⚙️ Preferences:'); prefs.results.slice(0, 3).forEach((p) => { const title = p.title || 'Preference'; // Truncate to save tokens parts.push(` • ${title.slice(0, 60)}${title.length > 60 ? '...' : ''}`); }); } } catch { /* optional */ } // Get memory count try { const summary = await this.memorySummary(withDefaults.workspace_id) as { events?: number }; memoryCount = summary.events || 0; if (memoryCount > 0) { parts.push(''); parts.push(`🧠 Memory: ${memoryCount} events stored`); } } catch { /* optional */ } // Add usage hint parts.push(''); parts.push('💡 Use session_recall("topic") for specific context'); const summary = parts.join('\n'); return { summary, workspace_name: workspaceName, project_name: projectName, decision_count: decisionCount, memory_count: memoryCount, }; } /** * Compress chat history into structured memory events. * This extracts key information and stores it, allowing the chat * history to be cleared while preserving context. * * Use this at the end of a conversation or when context window is full. */ async compressChat(params: { workspace_id?: string; project_id?: string; chat_history: string; extract_types?: Array<'decisions' | 'preferences' | 'insights' | 'tasks' | 'code_patterns'>; }): Promise<{ events_created: number; extracted: { decisions: string[]; preferences: string[]; insights: string[]; tasks: string[]; code_patterns: string[]; }; }> { const withDefaults = this.withDefaults(params); if (!withDefaults.workspace_id) { throw new Error('workspace_id is required for compressChat'); } const extractTypes = params.extract_types || ['decisions', 'preferences', 'insights', 'tasks', 'code_patterns']; const extracted: { decisions: string[]; preferences: string[]; insights: string[]; tasks: string[]; code_patterns: string[]; } = { decisions: [], preferences: [], insights: [], tasks: [], code_patterns: [], }; let eventsCreated = 0; // Simple extraction patterns (AI can do better, but this works without LLM call) const lines = params.chat_history.split('\n'); for (const line of lines) { const lowerLine = line.toLowerCase(); // Decision patterns if (extractTypes.includes('decisions')) { if (lowerLine.includes('decided to') || lowerLine.includes('decision:') || lowerLine.includes('we\'ll use') || lowerLine.includes('going with') || lowerLine.includes('chose ')) { extracted.decisions.push(line.trim()); } } // Preference patterns if (extractTypes.includes('preferences')) { if (lowerLine.includes('prefer') || lowerLine.includes('i like') || lowerLine.includes('always use') || lowerLine.includes('don\'t use') || lowerLine.includes('never use')) { extracted.preferences.push(line.trim()); } } // Task patterns if (extractTypes.includes('tasks')) { if (lowerLine.includes('todo:') || lowerLine.includes('task:') || lowerLine.includes('need to') || lowerLine.includes('should implement') || lowerLine.includes('will add')) { extracted.tasks.push(line.trim()); } } // Insight patterns if (extractTypes.includes('insights')) { if (lowerLine.includes('learned that') || lowerLine.includes('realized') || lowerLine.includes('found out') || lowerLine.includes('discovered') || lowerLine.includes('important:') || lowerLine.includes('note:')) { extracted.insights.push(line.trim()); } } // Code pattern patterns if (extractTypes.includes('code_patterns')) { if (lowerLine.includes('pattern:') || lowerLine.includes('convention:') || lowerLine.includes('style:') || lowerLine.includes('always format') || lowerLine.includes('naming convention')) { extracted.code_patterns.push(line.trim()); } } } // Store extracted items as memory events for (const decision of extracted.decisions.slice(0, 5)) { try { await this.captureContext({ workspace_id: withDefaults.workspace_id, project_id: withDefaults.project_id, event_type: 'decision', title: decision.slice(0, 100), content: decision, importance: 'medium', }); eventsCreated++; } catch { /* continue */ } } for (const pref of extracted.preferences.slice(0, 5)) { try { await this.captureContext({ workspace_id: withDefaults.workspace_id, project_id: withDefaults.project_id, event_type: 'preference', title: pref.slice(0, 100), content: pref, importance: 'medium', }); eventsCreated++; } catch { /* continue */ } } for (const task of extracted.tasks.slice(0, 5)) { try { await this.captureContext({ workspace_id: withDefaults.workspace_id, project_id: withDefaults.project_id, event_type: 'task', title: task.slice(0, 100), content: task, importance: 'medium', }); eventsCreated++; } catch { /* continue */ } } for (const insight of extracted.insights.slice(0, 5)) { try { await this.captureContext({ workspace_id: withDefaults.workspace_id, project_id: withDefaults.project_id, event_type: 'insight', title: insight.slice(0, 100), content: insight, importance: 'medium', }); eventsCreated++; } catch { /* continue */ } } return { events_created: eventsCreated, extracted, }; } /** * Get context optimized for a token budget. * Returns the most relevant context that fits within the specified token limit. * * This is the key tool for token-efficient AI interactions: * - AI calls this with a query and token budget * - Gets optimally selected context * - No need to include full chat history */ async getContextWithBudget(params: { query: string; workspace_id?: string; project_id?: string; max_tokens: number; include_decisions?: boolean; include_code?: boolean; include_memory?: boolean; }): Promise<{ context: string; token_estimate: number; sources: Array<{ type: string; title: string }>; }> { const withDefaults = this.withDefaults(params); const maxTokens = params.max_tokens || 2000; // Rough token estimation: ~4 chars per token const charsPerToken = 4; const maxChars = maxTokens * charsPerToken; const parts: string[] = []; const sources: Array<{ type: string; title: string }> = []; let currentChars = 0; // Priority 1: Decisions (most valuable per token) if (params.include_decisions !== false && withDefaults.workspace_id) { try { const decisions = await this.memoryDecisions({ workspace_id: withDefaults.workspace_id, project_id: withDefaults.project_id, limit: 10, }) as { items?: Array<{ title?: string; content?: string }> }; if (decisions.items) { parts.push('## Relevant Decisions\n'); currentChars += 25; for (const d of decisions.items) { const entry = `• ${d.title || 'Decision'}\n`; if (currentChars + entry.length > maxChars * 0.4) break; // Reserve 40% for decisions parts.push(entry); currentChars += entry.length; sources.push({ type: 'decision', title: d.title || 'Decision' }); } parts.push('\n'); } } catch { /* optional */ } } // Priority 2: Memory search results (query-relevant) if (params.include_memory !== false && withDefaults.workspace_id) { try { const memory = await this.memorySearch({ query: params.query, workspace_id: withDefaults.workspace_id, project_id: withDefaults.project_id, limit: 5, }) as { results?: Array<{ title?: string; content?: string }> }; if (memory.results) { parts.push('## Related Context\n'); currentChars += 20; for (const m of memory.results) { // Truncate content to fit budget const title = m.title || 'Context'; const content = m.content?.slice(0, 200) || ''; const entry = `• ${title}: ${content}...\n`; if (currentChars + entry.length > maxChars * 0.7) break; // Reserve 30% for code parts.push(entry); currentChars += entry.length; sources.push({ type: 'memory', title }); } parts.push('\n'); } } catch { /* optional */ } } // Priority 3: Code search results (if budget allows) if (params.include_code && withDefaults.project_id && currentChars < maxChars * 0.8) { try { const code = await this.searchSemantic({ query: params.query, workspace_id: withDefaults.workspace_id, project_id: withDefaults.project_id, limit: 3, }) as { results?: Array<{ file_path?: string; content?: string }> }; if (code.results) { parts.push('## Relevant Code\n'); currentChars += 18; for (const c of code.results) { const path = c.file_path || 'file'; const content = c.content?.slice(0, 150) || ''; const entry = `• ${path}: ${content}...\n`; if (currentChars + entry.length > maxChars) break; parts.push(entry); currentChars += entry.length; sources.push({ type: 'code', title: path }); } } } catch { /* optional */ } } const context = parts.join(''); const tokenEstimate = Math.ceil(context.length / charsPerToken); return { context, token_estimate: tokenEstimate, sources, }; } /** * Get incremental context changes since a given timestamp. * Useful for syncing context without reloading everything. */ async getContextDelta(params: { workspace_id?: string; project_id?: string; since: string; // ISO timestamp limit?: number; }): Promise<{ new_decisions: number; new_memory: number; items: Array<{ type: string; title: string; created_at: string }>; }> { const withDefaults = this.withDefaults(params); if (!withDefaults.workspace_id) { return { new_decisions: 0, new_memory: 0, items: [] }; } const items: Array<{ type: string; title: string; created_at: string }> = []; let newDecisions = 0; let newMemory = 0; try { const memory = await this.listMemoryEvents({ workspace_id: withDefaults.workspace_id, project_id: withDefaults.project_id, limit: params.limit || 20, }) as { items?: Array<{ title?: string; created_at?: string; metadata?: { original_type?: string } }> }; if (memory.items) { for (const item of memory.items) { const createdAt = item.created_at || ''; if (createdAt > params.since) { const type = item.metadata?.original_type || 'memory'; items.push({ type, title: item.title || 'Untitled', created_at: createdAt, }); if (type === 'decision') newDecisions++; else newMemory++; } } } } catch { /* optional */ } return { new_decisions: newDecisions, new_memory: newMemory, items, }; } /** * Get smart context for a user query - CALL THIS BEFORE EVERY RESPONSE. * * This is the key tool for automatic context injection: * 1. Analyzes the user's message to understand what context is needed * 2. Retrieves relevant context in a minified, token-efficient format * 3. Returns context that the AI can use without including chat history * * The format is optimized for AI consumption: * - Compact notation (D: for Decision, P: for Preference, etc.) * - No redundant whitespace * - Structured for easy parsing * * Format options: * - 'minified': Ultra-compact TYPE:value|TYPE:value|... * - 'readable': Human-readable with line breaks * - 'structured': JSON-like grouped format */ async getSmartContext(params: { user_message: string; workspace_id?: string; project_id?: string; max_tokens?: number; format?: 'minified' | 'readable' | 'structured'; }): Promise<{ context: string; token_estimate: number; format: string; sources_used: number; }> { const withDefaults = this.withDefaults(params); const maxTokens = params.max_tokens || 800; const format = params.format || 'minified'; if (!withDefaults.workspace_id) { return { context: '[NO_WORKSPACE]', token_estimate: 2, format, sources_used: 0, }; } // Extract keywords from user message for targeted search const message = params.user_message.toLowerCase(); const keywords = this.extractKeywords(message); // Collect context items const items: Array<{ type: string; key: string; value: string; relevance: number }> = []; // 1. Get workspace/project info (always include, very compact) try { const ws = await this.getWorkspace(withDefaults.workspace_id) as { name?: string }; if (ws?.name) { items.push({ type: 'W', key: 'workspace', value: ws.name, relevance: 1 }); } } catch { /* skip */ } if (withDefaults.project_id) { try { const proj = await this.getProject(withDefaults.project_id) as { name?: string }; if (proj?.name) { items.push({ type: 'P', key: 'project', value: proj.name, relevance: 1 }); } } catch { /* skip */ } } // 2. Get decisions (prioritize based on keyword match) try { const decisions = await this.memoryDecisions({ workspace_id: withDefaults.workspace_id, project_id: withDefaults.project_id, limit: 10, }) as { items?: Array<{ title?: string; content?: string }> }; if (decisions.items) { for (const d of decisions.items) { const title = d.title || ''; const content = d.content || ''; const relevance = this.calculateRelevance(keywords, title + ' ' + content); items.push({ type: 'D', key: 'decision', value: title.slice(0, 80), relevance, }); } } } catch { /* skip */ } // 3. Search memory for query-relevant items if (keywords.length > 0) { try { const memory = await this.memorySearch({ query: params.user_message.slice(0, 200), workspace_id: withDefaults.workspace_id, project_id: withDefaults.project_id, limit: 5, }) as { results?: Array<{ title?: string; content?: string }> }; if (memory.results) { for (const m of memory.results) { const title = m.title || ''; const content = m.content || ''; items.push({ type: 'M', key: 'memory', value: title.slice(0, 80) + (content ? ': ' + content.slice(0, 100) : ''), relevance: 0.8, // Memory search already ranked by relevance }); } } } catch { /* skip */ } } // 4. Get relevant lessons (high priority - surface warnings) try { const lessons = await this.getHighPriorityLessons({ workspace_id: withDefaults.workspace_id, project_id: withDefaults.project_id, context_hint: params.user_message, limit: 3, }); for (const lesson of lessons) { // Use L for Lesson type, add warning emoji for critical const prefix = lesson.severity === 'critical' ? '⚠️ ' : ''; items.push({ type: 'L', key: 'lesson', value: `${prefix}${lesson.title}: ${lesson.prevention.slice(0, 100)}`, relevance: lesson.severity === 'critical' ? 1.0 : 0.9, // Lessons are high priority }); } } catch { /* skip */ } // Sort by relevance items.sort((a, b) => b.relevance - a.relevance); // Build context string based on format let context: string; let charsUsed = 0; const maxChars = maxTokens * 4; // ~4 chars per token if (format === 'minified') { // Ultra-compact format: TYPE:value|TYPE:value|... const parts: string[] = []; for (const item of items) { const entry = `${item.type}:${item.value}`; if (charsUsed + entry.length + 1 > maxChars) break; parts.push(entry); charsUsed += entry.length + 1; } context = parts.join('|'); } else if (format === 'structured') { // JSON-like compact format const grouped: Record<string, string[]> = {}; for (const item of items) { if (charsUsed > maxChars) break; if (!grouped[item.type]) grouped[item.type] = []; grouped[item.type].push(item.value); charsUsed += item.value.length + 5; } context = JSON.stringify(grouped); } else { // Readable format (default) const lines: string[] = ['[CTX]']; for (const item of items) { const line = `${item.type}:${item.value}`; if (charsUsed + line.length + 1 > maxChars) break; lines.push(line); charsUsed += line.length + 1; } lines.push('[/CTX]'); context = lines.join('\n'); } return { context, token_estimate: Math.ceil(context.length / 4), format, sources_used: items.filter(i => context.includes(i.value.slice(0, 20))).length, }; } /** * Get high-priority lessons that should be surfaced proactively. * Returns critical and high severity lessons for warnings. */ async getHighPriorityLessons(params: { workspace_id: string; project_id?: string; context_hint?: string; limit?: number; }): Promise<Array<{ title: string; severity: string; category: string; prevention: string; }>> { const limit = params.limit || 5; try { // Search for lessons, prioritizing those relevant to the context const searchQuery = params.context_hint ? `${params.context_hint} lesson warning prevention mistake` : 'lesson warning prevention mistake critical high'; const searchResult = await this.memorySearch({ query: searchQuery, workspace_id: params.workspace_id, project_id: params.project_id, limit: limit * 2, // Fetch more to filter }) as { results?: Array<{ title?: string; content?: string; metadata?: { tags?: string[]; importance?: string }; }> }; if (!searchResult?.results) return []; // Filter for lessons with high/critical severity const lessons = searchResult.results .filter((item) => { const tags = item.metadata?.tags || []; const isLesson = tags.includes('lesson') || tags.includes('lesson_system'); if (!isLesson) return false; // Get severity from tags or importance const severityTag = tags.find((t: string) => t.startsWith('severity:')); const severity = severityTag?.split(':')[1] || item.metadata?.importance || 'medium'; return severity === 'critical' || severity === 'high'; }) .slice(0, limit) .map((item) => { const tags = item.metadata?.tags || []; const severityTag = tags.find((t: string) => t.startsWith('severity:')); const severity = severityTag?.split(':')[1] || item.metadata?.importance || 'medium'; const category = tags.find((t: string) => ['workflow', 'code_quality', 'verification', 'communication', 'project_specific'].includes(t) ) || 'unknown'; // Extract prevention from content const content = item.content || ''; const preventionMatch = content.match(/### Prevention\n([\s\S]*?)(?:\n\n|\n\*\*|$)/); const prevention = preventionMatch?.[1]?.trim() || content.slice(0, 200); return { title: item.title || 'Lesson', severity, category, prevention, }; }); return lessons; } catch { return []; } } /** * Extract keywords from a message for relevance matching */ private extractKeywords(message: string): string[] { // Remove common words and extract meaningful terms const stopWords = new Set([ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'can', 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through', 'during', 'before', 'after', 'above', 'below', 'between', 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when', 'where', 'why', 'how', 'all', 'each', 'few', 'more', 'most', 'other', 'some', 'such', 'no', 'nor', 'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 'just', 'and', 'but', 'if', 'or', 'because', 'until', 'while', 'this', 'that', 'these', 'those', 'what', 'which', 'who', 'whom', 'i', 'me', 'my', 'we', 'our', 'you', 'your', 'he', 'she', 'it', 'they', 'them', ]); return message .toLowerCase() .replace(/[^\w\s]/g, ' ') .split(/\s+/) .filter(word => word.length > 2 && !stopWords.has(word)); } /** * Calculate relevance score based on keyword matches */ private calculateRelevance(keywords: string[], text: string): number { if (keywords.length === 0) return 0.5; const textLower = text.toLowerCase(); let matches = 0; for (const keyword of keywords) { if (textLower.includes(keyword)) { matches++; } } return matches / keywords.length; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/contextstream/mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server