titan-synapse 0.1.1

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 (62) hide show
  1. package/CONTRIBUTING.md +187 -0
  2. package/Cargo.lock +3976 -0
  3. package/Cargo.toml +10 -0
  4. package/LICENSE +190 -0
  5. package/PROGRESS.md +151 -0
  6. package/README.md +514 -0
  7. package/TEST_LOG.md +220 -0
  8. package/config/default.yaml +36 -0
  9. package/crates/synapse/Cargo.toml +70 -0
  10. package/crates/synapse/src/cli/bench.rs +44 -0
  11. package/crates/synapse/src/cli/eval.rs +395 -0
  12. package/crates/synapse/src/cli/export.rs +45 -0
  13. package/crates/synapse/src/cli/hub.rs +179 -0
  14. package/crates/synapse/src/cli/import.rs +35 -0
  15. package/crates/synapse/src/cli/learn.rs +53 -0
  16. package/crates/synapse/src/cli/mod.rs +10 -0
  17. package/crates/synapse/src/cli/models.rs +36 -0
  18. package/crates/synapse/src/cli/pull.rs +60 -0
  19. package/crates/synapse/src/cli/status.rs +52 -0
  20. package/crates/synapse/src/cli/train.rs +99 -0
  21. package/crates/synapse/src/config.rs +220 -0
  22. package/crates/synapse/src/dashboard.rs +281 -0
  23. package/crates/synapse/src/format/manifest.rs +57 -0
  24. package/crates/synapse/src/format/mod.rs +4 -0
  25. package/crates/synapse/src/format/packer.rs +213 -0
  26. package/crates/synapse/src/inference/engine.rs +361 -0
  27. package/crates/synapse/src/inference/kv_cache.rs +97 -0
  28. package/crates/synapse/src/inference/lora.rs +166 -0
  29. package/crates/synapse/src/inference/mod.rs +9 -0
  30. package/crates/synapse/src/inference/model.rs +167 -0
  31. package/crates/synapse/src/inference/sampler.rs +133 -0
  32. package/crates/synapse/src/inference/speculative.rs +153 -0
  33. package/crates/synapse/src/learn/cloud_fallback.rs +186 -0
  34. package/crates/synapse/src/learn/engine.rs +109 -0
  35. package/crates/synapse/src/learn/mod.rs +5 -0
  36. package/crates/synapse/src/main.rs +185 -0
  37. package/crates/synapse/src/memory/extractor.rs +201 -0
  38. package/crates/synapse/src/memory/graph.rs +332 -0
  39. package/crates/synapse/src/memory/hallucination.rs +259 -0
  40. package/crates/synapse/src/memory/mod.rs +7 -0
  41. package/crates/synapse/src/openai.rs +232 -0
  42. package/crates/synapse/src/server.rs +166 -0
  43. package/crates/synapse/src/streaming.rs +80 -0
  44. package/crates/synapse/src/swarm/coordinator.rs +198 -0
  45. package/crates/synapse/src/swarm/mod.rs +8 -0
  46. package/crates/synapse/src/swarm/orchestrator.rs +225 -0
  47. package/crates/synapse/src/swarm/pool.rs +64 -0
  48. package/crates/synapse/src/swarm/spawner.rs +199 -0
  49. package/crates/synapse/src/swarm/synthesizer.rs +26 -0
  50. package/crates/synapse/src/vram/manager.rs +67 -0
  51. package/crates/synapse/src/vram/mod.rs +3 -0
  52. package/docker-compose.yml +19 -0
  53. package/install.sh +311 -0
  54. package/package.json +36 -0
  55. package/python/Dockerfile.learn +18 -0
  56. package/python/requirements.txt +11 -0
  57. package/python/synapse_learn/__init__.py +0 -0
  58. package/python/synapse_learn/datasets.py +233 -0
  59. package/python/synapse_learn/real_eval.py +616 -0
  60. package/python/synapse_learn/server.py +431 -0
  61. package/python/synapse_learn/train_base.py +672 -0
  62. package/python/synapse_learn/train_specialists.py +787 -0
