hrr-memory 0.2.0 → 0.3.1
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/README.md +137 -22
- package/package.json +12 -5
- package/src/bucket.js +0 -8
- package/src/conflict.js +50 -0
- package/src/index.js +12 -5
- package/src/memory.js +3 -112
- 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 +135 -0
package/README.md
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
# hrr-memory
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Structured fact recall for AI agents. Sub-2ms queries, zero dependencies.**
|
|
4
4
|
|
|
5
|
-
hrr-memory stores
|
|
6
|
-
|
|
7
|
-
<p align="center">
|
|
8
|
-
<img src="assets/hrr-diagram.svg" alt="How HRR Memory Works" width="800" />
|
|
9
|
-
</p>
|
|
5
|
+
hrr-memory stores facts as `(subject, relation, object)` triples using Holographic Reduced Representations and retrieves them instantly — no vector database, no embeddings API. Includes built-in temporal awareness, conflict detection, and LLM-driven belief synthesis.
|
|
10
6
|
|
|
11
7
|
## Install
|
|
12
8
|
|
|
@@ -14,7 +10,7 @@ hrr-memory stores structured facts as `(subject, relation, object)` triples and
|
|
|
14
10
|
npm install hrr-memory
|
|
15
11
|
```
|
|
16
12
|
|
|
17
|
-
##
|
|
13
|
+
## Quick Demo
|
|
18
14
|
|
|
19
15
|
```js
|
|
20
16
|
import { HRRMemory } from 'hrr-memory';
|
|
@@ -25,39 +21,158 @@ mem.store('alice', 'lives_in', 'paris');
|
|
|
25
21
|
mem.store('alice', 'works_at', 'acme');
|
|
26
22
|
mem.store('bob', 'lives_in', 'tokyo');
|
|
27
23
|
|
|
28
|
-
mem.query('alice', 'lives_in'); //
|
|
29
|
-
mem.query('bob', 'lives_in'); //
|
|
24
|
+
mem.query('alice', 'lives_in'); // { match: 'paris', confident: true }
|
|
25
|
+
mem.query('bob', 'lives_in'); // { match: 'tokyo', confident: true }
|
|
30
26
|
|
|
31
27
|
mem.querySubject('alice');
|
|
32
|
-
//
|
|
33
|
-
//
|
|
28
|
+
// [{ relation: 'lives_in', object: 'paris' },
|
|
29
|
+
// { relation: 'works_at', object: 'acme' }]
|
|
30
|
+
|
|
31
|
+
mem.ask("What is alice's timezone?");
|
|
32
|
+
// Direct lookup with stop-word stripping and natural language handling
|
|
34
33
|
|
|
35
34
|
mem.save('memory.json');
|
|
36
35
|
```
|
|
37
36
|
|
|
38
37
|
## Why Not Just RAG?
|
|
39
38
|
|
|
40
|
-
| Query | RAG | hrr-memory |
|
|
41
|
-
|
|
39
|
+
| Query type | RAG | hrr-memory |
|
|
40
|
+
|---|---|---|
|
|
42
41
|
| "What is Alice's timezone?" | Returns paragraphs, maybe | Returns `cet` in <1ms |
|
|
43
|
-
| "Find notes about deployment" | Ranked chunks |
|
|
42
|
+
| "Find notes about deployment" | Ranked chunks | Not suited — use RAG |
|
|
43
|
+
| "What changed about Alice?" | No built-in tracking | Conflict detection + timeline |
|
|
44
|
+
|
|
45
|
+
Use both. HRR for structured facts, RAG for semantic search.
|
|
46
|
+
|
|
47
|
+
## Observation Layer
|
|
48
|
+
|
|
49
|
+
Track how knowledge evolves over time. ObservationMemory wraps HRRMemory with a timeline, automatic conflict detection, and belief synthesis.
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
import { HRRMemory, ObservationMemory } from 'hrr-memory';
|
|
53
|
+
|
|
54
|
+
const hrr = new HRRMemory();
|
|
55
|
+
const mem = new ObservationMemory(hrr);
|
|
56
|
+
|
|
57
|
+
await mem.store('alice', 'interested_in', 'rust');
|
|
58
|
+
await mem.store('alice', 'interested_in', 'payments');
|
|
59
|
+
|
|
60
|
+
mem.flags();
|
|
61
|
+
// [{ subject: 'alice', oldObject: 'rust', newObject: 'payments', similarity: 0.05 }]
|
|
62
|
+
|
|
63
|
+
mem.history('alice');
|
|
64
|
+
// [{ ts: ..., op: 'store', object: 'rust' },
|
|
65
|
+
// { ts: ..., op: 'store', object: 'payments', conflict: { oldObject: 'rust' } }]
|
|
66
|
+
```
|
|
44
67
|
|
|
45
|
-
|
|
68
|
+
Conflict detection is algebraic — it compares HRR symbol vectors using cosine similarity. Low similarity between old and new values means a belief change, which gets flagged automatically.
|
|
69
|
+
|
|
70
|
+
## Point-in-Time Queries
|
|
71
|
+
|
|
72
|
+
Symbolic replay of the timeline. Fast and cheap — no HRR rebuild.
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
mem.at(lastWeek).facts('alice', 'interested_in');
|
|
76
|
+
// ['rust'] — what we knew then
|
|
77
|
+
|
|
78
|
+
mem.at(Date.now()).facts('alice', 'interested_in');
|
|
79
|
+
// ['rust', 'payments'] — what we know now
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## LLM Consolidation
|
|
83
|
+
|
|
84
|
+
Flags accumulate until you consolidate them. The library builds the prompt; you bring the LLM.
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
const mem = new ObservationMemory(hrr, {
|
|
88
|
+
executor: (prompt) => callYourLLM(prompt),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// After conflicting stores...
|
|
92
|
+
const observations = await mem.consolidate();
|
|
93
|
+
// [{ subject: 'alice', observation: 'Interest shifted from Rust to payments',
|
|
94
|
+
// evidence: [...], confidence: 'high' }]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Or skip the LLM and write observations directly:
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
mem.addObservation({
|
|
101
|
+
subject: 'alice',
|
|
102
|
+
observation: 'Interest shifted from Rust to payments',
|
|
103
|
+
evidence: [{ ts: 1711234567890, triple: ['alice', 'interested_in', 'rust'] }],
|
|
104
|
+
confidence: 'high',
|
|
105
|
+
});
|
|
106
|
+
```
|
|
46
107
|
|
|
47
108
|
## Performance
|
|
48
109
|
|
|
49
|
-
| Facts | Accuracy | Query | RAM |
|
|
50
|
-
|
|
110
|
+
| Facts | Accuracy | Query time | RAM |
|
|
111
|
+
|---|---|---|---|
|
|
51
112
|
| 100 | 100% | <1ms | 0.1 MB |
|
|
52
113
|
| 1,000 | 100% | 1.5ms | 4 MB |
|
|
53
114
|
| 10,000 | 100% | 1.8ms | 86 MB |
|
|
54
115
|
|
|
55
|
-
|
|
116
|
+
Auto-sharding kicks in at 25 facts per subject. Accuracy stays at 100% across shards.
|
|
117
|
+
|
|
118
|
+
## API
|
|
119
|
+
|
|
120
|
+
### HRRMemory (core)
|
|
121
|
+
|
|
122
|
+
| Method | Returns | Description |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `store(s, r, o)` | `boolean` | Store a triple. Returns false if duplicate. |
|
|
125
|
+
| `forget(s, r, o)` | `boolean` | Remove a triple. |
|
|
126
|
+
| `query(s, r)` | `QueryResult` | Retrieve the object for a subject+relation pair. |
|
|
127
|
+
| `querySubject(s)` | `Fact[]` | All facts about a subject. |
|
|
128
|
+
| `search(r?, o?)` | `Triple[]` | Find triples by relation and/or object. |
|
|
129
|
+
| `ask(question)` | `AskResult` | Natural language query with stop-word handling. |
|
|
130
|
+
| `stats()` | `Stats` | Memory usage, bucket info, fact counts. |
|
|
131
|
+
| `save(path)` | `void` | Persist to JSON file. |
|
|
132
|
+
| `HRRMemory.load(path)` | `HRRMemory` | Load from JSON file. |
|
|
133
|
+
|
|
134
|
+
### ObservationMemory (observation layer)
|
|
135
|
+
|
|
136
|
+
All HRRMemory methods are delegated (query, querySubject, search, ask, stats).
|
|
137
|
+
|
|
138
|
+
| Method | Returns | Description |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| `await store(s, r, o)` | `boolean` | Store with timeline recording and conflict detection. |
|
|
141
|
+
| `await forget(s, r, o)` | `boolean` | Forget with timeline recording. |
|
|
142
|
+
| `history(s, r?)` | `TimelineEntry[]` | Temporal history, oldest first. |
|
|
143
|
+
| `at(ts).facts(s, r?)` | `string[]` | Point-in-time symbolic query. |
|
|
144
|
+
| `flags()` | `ConflictFlag[]` | Unflushed conflict flags. |
|
|
145
|
+
| `clearFlags(subject)` | `void` | Clear flags for a subject. |
|
|
146
|
+
| `observations(s?)` | `Observation[]` | Synthesized beliefs, newest first. |
|
|
147
|
+
| `addObservation(obs)` | `Observation` | Store observation directly (no LLM). |
|
|
148
|
+
| `await consolidate()` | `Observation[]` | LLM-driven synthesis of flagged changes. |
|
|
149
|
+
| `save(hrrPath, obsPath)` | `void` | Persist both stores. |
|
|
150
|
+
| `ObservationMemory.load(hrrPath, obsPath, opts)` | `ObservationMemory` | Load both stores. |
|
|
151
|
+
|
|
152
|
+
### Standalone Components
|
|
56
153
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
154
|
+
Each layer works independently:
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
import { Timeline, ConflictDetector, defaultPrompt } from 'hrr-memory';
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## TypeScript
|
|
161
|
+
|
|
162
|
+
Full type definitions included. All interfaces and classes are exported from `types/index.d.ts`.
|
|
163
|
+
|
|
164
|
+
## Ecosystem
|
|
165
|
+
|
|
166
|
+
- **[openclaw-hrr-memory](https://github.com/Joncik91/openclaw-hrr-memory)** — OpenClaw plugin for agent fact recall
|
|
167
|
+
|
|
168
|
+
## Migration from hrr-memory-obs
|
|
169
|
+
|
|
170
|
+
The `hrr-memory-obs` package is deprecated. All its exports are now part of `hrr-memory`:
|
|
171
|
+
|
|
172
|
+
```diff
|
|
173
|
+
- import { ObservationMemory } from 'hrr-memory-obs';
|
|
174
|
+
+ import { ObservationMemory } from 'hrr-memory';
|
|
175
|
+
```
|
|
61
176
|
|
|
62
177
|
## License
|
|
63
178
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hrr-memory",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Holographic Reduced Representations for structured agent memory.
|
|
3
|
+
"version": "0.3.1",
|
|
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);
|
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
|
@@ -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();
|
|
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, '')
|
|
239
|
-
.replace(/-/g, '_')
|
|
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)) {
|
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
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;
|