<?php

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

class Niwa_AI_Companion_Memory_Manager {
    private const CAPTURE_CATEGORIES = array(
        'identity_name',
        'identity_persona',
        'user_name',
        'founder_address',
        'user_preference',
        'communication_preference',
        'relationship_tone',
        'inside_joke',
        'shared_context',
        'other',
    );
    private const COMPANION_DOC_LIMIT = 1000;

    private $workspace;
    private $session_state;
    private $auth_manager;
    private $openai_client;
    private $model_router;
    private $storage_ready = false;
    private $captured_turn_keys = array();
    /** @var Niwa_AI_Companion_Profile_Promoter|null */
    private $profile_promoter = null;

    public function __construct( $workspace = null, $session_state = null, $auth_manager = null, $openai_client = null, $model_router = null ) {
        $this->workspace     = $workspace;
        $this->session_state = $session_state;
        $this->auth_manager  = $auth_manager;
        $this->openai_client = $openai_client;
        $this->model_router  = $model_router;
    }

    public function set_workspace( $workspace ) {
        $this->workspace = $workspace;
        if ( $this->profile_promoter && method_exists( $this->profile_promoter, 'set_workspace' ) ) {
            $this->profile_promoter->set_workspace( $workspace );
        }
    }

    public function set_session_state( $session_state ) {
        $this->session_state = $session_state;
    }

    public function set_llm_dependencies( $auth_manager = null, $openai_client = null, $model_router = null ) {
        $this->auth_manager  = $auth_manager;
        $this->openai_client = $openai_client;
        $this->model_router  = $model_router;
    }

    public function build_prompt_summary( $session_id = '', $limit = 1800 ) {
        $session_id = trim( (string) $session_id );
        $limit      = max( 400, intval( $limit ) );

        $this->ensure_storage();

        $blocks = array();
        foreach ( array(
            'identity'     => 'Identity',
            'user'         => 'Founder',
            'relationship' => 'Relationship',
        ) as $key => $label ) {
            $content = $this->read_companion_document( $key );
            if ( '' !== $content ) {
                $blocks[] = $label . ":\n" . $this->strip_markdown_title( $content );
            }
        }

        return $this->truncate_block( implode( "\n\n", array_filter( $blocks ) ), min( $limit, self::COMPANION_DOC_LIMIT ) );
    }

    public function capture_fact( $session_id, $turn_id, $category, $fact ) {
        $session_id = trim( (string) $session_id );
        $turn_id    = trim( (string) $turn_id );
        $category   = sanitize_key( (string) $category );
        $fact       = $this->normalize_inline_text( $fact );

        if ( ! in_array( $category, self::CAPTURE_CATEGORIES, true ) ) {
            return array(
                'ok'    => false,
                'error' => 'Invalid companion memory category.',
            );
        }

        if ( '' === $fact ) {
            return array(
                'ok'    => false,
                'error' => 'Companion memory fact is empty.',
            );
        }

        $fact = $this->truncate_block( $fact, 200 );
        $key  = $this->capture_turn_key( $session_id, $turn_id );
        if ( '' !== $key ) {
            if ( isset( $this->captured_turn_keys[ $key ] ) || $this->facts_have_turn_capture( $session_id, $turn_id ) ) {
                $this->captured_turn_keys[ $key ] = true;
                return array(
                    'ok'      => true,
                    'stored'  => false,
                    'reason'  => 'already_captured_for_turn',
                    'message' => 'Companion fact already captured for this turn.',
                );
            }
            $this->captured_turn_keys[ $key ] = true;
        }

        $paths = $this->ensure_storage();
        if ( empty( $paths['facts'] ) ) {
            return array(
                'ok'    => false,
                'error' => 'Companion facts storage is not available.',
            );
        }

        $entry = array(
            'id'         => $this->build_fact_id( $session_id, $turn_id, $category, $fact ),
            'created_at' => time(),
            'created'    => wp_date( 'c' ),
            'session_id' => $session_id,
            'turn_id'    => $turn_id,
            'category'   => $category,
            'fact'       => $fact,
        );

        $line = wp_json_encode( $entry );
        if ( false === $line || '' === $line ) {
            return array(
                'ok'    => false,
                'error' => 'Companion fact could not be encoded.',
            );
        }

        file_put_contents( $paths['facts'], $line . "\n", FILE_APPEND | LOCK_EX );
        $this->promote_facts_to_profile( array( $entry ) );

        return array(
            'ok'     => true,
            'stored' => true,
        );
    }

    public function companion_distillation_tick( $limit = 40 ) {
        $paths = $this->ensure_storage();
        if ( empty( $paths['facts'] ) || empty( $paths['state'] ) ) {
            return array(
                'success' => false,
                'error'   => 'Companion distillation storage is not available.',
            );
        }

        $facts = $this->read_facts_jsonl();
        if ( empty( $facts ) ) {
            return array(
                'success'   => true,
                'processed' => 0,
                'reason'    => 'no_facts',
            );
        }

        $state          = $this->read_distillation_state();
        $distilled_count = isset( $state['distilled_count'] ) ? max( 0, intval( $state['distilled_count'] ) ) : 0;
		$new_facts      = array_slice( $facts, $distilled_count, max( 1, intval( $limit ) ) );

		if ( empty( $new_facts ) ) {
			$profile_backfilled = $this->promote_facts_to_profile( $facts );
			return array(
				'success'   => true,
				'processed' => 0,
				'reason'    => 'already_current',
				'profile_backfilled' => (bool) $profile_backfilled,
			);
		}

        $current = array(
            'identity'     => $this->read_companion_document( 'identity' ),
            'user'         => $this->read_companion_document( 'user' ),
            'relationship' => $this->read_companion_document( 'relationship' ),
        );

        $distilled = $this->generate_distilled_documents( $current, $new_facts );
        if ( empty( $distilled ) ) {
            $distilled = $this->deterministic_distill_documents( $current, $new_facts );
        }

		$this->promote_facts_to_profile( $new_facts );
		foreach ( array( 'identity', 'user', 'relationship' ) as $key ) {
			if ( ! isset( $distilled[ $key ] ) ) {
				continue;
			}
			$this->write_companion_document( $key, $distilled[ $key ] );
		}

		$state['distilled_count'] = min( count( $facts ), $distilled_count + count( $new_facts ) );
        $state['distilled_at']    = time();
        $state['updated']         = wp_date( 'c' );
        $state['last_fact_id']    = isset( $new_facts[ count( $new_facts ) - 1 ]['id'] ) ? (string) $new_facts[ count( $new_facts ) - 1 ]['id'] : '';
        file_put_contents( $paths['state'], wp_json_encode( $state ) . "\n", LOCK_EX );

        return array(
            'success'   => true,
            'processed' => count( $new_facts ),
            'total'     => count( $facts ),
            'distilled_count' => intval( $state['distilled_count'] ),
        );
    }

    public function capture_exchange( $session_id, $user_text, $reply, $context = array() ) {
        $session_id = trim( (string) $session_id );
        if ( '' === $session_id ) {
            return array(
                'success' => false,
                'error'   => 'Session ID je prazan.',
            );
        }

        $this->sync_main_memory();
        $this->sync_session_memory( $session_id, $context );

        $decision = $this->maybe_extract_decision_entry( $session_id, $user_text, $reply );
        if ( ! empty( $decision ) ) {
            $this->append_decision_entry( $decision );
        }

        return array(
            'success'           => true,
            'decision_captured' => ! empty( $decision ),
            'promotion'         => array(
                'profile_updated' => false,
                'reason'          => 'model_tool_driven',
            ),
        );
    }

    public function save_memory( $session_id, $context = array(), $reason = 'manual-save' ) {
        $session_id = trim( (string) $session_id );
        if ( '' === $session_id ) {
            return array(
                'success' => false,
                'error'   => 'Session ID je prazan.',
            );
        }

        $this->sync_main_memory();
        $this->sync_session_memory( $session_id, $context );

        $snapshot = $this->get_session_snapshot( $session_id );
        $summary  = $this->build_diary_summary( $session_id, $snapshot, $context );
        $result   = $this->append_diary_entry( $summary, $reason, $session_id );
        return array(
            'success' => ! empty( $result['success'] ),
            'path'    => isset( $result['path'] ) ? (string) $result['path'] : '',
            'summary' => $summary,
            'promotion' => array(
                'profile_updated' => false,
                'reason'          => 'model_tool_driven',
            ),
        );
    }

    public function record_session_flush( $session_id, $reason, $summary = '', $session = array() ) {
        $session_id = trim( (string) $session_id );
        if ( '' === $session_id ) {
            return array(
                'success' => false,
                'error'   => 'Session ID je prazan.',
            );
        }

        unset( $reason, $summary, $session );

        return array(
            'success' => true,
            'skipped' => 'session_flush_no_longer_writes_companion_raw_memory',
        );
    }

