@advicenxt/sbp-server 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/benchmarks/bench.ts +272 -0
- package/dist/auth.d.ts +20 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +69 -0
- package/dist/auth.js.map +1 -0
- package/dist/blackboard.d.ts +84 -0
- package/dist/blackboard.d.ts.map +1 -0
- package/dist/blackboard.js +502 -0
- package/dist/blackboard.js.map +1 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +102 -0
- package/dist/cli.js.map +1 -0
- package/dist/conditions.d.ts +27 -0
- package/dist/conditions.d.ts.map +1 -0
- package/dist/conditions.js +240 -0
- package/dist/conditions.js.map +1 -0
- package/dist/decay.d.ts +21 -0
- package/dist/decay.d.ts.map +1 -0
- package/dist/decay.js +88 -0
- package/dist/decay.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/rate-limiter.d.ts +21 -0
- package/dist/rate-limiter.d.ts.map +1 -0
- package/dist/rate-limiter.js +75 -0
- package/dist/rate-limiter.js.map +1 -0
- package/dist/server.d.ts +63 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +401 -0
- package/dist/server.js.map +1 -0
- package/dist/store.d.ts +54 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +55 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +247 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +26 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +296 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +205 -0
- package/dist/validation.js.map +1 -0
- package/eslint.config.js +26 -0
- package/package.json +66 -0
- package/src/auth.ts +89 -0
- package/src/blackboard.test.ts +287 -0
- package/src/blackboard.ts +651 -0
- package/src/cli.ts +116 -0
- package/src/conditions.ts +305 -0
- package/src/conformance.test.ts +686 -0
- package/src/decay.ts +103 -0
- package/src/index.ts +24 -0
- package/src/rate-limiter.ts +104 -0
- package/src/server.integration.test.ts +436 -0
- package/src/server.ts +500 -0
- package/src/store.ts +108 -0
- package/src/types.ts +314 -0
- package/src/validation.ts +251 -0
- package/tsconfig.eslint.json +5 -0
- package/tsconfig.json +20 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SBP Server CLI
|
|
4
|
+
* Streamable HTTP with SSE transport
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { SbpServer } from "./server.js";
|
|
8
|
+
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
|
|
11
|
+
function parseArgs(args: string[]): Record<string, string | boolean> {
|
|
12
|
+
const result: Record<string, string | boolean> = {};
|
|
13
|
+
|
|
14
|
+
for (let i = 0; i < args.length; i++) {
|
|
15
|
+
const arg = args[i];
|
|
16
|
+
|
|
17
|
+
if (arg.startsWith("--")) {
|
|
18
|
+
const key = arg.slice(2);
|
|
19
|
+
const next = args[i + 1];
|
|
20
|
+
|
|
21
|
+
if (next && !next.startsWith("--")) {
|
|
22
|
+
result[key] = next;
|
|
23
|
+
i++;
|
|
24
|
+
} else {
|
|
25
|
+
result[key] = true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function printHelp(): void {
|
|
34
|
+
console.log(`
|
|
35
|
+
SBP Server - Stigmergic Blackboard Protocol
|
|
36
|
+
|
|
37
|
+
Usage:
|
|
38
|
+
sbp-server [options]
|
|
39
|
+
|
|
40
|
+
Options:
|
|
41
|
+
--port <number> Port to listen on (default: 3000)
|
|
42
|
+
--host <string> Host to bind to (default: localhost)
|
|
43
|
+
--log Enable request logging
|
|
44
|
+
--api-key <keys> Comma-separated API keys for authentication
|
|
45
|
+
--rate-limit <n> Max requests per minute per agent (default: off)
|
|
46
|
+
--help Show this help message
|
|
47
|
+
|
|
48
|
+
Transport:
|
|
49
|
+
Streamable HTTP with SSE (Server-Sent Events)
|
|
50
|
+
- POST /sbp Client -> Server messages
|
|
51
|
+
- GET /sbp Server -> Client triggers (SSE stream)
|
|
52
|
+
|
|
53
|
+
Authentication:
|
|
54
|
+
When --api-key is set, all requests must include:
|
|
55
|
+
Authorization: Bearer <api-key>
|
|
56
|
+
API keys can also be set via SBP_API_KEYS env variable.
|
|
57
|
+
|
|
58
|
+
Examples:
|
|
59
|
+
sbp-server
|
|
60
|
+
sbp-server --port 8080
|
|
61
|
+
sbp-server --host 0.0.0.0 --port 3000 --log
|
|
62
|
+
sbp-server --api-key key1,key2 --rate-limit 500
|
|
63
|
+
`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function main(): Promise<void> {
|
|
67
|
+
const options = parseArgs(args);
|
|
68
|
+
|
|
69
|
+
if (options.help) {
|
|
70
|
+
printHelp();
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Parse API keys from CLI arg or env variable
|
|
75
|
+
const apiKeyArg = (options["api-key"] as string) || process.env.SBP_API_KEYS || "";
|
|
76
|
+
const apiKeys = apiKeyArg
|
|
77
|
+
.split(",")
|
|
78
|
+
.map((k) => k.trim())
|
|
79
|
+
.filter(Boolean);
|
|
80
|
+
|
|
81
|
+
// Parse rate limit
|
|
82
|
+
const rateLimitMax = options["rate-limit"]
|
|
83
|
+
? parseInt(options["rate-limit"] as string, 10)
|
|
84
|
+
: undefined;
|
|
85
|
+
|
|
86
|
+
const server = new SbpServer({
|
|
87
|
+
port: options.port ? parseInt(options.port as string, 10) : 3000,
|
|
88
|
+
host: (options.host as string) || "localhost",
|
|
89
|
+
logging: !!options.log,
|
|
90
|
+
auth: apiKeys.length > 0
|
|
91
|
+
? { apiKeys, requireAuth: true }
|
|
92
|
+
: undefined,
|
|
93
|
+
rateLimit: rateLimitMax
|
|
94
|
+
? { maxRequests: rateLimitMax, windowMs: 60000 }
|
|
95
|
+
: undefined,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Graceful shutdown
|
|
99
|
+
const shutdown = async () => {
|
|
100
|
+
console.log("\n[SBP] Shutting down...");
|
|
101
|
+
await server.stop();
|
|
102
|
+
process.exit(0);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
process.on("SIGINT", shutdown);
|
|
106
|
+
process.on("SIGTERM", shutdown);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
await server.start();
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error("[SBP] Failed to start server:", err);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
main();
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scent condition evaluation
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
Pheromone,
|
|
7
|
+
PheromoneSnapshot,
|
|
8
|
+
ScentCondition,
|
|
9
|
+
ThresholdCondition,
|
|
10
|
+
CompositeCondition,
|
|
11
|
+
RateCondition,
|
|
12
|
+
PatternCondition,
|
|
13
|
+
TagFilter,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
import { computeIntensity, isEvaporated } from "./decay.js";
|
|
16
|
+
|
|
17
|
+
export interface EvaluationContext {
|
|
18
|
+
pheromones: Pheromone[];
|
|
19
|
+
now: number;
|
|
20
|
+
emissionHistory?: Array<{ trail: string; type: string; timestamp: number }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface EvaluationResult {
|
|
24
|
+
met: boolean;
|
|
25
|
+
value: number;
|
|
26
|
+
matchingPheromoneIds: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Evaluate a scent condition against the current environment
|
|
31
|
+
*/
|
|
32
|
+
export function evaluateCondition(
|
|
33
|
+
condition: ScentCondition,
|
|
34
|
+
ctx: EvaluationContext
|
|
35
|
+
): EvaluationResult {
|
|
36
|
+
switch (condition.type) {
|
|
37
|
+
case "threshold":
|
|
38
|
+
return evaluateThreshold(condition, ctx);
|
|
39
|
+
case "composite":
|
|
40
|
+
return evaluateComposite(condition, ctx);
|
|
41
|
+
case "rate":
|
|
42
|
+
return evaluateRate(condition, ctx);
|
|
43
|
+
case "pattern":
|
|
44
|
+
return evaluatePattern(condition, ctx);
|
|
45
|
+
default:
|
|
46
|
+
return { met: false, value: 0, matchingPheromoneIds: [] };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Evaluate a threshold condition
|
|
52
|
+
*/
|
|
53
|
+
function evaluateThreshold(
|
|
54
|
+
condition: ThresholdCondition,
|
|
55
|
+
ctx: EvaluationContext
|
|
56
|
+
): EvaluationResult {
|
|
57
|
+
const { pheromones, now } = ctx;
|
|
58
|
+
|
|
59
|
+
// Filter matching pheromones
|
|
60
|
+
const matching = pheromones.filter((p) => {
|
|
61
|
+
// Trail must match
|
|
62
|
+
if (p.trail !== condition.trail) return false;
|
|
63
|
+
|
|
64
|
+
// Type must match (or wildcard)
|
|
65
|
+
if (condition.signal_type !== "*" && p.type !== condition.signal_type) return false;
|
|
66
|
+
|
|
67
|
+
// Must not be evaporated
|
|
68
|
+
if (isEvaporated(p, now)) return false;
|
|
69
|
+
|
|
70
|
+
// Tag filter
|
|
71
|
+
if (condition.tags && !matchTags(p.tags, condition.tags)) return false;
|
|
72
|
+
|
|
73
|
+
return true;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Compute aggregate value
|
|
77
|
+
let aggValue: number;
|
|
78
|
+
const intensities = matching.map((p) => computeIntensity(p, now));
|
|
79
|
+
|
|
80
|
+
switch (condition.aggregation) {
|
|
81
|
+
case "sum":
|
|
82
|
+
aggValue = intensities.reduce((a, b) => a + b, 0);
|
|
83
|
+
break;
|
|
84
|
+
case "max":
|
|
85
|
+
aggValue = intensities.length > 0 ? Math.max(...intensities) : 0;
|
|
86
|
+
break;
|
|
87
|
+
case "avg":
|
|
88
|
+
aggValue = intensities.length > 0
|
|
89
|
+
? intensities.reduce((a, b) => a + b, 0) / intensities.length
|
|
90
|
+
: 0;
|
|
91
|
+
break;
|
|
92
|
+
case "count":
|
|
93
|
+
aggValue = matching.length;
|
|
94
|
+
break;
|
|
95
|
+
case "any":
|
|
96
|
+
aggValue = matching.length > 0 ? 1 : 0;
|
|
97
|
+
break;
|
|
98
|
+
default:
|
|
99
|
+
aggValue = 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Compare
|
|
103
|
+
const met = compare(aggValue, condition.operator, condition.value);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
met,
|
|
107
|
+
value: aggValue,
|
|
108
|
+
matchingPheromoneIds: matching.map((p) => p.id),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Evaluate a composite condition (AND, OR, NOT)
|
|
114
|
+
*/
|
|
115
|
+
function evaluateComposite(
|
|
116
|
+
condition: CompositeCondition,
|
|
117
|
+
ctx: EvaluationContext
|
|
118
|
+
): EvaluationResult {
|
|
119
|
+
if (condition.conditions.length === 0) {
|
|
120
|
+
return { met: false, value: 0, matchingPheromoneIds: [] };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const results = condition.conditions.map((c) => evaluateCondition(c, ctx));
|
|
124
|
+
const allPheromoneIds = [...new Set(results.flatMap((r) => r.matchingPheromoneIds))];
|
|
125
|
+
|
|
126
|
+
let met: boolean;
|
|
127
|
+
switch (condition.operator) {
|
|
128
|
+
case "and":
|
|
129
|
+
met = results.every((r) => r.met);
|
|
130
|
+
break;
|
|
131
|
+
case "or":
|
|
132
|
+
met = results.some((r) => r.met);
|
|
133
|
+
break;
|
|
134
|
+
case "not":
|
|
135
|
+
met = !results[0].met;
|
|
136
|
+
break;
|
|
137
|
+
default:
|
|
138
|
+
met = false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
met,
|
|
143
|
+
value: results.filter((r) => r.met).length,
|
|
144
|
+
matchingPheromoneIds: allPheromoneIds,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Evaluate a rate condition
|
|
150
|
+
*/
|
|
151
|
+
function evaluateRate(
|
|
152
|
+
condition: RateCondition,
|
|
153
|
+
ctx: EvaluationContext
|
|
154
|
+
): EvaluationResult {
|
|
155
|
+
const { emissionHistory = [], now } = ctx;
|
|
156
|
+
|
|
157
|
+
// Filter emissions in the window
|
|
158
|
+
const windowStart = now - condition.window_ms;
|
|
159
|
+
const relevantEmissions = emissionHistory.filter(
|
|
160
|
+
(e) =>
|
|
161
|
+
e.trail === condition.trail &&
|
|
162
|
+
(condition.signal_type === "*" || e.type === condition.signal_type) &&
|
|
163
|
+
e.timestamp >= windowStart
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
let value: number;
|
|
167
|
+
if (condition.metric === "emissions_per_second") {
|
|
168
|
+
const windowSeconds = condition.window_ms / 1000;
|
|
169
|
+
value = relevantEmissions.length / windowSeconds;
|
|
170
|
+
} else {
|
|
171
|
+
// intensity_delta would require tracking intensity over time
|
|
172
|
+
// For now, approximate with emission count
|
|
173
|
+
value = relevantEmissions.length;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const met = compare(value, condition.operator, condition.value);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
met,
|
|
180
|
+
value,
|
|
181
|
+
matchingPheromoneIds: [],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Evaluate a pattern condition
|
|
187
|
+
* Checks if a sequence of pheromone emissions occurred within a time window
|
|
188
|
+
*/
|
|
189
|
+
function evaluatePattern(
|
|
190
|
+
condition: PatternCondition,
|
|
191
|
+
ctx: EvaluationContext
|
|
192
|
+
): EvaluationResult {
|
|
193
|
+
const { emissionHistory = [], now } = ctx;
|
|
194
|
+
const { sequence, window_ms, ordered = true } = condition;
|
|
195
|
+
|
|
196
|
+
// Filter emissions within the window
|
|
197
|
+
const windowStart = now - window_ms;
|
|
198
|
+
const relevant = emissionHistory.filter((e) => e.timestamp >= windowStart);
|
|
199
|
+
|
|
200
|
+
if (relevant.length === 0 || sequence.length === 0) {
|
|
201
|
+
return { met: false, value: 0, matchingPheromoneIds: [] };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (ordered) {
|
|
205
|
+
// Ordered: each step must appear after the previous one
|
|
206
|
+
let searchFrom = 0;
|
|
207
|
+
let matchCount = 0;
|
|
208
|
+
|
|
209
|
+
for (const step of sequence) {
|
|
210
|
+
let found = false;
|
|
211
|
+
for (let i = searchFrom; i < relevant.length; i++) {
|
|
212
|
+
const emission = relevant[i];
|
|
213
|
+
if (
|
|
214
|
+
emission.trail === step.trail &&
|
|
215
|
+
emission.type === step.signal_type
|
|
216
|
+
) {
|
|
217
|
+
found = true;
|
|
218
|
+
searchFrom = i + 1;
|
|
219
|
+
matchCount++;
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (!found) break;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
met: matchCount === sequence.length,
|
|
228
|
+
value: matchCount / sequence.length,
|
|
229
|
+
matchingPheromoneIds: [],
|
|
230
|
+
};
|
|
231
|
+
} else {
|
|
232
|
+
// Unordered: all steps must appear in any order
|
|
233
|
+
const remaining = [...relevant];
|
|
234
|
+
let matchCount = 0;
|
|
235
|
+
|
|
236
|
+
for (const step of sequence) {
|
|
237
|
+
const idx = remaining.findIndex(
|
|
238
|
+
(e) => e.trail === step.trail && e.type === step.signal_type
|
|
239
|
+
);
|
|
240
|
+
if (idx >= 0) {
|
|
241
|
+
remaining.splice(idx, 1);
|
|
242
|
+
matchCount++;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
met: matchCount === sequence.length,
|
|
248
|
+
value: matchCount / sequence.length,
|
|
249
|
+
matchingPheromoneIds: [],
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Match tags against a filter
|
|
256
|
+
*/
|
|
257
|
+
function matchTags(tags: string[], filter: TagFilter): boolean {
|
|
258
|
+
if (filter.any && filter.any.length > 0) {
|
|
259
|
+
if (!filter.any.some((t) => tags.includes(t))) return false;
|
|
260
|
+
}
|
|
261
|
+
if (filter.all && filter.all.length > 0) {
|
|
262
|
+
if (!filter.all.every((t) => tags.includes(t))) return false;
|
|
263
|
+
}
|
|
264
|
+
if (filter.none && filter.none.length > 0) {
|
|
265
|
+
if (filter.none.some((t) => tags.includes(t))) return false;
|
|
266
|
+
}
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Compare two values with an operator
|
|
272
|
+
*/
|
|
273
|
+
function compare(a: number, op: string, b: number): boolean {
|
|
274
|
+
switch (op) {
|
|
275
|
+
case ">=":
|
|
276
|
+
return a >= b;
|
|
277
|
+
case ">":
|
|
278
|
+
return a > b;
|
|
279
|
+
case "<=":
|
|
280
|
+
return a <= b;
|
|
281
|
+
case "<":
|
|
282
|
+
return a < b;
|
|
283
|
+
case "==":
|
|
284
|
+
return a === b;
|
|
285
|
+
case "!=":
|
|
286
|
+
return a !== b;
|
|
287
|
+
default:
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Create a snapshot of a pheromone for trigger payloads
|
|
294
|
+
*/
|
|
295
|
+
export function createSnapshot(pheromone: Pheromone, now: number): PheromoneSnapshot {
|
|
296
|
+
return {
|
|
297
|
+
id: pheromone.id,
|
|
298
|
+
trail: pheromone.trail,
|
|
299
|
+
type: pheromone.type,
|
|
300
|
+
current_intensity: computeIntensity(pheromone, now),
|
|
301
|
+
payload: pheromone.payload,
|
|
302
|
+
age_ms: now - pheromone.emitted_at,
|
|
303
|
+
tags: pheromone.tags,
|
|
304
|
+
};
|
|
305
|
+
}
|