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

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);