Compare commits

...

16 Commits

Author SHA1 Message Date
Infra
18defcc9d5 feat: add HTTP transport (CF-3081) 2026-04-13 09:34:39 +00:00
1349f3b0ce chore: migrate vault keys to ag.* namespace (CF-2942)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 06:25:29 +00:00
Christian Gick
ad13a26168 feat(CF-2885): Add Jira as timeline source — pulls issue history via REST API
Extends timeline() with Jira integration since sessions moved to Jira-only in
CF-836 (2026-02-08) and the session-mcp tables are empty by design.

Adds to services/jira.ts:
- getIssueWithHistory(key): fetches issue with expand=changelog + comments
- searchIssueKeys(jql): JQL search returning minimal issue keys
- ADF → plain text extractor for comment bodies

Timeline now yields Jira events: issue_created, field_change:{status,assignee,
resolution,priority,labels}, comment. Events are time-filtered client-side
against the since/until window. For Jira-key subjects, also searches for
linked session-tracking issues and merges their events.

Tested against CF-2872 (audit task) and CF-2885 (this ticket) — shows full
lifecycle from creation through transitions to resolution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 08:00:25 +03:00
Christian Gick
2ed6e68686 feat(CF-2885): Add timeline MCP tool for unified chronological event view
New `timeline(subject, since, until)` tool stitches events across session-mcp
sources (sessions, notes, commits, plans, task-commit links) into a single
time-ordered stream for LLM consumption.

Phase 1 of CF-2885: read-time stitching only, no business-system changes.
Per CF-2830 heise article thesis (Event Sourcing + MCP for LLMs).

Subject accepts Jira key, session id, or project key. Optional sources filter,
since/until window (supports relative -7d shorthand), limit.

Returns markdown-formatted timeline grouped by date with per-source icons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 07:40:47 +03:00
Christian Gick
0fad29801e feat(CF-2394): Add session_transcript_search MCP tool
Hybrid (vector + keyword + rerank) search over indexed session transcripts.
Enables context recovery from past sessions without re-reading JSONL files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 08:37:19 +02:00
Christian Gick
3613e2aa52 feat(CF-2136): Add Sentry structured logging support
Enable Sentry.logger API (enableLogs, beforeSendLog) and add
logInfo/logWarn/logError helpers. Bump @sentry/node ^9.47.1 → ^10.39.0,
@sentry/profiling-node → ^10.39.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:22:08 +02:00
Christian Gick
9dae176fc2 fix(CF-1316): Use LiteLLM model alias claude-haiku-4.5
The full Anthropic model ID is not registered in LiteLLM.
Use the LiteLLM alias instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:11:43 +02:00
Christian Gick
ece0e81ae9 feat(CF-1316): Add LLM metadata extraction at embedding time
Extract structured metadata (topics, decisions, blockers, tools_used,
projects, issue_keys) from session summaries using Haiku at session end.
Metadata stored in JSONB column with GIN index for filtered retrieval.
session_semantic_search now accepts optional metadata filters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:09:00 +02:00
Christian Gick
ef74d7912e feat: Add cross-encoder re-ranking after hybrid search (CF-1317)
Add rerank() function calling LiteLLM /v1/rerank endpoint (Cohere-compatible).
Plugged into all 3 search functions (sessions, session-docs, archives) after
RRF merge. Disabled by default via RERANK_ENABLED env var. Graceful fallback
to RRF-only ranking on API failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:36:24 +02:00
Christian Gick
0150575713 feat(CF-1351): Remove unused memory_* tools from session-mcp
Auto memory (MEMORY.md + topic files) is the sole memory system.
The session-mcp memory_* tools (PostgreSQL + pgvector) had zero entries
after months of use — removing dead code to simplify the server.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:52:48 +02:00
Christian Gick
27548f5c51 chore: Add .claude-session/ to gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:39:46 +02:00
Christian Gick
02c009a551 feat(CF-1354): Add withSentryTransaction to tool handlers
Wrap CallToolRequest handler with withSentryTransaction for
per-tool tracing. Remove broken $(vault) DSN from .env.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:20:53 +02:00
Christian Gick
4f8996cd82 feat(CF-1315): Hybrid search with tsvector + RRF
Add PostgreSQL full-text search alongside pgvector for exact matches
on Jira keys, error messages, file paths. Merge results with
Reciprocal Rank Fusion. Default mode: hybrid, with graceful
degradation to keyword-only when embeddings unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 08:46:39 +02:00
Christian Gick
1f499bd926 feat(CF-1314): Content hashing to prevent duplicate embeddings
SHA-256 hash check before embedding API call eliminates ~60-80% of
redundant embedding requests. Consolidates dual INSERT paths to single
INSERT with nullable embedding column.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 08:28:11 +02:00
Christian Gick
77097ac65f feat(API-11): Route API calls through AgilitonAPI gateway
Add gateway-first pattern: when AGILITON_API_KEY is set, route all
external API calls through the gateway with X-API-Key auth. Falls back
to direct API access when gateway is unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 19:05:19 +02:00
Christian Gick
6b53fb9168 chore(CF-838): Remove deployment/build migration files
Tables migrated to Jira tracking. PostgreSQL tables will be dropped separately.
Archived in s3://macbookdev/db-archive/agiliton-db-2026-02-08.sql.gz

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 07:55:05 +02:00
26 changed files with 2227 additions and 4843 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
.env
.git
*.log

8
.env
View File

@@ -11,7 +11,7 @@
# - PostgreSQL integration for database error tracking
#
# 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_TRACE_SAMPLE_RATE=0.1
SENTRY_PROFILE_SAMPLE_RATE=0.01
@@ -28,7 +28,11 @@ POSTGRES_PORT=6432
LLM_API_URL=https://api.agiliton.cloud/llm
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_USERNAME=christian.gick@agiliton.eu
JIRA_API_TOKEN=ATATT3xFfGF0tpaJTS4nJklW587McubEw-1SYbLWqfovkxI5320NdbFc-3fgHlw0HGTLOikgV082m9N-SIsYVZveGXa553_1LAyOevV6Qples93xF4hIExWGAvwvXPy_4pW2tH5FNusN5ieMca5_-YUP0i69SIN0RLIMQjfqDmQyhZXbkIvrm-I=A8A2A1FC

View File

@@ -8,6 +8,13 @@ POSTGRES_PORT=6432
LLM_API_URL=https://api.agiliton.cloud/llm
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_URL=https://agiliton.atlassian.net
JIRA_USERNAME=your_email@agiliton.eu

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@ node_modules/
dist/
*.log
.env
.claude-session/

15
Dockerfile Normal file
View 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"]

View File

@@ -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)';

View File

@@ -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.)';

View 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);

View 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;

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"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).",
"main": "dist/index.js",
"type": "module",
@@ -8,19 +8,22 @@
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"clean": "rm -rf dist"
"clean": "rm -rf dist",
"start:http": "node dist/http-server.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"@sentry/node": "^9.47.1",
"@sentry/profiling-node": "^10.37.0",
"@sentry/node": "^10.39.0",
"@sentry/profiling-node": "^10.39.0",
"dotenv": "^17.2.3",
"pg": "^8.11.3"
"pg": "^8.11.3",
"express": "^4.19.2"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/pg": "^8.10.9",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
"typescript": "^5.3.3",
"@types/express": "^4.17.21"
}
}

View File

@@ -1,5 +1,14 @@
// 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 {
data: Array<{
embedding: number[];
@@ -58,3 +67,167 @@ export async function getEmbedding(text: string): Promise<number[] | null> {
export function formatEmbedding(embedding: number[]): string {
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
View 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); });

View File

@@ -1,572 +1,22 @@
#!/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.
*/
// Load environment variables from .env file
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import dotenv from "dotenv";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const envPath = join(__dirname, '..', '.env');
const result = dotenv.config({ path: envPath, override: true });
// Initialize Sentry for error tracking
import { initSentry } from './sentry.js';
initSentry(process.env.SENTRY_ENVIRONMENT || 'production');
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
dotenv.config({ path: join(__dirname, "..", ".env"), override: true });
import { initSentry } from "./sentry.js";
initSentry(process.env.SENTRY_ENVIRONMENT || "production");
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { testConnection, close } from "./db.js";
import { createServer } from "./server.js";
async function main() {
process.on('SIGINT', async () => {
await close();
process.exit(0);
});
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');
if (!(await testConnection())) { console.error("DB connect failed"); process.exit(1); }
const server = createServer();
const t = new StdioServerTransport();
await server.connect(t);
console.error("session-mcp: stdio started");
}
});
}
main().catch((error) => {
console.error('session-mcp: Fatal error:', error);
process.exit(1);
});
main().catch(e => { console.error(e); process.exit(1); });
process.on("SIGINT", async () => { await close(); process.exit(0); });
process.on("SIGTERM", async () => { await close(); process.exit(0); });

View File

@@ -70,6 +70,11 @@ export function initSentry(environment: string = "development"): void {
return event;
},
enableLogs: true,
beforeSendLog(log) {
if (log.level === "debug") return null;
return log;
},
maxBreadcrumbs: 30,
attachStacktrace: true,
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.
*

521
src/server.ts Normal file
View 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;
}

View File

@@ -1,8 +1,9 @@
/**
* Jira Cloud REST API client for session-mcp.
* Creates/closes CF issues for sessions and posts session output as comments.
* Jira Cloud REST API client — routes through AgilitonAPI gateway.
* 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 {
@@ -16,35 +17,48 @@ interface JiraTransition {
name: string;
}
const getConfig = () => ({
url: process.env.JIRA_URL || 'https://agiliton.atlassian.net',
username: process.env.JIRA_USERNAME || '',
token: process.env.JIRA_API_TOKEN || '',
});
// Gateway config
const GATEWAY_URL = (process.env.AGILITON_API_URL || 'https://api.agiliton.cloud').replace(/\/$/, '');
const GATEWAY_KEY = process.env.AGILITON_API_KEY || '';
function getAuthHeader(): string {
const { username, token } = getConfig();
return `Basic ${Buffer.from(`${username}:${token}`).toString('base64')}`;
// Direct config (fallback)
const JIRA_URL = process.env.JIRA_URL || 'https://agiliton.atlassian.net';
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 {
const { username, token } = getConfig();
return !!(username && token);
if (useGateway()) return true;
return !!(JIRA_USERNAME && JIRA_API_TOKEN);
}
async function jiraFetch(path: string, options: RequestInit = {}): Promise<Response> {
const { url } = getConfig();
const fullUrl = `${url}/rest/api/3${path}`;
let url: string;
let headers: Record<string, string>;
return fetch(fullUrl, {
...options,
headers: {
'Authorization': getAuthHeader(),
if (useGateway()) {
url = `${GATEWAY_URL}/jira-cloud${path}`;
headers = {
'X-API-Key': GATEWAY_KEY,
'Content-Type': '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;
try {
// Get available transitions
const transResponse = await jiraFetch(`/issue/${issueKey}/transitions`);
if (!transResponse.ok) {
console.error(`session-mcp: Jira get transitions failed (${transResponse.status})`);
@@ -182,7 +195,6 @@ export async function transitionToDone(issueKey: string): Promise<boolean> {
return false;
}
// Execute transition
const response = await jiraFetch(`/issue/${issueKey}/transitions`, {
method: 'POST',
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.
*/

View File