    public function search( $query, $session_id = '', $limit = 5 ) {
        $query      = trim( (string) $query );
        $session_id = trim( (string) $session_id );
        $limit      = max( 1, min( 10, intval( $limit ) ) );
        if ( '' === $query ) {
            return array();
        }

        $this->sync_main_memory();
        if ( '' !== $session_id ) {
            $this->sync_session_memory( $session_id );
        }

        $terms      = $this->tokenize_query( $query );
        $documents  = $this->get_search_documents();
        $candidates = array();

        foreach ( $documents as $document ) {
            if ( ! is_array( $document ) || empty( $document['path'] ) || empty( $document['content'] ) ) {
                continue;
            }

            $lines       = Niwa_AI_Text_Normalizer::split_lines( isset( $document['content'] ) ? $document['content'] : '' );
            $line_count  = count( $lines );
            $window      = 10;
            $overlap     = 3;
            $start_index = 0;

            while ( $start_index < $line_count ) {
                $slice = array_slice( $lines, $start_index, $window );
                $text  = trim( implode( "\n", (array) $slice ) );
                if ( '' !== $text ) {
                    $score = $this->score_window(
                        $query,
                        $terms,
                        $text,
                        isset( $document['source_type'] ) ? (string) $document['source_type'] : ''
                    );
                    if ( $score > 0 ) {
                        $start_line   = $start_index + 1;
                        $end_line     = $start_line + count( $slice ) - 1;
                        $candidates[] = array(
                            'path'        => (string) $document['path'],
                            'start_line'  => $start_line,
                            'end_line'    => $end_line,
                            'text'        => $this->truncate_block( $text, 640 ),
                            'score'       => round( $score, 4 ),
                            'source_type' => isset( $document['source_type'] ) ? (string) $document['source_type'] : '',
                        );
                    }
                }

                if ( $start_index + $window >= $line_count ) {
                    break;
                }

                $start_index += max( 1, $window - $overlap );
            }
        }

        if ( empty( $candidates ) ) {
            return array();
        }

        usort(
            $candidates,
            function ( $left, $right ) {
                return floatval( $right['score'] ) <=> floatval( $left['score'] );
            }
        );

        return array_slice( $candidates, 0, $limit );
    }

    public function read_entry( $path, $from = 1, $lines = 20 ) {
        $path  = ltrim( str_replace( '\\', '/', trim( (string) $path ) ), '/' );
        $from  = max( 1, intval( $from ) );
        $lines = max( 1, min( 120, intval( $lines ) ) );
        if ( '' === $path ) {
            return array(
                'path'  => '',
                'text'  => '',
                'error' => 'Companion memory path je prazan.',
            );
        }

        $abs_path = $this->resolve_rel_path( $path );
        if ( '' === $abs_path || ! file_exists( $abs_path ) || ! is_readable( $abs_path ) ) {
            return array(
                'path'  => $path,
                'text'  => '',
                'error' => 'Companion memory entry nije pronadjen.',
            );
        }

        $read = $this->read_memory_file_contents( $abs_path );
        if ( empty( $read['success'] ) ) {
            return array(
                'path'  => $path,
                'text'  => '',
                'error' => 'Companion memory entry nije citljiv.',
            );
        }

        $raw_lines = Niwa_AI_Text_Normalizer::split_lines( isset( $read['content'] ) ? $read['content'] : '' );

        $slice = array_slice( $raw_lines, $from - 1, $lines );

        return array(
            'path'       => $path,
            'from'       => $from,
            'lines'      => count( $slice ),
            'start_line' => $from,
            'end_line'   => $from + max( 0, count( $slice ) - 1 ),
            'text'       => implode( "\n", $slice ),
        );
    }

    public function recall( $query, $session_id = '', $mode = 'general', $limit = 4 ) {
        $query      = trim( (string) $query );
        $session_id = trim( (string) $session_id );
        $mode       = sanitize_key( (string) $mode );
        $limit      = max( 1, min( 6, intval( $limit ) ) );

        $this->sync_main_memory();
        if ( '' !== $session_id ) {
            $this->sync_session_memory( $session_id );
        }

        $documents = $this->get_search_documents( $session_id, true );
        $matches   = $this->collect_recall_matches( $query, $documents, $mode, $limit );
        $summary   = $this->build_recall_summary( $query, $matches, $mode );

        return array(
            'query'       => $query,
            'mode'        => '' !== $mode ? $mode : 'general',
            'summary'     => $summary,
            'confidence'  => $this->determine_recall_confidence( $matches ),
            'uncertain'   => empty( $matches ) || 'low' === $this->determine_recall_confidence( $matches ),
            'matches'     => $matches,
            'source_hint' => $this->build_recall_source_hint( $matches ),
        );
    }

    public function review_memory( $session_id = '', $scope = 'overview' ) {
        $session_id = trim( (string) $session_id );
        $scope      = sanitize_key( (string) $scope );

        $this->sync_main_memory();
        if ( '' !== $session_id ) {
            $this->sync_session_memory( $session_id );
        }

        $profile   = trim( implode( "\n\n", array_filter( array(
            $this->read_companion_document( 'identity' ),
            $this->read_companion_document( 'user' ),
            $this->read_companion_document( 'relationship' ),
        ) ) ) );
        $session   = $this->read_current_session_projection();
        $diary     = (array) ( $this->workspace && method_exists( $this->workspace, 'get_companion_diary_entries' ) ? $this->workspace->get_companion_diary_entries( 3 ) : array() );
        $decisions = (array) ( $this->workspace && method_exists( $this->workspace, 'get_companion_decision_entries' ) ? $this->workspace->get_companion_decision_entries( 3 ) : array() );
        $facts     = array_slice( $this->read_facts_jsonl( 12 ), -5 );

        $summary_parts = array();
        if ( '' !== $profile ) {
            $summary_parts[] = 'Distilled companion memory is populated with identity, founder, and relationship cues.';
        }
        if ( ! empty( $facts ) ) {
            $summary_parts[] = 'Append-only facts log has ' . count( $facts ) . ' recent captured durable facts.';
        }
        if ( '' !== trim( $session ) ) {
            $summary_parts[] = 'Current-session RAM is synced and available for restart continuity.';
        }
        if ( ! empty( $diary ) ) {
            $summary_parts[] = 'Diary holds ' . count( $diary ) . ' recent narrative entries.';
        }
        if ( ! empty( $decisions ) ) {
            $summary_parts[] = 'Decision log holds ' . count( $decisions ) . ' recent append-only decisions.';
        }
        if ( empty( $summary_parts ) ) {
            $summary_parts[] = 'Companion memory is still sparse; only lightweight baseline memory is available.';
        }

        if ( 'profile' === $scope ) {
            $summary_parts[] = 'Profile scope prioritizes durable relationship memory over session details.';
        } elseif ( 'session' === $scope ) {
            $summary_parts[] = 'Session scope prioritizes restart recap and active execution context.';
        }

        return array(
            'scope'            => '' !== $scope ? $scope : 'overview',
            'summary'          => implode( ' ', $summary_parts ),
            'profile'          => $profile,
            'current_session'  => $this->truncate_block( $session, 900 ),
            'recent_facts'      => $facts,
            'recent_diary'     => $diary,
            'recent_decisions' => $decisions,
        );
    }

    public function review_growth( $session_id = '' ) {
        $session_id = trim( (string) $session_id );
        $overview   = $this->review_memory( $session_id, 'overview' );
        $profile    = $this->workspace && method_exists( $this->workspace, 'get_companion_memory_profile' )
            ? (array) $this->workspace->get_companion_memory_profile()
            : array();

        $growth_points = array();
        $preference_count = 0;
        foreach ( array( 'founder_preferences', 'motivators', 'boundaries', 'current_focus_areas', 'assistant_personality_facets' ) as $key ) {
            $preference_count += isset( $profile[ $key ] ) && is_array( $profile[ $key ] ) ? count( $profile[ $key ] ) : 0;
        }

        if ( $preference_count > 0 ) {
            $growth_points[] = 'Durable relationship profile now tracks ' . $preference_count . ' explicit founder or companion signals.';
        }
        if ( ! empty( $overview['recent_diary'] ) ) {
            $growth_points[] = 'Narrative diary is capturing recent sessions instead of relying only on raw transcript memory.';
        }
        if ( ! empty( $overview['recent_decisions'] ) ) {
            $growth_points[] = 'Decision memory is preserving trade-offs and prior choices for later recall.';
        }

        if ( empty( $growth_points ) ) {
            $growth_points[] = 'Growth history is still shallow; Niwa has not accumulated many durable relationship signals yet.';
        }

        $timeline = array();
        foreach ( array_slice( (array) $overview['recent_diary'], -3 ) as $entry ) {
            if ( ! is_array( $entry ) ) {
                continue;
            }
            $date    = isset( $entry['date'] ) ? trim( (string) $entry['date'] ) : '';
            $summary = isset( $entry['summary'] ) ? $this->truncate_block( (string) $entry['summary'], 160 ) : '';
            if ( '' !== $summary ) {
                $timeline[] = trim( $date . ': ' . $summary, ': ' );
            }
        }

        return array(
            'summary'  => implode( ' ', $growth_points ),
            'timeline' => $timeline,
            'profile'  => $profile,
        );
    }

    private function sync_main_memory() {
        $this->ensure_storage();
	}

