tramamind 0.5.0__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.
- cli/__init__.py +1 -0
- cli/__main__.py +4 -0
- cli/assets/index.html +243 -0
- cli/keys.py +129 -0
- cli/main.py +265 -0
- cli/repl.py +135 -0
- cli/ui_server.py +206 -0
- router/__init__.py +1 -0
- router/client.py +152 -0
- router/engine.py +292 -0
- router/keystore.py +255 -0
- router/packet.py +349 -0
- router/paths.py +47 -0
- router/presets.py +67 -0
- router/profile.py +103 -0
- router/providers.py +42 -0
- router/proxy.py +678 -0
- router/runtime.py +338 -0
- router/stats.py +55 -0
- router/store.py +99 -0
- tramamind-0.5.0.dist-info/METADATA +144 -0
- tramamind-0.5.0.dist-info/RECORD +26 -0
- tramamind-0.5.0.dist-info/WHEEL +5 -0
- tramamind-0.5.0.dist-info/entry_points.txt +2 -0
- tramamind-0.5.0.dist-info/licenses/LICENSE +21 -0
- tramamind-0.5.0.dist-info/top_level.txt +2 -0
cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""TramaMind CLI."""
|
cli/__main__.py
ADDED
cli/assets/index.html
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="it">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>TramaMind</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: dark; }
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
body {
|
|
11
|
+
margin: 0; min-height: 100vh;
|
|
12
|
+
background: #161410; color: #f4efe6;
|
|
13
|
+
font: 16px/1.45 ui-sans-serif, system-ui, sans-serif;
|
|
14
|
+
}
|
|
15
|
+
header, main { max-width: 980px; margin: 0 auto; padding: 1.2rem 1.2rem 0; }
|
|
16
|
+
h1 { font-size: 1.35rem; font-weight: 600; margin: 0 0 .2rem; }
|
|
17
|
+
p.lead { margin: 0; color: #b7ab9a; }
|
|
18
|
+
.grid { display: grid; grid-template-columns: 280px 1fr; gap: 1.2rem; padding: 1.2rem; max-width: 980px; margin: 0 auto; }
|
|
19
|
+
.grid > div { min-width: 0; }
|
|
20
|
+
section { border-top: 1px solid #3a342c; padding-top: .8rem; }
|
|
21
|
+
h2 { font-size: .78rem; letter-spacing: .06em; text-transform: uppercase; color: #b7ab9a; margin: 0 0 .6rem; }
|
|
22
|
+
button, select, textarea, input {
|
|
23
|
+
font: inherit; color: inherit; background: #221e19; border: 1px solid #4a4338; border-radius: 6px;
|
|
24
|
+
}
|
|
25
|
+
button { padding: .35rem .7rem; cursor: pointer; }
|
|
26
|
+
button.primary { background: #e6a15c; color: #1b140c; border-color: #e6a15c; }
|
|
27
|
+
button:disabled { opacity: .5; cursor: wait; }
|
|
28
|
+
.presets { display: flex; flex-wrap: wrap; gap: .4rem; }
|
|
29
|
+
.check { display: flex; gap: .4rem; margin: .2rem 0; }
|
|
30
|
+
.ok { color: #b7d3a8; } .warn { color: #e6c15c; } .fail { color: #e58b7a; }
|
|
31
|
+
#log { min-height: 240px; white-space: pre-wrap; }
|
|
32
|
+
.bubble { margin: 0 0 .9rem; }
|
|
33
|
+
.who { color: #b7ab9a; font-size: .85rem; }
|
|
34
|
+
.stats { color: #8d8274; font-size: .85rem; margin-top: .25rem; }
|
|
35
|
+
form.chat { display: flex; gap: .5rem; margin-top: .6rem; }
|
|
36
|
+
form.chat textarea { flex: 1; min-height: 3.2rem; padding: .5rem; resize: vertical; }
|
|
37
|
+
label { display: block; margin: .45rem 0 .2rem; color: #b7ab9a; font-size: .85rem; }
|
|
38
|
+
input[type="password"], input[type="number"], select, textarea.card { width: 100%; max-width: 100%; min-width: 0; padding: .4rem; }
|
|
39
|
+
.row { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .6rem; }
|
|
40
|
+
section.proxy { max-width: 28rem; }
|
|
41
|
+
textarea.card { min-height: 7rem; }
|
|
42
|
+
@media (max-width: 800px) { .grid { grid-template-columns: 1fr; } }
|
|
43
|
+
</style>
|
|
44
|
+
</head>
|
|
45
|
+
<body>
|
|
46
|
+
<header>
|
|
47
|
+
<h1>TramaMind</h1>
|
|
48
|
+
<p class="lead">Modello sul tuo PC. Il cloud parte solo se la risposta locale non basta.</p>
|
|
49
|
+
</header>
|
|
50
|
+
<main class="grid">
|
|
51
|
+
<div>
|
|
52
|
+
<section class="proxy">
|
|
53
|
+
<h2>Proxy</h2>
|
|
54
|
+
<label for="budget">Tetto</label>
|
|
55
|
+
<input id="budget" type="number" min="1" step="1" inputmode="numeric">
|
|
56
|
+
<label for="verbatim">Turni interi</label>
|
|
57
|
+
<input id="verbatim" type="number" min="1" step="1" inputmode="numeric">
|
|
58
|
+
<label for="upstream">Upstream</label>
|
|
59
|
+
<select id="upstream"></select>
|
|
60
|
+
<p class="stats" id="upstream-url"></p>
|
|
61
|
+
<p class="stats" id="proxy-url"></p>
|
|
62
|
+
<p class="stats" id="proxy-line">nessuna richiesta</p>
|
|
63
|
+
<p class="stats">Ferma e riavvia per applicare tetto e upstream.</p>
|
|
64
|
+
<div class="row">
|
|
65
|
+
<button id="save-proxy" type="button">Salva</button>
|
|
66
|
+
<button id="toggle-proxy" class="primary" type="button">Avvia</button>
|
|
67
|
+
</div>
|
|
68
|
+
<div id="proxy-note" class="stats"></div>
|
|
69
|
+
</section>
|
|
70
|
+
<section>
|
|
71
|
+
<h2>Macchina</h2>
|
|
72
|
+
<div id="checks"></div>
|
|
73
|
+
</section>
|
|
74
|
+
<section>
|
|
75
|
+
<h2>Preset</h2>
|
|
76
|
+
<div class="presets" id="presets"></div>
|
|
77
|
+
</section>
|
|
78
|
+
<section>
|
|
79
|
+
<h2>Chiave cloud</h2>
|
|
80
|
+
<label for="provider">Provider</label>
|
|
81
|
+
<select id="provider"></select>
|
|
82
|
+
<label for="secret">Chiave, resta su questo PC</label>
|
|
83
|
+
<input id="secret" type="password" autocomplete="off">
|
|
84
|
+
<button id="save-key" style="margin-top:.5rem">Salva</button>
|
|
85
|
+
<div id="key-note" class="stats"></div>
|
|
86
|
+
</section>
|
|
87
|
+
<section>
|
|
88
|
+
<h2>Scheda</h2>
|
|
89
|
+
<textarea id="card" class="card"></textarea>
|
|
90
|
+
<button id="save-card" style="margin-top:.5rem">Aggiorna scheda</button>
|
|
91
|
+
</section>
|
|
92
|
+
</div>
|
|
93
|
+
<div>
|
|
94
|
+
<section>
|
|
95
|
+
<h2>Chat</h2>
|
|
96
|
+
<label for="session">Sessione</label>
|
|
97
|
+
<select id="session"></select>
|
|
98
|
+
<div id="log"></div>
|
|
99
|
+
<form class="chat" id="chat">
|
|
100
|
+
<textarea id="message" placeholder="Scrivi. La storia lunga viene riassunta, non rispedita."></textarea>
|
|
101
|
+
<button class="primary" type="submit" id="send">Invia</button>
|
|
102
|
+
</form>
|
|
103
|
+
</section>
|
|
104
|
+
</div>
|
|
105
|
+
</main>
|
|
106
|
+
<script>
|
|
107
|
+
const $ = (id) => document.getElementById(id);
|
|
108
|
+
|
|
109
|
+
async function api(path, body) {
|
|
110
|
+
const opts = body ? {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(body)} : {};
|
|
111
|
+
const res = await fetch(path, opts);
|
|
112
|
+
const data = await res.json();
|
|
113
|
+
if (!res.ok) throw new Error(data.error || res.statusText);
|
|
114
|
+
return data;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function render(status) {
|
|
118
|
+
$("checks").innerHTML = status.checks.map(c =>
|
|
119
|
+
`<div class="check ${c.level}"><span>${c.level === "ok" ? "✓" : c.level === "warn" ? "!" : "✗"}</span><span><b>${c.name}</b> ${c.detail}</span></div>`
|
|
120
|
+
).join("");
|
|
121
|
+
$("presets").innerHTML = Object.entries(status.presets).map(([id, label]) =>
|
|
122
|
+
`<button data-preset="${id}" ${status.profile && status.profile.preset === id ? "class=primary" : ""}>${id}<br><span class="stats">${label}</span></button>`
|
|
123
|
+
).join("");
|
|
124
|
+
$("presets").querySelectorAll("button").forEach(btn => btn.onclick = async () => {
|
|
125
|
+
await api("/api/profile", {preset: btn.dataset.preset});
|
|
126
|
+
await refresh();
|
|
127
|
+
});
|
|
128
|
+
const current = $("provider").value;
|
|
129
|
+
$("provider").innerHTML = status.keys.filter(k => k.id !== "omniroute").map(k =>
|
|
130
|
+
`<option value="${k.id}">${k.label}${k.present ? " · presente" : ""}</option>`
|
|
131
|
+
).join("");
|
|
132
|
+
if (current) $("provider").value = current;
|
|
133
|
+
if (document.activeElement !== $("card")) $("card").value = status.card || "";
|
|
134
|
+
const sid = $("session").value;
|
|
135
|
+
$("session").innerHTML = `<option value="">continua l'ultima</option>` + status.sessions.map(s =>
|
|
136
|
+
`<option value="${s.id}">${s.title}</option>`
|
|
137
|
+
).join("");
|
|
138
|
+
if (sid) $("session").value = sid;
|
|
139
|
+
const proxy = status.proxy;
|
|
140
|
+
if (document.activeElement !== $("budget")) $("budget").value = proxy.budget_tokens;
|
|
141
|
+
if (document.activeElement !== $("verbatim")) $("verbatim").value = proxy.verbatim_turns;
|
|
142
|
+
const signature = proxy.upstreams.map(item => item.id + item.url).join("|");
|
|
143
|
+
if ($("upstream").dataset.sig !== signature) {
|
|
144
|
+
const chosen = $("upstream").value || proxy.upstream;
|
|
145
|
+
$("upstream").innerHTML = proxy.upstreams.map(item =>
|
|
146
|
+
`<option value="${item.id}" data-url="${item.url}">${item.label}</option>`
|
|
147
|
+
).join("");
|
|
148
|
+
$("upstream").dataset.sig = signature;
|
|
149
|
+
$("upstream").value = chosen;
|
|
150
|
+
}
|
|
151
|
+
if (document.activeElement !== $("upstream")) $("upstream").value = proxy.upstream;
|
|
152
|
+
const selected = $("upstream").selectedOptions[0];
|
|
153
|
+
$("upstream-url").textContent = selected ? selected.dataset.url : "";
|
|
154
|
+
$("proxy-url").textContent = proxy.url;
|
|
155
|
+
$("proxy-line").textContent = proxy.last_line || "nessuna richiesta";
|
|
156
|
+
$("toggle-proxy").textContent = proxy.running ? "Ferma" : "Avvia";
|
|
157
|
+
$("toggle-proxy").dataset.running = proxy.running ? "1" : "0";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function refresh() { render(await api("/api/status")); }
|
|
161
|
+
|
|
162
|
+
$("save-key").onclick = async () => {
|
|
163
|
+
$("key-note").textContent = "";
|
|
164
|
+
try {
|
|
165
|
+
const out = await api("/api/keys", {provider: $("provider").value, key: $("secret").value});
|
|
166
|
+
$("secret").value = "";
|
|
167
|
+
$("key-note").textContent = "salvata in " + out.backend;
|
|
168
|
+
await refresh();
|
|
169
|
+
} catch (err) { $("key-note").textContent = err.message; }
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
$("save-card").onclick = async () => {
|
|
173
|
+
await api("/api/card", {text: $("card").value});
|
|
174
|
+
$("key-note").textContent = "";
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
function proxyBody() {
|
|
178
|
+
return {
|
|
179
|
+
budget: Number($("budget").value),
|
|
180
|
+
verbatim: Number($("verbatim").value),
|
|
181
|
+
upstream: $("upstream").value,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
$("save-proxy").onclick = async () => {
|
|
186
|
+
$("proxy-note").textContent = "";
|
|
187
|
+
try {
|
|
188
|
+
await api("/api/proxy", Object.assign({action: "save"}, proxyBody()));
|
|
189
|
+
$("proxy-note").textContent = "salvato nel profilo";
|
|
190
|
+
await refresh();
|
|
191
|
+
} catch (err) { $("proxy-note").textContent = err.message; }
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
$("upstream").onchange = () => {
|
|
195
|
+
const selected = $("upstream").selectedOptions[0];
|
|
196
|
+
$("upstream-url").textContent = selected ? selected.dataset.url : "";
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
$("toggle-proxy").onclick = async () => {
|
|
200
|
+
$("proxy-note").textContent = "";
|
|
201
|
+
const action = $("toggle-proxy").dataset.running === "1" ? "stop" : "start";
|
|
202
|
+
$("toggle-proxy").disabled = true;
|
|
203
|
+
try {
|
|
204
|
+
await api("/api/proxy", Object.assign({action}, proxyBody()));
|
|
205
|
+
await refresh();
|
|
206
|
+
} catch (err) {
|
|
207
|
+
$("proxy-note").textContent = err.message;
|
|
208
|
+
} finally { $("toggle-proxy").disabled = false; }
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
$("chat").onsubmit = async (ev) => {
|
|
212
|
+
ev.preventDefault();
|
|
213
|
+
const message = $("message").value.trim();
|
|
214
|
+
if (!message) return;
|
|
215
|
+
$("send").disabled = true;
|
|
216
|
+
const log = $("log");
|
|
217
|
+
log.insertAdjacentHTML("beforeend", `<div class="bubble"><div class="who">tu</div>${escapeHtml(message)}</div>`);
|
|
218
|
+
$("message").value = "";
|
|
219
|
+
try {
|
|
220
|
+
const body = {message};
|
|
221
|
+
if ($("session").value) body.session = $("session").value;
|
|
222
|
+
const out = await api("/api/chat", body);
|
|
223
|
+
let html = "";
|
|
224
|
+
if (out.local_text) html += `<div class="stats">${escapeHtml(out.local_text)}</div>`;
|
|
225
|
+
html += `<div class="bubble"><div class="who">tramamind</div>${escapeHtml(out.text)}<div class="stats">${escapeHtml(out.stats)}</div></div>`;
|
|
226
|
+
log.insertAdjacentHTML("beforeend", html);
|
|
227
|
+
$("session").value = out.session;
|
|
228
|
+
await refresh();
|
|
229
|
+
$("session").value = out.session;
|
|
230
|
+
} catch (err) {
|
|
231
|
+
log.insertAdjacentHTML("beforeend", `<div class="fail">${escapeHtml(err.message)}</div>`);
|
|
232
|
+
} finally { $("send").disabled = false; }
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
function escapeHtml(text) {
|
|
236
|
+
return String(text).replace(/[&<>]/g, (c) => ({ "&":"&", "<":"<", ">":">" }[c]));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
refresh().catch(err => { $("checks").textContent = err.message; });
|
|
240
|
+
setInterval(() => { refresh().catch(() => {}); }, 3000);
|
|
241
|
+
</script>
|
|
242
|
+
</body>
|
|
243
|
+
</html>
|
cli/keys.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""`tramamind keys` — le chiavi stanno nel keyring, in age o in .env locale."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import getpass
|
|
5
|
+
|
|
6
|
+
from router import keystore
|
|
7
|
+
from router.providers import resolve_provider
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _load_providers() -> list[str]:
|
|
11
|
+
from router.providers import provider_ids
|
|
12
|
+
return provider_ids()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _pick_backend(cli_choice: str | None) -> str:
|
|
16
|
+
if cli_choice:
|
|
17
|
+
return cli_choice
|
|
18
|
+
best = keystore.detect_best_backend()
|
|
19
|
+
labels = {
|
|
20
|
+
"keyring": "Keyring di sistema (consigliato su desktop)",
|
|
21
|
+
"age": "File cifrato age (consigliato su server/headless)",
|
|
22
|
+
"plain": ".env plain (solo sviluppo)",
|
|
23
|
+
}
|
|
24
|
+
print("\nSalvataggio:")
|
|
25
|
+
options = list(labels)
|
|
26
|
+
for i, b in enumerate(options, 1):
|
|
27
|
+
marker = " <- rilevato" if b == best else ""
|
|
28
|
+
print(f" [{i}] {labels[b]}{marker}")
|
|
29
|
+
raw = input(f"Scelta [{options.index(best) + 1}]: ").strip()
|
|
30
|
+
if not raw:
|
|
31
|
+
return best
|
|
32
|
+
try:
|
|
33
|
+
return options[int(raw) - 1]
|
|
34
|
+
except (ValueError, IndexError):
|
|
35
|
+
print("Scelta non valida, uso il rilevamento automatico.")
|
|
36
|
+
return best
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _ask_key(provider: str) -> str | None:
|
|
40
|
+
key = getpass.getpass(f"{provider}: ").strip()
|
|
41
|
+
if not key:
|
|
42
|
+
print(f" {provider}: saltato")
|
|
43
|
+
return None
|
|
44
|
+
return key
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cmd_add(provider: str | None, backend: str | None) -> int:
|
|
48
|
+
providers = _load_providers()
|
|
49
|
+
if not provider:
|
|
50
|
+
print("Provider disponibili:")
|
|
51
|
+
for i, p in enumerate(providers, 1):
|
|
52
|
+
print(f" [{i}] {p}")
|
|
53
|
+
raw = input("Scelta: ").strip()
|
|
54
|
+
try:
|
|
55
|
+
provider = providers[int(raw) - 1]
|
|
56
|
+
except (ValueError, IndexError):
|
|
57
|
+
print("Scelta non valida.")
|
|
58
|
+
return 1
|
|
59
|
+
known = resolve_provider(provider)
|
|
60
|
+
if known is None and provider not in providers:
|
|
61
|
+
print(f"nota: '{provider}' non è nel catalogo — procedo comunque")
|
|
62
|
+
elif known is not None:
|
|
63
|
+
provider = known.id
|
|
64
|
+
|
|
65
|
+
key = _ask_key(provider)
|
|
66
|
+
if not key:
|
|
67
|
+
return 1
|
|
68
|
+
chosen = _pick_backend(backend)
|
|
69
|
+
keystore.store_key(provider, key, chosen)
|
|
70
|
+
print(f"OK: {provider} salvata ({chosen})")
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cmd_setup(backend: str | None) -> int:
|
|
75
|
+
providers = _load_providers()
|
|
76
|
+
print("\nTramaMind - setup API key")
|
|
77
|
+
print("Incolla le chiavi (invio per saltare):\n")
|
|
78
|
+
collected = {}
|
|
79
|
+
for p in providers:
|
|
80
|
+
key = _ask_key(p)
|
|
81
|
+
if key:
|
|
82
|
+
collected[p] = key
|
|
83
|
+
if not collected:
|
|
84
|
+
print("\nNessuna chiave inserita.")
|
|
85
|
+
return 1
|
|
86
|
+
chosen = _pick_backend(backend)
|
|
87
|
+
for p, k in collected.items():
|
|
88
|
+
keystore.store_key(p, k, chosen)
|
|
89
|
+
print(f"\nOK: {len(collected)} chiavi salvate ({chosen})")
|
|
90
|
+
print("Per spingerle in OmniRoute, se lo usi: `tramamind sync`")
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cmd_list() -> int:
|
|
95
|
+
providers = _load_providers()
|
|
96
|
+
print(f"\n{'Provider':<14}{'Stato':<12}{'Storage':<10}Chiave")
|
|
97
|
+
print("-" * 48)
|
|
98
|
+
missing = 0
|
|
99
|
+
for p in providers:
|
|
100
|
+
key, storage = keystore.get_key(p)
|
|
101
|
+
if key:
|
|
102
|
+
print(f"{p:<14}{'presente':<12}{storage or '?':<10}{keystore.mask(key)}")
|
|
103
|
+
else:
|
|
104
|
+
print(f"{p:<14}{'mancante':<12}{'-':<10}")
|
|
105
|
+
missing += 1
|
|
106
|
+
if missing:
|
|
107
|
+
print(f"\n{missing} provider senza chiave: `tramamind keys add <provider>`")
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cmd_remove(provider: str) -> int:
|
|
112
|
+
removed = keystore.delete_key(provider)
|
|
113
|
+
if removed:
|
|
114
|
+
print(f"OK: {provider} rimossa da: {', '.join(removed)}")
|
|
115
|
+
else:
|
|
116
|
+
print(f"{provider}: nessuna chiave trovata")
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def run(args) -> int:
|
|
121
|
+
if args.keys_command == "add":
|
|
122
|
+
return cmd_add(args.provider, args.backend)
|
|
123
|
+
if args.keys_command == "setup":
|
|
124
|
+
return cmd_setup(args.backend)
|
|
125
|
+
if args.keys_command == "list":
|
|
126
|
+
return cmd_list()
|
|
127
|
+
if args.keys_command == "remove":
|
|
128
|
+
return cmd_remove(args.provider)
|
|
129
|
+
return 1
|
cli/main.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""TramaMind — profilo, chat con tetto, escalation singola."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from cli import keys as keys_cmd
|
|
8
|
+
from cli.repl import describe_profile, open_session, run_once, run_repl
|
|
9
|
+
from router.engine import Engine
|
|
10
|
+
from router.keystore import BACKENDS
|
|
11
|
+
from router.presets import PRESETS, suggest_preset
|
|
12
|
+
from router.profile import fresh_profile, load_profile, require_profile, save_profile
|
|
13
|
+
from router.runtime import (
|
|
14
|
+
apply_preset,
|
|
15
|
+
checks,
|
|
16
|
+
down,
|
|
17
|
+
probe,
|
|
18
|
+
pull_models,
|
|
19
|
+
sync_keys,
|
|
20
|
+
up,
|
|
21
|
+
worst_level,
|
|
22
|
+
)
|
|
23
|
+
from router.stats import render_summary, summarize
|
|
24
|
+
from router.store import ensure_card, read_card, write_card
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog="tramamind",
|
|
30
|
+
description="Assistente personale: modello locale, contesto corto, cloud solo se serve.",
|
|
31
|
+
)
|
|
32
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
33
|
+
|
|
34
|
+
setup = sub.add_parser("setup", help="Scrivi il profilo e, se vuoi, le chiavi")
|
|
35
|
+
setup.add_argument("--preset", choices=sorted(PRESETS))
|
|
36
|
+
setup.add_argument("--non-interactive", action="store_true")
|
|
37
|
+
setup.add_argument("--pull", action="store_true", help="Scarica i modelli del preset")
|
|
38
|
+
setup.add_argument("--force", action="store_true")
|
|
39
|
+
|
|
40
|
+
sub.add_parser("up", help="Avvia Ollama e, se ci sono chiavi, OmniRoute")
|
|
41
|
+
sub.add_parser("down", help="Ferma solo i processi avviati da tramamind up")
|
|
42
|
+
doctor = sub.add_parser("doctor", help="Dice cosa manca, senza fingere")
|
|
43
|
+
doctor.add_argument("--probe", action="store_true", help="Una completion minima sul modello locale")
|
|
44
|
+
|
|
45
|
+
chat = sub.add_parser("chat", help="Chat persistente")
|
|
46
|
+
chat.add_argument("message", nargs="*")
|
|
47
|
+
chat.add_argument("--code", action="store_true")
|
|
48
|
+
chat.add_argument("--think", action="store_true")
|
|
49
|
+
chat.add_argument("--fast", action="store_true")
|
|
50
|
+
chat.add_argument("--new", action="store_true")
|
|
51
|
+
chat.add_argument("--debug", action="store_true")
|
|
52
|
+
|
|
53
|
+
keys = sub.add_parser("keys", help="Chiavi nel keyring, in age o in .env locale")
|
|
54
|
+
keys_sub = keys.add_subparsers(dest="keys_command", required=True)
|
|
55
|
+
add = keys_sub.add_parser("add")
|
|
56
|
+
add.add_argument("provider", nargs="?")
|
|
57
|
+
add.add_argument("--backend", choices=BACKENDS)
|
|
58
|
+
setup_keys = keys_sub.add_parser("setup")
|
|
59
|
+
setup_keys.add_argument("--backend", choices=BACKENDS)
|
|
60
|
+
keys_sub.add_parser("list")
|
|
61
|
+
remove = keys_sub.add_parser("remove")
|
|
62
|
+
remove.add_argument("provider")
|
|
63
|
+
|
|
64
|
+
sub.add_parser("sync", help="Spinge le chiavi in OmniRoute, se è installato")
|
|
65
|
+
ui = sub.add_parser("ui", help="Pagina locale su 127.0.0.1:8787")
|
|
66
|
+
ui.add_argument("--port", type=int, default=8787)
|
|
67
|
+
sub.add_parser("pull", help="ollama pull dei modelli del profilo")
|
|
68
|
+
sub.add_parser("stats", help="Conti dei pacchetti, senza il testo")
|
|
69
|
+
card = sub.add_parser("card", help="Scheda stabile che entra in ogni chat")
|
|
70
|
+
card.add_argument("text", nargs="*")
|
|
71
|
+
card.add_argument("--edit", action="store_true")
|
|
72
|
+
|
|
73
|
+
proxy = sub.add_parser("proxy", help="Proxy OpenAI-compatible su 127.0.0.1:8788")
|
|
74
|
+
proxy.add_argument("--upstream", default=None, help="Base OpenAI. Se manca, Ollama o OmniRoute dal profilo")
|
|
75
|
+
proxy.add_argument("--host", default="127.0.0.1")
|
|
76
|
+
proxy.add_argument("--port", type=int, default=8788)
|
|
77
|
+
proxy.add_argument("--budget", type=int, default=None, help="Tetto in token stimati")
|
|
78
|
+
proxy.add_argument("--verbatim", type=int, default=None, help="Ultimi messaggi tenuti interi")
|
|
79
|
+
|
|
80
|
+
sub.add_parser("demo", help="Conta i token su una chat finta, senza modello")
|
|
81
|
+
|
|
82
|
+
pack = sub.add_parser("pack", help="Conta il pacchetto di una trascrizione JSON, senza modello")
|
|
83
|
+
pack.add_argument("path", help="Array di messaggi, oppure un oggetto con la chiave messages")
|
|
84
|
+
pack.add_argument("--budget", type=int, default=None, help="Tetto in token stimati")
|
|
85
|
+
pack.add_argument("--verbatim", type=int, default=None, help="Ultimi messaggi tenuti interi")
|
|
86
|
+
pack.add_argument("--out", help="Scrive i messaggi del pacchetto in questo file")
|
|
87
|
+
return parser
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main(argv: list[str] | None = None) -> int:
|
|
91
|
+
args = build_parser().parse_args(argv)
|
|
92
|
+
if args.command == "keys":
|
|
93
|
+
return keys_cmd.run(args)
|
|
94
|
+
if args.command == "setup":
|
|
95
|
+
return cmd_setup(args)
|
|
96
|
+
if args.command == "up":
|
|
97
|
+
return _print_lines(up(require_profile()))
|
|
98
|
+
if args.command == "down":
|
|
99
|
+
return _print_lines(down())
|
|
100
|
+
if args.command == "doctor":
|
|
101
|
+
return cmd_doctor(args.probe)
|
|
102
|
+
if args.command == "chat":
|
|
103
|
+
return cmd_chat(args)
|
|
104
|
+
if args.command == "sync":
|
|
105
|
+
return _print_sync(sync_keys())
|
|
106
|
+
if args.command == "ui":
|
|
107
|
+
from cli.ui_server import serve
|
|
108
|
+
serve(args.port)
|
|
109
|
+
return 0
|
|
110
|
+
if args.command == "pull":
|
|
111
|
+
return pull_models(require_profile())
|
|
112
|
+
if args.command == "stats":
|
|
113
|
+
print(render_summary(summarize()))
|
|
114
|
+
return 0
|
|
115
|
+
if args.command == "card":
|
|
116
|
+
return cmd_card(args)
|
|
117
|
+
if args.command == "proxy":
|
|
118
|
+
from router.proxy import proxy_target, serve
|
|
119
|
+
budget, verbatim = _proxy_limits(args.budget, args.verbatim)
|
|
120
|
+
serve(proxy_target(args.upstream, _profile_if_present()), args.host, args.port, budget, verbatim)
|
|
121
|
+
return 0
|
|
122
|
+
if args.command == "demo":
|
|
123
|
+
from router.proxy import DEFAULT_BUDGET, DEFAULT_VERBATIM, render_demo
|
|
124
|
+
print(render_demo(DEFAULT_BUDGET, DEFAULT_VERBATIM))
|
|
125
|
+
return 0
|
|
126
|
+
if args.command == "pack":
|
|
127
|
+
return cmd_pack(args)
|
|
128
|
+
return 1
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_setup(args) -> int:
|
|
132
|
+
preset = args.preset
|
|
133
|
+
interactive = not args.non_interactive and sys.stdin.isatty()
|
|
134
|
+
if preset is None and interactive:
|
|
135
|
+
suggested = suggest_preset()
|
|
136
|
+
print("Preset:")
|
|
137
|
+
for key, item in PRESETS.items():
|
|
138
|
+
mark = " ← consigliato" if key == suggested else ""
|
|
139
|
+
print(f" {key:6} {item.label}{mark}")
|
|
140
|
+
raw = input(f"Scelta [{suggested}]: ").strip()
|
|
141
|
+
preset = raw or suggested
|
|
142
|
+
preset = preset or suggest_preset()
|
|
143
|
+
if preset not in PRESETS:
|
|
144
|
+
print(f"preset sconosciuto: {preset}")
|
|
145
|
+
return 1
|
|
146
|
+
existed = load_profile() is not None
|
|
147
|
+
if existed and not args.force:
|
|
148
|
+
print(f"profilo già presente ({describe_profile(load_profile())}). Usa --force per rifare i modelli.")
|
|
149
|
+
elif existed:
|
|
150
|
+
print(describe_profile(apply_preset(preset)))
|
|
151
|
+
else:
|
|
152
|
+
profile = fresh_profile(preset)
|
|
153
|
+
save_profile(profile)
|
|
154
|
+
print(describe_profile(profile))
|
|
155
|
+
ensure_card()
|
|
156
|
+
if interactive:
|
|
157
|
+
print("Chiavi cloud, invio per saltarle. Servono solo per l'escalation.")
|
|
158
|
+
keys_cmd.cmd_setup(None)
|
|
159
|
+
if args.pull:
|
|
160
|
+
return pull_models(require_profile())
|
|
161
|
+
print("Poi: tramamind pull && tramamind chat")
|
|
162
|
+
print("Oppure: tramamind ui")
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def cmd_doctor(do_probe: bool) -> int:
|
|
167
|
+
items = checks()
|
|
168
|
+
for item in items:
|
|
169
|
+
mark = {"ok": "✓", "warn": "!", "fail": "✗"}[item.level]
|
|
170
|
+
print(f" {mark} {item.name}: {item.detail}")
|
|
171
|
+
if do_probe:
|
|
172
|
+
profile = load_profile()
|
|
173
|
+
if profile is None:
|
|
174
|
+
print(" ✗ probe: profilo assente")
|
|
175
|
+
return 1
|
|
176
|
+
try:
|
|
177
|
+
text = probe(profile)
|
|
178
|
+
print(f" ✓ probe: {text!r}")
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
print(f" ✗ probe: {exc}")
|
|
181
|
+
return 1
|
|
182
|
+
return 1 if worst_level(items) == "fail" else 0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def cmd_chat(args) -> int:
|
|
186
|
+
profile = require_profile()
|
|
187
|
+
lanes = [name for name, on in (("code", args.code), ("think", args.think), ("fast", args.fast)) if on]
|
|
188
|
+
if len(lanes) > 1:
|
|
189
|
+
print("scegli una sola corsia", file=sys.stderr)
|
|
190
|
+
return 2
|
|
191
|
+
lane = lanes[0] if lanes else None
|
|
192
|
+
session = open_session(args.new)
|
|
193
|
+
engine = Engine(profile)
|
|
194
|
+
if args.message:
|
|
195
|
+
return run_once(engine, session, " ".join(args.message), lane, args.debug)
|
|
196
|
+
print(describe_profile(profile))
|
|
197
|
+
return run_repl(engine, session, lane)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def cmd_card(args) -> int:
|
|
201
|
+
ensure_card()
|
|
202
|
+
if args.edit:
|
|
203
|
+
import os
|
|
204
|
+
import subprocess
|
|
205
|
+
subprocess.call([os.environ.get("EDITOR", "vi"), str(__import__("router.paths", fromlist=["card_path"]).card_path())])
|
|
206
|
+
return 0
|
|
207
|
+
if args.text:
|
|
208
|
+
write_card(" ".join(args.text) + "\n")
|
|
209
|
+
print("scheda aggiornata")
|
|
210
|
+
return 0
|
|
211
|
+
text = read_card().strip()
|
|
212
|
+
print(text or "(scheda vuota)")
|
|
213
|
+
return 0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def cmd_pack(args) -> int:
|
|
217
|
+
from router.proxy import run_pack
|
|
218
|
+
budget, verbatim = _proxy_limits(args.budget, args.verbatim)
|
|
219
|
+
try:
|
|
220
|
+
print(run_pack(args.path, budget, verbatim, args.out))
|
|
221
|
+
except (OSError, ValueError) as exc:
|
|
222
|
+
print(str(exc), file=sys.stderr)
|
|
223
|
+
return 1
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _proxy_limits(budget: int | None, verbatim: int | None) -> tuple[int, int]:
|
|
228
|
+
profile = _profile_if_present()
|
|
229
|
+
if budget is None:
|
|
230
|
+
budget = profile.budget_tokens if profile is not None else 2500
|
|
231
|
+
if verbatim is None:
|
|
232
|
+
verbatim = profile.verbatim_turns if profile is not None else 6
|
|
233
|
+
return budget, verbatim
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _profile_if_present():
|
|
237
|
+
"""Non crea ~/.config/tramamind solo perché qualcuno lancia demo o proxy."""
|
|
238
|
+
import os
|
|
239
|
+
from pathlib import Path
|
|
240
|
+
|
|
241
|
+
override = os.environ.get("TRAMAMIND_CONFIG_DIR")
|
|
242
|
+
root = Path(override) if override else Path.home() / ".config" / "tramamind"
|
|
243
|
+
if not (root / "profile.yaml").exists():
|
|
244
|
+
return None
|
|
245
|
+
return load_profile()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _print_lines(lines: list[str]) -> int:
|
|
249
|
+
for line in lines:
|
|
250
|
+
print(line)
|
|
251
|
+
failed = any("non ha risposto" in line or "assente" in line for line in lines)
|
|
252
|
+
return 1 if failed else 0
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _print_sync(rows: list[dict]) -> int:
|
|
256
|
+
failed = False
|
|
257
|
+
for row in rows:
|
|
258
|
+
mark = "✓" if row["ok"] else "!"
|
|
259
|
+
print(f" {mark} {row['provider']}: {row['detail']}")
|
|
260
|
+
failed = failed or not row["ok"]
|
|
261
|
+
return 1 if failed else 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
if __name__ == "__main__":
|
|
265
|
+
sys.exit(main())
|