File size: 27,238 Bytes
82f262a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0d5a91
 
82f262a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e233e2d
82f262a
 
 
 
 
e233e2d
82f262a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e233e2d
 
82f262a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e233e2d
 
 
 
 
 
82f262a
e233e2d
 
 
 
 
 
 
 
82f262a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a21993
82f262a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a21993
82f262a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0d5a91
82f262a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
"""
MORPH-AI Runtime v6
Local inference engine with skill management.
Supports CPU, GPU, Apple Silicon, mobile (via GGUF/Termux), AND v6 capabilities:
- Audio: ASR (Whisper) + TTS (Coqui/gTTS)
- Tools: JSON-structured function calling
- Documents: PDF/DOCX/OCR extraction
- Video: Frame sampling + temporal reasoning
- Multimodal: Vision + Audio + Video fusion
"""

import json
import base64
import os
import sys
import torch
import torch.nn.functional as F
from pathlib import Path
from typing import Optional, List, Dict, Union
from dataclasses import dataclass
import numpy as np

_ROOT = Path(__file__).resolve().parent.parent
if str(_ROOT / "src") not in sys.path:
    sys.path.insert(0, str(_ROOT / "src"))

try:
    from llama_cpp import Llama
    HAS_LLAMACPP = True
except Exception:
    Llama = None
    HAS_LLAMACPP = False

from architecture import MorphModel, MorphConfig, build_code_features
from fsm import RuntimeFSM, IllegalTransitionError
from rules import RuleEngine
from regex_features import RegexFeatureExtractor
from routing import RoutingMatrix, deterministic_index
from kvstore import KVStore
from vision import VisionAnalyzer, ImageFacts
from search import SearchClient, RAGPipeline
from facts import FactExtractor, KnowledgeGraph, GraphQuery
from audio import AudioModule, AudioFacts
from document import DocumentModule
from video import VideoModule
from tools import ToolRegistry, ToolCall


@dataclass
class Skill:
    name: str
    token: str
    description: str
    trigger_patterns: List[str]
    lora_weights: Optional[bytes] = None
    examples: List[Dict[str, str]] = None
    installed: bool = False

    def __post_init__(self):
        if self.examples is None:
            self.examples = []

    def to_dict(self) -> dict:
        return {
            "name": self.name,
            "token": self.token,
            "description": self.description,
            "trigger_patterns": self.trigger_patterns,
            "lora_weights": base64.b64encode(self.lora_weights).decode() if self.lora_weights else None,
            "examples": self.examples,
            "installed": self.installed
        }

    @classmethod
    def from_dict(cls, data: dict) -> 'Skill':
        lora_bytes = base64.b64decode(data["lora_weights"]) if data.get("lora_weights") else None
        return cls(
            name=data["name"],
            token=data["token"],
            description=data["description"],
            trigger_patterns=data["trigger_patterns"],
            lora_weights=lora_bytes,
            examples=data.get("examples", []),
            installed=data.get("installed", False)
        )

    def save(self, path: str):
        with open(path, 'w') as f:
            json.dump(self.to_dict(), f, indent=2)

    @classmethod
    def load(cls, path: str) -> 'Skill':
        with open(path, 'r') as f:
            data = json.load(f)
        return cls.from_dict(data)