    private function sync_session_memory( $session_id, $context = array() ) {
        $paths = $this->ensure_storage();
        if ( empty( $paths['current_session'] ) ) {
            return;
        }

        $snapshot     = $this->get_session_snapshot( $session_id );
        $worklog      = trim( (string) ( isset( $snapshot['worklog_summary'] ) ? $snapshot['worklog_summary'] : '' ) );
        $sticky_notes = isset( $snapshot['sticky_notes'] ) && is_array( $snapshot['sticky_notes'] ) ? array_slice( $snapshot['sticky_notes'], -6 ) : array();
        $recent_turns = isset( $snapshot['recent_turns'] ) && is_array( $snapshot['recent_turns'] ) ? array_slice( $snapshot['recent_turns'], -4 ) : array();
        $session_ram  = isset( $snapshot['session_ram'] ) && is_array( $snapshot['session_ram'] ) ? $snapshot['session_ram'] : array();
        $current      = trim( (string) ( isset( $context['current_prompt'] ) ? $context['current_prompt'] : '' ) );

        $lines = array(
            '# Companion Current Session',
            '',
            '- Session ID: ' . $session_id,
            '- Last Synced: ' . wp_date( 'c' ),
            '',
            '## Active Context',
        );

        if ( '' !== $current ) {
            $lines[] = '- Current prompt: ' . trim( preg_replace( '/\s+/u', ' ', $current ) );
        }
        if ( '' !== $worklog ) {
            $lines[] = '- Worklog summary: ' . trim( preg_replace( '/\s+/u', ' ', $worklog ) );
        }
        if ( ! empty( $session_ram['current_topic'] ) ) {
            $lines[] = '- Current topic: ' . $this->normalize_inline_text( $session_ram['current_topic'] );
        }
        if ( ! empty( $session_ram['user_state'] ) ) {
            $lines[] = '- Founder state: ' . $this->normalize_inline_text( $session_ram['user_state'] );
        }
        if ( empty( $sticky_notes ) ) {
            $lines[] = '- Sticky notes: none';
        } else {
            foreach ( $sticky_notes as $note ) {
                if ( ! is_array( $note ) || empty( $note['text'] ) ) {
                    continue;
                }
                $lines[] = '- Sticky: ' . trim( preg_replace( '/\s+/u', ' ', (string) $note['text'] ) );
            }
        }

        $lines[] = '';
        $lines[] = '## Session RAM';
        $lines   = array_merge( $lines, $this->build_session_ram_projection_lines( $session_ram ) );
        $lines[] = '';
        $lines[] = '## Recent Progress';
        if ( empty( $recent_turns ) ) {
            $lines[] = '- No recent turns captured yet.';
        } else {
            foreach ( $recent_turns as $turn ) {
                if ( ! is_array( $turn ) ) {
                    continue;
                }

                $prompt = isset( $turn['prompt'] ) ? trim( preg_replace( '/\s+/u', ' ', (string) $turn['prompt'] ) ) : '';
                $reply  = isset( $turn['reply'] ) ? trim( preg_replace( '/\s+/u', ' ', (string) $turn['reply'] ) ) : '';
                if ( '' !== $prompt ) {
                    $lines[] = '- Founder: ' . $this->truncate_block( $prompt, 220 );
                }
                if ( '' !== $reply ) {
                    $lines[] = '- Niwa: ' . $this->truncate_block( $reply, 220 );
                }
            }
        }

        $content = Niwa_AI_Text_Normalizer::normalize(
            implode( "\n", $lines ) . "\n",
            array(
                'trim'              => false,
                'preserve_newlines' => true,
            )
        );
        if ( ! $this->should_write_session_projection( $paths['current_session'], $content ) ) {
            return;
        }

        file_put_contents( $paths['current_session'], $content, LOCK_EX );
    }

    private function should_write_session_projection( $path, $content ) {
        $path = (string) $path;
        if ( '' === $path || ! file_exists( $path ) || ! is_readable( $path ) ) {
            return true;
        }

        $read = Niwa_AI_Text_Normalizer::normalize_file_contents( $path, false );
        if ( empty( $read['success'] ) ) {
            return true;
        }

        $existing  = isset( $read['content'] ) ? (string) $read['content'] : '';
        $candidate = (string) $content;
        if ( $existing === $candidate ) {
            return false;
        }

        return $this->normalize_session_projection_for_compare( $existing ) !== $this->normalize_session_projection_for_compare( $candidate );
    }

    private function normalize_session_projection_for_compare( $content ) {
        $content = Niwa_AI_Text_Normalizer::normalize(
            $content,
            array(
                'trim'              => true,
                'preserve_newlines' => true,
            )
        );
        $content = preg_replace( '/^- Last Synced: .+$/m', '- Last Synced: [volatile]', (string) $content );

        return trim( (string) $content );
    }

    private function build_profile_summary_block() {
        if ( ! $this->workspace || ! method_exists( $this->workspace, 'get_companion_memory_profile' ) ) {
            return '';
        }

        $profile = (array) $this->workspace->get_companion_memory_profile();
        if ( empty( $profile ) ) {
            return '';
        }

        $lines = array();
        if ( ! empty( $profile['assistant_name'] ) ) {
            $lines[] = '- Assistant: ' . trim( (string) $profile['assistant_name'] );
        }
        if ( ! empty( $profile['assistant_role'] ) ) {
            $lines[] = '- Role: ' . trim( (string) $profile['assistant_role'] );
        }
        if ( ! empty( $profile['companion_summary'] ) ) {
            $lines[] = '- Summary: ' . $this->truncate_block( (string) $profile['companion_summary'], 260 );
        }
        if ( ! empty( $profile['relationship_style'] ) ) {
            $lines[] = '- Relationship: ' . $this->truncate_block( (string) $profile['relationship_style'], 220 );
        }
        if ( ! empty( $profile['communication_style'] ) ) {
            $lines[] = '- Communication style: ' . $this->truncate_block( (string) $profile['communication_style'], 220 );
        }
        if ( ! empty( $profile['current_focus'] ) ) {
            $lines[] = '- Current focus: ' . $this->truncate_block( (string) $profile['current_focus'], 220 );
        }
        if ( ! empty( $profile['relationship_history_summary'] ) ) {
            $lines[] = '- Relationship history: ' . $this->truncate_block( (string) $profile['relationship_history_summary'], 240 );
        }

        foreach ( array(
            'founder_preferences'       => 'Founder preferences',
            'communication_preferences' => 'Communication preferences',
            'motivators'                => 'Motivators',
            'boundaries'                => 'Boundaries',
            'memory_priorities'         => 'Memory priorities',
            'decision_principles'       => 'Decision principles',
            'current_focus_areas'       => 'Current focus areas',
            'assistant_personality_facets' => 'Assistant facets',
        ) as $key => $label ) {
            $items = isset( $profile[ $key ] ) && is_array( $profile[ $key ] ) ? array_slice( $profile[ $key ], 0, 4 ) : array();
            if ( empty( $items ) ) {
                continue;
            }
            $lines[] = '- ' . $label . ': ' . implode( '; ', array_map( array( $this, 'normalize_inline_text' ), $items ) );
        }

        return $this->truncate_block( implode( "\n", $lines ), 760 );
    }

    private function build_latest_diary_block() {
        if ( ! $this->workspace || ! method_exists( $this->workspace, 'get_companion_diary_entries' ) ) {
            return '';
        }

        $entries = (array) $this->workspace->get_companion_diary_entries( 1 );
        if ( empty( $entries[0] ) || ! is_array( $entries[0] ) ) {
            return '';
        }

        $entry = $entries[0];
        $parts = array();
        if ( ! empty( $entry['date'] ) ) {
            $parts[] = '- Date: ' . trim( (string) $entry['date'] );
        }
        if ( ! empty( $entry['title'] ) ) {
            $parts[] = '- Title: ' . trim( (string) $entry['title'] );
        }
        if ( ! empty( $entry['summary'] ) ) {
            $parts[] = '- Summary: ' . $this->truncate_block( (string) $entry['summary'], 260 );
        }

        return implode( "\n", $parts );
    }

    private function build_latest_decision_block() {
        if ( ! $this->workspace || ! method_exists( $this->workspace, 'get_companion_decision_entries' ) ) {
            return '';
        }

        $entries = (array) $this->workspace->get_companion_decision_entries( 1 );
        if ( empty( $entries[0] ) || ! is_array( $entries[0] ) ) {
            return '';
        }

        $entry = $entries[0];
        $parts = array();
        if ( ! empty( $entry['date'] ) ) {
            $parts[] = '- Date: ' . trim( (string) $entry['date'] );
        }
        if ( ! empty( $entry['title'] ) ) {
            $parts[] = '- Title: ' . trim( (string) $entry['title'] );
        }
        if ( ! empty( $entry['decision'] ) ) {
            $parts[] = '- Decision: ' . $this->truncate_block( (string) $entry['decision'], 260 );
        }
        if ( ! empty( $entry['rationale'] ) ) {
            $parts[] = '- Rationale: ' . $this->truncate_block( (string) $entry['rationale'], 220 );
        }

        return implode( "\n", $parts );
    }

    private function append_diary_entry( $summary, $reason, $session_id ) {
        if ( ! $this->workspace || ! method_exists( $this->workspace, 'append_companion_diary_entry' ) ) {
            return array(
                'success' => false,
                'error'   => 'Companion diary nije dostupan.',
            );
        }

        $title = ucfirst( str_replace( '-', ' ', sanitize_key( (string) $reason ) ) );
        $entry = array(
            'title'      => '' !== $title ? $title : 'Session memory',
            'summary'    => $summary,
            'details'    => 'Session ' . $session_id,
            'tags'       => array( sanitize_key( (string) $reason ), 'session:' . sanitize_key( $session_id ) ),
            'date'       => wp_date( 'Y-m-d' ),
            'created_at' => time(),
        );

        return (array) $this->workspace->append_companion_diary_entry( $entry, true );
    }

    private function append_decision_entry( $entry ) {
        if ( ! $this->workspace || ! method_exists( $this->workspace, 'append_companion_decision_entry' ) ) {
            return array(
                'success' => false,
                'error'   => 'Companion decision log nije dostupan.',
            );
        }

        return (array) $this->workspace->append_companion_decision_entry( $entry, true );
    }

    private function maybe_extract_decision_entry( $session_id, $user_text, $reply ) {
        $user_text = trim( (string) $user_text );
        $reply     = trim( (string) $reply );
        if ( '' === $user_text && '' === $reply ) {
            return array();
        }

        $combined = $this->strtolower( $user_text . "\n" . $reply );
        $signals  = array(
            'let\'s go with',
            'we chose',
            'trade-off',
            'instead of',
            'we will use',
            'odluc',
            'biramo',
            'idemo sa',
            'umesto',
            'necemo',
            'koristicemo',
        );

        $matched = false;
        foreach ( $signals as $signal ) {
            if ( false !== strpos( $combined, $this->strtolower( $signal ) ) ) {
                $matched = true;
                break;
            }
        }

        if ( ! $matched ) {
            return array();
        }

        $decision_text = '' !== $reply ? $reply : $user_text;
        $context_text  = '' !== $user_text ? $user_text : $reply;

        return array(
            'title'      => $this->build_decision_title( $context_text ),
            'context'    => $this->truncate_block( $context_text, 320 ),
            'decision'   => $this->truncate_block( $decision_text, 360 ),
            'rationale'  => $this->truncate_block( $reply, 260 ),
            'tags'       => array( 'session:' . sanitize_key( $session_id ) ),
            'date'       => wp_date( 'Y-m-d' ),
            'created_at' => time(),
        );
    }

