feat: Gedächtnis mit Append-Only-Log, Traumphase und Ankerpunkten

- memory_events als append-only Log (nur INSERT), daraus Zustand
  deterministisch rekonstruierbar (fold/rebuild)
- Traumphase konsolidiert Log in Ankerpunkte: LLM-Extraktion mit
  Heuristik-Fallback, Dedupe über Wort-Ähnlichkeit, Decay auf
  ungepinnten Ankern
- einstellbares Gedächtnis-Fenster (Default 10 Schritte) statt
  hartkodiertem slice(-24)
- wichtigste Ankerpunkte werden als System-Kontext eingespielt
- neue Werkzeuge remember (schreibend, gepinnt) und recall
- Gedächtnis-Panel im Frontend: Ankerliste, Pinnen, Traumphase
  manuell auslösen, Rekonstruktion
- Eval-Skript: npm run memory:eval --workspace server
This commit is contained in:
Jeuner 2026-08-27 21:41:35 +02:00
parent e6d55f35a3
commit 9bce768f95
22 changed files with 1388 additions and 100 deletions

View file

@ -129,9 +129,56 @@ sich das in den Einstellungen.
| `calculate` | Arithmetik mit eigenem Parser |
| `read_file` | Textdatei unterhalb des Projektverzeichnisses lesen |
| `list_files` | Verzeichnis auflisten |
| `remember` | Wichtigen Punkt als gepinnten Ankerpunkt ins Gedächtnis schreiben |
| `recall` | Gedächtnis (Ankerpunkte) durchsuchen |
Aktuelle Liste: `curl -s http://localhost:8788/api/tools`
## Gedächtnis (Chat-Memory)
Das Gedächtnis hat drei Schichten:
| Schicht | Speicher | Zweck |
|---|---|---|
| Arbeitsgedächtnis | Nachrichtenfenster | Einstellbar (Default 10 Schritte), geht ans Modell |
| Episodisch | `memory_events` | Append-Only-Log: nur INSERT, nie UPDATE/DELETE |
| Semantisch | `anchors` | Ankerpunkte, destilliert in der Traumphase |
Das Fenster ist in den Einstellungen per Slider einstellbar (2100 Schritte).
### Traumphase
Die Traumphase konsolidiert neue Log-Einträge in Ankerpunkte — bevorzugt per
LLM-Extraktion (JSON-Format, Temperatur 0.2), mit einer Regex-Heuristik als
Fallback. Auslöser:
- automatisch nach 3 Minuten Inaktivität oder wenn 10 Schritte unkonsolidiert sind
- manuell über 🧠 **Gedächtnis** → „Traumphase jetzt"
Ankerpunkte haben eine Wichtigkeit (01), eine Art (`fact`, `decision`,
`preference`, `entity`, `open_question`) und einen Ursprung (`dream`, `model`,
`heuristic`, `test`). Ähnliche Anker werden zusammengeführt (Wort-Ähnlichkeit
≥ 0.5, `hits` steigt). Bei jedem Traumlauf verfallen ungepinnte Anker
(Importance × 0.9); unter 0.15 und ohne Treffer werden sie gelöscht. Gepinnte
(★) bleiben dauerhaft.
Die wichtigsten Anker werden als System-Kontext eingespielt (Budget ~1200
Zeichen) — das Modell beantwortet dann Fragen aus Inhalten, die nie im
sichtbaren Chatverlauf standen.
### Rekonstruktion
Weil das Log append-only ist, lässt sich der Zustand jederzeit deterministisch
aus Seq 0 neu falten: 🧠 **Gedächtnis** → „Aus Log rekonstruieren" (gepinnte
Anker bleiben erhalten). Die Eval der Heuristik und des Dream-Parsers:
```bash
npm run memory:eval --workspace server
```
Endpunkte: `GET /api/sessions/:id/memory`, `POST /api/sessions/:id/dream`,
`POST /api/sessions/:id/memory/rebuild`, `PATCH|DELETE /api/anchors/:id`.
### Grenzen
Alle Werkzeuge sind **ausschließlich lesend**. Es gibt nichts, was schreibt,

View file

