pi-observational-memory 2.1.3 → 2.4.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/README.md +40 -22
- package/package.json +10 -6
- package/src/branch.ts +395 -8
- package/src/commands/status.ts +37 -11
- package/src/commands/view.ts +6 -5
- package/src/compaction.ts +331 -45
- package/src/config.ts +21 -2
- package/src/hooks/compaction-hook.ts +31 -25
- package/src/hooks/compaction-trigger.ts +31 -23
- package/src/hooks/observer-trigger.ts +17 -10
- package/src/index.ts +2 -0
- package/src/observer.ts +42 -4
- package/src/prompts.ts +44 -15
- package/src/runtime.ts +6 -1
- package/src/serialize.ts +144 -31
- package/src/tools/recall-observation.ts +613 -0
- package/src/types.ts +101 -10
package/README.md
CHANGED
|
@@ -22,31 +22,34 @@ What the agent sees after compaction looks like this:
|
|
|
22
22
|
|
|
23
23
|
```
|
|
24
24
|
## Reflections
|
|
25
|
-
User works at Acme Corp building Acme Dashboard on Next.js 15 with Supabase auth.
|
|
26
|
-
Hard constraint: ship by January 22nd 2026.
|
|
27
|
-
Public API uses GraphQL (switched from REST to reduce mobile over-fetching).
|
|
25
|
+
[a1b2c3d4e5f6] User works at Acme Corp building Acme Dashboard on Next.js 15 with Supabase auth.
|
|
26
|
+
[b2c3d4e5f6a1] Hard constraint: ship by January 22nd 2026.
|
|
27
|
+
[c3d4e5f6a1b2] Public API uses GraphQL (switched from REST to reduce mobile over-fetching).
|
|
28
28
|
|
|
29
29
|
## Observations
|
|
30
|
-
2026-01-15 14:30 [high] User decided to switch from REST to GraphQL for the public API; motivation was reducing over-fetching on mobile clients.
|
|
31
|
-
2026-01-15 14:35 [medium] Agent scaffolded GraphQL schema in src/schema.ts.
|
|
32
|
-
2026-01-15 14:50 [medium] GraphQL migration completed; user confirmed queries working.
|
|
33
|
-
2026-01-15 15:10 [critical] User wants rate limiting on all public endpoints; prefers token bucket algorithm at 100 req/min per API key.
|
|
30
|
+
[d4e5f6a1b2c3] 2026-01-15 14:30 [high] User decided to switch from REST to GraphQL for the public API; motivation was reducing over-fetching on mobile clients.
|
|
31
|
+
[e5f6a1b2c3d4] 2026-01-15 14:35 [medium] Agent scaffolded GraphQL schema in src/schema.ts.
|
|
32
|
+
[f6a1b2c3d4e5] 2026-01-15 14:50 [medium] GraphQL migration completed; user confirmed queries working.
|
|
33
|
+
[a6b1c2d3e4f5] 2026-01-15 15:10 [critical] User wants rate limiting on all public endpoints; prefers token bucket algorithm at 100 req/min per API key.
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
Two layers of memory, two different jobs:
|
|
37
37
|
|
|
38
|
-
- **Reflections** are durable patterns — who you are, what you've decided, hard constraints.
|
|
39
|
-
- **Observations** are timestamped events with a per-entry relevance tier (`low` / `medium` / `high` / `critical`). They're written near-real-time, then pruned over time — but never paraphrased.
|
|
38
|
+
- **Reflections** are durable patterns — who you are, what you've decided, hard constraints. They render as plain prose with an id handle when recallable, and persist across future compactions.
|
|
39
|
+
- **Observations** are timestamped events with an id and a per-entry relevance tier (`low` / `medium` / `high` / `critical`). They're written near-real-time, then pruned over time — but never paraphrased.
|
|
40
|
+
|
|
41
|
+
Those ids are not decoration. When the agent needs exact evidence behind a compacted memory item, it can call the agent-facing `recall` tool with a reflection or observation id. The TUI shows a compact evidence summary, while the agent receives the full raw source context that produced the memory.
|
|
40
42
|
|
|
41
43
|
Hour six should feel like hour one. The agent knows who you are, what you've built together, and what's left to do.
|
|
42
44
|
|
|
43
45
|
## What you actually get from it
|
|
44
46
|
|
|
45
|
-
- **Continuity
|
|
47
|
+
- **Continuity across many compactions.** The summary is built by mechanical concatenation, not an LLM rewrite. Kept observations and reflections are carried forward without paraphrase; observations may be pruned later, but they are never rewritten into summary-of-summary drift.
|
|
46
48
|
- **Temporal reasoning.** Every observation carries a per-minute timestamp. The agent can reason about *when* something happened, not just *that* it happened.
|
|
49
|
+
- **Source-backed recall.** Observation and reflection ids let the agent recover the exact prior conversation/tool evidence behind compacted memory when precision matters.
|
|
47
50
|
- **Relevance-aware pruning.** Four relevance tiers drive what gets dropped first when the observation pool grows. Trivia goes; user assertions, decisions, and verbatim errors stay.
|
|
48
51
|
- **Reflections that crystallize.** Identity, constraints, and durable preferences settle into a separate layer that doesn't get re-paraphrased on each compaction.
|
|
49
|
-
- **Predictable token cost.** Properly configured for your use case, this can save real money. The reflector + pruner only run above a configurable gate, so
|
|
52
|
+
- **Predictable token cost.** Properly configured for your use case, this can save real money. The reflector + pruner only run above a configurable gate, so below-gate compactions skip those LLM calls; they only call a model if sync catch-up observation is needed. The observer can be pointed at a cheap fast model independently of your main coding model.
|
|
50
53
|
- **Cache-friendly by design.** Memory updates are batched at compaction boundaries instead of injected into every turn, so prompt prefix caching keeps working between compactions.
|
|
51
54
|
- **Fewer mid-work surprises.** The extension proactively triggers compaction when the agent is idle, and this will not affect your current work as after compaction you still keep the tail of your session intact.
|
|
52
55
|
|
|
@@ -73,19 +76,19 @@ flowchart TD
|
|
|
73
76
|
Conv([Conversation accumulates])
|
|
74
77
|
Obs[Observer<br/>async, fire-and-forget<br/>compresses each chunk into timestamped,<br/>relevance-tagged observations<br/>stored as silent tree entries]
|
|
75
78
|
Comp[Compaction<br/>extension-owned; merges accumulated<br/>observations with prior compaction state]
|
|
76
|
-
RP[Reflector + Pruner<br/>Reflector
|
|
77
|
-
Sum[Summary mechanically assembled<br/>## Reflections<br/>
|
|
79
|
+
RP[Reflector + Pruner<br/>Reflector runs focused passes<br/>to crystallize durable patterns<br/>Pruner drops observations by id<br/>across up to 5 passes]
|
|
80
|
+
Sum[Summary mechanically assembled<br/>## Reflections<br/> [id] durable insight<br/>## Observations<br/> [id] YYYY-MM-DD HH:MM relevance ...<br/>Becomes the compactionSummary<br/>the agent sees on the next turn]
|
|
78
81
|
|
|
79
82
|
Conv -->|every ~1k raw tokens since last bound| Obs
|
|
80
|
-
Obs -->|
|
|
83
|
+
Obs -->|live tail reaches ~50k raw tokens| Comp
|
|
81
84
|
Comp -->|observation pool ≥ 30k tokens| RP
|
|
82
85
|
RP --> Sum
|
|
83
|
-
Comp -.->|pool below gate — skip
|
|
86
|
+
Comp -.->|pool below gate — skip reflector/pruner| Sum
|
|
84
87
|
```
|
|
85
88
|
|
|
86
89
|
- **Observer** runs in the background as turns complete. The user never waits on it.
|
|
87
90
|
- **Compaction** is owned by the extension. The summary is *mechanically concatenated* from current reflections + current observations — never an LLM rewrite. This is what eliminates the summary-of-a-summary problem.
|
|
88
|
-
- **Reflector + Pruner** run as an inseparable pair, and only when there's enough material to crystallize. Below the gate, compaction
|
|
91
|
+
- **Reflector + Pruner** run as an inseparable pair, and only when there's enough material to crystallize. Below the gate, those roles are skipped; compaction only calls a model if sync catch-up observation is needed for uncovered raw history.
|
|
89
92
|
|
|
90
93
|
The agent only ever sees the most recent compaction summary, packaged as a normal `compactionSummary` message. Observations and reflections are never injected into the live message stream — that would invalidate prefix caching with every observation. By batching memory updates at compaction boundaries, the prefix stays stable between compactions and prefix caching keeps working.
|
|
91
94
|
|
|
@@ -104,7 +107,8 @@ Settings live in Pi's `settings.json` — globally at `~/.pi/agent/settings.json
|
|
|
104
107
|
"observational-memory": {
|
|
105
108
|
"observationThresholdTokens": 1000,
|
|
106
109
|
"compactionThresholdTokens": 50000,
|
|
107
|
-
"reflectionThresholdTokens": 30000
|
|
110
|
+
"reflectionThresholdTokens": 30000,
|
|
111
|
+
"passive": false
|
|
108
112
|
},
|
|
109
113
|
"compaction": {
|
|
110
114
|
"keepRecentTokens": 20000
|
|
@@ -112,26 +116,40 @@ Settings live in Pi's `settings.json` — globally at `~/.pi/agent/settings.json
|
|
|
112
116
|
}
|
|
113
117
|
```
|
|
114
118
|
|
|
115
|
-
|
|
119
|
+
To run the background memory work (observer, reflector, pruner) on a cheaper / faster model than your main coding agent — often the single biggest cost lever the extension exposes — add `compactionModel`:
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{
|
|
123
|
+
"observational-memory": {
|
|
124
|
+
"compactionModel": { "provider": "openrouter", "id": "google/gemma-4-31b-it" }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The six settings most worth knowing:
|
|
116
130
|
|
|
117
131
|
| Setting | Default | What it controls |
|
|
118
132
|
|---|---|---|
|
|
119
133
|
| `observationThresholdTokens` | `1,000` | How often the observer fires in the background |
|
|
120
134
|
| `compactionThresholdTokens` | `50,000` | How often the extension proactively triggers compaction |
|
|
121
135
|
| `reflectionThresholdTokens` | `30,000` | The observation pool size at which reflector + pruner engage |
|
|
136
|
+
| `passive` | `false` | Disables proactive observation and extension-triggered compaction while keeping manual/Pi compaction and commands active |
|
|
122
137
|
| `compactionModel` | session model | Which model runs the observer / reflector / pruner — point at a cheaper one to save cost |
|
|
123
138
|
| `compaction.keepRecentTokens` | `20,000` | How much recent conversation Pi keeps verbatim post-compaction (Pi setting; structural to the extension) |
|
|
124
139
|
|
|
140
|
+
For shell/session-level control, `PI_OBSERVATIONAL_MEMORY_PASSIVE` overrides global and project settings. Use `1`, `true`, `yes`, or `on` to enable passive mode; use `0`, `false`, `no`, or `off` to force it off.
|
|
141
|
+
|
|
125
142
|
For the full list and tuning recipes, see **[docs/configuration.md](docs/configuration.md)**.
|
|
126
143
|
|
|
127
144
|
> **Upgrading from `pi-observational-memory@1.x`?** The config keys changed: v1's `observationThreshold` is now `compactionThresholdTokens`, v1's `reflectionThreshold` is now `reflectionThresholdTokens`, and `observationThresholdTokens` is new. Old v1 keys are silently ignored — update your `settings.json`.
|
|
128
145
|
|
|
129
|
-
## Commands
|
|
146
|
+
## Commands and agent tool
|
|
130
147
|
|
|
131
|
-
|
|
|
148
|
+
| Surface | What it does |
|
|
132
149
|
|---|---|
|
|
133
|
-
| `/om-status` | Memory totals, percent-to-threshold for each gate, and in-flight flags for observer and compaction |
|
|
134
|
-
| `/om-view` | Full dump of memory state: every reflection, every committed observation, every pending observation.
|
|
150
|
+
| `/om-status` | Memory totals, percent-to-threshold for each gate, passive-mode status, and in-flight flags for observer and compaction |
|
|
151
|
+
| `/om-view` | Full dump of memory state: every reflection, every committed observation, every pending observation. Reflection and observation ids shown here can be used with `recall` |
|
|
152
|
+
| `recall` agent tool | Recovers exact source evidence for a specific reflection or observation id on the current branch. It is for agent self-recovery and provenance, not a user command, semantic search, or transcript browser |
|
|
135
153
|
|
|
136
154
|
## Credits
|
|
137
155
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-observational-memory",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Observational memory extension for pi — cache-friendly tiered compaction with observations and reflections.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,18 +30,22 @@
|
|
|
30
30
|
"README.md"
|
|
31
31
|
],
|
|
32
32
|
"scripts": {
|
|
33
|
-
"typecheck": "tsc --noEmit"
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"test": "vitest run"
|
|
34
35
|
},
|
|
35
36
|
"peerDependencies": {
|
|
36
|
-
"@mariozechner/pi-
|
|
37
|
+
"@mariozechner/pi-agent-core": "*",
|
|
37
38
|
"@mariozechner/pi-ai": "*",
|
|
38
|
-
"@mariozechner/pi-agent
|
|
39
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
40
|
+
"@mariozechner/pi-tui": "*"
|
|
39
41
|
},
|
|
40
42
|
"devDependencies": {
|
|
41
|
-
"@mariozechner/pi-ai": "^0.66.1",
|
|
42
43
|
"@mariozechner/pi-agent-core": "^0.66.1",
|
|
44
|
+
"@mariozechner/pi-ai": "^0.66.1",
|
|
43
45
|
"@mariozechner/pi-coding-agent": "^0.66.1",
|
|
46
|
+
"@mariozechner/pi-tui": "^0.66.1",
|
|
44
47
|
"@types/node": "^22.0.0",
|
|
45
|
-
"typescript": "^5.6.0"
|
|
48
|
+
"typescript": "^5.6.0",
|
|
49
|
+
"vitest": "^4.1.5"
|
|
46
50
|
}
|
|
47
51
|
}
|
package/src/branch.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
OBSERVATION_CUSTOM_TYPE,
|
|
3
|
-
isMemoryDetails,
|
|
4
3
|
isObservationEntryData,
|
|
5
|
-
|
|
4
|
+
isReflectionRecord,
|
|
5
|
+
isSupportedMemoryDetails,
|
|
6
|
+
type MemoryReflection,
|
|
6
7
|
type ObservationEntryData,
|
|
7
8
|
type ObservationRecord,
|
|
8
|
-
type
|
|
9
|
+
type ReflectionRecord,
|
|
10
|
+
type SupportedMemoryDetails,
|
|
9
11
|
} from "./types.js";
|
|
10
12
|
import { estimateEntryTokens } from "./tokens.js";
|
|
11
13
|
|
|
12
|
-
type Entry = {
|
|
14
|
+
export type Entry = {
|
|
13
15
|
type: string;
|
|
14
16
|
id: string;
|
|
15
17
|
timestamp?: string;
|
|
@@ -25,10 +27,130 @@ type Entry = {
|
|
|
25
27
|
|
|
26
28
|
const RAW_TYPES = new Set(["message", "custom_message", "branch_summary"]);
|
|
27
29
|
|
|
30
|
+
export function isSourceEntry(entry: Entry): boolean {
|
|
31
|
+
return RAW_TYPES.has(entry.type);
|
|
32
|
+
}
|
|
33
|
+
|
|
28
34
|
function isObservationEntry(entry: Entry): boolean {
|
|
29
35
|
return entry.type === "custom" && entry.customType === OBSERVATION_CUSTOM_TYPE;
|
|
30
36
|
}
|
|
31
37
|
|
|
38
|
+
export type RecallObservationMatch =
|
|
39
|
+
| {
|
|
40
|
+
status: "ok";
|
|
41
|
+
observation: ObservationRecord;
|
|
42
|
+
observationEntryId: string;
|
|
43
|
+
sourceEntryIds: string[];
|
|
44
|
+
sourceEntries: Entry[];
|
|
45
|
+
}
|
|
46
|
+
| {
|
|
47
|
+
status: "no_source";
|
|
48
|
+
observation: ObservationRecord;
|
|
49
|
+
observationEntryId: string;
|
|
50
|
+
}
|
|
51
|
+
| {
|
|
52
|
+
status: "source_unavailable";
|
|
53
|
+
observation: ObservationRecord;
|
|
54
|
+
observationEntryId: string;
|
|
55
|
+
sourceEntryIds: string[];
|
|
56
|
+
missingSourceEntryIds: string[];
|
|
57
|
+
nonSourceEntryIds: string[];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type RecallObservationSourcesResult =
|
|
61
|
+
| {
|
|
62
|
+
status: "not_found";
|
|
63
|
+
observationId: string;
|
|
64
|
+
matches: [];
|
|
65
|
+
collision: false;
|
|
66
|
+
}
|
|
67
|
+
| {
|
|
68
|
+
status: "found";
|
|
69
|
+
observationId: string;
|
|
70
|
+
matches: RecallObservationMatch[];
|
|
71
|
+
collision: boolean;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type RecallMemoryObservation =
|
|
75
|
+
| {
|
|
76
|
+
status: "ok";
|
|
77
|
+
observation: ObservationRecord;
|
|
78
|
+
observationEntryId: string;
|
|
79
|
+
observationRecordIndex: number;
|
|
80
|
+
sourceEntryIds: string[];
|
|
81
|
+
sourceEntries: Entry[];
|
|
82
|
+
}
|
|
83
|
+
| {
|
|
84
|
+
status: "no_source";
|
|
85
|
+
observation: ObservationRecord;
|
|
86
|
+
observationEntryId: string;
|
|
87
|
+
observationRecordIndex: number;
|
|
88
|
+
}
|
|
89
|
+
| {
|
|
90
|
+
status: "source_unavailable";
|
|
91
|
+
observation: ObservationRecord;
|
|
92
|
+
observationEntryId: string;
|
|
93
|
+
observationRecordIndex: number;
|
|
94
|
+
sourceEntryIds: string[];
|
|
95
|
+
sourceEntries: Entry[];
|
|
96
|
+
missingSourceEntryIds: string[];
|
|
97
|
+
nonSourceEntryIds: string[];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type RecallMemoryReflectionMatch = {
|
|
101
|
+
reflection: ReflectionRecord;
|
|
102
|
+
reflectionIndex: number;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type RecallUnavailableSupportingObservation = {
|
|
106
|
+
reflection: ReflectionRecord;
|
|
107
|
+
reflectionIndex: number;
|
|
108
|
+
observationId: string;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export type RecallUnavailableReflectionProvenance = {
|
|
112
|
+
reflection: ReflectionRecord;
|
|
113
|
+
reflectionIndex: number;
|
|
114
|
+
reason: "legacy";
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export type RecallMemorySourcesResult =
|
|
118
|
+
| {
|
|
119
|
+
status: "not_found";
|
|
120
|
+
memoryId: string;
|
|
121
|
+
reflectionMatches: [];
|
|
122
|
+
directObservationMatches: [];
|
|
123
|
+
observations: [];
|
|
124
|
+
sourceEntries: [];
|
|
125
|
+
unavailableSupportingObservations: [];
|
|
126
|
+
unavailableReflectionProvenance: [];
|
|
127
|
+
missingSourceEntryIds: [];
|
|
128
|
+
nonSourceEntryIds: [];
|
|
129
|
+
collision: false;
|
|
130
|
+
partial: false;
|
|
131
|
+
}
|
|
132
|
+
| {
|
|
133
|
+
status: "found";
|
|
134
|
+
memoryId: string;
|
|
135
|
+
reflectionMatches: RecallMemoryReflectionMatch[];
|
|
136
|
+
directObservationMatches: RecallMemoryObservation[];
|
|
137
|
+
observations: RecallMemoryObservation[];
|
|
138
|
+
sourceEntries: Entry[];
|
|
139
|
+
unavailableSupportingObservations: RecallUnavailableSupportingObservation[];
|
|
140
|
+
unavailableReflectionProvenance: RecallUnavailableReflectionProvenance[];
|
|
141
|
+
missingSourceEntryIds: string[];
|
|
142
|
+
nonSourceEntryIds: string[];
|
|
143
|
+
collision: boolean;
|
|
144
|
+
partial: boolean;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
type IndexedObservation = {
|
|
148
|
+
observation: ObservationRecord;
|
|
149
|
+
observationEntryId: string;
|
|
150
|
+
observationRecordIndex: number;
|
|
151
|
+
branchIndex: number;
|
|
152
|
+
};
|
|
153
|
+
|
|
32
154
|
export function findLastCompactionIndex(entries: Entry[]): number {
|
|
33
155
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
34
156
|
if (entries[i].type === "compaction") return i;
|
|
@@ -103,16 +225,281 @@ export function rawTailEntriesBetween(entries: Entry[], fromId: string, untilId:
|
|
|
103
225
|
|
|
104
226
|
const result: Entry[] = [];
|
|
105
227
|
for (let i = fromIdx; i <= untilIdx; i++) {
|
|
106
|
-
if (
|
|
228
|
+
if (isSourceEntry(entries[i])) result.push(entries[i]);
|
|
107
229
|
}
|
|
108
230
|
return result;
|
|
109
231
|
}
|
|
110
232
|
|
|
111
|
-
function
|
|
233
|
+
function uniqueIds(ids: string[]): string[] {
|
|
234
|
+
return Array.from(new Set(ids));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function collectIndexedObservations(entries: Entry[]): IndexedObservation[] {
|
|
238
|
+
const observations: IndexedObservation[] = [];
|
|
239
|
+
for (let branchIndex = 0; branchIndex < entries.length; branchIndex++) {
|
|
240
|
+
const entry = entries[branchIndex];
|
|
241
|
+
if (!isObservationEntry(entry)) continue;
|
|
242
|
+
if (!isObservationEntryData(entry.data)) continue;
|
|
243
|
+
entry.data.records.forEach((observation, observationRecordIndex) => {
|
|
244
|
+
observations.push({ observation, observationEntryId: entry.id, observationRecordIndex, branchIndex });
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return observations;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function observationKey(observation: Pick<IndexedObservation, "observationEntryId" | "observationRecordIndex">): string {
|
|
251
|
+
return `${observation.observationEntryId}:${observation.observationRecordIndex}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function resolveSourceEntries(entries: Entry[], sourceEntryIds: string[]): {
|
|
255
|
+
status: "ok" | "source_unavailable";
|
|
256
|
+
sourceEntryIds: string[];
|
|
257
|
+
sourceEntries: Entry[];
|
|
258
|
+
missingSourceEntryIds: string[];
|
|
259
|
+
nonSourceEntryIds: string[];
|
|
260
|
+
} {
|
|
261
|
+
const requested = uniqueIds(sourceEntryIds);
|
|
262
|
+
const requestedSet = new Set(requested);
|
|
263
|
+
const entriesById = new Map(entries.map((entry) => [entry.id, entry]));
|
|
264
|
+
const missingSourceEntryIds = requested.filter((id) => !entriesById.has(id));
|
|
265
|
+
const nonSourceEntryIds = requested.filter((id) => {
|
|
266
|
+
const entry = entriesById.get(id);
|
|
267
|
+
return entry !== undefined && !isSourceEntry(entry);
|
|
268
|
+
});
|
|
269
|
+
if (missingSourceEntryIds.length > 0 || nonSourceEntryIds.length > 0) {
|
|
270
|
+
return {
|
|
271
|
+
status: "source_unavailable",
|
|
272
|
+
sourceEntryIds: requested,
|
|
273
|
+
sourceEntries: [],
|
|
274
|
+
missingSourceEntryIds,
|
|
275
|
+
nonSourceEntryIds,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const sourceEntries = entries.filter((entry) => requestedSet.has(entry.id));
|
|
280
|
+
return {
|
|
281
|
+
status: "ok",
|
|
282
|
+
sourceEntryIds: sourceEntries.map((entry) => entry.id),
|
|
283
|
+
sourceEntries,
|
|
284
|
+
missingSourceEntryIds: [],
|
|
285
|
+
nonSourceEntryIds: [],
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function resolveSourceEntriesPartial(entries: Entry[], sourceEntryIds: string[]): {
|
|
290
|
+
status: "ok" | "source_unavailable";
|
|
291
|
+
sourceEntryIds: string[];
|
|
292
|
+
sourceEntries: Entry[];
|
|
293
|
+
missingSourceEntryIds: string[];
|
|
294
|
+
nonSourceEntryIds: string[];
|
|
295
|
+
} {
|
|
296
|
+
const requested = uniqueIds(sourceEntryIds);
|
|
297
|
+
const requestedSet = new Set(requested);
|
|
298
|
+
const entriesById = new Map(entries.map((entry) => [entry.id, entry]));
|
|
299
|
+
const missingSourceEntryIds = requested.filter((id) => !entriesById.has(id));
|
|
300
|
+
const nonSourceEntryIds = requested.filter((id) => {
|
|
301
|
+
const entry = entriesById.get(id);
|
|
302
|
+
return entry !== undefined && !isSourceEntry(entry);
|
|
303
|
+
});
|
|
304
|
+
const sourceEntries = entries.filter((entry) => requestedSet.has(entry.id) && isSourceEntry(entry));
|
|
305
|
+
return {
|
|
306
|
+
status: missingSourceEntryIds.length > 0 || nonSourceEntryIds.length > 0 ? "source_unavailable" : "ok",
|
|
307
|
+
sourceEntryIds: requested,
|
|
308
|
+
sourceEntries,
|
|
309
|
+
missingSourceEntryIds,
|
|
310
|
+
nonSourceEntryIds,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function memoryObservationFromIndexed(entries: Entry[], indexed: IndexedObservation): RecallMemoryObservation {
|
|
315
|
+
const { observation, observationEntryId, observationRecordIndex } = indexed;
|
|
316
|
+
if (!observation.sourceEntryIds || observation.sourceEntryIds.length === 0) {
|
|
317
|
+
return { status: "no_source", observation, observationEntryId, observationRecordIndex };
|
|
318
|
+
}
|
|
319
|
+
const resolved = resolveSourceEntriesPartial(entries, observation.sourceEntryIds);
|
|
320
|
+
if (resolved.status === "source_unavailable") {
|
|
321
|
+
return {
|
|
322
|
+
status: "source_unavailable",
|
|
323
|
+
observation,
|
|
324
|
+
observationEntryId,
|
|
325
|
+
observationRecordIndex,
|
|
326
|
+
sourceEntryIds: resolved.sourceEntryIds,
|
|
327
|
+
sourceEntries: resolved.sourceEntries,
|
|
328
|
+
missingSourceEntryIds: resolved.missingSourceEntryIds,
|
|
329
|
+
nonSourceEntryIds: resolved.nonSourceEntryIds,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
status: "ok",
|
|
334
|
+
observation,
|
|
335
|
+
observationEntryId,
|
|
336
|
+
observationRecordIndex,
|
|
337
|
+
sourceEntryIds: resolved.sourceEntryIds,
|
|
338
|
+
sourceEntries: resolved.sourceEntries,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function uniqueSourceEntriesInBranchOrder(entries: Entry[], observations: RecallMemoryObservation[]): Entry[] {
|
|
343
|
+
const requested = new Set<string>();
|
|
344
|
+
for (const observation of observations) {
|
|
345
|
+
if (observation.status === "ok" || observation.status === "source_unavailable") {
|
|
346
|
+
for (const entry of observation.sourceEntries) requested.add(entry.id);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return entries.filter((entry) => requested.has(entry.id) && isSourceEntry(entry));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function uniqueUnavailableSourceIds(
|
|
353
|
+
observations: RecallMemoryObservation[],
|
|
354
|
+
field: "missingSourceEntryIds" | "nonSourceEntryIds",
|
|
355
|
+
): string[] {
|
|
356
|
+
const ids: string[] = [];
|
|
357
|
+
for (const observation of observations) {
|
|
358
|
+
if (observation.status !== "source_unavailable") continue;
|
|
359
|
+
ids.push(...observation[field]);
|
|
360
|
+
}
|
|
361
|
+
return uniqueIds(ids);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function recallObservationSources(entries: Entry[], observationId: string): RecallObservationSourcesResult {
|
|
365
|
+
const matches: RecallObservationMatch[] = [];
|
|
366
|
+
for (const entry of entries) {
|
|
367
|
+
if (!isObservationEntry(entry)) continue;
|
|
368
|
+
if (!isObservationEntryData(entry.data)) continue;
|
|
369
|
+
for (const observation of entry.data.records) {
|
|
370
|
+
if (observation.id !== observationId) continue;
|
|
371
|
+
if (!observation.sourceEntryIds || observation.sourceEntryIds.length === 0) {
|
|
372
|
+
matches.push({ status: "no_source", observation, observationEntryId: entry.id });
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const resolved = resolveSourceEntries(entries, observation.sourceEntryIds);
|
|
377
|
+
if (resolved.status === "source_unavailable") {
|
|
378
|
+
matches.push({
|
|
379
|
+
status: "source_unavailable",
|
|
380
|
+
observation,
|
|
381
|
+
observationEntryId: entry.id,
|
|
382
|
+
sourceEntryIds: resolved.sourceEntryIds,
|
|
383
|
+
missingSourceEntryIds: resolved.missingSourceEntryIds,
|
|
384
|
+
nonSourceEntryIds: resolved.nonSourceEntryIds,
|
|
385
|
+
});
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
matches.push({
|
|
390
|
+
status: "ok",
|
|
391
|
+
observation,
|
|
392
|
+
observationEntryId: entry.id,
|
|
393
|
+
sourceEntryIds: resolved.sourceEntryIds,
|
|
394
|
+
sourceEntries: resolved.sourceEntries,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (matches.length === 0) return { status: "not_found", observationId, matches: [], collision: false };
|
|
400
|
+
return { status: "found", observationId, matches, collision: matches.length > 1 };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function getPriorMemoryDetails(entries: Entry[]): SupportedMemoryDetails | undefined {
|
|
112
404
|
const idx = findLastCompactionIndex(entries);
|
|
113
405
|
if (idx === -1) return undefined;
|
|
114
406
|
const details = entries[idx].details;
|
|
115
|
-
return
|
|
407
|
+
return isSupportedMemoryDetails(details) ? details : undefined;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function recallMemorySources(entries: Entry[], memoryId: string): RecallMemorySourcesResult {
|
|
411
|
+
const indexedObservations = collectIndexedObservations(entries);
|
|
412
|
+
const observationsById = new Map<string, IndexedObservation[]>();
|
|
413
|
+
for (const observation of indexedObservations) {
|
|
414
|
+
const matches = observationsById.get(observation.observation.id) ?? [];
|
|
415
|
+
matches.push(observation);
|
|
416
|
+
observationsById.set(observation.observation.id, matches);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const priorDetails = getPriorMemoryDetails(entries);
|
|
420
|
+
const reflectionMatches: RecallMemoryReflectionMatch[] = [];
|
|
421
|
+
if (priorDetails) {
|
|
422
|
+
priorDetails.reflections.forEach((reflection, reflectionIndex) => {
|
|
423
|
+
if (!isReflectionRecord(reflection) || reflection.id !== memoryId) return;
|
|
424
|
+
reflectionMatches.push({ reflection, reflectionIndex });
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const directIndexedObservations = observationsById.get(memoryId) ?? [];
|
|
429
|
+
const observationsByKey = new Map<string, IndexedObservation>();
|
|
430
|
+
for (const observation of directIndexedObservations) {
|
|
431
|
+
observationsByKey.set(observationKey(observation), observation);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const unavailableSupportingObservations: RecallUnavailableSupportingObservation[] = [];
|
|
435
|
+
const unavailableReflectionProvenance: RecallUnavailableReflectionProvenance[] = [];
|
|
436
|
+
for (const reflectionMatch of reflectionMatches) {
|
|
437
|
+
if (reflectionMatch.reflection.legacy === true) {
|
|
438
|
+
unavailableReflectionProvenance.push({ ...reflectionMatch, reason: "legacy" });
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
for (const observationId of reflectionMatch.reflection.supportingObservationIds) {
|
|
442
|
+
const supportingObservations = observationsById.get(observationId);
|
|
443
|
+
if (!supportingObservations || supportingObservations.length === 0) {
|
|
444
|
+
unavailableSupportingObservations.push({ ...reflectionMatch, observationId });
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
for (const observation of supportingObservations) {
|
|
448
|
+
observationsByKey.set(observationKey(observation), observation);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const indexedObservationBag = Array.from(observationsByKey.values()).sort((a, b) => {
|
|
454
|
+
if (a.branchIndex !== b.branchIndex) return a.branchIndex - b.branchIndex;
|
|
455
|
+
return a.observationRecordIndex - b.observationRecordIndex;
|
|
456
|
+
});
|
|
457
|
+
const observations = indexedObservationBag.map((observation) => memoryObservationFromIndexed(entries, observation));
|
|
458
|
+
const directObservationKeys = new Set(directIndexedObservations.map(observationKey));
|
|
459
|
+
const directObservationMatches = observations.filter((observation) => directObservationKeys.has(observationKey(observation)));
|
|
460
|
+
const sourceEntries = uniqueSourceEntriesInBranchOrder(entries, observations);
|
|
461
|
+
const missingSourceEntryIds = uniqueUnavailableSourceIds(observations, "missingSourceEntryIds");
|
|
462
|
+
const nonSourceEntryIds = uniqueUnavailableSourceIds(observations, "nonSourceEntryIds");
|
|
463
|
+
|
|
464
|
+
if (reflectionMatches.length === 0 && directObservationMatches.length === 0) {
|
|
465
|
+
return {
|
|
466
|
+
status: "not_found",
|
|
467
|
+
memoryId,
|
|
468
|
+
reflectionMatches: [],
|
|
469
|
+
directObservationMatches: [],
|
|
470
|
+
observations: [],
|
|
471
|
+
sourceEntries: [],
|
|
472
|
+
unavailableSupportingObservations: [],
|
|
473
|
+
unavailableReflectionProvenance: [],
|
|
474
|
+
missingSourceEntryIds: [],
|
|
475
|
+
nonSourceEntryIds: [],
|
|
476
|
+
collision: false,
|
|
477
|
+
partial: false,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const hasNoSourceObservationInMemoryRecall =
|
|
482
|
+
reflectionMatches.length > 0 && observations.some((observation) => observation.status === "no_source");
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
status: "found",
|
|
486
|
+
memoryId,
|
|
487
|
+
reflectionMatches,
|
|
488
|
+
directObservationMatches,
|
|
489
|
+
observations,
|
|
490
|
+
sourceEntries,
|
|
491
|
+
unavailableSupportingObservations,
|
|
492
|
+
unavailableReflectionProvenance,
|
|
493
|
+
missingSourceEntryIds,
|
|
494
|
+
nonSourceEntryIds,
|
|
495
|
+
collision: reflectionMatches.length + directObservationMatches.length > 1,
|
|
496
|
+
partial:
|
|
497
|
+
unavailableReflectionProvenance.length > 0 ||
|
|
498
|
+
hasNoSourceObservationInMemoryRecall ||
|
|
499
|
+
unavailableSupportingObservations.length > 0 ||
|
|
500
|
+
missingSourceEntryIds.length > 0 ||
|
|
501
|
+
nonSourceEntryIds.length > 0,
|
|
502
|
+
};
|
|
116
503
|
}
|
|
117
504
|
|
|
118
505
|
export function collectObservationsByCoverage(
|
|
@@ -174,7 +561,7 @@ function collectObservationsPendingNextCompaction(entries: Entry[]): Observation
|
|
|
174
561
|
}
|
|
175
562
|
|
|
176
563
|
export interface MemoryState {
|
|
177
|
-
reflections:
|
|
564
|
+
reflections: MemoryReflection[];
|
|
178
565
|
committedObs: ObservationRecord[];
|
|
179
566
|
pendingObs: ObservationRecord[];
|
|
180
567
|
}
|