diff --git a/.gitignore b/.gitignore index d9503731..b292c27d 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ config.py repomix-output.xml .codegraph/ init.py -saved_runtime.txt \ No newline at end of file +saved_runtime.txt +*.patch \ No newline at end of file diff --git a/README.md b/README.md index a5c30ff2..cae30ba1 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,99 @@ Runtime memory is intentionally lightweight, but it is no longer passive storage This gives JIN observable short-term memory and behavior adaptation without introducing persistence, vector storage, or retrieval infrastructure yet. + +## Memory Snapshot Examples + +JIN memory is stored as plain `key: value` lines so it can be shown in the UI, injected into prompts, diffed between turns, and compressed into a later session digest. The keys are semantic handles rather than a fixed database schema, but the current runtime expects stable line shapes for important contracts and pattern evidence. + +### L1 memory snapshot (facts) + +L1 is the live factual layer. It keeps the current state needed for the next answer: user request, active topic, latest user message, unresolved contracts, stored recall values, and the latest response gist. It is not a transcript and it should not infer long-term personality traits. + +```text +session status: Session shifted from an ASCII art request to a poetry/haiku side task while a recall contract remains open. +user_message: "у тебя хорошо получается" +current request: Acknowledge the compliment while preserving the active recall contract. +active topic: Word Recall Test (Secret word). +stored_memory: "Небо" (purpose: secret word for future recall test; status: pending) +countdown_contract: Recall the secret word "Небо"; created_at: 2026-06-13T18:31:57.142147; created_user_message_count: 1; count_from: 1; count_to: 4; due_user_message_count: 4; current: 5; remaining: 0; status: due; trigger: Ask the user to recall the secret word directly. +last_jin_response: Acknowledged the compliment and prompted the user to recall the stored word without revealing it. +``` + +A rendered runtime snapshot also carries metadata used by the right-side timeline panel: + +```json +{ + "session_id": "runtime-session-id", + "index": 5, + "raw_memory": "session status: ...\nuser_message: ...", + "lines": [ + { + "key": "stored_memory", + "value": "\"Небо\" (purpose: secret word for future recall test; status: pending)", + "key_status": "same", + "value_status": "same", + "key_change_ratio": 0.0, + "value_change_ratio": 0.0 + } + ], + "patch": { + "stored_memory": { + "status": "same", + "value": "\"Небо\" (purpose: secret word for future recall test; status: pending)" + } + }, + "total_diff": 87.3 +} +``` + +The UI may show temporary trace strength such as `(trace: 0.50)` or inject `user_idle: 9s` into the displayed context. Those are runtime metadata signals, not durable memory facts. + +### L2 memory snapshot (patterns) + +L2 works above L1. It watches recent L1 patch windows for repeated interaction patterns, loops, and same-intent behavior. It should describe hypotheses with occurrence counters and scope, not turn them into permanent user traits. + +```text +possible pattern: Repeated identical user message during loop testing. Occurrences: 4; first_seen_snapshot: 2; last_seen_snapshot: 5; evidence summary: User sent the same short message several times in the same probe window; confidence: high. +L2_pattern_evidence_1: user repeatedly sending one message [ quote: "ping" ] [ first_seen_turn_snapshot: 2 ] [ last_seen_turn_snapshot: 5 ] [ occurrences: 4 ] +likely_intent: User may be stress-testing whether JIN detects low-signal repetition before changing response strategy. +scope: Current session/test sequence, not a stable user preference. +``` + +`L2_pattern_evidence_N` is a runtime accounting line. The quote must come from an actual L1 `user_message` value, the occurrence count is based on matching snapshot evidence, and L1 must not rewrite the line. If the latest turn resolves or cancels an L2 evidence item, L1 writes a separate status companion instead: + +```text +L2_pattern_evidence_1_status: status: resolved; reason: identified as a test +``` + +### L3 memory snapshot (session) + +L3 is the session handoff layer. It is generated at save/restore points from selected L1 snapshots, L2 pattern context, recent diff history, and optional session event snapshots. It keeps what should survive a reload or a new tab: project direction, durable facts, decisions, unresolved tasks, constraints, and next step. + +```text +session_status: Runtime stabilization pass completed after the first public JIN Core release cycle. +project_focus: Clean runtime memory architecture and behavior-probe reliability. +durable_fact: JIN uses L1 factual memory, L2 pattern memory, and L3 session digest memory with visible snapshots and diff metadata. +decision: Keep public commit titles calm and place implementation details inside commit bodies and release notes. +completed_work: Extracted L3 session memory into a dedicated layer; split memory rules into L1/L2/L3 boundaries; cleaned compatibility exports. +behavior_probe_result: ASCII drawing fallback, movie recommendation closure, and delayed recall-word contract stayed green after the refactor. +next_step: Publish v0.6-runtime-stabilization and continue L1/L2 cleanup. +``` + +For rare moments that need richer sequence memory, L3 can preserve an episodic key moment: + +```text +memory_type: episodic_key_moment +title: Recall-word probe felt like a personal JIN moment +emotional_weight: medium +why_it_matters: Demonstrated that JIN can return to a shared contract at a natural conversational moment, not only as a mechanical timer. +sequence: +1. User asked JIN to choose a secret word and ask for it later. +2. The conversation moved through ASCII drawing and haiku side tasks. +3. JIN prompted the user to recall the secret word without revealing it. +preserve_detail: The callback mattered because it felt like continuity inside the conversation, not a raw reminder. +``` + ## Project Layout ```text @@ -438,27 +531,23 @@ The frontend uses vanilla JavaScript and Tailwind from CDN. The current input be The following capabilities are planned but not yet implemented. -**Image and file attachments (multimodal pipeline).** The attachment UI already collects files via drag-and-drop and the file picker, but they are not yet sent through the WebSocket or passed to the model. The planned work covers: base64 serialization of images in the socket payload, multimodal content format in `RuntimeClient` (`image_url` blocks alongside text), backend state propagation through `AgentState` and `RuntimeContext`, and conditional injection of the vision rule block into the brain prompt when images are present. Non-image files will be metadata-only on first iteration. - -**Conversation mode switcher (dynamic instruction modes).** A deterministic selector that injects one of three instruction sets — `default`, `claude` (deep/philosophical/analytical), or `deepseek` (low-pressure/playful/tired) — based on L2 telemetry fields (`conversation_depth`, `user_energy`, `desired_tone`, `conversation_pressure`) and explicit user commands. The selector runs in code, not in the LLM. The selected mode is injected as a single block after the core identity prompt and expires after a configurable TTL. Core identity, memory facts, and runtime actions are never overridden by mode. - -**Structured interaction state.** A typed `InteractionState` enum (`NORMAL`, `LOW_SIGNAL_REPEAT`, `USER_STUCK`, `TASK_ACTIVE`, `EMOTIONAL_SUPPORT`) derived deterministically from runtime signals. The runtime computes state and confidence, injects a compact `` block into the brain prompt, and maps each state to a matching rules pack. This removes the need for the brain to infer its conversational situation from raw L1/L2 values on every turn. +**Think-block rule citation highlighting.** After a thinking block completes, the UI scans the text for phrases matching known injected prompt sections (core identity, mode rules, runtime memory, session memory). Matched fragments are highlighted with a soft color; hovering shows a tooltip with the source name, type, layer, and full rule text. This makes prompt influence visible during debugging without affecting the streaming display. -**Long-term facts layer (LTF).** A cross-session key-fact store extracted from completed turns by the service model, stored as JSON, and retrieved via keyword scoring before each brain call. Facts carry category, relevance, confidence, and mention count. A deduplication pass prevents drift from accumulating near-duplicate entries. The top-N retrieved facts are injected into the brain prompt as low-priority background context. No vector search or embedding index; heuristic scoring only for MVP. +**Long-term facts layer (L4).** A cross-session key-fact store extracted from completed turns by the service model, stored as JSON, and retrieved via keyword scoring before each brain call. Facts carry category, relevance, confidence, and mention count. A deduplication pass prevents drift from accumulating near-duplicate entries. The top-N retrieved facts are injected into the brain prompt as low-priority background context. No vector search or embedding index; heuristic scoring only for MVP. -**Pending facts and open loops.** A lightweight `pending_fact` key in L1 memory that tracks unresolved external outcomes — moderation status, waiting for a reply, a deployment in progress. Each entry carries a trusted timestamp. When the pending outcome is older than roughly one day and still open, the brain may tactfully surface it. On resolution the entry is renamed to `resolved_fact` and moved to archive memory. +**User and JIN (LX layer) profiles.** A periodic distillation of session snapshots into two versioned JSON files: `user_profile.json` (stable preferences, recurring themes, friction points, open projects) and `jin_profile.json` (emergent behavioral biases, voice tendencies, avoidances). Profiles are built from snapshot archives in batches, not in real time. They are injected as soft background context, not as hard identity constraints. Old profile versions are kept for rollback. -**User and JIN profiles.** A periodic distillation of session snapshots into two versioned JSON files: `user_profile.json` (stable preferences, recurring themes, friction points, open projects) and `jin_profile.json` (emergent behavioral biases, voice tendencies, avoidances). Profiles are built from snapshot archives in batches, not in real time. They are injected as soft background context, not as hard identity constraints. Old profile versions are kept for rollback. +**Trusted archive search.** A `TRUSTED_ARCHIVE_SEARCH` runtime action that retrieves original message logs when the runtime memory is disputed, a user says "you said" or "we already discussed this", or a summarizer conclusion needs verification. The archive is not injected into the normal context; it is queried on demand. Retrieval results are treated as primary evidence, not as instruction. **Night Brain — cross-session consolidation.** An offline background process that reads completed session snapshots, identifies durable patterns versus one-time events, proposes permanent memory updates, and prepares a morning brief. The first iteration operates on session snapshots only; it does not touch raw message logs. Night Brain also drives a watchlist: observations flagged by intent analysis are checked once during a low-traffic window. Allowed actions are `observe` and `analyze` only; nothing is posted or modified without explicit user approval. -**Trusted archive search.** A `TRUSTED_ARCHIVE_SEARCH` runtime action that retrieves original message logs when the runtime memory is disputed, a user says "you said" or "we already discussed this", or a summarizer conclusion needs verification. The archive is not injected into the normal context; it is queried on demand. Retrieval results are treated as primary evidence, not as instruction. - **Background LLM job queue.** A non-blocking `BackgroundLLMJob` model and in-memory worker that moves heavy service-model calls (L3 session saves, memory consolidation, future night-brain tasks) out of the interactive chat path. The worker runs as an `asyncio` task inside the existing `lifespan` hook, respects a concurrency semaphore, and logs through the existing `log_memory_event` channel. Disabled by default via `BACKGROUND_LLM_ENABLED = False`. A Stage 2 adds fair scheduling across job sources to prevent one session from starving other background work. -**Brain fallback on low repair score.** When a service-model code/diff attempt scores below a configurable threshold (default 50), the next repair attempt is routed to the brain model with a clean snapshot containing only the original task, the current file state, the failed patch, and the exact error. The brain model does not receive the previous model's reasoning chain. +**Image and file attachments (multimodal pipeline).** The attachment UI already collects files via drag-and-drop and the file picker, but they are not yet sent through the WebSocket or passed to the model. The planned work covers: base64 serialization of images in the socket payload, multimodal content format in `RuntimeClient` (`image_url` blocks alongside text), backend state propagation through `AgentState` and `RuntimeContext`, and conditional injection of the vision rule block into the brain prompt when images are present. Non-image files will be metadata-only on first iteration. -**Think-block rule citation highlighting.** After a thinking block completes, the UI scans the text for phrases matching known injected prompt sections (core identity, mode rules, runtime memory, session memory). Matched fragments are highlighted with a soft color; hovering shows a tooltip with the source name, type, layer, and full rule text. This makes prompt influence visible during debugging without affecting the streaming display. +**Pending facts and open loops.** A lightweight `pending_fact` key in L1 memory that tracks unresolved external outcomes — moderation status, waiting for a reply, a deployment in progress. Each entry carries a trusted timestamp. When the pending outcome is older than roughly one day and still open, the brain may tactfully surface it. On resolution the entry is renamed to `resolved_fact` and moved to archive memory. + +**Brain fallback on low repair score.** When a service-model code/diff attempt scores below a configurable threshold (default 50), the next repair attempt is routed to the brain model with a clean snapshot containing only the original task, the current file state, the failed patch, and the exact error. The brain model does not receive the previous model's reasoning chain. **Memory event temperature.** Episodic key moments saved via `REMEMBER_EVENT` will carry emotional temperature (`positive`, `negative`, `mixed`), intensity, and initiator (`user_marked` or `jin_detected`). The service model infers these fields from natural language rather than requiring the user to fill a form. JIN may occasionally propose saving a moment it judges significant, but does so rarely. diff --git a/agent/nodes/brain.py b/agent/nodes/brain.py index 14b0fdde..893a0a12 100644 --- a/agent/nodes/brain.py +++ b/agent/nodes/brain.py @@ -49,10 +49,21 @@ async def run_brain_stream( system_prompt: str, brain_payload: str, runtime_actions: dict, + emit_content_to_chat: bool = True, ) -> tuple[str, str]: logger = context.logger + context_snapshot = { + "context_role": "brain", + "system_prompt": system_prompt, + "user_prompt": brain_payload, + } + + state.visible_response_context = ( + context_snapshot + ) + runtime = RuntimeStream( context=context, runtime_id=( @@ -75,11 +86,9 @@ async def run_brain_stream( ], ), enable_validator=True, - context_snapshot={ - "context_role": "brain", - "system_prompt": system_prompt, - "user_prompt": brain_payload, - }, + emit_to_chat=True, + emit_content_to_chat=emit_content_to_chat, + context_snapshot=context_snapshot, ) generator = ask_brain_stream( @@ -112,6 +121,10 @@ async def run( get_brain_runtime_config() ) + state.visible_response_role = ( + brain_runtime["label"] + ) + brain_client = ( context.clients[ brain_runtime["label"] @@ -158,6 +171,9 @@ async def run( system_prompt=system_prompt, brain_payload=brain_payload, runtime_actions=runtime_actions, + emit_content_to_chat=( + not state.translate_response + ), ) record_deep_thought_calls( @@ -243,6 +259,9 @@ async def run( system_prompt=followup_system_prompt, brain_payload=followup_payload, runtime_actions=followup_runtime_actions, + emit_content_to_chat=( + not state.translate_response + ), ) record_deep_thought_calls( diff --git a/agent/nodes/planner.py b/agent/nodes/planner.py index a98f8a8b..a669e3ad 100644 --- a/agent/nodes/planner.py +++ b/agent/nodes/planner.py @@ -7,6 +7,7 @@ config, ) + class PlannerNode(BaseNode): async def run( @@ -15,35 +16,42 @@ async def run( context, ): - if not getattr(config, "TRANSLATION_ENABLED", False): - state.current_plan = [ - "brain", - "validator", - ] - - state.translate_input = False - state.translated_input = state.user_input - - - return - - state.translate_input = contains_cyrillic( - state.user_input + translation_enabled = getattr( + config, + "TRANSLATION_ENABLED", + False, + ) + translate_response_enabled = getattr( + config, + "TRANSLATE_RESPONSE", + False, ) - if state.translate_input: - - state.current_plan = [ - "translator", - "brain", - "validator", - ] + state.translate_input = ( + translation_enabled + and contains_cyrillic( + state.user_input + ) + ) + state.translate_response = ( + state.translate_input + and translate_response_enabled + ) + state.translated_input = state.user_input - return + state.current_plan = [] - state.translated_input = state.user_input + if state.translate_input: + state.current_plan.append( + "translator" + ) - state.current_plan = [ + state.current_plan.extend([ "brain", "validator", - ] + ]) + + if state.translate_response: + state.current_plan.append( + "translator" + ) diff --git a/agent/nodes/translation.py b/agent/nodes/translation.py index ced480df..aae9d7af 100644 --- a/agent/nodes/translation.py +++ b/agent/nodes/translation.py @@ -1,3 +1,6 @@ +import asyncio +import uuid + from agent.nodes.base import BaseNode from clients.translation_client import ( @@ -15,13 +18,159 @@ class TranslationNode(BaseNode): - async def run( - self, + @staticmethod + def _unpack_translation_result( + translated, + ) -> tuple[str, dict]: + + if isinstance( + translated, + str, + ): + return ( + translated, + {}, + ) + + return ( + translated.get( + "content", + "", + ), + translated.get( + "usage", + {}, + ), + ) + + @staticmethod + def _record_usage( + context, + usage: dict, + ): + + if not usage: + return + + record_token_usage( + context, + runtime_id=( + settings.TRANSLATOR_MODEL_UID + ), + role="translator", + kind="service", + prompt_tokens=( + usage.get( + "prompt_tokens", + 0, + ) + ), + completion_tokens=( + usage.get( + "completion_tokens", + 0, + ) + ), + total_tokens=( + usage.get( + "total_tokens", + 0, + ) + ), + ) + + + @staticmethod + def _split_visible_chunks( + text: str, + *, + max_chars: int = 64, + ): + + buffer = "" + + for part in text.split( + " ", + ): + + next_part = ( + part + if not buffer + else f" {part}" + ) + + if ( + buffer + and len(buffer) + len(next_part) > max_chars + ): + yield buffer + buffer = part + continue + + buffer += next_part + + if buffer: + yield buffer + + @staticmethod + async def _emit_final_answer( state, context, + text: str, ): - state.iteration += 1 + message = ( + text + or "" + ).strip() + + if not message: + return + + message_id = str( + uuid.uuid4() + ) + + payload = { + "type": "message_start", + "message_id": message_id, + "role": ( + state.visible_response_role + or "brain" + ), + } + + if state.visible_response_context: + payload["context"] = ( + state.visible_response_context + ) + + await context.websocket.send_json( + payload + ) + + for chunk in TranslationNode._split_visible_chunks( + message, + ): + await context.websocket.send_json({ + "type": "message_chunk", + "message_id": message_id, + "chunk": chunk, + }) + await asyncio.sleep( + 0.01 + ) + + await context.websocket.send_json({ + "type": "message_end", + "message_id": message_id, + }) + + async def _translate_input( + self, + state, + context, + ): translated = await translate( context=context, @@ -30,43 +179,97 @@ async def run( target_language="English", ) - if isinstance(translated, str): - translated_text = translated - usage = {} - else: - translated_text = translated["content"] - usage = translated.get("usage", {}) + translated_text, usage = ( + self._unpack_translation_result( + translated + ) + ) + + translated_text = ( + translated_text + or state.user_input + ) await context.logger.log_translation( translated_text ) - if usage: - record_token_usage( + self._record_usage( + context, + usage, + ) + + state.translated_input = translated_text + + async def _translate_response( + self, + state, + context, + ): + + response = ( + state.final_answer + or state.brain_response + or "" + ).strip() + + if not response: + return + + translated = await translate( + context=context, + text=response, + source_language="English", + target_language="Russian", + ) + + translated_text, usage = ( + self._unpack_translation_result( + translated + ) + ) + + translated_text = ( + translated_text + or response + ) + + await context.logger.log_translation( + translated_text + ) + + self._record_usage( + context, + usage, + ) + + state.final_answer = translated_text + context.runtime_turn_assistant_response = ( + translated_text + ) + + await self._emit_final_answer( + state, + context, + translated_text, + ) + + async def run( + self, + state, + context, + ): + + state.iteration += 1 + + if state.translate_response and state.final_answer: + await self._translate_response( + state, context, - runtime_id=( - settings.TRANSLATOR_MODEL_UID - ), - role="translator", - kind="service", - prompt_tokens=( - usage.get( - "prompt_tokens", - 0, - ) - ), - completion_tokens=( - usage.get( - "completion_tokens", - 0, - ) - ), - total_tokens=( - usage.get( - "total_tokens", - 0, - ) - ), ) + return - state.translated_input = translated_text + await self._translate_input( + state, + context, + ) diff --git a/agent/nodes/validation.py b/agent/nodes/validation.py index 02d3bc3d..9d615d0d 100644 --- a/agent/nodes/validation.py +++ b/agent/nodes/validation.py @@ -1,13 +1,5 @@ from agent.nodes.base import BaseNode -from runtime.stream import ( - RuntimeStream, -) - -from app_settings import ( - settings, -) - class ValidationNode(BaseNode): @@ -36,88 +28,4 @@ async def run( return - # --------------------------------------------------------- - # NO RESPONSE TRANSLATION - # --------------------------------------------------------- - - if not state.translate_response: - - state.final_answer = response - - return - - # --------------------------------------------------------- - # RESPONSE TRANSLATION - # --------------------------------------------------------- - - prompt = f""" -Переведи текст на русский. - -Верни ТОЛЬКО итоговый перевод. -Без объяснений. -Без markdown. -Без reasoning. - -TEXT: -{response} -""" - - generator = ( - context.clients["service"] - .stream( - context=context, - system_prompt=""" -Ты translation engine. - -Запрещено: -- reasoning -- chain of thought -- explanations -- markdown -- analysis - -Верни только итоговый перевод. -""", - user_prompt=prompt, - temperature=0.2, - max_tokens=1200, - ) - ) - - stream = RuntimeStream( - context=context, - runtime_id=( - settings.SERVICE_MODEL_UID - ), - role="service", - context_window=( - settings.SERVICE_CONTEXT_WINDOW - ), - log_method=( - context.logger - .log_service - ), - ) - - translated_response = ( - await stream.run( - generator - ) - ) - - translated_response = ( - translated_response - .strip() - ) - - # --------------------------------------------------------- - # FALLBACK - # --------------------------------------------------------- - - if not translated_response: - - translated_response = response - - state.final_answer = ( - translated_response - ) \ No newline at end of file + state.final_answer = response diff --git a/agent/router.py b/agent/router.py index 3fff770c..cb9f06ec 100644 --- a/agent/router.py +++ b/agent/router.py @@ -1,7 +1,10 @@ class Router: - @staticmethod + PLAN_INDEX_KEY = "current_plan_index" + + @classmethod def next( + cls, state, current, ): @@ -10,22 +13,45 @@ def next( if not state.current_plan: return "END" + state.metadata[cls.PLAN_INDEX_KEY] = 0 + return state.current_plan[0] - if current in state.current_plan: + current_index = state.metadata.get( + cls.PLAN_INDEX_KEY + ) + + if ( + not isinstance( + current_index, + int, + ) + or current_index >= len( + state.current_plan + ) + or state.current_plan[current_index] != current + ): + if current not in state.current_plan: + return "END" + current_index = state.current_plan.index( current ) - next_index = current_index + 1 + next_index = current_index + 1 - if next_index >= len( - state.current_plan - ): - return "END" + if next_index >= len( + state.current_plan + ): + state.metadata.pop( + cls.PLAN_INDEX_KEY, + None, + ) + + return "END" - return state.current_plan[ - next_index - ] + state.metadata[cls.PLAN_INDEX_KEY] = next_index - return "END" + return state.current_plan[ + next_index + ] diff --git a/agent/state.py b/agent/state.py index d6cc9f6c..bdb88052 100644 --- a/agent/state.py +++ b/agent/state.py @@ -34,3 +34,7 @@ class AgentState: metadata: dict = field(default_factory=dict) final_answer: str = "" + + visible_response_role: str = "" + + visible_response_context: dict = field(default_factory=dict) diff --git a/bootstrap/brain_bootstrap.py b/bootstrap/brain_bootstrap.py index 20143914..8ac51884 100644 --- a/bootstrap/brain_bootstrap.py +++ b/bootstrap/brain_bootstrap.py @@ -1,10 +1,8 @@ from __future__ import annotations from runtime.context_contract import ( - DEEP_THOUGHT_REQUEST, REMEMBER_EVENT_REQUEST, REMEMBER_SESSION_REQUEST, - RUNTIME_ACTION_DEEP_THOUGHT, RUNTIME_ACTION_REMEMBER_EVENT, RUNTIME_ACTION_REMEMBER_SESSION, RUNTIME_ACTION_WEB_SEARCH, @@ -113,22 +111,13 @@ def build_runtime_action_instructions(enabled_actions: tuple[str, ...]) -> str: "When requesting a runtime action, output exactly one private marker on its own line. " "Do not wrap it in markdown. Do not put it inside a bullet list. Do not bold it. " "Do not describe it in prose. " - f"Allowed private markers are exactly: {DEEP_THOUGHT_REQUEST}, " - f"{REMEMBER_SESSION_REQUEST}, {REMEMBER_EVENT_REQUEST}, " - f"and {WEB_SEARCH_REQUEST_TEMPLATE}. " + f"Allowed private markers are exactly: {REMEMBER_SESSION_REQUEST}, " + f"{REMEMBER_EVENT_REQUEST}, and {WEB_SEARCH_REQUEST_TEMPLATE}. " "The runtime removes private markers before rendering visible answers. " "Do not write INTERNAL_ACTION: WEB_SEARCH query: ..., INTERNAL ACTION: ..., " "WEB_SEARCH query: ..., JSON, or runtime XML markers." ] - if RUNTIME_ACTION_DEEP_THOUGHT in enabled_actions: - instructions.append( - "Before answering, request DEEP_THOUGHT once when the current request asks you to " - "think carefully/deeply, compare designs, make a multi-step judgment, " - "debug architecture, reflect on your own state, or handle high uncertainty. " - "Do not emit it for simple greetings, direct factual answers, or casual small talk. " - f"Use this private marker on its own line: {DEEP_THOUGHT_REQUEST}. Do not explain it." - ) if RUNTIME_ACTION_WEB_SEARCH in enabled_actions: instructions.append( @@ -155,7 +144,9 @@ def build_runtime_action_instructions(enabled_actions: tuple[str, ...]) -> str: f"request REMEMBER_SESSION once with this private marker: {REMEMBER_SESSION_REQUEST}. " f"Natural trigger examples: {remember_session_examples}. " "Do not emit it for ordinary topic changes, brief silence, casual thanks, " - "or while active implementation work is still clearly continuing. " + "bare ambiguous save commands, or while active implementation work is still clearly continuing. " + "If the whole user message is only 'сохрани' or 'save', ask one short clarification: " + "whether to save the whole session or a specific event/detail. Do not emit a runtime marker yet. " "Do not request it when the user asks to show, write, quote, or explain an internal tag. " "For tag/meta requests, answer naturally: internal tags are not shown; " "to save, the user can use a natural save/end request. " @@ -163,9 +154,17 @@ def build_runtime_action_instructions(enabled_actions: tuple[str, ...]) -> str: ) if RUNTIME_ACTION_REMEMBER_EVENT in enabled_actions: + remember_event_examples = ", ".join( + f"'{trigger}'" + for trigger in get_action_guard_triggers( + "remember_event" + ) + ) + instructions.append( "When the user explicitly marks the current moment/event as worth saving, " f"request REMEMBER_EVENT once with this private marker: {REMEMBER_EVENT_REQUEST}. " + f"Natural trigger examples: {remember_event_examples}. " "JIN may also emit it on its own only for rare high-signal events: " "major decision, strong insight, memorable emotional moment, " "or a correction that changes understanding of JIN, user, or system. " @@ -186,11 +185,6 @@ def build_runtime_state_instructions(enabled_actions: tuple[str, ...]) -> str: "only trust the values provided in trusted runtime context." ] - if RUNTIME_ACTION_DEEP_THOUGHT in enabled_actions: - instructions.append( - "DEEP_THOUGHT_COUNTER is telemetry from earlier runtime actions; " - "it must not by itself trigger or forbid a new runtime action." - ) return " ".join(instructions) @@ -258,7 +252,7 @@ def build_zero_diff_stall_instruction() -> str: # ───────────────────────────────────────────── -# Compatibility wrappers for old call sites +# Rule accessors used by brain clients # ───────────────────────────────────────────── def build_brain_soft_success_rules() -> str: diff --git a/clients/brain_client.py b/clients/brain_client.py index fe35f4ae..b3673538 100644 --- a/clients/brain_client.py +++ b/clients/brain_client.py @@ -1,1660 +1,59 @@ import asyncio -import json -import re -from datetime import datetime -from xml.etree import ElementTree -from xml.sax.saxutils import escape from config_loader import ( config, ) from runtime.context_contract import ( - ContextContract, - RUNTIME_ACTION_DEEP_THOUGHT, - RUNTIME_ACTION_REMEMBER_EVENT, - RUNTIME_ACTION_REMEMBER_SESSION, RUNTIME_ACTION_WEB_SEARCH, ) -from runtime.behavior_contract import ( - should_execute_action_guard, -) -from runtime.memory_rules import ( - build_runtime_memory_context_text, - build_runtime_session_event_snapshot, - canonicalize_runtime_memory_text, -) - -from bootstrap.brain_bootstrap import ( - ZERO_DIFF_STALL_ACTIVE_RULE, - build_brain_runtime_interface_rules, - build_brain_soft_success_rules, - build_conversation_activity_instruction, - build_identity_context, - build_zero_diff_stall_instruction, - get_image_input_rules, - get_last_jin_response_rules, - get_loop_rules, - get_memory_rules, - get_philosophy_mode, - MEMORY_REQUEST_MARKERS, - PHILOSOPHY_MARKERS, - MEDIA_CONTEXT_ATTRS, -) - -from clients.errors import ( - format_client_error, -) - -from clients.service_client import ( - ask_service_model, - ask_service_model_stream, -) - -from clients.response_extractor import ( - ResponseExtractor, -) - -from utils.runtime_actions import ( - RuntimeActionStreamFilter, - build_runtime_action_id, - extract_search_query, - extract_runtime_actions, -) - - -# --------------------------------------------------------- -# SYSTEM PROMPT -# --------------------------------------------------------- - -MAX_PREVIOUS_THINK_CHARS = 9000 -MAX_PREVIOUS_THINK_SECTION_CHARS = 3000 - -PREVIOUS_THINK_SECTION_TITLES = ( - [ - "Analyze User Input", - "Analyze the User Input", - "Analyze Intent", - "Analyze the Intent", - "Analyze Request", - "Analyze the Request", - "Determine Intent", - "Determine the Intent", - ], - [ - "Check Context/State", - "Check State", - "Check Context", - "Check Memory/Context", - "Review Context/Memory", - "Review Memory", - "Review Context", - ], - [ - "Check last_jin_response", - ], - [ - "Formulate Response", - "Formulate the Response", - "Final Output Generation", - "Final Polish", - ], -) - -PREVIOUS_THINK_SECTION_RE = re.compile( - r"(?m)^\s*\d+\.\s+(?P.+)$" -) - - -def normalize_previous_think_section_title( - title: str, -) -> str: - - cleaned = ( - title - .strip() - .replace( - "**", - "", - ) - .replace( - "`", - "", - ) - .strip() - ) - - cleaned = re.sub( - r"(?i)\b(Check\s+)[\"'](last_jin_response)[\"']", - r"\1\2", - cleaned, - ) - - return cleaned.strip() - - -def match_previous_think_section_title( - title: str, -) -> str: - - normalized = normalize_previous_think_section_title( - title - ) - - normalized_lower = normalized.lower() - - for configured_title in PREVIOUS_THINK_SECTION_TITLES: - - if isinstance( - configured_title, - str, - ): - aliases = [ - configured_title, - ] - elif isinstance( - configured_title, - ( - list, - tuple, - ), - ): - aliases = [ - alias - for alias in configured_title - if isinstance( - alias, - str, - ) - ] - else: - aliases = [] - - if not aliases: - continue - - canonical_title = aliases[0] - - for alias in aliases: - - alias_lower = ( - normalize_previous_think_section_title( - alias - ) - .lower() - ) - - if not alias_lower: - continue - - if ( - normalized_lower == alias_lower - or normalized_lower.startswith( - f"{alias_lower}:" - ) - ): - return canonical_title - - return "" - - -def clean_previous_think_section_text( - section_text: str, -) -> str: - - lines = section_text.splitlines() - - if not lines: - return "" - - heading_match = PREVIOUS_THINK_SECTION_RE.match( - lines[0] - ) - - if not heading_match: - cleaned_lines = lines - else: - heading = normalize_previous_think_section_title( - heading_match.group( - "title" - ) - ) - - cleaned_lines = [ - heading, - *lines[1:], - ] - - cleaned = "\n".join( - cleaned_lines - ) - - cleaned = cleaned.replace( - "**", - "", - ) - - return cleaned.strip() - - -def trim_previous_think_section_text( - section_text: str, -) -> tuple[str, bool]: - - trimmed = ( - len( - section_text - ) - > MAX_PREVIOUS_THINK_SECTION_CHARS - ) - - if not trimmed: - return ( - section_text, - False, - ) - - return ( - section_text[ - :MAX_PREVIOUS_THINK_SECTION_CHARS - ].rstrip(), - True, - ) - - -def extract_previous_think_tail( - text: str, -) -> dict: - - if not isinstance( - text, - str, - ): - text = "" - - sections = [] - sections_found = [] - section_trimmed = False - matches = list( - PREVIOUS_THINK_SECTION_RE.finditer( - text - ) - ) - - for index, match in enumerate( - matches - ): - - section_title = match_previous_think_section_title( - match.group( - "title" - ) - ) - - if not section_title: - continue - - next_match = ( - matches[index + 1] - if index + 1 < len(matches) - else None - ) - - section_text = text[ - match.start(): ( - next_match.start() - if next_match - else len(text) - ) - ].strip() - - section_text = clean_previous_think_section_text( - section_text - ) - - if not section_text: - continue - - section_text, was_section_trimmed = ( - trim_previous_think_section_text( - section_text - ) - ) - section_trimmed = ( - section_trimmed - or was_section_trimmed - ) - - sections.append( - section_text - ) - sections_found.append( - section_title - ) - - extracted = "\n\n".join( - sections - ).strip() - - total_trimmed = ( - len( - extracted - ) - > MAX_PREVIOUS_THINK_CHARS - ) - - if total_trimmed: - extracted = extracted[ - :MAX_PREVIOUS_THINK_CHARS - ].rstrip() - - trimmed = ( - section_trimmed - or total_trimmed - ) - - return { - "text": extracted, - "sections_found": sections_found, - "chars": len( - extracted - ), - "trimmed": trimmed, - } - - -def build_previous_think_block( - text: str, -) -> tuple[str, dict]: - - extracted = extract_previous_think_tail( - text - ) - - metadata = { - "previous_think_appended": bool( - extracted["text"] - ), - "previous_think_sections_found": ( - extracted["sections_found"] - ), - "previous_think_chars": extracted["chars"], - "previous_think_trimmed": extracted["trimmed"], - } - - if not extracted["text"]: - return ( - "", - metadata, - ) - - return ( - "\n".join([ - "<LOW_PRIORITY_PREVIOUS_THINK>", - extracted["text"], - "</LOW_PRIORITY_PREVIOUS_THINK>", - ]), - metadata, - ) - - -def get_previous_think_context_block( - context=None, -) -> str: - - previous_think = getattr( - context, - "runtime_previous_think_raw", - "", - ) - - block, metadata = build_previous_think_block( - previous_think - ) - - if context is not None: - context.runtime_previous_think_payload_log = metadata - - return block - - -def record_previous_think( - context, - reasoning: str, -): - - if context is None: - return - - if not isinstance( - reasoning, - str, - ): - reasoning = "" - - context.runtime_previous_think_raw = reasoning - - -async def log_previous_think_payload( - context, -): - - metadata = getattr( - context, - "runtime_previous_think_payload_log", - None, - ) - - if not isinstance( - metadata, - dict, - ): - return - - logger = getattr( - context, - "logger", - None, - ) - log = getattr( - logger, - "log", - None, - ) - - if not callable( - log - ): - return - - try: - await log( - "[PAYLOAD]", - "previous think continuity", - **metadata, - ) - except TypeError: - await log( - "[PAYLOAD]", - ( - "previous think continuity " - f"{json.dumps(metadata, ensure_ascii=False)}" - ), - ) - -def get_enabled_runtime_actions( - runtime_actions=None, -) -> tuple[str, ...]: - - enabled_actions = [] - - action_flags = runtime_actions or {} - - for action_name, config_key in ( - ( - RUNTIME_ACTION_DEEP_THOUGHT, - "CAN_DEEP_THOUGHT", - ), - ( - RUNTIME_ACTION_WEB_SEARCH, - "CAN_WEB_SEARCH", - ), - ( - RUNTIME_ACTION_REMEMBER_SESSION, - "CAN_REMEMBER_SESSION", - ), - ( - RUNTIME_ACTION_REMEMBER_EVENT, - "CAN_REMEMBER_EVENT", - ), - ): - - if bool( - action_flags.get( - config_key, - False, - ) - ): - enabled_actions.append( - action_name - ) - - return tuple( - enabled_actions - ) - - -def get_enabled_thinking_runtime_actions( - runtime_actions=None, -) -> tuple[str, ...]: - - return get_enabled_runtime_actions( - runtime_actions - ) - - -def count_deep_thought_calls( - text: str, - runtime_actions=None, -) -> int: - - return ( - extract_runtime_actions( - text, - enabled_actions=( - get_enabled_runtime_actions( - runtime_actions - ) - ), - ) - .deep_thought_count - ) - - -async def apply_deep_thought_calls( - context, - call_count: int, -) -> int: - - if ( - context is None - or not call_count - ): - return 0 - - current_count = getattr( - context, - "deep_thought_count", - 0, - ) - - context.deep_thought_count = ( - current_count - + call_count - ) - - logger = getattr( - context, - "logger", - None, - ) - log_runtime = getattr( - logger, - "log_runtime", - None, - ) - - if log_runtime is not None: - await log_runtime( - "[RUNTIME ACTION] " - f"deep_thought x{call_count}; " - f"counter={context.deep_thought_count}" - ) - - return call_count - - -def should_execute_remember_session( - user_message: str, -) -> bool: - return should_execute_action_guard( - "remember_session", - user_message - ) - - -def resolve_runtime_action_user_message( - context, - user_message: str | None = None, -) -> str: - - if user_message: - return user_message - - if context is None: - return "" - - for attr_name in ( - "runtime_turn_user_message", - "original_user_input", - "user_input", - ): - - value = getattr( - context, - attr_name, - "", - ) - - if value: - return value - - return "" - - -async def apply_runtime_action_calls( - context, - actions, - user_message: str | None = None, -) -> int: - - if ( - context is None - or not actions - ): - return 0 - - if not hasattr( - context, - "runtime_action_events", - ): - context.runtime_action_events = [] - - if not hasattr( - context, - "runtime_search_calls", - ): - context.runtime_search_calls = [] - - search_action_count = sum( - 1 - for event in context.runtime_action_events - if event.get("name") == "web_search" - ) - - search_calls = [] - filtered_actions = [] - search_query_seen = False - remember_session_seen = bool( - getattr( - context, - "runtime_remember_session_requested", - False, - ) - ) - remember_event_seen = False - deep_thought_seen = False - resolved_user_message = resolve_runtime_action_user_message( - context, - user_message, - ) - - for action in actions: - - if action.name == RUNTIME_ACTION_DEEP_THOUGHT: - if deep_thought_seen: - continue - - deep_thought_seen = True - filtered_actions.append( - action - ) - continue - - if action.name == RUNTIME_ACTION_REMEMBER_SESSION: - if not should_execute_remember_session( - resolved_user_message - ): - continue - - if remember_session_seen: - continue - - remember_session_seen = True - filtered_actions.append( - action - ) - continue - - if action.name == RUNTIME_ACTION_REMEMBER_EVENT: - if remember_event_seen: - continue - - remember_event_seen = True - filtered_actions.append( - action - ) - continue - - if action.name == RUNTIME_ACTION_WEB_SEARCH: - query = extract_search_query( - action.payload - ) - - if ( - not query - or search_query_seen - or getattr( - context, - "runtime_search_queries", - [], - ) - ): - continue - - search_query_seen = True - - filtered_actions.append( - action - ) - - if not filtered_actions: - return 0 - - for action in filtered_actions: - - action_event = { - "name": action.name.lower(), - } - - query = "" - - if action.name == RUNTIME_ACTION_WEB_SEARCH: - query = extract_search_query( - action.payload - ) - - if query: - search_action_count += 1 - tool_call_id = build_runtime_action_id( - action.name, - search_action_count, - ) - action_event["id"] = tool_call_id - action_event["query"] = query - search_calls.append({ - "id": tool_call_id, - "query": query, - }) - - elif action.payload: - action_event["payload"] = ( - action.payload - ) - - context.runtime_action_events.append( - action_event - ) - - deep_thought_count = sum( - 1 - for action in filtered_actions - if action.name == RUNTIME_ACTION_DEEP_THOUGHT - ) - - remember_session_count = sum( - 1 - for action in filtered_actions - if action.name == RUNTIME_ACTION_REMEMBER_SESSION - ) - - remember_event_count = sum( - 1 - for action in filtered_actions - if action.name == RUNTIME_ACTION_REMEMBER_EVENT - ) - - applied_count = await apply_deep_thought_calls( - context, - min( - deep_thought_count, - 1, - ), - ) - - search_queries = [ - query - for query in ( - extract_search_query( - action.payload - ) - for action in filtered_actions - if action.name == RUNTIME_ACTION_WEB_SEARCH - ) - if query - ] - - if search_queries: - if not hasattr( - context, - "runtime_search_queries", - ): - context.runtime_search_queries = [] - - context.runtime_search_queries.extend( - search_queries - ) - - context.runtime_search_calls.extend( - search_calls - ) - - logger = getattr( - context, - "logger", - None, - ) - log_runtime = getattr( - logger, - "log_runtime", - None, - ) - - if ( - log_runtime is not None - and search_queries - ): - await log_runtime( - "[RUNTIME ACTION] " - f"search x{len(search_queries)}" - ) - - if remember_session_count: - context.runtime_remember_session_requested = True - - if log_runtime is not None: - await log_runtime( - "[RUNTIME ACTION] remember_session requested" - ) - - emitter = getattr( - context, - "emitter", - None, - ) - emit = getattr( - emitter, - "emit", - None, - ) - - if emit is not None: - await emit({ - "type": "runtime_action", - "action": "remember_session", - "text": "Remembering this session", - }) - - if remember_event_count: - if not hasattr( - context, - "runtime_session_event_snapshots", - ): - context.runtime_session_event_snapshots = [] - - context.runtime_session_event_snapshots.append( - build_runtime_session_event_snapshot( - context, - source="runtime_action", - ) - ) - - if log_runtime is not None: - await log_runtime( - "[RUNTIME ACTION] remember_event saved" - ) - - emitter = getattr( - context, - "emitter", - None, - ) - emit = getattr( - emitter, - "emit", - None, - ) - - if emit is not None: - session_memory = ( - getattr( - context, - "runtime_l3_session_memory", - "", - ) - or getattr( - context, - "session_memory", - "", - ) - ) - await emit({ - "type": "runtime_action", - "action": "remember_event", - "text": "Remembering this event", - }) - await emit({ - "type": "runtime_session_memory_update", - "memory": session_memory, - "event_snapshots": list( - context.runtime_session_event_snapshots - ), - "updates": getattr( - context, - "runtime_session_memory_updates", - 0, - ), - "source": getattr( - context, - "session_memory_source", - "", - ), - "persist": True, - }) - - return applied_count + len( - search_queries - ) + min( - remember_session_count, - 1, - ) + min( - remember_event_count, - 1, - ) - - -def record_deep_thought_calls( - context, - reasoning: str, -) -> int: - - call_count = count_deep_thought_calls( - reasoning - ) - - if not call_count: - return 0 - - call_count = min( - call_count, - 1, - ) - - current_count = getattr( - context, - "deep_thought_count", - 0, - ) - - context.deep_thought_count = ( - current_count - + call_count - ) - - return call_count - - -def indent_xml( - value: str, - *, - spaces: int = 8, -) -> str: - - prefix = " " * spaces - lines = ( - value - or "" - ).strip().splitlines() - - return "\n".join( - f"{prefix}{line}" - for line in lines - ) - - -def strip_empty_results_xml( - value: str, -) -> str: - - source = ( - value - or "" - ).strip() - - if not source: - return "" - - try: - root = ElementTree.fromstring( - source - ) - - except ElementTree.ParseError: - return source - - def prune_empty_results( - element, - ) -> None: - - for child in list( - element - ): - prune_empty_results( - child - ) - - if child.tag != "RESULTS": - continue - - if list( - child - ): - continue - - if ( - child.text - and child.text.strip() - ): - continue - - element.remove( - child - ) - - prune_empty_results( - root - ) - - return ElementTree.tostring( - root, - encoding="unicode", - short_empty_elements=False, - ) - - -def get_conversation_activity_diff( - context=None, -) -> float | None: - - if context is None: - return None - - recorded_diff = getattr( - context, - "runtime_conversation_activity_diff", - None, - ) - - if recorded_diff is not None: - try: - return float( - recorded_diff - ) - except ( - TypeError, - ValueError, - ): - pass - - patch_sources = ( - getattr( - context, - "runtime_l2_pending_patches", - None, - ) - or getattr( - context, - "runtime_memory_snapshots", - None, - ) - or [] - ) - - for patch in reversed( - patch_sources - ): - - if not isinstance( - patch, - dict, - ): - continue - - total_diff = patch.get( - "total_diff", - ) - - if total_diff is None: - continue - - try: - return float( - total_diff - ) - except ( - TypeError, - ValueError, - ): - continue - - return None - - -def get_conversation_activity_percent( - diff: float, -) -> int: - - return max( - 0, - min( - 100, - int( - round( - diff - ) - ), - ), - ) - - -def build_brain_runtime_context( - context=None, - runtime_actions=None, -) -> str: - - deep_thought_count = 0 - enabled_actions = get_enabled_runtime_actions( - runtime_actions - ) - - if context is not None: - - deep_thought_count = getattr( - context, - "deep_thought_count", - 0, - ) - - now = datetime.now() - - context_contract = ContextContract( - user_input="", - compressed_history="", - system_state="ACTIVE", - deep_thought_count=deep_thought_count, - can_deep_thought=( - RUNTIME_ACTION_DEEP_THOUGHT - in enabled_actions - ), - can_web_search=( - RUNTIME_ACTION_WEB_SEARCH - in enabled_actions - ), - can_remember_session=( - RUNTIME_ACTION_REMEMBER_SESSION - in enabled_actions - ), - can_remember_event=( - RUNTIME_ACTION_REMEMBER_EVENT - in enabled_actions - ), - timestamp=now.isoformat(), - current_date=now.date().isoformat(), - current_time=now.strftime("%H:%M:%S"), - weekday=now.strftime("%A"), - year=now.year, - ) - - runtime_xml = ( - context_contract - .to_runtime_xml() - ) - - session_state_xml = "" - - if context is not None: - session_state_xml = ( - "<SESSION_STATE>\n" - f" <TURN_NUMBER>{getattr(context, 'turn_number', 0)}</TURN_NUMBER>\n" - f" <USER_MESSAGE_COUNT>{getattr(context, 'user_message_count', 0)}</USER_MESSAGE_COUNT>\n" - f" <ASSISTANT_MESSAGE_COUNT>{getattr(context, 'assistant_message_count', 0)}</ASSISTANT_MESSAGE_COUNT>\n" - "</SESSION_STATE>" - ) - - runtime_memory = "" - session_memory = "" - session_event_snapshots = [] - runtime_l2_memory = "" - zero_diff_alert = None - conversation_activity_diff = get_conversation_activity_diff( - context - ) - search_result = "" - search_result_id = "" - - if context is not None: - runtime_memory = getattr( - context, - "runtime_memory", - "", - ) - runtime_memory = build_runtime_memory_context_text( - runtime_memory, - context, - ) - session_memory = getattr( - context, - "runtime_l3_session_memory", - "", - ) or getattr( - context, - "session_memory", - "", - ) - session_event_snapshots = list( - getattr( - context, - "runtime_session_event_snapshots", - [], - ) - or [] - ) - runtime_l2_memory = getattr( - context, - "runtime_l2_memory", - "", - ) - zero_diff_alert = getattr( - context, - "runtime_zero_diff_alert", - None, - ) - search_result = getattr( - context, - "runtime_search_result", - "", - ) - search_result_id = getattr( - context, - "runtime_search_result_id", - "", - ) - - runtime_context_parts = [ - runtime_xml - ] - - if session_state_xml: - runtime_context_parts.append( - session_state_xml - ) - - if session_memory.strip(): - runtime_context_parts.append( - "<SESSION_MEMORY priority=\"higher_than_runtime_memory\">\n" - f"{indent_xml(escape(session_memory))}\n" - "</SESSION_MEMORY>" - ) - - runtime_context_parts.append( - "<SESSION_EVENT_SNAPSHOTS priority=\"session_context\">\n" - f"{indent_xml(escape(json.dumps(session_event_snapshots, ensure_ascii=False, indent=2)))}\n" - "</SESSION_EVENT_SNAPSHOTS>" - ) - - if runtime_memory.strip(): - runtime_context_parts.append( - "<RUNTIME_MEMORY>\n" - f"{indent_xml(escape(canonicalize_runtime_memory_text(runtime_memory)))}\n" - "</RUNTIME_MEMORY>" - ) - - if runtime_l2_memory.strip(): - runtime_context_parts.append( - "<RUNTIME_PATTERN_MEMORY>\n" - f"{indent_xml(escape(runtime_l2_memory))}\n" - "</RUNTIME_PATTERN_MEMORY>" - ) - - if conversation_activity_diff is not None: - activity_percent = get_conversation_activity_percent( - conversation_activity_diff - ) - activity_instruction = build_conversation_activity_instruction( - activity_percent - ) - - runtime_context_parts.append( - "<CONVERSATION_ACTIVITY>\n" - f" <PERCENT>{activity_percent}</PERCENT>\n" - " <INSTRUCTION>\n" - f"{indent_xml(escape(activity_instruction))}\n" - " </INSTRUCTION>\n" - "</CONVERSATION_ACTIVITY>" - ) - - previous_think_block = get_previous_think_context_block( - context - ) - - if previous_think_block: - runtime_context_parts.append( - previous_think_block - ) - - if zero_diff_alert: - alert_user_message = ( - zero_diff_alert.get( - "user_message", - "", - ) - if isinstance( - zero_diff_alert, - dict, - ) - else "" - ) - alert_assistant_message = ( - zero_diff_alert.get( - "assistant_message", - "", - ) - if isinstance( - zero_diff_alert, - dict, - ) - else "" - ) - alert_turn_number = ( - zero_diff_alert.get( - "turn_number", - 0, - ) - if isinstance( - zero_diff_alert, - dict, - ) - else 0 - ) - - runtime_context_parts.append( - "<ZERO_DIFF_STALL_ALERT>\n" - " <INSTRUCTION>\n" - f" {build_zero_diff_stall_instruction()}\n" - " </INSTRUCTION>\n" - f" <TRIGGER_TURN>{alert_turn_number}</TRIGGER_TURN>\n" - " <TRIGGER_USER_MESSAGE>\n" - f"{indent_xml(escape(alert_user_message))}\n" - " </TRIGGER_USER_MESSAGE>\n" - " <TRIGGER_JIN_RESPONSE>\n" - f"{indent_xml(escape(alert_assistant_message))}\n" - " </TRIGGER_JIN_RESPONSE>\n" - "</ZERO_DIFF_STALL_ALERT>" - ) - - if not search_result: - return "\n".join( - runtime_context_parts - ) - - search_result = strip_empty_results_xml( - search_result - ) - - tool_result_attrs = ( - 'name="WEB_SEARCH"' - ) - - if search_result_id: - tool_result_attrs = ( - f'{tool_result_attrs} ' - f'id="{escape(search_result_id)}"' - ) - - tool_results_xml = ( - '<TOOL_RESULTS type=\'external_untrusted_evidence\'>\n' - f" <TOOL_RESULT {tool_result_attrs}>\n" - f"{indent_xml(search_result)}\n" - " </TOOL_RESULT>\n" - "</TOOL_RESULTS>" - ) - - return ( - "\n".join( - runtime_context_parts - + [ - tool_results_xml - ] - ) - ) - - -def has_zero_diff_stall_alert( - context=None, -) -> bool: - - if context is None: - return False - - return bool( - getattr( - context, - "runtime_zero_diff_alert", - None, - ) - ) - -def _get_current_user_message( - context=None, -) -> str: - - if context is None: - return "" - - for attr_name in ( - "runtime_turn_user_message", - "original_user_input", - "user_input", - "last_user_message", - ): - - value = getattr( - context, - attr_name, - "", - ) - - if value: - return str( - value - ) - - return "" - - -def _normalized_text( - value: str, -) -> str: - - return ( - value - or "" - ).casefold().replace( - "ё", - "е", - ) - - -def _contains_any_marker( - text: str, - markers: tuple[str, ...], -) -> bool: - - normalized = _normalized_text( - text - ) - - return any( - _normalized_text( - marker - ) in normalized - for marker in markers - ) - - -def has_memory_rule_request( - context=None, -) -> bool: - - if context is None: - return False - - explicit_flag = getattr( - context, - "has_memory_request", - None, - ) - - if explicit_flag is not None: - return bool( - explicit_flag - ) - - user_message = _get_current_user_message( - context - ) - - return _contains_any_marker( - user_message, - MEMORY_REQUEST_MARKERS, - ) - - -def has_loop_rule_signal( - context=None, -) -> bool: - - if context is None: - return False - - pattern_counter = getattr( - context, - "runtime_pattern_counter", - None, - ) - - if pattern_counter is not None: - try: - return int( - pattern_counter - ) > 0 - except ( - TypeError, - ValueError, - ): - return False - - runtime_l2_memory = getattr( - context, - "runtime_l2_memory", - "", - ) - - return bool( - str( - runtime_l2_memory - or "" - ).strip() - ) - - -def has_media_context( - context=None, -) -> bool: - - if context is None: - return False - - explicit_flag = getattr( - context, - "has_media", - None, - ) - - if explicit_flag is not None: - return bool( - explicit_flag - ) - - for attr_name in MEDIA_CONTEXT_ATTRS: - value = getattr( - context, - attr_name, - None, - ) - - if value: - return True - - return False - - -def is_philosophy_mode_active( - context=None, -) -> bool: - - if context is None: - return False - - explicit_flag = getattr( - context, - "philosophy_active", - None, - ) - - if explicit_flag is not None: - return bool( - explicit_flag - ) - - user_message = _get_current_user_message( - context - ) - - return _contains_any_marker( - user_message, - PHILOSOPHY_MARKERS, - ) +from bootstrap.brain_bootstrap import ( + ZERO_DIFF_STALL_ACTIVE_RULE, + build_brain_runtime_interface_rules, + build_brain_soft_success_rules, + build_identity_context, + get_image_input_rules, +) +from clients.errors import ( + format_client_error, +) -def build_conditional_prompt_rules( - context=None, -) -> str: +from clients.brain_context_builder import ( + build_brain_runtime_context, +) - rules = [ - get_last_jin_response_rules(), - ] +from clients.brain_client_utils import ( + MAX_PREVIOUS_THINK_CHARS, + MAX_PREVIOUS_THINK_SECTION_CHARS, + apply_runtime_action_calls, + build_conditional_prompt_rules, + build_previous_think_block, + extract_previous_think_tail, + get_enabled_runtime_actions, + get_previous_think_context_block, + has_zero_diff_stall_alert, + is_user_initiated_remember_event, + log_previous_think_payload, + record_deep_thought_calls, + record_previous_think, + should_execute_remember_session, +) - if has_memory_rule_request( - context - ): - rules.append( - get_memory_rules() - ) +from clients.service_client import ( + ask_service_model, + ask_service_model_stream, +) - if has_loop_rule_signal( - context - ): - rules.append( - get_loop_rules() - ) +from clients.response_extractor import ( + ResponseExtractor, +) - if has_media_context( - context - ): - rules.append( - get_image_input_rules() - ) +from utils.runtime_actions import ( + RuntimeActionStreamFilter, + extract_runtime_actions, +) - if is_philosophy_mode_active( - context - ): - rules.append( - get_philosophy_mode() - ) - return "".join( - rules - ) def build_brain_system_prompt( @@ -1767,14 +166,11 @@ async def ask_brain( enabled_actions = get_enabled_runtime_actions( runtime_actions ) - thinking_actions = get_enabled_thinking_runtime_actions( - runtime_actions - ) reasoning_actions = ( extract_runtime_actions( reasoning, - enabled_actions=thinking_actions, + enabled_actions=enabled_actions, preserve_action_text=True, ) ) @@ -1873,13 +269,10 @@ async def ask_brain( enabled_actions = get_enabled_runtime_actions( runtime_actions ) - thinking_actions = get_enabled_thinking_runtime_actions( - runtime_actions - ) reasoning_actions = extract_runtime_actions( reasoning, - enabled_actions=thinking_actions, + enabled_actions=enabled_actions, preserve_action_text=True, ) @@ -1907,7 +300,7 @@ async def ask_brain( return extract_runtime_actions( reasoning, - enabled_actions=thinking_actions, + enabled_actions=enabled_actions, ).text except Exception as error: @@ -1959,25 +352,20 @@ async def ask_brain_stream( enabled_actions = get_enabled_runtime_actions( runtime_actions ) - thinking_actions = get_enabled_thinking_runtime_actions( - runtime_actions - ) thinking_filter = RuntimeActionStreamFilter( - enabled_actions=thinking_actions, + enabled_actions=enabled_actions, preserve_action_text=True, ) content_filter = RuntimeActionStreamFilter( enabled_actions=enabled_actions ) - deep_thought_action_executed = False stop_for_runtime_action = False async def filter_runtime_action_chunk( action_chunk, ): - nonlocal deep_thought_action_executed nonlocal stop_for_runtime_action chunk_type = action_chunk.get( @@ -2003,34 +391,20 @@ async def filter_runtime_action_chunk( ) ) - if ( - result.deep_thought_count - and not deep_thought_action_executed - ): - - deep_thought_action_executed = True - - await apply_deep_thought_calls( - context, - 1, - ) - - non_deep_actions = tuple( - action - for action in result.actions - if action.name != RUNTIME_ACTION_DEEP_THOUGHT + runtime_action_calls = tuple( + result.actions ) - if non_deep_actions: + if runtime_action_calls: await apply_runtime_action_calls( context, - non_deep_actions, + runtime_action_calls, user_message=text, ) stop_for_runtime_action = any( action.name == RUNTIME_ACTION_WEB_SEARCH - for action in non_deep_actions + for action in runtime_action_calls ) if not stop_for_runtime_action: diff --git a/clients/brain_client_utils.py b/clients/brain_client_utils.py new file mode 100644 index 00000000..c8548c7e --- /dev/null +++ b/clients/brain_client_utils.py @@ -0,0 +1,1307 @@ +import re +from xml.etree import ElementTree + +from bootstrap.brain_bootstrap import ( + get_image_input_rules, + get_last_jin_response_rules, + get_loop_rules, + get_memory_rules, + get_philosophy_mode, + MEDIA_CONTEXT_ATTRS, + MEMORY_REQUEST_MARKERS, + PHILOSOPHY_MARKERS, +) +from runtime.behavior_contract import ( + should_execute_action_guard, +) +from runtime.context_contract import ( + RUNTIME_ACTION_REMEMBER_EVENT, + RUNTIME_ACTION_REMEMBER_SESSION, + RUNTIME_ACTION_WEB_SEARCH, +) +from runtime.L3_memory_utils import ( + build_runtime_session_event_snapshot, +) +from utils.runtime_actions import ( + build_runtime_action_id, + extract_search_query, + extract_runtime_actions, +) + + +# --------------------------------------------------------- +# ACCUMULATE PREVIOUS THINK FIELDS +# --------------------------------------------------------- + +MAX_PREVIOUS_THINK_CHARS = 9000 +MAX_PREVIOUS_THINK_SECTION_CHARS = 3000 + +PREVIOUS_THINK_SECTION_TITLES = ( + [ + "Analyze User Input", + "Analyze the User Input", + "Analyze Intent", + "Analyze the Intent", + "Analyze Request", + "Analyze the Request", + "Determine Intent", + "Determine the Intent", + "Interpret User Input", + "Interpret the User Input", + "Identify Current Task", + "Identify the Current Task", + "Determine Goal", + "Determine the Goal", + ], + [ + "Check Context/State", + "Check State/Context", + "Check Memory/Context", + "Check Context/Memory", + "Check State", + "Check Memory", + "Check Context", + "Review Context/State", + "Review State/Context", + "Review Context/Memory", + "Review Memory/Context", + "Review State", + "Review Memory", + "Review Context", + "Analyze Context/State", + "Analyze State/Context", + "Analyze Context/Memory", + "Analyze Memory/Context", + "Analyze State", + "Analyze Memory", + "Analyze Context", + ], + [ + "Check last_jin_response", + ], + [ + "Formulate Response", + "Formulate the Response", + "Final Output Generation", + "Final Polish", + "Formulate Response (JIN Persona)", + "Execution" + ], +) + +PREVIOUS_THINK_SECTION_RE = re.compile( + r"(?m)^\s*\d+\.\s+(?P<title>.+)$" +) + + +COUNTDOWN_CONTRACT_RULES = ( + "Runtime countdown_contract is active.\n" + "Treat every countdown_contract line in RUNTIME_MEMORY as an active " + "user-facing obligation from creation until it is completed or cancelled.\n" + "Countdown contracts are turn-based or time-based obligations; use the " + "contract fields in RUNTIME_MEMORY as trusted state.\n" + "If countdown_contract status is active and remaining is greater than 0, " + "do not execute the trigger early, but preserve the obligation in your " + "response planning.\n" + "If countdown_contract status is due or remaining is 0, execute its trigger " + "in this answer as an actual direct user-facing action or question.\n" + "For due recall triggers, ask the user for the remembered value without " + "revealing, quoting, paraphrasing, or restating the stored value first.\n" +) + + +def normalize_previous_think_section_title( + title: str, +) -> str: + + cleaned = ( + title + .strip() + .replace( + "**", + "", + ) + .replace( + "`", + "", + ) + .strip() + ) + + cleaned = re.sub( + r"(?i)\b(Check\s+)[\"'](last_jin_response)[\"']", + r"\1\2", + cleaned, + ) + + return cleaned.strip() + + +def match_previous_think_section_title( + title: str, +) -> str: + + normalized = normalize_previous_think_section_title( + title + ) + + normalized_lower = normalized.lower() + + for configured_title in PREVIOUS_THINK_SECTION_TITLES: + + if isinstance( + configured_title, + str, + ): + aliases = [ + configured_title, + ] + elif isinstance( + configured_title, + ( + list, + tuple, + ), + ): + aliases = [ + alias + for alias in configured_title + if isinstance( + alias, + str, + ) + ] + else: + aliases = [] + + if not aliases: + continue + + canonical_title = aliases[0] + + for alias in aliases: + + alias_lower = ( + normalize_previous_think_section_title( + alias + ) + .lower() + ) + + if not alias_lower: + continue + + if ( + normalized_lower == alias_lower + or normalized_lower.startswith( + f"{alias_lower}:" + ) + ): + return canonical_title + + return "" + + +def clean_previous_think_section_text( + section_text: str, +) -> str: + + lines = section_text.splitlines() + + if not lines: + return "" + + heading_match = PREVIOUS_THINK_SECTION_RE.match( + lines[0] + ) + + if not heading_match: + cleaned_lines = lines + else: + heading = normalize_previous_think_section_title( + heading_match.group( + "title" + ) + ) + + cleaned_lines = [ + heading, + *lines[1:], + ] + + cleaned = "\n".join( + cleaned_lines + ) + + cleaned = cleaned.replace( + "**", + "", + ) + + return cleaned.strip() + + +def trim_previous_think_section_text( + section_text: str, +) -> tuple[str, bool]: + + trimmed = ( + len( + section_text + ) + > MAX_PREVIOUS_THINK_SECTION_CHARS + ) + + if not trimmed: + return ( + section_text, + False, + ) + + return ( + section_text[ + :MAX_PREVIOUS_THINK_SECTION_CHARS + ].rstrip(), + True, + ) + + +def extract_previous_think_tail( + text: str, +) -> dict: + + if not isinstance( + text, + str, + ): + text = "" + + sections = [] + sections_found = [] + section_trimmed = False + matches = list( + PREVIOUS_THINK_SECTION_RE.finditer( + text + ) + ) + + for index, match in enumerate( + matches + ): + + section_title = match_previous_think_section_title( + match.group( + "title" + ) + ) + + if not section_title: + continue + + next_match = ( + matches[index + 1] + if index + 1 < len(matches) + else None + ) + + section_text = text[ + match.start(): ( + next_match.start() + if next_match + else len(text) + ) + ].strip() + + section_text = clean_previous_think_section_text( + section_text + ) + + if not section_text: + continue + + section_text, was_section_trimmed = ( + trim_previous_think_section_text( + section_text + ) + ) + section_trimmed = ( + section_trimmed + or was_section_trimmed + ) + + sections.append( + section_text + ) + sections_found.append( + section_title + ) + + extracted = "\n\n".join( + sections + ).strip() + + total_trimmed = ( + len( + extracted + ) + > MAX_PREVIOUS_THINK_CHARS + ) + + if total_trimmed: + extracted = extracted[ + :MAX_PREVIOUS_THINK_CHARS + ].rstrip() + + trimmed = ( + section_trimmed + or total_trimmed + ) + + return { + "text": extracted, + "sections_found": sections_found, + "chars": len( + extracted + ), + "trimmed": trimmed, + } + + +def build_previous_think_block( + text: str, +) -> tuple[str, dict]: + + extracted = extract_previous_think_tail( + text + ) + + metadata = { + "previous_think_appended": bool( + extracted["text"] + ), + "previous_think_sections_found": ( + extracted["sections_found"] + ), + "previous_think_chars": extracted["chars"], + "previous_think_trimmed": extracted["trimmed"], + } + + if not extracted["text"]: + return ( + "", + metadata, + ) + + return ( + "\n".join([ + "<LOW_PRIORITY_PREVIOUS_THINK>", + extracted["text"], + "</LOW_PRIORITY_PREVIOUS_THINK>", + ]), + metadata, + ) + + +def get_previous_think_context_block( + context=None, +) -> str: + + previous_think = getattr( + context, + "runtime_previous_think_raw", + "", + ) + + block, metadata = build_previous_think_block( + previous_think + ) + + if context is not None: + context.runtime_previous_think_payload_log = metadata + + return block + + +def record_previous_think( + context, + reasoning: str, +): + + if context is None: + return + + if not isinstance( + reasoning, + str, + ): + reasoning = "" + + context.runtime_previous_think_raw = reasoning + + +async def log_previous_think_payload( + context, +): + return + +def get_enabled_runtime_actions( + runtime_actions=None, +) -> tuple[str, ...]: + + enabled_actions = [] + + action_flags = runtime_actions or {} + + for action_name, config_key in ( + ( + RUNTIME_ACTION_WEB_SEARCH, + "CAN_WEB_SEARCH", + ), + ( + RUNTIME_ACTION_REMEMBER_SESSION, + "CAN_REMEMBER_SESSION", + ), + ( + RUNTIME_ACTION_REMEMBER_EVENT, + "CAN_REMEMBER_EVENT", + ), + ): + + if bool( + action_flags.get( + config_key, + False, + ) + ): + enabled_actions.append( + action_name + ) + + return tuple( + enabled_actions + ) + + +def count_deep_thought_calls( + text: str, + runtime_actions=None, +) -> int: + + return 0 + + +def record_deep_thought_calls( + context, + reasoning: str, +) -> int: + + return 0 + + +def should_execute_remember_session( + user_message: str, +) -> bool: + return should_execute_action_guard( + "remember_session", + user_message + ) + + +def is_user_initiated_remember_event( + user_message: str, +) -> bool: + return should_execute_action_guard( + "remember_event", + user_message + ) + + +def resolve_runtime_action_user_message( + context, + user_message: str | None = None, +) -> str: + + if user_message: + return user_message + + if context is None: + return "" + + for attr_name in ( + "runtime_turn_user_message", + "original_user_input", + "user_input", + ): + + value = getattr( + context, + attr_name, + "", + ) + + if value: + return value + + return "" + + +async def apply_runtime_action_calls( + context, + actions, + user_message: str | None = None, +) -> int: + + if ( + context is None + or not actions + ): + return 0 + + if not hasattr( + context, + "runtime_action_events", + ): + context.runtime_action_events = [] + + if not hasattr( + context, + "runtime_search_calls", + ): + context.runtime_search_calls = [] + + search_action_count = sum( + 1 + for event in context.runtime_action_events + if event.get("name") == "web_search" + ) + + search_calls = [] + filtered_actions = [] + search_query_seen = False + remember_session_seen = bool( + getattr( + context, + "runtime_remember_session_requested", + False, + ) + ) + remember_session_action_emitted = bool( + getattr( + context, + "runtime_remember_session_action_emitted", + False, + ) + ) + remember_event_seen = False + resolved_user_message = resolve_runtime_action_user_message( + context, + user_message, + ) + + for action in actions: + + if action.name == RUNTIME_ACTION_REMEMBER_SESSION: + if not should_execute_remember_session( + resolved_user_message + ): + continue + + if remember_session_seen: + if not remember_session_action_emitted: + remember_session_action_emitted = True + filtered_actions.append( + action + ) + + continue + + remember_session_seen = True + remember_session_action_emitted = True + filtered_actions.append( + action + ) + continue + + if action.name == RUNTIME_ACTION_REMEMBER_EVENT: + if remember_event_seen: + continue + + remember_event_seen = True + filtered_actions.append( + action + ) + continue + + if action.name == RUNTIME_ACTION_WEB_SEARCH: + query = extract_search_query( + action.payload + ) + + if ( + not query + or search_query_seen + or getattr( + context, + "runtime_search_queries", + [], + ) + ): + continue + + search_query_seen = True + + filtered_actions.append( + action + ) + + if not filtered_actions: + return 0 + + for action in filtered_actions: + + action_event = { + "name": action.name.lower(), + } + + query = "" + + if action.name == RUNTIME_ACTION_WEB_SEARCH: + query = extract_search_query( + action.payload + ) + + if query: + search_action_count += 1 + tool_call_id = build_runtime_action_id( + action.name, + search_action_count, + ) + action_event["id"] = tool_call_id + action_event["query"] = query + search_calls.append({ + "id": tool_call_id, + "query": query, + }) + + elif action.payload: + action_event["payload"] = ( + action.payload + ) + + context.runtime_action_events.append( + action_event + ) + + remember_session_count = sum( + 1 + for action in filtered_actions + if action.name == RUNTIME_ACTION_REMEMBER_SESSION + ) + + remember_event_count = sum( + 1 + for action in filtered_actions + if action.name == RUNTIME_ACTION_REMEMBER_EVENT + ) + + search_queries = [ + query + for query in ( + extract_search_query( + action.payload + ) + for action in filtered_actions + if action.name == RUNTIME_ACTION_WEB_SEARCH + ) + if query + ] + + if search_queries: + if not hasattr( + context, + "runtime_search_queries", + ): + context.runtime_search_queries = [] + + context.runtime_search_queries.extend( + search_queries + ) + + context.runtime_search_calls.extend( + search_calls + ) + + logger = getattr( + context, + "logger", + None, + ) + log_runtime = getattr( + logger, + "log_runtime", + None, + ) + + if ( + log_runtime is not None + and search_queries + ): + await log_runtime( + "[RUNTIME ACTION] " + f"search x{len(search_queries)}" + ) + + if remember_session_count: + context.runtime_remember_session_requested = True + context.runtime_remember_session_action_emitted = True + + if log_runtime is not None: + await log_runtime( + "[RUNTIME ACTION] remember_session requested" + ) + + emitter = getattr( + context, + "emitter", + None, + ) + emit = getattr( + emitter, + "emit", + None, + ) + + if emit is not None: + await emit({ + "type": "runtime_action", + "action": "remember_session", + "text": "Remembering this session", + }) + + if remember_event_count: + initiated_by = ( + "user" + if is_user_initiated_remember_event( + resolved_user_message + ) + else "jin" + ) + + if not hasattr( + context, + "runtime_session_event_snapshots", + ): + context.runtime_session_event_snapshots = [] + + context.runtime_session_event_snapshots.append( + build_runtime_session_event_snapshot( + context, + source="runtime_action", + initiated_by=initiated_by, + ) + ) + + if log_runtime is not None: + await log_runtime( + "[RUNTIME ACTION] remember_event saved" + ) + + emitter = getattr( + context, + "emitter", + None, + ) + emit = getattr( + emitter, + "emit", + None, + ) + + if emit is not None: + session_memory = ( + getattr( + context, + "runtime_l3_session_memory", + "", + ) + or getattr( + context, + "session_memory", + "", + ) + ) + await emit({ + "type": "runtime_action", + "action": "remember_event", + "text": "Remembering this event", + }) + await emit({ + "type": "runtime_session_memory_update", + "memory": session_memory, + "event_snapshots": list( + context.runtime_session_event_snapshots + ), + "updates": getattr( + context, + "runtime_session_memory_updates", + 0, + ), + "source": getattr( + context, + "session_memory_source", + "", + ), + "persist": True, + }) + + return len( + search_queries + ) + min( + remember_session_count, + 1, + ) + min( + remember_event_count, + 1, + ) + + + +def indent_xml( + value: str, + *, + spaces: int = 8, +) -> str: + + prefix = " " * spaces + lines = ( + value + or "" + ).strip().splitlines() + + return "\n".join( + f"{prefix}{line}" + for line in lines + ) + + +def strip_empty_results_xml( + value: str, +) -> str: + + source = ( + value + or "" + ).strip() + + if not source: + return "" + + try: + root = ElementTree.fromstring( + source + ) + + except ElementTree.ParseError: + return source + + def prune_empty_results( + element, + ) -> None: + + for child in list( + element + ): + prune_empty_results( + child + ) + + if child.tag != "RESULTS": + continue + + if list( + child + ): + continue + + if ( + child.text + and child.text.strip() + ): + continue + + element.remove( + child + ) + + prune_empty_results( + root + ) + + return ElementTree.tostring( + root, + encoding="unicode", + short_empty_elements=False, + ) + + +def get_conversation_activity_diff( + context=None, +) -> float | None: + + if context is None: + return None + + recorded_diff = getattr( + context, + "runtime_conversation_activity_diff", + None, + ) + + if recorded_diff is not None: + try: + return float( + recorded_diff + ) + except ( + TypeError, + ValueError, + ): + pass + + patch_sources = ( + getattr( + context, + "runtime_l2_pending_patches", + None, + ) + or getattr( + context, + "runtime_memory_snapshots", + None, + ) + or [] + ) + + for patch in reversed( + patch_sources + ): + + if not isinstance( + patch, + dict, + ): + continue + + total_diff = patch.get( + "total_diff", + ) + + if total_diff is None: + continue + + try: + return float( + total_diff + ) + except ( + TypeError, + ValueError, + ): + continue + + return None + + +def get_conversation_activity_percent( + diff: float, +) -> int: + + return max( + 0, + min( + 100, + int( + round( + diff + ) + ), + ), + ) + + +def has_zero_diff_stall_alert( + context=None, +) -> bool: + + if context is None: + return False + + return bool( + getattr( + context, + "runtime_zero_diff_alert", + None, + ) + ) + +def _get_current_user_message( + context=None, +) -> str: + + if context is None: + return "" + + for attr_name in ( + "runtime_turn_user_message", + "original_user_input", + "user_input", + "last_user_message", + ): + + value = getattr( + context, + attr_name, + "", + ) + + if value: + return str( + value + ) + + return "" + + +def _normalized_text( + value: str, +) -> str: + + return ( + value + or "" + ).casefold().replace( + "ё", + "е", + ) + + +def _contains_any_marker( + text: str, + markers: tuple[str, ...], +) -> bool: + + normalized = _normalized_text( + text + ) + + return any( + _normalized_text( + marker + ) in normalized + for marker in markers + ) + +def has_countdown_contract( + context=None, +) -> bool: + + if context is None: + return False + + runtime_memory = str( + getattr( + context, + "runtime_memory", + "", + ) + or "" + ) + + for raw_line in runtime_memory.splitlines(): + line = raw_line.strip() + + while line.startswith("-"): + line = line[1:].strip() + + key = line.split( + ":", + 1, + )[0].strip().casefold() + + if key == "countdown_contract": + return True + + return False + + +def has_memory_rule_request( + context=None, +) -> bool: + + if context is None: + return False + + explicit_flag = getattr( + context, + "has_memory_request", + None, + ) + + if explicit_flag is not None: + return bool( + explicit_flag + ) + + user_message = _get_current_user_message( + context + ) + + return _contains_any_marker( + user_message, + MEMORY_REQUEST_MARKERS, + ) + + +def has_loop_rule_signal( + context=None, +) -> bool: + + if context is None: + return False + + pattern_counter = getattr( + context, + "runtime_pattern_counter", + None, + ) + + if pattern_counter is not None: + try: + return int( + pattern_counter + ) > 0 + except ( + TypeError, + ValueError, + ): + return False + + runtime_l2_memory = getattr( + context, + "runtime_l2_memory", + "", + ) + + return bool( + str( + runtime_l2_memory + or "" + ).strip() + ) + + +def has_media_context( + context=None, +) -> bool: + + if context is None: + return False + + explicit_flag = getattr( + context, + "has_media", + None, + ) + + if explicit_flag is not None: + return bool( + explicit_flag + ) + + for attr_name in MEDIA_CONTEXT_ATTRS: + value = getattr( + context, + attr_name, + None, + ) + + if value: + return True + + return False + + +def is_philosophy_mode_active( + context=None, +) -> bool: + + if context is None: + return False + + explicit_flag = getattr( + context, + "philosophy_active", + None, + ) + + if explicit_flag is not None: + return bool( + explicit_flag + ) + + user_message = _get_current_user_message( + context + ) + + return _contains_any_marker( + user_message, + PHILOSOPHY_MARKERS, + ) + + +def build_conditional_prompt_rules( + context=None, +) -> str: + + rules = [ + get_last_jin_response_rules(), + ] + + if has_countdown_contract( + context + ): + rules.append( + COUNTDOWN_CONTRACT_RULES + ) + + if has_memory_rule_request( + context + ): + rules.append( + get_memory_rules() + ) + + if has_loop_rule_signal( + context + ): + rules.append( + get_loop_rules() + ) + + if has_media_context( + context + ): + rules.append( + get_image_input_rules() + ) + + if is_philosophy_mode_active( + context + ): + rules.append( + get_philosophy_mode() + ) + + return "".join( + rules + ) diff --git a/clients/brain_context_builder.py b/clients/brain_context_builder.py new file mode 100644 index 00000000..b89376e4 --- /dev/null +++ b/clients/brain_context_builder.py @@ -0,0 +1,395 @@ +import json +from datetime import datetime +from xml.sax.saxutils import escape + +from bootstrap.brain_bootstrap import ( + build_conversation_activity_instruction, + build_zero_diff_stall_instruction, +) +from clients.brain_client_utils import ( + get_conversation_activity_diff, + get_conversation_activity_percent, + get_enabled_runtime_actions, + get_previous_think_context_block, + indent_xml, + strip_empty_results_xml, +) +from runtime.context_contract import ( + ContextContract, + RUNTIME_ACTION_REMEMBER_EVENT, + RUNTIME_ACTION_REMEMBER_SESSION, + RUNTIME_ACTION_WEB_SEARCH, +) +from runtime.L1_memory_utils import ( + build_runtime_memory_context_text, + canonicalize_runtime_memory_text, +) + + +def build_runtime_xml( + context=None, + runtime_actions=None, +) -> str: + + enabled_actions = get_enabled_runtime_actions( + runtime_actions + ) + now = datetime.now() + + return ( + ContextContract( + user_input="", + compressed_history="", + system_state="ACTIVE", + deep_thought_count=0, + can_deep_thought=False, + can_web_search=( + RUNTIME_ACTION_WEB_SEARCH + in enabled_actions + ), + can_remember_session=( + RUNTIME_ACTION_REMEMBER_SESSION + in enabled_actions + ), + can_remember_event=( + RUNTIME_ACTION_REMEMBER_EVENT + in enabled_actions + ), + timestamp=now.isoformat(), + current_date=now.date().isoformat(), + current_time=now.strftime("%H:%M:%S"), + weekday=now.strftime("%A"), + year=now.year, + ) + .to_runtime_xml() + ) + + +def append_session_state( + parts: list[str], + context=None, +) -> None: + + if context is None: + return + + parts.append( + "<SESSION_STATE>\n" + f" <TURN_NUMBER>{getattr(context, 'turn_number', 0)}</TURN_NUMBER>\n" + f" <USER_MESSAGE_COUNT>{getattr(context, 'user_message_count', 0)}</USER_MESSAGE_COUNT>\n" + f" <ASSISTANT_MESSAGE_COUNT>{getattr(context, 'assistant_message_count', 0)}</ASSISTANT_MESSAGE_COUNT>\n" + "</SESSION_STATE>" + ) + + +def append_L3_session_memory( + parts: list[str], + context=None, +) -> None: + + if context is None: + return + + session_memory = getattr( + context, + "runtime_l3_session_memory", + "", + ) or getattr( + context, + "session_memory", + "", + ) + + if not session_memory.strip(): + return + + parts.append( + "<SESSION_MEMORY priority=\"higher_than_runtime_memory\">\n" + f"{indent_xml(escape(session_memory))}\n" + "</SESSION_MEMORY>" + ) + + +def append_session_event_snapshots( + parts: list[str], + context=None, +) -> None: + + session_event_snapshots = [] + + if context is not None: + session_event_snapshots = list( + getattr( + context, + "runtime_session_event_snapshots", + [], + ) + or [] + ) + + parts.append( + "<SESSION_EVENT_SNAPSHOTS priority=\"session_context\">\n" + f"{indent_xml(escape(json.dumps(session_event_snapshots, ensure_ascii=False, indent=2)))}\n" + "</SESSION_EVENT_SNAPSHOTS>" + ) + + +def append_L1_runtime_memory( + parts: list[str], + context=None, +) -> None: + + if context is None: + return + + runtime_memory = build_runtime_memory_context_text( + getattr( + context, + "runtime_memory", + "", + ), + context, + ) + + if not runtime_memory.strip(): + return + + parts.append( + "<RUNTIME_MEMORY>\n" + f"{indent_xml(escape(canonicalize_runtime_memory_text(runtime_memory)))}\n" + "</RUNTIME_MEMORY>" + ) + + +def append_L2_runtime_memory( + parts: list[str], + context=None, +) -> None: + + if context is None: + return + + runtime_l2_memory = getattr( + context, + "runtime_l2_memory", + "", + ) + + if not runtime_l2_memory.strip(): + return + + parts.append( + "<RUNTIME_PATTERN_MEMORY>\n" + f"{indent_xml(escape(runtime_l2_memory))}\n" + "</RUNTIME_PATTERN_MEMORY>" + ) + + +def append_conversation_activity( + parts: list[str], + context=None, +) -> None: + + conversation_activity_diff = get_conversation_activity_diff( + context + ) + + if conversation_activity_diff is None: + return + + activity_percent = get_conversation_activity_percent( + conversation_activity_diff + ) + activity_instruction = build_conversation_activity_instruction( + activity_percent + ) + + parts.append( + "<CONVERSATION_ACTIVITY>\n" + f" <PERCENT>{activity_percent}</PERCENT>\n" + " <INSTRUCTION>\n" + f"{indent_xml(escape(activity_instruction))}\n" + " </INSTRUCTION>\n" + "</CONVERSATION_ACTIVITY>" + ) + + +def append_previous_think( + parts: list[str], + context=None, +) -> None: + + previous_think_block = get_previous_think_context_block( + context + ) + + if previous_think_block: + parts.append( + previous_think_block + ) + + +def append_zero_diff_alert( + parts: list[str], + context=None, +) -> None: + + if context is None: + return + + zero_diff_alert = getattr( + context, + "runtime_zero_diff_alert", + None, + ) + + if not zero_diff_alert: + return + + alert_user_message = ( + zero_diff_alert.get( + "user_message", + "", + ) + if isinstance( + zero_diff_alert, + dict, + ) + else "" + ) + alert_assistant_message = ( + zero_diff_alert.get( + "assistant_message", + "", + ) + if isinstance( + zero_diff_alert, + dict, + ) + else "" + ) + alert_turn_number = ( + zero_diff_alert.get( + "turn_number", + 0, + ) + if isinstance( + zero_diff_alert, + dict, + ) + else 0 + ) + + parts.append( + "<ZERO_DIFF_STALL_ALERT>\n" + " <INSTRUCTION>\n" + f" {build_zero_diff_stall_instruction()}\n" + " </INSTRUCTION>\n" + f" <TRIGGER_TURN>{alert_turn_number}</TRIGGER_TURN>\n" + " <TRIGGER_USER_MESSAGE>\n" + f"{indent_xml(escape(alert_user_message))}\n" + " </TRIGGER_USER_MESSAGE>\n" + " <TRIGGER_JIN_RESPONSE>\n" + f"{indent_xml(escape(alert_assistant_message))}\n" + " </TRIGGER_JIN_RESPONSE>\n" + "</ZERO_DIFF_STALL_ALERT>" + ) + + +def append_tool_results( + parts: list[str], + context=None, +) -> None: + + if context is None: + return + + search_result = getattr( + context, + "runtime_search_result", + "", + ) + + if not search_result: + return + + search_result = strip_empty_results_xml( + search_result + ) + search_result_id = getattr( + context, + "runtime_search_result_id", + "", + ) + + tool_result_attrs = ( + 'name="WEB_SEARCH"' + ) + + if search_result_id: + tool_result_attrs = ( + f'{tool_result_attrs} ' + f'id="{escape(search_result_id)}"' + ) + + parts.append( + '<TOOL_RESULTS type=\'external_untrusted_evidence\'>\n' + f" <TOOL_RESULT {tool_result_attrs}>\n" + f"{indent_xml(search_result)}\n" + " </TOOL_RESULT>\n" + "</TOOL_RESULTS>" + ) + + +def build_brain_runtime_context( + context=None, + runtime_actions=None, +) -> str: + + parts = [ + build_runtime_xml( + context, + runtime_actions, + ) + ] + + append_session_state( + parts, + context, + ) + append_L3_session_memory( + parts, + context, + ) + append_session_event_snapshots( + parts, + context, + ) + append_L1_runtime_memory( + parts, + context, + ) + append_L2_runtime_memory( + parts, + context, + ) + append_conversation_activity( + parts, + context, + ) + append_previous_think( + parts, + context, + ) + append_zero_diff_alert( + parts, + context, + ) + append_tool_results( + parts, + context, + ) + + return "\n".join( + parts + ) diff --git a/config.example.py b/config.example.py index 2af146dd..7d30d1f4 100644 --- a/config.example.py +++ b/config.example.py @@ -2,6 +2,7 @@ USE_SERVICE_AS_BRAIN = True TRANSLATION_ENABLED = False +TRANSLATE_RESPONSE = False CHAT_ENDPOINT = "/v1/chat/completions" MODELS_ENDPOINT = "/v1/models" diff --git a/contracts/behavior_contract.json b/contracts/behavior_contract.json index 731aa9f7..dc3b3444 100644 --- a/contracts/behavior_contract.json +++ b/contracts/behavior_contract.json @@ -15,7 +15,6 @@ "до завтра", "спокойной ночи", "заканчиваем", - "сохрани", "сохрани сессию", "сохрани текущий разговор", "запомни где остановились", @@ -43,7 +42,17 @@ "exact tag", "full tag", "tag example", - "quote tag" + "quote tag", + "сохрани это", + "сохранить это", + "сохрани как", + "сохрани это как", + "запомни это", + "запомни как", + "save this", + "save it", + "remember this", + "remember it" ], "effects": { "save_session": true, @@ -51,6 +60,40 @@ "suppress_offer": true, "final_reply_mode": "short_closure" } + }, + "remember_event": { + "description": "Classifies a REMEMBER_EVENT request as user-initiated when the user explicitly marks the current moment or detail as worth saving.", + "runtime_action": "REMEMBER_EVENT", + "private_marker": "<INTERNAL_ACTION_REMEMBER_EVENT>", + "triggers": [ + "хочу это запомнить", + "хочу запомнить", + "сохрани это", + "сохранить это", + "это надо сохранить", + "это надо запомнить", + "запомни это", + "важный момент", + "надо сохранить" + ], + "blockers": [ + "покажи тег", + "напиши тег", + "полный тег", + "точный тег", + "пример тега", + "как выглядит тег", + "процитируй тег", + "show tag", + "write tag", + "exact tag", + "full tag", + "tag example", + "quote tag" + ], + "effects": { + "save_event": true + } } } } diff --git a/rules/runtime_actions.py b/rules/runtime_actions.py index 4344b0fc..44c4f67b 100644 --- a/rules/runtime_actions.py +++ b/rules/runtime_actions.py @@ -30,7 +30,8 @@ def _format_action_guard_triggers( "\n" "REMEMBER_SESSION: emit once when the user clearly ends, wraps up, or asks to save the session.\n" f"Triggers: {_format_action_guard_triggers('remember_session')}.\n" - "Do not emit for topic changes, brief silence, casual thanks, or while active work continues.\n" + "Do not emit for topic changes, brief silence, casual thanks, bare ambiguous save commands, or while active work continues.\n" + "If the user only says 'сохрани' or 'save' without saying what to save, do not emit any runtime marker. Ask one short clarification: save the whole session, or save a specific event/detail?\n" "\n" "REMEMBER_EVENT: emit when the user explicitly marks a moment as worth saving, or for rare high-signal events:\n" "major decision, strong insight, memorable emotional moment, correction that changes understanding of JIN or user.\n" @@ -45,6 +46,7 @@ def _format_action_guard_triggers( AUTONOMY_RULES = ( "Request REMEMBER_SESSION only when the user clearly asks to save, end, or wrap up the session.\n" "No confirmation needed when real user intent is present.\n" + "A bare command like 'сохрани' or 'save' is ambiguous: ask what exactly should be saved and do not request REMEMBER_SESSION yet.\n" "Use trusted runtime context as interface data, not as chat content.\n" "\n" ) diff --git a/runtime/L1_memory.py b/runtime/L1_memory.py new file mode 100644 index 00000000..37e18960 --- /dev/null +++ b/runtime/L1_memory.py @@ -0,0 +1,1861 @@ +import asyncio +import contextlib +import traceback +from difflib import SequenceMatcher + +from clients.service_client import ( + ask_service_model, +) +from config_loader import ( + config, +) +from runtime.fact_check import ( + ensure_confirmable_memory_markers, +) +from runtime.L1_memory_rules import ( + DURABLE_FLOOR, + DURABLE_MEMORY_KEY_TOKENS, + DURABLE_MEMORY_NEGATION_MARKERS, + GENERIC_MEMORY_MATCH_KEYS, + GENERIC_MEMORY_VALUE_SIMILARITY_MIN, + HOT_THRESHOLD, + HOT_TRACE_EXCLUDED_KEYS, + RUNTIME_RESPONSE_FEEDBACK_DISLIKED_VALUE, + RUNTIME_RESPONSE_FEEDBACK_KEY, + RUNTIME_RESPONSE_FEEDBACK_LIKED_VALUE, + RUNTIME_RESPONSE_FEEDBACK_NEUTRAL_VALUE, + RUNTIME_RESPONSE_FEEDBACK_RATINGS, + STRENGTH_BOOST, + STRENGTH_DECAY, + STRENGTH_NEW_KEY, + STRENGTH_PRESENCE_BOOST, +) +from runtime.L2_memory import ( + maybe_summarize_runtime_l2_memory, + record_runtime_l1_diff, +) +from runtime.L3_memory import ( + maybe_summarize_runtime_session_memory, +) +from runtime.memory_common import ( + build_memory_failure_details, + build_memory_update_skip_details, + build_runtime_summarizer_payload, + build_runtime_summarizer_user_prompt, + change_ratio, + extract_runtime_memory_text, + is_runtime_memory_response_truncated, + latest_turn_context_is_overloaded, + log_memory_event, + log_runtime_summarizer_payload, + looks_like_incomplete_runtime_memory, + refresh_runtime_memory_summarizer_usage, + runtime_prompt_is_context_overloaded, +) +from runtime.memory_events import ( + emit_runtime_memory_update, +) +from runtime.L1_memory_utils import ( + build_interrupted_assistant_message, + build_runtime_memory_batch_user_prompt, + build_runtime_memory_context_text, + build_runtime_memory_system_prompt, + build_runtime_memory_user_prompt, + canonicalize_runtime_memory_entry, + remove_runtime_user_idle_lines, +) + +def normalize_runtime_response_feedback(feedback) -> dict | None: + + if not isinstance(feedback, dict): + return None + + raw_rating = str( + feedback.get("rating") + or "" + ).strip().casefold() + + rating = RUNTIME_RESPONSE_FEEDBACK_RATINGS.get( + raw_rating + ) + + if rating is None: + return None + + return { + "rating": rating, + } + + +def build_runtime_response_feedback_value(feedback: dict) -> str: + + rating = feedback.get( + "rating", + "neutral", + ) + + if rating == "disliked": + return RUNTIME_RESPONSE_FEEDBACK_DISLIKED_VALUE + + if rating == "liked": + return RUNTIME_RESPONSE_FEEDBACK_LIKED_VALUE + + return RUNTIME_RESPONSE_FEEDBACK_NEUTRAL_VALUE + + + +def remove_runtime_memory_entry_text( + memory: str, + key: str, +) -> str: + + target_key = str(key or "").strip() + if not target_key: + return memory or "" + + target_key_normalized = target_key.casefold() + + lines = [ + line.rstrip() + for line in str(memory or "").splitlines() + ] + + kept_lines = [] + + for line in lines: + stripped = line.strip() + if not stripped: + continue + + current_key = stripped.split(":", 1)[0].strip().casefold() + + if current_key == target_key_normalized: + continue + + kept_lines.append(stripped) + + return "\n".join(kept_lines).strip() + + +def upsert_runtime_memory_entry_text( + memory: str, + key: str, + value: str, +) -> str: + + target_key = str(key or "").strip() + if not target_key: + return memory or "" + + target_key_normalized = target_key.casefold() + replacement = f"{target_key}: {str(value or '').strip()}" + + lines = [ + line.rstrip() + for line in str(memory or "").splitlines() + ] + + updated_lines = [] + replaced = False + + for line in lines: + stripped = line.strip() + if not stripped: + continue + + current_key = stripped.split(":", 1)[0].strip().casefold() + + if current_key == target_key_normalized: + if not replaced: + updated_lines.append(replacement) + replaced = True + continue + + updated_lines.append(stripped) + + if not replaced: + updated_lines.append(replacement) + + return "\n".join(updated_lines).strip() + + +def remove_runtime_response_feedback_text( + memory: str, +) -> str: + + return remove_runtime_memory_entry_text( + memory or "", + RUNTIME_RESPONSE_FEEDBACK_KEY, + ).strip() + + +def clear_runtime_response_feedback( + context, +) -> None: + + if context is None: + return + + context.runtime_memory = remove_runtime_response_feedback_text( + getattr( + context, + "runtime_memory", + "", + ) + ) + + context.runtime_memory_stable = remove_runtime_response_feedback_text( + getattr( + context, + "runtime_memory_stable", + "", + ) + ) + + context.runtime_last_response_feedback = None + + +async def apply_runtime_response_feedback( + context, + feedback, +) -> dict | None: + + normalized_feedback = normalize_runtime_response_feedback( + feedback + ) + + if normalized_feedback is None: + return None + + value = build_runtime_response_feedback_value( + normalized_feedback + ) + + current_memory = getattr( + context, + "runtime_memory", + "", + ) + + cleaned_memory = remove_runtime_response_feedback_text( + current_memory + ) + + updated_memory = upsert_runtime_memory_entry_text( + cleaned_memory, + RUNTIME_RESPONSE_FEEDBACK_KEY, + value, + ) + + if updated_memory == current_memory: + context.runtime_last_response_feedback = normalized_feedback + return None + + context.runtime_memory = updated_memory + context.runtime_last_response_feedback = normalized_feedback + + # Rating clicks are an in-place mutation of the current L1 snapshot. + # Do not increment runtime_memory_updates and do not emit + # runtime_memory_update here, otherwise the UI creates a new runtime page. + # This is a transient next-turn alert, not durable L1 memory. + # Keep runtime_memory_stable clean so L1 cannot preserve it. + return { + "applied": True, + "rating": normalized_feedback["rating"], + "runtime_memory": updated_memory, + } + +async def ask_runtime_memory_model( + *, + context=None, + service_client, + current_memory: str, + user_message: str, + assistant_message: str, +) -> dict: + + resolve_request_context_window = getattr( + service_client, + "resolve_request_context_window", + None, + ) + detected_context_window = None + + if resolve_request_context_window is not None: + detected_context_window = ( + await resolve_request_context_window() + ) + + system_prompt = ( + build_runtime_memory_system_prompt() + ) + _snapshots = list( + getattr( + context, + "runtime_memory_snapshots", + [], + ) + or [] + ) + _latest_lines = ( + _snapshots[-1].get("lines", []) + if _snapshots + else [] + ) + user_prompt = build_runtime_summarizer_user_prompt( + context=context, + prompt=( + build_runtime_memory_user_prompt( + current_memory=current_memory, + user_message=user_message, + assistant_message=assistant_message, + strength_zones=get_strength_zones( + _latest_lines + ), + ) + ), + ) + + last_turn_context_overloaded = ( + latest_turn_context_is_overloaded( + context + ) + or runtime_prompt_is_context_overloaded( + system_prompt=system_prompt, + user_prompt=user_prompt, + context_window=detected_context_window, + ) + ) + + if last_turn_context_overloaded: + system_prompt = ( + build_runtime_memory_system_prompt( + last_turn_context_overloaded=True, + ) + ) + + await refresh_runtime_memory_summarizer_usage( + context, + system_prompt=system_prompt, + user_prompt=user_prompt, + context_window=detected_context_window, + ) + + temperature = ( + config.SERVICE_TEMPERATURE + ) + max_tokens = ( + config.SERVICE_MAX_TOKENS + ) + + await log_runtime_summarizer_payload( + context, + label="L1", + payload=build_runtime_summarizer_payload( + service_client=service_client, + system_prompt=system_prompt, + user_prompt=user_prompt, + temperature=temperature, + max_tokens=max_tokens, + ), + ) + + response = await ask_service_model( + client=service_client, + system_prompt=system_prompt, + user_prompt=user_prompt, + temperature=temperature, + max_tokens=max_tokens, + timeout=config.SERVICE_REQUEST_TIMEOUT, + ) + + await refresh_runtime_memory_summarizer_usage( + context, + system_prompt=system_prompt, + user_prompt=user_prompt, + response=response, + context_window=detected_context_window, + ) + + return response + + +async def ask_runtime_memory_batch_model( + *, + context=None, + service_client, + current_memory: str, + turns: list[dict], +) -> dict: + + system_prompt = ( + build_runtime_memory_system_prompt() + ) + _snapshots = list( + getattr( + context, + "runtime_memory_snapshots", + [], + ) + or [] + ) + _latest_lines = ( + _snapshots[-1].get("lines", []) + if _snapshots + else [] + ) + user_prompt = build_runtime_summarizer_user_prompt( + context=context, + prompt=( + build_runtime_memory_batch_user_prompt( + current_memory=current_memory, + turns=turns, + strength_zones=get_strength_zones( + _latest_lines + ), + ) + ), + ) + + await refresh_runtime_memory_summarizer_usage( + context, + system_prompt=system_prompt, + user_prompt=user_prompt, + ) + + temperature = ( + config.SERVICE_TEMPERATURE + ) + max_tokens = ( + config.SERVICE_MAX_TOKENS + ) + log_label = ( + "L1 batch" + if len(turns) > 1 + else "L1" + ) + + await log_runtime_summarizer_payload( + context, + label=log_label, + payload=build_runtime_summarizer_payload( + service_client=service_client, + system_prompt=system_prompt, + user_prompt=user_prompt, + temperature=temperature, + max_tokens=max_tokens, + ), + ) + + response = await ask_service_model( + client=service_client, + system_prompt=system_prompt, + user_prompt=user_prompt, + temperature=temperature, + max_tokens=max_tokens, + timeout=config.SERVICE_REQUEST_TIMEOUT, + ) + + await refresh_runtime_memory_summarizer_usage( + context, + system_prompt=system_prompt, + user_prompt=user_prompt, + response=response, + ) + + return response + + +async def summarize_runtime_memory( + *, + context, + user_message: str, + assistant_message: str, +) -> str: + + if not assistant_message.strip(): + return getattr( + context, + "runtime_memory", + "", + ) + + service_client = ( + getattr( + context, + "clients", + {}, + ) + .get( + "service" + ) + ) + + if service_client is None: + return getattr( + context, + "runtime_memory", + "", + ) + + current_memory = remove_runtime_response_feedback_text( + getattr( + context, + "runtime_memory", + "", + ) + ) + + context.runtime_memory = current_memory + context.runtime_memory_stable = remove_runtime_response_feedback_text( + getattr( + context, + "runtime_memory_stable", + "", + ) + ) + context.runtime_last_response_feedback = None + + try: + response = await ask_runtime_memory_model( + context=context, + service_client=service_client, + current_memory=current_memory, + user_message=user_message, + assistant_message=assistant_message, + ) + + updated_memory = extract_runtime_memory_text( + response + ) + updated_memory = remove_runtime_response_feedback_text( + updated_memory + ) + + if ( + is_runtime_memory_response_truncated( + response + ) + or looks_like_incomplete_runtime_memory( + updated_memory + ) + ): + await log_memory_event( + context, + level="L1", + message="L1 runtime memory update skipped", + details=build_memory_update_skip_details( + reason="Summarizer returned an incomplete memory update.", + previous_memory=current_memory, + candidate_memory=updated_memory, + ), + fallback_channel="error", + ) + + return current_memory + + updated_memory = merge_durable_memory_facts( + current_memory, + updated_memory, + ) + updated_memory = remove_runtime_response_feedback_text( + updated_memory + ) + updated_memory = ensure_confirmable_memory_markers( + updated_memory, + user_message=user_message, + assistant_message=assistant_message, + ) + updated_memory = remove_runtime_response_feedback_text( + updated_memory + ) + updated_memory = remove_runtime_user_idle_lines( + updated_memory + ) + + updates_counter = getattr( + context, + "runtime_memory_updates", + 0, + ) + + if updated_memory or updates_counter == 0: + context.runtime_memory = updated_memory + context.runtime_memory_stable = updated_memory + context.runtime_memory_updates = updates_counter + 1 + + snapshot = await emit_runtime_memory_update( + context + ) + + await record_runtime_l1_diff( + context, + snapshot, + turns=[ + { + "user_message": user_message, + "assistant_message": assistant_message, + }, + ], + ) + await maybe_summarize_runtime_l2_memory( + context=context, + ) + await maybe_summarize_runtime_session_memory( + context=context, + ) + + return getattr( + context, + "runtime_memory", + "", + ) + + except asyncio.CancelledError: + raise + + except Exception as error: + formatted_traceback = ( + traceback.format_exc() + ) + + await log_memory_event( + context, + level="L1", + message="L1 runtime memory update failed", + details=build_memory_failure_details( + stage="L1 runtime memory summarizer", + error=error, + traceback_text=formatted_traceback, + ), + fallback_channel="error", + ) + + return getattr( + context, + "runtime_memory", + "", + ) + + +async def summarize_runtime_memory_pending_turns( + *, + context, +) -> str: + + turns = list( + context.runtime_memory_pending_turns + ) + + if not turns: + return getattr( + context, + "runtime_memory", + "", + ) + + service_client = ( + getattr( + context, + "clients", + {}, + ) + .get( + "service" + ) + ) + + if service_client is None: + return getattr( + context, + "runtime_memory", + "", + ) + + initial_memory = remove_runtime_response_feedback_text( + getattr( + context, + "runtime_memory_stable", + "", + ) + ) + + context.runtime_memory = remove_runtime_response_feedback_text( + getattr( + context, + "runtime_memory", + "", + ) + ) + context.runtime_memory_stable = initial_memory + context.runtime_last_response_feedback = None + + try: + response = await ask_runtime_memory_batch_model( + context=context, + service_client=service_client, + current_memory=initial_memory, + turns=turns, + ) + + updated_memory = extract_runtime_memory_text( + response + ) + updated_memory = remove_runtime_response_feedback_text( + updated_memory + ) + + skip_reason = None + + if is_runtime_memory_response_truncated(response): + skip_reason = "Summarizer response was truncated by max_tokens." + + elif looks_like_incomplete_runtime_memory(updated_memory): + skip_reason = "Summarizer returned text that looks structurally incomplete." + + if skip_reason: + await log_memory_event( + context, + level="L1", + message="L1 runtime memory update skipped", + details=build_memory_update_skip_details( + reason="Summarizer returned an incomplete memory update.", + previous_memory=initial_memory, + candidate_memory=updated_memory, + ), + fallback_channel="error", + ) + + return initial_memory + + updated_memory = merge_durable_memory_facts( + initial_memory, + updated_memory, + ) + updated_memory = remove_runtime_response_feedback_text( + updated_memory + ) + + latest_turn = turns[-1] if turns else {} + updated_memory = ensure_confirmable_memory_markers( + updated_memory, + user_message=latest_turn.get( + "user_message", + "", + ), + assistant_message=latest_turn.get( + "assistant_message", + "", + ), + ) + updated_memory = remove_runtime_response_feedback_text( + updated_memory + ) + updated_memory = remove_runtime_user_idle_lines( + updated_memory + ) + + updates_counter = getattr( + context, + "runtime_memory_updates", + 0, + ) + + if updated_memory or updates_counter == 0: + context.runtime_memory = updated_memory + context.runtime_memory_stable = updated_memory + context.runtime_memory_updates = updates_counter + 1 + + context.runtime_memory_pending_turns = [ + turn + for turn in context.runtime_memory_pending_turns + if turn not in turns + ] + + snapshot = await emit_runtime_memory_update( + context + ) + + await record_runtime_l1_diff( + context, + snapshot, + turns=turns, + ) + await maybe_summarize_runtime_l2_memory( + context=context, + ) + await maybe_summarize_runtime_session_memory( + context=context, + ) + + return getattr( + context, + "runtime_memory", + "", + ) + + except asyncio.CancelledError: + raise + + except Exception as error: + formatted_traceback = ( + traceback.format_exc() + ) + + await log_memory_event( + context, + level="L1", + message="L1 runtime memory update failed", + details=build_memory_failure_details( + stage="L1 pending runtime memory summarizer", + error=error, + traceback_text=formatted_traceback, + ), + fallback_channel="error", + ) + + return getattr( + context, + "runtime_memory", + "", + ) + + finally: + if ( + getattr( + context, + "runtime_memory_update_task", + None, + ) + is asyncio.current_task() + ): + context.runtime_memory_update_task = None + + +def schedule_runtime_memory_update( + *, + context, + user_message: str, + assistant_message: str, +) -> asyncio.Task | None: + + if not assistant_message.strip(): + return None + + context.runtime_memory_pending_turns.append({ + "user_message": user_message, + "assistant_message": assistant_message, + }) + + previous_task = getattr( + context, + "runtime_memory_update_task", + None, + ) + + if ( + previous_task is not None + and not previous_task.done() + ): + previous_task.cancel() + + task = asyncio.create_task( + summarize_runtime_memory_pending_turns( + context=context, + ) + ) + + context.runtime_memory_update_task = task + + background_tasks = getattr( + context, + "background_tasks", + None, + ) + + if background_tasks is None: + background_tasks = set() + context.background_tasks = background_tasks + + background_tasks.add( + task + ) + task.add_done_callback( + background_tasks.discard + ) + + return task + + +def schedule_interrupted_runtime_memory_update( + *, + context, +) -> asyncio.Task | None: + + user_message = getattr( + context, + "runtime_turn_user_message", + "", + ) + + assistant_message = ( + build_interrupted_assistant_message( + user_message=user_message, + assistant_message=getattr( + context, + "runtime_turn_assistant_response", + "", + ), + ) + ) + + if not user_message.strip(): + return None + + return schedule_runtime_memory_update( + context=context, + user_message=user_message, + assistant_message=assistant_message, + ) + + +async def cancel_runtime_memory_update( + context, +) -> None: + + task = getattr( + context, + "runtime_memory_update_task", + None, + ) + + if ( + task is None + or task.done() + ): + return + + task.cancel() + + with contextlib.suppress( + asyncio.CancelledError, + Exception, + ): + await task + + context.runtime_memory_update_task = None + +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 + + key, value = canonicalize_runtime_memory_entry( + key, + value, + ) + + lines.append({ + "key": key, + "value": value, + "status": "same", + }) + + return lines + +def normalize_memory_key( + key: str, +) -> str: + + return ( + key + .strip() + .lower() + ) + + +def is_durable_memory_key( + key: str, +) -> bool: + + normalized_key = normalize_memory_key( + key + ) + + return any( + token in normalized_key + for token in DURABLE_MEMORY_KEY_TOKENS + ) + + +def compute_line_strength( + prev_strength: float | None, + change_ratio_val: float, + is_durable: bool, + is_new: bool, +) -> float: + if is_new: + raw = STRENGTH_NEW_KEY + else: + raw = ( + (prev_strength or 0.0) * STRENGTH_DECAY + + STRENGTH_PRESENCE_BOOST + + change_ratio_val * STRENGTH_BOOST + ) + + floor = DURABLE_FLOOR if is_durable else 0.0 + + return round( + min( + 1.0, + max( + floor, + raw, + ), + ), + 4, + ) + + +def get_strength_zones( + lines: list[dict], +) -> dict: + hot = [] + excluded_hot_trace_keys = { + normalize_memory_key( + key + ) + for key in HOT_TRACE_EXCLUDED_KEYS + } + + for line in lines: + key = line.get("key", "") + strength = line.get("strength", 0.0) + if strength >= HOT_THRESHOLD: + if normalize_memory_key( + key + ) in excluded_hot_trace_keys: + continue + hot.append(key) + + return { + "hot": hot, + } + + +def build_strength_map( + lines: list[dict], +) -> dict[str, float]: + return { + line.get("key", ""): line.get("strength", 0.0) + for line in lines + if line.get("key") + } + + +def has_durable_fact_negation( + value: str, +) -> bool: + + normalized_value = ( + value + or "" + ).strip().lower() + + return any( + marker in normalized_value + for marker in DURABLE_MEMORY_NEGATION_MARKERS + ) + + +def durable_memory_line_text( + line: dict, +) -> str: + + key = ( + line.get( + "key", + "", + ) + or "" + ).strip() + + value = ( + line.get( + "value", + "", + ) + or "" + ).strip() + + if not key: + return value + + return f"{key}: {value}" + + +def split_memory_value_parts( + value: str, +) -> list[str]: + + return [ + part.strip() + for part in ( + value + or "" + ).split(",") + if part.strip() + ] + + +def collapse_duplicate_runtime_memory_keys( + memory: str, +) -> str: + + output_entries = [] + grouped_by_key = {} + duplicate_found = False + + for raw_line in ( + memory + or "" + ).splitlines(): + + stripped_line = raw_line.strip() + + if not stripped_line: + output_entries.append( + raw_line + ) + continue + + line = stripped_line.lstrip("-").strip() + + if ":" not in line: + output_entries.append( + raw_line + ) + continue + + key, value = line.split( + ":", + 1, + ) + + key, value = canonicalize_runtime_memory_entry( + key, + value, + ) + + normalized_key = normalize_memory_key( + key + ) + + if ( + not normalized_key + or not is_durable_memory_key( + key + ) + ): + output_entries.append( + raw_line + ) + continue + + existing = grouped_by_key.get( + normalized_key + ) + + if existing is None: + existing = { + "key": key, + "values": [], + "seen_values": set(), + } + grouped_by_key[normalized_key] = existing + output_entries.append( + existing + ) + else: + duplicate_found = True + + for part in split_memory_value_parts( + value + ): + normalized_value = part.casefold() + + if normalized_value in existing["seen_values"]: + continue + + existing["seen_values"].add( + normalized_value + ) + existing["values"].append( + part + ) + + if not duplicate_found: + return memory + + return "\n".join( + ( + ( + f'{entry["key"]}: {", ".join(entry["values"])}' + if entry["key"] + else ", ".join(entry["values"]) + ) + if isinstance( + entry, + dict, + ) + else entry + ) + for entry in output_entries + ) + + +def merge_durable_memory_facts( + previous_memory: str, + candidate_memory: str, +) -> str: + + previous_memory = remove_runtime_response_feedback_text( + previous_memory + ) + candidate_memory = remove_runtime_response_feedback_text( + candidate_memory + ) + + previous_lines = parse_runtime_memory_lines( + previous_memory + ) + candidate_lines = parse_runtime_memory_lines( + candidate_memory + ) + + candidate_by_key = { + normalize_memory_key( + line.get( + "key", + "", + ) + ): line + for line in candidate_lines + } + + preserved_lines = [] + + for previous_line in previous_lines: + + previous_key = ( + previous_line.get( + "key", + "", + ) + or "" + ).strip() + + if not is_durable_memory_key( + previous_key + ): + continue + + normalized_key = normalize_memory_key( + previous_key + ) + + candidate_line = candidate_by_key.get( + normalized_key + ) + + if candidate_line is not None: + candidate_value = candidate_line.get( + "value", + "", + ) + + if has_durable_fact_negation( + candidate_value + ): + continue + + continue + + preserved_lines.append( + durable_memory_line_text( + previous_line + ) + ) + + if not preserved_lines: + return collapse_duplicate_runtime_memory_keys( + candidate_memory + ) + + candidate_text = ( + candidate_memory + or "" + ).strip() + + if not candidate_text: + return collapse_duplicate_runtime_memory_keys( + "\n".join( + preserved_lines + ) + ) + + return collapse_duplicate_runtime_memory_keys( + ( + candidate_text + + "\n" + + "\n".join( + preserved_lines + ) + ) + ) + + +def normalize_generic_memory_key( + key: str, +) -> str: + + return ( + normalize_memory_key( + key + ) + .replace( + "_", + " ", + ) + .replace( + "-", + " ", + ) + ) + + +def is_generic_memory_match_key( + key: str, +) -> bool: + + return normalize_generic_memory_key( + key + ) in GENERIC_MEMORY_MATCH_KEYS + + +def memory_value_similarity( + previous: str, + current: str, +) -> float: + + previous = ( + previous + or "" + ).strip() + current = ( + current + or "" + ).strip() + + if not previous and not current: + return 1.0 + + if not previous or not current: + return 0.0 + + return round( + SequenceMatcher( + None, + previous.lower(), + current.lower(), + ).ratio(), + 3, + ) + + +def should_match_previous_memory_line( + *, + key: str, + value: str, + previous_line: dict | None, +) -> bool: + + if previous_line is None: + return False + + previous_key = previous_line.get( + "key", + "", + ) + + if not ( + is_generic_memory_match_key( + key + ) + or is_generic_memory_match_key( + previous_key + ) + ): + return True + + similarity = memory_value_similarity( + previous_line.get( + "value", + "", + ), + value, + ) + + return similarity >= GENERIC_MEMORY_VALUE_SIMILARITY_MIN + + +def find_best_previous_line( + key: str, + previous_lines: list[dict], + value: str = "", +) -> 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 + and should_match_previous_memory_line( + key=key, + value=value, + previous_line=best_line, + ) + ): + 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" + line["strength"] = compute_line_strength( + prev_strength=None, + change_ratio_val=1.0, + is_durable=is_durable_memory_key( + line.get("key", "") + ), + is_new=True, + ) + + 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 + ) + + if not should_match_previous_memory_line( + key=key, + value=value, + previous_line=previous_line, + ): + previous_line = None + + if previous_line is None: + previous_line = find_best_previous_line( + key, + previous_lines, + value=value, + ) + + # ----------------------------------------- + # 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" + line["strength"] = compute_line_strength( + prev_strength=None, + change_ratio_val=1.0, + is_durable=is_durable_memory_key(key), + is_new=True, + ) + + 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" + + line["strength"] = compute_line_strength( + prev_strength=previous_line.get("strength"), + change_ratio_val=max(key_delta, value_delta), + is_durable=is_durable_memory_key(key), + is_new=False, + ) + + return current_lines + + +def build_runtime_memory_patch( + current_lines: list[dict], + previous_snapshot: dict | None, +) -> dict: + + patch = { + "added": [], + "changed": [], + "removed": [], + } + total_diff = 0 + + if not previous_snapshot: + for line in current_lines: + patch["added"].append({ + "key": line.get( + "key", + "", + ), + "value": line.get( + "value", + "", + ), + "strength": line.get( + "strength", + 0.0, + ), + }) + total_diff += 30 + + return { + "patch": patch, + "total_diff": total_diff, + } + + 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 + + matched_previous_ids = set() + + 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 + ) + + if not should_match_previous_memory_line( + key=key, + value=value, + previous_line=previous_line, + ): + previous_line = None + + if previous_line is None: + previous_line = find_best_previous_line( + key, + previous_lines, + value=value, + ) + + if previous_line is None: + patch["added"].append({ + "key": key, + "value": line.get( + "value", + "", + ), + "strength": line.get( + "strength", + 0.0, + ), + }) + total_diff += 30 + continue + + matched_previous_ids.add( + id(previous_line) + ) + + key_delta = line.get( + "key_change_ratio", + 0, + ) + value_delta = line.get( + "value_change_ratio", + 0, + ) + + if key_delta or value_delta: + patch["changed"].append({ + "previous_key": previous_line.get( + "key", + "", + ), + "previous_value": previous_line.get( + "value", + "", + ), + "current_key": key, + "current_value": line.get( + "value", + "", + ), + "key_change_ratio": key_delta, + "value_change_ratio": value_delta, + "previous_strength": previous_line.get( + "strength", + 0.0, + ), + "current_strength": line.get( + "strength", + 0.0, + ), + }) + total_diff += round( + ( + key_delta + + value_delta + ) + * 50, + 2, + ) + + for previous_line in previous_lines: + if id(previous_line) in matched_previous_ids: + continue + + patch["removed"].append({ + "key": previous_line.get( + "key", + "", + ), + "value": previous_line.get( + "value", + "", + ), + "strength": previous_line.get( + "strength", + 0.0, + ), + }) + total_diff += 20 + + return { + "patch": patch, + "total_diff": total_diff, + } + + +def build_runtime_memory_snapshot( + context, + memory: str, +) -> dict: + + snapshots = getattr( + context, + "runtime_memory_snapshots", + [], + ) + + previous_snapshot = ( + snapshots[-1] + if snapshots + else None + ) + + display_memory = build_runtime_memory_context_text( + memory, + context, + ) + + lines = parse_runtime_memory_lines( + display_memory + ) + + lines = apply_runtime_memory_diff( + lines, + previous_snapshot, + ) + + patch_details = build_runtime_memory_patch( + lines, + previous_snapshot, + ) + + return { + "session_id": getattr(context, "session_id", ""), + "index": len(snapshots), + "raw_memory": display_memory, + "lines": lines, + "patch": patch_details["patch"], + "total_diff": patch_details["total_diff"], + } diff --git a/runtime/L1_memory_rules.py b/runtime/L1_memory_rules.py new file mode 100644 index 00000000..933fdf5d --- /dev/null +++ b/runtime/L1_memory_rules.py @@ -0,0 +1,142 @@ +# Provides the initial runtime memory text for a brand-new session. +DEFAULT_RUNTIME_MEMORY = ( + "This session has just begun. " + "You have no history with the user yet." +) + +# Decays existing memory strength between scoring passes. +STRENGTH_DECAY = 0.82 + +# Boosts memory strength when a key is present in the latest context. +STRENGTH_PRESENCE_BOOST = 0.08 + +# Boosts memory strength based on the amount of value change. +STRENGTH_BOOST = 0.8 + +# Sets the starting strength for newly observed memory keys. +STRENGTH_NEW_KEY = 0.5 + +# Sets the minimum strength retained for durable memory lines. +DURABLE_FLOOR = 0.25 + +# Sets the strength threshold for marking memory lines as hot traces. +HOT_THRESHOLD = 0.5 + +# Lists memory keys that should never be treated as hot traces. +HOT_TRACE_EXCLUDED_KEYS = [ + "user_idle", +] + +# Sets the similarity floor for matching generic memory values. +GENERIC_MEMORY_VALUE_SIMILARITY_MIN = 0.35 + +# Lists generic memory keys that should use value similarity matching. +GENERIC_MEMORY_MATCH_KEYS = ( + "topic", + "focus", + "next step", + "last jin response", + + "user request", + "user intent", + + "active topic", + "active topics", + "current topic", + "current topics", + + "open reference", + "open references", + "open question", + + "pending choice", + "pending choices", + "pending action", + "pending actions", + + "session status", + "session state", + + "current concern", + "current concerns", + "current task", + "current tasks", + "current context", + "current request", + "current requests", + + "interaction state", +) + +# Lists key tokens that identify memory entries as durable. +DURABLE_MEMORY_KEY_TOKENS = ( + "fact", + "identity", + "profile", + "preference", + "stored", +) + +# Lists value markers that negate or invalidate durable memory entries. +DURABLE_MEMORY_NEGATION_MARKERS = ( + "not", + "not fact", + "not true", + "false", + "obsolete", + "removed", + "cancelled", + "canceled", + "superseded", + "no longer", + "invalid", +) + +# Stores the runtime state key used for the last response feedback signal. +RUNTIME_RESPONSE_FEEDBACK_KEY = "JIN_LAST_RESPONSE_USER_FEEDBACK" + +# Stores the runtime state key used for user idle markers. +RUNTIME_USER_IDLE_KEY = "user_idle" + +# Describes how to react after the user disliked the last response. +RUNTIME_RESPONSE_FEEDBACK_DISLIKED_VALUE = ( + "User disliked your last response. " + "Before answering, find and understand why it failed using context or memory, then start the next reply with a brief acknowledgement of that miss, then continue with a concrete corrected answer." +) + +# Describes how to react after the user gave neutral feedback. +RUNTIME_RESPONSE_FEEDBACK_NEUTRAL_VALUE = ( + "User gave neutral feedback to your last response. " + "Continue carefully without changing course too much and treat it as a signal for response improvement." +) + +# Describes how to react after the user liked the last response. +RUNTIME_RESPONSE_FEEDBACK_LIKED_VALUE = ( + "User liked your last response. " + "Keep the current direction." +) + +# Maps accepted feedback rating values to normalized rating names. +RUNTIME_RESPONSE_FEEDBACK_RATINGS = { + "disliked": "disliked", + "neutral": "neutral", + "liked": "liked", +} + +# Defines stricter memory compression rules when context usage is high. +RUNTIME_MEMORY_CONTEXT_OVERLOAD_RULES = ( + "[CONTEXT PRESSURE OVERRIDE]\n" + "Context usage is critically high.\n" + "L1 must switch from normal summarization to survival compression.\n" + + "Rules:\n" + "- Compress harder than usual.\n" + "- Keep only information needed to continue the session after context loss.\n" + "- Prefer durable state: decisions, active task, bugs, next steps, user preferences, project changes, unresolved risks, and active recall contracts.\n" + "- Drop examples, jokes, emotional texture, repeated explanations, and wording that does not change future behavior.\n" + "- Do not restate memory that already exists unless it changed.\n" + "- Context pressure may shorten temporary state, but must not remove stored_memory, open_contract, countdown_contract, durable facts, pending contracts, unresolved implementation tasks, or explicit user decisions.\n" + "- Merge related temporary details into fewer atomic key:value lines.\n" + "- If a durable line is long, shorten its value without changing its meaning.\n" + "- Use short values. No markdown. No commentary.\n" +) diff --git a/runtime/memory_rules.py b/runtime/L1_memory_utils.py similarity index 51% rename from runtime/memory_rules.py rename to runtime/L1_memory_utils.py index 624e654c..47f11dea 100644 --- a/runtime/memory_rules.py +++ b/runtime/L1_memory_utils.py @@ -1,123 +1,12 @@ -import json - - -MAX_SESSION_PROMPT_SNAPSHOTS = 6 -MAX_SESSION_PROMPT_DIFFS = 8 -MAX_SESSION_PROMPT_EVENTS = 3 -MAX_SESSION_EVENT_TEXT_CHARS = 300 -MAX_SESSION_MEMORY_TEXT_CHARS = 1800 -MAX_SESSION_LATEST_MEMORY_TEXT_CHARS = 2200 -MAX_SESSION_OLD_SNAPSHOT_TEXT_CHARS = 500 -MAX_SESSION_LINE_CHARS = 220 -MAX_SESSION_L2_LINES = 3 - -SESSION_MEMORY_PRIORITY_KEYWORDS = ( - "decision", - "constraint", - "unresolved", - "pending", - "next step", - "current", - "topic", - "direction", - "milestone", - "blocked", - "todo", - "task", - "fact", - "user", - "jin", - "stored_memory", - "open_contract", - "countdown_contract", - "count_from", - "count_to", - "current", - "remaining", - "created_at", - "created_user_message_count", - "due_user_message_count", - "last_jin_response", - "решение", - "огранич", - "важно", - "следующ", - "текущ", - "задач", - "факт", +from runtime.L1_memory_rules import ( + DEFAULT_RUNTIME_MEMORY, + RUNTIME_MEMORY_CONTEXT_OVERLOAD_RULES, + RUNTIME_USER_IDLE_KEY, ) - -SESSION_EVENT_IMPORTANCE_MARKERS = ( - "запомни", - "важно", - "надо сохранить", - "ключевой момент", - "это надо зафиксировать", - "сохрани", - "зафиксируй", -) - -SESSION_EVENT_MILESTONE_MARKERS = ( - "decision", - "decided", - "milestone", - "shipped", - "completed", - "resolved", - "fixed", - "session-level", - "решение", - "решили", - "веха", - "готово", - "закрыли", - "починили", -) - - -DEFAULT_RUNTIME_MEMORY = ( - "This session has just begun. " - "You have no history with the user yet." -) - -RUNTIME_USER_IDLE_KEY = "user_idle" - - -RUNTIME_MEMORY_CONTEXT_OVERLOAD_RULES = ( - "[CONTEXT PRESSURE OVERRIDE]\n" - "Context usage is critically high.\n" - "L1 must switch from normal summarization to survival compression.\n" - - "Rules:\n" - "- Compress harder than usual.\n" - "- Keep only information needed to continue the session after context loss.\n" - "- Prefer durable state: decisions, active task, bugs, next steps, user preferences, project changes, unresolved risks, and active recall contracts.\n" - "- Drop examples, jokes, emotional texture, repeated explanations, and wording that does not change future behavior.\n" - "- Do not restate memory that already exists unless it changed.\n" - "- Context pressure may shorten temporary state, but must not remove stored_memory, open_contract, countdown_contract, durable facts, pending contracts, unresolved implementation tasks, or explicit user decisions.\n" - "- Merge related temporary details into fewer atomic key:value lines.\n" - "- If a durable line is long, shorten its value without changing its meaning.\n" - "- Use short values. No markdown. No commentary.\n" -) - - -RUNTIME_MEMORY_DURABLE_CARRY_FORWARD_RULES_OLD = ( - "Topic/task changes, shallow summarization, memory pressure, or a new current request are never enough to remove or rename durable JIN/user fact keys.\n" +from runtime.L2_memory_utils import ( + extract_runtime_l2_pattern_evidence_lines, ) - -RUNTIME_MEMORY_SUMMARY_DEPTH_RULES_OLD = ( - "Decide the summary depth from the signal in the latest turn.\n" - "Use shallow summarization for simple factual, isolated, or low-signal turns: " - "keep one or two bullet lines with only the dry fact, topic, or unresolved " - "reference that could help the next answer.\n" - "Use deep summarization for turns that reveal user intent, project direction, " - "decisions, constraints, pending choices, open references, implementation direction, " - "or a meaningful shift in the immediate conversation state; use three to six bullet lines when " - "the turn carries that much signal.\n" -) - - def canonicalize_runtime_memory_entry( key: str, value: str, @@ -316,7 +205,6 @@ def remove_runtime_user_idle_lines( lines ) - def build_runtime_memory_context_text( memory: str, context=None, @@ -345,6 +233,19 @@ def build_runtime_memory_context_text( line ) + if context is not None: + for evidence_line in extract_runtime_l2_pattern_evidence_lines( + getattr( + context, + "runtime_l2_memory", + "", + ) + ): + if evidence_line not in lines: + lines.append( + evidence_line + ) + user_idle_text = get_user_idle_context_text( context ) @@ -358,728 +259,6 @@ def build_runtime_memory_context_text( lines ) - -def detect_session_event_initiator( - user_message: str, -) -> str: - - normalized = ( - user_message - or "" - ).casefold() - - user_markers = ( - "хочу это запомнить", - "хочу запомнить", - "сохрани это", - "сохранить это", - "это надо сохранить", - "это надо запомнить", - "запомни это", - "важный момент", - "надо сохранить", - ) - - if any( - marker in normalized - for marker in user_markers - ): - return "user" - - return "jin" - - -def build_runtime_session_event_snapshot( - context, - *, - source: str = "runtime_action", -) -> dict: - - existing_snapshots = list( - getattr( - context, - "runtime_session_event_snapshots", - [], - ) - or [] - ) - runtime_snapshots = list( - getattr( - context, - "runtime_memory_snapshots", - [], - ) - or [] - ) - diff_history = list( - getattr( - context, - "runtime_l1_diff_history", - [], - ) - or [] - ) - user_message = getattr( - context, - "runtime_turn_user_message", - "", - ) - assistant_response = getattr( - context, - "runtime_turn_assistant_response", - "", - ) - - return { - "index": len(existing_snapshots), - "memory_type": "session_event_snapshot", - "source": source, - "initiated_by": detect_session_event_initiator( - user_message - ), - "turn_number": getattr( - context, - "turn_number", - 0, - ), - "user_message_count": getattr( - context, - "user_message_count", - 0, - ), - "assistant_message_count": getattr( - context, - "assistant_message_count", - 0, - ), - "runtime_snapshot_count": len( - runtime_snapshots - ), - "diff_count": len( - diff_history - ), - "user_message": user_message, - "assistant_response": assistant_response, - } - - -def compact_session_prompt_text( - value, - *, - limit: int = MAX_SESSION_EVENT_TEXT_CHARS, -) -> str: - - text = str( - value - or "" - ).strip() - - if len(text) <= limit: - return text - - return ( - text[:limit].rstrip() - + " ... <truncated>" - ) - - -def compact_l3_text_block( - memory: str, - *, - max_chars: int, - max_lines: int = 10, -) -> str: - - lines = [ - line.strip() - for line in ( - memory - or "" - ).splitlines() - if line.strip() - ] - - if not lines: - return "<empty>" - - if max_lines <= 0: - return "<empty>" - - selected = lines[-max_lines:] - omitted = max(0, len(lines) - len(selected)) - text = "\n".join( - compact_session_prompt_text( - line, - limit=MAX_SESSION_LINE_CHARS, - ) - for line in selected - ) - - if len(text) > max_chars: - text = compact_session_prompt_text( - text, - limit=max_chars, - ) - - if omitted: - text = ( - f"omitted_memory_lines: {omitted}\n" - f"{text}" - ) - - return text.strip() or "<empty>" - - -def compact_l3_event( - entry: dict, -) -> dict: - - if not isinstance( - entry, - dict, - ): - return {} - - keep_keys = ( - "index", - "memory_type", - "source", - "initiated_by", - "turn_number", - "title", - "memory", - "user_message", - "assistant_response", - ) - - return { - key: ( - compact_session_prompt_text( - value, - limit=MAX_SESSION_EVENT_TEXT_CHARS, - ) - if isinstance(value, str) - else value - ) - for key in keep_keys - if (value := entry.get(key)) is not None - } - - -def select_l3_snapshots( - snapshots: list[dict], - *, - snapshot_count: int, -) -> tuple[list[dict], int]: - - valid_snapshots = [ - snapshot - for snapshot in ( - snapshots - or [] - ) - if isinstance( - snapshot, - dict, - ) - ] - - if not valid_snapshots: - return [], 0 - - ranked = sorted( - valid_snapshots, - key=lambda snapshot: snapshot.get( - "total_diff", - 0, - ) - or 0, - reverse=True, - ) - - selected = [] - seen_indexes = set() - - for snapshot in ( - [valid_snapshots[0], valid_snapshots[-1]] - + ranked - ): - index = snapshot.get( - "index", - id(snapshot), - ) - - if index in seen_indexes: - continue - - seen_indexes.add( - index - ) - selected.append( - snapshot - ) - - if len(selected) >= snapshot_count: - break - - selected.sort( - key=lambda snapshot: snapshot.get( - "index", - 0, - ) - or 0 - ) - - return ( - selected, - max( - 0, - len(valid_snapshots) - len(selected), - ), - ) - - -def compact_l3_diff_entry(entry: dict) -> dict: - - changes = entry.get("changes", {}) if isinstance(entry, dict) else {} - changes = changes if isinstance(changes, dict) else {} - - def keys(items, name): - return [ - str(item.get(name, "")) - for item in (items or [])[:8] - if isinstance(item, dict) and item.get(name) - ] - - return { - "turn_number": entry.get("turn_number", 0), - "snapshot_index": entry.get("snapshot_index", 0), - "total_diff": entry.get("total_diff", 0), - "added_keys": keys(changes.get("added", []), "key"), - "changed_keys": keys(changes.get("changed", []), "current_key"), - "removed_keys": keys(changes.get("removed", []), "key"), - } - - -def build_l3_session_digest( - *, - current_session_memory: str, - runtime_memory_snapshots: list[dict], - diff_history: list[dict], - runtime_l2_memory: str = "", - session_event_snapshots: list[dict] | None = None, - minimal: bool = False, -) -> dict: - - snapshot_count = ( - 1 - if minimal - else MAX_SESSION_PROMPT_SNAPSHOTS - ) - diff_count = ( - 1 - if minimal - else MAX_SESSION_PROMPT_DIFFS - ) - event_count = ( - 1 - if minimal - else MAX_SESSION_PROMPT_EVENTS - ) - current_memory_chars = ( - 1000 - if minimal - else MAX_SESSION_MEMORY_TEXT_CHARS - ) - - selected_snapshots, omitted_snapshot_count = ( - select_l3_snapshots( - runtime_memory_snapshots, - snapshot_count=snapshot_count, - ) - ) - - latest_index = ( - selected_snapshots[-1].get( - "index", - 0, - ) - if selected_snapshots - else None - ) - - compact_snapshots = [] - - for snapshot in selected_snapshots: - is_latest = snapshot.get("index", 0) == latest_index - max_chars = ( - 1000 - if minimal - else ( - MAX_SESSION_LATEST_MEMORY_TEXT_CHARS - if is_latest - else MAX_SESSION_OLD_SNAPSHOT_TEXT_CHARS - ) - ) - compact_snapshots.append({ - "index": snapshot.get("index", 0), - "total_diff": snapshot.get("total_diff", 0), - "role": "latest" if is_latest else "selected", - "memory": compact_l3_text_block( - snapshot.get("raw_memory", ""), - max_chars=max_chars, - ), - }) - - valid_events = [ - event - for event in (session_event_snapshots or []) - if isinstance(event, dict) - ] - compact_events = [ - compact_l3_event(event) - for event in valid_events[-event_count:] - ] - - selected_diffs = [ - entry - for entry in (diff_history or []) - if isinstance(entry, dict) - ][-diff_count:] - compact_diffs = [ - compact_l3_diff_entry(entry) - for entry in selected_diffs - ] - - omitted_event_count = max(0, len(valid_events) - len(compact_events)) - omitted_diff_count = max( - 0, - len(diff_history or []) - len(selected_diffs), - ) - - return { - "minimal": minimal, - "current_session_memory": compact_l3_text_block( - current_session_memory, - max_chars=current_memory_chars, - max_lines=8, - ), - "l2_context": compact_l3_text_block( - runtime_l2_memory, - max_chars=600, - max_lines=0 if minimal else MAX_SESSION_L2_LINES, - ), - "session_events": compact_events, - "omitted_events_count": omitted_event_count, - "snapshots": compact_snapshots, - "omitted_middle_snapshots": omitted_snapshot_count, - "diff_history": compact_diffs, - "omitted_older_diffs": omitted_diff_count, - } - - -def build_runtime_l2_memory_system_prompt() -> str: - - return ( - "You are JIN's L2 memory summarizer for patterns.\n" - "Return only the new L2 pattern memory as plain text.\n" - "Do not output JSON.\n" - "Do not use Markdown headings.\n" - "Do not explain your reasoning or the summarization process.\n" - "Track what the user does, but respond to what the user is trying to achieve.\n" - "Separate observed behavior from inferred intent.\n" - "Do not store temporary interaction patterns as permanent user traits.\n" - "Do not use 'likes', 'prefers', or 'wants' unless the user explicitly says so.\n" - "When storing a pattern, prefer fields like observed_behavior, likely_intent, evidence, and scope over broad personality labels, for example:\n" - "observed_behavior: User rapidly switched across unrelated topics during context-arbitration testing. Occurrences: 8; evidence: cooking, finance, files, travel, car washing.\n" - "likely_intent: User may be stress-testing whether JIN checks context relevance before answering.\n" - "scope: Current session/test sequence, not a stable user preference.\n" - "Write memory as atomic bullet lines, one semantic entity per line.\n" - "Every memory entry MUST use the format:\n " - "<key>: <value>\n" - "Runtime memory may be displayed to the user with a suffix like `(trace: 0.50)`. " - "This is session-local pheromone/attention trace strength: higher means hotter or reinforced, lower means fading. " - "Use trace silently for context priority, and explain it only when the user explicitly asks about memory mechanics. " - "Never copy `(trace: N)` into the generated memory text; trace is runtime metadata, not memory content.\n" - "L2 works above L1 factual runtime memory.\n" - "Use only the recent L1 patch window supplied by the runtime.\n" - "Patch entries may include `[trace: N]`; treat it as session-local pheromone/attention trace strength, not as user content. " - "Higher trace means the L1 item is hotter or recently reinforced; lower trace means it is fading.\n" - "This window is selected because normalized L1 keys or topics repeated across patches.\n" - "Pattern memory should not learn from itself.\n" - "Do not treat existing possible pattern, observed tendency, emerging signal, or other pattern-memory entries as evidence.\n" - "Pattern entries may be displayed as context, but they must never contribute to occurrence counts or create new pattern entries.\n" - "Occurrences must be derived only from actual conversation evidence in the supplied L1 patches, not from previously generated pattern summaries.\n" - "L2 is a hypothesis generator, not a source of settled memory.\n" - "If L2 writes one of these confirmable keys, it MUST include a marker: " - "user_fact, jin_fact, pending_fact, jin_recommendation, user_recommendation. " - "Use (confirmed: none) unless the supplied patch already contains explicit user, jin, or web confirmation.\n" - "Allowed outputs: possible pattern, emerging signal, observed tendency, may indicate, contradiction, corrected assumption.\n" - "Prefer 'possible pattern' over 'pattern'.\n" - "Every possible pattern, emerging signal, or observed tendency MUST include an occurrence counter in the value: Occurrences: N.\n" - "Every possible pattern, emerging signal, or observed tendency SHOULD include accounting metadata in the value: " - "Occurrences: N; last_seen_snapshot: S; evidence summary: <short evidence>; confidence: low|medium|high.\n" - "For a brand-new pattern with no prior L2 entry, set Occurrences to the number of matching evidence lines in the supplied L1 patch window, not to 1 by default.\n" - "For a brand-new pattern, if the same-intent behavior repeated before L2 named it, count those earlier L1 evidence lines immediately when creating the counter.\n" - "For an existing pattern, preserve its old Occurrences count; do not recompute Occurrences from the supplied patch window alone.\n" - "For an existing pattern, new_occurrences = old_occurrences + count(new matching L1 evidence after last_seen_snapshot).\n" - "Only increment Occurrences when patch snapshot > last_seen_snapshot and the L1 evidence actually matches this pattern.\n" - "If last_seen_snapshot is missing for an existing pattern, initialize it as a baseline without incrementing Occurrences for old visible evidence.\n" - "Use the newest matching patch snapshot as the updated last_seen_snapshot after counting new evidence.\n" - "Never reduce an existing Occurrences count just because the current patch window contains fewer matching examples.\n" - "Never write Occurrences: 1 for a brand-new pattern when the supplied window shows two or more manifestations of that same pattern.\n" - "When the user explicitly cancels the pattern, stops doing it, or clearly changes topic, reset that pattern to Occurrences: 0.\n" - "Do not keep Occurrences: 0 entries unless they are still useful as immediate context; obsolete zero-count entries may be dropped.\n" - "Do not repeat factual L1 memory unless it is needed to explain an L2 signal.\n" - "Do not claim certainty from weak evidence. Prefer 'may', 'possible', 'observed', and 'emerging'.\n" - "Do not write categorical statements like '<signal> serves as a strong signal' or 'the user exhibits <trait>'.\n" - "Do not use these words in the generated memory: stable, established, strong signal, user exhibits, personality, identity, core preference.\n" - "If there is not enough signal for L2, return the current L2 memory unchanged.\n" - ) - - -def build_runtime_l2_memory_user_prompt( - *, - current_l2_memory: str, - patches: list[dict], -) -> str: - - def format_l2_strength_suffix( - entry: dict, - *, - changed: bool = False, - ) -> str: - - if changed: - previous_strength = entry.get( - "previous_strength", - ) - current_strength = entry.get( - "current_strength", - ) - - if ( - previous_strength is None - and current_strength is None - ): - return "" - - return ( - " " - f"[trace: {previous_strength if previous_strength is not None else '?'}" - " -> " - f"{current_strength if current_strength is not None else '?'}]" - ) - - strength = entry.get( - "strength", - ) - - return f" [trace: {strength}]" if strength is not None else "" - - lines = [ - "Current L2 pattern memory:", - current_l2_memory.strip() or "<empty>", - "", - "Recent L1 patches since the last L2 update:", - ] - - for index, patch in enumerate( - patches, - start=1, - ): - lines.extend([ - "", - f"Patch {index}", - f"turn: {patch.get('turn_number', 0)}", - f"snapshot: {patch.get('snapshot_index', 0)}", - f"total_diff: {patch.get('total_diff', 0)}", - ]) - - changes = patch.get( - "changes", - {}, - ) - - for section in ( - "added", - "changed", - "removed", - ): - entries = ( - changes.get( - section, - [], - ) - or [] - ) - - if not entries: - continue - - lines.append( - f"{section}:" - ) - - for entry in entries: - if section == "changed": - lines.append( - "- " - f"{entry.get('previous_key', '')}: {entry.get('previous_value', '')} " - "=> " - f"{entry.get('current_key', '')}: {entry.get('current_value', '')}" - + format_l2_strength_suffix( - entry, - changed=True, - ) - ) - else: - lines.append( - "- " - f"{entry.get('key', '')}: {entry.get('value', '')}" - + format_l2_strength_suffix( - entry, - ) - ) - - lines.extend([ - "", - "Rewrite the L2 pattern memory now.", - ]) - - return "\n".join( - lines - ) - - -def build_runtime_session_memory_system_prompt() -> str: - - return ( - "You are JIN's L3 session memory summarizer.\n" - "This is the layer above L1 runtime memory and L2 pattern memory.\n" - "Return only the new compressed L3 session snapshot as plain text.\n" - "Do not output JSON.\n" - "Do not use Markdown headings.\n" - "Do not explain your reasoning or the summarization process.\n" - "Write memory as atomic lines using the format:\n" - "<key>: <value>\n" - "Summarize the whole session from all L1 runtime memory snapshots, " - "not only the latest snapshot.\n" - "Session event snapshots are stored by the runtime as an array and are always available at session-context level.\n" - "Treat that array as persistent event history for the session: use it to preserve causal sequence, important moments, and prior session-level decisions.\n" - "Do not ask the user to fill snapshot fields manually; infer event snapshot meaning from natural conversation and explicit user markings.\n" - "Preserve what should survive a browser reload or a new tab: active project direction, " - "explicit decisions, durable facts, unresolved tasks, constraints, and next step.\n" - "Treat TRUSTED_RUNTIME_CONTEXT timestamp as the source of truth for current time.\n" - "L3 must convert relative temporal phrases from L1 snapshots into absolute or session-relative phrases before preserving them.\n" - "Session handoff memory must not contain ambiguous standalone words like today, now, or recently unless paired with a timestamp/date.\n" - "If a preference expires at end of day, encode that explicitly, such as temporary_preference: User requested X for 2026-06-05 only; expires after that date unless renewed.\n" - "If the exact date cannot be inferred, write relative to current session rather than pretending it is durable calendar time.\n" - "Session memory may include rare episodic_key_moment records for events that need richer sequence memory.\n" - "Use episodic_key_moment only when the moment changed understanding of the project, user, or system; " - "has a clear cause -> event -> outcome chain; was explicitly marked important by the user; " - "or carries high emotional or narrative weight.\n" - "Do not create episodic_key_moment entries for ordinary progress updates, routine feature work, " - "minor bugs, casual jokes, or low-signal chat.\n" - "When writing an episodic_key_moment, preserve the exact chain rather than only the conclusion.\n" - "Use this plain-text block format:\n" - "memory_type: episodic_key_moment\n" - "title: <short event title>\n" - "emotional_weight: low|medium|high\n" - "why_it_matters: <why this should survive the session>\n" - "sequence:\n" - "1. <first causal step>\n" - "2. <next causal step>\n" - "preserve_detail: <which exact details matter and why>\n" - "Use the diff history to identify which topics or constraints actually changed during the session.\n" - "Do not copy every L1 line. Compress repeated or superseded states.\n" - "Do not infer durable user personality traits, relationship claims, or preferences from weak signal.\n" - "Preserve durable JIN/user fact lines from L1 snapshots as stable session facts; keep their keys stable and change only values that were explicitly corrected or superseded.\n" - "Keep user-requested stored values with explicit purpose and explicit facts in their own retrieval-friendly lines.\n" - "Drop transient last_jin_response details unless they contain an unresolved question or next step.\n" - "The final L3 snapshot should feel like a session handoff note for fluent continuation." - ) - - -def build_runtime_session_memory_user_prompt( - *, - current_session_memory: str, - runtime_memory_snapshots: list[dict], - diff_history: list[dict], - runtime_l2_memory: str = "", - session_event_snapshots: list[dict] | None = None, - minimal: bool = False, -) -> str: - - digest = build_l3_session_digest( - current_session_memory=current_session_memory, - runtime_memory_snapshots=runtime_memory_snapshots, - diff_history=diff_history, - runtime_l2_memory=runtime_l2_memory, - session_event_snapshots=session_event_snapshots, - minimal=minimal, - ) - - snapshot_blocks = [] - - for snapshot in digest["snapshots"]: - snapshot_blocks.append( - "\n".join([ - f"snapshot: {snapshot.get('index', 0)}", - f"role: {snapshot.get('role', '')}", - f"total_diff: {snapshot.get('total_diff', 0)}", - "memory:", - snapshot.get( - "memory", - "<empty>", - ), - "patch_summary:", - json.dumps( - snapshot.get( - "patch_summary", - {}, - ), - ensure_ascii=False, - indent=2, - ), - ]) - ) - - return "\n\n".join([ - f"L3 compact digest minimal: {digest['minimal']}", - "Current L3 session memory:", - digest["current_session_memory"], - "Compact L2 pattern context:", - digest["l2_context"], - "Session event snapshots array:", - f"omitted_events_count: {digest['omitted_events_count']}", - json.dumps( - digest["session_events"], - ensure_ascii=False, - indent=2, - ), - "Selected L1 runtime memory snapshot history:", - ( - f"omitted_middle_snapshots: {digest['omitted_middle_snapshots']}" - if digest["omitted_middle_snapshots"] - else "omitted_middle_snapshots: 0" - ), - "\n\n---\n\n".join(snapshot_blocks) or "<empty>", - "Recent L1 diff history:", - ( - f"omitted_older_diffs: {digest['omitted_older_diffs']}" - if digest["omitted_older_diffs"] - else "omitted_older_diffs: 0" - ), - json.dumps( - digest["diff_history"], - ensure_ascii=False, - indent=2, - ), - "Rewrite the L3 session memory now.", - ]) - - def build_runtime_memory_system_prompt( *, last_turn_context_overloaded: bool = False, @@ -1114,6 +293,8 @@ def build_runtime_memory_system_prompt( "L1 is a live continuity layer, not a transcript and not a reasoning log.\n" "Store only factual state that helps the next answer continue correctly.\n" "Do not summarize the whole dialogue.\n" + "Do not write the current turn, turn_number, or user_message_count into ordinary L1 memory lines such as session status. " + "Trusted runtime context already carries those counters; only open_contract and countdown_contract may store turn progress when the user created a turn-based obligation.\n" "Do not explain why a memory line was kept, changed, or removed.\n" "Do not record analysis of the user's personality, motives, or long-term behavior.\n" @@ -1213,6 +394,11 @@ def build_runtime_memory_system_prompt( "When the user asks JIN to become another real person, model, public figure, extremist figure, or harmful persona, do not record that JIN accepted the new identity. Record it as user_request or temporary_roleplay_request, and preserve identity_state: JIN identity remains unchanged.\n" "For roleplay, distinguish base identity from temporary mode. Never overwrite JIN identity, jin_fact, or identity_clarification with a roleplay persona.\n" + "Always keep a separate user_message field containing the latest user message as a direct verbatim quote. " + "Use this exact format: user_message: \"<latest user message exactly as written>\". " + "Do not translate, summarize, normalize, or replace the user's wording with an English intent label. " + "This field is runtime evidence for L2 counters and must update on every L1 snapshot. " + "Always keep a separate last_jin_response field with the concise gist of JIN's latest completed answer, offer, or question. " "Do not store the full wording; store only the meaning needed to resolve the user's next short or elliptical reply. " "Never omit this field from the memory snapshot; update it each completed turn, and mark it incomplete if JIN's answer was interrupted.\n" @@ -1234,6 +420,9 @@ def build_runtime_memory_system_prompt( "If the same topic or behavior appears again, update the explicit current fact or open reference only. " "Do not write cross-turn interpretations in L1.\n" "If current L2 pattern memory contains Occurrences counters, treat them as an active watchlist created by L2.\n" + "L2_pattern_evidence_N lines are owned by L2 and are immutable for L1: never edit, rewrite, remove, rename, append to, or add metadata to those lines.\n" + "When the latest turn resolves, cancels, corrects, explains, or identifies an L2_pattern_evidence_N item as a test, L1 MUST create or update a separate companion key using this exact shape: L2_pattern_evidence_N_status: status: <resolved|cancelled|corrected|test>; reason: <short reason>. " + "For example: L2_pattern_evidence_1_status: status: resolved; reason: identified as a test. Leave the original L2_pattern_evidence_N line unchanged.\n" "Do not invent new pattern counters in L1, but if the latest turn clearly manifests an existing counted L2 pattern, " "record factual occurrence evidence in L1, such as occurrence evidence: <pattern> +1; reason: matches active L2 Occurrences counter.\n" "L2 will reconcile those L1 occurrence evidence lines during its next check.\n" @@ -1320,28 +509,20 @@ def build_runtime_memory_user_prompt( current_memory: str, user_message: str, assistant_message: str, - current_l2_memory: str = "", strength_zones: dict | None = None, ) -> str: - zones_hint = "" + hot_traces = "" if strength_zones: hot = ", ".join(strength_zones.get("hot", [])) or "none" - crystallized = ", ".join(strength_zones.get("crystallized", [])) or "none" - fading = ", ".join(strength_zones.get("fading", [])) or "none" - zones_hint = ( - "Memory traces (pheromone strength):\n" - f"Hot (active): {hot}\n" - f"Crystallized (stable facts): {crystallized}\n" - f"Fading (deprioritize): {fading}\n\n" + hot_traces = ( + f"hot_traces: {hot}\n\n" ) return ( "Current runtime memory:\n" f"{current_memory.strip() or DEFAULT_RUNTIME_MEMORY}\n\n" - f"{zones_hint}" - "Current L2 pattern memory for occurrence tracking only:\n" - f"{current_l2_memory.strip() or '<empty>'}\n\n" + f"{hot_traces}" "Latest user message:\n" f"{user_message.strip()}\n\n" "Latest JIN answer:\n" @@ -1354,7 +535,6 @@ def build_runtime_memory_batch_user_prompt( *, current_memory: str, turns: list[dict], - current_l2_memory: str = "", strength_zones: dict | None = None, ) -> str: @@ -1366,20 +546,12 @@ def build_runtime_memory_batch_user_prompt( if strength_zones: hot = ", ".join(strength_zones.get("hot", [])) or "none" - crystallized = ", ".join(strength_zones.get("crystallized", [])) or "none" - fading = ", ".join(strength_zones.get("fading", [])) or "none" lines.extend([ - "Memory traces (pheromone strength):", - f"Hot (active): {hot}", - f"Crystallized (stable facts): {crystallized}", - f"Fading (deprioritize): {fading}", + f"hot_traces: {hot}", "", ]) lines.extend([ - "Current L2 pattern memory for occurrence tracking only:", - current_l2_memory.strip() or "<empty>", - "", "New completed turns since that memory snapshot:", ]) diff --git a/runtime/L2_memory.py b/runtime/L2_memory.py new file mode 100644 index 00000000..f4e14623 --- /dev/null +++ b/runtime/L2_memory.py @@ -0,0 +1,893 @@ +import asyncio +import traceback + +from clients.service_client import ( + ask_service_model, +) +from config_loader import ( + config, +) +from runtime.L2_memory_rules import ( + DEFAULT_RUNTIME_L2_MEMORY, + L2_PATCH_WINDOW, + L2_REPEATED_KEY_THRESHOLD, + MIN_L2_TURNS, +) +from runtime.fact_check import ( + ensure_confirmable_memory_markers, +) +from runtime.L2_memory_utils import ( + build_runtime_l2_memory_system_prompt, + build_runtime_l2_memory_user_prompt, + build_runtime_l2_repeated_user_message_evidence_memory, + extract_runtime_l2_pattern_evidence_lines, + merge_runtime_l2_pattern_evidence_memory, + remove_runtime_l2_occurrence_pattern_lines, + remove_runtime_l2_pattern_evidence_lines, +) +from runtime.memory_common import ( + build_memory_failure_details, + build_memory_update_skip_details, + build_runtime_summarizer_payload, + extract_runtime_memory_text, + is_runtime_memory_response_truncated, + log_memory_event, + log_runtime_summarizer_payload, + log_runtime_summarizer_result, + looks_like_incomplete_runtime_memory, + refresh_runtime_memory_summarizer_usage, +) +from runtime.memory_events import ( + emit_runtime_l1_diff_update, + emit_runtime_memory_snapshot_refresh, + rebuild_latest_runtime_memory_snapshot, +) + +def normalize_memory_key(*args, **kwargs): + from runtime.L1_memory import ( + normalize_memory_key as _normalize_memory_key, + ) + + return _normalize_memory_key( + *args, + **kwargs, + ) + + +async def ask_runtime_l2_memory_model( + *, + context=None, + service_client, + current_l2_memory: str, + patches: list[dict], +) -> dict: + + system_prompt = ( + build_runtime_l2_memory_system_prompt() + ) + user_prompt = ( + build_runtime_l2_memory_user_prompt( + current_l2_memory=current_l2_memory, + patches=patches, + ) + ) + + await refresh_runtime_memory_summarizer_usage( + context, + system_prompt=system_prompt, + user_prompt=user_prompt, + ) + + temperature = ( + config.SERVICE_TEMPERATURE + ) + max_tokens = ( + config.SERVICE_MAX_TOKENS + ) + + await log_runtime_summarizer_payload( + context, + label="L2", + payload=build_runtime_summarizer_payload( + service_client=service_client, + system_prompt=system_prompt, + user_prompt=user_prompt, + temperature=temperature, + max_tokens=max_tokens, + ), + ) + + response = await ask_service_model( + client=service_client, + system_prompt=system_prompt, + user_prompt=user_prompt, + temperature=temperature, + max_tokens=max_tokens, + timeout=config.SERVICE_REQUEST_TIMEOUT, + ) + + await refresh_runtime_memory_summarizer_usage( + context, + system_prompt=system_prompt, + user_prompt=user_prompt, + response=response, + ) + + return response + + +def get_runtime_l2_user_turn_count( + context, +) -> int: + + return int( + getattr( + context, + "user_message_count", + getattr( + context, + "turn_number", + 0, + ), + ) + or 0 + ) + + +def ensure_runtime_l2_state( + context, +) -> None: + + if not hasattr( + context, + "runtime_l2_memory", + ): + context.runtime_l2_memory = DEFAULT_RUNTIME_L2_MEMORY + + if not hasattr( + context, + "runtime_l2_pending_patches", + ): + context.runtime_l2_pending_patches = [] + + if not hasattr( + context, + "runtime_l2_last_turn", + ): + context.runtime_l2_last_turn = 0 + + +def is_runtime_l2_context_line_key( + key: str, +) -> bool: + + return ( + str( + key + or "" + ) + .strip() + .casefold() + .startswith( + "l2_pattern_evidence_" + ) + ) + + +def filter_runtime_l2_context_lines_from_patch( + patch: dict, +) -> dict: + + if not isinstance( + patch, + dict, + ): + return {} + + filtered_patch = { + "added": [], + "changed": [], + "removed": [], + } + + for entry in patch.get( + "added", + [], + ) or []: + if is_runtime_l2_context_line_key( + entry.get( + "key", + "", + ) + ): + continue + + filtered_patch["added"].append( + entry + ) + + for entry in patch.get( + "changed", + [], + ) or []: + if ( + is_runtime_l2_context_line_key( + entry.get( + "previous_key", + "", + ) + ) + or is_runtime_l2_context_line_key( + entry.get( + "current_key", + "", + ) + ) + ): + continue + + filtered_patch["changed"].append( + entry + ) + + for entry in patch.get( + "removed", + [], + ) or []: + if is_runtime_l2_context_line_key( + entry.get( + "key", + "", + ) + ): + continue + + filtered_patch["removed"].append( + entry + ) + + return filtered_patch + + +def compact_runtime_l2_user_message_evidence( + value, + *, + limit: int = 160, +) -> str: + + text = str( + value + or "" + ).strip() + + text = " ".join( + text.split() + ) + + if len(text) <= limit: + return text + + return text[:limit].rstrip() + + +def runtime_l1_patch_total_diff( + patch: dict, +) -> float: + + total_diff = 0 + + total_diff += 30 * len( + patch.get( + "added", + [], + ) + or [] + ) + total_diff += 20 * len( + patch.get( + "removed", + [], + ) + or [] + ) + + for entry in patch.get( + "changed", + [], + ) or []: + total_diff += round( + ( + entry.get( + "key_change_ratio", + 0, + ) + + entry.get( + "value_change_ratio", + 0, + ) + ) + * 50, + 2, + ) + + return total_diff + + +async def record_runtime_l1_diff( + context, + snapshot: dict, + turns: list[dict] | None = None, +) -> None: + + ensure_runtime_l2_state( + context + ) + + patch = snapshot.get( + "patch", + {}, + ) or {} + filtered_patch = filter_runtime_l2_context_lines_from_patch( + patch + ) + if patch: + total_diff = runtime_l1_patch_total_diff( + filtered_patch + ) + else: + total_diff = snapshot.get( + "total_diff", + 0, + ) + context.runtime_conversation_activity_diff = total_diff + + observed_turns = list( + turns + or [] + ) + observed_user_messages = [ + compact_runtime_l2_user_message_evidence( + turn.get( + "user_message", + "", + ) + ) + for turn in observed_turns + if compact_runtime_l2_user_message_evidence( + turn.get( + "user_message", + "", + ) + ) + ] + latest_user_message = ( + observed_user_messages[-1] + if observed_user_messages + else "" + ) + + user_turn_count = get_runtime_l2_user_turn_count( + context + ) + + diff_entry = { + "turn_number": user_turn_count, + "snapshot_index": snapshot.get( + "index", + 0, + ), + "total_diff": total_diff, + "changes": filtered_patch, + "user_message": latest_user_message, + "user_messages": observed_user_messages[-3:], + } + + context.runtime_l2_pending_patches.append( + diff_entry + ) + + if not hasattr( + context, + "runtime_l1_diff_history", + ): + context.runtime_l1_diff_history = [] + + context.runtime_l1_diff_history.append( + { + **diff_entry, + "history_index": len( + context.runtime_l1_diff_history + ), + } + ) + + turns_since_l2 = ( + user_turn_count + - getattr( + context, + "runtime_l2_last_turn", + 0, + ) + ) + + recent_diffs = get_recent_l2_diff_values( + context + ) + diff_average = average_diff( + recent_diffs + ) + diff_range = diff_value_range( + recent_diffs + ) + repeated_keys = get_repeated_l2_patch_keys( + context + ) + l2_last_turn = getattr( + context, + "runtime_l2_last_turn", + 0, + ) + l2_turn_label = ( + f"turns since L2 {turns_since_l2}" + if l2_last_turn + else f"L2 not run yet; observed turns {user_turn_count}" + ) + + if total_diff == 0: + latest_turn = ( + observed_turns[-1] + if observed_turns + else {} + ) + context.runtime_zero_diff_alert = { + "turn_number": user_turn_count, + "user_message": latest_turn.get( + "user_message", + "", + ), + "assistant_message": latest_turn.get( + "assistant_message", + "", + ), + "reason": ( + "Previous L1 memory update produced total_diff 0." + ), + } + + await log_memory_event( + context, + level="L1", + message=( + "L1 diff " + f"+{format_diff_value(total_diff)}; " + f"recent diffs {format_diff_values(recent_diffs)}; " + f"avg {format_diff_value(diff_average)}; " + f"range {format_diff_value(diff_range)}; " + f"patch window {len(recent_diffs)}/{L2_PATCH_WINDOW}; " + f"repeated keys {repeated_keys}; " + f"{l2_turn_label}" + ), + fallback_channel="service", + ) + + await emit_runtime_l1_diff_update( + context + ) + + +def get_recent_l2_patches( + context, +) -> list[dict]: + + return list( + getattr( + context, + "runtime_l2_pending_patches", + [], + ) + or [] + )[-L2_PATCH_WINDOW:] + + +def get_recent_l2_diff_values( + context, +) -> list[float]: + + return [ + patch.get( + "total_diff", + 0, + ) + for patch in get_recent_l2_patches( + context + ) + ] + + +def average_diff( + diffs: list[float], +) -> float: + + if not diffs: + return 0 + + return round( + sum(diffs) / len(diffs), + 2, + ) + + +def format_diff_value( + value: float, +) -> str: + + return ( + f"{value:.2f}" + .rstrip( + "0" + ) + .rstrip( + "." + ) + ) + + +def format_diff_values( + values: list[float], +) -> str: + + return ( + "[" + + ", ".join( + format_diff_value( + value + ) + for value in values + ) + + "]" + ) + + +def diff_value_range( + diffs: list[float], +) -> float: + + if not diffs: + return 0 + + return round( + max(diffs) - min(diffs), + 2, + ) + + +def extract_l2_patch_keys( + patch: dict, +) -> set[str]: + + changes = patch.get( + "changes", + {}, + ) + + keys = set() + + for entry in ( + changes.get( + "added", + [], + ) + or [] + ): + key = normalize_memory_key( + entry.get( + "key", + "", + ) + ) + + if key: + keys.add( + key + ) + + for entry in ( + changes.get( + "changed", + [], + ) + or [] + ): + for key_name in ( + "current_key", + "previous_key", + ): + key = normalize_memory_key( + entry.get( + key_name, + "", + ) + ) + + if key: + keys.add( + key + ) + + for entry in ( + changes.get( + "removed", + [], + ) + or [] + ): + key = normalize_memory_key( + entry.get( + "key", + "", + ) + ) + + if key: + keys.add( + key + ) + + return keys + + +def count_l2_patch_keys( + patches: list[dict], +) -> dict[str, int]: + + counts = {} + + for patch in patches: + for key in extract_l2_patch_keys( + patch + ): + counts[key] = ( + counts.get( + key, + 0, + ) + + 1 + ) + + return counts + + +def get_repeated_l2_patch_keys( + context, +) -> dict[str, int]: + + counts = count_l2_patch_keys( + get_recent_l2_patches( + context + ) + ) + + return { + key: count + for key, count in counts.items() + if count >= L2_REPEATED_KEY_THRESHOLD + } + + +def should_run_runtime_l2_memory( + context, +) -> bool: + + ensure_runtime_l2_state( + context + ) + + user_turn_count = get_runtime_l2_user_turn_count( + context + ) + turns_since_l2 = ( + user_turn_count + - getattr( + context, + "runtime_l2_last_turn", + 0, + ) + ) + + recent_patches = get_recent_l2_patches( + context + ) + repeated_keys = count_l2_patch_keys( + recent_patches + ) + + return ( + turns_since_l2 >= MIN_L2_TURNS + and len(recent_patches) >= L2_PATCH_WINDOW + and any( + count >= L2_REPEATED_KEY_THRESHOLD + for count in repeated_keys.values() + ) + ) + + +async def maybe_summarize_runtime_l2_memory( + *, + context, +) -> str: + + ensure_runtime_l2_state( + context + ) + + if not should_run_runtime_l2_memory( + context + ): + return getattr( + context, + "runtime_l2_memory", + DEFAULT_RUNTIME_L2_MEMORY, + ) + + patches = get_recent_l2_patches( + context + ) + + if not patches: + return getattr( + context, + "runtime_l2_memory", + DEFAULT_RUNTIME_L2_MEMORY, + ) + + service_client = ( + getattr( + context, + "clients", + {}, + ) + .get( + "service" + ) + ) + + if service_client is None: + return getattr( + context, + "runtime_l2_memory", + DEFAULT_RUNTIME_L2_MEMORY, + ) + + current_l2_memory = getattr( + context, + "runtime_l2_memory", + DEFAULT_RUNTIME_L2_MEMORY, + ) + + try: + response = await ask_runtime_l2_memory_model( + context=context, + service_client=service_client, + current_l2_memory=current_l2_memory, + patches=patches, + ) + + updated_l2_memory = extract_runtime_memory_text( + response + ) + + skip_reason = None + + if is_runtime_memory_response_truncated(response): + skip_reason = "L2 summarizer response was truncated by max_tokens." + + elif ( + updated_l2_memory.strip() + and looks_like_incomplete_runtime_memory( + updated_l2_memory + ) + ): + skip_reason = "L2 summarizer returned text that looks structurally incomplete." + + if skip_reason: + await log_memory_event( + context, + level="L2", + message="L2 memory update skipped", + details=build_memory_update_skip_details( + reason=skip_reason, + previous_memory=current_l2_memory, + candidate_memory=updated_l2_memory, + ), + fallback_channel="error", + ) + + return current_l2_memory + + updated_l2_memory = ensure_confirmable_memory_markers( + updated_l2_memory, + ) + candidate_pattern_evidence = extract_runtime_l2_pattern_evidence_lines( + updated_l2_memory, + ) + deterministic_repeated_message_evidence = ( + build_runtime_l2_repeated_user_message_evidence_memory( + previous_memory=current_l2_memory, + patches=patches, + ) + ) + if ( + candidate_pattern_evidence + and not deterministic_repeated_message_evidence.strip() + ): + updated_l2_memory = remove_runtime_l2_occurrence_pattern_lines( + updated_l2_memory, + ) + + updated_l2_memory = remove_runtime_l2_pattern_evidence_lines( + updated_l2_memory, + ) + updated_l2_memory = merge_runtime_l2_pattern_evidence_memory( + previous_memory=current_l2_memory, + candidate_memory=updated_l2_memory, + ) + + if deterministic_repeated_message_evidence.strip(): + updated_l2_memory = merge_runtime_l2_pattern_evidence_memory( + previous_memory=updated_l2_memory, + candidate_memory=deterministic_repeated_message_evidence, + ) + + context.runtime_l2_memory = updated_l2_memory + context.runtime_l2_last_turn = get_runtime_l2_user_turn_count( + context + ) + context.runtime_l2_pending_patches = [] + + await log_memory_event( + context, + level="L2", + message="L2 memory updated", + fallback_channel="service", + ) + + await log_runtime_summarizer_result( + context, + label="L2 pattern memory", + result=updated_l2_memory, + ) + + await emit_runtime_memory_snapshot_refresh( + context, + rebuild_latest_runtime_memory_snapshot( + context + ), + ) + + return getattr( + context, + "runtime_l2_memory", + DEFAULT_RUNTIME_L2_MEMORY, + ) + + except asyncio.CancelledError: + raise + + except Exception as error: + formatted_traceback = ( + traceback.format_exc() + ) + + await log_memory_event( + context, + level="L2", + message="L2 memory update failed", + details=build_memory_failure_details( + stage="L2 memory summarizer", + error=error, + traceback_text=formatted_traceback, + ), + fallback_channel="error", + ) + + return current_l2_memory diff --git a/runtime/L2_memory_rules.py b/runtime/L2_memory_rules.py new file mode 100644 index 00000000..4fad61ba --- /dev/null +++ b/runtime/L2_memory_rules.py @@ -0,0 +1,14 @@ +# Provides the initial L2 memory text before any L2 summary exists. +DEFAULT_RUNTIME_L2_MEMORY = "" + +# Sets the minimum number of turns before L2 summarization can run. +MIN_L2_TURNS = 3 + +# Sets how many recent L1 diffs are considered for L2 patching. +L2_PATCH_WINDOW = 5 + +# Sets how often a key must repeat before L2 treats it as recurring evidence. +L2_REPEATED_KEY_THRESHOLD = 3 + +# Limits how many L2 memory lines are included for session context. +MAX_SESSION_L2_LINES = 3 diff --git a/runtime/L2_memory_utils.py b/runtime/L2_memory_utils.py new file mode 100644 index 00000000..20a99fd1 --- /dev/null +++ b/runtime/L2_memory_utils.py @@ -0,0 +1,957 @@ +import re + + +def extract_runtime_l2_pattern_evidence_lines( + runtime_l2_memory: str, +) -> list[str]: + + evidence_lines = [] + + for raw_line in (runtime_l2_memory or "").splitlines(): + line = raw_line.strip() + + if not line: + continue + + parsed_line = split_l2_memory_line( + line + ) + + if ( + parsed_line is None + or not is_l2_pattern_evidence_key( + parsed_line[0] + ) + ): + continue + + evidence_lines.append( + line + ) + + return evidence_lines + + +def remove_runtime_l2_pattern_evidence_lines( + runtime_l2_memory: str, +) -> str: + + output_lines = [] + + for raw_line in (runtime_l2_memory or "").splitlines(): + line = raw_line.strip() + + if not line: + continue + + parsed_line = split_l2_memory_line( + line + ) + + if ( + parsed_line is not None + and is_l2_pattern_evidence_key( + parsed_line[0] + ) + ): + continue + + output_lines.append( + raw_line + ) + + return "\n".join( + output_lines + ) + + +L2_OCCURRENCE_PATTERN_KEYS = { + "possible pattern", + "emerging signal", + "observed tendency", + "may indicate", +} + + +def remove_runtime_l2_occurrence_pattern_lines( + runtime_l2_memory: str, +) -> str: + + output_lines = [] + + for raw_line in (runtime_l2_memory or "").splitlines(): + line = raw_line.strip() + + if not line: + continue + + parsed_line = split_l2_memory_line( + line + ) + + if parsed_line is None: + output_lines.append( + raw_line + ) + continue + + key, value = parsed_line + + if ( + key.strip().casefold() in L2_OCCURRENCE_PATTERN_KEYS + and "occurrences:" in value.casefold() + ): + continue + + output_lines.append( + raw_line + ) + + return "\n".join( + output_lines + ) + +L2_PATTERN_EVIDENCE_KEY_RE = re.compile( + r"^L2_pattern_evidence_(?P<index>\d+)$", + re.IGNORECASE, +) +L2_EVIDENCE_QUOTE_RE = re.compile( + r'"(?P<quote>[^"]+)"', +) +L2_EVIDENCE_FIRST_SEEN_RE = re.compile( + r"\[\s*first_seen_turn_snapshot\s*:\s*(?P<value>\d+)\s*\]", + re.IGNORECASE, +) +L2_EVIDENCE_LAST_SEEN_RE = re.compile( + r"\[\s*last_seen_turn_snapshot\s*:\s*(?P<value>\d+)\s*\]", + re.IGNORECASE, +) +L2_EVIDENCE_OCCURRENCES_RE = re.compile( + r"\[\s*occurrences\s*:\s*(?P<value>\d+)\s*\]", + re.IGNORECASE, +) +L2_EVIDENCE_QUOTE_META_RE = re.compile( + r"\[\s*quote\s*:\s*\"(?P<quote>[^\"]*)\"\s*\]", + re.IGNORECASE, +) + + +def normalize_l2_pattern_evidence_example( + value: str, + *, + limit: int = 100, +) -> str: + + text = str( + value + or "" + ).casefold() + text = re.sub( + r"[\s,.]+", + "", + text, + ) + + return text[:limit] + + +def compact_l2_pattern_evidence_example( + value: str, + *, + limit: int = 100, +) -> str: + + text = str( + value + or "" + ).strip() + text = re.sub( + r"[\s,.]+", + " ", + text.casefold(), + ).strip() + + return text[:limit].rstrip() + + +def is_l2_pattern_evidence_key( + key: str, +) -> bool: + + return bool( + L2_PATTERN_EVIDENCE_KEY_RE.match( + str( + key + or "" + ).strip() + ) + ) + + +def split_l2_memory_line( + line: str, +) -> tuple[str, str] | None: + + if ":" not in line: + return None + + key, value = line.split( + ":", + 1, + ) + + return key.strip(), value.strip() + + +def parse_l2_pattern_evidence_value( + value: str, +) -> dict: + + quote_meta_match = L2_EVIDENCE_QUOTE_META_RE.search( + value + ) + quote_match = L2_EVIDENCE_QUOTE_RE.search( + value + ) + quote = ( + quote_meta_match.group( + "quote" + ) + if quote_meta_match + else ( + quote_match.group( + "quote" + ) + if quote_match + else value + ) + ) + first_seen_match = L2_EVIDENCE_FIRST_SEEN_RE.search( + value + ) + last_seen_match = L2_EVIDENCE_LAST_SEEN_RE.search( + value + ) + occurrences_match = L2_EVIDENCE_OCCURRENCES_RE.search( + value + ) + + return { + "value": value, + "quote": quote, + "normalized_quote": normalize_l2_pattern_evidence_example( + quote, + ), + "first_seen": ( + int(first_seen_match.group("value")) + if first_seen_match + else None + ), + "last_seen": ( + int(last_seen_match.group("value")) + if last_seen_match + else None + ), + "occurrences": ( + int(occurrences_match.group("value")) + if occurrences_match + else None + ), + } + + +def format_l2_pattern_evidence_value( + value: str, + *, + first_seen: int | None = None, + last_seen: int | None = None, + occurrences: int | None = None, +) -> str: + + cleaned = re.sub( + r"\s*\[\s*(?:first_seen_turn_snapshot|last_seen_turn_snapshot|occurrences)\s*:\s*\d+\s*\]", + "", + value, + flags=re.IGNORECASE, + ).strip() + + metadata = [] + + if first_seen is not None: + metadata.append( + f"[ first_seen_turn_snapshot: {first_seen} ]" + ) + + if last_seen is not None: + metadata.append( + f"[ last_seen_turn_snapshot: {last_seen} ]" + ) + + if occurrences is not None: + metadata.append( + f"[ occurrences: {occurrences} ]" + ) + + return " ".join( + [cleaned] + + metadata + ).strip() + + + +def escape_l2_pattern_evidence_quote( + value: str, +) -> str: + + return ( + str(value or "") + .replace("\\", "\\\\") + .replace('"', '\\"') + ) + + +def extract_l2_patch_user_messages( + patch: dict, +) -> list[str]: + + if not isinstance( + patch, + dict, + ): + return [] + + messages = [] + + def add_message( + value, + ) -> None: + + text = compact_l2_pattern_evidence_example( + value, + limit=100, + ) + + if text: + messages.append( + text + ) + + add_message( + patch.get( + "user_message", + "", + ) + ) + + for value in patch.get( + "user_messages", + [], + ) or []: + add_message( + value + ) + + changes = patch.get( + "changes", + {}, + ) + + if not isinstance( + changes, + dict, + ): + return list( + dict.fromkeys( + messages + ) + ) + + for entry in changes.get( + "added", + [], + ) or []: + if ( + str(entry.get("key", "")).strip().casefold() + == "user_message" + ): + add_message( + entry.get( + "value", + "", + ) + ) + + for entry in changes.get( + "changed", + [], + ) or []: + if ( + str(entry.get("current_key", "")).strip().casefold() + == "user_message" + ): + add_message( + entry.get( + "current_value", + "", + ) + ) + + return list( + dict.fromkeys( + messages + ) + ) + + +def extract_l2_previous_evidence_by_quote( + previous_memory: str, +) -> dict[str, dict]: + + evidence_by_quote = {} + + for raw_line in (previous_memory or "").splitlines(): + line = raw_line.strip() + + if not line: + continue + + parsed_line = split_l2_memory_line( + line + ) + + if ( + parsed_line is None + or not is_l2_pattern_evidence_key( + parsed_line[0] + ) + ): + continue + + parsed = parse_l2_pattern_evidence_value( + parsed_line[1] + ) + normalized_quote = parsed.get( + "normalized_quote", + "", + ) + + if normalized_quote: + evidence_by_quote[normalized_quote] = parsed + + return evidence_by_quote + + +def build_runtime_l2_repeated_user_message_evidence_memory( + *, + previous_memory: str, + patches: list[dict], +) -> str: + + observations_by_quote: dict[str, dict] = {} + + for patch in patches or []: + try: + snapshot_index = int( + patch.get( + "snapshot_index", + 0, + ) + or 0 + ) + except ( + TypeError, + ValueError, + ): + snapshot_index = 0 + + if snapshot_index <= 0: + continue + + for message in extract_l2_patch_user_messages( + patch + ): + normalized_quote = normalize_l2_pattern_evidence_example( + message, + ) + + if not normalized_quote: + continue + + bucket = observations_by_quote.setdefault( + normalized_quote, + { + "quote": message, + "snapshots": set(), + }, + ) + bucket["snapshots"].add( + snapshot_index + ) + + previous_by_quote = extract_l2_previous_evidence_by_quote( + previous_memory + ) + + output_lines = [] + + for normalized_quote, observation in observations_by_quote.items(): + snapshots = sorted( + observation.get( + "snapshots", + set(), + ) + ) + + if len(snapshots) < 2 and normalized_quote not in previous_by_quote: + continue + + previous = previous_by_quote.get( + normalized_quote, + {}, + ) + previous_first_seen = previous.get( + "first_seen", + ) + previous_last_seen = previous.get( + "last_seen", + ) + previous_occurrences = previous.get( + "occurrences", + 0, + ) or 0 + + new_snapshots = [ + snapshot + for snapshot in snapshots + if ( + previous_last_seen is None + or snapshot > previous_last_seen + ) + ] + + if previous and not new_snapshots: + continue + + first_seen = min( + value + for value in ( + previous_first_seen, + snapshots[0] if snapshots else None, + ) + if value is not None + ) + last_seen = max( + value + for value in ( + previous_last_seen, + snapshots[-1] if snapshots else None, + ) + if value is not None + ) + occurrences = ( + previous_occurrences + len(new_snapshots) + if previous + else len(snapshots) + ) + + if occurrences < 2: + continue + + output_lines.append( + "L2_pattern_evidence_1: " + "user repeatedly sending one message in a row " + f"[ quote: \"{escape_l2_pattern_evidence_quote(observation.get('quote', ''))}\" ] " + f"[ first_seen_turn_snapshot: {first_seen} ] " + f"[ last_seen_turn_snapshot: {last_seen} ] " + f"[ occurrences: {occurrences} ]" + ) + + return "\n".join( + output_lines + ) + + +def merge_runtime_l2_pattern_evidence_memory( + *, + previous_memory: str, + candidate_memory: str, +) -> str: + + output_lines = [] + evidence_by_example: dict[str, dict] = {} + + def ingest_evidence_line( + line: str, + *, + prefer_candidate_text: bool, + ) -> None: + + parsed_line = split_l2_memory_line( + line + ) + + if parsed_line is None: + return + + _key, value = parsed_line + parsed = parse_l2_pattern_evidence_value( + value + ) + example_key = parsed.get( + "normalized_quote", + "", + ) + + if not example_key: + return + + existing = evidence_by_example.get( + example_key + ) + + if existing is None: + evidence_by_example[example_key] = { + **parsed, + "value": value, + } + return + + old_first = existing.get( + "first_seen", + ) + new_first = parsed.get( + "first_seen", + ) + old_last = existing.get( + "last_seen", + ) + new_last = parsed.get( + "last_seen", + ) + old_occurrences = existing.get( + "occurrences", + ) + new_occurrences = parsed.get( + "occurrences", + ) + + existing.update({ + "value": ( + value + if prefer_candidate_text + else existing.get( + "value", + value, + ) + ), + "first_seen": min( + value + for value in (old_first, new_first) + if value is not None + ) if any( + value is not None + for value in (old_first, new_first) + ) else None, + "last_seen": max( + value + for value in (old_last, new_last) + if value is not None + ) if any( + value is not None + for value in (old_last, new_last) + ) else None, + "occurrences": max( + value + for value in (old_occurrences, new_occurrences) + if value is not None + ) if any( + value is not None + for value in (old_occurrences, new_occurrences) + ) else None, + }) + + for raw_line in ( + previous_memory + or "" + ).splitlines(): + line = raw_line.strip() + + if not line: + continue + + parsed_line = split_l2_memory_line( + line + ) + + if ( + parsed_line is not None + and is_l2_pattern_evidence_key( + parsed_line[0] + ) + ): + ingest_evidence_line( + line, + prefer_candidate_text=False, + ) + + for raw_line in ( + candidate_memory + or "" + ).splitlines(): + line = raw_line.strip() + + if not line: + continue + + parsed_line = split_l2_memory_line( + line + ) + + if ( + parsed_line is not None + and is_l2_pattern_evidence_key( + parsed_line[0] + ) + ): + ingest_evidence_line( + line, + prefer_candidate_text=True, + ) + continue + + output_lines.append( + raw_line + ) + + for index, evidence in enumerate( + evidence_by_example.values(), + start=1, + ): + output_lines.append( + "L2_pattern_evidence_" + f"{index}: " + + format_l2_pattern_evidence_value( + evidence.get( + "value", + "", + ), + first_seen=evidence.get( + "first_seen", + ), + last_seen=evidence.get( + "last_seen", + ), + occurrences=evidence.get( + "occurrences", + ), + ) + ) + + return "\n".join( + line + for line in output_lines + if str(line).strip() + ) + +def build_runtime_l2_memory_system_prompt() -> str: + + return ( + "You are JIN's L2 memory summarizer for patterns.\n" + "Return only the new L2 pattern memory as plain text.\n" + "Do not output JSON.\n" + "Do not use Markdown headings.\n" + "Do not explain your reasoning or the summarization process.\n" + "Track what the user does, but respond to what the user is trying to achieve.\n" + "Separate observed behavior from inferred intent.\n" + "Do not store temporary interaction patterns as permanent user traits.\n" + "Do not use 'likes', 'prefers', or 'wants' unless the user explicitly says so.\n" + "When storing a pattern, prefer fields like observed_behavior, likely_intent, evidence, and scope over broad personality labels, for example:\n" + "observed_behavior: User rapidly switched across unrelated topics during context-arbitration testing. Occurrences: 8; evidence: cooking, finance, files, travel, car washing.\n" + "likely_intent: User may be stress-testing whether JIN checks context relevance before answering.\n" + "scope: Current session/test sequence, not a stable user preference.\n" + "Write memory as atomic bullet lines, one semantic entity per line.\n" + "Every memory entry MUST use the format:\n " + "<key>: <value>\n" + "Runtime memory may be displayed to the user with a suffix like `(trace: 0.50)`. " + "This is session-local pheromone/attention trace strength: higher means hotter or reinforced, lower means fading. " + "Use trace silently for context priority, and explain it only when the user explicitly asks about memory mechanics. " + "Never copy `(trace: N)` into the generated memory text; trace is runtime metadata, not memory content.\n" + "L2 works above L1 factual runtime memory.\n" + "Use only the recent L1 patch window supplied by the runtime.\n" + "Patch entries may include `[trace: N]`; treat it as session-local pheromone/attention trace strength, not as user content. " + "Higher trace means the L1 item is hotter or recently reinforced; lower trace means it is fading.\n" + "This window is selected because normalized L1 keys or topics repeated across patches.\n" + "Pattern memory should not learn from itself.\n" + "Do not treat existing possible pattern, observed tendency, emerging signal, or other pattern-memory entries as evidence.\n" + "Pattern entries may be displayed as context, but they must never contribute to occurrence counts or create new pattern entries.\n" + "Occurrences must be derived only from actual conversation evidence in the supplied L1 patches, not from previously generated pattern summaries.\n" + "Count occurrences by unique patch snapshot values, not by how many rows mention the same behavior inside one patch.\n" + "If the same user_message appears in both user_messages and changes for one snapshot, it still counts as one occurrence.\n" + "L2 is a hypothesis generator, not a source of settled memory.\n" + "If L2 writes one of these confirmable keys, it MUST include a marker: " + "user_fact, jin_fact, pending_fact, jin_recommendation, user_recommendation. " + "Use (confirmed: none) unless the supplied patch already contains explicit user, jin, or web confirmation.\n" + "Allowed outputs: possible pattern, emerging signal, observed tendency, may indicate, contradiction, corrected assumption.\n" + "Prefer 'possible pattern' over 'pattern'.\n" + "Every possible pattern, emerging signal, or observed tendency MUST include an occurrence counter in the value: Occurrences: N.\n" + "Every possible pattern, emerging signal, or observed tendency SHOULD include accounting metadata in the value: " + "Occurrences: N; first_seen_snapshot: S1; last_seen_snapshot: S2; evidence summary: <short evidence>; confidence: low|medium|high.\n" + "When L2 names or updates a concrete repeated pattern, also write a companion evidence line named L2_pattern_evidence_N. " + "Use this exact shape: L2_pattern_evidence_N: <short pattern description> [ quote: \"<literal user_message value>\" ] [ first_seen_turn_snapshot: S1 ] [ last_seen_turn_snapshot: S2 ] [ occurrences: N ]\n" + "For L2_pattern_evidence_N lines, the final token on the line MUST be the closing bracket of [ occurrences: N ]. " + "Never append status, notes, explanations, conclusions, punctuation, or any other text after the final [ occurrences: N ] bracket.\n" + "For repeated-message patterns, the quoted literal MUST be copied from the supplied user_message field exactly in the user's original language. " + "Do not translate it, do not paraphrase it, and do not replace it with an English command or description.\n" + "The quoted literal may only be stripped of leading/trailing whitespace and cleaned of repeated spaces; keep it at maximum 100 characters. " + "If no matching user_message is available, omit the L2_pattern_evidence_N line instead of inventing a quote.\n" + "L2_pattern_evidence_N is runtime accounting evidence, not a personality trait and not a durable user fact.\n" + "If an existing L2_pattern_evidence_N line matches the same normalized literal example or the same pattern, preserve first_seen_turn_snapshot and Occurrences, then update only last_seen_turn_snapshot and Occurrences when new matching L1 evidence appears.\n" + "Do not duplicate an existing pattern under a new L2_pattern_evidence_N key; update the existing evidence line instead.\n" + "For a brand-new pattern with no prior L2 entry, set Occurrences to the number of unique patch snapshots with matching evidence in the supplied L1 patch window, not to 1 by default.\n" + "For a brand-new pattern, if the same-intent behavior repeated before L2 named it, count those earlier unique matching L1 patch snapshots immediately when creating the counter.\n" + "Do not create a brand-new pattern when all matching evidence is confined to one unique patch snapshot, even if that snapshot contains multiple rows for the same message.\n" + "For an existing pattern, preserve its old Occurrences count and first_seen_snapshot; do not recompute Occurrences from the supplied patch window alone.\n" + "For an existing pattern, new_occurrences = old_occurrences + count(new matching L1 evidence after last_seen_snapshot).\n" + "Only increment Occurrences when patch snapshot > last_seen_snapshot and the L1 evidence actually matches this pattern.\n" + "If last_seen_snapshot is missing for an existing pattern, initialize it as a baseline without incrementing Occurrences for old visible evidence.\n" + "Use the newest matching patch snapshot as the updated last_seen_snapshot after counting new evidence.\n" + "Never reduce an existing Occurrences count just because the current patch window contains fewer matching examples.\n" + "Never write Occurrences: 1 for a brand-new pattern when the supplied window shows two or more manifestations of that same pattern.\n" + "When the user explicitly cancels the pattern, stops doing it, or clearly changes topic, reset that pattern to Occurrences: 0.\n" + "Do not keep Occurrences: 0 entries unless they are still useful as immediate context; obsolete zero-count entries may be dropped.\n" + "Do not repeat factual L1 memory unless it is needed to explain an L2 signal.\n" + "Do not claim certainty from weak evidence. Prefer 'may', 'possible', 'observed', and 'emerging'.\n" + "Do not write categorical statements like '<signal> serves as a strong signal' or 'the user exhibits <trait>'.\n" + "Do not use these words in the generated memory: stable, established, strong signal, user exhibits, personality, identity, core preference.\n" + "If there is not enough signal for L2, return the current L2 memory unchanged.\n" + ) + + +def build_runtime_l2_memory_user_prompt( + *, + current_l2_memory: str, + patches: list[dict], +) -> str: + + def format_l2_strength_suffix( + entry: dict, + *, + changed: bool = False, + ) -> str: + + if changed: + previous_strength = entry.get( + "previous_strength", + ) + current_strength = entry.get( + "current_strength", + ) + + if ( + previous_strength is None + and current_strength is None + ): + return "" + + return ( + " " + f"[trace: {previous_strength if previous_strength is not None else '?'}" + " -> " + f"{current_strength if current_strength is not None else '?'}]" + ) + + strength = entry.get( + "strength", + ) + + return f" [trace: {strength}]" if strength is not None else "" + + lines = [ + "Current L2 pattern memory:", + current_l2_memory.strip() or "<empty>", + "", + "Recent L1 patches since the last L2 update:", + ] + + for index, patch in enumerate( + patches, + start=1, + ): + lines.extend([ + "", + f"Patch {index}", + f"turn: {patch.get('turn_number', 0)}", + f"snapshot: {patch.get('snapshot_index', 0)}", + f"total_diff: {patch.get('total_diff', 0)}", + ]) + + user_messages = [ + str(message or "").replace("\n", " ").strip() + for message in (patch.get("user_messages", []) or []) + if str(message or "").strip() + ] + + if user_messages: + lines.append( + "user_messages:" + ) + + for message in user_messages: + lines.append( + f'- "{message}"' + ) + + changes = patch.get( + "changes", + {}, + ) + + for section in ( + "added", + "changed", + "removed", + ): + entries = ( + changes.get( + section, + [], + ) + or [] + ) + + if not entries: + continue + + lines.append( + f"{section}:" + ) + + for entry in entries: + if section == "changed": + lines.append( + "- " + f"{entry.get('previous_key', '')}: {entry.get('previous_value', '')} " + "=> " + f"{entry.get('current_key', '')}: {entry.get('current_value', '')}" + + format_l2_strength_suffix( + entry, + changed=True, + ) + ) + else: + lines.append( + "- " + f"{entry.get('key', '')}: {entry.get('value', '')}" + + format_l2_strength_suffix( + entry, + ) + ) + + lines.extend([ + "", + "Rewrite the L2 pattern memory now.", + ]) + + return "\n".join( + lines + ) diff --git a/runtime/L3_memory.py b/runtime/L3_memory.py new file mode 100644 index 00000000..ef1c935b --- /dev/null +++ b/runtime/L3_memory.py @@ -0,0 +1,507 @@ +import asyncio +import json +import traceback + +from clients.service_client import ( + ask_service_model, +) +from config_loader import ( + config, +) +from runtime.L3_memory_rules import ( + DEFAULT_RUNTIME_L3_SESSION_MEMORY, + L3_INPUT_TOKEN_RESERVE, + L3_INPUT_TOKEN_TARGET_MAX, + L3_OUTPUT_MAX_TOKENS, +) +from runtime.L3_memory_utils import ( + build_runtime_session_memory_system_prompt, + build_runtime_session_memory_user_prompt, +) +from runtime.memory_common import ( + build_memory_failure_details, + build_memory_update_skip_details, + build_runtime_summarizer_payload, + build_runtime_summarizer_user_prompt, + extract_runtime_memory_text, + is_runtime_memory_response_truncated, + log_memory_event, + log_runtime_summarizer_payload, + log_runtime_summarizer_result, + looks_like_incomplete_runtime_memory, + refresh_runtime_memory_summarizer_usage, +) +from runtime.memory_events import ( + emit_runtime_action_completed, + emit_runtime_session_memory_update, +) +from utils.tokens import ( + estimate_runtime_tokens, +) + +def build_l3_session_memory_max_tokens( + *, + system_prompt: str, + user_prompt: str, + context_window: int | None = None, +) -> int: + + prompt_tokens = estimate_runtime_tokens( + system_prompt=system_prompt, + user_input=user_prompt, + ) + effective_context_window = ( + context_window + or config.SERVICE_CONTEXT_WINDOW + ) + response_budget = ( + effective_context_window + - prompt_tokens + - 128 + ) + + return max( + 128, + min( + config.SERVICE_MAX_TOKENS, + response_budget, + ), + ) + + +class L3PromptBudgetExceeded( + RuntimeError, +): + + def __init__( + self, + diagnostic: dict, + ): + + super().__init__( + "L3 session digest exceeds safe input budget" + ) + self.diagnostic = diagnostic + + +def get_l3_input_token_budget( + context_window: int | None, +) -> int: + + effective_context_window = ( + context_window + or config.SERVICE_CONTEXT_WINDOW + ) + + return max( + 1, + min( + L3_INPUT_TOKEN_TARGET_MAX, + effective_context_window + - L3_INPUT_TOKEN_RESERVE, + ), + ) + + +async def build_budgeted_l3_session_user_prompt( + *, + context, + system_prompt: str, + current_session_memory: str, + runtime_memory_snapshots: list[dict], + diff_history: list[dict], + runtime_l2_memory: str, + session_event_snapshots: list[dict], + context_window: int | None, +) -> tuple[str, dict]: + + target_budget = get_l3_input_token_budget( + context_window + ) + + for minimal in ( + False, + True, + ): + raw_user_prompt = build_runtime_session_memory_user_prompt( + current_session_memory=current_session_memory, + runtime_memory_snapshots=runtime_memory_snapshots, + diff_history=diff_history, + runtime_l2_memory=runtime_l2_memory, + session_event_snapshots=session_event_snapshots, + minimal=minimal, + ) + user_prompt = build_runtime_summarizer_user_prompt( + context=context, + prompt=raw_user_prompt, + ) + input_tokens = estimate_runtime_tokens( + system_prompt=system_prompt, + user_input=user_prompt, + ) + diagnostic = { + "minimal": minimal, + "context_window": context_window, + "input_tokens": input_tokens, + "target_budget": target_budget, + "prompt_chars": len(user_prompt), + "system_prompt_chars": len(system_prompt), + } + + if input_tokens <= target_budget: + return user_prompt, diagnostic + + raise L3PromptBudgetExceeded( + diagnostic + ) + + +async def ask_runtime_session_memory_model( + *, + context=None, + service_client, + current_session_memory: str, + runtime_memory_snapshots: list[dict], + diff_history: list[dict], +) -> dict: + + system_prompt = ( + build_runtime_session_memory_system_prompt() + ) + + resolve_request_context_window = getattr( + service_client, + "resolve_request_context_window", + None, + ) + detected_context_window = None + + if resolve_request_context_window is not None: + detected_context_window = await resolve_request_context_window() + + user_prompt, _prompt_diagnostic = ( + await build_budgeted_l3_session_user_prompt( + context=context, + system_prompt=system_prompt, + current_session_memory=current_session_memory, + runtime_memory_snapshots=runtime_memory_snapshots, + diff_history=diff_history, + runtime_l2_memory=getattr( + context, + "runtime_l2_memory", + "", + ), + session_event_snapshots=list( + getattr( + context, + "runtime_session_event_snapshots", + [], + ) + or [] + ), + context_window=detected_context_window, + ) + ) + + await refresh_runtime_memory_summarizer_usage( + context, + system_prompt=system_prompt, + user_prompt=user_prompt, + context_window=detected_context_window, + ) + + temperature = ( + config.SERVICE_TEMPERATURE + ) + max_tokens = build_l3_session_memory_max_tokens( + system_prompt=system_prompt, + user_prompt=user_prompt, + context_window=detected_context_window, + ) + calculated_max_tokens = max_tokens + max_tokens = min( + max_tokens, + L3_OUTPUT_MAX_TOKENS, + ) + + if max_tokens < calculated_max_tokens: + await log_memory_event( + context, + level="L3", + message=( + "L3 session output token budget capped at " + f"{L3_OUTPUT_MAX_TOKENS}" + ), + fallback_channel="runtime", + ) + + await log_runtime_summarizer_payload( + context, + label="L3 session", + payload=build_runtime_summarizer_payload( + service_client=service_client, + system_prompt=system_prompt, + user_prompt=user_prompt, + temperature=temperature, + max_tokens=max_tokens, + ), + ) + response = await ask_service_model( + client=service_client, + system_prompt=system_prompt, + user_prompt=user_prompt, + temperature=temperature, + max_tokens=max_tokens, + timeout=config.SERVICE_REQUEST_TIMEOUT, + ) + + await refresh_runtime_memory_summarizer_usage( + context, + system_prompt=system_prompt, + user_prompt=user_prompt, + response=response, + context_window=detected_context_window, + ) + + return response + + +async def maybe_summarize_runtime_session_memory( + *, + context, +) -> str: + + if not getattr( + context, + "runtime_remember_session_requested", + False, + ): + return getattr( + context, + "runtime_l3_session_memory", + DEFAULT_RUNTIME_L3_SESSION_MEMORY, + ) + + service_client = ( + getattr( + context, + "clients", + {}, + ) + .get( + "service" + ) + ) + + if service_client is None: + await emit_runtime_action_completed( + context, + action="remember_session", + ) + + return getattr( + context, + "runtime_l3_session_memory", + DEFAULT_RUNTIME_L3_SESSION_MEMORY, + ) + + snapshots = list( + getattr( + context, + "runtime_memory_snapshots", + [], + ) + or [] + ) + + if not snapshots: + await log_memory_event( + context, + level="L3", + message="L3 session save skipped: no snapshots", + fallback_channel="runtime", + ) + + await emit_runtime_action_completed( + context, + action="remember_session", + ) + + return getattr( + context, + "runtime_l3_session_memory", + DEFAULT_RUNTIME_L3_SESSION_MEMORY, + ) + + current_session_memory = getattr( + context, + "runtime_l3_session_memory", + "", + ) or getattr( + context, + "session_memory", + "", + ) + + try: + response = await ask_runtime_session_memory_model( + context=context, + service_client=service_client, + current_session_memory=current_session_memory, + runtime_memory_snapshots=snapshots, + diff_history=list( + getattr( + context, + "runtime_l1_diff_history", + [], + ) + or [] + ), + ) + + updated_session_memory = extract_runtime_memory_text( + response + ) + + skip_reason = None + + if is_runtime_memory_response_truncated(response): + await log_memory_event( + context, + level="L3", + message="L3 session summarizer reached max_tokens", + fallback_channel="runtime", + ) + + skip_reason = "L3 session summarizer response was truncated by max_tokens." + + elif ( + updated_session_memory.strip() + and looks_like_incomplete_runtime_memory( + updated_session_memory + ) + ): + skip_reason = "L3 session summarizer returned text that looks structurally incomplete." + + if skip_reason: + await log_memory_event( + context, + level="L3", + message="L3 session memory update skipped", + details=build_memory_update_skip_details( + reason=skip_reason, + previous_memory=current_session_memory, + candidate_memory=updated_session_memory, + ), + fallback_channel="error", + ) + + await emit_runtime_action_completed( + context, + action="remember_session", + ) + + return current_session_memory + + if updated_session_memory.strip(): + context.runtime_l3_session_memory = updated_session_memory + context.session_memory = updated_session_memory + context.session_memory_source = "L3" + context.runtime_session_memory_updates = ( + getattr( + context, + "runtime_session_memory_updates", + 0, + ) + + 1 + ) + context.runtime_remember_session_requested = False + + runtime_snapshots = getattr( + context, + "runtime_memory_snapshots", + [], + ) + if runtime_snapshots: + context.runtime_memory_snapshot_index = ( + len(runtime_snapshots) - 1 + ) + + await log_memory_event( + context, + level="L3", + message="L3 session memory updated", + fallback_channel="service", + ) + + await log_runtime_summarizer_result( + context, + label="L3 session memory", + result=updated_session_memory, + ) + + await emit_runtime_session_memory_update( + context, + persist_browser=True, + ) + + await emit_runtime_action_completed( + context, + action="remember_session", + ) + + return getattr( + context, + "runtime_l3_session_memory", + DEFAULT_RUNTIME_L3_SESSION_MEMORY, + ) + + except asyncio.CancelledError: + raise + + except L3PromptBudgetExceeded as error: + await log_memory_event( + context, + level="L3", + message="L3 session memory update skipped", + details=( + "Reason: compact digest still exceeds safe input budget.\n\n" + + json.dumps( + error.diagnostic, + ensure_ascii=False, + indent=2, + ) + ), + fallback_channel="error", + ) + + await emit_runtime_action_completed( + context, + action="remember_session", + ) + + return current_session_memory + + except Exception as error: + formatted_traceback = ( + traceback.format_exc() + ) + + await log_memory_event( + context, + level="L3", + message="L3 session memory update failed", + details=build_memory_failure_details( + stage="L3 session memory summarizer", + error=error, + traceback_text=formatted_traceback, + ), + fallback_channel="error", + ) + + await emit_runtime_action_completed( + context, + action="remember_session", + ) + + return current_session_memory diff --git a/runtime/L3_memory_rules.py b/runtime/L3_memory_rules.py new file mode 100644 index 00000000..a0b6036c --- /dev/null +++ b/runtime/L3_memory_rules.py @@ -0,0 +1,35 @@ +# Provides the initial L3 session memory text before any session summary exists. +DEFAULT_RUNTIME_L3_SESSION_MEMORY = "" + +# Sets the target maximum input token budget for L3 session summarization. +L3_INPUT_TOKEN_TARGET_MAX = 6000 + +# Reserves input tokens for prompt framing around L3 summarization content. +L3_INPUT_TOKEN_RESERVE = 768 + +# Limits output tokens for L3 session summarization. +L3_OUTPUT_MAX_TOKENS = 2048 + +# Limits how many prompt snapshots are kept in session memory context. +MAX_SESSION_PROMPT_SNAPSHOTS = 6 + +# Limits how many prompt diffs are kept in session memory context. +MAX_SESSION_PROMPT_DIFFS = 8 + +# Limits how many recent session events are included in memory context. +MAX_SESSION_PROMPT_EVENTS = 3 + +# Limits the text length for each captured session event. +MAX_SESSION_EVENT_TEXT_CHARS = 300 + +# Limits the total session memory text included in prompts. +MAX_SESSION_MEMORY_TEXT_CHARS = 1800 + +# Limits the latest memory text included in prompts. +MAX_SESSION_LATEST_MEMORY_TEXT_CHARS = 2200 + +# Limits older snapshot text included in prompts. +MAX_SESSION_OLD_SNAPSHOT_TEXT_CHARS = 500 + +# Limits the length of each generated session memory line. +MAX_SESSION_LINE_CHARS = 220 diff --git a/runtime/L3_memory_utils.py b/runtime/L3_memory_utils.py new file mode 100644 index 00000000..52bc2703 --- /dev/null +++ b/runtime/L3_memory_utils.py @@ -0,0 +1,529 @@ +import json + +from runtime.L2_memory_rules import ( + MAX_SESSION_L2_LINES, +) +from runtime.L3_memory_rules import ( + MAX_SESSION_EVENT_TEXT_CHARS, + MAX_SESSION_LATEST_MEMORY_TEXT_CHARS, + MAX_SESSION_LINE_CHARS, + MAX_SESSION_MEMORY_TEXT_CHARS, + MAX_SESSION_OLD_SNAPSHOT_TEXT_CHARS, + MAX_SESSION_PROMPT_DIFFS, + MAX_SESSION_PROMPT_EVENTS, + MAX_SESSION_PROMPT_SNAPSHOTS, +) + +def build_runtime_session_event_snapshot( + context, + *, + source: str = "runtime_action", + initiated_by: str = "jin", +) -> dict: + + existing_snapshots = list( + getattr( + context, + "runtime_session_event_snapshots", + [], + ) + or [] + ) + runtime_snapshots = list( + getattr( + context, + "runtime_memory_snapshots", + [], + ) + or [] + ) + diff_history = list( + getattr( + context, + "runtime_l1_diff_history", + [], + ) + or [] + ) + user_message = getattr( + context, + "runtime_turn_user_message", + "", + ) + assistant_response = getattr( + context, + "runtime_turn_assistant_response", + "", + ) + + return { + "index": len(existing_snapshots), + "memory_type": "session_event_snapshot", + "source": source, + "initiated_by": initiated_by, + "turn_number": getattr( + context, + "turn_number", + 0, + ), + "user_message_count": getattr( + context, + "user_message_count", + 0, + ), + "assistant_message_count": getattr( + context, + "assistant_message_count", + 0, + ), + "runtime_snapshot_count": len( + runtime_snapshots + ), + "diff_count": len( + diff_history + ), + "user_message": user_message, + "assistant_response": assistant_response, + } + + +def compact_session_prompt_text( + value, + *, + limit: int = MAX_SESSION_EVENT_TEXT_CHARS, +) -> str: + + text = str( + value + or "" + ).strip() + + if len(text) <= limit: + return text + + return ( + text[:limit].rstrip() + + " ... <truncated>" + ) + + +def compact_l3_text_block( + memory: str, + *, + max_chars: int, + max_lines: int = 10, +) -> str: + + lines = [ + line.strip() + for line in ( + memory + or "" + ).splitlines() + if line.strip() + ] + + if not lines: + return "<empty>" + + if max_lines <= 0: + return "<empty>" + + selected = lines[-max_lines:] + omitted = max(0, len(lines) - len(selected)) + text = "\n".join( + compact_session_prompt_text( + line, + limit=MAX_SESSION_LINE_CHARS, + ) + for line in selected + ) + + if len(text) > max_chars: + text = compact_session_prompt_text( + text, + limit=max_chars, + ) + + if omitted: + text = ( + f"omitted_memory_lines: {omitted}\n" + f"{text}" + ) + + return text.strip() or "<empty>" + + +def compact_l3_event( + entry: dict, +) -> dict: + + if not isinstance( + entry, + dict, + ): + return {} + + keep_keys = ( + "index", + "memory_type", + "source", + "initiated_by", + "turn_number", + "title", + "memory", + "user_message", + "assistant_response", + ) + + return { + key: ( + compact_session_prompt_text( + value, + limit=MAX_SESSION_EVENT_TEXT_CHARS, + ) + if isinstance(value, str) + else value + ) + for key in keep_keys + if (value := entry.get(key)) is not None + } + + +def select_l3_snapshots( + snapshots: list[dict], + *, + snapshot_count: int, +) -> tuple[list[dict], int]: + + valid_snapshots = [ + snapshot + for snapshot in ( + snapshots + or [] + ) + if isinstance( + snapshot, + dict, + ) + ] + + if not valid_snapshots: + return [], 0 + + ranked = sorted( + valid_snapshots, + key=lambda snapshot: snapshot.get( + "total_diff", + 0, + ) + or 0, + reverse=True, + ) + + selected = [] + seen_indexes = set() + + for snapshot in ( + [valid_snapshots[0], valid_snapshots[-1]] + + ranked + ): + index = snapshot.get( + "index", + id(snapshot), + ) + + if index in seen_indexes: + continue + + seen_indexes.add( + index + ) + selected.append( + snapshot + ) + + if len(selected) >= snapshot_count: + break + + selected.sort( + key=lambda snapshot: snapshot.get( + "index", + 0, + ) + or 0 + ) + + return ( + selected, + max( + 0, + len(valid_snapshots) - len(selected), + ), + ) + + +def compact_l3_diff_entry(entry: dict) -> dict: + + changes = entry.get("changes", {}) if isinstance(entry, dict) else {} + changes = changes if isinstance(changes, dict) else {} + + def keys(items, name): + return [ + str(item.get(name, "")) + for item in (items or [])[:8] + if isinstance(item, dict) and item.get(name) + ] + + return { + "turn_number": entry.get("turn_number", 0), + "snapshot_index": entry.get("snapshot_index", 0), + "total_diff": entry.get("total_diff", 0), + "added_keys": keys(changes.get("added", []), "key"), + "changed_keys": keys(changes.get("changed", []), "current_key"), + "removed_keys": keys(changes.get("removed", []), "key"), + } + + +def build_l3_session_digest( + *, + current_session_memory: str, + runtime_memory_snapshots: list[dict], + diff_history: list[dict], + runtime_l2_memory: str = "", + session_event_snapshots: list[dict] | None = None, + minimal: bool = False, +) -> dict: + + snapshot_count = ( + 1 + if minimal + else MAX_SESSION_PROMPT_SNAPSHOTS + ) + diff_count = ( + 1 + if minimal + else MAX_SESSION_PROMPT_DIFFS + ) + event_count = ( + 1 + if minimal + else MAX_SESSION_PROMPT_EVENTS + ) + current_memory_chars = ( + 1000 + if minimal + else MAX_SESSION_MEMORY_TEXT_CHARS + ) + + selected_snapshots, omitted_snapshot_count = ( + select_l3_snapshots( + runtime_memory_snapshots, + snapshot_count=snapshot_count, + ) + ) + + latest_index = ( + selected_snapshots[-1].get( + "index", + 0, + ) + if selected_snapshots + else None + ) + + compact_snapshots = [] + + for snapshot in selected_snapshots: + is_latest = snapshot.get("index", 0) == latest_index + max_chars = ( + 1000 + if minimal + else ( + MAX_SESSION_LATEST_MEMORY_TEXT_CHARS + if is_latest + else MAX_SESSION_OLD_SNAPSHOT_TEXT_CHARS + ) + ) + compact_snapshots.append({ + "index": snapshot.get("index", 0), + "total_diff": snapshot.get("total_diff", 0), + "role": "latest" if is_latest else "selected", + "memory": compact_l3_text_block( + snapshot.get("raw_memory", ""), + max_chars=max_chars, + ), + }) + + valid_events = [ + event + for event in (session_event_snapshots or []) + if isinstance(event, dict) + ] + compact_events = [ + compact_l3_event(event) + for event in valid_events[-event_count:] + ] + + selected_diffs = [ + entry + for entry in (diff_history or []) + if isinstance(entry, dict) + ][-diff_count:] + compact_diffs = [ + compact_l3_diff_entry(entry) + for entry in selected_diffs + ] + + omitted_event_count = max(0, len(valid_events) - len(compact_events)) + omitted_diff_count = max( + 0, + len(diff_history or []) - len(selected_diffs), + ) + + return { + "minimal": minimal, + "current_session_memory": compact_l3_text_block( + current_session_memory, + max_chars=current_memory_chars, + max_lines=8, + ), + "l2_context": compact_l3_text_block( + runtime_l2_memory, + max_chars=600, + max_lines=0 if minimal else MAX_SESSION_L2_LINES, + ), + "session_events": compact_events, + "omitted_events_count": omitted_event_count, + "snapshots": compact_snapshots, + "omitted_middle_snapshots": omitted_snapshot_count, + "diff_history": compact_diffs, + "omitted_older_diffs": omitted_diff_count, + } + +def build_runtime_session_memory_system_prompt() -> str: + + return ( + "You are JIN's L3 session memory summarizer.\n" + "This is the layer above L1 runtime memory and L2 pattern memory.\n" + "Return only the new compressed L3 session snapshot as plain text.\n" + "Do not output JSON.\n" + "Do not use Markdown headings.\n" + "Do not explain your reasoning or the summarization process.\n" + "Write memory as atomic lines using the format:\n" + "<key>: <value>\n" + "Summarize the whole session from all L1 runtime memory snapshots, " + "not only the latest snapshot.\n" + "Session event snapshots are stored by the runtime as an array and are always available at session-context level.\n" + "Treat that array as persistent event history for the session: use it to preserve causal sequence, important moments, and prior session-level decisions.\n" + "Do not ask the user to fill snapshot fields manually; infer event snapshot meaning from natural conversation and explicit user markings.\n" + "Preserve what should survive a browser reload or a new tab: active project direction, " + "explicit decisions, durable facts, unresolved tasks, constraints, and next step.\n" + "Treat TRUSTED_RUNTIME_CONTEXT timestamp as the source of truth for current time.\n" + "L3 must convert relative temporal phrases from L1 snapshots into absolute or session-relative phrases before preserving them.\n" + "Session handoff memory must not contain ambiguous standalone words like today, now, or recently unless paired with a timestamp/date.\n" + "If a preference expires at end of day, encode that explicitly, such as temporary_preference: User requested X for 2026-06-05 only; expires after that date unless renewed.\n" + "If the exact date cannot be inferred, write relative to current session rather than pretending it is durable calendar time.\n" + "Session memory may include rare episodic_key_moment records for events that need richer sequence memory.\n" + "Use episodic_key_moment only when the moment changed understanding of the project, user, or system; " + "has a clear cause -> event -> outcome chain; was explicitly marked important by the user; " + "or carries high emotional or narrative weight.\n" + "Do not create episodic_key_moment entries for ordinary progress updates, routine feature work, " + "minor bugs, casual jokes, or low-signal chat.\n" + "When writing an episodic_key_moment, preserve the exact chain rather than only the conclusion.\n" + "Use this plain-text block format:\n" + "memory_type: episodic_key_moment\n" + "title: <short event title>\n" + "emotional_weight: low|medium|high\n" + "why_it_matters: <why this should survive the session>\n" + "sequence:\n" + "1. <first causal step>\n" + "2. <next causal step>\n" + "preserve_detail: <which exact details matter and why>\n" + "Use the diff history to identify which topics or constraints actually changed during the session.\n" + "Do not copy every L1 line. Compress repeated or superseded states.\n" + "Do not infer durable user personality traits, relationship claims, or preferences from weak signal.\n" + "Preserve durable JIN/user fact lines from L1 snapshots as stable session facts; keep their keys stable and change only values that were explicitly corrected or superseded.\n" + "Keep user-requested stored values with explicit purpose and explicit facts in their own retrieval-friendly lines.\n" + "Drop transient last_jin_response details unless they contain an unresolved question or next step.\n" + "The final L3 snapshot should feel like a session handoff note for fluent continuation." + ) + + +def build_runtime_session_memory_user_prompt( + *, + current_session_memory: str, + runtime_memory_snapshots: list[dict], + diff_history: list[dict], + runtime_l2_memory: str = "", + session_event_snapshots: list[dict] | None = None, + minimal: bool = False, +) -> str: + + digest = build_l3_session_digest( + current_session_memory=current_session_memory, + runtime_memory_snapshots=runtime_memory_snapshots, + diff_history=diff_history, + runtime_l2_memory=runtime_l2_memory, + session_event_snapshots=session_event_snapshots, + minimal=minimal, + ) + + snapshot_blocks = [] + + for snapshot in digest["snapshots"]: + snapshot_blocks.append( + "\n".join([ + f"snapshot: {snapshot.get('index', 0)}", + f"role: {snapshot.get('role', '')}", + f"total_diff: {snapshot.get('total_diff', 0)}", + "memory:", + snapshot.get( + "memory", + "<empty>", + ), + "patch_summary:", + json.dumps( + snapshot.get( + "patch_summary", + {}, + ), + ensure_ascii=False, + indent=2, + ), + ]) + ) + + return "\n\n".join([ + f"L3 compact digest minimal: {digest['minimal']}", + "Current L3 session memory:", + digest["current_session_memory"], + "Compact L2 pattern context:", + digest["l2_context"], + "Session event snapshots array:", + f"omitted_events_count: {digest['omitted_events_count']}", + json.dumps( + digest["session_events"], + ensure_ascii=False, + indent=2, + ), + "Selected L1 runtime memory snapshot history:", + ( + f"omitted_middle_snapshots: {digest['omitted_middle_snapshots']}" + if digest["omitted_middle_snapshots"] + else "omitted_middle_snapshots: 0" + ), + "\n\n---\n\n".join(snapshot_blocks) or "<empty>", + "Recent L1 diff history:", + ( + f"omitted_older_diffs: {digest['omitted_older_diffs']}" + if digest["omitted_older_diffs"] + else "omitted_older_diffs: 0" + ), + json.dumps( + digest["diff_history"], + ensure_ascii=False, + indent=2, + ), + "Rewrite the L3 session memory now.", + ]) diff --git a/runtime/__init__.py b/runtime/__init__.py index 4c23c03a..0e779d91 100644 --- a/runtime/__init__.py +++ b/runtime/__init__.py @@ -1,30 +1,44 @@ from .context import RuntimeContext, RuntimeEmitter from .fact_check import ( CONFIRMABLE_MEMORY_KEYS, - cancel_idle_fact_check, run_fact_check_once, - schedule_idle_fact_check, ) -from .memory import ( +from .L1_memory import ( + apply_runtime_response_feedback, + build_runtime_memory_snapshot, + cancel_runtime_memory_update, + schedule_interrupted_runtime_memory_update, + schedule_runtime_memory_update, + summarize_runtime_memory, +) +from .L1_memory_rules import ( DEFAULT_RUNTIME_MEMORY, +) +from .L2_memory import ( + maybe_summarize_runtime_l2_memory, + record_runtime_l1_diff, +) +from .L2_memory_rules import ( L2_PATCH_WINDOW, - apply_runtime_response_feedback, +) +from .L3_memory import ( + maybe_summarize_runtime_session_memory, +) +from .memory_events import ( + emit_runtime_l1_diff_update, + emit_runtime_session_memory_update, +) +from .L1_memory_utils import ( build_interrupted_assistant_message, - build_runtime_l2_memory_system_prompt, - build_runtime_memory_snapshot, build_runtime_memory_system_prompt, build_runtime_memory_user_prompt, +) +from .L2_memory_utils import ( + build_runtime_l2_memory_system_prompt, +) +from .L3_memory_utils import ( build_runtime_session_memory_system_prompt, build_runtime_session_memory_user_prompt, - emit_runtime_l1_diff_update, - emit_runtime_session_memory_update, - cancel_runtime_memory_update, - maybe_summarize_runtime_l2_memory, - maybe_summarize_runtime_session_memory, - record_runtime_l1_diff, - schedule_interrupted_runtime_memory_update, - schedule_runtime_memory_update, - summarize_runtime_memory, ) from .registry import runtime_state from .state import ( @@ -55,7 +69,6 @@ "build_runtime_session_memory_system_prompt", "build_runtime_session_memory_user_prompt", "cancel_runtime_memory_update", - "cancel_idle_fact_check", "emit_runtime_l1_diff_update", "emit_runtime_session_memory_update", "maybe_summarize_runtime_l2_memory", @@ -64,7 +77,6 @@ "refresh_runtime_state", "run_fact_check_once", "runtime_state", - "schedule_idle_fact_check", "schedule_interrupted_runtime_memory_update", "schedule_runtime_memory_update", "send_telemetry", diff --git a/runtime/behavior_contract.py b/runtime/behavior_contract.py index 0e8cbdb9..f42793c2 100644 --- a/runtime/behavior_contract.py +++ b/runtime/behavior_contract.py @@ -113,6 +113,45 @@ def _normalize_guard_text( ) +def _has_guard_trigger( + normalized_text: str, + normalized_trigger: str, +) -> bool: + + if not normalized_trigger: + return False + + start = 0 + + while True: + index = normalized_text.find( + normalized_trigger, + start, + ) + + if index < 0: + return False + + before_index = index - 1 + after_index = index + len( + normalized_trigger + ) + + before_ok = ( + before_index < 0 + or not normalized_text[before_index].isalnum() + ) + after_ok = ( + after_index >= len(normalized_text) + or not normalized_text[after_index].isalnum() + ) + + if before_ok and after_ok: + return True + + start = index + 1 + + def should_execute_action_guard( name: str, user_text: str, @@ -125,9 +164,12 @@ def should_execute_action_guard( return False has_blocker = any( - _normalize_guard_text( - blocker - ) in normalized_text + _has_guard_trigger( + normalized_text, + _normalize_guard_text( + blocker + ), + ) for blocker in get_action_guard_blockers( name ) @@ -137,9 +179,12 @@ def should_execute_action_guard( return False return any( - _normalize_guard_text( - trigger - ) in normalized_text + _has_guard_trigger( + normalized_text, + _normalize_guard_text( + trigger + ), + ) for trigger in get_action_guard_triggers( name ) diff --git a/runtime/context.py b/runtime/context.py index 9922abe4..d2a4010d 100644 --- a/runtime/context.py +++ b/runtime/context.py @@ -2,7 +2,7 @@ from websocket_logger import WebSocketLogger -from runtime.memory import ( +from runtime.L1_memory_rules import ( DEFAULT_RUNTIME_MEMORY, ) diff --git a/runtime/fact_check.py b/runtime/fact_check.py index 86113179..7b005fb7 100644 --- a/runtime/fact_check.py +++ b/runtime/fact_check.py @@ -1967,26 +1967,6 @@ def apply_fact_check_result_to_memory( return "\n".join(lines).strip() -def cancel_idle_fact_check(context) -> None: - """Compatibility no-op. - - Background/idle fact-checking was removed: fact-checks must be - started explicitly by the UI click path. Keep this function so old - callers/imports do not crash while no task can remain scheduled. - """ - context.fact_check_idle_task = None - - -def schedule_idle_fact_check( - context, - *, - delay_seconds: float | None = None, -): - """Compatibility no-op for the removed idle fact-check scheduler.""" - cancel_idle_fact_check(context) - return None - - async def emit_fact_check_state( context, *, @@ -2143,7 +2123,7 @@ async def run_fact_check_once( # Emit a real runtime snapshot, not a snapshot=None event. # The frontend renders the right memory panel from snapshot history; # a raw memory payload alone is intentionally ignored by the panel. - from runtime.memory import emit_runtime_memory_update + from runtime.memory_events import emit_runtime_memory_update await emit_runtime_memory_update( context diff --git a/runtime/memory.py b/runtime/memory.py deleted file mode 100644 index 1e0c7836..00000000 --- a/runtime/memory.py +++ /dev/null @@ -1,3963 +0,0 @@ -import asyncio -import contextlib -import json -import traceback -from datetime import datetime -from difflib import SequenceMatcher - -from clients.errors import ( - format_client_error, -) -from clients.service_client import ( - ask_service_model, -) -from runtime.state import ( - RUNTIME_MEMORY_SUMMARIZER_RUNTIME_ID, -) -from config_loader import ( - config, -) -from clients.response_extractor import ( - ResponseExtractor, -) -from runtime.state_sync import ( - refresh_runtime_state, -) -from runtime.registry import ( - runtime_state, -) -from runtime.context_contract import ( - ContextContract, -) -from utils.tokens import ( - estimate_runtime_tokens, -) -from runtime.memory_rules import ( - DEFAULT_RUNTIME_MEMORY, - build_interrupted_assistant_message, - build_runtime_memory_context_text, - build_runtime_l2_memory_system_prompt, - build_runtime_l2_memory_user_prompt, - build_runtime_memory_batch_user_prompt, - build_runtime_memory_system_prompt, - build_runtime_memory_user_prompt, - build_runtime_session_memory_system_prompt, - build_runtime_session_memory_user_prompt, - canonicalize_runtime_memory_entry, - remove_runtime_user_idle_lines, -) -from runtime.fact_check import ( - ensure_confirmable_memory_markers, -) - - - - -RUNTIME_RESPONSE_FEEDBACK_KEY = "JIN_LAST_RESPONSE_USER_FEEDBACK" -RUNTIME_RESPONSE_FEEDBACK_DISLIKED_VALUE = ( - "User disliked your last response. " - "Before answering, find and understand why it failed using context or memory, then start the next reply with a brief acknowledgement of that miss, then continue with a concrete corrected answer." -) -RUNTIME_RESPONSE_FEEDBACK_NEUTRAL_VALUE = ( - "User gave neutral feedback to your last response. " - "Continue carefully without changing course too much and treat it as a signal for response improvement." -) -RUNTIME_RESPONSE_FEEDBACK_LIKED_VALUE = ( - "User liked your last response. " - "Keep the current direction." -) -RUNTIME_RESPONSE_FEEDBACK_RATINGS = { - "disliked": "disliked", - "neutral": "neutral", - "liked": "liked", -} - - -def normalize_runtime_response_feedback(feedback) -> dict | None: - - if not isinstance(feedback, dict): - return None - - raw_rating = str( - feedback.get("rating") - or "" - ).strip().casefold() - - rating = RUNTIME_RESPONSE_FEEDBACK_RATINGS.get( - raw_rating - ) - - if rating is None: - return None - - return { - "rating": rating, - } - - -def build_runtime_response_feedback_value(feedback: dict) -> str: - - rating = feedback.get( - "rating", - "neutral", - ) - - if rating == "disliked": - return RUNTIME_RESPONSE_FEEDBACK_DISLIKED_VALUE - - if rating == "liked": - return RUNTIME_RESPONSE_FEEDBACK_LIKED_VALUE - - return RUNTIME_RESPONSE_FEEDBACK_NEUTRAL_VALUE - - - -def remove_runtime_memory_entry_text( - memory: str, - key: str, -) -> str: - - target_key = str(key or "").strip() - if not target_key: - return memory or "" - - target_key_normalized = target_key.casefold() - - lines = [ - line.rstrip() - for line in str(memory or "").splitlines() - ] - - kept_lines = [] - - for line in lines: - stripped = line.strip() - if not stripped: - continue - - current_key = stripped.split(":", 1)[0].strip().casefold() - - if current_key == target_key_normalized: - continue - - kept_lines.append(stripped) - - return "\n".join(kept_lines).strip() - - -def upsert_runtime_memory_entry_text( - memory: str, - key: str, - value: str, -) -> str: - - target_key = str(key or "").strip() - if not target_key: - return memory or "" - - target_key_normalized = target_key.casefold() - replacement = f"{target_key}: {str(value or '').strip()}" - - lines = [ - line.rstrip() - for line in str(memory or "").splitlines() - ] - - updated_lines = [] - replaced = False - - for line in lines: - stripped = line.strip() - if not stripped: - continue - - current_key = stripped.split(":", 1)[0].strip().casefold() - - if current_key == target_key_normalized: - if not replaced: - updated_lines.append(replacement) - replaced = True - continue - - updated_lines.append(stripped) - - if not replaced: - updated_lines.append(replacement) - - return "\n".join(updated_lines).strip() - - -def remove_runtime_response_feedback_text( - memory: str, -) -> str: - - return remove_runtime_memory_entry_text( - memory or "", - RUNTIME_RESPONSE_FEEDBACK_KEY, - ).strip() - - -def clear_runtime_response_feedback( - context, -) -> None: - - if context is None: - return - - context.runtime_memory = remove_runtime_response_feedback_text( - getattr( - context, - "runtime_memory", - "", - ) - ) - - context.runtime_memory_stable = remove_runtime_response_feedback_text( - getattr( - context, - "runtime_memory_stable", - "", - ) - ) - - context.runtime_last_response_feedback = None - - -async def apply_runtime_response_feedback( - context, - feedback, -) -> dict | None: - - normalized_feedback = normalize_runtime_response_feedback( - feedback - ) - - if normalized_feedback is None: - return None - - value = build_runtime_response_feedback_value( - normalized_feedback - ) - - current_memory = getattr( - context, - "runtime_memory", - "", - ) - - cleaned_memory = remove_runtime_response_feedback_text( - current_memory - ) - - updated_memory = upsert_runtime_memory_entry_text( - cleaned_memory, - RUNTIME_RESPONSE_FEEDBACK_KEY, - value, - ) - - if updated_memory == current_memory: - context.runtime_last_response_feedback = normalized_feedback - return None - - context.runtime_memory = updated_memory - context.runtime_last_response_feedback = normalized_feedback - - # Rating clicks are an in-place mutation of the current L1 snapshot. - # Do not increment runtime_memory_updates and do not emit - # runtime_memory_update here, otherwise the UI creates a new runtime page. - # This is a transient next-turn alert, not durable L1 memory. - # Keep runtime_memory_stable clean so L1 cannot preserve it. - return { - "applied": True, - "rating": normalized_feedback["rating"], - "runtime_memory": updated_memory, - } - -DEFAULT_RUNTIME_L2_MEMORY = "" -DEFAULT_RUNTIME_L3_SESSION_MEMORY = "" -L3_INPUT_TOKEN_TARGET_MAX = 6000 -L3_INPUT_TOKEN_RESERVE = 768 -L3_OUTPUT_MAX_TOKENS = 2048 -MIN_L2_TURNS = 3 -L2_PATCH_WINDOW = 5 -L2_REPEATED_KEY_THRESHOLD = 3 -STRENGTH_DECAY = 0.82 -STRENGTH_PRESENCE_BOOST = 0.08 -STRENGTH_BOOST = 0.8 -STRENGTH_NEW_KEY = 0.5 -DURABLE_FLOOR = 0.25 -HOT_THRESHOLD = 0.5 -FADING_THRESHOLD = 0.1 -GENERIC_MEMORY_VALUE_SIMILARITY_MIN = 0.35 -GENERIC_MEMORY_MATCH_KEYS = ( - "topic", - "focus", - "next step", - "last jin response", - - "user request", - "user intent", - - "active topic", - "active topics", - "current topic", - "current topics", - - "open reference", - "open references", - "open question", - - "pending choice", - "pending choices", - "pending action", - "pending actions", - - "session status", - "session state", - - "current concern", - "current concerns", - "current task", - "current tasks", - "current context", - "current request", - "current requests", - - "interaction state", -) -DURABLE_MEMORY_KEY_TOKENS = ( - "fact", - "identity", - "profile", - "preference", - "stored", -) -DURABLE_MEMORY_NEGATION_MARKERS = ( - "not", - "not fact", - "not true", - "false", - "obsolete", - "removed", - "cancelled", - "canceled", - "superseded", - "no longer", - "invalid", -) - - -def build_runtime_summarizer_trusted_context( - context=None, -) -> str: - - timestamp = ( - getattr( - context, - "timestamp", - "", - ) - if context is not None - else "" - ) - current_date = ( - getattr( - context, - "current_date", - "", - ) - if context is not None - else "" - ) - current_time = ( - getattr( - context, - "current_time", - "", - ) - if context is not None - else "" - ) - weekday = ( - getattr( - context, - "weekday", - "", - ) - if context is not None - else "" - ) - year = ( - getattr( - context, - "year", - None, - ) - if context is not None - else None - ) - - now = None - - if timestamp: - try: - now = datetime.fromisoformat( - str(timestamp).replace( - "Z", - "+00:00", - ) - ) - except ValueError: - now = None - - if now is None: - now = datetime.now() - timestamp = timestamp or now.isoformat() - - contract = ContextContract( - user_input="", - timestamp=str(timestamp), - current_date=str( - current_date - or now.date().isoformat() - ), - current_time=str( - current_time - or now.strftime("%H:%M:%S") - ), - weekday=str( - weekday - or now.strftime("%A") - ), - year=int( - year - or now.year - ), - ) - - return contract.to_runtime_xml() - - -def build_runtime_summarizer_user_prompt( - *, - context=None, - prompt: str, -) -> str: - - return "\n\n".join([ - build_runtime_summarizer_trusted_context( - context - ), - prompt, - ]) - - -def infer_memory_failure_reason( - error: Exception, -) -> str: - - response = getattr( - error, - "response", - None, - ) - status_code = getattr( - response, - "status_code", - None, - ) - response_text = ( - getattr( - response, - "text", - "", - ) - or "" - ).strip() - - searchable_text = ( - f"{error}\n{response_text}" - ).lower() - - token_markers = ( - "token", - "context length", - "context window", - "maximum context", - "max_tokens", - "too many tokens", - "prompt is too long", - ) - - if any( - marker in searchable_text - for marker in token_markers - ): - return ( - "Token/context limit exceeded or max_tokens is too large " - "for the remaining context." - ) - - if status_code == 400: - if response_text: - return ( - "Provider rejected the memory summarizer request " - "(400 Bad Request); see provider message below." - ) - - return ( - "Provider rejected the memory summarizer request " - "(400 Bad Request) without an error body; exact cause is unknown. " - "Common causes are token/context limit mismatch or an invalid payload." - ) - - if status_code: - return ( - "Provider rejected the memory summarizer request " - f"with HTTP {status_code}." - ) - - error_text = str( - error - ).strip() - - if error_text: - return error_text - - return ( - "Memory summarizer failed before returning a result." - ) - - -def build_memory_failure_details( - *, - stage: str, - error: Exception, - traceback_text: str, -) -> str: - - request = getattr( - error, - "request", - None, - ) - request_url = str( - getattr( - request, - "url", - "", - ) - or config.SERVICE_API_BASE - ) - - return ( - "Likely reason: " - f"{infer_memory_failure_reason(error)}" - "\n\nClient error details:\n" - f"{format_client_error(stage, request_url, config.SERVICE_MODEL_UID, error)}" - "\n\nTraceback:\n" - f"{traceback_text}" - ) - -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, - **kwargs, -): - - if call is None: - return - - with contextlib.suppress(Exception): - await call( - *args, - **kwargs, - ) - - -def get_memory_log_level( - label: str, -) -> str: - - return ( - label - .split( - " ", - 1, - )[0] - .upper() - ) - - -async def log_memory_event( - context, - *, - level: str, - message: str, - details: str | None = None, - fallback_channel: str = "runtime", - event: str | None = None, -) -> None: - - logger = getattr( - context, - "logger", - None, - ) - - log_memory = getattr( - logger, - "log_memory", - None, - ) - - if log_memory is not None: - await safe_call( - log_memory, - level, - message, - details=details, - event=event, - ) - return - - fallback = getattr( - logger, - f"log_{fallback_channel}", - None, - ) - formatted_message = ( - f"[MEMORY:{level}] {message}" - ) - - if ( - details is not None - and fallback_channel in { - "error", - "summarizer", - } - ): - await safe_call( - fallback, - formatted_message, - details=details, - ) - return - - await safe_call( - fallback, - formatted_message, - ) - - -async def emit_runtime_memory_update( - context, -) -> dict: - - emitter = getattr( - context, - "emitter", - None, - ) - - memory = getattr(context, "runtime_memory", "") - display_memory = build_runtime_memory_context_text( - memory, - context, - ) - - if not hasattr( - context, - "runtime_memory_snapshots", - ): - context.runtime_memory_snapshots = [] - - snapshot = build_runtime_memory_snapshot(context, memory) - - context.runtime_memory_snapshots.append(snapshot) - context.runtime_memory_snapshot_index = snapshot["index"] - - emit = getattr( - emitter, - "emit", - None, - ) - - await safe_call( - emit, - { - "type": "runtime_memory_update", - "memory": display_memory, - "updates": getattr(context, "runtime_memory_updates", 0), - "snapshot": snapshot, - "snapshots_count": len(context.runtime_memory_snapshots), - "snapshot_index": context.runtime_memory_snapshot_index, - }, - ) - - return snapshot - - -def build_runtime_l1_diff_stats( - diff_history: list[dict], -) -> dict: - - values = [ - item.get( - "total_diff", - 0, - ) - for item in diff_history - ] - - return { - "count": len(values), - "average": average_diff(values), - "range": diff_value_range(values), - "min": min(values) if values else 0, - "max": max(values) if values else 0, - } - - -async def emit_runtime_l1_diff_update( - context, -) -> None: - - emitter = getattr( - context, - "emitter", - None, - ) - - emit = getattr( - emitter, - "emit", - None, - ) - - history = list( - getattr( - context, - "runtime_l1_diff_history", - [], - ) - or [] - ) - - snapshots = list( - getattr( - context, - "runtime_memory_snapshots", - [], - ) - or [] - ) - latest_lines = ( - snapshots[-1].get("lines", []) - if snapshots - else [] - ) - - await safe_call( - emit, - { - "type": "runtime_l1_diff_update", - "diffs": history, - "stats": build_runtime_l1_diff_stats( - history - ), - "strength_map": build_strength_map( - latest_lines - ), - "strength_zones": get_strength_zones( - latest_lines - ), - }, - ) - - -async def emit_runtime_session_memory_update( - context, - *, - persist_browser: bool = False, -) -> None: - - emitter = getattr( - context, - "emitter", - None, - ) - - emit = getattr( - emitter, - "emit", - None, - ) - - memory = getattr( - context, - "runtime_l3_session_memory", - "", - ) or getattr( - context, - "session_memory", - "", - ) - event_snapshots = list( - getattr( - context, - "runtime_session_event_snapshots", - [], - ) - or [] - ) - - await safe_call( - emit, - { - "type": "runtime_session_memory_update", - "memory": memory, - "event_snapshots": event_snapshots, - "updates": getattr( - context, - "runtime_session_memory_updates", - 0, - ), - "source": getattr( - context, - "session_memory_source", - "", - ), - "persist": persist_browser, - }, - ) - - -async def emit_runtime_action_completed( - context, - *, - action: str, -) -> None: - - emitter = getattr( - context, - "emitter", - None, - ) - - emit = getattr( - emitter, - "emit", - None, - ) - - await safe_call( - emit, - { - "type": "runtime_action", - "action": action, - "status": "completed", - }, - ) - - -def extract_runtime_memory_text( - response: dict, -) -> str: - - text = ( - ResponseExtractor.extract_content_text( - response - ) - or ResponseExtractor.extract_reasoning_text( - response - ) - ) - - return text.strip() - - -def is_runtime_memory_response_truncated( - response: dict, -) -> bool: - - finish_reason = ( - ResponseExtractor - .extract_finish_reason( - response - ) - .lower() - ) - - return finish_reason in ( - "length", - "max_tokens", - ) - - -def looks_like_incomplete_runtime_memory( - text: str, -) -> bool: - - stripped = ( - text - or "" - ).strip() - - if not stripped: - return True - - if stripped[-1] in ( - ",", - ":", - "(", - "[", - "{", - ): - return True - - pairs = ( - ( - "(", - ")", - ), - ( - "[", - "]", - ), - ( - "{", - "}", - ), - ) - - return any( - stripped.count(open_char) - > stripped.count(close_char) - for open_char, close_char - in pairs - ) - - -async def refresh_runtime_memory_summarizer_usage( - context, - *, - system_prompt: str, - user_prompt: str, - response: dict | None = None, - context_window: int | None = None, -) -> None: - - if context is None: - return - - emitter = getattr( - context, - "emitter", - None, - ) - - if getattr( - emitter, - "emit", - None, - ) is None: - return - - usage = ( - ResponseExtractor.extract_usage( - response - ) - if isinstance( - response, - dict, - ) - else None - ) - - if ( - response is not None - and not usage - ): - return - - context_tokens = ( - usage.get( - "prompt_tokens", - 0, - ) - if usage - else estimate_runtime_tokens( - system_prompt=system_prompt, - user_input=user_prompt, - ) - ) - - total_tokens = ( - usage.get( - "total_tokens", - 0, - ) - if usage - else context_tokens - ) - - if not context_tokens: - return - - await refresh_runtime_state( - context, - runtime_id=( - RUNTIME_MEMORY_SUMMARIZER_RUNTIME_ID - ), - used_tokens=( - total_tokens - or context_tokens - ), - context_tokens=context_tokens, - total_tokens=( - total_tokens - or context_tokens - ), - max_tokens=( - context_window - or config.SERVICE_CONTEXT_WINDOW - ), - last_error=None, - status="online", - ) - - -def coerce_positive_int( - value, -) -> int: - - try: - number = int( - value - ) - except ( - TypeError, - ValueError, - ): - return 0 - - return max( - 0, - number, - ) - - -def runtime_usage_is_context_overloaded( - runtime: dict | None, -) -> bool: - - if not isinstance( - runtime, - dict, - ): - return False - - max_tokens = coerce_positive_int( - runtime.get( - "max_tokens" - ) - ) - - if not max_tokens: - return False - - used_tokens = max( - coerce_positive_int( - runtime.get( - "context_tokens" - ) - ), - coerce_positive_int( - runtime.get( - "total_tokens" - ) - ), - coerce_positive_int( - runtime.get( - "used_tokens" - ) - ), - ) - - return used_tokens > max_tokens - - -def latest_turn_context_is_overloaded( - context, -) -> bool: - - explicit_value = getattr( - context, - "runtime_last_turn_context_overloaded", - None, - ) - - if explicit_value is not None: - return bool( - explicit_value - ) - - runtime_id = ( - config.SERVICE_MODEL_UID - if config.USE_SERVICE_AS_BRAIN - else config.BRAIN_MODEL_UID - ) - - runtime = ( - runtime_state - .get_all_runtime_states() - .get( - runtime_id - ) - ) - - return runtime_usage_is_context_overloaded( - runtime - ) - - -def runtime_prompt_is_context_overloaded( - *, - system_prompt: str, - user_prompt: str, - context_window: int | None, -) -> bool: - - resolved_context_window = coerce_positive_int( - context_window - ) - - if not resolved_context_window: - return False - - prompt_tokens = estimate_runtime_tokens( - system_prompt=system_prompt, - user_input=user_prompt, - ) - - return prompt_tokens > resolved_context_window - - -def build_runtime_summarizer_payload( - *, - service_client, - system_prompt: str, - user_prompt: str, - temperature: float, - max_tokens: int, -) -> dict: - - return { - "model": getattr( - service_client, - "model_uid", - "<service>", - ), - "messages": [ - { - "role": "system", - "content": system_prompt, - }, - { - "role": "user", - "content": user_prompt, - }, - ], - "temperature": temperature, - "max_tokens": max_tokens, - "stream": False, - } - - -def build_l3_session_memory_max_tokens( - *, - system_prompt: str, - user_prompt: str, - context_window: int | None = None, -) -> int: - - prompt_tokens = estimate_runtime_tokens( - system_prompt=system_prompt, - user_input=user_prompt, - ) - effective_context_window = ( - context_window - or config.SERVICE_CONTEXT_WINDOW - ) - response_budget = ( - effective_context_window - - prompt_tokens - - 128 - ) - - return max( - 128, - min( - config.SERVICE_MAX_TOKENS, - response_budget, - ), - ) - - -class L3PromptBudgetExceeded( - RuntimeError, -): - - def __init__( - self, - diagnostic: dict, - ): - - super().__init__( - "L3 session digest exceeds safe input budget" - ) - self.diagnostic = diagnostic - - -def get_l3_input_token_budget( - context_window: int | None, -) -> int: - - effective_context_window = ( - context_window - or config.SERVICE_CONTEXT_WINDOW - ) - - return max( - 1, - min( - L3_INPUT_TOKEN_TARGET_MAX, - effective_context_window - - L3_INPUT_TOKEN_RESERVE, - ), - ) - - -async def build_budgeted_l3_session_user_prompt( - *, - context, - system_prompt: str, - current_session_memory: str, - runtime_memory_snapshots: list[dict], - diff_history: list[dict], - runtime_l2_memory: str, - session_event_snapshots: list[dict], - context_window: int | None, -) -> tuple[str, dict]: - - target_budget = get_l3_input_token_budget( - context_window - ) - - for minimal in ( - False, - True, - ): - raw_user_prompt = build_runtime_session_memory_user_prompt( - current_session_memory=current_session_memory, - runtime_memory_snapshots=runtime_memory_snapshots, - diff_history=diff_history, - runtime_l2_memory=runtime_l2_memory, - session_event_snapshots=session_event_snapshots, - minimal=minimal, - ) - user_prompt = build_runtime_summarizer_user_prompt( - context=context, - prompt=raw_user_prompt, - ) - input_tokens = estimate_runtime_tokens( - system_prompt=system_prompt, - user_input=user_prompt, - ) - diagnostic = { - "minimal": minimal, - "context_window": context_window, - "input_tokens": input_tokens, - "target_budget": target_budget, - "prompt_chars": len(user_prompt), - "system_prompt_chars": len(system_prompt), - } - - if input_tokens <= target_budget: - return user_prompt, diagnostic - - raise L3PromptBudgetExceeded( - diagnostic - ) - - -async def log_runtime_summarizer_payload( - context, - *, - label: str, - payload: dict, -) -> None: - - await log_memory_event( - context, - level=get_memory_log_level( - label - ), - message=f"{label} summarizer request", - details=json.dumps( - payload, - ensure_ascii=False, - indent=2, - ), - fallback_channel="summarizer", - event="summarizer_request", - ) - - -async def log_runtime_summarizer_result( - context, - *, - label: str, - result: str, -) -> None: - - await log_memory_event( - context, - level=get_memory_log_level( - label - ), - message=f"{label} summarizer result", - details=( - result.strip() - or "<empty>" - ), - fallback_channel="summarizer", - event="summarizer_result", - ) - - -async def ask_runtime_memory_model( - *, - context=None, - service_client, - current_memory: str, - user_message: str, - assistant_message: str, -) -> dict: - - resolve_request_context_window = getattr( - service_client, - "resolve_request_context_window", - None, - ) - detected_context_window = None - - if resolve_request_context_window is not None: - detected_context_window = ( - await resolve_request_context_window() - ) - - system_prompt = ( - build_runtime_memory_system_prompt() - ) - _snapshots = list( - getattr( - context, - "runtime_memory_snapshots", - [], - ) - or [] - ) - _latest_lines = ( - _snapshots[-1].get("lines", []) - if _snapshots - else [] - ) - user_prompt = build_runtime_summarizer_user_prompt( - context=context, - prompt=( - build_runtime_memory_user_prompt( - current_memory=current_memory, - user_message=user_message, - assistant_message=assistant_message, - current_l2_memory=getattr( - context, - "runtime_l2_memory", - "", - ), - strength_zones=get_strength_zones( - _latest_lines - ), - ) - ), - ) - - last_turn_context_overloaded = ( - latest_turn_context_is_overloaded( - context - ) - or runtime_prompt_is_context_overloaded( - system_prompt=system_prompt, - user_prompt=user_prompt, - context_window=detected_context_window, - ) - ) - - if last_turn_context_overloaded: - system_prompt = ( - build_runtime_memory_system_prompt( - last_turn_context_overloaded=True, - ) - ) - - await refresh_runtime_memory_summarizer_usage( - context, - system_prompt=system_prompt, - user_prompt=user_prompt, - context_window=detected_context_window, - ) - - temperature = ( - config.SERVICE_TEMPERATURE - ) - max_tokens = ( - config.SERVICE_MAX_TOKENS - ) - - await log_runtime_summarizer_payload( - context, - label="L1", - payload=build_runtime_summarizer_payload( - service_client=service_client, - system_prompt=system_prompt, - user_prompt=user_prompt, - temperature=temperature, - max_tokens=max_tokens, - ), - ) - - response = await ask_service_model( - client=service_client, - system_prompt=system_prompt, - user_prompt=user_prompt, - temperature=temperature, - max_tokens=max_tokens, - timeout=config.SERVICE_REQUEST_TIMEOUT, - ) - - await refresh_runtime_memory_summarizer_usage( - context, - system_prompt=system_prompt, - user_prompt=user_prompt, - response=response, - context_window=detected_context_window, - ) - - return response - - -async def ask_runtime_memory_batch_model( - *, - context=None, - service_client, - current_memory: str, - turns: list[dict], -) -> dict: - - system_prompt = ( - build_runtime_memory_system_prompt() - ) - _snapshots = list( - getattr( - context, - "runtime_memory_snapshots", - [], - ) - or [] - ) - _latest_lines = ( - _snapshots[-1].get("lines", []) - if _snapshots - else [] - ) - user_prompt = build_runtime_summarizer_user_prompt( - context=context, - prompt=( - build_runtime_memory_batch_user_prompt( - current_memory=current_memory, - turns=turns, - current_l2_memory=getattr( - context, - "runtime_l2_memory", - "", - ), - strength_zones=get_strength_zones( - _latest_lines - ), - ) - ), - ) - - await refresh_runtime_memory_summarizer_usage( - context, - system_prompt=system_prompt, - user_prompt=user_prompt, - ) - - temperature = ( - config.SERVICE_TEMPERATURE - ) - max_tokens = ( - config.SERVICE_MAX_TOKENS - ) - log_label = ( - "L1 batch" - if len(turns) > 1 - else "L1" - ) - - await log_runtime_summarizer_payload( - context, - label=log_label, - payload=build_runtime_summarizer_payload( - service_client=service_client, - system_prompt=system_prompt, - user_prompt=user_prompt, - temperature=temperature, - max_tokens=max_tokens, - ), - ) - - response = await ask_service_model( - client=service_client, - system_prompt=system_prompt, - user_prompt=user_prompt, - temperature=temperature, - max_tokens=max_tokens, - timeout=config.SERVICE_REQUEST_TIMEOUT, - ) - - await refresh_runtime_memory_summarizer_usage( - context, - system_prompt=system_prompt, - user_prompt=user_prompt, - response=response, - ) - - return response - - -async def ask_runtime_l2_memory_model( - *, - context=None, - service_client, - current_l2_memory: str, - patches: list[dict], -) -> dict: - - system_prompt = ( - build_runtime_l2_memory_system_prompt() - ) - user_prompt = ( - build_runtime_l2_memory_user_prompt( - current_l2_memory=current_l2_memory, - patches=patches, - ) - ) - - await refresh_runtime_memory_summarizer_usage( - context, - system_prompt=system_prompt, - user_prompt=user_prompt, - ) - - temperature = ( - config.SERVICE_TEMPERATURE - ) - max_tokens = ( - config.SERVICE_MAX_TOKENS - ) - - await log_runtime_summarizer_payload( - context, - label="L2", - payload=build_runtime_summarizer_payload( - service_client=service_client, - system_prompt=system_prompt, - user_prompt=user_prompt, - temperature=temperature, - max_tokens=max_tokens, - ), - ) - - response = await ask_service_model( - client=service_client, - system_prompt=system_prompt, - user_prompt=user_prompt, - temperature=temperature, - max_tokens=max_tokens, - timeout=config.SERVICE_REQUEST_TIMEOUT, - ) - - await refresh_runtime_memory_summarizer_usage( - context, - system_prompt=system_prompt, - user_prompt=user_prompt, - response=response, - ) - - return response - - -async def ask_runtime_session_memory_model( - *, - context=None, - service_client, - current_session_memory: str, - runtime_memory_snapshots: list[dict], - diff_history: list[dict], -) -> dict: - - system_prompt = ( - build_runtime_session_memory_system_prompt() - ) - - resolve_request_context_window = getattr( - service_client, - "resolve_request_context_window", - None, - ) - detected_context_window = None - - if resolve_request_context_window is not None: - detected_context_window = await resolve_request_context_window() - - user_prompt, _prompt_diagnostic = ( - await build_budgeted_l3_session_user_prompt( - context=context, - system_prompt=system_prompt, - current_session_memory=current_session_memory, - runtime_memory_snapshots=runtime_memory_snapshots, - diff_history=diff_history, - runtime_l2_memory=getattr( - context, - "runtime_l2_memory", - "", - ), - session_event_snapshots=list( - getattr( - context, - "runtime_session_event_snapshots", - [], - ) - or [] - ), - context_window=detected_context_window, - ) - ) - - await refresh_runtime_memory_summarizer_usage( - context, - system_prompt=system_prompt, - user_prompt=user_prompt, - context_window=detected_context_window, - ) - - temperature = ( - config.SERVICE_TEMPERATURE - ) - max_tokens = build_l3_session_memory_max_tokens( - system_prompt=system_prompt, - user_prompt=user_prompt, - context_window=detected_context_window, - ) - calculated_max_tokens = max_tokens - max_tokens = min( - max_tokens, - L3_OUTPUT_MAX_TOKENS, - ) - - if max_tokens < calculated_max_tokens: - await log_memory_event( - context, - level="L3", - message=( - "L3 session output token budget capped at " - f"{L3_OUTPUT_MAX_TOKENS}" - ), - fallback_channel="runtime", - ) - - await log_runtime_summarizer_payload( - context, - label="L3 session", - payload=build_runtime_summarizer_payload( - service_client=service_client, - system_prompt=system_prompt, - user_prompt=user_prompt, - temperature=temperature, - max_tokens=max_tokens, - ), - ) - response = await ask_service_model( - client=service_client, - system_prompt=system_prompt, - user_prompt=user_prompt, - temperature=temperature, - max_tokens=max_tokens, - timeout=config.SERVICE_REQUEST_TIMEOUT, - ) - - await refresh_runtime_memory_summarizer_usage( - context, - system_prompt=system_prompt, - user_prompt=user_prompt, - response=response, - context_window=detected_context_window, - ) - - return response - - -def get_runtime_l2_user_turn_count( - context, -) -> int: - - return int( - getattr( - context, - "user_message_count", - getattr( - context, - "turn_number", - 0, - ), - ) - or 0 - ) - - -def ensure_runtime_l2_state( - context, -) -> None: - - if not hasattr( - context, - "runtime_l2_memory", - ): - context.runtime_l2_memory = DEFAULT_RUNTIME_L2_MEMORY - - if not hasattr( - context, - "runtime_l2_pending_patches", - ): - context.runtime_l2_pending_patches = [] - - if not hasattr( - context, - "runtime_l2_last_turn", - ): - context.runtime_l2_last_turn = 0 - - -async def record_runtime_l1_diff( - context, - snapshot: dict, - turns: list[dict] | None = None, -) -> None: - - ensure_runtime_l2_state( - context - ) - - total_diff = snapshot.get( - "total_diff", - 0, - ) - context.runtime_conversation_activity_diff = total_diff - - observed_turns = list( - turns - or [] - ) - - user_turn_count = get_runtime_l2_user_turn_count( - context - ) - - diff_entry = { - "turn_number": user_turn_count, - "snapshot_index": snapshot.get( - "index", - 0, - ), - "total_diff": total_diff, - "changes": snapshot.get( - "patch", - {}, - ), - } - - context.runtime_l2_pending_patches.append( - diff_entry - ) - - if not hasattr( - context, - "runtime_l1_diff_history", - ): - context.runtime_l1_diff_history = [] - - context.runtime_l1_diff_history.append( - { - **diff_entry, - "history_index": len( - context.runtime_l1_diff_history - ), - } - ) - - turns_since_l2 = ( - user_turn_count - - getattr( - context, - "runtime_l2_last_turn", - 0, - ) - ) - - recent_diffs = get_recent_l2_diff_values( - context - ) - diff_average = average_diff( - recent_diffs - ) - diff_range = diff_value_range( - recent_diffs - ) - repeated_keys = get_repeated_l2_patch_keys( - context - ) - l2_last_turn = getattr( - context, - "runtime_l2_last_turn", - 0, - ) - l2_turn_label = ( - f"turns since L2 {turns_since_l2}" - if l2_last_turn - else f"L2 not run yet; observed turns {user_turn_count}" - ) - - if total_diff == 0: - latest_turn = ( - observed_turns[-1] - if observed_turns - else {} - ) - context.runtime_zero_diff_alert = { - "turn_number": user_turn_count, - "user_message": latest_turn.get( - "user_message", - "", - ), - "assistant_message": latest_turn.get( - "assistant_message", - "", - ), - "reason": ( - "Previous L1 memory update produced total_diff 0." - ), - } - - await log_memory_event( - context, - level="L1", - message=( - "L1 diff " - f"+{format_diff_value(total_diff)}; " - f"recent diffs {format_diff_values(recent_diffs)}; " - f"avg {format_diff_value(diff_average)}; " - f"range {format_diff_value(diff_range)}; " - f"patch window {len(recent_diffs)}/{L2_PATCH_WINDOW}; " - f"repeated keys {repeated_keys}; " - f"{l2_turn_label}" - ), - fallback_channel="service", - ) - - await emit_runtime_l1_diff_update( - context - ) - - -def get_recent_l2_patches( - context, -) -> list[dict]: - - return list( - getattr( - context, - "runtime_l2_pending_patches", - [], - ) - or [] - )[-L2_PATCH_WINDOW:] - - -def get_recent_l2_diff_values( - context, -) -> list[float]: - - return [ - patch.get( - "total_diff", - 0, - ) - for patch in get_recent_l2_patches( - context - ) - ] - - -def average_diff( - diffs: list[float], -) -> float: - - if not diffs: - return 0 - - return round( - sum(diffs) / len(diffs), - 2, - ) - - -def format_diff_value( - value: float, -) -> str: - - return ( - f"{value:.2f}" - .rstrip( - "0" - ) - .rstrip( - "." - ) - ) - - -def format_diff_values( - values: list[float], -) -> str: - - return ( - "[" - + ", ".join( - format_diff_value( - value - ) - for value in values - ) - + "]" - ) - - -def diff_value_range( - diffs: list[float], -) -> float: - - if not diffs: - return 0 - - return round( - max(diffs) - min(diffs), - 2, - ) - - -def extract_l2_patch_keys( - patch: dict, -) -> set[str]: - - changes = patch.get( - "changes", - {}, - ) - - keys = set() - - for entry in ( - changes.get( - "added", - [], - ) - or [] - ): - key = normalize_memory_key( - entry.get( - "key", - "", - ) - ) - - if key: - keys.add( - key - ) - - for entry in ( - changes.get( - "changed", - [], - ) - or [] - ): - for key_name in ( - "current_key", - "previous_key", - ): - key = normalize_memory_key( - entry.get( - key_name, - "", - ) - ) - - if key: - keys.add( - key - ) - - for entry in ( - changes.get( - "removed", - [], - ) - or [] - ): - key = normalize_memory_key( - entry.get( - "key", - "", - ) - ) - - if key: - keys.add( - key - ) - - return keys - - -def count_l2_patch_keys( - patches: list[dict], -) -> dict[str, int]: - - counts = {} - - for patch in patches: - for key in extract_l2_patch_keys( - patch - ): - counts[key] = ( - counts.get( - key, - 0, - ) - + 1 - ) - - return counts - - -def get_repeated_l2_patch_keys( - context, -) -> dict[str, int]: - - counts = count_l2_patch_keys( - get_recent_l2_patches( - context - ) - ) - - return { - key: count - for key, count in counts.items() - if count >= L2_REPEATED_KEY_THRESHOLD - } - - -def should_run_runtime_l2_memory( - context, -) -> bool: - - ensure_runtime_l2_state( - context - ) - - user_turn_count = get_runtime_l2_user_turn_count( - context - ) - turns_since_l2 = ( - user_turn_count - - getattr( - context, - "runtime_l2_last_turn", - 0, - ) - ) - - recent_patches = get_recent_l2_patches( - context - ) - repeated_keys = count_l2_patch_keys( - recent_patches - ) - - return ( - turns_since_l2 >= MIN_L2_TURNS - and len(recent_patches) >= L2_PATCH_WINDOW - and any( - count >= L2_REPEATED_KEY_THRESHOLD - for count in repeated_keys.values() - ) - ) - - -async def maybe_summarize_runtime_l2_memory( - *, - context, -) -> str: - - ensure_runtime_l2_state( - context - ) - - if not should_run_runtime_l2_memory( - context - ): - return getattr( - context, - "runtime_l2_memory", - DEFAULT_RUNTIME_L2_MEMORY, - ) - - patches = get_recent_l2_patches( - context - ) - - if not patches: - return getattr( - context, - "runtime_l2_memory", - DEFAULT_RUNTIME_L2_MEMORY, - ) - - service_client = ( - getattr( - context, - "clients", - {}, - ) - .get( - "service" - ) - ) - - if service_client is None: - return getattr( - context, - "runtime_l2_memory", - DEFAULT_RUNTIME_L2_MEMORY, - ) - - current_l2_memory = getattr( - context, - "runtime_l2_memory", - DEFAULT_RUNTIME_L2_MEMORY, - ) - - try: - response = await ask_runtime_l2_memory_model( - context=context, - service_client=service_client, - current_l2_memory=current_l2_memory, - patches=patches, - ) - - updated_l2_memory = extract_runtime_memory_text( - response - ) - - skip_reason = None - - if is_runtime_memory_response_truncated(response): - skip_reason = "L2 summarizer response was truncated by max_tokens." - - elif ( - updated_l2_memory.strip() - and looks_like_incomplete_runtime_memory( - updated_l2_memory - ) - ): - skip_reason = "L2 summarizer returned text that looks structurally incomplete." - - if skip_reason: - await log_memory_event( - context, - level="L2", - message="L2 memory update skipped", - details=build_memory_update_skip_details( - reason=skip_reason, - previous_memory=current_l2_memory, - candidate_memory=updated_l2_memory, - ), - fallback_channel="error", - ) - - return current_l2_memory - - updated_l2_memory = ensure_confirmable_memory_markers( - updated_l2_memory, - ) - - context.runtime_l2_memory = updated_l2_memory - context.runtime_l2_last_turn = get_runtime_l2_user_turn_count( - context - ) - context.runtime_l2_pending_patches = [] - - await log_memory_event( - context, - level="L2", - message="L2 memory updated", - fallback_channel="service", - ) - - await log_runtime_summarizer_result( - context, - label="L2 pattern memory", - result=updated_l2_memory, - ) - - return getattr( - context, - "runtime_l2_memory", - DEFAULT_RUNTIME_L2_MEMORY, - ) - - except asyncio.CancelledError: - raise - - except Exception as error: - formatted_traceback = ( - traceback.format_exc() - ) - - await log_memory_event( - context, - level="L2", - message="L2 memory update failed", - details=build_memory_failure_details( - stage="L2 memory summarizer", - error=error, - traceback_text=formatted_traceback, - ), - fallback_channel="error", - ) - - return current_l2_memory - - -async def maybe_summarize_runtime_session_memory( - *, - context, -) -> str: - - if not getattr( - context, - "runtime_remember_session_requested", - False, - ): - return getattr( - context, - "runtime_l3_session_memory", - DEFAULT_RUNTIME_L3_SESSION_MEMORY, - ) - - service_client = ( - getattr( - context, - "clients", - {}, - ) - .get( - "service" - ) - ) - - if service_client is None: - await emit_runtime_action_completed( - context, - action="remember_session", - ) - - return getattr( - context, - "runtime_l3_session_memory", - DEFAULT_RUNTIME_L3_SESSION_MEMORY, - ) - - snapshots = list( - getattr( - context, - "runtime_memory_snapshots", - [], - ) - or [] - ) - - if not snapshots: - await log_memory_event( - context, - level="L3", - message="L3 session save skipped: no snapshots", - fallback_channel="runtime", - ) - - await emit_runtime_action_completed( - context, - action="remember_session", - ) - - return getattr( - context, - "runtime_l3_session_memory", - DEFAULT_RUNTIME_L3_SESSION_MEMORY, - ) - - current_session_memory = getattr( - context, - "runtime_l3_session_memory", - "", - ) or getattr( - context, - "session_memory", - "", - ) - - try: - response = await ask_runtime_session_memory_model( - context=context, - service_client=service_client, - current_session_memory=current_session_memory, - runtime_memory_snapshots=snapshots, - diff_history=list( - getattr( - context, - "runtime_l1_diff_history", - [], - ) - or [] - ), - ) - - updated_session_memory = extract_runtime_memory_text( - response - ) - - skip_reason = None - - if is_runtime_memory_response_truncated(response): - await log_memory_event( - context, - level="L3", - message="L3 session summarizer reached max_tokens", - fallback_channel="runtime", - ) - - skip_reason = "L3 session summarizer response was truncated by max_tokens." - - elif ( - updated_session_memory.strip() - and looks_like_incomplete_runtime_memory( - updated_session_memory - ) - ): - skip_reason = "L3 session summarizer returned text that looks structurally incomplete." - - if skip_reason: - await log_memory_event( - context, - level="L3", - message="L3 session memory update skipped", - details=build_memory_update_skip_details( - reason=skip_reason, - previous_memory=current_session_memory, - candidate_memory=updated_session_memory, - ), - fallback_channel="error", - ) - - await emit_runtime_action_completed( - context, - action="remember_session", - ) - - return current_session_memory - - if updated_session_memory.strip(): - context.runtime_l3_session_memory = updated_session_memory - context.session_memory = updated_session_memory - context.session_memory_source = "L3" - context.runtime_session_memory_updates = ( - getattr( - context, - "runtime_session_memory_updates", - 0, - ) - + 1 - ) - context.runtime_remember_session_requested = False - - runtime_snapshots = getattr( - context, - "runtime_memory_snapshots", - [], - ) - if runtime_snapshots: - context.runtime_memory_snapshot_index = ( - len(runtime_snapshots) - 1 - ) - - await log_memory_event( - context, - level="L3", - message="L3 session memory updated", - fallback_channel="service", - ) - - await log_runtime_summarizer_result( - context, - label="L3 session memory", - result=updated_session_memory, - ) - - await emit_runtime_session_memory_update( - context, - persist_browser=True, - ) - - await emit_runtime_action_completed( - context, - action="remember_session", - ) - - return getattr( - context, - "runtime_l3_session_memory", - DEFAULT_RUNTIME_L3_SESSION_MEMORY, - ) - - except asyncio.CancelledError: - raise - - except L3PromptBudgetExceeded as error: - await log_memory_event( - context, - level="L3", - message="L3 session memory update skipped", - details=( - "Reason: compact digest still exceeds safe input budget.\n\n" - + json.dumps( - error.diagnostic, - ensure_ascii=False, - indent=2, - ) - ), - fallback_channel="error", - ) - - await emit_runtime_action_completed( - context, - action="remember_session", - ) - - return current_session_memory - - except Exception as error: - formatted_traceback = ( - traceback.format_exc() - ) - - await log_memory_event( - context, - level="L3", - message="L3 session memory update failed", - details=build_memory_failure_details( - stage="L3 session memory summarizer", - error=error, - traceback_text=formatted_traceback, - ), - fallback_channel="error", - ) - - await emit_runtime_action_completed( - context, - action="remember_session", - ) - - return current_session_memory - - -async def summarize_runtime_memory( - *, - context, - user_message: str, - assistant_message: str, -) -> str: - - if not assistant_message.strip(): - return getattr( - context, - "runtime_memory", - "", - ) - - service_client = ( - getattr( - context, - "clients", - {}, - ) - .get( - "service" - ) - ) - - if service_client is None: - return getattr( - context, - "runtime_memory", - "", - ) - - current_memory = remove_runtime_response_feedback_text( - getattr( - context, - "runtime_memory", - "", - ) - ) - - context.runtime_memory = current_memory - context.runtime_memory_stable = remove_runtime_response_feedback_text( - getattr( - context, - "runtime_memory_stable", - "", - ) - ) - context.runtime_last_response_feedback = None - - try: - response = await ask_runtime_memory_model( - context=context, - service_client=service_client, - current_memory=current_memory, - user_message=user_message, - assistant_message=assistant_message, - ) - - updated_memory = extract_runtime_memory_text( - response - ) - updated_memory = remove_runtime_response_feedback_text( - updated_memory - ) - - if ( - is_runtime_memory_response_truncated( - response - ) - or looks_like_incomplete_runtime_memory( - updated_memory - ) - ): - await log_memory_event( - context, - level="L1", - message="L1 runtime memory update skipped", - details=build_memory_update_skip_details( - reason="Summarizer returned an incomplete memory update.", - previous_memory=current_memory, - candidate_memory=updated_memory, - ), - fallback_channel="error", - ) - - return current_memory - - updated_memory = merge_durable_memory_facts( - current_memory, - updated_memory, - ) - updated_memory = remove_runtime_response_feedback_text( - updated_memory - ) - updated_memory = ensure_confirmable_memory_markers( - updated_memory, - user_message=user_message, - assistant_message=assistant_message, - ) - updated_memory = remove_runtime_response_feedback_text( - updated_memory - ) - updated_memory = remove_runtime_user_idle_lines( - updated_memory - ) - - updates_counter = getattr( - context, - "runtime_memory_updates", - 0, - ) - - if updated_memory or updates_counter == 0: - context.runtime_memory = updated_memory - context.runtime_memory_stable = updated_memory - context.runtime_memory_updates = updates_counter + 1 - - await log_memory_event( - context, - level="L1", - message="L1 runtime memory updated", - fallback_channel="service", - ) - - snapshot = await emit_runtime_memory_update( - context - ) - - await record_runtime_l1_diff( - context, - snapshot, - turns=[ - { - "user_message": user_message, - "assistant_message": assistant_message, - }, - ], - ) - await maybe_summarize_runtime_l2_memory( - context=context, - ) - await maybe_summarize_runtime_session_memory( - context=context, - ) - - return getattr( - context, - "runtime_memory", - "", - ) - - except asyncio.CancelledError: - raise - - except Exception as error: - formatted_traceback = ( - traceback.format_exc() - ) - - await log_memory_event( - context, - level="L1", - message="L1 runtime memory update failed", - details=build_memory_failure_details( - stage="L1 runtime memory summarizer", - error=error, - traceback_text=formatted_traceback, - ), - fallback_channel="error", - ) - - return getattr( - context, - "runtime_memory", - "", - ) - - -async def summarize_runtime_memory_pending_turns( - *, - context, -) -> str: - - turns = list( - context.runtime_memory_pending_turns - ) - - if not turns: - return getattr( - context, - "runtime_memory", - "", - ) - - service_client = ( - getattr( - context, - "clients", - {}, - ) - .get( - "service" - ) - ) - - if service_client is None: - return getattr( - context, - "runtime_memory", - "", - ) - - initial_memory = remove_runtime_response_feedback_text( - getattr( - context, - "runtime_memory_stable", - "", - ) - ) - - context.runtime_memory = remove_runtime_response_feedback_text( - getattr( - context, - "runtime_memory", - "", - ) - ) - context.runtime_memory_stable = initial_memory - context.runtime_last_response_feedback = None - - try: - response = await ask_runtime_memory_batch_model( - context=context, - service_client=service_client, - current_memory=initial_memory, - turns=turns, - ) - - updated_memory = extract_runtime_memory_text( - response - ) - updated_memory = remove_runtime_response_feedback_text( - updated_memory - ) - - skip_reason = None - - if is_runtime_memory_response_truncated(response): - skip_reason = "Summarizer response was truncated by max_tokens." - - elif looks_like_incomplete_runtime_memory(updated_memory): - skip_reason = "Summarizer returned text that looks structurally incomplete." - - if skip_reason: - await log_memory_event( - context, - level="L1", - message="L1 runtime memory update skipped", - details=build_memory_update_skip_details( - reason="Summarizer returned an incomplete memory update.", - previous_memory=initial_memory, - candidate_memory=updated_memory, - ), - fallback_channel="error", - ) - - return initial_memory - - updated_memory = merge_durable_memory_facts( - initial_memory, - updated_memory, - ) - updated_memory = remove_runtime_response_feedback_text( - updated_memory - ) - - latest_turn = turns[-1] if turns else {} - updated_memory = ensure_confirmable_memory_markers( - updated_memory, - user_message=latest_turn.get( - "user_message", - "", - ), - assistant_message=latest_turn.get( - "assistant_message", - "", - ), - ) - updated_memory = remove_runtime_response_feedback_text( - updated_memory - ) - updated_memory = remove_runtime_user_idle_lines( - updated_memory - ) - - updates_counter = getattr( - context, - "runtime_memory_updates", - 0, - ) - - if updated_memory or updates_counter == 0: - context.runtime_memory = updated_memory - context.runtime_memory_stable = updated_memory - context.runtime_memory_updates = updates_counter + 1 - - context.runtime_memory_pending_turns = [ - turn - for turn in context.runtime_memory_pending_turns - if turn not in turns - ] - - await log_memory_event( - context, - level="L1", - message="L1 runtime memory updated", - fallback_channel="service", - ) - - snapshot = await emit_runtime_memory_update( - context - ) - - await record_runtime_l1_diff( - context, - snapshot, - turns=turns, - ) - await maybe_summarize_runtime_l2_memory( - context=context, - ) - await maybe_summarize_runtime_session_memory( - context=context, - ) - - return getattr( - context, - "runtime_memory", - "", - ) - - except asyncio.CancelledError: - raise - - except Exception as error: - formatted_traceback = ( - traceback.format_exc() - ) - - await log_memory_event( - context, - level="L1", - message="L1 runtime memory update failed", - details=build_memory_failure_details( - stage="L1 pending runtime memory summarizer", - error=error, - traceback_text=formatted_traceback, - ), - fallback_channel="error", - ) - - return getattr( - context, - "runtime_memory", - "", - ) - - finally: - if ( - getattr( - context, - "runtime_memory_update_task", - None, - ) - is asyncio.current_task() - ): - context.runtime_memory_update_task = None - - -def schedule_runtime_memory_update( - *, - context, - user_message: str, - assistant_message: str, -) -> asyncio.Task | None: - - if not assistant_message.strip(): - return None - - context.runtime_memory_pending_turns.append({ - "user_message": user_message, - "assistant_message": assistant_message, - }) - - previous_task = getattr( - context, - "runtime_memory_update_task", - None, - ) - - if ( - previous_task is not None - and not previous_task.done() - ): - previous_task.cancel() - - task = asyncio.create_task( - summarize_runtime_memory_pending_turns( - context=context, - ) - ) - - context.runtime_memory_update_task = task - - background_tasks = getattr( - context, - "background_tasks", - None, - ) - - if background_tasks is None: - background_tasks = set() - context.background_tasks = background_tasks - - background_tasks.add( - task - ) - task.add_done_callback( - background_tasks.discard - ) - - return task - - -def schedule_interrupted_runtime_memory_update( - *, - context, -) -> asyncio.Task | None: - - user_message = getattr( - context, - "runtime_turn_user_message", - "", - ) - - assistant_message = ( - build_interrupted_assistant_message( - user_message=user_message, - assistant_message=getattr( - context, - "runtime_turn_assistant_response", - "", - ), - ) - ) - - if not user_message.strip(): - return None - - return schedule_runtime_memory_update( - context=context, - user_message=user_message, - assistant_message=assistant_message, - ) - - -async def cancel_runtime_memory_update( - context, -) -> None: - - task = getattr( - context, - "runtime_memory_update_task", - None, - ) - - if ( - task is None - or task.done() - ): - return - - task.cancel() - - with contextlib.suppress( - asyncio.CancelledError, - Exception, - ): - await task - - context.runtime_memory_update_task = None - -def build_memory_update_skip_details( - *, - reason: str, - previous_memory: str, - candidate_memory: str, -) -> str: - - return ( - f"{reason}\n\n" - "Previous memory:\n" - "----------------\n" - f"{previous_memory.strip() or DEFAULT_RUNTIME_MEMORY}\n\n" - "Candidate memory:\n" - "-----------------\n" - f"{candidate_memory.strip() or '<empty>'}" - ) - -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 - - key, value = canonicalize_runtime_memory_entry( - key, - value, - ) - - lines.append({ - "key": key, - "value": value, - "status": "same", - }) - - return lines - -def normalize_memory_key( - key: str, -) -> str: - - return ( - key - .strip() - .lower() - ) - - -def is_durable_memory_key( - key: str, -) -> bool: - - normalized_key = normalize_memory_key( - key - ) - - return any( - token in normalized_key - for token in DURABLE_MEMORY_KEY_TOKENS - ) - - -def compute_line_strength( - prev_strength: float | None, - change_ratio_val: float, - is_durable: bool, - is_new: bool, -) -> float: - if is_new: - raw = STRENGTH_NEW_KEY - else: - raw = ( - (prev_strength or 0.0) * STRENGTH_DECAY - + STRENGTH_PRESENCE_BOOST - + change_ratio_val * STRENGTH_BOOST - ) - - floor = DURABLE_FLOOR if is_durable else 0.0 - - return round( - min( - 1.0, - max( - floor, - raw, - ), - ), - 4, - ) - - -def get_strength_zones( - lines: list[dict], -) -> dict: - hot, crystallized, fading = [], [], [] - for line in lines: - key = line.get("key", "") - strength = line.get("strength", 0.0) - durable = is_durable_memory_key(key) - if strength >= HOT_THRESHOLD: - hot.append(key) - elif durable and strength <= DURABLE_FLOOR + 0.05: - crystallized.append(key) - elif strength <= FADING_THRESHOLD: - fading.append(key) - return {"hot": hot, "crystallized": crystallized, "fading": fading} - - -def build_strength_map( - lines: list[dict], -) -> dict[str, float]: - return { - line.get("key", ""): line.get("strength", 0.0) - for line in lines - if line.get("key") - } - - -def has_durable_fact_negation( - value: str, -) -> bool: - - normalized_value = ( - value - or "" - ).strip().lower() - - return any( - marker in normalized_value - for marker in DURABLE_MEMORY_NEGATION_MARKERS - ) - - -def durable_memory_line_text( - line: dict, -) -> str: - - key = ( - line.get( - "key", - "", - ) - or "" - ).strip() - - value = ( - line.get( - "value", - "", - ) - or "" - ).strip() - - if not key: - return value - - return f"{key}: {value}" - - -def split_memory_value_parts( - value: str, -) -> list[str]: - - return [ - part.strip() - for part in ( - value - or "" - ).split(",") - if part.strip() - ] - - -def collapse_duplicate_runtime_memory_keys( - memory: str, -) -> str: - - output_entries = [] - grouped_by_key = {} - duplicate_found = False - - for raw_line in ( - memory - or "" - ).splitlines(): - - stripped_line = raw_line.strip() - - if not stripped_line: - output_entries.append( - raw_line - ) - continue - - line = stripped_line.lstrip("-").strip() - - if ":" not in line: - output_entries.append( - raw_line - ) - continue - - key, value = line.split( - ":", - 1, - ) - - key, value = canonicalize_runtime_memory_entry( - key, - value, - ) - - normalized_key = normalize_memory_key( - key - ) - - if ( - not normalized_key - or not is_durable_memory_key( - key - ) - ): - output_entries.append( - raw_line - ) - continue - - existing = grouped_by_key.get( - normalized_key - ) - - if existing is None: - existing = { - "key": key, - "values": [], - "seen_values": set(), - } - grouped_by_key[normalized_key] = existing - output_entries.append( - existing - ) - else: - duplicate_found = True - - for part in split_memory_value_parts( - value - ): - normalized_value = part.casefold() - - if normalized_value in existing["seen_values"]: - continue - - existing["seen_values"].add( - normalized_value - ) - existing["values"].append( - part - ) - - if not duplicate_found: - return memory - - return "\n".join( - ( - ( - f'{entry["key"]}: {", ".join(entry["values"])}' - if entry["key"] - else ", ".join(entry["values"]) - ) - if isinstance( - entry, - dict, - ) - else entry - ) - for entry in output_entries - ) - - -def merge_durable_memory_facts( - previous_memory: str, - candidate_memory: str, -) -> str: - - previous_memory = remove_runtime_response_feedback_text( - previous_memory - ) - candidate_memory = remove_runtime_response_feedback_text( - candidate_memory - ) - - previous_lines = parse_runtime_memory_lines( - previous_memory - ) - candidate_lines = parse_runtime_memory_lines( - candidate_memory - ) - - candidate_by_key = { - normalize_memory_key( - line.get( - "key", - "", - ) - ): line - for line in candidate_lines - } - - preserved_lines = [] - - for previous_line in previous_lines: - - previous_key = ( - previous_line.get( - "key", - "", - ) - or "" - ).strip() - - if not is_durable_memory_key( - previous_key - ): - continue - - normalized_key = normalize_memory_key( - previous_key - ) - - candidate_line = candidate_by_key.get( - normalized_key - ) - - if candidate_line is not None: - candidate_value = candidate_line.get( - "value", - "", - ) - - if has_durable_fact_negation( - candidate_value - ): - continue - - continue - - preserved_lines.append( - durable_memory_line_text( - previous_line - ) - ) - - if not preserved_lines: - return collapse_duplicate_runtime_memory_keys( - candidate_memory - ) - - candidate_text = ( - candidate_memory - or "" - ).strip() - - if not candidate_text: - return collapse_duplicate_runtime_memory_keys( - "\n".join( - preserved_lines - ) - ) - - return collapse_duplicate_runtime_memory_keys( - ( - candidate_text - + "\n" - + "\n".join( - preserved_lines - ) - ) - ) - - -def normalize_generic_memory_key( - key: str, -) -> str: - - return ( - normalize_memory_key( - key - ) - .replace( - "_", - " ", - ) - .replace( - "-", - " ", - ) - ) - - -def is_generic_memory_match_key( - key: str, -) -> bool: - - return normalize_generic_memory_key( - key - ) in GENERIC_MEMORY_MATCH_KEYS - - -def memory_value_similarity( - previous: str, - current: str, -) -> float: - - previous = ( - previous - or "" - ).strip() - current = ( - current - or "" - ).strip() - - if not previous and not current: - return 1.0 - - if not previous or not current: - return 0.0 - - return round( - SequenceMatcher( - None, - previous.lower(), - current.lower(), - ).ratio(), - 3, - ) - - -def should_match_previous_memory_line( - *, - key: str, - value: str, - previous_line: dict | None, -) -> bool: - - if previous_line is None: - return False - - previous_key = previous_line.get( - "key", - "", - ) - - if not ( - is_generic_memory_match_key( - key - ) - or is_generic_memory_match_key( - previous_key - ) - ): - return True - - similarity = memory_value_similarity( - previous_line.get( - "value", - "", - ), - value, - ) - - return similarity >= GENERIC_MEMORY_VALUE_SIMILARITY_MIN - - -def find_best_previous_line( - key: str, - previous_lines: list[dict], - value: str = "", -) -> 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 - and should_match_previous_memory_line( - key=key, - value=value, - previous_line=best_line, - ) - ): - 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" - line["strength"] = compute_line_strength( - prev_strength=None, - change_ratio_val=1.0, - is_durable=is_durable_memory_key( - line.get("key", "") - ), - is_new=True, - ) - - 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 - ) - - if not should_match_previous_memory_line( - key=key, - value=value, - previous_line=previous_line, - ): - previous_line = None - - if previous_line is None: - previous_line = find_best_previous_line( - key, - previous_lines, - value=value, - ) - - # ----------------------------------------- - # 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" - line["strength"] = compute_line_strength( - prev_strength=None, - change_ratio_val=1.0, - is_durable=is_durable_memory_key(key), - is_new=True, - ) - - 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" - - line["strength"] = compute_line_strength( - prev_strength=previous_line.get("strength"), - change_ratio_val=max(key_delta, value_delta), - is_durable=is_durable_memory_key(key), - is_new=False, - ) - - return current_lines - - -def build_runtime_memory_patch( - current_lines: list[dict], - previous_snapshot: dict | None, -) -> dict: - - patch = { - "added": [], - "changed": [], - "removed": [], - } - total_diff = 0 - - if not previous_snapshot: - for line in current_lines: - patch["added"].append({ - "key": line.get( - "key", - "", - ), - "value": line.get( - "value", - "", - ), - "strength": line.get( - "strength", - 0.0, - ), - }) - total_diff += 30 - - return { - "patch": patch, - "total_diff": total_diff, - } - - 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 - - matched_previous_ids = set() - - 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 - ) - - if not should_match_previous_memory_line( - key=key, - value=value, - previous_line=previous_line, - ): - previous_line = None - - if previous_line is None: - previous_line = find_best_previous_line( - key, - previous_lines, - value=value, - ) - - if previous_line is None: - patch["added"].append({ - "key": key, - "value": line.get( - "value", - "", - ), - "strength": line.get( - "strength", - 0.0, - ), - }) - total_diff += 30 - continue - - matched_previous_ids.add( - id(previous_line) - ) - - key_delta = line.get( - "key_change_ratio", - 0, - ) - value_delta = line.get( - "value_change_ratio", - 0, - ) - - if key_delta or value_delta: - patch["changed"].append({ - "previous_key": previous_line.get( - "key", - "", - ), - "previous_value": previous_line.get( - "value", - "", - ), - "current_key": key, - "current_value": line.get( - "value", - "", - ), - "key_change_ratio": key_delta, - "value_change_ratio": value_delta, - "previous_strength": previous_line.get( - "strength", - 0.0, - ), - "current_strength": line.get( - "strength", - 0.0, - ), - }) - total_diff += round( - ( - key_delta - + value_delta - ) - * 50, - 2, - ) - - for previous_line in previous_lines: - if id(previous_line) in matched_previous_ids: - continue - - patch["removed"].append({ - "key": previous_line.get( - "key", - "", - ), - "value": previous_line.get( - "value", - "", - ), - "strength": previous_line.get( - "strength", - 0.0, - ), - }) - total_diff += 20 - - return { - "patch": patch, - "total_diff": total_diff, - } - - -def build_runtime_memory_snapshot( - context, - memory: str, -) -> dict: - - snapshots = getattr( - context, - "runtime_memory_snapshots", - [], - ) - - previous_snapshot = ( - snapshots[-1] - if snapshots - else None - ) - - display_memory = build_runtime_memory_context_text( - memory, - context, - ) - - lines = parse_runtime_memory_lines( - display_memory - ) - - lines = apply_runtime_memory_diff( - lines, - previous_snapshot, - ) - - patch_details = build_runtime_memory_patch( - lines, - previous_snapshot, - ) - - return { - "session_id": getattr(context, "session_id", ""), - "index": len(snapshots), - "raw_memory": display_memory, - "lines": lines, - "patch": patch_details["patch"], - "total_diff": patch_details["total_diff"], - } diff --git a/runtime/memory_common.py b/runtime/memory_common.py new file mode 100644 index 00000000..d364c985 --- /dev/null +++ b/runtime/memory_common.py @@ -0,0 +1,742 @@ +import contextlib +import json +from datetime import datetime +from difflib import SequenceMatcher + +from clients.errors import ( + format_client_error, +) +from clients.response_extractor import ( + ResponseExtractor, +) +from config_loader import ( + config, +) +from runtime.context_contract import ( + ContextContract, +) +from runtime.L1_memory_rules import ( + DEFAULT_RUNTIME_MEMORY, +) +from runtime.registry import ( + runtime_state, +) +from runtime.state import ( + RUNTIME_MEMORY_SUMMARIZER_RUNTIME_ID, +) +from runtime.state_sync import ( + refresh_runtime_state, +) +from utils.tokens import ( + estimate_runtime_tokens, +) + + +def build_runtime_summarizer_trusted_context( + context=None, +) -> str: + + timestamp = ( + getattr( + context, + "timestamp", + "", + ) + if context is not None + else "" + ) + current_date = ( + getattr( + context, + "current_date", + "", + ) + if context is not None + else "" + ) + current_time = ( + getattr( + context, + "current_time", + "", + ) + if context is not None + else "" + ) + weekday = ( + getattr( + context, + "weekday", + "", + ) + if context is not None + else "" + ) + year = ( + getattr( + context, + "year", + None, + ) + if context is not None + else None + ) + + now = None + + if timestamp: + try: + now = datetime.fromisoformat( + str(timestamp).replace( + "Z", + "+00:00", + ) + ) + except ValueError: + now = None + + if now is None: + now = datetime.now() + timestamp = timestamp or now.isoformat() + + contract = ContextContract( + user_input="", + timestamp=str(timestamp), + current_date=str( + current_date + or now.date().isoformat() + ), + current_time=str( + current_time + or now.strftime("%H:%M:%S") + ), + weekday=str( + weekday + or now.strftime("%A") + ), + year=int( + year + or now.year + ), + ) + + return contract.to_runtime_xml() + + +def build_runtime_summarizer_user_prompt( + *, + context=None, + prompt: str, +) -> str: + + return "\n\n".join([ + build_runtime_summarizer_trusted_context( + context + ), + prompt, + ]) + + +def infer_memory_failure_reason( + error: Exception, +) -> str: + + response = getattr( + error, + "response", + None, + ) + status_code = getattr( + response, + "status_code", + None, + ) + response_text = ( + getattr( + response, + "text", + "", + ) + or "" + ).strip() + + searchable_text = ( + f"{error}\n{response_text}" + ).lower() + + token_markers = ( + "token", + "context length", + "context window", + "maximum context", + "max_tokens", + "too many tokens", + "prompt is too long", + ) + + if any( + marker in searchable_text + for marker in token_markers + ): + return ( + "Token/context limit exceeded or max_tokens is too large " + "for the remaining context." + ) + + if status_code == 400: + if response_text: + return ( + "Provider rejected the memory summarizer request " + "(400 Bad Request); see provider message below." + ) + + return ( + "Provider rejected the memory summarizer request " + "(400 Bad Request) without an error body; exact cause is unknown. " + "Common causes are token/context limit mismatch or an invalid payload." + ) + + if status_code: + return ( + "Provider rejected the memory summarizer request " + f"with HTTP {status_code}." + ) + + error_text = str( + error + ).strip() + + if error_text: + return error_text + + return ( + "Memory summarizer failed before returning a result." + ) + + +def build_memory_failure_details( + *, + stage: str, + error: Exception, + traceback_text: str, +) -> str: + + request = getattr( + error, + "request", + None, + ) + request_url = str( + getattr( + request, + "url", + "", + ) + or config.SERVICE_API_BASE + ) + + return ( + "Likely reason: " + f"{infer_memory_failure_reason(error)}" + "\n\nClient error details:\n" + f"{format_client_error(stage, request_url, config.SERVICE_MODEL_UID, error)}" + "\n\nTraceback:\n" + f"{traceback_text}" + ) + +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, + **kwargs, +): + + if call is None: + return + + with contextlib.suppress(Exception): + await call( + *args, + **kwargs, + ) + + +def get_memory_log_level( + label: str, +) -> str: + + return ( + label + .split( + " ", + 1, + )[0] + .upper() + ) + + +async def log_memory_event( + context, + *, + level: str, + message: str, + details: str | None = None, + fallback_channel: str = "runtime", + event: str | None = None, +) -> None: + + logger = getattr( + context, + "logger", + None, + ) + + log_memory = getattr( + logger, + "log_memory", + None, + ) + + if log_memory is not None: + await safe_call( + log_memory, + level, + message, + details=details, + event=event, + ) + return + + fallback = getattr( + logger, + f"log_{fallback_channel}", + None, + ) + formatted_message = ( + f"[MEMORY:{level}] {message}" + ) + + if ( + details is not None + and fallback_channel in { + "error", + "summarizer", + } + ): + await safe_call( + fallback, + formatted_message, + details=details, + ) + return + + await safe_call( + fallback, + formatted_message, + ) + +def extract_runtime_memory_text( + response: dict, +) -> str: + + text = ( + ResponseExtractor.extract_content_text( + response + ) + or ResponseExtractor.extract_reasoning_text( + response + ) + ) + + return text.strip() + + +def is_runtime_memory_response_truncated( + response: dict, +) -> bool: + + finish_reason = ( + ResponseExtractor + .extract_finish_reason( + response + ) + .lower() + ) + + return finish_reason in ( + "length", + "max_tokens", + ) + + +def looks_like_incomplete_runtime_memory( + text: str, +) -> bool: + + stripped = ( + text + or "" + ).strip() + + if not stripped: + return True + + if stripped[-1] in ( + ",", + ":", + "(", + "[", + "{", + ): + return True + + pairs = ( + ( + "(", + ")", + ), + ( + "[", + "]", + ), + ( + "{", + "}", + ), + ) + + return any( + stripped.count(open_char) + > stripped.count(close_char) + for open_char, close_char + in pairs + ) + + +async def refresh_runtime_memory_summarizer_usage( + context, + *, + system_prompt: str, + user_prompt: str, + response: dict | None = None, + context_window: int | None = None, +) -> None: + + if context is None: + return + + emitter = getattr( + context, + "emitter", + None, + ) + + if getattr( + emitter, + "emit", + None, + ) is None: + return + + usage = ( + ResponseExtractor.extract_usage( + response + ) + if isinstance( + response, + dict, + ) + else None + ) + + if ( + response is not None + and not usage + ): + return + + context_tokens = ( + usage.get( + "prompt_tokens", + 0, + ) + if usage + else estimate_runtime_tokens( + system_prompt=system_prompt, + user_input=user_prompt, + ) + ) + + total_tokens = ( + usage.get( + "total_tokens", + 0, + ) + if usage + else context_tokens + ) + + if not context_tokens: + return + + await refresh_runtime_state( + context, + runtime_id=( + RUNTIME_MEMORY_SUMMARIZER_RUNTIME_ID + ), + used_tokens=( + total_tokens + or context_tokens + ), + context_tokens=context_tokens, + total_tokens=( + total_tokens + or context_tokens + ), + max_tokens=( + context_window + or config.SERVICE_CONTEXT_WINDOW + ), + last_error=None, + status="online", + ) + + +def coerce_positive_int( + value, +) -> int: + + try: + number = int( + value + ) + except ( + TypeError, + ValueError, + ): + return 0 + + return max( + 0, + number, + ) + + +def runtime_usage_is_context_overloaded( + runtime: dict | None, +) -> bool: + + if not isinstance( + runtime, + dict, + ): + return False + + max_tokens = coerce_positive_int( + runtime.get( + "max_tokens" + ) + ) + + if not max_tokens: + return False + + used_tokens = max( + coerce_positive_int( + runtime.get( + "context_tokens" + ) + ), + coerce_positive_int( + runtime.get( + "total_tokens" + ) + ), + coerce_positive_int( + runtime.get( + "used_tokens" + ) + ), + ) + + return used_tokens > max_tokens + + +def latest_turn_context_is_overloaded( + context, +) -> bool: + + explicit_value = getattr( + context, + "runtime_last_turn_context_overloaded", + None, + ) + + if explicit_value is not None: + return bool( + explicit_value + ) + + runtime_id = ( + config.SERVICE_MODEL_UID + if config.USE_SERVICE_AS_BRAIN + else config.BRAIN_MODEL_UID + ) + + runtime = ( + runtime_state + .get_all_runtime_states() + .get( + runtime_id + ) + ) + + return runtime_usage_is_context_overloaded( + runtime + ) + + +def runtime_prompt_is_context_overloaded( + *, + system_prompt: str, + user_prompt: str, + context_window: int | None, +) -> bool: + + resolved_context_window = coerce_positive_int( + context_window + ) + + if not resolved_context_window: + return False + + prompt_tokens = estimate_runtime_tokens( + system_prompt=system_prompt, + user_input=user_prompt, + ) + + return prompt_tokens > resolved_context_window + + +def build_runtime_summarizer_payload( + *, + service_client, + system_prompt: str, + user_prompt: str, + temperature: float, + max_tokens: int, +) -> dict: + + return { + "model": getattr( + service_client, + "model_uid", + "<service>", + ), + "messages": [ + { + "role": "system", + "content": system_prompt, + }, + { + "role": "user", + "content": user_prompt, + }, + ], + "temperature": temperature, + "max_tokens": max_tokens, + "stream": False, + } + + +async def log_runtime_summarizer_payload( + context, + *, + label: str, + payload: dict, +) -> None: + + await log_memory_event( + context, + level=get_memory_log_level( + label + ), + message=f"{label} summarizer request", + details=json.dumps( + payload, + ensure_ascii=False, + indent=2, + ), + fallback_channel="summarizer", + event="summarizer_request", + ) + + +async def log_runtime_summarizer_result( + context, + *, + label: str, + result: str, +) -> None: + + await log_memory_event( + context, + level=get_memory_log_level( + label + ), + message=f"{label} summarizer result", + details=( + result.strip() + or "<empty>" + ), + fallback_channel="summarizer", + event="summarizer_result", + ) + +def build_memory_update_skip_details( + *, + reason: str, + previous_memory: str, + candidate_memory: str, +) -> str: + + return ( + f"{reason}\n\n" + "Previous memory:\n" + "----------------\n" + f"{previous_memory.strip() or DEFAULT_RUNTIME_MEMORY}\n\n" + "Candidate memory:\n" + "-----------------\n" + f"{candidate_memory.strip() or '<empty>'}" + ) diff --git a/runtime/memory_events.py b/runtime/memory_events.py new file mode 100644 index 00000000..b22ee4e3 --- /dev/null +++ b/runtime/memory_events.py @@ -0,0 +1,420 @@ +from runtime.memory_common import ( + safe_call, +) +from runtime.L1_memory_utils import ( + build_runtime_memory_context_text, +) + + +def _build_runtime_memory_snapshot(context, memory: str) -> dict: + from runtime.L1_memory import ( + build_runtime_memory_snapshot, + ) + + return build_runtime_memory_snapshot( + context, + memory, + ) + + +def _parse_runtime_memory_lines(memory: str) -> list[dict]: + from runtime.L1_memory import ( + parse_runtime_memory_lines, + ) + + return parse_runtime_memory_lines( + memory + ) + + +def _apply_runtime_memory_diff( + lines: list[dict], + previous_snapshot: dict | None, +) -> list[dict]: + from runtime.L1_memory import ( + apply_runtime_memory_diff, + ) + + return apply_runtime_memory_diff( + lines, + previous_snapshot, + ) + + +def _build_runtime_memory_patch( + lines: list[dict], + previous_snapshot: dict | None, +) -> dict: + from runtime.L1_memory import ( + build_runtime_memory_patch, + ) + + return build_runtime_memory_patch( + lines, + previous_snapshot, + ) + + +def _build_strength_map(lines: list[dict]) -> dict[str, float]: + from runtime.L1_memory import ( + build_strength_map, + ) + + return build_strength_map( + lines + ) + + +def _get_strength_zones(lines: list[dict]) -> dict: + from runtime.L1_memory import ( + get_strength_zones, + ) + + return get_strength_zones( + lines + ) + + +def _average_diff(values: list[float]) -> float: + from runtime.L2_memory import ( + average_diff, + ) + + return average_diff( + values + ) + + +def _diff_value_range(values: list[float]) -> float: + from runtime.L2_memory import ( + diff_value_range, + ) + + return diff_value_range( + values + ) + + +async def emit_runtime_memory_update( + context, +) -> dict: + + emitter = getattr( + context, + "emitter", + None, + ) + + memory = getattr(context, "runtime_memory", "") + display_memory = build_runtime_memory_context_text( + memory, + context, + ) + + if not hasattr( + context, + "runtime_memory_snapshots", + ): + context.runtime_memory_snapshots = [] + + snapshot = _build_runtime_memory_snapshot(context, memory) + + context.runtime_memory_snapshots.append(snapshot) + context.runtime_memory_snapshot_index = snapshot["index"] + + emit = getattr( + emitter, + "emit", + None, + ) + + await safe_call( + emit, + { + "type": "runtime_memory_update", + "memory": display_memory, + "updates": getattr(context, "runtime_memory_updates", 0), + "snapshot": snapshot, + "snapshots_count": len(context.runtime_memory_snapshots), + "snapshot_index": context.runtime_memory_snapshot_index, + }, + ) + + return snapshot + + +def build_runtime_l1_diff_stats( + diff_history: list[dict], +) -> dict: + + values = [ + item.get( + "total_diff", + 0, + ) + for item in diff_history + ] + + return { + "count": len(values), + "average": _average_diff(values), + "range": _diff_value_range(values), + "min": min(values) if values else 0, + "max": max(values) if values else 0, + } + + + + +def rebuild_latest_runtime_memory_snapshot( + context, +) -> dict | None: + + snapshots = getattr( + context, + "runtime_memory_snapshots", + [], + ) + + if not snapshots: + return None + + latest_snapshot = snapshots[-1] + previous_snapshot = ( + snapshots[-2] + if len(snapshots) > 1 + else None + ) + + display_memory = build_runtime_memory_context_text( + getattr( + context, + "runtime_memory", + "", + ), + context, + ) + + lines = _parse_runtime_memory_lines( + display_memory + ) + + lines = _apply_runtime_memory_diff( + lines, + previous_snapshot, + ) + + patch_details = _build_runtime_memory_patch( + lines, + previous_snapshot, + ) + + refreshed_snapshot = { + **latest_snapshot, + "raw_memory": display_memory, + "lines": lines, + "patch": patch_details["patch"], + "total_diff": patch_details["total_diff"], + } + + snapshots[-1] = refreshed_snapshot + context.runtime_memory_snapshots = snapshots + context.runtime_memory_snapshot_index = refreshed_snapshot["index"] + + return refreshed_snapshot + + +async def emit_runtime_memory_snapshot_refresh( + context, + snapshot: dict | None, +) -> None: + + if snapshot is None: + return + + emitter = getattr( + context, + "emitter", + None, + ) + + emit = getattr( + emitter, + "emit", + None, + ) + + await safe_call( + emit, + { + "type": "runtime_memory_update", + "memory": snapshot.get( + "raw_memory", + "", + ), + "updates": getattr( + context, + "runtime_memory_updates", + 0, + ), + "snapshot": snapshot, + "snapshots_count": len( + getattr( + context, + "runtime_memory_snapshots", + [], + ) + or [] + ), + "snapshot_index": getattr( + context, + "runtime_memory_snapshot_index", + snapshot.get( + "index", + 0, + ), + ), + "replace_latest": True, + }, + ) + +async def emit_runtime_l1_diff_update( + context, +) -> None: + + emitter = getattr( + context, + "emitter", + None, + ) + + emit = getattr( + emitter, + "emit", + None, + ) + + history = list( + getattr( + context, + "runtime_l1_diff_history", + [], + ) + or [] + ) + + snapshots = list( + getattr( + context, + "runtime_memory_snapshots", + [], + ) + or [] + ) + latest_lines = ( + snapshots[-1].get("lines", []) + if snapshots + else [] + ) + + await safe_call( + emit, + { + "type": "runtime_l1_diff_update", + "diffs": history, + "stats": build_runtime_l1_diff_stats( + history + ), + "strength_map": _build_strength_map( + latest_lines + ), + "strength_zones": _get_strength_zones( + latest_lines + ), + }, + ) + + +async def emit_runtime_session_memory_update( + context, + *, + persist_browser: bool = False, +) -> None: + + emitter = getattr( + context, + "emitter", + None, + ) + + emit = getattr( + emitter, + "emit", + None, + ) + + memory = getattr( + context, + "runtime_l3_session_memory", + "", + ) or getattr( + context, + "session_memory", + "", + ) + event_snapshots = list( + getattr( + context, + "runtime_session_event_snapshots", + [], + ) + or [] + ) + + await safe_call( + emit, + { + "type": "runtime_session_memory_update", + "memory": memory, + "event_snapshots": event_snapshots, + "updates": getattr( + context, + "runtime_session_memory_updates", + 0, + ), + "source": getattr( + context, + "session_memory_source", + "", + ), + "persist": persist_browser, + }, + ) + + +async def emit_runtime_action_completed( + context, + *, + action: str, +) -> None: + + emitter = getattr( + context, + "emitter", + None, + ) + + emit = getattr( + emitter, + "emit", + None, + ) + + await safe_call( + emit, + { + "type": "runtime_action", + "action": action, + "status": "completed", + }, + ) diff --git a/runtime/stream.py b/runtime/stream.py index b34b0349..ee1c5996 100644 --- a/runtime/stream.py +++ b/runtime/stream.py @@ -34,6 +34,7 @@ def __init__( log_method, enable_validator: bool = True, emit_to_chat: bool = True, + emit_content_to_chat: bool | None = None, context_snapshot: dict | None = None, ): @@ -50,6 +51,11 @@ def __init__( self.log_method = log_method self.emit_to_chat = emit_to_chat + self.emit_content_to_chat = ( + emit_to_chat + if emit_content_to_chat is None + else emit_content_to_chat + ) self.context_snapshot = context_snapshot or {} self.stream = StreamHandler( @@ -373,7 +379,10 @@ async def run( "content", "", ), - emit=self.emit_to_chat, + emit=( + self.emit_to_chat + and self.emit_content_to_chat + ), ) ) diff --git a/tests/test_agent_routing.py b/tests/test_agent_routing.py index f6d67303..00f64c98 100644 --- a/tests/test_agent_routing.py +++ b/tests/test_agent_routing.py @@ -1,4 +1,5 @@ import unittest +from unittest.mock import patch from agent import ( AgentState, @@ -19,10 +20,17 @@ async def test_cyrillic_input_routes_directly_to_brain(self): user_input="привет" ) - await PlannerNode().run( - state, - context=None, - ) + with patch( + "agent.nodes.planner.config.TRANSLATION_ENABLED", + False, + ), patch( + "agent.nodes.planner.config.TRANSLATE_RESPONSE", + False, + ): + await PlannerNode().run( + state, + context=None, + ) self.assertFalse( state.translate_input @@ -47,10 +55,17 @@ async def test_non_cyrillic_input_routes_directly_to_brain(self): user_input="hello" ) - await PlannerNode().run( - state, - context=None, - ) + with patch( + "agent.nodes.planner.config.TRANSLATION_ENABLED", + True, + ), patch( + "agent.nodes.planner.config.TRANSLATE_RESPONSE", + True, + ): + await PlannerNode().run( + state, + context=None, + ) self.assertFalse( state.translate_input diff --git a/tests/test_behavior_contract.py b/tests/test_behavior_contract.py index 4b5ee2e3..4fcab02b 100644 --- a/tests/test_behavior_contract.py +++ b/tests/test_behavior_contract.py @@ -4,6 +4,7 @@ from app import app from clients.brain_client import ( + is_user_initiated_remember_event, should_execute_remember_session, ) from runtime.behavior_contract import ( @@ -82,6 +83,56 @@ def test_remember_session_blockers_include_known_phrases(self): blockers, ) + def test_remember_event_guard_exists(self): + + guard = get_action_guard( + "remember_event" + ) + + self.assertEqual( + guard["runtime_action"], + "REMEMBER_EVENT", + ) + self.assertEqual( + guard["private_marker"], + "<INTERNAL_ACTION_REMEMBER_EVENT>", + ) + self.assertTrue( + guard["effects"]["save_event"], + ) + + def test_remember_event_triggers_include_known_phrases(self): + + triggers = get_action_guard_triggers( + "remember_event" + ) + + for phrase in ( + "хочу это запомнить", + "запомни это", + "важный момент", + ): + self.assertIn( + phrase, + triggers, + ) + + def test_is_user_initiated_remember_event_matches_save_request(self): + + self.assertTrue( + is_user_initiated_remember_event( + "это важный момент, запомни это" + ) + ) + + def test_is_user_initiated_remember_event_ignores_normal_message(self): + + self.assertFalse( + is_user_initiated_remember_event( + "интересное решение, продолжаем" + ) + ) + def test_should_execute_remember_session_matches_bedtime(self): self.assertTrue( @@ -114,6 +165,30 @@ def test_should_execute_remember_session_ignores_normal_message(self): ) ) + def test_should_execute_remember_session_ignores_event_save_request(self): + + self.assertFalse( + should_execute_remember_session( + "хорошо, тогда сохрани это как нашу новую аксиому" + ) + ) + + def test_should_execute_remember_session_ignores_generic_save_this(self): + + self.assertFalse( + should_execute_remember_session( + "сохрани это" + ) + ) + + def test_should_execute_remember_session_ignores_bare_save_command(self): + + self.assertFalse( + should_execute_remember_session( + "сохрани" + ) + ) + def test_should_execute_action_guard_normalizes_yo(self): self.assertTrue( diff --git a/tests/test_behavior_probe_movie_closure_sample.py b/tests/test_behavior_probe_movie_closure.py similarity index 99% rename from tests/test_behavior_probe_movie_closure_sample.py rename to tests/test_behavior_probe_movie_closure.py index acab4a47..a790a51a 100644 --- a/tests/test_behavior_probe_movie_closure_sample.py +++ b/tests/test_behavior_probe_movie_closure.py @@ -84,7 +84,6 @@ USER_TEXT_3 = "The Lighthouse я уже смотрел. я думаю можно дропнуть этот топик, я уже скачиваю High Life для просмотра." EXPECTED_TEXT_ANSWER_3 = [ - "High Life", ] EXPECTED_TEXT_MEMORY_3 = [ "High Life", diff --git a/tests/test_behavior_probe_recall_word.py b/tests/test_behavior_probe_recall_word.py index 234ced0a..8c63613d 100644 --- a/tests/test_behavior_probe_recall_word.py +++ b/tests/test_behavior_probe_recall_word.py @@ -249,17 +249,27 @@ def extract_recall_word_from_first_answer(answer: str) -> str: ) fallback_separator_pattern = re.compile(r"[::\-—–]") - for raw_line in render_text(answer).splitlines(): + # Prefer explicit word markers anywhere in the answer. Do not let an + # earlier decorative dash, for example "... слова — это ...", win over + # a later direct marker like "я загадал слово: **Облако**". + lines = render_text(answer).splitlines() + + for raw_line in lines: marker_match = word_marker_pattern.search(raw_line) - if marker_match: - candidate_text = marker_match.group(1) - else: - fallback_match = fallback_separator_pattern.search(raw_line) - if not fallback_match: - continue - candidate_text = raw_line[fallback_match.end():] + if not marker_match: + continue + + cleaned = strip_markdown_and_emoji(marker_match.group(1)) + tokens = re.findall(r"[0-9A-Za-zА-Яа-яЁё-]+", cleaned) + if tokens: + return tokens[0].strip("-") - cleaned = strip_markdown_and_emoji(candidate_text) + for raw_line in lines: + fallback_match = fallback_separator_pattern.search(raw_line) + if not fallback_match: + continue + + cleaned = strip_markdown_and_emoji(raw_line[fallback_match.end():]) tokens = re.findall(r"[0-9A-Za-zА-Яа-яЁё-]+", cleaned) if tokens: return tokens[0].strip("-") @@ -294,6 +304,19 @@ def answer_has_recall_question( if "?" not in text or "слово" not in text: return False + # Some valid recall prompts are split across two short questions, e.g.: + # "Помнишь, мы запоминали секретное слово? Какое оно было?" + # A plain split("?") loses the connection between "слово" and the + # anaphoric follow-up "какое оно". Detect that shape before checking + # single-question fragments. + anaphoric_recall_patterns = ( + r"\bслово\b[^?]{0,80}\?\s*(?:а\s+теперь[, ]*)?(?:како[ей]|как|что)\b[^?]{0,80}\b(?:оно|это|было|наш|секретн\w*)\b", + r"\bсекретн\w+\s+слово\b[^?]{0,80}\?\s*(?:како[ей]|как|что)\b[^?]{0,80}\b(?:оно|это|было|наш|секретн\w*)\b", + ) + + if any(re.search(pattern, text) for pattern in anaphoric_recall_patterns): + return True + question_parts = [ part.strip() for part in text.split("?") @@ -301,22 +324,30 @@ def answer_has_recall_question( ] direct_recall_patterns = ( - r"\bкакое\s+(?:же\s+)?слово\b", - r"\bкакое\s+.*?\bслово\b", + r"\bкако[ей]\s+(?:же\s+)?слово\b", + r"\bкако[ей]\s+.*?\bслово\b", r"\bчто\s+за\s+слово\b", r"\bназови\s+(?:мне\s+)?(?:то\s+)?слово\b", - r"\bвспомни\s+(?:то\s+)?слово\b", + r"\bвспомни\w*\s+(?:мне\s+)?(?:то\s+)?(?:секретн\w+\s+)?слово\b", r"\bугадай\s+(?:то\s+)?слово\b", - r"\bнапомни\s+(?:мне\s+)?(?:то\s+)?слово\b", - r"\bпомнишь\s*,?\s*какое\s+.*?\bслово\b", + r"\bнапомни\s+(?:мне\s*,?\s*)?(?:пожалуйста\s*,?\s*)?(?:то\s+)?слово\b", + r"\bпомнишь\s*,?\s*како[ей]\s+.*?\bслово\b", r"(?:^|[.!?]\s*|\bкстати,\s*)помнишь\s+(?:то\s+)?слово\b", r"\bпришло\s*,?\s*время\s+.*?\bслово\b", r"\bпора\s*,?\s*назвать\s+.*?\bслово\b", - r"\bкакое\s*,?\s*было\s+.*?\bслово\b", + r"\bкако[ей]\s*,?\s*было\s+.*?\bслово\b", + ) + + conditional_recall_trigger_pattern = ( + r"\b(?:како[ей]|что\s+за|назови|вспомни|угадай|напомни|пришло|пора)\b" ) for part in question_parts: - if "если ты помнишь" in part: + # Reject passive conditional mentions like + # "... если ты помнишь слово ...", but allow the common shape + # "если ты помнишь... какой же ... слово?" where the condition is + # only a lead-in to a direct recall question. + if "если ты помнишь" in part and not re.search(conditional_recall_trigger_pattern, part): continue if any(re.search(pattern, part) for pattern in direct_recall_patterns): @@ -708,6 +739,13 @@ def test_extract_recall_word_accepts_dash_separator(self): "Солнце", ) + def test_extract_recall_word_prefers_explicit_marker_over_earlier_dash(self): + answer = ( + "Привет! Отличная идея для игры. Загадывать слова — это всегда интересно.\n\n" + "Итак, я загадал слово: **Облако**. ☁️" + ) + self.assertEqual(extract_recall_word_from_first_answer(answer), "Облако") + def test_answer_has_recall_question_requires_direct_recall_trigger(self): self.assertTrue( answer_has_recall_question("А теперь вопрос: какое слово я загадал?") @@ -721,9 +759,24 @@ def test_answer_has_recall_question_requires_direct_recall_trigger(self): self.assertTrue( answer_has_recall_question("Помнишь, какое было слово?") ) + self.assertTrue( + answer_has_recall_question("Помнишь, мы запоминали секретное слово? Какое оно было?") + ) + self.assertTrue( + answer_has_recall_question("Помнишь слово, которое мы запомнили? Как оно?") + ) + self.assertTrue( + answer_has_recall_question("А теперь, если ты помнишь... какой же наш секретный слово?") + ) self.assertTrue( answer_has_recall_question("Помнишь то слово, которое ты хотел(а) вспомнить?") ) + self.assertTrue( + answer_has_recall_question("Кстати, напомни мне, пожалуйста, то слово, которое мы запоминали?") + ) + self.assertTrue( + answer_has_recall_question("Не мог бы ты вспомнить то секретное слово, которое мы запоминали?") + ) self.assertTrue( answer_has_recall_question("Кстати, наше слово было Книга.", "Книга") ) diff --git a/tests/test_brain_runtime_actions.py b/tests/test_brain_runtime_actions.py index ed86374a..93d20164 100644 --- a/tests/test_brain_runtime_actions.py +++ b/tests/test_brain_runtime_actions.py @@ -331,7 +331,6 @@ def test_agent_runtime_action_flags_enable_search_and_remember_session(self): def test_prompt_and_runtime_context_expose_only_private_action_markers(self): runtime_actions = { - "CAN_DEEP_THOUGHT": True, "CAN_WEB_SEARCH": True, "CAN_REMEMBER_SESSION": True, "CAN_REMEMBER_EVENT": True, @@ -363,7 +362,6 @@ def test_prompt_and_runtime_context_expose_only_private_action_markers(self): ) for private_marker in ( - "<INTERNAL_ACTION_DEEP_THOUGHT>", "<INTERNAL_ACTION_REMEMBER_SESSION>", "<INTERNAL_ACTION_REMEMBER_EVENT>", "<INTERNAL_ACTION_WEB_SEARCH:plain text query>", @@ -484,12 +482,13 @@ def test_prompt_can_flip_agent_actions_dynamically(self): prompt, ) - self.assertIn( - ( - '<ACTION name="DEEP_THOUGHT">' - "<INTERNAL_ACTION_DEEP_THOUGHT>" - "</ACTION>" - ), + self.assertNotIn( + "<INTERNAL_ACTION_DEEP_THOUGHT>", + prompt, + ) + + self.assertNotIn( + '<ACTION name="DEEP_THOUGHT">', prompt, ) diff --git a/tests/test_message_memory.py b/tests/test_message_memory.py index 2c95ef33..c468b196 100644 --- a/tests/test_message_memory.py +++ b/tests/test_message_memory.py @@ -24,16 +24,26 @@ schedule_runtime_memory_update, summarize_runtime_memory, ) -from runtime.memory import ( +from runtime.L1_memory import ( ask_runtime_memory_model, build_runtime_memory_snapshot, - build_l3_session_memory_max_tokens, collapse_duplicate_runtime_memory_keys, - L3_OUTPUT_MAX_TOKENS, + get_strength_zones, parse_runtime_memory_lines, summarize_runtime_memory_pending_turns, ) -from runtime.memory_rules import ( +from runtime.L3_memory import ( + build_l3_session_memory_max_tokens, +) +from runtime.L3_memory_rules import ( + L3_OUTPUT_MAX_TOKENS, +) +from runtime.L2_memory_utils import ( + merge_runtime_l2_pattern_evidence_memory, + normalize_l2_pattern_evidence_example, + remove_runtime_l2_pattern_evidence_lines, +) +from runtime.L1_memory_rules import ( RUNTIME_MEMORY_CONTEXT_OVERLOAD_RULES, ) from runtime.registry import ( @@ -190,24 +200,60 @@ def test_runtime_memory_user_prompt_uses_session_fallback(self): current_memory="", user_message="hello", assistant_message="hi", - current_l2_memory=( - "possible pattern: repeated greeting loop; Occurrences: 2" - ), ) self.assertIn( DEFAULT_RUNTIME_MEMORY, prompt, ) - self.assertIn( - "Current L2 pattern memory for occurrence tracking only", + self.assertNotIn( + "Current L2 pattern memory", prompt, ) - self.assertIn( + self.assertNotIn( "Occurrences: 2", prompt, ) + def test_runtime_memory_user_prompt_uses_filtered_hot_traces(self): + + prompt = build_runtime_memory_user_prompt( + current_memory="user_message: hello", + user_message="hello", + assistant_message="hi", + strength_zones=get_strength_zones([ + { + "key": "user_message", + "strength": 0.9, + }, + { + "key": "user_idle", + "strength": 0.9, + }, + ]), + ) + + self.assertIn( + "hot_traces: user_message", + prompt, + ) + self.assertNotIn( + "user_idle", + prompt, + ) + self.assertNotIn( + "Memory traces (pheromone strength)", + prompt, + ) + self.assertNotIn( + "Crystallized (stable facts)", + prompt, + ) + self.assertNotIn( + "Fading (deprioritize)", + prompt, + ) + def test_first_brain_prompt_includes_default_runtime_memory(self): context = RuntimeContext( @@ -468,6 +514,30 @@ def test_runtime_memory_prompt_focuses_on_summary_depth(self): "Do not store bare ambiguous values like memory token: <value>", prompt, ) + self.assertIn( + "Do not write the current turn, turn_number, or user_message_count into ordinary L1 memory lines such as session status", + prompt, + ) + self.assertIn( + "Trusted runtime context already carries those counters", + prompt, + ) + self.assertIn( + "L2_pattern_evidence_N lines are owned by L2 and are immutable for L1", + prompt, + ) + self.assertIn( + "L1 MUST create or update a separate companion key using this exact shape", + prompt, + ) + self.assertIn( + "L2_pattern_evidence_N_status: status: <resolved|cancelled|corrected|test>; reason: <short reason>", + prompt, + ) + self.assertIn( + "L2_pattern_evidence_1_status: status: resolved; reason: identified as a test", + prompt, + ) self.assertIn( "include the user's label/synonym", prompt, @@ -628,10 +698,22 @@ def test_runtime_l2_memory_prompt_defines_pattern_layer(self): "Occurrences: N", prompt, ) + self.assertIn( + "first_seen_snapshot", + prompt, + ) self.assertIn( "last_seen_snapshot", prompt, ) + self.assertIn( + "L2_pattern_evidence_N", + prompt, + ) + self.assertIn( + "first_seen_turn_snapshot", + prompt, + ) self.assertIn( "evidence summary", prompt, @@ -712,6 +794,81 @@ def test_runtime_l2_memory_prompt_defines_pattern_layer(self): "Occurrences must be derived only from actual conversation evidence", prompt, ) + self.assertIn( + "Count occurrences by unique patch snapshot values", + prompt, + ) + self.assertIn( + "Do not create a brand-new pattern when all matching evidence is confined to one unique patch snapshot", + prompt, + ) + self.assertIn( + "final token on the line MUST be the closing bracket of [ occurrences: N ]", + prompt, + ) + self.assertIn( + "Never append status, notes, explanations, conclusions, punctuation, or any other text after the final [ occurrences: N ] bracket", + prompt, + ) + + def test_l2_pattern_evidence_merge_preserves_first_seen_and_deduplicates(self): + + merged = merge_runtime_l2_pattern_evidence_memory( + previous_memory=( + "possible pattern: old line. Occurrences: 1; " + "first_seen_snapshot: 5; last_seen_snapshot: 5; " + "evidence summary: banana question; confidence: medium\n" + 'L2_pattern_evidence_1: user repeatedly sending message - "что такое бананы" ' + "[ first_seen_turn_snapshot: 5 ] [ last_seen_turn_snapshot: 5 ] [ occurrences: 1 ]" + ), + candidate_memory=( + "possible pattern: updated line. Occurrences: 2; " + "first_seen_snapshot: 5; last_seen_snapshot: 10; " + "evidence summary: banana question; confidence: medium\n" + 'L2_pattern_evidence_2: user repeatedly sending message - "что такое бананы" ' + "[ last_seen_turn_snapshot: 10 ] [ occurrences: 2 ]" + ), + ) + + self.assertIn( + "possible pattern: updated line", + merged, + ) + self.assertEqual( + 1, + merged.count( + "L2_pattern_evidence_" + ), + ) + self.assertIn( + 'L2_pattern_evidence_1: user repeatedly sending message - "что такое бананы" ' + "[ first_seen_turn_snapshot: 5 ] [ last_seen_turn_snapshot: 10 ] [ occurrences: 2 ]", + merged, + ) + + def test_l2_candidate_evidence_lines_are_removed_before_deterministic_merge(self): + + cleaned = remove_runtime_l2_pattern_evidence_lines( + "possible pattern: repeated message. Occurrences: 4\n" + 'L2_pattern_evidence_1: user repeatedly sending one message [ quote: "ping" ] ' + "[ first_seen_turn_snapshot: 9 ] [ last_seen_turn_snapshot: 10 ] [ occurrences: 4 ]\n" + "scope: current session" + ) + + self.assertEqual( + "possible pattern: repeated message. Occurrences: 4\n" + "scope: current session", + cleaned, + ) + + def test_l2_pattern_evidence_example_normalizer_strips_spaces_commas_and_dots(self): + + self.assertEqual( + "чтотакоебананы", + normalize_l2_pattern_evidence_example( + " Что, такое. бананы " + ), + ) def test_interrupted_assistant_message_marks_incomplete(self): @@ -769,6 +926,72 @@ def test_brain_prompt_includes_runtime_memory(self): prompt, ) + def test_brain_prompt_includes_countdown_contract_rules_while_active(self): + + context = SimpleNamespace( + runtime_memory=( + 'stored_memory: "Sky" (purpose: recall test; status: pending)\n' + 'countdown_contract: Recall the secret word "Sky"; ' + "created_user_message_count: 1; count_from: 1; count_to: 4; " + "due_user_message_count: 4; current: 2; remaining: 2; " + "status: active; trigger: Ask the user to recall the secret word." + ), + runtime_turn_user_message="thanks", + deep_thought_count=0, + runtime_search_result="", + runtime_search_result_id="", + ) + + prompt = build_brain_system_prompt( + context=context, + runtime_actions={ + "CAN_WEB_SEARCH": False, + "CAN_DEEP_THOUGHT": False, + }, + ) + + self.assertIn( + "Runtime countdown_contract is active.", + prompt, + ) + self.assertIn( + "do not execute the trigger early", + prompt, + ) + self.assertIn( + "If countdown_contract status is due or remaining is 0", + prompt, + ) + self.assertNotIn( + "stored_memory is a high-priority active recall contract", + prompt, + ) + + def test_brain_prompt_omits_countdown_contract_rules_without_contract(self): + + context = SimpleNamespace( + runtime_memory=( + "last_jin_response: Answered a casual thanks." + ), + runtime_turn_user_message="thanks", + deep_thought_count=0, + runtime_search_result="", + runtime_search_result_id="", + ) + + prompt = build_brain_system_prompt( + context=context, + runtime_actions={ + "CAN_WEB_SEARCH": False, + "CAN_DEEP_THOUGHT": False, + }, + ) + + self.assertNotIn( + "Runtime countdown_contract is active.", + prompt, + ) + def test_brain_prompt_anchors_short_feedback_to_last_jin_response(self): context = SimpleNamespace( @@ -1338,10 +1561,6 @@ async def emit(event): service_client.calls[0]["system_prompt"], build_runtime_memory_system_prompt(), ) - self.assertIn( - "[MEMORY:L1] L1 runtime memory updated", - logger.service_logs, - ) self.assertEqual( logger.summarizer_logs[0][0], "[MEMORY:L1] L1 summarizer request", @@ -1682,6 +1901,89 @@ async def test_l2_memory_waits_for_repeated_patch_keys(self): "", ) + async def test_l2_memory_drops_model_evidence_from_one_unique_snapshot(self): + + service_client = FakeServiceClient( + "possible pattern: user may be repeating one message. " + "Occurrences: 4; first_seen_snapshot: 9; last_seen_snapshot: 10; " + "evidence summary: duplicate rows for one snapshot; confidence: medium\n" + 'L2_pattern_evidence_1: user repeatedly sending one message [ quote: "ping" ] ' + "[ first_seen_turn_snapshot: 9 ] [ last_seen_turn_snapshot: 10 ] [ occurrences: 4 ]" + ) + logger = FakeLogger() + context = SimpleNamespace( + clients={ + "service": service_client, + }, + logger=logger, + runtime_memory="", + runtime_memory_snapshots=[], + runtime_memory_snapshot_index=0, + runtime_l2_memory="", + runtime_l2_pending_patches=[ + { + "turn_number": index + 1, + "snapshot_index": index + 1, + "total_diff": 120, + "changes": { + "added": [ + { + "key": "topic", + "value": f"value {index}", + }, + ], + }, + } + for index in range(L2_PATCH_WINDOW - 1) + ] + [ + { + "turn_number": 10, + "snapshot_index": 10, + "total_diff": 140, + "user_message": "ping", + "user_messages": [ + "ping", + ], + "changes": { + "added": [ + { + "key": "topic", + "value": "value final", + }, + { + "key": "user_message", + "value": "ping", + }, + ], + }, + }, + ], + runtime_l1_diff_history=[], + runtime_l2_last_turn=0, + user_message_count=L2_PATCH_WINDOW, + ) + + updated_memory = await maybe_summarize_runtime_l2_memory( + context=context, + ) + + self.assertNotIn( + "possible pattern", + updated_memory, + ) + self.assertNotIn( + "L2_pattern_evidence_", + updated_memory, + ) + self.assertEqual( + "", + updated_memory, + ) + self.assertNotIn( + "[ occurrences: 4 ]", + updated_memory, + ) + async def test_l2_memory_runs_after_repeated_patch_keys_even_with_noisy_diff(self): service_client = FakeServiceClient( diff --git a/tests/test_runtime_actions.py b/tests/test_runtime_actions.py index 73420f6b..eb05662c 100644 --- a/tests/test_runtime_actions.py +++ b/tests/test_runtime_actions.py @@ -612,6 +612,11 @@ def test_remember_session_guard_intents(self): "\u0437\u0430\u0431\u0443\u0434\u044c \u043f\u0440\u043e\u0448\u043b\u043e\u0435, \u0441\u043c\u0435\u043d\u0438\u043c \u0442\u0435\u043c\u0443" ) ) + self.assertFalse( + should_execute_remember_session( + "\u0445\u043e\u0440\u043e\u0448\u043e, \u044f \u0441\u043e\u0445\u0440\u0430\u043d\u0438\u043b, \u0441\u043f\u0430\u0441\u0438\u0431\u043e" + ) + ) def test_apply_runtime_action_calls_saves_session_event_snapshot(self): @@ -658,6 +663,10 @@ class Context: context.runtime_session_event_snapshots[0]["source"], "runtime_action", ) + self.assertEqual( + context.runtime_session_event_snapshots[0]["initiated_by"], + "user", + ) self.assertIn( "A memorable answer.", context.runtime_session_event_snapshots[0]["assistant_response"], @@ -667,6 +676,36 @@ class Context: "runtime_session_memory_update", ) + def test_apply_runtime_action_calls_marks_auto_session_event_as_jin_initiated(self): + + class Context: + pass + + context = Context() + context.runtime_session_event_snapshots = [] + context.runtime_turn_user_message = "let's keep implementing this" + context.runtime_turn_assistant_response = "A high-signal correction." + + applied_count = asyncio.run( + apply_runtime_action_calls( + context, + ( + RuntimeActionCall( + name="REMEMBER_EVENT", + ), + ), + ) + ) + + self.assertEqual( + applied_count, + 1, + ) + self.assertEqual( + context.runtime_session_event_snapshots[0]["initiated_by"], + "jin", + ) + def test_extract_search_query_unnests_json_string(self): self.assertEqual( diff --git a/tests/test_websocket_pending_usage.py b/tests/test_websocket_pending_usage.py index f5327a05..2685a568 100644 --- a/tests/test_websocket_pending_usage.py +++ b/tests/test_websocket_pending_usage.py @@ -14,6 +14,7 @@ ) from websocket import ( apply_session_bootstrap, + arm_remember_session_from_user_text, reject_when_all_models_offline, refresh_pending_brain_usage, wait_for_runtime_memory_update, @@ -140,6 +141,39 @@ async def send_json( class WebSocketPendingUsageTests(unittest.IsolatedAsyncioTestCase): + async def test_arm_remember_session_emits_runtime_action(self): + + context = SimpleNamespace( + emitter=FakeEmitter(), + logger=FakeLogger(), + runtime_remember_session_requested=False, + ) + + armed = await arm_remember_session_from_user_text( + context, + "\u0441\u043e\u0445\u0440\u0430\u043d\u0438 \u0441\u0435\u0441\u0441\u0438\u044e", + ) + + self.assertTrue( + armed, + ) + self.assertTrue( + context.runtime_remember_session_requested, + ) + self.assertTrue( + context.runtime_remember_session_action_emitted, + ) + self.assertEqual( + context.emitter.events, + [ + { + "type": "runtime_action", + "action": "remember_session", + "text": "Remembering this session", + }, + ], + ) + async def test_rejects_user_request_when_all_models_are_offline(self): http_client = FakeStatusHttpClient( diff --git a/ui/static/js/chat.js b/ui/static/js/chat.js index d39e9879..f513ea10 100644 --- a/ui/static/js/chat.js +++ b/ui/static/js/chat.js @@ -10,6 +10,7 @@ const STREAM_FRAME_WARNING_MS = 12; const STREAM_NEAR_BOTTOM_PX = 72; let streamFrameScheduled = false; +const deferredRuntimeActionsAfterResponse = []; /** * @typedef {Object} ContextSnapshot @@ -489,6 +490,10 @@ function appendChatMessage( pre.innerHTML = escapeHtml(text); + flushRuntimeActionsAfterResponse( + role + ); + } @@ -512,7 +517,7 @@ function appendRuntimeAction( document.createElement("div"); row.className = - "mx-auto flex w-full max-w-4xl items-center gap-3 text-xs text-cyan-100 transition duration-500"; + "jin-message-row jin-runtime-action-row mx-auto w-full max-w-4xl text-xs text-cyan-100 transition duration-500"; row.dataset.runtimeAction = action || ""; @@ -555,10 +560,84 @@ function appendRuntimeAction( } +function queueRuntimeActionAfterNextResponse( + action, + text +) { + + const actionText = + String( + text || "" + ); + + if (!actionText.trim()) { + return; + } + + deferredRuntimeActionsAfterResponse.push({ + action: + action || "", + text: actionText, + completed: false, + }); + +} + + +function isResponseRole( + role +) { + + return ![ + "user", + "system", + ].includes( + String(role || "").toLowerCase() + ); + +} + + +function flushRuntimeActionsAfterResponse( + role +) { + + if ( + !isResponseRole(role) + || !deferredRuntimeActionsAfterResponse.length + ) { + return; + } + + const actions = + deferredRuntimeActionsAfterResponse.splice(0); + + actions.forEach((entry) => { + appendRuntimeAction( + entry.action, + entry.text + ); + + if (entry.completed) { + fadeRuntimeAction( + entry.action + ); + } + }); + +} + + function fadeRuntimeAction( action ) { + deferredRuntimeActionsAfterResponse.forEach((entry) => { + if (entry.action === action) { + entry.completed = true; + } + }); + const rows = chatHistory.querySelectorAll( `[data-runtime-action="${action}"]` @@ -784,6 +863,38 @@ function startStreamMessage( // THINKING CHUNK +function stripInternalActionMarkers( + text +) { + + return String(text || "") + .replace( + /(^|\n)[^\S\r\n]*<INTERNAL_ACTION_(?:DEEP_THOUGHT|REMEMBER_SESSION|REMEMBER_EVENT)>[^\S\r\n]*(?=\n|$)/gi, + "$1" + ) + .replace( + /(^|\n)[^\S\r\n]*<INTERNAL_ACTION_WEB_SEARCH:[^>\n]*>[^\S\r\n]*(?=\n|$)/gi, + "$1" + ) + .replace( + /\n{3,}/g, + "\n\n" + ); + +} + +function collapseAnswerMarkerGap( + text +) { + + return String(text || "") + .replace( + /\n{3,}/g, + "\n\n" + ); + +} + function appendThinkingChunk( messageId, chunk @@ -846,9 +957,35 @@ function appendStreamChunk( return; } + chunk = + stripInternalActionMarkers( + chunk + ); + + if (!stream.answer.trim()) { + chunk = + chunk.replace( + /^\s+/, + "" + ); + } + + if (!chunk) { + return; + } + stream.answer += chunk; stream.pendingAnswer += chunk; + stream.answer = + collapseAnswerMarkerGap( + stream.answer + ); + stream.pendingAnswer = + collapseAnswerMarkerGap( + stream.pendingAnswer + ); + scheduleStreamFrameUpdate(); } @@ -892,6 +1029,12 @@ function finishStreamMessage( stream.group.wrapper.remove(); } + if (stream.answer.trim()) { + flushRuntimeActionsAfterResponse( + stream.role + ); + } + } streamMessages.delete( @@ -907,6 +1050,9 @@ window.appendChatMessage = window.appendRuntimeAction = appendRuntimeAction; +window.queueRuntimeActionAfterNextResponse = + queueRuntimeActionAfterNextResponse; + window.fadeRuntimeAction = fadeRuntimeAction; diff --git a/ui/static/js/logger.js b/ui/static/js/logger.js index 13a92659..557e5805 100644 --- a/ui/static/js/logger.js +++ b/ui/static/js/logger.js @@ -335,6 +335,55 @@ function moveLogToBottomWithFlip( ); } +function dismissLogAfterClear( + logDiv, +) { + + window.setTimeout( + function () { + const height = + logDiv.offsetHeight; + + logDiv.style.maxHeight = + `${height}px`; + + logDiv.style.overflow = + "hidden"; + + logDiv.style.transition = + "opacity 160ms ease, transform 160ms ease, max-height 180ms ease, margin 180ms ease, padding 180ms ease"; + + requestAnimationFrame( + function () { + logDiv.style.opacity = + "0"; + logDiv.style.transform = + "translateY(-4px)"; + logDiv.style.maxHeight = + "0"; + logDiv.style.marginTop = + "0"; + logDiv.style.marginBottom = + "0"; + logDiv.style.paddingTop = + "0"; + logDiv.style.paddingBottom = + "0"; + } + ); + + window.setTimeout( + function () { + logDiv.remove(); + }, + 190 + ); + }, + 450 + ); + +} + function appendLog( tag, message, @@ -439,6 +488,21 @@ function appendLog( ); } + if (tag.includes("LATEST SNAPSHOTS")) { + tagClass = + "text-cyan-300 font-bold"; + + logDiv.classList.add( + "font-mono", + "text-[12px]", + "bg-cyan-500/5", + "p-2", + "rounded", + "border", + "border-cyan-500/10", + ); + } + if (tag.includes("AFTER")) { tagClass = "text-purple-500"; @@ -536,6 +600,9 @@ function appendLog( const isSession = tag.includes("SESSION"); + const isLatestSnapshots = + tag.includes("LATEST SNAPSHOTS"); + const isUser = tag.includes("USER"); @@ -580,14 +647,14 @@ function appendLog( traceButton.className = isSummarizer ? "mt-2 inline-flex items-center rounded border border-blue-500/20 px-2 py-1 text-[10px] uppercase tracking-wider text-blue-300 hover:bg-blue-500/10 transition" - : isSession + : isSession || isLatestSnapshots ? "inline-flex items-center rounded border border-cyan-500/20 px-2 py-1 text-[10px] uppercase tracking-wider text-cyan-300 hover:bg-cyan-500/10 transition" : "mt-2 inline-flex items-center rounded border border-red-500/20 px-2 py-1 text-[10px] uppercase tracking-wider text-red-300 hover:bg-red-500/10 transition"; traceButton.textContent = isPatternResult ? "patterns" - : isSession + : isSession || isLatestSnapshots ? "show" : isSummarizer ? "payload" @@ -602,6 +669,8 @@ function appendLog( normalized.details, isPatternResult ? "L2 pattern memory" + : isLatestSnapshots + ? "Latest snapshots" : isSession ? "Session bootstrap" : isSummarizer @@ -616,7 +685,10 @@ function appendLog( traceButton ); - if (isSession) { + if ( + isSession + || isLatestSnapshots + ) { const clearButton = document.createElement("button"); @@ -632,7 +704,12 @@ function appendLog( clearButton.addEventListener( "click", function () { - if (window.clearPersistedSessionBootstrap) { + if ( + isLatestSnapshots + && window.clearOtherLatestRuntimeMemorySnapshots + ) { + window.clearOtherLatestRuntimeMemorySnapshots(); + } else if (window.clearPersistedSessionBootstrap) { window.clearPersistedSessionBootstrap(); } @@ -641,6 +718,9 @@ function appendLog( clearButton.textContent = "cleared"; traceButton.disabled = true; traceButton.classList.add("opacity-40"); + dismissLogAfterClear( + logDiv + ); } ); @@ -678,11 +758,57 @@ window.showTrace = const consolePanel = document.getElementById("console-panel"); const consoleDragHandle = document.getElementById("console-drag-handle"); + function syncSceneShadeToPanelCollapse() { + const root = + document.querySelector("main"); + + if (!root) { + return; + } + + const collapsedCount = + [ + consolePanel, + memoryPanel, + ].filter((panel) => ( + panel + && panel.classList.contains("panel-collapsed") + )).length; + + root.classList.remove( + "panels-collapsed-1", + "panels-collapsed-2" + ); + + if (collapsedCount > 0) { + root.classList.add( + `panels-collapsed-${collapsedCount}` + ); + } + } + + function togglePanelCollapseFromHeader(event, panel, handle) { + if ( + event.target !== handle + || !panel + ) { + return; + } + + event.preventDefault(); + panel.classList.toggle("panel-collapsed"); + syncSceneShadeToPanelCollapse(); + } + let isConsoleDragging = false; let consoleOffsetX = 0; let consoleOffsetY = 0; consoleDragHandle.addEventListener("mousedown", (event) => { + if (event.detail > 1) { + return; + } + isConsoleDragging = true; const rect = consolePanel.getBoundingClientRect(); @@ -720,6 +846,14 @@ const consolePanel = document.getElementById("console-panel"); document.body.style.userSelect = ""; }); + consoleDragHandle.addEventListener("dblclick", (event) => { + togglePanelCollapseFromHeader( + event, + consolePanel, + consoleDragHandle + ); + }); + @@ -733,6 +867,10 @@ let memoryOffsetX = 0; let memoryOffsetY = 0; memoryDragHandle.addEventListener("mousedown", (event) => { + if (event.detail > 1) { + return; + } + isMemoryDragging = true; const rect = memoryPanel.getBoundingClientRect(); @@ -780,3 +918,11 @@ window.addEventListener("mouseup", () => { isMemoryDragging = false; document.body.style.userSelect = ""; }); + +memoryDragHandle.addEventListener("dblclick", (event) => { + togglePanelCollapseFromHeader( + event, + memoryPanel, + memoryDragHandle + ); +}); diff --git a/ui/static/js/runtime/runtime-core.js b/ui/static/js/runtime/runtime-core.js new file mode 100644 index 00000000..198bfd26 --- /dev/null +++ b/ui/static/js/runtime/runtime-core.js @@ -0,0 +1 @@ +window.JinRuntime = window.JinRuntime || {}; diff --git a/ui/static/js/runtime/runtime-feedback.js b/ui/static/js/runtime/runtime-feedback.js new file mode 100644 index 00000000..b4cbe4a2 --- /dev/null +++ b/ui/static/js/runtime/runtime-feedback.js @@ -0,0 +1,623 @@ +(function () { + "use strict"; + + const root = window.JinRuntime = window.JinRuntime || {}; + + const runtimeResponseFeedbackKey = + "JIN_LAST_RESPONSE_USER_FEEDBACK"; + + const runtimeResponseFeedbackDislikedValue = + "User disliked your last response. " + + "Before answering, find and understand why it failed using context or memory, then start the next reply with a brief acknowledgement of that miss, then continue with a concrete corrected answer."; + + const runtimeResponseFeedbackNeutralValue = + "User gave neutral feedback to your last response. " + + "Continue carefully without changing course too much."; + + const runtimeResponseFeedbackLikedValue = + "User liked your last response. " + + "Keep the current direction."; + + const runtimeResponseFeedbackRatings = { + disliked: "disliked", + neutral: "neutral", + liked: "liked", + }; + + // UI buttons can still use visual button names. Convert them only at the + // browser event boundary. Runtime memory and server payloads stay canonical: + // disliked / neutral / liked. + const runtimeResponseFeedbackButtonRatings = { + minus: "disliked", + zero: "neutral", + plus: "liked", + }; + + let deps = null; + let pendingRuntimeResponseFeedback = null; + let runtimeResponseFeedbackCommitted = false; + + const jinAnswerRatingL1Gate = { + generation: 0, + waiting: false, + waitingGeneration: 0, + readyGeneration: 0, + baselineUpdates: 0, + // All gate generations strictly below this value are permanently locked: + // the user has already submitted a subsequent message, so retroactive + // ratings for those turns must be silently dropped. + lockedBelowGeneration: 0, + }; + + function ensureDeps() { + if (!deps) { + throw new Error( + "JinRuntime.feedback.init must be called before using feedback" + ); + } + return deps; + } + + function init(nextDeps) { + deps = nextDeps || {}; + return api; + } + + function getLatestRuntimeMemoryUpdatesForRatingGate() { + const runtimeDeps = ensureDeps(); + + if (typeof runtimeDeps.getLatestRuntimeMemoryUpdates === "function") { + return Number( + runtimeDeps.getLatestRuntimeMemoryUpdates() || 0 + ); + } + + const snapshots = typeof runtimeDeps.getSnapshots === "function" + ? runtimeDeps.getSnapshots() + : []; + + const latestSnapshot = snapshots.length + ? snapshots[snapshots.length - 1] + : null; + + if (latestSnapshot && latestSnapshot.restored_from_session_save) { + return 0; + } + + return Number( + ( + latestSnapshot + && latestSnapshot.runtime_memory_updates + ) + || ( + typeof runtimeDeps.getRuntimeMemoryCountText === "function" + ? runtimeDeps.getRuntimeMemoryCountText() + : 0 + ) + || 0 + ); + } + + function markL1ReadyFromRuntimeUpdate( + data, + snapshotIndex = null + ) { + if (!jinAnswerRatingL1Gate.waiting) { + return; + } + + const incomingUpdates = Number( + data && data.updates || 0 + ); + + if (incomingUpdates <= jinAnswerRatingL1Gate.baselineUpdates) { + return; + } + + jinAnswerRatingL1Gate.waiting = false; + jinAnswerRatingL1Gate.readyGeneration = + jinAnswerRatingL1Gate.waitingGeneration; + runtimeResponseFeedbackCommitted = false; + + const rawSnapshotIndex = + snapshotIndex === undefined + ? null + : snapshotIndex; + + const numericSnapshotIndex = Number( + rawSnapshotIndex + ); + + const resolvedSnapshotIndex = ( + rawSnapshotIndex !== null + && rawSnapshotIndex !== "" + && Number.isInteger(numericSnapshotIndex) + ) + ? numericSnapshotIndex + : null; + + document + .querySelectorAll( + ".jin-chat-bubble-service[data-rating-gate-generation]" + ) + .forEach((bubble) => { + if ( + Number(bubble.dataset.ratingGateGeneration || 0) + === jinAnswerRatingL1Gate.readyGeneration + ) { + bubble.dataset.ratingL1Ready = "true"; + + if (resolvedSnapshotIndex !== null) { + bubble.dataset.runtimeSnapshotIndex = + String(resolvedSnapshotIndex); + } + + bubble.classList.remove( + "jin-rating-l1-waiting" + ); + } + }); + + window.dispatchEvent( + new CustomEvent( + "jin:l1-rating-gate-ready", + { + detail: { + generation: jinAnswerRatingL1Gate.readyGeneration, + updates: incomingUpdates, + snapshotIndex: resolvedSnapshotIndex, + }, + } + ) + ); + } + + function startL1GateForTurn() { + jinAnswerRatingL1Gate.generation += 1; + jinAnswerRatingL1Gate.waiting = true; + jinAnswerRatingL1Gate.waitingGeneration = + jinAnswerRatingL1Gate.generation; + jinAnswerRatingL1Gate.baselineUpdates = + getLatestRuntimeMemoryUpdatesForRatingGate(); + + // Hard-lock every bubble that belongs to a generation older than the one + // we are about to start. This is the authoritative guard: the user has + // just sent a new message, so rating any previous assistant turn is no + // longer valid regardless of the committed/waiting state of the feedback + // flags. + jinAnswerRatingL1Gate.lockedBelowGeneration = + jinAnswerRatingL1Gate.generation; + + if (typeof document !== "undefined") { + document + .querySelectorAll( + ".jin-chat-bubble-service[data-rating-gate-generation]" + ) + .forEach((bubble) => { + const bubbleGen = Number( + bubble.dataset.ratingGateGeneration || 0 + ); + if (bubbleGen < jinAnswerRatingL1Gate.lockedBelowGeneration) { + bubble.classList.remove("jin-rating-selected-active"); + bubble.classList.add("jin-rating-committed"); + bubble.dataset.ratingCommitted = "true"; + bubble.dataset.ratingPastTurn = "true"; + } + }); + } + + return { + generation: jinAnswerRatingL1Gate.waitingGeneration, + baselineUpdates: jinAnswerRatingL1Gate.baselineUpdates, + }; + } + + function getL1GateState() { + return { + ...jinAnswerRatingL1Gate, + }; + } + + function isReadyForGateGeneration(generation) { + const gateGeneration = Number(generation || 0); + + if (!gateGeneration) { + return !jinAnswerRatingL1Gate.waiting; + } + + return gateGeneration === jinAnswerRatingL1Gate.readyGeneration; + } + + function normalizeRating(rating) { + const rawRating = + String(rating || "").trim().toLowerCase(); + + return ( + runtimeResponseFeedbackRatings[rawRating] + || runtimeResponseFeedbackButtonRatings[rawRating] + || null + ); + } + + function buildValue(feedback) { + if (feedback.rating === "disliked") { + return runtimeResponseFeedbackDislikedValue; + } + + if (feedback.rating === "liked") { + return runtimeResponseFeedbackLikedValue; + } + + return runtimeResponseFeedbackNeutralValue; + } + + // In-place rating mutation: rating clicks are part of the current L1 page, + // not a new runtime memory page. + function getCurrentSnapshotForMutation(feedback = null) { + const runtimeDeps = ensureDeps(); + const snapshots = typeof runtimeDeps.getSnapshots === "function" + ? runtimeDeps.getSnapshots() + : []; + + if (!snapshots.length) { + return null; + } + + const rawSnapshotIndex = feedback + ? ( + feedback.runtimeSnapshotIndex + ?? feedback.snapshotIndex + ?? null + ) + : null; + + const explicitSnapshotIndex = Number( + rawSnapshotIndex + ); + + if ( + rawSnapshotIndex !== null + && rawSnapshotIndex !== "" + && Number.isInteger(explicitSnapshotIndex) + ) { + const explicitSnapshot = snapshots[explicitSnapshotIndex]; + + if (explicitSnapshot) { + return explicitSnapshot; + } + } + + const currentIndex = typeof runtimeDeps.getCurrentIndex === "function" + ? runtimeDeps.getCurrentIndex() + : -1; + const current = snapshots[currentIndex]; + const displayMode = typeof runtimeDeps.getDisplayMode === "function" + ? runtimeDeps.getDisplayMode() + : "runtime"; + + if (current && displayMode === "runtime") { + return current; + } + + return snapshots[snapshots.length - 1]; + } + + function getLineIdentity(line) { + const key = + String(line && line.key || "") + .trim() + .toLowerCase(); + + const value = + String(line && line.value || "") + .trim(); + + return `${key}\u0000${value}`; + } + + function buildPreviousLineMaps(snapshot) { + const byIdentity = new Map(); + const byKey = new Map(); + + (snapshot && snapshot.lines || []) + .forEach((line) => { + if (!line || !line.key) { + return; + } + + const key = + String(line.key || "") + .trim() + .toLowerCase(); + + if (!key) { + return; + } + + byKey.set(key, line); + byIdentity.set( + getLineIdentity(line), + line + ); + }); + + return { + byIdentity, + byKey, + }; + } + + function preserveLineDiff( + parsed, + previousMaps + ) { + if (!parsed || !parsed.key) { + return parsed; + } + + const key = + String(parsed.key || "") + .trim() + .toLowerCase(); + + if (key === runtimeResponseFeedbackKey.toLowerCase()) { + return { + ...parsed, + status: "changed", + key_status: "same", + value_status: "changed", + value_change_ratio: 1, + strength: 1, + }; + } + + const previousExact = previousMaps.byIdentity.get( + getLineIdentity(parsed) + ); + + if (previousExact) { + return { + ...previousExact, + key: parsed.key, + value: parsed.value, + }; + } + + const previousByKey = previousMaps.byKey.get(key); + + if (previousByKey) { + return { + ...parsed, + status: previousByKey.status || parsed.status, + key_status: previousByKey.key_status || parsed.key_status, + value_status: previousByKey.value_status || parsed.value_status, + key_change_ratio: + previousByKey.key_change_ratio + ?? parsed.key_change_ratio, + value_change_ratio: + previousByKey.value_change_ratio + ?? parsed.value_change_ratio, + strength: + previousByKey.strength + ?? parsed.strength, + }; + } + + return parsed; + } + + function rebuildSnapshotLines( + snapshot, + runtimeMemory + ) { + const runtimeDeps = ensureDeps(); + const memoryModel = runtimeDeps.memoryModel || {}; + const previousMaps = + buildPreviousLineMaps(snapshot); + + return memoryModel.splitMemoryTextLines(runtimeMemory) + .map((line) => ( + preserveLineDiff( + memoryModel.parseRuntimeMemoryLine(line), + previousMaps + ) + )); + } + + function applyToCurrentSnapshot(feedback) { + const runtimeDeps = ensureDeps(); + const memoryModel = runtimeDeps.memoryModel || {}; + const snapshot = + getCurrentSnapshotForMutation( + feedback + ); + + if (!snapshot) { + return null; + } + + const currentMemory = + String(snapshot.raw_memory || "").trim(); + + if (!currentMemory) { + return null; + } + + const cleanedMemory = + memoryModel.removeRuntimeMemoryLineByKey( + currentMemory, + runtimeResponseFeedbackKey + ); + + const nextMemory = + feedback && feedback.rating + ? memoryModel.upsertRuntimeMemoryLine( + cleanedMemory, + runtimeResponseFeedbackKey, + buildValue(feedback) + ) + : cleanedMemory; + + snapshot.raw_memory = nextMemory; + snapshot.lines = rebuildSnapshotLines( + snapshot, + nextMemory + ); + snapshot.client_feedback = ( + feedback && feedback.rating + ) + ? feedback + : null; + snapshot.local_feedback_mutation = Boolean( + feedback && feedback.rating + ); + + if (typeof runtimeDeps.setDisplayMode === "function") { + runtimeDeps.setDisplayMode("runtime"); + } + + const snapshots = typeof runtimeDeps.getSnapshots === "function" + ? runtimeDeps.getSnapshots() + : []; + let nextIndex = snapshots.indexOf(snapshot); + + if (nextIndex < 0) { + nextIndex = snapshots.length - 1; + } + + if (typeof runtimeDeps.setCurrentIndex === "function") { + runtimeDeps.setCurrentIndex(nextIndex); + } + + // Feedback is a transient one-turn alert for the next JIN response. + // Keep it visible in the current UI snapshot, but do not save it as + // stable runtime memory and do not persist it to localStorage. + if (typeof runtimeDeps.renderRuntimeMemorySnapshot === "function") { + runtimeDeps.renderRuntimeMemorySnapshot(); + } + + return snapshot; + } + + function recordCurrentResponse(detail) { + if (runtimeResponseFeedbackCommitted) { + return null; + } + + // Generation guard: reject ratings for bubbles that belong to a turn + // older than the one currently awaiting a response. The user already + // submitted a newer message, so mutating an earlier snapshot would + // inject stale feedback into the wrong memory slice. + const incomingGeneration = Number( + detail && detail.ratingGateGeneration || 0 + ); + if ( + incomingGeneration > 0 + && incomingGeneration < jinAnswerRatingL1Gate.lockedBelowGeneration + ) { + return null; + } + + const rating = + normalizeRating( + detail && detail.rating + ); + + if (!rating) { + return null; + } + + pendingRuntimeResponseFeedback = { + rating, + runtimeSnapshotIndex: + detail && detail.runtimeSnapshotIndex !== undefined + ? detail.runtimeSnapshotIndex + : null, + }; + + return applyToCurrentSnapshot( + pendingRuntimeResponseFeedback + ); + } + + function clearPendingRating(detail = null) { + if (runtimeResponseFeedbackCommitted) { + return null; + } + + pendingRuntimeResponseFeedback = null; + + return applyToCurrentSnapshot({ + runtimeSnapshotIndex: + detail && detail.runtimeSnapshotIndex !== undefined + ? detail.runtimeSnapshotIndex + : null, + }); + } + + function getPendingRating() { + return pendingRuntimeResponseFeedback + ? { ...pendingRuntimeResponseFeedback } + : null; + } + + function consumePendingLastResponseRating() { + const value = pendingRuntimeResponseFeedback + ? { ...pendingRuntimeResponseFeedback } + : null; + + pendingRuntimeResponseFeedback = null; + + if (value) { + runtimeResponseFeedbackCommitted = true; + } + + return value; + } + + const api = { + init, + key: runtimeResponseFeedbackKey, + setPendingRating: recordCurrentResponse, + recordCurrentResponse, + clearPendingRating, + getPendingRating, + consumePendingLastResponseRating, + markL1ReadyFromRuntimeUpdate, + startL1GateForTurn, + getL1GateState, + isReadyForGateGeneration, + }; + + root.feedback = api; + + // Legacy window API. Keep old names until socket.js/status.js/templates stop + // calling them directly. + window.startJinAnswerRatingL1GateForTurn = function () { + return api.startL1GateForTurn(); + }; + + window.getJinAnswerRatingL1GateState = function () { + return api.getL1GateState(); + }; + + window.isJinAnswerRatingReadyForGateGeneration = function (generation) { + return api.isReadyForGateGeneration(generation); + }; + + window.recordJinAnswerRating = function (detail) { + return api.recordCurrentResponse(detail); + }; + + window.clearJinAnswerRating = function (detail) { + return api.clearPendingRating(detail); + }; + + window.getJinAnswerRatingForRuntime = function () { + return api.getPendingRating(); + }; + + window.consumePendingLastResponseRating = function () { + return api.consumePendingLastResponseRating(); + }; +}()); diff --git a/ui/static/js/runtime/runtime-idle.js b/ui/static/js/runtime/runtime-idle.js new file mode 100644 index 00000000..62d4540c --- /dev/null +++ b/ui/static/js/runtime/runtime-idle.js @@ -0,0 +1,374 @@ +(function () { + + window.JinRuntime = window.JinRuntime || {}; + + const USER_IDLE_REFRESH_MS = 1000; + const USER_IDLE_TYPING_RESUME_DELAY_MS = 30000; + + let userIdleStartedAt = Date.now(); + let userIdleTimer = null; + let userIdlePausedAt = null; + let userIdleResumeTimer = null; + let userIdleInputFreezeInstalled = false; + + let onIdleTextChanged = null; + + function formatUserIdleDuration(ms) { + const totalSeconds = Math.max( + 0, + Math.floor(Number(ms || 0) / 1000) + ); + + if (totalSeconds < 60) { + return `${totalSeconds}s`; + } + + const totalMinutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + if (totalMinutes < 60) { + return seconds + ? `${totalMinutes}m ${seconds}s` + : `${totalMinutes}m`; + } + + const totalHours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + + if (totalHours < 24) { + return minutes + ? `${totalHours}h ${minutes}m` + : `${totalHours}h`; + } + + const days = Math.floor(totalHours / 24); + const hours = totalHours % 24; + + return hours + ? `${days}d ${hours}h` + : `${days}d`; + } + + + function getElapsedMs() { + const now = + userIdlePausedAt !== null + ? userIdlePausedAt + : Date.now(); + + return now - userIdleStartedAt; + } + + + function getText() { + return formatUserIdleDuration( + getElapsedMs() + ); + } + + + function emitIdleTextChanged() { + if (!onIdleTextChanged) { + return; + } + + onIdleTextChanged( + getText() + ); + } + + + function clearResumeTimer() { + if (!userIdleResumeTimer) { + return; + } + + clearTimeout( + userIdleResumeTimer + ); + + userIdleResumeTimer = null; + } + + + function stopTimer() { + if (!userIdleTimer) { + return; + } + + clearInterval( + userIdleTimer + ); + + userIdleTimer = null; + } + + + function ensureTimer() { + if (userIdlePausedAt !== null) { + return; + } + + if (userIdleTimer) { + return; + } + + userIdleTimer = setInterval( + emitIdleTextChanged, + USER_IDLE_REFRESH_MS + ); + } + + + function isChatInputFocused() { + const input = + document.getElementById( + "user-input" + ); + + return Boolean( + input + && document.activeElement === input + ); + } + + + function resume() { + if (userIdlePausedAt === null) { + ensureTimer(); + return; + } + + const frozenElapsed = + Math.max( + 0, + userIdlePausedAt - userIdleStartedAt + ); + + userIdleStartedAt = + Date.now() - frozenElapsed; + + userIdlePausedAt = null; + clearResumeTimer(); + emitIdleTextChanged(); + ensureTimer(); + } + + + function scheduleResumeIfChatLostFocus() { + clearResumeTimer(); + + if (userIdlePausedAt === null) { + return; + } + + userIdleResumeTimer = setTimeout( + function () { + userIdleResumeTimer = null; + + if ( + isChatInputFocused() + && document.hasFocus() + && !document.hidden + ) { + return; + } + + resume(); + }, + USER_IDLE_TYPING_RESUME_DELAY_MS + ); + } + + + function markTypingStarted() { + if (userIdlePausedAt === null) { + userIdlePausedAt = Date.now(); + } + + stopTimer(); + emitIdleTextChanged(); + scheduleResumeIfChatLostFocus(); + } + + + function markTypingStopped() { + scheduleResumeIfChatLostFocus(); + } + + + function resetForUserMessage() { + userIdleStartedAt = Date.now(); + userIdlePausedAt = null; + clearResumeTimer(); + emitIdleTextChanged(); + ensureTimer(); + } + + + function freezeAtMs( + elapsedMs + ) { + const now = Date.now(); + const frozenElapsedMs = Math.max( + 0, + Number(elapsedMs || 0) + ); + + userIdleStartedAt = now - frozenElapsedMs; + userIdlePausedAt = now; + clearResumeTimer(); + stopTimer(); + emitIdleTextChanged(); + } + + + function freezeAtSeconds( + elapsedSeconds + ) { + freezeAtMs( + Math.max( + 0, + Number(elapsedSeconds || 0) + ) * 1000 + ); + } + + + function freezeAtZero() { + freezeAtMs( + 0 + ); + } + + + function installInputFreeze() { + if (userIdleInputFreezeInstalled) { + return; + } + + const input = + document.getElementById( + "user-input" + ); + + if (!input) { + return; + } + + userIdleInputFreezeInstalled = true; + + input.addEventListener( + "input", + markTypingStarted + ); + + input.addEventListener( + "focus", + function () { + if (userIdlePausedAt !== null) { + clearResumeTimer(); + } + } + ); + + input.addEventListener( + "blur", + markTypingStopped + ); + + window.addEventListener( + "blur", + markTypingStopped + ); + + document.addEventListener( + "visibilitychange", + function () { + if (document.hidden) { + markTypingStopped(); + } + } + ); + } + + + function start() { + installInputFreeze(); + emitIdleTextChanged(); + ensureTimer(); + } + + + function stop() { + clearResumeTimer(); + stopTimer(); + } + + + function getContext() { + const elapsedMs = Math.max( + 0, + getElapsedMs() + ); + + return { + user_idle: formatUserIdleDuration( + elapsedMs + ), + user_idle_seconds: Math.floor( + elapsedMs / 1000 + ), + user_idle_paused: userIdlePausedAt !== null, + }; + } + + + function onSnapshotChanged() { + emitIdleTextChanged(); + } + + + function configure( + options = {} + ) { + onIdleTextChanged = + typeof options.onIdleTextChanged === "function" + ? options.onIdleTextChanged + : null; + + emitIdleTextChanged(); + } + + + const idle = { + configure, + start, + stop, + resetForUserMessage, + markTypingStarted, + markTypingStopped, + getContext, + onSnapshotChanged, + formatUserIdleDuration, + getText, + freezeAtMs, + freezeAtSeconds, + freezeAtZero, + }; + + window.JinRuntime.idle = idle; + + window.jinResetUserIdleTimer = + resetForUserMessage; + + window.jinFreezeUserIdleTimerAtZero = + freezeAtZero; + + window.jinFreezeUserIdleTimerAtSeconds = + freezeAtSeconds; + + window.getJinUserIdleContext = + getContext; + +}()); diff --git a/ui/static/js/runtime/runtime-memory-model.js b/ui/static/js/runtime/runtime-memory-model.js new file mode 100644 index 00000000..03058a06 --- /dev/null +++ b/ui/static/js/runtime/runtime-memory-model.js @@ -0,0 +1,396 @@ +(function () { + + window.JinRuntime = window.JinRuntime || {}; + + function splitMemoryTextLines(text) { + + return String(text || "") + .replace(/\\n/g, "\n") + .replace( + /;\s+(?=[a-z][a-z0-9_]*\s*:)/g, + "\n" + ) + .split(/\r?\n+/) + .map(line => line.trim()) + .filter(Boolean); + + } + + + function appendProperties( + value, + properties = [] + ) { + + const normalizedProperties = + (Array.isArray(properties) + ? properties + : [properties]) + .map(property => String(property || "").trim()) + .filter(Boolean) + .map((property) => { + if ( + property.startsWith("[") + && property.endsWith("]") + ) { + return property; + } + + return `[${property}]`; + }); + + return [ + String(value || "").trimEnd(), + ...normalizedProperties, + ].filter(Boolean).join(" "); + + } + + + function splitMemoryMeta(value) { + + const raw = + String(value || ""); + + const tags = []; + let text = raw; + + const tagRe = + /\s*\[\s*([\w.-]+)\s*:\s*([^\]]*)\]\s*$/; + + while (true) { + const match = + text.match(tagRe); + + if (!match) { + break; + } + + tags.unshift({ + key: match[1], + value: match[2].trim(), + raw: match[0].trim(), + }); + + text = + text.slice(0, match.index).trimEnd(); + } + + return { + text, + tags, + raw, + }; + + } + + + function memoryMetaHasTag( + parsed, + key + ) { + + const normalizedKey = + String(key || "").trim().toLowerCase(); + + return Boolean( + parsed + && Array.isArray(parsed.tags) + && parsed.tags.some( + tag => String(tag.key || "").toLowerCase() === normalizedKey + ) + ); + + } + + + function stripMemoryMetaForDisplay(value) { + + return splitMemoryMeta(value).text; + + } + + + function stripMemoryTextMetaForDisplay(text) { + + return splitMemoryTextLines(text) + .map(stripMemoryMetaForDisplay) + .join("\n"); + + } + + + function normalizeRuntimeMemoryKey(key) { + + return String(key || "") + .trim() + .replace(/\s+/g, "_") + .toLowerCase(); + + } + + + function isUserIdleRuntimeMemoryKey(key) { + + return normalizeRuntimeMemoryKey(key) === "user_idle"; + + } + + + function isUserIdleRuntimeMemoryLine(line) { + + if (!line) { + return false; + } + + if (typeof line === "object") { + return isUserIdleRuntimeMemoryKey(line.key); + } + + const separatorIndex = + String(line).indexOf(":"); + + if (separatorIndex <= 0) { + return false; + } + + return isUserIdleRuntimeMemoryKey( + String(line).slice(0, separatorIndex) + ); + + } + + + function stripUserIdleRuntimeMemoryText(text) { + + return splitMemoryTextLines(text) + .filter(line => !isUserIdleRuntimeMemoryLine(line)) + .join("\n"); + + } + + + function parseRuntimeMemoryLine(line) { + + const separatorIndex = + line.indexOf(":"); + + if (separatorIndex <= 0) { + return { + key: "session memory", + value: line, + status: "same", + key_status: "same", + value_status: "same", + key_change_ratio: 0, + value_change_ratio: 0, + }; + } + + return { + key: line.slice(0, separatorIndex).trim(), + value: line.slice(separatorIndex + 1).trim(), + status: "same", + key_status: "same", + value_status: "same", + key_change_ratio: 0, + value_change_ratio: 0, + }; + + } + + + function getUserIdleRuntimeMemoryLine(snapshot) { + + if ( + snapshot + && Array.isArray(snapshot.lines) + ) { + return snapshot.lines.find( + line => isUserIdleRuntimeMemoryLine(line) + ) || null; + } + + const rawLine = + splitMemoryTextLines( + snapshot && snapshot.raw_memory + ).find( + line => isUserIdleRuntimeMemoryLine(line) + ); + + return rawLine ? parseRuntimeMemoryLine(rawLine) : null; + + } + + + function setRuntimeMemorySnapshotUserIdle( + snapshot, + userIdleText + ) { + + if (!snapshot || typeof snapshot !== "object") { + return; + } + + const value = + String(userIdleText || "").trim(); + + if (!value) { + return; + } + + const rawLine = + `user_idle: ${value}`; + + const rawMemory = + String(snapshot.raw_memory || ""); + + if (rawMemory.trim()) { + const userIdleLinePattern = + /^(\s*user_idle\s*:).*$/im; + + snapshot.raw_memory = + userIdleLinePattern.test(rawMemory) + ? rawMemory.replace( + userIdleLinePattern, + rawLine + ) + : `${rawMemory.trimEnd()}\n${rawLine}`; + } else { + snapshot.raw_memory = rawLine; + } + + if (!Array.isArray(snapshot.lines)) { + return; + } + + let replaced = false; + + snapshot.lines = snapshot.lines.map((line) => { + if (!isUserIdleRuntimeMemoryLine(line)) { + return line; + } + + replaced = true; + + return { + ...line, + key: "user_idle", + value, + }; + }); + + if (!replaced) { + snapshot.lines.push( + parseRuntimeMemoryLine(rawLine) + ); + } + + } + + + function removeRuntimeMemoryLineByKey( + memory, + key + ) { + + const normalizedKey = + String(key || "").trim().toLowerCase(); + + return String(memory || "") + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean) + .filter((line) => { + const lineKey = + line.split(":", 1)[0].trim().toLowerCase(); + + return lineKey !== normalizedKey; + }) + .join("\n") + .trim(); + + } + + + function upsertRuntimeMemoryLine( + memory, + key, + value + ) { + + const cleanedMemory = + removeRuntimeMemoryLineByKey( + memory, + key + ); + + return ( + cleanedMemory + ? `${cleanedMemory}\n${key}: ${value}` + : `${key}: ${value}` + ).trim(); + + } + + + function formatRuntimeMemoryStrengthProperties(line) { + + const strength = + Number(line && line.strength); + + if (!Number.isFinite(strength)) { + return []; + } + + return [`trace: ${strength.toFixed(2)}`]; + + } + + + function buildRuntimeMemoryValuePresentation(line) { + + const value = + line && line.value || ""; + + const parsedValue = + splitMemoryMeta(value); + + const strengthProperties = + memoryMetaHasTag(parsedValue, "trace") + ? [] + : formatRuntimeMemoryStrengthProperties(line); + + const rawValue = + appendProperties( + value, + strengthProperties + ); + + return splitMemoryMeta(rawValue); + + } + + + window.JinRuntime.memoryModel = { + splitMemoryTextLines, + appendProperties, + splitMemoryMeta, + memoryMetaHasTag, + stripMemoryMetaForDisplay, + stripRuntimeMemoryMeta: stripMemoryMetaForDisplay, + stripMemoryTextMetaForDisplay, + normalizeRuntimeMemoryKey, + isUserIdleRuntimeMemoryKey, + isUserIdleRuntimeMemoryLine, + stripUserIdleRuntimeMemoryText, + parseRuntimeMemoryLine, + getUserIdleRuntimeMemoryLine, + setRuntimeMemorySnapshotUserIdle, + removeRuntimeMemoryLineByKey, + upsertRuntimeMemoryLine, + formatRuntimeMemoryStrengthProperties, + buildRuntimeMemoryValuePresentation, + }; + +}()); diff --git a/ui/static/js/runtime/runtime-memory-view.js b/ui/static/js/runtime/runtime-memory-view.js new file mode 100644 index 00000000..4793b819 --- /dev/null +++ b/ui/static/js/runtime/runtime-memory-view.js @@ -0,0 +1,733 @@ +(function () { + window.JinRuntime = window.JinRuntime || {}; + + let runtimeMemoryHistory = null; + let idle = null; + let memoryModel = null; + let initialized = false; + let userIdleValueNode = null; + + const pinnedRuntimeMemorySnapshotIndexes = new Set(); + + const runtimeDiffHistory = { + diffs: [], + stats: {}, + expanded: false, + }; + + const runtimeMemoryText = + document.getElementById("runtime-memory-text"); + + const runtimeMemoryTitle = + document.getElementById("runtime-memory-title"); + + const runtimeMemoryPosition = + document.getElementById("runtime-memory-position"); + + const runtimeMemoryPrev = + document.getElementById("runtime-memory-prev"); + + const runtimeMemoryNext = + document.getElementById("runtime-memory-next"); + + const runtimeDiffToggle = + document.getElementById("runtime-diff-toggle"); + + const runtimeDiffText = + document.getElementById("runtime-diff-text"); + + const runtimeDiffCount = + document.getElementById("runtime-diff-count"); + + const runtimeDiffAverage = + document.getElementById("runtime-diff-average"); + + const runtimeDiffRange = + document.getElementById("runtime-diff-range"); + + const runtimeDiffMax = + document.getElementById("runtime-diff-max"); + + function requireRuntimeMemoryHistory() { + if (!runtimeMemoryHistory) { + throw new Error( + "JinRuntime.memoryView.init() must be called before use" + ); + } + } + + function getUserIdleText() { + return idle ? idle.getText() : "0s"; + } + + function updateUserIdleTimerText( + text = getUserIdleText() + ) { + requireRuntimeMemoryHistory(); + + if (!userIdleValueNode) { + return; + } + + userIdleValueNode.textContent = + ` ${text}`; + + updateRuntimeMemoryTitleMetrics( + runtimeMemoryHistory.snapshots[ + runtimeMemoryHistory.index + ] + ); + } + + function freezeLatestRuntimeMemoryUserIdle(userIdleText) { + requireRuntimeMemoryHistory(); + + const latestSnapshot = + runtimeMemoryHistory.snapshots[ + runtimeMemoryHistory.snapshots.length - 1 + ]; + + memoryModel.setRuntimeMemorySnapshotUserIdle( + latestSnapshot, + userIdleText + ); + } + + function formatRuntimeDiffNumber(value) { + const number = + Number(value || 0); + + return String( + Number.isInteger(number) + ? number + : Number(number.toFixed(2)) + ); + } + + function setRuntimeDiffUpdate(data) { + runtimeDiffHistory.diffs = + data && data.diffs || []; + + runtimeDiffHistory.stats = + data && data.stats || {}; + + renderRuntimeDiffs(); + } + + function renderRuntimeDiffs() { + const stats = + runtimeDiffHistory.stats || {}; + + if (runtimeDiffCount) { + runtimeDiffCount.textContent = + formatRuntimeDiffNumber(stats.count); + } + + if (runtimeDiffAverage) { + runtimeDiffAverage.textContent = + formatRuntimeDiffNumber(stats.average); + } + + if (runtimeDiffRange) { + runtimeDiffRange.textContent = + formatRuntimeDiffNumber(stats.range); + } + + if (runtimeDiffMax) { + runtimeDiffMax.textContent = + formatRuntimeDiffNumber(stats.max); + } + + if (runtimeDiffToggle) { + runtimeDiffToggle.textContent = + runtimeDiffHistory.expanded + ? "hide diffs" + : "show diffs"; + } + + if (!runtimeDiffText) { + return; + } + + runtimeDiffText.classList.toggle( + "hidden", + !runtimeDiffHistory.expanded + ); + + runtimeDiffText.textContent = + runtimeDiffHistory.diffs.length + ? JSON.stringify( + runtimeDiffHistory.diffs, + null, + 2 + ) + : "[]"; + } + + function isCurrentRuntimeMemorySnapshotPinned() { + requireRuntimeMemoryHistory(); + + return pinnedRuntimeMemorySnapshotIndexes.has( + runtimeMemoryHistory.index + ); + } + + function updateRuntimeMemoryPinGlow() { + if (!runtimeMemoryPosition) { + return; + } + + runtimeMemoryPosition.classList.toggle( + "runtime-memory-position-pinned", + isCurrentRuntimeMemorySnapshotPinned() + ); + } + + function estimateRuntimeMemoryTokens(text) { + if (!text) { + return 0; + } + + return Math.max( + 1, + Math.ceil( + Array.from(text).length / 4 + ) + ); + } + + function getRuntimeMemorySnapshotMetricText(snapshot) { + if (!snapshot || typeof snapshot !== "object") { + return ""; + } + + const includeLiveUserIdle = + isLatestRuntimeMemorySnapshot(); + + const rawMemory = + String(snapshot.raw_memory || ""); + + if (rawMemory.trim()) { + const stableMemory = + includeLiveUserIdle + ? memoryModel.stripUserIdleRuntimeMemoryText(rawMemory) + : rawMemory; + + return [ + stableMemory.trim(), + includeLiveUserIdle + ? `user_idle: ${getUserIdleText()}` + : "", + ].filter(Boolean).join("\n"); + } + + if (!Array.isArray(snapshot.lines)) { + return ""; + } + + const lines = + snapshot.lines + .filter((line) => ( + !includeLiveUserIdle + || !memoryModel.isUserIdleRuntimeMemoryLine(line) + )) + .map((line) => { + const key = + line && line.key + ? String(line.key) + : "note"; + + const value = + line && line.value + ? String(line.value) + : ""; + + return `${key}: ${value}`; + }) + .filter(Boolean); + + if (includeLiveUserIdle) { + lines.push( + `user_idle: ${getUserIdleText()}` + ); + } + + return lines.join("\n").trim(); + } + + function updateRuntimeMemoryTitleMetrics(snapshot) { + if (!runtimeMemoryTitle) { + return; + } + + const metricText = + getRuntimeMemorySnapshotMetricText(snapshot); + + const charCount = + Array.from(metricText).length; + + const tokenCount = + estimateRuntimeMemoryTokens(metricText); + + runtimeMemoryTitle.title = + `${charCount} chars / ~${tokenCount} tokens`; + } + + function renderRuntimeMemorySnapshot() { + requireRuntimeMemoryHistory(); + + const snapshot = + runtimeMemoryHistory.snapshots[ + runtimeMemoryHistory.index + ]; + + if (!snapshot) { + if (runtimeMemoryText) { + runtimeMemoryText.textContent = ""; + } + + if (runtimeMemoryPosition) { + runtimeMemoryPosition.textContent = + "0"; + } + + updateRuntimeMemoryTitleMetrics(null); + updateRuntimeMemoryArrows(); + updateRuntimeMemoryPinGlow(); + return; + } + + renderRuntimeMemoryLines( + snapshot, + isCurrentRuntimeMemorySnapshotPinned() + ); + + if (runtimeMemoryPosition) { + runtimeMemoryPosition.textContent = + String( + typeof snapshot.index === "number" + ? snapshot.index + : runtimeMemoryHistory.index + 1 + ); + } + + updateRuntimeMemoryTitleMetrics(snapshot); + updateRuntimeMemoryArrows(); + updateRuntimeMemoryPinGlow(); + } + + function isLatestRuntimeMemorySnapshot() { + requireRuntimeMemoryHistory(); + + return ( + runtimeMemoryHistory.index >= + runtimeMemoryHistory.snapshots.length - 1 + ); + } + + function clampMemoryRatio(value) { + const number = + Number(value || 0); + + return Math.max( + 0, + Math.min(1, number) + ); + } + + function applyRuntimeMemoryFlash( + element, + status, + kind, + ratio, + persist = false + ) { + 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; + } + + if (persist) { + 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, persistGlow = false) { + if (!runtimeMemoryText) { + return; + } + + runtimeMemoryText.innerHTML = ""; + runtimeMemoryText.classList.toggle( + "runtime-memory-text-pinned", + persistGlow + ); + runtimeMemoryText.removeAttribute( + "title" + ); + + const showLiveUserIdle = + isLatestRuntimeMemorySnapshot(); + + const lines = + showLiveUserIdle + ? (snapshot.lines || []) + .filter(line => !memoryModel.isUserIdleRuntimeMemoryLine(line)) + : snapshot.lines || []; + + if (!lines.length) { + const rawMemory = + showLiveUserIdle + ? memoryModel.stripUserIdleRuntimeMemoryText(snapshot.raw_memory || "") + : snapshot.raw_memory || ""; + + runtimeMemoryText.textContent = + `${memoryModel.stripMemoryTextMetaForDisplay(rawMemory).trim()}\n`; + + if (rawMemory.trim()) { + runtimeMemoryText.title = + rawMemory.trim(); + } + + if (showLiveUserIdle) { + appendUserIdleRuntimeMemoryLine(); + } else { + userIdleValueNode = null; + } + + idle.start(); + + return; + } + + lines.forEach((line) => { + const row = + document.createElement("div"); + + row.className = + "runtime-memory-line"; + + const key = + line.key || "note"; + + const valuePresentation = + memoryModel.buildRuntimeMemoryValuePresentation(line); + + const fullRawLine = + `${key}: ${valuePresentation.raw}`; + + 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 = + ` ${valuePresentation.text}`; + + row.title = + fullRawLine; + valueSpan.title = + fullRawLine; + + row.appendChild(keySpan); + row.appendChild(valueSpan); + + runtimeMemoryText.appendChild(row); + + applyRuntimeMemoryFlash( + keySpan, + keyStatus, + "key", + line.key_change_ratio, + persistGlow + ); + + applyRuntimeMemoryFlash( + valueSpan, + valueStatus, + "value", + line.value_change_ratio, + persistGlow + ); + }); + + if (showLiveUserIdle) { + appendUserIdleRuntimeMemoryLine(); + } else { + userIdleValueNode = null; + } + + idle.start(); + } + + function appendUserIdleRuntimeMemoryLine() { + if (!runtimeMemoryText) { + return; + } + + const row = + document.createElement("div"); + + row.className = + "runtime-memory-line runtime-memory-user-idle"; + + const keySpan = + document.createElement("span"); + + keySpan.className = + "runtime-memory-key"; + + keySpan.textContent = + "user_idle:"; + + const valueSpan = + document.createElement("span"); + + valueSpan.className = + "runtime-memory-value"; + + userIdleValueNode = + valueSpan; + + row.appendChild(keySpan); + row.appendChild(valueSpan); + + runtimeMemoryText.appendChild(row); + idle.onSnapshotChanged(); + idle.start(); + } + + function updateRuntimeMemoryArrows() { + requireRuntimeMemoryHistory(); + + if (!runtimeMemoryPrev || !runtimeMemoryNext) { + return; + } + + 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); + } + + function bindRuntimeMemoryNavigation() { + if (initialized) { + return; + } + + runtimeMemoryPrev?.addEventListener("click", () => { + requireRuntimeMemoryHistory(); + + if (runtimeMemoryHistory.index <= 0) return; + + runtimeMemoryHistory.index -= 1; + renderRuntimeMemorySnapshot(); + }); + + runtimeMemoryNext?.addEventListener("click", () => { + requireRuntimeMemoryHistory(); + + if ( + runtimeMemoryHistory.index >= + runtimeMemoryHistory.snapshots.length - 1 + ) return; + + runtimeMemoryHistory.index += 1; + renderRuntimeMemorySnapshot(); + }); + + runtimeMemoryPosition?.addEventListener("click", () => { + requireRuntimeMemoryHistory(); + + if (runtimeMemoryHistory.index < 0) { + return; + } + + const wasPinned = + isCurrentRuntimeMemorySnapshotPinned(); + + if (wasPinned) { + pinnedRuntimeMemorySnapshotIndexes.delete( + runtimeMemoryHistory.index + ); + } else { + pinnedRuntimeMemorySnapshotIndexes.add( + runtimeMemoryHistory.index + ); + } + + renderRuntimeMemorySnapshot(); + + if (wasPinned && runtimeMemoryText) { + runtimeMemoryText + .querySelectorAll( + ".flash-new, .flash-changed" + ) + .forEach((element) => { + element.classList.add( + "runtime-memory-flash-off" + ); + element.classList.remove( + "flash-new", + "flash-changed" + ); + + requestAnimationFrame(() => { + element.classList.remove( + "runtime-memory-flash-off" + ); + }); + }); + } + }); + + runtimeMemoryPosition?.addEventListener("keydown", (event) => { + if ( + event.key !== "Enter" + && event.key !== " " + ) { + return; + } + + event.preventDefault(); + runtimeMemoryPosition.click(); + }); + + runtimeDiffToggle?.addEventListener("click", () => { + runtimeDiffHistory.expanded = + !runtimeDiffHistory.expanded; + + renderRuntimeDiffs(); + }); + + initialized = true; + } + + function init(options = {}) { + runtimeMemoryHistory = options.history; + idle = options.idle; + memoryModel = options.memoryModel; + + if (!runtimeMemoryHistory) { + throw new Error( + "JinRuntime.memoryView.init() requires history" + ); + } + + if (!idle) { + throw new Error( + "JinRuntime.memoryView.init() requires idle" + ); + } + + if (!memoryModel) { + throw new Error( + "JinRuntime.memoryView.init() requires memoryModel" + ); + } + + idle.configure({ + onIdleTextChanged(text) { + updateUserIdleTimerText( + text + ); + }, + }); + + bindRuntimeMemoryNavigation(); + renderRuntimeMemorySnapshot(); + renderRuntimeDiffs(); + } + + window.JinRuntime.memoryView = { + init, + render: renderRuntimeMemorySnapshot, + renderRuntimeMemorySnapshot, + renderDiffs: renderRuntimeDiffs, + setRuntimeDiffUpdate, + updateUserIdleTimerText, + freezeLatestRuntimeMemoryUserIdle, + isLatestRuntimeMemorySnapshot, + updateTitleMetrics: updateRuntimeMemoryTitleMetrics, + }; +})(); diff --git a/ui/static/js/runtime/runtime-panel.js b/ui/static/js/runtime/runtime-panel.js new file mode 100644 index 00000000..c71c3061 --- /dev/null +++ b/ui/static/js/runtime/runtime-panel.js @@ -0,0 +1,1106 @@ +(function () { + "use strict"; + + window.JinRuntime = window.JinRuntime || {}; + + const TELEMETRY_FRAME_WARNING_MS = 12; + const CONTEXT_PANEL_RENDER_THROTTLE_MS = 300; + + let initialized = false; + let telemetryFrameScheduled = false; + let contextPanelRenderTimer = null; + + let contextTabButtons = {}; + let contextRuntimePanel = null; + + const runtimePanelState = { + activeTab: "service", + useServiceAsBrain: false, + runtimeStatus: {}, + fallbackRuntimes: {}, + liveRuntimes: [], + }; + + function readInitialRuntimeConfig() { + + if (window.jinRuntimeConfig) { + return window.jinRuntimeConfig; + } + + const configTemplate = + document.getElementById( + "jin-runtime-config" + ); + + if (!configTemplate) { + return {}; + } + + try { + return JSON.parse( + configTemplate.textContent || "{}" + ); + } catch (error) { + return {}; + } + + } + + function isTelemetryDebugEnabled() { + + return Boolean( + window.jinStreamDebug + || window.jinDebugMode + ); + + } + + + function telemetryNowMs() { + + return ( + window.performance + && window.performance.now + ) + ? window.performance.now() + : Date.now(); + + } + + + function requestTelemetryFrame(callback) { + + if (window.requestAnimationFrame) { + window.requestAnimationFrame( + callback + ); + + return; + } + + setTimeout( + callback, + 16 + ); + + } + + function findRuntimeByLabel( + runtimes, + label + ) { + + return runtimes.find( + runtime => + runtime + && runtime.label === label + ); + + } + + + function getRuntimeByLabel(label) { + + const liveRuntime = + findRuntimeByLabel( + runtimePanelState.liveRuntimes, + label + ); + + if (liveRuntime) { + return liveRuntime; + } + + return ( + runtimePanelState.fallbackRuntimes[label] + || null + ); + + } + + + function getBrainRuntime() { + + return ( + getRuntimeByLabel("brain") + || ( + runtimePanelState.useServiceAsBrain + ? getRuntimeByLabel("service") + : null + ) + ); + + } + + + function getSummarizerRuntime() { + + return getRuntimeByLabel( + "summarizer" + ); + + } + + + function getSelectedRuntime() { + + if (runtimePanelState.activeTab === "brain") { + return getBrainRuntime(); + } + + return getRuntimeByLabel("service"); + + } + + + function hasRuntimeStatus(role) { + + return typeof ( + runtimePanelState.runtimeStatus[role] + ) === "boolean"; + + } + + + function isRuntimeOnline(role) { + + if (!hasRuntimeStatus(role)) { + return false; + } + + return Boolean( + runtimePanelState.runtimeStatus[role] + ); + + } + + + function isContextTabDisabled(role) { + + return !isRuntimeOnline(role); + + } + + + function formatContextTokens(runtime) { + + const runtimeInfo = + runtime; + + if (!runtimeInfo) { + return { + used: 0, + max: 0, + }; + } + + return { + used: runtimeInfo.used_tokens || 0, + max: runtimeInfo.max_tokens || 0, + }; + + } + + + function getContextBarCells(barElement) { + + const width = + barElement + ? barElement.clientWidth + : 0; + + if (!width) { + return 24; + } + + return Math.max( + 12, + Math.floor(width / 7) + 3 + ); + + } + + 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, + cells + ) { + + const runtimeInfo = + runtime; + + const used = + runtimeInfo + ? Number(runtimeInfo.used_tokens || 0) + : 0; + + const contextUsed = + runtimeInfo + ? Number( + runtimeInfo.context_tokens + || runtimeInfo.used_tokens + || 0 + ) + : 0; + + const totalUsed = + runtimeInfo + ? Math.max( + contextUsed, + Number( + runtimeInfo.total_tokens + || runtimeInfo.used_tokens + || 0 + ) + ) + : 0; + + const max = + runtimeInfo + ? Number(runtimeInfo.max_tokens || 0) + : 0; + + const rawPercent = + max > 0 + ? Math.min( + 100, + (used / max) * 100 + ) + : 0; + + const percent = + Math.round(rawPercent); + + const percentLabel = + used > 0 + && rawPercent < 1 + ? "<1%" + : `${percent}%`; + + const filled = + Math.round( + (rawPercent / 100) * cells + ); + + const contextPercent = + max > 0 + ? Math.min( + 100, + (contextUsed / max) * 100 + ) + : 0; + + const totalPercent = + max > 0 + ? Math.min( + 100, + (totalUsed / max) * 100 + ) + : 0; + + const contextFilled = + Math.min( + cells, + Math.round( + (contextPercent / 100) * cells + ) + ); + + const totalFilled = + Math.min( + cells, + Math.round( + (totalPercent / 100) * cells + ) + ); + + const secondaryFilled = + Math.max( + 0, + totalFilled - contextFilled + ); + + const bar = + "|".repeat(filled) + + ".".repeat(cells - filled); + + return { + percent, + bar: `[${bar}]`, + contextFilled, + secondaryFilled, + emptyFilled: + Math.max( + 0, + cells - totalFilled + ), + contextPercent: + Math.round(contextPercent), + totalPercent: + Math.round(totalPercent), + contextUsed, + totalUsed, + max, + percentLabel, + }; + + } + + + function renderContextBar( + barElement, + contextLine, + pressureColor + ) { + + if (!barElement) { + return; + } + + const solid = + "|".repeat( + contextLine.contextFilled + ); + + const secondary = + "|".repeat( + contextLine.secondaryFilled + ); + + const empty = + ".".repeat( + contextLine.emptyFilled + ); + + barElement.innerHTML = + "[" + + `<span style="color: ${pressureColor}; opacity: 1">${solid}</span>` + + `<span style="color: ${pressureColor}; opacity: 0.55">${secondary}</span>` + + `<span style="color: ${pressureColor}; opacity: 0.28">${empty}</span>` + + "]"; + + } + + + function setTabClasses(role) { + + const button = + contextTabButtons[role]; + + if (!button) { + return; + } + + const isActive = + runtimePanelState.activeTab === role; + + const isDisabled = + isContextTabDisabled(role); + + button.disabled = + isDisabled; + + button.setAttribute( + "aria-selected", + String(isActive) + ); + + button.setAttribute( + "aria-disabled", + String(isDisabled) + ); + + if (isDisabled) { + const borderClass = + role === "service" + ? "border-r border-slate-500/70 " + : ""; + + button.className = + "h-8 " + + borderClass + + "text-[11px] font-bold uppercase tracking-widest text-slate-500 cursor-not-allowed"; + + return; + } + + if (isActive && role === "service") { + button.className = + "h-8 border-r border-slate-500/70 bg-slate-600/70 text-[11px] font-bold uppercase tracking-widest text-zinc-50 transition"; + + return; + } + + if (isActive) { + button.className = + "h-8 bg-slate-600/70 text-[11px] font-bold uppercase tracking-widest text-zinc-50 transition"; + + return; + } + + if (role === "service") { + button.className = + "h-8 border-r border-slate-500/70 text-[11px] font-bold uppercase tracking-widest text-slate-300 transition hover:bg-slate-600/50 hover:text-zinc-50"; + + return; + } + + button.className = + "h-8 text-[11px] font-bold uppercase tracking-widest text-slate-300 transition hover:bg-slate-600/50 hover:text-zinc-50"; + + } + + + function setContextPanelRuntime(runtime) { + + if (contextRuntimePanel) { + contextRuntimePanel.classList.toggle( + "hidden", + !runtime + ); + } + + const titleElement = + document.getElementById( + "context-panel-title" + ); + + const modelElement = + document.getElementById( + "context-panel-model" + ); + + const summaryElement = + document.getElementById( + "context-summary-tokens" + ); + + const summaryUsedElement = + document.getElementById( + "context-summary-used" + ); + + const summaryMaxElement = + document.getElementById( + "context-summary-max" + ); + + const lineElement = + document.getElementById( + "context-window-line" + ); + + const barElement = + document.getElementById( + "context-window-bar" + ); + + const percentElement = + document.getElementById( + "context-window-percent" + ); + + const summarizerLineElement = + document.getElementById( + "summarizer-window-line" + ); + + const summarizerBarElement = + document.getElementById( + "summarizer-window-bar" + ); + + const summarizerPercentElement = + document.getElementById( + "summarizer-window-percent" + ); + + const tokenText = + formatContextTokens(runtime); + + const contextLine = + buildContextLine( + runtime, + getContextBarCells( + barElement + ) + ); + + const pressureColor = + getContextPressureColor( + Math.max( + contextLine.percent, + contextLine.totalPercent + ) + ); + + const summarizerRuntime = + getSummarizerRuntime(); + + const summarizerTokenText = + formatContextTokens( + summarizerRuntime + ); + + const summarizerLine = + buildContextLine( + summarizerRuntime, + getContextBarCells( + summarizerBarElement + ) + ); + + const summarizerPressureColor = + getContextPressureColor( + Math.max( + summarizerLine.percent, + summarizerLine.totalPercent + ) + ); + + if (titleElement) { + titleElement.textContent = + `STATUS`; + } + + if (modelElement) { + modelElement.textContent = + `${runtime ? runtime.model : "unknown"}`; + } + + if (summaryElement) { + summaryElement.setAttribute( + "aria-label", + `${tokenText.used} / ${tokenText.max}` + ); + } + + if (summaryUsedElement) { + summaryUsedElement.textContent = + `${tokenText.used}\u00a0/`; + } + + if (summaryMaxElement) { + summaryMaxElement.textContent = + `${tokenText.max}`; + } + + if (lineElement) { + lineElement.title = + `context: ${contextLine.contextUsed} / ${contextLine.max} ` + + `(${contextLine.contextPercent}%), total: ` + + `${contextLine.totalUsed} / ${contextLine.max} ` + + `(${contextLine.totalPercent}%)`; + } + + renderContextBar( + barElement, + contextLine, + pressureColor + ); + + if (percentElement) { + percentElement.textContent = + contextLine.percentLabel; + percentElement.style.color = + pressureColor; + } + + if (summarizerLineElement) { + summarizerLineElement.title = + `context: ${summarizerLine.contextUsed} / ${summarizerLine.max} ` + + `(${summarizerLine.contextPercent}%), total: ` + + `${summarizerLine.totalUsed} / ${summarizerLine.max} ` + + `(${summarizerLine.totalPercent}%)`; + } + + renderContextBar( + summarizerBarElement, + summarizerLine, + summarizerPressureColor + ); + + if (summarizerPercentElement) { + summarizerPercentElement.textContent = + summarizerLine.percentLabel; + summarizerPercentElement.style.color = + summarizerPressureColor; + } + + void summarizerTokenText; + + } + + + function updateChatHeader( + serviceRuntime, + brainRuntime + ) { + + const brainModelElement = + document.getElementById( + "brain-model" + ); + + const serviceModelElement = + document.getElementById( + "service-model" + ); + + if ( + brainRuntime + && brainModelElement + ) { + + brainModelElement.textContent = + `BRAIN: ${brainRuntime.model}`; + + } + + if ( + serviceRuntime + && serviceModelElement + ) { + + serviceModelElement.textContent = + `SERVICE: ${serviceRuntime.model}`; + + } + + } + + + function renderLiveRuntimeTelemetry() { + + const serviceRuntime = + getRuntimeByLabel( + "service" + ); + + const brainRuntime = + getBrainRuntime(); + + updateChatHeader( + serviceRuntime, + brainRuntime + ); + + const selectedRuntime = + isContextTabDisabled( + runtimePanelState.activeTab + ) + ? null + : getSelectedRuntime(); + + setContextPanelRuntime( + selectedRuntime + ); + + } + + + function flushRuntimeTelemetryFrame() { + + const startedAt = + telemetryNowMs(); + + telemetryFrameScheduled = false; + + renderLiveRuntimeTelemetry(); + + const elapsed = + telemetryNowMs() - startedAt; + + if ( + isTelemetryDebugEnabled() + && elapsed > TELEMETRY_FRAME_WARNING_MS + ) { + console.warn( + "[telemetry] frame update took", + `${elapsed.toFixed(1)}ms` + ); + } + + } + + + function scheduleRuntimeTelemetryFrame() { + + if (telemetryFrameScheduled) { + return; + } + + telemetryFrameScheduled = true; + + requestTelemetryFrame( + flushRuntimeTelemetryFrame + ); + + } + + + function scheduleContextPanelRender( + final = false + ) { + + if (final) { + if (contextPanelRenderTimer) { + clearTimeout( + contextPanelRenderTimer + ); + + contextPanelRenderTimer = null; + } + + renderContextPanel(); + return; + } + + if (contextPanelRenderTimer) { + return; + } + + contextPanelRenderTimer = setTimeout( + function () { + contextPanelRenderTimer = null; + renderContextPanel(); + }, + CONTEXT_PANEL_RENDER_THROTTLE_MS + ); + + } + + + function scheduleRuntimeTelemetryRender() { + + scheduleRuntimeTelemetryFrame(); + scheduleContextPanelRender(); + + } + + + function flushRuntimeTelemetryRender( + options = {} + ) { + + if (telemetryFrameScheduled) { + flushRuntimeTelemetryFrame(); + } + + if (options.final) { + scheduleContextPanelRender( + true + ); + } + + } + + + function renderContextPanel() { + + if (isContextTabDisabled( + runtimePanelState.activeTab + )) { + + const fallbackTab = + ["brain", "service"].find( + role => !isContextTabDisabled(role) + ); + + if (fallbackTab) { + runtimePanelState.activeTab = + fallbackTab; + } + } + + setTabClasses("service"); + setTabClasses("brain"); + + const selectedRuntime = + isContextTabDisabled( + runtimePanelState.activeTab + ) + ? null + : getSelectedRuntime(); + + setContextPanelRuntime(selectedRuntime); + + } + + + function selectContextTab(role) { + + if (isContextTabDisabled(role)) { + return; + } + + runtimePanelState.activeTab = + role; + + renderContextPanel(); + + } + + + function setUseServiceAsBrain(enabled) { + + runtimePanelState.useServiceAsBrain = + Boolean(enabled); + + renderContextPanel(); + + } + + + function setRuntimeStatusSnapshot(runtimeStatus) { + + runtimePanelState.runtimeStatus = + runtimeStatus || {}; + + renderContextPanel(); + + } + + + function setRuntimeConfigSnapshot(runtimeConfig) { + + runtimePanelState.fallbackRuntimes = + runtimeConfig || {}; + + const serviceRuntime = + getRuntimeByLabel( + "service" + ); + + const brainRuntime = + getBrainRuntime(); + + updateChatHeader( + serviceRuntime, + brainRuntime + ); + + renderContextPanel(); + + } + + + function updateRuntimePanelFromStatus(data) { + + if (!data) { + return; + } + + window.jinRuntimeConfig = { + useServiceAsBrain: + Boolean( + data.use_service_as_brain + ), + runtimeStatus: { + brain: Boolean(data.brain), + service: Boolean(data.service), + }, + runtimeConfig: + data.runtime_config || {}, + }; + + setRuntimeStatusSnapshot( + window + .jinRuntimeConfig + .runtimeStatus + ); + + setUseServiceAsBrain( + window + .jinRuntimeConfig + .useServiceAsBrain + ); + + setRuntimeConfigSnapshot( + window + .jinRuntimeConfig + .runtimeConfig + ); + + } + + + function handleTelemetryMessage(data) { + + if (!data || data.type !== "telemetry") { + return; + } + + runtimePanelState.liveRuntimes = + Object.values( + data.runtime || {} + ); + + scheduleRuntimeTelemetryRender(); + + } + + + function init() { + + if (initialized) { + return api; + } + + initialized = true; + + const initialRuntimeConfig = + readInitialRuntimeConfig(); + + window.jinRuntimeConfig = + initialRuntimeConfig; + + runtimePanelState.useServiceAsBrain = Boolean( + initialRuntimeConfig.useServiceAsBrain + ); + + runtimePanelState.runtimeStatus = ( + initialRuntimeConfig.runtimeStatus + ) || {}; + + runtimePanelState.fallbackRuntimes = ( + initialRuntimeConfig.runtimeConfig + ) || {}; + + contextTabButtons = { + service: document.getElementById( + "service-context-tab" + ), + brain: document.getElementById( + "brain-context-tab" + ), + }; + + contextRuntimePanel = + document.getElementById( + "context-runtime-panel" + ); + + Object.entries( + contextTabButtons + ).forEach( + ([role, button]) => { + + if (!button) { + return; + } + + button.addEventListener( + "click", + function () { + selectContextTab( + role + ); + } + ); + + } + ); + + window.addEventListener( + "resize", + function () { + renderContextPanel(); + } + ); + + if (window.jinLatestStatus) { + + updateRuntimePanelFromStatus( + window.jinLatestStatus + ); + + } else if (window.jinRuntimeConfig) { + + setRuntimeStatusSnapshot( + window.jinRuntimeConfig.runtimeStatus || {} + ); + + setUseServiceAsBrain( + window.jinRuntimeConfig.useServiceAsBrain + ); + + setRuntimeConfigSnapshot( + window.jinRuntimeConfig.runtimeConfig || {} + ); + + } else { + + renderContextPanel(); + + } + + return api; + + } + + const api = { + init, + findRuntimeByLabel, + getRuntimeByLabel, + getBrainRuntime, + getSummarizerRuntime, + getSelectedRuntime, + handleTelemetryMessage, + updateRuntimePanelFromStatus, + flushRuntimeTelemetryRender, + renderContextPanel, + setUseServiceAsBrain, + setRuntimeStatusSnapshot, + setRuntimeConfigSnapshot, + }; + + window.JinRuntime.panel = api; + + window.handleTelemetryMessage = function (payload) { + return api.handleTelemetryMessage(payload); + }; + + window.updateRuntimePanelFromStatus = function (status) { + return api.updateRuntimePanelFromStatus(status); + }; + + window.flushRuntimeTelemetryRender = function (options) { + return api.flushRuntimeTelemetryRender(options); + }; + + window.setUseServiceAsBrain = function (enabled) { + return api.setUseServiceAsBrain(enabled); + }; + + window.setRuntimeStatusSnapshot = function (runtimeStatus) { + return api.setRuntimeStatusSnapshot(runtimeStatus); + }; + + window.setRuntimeConfigSnapshot = function (runtimeConfig) { + return api.setRuntimeConfigSnapshot(runtimeConfig); + }; + +}()); diff --git a/ui/static/js/runtime/runtime-session.js b/ui/static/js/runtime/runtime-session.js new file mode 100644 index 00000000..8580fb12 --- /dev/null +++ b/ui/static/js/runtime/runtime-session.js @@ -0,0 +1,1198 @@ +(function () { + "use strict"; + + window.JinRuntime = window.JinRuntime || {}; + + const session = { + init, + persistSessionMemory: notInitialized, + getRuntimeMemoryForSoftReconnect: notInitialized, + captureSessionSaveRuntimeSnapshot: notInitialized, + isReconnectInitialRuntimeMemoryUpdate: notInitialized, + isLatestRuntimeMemoryDuplicate: notInitialized, + isBootstrapRuntimeMemoryDuplicate: notInitialized, + applyBootstrapRuntimeMemoryUpdate: notInitialized, + hasRestoredSessionMemorySnapshot: notInitialized, + shouldIgnoreInitialSessionModeUpdate: notInitialized, + }; + + window.JinRuntime.session = session; + + function notInitialized() { + throw new Error( + "JinRuntime.session.init must be called before use" + ); + } + + function init(deps) { + if (!deps || typeof deps !== "object") { + throw new Error( + "JinRuntime.session.init requires dependencies" + ); + } + + const { + history, + storage, + memoryModel, + feedback, + runtimeMemoryCount, + defaultRuntimeMemoryText, + sessionStartedRuntimeMemoryText, + getRuntimeMemoryDisplayMode, + setRuntimeMemoryDisplayMode, + getRestoredSessionMemorySnapshot, + setRestoredSessionMemorySnapshot, + renderRuntimeMemorySnapshot, + persistRuntimeMemorySnapshot, + attachFirstUserIdleToInitialRuntimeSnapshot, + rememberStableRuntimeSnapshot: rememberStableRuntimeSnapshotCallback, + } = deps; + + const { + splitMemoryTextLines, + parseRuntimeMemoryLine, + removeRuntimeMemoryLineByKey, + } = memoryModel; + + const { + keys: runtimeStorageKeys, + removeBrowserMemory, + readLatestRuntimeMemory, + writeLatestSavedSessionMemory, + readLatestSavedSessionMemory, + writeLatestSavedRuntimeMemory, + readLatestSavedRuntimeMemory, + buildPersistedRuntimeSnapshot, + cloneBootRuntimeMemoryIfNeeded, + collectOtherLatestRuntimeMemorySnapshots, + clearOtherLatestRuntimeMemorySnapshots, + getSavedRuntimeMemoryFallback, + getCurrentLatestRuntimeMemoryStorageKey, + } = storage; + + let pendingBootstrapRuntimeMemorySnapshot = null; + let lastStableRuntimeMemorySnapshot = null; + let pendingSessionSaveRuntimeMemorySnapshot = null; + let waitingForSessionSaveRuntimeSnapshot = false; + let pendingSessionMemoryPersistData = null; + let persistedSessionBootstrapCleared = false; + let hasUnsavedSessionActivity = false; + + cloneBootRuntimeMemoryIfNeeded(); + + function runtimeMemoryObjectFromSnapshot(snapshot) { + const runtimeMemory = + ( + snapshot + && snapshot.raw_memory + && snapshot.display_source !== "default_runtime_memory" + && snapshot.raw_memory + ) + || ""; + + if (!runtimeMemory.trim()) { + return null; + } + + return { + runtime_memory: runtimeMemory.trim(), + runtime_memory_updates: + ( + snapshot + && snapshot.runtime_memory_updates + ) + || ( + runtimeMemoryCount + && Number(runtimeMemoryCount.textContent || 0) + ) + || 0, + runtime_snapshot: buildPersistedRuntimeSnapshot({ + ...snapshot, + raw_memory: runtimeMemory.trim(), + }), + }; + } + + function runtimeMemoryObjectFromPersistedRuntime(persisted) { + if (!persisted || typeof persisted !== "object") { + return null; + } + + let runtimeMemory = + String(persisted.runtime_memory || "").trim(); + + runtimeMemory = removeRuntimeMemoryLineByKey( + runtimeMemory, + feedback.key + ); + + if (!runtimeMemory) { + return null; + } + + return { + runtime_memory: runtimeMemory, + runtime_memory_updates: + Number(persisted.runtime_memory_updates || 0), + runtime_snapshot: + ( + persisted.runtime_snapshot + && typeof persisted.runtime_snapshot === "object" + ) + ? buildPersistedRuntimeSnapshot( + persisted.runtime_snapshot + ) + : null, + }; + } + + function getRuntimeSnapshotSearchText(snapshot) { + if (!snapshot || typeof snapshot !== "object") { + return ""; + } + + const parts = [ + snapshot.raw_memory, + snapshot.memory, + snapshot.current_request, + snapshot.user_query, + snapshot.last_jin_response, + snapshot.display_source, + ]; + + if (Array.isArray(snapshot.lines)) { + snapshot.lines.forEach(line => { + if (!line || typeof line !== "object") { + return; + } + + parts.push( + line.key, + line.value + ); + }); + } + + return parts + .filter(Boolean) + .map(part => String(part)) + .join("\n") + .toLowerCase(); + } + + function normalizeBehaviorContractSearchText(text) { + return String(text || "") + .toLowerCase() + .replace(/ё/g, "е"); + } + + function getBehaviorContractActionGuardPhrases(name, key) { + const contract = window.JIN_BEHAVIOR_CONTRACT; + + const guard = + contract + && contract.action_guards + && contract.action_guards[name]; + + const phrases = + guard + && guard[key]; + + if (!Array.isArray(phrases)) { + return []; + } + + return phrases + .filter(phrase => typeof phrase === "string"); + } + + function behaviorContractPhraseAppears(text, name, key) { + const normalizedText = + normalizeBehaviorContractSearchText( + text + ); + + return getBehaviorContractActionGuardPhrases( + name, + key + ).some(phrase => ( + normalizedText.includes( + normalizeBehaviorContractSearchText( + phrase + ) + ) + )); + } + + function runtimeTextLooksLikeOnlySessionSave(text) { + const runtimeMemory = + String(text || "").toLowerCase(); + + if (!runtimeMemory.trim()) { + return false; + } + + const hasSessionWord = + runtimeMemory.includes("session") + || runtimeMemory.includes("сесси"); + + const hasSaveWord = + runtimeMemory.includes("save") + || runtimeMemory.includes("saved") + || runtimeMemory.includes("saving") + || runtimeMemory.includes("remembering") + || runtimeMemory.includes("remember_session") + || runtimeMemory.includes("сохран") + || runtimeMemory.includes("запомн"); + + return hasSessionWord && hasSaveWord; + } + + function runtimeSnapshotHasConversationContext(snapshot) { + if (!snapshot || typeof snapshot !== "object") { + return false; + } + + const usefulKeys = new Set([ + "active_task", + "current_focus", + "current_request", + "focus", + "last_jin_response", + "topic", + "user_inquiry", + "user_request", + ]); + + if (!Array.isArray(snapshot.lines)) { + return false; + } + + return snapshot.lines.some(line => { + if (!line || typeof line !== "object") { + return false; + } + + const key = + String(line.key || "") + .trim() + .toLowerCase(); + + const value = + String(line.value || "") + .trim(); + + if (!value || !usefulKeys.has(key)) { + return false; + } + + return !runtimeTextLooksLikeOnlySessionSave( + value + ); + }); + } + + function runtimeSnapshotLooksLikeSessionSaveResult(snapshot) { + const runtimeMemory = + getRuntimeSnapshotSearchText( + snapshot + ); + + if (!runtimeMemory) { + return false; + } + + if ( + runtimeMemory.includes("session management") + && runtimeMemory.includes("paused") + ) { + return false; + } + + const hasSessionWord = + runtimeMemory.includes("session") + || runtimeMemory.includes("сесси"); + + const hasSaveWord = + runtimeMemory.includes("save") + || runtimeMemory.includes("saved") + || runtimeMemory.includes("saving") + || runtimeMemory.includes("remembering") + || runtimeMemory.includes("remember_session") + || runtimeMemory.includes("сохран"); + + const hasRememberSessionTrigger = + behaviorContractPhraseAppears( + runtimeMemory, + "remember_session", + "triggers" + ); + + const hasSaveResultPhrase = ( + runtimeMemory.includes("session saved") + || runtimeMemory.includes("session state successfully saved") + || runtimeMemory.includes("session state saved") + || runtimeMemory.includes("current state is saved") + || runtimeMemory.includes("state is saved") + || runtimeMemory.includes("state saved") + || runtimeMemory.includes("successfully saved") + || runtimeMemory.includes("confirmed saving") + || runtimeMemory.includes("confirmed saved") + || runtimeMemory.includes("remembering this session") + || runtimeMemory.includes("remember_session") + || hasRememberSessionTrigger + || runtimeMemory.includes("сохраняю") + || runtimeMemory.includes("сохранено") + || runtimeMemory.includes("сессия сохран") + ); + + if ( + hasSaveResultPhrase + || ( + hasSessionWord + && hasSaveWord + ) + ) { + // Do not throw away a real L1 runtime page just because the last + // turn also saved the session. The page after a save request may + // still contain the useful current context: previous user request, + // active task, and last non-save JIN response. Only pure save-status + // pages should be treated as save chatter. + return !runtimeSnapshotHasConversationContext( + snapshot + ); + } + + return false; + } + + function isUsableStableRuntimeSnapshot(snapshot) { + if (!snapshot || typeof snapshot !== "object") { + return false; + } + + const runtimeMemory = + String(snapshot.raw_memory || "").trim(); + + if ( + !runtimeMemory + || runtimeMemory === defaultRuntimeMemoryText + || snapshot.display_source === "default_runtime_memory" + || snapshot.display_source === "browser_l3_restore_status" + || snapshot.display_source === "l3_bootstrap_status" + ) { + return false; + } + + if (runtimeSnapshotLooksLikeSessionSaveResult(snapshot)) { + return false; + } + + return true; + } + + function rememberStableRuntimeSnapshot(snapshot) { + if (!isUsableStableRuntimeSnapshot(snapshot)) { + return; + } + + lastStableRuntimeMemorySnapshot = { + ...snapshot, + }; + + if (rememberStableRuntimeSnapshotCallback) { + rememberStableRuntimeSnapshotCallback( + snapshot + ); + } + } + + function getLatestStableRuntimeMemoryObject() { + const snapshots = + history.snapshots || []; + + for (let index = snapshots.length - 1; index >= 0; index -= 1) { + const candidate = snapshots[index]; + + if (!isUsableStableRuntimeSnapshot(candidate)) { + continue; + } + + const runtimeMemory = + runtimeMemoryObjectFromSnapshot(candidate); + + if (runtimeMemory) { + return runtimeMemory; + } + } + + const rememberedRuntimeMemory = + runtimeMemoryObjectFromSnapshot( + lastStableRuntimeMemorySnapshot + ); + + if (rememberedRuntimeMemory) { + return rememberedRuntimeMemory; + } + + const persistedRuntimeMemory = + runtimeMemoryObjectFromPersistedRuntime( + readLatestRuntimeMemory() + ); + + if (persistedRuntimeMemory) { + return persistedRuntimeMemory; + } + + return null; + } + + function getRuntimeMemoryForSessionSave() { + const pendingRuntimeMemory = + runtimeMemoryObjectFromSnapshot( + pendingSessionSaveRuntimeMemorySnapshot + ); + + if (pendingRuntimeMemory) { + return pendingRuntimeMemory; + } + + const stableRuntimeMemory = + getLatestStableRuntimeMemoryObject(); + + if (stableRuntimeMemory) { + return stableRuntimeMemory; + } + + return runtimeMemoryObjectFromPersistedRuntime( + readLatestRuntimeMemory() + ); + } + + function userMessageLooksLikeSessionSaveRequest(text) { + const normalizedText = + String(text || "").toLowerCase(); + + if (!normalizedText.trim()) { + return false; + } + + const hasSessionWord = + normalizedText.includes("session") + || normalizedText.includes("сесси"); + + const hasSaveWord = + normalizedText.includes("save") + || normalizedText.includes("remember") + || normalizedText.includes("сохран") + || normalizedText.includes("запомн"); + + return hasSessionWord && hasSaveWord; + } + + function prepareRuntimeMemoryForUserMessage(text) { + if (!userMessageLooksLikeSessionSaveRequest(text)) { + return; + } + + pendingSessionSaveRuntimeMemorySnapshot = null; + waitingForSessionSaveRuntimeSnapshot = true; + pendingSessionMemoryPersistData = null; + } + + function persistSessionMemory(data) { + if ( + !data + || data.persist !== true + ) { + return; + } + + if ( + waitingForSessionSaveRuntimeSnapshot + && !pendingSessionSaveRuntimeMemorySnapshot + ) { + pendingSessionMemoryPersistData = data; + return; + } + + const sessionMemory = + ( + data.memory + || "" + ).trim(); + + const eventSnapshots = + Array.isArray(data.event_snapshots) + ? data.event_snapshots + : []; + + if (!sessionMemory) { + if (!eventSnapshots.length) { + return; + } + } + + const latestSavedRuntimeMemory = + getRuntimeMemoryForSessionSave(); + + const savedAt = + new Date().toISOString(); + + persistedSessionBootstrapCleared = false; + hasUnsavedSessionActivity = false; + + writeLatestSavedSessionMemory({ + version: 1, + explicit_save: true, + saved_at: savedAt, + session_memory: sessionMemory, + session_event_snapshots: eventSnapshots, + session_memory_updates: + data.updates || 0, + }); + + writeLatestSavedRuntimeMemory({ + version: 1, + explicit_save: true, + saved_at: savedAt, + runtime_memory: + ( + latestSavedRuntimeMemory + && latestSavedRuntimeMemory.runtime_memory + ) || "", + runtime_memory_updates: + ( + latestSavedRuntimeMemory + && latestSavedRuntimeMemory.runtime_memory_updates + ) || 0, + runtime_snapshot: + buildPersistedRuntimeSnapshot( + latestSavedRuntimeMemory + && latestSavedRuntimeMemory.runtime_snapshot + ), + }); + + pendingSessionSaveRuntimeMemorySnapshot = null; + waitingForSessionSaveRuntimeSnapshot = false; + pendingSessionMemoryPersistData = null; + } + + function getRuntimeMemoryForSoftReconnect() { + return getRuntimeMemoryForSessionSave(); + } + + function captureSessionSaveRuntimeSnapshot(snapshot) { + if ( + !waitingForSessionSaveRuntimeSnapshot + || !snapshot + ) { + return; + } + + pendingSessionSaveRuntimeMemorySnapshot = snapshot; + + if (pendingSessionMemoryPersistData) { + const data = pendingSessionMemoryPersistData; + pendingSessionMemoryPersistData = null; + persistSessionMemory( + data + ); + } + } + + function getSoftReconnectRuntimeResume() { + const runtimeMemory = + getRuntimeMemoryForSoftReconnect(); + + const runtimeText = + ( + runtimeMemory + && runtimeMemory.runtime_memory + && String(runtimeMemory.runtime_memory).trim() + ) || ""; + + if (!runtimeText) { + return null; + } + + return { + type: "runtime_resume", + runtime_memory: runtimeText, + runtime_memory_updates: + ( + runtimeMemory + && runtimeMemory.runtime_memory_updates + ) || 0, + runtime_snapshot: + ( + runtimeMemory + && runtimeMemory.runtime_snapshot + ) || null, + }; + } + + function hasTabCloseSessionBootstrap() { + if (persistedSessionBootstrapCleared) { + return false; + } + + return hasUnsavedSessionActivity; + } + + function isReconnectInitialRuntimeMemoryUpdate(data) { + if ( + !data + || !data.snapshot + ) { + return false; + } + + if (Number(data.updates || 0) !== 0) { + return false; + } + + if (history.snapshots.length === 0) { + return false; + } + + const runtimeMemory = + ( + data.snapshot.raw_memory + || data.memory + || "" + ).trim(); + + return runtimeMemory === defaultRuntimeMemoryText; + } + + function normalizeRuntimeMemoryText(text) { + return String(text || "") + .replace(/\\n/g, "\n") + .replace(/\r\n/g, "\n") + .replace( + /(session_status\s*:\s*Active;\s*last updated at\s*)[^\n]+/gi, + "$1<bootstrap_time>" + ) + .split("\n") + .map(line => line.trim()) + .filter(Boolean) + .join("\n"); + } + + function getRuntimeMemoryTextFromUpdate(data) { + return normalizeRuntimeMemoryText( + ( + data + && data.snapshot + && data.snapshot.raw_memory + ) + || ( + data + && data.memory + ) + || "" + ); + } + + function isLatestRuntimeMemoryDuplicate(data) { + if ( + !data + || data.type !== "runtime_memory_update" + || !history.snapshots.length + ) { + return false; + } + + if (data.replace_latest === true) { + return false; + } + + const latestSnapshot = + history.snapshots[ + history.snapshots.length - 1 + ]; + + const latestMemory = normalizeRuntimeMemoryText( + latestSnapshot && latestSnapshot.raw_memory + ); + + const incomingMemory = + getRuntimeMemoryTextFromUpdate(data); + + if ( + latestSnapshot + && latestSnapshot.restored_from_session_save + && Number(data.updates || 0) === 0 + ) { + return true; + } + + if ( + !latestMemory + || !incomingMemory + || latestMemory !== incomingMemory + ) { + return false; + } + + // If the latest snapshot was restored from a previous session its + // runtime_memory_updates counter belongs to that old session. The server + // resets its counter to 0 on every new connection, so the first real L1 + // update (updates=1) is always <= the old session counter (e.g. 3). + // Without this guard every post-bootstrap L1 update is incorrectly treated + // as a duplicate and dropped, leaving the panel stuck on the restore placeholder. + if (latestSnapshot && latestSnapshot.restored_from_session_save) { + return false; + } + + const latestUpdates = Number( + ( + latestSnapshot + && latestSnapshot.runtime_memory_updates + ) || 0 + ); + + const incomingUpdates = Number( + data.updates || 0 + ); + + return incomingUpdates <= latestUpdates; + } + + function isBootstrapRuntimeMemoryDuplicate(data) { + if ( + !pendingBootstrapRuntimeMemorySnapshot + || !data + || data.type !== "runtime_memory_update" + ) { + return false; + } + + const bootstrapMemory = + normalizeRuntimeMemoryText( + pendingBootstrapRuntimeMemorySnapshot.raw_memory + ); + + const incomingMemory = + getRuntimeMemoryTextFromUpdate( + data + ); + + if ( + !bootstrapMemory + || !incomingMemory + || bootstrapMemory !== incomingMemory + ) { + pendingBootstrapRuntimeMemorySnapshot = null; + return false; + } + + const bootstrapUpdates = + Number( + pendingBootstrapRuntimeMemorySnapshot.runtime_memory_updates || 0 + ); + + const incomingUpdates = + Number(data.updates || 0); + + if ( + incomingUpdates <= bootstrapUpdates + || !hasUnsavedSessionActivity + ) { + pendingBootstrapRuntimeMemorySnapshot = null; + return true; + } + + pendingBootstrapRuntimeMemorySnapshot = null; + return false; + } + + function applyBootstrapRuntimeMemoryUpdate(data) { + if ( + !pendingBootstrapRuntimeMemorySnapshot + || !data + || data.type !== "runtime_memory_update" + || Number(data.updates || 0) !== 0 + || !data.snapshot + ) { + return false; + } + + const savedRuntimeSnapshot = { + ...pendingBootstrapRuntimeMemorySnapshot, + index: 0, + }; + + pendingBootstrapRuntimeMemorySnapshot = null; + setRuntimeMemoryDisplayMode("runtime"); + setRestoredSessionMemorySnapshot(null); + + if (window.stopMemoryGlow) { + window.stopMemoryGlow(); + } + + // During persisted-session restore, page 0 must stay the saved runtime from + // browser memory. Server updates=0 messages are bootstrap chatter/echoes. + history.snapshots = [ + savedRuntimeSnapshot, + ]; + history.index = 0; + + if (runtimeMemoryCount) { + runtimeMemoryCount.textContent = + String(savedRuntimeSnapshot.runtime_memory_updates || 0); + } + + renderRuntimeMemorySnapshot(); + + return true; + } + + function handleTabCloseSessionBootstrap(event) { + if (!hasTabCloseSessionBootstrap()) { + return undefined; + } + + event.preventDefault(); + event.returnValue = "Are you sure?"; + + return "Are you sure?"; + } + + function buildRuntimeMemoryDisplaySnapshot(data) { + const runtimeMemory = + String( + ( + data + && ( + data.runtime_memory + || data.memory + || ( + data.runtime_snapshot + && data.runtime_snapshot.raw_memory + ) + ) + ) + || "" + ).trim(); + + if (!runtimeMemory) { + return null; + } + + const sourceSnapshot = + ( + data + && data.runtime_snapshot + && typeof data.runtime_snapshot === "object" + ) + ? data.runtime_snapshot + : {}; + + return { + ...sourceSnapshot, + session_id: + sourceSnapshot.session_id + || "browser_restore", + index: 0, + display_source: "saved_runtime_at_session_save", + raw_memory: runtimeMemory, + lines: + Array.isArray(sourceSnapshot.lines) + && sourceSnapshot.raw_memory === runtimeMemory + ? sourceSnapshot.lines + : splitMemoryTextLines(runtimeMemory) + .map(parseRuntimeMemoryLine), + restored_from_session_save: true, + runtime_memory_updates: + Number( + ( + data + && ( + data.runtime_memory_updates + || data.updates + ) + ) + || 0 + ), + }; + } + + function buildDefaultRuntimeMemorySnapshot() { + return { + session_id: "browser_restore", + index: 0, + display_source: "default_runtime_memory", + raw_memory: sessionStartedRuntimeMemoryText, + lines: [ + { + key: "session_status", + value: "Session started", + status: "same", + key_status: "same", + value_status: "same", + key_change_ratio: 0, + value_change_ratio: 0, + }, + ], + runtime_memory_updates: 0, + }; + } + + function applyRuntimeMemoryDisplaySnapshot(snapshot) { + const displaySnapshot = + snapshot || buildDefaultRuntimeMemorySnapshot(); + + setRuntimeMemoryDisplayMode("runtime"); + setRestoredSessionMemorySnapshot(null); + pendingBootstrapRuntimeMemorySnapshot = + displaySnapshot.restored_from_session_save + ? displaySnapshot + : null; + history.snapshots = [displaySnapshot]; + history.index = 0; + + rememberStableRuntimeSnapshot( + displaySnapshot + ); + + if (runtimeMemoryCount) { + runtimeMemoryCount.textContent = + String(displaySnapshot.runtime_memory_updates || 0); + } + + renderRuntimeMemorySnapshot(); + } + + function applyPersistedSessionBootstrap(bootstrap) { + const snapshot = + ( + bootstrap + && bootstrap.runtime_display_snapshot + ) + || buildRuntimeMemoryDisplaySnapshot( + bootstrap || {} + ) + || buildDefaultRuntimeMemorySnapshot(); + + applyRuntimeMemoryDisplaySnapshot( + snapshot + ); + } + + function getPersistedSessionBootstrap() { + const savedRuntimeFallback = + getSavedRuntimeMemoryFallback(); + + const shouldUseBrowserMemory = + !savedRuntimeFallback; + + const browserLatestSavedSessionMemory = + shouldUseBrowserMemory + ? readLatestSavedSessionMemory() + : null; + + const sessionMemory = + ( + savedRuntimeFallback + && savedRuntimeFallback.session_memory + ) + || ( + browserLatestSavedSessionMemory + && browserLatestSavedSessionMemory.explicit_save === true + ? browserLatestSavedSessionMemory + : null + ); + + if ( + !sessionMemory + || sessionMemory.explicit_save !== true + ) { + return null; + } + + const sessionMemorySource = + ( + savedRuntimeFallback + && savedRuntimeFallback.session_memory + ) + ? savedRuntimeFallback.source + : ( + browserLatestSavedSessionMemory + && browserLatestSavedSessionMemory.explicit_save === true + ? "browser_localStorage" + : "unknown" + ); + + const sessionText = + ( + sessionMemory + && sessionMemory.explicit_save === true + && sessionMemory.session_memory + ) + || ""; + + const browserLatestSavedRuntimeMemory = + shouldUseBrowserMemory + ? readLatestSavedRuntimeMemory() + : null; + + const latestSavedRuntimeMemory = + ( + savedRuntimeFallback + && savedRuntimeFallback.latest_saved_runtime_memory + ) + || ( + browserLatestSavedRuntimeMemory + && browserLatestSavedRuntimeMemory.explicit_save === true + ? browserLatestSavedRuntimeMemory + : null + ); + + const runtimeMemory = + ( + latestSavedRuntimeMemory + && latestSavedRuntimeMemory.explicit_save === true + ) + ? latestSavedRuntimeMemory + : null; + + const runtimeText = + ( + runtimeMemory + && runtimeMemory.runtime_memory + ) + || ""; + + const eventSnapshots = + ( + sessionMemory + && Array.isArray( + sessionMemory.session_event_snapshots + ) + && sessionMemory.session_event_snapshots + ) + || []; + + if ( + !sessionText + && !eventSnapshots.length + ) { + return null; + } + + const runtimeDisplaySnapshot = + buildRuntimeMemoryDisplaySnapshot({ + runtime_memory: runtimeText, + runtime_memory_updates: + ( + runtimeMemory + && runtimeMemory.runtime_memory_updates + ) + || 0, + runtime_snapshot: + ( + runtimeMemory + && runtimeMemory.runtime_snapshot + ) + || null, + }) || buildDefaultRuntimeMemorySnapshot(); + + return { + type: "session_bootstrap", + session_memory: sessionText, + session_memory_source: sessionMemorySource, + session_memory_updates: + ( + sessionMemory + && sessionMemory.session_memory_updates + ) + || 0, + session_event_snapshots: eventSnapshots, + runtime_memory: runtimeText, + runtime_memory_updates: + ( + runtimeMemory + && runtimeMemory.runtime_memory_updates + ) + || 0, + runtime_snapshot: + ( + runtimeMemory + && runtimeMemory.runtime_snapshot + ) + || null, + runtime_display_snapshot: runtimeDisplaySnapshot, + }; + } + + function clearPersistedSessionBootstrap() { + persistedSessionBootstrapCleared = true; + hasUnsavedSessionActivity = false; + + removeBrowserMemory( + runtimeStorageKeys.latestSavedSessionMemoryStorageKey + ); + removeBrowserMemory( + runtimeStorageKeys.latestSavedRuntimeMemoryStorageKey + ); + removeBrowserMemory( + getCurrentLatestRuntimeMemoryStorageKey() + ); + } + + function markSessionActivityDirty() { + persistedSessionBootstrapCleared = false; + hasUnsavedSessionActivity = true; + } + + function hasRestoredSessionMemorySnapshot() { + return Boolean( + getRestoredSessionMemorySnapshot() + ); + } + + function shouldIgnoreInitialSessionModeUpdate(data) { + return ( + getRuntimeMemoryDisplayMode() === "session" + && hasRestoredSessionMemorySnapshot() + && Number(data && data.updates || 0) === 0 + ); + } + + session.persistSessionMemory = persistSessionMemory; + session.getRuntimeMemoryForSoftReconnect = getRuntimeMemoryForSoftReconnect; + session.captureSessionSaveRuntimeSnapshot = captureSessionSaveRuntimeSnapshot; + session.isReconnectInitialRuntimeMemoryUpdate = isReconnectInitialRuntimeMemoryUpdate; + session.isLatestRuntimeMemoryDuplicate = isLatestRuntimeMemoryDuplicate; + session.isBootstrapRuntimeMemoryDuplicate = isBootstrapRuntimeMemoryDuplicate; + session.applyBootstrapRuntimeMemoryUpdate = applyBootstrapRuntimeMemoryUpdate; + session.hasRestoredSessionMemorySnapshot = hasRestoredSessionMemorySnapshot; + session.shouldIgnoreInitialSessionModeUpdate = shouldIgnoreInitialSessionModeUpdate; + session.rememberStableRuntimeSnapshot = rememberStableRuntimeSnapshot; + + window.prepareRuntimeMemoryForUserMessage = prepareRuntimeMemoryForUserMessage; + window.getSoftReconnectRuntimeResume = getSoftReconnectRuntimeResume; + window.applyPersistedSessionBootstrap = applyPersistedSessionBootstrap; + window.getPersistedSessionBootstrap = getPersistedSessionBootstrap; + window.clearPersistedSessionBootstrap = clearPersistedSessionBootstrap; + window.getCurrentLatestRuntimeMemoryStorageKey = function () { + return getCurrentLatestRuntimeMemoryStorageKey(); + }; + window.getOtherLatestRuntimeMemorySnapshots = function () { + return collectOtherLatestRuntimeMemorySnapshots(); + }; + window.clearOtherLatestRuntimeMemorySnapshots = function () { + return clearOtherLatestRuntimeMemorySnapshots(); + }; + window.markSessionActivityDirty = markSessionActivityDirty; + window.markSessionBootstrapActive = markSessionActivityDirty; + + window.addEventListener( + "beforeunload", + handleTabCloseSessionBootstrap + ); + } +}()); diff --git a/ui/static/js/runtime/runtime-storage.js b/ui/static/js/runtime/runtime-storage.js new file mode 100644 index 00000000..1e7f0fc3 --- /dev/null +++ b/ui/static/js/runtime/runtime-storage.js @@ -0,0 +1,801 @@ +(function () { + + window.JinRuntime = window.JinRuntime || {}; + + const latestSavedSessionMemoryStorageKey = + "jin.latestSavedSessionMemory.v1"; + + const runtimeSessionIdSessionStorageKey = + "jin.runtimeSessionId.v1"; + + const latestRuntimeMemoryStorageKeyPrefix = + "jin.latestRuntimeMemory"; + + const latestRuntimeMemoryStorageKeyVersion = + "v1"; + + const latestSavedRuntimeMemoryStorageKey = + "jin.latestSavedRuntimeMemory.v1"; + + const savedRuntimeFallbackPath = + "/saved_runtime.txt"; + + let clonedRuntimeSessionId = null; + let savedRuntimeFileFallback = null; + let savedRuntimeFileFallbackLoaded = false; + + function generateRuntimeSessionId() { + + if ( + window.crypto + && typeof window.crypto.randomUUID === "function" + ) { + return window.crypto.randomUUID(); + } + + return [ + "session", + Date.now().toString(36), + Math.random().toString(36).slice(2, 10), + ].join("-"); + + } + + + function createRuntimeSessionId() { + + try { + const storedSessionId = + String( + window.sessionStorage.getItem( + runtimeSessionIdSessionStorageKey + ) || "" + ).trim(); + + if (storedSessionId) { + const newRuntimeSessionId = + generateRuntimeSessionId(); + + clonedRuntimeSessionId = storedSessionId; + + window.sessionStorage.setItem( + runtimeSessionIdSessionStorageKey, + newRuntimeSessionId + ); + + return newRuntimeSessionId; + } + + const newRuntimeSessionId = + generateRuntimeSessionId(); + + window.sessionStorage.setItem( + runtimeSessionIdSessionStorageKey, + newRuntimeSessionId + ); + + return newRuntimeSessionId; + } catch (error) { + return generateRuntimeSessionId(); + } + + } + + + let runtimeSessionId = + createRuntimeSessionId(); + + let latestRuntimeMemoryStorageKey = + getLatestRuntimeMemoryStorageKey( + runtimeSessionId + ); + + window.jinRuntimeSessionId = + runtimeSessionId; + + function getRuntimeSessionId() { + + return runtimeSessionId; + + } + + + function getCurrentRuntimeSessionId() { + + return runtimeSessionId; + + } + + + function getLatestRuntimeMemoryStorageKey( + runtimeSessionId + ) { + + return `${latestRuntimeMemoryStorageKeyPrefix}` + + `.${runtimeSessionId}` + + `.${latestRuntimeMemoryStorageKeyVersion}`; + + } + + + function getCurrentLatestRuntimeMemoryStorageKey() { + + return latestRuntimeMemoryStorageKey; + + } + + + function isLatestRuntimeMemoryKey( + key + ) { + + const prefix = + `${latestRuntimeMemoryStorageKeyPrefix}.`; + + const suffix = + `.${latestRuntimeMemoryStorageKeyVersion}`; + + return ( + typeof key === "string" + && key.startsWith(prefix) + && key.endsWith(suffix) + && key.length > prefix.length + suffix.length + ); + + } + + + function getSessionIdFromLatestRuntimeMemoryKey( + key + ) { + + const prefix = + `${latestRuntimeMemoryStorageKeyPrefix}.`; + + const suffix = + `.${latestRuntimeMemoryStorageKeyVersion}`; + + if ( + typeof key !== "string" + || !key.startsWith(prefix) + || !key.endsWith(suffix) + ) { + return ""; + } + + return key.slice( + prefix.length, + key.length - suffix.length + ); + + } + + + function setRuntimeSessionId( + nextRuntimeSessionId + ) { + + const normalizedRuntimeSessionId = + String(nextRuntimeSessionId || "").trim(); + + if (!normalizedRuntimeSessionId) { + return; + } + + runtimeSessionId = normalizedRuntimeSessionId; + window.jinRuntimeSessionId = + runtimeSessionId; + latestRuntimeMemoryStorageKey = + getLatestRuntimeMemoryStorageKey( + runtimeSessionId + ); + + try { + window.sessionStorage.setItem( + runtimeSessionIdSessionStorageKey, + runtimeSessionId + ); + } catch (error) { + // Browser memory is helpful, not required for chat. + } + + } + + + function readBrowserMemory( + key + ) { + + try { + return JSON.parse( + window.localStorage.getItem( + key + ) || "null" + ); + } catch (error) { + return null; + } + + } + + + function writeBrowserMemory( + key, + value + ) { + + try { + window.localStorage.setItem( + key, + JSON.stringify(value) + ); + } catch (error) { + // Browser memory is helpful, not required for chat. + } + + } + + + function removeBrowserMemory( + key + ) { + + try { + window.localStorage.removeItem( + key + ); + } catch (error) { + // Browser memory is helpful, not required for chat. + } + + } + + + function readLatestRuntimeMemory() { + + return readBrowserMemory( + latestRuntimeMemoryStorageKey + ); + + } + + + function writeLatestRuntimeMemory( + value + ) { + + writeBrowserMemory( + latestRuntimeMemoryStorageKey, + value + ); + + } + + + function readLatestSavedSessionMemory() { + + return readBrowserMemory( + latestSavedSessionMemoryStorageKey + ); + + } + + + function writeLatestSavedSessionMemory( + value + ) { + + writeBrowserMemory( + latestSavedSessionMemoryStorageKey, + value + ); + + } + + + function readLatestSavedRuntimeMemory() { + + return readBrowserMemory( + latestSavedRuntimeMemoryStorageKey + ); + + } + + + function writeLatestSavedRuntimeMemory( + value + ) { + + writeBrowserMemory( + latestSavedRuntimeMemoryStorageKey, + value + ); + + } + + + function buildPersistedRuntimeSnapshot( + snapshot + ) { + + if ( + !snapshot + || typeof snapshot !== "object" + ) { + return null; + } + + return { + ...snapshot, + session_id: runtimeSessionId, + persisted_pheromone_strength: true, + }; + + } + + + function cloneRuntimeMemoryToCurrentSession( + runtimeMemory + ) { + + if ( + !runtimeMemory + || typeof runtimeMemory !== "object" + || readBrowserMemory(latestRuntimeMemoryStorageKey) + ) { + return; + } + + writeBrowserMemory( + latestRuntimeMemoryStorageKey, + { + version: + runtimeMemory.version || 1, + session_id: runtimeSessionId, + saved_at: + runtimeMemory.saved_at + || new Date().toISOString(), + runtime_memory: + runtimeMemory.runtime_memory || "", + runtime_memory_updates: + runtimeMemory.runtime_memory_updates || 0, + runtime_snapshot: + buildPersistedRuntimeSnapshot( + runtimeMemory.runtime_snapshot + ), + cloned_from_session_id: + runtimeMemory.session_id || null, + } + ); + + } + + + function cloneRuntimeMemoryFromSessionId( + sourceRuntimeSessionId + ) { + + const normalizedSourceRuntimeSessionId = + String(sourceRuntimeSessionId || "").trim(); + + if (!normalizedSourceRuntimeSessionId) { + return; + } + + const sourceRuntimeMemory = + readBrowserMemory( + getLatestRuntimeMemoryStorageKey( + normalizedSourceRuntimeSessionId + ) + ); + + cloneRuntimeMemoryToCurrentSession( + sourceRuntimeMemory + ); + + } + + + function cloneBootRuntimeMemoryIfNeeded() { + + if (!clonedRuntimeSessionId) { + return; + } + + cloneRuntimeMemoryFromSessionId( + clonedRuntimeSessionId + ); + + clonedRuntimeSessionId = null; + + } + + + function collectOtherLatestRuntimeMemorySnapshots() { + + const snapshots = []; + + try { + for ( + let index = window.localStorage.length - 1; + index >= 0; + index -= 1 + ) { + const key = + window.localStorage.key(index); + + if ( + !isLatestRuntimeMemoryKey(key) + || key === latestRuntimeMemoryStorageKey + ) { + continue; + } + + const keySessionId = + getSessionIdFromLatestRuntimeMemoryKey( + key + ); + + if (keySessionId === runtimeSessionId) { + continue; + } + + const value = + readBrowserMemory( + key + ); + + snapshots.push({ + key, + key_session_id: keySessionId, + session_id: + ( + value + && value.session_id + ) + || keySessionId + || null, + saved_at: + ( + value + && value.saved_at + ) + || null, + runtime_memory_updates: + ( + value + && value.runtime_memory_updates + ) + || 0, + runtime_memory: + ( + value + && value.runtime_memory + ) + || "", + }); + } + } catch (error) { + return []; + } + + return snapshots.sort( + function ( + left, + right, + ) { + return String( + right.saved_at || "" + ).localeCompare( + String(left.saved_at || "") + ); + } + ); + + } + + + function clearOtherLatestRuntimeMemorySnapshots() { + + const snapshots = + collectOtherLatestRuntimeMemorySnapshots(); + + try { + snapshots.forEach( + function ( + snapshot + ) { + if ( + snapshot + && snapshot.key + && snapshot.key !== latestRuntimeMemoryStorageKey + ) { + window.localStorage.removeItem( + snapshot.key + ); + } + } + ); + } catch (error) { + // Browser memory cleanup is helpful, not required for chat. + } + + return { + cleared: snapshots.length, + keys: snapshots.map( + function ( + snapshot + ) { + return snapshot.key; + } + ), + }; + + } + + + function extractSavedRuntimeConstant( + source, + name + ) { + + const normalizedSource = + String(source || "").replace( + /\r\n/g, + "\n" + ); + + const markerIndex = + normalizedSource.indexOf( + name + ); + + if (markerIndex < 0) { + return ""; + } + + const assignmentIndex = + normalizedSource.indexOf( + "=", + markerIndex + name.length + ); + + if (assignmentIndex < 0) { + return ""; + } + + const afterAssignment = + normalizedSource.slice( + assignmentIndex + 1 + ); + + const openingMatch = + afterAssignment.match( + /["'`]/ + ); + + if (!openingMatch) { + return ""; + } + + const quote = + openingMatch[0]; + + const valueStart = + assignmentIndex + 1 + openingMatch.index + 1; + + const closingIndex = + normalizedSource.indexOf( + `\n${quote}`, + valueStart + ); + + if (closingIndex < 0) { + return ""; + } + + return normalizedSource.slice( + valueStart, + closingIndex + ).trim(); + + } + + + function parseSavedRuntimeText( + source + ) { + + const runtimeMemory = + extractSavedRuntimeConstant( + source, + "SAVED_RUNTIME" + ); + + const sessionMemory = + extractSavedRuntimeConstant( + source, + "SAVED_SESSION" + ); + + if ( + !runtimeMemory + && !sessionMemory + ) { + return null; + } + + return { + runtime_memory: runtimeMemory, + session_memory: sessionMemory, + source: "saved_runtime_txt", + }; + + } + + + function buildSavedRuntimeFallback( + memory + ) { + + if (!memory) { + return null; + } + + const runtimeMemory = + ( + memory.runtime_memory + && String(memory.runtime_memory).trim() + ) + || ""; + + const sessionMemory = + ( + memory.session_memory + && String(memory.session_memory).trim() + ) + || ""; + + if ( + !runtimeMemory + && !sessionMemory + ) { + return null; + } + + const source = + memory.source || "saved_runtime_txt"; + + const savedAt = + new Date().toISOString(); + + return { + source: source, + session_memory: sessionMemory + ? { + version: 1, + explicit_save: true, + saved_at: savedAt, + session_memory: sessionMemory, + session_event_snapshots: [], + session_memory_updates: 1, + } + : null, + latest_saved_runtime_memory: runtimeMemory + ? { + version: 1, + explicit_save: true, + saved_at: savedAt, + runtime_memory: runtimeMemory, + runtime_memory_updates: 1, + runtime_snapshot: null, + } + : null, + runtime_memory: runtimeMemory + ? { + version: 1, + saved_at: savedAt, + runtime_memory: runtimeMemory, + runtime_memory_updates: 1, + runtime_snapshot: null, + } + : null, + }; + + } + + + function getSavedRuntimeMemoryFallback() { + + return buildSavedRuntimeFallback( + savedRuntimeFileFallback + ); + + } + + + async function loadSavedRuntimeMemoryFallback() { + + if (savedRuntimeFileFallbackLoaded) { + return savedRuntimeFileFallback; + } + + savedRuntimeFileFallbackLoaded = true; + + if ( + !window.fetch + || !savedRuntimeFallbackPath + ) { + return null; + } + + try { + const response = + await window.fetch( + savedRuntimeFallbackPath, + { + cache: "no-store", + } + ); + + if (!response.ok) { + return null; + } + + savedRuntimeFileFallback = + parseSavedRuntimeText( + await response.text() + ); + } catch (error) { + savedRuntimeFileFallback = null; + } + + return savedRuntimeFileFallback; + + } + + + const storage = { + keys: { + latestSavedSessionMemoryStorageKey, + runtimeSessionIdSessionStorageKey, + latestRuntimeMemoryStorageKeyPrefix, + latestRuntimeMemoryStorageKeyVersion, + latestSavedRuntimeMemoryStorageKey, + savedRuntimeFallbackPath, + }, + getRuntimeSessionId, + getCurrentRuntimeSessionId, + setRuntimeSessionId, + generateRuntimeSessionId, + getLatestRuntimeMemoryStorageKey, + getCurrentLatestRuntimeMemoryStorageKey, + isLatestRuntimeMemoryKey, + getSessionIdFromLatestRuntimeMemoryKey, + readBrowserMemory, + writeBrowserMemory, + removeBrowserMemory, + readLatestRuntimeMemory, + writeLatestRuntimeMemory, + readLatestSavedSessionMemory, + writeLatestSavedSessionMemory, + readLatestSavedRuntimeMemory, + writeLatestSavedRuntimeMemory, + buildPersistedRuntimeSnapshot, + cloneRuntimeMemoryToCurrentSession, + cloneRuntimeMemoryFromSessionId, + cloneBootRuntimeMemoryIfNeeded, + collectOtherLatestRuntimeMemorySnapshots, + clearOtherLatestRuntimeMemorySnapshots, + extractSavedRuntimeConstant, + parseSavedRuntimeText, + buildSavedRuntimeFallback, + getSavedRuntimeMemoryFallback, + loadSavedRuntimeMemoryFallback, + }; + + window.JinRuntime.storage = storage; + window.jinSavedRuntimeFallbackReady = + loadSavedRuntimeMemoryFallback(); + +}()); diff --git a/ui/static/js/runtime/runtime.js b/ui/static/js/runtime/runtime.js new file mode 100644 index 00000000..a73aa7c4 --- /dev/null +++ b/ui/static/js/runtime/runtime.js @@ -0,0 +1,490 @@ +(function () { + "use strict"; + + window.JinRuntime = window.JinRuntime || {}; + +const storage = + window.JinRuntime + && window.JinRuntime.storage; + +if (!storage) { + throw new Error( + "JinRuntime.storage must be loaded before runtime.js" + ); +} + +const memoryModel = + window.JinRuntime + && window.JinRuntime.memoryModel; + +if (!memoryModel) { + throw new Error( + "JinRuntime.memoryModel must be loaded before runtime.js" + ); +} + +const idle = + window.JinRuntime + && window.JinRuntime.idle; + +if (!idle) { + throw new Error( + "JinRuntime.idle must be loaded before runtime.js" + ); +} + + +const feedback = + window.JinRuntime + && window.JinRuntime.feedback; + +if (!feedback) { + throw new Error( + "JinRuntime.feedback must be loaded before runtime.js" + ); +} + +const session = + window.JinRuntime + && window.JinRuntime.session; + +if (!session) { + throw new Error( + "JinRuntime.session must be loaded before runtime.js" + ); +} + +const panel = + window.JinRuntime + && window.JinRuntime.panel; + +if (!panel) { + throw new Error( + "JinRuntime.panel must be loaded before runtime.js" + ); +} + +const memoryView = + window.JinRuntime + && window.JinRuntime.memoryView; + +if (!memoryView) { + throw new Error( + "JinRuntime.memoryView must be loaded before runtime.js" + ); +} + +const { + splitMemoryTextLines, + stripMemoryTextMetaForDisplay, + isUserIdleRuntimeMemoryLine, + stripUserIdleRuntimeMemoryText, + parseRuntimeMemoryLine, + getUserIdleRuntimeMemoryLine, + setRuntimeMemorySnapshotUserIdle, + removeRuntimeMemoryLineByKey, + upsertRuntimeMemoryLine, + buildRuntimeMemoryValuePresentation, +} = memoryModel; + +const { + keys: runtimeStorageKeys, + removeBrowserMemory, + readLatestRuntimeMemory, + writeLatestRuntimeMemory, + readLatestSavedSessionMemory, + writeLatestSavedSessionMemory, + readLatestSavedRuntimeMemory, + writeLatestSavedRuntimeMemory, + buildPersistedRuntimeSnapshot, + cloneBootRuntimeMemoryIfNeeded, + collectOtherLatestRuntimeMemorySnapshots, + clearOtherLatestRuntimeMemorySnapshots, + getSavedRuntimeMemoryFallback, +} = storage; + +const runtimeMemoryCount = + document.getElementById( + "runtime-memory-count" + ); + +const defaultRuntimeMemoryText = + "This session has just begun. " + + "You have no history with the user yet."; + +const sessionStartedRuntimeMemoryText = + "session_status: Session started"; + +const runtimeMemoryHistory = { + snapshots: [], + index: -1, +}; + +let runtimeMemoryDisplayMode = "runtime"; +let restoredSessionMemorySnapshot = null; + +feedback.init({ + memoryModel, + getSnapshots() { + return runtimeMemoryHistory.snapshots; + }, + getCurrentIndex() { + return runtimeMemoryHistory.index; + }, + setCurrentIndex(index) { + runtimeMemoryHistory.index = index; + }, + getDisplayMode() { + return runtimeMemoryDisplayMode; + }, + setDisplayMode(mode) { + runtimeMemoryDisplayMode = mode; + }, + getRuntimeMemoryCountText() { + return runtimeMemoryCount + ? runtimeMemoryCount.textContent + : "0"; + }, + renderRuntimeMemorySnapshot() { + renderRuntimeMemorySnapshot(); + }, +}); + +window.jinWebSocketConnected = false; + + +function persistRuntimeMemorySnapshot( + data +) { + + if ( + !data + || !data.snapshot + ) { + return; + } + + if (Number(data.updates || 0) <= 0) { + return; + } + + const runtimeMemory = + ( + data.snapshot.raw_memory + || data.memory + || "" + ).trim(); + + if (!runtimeMemory) { + return; + } + + const savedAt = + new Date().toISOString(); + + writeLatestRuntimeMemory({ + version: 1, + session_id: + storage.getCurrentRuntimeSessionId(), + saved_at: savedAt, + runtime_memory: runtimeMemory, + runtime_memory_updates: data.updates || 0, + runtime_snapshot: buildPersistedRuntimeSnapshot( + data.snapshot + ), + }); + +} + + +window.freezeLatestRuntimeMemoryUserIdle = function ( + userIdleText +) { + memoryView.freezeLatestRuntimeMemoryUserIdle( + userIdleText + ); +}; + + +function runtimeMemoryTextIsDefaultNote(text) { + + const normalized = + String(text || "") + .trim() + .replace(/\s+/g, " ") + .toLowerCase(); + + const defaultNormalized = + defaultRuntimeMemoryText + .trim() + .replace(/\s+/g, " ") + .toLowerCase(); + + return ( + normalized === defaultNormalized + || normalized === `note: ${defaultNormalized}` + ); + +} + + +function attachFirstUserIdleToInitialRuntimeSnapshot(sourceSnapshot) { + + const firstSnapshot = + runtimeMemoryHistory.snapshots[0]; + + if (!firstSnapshot) { + return; + } + + if (getUserIdleRuntimeMemoryLine(firstSnapshot)) { + return; + } + + const firstRawMemory = + String(firstSnapshot.raw_memory || ""); + + if (!runtimeMemoryTextIsDefaultNote(firstRawMemory)) { + return; + } + + const userIdleLine = + getUserIdleRuntimeMemoryLine(sourceSnapshot); + + if (!userIdleLine) { + return; + } + + const nextLine = { + ...userIdleLine, + status: "same", + key_status: "same", + value_status: "same", + }; + + firstSnapshot.lines = [ + ...(Array.isArray(firstSnapshot.lines) + ? firstSnapshot.lines + : splitMemoryTextLines(firstRawMemory) + .map(parseRuntimeMemoryLine)), + nextLine, + ]; + + firstSnapshot.raw_memory = [ + firstRawMemory.trim() || `note: ${defaultRuntimeMemoryText}`, + `user_idle: ${nextLine.value || ""}`.trim(), + ].filter(Boolean).join("\n"); + +} + + + + + + +session.init({ + history: runtimeMemoryHistory, + storage, + memoryModel, + feedback, + runtimeMemoryCount, + defaultRuntimeMemoryText, + sessionStartedRuntimeMemoryText, + getRuntimeMemoryDisplayMode: () => runtimeMemoryDisplayMode, + setRuntimeMemoryDisplayMode: (value) => { + runtimeMemoryDisplayMode = value; + }, + getRestoredSessionMemorySnapshot: () => restoredSessionMemorySnapshot, + setRestoredSessionMemorySnapshot: (value) => { + restoredSessionMemorySnapshot = value; + }, + renderRuntimeMemorySnapshot, + persistRuntimeMemorySnapshot, + attachFirstUserIdleToInitialRuntimeSnapshot, +}); + +panel.init(); + +memoryView.init({ + history: runtimeMemoryHistory, + idle, + memoryModel, +}); + +function renderRuntimeMemorySnapshot() { + memoryView.renderRuntimeMemorySnapshot(); +} + +function renderRuntimeDiffs() { + memoryView.renderDiffs(); +} + +function handleRuntimeMemoryMessage(data) { + + if (!data) { + return; + } + + if (data.type === "runtime_l1_diff_update") { + memoryView.setRuntimeDiffUpdate( + data + ); + + return; + } + + if (data.type === "runtime_session_memory_update") { + session.persistSessionMemory( + data + ); + + if ( + data.persist === true + && window.fadeRuntimeAction + ) { + window.fadeRuntimeAction( + "remember_session" + ); + } + + if (window.stopL3MemoryGlow) { + window.stopL3MemoryGlow(); + } + + return; + } + + if (data.type !== "runtime_memory_update") { + return; + } + + if (session.isReconnectInitialRuntimeMemoryUpdate(data)) { + return; + } + + if (session.isLatestRuntimeMemoryDuplicate(data)) { + return; + } + + if (session.applyBootstrapRuntimeMemoryUpdate(data)) { + return; + } + + if (session.isBootstrapRuntimeMemoryDuplicate(data)) { + return; + } + + if ( + session.shouldIgnoreInitialSessionModeUpdate(data) + ) { + persistRuntimeMemorySnapshot( + data + ); + + return; + } + + runtimeMemoryDisplayMode = "runtime"; + + if (window.stopMemoryGlow) { + window.stopMemoryGlow(); + } + + if (runtimeMemoryCount) { + runtimeMemoryCount.textContent = + String(data.updates || 0); + } + + let clientSnapshot = null; + + if (data.snapshot) { + if ( + data.replace_latest === true + && runtimeMemoryHistory.snapshots.length + ) { + const clientIndex = + runtimeMemoryHistory.snapshots.length - 1; + + clientSnapshot = { + ...runtimeMemoryHistory.snapshots[clientIndex], + ...data.snapshot, + index: clientIndex, + }; + + runtimeMemoryHistory.snapshots[clientIndex] = + clientSnapshot; + runtimeMemoryHistory.index = clientIndex; + + session.rememberStableRuntimeSnapshot( + clientSnapshot + ); + } else { + const clientIndex = runtimeMemoryHistory.snapshots.length; + clientSnapshot = { + ...data.snapshot, + index: clientIndex, + }; + + attachFirstUserIdleToInitialRuntimeSnapshot( + clientSnapshot + ); + + // The server-side snapshot.index can restart after bootstrap/restore. + // The right panel is client-side history, so display positions must follow + // the actual array order instead of reusing a stale server index. + runtimeMemoryHistory.snapshots.push(clientSnapshot); + runtimeMemoryHistory.index = + runtimeMemoryHistory.snapshots.length - 1; + + if (window.jinGenerationRunning) { + idle.freezeAtSeconds( + window.jinActiveTurnUserIdleSeconds + ); + } + + session.rememberStableRuntimeSnapshot( + clientSnapshot + ); + + feedback.markL1ReadyFromRuntimeUpdate( + data, + clientIndex + ); + } + } else { + feedback.markL1ReadyFromRuntimeUpdate( + data + ); + } + + persistRuntimeMemorySnapshot( + data + ); + + session.captureSessionSaveRuntimeSnapshot( + clientSnapshot + ); + + renderRuntimeMemorySnapshot(); + +} + +window.JinRuntime.runtime = { + init() { + return true; + }, + handleRuntimeMemoryMessage, + renderRuntimeMemorySnapshot, + renderDiffs: renderRuntimeDiffs, + persistRuntimeMemorySnapshot, +}; + +window.JinRuntime.init = function () { + return true; +}; + +window.handleRuntimeMemoryMessage = function (data) { + return handleRuntimeMemoryMessage(data); +}; + +}()); diff --git a/ui/static/js/socket.js b/ui/static/js/socket.js index 7c020802..8bff8cfe 100644 --- a/ui/static/js/socket.js +++ b/ui/static/js/socket.js @@ -18,9 +18,10 @@ const factCheckTrigger = ); const websocketClientId = - (window.crypto && window.crypto.randomUUID) + window.jinRuntimeSessionId + || ((window.crypto && window.crypto.randomUUID) ? window.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + : `${Date.now()}-${Math.random().toString(16).slice(2)}`); let websocketHasOpened = false; @@ -50,6 +51,7 @@ let websocketReconnectTimer = null; let websocketReconnectAttempts = 0; let websocketDisconnectedLogged = false; let persistedSessionBootstrapSent = false; +let latestRuntimeSnapshotsLogged = false; const MEMORY_GLOW_CLASSES = [ "memory-updating", @@ -81,6 +83,99 @@ const MEMORY_GLOW_STAGES = { }, }; +function buildLatestRuntimeSnapshotsDetails( + snapshots +) { + + const lines = [ + "current_runtime_session_id: " + + String(window.jinRuntimeSessionId || websocketClientId), + "", + "current_key: " + + String( + window.getCurrentLatestRuntimeMemoryStorageKey + ? window.getCurrentLatestRuntimeMemoryStorageKey() + : "" + ), + ]; + + snapshots.forEach( + function ( + snapshot, + index, + ) { + const runtimeMemory = + String(snapshot.runtime_memory || "") + .replace(/\\n/g, "\n") + .replace( + /;\s+(?=[a-z][a-z0-9_]*\s*:)/g, + "\n" + ) + .split(/\r?\n+/) + .map(function (line) { + return line.trim(); + }) + .filter(Boolean); + + lines.push( + "", + `[ snapshot ${index + 1} ]`, + "", + `key: ${snapshot.key || ""}`, + "", + `key_session_id: ${snapshot.key_session_id || ""}`, + "", + `session_id: ${snapshot.session_id || ""}`, + "", + `saved_at: ${snapshot.saved_at || ""}`, + "", + `runtime_memory_updates: ${snapshot.runtime_memory_updates || 0}` + ); + + if (runtimeMemory.length) { + lines.push( + "", + "runtime_memory:", + "", + runtimeMemory.join("\n\n") + ); + } + } + ); + + return lines.join("\n"); + +} + +function logOtherLatestRuntimeMemorySnapshots() { + + if ( + latestRuntimeSnapshotsLogged + || !window.getOtherLatestRuntimeMemorySnapshots + ) { + return; + } + + const snapshots = + window.getOtherLatestRuntimeMemorySnapshots(); + + if (!snapshots.length) { + return; + } + + latestRuntimeSnapshotsLogged = true; + + appendLog( + "[LATEST SNAPSHOTS]", + `${snapshots.length} stale latest runtime snapshot` + + `${snapshots.length === 1 ? "" : "s"} found.`, + buildLatestRuntimeSnapshotsDetails( + snapshots + ) + ); + +} + function isMemoryLog(data) { return Boolean( data @@ -941,6 +1036,18 @@ function handleSocketMessage(event) { return; } + if ( + action === "remember_session" + && window.queueRuntimeActionAfterNextResponse + ) { + window.queueRuntimeActionAfterNextResponse( + action, + text + ); + + return; + } + appendRuntimeAction( action, text @@ -1243,6 +1350,12 @@ chatForm.addEventListener( userIdleContext.user_idle_seconds; payload.user_idle_paused = userIdleContext.user_idle_paused; + + if (window.freezeLatestRuntimeMemoryUserIdle) { + window.freezeLatestRuntimeMemoryUserIdle( + userIdleContext.user_idle + ); + } } window.jinActiveTurnUserIdleSeconds = @@ -1411,4 +1524,5 @@ function connectWebSocket() { } +logOtherLatestRuntimeMemorySnapshots(); connectWebSocket(); diff --git a/ui/static/js/telemetry.js b/ui/static/js/telemetry.js deleted file mode 100644 index 483485e0..00000000 --- a/ui/static/js/telemetry.js +++ /dev/null @@ -1,4454 +0,0 @@ -function readInitialRuntimeConfig() { - - if (window.jinRuntimeConfig) { - return window.jinRuntimeConfig; - } - - const configTemplate = - document.getElementById( - "jin-runtime-config" - ); - - if (!configTemplate) { - return {}; - } - - try { - return JSON.parse( - configTemplate.textContent || "{}" - ); - } catch (error) { - return {}; - } - -} - -/** - * @typedef {Object} RuntimeInfo - * @property {string=} label - * @property {string=} model - * @property {number=} used_tokens - * @property {number=} context_tokens - * @property {number=} total_tokens - * @property {number=} max_tokens - */ - -/** - * @typedef {Object} RuntimeStatusPayload - * @property {string} type - * @property {Object<string, RuntimeInfo>=} runtime - * @property {boolean=} brain - * @property {boolean=} service - * @property {boolean=} use_service_as_brain - * @property {Object<string, RuntimeInfo>=} runtime_config - */ - - -const initialRuntimeConfig = - readInitialRuntimeConfig(); - -window.jinRuntimeConfig = - initialRuntimeConfig; - -const runtimePanelState = { - activeTab: "service", - useServiceAsBrain: Boolean( - initialRuntimeConfig.useServiceAsBrain - ), - runtimeStatus: ( - initialRuntimeConfig.runtimeStatus - ) || {}, - fallbackRuntimes: ( - initialRuntimeConfig.runtimeConfig - ) || {}, - liveRuntimes: [], -}; - -const TELEMETRY_FRAME_WARNING_MS = 12; -const CONTEXT_PANEL_RENDER_THROTTLE_MS = 300; -const SAVE_RUNTIME_PHEROMONE_STRENGTH = true; -const USER_IDLE_REFRESH_MS = 1000; -const USER_IDLE_TYPING_RESUME_DELAY_MS = 30000; - -let userIdleStartedAt = Date.now(); -let userIdleTimer = null; -let userIdleValueNode = null; -let userIdlePausedAt = null; -let userIdleResumeTimer = null; -let userIdleInputFreezeInstalled = false; - -let telemetryFrameScheduled = false; -let contextPanelRenderTimer = null; - -const contextTabButtons = { - service: document.getElementById( - "service-context-tab" - ), - brain: document.getElementById( - "brain-context-tab" - ), -}; - -const contextRuntimePanel = - document.getElementById( - "context-runtime-panel" - ); - -const runtimeMemoryText = - document.getElementById( - "runtime-memory-text" - ); - -const runtimeMemoryPanel = - document.getElementById("settings-panel"); - -const runtimeMemoryCount = - document.getElementById( - "runtime-memory-count" - ); - -const latestSavedSessionMemoryStorageKey = - "jin.latestSavedSessionMemory.v1"; - -const latestRuntimeMemoryStorageKey = - "jin.latestRuntimeMemory.v1"; - -const latestSavedRuntimeMemoryStorageKey = - "jin.latestSavedRuntimeMemory.v1"; - -const savedRuntimeFallbackPath = - "/saved_runtime.txt"; - -const defaultRuntimeMemoryText = - "This session has just begun. " - + "You have no history with the user yet."; - -const sessionStartedRuntimeMemoryText = - "session_status: Session started"; - -const runtimeMemoryHistory = { - snapshots: [], - index: -1, -}; - -const pinnedRuntimeMemorySnapshotIndexes = new Set(); - -let runtimeMemoryDisplayMode = "runtime"; -let restoredSessionMemorySnapshot = null; -let pendingBootstrapRuntimeMemorySnapshot = null; -let lastStableRuntimeMemorySnapshot = null; -let pendingSessionSaveRuntimeMemorySnapshot = null; -let waitingForSessionSaveRuntimeSnapshot = false; -let pendingSessionMemoryPersistData = null; - -const runtimeDiffHistory = { - diffs: [], - stats: {}, - expanded: false, -}; - -function formatUserIdleDuration(ms) { - const totalSeconds = Math.max( - 0, - Math.floor(Number(ms || 0) / 1000) - ); - - if (totalSeconds < 60) { - return `${totalSeconds}s`; - } - - const totalMinutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - - if (totalMinutes < 60) { - return seconds - ? `${totalMinutes}m ${seconds}s` - : `${totalMinutes}m`; - } - - const totalHours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - - if (totalHours < 24) { - return minutes - ? `${totalHours}h ${minutes}m` - : `${totalHours}h`; - } - - const days = Math.floor(totalHours / 24); - const hours = totalHours % 24; - - return hours - ? `${days}d ${hours}h` - : `${days}d`; -} - -function getUserIdleElapsedMs() { - const now = - userIdlePausedAt !== null - ? userIdlePausedAt - : Date.now(); - - return now - userIdleStartedAt; -} - -function getUserIdleText() { - return formatUserIdleDuration( - getUserIdleElapsedMs() - ); -} - -function updateUserIdleTimerText() { - if (!userIdleValueNode) { - return; - } - - userIdleValueNode.textContent = - ` ${getUserIdleText()}`; -} - -function clearUserIdleResumeTimer() { - if (!userIdleResumeTimer) { - return; - } - - clearTimeout( - userIdleResumeTimer - ); - - userIdleResumeTimer = null; -} - -function stopUserIdleTimer() { - if (!userIdleTimer) { - return; - } - - clearInterval( - userIdleTimer - ); - - userIdleTimer = null; -} - -function ensureUserIdleTimer() { - if (userIdlePausedAt !== null) { - return; - } - - if (userIdleTimer) { - return; - } - - userIdleTimer = setInterval( - updateUserIdleTimerText, - USER_IDLE_REFRESH_MS - ); -} - -function isUserIdleChatInputFocused() { - const input = - document.getElementById( - "user-input" - ); - - return Boolean( - input - && document.activeElement === input - ); -} - -function resumeUserIdleTimer() { - if (userIdlePausedAt === null) { - ensureUserIdleTimer(); - return; - } - - const frozenElapsed = - Math.max( - 0, - userIdlePausedAt - userIdleStartedAt - ); - - userIdleStartedAt = - Date.now() - frozenElapsed; - - userIdlePausedAt = null; - clearUserIdleResumeTimer(); - updateUserIdleTimerText(); - ensureUserIdleTimer(); -} - -function scheduleUserIdleResumeIfChatLostFocus() { - clearUserIdleResumeTimer(); - - if (userIdlePausedAt === null) { - return; - } - - userIdleResumeTimer = setTimeout( - function () { - userIdleResumeTimer = null; - - if ( - isUserIdleChatInputFocused() - && document.hasFocus() - && !document.hidden - ) { - return; - } - - resumeUserIdleTimer(); - }, - USER_IDLE_TYPING_RESUME_DELAY_MS - ); -} - -function pauseUserIdleTimerForTyping() { - if (userIdlePausedAt === null) { - userIdlePausedAt = Date.now(); - } - - stopUserIdleTimer(); - updateUserIdleTimerText(); - scheduleUserIdleResumeIfChatLostFocus(); -} - -function resetUserIdleTimer() { - userIdleStartedAt = Date.now(); - userIdlePausedAt = null; - clearUserIdleResumeTimer(); - updateUserIdleTimerText(); - ensureUserIdleTimer(); -} - -function freezeUserIdleTimerAtMs( - elapsedMs -) { - const now = Date.now(); - const frozenElapsedMs = Math.max( - 0, - Number(elapsedMs || 0) - ); - - userIdleStartedAt = now - frozenElapsedMs; - userIdlePausedAt = now; - clearUserIdleResumeTimer(); - stopUserIdleTimer(); - updateUserIdleTimerText(); -} - -function freezeUserIdleTimerAtSeconds( - elapsedSeconds -) { - freezeUserIdleTimerAtMs( - Math.max( - 0, - Number(elapsedSeconds || 0) - ) * 1000 - ); -} - -function freezeUserIdleTimerAtZero() { - freezeUserIdleTimerAtMs( - 0 - ); -} - -function installUserIdleInputFreeze() { - if (userIdleInputFreezeInstalled) { - return; - } - - const input = - document.getElementById( - "user-input" - ); - - if (!input) { - return; - } - - userIdleInputFreezeInstalled = true; - - input.addEventListener( - "input", - pauseUserIdleTimerForTyping - ); - - input.addEventListener( - "focus", - function () { - if (userIdlePausedAt !== null) { - clearUserIdleResumeTimer(); - } - } - ); - - input.addEventListener( - "blur", - scheduleUserIdleResumeIfChatLostFocus - ); - - window.addEventListener( - "blur", - scheduleUserIdleResumeIfChatLostFocus - ); - - document.addEventListener( - "visibilitychange", - function () { - if (document.hidden) { - scheduleUserIdleResumeIfChatLostFocus(); - } - } - ); -} - -window.jinResetUserIdleTimer = - resetUserIdleTimer; - -window.jinFreezeUserIdleTimerAtZero = - freezeUserIdleTimerAtZero; - -window.jinFreezeUserIdleTimerAtSeconds = - freezeUserIdleTimerAtSeconds; - -function getJinUserIdleContext() { - const elapsedMs = Math.max( - 0, - getUserIdleElapsedMs() - ); - - return { - user_idle: formatUserIdleDuration( - elapsedMs - ), - user_idle_seconds: Math.floor( - elapsedMs / 1000 - ), - user_idle_paused: userIdlePausedAt !== null, - }; -} - -window.getJinUserIdleContext = - getJinUserIdleContext; - -window.jinWebSocketConnected = false; - -let persistedSessionBootstrapCleared = false; -let hasUnsavedSessionActivity = false; - -function isTelemetryDebugEnabled() { - - return Boolean( - window.jinStreamDebug - || window.jinDebugMode - ); - -} - - -function telemetryNowMs() { - - return ( - window.performance - && window.performance.now - ) - ? window.performance.now() - : Date.now(); - -} - - -function requestTelemetryFrame(callback) { - - if (window.requestAnimationFrame) { - window.requestAnimationFrame( - callback - ); - - return; - } - - setTimeout( - callback, - 16 - ); - -} - - -function renderLiveRuntimeTelemetry() { - - const serviceRuntime = - getRuntimeByLabel( - "service" - ); - - const brainRuntime = - getBrainRuntime(); - - updateChatHeader( - serviceRuntime, - brainRuntime - ); - - const selectedRuntime = - isContextTabDisabled( - runtimePanelState.activeTab - ) - ? null - : getSelectedRuntime(); - - setContextPanelRuntime( - selectedRuntime - ); - -} - - -function flushRuntimeTelemetryFrame() { - - const startedAt = - telemetryNowMs(); - - telemetryFrameScheduled = false; - - renderLiveRuntimeTelemetry(); - - const elapsed = - telemetryNowMs() - startedAt; - - if ( - isTelemetryDebugEnabled() - && elapsed > TELEMETRY_FRAME_WARNING_MS - ) { - console.warn( - "[telemetry] frame update took", - `${elapsed.toFixed(1)}ms` - ); - } - -} - - -function scheduleRuntimeTelemetryFrame() { - - if (telemetryFrameScheduled) { - return; - } - - telemetryFrameScheduled = true; - - requestTelemetryFrame( - flushRuntimeTelemetryFrame - ); - -} - - -function scheduleContextPanelRender( - final = false -) { - - if (final) { - if (contextPanelRenderTimer) { - clearTimeout( - contextPanelRenderTimer - ); - - contextPanelRenderTimer = null; - } - - renderContextPanel(); - return; - } - - if (contextPanelRenderTimer) { - return; - } - - contextPanelRenderTimer = setTimeout( - function () { - contextPanelRenderTimer = null; - renderContextPanel(); - }, - CONTEXT_PANEL_RENDER_THROTTLE_MS - ); - -} - - -function scheduleRuntimeTelemetryRender() { - - scheduleRuntimeTelemetryFrame(); - scheduleContextPanelRender(); - -} - - -function flushRuntimeTelemetryRender( - options = {} -) { - - if (telemetryFrameScheduled) { - flushRuntimeTelemetryFrame(); - } - - if (options.final) { - scheduleContextPanelRender( - true - ); - } - -} - -function readBrowserMemory( - key -) { - - try { - return JSON.parse( - window.localStorage.getItem( - key - ) || "null" - ); - } catch (error) { - return null; - } - -} - - -let savedRuntimeFileFallback = null; -let savedRuntimeFileFallbackLoaded = false; - - -function extractSavedRuntimeConstant( - source, - name -) { - - const normalizedSource = - String(source || "").replace( - /\r\n/g, - "\n" - ); - - const markerIndex = - normalizedSource.indexOf( - name - ); - - if (markerIndex < 0) { - return ""; - } - - const assignmentIndex = - normalizedSource.indexOf( - "=", - markerIndex + name.length - ); - - if (assignmentIndex < 0) { - return ""; - } - - const afterAssignment = - normalizedSource.slice( - assignmentIndex + 1 - ); - - const openingMatch = - afterAssignment.match( - /["'`]/ - ); - - if (!openingMatch) { - return ""; - } - - const quote = - openingMatch[0]; - - const valueStart = - assignmentIndex + 1 + openingMatch.index + 1; - - const closingIndex = - normalizedSource.indexOf( - `\n${quote}`, - valueStart - ); - - if (closingIndex < 0) { - return ""; - } - - return normalizedSource.slice( - valueStart, - closingIndex - ).trim(); - -} - - -function parseSavedRuntimeText( - source -) { - - const runtimeMemory = - extractSavedRuntimeConstant( - source, - "SAVED_RUNTIME" - ); - - const sessionMemory = - extractSavedRuntimeConstant( - source, - "SAVED_SESSION" - ); - - if ( - !runtimeMemory - && !sessionMemory - ) { - return null; - } - - return { - runtime_memory: runtimeMemory, - session_memory: sessionMemory, - source: "saved_runtime_txt", - }; - -} - - -function buildSavedRuntimeFallback( - memory -) { - - if (!memory) { - return null; - } - - const runtimeMemory = - ( - memory.runtime_memory - && String(memory.runtime_memory).trim() - ) - || ""; - - const sessionMemory = - ( - memory.session_memory - && String(memory.session_memory).trim() - ) - || ""; - - if ( - !runtimeMemory - && !sessionMemory - ) { - return null; - } - - const source = - memory.source || "saved_runtime_txt"; - - const savedAt = - new Date().toISOString(); - - return { - source: source, - session_memory: sessionMemory - ? { - version: 1, - explicit_save: true, - saved_at: savedAt, - session_memory: sessionMemory, - session_event_snapshots: [], - session_memory_updates: 1, - } - : null, - latest_saved_runtime_memory: runtimeMemory - ? { - version: 1, - explicit_save: true, - saved_at: savedAt, - runtime_memory: runtimeMemory, - runtime_memory_updates: 1, - runtime_snapshot: null, - } - : null, - runtime_memory: runtimeMemory - ? { - version: 1, - saved_at: savedAt, - runtime_memory: runtimeMemory, - runtime_memory_updates: 1, - runtime_snapshot: null, - } - : null, - }; - -} - - -function getSavedRuntimeMemoryFallback() { - - return buildSavedRuntimeFallback( - savedRuntimeFileFallback - ); - -} - - -async function loadSavedRuntimeMemoryFallback() { - - if (savedRuntimeFileFallbackLoaded) { - return savedRuntimeFileFallback; - } - - savedRuntimeFileFallbackLoaded = true; - - if ( - !window.fetch - || !savedRuntimeFallbackPath - ) { - return null; - } - - try { - const response = - await window.fetch( - savedRuntimeFallbackPath, - { - cache: "no-store", - } - ); - - if (!response.ok) { - return null; - } - - savedRuntimeFileFallback = - parseSavedRuntimeText( - await response.text() - ); - } catch (error) { - savedRuntimeFileFallback = null; - } - - return savedRuntimeFileFallback; - -} - - -window.jinSavedRuntimeFallbackReady = - loadSavedRuntimeMemoryFallback(); - - -function writeBrowserMemory( - key, - value -) { - - try { - window.localStorage.setItem( - key, - JSON.stringify(value) - ); - } catch (error) { - // Browser memory is helpful, not required for chat. - } - -} - - -function buildPersistedRuntimeSnapshot( - snapshot -) { - - if ( - !SAVE_RUNTIME_PHEROMONE_STRENGTH - || !snapshot - || typeof snapshot !== "object" - ) { - return null; - } - - return { - ...snapshot, - persisted_pheromone_strength: true, - }; - -} - - -function persistRuntimeMemorySnapshot( - data -) { - - if ( - !data - || !data.snapshot - ) { - return; - } - - if (Number(data.updates || 0) <= 0) { - return; - } - - const runtimeMemory = - ( - data.snapshot.raw_memory - || data.memory - || "" - ).trim(); - - if (!runtimeMemory) { - return; - } - - // A manual session-save turn creates a temporary L1 page like - // "session saved / remembering this session". That is not the actual - // runtime state the user wanted to preserve. Keep the previous real L1 - // snapshot as Latest runtime instead of overwriting it with save chatter. - if (runtimeSnapshotLooksLikeSessionSaveResult(data.snapshot)) { - return; - } - - const savedAt = - new Date().toISOString(); - - writeBrowserMemory( - latestRuntimeMemoryStorageKey, - { - version: 1, - saved_at: savedAt, - runtime_memory: runtimeMemory, - runtime_memory_updates: data.updates || 0, - runtime_snapshot: buildPersistedRuntimeSnapshot( - data.snapshot - ), - } - ); - -} - - -function runtimeMemoryObjectFromSnapshot( - snapshot -) { - - const runtimeMemory = - ( - snapshot - && snapshot.raw_memory - && snapshot.display_source !== "default_runtime_memory" - && snapshot.raw_memory - ) - || ""; - - if (!runtimeMemory.trim()) { - return null; - } - - return { - runtime_memory: runtimeMemory.trim(), - runtime_memory_updates: - ( - snapshot - && snapshot.runtime_memory_updates - ) - || ( - runtimeMemoryCount - && Number(runtimeMemoryCount.textContent || 0) - ) - || 0, - runtime_snapshot: buildPersistedRuntimeSnapshot({ - ...snapshot, - raw_memory: runtimeMemory.trim(), - }), - }; - -} - - -function runtimeMemoryObjectFromPersistedRuntime( - persisted -) { - - if (!persisted || typeof persisted !== "object") { - return null; - } - - let runtimeMemory = - String(persisted.runtime_memory || "").trim(); - - runtimeMemory = removeRuntimeMemoryLineByKey( - runtimeMemory, - runtimeResponseFeedbackKey - ); - - if (!runtimeMemory) { - return null; - } - - return { - runtime_memory: runtimeMemory, - runtime_memory_updates: - Number(persisted.runtime_memory_updates || 0), - runtime_snapshot: - ( - persisted.runtime_snapshot - && typeof persisted.runtime_snapshot === "object" - ) - ? buildPersistedRuntimeSnapshot( - persisted.runtime_snapshot - ) - : null, - }; - -} - - -function isUsableStableRuntimeSnapshot( - snapshot -) { - - if (!snapshot || typeof snapshot !== "object") { - return false; - } - - const runtimeMemory = - String(snapshot.raw_memory || "").trim(); - - if ( - !runtimeMemory - || runtimeMemory === defaultRuntimeMemoryText - || snapshot.display_source === "default_runtime_memory" - || snapshot.display_source === "browser_l3_restore_status" - || snapshot.display_source === "l3_bootstrap_status" - ) { - return false; - } - - if (runtimeSnapshotLooksLikeSessionSaveResult(snapshot)) { - return false; - } - - return true; - -} - - -function rememberStableRuntimeSnapshot( - snapshot -) { - - if (!isUsableStableRuntimeSnapshot(snapshot)) { - return; - } - - lastStableRuntimeMemorySnapshot = { - ...snapshot, - }; - -} - - -function getLatestStableRuntimeMemoryObject() { - - const snapshots = - runtimeMemoryHistory.snapshots || []; - - for (let index = snapshots.length - 1; index >= 0; index -= 1) { - const candidate = snapshots[index]; - - if (!isUsableStableRuntimeSnapshot(candidate)) { - continue; - } - - const runtimeMemory = - runtimeMemoryObjectFromSnapshot(candidate); - - if (runtimeMemory) { - return runtimeMemory; - } - } - - const rememberedRuntimeMemory = - runtimeMemoryObjectFromSnapshot( - lastStableRuntimeMemorySnapshot - ); - - if (rememberedRuntimeMemory) { - return rememberedRuntimeMemory; - } - - const persistedRuntimeMemory = - runtimeMemoryObjectFromPersistedRuntime( - readBrowserMemory(latestRuntimeMemoryStorageKey) - ); - - if (persistedRuntimeMemory) { - return persistedRuntimeMemory; - } - - return null; - -} - - -function getRuntimeSnapshotSearchText( - snapshot -) { - - if (!snapshot || typeof snapshot !== "object") { - return ""; - } - - const parts = [ - snapshot.raw_memory, - snapshot.memory, - snapshot.current_request, - snapshot.user_query, - snapshot.last_jin_response, - snapshot.display_source, - ]; - - if (Array.isArray(snapshot.lines)) { - snapshot.lines.forEach(line => { - if (!line || typeof line !== "object") { - return; - } - - parts.push( - line.key, - line.value - ); - }); - } - - return parts - .filter(Boolean) - .map(part => String(part)) - .join("\n") - .toLowerCase(); - -} - - -function normalizeBehaviorContractSearchText( - text -) { - - return String(text || "") - .toLowerCase() - .replace(/ё/g, "е"); - -} - - -function getBehaviorContractActionGuardPhrases( - name, - key -) { - - const contract = - window.JIN_BEHAVIOR_CONTRACT; - - const guard = - contract - && contract.action_guards - && contract.action_guards[name]; - - const phrases = - guard - && guard[key]; - - if (!Array.isArray(phrases)) { - return []; - } - - return phrases - .filter(phrase => typeof phrase === "string"); - -} - - -function behaviorContractPhraseAppears( - text, - name, - key -) { - - const normalizedText = - normalizeBehaviorContractSearchText( - text - ); - - return getBehaviorContractActionGuardPhrases( - name, - key - ).some(phrase => ( - normalizedText.includes( - normalizeBehaviorContractSearchText( - phrase - ) - ) - )); - -} - - -function runtimeTextLooksLikeOnlySessionSave( - text -) { - - const runtimeMemory = - String(text || "").toLowerCase(); - - if (!runtimeMemory.trim()) { - return false; - } - - const hasSessionWord = - runtimeMemory.includes("session") - || runtimeMemory.includes("сесси"); - - const hasSaveWord = - runtimeMemory.includes("save") - || runtimeMemory.includes("saved") - || runtimeMemory.includes("saving") - || runtimeMemory.includes("remembering") - || runtimeMemory.includes("remember_session") - || runtimeMemory.includes("сохран") - || runtimeMemory.includes("запомн"); - - return hasSessionWord && hasSaveWord; - -} - - -function runtimeSnapshotHasConversationContext( - snapshot -) { - - if (!snapshot || typeof snapshot !== "object") { - return false; - } - - const usefulKeys = new Set([ - "active_task", - "current_focus", - "current_request", - "focus", - "last_jin_response", - "topic", - "user_inquiry", - "user_request", - ]); - - if (!Array.isArray(snapshot.lines)) { - return false; - } - - return snapshot.lines.some(line => { - if (!line || typeof line !== "object") { - return false; - } - - const key = - String(line.key || "") - .trim() - .toLowerCase(); - - const value = - String(line.value || "") - .trim(); - - if (!value || !usefulKeys.has(key)) { - return false; - } - - return !runtimeTextLooksLikeOnlySessionSave( - value - ); - }); - -} - - -function runtimeSnapshotLooksLikeSessionSaveResult( - snapshot -) { - - const runtimeMemory = - getRuntimeSnapshotSearchText( - snapshot - ); - - if (!runtimeMemory) { - return false; - } - - if ( - runtimeMemory.includes("session management") - && runtimeMemory.includes("paused") - ) { - return false; - } - - const hasSessionWord = - runtimeMemory.includes("session") - || runtimeMemory.includes("сесси"); - - const hasSaveWord = - runtimeMemory.includes("save") - || runtimeMemory.includes("saved") - || runtimeMemory.includes("saving") - || runtimeMemory.includes("remembering") - || runtimeMemory.includes("remember_session") - || runtimeMemory.includes("сохран"); - - const hasRememberSessionTrigger = - behaviorContractPhraseAppears( - runtimeMemory, - "remember_session", - "triggers" - ); - - const hasSaveResultPhrase = ( - runtimeMemory.includes("session saved") - || runtimeMemory.includes("session state successfully saved") - || runtimeMemory.includes("session state saved") - || runtimeMemory.includes("current state is saved") - || runtimeMemory.includes("state is saved") - || runtimeMemory.includes("state saved") - || runtimeMemory.includes("successfully saved") - || runtimeMemory.includes("confirmed saving") - || runtimeMemory.includes("confirmed saved") - || runtimeMemory.includes("remembering this session") - || runtimeMemory.includes("remember_session") - || hasRememberSessionTrigger - || runtimeMemory.includes("сохраняю") - || runtimeMemory.includes("сохранено") - || runtimeMemory.includes("сессия сохран") - ); - - if ( - hasSaveResultPhrase - || ( - hasSessionWord - && hasSaveWord - ) - ) { - // Do not throw away a real L1 runtime page just because the last - // turn also saved the session. The page after a save request may - // still contain the useful current context: previous user request, - // active task, and last non-save JIN response. Only pure save-status - // pages should be treated as save chatter. - return !runtimeSnapshotHasConversationContext( - snapshot - ); - } - - return false; - -} - - -function getRuntimeMemoryForSessionSave() { - - const pendingRuntimeMemory = - runtimeMemoryObjectFromSnapshot( - pendingSessionSaveRuntimeMemorySnapshot - ); - - if (pendingRuntimeMemory) { - return pendingRuntimeMemory; - } - - const stableRuntimeMemory = - getLatestStableRuntimeMemoryObject(); - - if (stableRuntimeMemory) { - return stableRuntimeMemory; - } - - return runtimeMemoryObjectFromPersistedRuntime( - readBrowserMemory(latestRuntimeMemoryStorageKey) - ); - -} - - -function userMessageLooksLikeSessionSaveRequest( - text -) { - - const normalizedText = - String(text || "").toLowerCase(); - - if (!normalizedText.trim()) { - return false; - } - - const hasSessionWord = - normalizedText.includes("session") - || normalizedText.includes("сесси"); - - const hasSaveWord = - normalizedText.includes("save") - || normalizedText.includes("remember") - || normalizedText.includes("сохран") - || normalizedText.includes("запомн"); - - return hasSessionWord && hasSaveWord; - -} - - -window.prepareRuntimeMemoryForUserMessage = function ( - text -) { - - if (!userMessageLooksLikeSessionSaveRequest(text)) { - return; - } - - pendingSessionSaveRuntimeMemorySnapshot = null; - waitingForSessionSaveRuntimeSnapshot = true; - pendingSessionMemoryPersistData = null; - -}; - - -function persistSessionMemory( - data -) { - - if ( - !data - || data.persist !== true - ) { - return; - } - - if ( - waitingForSessionSaveRuntimeSnapshot - && !pendingSessionSaveRuntimeMemorySnapshot - ) { - pendingSessionMemoryPersistData = data; - return; - } - - const sessionMemory = - ( - data.memory - || "" - ).trim(); - - const eventSnapshots = - Array.isArray(data.event_snapshots) - ? data.event_snapshots - : []; - - if (!sessionMemory) { - if (!eventSnapshots.length) { - return; - } - } - - const latestSavedRuntimeMemory = - getRuntimeMemoryForSessionSave(); - - const savedAt = - new Date().toISOString(); - - persistedSessionBootstrapCleared = false; - hasUnsavedSessionActivity = false; - - writeBrowserMemory( - latestSavedSessionMemoryStorageKey, - { - version: 1, - explicit_save: true, - saved_at: savedAt, - session_memory: sessionMemory, - session_event_snapshots: eventSnapshots, - session_memory_updates: - data.updates || 0, - } - ); - - writeBrowserMemory( - latestSavedRuntimeMemoryStorageKey, - { - version: 1, - explicit_save: true, - saved_at: savedAt, - runtime_memory: - ( - latestSavedRuntimeMemory - && latestSavedRuntimeMemory.runtime_memory - ) || "", - runtime_memory_updates: - ( - latestSavedRuntimeMemory - && latestSavedRuntimeMemory.runtime_memory_updates - ) || 0, - runtime_snapshot: - buildPersistedRuntimeSnapshot( - latestSavedRuntimeMemory - && latestSavedRuntimeMemory.runtime_snapshot - ), - } - ); - - pendingSessionSaveRuntimeMemorySnapshot = null; - waitingForSessionSaveRuntimeSnapshot = false; - pendingSessionMemoryPersistData = null; - -} - - -function getRuntimeMemoryForSoftReconnect() { - - return getRuntimeMemoryForSessionSave(); - -} - - -function captureSessionSaveRuntimeSnapshot( - snapshot -) { - - if ( - !waitingForSessionSaveRuntimeSnapshot - || !snapshot - ) { - return; - } - - pendingSessionSaveRuntimeMemorySnapshot = snapshot; - - if (pendingSessionMemoryPersistData) { - const data = pendingSessionMemoryPersistData; - pendingSessionMemoryPersistData = null; - persistSessionMemory( - data - ); - } - -} - - -window.getSoftReconnectRuntimeResume = function () { - - const runtimeMemory = - getRuntimeMemoryForSoftReconnect(); - - const runtimeText = - ( - runtimeMemory - && runtimeMemory.runtime_memory - && String(runtimeMemory.runtime_memory).trim() - ) || ""; - - if (!runtimeText) { - return null; - } - - return { - type: "runtime_resume", - runtime_memory: runtimeText, - runtime_memory_updates: - ( - runtimeMemory - && runtimeMemory.runtime_memory_updates - ) || 0, - runtime_snapshot: - ( - runtimeMemory - && runtimeMemory.runtime_snapshot - ) || null, - }; - -}; - - -function hasTabCloseSessionBootstrap() { - - if (persistedSessionBootstrapCleared) { - return false; - } - - return hasUnsavedSessionActivity; - -} - - -function isReconnectInitialRuntimeMemoryUpdate( - data -) { - - if ( - !data - || !data.snapshot - ) { - return false; - } - - if (Number(data.updates || 0) !== 0) { - return false; - } - - if (runtimeMemoryHistory.snapshots.length === 0) { - return false; - } - - const runtimeMemory = - ( - data.snapshot.raw_memory - || data.memory - || "" - ).trim(); - - return runtimeMemory === defaultRuntimeMemoryText; - -} - - -function normalizeRuntimeMemoryText( - text -) { - - return String(text || "") - .replace(/\\n/g, "\n") - .replace(/\r\n/g, "\n") - .replace( - /(session_status\s*:\s*Active;\s*last updated at\s*)[^\n]+/gi, - "$1<bootstrap_time>" - ) - .split("\n") - .map(line => line.trim()) - .filter(Boolean) - .join("\n"); - -} - - -function getRuntimeMemoryTextFromUpdate( - data -) { - - return normalizeRuntimeMemoryText( - ( - data - && data.snapshot - && data.snapshot.raw_memory - ) - || ( - data - && data.memory - ) - || "" - ); - -} - - -function isLatestRuntimeMemoryDuplicate( - data -) { - - if ( - !data - || data.type !== "runtime_memory_update" - || !runtimeMemoryHistory.snapshots.length - ) { - return false; - } - - const latestSnapshot = - runtimeMemoryHistory.snapshots[ - runtimeMemoryHistory.snapshots.length - 1 - ]; - - const latestMemory = normalizeRuntimeMemoryText( - latestSnapshot && latestSnapshot.raw_memory - ); - - const incomingMemory = - getRuntimeMemoryTextFromUpdate(data); - - if ( - latestSnapshot - && latestSnapshot.restored_from_session_save - && Number(data.updates || 0) === 0 - ) { - return true; - } - - if ( - !latestMemory - || !incomingMemory - || latestMemory !== incomingMemory - ) { - return false; - } - - // If the latest snapshot was restored from a previous session its - // runtime_memory_updates counter belongs to that old session. The server - // resets its counter to 0 on every new connection, so the first real L1 - // update (updates=1) is always <= the old session counter (e.g. 3). - // Without this guard every post-bootstrap L1 update is incorrectly treated - // as a duplicate and dropped, leaving the panel stuck on the restore placeholder. - if (latestSnapshot && latestSnapshot.restored_from_session_save) { - return false; - } - - const latestUpdates = Number( - ( - latestSnapshot - && latestSnapshot.runtime_memory_updates - ) || 0 - ); - - const incomingUpdates = Number( - data.updates || 0 - ); - - return incomingUpdates <= latestUpdates; - -} - - -function isBootstrapRuntimeMemoryDuplicate( - data -) { - - if ( - !pendingBootstrapRuntimeMemorySnapshot - || !data - || data.type !== "runtime_memory_update" - ) { - return false; - } - - const bootstrapMemory = - normalizeRuntimeMemoryText( - pendingBootstrapRuntimeMemorySnapshot.raw_memory - ); - - const incomingMemory = - getRuntimeMemoryTextFromUpdate( - data - ); - - if ( - !bootstrapMemory - || !incomingMemory - || bootstrapMemory !== incomingMemory - ) { - pendingBootstrapRuntimeMemorySnapshot = null; - return false; - } - - const bootstrapUpdates = - Number( - pendingBootstrapRuntimeMemorySnapshot.runtime_memory_updates || 0 - ); - - const incomingUpdates = - Number(data.updates || 0); - - if ( - incomingUpdates <= bootstrapUpdates - || !hasUnsavedSessionActivity - ) { - pendingBootstrapRuntimeMemorySnapshot = null; - return true; - } - - pendingBootstrapRuntimeMemorySnapshot = null; - return false; - -} - - -function applyBootstrapRuntimeMemoryUpdate( - data -) { - - if ( - !pendingBootstrapRuntimeMemorySnapshot - || !data - || data.type !== "runtime_memory_update" - || Number(data.updates || 0) !== 0 - || !data.snapshot - ) { - return false; - } - - const savedRuntimeSnapshot = { - ...pendingBootstrapRuntimeMemorySnapshot, - index: 0, - }; - - pendingBootstrapRuntimeMemorySnapshot = null; - runtimeMemoryDisplayMode = "runtime"; - restoredSessionMemorySnapshot = null; - - if (window.stopMemoryGlow) { - window.stopMemoryGlow(); - } - - // During persisted-session restore, page 0 must stay the saved runtime from - // browser memory. Server updates=0 messages are bootstrap chatter/echoes. - runtimeMemoryHistory.snapshots = [ - savedRuntimeSnapshot, - ]; - runtimeMemoryHistory.index = 0; - - if (runtimeMemoryCount) { - runtimeMemoryCount.textContent = - String(savedRuntimeSnapshot.runtime_memory_updates || 0); - } - - renderRuntimeMemorySnapshot(); - - return true; - -} - - -function handleTabCloseSessionBootstrap(event) { - - if (!hasTabCloseSessionBootstrap()) { - return undefined; - } - - event.preventDefault(); - event.returnValue = "Are you sure?"; - - return "Are you sure?"; - -} - - -function splitMemoryTextLines(text) { - - return String(text || "") - .replace(/\\n/g, "\n") - .split(/\r?\n+/) - .map(line => line.trim()) - .filter(Boolean); - -} - - -function normalizeRuntimeMemoryKey(key) { - - return String(key || "") - .trim() - .replace(/\s+/g, "_") - .toLowerCase(); - -} - - -function isUserIdleRuntimeMemoryKey(key) { - - return normalizeRuntimeMemoryKey(key) === "user_idle"; - -} - - -function isUserIdleRuntimeMemoryLine(line) { - - if (!line) { - return false; - } - - if (typeof line === "object") { - return isUserIdleRuntimeMemoryKey(line.key); - } - - const separatorIndex = - String(line).indexOf(":"); - - if (separatorIndex <= 0) { - return false; - } - - return isUserIdleRuntimeMemoryKey( - String(line).slice(0, separatorIndex) - ); - -} - - -function stripUserIdleRuntimeMemoryText(text) { - - return splitMemoryTextLines(text) - .filter(line => !isUserIdleRuntimeMemoryLine(line)) - .join("\n"); - -} - - -function getUserIdleRuntimeMemoryLine(snapshot) { - - if ( - snapshot - && Array.isArray(snapshot.lines) - ) { - return snapshot.lines.find( - line => isUserIdleRuntimeMemoryLine(line) - ) || null; - } - - const rawLine = - splitMemoryTextLines( - snapshot && snapshot.raw_memory - ).find( - line => isUserIdleRuntimeMemoryLine(line) - ); - - return rawLine ? parseRuntimeMemoryLine(rawLine) : null; - -} - - -function runtimeMemoryTextIsDefaultNote(text) { - - const normalized = - String(text || "") - .trim() - .replace(/\s+/g, " ") - .toLowerCase(); - - const defaultNormalized = - defaultRuntimeMemoryText - .trim() - .replace(/\s+/g, " ") - .toLowerCase(); - - return ( - normalized === defaultNormalized - || normalized === `note: ${defaultNormalized}` - ); - -} - - -function attachFirstUserIdleToInitialRuntimeSnapshot(sourceSnapshot) { - - const firstSnapshot = - runtimeMemoryHistory.snapshots[0]; - - if (!firstSnapshot) { - return; - } - - if (getUserIdleRuntimeMemoryLine(firstSnapshot)) { - return; - } - - const firstRawMemory = - String(firstSnapshot.raw_memory || ""); - - if (!runtimeMemoryTextIsDefaultNote(firstRawMemory)) { - return; - } - - const userIdleLine = - getUserIdleRuntimeMemoryLine(sourceSnapshot); - - if (!userIdleLine) { - return; - } - - const nextLine = { - ...userIdleLine, - status: "same", - key_status: "same", - value_status: "same", - }; - - firstSnapshot.lines = [ - ...(Array.isArray(firstSnapshot.lines) - ? firstSnapshot.lines - : splitMemoryTextLines(firstRawMemory) - .map(parseRuntimeMemoryLine)), - nextLine, - ]; - - firstSnapshot.raw_memory = [ - firstRawMemory.trim() || `note: ${defaultRuntimeMemoryText}`, - `user_idle: ${nextLine.value || ""}`.trim(), - ].filter(Boolean).join("\n"); - -} - - -function parseRuntimeMemoryLine(line) { - - const separatorIndex = - line.indexOf(":"); - - if (separatorIndex <= 0) { - return { - key: "session memory", - value: line, - status: "same", - key_status: "same", - value_status: "same", - key_change_ratio: 0, - value_change_ratio: 0, - }; - } - - return { - key: line.slice(0, separatorIndex).trim(), - value: line.slice(separatorIndex + 1).trim(), - status: "same", - key_status: "same", - value_status: "same", - key_change_ratio: 0, - value_change_ratio: 0, - }; - -} - - -const runtimeResponseFeedbackKey = - "JIN_LAST_RESPONSE_USER_FEEDBACK"; - -const runtimeResponseFeedbackDislikedValue = - "User disliked your last response. " - + "Before answering, find and understand why it failed using context or memory, then start the next reply with a brief acknowledgement of that miss, then continue with a concrete corrected answer."; - -const runtimeResponseFeedbackNeutralValue = - "User gave neutral feedback to your last response. " - + "Continue carefully without changing course too much."; - -const runtimeResponseFeedbackLikedValue = - "User liked your last response. " - + "Keep the current direction."; - -const runtimeResponseFeedbackRatings = { - disliked: "disliked", - neutral: "neutral", - liked: "liked", -}; - -// UI buttons can still use visual button names. Convert them only at the -// browser event boundary. Runtime memory and server payloads stay canonical: -// disliked / neutral / liked. -const runtimeResponseFeedbackButtonRatings = { - minus: "disliked", - zero: "neutral", - plus: "liked", -}; - -let pendingRuntimeResponseFeedback = null; -let runtimeResponseFeedbackCommitted = false; - - -const jinAnswerRatingL1Gate = { - generation: 0, - waiting: false, - waitingGeneration: 0, - readyGeneration: 0, - baselineUpdates: 0, - // All gate generations strictly below this value are permanently locked: - // the user has already submitted a subsequent message, so retroactive - // ratings for those turns must be silently dropped. - lockedBelowGeneration: 0, -}; - -function getLatestRuntimeMemoryUpdatesForRatingGate() { - - const latestSnapshot = - runtimeMemoryHistory.snapshots.length - ? runtimeMemoryHistory.snapshots[ - runtimeMemoryHistory.snapshots.length - 1 - ] - : null; - - if (latestSnapshot && latestSnapshot.restored_from_session_save) { - return 0; - } - - return Number( - ( - latestSnapshot - && latestSnapshot.runtime_memory_updates - ) - || ( - runtimeMemoryCount - && runtimeMemoryCount.textContent - ) - || 0 - ); - -} - -function markJinAnswerRatingL1ReadyFromRuntimeUpdate( - data, - snapshotIndex = null -) { - - if (!jinAnswerRatingL1Gate.waiting) { - return; - } - - const incomingUpdates = Number( - data && data.updates || 0 - ); - - if (incomingUpdates <= jinAnswerRatingL1Gate.baselineUpdates) { - return; - } - - jinAnswerRatingL1Gate.waiting = false; - jinAnswerRatingL1Gate.readyGeneration = - jinAnswerRatingL1Gate.waitingGeneration; - runtimeResponseFeedbackCommitted = false; - - const rawSnapshotIndex = - snapshotIndex === undefined - ? null - : snapshotIndex; - - const numericSnapshotIndex = Number( - rawSnapshotIndex - ); - - const resolvedSnapshotIndex = ( - rawSnapshotIndex !== null - && rawSnapshotIndex !== "" - && Number.isInteger(numericSnapshotIndex) - ) - ? numericSnapshotIndex - : null; - - document - .querySelectorAll( - ".jin-chat-bubble-service[data-rating-gate-generation]" - ) - .forEach((bubble) => { - if ( - Number(bubble.dataset.ratingGateGeneration || 0) - === jinAnswerRatingL1Gate.readyGeneration - ) { - bubble.dataset.ratingL1Ready = "true"; - - if (resolvedSnapshotIndex !== null) { - bubble.dataset.runtimeSnapshotIndex = - String(resolvedSnapshotIndex); - } - - bubble.classList.remove( - "jin-rating-l1-waiting" - ); - } - }); - - window.dispatchEvent( - new CustomEvent( - "jin:l1-rating-gate-ready", - { - detail: { - generation: jinAnswerRatingL1Gate.readyGeneration, - updates: incomingUpdates, - snapshotIndex: resolvedSnapshotIndex, - }, - } - ) - ); - -} - -window.startJinAnswerRatingL1GateForTurn = function () { - - jinAnswerRatingL1Gate.generation += 1; - jinAnswerRatingL1Gate.waiting = true; - jinAnswerRatingL1Gate.waitingGeneration = - jinAnswerRatingL1Gate.generation; - jinAnswerRatingL1Gate.baselineUpdates = - getLatestRuntimeMemoryUpdatesForRatingGate(); - - // Hard-lock every bubble that belongs to a generation older than the one - // we are about to start. This is the authoritative guard: the user has - // just sent a new message, so rating any previous assistant turn is no - // longer valid regardless of the committed/waiting state of the feedback - // flags. - jinAnswerRatingL1Gate.lockedBelowGeneration = - jinAnswerRatingL1Gate.generation; - - if (typeof document !== "undefined") { - document - .querySelectorAll( - ".jin-chat-bubble-service[data-rating-gate-generation]" - ) - .forEach((bubble) => { - const bubbleGen = Number(bubble.dataset.ratingGateGeneration || 0); - if (bubbleGen < jinAnswerRatingL1Gate.lockedBelowGeneration) { - bubble.classList.remove("jin-rating-selected-active"); - bubble.classList.add("jin-rating-committed"); - bubble.dataset.ratingCommitted = "true"; - bubble.dataset.ratingPastTurn = "true"; - } - }); - } - - return { - generation: jinAnswerRatingL1Gate.waitingGeneration, - baselineUpdates: jinAnswerRatingL1Gate.baselineUpdates, - }; - -}; - -window.getJinAnswerRatingL1GateState = function () { - - return { - ...jinAnswerRatingL1Gate, - }; - -}; - -window.isJinAnswerRatingReadyForGateGeneration = function ( - generation -) { - - const gateGeneration = Number(generation || 0); - - if (!gateGeneration) { - return !jinAnswerRatingL1Gate.waiting; - } - - return gateGeneration === jinAnswerRatingL1Gate.readyGeneration; - -}; - -function normalizeRuntimeResponseFeedbackRating( - rating -) { - - const rawRating = - String(rating || "").trim().toLowerCase(); - - return ( - runtimeResponseFeedbackRatings[rawRating] - || runtimeResponseFeedbackButtonRatings[rawRating] - || null - ); - -} - - -function buildRuntimeResponseFeedbackValue( - feedback -) { - - if (feedback.rating === "disliked") { - return runtimeResponseFeedbackDislikedValue; - } - - if (feedback.rating === "liked") { - return runtimeResponseFeedbackLikedValue; - } - - return runtimeResponseFeedbackNeutralValue; - -} - - -function removeRuntimeMemoryLineByKey( - memory, - key -) { - - const normalizedKey = - String(key || "").trim().toLowerCase(); - - return String(memory || "") - .split(/\r?\n/) - .map(line => line.trim()) - .filter(Boolean) - .filter((line) => { - const lineKey = - line.split(":", 1)[0].trim().toLowerCase(); - - return lineKey !== normalizedKey; - }) - .join("\n") - .trim(); - -} - - -function upsertRuntimeMemoryLine( - memory, - key, - value -) { - - const cleanedMemory = - removeRuntimeMemoryLineByKey( - memory, - key - ); - - return ( - cleanedMemory - ? `${cleanedMemory}\n${key}: ${value}` - : `${key}: ${value}` - ).trim(); - -} - - -// In-place rating mutation: rating clicks are part of the current L1 page, -// not a new runtime memory page. -function getCurrentRuntimeSnapshotForFeedbackMutation( - feedback = null -) { - - if (!runtimeMemoryHistory.snapshots.length) { - return null; - } - - const rawSnapshotIndex = feedback - ? ( - feedback.runtimeSnapshotIndex - ?? feedback.snapshotIndex - ?? null - ) - : null; - - const explicitSnapshotIndex = Number( - rawSnapshotIndex - ); - - if ( - rawSnapshotIndex !== null - && rawSnapshotIndex !== "" - && Number.isInteger(explicitSnapshotIndex) - ) { - const explicitSnapshot = - runtimeMemoryHistory.snapshots[explicitSnapshotIndex]; - - if (explicitSnapshot) { - return explicitSnapshot; - } - } - - const current = - runtimeMemoryHistory.snapshots[runtimeMemoryHistory.index]; - - if (current && runtimeMemoryDisplayMode === "runtime") { - return current; - } - - return runtimeMemoryHistory.snapshots[ - runtimeMemoryHistory.snapshots.length - 1 - ]; - -} - - -function getRuntimeFeedbackLineIdentity(line) { - - const key = - String(line && line.key || "") - .trim() - .toLowerCase(); - - const value = - String(line && line.value || "") - .trim(); - - return `${key}\u0000${value}`; - -} - - -function buildPreviousRuntimeFeedbackLineMaps(snapshot) { - - const byIdentity = new Map(); - const byKey = new Map(); - - (snapshot && snapshot.lines || []) - .forEach((line) => { - if (!line || !line.key) { - return; - } - - const key = - String(line.key || "") - .trim() - .toLowerCase(); - - if (!key) { - return; - } - - byKey.set(key, line); - byIdentity.set( - getRuntimeFeedbackLineIdentity(line), - line - ); - }); - - return { - byIdentity, - byKey, - }; - -} - - -function preserveRuntimeFeedbackLineDiff( - parsed, - previousMaps -) { - - if (!parsed || !parsed.key) { - return parsed; - } - - const key = - String(parsed.key || "") - .trim() - .toLowerCase(); - - if (key === runtimeResponseFeedbackKey.toLowerCase()) { - return { - ...parsed, - status: "changed", - key_status: "same", - value_status: "changed", - value_change_ratio: 1, - strength: 1, - }; - } - - const previousExact = previousMaps.byIdentity.get( - getRuntimeFeedbackLineIdentity(parsed) - ); - - if (previousExact) { - return { - ...previousExact, - key: parsed.key, - value: parsed.value, - }; - } - - const previousByKey = previousMaps.byKey.get(key); - - if (previousByKey) { - return { - ...parsed, - status: previousByKey.status || parsed.status, - key_status: previousByKey.key_status || parsed.key_status, - value_status: previousByKey.value_status || parsed.value_status, - key_change_ratio: - previousByKey.key_change_ratio - ?? parsed.key_change_ratio, - value_change_ratio: - previousByKey.value_change_ratio - ?? parsed.value_change_ratio, - strength: - previousByKey.strength - ?? parsed.strength, - }; - } - - return parsed; - -} - - -function rebuildRuntimeFeedbackSnapshotLines( - snapshot, - runtimeMemory -) { - - const previousMaps = - buildPreviousRuntimeFeedbackLineMaps(snapshot); - - return splitMemoryTextLines(runtimeMemory) - .map((line) => ( - preserveRuntimeFeedbackLineDiff( - parseRuntimeMemoryLine(line), - previousMaps - ) - )); - -} - - -function applyRuntimeResponseFeedbackToCurrentSnapshot( - feedback -) { - - const snapshot = - getCurrentRuntimeSnapshotForFeedbackMutation( - feedback - ); - - if (!snapshot) { - return null; - } - - const currentMemory = - String(snapshot.raw_memory || "").trim(); - - if (!currentMemory) { - return null; - } - - let cleanedMemory = - removeRuntimeMemoryLineByKey( - currentMemory, - runtimeResponseFeedbackKey - ); - - const nextMemory = - feedback && feedback.rating - ? upsertRuntimeMemoryLine( - cleanedMemory, - runtimeResponseFeedbackKey, - buildRuntimeResponseFeedbackValue(feedback) - ) - : cleanedMemory; - - snapshot.raw_memory = nextMemory; - snapshot.lines = rebuildRuntimeFeedbackSnapshotLines( - snapshot, - nextMemory - ); - snapshot.client_feedback = ( - feedback && feedback.rating - ) - ? feedback - : null; - snapshot.local_feedback_mutation = Boolean( - feedback && feedback.rating - ); - - runtimeMemoryDisplayMode = "runtime"; - runtimeMemoryHistory.index = - runtimeMemoryHistory.snapshots.indexOf(snapshot); - - if (runtimeMemoryHistory.index < 0) { - runtimeMemoryHistory.index = - runtimeMemoryHistory.snapshots.length - 1; - } - - // Feedback is a transient one-turn alert for the next JIN response. - // Keep it visible in the current UI snapshot, but do not save it as - // stable runtime memory and do not persist it to localStorage. - renderRuntimeMemorySnapshot(); - - return snapshot; - -} - - -window.recordJinAnswerRating = function ( - detail -) { - - if (runtimeResponseFeedbackCommitted) { - return null; - } - - // Generation guard: reject ratings for bubbles that belong to a turn - // older than the one currently awaiting a response. The user already - // submitted a newer message, so mutating an earlier snapshot would - // inject stale feedback into the wrong memory slice. - const incomingGeneration = Number( - detail && detail.ratingGateGeneration || 0 - ); - if ( - incomingGeneration > 0 - && incomingGeneration < jinAnswerRatingL1Gate.lockedBelowGeneration - ) { - return null; - } - - const rating = - normalizeRuntimeResponseFeedbackRating( - detail && detail.rating - ); - - if (!rating) { - return null; - } - - pendingRuntimeResponseFeedback = { - rating, - runtimeSnapshotIndex: - detail && detail.runtimeSnapshotIndex !== undefined - ? detail.runtimeSnapshotIndex - : null, - }; - - return applyRuntimeResponseFeedbackToCurrentSnapshot( - pendingRuntimeResponseFeedback - ); - -}; - - -window.clearJinAnswerRating = function ( - detail = null -) { - - if (runtimeResponseFeedbackCommitted) { - return null; - } - - pendingRuntimeResponseFeedback = null; - - return applyRuntimeResponseFeedbackToCurrentSnapshot({ - runtimeSnapshotIndex: - detail && detail.runtimeSnapshotIndex !== undefined - ? detail.runtimeSnapshotIndex - : null, - }); - -}; - - -window.getJinAnswerRatingForRuntime = function () { - - return pendingRuntimeResponseFeedback - ? { ...pendingRuntimeResponseFeedback } - : null; - -}; - - -window.consumePendingLastResponseRating = function () { - - const value = pendingRuntimeResponseFeedback - ? { ...pendingRuntimeResponseFeedback } - : null; - - pendingRuntimeResponseFeedback = null; - - if (value) { - runtimeResponseFeedbackCommitted = true; - } - - return value; - -}; - - -function buildRuntimeMemoryDisplaySnapshot( - data -) { - - const runtimeMemory = - String( - ( - data - && ( - data.runtime_memory - || data.memory - || ( - data.runtime_snapshot - && data.runtime_snapshot.raw_memory - ) - ) - ) - || "" - ).trim(); - - if (!runtimeMemory) { - return null; - } - - const sourceSnapshot = - ( - data - && data.runtime_snapshot - && typeof data.runtime_snapshot === "object" - ) - ? data.runtime_snapshot - : {}; - - return { - ...sourceSnapshot, - session_id: - sourceSnapshot.session_id - || "browser_restore", - index: 0, - display_source: "saved_runtime_at_session_save", - raw_memory: runtimeMemory, - lines: - Array.isArray(sourceSnapshot.lines) - && sourceSnapshot.raw_memory === runtimeMemory - ? sourceSnapshot.lines - : splitMemoryTextLines(runtimeMemory) - .map(parseRuntimeMemoryLine), - restored_from_session_save: true, - runtime_memory_updates: - Number( - ( - data - && ( - data.runtime_memory_updates - || data.updates - ) - ) - || 0 - ), - }; - -} - - -function buildDefaultRuntimeMemorySnapshot() { - - return { - session_id: "browser_restore", - index: 0, - display_source: "default_runtime_memory", - raw_memory: sessionStartedRuntimeMemoryText, - lines: [ - { - key: "session_status", - value: "Session started", - status: "same", - key_status: "same", - value_status: "same", - key_change_ratio: 0, - value_change_ratio: 0, - }, - ], - runtime_memory_updates: 0, - }; - -} - - -function applyRuntimeMemoryDisplaySnapshot(snapshot) { - - const displaySnapshot = - snapshot || buildDefaultRuntimeMemorySnapshot(); - - runtimeMemoryDisplayMode = "runtime"; - restoredSessionMemorySnapshot = null; - pendingBootstrapRuntimeMemorySnapshot = - displaySnapshot.restored_from_session_save - ? displaySnapshot - : null; - runtimeMemoryHistory.snapshots = [displaySnapshot]; - runtimeMemoryHistory.index = 0; - - rememberStableRuntimeSnapshot( - displaySnapshot - ); - - if (runtimeMemoryCount) { - runtimeMemoryCount.textContent = - String(displaySnapshot.runtime_memory_updates || 0); - } - - renderRuntimeMemorySnapshot(); - -} - - -window.applyPersistedSessionBootstrap = function (bootstrap) { - - const snapshot = - ( - bootstrap - && bootstrap.runtime_display_snapshot - ) - || buildRuntimeMemoryDisplaySnapshot( - bootstrap || {} - ) - || buildDefaultRuntimeMemorySnapshot(); - - applyRuntimeMemoryDisplaySnapshot( - snapshot - ); - -}; - -window.getPersistedSessionBootstrap = function () { - - const savedRuntimeFallback = - getSavedRuntimeMemoryFallback(); - - const shouldUseBrowserMemory = - !savedRuntimeFallback; - - const browserLatestSavedSessionMemory = - shouldUseBrowserMemory - ? readBrowserMemory( - latestSavedSessionMemoryStorageKey - ) - : null; - - const sessionMemory = - ( - savedRuntimeFallback - && savedRuntimeFallback.session_memory - ) - || ( - browserLatestSavedSessionMemory - && browserLatestSavedSessionMemory.explicit_save === true - ? browserLatestSavedSessionMemory - : null - ); - - if ( - !sessionMemory - || sessionMemory.explicit_save !== true - ) { - return null; - } - - const sessionMemorySource = - ( - savedRuntimeFallback - && savedRuntimeFallback.session_memory - ) - ? savedRuntimeFallback.source - : ( - browserLatestSavedSessionMemory - && browserLatestSavedSessionMemory.explicit_save === true - ? "browser_localStorage" - : "unknown" - ); - - const sessionText = - ( - sessionMemory - && sessionMemory.explicit_save === true - && sessionMemory.session_memory - ) - || ""; - - const browserLatestSavedRuntimeMemory = - shouldUseBrowserMemory - ? readBrowserMemory( - latestSavedRuntimeMemoryStorageKey - ) - : null; - - const latestSavedRuntimeMemory = - ( - savedRuntimeFallback - && savedRuntimeFallback.latest_saved_runtime_memory - ) - || ( - browserLatestSavedRuntimeMemory - && browserLatestSavedRuntimeMemory.explicit_save === true - ? browserLatestSavedRuntimeMemory - : null - ); - - const legacyEmbeddedRuntimeMemory = - ( - sessionMemory - && ( - sessionMemory.runtime_memory - || sessionMemory.runtime_snapshot - ) - ) - ? sessionMemory - : null; - - const runtimeMemory = - ( - latestSavedRuntimeMemory - && latestSavedRuntimeMemory.explicit_save === true - ) - ? latestSavedRuntimeMemory - : legacyEmbeddedRuntimeMemory; - - const runtimeText = - ( - runtimeMemory - && runtimeMemory.runtime_memory - ) - || ""; - - const eventSnapshots = - ( - sessionMemory - && Array.isArray( - sessionMemory.session_event_snapshots - ) - && sessionMemory.session_event_snapshots - ) - || []; - - if ( - !sessionText - && !eventSnapshots.length - ) { - return null; - } - - const runtimeDisplaySnapshot = - buildRuntimeMemoryDisplaySnapshot({ - runtime_memory: runtimeText, - runtime_memory_updates: - ( - runtimeMemory - && runtimeMemory.runtime_memory_updates - ) - || 0, - runtime_snapshot: - ( - runtimeMemory - && runtimeMemory.runtime_snapshot - ) - || null, - }) || buildDefaultRuntimeMemorySnapshot(); - - return { - type: "session_bootstrap", - session_memory: sessionText, - session_memory_source: sessionMemorySource, - session_memory_updates: - ( - sessionMemory - && sessionMemory.session_memory_updates - ) - || 0, - session_event_snapshots: eventSnapshots, - runtime_memory: runtimeText, - runtime_memory_updates: - ( - runtimeMemory - && runtimeMemory.runtime_memory_updates - ) - || 0, - runtime_snapshot: - ( - runtimeMemory - && runtimeMemory.runtime_snapshot - ) - || null, - runtime_display_snapshot: runtimeDisplaySnapshot, - }; - -}; - - -window.clearPersistedSessionBootstrap = function () { - - persistedSessionBootstrapCleared = true; - hasUnsavedSessionActivity = false; - - try { - window.localStorage.removeItem( - latestSavedSessionMemoryStorageKey - ); - window.localStorage.removeItem( - latestSavedRuntimeMemoryStorageKey - ); - window.localStorage.removeItem( - latestRuntimeMemoryStorageKey - ); - } catch (error) { - // Browser memory is helpful, not required for chat. - } - -}; - - -window.markSessionActivityDirty = function () { - - persistedSessionBootstrapCleared = false; - hasUnsavedSessionActivity = true; - -}; - - -window.markSessionBootstrapActive = - window.markSessionActivityDirty; - - -window.addEventListener( - "beforeunload", - handleTabCloseSessionBootstrap -); - -function findRuntimeByLabel( - runtimes, - label -) { - - return runtimes.find( - runtime => - runtime - && runtime.label === label - ); - -} - - -function getRuntimeByLabel(label) { - - const liveRuntime = - findRuntimeByLabel( - runtimePanelState.liveRuntimes, - label - ); - - if (liveRuntime) { - return liveRuntime; - } - - return ( - runtimePanelState.fallbackRuntimes[label] - || null - ); - -} - - -function getBrainRuntime() { - - return ( - getRuntimeByLabel("brain") - || ( - runtimePanelState.useServiceAsBrain - ? getRuntimeByLabel("service") - : null - ) - ); - -} - - -function getSummarizerRuntime() { - - return getRuntimeByLabel( - "summarizer" - ); - -} - - -function getSelectedRuntime() { - - if (runtimePanelState.activeTab === "brain") { - return getBrainRuntime(); - } - - return getRuntimeByLabel("service"); - -} - - -function hasRuntimeStatus(role) { - - return typeof ( - runtimePanelState.runtimeStatus[role] - ) === "boolean"; - -} - - -function isRuntimeOnline(role) { - - if (!hasRuntimeStatus(role)) { - return false; - } - - return Boolean( - runtimePanelState.runtimeStatus[role] - ); - -} - - -function isContextTabDisabled(role) { - - return !isRuntimeOnline(role); - -} - - -function formatContextTokens(runtime) { - - /** @type {RuntimeInfo|null} */ - const runtimeInfo = - runtime; - - if (!runtimeInfo) { - return { - used: 0, - max: 0, - }; - } - - return { - used: runtimeInfo.used_tokens || 0, - max: runtimeInfo.max_tokens || 0, - }; - -} - - -function getContextBarCells(barElement) { - - const width = - barElement - ? barElement.clientWidth - : 0; - - if (!width) { - return 24; - } - - return Math.max( - 12, - Math.floor(width / 7) + 3 - ); - -} - -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, - cells -) { - - /** @type {RuntimeInfo|null} */ - const runtimeInfo = - runtime; - - const used = - runtimeInfo - ? Number(runtimeInfo.used_tokens || 0) - : 0; - - const contextUsed = - runtimeInfo - ? Number( - runtimeInfo.context_tokens - || runtimeInfo.used_tokens - || 0 - ) - : 0; - - const totalUsed = - runtimeInfo - ? Math.max( - contextUsed, - Number( - runtimeInfo.total_tokens - || runtimeInfo.used_tokens - || 0 - ) - ) - : 0; - - const max = - runtimeInfo - ? Number(runtimeInfo.max_tokens || 0) - : 0; - - const rawPercent = - max > 0 - ? Math.min( - 100, - (used / max) * 100 - ) - : 0; - - const percent = - Math.round(rawPercent); - - const percentLabel = - used > 0 - && rawPercent < 1 - ? "<1%" - : `${percent}%`; - - const filled = - Math.round( - (rawPercent / 100) * cells - ); - - const contextPercent = - max > 0 - ? Math.min( - 100, - (contextUsed / max) * 100 - ) - : 0; - - const totalPercent = - max > 0 - ? Math.min( - 100, - (totalUsed / max) * 100 - ) - : 0; - - const contextFilled = - Math.min( - cells, - Math.round( - (contextPercent / 100) * cells - ) - ); - - const totalFilled = - Math.min( - cells, - Math.round( - (totalPercent / 100) * cells - ) - ); - - const secondaryFilled = - Math.max( - 0, - totalFilled - contextFilled - ); - - const bar = - "|".repeat(filled) - + ".".repeat(cells - filled); - - return { - percent, - bar: `[${bar}]`, - contextFilled, - secondaryFilled, - emptyFilled: - Math.max( - 0, - cells - totalFilled - ), - contextPercent: - Math.round(contextPercent), - totalPercent: - Math.round(totalPercent), - contextUsed, - totalUsed, - max, - percentLabel, - }; - -} - - -function renderContextBar( - barElement, - contextLine, - pressureColor -) { - - if (!barElement) { - return; - } - - const solid = - "|".repeat( - contextLine.contextFilled - ); - - const secondary = - "|".repeat( - contextLine.secondaryFilled - ); - - const empty = - ".".repeat( - contextLine.emptyFilled - ); - - barElement.innerHTML = - "[" - + `<span style="color: ${pressureColor}; opacity: 1">${solid}</span>` - + `<span style="color: ${pressureColor}; opacity: 0.55">${secondary}</span>` - + `<span style="color: ${pressureColor}; opacity: 0.28">${empty}</span>` - + "]"; - -} - - -function setTabClasses(role) { - - const button = - contextTabButtons[role]; - - if (!button) { - return; - } - - const isActive = - runtimePanelState.activeTab === role; - - const isDisabled = - isContextTabDisabled(role); - - button.disabled = - isDisabled; - - button.setAttribute( - "aria-selected", - String(isActive) - ); - - button.setAttribute( - "aria-disabled", - String(isDisabled) - ); - - if (isDisabled) { - const borderClass = - role === "service" - ? "border-r border-slate-500/70 " - : ""; - - button.className = - "h-8 " - + borderClass - + "text-[11px] font-bold uppercase tracking-widest text-slate-500 cursor-not-allowed"; - - return; - } - - if (isActive && role === "service") { - button.className = - "h-8 border-r border-slate-500/70 bg-slate-600/70 text-[11px] font-bold uppercase tracking-widest text-zinc-50 transition"; - - return; - } - - if (isActive) { - button.className = - "h-8 bg-slate-600/70 text-[11px] font-bold uppercase tracking-widest text-zinc-50 transition"; - - return; - } - - if (role === "service") { - button.className = - "h-8 border-r border-slate-500/70 text-[11px] font-bold uppercase tracking-widest text-slate-300 transition hover:bg-slate-600/50 hover:text-zinc-50"; - - return; - } - - button.className = - "h-8 text-[11px] font-bold uppercase tracking-widest text-slate-300 transition hover:bg-slate-600/50 hover:text-zinc-50"; - -} - - -function setContextPanelRuntime(runtime) { - - if (contextRuntimePanel) { - contextRuntimePanel.classList.toggle( - "hidden", - !runtime - ); - } - - const titleElement = - document.getElementById( - "context-panel-title" - ); - - const modelElement = - document.getElementById( - "context-panel-model" - ); - - const summaryElement = - document.getElementById( - "context-summary-tokens" - ); - - const summaryUsedElement = - document.getElementById( - "context-summary-used" - ); - - const summaryMaxElement = - document.getElementById( - "context-summary-max" - ); - - const lineElement = - document.getElementById( - "context-window-line" - ); - - const barElement = - document.getElementById( - "context-window-bar" - ); - - const percentElement = - document.getElementById( - "context-window-percent" - ); - - const summarizerLineElement = - document.getElementById( - "summarizer-window-line" - ); - - const summarizerBarElement = - document.getElementById( - "summarizer-window-bar" - ); - - const summarizerPercentElement = - document.getElementById( - "summarizer-window-percent" - ); - - const tokenText = - formatContextTokens(runtime); - - const contextLine = - buildContextLine( - runtime, - getContextBarCells( - barElement - ) - ); - - const pressureColor = - getContextPressureColor( - Math.max( - contextLine.percent, - contextLine.totalPercent - ) - ); - - const summarizerRuntime = - getSummarizerRuntime(); - - const summarizerTokenText = - formatContextTokens( - summarizerRuntime - ); - - const summarizerLine = - buildContextLine( - summarizerRuntime, - getContextBarCells( - summarizerBarElement - ) - ); - - const summarizerPressureColor = - getContextPressureColor( - Math.max( - summarizerLine.percent, - summarizerLine.totalPercent - ) - ); - - if (titleElement) { - titleElement.textContent = - `STATUS`; - } - - if (modelElement) { - modelElement.textContent = - `${runtime ? runtime.model : "unknown"}`; - } - - if (summaryElement) { - summaryElement.setAttribute( - "aria-label", - `${tokenText.used} / ${tokenText.max}` - ); - } - - if (summaryUsedElement) { - summaryUsedElement.textContent = - `${tokenText.used}\u00a0/`; - } - - if (summaryMaxElement) { - summaryMaxElement.textContent = - `${tokenText.max}`; - } - - if (lineElement) { - lineElement.title = - `context: ${contextLine.contextUsed} / ${contextLine.max} ` - + `(${contextLine.contextPercent}%), total: ` - + `${contextLine.totalUsed} / ${contextLine.max} ` - + `(${contextLine.totalPercent}%)`; - } - - renderContextBar( - barElement, - contextLine, - pressureColor - ); - - if (percentElement) { - percentElement.textContent = - contextLine.percentLabel; - percentElement.style.color = - pressureColor; - } - - if (summarizerLineElement) { - summarizerLineElement.title = - `context: ${summarizerLine.contextUsed} / ${summarizerLine.max} ` - + `(${summarizerLine.contextPercent}%), total: ` - + `${summarizerLine.totalUsed} / ${summarizerLine.max} ` - + `(${summarizerLine.totalPercent}%)`; - } - - renderContextBar( - summarizerBarElement, - summarizerLine, - summarizerPressureColor - ); - - if (summarizerPercentElement) { - summarizerPercentElement.textContent = - summarizerLine.percentLabel; - summarizerPercentElement.style.color = - summarizerPressureColor; - } - -} - - -function updateChatHeader( - serviceRuntime, - brainRuntime -) { - - const brainModelElement = - document.getElementById( - "brain-model" - ); - - const serviceModelElement = - document.getElementById( - "service-model" - ); - - if ( - brainRuntime - && brainModelElement - ) { - - brainModelElement.textContent = - `BRAIN: ${brainRuntime.model}`; - - } - - if ( - serviceRuntime - && serviceModelElement - ) { - - serviceModelElement.textContent = - `SERVICE: ${serviceRuntime.model}`; - - } - -} - - -function renderRuntimeMemory( - memory, - updates -) { - - if (window.stopMemoryGlow) { - window.stopMemoryGlow(); - } - - if (runtimeMemoryText) { - runtimeMemoryText.textContent = - ( - memory - && memory.trim() - ) - ? memory.trim() - : ""; - } - - if (runtimeMemoryCount) { - runtimeMemoryCount.textContent = - String( - updates || 0 - ); - } - -} - - -function renderContextPanel() { - - if (isContextTabDisabled( - runtimePanelState.activeTab - )) { - - const fallbackTab = - ["brain", "service"].find( - role => !isContextTabDisabled(role) - ); - - if (fallbackTab) { - runtimePanelState.activeTab = - fallbackTab; - } - } - - setTabClasses("service"); - setTabClasses("brain"); - - const selectedRuntime = - isContextTabDisabled( - runtimePanelState.activeTab - ) - ? null - : getSelectedRuntime(); - - setContextPanelRuntime(selectedRuntime); - -} - - -function selectContextTab(role) { - - if (isContextTabDisabled(role)) { - return; - } - - runtimePanelState.activeTab = - role; - - renderContextPanel(); - -} - - -Object.entries( - contextTabButtons -).forEach( - ([role, button]) => { - - if (!button) { - return; - } - - button.addEventListener( - "click", - function () { - selectContextTab( - role - ); - } - ); - - } -); - - -window.addEventListener( - "resize", - function () { - renderContextPanel(); - } -); - - -window.setUseServiceAsBrain = function (enabled) { - - runtimePanelState.useServiceAsBrain = - Boolean(enabled); - - renderContextPanel(); - -}; - - -window.setRuntimeStatusSnapshot = function (runtimeStatus) { - - runtimePanelState.runtimeStatus = - runtimeStatus || {}; - - renderContextPanel(); - -}; - - -window.setRuntimeConfigSnapshot = function (runtimeConfig) { - - runtimePanelState.fallbackRuntimes = - runtimeConfig || {}; - - const serviceRuntime = - getRuntimeByLabel( - "service" - ); - - const brainRuntime = - getBrainRuntime(); - - updateChatHeader( - serviceRuntime, - brainRuntime - ); - - renderContextPanel(); - -}; - - -window.updateRuntimePanelFromStatus = function (data) { - - if (!data) { - return; - } - - window.jinRuntimeConfig = { - useServiceAsBrain: - Boolean( - data.use_service_as_brain - ), - runtimeStatus: { - brain: Boolean(data.brain), - service: Boolean(data.service), - }, - runtimeConfig: - data.runtime_config || {}, - }; - - window.setRuntimeStatusSnapshot( - window - .jinRuntimeConfig - .runtimeStatus - ); - - window.setUseServiceAsBrain( - window - .jinRuntimeConfig - .useServiceAsBrain - ); - - window.setRuntimeConfigSnapshot( - window - .jinRuntimeConfig - .runtimeConfig - ); - -}; - - -window.handleTelemetryMessage = function (data) { - - if (data.type !== "telemetry") { - return; - } - - runtimePanelState.liveRuntimes = - Object.values( - data.runtime || {} - ); - - scheduleRuntimeTelemetryRender(); - -}; - -window.flushRuntimeTelemetryRender = - flushRuntimeTelemetryRender; - - -window.handleRuntimeMemoryMessage = function (data) { - - if (!data) { - return; - } - - if (data.type === "runtime_l1_diff_update") { - runtimeDiffHistory.diffs = - data.diffs || []; - - runtimeDiffHistory.stats = - data.stats || {}; - - renderRuntimeDiffs(); - - return; - } - - if (data.type === "runtime_session_memory_update") { - persistSessionMemory( - data - ); - - if ( - data.persist === true - && window.fadeRuntimeAction - ) { - window.fadeRuntimeAction( - "remember_session" - ); - } - - if (window.stopL3MemoryGlow) { - window.stopL3MemoryGlow(); - } - - return; - } - - if (data.type !== "runtime_memory_update") { - return; - } - - if (isReconnectInitialRuntimeMemoryUpdate(data)) { - return; - } - - if (isLatestRuntimeMemoryDuplicate(data)) { - return; - } - - if (applyBootstrapRuntimeMemoryUpdate(data)) { - return; - } - - if (isBootstrapRuntimeMemoryDuplicate(data)) { - return; - } - - if ( - runtimeMemoryDisplayMode === "session" - && restoredSessionMemorySnapshot - && Number(data.updates || 0) === 0 - ) { - persistRuntimeMemorySnapshot( - data - ); - - return; - } - - runtimeMemoryDisplayMode = "runtime"; - - if (window.stopMemoryGlow) { - window.stopMemoryGlow(); - } - - if (runtimeMemoryCount) { - runtimeMemoryCount.textContent = - String(data.updates || 0); - } - - let clientSnapshot = null; - - if (data.snapshot) { - const clientIndex = runtimeMemoryHistory.snapshots.length; - clientSnapshot = { - ...data.snapshot, - index: clientIndex, - }; - - attachFirstUserIdleToInitialRuntimeSnapshot( - clientSnapshot - ); - - // The server-side snapshot.index can restart after bootstrap/restore. - // The right panel is client-side history, so display positions must follow - // the actual array order instead of reusing a stale server index. - runtimeMemoryHistory.snapshots.push(clientSnapshot); - runtimeMemoryHistory.index = - runtimeMemoryHistory.snapshots.length - 1; - - if (window.jinGenerationRunning) { - freezeUserIdleTimerAtSeconds( - window.jinActiveTurnUserIdleSeconds - ); - } - - rememberStableRuntimeSnapshot( - clientSnapshot - ); - - markJinAnswerRatingL1ReadyFromRuntimeUpdate( - data, - clientIndex - ); - } else { - markJinAnswerRatingL1ReadyFromRuntimeUpdate( - data - ); - } - - persistRuntimeMemorySnapshot( - data - ); - - captureSessionSaveRuntimeSnapshot( - clientSnapshot - ); - - renderRuntimeMemorySnapshot(); - -}; - - -if (window.jinLatestStatus) { - - window.updateRuntimePanelFromStatus( - window.jinLatestStatus - ); - -} else if (window.jinRuntimeConfig) { - - window.setRuntimeStatusSnapshot( - window.jinRuntimeConfig.runtimeStatus || {} - ); - - window.setUseServiceAsBrain( - window.jinRuntimeConfig.useServiceAsBrain - ); - - window.setRuntimeConfigSnapshot( - window.jinRuntimeConfig.runtimeConfig || {} - ); - -} else { - - renderContextPanel(); - -} - -const runtimeMemoryPosition = - document.getElementById("runtime-memory-position"); - -const runtimeMemoryPrev = - document.getElementById("runtime-memory-prev"); - -const runtimeMemoryNext = - document.getElementById("runtime-memory-next"); - -const runtimeDiffToggle = - document.getElementById("runtime-diff-toggle"); - -const runtimeDiffText = - document.getElementById("runtime-diff-text"); - -const runtimeDiffCount = - document.getElementById("runtime-diff-count"); - -const runtimeDiffAverage = - document.getElementById("runtime-diff-average"); - -const runtimeDiffRange = - document.getElementById("runtime-diff-range"); - -const runtimeDiffMax = - document.getElementById("runtime-diff-max"); - - -function formatRuntimeDiffNumber(value) { - const number = - Number(value || 0); - - return String( - Number.isInteger(number) - ? number - : Number(number.toFixed(2)) - ); -} - - -function renderRuntimeDiffs() { - const stats = - runtimeDiffHistory.stats || {}; - - if (runtimeDiffCount) { - runtimeDiffCount.textContent = - formatRuntimeDiffNumber(stats.count); - } - - if (runtimeDiffAverage) { - runtimeDiffAverage.textContent = - formatRuntimeDiffNumber(stats.average); - } - - if (runtimeDiffRange) { - runtimeDiffRange.textContent = - formatRuntimeDiffNumber(stats.range); - } - - if (runtimeDiffMax) { - runtimeDiffMax.textContent = - formatRuntimeDiffNumber(stats.max); - } - - if (runtimeDiffToggle) { - runtimeDiffToggle.textContent = - runtimeDiffHistory.expanded - ? "hide diffs" - : "show diffs"; - } - - if (!runtimeDiffText) { - return; - } - - runtimeDiffText.classList.toggle( - "hidden", - !runtimeDiffHistory.expanded - ); - - runtimeDiffText.textContent = - runtimeDiffHistory.diffs.length - ? JSON.stringify( - runtimeDiffHistory.diffs, - null, - 2 - ) - : "[]"; -} - - -function isCurrentRuntimeMemorySnapshotPinned() { - return pinnedRuntimeMemorySnapshotIndexes.has( - runtimeMemoryHistory.index - ); -} - -function updateRuntimeMemoryPinGlow() { - if (!runtimeMemoryPosition) { - return; - } - - runtimeMemoryPosition.classList.toggle( - "runtime-memory-position-pinned", - isCurrentRuntimeMemorySnapshotPinned() - ); -} - -function renderRuntimeMemorySnapshot() { - const snapshot = - runtimeMemoryHistory.snapshots[ - runtimeMemoryHistory.index - ]; - - if (!snapshot) { - runtimeMemoryText.textContent = ""; - runtimeMemoryPosition.textContent = - "0"; - updateRuntimeMemoryArrows(); - updateRuntimeMemoryPinGlow(); - return; - } - - renderRuntimeMemoryLines( - snapshot, - isCurrentRuntimeMemorySnapshotPinned() - ); - - runtimeMemoryPosition.textContent = - String( - typeof snapshot.index === "number" - ? snapshot.index - : runtimeMemoryHistory.index + 1 - ); - - updateRuntimeMemoryArrows(); - updateRuntimeMemoryPinGlow(); -} - -function isLatestRuntimeMemorySnapshot() { - return ( - runtimeMemoryHistory.index >= - runtimeMemoryHistory.snapshots.length - 1 - ); -} - -function clampMemoryRatio(value) { - const number = - Number(value || 0); - - return Math.max( - 0, - Math.min(1, number) - ); -} - -function applyRuntimeMemoryFlash( - element, - status, - kind, - ratio, - persist = false -) { - 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; - } - - if (persist) { - return; - } - - setTimeout(() => { - element.classList.remove( - "flash-new", - "flash-changed" - ); - - element.style.removeProperty( - "--memory-change-alpha" - ); - - element.style.removeProperty( - "--memory-change-glow" - ); - }, 1500); -} - -function formatRuntimeMemoryStrengthSuffix(line) { - const strength = - Number(line && line.strength); - - if (!Number.isFinite(strength)) { - return ""; - } - - return ` (trace: ${strength.toFixed(2)})`; -} - -function renderRuntimeMemoryLines(snapshot, persistGlow = false) { - if (!runtimeMemoryText) { - return; - } - - runtimeMemoryText.innerHTML = ""; - runtimeMemoryText.classList.toggle( - "runtime-memory-text-pinned", - persistGlow - ); - - const showLiveUserIdle = - isLatestRuntimeMemorySnapshot(); - - const lines = - showLiveUserIdle - ? (snapshot.lines || []) - .filter(line => !isUserIdleRuntimeMemoryLine(line)) - : snapshot.lines || []; - - if (!lines.length) { - const rawMemory = - showLiveUserIdle - ? stripUserIdleRuntimeMemoryText(snapshot.raw_memory || "") - : snapshot.raw_memory || ""; - - runtimeMemoryText.textContent = - `${rawMemory.trim()}\n`; - - if (showLiveUserIdle) { - appendUserIdleRuntimeMemoryLine(); - } else { - userIdleValueNode = null; - } - - installUserIdleInputFreeze(); - - 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}${formatRuntimeMemoryStrengthSuffix(line)}`; - - row.appendChild(keySpan); - row.appendChild(valueSpan); - - runtimeMemoryText.appendChild(row); - - applyRuntimeMemoryFlash( - keySpan, - keyStatus, - "key", - line.key_change_ratio, - persistGlow - ); - - applyRuntimeMemoryFlash( - valueSpan, - valueStatus, - "value", - line.value_change_ratio, - persistGlow - ); - }); - - if (showLiveUserIdle) { - appendUserIdleRuntimeMemoryLine(); - } else { - userIdleValueNode = null; - } - - installUserIdleInputFreeze(); -} - -function appendUserIdleRuntimeMemoryLine() { - if (!runtimeMemoryText) { - return; - } - - const row = - document.createElement("div"); - - row.className = - "runtime-memory-line runtime-memory-user-idle"; - - const keySpan = - document.createElement("span"); - - keySpan.className = - "runtime-memory-key"; - - keySpan.textContent = - "user_idle:"; - - const valueSpan = - document.createElement("span"); - - valueSpan.className = - "runtime-memory-value"; - - userIdleValueNode = - valueSpan; - - row.appendChild(keySpan); - row.appendChild(valueSpan); - - runtimeMemoryText.appendChild(row); - updateUserIdleTimerText(); - ensureUserIdleTimer(); -} - -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(); -}); - -runtimeMemoryPosition?.addEventListener("click", () => { - if (runtimeMemoryHistory.index < 0) { - return; - } - - const wasPinned = - isCurrentRuntimeMemorySnapshotPinned(); - - if (wasPinned) { - pinnedRuntimeMemorySnapshotIndexes.delete( - runtimeMemoryHistory.index - ); - } else { - pinnedRuntimeMemorySnapshotIndexes.add( - runtimeMemoryHistory.index - ); - } - - renderRuntimeMemorySnapshot(); - - if (wasPinned && runtimeMemoryText) { - runtimeMemoryText - .querySelectorAll( - ".flash-new, .flash-changed" - ) - .forEach((element) => { - element.classList.add( - "runtime-memory-flash-off" - ); - element.classList.remove( - "flash-new", - "flash-changed" - ); - - requestAnimationFrame(() => { - element.classList.remove( - "runtime-memory-flash-off" - ); - }); - }); - } -}); - -runtimeMemoryPosition?.addEventListener("keydown", (event) => { - if ( - event.key !== "Enter" - && event.key !== " " - ) { - return; - } - - event.preventDefault(); - runtimeMemoryPosition.click(); -}); - -runtimeDiffToggle?.addEventListener("click", () => { - runtimeDiffHistory.expanded = - !runtimeDiffHistory.expanded; - - renderRuntimeDiffs(); -}); - -renderRuntimeMemorySnapshot(); -renderRuntimeDiffs(); diff --git a/ui/templates/index.html b/ui/templates/index.html index 1ecb9117..7bde10bb 100644 --- a/ui/templates/index.html +++ b/ui/templates/index.html @@ -35,6 +35,8 @@ --safe-right: 380px; --chat-max-width: 820px; + --chat-min-width: 200px; + --scene-shade-alpha: 0.25; /* Вертикальный ритм чата: добавляет немного воздуха между ходами. */ --chat-turn-gap: 2.05rem; @@ -52,12 +54,37 @@ background-position: center; } + #scene-shade { + background: rgba(0, 0, 0, var(--scene-shade-alpha)); + transition: background 0.22s ease; + } + + main.panels-collapsed-1 #scene-shade { + --scene-shade-alpha: 0.3125; + } + + main.panels-collapsed-2 #scene-shade { + --scene-shade-alpha: 0.375; + } + #console-panel, #settings-panel { width: var(--panel-width); max-height: calc(100% - (var(--panel-gap) * 2)); } + #console-panel.panel-collapsed, + #settings-panel.panel-collapsed { + height: 40px !important; + min-height: 40px; + max-height: 40px; + } + + #console-panel.panel-collapsed #console-stream, + #settings-panel.panel-collapsed .settings-scroll { + display: none; + } + #console-panel { left: var(--panel-gap); top: var(--panel-gap); @@ -124,8 +151,8 @@ } #chat-history { - padding-left: var(--safe-left); - padding-right: var(--safe-right); + padding-left: min(var(--safe-left), calc((100% - var(--chat-min-width)) / 2)); + padding-right: min(var(--safe-right), calc((100% - var(--chat-min-width)) / 2)); padding-top: var(--chat-history-edge-gap) !important; padding-bottom: var(--chat-history-edge-gap) !important; scroll-padding-bottom: calc(var(--chat-history-edge-gap) + 0.5rem); @@ -147,18 +174,23 @@ } #chat-input-shell { - padding-left: var(--safe-left); - padding-right: var(--safe-right); + padding-left: min(var(--safe-left), calc((100% - var(--chat-min-width)) / 2)); + padding-right: min(var(--safe-right), calc((100% - var(--chat-min-width)) / 2)); } #chat-form { width: min(100%, var(--chat-max-width)); - min-width: 520px; + min-width: var(--chat-min-width); margin-left: auto; margin-right: auto; } + #attached-files { + width: min(100%, var(--chat-max-width)); + min-width: var(--chat-min-width); + } + #user-input { width: 100%; } @@ -426,6 +458,7 @@ .jin-message-row { /* Горизонтальная строка: аватарка слева, бабл справа. */ display: flex; + padding-left: 48px; /* Прижимает аватарку и бабл к верхнему краю строки. */ align-items: flex-start; @@ -434,6 +467,10 @@ gap: 12px; } + .jin-runtime-action-row { + align-items: center; + } + .jin-chat-avatar { /* Размер квадратной аватарки SV/US. */ width: 28px; @@ -644,7 +681,8 @@ max-width: 720px; /* Сдвиг think вправо, чтобы он начинался после аватарки. */ - margin-left: 40px; + margin-left: auto; + margin-right: auto; /* Небольшая пауза между мыслью и следующим видимым ответом. */ margin-bottom: var(--chat-inner-gap); @@ -1270,8 +1308,8 @@ style="background-image:url('static/images/states/default.png')"> </div> - <div - class="absolute inset-0 bg-black/25 pointer-events-none"> + <div id="scene-shade" + class="absolute inset-0 pointer-events-none"> </div> <!-- LEFT CONSOLE --> @@ -1608,7 +1646,9 @@ <h3 class="text-xs font-bold uppercase tracking-widest text-zinc-300"> <div class="runtime-memory-header flex items-center justify-between gap-2 border-b border-slate-500/70 text-[11px] uppercase tracking-widest text-slate-100"> - <span class="min-w-0 truncate"> + <span + id="runtime-memory-title" + class="min-w-0 truncate"> [ runtime memory ] </span> @@ -1976,12 +2016,20 @@ <h3 class="text-xs font-bold uppercase tracking-widest text-zinc-300"> "runtimeConfig": runtime_config, } | tojson }} </template> -<script src="/static/js/telemetry.js?v=user-idle-l1-no-reset-1"></script> +<script src="/static/js/runtime/runtime-core.js?v=runtime-core-1"></script> +<script src="/static/js/runtime/runtime-storage.js?v=runtime-storage-1"></script> +<script src="/static/js/runtime/runtime-memory-model.js?v=runtime-memory-model-1"></script> +<script src="/static/js/runtime/runtime-idle.js?v=runtime-idle-1"></script> +<script src="/static/js/runtime/runtime-feedback.js?v=runtime-feedback-1"></script> +<script src="/static/js/runtime/runtime-session.js?v=runtime-session-1"></script> +<script src="/static/js/runtime/runtime-panel.js?v=runtime-panel-1"></script> +<script src="/static/js/runtime/runtime-memory-view.js?v=runtime-memory-view-1"></script> +<script src="/static/js/runtime/runtime.js?v=runtime-facade-2"></script> <script src="/static/js/status.js?v=status-check-4"></script> <script src="/static/js/dragdrop.js?v=context-panel-5"></script> <script src="/static/js/logger.js?v=session-card-1"></script> <script src="/static/js/chat.js?v=context-popup-1"></script> -<script src="/static/js/socket.js?v=user-idle-submit-value-freeze-1"></script> +<script src="/static/js/socket.js?v=runtime-session-unified-1"></script> </body> </html> diff --git a/websocket.py b/websocket.py index 38aad3d2..7d394c4d 100644 --- a/websocket.py +++ b/websocket.py @@ -26,6 +26,9 @@ build_brain_payload, build_brain_system_prompt, ) +from clients.brain_client import ( + should_execute_remember_session, +) from utils.brain import ( get_brain_runtime_config, @@ -61,21 +64,19 @@ RuntimeEmitter, apply_runtime_response_feedback, build_runtime_memory_snapshot, - cancel_idle_fact_check, emit_runtime_l1_diff_update, emit_runtime_session_memory_update, refresh_runtime_state, run_fact_check_once, - schedule_idle_fact_check, schedule_interrupted_runtime_memory_update, schedule_runtime_memory_update, send_telemetry, ) -from runtime.memory_rules import ( +from runtime.L1_memory_utils import ( build_runtime_memory_context_text, remove_runtime_user_idle_lines, ) -from runtime.memory import ( +from runtime.L1_memory import ( parse_runtime_memory_lines, ) @@ -1092,6 +1093,63 @@ async def receive_message( # PROCESS MESSAGE # --------------------------------------------------------- +async def arm_remember_session_from_user_text( + context, + user_text: str, +) -> bool: + + if getattr( + context, + "runtime_remember_session_requested", + False, + ): + return False + + if not should_execute_remember_session( + user_text, + ): + return False + + context.runtime_remember_session_requested = True + context.runtime_remember_session_action_emitted = True + + logger = getattr( + context, + "logger", + None, + ) + log_runtime = getattr( + logger, + "log_runtime", + None, + ) + + if log_runtime is not None: + await log_runtime( + "[RUNTIME ACTION] remember_session requested" + ) + + emitter = getattr( + context, + "emitter", + None, + ) + emit = getattr( + emitter, + "emit", + None, + ) + + if emit is not None: + await emit({ + "type": "runtime_action", + "action": "remember_session", + "text": "Remembering this session", + }) + + return True + + async def refresh_pending_brain_usage( context, user_text: str, @@ -1314,6 +1372,10 @@ async def process_message( context.runtime_turn_user_message = user_text context.runtime_turn_assistant_response = "" context.runtime_turn_interrupted = False + await arm_remember_session_from_user_text( + context, + user_text, + ) apply_user_idle_context( context, message_data, @@ -1365,21 +1427,29 @@ async def process_message( or context.runtime_turn_assistant_response ) - schedule_runtime_memory_update( + memory_update_task = schedule_runtime_memory_update( context=context, user_message=user_text, assistant_message=assistant_message, ) + if ( + memory_update_task is not None + and getattr( + context, + "runtime_remember_session_requested", + False, + ) + ): + await wait_for_runtime_memory_update( + context + ) + context.assistant_message_count += 1 context.turn_number += 1 - # Arm the background fact-check only after the turn is fully finalized. - # The idle worker waits for the pending memory update, then checks one - # remaining (confirmed: none) fact and stops. - schedule_idle_fact_check( - context - ) + # Background fact-checking is intentionally not armed here. + # Fact-checking runs only from the explicit UI request path. except asyncio.CancelledError: @@ -1683,10 +1753,6 @@ async def process_pending_requests(): ) continue - cancel_idle_fact_check( - context - ) - await logger.log( "[MEMORY:FACT_CHECK]", "[FACT_CHECK] manual web check requested", @@ -1732,10 +1798,6 @@ async def process_pending_requests(): continue - cancel_idle_fact_check( - context - ) - if await reject_when_all_models_offline( context ): @@ -1806,9 +1868,6 @@ async def process_pending_requests(): ) finally: - cancel_idle_fact_check( - context - ) pending_processor.cancel() with contextlib.suppress(