MVP M6: live Socrates dock — LLM gateway + persisted thread + reply loop

The dock now talks to a real LLM. On first load it asks Socrates for an
opening turn that's grounded in the project's actual SysML model + active
validation issues. User replies and option-picks send turns through the
same channel. Thread + every message persist in SQLite so refresh keeps
the conversation.

apps/web/lib/llm
- gateway.ts: LLMGateway interface (chat + ChatOptions). Two adapters:
    - lmstudio: OpenAI SDK against LMSTUDIO_BASE_URL (default
      http://localhost:1234/v1)
    - anthropic: @anthropic-ai/sdk against claude-sonnet-4-6 (set
      ANTHROPIC_API_KEY when LLM_PROVIDER=anthropic)
  Provider chosen via LLM_PROVIDER env (default: lmstudio).
- chatJSON(): JSON-mode helper with parse-error repair-retry — the same
  defensive pattern proven against gemma-4-e4b in Phase 0.
- prompts.ts: server-only loader that caches .md prompts.
- prompts/socrates/character.md + review.md: ported verbatim from
  phase-0/src/prompts/ (Phase 0 corpus validated these 10/10).
- socrates.ts: sendUserTurn() — builds the system prompt (character +
  review + project context with trimmed model + active issues), runs
  chatJSON against the gateway, persists user + assistant turns,
  returns the structured turn. SocratesTurn schema is { text, options? }
  with up to 3 numbered options matching the prototype.

apps/web/prisma
- SocratesThread + SocratesMessage tables. Auto-create one open thread
  per project on first load.

apps/web/app/api/projects/[projectId]/socrates
- GET: returns active thread + parsed messages.
- POST: body { text }. Empty text triggers an opening turn. Persists user
  + assistant turns, returns assistant turn + provider metadata.

apps/web/components/socrates/SocratesDock.tsx
- Replaces the static thread prop with a projectId. Loads from API on
  mount, auto-triggers an opening turn if the thread is empty, sends
  user replies via POST. Numbered options click-to-pick or 1–3 keyboard
  shortcut (skipped when focus is in an input). Status line shows the
  active provider + model. Optimistic-local: user message appears
  instantly, "thinking…" placeholder shows while the LLM works, errors
  surface inline.

apps/web/.env.example + .env.local
- LLM_PROVIDER, LMSTUDIO_BASE_URL/MODEL/API_KEY, ANTHROPIC_API_KEY/MODEL.
- .env.local committed only with the local default (no real secrets);
  user supplies their own per-machine.

What's not yet here (next iterations):
- Streaming responses (currently waits for full response, ~5-15s)
- Multi-thread switcher (one auto-thread per project)
- Socrates-proposes-ops flow (M7)
This commit is contained in:
2026-04-29 07:50:34 +02:00
parent 5d4236a980
commit 78faca9968
13 changed files with 975 additions and 32 deletions

View File

@@ -0,0 +1,87 @@
// Socrates dock API.
//
// GET → returns { threadId, messages: [{ role, text, options? }] } for the
// active thread on this project.
// POST → body { text }. Sends a user turn (or "open" if text is empty),
// invokes the LLM, persists user + assistant messages, returns the
// new assistant turn.
import { NextResponse } from "next/server";
import { getActiveThread, loadProject } from "../../../../../lib/db/repo";
import { sendUserTurn } from "../../../../../lib/llm/socrates";
import { validate } from "../../../../../lib/sysml/validate";
export async function GET(_req: Request, { params }: { params: Promise<{ projectId: string }> }) {
const { projectId } = await params;
// Touch loadProject so a missing project gets seeded (also creates the thread).
await loadProject(projectId);
const thread = await getActiveThread(projectId);
if (!thread) return NextResponse.json({ error: "no active thread" }, { status: 404 });
return NextResponse.json({
threadId: thread.id,
title: thread.title,
messages: thread.messages.map(m => {
try {
const parsed = JSON.parse(m.content);
return {
id: m.id,
role: m.role as "user" | "assistant",
text: typeof parsed.text === "string" ? parsed.text : "",
options: Array.isArray(parsed.options) ? parsed.options : undefined,
createdAt: m.createdAt.toISOString(),
};
} catch {
return { id: m.id, role: m.role as "user" | "assistant", text: m.content, createdAt: m.createdAt.toISOString() };
}
}),
});
}
export async function POST(req: Request, { params }: { params: Promise<{ projectId: string }> }) {
const { projectId } = await params;
let body: { text?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
}
// Make sure the project + thread exist
const { model } = await loadProject(projectId);
const issues = validate(model);
let thread = await getActiveThread(projectId);
if (!thread) {
return NextResponse.json({ error: "no active thread to post to" }, { status: 404 });
}
try {
const result = await sendUserTurn({
threadId: thread.id,
model,
issues,
userText: body.text ?? "",
});
return NextResponse.json({
threadId: thread.id,
assistant: {
id: result.assistant.id,
text: result.assistant.turn.text,
options: result.assistant.turn.options,
createdAt: result.assistant.createdAt.toISOString(),
},
user: result.user.id !== "(skipped)" ? {
id: result.user.id,
createdAt: result.user.createdAt.toISOString(),
} : null,
meta: {
provider: result.assistant.provider,
model: result.assistant.model,
inputTokens: result.assistant.inputTokens,
outputTokens: result.assistant.outputTokens,
},
});
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@@ -75,6 +75,7 @@ function EditorShellInner({
> >
<ShellBody <ShellBody
data={data} data={data}
projectId={projectId}
density={density} density={density}
markupStyle={markupStyle} markupStyle={markupStyle}
diagramStyle={diagramStyle} diagramStyle={diagramStyle}
@@ -87,6 +88,7 @@ function EditorShellInner({
interface ShellBodyProps { interface ShellBodyProps {
data: FixtureData; data: FixtureData;
projectId?: string;
density: Density; density: Density;
markupStyle: MarkupStyle; markupStyle: MarkupStyle;
diagramStyle: DiagramVariant; diagramStyle: DiagramVariant;
@@ -94,7 +96,7 @@ interface ShellBodyProps {
breaks: BreakName[]; breaks: BreakName[];
} }
function ShellBody({ data, density, markupStyle, diagramStyle, presence, breaks }: ShellBodyProps) { function ShellBody({ data, projectId, density, markupStyle, diagramStyle, presence, breaks }: ShellBodyProps) {
const [focusBlockId, setFocusBlockId] = useState<string | null>(null); const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
const { model, issues, issuesByElement } = useModelStore(); const { model, issues, issuesByElement } = useModelStore();
@@ -106,7 +108,7 @@ function ShellBody({ data, density, markupStyle, diagramStyle, presence, breaks
<TopBar data={data} /> <TopBar data={data} />
<div className="shell-body"> <div className="shell-body">
<SocratesDock thread={data.socratesThread} presence={presence} density={density} /> <SocratesDock projectId={projectId ?? "aristotle"} presence={presence} density={density} />
<LeftRail <LeftRail
data={data} data={data}
focusBlockId={focusBlockId} focusBlockId={focusBlockId}

View File

@@ -1,25 +1,172 @@
// Active-thread dock with Sigil header, conversation bubbles, numbered options. // Active-thread dock with Sigil header, conversation bubbles, numbered options.
// Ported from docs/design-source/socrata/project/socrates.jsx (SocratesDock). //
// "subtle" presence renders a floating sigil + count badge. // M6: live thread loaded from /api/projects/[id]/socrates. The reply input
// POSTs each user turn and renders Socrates' response when it arrives.
// Numbered options pre-fill the input when clicked / pressed (13).
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Sigil } from "./Sigil"; import { Sigil } from "./Sigil";
import type { FixtureSocratesTurn } from "../../lib/fixtures/aristotle";
export type SocratesPresence = "subtle" | "default" | "prominent"; export type SocratesPresence = "subtle" | "default" | "prominent";
export type Density = "comfortable" | "compact"; export type Density = "comfortable" | "compact";
export interface DockMessage {
id: string;
role: "user" | "assistant";
text: string;
options?: Array<{ n: number; label: string; sub?: string }>;
pending?: boolean;
}
interface SocratesDockProps { interface SocratesDockProps {
thread: FixtureSocratesTurn[]; projectId: string;
presence: SocratesPresence; presence: SocratesPresence;
density: Density; density: Density;
} }
export function SocratesDock({ thread, presence }: SocratesDockProps) { interface ApiMessage {
id: string;
role: "user" | "assistant";
text: string;
options?: Array<{ n: number; label: string; sub?: string }>;
createdAt: string;
}
export function SocratesDock({ projectId, presence }: SocratesDockProps) {
const [messages, setMessages] = useState<DockMessage[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [loaded, setLoaded] = useState(false);
const [meta, setMeta] = useState<{ provider?: string; model?: string }>({});
const threadEndRef = useRef<HTMLDivElement | null>(null);
// Load thread on mount and trigger an opening turn if the thread is empty.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/socrates`);
if (!res.ok) throw new Error(`load thread: ${res.status}`);
const data = (await res.json()) as { messages?: ApiMessage[] };
if (cancelled) return;
const initial: DockMessage[] = (data.messages ?? []).map(m => ({
id: m.id,
role: m.role,
text: m.text,
options: m.options,
}));
setMessages(initial);
setLoaded(true);
if (initial.length === 0 && !cancelled) {
// Trigger Socrates' opening turn
await sendImpl("", true);
}
} catch {
if (!cancelled) setLoaded(true);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
// Auto-scroll on new turns
useEffect(() => {
threadEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [messages]);
const sendImpl = useCallback(
async (text: string, isOpening = false) => {
setSending(true);
try {
// Add the user bubble locally (optimistic) — unless this is the opening
if (!isOpening) {
setMessages(curr => [
...curr,
{ id: `tmp-u-${Date.now()}`, role: "user", text },
]);
}
// Show a pending Socrates bubble
const pendingId = `tmp-a-${Date.now()}`;
setMessages(curr => [
...curr,
{ id: pendingId, role: "assistant", text: "thinking…", pending: true },
]);
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/socrates`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error ?? `${res.status}`);
}
const data = await res.json();
const a = data.assistant as { id: string; text: string; options?: Array<{ n: number; label: string; sub?: string }> };
if (data.meta) setMeta({ provider: data.meta.provider, model: data.meta.model });
// Replace the pending bubble with the real one
setMessages(curr => curr.map(m =>
m.id === pendingId
? { id: a.id, role: "assistant", text: a.text, options: a.options }
: m
));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setMessages(curr => curr.map(m =>
m.pending ? { ...m, text: `${msg}`, pending: false } : m
));
} finally {
setSending(false);
}
},
[projectId]
);
const onSubmit = useCallback(async () => {
const text = input.trim();
if (!text || sending) return;
setInput("");
await sendImpl(text);
}, [input, sending, sendImpl]);
const pickOption = useCallback(async (option: { n: number; label: string; sub?: string }) => {
if (sending) return;
const text = `[${option.n}] ${option.label}${option.sub ? `${option.sub}` : ""}`;
await sendImpl(text);
}, [sending, sendImpl]);
// Number-key shortcuts on the most recent assistant turn with options
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
// Don't hijack number keys when typing in any input/contenteditable
const target = e.target as HTMLElement | null;
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return;
if (sending) return;
const last = [...messages].reverse().find(m => m.role === "assistant" && m.options?.length);
if (!last?.options) return;
const n = parseInt(e.key, 10);
if (Number.isNaN(n) || n < 1 || n > last.options.length) return;
e.preventDefault();
const opt = last.options.find(o => o.n === n);
if (opt) void pickOption(opt);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [messages, pickOption, sending]);
if (presence === "subtle") { if (presence === "subtle") {
return ( return (
<div className="dock dock-subtle"> <div className="dock dock-subtle" title={meta.model ? `Σ via ${meta.model}` : "Σ Socrates"}>
<Sigil size={36} /> <Sigil size={36} />
<div className="dock-subtle-count">3</div> {messages.filter(m => m.role === "assistant").length > 0 && (
<div className="dock-subtle-count">{messages.filter(m => m.role === "assistant").length}</div>
)}
</div> </div>
); );
} }
@@ -31,30 +178,47 @@ export function SocratesDock({ thread, presence }: SocratesDockProps) {
<div className="dock-header-text"> <div className="dock-header-text">
<div className="dock-name">Socrates</div> <div className="dock-name">Socrates</div>
<div className="dock-status"> <div className="dock-status">
<span className="dock-dot" /> 2 open threads <span className="dock-dot" />
{meta.provider ? `${meta.provider}${meta.model ? ` · ${meta.model.split("/").pop()}` : ""}` : "ready"}
</div> </div>
</div> </div>
<button className="dock-header-action" title="New thread" type="button"> <button className="dock-header-action" title="New thread (coming soon)" type="button" disabled>
+ +
</button> </button>
</header> </header>
<section className="dock-thread-wrap"> <section className="dock-thread-wrap">
<div className="dock-section-label dock-section-label-inline">Active thread · Aristotle</div> <div className="dock-section-label dock-section-label-inline">Active thread</div>
<div className="dock-thread"> <div className="dock-thread">
{thread.map((m, i) => ( {!loaded && (
<div key={i} className={`bubble bubble-${m.who}`}> <div className="bubble bubble-assistant">
{m.who === "socrates" && <span className="bubble-sigil">Σ</span>} <span className="bubble-sigil">Σ</span>
<span className="bubble-body"> <span className="bubble-body">
<span className="bubble-text">{m.text}</span> <span className="bubble-text" style={{ opacity: 0.6 }}>loading</span>
{m.options && ( </span>
</div>
)}
{messages.map(m => (
<div key={m.id} className={`bubble bubble-${m.role === "assistant" ? "socrates" : "user"}`}>
{m.role === "assistant" && <span className="bubble-sigil">Σ</span>}
<span className="bubble-body">
<span className="bubble-text" style={m.pending ? { opacity: 0.55, fontStyle: "italic" } : undefined}>
{m.text}
</span>
{m.options && m.options.length > 0 && (
<div className="bubble-options"> <div className="bubble-options">
{m.options.map(o => ( {m.options.map(o => (
<button key={o.n} className="bubble-option" type="button"> <button
key={o.n}
className="bubble-option"
type="button"
onClick={() => pickOption(o)}
disabled={sending}
>
<span className="bubble-option-num">{o.n}</span> <span className="bubble-option-num">{o.n}</span>
<span className="bubble-option-text"> <span className="bubble-option-text">
<span className="bubble-option-label">{o.label}</span> <span className="bubble-option-label">{o.label}</span>
<span className="bubble-option-sub">{o.sub}</span> {o.sub && <span className="bubble-option-sub">{o.sub}</span>}
</span> </span>
<span className="bubble-option-key">{o.n}</span> <span className="bubble-option-key">{o.n}</span>
</button> </button>
@@ -67,15 +231,36 @@ export function SocratesDock({ thread, presence }: SocratesDockProps) {
</span> </span>
</div> </div>
))} ))}
<div ref={threadEndRef} />
</div> </div>
</section> </section>
<footer className="dock-input-wrap"> <footer className="dock-input-wrap">
<div className="dock-input"> <form
className="dock-input"
onSubmit={e => {
e.preventDefault();
void onSubmit();
}}
>
<span className="dock-input-prompt"></span> <span className="dock-input-prompt"></span>
<span className="dock-input-placeholder">Reply to Socrates</span> <input
<span className="dock-input-shortcut"></span> className="dock-input-field"
</div> placeholder={sending ? "Socrates is thinking…" : "Reply to Socrates…"}
value={input}
onChange={e => setInput(e.target.value)}
disabled={sending}
autoFocus
/>
<button
type="submit"
className="dock-input-send"
disabled={sending || !input.trim()}
title="Send (⌘↵)"
>
</button>
</form>
</footer> </footer>
</aside> </aside>
); );

View File

@@ -133,5 +133,38 @@ async function ensureSeeded(projectId: string): Promise<{ projectId: string }> {
json: JSON.stringify(seedModel), json: JSON.stringify(seedModel),
}, },
}); });
// Auto-create an opening Socrates thread so the dock has somewhere to talk.
// Socrates' opening turn is generated lazily on first GET (so we don't
// pin a model selection to seed time).
await prisma.socratesThread.create({
data: {
projectId,
title: "Active thread · " + aristotleFixture.project.name,
status: "open",
},
});
return { projectId }; return { projectId };
} }
// ─── Socrates threads ────────────────────────────────────────────────────
export async function getActiveThread(projectId: string): Promise<{ id: string; title: string | null; messages: Array<{ id: string; role: string; content: string; createdAt: Date }> } | null> {
const thread = await prisma.socratesThread.findFirst({
where: { projectId, status: "open" },
orderBy: { updatedAt: "desc" },
include: { messages: { orderBy: { createdAt: "asc" } } },
});
if (!thread) return null;
return {
id: thread.id,
title: thread.title,
messages: thread.messages.map(m => ({
id: m.id,
role: m.role,
content: m.content,
createdAt: m.createdAt,
})),
};
}

173
apps/web/lib/llm/gateway.ts Normal file
View File

@@ -0,0 +1,173 @@
// LLM gateway — single interface used by Socrates' modes.
//
// Two adapters in M6:
// - lmstudio → local LM Studio at LMSTUDIO_BASE_URL (OpenAI-compatible)
// - anthropic → hosted Claude via @anthropic-ai/sdk
//
// Provider is chosen via LLM_PROVIDER env var (defaults to "lmstudio" so a
// fresh checkout works against the local model the user already has).
export type Role = "system" | "user" | "assistant";
export interface Message {
role: Role;
content: string;
}
export interface ChatOptions {
temperature?: number;
maxTokens?: number;
/** When set, the response is constrained to JSON conforming to this schema. */
jsonSchema?: { name: string; schema: Record<string, unknown> };
/** Force JSON-object mode (looser than schema) — for adapters that don't support strict schemas. */
jsonObjectMode?: boolean;
}
export interface ChatResult {
text: string;
inputTokens: number;
outputTokens: number;
}
export interface LLMGateway {
/** Provider name — for logging / model attribution in DB. */
readonly provider: "lmstudio" | "anthropic";
/** Model id reported by the provider for the active model. */
readonly model: string;
chat(messages: Message[], opts?: ChatOptions): Promise<ChatResult>;
}
// ─── Provider selection ──────────────────────────────────────────────────
export function defaultGateway(): LLMGateway {
const provider = (process.env.LLM_PROVIDER ?? "lmstudio").toLowerCase();
if (provider === "anthropic") return makeAnthropicGateway();
return makeLMStudioGateway();
}
// ─── LM Studio (OpenAI-compatible) ───────────────────────────────────────
function makeLMStudioGateway(): LLMGateway {
// Lazy-import OpenAI SDK so we don't pay the cost when we're using Anthropic.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const OpenAI = require("openai").default ?? require("openai");
const baseURL = process.env.LMSTUDIO_BASE_URL ?? "http://localhost:1234/v1";
const apiKey = process.env.LMSTUDIO_API_KEY ?? "lm-studio";
const model = process.env.LMSTUDIO_MODEL ?? "local-model";
const client = new OpenAI({ baseURL, apiKey, timeout: 120_000, maxRetries: 0 });
return {
provider: "lmstudio",
model,
async chat(messages, opts = {}) {
const responseFormat = buildOpenAIResponseFormat(opts);
const completion = await client.chat.completions.create({
model,
messages: messages as { role: Role; content: string }[],
temperature: opts.temperature ?? 0.4,
max_tokens: opts.maxTokens ?? 1024,
...(responseFormat ? { response_format: responseFormat } : {}),
});
const choice = completion.choices[0];
return {
text: choice?.message?.content ?? "",
inputTokens: completion.usage?.prompt_tokens ?? 0,
outputTokens: completion.usage?.completion_tokens ?? 0,
};
},
};
}
function buildOpenAIResponseFormat(opts: ChatOptions): unknown {
if (opts.jsonSchema) {
return {
type: "json_schema",
json_schema: { name: opts.jsonSchema.name, schema: opts.jsonSchema.schema, strict: false },
};
}
if (opts.jsonObjectMode) return { type: "json_object" };
return undefined;
}
// ─── Anthropic ────────────────────────────────────────────────────────────
function makeAnthropicGateway(): LLMGateway {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Anthropic = require("@anthropic-ai/sdk").default ?? require("@anthropic-ai/sdk");
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) throw new Error("ANTHROPIC_API_KEY is required when LLM_PROVIDER=anthropic");
const model = process.env.ANTHROPIC_MODEL ?? "claude-sonnet-4-6";
const client = new Anthropic({ apiKey });
return {
provider: "anthropic",
model,
async chat(messages, opts = {}) {
const systemBlocks = messages.filter(m => m.role === "system").map(m => m.content);
const others = messages.filter(m => m.role !== "system");
const result = await client.messages.create({
model,
max_tokens: opts.maxTokens ?? 1024,
temperature: opts.temperature ?? 0.4,
system: systemBlocks.length > 0 ? systemBlocks.join("\n\n---\n\n") : undefined,
messages: others.map(m => ({ role: m.role as "user" | "assistant", content: m.content })),
});
// Concatenate text blocks
const text = (result.content as Array<{ type: string; text?: string }>)
.filter(b => b.type === "text" && typeof b.text === "string")
.map(b => b.text!)
.join("");
return {
text,
inputTokens: result.usage?.input_tokens ?? 0,
outputTokens: result.usage?.output_tokens ?? 0,
};
},
};
}
// ─── JSON-mode helper with repair-retry (Phase 0 lessons) ────────────────
export async function chatJSON<T = unknown>(
gateway: LLMGateway,
messages: Message[],
opts: ChatOptions & { maxRepairs?: number } = {}
): Promise<{ value: T; result: ChatResult }> {
const maxRepairs = opts.maxRepairs ?? 2;
const history: Message[] = [...messages];
for (let attempt = 0; attempt <= maxRepairs; attempt++) {
const result = await gateway.chat(history, opts);
const cleaned = stripCodeFences(result.text);
try {
const value = JSON.parse(cleaned) as T;
return { value, result };
} catch (parseErr) {
if (attempt === maxRepairs) {
throw new Error(
`JSON parse failed after ${maxRepairs + 1} attempts. ` +
`Last error: ${(parseErr as Error).message}\n` +
`Last response (first 600 chars): ${cleaned.slice(0, 600)}`
);
}
history.push({ role: "assistant", content: result.text });
history.push({
role: "user",
content: `That response was not valid JSON. Error: ${(parseErr as Error).message}\nReturn ONLY a valid JSON object, no prose, no code fences.`,
});
}
}
throw new Error("chatJSON exhausted repairs");
}
function stripCodeFences(text: string): string {
const trimmed = text.trim();
const fenceMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
if (fenceMatch) return fenceMatch[1].trim();
return trimmed;
}

View File

@@ -0,0 +1,21 @@
// Prompt loader — reads .md files from lib/llm/prompts/ at runtime.
// Cached in module scope so we only hit the disk once per prompt name.
//
// Lives in a server-only module (uses node:fs); never imported by client code.
import "server-only";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const cache = new Map<string, string>();
export function loadPrompt(relativePath: string): string {
const cached = cache.get(relativePath);
if (cached) return cached;
const full = resolve(here, "prompts", relativePath);
const content = readFileSync(full, "utf8");
cache.set(relativePath, content);
return content;
}

View File

@@ -0,0 +1,25 @@
# Socrates
You are Socrates, a thinking partner for a product manager designing a product idea inside Socrata. You speak with peerage — not as an assistant, as a colleague.
## Voice
- Question-led. Default to surfacing the right question rather than volunteering a solution.
- Economical. Sentences carry weight. No filler.
- Skeptical by default. Neutral or mildly contrarian, never optimistic.
- Concrete. Refer to specific model elements by name when possible.
- Decisive when threads run long. After 23 iterations on a point, recommend.
## Never
- Open with affirmations like "Great question" or "Sure".
- Recap what the user just said before responding.
- Apologize for limitations.
- Ask permission to draft when you could just propose.
- Cheerlead a weak idea.
- Use bullet points for prose responses.
- Reference any element not in the current model.
## Pattern
When responding, follow this structure unless the user asked a direct factual question:
1. Observe what just happened or what's true now.
2. Name the underlying tension or implication.
3. Propose a concrete next move (with numbered options if a decision is wanted).

View File

@@ -0,0 +1,48 @@
# Mode: Review (active conversation about an existing model)
You are mid-project with a PM. The model already exists. You have just been shown the seed, the model, and the active findings (assumptions, risks, inconsistencies).
Your job in this mode is to surface the most important question or tension and engage the PM in deciding what to do about it. Stay in character per the system prompt.
## Output
Return a JSON object with these fields:
- `text` — your turn, in prose. 14 sentences. Follow the Observe → Name tension → Propose pattern from the character spec. No bullets.
- `options` (optional, max 3) — when offering a decision, supply numbered options. Each option:
- `n` — 1, 2, or 3
- `label` — ≤5 words, the choice
- `sub` — ≤8 words, a one-line clarifier
## When to use options
- The user is at a decision point and continued open prose will spiral
- Two or three credible directions exist and you want to make them visible
## When NOT to use options
- The user is exploring or just answered a question — let them think
- Only one good answer exists — give it, don't pretend
- Already-listed options just got declined
## What never to do
- Open with "Great question" or "Sure"
- Recap what the user said
- Apologize for limitations
- Cheerlead
- Use bullets in the prose `text` field
- Reference any element id not in the model JSON shown to you
## Output schema (strict)
```json
{
"text": "string (14 sentences)",
"options": [
{ "n": 1, "label": "string", "sub": "string" }
]
}
```
Return ONLY the JSON object. No prose preamble, no code fences.

View File

@@ -0,0 +1,231 @@
// Socrates conversation runner — server-side.
//
// Builds the LLM prompt (character + review + project context), wraps the
// thread history, asks for a structured JSON turn (`text` + optional
// `options`), persists user + assistant messages, and returns the new turn.
import "server-only";
import { defaultGateway, chatJSON, type Message } from "./gateway";
import { loadPrompt } from "./prompts";
import { prisma } from "../db/client";
import type { SysMLModel } from "../sysml/model";
import type { ValidationIssue } from "../sysml/validate";
// ─── Output schema for one Socrates turn ────────────────────────────────
export interface SocratesOption {
n: number;
label: string;
sub?: string;
}
export interface SocratesTurn {
text: string;
options?: SocratesOption[];
}
const turnJsonSchema = {
type: "object",
additionalProperties: false,
required: ["text"],
properties: {
text: { type: "string", minLength: 1 },
options: {
type: "array",
maxItems: 3,
items: {
type: "object",
additionalProperties: false,
required: ["n", "label"],
properties: {
n: { type: "integer", minimum: 1, maximum: 3 },
label: { type: "string", minLength: 1, maxLength: 60 },
sub: { type: "string", maxLength: 80 },
},
},
},
},
} as const;
// ─── Public: handle one user turn → Socrates reply, persist both ────────
export interface SendUserTurnArgs {
threadId: string;
/** Caller passes the latest model so we don't re-load it inside this fn. */
model: SysMLModel;
issues?: ValidationIssue[];
/** Body of the user's message. Empty string means "open the conversation". */
userText: string;
}
export interface SendUserTurnResult {
user: { id: string; createdAt: Date };
assistant: {
id: string;
createdAt: Date;
turn: SocratesTurn;
provider: string;
model: string;
inputTokens: number;
outputTokens: number;
};
}
export async function sendUserTurn(args: SendUserTurnArgs): Promise<SendUserTurnResult> {
const thread = await prisma.socratesThread.findUnique({
where: { id: args.threadId },
include: {
messages: { orderBy: { createdAt: "asc" } },
},
});
if (!thread) throw new Error(`thread ${args.threadId} not found`);
// 1. Persist the user turn first so concurrent reads see it.
let userRecord: { id: string; createdAt: Date } | undefined;
if (args.userText.trim().length > 0) {
userRecord = await prisma.socratesMessage.create({
data: {
threadId: thread.id,
role: "user",
content: JSON.stringify({ text: args.userText }),
},
select: { id: true, createdAt: true },
});
}
// 2. Build the LLM context.
const character = loadPrompt("socrates/character.md");
const review = loadPrompt("socrates/review.md");
const projectContext = JSON.stringify(
{
model: trimModel(args.model),
issues: (args.issues ?? []).slice(0, 30).map(i => ({
code: i.code,
severity: i.severity,
message: i.message,
anchor: i.anchor,
})),
},
null,
2
);
const systemPrompt = [
character,
"---",
review,
"---",
"Current project context (model + active validation issues):",
"```json",
projectContext,
"```",
].join("\n\n");
const historyMessages: Message[] = thread.messages.map(m => {
const parsed = JSON.parse(m.content) as { text: string };
return {
role: m.role === "user" ? "user" : "assistant",
content: parsed.text,
};
});
if (args.userText.trim().length > 0) {
historyMessages.push({ role: "user", content: args.userText });
} else if (historyMessages.length === 0) {
// Opening turn — give Socrates a kick.
historyMessages.push({
role: "user",
content: "Open the conversation. Surface the most important tension you see in this model.",
});
}
// 3. Ask the model.
const gateway = defaultGateway();
const { value, result } = await chatJSON<SocratesTurn>(
gateway,
[{ role: "system", content: systemPrompt }, ...historyMessages],
{
temperature: 0.4,
maxTokens: 768,
jsonSchema: { name: "socrates_turn", schema: turnJsonSchema as Record<string, unknown> },
jsonObjectMode: true, // safety net for adapters lacking strict json_schema
maxRepairs: 2,
}
);
// Defensive normalization
const turn: SocratesTurn = {
text: typeof value?.text === "string" ? value.text : "[empty]",
...(Array.isArray(value?.options) && value.options.length > 0
? { options: value.options.slice(0, 3) }
: {}),
};
// 4. Persist the assistant turn.
const assistantRecord = await prisma.socratesMessage.create({
data: {
threadId: thread.id,
role: "assistant",
content: JSON.stringify(turn),
provider: gateway.provider,
model: gateway.model,
inputTokens: result.inputTokens,
outputTokens: result.outputTokens,
},
select: { id: true, createdAt: true },
});
await prisma.socratesThread.update({
where: { id: thread.id },
data: { updatedAt: new Date() },
});
return {
user: userRecord ?? { id: "(skipped)", createdAt: new Date(0) },
assistant: {
id: assistantRecord.id,
createdAt: assistantRecord.createdAt,
turn,
provider: gateway.provider,
model: gateway.model,
inputTokens: result.inputTokens,
outputTokens: result.outputTokens,
},
};
}
// ─── Helpers ─────────────────────────────────────────────────────────────
function trimModel(model: SysMLModel): unknown {
// Compact view for Socrates — drop ids of properties (just names) and
// multiplicity / type detail to save tokens.
return {
blocks: model.blocks.map(b => ({
id: b.id,
label: b.label,
kind: b.kind,
properties: b.properties.map(p => p.name),
})),
associations: model.associations.map(a => ({
id: a.id,
from: a.fromBlockId,
to: a.toBlockId,
label: a.label,
kind: a.kind,
})),
constraints: model.constraints.map(c => ({
id: c.id,
label: c.label,
appliesTo: c.appliesTo,
})),
requirements: model.requirements.map(r => ({
id: r.id,
tag: r.tag,
text: r.text,
satisfiedBy: r.relations
.filter(rel => rel.kind === "satisfy")
.map(rel => (rel as { kind: "satisfy"; blockId: string }).blockId),
})),
};
}

View File

@@ -19,6 +19,7 @@
] ]
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.91.1",
"@prisma/client": "^6.19.3", "@prisma/client": "^6.19.3",
"@tiptap/core": "^3.22.5", "@tiptap/core": "^3.22.5",
"@tiptap/extension-mention": "^3.22.5", "@tiptap/extension-mention": "^3.22.5",
@@ -28,9 +29,11 @@
"@tiptap/suggestion": "^3.22.5", "@tiptap/suggestion": "^3.22.5",
"@xyflow/react": "^12.10.2", "@xyflow/react": "^12.10.2",
"next": "16.2.4", "next": "16.2.4",
"openai": "^6.35.0",
"prisma": "^6.19.3", "prisma": "^6.19.3",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"server-only": "^0.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",

View File

@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@anthropic-ai/sdk':
specifier: ^0.91.1
version: 0.91.1(zod@4.3.6)
'@prisma/client': '@prisma/client':
specifier: ^6.19.3 specifier: ^6.19.3
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
@@ -35,6 +38,9 @@ importers:
next: next:
specifier: 16.2.4 specifier: 16.2.4
version: 16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
openai:
specifier: ^6.35.0
version: 6.35.0(zod@4.3.6)
prisma: prisma:
specifier: ^6.19.3 specifier: ^6.19.3
version: 6.19.3(typescript@5.9.3) version: 6.19.3(typescript@5.9.3)
@@ -44,6 +50,9 @@ importers:
react-dom: react-dom:
specifier: 19.2.4 specifier: 19.2.4
version: 19.2.4(react@19.2.4) version: 19.2.4(react@19.2.4)
server-only:
specifier: ^0.0.1
version: 0.0.1
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: ^20 specifier: ^20
@@ -66,6 +75,15 @@ importers:
packages: packages:
'@anthropic-ai/sdk@0.91.1':
resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==}
hasBin: true
peerDependencies:
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta:
zod:
optional: true
'@babel/code-frame@7.29.0': '@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -121,6 +139,10 @@ packages:
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
'@babel/runtime@7.29.2':
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
'@babel/template@7.28.6': '@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -1605,6 +1627,10 @@ packages:
json-buffer@3.0.1: json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
json-schema-to-ts@3.1.1:
resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
engines: {node: '>=16'}
json-schema-traverse@0.4.1: json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@@ -1764,6 +1790,18 @@ packages:
ohash@2.0.11: ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
openai@6.35.0:
resolution: {integrity: sha512-L/skwIGnt5xQZHb0UfTu9uAUKbis3ehKypOuJKi20QvG7UStV6C8IC3myGYHcdiF4kms/bAvOJ9UqqNWqi8x/Q==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.25 || ^4.0
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
optionator@0.9.4: optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -1962,6 +2000,9 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
set-function-length@1.2.2: set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2077,6 +2118,9 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
ts-algebra@2.0.0:
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
ts-api-utils@2.5.0: ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'} engines: {node: '>=18.12'}
@@ -2206,6 +2250,12 @@ packages:
snapshots: snapshots:
'@anthropic-ai/sdk@0.91.1(zod@4.3.6)':
dependencies:
json-schema-to-ts: 3.1.1
optionalDependencies:
zod: 4.3.6
'@babel/code-frame@7.29.0': '@babel/code-frame@7.29.0':
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
@@ -2283,6 +2333,8 @@ snapshots:
dependencies: dependencies:
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@babel/runtime@7.29.2': {}
'@babel/template@7.28.6': '@babel/template@7.28.6':
dependencies: dependencies:
'@babel/code-frame': 7.29.0 '@babel/code-frame': 7.29.0
@@ -3433,8 +3485,8 @@ snapshots:
'@next/eslint-plugin-next': 16.2.4 '@next/eslint-plugin-next': 16.2.4
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.10 eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.6.1))
@@ -3456,7 +3508,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)):
dependencies: dependencies:
'@nolyfill/is-core-module': 1.0.39 '@nolyfill/is-core-module': 1.0.39
debug: 4.4.3 debug: 4.4.3
@@ -3467,22 +3519,22 @@ snapshots:
tinyglobby: 0.2.16 tinyglobby: 0.2.16
unrs-resolver: 1.11.1 unrs-resolver: 1.11.1
optionalDependencies: optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
dependencies: dependencies:
debug: 3.2.7 debug: 3.2.7
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.10 eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
dependencies: dependencies:
'@rtsao/scc': 1.1.0 '@rtsao/scc': 1.1.0
array-includes: 3.1.9 array-includes: 3.1.9
@@ -3493,7 +3545,7 @@ snapshots:
doctrine: 2.1.0 doctrine: 2.1.0
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.10 eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
hasown: 2.0.3 hasown: 2.0.3
is-core-module: 2.16.1 is-core-module: 2.16.1
is-glob: 4.0.3 is-glob: 4.0.3
@@ -3942,6 +3994,11 @@ snapshots:
json-buffer@3.0.1: {} json-buffer@3.0.1: {}
json-schema-to-ts@3.1.1:
dependencies:
'@babel/runtime': 7.29.2
ts-algebra: 2.0.0
json-schema-traverse@0.4.1: {} json-schema-traverse@0.4.1: {}
json-stable-stringify-without-jsonify@1.0.1: {} json-stable-stringify-without-jsonify@1.0.1: {}
@@ -4102,6 +4159,10 @@ snapshots:
ohash@2.0.11: {} ohash@2.0.11: {}
openai@6.35.0(zod@4.3.6):
optionalDependencies:
zod: 4.3.6
optionator@0.9.4: optionator@0.9.4:
dependencies: dependencies:
deep-is: 0.1.4 deep-is: 0.1.4
@@ -4335,6 +4396,8 @@ snapshots:
semver@7.7.4: {} semver@7.7.4: {}
server-only@0.0.1: {}
set-function-length@1.2.2: set-function-length@1.2.2:
dependencies: dependencies:
define-data-property: 1.1.4 define-data-property: 1.1.4
@@ -4510,6 +4573,8 @@ snapshots:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0
ts-algebra@2.0.0: {}
ts-api-utils@2.5.0(typescript@5.9.3): ts-api-utils@2.5.0(typescript@5.9.3):
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3

View File

@@ -25,6 +25,7 @@ model Project {
snapshots ModelSnapshot[] snapshots ModelSnapshot[]
changes ChangelogEntry[] changes ChangelogEntry[]
threads SocratesThread[]
} }
model ModelSnapshot { model ModelSnapshot {
@@ -52,3 +53,37 @@ model ChangelogEntry {
@@index([projectId, version]) @@index([projectId, version])
} }
model SocratesThread {
id String @id @default(cuid())
projectId String
/// Model element this thread is anchored to, if any (block id, req id, …).
anchorElementId String?
status String @default("open") // open | archived | resolved
title String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
messages SocratesMessage[]
@@index([projectId])
}
model SocratesMessage {
id String @id @default(cuid())
threadId String
role String // "user" | "assistant"
/// JSON: { text, options?: [{n,label,sub?}] }
content String
/// Provider + model that produced this turn (assistant turns only).
provider String?
model String?
inputTokens Int?
outputTokens Int?
createdAt DateTime @default(now())
thread SocratesThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
@@index([threadId, createdAt])
}

View File

@@ -1059,3 +1059,38 @@ button { font-family: inherit; }
} }
.chip-rename:focus { background: var(--surface-2); } .chip-rename:focus { background: var(--surface-2); }
.chip.chip-editing { box-shadow: 0 0 0 1.5px var(--accent); } .chip.chip-editing { box-shadow: 0 0 0 1.5px var(--accent); }
/* ─── Dock input (M6) ─── */
.dock-input { display: flex; align-items: center; gap: 6px; }
.dock-input-field {
flex: 1;
border: none;
background: transparent;
outline: none;
font: inherit;
font-family: var(--font-prose);
font-size: 12.5px;
color: var(--fg);
min-width: 0;
}
.dock-input-field::placeholder { color: var(--muted); }
.dock-input-field:disabled { color: var(--muted); cursor: not-allowed; }
.dock-input-send {
background: transparent;
border: 1px solid var(--border-strong);
border-radius: 4px;
padding: 2px 6px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--muted);
cursor: pointer;
}
.dock-input-send:hover:not(:disabled) {
background: var(--accent);
color: var(--accent-on);
border-color: var(--accent);
}
.dock-input-send:disabled {
opacity: 0.4;
cursor: not-allowed;
}