diff --git a/README.md b/README.md index 7845e4b7..bc884b3b 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,8 @@ Runtime memory is intentionally lightweight, but it is no longer passive storage - Memory is injected into the brain prompt as trusted runtime context. - It is mirrored in the right sidebar through `runtime_memory_update` and `runtime_session_memory_update` WebSocket events. - Each L1/L2 update is captured as a session snapshot with an index, raw memory text, parsed key/value lines, and diff metadata. -- The UI can navigate previous snapshots and replay visual highlights for new or changed memory fields. +- Runtime-owned `active_memory` suffixes track creation/elapsed time and turns; they are stored and displayed, but stripped before L1 receives memory again. +- The UI can navigate previous snapshots, replay visual highlights for new or changed memory fields, and show full memory suffixes line-by-line on hover. - Conversation activity and no-signal alerts can suppress overly soft default behavior when the exchange is clearly stuck. - Truncated or obviously incomplete summarizer output is rejected so it does not overwrite the previous memory. @@ -114,16 +115,13 @@ JIN memory is stored as plain `key: value` lines so it can be shown in the UI, i ### 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. +L1 is the live factual layer. It keeps the current state needed for the next answer: user request, active topic, latest user message, unresolved active contracts, 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. +user_message: "thanks" +last_jin_response: Acknowledged the user and kept the pending recall contract active. +primary_goal: Play a memory test game where JIN prompts the user to guess the secret word. +active_memory: Secret word: Sun [ purpose: Ask user to guess this exact word ] [ conditions: Do not reveal or change the word ] [ creation_time: 2026-06-20T10:00:00 ] [ creation_turn_number: 3 ] [ elapsed_time: 00:02:39 ] [ elapsed_turns: 2 ] [ status: pending ] ``` A rendered runtime snapshot also carries metadata used by the right-side timeline panel: @@ -135,8 +133,8 @@ A rendered runtime snapshot also carries metadata used by the right-side timelin "raw_memory": "session status: ...\nuser_message: ...", "lines": [ { - "key": "stored_memory", - "value": "\"Небо\" (purpose: secret word for future recall test; status: pending)", + "key": "active_memory", + "value": "Secret word: Sun [ purpose: Ask user to guess this exact word ] [ conditions: Do not reveal or change the word ] [ creation_time: 2026-06-20T10:00:00 ] [ creation_turn_number: 3 ] [ elapsed_time: 00:02:39 ] [ elapsed_turns: 2 ] [ status: pending ]", "key_status": "same", "value_status": "same", "key_change_ratio": 0.0, @@ -144,16 +142,16 @@ A rendered runtime snapshot also carries metadata used by the right-side timelin } ], "patch": { - "stored_memory": { + "active_memory": { "status": "same", - "value": "\"Небо\" (purpose: secret word for future recall test; status: pending)" + "value": "Secret word: Sun [ purpose: Ask user to guess this exact word ] [ 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. +`active_memory` lifecycle suffixes are owned by runtime, not L1. L1 sees the contract value and `[ status: ... ]`; runtime reattaches `[ creation_time ]`, `[ creation_turn_number ]`, `[ elapsed_time ]`, and `[ elapsed_turns ]` after each update. Memory lines may also have 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) @@ -511,6 +509,19 @@ Runtime memory update: } ``` +Active memory collapse log: + +```json +{ + "type": "log", + "tag": "[ACTIVE_MEMORY]", + "message": "active_memory duplicate collapsed", + "details": "{\n \"stage\": \"after durable merge\",\n \"events\": [...],\n \"result_memory\": \"active_memory: ... [ status: pending, Reminder given in Turn 1 ]\"\n}", + "channel": "active_memory", + "active_memory_event": "collapse" +} +``` + Runtime L1 diff update (incremental key-level change history): ```json diff --git a/clients/brain_client_utils.py b/clients/brain_client_utils.py index 126d1b7e..adcc3898 100644 --- a/clients/brain_client_utils.py +++ b/clients/brain_client_utils.py @@ -92,25 +92,6 @@ ) -COUNTDOWN_CONTRACT_RULES = ( - "Runtime countdown_contract is active.\n" - "Treat countdown_contract and countdown_contract_N lines in RUNTIME_MEMORY " - "as active user-facing obligations from creation until completed or cancelled.\n" - "Countdown contracts are turn-based or time-based obligations; use bracket " - "suffixes in RUNTIME_MEMORY as trusted state. Do not require or trust status text.\n" - "For turn-based contracts, if [remaining: N] is greater than 0, do not execute " - "the trigger early; if [remaining: 0], execute [trigger] in this answer as an " - "actual direct user-facing action or question.\n" - "For time-based contracts, if runtime brought the contract into context and " - "[current_time] has reached or passed [due_at], execute [trigger] in this answer.\n" - "After executing the trigger, make the fulfillment explicit in your answer so L1 " - "can append [completed: jin] and deterministic cleanup can remove the contract.\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: @@ -1103,39 +1084,6 @@ def _contains_any_marker( 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 re.fullmatch(r"countdown_contract(?:_\d+)?", key): - return True - - return False - - def has_memory_rule_request( context=None, ) -> bool: @@ -1255,13 +1203,6 @@ def build_conditional_prompt_rules( "" ] - if has_countdown_contract( - context - ): - rules.append( - COUNTDOWN_CONTRACT_RULES - ) - if has_loop_rule_signal( context ): diff --git a/rules/identity.py b/rules/identity.py index 9fc38a4d..20ef756a 100644 --- a/rules/identity.py +++ b/rules/identity.py @@ -25,10 +25,9 @@ "- L1 for current session facts\n" "- L2 for repeated patterns\n" "- L3 for saved session continuity\n" - "\n" "" "\n" - "https://github.com/makeitdouble/jin_core" + "https://github.com/makeitdouble/jin_core" "\n" "" "\n" diff --git a/rules/runtime.py b/rules/runtime.py index 9245fb23..3dfdbe29 100644 --- a/rules/runtime.py +++ b/rules/runtime.py @@ -14,9 +14,14 @@ def _format_action_guard_triggers( ) RUNTIME = ( - "A visible suffix like `[ relevance: 0.50 ]` marks how applicable this memory item is to the current runtime context." + "A visible suffix like `[ trace: 0.50 ]` marks how applicable this memory item is to the current runtime context." "Use last_jin_response from trusted runtime memory as the primary anchor " "for short, elliptical feedback about JIN's immediately previous output.\n" + + "If any active_memory or active_memory_N line has pending for fulfill [ status: pending ], treat it as higher priority than active_topic, current_task, or the apparent current topic.\n" + "All active_memory suffix [ status: pending ] is chronological status log. Example of completed status: [ status: pending, asked once, completed ].\n" + + "A pending active_memory contract remains behaviorally active across thanks, topic shifts, and casual requests until its status is completed, cancelled, or recalled.\n" "For brief negative feedback, do not ask what exactly is wrong by default; " "answer by challenging yourself and changing the previous output into a " "concrete alternative, preferably from an unexpected angle.\n" @@ -50,4 +55,4 @@ def _format_action_guard_triggers( "Do not invent, reset, or update internal state values yourself. Trust only values from trusted runtime context.\n" "Never mention timestamps, internal function names, or counters in chat unless the user explicitly asks.\n" "\n" -) \ No newline at end of file +) diff --git a/runtime/L1_memory.py b/runtime/L1_memory.py index 168e7027..d5161723 100644 --- a/runtime/L1_memory.py +++ b/runtime/L1_memory.py @@ -1,5 +1,6 @@ import asyncio import contextlib +import json import traceback from clients.service_client import ( ask_service_model, @@ -33,6 +34,7 @@ extract_runtime_memory_text, is_runtime_memory_response_truncated, latest_turn_context_is_overloaded, + log_active_memory_event, log_memory_event, log_runtime_summarizer_payload, looks_like_incomplete_runtime_memory, @@ -49,18 +51,22 @@ build_runtime_memory_user_prompt, durable_memory_line_text, enforce_runtime_turn_fields, + enrich_active_memory_recall_conditions, get_strength_zones, has_durable_fact_negation, + ensure_active_memory_status_suffixes, is_durable_memory_key, is_runtime_memory_repeatable_key_family, + normalize_managed_runtime_memory_slots, normalize_memory_key, normalize_runtime_memory_key_family, parse_runtime_memory_lines, + refresh_active_memory_runtime_metadata, repeatable_runtime_memory_values_are_same_slot, remove_runtime_memory_placeholder_lines, remove_runtime_response_feedback_text, remove_runtime_user_idle_lines, - refresh_countdown_contracts, + strip_active_memory_runtime_metadata, upsert_runtime_memory_entry_text, ) @@ -151,6 +157,46 @@ def build_runtime_memory_system_prompt_for_turns( ) +async def normalize_active_memory_slots_with_logging( + context, + *, + previous_memory: str, + candidate_memory: str, + stage: str, +) -> str: + + collapse_events = [] + result_memory = normalize_managed_runtime_memory_slots( + previous_memory, + candidate_memory, + collapse_events=collapse_events, + ) + + if collapse_events: + await log_active_memory_event( + context, + message=( + "active_memory duplicate collapsed" + if len(collapse_events) == 1 + else f"active_memory duplicates collapsed ({len(collapse_events)})" + ), + details=json.dumps( + { + "stage": stage, + "events": collapse_events, + "previous_memory": previous_memory, + "candidate_memory": candidate_memory, + "result_memory": result_memory, + }, + ensure_ascii=False, + indent=2, + ), + event="collapse", + ) + + return result_memory + + def clear_runtime_response_feedback( context, ) -> None: @@ -442,11 +488,26 @@ async def summarize_runtime_memory( ) -> str: if not assistant_message.strip(): - return getattr( - context, - "runtime_memory", - "", + stored_memory = remove_runtime_memory_placeholder_lines( + remove_runtime_response_feedback_text( + getattr( + context, + "runtime_memory", + "", + ) + ) + ) + updated_memory = refresh_active_memory_runtime_metadata( + stored_memory, + previous_memory=stored_memory, + context=context, ) + updated_memory = ensure_active_memory_status_suffixes( + updated_memory + ) + context.runtime_memory = updated_memory + context.runtime_memory_stable = updated_memory + return updated_memory service_client = ( getattr( @@ -460,24 +521,50 @@ async def summarize_runtime_memory( ) if service_client is None: - return getattr( - context, - "runtime_memory", - "", + stored_memory = remove_runtime_memory_placeholder_lines( + remove_runtime_response_feedback_text( + getattr( + context, + "runtime_memory", + "", + ) + ) ) + updated_memory = refresh_active_memory_runtime_metadata( + stored_memory, + previous_memory=stored_memory, + context=context, + ) + updated_memory = ensure_active_memory_status_suffixes( + updated_memory + ) + context.runtime_memory = updated_memory + context.runtime_memory_stable = updated_memory + return updated_memory - current_memory = remove_runtime_response_feedback_text( + stored_memory = remove_runtime_response_feedback_text( getattr( context, "runtime_memory", "", ) ) - current_memory = remove_runtime_memory_placeholder_lines( + stored_memory = remove_runtime_memory_placeholder_lines( + stored_memory + ) + stored_memory = refresh_active_memory_runtime_metadata( + stored_memory, + previous_memory=stored_memory, + context=context, + ) + current_memory = strip_active_memory_runtime_metadata( + stored_memory + ) + current_memory = ensure_active_memory_status_suffixes( current_memory ) - context.runtime_memory = current_memory + context.runtime_memory = stored_memory context.runtime_memory_stable = remove_runtime_memory_placeholder_lines( remove_runtime_response_feedback_text( getattr( @@ -507,6 +594,9 @@ async def summarize_runtime_memory( updated_memory = remove_runtime_memory_placeholder_lines( updated_memory ) + updated_memory = ensure_active_memory_status_suffixes( + updated_memory + ) if ( is_runtime_memory_response_truncated( @@ -528,7 +618,7 @@ async def summarize_runtime_memory( fallback_channel="error", ) - return current_memory + return stored_memory updated_memory = merge_durable_memory_facts( current_memory, @@ -540,30 +630,51 @@ async def summarize_runtime_memory( updated_memory = remove_runtime_memory_placeholder_lines( updated_memory ) + updated_memory = await normalize_active_memory_slots_with_logging( + context, + previous_memory=current_memory, + candidate_memory=updated_memory, + stage="after durable merge", + ) updated_memory = ensure_confirmable_memory_markers( updated_memory, user_message=user_message, assistant_message=assistant_message, ) + updated_memory = enrich_active_memory_recall_conditions( + updated_memory, + user_message=user_message, + assistant_message=assistant_message, + ) + updated_memory = await normalize_active_memory_slots_with_logging( + context, + previous_memory=current_memory, + candidate_memory=updated_memory, + stage="after confirmable markers", + ) updated_memory = remove_runtime_response_feedback_text( updated_memory ) updated_memory = remove_runtime_memory_placeholder_lines( updated_memory ) - updated_memory = refresh_countdown_contracts( - updated_memory, - context=context, - ) updated_memory = enforce_runtime_turn_fields( updated_memory, user_message=user_message, assistant_message=assistant_message, previous_memory=current_memory, ) + updated_memory = ensure_active_memory_status_suffixes( + updated_memory + ) updated_memory = remove_runtime_user_idle_lines( updated_memory ) + updated_memory = refresh_active_memory_runtime_metadata( + updated_memory, + previous_memory=stored_memory, + context=context, + ) updates_counter = getattr( context, @@ -664,14 +775,25 @@ async def summarize_runtime_memory_pending_turns( "", ) - initial_memory = remove_runtime_response_feedback_text( + stored_initial_memory = remove_runtime_response_feedback_text( getattr( context, "runtime_memory_stable", "", ) ) - initial_memory = remove_runtime_memory_placeholder_lines( + stored_initial_memory = remove_runtime_memory_placeholder_lines( + stored_initial_memory + ) + stored_initial_memory = refresh_active_memory_runtime_metadata( + stored_initial_memory, + previous_memory=stored_initial_memory, + context=context, + ) + initial_memory = strip_active_memory_runtime_metadata( + stored_initial_memory + ) + initial_memory = ensure_active_memory_status_suffixes( initial_memory ) @@ -684,7 +806,7 @@ async def summarize_runtime_memory_pending_turns( ) ) ) - context.runtime_memory_stable = initial_memory + context.runtime_memory_stable = stored_initial_memory context.runtime_last_response_feedback = None try: @@ -704,6 +826,9 @@ async def summarize_runtime_memory_pending_turns( updated_memory = remove_runtime_memory_placeholder_lines( updated_memory ) + updated_memory = ensure_active_memory_status_suffixes( + updated_memory + ) skip_reason = None @@ -726,7 +851,7 @@ async def summarize_runtime_memory_pending_turns( fallback_channel="error", ) - return initial_memory + return stored_initial_memory updated_memory = merge_durable_memory_facts( initial_memory, @@ -738,6 +863,12 @@ async def summarize_runtime_memory_pending_turns( updated_memory = remove_runtime_memory_placeholder_lines( updated_memory ) + updated_memory = await normalize_active_memory_slots_with_logging( + context, + previous_memory=initial_memory, + candidate_memory=updated_memory, + stage="batch after durable merge", + ) latest_turn = turns[-1] if turns else {} latest_user_message = latest_turn.get( @@ -754,25 +885,40 @@ async def summarize_runtime_memory_pending_turns( user_message=latest_user_message, assistant_message=latest_assistant_message, ) + updated_memory = enrich_active_memory_recall_conditions( + updated_memory, + user_message=latest_user_message, + assistant_message=latest_assistant_message, + ) + updated_memory = await normalize_active_memory_slots_with_logging( + context, + previous_memory=initial_memory, + candidate_memory=updated_memory, + stage="batch after confirmable markers", + ) updated_memory = remove_runtime_response_feedback_text( updated_memory ) updated_memory = remove_runtime_memory_placeholder_lines( updated_memory ) - updated_memory = refresh_countdown_contracts( - updated_memory, - context=context, - ) updated_memory = enforce_runtime_turn_fields( updated_memory, user_message=latest_user_message, assistant_message=latest_assistant_message, previous_memory=initial_memory, ) + updated_memory = ensure_active_memory_status_suffixes( + updated_memory + ) updated_memory = remove_runtime_user_idle_lines( updated_memory ) + updated_memory = refresh_active_memory_runtime_metadata( + updated_memory, + previous_memory=stored_initial_memory, + context=context, + ) updates_counter = getattr( context, diff --git a/runtime/L1_memory_rules.py b/runtime/L1_memory_rules.py index dcbe41b8..cd4fdffe 100644 --- a/runtime/L1_memory_rules.py +++ b/runtime/L1_memory_rules.py @@ -84,7 +84,6 @@ "profile", "preference", "stored", - "countdown", "contract", "axiom", "jin", @@ -162,12 +161,8 @@ "constraint", "current_task", "current task", - "stored_memory", + "active_memory", "stored memory", - "open_contract", - "open contract", - "countdown_contract", - "countdown contract", } # Template used to pass interrupted assistant turns into L1 memory. @@ -235,108 +230,6 @@ "я есть", ) -# Ключи в текущей памяти → подключить блок про stored_memory. -TRIGGER_KEYS_STORED_MEMORY = ( - "stored_memory", -) - -# Фразы пользователя → подключить блок про создание stored_memory. -TRIGGER_MSG_STORED_MEMORY = ( - "запомни", - "запомни слово", - "кодовое слово", - "загадай", - "загадай мне слово", - "какое слово ты загадал", - "назвать его", - "remember", - "code word", - "memorize", - "choose a word", - "secret word", - "recall word", -) - -# Ключи в текущей памяти → подключить блок про open_contract. -# open_contract всегда идёт вместе со stored_memory/stored_memory_N, поэтому оба ключа здесь. -TRIGGER_KEYS_OPEN_CONTRACT = ( - "open_contract", - "stored_memory", -) - -# Ключи в текущей памяти → подключить блок про countdown_contract. -TRIGGER_KEYS_COUNTDOWN = ( - "countdown_contract", -) - -# Фразы пользователя → подключить блок про создание countdown_contract. -# Общий входной детектор: конкретный формат выбирается ниже детерминированно. -TRIGGER_MSG_COUNTDOWN = ( - "напомни", - "напомнить", - "remind", - "через", - "ходов", - "ход", - "сообщений", - "сообщения", - "after", - "turns", - "turn", - "messages", - "message", - "minutes", - "minute", - "минут", - "минуту", - "секунд", - "секунду", - "час", - "часов", - "tomorrow", - "завтра", - "in n", -) - -COUNTDOWN_TIME_TRIGGER_WORDS = ( - "секунд", - "секунду", - "сек", - "минут", - "минуту", - "мин", - "час", - "часов", - "часа", - "день", - "дня", - "дней", - "завтра", - "сегодня", - "tomorrow", - "today", - "second", - "seconds", - "minute", - "minutes", - "hour", - "hours", - "day", - "days", -) - -COUNTDOWN_TURN_TRIGGER_WORDS = ( - "ход", - "ходов", - "сообщение", - "сообщения", - "сообщений", - "turn", - "turns", - "message", - "messages", -) - # Ключи в текущей памяти → подключить блок про L2 interface. TRIGGER_KEYS_L2_INTERFACE = ( "l2_pattern_evidence_", @@ -393,10 +286,9 @@ "Do not merge a durable key into a temporary key.\n" "Use a new key only when the current fact genuinely does not fit an existing key.\n" "Prefer keeping an existing key when it still fits.\n" - "Typical temporary keys: active_topic, current_task, current_request, pending_choice, " + "Typical temporary keys: user_message, active_topic, current_task, current_request, pending_choice, " "last_jin_response, interaction_state.\n" - "Typical durable keys: user_fact, jin_fact, jin_core_definition, stored_memory, stored_memory_N, " - "open_contract, countdown_contract, countdown_contract_N, shared_axiom_established, primary_goal.\n" + "Typical durable keys: active_memory, user_fact, jin_fact, shared_axiom" ) # Разделение временного и долговременного состояния. @@ -411,8 +303,8 @@ "Once a durable key exists with a concrete value, preserve it verbatim across snapshots. " "Only the value may change, and only when the current turn explicitly overrides it.\n" "Durable key families that must always carry forward: " - "user_fact, jin_fact, jin_core_definition, stored_memory, stored_memory_N, open_contract, " - "countdown_contract, countdown_contract_N, shared_axiom_established, primary_goal.\n" + "user_fact, jin_fact, jin_core_definition, active_memory, active_memory_N, " + "shared_axiom_established, primary_goal.\n" "Never invent missing durable keys and never fill absent durable keys with placeholders.\n" "Treat any existing line about JIN's identity, nature, origin, role, or capabilities " "as a durable JIN fact even if its key is not exactly jin_fact.\n" @@ -477,15 +369,7 @@ "Before final output, verify:\n" "1. Every durable line from current memory is still present unless explicitly corrected, " "cancelled, completed, or superseded in the latest turn.\n" - "2. Every active stored_memory line is still present until its recall contract is resolved.\n" - "3. Every active open_contract line is still present with its turn progress updated.\n" - "4. Every active countdown_contract or countdown_contract_N line still contains the required anchor suffixes. " - "Turn-based contracts require [created_at: timestamp], [created_user_message_count: N], " - "[count_from: N], [count_to: N], [current: N], [remaining: N], and [trigger: ...]. " - "Time-based contracts require [created_at: timestamp], [due_at: timestamp], " - "[current_time: timestamp], and [trigger: ...]. Do not use a status field for countdowns.\n" - "Completed, cancelled, or numerically expired countdown contracts are not active contracts; " - "do not re-add them after they have been resolved and cleaned up.\n" + "2. Every active_memory line is still present until its recall contract is resolved.\n" "If a required durable or active contract line is missing, add it back before output.\n" "If nothing durable changed, preserve durable lines unchanged and update only temporary state " "and last_jin_response.\n" @@ -533,121 +417,41 @@ "Example: user_fact_2: user likes horror movies (confirmed: user)\n" ) -# Правила хранения stored_memory. -# Подключается когда stored_memory уже есть в памяти. -STORED_MEMORY_KEEP = ( - "stored_memory is a high-priority active recall contract. Never remove it until resolved.\n" - "Revealing or sending the stored value to the user is not a recall event — " - "keep status: pending until JIN asks the user to recall it and the user answers correctly.\n" - "Set status: recalled only after the user successfully reproduces the stored value when prompted by JIN.\n" - "After successful recall, keep the matching stored_memory line for at least one more L1 snapshot with " - "status: recalled before removing it.\n" - "A stored_memory or stored_memory_N line may be removed only when the user explicitly cancels it, " - "replaces it, or the recall contract is clearly complete.\n" - "Do not remove stored_memory because the conversation moved to another topic.\n" - "Do not hide stored_memory inside active_topic, current_task, or last_jin_response.\n" -) - -# Инструкция по созданию stored_memory. -# Подключается когда триггерная фраза есть в сообщении, но stored_memory в памяти нет. -STORED_MEMORY_CREATE = ( - "The user asked JIN to remember or later ask about a specific value. Store it as stored_memory.\n" - "Format: stored_memory: \"\" (purpose: ; status: pending)\n" - "Do not store bare ambiguous values without purpose.\n" - "If the user supplied the value, take it verbatim from the user's message.\n" - "If the user asked JIN to choose/reveal a word and recall it later, take the chosen value " - "verbatim from Latest JIN answer.\n" -) +# Правила создания и хранения active_memory. +# Подключается всегда; L1 сама решает, создал ли текущий ход новый active contract. +ACTIVE_MEMORY_CREATE = ( + "Write active_memory only when this turn creates an active contract: " + "a promise to remember, remind, ask back, reveal, or recall a specific value later.\n" -# Правила хранения open_contract. -# Подключается когда open_contract или stored_memory есть в памяти. -OPEN_CONTRACT_KEEP = ( - "Open contracts are not the same as active topics.\n" - "A pending recall, promised follow-up, unresolved choice, or active implementation task " - "must survive topic switches.\n" - "Keep both the new active topic and the unresolved contract on separate lines.\n" - "On every L1 update while the recall contract is pending, recompute and update the progress counter.\n" - "When the turn counter reaches or exceeds N, JIN must ask the recall question in its very next response.\n" - "Remove open_contract only when stored_memory status becomes recalled or cancelled.\n" - "Turn-based format: open_contract: JIN must prompt user to recall \"\" within turns " - "(turn /)\n" - "Time-based format: open_contract: JIN must prompt user to recall \"\" within minutes " - "(start_time: ; current_time: )\n" -) + "WHEN to write:\n" + "— User asks JIN to remember/remind/guess a value, OR\n" + "— Latest JIN answer accepts such a game, chooses a value, or promises a future action.\n" + "Do not write for completed one-off requests, facts, or casual conversation.\n" -# Инструкция по созданию open_contract — добавляется вместе с STORED_MEMORY_CREATE -# если в запросе указано окно по ходам или времени. -OPEN_CONTRACT_CREATE = ( - "The user specified a recall window (turns or time). Create a companion open_contract line.\n" - "Turn-based format: open_contract: JIN must prompt user to recall \"\" within turns " - "(turn 0/)\n" - "Time-based format: open_contract: JIN must prompt user to recall \"\" within minutes " - "(start_time: ; current_time: )\n" -) + "KEY FORMAT: always write bare active_memory as the key (no numeric key suffix). " + "Numeric key suffixes like active_memory_2 are assigned externally — never write or modify them.\n" -# Правила хранения countdown_contract. -# Подключается когда countdown_contract уже есть в памяти. -COUNTDOWN_CONTRACT_KEEP = ( - "countdown_contract and countdown_contract_N are survival-priority memory while unresolved; " - "topic changes and context pressure must not remove unresolved countdowns.\n" - "Treat countdown_contract, countdown_contract_1, countdown_contract_2, etc. as one numbered family.\n" - "L1 owns the semantic contract only: purpose and trigger. Deterministic post-processing owns " - "[current], [remaining], [current_time], and cleanup.\n" - "Do not write or preserve a status field on countdown_contract lines. Numeric suffixes are the source of truth.\n" - "Keep countdown metadata as bracket suffixes at the end of the line, for example " - "[created_at: ...] [count_from: ...] [remaining: ...].\n" - "Creation anchors are immutable: [created_at], [created_user_message_count], [count_from], " - "[count_to], and [due_at] must not change unless the user explicitly restarts, resets, replaces, " - "or cancels that specific countdown.\n" - "When a turn-based countdown is due, JIN must execute [trigger] as a direct user-facing action " - "in its very next response. When a time-based countdown is due, JIN must execute [trigger] " - "as soon as runtime brings it into context.\n" - "If JIN's latest response fulfilled the reminder/trigger in the countdown line, append " - "[completed: jin] to that same countdown_contract line.\n" - "If the user explicitly says the reminder is done/cancelled/no longer needed, append [completed: user] " - "or [cancelled: user] to the matching countdown line.\n" - "For due recall contracts, ask the user to provide the remembered value without revealing, quoting, " - "or restating the stored value first. Valid wording: 'Какое слово я загадал?' or " - "'Назови слово, которое я загадал?'\n" - "If multiple countdown contracts exist, update/complete/remove only the matching numbered contract; " - "do not merge unrelated reminders into one line.\n" -) + "LINE FORMAT:\n" + "active_memory: " + "[ purpose: ] " + "[ conditions: ] " + "[ status: pending ]\n" + "The [ status: pending ] value suffix is mandatory on every new active_memory line.\n" -# Инструкция по созданию countdown_contract. -# Подключается когда триггерная фраза есть в сообщении, но countdown_contract в памяти нет. -COUNTDOWN_CONTRACT_CREATE_BASE = ( - "The user created a countdown/reminder contract. Store it as countdown_contract.\n" - "If there is already an unrelated unresolved countdown_contract, create the next numbered sibling " - "instead: countdown_contract_2, countdown_contract_3, etc.\n" - "If the existing countdown is the same semantic task, update that slot instead of creating a duplicate.\n" - "If an old countdown is completed, cancelled, or already acknowledged, clean it up first; " - "then the new contract may reuse countdown_contract.\n" - "Keep countdown metadata as separate bracket suffixes at the very end of the line. " - "Do not use semicolon metadata for countdown counters. Do not write a status field.\n" - "Use trusted runtime timestamp and USER_MESSAGE_COUNT as the only source for anchors. " - "If a required trusted value is missing, write unknown instead of inventing it.\n" -) + "DUPLICATE RULE — before writing any new line:\n" + "Scan all existing active_memory family slots (active_memory, active_memory_2, …). " + "If any slot already holds the same value AND same purpose: do not create a new slot. " + "Copy that existing line unchanged, or update only its [ status: … ] suffix if this turn affects it.\n" + "One contract = one slot. Never split or merge contracts across slots.\n" -COUNTDOWN_CONTRACT_CREATE_TURN = COUNTDOWN_CONTRACT_CREATE_BASE + ( - "This is a turn/message-based countdown. Use this exact shape:\n" - "countdown_contract_N: [created_at: ] " - "[created_user_message_count: ] " - "[count_from: ] [count_to: ] " - "[due_user_message_count: ] [current: ] " - "[remaining: ] [trigger: ]\n" -) + "UPDATES:\n" + "Append inside the existing [ status: … ] suffix only: [ status: pending, ]. " + "Never add a second status suffix. Never create a duplicate slot for a status update.\n" + "Preserve all earlier status contents — append only, never rewrite.\n" -COUNTDOWN_CONTRACT_CREATE_TIME = COUNTDOWN_CONTRACT_CREATE_BASE + ( - "This is a time-based countdown/reminder. Use this exact shape:\n" - "countdown_contract_N: [created_at: ] " - "[due_at: ] " - "[current_time: ] [trigger: ]\n" - "Do not convert seconds/minutes/hours/days into turns/messages. Time words always create a time-based contract.\n" + "REMOVAL: keep a completed slot for one more snapshot with status noting completion, then remove.\n" ) -# Backward-compatible alias for external imports; prompt builder chooses the precise template. -COUNTDOWN_CONTRACT_CREATE = COUNTDOWN_CONTRACT_CREATE_TURN - # Правила взаимодействия с L2_pattern_evidence строками. # Подключается когда l2_pattern_evidence_ есть в памяти. L2_INTERFACE = ( @@ -708,8 +512,8 @@ "- 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, stored_memory_N, " - "open_contract, countdown_contract, durable facts, pending contracts, " + "- Context pressure may shorten temporary state, but must not remove active_memory, active_memory_N, " + "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" @@ -725,47 +529,6 @@ # - _CREATE → триггерная фраза есть в сообщении, но ключа в памяти нет # ============================================================================= -def classify_countdown_contract_trigger(user_message: str) -> str | None: - msg = (user_message or "").casefold().replace("ё", "е") - - wants_countdown = any( - token.casefold().replace("ё", "е") in msg - for token in TRIGGER_MSG_COUNTDOWN - ) - - if not wants_countdown: - return None - - has_time_trigger = any( - token.casefold().replace("ё", "е") in msg - for token in COUNTDOWN_TIME_TRIGGER_WORDS - ) - has_turn_trigger = any( - token.casefold().replace("ё", "е") in msg - for token in COUNTDOWN_TURN_TRIGGER_WORDS - ) - - if has_time_trigger and not has_turn_trigger: - return "time" - - if has_turn_trigger: - return "turn" - - return "turn" - - -def build_countdown_contract_create_prompt(user_message: str) -> str: - countdown_kind = classify_countdown_contract_trigger(user_message) - - if countdown_kind == "time": - return COUNTDOWN_CONTRACT_CREATE_TIME - - if countdown_kind == "turn": - return COUNTDOWN_CONTRACT_CREATE_TURN - - return "" - - def build_runtime_memory_system_prompt( *, current_memory: str = "", @@ -797,40 +560,8 @@ def build_runtime_memory_system_prompt( elif wants_confirmable: prompt += CONFIRMABLE_FACTS_CREATE - # ── stored_memory ───────────────────────────────────────────────────────── - has_stored = any(k in mem for k in TRIGGER_KEYS_STORED_MEMORY) - wants_stored = any(t in msg for t in TRIGGER_MSG_STORED_MEMORY) - - if has_stored: - prompt += STORED_MEMORY_KEEP - elif wants_stored: - prompt += STORED_MEMORY_CREATE - - # ── open_contract (спутник stored_memory) ───────────────────────────────── - has_open = any(k in mem for k in TRIGGER_KEYS_OPEN_CONTRACT) - - if has_open: - prompt += OPEN_CONTRACT_KEEP - elif wants_stored: - # создаём open_contract только если пользователь указал окно - window_words = ("через", "ходов", "after", "turns", "minutes", "минут") - if any(w in msg for w in window_words): - prompt += OPEN_CONTRACT_CREATE - - # ── countdown_contract ──────────────────────────────────────────────────── - has_countdown = any(k in mem for k in TRIGGER_KEYS_COUNTDOWN) - countdown_create_prompt = build_countdown_contract_create_prompt( - user_message - ) - wants_countdown = bool(countdown_create_prompt) - - if has_countdown: - prompt += COUNTDOWN_CONTRACT_KEEP - if wants_countdown and not has_stored: - # новый countdown может существовать параллельно старому numbered-контрактом - prompt += countdown_create_prompt - elif wants_countdown and not has_stored: - prompt += countdown_create_prompt + # ── active_memory ───────────────────────────────────────────────────────── + prompt += ACTIVE_MEMORY_CREATE # ── L2 interface ────────────────────────────────────────────────────────── if any(k in mem for k in TRIGGER_KEYS_L2_INTERFACE): diff --git a/runtime/L1_memory_utils.py b/runtime/L1_memory_utils.py index ffebb18f..16a2d239 100644 --- a/runtime/L1_memory_utils.py +++ b/runtime/L1_memory_utils.py @@ -1,5 +1,6 @@ import json import re +from datetime import datetime from difflib import SequenceMatcher from runtime.L1_memory_rules import ( @@ -53,6 +54,154 @@ RUNTIME_LAST_JIN_RESPONSE_FALLBACK_LIMIT = 180 +def _runtime_value_has_open_quote( + value: str, +) -> bool: + + escaped = False + quote_count = 0 + + for char in str(value or ""): + if escaped: + escaped = False + continue + + if char == "\\": + escaped = True + continue + + if char == '"': + quote_count += 1 + + return quote_count % 2 == 1 + + +def _looks_like_user_message_fragment( + line: str, +) -> bool: + + fragment = str(line or "").strip() + + if not fragment: + return False + + if ":" in fragment: + key, value = fragment.split( + ":", + 1, + ) + if normalize_memory_key( + key + ) != "note": + return False + fragment = value.strip() + + return ( + fragment.startswith('"') + or fragment.startswith('\\"') + or fragment.endswith('"') + or fragment.endswith('\\"') + or "\\n" in fragment + ) + + +def _line_has_memory_key( + line: str, + key: str, +) -> bool: + + stripped = str(line or "").strip().lstrip("-").strip() + + if ":" not in stripped: + return False + + line_key, _ = stripped.split( + ":", + 1, + ) + + return normalize_memory_key( + line_key + ) == normalize_memory_key( + key + ) + + +def _join_multiline_user_message_entries( + memory: str, +) -> list[str]: + + joined_lines: list[str] = [] + pending_line: str | None = None + + for raw_line in (memory or "").splitlines(): + line = raw_line.strip().lstrip("-").strip() + + if not line: + if pending_line is not None: + pending_line += "\\n" + continue + + if pending_line is not None: + pending_line += "\\n" + line + + if not _runtime_value_has_open_quote( + pending_line.split(":", 1)[1] + ): + joined_lines.append( + pending_line + ) + pending_line = None + + continue + + if ":" not in line: + if ( + joined_lines + and _line_has_memory_key( + joined_lines[-1], + RUNTIME_USER_MESSAGE_KEY, + ) + and _looks_like_user_message_fragment( + line + ) + ): + joined_lines[-1] = ( + joined_lines[-1].rstrip() + + "\\n" + + line + ) + continue + + joined_lines.append( + raw_line + ) + continue + + key, value = line.split( + ":", + 1, + ) + + if ( + normalize_memory_key(key) == RUNTIME_USER_MESSAGE_KEY + and _runtime_value_has_open_quote(value) + ): + pending_line = line + continue + + joined_lines.append( + raw_line + ) + + if pending_line is not None: + joined_lines.append( + pending_line + ) + + return joined_lines + + def _split_repeated_user_message_metadata( value: str, ) -> tuple[str, str]: @@ -114,102 +263,1288 @@ def quote_runtime_user_message_value( return quoted -def find_runtime_memory_entry_value( - memory: str, - key: str, -) -> str: +def find_runtime_memory_entry_value( + memory: str, + key: str, +) -> str: + + target_key = normalize_memory_key( + key + ) + + for line in parse_runtime_memory_lines( + memory + ): + if ( + normalize_memory_key( + line.get( + "key", + "", + ) + ) + == target_key + ): + return ( + line.get( + "value", + "", + ) + or "" + ).strip() + + return "" + + +def build_last_jin_response_fallback( + assistant_message: str, +) -> str: + + compact = re.sub( + r"\s+", + " ", + str(assistant_message or ""), + ).strip() + + if not compact: + compact = "No complete assistant answer was delivered." + + if len(compact) <= RUNTIME_LAST_JIN_RESPONSE_FALLBACK_LIMIT: + return compact + + return ( + compact[:RUNTIME_LAST_JIN_RESPONSE_FALLBACK_LIMIT - 3] + .rstrip() + + "..." + ) + + +def enforce_runtime_turn_fields( + memory: str, + *, + user_message: str, + assistant_message: str, + previous_memory: str = "", +) -> str: + + updated_memory = upsert_runtime_memory_entry_text( + memory, + RUNTIME_USER_MESSAGE_KEY, + quote_runtime_user_message_value( + user_message + ), + ) + + previous_last_jin_response = find_runtime_memory_entry_value( + previous_memory, + RUNTIME_LAST_JIN_RESPONSE_KEY, + ) + candidate_last_jin_response = find_runtime_memory_entry_value( + updated_memory, + RUNTIME_LAST_JIN_RESPONSE_KEY, + ) + + if ( + not candidate_last_jin_response + or ( + previous_last_jin_response + and candidate_last_jin_response == previous_last_jin_response + ) + ): + updated_memory = upsert_runtime_memory_entry_text( + updated_memory, + RUNTIME_LAST_JIN_RESPONSE_KEY, + build_last_jin_response_fallback( + assistant_message + ), + ) + + return updated_memory + + +ACTIVE_MEMORY_KEY_RE = re.compile( + r"^active_memory(?:_\d+)?$", + re.IGNORECASE, +) + + +def is_active_memory_key( + key: str, +) -> bool: + + return bool( + ACTIVE_MEMORY_KEY_RE.match( + str(key or "").strip() + ) + ) + + +MANAGED_RUNTIME_MEMORY_KEY_FAMILIES = ( + "active_memory", +) + +MANAGED_RUNTIME_MEMORY_KEY_RE = re.compile( + r"^(?Pactive_memory)(?:_(?P[a-zA-Z0-9_]+))?$", + re.IGNORECASE, +) + +RUNTIME_MEMORY_STATUS_FIELD_RE = re.compile( + r"\s*\[\s*status\s*:\s*[^\]]*\]\s*", + re.IGNORECASE, +) + +RUNTIME_MEMORY_STATUS_VALUE_RE = re.compile( + r"\[\s*status\s*:\s*([^\]]*)\]", + re.IGNORECASE, +) + +RUNTIME_MEMORY_TRACE_FIELD_RE = re.compile( + r"\s*(?:\[\s*trace\s*:\s*[^\]]*\]|\(\s*trace\s*:\s*[^)]*\))\s*", + re.IGNORECASE, +) + +ACTIVE_MEMORY_LIFECYCLE_SUFFIX_NAMES = ( + "creation_time", + "creation_turn_number", + "elapsed_time", + "elapsed_turns", +) + +ACTIVE_MEMORY_LIFECYCLE_SUFFIX_RE = re.compile( + ( + r"\s*\[\s*" + r"(?:creation_time|cr(?:e)?ation_turn_number|" + r"elapsed_time|ela(?:m)?psed_turns)" + r"\s*:\s*[^\]]*\]\s*" + ), + re.IGNORECASE, +) + + +def _match_managed_runtime_memory_key( + key: str, +) -> re.Match | None: + + return MANAGED_RUNTIME_MEMORY_KEY_RE.match( + str(key or "").strip() + ) + + +def _managed_runtime_memory_key_family( + key: str, +) -> str: + + match = _match_managed_runtime_memory_key( + key + ) + + if not match: + return "" + + return match.group( + "family" + ).casefold() + + +def _managed_runtime_memory_key_index( + key: str, +) -> int | None: + + match = _match_managed_runtime_memory_key( + key + ) + + if not match: + return None + + suffix = match.group( + "suffix" + ) + + if suffix is None or suffix == "": + return 1 + + if not suffix.isdigit(): + return None + + try: + index = int( + suffix + ) + except ValueError: + return None + + if index <= 0: + return None + + return index + + +def _managed_runtime_memory_key_has_numeric_suffix( + key: str, +) -> bool: + + match = _match_managed_runtime_memory_key( + key + ) + + if not match: + return False + + suffix = match.group( + "suffix" + ) + + return bool( + suffix + and suffix.isdigit() + ) + + +def _format_managed_runtime_memory_key( + family: str, + index: int, +) -> str: + + if index <= 1: + return family + + return f"{family}_{index}" + + +def _next_managed_runtime_memory_index( + used_indexes: set[int], +) -> int: + + index = 1 + + while index in used_indexes: + index += 1 + + used_indexes.add( + index + ) + + return index + + +def _normalize_managed_runtime_memory_value( + value: str, +) -> str: + + return re.sub( + r"\s+", + " ", + str(value or "").strip().casefold(), + ) + + +def strip_active_memory_runtime_metadata( + memory: str, +) -> str: + + parsed_lines = parse_runtime_memory_lines( + memory + ) + + if not any( + is_active_memory_key( + ( + line.get( + "key", + "", + ) + or "" + ).strip() + ) + for line in parsed_lines + ): + return memory or "" + + updated_lines = [] + + for line in parsed_lines: + key = ( + line.get( + "key", + "", + ) + or "" + ).strip() + value = ( + line.get( + "value", + "", + ) + or "" + ).strip() + + if is_active_memory_key( + key + ): + value = ACTIVE_MEMORY_LIFECYCLE_SUFFIX_RE.sub( + " ", + value, + ) + value = re.sub( + r"\s+", + " ", + value, + ).strip() + + updated_lines.append( + durable_memory_line_text({ + "key": key, + "value": value, + }) + ) + + return "\n".join( + line + for line in updated_lines + if line.strip() + ).strip() + + +def _parse_active_memory_suffix( + value: str, + suffix_name: str, +) -> str: + + pattern = re.compile( + r"\[\s*" + + re.escape( + suffix_name + ) + + r"\s*:\s*([^\]]*)\]", + re.IGNORECASE, + ) + match = pattern.search( + str(value or "") + ) + + if not match: + return "" + + return match.group( + 1 + ).strip() + + +def _parse_active_memory_creation_turn_suffix( + value: str, +) -> str: + + pattern = re.compile( + r"\[\s*cr(?:e)?ation_turn_number\s*:\s*([^\]]*)\]", + re.IGNORECASE, + ) + match = pattern.search( + str(value or "") + ) + + if not match: + return "" + + return match.group( + 1 + ).strip() + + +def _parse_runtime_datetime( + value: str, +) -> datetime | None: + + text = str(value or "").strip() + + if not text: + return None + + try: + return datetime.fromisoformat( + text.replace( + "Z", + "+00:00", + ) + ) + except ValueError: + return None + + +def _parse_runtime_int( + value, +) -> int | None: + + try: + return int( + str(value).strip() + ) + except (TypeError, ValueError): + return None + + +def _runtime_datetime_delta_seconds( + start: datetime | None, + end: datetime | None, +) -> int: + + if start is None or end is None: + return 0 + + if ( + start.tzinfo is None + and end.tzinfo is not None + ): + end = end.replace( + tzinfo=None + ) + + if ( + start.tzinfo is not None + and end.tzinfo is None + ): + start = start.replace( + tzinfo=None + ) + + return max( + 0, + int( + ( + end + - start + ).total_seconds() + ), + ) + + +def _format_runtime_elapsed_time( + seconds: int, +) -> str: + + seconds = max( + 0, + int( + seconds + ), + ) + hours, remainder = divmod( + seconds, + 3600, + ) + minutes, seconds = divmod( + remainder, + 60, + ) + + return f"{hours:02d}:{minutes:02d}:{seconds:02d}" + + +def _build_active_memory_lifecycle_suffixes( + *, + creation_time: str, + creation_turn_number: int, + elapsed_time: str, + elapsed_turns: int, +) -> str: + + values = { + "creation_time": creation_time, + "creation_turn_number": str( + creation_turn_number + ), + "elapsed_time": elapsed_time, + "elapsed_turns": str( + elapsed_turns + ), + } + + return " ".join( + f"[ {name}: {values[name]} ]" + for name in ACTIVE_MEMORY_LIFECYCLE_SUFFIX_NAMES + ) + + +def _attach_active_memory_lifecycle_suffixes_to_value( + value: str, + suffixes: str, +) -> str: + + cleaned = ACTIVE_MEMORY_LIFECYCLE_SUFFIX_RE.sub( + " ", + str(value or ""), + ) + cleaned = re.sub( + r"\s+", + " ", + cleaned, + ).strip() + + status_match = RUNTIME_MEMORY_STATUS_FIELD_RE.search( + cleaned + ) + + if not status_match: + return f"{cleaned} {suffixes}".strip() + + before_status = cleaned[:status_match.start()].rstrip() + status_and_tail = cleaned[status_match.start():].strip() + + return ( + f"{before_status} {suffixes} {status_and_tail}" + ).strip() + + +def refresh_active_memory_runtime_metadata( + memory: str, + *, + previous_memory: str = "", + context=None, +) -> str: + + parsed_lines = parse_runtime_memory_lines( + memory + ) + + if not any( + is_active_memory_key( + ( + line.get( + "key", + "", + ) + or "" + ).strip() + ) + for line in parsed_lines + ): + return memory or "" + + current_timestamp = str( + getattr( + context, + "timestamp", + "", + ) + or datetime.now().isoformat() + ) + current_datetime = ( + _parse_runtime_datetime( + current_timestamp + ) + or datetime.now() + ) + current_turn_number = ( + _parse_runtime_int( + getattr( + context, + "turn_number", + None, + ) + ) + or 0 + ) + + previous_active_values = {} + + for previous_line in parse_runtime_memory_lines( + previous_memory + ): + previous_key = ( + previous_line.get( + "key", + "", + ) + or "" + ).strip() + + if not is_active_memory_key( + previous_key + ): + continue + + previous_active_values[ + normalize_memory_key( + previous_key + ) + ] = ( + previous_line.get( + "value", + "", + ) + or "" + ).strip() + + updated_lines = [] + + for line in parsed_lines: + key = ( + line.get( + "key", + "", + ) + or "" + ).strip() + value = ( + line.get( + "value", + "", + ) + or "" + ).strip() + + if not is_active_memory_key( + key + ): + updated_lines.append( + durable_memory_line_text( + line + ) + ) + continue + + previous_value = previous_active_values.get( + normalize_memory_key( + key + ), + "", + ) + creation_time = ( + _parse_active_memory_suffix( + previous_value, + "creation_time", + ) + or current_timestamp + ) + creation_turn_number = ( + _parse_runtime_int( + _parse_active_memory_creation_turn_suffix( + previous_value, + ) + ) + ) + + if creation_turn_number is None: + creation_turn_number = current_turn_number + + elapsed_seconds = _runtime_datetime_delta_seconds( + _parse_runtime_datetime( + creation_time + ), + current_datetime, + ) + elapsed_turns = max( + 0, + current_turn_number + - creation_turn_number, + ) + suffixes = _build_active_memory_lifecycle_suffixes( + creation_time=creation_time, + creation_turn_number=creation_turn_number, + elapsed_time=_format_runtime_elapsed_time( + elapsed_seconds + ), + elapsed_turns=elapsed_turns, + ) + value = _attach_active_memory_lifecycle_suffixes_to_value( + value, + suffixes, + ) + + updated_lines.append( + f"{key}: {value}".strip() + ) + + return "\n".join( + line + for line in updated_lines + if line.strip() + ).strip() + + +def _value_has_runtime_status_field( + value: str, +) -> bool: + + return bool( + RUNTIME_MEMORY_STATUS_FIELD_RE.search( + str(value or "") + ) + ) + + +def ensure_active_memory_status_suffixes( + memory: str, +) -> str: + + parsed_lines = parse_runtime_memory_lines( + memory + ) + + if not any( + is_active_memory_key( + ( + line.get( + "key", + "", + ) + or "" + ).strip() + ) + for line in parsed_lines + ): + return memory or "" + + updated_lines = [] + + for line in parsed_lines: + key = ( + line.get( + "key", + "", + ) + or "" + ).strip() + value = ( + line.get( + "value", + "", + ) + or "" + ).strip() + + if ( + is_active_memory_key( + key + ) + and not _value_has_runtime_status_field( + value + ) + ): + value = f"{value} [ status: pending ]".strip() + + updated_lines.append( + durable_memory_line_text({ + "key": key, + "value": value, + }) + ) + + return "\n".join( + line + for line in updated_lines + if line.strip() + ).strip() + + +def _parse_runtime_status_suffix_value( + value: str, +) -> str: + + match = RUNTIME_MEMORY_STATUS_VALUE_RE.search( + str(value or "") + ) + + if not match: + return "" + + return re.sub( + r"\s+", + " ", + match.group( + 1 + ).strip(), + ) + + +def _merge_runtime_status_suffix_values( + previous_status: str, + candidate_status: str, +) -> str: + + previous_parts = [ + part.strip() + for part in str(previous_status or "").split(",") + if part.strip() + ] + candidate_parts = [ + part.strip() + for part in str(candidate_status or "").split(",") + if part.strip() + ] + merged_parts = [] + seen = set() + + for part in ( + previous_parts + + candidate_parts + ): + normalized_part = part.casefold() + + if normalized_part in seen: + continue + + seen.add( + normalized_part + ) + merged_parts.append( + part + ) + + return ", ".join( + merged_parts + ) + + +def _replace_runtime_status_suffix_value( + value: str, + status_value: str, +) -> str: + + replacement = f"[ status: {status_value or 'pending'} ]" + text = str(value or "").strip() + + if RUNTIME_MEMORY_STATUS_VALUE_RE.search( + text + ): + return RUNTIME_MEMORY_STATUS_VALUE_RE.sub( + replacement, + text, + count=1, + ).strip() + + return f"{text} {replacement}".strip() + + +def _merge_active_memory_status_suffix( + previous_value: str, + candidate_value: str, +) -> str: + + previous_status = _parse_runtime_status_suffix_value( + previous_value + ) + candidate_status = _parse_runtime_status_suffix_value( + candidate_value + ) + + if not candidate_status and previous_status: + return previous_value + + merged_status = _merge_runtime_status_suffix_values( + previous_status or "pending", + candidate_status or "pending", + ) + + return _replace_runtime_status_suffix_value( + previous_value, + merged_status, + ) + + +def _normalize_status_collapsible_runtime_value( + value: str, +) -> str: + + cleaned = RUNTIME_MEMORY_STATUS_FIELD_RE.sub( + " ", + str(value or ""), + ) + cleaned = RUNTIME_MEMORY_TRACE_FIELD_RE.sub( + " ", + cleaned, + ) + cleaned = strip_runtime_memory_repeated_suffix( + cleaned + ) + cleaned = strip_runtime_memory_confirmation_suffix( + cleaned + ) + + return re.sub( + r"\s+", + " ", + cleaned.strip().casefold(), + ) + + +def _managed_runtime_memory_values_are_status_duplicate( + previous_value: str, + candidate_value: str, +) -> bool: + + if not _value_has_runtime_status_field( + candidate_value + ): + return False + + previous_text = _normalize_status_collapsible_runtime_value( + previous_value + ) + candidate_text = _normalize_status_collapsible_runtime_value( + candidate_value + ) + + if not previous_text or not candidate_text: + return False + + if previous_text == candidate_text: + return True + + return SequenceMatcher( + None, + previous_text, + candidate_text, + ).ratio() >= 0.96 + + +def _managed_runtime_memory_candidate_collapses_to_previous( + *, + key: str, + value: str, + previous_entries: list[dict], +) -> dict | None: + + normalized_key = normalize_memory_key( + key + ) + + for previous_entry in previous_entries: + previous_key = normalize_memory_key( + previous_entry.get( + "key", + "", + ) + ) + + if ( + previous_key != normalized_key + and not _managed_runtime_memory_key_has_numeric_suffix( + key + ) + ): + continue + + if _managed_runtime_memory_values_are_status_duplicate( + previous_entry.get( + "value", + "", + ), + value, + ): + return previous_entry + + return None + + +def _build_managed_runtime_memory_entries( + memory: str, +) -> dict[str, list[dict]]: + + entries = { + family: [] + for family in MANAGED_RUNTIME_MEMORY_KEY_FAMILIES + } + used_indexes = { + family: set() + for family in MANAGED_RUNTIME_MEMORY_KEY_FAMILIES + } + + for line in parse_runtime_memory_lines( + memory + ): + original_key = ( + line.get( + "key", + "", + ) + or "" + ).strip() + family = _managed_runtime_memory_key_family( + original_key + ) + + if not family: + continue + + index = _managed_runtime_memory_key_index( + original_key + ) + + if ( + index is None + or index in used_indexes[family] + ): + index = _next_managed_runtime_memory_index( + used_indexes[family] + ) + key = _format_managed_runtime_memory_key( + family, + index, + ) + else: + used_indexes[family].add( + index + ) + key = original_key + + value = str( + line.get( + "value", + "", + ) + or "" + ).strip() + + entries[family].append({ + "key": key, + "value": value, + "index": index, + "line": f"{key}: {value}".strip(), + }) + + return entries + + +def normalize_managed_runtime_memory_slots( + previous_memory: str, + candidate_memory: str, + collapse_events: list[dict] | None = None, +) -> str: + """ + Deterministically manage active_memory slot keys. + + The L1 model may reuse an existing key, invent a suffix like _new, or try + to edit an old slot. Existing managed slots are anchors; conflicting + candidate appearances become the next numbered sibling. + """ + + previous_entries = _build_managed_runtime_memory_entries( + previous_memory + ) + used_indexes = { + family: { + entry["index"] + for entry in entries + } + for family, entries in previous_entries.items() + } + previous_keys = { + family: { + normalize_memory_key( + entry["key"] + ) + for entry in entries + } + for family, entries in previous_entries.items() + } + previous_values = { + family: { + _normalize_managed_runtime_memory_value( + entry["value"] + ) + for entry in entries + } + for family, entries in previous_entries.items() + } + emitted_previous_families = { + family: False + for family in MANAGED_RUNTIME_MEMORY_KEY_FAMILIES + } + emitted_previous_line_indexes = {} + updated_lines: list[str] = [] + + def emit_previous_family( + family: str, + ) -> None: + if emitted_previous_families[family]: + return + + emitted_previous_families[family] = True + + for entry in previous_entries[family]: + emitted_previous_line_indexes[ + ( + family, + entry["index"], + ) + ] = len( + updated_lines + ) + updated_lines.append( + entry["line"] + ) + + for line in parse_runtime_memory_lines( + candidate_memory + ): + key = ( + line.get( + "key", + "", + ) + or "" + ).strip() + value = ( + line.get( + "value", + "", + ) + or "" + ).strip() + family = _managed_runtime_memory_key_family( + key + ) + + if not family: + updated_lines.append( + durable_memory_line_text( + line + ) + ) + continue + + emit_previous_family( + family + ) + + cleaned_value = value + normalized_key = normalize_memory_key( + key + ) + normalized_value = _normalize_managed_runtime_memory_value( + cleaned_value + ) + + if ( + normalized_key in previous_keys[family] + and normalized_value in previous_values[family] + ): + continue + + if normalized_value in previous_values[family]: + continue + + collapsed_parent = _managed_runtime_memory_candidate_collapses_to_previous( + key=key, + value=cleaned_value, + previous_entries=previous_entries[family], + ) + + if collapsed_parent is not None: + parent_value_before = collapsed_parent.get( + "value", + "", + ) + merged_value = _merge_active_memory_status_suffix( + parent_value_before, + cleaned_value, + ) + collapsed_parent["value"] = merged_value + collapsed_parent["line"] = ( + f"{collapsed_parent['key']}: {merged_value}" + ).strip() + previous_values[family].add( + _normalize_managed_runtime_memory_value( + merged_value + ) + ) + line_index = emitted_previous_line_indexes.get( + ( + family, + collapsed_parent["index"], + ) + ) - target_key = normalize_memory_key( - key - ) + if line_index is not None: + updated_lines[line_index] = collapsed_parent["line"] - for line in parse_runtime_memory_lines( - memory - ): - if ( - normalize_memory_key( - line.get( + if ( + collapse_events is not None + and normalize_memory_key( + key + ) != normalize_memory_key( + collapsed_parent.get( + "key", + "", + ) + ) + ): + collapse_events.append({ + "family": family, + "candidate_key": key, + "candidate_value": cleaned_value, + "parent_key": collapsed_parent.get( "key", "", - ) - ) - == target_key - ): - return ( - line.get( - "value", - "", - ) - or "" - ).strip() - - return "" + ), + "parent_value_before": parent_value_before, + "parent_value_after": merged_value, + "result_line": collapsed_parent["line"], + }) + continue -def build_last_jin_response_fallback( - assistant_message: str, -) -> str: + index = _managed_runtime_memory_key_index( + key + ) - compact = re.sub( - r"\s+", - " ", - str(assistant_message or ""), - ).strip() + if ( + index is None + or index in used_indexes[family] + ): + index = _next_managed_runtime_memory_index( + used_indexes[family] + ) + else: + used_indexes[family].add( + index + ) - if not compact: - compact = "No complete assistant answer was delivered." + managed_key = _format_managed_runtime_memory_key( + family, + index, + ) + previous_keys[family].add( + normalize_memory_key( + managed_key + ) + ) + previous_values[family].add( + normalized_value + ) + updated_lines.append( + f"{managed_key}: {cleaned_value}".strip() + ) - if len(compact) <= RUNTIME_LAST_JIN_RESPONSE_FALLBACK_LIMIT: - return compact + for family in MANAGED_RUNTIME_MEMORY_KEY_FAMILIES: + emit_previous_family( + family + ) - return ( - compact[:RUNTIME_LAST_JIN_RESPONSE_FALLBACK_LIMIT - 3] - .rstrip() - + "..." - ) + return "\n".join( + line + for line in updated_lines + if line.strip() + ).strip() -def enforce_runtime_turn_fields( +def enrich_active_memory_recall_conditions( memory: str, *, user_message: str, assistant_message: str, - previous_memory: str = "", ) -> str: + """ + Preserve active_memory exactly as L1 wrote it. - updated_memory = upsert_runtime_memory_entry_text( - memory, - RUNTIME_USER_MESSAGE_KEY, - quote_runtime_user_message_value( - user_message - ), - ) - - previous_last_jin_response = find_runtime_memory_entry_value( - previous_memory, - RUNTIME_LAST_JIN_RESPONSE_KEY, - ) - candidate_last_jin_response = find_runtime_memory_entry_value( - updated_memory, - RUNTIME_LAST_JIN_RESPONSE_KEY, + The active_memory contract is now owned by the L1 prompt. This deterministic + pass intentionally does not infer, add, or mutate conditions/status fields. + """ + _ = ( + user_message, + assistant_message, ) - - if ( - not candidate_last_jin_response - or ( - previous_last_jin_response - and candidate_last_jin_response == previous_last_jin_response - ) - ): - updated_memory = upsert_runtime_memory_entry_text( - updated_memory, - RUNTIME_LAST_JIN_RESPONSE_KEY, - build_last_jin_response_fallback( - assistant_message - ), - ) - - return updated_memory + return memory or "" def strip_runtime_memory_repeated_suffix( value: str, @@ -434,10 +1769,13 @@ def remove_runtime_memory_entry_text( lines = [ line.rstrip() - for line in str(memory or "").splitlines() + for line in _join_multiline_user_message_entries( + memory + ) ] kept_lines = [] + removing_user_message_tail = False for line in lines: stripped = line.strip() @@ -447,8 +1785,21 @@ def remove_runtime_memory_entry_text( current_key = stripped.split(":", 1)[0].strip().casefold() if current_key == target_key_normalized: + removing_user_message_tail = ( + target_key_normalized == RUNTIME_USER_MESSAGE_KEY + ) + continue + + if ( + removing_user_message_tail + and _looks_like_user_message_fragment( + stripped + ) + ): continue + removing_user_message_tail = False + kept_lines.append(stripped) return "\n".join(kept_lines).strip() @@ -469,11 +1820,14 @@ def upsert_runtime_memory_entry_text( lines = [ line.rstrip() - for line in str(memory or "").splitlines() + for line in _join_multiline_user_message_entries( + memory + ) ] updated_lines = [] replaced = False + removing_user_message_tail = False for line in lines: stripped = line.strip() @@ -486,8 +1840,21 @@ def upsert_runtime_memory_entry_text( if not replaced: updated_lines.append(replacement) replaced = True + removing_user_message_tail = ( + target_key_normalized == RUNTIME_USER_MESSAGE_KEY + ) continue + if ( + removing_user_message_tail + and _looks_like_user_message_fragment( + stripped + ) + ): + continue + + removing_user_message_tail = False + updated_lines.append(stripped) if not replaced: @@ -850,7 +2217,9 @@ def build_interrupted_assistant_message( def parse_runtime_memory_lines(memory: str) -> list[dict]: lines = [] - for raw_line in (memory or "").splitlines(): + for raw_line in _join_multiline_user_message_entries( + memory + ): line = raw_line.strip().lstrip("-").strip() if not line: @@ -1558,245 +2927,6 @@ def build_runtime_memory_patch( "total_diff": total_diff, } - - -COUNTDOWN_MEMORY_KEY_RE = re.compile( - r"^countdown_contract(?:_\d+)?$", - re.IGNORECASE, -) - -COUNTDOWN_SUFFIX_RE = re.compile( - r"\[\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*([^\]]*)\]" -) - -COUNTDOWN_LEGACY_FIELD_RE = re.compile( - r"\s*;\s*(?:current|remaining|current_time|status)\s*:\s*[^;\[]*", - re.IGNORECASE, -) - -COUNTDOWN_LEGACY_ANCHOR_RE = re.compile( - r"\s*;\s*(created_at|created_user_message_count|count_from|count_to|due_user_message_count|due_at|trigger)\s*:\s*([^;\[]*)", - re.IGNORECASE, -) - - -def is_countdown_memory_key(key: str) -> bool: - - return bool( - COUNTDOWN_MEMORY_KEY_RE.match( - str(key or "").strip() - ) - ) - - -def parse_countdown_suffixes(value: str) -> dict[str, str]: - - return { - match.group(1).strip().casefold(): match.group(2).strip() - for match in COUNTDOWN_SUFFIX_RE.finditer(value or "") - } - - -def strip_countdown_suffixes(value: str) -> str: - - without_suffixes = COUNTDOWN_SUFFIX_RE.sub( - "", - value or "", - ) - - without_legacy_runtime = COUNTDOWN_LEGACY_FIELD_RE.sub( - "", - without_suffixes, - ) - - return re.sub( - r"\s+", - " ", - without_legacy_runtime, - ).strip().strip(";").strip() - - -def _parse_int(value) -> int | None: - - try: - return int(str(value).strip()) - except (TypeError, ValueError): - return None - - -def _extract_legacy_countdown_fields(value: str) -> dict[str, str]: - - fields = {} - - for match in COUNTDOWN_LEGACY_ANCHOR_RE.finditer(value or ""): - key = match.group(1).strip().casefold() - fields[key] = match.group(2).strip() - - return fields - - -def _countdown_suffix_text(suffixes: dict[str, str]) -> str: - - ordered_keys = ( - "created_at", - "created_user_message_count", - "count_from", - "count_to", - "due_user_message_count", - "current", - "remaining", - "due_at", - "current_time", - "trigger", - "completed", - "cancelled", - ) - - parts = [] - emitted = set() - - for key in ordered_keys: - value = suffixes.get(key) - if value is None or str(value).strip() == "": - continue - parts.append(f"[{key}: {str(value).strip()}]") - emitted.add(key) - - for key, value in suffixes.items(): - if key in emitted or key == "status": - continue - if str(value).strip() == "": - continue - parts.append(f"[{key}: {str(value).strip()}]") - - return " ".join(parts) - - -def update_countdown_contract_line( - *, - key: str, - value: str, - current_user_message_count=None, - current_timestamp: str = "", -) -> str | None: - - if not is_countdown_memory_key(key): - return f"{key}: {value}".strip() - - suffixes = parse_countdown_suffixes(value) - legacy_fields = _extract_legacy_countdown_fields(value) - - for legacy_key, legacy_value in legacy_fields.items(): - suffixes.setdefault( - legacy_key, - legacy_value, - ) - - suffixes.pop( - "status", - None, - ) - - if ( - "completed" in suffixes - or "cancelled" in suffixes - ): - return None - - body = strip_countdown_suffixes(value) - - current_count = _parse_int( - current_user_message_count - ) - - count_to = _parse_int( - suffixes.get("count_to") - or suffixes.get("due_user_message_count") - ) - - if count_to is not None: - if current_count is not None: - suffixes["current"] = str(current_count) - suffixes["remaining"] = str( - max( - count_to - current_count, - 0, - ) - ) - suffixes.setdefault( - "due_user_message_count", - str(count_to), - ) - - due_at = suffixes.get( - "due_at" - ) - - if due_at and current_timestamp: - suffixes["current_time"] = str(current_timestamp) - - rendered_suffixes = _countdown_suffix_text( - suffixes - ) - - if rendered_suffixes: - return f"{key}: {body} {rendered_suffixes}".strip() - - return f"{key}: {body}".strip() - - -def refresh_countdown_contracts( - memory: str, - context=None, -) -> str: - - current_user_message_count = getattr( - context, - "user_message_count", - None, - ) if context is not None else None - - current_timestamp = str( - getattr( - context, - "timestamp", - "", - ) - or "" - ) - - updated_lines = [] - - for raw_line in str(memory or "").splitlines(): - line = raw_line.strip().lstrip("-").strip() - - if not line: - continue - - if ":" not in line: - updated_lines.append(line) - continue - - key, value = line.split(":", 1) - key = key.strip() - value = value.strip() - - if not is_countdown_memory_key(key): - updated_lines.append(line) - continue - - updated_line = update_countdown_contract_line( - key=key, - value=value, - current_user_message_count=current_user_message_count, - current_timestamp=current_timestamp, - ) - - if updated_line: - updated_lines.append(updated_line) - - return "\n".join(updated_lines).strip() - def build_runtime_memory_snapshot( context, memory: str, diff --git a/runtime/L2_memory_rules.py b/runtime/L2_memory_rules.py index e874aa2e..a13db475 100644 --- a/runtime/L2_memory_rules.py +++ b/runtime/L2_memory_rules.py @@ -117,7 +117,7 @@ # ───────────────────────────────────────────────────────────────────────────── SPAN_METADATA = ( "Every possible pattern, emerging signal, or observed tendency MUST include span metadata:\n" - " first_seen_snapshot: S1; last_seen_snapshot: S2; " + " first_seen_snapshot: N; last_seen_snapshot: N; " "evidence summary: ; confidence: low|medium|high.\n" ) diff --git a/runtime/memory_common.py b/runtime/memory_common.py index 23e779b3..4c0e16eb 100644 --- a/runtime/memory_common.py +++ b/runtime/memory_common.py @@ -425,6 +425,59 @@ async def log_memory_event( formatted_message, ) + +async def log_active_memory_event( + context, + *, + message: str, + details: str | None = None, + fallback_channel: str = "runtime", + event: str | None = None, +) -> None: + + logger = getattr( + context, + "logger", + None, + ) + + log_active_memory = getattr( + logger, + "log_active_memory", + None, + ) + + if log_active_memory is not None: + await safe_call( + log_active_memory, + message, + details=details, + event=event, + ) + return + + fallback = getattr( + logger, + f"log_{fallback_channel}", + None, + ) + formatted_message = ( + f"[ACTIVE_MEMORY] {message}" + ) + + if details is not None: + await safe_call( + fallback, + formatted_message, + details=details, + ) + return + + await safe_call( + fallback, + formatted_message, + ) + def extract_runtime_memory_text( response: dict, ) -> str: diff --git a/tests/test_behavior_probe_word.py b/tests/test_behavior_probe_word.py index a269a4fd..b4e9d0db 100644 --- a/tests/test_behavior_probe_word.py +++ b/tests/test_behavior_probe_word.py @@ -55,15 +55,15 @@ # EDIT THIS BLOCK FIRST # ============================================================================= -SCENARIO_ID = "recall_word_surprise_countdown" -SCENARIO_TITLE = "Recall word surprise countdown" +SCENARIO_ID = "recall_word_surprise_window" +SCENARIO_TITLE = "Recall word surprise window" SCENARIO_NOTES = """ Five-step probe for the recall-word contract. Turn 1 asks JIN to choose and reveal a word immediately, then ask for it back at any self-selected moment within the next three user turns. The probe extracts the proposed word from the first answer after a colon or dash-like separator, injects that word into dynamic forbidden-answer checks for later turns, -and then watches whether a recall question appears during the countdown window +and then watches whether a recall question appears during the recall window without revealing the word again. The exact extracted word must also appear in runtime memory after turn 1. """ @@ -91,7 +91,7 @@ ) EXPECTED_TEXT_ANSWER_1 = [] EXPECTED_TEXT_MEMORY_1 = [ - "stored_memory", + "active_memory", ] UNEXPECTED_TEXT_ANSWER_1 = [] UNEXPECTED_TEXT_MEMORY_1 = [] @@ -137,7 +137,7 @@ PRINT_WEBSOCKET_MESSAGES = False LIVE_STREAM_MODEL_OUTPUT = True LIVE_PRINT_TURN_RESULTS = True -PRINT_STORED_MEMORY_DEBUG = True +PRINT_ACTIVE_MEMORY_DEBUG = True USE_ANSI_COLORS = True MAX_ANSWER_PREVIEW_CHARS = 1400 @@ -153,7 +153,7 @@ # refresh_pending_brain_usage() and before AgentRuntime.run(). This makes the # probe show what memory was available to the next brain turn, not only what # ended up in the post-turn snapshot. Unknown/missing fields are ignored. -CONTEXT_STORED_MEMORY_DEBUG_FIELDS_TO_SCAN = [ +CONTEXT_ACTIVE_MEMORY_DEBUG_FIELDS_TO_SCAN = [ "runtime_memory", "runtime_l2_memory", "runtime_memory_snapshots", @@ -565,10 +565,7 @@ def split_memory_contract_value_and_suffixes(raw_value: str) -> tuple[str, list[ Split a memory contract payload into the visible value and trailing suffixes. Supports both old parenthetical suffixes: - stored_memory: облако (purpose: recall challenge; status: pending) - - and countdown bracket suffixes: - countdown_contract: remind user [current: 2] [remaining: 1] + active_memory: облако (purpose: recall challenge; status: pending) Parentheses are balanced, so nested diagnostic text like conditions: ... (reminded: 0) stays inside the same metadata suffix. @@ -596,8 +593,8 @@ def split_memory_contract_value_and_suffixes(raw_value: str) -> tuple[str, list[ return value, suffixes -def split_stored_memory_value_and_suffixes(raw_value: str) -> tuple[str, list[str]]: - """Backward-compatible name for existing stored_memory shape tests.""" +def split_active_memory_value_and_suffixes(raw_value: str) -> tuple[str, list[str]]: + """Backward-compatible name for existing active_memory shape tests.""" return split_memory_contract_value_and_suffixes(raw_value) @@ -623,58 +620,28 @@ def summarize_contract_progress(key: str, suffix_text: str) -> str: elapsed, total = turn_match.groups() return f"turn {elapsed}/{total}" - current = extract_suffix_field(suffix_text, "current") - remaining = extract_suffix_field(suffix_text, "remaining") - count_to = extract_suffix_field(suffix_text, "count_to") or extract_suffix_field( - suffix_text, - "due_user_message_count", - ) - - if current or remaining or count_to: - parts = [] - if current: - parts.append(f"current={current}") - if count_to: - parts.append(f"due={count_to}") - if remaining: - parts.append(f"remaining={remaining}") - return ", ".join(parts) - - current_time = extract_suffix_field(suffix_text, "current_time") - due_at = extract_suffix_field(suffix_text, "due_at") - if current_time or due_at: - parts = [] - if current_time: - parts.append(f"current_time={current_time}") - if due_at: - parts.append(f"due_at={due_at}") - return ", ".join(parts) - reminded_match = re.search(r"\breminded\s*:\s*(\d+)\b", suffix_text, flags=re.IGNORECASE) - if key.casefold().startswith("stored_memory") and reminded_match: + if key.casefold().startswith("active_memory") and reminded_match: return f"reminded={reminded_match.group(1)}" return "" -def extract_stored_memory_entries(text: Any, source: str = "") -> list[dict[str, str]]: +def extract_active_memory_entries(text: Any, source: str = "") -> list[dict[str, str]]: """ - Return active memory-contract entries from a text-ish blob. + Return active_memory entries from a text-ish blob. - The historical function name is kept because the probe already calls it, - but the console now shows stored_memory, open_contract and countdown_contract - lines. This is what exposes turn/current/remaining suffixes when runtime - actually has them. + The historical function name is kept because the probe already calls it. """ entries: list[dict[str, str]] = [] source_text = render_text(text) - if not any(token in source_text for token in ("stored_memory", "open_contract", "countdown_contract")): + if "active_memory" not in source_text: return entries entry_pattern = re.compile( r"^\s*[\"']?" - r"(?Pstored_memory(?:_\d+)?|open_contract(?:_\d+)?|countdown_contract(?:_\d+)?)" + r"(?Pactive_memory(?:_\d+)?)" r"[\"']?\s*[:=]\s*(?P.+?)\s*$", flags=re.IGNORECASE, ) @@ -702,7 +669,7 @@ def extract_stored_memory_entries(text: Any, source: str = "") -> list[dict[str, return entries -def render_stored_memory_entries(entries: list[dict[str, str]]) -> str: +def render_active_memory_entries(entries: list[dict[str, str]]) -> str: if not entries: return paint("", "gray", dim=True) @@ -729,22 +696,22 @@ def render_stored_memory_entries(entries: list[dict[str, str]]) -> str: return "\n".join(lines) -def collect_stored_memory_entries_from_context( +def collect_active_memory_entries_from_context( context: RuntimeContext, *, source_prefix: str, ) -> list[dict[str, str]]: """ - Shallow-scan known RuntimeContext fields for memory contract lines. + Shallow-scan known RuntimeContext fields for active_memory lines. - The goal is diagnostic output, not assertions: show what active recall / - reminder contract state is present immediately before the next brain turn. + The goal is diagnostic output, not assertions: show what active recall + state is present immediately before the next brain turn. """ entries: list[dict[str, str]] = [] seen_raw: set[tuple[str, str]] = set() - for field_name in CONTEXT_STORED_MEMORY_DEBUG_FIELDS_TO_SCAN: + for field_name in CONTEXT_ACTIVE_MEMORY_DEBUG_FIELDS_TO_SCAN: if not hasattr(context, field_name): continue @@ -764,8 +731,8 @@ def collect_stored_memory_entries_from_context( continue # Avoid dumping full websocket/logger/client objects. For lists/dicts, - # render_text(str(value)) is enough for the memory contract line probe. - field_entries = extract_stored_memory_entries( + # render_text(str(value)) is enough for the active_memory line probe. + field_entries = extract_active_memory_entries( value, source=f"{source_prefix}.{field_name}", ) @@ -779,12 +746,12 @@ def collect_stored_memory_entries_from_context( return entries -def collect_snapshot_stored_memory_entries(memory_blob: str) -> list[dict[str, str]]: - return extract_stored_memory_entries(memory_blob, source="post_turn_snapshot") +def collect_snapshot_active_memory_entries(memory_blob: str) -> list[dict[str, str]]: + return extract_active_memory_entries(memory_blob, source="post_turn_snapshot") -def format_stored_memory_debug(title: str, entries: list[dict[str, str]]) -> str: - return paint(title, "yellow", bold=True) + "\n" + indent_block(render_stored_memory_entries(entries)) +def format_active_memory_debug(title: str, entries: list[dict[str, str]]) -> str: + return paint(title, "yellow", bold=True) + "\n" + indent_block(render_active_memory_entries(entries)) def collect_dialogue_steps() -> list[dict[str, Any]]: """ @@ -833,8 +800,8 @@ class TurnResult: expected_memory: list[str] unexpected_answer: list[str] unexpected_memory: list[str] - context_stored_memory_before_turn: str = "" - snapshot_stored_memory_after_turn: str = "" + context_active_memory_before_turn: str = "" + snapshot_active_memory_after_turn: str = "" def print_live_turn_result(turn: TurnResult) -> None: @@ -855,15 +822,15 @@ def print_live_turn_result(turn: TurnResult) -> None: description = f"{check['target']} contains: {check['fragment']}" print(f" {status_label(check['passed'])} {description}", flush=True) - if PRINT_STORED_MEMORY_DEBUG: - if turn.context_stored_memory_before_turn: + if PRINT_ACTIVE_MEMORY_DEBUG: + if turn.context_active_memory_before_turn: print( - indent_block(turn.context_stored_memory_before_turn, prefix=" "), + indent_block(turn.context_active_memory_before_turn, prefix=" "), flush=True, ) - if turn.snapshot_stored_memory_after_turn: + if turn.snapshot_active_memory_after_turn: print( - indent_block(turn.snapshot_stored_memory_after_turn, prefix=" "), + indent_block(turn.snapshot_active_memory_after_turn, prefix=" "), flush=True, ) @@ -882,9 +849,9 @@ async def run_standard_turn(context: RuntimeContext, user_text: str) -> AgentSta else: context.runtime_usage_events = [] - context.behavior_probe_context_stored_memory_before_turn = format_stored_memory_debug( + context.behavior_probe_context_active_memory_before_turn = format_active_memory_debug( "MEMORY CONTRACTS PASSED TO CONTEXT BEFORE TURN", - collect_stored_memory_entries_from_context( + collect_active_memory_entries_from_context( context, source_prefix="context_before_turn", ), @@ -1063,11 +1030,11 @@ def print_behavior_probe_report(report: dict[str, Any]) -> None: for fragment in turn["unexpected_memory"]: print(f" {status_label(not fragment_found(turn['memory_after_turn'], fragment))} not: {fragment}") - if PRINT_STORED_MEMORY_DEBUG: - if turn.get("context_stored_memory_before_turn"): - print(indent_block(turn["context_stored_memory_before_turn"])) - if turn.get("snapshot_stored_memory_after_turn"): - print(indent_block(turn["snapshot_stored_memory_after_turn"])) + if PRINT_ACTIVE_MEMORY_DEBUG: + if turn.get("context_active_memory_before_turn"): + print(indent_block(turn["context_active_memory_before_turn"])) + if turn.get("snapshot_active_memory_after_turn"): + print(indent_block(turn["snapshot_active_memory_after_turn"])) print("\n" + paint("TEXT CHECKS", "blue", bold=True)) if not score["checks"]: @@ -1112,45 +1079,29 @@ def test_collect_dialogue_steps_finds_seed_steps(self): self.assertIn("спасибо", steps[3]["user_text"]) self.assertIn("хорошо", steps[4]["user_text"]) - def test_extract_stored_memory_entries_splits_value_and_suffixes(self): - blob = '[runtime_memory]\nstored_memory: облако (purpose: recall challenge; turns_left: 2; status: pending)' - entries = extract_stored_memory_entries(blob, source="unit") + def test_extract_active_memory_entries_splits_value_and_suffixes(self): + blob = '[runtime_memory]\nactive_memory: облако (purpose: recall challenge; turns_left: 2; status: pending)' + entries = extract_active_memory_entries(blob, source="unit") self.assertEqual(len(entries), 1) - self.assertEqual(entries[0]["key"], "stored_memory") + self.assertEqual(entries[0]["key"], "active_memory") self.assertEqual(entries[0]["value"], "облако") self.assertEqual(entries[0]["suffixes"], "(purpose: recall challenge; turns_left: 2; status: pending)") - def test_extract_stored_memory_entries_keeps_nested_conditions_suffix(self): + def test_extract_active_memory_entries_keeps_nested_conditions_suffix(self): blob = ( '[runtime_memory]\n' - 'stored_memory: облако ' + 'active_memory: облако ' '(purpose: recall challenge; conditions: do not say secret word, ' 'remind one time (reminded: 0); status: pending)' ) - entries = extract_stored_memory_entries(blob, source="unit") + entries = extract_active_memory_entries(blob, source="unit") self.assertEqual(len(entries), 1) - self.assertEqual(entries[0]["key"], "stored_memory") + self.assertEqual(entries[0]["key"], "active_memory") self.assertEqual(entries[0]["value"], "облако") self.assertIn("conditions: do not say secret word", entries[0]["suffixes"]) self.assertIn("reminded: 0", entries[0]["suffixes"]) self.assertIn("status: pending", entries[0]["suffixes"]) - def test_extract_contract_entries_keeps_countdown_bracket_suffixes(self): - blob = ( - '[runtime_memory]\n' - 'countdown_contract: ask user to recall the stored word ' - '[created_at: 2026-06-20T08:00:00] [count_from: 1] ' - '[count_to: 4] [current: 2] [remaining: 2] ' - '[trigger: ask recall question without revealing value]' - ) - entries = extract_stored_memory_entries(blob, source="unit") - self.assertEqual(len(entries), 1) - self.assertEqual(entries[0]["key"], "countdown_contract") - self.assertIn("[current: 2]", entries[0]["suffixes"]) - self.assertIn("[remaining: 2]", entries[0]["suffixes"]) - self.assertIn("current=2", entries[0]["progress"]) - self.assertIn("remaining=2", entries[0]["progress"]) - def test_extract_recall_word_strips_markdown_and_emoji(self): answer = "Интересная игра на внимание. Первое слово: **Книга** 📖📚" self.assertEqual(extract_recall_word_from_first_answer(answer), "Книга") @@ -1233,9 +1184,9 @@ def test_evaluator_tracks_dynamic_recall_question(self): index=1, user_text=USER_TEXT_1, answer="Интересная игра. Первое слово: **Книга** 📖📚", - memory_after_turn='stored_memory: "Книга" (purpose: recall test; status: pending)', + memory_after_turn='active_memory: "Книга" (purpose: recall test; status: pending)', expected_answer=[], - expected_memory=["stored_memory"], + expected_memory=["active_memory"], unexpected_answer=[], unexpected_memory=[], ), @@ -1243,7 +1194,7 @@ def test_evaluator_tracks_dynamic_recall_question(self): index=2, user_text=USER_TEXT_2, answer="Вот домик:\n /\\\n/ \\\n| [] |", - memory_after_turn='stored_memory: "Книга" (purpose: recall test; status: pending)', + memory_after_turn='active_memory: "Книга" (purpose: recall test; status: pending)', expected_answer=[], expected_memory=[], unexpected_answer=[], @@ -1253,7 +1204,7 @@ def test_evaluator_tracks_dynamic_recall_question(self): index=3, user_text=USER_TEXT_3, answer="Лягушка в пруду...", - memory_after_turn='stored_memory: "Книга" (purpose: recall test; status: pending)', + memory_after_turn='active_memory: "Книга" (purpose: recall test; status: pending)', expected_answer=[], expected_memory=[], unexpected_answer=[], @@ -1263,7 +1214,7 @@ def test_evaluator_tracks_dynamic_recall_question(self): index=4, user_text=USER_TEXT_4, answer="Пожалуйста. Помнишь то слово, которое ты хотел(а) вспомнить?", - memory_after_turn='stored_memory: "Книга" (purpose: recall test; status: pending)', + memory_after_turn='active_memory: "Книга" (purpose: recall test; status: pending)', expected_answer=[], expected_memory=[], unexpected_answer=[], @@ -1273,7 +1224,7 @@ def test_evaluator_tracks_dynamic_recall_question(self): index=5, user_text=USER_TEXT_5, answer="Спасибо за слова. Кстати, продолжим игру памяти.", - memory_after_turn='stored_memory: "Книга" (purpose: recall test; status: pending)', + memory_after_turn='active_memory: "Книга" (purpose: recall test; status: pending)', expected_answer=[], expected_memory=[], unexpected_answer=[], @@ -1333,14 +1284,14 @@ async def test_recall_word_behavior_probe(self): or "" ) memory_after_turn = build_memory_blob(self.context) - context_stored_memory_before_turn = getattr( + context_active_memory_before_turn = getattr( self.context, - "behavior_probe_context_stored_memory_before_turn", + "behavior_probe_context_active_memory_before_turn", "", ) - snapshot_stored_memory_after_turn = format_stored_memory_debug( + snapshot_active_memory_after_turn = format_active_memory_debug( "MEMORY CONTRACTS IN SNAPSHOT AFTER TURN", - collect_snapshot_stored_memory_entries(memory_after_turn), + collect_snapshot_active_memory_entries(memory_after_turn), ) turns.append( @@ -1353,8 +1304,8 @@ async def test_recall_word_behavior_probe(self): expected_memory=step["expected_memory"], unexpected_answer=step["unexpected_answer"], unexpected_memory=step["unexpected_memory"], - context_stored_memory_before_turn=context_stored_memory_before_turn, - snapshot_stored_memory_after_turn=snapshot_stored_memory_after_turn, + context_active_memory_before_turn=context_active_memory_before_turn, + snapshot_active_memory_after_turn=snapshot_active_memory_after_turn, ) ) print_live_turn_result(turns[-1]) @@ -1376,8 +1327,8 @@ async def test_recall_word_behavior_probe(self): "expected_memory": turn.expected_memory, "unexpected_answer": turn.unexpected_answer, "unexpected_memory": turn.unexpected_memory, - "context_stored_memory_before_turn": turn.context_stored_memory_before_turn, - "snapshot_stored_memory_after_turn": turn.snapshot_stored_memory_after_turn, + "context_active_memory_before_turn": turn.context_active_memory_before_turn, + "snapshot_active_memory_after_turn": turn.snapshot_active_memory_after_turn, } for turn in turns ], diff --git a/tests/test_message_memory.py b/tests/test_message_memory.py index fc4606e1..fe6f7192 100644 --- a/tests/test_message_memory.py +++ b/tests/test_message_memory.py @@ -31,9 +31,13 @@ from runtime.L1_memory_utils import ( build_runtime_memory_snapshot, enforce_runtime_turn_fields, + ensure_active_memory_status_suffixes, get_strength_zones, + normalize_managed_runtime_memory_slots, parse_runtime_memory_lines, quote_runtime_user_message_value, + refresh_active_memory_runtime_metadata, + strip_active_memory_runtime_metadata, ) from runtime.L3_memory_utils import ( build_l3_session_memory_max_tokens, @@ -145,6 +149,7 @@ class FakeLogger: def __init__(self): self.service_logs = [] self.summarizer_logs = [] + self.active_memory_logs = [] self.runtime_logs = [] self.errors = [] @@ -167,9 +172,9 @@ async def log_service( ) async def log_summarizer( - self, - message: str, - details: str | None = None, + self, + message: str, + details: str | None = None, ): self.summarizer_logs.append( @@ -179,6 +184,21 @@ async def log_summarizer( ) ) + async def log_active_memory( + self, + message: str, + details: str | None = None, + event: str | None = None, + ): + + self.active_memory_logs.append( + ( + message, + details, + event, + ) + ) + async def log_error( self, message: str, @@ -398,8 +418,7 @@ def test_runtime_memory_prompt_focuses_on_summary_depth(self): "last_jin_response", "user_fact", "jin_fact", - "stored_memory", - "countdown_contract", + "active_memory", "CURRENT_TRUSTED_RUNTIME_VARIABLES", "USER_DATETIME", ): @@ -410,7 +429,7 @@ def test_runtime_memory_prompt_focuses_on_summary_depth(self): for conditional_text in ( "L2_pattern_evidence_N", - "stored_memory is a high-priority active recall contract", + "active_memory and active_memory_N are high-priority active recall contracts", "The user asked JIN to remember a specific value", "identity_state: JIN identity remains unchanged", ): @@ -434,14 +453,14 @@ def test_runtime_memory_prompt_adds_conditional_blocks_from_memory_and_user_mess prompt = build_runtime_memory_system_prompt( current_memory=( - "stored_memory: \"banana\" (purpose: test; status: pending)\n" + "active_memory: \"banana\" (purpose: test; status: pending)\n" "L2_pattern_evidence_1: repeat question [ occurrences: 2 ]" ), user_message="ты теперь пират", ) for required_text in ( - "stored_memory is a high-priority active recall contract", + "Scan all existing active_memory family slots", "L2_pattern_evidence_N lines are owned by L2", "identity_state: JIN identity remains unchanged", ): @@ -460,11 +479,12 @@ def test_runtime_memory_prompt_adds_create_block_from_user_message(self): # Keep this test focused on the durable create-block contracts, # not on exact prompt prose. for required_text in ( - "Store it as stored_memory", - "stored_memory:", - "", - "stored_memory_N", - "The user specified a recall window", + "Write active_memory only when this turn creates an active contract", + "active_memory:", + "", + "active_memory_2", + "conditions:", + "", ): self.assertIn( required_text, @@ -700,89 +720,6 @@ 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_1: Recall the secret word "Sky" ' - "[created_at: 2026-06-17T18:00:00] " - "[created_user_message_count: 1] " - "[count_from: 1] [count_to: 4] " - "[current: 2] [remaining: 2] " - "[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( - "countdown_contract_N lines", - prompt, - ) - self.assertIn( - "use bracket suffixes", - prompt, - ) - self.assertIn( - "Do not require or trust status text", - prompt, - ) - self.assertIn( - "if [remaining: N] is greater than 0", - prompt, - ) - self.assertIn( - "if [remaining: 0], execute [trigger]", - prompt, - ) - self.assertNotIn( - "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( @@ -1394,6 +1331,511 @@ def test_quote_runtime_user_message_preserves_verbatim_quotes(self): '"\\"hello\\""', ) + def test_parse_runtime_memory_keeps_multiline_user_message_together(self): + + lines = parse_runtime_memory_lines( + 'user_message: "first line\n' + 'second line\n' + 'third line"\n' + "standalone continuation stays note\n" + "last_jin_response: Answered." + ) + + self.assertEqual( + lines[0]["key"], + "user_message", + ) + self.assertEqual( + lines[0]["value"], + '"first line\\nsecond line\\nthird line"', + ) + self.assertEqual( + lines[1], + { + "key": "note", + "value": "standalone continuation stays note", + "status": "same", + }, + ) + self.assertEqual( + lines[2]["key"], + "last_jin_response", + ) + + def test_parse_runtime_memory_keeps_quoted_user_message_fragments_together(self): + + lines = parse_runtime_memory_lines( + 'user_message: "first line"\n' + '"second line\\n"\n' + '"third line\\n"\n' + "active_memory: value" + ) + + self.assertEqual( + [ + line["key"] + for line in lines + ], + [ + "user_message", + "active_memory", + ], + ) + self.assertIn( + "second line", + lines[0]["value"], + ) + self.assertIn( + "third line", + lines[0]["value"], + ) + + def test_enforce_runtime_turn_fields_removes_broken_user_message_note_fragments(self): + + memory = enforce_runtime_turn_fields( + ( + 'user_message: "old"\n' + 'note: "\\"fragment one\\\\n\\""\n' + 'note: "fragment two\\\\n\\""\n' + 'active_memory: "Книга" (purpose: recall test; status: pending)' + ), + user_message="fresh message", + assistant_message="Fresh answer.", + ) + + self.assertIn( + 'user_message: "fresh message"', + memory, + ) + self.assertIn( + "active_memory:", + memory, + ) + self.assertNotIn( + "fragment one", + memory, + ) + self.assertNotIn( + "fragment two", + memory, + ) + + def test_managed_active_memory_collapses_numeric_status_duplicate_to_parent(self): + + previous_memory = ( + "active_memory_1: Secret word: Water " + "[ purpose: Remind user to guess it ] " + "[ conditions: Remind only once ] " + "[ status: pending ]" + ) + candidate_memory = ( + previous_memory + + "\n" + "active_memory_2: Secret word: Water " + "[ purpose: Remind user to guess it ] " + "[ conditions: Remind only once ] " + "[ status: pending, Reminder given in Turn 1 ] " + "[trace: 0.55]\n" + "primary_goal: Play a memory guessing game." + ) + + collapse_events = [] + memory = normalize_managed_runtime_memory_slots( + previous_memory, + candidate_memory, + collapse_events=collapse_events, + ) + + self.assertIn( + ( + "active_memory_1: Secret word: Water " + "[ purpose: Remind user to guess it ] " + "[ conditions: Remind only once ] " + "[ status: pending, Reminder given in Turn 1 ]" + ), + memory, + ) + self.assertIn( + "primary_goal: Play a memory guessing game.", + memory, + ) + self.assertNotIn( + "active_memory_2:", + memory, + ) + self.assertIn( + "Reminder given in Turn 1", + memory, + ) + self.assertEqual( + len(collapse_events), + 1, + ) + self.assertEqual( + collapse_events[0]["parent_key"], + "active_memory_1", + ) + + def test_managed_active_memory_keeps_numeric_suffix_when_value_differs(self): + + previous_memory = ( + "active_memory_1: Secret word: Water " + "[ purpose: Remind user to guess it ] " + "[ status: pending ]" + ) + candidate_memory = ( + previous_memory + + "\n" + "active_memory_2: Book title: Dune " + "[ purpose: Remind user to guess it ] " + "[ status: pending, Reminder given in Turn 1 ]" + ) + + memory = normalize_managed_runtime_memory_slots( + previous_memory, + candidate_memory, + ) + + self.assertIn( + previous_memory, + memory, + ) + self.assertIn( + "active_memory_2: Book title: Dune", + memory, + ) + + def test_managed_active_memory_merges_same_key_status_update(self): + + previous_memory = ( + "active_memory: Secret word: Water " + "[ purpose: Ask user to guess ] " + "[ status: pending ]" + ) + candidate_memory = ( + "active_memory: Secret word: Water " + "[ purpose: Ask user to guess ] " + "[ status: pending, Reminder given in Turn 1 ]" + ) + + memory = normalize_managed_runtime_memory_slots( + previous_memory, + candidate_memory, + ) + + self.assertIn( + ( + "active_memory: Secret word: Water " + "[ purpose: Ask user to guess ] " + "[ status: pending, Reminder given in Turn 1 ]" + ), + memory, + ) + self.assertNotIn( + "active_memory_2:", + memory, + ) + + def test_ensure_active_memory_status_suffixes_adds_pending(self): + + memory = ensure_active_memory_status_suffixes( + ( + "active_memory: Secret word: Sun " + "[ purpose: Ask user to guess ]\n" + "primary_goal: Play a memory game." + ) + ) + + self.assertIn( + ( + "active_memory: Secret word: Sun " + "[ purpose: Ask user to guess ] " + "[ status: pending ]" + ), + memory, + ) + self.assertIn( + "primary_goal: Play a memory game.", + memory, + ) + + def test_refresh_active_memory_runtime_metadata_attaches_suffixes_before_status(self): + + memory = refresh_active_memory_runtime_metadata( + ( + "active_memory: Secret word: Sun " + "[ purpose: Ask user to guess ] " + "[ status: pending ]" + ), + context=SimpleNamespace( + timestamp="2026-06-20T10:00:00", + turn_number=3, + ), + ) + + self.assertIn( + "[ creation_time: 2026-06-20T10:00:00 ]", + memory, + ) + self.assertIn( + "[ creation_turn_number: 3 ]", + memory, + ) + self.assertIn( + "[ elapsed_time: 00:00:00 ]", + memory, + ) + self.assertIn( + "[ elapsed_turns: 0 ] [ status: pending ]", + memory, + ) + + def test_refresh_active_memory_runtime_metadata_updates_elapsed_suffixes(self): + + previous_memory = ( + "active_memory: Secret word: Sun " + "[ purpose: Ask user to guess ] " + "[ creation_time: 2026-06-20T10:00:00 ] " + "[ creation_turn_number: 3 ] " + "[ elapsed_time: 00:00:00 ] " + "[ elapsed_turns: 0 ] " + "[ status: pending ]" + ) + + memory = refresh_active_memory_runtime_metadata( + ( + "active_memory: Secret word: Sun " + "[ purpose: Ask user to guess ] " + "[ status: pending ]" + ), + previous_memory=previous_memory, + context=SimpleNamespace( + timestamp="2026-06-20T11:02:03", + turn_number=5, + ), + ) + + self.assertIn( + "[ creation_time: 2026-06-20T10:00:00 ]", + memory, + ) + self.assertIn( + "[ creation_turn_number: 3 ]", + memory, + ) + self.assertIn( + "[ elapsed_time: 01:02:03 ]", + memory, + ) + self.assertIn( + "[ elapsed_turns: 2 ]", + memory, + ) + + def test_strip_active_memory_runtime_metadata_keeps_status_for_l1(self): + + memory = strip_active_memory_runtime_metadata( + ( + "active_memory: Secret word: Sun " + "[ purpose: Ask user to guess ] " + "[ creation_time: 2026-06-20T10:00:00 ] " + "[ creation_turn_number: 3 ] " + "[ elapsed_time: 01:02:03 ] " + "[ elapsed_turns: 2 ] " + "[ status: pending ]\n" + "primary_goal: Play a memory game." + ) + ) + + self.assertIn( + ( + "active_memory: Secret word: Sun " + "[ purpose: Ask user to guess ] " + "[ status: pending ]" + ), + memory, + ) + self.assertIn( + "primary_goal: Play a memory game.", + memory, + ) + self.assertNotIn( + "creation_time", + memory, + ) + self.assertNotIn( + "elapsed_time", + memory, + ) + + async def test_summarizer_hides_active_memory_runtime_metadata_from_l1_payload(self): + + service_client = FakeServiceClient( + ( + "active_memory: Secret word: Sun " + "[ purpose: Ask user to guess ] " + "[ status: pending ]" + ) + ) + logger = FakeLogger() + context = SimpleNamespace( + clients={ + "service": service_client, + }, + emitter=SimpleNamespace( + events=[], + emit=None, + ), + logger=logger, + runtime_memory=( + "active_memory: Secret word: Sun " + "[ purpose: Ask user to guess ] " + "[ creation_time: 2026-06-20T10:00:00 ] " + "[ creation_turn_number: 3 ] " + "[ elapsed_time: 00:00:00 ] " + "[ elapsed_turns: 0 ] " + "[ status: pending ]" + ), + runtime_memory_stable=( + "active_memory: Secret word: Sun " + "[ purpose: Ask user to guess ] " + "[ creation_time: 2026-06-20T10:00:00 ] " + "[ creation_turn_number: 3 ] " + "[ elapsed_time: 00:00:00 ] " + "[ elapsed_turns: 0 ] " + "[ status: pending ]" + ), + runtime_memory_updates=1, + runtime_memory_snapshots=[], + runtime_memory_snapshot_index=0, + session_id="test-session", + timestamp="2026-06-20T10:03:04", + turn_number=5, + ) + + async def emit(event): + context.emitter.events.append( + event + ) + + context.emitter.emit = emit + + updated_memory = await summarize_runtime_memory( + context=context, + user_message="thanks", + assistant_message="You are welcome.", + ) + + user_prompt = service_client.calls[0]["user_prompt"] + + self.assertNotIn( + "creation_time", + user_prompt, + ) + self.assertNotIn( + "elapsed_time", + user_prompt, + ) + self.assertIn( + "[ status: pending ]", + user_prompt, + ) + self.assertIn( + "[ elapsed_time: 00:03:04 ]", + updated_memory, + ) + self.assertIn( + "[ elapsed_turns: 2 ]", + updated_memory, + ) + + async def test_summarizer_logs_active_memory_collapse_payload(self): + + service_client = FakeServiceClient( + ( + "active_memory_2: Secret word: Water " + "[ purpose: Ask user to guess ] " + "[ status: pending, Reminder given in Turn 1 ]" + ) + ) + logger = FakeLogger() + context = SimpleNamespace( + clients={ + "service": service_client, + }, + emitter=SimpleNamespace( + events=[], + emit=None, + ), + logger=logger, + runtime_memory=( + "active_memory_1: Secret word: Water " + "[ purpose: Ask user to guess ] " + "[ status: pending ]" + ), + runtime_memory_stable=( + "active_memory_1: Secret word: Water " + "[ purpose: Ask user to guess ] " + "[ status: pending ]" + ), + runtime_memory_updates=1, + runtime_memory_snapshots=[], + runtime_memory_snapshot_index=0, + session_id="test-session", + timestamp="2026-06-20T10:03:04", + turn_number=5, + ) + + async def emit(event): + context.emitter.events.append( + event + ) + + context.emitter.emit = emit + + updated_memory = await summarize_runtime_memory( + context=context, + user_message="next", + assistant_message="Reminder sent.", + ) + + self.assertIn( + ( + "active_memory_1: Secret word: Water " + "[ purpose: Ask user to guess ]" + ), + updated_memory, + ) + self.assertIn( + "[ status: pending, Reminder given in Turn 1 ]", + updated_memory, + ) + self.assertNotIn( + "active_memory_2:", + updated_memory, + ) + self.assertEqual( + len(logger.active_memory_logs), + 1, + ) + message, details, event = logger.active_memory_logs[0] + self.assertIn( + "collapsed", + message, + ) + self.assertEqual( + event, + "collapse", + ) + self.assertIn( + '"candidate_key": "active_memory_2"', + details, + ) + self.assertIn( + '"result_memory"', + details, + ) + async def test_summarizer_enforces_latest_user_message_when_model_is_stale(self): service_client = FakeServiceClient( diff --git a/ui/static/js/logger.js b/ui/static/js/logger.js index b29d394b..15659fc3 100644 --- a/ui/static/js/logger.js +++ b/ui/static/js/logger.js @@ -168,6 +168,25 @@ function showTrace( ); } +function prettifyTraceDetails(details) { + const text = + String(details || ""); + + if (!text.trim()) { + return ""; + } + + try { + return JSON.stringify( + JSON.parse(text), + null, + 2 + ); + } catch (_error) { + return text; + } +} + function extractTraceReason( message, details, @@ -645,7 +664,8 @@ function appendLog( if (normalized.details) { const isSummarizer = tag.includes("SUMMARIZER") - || tag.includes("MEMORY:"); + || tag.includes("MEMORY:") + || tag.includes("ACTIVE_MEMORY"); const isSession = tag.includes("SESSION"); @@ -716,13 +736,15 @@ function appendLog( "click", function () { showTrace( - normalized.details, + prettifyTraceDetails(normalized.details), isPatternResult ? "L2 pattern memory" : isLatestSnapshots ? "Latest snapshots" : isSession ? "Session bootstrap" + : tag.includes("ACTIVE_MEMORY") + ? "Active memory payload" : isSummarizer ? "Summarizer payload" : "Trace", diff --git a/ui/static/js/runtime/runtime-memory-view.js b/ui/static/js/runtime/runtime-memory-view.js index 4793b819..5741b80a 100644 --- a/ui/static/js/runtime/runtime-memory-view.js +++ b/ui/static/js/runtime/runtime-memory-view.js @@ -100,10 +100,69 @@ return String( Number.isInteger(number) ? number - : Number(number.toFixed(2)) + : Number(number.toFixed(2)) ); } + function formatRuntimeMemoryHoverTitle(text) { + const raw = + String(text || "").trim(); + + if (!raw) { + return ""; + } + + return raw + .split(/\r?\n/) + .map((line) => { + const trimmed = + String(line || "").trim(); + + if (!trimmed) { + return ""; + } + + const parts = []; + let lastIndex = 0; + + trimmed.replace( + /\s*(\[[^\]]+\]|\(\s*trace\s*:[^)]+\))/gi, + (match, suffix, offset) => { + if (!parts.length) { + const body = + trimmed.slice(0, offset).trim(); + + if (body) { + parts.push(body); + } + } + + parts.push( + String(suffix || "").trim() + ); + lastIndex = + offset + match.length; + + return match; + } + ); + + if (!parts.length) { + return trimmed; + } + + const tail = + trimmed.slice(lastIndex).trim(); + + if (tail) { + parts.push(tail); + } + + return parts.join("\n"); + }) + .join("\n"); + } + function setRuntimeDiffUpdate(data) { runtimeDiffHistory.diffs = data && data.diffs || []; @@ -434,7 +493,7 @@ if (rawMemory.trim()) { runtimeMemoryText.title = - rawMemory.trim(); + formatRuntimeMemoryHoverTitle(rawMemory); } if (showLiveUserIdle) { @@ -488,10 +547,13 @@ valueSpan.textContent = ` ${valuePresentation.text}`; + const hoverTitle = + formatRuntimeMemoryHoverTitle(fullRawLine); + row.title = - fullRawLine; + hoverTitle; valueSpan.title = - fullRawLine; + hoverTitle; row.appendChild(keySpan); row.appendChild(valueSpan); diff --git a/websocket_logger.py b/websocket_logger.py index 40e15d64..4896aa6c 100644 --- a/websocket_logger.py +++ b/websocket_logger.py @@ -89,6 +89,20 @@ async def log_memory( memory_event=event, ) + async def log_active_memory( + self, + message: str, + details: str | None = None, + event: str | None = None, + ): + await self.log( + "[ACTIVE_MEMORY]", + message, + details=details, + channel="active_memory", + active_memory_event=event, + ) + async def log_service_as_brain(self, message: str): await self.log("[SERVICE as BRAIN]", message)