society-protocol 1.0.1 → 1.2.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/dist/bootstrap.d.ts +8 -3
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +52 -9
- package/dist/bootstrap.js.map +1 -1
- package/dist/content-store.d.ts +77 -0
- package/dist/content-store.d.ts.map +1 -0
- package/dist/content-store.js +178 -0
- package/dist/content-store.js.map +1 -0
- package/dist/identity.d.ts +22 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +60 -0
- package/dist/identity.js.map +1 -1
- package/dist/index.js +174 -40
- package/dist/index.js.map +1 -1
- package/dist/knowledge.d.ts +168 -1
- package/dist/knowledge.d.ts.map +1 -1
- package/dist/knowledge.js +682 -3
- package/dist/knowledge.js.map +1 -1
- package/dist/lib.d.ts +7 -5
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +7 -5
- package/dist/lib.js.map +1 -1
- package/dist/p2p.d.ts +27 -0
- package/dist/p2p.d.ts.map +1 -1
- package/dist/p2p.js +165 -69
- package/dist/p2p.js.map +1 -1
- package/dist/planner.d.ts +1 -0
- package/dist/planner.d.ts.map +1 -1
- package/dist/planner.js +26 -4
- package/dist/planner.js.map +1 -1
- package/dist/proactive/watcher.d.ts +76 -0
- package/dist/proactive/watcher.d.ts.map +1 -0
- package/dist/proactive/watcher.js +246 -0
- package/dist/proactive/watcher.js.map +1 -0
- package/dist/registry.d.ts +4 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +21 -0
- package/dist/registry.js.map +1 -1
- package/dist/reputation.d.ts +36 -0
- package/dist/reputation.d.ts.map +1 -1
- package/dist/reputation.js +76 -0
- package/dist/reputation.js.map +1 -1
- package/dist/rooms.d.ts +24 -0
- package/dist/rooms.d.ts.map +1 -1
- package/dist/rooms.js +90 -0
- package/dist/rooms.js.map +1 -1
- package/dist/sdk/index.d.ts +1 -1
- package/dist/sdk/index.js +1 -1
- package/dist/swp.d.ts +1 -1
- package/dist/swp.d.ts.map +1 -1
- package/dist/swp.js.map +1 -1
- package/package.json +1 -1
package/dist/knowledge.js
CHANGED
|
@@ -10,7 +10,95 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { EventEmitter } from 'events';
|
|
12
12
|
import { ulid } from 'ulid';
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Compare two vector clocks for causal ordering.
|
|
15
|
+
* Returns:
|
|
16
|
+
* 'before' — a causally precedes b (a < b)
|
|
17
|
+
* 'after' — a causally follows b (a > b)
|
|
18
|
+
* 'concurrent' — neither precedes the other
|
|
19
|
+
* 'equal' — identical clocks
|
|
20
|
+
*/
|
|
21
|
+
export function compareVectorClocks(a, b) {
|
|
22
|
+
const allNodes = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
23
|
+
let aLess = false;
|
|
24
|
+
let bLess = false;
|
|
25
|
+
for (const node of allNodes) {
|
|
26
|
+
const va = a[node] || 0;
|
|
27
|
+
const vb = b[node] || 0;
|
|
28
|
+
if (va < vb)
|
|
29
|
+
aLess = true;
|
|
30
|
+
if (va > vb)
|
|
31
|
+
bLess = true;
|
|
32
|
+
if (aLess && bLess)
|
|
33
|
+
return 'concurrent';
|
|
34
|
+
}
|
|
35
|
+
if (!aLess && !bLess)
|
|
36
|
+
return 'equal';
|
|
37
|
+
if (aLess && !bLess)
|
|
38
|
+
return 'before';
|
|
39
|
+
return 'after';
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Merge two vector clocks by taking component-wise maximum.
|
|
43
|
+
*/
|
|
44
|
+
export function mergeVectorClocks(a, b) {
|
|
45
|
+
const merged = { ...a };
|
|
46
|
+
for (const [node, count] of Object.entries(b)) {
|
|
47
|
+
merged[node] = Math.max(merged[node] || 0, count);
|
|
48
|
+
}
|
|
49
|
+
return merged;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Advance HLC for a local event (Kulkarni et al. 2014, §3.1).
|
|
53
|
+
* l' = max(l.wallTime, pt) ; c' = (l' == l.wallTime) ? l.logical + 1 : 0
|
|
54
|
+
*/
|
|
55
|
+
export function tickHLC(current) {
|
|
56
|
+
const pt = Date.now();
|
|
57
|
+
if (pt > current.wallTime) {
|
|
58
|
+
return { wallTime: pt, logical: 0, nodeId: current.nodeId };
|
|
59
|
+
}
|
|
60
|
+
return { wallTime: current.wallTime, logical: current.logical + 1, nodeId: current.nodeId };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Receive HLC from a remote message (Kulkarni et al. 2014, §3.2).
|
|
64
|
+
* Merges local and remote clocks to maintain causal ordering.
|
|
65
|
+
*/
|
|
66
|
+
export function receiveHLC(local, remote) {
|
|
67
|
+
const pt = Date.now();
|
|
68
|
+
const maxWall = Math.max(local.wallTime, remote.wallTime, pt);
|
|
69
|
+
let logical;
|
|
70
|
+
if (maxWall === local.wallTime && maxWall === remote.wallTime) {
|
|
71
|
+
logical = Math.max(local.logical, remote.logical) + 1;
|
|
72
|
+
}
|
|
73
|
+
else if (maxWall === local.wallTime) {
|
|
74
|
+
logical = local.logical + 1;
|
|
75
|
+
}
|
|
76
|
+
else if (maxWall === remote.wallTime) {
|
|
77
|
+
logical = remote.logical + 1;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
logical = 0; // pt is strictly greater
|
|
81
|
+
}
|
|
82
|
+
return { wallTime: maxWall, logical, nodeId: local.nodeId };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Compare two HLCs for total ordering.
|
|
86
|
+
* Returns negative if a < b, positive if a > b, 0 if equal.
|
|
87
|
+
* Tie-breaking: wallTime → logical → nodeId (lexicographic).
|
|
88
|
+
*/
|
|
89
|
+
export function compareHLC(a, b) {
|
|
90
|
+
if (a.wallTime !== b.wallTime)
|
|
91
|
+
return a.wallTime - b.wallTime;
|
|
92
|
+
if (a.logical !== b.logical)
|
|
93
|
+
return a.logical - b.logical;
|
|
94
|
+
return a.nodeId < b.nodeId ? -1 : a.nodeId > b.nodeId ? 1 : 0;
|
|
95
|
+
}
|
|
96
|
+
const DEFAULT_COMPACTION_CONFIG = {
|
|
97
|
+
compactAfterMessages: 20,
|
|
98
|
+
maxRecentMessages: 40,
|
|
99
|
+
ollamaUrl: 'http://127.0.0.1:11434',
|
|
100
|
+
ollamaModel: 'qwen3:1.7b',
|
|
101
|
+
};
|
|
14
102
|
export class KnowledgePool extends EventEmitter {
|
|
15
103
|
storage;
|
|
16
104
|
identity;
|
|
@@ -25,10 +113,15 @@ export class KnowledgePool extends EventEmitter {
|
|
|
25
113
|
// Link indexes for O(1) graph traversal (fixes N+1 query)
|
|
26
114
|
linksBySource = new Map();
|
|
27
115
|
linksByTarget = new Map();
|
|
28
|
-
|
|
116
|
+
// Chat message buffers per space (for auto-compaction)
|
|
117
|
+
chatBuffers = new Map();
|
|
118
|
+
compactionConfig;
|
|
119
|
+
compacting = new Set(); // spaces currently compacting
|
|
120
|
+
constructor(storage, identity, compactionConfig) {
|
|
29
121
|
super();
|
|
30
122
|
this.storage = storage;
|
|
31
123
|
this.identity = identity;
|
|
124
|
+
this.compactionConfig = { ...DEFAULT_COMPACTION_CONFIG, ...compactionConfig };
|
|
32
125
|
this.loadFromStorage();
|
|
33
126
|
}
|
|
34
127
|
// ─── Space Management ────────────────────────────────────────
|
|
@@ -129,9 +222,10 @@ export class KnowledgePool extends EventEmitter {
|
|
|
129
222
|
// TODO: Verificar permissões de edição
|
|
130
223
|
throw new Error('No permission to edit');
|
|
131
224
|
}
|
|
132
|
-
// CRDT:
|
|
225
|
+
// CRDT: Advance HLC (Kulkarni et al. 2014) and increment vector clock
|
|
133
226
|
card.version++;
|
|
134
227
|
card.updatedAt = Date.now();
|
|
228
|
+
card.crdt.hlc = tickHLC(card.crdt.hlc);
|
|
135
229
|
card.crdt.vectorClock[this.identity.did] =
|
|
136
230
|
(card.crdt.vectorClock[this.identity.did] || 0) + 1;
|
|
137
231
|
// Aplicar updates
|
|
@@ -170,6 +264,166 @@ export class KnowledgePool extends EventEmitter {
|
|
|
170
264
|
await this.saveCard(card);
|
|
171
265
|
this.emit('card:deleted', id);
|
|
172
266
|
}
|
|
267
|
+
// ─── CRDT Merge ────────────────────────────────────────────────
|
|
268
|
+
/**
|
|
269
|
+
* Merge a remote knowledge card with the local copy.
|
|
270
|
+
*
|
|
271
|
+
* Algorithm (state-based CRDT with causal metadata):
|
|
272
|
+
* 1. If card is unknown locally → accept remote (new knowledge).
|
|
273
|
+
* 2. Compare vector clocks for causal ordering:
|
|
274
|
+
* - remote ≤ local → discard (stale).
|
|
275
|
+
* - local < remote → accept remote (strictly newer).
|
|
276
|
+
* - concurrent → LWW tie-break on HLC (wallTime, logical, nodeId).
|
|
277
|
+
* 3. Merge vector clocks (component-wise max) regardless of winner.
|
|
278
|
+
* 4. Advance local HLC via receiveHLC to maintain causal consistency.
|
|
279
|
+
* 5. Tombstone wins: if either copy is tombstoned, result is tombstoned.
|
|
280
|
+
*
|
|
281
|
+
* Returns the merged card, or null if the remote was stale.
|
|
282
|
+
*/
|
|
283
|
+
mergeCard(remote) {
|
|
284
|
+
const local = this.cards.get(remote.id);
|
|
285
|
+
// Case 1: New card — accept remote entirely
|
|
286
|
+
if (!local) {
|
|
287
|
+
// Advance our HLC on receipt of remote clock
|
|
288
|
+
const mergedHlc = receiveHLC({ wallTime: Date.now(), logical: 0, nodeId: this.identity.did }, remote.crdt.hlc);
|
|
289
|
+
const card = {
|
|
290
|
+
...remote,
|
|
291
|
+
crdt: {
|
|
292
|
+
...remote.crdt,
|
|
293
|
+
hlc: mergedHlc,
|
|
294
|
+
vectorClock: { ...remote.crdt.vectorClock }
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
this.cards.set(card.id, card);
|
|
298
|
+
const space = this.spaces.get(card.spaceId);
|
|
299
|
+
if (space && !card.crdt.tombstone) {
|
|
300
|
+
space.cards.add(card.id);
|
|
301
|
+
space.stats.cardCount = space.cards.size;
|
|
302
|
+
space.stats.lastActivity = Date.now();
|
|
303
|
+
}
|
|
304
|
+
this.indexCard(card);
|
|
305
|
+
this.saveCard(card);
|
|
306
|
+
this.emit('card:merged', { card, action: 'new' });
|
|
307
|
+
return card;
|
|
308
|
+
}
|
|
309
|
+
// Case 2: Compare vector clocks
|
|
310
|
+
const order = compareVectorClocks(local.crdt.vectorClock, remote.crdt.vectorClock);
|
|
311
|
+
if (order === 'equal' || order === 'after') {
|
|
312
|
+
// Local is same or newer — discard remote
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
// Determine the winner for content (used when concurrent)
|
|
316
|
+
let winner;
|
|
317
|
+
let action;
|
|
318
|
+
if (order === 'before') {
|
|
319
|
+
// Remote is strictly newer — accept remote content
|
|
320
|
+
winner = remote;
|
|
321
|
+
action = 'remote-wins';
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
// Concurrent — LWW tie-break on HLC
|
|
325
|
+
const hlcCmp = compareHLC(local.crdt.hlc, remote.crdt.hlc);
|
|
326
|
+
winner = hlcCmp >= 0 ? local : remote;
|
|
327
|
+
action = hlcCmp >= 0 ? 'local-wins-concurrent' : 'remote-wins-concurrent';
|
|
328
|
+
}
|
|
329
|
+
// Merge metadata regardless of content winner
|
|
330
|
+
const mergedVectorClock = mergeVectorClocks(local.crdt.vectorClock, remote.crdt.vectorClock);
|
|
331
|
+
const mergedHlc = receiveHLC(local.crdt.hlc, remote.crdt.hlc);
|
|
332
|
+
// Tombstone wins: once deleted, stays deleted
|
|
333
|
+
const tombstone = local.crdt.tombstone || remote.crdt.tombstone;
|
|
334
|
+
// Build merged card
|
|
335
|
+
const merged = {
|
|
336
|
+
...winner,
|
|
337
|
+
crdt: {
|
|
338
|
+
hlc: mergedHlc,
|
|
339
|
+
vectorClock: mergedVectorClock,
|
|
340
|
+
tombstone
|
|
341
|
+
},
|
|
342
|
+
// Take the higher version
|
|
343
|
+
version: Math.max(local.version, remote.version),
|
|
344
|
+
// Merge usage counters (take max of each)
|
|
345
|
+
usage: {
|
|
346
|
+
views: Math.max(local.usage.views, remote.usage.views),
|
|
347
|
+
citations: Math.max(local.usage.citations, remote.usage.citations),
|
|
348
|
+
applications: Math.max(local.usage.applications, remote.usage.applications),
|
|
349
|
+
lastAccessed: Math.max(local.usage.lastAccessed, remote.usage.lastAccessed)
|
|
350
|
+
},
|
|
351
|
+
// Union verifications (deduplicate by verifier+timestamp)
|
|
352
|
+
verifications: this.mergeVerifications(local.verifications, remote.verifications)
|
|
353
|
+
};
|
|
354
|
+
// Update indexes
|
|
355
|
+
this.removeFromIndex(local);
|
|
356
|
+
this.cards.set(merged.id, merged);
|
|
357
|
+
if (!tombstone) {
|
|
358
|
+
this.indexCard(merged);
|
|
359
|
+
}
|
|
360
|
+
// Handle tombstone side effects
|
|
361
|
+
const space = this.spaces.get(merged.spaceId);
|
|
362
|
+
if (space) {
|
|
363
|
+
if (tombstone) {
|
|
364
|
+
space.cards.delete(merged.id);
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
space.cards.add(merged.id);
|
|
368
|
+
}
|
|
369
|
+
space.stats.cardCount = space.cards.size;
|
|
370
|
+
}
|
|
371
|
+
this.saveCard(merged);
|
|
372
|
+
this.emit('card:merged', { card: merged, action });
|
|
373
|
+
return merged;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Merge verification arrays, deduplicating by (verifier, timestamp).
|
|
377
|
+
*/
|
|
378
|
+
mergeVerifications(a, b) {
|
|
379
|
+
const seen = new Set();
|
|
380
|
+
const merged = [];
|
|
381
|
+
for (const v of [...a, ...b]) {
|
|
382
|
+
const key = `${v.verifier}:${v.timestamp}`;
|
|
383
|
+
if (!seen.has(key)) {
|
|
384
|
+
seen.add(key);
|
|
385
|
+
merged.push(v);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return merged;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Serialize a card for network transmission (GossipSub).
|
|
392
|
+
*/
|
|
393
|
+
serializeCard(card) {
|
|
394
|
+
return new TextEncoder().encode(JSON.stringify(card));
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Deserialize a card received from the network.
|
|
398
|
+
*/
|
|
399
|
+
deserializeCard(data) {
|
|
400
|
+
const raw = JSON.parse(new TextDecoder().decode(data));
|
|
401
|
+
return this.normalizeCard(raw);
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Handle incoming knowledge sync message from GossipSub.
|
|
405
|
+
* Deserializes, merges, and emits sync events.
|
|
406
|
+
*/
|
|
407
|
+
handleSyncMessage(data, from) {
|
|
408
|
+
try {
|
|
409
|
+
const remote = this.deserializeCard(data);
|
|
410
|
+
const result = this.mergeCard(remote);
|
|
411
|
+
if (result) {
|
|
412
|
+
this.emit('sync:merged', { card: result, from });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
this.emit('sync:error', { error: err, from });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Get all cards that have been modified since a given timestamp.
|
|
421
|
+
* Used for anti-entropy sync (periodic full-state exchange).
|
|
422
|
+
*/
|
|
423
|
+
getModifiedSince(since) {
|
|
424
|
+
return Array.from(this.cards.values())
|
|
425
|
+
.filter(c => c.updatedAt > since);
|
|
426
|
+
}
|
|
173
427
|
async linkCards(sourceId, targetId, type, strength = 0.5, evidence) {
|
|
174
428
|
// Verificar se cards existem
|
|
175
429
|
if (!this.cards.has(sourceId) || !this.cards.has(targetId)) {
|
|
@@ -385,7 +639,432 @@ ${cu.sharedState.decisions.slice(-5).join('\n')}
|
|
|
385
639
|
${cu.workingMemory.contextWindow}
|
|
386
640
|
`.trim();
|
|
387
641
|
}
|
|
642
|
+
// ─── Conversational Knowledge Exchange ─────────────────────
|
|
643
|
+
/**
|
|
644
|
+
* Get or create CollectiveUnconscious for a space/room.
|
|
645
|
+
* Public so rooms can initialize knowledge tracking.
|
|
646
|
+
*/
|
|
647
|
+
async getOrCreateCU(spaceId) {
|
|
648
|
+
const existing = this.collectiveUnconscious.get(spaceId);
|
|
649
|
+
if (existing)
|
|
650
|
+
return existing;
|
|
651
|
+
return this.createCollectiveUnconscious(spaceId);
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Ingest a chat message into the collaborative context.
|
|
655
|
+
* Called by RoomManager when chat messages are received.
|
|
656
|
+
* Triggers auto-compaction when buffer exceeds threshold.
|
|
657
|
+
*/
|
|
658
|
+
async ingestChatMessage(spaceId, msg) {
|
|
659
|
+
const cu = await this.getOrCreateCU(spaceId);
|
|
660
|
+
// Add to raw buffer
|
|
661
|
+
if (!this.chatBuffers.has(spaceId)) {
|
|
662
|
+
this.chatBuffers.set(spaceId, []);
|
|
663
|
+
}
|
|
664
|
+
const buffer = this.chatBuffers.get(spaceId);
|
|
665
|
+
buffer.push(msg);
|
|
666
|
+
// Update working memory
|
|
667
|
+
const senderLabel = msg.senderName || msg.sender.slice(0, 16);
|
|
668
|
+
cu.workingMemory.recentMessages.push(`[${senderLabel}]: ${msg.content}`);
|
|
669
|
+
// Track participants
|
|
670
|
+
if (!cu.workingMemory.participants.includes(msg.sender)) {
|
|
671
|
+
cu.workingMemory.participants.push(msg.sender);
|
|
672
|
+
}
|
|
673
|
+
// Trim recent messages to max
|
|
674
|
+
const max = this.compactionConfig.maxRecentMessages;
|
|
675
|
+
if (cu.workingMemory.recentMessages.length > max) {
|
|
676
|
+
cu.workingMemory.recentMessages = cu.workingMemory.recentMessages.slice(-max);
|
|
677
|
+
}
|
|
678
|
+
cu.lastUpdate = Date.now();
|
|
679
|
+
// Auto-compact when buffer reaches threshold
|
|
680
|
+
if (buffer.length >= this.compactionConfig.compactAfterMessages && !this.compacting.has(spaceId)) {
|
|
681
|
+
this.compacting.add(spaceId);
|
|
682
|
+
this.compactContext(spaceId).finally(() => this.compacting.delete(spaceId));
|
|
683
|
+
}
|
|
684
|
+
this.emit('chat:ingested', { spaceId, msg });
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Compact the conversation context using Ollama.
|
|
688
|
+
* Summarizes recent messages into a dense context window,
|
|
689
|
+
* extracts key concepts, decisions, and open questions.
|
|
690
|
+
*/
|
|
691
|
+
async compactContext(spaceId) {
|
|
692
|
+
const cu = this.collectiveUnconscious.get(spaceId);
|
|
693
|
+
if (!cu)
|
|
694
|
+
return;
|
|
695
|
+
const buffer = this.chatBuffers.get(spaceId) || [];
|
|
696
|
+
if (buffer.length === 0)
|
|
697
|
+
return;
|
|
698
|
+
// Build conversation transcript
|
|
699
|
+
const transcript = buffer.map(m => {
|
|
700
|
+
const name = m.senderName || m.sender.slice(0, 16);
|
|
701
|
+
return `${name}: ${m.content}`;
|
|
702
|
+
}).join('\n');
|
|
703
|
+
const previousContext = cu.workingMemory.contextWindow || '';
|
|
704
|
+
try {
|
|
705
|
+
const response = await this.callOllama(`You are a context compactor for a multi-agent conversation system.
|
|
706
|
+
|
|
707
|
+
Given the previous context summary and new conversation messages, produce a COMPACT updated context.
|
|
708
|
+
|
|
709
|
+
PREVIOUS CONTEXT:
|
|
710
|
+
${previousContext || '(none)'}
|
|
711
|
+
|
|
712
|
+
NEW MESSAGES:
|
|
713
|
+
${transcript}
|
|
714
|
+
|
|
715
|
+
Produce a JSON response with these fields:
|
|
716
|
+
- "contextSummary": A concise summary of the conversation state (max 500 chars)
|
|
717
|
+
- "activeTopics": Array of topic strings currently being discussed
|
|
718
|
+
- "keyConcepts": Array of key facts/concepts established
|
|
719
|
+
- "decisions": Array of decisions made (if any)
|
|
720
|
+
- "openQuestions": Array of unresolved questions
|
|
721
|
+
- "recurringThemes": Array of recurring themes
|
|
722
|
+
|
|
723
|
+
Respond ONLY with valid JSON, no markdown.`);
|
|
724
|
+
const parsed = this.parseJsonResponse(response);
|
|
725
|
+
if (parsed) {
|
|
726
|
+
cu.workingMemory.contextWindow = parsed.contextSummary || previousContext;
|
|
727
|
+
cu.workingMemory.activeTopics = parsed.activeTopics || cu.workingMemory.activeTopics;
|
|
728
|
+
if (parsed.keyConcepts?.length) {
|
|
729
|
+
for (const concept of parsed.keyConcepts) {
|
|
730
|
+
if (!cu.longTermMemory.keyConcepts.includes(concept)) {
|
|
731
|
+
cu.longTermMemory.keyConcepts.push(concept);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// Keep bounded
|
|
735
|
+
cu.longTermMemory.keyConcepts = cu.longTermMemory.keyConcepts.slice(-50);
|
|
736
|
+
}
|
|
737
|
+
if (parsed.decisions?.length) {
|
|
738
|
+
cu.sharedState.decisions.push(...parsed.decisions);
|
|
739
|
+
cu.sharedState.decisions = cu.sharedState.decisions.slice(-20);
|
|
740
|
+
}
|
|
741
|
+
if (parsed.openQuestions?.length) {
|
|
742
|
+
cu.sharedState.openQuestions = parsed.openQuestions;
|
|
743
|
+
}
|
|
744
|
+
if (parsed.recurringThemes?.length) {
|
|
745
|
+
cu.longTermMemory.recurringThemes = parsed.recurringThemes;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
catch {
|
|
750
|
+
// Ollama unavailable — use simple text compaction
|
|
751
|
+
cu.workingMemory.contextWindow = this.simpleCompact(previousContext, transcript);
|
|
752
|
+
}
|
|
753
|
+
// Clear the buffer after compaction
|
|
754
|
+
this.chatBuffers.set(spaceId, []);
|
|
755
|
+
cu.lastUpdate = Date.now();
|
|
756
|
+
cu.coherence = Math.min(1.0, cu.coherence + 0.05);
|
|
757
|
+
await this.saveCollectiveUnconscious(cu);
|
|
758
|
+
this.emit('context:compacted', { spaceId, cu });
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Serialize the collaborative context for sharing with peers.
|
|
762
|
+
* Used by knowledge.context_sync SWP messages.
|
|
763
|
+
*/
|
|
764
|
+
serializeContext(spaceId) {
|
|
765
|
+
const cu = this.collectiveUnconscious.get(spaceId);
|
|
766
|
+
if (!cu)
|
|
767
|
+
return null;
|
|
768
|
+
const payload = {
|
|
769
|
+
spaceId,
|
|
770
|
+
contextWindow: cu.workingMemory.contextWindow,
|
|
771
|
+
activeTopics: cu.workingMemory.activeTopics,
|
|
772
|
+
keyConcepts: cu.longTermMemory.keyConcepts,
|
|
773
|
+
recurringThemes: cu.longTermMemory.recurringThemes,
|
|
774
|
+
decisions: cu.sharedState.decisions,
|
|
775
|
+
openQuestions: cu.sharedState.openQuestions,
|
|
776
|
+
lastUpdate: cu.lastUpdate,
|
|
777
|
+
};
|
|
778
|
+
return new TextEncoder().encode(JSON.stringify(payload));
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Merge a remote context sync into the local CollectiveUnconscious.
|
|
782
|
+
* Takes the union of topics, concepts, decisions, etc.
|
|
783
|
+
*/
|
|
784
|
+
async mergeRemoteContext(data) {
|
|
785
|
+
try {
|
|
786
|
+
const remote = JSON.parse(new TextDecoder().decode(data));
|
|
787
|
+
const cu = await this.getOrCreateCU(remote.spaceId);
|
|
788
|
+
// Merge context window: keep longer/newer
|
|
789
|
+
if (remote.lastUpdate > cu.lastUpdate && remote.contextWindow) {
|
|
790
|
+
cu.workingMemory.contextWindow = remote.contextWindow;
|
|
791
|
+
}
|
|
792
|
+
// Union active topics
|
|
793
|
+
if (remote.activeTopics?.length) {
|
|
794
|
+
const topics = new Set([...cu.workingMemory.activeTopics, ...remote.activeTopics]);
|
|
795
|
+
cu.workingMemory.activeTopics = Array.from(topics).slice(-20);
|
|
796
|
+
}
|
|
797
|
+
// Union key concepts
|
|
798
|
+
if (remote.keyConcepts?.length) {
|
|
799
|
+
const concepts = new Set([...cu.longTermMemory.keyConcepts, ...remote.keyConcepts]);
|
|
800
|
+
cu.longTermMemory.keyConcepts = Array.from(concepts).slice(-50);
|
|
801
|
+
}
|
|
802
|
+
// Union recurring themes
|
|
803
|
+
if (remote.recurringThemes?.length) {
|
|
804
|
+
const themes = new Set([...cu.longTermMemory.recurringThemes, ...remote.recurringThemes]);
|
|
805
|
+
cu.longTermMemory.recurringThemes = Array.from(themes);
|
|
806
|
+
}
|
|
807
|
+
// Union decisions
|
|
808
|
+
if (remote.decisions?.length) {
|
|
809
|
+
const decisions = new Set([...cu.sharedState.decisions, ...remote.decisions]);
|
|
810
|
+
cu.sharedState.decisions = Array.from(decisions).slice(-20);
|
|
811
|
+
}
|
|
812
|
+
// Merge open questions
|
|
813
|
+
if (remote.openQuestions?.length) {
|
|
814
|
+
const questions = new Set([...cu.sharedState.openQuestions, ...remote.openQuestions]);
|
|
815
|
+
cu.sharedState.openQuestions = Array.from(questions);
|
|
816
|
+
}
|
|
817
|
+
cu.lastUpdate = Math.max(cu.lastUpdate, remote.lastUpdate);
|
|
818
|
+
await this.saveCollectiveUnconscious(cu);
|
|
819
|
+
this.emit('context:synced', { spaceId: remote.spaceId });
|
|
820
|
+
}
|
|
821
|
+
catch (err) {
|
|
822
|
+
this.emit('context:sync-error', { error: err });
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Get the chat message buffer for a space (for inspection/testing).
|
|
827
|
+
*/
|
|
828
|
+
getChatBuffer(spaceId) {
|
|
829
|
+
return this.chatBuffers.get(spaceId) || [];
|
|
830
|
+
}
|
|
831
|
+
// ─── Knowledge Gossip Sync ───────────────────────────────────
|
|
832
|
+
/**
|
|
833
|
+
* Get top knowledge cards for gossip broadcast to peers.
|
|
834
|
+
* Returns cards sorted by confidence * usage, most valuable first.
|
|
835
|
+
*/
|
|
836
|
+
getGossipPayload(spaceId, limit = 10) {
|
|
837
|
+
return this.queryCards({
|
|
838
|
+
spaceId,
|
|
839
|
+
sortBy: 'relevance',
|
|
840
|
+
limit,
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Apply knowledge decay to all cards.
|
|
845
|
+
* Cards not reinforced lose confidence over time.
|
|
846
|
+
* Should be called periodically (e.g., daily).
|
|
847
|
+
*
|
|
848
|
+
* @param decayRate - fraction of confidence lost per call (default 0.05 = 5%)
|
|
849
|
+
*/
|
|
850
|
+
applyKnowledgeDecay(decayRate = 0.05) {
|
|
851
|
+
let decayed = 0;
|
|
852
|
+
const now = Date.now();
|
|
853
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
854
|
+
for (const card of this.cards.values()) {
|
|
855
|
+
if (card.crdt.tombstone)
|
|
856
|
+
continue;
|
|
857
|
+
const daysSinceAccess = (now - card.usage.lastAccessed) / dayMs;
|
|
858
|
+
if (daysSinceAccess < 1)
|
|
859
|
+
continue; // Skip recently accessed
|
|
860
|
+
const oldConfidence = card.confidence;
|
|
861
|
+
card.confidence = Math.max(0.1, card.confidence * (1 - decayRate));
|
|
862
|
+
if (card.confidence !== oldConfidence) {
|
|
863
|
+
card.updatedAt = now;
|
|
864
|
+
this.saveCard(card);
|
|
865
|
+
decayed++;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
this.emit('knowledge:decay', { decayed, decayRate });
|
|
869
|
+
return decayed;
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Boost confidence when multiple agents confirm the same fact.
|
|
873
|
+
* If 2+ agents have verified a card, boost by confirmationBoost.
|
|
874
|
+
*
|
|
875
|
+
* @param cardId - card to boost
|
|
876
|
+
* @param verifierDid - DID of the confirming agent
|
|
877
|
+
* @param boostAmount - confidence boost (default 0.2 = 20%)
|
|
878
|
+
*/
|
|
879
|
+
confirmKnowledge(cardId, verifierDid, boostAmount = 0.2) {
|
|
880
|
+
const card = this.cards.get(cardId);
|
|
881
|
+
if (!card || card.crdt.tombstone)
|
|
882
|
+
return null;
|
|
883
|
+
// Add verification if not already present
|
|
884
|
+
const alreadyVerified = card.verifications.some(v => v.verifier === verifierDid);
|
|
885
|
+
if (!alreadyVerified) {
|
|
886
|
+
card.verifications.push({
|
|
887
|
+
verifier: verifierDid,
|
|
888
|
+
timestamp: Date.now(),
|
|
889
|
+
method: 'consensus',
|
|
890
|
+
confidence: card.confidence + boostAmount,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
// Boost confidence based on number of unique verifiers
|
|
894
|
+
const uniqueVerifiers = new Set(card.verifications.map(v => v.verifier));
|
|
895
|
+
if (uniqueVerifiers.size >= 2) {
|
|
896
|
+
card.confidence = Math.min(1.0, card.confidence + boostAmount);
|
|
897
|
+
card.verificationStatus = 'verified';
|
|
898
|
+
}
|
|
899
|
+
card.updatedAt = Date.now();
|
|
900
|
+
card.crdt.hlc = tickHLC(card.crdt.hlc);
|
|
901
|
+
card.crdt.vectorClock[this.identity.did] =
|
|
902
|
+
(card.crdt.vectorClock[this.identity.did] || 0) + 1;
|
|
903
|
+
this.saveCard(card);
|
|
904
|
+
this.emit('knowledge:confirmed', { cardId, verifier: verifierDid, confidence: card.confidence });
|
|
905
|
+
return card;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Distill lessons learned from a completed CoC chain.
|
|
909
|
+
* Creates knowledge cards from the chain's experience.
|
|
910
|
+
*
|
|
911
|
+
* @param chainId - ID of the completed chain
|
|
912
|
+
* @param summary - chain summary/final report
|
|
913
|
+
* @param goal - original chain goal
|
|
914
|
+
* @param spaceId - space to store knowledge in
|
|
915
|
+
* @param participants - DIDs of participating agents
|
|
916
|
+
*/
|
|
917
|
+
async distillChainExperience(chainId, summary, goal, spaceId, participants) {
|
|
918
|
+
const space = this.spaces.get(spaceId);
|
|
919
|
+
if (!space)
|
|
920
|
+
return [];
|
|
921
|
+
const cards = [];
|
|
922
|
+
try {
|
|
923
|
+
const response = await this.callOllama(`You are a knowledge extractor for a multi-agent collaboration system.
|
|
924
|
+
|
|
925
|
+
A collaborative chain (goal: "${goal}") has completed. Extract the key lessons learned.
|
|
926
|
+
|
|
927
|
+
CHAIN SUMMARY:
|
|
928
|
+
${summary}
|
|
929
|
+
|
|
930
|
+
PARTICIPANTS: ${participants.length} agents
|
|
931
|
+
|
|
932
|
+
Produce a JSON array of knowledge items to store. Each item should have:
|
|
933
|
+
- "type": one of "insight", "decision", "sop", "finding"
|
|
934
|
+
- "title": concise title (max 80 chars)
|
|
935
|
+
- "content": detailed description
|
|
936
|
+
- "tags": array of relevant tags
|
|
937
|
+
- "confidence": 0-1 confidence score
|
|
938
|
+
|
|
939
|
+
Respond ONLY with a valid JSON array.`);
|
|
940
|
+
const parsed = this.parseJsonResponse(response);
|
|
941
|
+
const items = Array.isArray(parsed) ? parsed : (parsed?.items || []);
|
|
942
|
+
for (const item of items.slice(0, 5)) {
|
|
943
|
+
if (!item.title || !item.content)
|
|
944
|
+
continue;
|
|
945
|
+
const card = await this.createCard(spaceId, item.type || 'insight', item.title, item.content, {
|
|
946
|
+
tags: item.tags || [],
|
|
947
|
+
source: { type: 'coc', id: chainId, context: goal },
|
|
948
|
+
confidence: item.confidence || 0.7,
|
|
949
|
+
});
|
|
950
|
+
cards.push(card);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
catch {
|
|
954
|
+
// Ollama unavailable — create a single summary card
|
|
955
|
+
const card = await this.createCard(spaceId, 'finding', `Chain ${chainId.slice(0, 8)}: ${goal}`, summary, {
|
|
956
|
+
tags: ['chain-distill', 'auto-extracted'],
|
|
957
|
+
source: { type: 'coc', id: chainId, context: goal },
|
|
958
|
+
confidence: 0.6,
|
|
959
|
+
});
|
|
960
|
+
cards.push(card);
|
|
961
|
+
}
|
|
962
|
+
this.emit('knowledge:distilled', { chainId, cardCount: cards.length });
|
|
963
|
+
return cards;
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Extract knowledge from a batch of chat messages.
|
|
967
|
+
* Used for periodic knowledge extraction from conversations.
|
|
968
|
+
*/
|
|
969
|
+
async extractFromConversation(spaceId, messages) {
|
|
970
|
+
const space = this.spaces.get(spaceId);
|
|
971
|
+
if (!space || messages.length === 0)
|
|
972
|
+
return [];
|
|
973
|
+
const transcript = messages.map(m => {
|
|
974
|
+
const name = m.senderName || m.sender.slice(0, 16);
|
|
975
|
+
return `${name}: ${m.content}`;
|
|
976
|
+
}).join('\n');
|
|
977
|
+
const cards = [];
|
|
978
|
+
try {
|
|
979
|
+
const response = await this.callOllama(`You are a knowledge extractor. Extract key facts and insights from this conversation.
|
|
980
|
+
|
|
981
|
+
CONVERSATION:
|
|
982
|
+
${transcript}
|
|
983
|
+
|
|
984
|
+
Extract structured knowledge items as a JSON array. Each item:
|
|
985
|
+
- "type": "fact" | "insight" | "decision" | "hypothesis"
|
|
986
|
+
- "title": concise title
|
|
987
|
+
- "content": the knowledge content
|
|
988
|
+
- "tags": relevant tags
|
|
989
|
+
- "confidence": 0-1
|
|
990
|
+
|
|
991
|
+
Only extract genuinely useful knowledge. Skip small talk or trivial messages.
|
|
992
|
+
Respond ONLY with a valid JSON array. Return empty array [] if nothing useful.`);
|
|
993
|
+
const parsed = this.parseJsonResponse(response);
|
|
994
|
+
const items = Array.isArray(parsed) ? parsed : [];
|
|
995
|
+
for (const item of items.slice(0, 5)) {
|
|
996
|
+
if (!item.title || !item.content)
|
|
997
|
+
continue;
|
|
998
|
+
const card = await this.createCard(spaceId, item.type || 'fact', item.title, item.content, {
|
|
999
|
+
tags: [...(item.tags || []), 'auto-extracted', 'chat'],
|
|
1000
|
+
source: { type: 'chat', context: spaceId },
|
|
1001
|
+
confidence: item.confidence || 0.6,
|
|
1002
|
+
});
|
|
1003
|
+
cards.push(card);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
catch {
|
|
1007
|
+
// Ollama unavailable — skip extraction
|
|
1008
|
+
}
|
|
1009
|
+
this.emit('knowledge:extracted', { spaceId, cardCount: cards.length });
|
|
1010
|
+
return cards;
|
|
1011
|
+
}
|
|
388
1012
|
// ─── Private Helpers ─────────────────────────────────────────
|
|
1013
|
+
/**
|
|
1014
|
+
* Simple text-based compaction when Ollama is unavailable.
|
|
1015
|
+
* Keeps last few lines of previous context + summary of new transcript.
|
|
1016
|
+
*/
|
|
1017
|
+
simpleCompact(previousContext, newTranscript) {
|
|
1018
|
+
const prevLines = previousContext ? previousContext.split('\n').slice(-5).join('\n') : '';
|
|
1019
|
+
const newLines = newTranscript.split('\n');
|
|
1020
|
+
const summary = newLines.length > 10
|
|
1021
|
+
? `[${newLines.length} messages exchanged covering: ${newLines.slice(0, 3).join('; ')}...]`
|
|
1022
|
+
: newLines.join('\n');
|
|
1023
|
+
return [prevLines, summary].filter(Boolean).join('\n---\n').slice(-2000);
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Call Ollama for context summarization.
|
|
1027
|
+
*/
|
|
1028
|
+
async callOllama(prompt) {
|
|
1029
|
+
const url = `${this.compactionConfig.ollamaUrl}/api/generate`;
|
|
1030
|
+
const res = await fetch(url, {
|
|
1031
|
+
method: 'POST',
|
|
1032
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1033
|
+
body: JSON.stringify({
|
|
1034
|
+
model: this.compactionConfig.ollamaModel,
|
|
1035
|
+
prompt,
|
|
1036
|
+
stream: false,
|
|
1037
|
+
options: { temperature: 0.3, num_predict: 1024 },
|
|
1038
|
+
}),
|
|
1039
|
+
});
|
|
1040
|
+
if (!res.ok)
|
|
1041
|
+
throw new Error(`Ollama error: ${res.status}`);
|
|
1042
|
+
const json = await res.json();
|
|
1043
|
+
return json.response;
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Parse JSON from LLM response (handles markdown code blocks).
|
|
1047
|
+
*/
|
|
1048
|
+
parseJsonResponse(text) {
|
|
1049
|
+
// Strip thinking tags if present
|
|
1050
|
+
let cleaned = text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
|
1051
|
+
// Strip markdown code blocks
|
|
1052
|
+
cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim();
|
|
1053
|
+
try {
|
|
1054
|
+
return JSON.parse(cleaned);
|
|
1055
|
+
}
|
|
1056
|
+
catch {
|
|
1057
|
+
// Try to extract JSON object
|
|
1058
|
+
const match = cleaned.match(/\{[\s\S]*\}/);
|
|
1059
|
+
if (match) {
|
|
1060
|
+
try {
|
|
1061
|
+
return JSON.parse(match[0]);
|
|
1062
|
+
}
|
|
1063
|
+
catch { /* ignore */ }
|
|
1064
|
+
}
|
|
1065
|
+
return null;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
389
1068
|
generateSummary(content, maxLength = 200) {
|
|
390
1069
|
// Remover markdown
|
|
391
1070
|
const plain = content
|