hrr-memory 0.1.1 → 0.3.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 CHANGED
@@ -1,18 +1,21 @@
1
1
  {
2
2
  "name": "hrr-memory",
3
- "version": "0.1.1",
4
- "description": "Holographic Reduced Representations for structured agent memory. Zero dependencies. Complements RAG with algebraic fact queries.",
3
+ "version": "0.3.0",
4
+ "description": "Holographic Reduced Representations for structured agent memory. Structured facts, temporal awareness, conflict detection, and belief synthesis. Zero dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
8
8
  ".": "./src/index.js"
9
9
  },
10
+ "types": "types/index.d.ts",
10
11
  "files": [
11
- "src/"
12
+ "src/",
13
+ "types/"
12
14
  ],
13
15
  "scripts": {
14
- "test": "node --test test/",
15
- "bench": "node bench/benchmark.js"
16
+ "test": "node --test test/memory.test.js test/timeline.test.js test/conflict.test.js test/prompt.test.js test/integration.test.js",
17
+ "bench": "node bench/benchmark.js",
18
+ "bench:obs": "node bench/benchmark-obs.js"
16
19
  },
17
20
  "keywords": [
18
21
  "hrr",
@@ -36,7 +39,13 @@
36
39
  "associative-memory",
37
40
  "semantic-memory",
38
41
  "ai-agent",
39
- "no-dependencies"
42
+ "no-dependencies",
43
+ "observation",
44
+ "belief-tracking",
45
+ "temporal",
46
+ "conflict-detection",
47
+ "timeline",
48
+ "knowledge-evolution"
40
49
  ],
41
50
  "author": "Jounes",
42
51
  "license": "MIT",
package/src/bucket.js CHANGED
@@ -3,6 +3,8 @@
3
3
  * Auto-splits are managed by HRRMemory, not by the bucket itself.
4
4
  */
5
5
 
6
+ import { bind } from './ops.js';
7
+
6
8
  export const MAX_BUCKET_SIZE = 25;
7
9
 
