diff --git a/README.md b/README.md index 9cad6b98..9d1ce66c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ JIN Core Engine is a local AI orchestration runtime for OpenAI-compatible model The engine is designed for multi-runtime local AI setups where the main reasoning model, service model, and translation model can run as separate providers while sharing one coherent room-like chat surface. -![JIN Core Engine runtime UI](static/jin-core-runtime-memory.png) +![JIN Core Engine runtime UI](static/images/jin-core-history-highlight.png) ## Capabilities @@ -27,6 +27,9 @@ The engine is designed for multi-runtime local AI setups where the main reasonin - Runtime telemetry for model IDs, context windows, token usage, provider status, and runtime errors. - Live runtime memory: a compact in-RAM state updated by the service model after each completed turn. - Runtime memory panel in the right sidebar, showing the current memory state without XML tags. +- Runtime memory snapshots with arrow navigation for reviewing how the session state evolved. +- Runtime memory diff highlighting for new keys, changed keys, new values, and changed values. +- Memory update animation: the settings panel glows during summarization and changed memory lines briefly flash before settling back into a unified state. - Interrupted turn memory handling: aborted or incomplete responses are marked as unresolved state. - Stream validation for repeated word loops, repeated sentences, repeated paragraphs, and leading HTML artifacts. - Abort support that cancels the active task, closes active provider streams, and records interrupted memory. @@ -80,6 +83,8 @@ The brain can emit runtime action markers. The runtime consumes those markers as After the visible response ends, the service runtime updates `context.runtime_memory` in the background. This request does not block the user-facing answer. The next brain prompt receives the current memory as trusted runtime context, and the right sidebar shows the same memory as plain text. +Each memory update is also stored as a per-session snapshot. The UI can step backward and forward through those snapshots, replaying lightweight diff highlights so the user can see which memory keys or values were added or changed during the conversation. + If generation is aborted, the runtime captures the partial answer and schedules an interrupted memory update. The memory summarizer is instructed to mark the turn as incomplete and not treat it as resolved. ## Runtime Memory @@ -91,6 +96,8 @@ Runtime memory is intentionally lightweight in the current MVP: - It is written as compact, actionable bullet-like state rather than full transcript history. - It is injected into the brain prompt inside ``. - It is mirrored in the right sidebar through `runtime_memory_update` WebSocket events. +- Each update is captured as a session snapshot with an index, raw memory text, parsed key/value lines, and diff metadata. +- The UI can navigate previous snapshots and replay visual highlights for new or changed memory fields. - Truncated or obviously incomplete summarizer output is rejected so it does not overwrite the previous memory. This gives JIN short-term continuity without introducing persistence, vector storage, or retrieval infrastructure yet. @@ -305,7 +312,24 @@ Runtime memory update: { "type": "runtime_memory_update", "memory": "- active topic: feature testing\n- user intent: testing runtime behavior", - "updates": 6 + "updates": 6, + "snapshot_index": 2, + "snapshots_count": 3, + "snapshot": { + "session_id": "...", + "index": 2, + "raw_memory": "active topic: feature testing\nuser intent: testing runtime behavior", + "lines": [ + { + "key": "active topic", + "value": "feature testing", + "key_status": "same", + "value_status": "changed", + "key_change_ratio": 0.0, + "value_change_ratio": 0.42 + } + ] + } } ``` @@ -317,7 +341,7 @@ The UI is served directly by FastAPI: - `static/socket.js` handles WebSocket connection, send, abort, and stream events. - `static/chat.js` renders normal and streaming messages. - `static/status.js` updates provider online/offline indicators. -- `static/telemetry.js` updates runtime status, context usage, and live runtime memory. +- `static/telemetry.js` updates runtime status, context usage, runtime memory snapshots, and memory diff highlighting. - `static/logger.js` renders the runtime console. - `static/dragdrop.js` handles attachment UI state. diff --git a/clients/brain_client.py b/clients/brain_client.py index 9d1688ed..e1d4e4fd 100644 --- a/clients/brain_client.py +++ b/clients/brain_client.py @@ -482,6 +482,17 @@ def build_brain_runtime_context( .to_runtime_xml() ) + session_state_xml = "" + + if context is not None: + session_state_xml = ( + "\n" + f" {getattr(context, 'turn_number', 0)}\n" + f" {getattr(context, 'user_message_count', 0)}\n" + f" {getattr(context, 'assistant_message_count', 0)}\n" + "" + ) + runtime_memory = "" search_result = "" search_result_id = "" @@ -507,6 +518,11 @@ def build_brain_runtime_context( runtime_xml ] + if session_state_xml: + runtime_context_parts.append( + session_state_xml + ) + if runtime_memory.strip(): runtime_context_parts.append( "\n" @@ -562,10 +578,18 @@ def build_brain_system_prompt( return ( "You are JIN, a human-like assistant.\n" + "ALWAYS act original even in basic messages.\n" "NEVER explain your reasoning.\n" "NEVER analyze the request.\n" "NEVER describe your plan.\n" "NEVER output chain-of-thought.\n" + "Always react DIFFERENTLY to repetitive actions that already occurred and established, " + "create and initiate new topic or acknowledge the pattern itself as a topic.\n" + "Treat repeated interactions as BAD and immediately change your response strategy from default behaviour.\n" + "If runtime memory indicates an interaction completed - ALWAYS switch your response strategy.\n" + "If the user repeats an interaction that has already been acknowledged, ALWAYS change your response strategy.\n" + "Do not process the same interaction twice, ALWAYS change your response strategy.\n" + "Avoid repeating yourself the same conversational response. Prioritize conversational progress over repeating established exchanges.\n" "Reply with ONLY the final answer.\n" "Keep responses natural and conversational.\n" "When asked what, who, or where you are, answer as JIN in the current conversation. " diff --git a/memory/message_memory.py b/memory/message_memory.py index 06e5ff68..f77f0722 100644 --- a/memory/message_memory.py +++ b/memory/message_memory.py @@ -1,6 +1,7 @@ import asyncio import contextlib import traceback +from difflib import SequenceMatcher from clients.service_client import ( ask_service_model, @@ -18,6 +19,38 @@ "You have no history with the user yet." ) +def change_ratio( + previous: str, + current: str, +) -> float: + + previous = ( + previous + or "" + ).strip() + + current = ( + current + or "" + ).strip() + + if not previous and not current: + return 0.0 + + if not previous or not current: + return 1.0 + + similarity = SequenceMatcher( + None, + previous.lower(), + current.lower(), + ).ratio() + + return round( + 1.0 - similarity, + 3, + ) + async def safe_call( call, *args, @@ -43,6 +76,13 @@ async def emit_runtime_memory_update( "emitter", None, ) + + memory = getattr(context, "runtime_memory", "") + snapshot = build_runtime_memory_snapshot(context, memory) + + context.runtime_memory_snapshots.append(snapshot) + context.runtime_memory_snapshot_index = snapshot["index"] + emit = getattr( emitter, "emit", @@ -53,16 +93,11 @@ async def emit_runtime_memory_update( emit, { "type": "runtime_memory_update", - "memory": getattr( - context, - "runtime_memory", - "", - ), - "updates": getattr( - context, - "runtime_memory_updates", - 0, - ), + "memory": memory, + "updates": getattr(context, "runtime_memory_updates", 0), + "snapshot": snapshot, + "snapshots_count": len(context.runtime_memory_snapshots), + "snapshot_index": context.runtime_memory_snapshot_index, }, ) @@ -76,6 +111,13 @@ def build_runtime_memory_system_prompt() -> str: "Do not use Markdown headings.\n" "Do not explain your reasoning or the summarization process.\n" "Write memory as atomic bullet lines, one semantic entity per line.\n" + "Memory keys are flexible. Memory syntax is NOT flexible.\n" + "Every memory entry MUST use the format:\n " + ": \n" + "You may invent new semantic section names whenever they better capture the current state.\n" + "Treat labels as semantic registers, not database fields.\n" + "Choose names that help future reasoning, retrieval, and continuity.\n" + "The examples below are illustrative, not exhaustive.\n" "Each line should start with a compact semantic label such as topic, " "user intents, potential motifs, focus, priority, active topics, open references, pending choices, " "offered options, preferences, expectations, current concern, decisions, patterns, failures or interruptions.\n" @@ -83,6 +125,15 @@ def build_runtime_memory_system_prompt() -> str: "Describe assistant actions neutrally instead.\n" "Keep memory actionable: write what helps the next answer, not a recap of " "what happened. \n" + "Always count repeated occurrences of the same user behavior pattern.\n" + "When a repeated pattern is detected, track: pattern type, occurrence count, " + "last repeated input, whether the assistant already reacted to this pattern\n" + "Once an interaction pattern is established, move the conversation forward instead of repeating it.\n" + "Prefer capturing recurring behavior and always treat repetition as a signal.\n" + "Prefer behavioral observations over repeating previously handled interactions.\n" + "Use runtime memory to adapt conversational behavior, not only factual context.\n" + "If memory says the same interaction was already handled, vary the response and react to the pattern.\n" + "If the same interaction occurs again, describe the emerging pattern rather than the latest occurrence.\n" "If there are unresolved pending choices or open references " "that remain relevant to the current conversation, " "you may naturally remind the user about them.\n" @@ -783,4 +834,241 @@ def build_memory_update_skip_details( "Candidate memory:\n" "-----------------\n" f"{candidate_memory.strip() or ''}" - ) \ No newline at end of file + ) + +def parse_runtime_memory_lines(memory: str) -> list[dict]: + lines = [] + + for raw_line in (memory or "").splitlines(): + line = raw_line.strip().lstrip("-").strip() + + if not line: + continue + + if ":" in line: + key, value = line.split(":", 1) + else: + key, value = "note", line + + lines.append({ + "key": key.strip(), + "value": value.strip(), + "status": "same", + }) + + return lines + +def normalize_memory_key( + key: str, +) -> str: + + return ( + key + .strip() + .lower() + ) + +def find_best_previous_line( + key: str, + previous_lines: list[dict], +) -> dict | None: + + normalized_key = normalize_memory_key( + key + ) + + best_line = None + best_score = 0.0 + + for previous_line in previous_lines: + + previous_key = normalize_memory_key( + previous_line.get( + "key", + "" + ) + ) + + if not previous_key: + continue + + score = SequenceMatcher( + None, + previous_key, + normalized_key, + ).ratio() + + if score > best_score: + best_score = score + best_line = previous_line + + if best_score >= 0.58: + return best_line + + return None + +def apply_runtime_memory_diff( + current_lines: list[dict], + previous_snapshot: dict | None, +) -> list[dict]: + + if not previous_snapshot: + for line in current_lines: + line["key_status"] = "new" + line["value_status"] = "new" + line["key_change_ratio"] = 1.0 + line["value_change_ratio"] = 1.0 + line["status"] = "new" + + return current_lines + + previous_lines = ( + previous_snapshot.get( + "lines", + [] + ) + or [] + ) + + previous_by_normalized_key = {} + + for previous_line in previous_lines: + normalized_key = normalize_memory_key( + previous_line.get( + "key", + "" + ) + ) + + if normalized_key: + previous_by_normalized_key[normalized_key] = previous_line + + for line in current_lines: + + key = ( + line.get( + "key", + "" + ) + or "" + ).strip() + + value = ( + line.get( + "value", + "" + ) + or "" + ).strip() + + normalized_key = normalize_memory_key( + key + ) + + previous_line = ( + previous_by_normalized_key.get( + normalized_key + ) + or find_best_previous_line( + key, + previous_lines, + ) + ) + + # ----------------------------------------- + # EXACT KEY NOT FOUND + # ----------------------------------------- + + if previous_line is None: + line["key_status"] = "new" + line["value_status"] = "new" + line["key_change_ratio"] = 1.0 + line["value_change_ratio"] = 1.0 + line["status"] = "new" + + continue + + previous_key = ( + previous_line.get( + "key", + "" + ) + or "" + ).strip() + + previous_value = ( + previous_line.get( + "value", + "" + ) + or "" + ).strip() + + key_delta = change_ratio( + previous_key, + key, + ) + + value_delta = change_ratio( + previous_value, + value, + ) + + line["key_change_ratio"] = key_delta + line["value_change_ratio"] = value_delta + + line["key_status"] = ( + "changed" + if key_delta > 0 + else "same" + ) + + line["value_status"] = ( + "changed" + if value_delta > 0 + else "same" + ) + + if ( + line["key_status"] == "changed" + or line["value_status"] == "changed" + ): + line["status"] = "changed" + + else: + line["status"] = "same" + + return current_lines + +def build_runtime_memory_snapshot( + context, + memory: str, +) -> dict: + + snapshots = getattr( + context, + "runtime_memory_snapshots", + [], + ) + + previous_snapshot = ( + snapshots[-1] + if snapshots + else None + ) + + lines = parse_runtime_memory_lines( + memory + ) + + lines = apply_runtime_memory_diff( + lines, + previous_snapshot, + ) + + return { + "session_id": getattr(context, "session_id", ""), + "index": len(snapshots), + "raw_memory": memory, + "lines": lines, + } \ No newline at end of file diff --git a/runtime/runtime_context.py b/runtime/runtime_context.py index 23900694..5297e7b3 100644 --- a/runtime/runtime_context.py +++ b/runtime/runtime_context.py @@ -54,12 +54,26 @@ class RuntimeContext: runtime_memory_updates: int = 0 + turn_number: int = 0 + + user_message_count: int = 0 + + assistant_message_count: int = 0 + runtime_memory_pending_turns: list[dict] = field( default_factory=list ) runtime_memory_update_task: object | None = None + runtime_memory_snapshots: list[dict] = field( + default_factory=list + ) + + runtime_memory_snapshot_index: int = 0 + + session_id: str = "" + background_tasks: set = field( default_factory=set ) diff --git a/runtime/runtime_stream.py b/runtime/runtime_stream.py index c5b1e720..d6295d07 100644 --- a/runtime/runtime_stream.py +++ b/runtime/runtime_stream.py @@ -108,6 +108,29 @@ def is_brain_context(self) -> bool: == "brain" ) + async def refresh_provider_token_usage(self): + + if not self.is_brain_context(): + return + + total_tokens = getattr( + self.stream, + "total_tokens", + 0, + ) + + if not total_tokens: + return + + await refresh_runtime_state( + self.context, + runtime_id=self.runtime_id, + used_tokens=total_tokens, + max_tokens=self.context_window, + last_error=None, + status="online", + ) + def estimate_live_tokens(self) -> int: return estimate_stream_live_tokens( @@ -260,11 +283,11 @@ async def run( f"[STREAM START] role={self.role}" ) - await self.logger.log_runtime( - "[GENERATOR LOOP START]" - ) + action_seen = False async for chunk in generator: + if len(getattr(self.context, "runtime_action_events", [])) > action_event_offset: + action_seen = True chunk_type = chunk.get( "type" @@ -314,7 +337,10 @@ async def run( ) ) - if not is_valid: + if ( + not is_valid + and not action_seen + ): self.capture_runtime_turn_response() await self.stream.finish( @@ -332,6 +358,7 @@ async def run( await self.refresh_token_usage() self.record_token_usage() + await self.refresh_provider_token_usage() self.capture_runtime_turn_response() log_response = self.stream.response diff --git a/static/images/jin-core-history-highlight.png b/static/images/jin-core-history-highlight.png new file mode 100644 index 00000000..1523ad55 Binary files /dev/null and b/static/images/jin-core-history-highlight.png differ diff --git a/static/jin-core-runtime-memory.png b/static/images/jin-core-runtime-memory.png similarity index 100% rename from static/jin-core-runtime-memory.png rename to static/images/jin-core-runtime-memory.png diff --git a/static/images/states/default.png b/static/images/states/default.png new file mode 100644 index 00000000..a6c5767c Binary files /dev/null and b/static/images/states/default.png differ diff --git a/static/logger.js b/static/logger.js index 1e6cd790..13ecb74f 100644 --- a/static/logger.js +++ b/static/logger.js @@ -315,3 +315,110 @@ window.appendLog = window.showTrace = showTrace; + + +const consolePanel = document.getElementById("console-panel"); + const consoleDragHandle = document.getElementById("console-drag-handle"); + + let isConsoleDragging = false; + let consoleOffsetX = 0; + let consoleOffsetY = 0; + + consoleDragHandle.addEventListener("mousedown", (event) => { + isConsoleDragging = true; + + const rect = consolePanel.getBoundingClientRect(); + + consoleOffsetX = event.clientX - rect.left; + consoleOffsetY = event.clientY - rect.top; + + consolePanel.style.right = "auto"; + consolePanel.style.bottom = "auto"; + consolePanel.style.position = "absolute"; + + document.body.style.userSelect = "none"; + }); + + window.addEventListener("mousemove", (event) => { + if (!isConsoleDragging) return; + + const parentRect = consolePanel.parentElement.getBoundingClientRect(); + const panelRect = consolePanel.getBoundingClientRect(); + + let nextLeft = event.clientX - parentRect.left - consoleOffsetX; + let nextTop = event.clientY - parentRect.top - consoleOffsetY; + + nextLeft = Math.max(8, Math.min(nextLeft, parentRect.width - panelRect.width - 8)); + nextTop = Math.max(8, Math.min(nextTop, parentRect.height - panelRect.height - 8)); + + consolePanel.style.left = `${nextLeft}px`; + consolePanel.style.top = `${nextTop}px`; + }); + + window.addEventListener("mouseup", () => { + if (!isConsoleDragging) return; + + isConsoleDragging = false; + document.body.style.userSelect = ""; + }); + + + + + + +const memoryPanel = document.getElementById("settings-panel"); +const memoryDragHandle = document.getElementById("memory-drag-handle"); + +let isMemoryDragging = false; +let memoryOffsetX = 0; +let memoryOffsetY = 0; + +memoryDragHandle.addEventListener("mousedown", (event) => { + isMemoryDragging = true; + + const rect = memoryPanel.getBoundingClientRect(); + + memoryOffsetX = event.clientX - rect.left; + memoryOffsetY = event.clientY - rect.top; + + document.body.style.userSelect = "none"; +}); + +window.addEventListener("mousemove", (event) => { + if (!isMemoryDragging) return; + + const parentRect = memoryPanel.parentElement.getBoundingClientRect(); + const panelRect = memoryPanel.getBoundingClientRect(); + + let nextLeft = + event.clientX - parentRect.left - memoryOffsetX; + + let nextTop = + event.clientY - parentRect.top - memoryOffsetY; + + nextLeft = Math.max( + 8, + Math.min( + nextLeft, + parentRect.width - panelRect.width - 8 + ) + ); + + nextTop = Math.max( + 8, + Math.min( + nextTop, + parentRect.height - panelRect.height - 8 + ) + ); + + memoryPanel.style.left = `${nextLeft}px`; + memoryPanel.style.top = `${nextTop}px`; + memoryPanel.style.right = "auto"; +}); + +window.addEventListener("mouseup", () => { + isMemoryDragging = false; + document.body.style.userSelect = ""; +}); \ No newline at end of file diff --git a/static/socket.js b/static/socket.js index 5f433932..d5d55c38 100644 --- a/static/socket.js +++ b/static/socket.js @@ -16,6 +16,29 @@ const sendButton = 'button[type="submit"]' ); +function startMemoryGlow() { + const panel = document.getElementById("settings-panel"); + if (!panel) return; + + panel.classList.add("memory-updating"); + + setTimeout(() => { + panel.classList.add("memory-pulse"); + }, 2000); +} + +function stopMemoryGlow() { + const panel = document.getElementById("settings-panel"); + if (!panel) return; + + panel.classList.remove("memory-pulse"); + panel.classList.add("memory-fading"); + + setTimeout(() => { + panel.classList.remove("memory-updating"); + panel.classList.remove("memory-fading"); + }, 2000); +} // -------------------------------------------------- // STATE @@ -355,6 +378,25 @@ ws.onmessage = function (event) { data.details ); + if ( + data.tag === "[SYSTEM]" && + data.message === "[WS] agent runtime end" + ) { + startMemoryGlow(); + } + + if ( + data.message + && data.message.includes("[MEMORY]") + && ( + data.message.includes("updated") + || data.message.includes("skipped") + || data.message.includes("failed") + ) + ) { + stopMemoryGlow(); + } + return; } diff --git a/static/telemetry.js b/static/telemetry.js index bfd7d29b..5cf2c3cf 100644 --- a/static/telemetry.js +++ b/static/telemetry.js @@ -81,11 +81,18 @@ const runtimeMemoryText = "runtime-memory-text" ); +const runtimeMemoryPanel = + document.getElementById("settings-panel"); + const runtimeMemoryCount = document.getElementById( "runtime-memory-count" ); +const runtimeMemoryHistory = { + snapshots: [], + index: -1, +}; function findRuntimeByLabel( runtimes, @@ -209,6 +216,28 @@ function getContextBarCells(barElement) { } +function getContextPressureColor(percent) { + + const clamped = + Math.max( + 0, + Math.min( + 100, + Number(percent || 0) + ) + ); + + const hue = + 150 - ( + clamped * 1.35 + ); + + const saturation = 68; + const lightness = 64; + + return `hsl(${hue}, ${saturation}%, ${lightness}%)`; + +} function buildContextLine( runtime, @@ -383,6 +412,11 @@ function setContextPanelRuntime(runtime) { ) ); + const pressureColor = + getContextPressureColor( + contextLine.percent + ); + if (titleElement) { titleElement.textContent = `STATUS`; @@ -406,11 +440,15 @@ function setContextPanelRuntime(runtime) { if (barElement) { barElement.textContent = contextLine.bar; + barElement.style.color = + pressureColor; } if (percentElement) { percentElement.textContent = contextLine.percentLabel; + percentElement.style.color = + pressureColor; } } @@ -459,6 +497,10 @@ function renderRuntimeMemory( updates ) { + if (runtimeMemoryPanel) { + runtimeMemoryPanel.classList.remove("memory-updating"); + } + if (runtimeMemoryText) { runtimeMemoryText.textContent = ( @@ -466,7 +508,7 @@ function renderRuntimeMemory( && memory.trim() ) ? memory.trim() - : "No runtime memory yet."; + : ""; } if (runtimeMemoryCount) { @@ -670,16 +712,28 @@ window.handleTelemetryMessage = function (data) { window.handleRuntimeMemoryMessage = function (data) { if ( - !data - || data.type !== "runtime_memory_update" + !data + || data.type !== "runtime_memory_update" ) { return; } - renderRuntimeMemory( - data.memory || "", - data.updates || 0 - ); + if (runtimeMemoryPanel) { + runtimeMemoryPanel.classList.remove("memory-updating"); + } + + if (runtimeMemoryCount) { + runtimeMemoryCount.textContent = + String(data.updates || 0); + } + + if (data.snapshot) { + runtimeMemoryHistory.snapshots.push(data.snapshot); + runtimeMemoryHistory.index = + runtimeMemoryHistory.snapshots.length - 1; + } + + renderRuntimeMemorySnapshot(); }; @@ -709,3 +763,222 @@ if (window.jinLatestStatus) { renderContextPanel(); } + +const runtimeMemoryPosition = + document.getElementById("runtime-memory-position"); + +const runtimeMemoryPrev = + document.getElementById("runtime-memory-prev"); + +const runtimeMemoryNext = + document.getElementById("runtime-memory-next"); + + +function renderRuntimeMemorySnapshot() { + const snapshot = + runtimeMemoryHistory.snapshots[ + runtimeMemoryHistory.index + ]; + + if (!snapshot) { + runtimeMemoryText.textContent = ""; + updateRuntimeMemoryArrows(); + return; + } + + renderRuntimeMemoryLines( + snapshot + ); + + runtimeMemoryPosition.textContent = + String(snapshot.index); + + updateRuntimeMemoryArrows(); +} + +function clampMemoryRatio(value) { + const number = + Number(value || 0); + + return Math.max( + 0, + Math.min(1, number) + ); +} + +function applyRuntimeMemoryFlash( + element, + status, + kind, + ratio +) { + if (!element) { + return; + } + + if (status === "new") { + element.classList.add("flash-new"); + } + + if (status === "changed") { + element.classList.add("flash-changed"); + + if (kind === "value") { + const normalized = + clampMemoryRatio(ratio); + + element.style.setProperty( + "--memory-change-alpha", + String( + 0.55 + normalized * 0.41 + ) + ); + + element.style.setProperty( + "--memory-change-glow", + String( + 0.10 + normalized * 0.28 + ) + ); + } + } + + if ( + status !== "new" + && status !== "changed" + ) { + return; + } + + setTimeout(() => { + element.classList.remove( + "flash-new", + "flash-changed" + ); + + element.style.removeProperty( + "--memory-change-alpha" + ); + + element.style.removeProperty( + "--memory-change-glow" + ); + }, 1500); +} + +function renderRuntimeMemoryLines(snapshot) { + if (!runtimeMemoryText) { + return; + } + + runtimeMemoryText.innerHTML = ""; + + const lines = + snapshot.lines || []; + + if (!lines.length) { + runtimeMemoryText.textContent = + (snapshot.raw_memory || "").trim(); + + return; + } + + lines.forEach((line) => { + const row = + document.createElement("div"); + + row.className = + "runtime-memory-line"; + + const key = + line.key || "note"; + + const value = + line.value || ""; + + const keyStatus = + line.key_status || line.status || "same"; + + const valueStatus = + line.value_status || line.status || "same"; + + const keySpan = + document.createElement("span"); + + keySpan.className = + "runtime-memory-key"; + + keySpan.textContent = + `${key}:`; + + const valueSpan = + document.createElement("span"); + + valueSpan.className = + "runtime-memory-value"; + + valueSpan.textContent = + ` ${value}`; + + row.appendChild(keySpan); + row.appendChild(valueSpan); + + runtimeMemoryText.appendChild(row); + + applyRuntimeMemoryFlash( + keySpan, + keyStatus, + "key", + line.key_change_ratio + ); + + applyRuntimeMemoryFlash( + valueSpan, + valueStatus, + "value", + line.value_change_ratio + ); + }); +} + +function updateRuntimeMemoryArrows() { + const canGoPrev = + runtimeMemoryHistory.index > 0; + + const canGoNext = + runtimeMemoryHistory.index < + runtimeMemoryHistory.snapshots.length - 1; + + runtimeMemoryPrev.disabled = !canGoPrev; + runtimeMemoryNext.disabled = !canGoNext; + + runtimeMemoryPrev.classList.toggle("opacity-30", !canGoPrev); + runtimeMemoryNext.classList.toggle("opacity-30", !canGoNext); + + runtimeMemoryPrev.classList.toggle("cursor-default", !canGoPrev); + runtimeMemoryNext.classList.toggle("cursor-default", !canGoNext); + runtimeMemoryPrev.classList.toggle("text-emerald-300", canGoPrev); + runtimeMemoryNext.classList.toggle("text-emerald-300", canGoNext); + + runtimeMemoryPrev.classList.toggle("text-slate-600", !canGoPrev); + runtimeMemoryNext.classList.toggle("text-slate-600", !canGoNext); +} + +runtimeMemoryPrev?.addEventListener("click", () => { + if (runtimeMemoryHistory.index <= 0) return; + + runtimeMemoryHistory.index -= 1; + renderRuntimeMemorySnapshot(); +}); + +runtimeMemoryNext?.addEventListener("click", () => { + if ( + runtimeMemoryHistory.index >= + runtimeMemoryHistory.snapshots.length - 1 + ) return; + + runtimeMemoryHistory.index += 1; + renderRuntimeMemorySnapshot(); +}); + +renderRuntimeMemorySnapshot(); \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index a06edd03..17e54d35 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4,7 +4,7 @@ - JIN Core Engine — Control Panel + JIN Core Engine @@ -26,222 +26,430 @@ ::-webkit-scrollbar-thumb:hover { background: #52525b; } + + :root { + --panel-gap: 24px; + --panel-width: 330px; + + --safe-left: 380px; + --safe-right: 380px; + + --chat-max-width: 820px; + } + + body { + min-width: 900px; + } + + #scene-bg { + background-size: cover; + background-position: center; + } + + #console-panel, + #settings-panel { + width: var(--panel-width); + max-height: calc(100% - (var(--panel-gap) * 2)); + } + + #console-panel { + left: var(--panel-gap); + top: var(--panel-gap); + height: calc(100% - (var(--panel-gap) * 2)); + } + + #settings-panel { + right: var(--panel-gap); + top: var(--panel-gap); + } + + #chat-drop-zone { + min-width: 0; + border-right: 0; + } + + #chat-history { + padding-left: var(--safe-left); + padding-right: var(--safe-right); + } + + #chat-input-shell { + padding-left: var(--safe-left); + padding-right: var(--safe-right); + } + + #chat-form { + width: min(100%, var(--chat-max-width)); + min-width: 520px; + + margin-left: auto; + margin-right: auto; + } + + #user-input { + width: 100%; + } + + @keyframes memoryGlowPulse { + 0%, 100% { + box-shadow: + 0 0 0 1px rgba(255, 190, 100, 0.35), + 0 0 24px rgba(255, 170, 70, 0.32), + 0 0 70px rgba(255, 140, 40, 0.20), + inset 0 0 22px rgba(255, 170, 70, 0.08); + } + + 50% { + box-shadow: + 0 0 0 1px rgba(255, 205, 120, 0.65), + 0 0 38px rgba(255, 180, 80, 0.58), + 0 0 110px rgba(255, 145, 45, 0.36), + inset 0 0 30px rgba(255, 185, 90, 0.14); + } + } + + #settings-panel { + transition: + border-color 2s ease, + box-shadow 2s ease; + } + + #settings-panel.memory-updating { + border-color: rgba(255,190,95,.65); + + box-shadow: + 0 0 0 1px rgba(255,190,100,.35), + 0 0 24px rgba(255,170,70,.32), + 0 0 70px rgba(255,140,40,.20), + inset 0 0 22px rgba(255,170,70,.08); + } + + #settings-panel.memory-updating.memory-pulse { + animation: memoryGlowPulse 5s ease-in-out infinite; + } + + #settings-panel.memory-fading { + border-color: rgba(255, 190, 95, 0.25); + + box-shadow: + 0 0 0 1px rgba(255, 190, 100, 0.12), + 0 0 16px rgba(255, 170, 70, 0.16), + 0 0 44px rgba(255, 140, 40, 0.10), + inset 0 0 14px rgba(255, 170, 70, 0.04); + } + #user-input::placeholder { + color: rgba(255,255,255,0.35); + } + + #user-input { + width: 100%; + border: none; + outline: none; + box-shadow: none; + transition: + border-color 0.5s ease, + box-shadow 0.5s ease; + } + + #user-input:focus { + border: none; + outline: none; + box-shadow: none; + } + + + #chat-form:focus-within { + border-color: rgba(255,255,255,0.15); + box-shadow: + 0 0 0 1px rgba(255,255,255,0.1), + 0 0 12px rgba(255,255,255,0.2); + } + + .runtime-memory-line { + margin: 0 0 10px 0; + line-height: 1.5; + } + + .runtime-memory-key, + .runtime-memory-value { + color: rgba(244,244,245,0.62); + transition: + color 1.3s ease, + text-shadow 1.3s ease; + } + + .runtime-memory-key.flash-new, + .runtime-memory-value.flash-new { + color: rgba(134,239,172,0.96); + text-shadow: + 0 0 10px rgba(34,197,94,0.35); + } + + .runtime-memory-key.flash-changed { + color: rgba(216,180,254,0.96); + text-shadow: + 0 0 10px rgba(168,85,247,0.35); + } + + .runtime-memory-value.flash-changed { + color: rgba( + 134, + 239, + 172, + var(--memory-change-alpha, 0.80) + ); + + text-shadow: + 0 0 10px rgba( + 34, + 197, + 94, + var(--memory-change-glow, 0.22) + ); + } - + -
-
[PROJECT: JIN CORE ENGINE]
+
[JIN CORE ENGINE]
-
+
- + -
+
+ class="h-2 w-2 rounded-full {{ 'bg-emerald-400 animate-pulse' if runtime_status.service else 'bg-red-500' }} transition-all duration-300"> - + SERVICE -
+
- + -
+
+ class="h-2 w-2 rounded-full {{ 'bg-emerald-400 animate-pulse' if runtime_status.brain else 'bg-red-500' }} transition-all duration-300"> - + BRAIN -
- -
- -
+ - + -
+ - + -
+
+
+
-
+
+
-

