@advicenxt/sbp-client 0.1.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.
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@advicenxt/sbp-client",
3
+ "version": "0.1.0",
4
+ "description": "Stigmergic Blackboard Protocol - TypeScript/JavaScript Client",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "test": "vitest run",
17
+ "test:watch": "vitest",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "keywords": [
21
+ "sbp",
22
+ "stigmergy",
23
+ "blackboard",
24
+ "multi-agent",
25
+ "coordination",
26
+ "sse"
27
+ ],
28
+ "author": "SBP Contributors",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/AdviceNXT/sbp.git",
32
+ "directory": "packages/client-ts"
33
+ },
34
+ "homepage": "https://sbp.dev",
35
+ "bugs": {
36
+ "url": "https://github.com/AdviceNXT/sbp/issues"
37
+ },
38
+ "license": "MIT",
39
+ "devDependencies": {
40
+ "@types/node": "^20.11.0",
41
+ "typescript": "^5.3.3",
42
+ "vitest": "^1.2.0"
43
+ },
44
+ "peerDependencies": {
45
+ "typescript": ">=4.7"
46
+ },
47
+ "engines": {
48
+ "node": ">=18.0.0"
49
+ }
50
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { SbpClient, SbpAgent } from './index.js';
3
+
4
+ describe('SbpClient', () => {
5
+ it('should be instantiable', () => {
6
+ const client = new SbpClient();
7
+ expect(client).toBeDefined();
8
+ });
9
+ });
10
+
11
+ describe('SbpAgent', () => {
12
+ it('should be instantiable', () => {
13
+ const agent = new SbpAgent('test-agent');
14
+ expect(agent).toBeDefined();
15
+ });
16
+ });
package/src/index.ts ADDED
@@ -0,0 +1,526 @@
1
+ /**
2
+ * SBP Client - TypeScript/JavaScript SDK
3
+ * Streamable HTTP with SSE (following MCP transport patterns)
4
+ */
5
+
6
+ // ============================================================================
7
+ // TYPES
8
+ // ============================================================================
9
+
10
+ export interface ExponentialDecay {
11
+ type: "exponential";
12
+ half_life_ms: number;
13
+ }
14
+
15
+ export interface LinearDecay {
16
+ type: "linear";
17
+ rate_per_ms: number;
18
+ }
19
+
20
+ export interface ImmortalDecay {
21
+ type: "immortal";
22
+ }
23
+
24
+ export type DecayModel = ExponentialDecay | LinearDecay | ImmortalDecay;
25
+
26
+ export interface PheromoneSnapshot {
27
+ id: string;
28
+ trail: string;
29
+ type: string;
30
+ current_intensity: number;
31
+ payload: Record<string, unknown>;
32
+ age_ms: number;
33
+ tags: string[];
34
+ }
35
+
36
+ export interface ThresholdCondition {
37
+ type: "threshold";
38
+ trail: string;
39
+ signal_type: string;
40
+ aggregation: "sum" | "max" | "avg" | "count" | "any";
41
+ operator: ">=" | ">" | "<=" | "<" | "==" | "!=";
42
+ value: number;
43
+ }
44
+
45
+ export interface CompositeCondition {
46
+ type: "composite";
47
+ operator: "and" | "or" | "not";
48
+ conditions: ScentCondition[];
49
+ }
50
+
51
+ export interface RateCondition {
52
+ type: "rate";
53
+ trail: string;
54
+ signal_type: string;
55
+ metric: "emissions_per_second" | "intensity_delta";
56
+ window_ms: number;
57
+ operator: ">=" | ">" | "<=" | "<";
58
+ value: number;
59
+ }
60
+
61
+ export type ScentCondition = ThresholdCondition | CompositeCondition | RateCondition;
62
+
63
+ export interface EmitResult {
64
+ pheromone_id: string;
65
+ action: "created" | "reinforced" | "replaced" | "merged";
66
+ previous_intensity?: number;
67
+ new_intensity: number;
68
+ }
69
+
70
+ export interface AggregateStats {
71
+ count: number;
72
+ sum_intensity: number;
73
+ max_intensity: number;
74
+ avg_intensity: number;
75
+ }
76
+
77
+ export interface SniffResult {
78
+ timestamp: number;
79
+ pheromones: PheromoneSnapshot[];
80
+ aggregates: Record<string, AggregateStats>;
81
+ }
82
+
83
+ export interface RegisterScentResult {
84
+ scent_id: string;
85
+ status: "registered" | "updated";
86
+ current_condition_state: {
87
+ met: boolean;
88
+ };
89
+ }
90
+
91
+ export interface TriggerPayload {
92
+ scent_id: string;
93
+ triggered_at: number;
94
+ condition_snapshot: Record<string, { value: number; pheromone_ids: string[] }>;
95
+ context_pheromones: PheromoneSnapshot[];
96
+ activation_payload: Record<string, unknown>;
97
+ }
98
+
99
+ // ============================================================================
100
+ // HELPER FUNCTIONS
101
+ // ============================================================================
102
+
103
+ export function exponentialDecay(halfLifeMs: number): ExponentialDecay {
104
+ return { type: "exponential", half_life_ms: halfLifeMs };
105
+ }
106
+
107
+ export function linearDecay(ratePerMs: number): LinearDecay {
108
+ return { type: "linear", rate_per_ms: ratePerMs };
109
+ }
110
+
111
+ export function immortal(): ImmortalDecay {
112
+ return { type: "immortal" };
113
+ }
114
+
115
+ export function threshold(
116
+ trail: string,
117
+ signalType: string,
118
+ operator: ThresholdCondition["operator"],
119
+ value: number,
120
+ aggregation: ThresholdCondition["aggregation"] = "max"
121
+ ): ThresholdCondition {
122
+ return {
123
+ type: "threshold",
124
+ trail,
125
+ signal_type: signalType,
126
+ aggregation,
127
+ operator,
128
+ value,
129
+ };
130
+ }
131
+
132
+ export function and(...conditions: ScentCondition[]): CompositeCondition {
133
+ return { type: "composite", operator: "and", conditions };
134
+ }
135
+
136
+ export function or(...conditions: ScentCondition[]): CompositeCondition {
137
+ return { type: "composite", operator: "or", conditions };
138
+ }
139
+
140
+ export function not(condition: ScentCondition): CompositeCondition {
141
+ return { type: "composite", operator: "not", conditions: [condition] };
142
+ }
143
+
144
+ // ============================================================================
145
+ // CLIENT
146
+ // ============================================================================
147
+
148
+ export interface SbpClientOptions {
149
+ url?: string;
150
+ agentId?: string;
151
+ timeout?: number;
152
+ }
153
+
154
+ export interface EmitOptions {
155
+ decay?: DecayModel;
156
+ payload?: Record<string, unknown>;
157
+ tags?: string[];
158
+ mergeStrategy?: "reinforce" | "replace" | "max" | "add" | "new";
159
+ }
160
+
161
+ export interface SniffOptions {
162
+ trails?: string[];
163
+ types?: string[];
164
+ minIntensity?: number;
165
+ limit?: number;
166
+ includeEvaporated?: boolean;
167
+ }
168
+
169
+ export interface RegisterScentOptions {
170
+ cooldownMs?: number;
171
+ activationPayload?: Record<string, unknown>;
172
+ triggerMode?: "level" | "edge_rising" | "edge_falling";
173
+ contextTrails?: string[];
174
+ }
175
+
176
+ type TriggerHandler = (payload: TriggerPayload) => void | Promise<void>;
177
+
178
+ export class SbpClient {
179
+ private url: string;
180
+ private agentId: string;
181
+ private timeout: number;
182
+ private sessionId: string | null = null;
183
+ private sseController: AbortController | null = null;
184
+ private sseHandlers = new Map<string, TriggerHandler>();
185
+ private requestId = 0;
186
+ private lastEventId: string | null = null;
187
+
188
+ constructor(options: SbpClientOptions = {}) {
189
+ this.url = options.url ?? "http://localhost:3000";
190
+ this.agentId = options.agentId ?? `agent-${Math.random().toString(36).slice(2, 10)}`;
191
+ this.timeout = options.timeout ?? 30000;
192
+ }
193
+
194
+ // ==========================================================================
195
+ // JSON-RPC via POST
196
+ // ==========================================================================
197
+
198
+ private async rpc<T>(method: string, params: Record<string, unknown>): Promise<T> {
199
+ const headers: Record<string, string> = {
200
+ "Content-Type": "application/json",
201
+ Accept: "application/json, text/event-stream",
202
+ "Sbp-Protocol-Version": "0.1",
203
+ "Sbp-Agent-Id": this.agentId,
204
+ };
205
+
206
+ if (this.sessionId) {
207
+ headers["Sbp-Session-Id"] = this.sessionId;
208
+ }
209
+
210
+ const response = await fetch(`${this.url}/sbp`, {
211
+ method: "POST",
212
+ headers,
213
+ body: JSON.stringify({
214
+ jsonrpc: "2.0",
215
+ id: ++this.requestId,
216
+ method,
217
+ params,
218
+ }),
219
+ signal: AbortSignal.timeout(this.timeout),
220
+ });
221
+
222
+ // Capture session ID
223
+ const newSessionId = response.headers.get("Sbp-Session-Id");
224
+ if (newSessionId) {
225
+ this.sessionId = newSessionId;
226
+ }
227
+
228
+ const data = await response.json() as { result?: T; error?: { code: number; message: string } };
229
+
230
+ if (data.error) {
231
+ throw new Error(`SBP Error ${data.error.code}: ${data.error.message}`);
232
+ }
233
+
234
+ return data.result as T;
235
+ }
236
+
237
+ // ==========================================================================
238
+ // EMIT
239
+ // ==========================================================================
240
+
241
+ async emit(
242
+ trail: string,
243
+ type: string,
244
+ intensity: number,
245
+ options: EmitOptions = {}
246
+ ): Promise<EmitResult> {
247
+ return this.rpc<EmitResult>("sbp/emit", {
248
+ trail,
249
+ type,
250
+ intensity,
251
+ decay: options.decay,
252
+ payload: options.payload,
253
+ tags: options.tags,
254
+ merge_strategy: options.mergeStrategy ?? "reinforce",
255
+ source_agent: this.agentId,
256
+ });
257
+ }
258
+
259
+ // ==========================================================================
260
+ // SNIFF
261
+ // ==========================================================================
262
+
263
+ async sniff(options: SniffOptions = {}): Promise<SniffResult> {
264
+ return this.rpc<SniffResult>("sbp/sniff", {
265
+ trails: options.trails,
266
+ types: options.types,
267
+ min_intensity: options.minIntensity ?? 0,
268
+ limit: options.limit ?? 100,
269
+ include_evaporated: options.includeEvaporated ?? false,
270
+ });
271
+ }
272
+
273
+ // ==========================================================================
274
+ // REGISTER_SCENT
275
+ // ==========================================================================
276
+
277
+ async registerScent(
278
+ scentId: string,
279
+ condition: ScentCondition,
280
+ options: RegisterScentOptions = {}
281
+ ): Promise<RegisterScentResult> {
282
+ return this.rpc<RegisterScentResult>("sbp/register_scent", {
283
+ scent_id: scentId,
284
+ agent_endpoint: `sse://${this.agentId}`,
285
+ condition,
286
+ cooldown_ms: options.cooldownMs ?? 0,
287
+ activation_payload: options.activationPayload,
288
+ trigger_mode: options.triggerMode ?? "level",
289
+ context_trails: options.contextTrails,
290
+ });
291
+ }
292
+
293
+ // ==========================================================================
294
+ // DEREGISTER_SCENT
295
+ // ==========================================================================
296
+
297
+ async deregisterScent(scentId: string): Promise<{ scent_id: string; status: string }> {
298
+ return this.rpc("sbp/deregister_scent", { scent_id: scentId });
299
+ }
300
+
301
+ // ==========================================================================
302
+ // INSPECT
303
+ // ==========================================================================
304
+
305
+ async inspect(include?: string[]): Promise<Record<string, unknown>> {
306
+ return this.rpc("sbp/inspect", { include: include ?? ["trails", "scents", "stats"] });
307
+ }
308
+
309
+ // ==========================================================================
310
+ // SSE SUBSCRIPTIONS
311
+ // ==========================================================================
312
+
313
+ async subscribe(scentId: string, handler: TriggerHandler): Promise<void> {
314
+ // Register handler
315
+ this.sseHandlers.set(scentId, handler);
316
+
317
+ // Tell server we want this scent's triggers
318
+ await this.rpc("sbp/subscribe", { scent_id: scentId });
319
+
320
+ // Start SSE listener if not already running
321
+ if (!this.sseController) {
322
+ this.startSSE();
323
+ }
324
+ }
325
+
326
+ async unsubscribe(scentId: string): Promise<void> {
327
+ this.sseHandlers.delete(scentId);
328
+ await this.rpc("sbp/unsubscribe", { scent_id: scentId });
329
+
330
+ // Stop SSE if no more handlers
331
+ if (this.sseHandlers.size === 0 && this.sseController) {
332
+ this.sseController.abort();
333
+ this.sseController = null;
334
+ }
335
+ }
336
+
337
+ private async startSSE(): Promise<void> {
338
+ this.sseController = new AbortController();
339
+
340
+ const headers: Record<string, string> = {
341
+ Accept: "text/event-stream",
342
+ "Sbp-Protocol-Version": "0.1",
343
+ "Sbp-Agent-Id": this.agentId,
344
+ };
345
+
346
+ if (this.sessionId) {
347
+ headers["Sbp-Session-Id"] = this.sessionId;
348
+ }
349
+
350
+ if (this.lastEventId) {
351
+ headers["Last-Event-ID"] = this.lastEventId;
352
+ }
353
+
354
+ try {
355
+ const response = await fetch(`${this.url}/sbp`, {
356
+ method: "GET",
357
+ headers,
358
+ signal: this.sseController.signal,
359
+ });
360
+
361
+ if (!response.ok || !response.body) {
362
+ console.error("[SBP] Failed to open SSE stream");
363
+ return;
364
+ }
365
+
366
+ // Capture session ID
367
+ const newSessionId = response.headers.get("Sbp-Session-Id");
368
+ if (newSessionId) {
369
+ this.sessionId = newSessionId;
370
+ }
371
+
372
+ const reader = response.body.getReader();
373
+ const decoder = new TextDecoder();
374
+ let buffer = "";
375
+ let eventType = "";
376
+ let eventData = "";
377
+ let eventId = "";
378
+
379
+ while (true) {
380
+ const { done, value } = await reader.read();
381
+ if (done) break;
382
+
383
+ buffer += decoder.decode(value, { stream: true });
384
+ const lines = buffer.split("\n");
385
+ buffer = lines.pop() || "";
386
+
387
+ for (const line of lines) {
388
+ if (line.startsWith("event:")) {
389
+ eventType = line.slice(6).trim();
390
+ } else if (line.startsWith("id:")) {
391
+ eventId = line.slice(3).trim();
392
+ this.lastEventId = eventId;
393
+ } else if (line.startsWith("data:")) {
394
+ eventData = line.slice(5).trim();
395
+ } else if (line === "" && eventData) {
396
+ // End of event
397
+ this.handleSSEEvent(eventType, eventData);
398
+ eventType = "";
399
+ eventData = "";
400
+ eventId = "";
401
+ }
402
+ // Ignore comment lines starting with ":"
403
+ }
404
+ }
405
+ } catch (err) {
406
+ if ((err as Error).name !== "AbortError") {
407
+ console.error("[SBP] SSE error:", err);
408
+ // Reconnect after delay
409
+ setTimeout(() => {
410
+ if (this.sseHandlers.size > 0) {
411
+ this.startSSE();
412
+ }
413
+ }, 1000);
414
+ }
415
+ }
416
+ }
417
+
418
+ private handleSSEEvent(eventType: string, data: string): void {
419
+ try {
420
+ if (eventType === "message") {
421
+ const msg = JSON.parse(data);
422
+ if (msg.method === "sbp/trigger") {
423
+ const payload = msg.params as TriggerPayload;
424
+ const handler = this.sseHandlers.get(payload.scent_id);
425
+ if (handler) {
426
+ Promise.resolve(handler(payload)).catch((err) => {
427
+ console.error("[SBP] Trigger handler error:", err);
428
+ });
429
+ }
430
+ }
431
+ }
432
+ } catch (err) {
433
+ console.error("[SBP] SSE event parse error:", err);
434
+ }
435
+ }
436
+
437
+ async close(): Promise<void> {
438
+ if (this.sseController) {
439
+ this.sseController.abort();
440
+ this.sseController = null;
441
+ }
442
+ }
443
+ }
444
+
445
+ // ============================================================================
446
+ // AGENT HELPER
447
+ // ============================================================================
448
+
449
+ export interface AgentOptions extends SbpClientOptions {
450
+ onError?: (error: Error) => void;
451
+ }
452
+
453
+ export class SbpAgent {
454
+ private client: SbpClient;
455
+ private scents: Array<{
456
+ scentId: string;
457
+ condition: ScentCondition;
458
+ handler: TriggerHandler;
459
+ options: RegisterScentOptions;
460
+ }> = [];
461
+
462
+ constructor(agentId: string, options: AgentOptions = {}) {
463
+ this.client = new SbpClient({ ...options, agentId });
464
+ }
465
+
466
+ when(
467
+ trail: string,
468
+ signalType: string,
469
+ operator: ThresholdCondition["operator"],
470
+ value: number,
471
+ handler: TriggerHandler,
472
+ options: RegisterScentOptions = {}
473
+ ): this {
474
+ const scentId = `${trail}/${signalType}`;
475
+ this.scents.push({
476
+ scentId,
477
+ condition: threshold(trail, signalType, operator, value),
478
+ handler,
479
+ options,
480
+ });
481
+ return this;
482
+ }
483
+
484
+ onScent(
485
+ scentId: string,
486
+ condition: ScentCondition,
487
+ handler: TriggerHandler,
488
+ options: RegisterScentOptions = {}
489
+ ): this {
490
+ this.scents.push({ scentId, condition, handler, options });
491
+ return this;
492
+ }
493
+
494
+ async emit(
495
+ trail: string,
496
+ type: string,
497
+ intensity: number,
498
+ options?: EmitOptions
499
+ ): Promise<EmitResult> {
500
+ return this.client.emit(trail, type, intensity, options);
501
+ }
502
+
503
+ async sniff(options?: SniffOptions): Promise<SniffResult> {
504
+ return this.client.sniff(options);
505
+ }
506
+
507
+ async run(): Promise<void> {
508
+ for (const { scentId, condition, handler, options } of this.scents) {
509
+ await this.client.registerScent(scentId, condition, options);
510
+ await this.client.subscribe(scentId, handler);
511
+ console.log(`[SBP Agent] Registered: ${scentId}`);
512
+ }
513
+
514
+ console.log(`[SBP Agent] Running with ${this.scents.length} scents`);
515
+
516
+ // Keep alive
517
+ await new Promise(() => {});
518
+ }
519
+
520
+ async stop(): Promise<void> {
521
+ for (const { scentId } of this.scents) {
522
+ await this.client.deregisterScent(scentId);
523
+ }
524
+ await this.client.close();
525
+ }
526
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2022"],
7
+ "outDir": "./dist",
8
+ "rootDir": "./src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true
16
+ },
17
+ "include": ["src/**/*"],
18
+ "exclude": ["node_modules", "dist"]
19
+ }