    private function build_decision_title( $text ) {
        $text = trim( preg_replace( '/\s+/u', ' ', (string) $text ) );
        if ( '' === $text ) {
            return 'Working decision';
        }

        if ( function_exists( 'mb_substr' ) ) {
            return mb_substr( $text, 0, 72, 'UTF-8' );
        }

        return substr( $text, 0, 72 );
    }

    private function build_diary_summary( $session_id, $snapshot = array(), $context = array() ) {
        $snapshot = is_array( $snapshot ) ? $snapshot : array();
        $parts    = array();
        $worklog  = trim( (string) ( isset( $snapshot['worklog_summary'] ) ? $snapshot['worklog_summary'] : '' ) );
        if ( '' !== $worklog ) {
            $parts[] = 'Worklog: ' . $worklog;
        }

        $session_ram = isset( $snapshot['session_ram'] ) && is_array( $snapshot['session_ram'] ) ? $snapshot['session_ram'] : array();
        if ( ! empty( $session_ram['current_topic'] ) ) {
            $parts[] = 'Topic: ' . $this->normalize_inline_text( $session_ram['current_topic'] );
        }
        foreach ( array(
            'immediate_goals' => 'Immediate goals',
            'recent_progress' => 'Progress',
            'next_steps'      => 'Next steps',
        ) as $key => $label ) {
            $items = isset( $session_ram[ $key ] ) && is_array( $session_ram[ $key ] ) ? array_slice( $session_ram[ $key ], -3 ) : array();
            if ( ! empty( $items ) ) {
                $parts[] = $label . ': ' . implode( '; ', array_map( array( $this, 'normalize_inline_text' ), $items ) );
            }
        }
        if ( ! empty( $session_ram['user_state'] ) ) {
            $parts[] = 'Founder state: ' . $this->normalize_inline_text( $session_ram['user_state'] );
        }

        $notes = isset( $snapshot['sticky_notes'] ) && is_array( $snapshot['sticky_notes'] ) ? array_slice( $snapshot['sticky_notes'], -4 ) : array();
        foreach ( $notes as $note ) {
            if ( is_array( $note ) && ! empty( $note['text'] ) ) {
                $parts[] = 'Sticky note: ' . $this->normalize_inline_text( $note['text'] );
            }
        }

        $turns = isset( $snapshot['recent_turns'] ) && is_array( $snapshot['recent_turns'] ) ? array_slice( $snapshot['recent_turns'], -3 ) : array();
        foreach ( $turns as $turn ) {
            if ( ! is_array( $turn ) ) {
                continue;
            }

            $prompt = isset( $turn['prompt'] ) ? $this->normalize_inline_text( $turn['prompt'] ) : '';
            $reply  = isset( $turn['reply'] ) ? $this->normalize_inline_text( $turn['reply'] ) : '';
            if ( '' !== $prompt ) {
                $parts[] = 'Prompt: ' . $this->truncate_block( $prompt, 180 );
            }
            if ( '' !== $reply ) {
                $parts[] = 'Outcome: ' . $this->truncate_block( $reply, 220 );
            }
        }

        if ( empty( $parts ) && ! empty( $context['current_prompt'] ) ) {
            $parts[] = 'Current prompt: ' . $this->normalize_inline_text( $context['current_prompt'] );
        }

        if ( empty( $parts ) ) {
            $parts[] = 'Session ' . $session_id . ' was synced without a richer recap yet.';
        }

        return $this->truncate_block( implode( ' ', $parts ), 620 );
    }

    private function get_session_snapshot( $session_id ) {
        if ( ! $this->session_state || ! method_exists( $this->session_state, 'get_session_snapshot' ) ) {
            return array();
        }

        return (array) $this->session_state->get_session_snapshot( $session_id );
    }

    private function ensure_storage() {
        $memory_root = $this->get_memory_root_path();
        if ( '' === $memory_root ) {
            return array();
        }

        $root = trailingslashit( $memory_root ) . 'companion';
        wp_mkdir_p( $root );

        $current_session = trailingslashit( $root ) . 'current-session.md';
        if ( ! file_exists( $current_session ) ) {
            file_put_contents( $current_session, "# Companion Current Session\n\n", LOCK_EX );
        }

        $paths = array(
            'root'            => $root,
            'current_session' => $current_session,
            'identity'        => trailingslashit( $root ) . 'identity.md',
            'user'            => trailingslashit( $root ) . 'user.md',
            'relationship'    => trailingslashit( $root ) . 'relationship.md',
            'facts'           => trailingslashit( $root ) . 'facts.jsonl',
            'state'           => trailingslashit( $root ) . 'facts-state.json',
            'profile'         => trailingslashit( $root ) . 'profile.md',
        );

        foreach ( array( 'facts', 'state' ) as $key ) {
            if ( ! file_exists( $paths[ $key ] ) ) {
                file_put_contents( $paths[ $key ], 'facts' === $key ? '' : "{}\n", LOCK_EX );
            }
        }

        if ( ! $this->storage_ready ) {
            $this->migrate_legacy_companion_documents( $paths );
            $this->storage_ready = true;
        }

        return $paths;
    }

    private function migrate_legacy_companion_documents( $paths ) {
        $paths = is_array( $paths ) ? $paths : array();
        if ( empty( $paths['identity'] ) || empty( $paths['user'] ) || empty( $paths['relationship'] ) ) {
            return;
        }

        $profile = $this->workspace && method_exists( $this->workspace, 'get_companion_memory_profile' )
            ? (array) $this->workspace->get_companion_memory_profile()
            : array();

        $legacy_profile = ! empty( $paths['profile'] ) ? $this->read_file( $paths['profile'] ) : '';
        $bootstrap      = array(
            'SOUL.md'     => $this->read_bootstrap_content( 'SOUL.md' ),
            'IDENTITY.md' => $this->read_bootstrap_content( 'IDENTITY.md' ),
            'USER.md'     => $this->read_bootstrap_content( 'USER.md' ),
        );

        if ( ! file_exists( $paths['identity'] ) || '' === trim( $this->read_file( $paths['identity'] ) ) ) {
            $identity = $this->build_seed_identity_document( $profile, $bootstrap, $legacy_profile );
            file_put_contents( $paths['identity'], $identity, LOCK_EX );
        }

        if ( ! file_exists( $paths['user'] ) || '' === trim( $this->read_file( $paths['user'] ) ) ) {
            $user = $this->build_seed_user_document( $profile, $bootstrap, $legacy_profile );
            file_put_contents( $paths['user'], $user, LOCK_EX );
        }

        if ( ! file_exists( $paths['relationship'] ) || '' === trim( $this->read_file( $paths['relationship'] ) ) ) {
            $relationship = $this->build_seed_relationship_document( $profile, $bootstrap, $legacy_profile );
            file_put_contents( $paths['relationship'], $relationship, LOCK_EX );
        }
    }

    private function build_seed_identity_document( $profile, $bootstrap, $legacy_profile ) {
        $lines = array( '# Companion Identity', '' );
        if ( ! empty( $profile['assistant_name'] ) ) {
            $lines[] = '- Name: ' . $this->normalize_inline_text( $profile['assistant_name'] );
        }
        if ( ! empty( $profile['assistant_role'] ) ) {
            $lines[] = '- Role: ' . $this->normalize_inline_text( $profile['assistant_role'] );
        }
        if ( ! empty( $profile['assistant_identity'] ) ) {
            $lines[] = '- Identity: ' . $this->truncate_block( $this->normalize_inline_text( $profile['assistant_identity'] ), 300 );
        } elseif ( ! empty( $bootstrap['IDENTITY.md'] ) ) {
            $lines[] = '- Identity: ' . $this->truncate_block( $this->normalize_inline_text( $bootstrap['IDENTITY.md'] ), 300 );
        }
        if ( ! empty( $profile['companion_summary'] ) ) {
            $lines[] = '- Summary: ' . $this->truncate_block( $this->normalize_inline_text( $profile['companion_summary'] ), 260 );
        } elseif ( ! empty( $bootstrap['SOUL.md'] ) ) {
            $lines[] = '- Summary: ' . $this->truncate_block( $this->normalize_inline_text( $bootstrap['SOUL.md'] ), 260 );
        }
        if ( ! empty( $profile['assistant_personality_facets'] ) && is_array( $profile['assistant_personality_facets'] ) ) {
            $lines[] = '- Facets: ' . implode( '; ', array_map( array( $this, 'normalize_inline_text' ), array_slice( $profile['assistant_personality_facets'], 0, 5 ) ) );
        }

        if ( count( $lines ) <= 2 && '' !== trim( $legacy_profile ) ) {
            $lines[] = '- Legacy profile seed: ' . $this->truncate_block( $this->normalize_inline_text( $legacy_profile ), 360 );
        }

        return $this->format_seed_document( $lines, 'Identity details are still sparse.' );
    }

