Files
boocode/apps/server/src/services/auto_name.ts
indifferentketchup 9ef00c0268 v1.12.4: complete inference.ts split into services/inference/
- sentinel-summaries.ts: runCapHitSummary, insertCapHitSentinel,
  runDoomLoopSummary, insertDoomLoopSentinel
- inference.ts → inference/turn.ts: residue is runAssistantTurn,
  runInference, createInferenceRunner orchestration only
- inference/index.ts: re-export shim preserves the public surface
  (createInferenceRunner, runInference, runAssistantTurn,
  detectDoomLoop, DOOM_LOOP_THRESHOLD, buildMessagesPayload, plus
  type-side InferenceContext/InferenceFrame/StreamResult/TurnArgs/
  FramePublisher)
- src/index.ts + auto_name.ts + the two vitest test files updated to
  import from ./services/inference/index.js explicitly (NodeNext ESM
  doesn't honor directory-index resolution)

Final tally: 11 files under services/inference/, the largest being
sentinel-summaries.ts at 523 LoC (two near-clone summary paths kept
side-by-side until a third sentinel justifies factoring out a shared
runWrapUpSummary). turn.ts is now 326 LoC, the next-largest is
stream-phase.ts at 380. Public import surface unchanged.

tool-phase.ts → turn.ts back-edge for runAssistantTurn remains
(cycle is safe; resolved at call time).

Prepares the file structure for v1.13 AI SDK migration — streamText
swap targets stream-phase.ts only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:36:35 +00:00

167 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { InferenceContext } from './inference/index.js';
const NAMING_SYSTEM_PROMPT =
'You name chat sessions. Reply directly with no thinking, reasoning, or explanation. Output ONLY the title, 4 words max, no quotes, no punctuation, no prefix like "Title:".';
const MAX_TITLE_CHARS = 60;
function cleanTitle(raw: string): string {
let name = raw.trim();
const quotes = ['"', "'", '`', '', '', '“', '”'];
while (name.length >= 2 && quotes.includes(name[0]!) && quotes.includes(name[name.length - 1]!)) {
name = name.slice(1, -1).trim();
}
name = name.replace(/^title\s*:\s*/i, '').trim();
if (name.length > MAX_TITLE_CHARS) {
name = name.slice(0, MAX_TITLE_CHARS).trim();
}
return name;
}
interface NamingResponse {
choices?: Array<{
message?: {
content?: string;
reasoning_content?: string;
};
}>;
}
function pickTitleSource(data: NamingResponse): string {
const choice = data.choices?.[0]?.message;
if (!choice) return '';
if (choice.content && choice.content.trim().length > 0) return choice.content;
const reasoning = choice.reasoning_content ?? '';
if (reasoning.length === 0) return '';
const lines = reasoning
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0);
return lines[lines.length - 1] ?? '';
}
export async function maybeAutoNameChat(
ctx: InferenceContext,
chatId: string,
sessionId: string
): Promise<void> {
const counts = await ctx.sql<{ n: number }[]>`
SELECT COUNT(*)::int AS n
FROM messages
WHERE chat_id = ${chatId}
AND role = 'assistant'
AND status = 'complete'
AND content <> ''
`;
if ((counts[0]?.n ?? 0) < 1) return;
const chatRows = await ctx.sql<
{ id: string; name: string | null; session_id: string }[]
>`
SELECT id, name, session_id FROM chats WHERE id = ${chatId}
`;
const chat = chatRows[0];
if (!chat) return;
if (chat.name !== null && chat.name !== '') return;
const sessionRows = await ctx.sql<{ model: string }[]>`
SELECT model FROM sessions WHERE id = ${sessionId}
`;
const model = sessionRows[0]?.model;
if (!model) return;
const userMsg = await ctx.sql<{ content: string }[]>`
SELECT content FROM messages
WHERE chat_id = ${chatId} AND role = 'user'
ORDER BY created_at ASC
LIMIT 1
`;
const assistantMsg = await ctx.sql<{ content: string }[]>`
SELECT content FROM messages
WHERE chat_id = ${chatId}
AND role = 'assistant'
AND status = 'complete'
AND content <> ''
ORDER BY created_at ASC
LIMIT 1
`;
if (!userMsg[0] || !assistantMsg[0]) return;
const userText = userMsg[0].content.slice(0, 2000);
const assistantText = assistantMsg[0].content.slice(0, 2000);
const body = {
model,
messages: [
{ role: 'system', content: NAMING_SYSTEM_PROMPT },
{
role: 'user',
content: `First user message: ${userText}\nFirst assistant reply: ${assistantText}`,
},
],
max_tokens: 30,
temperature: 0.3,
stream: false,
chat_template_kwargs: { enable_thinking: false },
};
const res = await fetch(`${ctx.config.LLAMA_SWAP_URL}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`naming request failed: ${res.status} ${text.slice(0, 200)}`);
}
const data = (await res.json()) as NamingResponse;
const raw = pickTitleSource(data);
const name = cleanTitle(raw);
if (!name) {
ctx.log.warn({ chatId, raw }, 'auto-name: empty title from model');
return;
}
const updated = await ctx.sql<{ id: string; name: string; session_id: string; updated_at: string }[]>`
UPDATE chats
SET name = ${name}, updated_at = clock_timestamp()
WHERE id = ${chatId}
AND (name IS NULL OR name = '')
RETURNING id, name, session_id, updated_at
`;
if (updated.length === 0) return;
ctx.publish(sessionId, {
type: 'chat_renamed',
chat_id: chatId,
name,
});
ctx.publishUser({
type: 'chat_updated',
chat_id: chatId,
session_id: sessionId,
name,
updated_at: updated[0]!.updated_at,
});
ctx.log.info({ chatId, name }, 'chat auto-named');
// Propagate to the parent session if it's still on its default name.
// The WHERE guard makes the check atomic — if the user has already
// renamed (or a prior chat already propagated), this UPDATE matches
// zero rows and we do nothing. First chat wins; manual renames win.
const renamedSession = await ctx.sql<{ id: string; name: string }[]>`
UPDATE sessions
SET name = ${name}
WHERE id = ${sessionId} AND name = 'New session'
RETURNING id, name
`;
if (renamedSession.length > 0) {
ctx.publishUser({
type: 'session_renamed',
session_id: sessionId,
name,
});
ctx.log.info({ sessionId, name }, 'session auto-named from chat');
}
}