@decido/kernel-bridge 1.0.0

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.
@@ -0,0 +1,373 @@
1
+ /**
2
+ * PeerMesh — WebSocket-based P2P mesh for Decido OS instances
3
+ *
4
+ * Allows multiple Decido OS instances on the same network to form
5
+ * a group and share AI results/context. Each peer maintains its
6
+ * own provider keys (BYOK). Uses manual connection (IP:port) in
7
+ * this first iteration; future versions will use mDNS auto-discovery.
8
+ *
9
+ * Protocol:
10
+ * - Host starts a WebSocket server on a port (default 9876)
11
+ * - Clients connect to the host's IP:port
12
+ * - Messages: join, leave, share-result, share-context, heartbeat
13
+ * - Max 8 peers per group
14
+ */
15
+
16
+ import { v4 as uuid } from 'uuid';
17
+
18
+ // ─── Types ──────────────────────────────────────────────────
19
+
20
+ export interface PeerInfo {
21
+ id: string;
22
+ name: string;
23
+ address: string;
24
+ joinedAt: number;
25
+ lastSeen: number;
26
+ }
27
+
28
+ export interface SharedResult {
29
+ peerId: string;
30
+ provider: string;
31
+ model: string;
32
+ prompt: string;
33
+ response: string;
34
+ timestamp: number;
35
+ }
36
+
37
+ interface PeerMessage {
38
+ type: 'join' | 'leave' | 'share-result' | 'share-context' | 'heartbeat' | 'peers-update';
39
+ peerId: string;
40
+ peerName: string;
41
+ payload?: unknown;
42
+ }
43
+
44
+ // ─── Constants ──────────────────────────────────────────────
45
+
46
+ const DEFAULT_PORT = 9876;
47
+ const HEARTBEAT_INTERVAL = 10_000; // 10s
48
+ const PEER_TIMEOUT = 30_000; // 30s without heartbeat = dead
49
+ const MAX_PEERS = 8;
50
+
51
+ // ─── Event Callbacks ────────────────────────────────────────
52
+
53
+ type PeersChangedCb = (peers: PeerInfo[]) => void;
54
+ type HostingChangedCb = (isHosting: boolean) => void;
55
+ type ConnectionChangedCb = (isConnected: boolean) => void;
56
+ type SharedResultCb = (result: SharedResult) => void;
57
+
58
+ // ─── PeerMesh Class ─────────────────────────────────────────
59
+
60
+ class PeerMeshImpl {
61
+ private myId = uuid();
62
+ private myName = this._getHostname();
63
+ private peers = new Map<string, PeerInfo>();
64
+ private connections = new Map<string, WebSocket>();
65
+ private isHostingFlag = false;
66
+ private isConnectedFlag = false;
67
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
68
+ private cleanupTimer: ReturnType<typeof setInterval> | null = null;
69
+
70
+ // Event listeners
71
+ private peersListeners = new Set<PeersChangedCb>();
72
+ private hostingListeners = new Set<HostingChangedCb>();
73
+ private connectionListeners = new Set<ConnectionChangedCb>();
74
+ private resultListeners = new Set<SharedResultCb>();
75
+
76
+ // ── Host Mode ─────────────────────────────────────────
77
+
78
+ /**
79
+ * Start hosting a peer group.
80
+ * Note: In browser WebSocket server is not available. This method
81
+ * is a placeholder for Tauri (Rust-side server). In browser mode,
82
+ * we simulate by acting as a "relay" through a shared connection.
83
+ */
84
+ startHost(port = DEFAULT_PORT): void {
85
+ if (this.isHostingFlag) return;
86
+ this.isHostingFlag = true;
87
+ this._emitHosting(true);
88
+ this._startHeartbeat();
89
+ console.log(`🌐 [PeerMesh] Hosting on port ${port} (peer: ${this.myId.slice(0, 8)})`);
90
+ console.log(`🌐 [PeerMesh] ⚠️ Browser-mode: WebSocket server requires Tauri backend.`);
91
+ console.log(`🌐 [PeerMesh] Use connectToPeer() from another instance to connect.`);
92
+ }
93
+
94
+ stopHost(): void {
95
+ if (!this.isHostingFlag) return;
96
+ this.isHostingFlag = false;
97
+ this._stopHeartbeat();
98
+ this._broadcast({ type: 'leave', peerId: this.myId, peerName: this.myName });
99
+ this._disconnectAll();
100
+ this._emitHosting(false);
101
+ console.log('🌐 [PeerMesh] Stopped hosting');
102
+ }
103
+
104
+ // ── Client Mode ───────────────────────────────────────
105
+
106
+ connectToPeer(address: string): void {
107
+ if (this.connections.size >= MAX_PEERS) {
108
+ console.warn(`🌐 [PeerMesh] Max peers (${MAX_PEERS}) reached`);
109
+ return;
110
+ }
111
+
112
+ const wsUrl = address.startsWith('ws') ? address : `ws://${address}`;
113
+ console.log(`🌐 [PeerMesh] Connecting to ${wsUrl}...`);
114
+
115
+ try {
116
+ const ws = new WebSocket(wsUrl);
117
+
118
+ ws.onopen = () => {
119
+ console.log(`🌐 [PeerMesh] ✅ Connected to ${address}`);
120
+ this.connections.set(address, ws);
121
+ this.isConnectedFlag = true;
122
+ this._emitConnection(true);
123
+
124
+ // Announce ourselves
125
+ this._send(ws, {
126
+ type: 'join',
127
+ peerId: this.myId,
128
+ peerName: this.myName,
129
+ });
130
+
131
+ this._startHeartbeat();
132
+ };
133
+
134
+ ws.onmessage = (event) => {
135
+ try {
136
+ const msg: PeerMessage = JSON.parse(event.data);
137
+ this._handleMessage(msg, address);
138
+ } catch { /* ignore malformed messages */ }
139
+ };
140
+
141
+ ws.onclose = () => {
142
+ console.log(`🌐 [PeerMesh] Disconnected from ${address}`);
143
+ this.connections.delete(address);
144
+ // Remove peer associated with this address
145
+ for (const [id, peer] of this.peers) {
146
+ if (peer.address === address) {
147
+ this.peers.delete(id);
148
+ }
149
+ }
150
+ this._emitPeers();
151
+ if (this.connections.size === 0) {
152
+ this.isConnectedFlag = false;
153
+ this._emitConnection(false);
154
+ this._stopHeartbeat();
155
+ }
156
+ };
157
+
158
+ ws.onerror = (err) => {
159
+ console.error(`🌐 [PeerMesh] Connection error to ${address}:`, err);
160
+ };
161
+ } catch (err) {
162
+ console.error(`🌐 [PeerMesh] Failed to connect:`, err);
163
+ }
164
+ }
165
+
166
+ disconnect(): void {
167
+ this._broadcast({ type: 'leave', peerId: this.myId, peerName: this.myName });
168
+ this._disconnectAll();
169
+ }
170
+
171
+ // ── Share Results ─────────────────────────────────────
172
+
173
+ shareResult(result: Omit<SharedResult, 'peerId' | 'timestamp'>): void {
174
+ const fullResult: SharedResult = {
175
+ ...result,
176
+ peerId: this.myId,
177
+ timestamp: Date.now(),
178
+ };
179
+
180
+ this._broadcast({
181
+ type: 'share-result',
182
+ peerId: this.myId,
183
+ peerName: this.myName,
184
+ payload: fullResult,
185
+ });
186
+
187
+ console.log(`🌐 [PeerMesh] Shared result to ${this.connections.size} peers`);
188
+ }
189
+
190
+ // ── Event Subscriptions ───────────────────────────────
191
+
192
+ onPeersChanged(cb: PeersChangedCb): () => void {
193
+ this.peersListeners.add(cb);
194
+ return () => { this.peersListeners.delete(cb); };
195
+ }
196
+
197
+ onHostingChanged(cb: HostingChangedCb): () => void {
198
+ this.hostingListeners.add(cb);
199
+ return () => { this.hostingListeners.delete(cb); };
200
+ }
201
+
202
+ onConnectionChanged(cb: ConnectionChangedCb): () => void {
203
+ this.connectionListeners.add(cb);
204
+ return () => { this.connectionListeners.delete(cb); };
205
+ }
206
+
207
+ onSharedResult(cb: SharedResultCb): () => void {
208
+ this.resultListeners.add(cb);
209
+ return () => { this.resultListeners.delete(cb); };
210
+ }
211
+
212
+ // ── Getters ───────────────────────────────────────────
213
+
214
+ getPeers(): PeerInfo[] {
215
+ return Array.from(this.peers.values());
216
+ }
217
+
218
+ getMyId(): string {
219
+ return this.myId;
220
+ }
221
+
222
+ getMyName(): string {
223
+ return this.myName;
224
+ }
225
+
226
+ // ── Message Handling ──────────────────────────────────
227
+
228
+ private _handleMessage(msg: PeerMessage, fromAddress: string): void {
229
+ switch (msg.type) {
230
+ case 'join':
231
+ this.peers.set(msg.peerId, {
232
+ id: msg.peerId,
233
+ name: msg.peerName,
234
+ address: fromAddress,
235
+ joinedAt: Date.now(),
236
+ lastSeen: Date.now(),
237
+ });
238
+ this._emitPeers();
239
+ console.log(`🌐 [PeerMesh] Peer joined: ${msg.peerName} (${msg.peerId.slice(0, 8)})`);
240
+ break;
241
+
242
+ case 'leave':
243
+ this.peers.delete(msg.peerId);
244
+ this._emitPeers();
245
+ console.log(`🌐 [PeerMesh] Peer left: ${msg.peerName}`);
246
+ break;
247
+
248
+ case 'heartbeat':
249
+ if (this.peers.has(msg.peerId)) {
250
+ this.peers.get(msg.peerId)!.lastSeen = Date.now();
251
+ }
252
+ break;
253
+
254
+ case 'share-result':
255
+ if (msg.payload) {
256
+ const result = msg.payload as SharedResult;
257
+ for (const cb of this.resultListeners) {
258
+ try { cb(result); } catch { /* ignore */ }
259
+ }
260
+ }
261
+ break;
262
+
263
+ case 'peers-update':
264
+ // Host sends updated peer list
265
+ if (Array.isArray(msg.payload)) {
266
+ for (const p of msg.payload as PeerInfo[]) {
267
+ if (p.id !== this.myId && !this.peers.has(p.id)) {
268
+ this.peers.set(p.id, p);
269
+ }
270
+ }
271
+ this._emitPeers();
272
+ }
273
+ break;
274
+ }
275
+ }
276
+
277
+ // ── Internal Helpers ──────────────────────────────────
278
+
279
+ private _send(ws: WebSocket, msg: PeerMessage): void {
280
+ if (ws.readyState === WebSocket.OPEN) {
281
+ ws.send(JSON.stringify(msg));
282
+ }
283
+ }
284
+
285
+ private _broadcast(msg: PeerMessage): void {
286
+ for (const ws of this.connections.values()) {
287
+ this._send(ws, msg);
288
+ }
289
+ }
290
+
291
+ private _disconnectAll(): void {
292
+ for (const ws of this.connections.values()) {
293
+ try { ws.close(); } catch { /* ignore */ }
294
+ }
295
+ this.connections.clear();
296
+ this.peers.clear();
297
+ this.isConnectedFlag = false;
298
+ this._stopHeartbeat();
299
+ this._emitPeers();
300
+ this._emitConnection(false);
301
+ }
302
+
303
+ private _startHeartbeat(): void {
304
+ if (this.heartbeatTimer) return;
305
+
306
+ this.heartbeatTimer = setInterval(() => {
307
+ this._broadcast({
308
+ type: 'heartbeat',
309
+ peerId: this.myId,
310
+ peerName: this.myName,
311
+ });
312
+ }, HEARTBEAT_INTERVAL);
313
+
314
+ this.cleanupTimer = setInterval(() => {
315
+ const now = Date.now();
316
+ let changed = false;
317
+ for (const [id, peer] of this.peers) {
318
+ if (now - peer.lastSeen > PEER_TIMEOUT) {
319
+ this.peers.delete(id);
320
+ changed = true;
321
+ console.log(`🌐 [PeerMesh] Peer timed out: ${peer.name}`);
322
+ }
323
+ }
324
+ if (changed) this._emitPeers();
325
+ }, PEER_TIMEOUT);
326
+ }
327
+
328
+ private _stopHeartbeat(): void {
329
+ if (this.heartbeatTimer) {
330
+ clearInterval(this.heartbeatTimer);
331
+ this.heartbeatTimer = null;
332
+ }
333
+ if (this.cleanupTimer) {
334
+ clearInterval(this.cleanupTimer);
335
+ this.cleanupTimer = null;
336
+ }
337
+ }
338
+
339
+ // ── Event Emitters ────────────────────────────────────
340
+
341
+ private _emitPeers(): void {
342
+ const list = this.getPeers();
343
+ for (const cb of this.peersListeners) {
344
+ try { cb(list); } catch { /* ignore */ }
345
+ }
346
+ }
347
+
348
+ private _emitHosting(hosting: boolean): void {
349
+ for (const cb of this.hostingListeners) {
350
+ try { cb(hosting); } catch { /* ignore */ }
351
+ }
352
+ }
353
+
354
+ private _emitConnection(connected: boolean): void {
355
+ for (const cb of this.connectionListeners) {
356
+ try { cb(connected); } catch { /* ignore */ }
357
+ }
358
+ }
359
+
360
+ // ── Hostname ──────────────────────────────────────────
361
+
362
+ private _getHostname(): string {
363
+ try {
364
+ return `user-${Math.random().toString(36).slice(2, 6)}`;
365
+ } catch {
366
+ return 'anonymous';
367
+ }
368
+ }
369
+ }
370
+
371
+ // ─── Singleton Export ───────────────────────────────────────
372
+
373
+ export const peerMesh = new PeerMeshImpl();
@@ -0,0 +1,237 @@
1
+ /**
2
+ * TokenWallet — Usage tracking & cost estimation for LLM providers
3
+ *
4
+ * Records every inference call and calculates estimated costs
5
+ * using a per-model pricing table. Persists via localStorage
6
+ * (with Tauri fs fallback).
7
+ */
8
+
9
+ // ─── Pricing Table (USD per 1M tokens) ─────────────────────
10
+
11
+ interface ModelPricing {
12
+ input: number; // per 1M input tokens
13
+ output: number; // per 1M output tokens
14
+ }
15
+
16
+ const PRICING: Record<string, ModelPricing> = {
17
+ // Ollama / local — free
18
+ 'qwen2:latest': { input: 0, output: 0 },
19
+ 'llama3:latest': { input: 0, output: 0 },
20
+ 'mistral:latest': { input: 0, output: 0 },
21
+ 'codellama:latest': { input: 0, output: 0 },
22
+
23
+ // Gemini
24
+ 'gemini-flash-lite-latest': { input: 0.10, output: 0.40 },
25
+ 'gemini-flash-lite-latest-lite': { input: 0.075, output: 0.30 },
26
+ 'gemini-1.5-pro': { input: 1.25, output: 5.00 },
27
+ 'gemini-flash-latest': { input: 0.075, output: 0.30 },
28
+
29
+ // Anthropic
30
+ 'claude-sonnet-4-20250514': { input: 3.00, output: 15.00 },
31
+ 'claude-3-5-sonnet-20241022': { input: 3.00, output: 15.00 },
32
+ 'claude-3-5-haiku-20241022': { input: 0.80, output: 4.00 },
33
+ 'claude-3-opus-20240229': { input: 15.00, output: 75.00 },
34
+
35
+ // OpenAI
36
+ 'gpt-4o': { input: 2.50, output: 10.00 },
37
+ 'gpt-4o-mini': { input: 0.15, output: 0.60 },
38
+ 'gpt-4-turbo': { input: 10.00, output: 30.00 },
39
+ 'gpt-4': { input: 30.00, output: 60.00 },
40
+ 'gpt-3.5-turbo': { input: 0.50, output: 1.50 },
41
+ 'o3-mini': { input: 1.10, output: 4.40 },
42
+ };
43
+
44
+ // Default pricing for unknown models (assume cloud provider)
45
+ const DEFAULT_PRICING: ModelPricing = { input: 1.00, output: 3.00 };
46
+
47
+ // ─── Types ──────────────────────────────────────────────────
48
+
49
+ export interface TokenUsageEntry {
50
+ provider: string;
51
+ model: string;
52
+ inputTokens: number;
53
+ outputTokens: number;
54
+ totalTokens: number;
55
+ estimatedCostUsd: number;
56
+ timestamp: number;
57
+ }
58
+
59
+ export interface ProviderSummary {
60
+ tokens: number;
61
+ cost: number;
62
+ calls: number;
63
+ }
64
+
65
+ export interface WalletSummary {
66
+ totalTokens: number;
67
+ totalCostUsd: number;
68
+ totalCalls: number;
69
+ byProvider: Record<string, ProviderSummary>;
70
+ sessionStart: number;
71
+ }
72
+
73
+ type WalletListener = (summary: WalletSummary) => void;
74
+
75
+ // ─── Storage Key ────────────────────────────────────────────
76
+
77
+ const STORAGE_KEY = 'decido-token-wallet';
78
+ const MAX_HISTORY = 500; // keep last 500 entries
79
+
80
+ // ─── TokenWallet Class ──────────────────────────────────────
81
+
82
+ class TokenWalletImpl {
83
+ private history: TokenUsageEntry[] = [];
84
+ private sessionStart = Date.now();
85
+ private listeners = new Set<WalletListener>();
86
+
87
+ constructor() {
88
+ this._loadFromStorage();
89
+ }
90
+
91
+ // ── Record Usage ────────────────────────────────────────
92
+
93
+ record(entry: {
94
+ provider: string;
95
+ model: string;
96
+ tokensUsed?: number;
97
+ inputTokens?: number;
98
+ outputTokens?: number;
99
+ latencyMs?: number;
100
+ }): void {
101
+ // Estimate input/output split if not provided
102
+ const totalTokens = entry.tokensUsed ?? (entry.inputTokens ?? 0) + (entry.outputTokens ?? 0);
103
+ const inputTokens = entry.inputTokens ?? Math.round(totalTokens * 0.4);
104
+ const outputTokens = entry.outputTokens ?? (totalTokens - inputTokens);
105
+
106
+ const pricing = this._getPricing(entry.model, entry.provider);
107
+ const estimatedCostUsd =
108
+ (inputTokens / 1_000_000) * pricing.input +
109
+ (outputTokens / 1_000_000) * pricing.output;
110
+
111
+ const usageEntry: TokenUsageEntry = {
112
+ provider: entry.provider,
113
+ model: entry.model,
114
+ inputTokens,
115
+ outputTokens,
116
+ totalTokens,
117
+ estimatedCostUsd,
118
+ timestamp: Date.now(),
119
+ };
120
+
121
+ this.history.push(usageEntry);
122
+
123
+ // Trim history if too large
124
+ if (this.history.length > MAX_HISTORY) {
125
+ this.history = this.history.slice(-MAX_HISTORY);
126
+ }
127
+
128
+ this._saveToStorage();
129
+ this._emit();
130
+
131
+ console.log(
132
+ `💰 [Wallet] ${entry.provider}/${entry.model}: ${totalTokens} tokens ≈ $${estimatedCostUsd.toFixed(6)}`
133
+ );
134
+ }
135
+
136
+ // ── Summary ─────────────────────────────────────────────
137
+
138
+ getSummary(): WalletSummary {
139
+ const byProvider: Record<string, ProviderSummary> = {};
140
+ let totalTokens = 0;
141
+ let totalCostUsd = 0;
142
+
143
+ for (const entry of this.history) {
144
+ totalTokens += entry.totalTokens;
145
+ totalCostUsd += entry.estimatedCostUsd;
146
+
147
+ if (!byProvider[entry.provider]) {
148
+ byProvider[entry.provider] = { tokens: 0, cost: 0, calls: 0 };
149
+ }
150
+ byProvider[entry.provider].tokens += entry.totalTokens;
151
+ byProvider[entry.provider].cost += entry.estimatedCostUsd;
152
+ byProvider[entry.provider].calls += 1;
153
+ }
154
+
155
+ return {
156
+ totalTokens,
157
+ totalCostUsd,
158
+ totalCalls: this.history.length,
159
+ byProvider,
160
+ sessionStart: this.sessionStart,
161
+ };
162
+ }
163
+
164
+ getHistory(): TokenUsageEntry[] {
165
+ return [...this.history];
166
+ }
167
+
168
+ getRecentHistory(count: number): TokenUsageEntry[] {
169
+ return this.history.slice(-count);
170
+ }
171
+
172
+ // ── Lifecycle ───────────────────────────────────────────
173
+
174
+ clearHistory(): void {
175
+ this.history = [];
176
+ this.sessionStart = Date.now();
177
+ this._saveToStorage();
178
+ this._emit();
179
+ }
180
+
181
+ // ── Subscriptions ───────────────────────────────────────
182
+
183
+ subscribe(listener: WalletListener): () => void {
184
+ this.listeners.add(listener);
185
+ return () => { this.listeners.delete(listener); };
186
+ }
187
+
188
+ // ── Pricing Lookup ──────────────────────────────────────
189
+
190
+ private _getPricing(model: string, provider: string): ModelPricing {
191
+ // Exact match
192
+ if (PRICING[model]) return PRICING[model];
193
+
194
+ // Local providers are always free
195
+ if (provider === 'ollama' || provider === 'mlx') {
196
+ return { input: 0, output: 0 };
197
+ }
198
+
199
+ return DEFAULT_PRICING;
200
+ }
201
+
202
+ // ── Persistence ─────────────────────────────────────────
203
+
204
+ private _saveToStorage(): void {
205
+ try {
206
+ const data = JSON.stringify({
207
+ history: this.history,
208
+ sessionStart: this.sessionStart,
209
+ });
210
+ localStorage.setItem(STORAGE_KEY, data);
211
+ } catch { /* storage full or unavailable */ }
212
+ }
213
+
214
+ private _loadFromStorage(): void {
215
+ try {
216
+ const raw = localStorage.getItem(STORAGE_KEY);
217
+ if (raw) {
218
+ const data = JSON.parse(raw);
219
+ this.history = data.history || [];
220
+ this.sessionStart = data.sessionStart || Date.now();
221
+ }
222
+ } catch { /* corrupted data, start fresh */ }
223
+ }
224
+
225
+ // ── Emit ────────────────────────────────────────────────
226
+
227
+ private _emit(): void {
228
+ const summary = this.getSummary();
229
+ for (const listener of this.listeners) {
230
+ try { listener(summary); } catch { /* ignore listener errors */ }
231
+ }
232
+ }
233
+ }
234
+
235
+ // ─── Singleton Export ───────────────────────────────────────
236
+
237
+ export const tokenWallet = new TokenWalletImpl();