    private function build_seed_user_document( $profile, $bootstrap, $legacy_profile ) {
        $lines = array( '# Founder', '' );
        if ( ! empty( $profile['founder_name'] ) ) {
            $lines[] = '- Name: ' . $this->normalize_inline_text( $profile['founder_name'] );
        }
        if ( ! empty( $profile['founder_address'] ) ) {
            $lines[] = '- Address: ' . $this->normalize_inline_text( $profile['founder_address'] );
        }
        if ( ! empty( $profile['founder_profile'] ) ) {
            $lines[] = '- Profile: ' . $this->truncate_block( $this->normalize_inline_text( $profile['founder_profile'] ), 300 );
        }
        foreach ( array(
            'founder_preferences'       => 'Preferences',
            'communication_preferences' => 'Communication preferences',
            'memory_priorities'         => 'Memory priorities',
            'current_focus_areas'       => 'Current focus',
        ) as $key => $label ) {
            if ( ! empty( $profile[ $key ] ) && is_array( $profile[ $key ] ) ) {
                $lines[] = '- ' . $label . ': ' . implode( '; ', array_map( array( $this, 'normalize_inline_text' ), array_slice( $profile[ $key ], 0, 5 ) ) );
            }
        }
        if ( ! empty( $profile['communication_style'] ) ) {
            $lines[] = '- Communication style: ' . $this->truncate_block( $this->normalize_inline_text( $profile['communication_style'] ), 260 );
        } elseif ( ! empty( $bootstrap['USER.md'] ) ) {
            $lines[] = '- Legacy user seed: ' . $this->truncate_block( $this->normalize_inline_text( $bootstrap['USER.md'] ), 300 );
        } elseif ( '' !== trim( $legacy_profile ) ) {
            $lines[] = '- Legacy profile seed: ' . $this->truncate_block( $this->normalize_inline_text( $legacy_profile ), 300 );
        }

        return $this->format_seed_document( $lines, 'Founder details are still sparse.' );
    }

    private function build_seed_relationship_document( $profile, $bootstrap, $legacy_profile ) {
        $lines = array( '# Relationship', '' );
        if ( ! empty( $profile['relationship_style'] ) ) {
            $lines[] = '- Style: ' . $this->truncate_block( $this->normalize_inline_text( $profile['relationship_style'] ), 320 );
        }
        if ( ! empty( $profile['relationship_notes'] ) ) {
            $lines[] = '- Notes: ' . $this->truncate_block( $this->normalize_inline_text( $profile['relationship_notes'] ), 300 );
        }
        if ( ! empty( $profile['relationship_history_summary'] ) ) {
            $lines[] = '- History: ' . $this->truncate_block( $this->normalize_inline_text( $profile['relationship_history_summary'] ), 300 );
        }
        foreach ( array(
            'motivators'          => 'Motivators',
            'boundaries'          => 'Boundaries',
            'decision_principles' => 'Decision principles',
            'durable_rules'       => 'Durable rules',
        ) as $key => $label ) {
            if ( ! empty( $profile[ $key ] ) && is_array( $profile[ $key ] ) ) {
                $lines[] = '- ' . $label . ': ' . implode( '; ', array_map( array( $this, 'normalize_inline_text' ), array_slice( $profile[ $key ], 0, 5 ) ) );
            }
        }
        if ( count( $lines ) <= 2 && '' !== trim( $legacy_profile ) ) {
            $lines[] = '- Legacy profile seed: ' . $this->truncate_block( $this->normalize_inline_text( $legacy_profile ), 360 );
        }

        return $this->format_seed_document( $lines, 'Relationship details are still sparse.' );
    }

    private function format_seed_document( $lines, $fallback_line ) {
        $lines = array_values( array_filter( array_map( 'strval', (array) $lines ) ) );
        if ( count( $lines ) <= 1 ) {
            $lines[] = '- ' . $fallback_line;
        }

        return $this->truncate_block( implode( "\n", $lines ), self::COMPANION_DOC_LIMIT ) . "\n";
    }

    private function read_bootstrap_content( $filename ) {
        $filename = trim( (string) $filename );
        if ( '' === $filename || ! $this->workspace || ! method_exists( $this->workspace, 'read_bootstrap_file' ) ) {
            return '';
        }

        $record = (array) $this->workspace->read_bootstrap_file( $filename );
        return ! empty( $record['success'] ) && isset( $record['content'] ) ? (string) $record['content'] : '';
    }

    private function read_companion_document( $key ) {
        $paths = $this->ensure_storage();
        $key   = sanitize_key( (string) $key );
        if ( empty( $paths[ $key ] ) ) {
            return '';
        }

        return $this->read_file( $paths[ $key ] );
    }

    private function write_companion_document( $key, $content ) {
        $paths = $this->ensure_storage();
        $key   = sanitize_key( (string) $key );
        if ( empty( $paths[ $key ] ) ) {
            return false;
        }

        $content = Niwa_AI_Text_Normalizer::normalize(
            $this->truncate_block( (string) $content, self::COMPANION_DOC_LIMIT ) . "\n",
            array(
                'trim'              => false,
                'preserve_newlines' => true,
            )
        );

        return false !== file_put_contents( $paths[ $key ], $content, LOCK_EX );
    }

    private function strip_markdown_title( $content ) {
        $lines = Niwa_AI_Text_Normalizer::split_lines( (string) $content );
        if ( ! empty( $lines[0] ) && preg_match( '/^\s*#\s+/', (string) $lines[0] ) ) {
            array_shift( $lines );
        }

        return trim( implode( "\n", $lines ) );
    }

    private function capture_turn_key( $session_id, $turn_id ) {
        $session_id = trim( (string) $session_id );
        $turn_id    = trim( (string) $turn_id );
        if ( '' === $session_id || '' === $turn_id ) {
            return '';
        }

        return $session_id . '|' . $turn_id;
    }

    private function facts_have_turn_capture( $session_id, $turn_id ) {
        $key = $this->capture_turn_key( $session_id, $turn_id );
        if ( '' === $key ) {
            return false;
        }

        foreach ( array_reverse( $this->read_facts_jsonl( 200 ) ) as $entry ) {
            if ( ! is_array( $entry ) ) {
                continue;
            }
            $entry_key = $this->capture_turn_key(
                isset( $entry['session_id'] ) ? (string) $entry['session_id'] : '',
                isset( $entry['turn_id'] ) ? (string) $entry['turn_id'] : ''
            );
            if ( $key === $entry_key ) {
                return true;
            }
        }

        return false;
    }

    private function build_fact_id( $session_id, $turn_id, $category, $fact ) {
        return substr( sha1( trim( (string) $session_id ) . '|' . trim( (string) $turn_id ) . '|' . sanitize_key( (string) $category ) . '|' . $this->normalize_inline_text( $fact ) ), 0, 20 );
    }

