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 |
|
||||
| `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 (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
|
||||
|
||||
Alle Werkzeuge sind **ausschließlich lesend**. Es gibt nichts, was schreibt,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
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 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
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;
|
||||
/** 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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
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 {
|
||||
/** 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 {
|
||||
|
|
|
|||
|
|
@ -8,5 +8,5 @@
|
|||
"noEmit": true,
|
||||
"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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
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;
|
||||
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;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue