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 +11 -0
- synarmo/autocomplete_eval.py +140 -0
- synarmo/cli.py +147 -0
- synarmo/config.py +169 -0
- synarmo/context.py +23 -0
- synarmo/engine.py +256 -0
- synarmo/memory.py +26 -0
- synarmo/models/__init__.py +4 -0
- synarmo/models/base.py +19 -0
- synarmo/models/factory.py +22 -0
- synarmo/models/llama_cpp_backend.py +130 -0
- synarmo/models/mock_backend.py +15 -0
- synarmo/prompts.py +57 -0
- synarmo/service/__init__.py +3 -0
- synarmo/service/app.py +175 -0
- synarmo/suggestions.py +106 -0
- synarmo/ui/__init__.py +1 -0
- synarmo/ui/static/css/synarmo.css +372 -0
- synarmo/ui/static/js/synarmo.js +274 -0
- synarmo/ui/templates/__init__.py +1 -0
- synarmo/ui/templates/synarmo.html +160 -0
- synarmo-0.1.1.dist-info/METADATA +816 -0
- synarmo-0.1.1.dist-info/RECORD +25 -0
- synarmo-0.1.1.dist-info/WHEEL +4 -0
- synarmo-0.1.1.dist-info/entry_points.txt +2 -0
synarmo/__init__.py
ADDED
|
@@ -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 :]
|