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 +15 -6
- package/src/bucket.js +14 -4
- package/src/conflict.js +50 -0
- package/src/index.js +12 -5
- package/src/memory.js +47 -71
- package/src/observation.js +284 -0
- package/src/prompt.js +59 -0
- package/src/symbols.js +0 -7
- package/src/timeline.js +71 -0
- package/types/index.d.ts +257 -0
- package/README.md +0 -64
package/package.json
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hrr-memory",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Holographic Reduced Representations for structured agent memory.
|
|
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
|
-
|
|
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);
|
package/src/conflict.js
ADDED
|
@@ -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
|
|
9
|
-
* mem
|
|
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();
|
|
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
|
-
|
|
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()
|
|
175
|
-
|
|
176
|
-
|
|
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)) {
|
package/src/timeline.js
ADDED
|
@@ -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
ADDED
|
@@ -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
|