@ -6,7 +6,8 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"memory:eval": "tsx scripts/memory-eval.ts"
},
"dependencies": {
"@fastify/cors": "^10.0.1",

View file

@ -0,0 +1,95 @@
import { heuristicAnchors, parseDreamAnchors, similarity } from "../src/memory-text.js";
let pass = 0;
let fail = 0;
function check(name: string, cond: boolean) {
if (cond) {
pass++;
console.log(` ok ${name}`);
} else {
fail++;
console.error(`FAIL ${name}`);
}
}
console.log("Heuristik:");
check(
"Merke-Satz wird als Fakt erkannt",
heuristicAnchors("Bitte merke dir: Das Deployment läuft über Coolify.").some(
(a) => a.kind === "fact" && a.importance >= 0.8,
),
);
check(
"Namensnennung wird als Fakt erkannt",
heuristicAnchors("Ich heiße Jonas und wohne in Bonn.").some((a) => a.kind === "fact"),
);
check(
"Präferenz wird erkannt",
heuristicAnchors("Ich mag keine Benachrichtigungen am Abend.").some(
(a) => a.kind === "preference",
),
);
check(
"Entscheidung wird erkannt",
heuristicAnchors("Wir machen Code-Reviews von nun an immer paarweise.").some(
(a) => a.kind === "decision",
),
);
check(
"Offene Frage wird erkannt",
heuristicAnchors("Der Name der neuen API ist noch unklar.").some(
(a) => a.kind === "open_question",
),
);
check(
"Smalltalk bleibt außen vor",
heuristicAnchors("Hallo, wie geht es dir heute? Mir geht es gut, danke!").length === 0,
);
check(
"Leerer Text liefert nichts",
heuristicAnchors("").length === 0,
);
console.log("Dedupe (Similarity):");
check(
"Umformulierte Wiederholung liegt über Schwelle",
similarity(
"Ich heiße Jonas und wohne in Bonn",
"Mein Name ist Jonas, ich wohne in Bonn",
) >= 0.5,
);
check(
"Fremde Sätze liegen unter Schwelle",
similarity("Ich mag keine Benachrichtigungen", "Das Deployment läuft über Coolify") < 0.4,
);
console.log("Dream-Parser:");
check(
"Gültiges anchors-JSON wird geparst",
parseDreamAnchors(
'{"anchors":[{"text":"Nutzer wohnt in Bonn","kind":"fact","importance":0.9}]}',
).length === 1,
);
check(
"Bekanntes Kind bleibt erhalten, unbekanntes wird fact",
parseDreamAnchors(
'{"anchors":[{"text":"Wir nutzen pnpm","kind":"decision"},{"text":"Server heißt alfa","kind":"seltsam"}]}',
)[1]?.kind === "fact",
);
check(
"Wichtigkeit wird auf 0.11 begrenzt",
parseDreamAnchors('{"anchors":[{"text":"Sehr wichtig alles","importance":99}]}')[0]
?.importance === 1,
);
check(
"Müll wird abgewiesen",
parseDreamAnchors("kein json hier") .length === 0,
);
check(
"Text in JSON-Wrapper wird noch gefunden",
parseDreamAnchors('Sure! Here you go: {"anchors":[{"text":"API-Key liegt im Vault","kind":"fact","importance":0.8}]}').length === 1,
);
console.log(`\n${pass} bestanden, ${fail} fehlgeschlagen`);
process.exit(fail > 0 ? 1 : 0);

View file

@ -5,6 +5,7 @@ import type { IncomingMessage } from "node:http";
import { readFileSync } from "node:fs";
import path from "node:path";
import * as dbmod from "./db.js";
import * as mem from "./memory.js";
import { streamChat, type OllamaOptions } from "./ollama.js";
import { transcribeAudio, synthesizeSpeech, MAX_AUDIO_BYTES } from "./voice.js";
import {
@ -31,6 +32,38 @@ try {
const PORT = Number(process.env.PORT ?? 8788);
const OLLAMA_URL = process.env.OLLAMA_URL ?? "http://localhost:11434";
const MODEL = process.env.MODEL ?? "qwen3.5:latest";
const DREAM_IDLE_MS = 180_000;
const DREAM_BATCH = 10;
const dreamTimers = new Map<string, ReturnType<typeof setTimeout>>();
const activeDreams = new Set<string>();
function runDream(sessionId: string): Promise<mem.DreamResult | null> {
if (activeDreams.has(sessionId)) return Promise.resolve(null);
activeDreams.add(sessionId);
clearTimeout(dreamTimers.get(sessionId));
dreamTimers.delete(sessionId);
return mem
.dream(sessionId, { ollamaUrl: OLLAMA_URL, model: MODEL })
.then((result) => {
broadcast({ type: "memory-updated", sessionId, result });
return result;
})
.catch((err) => {
app.log.error({ err }, "Traumphase fehlgeschlagen");
return null;
})
.finally(() => activeDreams.delete(sessionId));
}
function scheduleDream(sessionId: string) {
clearTimeout(dreamTimers.get(sessionId));
dreamTimers.set(
sessionId,
setTimeout(() => void runDream(sessionId), DREAM_IDLE_MS),
);
}
const app = Fastify({ logger: true, bodyLimit: MAX_AUDIO_BYTES });
@ -64,7 +97,7 @@ app.get("/api/model", async () => {
const res = await fetch(`${OLLAMA_URL}/api/show`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: process.env.MODEL ?? "qwen3.5:latest" }),
body: JSON.stringify({ name: MODEL }),
});
if (!res.ok) return { ok: false, error: `Ollama HTTP ${res.status}` };
const data = (await res.json()) as {
@ -73,7 +106,7 @@ app.get("/api/model", async () => {
};
return {
ok: true,
model: process.env.MODEL ?? "qwen3.5:latest",
model: MODEL,
parameterSize: data.details?.parameter_size,
quantization: data.details?.quantization_level,
capabilities: data.capabilities ?? [],
@ -94,11 +127,56 @@ app.get("/api/sessions/:id/messages", async (req, reply) => {
});
app.delete("/api/sessions/:id", async (req) => {
const { id } = req.params as { id: string };
clearTimeout(dreamTimers.get(id));
dreamTimers.delete(id);
dbmod.deleteSession(id);
mem.deleteSessionMemory(id);
broadcast({ type: "session-deleted", id });
return { ok: true };
});
// --- Gedächtnis ---
app.get("/api/sessions/:id/memory", async (req, reply) => {
const { id } = req.params as { id: string };
if (!dbmod.getSession(id)) return reply.code(404).send({ error: "not found" });
return { state: mem.getMemoryState(id), anchors: mem.listAnchors(id) };
});
app.post("/api/sessions/:id/dream", async (req, reply) => {
const { id } = req.params as { id: string };
if (!dbmod.getSession(id)) return reply.code(404).send({ error: "not found" });
const result = await mem.dream(id, { ollamaUrl: OLLAMA_URL, model: MODEL });
broadcast({ type: "memory-updated", sessionId: id, result });
return result;
});
app.post("/api/sessions/:id/memory/rebuild", async (req, reply) => {
const { id } = req.params as { id: string };
if (!dbmod.getSession(id)) return reply.code(404).send({ error: "not found" });
const result = mem.rebuildMemory(id);
broadcast({ type: "memory-updated", sessionId: id, result });
return { ok: true, ...result };
});
app.patch("/api/anchors/:id", async (req, reply) => {
const id = Number((req.params as { id: string }).id);
const { pinned } = (req.body ?? {}) as { pinned?: boolean };
if (!Number.isInteger(id) || typeof pinned !== "boolean") {
return reply.code(400).send({ error: "id/pinned fehlt" });
}
if (!mem.setAnchorPinned(id, pinned)) {
return reply.code(404).send({ error: "not found" });
}
return { ok: true };
});
app.delete("/api/anchors/:id", async (req, reply) => {
const id = Number((req.params as { id: string }).id);
if (!Number.isInteger(id)) return reply.code(400).send({ error: "id ungültig" });
if (!mem.deleteAnchor(id)) return reply.code(404).send({ error: "not found" });
return { ok: true };
});
// --- Voice ---
app.post("/api/stt", async (req, reply) => {
if (!sttLimiter(req.ip)) {
@ -165,15 +243,21 @@ interface ChatOptionsPayload {
provider?: string;
openrouterModel?: string;
tools?: boolean;
memorySteps?: number;
memoryAnchors?: boolean;
dreamAuto?: boolean;
}
function parseOptions(raw: unknown): OllamaOptions & {
provider: "ollama" | "openrouter";
openrouterModel: string;
memorySteps: number;
memoryAnchors: boolean;
dreamAuto: boolean;
} {
const o = (raw ?? {}) as Partial<ChatOptionsPayload>;
return {
model: process.env.MODEL ?? "qwen3.5:latest",
model: MODEL,
think: o.think !== false,
tools: o.tools !== false,
temperature: clamp(Number(o.temperature ?? 0.7), 0, 2),
@ -184,6 +268,9 @@ function parseOptions(raw: unknown): OllamaOptions & {
/^[\w./:-]{3,120}$/.test(o.openrouterModel)
? o.openrouterModel
: "anthropic/claude-sonnet-4.5",
memorySteps: Math.min(Math.max(Math.round(Number(o.memorySteps ?? 10)), 2), 100),
memoryAnchors: o.memoryAnchors !== false,
dreamAuto: o.dreamAuto !== false,
};
}
@ -246,13 +333,19 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
dbmod.renameSessionIfDefault(session.id, content || "Bild");
const userMsg = dbmod.insertMessage(session.id, "user", content, null, images);
mem.appendEvent(session.id, "message", {
role: "user",
content,
images: images.length,
});
socket.send(JSON.stringify({ type: "user-message", message: userMsg }));
broadcast({ type: "sessions-changed" });
const opts = parseOptions(msg.options);
opts.sessionId = session.id;
const history = dbmod
.listMessages(session.id)
.slice(-24)
.slice(-opts.memorySteps)
.map((m) => {
const imgs = dbmod.parseImages(m);
return imgs.length
@ -260,6 +353,15 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
: { role: m.role, content: m.content };
});
const userSystem =
typeof msg.systemPrompt === "string" && msg.systemPrompt.trim()
? (msg.systemPrompt as string)
: undefined;
const anchorBlock = opts.memoryAnchors
? mem.anchorContextBlock(session.id)
: null;
const system = [userSystem, anchorBlock].filter(Boolean).join("\n\n") || undefined;
const assistantRow = dbmod.insertMessage(session.id, "assistant", "");
socket.send(JSON.stringify({ type: "assistant-start", message: assistantRow }));
@ -281,6 +383,12 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
},
onDone() {},
onToolCall(name: string, args: Record<string, unknown>) {
mem.appendEvent(session.id, "tool_call", {
name,
args: JSON.parse(JSON.stringify(args, (_k, v) =>
typeof v === "string" && v.length > 300 ? v.slice(0, 300) + "…" : v,
)),
});
socket.send(
JSON.stringify({
type: "tool-call",
@ -305,9 +413,7 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
if (!apiKey) throw new Error("OPENROUTER_API_KEY nicht gesetzt (.env fehlt)");
await streamOpenRouter(
history,
typeof msg.systemPrompt === "string" && msg.systemPrompt.trim()
? (msg.systemPrompt as string)
: undefined,
system,
{
model: opts.openrouterModel,
temperature: opts.temperature,
@ -318,17 +424,13 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
activeAbort.signal,
);
} else {
await streamChat(
history,
typeof msg.systemPrompt === "string" && msg.systemPrompt.trim()
? (msg.systemPrompt as string)
: undefined,
opts,
callbacks,
activeAbort.signal,
);
await streamChat(history, system, opts, callbacks, activeAbort.signal);
}
dbmod.updateAssistantMessage(assistantRow.id, full.trim(), thinking.trim() || null);
mem.appendEvent(session.id, "message", {
role: "assistant",
content: full.trim().slice(0, 4000),
});
socket.send(
JSON.stringify({
type: "done",
@ -337,6 +439,14 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
}),
);
broadcast({ type: "sessions-changed" });
if (opts.dreamAuto) {
const st = mem.getMemoryState(session.id);
if (st.last_seq - st.last_dream_seq >= DREAM_BATCH) {
void runDream(session.id);
} else {
scheduleDream(session.id);
}
}
} catch (err) {
const aborted =
activeAbort.signal.aborted ||

133
server/src/memory-text.ts Normal file
View file

@ -0,0 +1,133 @@
export const ANCHOR_KINDS = [
"fact",
"decision",
"preference",
"entity",
"open_question",
] as const;
export type AnchorKind = (typeof ANCHOR_KINDS)[number];
export interface AnchorCandidate {
text: string;
kind: AnchorKind;
importance: number;
}
const STOPWORDS = new Set([
"der", "die", "das", "und", "ich", "ein", "eine", "einer", "ist", "nicht",
"zu", "mit", "auf", "für", "im", "in", "den", "dem", "des", "sich", "hat",
"haben", "sein", "wird", "werden", "von", "mir", "mich", "du", "er", "sie",
"es", "wir", "ihr", "aber", "auch", "dass", "wie", "bei", "aus", "bitte",
"dann", "noch", "schon", "wäre", "habe", "hatte", "kann", "muss", "soll",
]);
export function normalizeText(s: string): string {
return s
.toLowerCase()
.replace(/[^\p{L}\p{N}\s]/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
/** Jaccard-Ähnlichkeit auf Wortebene, Stoppwörter und Kurzwörter ignoriert. */
export function similarity(a: string, b: string): number {
const wa = normalizeText(a)
.split(" ")
.filter((w) => w.length > 2 && !STOPWORDS.has(w));
const wb = normalizeText(b)
.split(" ")
.filter((w) => w.length > 2 && !STOPWORDS.has(w));
if (wa.length === 0 || wb.length === 0) return 0;
const setB = new Set(wb);
const inter = new Set(wa.filter((w) => setB.has(w))).size;
const union = new Set([...wa, ...wb]).size;
return union === 0 ? 0 : inter / union;
}
const HEURISTICS: { pattern: RegExp; kind: AnchorKind; importance: number }[] = [
{
pattern: /(merke (dir|dich)|vergiss (das )?nicht|denk dran|nicht vergessen)/i,
kind: "fact",
importance: 0.85,
},
{
pattern: /^(ich hei(ß|ss)e|mein name ist|ich wohne (in|bei)|ich arbeite (als|bei|in)|meine e-?mail|meine telefonnummer|ich bin von beruf)/i,
kind: "fact",
importance: 0.9,
},
{
pattern: /^(ich mag|ich mag (kein|keine)|ich bevorzuge|ich hasse|ich liebe|mir gef(ä|a)llt|mir gef(ä|a)llen (kein|keine))/i,
kind: "preference",
importance: 0.7,
},
{
pattern: /^(wir (machen|nutzen|nehmen|entscheiden|bleiben)|ab jetzt|von nun an|regel:)/i,
kind: "decision",
importance: 0.75,
},
{
pattern: /(offene frage|noch unklar|noch zu kl(ä|a)ren|ist ungekl(ä|a)rt|bleibt offen)/i,
kind: "open_question",
importance: 0.6,
},
];
/** Satzweise Heuristik-Extraktion ohne LLM — Fallback und Erstfilter. */
export function heuristicAnchors(text: string): AnchorCandidate[] {
const sentences = text
.split(/(?<=[.!?…])\s+|\n+/)
.map((s) => s.trim())
.filter((s) => s.length >= 10 && s.length <= 240);
const out: AnchorCandidate[] = [];
for (const s of sentences) {
for (const h of HEURISTICS) {
if (h.pattern.test(s)) {
out.push({ text: s.slice(0, 240), kind: h.kind, importance: h.importance });
break;
}
}
}
return out;
}
function clampImportance(n: number): number {
if (!Number.isFinite(n)) return 0.5;
return Math.min(1, Math.max(0.1, n));
}
/**
* Parst die Traumphase-Antwort des Modells. Akzeptiert {"anchors":[]} oder
* ein nacktes Array; kaputte Einträge werden still verworfen, nie geworfen.
*/
export function parseDreamAnchors(raw: string): AnchorCandidate[] {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
const m = raw.match(/\{[\s\S]*\}|\[[\s\S]*\]/);
if (!m) return [];
try {
parsed = JSON.parse(m[0]);
} catch {
return [];
}
}
const list = Array.isArray(parsed)
? parsed
: (parsed as { anchors?: unknown } | null)?.anchors;
if (!Array.isArray(list)) return [];
const out: AnchorCandidate[] = [];
for (const item of list.slice(0, 8)) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const o = item as Record<string, unknown>;
if (typeof o.text !== "string") continue;
const text = o.text.trim().slice(0, 300);
if (text.length < 6) continue;
const kind = ANCHOR_KINDS.includes(o.kind as AnchorKind)
? (o.kind as AnchorKind)
: "fact";
out.push({ text, kind, importance: clampImportance(Number(o.importance)) });
}
return out;
}

405
server/src/memory.ts Normal file
View file

@ -0,0 +1,405 @@
import { db } from "./db.js";
import {
ANCHOR_KINDS,
heuristicAnchors,
parseDreamAnchors,
similarity,
type AnchorKind,
} from "./memory-text.js";
db.exec(`
CREATE TABLE IF NOT EXISTS memory_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('message','tool_call')),
payload TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_memory_events_session
ON memory_events(session_id, seq);
CREATE TABLE IF NOT EXISTS memory_state (
session_id TEXT PRIMARY KEY,
last_seq INTEGER NOT NULL DEFAULT 0,
last_dream_seq INTEGER NOT NULL DEFAULT 0,
dream_count INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS anchors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
text TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'fact',
importance REAL NOT NULL DEFAULT 0.5,
hits INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
origin TEXT NOT NULL DEFAULT 'heuristic',
last_seq INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_anchors_session
ON anchors(session_id, importance);
`);
export interface MemoryEventRow {
seq: number;
session_id: string;
type: "message" | "tool_call";
payload: string;
created_at: number;
}
export interface MemoryStateRow {
session_id: string;
last_seq: number;
last_dream_seq: number;
dream_count: number;
updated_at: number;
}
export interface AnchorRow {
id: number;
session_id: string;
text: string;
kind: AnchorKind;
importance: number;
hits: number;
pinned: number;
origin: string;
last_seq: number;
created_at: number;
updated_at: number;
}
export interface AnchorInput {
text: string;
kind?: AnchorKind;
importance?: number;
seq?: number;
origin?: string;
pinned?: boolean;
}
export interface DreamResult {
ok: boolean;
events: number;
inserted: number;
merged: number;
pruned: number;
source: "llm" | "heuristic" | "none";
error?: string;
}
export interface DreamOptions {
ollamaUrl: string;
model: string;
signal?: AbortSignal;
}
export function appendEvent(
sessionId: string,
type: "message" | "tool_call",
payload: Record<string, unknown>,
): number {
const res = db
.prepare(
"INSERT INTO memory_events (session_id, type, payload, created_at) VALUES (?, ?, ?, ?)",
)
.run(sessionId, type, JSON.stringify(payload), Date.now());
return Number(res.lastInsertRowid);
}
export function listEvents(
sessionId: string,
afterSeq = 0,
): MemoryEventRow[] {
return db
.prepare(
"SELECT * FROM memory_events WHERE session_id = ? AND seq > ? ORDER BY seq ASC",
)
.all(sessionId, afterSeq) as unknown as MemoryEventRow[];
}
export function getMemoryState(sessionId: string): MemoryStateRow {
db.prepare(
"INSERT OR IGNORE INTO memory_state (session_id, last_seq, last_dream_seq, dream_count, updated_at) VALUES (?, 0, 0, 0, ?)",
).run(sessionId, Date.now());
return db
.prepare("SELECT * FROM memory_state WHERE session_id = ?")
.get(sessionId) as unknown as MemoryStateRow;
}
export function listAnchors(sessionId: string): AnchorRow[] {
return db
.prepare(
"SELECT * FROM anchors WHERE session_id = ? ORDER BY pinned DESC, importance DESC, updated_at DESC",
)
.all(sessionId) as unknown as AnchorRow[];
}
function countAnchors(sessionId: string): number {
return Number(
(
db
.prepare("SELECT COUNT(*) AS n FROM anchors WHERE session_id = ?")
.get(sessionId) as { n: number }
).n,
);
}
const SIMILARITY_THRESHOLD = 0.5;
/**
* Fügt einen Ankerpunkt ein oder verstärkt einen vorhandenen. Dedupe über
* Wort-Ähnlichkeit; Merge erhöht Treffer und nimmt die höhere Wichtigkeit.
*/
export function upsertAnchor(
sessionId: string,
input: AnchorInput,
): "inserted" | "merged" | "skipped" {
const text = input.text.trim().replace(/\s+/g, " ").slice(0, 300);
if (text.length < 6) return "skipped";
const kind = ANCHOR_KINDS.includes(input.kind as AnchorKind)
? (input.kind as AnchorKind)
: "fact";
const importance = Number.isFinite(input.importance)
? Math.min(1, Math.max(0.1, Number(input.importance)))
: 0.5;
const now = Date.now();
const pinned = input.pinned ? 1 : 0;
const match = listAnchors(sessionId).find(
(a) => similarity(a.text, text) >= SIMILARITY_THRESHOLD,
);
if (match) {
db.prepare(
"UPDATE anchors SET hits = hits + 1, importance = MAX(importance, ?), last_seq = MAX(last_seq, ?), pinned = MAX(pinned, ?), updated_at = ? WHERE id = ?",
).run(importance, input.seq ?? 0, pinned, now, match.id);
return "merged";
}
const seq =
input.seq ??
Number(
(
db
.prepare(
"SELECT COALESCE(MAX(seq), 0) AS s FROM memory_events WHERE session_id = ?",
)
.get(sessionId) as { s: number }
).s,
);
db.prepare(
"INSERT INTO anchors (session_id, text, kind, importance, hits, pinned, origin, last_seq, created_at, updated_at) VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?)",
).run(sessionId, text, kind, importance, pinned, input.origin ?? "heuristic", seq, now, now);
return "inserted";
}
/**
* Inkrementelle Rekonstruktion: faltet alle Events seit last_seq in
* Ankerpunkte. Da das Log append-only ist, liefert Replay aus Seq 0 immer
* denselben Zustand Rekonstruktion ist damit deterministisch.
*/
export function foldSession(sessionId: string): {
folded: number;
anchors: number;
} {
const state = getMemoryState(sessionId);
const events = listEvents(sessionId, state.last_seq);
let folded = 0;
for (const ev of events) {
if (ev.type !== "message") continue;
let payload: { role?: string; content?: string };
try {
payload = JSON.parse(ev.payload) as { role?: string; content?: string };
} catch {
continue;
}
if (payload.role !== "user" || !payload.content) continue;
for (const c of heuristicAnchors(payload.content)) {
upsertAnchor(sessionId, { ...c, seq: ev.seq });
}
folded++;
}
const top = events.length ? events[events.length - 1].seq : state.last_seq;
db.prepare(
"UPDATE memory_state SET last_seq = ?, updated_at = ? WHERE session_id = ?",
).run(top, Date.now(), sessionId);
return { folded, anchors: countAnchors(sessionId) };
}
/** Vollständiger Replay aus Seq 0; gepinnte Ankerpunkte bleiben erhalten. */
export function rebuildMemory(sessionId: string): {
events: number;
anchors: number;
} {
db.prepare("DELETE FROM anchors WHERE session_id = ? AND pinned = 0").run(
sessionId,
);
db.prepare(
"UPDATE memory_state SET last_seq = 0, last_dream_seq = 0, dream_count = 0, updated_at = ? WHERE session_id = ?",
).run(Date.now(), sessionId);
const { folded, anchors } = foldSession(sessionId);
return { events: folded, anchors };
}
function applyDecay(sessionId: string): number {
db.prepare(
"UPDATE anchors SET importance = importance * 0.9, updated_at = ? WHERE session_id = ? AND pinned = 0",
).run(Date.now(), sessionId);
const res = db
.prepare(
"DELETE FROM anchors WHERE session_id = ? AND pinned = 0 AND hits = 0 AND importance < 0.15",
)
.run(sessionId);
return Number(res.changes);
}
const DREAM_SYSTEM = `Du destillierst aus einem Chatverlauf langlebige Gedächtnisanker ("Ankerpunkte").
Antworte NUR mit JSON im Format:
{"anchors":[{"text":"...","kind":"fact|decision|preference|entity|open_question","importance":0.0}]}
Regeln: Maximal 8 Anker. Jeder Anker ist ein prägnanter, in sich verständlicher Satz.
Nur dauerhaft Wichtiges: Fakten über den Nutzer, Entscheidungen, Präferenzen, benannte Entitäten, offene Punkte.
Kein Smalltalk, keine Meta-Gesprächsinhalte, keine Aufgaben, die schon erledigt sind.
importance zwischen 0.1 und 1.0.`;
/**
* Traumphase: konsolidiert neue Events in Ankerpunkte bevorzugt per
* LLM-Extraktion, mit der Heuristik als Fallback. Dann Decay auf ungepinnten
* Ankern; nicht mehr getragene verfallen und werden gelöscht.
*/
export async function dream(
sessionId: string,
opts: DreamOptions,
): Promise<DreamResult> {
foldSession(sessionId);
const state = getMemoryState(sessionId);
const pending = listEvents(sessionId, state.last_dream_seq).slice(0, 40);
if (pending.length === 0) {
return { ok: true, events: 0, inserted: 0, merged: 0, pruned: 0, source: "none" };
}
const transcript = pending
.map((ev) => {
let p: { role?: string; content?: string; name?: string; args?: unknown } = {};
try {
p = JSON.parse(ev.payload) as typeof p;
} catch {
/* leer */
}
const who = ev.type === "tool_call" ? `tool:${p.name ?? "?"}` : (p.role ?? "?");
const body = String(p.content ?? JSON.stringify(p.args ?? "")).slice(0, 500);
return `[${ev.seq}] ${who}: ${body}`;
})
.join("\n");
let inserted = 0;
let merged = 0;
let source: DreamResult["source"] = "heuristic";
let error: string | undefined;
try {
const res = await fetch(`${opts.ollamaUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: opts.model,
stream: false,
think: false,
format: "json",
messages: [
{ role: "system", content: DREAM_SYSTEM },
{ role: "user", content: transcript },
],
options: { temperature: 0.2, num_predict: 1024 },
}),
signal: opts.signal,
});
if (!res.ok) throw new Error(`Ollama HTTP ${res.status}`);
const data = (await res.json()) as { message?: { content?: string } };
const parsed = parseDreamAnchors(String(data.message?.content ?? ""));
if (parsed.length === 0) throw new Error("keine brauchbaren Ankerpunkte");
const lastSeq = pending[pending.length - 1].seq;
for (const a of parsed) {
const r = upsertAnchor(sessionId, { ...a, seq: lastSeq, origin: "dream" });
if (r === "inserted") inserted++;
else if (r === "merged") merged++;
}
source = "llm";
} catch (err) {
error = err instanceof Error ? err.message : String(err);
}
const pruned = applyDecay(sessionId);
if (source === "llm") {
db.prepare(
"UPDATE memory_state SET last_dream_seq = ?, dream_count = dream_count + 1, updated_at = ? WHERE session_id = ?",
).run(pending[pending.length - 1].seq, Date.now(), sessionId);
}
return { ok: true, events: pending.length, inserted, merged, pruned, source, error };
}
const KIND_LABEL: Record<AnchorKind, string> = {
fact: "Fakt",
decision: "Entscheidung",
preference: "Präferenz",
entity: "Entität",
open_question: "Offene Frage",
};
/** System-Prompt-Block mit den wichtigsten Ankern, Token-Budget über maxChars. */
export function anchorContextBlock(
sessionId: string,
maxChars = 1200,
): string | null {
const anchors = listAnchors(sessionId).slice(0, 20);
const lines: string[] = [];
let chars = 0;
for (const a of anchors) {
const line = `- [${KIND_LABEL[a.kind] ?? a.kind}] ${a.text}`;
if (chars + line.length > maxChars) break;
lines.push(line);
chars += line.length;
}
if (lines.length === 0) return null;
return `Bekannte Ankerpunkte aus diesem und früheren Gesprächen (Gedächtnis — als Kontext nutzen, nicht wörtlich zitieren):\n${lines.join("\n")}`;
}
export function queryAnchors(
sessionId: string,
query: string,
limit = 12,
): AnchorRow[] {
const anchors = listAnchors(sessionId);
const q = query.trim();
if (!q) return anchors.slice(0, limit);
return anchors
.map((a) => ({ a, score: similarity(a.text, q) }))
.filter(
({ a, score }) =>
score >= 0.15 || a.text.toLowerCase().includes(q.toLowerCase()),
)
.sort((x, y) => y.score - x.score)
.slice(0, limit)
.map(({ a }) => a);
}
export function setAnchorPinned(id: number, pinned: boolean): boolean {
const res = db
.prepare("UPDATE anchors SET pinned = ?, updated_at = ? WHERE id = ?")
.run(pinned ? 1 : 0, Date.now(), id);
return Number(res.changes) > 0;
}
export function deleteAnchor(id: number): boolean {
const res = db.prepare("DELETE FROM anchors WHERE id = ?").run(id);
return Number(res.changes) > 0;
}
export function deleteSessionMemory(sessionId: string): void {
db.prepare("DELETE FROM memory_events WHERE session_id = ?").run(sessionId);
db.prepare("DELETE FROM memory_state WHERE session_id = ?").run(sessionId);
db.prepare("DELETE FROM anchors WHERE session_id = ?").run(sessionId);
}

View file

@ -12,6 +12,8 @@ export interface OllamaOptions {
numPredict: number;
/** Werkzeuge mitschicken. Aus, wenn der Nutzer sie abgeschaltet hat. */
tools?: boolean;
/** Sitzung des Chats — für Gedächtnis-Werkzeuge (remember/recall). */
sessionId?: string;
}
export interface StreamCallbacks {
@ -198,7 +200,7 @@ export async function streamChat(
for (const call of toolCalls) {
cb.onToolCall?.(call.name, call.arguments);
const result = await runTool(call, signal);
const result = await runTool(call, { signal, sessionId: opts.sessionId });
cb.onToolResult?.(result.name, result.ok, result.durationMs);
messages.push({ role: "tool", tool_name: result.name, content: result.content });
}

View file

@ -1,7 +1,9 @@
import { timeTool } from "./time.js";
import { calculateTool } from "./calculate.js";
import { readFileTool, listFilesTool } from "./files.js";
import type { Tool, ToolCall, ToolResult } from "./types.js";
import { rememberTool } from "./remember.js";
import { recallTool } from "./recall.js";
import type { Tool, ToolCall, ToolContext, ToolResult } from "./types.js";
import { ToolError } from "./types.js";
export type { Tool, ToolCall, ToolResult } from "./types.js";
@ -12,7 +14,14 @@ const TOOL_TIMEOUT_MS = 15_000;
/** Obergrenze für Werkzeug-Runden pro Nachricht — verhindert Endlosschleifen. */
export const MAX_TOOL_ROUNDS = 5;
const REGISTRY: Tool[] = [timeTool, calculateTool, readFileTool, listFilesTool];
const REGISTRY: Tool[] = [
timeTool,
calculateTool,
readFileTool,
listFilesTool,
rememberTool,
recallTool,
];
const BY_NAME = new Map(REGISTRY.map((t) => [t.name, t]));
@ -50,7 +59,7 @@ function withTimeout<T>(p: Promise<T>, ms: number, name: string): Promise<T> {
* Ergebnis zurückgegeben das Modell soll erfahren, was schiefging, und
* darauf reagieren können, statt dass die ganze Antwort abbricht.
*/
export async function runTool(call: ToolCall, signal: AbortSignal): Promise<ToolResult> {
export async function runTool(call: ToolCall, ctx: ToolContext): Promise<ToolResult> {
const started = Date.now();
const tool = BY_NAME.get(call.name);
@ -64,7 +73,11 @@ export async function runTool(call: ToolCall, signal: AbortSignal): Promise<Tool
}
try {
const value = await withTimeout(tool.run(call.arguments, { signal }), TOOL_TIMEOUT_MS, tool.name);
const value = await withTimeout(
tool.run(call.arguments, ctx),
TOOL_TIMEOUT_MS,
tool.name,
);
return {
name: tool.name,
content: JSON.stringify(value),

View file

@ -0,0 +1,31 @@
import { queryAnchors } from "../memory.js";
import { ToolError } from "./types.js";
import type { Tool } from "./types.js";
export const recallTool: Tool = {
name: "recall",
description:
"Durchsucht das Gedächtnis (Ankerpunkte) des aktuellen Chats, z. B. um frühere Fakten, Entscheidungen oder Präferenzen abzurufen.",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "Optionaler Suchbegriff; ohne Query kommen die wichtigsten Ankerpunkte.",
},
},
},
async run(args, ctx) {
if (!ctx.sessionId) throw new ToolError("Keine Sitzung für das Gedächtnis bekannt");
const anchors = queryAnchors(ctx.sessionId, String(args.query ?? ""));
return {
count: anchors.length,
anchors: anchors.map((a) => ({
text: a.text,
kind: a.kind,
importance: a.importance,
hits: a.hits,
})),
};
},
};

View file

@ -0,0 +1,41 @@
import { upsertAnchor } from "../memory.js";
import { ANCHOR_KINDS, type AnchorKind } from "../memory-text.js";
import { ToolError } from "./types.js";
import type { Tool } from "./types.js";
export const rememberTool: Tool = {
name: "remember",
description:
"Speichert einen dauerhaft wichtigen Punkt als Ankerpunkt im Gedächtnis des aktuellen Chats. Nur für Fakten, Entscheidungen, Präferenzen oder offene Punkte — nicht für flüchtige Inhalte.",
requiresConfirmation: true,
parameters: {
type: "object",
properties: {
text: {
type: "string",
description: "Der zu merkende Punkt als ein prägnanter Satz.",
},
kind: {
type: "string",
enum: [...ANCHOR_KINDS],
description: "Art des Ankerpunkts (Default: fact).",
},
},
required: ["text"],
},
async run(args, ctx) {
const text = String(args.text ?? "").trim();
if (!ctx.sessionId) throw new ToolError("Keine Sitzung für das Gedächtnis bekannt");
if (text.length < 6) throw new ToolError("Text ist zu kurz, um ihn zu merken");
const result = upsertAnchor(ctx.sessionId, {
text,
kind: ANCHOR_KINDS.includes(args.kind as AnchorKind)
? (args.kind as AnchorKind)
: "fact",
importance: 0.9,
origin: "model",
pinned: true,
});
return { stored: result, text };
},
};

View file

@ -8,6 +8,8 @@ export interface ToolSchema {
export interface ToolContext {
/** Bricht die Ausführung ab, wenn der Nutzer die Antwort stoppt. */
signal: AbortSignal;
/** Sitzung des aktuellen Chats — für Werkzeuge mit Gedächtniszugriff. */
sessionId?: string;
}
export interface Tool {

View file

@ -8,5 +8,5 @@
"noEmit": true,
"types": ["node"]
},
"include": ["src"]
"include": ["src", "scripts"]
}

File diff suppressed because one or more lines are too long

74
web/dist/assets/index-BvIjR1bZ.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

7
web/dist/favicon.svg vendored Normal file
View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="agenttwo-tools">
<rect width="32" height="32" rx="7" fill="#0a0f0c"/>
<rect x="0.75" y="0.75" width="30.5" height="30.5" rx="6.25" fill="none" stroke="#1e2c23" stroke-width="1.5"/>
<path d="M8 10.5 L13.5 16 L8 21.5" fill="none" stroke="#3ddc84" stroke-width="3.2"
stroke-linecap="round" stroke-linejoin="round"/>
<rect x="16.5" y="19" width="8.5" height="3" rx="1.5" fill="#ffb454"/>
</svg>

After

Width:  |  Height:  |  Size: 490 B

6
web/dist/index.html vendored
View file

@ -3,9 +3,11 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="theme-color" content="#0a0f0c" />
<title>agenttwo-tools — qwen3 mit Vision</title>
<script type="module" crossorigin src="/assets/index-BNj3u6UE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BvIjEji7.css">
<script type="module" crossorigin src="/assets/index-BvIjR1bZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CKaNlN-w.css">
</head>
<body>
<div id="root"></div>

View file

@ -4,6 +4,7 @@ import { useVoice } from "./useVoice";
import { Sidebar } from "./components/Sidebar";
import { ChatMessage } from "./components/ChatMessage";
import { Composer } from "./components/Composer";
import { MemoryPanel } from "./components/MemoryPanel";
import type { OpenRouterModel } from "./types";
const VOICE_KEY = "oxagenttwo.voiceMode";
@ -12,6 +13,7 @@ export default function App() {
const chat = useChat();
const scrollRef = useRef<HTMLDivElement>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [memoryOpen, setMemoryOpen] = useState(false);
const [injectedText, setInjectedText] = useState<string | null>(null);
const [voiceMode, setVoiceModeState] = useState(
() => localStorage.getItem(VOICE_KEY) === "1",
@ -209,6 +211,14 @@ export default function App() {
>
{voiceMode ? "🔊 Stimme: an" : "🔇 Stimme: aus"}
</button>
<button
className="btn-settings"
onClick={() => setMemoryOpen((v) => !v)}
disabled={!chat.activeId}
title="Gedächtnis: Ankerpunkte, Traumphase, Log-Rekonstruktion"
>
🧠 Gedächtnis
</button>
<button
className="btn-settings"
onClick={() => setSettingsOpen((v) => !v)}
@ -299,6 +309,42 @@ export default function App() {
}
/>
</label>
<label className="setting-row">
<span>Gedächtnis-Fenster: {chat.options.memorySteps} Schritte</span>
<input
type="range"
min={2}
max={50}
step={1}
value={chat.options.memorySteps}
onChange={(e) =>
chat.setOptions({ memorySteps: Number(e.target.value) })
}
/>
</label>
<label className="setting-row checkbox">
<input
type="checkbox"
checked={chat.options.memoryAnchors}
onChange={(e) =>
chat.setOptions({ memoryAnchors: e.target.checked })
}
/>
<span>
Ankerpunkte ins Modell einspielen (Gedächtnis als System-Kontext)
</span>
</label>
<label className="setting-row checkbox">
<input
type="checkbox"
checked={chat.options.dreamAuto}
onChange={(e) => chat.setOptions({ dreamAuto: e.target.checked })}
/>
<span>
Auto-Traumphase (Konsolidierung nach 3 Min Inaktivität oder 10
Schritten)
</span>
</label>
<label className="setting-row column">
<span>System-Prompt</span>
<textarea
@ -311,6 +357,13 @@ export default function App() {
</section>
)}
{memoryOpen && chat.activeId && (
<MemoryPanel
sessionId={chat.activeId}
onClose={() => setMemoryOpen(false)}
/>
)}
<div className="messages" ref={scrollRef}>
{chat.messages.length === 0 && (
<div className="welcome">

View file

@ -0,0 +1,146 @@
import { useCallback, useEffect, useState } from "react";
import type { Anchor, MemoryInfo } from "../types";
const KIND_LABEL: Record<string, string> = {
fact: "Fakt",
decision: "Entscheidung",
preference: "Präferenz",
entity: "Entität",
open_question: "Offen",
};
export function MemoryPanel({
sessionId,
onClose,
}: {
sessionId: string;
onClose: () => void;
}) {
const [info, setInfo] = useState<MemoryInfo | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const res = await fetch(`/api/sessions/${sessionId}/memory`);
const d = (await res.json()) as MemoryInfo;
if (d.state) setInfo(d);
} catch {
setInfo(null);
}
}, [sessionId]);
useEffect(() => {
void load();
}, [load]);
const dreamNow = async () => {
setBusy("dream");
try {
await fetch(`/api/sessions/${sessionId}/dream`, { method: "POST" });
await load();
} finally {
setBusy(null);
}
};
const rebuild = async () => {
setBusy("rebuild");
try {
await fetch(`/api/sessions/${sessionId}/memory/rebuild`, { method: "POST" });
await load();
} finally {
setBusy(null);
}
};
const togglePin = async (a: Anchor) => {
await fetch(`/api/anchors/${a.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pinned: !a.pinned }),
});
await load();
};
const remove = async (a: Anchor) => {
await fetch(`/api/anchors/${a.id}`, { method: "DELETE" });
await load();
};
const pending = info ? info.state.last_seq - info.state.last_dream_seq : 0;
return (
<div className="memory-overlay" onClick={onClose}>
<section className="memory-panel" onClick={(e) => e.stopPropagation()}>
<header className="memory-header">
<div>
<div className="memory-title">🧠 Gedächtnis</div>
<div className="memory-stats">
{info
? `${info.state.last_seq} Log-Schritte · ${info.state.dream_count}× geträumt · ${pending} unkonsolidiert`
: "lädt …"}
</div>
</div>
<button className="memory-close" onClick={onClose} title="Schließen">
</button>
</header>
<div className="memory-actions">
<button onClick={() => void dreamNow()} disabled={busy !== null}>
{busy === "dream" ? "… träumt" : "💤 Traumphase jetzt"}
</button>
<button onClick={() => void rebuild()} disabled={busy !== null}>
{busy === "rebuild" ? "… faltet" : "↻ Aus Log rekonstruieren"}
</button>
</div>
{info && info.anchors.length === 0 && (
<div className="memory-empty">
Noch keine Ankerpunkte. Sie entstehen durch die Traumphase (automatisch
nach Inaktivität oder Manual oben) oder wenn das Modell sich etwas
mit remember merkt.
</div>
)}
{info?.anchors.map((a) => (
<div
key={a.id}
className={"anchor-row" + (a.pinned ? " pinned" : "")}
>
<div className="anchor-body">
<div className="anchor-kind">
{KIND_LABEL[a.kind] ?? a.kind} · {a.origin}
</div>
<div className="anchor-text">{a.text}</div>
<div className="anchor-meta">
Wichtigkeit {(a.importance * 100).toFixed(0)} % · {a.hits}×
bestätigt
</div>
<div className="anchor-bar">
<div
className="anchor-bar-fill"
style={{ width: `${Math.round(a.importance * 100)}%` }}
/>
</div>
</div>
<div className="anchor-actions">
<button
onClick={() => void togglePin(a)}
title={a.pinned ? "Pin lösen (Decay möglich)" : "Pinnen (vor Decay geschützt)"}
>
{a.pinned ? "★" : "☆"}
</button>
<button
onClick={() => void remove(a)}
title="Ankerpunkt löschen"
>
</button>
</div>
</div>
))}
</section>
</div>
);
}

View file

@ -674,3 +674,162 @@ body {
opacity: 0.6;
font-variant-numeric: tabular-nums;
}
/* ---------- Gedächtnis ---------- */
.memory-overlay {
position: fixed;
inset: 0;
background: rgba(4, 8, 6, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.memory-panel {
width: min(660px, 92vw);
max-height: 80vh;
overflow-y: auto;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 10px;
}
.memory-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.memory-title {
color: var(--accent);
font-weight: 700;
letter-spacing: 0.03em;
}
.memory-stats {
color: var(--text-dim);
font-size: 12px;
margin-top: 2px;
}
.memory-close {
background: transparent;
border: none;
color: var(--text-dim);
font-size: 15px;
cursor: pointer;
}
.memory-close:hover {
color: var(--text);
}
.memory-actions {
display: flex;
gap: 8px;
}
.memory-actions button {
font-family: inherit;
font-size: 12px;
background: transparent;
color: var(--accent);
border: 1px solid var(--accent-dim);
border-radius: 6px;
padding: 5px 10px;
cursor: pointer;
}
.memory-actions button:hover:not(:disabled) {
background: var(--user-bg);
}
.memory-actions button:disabled {
opacity: 0.5;
cursor: default;
}
.memory-empty {
color: var(--text-dim);
font-size: 13px;
padding: 14px;
text-align: center;
border: 1px dashed var(--border);
border-radius: 8px;
}
.anchor-row {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 9px 12px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 8px;
}
.anchor-row.pinned {
border-color: var(--accent-dim);
}
.anchor-body {
flex: 1;
min-width: 0;
}
.anchor-kind {
font-size: 11px;
color: var(--accent-warm);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.anchor-text {
font-size: 13px;
margin-top: 3px;
overflow-wrap: anywhere;
}
.anchor-meta {
font-size: 11px;
color: var(--text-dim);
margin-top: 4px;
}
.anchor-bar {
height: 3px;
background: var(--border);
border-radius: 2px;
margin-top: 6px;
overflow: hidden;
}
.anchor-bar-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
}
.anchor-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
}
.anchor-actions button {
background: transparent;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 14px;
padding: 2px 4px;
}
.anchor-actions button:hover {
color: var(--accent);
}

View file

@ -30,6 +30,44 @@ export interface ChatOptions {
numPredict: number;
provider: "ollama" | "openrouter";
openrouterModel: string;
memorySteps: number;
memoryAnchors: boolean;
dreamAuto: boolean;
}
export interface Anchor {
id: number;
session_id: string;
text: string;
kind: string;
importance: number;
hits: number;
pinned: number;
origin: string;
last_seq: number;
created_at: number;
updated_at: number;
}
export interface MemoryState {
last_seq: number;
last_dream_seq: number;
dream_count: number;
}
export interface DreamResult {
ok: boolean;
events: number;
inserted: number;
merged: number;
pruned: number;
source: string;
error?: string;
}
export interface MemoryInfo {
state: MemoryState;
anchors: Anchor[];
}
export interface OpenRouterModel {

View file

@ -24,6 +24,9 @@ function loadOptions(): ChatOptions {
numPredict: 2048,
provider: "ollama",
openrouterModel: "anthropic/claude-sonnet-4.5",
memorySteps: 10,
memoryAnchors: true,
dreamAuto: true,
};
try {
const raw = localStorage.getItem(OPTIONS_KEY);