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.
- tramamind-0.5.0/LICENSE +21 -0
- tramamind-0.5.0/PKG-INFO +144 -0
- tramamind-0.5.0/README.md +115 -0
- tramamind-0.5.0/cli/__init__.py +1 -0
- tramamind-0.5.0/cli/__main__.py +4 -0
- tramamind-0.5.0/cli/assets/index.html +243 -0
- tramamind-0.5.0/cli/keys.py +129 -0
- tramamind-0.5.0/cli/main.py +265 -0
- tramamind-0.5.0/cli/repl.py +135 -0
- tramamind-0.5.0/cli/ui_server.py +206 -0
- tramamind-0.5.0/pyproject.toml +49 -0
- tramamind-0.5.0/router/__init__.py +1 -0
- tramamind-0.5.0/router/client.py +152 -0
- tramamind-0.5.0/router/engine.py +292 -0
- tramamind-0.5.0/router/keystore.py +255 -0
- tramamind-0.5.0/router/packet.py +349 -0
- tramamind-0.5.0/router/paths.py +47 -0
- tramamind-0.5.0/router/presets.py +67 -0
- tramamind-0.5.0/router/profile.py +103 -0
- tramamind-0.5.0/router/providers.py +42 -0
- tramamind-0.5.0/router/proxy.py +678 -0
- tramamind-0.5.0/router/runtime.py +338 -0
- tramamind-0.5.0/router/stats.py +55 -0
- tramamind-0.5.0/router/store.py +99 -0
- tramamind-0.5.0/setup.cfg +4 -0
- tramamind-0.5.0/tests/test_client_ui.py +222 -0
- tramamind-0.5.0/tests/test_engine.py +141 -0
- tramamind-0.5.0/tests/test_keystore.py +36 -0
- tramamind-0.5.0/tests/test_packet.py +129 -0
- tramamind-0.5.0/tests/test_proxy.py +367 -0
- tramamind-0.5.0/tramamind.egg-info/PKG-INFO +144 -0
- tramamind-0.5.0/tramamind.egg-info/SOURCES.txt +34 -0
- tramamind-0.5.0/tramamind.egg-info/dependency_links.txt +1 -0
- tramamind-0.5.0/tramamind.egg-info/entry_points.txt +2 -0
- tramamind-0.5.0/tramamind.egg-info/requires.txt +6 -0
- tramamind-0.5.0/tramamind.egg-info/top_level.txt +2 -0
tramamind-0.5.0/LICENSE
ADDED
|
@@ -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.
|
tramamind-0.5.0/PKG-INFO
ADDED
|
@@ -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,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>
|
|
@@ -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
|