synarmo 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
synarmo/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ from synarmo.config import SynarmoConfig
2
+ from synarmo.engine import SynarmoEngine, predict, suggest
3
+ from synarmo.suggestions import Suggestion
4
+
5
+ __all__ = [
6
+ "Suggestion",
7
+ "SynarmoConfig",
8
+ "SynarmoEngine",
9
+ "predict",
10
+ "suggest",
11
+ ]
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class LogprobToken:
9
+ text: str
10
+ logprob: float
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class AutocompleteCandidate:
15
+ text: str
16
+ starter: str
17
+ rest: str
18
+ logprob: float
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class AutocompleteEvaluation:
23
+ context: str
24
+ prompt: str
25
+ candidates: list[AutocompleteCandidate]
26
+ top_tokens: list[LogprobToken] = field(default_factory=list)
27
+
28
+
29
+ def build_autocomplete_prompt(context: str, typed_text: str) -> str:
30
+ context_lines = [
31
+ line
32
+ for line in context.rstrip().splitlines()
33
+ if not line.lower().startswith("current typed text:")
34
+ ]
35
+ context_block = "\n".join(context_lines).strip()
36
+ if context_block:
37
+ context_block = f"Context:\n{context_block}\n\n"
38
+
39
+ return f"""{context_block}Continue the message with only the next few words.
40
+ Do not repeat labels or earlier text.
41
+ Message:
42
+ {typed_text}"""
43
+
44
+
45
+ def normalize_candidate(text: str, *, max_words: int) -> str:
46
+ candidate = text.replace("\r", "").replace("\n", " ").strip()
47
+ candidate = candidate.removeprefix(":").strip()
48
+ candidate = candidate.strip("\"'` ")
49
+ words = candidate.split()
50
+ if max_words > 0:
51
+ words = words[:max_words]
52
+ return " ".join(words).strip(" ,;:")
53
+
54
+
55
+ def extract_top_logprobs(probe: dict[str, Any]) -> dict[str, float]:
56
+ choices = probe.get("choices")
57
+ if not choices:
58
+ return {}
59
+
60
+ logprobs = choices[0].get("logprobs")
61
+ if not logprobs:
62
+ return {}
63
+
64
+ top_logprobs = logprobs.get("top_logprobs")
65
+ if not top_logprobs:
66
+ return {}
67
+
68
+ raw_top_logprobs = top_logprobs[0]
69
+ if not isinstance(raw_top_logprobs, dict):
70
+ return {}
71
+
72
+ return raw_top_logprobs
73
+
74
+
75
+ def evaluate_with_llama(
76
+ llm: Any,
77
+ *,
78
+ context: str,
79
+ typed_text: str,
80
+ choices: int = 3,
81
+ max_tokens: int = 5,
82
+ max_words: int = 1,
83
+ temperature: float = 0.5,
84
+ top_p: float = 0.95,
85
+ logprob_pool: int = 24,
86
+ ) -> AutocompleteEvaluation:
87
+ prompt = build_autocomplete_prompt(context, typed_text)
88
+ probe = llm(
89
+ prompt=prompt,
90
+ max_tokens=1,
91
+ temperature=temperature,
92
+ top_p=top_p,
93
+ logprobs=logprob_pool,
94
+ echo=False,
95
+ )
96
+ raw_top_logprobs = extract_top_logprobs(probe)
97
+ ranked = sorted(raw_top_logprobs.items(), key=lambda item: item[1], reverse=True)
98
+ top_tokens = [LogprobToken(text=text, logprob=float(logprob)) for text, logprob in ranked]
99
+
100
+ seen_words: set[str] = set()
101
+ starters: list[LogprobToken] = []
102
+ for token_text, logprob in ranked:
103
+ stripped = token_text.strip()
104
+ if not stripped:
105
+ continue
106
+ first_word = stripped.split()[0].lower()
107
+ if first_word in seen_words:
108
+ continue
109
+ seen_words.add(first_word)
110
+ starters.append(LogprobToken(text=token_text, logprob=float(logprob)))
111
+ if len(starters) >= choices:
112
+ break
113
+
114
+ candidates: list[AutocompleteCandidate] = []
115
+ for starter in starters:
116
+ response = llm(
117
+ prompt=prompt + starter.text,
118
+ max_tokens=max(max_tokens - 1, 1),
119
+ temperature=0.0,
120
+ top_p=1.0,
121
+ stop=["\n", ".", "!", "?"],
122
+ echo=False,
123
+ )
124
+ rest = str(response["choices"][0]["text"])
125
+ candidate = normalize_candidate(starter.text + rest, max_words=max_words)
126
+ candidates.append(
127
+ AutocompleteCandidate(
128
+ text=candidate,
129
+ starter=starter.text,
130
+ rest=rest,
131
+ logprob=starter.logprob,
132
+ )
133
+ )
134
+
135
+ return AutocompleteEvaluation(
136
+ context=context,
137
+ prompt=prompt,
138
+ candidates=candidates,
139
+ top_tokens=top_tokens,
140
+ )
synarmo/cli.py ADDED
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from synarmo.engine import SynarmoEngine
7
+
8
+
9
+ def build_parser() -> argparse.ArgumentParser:
10
+ parser = argparse.ArgumentParser(prog="synarmo")
11
+ subcommands = parser.add_subparsers(dest="command", required=True)
12
+
13
+ suggest_parser = subcommands.add_parser("suggest", help="Generate local type-ahead suggestions.")
14
+ suggest_parser.add_argument("text")
15
+ suggest_parser.add_argument("--context", default="")
16
+ suggest_parser.add_argument("--profile", default="default")
17
+ suggest_parser.add_argument("--backend", choices=["mock", "llama-cpp"], default="mock")
18
+ suggest_parser.add_argument("--model-path", type=Path)
19
+ suggest_parser.add_argument("--max-suggestions", type=int)
20
+
21
+ model_ensure_parser = subcommands.add_parser(
22
+ "model-ensure",
23
+ help="Download or verify the configured local inference model.",
24
+ )
25
+ model_ensure_parser.add_argument("--profile", default="default")
26
+ model_ensure_parser.add_argument("--backend", choices=["mock", "llama-cpp"], default="llama-cpp")
27
+ model_ensure_parser.add_argument("--model-path", type=Path)
28
+
29
+ compose_parser = subcommands.add_parser(
30
+ "compose",
31
+ help="Interactively choose suggestions and continue predicting.",
32
+ )
33
+ compose_parser.add_argument("text")
34
+ compose_parser.add_argument("--context", default="")
35
+ compose_parser.add_argument("--profile", default="default")
36
+ compose_parser.add_argument("--backend", choices=["mock", "llama-cpp"], default="mock")
37
+ compose_parser.add_argument("--model-path", type=Path)
38
+ compose_parser.add_argument("--max-suggestions", type=int)
39
+
40
+ serve_parser = subcommands.add_parser("serve", help="Run the local REST/WebSocket service.")
41
+ serve_parser.add_argument(
42
+ "--host",
43
+ default="127.0.0.1",
44
+ help="Bind address for the service. Use 0.0.0.0 to listen on all interfaces.",
45
+ )
46
+ serve_parser.add_argument("--port", type=int, default=8765)
47
+ serve_parser.add_argument("--profile", default="default")
48
+ serve_parser.add_argument("--backend", choices=["mock", "llama-cpp"], default="mock")
49
+ serve_parser.add_argument("--model-path", type=Path)
50
+ serve_parser.add_argument("--max-suggestions", type=int)
51
+ return parser
52
+
53
+
54
+ def app() -> None:
55
+ parser = build_parser()
56
+ args = parser.parse_args()
57
+
58
+ if args.command == "suggest":
59
+ engine = _load_engine(args)
60
+ for item in engine.suggest(text=args.text, context=args.context):
61
+ print(item.text)
62
+ return
63
+
64
+ if args.command == "model-ensure":
65
+ engine = _load_engine(args)
66
+ print(_format_runtime_diagnostics(engine))
67
+ print(f"Model ready for {args.backend}")
68
+ return
69
+
70
+ if args.command == "compose":
71
+ engine = _load_engine(args)
72
+ _compose(engine, text=args.text, context=args.context)
73
+ return
74
+
75
+ if args.command == "serve":
76
+ try:
77
+ import uvicorn
78
+ except ImportError as exc:
79
+ raise SystemExit("Install service extras first: pip install synarmo[service]") from exc
80
+
81
+ from synarmo.service.app import create_app
82
+
83
+ engine = _load_engine(args)
84
+ print(_format_runtime_diagnostics(engine))
85
+ uvicorn.run(create_app(engine), host=args.host, port=args.port)
86
+
87
+
88
+ def _load_engine(args: argparse.Namespace) -> SynarmoEngine:
89
+ options: dict[str, object] = {}
90
+ max_suggestions = getattr(args, "max_suggestions", None)
91
+ if max_suggestions is not None:
92
+ options["max_suggestions"] = max_suggestions
93
+
94
+ return SynarmoEngine.load(
95
+ profile=args.profile,
96
+ backend=args.backend,
97
+ model_path=args.model_path,
98
+ **options,
99
+ )
100
+
101
+
102
+ def _format_runtime_diagnostics(engine: SynarmoEngine) -> str:
103
+ diagnostics = engine.runtime_diagnostics()
104
+ parts = [
105
+ f"backend={diagnostics.get('backend', '')}",
106
+ f"model={diagnostics.get('model', '')}",
107
+ f"n_gpu_layers={diagnostics.get('n_gpu_layers', '')}",
108
+ ]
109
+ if "requested_gpu_layers" in diagnostics:
110
+ parts.append(f"requested_gpu_layers={diagnostics['requested_gpu_layers']}")
111
+ if "model_layers" in diagnostics:
112
+ parts.append(f"model_layers={diagnostics['model_layers']}")
113
+ if "gpu_offload_supported" in diagnostics:
114
+ parts.append(f"gpu_offload_supported={diagnostics['gpu_offload_supported']}")
115
+ if "llama_verbose" in diagnostics:
116
+ parts.append(f"llama_verbose={diagnostics['llama_verbose']}")
117
+ return "Synarmo runtime: " + " ".join(parts)
118
+
119
+
120
+ def _compose(engine: SynarmoEngine, *, text: str, context: str) -> None:
121
+ current = text
122
+ while True:
123
+ print(f"\n{current}")
124
+ suggestions = engine.suggest(text=current, context=context)
125
+ if not suggestions:
126
+ print("No suggestions.")
127
+ return
128
+
129
+ for index, item in enumerate(suggestions, start=1):
130
+ print(f"{index}. {item.text}")
131
+
132
+ choice = input(
133
+ f"Choose 1-{len(suggestions)}, enter custom text, or q to quit: "
134
+ ).strip()
135
+ if choice.lower() in {"q", "quit", "exit"}:
136
+ return
137
+ if not choice:
138
+ continue
139
+ if choice.isdigit() and 1 <= int(choice) <= len(suggestions):
140
+ selected = suggestions[int(choice) - 1].text
141
+ else:
142
+ selected = choice
143
+ current = f"{current.rstrip()} {selected.strip()}".strip()
144
+
145
+
146
+ if __name__ == "__main__":
147
+ app()
synarmo/config.py ADDED
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field, replace
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ BackendName = Literal["mock", "llama-cpp"]
9
+
10
+ DEFAULT_MODELS_CACHE = Path("~/models/synarmo")
11
+ ENV_FILE = ".env"
12
+ LOCAL_MODELS_CACHE_ENV = "LOCAL_MODELS_CACHE"
13
+ MODEL_ENV = "SYNARMO_MODEL"
14
+ MODEL_PATH_ENV = "SYNARMO_MODEL_PATH"
15
+ MODEL_REPO_ID_ENV = "SYNARMO_MODEL_REPO_ID"
16
+ MAX_SUGGESTIONS_ENV = "SYNARMO_MAX_SUGGESTIONS"
17
+ MAX_TOKENS_ENV = "SYNARMO_MAX_TOKENS"
18
+ MAX_SUGGESTION_WORDS_ENV = "SYNARMO_MAX_SUGGESTION_WORDS"
19
+ TEMPERATURE_ENV = "SYNARMO_TEMPERATURE"
20
+ TOP_P_ENV = "SYNARMO_TOP_P"
21
+ LOGPROB_POOL_ENV = "SYNARMO_LOGPROB_POOL"
22
+ N_GPU_LAYERS_ENV = "SYNARMO_N_GPU_LAYERS"
23
+ LLAMA_VERBOSE_ENV = "SYNARMO_LLAMA_VERBOSE"
24
+
25
+
26
+ def load_env_file(path: str | Path = ENV_FILE) -> None:
27
+ env_path = Path(path)
28
+ if not env_path.exists():
29
+ return
30
+
31
+ for raw_line in env_path.read_text(encoding="utf-8").splitlines():
32
+ line = raw_line.strip()
33
+ if not line or line.startswith("#") or "=" not in line:
34
+ continue
35
+
36
+ key, value = line.split("=", 1)
37
+ key = key.strip()
38
+ value = value.strip().strip("'\"")
39
+ if key and key not in os.environ:
40
+ os.environ[key] = value
41
+
42
+
43
+ def configured_models_cache() -> Path:
44
+ value = os.getenv(LOCAL_MODELS_CACHE_ENV)
45
+ return Path(value).expanduser() if value else DEFAULT_MODELS_CACHE.expanduser()
46
+
47
+
48
+ def configured_model_path(model_path: str | Path | None = None) -> Path | None:
49
+ if model_path:
50
+ return Path(model_path).expanduser()
51
+
52
+ value = _env_model()
53
+ if value is None:
54
+ return None
55
+
56
+ value = value.expanduser()
57
+ if value.is_absolute():
58
+ return value
59
+ return configured_models_cache() / value
60
+
61
+
62
+ def configured_model_filename(model_path: str | Path | None = None) -> str | None:
63
+ if model_path:
64
+ return Path(model_path).name
65
+
66
+ value = _env_model()
67
+ return value.name if value else None
68
+
69
+
70
+ def configured_model_repo_id() -> str | None:
71
+ value = os.getenv(MODEL_REPO_ID_ENV)
72
+ return value.strip() if value and value.strip() else None
73
+
74
+
75
+ def _env_model() -> Path | None:
76
+ value = os.getenv(MODEL_ENV) or os.getenv(MODEL_PATH_ENV)
77
+ return Path(value) if value else None
78
+
79
+
80
+ def configured_max_suggestions() -> int:
81
+ value = os.getenv(MAX_SUGGESTIONS_ENV)
82
+ return int(value) if value else 3
83
+
84
+
85
+ def configured_max_tokens() -> int:
86
+ value = os.getenv(MAX_TOKENS_ENV)
87
+ return int(value) if value else 5
88
+
89
+
90
+ def configured_max_suggestion_words() -> int:
91
+ value = os.getenv(MAX_SUGGESTION_WORDS_ENV)
92
+ return int(value) if value else 4
93
+
94
+
95
+ def configured_temperature() -> float:
96
+ value = os.getenv(TEMPERATURE_ENV)
97
+ return float(value) if value else 0.25
98
+
99
+
100
+ def configured_top_p() -> float:
101
+ value = os.getenv(TOP_P_ENV)
102
+ return float(value) if value else 0.95
103
+
104
+
105
+ def configured_logprob_pool() -> int:
106
+ value = os.getenv(LOGPROB_POOL_ENV)
107
+ return int(value) if value else 24
108
+
109
+
110
+ def configured_n_gpu_layers() -> int:
111
+ value = os.getenv(N_GPU_LAYERS_ENV)
112
+ return int(value) if value else 0
113
+
114
+
115
+ def configured_llama_verbose() -> bool:
116
+ value = os.getenv(LLAMA_VERBOSE_ENV)
117
+ return value is not None and value.strip().lower() in {"1", "true", "yes", "on"}
118
+
119
+
120
+ @dataclass(slots=True)
121
+ class SynarmoConfig:
122
+ backend: BackendName = "mock"
123
+ model_path: Path | None = None
124
+ model_repo_id: str | None = None
125
+ model_filename: str | None = None
126
+ models_cache_dir: Path = field(default_factory=configured_models_cache)
127
+ profile: str = "default"
128
+ max_suggestions: int = field(default_factory=configured_max_suggestions)
129
+ max_latency_ms: int = 100
130
+ context_window: int = 2048
131
+ style_adaptation: bool = True
132
+ temperature: float = field(default_factory=configured_temperature)
133
+ top_p: float = field(default_factory=configured_top_p)
134
+ max_tokens: int = field(default_factory=configured_max_tokens)
135
+ max_suggestion_words: int = field(default_factory=configured_max_suggestion_words)
136
+ logprob_pool: int = field(default_factory=configured_logprob_pool)
137
+ n_gpu_layers: int = field(default_factory=configured_n_gpu_layers)
138
+ llama_verbose: bool = field(default_factory=configured_llama_verbose)
139
+ stop: list[str] = field(default_factory=lambda: ["\n\n"])
140
+ profiles_dir: Path = Path("profiles")
141
+
142
+ def __post_init__(self) -> None:
143
+ self.models_cache_dir = self.models_cache_dir.expanduser()
144
+ if self.model_path is not None:
145
+ self.model_path = self.model_path.expanduser()
146
+ if not 1 <= self.max_suggestions <= 10:
147
+ raise ValueError("max_suggestions must be between 1 and 10")
148
+ if self.max_latency_ms < 1:
149
+ raise ValueError("max_latency_ms must be positive")
150
+ if self.context_window < 128:
151
+ raise ValueError("context_window must be at least 128")
152
+ if not 0.0 <= self.temperature <= 2.0:
153
+ raise ValueError("temperature must be between 0.0 and 2.0")
154
+ if not 0.0 < self.top_p <= 1.0:
155
+ raise ValueError("top_p must be greater than 0.0 and at most 1.0")
156
+ if not 1 <= self.max_tokens <= 128:
157
+ raise ValueError("max_tokens must be between 1 and 128")
158
+ if not 1 <= self.max_suggestion_words <= 20:
159
+ raise ValueError("max_suggestion_words must be between 1 and 20")
160
+ if not 1 <= self.logprob_pool <= 50:
161
+ raise ValueError("logprob_pool must be between 1 and 50")
162
+ if self.n_gpu_layers < -1:
163
+ raise ValueError("n_gpu_layers must be -1 or greater")
164
+
165
+ def resolved_profile_dir(self) -> Path:
166
+ return self.profiles_dir.expanduser().resolve() / self.profile
167
+
168
+ def copy_with(self, **updates: object) -> "SynarmoConfig":
169
+ return replace(self, **updates)
synarmo/context.py ADDED
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from synarmo.memory import UserMemory
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class ContextAssembler:
10
+ max_chars: int = 4000
11
+
12
+ def assemble(self, *, text: str, context: str | None, memory: UserMemory) -> str:
13
+ parts: list[str] = []
14
+ if memory.style_summary:
15
+ parts.append(f"User style: {memory.style_summary}")
16
+ if memory.preferences:
17
+ prefs = "; ".join(f"{key}: {value}" for key, value in sorted(memory.preferences.items()))
18
+ parts.append(f"Known preferences: {prefs}")
19
+ if context:
20
+ parts.append(f"Current context: {context.strip()}")
21
+ parts.append(f"Current typed text: {text.strip()}")
22
+ assembled = "\n".join(part for part in parts if part)
23
+ return assembled[-self.max_chars :]