8
10
  export class Bucket {
@@ -14,17 +16,26 @@ export class Bucket {
14
16
  this.triples = [];
15
17
  }
16
18
 
17
- /** Add a pre-computed association vector */
18
19
  storeVector(association, triple) {
19
20
  for (let i = 0; i < this.d; i++) this.memory[i] += association[i];
20
21
  this.count++;
21
22
  this.triples.push(triple);
22
23
  }
23
24
 
24
- /** Whether the bucket has reached max capacity */
25
25
  get isFull() { return this.count >= MAX_BUCKET_SIZE; }
26
26
 
27
- /** Serialize */
27
+ rebuild(symbols) {
28
+ this.memory = new Float32Array(this.d);
29
+ for (const t of this.triples) {
30
+ const s = symbols.get(t.subject);
31
+ const r = symbols.get(t.relation);
32
+ const o = symbols.get(t.object);
33
+ const association = bind(bind(s, r), o);
34
+ for (let i = 0; i < this.d; i++) this.memory[i] += association[i];
35
+ }
36
+ this.count = this.triples.length;
37
+ }
38
+
28
39
  toJSON() {
29
40
  return {
30
41
  name: this.name,
@@ -34,7 +45,6 @@ export class Bucket {
34
45
  };
35
46
  }
36
47
 
37
- /** Deserialize */
38
48
  static fromJSON(data, d) {
39
49
  const b = new Bucket(data.name, d);
40
50
  b.memory = new Float32Array(data.memory);
@@ -0,0 +1,50 @@
1
+ /**
2
+ * ConflictDetector — algebraic conflict detection using HRR symbol vectors.
3
+ *
4
+ * @typedef {{ ts: number, subject: string, relation: string, newObject: string, oldObject: string, similarity: number }} ConflictFlag
5
+ */
6
+
7
+ import { similarity } from './ops.js';
8
+
9
+ export class ConflictDetector {
10
+ constructor(hrr, threshold = 0.3) {
11
+ this._hrr = hrr;
12
+ this._threshold = threshold;
13
+ this._knownPairs = new Set(); // track subject+relation pairs we've seen
14
+ }
15
+
16
+ /**
17
+ * Record that a (subject, relation) pair has been stored.
18
+ * Call this after a successful store so future checks know to run the query.
19
+ */
20
+ track(subject, relation) {
21
+ this._knownPairs.add(`${subject}\0${relation}`);
22
+ }
23
+
24
+ check(subject, relation, object, ts) {
25
+ const s = subject.toLowerCase().trim();
26
+ const r = relation.toLowerCase().trim();
27
+
28
+ // Fast path: if we've never stored this pair, no conflict possible.
29
+ // Skip the expensive HRR query entirely.
30
+ if (!this._knownPairs.has(`${s}\0${r}`)) return null;
31
+
32
+ const existing = this._hrr.query(subject, relation);
33
+ if (!existing.confident) return null;
34
+
35
+ const oldVec = this._hrr.symbols.get(existing.match);
36
+ const newVec = this._hrr.symbols.get(object.toLowerCase().trim());
37
+ const sim = similarity(oldVec, newVec);
38
+
39
+ if (sim >= this._threshold) return null;
40
+
41
+ return {
42
+ ts: ts ?? Date.now(),
43
+ subject: subject.toLowerCase().trim(),
44
+ relation: relation.toLowerCase().trim(),
45
+ newObject: object.toLowerCase().trim(),
46
+ oldObject: existing.match,
47
+ similarity: Math.round(sim * 1000) / 1000,
48
+ };
49
+ }
50
+ }
package/src/index.js CHANGED
@@ -2,17 +2,24 @@
2
2
  * hrr-memory — Holographic Reduced Representations for structured agent memory.
3
3
  *
4
4
  * Zero dependencies. Pure JS. Float32 storage. Auto-sharding.
5
+ * Includes observation layer: temporal awareness, conflict detection, belief synthesis.
5
6
  *
6
7
  * Usage:
7
- * import { HRRMemory } from 'hrr-memory';
8
- * const mem = new HRRMemory();
9
- * mem.store('alice', 'lives_in', 'paris');
8
+ * import { HRRMemory, ObservationMemory } from 'hrr-memory';
9
+ * const hrr = new HRRMemory();
10
+ * const mem = new ObservationMemory(hrr);
11
+ * await mem.store('alice', 'lives_in', 'paris');
10
12
  * mem.query('alice', 'lives_in'); // → { match: 'paris', score: 0.3, confident: true }
11
- *
12
- * Based on Plate (1994): "Distributed Representations and Nested Compositional Structure"
13
13
  */
14
14
 
15
+ // Core
15
16
  export { HRRMemory } from './memory.js';
16
17
  export { SymbolTable } from './symbols.js';
17
18
  export { Bucket } from './bucket.js';
18
19
  export { bind, unbind, similarity, randomVector, normalize } from './ops.js';
20
+
21
+ // Observation layer
22
+ export { ObservationMemory, ObservationParseError } from './observation.js';
23
+ export { Timeline, SymbolicProxy } from './timeline.js';
24
+ export { ConflictDetector } from './conflict.js';
25
+ export { defaultPrompt } from './prompt.js';
package/src/memory.js CHANGED
@@ -6,6 +6,18 @@
6
6
  * is created automatically. Queries scan all buckets for a subject.
7
7
  *
8
8
  * Symbol vectors are shared across all buckets.
9
+ * All values are lowercased on store. If you need case-sensitive values,
10
+ * normalize them yourself before calling store().
11
+ *
12
+ * @typedef {{ match: string|null, score: number, confident: boolean, bucket: string|null }} QueryResult
13
+ * @typedef {{ relation: string, object: string }} Fact
14
+ * @typedef {{ subject: string, relation: string, object: string }} Triple
15
+ * @typedef {{ type: 'direct', match: string, score: number, confident: boolean, subject: string, relation: string, bucket: string|null }} DirectAskResult
16
+ * @typedef {{ type: 'subject', subject: string, facts: Fact[] }} SubjectAskResult
17
+ * @typedef {{ type: 'search', term: string, results: Triple[] }} SearchAskResult
18
+ * @typedef {{ type: 'miss', query: string }} MissAskResult
19
+ * @typedef {DirectAskResult | SubjectAskResult | SearchAskResult | MissAskResult} AskResult
20
+ * @typedef {{ dimensions: number, maxBucketSize: number, symbols: number, buckets: number, subjects: number, totalFacts: number, ramBytes: number, ramMB: number, perBucket: Array<{name: string, facts: number, full: boolean}> }} Stats
9
21
  */
10
22
 
11
23
  import { readFileSync, writeFileSync, existsSync } from 'fs';
@@ -13,68 +25,50 @@ import { bind, unbind, similarity } from './ops.js';
13
25
  import { SymbolTable } from './symbols.js';
14
26
  import { Bucket, MAX_BUCKET_SIZE } from './bucket.js';
15
27
 
28
+ const STOP_WORDS = new Set([
29
+ 'what', 'is', 'the', 'a', 'an', 'does', 'do', 'where', 'who', 'how',
30
+ 'which', 'of', 'for', 'in', 'at', 'to', 'my', 'your', 'their', 'has',
31
+ 'have', 'was', 'were', 'are', 'been',
32
+ ]);
33
+
16
34
  export class HRRMemory {
17
- /**
18
- * Create a new HRR memory store.
19
- * @param {number} d - Vector dimensions (default 2048). Higher = more capacity per bucket.
20
- */
21
35
  constructor(d = 2048) {
22
36
  this.d = d;
23
37
  this.symbols = new SymbolTable(d);
24
38
  this.buckets = new Map();
25
- this.routing = new Map(); // subject → [bucket_id, ...]
39
+ this.routing = new Map();
26
40
  }
27
41
 
28
- // ── Bucket management ──────────────────────────────
29
-
30
- /** Get the active (non-full) bucket for a subject, splitting if needed */
31
42
  _activeBucket(subject) {
32
43
  const key = subject.toLowerCase().trim();
33
44
  const ids = this.routing.get(key);
34
-
35
45
  if (ids) {
36
46
  const lastId = ids[ids.length - 1];
37
47
  const last = this.buckets.get(lastId);
38
48
  if (!last.isFull) return last;
39
-
40
- // Overflow: create new bucket
41
49
  const newId = key + '#' + ids.length;
42
50
  const nb = new Bucket(newId, this.d);
43
51
  this.buckets.set(newId, nb);
44
52
  ids.push(newId);
45
53
  return nb;
46
54
  }
47
-
48
- // First bucket for this subject
49
55
  const b = new Bucket(key, this.d);
50
56
  this.buckets.set(key, b);
51
57
  this.routing.set(key, [key]);
52
58
  return b;
53
59
  }
54
60
 
55
- /** Get all buckets for a subject */
56
61
  _subjectBuckets(subject) {
57
62
  const ids = this.routing.get(subject.toLowerCase().trim()) || [];
58
63
  return ids.map(id => this.buckets.get(id)).filter(Boolean);
59
64
  }
60
65
 
61
- // ── Store ──────────────────────────────────────────
62
-
63
- /**
64
- * Store a fact as a (subject, relation, object) triple.
65
- * @param {string} subject - The entity (e.g., 'alice')
66
- * @param {string} relation - The attribute (e.g., 'lives_in')
67
- * @param {string} object - The value (e.g., 'paris')
68
- * @returns {boolean} true if stored, false if duplicate
69
- */
70
66
  store(subject, relation, object) {
71
67
  const triple = {
72
68
  subject: subject.toLowerCase().trim(),
73
69
  relation: relation.toLowerCase().trim(),
74
70
  object: object.toLowerCase().trim(),
75
71
  };
76
-
77
- // Dedup across all subject buckets
78
72
  for (const b of this._subjectBuckets(subject)) {
79
73
  if (b.triples.some(t =>
80
74
  t.subject === triple.subject &&
@@ -82,36 +76,45 @@ export class HRRMemory {
82
76
  t.object === triple.object
83
77
  )) return false;
84
78
  }
85
-
86
79
  const s = this.symbols.get(subject);
87
80
  const r = this.symbols.get(relation);
88
81
  const o = this.symbols.get(object);
89
82
  const association = bind(bind(s, r), o);
90
-
91
83
  this._activeBucket(subject).storeVector(association, triple);
92
84
  return true;
93
85
  }
94
86
 
95
- // ── Query ──────────────────────────────────────────
87
+ forget(subject, relation, object) {
88
+ const s = subject.toLowerCase().trim();
89
+ const r = relation.toLowerCase().trim();
90
+ const o = object.toLowerCase().trim();
91
+ const ids = this.routing.get(s);
92
+ if (!ids) return false;
93
+ for (let i = 0; i < ids.length; i++) {
94
+ const bucket = this.buckets.get(ids[i]);
95
+ const idx = bucket.triples.findIndex(t =>
96
+ t.subject === s && t.relation === r && t.object === o
97
+ );
98
+ if (idx === -1) continue;
99
+ bucket.triples.splice(idx, 1);
100
+ bucket.rebuild(this.symbols);
101
+ if (bucket.count === 0 && ids[i].includes('#')) {
102
+ this.buckets.delete(ids[i]);
103
+ ids.splice(i, 1);
104
+ }
105
+ return true;
106
+ }
107
+ return false;
108
+ }
96
109
 
97
- /**
98
- * Query: given subject and relation, retrieve the object.
99
- * @param {string} subject
100
- * @param {string} relation
101
- * @returns {{ match: string|null, score: number, confident: boolean, bucket: string|null }}
102
- */
103
110
  query(subject, relation) {
104
111
  const buckets = this._subjectBuckets(subject);
105
112
  if (buckets.length === 0) return { match: null, score: 0, confident: false, bucket: null };
106
-
107
113
  const probe = bind(this.symbols.get(subject), this.symbols.get(relation));
108
114
  let bestName = null, bestScore = -1, bestBucket = null;
109
-
110
115
  for (const bucket of buckets) {
111
116
  if (bucket.count === 0) continue;
112
117
  const result = unbind(probe, bucket.memory);
113
-
114
- // Optimized: only scan object symbols in this bucket
115
118
  for (const t of bucket.triples) {
116
119
  const score = similarity(result, this.symbols.get(t.object));
117
120
  if (score > bestScore) {
@@ -121,7 +124,6 @@ export class HRRMemory {
121
124
  }
122
125
  }
123
126
  }
124
-
125
127
  return {
126
128
  match: bestName,
127
129
  score: Math.round(bestScore * 1000) / 1000,
@@ -130,11 +132,6 @@ export class HRRMemory {
130
132
  };
131
133
  }
132
134
 
133
- /**
134
- * Get all known facts about a subject (symbolic, exact).
135
- * @param {string} subject
136
- * @returns {Array<{ relation: string, object: string }>}
137
- */
138
135
  querySubject(subject) {
139
136
  const key = subject.toLowerCase().trim();
140
137
  const facts = [];
@@ -146,12 +143,6 @@ export class HRRMemory {
146
143
  return facts;
147
144
  }
148
145
 
149
- /**
150
- * Search across all buckets for triples matching a relation and/or object.
151
- * @param {string|null} relation - Filter by relation (null = any)
152
- * @param {string|null} object - Filter by object value (null = any)
153
- * @returns {Array<{ subject: string, relation: string, object: string }>}
154
- */
155
146
  search(relation, object) {
156
147
  const results = [];
157
148
  const rel = relation ? relation.toLowerCase().trim() : null;
@@ -166,37 +157,28 @@ export class HRRMemory {
166
157
  return results;
167
158
  }
168
159
 
169
- /**
170
- * Free-form query: tries subject+relation, then subject lookup, then cross-bucket search.
171
- * @param {string} question
172
- */
173
160
  ask(question) {
174
- const parts = question.toLowerCase().trim().replace(/[?.,!]/g, '').split(/\s+/);
175
-
176
- // Try consecutive word pairs as subject+relation
161
+ const parts = question.toLowerCase().trim()
162
+ .replace(/[?.,!]/g, '')
163
+ .replace(/'s\b/g, '')
164
+ .replace(/-/g, '_')
165
+ .split(/\s+/)
166
+ .filter(w => !STOP_WORDS.has(w) && w.length > 0);
177
167
  for (let i = 0; i < parts.length - 1; i++) {
178
168
  const result = this.query(parts[i], parts[i + 1]);
179
169
  if (result.confident) return { type: 'direct', ...result, subject: parts[i], relation: parts[i + 1] };
180
170
  }
181
-
182
- // Try each word as a subject
183
171
  for (const word of parts) {
184
172
  const facts = this.querySubject(word);
185
173
  if (facts.length > 0) return { type: 'subject', subject: word, facts };
186
174
  }
187
-
188
- // Search across all buckets for any matching object
189
175
  for (const word of parts) {
190
176
  const results = this.search(null, word);
191
177
  if (results.length > 0) return { type: 'search', term: word, results };
192
178
  }
193
-
194
179
  return { type: 'miss', query: question };
195
180
  }
196
181
 
197
- // ── Stats ──────────────────────────────────────────
198
-
199
- /** Get memory statistics */
200
182
  stats() {
201
183
  let totalFacts = 0;
202
184
  const bucketInfo = [];
@@ -219,9 +201,6 @@ export class HRRMemory {
219
201
  };
220
202
  }
221
203
 
222
- // ── Persistence ────────────────────────────────────
223
-
224
- /** Serialize to JSON */
225
204
  toJSON() {
226
205
  const buckets = {};
227
206
  for (const [k, v] of this.buckets) buckets[k] = v.toJSON();
@@ -230,7 +209,6 @@ export class HRRMemory {
230
209
  return { version: 3, d: this.d, symbols: this.symbols.toJSON(), buckets, routing };
231
210
  }
232
211
 
233
- /** Deserialize from JSON */
234
212
  static fromJSON(data) {
235
213
  const d = data.d || 2048;
236
214
  const mem = new HRRMemory(d);
@@ -244,12 +222,10 @@ export class HRRMemory {
244
222
  return mem;
245
223
  }
246
224
 
247
- /** Save to a JSON file */
248
225
  save(filePath) {
249
226
  writeFileSync(filePath, JSON.stringify(this.toJSON()));
250
227
  }
251
228
 
252
- /** Load from a JSON file (returns new empty store if file doesn't exist) */
253
229
  static load(filePath, d = 2048) {
254
230
  if (!existsSync(filePath)) return new HRRMemory(d);
255
231
  try { return HRRMemory.fromJSON(JSON.parse(readFileSync(filePath, 'utf8'))); }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * ObservationMemory — composition wrapper around HRRMemory.
3
+ *
4
+ * Adds timeline recording, conflict detection, and observation consolidation
5
+ * on top of HRRMemory's triple store.
6
+ *
7
+ * @typedef {{ id: string, subject: string, observation: string, evidence: Array<{ ts: number, triple: [string, string, string] }>, confidence: 'high' | 'medium' | 'low', supersedes: string[], createdAt: number }} Observation
8
+ */
9
+
10
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
11
+ import { HRRMemory } from './memory.js';
12
+ import { Timeline } from './timeline.js';
13
+ import { ConflictDetector } from './conflict.js';
14
+ import { defaultPrompt } from './prompt.js';
15
+
16
+ export class ObservationParseError extends Error {
17
+ constructor(message, raw) {
18
+ super(message);
19
+ this.name = 'ObservationParseError';
20
+ this.raw = raw;
21
+ }
22
+ }
23
+
24
+ export class ObservationMemory {
25
+ constructor(hrr, options = {}) {
26
+ this._hrr = hrr;
27
+ this._executor = options.executor || null;
28
+ this._autoConsolidateAfter = options.autoConsolidateAfter ?? null;
29
+ this._promptFn = options.promptFn || defaultPrompt;
30
+ this._timeline = new Timeline();
31
+ this._conflict = new ConflictDetector(hrr, options.conflictThreshold ?? 0.3);
32
+ this._flags = [];
33
+ this._observations = [];
34
+ this._obsCounter = 0;
35
+ this._consolidating = false;
36
+ this._meta = {
37
+ createdAt: Date.now(),
38
+ lastConsolidation: null,
39
+ totalStores: 0,
40
+ totalForgets: 0,
41
+ totalConsolidations: 0,
42
+ };
43
+ }
44
+
45
+ // ── Delegated methods ──────────────────────────────
46
+
47
+ query(subject, relation) { return this._hrr.query(subject, relation); }
48
+ querySubject(subject) { return this._hrr.querySubject(subject); }
49
+ search(relation, object) { return this._hrr.search(relation, object); }
50
+ ask(question) { return this._hrr.ask(question); }
51
+ stats() { return this._hrr.stats(); }
52
+
53
+ // ── Store (async) ──────────────────────────────────
54
+
55
+ async store(subject, relation, object) {
56
+ const ts = Date.now();
57
+ const s = subject.toLowerCase().trim();
58
+ const r = relation.toLowerCase().trim();
59
+ const o = object.toLowerCase().trim();
60
+
61
+ // Check for conflict BEFORE storing (so query sees old state)
62
+ const flag = this._conflict.check(s, r, o, ts);
63
+
64
+ // Delegate to HRR
65
+ const stored = this._hrr.store(subject, relation, object);
66
+
67
+ // Only record timeline entry if actually stored (not a duplicate)
68
+ if (stored) {
69
+ const entry = { ts, subject: s, relation: r, object: o, op: 'store' };
70
+ if (flag) {
71
+ entry.conflict = { oldObject: flag.oldObject, similarity: flag.similarity };
72
+ this._flags.push(flag);
73
+ }
74
+ this._timeline.append(entry);
75
+ this._meta.totalStores++;
76
+
77
+ // Track this pair so future stores trigger conflict checks
78
+ this._conflict.track(s, r);
79
+
80
+ // Auto-consolidation
81
+ if (this._autoConsolidateAfter && this._flags.length >= this._autoConsolidateAfter) {
82
+ await this.consolidate();
83
+ }
84
+ }
85
+
86
+ return stored;
87
+ }
88
+
89
+ // ── Forget (async) ─────────────────────────────────
90
+
91
+ async forget(subject, relation, object) {
92
+ const ts = Date.now();
93
+ const s = subject.toLowerCase().trim();
94
+ const r = relation.toLowerCase().trim();
95
+ const o = object.toLowerCase().trim();
96
+
97
+ const forgotten = this._hrr.forget(subject, relation, object);
98
+
99
+ if (forgotten) {
100
+ this._timeline.append({ ts, subject: s, relation: r, object: o, op: 'forget' });
101
+ this._meta.totalForgets++;
102
+ }
103
+
104
+ return forgotten;
105
+ }
106
+
107
+ // ── Timeline ───────────────────────────────────────
108
+
109
+ history(subject, relation) { return this._timeline.history(subject, relation); }
110
+ at(ts) { return this._timeline.at(ts); }
111
+
112
+ // ── Flags ──────────────────────────────────────────
113
+
114
+ flags() { return [...this._flags]; }
115
+
116
+ // ── Observations ───────────────────────────────────
117
+
118
+ observations(subject) {
119
+ let obs = [...this._observations];
120
+ if (subject) {
121
+ obs = obs.filter(o => o.subject === subject.toLowerCase().trim());
122
+ }
123
+ return obs.sort((a, b) => b.createdAt - a.createdAt);
124
+ }
125
+
126
+ /**
127
+ * Add an observation directly (without calling an LLM).
128
+ * Use this when an external agent has already synthesized the observation.
129
+ * @param {{ subject: string, observation: string, evidence: Array<{ ts: number, triple: [string, string, string] }>, confidence: 'high' | 'medium' | 'low', supersedes?: string[] }} obs
130
+ * @returns {Observation}
131
+ */
132
+ addObservation({ subject, observation, evidence, confidence, supersedes = [] }) {
133
+ const obs = {
134
+ id: `obs_${String(++this._obsCounter).padStart(3, '0')}`,
135
+ subject: subject.toLowerCase().trim(),
136
+ observation,
137
+ evidence,
138
+ confidence,
139
+ supersedes,
140
+ createdAt: Date.now(),
141
+ };
142
+ this._observations.push(obs);
143
+ return obs;
144
+ }
145
+
146
+ /**
147
+ * Clear conflict flags for a specific subject.
148
+ * @param {string} subject
149
+ */
150
+ clearFlags(subject) {
151
+ const s = subject.toLowerCase().trim();
152
+ this._flags = this._flags.filter(f => f.subject !== s);
153
+ }
154
+
155
+ async consolidate() {
156
+ if (!this._executor) {
157
+ throw new Error('No executor configured. Pass executor in ObservationMemory options.');
158
+ }
159
+ if (this._flags.length === 0) return [];
160
+ if (this._consolidating) return [];
161
+
162
+ this._consolidating = true;
163
+ try {
164
+ // Gather timeline entries for flagged subjects
165
+ const subjects = new Set(this._flags.map(f => f.subject));
166
+ const entries = [];
167
+ for (const s of subjects) {
168
+ entries.push(...this._timeline.history(s));
169
+ }
170
+
171
+ // Gather existing observations for flagged subjects
172
+ const existingObservations = this._observations
173
+ .filter(o => subjects.has(o.subject))
174
+ .map(o => ({ id: o.id, subject: o.subject, observation: o.observation, confidence: o.confidence }));
175
+
176
+ // Build prompt
177
+ const input = { entries, flags: [...this._flags], existingObservations };
178
+ const prompt = this._promptFn(input);
179
+
180
+ // Call LLM
181
+ const raw = await this._executor(prompt);
182
+
183
+ // Parse response
184
+ let parsed;
185
+ try {
186
+ parsed = JSON.parse(raw);
187
+ } catch {
188
+ throw new ObservationParseError('Executor returned invalid JSON', raw);
189
+ }
190
+
191
+ if (!parsed.observations || !Array.isArray(parsed.observations)) {
192
+ throw new ObservationParseError('Response missing "observations" array', raw);
193
+ }
194
+
195
+ // Validate and store observations
196
+ const allTimestamps = new Set(this._timeline.all().map(e => e.ts));
197
+ const allObsIds = new Set(this._observations.map(o => o.id));
198
+ const newObs = [];
199
+
200
+ for (const raw of parsed.observations) {
201
+ // Require fields
202
+ if (!raw.subject || !raw.observation || !raw.evidence || !raw.confidence) continue;
203
+
204
+ // Filter evidence to valid timestamps
205
+ const validEvidence = (Array.isArray(raw.evidence) ? raw.evidence : [])
206
+ .filter(e => e.ts && allTimestamps.has(e.ts) && Array.isArray(e.triple));
207
+
208
+ if (validEvidence.length === 0) continue;
209
+
210
+ // Filter supersedes to valid IDs
211
+ const validSupersedes = (Array.isArray(raw.supersedes) ? raw.supersedes : [])
212
+ .filter(id => allObsIds.has(id));
213
+
214
+ const obs = {
215
+ id: `obs_${String(++this._obsCounter).padStart(3, '0')}`,
216
+ subject: raw.subject,
217
+ observation: raw.observation,
218
+ evidence: validEvidence,
219
+ confidence: raw.confidence,
220
+ supersedes: validSupersedes,
221
+ createdAt: Date.now(),
222
+ };
223
+
224
+ this._observations.push(obs);
225
+ allObsIds.add(obs.id);
226
+ newObs.push(obs);
227
+ }
228
+
229
+ // Clear flags
230
+ this._flags = [];
231
+ this._meta.lastConsolidation = Date.now();
232
+ this._meta.totalConsolidations++;
233
+
234
+ return newObs;
235
+ } finally {
236
+ this._consolidating = false;
237
+ }
238
+ }
239
+
240
+ // ── Persistence ────────────────────────────────────
241
+
242
+ toJSON() {
243
+ return {
244
+ version: 1,
245
+ meta: { ...this._meta },
246
+ timeline: this._timeline.toJSON(),
247
+ observations: this._observations.map(o => ({ ...o })),
248
+ flags: this._flags.map(f => ({ ...f })),
249
+ obsCounter: this._obsCounter,
250
+ };
251
+ }
252
+
253
+ static fromJSON(hrr, data, options = {}) {
254
+ const mem = new ObservationMemory(hrr, options);
255
+ if (data.meta) mem._meta = { ...data.meta };
256
+ if (data.timeline) mem._timeline = Timeline.fromJSON(data.timeline);
257
+ if (data.observations) mem._observations = data.observations.map(o => ({ ...o }));
258
+ if (data.flags) mem._flags = data.flags.map(f => ({ ...f }));
259
+ if (typeof data.obsCounter === 'number') mem._obsCounter = data.obsCounter;
260
+ // Rebuild known pairs from timeline so conflict detection works after load
261
+ if (data.timeline) {
262
+ for (const e of data.timeline) {
263
+ if (e.op === 'store') mem._conflict.track(e.subject, e.relation);
264
+ }
265
+ }
266
+ return mem;
267
+ }
268
+
269
+ save(hrrPath, obsPath) {
270
+ this._hrr.save(hrrPath);
271
+ writeFileSync(obsPath, JSON.stringify(this.toJSON(), null, 2));
272
+ }
273
+
274
+ static load(hrrPath, obsPath, options = {}) {
275
+ const hrr = HRRMemory.load(hrrPath);
276
+ if (!existsSync(obsPath)) return new ObservationMemory(hrr, options);
277
+ try {
278
+ const data = JSON.parse(readFileSync(obsPath, 'utf8'));
279
+ return ObservationMemory.fromJSON(hrr, data, options);
280
+ } catch {
281
+ return new ObservationMemory(hrr, options);
282
+ }
283
+ }
284
+ }
package/src/prompt.js ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * defaultPrompt — builds a consolidation prompt for LLM-driven observation synthesis.
3
+ *
4
+ * @typedef {{ entries: Array, flags: Array, existingObservations: Array<{ id: string, subject: string, observation: string, confidence: string }> }} ConsolidationInput
5
+ */
6
+
7
+ export function defaultPrompt(input) {
8
+ const { entries, flags, existingObservations } = input;
9
+
10
+ const timelineSection = entries
11
+ .sort((a, b) => a.ts - b.ts)
12
+ .map(e => {
13
+ const date = new Date(e.ts).toISOString().split('T')[0];
14
+ let line = `[${date}] ${e.op.toUpperCase()} (${e.subject}, ${e.relation}, ${e.object})`;
15
+ if (e.conflict) {
16
+ line += ` ⚡ CONFLICT with '${e.conflict.oldObject}' (sim: ${e.conflict.similarity})`;
17
+ }
18
+ return line;
19
+ })
20
+ .join('\n');
21
+
22
+ let existingSection = '';
23
+ if (existingObservations.length > 0) {
24
+ existingSection = `\n\nExisting observations (use "supersedes" to indicate if a new observation replaces one of these):\n` +
25
+ existingObservations.map(o => `- [${o.id}] (${o.subject}, ${o.confidence}): ${o.observation}`).join('\n');
26
+ }
27
+
28
+ return `You are analyzing changes in an agent's structured memory (a triple store of subject-relation-object facts).
29
+
30
+ The following facts were stored or forgotten over time. Entries marked with ⚡ CONFLICT indicate that a new value was stored for a relation that already had a different value.
31
+
32
+ Timeline:
33
+ ${timelineSection}
34
+ ${existingSection}
35
+
36
+ Your task: Synthesize observations about what has changed in this agent's knowledge.
37
+
38
+ Rules:
39
+ - Distinguish between "shifted from X to Y" (a belief change) and "added Y alongside X" (accumulation). Look at the full timeline context for each subject+relation pair to judge which case applies.
40
+ - Each observation should be 1-2 concise sentences.
41
+ - Set confidence to "high" if the pattern is clear across multiple entries, "medium" for a single conflict, "low" if ambiguous.
42
+ - Use "supersedes" to reference IDs of existing observations that this new observation revises. Use an empty array if this is a new observation.
43
+ - Evidence must reference actual timestamps from the timeline above.
44
+
45
+ Respond with ONLY valid JSON (no markdown fencing, no preamble):
46
+ {
47
+ "observations": [
48
+ {
49
+ "subject": "the primary subject",
50
+ "observation": "concise synthesis of the change",
51
+ "evidence": [
52
+ { "ts": 100, "triple": ["subject", "relation", "object"] }
53
+ ],
54
+ "confidence": "high",
55
+ "supersedes": []
56
+ }
57
+ ]
58
+ }`;
59
+ }
package/src/symbols.js CHANGED
@@ -11,23 +11,18 @@ export class SymbolTable {
11
11
  this.symbols = new Map();
12
12
  }
13
13
 
14
- /** Get or create a vector for a symbol name */
15
14
  get(name) {
16
15
  const key = name.toLowerCase().trim();
17
16
  if (!this.symbols.has(key)) this.symbols.set(key, randomVector(this.d));
18
17
  return this.symbols.get(key);
19
18
  }
20
19
 
21
- /** Check if a symbol exists */
22
20
  has(name) { return this.symbols.has(name.toLowerCase().trim()); }
23
21
 
24
- /** Number of symbols */
25
22
  get size() { return this.symbols.size; }
26
23
 
27
- /** Memory footprint in bytes */
28
24
  get bytes() { return this.symbols.size * this.d * 4; }
29
25
 
30
- /** Find the nearest symbol(s) to a result vector */
31
26
  nearest(vec, candidates = null, topK = 1) {
32
27
  const source = candidates || this.symbols;
33
28
  const results = [];
@@ -38,14 +33,12 @@ export class SymbolTable {
38
33
  return topK === 1 ? results[0] || null : results.slice(0, topK);
39
34
  }
40
35
 
41
- /** Serialize to plain object */
42
36
  toJSON() {
43
37
  const out = {};
44
38
  for (const [k, v] of this.symbols) out[k] = Array.from(v);
45
39
  return out;
46
40
  }
47
41
 
48
- /** Deserialize from plain object */
49
42
  static fromJSON(data, d) {
50
43
  const st = new SymbolTable(d);
51
44
  for (const [k, v] of Object.entries(data)) {
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Timeline — append-only event log for HRR memory operations.
3
+ *
4
+ * @typedef {{ ts: number, subject: string, relation: string, object: string, op: 'store' | 'forget', conflict?: { oldObject: string, similarity: number } }} TimelineEntry
5
+ */
6
+
7
+ export class Timeline {
8
+ constructor() {
9
+ /** @type {TimelineEntry[]} */
10
+ this._entries = [];
11
+ }
12
+
13
+ append(entry) {
14
+ this._entries.push({ ...entry });
15
+ }
16
+
17
+ history(subject, relation) {
18
+ let results = this._entries.filter(e => e.subject === subject);
19
+ if (relation !== undefined) {
20
+ results = results.filter(e => e.relation === relation);
21
+ }
22
+ return results.sort((a, b) => a.ts - b.ts);
23
+ }
24
+
25
+ all() {
26
+ return [...this._entries].sort((a, b) => a.ts - b.ts);
27
+ }
28
+
29
+ get length() {
30
+ return this._entries.length;
31
+ }
32
+
33
+ at(ts) {
34
+ return new SymbolicProxy(this._entries, ts);
35
+ }
36
+
37
+ toJSON() {
38
+ return this._entries.map(e => ({ ...e }));
39
+ }
40
+
41
+ static fromJSON(data) {
42
+ const tl = new Timeline();
43
+ for (const entry of data) {
44
+ tl._entries.push({ ...entry });
45
+ }
46
+ return tl;
47
+ }
48
+ }
49
+
50
+ export class SymbolicProxy {
51
+ constructor(entries, ts) {
52
+ this._entries = entries;
53
+ this._ts = ts;
54
+ }
55
+
56
+ facts(subject, relation) {
57
+ const relevant = this._entries
58
+ .filter(e => e.ts <= this._ts && e.subject === subject
59
+ && (relation === undefined || e.relation === relation))
60
+ .sort((a, b) => a.ts - b.ts);
61
+
62
+ // Use Map<relation\0object, object> to handle colons in values
63
+ const live = new Map();
64
+ for (const e of relevant) {
65
+ const key = `${e.relation}\0${e.object}`;
66
+ if (e.op === 'store') live.set(key, e.object);
67
+ else if (e.op === 'forget') live.delete(key);
68
+ }
69
+ return [...live.values()];
70
+ }
71
+ }
@@ -0,0 +1,257 @@
1
+ // ── Core types ───────────────────────────────────────
2
+
3
+ export interface QueryResult {
4
+ match: string | null;
5
+ score: number;
6
+ confident: boolean;
7
+ bucket: string | null;
8
+ }
9
+
10
+ export interface Fact {
11
+ relation: string;
12
+ object: string;
13
+ }
14
+
15
+ export interface Triple {
16
+ subject: string;
17
+ relation: string;
18
+ object: string;
19
+ }
20
+
21
+ export interface DirectAskResult {
22
+ type: 'direct';
23
+ match: string;
24
+ score: number;
25
+ confident: boolean;
26
+ subject: string;
27
+ relation: string;
28
+ bucket: string | null;
29
+ }
30
+
31
+ export interface SubjectAskResult {
32
+ type: 'subject';
33
+ subject: string;
34
+ facts: Fact[];
35
+ }
36
+
37
+ export interface SearchAskResult {
38
+ type: 'search';
39
+ term: string;
40
+ results: Triple[];
41
+ }
42
+
43
+ export interface MissAskResult {
44
+ type: 'miss';
45
+ query: string;
46
+ }
47
+
48
+ export type AskResult = DirectAskResult | SubjectAskResult | SearchAskResult | MissAskResult;
49
+
50
+ export interface BucketStats {
51
+ name: string;
52
+ facts: number;
53
+ full: boolean;
54
+ }
55
+
56
+ export interface Stats {
57
+ dimensions: number;
58
+ maxBucketSize: number;
59
+ symbols: number;
60
+ buckets: number;
61
+ subjects: number;
62
+ totalFacts: number;
63
+ ramBytes: number;
64
+ ramMB: number;
65
+ perBucket: BucketStats[];
66
+ }
67
+
68
+ export interface SerializedMemory {
69
+ version: number;
70
+ d: number;
71
+ symbols: Record<string, number[]>;
72
+ buckets: Record<string, unknown>;
73
+ routing: Record<string, string[]>;
74
+ }
75
+
76
+ export class HRRMemory {
77
+ constructor(dimensions?: number);
78
+
79
+ store(subject: string, relation: string, object: string): boolean;
80
+ forget(subject: string, relation: string, object: string): boolean;
81
+ query(subject: string, relation: string): QueryResult;
82
+ querySubject(subject: string): Fact[];
83
+ search(relation: string | null, object: string | null): Triple[];
84
+ ask(question: string): AskResult;
85
+ stats(): Stats;
86
+
87
+ save(filePath: string): void;
88
+ static load(filePath: string, dimensions?: number): HRRMemory;
89
+
90
+ toJSON(): SerializedMemory;
91
+ static fromJSON(data: SerializedMemory): HRRMemory;
92
+ }
93
+
94
+ export class SymbolTable {
95
+ constructor(dimensions?: number);
96
+ get(name: string): Float32Array;
97
+ has(name: string): boolean;
98
+ readonly size: number;
99
+ readonly bytes: number;
100
+ nearest(
101
+ vec: Float32Array,
102
+ candidates?: Map<string, Float32Array> | null,
103
+ topK?: number
104
+ ): { name: string; score: number } | { name: string; score: number }[] | null;
105
+ toJSON(): Record<string, number[]>;
106
+ static fromJSON(data: Record<string, number[]>, dimensions: number): SymbolTable;
107
+ }
108
+
109
+ export class Bucket {
110
+ constructor(name: string, dimensions?: number);
111
+ readonly name: string;
112
+ readonly count: number;
113
+ readonly isFull: boolean;
114
+ storeVector(association: Float32Array, triple: Triple): void;
115
+ rebuild(symbols: SymbolTable): void;
116
+ toJSON(): unknown;
117
+ static fromJSON(data: unknown, dimensions: number): Bucket;
118
+ }
119
+
120
+ export function bind(a: Float32Array, b: Float32Array): Float32Array;
121
+ export function unbind(key: Float32Array, memory: Float32Array): Float32Array;
122
+ export function similarity(a: Float32Array, b: Float32Array): number;
123
+ export function randomVector(dimensions: number): Float32Array;
124
+ export function normalize(v: Float32Array): Float32Array;
125
+
126
+ // ── Observation layer types ───��──────────────────────
127
+
128
+ export interface TimelineEntry {
129
+ ts: number;
130
+ subject: string;
131
+ relation: string;
132
+ object: string;
133
+ op: 'store' | 'forget';
134
+ conflict?: { oldObject: string; similarity: number };
135
+ }
136
+
137
+ export interface ConflictFlag {
138
+ ts: number;
139
+ subject: string;
140
+ relation: string;
141
+ newObject: string;
142
+ oldObject: string;
143
+ similarity: number;
144
+ }
145
+
146
+ export interface Observation {
147
+ id: string;
148
+ subject: string;
149
+ observation: string;
150
+ evidence: Array<{ ts: number; triple: [string, string, string] }>;
151
+ confidence: 'high' | 'medium' | 'low';
152
+ supersedes: string[];
153
+ createdAt: number;
154
+ }
155
+
156
+ export interface ConsolidationInput {
157
+ entries: TimelineEntry[];
158
+ flags: ConflictFlag[];
159
+ existingObservations: Array<{
160
+ id: string;
161
+ subject: string;
162
+ observation: string;
163
+ confidence: string;
164
+ }>;
165
+ }
166
+
167
+ export interface ObservationMemoryOptions {
168
+ executor?: (prompt: string) => Promise<string>;
169
+ autoConsolidateAfter?: number | null;
170
+ promptFn?: (input: ConsolidationInput) => string;
171
+ conflictThreshold?: number;
172
+ }
173
+
174
+ export interface SerializedObservationMemory {
175
+ version: number;
176
+ meta: {
177
+ createdAt: number;
178
+ lastConsolidation: number | null;
179
+ totalStores: number;
180
+ totalForgets: number;
181
+ totalConsolidations: number;
182
+ };
183
+ timeline: TimelineEntry[];
184
+ observations: Observation[];
185
+ flags: ConflictFlag[];
186
+ obsCounter: number;
187
+ }
188
+
189
+ export class Timeline {
190
+ constructor();
191
+ append(entry: TimelineEntry): void;
192
+ history(subject: string, relation?: string): TimelineEntry[];
193
+ all(): TimelineEntry[];
194
+ readonly length: number;
195
+ at(ts: number): SymbolicProxy;
196
+ toJSON(): TimelineEntry[];
197
+ static fromJSON(data: TimelineEntry[]): Timeline;
198
+ }
199
+
200
+ export class SymbolicProxy {
201
+ constructor(entries: TimelineEntry[], ts: number);
202
+ facts(subject: string, relation?: string): string[];
203
+ }
204
+
205
+ export class ConflictDetector {
206
+ constructor(hrr: HRRMemory, threshold?: number);
207
+ track(subject: string, relation: string): void;
208
+ check(subject: string, relation: string, object: string, ts?: number): ConflictFlag | null;
209
+ }
210
+
211
+ export class ObservationParseError extends Error {
212
+ constructor(message: string, raw: string);
213
+ readonly name: 'ObservationParseError';
214
+ readonly raw: string;
215
+ }
216
+
217
+ export class ObservationMemory {
218
+ constructor(hrr: HRRMemory, options?: ObservationMemoryOptions);
219
+
220
+ // Delegated to HRRMemory
221
+ query(subject: string, relation: string): QueryResult;
222
+ querySubject(subject: string): Fact[];
223
+ search(relation: string | null, object: string | null): Triple[];
224
+ ask(question: string): AskResult;
225
+ stats(): Stats;
226
+
227
+ // Store/forget with observation tracking
228
+ store(subject: string, relation: string, object: string): Promise<boolean>;
229
+ forget(subject: string, relation: string, object: string): Promise<boolean>;
230
+
231
+ // Timeline
232
+ history(subject: string, relation?: string): TimelineEntry[];
233
+ at(ts: number): SymbolicProxy;
234
+
235
+ // Conflict flags
236
+ flags(): ConflictFlag[];
237
+ clearFlags(subject: string): void;
238
+
239
+ // Observations
240
+ observations(subject?: string): Observation[];
241
+ addObservation(obs: {
242
+ subject: string;
243
+ observation: string;
244
+ evidence: Array<{ ts: number; triple: [string, string, string] }>;
245
+ confidence: 'high' | 'medium' | 'low';
246
+ supersedes?: string[];
247
+ }): Observation;
248
+ consolidate(): Promise<Observation[]>;
249
+
250
+ // Persistence
251
+ save(hrrPath: string, obsPath: string): void;
252
+ static load(hrrPath: string, obsPath: string, options?: ObservationMemoryOptions): ObservationMemory;
253
+ toJSON(): SerializedObservationMemory;
254
+ static fromJSON(hrr: HRRMemory, data: SerializedObservationMemory, options?: ObservationMemoryOptions): ObservationMemory;
255
+ }
256
+
257
+ export function defaultPrompt(input: ConsolidationInput): string;
package/README.md DELETED
@@ -1,64 +0,0 @@
1
- # hrr-memory
2
-
3
- **Your AI agent already has RAG. Now give it instant fact recall.**
4
-
5
- hrr-memory stores structured facts as `(subject, relation, object)` triples and retrieves them in under 2 milliseconds — no vector database, no embeddings API, no dependencies. It complements RAG, not replaces it.
6
-
7
- <p align="center">
8
- <img src="assets/hrr-diagram.svg" alt="How HRR Memory Works" width="800" />
9
- </p>
10
-
11
- ## Install
12
-
13
- ```bash
14
- npm install hrr-memory
15
- ```
16
-
17
- ## 30-Second Demo
18
-
19
- ```js
20
- import { HRRMemory } from 'hrr-memory';
21
-
22
- const mem = new HRRMemory();
23
-
24
- mem.store('alice', 'lives_in', 'paris');
25
- mem.store('alice', 'works_at', 'acme');
26
- mem.store('bob', 'lives_in', 'tokyo');
27
-
28
- mem.query('alice', 'lives_in'); // → { match: 'paris', confident: true }
29
- mem.query('bob', 'lives_in'); // → { match: 'tokyo', confident: true }
30
-
31
- mem.querySubject('alice');
32
- // → [{ relation: 'lives_in', object: 'paris' },
33
- // { relation: 'works_at', object: 'acme' }]
34
-
35
- mem.save('memory.json');
36
- ```
37
-
38
- ## Why Not Just RAG?
39
-
40
- | Query | RAG | hrr-memory |
41
- |-------|-----|------------|
42
- | "What is Alice's timezone?" | Returns paragraphs, maybe | Returns `cet` in <1ms |
43
- | "Find notes about deployment" | Ranked chunks | Can't — not a triple |
44
-
45
- Use both. HRR for structured facts, RAG for semantic search. [See the architecture guide →](docs/architecture.md)
46
-
47
- ## Performance
48
-
49
- | Facts | Accuracy | Query | RAM |
50
- |-------|----------|-------|-----|
51
- | 100 | 100% | <1ms | 0.1 MB |
52
- | 1,000 | 100% | 1.5ms | 4 MB |
53
- | 10,000 | 100% | 1.8ms | 86 MB |
54
-
55
- ## Documentation
56
-
57
- - **[Getting Started](docs/getting-started.md)** — installation, first facts, persistence
58
- - **[API Reference](docs/api.md)** — every method, parameter, and return type
59
- - **[Architecture](docs/architecture.md)** — how HRR works, auto-sharding, RAG integration
60
- - **[Performance](docs/performance.md)** — benchmarks, scaling limits, tuning
61
-
62
- ## License
63
-
64
- MIT