v3: AI SDK pivot — pi removed, Next.js 16 + AI SDK 7 + AI Elements teacher app

The teaching agent no longer runs on pi. Stack:
- ToolLoopAgent (ai@7) + OpenRouter (OPENROUTER_API_KEY), model via LH_MODEL,
  default anthropic/claude-sonnet-4.5 with reasoning tokens
- quiz/ask_user_question = client-side tools (no server execute): the call
  lands as a typed tool part in input-available state; the card grades via
  src/core/question-core.ts; addToolOutput sends the verdict; the teacher's
  reaction continues the same assistant message
- resume-mid-quiz is free: the pending tool part persists in the transcript
- persistence: one JSON per chat (data/chats/), full UIMessage transcripts,
  saved onFinish (also on abort)
- UI: AI Elements (owned copy-paste components, pruned to the five in use)
  + button-based QuizCard/AnsweredCard (ask answers never graded)
- pi fully removed: TUI, extensions, Fastify+WS server, bridge, custom
  protocol all deleted (recoverable from git history)
- tests: question-core units carry over; chat-flow integration drives
  handleChatRequest with MockLanguageModelV4 (48 tests, zero tokens);
  render smoke tests guard the cards (renderToStaticMarkup)

Carried over unchanged: teacher system prompt, question-core, schema/
ledger/derive/extract pipeline, data model.
This commit is contained in:
Amos
2026-08-22 02:18:27 +03:00
parent e127a9bfbd
commit a72c902d30
81 changed files with 9643 additions and 10860 deletions
+87
View File
@@ -0,0 +1,87 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { UIMessage } from "ai";
import { PATHS } from "../../config.ts";
/**
* Chat store — one JSON file per chat in data/chats/.
*
* The stored messages are full AI SDK UIMessages (parts included). This is
* the canonical session record: a pending quiz is a tool part in
* `input-available` state, which is exactly what makes resume-mid-question
* free. The extraction pipeline (src/extract) will read these later; old pi
* sessions (data/sessions) remain bootstrap input only.
*/
export interface ChatRecord {
id: string;
title: string;
createdAt: string;
updatedAt: string;
messages: UIMessage[];
}
export interface ChatSummary {
id: string;
title: string;
createdAt: string;
updatedAt: string;
messageCount: number;
}
function chatFile(id: string): string {
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error(`Invalid chat id: ${id}`);
return path.join(PATHS.chats, `${id}.json`);
}
export function createChat(title: string): ChatRecord {
const now = new Date().toISOString();
const record: ChatRecord = {
id: `chat_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`,
title: title.trim() || "Untitled session",
createdAt: now,
updatedAt: now,
messages: [],
};
fs.mkdirSync(PATHS.chats, { recursive: true });
fs.writeFileSync(chatFile(record.id), JSON.stringify(record, null, "\t"), "utf-8");
return record;
}
export function getChat(id: string): ChatRecord | null {
try {
return JSON.parse(fs.readFileSync(chatFile(id), "utf-8")) as ChatRecord;
} catch {
return null;
}
}
export function listChats(): ChatSummary[] {
try {
fs.mkdirSync(PATHS.chats, { recursive: true });
return fs
.readdirSync(PATHS.chats)
.filter((f) => f.endsWith(".json"))
.map((f) => getChat(f.slice(0, -5)))
.filter((r): r is ChatRecord => r !== null)
.map(({ messages, ...summary }) => ({ ...summary, messageCount: messages.length }))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
} catch {
return [];
}
}
/** Persist the full message list (client state + new response) after a turn. */
export function saveMessages(id: string, messages: UIMessage[]): void {
const record = getChat(id);
if (!record) throw new Error(`Chat not found: ${id}`);
record.messages = messages;
record.updatedAt = new Date().toISOString();
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
}
/** Derive a short title from the first user message. */
export function titleFromPrompt(prompt: string): string {
const oneLine = prompt.replace(/\s+/g, " ").trim();
return oneLine.length > 72 ? `${oneLine.slice(0, 72)}…` : oneLine;
}