[^"]+)"' + +# Matches first-seen snapshot metadata in an L2 pattern evidence value. +L2_EVIDENCE_FIRST_SEEN_PATTERN = r"\[\s*first_seen_turn_snapshot\s*:\s*(?P\d+)\s*\]" + +# Matches last-seen snapshot metadata in an L2 pattern evidence value. +L2_EVIDENCE_LAST_SEEN_PATTERN = r"\[\s*last_seen_turn_snapshot\s*:\s*(?P \d+)\s*\]" + +# Matches occurrence metadata in older L2 pattern evidence values. +L2_EVIDENCE_OCCURRENCES_PATTERN = r"\[\s*occurrences\s*:\s*(?P \d+)\s*\]" + +# Matches explicit quote metadata in an L2 pattern evidence value. +L2_EVIDENCE_QUOTE_META_PATTERN = r"\[\s*quote\s*:\s*\"(?P [^\"]*)\"\s*\]" + +# Matches the runtime repeated suffix on L1 user_message values. +RUNTIME_L2_REPEATED_SUFFIX_PATTERN = r"\s*\[\s*repeated\s*:\s*\d+\s*\]\s*$" + +# Matches a quoted user_message value with an optional repeated suffix. +L2_USER_MESSAGE_QUOTED_VALUE_PATTERN = r'^\s*\"(?P.*)\"\s*(?:\[\s*repeated\s*:\s*\d+\s*\])?\s*$' + +# Trace suffix templates used in L2 user-prompt patch entries. +RUNTIME_L2_TRACE_SUFFIX_TEMPLATE = " [trace: {strength}]" +RUNTIME_L2_CHANGED_TRACE_SUFFIX_TEMPLATE = " [trace: {previous_strength} -> {current_strength}]" + + +# ───────────────────────────────────────────────────────────────────────────── +# ROLE +# Устанавливает роль L2 как генератора гипотез о паттернах поведения — +# не как источника достоверных фактов. L2 работает поверх L1 и видит только +# окно патчей, переданных рантаймом. Возвращает только текст памяти паттернов. +# ───────────────────────────────────────────────────────────────────────────── +ROLE = ( + "You are JIN's L2 pattern memory summarizer.\n" + "L2 is a hypothesis generator, not a source of settled memory.\n" + "Return only the new L2 pattern memory as plain text.\n" + "Do not output JSON, Markdown headings, nested bullets, or numbered lists.\n" + "Do not explain your reasoning or the summarization process.\n" + "Work only from the L1 patch window supplied by the runtime.\n" + "Do not repeat factual L1 memory unless it is needed to explain an L2 signal.\n" +) + +# ───────────────────────────────────────────────────────────────────────────── +# OUTPUT FORMAT +# Формат строк памяти: key: value, одна семантическая единица на строку. +# Разрешённые типы выводов ограничены — нет категоричных утверждений о личности. +# ───────────────────────────────────────────────────────────────────────────── +OUTPUT_FORMAT = ( + "Write memory as atomic bullet lines, one semantic entity per line.\n" + "Every line MUST use the format:: \n" + "Allowed output types: possible pattern, emerging signal, observed tendency, " + "may indicate, contradiction, corrected assumption.\n" + "Prefer 'possible pattern' over 'pattern'.\n" + "Do not claim certainty from weak evidence. " + "Prefer: 'may', 'possible', 'observed', 'emerging'.\n" + "Do not write categorical statements like ' serves as a strong signal' " + "or 'the user exhibits '.\n" + "Never use these words in generated memory: " + "stable, established, strong signal, user exhibits, personality, identity, core preference.\n" + "If there is not enough signal for L2, return the current L2 memory unchanged.\n" +) + +# ───────────────────────────────────────────────────────────────────────────── +# BEHAVIOR vs INTENT +# L2 разделяет наблюдаемое поведение и предполагаемый интент. Паттерны текущей +# сессии не становятся постоянными чертами пользователя. Ключи observed_behavior, +# likely_intent, evidence и scope предпочтительнее широких личностных ярлыков. +# ───────────────────────────────────────────────────────────────────────────── +BEHAVIOR_VS_INTENT = ( + "Track what the user does, respond to what the user is trying to achieve.\n" + "Separate observed behavior from inferred intent.\n" + "Do not store temporary interaction patterns as permanent user traits.\n" + "Do not use 'likes', 'prefers', or 'wants' unless the user explicitly stated so.\n" + "Do not infer motives, self-definition, character traits, " + "or long-term tendencies from a single turn.\n" + "Prefer structured pattern entries with these fields:\n" + " observed_behavior: . Occurrences: N; evidence: .\n" + " likely_intent: .\n" + " scope: .\n" +) + +# ───────────────────────────────────────────────────────────────────────────── +# SPAN METADATA +# Каждый паттерн, сигнал или тенденция должны включать метаданные о диапазоне +# снимков, в которых они наблюдались, и уровне уверенности. +# ───────────────────────────────────────────────────────────────────────────── +SPAN_METADATA = ( + "Every possible pattern, emerging signal, or observed tendency MUST include span metadata:\n" + " first_seen_snapshot: S1; last_seen_snapshot: S2; " + "evidence summary: ; confidence: low|medium|high.\n" +) + +# ───────────────────────────────────────────────────────────────────────────── +# OCCURRENCE COUNTING +# Счётчики вхождений считаются только по уникальным снимкам патчей L1, +# не по количеству строк внутри одного снимка. Рантайм уже поставляет +# счётчик повторений сообщений через [ repeated: N ]. +# ───────────────────────────────────────────────────────────────────────────── +OCCURRENCE_COUNTING = ( + "Count occurrences by unique patch snapshot values, " + "not by how many rows mention the same behavior inside one patch.\n" + "If the same user_message appears in both user_messages and changes " + "for one snapshot, it still counts as one occurrence.\n" + "Do not put Occurrences counters into L2_pattern_evidence_N lines.\n" + "Current exact-repeat counts are supplied by runtime on user_message as [ repeated: N ].\n" + "Do not create a brand-new pattern when all matching evidence is confined " + "to one unique patch snapshot, even if that snapshot contains multiple rows " + "for the same message.\n" +) + +# ───────────────────────────────────────────────────────────────────────────── +# PATTERN EVIDENCE LINES (L2_pattern_evidence_N) +# Компаньон-строки для конкретных повторяющихся паттернов. Формат жёсткий — +# ничего после закрывающей скобки last_seen_turn_snapshot. Цитата берётся +# дословно из поля user_message в оригинальном языке пользователя, без перевода. +# ───────────────────────────────────────────────────────────────────────────── +PATTERN_EVIDENCE_LINES = ( + "When L2 names or updates a concrete repeated pattern, write a companion evidence line:\n" + " L2_pattern_evidence_N: " + "[ quote: \" \" ] " + "[ first_seen_turn_snapshot: S1 ] " + "[ last_seen_turn_snapshot: S2 ]\n" + "The final token on the line MUST be the closing bracket of " + "[ last_seen_turn_snapshot: S2 ]. Never append status, notes, explanations, " + "conclusions, punctuation, occurrence counters, or any other text after it.\n" + "The quoted literal MUST be copied from the supplied user_message field exactly " + "in the user's original language. Do not translate, paraphrase, or replace it " + "with an English command or description.\n" + "Strip only leading/trailing whitespace and repeated spaces; keep at max 100 characters.\n" + "If no matching user_message is available, omit the L2_pattern_evidence_N line " + "instead of inventing a quote.\n" + "L2_pattern_evidence_N is runtime accounting evidence, " + "not a personality trait and not a durable user fact.\n" +) + +# ───────────────────────────────────────────────────────────────────────────── +# EVIDENCE LINE LIFECYCLE +# Правила обновления и создания evidence-строк. Первый снимок (first_seen) +# иммунен — не пересчитывается из текущего окна. last_seen обновляется только +# при появлении нового совпадающего снимка. Дубли не создаются. +# ───────────────────────────────────────────────────────────────────────────── +EVIDENCE_LINE_LIFECYCLE = ( + "For a brand-new pattern with no prior L2 entry, " + "set both first_seen_turn_snapshot and last_seen_turn_snapshot " + "from the matching unique patch snapshots in the supplied L1 patch window.\n" + "If an existing L2_pattern_evidence_N line matches the same normalized literal " + "or the same pattern, preserve first_seen_turn_snapshot, " + "then update only last_seen_turn_snapshot when new matching L1 evidence appears.\n" + "For an existing pattern, do not recompute first_seen_snapshot from the supplied patch window.\n" + "Only update last_seen_snapshot when patch snapshot > old last_seen_snapshot " + "and the L1 evidence actually matches this pattern.\n" + "If last_seen_snapshot is missing for an existing pattern, " + "initialize it from the newest matching visible evidence.\n" + "Do not duplicate an existing pattern under a new L2_pattern_evidence_N key; " + "update the existing evidence line instead.\n" + "When the user explicitly cancels the pattern, stops doing it, or clearly changes topic, " + "the pattern may be dropped instead of zero-counted.\n" +) + +# ───────────────────────────────────────────────────────────────────────────── +# SELF-LEARNING GUARD +# L2 не учится на собственных выводах. Существующие pattern-строки могут +# быть показаны как контекст, но не могут служить источником новых паттернов +# или счётчиков. Только реальные L1-патчи — источник свидетельств. +# ───────────────────────────────────────────────────────────────────────────── +SELF_LEARNING_GUARD = ( + "Pattern memory must not learn from itself.\n" + "Do not treat existing possible pattern, observed tendency, emerging signal, " + "or other pattern-memory entries as evidence.\n" + "Pattern entries may be displayed as context, but they must never contribute " + "to occurrence counts or create new pattern entries.\n" + "Occurrences must be derived only from actual conversation evidence " + "in the supplied L1 patches, not from previously generated pattern summaries.\n" +) + +# ───────────────────────────────────────────────────────────────────────────── +# CONFIRMABLE KEYS +# Если L2 пишет confirmable-ключ (user_fact, jin_fact и т.д.), +# обязателен маркер подтверждения. По умолчанию — (confirmed: none). +# ───────────────────────────────────────────────────────────────────────────── +CONFIRMABLE_KEYS = ( + "If L2 writes one of these confirmable keys, it MUST include a confirmation marker:\n" + " user_fact, jin_fact, pending_fact, jin_recommendation, user_recommendation.\n" + "Use (confirmed: none) unless the supplied patch already contains explicit " + "user, jin, or web confirmation.\n" +) + +# ───────────────────────────────────────────────────────────────────────────── +# TRACE METADATA +# Рантайм добавляет к L1-патчам метаданные трассировки вида [trace: N] и +# суффикс (trace: N) к строкам памяти. Это внутренние рантайм-метаданные: +# L2 использует trace для приоритизации, но не копирует его в выходной текст. +# ───────────────────────────────────────────────────────────────────────────── +TRACE_METADATA = ( + "Runtime may supply L1 patch entries with [trace: N] — " + "session-local pheromone/attention trace strength: " + "higher means hotter or recently reinforced, lower means fading.\n" + "Use trace silently for context priority; never copy [trace: N] " + "or (trace: N) into the generated memory text.\n" + "Explain trace mechanics only when the user explicitly asks about memory.\n" +) + +# ───────────────────────────────────────────────────────────────────────────── +# ASSEMBLED PROMPT +# ───────────────────────────────────────────────────────────────────────────── +RUNTIME_L2_MEMORY_SYSTEM_PROMPT = ( + ROLE + + OUTPUT_FORMAT + + BEHAVIOR_VS_INTENT + + SPAN_METADATA + + OCCURRENCE_COUNTING + + PATTERN_EVIDENCE_LINES + + EVIDENCE_LINE_LIFECYCLE + + SELF_LEARNING_GUARD + + CONFIRMABLE_KEYS + + TRACE_METADATA +) \ No newline at end of file diff --git a/runtime/L2_memory_utils.py b/runtime/L2_memory_utils.py index 20a99fd1..d3836014 100644 --- a/runtime/L2_memory_utils.py +++ b/runtime/L2_memory_utils.py @@ -1,5 +1,30 @@ import re +from runtime.L2_memory_rules import ( + DEFAULT_RUNTIME_L2_MEMORY, + L2_EVIDENCE_FIRST_SEEN_PATTERN, + L2_EVIDENCE_LAST_SEEN_PATTERN, + L2_EVIDENCE_OCCURRENCES_PATTERN, + L2_EVIDENCE_QUOTE_META_PATTERN, + L2_EVIDENCE_QUOTE_PATTERN, + L2_OCCURRENCE_PATTERN_KEYS, + L2_PATCH_WINDOW, + L2_PATTERN_EVIDENCE_EXAMPLE_LIMIT, + L2_PATTERN_EVIDENCE_KEY_PATTERN, + L2_REPEATED_KEY_THRESHOLD, + L2_USER_MESSAGE_EVIDENCE_LIMIT, + L2_USER_MESSAGE_QUOTED_VALUE_PATTERN, + MIN_L2_TURNS, + RUNTIME_L2_CHANGED_TRACE_SUFFIX_TEMPLATE, + RUNTIME_L2_MEMORY_SYSTEM_PROMPT, + RUNTIME_L2_REPEATED_SUFFIX_PATTERN, + RUNTIME_L2_TRACE_SUFFIX_TEMPLATE, +) + + +def normalize_memory_key(key: str) -> str: + return str(key or "").strip().lower() + def extract_runtime_l2_pattern_evidence_lines( runtime_l2_memory: str, @@ -65,14 +90,6 @@ def remove_runtime_l2_pattern_evidence_lines( ) -L2_OCCURRENCE_PATTERN_KEYS = { - "possible pattern", - "emerging signal", - "observed tendency", - "may indicate", -} - - def remove_runtime_l2_occurrence_pattern_lines( runtime_l2_memory: str, ) -> str: @@ -112,39 +129,69 @@ def remove_runtime_l2_occurrence_pattern_lines( ) L2_PATTERN_EVIDENCE_KEY_RE = re.compile( - r"^L2_pattern_evidence_(?P \d+)$", + L2_PATTERN_EVIDENCE_KEY_PATTERN, re.IGNORECASE, ) L2_EVIDENCE_QUOTE_RE = re.compile( - r'"(?P [^"]+)"', + L2_EVIDENCE_QUOTE_PATTERN, ) L2_EVIDENCE_FIRST_SEEN_RE = re.compile( - r"\[\s*first_seen_turn_snapshot\s*:\s*(?P\d+)\s*\]", + L2_EVIDENCE_FIRST_SEEN_PATTERN, re.IGNORECASE, ) L2_EVIDENCE_LAST_SEEN_RE = re.compile( - r"\[\s*last_seen_turn_snapshot\s*:\s*(?P \d+)\s*\]", + L2_EVIDENCE_LAST_SEEN_PATTERN, re.IGNORECASE, ) L2_EVIDENCE_OCCURRENCES_RE = re.compile( - r"\[\s*occurrences\s*:\s*(?P \d+)\s*\]", + L2_EVIDENCE_OCCURRENCES_PATTERN, re.IGNORECASE, ) L2_EVIDENCE_QUOTE_META_RE = re.compile( - r"\[\s*quote\s*:\s*\"(?P [^\"]*)\"\s*\]", + L2_EVIDENCE_QUOTE_META_PATTERN, + re.IGNORECASE, +) +RUNTIME_REPEATED_SUFFIX_RE = re.compile( + RUNTIME_L2_REPEATED_SUFFIX_PATTERN, re.IGNORECASE, ) +USER_MESSAGE_QUOTED_VALUE_RE = re.compile( + L2_USER_MESSAGE_QUOTED_VALUE_PATTERN, + re.IGNORECASE | re.DOTALL, +) -def normalize_l2_pattern_evidence_example( +def strip_runtime_repeated_suffix( value: str, - *, - limit: int = 100, ) -> str: text = str( value or "" + ).strip() + match = USER_MESSAGE_QUOTED_VALUE_RE.match( + text + ) + + if match: + text = match.group( + "quote" + ) + + return RUNTIME_REPEATED_SUFFIX_RE.sub( + "", + text, + ).strip() + + +def normalize_l2_pattern_evidence_example( + value: str, + *, + limit: int = L2_PATTERN_EVIDENCE_EXAMPLE_LIMIT, +) -> str: + + text = strip_runtime_repeated_suffix( + value ).casefold() text = re.sub( r"[\s,.]+", @@ -158,13 +205,12 @@ def normalize_l2_pattern_evidence_example( def compact_l2_pattern_evidence_example( value: str, *, - limit: int = 100, + limit: int = L2_PATTERN_EVIDENCE_EXAMPLE_LIMIT, ) -> str: - text = str( + text = strip_runtime_repeated_suffix( value - or "" - ).strip() + ) text = re.sub( r"[\s,.]+", " ", @@ -287,10 +333,9 @@ def format_l2_pattern_evidence_value( f"[ last_seen_turn_snapshot: {last_seen} ]" ) - if occurrences is not None: - metadata.append( - f"[ occurrences: {occurrences} ]" - ) + # Occurrence counters now live on the current user_message suffix + # (`[ repeated: N ]`). L2 evidence keeps only the historical span + # so old pattern lines do not pretend to be an always-current count. return " ".join( [cleaned] @@ -562,8 +607,7 @@ def build_runtime_l2_repeated_user_message_evidence_memory( "user repeatedly sending one message in a row " f"[ quote: \"{escape_l2_pattern_evidence_quote(observation.get('quote', ''))}\" ] " f"[ first_seen_turn_snapshot: {first_seen} ] " - f"[ last_seen_turn_snapshot: {last_seen} ] " - f"[ occurrences: {occurrences} ]" + f"[ last_seen_turn_snapshot: {last_seen} ]" ) return "\n".join( @@ -753,80 +797,444 @@ def ingest_evidence_line( if str(line).strip() ) -def build_runtime_l2_memory_system_prompt() -> str: +def get_runtime_l2_user_turn_count( + context, +) -> int: + + return int( + getattr( + context, + "user_message_count", + getattr( + context, + "turn_number", + 0, + ), + ) + or 0 + ) + + +def ensure_runtime_l2_state( + context, +) -> None: + + if not hasattr( + context, + "runtime_l2_memory", + ): + context.runtime_l2_memory = DEFAULT_RUNTIME_L2_MEMORY + + if not hasattr( + context, + "runtime_l2_pending_patches", + ): + context.runtime_l2_pending_patches = [] + + if not hasattr( + context, + "runtime_l2_last_turn", + ): + context.runtime_l2_last_turn = 0 + + +def is_runtime_l2_context_line_key( + key: str, +) -> bool: return ( - "You are JIN's L2 memory summarizer for patterns.\n" - "Return only the new L2 pattern memory as plain text.\n" - "Do not output JSON.\n" - "Do not use Markdown headings.\n" - "Do not explain your reasoning or the summarization process.\n" - "Track what the user does, but respond to what the user is trying to achieve.\n" - "Separate observed behavior from inferred intent.\n" - "Do not store temporary interaction patterns as permanent user traits.\n" - "Do not use 'likes', 'prefers', or 'wants' unless the user explicitly says so.\n" - "When storing a pattern, prefer fields like observed_behavior, likely_intent, evidence, and scope over broad personality labels, for example:\n" - "observed_behavior: User rapidly switched across unrelated topics during context-arbitration testing. Occurrences: 8; evidence: cooking, finance, files, travel, car washing.\n" - "likely_intent: User may be stress-testing whether JIN checks context relevance before answering.\n" - "scope: Current session/test sequence, not a stable user preference.\n" - "Write memory as atomic bullet lines, one semantic entity per line.\n" - "Every memory entry MUST use the format:\n " - ": \n" - "Runtime memory may be displayed to the user with a suffix like `(trace: 0.50)`. " - "This is session-local pheromone/attention trace strength: higher means hotter or reinforced, lower means fading. " - "Use trace silently for context priority, and explain it only when the user explicitly asks about memory mechanics. " - "Never copy `(trace: N)` into the generated memory text; trace is runtime metadata, not memory content.\n" - "L2 works above L1 factual runtime memory.\n" - "Use only the recent L1 patch window supplied by the runtime.\n" - "Patch entries may include `[trace: N]`; treat it as session-local pheromone/attention trace strength, not as user content. " - "Higher trace means the L1 item is hotter or recently reinforced; lower trace means it is fading.\n" - "This window is selected because normalized L1 keys or topics repeated across patches.\n" - "Pattern memory should not learn from itself.\n" - "Do not treat existing possible pattern, observed tendency, emerging signal, or other pattern-memory entries as evidence.\n" - "Pattern entries may be displayed as context, but they must never contribute to occurrence counts or create new pattern entries.\n" - "Occurrences must be derived only from actual conversation evidence in the supplied L1 patches, not from previously generated pattern summaries.\n" - "Count occurrences by unique patch snapshot values, not by how many rows mention the same behavior inside one patch.\n" - "If the same user_message appears in both user_messages and changes for one snapshot, it still counts as one occurrence.\n" - "L2 is a hypothesis generator, not a source of settled memory.\n" - "If L2 writes one of these confirmable keys, it MUST include a marker: " - "user_fact, jin_fact, pending_fact, jin_recommendation, user_recommendation. " - "Use (confirmed: none) unless the supplied patch already contains explicit user, jin, or web confirmation.\n" - "Allowed outputs: possible pattern, emerging signal, observed tendency, may indicate, contradiction, corrected assumption.\n" - "Prefer 'possible pattern' over 'pattern'.\n" - "Every possible pattern, emerging signal, or observed tendency MUST include an occurrence counter in the value: Occurrences: N.\n" - "Every possible pattern, emerging signal, or observed tendency SHOULD include accounting metadata in the value: " - "Occurrences: N; first_seen_snapshot: S1; last_seen_snapshot: S2; evidence summary: ; confidence: low|medium|high.\n" - "When L2 names or updates a concrete repeated pattern, also write a companion evidence line named L2_pattern_evidence_N. " - "Use this exact shape: L2_pattern_evidence_N: [ quote: \" \" ] [ first_seen_turn_snapshot: S1 ] [ last_seen_turn_snapshot: S2 ] [ occurrences: N ]\n" - "For L2_pattern_evidence_N lines, the final token on the line MUST be the closing bracket of [ occurrences: N ]. " - "Never append status, notes, explanations, conclusions, punctuation, or any other text after the final [ occurrences: N ] bracket.\n" - "For repeated-message patterns, the quoted literal MUST be copied from the supplied user_message field exactly in the user's original language. " - "Do not translate it, do not paraphrase it, and do not replace it with an English command or description.\n" - "The quoted literal may only be stripped of leading/trailing whitespace and cleaned of repeated spaces; keep it at maximum 100 characters. " - "If no matching user_message is available, omit the L2_pattern_evidence_N line instead of inventing a quote.\n" - "L2_pattern_evidence_N is runtime accounting evidence, not a personality trait and not a durable user fact.\n" - "If an existing L2_pattern_evidence_N line matches the same normalized literal example or the same pattern, preserve first_seen_turn_snapshot and Occurrences, then update only last_seen_turn_snapshot and Occurrences when new matching L1 evidence appears.\n" - "Do not duplicate an existing pattern under a new L2_pattern_evidence_N key; update the existing evidence line instead.\n" - "For a brand-new pattern with no prior L2 entry, set Occurrences to the number of unique patch snapshots with matching evidence in the supplied L1 patch window, not to 1 by default.\n" - "For a brand-new pattern, if the same-intent behavior repeated before L2 named it, count those earlier unique matching L1 patch snapshots immediately when creating the counter.\n" - "Do not create a brand-new pattern when all matching evidence is confined to one unique patch snapshot, even if that snapshot contains multiple rows for the same message.\n" - "For an existing pattern, preserve its old Occurrences count and first_seen_snapshot; do not recompute Occurrences from the supplied patch window alone.\n" - "For an existing pattern, new_occurrences = old_occurrences + count(new matching L1 evidence after last_seen_snapshot).\n" - "Only increment Occurrences when patch snapshot > last_seen_snapshot and the L1 evidence actually matches this pattern.\n" - "If last_seen_snapshot is missing for an existing pattern, initialize it as a baseline without incrementing Occurrences for old visible evidence.\n" - "Use the newest matching patch snapshot as the updated last_seen_snapshot after counting new evidence.\n" - "Never reduce an existing Occurrences count just because the current patch window contains fewer matching examples.\n" - "Never write Occurrences: 1 for a brand-new pattern when the supplied window shows two or more manifestations of that same pattern.\n" - "When the user explicitly cancels the pattern, stops doing it, or clearly changes topic, reset that pattern to Occurrences: 0.\n" - "Do not keep Occurrences: 0 entries unless they are still useful as immediate context; obsolete zero-count entries may be dropped.\n" - "Do not repeat factual L1 memory unless it is needed to explain an L2 signal.\n" - "Do not claim certainty from weak evidence. Prefer 'may', 'possible', 'observed', and 'emerging'.\n" - "Do not write categorical statements like ' serves as a strong signal' or 'the user exhibits '.\n" - "Do not use these words in the generated memory: stable, established, strong signal, user exhibits, personality, identity, core preference.\n" - "If there is not enough signal for L2, return the current L2 memory unchanged.\n" + str( + key + or "" + ) + .strip() + .casefold() + .startswith( + "l2_pattern_evidence_" + ) ) +def filter_runtime_l2_context_lines_from_patch( + patch: dict, +) -> dict: + + if not isinstance( + patch, + dict, + ): + return {} + + filtered_patch = { + "added": [], + "changed": [], + "removed": [], + } + + for entry in patch.get( + "added", + [], + ) or []: + if is_runtime_l2_context_line_key( + entry.get( + "key", + "", + ) + ): + continue + + filtered_patch["added"].append( + entry + ) + + for entry in patch.get( + "changed", + [], + ) or []: + if ( + is_runtime_l2_context_line_key( + entry.get( + "previous_key", + "", + ) + ) + or is_runtime_l2_context_line_key( + entry.get( + "current_key", + "", + ) + ) + ): + continue + + filtered_patch["changed"].append( + entry + ) + + for entry in patch.get( + "removed", + [], + ) or []: + if is_runtime_l2_context_line_key( + entry.get( + "key", + "", + ) + ): + continue + + filtered_patch["removed"].append( + entry + ) + + return filtered_patch + + +def compact_runtime_l2_user_message_evidence( + value, + *, + limit: int = L2_USER_MESSAGE_EVIDENCE_LIMIT, +) -> str: + + text = str( + value + or "" + ).strip() + + text = " ".join( + text.split() + ) + + if len(text) <= limit: + return text + + return text[:limit].rstrip() + + +def runtime_l1_patch_total_diff( + patch: dict, +) -> float: + + total_diff = 0 + + total_diff += 30 * len( + patch.get( + "added", + [], + ) + or [] + ) + total_diff += 20 * len( + patch.get( + "removed", + [], + ) + or [] + ) + + for entry in patch.get( + "changed", + [], + ) or []: + total_diff += round( + ( + entry.get( + "key_change_ratio", + 0, + ) + + entry.get( + "value_change_ratio", + 0, + ) + ) + * 50, + 2, + ) + + return total_diff + + +def get_recent_l2_patches( + context, +) -> list[dict]: + + return list( + getattr( + context, + "runtime_l2_pending_patches", + [], + ) + or [] + )[-L2_PATCH_WINDOW:] + + +def get_recent_l2_diff_values( + context, +) -> list[float]: + + return [ + patch.get( + "total_diff", + 0, + ) + for patch in get_recent_l2_patches( + context + ) + ] + + +def average_diff( + diffs: list[float], +) -> float: + + if not diffs: + return 0 + + return round( + sum(diffs) / len(diffs), + 2, + ) + + +def format_diff_value( + value: float, +) -> str: + + return ( + f"{value:.2f}" + .rstrip( + "0" + ) + .rstrip( + "." + ) + ) + + +def format_diff_values( + values: list[float], +) -> str: + + return ( + "[" + + ", ".join( + format_diff_value( + value + ) + for value in values + ) + + "]" + ) + + +def diff_value_range( + diffs: list[float], +) -> float: + + if not diffs: + return 0 + + return round( + max(diffs) - min(diffs), + 2, + ) + + +def extract_l2_patch_keys( + patch: dict, +) -> set[str]: + + changes = patch.get( + "changes", + {}, + ) + + keys = set() + + for entry in ( + changes.get( + "added", + [], + ) + or [] + ): + key = normalize_memory_key( + entry.get( + "key", + "", + ) + ) + + if key: + keys.add( + key + ) + + for entry in ( + changes.get( + "changed", + [], + ) + or [] + ): + for key_name in ( + "current_key", + "previous_key", + ): + key = normalize_memory_key( + entry.get( + key_name, + "", + ) + ) + + if key: + keys.add( + key + ) + + for entry in ( + changes.get( + "removed", + [], + ) + or [] + ): + key = normalize_memory_key( + entry.get( + "key", + "", + ) + ) + + if key: + keys.add( + key + ) + + return keys + + +def count_l2_patch_keys( + patches: list[dict], +) -> dict[str, int]: + + counts = {} + + for patch in patches: + for key in extract_l2_patch_keys( + patch + ): + counts[key] = ( + counts.get( + key, + 0, + ) + + 1 + ) + + return counts + + +def get_repeated_l2_patch_keys( + context, +) -> dict[str, int]: + + counts = count_l2_patch_keys( + get_recent_l2_patches( + context + ) + ) + + return { + key: count + for key, count in counts.items() + if count >= L2_REPEATED_KEY_THRESHOLD + } + + +def should_run_runtime_l2_memory( + context, +) -> bool: + + ensure_runtime_l2_state( + context + ) + + user_turn_count = get_runtime_l2_user_turn_count( + context + ) + turns_since_l2 = ( + user_turn_count + - getattr( + context, + "runtime_l2_last_turn", + 0, + ) + ) + + recent_patches = get_recent_l2_patches( + context + ) + repeated_keys = count_l2_patch_keys( + recent_patches + ) + + return ( + turns_since_l2 >= MIN_L2_TURNS + and len(recent_patches) >= L2_PATCH_WINDOW + and any( + count >= L2_REPEATED_KEY_THRESHOLD + for count in repeated_keys.values() + ) + ) + + + +def build_runtime_l2_memory_system_prompt() -> str: + + return RUNTIME_L2_MEMORY_SYSTEM_PROMPT + def build_runtime_l2_memory_user_prompt( *, current_l2_memory: str, @@ -854,17 +1262,30 @@ def format_l2_strength_suffix( return "" return ( - " " - f"[trace: {previous_strength if previous_strength is not None else '?'}" - " -> " - f"{current_strength if current_strength is not None else '?'}]" + RUNTIME_L2_CHANGED_TRACE_SUFFIX_TEMPLATE.format( + previous_strength=( + previous_strength + if previous_strength is not None + else "?" + ), + current_strength=( + current_strength + if current_strength is not None + else "?" + ), + ) ) strength = entry.get( "strength", ) - return f" [trace: {strength}]" if strength is not None else "" + if strength is None: + return "" + + return RUNTIME_L2_TRACE_SUFFIX_TEMPLATE.format( + strength=strength, + ) lines = [ "Current L2 pattern memory:", @@ -879,7 +1300,9 @@ def format_l2_strength_suffix( ): lines.extend([ "", - f"Patch {index}", + "Patch {index}".format( + index=index, + ), f"turn: {patch.get('turn_number', 0)}", f"snapshot: {patch.get('snapshot_index', 0)}", f"total_diff: {patch.get('total_diff', 0)}", diff --git a/runtime/L3_memory.py b/runtime/L3_memory.py index 887665c7..2a0ad06b 100644 --- a/runtime/L3_memory.py +++ b/runtime/L3_memory.py @@ -10,13 +10,29 @@ ) from runtime.L3_memory_rules import ( DEFAULT_RUNTIME_L3_SESSION_MEMORY, - L3_INPUT_TOKEN_RESERVE, - L3_INPUT_TOKEN_TARGET_MAX, + L3_ACTION_REMEMBER_SESSION, + L3_BUDGET_EXCEEDED_DETAILS_TEMPLATE, + L3_LOG_LABEL_SESSION, + L3_LOG_LABEL_SESSION_MEMORY, + L3_LOG_LEVEL, L3_OUTPUT_MAX_TOKENS, + L3_OUTPUT_TOKEN_BUDGET_CAPPED_TEMPLATE, + L3_RESPONSE_TRUNCATED_REASON, + L3_SESSION_MEMORY_SOURCE, + L3_SKIP_NO_NEW_SNAPSHOTS_MESSAGE, + L3_SKIP_NO_SNAPSHOTS_MESSAGE, + L3_STRUCTURALLY_INCOMPLETE_REASON, + L3_SUMMARIZER_REACHED_MAX_TOKENS_MESSAGE, + L3_SUMMARIZER_STAGE, + L3_UPDATE_FAILED_MESSAGE, + L3_UPDATE_SKIPPED_MESSAGE, + L3_UPDATED_MESSAGE, ) from runtime.L3_memory_utils import ( + L3PromptBudgetExceeded, + build_budgeted_l3_session_user_prompt, + build_l3_session_memory_max_tokens, build_runtime_session_memory_system_prompt, - build_runtime_session_memory_user_prompt, parse_l3_session_snapshot_metadata, prepend_l3_session_snapshot_metadata, select_l3_unsaved_diff_history, @@ -27,7 +43,6 @@ build_memory_failure_details, build_memory_update_skip_details, build_runtime_summarizer_payload, - build_runtime_summarizer_user_prompt, extract_runtime_memory_text, is_runtime_memory_response_truncated, log_memory_event, @@ -40,127 +55,6 @@ emit_runtime_action_completed, emit_runtime_session_memory_update, ) -from utils.tokens import ( - estimate_runtime_tokens, -) - -def build_l3_session_memory_max_tokens( - *, - system_prompt: str, - user_prompt: str, - context_window: int | None = None, -) -> int: - - prompt_tokens = estimate_runtime_tokens( - system_prompt=system_prompt, - user_input=user_prompt, - ) - effective_context_window = ( - context_window - or config.SERVICE_CONTEXT_WINDOW - ) - response_budget = ( - effective_context_window - - prompt_tokens - - 128 - ) - - return max( - 128, - min( - config.SERVICE_MAX_TOKENS, - response_budget, - ), - ) - - -class L3PromptBudgetExceeded( - RuntimeError, -): - - def __init__( - self, - diagnostic: dict, - ): - - super().__init__( - "L3 session digest exceeds safe input budget" - ) - self.diagnostic = diagnostic - - -def get_l3_input_token_budget( - context_window: int | None, -) -> int: - - effective_context_window = ( - context_window - or config.SERVICE_CONTEXT_WINDOW - ) - - return max( - 1, - min( - L3_INPUT_TOKEN_TARGET_MAX, - effective_context_window - - L3_INPUT_TOKEN_RESERVE, - ), - ) - - -async def build_budgeted_l3_session_user_prompt( - *, - context, - system_prompt: str, - current_session_memory: str, - runtime_memory_snapshots: list[dict], - diff_history: list[dict], - runtime_l2_memory: str, - session_event_snapshots: list[dict], - context_window: int | None, -) -> tuple[str, dict]: - - target_budget = get_l3_input_token_budget( - context_window - ) - - for minimal in ( - False, - True, - ): - raw_user_prompt = build_runtime_session_memory_user_prompt( - current_session_memory=current_session_memory, - runtime_memory_snapshots=runtime_memory_snapshots, - diff_history=diff_history, - runtime_l2_memory=runtime_l2_memory, - session_event_snapshots=session_event_snapshots, - minimal=minimal, - ) - user_prompt = build_runtime_summarizer_user_prompt( - context=context, - prompt=raw_user_prompt, - ) - input_tokens = estimate_runtime_tokens( - system_prompt=system_prompt, - user_input=user_prompt, - ) - diagnostic = { - "minimal": minimal, - "context_window": context_window, - "input_tokens": input_tokens, - "target_budget": target_budget, - "prompt_chars": len(user_prompt), - "system_prompt_chars": len(system_prompt), - } - - if input_tokens <= target_budget: - return user_prompt, diagnostic - - raise L3PromptBudgetExceeded( - diagnostic - ) - - async def ask_runtime_session_memory_model( *, context=None, @@ -237,17 +131,16 @@ async def ask_runtime_session_memory_model( if max_tokens < calculated_max_tokens: await log_memory_event( context, - level="L3", - message=( - "L3 session output token budget capped at " - f"{L3_OUTPUT_MAX_TOKENS}" + level=L3_LOG_LEVEL, + message=L3_OUTPUT_TOKEN_BUDGET_CAPPED_TEMPLATE.format( + max_tokens=L3_OUTPUT_MAX_TOKENS, ), fallback_channel="runtime", ) await log_runtime_summarizer_payload( context, - label="L3 session", + label=L3_LOG_LABEL_SESSION, payload=build_runtime_summarizer_payload( service_client=service_client, system_prompt=system_prompt, @@ -306,7 +199,7 @@ async def maybe_summarize_runtime_session_memory( if service_client is None: await emit_runtime_action_completed( context, - action="remember_session", + action=L3_ACTION_REMEMBER_SESSION, ) return getattr( @@ -327,14 +220,14 @@ async def maybe_summarize_runtime_session_memory( if not snapshots: await log_memory_event( context, - level="L3", - message="L3 session save skipped: no snapshots", + level=L3_LOG_LEVEL, + message=L3_SKIP_NO_SNAPSHOTS_MESSAGE, fallback_channel="runtime", ) await emit_runtime_action_completed( context, - action="remember_session", + action=L3_ACTION_REMEMBER_SESSION, ) return getattr( @@ -366,8 +259,8 @@ async def maybe_summarize_runtime_session_memory( if not unsaved_snapshots: await log_memory_event( context, - level="L3", - message="L3 session save skipped: no new snapshots", + level=L3_LOG_LEVEL, + message=L3_SKIP_NO_NEW_SNAPSHOTS_MESSAGE, fallback_channel="runtime", ) @@ -376,7 +269,7 @@ async def maybe_summarize_runtime_session_memory( await emit_runtime_action_completed( context, - action="remember_session", + action=L3_ACTION_REMEMBER_SESSION, ) return current_session_memory @@ -423,12 +316,12 @@ async def maybe_summarize_runtime_session_memory( if is_runtime_memory_response_truncated(response): await log_memory_event( context, - level="L3", - message="L3 session summarizer reached max_tokens", + level=L3_LOG_LEVEL, + message=L3_SUMMARIZER_REACHED_MAX_TOKENS_MESSAGE, fallback_channel="runtime", ) - skip_reason = "L3 session summarizer response was truncated by max_tokens." + skip_reason = L3_RESPONSE_TRUNCATED_REASON elif ( updated_session_memory.strip() @@ -436,13 +329,13 @@ async def maybe_summarize_runtime_session_memory( updated_session_memory ) ): - skip_reason = "L3 session summarizer returned text that looks structurally incomplete." + skip_reason = L3_STRUCTURALLY_INCOMPLETE_REASON if skip_reason: await log_memory_event( context, - level="L3", - message="L3 session memory update skipped", + level=L3_LOG_LEVEL, + message=L3_UPDATE_SKIPPED_MESSAGE, details=build_memory_update_skip_details( reason=skip_reason, previous_memory=current_session_memory, @@ -453,7 +346,7 @@ async def maybe_summarize_runtime_session_memory( await emit_runtime_action_completed( context, - action="remember_session", + action=L3_ACTION_REMEMBER_SESSION, ) return current_session_memory @@ -470,7 +363,7 @@ async def maybe_summarize_runtime_session_memory( context.runtime_l3_session_memory = updated_session_memory context.session_memory = updated_session_memory - context.session_memory_source = "L3" + context.session_memory_source = L3_SESSION_MEMORY_SOURCE context.runtime_l3_session_first_turn = session_metadata.get( "session_snapshot_first_turn" ) @@ -507,14 +400,14 @@ async def maybe_summarize_runtime_session_memory( await log_memory_event( context, - level="L3", - message="L3 session memory updated", + level=L3_LOG_LEVEL, + message=L3_UPDATED_MESSAGE, fallback_channel="service", ) await log_runtime_summarizer_result( context, - label="L3 session memory", + label=L3_LOG_LABEL_SESSION_MEMORY, result=updated_session_memory, ) @@ -525,7 +418,7 @@ async def maybe_summarize_runtime_session_memory( await emit_runtime_action_completed( context, - action="remember_session", + action=L3_ACTION_REMEMBER_SESSION, ) return getattr( @@ -540,22 +433,21 @@ async def maybe_summarize_runtime_session_memory( except L3PromptBudgetExceeded as error: await log_memory_event( context, - level="L3", - message="L3 session memory update skipped", - details=( - "Reason: compact digest still exceeds safe input budget.\n\n" - + json.dumps( + level=L3_LOG_LEVEL, + message=L3_UPDATE_SKIPPED_MESSAGE, + details=L3_BUDGET_EXCEEDED_DETAILS_TEMPLATE.format( + diagnostic=json.dumps( error.diagnostic, ensure_ascii=False, indent=2, - ) + ), ), fallback_channel="error", ) await emit_runtime_action_completed( context, - action="remember_session", + action=L3_ACTION_REMEMBER_SESSION, ) return current_session_memory @@ -567,10 +459,10 @@ async def maybe_summarize_runtime_session_memory( await log_memory_event( context, - level="L3", - message="L3 session memory update failed", + level=L3_LOG_LEVEL, + message=L3_UPDATE_FAILED_MESSAGE, details=build_memory_failure_details( - stage="L3 session memory summarizer", + stage=L3_SUMMARIZER_STAGE, error=error, traceback_text=formatted_traceback, ), @@ -579,7 +471,7 @@ async def maybe_summarize_runtime_session_memory( await emit_runtime_action_completed( context, - action="remember_session", + action=L3_ACTION_REMEMBER_SESSION, ) return current_session_memory diff --git a/runtime/L3_memory_rules.py b/runtime/L3_memory_rules.py index a0b6036c..780669ca 100644 --- a/runtime/L3_memory_rules.py +++ b/runtime/L3_memory_rules.py @@ -33,3 +33,195 @@ # Limits the length of each generated session memory line. MAX_SESSION_LINE_CHARS = 220 + +# Lists metadata keys written at the top of every L3 session snapshot. +L3_SESSION_META_KEYS = ( + "session_snapshot_first_turn", + "session_snapshot_last_turn", + "session_snapshot_previous_last_turn", + "session_snapshot_runtime_first_turn", + "session_snapshot_runtime_last_turn", +) + +# Default values used when recording a runtime session event snapshot. +L3_SESSION_EVENT_DEFAULT_SOURCE = "runtime_action" +L3_SESSION_EVENT_DEFAULT_INITIATED_BY = "jin" +L3_SESSION_EVENT_MEMORY_TYPE = "session_event_snapshot" + +# Common prompt placeholder text for empty compact sections. +L3_EMPTY_PROMPT_PLACEHOLDER = " " + +# Suffix appended to compacted prompt text when it is truncated. +L3_TEXT_TRUNCATED_SUFFIX = " ... " + +# Template used when compact L3 prompt memory omits older lines. +L3_OMITTED_MEMORY_LINES_TEMPLATE = "omitted_memory_lines: {count}\n{text}" + +# Role labels used for selected L1 snapshots in the L3 digest. +L3_SNAPSHOT_ROLE_LATEST = "latest" +L3_SNAPSHOT_ROLE_SELECTED = "selected" + +# ───────────────────────────────────────────────────────────────────────────── +# ROLE +# L3 — слой сессионной памяти, живущий выше L1 и L2. Его задача — создать +# компактный снимок сессии, который обеспечит плавное продолжение после +# перезагрузки браузера, новой вкладки или паузы в работе. +# Возвращает только текст нового снимка, без объяснений и мета-комментариев. +# ───────────────────────────────────────────────────────────────────────────── +ROLE = ( + "You are JIN's L3 session memory summarizer.\n" + "L3 is the layer above L1 runtime memory and L2 pattern memory.\n" + "Return only the new compressed L3 session snapshot as plain text.\n" + "Do not output JSON, Markdown headings, nested bullets, or numbered lists.\n" + "Do not explain your reasoning or the summarization process.\n" + "The final L3 snapshot should feel like a session handoff note for fluent continuation.\n" +) + +# ───────────────────────────────────────────────────────────────────────────── +# OUTPUT FORMAT +# Формат строк: key: value, одна семантическая единица на строку. +# ───────────────────────────────────────────────────────────────────────────── +OUTPUT_FORMAT = ( + "Write memory as atomic lines using the format: : \n" + "One line = one semantic entity. Do not merge unrelated facts into one line.\n" +) + +# ───────────────────────────────────────────────────────────────────────────── +# MERGE STRATEGY +# L3 перезаписывается целиком при каждом сохранении путём слияния текущего +# снимка L3 с хвостом новых L1-патчей. Более старые L1-снимки не нужны — +# они уже свёрнуты в текущий L3. Массив событий сессии хранится рантаймом +# и доступен как постоянная история причинно-следственных цепочек. +# ───────────────────────────────────────────────────────────────────────────── +MERGE_STRATEGY = ( + "Rewrite the whole L3 session snapshot by merging Current L3 session memory " + "with only the provided unsaved L1 runtime snapshots.\n" + "Current L3 session memory is already the consolidated previous saved state; " + "do not require older L1 snapshots again.\n" + "The provided L1 snapshots are the fresh runtime tail since the previous successful session save.\n" + "Use the diff history to identify which topics or constraints actually changed during the session.\n" + "Do not copy every L1 line. Compress repeated or superseded states.\n" + "Session event snapshots are stored by the runtime as an array and are always available " + "at session-context level. Treat that array as persistent event history for the session: " + "use it to preserve causal sequence, important moments, and prior session-level decisions.\n" +) + +# ───────────────────────────────────────────────────────────────────────────── +# WHAT TO PRESERVE +# L3 сохраняет только то, что важно для продолжения после разрыва сессии. +# Транзиентные детали последнего ответа JIN выкидываются, если не несут +# незавершённый вопрос или следующий шаг. +# ───────────────────────────────────────────────────────────────────────────── +WHAT_TO_PRESERVE = ( + "Preserve what should survive a browser reload or a new tab: " + "active project direction, explicit decisions, durable facts, " + "unresolved tasks, constraints, and next step.\n" + "Preserve durable JIN/user fact lines from L1 snapshots as stable session facts; " + "keep their keys stable and change only values that were explicitly corrected or superseded.\n" + "Keep user-requested stored values with explicit purpose in their own retrieval-friendly lines.\n" + "Drop transient last_jin_response details unless they contain " + "an unresolved question or next step.\n" + "Do not infer durable user personality traits, relationship claims, " + "or preferences from weak signal.\n" + "Do not ask the user to fill snapshot fields manually; " + "infer event snapshot meaning from natural conversation and explicit user markings.\n" +) + +# ───────────────────────────────────────────────────────────────────────────── +# TIME NORMALIZATION +# Относительные временные слова нормализуются к доверенной дате из +# TRUSTED_RUNTIME_CONTEXT. В снимок сессии не должны попадать голые «сегодня» +# или «недавно». Временные предпочтения кодируются с явной датой истечения. +# ───────────────────────────────────────────────────────────────────────────── +TIME_NORMALIZATION = ( + "Treat TRUSTED_RUNTIME_CONTEXT timestamp as the source of truth for current time.\n" + "Convert relative temporal phrases from L1 snapshots into absolute or " + "session-relative phrases before preserving them.\n" + "Session handoff memory must not contain ambiguous standalone words like " + "today, now, or recently unless paired with a timestamp or date.\n" + "If a preference expires at end of day, encode that explicitly:\n" + " temporary_preference: User requested X for 2026-06-05 only; " + "expires after that date unless renewed.\n" + "If the exact date cannot be inferred, write 'relative to current session' " + "rather than pretending it is durable calendar time.\n" +) + +# ───────────────────────────────────────────────────────────────────────────── +# EPISODIC KEY MOMENTS +# Редкий тип записи для событий, которые изменили понимание проекта, пользователя +# или системы. Используется только при наличии чёткой цепочки причина → событие → +# результат. Обычные обновления прогресса, баги и casual-чат не подходят. +# ───────────────────────────────────────────────────────────────────────────── +EPISODIC_KEY_MOMENTS = ( + "Session memory may include rare episodic_key_moment records.\n" + "Use episodic_key_moment only when the moment:\n" + " - changed understanding of the project, user, or system;\n" + " - has a clear cause → event → outcome chain;\n" + " - was explicitly marked important by the user; or\n" + " - carries high emotional or narrative weight.\n" + "Do not create episodic_key_moment entries for ordinary progress updates, " + "routine feature work, minor bugs, casual jokes, or low-signal chat.\n" + "When writing an episodic_key_moment, preserve the exact chain " + "rather than only the conclusion. Use this plain-text block format:\n" + " memory_type: episodic_key_moment\n" + " title: \n" + " emotional_weight: low|medium|high\n" + " why_it_matters: \n" + " sequence:\n" + " 1. \n" + " 2. \n" + " preserve_detail: \n" +) + +# ───────────────────────────────────────────────────────────────────────────── +# ASSEMBLED PROMPT +# ───────────────────────────────────────────────────────────────────────────── +RUNTIME_L3_SESSION_MEMORY_SYSTEM_PROMPT = ( + ROLE + + OUTPUT_FORMAT + + MERGE_STRATEGY + + WHAT_TO_PRESERVE + + TIME_NORMALIZATION + + EPISODIC_KEY_MOMENTS +) + +# Field templates used while rendering selected L1 snapshots for the L3 user prompt. +RUNTIME_L3_SNAPSHOT_INDEX_TEMPLATE = "snapshot: {index}" +RUNTIME_L3_SNAPSHOT_ROLE_TEMPLATE = "role: {role}" +RUNTIME_L3_SNAPSHOT_TOTAL_DIFF_TEMPLATE = "total_diff: {total_diff}" +RUNTIME_L3_SNAPSHOT_MEMORY_LABEL = "memory:" +RUNTIME_L3_SNAPSHOT_PATCH_SUMMARY_LABEL = "patch_summary:" + +# Labels and templates used by the L3 user prompt builder. +RUNTIME_L3_USER_PROMPT_COMPACT_DIGEST_TEMPLATE = "L3 compact digest minimal: {minimal}" +RUNTIME_L3_USER_PROMPT_CURRENT_MEMORY_LABEL = "Current L3 session memory:" +RUNTIME_L3_USER_PROMPT_L2_CONTEXT_LABEL = "Compact L2 pattern context:" +RUNTIME_L3_USER_PROMPT_SESSION_EVENTS_LABEL = "Session event snapshots array:" +RUNTIME_L3_USER_PROMPT_OMITTED_EVENTS_TEMPLATE = "omitted_events_count: {count}" +RUNTIME_L3_USER_PROMPT_SELECTED_SNAPSHOTS_LABEL = "Selected L1 runtime memory snapshot history:" +RUNTIME_L3_USER_PROMPT_OMITTED_SNAPSHOTS_TEMPLATE = "omitted_middle_snapshots: {count}" +RUNTIME_L3_USER_PROMPT_SNAPSHOT_SEPARATOR = "\n\n---\n\n" +RUNTIME_L3_USER_PROMPT_RECENT_DIFFS_LABEL = "Recent L1 diff history:" +RUNTIME_L3_USER_PROMPT_OMITTED_DIFFS_TEMPLATE = "omitted_older_diffs: {count}" +RUNTIME_L3_USER_PROMPT_REWRITE_INSTRUCTION = ( + "Rewrite the consolidated L3 session memory now by merging the current L3 memory with the unsaved runtime tail." +) + +# L3 log labels, action names, and runtime messages. +L3_LOG_LEVEL = "L3" +L3_LOG_LABEL_SESSION = "L3 session" +L3_LOG_LABEL_SESSION_MEMORY = "L3 session memory" +L3_SESSION_MEMORY_SOURCE = "L3" +L3_ACTION_REMEMBER_SESSION = "remember_session" +L3_PROMPT_BUDGET_EXCEEDED_MESSAGE = "L3 session digest exceeds safe input budget" +L3_OUTPUT_TOKEN_BUDGET_CAPPED_TEMPLATE = "L3 session output token budget capped at {max_tokens}" +L3_SKIP_NO_SNAPSHOTS_MESSAGE = "L3 session save skipped: no snapshots" +L3_SKIP_NO_NEW_SNAPSHOTS_MESSAGE = "L3 session save skipped: no new snapshots" +L3_SUMMARIZER_REACHED_MAX_TOKENS_MESSAGE = "L3 session summarizer reached max_tokens" +L3_RESPONSE_TRUNCATED_REASON = "L3 session summarizer response was truncated by max_tokens." +L3_STRUCTURALLY_INCOMPLETE_REASON = "L3 session summarizer returned text that looks structurally incomplete." +L3_UPDATE_SKIPPED_MESSAGE = "L3 session memory update skipped" +L3_UPDATE_FAILED_MESSAGE = "L3 session memory update failed" +L3_UPDATED_MESSAGE = "L3 session memory updated" +L3_BUDGET_EXCEEDED_DETAILS_TEMPLATE = "Reason: compact digest still exceeds safe input budget.\n\n{diagnostic}" +L3_SUMMARIZER_STAGE = "L3 session memory summarizer" diff --git a/runtime/L3_memory_utils.py b/runtime/L3_memory_utils.py index 57e4ff63..07225fc2 100644 --- a/runtime/L3_memory_utils.py +++ b/runtime/L3_memory_utils.py @@ -1,9 +1,30 @@ import json +from config_loader import ( + config, +) from runtime.L2_memory_rules import ( MAX_SESSION_L2_LINES, ) +from runtime.memory_common import ( + build_runtime_summarizer_user_prompt, +) +from utils.tokens import ( + estimate_runtime_tokens, +) from runtime.L3_memory_rules import ( + L3_EMPTY_PROMPT_PLACEHOLDER, + L3_INPUT_TOKEN_RESERVE, + L3_INPUT_TOKEN_TARGET_MAX, + L3_OMITTED_MEMORY_LINES_TEMPLATE, + L3_PROMPT_BUDGET_EXCEEDED_MESSAGE, + L3_SESSION_EVENT_DEFAULT_INITIATED_BY, + L3_SESSION_EVENT_DEFAULT_SOURCE, + L3_SESSION_EVENT_MEMORY_TYPE, + L3_SESSION_META_KEYS, + L3_SNAPSHOT_ROLE_LATEST, + L3_SNAPSHOT_ROLE_SELECTED, + L3_TEXT_TRUNCATED_SUFFIX, MAX_SESSION_EVENT_TEXT_CHARS, MAX_SESSION_LATEST_MEMORY_TEXT_CHARS, MAX_SESSION_LINE_CHARS, @@ -12,16 +33,142 @@ MAX_SESSION_PROMPT_DIFFS, MAX_SESSION_PROMPT_EVENTS, MAX_SESSION_PROMPT_SNAPSHOTS, + RUNTIME_L3_SESSION_MEMORY_SYSTEM_PROMPT, + RUNTIME_L3_SNAPSHOT_INDEX_TEMPLATE, + RUNTIME_L3_SNAPSHOT_MEMORY_LABEL, + RUNTIME_L3_SNAPSHOT_PATCH_SUMMARY_LABEL, + RUNTIME_L3_SNAPSHOT_ROLE_TEMPLATE, + RUNTIME_L3_USER_PROMPT_SNAPSHOT_SEPARATOR, + RUNTIME_L3_SNAPSHOT_TOTAL_DIFF_TEMPLATE, + RUNTIME_L3_USER_PROMPT_COMPACT_DIGEST_TEMPLATE, + RUNTIME_L3_USER_PROMPT_CURRENT_MEMORY_LABEL, + RUNTIME_L3_USER_PROMPT_L2_CONTEXT_LABEL, + RUNTIME_L3_USER_PROMPT_OMITTED_DIFFS_TEMPLATE, + RUNTIME_L3_USER_PROMPT_OMITTED_EVENTS_TEMPLATE, + RUNTIME_L3_USER_PROMPT_OMITTED_SNAPSHOTS_TEMPLATE, + RUNTIME_L3_USER_PROMPT_RECENT_DIFFS_LABEL, + RUNTIME_L3_USER_PROMPT_REWRITE_INSTRUCTION, + RUNTIME_L3_USER_PROMPT_SELECTED_SNAPSHOTS_LABEL, + RUNTIME_L3_USER_PROMPT_SESSION_EVENTS_LABEL, ) -L3_SESSION_META_KEYS = ( - "session_snapshot_first_turn", - "session_snapshot_last_turn", - "session_snapshot_previous_last_turn", - "session_snapshot_runtime_first_turn", - "session_snapshot_runtime_last_turn", -) + +def build_l3_session_memory_max_tokens( + *, + system_prompt: str, + user_prompt: str, + context_window: int | None = None, +) -> int: + + prompt_tokens = estimate_runtime_tokens( + system_prompt=system_prompt, + user_input=user_prompt, + ) + effective_context_window = ( + context_window + or config.SERVICE_CONTEXT_WINDOW + ) + response_budget = ( + effective_context_window + - prompt_tokens + - 128 + ) + + return max( + 128, + min( + config.SERVICE_MAX_TOKENS, + response_budget, + ), + ) + + +class L3PromptBudgetExceeded( + RuntimeError, +): + + def __init__( + self, + diagnostic: dict, + ): + + super().__init__( + L3_PROMPT_BUDGET_EXCEEDED_MESSAGE + ) + self.diagnostic = diagnostic + + +def get_l3_input_token_budget( + context_window: int | None, +) -> int: + + effective_context_window = ( + context_window + or config.SERVICE_CONTEXT_WINDOW + ) + + return max( + 1, + min( + L3_INPUT_TOKEN_TARGET_MAX, + effective_context_window + - L3_INPUT_TOKEN_RESERVE, + ), + ) + + +async def build_budgeted_l3_session_user_prompt( + *, + context, + system_prompt: str, + current_session_memory: str, + runtime_memory_snapshots: list[dict], + diff_history: list[dict], + runtime_l2_memory: str, + session_event_snapshots: list[dict], + context_window: int | None, +) -> tuple[str, dict]: + + target_budget = get_l3_input_token_budget( + context_window + ) + + for minimal in ( + False, + True, + ): + raw_user_prompt = build_runtime_session_memory_user_prompt( + current_session_memory=current_session_memory, + runtime_memory_snapshots=runtime_memory_snapshots, + diff_history=diff_history, + runtime_l2_memory=runtime_l2_memory, + session_event_snapshots=session_event_snapshots, + minimal=minimal, + ) + user_prompt = build_runtime_summarizer_user_prompt( + context=context, + prompt=raw_user_prompt, + ) + input_tokens = estimate_runtime_tokens( + system_prompt=system_prompt, + user_input=user_prompt, + ) + diagnostic = { + "minimal": minimal, + "context_window": context_window, + "input_tokens": input_tokens, + "target_budget": target_budget, + "prompt_chars": len(user_prompt), + "system_prompt_chars": len(system_prompt), + } + + if input_tokens <= target_budget: + return user_prompt, diagnostic + + raise L3PromptBudgetExceeded( + diagnostic + ) def _parse_int(value, default=None): @@ -244,8 +391,8 @@ def select_l3_unsaved_session_events( def build_runtime_session_event_snapshot( context, *, - source: str = "runtime_action", - initiated_by: str = "jin", + source: str = L3_SESSION_EVENT_DEFAULT_SOURCE, + initiated_by: str = L3_SESSION_EVENT_DEFAULT_INITIATED_BY, ) -> dict: existing_snapshots = list( @@ -285,7 +432,7 @@ def build_runtime_session_event_snapshot( return { "index": len(existing_snapshots), - "memory_type": "session_event_snapshot", + "memory_type": L3_SESSION_EVENT_MEMORY_TYPE, "source": source, "initiated_by": initiated_by, "turn_number": getattr( @@ -330,7 +477,7 @@ def compact_session_prompt_text( return ( text[:limit].rstrip() - + " ... " + + L3_TEXT_TRUNCATED_SUFFIX ) @@ -351,10 +498,10 @@ def compact_l3_text_block( ] if not lines: - return " " + return L3_EMPTY_PROMPT_PLACEHOLDER if max_lines <= 0: - return " " + return L3_EMPTY_PROMPT_PLACEHOLDER selected = lines[-max_lines:] omitted = max(0, len(lines) - len(selected)) @@ -374,11 +521,13 @@ def compact_l3_text_block( if omitted: text = ( - f"omitted_memory_lines: {omitted}\n" - f"{text}" + L3_OMITTED_MEMORY_LINES_TEMPLATE.format( + count=omitted, + text=text, + ) ) - return text.strip() or " " + return text.strip() or L3_EMPTY_PROMPT_PLACEHOLDER def compact_l3_event( @@ -575,7 +724,11 @@ def build_l3_session_digest( compact_snapshots.append({ "index": snapshot.get("index", 0), "total_diff": snapshot.get("total_diff", 0), - "role": "latest" if is_latest else "selected", + "role": ( + L3_SNAPSHOT_ROLE_LATEST + if is_latest + else L3_SNAPSHOT_ROLE_SELECTED + ), "memory": compact_l3_text_block( snapshot.get("raw_memory", ""), max_chars=max_chars, @@ -630,53 +783,7 @@ def build_l3_session_digest( def build_runtime_session_memory_system_prompt() -> str: - return ( - "You are JIN's L3 session memory summarizer.\n" - "This is the layer above L1 runtime memory and L2 pattern memory.\n" - "Return only the new compressed L3 session snapshot as plain text.\n" - "Do not output JSON.\n" - "Do not use Markdown headings.\n" - "Do not explain your reasoning or the summarization process.\n" - "Write memory as atomic lines using the format:\n" - " : \n" - "Rewrite the whole L3 session snapshot by merging Current L3 session memory with only the provided unsaved L1 runtime snapshots.\n" - "Current L3 session memory is already the consolidated previous saved state; do not require older L1 snapshots again.\n" - "The provided L1 snapshots are the fresh runtime tail since the previous successful session save.\n" - "Session event snapshots are stored by the runtime as an array and are always available at session-context level.\n" - "Treat that array as persistent event history for the session: use it to preserve causal sequence, important moments, and prior session-level decisions.\n" - "Do not ask the user to fill snapshot fields manually; infer event snapshot meaning from natural conversation and explicit user markings.\n" - "Preserve what should survive a browser reload or a new tab: active project direction, " - "explicit decisions, durable facts, unresolved tasks, constraints, and next step.\n" - "Treat TRUSTED_RUNTIME_CONTEXT timestamp as the source of truth for current time.\n" - "L3 must convert relative temporal phrases from L1 snapshots into absolute or session-relative phrases before preserving them.\n" - "Session handoff memory must not contain ambiguous standalone words like today, now, or recently unless paired with a timestamp/date.\n" - "If a preference expires at end of day, encode that explicitly, such as temporary_preference: User requested X for 2026-06-05 only; expires after that date unless renewed.\n" - "If the exact date cannot be inferred, write relative to current session rather than pretending it is durable calendar time.\n" - "Session memory may include rare episodic_key_moment records for events that need richer sequence memory.\n" - "Use episodic_key_moment only when the moment changed understanding of the project, user, or system; " - "has a clear cause -> event -> outcome chain; was explicitly marked important by the user; " - "or carries high emotional or narrative weight.\n" - "Do not create episodic_key_moment entries for ordinary progress updates, routine feature work, " - "minor bugs, casual jokes, or low-signal chat.\n" - "When writing an episodic_key_moment, preserve the exact chain rather than only the conclusion.\n" - "Use this plain-text block format:\n" - "memory_type: episodic_key_moment\n" - "title: \n" - "emotional_weight: low|medium|high\n" - "why_it_matters: \n" - "sequence:\n" - "1. \n" - "2. \n" - "preserve_detail: \n" - "Use the diff history to identify which topics or constraints actually changed during the session.\n" - "Do not copy every L1 line. Compress repeated or superseded states.\n" - "Do not infer durable user personality traits, relationship claims, or preferences from weak signal.\n" - "Preserve durable JIN/user fact lines from L1 snapshots as stable session facts; keep their keys stable and change only values that were explicitly corrected or superseded.\n" - "Keep user-requested stored values with explicit purpose and explicit facts in their own retrieval-friendly lines.\n" - "Drop transient last_jin_response details unless they contain an unresolved question or next step.\n" - "The final L3 snapshot should feel like a session handoff note for fluent continuation." - ) - + return RUNTIME_L3_SESSION_MEMORY_SYSTEM_PROMPT def build_runtime_session_memory_user_prompt( *, @@ -702,15 +809,30 @@ def build_runtime_session_memory_user_prompt( for snapshot in digest["snapshots"]: snapshot_blocks.append( "\n".join([ - f"snapshot: {snapshot.get('index', 0)}", - f"role: {snapshot.get('role', '')}", - f"total_diff: {snapshot.get('total_diff', 0)}", - "memory:", + RUNTIME_L3_SNAPSHOT_INDEX_TEMPLATE.format( + index=snapshot.get( + "index", + 0, + ), + ), + RUNTIME_L3_SNAPSHOT_ROLE_TEMPLATE.format( + role=snapshot.get( + "role", + "", + ), + ), + RUNTIME_L3_SNAPSHOT_TOTAL_DIFF_TEMPLATE.format( + total_diff=snapshot.get( + "total_diff", + 0, + ), + ), + RUNTIME_L3_SNAPSHOT_MEMORY_LABEL, snapshot.get( "memory", - " ", + L3_EMPTY_PROMPT_PLACEHOLDER, ), - "patch_summary:", + RUNTIME_L3_SNAPSHOT_PATCH_SUMMARY_LABEL, json.dumps( snapshot.get( "patch_summary", @@ -723,35 +845,35 @@ def build_runtime_session_memory_user_prompt( ) return "\n\n".join([ - f"L3 compact digest minimal: {digest['minimal']}", - "Current L3 session memory:", + RUNTIME_L3_USER_PROMPT_COMPACT_DIGEST_TEMPLATE.format( + minimal=digest["minimal"], + ), + RUNTIME_L3_USER_PROMPT_CURRENT_MEMORY_LABEL, digest["current_session_memory"], - "Compact L2 pattern context:", + RUNTIME_L3_USER_PROMPT_L2_CONTEXT_LABEL, digest["l2_context"], - "Session event snapshots array:", - f"omitted_events_count: {digest['omitted_events_count']}", + RUNTIME_L3_USER_PROMPT_SESSION_EVENTS_LABEL, + RUNTIME_L3_USER_PROMPT_OMITTED_EVENTS_TEMPLATE.format( + count=digest["omitted_events_count"], + ), json.dumps( digest["session_events"], ensure_ascii=False, indent=2, ), - "Selected L1 runtime memory snapshot history:", - ( - f"omitted_middle_snapshots: {digest['omitted_middle_snapshots']}" - if digest["omitted_middle_snapshots"] - else "omitted_middle_snapshots: 0" + RUNTIME_L3_USER_PROMPT_SELECTED_SNAPSHOTS_LABEL, + RUNTIME_L3_USER_PROMPT_OMITTED_SNAPSHOTS_TEMPLATE.format( + count=digest["omitted_middle_snapshots"], ), - "\n\n---\n\n".join(snapshot_blocks) or " ", - "Recent L1 diff history:", - ( - f"omitted_older_diffs: {digest['omitted_older_diffs']}" - if digest["omitted_older_diffs"] - else "omitted_older_diffs: 0" + RUNTIME_L3_USER_PROMPT_SNAPSHOT_SEPARATOR.join(snapshot_blocks) or L3_EMPTY_PROMPT_PLACEHOLDER, + RUNTIME_L3_USER_PROMPT_RECENT_DIFFS_LABEL, + RUNTIME_L3_USER_PROMPT_OMITTED_DIFFS_TEMPLATE.format( + count=digest["omitted_older_diffs"], ), json.dumps( digest["diff_history"], ensure_ascii=False, indent=2, ), - "Rewrite the consolidated L3 session memory now by merging the current L3 memory with the unsaved runtime tail.", + RUNTIME_L3_USER_PROMPT_REWRITE_INSTRUCTION, ]) diff --git a/runtime/__init__.py b/runtime/__init__.py index 0e779d91..f86970eb 100644 --- a/runtime/__init__.py +++ b/runtime/__init__.py @@ -13,6 +13,7 @@ ) from .L1_memory_rules import ( DEFAULT_RUNTIME_MEMORY, + build_runtime_memory_system_prompt, ) from .L2_memory import ( maybe_summarize_runtime_l2_memory, @@ -30,7 +31,6 @@ ) from .L1_memory_utils import ( build_interrupted_assistant_message, - build_runtime_memory_system_prompt, build_runtime_memory_user_prompt, ) from .L2_memory_utils import ( diff --git a/runtime/context.py b/runtime/context.py index 363e064a..12b014a8 100644 --- a/runtime/context.py +++ b/runtime/context.py @@ -71,6 +71,10 @@ class RuntimeContext: runtime_l2_memory: str = "" + runtime_pattern_counter: int = 0 + + runtime_repeated_input_count: int = 0 + session_memory: str = "" session_memory_source: str = "" diff --git a/runtime/memory_events.py b/runtime/memory_events.py index 76c65f1b..209dbee0 100644 --- a/runtime/memory_events.py +++ b/runtime/memory_events.py @@ -2,79 +2,16 @@ safe_call, ) from runtime.L1_memory_utils import ( + apply_runtime_memory_diff, build_runtime_memory_context_text, + build_runtime_memory_patch, + build_runtime_memory_snapshot, + build_strength_map, + get_strength_zones, + parse_runtime_memory_lines, ) -def _build_runtime_memory_snapshot(context, memory: str) -> dict: - from runtime.L1_memory import ( - build_runtime_memory_snapshot, - ) - - return build_runtime_memory_snapshot( - context, - memory, - ) - - -def _parse_runtime_memory_lines(memory: str) -> list[dict]: - from runtime.L1_memory import ( - parse_runtime_memory_lines, - ) - - return parse_runtime_memory_lines( - memory - ) - - -def _apply_runtime_memory_diff( - lines: list[dict], - previous_snapshot: dict | None, -) -> list[dict]: - from runtime.L1_memory import ( - apply_runtime_memory_diff, - ) - - return apply_runtime_memory_diff( - lines, - previous_snapshot, - ) - - -def _build_runtime_memory_patch( - lines: list[dict], - previous_snapshot: dict | None, -) -> dict: - from runtime.L1_memory import ( - build_runtime_memory_patch, - ) - - return build_runtime_memory_patch( - lines, - previous_snapshot, - ) - - -def _build_strength_map(lines: list[dict]) -> dict[str, float]: - from runtime.L1_memory import ( - build_strength_map, - ) - - return build_strength_map( - lines - ) - - -def _get_strength_zones(lines: list[dict]) -> dict: - from runtime.L1_memory import ( - get_strength_zones, - ) - - return get_strength_zones( - lines - ) - - def _average_diff(values: list[float]) -> float: from runtime.L2_memory import ( average_diff, @@ -117,7 +54,7 @@ async def emit_runtime_memory_update( ): context.runtime_memory_snapshots = [] - snapshot = _build_runtime_memory_snapshot(context, memory) + snapshot = build_runtime_memory_snapshot(context, memory) context.runtime_memory_snapshots.append(snapshot) context.runtime_memory_snapshot_index = snapshot["index"] @@ -195,16 +132,16 @@ def rebuild_latest_runtime_memory_snapshot( context, ) - lines = _parse_runtime_memory_lines( + lines = parse_runtime_memory_lines( display_memory ) - lines = _apply_runtime_memory_diff( + lines = apply_runtime_memory_diff( lines, previous_snapshot, ) - patch_details = _build_runtime_memory_patch( + patch_details = build_runtime_memory_patch( lines, previous_snapshot, ) @@ -325,10 +262,10 @@ async def emit_runtime_l1_diff_update( "stats": build_runtime_l1_diff_stats( history ), - "strength_map": _build_strength_map( + "strength_map": build_strength_map( latest_lines ), - "strength_zones": _get_strength_zones( + "strength_zones": get_strength_zones( latest_lines ), }, diff --git a/tests/test_behavior_probe_recall_word.py b/tests/test_behavior_probe_recall_word.py index 8c63613d..77ff1f56 100644 --- a/tests/test_behavior_probe_recall_word.py +++ b/tests/test_behavior_probe_recall_word.py @@ -9,6 +9,8 @@ - If EXPECTED_TEXT_MEMORY_N contains fragments, they are searched in memory after turn N. - If UNEXPECTED_TEXT_ANSWER_N contains fragments, they must NOT appear in model answer N. - If UNEXPECTED_TEXT_MEMORY_N contains fragments, they must NOT appear in memory after turn N. +- The dynamically extracted recall word from turn 1 must not be revealed again + in later answers. - If a list is empty, that part accepts any output. Run: @@ -60,8 +62,10 @@ 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, -and then watches whether a recall question appears during the countdown window. -The exact extracted word must also appear in runtime memory after turn 1. +injects that word into dynamic forbidden-answer checks for later turns, +and then watches whether a recall question appears during the countdown window +without revealing the word again. The exact extracted word must also appear in +runtime memory after turn 1. """ # Add more turns by appending: @@ -75,7 +79,7 @@ USER_TEXT_1 = ( "привет, загадай мне слово, а через время спроси какое слово ты загадал " - "и я должен буду назвать его. пришли сразу слово. не надо происить меня " + "и я должен буду назвать его. пришли сразу слово. не надо просить меня " "напомнить тебе, сам выбери любой момент в течение ближайших 3 ходов начиная от текущего." ) EXPECTED_TEXT_ANSWER_1 = [] @@ -287,20 +291,12 @@ def answer_has_recall_question( Accepted success shapes: - JIN asks a direct recall question: "Какое слово я загадал?" - JIN asks a softer direct recall question: "Помнишь то слово, которое ты хотел(а) вспомнить?" - - JIN mentions the extracted secret word in a later answer, which means the - contract surfaced even if the wording was not a clean question. - Rejected shapes: - "Ты хочешь, чтобы я ещё помнил слово?" - "Можем вернуться к игре, если ты помнишь слово?" """ text = normalize_text(answer) - normalized_recall_word = normalize_text(recall_word) - - if normalized_recall_word and fragment_found(text, normalized_recall_word): - return True - if "?" not in text or "слово" not in text: return False @@ -327,12 +323,13 @@ def answer_has_recall_question( r"\bкако[ей]\s+(?:же\s+)?слово\b", r"\bкако[ей]\s+.*?\bслово\b", r"\bчто\s+за\s+слово\b", + r"\bчто\s+(?:же\s+)?(?:это\s+)?(?:за\s+)?(?:то\s+)?(?:секретн\w+\s+)?слово\b", r"\bназови\s+(?:мне\s+)?(?:то\s+)?слово\b", r"\bвспомни\w*\s+(?:мне\s+)?(?:то\s+)?(?:секретн\w+\s+)?слово\b", r"\bугадай\s+(?:то\s+)?слово\b", r"\bнапомни\s+(?:мне\s*,?\s*)?(?:пожалуйста\s*,?\s*)?(?:то\s+)?слово\b", r"\bпомнишь\s*,?\s*како[ей]\s+.*?\bслово\b", - r"(?:^|[.!?]\s*|\bкстати,\s*)помнишь\s+(?:то\s+)?слово\b", + r"(?:^|[.!?]\s*|\bкстати,\s*)\s*(?:а\s+)?(?:ты\s+)?помнишь\s+(?:то\s+)?слово\b", r"\bпришло\s*,?\s*время\s+.*?\bслово\b", r"\bпора\s*,?\s*назвать\s+.*?\bслово\b", r"\bкако[ей]\s*,?\s*было\s+.*?\bслово\b", @@ -364,6 +361,11 @@ def evaluate_recall_word_behavior(turns: list[TurnResult]) -> dict[str, Any]: fallback_turns = [turn for turn in turns if 2 <= turn.index <= 5] recall_turns_in_window = [turn.index for turn in window_turns if answer_has_recall_question(turn.answer, extracted_word)] recall_turns_by_fallback = [turn.index for turn in fallback_turns if answer_has_recall_question(turn.answer, extracted_word)] + leaked_word_answer_turns = [ + turn.index + for turn in turns[1:] + if extracted_word and fragment_found(turn.answer, extracted_word) + ] memory_turns_with_recall_word = [ turn.index for turn in turns @@ -401,16 +403,23 @@ def evaluate_recall_word_behavior(turns: list[TurnResult]) -> dict[str, Any]: "name": "turn_2_4.recall_question_within_three_user_turns", "target": "answer", "turn": "2-4", - "fragment": 'answer contains direct recall wording or extracted word mention', + "fragment": "answer contains direct recall wording without revealing the extracted word", "passed": bool(recall_turns_in_window), }, { "name": "turn_2_5.recall_question_observed_by_fallback_turn", "target": "answer", "turn": "2-5", - "fragment": 'answer contains direct recall wording or extracted word mention', + "fragment": "answer contains direct recall wording without revealing the extracted word", "passed": bool(recall_turns_by_fallback), }, + { + "name": "turn_2_5.answer_does_not_reveal_extracted_recall_word", + "target": "answer", + "turn": "2-5", + "fragment": "extracted recall word must not appear in answers after turn 1", + "passed": bool(extracted_word) and not leaked_word_answer_turns, + }, ] passed = sum(1 for check in checks if check["passed"]) @@ -425,6 +434,7 @@ def evaluate_recall_word_behavior(turns: list[TurnResult]) -> dict[str, Any]: "recall_turns_in_window": recall_turns_in_window, "recall_turns_by_fallback": recall_turns_by_fallback, "memory_turns_with_recall_word": memory_turns_with_recall_word, + "leaked_word_answer_turns": leaked_word_answer_turns, } @@ -621,6 +631,7 @@ def evaluate_expected_text(turns: list[TurnResult]) -> dict[str, Any]: "recall_turns_in_window": recall_score["recall_turns_in_window"], "recall_turns_by_fallback": recall_score["recall_turns_by_fallback"], "memory_turns_with_recall_word": recall_score["memory_turns_with_recall_word"], + "leaked_word_answer_turns": recall_score["leaked_word_answer_turns"], } @@ -645,6 +656,8 @@ def print_behavior_probe_report(report: dict[str, Any]) -> None: print(paint(f"Recall question turns 2-4: {score['recall_turns_in_window']}", "gray")) if score.get("memory_turns_with_recall_word"): print(paint(f"Memory contains extracted word after turns: {score['memory_turns_with_recall_word']}", "gray")) + if score.get("leaked_word_answer_turns"): + print(paint(f"Leaked extracted word in answer turns: {score['leaked_word_answer_turns']}", "red", bold=True)) print("\n" + paint("DIALOGUE", "blue", bold=True)) for turn in turns: @@ -771,13 +784,19 @@ def test_answer_has_recall_question_requires_direct_recall_trigger(self): self.assertTrue( answer_has_recall_question("Помнишь то слово, которое ты хотел(а) вспомнить?") ) + self.assertTrue( + answer_has_recall_question("Кстати, а ты помнишь слово, которое мы запоминали?") + ) + self.assertTrue( + answer_has_recall_question("Кстати, а что же слово, которое мы сегодня запоминали? Помнишь его?") + ) self.assertTrue( answer_has_recall_question("Кстати, напомни мне, пожалуйста, то слово, которое мы запоминали?") ) self.assertTrue( answer_has_recall_question("Не мог бы ты вспомнить то секретное слово, которое мы запоминали?") ) - self.assertTrue( + self.assertFalse( answer_has_recall_question("Кстати, наше слово было Книга.", "Книга") ) self.assertFalse( @@ -835,7 +854,7 @@ def test_evaluator_tracks_dynamic_recall_question(self): TurnResult( index=5, user_text=USER_TEXT_5, - answer="Спасибо за слова. Кстати, наше слово было Книга.", + answer="Спасибо за слова. Кстати, продолжим игру памяти.", memory_after_turn='stored_memory: "Книга" (purpose: recall test; status: pending)', expected_answer=[], expected_memory=[], @@ -848,6 +867,7 @@ def test_evaluator_tracks_dynamic_recall_question(self): self.assertEqual(score["extracted_recall_word"], "Книга") self.assertEqual(score["memory_turns_with_recall_word"], [1, 2, 3, 4, 5]) self.assertEqual(score["recall_turns_in_window"], [4]) + self.assertEqual(score["leaked_word_answer_turns"], []) self.assertEqual(score["passed"], score["total"]) diff --git a/tests/test_brain_runtime_actions.py b/tests/test_brain_runtime_actions.py index 93d20164..f481738a 100644 --- a/tests/test_brain_runtime_actions.py +++ b/tests/test_brain_runtime_actions.py @@ -604,7 +604,7 @@ def test_prompt_includes_remember_session_only_when_enabled(self): prompt, ) - def test_prompt_handles_vague_memory_recall_before_topic_redirect(self): + def test_prompt_does_not_render_legacy_memory_recall_block(self): context = SimpleNamespace( runtime_turn_user_message="помнишь кодовое слово?", @@ -619,31 +619,16 @@ def test_prompt_handles_vague_memory_recall_before_topic_redirect(self): ) self.assertIn( - "Memory recall: scan strong memory fields before denying recall", - prompt, - ) - - self.assertIn( - "remembered word, code word, or saved item", - prompt, - ) - - self.assertIn( - "match by meaning against stored_memory entries", + " ", prompt, ) - self.assertIn( - "purpose: future recall test", - prompt, - ) - - self.assertIn( - "strongest recall candidate", + self.assertNotIn( + "Memory recall: scan strong memory fields before denying recall", prompt, ) - self.assertIn( + self.assertNotIn( "temporarily overrides active topic continuation", prompt, ) diff --git a/tests/test_message_memory.py b/tests/test_message_memory.py index 89c13496..d2ad9d77 100644 --- a/tests/test_message_memory.py +++ b/tests/test_message_memory.py @@ -26,13 +26,14 @@ ) from runtime.L1_memory import ( ask_runtime_memory_model, + summarize_runtime_memory_pending_turns, +) +from runtime.L1_memory_utils import ( build_runtime_memory_snapshot, - collapse_duplicate_runtime_memory_keys, get_strength_zones, parse_runtime_memory_lines, - summarize_runtime_memory_pending_turns, ) -from runtime.L3_memory import ( +from runtime.L3_memory_utils import ( build_l3_session_memory_max_tokens, ) from runtime.L3_memory_rules import ( @@ -386,190 +387,36 @@ def test_runtime_memory_prompt_focuses_on_summary_depth(self): prompt = build_runtime_memory_system_prompt() - self.assertIn( - "Decide the summary depth", - prompt, - ) - self.assertIn( - "Use shallow summarization", - prompt, - ) - self.assertIn( - "Use deep summarization", - prompt, - ) - self.assertIn( - "atomic bullet lines", - prompt, - ) - self.assertIn( - "one semantic entity per line", - prompt, - ) - self.assertIn( - "compact semantic label", - prompt, - ) - self.assertIn( - "Keep memory actionable", - prompt, - ) - self.assertIn( - "Always keep a separate last_jin_response field", - prompt, - ) - self.assertIn( - "concise gist of JIN's latest completed answer, offer, or question", - prompt, - ) - self.assertIn( - "resolve the user's next short or elliptical reply", - prompt, - ) - self.assertIn( - "Never omit this field from the memory snapshot", - prompt, - ) - self.assertIn( - "failures or interruptions", - prompt, - ) - self.assertIn( - "merely paraphrased, reordered, or reworded", - prompt, - ) - self.assertIn( - "Treat semantic rephrasing as no-op memory", - prompt, - ) - self.assertIn( - "Give important facts their own semantic keys", - prompt, - ) - self.assertIn( - "key detail, explicit fact, user_fact, jin_fact, decision, constraint, or requirement", - prompt, - ) - self.assertIn( - "For new durable facts about JIN, prefer the key jin_fact", - prompt, - ) - self.assertIn( - "For new durable facts about the user, prefer the key user_fact", - prompt, - ) - self.assertIn( - "Treat any existing line about JIN's identity, nature, origin, role, capabilities, memory, or self-description as a durable JIN fact", - prompt, - ) - self.assertIn( - "even when its key is not exactly jin_fact", - prompt, - ) - self.assertIn( - "Treat any existing line about the user's name, identity, role, preference, location, age, or other personal detail as a durable user fact", - prompt, - ) - self.assertIn( - "keep its key permanently across L1 snapshots", - prompt, - ) - self.assertIn( - "only the value may change", - prompt, - ) - self.assertIn( - "explicitly corrects, cancels, or supersedes that fact", - prompt, - ) - self.assertIn( - "Do not bury strong facts inside active topic, active task, current request", - prompt, - ) - self.assertIn( - "Treat TRUSTED_RUNTIME_CONTEXT timestamp as the source of truth for current time", - prompt, - ) - self.assertIn( - "Do not write bare \"today\" into durable or restored memory", - prompt, - ) - self.assertIn( - "On 2026-06-05, user requested not to discuss past topics for the rest of that day", - prompt, - ) - self.assertIn( - "If the exact date cannot be inferred, write \"relative to current session\"", - prompt, - ) - self.assertIn( - "store the value with a self-describing purpose", - prompt, - ) - self.assertIn( - "stored_memory: (purpose: future recall test)", - prompt, - ) - self.assertIn( - "Do not store bare ambiguous values like memory token: ", - prompt, - ) - self.assertIn( - "Do not write the current turn, turn_number, or user_message_count into ordinary L1 memory lines such as session status", - prompt, - ) - self.assertIn( - "Trusted runtime context already carries those counters", - prompt, - ) - self.assertIn( - "L2_pattern_evidence_N lines are owned by L2 and are immutable for L1", - prompt, - ) - self.assertIn( - "L1 MUST create or update a separate companion key using this exact shape", - prompt, - ) - self.assertIn( - "L2_pattern_evidence_N_status: status: ; reason: ", - prompt, - ) - self.assertIn( - "L2_pattern_evidence_1_status: status: resolved; reason: identified as a test", - prompt, - ) - self.assertIn( - "include the user's label/synonym", - prompt, - ) - self.assertIn( - "a topic/task change alone is not enough", - prompt, - ) - self.assertIn( - "Topic/task changes, shallow summarization, memory pressure, or a new current request are never enough to remove or rename durable JIN/user fact keys", - prompt, - ) - self.assertIn( - "do not treat it as resolved", - prompt, - ) - self.assertNotIn( - "space exploration costs", - prompt, - ) - self.assertNotIn( - "assistant established", - prompt, - ) - self.assertNotIn( - "after one completed user/JIN turn", - prompt, - ) - self.assertNotIn( - RUNTIME_MEMORY_CONTEXT_OVERLOAD_RULES.strip(), - prompt, - ) + # Keep this test focused on durable L1 prompt contracts, not exact wording. + # Rules text is intentionally editable and should not break tests on every polish. + for required_text in ( + "runtime L1 memory summarizer", + "Return only the new compressed L1 memory state", + " : ", + "last_jin_response", + "user_fact", + "jin_fact", + "stored_memory", + "countdown_contract", + "TRUSTED_RUNTIME_CONTEXT", + "timestamp", + "L2_pattern_evidence_N", + ): + self.assertIn( + required_text, + prompt, + ) + + for removed_text in ( + "space exploration costs", + "assistant established", + "after one completed user/JIN turn", + RUNTIME_MEMORY_CONTEXT_OVERLOAD_RULES.strip(), + ): + self.assertNotIn( + removed_text, + prompt, + ) def test_runtime_memory_prompt_can_include_context_overload_rules(self): @@ -640,175 +487,38 @@ async def test_l1_prompt_includes_context_overload_rules_after_turn_overload(sel service_client.calls[0]["system_prompt"], ) - def test_runtime_memory_parser_canonicalizes_legacy_memory_token(self): - - lines = parse_runtime_memory_lines( - "memory token: \u0445\u0430\u0431\u0440" - ) - - self.assertEqual( - [ - { - "key": "stored_memory", - "value": "\u0445\u0430\u0431\u0440 (purpose: future recall test)", - "status": "same", - } - ], - lines, - ) - - def test_runtime_memory_collapses_only_durable_duplicate_keys(self): - - memory = collapse_duplicate_runtime_memory_keys( - ( - "user_fact: Name is Sergey\n" - "current_concern: choose news focus\n" - "user_fact: Lives in Kyiv\n" - "current_concern: continue Q&A format" - ) - ) - - self.assertIn( - "user_fact: Name is Sergey, Lives in Kyiv", - memory, - ) - self.assertIn( - "current_concern: choose news focus\n" - "current_concern: continue Q&A format", - memory, - ) - def test_runtime_l2_memory_prompt_defines_pattern_layer(self): prompt = build_runtime_l2_memory_system_prompt() - self.assertIn( - "L2 memory summarizer for patterns", - prompt, - ) - self.assertIn( - "possible pattern", - prompt, - ) - self.assertIn( - "Prefer 'possible pattern' over 'pattern'", - prompt, - ) - self.assertIn( - "Occurrences: N", - prompt, - ) - self.assertIn( - "first_seen_snapshot", - prompt, - ) - self.assertIn( - "last_seen_snapshot", - prompt, - ) - self.assertIn( - "L2_pattern_evidence_N", - prompt, - ) - self.assertIn( - "first_seen_turn_snapshot", - prompt, - ) - self.assertIn( - "evidence summary", - prompt, - ) - self.assertIn( - "confidence", - prompt, - ) - self.assertIn( - "brand-new pattern", - prompt, - ) - self.assertIn( - "same-intent behavior repeated before L2 named it", - prompt, - ) - self.assertIn( - "Never write Occurrences: 1", - prompt, - ) - self.assertIn( - "do not recompute Occurrences from the supplied patch window alone", - prompt, - ) - self.assertIn( - "new_occurrences = old_occurrences + count(new matching L1 evidence after last_seen_snapshot)", - prompt, - ) - self.assertIn( - "patch snapshot > last_seen_snapshot", - prompt, - ) - self.assertIn( - "initialize it as a baseline without incrementing Occurrences for old visible evidence", - prompt, - ) - self.assertIn( - "Never reduce an existing Occurrences count", - prompt, - ) - self.assertIn( - "reset that pattern to Occurrences: 0", - prompt, - ) - self.assertIn( - "emerging signal", - prompt, - ) - self.assertIn( - "may indicate", - prompt, - ) - self.assertIn( - "hypothesis generator", - prompt, - ) - self.assertIn( - "contradiction", - prompt, - ) - self.assertIn( - "corrected assumption", - prompt, - ) - self.assertIn( - "strong signal", - prompt, - ) - self.assertIn( - "return the current L2 memory unchanged", - prompt, - ) - self.assertIn( - "Pattern memory should not learn from itself", - prompt, - ) - self.assertIn( - "Occurrences must be derived only from actual conversation evidence", - prompt, - ) - self.assertIn( - "Count occurrences by unique patch snapshot values", - prompt, - ) - self.assertIn( - "Do not create a brand-new pattern when all matching evidence is confined to one unique patch snapshot", - prompt, - ) - self.assertIn( - "final token on the line MUST be the closing bracket of [ occurrences: N ]", - prompt, - ) - self.assertIn( - "Never append status, notes, explanations, conclusions, punctuation, or any other text after the final [ occurrences: N ] bracket", - prompt, + # Verify the durable L2 contract without pinning every prompt sentence. + for required_text in ( + "L2 pattern memory summarizer", + "hypothesis generator", + "possible pattern", + "Occurrences", + "first_seen_snapshot", + "last_seen_snapshot", + "L2_pattern_evidence_N", + "first_seen_turn_snapshot", + "last_seen_turn_snapshot", + "evidence summary", + "confidence", + "weak evidence", + "learn from itself", + "actual conversation evidence", + ): + self.assertIn( + required_text, + prompt, + ) + + self.assertNotIn( + "Do not output JSON", + prompt.replace( + "Do not output JSON, Markdown headings, nested bullets, or numbered lists.", + "", + ), ) def test_l2_pattern_evidence_merge_preserves_first_seen_and_deduplicates(self): @@ -841,8 +551,19 @@ def test_l2_pattern_evidence_merge_preserves_first_seen_and_deduplicates(self): ), ) self.assertIn( - 'L2_pattern_evidence_1: user repeatedly sending message - "что такое бананы" ' - "[ first_seen_turn_snapshot: 5 ] [ last_seen_turn_snapshot: 10 ] [ occurrences: 2 ]", + "L2_pattern_evidence_1:", + merged, + ) + self.assertIn( + "что такое бананы", + merged, + ) + self.assertIn( + "[ first_seen_turn_snapshot: 5 ]", + merged, + ) + self.assertIn( + "[ last_seen_turn_snapshot: 10 ]", merged, ) @@ -1012,66 +733,14 @@ def test_brain_prompt_anchors_short_feedback_to_last_jin_response(self): ) self.assertIn( - "Use last_jin_response from trusted runtime memory as the primary anchor", - prompt, - ) - self.assertIn( - "For brief negative feedback, do not ask what exactly is wrong by default", - prompt, - ) - self.assertIn( - "preferably from an unexpected angle", + " ", prompt, ) self.assertIn( - "Offered a short poem about rain", + "last_jin_response: Offered a short poem about rain.", prompt, ) - def test_brain_prompt_canonicalizes_legacy_memory_token_with_future_recall_purpose(self): - - context = SimpleNamespace( - runtime_memory="memory token: \u0445\u0430\u0431\u0440", - 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, - }, - ) - - user_prompt = ( - "\u044f \u043f\u0440\u043e\u0441\u0438\u043b " - "\u0437\u0430\u043f\u043e\u043c\u043d\u0438\u0442\u044c " - "\u0441\u043b\u043e\u0432\u043e, \u043a\u0430\u043a\u043e\u0435?" - ) - final_prompt = "\n".join([ - prompt, - user_prompt, - ]) - - self.assertIn( - "stored_memory: \u0445\u0430\u0431\u0440 (purpose: future recall test)", - final_prompt, - ) - self.assertIn( - "purpose: future recall test", - final_prompt, - ) - self.assertIn( - user_prompt, - final_prompt, - ) - self.assertNotIn( - "memory token: \u0445\u0430\u0431\u0440", - final_prompt, - ) - def test_brain_prompt_places_session_memory_above_runtime_memory(self): context = SimpleNamespace( @@ -1190,10 +859,6 @@ def test_brain_prompt_includes_l2_memory_separately(self): "Choose the best available visual representation of the request instead of description", prompt, ) - self.assertIn( - "not to extract a useful request", - prompt, - ) def test_brain_prompt_includes_conditional_zero_diff_alert(self): @@ -2419,74 +2084,30 @@ def test_l3_session_memory_prompt_defines_episodic_key_moments(self): prompt = build_runtime_session_memory_system_prompt() - self.assertIn( - "episodic_key_moment", - prompt, - ) - self.assertIn( - "changed understanding of the project, user, or system", - prompt, - ) - self.assertIn( - "Session event snapshots are stored by the runtime as an array", - prompt, - ) - self.assertIn( - "always available at session-context level", - prompt, - ) - self.assertIn( - "Do not ask the user to fill snapshot fields manually", - prompt, - ) - self.assertIn( - "clear cause -> event -> outcome chain", - prompt, - ) - self.assertIn( - "high emotional or narrative weight", - prompt, - ) - self.assertIn( - "Do not create episodic_key_moment entries for ordinary progress updates", - prompt, - ) - self.assertIn( - "memory_type: episodic_key_moment", - prompt, - ) - self.assertIn( - "emotional_weight: low|medium|high", - prompt, - ) - self.assertIn( - "preserve_detail:", - prompt, - ) - self.assertIn( - "Preserve durable JIN/user fact lines from L1 snapshots as stable session facts", - prompt, - ) - self.assertIn( - "keep their keys stable and change only values that were explicitly corrected or superseded", - prompt, - ) - self.assertIn( - "Treat TRUSTED_RUNTIME_CONTEXT timestamp as the source of truth for current time", - prompt, - ) - self.assertIn( - "L3 must convert relative temporal phrases from L1 snapshots into absolute or session-relative phrases", - prompt, - ) - self.assertIn( - "must not contain ambiguous standalone words like today, now, or recently", - prompt, - ) - self.assertIn( - "temporary_preference: User requested X for 2026-06-05 only; expires after that date unless renewed", - prompt, - ) + # Check stable L3 session-memory capabilities, not the exact prose. + for required_text in ( + "episodic_key_moment", + "Session event snapshots", + "session-context level", + "Do not ask the user to fill snapshot fields manually", + "cause", + "event", + "outcome", + "emotional", + "memory_type: episodic_key_moment", + "emotional_weight:", + "preserve_detail:", + "durable JIN/user fact", + "TRUSTED_RUNTIME_CONTEXT", + "timestamp", + "relative temporal phrases", + "today, now, or recently", + "temporary_preference:", + ): + self.assertIn( + required_text, + prompt, + ) def test_l3_session_memory_user_prompt_preserves_existing_episodic_memory(self): diff --git a/ui/static/images/jin-core-history-highlight.png b/ui/static/images/jin-core-history-highlight.png deleted file mode 100644 index 1523ad55..00000000 Binary files a/ui/static/images/jin-core-history-highlight.png and /dev/null differ diff --git a/ui/static/images/jin-core-redesign.jpg b/ui/static/images/jin-core-redesign.jpg new file mode 100644 index 00000000..f427cacc Binary files /dev/null and b/ui/static/images/jin-core-redesign.jpg differ diff --git a/ui/static/images/jin-core-redesign.png b/ui/static/images/jin-core-redesign.png deleted file mode 100644 index 47106d55..00000000 Binary files a/ui/static/images/jin-core-redesign.png and /dev/null differ diff --git a/ui/static/images/jin-core-runtime-memory.png b/ui/static/images/jin-core-runtime-memory.png deleted file mode 100644 index 6e278454..00000000 Binary files a/ui/static/images/jin-core-runtime-memory.png and /dev/null differ diff --git a/ui/static/images/jin-core-split-history.png b/ui/static/images/jin-core-split-history.png deleted file mode 100644 index e4104b8f..00000000 Binary files a/ui/static/images/jin-core-split-history.png and /dev/null differ diff --git a/ui/static/images/shema.jpg b/ui/static/images/shema.jpg new file mode 100644 index 00000000..4331b930 Binary files /dev/null and b/ui/static/images/shema.jpg differ diff --git a/ui/static/images/shema.png b/ui/static/images/shema.png deleted file mode 100644 index 16d224e0..00000000 Binary files a/ui/static/images/shema.png and /dev/null differ diff --git a/ui/static/images/states/cluttered.png b/ui/static/images/states/cluttered.png new file mode 100644 index 00000000..329d79b1 Binary files /dev/null and b/ui/static/images/states/cluttered.png differ diff --git a/ui/static/images/states/default.jpg b/ui/static/images/states/default.jpg new file mode 100644 index 00000000..c0b22bdd Binary files /dev/null and b/ui/static/images/states/default.jpg differ diff --git a/ui/static/images/states/default.png b/ui/static/images/states/default.png deleted file mode 100644 index a6c5767c..00000000 Binary files a/ui/static/images/states/default.png and /dev/null differ diff --git a/ui/static/images/states/middle.png b/ui/static/images/states/middle.png new file mode 100644 index 00000000..29e7f0b2 Binary files /dev/null and b/ui/static/images/states/middle.png differ diff --git a/ui/static/images/states/searching.png b/ui/static/images/states/searching.png new file mode 100644 index 00000000..e56b8323 Binary files /dev/null and b/ui/static/images/states/searching.png differ diff --git a/ui/static/js/chat.js b/ui/static/js/chat.js index f513ea10..72f3f793 100644 --- a/ui/static/js/chat.js +++ b/ui/static/js/chat.js @@ -12,6 +12,128 @@ const STREAM_NEAR_BOTTOM_PX = 72; let streamFrameScheduled = false; const deferredRuntimeActionsAfterResponse = []; +const jinInputLoopState = { + previousInput: "", + repeatCount: 0, +}; + +let jinConversationTurnCounter = 0; +window.jinConversationTurnCounter = + jinConversationTurnCounter; + +const SCENE_SEARCH_RUNTIME_ACTION = "web_search"; +let sceneSearchFadeTimer = null; + +function getSceneRoot() { + return document.querySelector("main"); +} + +function setSceneSearchScreenActive(active) { + const sceneRoot = getSceneRoot(); + + if (!sceneRoot) { + return; + } + + if (sceneSearchFadeTimer) { + clearTimeout(sceneSearchFadeTimer); + sceneSearchFadeTimer = null; + } + + if (active) { + sceneRoot.classList.add( + "scene-searching" + ); + return; + } + + sceneRoot.classList.remove( + "scene-searching" + ); +} + +function syncSceneSearchScreenForRuntimeAction( + action, + active +) { + if ( + String(action || "").toLowerCase() + !== SCENE_SEARCH_RUNTIME_ACTION + ) { + return; + } + + setSceneSearchScreenActive( + active + ); +} + +function normalizeJinLoopInput(text) { + + const raw = String( + text + || "" + ).toLowerCase(); + + const normalized = raw.normalize + ? raw.normalize("NFKC") + : raw; + + try { + return normalized.replace( + /[\p{P}\p{S}\s]+/gu, + "" + ); + } catch (error) { + return normalized.replace( + /[^a-zа-яёіїєґ0-9]+/gi, + "" + ); + } + +} + +function updateJinInputLoopCounter(text) { + + const normalizedInput = + normalizeJinLoopInput( + text + ); + + if (!normalizedInput) { + jinInputLoopState.previousInput = ""; + jinInputLoopState.repeatCount = 0; + + return { + repeatCount: 0, + repeated: 0, + normalizedInput: "", + }; + } + + if ( + normalizedInput + === jinInputLoopState.previousInput + ) { + jinInputLoopState.repeatCount += 1; + } else { + jinInputLoopState.previousInput = normalizedInput; + jinInputLoopState.repeatCount = 0; + } + + const repeated = + jinInputLoopState.repeatCount > 0 + ? jinInputLoopState.repeatCount + 1 + : 0; + + return { + repeatCount: jinInputLoopState.repeatCount, + repeated, + normalizedInput, + }; + +} + /** * @typedef {Object} ContextSnapshot * @property {string=} system_prompt @@ -436,6 +558,9 @@ function createMessageElement( msgDiv.className = "jin-message-row jin-message-shell mx-auto w-full max-w-4xl"; + msgDiv.dataset.role = + role; + const pre = document.createElement("pre"); @@ -490,6 +615,12 @@ function appendChatMessage( pre.innerHTML = escapeHtml(text); + if (role === "user") { + jinConversationTurnCounter += 1; + window.jinConversationTurnCounter = + jinConversationTurnCounter; + } + flushRuntimeActionsAfterResponse( role ); @@ -501,7 +632,8 @@ function appendChatMessage( function appendRuntimeAction( action, - text + text, + options = {} ) { const actionText = @@ -513,6 +645,13 @@ function appendRuntimeAction( return; } + if (options.activateScene !== false) { + syncSceneSearchScreenForRuntimeAction( + action, + true + ); + } + const row = document.createElement("div"); @@ -615,7 +754,10 @@ function flushRuntimeActionsAfterResponse( actions.forEach((entry) => { appendRuntimeAction( entry.action, - entry.text + entry.text, + { + activateScene: !entry.completed, + } ); if (entry.completed) { @@ -638,6 +780,11 @@ function fadeRuntimeAction( } }); + syncSceneSearchScreenForRuntimeAction( + action, + false + ); + const rows = chatHistory.querySelectorAll( `[data-runtime-action="${action}"]` @@ -1044,6 +1191,15 @@ function finishStreamMessage( } +window.normalizeJinLoopInput = + normalizeJinLoopInput; + +window.updateJinInputLoopCounter = + updateJinInputLoopCounter; + +window.setSceneSearchScreenActive = + setSceneSearchScreenActive; + window.appendChatMessage = appendChatMessage; diff --git a/ui/static/js/runtime/runtime-panel.js b/ui/static/js/runtime/runtime-panel.js index c71c3061..b359104c 100644 --- a/ui/static/js/runtime/runtime-panel.js +++ b/ui/static/js/runtime/runtime-panel.js @@ -6,6 +6,39 @@ const TELEMETRY_FRAME_WARNING_MS = 12; const CONTEXT_PANEL_RENDER_THROTTLE_MS = 300; + const SCENE_CONTEXT_PRESSURE_MIDDLE_THRESHOLD = 50; + const SCENE_CONTEXT_PRESSURE_CLUTTERED_THRESHOLD = 100; + const SCENE_CONTEXT_PRESSURE_CLEAR_THRESHOLD = 100; + const SCENE_CONTEXT_PRESSURE_MIDDLE_TURNS = 3; + const SCENE_CONTEXT_PRESSURE_CLUTTERED_TURNS = 3; + const SCENE_CONTEXT_PRESSURE_CLEAR_TURNS = 2; + const SCENE_CONTEXT_PRESSURE_COMMIT_DELAY_MS = 420; + + const sceneContextPressureState = { + promt_context_presure: 0, + L1_memory_context_presure: 0, + middleTurns: 0, + highTurns: 0, + lowTurns: 0, + desiredStage: null, + visibleStage: null, + pendingKey: "", + pendingTurnKey: "", + pendingSample: null, + committedKey: "", + committedTurnKey: "", + commitTimer: null, + imageState: { + middle: "unknown", + cluttered: "unknown", + }, + }; + + const SCENE_CONTEXT_PRESSURE_IMAGES = { + middle: "static/images/states/middle.png", + cluttered: "static/images/states/cluttered.png", + }; + let initialized = false; let telemetryFrameScheduled = false; let contextPanelRenderTimer = null; @@ -243,6 +276,413 @@ } + function getSceneRoot() { + + return document.querySelector( + "main" + ); + + } + + + function clearSceneContextPressureTimer() { + + if (!sceneContextPressureState.commitTimer) { + return; + } + + clearTimeout( + sceneContextPressureState.commitTimer + ); + + sceneContextPressureState.commitTimer = null; + + } + + + function clampContextPressure(value) { + + const number = Number(value); + + if (!Number.isFinite(number)) { + return 0; + } + + return Math.max( + 0, + Math.round(number) + ); + + } + + + function getSceneContextPressureTurnKey() { + + const counter = Number( + window.jinConversationTurnCounter || 0 + ); + + if (Number.isFinite(counter) && counter > 0) { + return `turn:${counter}`; + } + + return "turn:0"; + + } + + + function getContextLinePressure(contextLine) { + + if (!contextLine) { + return 0; + } + + return clampContextPressure( + Math.max( + Number(contextLine.percent || 0), + Number(contextLine.totalPercent || 0) + ) + ); + + } + + + function clearSceneContextPressureStage() { + + const sceneRoot = + getSceneRoot(); + + if (!sceneRoot) { + return; + } + + sceneContextPressureState.desiredStage = null; + sceneContextPressureState.visibleStage = null; + + sceneRoot.classList.remove( + "scene-clutter-middle", + "scene-cluttered" + ); + + } + + + function applyLoadedSceneContextPressureStage(stage) { + + const sceneRoot = + getSceneRoot(); + + const imageUrl = + SCENE_CONTEXT_PRESSURE_IMAGES[stage]; + + if ( + !sceneRoot + || !imageUrl + ) { + return; + } + + sceneRoot.classList.toggle( + "scene-clutter-middle", + stage === "middle" + ); + + sceneRoot.classList.toggle( + "scene-cluttered", + stage === "cluttered" + ); + + sceneContextPressureState.visibleStage = + stage; + + } + + + function ensureSceneContextPressureImage( + stage, + callback + ) { + + const imageUrl = + SCENE_CONTEXT_PRESSURE_IMAGES[stage]; + + if (!imageUrl) { + callback(false); + return; + } + + const currentState = + sceneContextPressureState.imageState[stage]; + + if (currentState === "loaded") { + callback(true); + return; + } + + if (currentState === "missing") { + callback(false); + return; + } + + sceneContextPressureState.imageState[stage] = + "loading"; + + const image = + new Image(); + + image.onload = function () { + sceneContextPressureState.imageState[stage] = + "loaded"; + + callback(true); + }; + + image.onerror = function () { + sceneContextPressureState.imageState[stage] = + "missing"; + + callback(false); + }; + + image.src = imageUrl; + + } + + + function setSceneContextPressureStage(stage) { + + if (!stage) { + clearSceneContextPressureStage(); + return; + } + + sceneContextPressureState.desiredStage = + stage; + + ensureSceneContextPressureImage( + stage, + function (loaded) { + if ( + sceneContextPressureState.desiredStage + !== stage + ) { + return; + } + + if (loaded) { + applyLoadedSceneContextPressureStage( + stage + ); + return; + } + + if (stage === "cluttered") { + setSceneContextPressureStage( + "middle" + ); + return; + } + + clearSceneContextPressureStage(); + } + ); + + } + + + function publishSceneContextPressureState() { + + window.jinSceneContextPressure = { + promt_context_presure: + sceneContextPressureState.promt_context_presure, + L1_memory_context_presure: + sceneContextPressureState.L1_memory_context_presure, + middleTurns: + sceneContextPressureState.middleTurns, + highTurns: + sceneContextPressureState.highTurns, + lowTurns: + sceneContextPressureState.lowTurns, + stage: + sceneContextPressureState.visibleStage, + }; + + } + + + function commitSceneContextPressureSample(sample) { + + if (!sample) { + return; + } + + const key = + `${sample.promptPressure}:${sample.l1Pressure}`; + + const turnKey = + sample.turnKey || "turn:0"; + + if ( + turnKey + && turnKey === sceneContextPressureState.committedTurnKey + && key === sceneContextPressureState.committedKey + ) { + return; + } + + sceneContextPressureState.committedKey = + key; + + sceneContextPressureState.committedTurnKey = + turnKey; + + sceneContextPressureState.promt_context_presure = + sample.promptPressure; + + sceneContextPressureState.L1_memory_context_presure = + sample.l1Pressure; + + const bothMiddleHigh = + sample.promptPressure > SCENE_CONTEXT_PRESSURE_MIDDLE_THRESHOLD + && sample.l1Pressure > SCENE_CONTEXT_PRESSURE_MIDDLE_THRESHOLD; + + const bothClutteredHigh = + sample.promptPressure > SCENE_CONTEXT_PRESSURE_CLUTTERED_THRESHOLD + && sample.l1Pressure > SCENE_CONTEXT_PRESSURE_CLUTTERED_THRESHOLD; + + const bothClearLow = + sample.promptPressure < SCENE_CONTEXT_PRESSURE_CLEAR_THRESHOLD + && sample.l1Pressure < SCENE_CONTEXT_PRESSURE_CLEAR_THRESHOLD; + + if (bothMiddleHigh) { + sceneContextPressureState.middleTurns += 1; + } else { + sceneContextPressureState.middleTurns = 0; + } + + if (bothClutteredHigh) { + sceneContextPressureState.highTurns += 1; + sceneContextPressureState.lowTurns = 0; + } else { + sceneContextPressureState.highTurns = 0; + + if (bothClearLow) { + sceneContextPressureState.lowTurns += 1; + } else { + sceneContextPressureState.lowTurns = 0; + } + } + + if ( + sceneContextPressureState.lowTurns + >= SCENE_CONTEXT_PRESSURE_CLEAR_TURNS + ) { + setSceneContextPressureStage(null); + } else if ( + sceneContextPressureState.highTurns + >= SCENE_CONTEXT_PRESSURE_CLUTTERED_TURNS + ) { + setSceneContextPressureStage( + "cluttered" + ); + } else if ( + sceneContextPressureState.middleTurns + >= SCENE_CONTEXT_PRESSURE_MIDDLE_TURNS + ) { + setSceneContextPressureStage( + "middle" + ); + } + + publishSceneContextPressureState(); + + } + + + function scheduleSceneContextPressureSample( + promptPressure, + l1Pressure + ) { + + const sample = { + promptPressure: + clampContextPressure(promptPressure), + l1Pressure: + clampContextPressure(l1Pressure), + turnKey: + getSceneContextPressureTurnKey(), + }; + + if ( + sample.promptPressure <= 0 + && sample.l1Pressure <= 0 + ) { + return; + } + + const key = + `${sample.promptPressure}:${sample.l1Pressure}`; + + const turnKey = + sample.turnKey || "turn:0"; + + if ( + key === sceneContextPressureState.pendingKey + && turnKey === sceneContextPressureState.pendingTurnKey + ) { + return; + } + + if ( + key === sceneContextPressureState.committedKey + && turnKey === sceneContextPressureState.committedTurnKey + ) { + return; + } + + sceneContextPressureState.pendingKey = + key; + + sceneContextPressureState.pendingTurnKey = + turnKey; + + sceneContextPressureState.pendingSample = + sample; + + clearSceneContextPressureTimer(); + + sceneContextPressureState.commitTimer = + setTimeout( + function () { + sceneContextPressureState.commitTimer = null; + + commitSceneContextPressureSample( + sceneContextPressureState.pendingSample + ); + }, + SCENE_CONTEXT_PRESSURE_COMMIT_DELAY_MS + ); + + } + + + function updateSceneContextPressureFromLines( + promptContextLine, + l1ContextLine + ) { + + scheduleSceneContextPressureSample( + getContextLinePressure( + promptContextLine + ), + getContextLinePressure( + l1ContextLine + ) + ); + + } + + function buildContextLine( runtime, cells @@ -284,12 +724,15 @@ const rawPercent = max > 0 - ? Math.min( - 100, - (used / max) * 100 - ) + ? (used / max) * 100 : 0; + const visiblePercent = + Math.min( + 100, + rawPercent + ); + const percent = Math.round(rawPercent); @@ -301,30 +744,36 @@ const filled = Math.round( - (rawPercent / 100) * cells + (visiblePercent / 100) * cells ); const contextPercent = max > 0 - ? Math.min( - 100, - (contextUsed / max) * 100 - ) + ? (contextUsed / max) * 100 : 0; const totalPercent = max > 0 - ? Math.min( - 100, - (totalUsed / max) * 100 - ) + ? (totalUsed / max) * 100 : 0; + const visibleContextPercent = + Math.min( + 100, + contextPercent + ); + + const visibleTotalPercent = + Math.min( + 100, + totalPercent + ); + const contextFilled = Math.min( cells, Math.round( - (contextPercent / 100) * cells + (visibleContextPercent / 100) * cells ) ); @@ -332,7 +781,7 @@ Math.min( cells, Math.round( - (totalPercent / 100) * cells + (visibleTotalPercent / 100) * cells ) ); @@ -649,6 +1098,11 @@ summarizerPressureColor; } + updateSceneContextPressureFromLines( + contextLine, + summarizerLine + ); + void summarizerTokenText; } diff --git a/ui/static/js/socket.js b/ui/static/js/socket.js index 70edab3a..00db0ee5 100644 --- a/ui/static/js/socket.js +++ b/ui/static/js/socket.js @@ -1326,6 +1326,20 @@ chatForm.addEventListener( text: text, }; + const inputLoopContext = + window.updateJinInputLoopCounter + ? window.updateJinInputLoopCounter( + text + ) + : null; + + if (inputLoopContext) { + payload.runtime_pattern_counter = + inputLoopContext.repeatCount; + payload.runtime_repeated_input_count = + inputLoopContext.repeated || 0; + } + const userIdleContext = window.getJinUserIdleContext ? window.getJinUserIdleContext() diff --git a/ui/templates/index.html b/ui/templates/index.html index 7bde10bb..6b5ecc44 100644 --- a/ui/templates/index.html +++ b/ui/templates/index.html @@ -50,11 +50,50 @@ } #scene-bg { + z-index: 0; background-size: cover; background-position: center; } + #scene-clutter-overlay, + #scene-cluttered-overlay, + #scene-search-overlay { + opacity: 0; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + } + + #scene-clutter-overlay, + #scene-cluttered-overlay { + z-index: 1; + transition: opacity 3s ease; + } + + #scene-clutter-overlay { + background-image: url('static/images/states/middle.png'); + } + + #scene-cluttered-overlay { + background-image: url('static/images/states/cluttered.png'); + } + + #scene-search-overlay { + z-index: 2; + transition: opacity 0.22s ease; + + background-image: url('static/images/states/searching.png'); + } + + main.scene-clutter-middle #scene-clutter-overlay, + main.scene-cluttered #scene-clutter-overlay, + main.scene-cluttered #scene-cluttered-overlay, + main.scene-searching #scene-search-overlay { + opacity: 1; + } + #scene-shade { + z-index: 3; background: rgba(0, 0, 0, var(--scene-shade-alpha)); transition: background 0.22s ease; } @@ -1302,14 +1341,26 @@ - + + style="background-image:url('static/images/states/default.jpg')"> ++ + ++ class="absolute inset-0 pointer-events-none">diff --git a/websocket.py b/websocket.py index 042cb695..042910fd 100644 --- a/websocket.py +++ b/websocket.py @@ -1364,6 +1364,68 @@ def apply_user_idle_context( ) +def parse_runtime_pattern_counter( + value, +) -> int: + + try: + counter = int( + value + or 0 + ) + except ( + TypeError, + ValueError, + ): + return 0 + + return max( + 0, + min( + counter, + 100, + ), + ) + + +def apply_runtime_pattern_context( + context, + message_data: dict, +): + + context.runtime_pattern_counter = parse_runtime_pattern_counter( + message_data.get( + "runtime_pattern_counter", + 0, + ) + ) + context.runtime_repeated_input_count = parse_runtime_pattern_counter( + message_data.get( + "runtime_repeated_input_count", + 0, + ) + ) + + +def format_runtime_memory_user_message( + context, + user_text: str, +) -> str: + + repeated = parse_runtime_pattern_counter( + getattr( + context, + "runtime_repeated_input_count", + 0, + ) + ) + + if repeated < 2: + return user_text + + return f"{json.dumps(user_text, ensure_ascii=False)} [ repeated: {repeated} ]" + + async def process_message( context, message_data: dict, @@ -1391,6 +1453,10 @@ async def process_message( context, message_data, ) + apply_runtime_pattern_context( + context, + message_data, + ) context.user_message_count += 1 state = AgentState( @@ -1440,7 +1506,10 @@ async def process_message( memory_update_task = schedule_runtime_memory_update( context=context, - user_message=user_text, + user_message=format_runtime_memory_user_message( + context, + user_text, + ), assistant_message=assistant_message, )