mirror of
https://github.com/Jeuners/agenttwo-tools.git
synced 2026-09-09 15:02:31 +02:00
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:
parent
e6d55f35a3
commit
9bce768f95
22 changed files with 1388 additions and 100 deletions
47
README.md
47
README.md
|
|
@ -129,9 +129,56 @@ sich das in den Einstellungen.
|
||||||
| `calculate` | Arithmetik mit eigenem Parser |
|
| `calculate` | Arithmetik mit eigenem Parser |
|
||||||
| `read_file` | Textdatei unterhalb des Projektverzeichnisses lesen |
|
| `read_file` | Textdatei unterhalb des Projektverzeichnisses lesen |
|
||||||
| `list_files` | Verzeichnis auflisten |
|
| `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`
|
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 (2–100 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 (0–1), 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
|
### Grenzen
|
||||||
|
|
||||||
Alle Werkzeuge sind **ausschließlich lesend**. Es gibt nichts, was schreibt,
|
Alle Werkzeuge sind **ausschließlich lesend**. Es gibt nichts, was schreibt,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"start": "tsx src/index.ts",
|
"start": "tsx src/index.ts",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"memory:eval": "tsx scripts/memory-eval.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^10.0.1",
|
"@fastify/cors": "^10.0.1",
|
||||||
|
|
|
||||||
95
server/scripts/memory-eval.ts
Normal file
95
server/scripts/memory-eval.ts
Normal 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.1–1 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);
|
||||||
|
|
@ -5,6 +5,7 @@ import type { IncomingMessage } from "node:http";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import * as dbmod from "./db.js";
|
import * as dbmod from "./db.js";
|
||||||
|
import * as mem from "./memory.js";
|
||||||
import { streamChat, type OllamaOptions } from "./ollama.js";
|
import { streamChat, type OllamaOptions } from "./ollama.js";
|
||||||
import { transcribeAudio, synthesizeSpeech, MAX_AUDIO_BYTES } from "./voice.js";
|
import { transcribeAudio, synthesizeSpeech, MAX_AUDIO_BYTES } from "./voice.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -31,6 +32,38 @@ try {
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT ?? 8788);
|
const PORT = Number(process.env.PORT ?? 8788);
|
||||||
const OLLAMA_URL = process.env.OLLAMA_URL ?? "http://localhost:11434";
|
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 });
|
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`, {
|
const res = await fetch(`${OLLAMA_URL}/api/show`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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}` };
|
if (!res.ok) return { ok: false, error: `Ollama HTTP ${res.status}` };
|
||||||
const data = (await res.json()) as {
|
const data = (await res.json()) as {
|
||||||
|
|
@ -73,7 +106,7 @@ app.get("/api/model", async () => {
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
model: process.env.MODEL ?? "qwen3.5:latest",
|
model: MODEL,
|
||||||
parameterSize: data.details?.parameter_size,
|
parameterSize: data.details?.parameter_size,
|
||||||
quantization: data.details?.quantization_level,
|
quantization: data.details?.quantization_level,
|
||||||
capabilities: data.capabilities ?? [],
|
capabilities: data.capabilities ?? [],
|
||||||
|
|
@ -94,11 +127,56 @@ app.get("/api/sessions/:id/messages", async (req, reply) => {
|
||||||
});
|
});
|
||||||
app.delete("/api/sessions/:id", async (req) => {
|
app.delete("/api/sessions/:id", async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
|
clearTimeout(dreamTimers.get(id));
|
||||||
|
dreamTimers.delete(id);
|
||||||
dbmod.deleteSession(id);
|
dbmod.deleteSession(id);
|
||||||
|
mem.deleteSessionMemory(id);
|
||||||
broadcast({ type: "session-deleted", id });
|
broadcast({ type: "session-deleted", id });
|
||||||
return { ok: true };
|
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 ---
|
// --- Voice ---
|
||||||
app.post("/api/stt", async (req, reply) => {
|
app.post("/api/stt", async (req, reply) => {
|
||||||
if (!sttLimiter(req.ip)) {
|
if (!sttLimiter(req.ip)) {
|
||||||
|
|
@ -165,15 +243,21 @@ interface ChatOptionsPayload {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
openrouterModel?: string;
|
openrouterModel?: string;
|
||||||
tools?: boolean;
|
tools?: boolean;
|
||||||
|
memorySteps?: number;
|
||||||
|
memoryAnchors?: boolean;
|
||||||
|
dreamAuto?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseOptions(raw: unknown): OllamaOptions & {
|
function parseOptions(raw: unknown): OllamaOptions & {
|
||||||
provider: "ollama" | "openrouter";
|
provider: "ollama" | "openrouter";
|
||||||
openrouterModel: string;
|
openrouterModel: string;
|
||||||
|
memorySteps: number;
|
||||||
|
memoryAnchors: boolean;
|
||||||
|
dreamAuto: boolean;
|
||||||
} {
|
} {
|
||||||
const o = (raw ?? {}) as Partial<ChatOptionsPayload>;
|
const o = (raw ?? {}) as Partial<ChatOptionsPayload>;
|
||||||
return {
|
return {
|
||||||
model: process.env.MODEL ?? "qwen3.5:latest",
|
model: MODEL,
|
||||||
think: o.think !== false,
|
think: o.think !== false,
|
||||||
tools: o.tools !== false,
|
tools: o.tools !== false,
|
||||||
temperature: clamp(Number(o.temperature ?? 0.7), 0, 2),
|
temperature: clamp(Number(o.temperature ?? 0.7), 0, 2),
|
||||||
|
|
@ -184,6 +268,9 @@ function parseOptions(raw: unknown): OllamaOptions & {
|
||||||
/^[\w./:-]{3,120}$/.test(o.openrouterModel)
|
/^[\w./:-]{3,120}$/.test(o.openrouterModel)
|
||||||
? o.openrouterModel
|
? o.openrouterModel
|
||||||
: "anthropic/claude-sonnet-4.5",
|
: "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");
|
dbmod.renameSessionIfDefault(session.id, content || "Bild");
|
||||||
|
|
||||||
const userMsg = dbmod.insertMessage(session.id, "user", content, null, images);
|
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 }));
|
socket.send(JSON.stringify({ type: "user-message", message: userMsg }));
|
||||||
broadcast({ type: "sessions-changed" });
|
broadcast({ type: "sessions-changed" });
|
||||||
|
|
||||||
const opts = parseOptions(msg.options);
|
const opts = parseOptions(msg.options);
|
||||||
|
opts.sessionId = session.id;
|
||||||
const history = dbmod
|
const history = dbmod
|
||||||
.listMessages(session.id)
|
.listMessages(session.id)
|
||||||
.slice(-24)
|
.slice(-opts.memorySteps)
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
const imgs = dbmod.parseImages(m);
|
const imgs = dbmod.parseImages(m);
|
||||||
return imgs.length
|
return imgs.length
|
||||||
|
|
@ -260,6 +353,15 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
||||||
: { role: m.role, content: m.content };
|
: { 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", "");
|
const assistantRow = dbmod.insertMessage(session.id, "assistant", "");
|
||||||
socket.send(JSON.stringify({ type: "assistant-start", message: assistantRow }));
|
socket.send(JSON.stringify({ type: "assistant-start", message: assistantRow }));
|
||||||
|
|
||||||
|
|
@ -281,6 +383,12 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
||||||
},
|
},
|
||||||
onDone() {},
|
onDone() {},
|
||||||
onToolCall(name: string, args: Record<string, unknown>) {
|
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(
|
socket.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "tool-call",
|
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)");
|
if (!apiKey) throw new Error("OPENROUTER_API_KEY nicht gesetzt (.env fehlt)");
|
||||||
await streamOpenRouter(
|
await streamOpenRouter(
|
||||||
history,
|
history,
|
||||||
typeof msg.systemPrompt === "string" && msg.systemPrompt.trim()
|
system,
|
||||||
? (msg.systemPrompt as string)
|
|
||||||
: undefined,
|
|
||||||
{
|
{
|
||||||
model: opts.openrouterModel,
|
model: opts.openrouterModel,
|
||||||
temperature: opts.temperature,
|
temperature: opts.temperature,
|
||||||
|
|
@ -318,17 +424,13 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
||||||
activeAbort.signal,
|
activeAbort.signal,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await streamChat(
|
await streamChat(history, system, opts, callbacks, activeAbort.signal);
|
||||||
history,
|
|
||||||
typeof msg.systemPrompt === "string" && msg.systemPrompt.trim()
|
|
||||||
? (msg.systemPrompt as string)
|
|
||||||
: undefined,
|
|
||||||
opts,
|
|
||||||
callbacks,
|
|
||||||
activeAbort.signal,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
dbmod.updateAssistantMessage(assistantRow.id, full.trim(), thinking.trim() || null);
|
dbmod.updateAssistantMessage(assistantRow.id, full.trim(), thinking.trim() || null);
|
||||||
|
mem.appendEvent(session.id, "message", {
|
||||||
|
role: "assistant",
|
||||||
|
content: full.trim().slice(0, 4000),
|
||||||
|
});
|
||||||
socket.send(
|
socket.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "done",
|
type: "done",
|
||||||
|
|
@ -337,6 +439,14 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
broadcast({ type: "sessions-changed" });
|
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) {
|
} catch (err) {
|
||||||
const aborted =
|
const aborted =
|
||||||
activeAbort.signal.aborted ||
|
activeAbort.signal.aborted ||
|
||||||
|
|
|
||||||
133
server/src/memory-text.ts
Normal file
133
server/src/memory-text.ts
Normal 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
405
server/src/memory.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,8 @@ export interface OllamaOptions {
|
||||||
numPredict: number;
|
numPredict: number;
|
||||||
/** Werkzeuge mitschicken. Aus, wenn der Nutzer sie abgeschaltet hat. */
|
/** Werkzeuge mitschicken. Aus, wenn der Nutzer sie abgeschaltet hat. */
|
||||||
tools?: boolean;
|
tools?: boolean;
|
||||||
|
/** Sitzung des Chats — für Gedächtnis-Werkzeuge (remember/recall). */
|
||||||
|
sessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamCallbacks {
|
export interface StreamCallbacks {
|
||||||
|
|
@ -198,7 +200,7 @@ export async function streamChat(
|
||||||
|
|
||||||
for (const call of toolCalls) {
|
for (const call of toolCalls) {
|
||||||
cb.onToolCall?.(call.name, call.arguments);
|
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);
|
cb.onToolResult?.(result.name, result.ok, result.durationMs);
|
||||||
messages.push({ role: "tool", tool_name: result.name, content: result.content });
|
messages.push({ role: "tool", tool_name: result.name, content: result.content });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { timeTool } from "./time.js";
|
import { timeTool } from "./time.js";
|
||||||
import { calculateTool } from "./calculate.js";
|
import { calculateTool } from "./calculate.js";
|
||||||
import { readFileTool, listFilesTool } from "./files.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";
|
import { ToolError } from "./types.js";
|
||||||
|
|
||||||
export type { Tool, ToolCall, ToolResult } 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. */
|
/** Obergrenze für Werkzeug-Runden pro Nachricht — verhindert Endlosschleifen. */
|
||||||
export const MAX_TOOL_ROUNDS = 5;
|
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]));
|
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
|
* Ergebnis zurückgegeben — das Modell soll erfahren, was schiefging, und
|
||||||
* darauf reagieren können, statt dass die ganze Antwort abbricht.
|
* 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 started = Date.now();
|
||||||
const tool = BY_NAME.get(call.name);
|
const tool = BY_NAME.get(call.name);
|
||||||
|
|
||||||
|
|
@ -64,7 +73,11 @@ export async function runTool(call: ToolCall, signal: AbortSignal): Promise<Tool
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 {
|
return {
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
content: JSON.stringify(value),
|
content: JSON.stringify(value),
|
||||||
|
|
|
||||||
31
server/src/tools/recall.ts
Normal file
31
server/src/tools/recall.ts
Normal 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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
41
server/src/tools/remember.ts
Normal file
41
server/src/tools/remember.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -8,6 +8,8 @@ export interface ToolSchema {
|
||||||
export interface ToolContext {
|
export interface ToolContext {
|
||||||
/** Bricht die Ausführung ab, wenn der Nutzer die Antwort stoppt. */
|
/** Bricht die Ausführung ab, wenn der Nutzer die Antwort stoppt. */
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
|
/** Sitzung des aktuellen Chats — für Werkzeuge mit Gedächtniszugriff. */
|
||||||
|
sessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Tool {
|
export interface Tool {
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,5 @@
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "scripts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
74
web/dist/assets/index-BNj3u6UE.js
vendored
74
web/dist/assets/index-BNj3u6UE.js
vendored
File diff suppressed because one or more lines are too long
74
web/dist/assets/index-BvIjR1bZ.js
vendored
Normal file
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
7
web/dist/favicon.svg
vendored
Normal 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
6
web/dist/index.html
vendored
|
|
@ -3,9 +3,11 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
<title>agenttwo-tools — qwen3 mit Vision</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BNj3u6UE.js"></script>
|
<script type="module" crossorigin src="/assets/index-BvIjR1bZ.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BvIjEji7.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CKaNlN-w.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useVoice } from "./useVoice";
|
||||||
import { Sidebar } from "./components/Sidebar";
|
import { Sidebar } from "./components/Sidebar";
|
||||||
import { ChatMessage } from "./components/ChatMessage";
|
import { ChatMessage } from "./components/ChatMessage";
|
||||||
import { Composer } from "./components/Composer";
|
import { Composer } from "./components/Composer";
|
||||||
|
import { MemoryPanel } from "./components/MemoryPanel";
|
||||||
import type { OpenRouterModel } from "./types";
|
import type { OpenRouterModel } from "./types";
|
||||||
|
|
||||||
const VOICE_KEY = "oxagenttwo.voiceMode";
|
const VOICE_KEY = "oxagenttwo.voiceMode";
|
||||||
|
|
@ -12,6 +13,7 @@ export default function App() {
|
||||||
const chat = useChat();
|
const chat = useChat();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||||
const [injectedText, setInjectedText] = useState<string | null>(null);
|
const [injectedText, setInjectedText] = useState<string | null>(null);
|
||||||
const [voiceMode, setVoiceModeState] = useState(
|
const [voiceMode, setVoiceModeState] = useState(
|
||||||
() => localStorage.getItem(VOICE_KEY) === "1",
|
() => localStorage.getItem(VOICE_KEY) === "1",
|
||||||
|
|
@ -209,6 +211,14 @@ export default function App() {
|
||||||
>
|
>
|
||||||
{voiceMode ? "🔊 Stimme: an" : "🔇 Stimme: aus"}
|
{voiceMode ? "🔊 Stimme: an" : "🔇 Stimme: aus"}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-settings"
|
||||||
|
onClick={() => setMemoryOpen((v) => !v)}
|
||||||
|
disabled={!chat.activeId}
|
||||||
|
title="Gedächtnis: Ankerpunkte, Traumphase, Log-Rekonstruktion"
|
||||||
|
>
|
||||||
|
🧠 Gedächtnis
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn-settings"
|
className="btn-settings"
|
||||||
onClick={() => setSettingsOpen((v) => !v)}
|
onClick={() => setSettingsOpen((v) => !v)}
|
||||||
|
|
@ -299,6 +309,42 @@ export default function App() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<label className="setting-row column">
|
||||||
<span>System-Prompt</span>
|
<span>System-Prompt</span>
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -311,6 +357,13 @@ export default function App() {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{memoryOpen && chat.activeId && (
|
||||||
|
<MemoryPanel
|
||||||
|
sessionId={chat.activeId}
|
||||||
|
onClose={() => setMemoryOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="messages" ref={scrollRef}>
|
<div className="messages" ref={scrollRef}>
|
||||||
{chat.messages.length === 0 && (
|
{chat.messages.length === 0 && (
|
||||||
<div className="welcome">
|
<div className="welcome">
|
||||||
|
|
|
||||||
146
web/src/components/MemoryPanel.tsx
Normal file
146
web/src/components/MemoryPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -674,3 +674,162 @@ body {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
font-variant-numeric: tabular-nums;
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,44 @@ export interface ChatOptions {
|
||||||
numPredict: number;
|
numPredict: number;
|
||||||
provider: "ollama" | "openrouter";
|
provider: "ollama" | "openrouter";
|
||||||
openrouterModel: string;
|
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 {
|
export interface OpenRouterModel {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ function loadOptions(): ChatOptions {
|
||||||
numPredict: 2048,
|
numPredict: 2048,
|
||||||
provider: "ollama",
|
provider: "ollama",
|
||||||
openrouterModel: "anthropic/claude-sonnet-4.5",
|
openrouterModel: "anthropic/claude-sonnet-4.5",
|
||||||
|
memorySteps: 10,
|
||||||
|
memoryAnchors: true,
|
||||||
|
dreamAuto: true,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(OPTIONS_KEY);
|
const raw = localStorage.getItem(OPTIONS_KEY);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue