Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

> **Note:** Versions 0.3.26 – 0.3.55 were released as git tags without changelog entries. Changelog resumes at 0.3.56 below.

## 0.5.7

### Added

- **Origin-aware retention.** `MemoryStore.compactByOrigin(localFreshnessMs, peerFreshnessMs)` lets callers move self-authored CMBs (`peerId == null`) and peer-received CMBs to cold tier on independent freshness thresholds. Useful when the agent's own lineage chains carry more retrospective value than peer chatter — apps configure local > peer freshness so their own emissions survive longer.
- **`SymNode` constructor opts `localRetentionSeconds` + `peerRetentionSeconds`.** Optional overrides of the uniform `retentionSeconds`. When omitted, both fall through to `retentionSeconds` (back-compat preserved). When set, `_runRetentionPurge` (run on start + hourly) uses the new origin-aware path and logs both values when they differ.

### Compatibility

- `MemoryStore.compact(freshnessMs)` is now a back-compat shim that calls `compactByOrigin(freshnessMs, freshnessMs)`. No behavioral change for callers that don't explicitly opt into origin-aware retention.
- Existing apps configured with only `retentionSeconds` see no behavioral change. The new opts are purely additive.

### Tests

- 2 new tests in `tests/memory-store.test.js`: shim equivalence (back-compat) + origin discrimination (peer compacts past peer cutoff while self stays hot under local cutoff). 18/18 pass.

## 0.5.6

