@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
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SBP Blackboard - Core State Management
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { v7 as uuidv7 } from "uuid";
|
|
6
|
+
import type { PheromoneStore } from "./store.js";
|
|
7
|
+
import { MemoryStore } from "./store.js";
|
|
8
|
+
import type {
|
|
9
|
+
Pheromone,
|
|
10
|
+
PheromoneSnapshot,
|
|
11
|
+
Scent,
|
|
12
|
+
DecayModel,
|
|
13
|
+
EmitParams,
|
|
14
|
+
EmitResult,
|
|
15
|
+
SniffParams,
|
|
16
|
+
SniffResult,
|
|
17
|
+
AggregateStats,
|
|
18
|
+
RegisterScentParams,
|
|
19
|
+
RegisterScentResult,
|
|
20
|
+
DeregisterScentParams,
|
|
21
|
+
DeregisterScentResult,
|
|
22
|
+
EvaporateParams,
|
|
23
|
+
EvaporateResult,
|
|
24
|
+
InspectParams,
|
|
25
|
+
InspectResult,
|
|
26
|
+
TriggerPayload,
|
|
27
|
+
TagFilter,
|
|
28
|
+
} from "./types.js";
|
|
29
|
+
import { computeIntensity, isEvaporated, defaultDecay } from "./decay.js";
|
|
30
|
+
import { evaluateCondition, createSnapshot } from "./conditions.js";
|
|
31
|
+
import { createHash } from "crypto";
|
|
32
|
+
|
|
33
|
+
export interface BlackboardOptions {
|
|
34
|
+
/** Interval for scent evaluation in ms (default: 100) */
|
|
35
|
+
evaluationInterval?: number;
|
|
36
|
+
/** Default decay model for new pheromones */
|
|
37
|
+
defaultDecay?: DecayModel;
|
|
38
|
+
/** Default TTL floor for evaporation */
|
|
39
|
+
defaultTtlFloor?: number;
|
|
40
|
+
/** Maximum pheromones before GC triggers */
|
|
41
|
+
maxPheromones?: number;
|
|
42
|
+
/** Enable emission history tracking for rate conditions */
|
|
43
|
+
trackEmissionHistory?: boolean;
|
|
44
|
+
/** How long to keep emission history (ms) */
|
|
45
|
+
emissionHistoryWindow?: number;
|
|
46
|
+
/** Pluggable pheromone storage backend (default: MemoryStore) */
|
|
47
|
+
store?: PheromoneStore;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TriggerHandler {
|
|
51
|
+
(payload: TriggerPayload): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class Blackboard {
|
|
55
|
+
private store: PheromoneStore;
|
|
56
|
+
private scents = new Map<string, Scent>();
|
|
57
|
+
private triggerHandlers = new Map<string, TriggerHandler>();
|
|
58
|
+
private emissionHistory: Array<{ trail: string; type: string; timestamp: number }> = [];
|
|
59
|
+
private evaluationTimer: ReturnType<typeof setInterval> | null = null;
|
|
60
|
+
private startTime = Date.now();
|
|
61
|
+
|
|
62
|
+
private options: Omit<Required<BlackboardOptions>, "store">;
|
|
63
|
+
|
|
64
|
+
constructor(options: BlackboardOptions = {}) {
|
|
65
|
+
this.store = options.store ?? new MemoryStore();
|
|
66
|
+
this.options = {
|
|
67
|
+
evaluationInterval: options.evaluationInterval ?? 100,
|
|
68
|
+
defaultDecay: options.defaultDecay ?? defaultDecay(),
|
|
69
|
+
defaultTtlFloor: options.defaultTtlFloor ?? 0.01,
|
|
70
|
+
maxPheromones: options.maxPheromones ?? 100000,
|
|
71
|
+
trackEmissionHistory: options.trackEmissionHistory ?? true,
|
|
72
|
+
emissionHistoryWindow: options.emissionHistoryWindow ?? 60000,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ==========================================================================
|
|
77
|
+
// EMIT
|
|
78
|
+
// ==========================================================================
|
|
79
|
+
|
|
80
|
+
emit(params: EmitParams): EmitResult {
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
const {
|
|
83
|
+
trail,
|
|
84
|
+
type,
|
|
85
|
+
intensity,
|
|
86
|
+
decay = this.options.defaultDecay,
|
|
87
|
+
payload = {},
|
|
88
|
+
tags = [],
|
|
89
|
+
merge_strategy = "reinforce",
|
|
90
|
+
source_agent,
|
|
91
|
+
} = params;
|
|
92
|
+
|
|
93
|
+
// Validate intensity
|
|
94
|
+
const clampedIntensity = Math.max(0, Math.min(1, intensity));
|
|
95
|
+
|
|
96
|
+
// Track emission for rate conditions
|
|
97
|
+
if (this.options.trackEmissionHistory) {
|
|
98
|
+
this.emissionHistory.push({ trail, type, timestamp: now });
|
|
99
|
+
this.pruneEmissionHistory(now);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Generate payload hash for matching
|
|
103
|
+
const payloadHash = this.hashPayload(payload);
|
|
104
|
+
|
|
105
|
+
// Find existing pheromone for merge
|
|
106
|
+
let existing: Pheromone | undefined;
|
|
107
|
+
if (merge_strategy !== "new") {
|
|
108
|
+
for (const p of this.store.values()) {
|
|
109
|
+
if (
|
|
110
|
+
p.trail === trail &&
|
|
111
|
+
p.type === type &&
|
|
112
|
+
this.hashPayload(p.payload) === payloadHash &&
|
|
113
|
+
!isEvaporated(p, now)
|
|
114
|
+
) {
|
|
115
|
+
existing = p;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (existing) {
|
|
122
|
+
const previousIntensity = computeIntensity(existing, now);
|
|
123
|
+
let action: EmitResult["action"] = "reinforced";
|
|
124
|
+
|
|
125
|
+
switch (merge_strategy) {
|
|
126
|
+
case "reinforce":
|
|
127
|
+
existing.initial_intensity = clampedIntensity;
|
|
128
|
+
existing.last_reinforced_at = now;
|
|
129
|
+
action = "reinforced";
|
|
130
|
+
break;
|
|
131
|
+
|
|
132
|
+
case "replace":
|
|
133
|
+
existing.initial_intensity = clampedIntensity;
|
|
134
|
+
existing.last_reinforced_at = now;
|
|
135
|
+
existing.payload = payload;
|
|
136
|
+
existing.tags = tags;
|
|
137
|
+
if (source_agent) existing.source_agent = source_agent;
|
|
138
|
+
action = "replaced";
|
|
139
|
+
break;
|
|
140
|
+
|
|
141
|
+
case "max":
|
|
142
|
+
existing.initial_intensity = Math.max(previousIntensity, clampedIntensity);
|
|
143
|
+
existing.last_reinforced_at = now;
|
|
144
|
+
action = "merged";
|
|
145
|
+
break;
|
|
146
|
+
|
|
147
|
+
case "add":
|
|
148
|
+
existing.initial_intensity = Math.min(1, previousIntensity + clampedIntensity);
|
|
149
|
+
existing.last_reinforced_at = now;
|
|
150
|
+
action = "merged";
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
pheromone_id: existing.id,
|
|
156
|
+
action,
|
|
157
|
+
previous_intensity: previousIntensity,
|
|
158
|
+
new_intensity: computeIntensity(existing, now),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Create new pheromone
|
|
163
|
+
const id = uuidv7();
|
|
164
|
+
const pheromone: Pheromone = {
|
|
165
|
+
id,
|
|
166
|
+
trail,
|
|
167
|
+
type,
|
|
168
|
+
emitted_at: now,
|
|
169
|
+
last_reinforced_at: now,
|
|
170
|
+
initial_intensity: clampedIntensity,
|
|
171
|
+
decay_model: decay,
|
|
172
|
+
payload,
|
|
173
|
+
source_agent,
|
|
174
|
+
tags,
|
|
175
|
+
ttl_floor: this.options.defaultTtlFloor,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
this.store.set(id, pheromone);
|
|
179
|
+
|
|
180
|
+
// Trigger GC if needed
|
|
181
|
+
if (this.store.size > this.options.maxPheromones) {
|
|
182
|
+
this.gc();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
pheromone_id: id,
|
|
187
|
+
action: "created",
|
|
188
|
+
new_intensity: clampedIntensity,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ==========================================================================
|
|
193
|
+
// SNIFF
|
|
194
|
+
// ==========================================================================
|
|
195
|
+
|
|
196
|
+
sniff(params: SniffParams = {}): SniffResult {
|
|
197
|
+
const now = Date.now();
|
|
198
|
+
const {
|
|
199
|
+
trails,
|
|
200
|
+
types,
|
|
201
|
+
min_intensity = 0,
|
|
202
|
+
max_age_ms,
|
|
203
|
+
tags,
|
|
204
|
+
limit = 100,
|
|
205
|
+
include_evaporated = false,
|
|
206
|
+
} = params;
|
|
207
|
+
|
|
208
|
+
const results: PheromoneSnapshot[] = [];
|
|
209
|
+
const aggregates = new Map<string, { count: number; sum: number; max: number }>();
|
|
210
|
+
|
|
211
|
+
for (const p of this.store.values()) {
|
|
212
|
+
// Filter by trail
|
|
213
|
+
if (trails && trails.length > 0 && !trails.includes(p.trail)) continue;
|
|
214
|
+
|
|
215
|
+
// Filter by type
|
|
216
|
+
if (types && types.length > 0 && !types.includes(p.type)) continue;
|
|
217
|
+
|
|
218
|
+
const intensity = computeIntensity(p, now);
|
|
219
|
+
|
|
220
|
+
// Filter evaporated
|
|
221
|
+
if (!include_evaporated && intensity < p.ttl_floor) continue;
|
|
222
|
+
|
|
223
|
+
// Filter by min intensity
|
|
224
|
+
if (intensity < min_intensity) continue;
|
|
225
|
+
|
|
226
|
+
// Filter by max age
|
|
227
|
+
if (max_age_ms !== undefined && now - p.emitted_at > max_age_ms) continue;
|
|
228
|
+
|
|
229
|
+
// Filter by tags
|
|
230
|
+
if (tags && !this.matchTags(p.tags, tags)) continue;
|
|
231
|
+
|
|
232
|
+
results.push(createSnapshot(p, now));
|
|
233
|
+
|
|
234
|
+
// Aggregate
|
|
235
|
+
const key = `${p.trail}/${p.type}`;
|
|
236
|
+
const agg = aggregates.get(key) || { count: 0, sum: 0, max: 0 };
|
|
237
|
+
agg.count++;
|
|
238
|
+
agg.sum += intensity;
|
|
239
|
+
agg.max = Math.max(agg.max, intensity);
|
|
240
|
+
aggregates.set(key, agg);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Sort by intensity descending
|
|
244
|
+
results.sort((a, b) => b.current_intensity - a.current_intensity);
|
|
245
|
+
|
|
246
|
+
// Build aggregates result
|
|
247
|
+
const aggregatesResult: Record<string, AggregateStats> = {};
|
|
248
|
+
for (const [key, agg] of aggregates) {
|
|
249
|
+
aggregatesResult[key] = {
|
|
250
|
+
count: agg.count,
|
|
251
|
+
sum_intensity: agg.sum,
|
|
252
|
+
max_intensity: agg.max,
|
|
253
|
+
avg_intensity: agg.count > 0 ? agg.sum / agg.count : 0,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
timestamp: now,
|
|
259
|
+
pheromones: results.slice(0, limit),
|
|
260
|
+
aggregates: aggregatesResult,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ==========================================================================
|
|
265
|
+
// REGISTER_SCENT
|
|
266
|
+
// ==========================================================================
|
|
267
|
+
|
|
268
|
+
registerScent(params: RegisterScentParams): RegisterScentResult {
|
|
269
|
+
const {
|
|
270
|
+
scent_id,
|
|
271
|
+
agent_endpoint,
|
|
272
|
+
condition,
|
|
273
|
+
cooldown_ms = 0,
|
|
274
|
+
activation_payload = {},
|
|
275
|
+
trigger_mode = "level",
|
|
276
|
+
hysteresis = 0,
|
|
277
|
+
max_execution_ms = 30000,
|
|
278
|
+
context_trails,
|
|
279
|
+
} = params;
|
|
280
|
+
|
|
281
|
+
const isUpdate = this.scents.has(scent_id);
|
|
282
|
+
|
|
283
|
+
const scent: Scent = {
|
|
284
|
+
scent_id,
|
|
285
|
+
agent_endpoint,
|
|
286
|
+
condition,
|
|
287
|
+
cooldown_ms,
|
|
288
|
+
activation_payload,
|
|
289
|
+
trigger_mode,
|
|
290
|
+
hysteresis,
|
|
291
|
+
max_execution_ms,
|
|
292
|
+
last_triggered_at: null,
|
|
293
|
+
last_condition_met: false,
|
|
294
|
+
context_trails,
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
this.scents.set(scent_id, scent);
|
|
298
|
+
|
|
299
|
+
// Evaluate current state
|
|
300
|
+
const now = Date.now();
|
|
301
|
+
const evalResult = evaluateCondition(condition, {
|
|
302
|
+
pheromones: [...this.store.values()],
|
|
303
|
+
now,
|
|
304
|
+
emissionHistory: this.emissionHistory,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
scent_id,
|
|
309
|
+
status: isUpdate ? "updated" : "registered",
|
|
310
|
+
current_condition_state: {
|
|
311
|
+
met: evalResult.met,
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ==========================================================================
|
|
317
|
+
// DEREGISTER_SCENT
|
|
318
|
+
// ==========================================================================
|
|
319
|
+
|
|
320
|
+
deregisterScent(params: DeregisterScentParams): DeregisterScentResult {
|
|
321
|
+
const { scent_id } = params;
|
|
322
|
+
|
|
323
|
+
if (this.scents.has(scent_id)) {
|
|
324
|
+
this.scents.delete(scent_id);
|
|
325
|
+
this.triggerHandlers.delete(scent_id);
|
|
326
|
+
return { scent_id, status: "deregistered" };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return { scent_id, status: "not_found" };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ==========================================================================
|
|
333
|
+
// EVAPORATE
|
|
334
|
+
// ==========================================================================
|
|
335
|
+
|
|
336
|
+
evaporate(params: EvaporateParams = {}): EvaporateResult {
|
|
337
|
+
const now = Date.now();
|
|
338
|
+
const { trail, types, older_than_ms, below_intensity, tags } = params;
|
|
339
|
+
|
|
340
|
+
const toRemove: string[] = [];
|
|
341
|
+
const trailsAffected = new Set<string>();
|
|
342
|
+
|
|
343
|
+
for (const [id, p] of this.store.entries()) {
|
|
344
|
+
if (trail && p.trail !== trail) continue;
|
|
345
|
+
if (types && types.length > 0 && !types.includes(p.type)) continue;
|
|
346
|
+
if (older_than_ms !== undefined && now - p.emitted_at < older_than_ms) continue;
|
|
347
|
+
if (below_intensity !== undefined && computeIntensity(p, now) >= below_intensity) continue;
|
|
348
|
+
if (tags && !this.matchTags(p.tags, tags)) continue;
|
|
349
|
+
|
|
350
|
+
toRemove.push(id);
|
|
351
|
+
trailsAffected.add(p.trail);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
for (const id of toRemove) {
|
|
355
|
+
this.store.delete(id);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
evaporated_count: toRemove.length,
|
|
360
|
+
trails_affected: [...trailsAffected],
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ==========================================================================
|
|
365
|
+
// INSPECT
|
|
366
|
+
// ==========================================================================
|
|
367
|
+
|
|
368
|
+
inspect(params: InspectParams = {}): InspectResult {
|
|
369
|
+
const now = Date.now();
|
|
370
|
+
const include = params.include ?? ["trails", "scents", "stats"];
|
|
371
|
+
const result: InspectResult = { timestamp: now };
|
|
372
|
+
|
|
373
|
+
if (include.includes("trails")) {
|
|
374
|
+
const trailMap = new Map<string, { count: number; intensity: number }>();
|
|
375
|
+
|
|
376
|
+
for (const p of this.store.values()) {
|
|
377
|
+
if (isEvaporated(p, now)) continue;
|
|
378
|
+
|
|
379
|
+
const current = trailMap.get(p.trail) || { count: 0, intensity: 0 };
|
|
380
|
+
current.count++;
|
|
381
|
+
current.intensity += computeIntensity(p, now);
|
|
382
|
+
trailMap.set(p.trail, current);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
result.trails = [...trailMap.entries()].map(([name, data]) => ({
|
|
386
|
+
name,
|
|
387
|
+
pheromone_count: data.count,
|
|
388
|
+
total_intensity: data.intensity,
|
|
389
|
+
avg_intensity: data.count > 0 ? data.intensity / data.count : 0,
|
|
390
|
+
}));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (include.includes("scents")) {
|
|
394
|
+
result.scents = [...this.scents.values()].map((s) => ({
|
|
395
|
+
scent_id: s.scent_id,
|
|
396
|
+
agent_endpoint: s.agent_endpoint,
|
|
397
|
+
condition_met: s.last_condition_met,
|
|
398
|
+
in_cooldown: s.last_triggered_at
|
|
399
|
+
? now - s.last_triggered_at < s.cooldown_ms
|
|
400
|
+
: false,
|
|
401
|
+
last_triggered_at: s.last_triggered_at,
|
|
402
|
+
}));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (include.includes("stats")) {
|
|
406
|
+
let activeCount = 0;
|
|
407
|
+
for (const p of this.store.values()) {
|
|
408
|
+
if (!isEvaporated(p, now)) activeCount++;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
result.stats = {
|
|
412
|
+
total_pheromones: this.store.size,
|
|
413
|
+
active_pheromones: activeCount,
|
|
414
|
+
total_scents: this.scents.size,
|
|
415
|
+
uptime_ms: now - this.startTime,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return result;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ==========================================================================
|
|
423
|
+
// TRIGGER HANDLING
|
|
424
|
+
// ==========================================================================
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Register a local handler for a scent
|
|
428
|
+
*/
|
|
429
|
+
onTrigger(scentId: string, handler: TriggerHandler): void {
|
|
430
|
+
this.triggerHandlers.set(scentId, handler);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Remove a local trigger handler
|
|
435
|
+
*/
|
|
436
|
+
offTrigger(scentId: string): void {
|
|
437
|
+
this.triggerHandlers.delete(scentId);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ==========================================================================
|
|
441
|
+
// EVALUATION LOOP
|
|
442
|
+
// ==========================================================================
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Start the background scent evaluation loop
|
|
446
|
+
*/
|
|
447
|
+
start(): void {
|
|
448
|
+
if (this.evaluationTimer) return;
|
|
449
|
+
|
|
450
|
+
this.evaluationTimer = setInterval(() => {
|
|
451
|
+
this.evaluateScents().catch((err) => {
|
|
452
|
+
console.error("[SBP] Evaluation error:", err);
|
|
453
|
+
});
|
|
454
|
+
}, this.options.evaluationInterval);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Stop the background evaluation loop
|
|
459
|
+
*/
|
|
460
|
+
stop(): void {
|
|
461
|
+
if (this.evaluationTimer) {
|
|
462
|
+
clearInterval(this.evaluationTimer);
|
|
463
|
+
this.evaluationTimer = null;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Evaluate all scents and trigger as needed
|
|
469
|
+
*/
|
|
470
|
+
async evaluateScents(): Promise<void> {
|
|
471
|
+
const now = Date.now();
|
|
472
|
+
const pheromones = [...this.store.values()];
|
|
473
|
+
|
|
474
|
+
for (const scent of this.scents.values()) {
|
|
475
|
+
// Check cooldown
|
|
476
|
+
if (scent.last_triggered_at && now - scent.last_triggered_at < scent.cooldown_ms) {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const evalResult = evaluateCondition(scent.condition, {
|
|
481
|
+
pheromones,
|
|
482
|
+
now,
|
|
483
|
+
emissionHistory: this.emissionHistory,
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const shouldTrigger = this.shouldTrigger(scent, evalResult.met, now);
|
|
487
|
+
scent.last_condition_met = evalResult.met;
|
|
488
|
+
|
|
489
|
+
if (shouldTrigger) {
|
|
490
|
+
scent.last_triggered_at = now;
|
|
491
|
+
await this.dispatchTrigger(scent, evalResult, now);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Determine if a scent should trigger based on mode
|
|
498
|
+
*/
|
|
499
|
+
private shouldTrigger(scent: Scent, conditionMet: boolean, _now: number): boolean {
|
|
500
|
+
switch (scent.trigger_mode) {
|
|
501
|
+
case "level":
|
|
502
|
+
return conditionMet;
|
|
503
|
+
|
|
504
|
+
case "edge_rising":
|
|
505
|
+
return conditionMet && !scent.last_condition_met;
|
|
506
|
+
|
|
507
|
+
case "edge_falling":
|
|
508
|
+
return !conditionMet && scent.last_condition_met;
|
|
509
|
+
|
|
510
|
+
default:
|
|
511
|
+
return conditionMet;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Dispatch a trigger to the agent
|
|
517
|
+
*/
|
|
518
|
+
private async dispatchTrigger(
|
|
519
|
+
scent: Scent,
|
|
520
|
+
evalResult: { met: boolean; value: number; matchingPheromoneIds: string[] },
|
|
521
|
+
now: number
|
|
522
|
+
): Promise<void> {
|
|
523
|
+
// Build context pheromones
|
|
524
|
+
const contextTrails = scent.context_trails || [];
|
|
525
|
+
const contextPheromones: PheromoneSnapshot[] = [];
|
|
526
|
+
|
|
527
|
+
if (contextTrails.length > 0) {
|
|
528
|
+
for (const p of this.store.values()) {
|
|
529
|
+
if (contextTrails.includes(p.trail) && !isEvaporated(p, now)) {
|
|
530
|
+
contextPheromones.push(createSnapshot(p, now));
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
} else {
|
|
534
|
+
// Include matching pheromones
|
|
535
|
+
for (const id of evalResult.matchingPheromoneIds) {
|
|
536
|
+
const p = this.store.get(id);
|
|
537
|
+
if (p) {
|
|
538
|
+
contextPheromones.push(createSnapshot(p, now));
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const payload: TriggerPayload = {
|
|
544
|
+
scent_id: scent.scent_id,
|
|
545
|
+
triggered_at: now,
|
|
546
|
+
condition_snapshot: {
|
|
547
|
+
[scent.scent_id]: {
|
|
548
|
+
value: evalResult.value,
|
|
549
|
+
pheromone_ids: evalResult.matchingPheromoneIds,
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
context_pheromones: contextPheromones,
|
|
553
|
+
activation_payload: scent.activation_payload,
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
// Try local handler first
|
|
557
|
+
const localHandler = this.triggerHandlers.get(scent.scent_id);
|
|
558
|
+
if (localHandler) {
|
|
559
|
+
try {
|
|
560
|
+
await localHandler(payload);
|
|
561
|
+
} catch (err) {
|
|
562
|
+
console.error(`[SBP] Trigger handler error for ${scent.scent_id}:`, err);
|
|
563
|
+
}
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Otherwise, HTTP POST to endpoint
|
|
568
|
+
try {
|
|
569
|
+
const response = await fetch(scent.agent_endpoint, {
|
|
570
|
+
method: "POST",
|
|
571
|
+
headers: {
|
|
572
|
+
"Content-Type": "application/json",
|
|
573
|
+
"X-SBP-Version": "0.1",
|
|
574
|
+
},
|
|
575
|
+
body: JSON.stringify({
|
|
576
|
+
jsonrpc: "2.0",
|
|
577
|
+
method: "sbp/trigger",
|
|
578
|
+
params: payload,
|
|
579
|
+
}),
|
|
580
|
+
signal: AbortSignal.timeout(scent.max_execution_ms),
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
if (!response.ok) {
|
|
584
|
+
console.error(`[SBP] Trigger failed for ${scent.scent_id}: ${response.status}`);
|
|
585
|
+
}
|
|
586
|
+
} catch (err) {
|
|
587
|
+
console.error(`[SBP] Trigger dispatch error for ${scent.scent_id}:`, err);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ==========================================================================
|
|
592
|
+
// UTILITIES
|
|
593
|
+
// ==========================================================================
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Garbage collect evaporated pheromones
|
|
597
|
+
*/
|
|
598
|
+
gc(): number {
|
|
599
|
+
const now = Date.now();
|
|
600
|
+
const toRemove: string[] = [];
|
|
601
|
+
|
|
602
|
+
for (const [id, p] of this.store.entries()) {
|
|
603
|
+
if (isEvaporated(p, now)) {
|
|
604
|
+
toRemove.push(id);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
for (const id of toRemove) {
|
|
609
|
+
this.store.delete(id);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return toRemove.length;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Get raw pheromone count (for testing)
|
|
617
|
+
*/
|
|
618
|
+
get size(): number {
|
|
619
|
+
return this.store.size;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Get scent count
|
|
624
|
+
*/
|
|
625
|
+
get scentCount(): number {
|
|
626
|
+
return this.scents.size;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private hashPayload(payload: Record<string, unknown>): string {
|
|
630
|
+
const content = JSON.stringify(payload, Object.keys(payload).sort());
|
|
631
|
+
return createHash("sha256").update(content).digest("hex").slice(0, 16);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
private matchTags(tags: string[], filter: TagFilter): boolean {
|
|
635
|
+
if (filter.any && filter.any.length > 0) {
|
|
636
|
+
if (!filter.any.some((t) => tags.includes(t))) return false;
|
|
637
|
+
}
|
|
638
|
+
if (filter.all && filter.all.length > 0) {
|
|
639
|
+
if (!filter.all.every((t) => tags.includes(t))) return false;
|
|
640
|
+
}
|
|
641
|
+
if (filter.none && filter.none.length > 0) {
|
|
642
|
+
if (filter.none.some((t) => tags.includes(t))) return false;
|
|
643
|
+
}
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private pruneEmissionHistory(now: number): void {
|
|
648
|
+
const cutoff = now - this.options.emissionHistoryWindow;
|
|
649
|
+
this.emissionHistory = this.emissionHistory.filter((e) => e.timestamp >= cutoff);
|
|
650
|
+
}
|
|
651
|
+
}
|