hrr-memory 0.2.0 → 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,7 +1,7 @@
1
1
  {
2
2
  "name": "hrr-memory",
3
- "version": "0.2.0",
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": {
@@ -13,8 +13,9 @@
13
13
  "types/"
14
14
  ],
15
15
  "scripts": {
16
- "test": "node --test test/memory.test.js",
17
- "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"
18
19
  },
19
20
  "keywords": [
20
21
  "hrr",
@@ -38,7 +39,13 @@
38
39
  "associative-memory",
39
40
  "semantic-memory",
40
41
  "ai-agent",
41
- "no-dependencies"
42
+ "no-dependencies",
43
+ "observation",
44
+ "belief-tracking",
45
+ "temporal",
46
+ "conflict-detection",
47
+ "timeline",
48
+ "knowledge-evolution"
42
49
  ],
43
50
  "author": "Jounes",
44
51
  "license": "MIT",
package/src/bucket.js CHANGED
@@ -16,20 +16,14 @@ export class Bucket {
16
16
  this.triples = [];
17
17
  }
18
18
 
19
- /** Add a pre-computed association vector */
20
19
  storeVector(association, triple) {
21
20
  for (let i = 0; i < this.d; i++) this.memory[i] += association[i];
22
21
  this.count++;
23
22
  this.triples.push(triple);
24
23
  }
25
24
 
26
- /** Whether the bucket has reached max capacity */
27
25
  get isFull() { return this.count >= MAX_BUCKET_SIZE; }
28
26
 