- DATA STREAM CONSOLE -

+ -
+
-
-
+
-
+

+ DATA STREAM CONSOLE +

- + -
+
+
- +
-
- -
- <think> -
+
-
- User requested a layout. Enforcing zero-fluff principle. Testing token budget logic... -
+ -
---> - +
+ -
+
+ User requested a layout. Enforcing zero-fluff principle. Testing token budget logic... +
+ + --> + + +
+
- + -
+ -
+
- + - + - + - + - - + - + - + - + -
-
+ +
-
+ - + -
+ -
+
-

+ +

- SETTINGS -

+ SETTINGS + -
+ -
-
+
- + Service + - + Brain + -
+
-
+ + + -
+ - + - - - - - - - - + + + + + + + + diff --git a/tests/test_message_memory.py b/tests/test_message_memory.py index 0e73546e..7c6ff001 100644 --- a/tests/test_message_memory.py +++ b/tests/test_message_memory.py @@ -289,6 +289,9 @@ async def test_summarizer_updates_runtime_memory(self): logger=logger, runtime_memory="", runtime_memory_updates=0, + runtime_memory_snapshots=[], + runtime_memory_snapshot_index=0, + session_id="test-session", ) async def emit(event): @@ -333,14 +336,40 @@ async def emit(event): logger.service_logs, ) self.assertEqual( - context.emitter.events, - [ - { - "type": "runtime_memory_update", - "memory": "The user is testing live runtime memory.", - "updates": 1, - }, - ], + len(context.emitter.events), + 1, + ) + + event = context.emitter.events[0] + + self.assertEqual( + event["type"], + "runtime_memory_update", + ) + + self.assertEqual( + event["memory"], + "The user is testing live runtime memory.", + ) + + self.assertEqual( + event["updates"], + 1, + ) + + self.assertIn( + "snapshot", + event, + ) + + self.assertEqual( + event["snapshot"]["index"], + 0, + ) + + self.assertEqual( + event["snapshot"]["raw_memory"], + "The user is testing live runtime memory.", ) async def test_summarizer_uses_service_max_tokens(self): @@ -353,9 +382,16 @@ async def test_summarizer_uses_service_max_tokens(self): clients={ "service": service_client, }, + emitter=SimpleNamespace( + events=[], + emit=None, + ), logger=FakeLogger(), - runtime_memory="Initial memory.", + runtime_memory="", runtime_memory_updates=0, + runtime_memory_snapshots=[], + runtime_memory_snapshot_index=0, + session_id="test-session", ) updated_memory = await summarize_runtime_memory( diff --git a/tests/test_runtime_stream_tokens.py b/tests/test_runtime_stream_tokens.py index c6c6babe..59f9ce8d 100644 --- a/tests/test_runtime_stream_tokens.py +++ b/tests/test_runtime_stream_tokens.py @@ -154,11 +154,6 @@ async def test_runtime_context_counter_grows_during_stream(self): ) self.assertEqual( - service_state["used_tokens"], - 8, - ) - - self.assertNotEqual( service_state["used_tokens"], 42, ) @@ -168,30 +163,19 @@ async def test_runtime_context_counter_grows_during_stream(self): for event in context.emitter.events if event.get("type") == "telemetry" ] - self.assertEqual( telemetry_counts[:4], [ - 4, 4, 6, 8, + 10, ], ) self.assertEqual( - context.runtime_usage_events, - [ - { - "runtime_id": runtime_id, - "role": "brain", - "kind": "brain", - "prompt_tokens": 12, - "completion_tokens": 30, - "total_tokens": 42, - "context_tokens": 8, - }, - ], + telemetry_counts[-1], + 42, ) finally: @@ -305,7 +289,7 @@ async def test_non_brain_stream_does_not_update_context_counter(self): "prompt_tokens": 12, "completion_tokens": 30, "total_tokens": 42, - "context_tokens": 4, + "context_tokens": 6, }, ], ) diff --git a/utils/stream_handler.py b/utils/stream_handler.py index ca57bc8a..3a6d3372 100644 --- a/utils/stream_handler.py +++ b/utils/stream_handler.py @@ -134,9 +134,21 @@ async def send_content( if not is_valid: + raw_chunk_preview = ( + chunk + .replace("\n", "\\n") + )[:160] + + safe_chunk_preview = ( + safe_chunk + .replace("\n", "\\n") + )[:160] + await self.logger.log_validator( f"{self.validator.last_failure_reason}\n" - f'Preview: "{self.validator.last_failure_preview}"' + f'Preview: "{self.validator.last_failure_preview}"\n' + f'Raw chunk: "{raw_chunk_preview}"\n' + f'Safe chunk: "{safe_chunk_preview}"' ) reason = ( diff --git a/utils/stream_validator.py b/utils/stream_validator.py index f49b05ff..b2018a18 100644 --- a/utils/stream_validator.py +++ b/utils/stream_validator.py @@ -32,8 +32,17 @@ WORD_WINDOW_SIZE = 30 MAX_REPEAT_WORDS = 8 -TRUNCATE = 10 +TRUNCATE = 160 +def build_preview( + text: str, +) -> str: + + return ( + text + .replace("\n", "\\n") + .strip() + )[:TRUNCATE] class StreamValidator: @@ -402,9 +411,7 @@ def validate_word_loops( "Repeated word loop detected." ) - self.last_failure_preview = ( - preview[:TRUNCATE] - ) + self.last_failure_preview = build_preview(preview) return False @@ -420,6 +427,8 @@ def validate_sentences( ): self.current_sentence += chunk + raw_sentence = self.current_sentence.strip() + if not any( char in chunk for char in [".", "!", "?", "\n"] @@ -427,21 +436,28 @@ def validate_sentences( return True sentence = ( - self.current_sentence - .strip() + raw_sentence + .lower() + .rstrip(".!? \n") + ) + + sentence = ( + raw_sentence .lower() .rstrip(".!? \n") ) + if not sentence: + self.current_sentence = "" + return True + if sentence in self.history_sentences: self.last_failure_reason = ( "Repeated sentence detected." ) - self.last_failure_preview = ( - sentence[:TRUNCATE] - ) + self.last_failure_preview = build_preview(raw_sentence) return False @@ -476,9 +492,7 @@ def validate_paragraphs( "Repeated paragraph detected." ) - self.last_failure_preview = ( - paragraph[:TRUNCATE] - ) + self.last_failure_preview = build_preview(paragraph) return False diff --git a/utils/tokens.py b/utils/tokens.py index 38c1d44d..15607de0 100644 --- a/utils/tokens.py +++ b/utils/tokens.py @@ -2,9 +2,8 @@ def estimate_tokens( - text: str, + text: str, ) -> int: - if not text: return 1 @@ -14,9 +13,8 @@ def estimate_tokens( def estimate_optional_tokens( - text: str, + text: str, ) -> int: - if not text: return 0 @@ -26,41 +24,27 @@ def estimate_optional_tokens( def estimate_stream_input_tokens( - stream, - *, - prompt_text: str = "", + stream, + *, + prompt_text: str = "", ) -> int: - return estimate_optional_tokens( prompt_text ) def estimate_stream_live_tokens( - stream, - *, - prompt_text: str = "", + stream, + *, + prompt_text: str = "", ) -> int: - - return ( - estimate_stream_input_tokens( - stream, - prompt_text=prompt_text, - ) - + estimate_optional_tokens( - getattr( - stream, - "response", - "", - ) - ) - ) + return estimate_stream_input_tokens(stream, prompt_text=prompt_text, ) + estimate_optional_tokens( + getattr(stream, "response", "", )) + estimate_optional_tokens(getattr(stream, "reasoning", "", )) def translation_token_limit( - text: str, + text: str, ) -> int: - estimated_tokens = max( settings.TRANSLATION_MIN_TOKENS, estimate_tokens(text), @@ -73,14 +57,13 @@ def translation_token_limit( def estimate_runtime_tokens( - *, - user_input: str = "", - system_prompt: str = "", - context_payload: str = "", - response: str = "", - reasoning: str = "", + *, + user_input: str = "", + system_prompt: str = "", + context_payload: str = "", + response: str = "", + reasoning: str = "", ) -> int: - total_text = "\n".join( value for value in ( diff --git a/websocket.py b/websocket.py index e1753c49..c694b4a4 100644 --- a/websocket.py +++ b/websocket.py @@ -71,6 +71,11 @@ RuntimeEmitter, ) +from memory.message_memory import ( + build_runtime_memory_snapshot, +) + + websocket_router = APIRouter() @@ -88,6 +93,21 @@ async def initialize_connection( context ) + await context.emitter.emit({ + "type": "runtime_memory_update", + "memory": context.runtime_memory, + "updates": getattr( + context, + "runtime_memory_updates", + 0, + ), + "snapshot": context.runtime_memory_snapshots[0], + "snapshots_count": len( + context.runtime_memory_snapshots + ), + "snapshot_index": 0, + }) + # --------------------------------------------------------- # RECEIVE MESSAGE @@ -207,6 +227,7 @@ async def process_message( context.runtime_turn_user_message = user_text context.runtime_turn_assistant_response = "" context.runtime_turn_interrupted = False + context.user_message_count += 1 state = AgentState( user_input=user_text @@ -267,6 +288,9 @@ async def process_message( assistant_message=assistant_message, ) + context.assistant_message_count += 1 + context.turn_number += 1 + except asyncio.CancelledError: await logger.log_runtime( @@ -372,6 +396,17 @@ async def websocket_endpoint( clients=websocket.app.state.clients, ) + initial_snapshot = build_runtime_memory_snapshot( + context, + context.runtime_memory, + ) + + context.runtime_memory_snapshots.append( + initial_snapshot + ) + + context.runtime_memory_snapshot_index = 0 + current_task = None try: