Files
ozan 0488cdda72
Build tinqs-git / build (push) Has started running
feat: migrate bot service from arikigame.com to tinqs.com domain
- Proxy: add bot.tinqs.com route alongside legacy bot.arikigame.com
- Bot: add /api/v1/ai/* rewrite alias for inference proxy (Cursor endpoint)
- Auth: update Gitea URL defaults from git.arikigame.com to tinqs.com
- UI: update all landing page, team-tool, callback URLs to tinqs.com
- Libs: update gitea.ts, design.ts, docs-search.ts, handoffs.ts,
  mcp-handler.ts, image-gen-context.ts to tinqs.com API base
- Config: add tinqs-ai provider entry in deeptinqs providers.json
- Tests: update smoke test default URL to bot.tinqs.com

All endpoints work on both domains during transition.
Old bot.arikigame.com stays in proxy routes for backwards compat.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-22 09:38:50 +01:00

668 lines
24 KiB
TypeScript

/**
* MCP JSON-RPC 2.0 handler for bot.arikigame.
*
* Implements Streamable HTTP transport (POST /api/mcp).
* Resources: tinqs://identity/*, tinqs://glossary/*
* Tools: get_identity, get_glossary, get_handoff, get_repo_file, search_docs,
* list_skills, get_skill, gitea_api, gh_api,
* list_active_meetings, get_live_transcript, query_meeting
*/
import { fetchFile, listDir, searchCode, listRepos } from "./gitea";
import { listGatewayPages, getGatewayPage } from "./gateway";
import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime";
import { listActiveMeetings, getMostRecentSessionId, getTranscript } from "./store";
import { getLastNMinutes, formatTranscript } from "./transcript";
import { callTacoClaude } from "./claude";
// --- Types ---
interface JsonRpcRequest {
jsonrpc: "2.0";
id: string | number;
method: string;
params?: Record<string, unknown>;
}
interface JsonRpcResponse {
jsonrpc: "2.0";
id: string | number;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
// --- Resource definitions ---
const IDENTITY_FILES: Record<string, { repo: string; path: string; cache: number }> = {
soul: { repo: "tinqs-ltd/docs", path: "ai/SOUL.md", cache: 300 },
company: { repo: "tinqs-ltd/docs", path: "ai/company.md", cache: 300 },
siblings: { repo: "tinqs-ltd/docs", path: "ai/siblings.md", cache: 300 },
urls: { repo: "tinqs-ltd/docs", path: "ai/urls.md", cache: 300 },
handoff: { repo: "tinqs-ltd/docs", path: "ai/handoff.md", cache: 60 },
gateway: { repo: "tinqs-ltd/docs", path: "ai/gateway.md", cache: 300 },
};
// --- MCP Protocol ---
const TOOLS = [
{
name: "get_identity",
description: "Returns combined hub identity: SOUL + company + siblings + urls. Call this first on session start.",
inputSchema: { type: "object" as const, properties: {}, required: [] as string[] },
},
{
name: "get_glossary",
description: "Returns speech-to-text glossary corrections for a specific user.",
inputSchema: {
type: "object" as const,
properties: { user: { type: "string", description: "User name (e.g. 'ozan', 'ozlem', 'jeremy')" } },
required: ["user"],
},
},
{
name: "get_handoff",
description: "Returns pending handoff tasks, optionally filtered by repo.",
inputSchema: {
type: "object" as const,
properties: { repo: { type: "string", description: "Optional: filter tasks mentioning this repo" } },
required: [] as string[],
},
},
{
name: "get_repo_file",
description: "Read any file from any repo on Git Studio.",
inputSchema: {
type: "object" as const,
properties: {
repo: { type: "string", description: "Full repo path, e.g. 'tinqs-ltd/docs'" },
path: { type: "string", description: "File path within repo, e.g. 'ai/SOUL.md'" },
},
required: ["repo", "path"],
},
},
{
name: "list_skills",
description: "List all shared skills available from the docs repo.",
inputSchema: { type: "object" as const, properties: {}, required: [] as string[] },
},
{
name: "get_skill",
description: "Get full SKILL.md content for a specific shared skill.",
inputSchema: {
type: "object" as const,
properties: { name: { type: "string", description: "Skill directory name" } },
required: ["name"],
},
},
{
name: "search_docs",
description: "Search across all Tinqs repos on Git Studio. Returns matching repos and file paths.",
inputSchema: {
type: "object" as const,
properties: {
query: { type: "string", description: "Search query" },
repo: { type: "string", description: "Optional: limit search to a specific repo (e.g. 'tinqs-ltd/isleborn')" },
limit: { type: "number", description: "Max results (default 20)" },
},
required: ["query"],
},
},
{
name: "list_all_repos",
description: "List all repos on Git Studio with descriptions. Helps agents discover available repos.",
inputSchema: { type: "object" as const, properties: {}, required: [] as string[] },
},
{
name: "gitea_api",
description: "Proxy any Gitea API call to Git Studio.",
inputSchema: {
type: "object" as const,
properties: {
method: { type: "string", enum: ["GET", "POST", "PUT", "DELETE", "PATCH"], description: "HTTP method" },
path: { type: "string", description: "API path after /api/v1, e.g. '/repos/tinqs-ltd/docs/issues'" },
body: { type: "object", description: "Optional request body for POST/PUT/PATCH" },
},
required: ["method", "path"],
},
},
{
name: "gh_api",
description: "Proxy any GitHub API call.",
inputSchema: {
type: "object" as const,
properties: {
method: { type: "string", enum: ["GET", "POST", "PUT", "DELETE", "PATCH"], description: "HTTP method" },
path: { type: "string", description: "API path, e.g. '/repos/tinqs-ltd/website/issues'" },
body: { type: "object", description: "Optional request body for POST/PUT/PATCH" },
},
required: ["method", "path"],
},
},
{
name: "eval_agent",
description: "Grade an agent's answer against a rubric using Bedrock LLM-as-judge. Fetches rubrics from tinqs-ltd/agent-eval repo. Returns a score 1-5 with reasoning.",
inputSchema: {
type: "object" as const,
properties: {
rubric_id: { type: "string", description: "Rubric ID, e.g. 'docs/q1-melih'. Use list_rubrics to see all available." },
agent_answer: { type: "string", description: "The agent's answer text to grade." },
},
required: ["rubric_id", "agent_answer"],
},
},
{
name: "list_rubrics",
description: "List all available eval rubrics from the agent-eval repo.",
inputSchema: { type: "object" as const, properties: {}, required: [] as string[] },
},
{
name: "run_eval",
description: "Run all docs rubrics against the MCP's own get_identity output. Self-test: calls get_identity, feeds it to Bedrock as the 'agent answer', scores each rubric. Returns full scorecard.",
inputSchema: { type: "object" as const, properties: {}, required: [] as string[] },
},
{
name: "list_gateway_pages",
description: "List all gateway HTML/MD pages across all Tinqs repos. Each repo's gateway/ folder is aggregated.",
inputSchema: { type: "object" as const, properties: {}, required: [] as string[] },
},
{
name: "get_gateway_page",
description: "Fetch a gateway page by slug. Returns the full HTML/MD content with parsed frontmatter.",
inputSchema: {
type: "object" as const,
properties: {
slug: { type: "string", description: "Page slug, e.g. 'docs--onboarding'. Use list_gateway_pages to see all slugs." },
},
required: ["slug"],
},
},
{
name: "list_active_meetings",
description: "List currently active meeting sessions being recorded by team members.",
inputSchema: { type: "object" as const, properties: {}, required: [] as string[] },
},
{
name: "get_live_transcript",
description: "Get the live rolling transcript from an active meeting session. Returns speaker-labeled, timestamped text.",
inputSchema: {
type: "object" as const,
properties: {
session_id: { type: "string", description: "Meeting session ID. If omitted, uses the most recent active session." },
},
required: [] as string[],
},
},
{
name: "query_meeting",
description: "Ask a question about an ongoing meeting and get an AI-powered answer based on the live transcript.",
inputSchema: {
type: "object" as const,
properties: {
question: { type: "string", description: "Question to answer from the meeting transcript." },
session_id: { type: "string", description: "Meeting session ID. If omitted, uses the most recent active session." },
},
required: ["question"],
},
},
];
const RESOURCES = [
{ uri: "tinqs://identity/soul", name: "Hub SOUL", description: "Tinqs shared identity", mimeType: "text/markdown" },
{ uri: "tinqs://identity/company", name: "Company", description: "Team roster, machines, values", mimeType: "text/markdown" },
{ uri: "tinqs://identity/siblings", name: "Siblings", description: "All repos and agents", mimeType: "text/markdown" },
{ uri: "tinqs://identity/urls", name: "URLs", description: "Service URLs", mimeType: "text/markdown" },
{ uri: "tinqs://identity/handoff", name: "Handoff", description: "Cross-machine task queue", mimeType: "text/markdown" },
{ uri: "tinqs://identity/gateway", name: "Gateway", description: "Gateway capabilities", mimeType: "text/markdown" },
];
// --- Tool handlers ---
async function handleGetIdentity(): Promise<string> {
const parts = await Promise.all([
fetchFile("tinqs-ltd/docs", "ai/SOUL.md", 300),
fetchFile("tinqs-ltd/docs", "ai/company.md", 300),
fetchFile("tinqs-ltd/docs", "ai/siblings.md", 300),
fetchFile("tinqs-ltd/docs", "ai/urls.md", 300),
]);
return parts.join("\n\n---\n\n");
}
async function handleGetGlossary(user: string): Promise<string> {
return fetchFile("tinqs-ltd/docs", `.cursor/glossary/glossary-${user}.md`, 300);
}
async function handleGetHandoff(repo?: string): Promise<string> {
const content = await fetchFile("tinqs-ltd/docs", "ai/handoff.md", 60);
if (!repo) return content;
// Filter to lines mentioning the repo
const lines = content.split("\n");
const filtered = lines.filter(
(l) => l.toLowerCase().includes(repo.toLowerCase()) || l.startsWith("#") || l.startsWith("---")
);
return filtered.join("\n");
}
async function handleGetRepoFile(repo: string, path: string): Promise<string> {
return fetchFile(repo, path, 120);
}
async function handleListSkills(): Promise<string> {
const entries = await listDir("tinqs-ltd/docs", ".cursor/skills");
const dirs = entries.filter((e) => e.type === "dir");
const skills = dirs.map((d) => `- ${d.name}`).join("\n");
return `# Shared Skills\n\n${skills}`;
}
async function handleGetSkill(name: string): Promise<string> {
return fetchFile("tinqs-ltd/docs", `.cursor/skills/${name}/SKILL.md`, 300);
}
async function handleSearchDocs(query: string, repo?: string, limit?: number): Promise<string> {
const results = await searchCode(query, { repo, limit: limit ?? 20 });
if (results.length === 0) return "No results found.";
const lines = results.map((r) => {
if (r.path) return `- **${r.repo}** / \`${r.path}\`${r.content ? " — " + r.content : ""}`;
return `- **${r.repo}**${r.content ? " — " + r.content : ""}`;
});
return `# Search: "${query}"\n\n${lines.join("\n")}`;
}
async function handleListAllRepos(): Promise<string> {
const repos = await listRepos();
const lines = repos.map((r) =>
`- **${r.full_name}**${r.private ? " (private)" : ""}${r.description || "no description"}`
);
return `# Repos on Git Studio\n\n${lines.join("\n")}`;
}
async function handleGiteaApi(
method: string,
path: string,
body?: Record<string, unknown>
): Promise<string> {
const token = process.env.GITEA_TOKEN?.trim();
if (!token) throw new Error("GITEA_TOKEN not set");
const url = `https://tinqs.com/api/v1${path}`;
const opts: RequestInit = {
method,
headers: {
Authorization: `token ${token}`,
"Content-Type": "application/json",
},
signal: AbortSignal.timeout(15000),
};
if (body && ["POST", "PUT", "PATCH"].includes(method)) {
opts.body = JSON.stringify(body);
}
const r = await fetch(url, opts);
const text = await r.text();
if (!r.ok) throw new Error(`Gitea ${r.status}: ${text.slice(0, 500)}`);
return text;
}
async function handleGhApi(
method: string,
path: string,
body?: Record<string, unknown>
): Promise<string> {
const token = process.env.GITHUB_TOKEN?.trim();
if (!token) throw new Error("GITHUB_TOKEN not set");
const url = `https://api.github.com${path}`;
const opts: RequestInit = {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
},
signal: AbortSignal.timeout(15000),
};
if (body && ["POST", "PUT", "PATCH"].includes(method)) {
opts.body = JSON.stringify(body);
}
const r = await fetch(url, opts);
const text = await r.text();
if (!r.ok) throw new Error(`GitHub ${r.status}: ${text.slice(0, 500)}`);
return text;
}
// --- Bedrock judge ---
async function bedrockJudge(rubricYaml: string, agentAnswer: string): Promise<string> {
const region = process.env.AWS_REGION ?? "eu-west-1";
const model = process.env.BEDROCK_JUDGE_MODEL ?? "mistral.mistral-large-2402-v1:0";
const client = new BedrockRuntimeClient({ region });
const prompt = `You are an LLM-as-judge. You grade whether an AI agent's context data contains the facts needed to answer a question.
RUBRIC (question + expected facts + scoring criteria):
${rubricYaml}
AGENT'S CONTEXT DATA (this is what the agent received from an MCP call — search it carefully for each golden fact):
${agentAnswer.slice(0, 12000)}
TASK: Search the context data above for each golden fact listed in the rubric. A fact counts as present if the information is there, even if worded differently. Then score 1-5 per the rubric.
Respond in exactly this format:
SCORE: <1-5>
FOUND: <facts found, comma-separated>
MISSING: <facts not found, or "none">
REASONING: <1 sentence>`;
const body = JSON.stringify({
messages: [
{ role: "user", content: prompt },
],
max_tokens: 400,
temperature: 0.0,
});
const command = new InvokeModelCommand({
modelId: model,
contentType: "application/json",
accept: "application/json",
body: new TextEncoder().encode(body),
});
const response = await client.send(command);
const result = JSON.parse(new TextDecoder().decode(response.body));
const text = result?.choices?.[0]?.message?.content;
return text?.trim() ?? "(No response from Bedrock)";
}
async function handleListRubrics(): Promise<string> {
const dirs = await listDir("tinqs-ltd/agent-eval", "rubrics");
const results: string[] = ["# Eval Rubrics\n"];
for (const d of dirs) {
if (d.type !== "dir") continue;
const files = await listDir("tinqs-ltd/agent-eval", `rubrics/${d.name}`);
for (const f of files) {
if (f.name.endsWith(".yaml")) {
results.push(`- ${d.name}/${f.name.replace(".yaml", "")}`);
}
}
}
return results.join("\n");
}
async function handleEvalAgent(rubricId: string, agentAnswer: string): Promise<string> {
const rubricYaml = await fetchFile("tinqs-ltd/agent-eval", `rubrics/${rubricId}.yaml`, 300);
const judgment = await bedrockJudge(rubricYaml, agentAnswer);
return `# Eval: ${rubricId}\n\n${judgment}`;
}
async function handleRunEval(): Promise<string> {
// Fetch MCP identity data (what an agent gets on session start)
const identity = await handleGetIdentity();
const handoff = await handleGetHandoff();
const allData = identity + "\n\n---\n\n" + handoff;
// Fetch all docs rubrics
const rubricFiles = await listDir("tinqs-ltd/agent-eval", "rubrics/docs");
const results: string[] = ["# MCP Self-Eval — Bedrock Judge\n"];
let totalScore = 0;
let count = 0;
for (const f of rubricFiles) {
if (!f.name.endsWith(".yaml")) continue;
const rubricId = `docs/${f.name.replace(".yaml", "")}`;
try {
const rubricYaml = await fetchFile("tinqs-ltd/agent-eval", `rubrics/${rubricId}.yaml`, 300);
const judgment = await bedrockJudge(rubricYaml, allData);
// Parse score from judgment
const scoreMatch = judgment.match(/SCORE:\s*(\d)/);
const score = scoreMatch ? parseInt(scoreMatch[1]) : 0;
totalScore += score;
count++;
results.push(`## ${rubricId}${score}/5\n${judgment}\n`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
results.push(`## ${rubricId} — ERROR\n${msg}\n`);
count++;
}
}
results.push(`---\n**TOTAL: ${totalScore}/${count * 5} | AVG: ${(totalScore / count).toFixed(1)}/5 | ${count} rubrics**`);
return results.join("\n");
}
// --- Meeting tool handlers ---
const MEETING_TRANSCRIPT_WINDOW_MINUTES = 30;
async function handleListActiveMeetings(): Promise<string> {
const meetings = await listActiveMeetings();
if (meetings.length === 0) return "No active meetings.";
const lines = meetings.map(
(m) =>
`- **${m.session_id}** — ${m.user}@${m.machine}, started ${m.started_at}, ${m.transcript_count} chunks`
);
return `# Active Meetings (${meetings.length})\n\n${lines.join("\n")}`;
}
async function resolveSessionId(sessionId?: string): Promise<string> {
if (sessionId) return sessionId;
const mostRecent = await getMostRecentSessionId();
if (!mostRecent) throw new Error("No active meeting sessions found.");
return mostRecent;
}
async function handleGetLiveTranscript(sessionId?: string): Promise<string> {
const resolved = await resolveSessionId(sessionId);
const allChunks = await getTranscript(resolved);
const recent = getLastNMinutes(allChunks, MEETING_TRANSCRIPT_WINDOW_MINUTES);
const formatted = formatTranscript(recent);
return `# Live Transcript — ${resolved}\n\n${formatted}`;
}
async function handleQueryMeeting(question: string, sessionId?: string): Promise<string> {
const resolved = await resolveSessionId(sessionId);
const allChunks = await getTranscript(resolved);
const recent = getLastNMinutes(allChunks, MEETING_TRANSCRIPT_WINDOW_MINUTES);
const transcript = formatTranscript(recent);
if (recent.length === 0) {
return `No transcript data available for session ${resolved}.`;
}
const answer = await callTacoClaude({
liveTranscriptBlock: transcript,
docsBlock: "",
question,
source: "chat",
});
return `# Meeting Q&A — ${resolved}\n\n**Q:** ${question}\n\n**A:** ${answer}`;
}
// --- Main handler ---
async function handleToolCall(
name: string,
args: Record<string, unknown>
): Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }> {
try {
let result: string;
switch (name) {
case "get_identity":
result = await handleGetIdentity();
break;
case "get_glossary":
result = await handleGetGlossary(args.user as string);
break;
case "get_handoff":
result = await handleGetHandoff(args.repo as string | undefined);
break;
case "get_repo_file":
result = await handleGetRepoFile(args.repo as string, args.path as string);
break;
case "search_docs":
result = await handleSearchDocs(args.query as string, args.repo as string | undefined, args.limit as number | undefined);
break;
case "list_all_repos":
result = await handleListAllRepos();
break;
case "list_skills":
result = await handleListSkills();
break;
case "get_skill":
result = await handleGetSkill(args.name as string);
break;
case "gitea_api":
result = await handleGiteaApi(
args.method as string,
args.path as string,
args.body as Record<string, unknown> | undefined
);
break;
case "gh_api":
result = await handleGhApi(
args.method as string,
args.path as string,
args.body as Record<string, unknown> | undefined
);
break;
case "eval_agent":
result = await handleEvalAgent(args.rubric_id as string, args.agent_answer as string);
break;
case "list_rubrics":
result = await handleListRubrics();
break;
case "run_eval":
result = await handleRunEval();
break;
case "list_gateway_pages": {
const pages = await listGatewayPages();
const lines = pages.map((p) =>
`- **${p.repoKey}** / \`${p.filename}\` — slug: \`${p.slug}\``
);
result = `# Gateway Pages (${pages.length})\n\n${lines.join("\n")}`;
break;
}
case "get_gateway_page": {
const gp = await getGatewayPage(args.slug as string);
if (!gp) { result = "Page not found"; break; }
const meta = [
gp.page.title && `**${gp.page.title}**`,
gp.page.author && `Author: ${gp.page.author}`,
gp.page.date && `Date: ${gp.page.date}`,
gp.page.source && `Source: ${gp.page.source}`,
gp.page.description,
].filter(Boolean).join(" | ");
result = `${meta}\n\n---\n\n${gp.content}`;
break;
}
case "list_active_meetings":
result = await handleListActiveMeetings();
break;
case "get_live_transcript":
result = await handleGetLiveTranscript(args.session_id as string | undefined);
break;
case "query_meeting":
result = await handleQueryMeeting(args.question as string, args.session_id as string | undefined);
break;
default:
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
}
return { content: [{ type: "text", text: result }] };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
}
}
async function handleReadResource(uri: string): Promise<{ contents: Array<{ uri: string; mimeType: string; text: string }> }> {
// Parse URI: tinqs://identity/soul → identity file
const match = uri.match(/^tinqs:\/\/(\w+)\/(.+)$/);
if (!match) throw new Error(`Unknown resource URI: ${uri}`);
const [, category, name] = match;
if (category === "identity") {
const file = IDENTITY_FILES[name];
if (!file) throw new Error(`Unknown identity resource: ${name}`);
const text = await fetchFile(file.repo, file.path, file.cache);
return { contents: [{ uri, mimeType: "text/markdown", text }] };
}
if (category === "glossary") {
const text = await fetchFile("tinqs-ltd/docs", `.cursor/glossary/glossary-${name}.md`, 300);
return { contents: [{ uri, mimeType: "text/markdown", text }] };
}
throw new Error(`Unknown resource category: ${category}`);
}
// --- JSON-RPC dispatch ---
export async function handleMcpRequest(req: JsonRpcRequest): Promise<JsonRpcResponse> {
const { id, method, params } = req;
try {
switch (method) {
case "initialize":
return {
jsonrpc: "2.0",
id,
result: {
protocolVersion: "2025-03-26",
capabilities: {
tools: { listChanged: false },
resources: { subscribe: false, listChanged: false },
},
serverInfo: {
name: "bot-arikigame",
version: "0.1.0",
},
},
};
case "notifications/initialized":
return { jsonrpc: "2.0", id, result: {} };
case "tools/list":
return { jsonrpc: "2.0", id, result: { tools: TOOLS } };
case "tools/call": {
const toolName = (params as { name: string }).name;
const toolArgs = ((params as { arguments?: Record<string, unknown> }).arguments) || {};
const result = await handleToolCall(toolName, toolArgs);
return { jsonrpc: "2.0", id, result };
}
case "resources/list":
return { jsonrpc: "2.0", id, result: { resources: RESOURCES } };
case "resources/read": {
const uri = (params as { uri: string }).uri;
const result = await handleReadResource(uri);
return { jsonrpc: "2.0", id, result };
}
case "ping":
return { jsonrpc: "2.0", id, result: {} };
default:
return {
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Method not found: ${method}` },
};
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return {
jsonrpc: "2.0",
id,
error: { code: -32000, message: msg },
};
}
}