29
- /**
30
- * Rebuild the memory vector from remaining triples.
31
- * Called after removing a triple — can't subtract cleanly due to superposition noise.
32
- */
33
27
  rebuild(symbols) {
34
28
  this.memory = new Float32Array(this.d);
35
29
  for (const t of this.triples) {
@@ -42,7 +36,6 @@ export class Bucket {
42
36
  this.count = this.triples.length;
43
37
  }
44
38
 
45
- /** Serialize */
46
39
  toJSON() {
47
40
  return {
48
41
  name: this.name,
@@ -52,7 +45,6 @@ export class Bucket {
52
45
  };
53
46
  }
54
47
 
55
- /** Deserialize */
56
48
  static fromJSON(data, d) {
57
49
  const b = new Bucket(data.name, d);
58
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
@@ -32,67 +32,43 @@ const STOP_WORDS = new Set([
32
32
  ]);
33
33
 
34
34
  export class HRRMemory {
35
- /**
36
- * Create a new HRR memory store.
37
- * @param {number} d - Vector dimensions (default 2048). Higher = more capacity per bucket.
38
- */
39
35
  constructor(d = 2048) {
40
36
  this.d = d;
41
37
  this.symbols = new SymbolTable(d);
42
38
  this.buckets = new Map();
43
- this.routing = new Map(); // subject → [bucket_id, ...]
39
+ this.routing = new Map();
44
40
  }
45
41
 
46
- // ── Bucket management ──────────────────────────────
47
-
48
- /** Get the active (non-full) bucket for a subject, splitting if needed */
49
42
  _activeBucket(subject) {
50
43
  const key = subject.toLowerCase().trim();
51
44
  const ids = this.routing.get(key);
52
-
53
45
  if (ids) {
54
46
  const lastId = ids[ids.length - 1];
55
47
  const last = this.buckets.get(lastId);
56
48
  if (!last.isFull) return last;
57
-
58
- // Overflow: create new bucket
59
49
  const newId = key + '#' + ids.length;
60
50
  const nb = new Bucket(newId, this.d);
61
51
  this.buckets.set(newId, nb);
62
52
  ids.push(newId);
63
53
  return nb;
64
54
  }
65
-
66
- // First bucket for this subject
67
55
  const b = new Bucket(key, this.d);
68
56
  this.buckets.set(key, b);
69
57
  this.routing.set(key, [key]);
70
58
  return b;
71
59
  }
72
60
 
73
- /** Get all buckets for a subject */
74
61
  _subjectBuckets(subject) {
75
62
  const ids = this.routing.get(subject.toLowerCase().trim()) || [];
76
63
  return ids.map(id => this.buckets.get(id)).filter(Boolean);
77
64
  }
78
65
 
79
- // ── Store ──────────────────────────────────────────
80
-
81
- /**
82
- * Store a fact as a (subject, relation, object) triple.
83
- * @param {string} subject - The entity (e.g., 'alice')
84
- * @param {string} relation - The attribute (e.g., 'lives_in')
85
- * @param {string} object - The value (e.g., 'paris')
86
- * @returns {boolean} true if stored, false if duplicate
87
- */
88
66
  store(subject, relation, object) {
89
67
  const triple = {
90
68
  subject: subject.toLowerCase().trim(),
91
69
  relation: relation.toLowerCase().trim(),
92
70
  object: object.toLowerCase().trim(),
93
71
  };
94
-
95
- // Dedup across all subject buckets
96
72
  for (const b of this._subjectBuckets(subject)) {
97
73
  if (b.triples.some(t =>
98
74
  t.subject === triple.subject &&
@@ -100,45 +76,28 @@ export class HRRMemory {
100
76
  t.object === triple.object
101
77
  )) return false;
102
78
  }
103
-
104
79
  const s = this.symbols.get(subject);
105
80
  const r = this.symbols.get(relation);
106
81
  const o = this.symbols.get(object);
107
82
  const association = bind(bind(s, r), o);
108
-
109
83
  this._activeBucket(subject).storeVector(association, triple);
110
84
  return true;
111
85
  }
112
86
 
113
- // ── Forget ─────────────────────────────────────────
114
-
115
- /**
116
- * Remove a fact from memory.
117
- * Rebuilds the affected bucket's memory vector from remaining triples.
118
- * @param {string} subject
119
- * @param {string} relation
120
- * @param {string} object
121
- * @returns {boolean} true if found and removed, false if not found
122
- */
123
87
  forget(subject, relation, object) {
124
88
  const s = subject.toLowerCase().trim();
125
89
  const r = relation.toLowerCase().trim();
126
90
  const o = object.toLowerCase().trim();
127
-
128
91
  const ids = this.routing.get(s);
129
92
  if (!ids) return false;
130
-
131
93
  for (let i = 0; i < ids.length; i++) {
132
94
  const bucket = this.buckets.get(ids[i]);
133
95
  const idx = bucket.triples.findIndex(t =>
134
96
  t.subject === s && t.relation === r && t.object === o
135
97
  );
136
98
  if (idx === -1) continue;
137
-
138
99
  bucket.triples.splice(idx, 1);
139
100
  bucket.rebuild(this.symbols);
140
-
141
- // Clean up empty overflow buckets
142
101
  if (bucket.count === 0 && ids[i].includes('#')) {
143
102
  this.buckets.delete(ids[i]);
144
103
  ids.splice(i, 1);
@@ -148,26 +107,14 @@ export class HRRMemory {
148
107
  return false;
149
108
  }
150
109
 
151
- // ── Query ──────────────────────────────────────────
152
-
153
- /**
154
- * Query: given subject and relation, retrieve the object.
155
- * @param {string} subject
156
- * @param {string} relation
157
- * @returns {{ match: string|null, score: number, confident: boolean, bucket: string|null }}
158
- */
159
110
  query(subject, relation) {
160
111
  const buckets = this._subjectBuckets(subject);
161
112
  if (buckets.length === 0) return { match: null, score: 0, confident: false, bucket: null };
162
-
163
113
  const probe = bind(this.symbols.get(subject), this.symbols.get(relation));
164
114
  let bestName = null, bestScore = -1, bestBucket = null;
165
-
166
115
  for (const bucket of buckets) {
167
116
  if (bucket.count === 0) continue;
168
117
  const result = unbind(probe, bucket.memory);
169
-
170
- // Optimized: only scan object symbols in this bucket
171
118
  for (const t of bucket.triples) {
172
119
  const score = similarity(result, this.symbols.get(t.object));
173
120
  if (score > bestScore) {
@@ -177,7 +124,6 @@ export class HRRMemory {
177
124
  }
178
125
  }
179
126
  }
180
-
181
127
  return {
182
128
  match: bestName,
183
129
  score: Math.round(bestScore * 1000) / 1000,
@@ -186,11 +132,6 @@ export class HRRMemory {
186
132
  };
187
133
  }
188
134
 
189
- /**
190
- * Get all known facts about a subject (symbolic, exact).
191
- * @param {string} subject
192
- * @returns {Array<{ relation: string, object: string }>}
193
- */
194
135
  querySubject(subject) {
195
136
  const key = subject.toLowerCase().trim();
196
137
  const facts = [];
@@ -202,12 +143,6 @@ export class HRRMemory {
202
143
  return facts;
203
144
  }
204
145
 
205
- /**
206
- * Search across all buckets for triples matching a relation and/or object.
207
- * @param {string|null} relation - Filter by relation (null = any)
208
- * @param {string|null} object - Filter by object value (null = any)
209
- * @returns {Array<{ subject: string, relation: string, object: string }>}
210
- */
211
146
  search(relation, object) {
212
147
  const results = [];
213
148
  const rel = relation ? relation.toLowerCase().trim() : null;
@@ -222,51 +157,28 @@ export class HRRMemory {
222
157
  return results;
223
158
  }
224
159
 
225
- /**
226
- * Free-form query: strips stop words and possessives, then tries subject+relation pairs,
227
- * subject lookup, and cross-bucket search in that order.
228
- * @param {string} question - Natural language question (e.g., "What is alice's timezone?")
229
- * @returns {AskResult} One of: DirectAskResult, SubjectAskResult, SearchAskResult, or MissAskResult
230
- * @example
231
- * mem.store('alice', 'timezone', 'cet');
232
- * mem.ask("What is alice's timezone?"); // → { type: 'direct', match: 'cet', ... }
233
- * mem.ask('alice'); // → { type: 'subject', facts: [...] }
234
- */
235
160
  ask(question) {
236
161
  const parts = question.toLowerCase().trim()
237
162
  .replace(/[?.,!]/g, '')
238
- .replace(/'s\b/g, '') // possessive: alice's → alice
239
- .replace(/-/g, '_') // lives-in → lives_in
163
+ .replace(/'s\b/g, '')
164
+ .replace(/-/g, '_')
240
165
  .split(/\s+/)
241
166
  .filter(w => !STOP_WORDS.has(w) && w.length > 0);
242
-
243
- // Try consecutive word pairs as subject+relation
244
167
  for (let i = 0; i < parts.length - 1; i++) {
245
168
  const result = this.query(parts[i], parts[i + 1]);
246
169
  if (result.confident) return { type: 'direct', ...result, subject: parts[i], relation: parts[i + 1] };
247
170
  }
248
-
249
- // Try each word as a subject
250
171
  for (const word of parts) {
251
172
  const facts = this.querySubject(word);
252
173
  if (facts.length > 0) return { type: 'subject', subject: word, facts };
253
174
  }
254
-
255
- // Search across all buckets for any matching object
256
175
  for (const word of parts) {
257
176
  const results = this.search(null, word);
258
177
  if (results.length > 0) return { type: 'search', term: word, results };
259
178
  }
260
-
261
179
  return { type: 'miss', query: question };
262
180
  }
263
181
 
264
- // ── Stats ──────────────────────────────────────────
265
-
266
- /**
267
- * Get memory statistics: dimensions, bucket count, total facts, RAM usage.
268
- * @returns {Stats}
269
- */
270
182
  stats() {
271
183
  let totalFacts = 0;
272
184
  const bucketInfo = [];
@@ -289,12 +201,6 @@ export class HRRMemory {
289
201
  };
290
202
  }
291
203
 
292
- // ── Persistence ────────────────────────────────────
293
-
294
- /**
295
- * Serialize the entire memory store to a plain object.
296
- * @returns {{ version: number, d: number, symbols: object, buckets: object, routing: object }}
297
- */
298
204
  toJSON() {
299
205
  const buckets = {};
300
206
  for (const [k, v] of this.buckets) buckets[k] = v.toJSON();
@@ -303,11 +209,6 @@ export class HRRMemory {
303
209
  return { version: 3, d: this.d, symbols: this.symbols.toJSON(), buckets, routing };
304
210
  }
305
211
 
306
- /**
307
- * Deserialize from a plain object (as produced by toJSON).
308
- * @param {object} data - Serialized memory data
309
- * @returns {HRRMemory}
310
- */
311
212
  static fromJSON(data) {
312
213
  const d = data.d || 2048;
313
214
  const mem = new HRRMemory(d);
@@ -321,20 +222,10 @@ export class HRRMemory {
321
222
  return mem;
322
223
  }
323
224
 
324
- /**
325
- * Save the memory store to a JSON file.
326
- * @param {string} filePath - Path to write the JSON file
327
- */
328
225
  save(filePath) {
329
226
  writeFileSync(filePath, JSON.stringify(this.toJSON()));
330
227
  }
331
228
 
332
- /**
333
- * Load a memory store from a JSON file. Returns a new empty store if the file doesn't exist.
334
- * @param {string} filePath - Path to the JSON file
335
- * @param {number} [d=2048] - Vector dimensions (used only if creating new store)
336
- * @returns {HRRMemory}
337
- */
338
229
  static load(filePath, d = 2048) {
339
230
  if (!existsSync(filePath)) return new HRRMemory(d);
340
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
+ }
package/types/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ // ── Core types ───────────────────────────────────────
2
+
1
3
  export interface QueryResult {
2
4
  match: string | null;
3
5
  score: number;
@@ -120,3 +122,136 @@ export function unbind(key: Float32Array, memory: Float32Array): Float32Array;
120
122
  export function similarity(a: Float32Array, b: Float32Array): number;
121
123
  export function randomVector(dimensions: number): Float32Array;
122
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