@@ -0,0 +1,281 @@
1
+ /// Embedded web dashboard — a single HTML page served from Rust.
2
+ /// No React. No build tools. No npm. Just HTML + JS + Tailwind CDN.
3
+ /// Normal people can open a browser and chat with their AI.
4
+
5
+ pub const DASHBOARD_HTML: &str = r#"<!DOCTYPE html>
6
+ <html lang="en" class="dark">
7
+ <head>
8
+ <meta charset="UTF-8">
9
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
10
+ <title>TITAN Synapse</title>
11
+ <script src="https://cdn.tailwindcss.com"></script>
12
+ <style>
13
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
14
+ body { font-family: 'Inter', sans-serif; background: #0a0a0f; color: #e0e0e0; }
15
+ .glow { box-shadow: 0 0 20px rgba(139, 92, 246, 0.15); }
16
+ .pulse { animation: pulse 2s ease-in-out infinite; }
17
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
18
+ .msg-user { background: linear-gradient(135deg, #1e1b4b 0%, #312e81 100%); }
19
+ .msg-ai { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); }
20
+ #chat-input:focus { box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.5); }
21
+ .specialist-tag { font-size: 0.65rem; padding: 1px 6px; border-radius: 9999px; }
22
+ ::-webkit-scrollbar { width: 6px; }
23
+ ::-webkit-scrollbar-track { background: transparent; }
24
+ ::-webkit-scrollbar-thumb { background: #4c1d95; border-radius: 3px; }
25
+ </style>
26
+ </head>
27
+ <body class="min-h-screen flex flex-col">
28
+
29
+ <!-- Header -->
30
+ <header class="border-b border-purple-900/30 px-6 py-3 flex items-center justify-between">
31
+ <div class="flex items-center gap-3">
32
+ <div class="text-2xl font-bold bg-gradient-to-r from-purple-400 to-cyan-400 bg-clip-text text-transparent">
33
+ ⚡ SYNAPSE
34
+ </div>
35
+ <span class="text-xs text-gray-500">v0.1.0</span>
36
+ <span id="status-dot" class="w-2 h-2 rounded-full bg-green-500 pulse"></span>
37
+ </div>
38
+ <div class="flex items-center gap-4 text-xs text-gray-400">
39
+ <span id="model-info">Loading...</span>
40
+ <button onclick="showPanel('stats')" class="hover:text-purple-400 transition">📊 Stats</button>
41
+ <button onclick="showPanel('confidence')" class="hover:text-purple-400 transition">🧠 Brain</button>
42
+ </div>
43
+ </header>
44
+
45
+ <!-- Main content -->
46
+ <div class="flex flex-1 overflow-hidden">
47
+ <!-- Chat area -->
48
+ <main class="flex-1 flex flex-col">
49
+ <!-- Messages -->
50
+ <div id="messages" class="flex-1 overflow-y-auto px-6 py-4 space-y-4">
51
+ <div class="text-center text-gray-500 mt-20">
52
+ <div class="text-4xl mb-3">🧠</div>
53
+ <div class="text-lg font-semibold">TITAN Synapse</div>
54
+ <div class="text-sm mt-1">Small models that think together. And learn.</div>
55
+ <div class="text-xs mt-2 text-gray-600">Every conversation makes me smarter.</div>
56
+ </div>
57
+ </div>
58
+
59
+ <!-- Input -->
60
+ <div class="border-t border-purple-900/30 px-6 py-4">
61
+ <form onsubmit="sendMessage(event)" class="flex gap-3">
62
+ <input id="chat-input" type="text" placeholder="Ask me anything..."
63
+ class="flex-1 bg-gray-900/50 border border-purple-900/30 rounded-xl px-4 py-3 text-sm
64
+ outline-none transition placeholder-gray-600" autocomplete="off">
65
+ <button type="submit" id="send-btn"
66
+ class="bg-purple-600 hover:bg-purple-500 px-6 py-3 rounded-xl text-sm font-medium transition">
67
+ Send
68
+ </button>
69
+ </form>
70
+ <div class="flex items-center gap-4 mt-2 text-xs text-gray-500">
71
+ <span id="typing-indicator" class="hidden text-purple-400">⚡ Thinking...</span>
72
+ <span id="tok-speed" class="ml-auto"></span>
73
+ </div>
74
+ </div>
75
+ </main>
76
+
77
+ <!-- Side panel -->
78
+ <aside id="side-panel" class="hidden w-80 border-l border-purple-900/30 overflow-y-auto p-4">
79
+ <div id="panel-content"></div>
80
+ </aside>
81
+ </div>
82
+
83
+ <script>
84
+ const API = window.location.origin;
85
+ let messageHistory = [];
86
+
87
+ async function sendMessage(e) {
88
+ e.preventDefault();
89
+ const input = document.getElementById('chat-input');
90
+ const msg = input.value.trim();
91
+ if (!msg) return;
92
+
93
+ input.value = '';
94
+ addMessage('user', msg);
95
+ messageHistory.push({ role: 'user', content: msg });
96
+
97
+ const typing = document.getElementById('typing-indicator');
98
+ const speedEl = document.getElementById('tok-speed');
99
+ typing.classList.remove('hidden');
100
+ document.getElementById('send-btn').disabled = true;
101
+
102
+ const start = performance.now();
103
+
104
+ try {
105
+ const resp = await fetch(API + '/v1/chat/completions', {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json' },
108
+ body: JSON.stringify({
109
+ model: 'synapse',
110
+ messages: messageHistory.slice(-10), // Last 10 messages for context
111
+ max_tokens: 2048,
112
+ temperature: 0.7,
113
+ }),
114
+ });
115
+
116
+ const data = await resp.json();
117
+ const elapsed = ((performance.now() - start) / 1000).toFixed(1);
118
+ const content = data.choices?.[0]?.message?.content || 'Error: No response';
119
+ const usage = data.usage || {};
120
+
121
+ addMessage('assistant', content, usage);
122
+ messageHistory.push({ role: 'assistant', content });
123
+
124
+ const tokPerSec = usage.completion_tokens
125
+ ? (usage.completion_tokens / parseFloat(elapsed)).toFixed(0)
126
+ : '?';
127
+ speedEl.textContent = `${usage.completion_tokens || '?'} tokens · ${elapsed}s · ${tokPerSec} tok/s`;
128
+ } catch (err) {
129
+ addMessage('assistant', `Connection error: ${err.message}. Is the server running?`);
130
+ }
131
+
132
+ typing.classList.add('hidden');
133
+ document.getElementById('send-btn').disabled = false;
134
+ }
135
+
136
+ function addMessage(role, content, usage) {
137
+ const container = document.getElementById('messages');
138
+ // Remove welcome message on first real message
139
+ if (messageHistory.length <= 1 && role === 'user') {
140
+ container.innerHTML = '';
141
+ }
142
+
143
+ const div = document.createElement('div');
144
+ div.className = `rounded-xl px-4 py-3 max-w-3xl ${role === 'user' ? 'msg-user ml-auto' : 'msg-ai'}`;
145
+
146
+ const label = document.createElement('div');
147
+ label.className = 'text-xs font-semibold mb-1 ' + (role === 'user' ? 'text-purple-300' : 'text-cyan-300');
148
+ label.textContent = role === 'user' ? 'You' : 'Synapse';
149
+ if (usage && usage.prompt_tokens) {
150
+ const badge = document.createElement('span');
151
+ badge.className = 'specialist-tag bg-purple-900/50 text-purple-300 ml-2';
152
+ badge.textContent = `${usage.prompt_tokens}→${usage.completion_tokens} tokens`;
153
+ label.appendChild(badge);
154
+ }
155
+
156
+ const text = document.createElement('div');
157
+ text.className = 'text-sm leading-relaxed whitespace-pre-wrap';
158
+ // Simple markdown-like formatting
159
+ text.innerHTML = formatContent(content);
160
+
161
+ div.appendChild(label);
162
+ div.appendChild(text);
163
+ container.appendChild(div);
164
+ container.scrollTop = container.scrollHeight;
165
+ }
166
+
167
+ function formatContent(text) {
168
+ // Code blocks
169
+ text = text.replace(/```(\w*)\n([\s\S]*?)```/g,
170
+ '<pre class="bg-black/40 rounded-lg p-3 my-2 overflow-x-auto text-xs"><code>$2</code></pre>');
171
+ // Inline code
172
+ text = text.replace(/`([^`]+)`/g, '<code class="bg-black/30 px-1 rounded text-purple-300">$1</code>');
173
+ // Bold
174
+ text = text.replace(/\*\*([^*]+)\*\*/g, '<strong class="text-white">$1</strong>');
175
+ // Lists
176
+ text = text.replace(/^(\d+)\. /gm, '<span class="text-purple-400">$1.</span> ');
177
+ text = text.replace(/^- /gm, '<span class="text-cyan-400">•</span> ');
178
+ return text;
179
+ }
180
+
181
+ async function showPanel(type) {
182
+ const panel = document.getElementById('side-panel');
183
+ const content = document.getElementById('panel-content');
184
+ panel.classList.toggle('hidden');
185
+
186
+ if (type === 'stats') {
187
+ content.innerHTML = '<div class="text-center text-gray-500">Loading...</div>';
188
+ try {
189
+ const resp = await fetch(API + '/api/status');
190
+ const data = await resp.json();
191
+ content.innerHTML = `
192
+ <h3 class="text-sm font-bold text-purple-300 mb-3">📊 System Status</h3>
193
+ <div class="space-y-2 text-xs">
194
+ <div class="flex justify-between"><span class="text-gray-400">Version</span><span>${data.version}</span></div>
195
+ <div class="flex justify-between"><span class="text-gray-400">Models</span><span>${data.models_loaded?.length || 0}</span></div>
196
+ <div class="flex justify-between"><span class="text-gray-400">Specialists</span><span>${data.specialists?.length || 0}</span></div>
197
+ <div class="flex justify-between"><span class="text-gray-400">Adapters</span><span>${data.adapters?.length || 0}</span></div>
198
+ <hr class="border-purple-900/30 my-2">
199
+ <h4 class="font-semibold text-cyan-300">Knowledge Graph</h4>
200
+ <div class="flex justify-between"><span class="text-gray-400">Facts known</span><span>${data.knowledge?.facts || 0}</span></div>
201
+ <div class="flex justify-between"><span class="text-gray-400">Conversations</span><span>${data.knowledge?.conversations || 0}</span></div>
202
+ <div class="flex justify-between"><span class="text-gray-400">Preference pairs</span><span>${data.knowledge?.preference_pairs || 0}</span></div>
203
+ <hr class="border-purple-900/30 my-2">
204
+ <h4 class="font-semibold text-cyan-300">Loaded Models</h4>
205
+ ${(data.models_loaded || []).map(m => `<div class="text-gray-300">• ${m}</div>`).join('')}
206
+ <hr class="border-purple-900/30 my-2">
207
+ <h4 class="font-semibold text-cyan-300">Hebbian Pathways</h4>
208
+ ${(data.hebbian_routing?.top_pathways || []).map(p =>
209
+ `<div class="flex justify-between"><span class="text-gray-400">${p.pathway}</span><span>×${p.strength}</span></div>`
210
+ ).join('')}
211
+ </div>
212
+ `;
213
+ } catch (e) {
214
+ content.innerHTML = '<div class="text-red-400 text-xs">Failed to load status</div>';
215
+ }
216
+ } else if (type === 'confidence') {
217
+ content.innerHTML = '<div class="text-center text-gray-500">Loading...</div>';
218
+ try {
219
+ const resp = await fetch(API + '/api/confidence');
220
+ const data = await resp.json();
221
+ const m = data.metacognition;
222
+ content.innerHTML = `
223
+ <h3 class="text-sm font-bold text-purple-300 mb-3">🧠 Metacognition</h3>
224
+ <p class="text-xs text-gray-400 mb-3">What the system knows about itself</p>
225
+ <div class="space-y-3">
226
+ <h4 class="font-semibold text-cyan-300 text-xs">Specialist Confidence</h4>
227
+ ${(m.specialists || []).map(s => `
228
+ <div class="bg-gray-900/50 rounded-lg p-2 text-xs">
229
+ <div class="flex justify-between font-medium">
230
+ <span>${s.specialist}</span>
231
+ <span class="text-purple-300">${s.avg_tok_per_sec?.toFixed(0)} tok/s</span>
232
+ </div>
233
+ <div class="flex justify-between text-gray-500 mt-1">
234
+ <span>${s.requests} requests</span>
235
+ <span>score: ${s.avg_score?.toFixed(1)}/5</span>
236
+ </div>
237
+ <div class="w-full bg-gray-800 rounded-full h-1 mt-1">
238
+ <div class="bg-purple-500 h-1 rounded-full" style="width: ${(s.avg_score/5*100)}%"></div>
239
+ </div>
240
+ </div>
241
+ `).join('')}
242
+ <hr class="border-purple-900/30">
243
+ <h4 class="font-semibold text-cyan-300 text-xs">Learning Status</h4>
244
+ <div class="text-xs space-y-1">
245
+ <div class="flex justify-between"><span class="text-gray-400">Facts learned</span><span>${m.learning_status?.facts_known || 0}</span></div>
246
+ <div class="flex justify-between"><span class="text-gray-400">Conversations</span><span>${m.learning_status?.conversations_logged || 0}</span></div>
247
+ <div class="flex justify-between"><span class="text-gray-400">Preferences</span><span>${m.learning_status?.preferences_collected || 0}</span></div>
248
+ </div>
249
+ </div>
250
+ `;
251
+ } catch (e) {
252
+ content.innerHTML = '<div class="text-red-400 text-xs">Failed to load confidence data</div>';
253
+ }
254
+ }
255
+ }
256
+
257
+ // Load initial status
258
+ async function init() {
259
+ try {
260
+ const resp = await fetch(API + '/api/status');
261
+ const data = await resp.json();
262
+ document.getElementById('model-info').textContent =
263
+ `${data.models_loaded?.length || 0} models · ${data.specialists?.length || 0} specialists · ${data.knowledge?.facts || 0} facts`;
264
+ } catch (e) {
265
+ document.getElementById('model-info').textContent = 'Disconnected';
266
+ document.getElementById('status-dot').className = 'w-2 h-2 rounded-full bg-red-500';
267
+ }
268
+ }
269
+
270
+ // Allow Enter to send, Shift+Enter for newline
271
+ document.getElementById('chat-input').addEventListener('keydown', (e) => {
272
+ if (e.key === 'Enter' && !e.shiftKey) {
273
+ e.preventDefault();
274
+ document.querySelector('form').dispatchEvent(new Event('submit'));
275
+ }
276
+ });
277
+
278
+ init();
279
+ </script>
280
+ </body>
281
+ </html>"#;
@@ -0,0 +1,57 @@
1
+ use serde::{Deserialize, Serialize};
2
+
3
+ /// Manifest for .synapse model format
4
+ #[derive(Debug, Clone, Serialize, Deserialize)]
5
+ pub struct SynapseManifest {
6
+ pub format: String,
7
+ pub version: String,
8
+ pub name: String,
9
+ pub base_model: String,
10
+ pub base_quantization: String,
11
+ pub vram_estimate_mb: u64,
12
+ pub capabilities: Vec<String>,
13
+ pub adapter_count: u32,
14
+ pub training_pairs_collected: u32,
15
+ pub last_trained: Option<String>,
16
+ pub performance_score: f32,
17
+ pub created_by: String,
18
+ }
19
+
20
+ impl SynapseManifest {
21
+ pub fn new(name: &str, base_model: &str) -> Self {
22
+ Self {
23
+ format: "synapse".into(),
24
+ version: env!("CARGO_PKG_VERSION").into(),
25
+ name: name.into(),
26
+ base_model: base_model.into(),
27
+ base_quantization: "Q4_K_M".into(),
28
+ vram_estimate_mb: 2100,
29
+ capabilities: vec![],
30
+ adapter_count: 0,
31
+ training_pairs_collected: 0,
32
+ last_trained: None,
33
+ performance_score: 0.0,
34
+ created_by: format!("titan-synapse/{}", env!("CARGO_PKG_VERSION")),
35
+ }
36
+ }
37
+ }
38
+
39
+ #[cfg(test)]
40
+ mod tests {
41
+ use super::*;
42
+
43
+ #[test]
44
+ fn test_manifest_creation() {
45
+ let m = SynapseManifest::new("python_expert", "Qwen3-3B");
46
+ assert_eq!(m.format, "synapse");
47
+ assert_eq!(m.name, "python_expert");
48
+ }
49
+
50
+ #[test]
51
+ fn test_manifest_serialization() {
52
+ let m = SynapseManifest::new("test", "Qwen3-3B");
53
+ let json = serde_json::to_string_pretty(&m).unwrap();
54
+ let parsed: SynapseManifest = serde_json::from_str(&json).unwrap();
55
+ assert_eq!(parsed.name, "test");
56
+ }
57
+ }
@@ -0,0 +1,4 @@
1
+ pub mod manifest;
2
+ pub mod packer;
3
+
4
+ pub use manifest::SynapseManifest;
@@ -0,0 +1,213 @@
1
+ use anyhow::Result;
2
+ use std::path::{Path, PathBuf};
3
+ use super::manifest::SynapseManifest;
4
+
5
+ /// Pack a specialist into a .synapse directory bundle
6
+ ///
7
+ /// Layout:
8
+ /// <name>.synapse/
9
+ /// manifest.json
10
+ /// model.gguf (if found)
11
+ /// adapters/*.safetensors
12
+ /// knowledge/graph.sqlite
13
+ /// agent.yaml
14
+ pub fn pack(
15
+ manifest: &SynapseManifest,
16
+ models_dir: &Path,
17
+ adapters_dir: &Path,
18
+ knowledge_db: Option<&Path>,
19
+ output_path: &Path,
20
+ ) -> Result<()> {
21
+ std::fs::create_dir_all(output_path)?;
22
+
23
+ // Write manifest
24
+ let manifest_json = serde_json::to_string_pretty(manifest)?;
25
+ std::fs::write(output_path.join("manifest.json"), manifest_json)?;
26
+
27
+ // Copy model GGUF if found
28
+ let model_glob = format!("{}*", manifest.name);
29
+ if let Ok(entries) = std::fs::read_dir(models_dir) {
30
+ for entry in entries.flatten() {
31
+ let path = entry.path();
32
+ if path.extension().is_some_and(|ext| ext == "gguf") {
33
+ let dest = output_path.join("model.gguf");
34
+ // Symlink instead of copy (saves disk space for multi-GB files)
35
+ #[cfg(unix)]
36
+ {
37
+ if let Err(_) = std::os::unix::fs::symlink(&path, &dest) {
38
+ std::fs::copy(&path, &dest)?;
39
+ }
40
+ }
41
+ #[cfg(not(unix))]
42
+ {
43
+ std::fs::copy(&path, &dest)?;
44
+ }
45
+ tracing::info!("Linked model: {}", path.display());
46
+ break;
47
+ }
48
+ }
49
+ }
50
+
51
+ // Copy adapters
52
+ let adapters_out = output_path.join("adapters");
53
+ std::fs::create_dir_all(&adapters_out)?;
54
+ let mut adapter_count = 0u32;
55
+
56
+ if adapters_dir.exists() {
57
+ for entry in std::fs::read_dir(adapters_dir)?.flatten() {
58
+ let path = entry.path();
59
+ if path.extension().is_some_and(|ext| ext == "safetensors") {
60
+ let name = path.file_name().unwrap();
61
+ std::fs::copy(&path, adapters_out.join(name))?;
62
+ adapter_count += 1;
63
+ }
64
+ }
65
+ }
66
+
67
+ // Copy knowledge graph
68
+ if let Some(db_path) = knowledge_db {
69
+ if db_path.exists() {
70
+ let knowledge_out = output_path.join("knowledge");
71
+ std::fs::create_dir_all(&knowledge_out)?;
72
+ std::fs::copy(db_path, knowledge_out.join("graph.sqlite"))?;
73
+ }
74
+ }
75
+
76
+ tracing::info!(
77
+ "Packed specialist '{}': {} adapters, output: {}",
78
+ manifest.name,
79
+ adapter_count,
80
+ output_path.display()
81
+ );
82
+
83
+ Ok(())
84
+ }
85
+
86
+ /// Unpack a .synapse directory bundle into the working directories
87
+ pub fn unpack(
88
+ synapse_path: &Path,
89
+ models_dir: &Path,
90
+ adapters_dir: &Path,
91
+ ) -> Result<SynapseManifest> {
92
+ if !synapse_path.exists() {
93
+ anyhow::bail!("Path does not exist: {}", synapse_path.display());
94
+ }
95
+
96
+ let manifest_path = synapse_path.join("manifest.json");
97
+ if !manifest_path.exists() {
98
+ anyhow::bail!("No manifest.json found in {}", synapse_path.display());
99
+ }
100
+
101
+ let content = std::fs::read_to_string(&manifest_path)?;
102
+ let manifest: SynapseManifest = serde_json::from_str(&content)?;
103
+
104
+ // Copy model if present
105
+ let model_file = synapse_path.join("model.gguf");
106
+ if model_file.exists() {
107
+ std::fs::create_dir_all(models_dir)?;
108
+ let dest = models_dir.join(format!("{}.gguf", manifest.name));
109
+ if !dest.exists() {
110
+ std::fs::copy(&model_file, &dest)?;
111
+ tracing::info!("Installed model: {}", dest.display());
112
+ }
113
+ }
114
+
115
+ // Copy adapters
116
+ let adapters_src = synapse_path.join("adapters");
117
+ if adapters_src.exists() {
118
+ std::fs::create_dir_all(adapters_dir)?;
119
+ for entry in std::fs::read_dir(&adapters_src)?.flatten() {
120
+ let path = entry.path();
121
+ if path.extension().is_some_and(|ext| ext == "safetensors") {
122
+ let name = path.file_name().unwrap();
123
+ let dest = adapters_dir.join(name);
124
+ std::fs::copy(&path, &dest)?;
125
+ }
126
+ }
127
+ }
128
+
129
+ // Copy knowledge graph
130
+ let knowledge_src = synapse_path.join("knowledge").join("graph.sqlite");
131
+ if knowledge_src.exists() {
132
+ let knowledge_dir = synapse_path.parent()
133
+ .unwrap_or(Path::new("."))
134
+ .join("knowledge");
135
+ std::fs::create_dir_all(&knowledge_dir)?;
136
+ std::fs::copy(&knowledge_src, knowledge_dir.join("graph.sqlite"))?;
137
+ }
138
+
139
+ tracing::info!("Unpacked specialist '{}' (base: {})", manifest.name, manifest.base_model);
140
+ Ok(manifest)
141
+ }
142
+
143
+ /// List all .synapse bundles in a directory
144
+ pub fn list_bundles(dir: &Path) -> Result<Vec<(PathBuf, SynapseManifest)>> {
145
+ let mut bundles = Vec::new();
146
+
147
+ if !dir.exists() {
148
+ return Ok(bundles);
149
+ }
150
+
151
+ for entry in std::fs::read_dir(dir)?.flatten() {
152
+ let path = entry.path();
153
+ if path.is_dir() {
154
+ let manifest_path = path.join("manifest.json");
155
+ if manifest_path.exists() {
156
+ if let Ok(content) = std::fs::read_to_string(&manifest_path) {
157
+ if let Ok(manifest) = serde_json::from_str::<SynapseManifest>(&content) {
158
+ bundles.push((path, manifest));
159
+ }
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ Ok(bundles)
166
+ }
167
+
168
+ #[cfg(test)]
169
+ mod tests {
170
+ use super::*;
171
+
172
+ #[test]
173
+ fn test_pack_and_unpack() {
174
+ let tmp = tempfile::tempdir().unwrap();
175
+ let models_dir = tmp.path().join("models");
176
+ let adapters_dir = tmp.path().join("adapters");
177
+ let output = tmp.path().join("test_specialist.synapse");
178
+ std::fs::create_dir_all(&models_dir).unwrap();
179
+ std::fs::create_dir_all(&adapters_dir).unwrap();
180
+
181
+ let manifest = SynapseManifest::new("test_specialist", "Qwen3-3B");
182
+
183
+ // Pack
184
+ pack(&manifest, &models_dir, &adapters_dir, None, &output).unwrap();
185
+ assert!(output.join("manifest.json").exists());
186
+
187
+ // Unpack into new dirs
188
+ let new_models = tmp.path().join("new_models");
189
+ let new_adapters = tmp.path().join("new_adapters");
190
+ let unpacked = unpack(&output, &new_models, &new_adapters).unwrap();
191
+ assert_eq!(unpacked.name, "test_specialist");
192
+ assert_eq!(unpacked.base_model, "Qwen3-3B");
193
+ }
194
+
195
+ #[test]
196
+ fn test_list_bundles() {
197
+ let tmp = tempfile::tempdir().unwrap();
198
+
199
+ // Create two bundles
200
+ for name in ["alpha", "beta"] {
201
+ let bundle_dir = tmp.path().join(format!("{name}.synapse"));
202
+ std::fs::create_dir_all(&bundle_dir).unwrap();
203
+ let manifest = SynapseManifest::new(name, "Qwen3-3B");
204
+ std::fs::write(
205
+ bundle_dir.join("manifest.json"),
206
+ serde_json::to_string(&manifest).unwrap(),
207
+ ).unwrap();
208
+ }
209
+
210
+ let bundles = list_bundles(tmp.path()).unwrap();
211
+ assert_eq!(bundles.len(), 2);
212
+ }
213
+ }