### Fixed
Expand Down
44 changes: 42 additions & 2 deletions lib/memory-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -469,14 +469,54 @@ class MemoryStore {
* @returns {number} count of CMBs moved to cold tier
*/
compact(freshnessMs) {
const cutoff = Date.now() - freshnessMs;
return this.compactByOrigin(freshnessMs, freshnessMs);
}

/**
* Origin-aware compaction. Locally-authored CMBs (peerId == null)
* use `localFreshnessMs`; peer-received CMBs use `peerFreshnessMs`.
* Lets callers retain self CMBs longer than peer CMBs — the agent's
* own lineage chains are higher value for retrospective analysis,
* peer CMBs go cold sooner since their value-to-this-agent is
* already encoded in the SVAF-admission decision.
*
* Lineage chains are preserved across origins — a peer entry whose
* self-descendant is still hot stays hot regardless of
* `peerFreshnessMs`. This matches `compact()`'s hot-descendant
* preservation rule and keeps lineage walks intact at retention
* boundaries.
*
* Callers that don't need the discrimination keep using
* `compact(freshnessMs)` — shimmed to call this with both values
* equal. Apps that benefit from the split configure via
* `localRetentionSeconds` + `peerRetentionSeconds` on the SymNode
* constructor.
*
* @param {number} localFreshnessMs — age threshold for self CMBs
* @param {number} peerFreshnessMs — age threshold for peer CMBs
* @returns {number} count of CMBs moved to cold tier
*/
compactByOrigin(localFreshnessMs, peerFreshnessMs) {
const now = Date.now();
const localCutoff = now - localFreshnessMs;
const peerCutoff = now - peerFreshnessMs;
let moved = 0;

for (const [key, entry] of this._index.byKey) {
if (entry.tier !== 'hot') continue;
// peerId is set for received CMBs, null for locally-authored.
// Use `!= null` (not truthy) so 0 / '' / false peerIds — which
// shouldn't occur today but might via test fixtures or future
// relay edge cases — are correctly classified as peer rather
// than silently mapped to self.
const cutoff = (entry.peerId != null) ? peerCutoff : localCutoff;
if (entry.storedAt >= cutoff) continue; // still fresh

// Keep hot if any descendant is hot
// Keep hot if any descendant is hot — preserves chains
// regardless of origin (matches `compact()` semantics). This
// is intentional: a peer entry whose self-descendant is still
// hot stays hot regardless of `peerFreshnessMs`, so lineage
// chains aren't broken at retention boundaries.
const descs = this._index.descendants(key);
let hasHotDesc = false;
for (const dk of descs) {
Expand Down
27 changes: 23 additions & 4 deletions lib/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ class SymNode extends EventEmitter {
* @param {number} [opts.svafTemporalLambda=0.3] — SVAF temporal decay lambda
* @param {number} [opts.svafFreshnessSeconds=1800] — SVAF freshness window
* @param {object} [opts.svafFieldWeights] — per-field weight profile (See MMP v0.2.0 Section 9)
* @param {number} [opts.retentionSeconds=86400] — CMB retention period
* @param {number} [opts.retentionSeconds=86400] — uniform CMB retention period (single-value knob, applies to both self-authored and peer-received entries)
* @param {number} [opts.localRetentionSeconds] — origin-aware retention for self-authored CMBs (peerId == null). Falls back to `retentionSeconds` when omitted.
* @param {number} [opts.peerRetentionSeconds] — origin-aware retention for peer-received CMBs. Falls back to `retentionSeconds` when omitted. Apps that value depth on their own lineage chains but tolerate faster forgetting of peer chatter set local > peer.
* @param {object} [opts.wakeChannel] — wake channel configuration
* @param {string} [opts.relay] — WebSocket relay URL (See MMP v0.2.0 Section 4)
* @param {string} [opts.relayToken] — relay authentication token
Expand Down Expand Up @@ -84,6 +86,15 @@ class SymNode extends EventEmitter {
// Retention — how long to keep CMBs in local storage (default 86400s = 24h)
// Regulated domains MUST set per compliance: legal (jurisdiction), health (HIPAA 6yr), finance (MiFID II 5yr, SEC 7yr)
this._retentionSeconds = opts.retentionSeconds ?? 86400;
// Origin-aware retention (optional override of the uniform value
// above). When set, self-authored CMBs (peerId == null) and peer-
// received CMBs use independent freshness thresholds during
// compaction. Useful when the app's own lineage chains are higher
// value for retrospective analysis than peer chatter. Defaults
// preserve back-compat: both fall through to `retentionSeconds`
// when not explicitly set.
this._localRetentionSeconds = opts.localRetentionSeconds ?? this._retentionSeconds;
this._peerRetentionSeconds = opts.peerRetentionSeconds ?? this._retentionSeconds;

// Neural SVAF evaluator (Layer 4 cognition). See MMP v0.2.0 Section 9.
this._svafEvaluator = new SVAFEvaluator({
Expand Down Expand Up @@ -405,11 +416,19 @@ class SymNode extends EventEmitter {
}

_runRetentionPurge() {
const retentionMs = this._retentionSeconds * 1000;
const compacted = this._store.compact(retentionMs);
const localMs = this._localRetentionSeconds * 1000;
const peerMs = this._peerRetentionSeconds * 1000;
const compacted = this._store.compactByOrigin(localMs, peerMs);
const purged = this._store.purge();
if (compacted > 0 || purged > 0) {
this._log(`Retention purge: ${compacted} compacted, ${purged} removed (retention: ${this._retentionSeconds}s)`);
// When local == peer (the back-compat path), log the single
// value to keep existing log-grep tooling working. When they
// differ, surface both so operators can confirm the origin
// discrimination is in effect.
const retentionDesc = (this._localRetentionSeconds === this._peerRetentionSeconds)
? `retention: ${this._retentionSeconds}s`
: `local: ${this._localRetentionSeconds}s, peer: ${this._peerRetentionSeconds}s`;
this._log(`Retention purge: ${compacted} compacted, ${purged} removed (${retentionDesc})`);
}
}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sym-bot/sym",
"version": "0.5.6",
"version": "0.5.7",
"description": "Infrastructure and protocol for multi-agent collective intelligence",
"main": "lib/node.js",
"bin": {
Expand Down
59 changes: 59 additions & 0 deletions tests/memory-store.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,65 @@ describe('MemoryStore', () => {
assert.ok(typeof removed === 'number', 'purge should return count');
});

it('compactByOrigin shims to compact when both freshnessMs values are equal', () => {
// Back-compat: the legacy single-value compact() now shims to
// compactByOrigin with equal local + peer thresholds. The result
// count must remain shaped like a number (existing assertions).
const isolatedDir = path.join(os.tmpdir(), `sym-test-shim-${Date.now()}`);
const isolated = new MemoryStore(isolatedDir, 'test-agent');
try {
isolated.write('shim-test entry', { tags: ['shim'] });
const compacted = isolated.compactByOrigin(0, 0);
assert.ok(typeof compacted === 'number',
'compactByOrigin should return count');
} finally {
fs.rmSync(isolatedDir, { recursive: true, force: true });
}
});

it('compactByOrigin uses peer threshold for peer entries and local threshold for self entries', () => {
// Origin-aware retention: a peer entry past its peerCutoff
// compacts to cold; a self entry of the same age but within its
// localCutoff stays hot. The discrimination is the whole point of
// the API — apps that retain own lineage longer than peer chatter
// configure local > peer freshness.
const isolatedDir = path.join(os.tmpdir(), `sym-test-origin-${Date.now()}`);
const isolated = new MemoryStore(isolatedDir, 'test-agent');
try {
// Self entry (peerId == null).
const selfEntry = isolated.write('self ancient', { tags: ['origin-test'] });
// Peer entry, written via the receiveFromPeer path so peerId is
// populated and the entry is treated as not-self by the index.
const peerEntry = isolated.receiveFromPeer('peer-x', {
key: 'peer-ancient-key',
content: 'peer ancient',
source: 'peer-x',
tags: ['origin-test'],
});
// Backdate both storedAt to 60 seconds ago via in-memory index
// mutation — test-only manipulation; production code never
// touches storedAt directly.
const sixtySecAgo = Date.now() - 60_000;
isolated._index.get(selfEntry.key).storedAt = sixtySecAgo;
if (peerEntry) isolated._index.get(peerEntry.key).storedAt = sixtySecAgo;

// localFreshness = 120s (self stays hot), peerFreshness = 30s
// (peer entry is past cutoff and should compact).
const moved = isolated.compactByOrigin(120_000, 30_000);

assert.strictEqual(moved, 1,
'exactly one entry (peer) should compact under split thresholds');
assert.strictEqual(isolated._index.get(selfEntry.key).tier, 'hot',
'self entry must stay hot when within local freshness window');
if (peerEntry) {
assert.strictEqual(isolated._index.get(peerEntry.key).tier, 'cold',
'peer entry must compact when past peer freshness window');
}
} finally {
fs.rmSync(isolatedDir, { recursive: true, force: true });
}
});

it('should receive from peer', () => {
const peerEntry = {
key: 'peer-mem-1',
Expand Down