    private function read_facts_jsonl( $tail_limit = 0 ) {
        $paths = $this->ensure_storage();
        if ( empty( $paths['facts'] ) || ! file_exists( $paths['facts'] ) || ! is_readable( $paths['facts'] ) ) {
            return array();
        }

        $lines = file( $paths['facts'], FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
        if ( ! is_array( $lines ) ) {
            return array();
        }
        if ( intval( $tail_limit ) > 0 ) {
            $lines = array_slice( $lines, -intval( $tail_limit ) );
        }

        $facts = array();
        foreach ( $lines as $line ) {
            $decoded = json_decode( (string) $line, true );
            if ( ! is_array( $decoded ) || empty( $decoded['fact'] ) ) {
                continue;
            }
            $decoded['category'] = isset( $decoded['category'] ) ? sanitize_key( (string) $decoded['category'] ) : 'other';
            if ( ! in_array( $decoded['category'], self::CAPTURE_CATEGORIES, true ) ) {
                $decoded['category'] = 'other';
            }
            $decoded['fact'] = $this->truncate_block( $this->normalize_inline_text( $decoded['fact'] ), 200 );
            $facts[] = $decoded;
        }

        return $facts;
    }

    private function read_distillation_state() {
        $paths = $this->ensure_storage();
        if ( empty( $paths['state'] ) || ! file_exists( $paths['state'] ) || ! is_readable( $paths['state'] ) ) {
            return array();
        }

        $decoded = json_decode( $this->read_file( $paths['state'] ), true );
        return is_array( $decoded ) ? $decoded : array();
    }

    private function generate_distilled_documents( $current, $facts ) {
        if ( ! $this->auth_manager || ! $this->openai_client || ! method_exists( $this->openai_client, 'generate' ) ) {
            return array();
        }

        $prompt = implode(
            "\n",
            array(
                '[CURRENT IDENTITY.MD]',
                isset( $current['identity'] ) ? (string) $current['identity'] : '',
                '[/CURRENT IDENTITY.MD]',
                '',
                '[CURRENT USER.MD]',
                isset( $current['user'] ) ? (string) $current['user'] : '',
                '[/CURRENT USER.MD]',
                '',
                '[CURRENT RELATIONSHIP.MD]',
                isset( $current['relationship'] ) ? (string) $current['relationship'] : '',
                '[/CURRENT RELATIONSHIP.MD]',
                '',
                '[NEW FACTS]',
                $this->format_facts_for_prompt( $facts ),
                '[/NEW FACTS]',
            )
        );

        $instructions = implode(
            "\n",
            array(
                'Update Niwa companion memory from the new facts.',
                'Preserve existing durable facts unless a newer fact clearly contradicts them.',
                'identity_markdown is about Niwa: name, persona, mission, chosen identity.',
                'user_markdown is about the founder/admin: name, preferences, address style, durable personal context.',
                'relationship_markdown is about the relationship: tone, inside jokes, shared references, emotional style.',
                'Each markdown value must be under 1000 characters and start with a single # heading.',
                'Do not include technical task progress, website work logs, tool output, or session summaries.',
                'Return minified JSON only with keys identity_markdown, user_markdown, relationship_markdown.',
            )
        );

        $model     = $this->resolve_distillation_model();
        $auth_mode = method_exists( $this->auth_manager, 'get_mode' ) ? $this->auth_manager->get_mode() : 'api_key';
        $creds     = method_exists( $this->auth_manager, 'get_credentials' ) ? $this->auth_manager->get_credentials() : array();
        $result    = $this->openai_client->generate( $model, $prompt, $instructions, $auth_mode, $creds, 220 );
        if ( empty( $result['success'] ) ) {
            return array();
        }

        $answer = isset( $result['answer'] ) ? trim( (string) $result['answer'] ) : '';
        if ( preg_match( '/\{.*\}/s', $answer, $matches ) ) {
            $answer = (string) $matches[0];
        }

        $decoded = json_decode( $answer, true );
        if ( ! is_array( $decoded ) ) {
            return array();
        }

        return array(
            'identity'     => isset( $decoded['identity_markdown'] ) ? (string) $decoded['identity_markdown'] : '',
            'user'         => isset( $decoded['user_markdown'] ) ? (string) $decoded['user_markdown'] : '',
            'relationship' => isset( $decoded['relationship_markdown'] ) ? (string) $decoded['relationship_markdown'] : '',
        );
    }

	private function deterministic_distill_documents( $current, $facts ) {
        $docs = array(
            'identity'     => '' !== trim( (string) ( isset( $current['identity'] ) ? $current['identity'] : '' ) ) ? (string) $current['identity'] : "# Companion Identity\n",
            'user'         => '' !== trim( (string) ( isset( $current['user'] ) ? $current['user'] : '' ) ) ? (string) $current['user'] : "# Founder\n",
            'relationship' => '' !== trim( (string) ( isset( $current['relationship'] ) ? $current['relationship'] : '' ) ) ? (string) $current['relationship'] : "# Relationship\n",
        );

        foreach ( (array) $facts as $entry ) {
            $category = isset( $entry['category'] ) ? sanitize_key( (string) $entry['category'] ) : 'other';
            $fact     = isset( $entry['fact'] ) ? $this->normalize_inline_text( $entry['fact'] ) : '';
            if ( '' === $fact ) {
                continue;
            }

            if ( in_array( $category, array( 'identity_name', 'identity_persona' ), true ) ) {
                $docs['identity'] = $this->append_unique_fact_line( $docs['identity'], $fact );
            } elseif ( in_array( $category, array( 'user_name', 'user_preference' ), true ) ) {
                $docs['user'] = $this->append_unique_fact_line( $docs['user'], $fact );
            } else {
                $docs['relationship'] = $this->append_unique_fact_line( $docs['relationship'], $fact );
            }
        }

        foreach ( $docs as $key => $content ) {
            $docs[ $key ] = $this->truncate_block( $content, self::COMPANION_DOC_LIMIT );
        }

		return $docs;
	}

	private function promote_facts_to_profile( $facts ) {
		$promoter = $this->get_profile_promoter();
		return $promoter ? $promoter->promote( $facts ) : false;
	}

	private function get_profile_promoter() {
		if ( null === $this->profile_promoter && class_exists( 'Niwa_AI_Companion_Profile_Promoter' ) ) {
			$this->profile_promoter = new Niwa_AI_Companion_Profile_Promoter( $this->workspace );
		} elseif ( $this->profile_promoter && method_exists( $this->profile_promoter, 'set_workspace' ) ) {
			$this->profile_promoter->set_workspace( $this->workspace );
		}

		return $this->profile_promoter;
	}

	private function append_unique_fact_line( $content, $fact ) {
        $content = trim( (string) $content );
        $fact    = $this->truncate_block( $this->normalize_inline_text( $fact ), 200 );
        if ( '' === $fact ) {
            return $content;
        }

        $haystack = $this->strtolower( $content );
        if ( false !== strpos( $haystack, $this->strtolower( $fact ) ) ) {
            return $content;
        }

        return trim( $content . "\n- " . $fact );
    }

    private function format_facts_for_prompt( $facts ) {
        $lines = array();
        foreach ( (array) $facts as $entry ) {
            if ( ! is_array( $entry ) || empty( $entry['fact'] ) ) {
                continue;
            }
            $lines[] = '- [' . ( isset( $entry['category'] ) ? sanitize_key( (string) $entry['category'] ) : 'other' ) . '] ' . $this->normalize_inline_text( $entry['fact'] );
        }

        return ! empty( $lines ) ? implode( "\n", $lines ) : '- None.';
    }

    private function resolve_distillation_model() {
        if ( $this->model_router && method_exists( $this->model_router, 'route_for_founder_agent' ) ) {
            $model = (string) $this->model_router->route_for_founder_agent();
            if ( '' !== trim( $model ) ) {
                return $model;
            }
        }
        if ( $this->model_router && method_exists( $this->model_router, 'route_for_agent' ) ) {
            $model = (string) $this->model_router->route_for_agent();
            if ( '' !== trim( $model ) ) {
                return $model;
            }
        }

        return 'gpt-5.4-mini';
    }

    private function get_memory_root_path() {
        if ( $this->workspace && method_exists( $this->workspace, 'get_memory_directory_path' ) ) {
            return (string) $this->workspace->get_memory_directory_path();
        }

        if ( ! $this->workspace || ! method_exists( $this->workspace, 'get_paths' ) ) {
            return '';
        }

        $paths = (array) $this->workspace->get_paths();
        return isset( $paths['memory'] ) ? (string) $paths['memory'] : '';
    }

    private function get_search_documents( $session_id = '', $include_session_exports = false ) {
        $documents = array();

        if ( $this->workspace && method_exists( $this->workspace, 'list_memory_documents' ) ) {
            foreach ( (array) $this->workspace->list_memory_documents( 64 ) as $record ) {
                if ( ! is_array( $record ) || empty( $record['path'] ) || empty( $record['rel_path'] ) ) {
                    continue;
                }

                $content = $this->read_file( (string) $record['path'] );
                if ( '' === $content ) {
                    continue;
                }

                $rel_path = ltrim( str_replace( '\\', '/', (string) $record['rel_path'] ), '/' );
                $documents[] = array(
                    'path'        => $rel_path,
                    'abs_path'    => (string) $record['path'],
                    'content'     => $content,
                    'source_type' => $this->classify_memory_document_source( $rel_path ),
                    'timestamp'   => isset( $record['modified'] ) ? intval( $record['modified'] ) : intval( @filemtime( (string) $record['path'] ) ),
                );
            }
        }

        $paths = $this->ensure_storage();
        if ( ! empty( $paths['facts'] ) && file_exists( $paths['facts'] ) && is_readable( $paths['facts'] ) ) {
            $facts_content = $this->build_facts_search_document_content( $this->read_facts_jsonl( 120 ) );
            if ( '' !== trim( $facts_content ) ) {
                $documents[] = array(
                    'path'        => 'memory/companion/facts.jsonl',
                    'abs_path'    => $paths['facts'],
                    'content'     => $facts_content,
                    'source_type' => 'companion_facts',
                    'timestamp'   => intval( @filemtime( $paths['facts'] ) ),
                );
            }
        }

        if ( $include_session_exports ) {
            $documents = array_merge( $documents, $this->build_session_export_documents( $session_id ) );
        }

        if ( ! empty( $paths['current_session'] ) ) {
            $content = $this->read_file( $paths['current_session'] );
            if ( '' !== $content ) {
                $documents[] = array(
                    'path'        => 'memory/companion/current-session.md',
                    'abs_path'    => $paths['current_session'],
                    'content'     => $content,
                    'source_type' => 'companion_session',
                    'timestamp'   => file_exists( $paths['current_session'] ) ? intval( @filemtime( $paths['current_session'] ) ) : 0,
                );
            }
        }

        return $documents;
    }

    private function build_facts_search_document_content( $facts ) {
        $lines = array( '# Companion Facts', '' );
        foreach ( (array) $facts as $entry ) {
            if ( ! is_array( $entry ) || empty( $entry['fact'] ) ) {
                continue;
            }
            $created  = isset( $entry['created'] ) ? trim( (string) $entry['created'] ) : '';
            $category = isset( $entry['category'] ) ? sanitize_key( (string) $entry['category'] ) : 'other';
            $lines[]  = '- ' . ( '' !== $created ? $created . ' ' : '' ) . '[' . $category . '] ' . $this->normalize_inline_text( $entry['fact'] );
        }

        return count( $lines ) > 2 ? implode( "\n", $lines ) : '';
    }

    private function build_session_ram_projection_lines( $session_ram ) {
        $session_ram = is_array( $session_ram ) ? $session_ram : array();
        $lines       = array();

        $topic = isset( $session_ram['current_topic'] ) ? trim( (string) $session_ram['current_topic'] ) : '';
        if ( '' !== $topic ) {
            $lines[] = '- Topic: ' . $this->normalize_inline_text( $topic );
        }

        foreach ( array(
            'immediate_goals' => 'Immediate goals',
            'recent_progress' => 'Recent progress',
            'next_steps'      => 'Next steps',
        ) as $key => $label ) {
            $items = isset( $session_ram[ $key ] ) && is_array( $session_ram[ $key ] ) ? $session_ram[ $key ] : array();
            if ( empty( $items ) ) {
                continue;
            }
            $lines[] = '- ' . $label . ': ' . implode( '; ', array_map( array( $this, 'normalize_inline_text' ), array_slice( $items, 0, 5 ) ) );
        }

        $restart = isset( $session_ram['restart_recap'] ) ? trim( (string) $session_ram['restart_recap'] ) : '';
        if ( '' !== $restart ) {
            $lines[] = '- Restart recap: ' . $this->truncate_block( $this->normalize_inline_text( $restart ), 260 );
        }

        if ( empty( $lines ) ) {
            $lines[] = '- Structured session RAM is still sparse.';
        }

        return $lines;
    }

    private function classify_memory_document_source( $rel_path ) {
        $rel_path = ltrim( str_replace( '\\', '/', (string) $rel_path ), '/' );

        if ( 'memory/decisions.md' === $rel_path || false !== strpos( $rel_path, '/decisions.md' ) ) {
            return 'decision_log';
        }
        if ( false !== strpos( $rel_path, 'memory/diary/current/' ) || false !== strpos( $rel_path, 'memory/diary/archived/' ) ) {
            return 'session_diary';
        }
        if ( 'memory/companion/profile.md' === $rel_path ) {
            return 'companion_profile';
        }
        if ( in_array( $rel_path, array( 'memory/companion/identity.md', 'memory/companion/user.md', 'memory/companion/relationship.md' ), true ) ) {
            return 'companion_profile';
        }
        if ( 'memory/companion/facts.jsonl' === $rel_path ) {
            return 'companion_facts';
        }
        if ( 'memory/companion/current-session.md' === $rel_path ) {
            return 'companion_session';
        }
        if ( false !== strpos( $rel_path, 'memory/companion/' ) ) {
            return 'companion_document';
        }
        if ( false !== strpos( $rel_path, 'memory/operational/' ) ) {
            return 'operational_memory';
        }
        if ( preg_match( '#^memory/\d{4}-\d{2}-\d{2}\.md$#', $rel_path ) ) {
            return 'daily_memory';
        }

        return 'daily_memory';
    }

    private function build_session_export_documents( $current_session_id = '' ) {
        if (
            ! $this->workspace ||
            ! method_exists( $this->workspace, 'list_session_manifests' ) ||
            ! method_exists( $this->workspace, 'read_session_manifest' ) ||
            ! method_exists( $this->workspace, 'read_session_transcript' ) ||
            ! method_exists( $this->workspace, 'get_session_manifest_path' )
        ) {
            return array();
        }

        $manifests = (array) $this->workspace->list_session_manifests( 16 );
        $selected  = array();
        foreach ( $manifests as $manifest ) {
            if ( ! is_array( $manifest ) || empty( $manifest['session_id'] ) ) {
                continue;
            }
            $session_key = trim( (string) $manifest['session_id'] );
            if ( '' !== $session_key ) {
                $selected[ $session_key ] = $session_key;
            }
        }

        if ( '' !== $current_session_id ) {
            $selected = array( $current_session_id => $current_session_id ) + $selected;
        }

        $documents = array();
        foreach ( array_values( $selected ) as $session_id ) {
            $manifest = (array) $this->workspace->read_session_manifest( $session_id );
            if ( empty( $manifest ) ) {
                continue;
            }

            $records  = (array) $this->workspace->read_session_transcript( $session_id, 60 );
            $content  = $this->build_session_export_document_content( $session_id, $manifest, $records );
            if ( '' === $content ) {
                continue;
            }

            $manifest_path = (string) $this->workspace->get_session_manifest_path( $session_id );
            $documents[]   = array(
                'path'        => 'sessions/' . sanitize_file_name( $session_id ) . '.md',
                'abs_path'    => $manifest_path,
                'content'     => $content,
                'source_type' => 'session_export',
                'timestamp'   => isset( $manifest['updated_at'] ) ? intval( $manifest['updated_at'] ) : 0,
            );
        }

        return $documents;
    }

    private function build_session_export_document_content( $session_id, $manifest, $records ) {
        $manifest = is_array( $manifest ) ? $manifest : array();
        $records  = is_array( $records ) ? $records : array();
        $lines    = array(
            '# Session Export',
            '',
            '- Session ID: ' . $session_id,
        );

        if ( ! empty( $manifest['updated_at'] ) ) {
            $lines[] = '- Updated: ' . wp_date( 'c', intval( $manifest['updated_at'] ) );
        }
        if ( ! empty( $manifest['worklog_summary'] ) ) {
            $lines[] = '- Worklog: ' . $this->normalize_inline_text( $manifest['worklog_summary'] );
        }

        $session_ram = isset( $manifest['session_ram'] ) && is_array( $manifest['session_ram'] ) ? $manifest['session_ram'] : array();
        if ( ! empty( $session_ram ) ) {
            $lines[] = '';
            $lines[] = '## Session RAM';
            $lines   = array_merge( $lines, $this->build_session_ram_projection_lines( $session_ram ) );
        }

        $sticky_notes = isset( $manifest['sticky_notes'] ) && is_array( $manifest['sticky_notes'] ) ? array_slice( $manifest['sticky_notes'], -5 ) : array();
        $lines[]      = '';
        $lines[]      = '## Sticky Notes';
        if ( empty( $sticky_notes ) ) {
            $lines[] = '- None.';
        } else {
            foreach ( $sticky_notes as $note ) {
                if ( is_array( $note ) && ! empty( $note['text'] ) ) {
                    $lines[] = '- ' . $this->normalize_inline_text( $note['text'] );
                }
            }
        }

        $lines[] = '';
        $lines[] = '## Transcript Highlights';
        $highlights = 0;
        foreach ( array_slice( $records, -8 ) as $record ) {
            if ( ! is_array( $record ) ) {
                continue;
            }
            $prompt = isset( $record['prompt'] ) ? trim( (string) $record['prompt'] ) : '';
            $reply  = isset( $record['reply'] ) ? trim( (string) $record['reply'] ) : '';
            if ( '' !== $prompt ) {
                $lines[] = '- Founder: ' . $this->truncate_block( $this->normalize_inline_text( $prompt ), 220 );
                $highlights++;
            }
            if ( '' !== $reply ) {
                $lines[] = '- Niwa: ' . $this->truncate_block( $this->normalize_inline_text( $reply ), 220 );
                $highlights++;
            }
        }

        if ( 0 === $highlights ) {
            $lines[] = '- No transcript highlights stored.';
        }

        return implode( "\n", $lines );
    }

    private function collect_recall_matches( $query, $documents, $mode, $limit ) {
        $documents     = is_array( $documents ) ? $documents : array();
        $mode          = sanitize_key( (string) $mode );
        $generic_query = $this->is_generic_recall_query( $query, $mode );
        $terms         = $generic_query ? array() : $this->tokenize_query( $query );
        $matches       = array();

        foreach ( $documents as $document ) {
            if ( ! is_array( $document ) || empty( $document['content'] ) || empty( $document['path'] ) ) {
                continue;
            }

            $window = $this->find_best_document_window( $query, $terms, $document, $generic_query, $mode );
            if ( empty( $window ) ) {
                continue;
            }

            $matches[] = $window;
        }

        usort(
            $matches,
            function ( $left, $right ) {
                $score_compare = floatval( $right['score'] ) <=> floatval( $left['score'] );
                if ( 0 !== $score_compare ) {
                    return $score_compare;
                }

                return intval( isset( $right['timestamp'] ) ? $right['timestamp'] : 0 ) <=> intval( isset( $left['timestamp'] ) ? $left['timestamp'] : 0 );
            }
        );

        $selected = array();
        $seen     = array();
        foreach ( $matches as $match ) {
            $key = (string) $match['source_type'];
            if ( isset( $seen[ $key ] ) ) {
                continue;
            }
            $seen[ $key ] = true;
            $selected[]   = $match;
            if ( count( $selected ) >= $limit ) {
                break;
            }
        }

        if ( empty( $selected ) ) {
            return array();
        }

        return array_values( $selected );
    }

    private function find_best_document_window( $query, $terms, $document, $generic_query, $mode ) {
        $lines      = Niwa_AI_Text_Normalizer::split_lines( isset( $document['content'] ) ? $document['content'] : '' );
        $line_count = count( $lines );
        if ( $line_count <= 0 ) {
            return array();
        }

        $best      = array();
        $window    = $generic_query ? 16 : 12;
        $overlap   = 4;
        $index     = 0;
        $source    = isset( $document['source_type'] ) ? (string) $document['source_type'] : '';
        $priority  = $this->get_source_priority_boost( $source, $mode );
        $timestamp = isset( $document['timestamp'] ) ? intval( $document['timestamp'] ) : 0;

        while ( $index < $line_count ) {
            $slice = array_slice( $lines, $index, $window );
            $text  = trim( implode( "\n", (array) $slice ) );
            if ( '' !== $text ) {
                $score = $generic_query
                    ? $priority + ( 0.08 * min( 3, substr_count( $text, "\n## " ) + substr_count( $text, "\n### " ) ) )
                    : $this->score_window( $query, $terms, $text, $source ) + $priority;
                if ( $score > 0 && ( empty( $best ) || $score > floatval( $best['score'] ) ) ) {
                    $start_line = $index + 1;
                    $best       = array(
                        'path'        => (string) $document['path'],
                        'source_type' => $source,
                        'timestamp'   => $timestamp,
                        'start_line'  => $start_line,
                        'end_line'    => $start_line + count( $slice ) - 1,
                        'text'        => $this->truncate_block( $text, 520 ),
                        'score'       => round( $score, 4 ),
                    );
                }
            }

            if ( $index + $window >= $line_count ) {
                break;
            }
            $index += max( 1, $window - $overlap );
        }

        return $best;
    }

    private function build_recall_summary( $query, $matches, $mode ) {
        $matches = is_array( $matches ) ? $matches : array();
        $mode    = sanitize_key( (string) $mode );
        $query   = trim( (string) $query );

        if ( empty( $matches ) ) {
            if ( '' === $query ) {
                return 'Nemam dovoljno jak companion trag za pouzdan recall jos uvek. Vidim samo opsti profil i trenutni session recap.';
            }

            return 'Nemam dovoljno siguran memory match za "' . $query . '". Mogu da se oslonim samo na visoki nivo profila ili recent session context, bez jacih diary ili decision tragova.';
        }

        $sentences = array();
        foreach ( $matches as $index => $match ) {
            $lead = 0 === $index ? 'Najjaci trag' : 'Drugi trag';
            if ( $index >= 2 ) {
                $lead = 'Dodatni trag';
            }
            $sentences[] = $lead . ' dolazi iz ' . $this->label_source_type( isset( $match['source_type'] ) ? $match['source_type'] : '' ) . ': ' . $this->flatten_excerpt_for_summary( isset( $match['text'] ) ? $match['text'] : '' );
        }

        if ( 'about_founder' === $mode ) {
            array_unshift( $sentences, 'Ovo je ono sto trenutno pamtim o founderu na osnovu durable profila i recent memory slojeva.' );
        } elseif ( 'decision' === $mode ) {
            array_unshift( $sentences, 'Ovo je najpouzdaniji recall prethodne odluke i njenog konteksta.' );
        } elseif ( 'event' === $mode ) {
            array_unshift( $sentences, 'Ovo je najjaci trag koji imam za trazeni raniji trenutak.' );
        } else {
            array_unshift( $sentences, 'Pronasla sam nekoliko relevantnih memory tragova kroz companion profile, diary i session slojeve.' );
        }

        return implode( ' ', array_slice( $sentences, 0, 4 ) );
    }

    private function determine_recall_confidence( $matches ) {
        $matches = is_array( $matches ) ? $matches : array();
        if ( empty( $matches ) ) {
            return 'low';
        }

        $top_score = isset( $matches[0]['score'] ) ? floatval( $matches[0]['score'] ) : 0.0;
        if ( $top_score >= 1.35 ) {
            return 'high';
        }
        if ( $top_score >= 0.85 ) {
            return 'medium';
        }

        return 'low';
    }

    private function build_recall_source_hint( $matches ) {
        $matches = is_array( $matches ) ? $matches : array();
        if ( empty( $matches ) ) {
            return 'No strong diary, decision, daily-memory, session-export, or bootstrap match was found.';
        }

        $labels = array();
        foreach ( $matches as $match ) {
            $labels[] = $this->label_source_type( isset( $match['source_type'] ) ? $match['source_type'] : '' );
        }

        $labels = array_values( array_unique( array_filter( $labels ) ) );
        return 'Sources used: ' . implode( ', ', $labels ) . '.';
    }

    private function is_generic_recall_query( $query, $mode ) {
        $mode  = sanitize_key( (string) $mode );
        $query = $this->strtolower( trim( (string) $query ) );
        if ( in_array( $mode, array( 'about_founder', 'overview' ), true ) ) {
            return true;
        }
        if ( '' === $query ) {
            return true;
        }

        $generic_queries = array(
            'what do you remember about me',
            'sta pamtis o meni',
            'sta znas o meni',
            'review memory',
            'review growth',
        );

        foreach ( $generic_queries as $candidate ) {
            if ( false !== strpos( $query, $candidate ) ) {
                return true;
            }
        }

        return false;
    }

    private function get_source_priority_boost( $source_type, $mode = 'general' ) {
        $source_type = sanitize_key( (string) $source_type );
        $mode        = sanitize_key( (string) $mode );
        $base = array(
            'session_diary'      => 0.45,
            'decision_log'       => 0.40,
            'daily_memory'       => 0.26,
            'session_export'     => 0.20,
            'companion_profile'  => 0.18,
            'companion_facts'     => 0.18,
            'companion_session'  => 0.16,
            'companion_document' => 0.14,
            'bootstrap'          => 0.10,
        );
        $boost = isset( $base[ $source_type ] ) ? floatval( $base[ $source_type ] ) : 0.0;

        if ( 'about_founder' === $mode && in_array( $source_type, array( 'companion_profile', 'bootstrap', 'session_diary' ), true ) ) {
            $boost += 0.16;
        } elseif ( 'decision' === $mode && 'decision_log' === $source_type ) {
            $boost += 0.22;
        } elseif ( 'event' === $mode && in_array( $source_type, array( 'session_diary', 'session_export' ), true ) ) {
            $boost += 0.14;
        }

        return $boost;
    }

    private function label_source_type( $source_type ) {
        switch ( sanitize_key( (string) $source_type ) ) {
            case 'session_diary':
                return 'session diary';
            case 'decision_log':
                return 'decision log';
            case 'daily_memory':
                return 'daily memory';
            case 'session_export':
                return 'session export';
            case 'companion_profile':
                return 'companion profile';
            case 'companion_facts':
                return 'companion facts';
            case 'companion_session':
                return 'current-session recap';
            case 'bootstrap':
                return 'bootstrap memory';
            default:
                return 'companion memory';
        }
    }

    private function flatten_excerpt_for_summary( $text ) {
        $text = $this->normalize_inline_text( $text );
        $text = preg_replace( '/\s*#{1,6}\s*/u', '', (string) $text );
        $text = preg_replace( '/\s*-\s*/u', ' ', (string) $text );
        return $this->truncate_block( trim( (string) $text ), 200 );
    }

    private function read_current_session_projection() {
        $paths = $this->ensure_storage();
        if ( empty( $paths['current_session'] ) ) {
            return '';
        }

        return $this->read_file( $paths['current_session'] );
    }

    private function read_memory_file_contents( $path ) {
        $path = (string) $path;
        if ( '' === trim( $path ) || ! file_exists( $path ) || ! is_readable( $path ) ) {
            return array(
                'success' => false,
                'content' => '',
            );
        }

        $raw = file_get_contents( $path );
        if ( false === $raw ) {
            return array(
                'success' => false,
                'content' => '',
            );
        }

        $repair = false !== strpos( $raw, "\0" ) || ! Niwa_AI_Text_Normalizer::is_valid_utf8( $raw );
        return Niwa_AI_Text_Normalizer::normalize_file_contents( $path, $repair );
    }

    private function read_file( $path ) {
        if ( '' === trim( (string) $path ) || ! file_exists( $path ) || ! is_readable( $path ) ) {
            return '';
        }

        $read = $this->read_memory_file_contents( $path );
        if ( empty( $read['success'] ) ) {
            return '';
        }

        return isset( $read['content'] ) ? (string) $read['content'] : '';
    }

    private function resolve_rel_path( $rel_path ) {
        $rel_path = ltrim( str_replace( '\\', '/', (string) $rel_path ), '/' );
        if ( '' === $rel_path ) {
            return '';
        }

        $paths = $this->ensure_storage();
        if ( 'memory/companion/current-session.md' === $rel_path && ! empty( $paths['current_session'] ) ) {
            return (string) $paths['current_session'];
        }

        if ( ! $this->workspace || ! method_exists( $this->workspace, 'get_paths' ) ) {
            return '';
        }

        $workspace_paths = (array) $this->workspace->get_paths();
        $root            = isset( $workspace_paths['root'] ) ? (string) $workspace_paths['root'] : '';
        if ( '' === $root ) {
            return '';
        }

        $candidate = trailingslashit( $root ) . str_replace( '/', DIRECTORY_SEPARATOR, $rel_path );
        $real_root = realpath( $root );
        $real      = file_exists( $candidate ) ? realpath( $candidate ) : false;
        if ( false !== $real && false !== $real_root && 0 !== strpos( $real, $real_root ) ) {
            return '';
        }

        return false !== $real ? $real : $candidate;
    }

    private function score_window( $query, $terms, $text, $source_type ) {
        $text = $this->strtolower( Niwa_AI_Text_Normalizer::normalize_inline( $text ) );
        if ( '' === $text ) {
            return 0.0;
        }

        $hits = 0;
        foreach ( (array) $terms as $term ) {
            if ( '' !== $term && false !== strpos( $text, $term ) ) {
                $hits++;
            }
        }

        if ( $hits <= 0 ) {
            return 0.0;
        }

        $score = $hits / max( 1, count( (array) $terms ) );
        if ( false !== strpos( $text, $this->strtolower( (string) $query ) ) ) {
            $score += 0.35;
        }

        if ( in_array( $source_type, array( 'session_diary', 'decision_log' ), true ) ) {
            $score += 0.24;
        } elseif ( 'companion_document' === $source_type || 'companion_session' === $source_type || 'companion_profile' === $source_type || 'companion_facts' === $source_type ) {
            $score += 0.18;
        } elseif ( 'session_export' === $source_type ) {
            $score += 0.12;
        } elseif ( 'bootstrap' === $source_type ) {
            $score += 0.08;
        }

        return $score;
    }

    private function tokenize_query( $query ) {
        $query = $this->strtolower( Niwa_AI_Text_Normalizer::normalize_inline( $query ) );
        $query = preg_replace( '/[^\p{L}\p{N}\s]+/u', ' ', $query );
        $parts = preg_split( '/\s+/u', trim( (string) $query ) );
        $parts = is_array( $parts ) ? $parts : array();

        $tokens = array();
        foreach ( $parts as $part ) {
            $part = trim( (string) $part );
            if ( '' === $part ) {
                continue;
            }
            if ( function_exists( 'mb_strlen' ) ) {
                if ( mb_strlen( $part, 'UTF-8' ) < 3 ) {
                    continue;
                }
            } elseif ( strlen( $part ) < 3 ) {
                continue;
            }
            $tokens[] = $part;
        }

        return array_values( array_unique( $tokens ) );
    }

    private function truncate_block( $text, $limit = 320 ) {
        if ( class_exists( 'Niwa_AI_Companion_Memory_Text' ) ) {
            return Niwa_AI_Companion_Memory_Text::truncate_with_ellipsis( $text, $limit, 120 );
        }

        $text  = trim( (string) $text );
        $limit = max( 120, intval( $limit ) );
        if ( '' === $text ) {
            return '';
        }

        if ( function_exists( 'mb_strlen' ) ) {
            if ( mb_strlen( $text, 'UTF-8' ) <= $limit ) {
                return $text;
            }

            return rtrim( mb_substr( $text, 0, $limit - 3, 'UTF-8' ) ) . '...';
        }

        if ( strlen( $text ) <= $limit ) {
            return $text;
        }

        return rtrim( substr( $text, 0, $limit - 3 ) ) . '...';
    }

    private function normalize_inline_text( $text ) {
        if ( class_exists( 'Niwa_AI_Companion_Memory_Text' ) ) {
            return Niwa_AI_Companion_Memory_Text::normalize_inline( $text );
        }

        return Niwa_AI_Text_Normalizer::normalize_inline( $text );
    }

    private function strtolower( $text ) {
        return function_exists( 'mb_strtolower' )
            ? mb_strtolower( (string) $text, 'UTF-8' )
            : strtolower( (string) $text );
    }
}
