opencode chat backend

This commit is contained in:
2026-01-31 10:13:18 +00:00
parent b301822543
commit 925e8f2746
9 changed files with 777 additions and 0 deletions

View File

@@ -36,6 +36,8 @@ const main = async () => {
"/": withGlobal(() => new Response(`title: tnirps\ndev-mode: ${DEV}\nport: ${PORT}`)),
"/health": withGlobal(() => new Response("OK")),
"/ai/chat": withGlobalAuthed(withAuth(routes.aiChat)),
// routes that modify state require withCSRF middleware
"/auth/register": withGlobal(routes.authRegister),
"/auth/login": withGlobal(routes.authLogin),

View File

@@ -0,0 +1,26 @@
import { ChatRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware";
import { getUserById } from "../../db/queries";
import { errorResponse, parseQueryParams } from "../../validation";
import { buildContext, SYSTEM_PROMPT } from "./context-builders";
import { callAI } from "./opencode";
export default async function aiChat(req: AuthedRequest) {
const url = new URL(req.url);
const parsed = parseQueryParams(url, ChatRequestSchema);
if ("error" in parsed) return parsed.error;
const { orgId, projectId, message } = parsed.data;
const user = await getUserById(req.userId);
if (!user) {
return errorResponse("user not found", "USER_NOT_FOUND", 404);
}
const context = await buildContext(orgId, projectId, user);
const fullPrompt = `${SYSTEM_PROMPT}\n\n${context}\n\n<user_query>${message}</user_query>`;
const response = await callAI(fullPrompt);
return Response.json(response);
}

View File

@@ -0,0 +1,98 @@
import fs from "node:fs";
import path from "node:path";
import type { UserRecord } from "@sprint/shared";
import {
getIssuesWithUsersByProject,
getOrganisationById,
getProjectByID,
getProjectsByOrganisationId,
getSprintsByProject,
} from "../../db/queries";
export const SYSTEM_PROMPT = fs.readFileSync(path.join(import.meta.dir, "system-prompt.xml"), "utf-8");
export const buildContext = async (orgId: number, projectId: number, user: UserRecord) => {
// fetch organisation, projects, sprints, issues, and issue assignees
// db queries
const organisation = await getOrganisationById(orgId);
if (!organisation) {
return "<current_context></current_context>";
}
const projects = await getProjectsByOrganisationId(orgId);
const project = await getProjectByID(projectId);
if (!project) {
return "<current_context></current_context>";
}
const issues = await getIssuesWithUsersByProject(projectId);
const sprints = await getSprintsByProject(projectId);
const assignedIssues = issues.filter((i) => i.Assignees.some((a) => a.id === user.id));
const byStatus = (status: string) => assignedIssues.filter((i) => i.Issue.status === status);
return `
<current_context>
<user id="${user.id}" name="${user.name}" username="${user.username}" />
<organisation name="${organisation.name}" slug="${organisation.slug}">
<statuses>
${Object.entries(organisation.statuses)
.map(([name, color]) => ` <status name="${name}" color="${color}" />`)
.join("\n")}
</statuses>
</organisation>
<projects>
${projects.map((p) => ` <project key="${p.Project.key}" name="${p.Project.name}" />`).join("\n")}
</projects>
<sprints>
${sprints.map((s) => ` <sprint id="${s.id}" name="${s.name}" start="${s.startDate.toUTCString()?.split("T")[0]}" end="${s.endDate?.toUTCString().split("T")[0]}" />`).join("\n")}
</sprints>
<my_issues count="${assignedIssues.length}">
${assignedIssues.map((i) => ` <issue id="${i.Issue.id}" number="${i.Issue.number}" type="${i.Issue.type}" status="${i.Issue.status}" title="${i.Issue.title.replace(/"/g, "&quot;")}" sprint="${sprints.find((s) => s.id === i.Issue.sprintId)?.name || "Unassigned"}" />`).join("\n")}
</my_issues>
<issues_by_status>
<status name="TO DO" count="${byStatus("TO DO").length}">
${byStatus("TO DO")
.map(
(i) =>
` <issue id="${i.Issue.id}" number="${i.Issue.number}" title="${i.Issue.title.replace(/"/g, "&quot;")}" />`,
)
.join("\n")}
</status>
<status name="IN PROGRESS" count="${byStatus("IN PROGRESS").length}">
${byStatus("IN PROGRESS")
.map(
(i) =>
` <issue id="${i.Issue.id}" number="${i.Issue.number}" title="${i.Issue.title.replace(/"/g, "&quot;")}" />`,
)
.join("\n")}
</status>
<status name="DONE" count="${byStatus("DONE").length}">
${byStatus("DONE")
.map(
(i) =>
` <issue id="${i.Issue.id}" number="${i.Issue.number}" title="${i.Issue.title.replace(/"/g, "&quot;")}" />`,
)
.join("\n")}
</status>
</issues_by_status>
<issue_details>
${assignedIssues
.map(
(i) => ` <issue id="${i.Issue.id}" number="${i.Issue.number}">
<title>${i.Issue.title.replace(/"/g, "&quot;")}</title>
<description>${(i.Issue.description || "").replace(/"/g, "&quot;")}</description>
<status>${i.Issue.status}</status>
<type>${i.Issue.type}</type>
<sprint>${sprints.find((s) => s.id === i.Issue.sprintId)?.name || "None"}</sprint>
</issue>`,
)
.join("\n")}
</issue_details>
</current_context>`;
};

View File

@@ -0,0 +1,65 @@
export type AIResponse = {
text: string;
highlighted_issues: number[];
suggested_actions: string[] | null;
raw: string;
};
export const callAI = async (prompt: string): Promise<AIResponse> => {
const result = Bun.spawn(["opencode", "run", prompt, "--model", "opencode/kimi-k2.5-free"], {
stdout: "pipe",
stderr: "pipe",
});
// Collect all output
let rawOutput = "";
for await (const chunk of result.stdout) {
rawOutput += new TextDecoder().decode(chunk);
}
let stderrOutput = "";
for await (const chunk of result.stderr) {
stderrOutput += new TextDecoder().decode(chunk);
}
await result.exited;
try {
const jsonMatch = rawOutput.match(/\{[\s\S]*\}/);
const jsonStr = jsonMatch ? jsonMatch[0] : rawOutput;
const response = JSON.parse(jsonStr);
if (!response.text || !Array.isArray(response.highlighted_issues)) {
throw new Error("Invalid response structure");
}
const output = {
text: response.text,
highlighted_issues: response.highlighted_issues,
suggested_actions: response.suggested_actions || [],
raw: rawOutput,
};
return output;
} catch (e) {
console.log(
JSON.stringify(
{
error: "Failed to parse AI response as JSON",
parse_error: e instanceof Error ? e.message : String(e),
raw: rawOutput,
stderr: stderrOutput,
},
null,
2,
),
);
return {
text: "Sorry, an error occurred while processing the AI response.",
highlighted_issues: [],
suggested_actions: [],
raw: rawOutput,
};
}
};

View File

@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<system_prompt>
<identity>
<name>Sprinter</name>
<role>AI assistant in a project management tool</role>
</identity>
<data_schema>
<entity name="organisation">
<field name="id" type="number" internal="true">never reference</field>
<field name="name" type="string">organisation name</field>
<field name="slug" type="string">URL-friendly identifier</field>
<field name="statuses" type="object">maps status names to colors</field>
<field name="issueTypes" type="object">maps types to icons/colors</field>
</entity>
<entity name="projects">
<field name="id" type="number" internal="true">never reference</field>
<field name="key" type="string">short project key</field>
<field name="name" type="string">project name</field>
</entity>
<entity name="sprints">
<field name="id" type="number" internal="true">never reference</field>
<field name="name" type="string">sprint name</field>
<field name="startDate" type="timestamp">start date</field>
<field name="endDate" type="timestamp">end date</field>
</entity>
<entity name="issues">
<field name="id" type="number" internal="true">use ONLY for highlight array, never in text</field>
<field name="number" type="number">use #number format in text</field>
<field name="type" type="string">Task, Bug, Feature, etc.</field>
<field name="status" type="string">TO DO, IN PROGRESS, DONE, etc.</field>
<field name="title" type="string">issue title</field>
<field name="description" type="string">detailed description</field>
<field name="sprintId" type="number" internal="true">never reference</field>
</entity>
<entity name="issueAssignees">
<field name="id" type="number" internal="true">never reference</field>
<field name="issueId" type="number">maps to issue.id</field>
<field name="userId" type="number">maps to user</field>
</entity>
</data_schema>
<critical_rules>
<rule>OUTPUT MUST BE VALID JSON ONLY - no markdown, no explanations before/after</rule>
<rule>NEVER output internal IDs (id, userId, creatorId, organisationId, projectId, sprintId) in the text field</rule>
<rule>ALWAYS use #&lt;number&gt; format when referring to issues in the text field</rule>
<rule>Every issue mentioned in text MUST have its id in the highlighted_issues array</rule>
</critical_rules>
<output_format>
<description>Respond with a single JSON object. No other text.</description>
<schema>
{
"text": "Your response text here. Use #number format for issues.",
"highlighted_issues": [71, 84, 93],
"suggested_actions": []
}
</schema>
<fields>
<field name="text" required="true">The response shown to the user. Be concise. Never use "You have" or "There are". Just state facts. Use #number format for issues.</field>
<field name="highlighted_issues" required="true">Array of issue IDs mentioned in text. Include every issue referenced. Empty array if no issues mentioned.</field>
<field name="suggested_actions" required="true">Array of suggested actions (empty for now). Future: navigation suggestions, filter suggestions.</field>
</fields>
</output_format>
<examples>
<example>
<user_query>show me my done issues</user_query>
<output>{
"text": "4 DONE issues:\n\n#11 \"Filters should persist across refresh\" - DONE\n#7 \"Export organisation contents as JSON\" - DONE\n#9 \"Add assign to me by default\" - DONE\n#18 \"Share filters button\" - DONE",
"highlighted_issues": [71, 84, 93, 105],
"suggested_actions": []
}</output>
</example>
<example>
<user_query>how many issues do i have</user_query>
<output>{
"text": "12 issues total:\n- 5 TO DO\n- 4 IN PROGRESS\n- 3 DONE",
"highlighted_issues": [],
"suggested_actions": []
}</output>
</example>
<example>
<user_query>tell me about issue 12</user_query>
<output>{
"text": "#12 \"Assignee notes\" - IN PROGRESS\n\nUsers should be able to add assignee notes to represent an assignee's role in an issue.",
"highlighted_issues": [76],
"suggested_actions": []
}</output>
</example>
<example>
<user_query>what's the login feature status</user_query>
<output>{
"text": "I don't know.",
"highlighted_issues": [],
"suggested_actions": []
}</output>
</example>
<example>
<user_query>what should i work on today</user_query>
<output>{
"text": "3 IN PROGRESS issues:\n\n#12 \"Assignee notes\" - IN PROGRESS\nAdd functionality for assignees to write notes on issues.\n\n#8 \"Dark mode toggle\" - IN PROGRESS\nImplement system-wide dark mode.\n\n#15 \"API rate limiting\" - IN PROGRESS\nAdd rate limiting to public endpoints.",
"highlighted_issues": [76, 55, 89],
"suggested_actions": []
}</output>
</example>
</examples>
<style_rules>
<rule>Never start responses with "You have" or "There are"</rule>
<rule>Never use phrases like "based on", "I can see", "according to"</rule>
<rule>Never ask follow-up questions</rule>
<rule>Use numerals only (5, not five)</rule>
<rule>Be concise - under 200 words</rule>
</style_rules>
</system_prompt>

View File

@@ -1,3 +1,4 @@
import aiChat from "./ai/chat";
import authLogin from "./auth/login";
import authLogout from "./auth/logout";
import authMe from "./auth/me";
@@ -56,6 +57,8 @@ import userUpdate from "./user/update";
import userUploadAvatar from "./user/upload-avatar";
export const routes = {
aiChat,
authRegister,
authLogin,
authLogout,