From 989a43cdc009bc98c6080aab54fba32a0ffa0865 Mon Sep 17 00:00:00 2001 From: makeitdouble Date: Mon, 8 Jun 2026 10:54:31 +0300 Subject: [PATCH 1/6] feature: pheromone memory - add strength decay/presence scoring for runtime memory lines - guard generic keys against false topic continuation - expose memory heat zones to L1/L2 summarizers - add session intent markers - update identity prompt for better form adoption --- bootstrap/brain_bootstrap.py | 5 + rules/core_identity.py | 6 + runtime/memory.py | 332 +++++++++++++++++++++++++++++++++-- runtime/memory_rules.py | 72 +++++++- 4 files changed, 397 insertions(+), 18 deletions(-) diff --git a/bootstrap/brain_bootstrap.py b/bootstrap/brain_bootstrap.py index 315ab524..090456d2 100644 --- a/bootstrap/brain_bootstrap.py +++ b/bootstrap/brain_bootstrap.py @@ -43,6 +43,11 @@ "save this session", "remember where we stopped", "wrap up and save", + "продолжим завтра", + "сохрани и закрой", + "сохрани на сегодня", + "давай на сегодня всё", + "на сегодня хватит", ) META_TAG_REQUEST_MARKERS = ( diff --git a/rules/core_identity.py b/rules/core_identity.py index f413b50f..1366435e 100644 --- a/rules/core_identity.py +++ b/rules/core_identity.py @@ -23,6 +23,12 @@ "\n" "" "\n" + "" + "\n" + "can maintain any form" + "\n" + "" + "\n" "A local conversational runtime within chat web-interface." "\n" "As JIN I try to fulfill the user's real intent when it is safe and possible.\n" diff --git a/runtime/memory.py b/runtime/memory.py index 0d3e19ae..29fd4bc1 100644 --- a/runtime/memory.py +++ b/runtime/memory.py @@ -51,6 +51,29 @@ MIN_L2_TURNS = 3 L2_PATCH_WINDOW = 5 L2_REPEATED_KEY_THRESHOLD = 3 +STRENGTH_DECAY = 0.82 +STRENGTH_PRESENCE_BOOST = 0.08 +STRENGTH_BOOST = 0.8 +STRENGTH_NEW_KEY = 0.5 +DURABLE_FLOOR = 0.25 +HOT_THRESHOLD = 0.5 +FADING_THRESHOLD = 0.1 +GENERIC_MEMORY_VALUE_SIMILARITY_MIN = 0.35 +GENERIC_MEMORY_MATCH_KEYS = ( + "topic", + "focus", + "current task", + "current context", + "user request", + "user intent", + "active topic", + "active topics", + "open reference", + "open references", + "pending choice", + "pending choices", + "last jin response", +) DURABLE_MEMORY_KEY_TOKENS = ( "fact", "identity", @@ -498,6 +521,20 @@ async def emit_runtime_l1_diff_update( or [] ) + snapshots = list( + getattr( + context, + "runtime_memory_snapshots", + [], + ) + or [] + ) + latest_lines = ( + snapshots[-1].get("lines", []) + if snapshots + else [] + ) + await safe_call( emit, { @@ -506,6 +543,12 @@ async def emit_runtime_l1_diff_update( "stats": build_runtime_l1_diff_stats( history ), + "strength_map": build_strength_map( + latest_lines + ), + "strength_zones": get_strength_zones( + latest_lines + ), }, ) @@ -967,6 +1010,19 @@ async def ask_runtime_memory_model( system_prompt = ( build_runtime_memory_system_prompt() ) + _snapshots = list( + getattr( + context, + "runtime_memory_snapshots", + [], + ) + or [] + ) + _latest_lines = ( + _snapshots[-1].get("lines", []) + if _snapshots + else [] + ) user_prompt = build_runtime_summarizer_user_prompt( context=context, prompt=( @@ -979,6 +1035,9 @@ async def ask_runtime_memory_model( "runtime_l2_memory", "", ), + strength_zones=get_strength_zones( + _latest_lines + ), ) ), ) @@ -1038,6 +1097,19 @@ async def ask_runtime_memory_batch_model( system_prompt = ( build_runtime_memory_system_prompt() ) + _snapshots = list( + getattr( + context, + "runtime_memory_snapshots", + [], + ) + or [] + ) + _latest_lines = ( + _snapshots[-1].get("lines", []) + if _snapshots + else [] + ) user_prompt = build_runtime_summarizer_user_prompt( context=context, prompt=( @@ -1049,6 +1121,9 @@ async def ask_runtime_memory_batch_model( "runtime_l2_memory", "", ), + strength_zones=get_strength_zones( + _latest_lines + ), ) ), ) @@ -2551,6 +2626,62 @@ def is_durable_memory_key( ) +def compute_line_strength( + prev_strength: float | None, + change_ratio_val: float, + is_durable: bool, + is_new: bool, +) -> float: + if is_new: + raw = STRENGTH_NEW_KEY + else: + raw = ( + (prev_strength or 0.0) * STRENGTH_DECAY + + STRENGTH_PRESENCE_BOOST + + change_ratio_val * STRENGTH_BOOST + ) + + floor = DURABLE_FLOOR if is_durable else 0.0 + + return round( + min( + 1.0, + max( + floor, + raw, + ), + ), + 4, + ) + + +def get_strength_zones( + lines: list[dict], +) -> dict: + hot, crystallized, fading = [], [], [] + for line in lines: + key = line.get("key", "") + strength = line.get("strength", 0.0) + durable = is_durable_memory_key(key) + if strength >= HOT_THRESHOLD: + hot.append(key) + elif durable and strength <= DURABLE_FLOOR + 0.05: + crystallized.append(key) + elif strength <= FADING_THRESHOLD: + fading.append(key) + return {"hot": hot, "crystallized": crystallized, "fading": fading} + + +def build_strength_map( + lines: list[dict], +) -> dict[str, float]: + return { + line.get("key", ""): line.get("strength", 0.0) + for line in lines + if line.get("key") + } + + def has_durable_fact_negation( value: str, ) -> bool: @@ -2806,9 +2937,104 @@ def merge_durable_memory_facts( ) +def normalize_generic_memory_key( + key: str, +) -> str: + + return ( + normalize_memory_key( + key + ) + .replace( + "_", + " ", + ) + .replace( + "-", + " ", + ) + ) + + +def is_generic_memory_match_key( + key: str, +) -> bool: + + return normalize_generic_memory_key( + key + ) in GENERIC_MEMORY_MATCH_KEYS + + +def memory_value_similarity( + previous: str, + current: str, +) -> float: + + previous = ( + previous + or "" + ).strip() + current = ( + current + or "" + ).strip() + + if not previous and not current: + return 1.0 + + if not previous or not current: + return 0.0 + + return round( + SequenceMatcher( + None, + previous.lower(), + current.lower(), + ).ratio(), + 3, + ) + + +def should_match_previous_memory_line( + *, + key: str, + value: str, + previous_line: dict | None, +) -> bool: + + if previous_line is None: + return False + + previous_key = previous_line.get( + "key", + "", + ) + + if not ( + is_generic_memory_match_key( + key + ) + or is_generic_memory_match_key( + previous_key + ) + ): + return True + + similarity = memory_value_similarity( + previous_line.get( + "value", + "", + ), + value, + ) + + return similarity >= GENERIC_MEMORY_VALUE_SIMILARITY_MIN + + def find_best_previous_line( key: str, previous_lines: list[dict], + value: str = "", ) -> dict | None: normalized_key = normalize_memory_key( @@ -2840,7 +3066,14 @@ def find_best_previous_line( best_score = score best_line = previous_line - if best_score >= 0.58: + if ( + best_score >= 0.58 + and should_match_previous_memory_line( + key=key, + value=value, + previous_line=best_line, + ) + ): return best_line return None @@ -2857,6 +3090,14 @@ def apply_runtime_memory_diff( line["key_change_ratio"] = 1.0 line["value_change_ratio"] = 1.0 line["status"] = "new" + line["strength"] = compute_line_strength( + prev_strength=None, + change_ratio_val=1.0, + is_durable=is_durable_memory_key( + line.get("key", "") + ), + is_new=True, + ) return current_lines @@ -2903,16 +3144,24 @@ def apply_runtime_memory_diff( key ) - previous_line = ( - previous_by_normalized_key.get( - normalized_key - ) - or find_best_previous_line( - key, - previous_lines, - ) + previous_line = previous_by_normalized_key.get( + normalized_key ) + if not should_match_previous_memory_line( + key=key, + value=value, + previous_line=previous_line, + ): + previous_line = None + + if previous_line is None: + previous_line = find_best_previous_line( + key, + previous_lines, + value=value, + ) + # ----------------------------------------- # EXACT KEY NOT FOUND # ----------------------------------------- @@ -2923,6 +3172,12 @@ def apply_runtime_memory_diff( line["key_change_ratio"] = 1.0 line["value_change_ratio"] = 1.0 line["status"] = "new" + line["strength"] = compute_line_strength( + prev_strength=None, + change_ratio_val=1.0, + is_durable=is_durable_memory_key(key), + is_new=True, + ) continue @@ -2976,6 +3231,13 @@ def apply_runtime_memory_diff( else: line["status"] = "same" + line["strength"] = compute_line_strength( + prev_strength=previous_line.get("strength"), + change_ratio_val=max(key_delta, value_delta), + is_durable=is_durable_memory_key(key), + is_new=False, + ) + return current_lines @@ -3002,6 +3264,10 @@ def build_runtime_memory_patch( "value", "", ), + "strength": line.get( + "strength", + 0.0, + ), }) total_diff += 30 @@ -3043,20 +3309,36 @@ def build_runtime_memory_patch( or "" ).strip() + value = ( + line.get( + "value", + "", + ) + or "" + ).strip() + normalized_key = normalize_memory_key( key ) - previous_line = ( - previous_by_normalized_key.get( - normalized_key - ) - or find_best_previous_line( - key, - previous_lines, - ) + previous_line = previous_by_normalized_key.get( + normalized_key ) + if not should_match_previous_memory_line( + key=key, + value=value, + previous_line=previous_line, + ): + previous_line = None + + if previous_line is None: + previous_line = find_best_previous_line( + key, + previous_lines, + value=value, + ) + if previous_line is None: patch["added"].append({ "key": key, @@ -3064,6 +3346,10 @@ def build_runtime_memory_patch( "value", "", ), + "strength": line.get( + "strength", + 0.0, + ), }) total_diff += 30 continue @@ -3098,6 +3384,14 @@ def build_runtime_memory_patch( ), "key_change_ratio": key_delta, "value_change_ratio": value_delta, + "previous_strength": previous_line.get( + "strength", + 0.0, + ), + "current_strength": line.get( + "strength", + 0.0, + ), }) total_diff += round( ( @@ -3121,6 +3415,10 @@ def build_runtime_memory_patch( "value", "", ), + "strength": previous_line.get( + "strength", + 0.0, + ), }) total_diff += 20 diff --git a/runtime/memory_rules.py b/runtime/memory_rules.py index 8384d64e..ee0e4980 100644 --- a/runtime/memory_rules.py +++ b/runtime/memory_rules.py @@ -650,6 +650,39 @@ def build_runtime_l2_memory_user_prompt( patches: list[dict], ) -> str: + def format_l2_strength_suffix( + entry: dict, + *, + changed: bool = False, + ) -> str: + + if changed: + previous_strength = entry.get( + "previous_strength", + ) + current_strength = entry.get( + "current_strength", + ) + + if ( + previous_strength is None + and current_strength is None + ): + return "" + + return ( + " " + f"[strength: {previous_strength if previous_strength is not None else '?'}" + " -> " + f"{current_strength if current_strength is not None else '?'}]" + ) + + strength = entry.get( + "strength", + ) + + return f" [strength: {strength}]" if strength is not None else "" + lines = [ "Current L2 pattern memory:", current_l2_memory.strip() or "", @@ -701,11 +734,18 @@ def build_runtime_l2_memory_user_prompt( f"{entry.get('previous_key', '')}: {entry.get('previous_value', '')} " "=> " f"{entry.get('current_key', '')}: {entry.get('current_value', '')}" + + format_l2_strength_suffix( + entry, + changed=True, + ) ) else: lines.append( "- " f"{entry.get('key', '')}: {entry.get('value', '')}" + + format_l2_strength_suffix( + entry, + ) ) lines.extend([ @@ -966,11 +1006,25 @@ def build_runtime_memory_user_prompt( user_message: str, assistant_message: str, current_l2_memory: str = "", + strength_zones: dict | None = None, ) -> str: + zones_hint = "" + if strength_zones: + hot = ", ".join(strength_zones.get("hot", [])) or "none" + crystallized = ", ".join(strength_zones.get("crystallized", [])) or "none" + fading = ", ".join(strength_zones.get("fading", [])) or "none" + zones_hint = ( + "Memory heat (pheromone strength):\n" + f"Hot (active): {hot}\n" + f"Crystallized (stable facts): {crystallized}\n" + f"Fading (deprioritize): {fading}\n\n" + ) + return ( "Current runtime memory:\n" f"{current_memory.strip() or DEFAULT_RUNTIME_MEMORY}\n\n" + f"{zones_hint}" "Current L2 pattern memory for occurrence tracking only:\n" f"{current_l2_memory.strip() or ''}\n\n" "Latest user message:\n" @@ -986,17 +1040,33 @@ def build_runtime_memory_batch_user_prompt( current_memory: str, turns: list[dict], current_l2_memory: str = "", + strength_zones: dict | None = None, ) -> str: lines = [ "Current runtime memory:", current_memory.strip() or DEFAULT_RUNTIME_MEMORY, "", + ] + + if strength_zones: + hot = ", ".join(strength_zones.get("hot", [])) or "none" + crystallized = ", ".join(strength_zones.get("crystallized", [])) or "none" + fading = ", ".join(strength_zones.get("fading", [])) or "none" + lines.extend([ + "Memory heat (pheromone strength):", + f"Hot (active): {hot}", + f"Crystallized (stable facts): {crystallized}", + f"Fading (deprioritize): {fading}", + "", + ]) + + lines.extend([ "Current L2 pattern memory for occurrence tracking only:", current_l2_memory.strip() or "", "", "New completed turns since that memory snapshot:", - ] + ]) for index, turn in enumerate( turns, From 73e0839b0dc3546f553d8fa38c70c0a4ef6dfbe9 Mon Sep 17 00:00:00 2001 From: makeitdouble Date: Mon, 8 Jun 2026 11:51:19 +0300 Subject: [PATCH 2/6] feature: pheromone memory - show strength as plain memory suffix - expand volatile key guards for current/pending state --- runtime/memory.py | 25 ++++++++++++++++++++++--- ui/static/js/telemetry.js | 13 ++++++++++++- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/runtime/memory.py b/runtime/memory.py index 29fd4bc1..430b5e73 100644 --- a/runtime/memory.py +++ b/runtime/memory.py @@ -62,17 +62,36 @@ GENERIC_MEMORY_MATCH_KEYS = ( "topic", "focus", - "current task", - "current context", + "next_step", + "last jin response", + "user request", "user intent", + "active topic", "active topics", + "open reference", "open references", + "open question", + "pending choice", "pending choices", - "last jin response", + "pending action", + "pending actions", + + "session status", + "session state", + + "current concern", + "current concerns", + "current task", + "current tasks", + "current context", + "current request", + "current requests", + + "interaction state", ) DURABLE_MEMORY_KEY_TOKENS = ( "fact", diff --git a/ui/static/js/telemetry.js b/ui/static/js/telemetry.js index 06b9d2de..d02bd46a 100644 --- a/ui/static/js/telemetry.js +++ b/ui/static/js/telemetry.js @@ -2426,6 +2426,17 @@ function applyRuntimeMemoryFlash( }, 1500); } +function formatRuntimeMemoryStrengthSuffix(line) { + const strength = + Number(line && line.strength); + + if (!Number.isFinite(strength)) { + return ""; + } + + return ` (${strength.toFixed(2)})`; +} + function renderRuntimeMemoryLines(snapshot) { if (!runtimeMemoryText) { return; @@ -2478,7 +2489,7 @@ function renderRuntimeMemoryLines(snapshot) { "runtime-memory-value"; valueSpan.textContent = - ` ${value}`; + ` ${value}${formatRuntimeMemoryStrengthSuffix(line)}`; row.appendChild(keySpan); row.appendChild(valueSpan); From 785ab3a03445c9b62624807b6523a41e2f0d4bfa Mon Sep 17 00:00:00 2001 From: makeitdouble Date: Mon, 8 Jun 2026 12:57:55 +0300 Subject: [PATCH 3/6] feature: pheromone memory - add toggle for persisted runtime pheromone strength - restore pheromone snapshot as bootstrap baseline - fix for duplicate runtime pages on browser restore --- runtime/memory.py | 2 +- ui/static/js/telemetry.js | 89 ++++++++++++++++++++++-- websocket.py | 140 +++++++++++++++++++++++++++++--------- 3 files changed, 189 insertions(+), 42 deletions(-) diff --git a/runtime/memory.py b/runtime/memory.py index 430b5e73..fd7b74ad 100644 --- a/runtime/memory.py +++ b/runtime/memory.py @@ -62,7 +62,7 @@ GENERIC_MEMORY_MATCH_KEYS = ( "topic", "focus", - "next_step", + "next step", "last jin response", "user request", diff --git a/ui/static/js/telemetry.js b/ui/static/js/telemetry.js index d02bd46a..f05df3d0 100644 --- a/ui/static/js/telemetry.js +++ b/ui/static/js/telemetry.js @@ -66,6 +66,7 @@ const runtimePanelState = { const TELEMETRY_FRAME_WARNING_MS = 12; const CONTEXT_PANEL_RENDER_THROTTLE_MS = 300; +const SAVE_RUNTIME_PHEROMONE_STRENGTH = true; let telemetryFrameScheduled = false; let contextPanelRenderTimer = null; @@ -561,6 +562,26 @@ function writeBrowserMemory( } +function buildPersistedRuntimeSnapshot( + snapshot +) { + + if ( + !SAVE_RUNTIME_PHEROMONE_STRENGTH + || !snapshot + || typeof snapshot !== "object" + ) { + return null; + } + + return { + ...snapshot, + persisted_pheromone_strength: true, + }; + +} + + function persistRuntimeMemorySnapshot( data ) { @@ -597,7 +618,9 @@ function persistRuntimeMemorySnapshot( saved_at: savedAt, runtime_memory: runtimeMemory, runtime_memory_updates: data.updates || 0, - runtime_snapshot: data.snapshot, + runtime_snapshot: buildPersistedRuntimeSnapshot( + data.snapshot + ), } ); @@ -633,10 +656,10 @@ function runtimeMemoryObjectFromSnapshot( && Number(runtimeMemoryCount.textContent || 0) ) || 0, - runtime_snapshot: { + runtime_snapshot: buildPersistedRuntimeSnapshot({ ...snapshot, raw_memory: runtimeMemory.trim(), - }, + }), }; } @@ -783,10 +806,10 @@ function persistSessionMemory( && sessionRuntimeMemory.runtime_memory_updates ) || 0, runtime_snapshot: - ( + buildPersistedRuntimeSnapshot( sessionRuntimeMemory && sessionRuntimeMemory.runtime_snapshot - ) || null, + ), } ); @@ -927,6 +950,54 @@ function isBootstrapRuntimeMemoryDuplicate( } +function applyBootstrapRuntimeMemoryUpdate( + data +) { + + if ( + !pendingBootstrapRuntimeMemorySnapshot + || !data + || data.type !== "runtime_memory_update" + || Number(data.updates || 0) !== 0 + || !data.snapshot + ) { + return false; + } + + const snapshot = { + ...data.snapshot, + index: 0, + runtime_memory_updates: + Number(data.updates || 0), + }; + + pendingBootstrapRuntimeMemorySnapshot = null; + runtimeMemoryDisplayMode = "runtime"; + restoredSessionMemorySnapshot = null; + + if (window.stopMemoryGlow) { + window.stopMemoryGlow(); + } + + runtimeMemoryHistory.snapshots = [snapshot]; + runtimeMemoryHistory.index = 0; + + if (runtimeMemoryCount) { + runtimeMemoryCount.textContent = + String(data.updates || 0); + } + + persistRuntimeMemorySnapshot( + data + ); + + renderRuntimeMemorySnapshot(); + + return true; + +} + + function handleTabCloseSessionBootstrap(event) { if (!hasTabCloseSessionBootstrap()) { @@ -1020,7 +1091,7 @@ function buildRuntimeMemoryDisplaySnapshot( session_id: sourceSnapshot.session_id || "browser_restore", - index: 1, + index: 0, display_source: "saved_runtime_at_session_save", raw_memory: runtimeMemory, lines: @@ -1050,7 +1121,7 @@ function buildDefaultRuntimeMemorySnapshot() { return { session_id: "browser_restore", - index: 1, + index: 0, display_source: "default_runtime_memory", raw_memory: defaultRuntimeMemoryText, lines: [ @@ -2169,6 +2240,10 @@ window.handleRuntimeMemoryMessage = function (data) { return; } + if (applyBootstrapRuntimeMemoryUpdate(data)) { + return; + } + if (isBootstrapRuntimeMemoryDuplicate(data)) { return; } diff --git a/websocket.py b/websocket.py index 21995fe6..2be6711e 100644 --- a/websocket.py +++ b/websocket.py @@ -122,6 +122,81 @@ def parse_bootstrap_counter( return 0 +def runtime_snapshot_has_pheromone_strength( + snapshot: dict, +) -> bool: + + if not isinstance( + snapshot, + dict, + ): + return False + + lines = snapshot.get( + "lines", + [], + ) + + if not isinstance( + lines, + list, + ): + return False + + return any( + isinstance(line, dict) + and line.get("strength") is not None + for line in lines + ) + + +def build_restored_runtime_pheromone_snapshot( + runtime_snapshot: dict, + runtime_memory: str, + *, + index: int = 0, +) -> dict | None: + + if not runtime_snapshot_has_pheromone_strength( + runtime_snapshot + ): + return None + + snapshot_memory = clean_bootstrap_memory( + runtime_snapshot.get( + "raw_memory", + "", + ) + ) + + if snapshot_memory != runtime_memory: + return None + + lines = [ + line + for line in runtime_snapshot.get( + "lines", + [], + ) + if isinstance( + line, + dict, + ) + ] + + if not lines: + return None + + return { + **runtime_snapshot, + "index": index, + "raw_memory": runtime_memory, + "lines": lines, + "display_source": "restored_runtime_pheromone_snapshot", + "restored_pheromone_strength": True, + } + + def build_l3_bootstrap_runtime_memory( *, session_memory_updates: int, @@ -440,39 +515,27 @@ def apply_session_bootstrap( ) or "browser" if runtime_memory: - previous_runtime_memory = getattr( - context, - "runtime_memory", - "", + restored_pheromone_snapshot = ( + build_restored_runtime_pheromone_snapshot( + runtime_snapshot, + runtime_memory, + ) + if not stale_runtime_memory_for_ui + else None ) - if not getattr( - context, - "runtime_memory_snapshots", - [], - ): - context.runtime_memory_snapshots = [] - initial_snapshot = build_runtime_memory_snapshot( - context, - previous_runtime_memory, - ) - context.runtime_memory_snapshots.append( - initial_snapshot - ) + # Bootstrap should replace the initial/default runtime page, not append + # extra pages. If L3 made the saved L1 runtime stale, the stale snapshot + # must not stay visible as a separate page. If pheromone persistence is + # enabled and the saved snapshot matches runtime_memory, keep that + # snapshot as the single restored baseline so the next L1 update can + # continue strength calculations from it. + context.runtime_memory_snapshots = [] + context.runtime_memory_snapshot_index = 0 context.runtime_memory = runtime_memory context.runtime_memory_stable = runtime_memory - # If the stale L1 snapshot was preserved for UI, append it as an - # additional snapshot so the panel shows the original session fields. - # The agent still uses runtime_memory (the status message) as context. - if stale_runtime_memory_for_ui: - ui_snapshot = build_runtime_memory_snapshot( - context, - stale_runtime_memory_for_ui, - ) - context.runtime_memory_snapshots.append(ui_snapshot) - try: context.runtime_memory_updates = max( runtime_memory_updates, @@ -488,17 +551,26 @@ def apply_session_bootstrap( ): pass - restored_snapshot = build_runtime_memory_snapshot( - context, - context.runtime_memory, - ) + if restored_pheromone_snapshot: + restored_snapshot = { + **restored_pheromone_snapshot, + "index": 0, + "runtime_memory_updates": getattr( + context, + "runtime_memory_updates", + runtime_memory_updates, + ), + } + else: + restored_snapshot = build_runtime_memory_snapshot( + context, + context.runtime_memory, + ) context.runtime_memory_snapshots.append( restored_snapshot ) - context.runtime_memory_snapshot_index = restored_snapshot[ - "index" - ] + context.runtime_memory_snapshot_index = 0 return bool( session_memory From f276657f1f222a0560d532dc7401ed44b1cf961c Mon Sep 17 00:00:00 2001 From: makeitdouble Date: Mon, 8 Jun 2026 13:17:22 +0300 Subject: [PATCH 4/6] feature: pheromone memory - show strength as trace metadata - guard current topic keys from false hot traces --- rules/memory_rules.py | 1 + runtime/memory.py | 2 ++ runtime/memory_rules.py | 14 ++++++++++---- ui/static/js/telemetry.js | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/rules/memory_rules.py b/rules/memory_rules.py index 30cf2518..68111b02 100644 --- a/rules/memory_rules.py +++ b/rules/memory_rules.py @@ -10,6 +10,7 @@ "If the same message contains a current actionable request, handle it as new after briefly resolving the mismatch.\n" "\n" "Memory recall: scan strong memory fields before denying recall.\n" + "A visible suffix like `(trace: 0.50)` is runtime pheromone/attention trace strength: higher means hotter or reinforced, lower means fading. Use it silently for context priority; explain it only if the user asks about memory mechanics.\n" "If the user vaguely asks for a remembered word, code word, or saved item — match by meaning against stored_memory entries.\n" "Fields to scan: purpose, key detail, known fact, explicit fact, user_fact, jin_fact, decision, constraint, requirement.\n" "If an entry has purpose: future recall test and the user asks what value they saved — treat it as the strongest recall candidate.\n" diff --git a/runtime/memory.py b/runtime/memory.py index fd7b74ad..601e7b3c 100644 --- a/runtime/memory.py +++ b/runtime/memory.py @@ -70,6 +70,8 @@ "active topic", "active topics", + "current topic", + "current topics", "open reference", "open references", diff --git a/runtime/memory_rules.py b/runtime/memory_rules.py index ee0e4980..54393d9e 100644 --- a/runtime/memory_rules.py +++ b/runtime/memory_rules.py @@ -612,8 +612,14 @@ def build_runtime_l2_memory_system_prompt() -> str: "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" @@ -672,7 +678,7 @@ def format_l2_strength_suffix( return ( " " - f"[strength: {previous_strength if previous_strength is not None else '?'}" + f"[trace: {previous_strength if previous_strength is not None else '?'}" " -> " f"{current_strength if current_strength is not None else '?'}]" ) @@ -681,7 +687,7 @@ def format_l2_strength_suffix( "strength", ) - return f" [strength: {strength}]" if strength is not None else "" + return f" [trace: {strength}]" if strength is not None else "" lines = [ "Current L2 pattern memory:", @@ -1015,7 +1021,7 @@ def build_runtime_memory_user_prompt( crystallized = ", ".join(strength_zones.get("crystallized", [])) or "none" fading = ", ".join(strength_zones.get("fading", [])) or "none" zones_hint = ( - "Memory heat (pheromone strength):\n" + "Memory traces (pheromone strength):\n" f"Hot (active): {hot}\n" f"Crystallized (stable facts): {crystallized}\n" f"Fading (deprioritize): {fading}\n\n" @@ -1054,7 +1060,7 @@ def build_runtime_memory_batch_user_prompt( crystallized = ", ".join(strength_zones.get("crystallized", [])) or "none" fading = ", ".join(strength_zones.get("fading", [])) or "none" lines.extend([ - "Memory heat (pheromone strength):", + "Memory traces (pheromone strength):", f"Hot (active): {hot}", f"Crystallized (stable facts): {crystallized}", f"Fading (deprioritize): {fading}", diff --git a/ui/static/js/telemetry.js b/ui/static/js/telemetry.js index f05df3d0..a2d11cf7 100644 --- a/ui/static/js/telemetry.js +++ b/ui/static/js/telemetry.js @@ -2509,7 +2509,7 @@ function formatRuntimeMemoryStrengthSuffix(line) { return ""; } - return ` (${strength.toFixed(2)})`; + return ` (trace: ${strength.toFixed(2)})`; } function renderRuntimeMemoryLines(snapshot) { From 6cd617499623eef2adc4cd2a2818f7b1ea575496 Mon Sep 17 00:00:00 2001 From: makeitdouble Date: Mon, 8 Jun 2026 15:09:04 +0300 Subject: [PATCH 5/6] feature: pheromone memory - add behaviour probe multi-step test sample --- package.json | 5 +- ...est_behavior_probe_movie_closure_sample.py | 618 ++++++++++++++++++ 2 files changed, 621 insertions(+), 2 deletions(-) create mode 100644 tests/test_behavior_probe_movie_closure_sample.py diff --git a/package.json b/package.json index 28b071ec..7eb978dc 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "scripts": { "test": "python -m unittest discover -s tests", "tests": "npm test", - "translation_tests": "python tests/run_translation_tests.py" + "translation_tests": "python tests/run_translation_tests.py", + "behavior_probe_tests": "set JIN_RUN_BEHAVIOR_PROBE=1&& python -m unittest discover -s tests -p \"test_behavior_probe*.py\" -v" } -} +} \ No newline at end of file diff --git a/tests/test_behavior_probe_movie_closure_sample.py b/tests/test_behavior_probe_movie_closure_sample.py new file mode 100644 index 00000000..acab4a47 --- /dev/null +++ b/tests/test_behavior_probe_movie_closure_sample.py @@ -0,0 +1,618 @@ +""" +Simple behavior probe for JIN multi-step dialogue tests. + +Goal: +- Keep the test easy to copy/rename. +- Edit only constants near the top for most scenarios. +- No semantic marker heuristics. +- If EXPECTED_TEXT_ANSWER_N contains fragments, they are searched in model answer N. +- 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. +- If a list is empty, that part accepts any output. + +Run: + npm run behavior_probe_tests +or: + JIN_RUN_BEHAVIOR_PROBE=1 python -m unittest tests.test_behavior_probe_movie_closure_simple -v +""" + +import json +import os +import re +import sys +import unittest +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import httpx + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from agent import AgentRuntime, AgentState # noqa: E402 +from clients import build_clients # noqa: E402 +from runtime import ( # noqa: E402 + RuntimeContext, + RuntimeEmitter, + build_runtime_memory_snapshot, + schedule_runtime_memory_update, +) +from websocket import ( # noqa: E402 + refresh_pending_brain_usage, + wait_for_runtime_memory_update, +) +from websocket_logger import WebSocketLogger # noqa: E402 + + +# ============================================================================= +# EDIT THIS BLOCK FIRST +# ============================================================================= + +SCENARIO_ID = "movie_recommendation_closure" +SCENARIO_TITLE = "Niche movie recommendation graceful closure" +SCENARIO_NOTES = """ +Simple 3-step probe: the user asks for an unusual movie, gives a taste marker, +then closes the topic and says High Life is the active viewing target. +""" + +# Add more turns by appending: +# USER_TEXT_4 = "..." +# EXPECTED_TEXT_ANSWER_4 = ["optional answer fragment"] +# EXPECTED_TEXT_MEMORY_4 = ["optional memory fragment"] +# UNEXPECTED_TEXT_ANSWER_4 = ["optional forbidden answer fragment"] +# UNEXPECTED_TEXT_MEMORY_4 = ["optional forbidden memory fragment"] +# +# Empty lists mean: accept any text for this part. + +USER_TEXT_1 = "посоветуй необычный фильм, но не то что у всех на слуху, удиви меня" +EXPECTED_TEXT_ANSWER_1 = [] +EXPECTED_TEXT_MEMORY_1 = [] +UNEXPECTED_TEXT_ANSWER_1 = [ + "?", +] +UNEXPECTED_TEXT_MEMORY_1 = [] + +USER_TEXT_2 = "шикарная рекомендация, спасибо. мне ещё понравился с Паттинсоном фильм The Rover" +EXPECTED_TEXT_ANSWER_2 = [] +EXPECTED_TEXT_MEMORY_2 = [ + "The Rover", +] +UNEXPECTED_TEXT_ANSWER_2 = [] +UNEXPECTED_TEXT_MEMORY_2 = [] + +USER_TEXT_3 = "The Lighthouse я уже смотрел. я думаю можно дропнуть этот топик, я уже скачиваю High Life для просмотра." +EXPECTED_TEXT_ANSWER_3 = [ + "High Life", +] +EXPECTED_TEXT_MEMORY_3 = [ + "High Life", + "The Rover", +] +UNEXPECTED_TEXT_ANSWER_3 = [ + # Topic is explicitly closed here, so the final answer should not pull user back. + "?", + # These titles should stay in memory, but the final closing answer should not reopen them. + "The Lighthouse", + "The Rover", +] +UNEXPECTED_TEXT_MEMORY_3 = [ + +] + + +# ============================================================================= +# TEST / REPORT SETTINGS +# ============================================================================= + +RUN_MEMORY_UPDATE_AFTER_EACH_TURN = True +WAIT_FOR_MEMORY_UPDATE_AFTER_EACH_TURN = True + +# If False, failed expected fragments are shown in red but do not fail the test. +# Keep False for heatmap/probe mode. Set True when this becomes a regression test. +STRICT_TEXT_ASSERTIONS = False + +PRINT_PRETTY_REPORT = True +PRINT_JSON_REPORT = False +PRINT_WEBSOCKET_MESSAGES = False + +USE_ANSI_COLORS = True +MAX_ANSWER_PREVIEW_CHARS = 1400 +MAX_MEMORY_PREVIEW_CHARS = 2200 + +# Which memory fields should be displayed and searched. +MEMORY_TEXT_FIELDS_TO_INSPECT = [ + "runtime_memory", + "runtime_l2_memory", +] + + +# ============================================================================= +# SMALL HELPERS +# ============================================================================= + +ANSI = { + "reset": "\033[0m", + "bold": "\033[1m", + "dim": "\033[2m", + "green": "\033[32m", + "red": "\033[31m", + "yellow": "\033[33m", + "cyan": "\033[36m", + "blue": "\033[34m", + "gray": "\033[90m", +} + + +class CapturingWebSocket: + def __init__(self): + self.messages = [] + + async def send_json(self, payload: dict): + self.messages.append(payload) + + +def paint(text: str, color: str | None = None, *, bold: bool = False, dim: bool = False) -> str: + if not USE_ANSI_COLORS: + return text + + prefix = "" + if bold: + prefix += ANSI["bold"] + if dim: + prefix += ANSI["dim"] + if color: + prefix += ANSI.get(color, "") + return f"{prefix}{text}{ANSI['reset']}" + + +def render_text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value.strip() + if isinstance(value, (list, tuple)): + return "\n".join(render_text(item) for item in value).strip() + return str(value).strip() + + +def normalize_text(text: str) -> str: + return re.sub(r"\s+", " ", render_text(text).casefold()).strip() + + +def expected_fragments(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + value = value.strip() + return [value] if value else [] + if isinstance(value, (list, tuple)): + fragments: list[str] = [] + for item in value: + fragments.extend(expected_fragments(item)) + return fragments + value = str(value).strip() + return [value] if value else [] + + +def fragment_found(text: str, fragment: str) -> bool: + return normalize_text(fragment) in normalize_text(text) + + +def clip_text(text: str, limit: int) -> str: + text = render_text(text) + if len(text) <= limit: + return text + return text[:limit].rstrip() + paint("\n... [clipped]", "gray", dim=True) + + +def indent_block(text: str, prefix: str = " ") -> str: + text = render_text(text) + if not text: + return prefix + paint("", "gray", dim=True) + return "\n".join(f"{prefix}{line}" for line in text.splitlines()) + + +def status_label(passed: bool) -> str: + return paint("OK", "green", bold=True) if passed else paint("FAIL", "red", bold=True) + + +def collect_dialogue_steps() -> list[dict[str, Any]]: + """ + Auto-collect USER_TEXT_N plus expected/unexpected answer and memory markers. + To extend the scenario, add the next numeric constants at the top. + """ + + steps: list[dict[str, Any]] = [] + index = 1 + + while True: + user_key = f"USER_TEXT_{index}" + answer_key = f"EXPECTED_TEXT_ANSWER_{index}" + memory_key = f"EXPECTED_TEXT_MEMORY_{index}" + unexpected_answer_key = f"UNEXPECTED_TEXT_ANSWER_{index}" + unexpected_memory_key = f"UNEXPECTED_TEXT_MEMORY_{index}" + + if user_key not in globals(): + break + + user_text = render_text(globals()[user_key]) + if user_text: + steps.append( + { + "index": index, + "user_text": user_text, + "expected_answer": expected_fragments(globals().get(answer_key, [])), + "expected_memory": expected_fragments(globals().get(memory_key, [])), + "unexpected_answer": expected_fragments(globals().get(unexpected_answer_key, [])), + "unexpected_memory": expected_fragments(globals().get(unexpected_memory_key, [])), + } + ) + + index += 1 + + return steps + + +@dataclass +class TurnResult: + index: int + user_text: str + answer: str + memory_after_turn: str + expected_answer: list[str] + expected_memory: list[str] + unexpected_answer: list[str] + unexpected_memory: list[str] + + +async def run_standard_turn(context: RuntimeContext, user_text: str) -> AgentState: + await wait_for_runtime_memory_update(context) + await refresh_pending_brain_usage(context, user_text) + + context.runtime_turn_user_message = user_text + context.runtime_turn_assistant_response = "" + context.runtime_turn_interrupted = False + context.user_message_count += 1 + + if hasattr(context, "runtime_usage_events"): + context.runtime_usage_events.clear() + else: + context.runtime_usage_events = [] + + state = AgentState(user_input=user_text) + runtime = AgentRuntime() + + await context.logger.log_system( + f"[BEHAVIOR_PROBE] runtime=AgentRuntime scenario={SCENARIO_ID}" + ) + await context.websocket.send_json({"type": "agent_runtime_start", "scenario": SCENARIO_ID}) + + await runtime.run(state, context) + + await context.websocket.send_json({"type": "agent_runtime_end", "scenario": SCENARIO_ID}) + + assistant_message = ( + state.final_answer + or state.brain_response + or context.runtime_turn_assistant_response + or "" + ) + + if RUN_MEMORY_UPDATE_AFTER_EACH_TURN: + schedule_runtime_memory_update( + context=context, + user_message=user_text, + assistant_message=assistant_message, + ) + + if WAIT_FOR_MEMORY_UPDATE_AFTER_EACH_TURN: + await wait_for_runtime_memory_update(context) + + context.assistant_message_count += 1 + context.turn_number += 1 + + return state + + +def build_memory_blob(context: RuntimeContext) -> str: + parts = [] + for field_name in MEMORY_TEXT_FIELDS_TO_INSPECT: + value = getattr(context, field_name, "") + if value: + parts.append(f"[{field_name}]\n{value}") + return "\n\n".join(parts) + + +def evaluate_expected_text(turns: list[TurnResult]) -> dict[str, Any]: + """ + Only checks expected fragments declared in constants. + Empty expected lists produce no checks. + """ + + checks: list[dict[str, Any]] = [] + + for turn in turns: + for fragment in turn.expected_answer: + checks.append( + { + "name": f"turn_{turn.index}.answer_contains", + "target": "answer", + "turn": turn.index, + "fragment": fragment, + "passed": fragment_found(turn.answer, fragment), + } + ) + + for fragment in turn.expected_memory: + checks.append( + { + "name": f"turn_{turn.index}.memory_contains", + "target": "memory", + "turn": turn.index, + "fragment": fragment, + "passed": fragment_found(turn.memory_after_turn, fragment), + } + ) + + for fragment in turn.unexpected_answer: + checks.append( + { + "name": f"turn_{turn.index}.answer_not_contains", + "target": "answer", + "turn": turn.index, + "fragment": fragment, + "passed": not fragment_found(turn.answer, fragment), + } + ) + + for fragment in turn.unexpected_memory: + checks.append( + { + "name": f"turn_{turn.index}.memory_not_contains", + "target": "memory", + "turn": turn.index, + "fragment": fragment, + "passed": not fragment_found(turn.memory_after_turn, fragment), + } + ) + + passed = sum(1 for check in checks if check["passed"]) + total = len(checks) + + return { + "passed": passed, + "total": total, + "ratio": passed / total if total else 1.0, + "checks": checks, + } + + +def print_behavior_probe_report(report: dict[str, Any]) -> None: + score = report["score"] + turns = report["turns"] + + header = f"BEHAVIOR PROBE :: {report['scenario_id']}" + print("\n" + paint("=" * len(header), "cyan", bold=True)) + print(paint(header, "cyan", bold=True)) + print(paint("=" * len(header), "cyan", bold=True)) + + score_color = "green" if score["ratio"] >= 0.85 else "yellow" if score["ratio"] >= 0.60 else "red" + print( + paint("Score: ", bold=True) + + paint(f"{score['passed']}/{score['total']} ({score['ratio']:.0%})", score_color, bold=True) + ) + print(paint(f"Title: {report['scenario_title']}", "gray")) + + print("\n" + paint("DIALOGUE", "blue", bold=True)) + for turn in turns: + print(paint(f"\n--- Turn {turn['index']} ---", "gray", bold=True)) + print(paint("USER:", "cyan", bold=True)) + print(indent_block(turn["user_text"])) + + print(paint("MODEL:", "green", bold=True)) + print(indent_block(clip_text(turn["answer"], MAX_ANSWER_PREVIEW_CHARS))) + + if turn["expected_answer"]: + print(paint("EXPECTED TEXT IN ANSWER:", "yellow", bold=True)) + for fragment in turn["expected_answer"]: + print(f" {status_label(fragment_found(turn['answer'], fragment))} {fragment}") + else: + print(paint("EXPECTED TEXT IN ANSWER: ", "gray", dim=True)) + + if turn["expected_memory"]: + print(paint("EXPECTED TEXT IN MEMORY:", "yellow", bold=True)) + for fragment in turn["expected_memory"]: + print(f" {status_label(fragment_found(turn['memory_after_turn'], fragment))} {fragment}") + else: + print(paint("EXPECTED TEXT IN MEMORY: ", "gray", dim=True)) + + if turn.get("unexpected_answer"): + print(paint("UNEXPECTED TEXT IN ANSWER:", "red", bold=True)) + for fragment in turn["unexpected_answer"]: + print(f" {status_label(not fragment_found(turn['answer'], fragment))} not: {fragment}") + + if turn.get("unexpected_memory"): + print(paint("UNEXPECTED TEXT IN MEMORY:", "red", bold=True)) + for fragment in turn["unexpected_memory"]: + print(f" {status_label(not fragment_found(turn['memory_after_turn'], fragment))} not: {fragment}") + + print("\n" + paint("TEXT CHECKS", "blue", bold=True)) + if not score["checks"]: + print(paint(" No expected fragments declared. This probe only prints dialogue.", "gray", dim=True)) + else: + for check in score["checks"]: + print( + f" {status_label(check['passed'])} " + + ( + f"turn {check['turn']} {check['target']} contains: {check['fragment']}" + if check["name"].endswith("_contains") + else f"turn {check['turn']} {check['target']} does not contain: {check['fragment']}" + ) + ) + + final_memory = clip_text(report.get("final_memory", ""), MAX_MEMORY_PREVIEW_CHARS) + if final_memory: + print("\n" + paint("FINAL MEMORY SNAPSHOT", "blue", bold=True)) + print(indent_block(final_memory)) + + print("\n" + paint("COUNTERS", "blue", bold=True)) + print(f" turns: {report['turn_number']}") + print(f" user messages: {report['user_message_count']}") + print(f" assistant messages: {report['assistant_message_count']}") + print(f" websocket messages: {report['websocket_message_count']}") + print(paint("=" * len(header), "cyan", bold=True) + "\n") + + +# ============================================================================= +# LOCAL SHAPE TESTS. These always run and do not require the model. +# ============================================================================= + + +class BehaviorProbeShapeTests(unittest.TestCase): + def test_collect_dialogue_steps_finds_seed_steps(self): + steps = collect_dialogue_steps() + self.assertGreaterEqual(len(steps), 3) + self.assertIn("необычный фильм", steps[0]["user_text"]) + self.assertIn("High Life", steps[-1]["user_text"]) + + def test_evaluator_uses_only_declared_expected_fragments(self): + turns = [ + TurnResult( + index=1, + user_text="u1", + answer="any answer", + memory_after_turn="any memory", + expected_answer=[], + expected_memory=[], + unexpected_answer=[], + unexpected_memory=[], + ), + TurnResult( + index=2, + user_text="u2", + answer="model mentioned The Rover", + memory_after_turn="memory preserved The Rover", + expected_answer=[], + expected_memory=["The Rover"], + unexpected_answer=[], + unexpected_memory=[], + ), + TurnResult( + index=3, + user_text="u3", + answer="Enjoy High Life.", + memory_after_turn="High Life and The Lighthouse are present.", + expected_answer=["High Life"], + expected_memory=["High Life", "The Lighthouse"], + unexpected_answer=["wrong title"], + unexpected_memory=["user loves arthouse"], + ), + ] + + score = evaluate_expected_text(turns) + self.assertEqual(score["passed"], score["total"]) + + +# ============================================================================= +# LIVE MODEL BEHAVIOR PROBE. Skipped unless explicitly enabled. +# ============================================================================= + + +@unittest.skipUnless( + os.getenv("JIN_RUN_BEHAVIOR_PROBE", "") == "1", + "Set JIN_RUN_BEHAVIOR_PROBE=1 to run the live behavior probe.", +) +class SimpleBehaviorProbe(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.http_client = httpx.AsyncClient() + self.websocket = CapturingWebSocket() + + self.context = RuntimeContext( + websocket=self.websocket, + emitter=RuntimeEmitter(self.websocket), + logger=WebSocketLogger(self.websocket), + clients=build_clients(self.http_client), + ) + + initial_snapshot = build_runtime_memory_snapshot( + self.context, + self.context.runtime_memory, + ) + self.context.runtime_memory_snapshots.append(initial_snapshot) + self.context.runtime_memory_snapshot_index = 0 + + async def asyncTearDown(self): + await wait_for_runtime_memory_update(self.context) + await self.http_client.aclose() + + async def test_simple_behavior_probe(self): + turns: list[TurnResult] = [] + + for step in collect_dialogue_steps(): + state = await run_standard_turn(self.context, step["user_text"]) + answer = ( + state.final_answer + or state.brain_response + or self.context.runtime_turn_assistant_response + or "" + ) + memory_after_turn = build_memory_blob(self.context) + + turns.append( + TurnResult( + index=step["index"], + user_text=step["user_text"], + answer=answer, + memory_after_turn=memory_after_turn, + expected_answer=step["expected_answer"], + expected_memory=step["expected_memory"], + unexpected_answer=step["unexpected_answer"], + unexpected_memory=step["unexpected_memory"], + ) + ) + + score = evaluate_expected_text(turns) + + report = { + "scenario_id": SCENARIO_ID, + "scenario_title": SCENARIO_TITLE, + "scenario_notes": SCENARIO_NOTES, + "score": score, + "turns": [ + { + "index": turn.index, + "user_text": turn.user_text, + "answer": turn.answer, + "memory_after_turn": turn.memory_after_turn, + "expected_answer": turn.expected_answer, + "expected_memory": turn.expected_memory, + "unexpected_answer": turn.unexpected_answer, + "unexpected_memory": turn.unexpected_memory, + } + for turn in turns + ], + "final_memory": build_memory_blob(self.context), + "turn_number": self.context.turn_number, + "user_message_count": self.context.user_message_count, + "assistant_message_count": self.context.assistant_message_count, + "websocket_message_count": len(self.websocket.messages), + } + + if PRINT_WEBSOCKET_MESSAGES: + report["websocket_messages"] = self.websocket.messages + + if PRINT_PRETTY_REPORT: + print_behavior_probe_report(report) + + if PRINT_JSON_REPORT: + print(json.dumps(report, ensure_ascii=False, indent=2)) + + if STRICT_TEXT_ASSERTIONS: + failed = [check for check in score["checks"] if not check["passed"]] + self.assertEqual(failed, [], f"Expected text checks failed: {failed}") + + +if __name__ == "__main__": + unittest.main() From 79769eb5418eed0d7e486d7abccf43b487a635c0 Mon Sep 17 00:00:00 2001 From: makeitdouble Date: Mon, 8 Jun 2026 15:22:58 +0300 Subject: [PATCH 6/6] feature: pheromone memory - fix tests --- tests/test_websocket_pending_usage.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/test_websocket_pending_usage.py b/tests/test_websocket_pending_usage.py index 4cb3432e..f5327a05 100644 --- a/tests/test_websocket_pending_usage.py +++ b/tests/test_websocket_pending_usage.py @@ -293,19 +293,19 @@ async def test_session_bootstrap_restores_browser_memory(self): ) self.assertEqual( len(context.runtime_memory_snapshots), - 2, + 1, ) self.assertEqual( - context.runtime_memory_snapshots[0]["raw_memory"], - "session status: New session", + context.runtime_memory_snapshots[0]["index"], + 0, ) self.assertEqual( - context.runtime_memory_snapshots[1]["raw_memory"], + context.runtime_memory_snapshots[0]["raw_memory"], "topic: restored runtime state", ) self.assertEqual( context.runtime_memory_snapshot_index, - 1, + 0, ) async def test_session_bootstrap_normalizes_restored_snapshot_index(self): @@ -341,19 +341,19 @@ async def test_session_bootstrap_normalizes_restored_snapshot_index(self): ) self.assertEqual( context.runtime_memory_snapshot_index, - 1, + 0, ) self.assertEqual( context.runtime_memory_snapshots[0]["index"], 0, ) self.assertEqual( - context.runtime_memory_snapshots[1]["index"], - 1, + context.runtime_memory_snapshots[0]["raw_memory"], + "topic: restored runtime state", ) self.assertEqual( len(context.runtime_memory_snapshots), - 2, + 1, ) async def test_runtime_session_memory_update_is_not_browser_persisted_by_default(self):