Download src/pcm/planner/chat_cli.py from Dermitio/Planner-Cache: direct link, hf CLI and curl.
- Browser
- Download file 21.7 kB
-
https://huggingface.co/Dermitio/Planner-Cache/resolve/main/src/pcm/planner/chat_cli.py
- Command line
-
hf download hf://Dermitio/Planner-Cache/src/pcm/planner/chat_cli.py
-
curl -L -o chat_cli.py https://huggingface.co/Dermitio/Planner-Cache/resolve/main/src/pcm/planner/chat_cli.py
21.7 kB
| """Shared Planner Cache chat session and terminal entry point.""" | |
| from __future__ import annotations | |
| import argparse | |
| from dataclasses import asdict | |
| import json | |
| from pathlib import Path | |
| import sys | |
| import traceback | |
| import torch | |
| from pcm.planner.canonical import CANONICAL_P_PROTOCOL, CanonicalPStore | |
| from pcm.planner.compatibility import CompatibilityKind, resolve_compatibility | |
| from pcm.planner.interactive_runtimes import ( | |
| GenerationResult, | |
| GemmaInteractiveRuntime, | |
| PythiaInteractiveRuntime, | |
| canonical_route, | |
| ) | |
| from pcm.planner.interactive_session import ( | |
| CanonicalStateManager, | |
| PersonalityManager, | |
| SessionRecorder, | |
| git_commit, | |
| utc_now, | |
| ) | |
| from pcm.planner.memory_review import PostTurnMemoryReviewer | |
| from pcm.planner.representation import train_and_probe_representation | |
| HELP = """Chat normally by typing any message. | |
| Commands: | |
| /help show this help | |
| /state show active canonical P-cache entries | |
| /personality show promoted personality entries and the last retrieval | |
| /events show the most recent Planner Cache events | |
| /save checkpoint P-cache and P-package state | |
| /quit save and exit | |
| Memory extraction is automatic. These explicit forms are also recognized: | |
| The silver key belongs to Alice | |
| Alice owns the silver key | |
| The silver key is currently in Paris | |
| The current status of the silver key is garden | |
| remember: silver key.owner=Alice | |
| invalidate: silver key.owner | |
| Pythia consumes accepted state through its TTL. Gemma uses an LTL for accepted | |
| lexical values. Rejected routes remain inert and conversation continues normally. | |
| """ | |
| def parser() -> argparse.ArgumentParser: | |
| root = Path(__file__).resolve().parents[3] | |
| result = argparse.ArgumentParser(description="Planner Cache interactive terminal") | |
| result.add_argument("runtime", choices=("pythia", "gemma")) | |
| result.add_argument("--repo-root", type=Path, default=root) | |
| result.add_argument("--model", type=Path, required=True) | |
| result.add_argument("--adapter", type=Path, required=True) | |
| result.add_argument("--router", type=Path, required=True) | |
| result.add_argument("--llama-cpp-dir", type=Path) | |
| result.add_argument("--tokenizer-bundle", type=Path) | |
| result.add_argument("--ppkg", type=Path) | |
| result.add_argument("--session-root", type=Path, required=True) | |
| result.add_argument("--p-cache", type=Path) | |
| result.add_argument("--slots", type=int, default=128) | |
| result.add_argument("--context-tokens", type=int) | |
| result.add_argument("--max-new-tokens", type=int, default=96) | |
| result.add_argument("--temperature", type=float, default=0.7) | |
| result.add_argument("--top-p", type=float, default=0.9) | |
| result.add_argument("--seed", type=int, default=1234) | |
| result.add_argument("--gpu-layers", type=int, default=12) | |
| result.add_argument("--threads", type=int, default=8) | |
| result.add_argument("--llama-pid-file", type=Path) | |
| result.add_argument("--review-model", type=Path) | |
| result.add_argument("--review-llama-cpp-dir", type=Path) | |
| result.add_argument("--review-gpu-layers", type=int, default=0) | |
| result.add_argument("--review-pid-file", type=Path) | |
| result.add_argument("--no-logging", action="store_true") | |
| return result | |
| def require_file(path: Path, label: str) -> Path: | |
| if not path.is_file(): | |
| raise FileNotFoundError(f"{label} not found: {path}") | |
| return path | |
| def validate_args(args: argparse.Namespace) -> None: | |
| if args.runtime == "pythia": | |
| if not args.model.is_dir(): | |
| raise FileNotFoundError(f"pythia model not found: {args.model}") | |
| review_model = getattr(args, "review_model", None) | |
| review_llama_cpp_dir = getattr(args, "review_llama_cpp_dir", None) | |
| if review_model is not None: | |
| require_file(review_model, "structured review model") | |
| if review_llama_cpp_dir is None: | |
| raise ValueError( | |
| "--review-llama-cpp-dir is required with --review-model" | |
| ) | |
| require_file( | |
| review_llama_cpp_dir / "build/bin/llama-server", | |
| "review llama-server", | |
| ) | |
| else: | |
| require_file(args.model, "gemma model") | |
| expected = ".ttl" if args.runtime == "pythia" else ".ltl" | |
| require_file(args.adapter, f"{expected} compatibility artifact") | |
| if args.adapter.suffix != expected: | |
| raise ValueError(f"{args.runtime} requires a {expected} compatibility artifact") | |
| resolution = resolve_compatibility(args.adapter) | |
| expected_kind = ( | |
| CompatibilityKind.TTL if args.runtime == "pythia" else CompatibilityKind.LTL | |
| ) | |
| if resolution.kind is not expected_kind: | |
| raise ValueError( | |
| f"{args.runtime} cannot use {resolution.kind.value} compatibility" | |
| ) | |
| require_file(args.router, ".router artifact") | |
| if args.p_cache is not None: | |
| require_file(args.p_cache, "P-cache snapshot") | |
| if args.runtime == "gemma": | |
| if args.llama_cpp_dir is None: | |
| raise ValueError("--llama-cpp-dir is required for Gemma") | |
| if args.tokenizer_bundle is None or not args.tokenizer_bundle.is_dir(): | |
| raise FileNotFoundError("--tokenizer-bundle is required for Gemma") | |
| require_file(args.llama_cpp_dir / "build/bin/llama-server", "llama-server") | |
| require_file(args.llama_cpp_dir / "build/bin/llama-cli", "llama-cli") | |
| def build_runtime(args: argparse.Namespace): | |
| common = { | |
| "model_path": args.model, | |
| "adapter_path": args.adapter, | |
| "router_path": args.router, | |
| "max_new_tokens": args.max_new_tokens, | |
| "temperature": args.temperature, | |
| "top_p": args.top_p, | |
| "seed": args.seed, | |
| } | |
| if args.runtime == "pythia": | |
| return PythiaInteractiveRuntime( | |
| **common, | |
| max_context_tokens=args.context_tokens or 1024, | |
| review_model_path=getattr(args, "review_model", None), | |
| review_llama_cpp_dir=getattr(args, "review_llama_cpp_dir", None), | |
| review_gpu_layers=getattr(args, "review_gpu_layers", 0), | |
| review_pid_file=getattr(args, "review_pid_file", None), | |
| ) | |
| return GemmaInteractiveRuntime( | |
| **common, | |
| llama_cpp_dir=args.llama_cpp_dir, | |
| tokenizer_bundle=args.tokenizer_bundle, | |
| max_context_tokens=args.context_tokens or 4096, | |
| gpu_layers=args.gpu_layers, | |
| threads=args.threads, | |
| pid_file=args.llama_pid_file, | |
| ) | |
| def display_json(value: object) -> None: | |
| print(json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False)) | |
| def save_session_state( | |
| recorder: SessionRecorder, | |
| state: CanonicalStateManager, | |
| personality: PersonalityManager | None, | |
| *, | |
| reason: str, | |
| ) -> None: | |
| snapshot = recorder.directory / "p-cache.safetensors" | |
| if recorder.enabled: | |
| state.store.save(snapshot) | |
| state.save_runtime_metadata(recorder.directory / "p-cache-runtime.json") | |
| checksum = personality.checkpoint() if personality is not None else None | |
| recorder.save_event( | |
| reason, | |
| p_cache_snapshot=str(snapshot) if recorder.enabled else None, | |
| ppkg_checksum=checksum, | |
| ) | |
| def final_payload( | |
| state: CanonicalStateManager, | |
| personality: PersonalityManager | None, | |
| ) -> dict[str, object]: | |
| personality_page = ( | |
| { | |
| "entries": [], "total_active": 0, "returned": 0, | |
| "limit": 100, "offset": 0, "truncated": False, | |
| } | |
| if personality is None | |
| else personality.visible_entry_page(limit=100) | |
| ) | |
| return { | |
| "canonical_p_protocol": CANONICAL_P_PROTOCOL, | |
| "active_p_cache_entries": state.snapshot(), | |
| "ppkg_path": None if personality is None else str(personality.path), | |
| "ppkg_mutations": [] if personality is None else personality.mutations, | |
| "promoted_personality_entries": personality_page.pop("entries"), | |
| "promoted_personality_page": personality_page, | |
| } | |
| class PlannerChatSession: | |
| """One active Planner Cache session shared by terminal and web front ends.""" | |
| def __init__(self, args: argparse.Namespace) -> None: | |
| validate_args(args) | |
| self.args = args | |
| representation, _config, probe = train_and_probe_representation() | |
| store = ( | |
| CanonicalPStore.load(args.p_cache, dtype=torch.float32) | |
| if args.p_cache is not None else None | |
| ) | |
| self.state = CanonicalStateManager(representation, slots=args.slots, store=store) | |
| if args.p_cache is not None: | |
| self.state.load_runtime_metadata( | |
| args.p_cache.with_name("p-cache-runtime.json") | |
| ) | |
| self.personality = ( | |
| PersonalityManager(args.ppkg, representation) | |
| if args.ppkg is not None else None | |
| ) | |
| try: | |
| self.runtime = build_runtime(args) | |
| except Exception: | |
| if self.personality is not None: | |
| self.personality.close() | |
| raise | |
| self.reviewer = PostTurnMemoryReviewer(self.runtime.review_memory) | |
| metadata = { | |
| **self.runtime.metadata(), | |
| "canonical_p_protocol_version": CANONICAL_P_PROTOCOL, | |
| "ppkg_path": ( | |
| None if self.personality is None | |
| else str(self.personality.path.resolve()) | |
| ), | |
| "git_commit": git_commit(args.repo_root), | |
| "launch_arguments": vars(args) | { | |
| key: None if value is None else str(value) | |
| for key, value in vars(args).items() if isinstance(value, Path) | |
| }, | |
| "canonical_representation": { | |
| "recipe": "train_and_probe_representation", | |
| "seed": 97, | |
| "training_steps": 600, | |
| "held_out_p_only_state_recovery": probe["p_only_state_recovery"], | |
| }, | |
| } | |
| self.recorder = SessionRecorder( | |
| args.session_root, args.runtime, metadata, | |
| enabled=not args.no_logging, | |
| ) | |
| self.history: list[tuple[str, str]] = [] | |
| self._pending_review: tuple[str, str, list[tuple[str, str]]] | None = None | |
| self.closed = False | |
| def title(self) -> str: | |
| return ( | |
| "Planner Cache — Pythia-1.4B" | |
| if self.args.runtime == "pythia" | |
| else "Planner Cache — Gemma4 E4B" | |
| ) | |
| def command( | |
| self, | |
| command: str, | |
| *, | |
| source: str = "terminal", | |
| personality_limit: int = 100, | |
| personality_offset: int = 0, | |
| ) -> object: | |
| command = command.strip().casefold() | |
| self.recorder.event("SESSION_COMMAND", source=source, command=command) | |
| if command == "/help": | |
| return HELP | |
| if command == "/state": | |
| return self.state.snapshot() | |
| if command == "/personality": | |
| page = ( | |
| { | |
| "entries": [], "total_active": 0, "returned": 0, | |
| "limit": personality_limit, "offset": personality_offset, | |
| "truncated": False, | |
| } | |
| if self.personality is None | |
| else self.personality.visible_entry_page( | |
| limit=personality_limit, offset=personality_offset, | |
| ) | |
| ) | |
| return { | |
| "promoted": page.pop("entries"), | |
| "page": page, | |
| "last_retrieval": ( | |
| None | |
| if self.personality is None | |
| or self.personality.last_selection is None | |
| else { | |
| "route": asdict(self.personality.last_selection.route), | |
| "entries": [ | |
| asdict(entry) | |
| for entry in self.personality.last_selection.entries | |
| ], | |
| } | |
| ), | |
| } | |
| if command == "/events": | |
| return list(self.recorder.recent_events) | |
| if command == "/save": | |
| save_session_state( | |
| self.recorder, self.state, self.personality, reason="explicit" | |
| ) | |
| return "Session state saved." | |
| if command == "/quit": | |
| return "quit" | |
| return "Unknown command. Type /help." | |
| def chat( | |
| self, | |
| message: str, | |
| *, | |
| raw_messages: list[dict[str, object]] | None = None, | |
| ) -> GenerationResult: | |
| if self._pending_review is not None: | |
| raise RuntimeError("previous post-turn memory review is incomplete") | |
| self.recorder.turn += 1 | |
| self.recorder.transcript( | |
| role="user", text=message, model=self.runtime.model_id, | |
| runtime=self.runtime.runtime, | |
| ) | |
| self.recorder.event( | |
| "P_STATE_BEFORE", source="p_cache", entries=self.state.snapshot() | |
| ) | |
| mutations = self.state.extract_manual_mutations(message) | |
| if mutations: | |
| self.state.apply(mutations, self.recorder) | |
| else: | |
| self.recorder.event( | |
| "P_IGNORE", source="p_cache", | |
| reason="no deterministic manual override before generation", | |
| ) | |
| if self.personality is not None: | |
| evidence = self.personality.extract_evidence( | |
| message, turn=self.recorder.turn, timestamp=utc_now(), | |
| ) | |
| if evidence is not None: | |
| self.personality.ingest(evidence, self.recorder) | |
| personality_store = self.personality.query(message, self.recorder) | |
| else: | |
| personality_store = None | |
| query = self.state.infer_query(message) | |
| if ( | |
| query.entity is None | |
| and personality_store is not None | |
| and personality_store.cache.occupied | |
| ): | |
| query = type(query)( | |
| entity="user", relation_id=0, relation="response_style", | |
| reason="accepted context-specific P-package state", | |
| ) | |
| open_values = bool(getattr(self.runtime, "supports_open_values", False)) | |
| translation_store = self.state.translation_store( | |
| include_open_values=open_values | |
| ) | |
| if query.entity is not None and query.relation_id is not None: | |
| full_route, full_candidates = canonical_route( | |
| self.runtime.router, self.runtime.encoder, self.state.store, query, | |
| ) | |
| if full_route is not None and full_route.has_valid: | |
| selected_slot = int(full_route.indices[0, 0]) | |
| accepted = bool(full_route.accepted[0]) | |
| if ( | |
| accepted | |
| and not open_values | |
| and not self.state.translator_compatible.get(selected_slot, True) | |
| ): | |
| self.recorder.event( | |
| "ROUTER_QUERY", source="canonical_compatibility_precheck", | |
| entity=query.entity, relation=query.relation, | |
| ) | |
| self.recorder.event( | |
| "ROUTER_CANDIDATES", source="p_cache", | |
| candidates=full_candidates, | |
| ) | |
| self.recorder.event( | |
| "ROUTER_ACCEPT", source="p_cache", | |
| score=float(full_route.scores[0, 0].detach()), | |
| selected_state=self.state.entry(selected_slot), | |
| ) | |
| self.recorder.event( | |
| "TTL_DISABLE", source="ttl", | |
| reason=( | |
| "canonical value is outside this TTL's supported vocabulary" | |
| ), | |
| selected_state=self.state.entry(selected_slot), | |
| ) | |
| self.recorder.event( | |
| "MODEL_GENERATION_START", source="model", model=self.runtime.model_id, | |
| runtime=self.runtime.runtime, query=asdict(query), | |
| ) | |
| started = utc_now() | |
| try: | |
| result = self.runtime.generate( | |
| message, self.history, translation_store, personality_store, query, | |
| emit=self.recorder.event if self.recorder.enabled else None, | |
| raw_messages=raw_messages, | |
| ) | |
| except Exception as error: | |
| self.recorder.event( | |
| "ERROR", source="model", error_type=type(error).__name__, | |
| message=str(error), traceback=traceback.format_exc(), | |
| ) | |
| raise | |
| self.recorder.event( | |
| "MODEL_GENERATION_END", source="model", started_at=started, | |
| latency_seconds=result.latency_seconds, | |
| input_tokens=result.input_tokens, output_tokens=result.output_tokens, | |
| diagnostics=result.diagnostics, | |
| ) | |
| self.recorder.transcript( | |
| role="assistant", text=result.text, model=self.runtime.model_id, | |
| runtime=self.runtime.runtime, latency_seconds=result.latency_seconds, | |
| input_tokens=result.input_tokens, output_tokens=result.output_tokens, | |
| ) | |
| model_candidates = self.state.extract_mutations(result.text) | |
| for candidate in model_candidates: | |
| self.recorder.event( | |
| "P_IGNORE", source="model_output", candidate=asdict(candidate), | |
| reason="model-generated state is not authoritative without user evidence", | |
| ) | |
| self._pending_review = (message, result.text, list(self.history)) | |
| return result | |
| def complete_turn_review(self) -> None: | |
| """Finish the hidden review before another visible turn may start.""" | |
| if self._pending_review is None: | |
| return | |
| message, response, prior_history = self._pending_review | |
| self._pending_review = None | |
| self.reviewer.run( | |
| user_message=message, | |
| assistant_response=response, | |
| recent_context=prior_history, | |
| state=self.state, | |
| recorder=self.recorder, | |
| ) | |
| self.history.append((message, response)) | |
| self.recorder.event( | |
| "P_STATE_AFTER", source="p_cache", entries=self.state.snapshot() | |
| ) | |
| def close(self, *, reason: str = "normal") -> None: | |
| if self.closed: | |
| return | |
| self.closed = True | |
| if self._pending_review is not None: | |
| self.complete_turn_review() | |
| try: | |
| self.runtime.close() | |
| except Exception as error: | |
| self.recorder.event( | |
| "ERROR", source="runtime_cleanup", | |
| error_type=type(error).__name__, message=str(error), | |
| ) | |
| try: | |
| save_session_state( | |
| self.recorder, self.state, self.personality, reason="final" | |
| ) | |
| except Exception as error: | |
| self.recorder.event( | |
| "ERROR", source="session_save", | |
| error_type=type(error).__name__, message=str(error), | |
| ) | |
| try: | |
| payload = final_payload(self.state, self.personality) | |
| except Exception as error: | |
| self.recorder.event( | |
| "ERROR", source="final_state", | |
| error_type=type(error).__name__, message=str(error), | |
| ) | |
| payload = { | |
| "canonical_p_protocol": CANONICAL_P_PROTOCOL, | |
| "active_p_cache_entries": self.state.snapshot(), | |
| "ppkg_mutations": [], | |
| "final_state_error": str(error), | |
| } | |
| self.recorder.finalize(payload, reason=reason) | |
| if self.personality is not None: | |
| try: | |
| self.personality.close() | |
| except Exception: | |
| pass | |
| def run(args: argparse.Namespace) -> int: | |
| session = None | |
| reason = "normal" | |
| try: | |
| session = PlannerChatSession(args) | |
| title = ( | |
| "Planner Cache — Pythia-1.4B" | |
| if args.runtime == "pythia" | |
| else "Planner Cache — Gemma4 E4B" | |
| ) | |
| print(f"\n{title}\n") | |
| while True: | |
| try: | |
| message = input("You: ") | |
| except EOFError: | |
| reason = "eof" | |
| break | |
| stripped = message.strip() | |
| if not stripped: | |
| continue | |
| if stripped.startswith("/"): | |
| command = stripped.casefold() | |
| if command == "/quit": | |
| session.command(command) | |
| reason = "quit" | |
| break | |
| value = session.command(command) | |
| if isinstance(value, str): | |
| print(value) | |
| else: | |
| display_json(value) | |
| continue | |
| try: | |
| result = session.chat(message) | |
| except KeyboardInterrupt: | |
| reason = "ctrl-c" | |
| raise | |
| except Exception as error: | |
| print(f"Generation failed: {error}", file=sys.stderr) | |
| continue | |
| print(f"Assistant: {result.text}") | |
| session.complete_turn_review() | |
| except KeyboardInterrupt: | |
| reason = "ctrl-c" | |
| print("\nStopping.") | |
| finally: | |
| if session is not None: | |
| session.close(reason=reason) | |
| return 0 | |
| def main() -> None: | |
| try: | |
| raise SystemExit(run(parser().parse_args())) | |
| except (FileNotFoundError, ValueError, RuntimeError) as error: | |
| print(f"Planner Cache startup failed: {error}", file=sys.stderr) | |
| raise SystemExit(2) from error | |
| if __name__ == "__main__": | |
| main() | |