Compare commits
16 Commits
c0c6918e2c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18defcc9d5 | ||
| 1349f3b0ce | |||
|
|
ad13a26168 | ||
|
|
2ed6e68686 | ||
|
|
0fad29801e | ||
|
|
3613e2aa52 | ||
|
|
9dae176fc2 | ||
|
|
ece0e81ae9 | ||
|
|
ef74d7912e | ||
|
|
0150575713 | ||
|
|
27548f5c51 | ||
|
|
02c009a551 | ||
|
|
4f8996cd82 | ||
|
|
1f499bd926 | ||
|
|
77097ac65f | ||
|
|
6b53fb9168 |
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
8
.env
8
.env
@@ -11,7 +11,7 @@
|
|||||||
# - PostgreSQL integration for database error tracking
|
# - PostgreSQL integration for database error tracking
|
||||||
#
|
#
|
||||||
# Created: 2026-01-29
|
# Created: 2026-01-29
|
||||||
SENTRY_DSN=$(vault get sentry.dsn.mcp-servers)
|
# SENTRY_DSN provided via ~/.claude.json env (dotenv can't expand shell commands)
|
||||||
SENTRY_ENVIRONMENT=production
|
SENTRY_ENVIRONMENT=production
|
||||||
SENTRY_TRACE_SAMPLE_RATE=0.1
|
SENTRY_TRACE_SAMPLE_RATE=0.1
|
||||||
SENTRY_PROFILE_SAMPLE_RATE=0.01
|
SENTRY_PROFILE_SAMPLE_RATE=0.01
|
||||||
@@ -28,7 +28,11 @@ POSTGRES_PORT=6432
|
|||||||
LLM_API_URL=https://api.agiliton.cloud/llm
|
LLM_API_URL=https://api.agiliton.cloud/llm
|
||||||
LLM_API_KEY=sk-c02d41a118ce8330c428100afaa816c8
|
LLM_API_KEY=sk-c02d41a118ce8330c428100afaa816c8
|
||||||
|
|
||||||
# Jira Cloud (CF-762 migration)
|
# AgilitonAPI Gateway (API-11: centralized API access)
|
||||||
|
AGILITON_API_KEY=gw_92399e154f02730ebadec65ddbde9426c9378ec77093d1c9
|
||||||
|
AGILITON_API_URL=https://api.agiliton.cloud
|
||||||
|
|
||||||
|
# Jira Cloud (fallback if gateway unavailable)
|
||||||
JIRA_URL=https://agiliton.atlassian.net
|
JIRA_URL=https://agiliton.atlassian.net
|
||||||
JIRA_USERNAME=christian.gick@agiliton.eu
|
JIRA_USERNAME=christian.gick@agiliton.eu
|
||||||
JIRA_API_TOKEN=ATATT3xFfGF0tpaJTS4nJklW587McubEw-1SYbLWqfovkxI5320NdbFc-3fgHlw0HGTLOikgV082m9N-SIsYVZveGXa553_1LAyOevV6Qples93xF4hIExWGAvwvXPy_4pW2tH5FNusN5ieMca5_-YUP0i69SIN0RLIMQjfqDmQyhZXbkIvrm-I=A8A2A1FC
|
JIRA_API_TOKEN=ATATT3xFfGF0tpaJTS4nJklW587McubEw-1SYbLWqfovkxI5320NdbFc-3fgHlw0HGTLOikgV082m9N-SIsYVZveGXa553_1LAyOevV6Qples93xF4hIExWGAvwvXPy_4pW2tH5FNusN5ieMca5_-YUP0i69SIN0RLIMQjfqDmQyhZXbkIvrm-I=A8A2A1FC
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ POSTGRES_PORT=6432
|
|||||||
LLM_API_URL=https://api.agiliton.cloud/llm
|
LLM_API_URL=https://api.agiliton.cloud/llm
|
||||||
LLM_API_KEY=your_llm_api_key_here
|
LLM_API_KEY=your_llm_api_key_here
|
||||||
|
|
||||||
|
# LLM metadata extraction at embedding time (CF-1316)
|
||||||
|
METADATA_EXTRACTION_MODEL=claude-haiku-4.5
|
||||||
|
|
||||||
|
# Cross-encoder re-ranking (CF-1317)
|
||||||
|
RERANK_ENABLED=false
|
||||||
|
RERANK_MODEL=rerank-v3.5
|
||||||
|
|
||||||
# Jira Cloud (session tracking)
|
# Jira Cloud (session tracking)
|
||||||
JIRA_URL=https://agiliton.atlassian.net
|
JIRA_URL=https://agiliton.atlassian.net
|
||||||
JIRA_USERNAME=your_email@agiliton.eu
|
JIRA_USERNAME=your_email@agiliton.eu
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
|
.claude-session/
|
||||||
|
|||||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json tsconfig.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY src ./src
|
||||||
|
RUN npm run build
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --omit=dev && npm cache clean --force
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
USER node
|
||||||
|
EXPOSE 9216
|
||||||
|
CMD ["node", "dist/http-server.js"]
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
-- Migration 012: Builds table for CI/CD tracking
|
|
||||||
-- Purpose: Track builds and link them to sessions and versions
|
|
||||||
-- Dependencies: 001_base_schema.sql (versions table), 010_sessions.sql (sessions table)
|
|
||||||
|
|
||||||
-- Builds table: Store build information linked to sessions and versions
|
|
||||||
CREATE TABLE IF NOT EXISTS builds (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL,
|
|
||||||
version_id TEXT REFERENCES versions(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
build_number INTEGER NOT NULL,
|
|
||||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'success', 'failed')),
|
|
||||||
|
|
||||||
-- Build metadata
|
|
||||||
git_commit_sha TEXT,
|
|
||||||
git_branch TEXT,
|
|
||||||
build_log_url TEXT,
|
|
||||||
artifacts_url TEXT,
|
|
||||||
|
|
||||||
-- Timing
|
|
||||||
started_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
||||||
finished_at TIMESTAMP WITH TIME ZONE,
|
|
||||||
duration_seconds INTEGER GENERATED ALWAYS AS
|
|
||||||
(EXTRACT(EPOCH FROM (finished_at - started_at))) STORED,
|
|
||||||
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Indexes for efficient querying
|
|
||||||
CREATE INDEX idx_builds_session ON builds(session_id);
|
|
||||||
CREATE INDEX idx_builds_version ON builds(version_id);
|
|
||||||
CREATE INDEX idx_builds_status ON builds(status);
|
|
||||||
CREATE INDEX idx_builds_started ON builds(started_at DESC);
|
|
||||||
CREATE INDEX idx_builds_commit ON builds(git_commit_sha);
|
|
||||||
|
|
||||||
-- Unique constraint: one build number per version
|
|
||||||
CREATE UNIQUE INDEX idx_builds_version_number ON builds(version_id, build_number)
|
|
||||||
WHERE version_id IS NOT NULL;
|
|
||||||
|
|
||||||
-- Comments for documentation
|
|
||||||
COMMENT ON TABLE builds IS 'CI/CD build tracking linked to sessions and versions';
|
|
||||||
COMMENT ON COLUMN builds.session_id IS 'Optional link to session that triggered the build';
|
|
||||||
COMMENT ON COLUMN builds.version_id IS 'Link to version being built';
|
|
||||||
COMMENT ON COLUMN builds.duration_seconds IS 'Auto-calculated build duration in seconds';
|
|
||||||
COMMENT ON COLUMN builds.build_log_url IS 'URL to build logs (e.g., GitHub Actions run)';
|
|
||||||
COMMENT ON COLUMN builds.artifacts_url IS 'URL to build artifacts (e.g., app binary, Docker image)';
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
-- Migration 018: Deployments tracking for deployment centralization
|
|
||||||
-- Purpose: Track all deployments (Docker, MCP, iOS/macOS, services) with logs
|
|
||||||
-- Dependencies: 001_base_schema.sql (tasks table), 010_sessions.sql (sessions table)
|
|
||||||
|
|
||||||
-- Deployments table: Store deployment information linked to sessions and tasks
|
|
||||||
CREATE TABLE IF NOT EXISTS deployments (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL,
|
|
||||||
task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
-- Project identification
|
|
||||||
project_name VARCHAR(255) NOT NULL,
|
|
||||||
project_path TEXT NOT NULL,
|
|
||||||
|
|
||||||
-- Deployment type and method
|
|
||||||
deployment_type VARCHAR(50) NOT NULL CHECK (deployment_type IN (
|
|
||||||
'docker-compose',
|
|
||||||
'mcp-server',
|
|
||||||
'ios-macos-app',
|
|
||||||
'python-service',
|
|
||||||
'node-service'
|
|
||||||
)),
|
|
||||||
deployment_method VARCHAR(50) NOT NULL CHECK (deployment_method IN (
|
|
||||||
'doco-cd',
|
|
||||||
'agiliton-build',
|
|
||||||
'direct',
|
|
||||||
'manual'
|
|
||||||
)),
|
|
||||||
|
|
||||||
-- Status tracking
|
|
||||||
status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (status IN (
|
|
||||||
'pending',
|
|
||||||
'running',
|
|
||||||
'success',
|
|
||||||
'failed',
|
|
||||||
'cancelled'
|
|
||||||
)),
|
|
||||||
|
|
||||||
-- Git integration
|
|
||||||
commit_sha VARCHAR(40),
|
|
||||||
git_branch TEXT,
|
|
||||||
|
|
||||||
-- Timing
|
|
||||||
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
|
||||||
completed_at TIMESTAMP WITH TIME ZONE,
|
|
||||||
duration_seconds INTEGER GENERATED ALWAYS AS
|
|
||||||
(EXTRACT(EPOCH FROM (completed_at - started_at))) STORED,
|
|
||||||
|
|
||||||
-- Error tracking
|
|
||||||
error_message TEXT,
|
|
||||||
|
|
||||||
-- Extra deployment-specific data (JSON)
|
|
||||||
metadata JSONB DEFAULT '{}'::jsonb,
|
|
||||||
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Deployment logs table: Store deployment log messages
|
|
||||||
CREATE TABLE IF NOT EXISTS deployment_logs (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
deployment_id INT NOT NULL REFERENCES deployments(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
|
||||||
level VARCHAR(20) NOT NULL CHECK (level IN ('debug', 'info', 'warn', 'error')),
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
|
|
||||||
-- Optional structured metadata
|
|
||||||
metadata JSONB DEFAULT '{}'::jsonb
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Indexes for efficient querying
|
|
||||||
CREATE INDEX idx_deployments_project ON deployments(project_name);
|
|
||||||
CREATE INDEX idx_deployments_session ON deployments(session_id);
|
|
||||||
CREATE INDEX idx_deployments_task ON deployments(task_id);
|
|
||||||
CREATE INDEX idx_deployments_status ON deployments(status);
|
|
||||||
CREATE INDEX idx_deployments_started ON deployments(started_at DESC);
|
|
||||||
CREATE INDEX idx_deployments_type ON deployments(deployment_type);
|
|
||||||
CREATE INDEX idx_deployments_commit ON deployments(commit_sha);
|
|
||||||
|
|
||||||
CREATE INDEX idx_deployment_logs_deployment ON deployment_logs(deployment_id);
|
|
||||||
CREATE INDEX idx_deployment_logs_timestamp ON deployment_logs(timestamp DESC);
|
|
||||||
CREATE INDEX idx_deployment_logs_level ON deployment_logs(level);
|
|
||||||
|
|
||||||
-- Comments for documentation
|
|
||||||
COMMENT ON TABLE deployments IS 'Deployment tracking for all project types (Docker, MCP, iOS/macOS, services)';
|
|
||||||
COMMENT ON COLUMN deployments.project_name IS 'Human-readable project name';
|
|
||||||
COMMENT ON COLUMN deployments.project_path IS 'Absolute filesystem path to project';
|
|
||||||
COMMENT ON COLUMN deployments.deployment_type IS 'Type of deployment (docker-compose, mcp-server, ios-macos-app, etc.)';
|
|
||||||
COMMENT ON COLUMN deployments.deployment_method IS 'Method used for deployment (doco-cd, agiliton-build, direct, manual)';
|
|
||||||
COMMENT ON COLUMN deployments.status IS 'Current deployment status';
|
|
||||||
COMMENT ON COLUMN deployments.duration_seconds IS 'Auto-calculated deployment duration in seconds';
|
|
||||||
COMMENT ON COLUMN deployments.metadata IS 'Extra deployment-specific data (runtime, host, build number, etc.)';
|
|
||||||
|
|
||||||
COMMENT ON TABLE deployment_logs IS 'Deployment log messages for debugging and audit trail';
|
|
||||||
COMMENT ON COLUMN deployment_logs.level IS 'Log level (debug, info, warn, error)';
|
|
||||||
COMMENT ON COLUMN deployment_logs.metadata IS 'Optional structured log metadata (source, context, etc.)';
|
|
||||||
20
migrations/033_content_hash_dedup.sql
Normal file
20
migrations/033_content_hash_dedup.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- CF-1314: Content hashing to prevent duplicate embeddings
|
||||||
|
-- Adds content_hash column to all embedding tables for dedup before API call
|
||||||
|
-- Adds source_id columns for future CF-1315 hybrid search
|
||||||
|
|
||||||
|
ALTER TABLE project_archives ADD COLUMN IF NOT EXISTS content_hash TEXT;
|
||||||
|
ALTER TABLE project_archives ADD COLUMN IF NOT EXISTS source_id TEXT;
|
||||||
|
ALTER TABLE memories ADD COLUMN IF NOT EXISTS content_hash TEXT;
|
||||||
|
ALTER TABLE memories ADD COLUMN IF NOT EXISTS source_id TEXT;
|
||||||
|
ALTER TABLE session_notes ADD COLUMN IF NOT EXISTS content_hash TEXT;
|
||||||
|
ALTER TABLE session_plans ADD COLUMN IF NOT EXISTS content_hash TEXT;
|
||||||
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS content_hash TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_archives_content_hash ON project_archives(content_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_content_hash ON memories(content_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_session_notes_content_hash ON session_notes(content_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_session_plans_content_hash ON session_plans(content_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_content_hash ON sessions(content_hash);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_archives_source_id ON project_archives(source_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_source_id ON memories(source_id);
|
||||||
53
migrations/034_hybrid_search.sql
Normal file
53
migrations/034_hybrid_search.sql
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
-- CF-1315: Hybrid search - tsvector columns, GIN indexes, triggers
|
||||||
|
|
||||||
|
-- 1. Add search_vector columns
|
||||||
|
ALTER TABLE project_archives ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
||||||
|
ALTER TABLE memories ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
||||||
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
||||||
|
|
||||||
|
-- 2. GIN indexes for fast full-text search
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_archives_search_vector ON project_archives USING gin(search_vector);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_search_vector ON memories USING gin(search_vector);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_search_vector ON sessions USING gin(search_vector);
|
||||||
|
|
||||||
|
-- 3. Triggers to auto-populate search_vector on INSERT/UPDATE
|
||||||
|
CREATE OR REPLACE FUNCTION update_archives_search_vector() RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.search_vector := to_tsvector('english', coalesce(NEW.title, '') || ' ' || coalesce(NEW.content, ''));
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_memories_search_vector() RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.search_vector := to_tsvector('english', coalesce(NEW.title, '') || ' ' || coalesce(NEW.content, ''));
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_sessions_search_vector() RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.search_vector := to_tsvector('english', coalesce(NEW.summary, ''));
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_archives_search_vector ON project_archives;
|
||||||
|
CREATE TRIGGER trg_archives_search_vector
|
||||||
|
BEFORE INSERT OR UPDATE OF title, content ON project_archives
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_archives_search_vector();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_memories_search_vector ON memories;
|
||||||
|
CREATE TRIGGER trg_memories_search_vector
|
||||||
|
BEFORE INSERT OR UPDATE OF title, content ON memories
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_memories_search_vector();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_sessions_search_vector ON sessions;
|
||||||
|
CREATE TRIGGER trg_sessions_search_vector
|
||||||
|
BEFORE INSERT OR UPDATE OF summary ON sessions
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_sessions_search_vector();
|
||||||
|
|
||||||
|
-- 4. Backfill existing rows (no-op if tables empty, safe to re-run)
|
||||||
|
UPDATE project_archives SET search_vector = to_tsvector('english', coalesce(title, '') || ' ' || coalesce(content, '')) WHERE search_vector IS NULL;
|
||||||
|
UPDATE memories SET search_vector = to_tsvector('english', coalesce(title, '') || ' ' || coalesce(content, '')) WHERE search_vector IS NULL;
|
||||||
|
UPDATE sessions SET search_vector = to_tsvector('english', coalesce(summary, '')) WHERE search_vector IS NULL AND summary IS NOT NULL;
|
||||||
7
migrations/035_extracted_metadata.sql
Normal file
7
migrations/035_extracted_metadata.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-- CF-1316: Add LLM-extracted metadata JSONB column for filtered retrieval
|
||||||
|
-- Schema: { topics: string[], decisions: string[], blockers: string[], tools_used: string[], projects: string[], issue_keys: string[] }
|
||||||
|
|
||||||
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS extracted_metadata JSONB;
|
||||||
|
|
||||||
|
-- GIN index for fast JSONB containment queries (@>)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_extracted_metadata ON sessions USING GIN(extracted_metadata);
|
||||||
3554
package-lock.json
generated
3554
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "session-mcp",
|
"name": "session-mcp",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "MCP server for session/memory/archive management with PostgreSQL/pgvector. Forked from task-mcp (CF-762).",
|
"description": "MCP server for session/memory/archive management with PostgreSQL/pgvector. Forked from task-mcp (CF-762).",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -8,19 +8,22 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
"clean": "rm -rf dist"
|
"clean": "rm -rf dist",
|
||||||
|
"start:http": "node dist/http-server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||||
"@sentry/node": "^9.47.1",
|
"@sentry/node": "^10.39.0",
|
||||||
"@sentry/profiling-node": "^10.37.0",
|
"@sentry/profiling-node": "^10.39.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"pg": "^8.11.3"
|
"pg": "^8.11.3",
|
||||||
|
"express": "^4.19.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
"@types/pg": "^8.10.9",
|
"@types/pg": "^8.10.9",
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3",
|
||||||
|
"@types/express": "^4.17.21"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
// Embeddings via LiteLLM API
|
// Embeddings via LiteLLM API
|
||||||
|
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate SHA-256 content hash for dedup before embedding API call (CF-1314)
|
||||||
|
*/
|
||||||
|
export function generateContentHash(text: string): string {
|
||||||
|
return createHash('sha256').update(text).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
interface EmbeddingResponse {
|
interface EmbeddingResponse {
|
||||||
data: Array<{
|
data: Array<{
|
||||||
embedding: number[];
|
embedding: number[];
|
||||||
@@ -58,3 +67,167 @@ export async function getEmbedding(text: string): Promise<number[] | null> {
|
|||||||
export function formatEmbedding(embedding: number[]): string {
|
export function formatEmbedding(embedding: number[]): string {
|
||||||
return `[${embedding.join(',')}]`;
|
return `[${embedding.join(',')}]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-encoder re-ranking via LiteLLM /rerank endpoint (CF-1317)
|
||||||
|
* Calls Cohere-compatible rerank API to reorder candidates by relevance.
|
||||||
|
* Returns null on failure (caller falls back to original order).
|
||||||
|
*/
|
||||||
|
export interface RerankResult {
|
||||||
|
index: number;
|
||||||
|
relevance_score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rerank(
|
||||||
|
query: string,
|
||||||
|
documents: string[],
|
||||||
|
topN?: number
|
||||||
|
): Promise<RerankResult[] | null> {
|
||||||
|
if (process.env.RERANK_ENABLED !== 'true') return null;
|
||||||
|
if (documents.length === 0) return null;
|
||||||
|
|
||||||
|
const LLM_API_URL = process.env.LLM_API_URL || 'https://api.agiliton.cloud/llm';
|
||||||
|
const LLM_API_KEY = process.env.LLM_API_KEY || '';
|
||||||
|
const model = process.env.RERANK_MODEL || 'rerank-v3.5';
|
||||||
|
|
||||||
|
if (!LLM_API_KEY) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${LLM_API_URL}/v1/rerank`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${LLM_API_KEY}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
query,
|
||||||
|
documents,
|
||||||
|
top_n: topN || documents.length,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Rerank API error:', response.status, await response.text());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as { results: RerankResult[] };
|
||||||
|
return data.results || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Rerank failed (falling back to RRF order):', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracted metadata schema (CF-1316)
|
||||||
|
*/
|
||||||
|
export interface ExtractedMetadata {
|
||||||
|
topics: string[];
|
||||||
|
decisions: string[];
|
||||||
|
blockers: string[];
|
||||||
|
tools_used: string[];
|
||||||
|
projects: string[];
|
||||||
|
issue_keys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract structured metadata from session content using a fast LLM (CF-1316)
|
||||||
|
* Uses first 8,000 chars of content for cost optimization.
|
||||||
|
* Returns null on failure (non-blocking — don't break embedding pipeline).
|
||||||
|
*/
|
||||||
|
export async function extractMetadata(content: string): Promise<ExtractedMetadata | null> {
|
||||||
|
const LLM_API_URL = process.env.LLM_API_URL || 'https://api.agiliton.cloud/llm';
|
||||||
|
const LLM_API_KEY = process.env.LLM_API_KEY || '';
|
||||||
|
const model = process.env.METADATA_EXTRACTION_MODEL || 'claude-haiku-4.5';
|
||||||
|
|
||||||
|
if (!LLM_API_KEY) return null;
|
||||||
|
|
||||||
|
// Truncate to first 8K chars (cost optimization from Agentic RAG Module 4)
|
||||||
|
const truncated = content.slice(0, 8000);
|
||||||
|
|
||||||
|
const systemPrompt = `Extract structured metadata from this session content. Return a JSON object with these fields:
|
||||||
|
- topics: Key technical topics discussed (e.g., "pgvector", "deployment", "authentication"). Max 10.
|
||||||
|
- decisions: Architecture or design decisions made (e.g., "Use RRF for hybrid search"). Max 5.
|
||||||
|
- blockers: Issues or blockers encountered (e.g., "Firecrawl connection refused"). Max 5.
|
||||||
|
- tools_used: Tools or commands used (e.g., "agiliton-deploy", "jira_create_issue"). Max 10.
|
||||||
|
- projects: Project keys mentioned (e.g., "CF", "BAB", "WF"). Max 5.
|
||||||
|
- issue_keys: Jira issue keys mentioned (e.g., "CF-1307", "BAB-42"). Max 10.
|
||||||
|
|
||||||
|
Return ONLY valid JSON. If a field has no matches, use an empty array [].`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${LLM_API_URL}/v1/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${LLM_API_KEY}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: truncated },
|
||||||
|
],
|
||||||
|
max_tokens: 1024,
|
||||||
|
temperature: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Metadata extraction API error:', response.status, await response.text());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as {
|
||||||
|
choices: Array<{ message: { content: string } }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const raw = data.choices?.[0]?.message?.content;
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
// Parse JSON from response (handle markdown code blocks)
|
||||||
|
const jsonStr = raw.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
|
||||||
|
const parsed = JSON.parse(jsonStr);
|
||||||
|
|
||||||
|
// Validate and normalize
|
||||||
|
return {
|
||||||
|
topics: Array.isArray(parsed.topics) ? parsed.topics.slice(0, 10) : [],
|
||||||
|
decisions: Array.isArray(parsed.decisions) ? parsed.decisions.slice(0, 5) : [],
|
||||||
|
blockers: Array.isArray(parsed.blockers) ? parsed.blockers.slice(0, 5) : [],
|
||||||
|
tools_used: Array.isArray(parsed.tools_used) ? parsed.tools_used.slice(0, 10) : [],
|
||||||
|
projects: Array.isArray(parsed.projects) ? parsed.projects.slice(0, 5) : [],
|
||||||
|
issue_keys: Array.isArray(parsed.issue_keys) ? parsed.issue_keys.slice(0, 10) : [],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Metadata extraction failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reciprocal Rank Fusion — merge two ranked result lists (CF-1315)
|
||||||
|
* @param vectorResults IDs ranked by vector similarity (best first)
|
||||||
|
* @param keywordResults IDs ranked by ts_rank (best first)
|
||||||
|
* @param k RRF parameter (default 60, standard)
|
||||||
|
* @returns Merged IDs sorted by RRF score descending
|
||||||
|
*/
|
||||||
|
export function rrfMerge(
|
||||||
|
vectorResults: (number | string)[],
|
||||||
|
keywordResults: (number | string)[],
|
||||||
|
k: number = 60
|
||||||
|
): { id: number | string; score: number }[] {
|
||||||
|
const scores = new Map<number | string, number>();
|
||||||
|
|
||||||
|
vectorResults.forEach((id, rank) => {
|
||||||
|
scores.set(id, (scores.get(id) || 0) + 1 / (k + rank + 1));
|
||||||
|
});
|
||||||
|
keywordResults.forEach((id, rank) => {
|
||||||
|
scores.set(id, (scores.get(id) || 0) + 1 / (k + rank + 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(scores.entries())
|
||||||
|
.map(([id, score]) => ({ id, score }))
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
}
|
||||||
|
|||||||
44
src/http-server.ts
Normal file
44
src/http-server.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { dirname, join } from "path";
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
dotenv.config({ path: join(__dirname, "..", ".env"), override: true });
|
||||||
|
import { initSentry } from "./sentry.js";
|
||||||
|
initSentry(process.env.SENTRY_ENVIRONMENT || "production");
|
||||||
|
import express from "express";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||||
|
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
|
import { testConnection, close } from "./db.js";
|
||||||
|
import { createServer } from "./server.js";
|
||||||
|
const PORT = parseInt(process.env.MCP_HTTP_PORT || "9216");
|
||||||
|
const HOST = process.env.MCP_HTTP_HOST || "0.0.0.0";
|
||||||
|
const transports = new Map<string, StreamableHTTPServerTransport>();
|
||||||
|
const sessionServers = new Map<string, Server>();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.post("/mcp", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sid0 = req.headers["mcp-session-id"] as string | undefined;
|
||||||
|
if (sid0 && transports.has(sid0)) { await transports.get(sid0)!.handleRequest(req, res, req.body); return; }
|
||||||
|
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: () => randomUUID(),
|
||||||
|
onsessioninitialized: (sid) => { transports.set(sid, transport); },
|
||||||
|
});
|
||||||
|
transport.onclose = () => { const sid = transport.sessionId; if (sid) { transports.delete(sid); sessionServers.delete(sid); } };
|
||||||
|
const ss = createServer(); await ss.connect(transport);
|
||||||
|
const sid = transport.sessionId; if (sid) sessionServers.set(sid, ss);
|
||||||
|
await transport.handleRequest(req, res, req.body);
|
||||||
|
} catch (err) { console.error("[session-mcp]", err); if (!res.headersSent) res.status(500).json({ error: "Internal" }); }
|
||||||
|
});
|
||||||
|
app.get("/mcp", async (req, res) => { const sid = req.headers["mcp-session-id"] as string|undefined; if(!sid||!transports.has(sid)){res.status(400).json({error:"bad"});return;} await transports.get(sid)!.handleRequest(req,res); });
|
||||||
|
app.delete("/mcp", async (req, res) => { const sid = req.headers["mcp-session-id"] as string|undefined; if(!sid||!transports.has(sid)){res.status(400).json({error:"bad"});return;} await transports.get(sid)!.handleRequest(req,res); });
|
||||||
|
app.get("/health", (_req, res) => res.json({ status: "ok", server: "session-mcp", activeSessions: transports.size }));
|
||||||
|
(async () => {
|
||||||
|
if (!(await testConnection())) { console.error("DB connect failed"); process.exit(1); }
|
||||||
|
app.listen(PORT, HOST, () => console.error(`session-mcp: HTTP on http://${HOST}:${PORT}/mcp`));
|
||||||
|
})();
|
||||||
|
process.on("SIGINT", async () => { await close(); process.exit(0); });
|
||||||
|
process.on("SIGTERM", async () => { await close(); process.exit(0); });
|
||||||
584
src/index.ts
584
src/index.ts
@@ -1,572 +1,22 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
import dotenv from "dotenv";
|
||||||
* Session MCP Server
|
import { fileURLToPath } from "url";
|
||||||
*
|
import { dirname, join } from "path";
|
||||||
* Forked from task-mcp (CF-762): Sessions, memory, archives, infrastructure.
|
|
||||||
* Task management now handled by Jira Cloud via mcp-atlassian.
|
|
||||||
*
|
|
||||||
* Uses PostgreSQL with pgvector for semantic search on sessions/memories.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Load environment variables from .env file
|
|
||||||
import dotenv from 'dotenv';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { dirname, join } from 'path';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
const envPath = join(__dirname, '..', '.env');
|
dotenv.config({ path: join(__dirname, "..", ".env"), override: true });
|
||||||
const result = dotenv.config({ path: envPath, override: true });
|
import { initSentry } from "./sentry.js";
|
||||||
|
initSentry(process.env.SENTRY_ENVIRONMENT || "production");
|
||||||
// Initialize Sentry for error tracking
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import { initSentry } from './sentry.js';
|
import { testConnection, close } from "./db.js";
|
||||||
initSentry(process.env.SENTRY_ENVIRONMENT || 'production');
|
import { createServer } from "./server.js";
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
console.error('Failed to load .env from:', envPath, result.error);
|
|
||||||
} else {
|
|
||||||
console.error('Loaded .env from:', envPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
||||||
import {
|
|
||||||
CallToolRequestSchema,
|
|
||||||
ListToolsRequestSchema,
|
|
||||||
} from '@modelcontextprotocol/sdk/types.js';
|
|
||||||
|
|
||||||
import { testConnection, close } from './db.js';
|
|
||||||
import { toolDefinitions } from './tools/index.js';
|
|
||||||
|
|
||||||
// Kept tools (sessions, memory, archives, infrastructure, docs, delegations, commits)
|
|
||||||
import { taskDelegations, taskDelegationQuery } from './tools/delegations.js';
|
|
||||||
import { projectLock, projectUnlock, projectLockStatus, projectContext } from './tools/locks.js';
|
|
||||||
import { taskCommitAdd, taskCommitRemove, taskCommitsList, taskLinkCommits, sessionTasks } from './tools/commits.js';
|
|
||||||
import { changelogAdd, changelogSinceSession, changelogList } from './tools/changelog.js';
|
|
||||||
import {
|
|
||||||
componentRegister,
|
|
||||||
componentList,
|
|
||||||
componentAddDependency,
|
|
||||||
componentAddFile,
|
|
||||||
componentAddCheck,
|
|
||||||
impactAnalysis,
|
|
||||||
impactLearn,
|
|
||||||
componentGraph,
|
|
||||||
} from './tools/impact.js';
|
|
||||||
import { memoryAdd, memorySearch, memoryList, memoryContext } from './tools/memories.js';
|
|
||||||
import { toolDocAdd, toolDocSearch, toolDocGet, toolDocList, toolDocExport } from './tools/tool-docs.js';
|
|
||||||
import {
|
|
||||||
sessionStart,
|
|
||||||
sessionUpdate,
|
|
||||||
sessionEnd,
|
|
||||||
sessionList,
|
|
||||||
sessionSearch,
|
|
||||||
sessionContext,
|
|
||||||
buildRecord,
|
|
||||||
sessionCommitLink,
|
|
||||||
sessionRecoverOrphaned,
|
|
||||||
sessionRecoverTempNotes,
|
|
||||||
} from './tools/sessions.js';
|
|
||||||
import {
|
|
||||||
sessionNoteAdd,
|
|
||||||
sessionNotesList,
|
|
||||||
sessionPlanSave,
|
|
||||||
sessionPlanUpdateStatus,
|
|
||||||
sessionPlanList,
|
|
||||||
projectDocUpsert,
|
|
||||||
projectDocGet,
|
|
||||||
projectDocList,
|
|
||||||
sessionDocumentationGenerate,
|
|
||||||
sessionSemanticSearch,
|
|
||||||
sessionProductivityAnalytics,
|
|
||||||
sessionPatternDetection,
|
|
||||||
} from './tools/session-docs.js';
|
|
||||||
import { archiveAdd, archiveSearch, archiveList, archiveGet } from './tools/archives.js';
|
|
||||||
import { projectArchive } from './tools/project-archive.js';
|
|
||||||
|
|
||||||
// Create MCP server
|
|
||||||
const server = new Server(
|
|
||||||
{ name: 'session-mcp', version: '1.0.0' },
|
|
||||||
{ capabilities: { tools: {} } }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Register tool list handler
|
|
||||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
||||||
tools: toolDefinitions,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Register tool call handler
|
|
||||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
||||||
const { name, arguments: args } = request.params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
let result: string;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const a = args as any;
|
|
||||||
|
|
||||||
switch (name) {
|
|
||||||
// Delegations
|
|
||||||
case 'task_delegations':
|
|
||||||
result = await taskDelegations({ task_id: a.task_id });
|
|
||||||
break;
|
|
||||||
case 'task_delegation_query':
|
|
||||||
result = await taskDelegationQuery({
|
|
||||||
status: a.status,
|
|
||||||
backend: a.backend,
|
|
||||||
limit: a.limit,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Project Locks
|
|
||||||
case 'project_lock':
|
|
||||||
result = await projectLock({
|
|
||||||
project: a.project,
|
|
||||||
session_id: a.session_id,
|
|
||||||
duration_minutes: a.duration_minutes,
|
|
||||||
reason: a.reason,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'project_unlock':
|
|
||||||
result = await projectUnlock({
|
|
||||||
project: a.project,
|
|
||||||
session_id: a.session_id,
|
|
||||||
force: a.force,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'project_lock_status':
|
|
||||||
result = await projectLockStatus({
|
|
||||||
project: a.project,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'project_context':
|
|
||||||
result = await projectContext();
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Commits
|
|
||||||
case 'task_commit_add':
|
|
||||||
result = await taskCommitAdd({
|
|
||||||
task_id: a.task_id,
|
|
||||||
commit_sha: a.commit_sha,
|
|
||||||
repo: a.repo,
|
|
||||||
source: a.source,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'task_commit_remove':
|
|
||||||
result = await taskCommitRemove({
|
|
||||||
task_id: a.task_id,
|
|
||||||
commit_sha: a.commit_sha,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'task_commits_list':
|
|
||||||
result = await taskCommitsList(a.task_id);
|
|
||||||
break;
|
|
||||||
case 'task_link_commits':
|
|
||||||
result = await taskLinkCommits({
|
|
||||||
repo: a.repo,
|
|
||||||
commits: a.commits,
|
|
||||||
dry_run: a.dry_run,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'session_tasks':
|
|
||||||
result = await sessionTasks({
|
|
||||||
session_id: a.session_id,
|
|
||||||
limit: a.limit,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Infrastructure Changelog
|
|
||||||
case 'changelog_add':
|
|
||||||
result = await changelogAdd(a as any);
|
|
||||||
break;
|
|
||||||
case 'changelog_since_session':
|
|
||||||
result = await changelogSinceSession(a as any);
|
|
||||||
break;
|
|
||||||
case 'changelog_list':
|
|
||||||
result = await changelogList(a.days_back, a.limit);
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Impact Analysis
|
|
||||||
case 'component_register':
|
|
||||||
result = JSON.stringify(await componentRegister(a.id, a.name, a.type, {
|
|
||||||
path: a.path,
|
|
||||||
repo: a.repo,
|
|
||||||
description: a.description,
|
|
||||||
health_check: a.health_check,
|
|
||||||
}), null, 2);
|
|
||||||
break;
|
|
||||||
case 'component_list':
|
|
||||||
result = JSON.stringify(await componentList(a.type), null, 2);
|
|
||||||
break;
|
|
||||||
case 'component_add_dependency':
|
|
||||||
result = JSON.stringify(await componentAddDependency(
|
|
||||||
a.component_id,
|
|
||||||
a.depends_on,
|
|
||||||
a.dependency_type,
|
|
||||||
a.description
|
|
||||||
), null, 2);
|
|
||||||
break;
|
|
||||||
case 'component_add_file':
|
|
||||||
result = JSON.stringify(await componentAddFile(a.component_id, a.file_pattern), null, 2);
|
|
||||||
break;
|
|
||||||
case 'component_add_check':
|
|
||||||
result = JSON.stringify(await componentAddCheck(a.component_id, a.name, a.check_type, a.check_command, {
|
|
||||||
expected_result: a.expected_result,
|
|
||||||
timeout_seconds: a.timeout_seconds,
|
|
||||||
}), null, 2);
|
|
||||||
break;
|
|
||||||
case 'impact_analysis':
|
|
||||||
result = JSON.stringify(await impactAnalysis(a.changed_files), null, 2);
|
|
||||||
break;
|
|
||||||
case 'impact_learn':
|
|
||||||
result = JSON.stringify(await impactLearn(
|
|
||||||
a.changed_component,
|
|
||||||
a.affected_component,
|
|
||||||
a.impact_description,
|
|
||||||
{ error_id: a.error_id, task_id: a.task_id }
|
|
||||||
), null, 2);
|
|
||||||
break;
|
|
||||||
case 'component_graph':
|
|
||||||
result = JSON.stringify(await componentGraph(a.component_id), null, 2);
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Memories
|
|
||||||
case 'memory_add':
|
|
||||||
result = await memoryAdd({
|
|
||||||
category: a.category,
|
|
||||||
title: a.title,
|
|
||||||
content: a.content,
|
|
||||||
context: a.context,
|
|
||||||
project: a.project,
|
|
||||||
session_id: a.session_id,
|
|
||||||
task_id: a.task_id,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'memory_search':
|
|
||||||
result = await memorySearch({
|
|
||||||
query: a.query,
|
|
||||||
project: a.project,
|
|
||||||
category: a.category,
|
|
||||||
limit: a.limit,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'memory_list':
|
|
||||||
result = await memoryList({
|
|
||||||
project: a.project,
|
|
||||||
category: a.category,
|
|
||||||
limit: a.limit,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'memory_context':
|
|
||||||
result = await memoryContext(a.project, a.task_description);
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Tool Documentation
|
|
||||||
case 'tool_doc_add':
|
|
||||||
result = await toolDocAdd({
|
|
||||||
tool_name: a.tool_name,
|
|
||||||
category: a.category,
|
|
||||||
title: a.title,
|
|
||||||
description: a.description,
|
|
||||||
usage_example: a.usage_example,
|
|
||||||
parameters: a.parameters,
|
|
||||||
notes: a.notes,
|
|
||||||
tags: a.tags,
|
|
||||||
source_file: a.source_file,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'tool_doc_search':
|
|
||||||
result = await toolDocSearch({
|
|
||||||
query: a.query,
|
|
||||||
category: a.category,
|
|
||||||
tags: a.tags,
|
|
||||||
limit: a.limit,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'tool_doc_get':
|
|
||||||
result = await toolDocGet({
|
|
||||||
tool_name: a.tool_name,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'tool_doc_list':
|
|
||||||
result = await toolDocList({
|
|
||||||
category: a.category,
|
|
||||||
tag: a.tag,
|
|
||||||
limit: a.limit,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'tool_doc_export':
|
|
||||||
result = await toolDocExport();
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Sessions
|
|
||||||
case 'session_start':
|
|
||||||
result = await sessionStart({
|
|
||||||
session_id: a.session_id,
|
|
||||||
project: a.project,
|
|
||||||
working_directory: a.working_directory,
|
|
||||||
git_branch: a.git_branch,
|
|
||||||
initial_prompt: a.initial_prompt,
|
|
||||||
jira_issue_key: a.jira_issue_key,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'session_update':
|
|
||||||
result = await sessionUpdate({
|
|
||||||
session_id: a.session_id,
|
|
||||||
message_count: a.message_count,
|
|
||||||
token_count: a.token_count,
|
|
||||||
tools_used: a.tools_used,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'session_end':
|
|
||||||
result = await sessionEnd({
|
|
||||||
session_id: a.session_id,
|
|
||||||
summary: a.summary,
|
|
||||||
status: a.status,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'session_list':
|
|
||||||
result = await sessionList({
|
|
||||||
project: a.project,
|
|
||||||
status: a.status,
|
|
||||||
since: a.since,
|
|
||||||
limit: a.limit,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'session_search':
|
|
||||||
result = await sessionSearch({
|
|
||||||
query: a.query,
|
|
||||||
project: a.project,
|
|
||||||
limit: a.limit,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'session_context':
|
|
||||||
result = await sessionContext(a.session_id);
|
|
||||||
break;
|
|
||||||
case 'build_record':
|
|
||||||
result = await buildRecord(
|
|
||||||
a.session_id,
|
|
||||||
a.version_id,
|
|
||||||
a.build_number,
|
|
||||||
a.git_commit_sha,
|
|
||||||
a.status,
|
|
||||||
a.started_at
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'session_commit_link':
|
|
||||||
result = await sessionCommitLink(
|
|
||||||
a.session_id,
|
|
||||||
a.commit_sha,
|
|
||||||
a.repo,
|
|
||||||
a.commit_message,
|
|
||||||
a.committed_at
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'session_recover_orphaned':
|
|
||||||
result = await sessionRecoverOrphaned({
|
|
||||||
project: a.project,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'session_recover_temp_notes':
|
|
||||||
result = await sessionRecoverTempNotes({
|
|
||||||
session_id: a.session_id,
|
|
||||||
temp_file_path: a.temp_file_path,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Session Documentation
|
|
||||||
case 'session_note_add':
|
|
||||||
result = await sessionNoteAdd({
|
|
||||||
session_id: a.session_id,
|
|
||||||
note_type: a.note_type,
|
|
||||||
content: a.content,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'session_notes_list':
|
|
||||||
result = JSON.stringify(
|
|
||||||
await sessionNotesList({
|
|
||||||
session_id: a.session_id,
|
|
||||||
note_type: a.note_type,
|
|
||||||
}),
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'session_plan_save':
|
|
||||||
result = await sessionPlanSave({
|
|
||||||
session_id: a.session_id,
|
|
||||||
plan_content: a.plan_content,
|
|
||||||
plan_file_name: a.plan_file_name,
|
|
||||||
status: a.status,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'session_plan_update_status':
|
|
||||||
result = await sessionPlanUpdateStatus({
|
|
||||||
plan_id: a.plan_id,
|
|
||||||
status: a.status,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'session_plan_list':
|
|
||||||
result = JSON.stringify(
|
|
||||||
await sessionPlanList({
|
|
||||||
session_id: a.session_id,
|
|
||||||
status: a.status,
|
|
||||||
}),
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'project_doc_upsert':
|
|
||||||
result = await projectDocUpsert({
|
|
||||||
project: a.project,
|
|
||||||
doc_type: a.doc_type,
|
|
||||||
title: a.title,
|
|
||||||
content: a.content,
|
|
||||||
session_id: a.session_id,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'project_doc_get':
|
|
||||||
result = JSON.stringify(
|
|
||||||
await projectDocGet({
|
|
||||||
project: a.project,
|
|
||||||
doc_type: a.doc_type,
|
|
||||||
}),
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'project_doc_list':
|
|
||||||
result = JSON.stringify(
|
|
||||||
await projectDocList({
|
|
||||||
project: a.project,
|
|
||||||
}),
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'session_documentation_generate':
|
|
||||||
result = await sessionDocumentationGenerate({
|
|
||||||
session_id: a.session_id,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'session_semantic_search':
|
|
||||||
result = JSON.stringify(
|
|
||||||
await sessionSemanticSearch({
|
|
||||||
query: a.query,
|
|
||||||
project: a.project,
|
|
||||||
limit: a.limit,
|
|
||||||
}),
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'session_productivity_analytics':
|
|
||||||
result = JSON.stringify(
|
|
||||||
await sessionProductivityAnalytics({
|
|
||||||
project: a.project,
|
|
||||||
time_period: a.time_period,
|
|
||||||
}),
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'session_pattern_detection':
|
|
||||||
result = JSON.stringify(
|
|
||||||
await sessionPatternDetection({
|
|
||||||
project: a.project,
|
|
||||||
pattern_type: a.pattern_type,
|
|
||||||
}),
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Archives
|
|
||||||
case 'archive_add':
|
|
||||||
result = await archiveAdd({
|
|
||||||
project: a.project,
|
|
||||||
archive_type: a.archive_type,
|
|
||||||
title: a.title,
|
|
||||||
content: a.content,
|
|
||||||
original_path: a.original_path,
|
|
||||||
file_size: a.file_size,
|
|
||||||
archived_by_session: a.archived_by_session,
|
|
||||||
metadata: a.metadata,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'archive_search':
|
|
||||||
result = await archiveSearch({
|
|
||||||
query: a.query,
|
|
||||||
project: a.project,
|
|
||||||
archive_type: a.archive_type,
|
|
||||||
limit: a.limit,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'archive_list':
|
|
||||||
result = await archiveList({
|
|
||||||
project: a.project,
|
|
||||||
archive_type: a.archive_type,
|
|
||||||
since: a.since,
|
|
||||||
limit: a.limit,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'archive_get':
|
|
||||||
result = await archiveGet({
|
|
||||||
id: a.id,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Project archival
|
|
||||||
case 'project_archive':
|
|
||||||
result = await projectArchive({
|
|
||||||
project_key: a.project_key,
|
|
||||||
project_path: a.project_path,
|
|
||||||
delete_local: a.delete_local,
|
|
||||||
session_id: a.session_id,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown tool: ${name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text', text: result }],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text', text: `Error: ${message}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Main entry point
|
|
||||||
async function main() {
|
async function main() {
|
||||||
process.on('SIGINT', async () => {
|
if (!(await testConnection())) { console.error("DB connect failed"); process.exit(1); }
|
||||||
await close();
|
const server = createServer();
|
||||||
process.exit(0);
|
const t = new StdioServerTransport();
|
||||||
});
|
await server.connect(t);
|
||||||
|
console.error("session-mcp: stdio started");
|
||||||
process.on('SIGTERM', async () => {
|
|
||||||
await close();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
const transport = new StdioServerTransport();
|
|
||||||
await server.connect(transport);
|
|
||||||
console.error('session-mcp: Server started');
|
|
||||||
|
|
||||||
testConnection().then((connected) => {
|
|
||||||
if (connected) {
|
|
||||||
console.error('session-mcp: Connected to database');
|
|
||||||
} else {
|
|
||||||
console.error('session-mcp: Warning - database not reachable, will retry on tool calls');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
main().catch(e => { console.error(e); process.exit(1); });
|
||||||
main().catch((error) => {
|
process.on("SIGINT", async () => { await close(); process.exit(0); });
|
||||||
console.error('session-mcp: Fatal error:', error);
|
process.on("SIGTERM", async () => { await close(); process.exit(0); });
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ export function initSentry(environment: string = "development"): void {
|
|||||||
|
|
||||||
return event;
|
return event;
|
||||||
},
|
},
|
||||||
|
enableLogs: true,
|
||||||
|
beforeSendLog(log) {
|
||||||
|
if (log.level === "debug") return null;
|
||||||
|
return log;
|
||||||
|
},
|
||||||
maxBreadcrumbs: 30,
|
maxBreadcrumbs: 30,
|
||||||
attachStacktrace: true,
|
attachStacktrace: true,
|
||||||
release: process.env.APP_VERSION || "unknown",
|
release: process.env.APP_VERSION || "unknown",
|
||||||
@@ -81,6 +86,18 @@ export function initSentry(environment: string = "development"): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function logInfo(msg: string, data?: Record<string, unknown>): void {
|
||||||
|
Sentry.logger.info(msg, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logWarn(msg: string, data?: Record<string, unknown>): void {
|
||||||
|
Sentry.logger.warn(msg, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logError(msg: string, data?: Record<string, unknown>): void {
|
||||||
|
Sentry.logger.error(msg, data);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap MCP tool handler with Sentry transaction tracking.
|
* Wrap MCP tool handler with Sentry transaction tracking.
|
||||||
*
|
*
|
||||||
|
|||||||
521
src/server.ts
Normal file
521
src/server.ts
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Session MCP Server
|
||||||
|
*
|
||||||
|
* Forked from task-mcp (CF-762): Sessions, memory, archives, infrastructure.
|
||||||
|
* Task management now handled by Jira Cloud via mcp-atlassian.
|
||||||
|
*
|
||||||
|
* Uses PostgreSQL with pgvector for semantic search on sessions/memories.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { withSentryTransaction } from "./sentry.js";
|
||||||
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
import {
|
||||||
|
CallToolRequestSchema,
|
||||||
|
ListToolsRequestSchema,
|
||||||
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
import { testConnection, close } from './db.js';
|
||||||
|
import { toolDefinitions } from './tools/index.js';
|
||||||
|
|
||||||
|
// Kept tools (sessions, archives, infrastructure, docs, delegations, commits)
|
||||||
|
import { taskDelegations, taskDelegationQuery } from './tools/delegations.js';
|
||||||
|
import { projectLock, projectUnlock, projectLockStatus, projectContext } from './tools/locks.js';
|
||||||
|
import { taskCommitAdd, taskCommitRemove, taskCommitsList, taskLinkCommits, sessionTasks } from './tools/commits.js';
|
||||||
|
import { changelogAdd, changelogSinceSession, changelogList } from './tools/changelog.js';
|
||||||
|
import { timeline } from './tools/timeline.js';
|
||||||
|
import {
|
||||||
|
componentRegister,
|
||||||
|
componentList,
|
||||||
|
componentAddDependency,
|
||||||
|
componentAddFile,
|
||||||
|
componentAddCheck,
|
||||||
|
impactAnalysis,
|
||||||
|
impactLearn,
|
||||||
|
componentGraph,
|
||||||
|
} from './tools/impact.js';
|
||||||
|
import { toolDocAdd, toolDocSearch, toolDocGet, toolDocList, toolDocExport } from './tools/tool-docs.js';
|
||||||
|
import {
|
||||||
|
sessionStart,
|
||||||
|
sessionUpdate,
|
||||||
|
sessionEnd,
|
||||||
|
sessionList,
|
||||||
|
sessionSearch,
|
||||||
|
sessionContext,
|
||||||
|
buildRecord,
|
||||||
|
sessionCommitLink,
|
||||||
|
sessionRecoverOrphaned,
|
||||||
|
sessionRecoverTempNotes,
|
||||||
|
} from './tools/sessions.js';
|
||||||
|
import {
|
||||||
|
sessionNoteAdd,
|
||||||
|
sessionNotesList,
|
||||||
|
sessionPlanSave,
|
||||||
|
sessionPlanUpdateStatus,
|
||||||
|
sessionPlanList,
|
||||||
|
projectDocUpsert,
|
||||||
|
projectDocGet,
|
||||||
|
projectDocList,
|
||||||
|
sessionDocumentationGenerate,
|
||||||
|
sessionSemanticSearch,
|
||||||
|
sessionProductivityAnalytics,
|
||||||
|
sessionPatternDetection,
|
||||||
|
} from './tools/session-docs.js';
|
||||||
|
import { archiveAdd, archiveSearch, archiveList, archiveGet } from './tools/archives.js';
|
||||||
|
import { transcriptSearch } from './tools/transcripts.js';
|
||||||
|
import { projectArchive } from './tools/project-archive.js';
|
||||||
|
|
||||||
|
// Create MCP server
|
||||||
|
|
||||||
|
export function createServer(): Server {
|
||||||
|
const server = new Server(
|
||||||
|
{ name: 'session-mcp', version: '1.0.0' },
|
||||||
|
{ capabilities: { tools: {} } }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register tool list handler
|
||||||
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||||
|
tools: toolDefinitions,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Register tool call handler
|
||||||
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
const { name, arguments: args } = request.params;
|
||||||
|
|
||||||
|
return withSentryTransaction(name, async () => {
|
||||||
|
try {
|
||||||
|
let result: string;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const a = args as any;
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
// Delegations
|
||||||
|
case 'task_delegations':
|
||||||
|
result = await taskDelegations({ task_id: a.task_id });
|
||||||
|
break;
|
||||||
|
case 'task_delegation_query':
|
||||||
|
result = await taskDelegationQuery({
|
||||||
|
status: a.status,
|
||||||
|
backend: a.backend,
|
||||||
|
limit: a.limit,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Project Locks
|
||||||
|
case 'project_lock':
|
||||||
|
result = await projectLock({
|
||||||
|
project: a.project,
|
||||||
|
session_id: a.session_id,
|
||||||
|
duration_minutes: a.duration_minutes,
|
||||||
|
reason: a.reason,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'project_unlock':
|
||||||
|
result = await projectUnlock({
|
||||||
|
project: a.project,
|
||||||
|
session_id: a.session_id,
|
||||||
|
force: a.force,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'project_lock_status':
|
||||||
|
result = await projectLockStatus({
|
||||||
|
project: a.project,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'project_context':
|
||||||
|
result = await projectContext();
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Commits
|
||||||
|
case 'task_commit_add':
|
||||||
|
result = await taskCommitAdd({
|
||||||
|
task_id: a.task_id,
|
||||||
|
commit_sha: a.commit_sha,
|
||||||
|
repo: a.repo,
|
||||||
|
source: a.source,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'task_commit_remove':
|
||||||
|
result = await taskCommitRemove({
|
||||||
|
task_id: a.task_id,
|
||||||
|
commit_sha: a.commit_sha,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'task_commits_list':
|
||||||
|
result = await taskCommitsList(a.task_id);
|
||||||
|
break;
|
||||||
|
case 'task_link_commits':
|
||||||
|
result = await taskLinkCommits({
|
||||||
|
repo: a.repo,
|
||||||
|
commits: a.commits,
|
||||||
|
dry_run: a.dry_run,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'session_tasks':
|
||||||
|
result = await sessionTasks({
|
||||||
|
session_id: a.session_id,
|
||||||
|
limit: a.limit,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Infrastructure Changelog
|
||||||
|
case 'changelog_add':
|
||||||
|
result = await changelogAdd(a as any);
|
||||||
|
break;
|
||||||
|
case 'changelog_since_session':
|
||||||
|
result = await changelogSinceSession(a as any);
|
||||||
|
break;
|
||||||
|
case 'changelog_list':
|
||||||
|
result = await changelogList(a.days_back, a.limit);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Event Timeline (CF-2885)
|
||||||
|
case 'timeline':
|
||||||
|
result = await timeline(a as any);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Impact Analysis
|
||||||
|
case 'component_register':
|
||||||
|
result = JSON.stringify(await componentRegister(a.id, a.name, a.type, {
|
||||||
|
path: a.path,
|
||||||
|
repo: a.repo,
|
||||||
|
description: a.description,
|
||||||
|
health_check: a.health_check,
|
||||||
|
}), null, 2);
|
||||||
|
break;
|
||||||
|
case 'component_list':
|
||||||
|
result = JSON.stringify(await componentList(a.type), null, 2);
|
||||||
|
break;
|
||||||
|
case 'component_add_dependency':
|
||||||
|
result = JSON.stringify(await componentAddDependency(
|
||||||
|
a.component_id,
|
||||||
|
a.depends_on,
|
||||||
|
a.dependency_type,
|
||||||
|
a.description
|
||||||
|
), null, 2);
|
||||||
|
break;
|
||||||
|
case 'component_add_file':
|
||||||
|
result = JSON.stringify(await componentAddFile(a.component_id, a.file_pattern), null, 2);
|
||||||
|
break;
|
||||||
|
case 'component_add_check':
|
||||||
|
result = JSON.stringify(await componentAddCheck(a.component_id, a.name, a.check_type, a.check_command, {
|
||||||
|
expected_result: a.expected_result,
|
||||||
|
timeout_seconds: a.timeout_seconds,
|
||||||
|
}), null, 2);
|
||||||
|
break;
|
||||||
|
case 'impact_analysis':
|
||||||
|
result = JSON.stringify(await impactAnalysis(a.changed_files), null, 2);
|
||||||
|
break;
|
||||||
|
case 'impact_learn':
|
||||||
|
result = JSON.stringify(await impactLearn(
|
||||||
|
a.changed_component,
|
||||||
|
a.affected_component,
|
||||||
|
a.impact_description,
|
||||||
|
{ error_id: a.error_id, task_id: a.task_id }
|
||||||
|
), null, 2);
|
||||||
|
break;
|
||||||
|
case 'component_graph':
|
||||||
|
result = JSON.stringify(await componentGraph(a.component_id), null, 2);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Tool Documentation
|
||||||
|
case 'tool_doc_add':
|
||||||
|
result = await toolDocAdd({
|
||||||
|
tool_name: a.tool_name,
|
||||||
|
category: a.category,
|
||||||
|
title: a.title,
|
||||||
|
description: a.description,
|
||||||
|
usage_example: a.usage_example,
|
||||||
|
parameters: a.parameters,
|
||||||
|
notes: a.notes,
|
||||||
|
tags: a.tags,
|
||||||
|
source_file: a.source_file,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'tool_doc_search':
|
||||||
|
result = await toolDocSearch({
|
||||||
|
query: a.query,
|
||||||
|
category: a.category,
|
||||||
|
tags: a.tags,
|
||||||
|
limit: a.limit,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'tool_doc_get':
|
||||||
|
result = await toolDocGet({
|
||||||
|
tool_name: a.tool_name,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'tool_doc_list':
|
||||||
|
result = await toolDocList({
|
||||||
|
category: a.category,
|
||||||
|
tag: a.tag,
|
||||||
|
limit: a.limit,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'tool_doc_export':
|
||||||
|
result = await toolDocExport();
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Sessions
|
||||||
|
case 'session_start':
|
||||||
|
result = await sessionStart({
|
||||||
|
session_id: a.session_id,
|
||||||
|
project: a.project,
|
||||||
|
working_directory: a.working_directory,
|
||||||
|
git_branch: a.git_branch,
|
||||||
|
initial_prompt: a.initial_prompt,
|
||||||
|
jira_issue_key: a.jira_issue_key,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'session_update':
|
||||||
|
result = await sessionUpdate({
|
||||||
|
session_id: a.session_id,
|
||||||
|
message_count: a.message_count,
|
||||||
|
token_count: a.token_count,
|
||||||
|
tools_used: a.tools_used,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'session_end':
|
||||||
|
result = await sessionEnd({
|
||||||
|
session_id: a.session_id,
|
||||||
|
summary: a.summary,
|
||||||
|
status: a.status,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'session_list':
|
||||||
|
result = await sessionList({
|
||||||
|
project: a.project,
|
||||||
|
status: a.status,
|
||||||
|
since: a.since,
|
||||||
|
limit: a.limit,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'session_search':
|
||||||
|
result = await sessionSearch({
|
||||||
|
query: a.query,
|
||||||
|
project: a.project,
|
||||||
|
limit: a.limit,
|
||||||
|
search_mode: a.search_mode,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'session_context':
|
||||||
|
result = await sessionContext(a.session_id);
|
||||||
|
break;
|
||||||
|
case 'build_record':
|
||||||
|
result = await buildRecord(
|
||||||
|
a.session_id,
|
||||||
|
a.version_id,
|
||||||
|
a.build_number,
|
||||||
|
a.git_commit_sha,
|
||||||
|
a.status,
|
||||||
|
a.started_at
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'session_commit_link':
|
||||||
|
result = await sessionCommitLink(
|
||||||
|
a.session_id,
|
||||||
|
a.commit_sha,
|
||||||
|
a.repo,
|
||||||
|
a.commit_message,
|
||||||
|
a.committed_at
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'session_recover_orphaned':
|
||||||
|
result = await sessionRecoverOrphaned({
|
||||||
|
project: a.project,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'session_recover_temp_notes':
|
||||||
|
result = await sessionRecoverTempNotes({
|
||||||
|
session_id: a.session_id,
|
||||||
|
temp_file_path: a.temp_file_path,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Session Documentation
|
||||||
|
case 'session_note_add':
|
||||||
|
result = await sessionNoteAdd({
|
||||||
|
session_id: a.session_id,
|
||||||
|
note_type: a.note_type,
|
||||||
|
content: a.content,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'session_notes_list':
|
||||||
|
result = JSON.stringify(
|
||||||
|
await sessionNotesList({
|
||||||
|
session_id: a.session_id,
|
||||||
|
note_type: a.note_type,
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'session_plan_save':
|
||||||
|
result = await sessionPlanSave({
|
||||||
|
session_id: a.session_id,
|
||||||
|
plan_content: a.plan_content,
|
||||||
|
plan_file_name: a.plan_file_name,
|
||||||
|
status: a.status,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'session_plan_update_status':
|
||||||
|
result = await sessionPlanUpdateStatus({
|
||||||
|
plan_id: a.plan_id,
|
||||||
|
status: a.status,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'session_plan_list':
|
||||||
|
result = JSON.stringify(
|
||||||
|
await sessionPlanList({
|
||||||
|
session_id: a.session_id,
|
||||||
|
status: a.status,
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'project_doc_upsert':
|
||||||
|
result = await projectDocUpsert({
|
||||||
|
project: a.project,
|
||||||
|
doc_type: a.doc_type,
|
||||||
|
title: a.title,
|
||||||
|
content: a.content,
|
||||||
|
session_id: a.session_id,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'project_doc_get':
|
||||||
|
result = JSON.stringify(
|
||||||
|
await projectDocGet({
|
||||||
|
project: a.project,
|
||||||
|
doc_type: a.doc_type,
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'project_doc_list':
|
||||||
|
result = JSON.stringify(
|
||||||
|
await projectDocList({
|
||||||
|
project: a.project,
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'session_documentation_generate':
|
||||||
|
result = await sessionDocumentationGenerate({
|
||||||
|
session_id: a.session_id,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'session_semantic_search':
|
||||||
|
result = JSON.stringify(
|
||||||
|
await sessionSemanticSearch({
|
||||||
|
query: a.query,
|
||||||
|
project: a.project,
|
||||||
|
limit: a.limit,
|
||||||
|
search_mode: a.search_mode,
|
||||||
|
filter_topics: a.filter_topics,
|
||||||
|
filter_projects: a.filter_projects,
|
||||||
|
filter_issue_keys: a.filter_issue_keys,
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'session_productivity_analytics':
|
||||||
|
result = JSON.stringify(
|
||||||
|
await sessionProductivityAnalytics({
|
||||||
|
project: a.project,
|
||||||
|
time_period: a.time_period,
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'session_pattern_detection':
|
||||||
|
result = JSON.stringify(
|
||||||
|
await sessionPatternDetection({
|
||||||
|
project: a.project,
|
||||||
|
pattern_type: a.pattern_type,
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Transcripts (CF-2394)
|
||||||
|
case 'session_transcript_search':
|
||||||
|
result = await transcriptSearch({
|
||||||
|
query: a.query,
|
||||||
|
project: a.project,
|
||||||
|
session_issue_key: a.session_issue_key,
|
||||||
|
limit: a.limit,
|
||||||
|
search_mode: a.search_mode,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Archives
|
||||||
|
case 'archive_add':
|
||||||
|
result = await archiveAdd({
|
||||||
|
project: a.project,
|
||||||
|
archive_type: a.archive_type,
|
||||||
|
title: a.title,
|
||||||
|
content: a.content,
|
||||||
|
original_path: a.original_path,
|
||||||
|
file_size: a.file_size,
|
||||||
|
archived_by_session: a.archived_by_session,
|
||||||
|
metadata: a.metadata,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'archive_search':
|
||||||
|
result = await archiveSearch({
|
||||||
|
query: a.query,
|
||||||
|
project: a.project,
|
||||||
|
archive_type: a.archive_type,
|
||||||
|
limit: a.limit,
|
||||||
|
search_mode: a.search_mode,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'archive_list':
|
||||||
|
result = await archiveList({
|
||||||
|
project: a.project,
|
||||||
|
archive_type: a.archive_type,
|
||||||
|
since: a.since,
|
||||||
|
limit: a.limit,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'archive_get':
|
||||||
|
result = await archiveGet({
|
||||||
|
id: a.id,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Project archival
|
||||||
|
case 'project_archive':
|
||||||
|
result = await projectArchive({
|
||||||
|
project_key: a.project_key,
|
||||||
|
project_path: a.project_path,
|
||||||
|
delete_local: a.delete_local,
|
||||||
|
session_id: a.session_id,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown tool: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: result }],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Error: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Jira Cloud REST API client for session-mcp.
|
* Jira Cloud REST API client — routes through AgilitonAPI gateway.
|
||||||
* Creates/closes CF issues for sessions and posts session output as comments.
|
* Falls back to direct Jira access if AGILITON_API_KEY is not set.
|
||||||
*
|
*
|
||||||
* Uses JIRA_URL, JIRA_USERNAME, JIRA_API_TOKEN env vars.
|
* Gateway: AGILITON_API_KEY + AGILITON_API_URL
|
||||||
|
* Direct: JIRA_URL, JIRA_USERNAME, JIRA_API_TOKEN
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface JiraIssue {
|
interface JiraIssue {
|
||||||
@@ -16,35 +17,48 @@ interface JiraTransition {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getConfig = () => ({
|
// Gateway config
|
||||||
url: process.env.JIRA_URL || 'https://agiliton.atlassian.net',
|
const GATEWAY_URL = (process.env.AGILITON_API_URL || 'https://api.agiliton.cloud').replace(/\/$/, '');
|
||||||
username: process.env.JIRA_USERNAME || '',
|
const GATEWAY_KEY = process.env.AGILITON_API_KEY || '';
|
||||||
token: process.env.JIRA_API_TOKEN || '',
|
|
||||||
});
|
|
||||||
|
|
||||||
function getAuthHeader(): string {
|
// Direct config (fallback)
|
||||||
const { username, token } = getConfig();
|
const JIRA_URL = process.env.JIRA_URL || 'https://agiliton.atlassian.net';
|
||||||
return `Basic ${Buffer.from(`${username}:${token}`).toString('base64')}`;
|
const JIRA_USERNAME = process.env.JIRA_USERNAME || '';
|
||||||
|
const JIRA_API_TOKEN = process.env.JIRA_API_TOKEN || '';
|
||||||
|
|
||||||
|
function useGateway(): boolean {
|
||||||
|
return !!GATEWAY_KEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isConfigured(): boolean {
|
function isConfigured(): boolean {
|
||||||
const { username, token } = getConfig();
|
if (useGateway()) return true;
|
||||||
return !!(username && token);
|
return !!(JIRA_USERNAME && JIRA_API_TOKEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function jiraFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
async function jiraFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||||
const { url } = getConfig();
|
let url: string;
|
||||||
const fullUrl = `${url}/rest/api/3${path}`;
|
let headers: Record<string, string>;
|
||||||
|
|
||||||
return fetch(fullUrl, {
|
if (useGateway()) {
|
||||||
...options,
|
url = `${GATEWAY_URL}/jira-cloud${path}`;
|
||||||
headers: {
|
headers = {
|
||||||
'Authorization': getAuthHeader(),
|
'X-API-Key': GATEWAY_KEY,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
...options.headers,
|
...options.headers as Record<string, string>,
|
||||||
},
|
};
|
||||||
});
|
} else {
|
||||||
|
url = `${JIRA_URL}/rest/api/3${path}`;
|
||||||
|
const auth = Buffer.from(`${JIRA_USERNAME}:${JIRA_API_TOKEN}`).toString('base64');
|
||||||
|
headers = {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
...options.headers as Record<string, string>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(url, { ...options, headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -165,7 +179,6 @@ export async function transitionToDone(issueKey: string): Promise<boolean> {
|
|||||||
if (!isConfigured()) return false;
|
if (!isConfigured()) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get available transitions
|
|
||||||
const transResponse = await jiraFetch(`/issue/${issueKey}/transitions`);
|
const transResponse = await jiraFetch(`/issue/${issueKey}/transitions`);
|
||||||
if (!transResponse.ok) {
|
if (!transResponse.ok) {
|
||||||
console.error(`session-mcp: Jira get transitions failed (${transResponse.status})`);
|
console.error(`session-mcp: Jira get transitions failed (${transResponse.status})`);
|
||||||
@@ -182,7 +195,6 @@ export async function transitionToDone(issueKey: string): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute transition
|
|
||||||
const response = await jiraFetch(`/issue/${issueKey}/transitions`, {
|
const response = await jiraFetch(`/issue/${issueKey}/transitions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -243,6 +255,156 @@ export async function updateIssueDescription(issueKey: string, description: stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Read operations (CF-2885 timeline tool) ----------
|
||||||
|
|
||||||
|
export interface JiraChangelogEntry {
|
||||||
|
ts: string; // ISO8601
|
||||||
|
author: string;
|
||||||
|
field: string; // e.g. "status", "assignee", "labels"
|
||||||
|
from: string | null;
|
||||||
|
to: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JiraComment {
|
||||||
|
id: string;
|
||||||
|
ts: string;
|
||||||
|
author: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JiraIssueHistory {
|
||||||
|
key: string;
|
||||||
|
summary: string;
|
||||||
|
status: string;
|
||||||
|
issueType: string;
|
||||||
|
created: string;
|
||||||
|
creator: string;
|
||||||
|
labels: string[];
|
||||||
|
parent?: string;
|
||||||
|
linkedIssues: Array<{ key: string; type: string; direction: 'in' | 'out' }>;
|
||||||
|
changelog: JiraChangelogEntry[];
|
||||||
|
comments: JiraComment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function adfToPlainText(adf: unknown): string {
|
||||||
|
// Minimal Atlassian Document Format → plain text extractor.
|
||||||
|
if (!adf || typeof adf !== 'object') return '';
|
||||||
|
const node = adf as { type?: string; text?: string; content?: unknown[] };
|
||||||
|
if (node.text) return node.text;
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
return node.content.map(c => adfToPlainText(c)).join(' ').trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a Jira issue with full changelog and comments. Returns null on failure.
|
||||||
|
*/
|
||||||
|
export async function getIssueWithHistory(issueKey: string): Promise<JiraIssueHistory | null> {
|
||||||
|
if (!isConfigured()) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Issue with changelog expansion
|
||||||
|
const issueResp = await jiraFetch(`/issue/${issueKey}?expand=changelog&fields=summary,status,issuetype,created,creator,labels,parent,issuelinks`);
|
||||||
|
if (!issueResp.ok) {
|
||||||
|
console.error(`session-mcp: Jira get issue failed (${issueResp.status}) for ${issueKey}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const issue = await issueResp.json() as any;
|
||||||
|
|
||||||
|
// Comments (separate endpoint for full data)
|
||||||
|
const commentsResp = await jiraFetch(`/issue/${issueKey}/comment?orderBy=created`);
|
||||||
|
const commentsJson = commentsResp.ok ? await commentsResp.json() as any : { comments: [] };
|
||||||
|
|
||||||
|
// Parse changelog histories → flat entries
|
||||||
|
const changelog: JiraChangelogEntry[] = [];
|
||||||
|
const histories = issue.changelog?.histories || [];
|
||||||
|
for (const h of histories) {
|
||||||
|
const author = h.author?.displayName || h.author?.emailAddress || 'unknown';
|
||||||
|
const ts = h.created;
|
||||||
|
for (const item of (h.items || [])) {
|
||||||
|
changelog.push({
|
||||||
|
ts,
|
||||||
|
author,
|
||||||
|
field: item.field,
|
||||||
|
from: item.fromString || item.from || null,
|
||||||
|
to: item.toString || item.to || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse comments
|
||||||
|
const comments: JiraComment[] = (commentsJson.comments || []).map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
ts: c.created,
|
||||||
|
author: c.author?.displayName || c.author?.emailAddress || 'unknown',
|
||||||
|
body: adfToPlainText(c.body),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Parse linked issues
|
||||||
|
const linkedIssues: Array<{ key: string; type: string; direction: 'in' | 'out' }> = [];
|
||||||
|
for (const link of (issue.fields?.issuelinks || [])) {
|
||||||
|
if (link.outwardIssue) {
|
||||||
|
linkedIssues.push({
|
||||||
|
key: link.outwardIssue.key,
|
||||||
|
type: link.type?.outward || 'relates to',
|
||||||
|
direction: 'out',
|
||||||
|
});
|
||||||
|
} else if (link.inwardIssue) {
|
||||||
|
linkedIssues.push({
|
||||||
|
key: link.inwardIssue.key,
|
||||||
|
type: link.type?.inward || 'relates to',
|
||||||
|
direction: 'in',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: issue.key,
|
||||||
|
summary: issue.fields?.summary || '',
|
||||||
|
status: issue.fields?.status?.name || '',
|
||||||
|
issueType: issue.fields?.issuetype?.name || '',
|
||||||
|
created: issue.fields?.created || '',
|
||||||
|
creator: issue.fields?.creator?.displayName || issue.fields?.creator?.emailAddress || 'unknown',
|
||||||
|
labels: issue.fields?.labels || [],
|
||||||
|
parent: issue.fields?.parent?.key,
|
||||||
|
linkedIssues,
|
||||||
|
changelog,
|
||||||
|
comments,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('session-mcp: Jira get issue history error:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for issues via JQL. Returns array of issue keys (minimal projection).
|
||||||
|
*/
|
||||||
|
export async function searchIssueKeys(jql: string, limit: number = 50): Promise<string[]> {
|
||||||
|
if (!isConfigured()) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
jql,
|
||||||
|
fields: 'summary',
|
||||||
|
maxResults: String(limit),
|
||||||
|
});
|
||||||
|
const response = await jiraFetch(`/search/jql?${params.toString()}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`session-mcp: Jira search failed (${response.status})`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const data = await response.json() as any;
|
||||||
|
return (data.issues || []).map((i: any) => i.key);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('session-mcp: Jira search error:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Write operations ----------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Link two Jira issues.
|
* Link two Jira issues.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Project archives operations for database-backed archival
|
// Project archives operations for database-backed archival
|
||||||
|
|
||||||
import { query, queryOne, execute } from '../db.js';
|
import { query, queryOne, execute } from '../db.js';
|
||||||
import { getEmbedding, formatEmbedding } from '../embeddings.js';
|
import { getEmbedding, formatEmbedding, generateContentHash, rrfMerge, rerank } from '../embeddings.js';
|
||||||
|
|
||||||
type ArchiveType = 'session' | 'research' | 'audit' | 'investigation' | 'completed' | 'migration';
|
type ArchiveType = 'session' | 'research' | 'audit' | 'investigation' | 'completed' | 'migration';
|
||||||
|
|
||||||
@@ -31,11 +31,14 @@ interface ArchiveAddArgs {
|
|||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SearchMode = 'hybrid' | 'vector' | 'keyword';
|
||||||
|
|
||||||
interface ArchiveSearchArgs {
|
interface ArchiveSearchArgs {
|
||||||
query: string;
|
query: string;
|
||||||
project?: string;
|
project?: string;
|
||||||
archive_type?: ArchiveType;
|
archive_type?: ArchiveType;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
search_mode?: SearchMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ArchiveListArgs {
|
interface ArchiveListArgs {
|
||||||
@@ -72,16 +75,26 @@ export async function archiveAdd(args: ArchiveAddArgs): Promise<string> {
|
|||||||
return `Error: Project not found: ${project}`;
|
return `Error: Project not found: ${project}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CF-1314: Hash content for dedup before embedding API call
|
||||||
|
const embedText = `${title}. ${content.substring(0, 1000)}`;
|
||||||
|
const contentHash = generateContentHash(embedText);
|
||||||
|
|
||||||
|
const existing = await queryOne<{ id: number }>(
|
||||||
|
'SELECT id FROM project_archives WHERE content_hash = $1 AND project_key = $2 LIMIT 1',
|
||||||
|
[contentHash, project]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
return `Archive already exists (id: ${existing.id}): [${archive_type}] ${title}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Generate embedding for semantic search
|
// Generate embedding for semantic search
|
||||||
const embedText = `${title}. ${content.substring(0, 1000)}`; // Limit content length for embedding
|
|
||||||
const embedding = await getEmbedding(embedText);
|
const embedding = await getEmbedding(embedText);
|
||||||
const embeddingValue = embedding ? formatEmbedding(embedding) : null;
|
const embeddingValue = embedding ? formatEmbedding(embedding) : null;
|
||||||
|
|
||||||
if (embeddingValue) {
|
|
||||||
await execute(
|
await execute(
|
||||||
`INSERT INTO project_archives
|
`INSERT INTO project_archives
|
||||||
(project_key, archive_type, title, content, original_path, file_size, archived_by_session, metadata, embedding)
|
(project_key, archive_type, title, content, original_path, file_size, archived_by_session, metadata, embedding, content_hash)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||||
[
|
[
|
||||||
project,
|
project,
|
||||||
archive_type,
|
archive_type,
|
||||||
@@ -91,123 +104,140 @@ export async function archiveAdd(args: ArchiveAddArgs): Promise<string> {
|
|||||||
file_size || null,
|
file_size || null,
|
||||||
archived_by_session || null,
|
archived_by_session || null,
|
||||||
JSON.stringify(metadata || {}),
|
JSON.stringify(metadata || {}),
|
||||||
embeddingValue
|
embeddingValue,
|
||||||
|
contentHash
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
await execute(
|
|
||||||
`INSERT INTO project_archives
|
|
||||||
(project_key, archive_type, title, content, original_path, file_size, archived_by_session, metadata)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
||||||
[
|
|
||||||
project,
|
|
||||||
archive_type,
|
|
||||||
title,
|
|
||||||
content,
|
|
||||||
original_path || null,
|
|
||||||
file_size || null,
|
|
||||||
archived_by_session || null,
|
|
||||||
JSON.stringify(metadata || {})
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sizeStr = file_size ? ` (${Math.round(file_size / 1024)}KB)` : '';
|
const sizeStr = file_size ? ` (${Math.round(file_size / 1024)}KB)` : '';
|
||||||
return `Archived: [${archive_type}] ${title}${sizeStr}`;
|
return `Archived: [${archive_type}] ${title}${sizeStr}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search archives semantically
|
* Search archives with hybrid (vector + keyword), vector-only, or keyword-only mode (CF-1315)
|
||||||
*/
|
*/
|
||||||
export async function archiveSearch(args: ArchiveSearchArgs): Promise<string> {
|
export async function archiveSearch(args: ArchiveSearchArgs): Promise<string> {
|
||||||
const { query: searchQuery, project, archive_type, limit = 5 } = args;
|
const { query: searchQuery, project, archive_type, limit = 5, search_mode = 'hybrid' } = args;
|
||||||
|
|
||||||
// Generate embedding for search
|
|
||||||
const embedding = await getEmbedding(searchQuery);
|
|
||||||
|
|
||||||
// Fallback to text search if embeddings unavailable
|
|
||||||
if (!embedding) {
|
|
||||||
console.warn('Embeddings unavailable, falling back to text search');
|
|
||||||
|
|
||||||
let whereClause = '(title ILIKE $1 OR content ILIKE $1)';
|
|
||||||
const params: unknown[] = [`%${searchQuery}%`];
|
|
||||||
let paramIndex = 2;
|
|
||||||
|
|
||||||
|
// Build shared filter clause
|
||||||
|
const buildFilter = (startIdx: number) => {
|
||||||
|
let where = '';
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let idx = startIdx;
|
||||||
if (project) {
|
if (project) {
|
||||||
whereClause += ` AND project_key = $${paramIndex++}`;
|
where += ` AND project_key = $${idx++}`;
|
||||||
params.push(project);
|
params.push(project);
|
||||||
}
|
}
|
||||||
if (archive_type) {
|
if (archive_type) {
|
||||||
whereClause += ` AND archive_type = $${paramIndex++}`;
|
where += ` AND archive_type = $${idx++}`;
|
||||||
params.push(archive_type);
|
params.push(archive_type);
|
||||||
}
|
}
|
||||||
|
return { where, params, nextIdx: idx };
|
||||||
|
};
|
||||||
|
|
||||||
params.push(limit);
|
// Vector search
|
||||||
|
let vectorIds: number[] = [];
|
||||||
|
let vectorRows: Map<number, Archive & { similarity: number }> = new Map();
|
||||||
|
let embeddingFailed = false;
|
||||||
|
|
||||||
const archives = await query<Archive>(
|
if (search_mode !== 'keyword') {
|
||||||
`SELECT id, archive_type, title, original_path, file_size,
|
const embedding = await getEmbedding(searchQuery);
|
||||||
to_char(archived_at, 'YYYY-MM-DD') as archived_at
|
if (embedding) {
|
||||||
FROM project_archives
|
|
||||||
WHERE ${whereClause}
|
|
||||||
ORDER BY archived_at DESC
|
|
||||||
LIMIT $${paramIndex}`,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
|
|
||||||
if (archives.length === 0) {
|
|
||||||
return 'No relevant archives found';
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = ['Relevant archives (text search - embeddings unavailable):\n'];
|
|
||||||
for (const a of archives) {
|
|
||||||
const sizeStr = a.file_size ? ` (${Math.round(a.file_size / 1024)}KB)` : '';
|
|
||||||
lines.push(`**[${a.archive_type}]** ${a.title}`);
|
|
||||||
lines.push(` Archived: ${a.archived_at}${sizeStr}`);
|
|
||||||
if (a.original_path) {
|
|
||||||
lines.push(` Path: ${a.original_path}`);
|
|
||||||
}
|
|
||||||
lines.push('');
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Semantic search with embeddings
|
|
||||||
const embeddingStr = formatEmbedding(embedding);
|
const embeddingStr = formatEmbedding(embedding);
|
||||||
|
const filter = buildFilter(3);
|
||||||
|
const params: unknown[] = [embeddingStr, limit, ...filter.params];
|
||||||
|
|
||||||
let whereClause = 'WHERE embedding IS NOT NULL';
|
const rows = await query<Archive & { similarity: number }>(
|
||||||
const params: unknown[] = [embeddingStr, limit];
|
|
||||||
let paramIndex = 3;
|
|
||||||
|
|
||||||
if (project) {
|
|
||||||
whereClause += ` AND project_key = $${paramIndex++}`;
|
|
||||||
params.splice(params.length - 1, 0, project);
|
|
||||||
}
|
|
||||||
if (archive_type) {
|
|
||||||
whereClause += ` AND archive_type = $${paramIndex++}`;
|
|
||||||
params.splice(params.length - 1, 0, archive_type);
|
|
||||||
}
|
|
||||||
|
|
||||||
const archives = await query<Archive & { similarity: number }>(
|
|
||||||
`SELECT id, archive_type, title, original_path, file_size,
|
`SELECT id, archive_type, title, original_path, file_size,
|
||||||
to_char(archived_at, 'YYYY-MM-DD') as archived_at,
|
to_char(archived_at, 'YYYY-MM-DD') as archived_at,
|
||||||
1 - (embedding <=> $1) as similarity
|
1 - (embedding <=> $1) as similarity
|
||||||
FROM project_archives
|
FROM project_archives
|
||||||
${whereClause}
|
WHERE embedding IS NOT NULL${filter.where}
|
||||||
ORDER BY embedding <=> $1
|
ORDER BY embedding <=> $1
|
||||||
LIMIT $2`,
|
LIMIT $2`,
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
|
vectorIds = rows.map(r => r.id);
|
||||||
|
for (const r of rows) vectorRows.set(r.id, r);
|
||||||
|
} else {
|
||||||
|
embeddingFailed = true;
|
||||||
|
if (search_mode === 'vector') {
|
||||||
|
return 'Error: Could not generate embedding for vector search';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (archives.length === 0) {
|
// Keyword search
|
||||||
|
let keywordIds: number[] = [];
|
||||||
|
let keywordRows: Map<number, Archive & { rank: number }> = new Map();
|
||||||
|
|
||||||
|
if (search_mode !== 'vector') {
|
||||||
|
const filter = buildFilter(3);
|
||||||
|
const params: unknown[] = [searchQuery, limit, ...filter.params];
|
||||||
|
|
||||||
|
const rows = await query<Archive & { rank: number }>(
|
||||||
|
`SELECT id, archive_type, title, original_path, file_size,
|
||||||
|
to_char(archived_at, 'YYYY-MM-DD') as archived_at,
|
||||||
|
ts_rank(search_vector, plainto_tsquery('english', $1)) as rank
|
||||||
|
FROM project_archives
|
||||||
|
WHERE search_vector @@ plainto_tsquery('english', $1)${filter.where}
|
||||||
|
ORDER BY rank DESC
|
||||||
|
LIMIT $2`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
keywordIds = rows.map(r => r.id);
|
||||||
|
for (const r of rows) keywordRows.set(r.id, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge results
|
||||||
|
let finalIds: number[];
|
||||||
|
let searchLabel: string;
|
||||||
|
|
||||||
|
let rerankScores: Map<number, number> | null = null;
|
||||||
|
|
||||||
|
if (search_mode === 'hybrid' && vectorIds.length > 0 && keywordIds.length > 0) {
|
||||||
|
const merged = rrfMerge(vectorIds, keywordIds);
|
||||||
|
finalIds = merged.map(m => m.id as number);
|
||||||
|
searchLabel = 'hybrid';
|
||||||
|
|
||||||
|
// Cross-encoder re-ranking (CF-1317)
|
||||||
|
const docs = finalIds.map(id => {
|
||||||
|
const r = vectorRows.get(id) || keywordRows.get(id);
|
||||||
|
return (r as any)?.title || '';
|
||||||
|
});
|
||||||
|
const reranked = await rerank(searchQuery, docs, limit);
|
||||||
|
if (reranked) {
|
||||||
|
rerankScores = new Map();
|
||||||
|
const reorderedIds = reranked.map(r => {
|
||||||
|
rerankScores!.set(finalIds[r.index], r.relevance_score);
|
||||||
|
return finalIds[r.index];
|
||||||
|
});
|
||||||
|
finalIds = reorderedIds;
|
||||||
|
searchLabel = 'hybrid+rerank';
|
||||||
|
} else {
|
||||||
|
finalIds = finalIds.slice(0, limit);
|
||||||
|
}
|
||||||
|
} else if (vectorIds.length > 0) {
|
||||||
|
finalIds = vectorIds;
|
||||||
|
searchLabel = 'vector';
|
||||||
|
} else if (keywordIds.length > 0) {
|
||||||
|
finalIds = keywordIds;
|
||||||
|
searchLabel = embeddingFailed ? 'keyword (embedding unavailable)' : 'keyword';
|
||||||
|
} else {
|
||||||
return 'No relevant archives found';
|
return 'No relevant archives found';
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = ['Relevant archives:\n'];
|
// Format output
|
||||||
for (const a of archives) {
|
const lines = [`Relevant archives (${searchLabel}):\n`];
|
||||||
const sim = Math.round(a.similarity * 100);
|
for (const id of finalIds) {
|
||||||
|
const a = vectorRows.get(id) || keywordRows.get(id);
|
||||||
|
if (!a) continue;
|
||||||
|
const simParts: string[] = [];
|
||||||
|
if (vectorRows.has(id)) simParts.push(`${Math.round((vectorRows.get(id)!).similarity * 100)}% match`);
|
||||||
|
if (rerankScores?.has(id)) simParts.push(`rerank: ${rerankScores.get(id)!.toFixed(2)}`);
|
||||||
|
const scores = simParts.length > 0 ? ` (${simParts.join(', ')})` : '';
|
||||||
const sizeStr = a.file_size ? ` (${Math.round(a.file_size / 1024)}KB)` : '';
|
const sizeStr = a.file_size ? ` (${Math.round(a.file_size / 1024)}KB)` : '';
|
||||||
lines.push(`**[${a.archive_type}]** ${a.title} (${sim}% match)`);
|
lines.push(`**[${a.archive_type}]** ${a.title}${scores}`);
|
||||||
lines.push(` Archived: ${a.archived_at}${sizeStr}`);
|
lines.push(` Archived: ${a.archived_at}${sizeStr}`);
|
||||||
if (a.original_path) {
|
if (a.original_path) {
|
||||||
lines.push(` Path: ${a.original_path}`);
|
lines.push(` Path: ${a.original_path}`);
|
||||||
|
|||||||
@@ -144,6 +144,27 @@ export const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Event Timeline (CF-2885)
|
||||||
|
{
|
||||||
|
name: 'timeline',
|
||||||
|
description: 'Unified chronological event timeline for a subject. Stitches sessions, notes, commits, plans, and task-commit links from all sessions touching the subject. Subject can be a Jira issue key (e.g., CF-2872), a session id, or a project key (e.g., CF). Returns events sorted oldest → newest.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
subject: { type: 'string', description: 'Jira issue key (CF-123), session id, or project key (CF)' },
|
||||||
|
since: { type: 'string', description: 'ISO8601 timestamp or relative "-7d"/"-24h"/"-30m" (default: -7d)' },
|
||||||
|
until: { type: 'string', description: 'ISO8601 timestamp (default: now)' },
|
||||||
|
sources: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string', enum: ['session', 'note', 'commit', 'plan', 'task_commit', 'jira'] },
|
||||||
|
description: 'Optional filter: which event sources to include (default: all). Jira source pulls issue history (transitions, comments, field changes) via the AgilitonAPI gateway.',
|
||||||
|
},
|
||||||
|
limit: { type: 'number', description: 'Max events to return (default: 100)' },
|
||||||
|
},
|
||||||
|
required: ['subject'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Project Lock Tools
|
// Project Lock Tools
|
||||||
{
|
{
|
||||||
name: 'project_lock',
|
name: 'project_lock',
|
||||||
@@ -298,62 +319,6 @@ export const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Memory Tools
|
|
||||||
{
|
|
||||||
name: 'memory_add',
|
|
||||||
description: 'Store a learning/memory for future sessions.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
category: { type: 'string', enum: ['pattern', 'fix', 'preference', 'gotcha', 'architecture'], description: 'Memory category' },
|
|
||||||
title: { type: 'string', description: 'Short title' },
|
|
||||||
content: { type: 'string', description: 'The learning/insight to remember' },
|
|
||||||
context: { type: 'string', description: 'When/where this applies (optional)' },
|
|
||||||
project: { type: 'string', description: 'Project (optional)' },
|
|
||||||
session_id: { type: 'string', description: 'Session ID (optional)' },
|
|
||||||
task_id: { type: 'string', description: 'Jira issue key (optional)' },
|
|
||||||
},
|
|
||||||
required: ['category', 'title', 'content'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'memory_search',
|
|
||||||
description: 'Search memories semantically.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
query: { type: 'string', description: 'Search query' },
|
|
||||||
project: { type: 'string', description: 'Filter by project (optional)' },
|
|
||||||
category: { type: 'string', enum: ['pattern', 'fix', 'preference', 'gotcha', 'architecture'], description: 'Filter by category (optional)' },
|
|
||||||
limit: { type: 'number', description: 'Max results (default: 5)' },
|
|
||||||
},
|
|
||||||
required: ['query'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'memory_list',
|
|
||||||
description: 'List stored memories (non-semantic)',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
project: { type: 'string', description: 'Filter by project (optional)' },
|
|
||||||
category: { type: 'string', enum: ['pattern', 'fix', 'preference', 'gotcha', 'architecture'], description: 'Filter by category (optional)' },
|
|
||||||
limit: { type: 'number', description: 'Max results (default: 20)' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'memory_context',
|
|
||||||
description: 'Get memories relevant to current session context.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
project: { type: 'string', description: 'Current project' },
|
|
||||||
task_description: { type: 'string', description: 'Description of planned work' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Tool Documentation Tools
|
// Tool Documentation Tools
|
||||||
{
|
{
|
||||||
name: 'tool_doc_add',
|
name: 'tool_doc_add',
|
||||||
@@ -479,13 +444,14 @@ export const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'session_search',
|
name: 'session_search',
|
||||||
description: 'Find similar sessions using vector search',
|
description: 'Find similar sessions using hybrid (vector + keyword), vector-only, or keyword-only search.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: 'string', description: 'Search query' },
|
query: { type: 'string', description: 'Search query' },
|
||||||
project: { type: 'string', description: 'Filter by project (optional)' },
|
project: { type: 'string', description: 'Filter by project (optional)' },
|
||||||
limit: { type: 'number', description: 'Max results (default: 5)' },
|
limit: { type: 'number', description: 'Max results (default: 5)' },
|
||||||
|
search_mode: { type: 'string', enum: ['hybrid', 'vector', 'keyword'], description: 'Search mode (default: hybrid)' },
|
||||||
},
|
},
|
||||||
required: ['query'],
|
required: ['query'],
|
||||||
},
|
},
|
||||||
@@ -670,13 +636,17 @@ export const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'session_semantic_search',
|
name: 'session_semantic_search',
|
||||||
description: 'Semantic search across all session documentation',
|
description: 'Search across all session documentation using hybrid (vector + keyword), vector-only, or keyword-only search. Supports optional metadata filters (topics, projects, issue_keys) — only use filters when the user explicitly mentions a topic/project. When unsure, search without filters.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: 'string', description: 'Search query' },
|
query: { type: 'string', description: 'Search query' },
|
||||||
project: { type: 'string', description: 'Filter by project (optional)' },
|
project: { type: 'string', description: 'Filter by project (optional)' },
|
||||||
limit: { type: 'number', description: 'Max results (default: 10)' },
|
limit: { type: 'number', description: 'Max results (default: 10)' },
|
||||||
|
search_mode: { type: 'string', enum: ['hybrid', 'vector', 'keyword'], description: 'Search mode (default: hybrid)' },
|
||||||
|
filter_topics: { type: 'array', items: { type: 'string' }, description: 'Filter by extracted topics (e.g., ["pgvector", "deployment"]). Only use when user explicitly mentions topics.' },
|
||||||
|
filter_projects: { type: 'array', items: { type: 'string' }, description: 'Filter by extracted project keys (e.g., ["CF", "BAB"]). Only use when user explicitly mentions projects.' },
|
||||||
|
filter_issue_keys: { type: 'array', items: { type: 'string' }, description: 'Filter by extracted Jira issue keys (e.g., ["CF-1307"]). Only use when user explicitly mentions issue keys.' },
|
||||||
},
|
},
|
||||||
required: ['query'],
|
required: ['query'],
|
||||||
},
|
},
|
||||||
@@ -704,6 +674,23 @@ export const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Transcript Tools (CF-2394)
|
||||||
|
{
|
||||||
|
name: 'session_transcript_search',
|
||||||
|
description: 'Search session transcripts (JSONL) using hybrid (vector + keyword) search. Finds past sessions by content — commands run, decisions made, plans discussed. Use when recovering context from prior sessions.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: { type: 'string', description: 'Search query (e.g., "hetzner disk resize", "auth migration plan")' },
|
||||||
|
project: { type: 'string', description: 'Filter by project key (optional)' },
|
||||||
|
session_issue_key: { type: 'string', description: 'Filter by session Jira issue key (optional)' },
|
||||||
|
limit: { type: 'number', description: 'Max results (default: 10)' },
|
||||||
|
search_mode: { type: 'string', enum: ['hybrid', 'vector', 'keyword'], description: 'Search mode (default: hybrid)' },
|
||||||
|
},
|
||||||
|
required: ['query'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Archive Tools
|
// Archive Tools
|
||||||
{
|
{
|
||||||
name: 'archive_add',
|
name: 'archive_add',
|
||||||
@@ -725,7 +712,7 @@ export const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'archive_search',
|
name: 'archive_search',
|
||||||
description: 'Search archives using semantic similarity',
|
description: 'Search archives using hybrid (vector + keyword), vector-only, or keyword-only search.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -733,6 +720,7 @@ export const toolDefinitions = [
|
|||||||
project: { type: 'string', description: 'Filter by project (optional)' },
|
project: { type: 'string', description: 'Filter by project (optional)' },
|
||||||
archive_type: { type: 'string', enum: ['session', 'research', 'audit', 'investigation', 'completed', 'migration'], description: 'Filter by archive type (optional)' },
|
archive_type: { type: 'string', enum: ['session', 'research', 'audit', 'investigation', 'completed', 'migration'], description: 'Filter by archive type (optional)' },
|
||||||
limit: { type: 'number', description: 'Max results (default: 5)' },
|
limit: { type: 'number', description: 'Max results (default: 5)' },
|
||||||
|
search_mode: { type: 'string', enum: ['hybrid', 'vector', 'keyword'], description: 'Search mode (default: hybrid)' },
|
||||||
},
|
},
|
||||||
required: ['query'],
|
required: ['query'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,275 +0,0 @@
|
|||||||
// Session memory operations for persistent learnings
|
|
||||||
|
|
||||||
import { query, queryOne, execute } from '../db.js';
|
|
||||||
import { getEmbedding, formatEmbedding } from '../embeddings.js';
|
|
||||||
|
|
||||||
type MemoryCategory = 'pattern' | 'fix' | 'preference' | 'gotcha' | 'architecture';
|
|
||||||
|
|
||||||
interface Memory {
|
|
||||||
id: number;
|
|
||||||
category: MemoryCategory;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
context: string | null;
|
|
||||||
project: string | null;
|
|
||||||
session_id: string | null;
|
|
||||||
task_id: string | null;
|
|
||||||
access_count: number;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MemoryAddArgs {
|
|
||||||
category: MemoryCategory;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
context?: string;
|
|
||||||
project?: string;
|
|
||||||
session_id?: string;
|
|
||||||
task_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MemorySearchArgs {
|
|
||||||
query: string;
|
|
||||||
project?: string;
|
|
||||||
category?: MemoryCategory;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MemoryListArgs {
|
|
||||||
project?: string;
|
|
||||||
category?: MemoryCategory;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a new memory/learning (enhanced with session_id and task_id)
|
|
||||||
* CF-306: Validates session_id exists before inserting to prevent foreign key violations
|
|
||||||
*/
|
|
||||||
export async function memoryAdd(args: MemoryAddArgs): Promise<string> {
|
|
||||||
const { category, title, content, context, project, session_id, task_id } = args;
|
|
||||||
|
|
||||||
// CF-306: Validate session_id exists if provided
|
|
||||||
let validSessionId = session_id || null;
|
|
||||||
if (session_id) {
|
|
||||||
const sessionExists = await queryOne<{ exists: boolean }>(
|
|
||||||
`SELECT EXISTS(SELECT 1 FROM sessions WHERE id = $1) as exists`,
|
|
||||||
[session_id]
|
|
||||||
);
|
|
||||||
if (!sessionExists?.exists) {
|
|
||||||
console.warn(`[CF-306] Session ${session_id} not found in database - using NULL instead`);
|
|
||||||
validSessionId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate embedding for semantic search
|
|
||||||
const embedText = `${title}. ${content}`;
|
|
||||||
const embedding = await getEmbedding(embedText);
|
|
||||||
const embeddingValue = embedding ? formatEmbedding(embedding) : null;
|
|
||||||
|
|
||||||
if (embeddingValue) {
|
|
||||||
await execute(
|
|
||||||
`INSERT INTO memories (category, title, content, context, project, session_id, task_id, embedding)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
||||||
[category, title, content, context || null, project || null, validSessionId, task_id || null, embeddingValue]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await execute(
|
|
||||||
`INSERT INTO memories (category, title, content, context, project, session_id, task_id)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
||||||
[category, title, content, context || null, project || null, validSessionId, task_id || null]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return `Stored memory: [${category}] ${title}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search memories semantically
|
|
||||||
*/
|
|
||||||
export async function memorySearch(args: MemorySearchArgs): Promise<string> {
|
|
||||||
const { query: searchQuery, project, category, limit = 5 } = args;
|
|
||||||
|
|
||||||
// Generate embedding for search
|
|
||||||
const embedding = await getEmbedding(searchQuery);
|
|
||||||
|
|
||||||
if (!embedding) {
|
|
||||||
return 'Error: Could not generate embedding for search';
|
|
||||||
}
|
|
||||||
|
|
||||||
const embeddingStr = formatEmbedding(embedding);
|
|
||||||
|
|
||||||
let whereClause = 'WHERE embedding IS NOT NULL';
|
|
||||||
const params: unknown[] = [embeddingStr, limit];
|
|
||||||
let paramIndex = 3;
|
|
||||||
|
|
||||||
if (project) {
|
|
||||||
whereClause += ` AND (project = $${paramIndex++} OR project IS NULL)`;
|
|
||||||
params.splice(params.length - 1, 0, project);
|
|
||||||
}
|
|
||||||
if (category) {
|
|
||||||
whereClause += ` AND category = $${paramIndex++}`;
|
|
||||||
params.splice(params.length - 1, 0, category);
|
|
||||||
}
|
|
||||||
|
|
||||||
const memories = await query<Memory & { similarity: number }>(
|
|
||||||
`SELECT id, category, title, content, context, project, access_count,
|
|
||||||
to_char(created_at, 'YYYY-MM-DD') as created_at,
|
|
||||||
1 - (embedding <=> $1) as similarity
|
|
||||||
FROM memories
|
|
||||||
${whereClause}
|
|
||||||
ORDER BY embedding <=> $1
|
|
||||||
LIMIT $2`,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
|
|
||||||
if (memories.length === 0) {
|
|
||||||
return 'No relevant memories found';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update access_count for returned memories
|
|
||||||
const ids = memories.map(m => m.id);
|
|
||||||
await execute(
|
|
||||||
`UPDATE memories SET access_count = access_count + 1, last_accessed_at = NOW() WHERE id = ANY($1)`,
|
|
||||||
[ids]
|
|
||||||
);
|
|
||||||
|
|
||||||
const lines = ['Relevant memories:\n'];
|
|
||||||
for (const m of memories) {
|
|
||||||
const sim = Math.round(m.similarity * 100);
|
|
||||||
const proj = m.project ? ` [${m.project}]` : '';
|
|
||||||
lines.push(`**[${m.category}]${proj}** ${m.title} (${sim}% match)`);
|
|
||||||
lines.push(` ${m.content}`);
|
|
||||||
if (m.context) {
|
|
||||||
lines.push(` _Context: ${m.context}_`);
|
|
||||||
}
|
|
||||||
lines.push('');
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List memories (non-semantic)
|
|
||||||
*/
|
|
||||||
export async function memoryList(args: MemoryListArgs): Promise<string> {
|
|
||||||
const { project, category, limit = 20 } = args;
|
|
||||||
|
|
||||||
let whereClause = 'WHERE 1=1';
|
|
||||||
const params: unknown[] = [];
|
|
||||||
let paramIndex = 1;
|
|
||||||
|
|
||||||
if (project) {
|
|
||||||
whereClause += ` AND (project = $${paramIndex++} OR project IS NULL)`;
|
|
||||||
params.push(project);
|
|
||||||
}
|
|
||||||
if (category) {
|
|
||||||
whereClause += ` AND category = $${paramIndex++}`;
|
|
||||||
params.push(category);
|
|
||||||
}
|
|
||||||
|
|
||||||
params.push(limit);
|
|
||||||
|
|
||||||
const memories = await query<Memory>(
|
|
||||||
`SELECT id, category, title, content, context, project, access_count,
|
|
||||||
to_char(created_at, 'YYYY-MM-DD') as created_at
|
|
||||||
FROM memories
|
|
||||||
${whereClause}
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT $${paramIndex}`,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
|
|
||||||
if (memories.length === 0) {
|
|
||||||
return `No memories found${project ? ` for project ${project}` : ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = [`Memories${project ? ` (${project})` : ''}:\n`];
|
|
||||||
for (const m of memories) {
|
|
||||||
const proj = m.project ? `[${m.project}] ` : '';
|
|
||||||
const accessed = m.access_count > 0 ? ` (accessed ${m.access_count}x)` : '';
|
|
||||||
lines.push(`• [${m.category}] ${proj}${m.title}${accessed}`);
|
|
||||||
lines.push(` ${m.content.slice(0, 100)}${m.content.length > 100 ? '...' : ''}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a memory by ID
|
|
||||||
*/
|
|
||||||
export async function memoryDelete(id: number): Promise<string> {
|
|
||||||
const result = await execute('DELETE FROM memories WHERE id = $1', [id]);
|
|
||||||
|
|
||||||
if (result === 0) {
|
|
||||||
return `Memory not found: ${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `Deleted memory: ${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get memories relevant to current context (for session start)
|
|
||||||
*/
|
|
||||||
export async function memoryContext(project: string | null, taskDescription?: string): Promise<string> {
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
// Get project-specific memories
|
|
||||||
if (project) {
|
|
||||||
const projectMemories = await query<Memory>(
|
|
||||||
`SELECT category, title, content FROM memories
|
|
||||||
WHERE project = $1
|
|
||||||
ORDER BY access_count DESC, created_at DESC
|
|
||||||
LIMIT 5`,
|
|
||||||
[project]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (projectMemories.length > 0) {
|
|
||||||
lines.push(`**${project} Memories:**`);
|
|
||||||
for (const m of projectMemories) {
|
|
||||||
lines.push(`• [${m.category}] ${m.title}: ${m.content}`);
|
|
||||||
}
|
|
||||||
lines.push('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If task description provided, do semantic search
|
|
||||||
if (taskDescription) {
|
|
||||||
const embedding = await getEmbedding(taskDescription);
|
|
||||||
if (embedding) {
|
|
||||||
const relevant = await query<Memory>(
|
|
||||||
`SELECT category, title, content, project
|
|
||||||
FROM memories
|
|
||||||
WHERE embedding IS NOT NULL
|
|
||||||
ORDER BY embedding <=> $1
|
|
||||||
LIMIT 3`,
|
|
||||||
[formatEmbedding(embedding)]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (relevant.length > 0) {
|
|
||||||
lines.push('**Relevant memories for this task:**');
|
|
||||||
for (const m of relevant) {
|
|
||||||
const proj = m.project ? `[${m.project}] ` : '';
|
|
||||||
lines.push(`• ${proj}${m.title}: ${m.content}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get recent gotchas (always useful)
|
|
||||||
const gotchas = await query<Memory>(
|
|
||||||
`SELECT title, content FROM memories
|
|
||||||
WHERE category = 'gotcha'
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 3`,
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (gotchas.length > 0) {
|
|
||||||
lines.push('\n**Recent gotchas:**');
|
|
||||||
for (const g of gotchas) {
|
|
||||||
lines.push(`⚠️ ${g.title}: ${g.content}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.length > 0 ? lines.join('\n') : 'No memories to surface';
|
|
||||||
}
|
|
||||||
@@ -43,9 +43,9 @@ async function getS3Credentials(): Promise<{
|
|||||||
endpoint: string;
|
endpoint: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const { stdout: accessKey } = await execAsync('vault get hetzner.s3_access_key');
|
const { stdout: accessKey } = await execAsync('vault get ag.org.s3.access_key');
|
||||||
const { stdout: secretKey } = await execAsync('vault get hetzner.s3_secret_key');
|
const { stdout: secretKey } = await execAsync('vault get ag.org.s3.secret_key');
|
||||||
const { stdout: endpoint } = await execAsync('vault get hetzner.s3_endpoint');
|
const { stdout: endpoint } = await execAsync('vault get ag.org.s3.endpoint');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessKey: accessKey.trim(),
|
accessKey: accessKey.trim(),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Replaces file-based CLAUDE.md and plan files with database storage
|
// Replaces file-based CLAUDE.md and plan files with database storage
|
||||||
|
|
||||||
import { query, queryOne, execute } from '../db.js';
|
import { query, queryOne, execute } from '../db.js';
|
||||||
import { getEmbedding, formatEmbedding } from '../embeddings.js';
|
import { getEmbedding, formatEmbedding, generateContentHash, rrfMerge, rerank } from '../embeddings.js';
|
||||||
import { getSessionId } from './session-id.js';
|
import { getSessionId } from './session-id.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -36,14 +36,25 @@ export async function sessionNoteAdd(args: SessionNoteAddArgs): Promise<string>
|
|||||||
const { session_id: providedSessionId, note_type, content } = args;
|
const { session_id: providedSessionId, note_type, content } = args;
|
||||||
const session_id = providedSessionId || getSessionId();
|
const session_id = providedSessionId || getSessionId();
|
||||||
|
|
||||||
|
// CF-1314: Hash content for dedup before embedding API call
|
||||||
|
const contentHash = generateContentHash(content);
|
||||||
|
|
||||||
|
const existing = await queryOne<{ id: number }>(
|
||||||
|
'SELECT id FROM session_notes WHERE content_hash = $1 AND session_id = $2 LIMIT 1',
|
||||||
|
[contentHash, session_id]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
return `Note already exists (id: ${existing.id}) in session ${session_id}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Generate embedding for semantic search
|
// Generate embedding for semantic search
|
||||||
const embedding = await getEmbedding(content);
|
const embedding = await getEmbedding(content);
|
||||||
const embeddingFormatted = embedding ? formatEmbedding(embedding) : null;
|
const embeddingFormatted = embedding ? formatEmbedding(embedding) : null;
|
||||||
|
|
||||||
await execute(
|
await execute(
|
||||||
`INSERT INTO session_notes (session_id, note_type, content, embedding)
|
`INSERT INTO session_notes (session_id, note_type, content, embedding, content_hash)
|
||||||
VALUES ($1, $2, $3, $4)`,
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
[session_id, note_type, content, embeddingFormatted]
|
[session_id, note_type, content, embeddingFormatted, contentHash]
|
||||||
);
|
);
|
||||||
|
|
||||||
return `Note added to session ${session_id} (type: ${note_type})`;
|
return `Note added to session ${session_id} (type: ${note_type})`;
|
||||||
@@ -113,15 +124,26 @@ interface SessionPlan {
|
|||||||
export async function sessionPlanSave(args: SessionPlanSaveArgs): Promise<string> {
|
export async function sessionPlanSave(args: SessionPlanSaveArgs): Promise<string> {
|
||||||
const { session_id, plan_content, plan_file_name, status = 'draft' } = args;
|
const { session_id, plan_content, plan_file_name, status = 'draft' } = args;
|
||||||
|
|
||||||
|
// CF-1314: Hash content for dedup before embedding API call
|
||||||
|
const contentHash = generateContentHash(plan_content);
|
||||||
|
|
||||||
|
const existing = await queryOne<{ id: number }>(
|
||||||
|
'SELECT id FROM session_plans WHERE content_hash = $1 AND session_id = $2 LIMIT 1',
|
||||||
|
[contentHash, session_id]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
return `Plan already exists (id: ${existing.id}) in session ${session_id}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Generate embedding for semantic search
|
// Generate embedding for semantic search
|
||||||
const embedding = await getEmbedding(plan_content);
|
const embedding = await getEmbedding(plan_content);
|
||||||
const embeddingFormatted = embedding ? formatEmbedding(embedding) : null;
|
const embeddingFormatted = embedding ? formatEmbedding(embedding) : null;
|
||||||
|
|
||||||
const result = await queryOne<{ id: number }>(
|
const result = await queryOne<{ id: number }>(
|
||||||
`INSERT INTO session_plans (session_id, plan_file_name, plan_content, status, embedding)
|
`INSERT INTO session_plans (session_id, plan_file_name, plan_content, status, embedding, content_hash)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[session_id, plan_file_name || null, plan_content, status, embeddingFormatted]
|
[session_id, plan_file_name || null, plan_content, status, embeddingFormatted, contentHash]
|
||||||
);
|
);
|
||||||
|
|
||||||
const planId = result?.id || 0;
|
const planId = result?.id || 0;
|
||||||
@@ -429,10 +451,16 @@ export async function sessionDocumentationGenerate(args: SessionDocumentationGen
|
|||||||
// SEMANTIC SEARCH & ANALYTICS
|
// SEMANTIC SEARCH & ANALYTICS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
type SearchMode = 'hybrid' | 'vector' | 'keyword';
|
||||||
|
|
||||||
interface SessionSemanticSearchArgs {
|
interface SessionSemanticSearchArgs {
|
||||||
query: string;
|
query: string;
|
||||||
project?: string;
|
project?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
search_mode?: SearchMode;
|
||||||
|
filter_topics?: string[];
|
||||||
|
filter_projects?: string[];
|
||||||
|
filter_issue_keys?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionSearchResult {
|
interface SessionSearchResult {
|
||||||
@@ -445,60 +473,120 @@ interface SessionSearchResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Semantic search across all session documentation
|
* Semantic search across all session documentation with hybrid/vector/keyword modes (CF-1315)
|
||||||
* Uses vector similarity to find related sessions
|
|
||||||
*/
|
*/
|
||||||
export async function sessionSemanticSearch(args: SessionSemanticSearchArgs): Promise<SessionSearchResult[]> {
|
export async function sessionSemanticSearch(args: SessionSemanticSearchArgs): Promise<SessionSearchResult[]> {
|
||||||
const { query: searchQuery, project, limit = 10 } = args;
|
const { query: searchQuery, project, limit = 10, search_mode = 'hybrid', filter_topics, filter_projects, filter_issue_keys } = args;
|
||||||
|
|
||||||
// Generate embedding for search query
|
// Build shared filter clause (CF-1316: metadata filters via JSONB @> containment)
|
||||||
const queryEmbedding = await getEmbedding(searchQuery);
|
const buildFilter = (startIdx: number) => {
|
||||||
|
let where = '';
|
||||||
if (!queryEmbedding) {
|
const params: unknown[] = [];
|
||||||
// Fallback to text search if embedding generation fails
|
let idx = startIdx;
|
||||||
let sql = `
|
if (project) {
|
||||||
SELECT
|
where += ` AND s.project = $${idx++}`;
|
||||||
s.id as session_id,
|
params.push(project);
|
||||||
s.session_number,
|
|
||||||
s.project,
|
|
||||||
s.summary,
|
|
||||||
s.started_at,
|
|
||||||
0.5 as similarity
|
|
||||||
FROM sessions s
|
|
||||||
WHERE s.summary IS NOT NULL
|
|
||||||
AND s.status = 'completed'
|
|
||||||
${project ? 'AND s.project = $1' : ''}
|
|
||||||
AND s.summary ILIKE $${project ? '2' : '1'}
|
|
||||||
ORDER BY s.started_at DESC
|
|
||||||
LIMIT $${project ? '3' : '2'}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const params: unknown[] = project ? [project, `%${searchQuery}%`, limit] : [`%${searchQuery}%`, limit];
|
|
||||||
const results = await query<SessionSearchResult>(sql, params);
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
if (filter_topics && filter_topics.length > 0) {
|
||||||
|
where += ` AND s.extracted_metadata->'topics' @> $${idx++}::jsonb`;
|
||||||
|
params.push(JSON.stringify(filter_topics));
|
||||||
|
}
|
||||||
|
if (filter_projects && filter_projects.length > 0) {
|
||||||
|
where += ` AND s.extracted_metadata->'projects' @> $${idx++}::jsonb`;
|
||||||
|
params.push(JSON.stringify(filter_projects));
|
||||||
|
}
|
||||||
|
if (filter_issue_keys && filter_issue_keys.length > 0) {
|
||||||
|
where += ` AND s.extracted_metadata->'issue_keys' @> $${idx++}::jsonb`;
|
||||||
|
params.push(JSON.stringify(filter_issue_keys));
|
||||||
|
}
|
||||||
|
return { where, params, nextIdx: idx };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vector search
|
||||||
|
let vectorIds: string[] = [];
|
||||||
|
let vectorRows: Map<string, SessionSearchResult> = new Map();
|
||||||
|
let embeddingFailed = false;
|
||||||
|
|
||||||
|
if (search_mode !== 'keyword') {
|
||||||
|
const queryEmbedding = await getEmbedding(searchQuery);
|
||||||
|
if (queryEmbedding) {
|
||||||
const embeddingFormatted = formatEmbedding(queryEmbedding);
|
const embeddingFormatted = formatEmbedding(queryEmbedding);
|
||||||
|
const filter = buildFilter(3);
|
||||||
|
const params: unknown[] = [embeddingFormatted, limit, ...filter.params];
|
||||||
|
|
||||||
// Vector similarity search
|
const rows = await query<SessionSearchResult>(
|
||||||
let sql = `
|
`SELECT s.id as session_id, s.session_number, s.project, s.summary, s.started_at,
|
||||||
SELECT
|
|
||||||
s.id as session_id,
|
|
||||||
s.session_number,
|
|
||||||
s.project,
|
|
||||||
s.summary,
|
|
||||||
s.started_at,
|
|
||||||
1 - (s.embedding <=> $1) as similarity
|
1 - (s.embedding <=> $1) as similarity
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
WHERE s.embedding IS NOT NULL
|
WHERE s.embedding IS NOT NULL AND s.status = 'completed'${filter.where}
|
||||||
${project ? 'AND s.project = $2' : ''}
|
|
||||||
AND s.status = 'completed'
|
|
||||||
ORDER BY s.embedding <=> $1
|
ORDER BY s.embedding <=> $1
|
||||||
LIMIT $${project ? '3' : '2'}
|
LIMIT $2`,
|
||||||
`;
|
params
|
||||||
|
);
|
||||||
|
vectorIds = rows.map(r => r.session_id);
|
||||||
|
for (const r of rows) vectorRows.set(r.session_id, r);
|
||||||
|
} else {
|
||||||
|
embeddingFailed = true;
|
||||||
|
if (search_mode === 'vector') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const params: unknown[] = project ? [embeddingFormatted, project, limit] : [embeddingFormatted, limit];
|
// Keyword search
|
||||||
const results = await query<SessionSearchResult>(sql, params);
|
let keywordIds: string[] = [];
|
||||||
|
let keywordRows: Map<string, SessionSearchResult> = new Map();
|
||||||
|
|
||||||
|
if (search_mode !== 'vector') {
|
||||||
|
const filter = buildFilter(3);
|
||||||
|
const params: unknown[] = [searchQuery, limit, ...filter.params];
|
||||||
|
|
||||||
|
const rows = await query<SessionSearchResult & { rank: number }>(
|
||||||
|
`SELECT s.id as session_id, s.session_number, s.project, s.summary, s.started_at,
|
||||||
|
ts_rank(s.search_vector, plainto_tsquery('english', $1)) as similarity
|
||||||
|
FROM sessions s
|
||||||
|
WHERE s.search_vector @@ plainto_tsquery('english', $1)
|
||||||
|
AND s.status = 'completed'${filter.where}
|
||||||
|
ORDER BY similarity DESC
|
||||||
|
LIMIT $2`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
keywordIds = rows.map(r => r.session_id);
|
||||||
|
for (const r of rows) keywordRows.set(r.session_id, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge results
|
||||||
|
let finalIds: string[];
|
||||||
|
|
||||||
|
if (search_mode === 'hybrid' && vectorIds.length > 0 && keywordIds.length > 0) {
|
||||||
|
const merged = rrfMerge(vectorIds, keywordIds);
|
||||||
|
finalIds = merged.map(m => m.id as string);
|
||||||
|
|
||||||
|
// Cross-encoder re-ranking (CF-1317)
|
||||||
|
const docs = finalIds.map(id => {
|
||||||
|
const r = vectorRows.get(id) || keywordRows.get(id);
|
||||||
|
return r?.summary || '';
|
||||||
|
});
|
||||||
|
const reranked = await rerank(searchQuery, docs, limit);
|
||||||
|
if (reranked) {
|
||||||
|
finalIds = reranked.map(r => finalIds[r.index]);
|
||||||
|
} else {
|
||||||
|
finalIds = finalIds.slice(0, limit);
|
||||||
|
}
|
||||||
|
} else if (vectorIds.length > 0) {
|
||||||
|
finalIds = vectorIds;
|
||||||
|
} else if (keywordIds.length > 0) {
|
||||||
|
finalIds = keywordIds;
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build final results preserving original similarity scores
|
||||||
|
const results: SessionSearchResult[] = [];
|
||||||
|
for (const id of finalIds) {
|
||||||
|
const r = vectorRows.get(id) || keywordRows.get(id);
|
||||||
|
if (r) results.push(r);
|
||||||
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Sessions auto-create CF Jira issues and post output on close (CF-762)
|
// Sessions auto-create CF Jira issues and post output on close (CF-762)
|
||||||
|
|
||||||
import { query, queryOne, execute } from '../db.js';
|
import { query, queryOne, execute } from '../db.js';
|
||||||
import { getEmbedding, formatEmbedding } from '../embeddings.js';
|
import { getEmbedding, formatEmbedding, generateContentHash, rrfMerge, rerank, extractMetadata } from '../embeddings.js';
|
||||||
import { createSessionIssue, addComment, transitionToDone, updateIssueDescription } from '../services/jira.js';
|
import { createSessionIssue, addComment, transitionToDone, updateIssueDescription } from '../services/jira.js';
|
||||||
|
|
||||||
interface SessionStartArgs {
|
interface SessionStartArgs {
|
||||||
@@ -34,10 +34,13 @@ interface SessionListArgs {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SearchMode = 'hybrid' | 'vector' | 'keyword';
|
||||||
|
|
||||||
interface SessionSearchArgs {
|
interface SessionSearchArgs {
|
||||||
query: string;
|
query: string;
|
||||||
project?: string;
|
project?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
search_mode?: SearchMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
@@ -157,32 +160,29 @@ export async function sessionUpdate(args: SessionUpdateArgs): Promise<string> {
|
|||||||
export async function sessionEnd(args: SessionEndArgs): Promise<string> {
|
export async function sessionEnd(args: SessionEndArgs): Promise<string> {
|
||||||
const { session_id, summary, status = 'completed' } = args;
|
const { session_id, summary, status = 'completed' } = args;
|
||||||
|
|
||||||
// Generate embedding for semantic search
|
// CF-1314: Store content hash alongside embedding
|
||||||
const embedding = await getEmbedding(summary);
|
const contentHash = generateContentHash(summary);
|
||||||
const embeddingValue = embedding ? formatEmbedding(embedding) : null;
|
|
||||||
|
// Generate embedding + extract metadata in parallel (CF-1316)
|
||||||
|
const [embedding, metadata] = await Promise.all([
|
||||||
|
getEmbedding(summary),
|
||||||
|
extractMetadata(summary),
|
||||||
|
]);
|
||||||
|
const embeddingValue = embedding ? formatEmbedding(embedding) : null;
|
||||||
|
const metadataValue = metadata ? JSON.stringify(metadata) : null;
|
||||||
|
|
||||||
if (embeddingValue) {
|
|
||||||
await execute(
|
await execute(
|
||||||
`UPDATE sessions
|
`UPDATE sessions
|
||||||
SET ended_at = NOW(),
|
SET ended_at = NOW(),
|
||||||
summary = $1,
|
summary = $1,
|
||||||
embedding = $2,
|
embedding = $2,
|
||||||
status = $3,
|
status = $3,
|
||||||
|
content_hash = $4,
|
||||||
|
extracted_metadata = $5::jsonb,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $4`,
|
WHERE id = $6`,
|
||||||
[summary, embeddingValue, status, session_id]
|
[summary, embeddingValue, status, contentHash, metadataValue, session_id]
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
await execute(
|
|
||||||
`UPDATE sessions
|
|
||||||
SET ended_at = NOW(),
|
|
||||||
summary = $1,
|
|
||||||
status = $2,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $3`,
|
|
||||||
[summary, status, session_id]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get session details
|
// Get session details
|
||||||
const session = await queryOne<Session & { jira_issue_key: string | null }>(
|
const session = await queryOne<Session & { jira_issue_key: string | null }>(
|
||||||
@@ -344,49 +344,125 @@ export async function sessionList(args: SessionListArgs): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Semantic search across sessions using vector similarity
|
* Search sessions with hybrid (vector + keyword), vector-only, or keyword-only mode (CF-1315)
|
||||||
*/
|
*/
|
||||||
export async function sessionSearch(args: SessionSearchArgs): Promise<string> {
|
export async function sessionSearch(args: SessionSearchArgs): Promise<string> {
|
||||||
const { query: searchQuery, project, limit = 5 } = args;
|
const { query: searchQuery, project, limit = 5, search_mode = 'hybrid' } = args;
|
||||||
|
|
||||||
// Generate embedding for search
|
|
||||||
const embedding = await getEmbedding(searchQuery);
|
|
||||||
|
|
||||||
if (!embedding) {
|
|
||||||
return 'Error: Could not generate embedding for search';
|
|
||||||
}
|
|
||||||
|
|
||||||
const embeddingStr = formatEmbedding(embedding);
|
|
||||||
|
|
||||||
let whereClause = 'WHERE embedding IS NOT NULL';
|
|
||||||
const params: unknown[] = [embeddingStr, limit];
|
|
||||||
|
|
||||||
|
// Build shared filter clause
|
||||||
|
const buildFilter = (startIdx: number) => {
|
||||||
|
let where = '';
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let idx = startIdx;
|
||||||
if (project) {
|
if (project) {
|
||||||
whereClause += ` AND project = $3`;
|
where += ` AND project = $${idx++}`;
|
||||||
params.splice(1, 0, project); // Insert before limit
|
params.push(project);
|
||||||
params[2] = limit; // Adjust limit position
|
|
||||||
}
|
}
|
||||||
|
return { where, params, nextIdx: idx };
|
||||||
|
};
|
||||||
|
|
||||||
const sessions = await query<Session & { similarity: number }>(
|
// Vector search
|
||||||
|
let vectorIds: string[] = [];
|
||||||
|
let vectorRows: Map<string, Session & { similarity: number }> = new Map();
|
||||||
|
let embeddingFailed = false;
|
||||||
|
|
||||||
|
if (search_mode !== 'keyword') {
|
||||||
|
const embedding = await getEmbedding(searchQuery);
|
||||||
|
if (embedding) {
|
||||||
|
const embeddingStr = formatEmbedding(embedding);
|
||||||
|
const filter = buildFilter(3);
|
||||||
|
const params: unknown[] = [embeddingStr, limit, ...filter.params];
|
||||||
|
|
||||||
|
const rows = await query<Session & { similarity: number }>(
|
||||||
`SELECT id, project, session_number, started_at, duration_minutes, summary,
|
`SELECT id, project, session_number, started_at, duration_minutes, summary,
|
||||||
1 - (embedding <=> $1) as similarity
|
1 - (embedding <=> $1) as similarity
|
||||||
FROM sessions
|
FROM sessions
|
||||||
${whereClause}
|
WHERE embedding IS NOT NULL${filter.where}
|
||||||
ORDER BY embedding <=> $1
|
ORDER BY embedding <=> $1
|
||||||
LIMIT $${project ? '3' : '2'}`,
|
LIMIT $2`,
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
|
vectorIds = rows.map(r => r.id);
|
||||||
|
for (const r of rows) vectorRows.set(r.id, r);
|
||||||
|
} else {
|
||||||
|
embeddingFailed = true;
|
||||||
|
if (search_mode === 'vector') {
|
||||||
|
return 'Error: Could not generate embedding for vector search';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (sessions.length === 0) {
|
// Keyword search
|
||||||
|
let keywordIds: string[] = [];
|
||||||
|
let keywordRows: Map<string, Session & { rank: number }> = new Map();
|
||||||
|
|
||||||
|
if (search_mode !== 'vector') {
|
||||||
|
const filter = buildFilter(3);
|
||||||
|
const params: unknown[] = [searchQuery, limit, ...filter.params];
|
||||||
|
|
||||||
|
const rows = await query<Session & { rank: number }>(
|
||||||
|
`SELECT id, project, session_number, started_at, duration_minutes, summary,
|
||||||
|
ts_rank(search_vector, plainto_tsquery('english', $1)) as rank
|
||||||
|
FROM sessions
|
||||||
|
WHERE search_vector @@ plainto_tsquery('english', $1)${filter.where}
|
||||||
|
ORDER BY rank DESC
|
||||||
|
LIMIT $2`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
keywordIds = rows.map(r => r.id);
|
||||||
|
for (const r of rows) keywordRows.set(r.id, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge results
|
||||||
|
let finalIds: string[];
|
||||||
|
let searchLabel: string;
|
||||||
|
|
||||||
|
let rerankScores: Map<string, number> | null = null;
|
||||||
|
|
||||||
|
if (search_mode === 'hybrid' && vectorIds.length > 0 && keywordIds.length > 0) {
|
||||||
|
const merged = rrfMerge(vectorIds, keywordIds);
|
||||||
|
finalIds = merged.map(m => m.id as string);
|
||||||
|
searchLabel = 'hybrid';
|
||||||
|
|
||||||
|
// Cross-encoder re-ranking (CF-1317)
|
||||||
|
const docs = finalIds.map(id => {
|
||||||
|
const r = vectorRows.get(id) || keywordRows.get(id);
|
||||||
|
return (r as any)?.summary || '';
|
||||||
|
});
|
||||||
|
const reranked = await rerank(searchQuery, docs, limit);
|
||||||
|
if (reranked) {
|
||||||
|
rerankScores = new Map();
|
||||||
|
const reorderedIds = reranked.map(r => {
|
||||||
|
rerankScores!.set(finalIds[r.index], r.relevance_score);
|
||||||
|
return finalIds[r.index];
|
||||||
|
});
|
||||||
|
finalIds = reorderedIds;
|
||||||
|
searchLabel = 'hybrid+rerank';
|
||||||
|
} else {
|
||||||
|
finalIds = finalIds.slice(0, limit);
|
||||||
|
}
|
||||||
|
} else if (vectorIds.length > 0) {
|
||||||
|
finalIds = vectorIds;
|
||||||
|
searchLabel = 'vector';
|
||||||
|
} else if (keywordIds.length > 0) {
|
||||||
|
finalIds = keywordIds;
|
||||||
|
searchLabel = embeddingFailed ? 'keyword (embedding unavailable)' : 'keyword';
|
||||||
|
} else {
|
||||||
return 'No relevant sessions found';
|
return 'No relevant sessions found';
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = ['Similar sessions:\n'];
|
// Format output
|
||||||
for (const s of sessions) {
|
const lines = [`Similar sessions (${searchLabel}):\n`];
|
||||||
const sim = Math.round(s.similarity * 100);
|
for (const id of finalIds) {
|
||||||
|
const s = vectorRows.get(id) || keywordRows.get(id);
|
||||||
|
if (!s) continue;
|
||||||
|
const simParts: string[] = [];
|
||||||
|
if (vectorRows.has(id)) simParts.push(`${Math.round((vectorRows.get(id)!).similarity * 100)}% match`);
|
||||||
|
if (rerankScores?.has(id)) simParts.push(`rerank: ${rerankScores.get(id)!.toFixed(2)}`);
|
||||||
|
const scores = simParts.length > 0 ? ` (${simParts.join(', ')})` : '';
|
||||||
const num = s.session_number ? `#${s.session_number}` : '';
|
const num = s.session_number ? `#${s.session_number}` : '';
|
||||||
const duration = s.duration_minutes ? `(${s.duration_minutes}m)` : '';
|
const duration = s.duration_minutes ? `(${s.duration_minutes}m)` : '';
|
||||||
lines.push(`**${s.project} ${num}** ${duration} (${sim}% match)`);
|
lines.push(`**${s.project} ${num}** ${duration}${scores}`);
|
||||||
lines.push(` ${s.summary || 'No summary'}`);
|
lines.push(` ${s.summary || 'No summary'}`);
|
||||||
lines.push('');
|
lines.push('');
|
||||||
}
|
}
|
||||||
|
|||||||
530
src/tools/timeline.ts
Normal file
530
src/tools/timeline.ts
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
// CF-2885: Event Timeline — unified chronological view across session-mcp sources
|
||||||
|
// Stitches sessions, notes, commits, plans, task-commit links, and Jira history
|
||||||
|
// into a single time-ordered event stream for LLM consumption.
|
||||||
|
|
||||||
|
import { query } from '../db.js';
|
||||||
|
import { getIssueWithHistory, searchIssueKeys } from '../services/jira.js';
|
||||||
|
|
||||||
|
export type EventSource = 'session' | 'note' | 'commit' | 'plan' | 'task_commit' | 'jira';
|
||||||
|
|
||||||
|
export interface TimelineEvent {
|
||||||
|
ts: string; // ISO8601
|
||||||
|
source: EventSource;
|
||||||
|
type: string; // e.g. "session_start", "note:decision", "commit"
|
||||||
|
subject: string; // Jira key | session id | repo
|
||||||
|
summary: string; // 1-line human readable
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
links: {
|
||||||
|
session?: string;
|
||||||
|
jira?: string;
|
||||||
|
commit?: { sha: string; repo: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineArgs {
|
||||||
|
subject: string; // Jira key (CF-123) | session id | project key (CF)
|
||||||
|
since?: string; // ISO8601 or relative like "-7d" (default: -7d)
|
||||||
|
until?: string; // ISO8601 (default: now)
|
||||||
|
sources?: EventSource[]; // optional filter
|
||||||
|
limit?: number; // default: 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
const JIRA_KEY_RE = /^[A-Z]{2,10}-\d+$/;
|
||||||
|
const PROJECT_KEY_RE = /^[A-Z]{2,10}$/;
|
||||||
|
|
||||||
|
function resolveSince(since?: string): string {
|
||||||
|
if (!since) return 'NOW() - INTERVAL \'7 days\'';
|
||||||
|
// Relative shorthand: -7d, -24h, -30m
|
||||||
|
const rel = since.match(/^-(\d+)([dhm])$/);
|
||||||
|
if (rel) {
|
||||||
|
const n = rel[1];
|
||||||
|
const unit = rel[2] === 'd' ? 'days' : rel[2] === 'h' ? 'hours' : 'minutes';
|
||||||
|
return `NOW() - INTERVAL '${n} ${unit}'`;
|
||||||
|
}
|
||||||
|
return `'${since}'::timestamptz`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUntil(until?: string): string {
|
||||||
|
if (!until) return 'NOW()';
|
||||||
|
return `'${until}'::timestamptz`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortSha(sha: string): string {
|
||||||
|
return sha.substring(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string | null | undefined, n: number): string {
|
||||||
|
if (!s) return '';
|
||||||
|
return s.length > n ? s.substring(0, n - 1) + '…' : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- subject classification ----------
|
||||||
|
|
||||||
|
type SubjectKind = 'jira' | 'session' | 'project' | 'unknown';
|
||||||
|
|
||||||
|
function classifySubject(subject: string): SubjectKind {
|
||||||
|
if (JIRA_KEY_RE.test(subject)) {
|
||||||
|
// Could be Jira issue or session id (sessions.id often uses jira key format)
|
||||||
|
// We'll query both — distinguish at query time
|
||||||
|
return 'jira';
|
||||||
|
}
|
||||||
|
if (PROJECT_KEY_RE.test(subject)) return 'project';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- source queries ----------
|
||||||
|
|
||||||
|
async function fetchSessionEvents(
|
||||||
|
subject: string,
|
||||||
|
kind: SubjectKind,
|
||||||
|
since: string,
|
||||||
|
until: string
|
||||||
|
): Promise<TimelineEvent[]> {
|
||||||
|
// Match sessions by: id (session id), jira_issue_key (linked ticket), project (broad)
|
||||||
|
const where =
|
||||||
|
kind === 'jira'
|
||||||
|
? '(s.id = $1 OR s.jira_issue_key = $1)'
|
||||||
|
: kind === 'project'
|
||||||
|
? 's.project = $1'
|
||||||
|
: '1=0';
|
||||||
|
|
||||||
|
const rows = await query<{
|
||||||
|
id: string;
|
||||||
|
project: string;
|
||||||
|
jira_issue_key: string | null;
|
||||||
|
started_at: string;
|
||||||
|
ended_at: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
status: string | null;
|
||||||
|
duration_minutes: number | null;
|
||||||
|
}>(
|
||||||
|
`SELECT id, project, jira_issue_key, started_at, ended_at, summary, status, duration_minutes
|
||||||
|
FROM sessions s
|
||||||
|
WHERE ${where}
|
||||||
|
AND s.started_at >= ${since}
|
||||||
|
AND s.started_at <= ${until}
|
||||||
|
ORDER BY s.started_at DESC
|
||||||
|
LIMIT 100`,
|
||||||
|
[subject]
|
||||||
|
);
|
||||||
|
|
||||||
|
const events: TimelineEvent[] = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
events.push({
|
||||||
|
ts: r.started_at,
|
||||||
|
source: 'session',
|
||||||
|
type: 'session_start',
|
||||||
|
subject: r.id,
|
||||||
|
summary: `Session ${r.id} started${r.jira_issue_key ? ` on ${r.jira_issue_key}` : ''}`,
|
||||||
|
details: { project: r.project, status: r.status },
|
||||||
|
links: {
|
||||||
|
session: r.id,
|
||||||
|
jira: r.jira_issue_key || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (r.ended_at) {
|
||||||
|
events.push({
|
||||||
|
ts: r.ended_at,
|
||||||
|
source: 'session',
|
||||||
|
type: 'session_end',
|
||||||
|
subject: r.id,
|
||||||
|
summary: `Session ${r.id} ended (${r.duration_minutes ?? '?'}min)${r.summary ? ': ' + truncate(r.summary, 120) : ''}`,
|
||||||
|
details: { summary: r.summary, status: r.status },
|
||||||
|
links: { session: r.id, jira: r.jira_issue_key || undefined },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchNoteEvents(
|
||||||
|
subject: string,
|
||||||
|
kind: SubjectKind,
|
||||||
|
since: string,
|
||||||
|
until: string
|
||||||
|
): Promise<TimelineEvent[]> {
|
||||||
|
// Notes belong to sessions. Find via session linkage.
|
||||||
|
const sessionFilter =
|
||||||
|
kind === 'jira'
|
||||||
|
? '(s.id = $1 OR s.jira_issue_key = $1)'
|
||||||
|
: kind === 'project'
|
||||||
|
? 's.project = $1'
|
||||||
|
: '1=0';
|
||||||
|
|
||||||
|
const rows = await query<{
|
||||||
|
id: number;
|
||||||
|
session_id: string;
|
||||||
|
note_type: string;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
jira_issue_key: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT n.id, n.session_id, n.note_type, n.content, n.created_at, s.jira_issue_key
|
||||||
|
FROM session_notes n
|
||||||
|
JOIN sessions s ON s.id = n.session_id
|
||||||
|
WHERE ${sessionFilter}
|
||||||
|
AND n.created_at >= ${since}
|
||||||
|
AND n.created_at <= ${until}
|
||||||
|
ORDER BY n.created_at DESC
|
||||||
|
LIMIT 200`,
|
||||||
|
[subject]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
ts: r.created_at,
|
||||||
|
source: 'note' as const,
|
||||||
|
type: `note:${r.note_type}`,
|
||||||
|
subject: r.session_id,
|
||||||
|
summary: `[${r.note_type}] ${truncate(r.content, 140)}`,
|
||||||
|
details: { full: r.content },
|
||||||
|
links: { session: r.session_id, jira: r.jira_issue_key || undefined },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCommitEvents(
|
||||||
|
subject: string,
|
||||||
|
kind: SubjectKind,
|
||||||
|
since: string,
|
||||||
|
until: string
|
||||||
|
): Promise<TimelineEvent[]> {
|
||||||
|
// session_commits: via session linkage
|
||||||
|
const sessionFilter =
|
||||||
|
kind === 'jira'
|
||||||
|
? '(s.id = $1 OR s.jira_issue_key = $1)'
|
||||||
|
: kind === 'project'
|
||||||
|
? 's.project = $1'
|
||||||
|
: '1=0';
|
||||||
|
|
||||||
|
const rows = await query<{
|
||||||
|
commit_sha: string;
|
||||||
|
repo: string;
|
||||||
|
commit_message: string | null;
|
||||||
|
committed_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
session_id: string;
|
||||||
|
jira_issue_key: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT c.commit_sha, c.repo, c.commit_message, c.committed_at, c.created_at,
|
||||||
|
c.session_id, s.jira_issue_key
|
||||||
|
FROM session_commits c
|
||||||
|
JOIN sessions s ON s.id = c.session_id
|
||||||
|
WHERE ${sessionFilter}
|
||||||
|
AND COALESCE(c.committed_at, c.created_at) >= ${since}
|
||||||
|
AND COALESCE(c.committed_at, c.created_at) <= ${until}
|
||||||
|
ORDER BY COALESCE(c.committed_at, c.created_at) DESC
|
||||||
|
LIMIT 200`,
|
||||||
|
[subject]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
ts: r.committed_at || r.created_at,
|
||||||
|
source: 'commit' as const,
|
||||||
|
type: 'commit',
|
||||||
|
subject: `${r.repo}@${shortSha(r.commit_sha)}`,
|
||||||
|
summary: `${shortSha(r.commit_sha)} (${r.repo}) ${truncate(r.commit_message, 100)}`,
|
||||||
|
details: { full_message: r.commit_message },
|
||||||
|
links: {
|
||||||
|
session: r.session_id,
|
||||||
|
jira: r.jira_issue_key || undefined,
|
||||||
|
commit: { sha: r.commit_sha, repo: r.repo },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTaskCommitEvents(
|
||||||
|
subject: string,
|
||||||
|
kind: SubjectKind,
|
||||||
|
since: string,
|
||||||
|
until: string
|
||||||
|
): Promise<TimelineEvent[]> {
|
||||||
|
// task_commits links Jira issue → commit independently of sessions
|
||||||
|
if (kind !== 'jira') return [];
|
||||||
|
|
||||||
|
const rows = await query<{
|
||||||
|
task_id: string;
|
||||||
|
commit_sha: string;
|
||||||
|
repo: string;
|
||||||
|
source: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}>(
|
||||||
|
`SELECT task_id, commit_sha, repo, source, created_at
|
||||||
|
FROM task_commits
|
||||||
|
WHERE task_id = $1
|
||||||
|
AND created_at >= ${since}
|
||||||
|
AND created_at <= ${until}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 200`,
|
||||||
|
[subject]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
ts: r.created_at,
|
||||||
|
source: 'task_commit' as const,
|
||||||
|
type: 'commit_link',
|
||||||
|
subject: r.task_id,
|
||||||
|
summary: `${shortSha(r.commit_sha)} linked to ${r.task_id} (${r.repo}) [${r.source || 'manual'}]`,
|
||||||
|
details: { source: r.source },
|
||||||
|
links: {
|
||||||
|
jira: r.task_id,
|
||||||
|
commit: { sha: r.commit_sha, repo: r.repo },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlanEvents(
|
||||||
|
subject: string,
|
||||||
|
kind: SubjectKind,
|
||||||
|
since: string,
|
||||||
|
until: string
|
||||||
|
): Promise<TimelineEvent[]> {
|
||||||
|
const sessionFilter =
|
||||||
|
kind === 'jira'
|
||||||
|
? '(s.id = $1 OR s.jira_issue_key = $1)'
|
||||||
|
: kind === 'project'
|
||||||
|
? 's.project = $1'
|
||||||
|
: '1=0';
|
||||||
|
|
||||||
|
const rows = await query<{
|
||||||
|
id: number;
|
||||||
|
session_id: string;
|
||||||
|
plan_file_name: string | null;
|
||||||
|
status: string | null;
|
||||||
|
created_at: string;
|
||||||
|
approved_at: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
jira_issue_key: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT p.id, p.session_id, p.plan_file_name, p.status,
|
||||||
|
p.created_at, p.approved_at, p.completed_at, s.jira_issue_key
|
||||||
|
FROM session_plans p
|
||||||
|
JOIN sessions s ON s.id = p.session_id
|
||||||
|
WHERE ${sessionFilter}
|
||||||
|
AND p.created_at >= ${since}
|
||||||
|
AND p.created_at <= ${until}
|
||||||
|
ORDER BY p.created_at DESC
|
||||||
|
LIMIT 50`,
|
||||||
|
[subject]
|
||||||
|
);
|
||||||
|
|
||||||
|
const events: TimelineEvent[] = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
const label = r.plan_file_name || `plan#${r.id}`;
|
||||||
|
events.push({
|
||||||
|
ts: r.created_at,
|
||||||
|
source: 'plan',
|
||||||
|
type: 'plan_created',
|
||||||
|
subject: r.session_id,
|
||||||
|
summary: `Plan created: ${label}`,
|
||||||
|
details: { status: r.status },
|
||||||
|
links: { session: r.session_id, jira: r.jira_issue_key || undefined },
|
||||||
|
});
|
||||||
|
if (r.approved_at) {
|
||||||
|
events.push({
|
||||||
|
ts: r.approved_at,
|
||||||
|
source: 'plan',
|
||||||
|
type: 'plan_approved',
|
||||||
|
subject: r.session_id,
|
||||||
|
summary: `Plan approved: ${label}`,
|
||||||
|
details: {},
|
||||||
|
links: { session: r.session_id, jira: r.jira_issue_key || undefined },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (r.completed_at) {
|
||||||
|
events.push({
|
||||||
|
ts: r.completed_at,
|
||||||
|
source: 'plan',
|
||||||
|
type: 'plan_completed',
|
||||||
|
subject: r.session_id,
|
||||||
|
summary: `Plan completed: ${label}`,
|
||||||
|
details: {},
|
||||||
|
links: { session: r.session_id, jira: r.jira_issue_key || undefined },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Jira source ----------
|
||||||
|
|
||||||
|
function filterByTimeWindow<T extends { ts: string }>(events: T[], sinceISO: string, untilISO: string): T[] {
|
||||||
|
const sinceMs = new Date(sinceISO).getTime();
|
||||||
|
const untilMs = new Date(untilISO).getTime();
|
||||||
|
return events.filter(e => {
|
||||||
|
const t = new Date(e.ts).getTime();
|
||||||
|
return t >= sinceMs && t <= untilMs;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve relative "since" into ISO8601 for client-side filtering of Jira data
|
||||||
|
function resolveSinceISO(since?: string): string {
|
||||||
|
if (!since) return new Date(Date.now() - 7 * 86400_000).toISOString();
|
||||||
|
const rel = since.match(/^-(\d+)([dhm])$/);
|
||||||
|
if (rel) {
|
||||||
|
const n = parseInt(rel[1]);
|
||||||
|
const ms = rel[2] === 'd' ? n * 86400_000 : rel[2] === 'h' ? n * 3600_000 : n * 60_000;
|
||||||
|
return new Date(Date.now() - ms).toISOString();
|
||||||
|
}
|
||||||
|
return since;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUntilISO(until?: string): string {
|
||||||
|
return until || new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJiraEvents(
|
||||||
|
subject: string,
|
||||||
|
kind: SubjectKind,
|
||||||
|
sinceISO: string,
|
||||||
|
untilISO: string
|
||||||
|
): Promise<TimelineEvent[]> {
|
||||||
|
if (kind !== 'jira') return [];
|
||||||
|
|
||||||
|
const issue = await getIssueWithHistory(subject);
|
||||||
|
if (!issue) return [];
|
||||||
|
|
||||||
|
const events: TimelineEvent[] = [];
|
||||||
|
|
||||||
|
// Creation event
|
||||||
|
if (issue.created) {
|
||||||
|
events.push({
|
||||||
|
ts: issue.created,
|
||||||
|
source: 'jira',
|
||||||
|
type: 'issue_created',
|
||||||
|
subject: issue.key,
|
||||||
|
summary: `${issue.issueType} created: ${issue.summary}`,
|
||||||
|
details: { labels: issue.labels, creator: issue.creator },
|
||||||
|
links: { jira: issue.key },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changelog entries (field changes)
|
||||||
|
for (const ch of issue.changelog) {
|
||||||
|
// Prioritize status transitions, assignee changes, resolution changes
|
||||||
|
const notable = new Set(['status', 'assignee', 'resolution', 'priority', 'labels']);
|
||||||
|
if (!notable.has(ch.field)) continue;
|
||||||
|
const fromStr = ch.from ?? '∅';
|
||||||
|
const toStr = ch.to ?? '∅';
|
||||||
|
events.push({
|
||||||
|
ts: ch.ts,
|
||||||
|
source: 'jira',
|
||||||
|
type: `field_change:${ch.field}`,
|
||||||
|
subject: issue.key,
|
||||||
|
summary: `${ch.field}: ${fromStr} → ${toStr} by ${ch.author}`,
|
||||||
|
details: { field: ch.field, from: ch.from, to: ch.to, author: ch.author },
|
||||||
|
links: { jira: issue.key },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
for (const c of issue.comments) {
|
||||||
|
events.push({
|
||||||
|
ts: c.ts,
|
||||||
|
source: 'jira',
|
||||||
|
type: 'comment',
|
||||||
|
subject: issue.key,
|
||||||
|
summary: `💬 ${c.author}: ${truncate(c.body, 120)}`,
|
||||||
|
details: { author: c.author, body: c.body },
|
||||||
|
links: { jira: issue.key },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filterByTimeWindow(events, sinceISO, untilISO);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLinkedSessionIssueKeys(subject: string, kind: SubjectKind): Promise<string[]> {
|
||||||
|
// For a task issue, find session-tracking issues that relate to it
|
||||||
|
if (kind !== 'jira') return [];
|
||||||
|
const jql = `labels = "session-tracking" AND issueFunction in linkedIssuesOf("key = ${subject}")`;
|
||||||
|
// Fallback: simpler query that works without Script Runner
|
||||||
|
const simpleJql = `labels = "session-tracking" AND text ~ "${subject}"`;
|
||||||
|
try {
|
||||||
|
const keys = await searchIssueKeys(simpleJql, 20);
|
||||||
|
return keys;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- main tool ----------
|
||||||
|
|
||||||
|
export async function timeline(args: TimelineArgs): Promise<string> {
|
||||||
|
const subject = args.subject?.trim();
|
||||||
|
if (!subject) {
|
||||||
|
return 'Error: subject is required (Jira key, session id, or project key)';
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = classifySubject(subject);
|
||||||
|
if (kind === 'unknown') {
|
||||||
|
return `Error: could not classify subject "${subject}". Expected Jira key (CF-123), session id, or project key (CF).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const since = resolveSince(args.since);
|
||||||
|
const until = resolveUntil(args.until);
|
||||||
|
const sinceISO = resolveSinceISO(args.since);
|
||||||
|
const untilISO = resolveUntilISO(args.until);
|
||||||
|
const limit = args.limit ?? 100;
|
||||||
|
const sourceFilter = new Set<EventSource>(
|
||||||
|
args.sources ?? ['session', 'note', 'commit', 'plan', 'task_commit', 'jira']
|
||||||
|
);
|
||||||
|
|
||||||
|
// For Jira subjects, also fetch any linked session-tracking issues and include their history
|
||||||
|
const jiraTargets: string[] = [];
|
||||||
|
if (kind === 'jira' && sourceFilter.has('jira')) {
|
||||||
|
jiraTargets.push(subject);
|
||||||
|
// Also pull linked session-tracking issues (if this is a task, its session issues)
|
||||||
|
const linked = await fetchLinkedSessionIssueKeys(subject, kind);
|
||||||
|
for (const k of linked) {
|
||||||
|
if (k !== subject) jiraTargets.push(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run all queries in parallel
|
||||||
|
const [sessionEvents, noteEvents, commitEvents, planEvents, taskCommitEvents, ...jiraEventArrays] = await Promise.all([
|
||||||
|
sourceFilter.has('session') ? fetchSessionEvents(subject, kind, since, until) : Promise.resolve([]),
|
||||||
|
sourceFilter.has('note') ? fetchNoteEvents(subject, kind, since, until) : Promise.resolve([]),
|
||||||
|
sourceFilter.has('commit') ? fetchCommitEvents(subject, kind, since, until) : Promise.resolve([]),
|
||||||
|
sourceFilter.has('plan') ? fetchPlanEvents(subject, kind, since, until) : Promise.resolve([]),
|
||||||
|
sourceFilter.has('task_commit') ? fetchTaskCommitEvents(subject, kind, since, until) : Promise.resolve([]),
|
||||||
|
...jiraTargets.map(k => fetchJiraEvents(k, 'jira', sinceISO, untilISO)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const jiraEvents = jiraEventArrays.flat();
|
||||||
|
const all = [...sessionEvents, ...noteEvents, ...commitEvents, ...planEvents, ...taskCommitEvents, ...jiraEvents];
|
||||||
|
|
||||||
|
// Sort chronologically (oldest → newest by default for narrative reading)
|
||||||
|
all.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
|
||||||
|
|
||||||
|
const limited = all.slice(-limit);
|
||||||
|
|
||||||
|
if (limited.length === 0) {
|
||||||
|
return `📭 No events for ${subject} (${kind}) in window ${args.since || '-7d'} → ${args.until || 'now'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format as markdown timeline
|
||||||
|
let output = `📜 **Timeline: ${subject}** (${kind}, ${limited.length} events)\n`;
|
||||||
|
output += `Window: ${args.since || '-7d'} → ${args.until || 'now'}\n\n`;
|
||||||
|
|
||||||
|
let lastDate = '';
|
||||||
|
for (const e of limited) {
|
||||||
|
const d = new Date(e.ts);
|
||||||
|
const dateStr = d.toISOString().substring(0, 10);
|
||||||
|
const timeStr = d.toISOString().substring(11, 16);
|
||||||
|
if (dateStr !== lastDate) {
|
||||||
|
output += `\n**${dateStr}**\n`;
|
||||||
|
lastDate = dateStr;
|
||||||
|
}
|
||||||
|
const icon = {
|
||||||
|
session: '🗂️',
|
||||||
|
note: '📝',
|
||||||
|
commit: '🔨',
|
||||||
|
plan: '📋',
|
||||||
|
task_commit: '🔗',
|
||||||
|
jira: '🎫',
|
||||||
|
}[e.source];
|
||||||
|
output += ` \`${timeStr}\` ${icon} \`${e.type}\` ${e.summary}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
161
src/tools/transcripts.ts
Normal file
161
src/tools/transcripts.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
// Session transcript search (CF-2394)
|
||||||
|
|
||||||
|
import { query } from '../db.js';
|
||||||
|
import { getEmbedding, formatEmbedding, rrfMerge, rerank } from '../embeddings.js';
|
||||||
|
|
||||||
|
interface TranscriptSearchArgs {
|
||||||
|
query: string;
|
||||||
|
project?: string;
|
||||||
|
session_issue_key?: string;
|
||||||
|
limit?: number;
|
||||||
|
search_mode?: 'hybrid' | 'vector' | 'keyword';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TranscriptRow {
|
||||||
|
id: number;
|
||||||
|
session_uuid: string;
|
||||||
|
session_issue_key: string | null;
|
||||||
|
project_key: string;
|
||||||
|
git_branch: string | null;
|
||||||
|
message_count: number;
|
||||||
|
tool_names: string[] | null;
|
||||||
|
started_at: string | null;
|
||||||
|
similarity?: number;
|
||||||
|
rank?: number;
|
||||||
|
snippet?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transcriptSearch(args: TranscriptSearchArgs): Promise<string> {
|
||||||
|
const { query: searchQuery, project, session_issue_key, limit = 10, search_mode = 'hybrid' } = args;
|
||||||
|
|
||||||
|
const buildFilter = (startIdx: number) => {
|
||||||
|
let where = '';
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let idx = startIdx;
|
||||||
|
if (project) {
|
||||||
|
where += ` AND project_key = $${idx++}`;
|
||||||
|
params.push(project);
|
||||||
|
}
|
||||||
|
if (session_issue_key) {
|
||||||
|
where += ` AND session_issue_key = $${idx++}`;
|
||||||
|
params.push(session_issue_key);
|
||||||
|
}
|
||||||
|
return { where, params, nextIdx: idx };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vector search
|
||||||
|
let vectorIds: number[] = [];
|
||||||
|
let vectorRows: Map<number, TranscriptRow> = new Map();
|
||||||
|
let embeddingFailed = false;
|
||||||
|
|
||||||
|
if (search_mode !== 'keyword') {
|
||||||
|
const embedding = await getEmbedding(searchQuery);
|
||||||
|
if (embedding) {
|
||||||
|
const embeddingStr = formatEmbedding(embedding);
|
||||||
|
const filter = buildFilter(3);
|
||||||
|
const params: unknown[] = [embeddingStr, limit, ...filter.params];
|
||||||
|
|
||||||
|
const rows = await query<TranscriptRow>(
|
||||||
|
`SELECT id, session_uuid, session_issue_key, project_key, git_branch,
|
||||||
|
message_count, tool_names,
|
||||||
|
to_char(started_at, 'YYYY-MM-DD HH24:MI') as started_at,
|
||||||
|
1 - (embedding <=> $1) as similarity
|
||||||
|
FROM session_transcripts
|
||||||
|
WHERE embedding IS NOT NULL${filter.where}
|
||||||
|
ORDER BY embedding <=> $1
|
||||||
|
LIMIT $2`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
vectorIds = rows.map(r => r.id);
|
||||||
|
for (const r of rows) vectorRows.set(r.id, r);
|
||||||
|
} else {
|
||||||
|
embeddingFailed = true;
|
||||||
|
if (search_mode === 'vector') {
|
||||||
|
return 'Error: Could not generate embedding for vector search';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyword search
|
||||||
|
let keywordIds: number[] = [];
|
||||||
|
let keywordRows: Map<number, TranscriptRow> = new Map();
|
||||||
|
|
||||||
|
if (search_mode !== 'vector') {
|
||||||
|
const filter = buildFilter(3);
|
||||||
|
const params: unknown[] = [searchQuery, limit, ...filter.params];
|
||||||
|
|
||||||
|
const rows = await query<TranscriptRow>(
|
||||||
|
`SELECT id, session_uuid, session_issue_key, project_key, git_branch,
|
||||||
|
message_count, tool_names,
|
||||||
|
to_char(started_at, 'YYYY-MM-DD HH24:MI') as started_at,
|
||||||
|
ts_rank(tsv, plainto_tsquery('english', $1)) as rank,
|
||||||
|
ts_headline('english', searchable_content,
|
||||||
|
plainto_tsquery('english', $1),
|
||||||
|
'StartSel=**,StopSel=**,MaxWords=25,MinWords=8') as snippet
|
||||||
|
FROM session_transcripts
|
||||||
|
WHERE tsv @@ plainto_tsquery('english', $1)${filter.where}
|
||||||
|
ORDER BY rank DESC
|
||||||
|
LIMIT $2`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
keywordIds = rows.map(r => r.id);
|
||||||
|
for (const r of rows) keywordRows.set(r.id, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge results
|
||||||
|
let finalIds: number[];
|
||||||
|
let searchLabel: string;
|
||||||
|
|
||||||
|
if (search_mode === 'hybrid' && vectorIds.length > 0 && keywordIds.length > 0) {
|
||||||
|
const merged = rrfMerge(vectorIds, keywordIds);
|
||||||
|
finalIds = merged.map(m => m.id as number);
|
||||||
|
searchLabel = 'hybrid';
|
||||||
|
|
||||||
|
// Re-rank using snippets
|
||||||
|
const docs = finalIds.map(id => {
|
||||||
|
const r = keywordRows.get(id) || vectorRows.get(id);
|
||||||
|
return r?.snippet || r?.session_issue_key || '';
|
||||||
|
});
|
||||||
|
const reranked = await rerank(searchQuery, docs, limit);
|
||||||
|
if (reranked) {
|
||||||
|
finalIds = reranked.map(r => finalIds[r.index]);
|
||||||
|
searchLabel = 'hybrid+rerank';
|
||||||
|
} else {
|
||||||
|
finalIds = finalIds.slice(0, limit);
|
||||||
|
}
|
||||||
|
} else if (vectorIds.length > 0) {
|
||||||
|
finalIds = vectorIds;
|
||||||
|
searchLabel = 'vector';
|
||||||
|
} else if (keywordIds.length > 0) {
|
||||||
|
finalIds = keywordIds;
|
||||||
|
searchLabel = embeddingFailed ? 'keyword (embedding unavailable)' : 'keyword';
|
||||||
|
} else {
|
||||||
|
return 'No matching transcripts found';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format output
|
||||||
|
const lines = [`Session transcripts (${searchLabel}, ${finalIds.length} results):\n`];
|
||||||
|
for (const id of finalIds) {
|
||||||
|
const r = vectorRows.get(id) || keywordRows.get(id);
|
||||||
|
if (!r) continue;
|
||||||
|
|
||||||
|
const scoreParts: string[] = [];
|
||||||
|
if (vectorRows.has(id)) scoreParts.push(`${Math.round(vectorRows.get(id)!.similarity! * 100)}% semantic`);
|
||||||
|
if (keywordRows.has(id)) scoreParts.push(`rank: ${keywordRows.get(id)!.rank!.toFixed(3)}`);
|
||||||
|
const scores = scoreParts.length > 0 ? ` (${scoreParts.join(', ')})` : '';
|
||||||
|
|
||||||
|
const issueLink = r.session_issue_key
|
||||||
|
? `[${r.session_issue_key}](https://agiliton.atlassian.net/browse/${r.session_issue_key})`
|
||||||
|
: 'unlinked';
|
||||||
|
const tools = r.tool_names?.slice(0, 5).join(', ') || 'none';
|
||||||
|
|
||||||
|
lines.push(`**#${r.id}** ${issueLink} — ${r.project_key} (${r.git_branch || 'no-branch'})${scores}`);
|
||||||
|
lines.push(` ${r.started_at || 'unknown date'} | ${r.message_count} msgs | Tools: ${tools}`);
|
||||||
|
if (r.snippet) {
|
||||||
|
lines.push(` > ${r.snippet.replace(/\n/g, ' ').substring(0, 150)}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user