tramamind 0.5.0__tar.gz

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.
Files changed (36) hide show
  1. tramamind-0.5.0/LICENSE +21 -0
  2. tramamind-0.5.0/PKG-INFO +144 -0
  3. tramamind-0.5.0/README.md +115 -0
  4. tramamind-0.5.0/cli/__init__.py +1 -0
  5. tramamind-0.5.0/cli/__main__.py +4 -0
  6. tramamind-0.5.0/cli/assets/index.html +243 -0
  7. tramamind-0.5.0/cli/keys.py +129 -0
  8. tramamind-0.5.0/cli/main.py +265 -0
  9. tramamind-0.5.0/cli/repl.py +135 -0
  10. tramamind-0.5.0/cli/ui_server.py +206 -0
  11. tramamind-0.5.0/pyproject.toml +49 -0
  12. tramamind-0.5.0/router/__init__.py +1 -0
  13. tramamind-0.5.0/router/client.py +152 -0
  14. tramamind-0.5.0/router/engine.py +292 -0
  15. tramamind-0.5.0/router/keystore.py +255 -0
  16. tramamind-0.5.0/router/packet.py +349 -0
  17. tramamind-0.5.0/router/paths.py +47 -0
  18. tramamind-0.5.0/router/presets.py +67 -0
  19. tramamind-0.5.0/router/profile.py +103 -0
  20. tramamind-0.5.0/router/providers.py +42 -0
  21. tramamind-0.5.0/router/proxy.py +678 -0
  22. tramamind-0.5.0/router/runtime.py +338 -0
  23. tramamind-0.5.0/router/stats.py +55 -0
  24. tramamind-0.5.0/router/store.py +99 -0
  25. tramamind-0.5.0/setup.cfg +4 -0
  26. tramamind-0.5.0/tests/test_client_ui.py +222 -0
  27. tramamind-0.5.0/tests/test_engine.py +141 -0
  28. tramamind-0.5.0/tests/test_keystore.py +36 -0
  29. tramamind-0.5.0/tests/test_packet.py +129 -0
  30. tramamind-0.5.0/tests/test_proxy.py +367 -0
  31. tramamind-0.5.0/tramamind.egg-info/PKG-INFO +144 -0
  32. tramamind-0.5.0/tramamind.egg-info/SOURCES.txt +34 -0
  33. tramamind-0.5.0/tramamind.egg-info/dependency_links.txt +1 -0
  34. tramamind-0.5.0/tramamind.egg-info/entry_points.txt +2 -0
  35. tramamind-0.5.0/tramamind.egg-info/requires.txt +6 -0
  36. tramamind-0.5.0/tramamind.egg-info/top_level.txt +2 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 bitfarmy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: tramamind
