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.
- package/CONTRIBUTING.md +187 -0
- package/Cargo.lock +3976 -0
- package/Cargo.toml +10 -0
- package/LICENSE +190 -0
- package/PROGRESS.md +151 -0
- package/README.md +514 -0
- package/TEST_LOG.md +220 -0
- package/config/default.yaml +36 -0
- package/crates/synapse/Cargo.toml +70 -0
- package/crates/synapse/src/cli/bench.rs +44 -0
- package/crates/synapse/src/cli/eval.rs +395 -0
- package/crates/synapse/src/cli/export.rs +45 -0
- package/crates/synapse/src/cli/hub.rs +179 -0
- package/crates/synapse/src/cli/import.rs +35 -0
- package/crates/synapse/src/cli/learn.rs +53 -0
- package/crates/synapse/src/cli/mod.rs +10 -0
- package/crates/synapse/src/cli/models.rs +36 -0
- package/crates/synapse/src/cli/pull.rs +60 -0
- package/crates/synapse/src/cli/status.rs +52 -0
- package/crates/synapse/src/cli/train.rs +99 -0
- package/crates/synapse/src/config.rs +220 -0
- package/crates/synapse/src/dashboard.rs +281 -0
- package/crates/synapse/src/format/manifest.rs +57 -0
- package/crates/synapse/src/format/mod.rs +4 -0
- package/crates/synapse/src/format/packer.rs +213 -0
- package/crates/synapse/src/inference/engine.rs +361 -0
- package/crates/synapse/src/inference/kv_cache.rs +97 -0
- package/crates/synapse/src/inference/lora.rs +166 -0
- package/crates/synapse/src/inference/mod.rs +9 -0
- package/crates/synapse/src/inference/model.rs +167 -0
- package/crates/synapse/src/inference/sampler.rs +133 -0
- package/crates/synapse/src/inference/speculative.rs +153 -0
- package/crates/synapse/src/learn/cloud_fallback.rs +186 -0
- package/crates/synapse/src/learn/engine.rs +109 -0
- package/crates/synapse/src/learn/mod.rs +5 -0
- package/crates/synapse/src/main.rs +185 -0
- package/crates/synapse/src/memory/extractor.rs +201 -0
- package/crates/synapse/src/memory/graph.rs +332 -0
- package/crates/synapse/src/memory/hallucination.rs +259 -0
- package/crates/synapse/src/memory/mod.rs +7 -0
- package/crates/synapse/src/openai.rs +232 -0
- package/crates/synapse/src/server.rs +166 -0
- package/crates/synapse/src/streaming.rs +80 -0
- package/crates/synapse/src/swarm/coordinator.rs +198 -0
- package/crates/synapse/src/swarm/mod.rs +8 -0
- package/crates/synapse/src/swarm/orchestrator.rs +225 -0
- package/crates/synapse/src/swarm/pool.rs +64 -0
- package/crates/synapse/src/swarm/spawner.rs +199 -0
- package/crates/synapse/src/swarm/synthesizer.rs +26 -0
- package/crates/synapse/src/vram/manager.rs +67 -0
- package/crates/synapse/src/vram/mod.rs +3 -0
- package/docker-compose.yml +19 -0
- package/install.sh +311 -0
- package/package.json +36 -0
- package/python/Dockerfile.learn +18 -0
- package/python/requirements.txt +11 -0
- package/python/synapse_learn/__init__.py +0 -0
- package/python/synapse_learn/datasets.py +233 -0
- package/python/synapse_learn/real_eval.py +616 -0
- package/python/synapse_learn/server.py +431 -0
- package/python/synapse_learn/train_base.py +672 -0
- 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,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
|
+
}
|