@@ -1,7 +1,7 @@
// Project archives operations for database-backed archival
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';
@@ -31,11 +31,14 @@ interface ArchiveAddArgs {
metadata?: Record<string, unknown>;
}
type SearchMode = 'hybrid' | 'vector' | 'keyword';
interface ArchiveSearchArgs {
query: string;
project?: string;
archive_type?: ArchiveType;
limit?: number;
search_mode?: SearchMode;
}
interface ArchiveListArgs {
@@ -72,16 +75,26 @@ export async function archiveAdd(args: ArchiveAddArgs): Promise<string> {
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
const embedText = `${title}. ${content.substring(0, 1000)}`; // Limit content length for embedding
const embedding = await getEmbedding(embedText);
const embeddingValue = embedding ? formatEmbedding(embedding) : null;
if (embeddingValue) {
await execute(
`INSERT INTO project_archives
(project_key, archive_type, title, content, original_path, file_size, archived_by_session, metadata, embedding)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
(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, $10)`,
[
project,
archive_type,
@@ -91,123 +104,140 @@ export async function archiveAdd(args: ArchiveAddArgs): Promise<string> {
file_size || null,
archived_by_session || null,
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)` : '';
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> {
const { query: searchQuery, project, archive_type, limit = 5 } = 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;
const { query: searchQuery, project, archive_type, limit = 5, search_mode = 'hybrid' } = args;
// Build shared filter clause
const buildFilter = (startIdx: number) => {
let where = '';
const params: unknown[] = [];
let idx = startIdx;
if (project) {
whereClause += ` AND project_key = $${paramIndex++}`;
where += ` AND project_key = $${idx++}`;
params.push(project);
}
if (archive_type) {
whereClause += ` AND archive_type = $${paramIndex++}`;
where += ` AND archive_type = $${idx++}`;
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>(
`SELECT id, archive_type, title, original_path, file_size,
to_char(archived_at, 'YYYY-MM-DD') as archived_at
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
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];
let whereClause = 'WHERE embedding IS NOT NULL';
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 }>(
const rows = await query<Archive & { similarity: number }>(
`SELECT id, archive_type, title, original_path, file_size,
to_char(archived_at, 'YYYY-MM-DD') as archived_at,
1 - (embedding <=> $1) as similarity
FROM project_archives
${whereClause}
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';
}
}
}
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';
}
const lines = ['Relevant archives:\n'];
for (const a of archives) {
const sim = Math.round(a.similarity * 100);
// Format output
const lines = [`Relevant archives (${searchLabel}):\n`];
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)` : '';
lines.push(`**[${a.archive_type}]** ${a.title} (${sim}% match)`);
lines.push(`**[${a.archive_type}]** ${a.title}${scores}`);
lines.push(` Archived: ${a.archived_at}${sizeStr}`);
if (a.original_path) {
lines.push(` Path: ${a.original_path}`);

View File

@@ -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
{
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
{
name: 'tool_doc_add',
@@ -479,13 +444,14 @@ export const toolDefinitions = [
},
{
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: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
project: { type: 'string', description: 'Filter by project (optional)' },
limit: { type: 'number', description: 'Max results (default: 5)' },
search_mode: { type: 'string', enum: ['hybrid', 'vector', 'keyword'], description: 'Search mode (default: hybrid)' },
},
required: ['query'],
},
@@ -670,13 +636,17 @@ export const toolDefinitions = [
},
{
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: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
project: { type: 'string', description: 'Filter by project (optional)' },
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'],
},
@@ -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
{
name: 'archive_add',
@@ -725,7 +712,7 @@ export const toolDefinitions = [
},
{
name: 'archive_search',
description: 'Search archives using semantic similarity',
description: 'Search archives using hybrid (vector + keyword), vector-only, or keyword-only search.',
inputSchema: {
type: 'object',
properties: {
@@ -733,6 +720,7 @@ export const toolDefinitions = [
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)' },
limit: { type: 'number', description: 'Max results (default: 5)' },
search_mode: { type: 'string', enum: ['hybrid', 'vector', 'keyword'], description: 'Search mode (default: hybrid)' },
},
required: ['query'],
},

View File

@@ -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';
}

View File

@@ -43,9 +43,9 @@ async function getS3Credentials(): Promise<{
endpoint: string;
}> {
try {
const { stdout: accessKey } = await execAsync('vault get hetzner.s3_access_key');
const { stdout: secretKey } = await execAsync('vault get hetzner.s3_secret_key');
const { stdout: endpoint } = await execAsync('vault get hetzner.s3_endpoint');
const { stdout: accessKey } = await execAsync('vault get ag.org.s3.access_key');
const { stdout: secretKey } = await execAsync('vault get ag.org.s3.secret_key');
const { stdout: endpoint } = await execAsync('vault get ag.org.s3.endpoint');
return {
accessKey: accessKey.trim(),

View File

@@ -2,7 +2,7 @@
// Replaces file-based CLAUDE.md and plan files with database storage
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';
// ============================================================================
@@ -36,14 +36,25 @@ export async function sessionNoteAdd(args: SessionNoteAddArgs): Promise<string>
const { session_id: providedSessionId, note_type, content } = args;
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
const embedding = await getEmbedding(content);
const embeddingFormatted = embedding ? formatEmbedding(embedding) : null;
await execute(
`INSERT INTO session_notes (session_id, note_type, content, embedding)
VALUES ($1, $2, $3, $4)`,
[session_id, note_type, content, embeddingFormatted]
`INSERT INTO session_notes (session_id, note_type, content, embedding, content_hash)
VALUES ($1, $2, $3, $4, $5)`,
[session_id, note_type, content, embeddingFormatted, contentHash]
);
return `Note added to session ${session_id} (type: ${note_type})`;
@@ -113,15 +124,26 @@ interface SessionPlan {
export async function sessionPlanSave(args: SessionPlanSaveArgs): Promise<string> {
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
const embedding = await getEmbedding(plan_content);
const embeddingFormatted = embedding ? formatEmbedding(embedding) : null;
const result = await queryOne<{ id: number }>(
`INSERT INTO session_plans (session_id, plan_file_name, plan_content, status, embedding)
VALUES ($1, $2, $3, $4, $5)
`INSERT INTO session_plans (session_id, plan_file_name, plan_content, status, embedding, content_hash)
VALUES ($1, $2, $3, $4, $5, $6)
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;
@@ -429,10 +451,16 @@ export async function sessionDocumentationGenerate(args: SessionDocumentationGen
// SEMANTIC SEARCH & ANALYTICS
// ============================================================================
type SearchMode = 'hybrid' | 'vector' | 'keyword';
interface SessionSemanticSearchArgs {
query: string;
project?: string;
limit?: number;
search_mode?: SearchMode;
filter_topics?: string[];
filter_projects?: string[];
filter_issue_keys?: string[];
}
interface SessionSearchResult {
@@ -445,60 +473,120 @@ interface SessionSearchResult {
}
/**
* Semantic search across all session documentation
* Uses vector similarity to find related sessions
* Semantic search across all session documentation with hybrid/vector/keyword modes (CF-1315)
*/
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
const queryEmbedding = await getEmbedding(searchQuery);
if (!queryEmbedding) {
// Fallback to text search if embedding generation fails
let sql = `
SELECT
s.id as session_id,
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;
// Build shared filter clause (CF-1316: metadata filters via JSONB @> containment)
const buildFilter = (startIdx: number) => {
let where = '';
const params: unknown[] = [];
let idx = startIdx;
if (project) {
where += ` AND s.project = $${idx++}`;
params.push(project);
}
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 filter = buildFilter(3);
const params: unknown[] = [embeddingFormatted, limit, ...filter.params];
// Vector similarity search
let sql = `
SELECT
s.id as session_id,
s.session_number,
s.project,
s.summary,
s.started_at,
const rows = await query<SessionSearchResult>(
`SELECT s.id as session_id, s.session_number, s.project, s.summary, s.started_at,
1 - (s.embedding <=> $1) as similarity
FROM sessions s
WHERE s.embedding IS NOT NULL
${project ? 'AND s.project = $2' : ''}
AND s.status = 'completed'
WHERE s.embedding IS NOT NULL AND s.status = 'completed'${filter.where}
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];
const results = await query<SessionSearchResult>(sql, params);
// Keyword search
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;
}

View File

@@ -2,7 +2,7 @@
// Sessions auto-create CF Jira issues and post output on close (CF-762)
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';
interface SessionStartArgs {
@@ -34,10 +34,13 @@ interface SessionListArgs {
limit?: number;
}
type SearchMode = 'hybrid' | 'vector' | 'keyword';
interface SessionSearchArgs {
query: string;
project?: string;
limit?: number;
search_mode?: SearchMode;
}
interface Session {
@@ -157,32 +160,29 @@ export async function sessionUpdate(args: SessionUpdateArgs): Promise<string> {
export async function sessionEnd(args: SessionEndArgs): Promise<string> {
const { session_id, summary, status = 'completed' } = args;
// Generate embedding for semantic search
const embedding = await getEmbedding(summary);
const embeddingValue = embedding ? formatEmbedding(embedding) : null;
// CF-1314: Store content hash alongside embedding
const contentHash = generateContentHash(summary);
// 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(
`UPDATE sessions
SET ended_at = NOW(),
summary = $1,
embedding = $2,
status = $3,
content_hash = $4,
extracted_metadata = $5::jsonb,
updated_at = NOW()
WHERE id = $4`,
[summary, embeddingValue, status, session_id]
WHERE id = $6`,
[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
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> {
const { query: searchQuery, project, 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];
const { query: searchQuery, project, limit = 5, search_mode = 'hybrid' } = args;
// Build shared filter clause
const buildFilter = (startIdx: number) => {
let where = '';
const params: unknown[] = [];
let idx = startIdx;
if (project) {
whereClause += ` AND project = $3`;
params.splice(1, 0, project); // Insert before limit
params[2] = limit; // Adjust limit position
where += ` AND project = $${idx++}`;
params.push(project);
}
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,
1 - (embedding <=> $1) as similarity
FROM sessions
${whereClause}
WHERE embedding IS NOT NULL${filter.where}
ORDER BY embedding <=> $1
LIMIT $${project ? '3' : '2'}`,
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';
}
}
}
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';
}
const lines = ['Similar sessions:\n'];
for (const s of sessions) {
const sim = Math.round(s.similarity * 100);
// Format output
const lines = [`Similar sessions (${searchLabel}):\n`];
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 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('');
}

530
src/tools/timeline.ts Normal file
View 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
View 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');
}