3
+ Version: 0.5.0
4
+ Summary: Local proxy that caps chat context, keeps recent turns and code verbatim, and prints the tokens it did not send.
5
+ Author: bitfarmy
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/bitfarmy/tramamind
8
+ Project-URL: Repository, https://github.com/bitfarmy/tramamind
9
+ Project-URL: Issues, https://github.com/bitfarmy/tramamind/issues
10
+ Keywords: ollama,llm,context,tokens,proxy
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: pydantic>=2.5
24
+ Requires-Dist: keyring>=24.3
25
+ Requires-Dist: PyYAML>=6.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8.0; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # TramaMind
31
+
32
+ Local proxy and chat that shows how many tokens it left out. Recent turns stay word for word, however long they are. From older turns it keeps up to four verbatim excerpts — a code block, a unified diff, a traceback, or a tool result — each at most 1500 characters, and only while they fit. One cloud call, and only when the local answer is empty, a short refusal, or you ask to redo it.
33
+
34
+ ```bash
35
+ pipx install "git+https://github.com/bitfarmy/tramamind"
36
+ tramamind demo
37
+ tramamind pack session.json
38
+ ```
39
+
40
+ `demo` needs no model. On the sample chat it prints:
41
+
42
+ ```
43
+ full history ~10273 · packet ~1211 · −88%
44
+ code kept verbatim: def add(a, b)
45
+ last user message kept: What does add return?
46
+ ```
47
+
48
+ The old turns in that sample are the same sentence repeated, which is why the gap is wide. `def add` comes from those old turns and is still in the packet. The last question is the original message. A long source file in the latest turns is sent whole, and the percentage gets smaller. The figure is this estimate (about 3.5 characters per token), on this chat.
49
+
50
+ `pack` runs the same packet on your own transcript. The file is a JSON array of messages, or an object with a `messages` key. The line is the one the proxy prints (`storia intera`, `inviati`, the percentage), then the blocks that stayed. `--out packet.json` writes the messages that would be forwarded. Nothing is sent to a model.
51
+
52
+ ## In front of Cursor, Continue, or any OpenAI client
53
+
54
+ ```bash
55
+ tramamind proxy
56
+ ```
57
+
58
+ OpenAI base URL: `http://127.0.0.1:8788/v1`
59
+
60
+ Default upstream: Ollama at `http://127.0.0.1:11434/v1`. OmniRoute, if you use it, is `--upstream http://127.0.0.1:20128/v1`. The chat page stays on port 8787.
61
+
62
+ ```yaml
63
+ # Continue
64
+ name: TramaMind
65
+ provider: openai
66
+ model: qwen2.5-coder:7b
67
+ apiBase: http://127.0.0.1:8788/v1
68
+ apiKey: ollama
69
+ ```
70
+
71
+ Each request prints a line on stderr, and the response carries `X-Tramamind-Raw-Tokens`, `X-Tramamind-Sent-Tokens`, and `X-Tramamind-Saved-Pct`. When the upstream sends `prompt_tokens`, the same line gains `· api N` and the response gains `X-Tramamind-Api-Prompt-Tokens`. The summary is an excerpt, so the model already loaded for the client stays loaded. Your system message stays as it arrived. The log stores the counts.
72
+
73
+ `--budget` defaults to 2500 estimated tokens and `--verbatim` to the last 6 messages. With a profile, those two values come from `~/.config/tramamind/profile.yaml`.
74
+
75
+ ## Chat on this machine
76
+
77
+ ```bash
78
+ ./scripts/install.sh
79
+ .venv/bin/tramamind setup
80
+ .venv/bin/tramamind pull
81
+ .venv/bin/tramamind chat
82
+ ```
83
+
84
+ `tramamind ui` serves http://127.0.0.1:8787. That page sets the token budget, chooses Ollama or OmniRoute, starts the proxy, and shows the last savings line.
85
+
86
+ ## In italiano
87
+
88
+ Assistente personale sul tuo PC. Il modello di default è locale, via Ollama. La chat ricorda le sessioni e, quando la storia si allunga, ne manda un riassunto più gli ultimi turni, non la trascrizione intera. Se la risposta locale è vuota, è una scusa, o chiedi di rifarla, parte **una** chiamata cloud.
89
+
90
+ OmniRoute, se lo installi, resta il tubo verso più provider. TramaMind non è un secondo router: decide cosa entra nel prompt e quale modello locale usare.
91
+
92
+ ```
93
+ tramamind chat
94
+ │
95
+ ▼
96
+ pacchetto con tetto ──► Ollama
97
+ │
98
+ └─ solo se serve ──► Groq diretto, oppure OmniRoute
99
+ ```
100
+
101
+ Ollama da solo non tiene un tetto sulla storia e non cambia modello in base alla domanda. OmniRoute da solo non sceglie i modelli per la tua RAM e non distingue una risposta scarsa da un errore HTTP. TramaMind fa quelle due cose e lascia il resto a loro.
102
+
103
+ `tramamind proxy` mette lo stesso pacchetto davanti a Cursor, Continue o a qualsiasi client compatibile con OpenAI. `tramamind demo` mostra il conto senza scaricare un modello. `tramamind pack sessione.json` fa lo stesso conto sulla tua trascrizione.
104
+
105
+ Claude Pro non passa di qui. Per il codice serio resta Claude Code, diretto.
106
+
107
+ ```bash
108
+ ./scripts/install.sh
109
+ .venv/bin/tramamind setup # preset in base alla RAM, chiavi opzionali
110
+ .venv/bin/tramamind pull # scarica i tag del preset
111
+ .venv/bin/tramamind chat
112
+ ```
113
+
114
+ Pagina locale, solo su questa macchina: `tramamind ui` poi http://127.0.0.1:8787. Da lì partono anche tetto, upstream e il proxy su `http://127.0.0.1:8788/v1`.
115
+
116
+ Corsie: `tramamind chat --code "…"`, `--think`, `--fast`. In chat: `/new`, `/sessions`, `/resume`, `/use code`, `/clear`.
117
+
118
+ Sotto ogni risposta c'è il conto: token inviati, stima della storia intera, e la percentuale tolta dal pacchetto quando c'è stato un riassunto. `tramamind stats` aggrega quei conti, senza il testo.
119
+
120
+ `tramamind doctor` dice cosa manca. Se Ollama non risponde, il comando fallisce: non stampa "attivo" lo stesso.
121
+
122
+ ### Preset
123
+
124
+ | Preset | Quando | Generale | Codice | Ragionamento |
125
+ |---|---|---|---|---|
126
+ | `cpu16` | portatile, 16 GB | `gemma3:4b` | `qwen2.5-coder:3b` | `deepseek-r1:1.5b` |
127
+ | `gpu12` | GPU ~12 GB | `qwen3:8b` | `qwen2.5-coder:7b` | `deepseek-r1:8b` |
128
+ | `gpu24` | GPU 24 GB | `qwen3:32b` | `qwen2.5-coder:14b` | `deepseek-r1:14b` |
129
+
130
+ Il tag `summary` del profilo è `gemma3:4b`. Viene chiamato solo se è già il modello della risposta (è il caso di `cpu16`). Sugli altri preset il riassunto è estrattivo, per non ricaricare un modello a ogni messaggio. Il proxy è sempre estrattivo. I tag sono quelli di [docs/local-models.md](docs/local-models.md).
131
+
132
+ ### Chiavi
133
+
134
+ `tramamind keys add groq` le mette nel keyring (oppure in un file age, oppure in `~/.config/tramamind/.env` con permessi `0600`). Con una chiave Groq l'escalation funziona anche senza OmniRoute. `tramamind sync` spinge le chiavi nel CLI di OmniRoute, se c'è.
135
+
136
+ Il modello cloud di default nel profilo è `openai/gpt-oss-120b`. Se quel catalogo cambia, lo sostituisci in `~/.config/tramamind/profile.yaml`.
137
+
138
+ ### Cosa non fa
139
+
140
+ Il risparmio è il pacchetto: ultimi turni interi, e dai turni vecchi un estratto più fino a quattro pezzi parola per parola (codice, diff, traceback, risultato di un tool) se ci stanno. Ogni pezzo vecchio è al massimo 1500 caratteri. La compressione di OmniRoute, se la accendi dalla sua dashboard, è un'altra cosa e può tagliare proprio il pezzo che serviva. Il numero di cui mi fido è quello stampato da `tramamind chat`, da `tramamind demo`, da `tramamind pack` e dal proxy. Quando il provider manda `prompt_tokens`, la riga del proxy lo affianca alla stima.
141
+
142
+ OpenHands e OpenClaw sono extra, spenti. L'app desktop e il nodo Oracle non fanno parte del flusso: gli appunti sono in [docs/future/](docs/future/).
143
+
144
+ Licenza MIT. Uso personale delle API gratuite: [docs/legal.md](docs/legal.md).
@@ -0,0 +1,115 @@
1
+ # TramaMind
2
+
3
+ Local proxy and chat that shows how many tokens it left out. Recent turns stay word for word, however long they are. From older turns it keeps up to four verbatim excerpts — a code block, a unified diff, a traceback, or a tool result — each at most 1500 characters, and only while they fit. One cloud call, and only when the local answer is empty, a short refusal, or you ask to redo it.
4
+
5
+ ```bash
6
+ pipx install "git+https://github.com/bitfarmy/tramamind"
7
+ tramamind demo
8
+ tramamind pack session.json
9
+ ```
10
+
11
+ `demo` needs no model. On the sample chat it prints:
12
+
13
+ ```
14
+ full history ~10273 · packet ~1211 · −88%
15
+ code kept verbatim: def add(a, b)
16
+ last user message kept: What does add return?
17
+ ```
18
+
19
+ The old turns in that sample are the same sentence repeated, which is why the gap is wide. `def add` comes from those old turns and is still in the packet. The last question is the original message. A long source file in the latest turns is sent whole, and the percentage gets smaller. The figure is this estimate (about 3.5 characters per token), on this chat.
20
+
21
+ `pack` runs the same packet on your own transcript. The file is a JSON array of messages, or an object with a `messages` key. The line is the one the proxy prints (`storia intera`, `inviati`, the percentage), then the blocks that stayed. `--out packet.json` writes the messages that would be forwarded. Nothing is sent to a model.
22
+
23
+ ## In front of Cursor, Continue, or any OpenAI client
24
+
25
+ ```bash
26
+ tramamind proxy
27
+ ```
28
+
29
+ OpenAI base URL: `http://127.0.0.1:8788/v1`
30
+
31
+ Default upstream: Ollama at `http://127.0.0.1:11434/v1`. OmniRoute, if you use it, is `--upstream http://127.0.0.1:20128/v1`. The chat page stays on port 8787.
32
+
33
+ ```yaml
34
+ # Continue
35
+ name: TramaMind
36
+ provider: openai
37
+ model: qwen2.5-coder:7b
38
+ apiBase: http://127.0.0.1:8788/v1
39
+ apiKey: ollama
40
+ ```
41
+
42
+ Each request prints a line on stderr, and the response carries `X-Tramamind-Raw-Tokens`, `X-Tramamind-Sent-Tokens`, and `X-Tramamind-Saved-Pct`. When the upstream sends `prompt_tokens`, the same line gains `· api N` and the response gains `X-Tramamind-Api-Prompt-Tokens`. The summary is an excerpt, so the model already loaded for the client stays loaded. Your system message stays as it arrived. The log stores the counts.
43
+
44
+ `--budget` defaults to 2500 estimated tokens and `--verbatim` to the last 6 messages. With a profile, those two values come from `~/.config/tramamind/profile.yaml`.
45
+
46
+ ## Chat on this machine
47
+
48
+ ```bash
49
+ ./scripts/install.sh
50
+ .venv/bin/tramamind setup
51
+ .venv/bin/tramamind pull
52
+ .venv/bin/tramamind chat
53
+ ```
54
+
55
+ `tramamind ui` serves http://127.0.0.1:8787. That page sets the token budget, chooses Ollama or OmniRoute, starts the proxy, and shows the last savings line.
56
+
57
+ ## In italiano
58
+
59
+ Assistente personale sul tuo PC. Il modello di default è locale, via Ollama. La chat ricorda le sessioni e, quando la storia si allunga, ne manda un riassunto più gli ultimi turni, non la trascrizione intera. Se la risposta locale è vuota, è una scusa, o chiedi di rifarla, parte **una** chiamata cloud.
60
+
61
+ OmniRoute, se lo installi, resta il tubo verso più provider. TramaMind non è un secondo router: decide cosa entra nel prompt e quale modello locale usare.
62
+
63
+ ```
64
+ tramamind chat
65
+ │
66
+ ▼
67
+ pacchetto con tetto ──► Ollama
68
+ │
69
+ └─ solo se serve ──► Groq diretto, oppure OmniRoute
70
+ ```
71
+
72
+ Ollama da solo non tiene un tetto sulla storia e non cambia modello in base alla domanda. OmniRoute da solo non sceglie i modelli per la tua RAM e non distingue una risposta scarsa da un errore HTTP. TramaMind fa quelle due cose e lascia il resto a loro.
73
+
74
+ `tramamind proxy` mette lo stesso pacchetto davanti a Cursor, Continue o a qualsiasi client compatibile con OpenAI. `tramamind demo` mostra il conto senza scaricare un modello. `tramamind pack sessione.json` fa lo stesso conto sulla tua trascrizione.
75
+
76
+ Claude Pro non passa di qui. Per il codice serio resta Claude Code, diretto.
77
+
78
+ ```bash
79
+ ./scripts/install.sh
80
+ .venv/bin/tramamind setup # preset in base alla RAM, chiavi opzionali
81
+ .venv/bin/tramamind pull # scarica i tag del preset
82
+ .venv/bin/tramamind chat
83
+ ```
84
+
85
+ Pagina locale, solo su questa macchina: `tramamind ui` poi http://127.0.0.1:8787. Da lì partono anche tetto, upstream e il proxy su `http://127.0.0.1:8788/v1`.
86
+
87
+ Corsie: `tramamind chat --code "…"`, `--think`, `--fast`. In chat: `/new`, `/sessions`, `/resume`, `/use code`, `/clear`.
88
+
89
+ Sotto ogni risposta c'è il conto: token inviati, stima della storia intera, e la percentuale tolta dal pacchetto quando c'è stato un riassunto. `tramamind stats` aggrega quei conti, senza il testo.
90
+
91
+ `tramamind doctor` dice cosa manca. Se Ollama non risponde, il comando fallisce: non stampa "attivo" lo stesso.
92
+
93
+ ### Preset
94
+
95
+ | Preset | Quando | Generale | Codice | Ragionamento |
96
+ |---|---|---|---|---|
97
+ | `cpu16` | portatile, 16 GB | `gemma3:4b` | `qwen2.5-coder:3b` | `deepseek-r1:1.5b` |
98
+ | `gpu12` | GPU ~12 GB | `qwen3:8b` | `qwen2.5-coder:7b` | `deepseek-r1:8b` |
99
+ | `gpu24` | GPU 24 GB | `qwen3:32b` | `qwen2.5-coder:14b` | `deepseek-r1:14b` |
100
+
101
+ Il tag `summary` del profilo è `gemma3:4b`. Viene chiamato solo se è già il modello della risposta (è il caso di `cpu16`). Sugli altri preset il riassunto è estrattivo, per non ricaricare un modello a ogni messaggio. Il proxy è sempre estrattivo. I tag sono quelli di [docs/local-models.md](docs/local-models.md).
102
+
103
+ ### Chiavi
104
+
105
+ `tramamind keys add groq` le mette nel keyring (oppure in un file age, oppure in `~/.config/tramamind/.env` con permessi `0600`). Con una chiave Groq l'escalation funziona anche senza OmniRoute. `tramamind sync` spinge le chiavi nel CLI di OmniRoute, se c'è.
106
+
107
+ Il modello cloud di default nel profilo è `openai/gpt-oss-120b`. Se quel catalogo cambia, lo sostituisci in `~/.config/tramamind/profile.yaml`.
108
+
109
+ ### Cosa non fa
110
+
111
+ Il risparmio è il pacchetto: ultimi turni interi, e dai turni vecchi un estratto più fino a quattro pezzi parola per parola (codice, diff, traceback, risultato di un tool) se ci stanno. Ogni pezzo vecchio è al massimo 1500 caratteri. La compressione di OmniRoute, se la accendi dalla sua dashboard, è un'altra cosa e può tagliare proprio il pezzo che serviva. Il numero di cui mi fido è quello stampato da `tramamind chat`, da `tramamind demo`, da `tramamind pack` e dal proxy. Quando il provider manda `prompt_tokens`, la riga del proxy lo affianca alla stima.
112
+
113
+ OpenHands e OpenClaw sono extra, spenti. L'app desktop e il nodo Oracle non fanno parte del flusso: gli appunti sono in [docs/future/](docs/future/).
114
+
115
+ Licenza MIT. Uso personale delle API gratuite: [docs/legal.md](docs/legal.md).
@@ -0,0 +1 @@
1
+ """TramaMind CLI."""
@@ -0,0 +1,4 @@
1
+ from cli.main import main
2
+ import sys
3
+
4
+ sys.exit(main())
@@ -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) => ({ "&":"&amp;", "<":"&lt;", ">":"&gt;" }[c]));
237
+ }
238
+
239
+ refresh().catch(err => { $("checks").textContent = err.message; });
240
+ setInterval(() => { refresh().catch(() => {}); }, 3000);
241
+ </script>
242
+ </body>
243
+ </html>
@@ -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