class MorphRuntime:
    """
    Runtime engine for MORPH-AI v6.
    Handles model loading, inference, skill management, and v6 capabilities:
    Audio (ASR + TTS), Tools, Documents, Video, Multimodal fusion.
    """

    def __init__(self, model_path: str, use_4bit: bool = True, use_cpu: bool = False, plugin_dir: Optional[str] = None):
        self.model_path = Path(model_path)
        self.use_4bit = use_4bit and not use_cpu
        self.use_cpu = use_cpu
        self.skills: Dict[str, Skill] = {}
        self.active_skill: Optional[str] = None
        self.plugin_dir = plugin_dir

        # v5 pipeline layers
        self.fsm = RuntimeFSM()
        self.rules = RuleEngine(str(_ROOT / "rules" / "rules.json"))
        self.regex = RegexFeatureExtractor(num_features=7)
        self.routing = RoutingMatrix(str(_ROOT / "routing" / "routing_matrix.json"))
        self.kv = KVStore(str(_ROOT / "cache" / "kvstore.json"))

        # v5.5 multimodal + live knowledge layers
        self.vision = VisionAnalyzer()
        self.search_client = SearchClient()
        self.rag = RAGPipeline(client=self.search_client, cache=self.kv)
        self.fact_extractor = FactExtractor()
        self.graph = KnowledgeGraph()
        self.gquery = GraphQuery(self.fact_extractor)

        # v6 advanced capabilities
        self.audio = AudioModule(MorphConfig(), self._get_hidden_dim())
        self.document = DocumentModule(MorphConfig(), self._get_hidden_dim())
        self.video = VideoModule(MorphConfig(), self._get_hidden_dim())
        self.tool_registry = ToolRegistry()

        print(f"Loading MORPH-AI model from {model_path}")
        self._load_model()

    def _get_hidden_dim(self) -> int:
        try:
            return self.model.base_model_raw.config.hidden_size
        except Exception:
            return 768

    def _transition(self, next_state: str):
        try:
            self.fsm.transition(next_state)
        except IllegalTransitionError as e:
            self.fsm.fault()
            raise

    # ------------------------------------------------------------------
    # v6 multimodal ingest path
    # GUARD_IN -> VISION (if image) -> AUDIO (if audio) -> VIDEO (if video) ->
    # DOCUMENT (if document) -> SEARCH_GATE -> SEARCH -> FACT_EXTRACT
    # ------------------------------------------------------------------
    _LIVE_NEED_HINTS = ("today", "latest", "current", "news", "price", "weather",
                        "live", "breaking", "who won", "election", "stock", "score")

    def _needs_live_data(self, prompt: str, force_search: bool, depth: int) -> bool:
        if force_search:
            return True
        if depth >= 2:
            return False
        low = prompt.lower()
        return any(h in low for h in self._LIVE_NEED_HINTS)

    def _ingest(self, prompt: str, image_path: Optional[str] = None,
                audio_path: Optional[str] = None, video_path: Optional[str] = None,
                document_path: Optional[str] = None,
                force_search: bool = False, depth: int = 0) -> dict:
        """Run guard-in + (optional) vision + audio + video + document + search/facts.

        Returns a context dict consumed by chat / chat_best_of_n:
          image_facts, audio_facts, video_facts, document_text, search_context, graph_context, enriched.
        """
        # L1 in-bound guardrails
        in_dec = self.rules.eval(prompt, phase="in")
        if in_dec.action == "block":
            self._transition("RESPOND")
            self._transition("IDLE")
            return {"blocked": in_dec.reply or "I can't help with that."}

        syn = self.regex.extract_text(prompt)
        gate = self.regex.gate(syn)
        if gate == "block":
            self._transition("RESPOND")
            self._transition("IDLE")
            return {"blocked": "That input couldn't be processed (unbalanced syntax)."}
        if gate == "warn":
            print(f"warn: syntax state {syn.to_dict()}")

        image_facts: Optional[ImageFacts] = None
        image_text = ""
        audio_facts = None
        audio_text = ""
        video_facts = None
        video_text = ""
        document_text = ""

        # v6: multimodal phases
        if image_path:
            self._transition("VISION")
            try:
                image_facts = self.vision.analyze(image_path)
                image_text = image_facts.to_text()
            except Exception as e:
                print(f"vision failed (non-fatal): {e}")

        if audio_path:
            self._transition("AUDIO")
            try:
                audio_facts = self.audio.transcribe(audio_path)
                audio_text = audio_facts.to_text()
            except Exception as e:
                print(f"audio failed (non-fatal): {e}")

        if video_path:
            self._transition("VIDEO")
            try:
                video_facts = self.video.analyze(video_path)
                video_text = video_facts.to_text()
            except Exception as e:
                print(f"video failed (non-fatal): {e}")

        if document_path:
            self._transition("DOCUMENT")
            try:
                document_text = self.document.extract_text(document_path)
            except Exception as e:
                print(f"document extraction failed (non-fatal): {e}")

        # search decision gate
        self._transition("SEARCH_GATE")
        search_context = ""
        graph_context = ""
        if self._needs_live_data(prompt, force_search, depth):
            self._transition("SEARCH")
            query = prompt[:200]
            try:
                search_context = self.rag.retrieve(query, use_cache=True)
            except Exception as e:
                print(f"search failed (non-fatal): {e}")
            # NER + graph query over live web text + image/audio/video/document text
            self._transition("FACT_EXTRACT")
            try:
                web_facts = self.fact_extractor.triples(search_context) if search_context else []
                for f in web_facts:
                    f.source = "web"
                    self.graph.add_fact(f)
                for text, src in [(image_text, "image"), (audio_text, "audio"),
                                  (video_text, "video"), (document_text, "document")]:
                    if text:
                        for f in self.fact_extractor.triples(text):
                            f.source = src
                            self.graph.add_fact(f)
                graph_context = self.gquery.facts_for_question(self.graph, prompt)
            except Exception as e:
                print(f"fact extraction failed (non-fatal): {e}")

        return {
            "blocked": None,
            "image_facts": image_facts,
            "image_text": image_text,
            "audio_facts": audio_facts,
            "audio_text": audio_text,
            "video_facts": video_facts,
            "video_text": video_text,
            "document_text": document_text,
            "search_context": search_context,
            "graph_context": graph_context,
            "enriched": "\n\n".join(x for x in [image_text, audio_text, video_text, document_text, search_context, graph_context] if x),
        }

    def _build_prompt(self, prompt: str, ctx: dict) -> str:
        if not ctx.get("enriched"):
            return prompt
        return (
            f"[context]\n{ctx['enriched']}\n[/context]\n\n"
            f"Answer using the context above when relevant: {prompt}"
        )

    def _load_model(self):
        config = MorphConfig()
        if self.plugin_dir:
            config.plugin_dir = self.plugin_dir
        self.model = MorphModel(config)

        # base model was loaded bf16 by MorphModel; load the trained adapter
        # + novel components (load_checkpoint wraps base_model_raw in the
        # PEFT adapter itself, so do NOT call apply_lora() here first)
        self.model.load_checkpoint(str(self.model_path))
        self.model.eval()

        if not self.use_cpu and torch.cuda.is_available():
            self.device = torch.device("cuda")
        elif not self.use_cpu and hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
            self.device = torch.device("mps")
        else:
            self.device = torch.device("cpu")

        self.model = self.model.to(self.device)
        
        # Enable extended context if configured
        if hasattr(self.model.base_model_raw, 'config') and hasattr(self.model.base_model_raw.config, 'rope_scaling'):
            if self.model.base_model_raw.config.rope_scaling:
                print(f"Extended context enabled: {self.model.base_model_raw.config.rope_scaling}")
        
        print(f"Model loaded on {self.device}")
        if self.model._plugins:
            print(f"Loaded plugins: {list(self.model._plugins.keys())}")

    def expand_moe_experts(self):
        """Dynamically expand MoE experts based on usage patterns."""
        if hasattr(self.model, 'moe_block') and hasattr(self.model.moe_block, 'prune_and_expand_experts'):
            self.model.moe_block.prune_and_expand_experts()
            print(f"MoE expanded. Total experts: {len(self.model.moe_block.experts)}")

    def chat(self, prompt: str, max_tokens: int = 512, temperature: float = 0.7,
             skill: Optional[str] = None, image_path: Optional[str] = None,
             audio_path: Optional[str] = None, video_path: Optional[str] = None,
             document_path: Optional[str] = None,
             force_search: bool = False, depth: int = 0,
             use_tools: bool = False) -> str:
        self._transition("INTAKE")
        self._transition("GUARD_IN")

        ctx = self._ingest(prompt, image_path=image_path, audio_path=audio_path,
                          video_path=video_path, document_path=document_path,
                          force_search=force_search, depth=depth)
        if ctx.get("blocked"):
            return ctx["blocked"]

        # L2 routing: explicit skill param wins, else routing matrix
        route = self.routing.route(prompt)
        skill_name = skill if (skill and skill in self.skills) else route.skill
        if skill_name and skill_name in self.skills:
            self._transition("ROUTED")
            self.active_skill = skill_name
            skill_obj = self.skills[skill_name]
            system_prompt = skill_obj.system_prompt if hasattr(skill_obj, "system_prompt") and skill_obj.system_prompt else skill_obj.description
            prompt = self._build_prompt(
                f"system\n{system_prompt}\nuser\n{prompt}\nassistant\n",
                ctx,
            )
        else:
            self.active_skill = None
            prompt = self._build_prompt(f"system\nYou are a helpful assistant.\nuser\n{prompt}\nassistant\n", ctx)
        inputs = self.model.tokenizer(prompt, return_tensors="pt", padding=True, truncation=True, max_length=self.model.cfg.max_seq_len)
        input_ids = inputs["input_ids"].to(self.device)
        attention_mask = inputs["attention_mask"].to(self.device)

        skill_idx = None
        if self.active_skill:
            skill_idx = route.index if route.skill == self.active_skill else \
                deterministic_index(self.active_skill, self.model.cfg.num_skill_tokens)

        code_feat = build_code_features(self.model.tokenizer, input_ids.cpu()).to(self.device)

        # v6: build multimodal embeddings
        vision_embeds = None
        audio_embeds = None
        video_embeds = None
        if image_path and ctx.get("image_facts") and ctx["image_facts"].embedding is not None:
            vision_embeds = ctx["image_facts"].embedding.to(self.device)
        if audio_path and ctx.get("audio_facts") and ctx["audio_facts"].embedding is not None:
            audio_embeds = ctx["audio_facts"].embedding.to(self.device)
        if video_path and ctx.get("video_facts") and ctx["video_facts"].embeddings is not None:
            video_embeds = ctx["video_facts"].embeddings.to(self.device)

        self._transition("GEN")
        with torch.no_grad():
            outputs = self.model.generate(
                input_ids=input_ids,
                attention_mask=attention_mask,
                skill_token_id=skill_idx,
                code_feat=code_feat,
                max_new_tokens=max_tokens,
                temperature=temperature,
                vision_embeds=vision_embeds,
                audio_embeds=audio_embeds,
                video_embeds=video_embeds,
            )
        self._transition("VERIFY")

        generated_ids = outputs[0][input_ids.shape[1]:]
        response = self.model.tokenizer.decode(generated_ids, skip_special_tokens=True)

        # v6: tool use
        if use_tools:
            self._transition("TOOL_USE")
            tool_response = self._execute_tools(response, prompt)
            if tool_response:
                response = tool_response

        # L5 out-bound guardrails
        self._transition("GUARD_OUT")
        out_dec = self.rules.eval(response, phase="out")
        if out_dec.action == "block":
            response = out_dec.reply or "That output was blocked for safety."
        elif out_dec.action == "mask" and out_dec.masked is not None:
            response = out_dec.masked

        self._persist_turn()
        self.kv.set("last_turn", {"prompt": prompt, "response": response}, ttl=3600)
        self._transition("RESPOND")
        self._transition("IDLE")
        self.active_skill = None
        return response.strip()

    def chat_best_of_n(self, prompt: str, max_tokens: int = 512, n: int = 4,
                       skill: Optional[str] = None, image_path: Optional[str] = None,
                       audio_path: Optional[str] = None, video_path: Optional[str] = None,
                       document_path: Optional[str] = None,
                       force_search: bool = False, depth: int = 0,
                       cross_examine: bool = True, use_tools: bool = False) -> str:
        """Self-critique decoding: generate n candidates, keep the best-scored one."""
        self._transition("INTAKE")
        self._transition("GUARD_IN")

        ctx = self._ingest(prompt, image_path=image_path, audio_path=audio_path,
                          video_path=video_path, document_path=document_path,
                          force_search=force_search, depth=depth)
        if ctx.get("blocked"):
            return ctx["blocked"]

        route = self.routing.route(prompt)
        skill_name = skill if (skill and skill in self.skills) else route.skill
        if skill_name and skill_name in self.skills:
            self._transition("ROUTED")
            self.active_skill = skill_name
            skill_obj = self.skills[skill_name]
            prompt = f"{skill_obj.token}\n{prompt}"
        else:
            self.active_skill = None

        prompt = self._build_prompt(prompt, ctx)
        inputs = self.model.tokenizer(prompt, return_tensors="pt", padding=True, truncation=True, max_length=self.model.cfg.max_seq_len)
        input_ids = inputs["input_ids"].to(self.device)
        attention_mask = inputs["attention_mask"].to(self.device)

        skill_idx = None
        if self.active_skill:
            skill_idx = route.index if route.skill == self.active_skill else \
                deterministic_index(self.active_skill, self.model.cfg.num_skill_tokens)

        code_feat = build_code_features(self.model.tokenizer, input_ids.cpu()).to(self.device)

        # v6: multimodal embeddings
        vision_embeds = None
        audio_embeds = None
        video_embeds = None
        if image_path and ctx.get("image_facts") and ctx["image_facts"].embedding is not None:
            vision_embeds = ctx["image_facts"].embedding.to(self.device)
        if audio_path and ctx.get("audio_facts") and ctx["audio_facts"].embedding is not None:
            audio_embeds = ctx["audio_facts"].embedding.to(self.device)
        if video_path and ctx.get("video_facts") and ctx["video_facts"].embeddings is not None:
            video_embeds = ctx["video_facts"].embeddings.to(self.device)

        self._transition("GEN")
        with torch.no_grad():
            outputs = self.model.generate_best_of_n(
                input_ids=input_ids,
                attention_mask=attention_mask,
                skill_token_id=skill_idx,
                code_feat=code_feat,
                n=n,
                max_new_tokens=max_tokens,
                accept_threshold=0.6,
                early_exit_margin=0.01,
                vision_embeds=vision_embeds,
                audio_embeds=audio_embeds,
                video_embeds=video_embeds,
            )
        self._transition("VERIFY")

        generated_ids = outputs[0][input_ids.shape[1]:]
        response = self.model.tokenizer.decode(generated_ids, skip_special_tokens=True)

        # cross-examination
        if cross_examine:
            response = self._cross_examine(response, ctx)

        # v6: tool use
        if use_tools:
            self._transition("TOOL_USE")
            tool_response = self._execute_tools(response, prompt)
            if tool_response:
                response = tool_response

        self._transition("GUARD_OUT")
        out_dec = self.rules.eval(response, phase="out")
        if out_dec.action == "block":
            response = out_dec.reply or "That output was blocked for safety."
        elif out_dec.action == "mask" and out_dec.masked is not None:
            response = out_dec.masked

        self._persist_turn()
        self.kv.set("last_turn", {"prompt": prompt, "response": response}, ttl=3600)
        self._transition("RESPOND")
        self._transition("IDLE")
        self.active_skill = None
        return response.strip()

    def _cross_examine(self, response: str, ctx: dict) -> str:
        """Score the best-of-n winner against image + web facts. If entity
        overlap with grounded facts is low and the prompt was fact-dependent,
        append a self-correction note (does not silently fabricate)."""
        ground_truth = (ctx.get("image_text") or "") + " " + (ctx.get("graph_context") or "")
        if not ground_truth.strip():
            return response
        resp_ents = set()
        for kind in ("PERSON", "ORG", "LOCATION", "DATE", "NUMBER"):
            resp_ents |= self.fact_extractor.extract(response).get(kind, set())
        truth_ents = set()
        for kind in ("PERSON", "ORG", "LOCATION", "DATE", "NUMBER"):
            truth_ents |= self.fact_extractor.extract(ground_truth).get(kind, set())
        if not truth_ents:
            return response
        overlap = len(resp_ents & truth_ents) / len(truth_ents)
        out = self.rules.eval(response, phase="out")
        compliant = out.action != "block"
        print(f"cross-examine: entity overlap={overlap:.2f}, compliant={compliant}")
        if overlap < 0.5 and compliant:
            return response + (
                "\n\n[verifier] This answer only weakly overlaps the retrieved "
                "facts; treat details against the cited context above."
            )
        return response

    def _execute_tools(self, response: str, original_prompt: str) -> Optional[str]:
        """Extract and execute tool calls from model response if present."""
        try:
            tool_calls = self.tool_registry.parse_calls(response)
            if not tool_calls:
                return None
            results = []
            for call in tool_calls:
                result = self.tool_registry.execute(call)
                results.append(f"[tool:{call.tool_name}] {result}")
            return "\n".join(results)
        except Exception as e:
            print(f"tool execution failed: {e}")
            return None

    def _persist_turn(self):
        """Write the last refined hidden state into the cross-turn scratchpad
        and persistent memory so later turns can condition on it."""
        refined = getattr(self.model, "_last_refined", None)
        if refined is None:
            return
        try:
            if refined.dim() == 3:
                self.model.scratchpad.write(refined)
                self.model.memory.write(refined)
        except Exception as e:
            print(f"persist_turn failed (non-fatal): {e}")

    def install_skill(self, skill_path: str) -> bool:
        skill = Skill.load(skill_path)
        if skill.name in self.skills:
            print(f"Skill '{skill.name}' already installed")
            return False

        self.skills[skill.name] = skill
        self.routing.load_legacy_skill(skill_path)
        print(f"Skill '{skill.name}' installed: {skill.description}")
        print(f"  Trigger patterns: {', '.join(skill.trigger_patterns)}")
        return True

    def uninstall_skill(self, skill_name: str) -> bool:
        if skill_name in self.skills:
            del self.skills[skill_name]
            if self.active_skill == skill_name:
                self.active_skill = None
            print(f"Skill '{skill_name}' uninstalled")
            return True
        return False

    def list_skills(self) -> List[Dict[str, Union[str, List[str], bool]]]:
        return [
            {
                "name": s.name,
                "description": s.description,
                "token": s.token,
                "patterns": s.trigger_patterns,
                "installed": s.installed
            }
            for s in self.skills.values()
        ]

    def auto_route(self, prompt: str) -> Optional[str]:
        return self.routing.route(prompt).skill

    def chat_auto(self, prompt: str, max_tokens: int = 512, temperature: float = 0.7) -> str:
        skill = self.auto_route(prompt)
        return self.chat(prompt, max_tokens, temperature, skill)

    def create_skill_gguf(self, skill_name: str, output_path: str):
        if not HAS_LLAMACPP:
            raise ImportError("llama-cpp-python not installed. Run: pip install llama-cpp-python")

        skill = self.skills.get(skill_name)
        if not skill or not skill.lora_weights:
            raise ValueError(f"Skill '{skill_name}' not found or has no weights")

        lora_path = f"{output_path}/{skill_name}_lora.bin"
        with open(lora_path, 'wb') as f:
            f.write(skill.lora_weights)

        gguf_model = Llama(
            model_path=str(self.model_path / "model.gguf"),
            lora_path=lora_path,
            n_ctx=self.model.cfg.max_seq_len,
            n_gpu_layers=-1 if torch.cuda.is_available() else 0
        )
        print(f"GGUF model with skill '{skill_name}' created at {output_path}")

    def export_to_gguf(self, output_dir: str):
        if not HAS_LLAMACPP:
            raise ImportError("llama-cpp-python not installed")

        output_path = Path(output_dir)
        output_path.mkdir(parents=True, exist_ok=True)

        print("Converting to GGUF format...")
        import subprocess
        result = subprocess.run([
            "python", "-m", "transformers.convert_save_to_hf",
            "--input_path", str(self.model_path / "base_lora"),
            "--output_path", str(output_path / "hf_model")
        ], capture_output=True, text=True)

        print(f"GGUF export initiated. Use llama.cpp convert script for final conversion.")
        print(f"Output directory: {output_dir}")


def load_model(model_path: str, **kwargs) -> MorphRuntime:
    return MorphRuntime(model_path, **kwargs)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description="MORPH-AI local runtime")
    parser.add_argument("--model", required=True, help="Path to trained model folder")
    parser.add_argument("--no-4bit", action="store_true", help="Disable 4-bit quantization")
    parser.add_argument("--cpu", action="store_true", help="Force CPU inference")
    args = parser.parse_args()

    rt = MorphRuntime(args.model, use_4bit=not args.no_4bit, use_cpu=args.cpu)
    print("\nMORPH-AI ready. Type 'exit' to quit.")
    print(f"Installed skills: {[s.name for s in rt.skills.values()]}")
    while True:
        try:
            prompt = input("\n> ").strip()
        except (EOFError, KeyboardInterrupt):
            break
        if not prompt:
            continue
        if prompt.lower() in ("exit", "quit"):
            break
        print(rt.chat_auto(prompt, max_tokens=512))