pi-blackhole 0.2.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/LICENSE +21 -0
- package/README.md +373 -0
- package/example-config.json +115 -0
- package/index.ts +39 -0
- package/package.json +55 -0
- package/src/commands/memory.ts +191 -0
- package/src/commands/pi-vcc.ts +94 -0
- package/src/commands/vcc-recall.ts +112 -0
- package/src/core/brief.ts +390 -0
- package/src/core/build-sections.ts +85 -0
- package/src/core/content.ts +60 -0
- package/src/core/filter-noise.ts +42 -0
- package/src/core/format-recall.ts +27 -0
- package/src/core/format.ts +76 -0
- package/src/core/lineage.ts +26 -0
- package/src/core/load-messages.ts +41 -0
- package/src/core/normalize.ts +79 -0
- package/src/core/recall-scope.ts +14 -0
- package/src/core/render-entries.ts +56 -0
- package/src/core/report.ts +237 -0
- package/src/core/sanitize.ts +5 -0
- package/src/core/search-entries.ts +227 -0
- package/src/core/settings.ts +34 -0
- package/src/core/skill-collapse.ts +35 -0
- package/src/core/summarize.ts +213 -0
- package/src/core/tool-args.ts +14 -0
- package/src/core/unified-config.ts +285 -0
- package/src/details.ts +13 -0
- package/src/extract/commits.ts +69 -0
- package/src/extract/files.ts +80 -0
- package/src/extract/goals.ts +79 -0
- package/src/extract/preferences.ts +55 -0
- package/src/hooks/before-compact.ts +345 -0
- package/src/om/agents/dropper/agent.ts +204 -0
- package/src/om/agents/dropper/prompts.ts +48 -0
- package/src/om/agents/observer/agent.ts +256 -0
- package/src/om/agents/observer/prompts.ts +119 -0
- package/src/om/agents/reflector/agent.ts +161 -0
- package/src/om/agents/reflector/prompts.ts +77 -0
- package/src/om/clipboard.ts +63 -0
- package/src/om/compaction-hook.ts +63 -0
- package/src/om/compaction-trigger.ts +92 -0
- package/src/om/config.ts +22 -0
- package/src/om/consolidation.ts +514 -0
- package/src/om/cooldown.ts +130 -0
- package/src/om/debug-log.ts +55 -0
- package/src/om/ids.ts +5 -0
- package/src/om/ledger/fold.ts +106 -0
- package/src/om/ledger/index.ts +6 -0
- package/src/om/ledger/progress.ts +225 -0
- package/src/om/ledger/projection.ts +237 -0
- package/src/om/ledger/recall.ts +243 -0
- package/src/om/ledger/render-summary.ts +44 -0
- package/src/om/ledger/types.ts +206 -0
- package/src/om/model-budget.ts +9 -0
- package/src/om/pending.ts +225 -0
- package/src/om/reverse-recall.ts +130 -0
- package/src/om/runtime.ts +241 -0
- package/src/om/serialize.ts +224 -0
- package/src/om/tokens.ts +33 -0
- package/src/sections.ts +18 -0
- package/src/tools/recall.ts +212 -0
- package/src/types.ts +19 -0
- package/vitest.config.ts +41 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug logging — writes JSONL to ~/.pi/agent/pi-blackhole/debug.ndjson.
|
|
3
|
+
*
|
|
4
|
+
* Upstream: https://github.com/elpapi42/pi-observational-memory (src/debug-log.ts)
|
|
5
|
+
* Modified: path changed from observational-memory/ to pi-blackhole/.
|
|
6
|
+
*/
|
|
7
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
8
|
+
import { existsSync, mkdirSync, renameSync, statSync, unlinkSync, appendFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
export const DEBUG_LOG_MAX_BYTES = 10 * 1024 * 1024;
|
|
13
|
+
export const DEBUG_LOG_RELATIVE_PATH = join("pi-blackhole", "debug.ndjson");
|
|
14
|
+
|
|
15
|
+
interface DebugLogContext {
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
cwd?: string;
|
|
18
|
+
runId?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const storage = new AsyncLocalStorage<DebugLogContext>();
|
|
22
|
+
|
|
23
|
+
export function withDebugLogContext<T>(context: DebugLogContext, fn: () => T): T {
|
|
24
|
+
const parent = storage.getStore();
|
|
25
|
+
return storage.run({ ...parent, ...context }, fn);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function debugLog(event: string, data: Record<string, unknown> = {}): void {
|
|
29
|
+
const context = storage.getStore();
|
|
30
|
+
if (context?.enabled !== true) return;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const path = join(getAgentDir(), DEBUG_LOG_RELATIVE_PATH);
|
|
34
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
35
|
+
rotateIfNeeded(path);
|
|
36
|
+
const payload = {
|
|
37
|
+
ts: new Date().toISOString(),
|
|
38
|
+
event,
|
|
39
|
+
cwd: context.cwd,
|
|
40
|
+
runId: context.runId,
|
|
41
|
+
data,
|
|
42
|
+
};
|
|
43
|
+
appendFileSync(path, `${JSON.stringify(payload)}\n`, "utf-8");
|
|
44
|
+
} catch {
|
|
45
|
+
// Debug logging must never affect memory behavior.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function rotateIfNeeded(path: string): void {
|
|
50
|
+
if (!existsSync(path)) return;
|
|
51
|
+
if (statSync(path).size < DEBUG_LOG_MAX_BYTES) return;
|
|
52
|
+
const backupPath = `${path}.1`;
|
|
53
|
+
if (existsSync(backupPath)) unlinkSync(backupPath);
|
|
54
|
+
renameSync(path, backupPath);
|
|
55
|
+
}
|
package/src/om/ids.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ledger folding — deduplicates observations/reflections and applies tombstones.
|
|
3
|
+
*
|
|
4
|
+
* Upstream: https://github.com/elpapi42/pi-observational-memory (src/session-ledger/fold.ts)
|
|
5
|
+
* Unmodified.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
isObservationsDroppedData,
|
|
9
|
+
isObservationsRecordedData,
|
|
10
|
+
isReflectionsRecordedData,
|
|
11
|
+
OM_OBSERVATIONS_DROPPED,
|
|
12
|
+
OM_OBSERVATIONS_RECORDED,
|
|
13
|
+
OM_REFLECTIONS_RECORDED,
|
|
14
|
+
type Entry,
|
|
15
|
+
type Observation,
|
|
16
|
+
type Reflection,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
|
|
19
|
+
export type FoldLedgerOptions = {
|
|
20
|
+
/** Fold entries from branch root through this entry id, inclusive. Omit to fold through branch tip. */
|
|
21
|
+
upToEntryId?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type FoldedLedger = {
|
|
25
|
+
/** All first-valid observation records encountered through the fold boundary, including dropped observations. */
|
|
26
|
+
observations: Observation[];
|
|
27
|
+
/** Observation records not tombstoned by a folded drop entry. */
|
|
28
|
+
activeObservations: Observation[];
|
|
29
|
+
/** Tombstoned observation ids, including ids that may not have a corresponding folded observation. */
|
|
30
|
+
droppedObservationIds: Set<string>;
|
|
31
|
+
/** All first-valid reflection records encountered through the fold boundary. */
|
|
32
|
+
reflections: Reflection[];
|
|
33
|
+
/** All first-valid observation records by id, including dropped observations. */
|
|
34
|
+
observationsById: Map<string, Observation>;
|
|
35
|
+
/** All first-valid reflection records by id. */
|
|
36
|
+
reflectionsById: Map<string, Reflection>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function foldEndIndex(entries: Entry[], upToEntryId: string | undefined): number {
|
|
40
|
+
if (!upToEntryId) return entries.length - 1;
|
|
41
|
+
const idx = entries.findIndex((entry) => entry.id === upToEntryId);
|
|
42
|
+
return idx === -1 ? entries.length - 1 : idx;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isCustomEntry(entry: Entry, customType: string): boolean {
|
|
46
|
+
return entry.type === "custom" && entry.customType === customType;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fold valid V3 memory ledger entries from the branch root through the target entry.
|
|
51
|
+
*
|
|
52
|
+
* Unknown custom entries, old V2 entries, invalid V3-shaped data, and compaction details are ignored.
|
|
53
|
+
* Observations and reflections use first-valid-record-wins semantics. Drops are tombstones and are
|
|
54
|
+
* retained even when the dropped id is unknown at the time of folding.
|
|
55
|
+
*/
|
|
56
|
+
export function foldLedger(entries: Entry[], options: FoldLedgerOptions = {}): FoldedLedger {
|
|
57
|
+
const observationsById = new Map<string, Observation>();
|
|
58
|
+
const reflectionsById = new Map<string, Reflection>();
|
|
59
|
+
const droppedObservationIds = new Set<string>();
|
|
60
|
+
const endIdx = foldEndIndex(entries, options.upToEntryId);
|
|
61
|
+
|
|
62
|
+
for (let i = 0; i <= endIdx; i++) {
|
|
63
|
+
const entry = entries[i];
|
|
64
|
+
if (!entry) continue;
|
|
65
|
+
|
|
66
|
+
if (isCustomEntry(entry, OM_OBSERVATIONS_RECORDED)) {
|
|
67
|
+
if (!isObservationsRecordedData(entry.data)) continue;
|
|
68
|
+
for (const observation of entry.data.observations) {
|
|
69
|
+
if (!observationsById.has(observation.id)) {
|
|
70
|
+
observationsById.set(observation.id, observation);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (isCustomEntry(entry, OM_REFLECTIONS_RECORDED)) {
|
|
77
|
+
if (!isReflectionsRecordedData(entry.data)) continue;
|
|
78
|
+
for (const reflection of entry.data.reflections) {
|
|
79
|
+
if (!reflectionsById.has(reflection.id)) {
|
|
80
|
+
reflectionsById.set(reflection.id, reflection);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (isCustomEntry(entry, OM_OBSERVATIONS_DROPPED)) {
|
|
87
|
+
if (!isObservationsDroppedData(entry.data)) continue;
|
|
88
|
+
for (const observationId of entry.data.observationIds) {
|
|
89
|
+
droppedObservationIds.add(observationId);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const observations = Array.from(observationsById.values());
|
|
95
|
+
const activeObservations = observations.filter((observation) => !droppedObservationIds.has(observation.id));
|
|
96
|
+
const reflections = Array.from(reflectionsById.values());
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
observations,
|
|
100
|
+
activeObservations,
|
|
101
|
+
droppedObservationIds,
|
|
102
|
+
reflections,
|
|
103
|
+
observationsById,
|
|
104
|
+
reflectionsById,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { estimateEntryTokens } from "../tokens.js";
|
|
2
|
+
import {
|
|
3
|
+
OM_OBSERVATIONS_DROPPED,
|
|
4
|
+
OM_OBSERVATIONS_RECORDED,
|
|
5
|
+
OM_REFLECTIONS_RECORDED,
|
|
6
|
+
isObservationsRecordedData,
|
|
7
|
+
isReflectionsRecordedData,
|
|
8
|
+
type Entry,
|
|
9
|
+
type Observation,
|
|
10
|
+
type Reflection,
|
|
11
|
+
type V3MemoryCustomType,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
|
|
14
|
+
const SOURCE_ENTRY_TYPES = new Set(["message", "custom_message", "branch_summary"]);
|
|
15
|
+
|
|
16
|
+
export function isSourceEntry(entry: Entry): boolean {
|
|
17
|
+
return SOURCE_ENTRY_TYPES.has(entry.type);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function entryIndexById(entries: Entry[]): Map<string, number> {
|
|
21
|
+
const idToIndex = new Map<string, number>();
|
|
22
|
+
for (let i = 0; i < entries.length; i++) idToIndex.set(entries[i].id, i);
|
|
23
|
+
return idToIndex;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function entryIndexForId(entries: Entry[], entryId: string | undefined): number {
|
|
27
|
+
if (!entryId) return -1;
|
|
28
|
+
const idx = entryIndexById(entries).get(entryId);
|
|
29
|
+
return idx ?? -1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
33
|
+
return typeof value === "object" && value !== null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isNonEmptyArray(value: unknown): value is unknown[] {
|
|
37
|
+
return Array.isArray(value) && value.length > 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isValidCoverageEntry(entry: Entry, customType: V3MemoryCustomType): entry is Entry & { data: { coversUpToId: string } } {
|
|
41
|
+
if (entry.type !== "custom" || entry.customType !== customType) return false;
|
|
42
|
+
if (!isObject(entry.data) || typeof entry.data.coversUpToId !== "string") return false;
|
|
43
|
+
|
|
44
|
+
if (customType === OM_OBSERVATIONS_RECORDED) return isNonEmptyArray(entry.data.observations);
|
|
45
|
+
if (customType === OM_REFLECTIONS_RECORDED) return isNonEmptyArray(entry.data.reflections);
|
|
46
|
+
return isNonEmptyArray(entry.data.observationIds);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function latestCoverageIndex(entries: Entry[], customType: V3MemoryCustomType): number {
|
|
50
|
+
const idToIndex = entryIndexById(entries);
|
|
51
|
+
let latest = -1;
|
|
52
|
+
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (!isValidCoverageEntry(entry, customType)) continue;
|
|
55
|
+
const coveredIndex = idToIndex.get(entry.data.coversUpToId);
|
|
56
|
+
if (coveredIndex === undefined) continue;
|
|
57
|
+
if (coveredIndex > latest) latest = coveredIndex;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return latest;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function latestCoverageMarkerId(entries: Entry[], customType: V3MemoryCustomType): string | undefined {
|
|
64
|
+
const idToIndex = entryIndexById(entries);
|
|
65
|
+
let latestIndex = -1;
|
|
66
|
+
let latestMarkerId: string | undefined;
|
|
67
|
+
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (!isValidCoverageEntry(entry, customType)) continue;
|
|
70
|
+
const coveredIndex = idToIndex.get(entry.data.coversUpToId);
|
|
71
|
+
if (coveredIndex === undefined) continue;
|
|
72
|
+
if (coveredIndex > latestIndex) {
|
|
73
|
+
latestIndex = coveredIndex;
|
|
74
|
+
latestMarkerId = entry.data.coversUpToId;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return latestMarkerId;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function earlierCoverageMarkerId(entries: Entry[], firstId: string | undefined, secondId: string | undefined): string | undefined {
|
|
82
|
+
if (!firstId) return secondId;
|
|
83
|
+
if (!secondId) return firstId;
|
|
84
|
+
|
|
85
|
+
const idToIndex = entryIndexById(entries);
|
|
86
|
+
const firstIndex = idToIndex.get(firstId);
|
|
87
|
+
const secondIndex = idToIndex.get(secondId);
|
|
88
|
+
if (firstIndex === undefined) return secondIndex === undefined ? undefined : secondId;
|
|
89
|
+
if (secondIndex === undefined) return firstId;
|
|
90
|
+
return firstIndex <= secondIndex ? firstId : secondId;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function rawTokensAfterIndex(entries: Entry[], index: number): number {
|
|
94
|
+
let total = 0;
|
|
95
|
+
for (let i = Math.max(0, index + 1); i < entries.length; i++) {
|
|
96
|
+
if (isSourceEntry(entries[i])) total += estimateEntryTokens(entries[i]);
|
|
97
|
+
}
|
|
98
|
+
return total;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function rawTokensSinceCoverage(entries: Entry[], customType: V3MemoryCustomType): number {
|
|
102
|
+
return rawTokensAfterIndex(entries, latestCoverageIndex(entries, customType));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function rawTokensSinceObservationCoverage(entries: Entry[]): number {
|
|
106
|
+
return rawTokensSinceCoverage(entries, OM_OBSERVATIONS_RECORDED);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function rawTokensSinceReflectionCoverage(entries: Entry[]): number {
|
|
110
|
+
return rawTokensSinceCoverage(entries, OM_REFLECTIONS_RECORDED);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function rawTokensSinceDropCoverage(entries: Entry[]): number {
|
|
114
|
+
return rawTokensSinceCoverage(entries, OM_OBSERVATIONS_DROPPED);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function findLastCompactionIndex(entries: Entry[]): number {
|
|
118
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
119
|
+
if (entries[i].type === "compaction") return i;
|
|
120
|
+
}
|
|
121
|
+
return -1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function rawTokensSinceLastCompaction(entries: Entry[]): number {
|
|
125
|
+
const compactionIndex = findLastCompactionIndex(entries);
|
|
126
|
+
if (compactionIndex === -1) return rawTokensAfterIndex(entries, -1);
|
|
127
|
+
|
|
128
|
+
const firstKeptEntryId = entries[compactionIndex].firstKeptEntryId;
|
|
129
|
+
const firstKeptIndex = entryIndexForId(entries, firstKeptEntryId);
|
|
130
|
+
|
|
131
|
+
if (firstKeptIndex === -1) return rawTokensAfterIndex(entries, compactionIndex);
|
|
132
|
+
return rawTokensAfterIndex(entries, firstKeptIndex - 1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Extract observations created since the given entry index.
|
|
137
|
+
* Walks the branch and collects observations from OM_OBSERVATIONS_RECORDED
|
|
138
|
+
* entries that were appended AFTER the given index.
|
|
139
|
+
*/
|
|
140
|
+
export function observationsCreatedAfterIndex(
|
|
141
|
+
entries: Entry[],
|
|
142
|
+
sinceIndex: number,
|
|
143
|
+
): Observation[] {
|
|
144
|
+
const observations: Observation[] = [];
|
|
145
|
+
const seen = new Set<string>();
|
|
146
|
+
|
|
147
|
+
for (let i = sinceIndex + 1; i < entries.length; i++) {
|
|
148
|
+
const entry = entries[i];
|
|
149
|
+
if (entry.type !== "custom") continue;
|
|
150
|
+
if (entry.customType !== OM_OBSERVATIONS_RECORDED) continue;
|
|
151
|
+
if (!isObservationsRecordedData(entry.data)) continue;
|
|
152
|
+
for (const obs of entry.data.observations) {
|
|
153
|
+
if (!seen.has(obs.id)) {
|
|
154
|
+
seen.add(obs.id);
|
|
155
|
+
observations.push(obs);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return observations;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Extract reflections created since the given entry index.
|
|
164
|
+
*/
|
|
165
|
+
export function reflectionsCreatedAfterIndex(
|
|
166
|
+
entries: Entry[],
|
|
167
|
+
sinceIndex: number,
|
|
168
|
+
): Reflection[] {
|
|
169
|
+
const reflections: Reflection[] = [];
|
|
170
|
+
const seen = new Set<string>();
|
|
171
|
+
|
|
172
|
+
for (let i = sinceIndex + 1; i < entries.length; i++) {
|
|
173
|
+
const entry = entries[i];
|
|
174
|
+
if (entry.type !== "custom") continue;
|
|
175
|
+
if (entry.customType !== OM_REFLECTIONS_RECORDED) continue;
|
|
176
|
+
if (!isReflectionsRecordedData(entry.data)) continue;
|
|
177
|
+
for (const ref of entry.data.reflections) {
|
|
178
|
+
if (!seen.has(ref.id)) {
|
|
179
|
+
seen.add(ref.id);
|
|
180
|
+
reflections.push(ref);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return reflections;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Build a compact one-line summary of existing observations for context.
|
|
189
|
+
* Capped at maxTokens.
|
|
190
|
+
*/
|
|
191
|
+
export function buildExistingObservationsSummary(
|
|
192
|
+
observations: Observation[],
|
|
193
|
+
maxTokens: number,
|
|
194
|
+
): string {
|
|
195
|
+
const lines: string[] = [];
|
|
196
|
+
let tokens = 0;
|
|
197
|
+
for (const obs of observations) {
|
|
198
|
+
const line = `[${obs.id}] ${obs.timestamp} [${obs.relevance}] ${obs.content}`;
|
|
199
|
+
const lineTokens = Math.ceil(line.length / 4);
|
|
200
|
+
if (tokens + lineTokens > maxTokens && lines.length > 0) break;
|
|
201
|
+
lines.push(line);
|
|
202
|
+
tokens += lineTokens;
|
|
203
|
+
}
|
|
204
|
+
return lines.join("\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Build a compact one-line summary of existing reflections for context.
|
|
209
|
+
* Capped at maxTokens.
|
|
210
|
+
*/
|
|
211
|
+
export function buildExistingReflectionsSummary(
|
|
212
|
+
reflections: Reflection[],
|
|
213
|
+
maxTokens: number,
|
|
214
|
+
): string {
|
|
215
|
+
const lines: string[] = [];
|
|
216
|
+
let tokens = 0;
|
|
217
|
+
for (const ref of reflections) {
|
|
218
|
+
const line = `[${ref.id}] ${ref.content}`;
|
|
219
|
+
const lineTokens = Math.ceil(line.length / 4);
|
|
220
|
+
if (tokens + lineTokens > maxTokens && lines.length > 0) break;
|
|
221
|
+
lines.push(line);
|
|
222
|
+
tokens += lineTokens;
|
|
223
|
+
}
|
|
224
|
+
return lines.join("\n");
|
|
225
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compaction projection — builds projection slices for compaction events.
|
|
3
|
+
*
|
|
4
|
+
* Upstream: https://github.com/elpapi42/pi-observational-memory (src/session-ledger/projection.ts)
|
|
5
|
+
* Unmodified.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
OM_FOLDED,
|
|
9
|
+
isMemoryDetails,
|
|
10
|
+
isObservationsDroppedEntry,
|
|
11
|
+
isObservationsRecordedEntry,
|
|
12
|
+
isReflectionsRecordedEntry,
|
|
13
|
+
type Entry,
|
|
14
|
+
type MemoryDetails,
|
|
15
|
+
type Observation,
|
|
16
|
+
type Reflection,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
|
|
19
|
+
export type Projection = {
|
|
20
|
+
observations: Observation[];
|
|
21
|
+
reflections: Reflection[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type ProjectionDiff = {
|
|
25
|
+
observationsOnlyInFull: Observation[];
|
|
26
|
+
reflectionsOnlyInFull: Reflection[];
|
|
27
|
+
droppedOnlyInFull: Observation[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type CompactionProjectionConfig = {
|
|
31
|
+
observationsPoolMaxTokens: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type CompactionProjection = Projection & {
|
|
35
|
+
fullFold: boolean;
|
|
36
|
+
details: MemoryDetails;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type ProjectionBoundary =
|
|
40
|
+
| { kind: "entry"; entryId: string }
|
|
41
|
+
| { kind: "tip" }
|
|
42
|
+
| { kind: "none" };
|
|
43
|
+
|
|
44
|
+
type ProjectionFoldOptions = {
|
|
45
|
+
observationsBoundary: ProjectionBoundary;
|
|
46
|
+
reflectionsBoundary: ProjectionBoundary;
|
|
47
|
+
dropsBoundary: ProjectionBoundary;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function entryIndexById(entries: Entry[]): Map<string, number> {
|
|
51
|
+
const indexes = new Map<string, number>();
|
|
52
|
+
for (let i = 0; i < entries.length; i++) indexes.set(entries[i].id, i);
|
|
53
|
+
return indexes;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function entryBoundary(entryId: string): ProjectionBoundary {
|
|
57
|
+
return { kind: "entry", entryId };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function tipBoundary(): ProjectionBoundary {
|
|
61
|
+
return { kind: "tip" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function noneBoundary(): ProjectionBoundary {
|
|
65
|
+
return { kind: "none" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function boundaryIndex(entries: Entry[], indexes: Map<string, number>, boundary: ProjectionBoundary): number {
|
|
69
|
+
if (boundary.kind === "tip") return entries.length - 1;
|
|
70
|
+
if (boundary.kind === "none") return -1;
|
|
71
|
+
return indexes.get(boundary.entryId) ?? -1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function coverageIndex(entry: Entry & { data: { coversUpToId: string } }, indexes: Map<string, number>): number {
|
|
75
|
+
return indexes.get(entry.data.coversUpToId) ?? -1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isAtOrBefore(index: number, boundaryIndex: number): boolean {
|
|
79
|
+
return index >= 0 && boundaryIndex >= 0 && index <= boundaryIndex;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isCoveredAtOrBefore(
|
|
83
|
+
entry: Entry & { data: { coversUpToId: string } },
|
|
84
|
+
indexes: Map<string, number>,
|
|
85
|
+
boundaryIndex: number,
|
|
86
|
+
): boolean {
|
|
87
|
+
return isAtOrBefore(coverageIndex(entry, indexes), boundaryIndex);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function foldProjection(entries: Entry[], options: ProjectionFoldOptions): Projection {
|
|
91
|
+
const indexes = entryIndexById(entries);
|
|
92
|
+
const observationsBoundary = boundaryIndex(entries, indexes, options.observationsBoundary);
|
|
93
|
+
const reflectionsBoundary = boundaryIndex(entries, indexes, options.reflectionsBoundary);
|
|
94
|
+
const dropsBoundary = boundaryIndex(entries, indexes, options.dropsBoundary);
|
|
95
|
+
const observations: Observation[] = [];
|
|
96
|
+
const reflections: Reflection[] = [];
|
|
97
|
+
const observationsById = new Set<string>();
|
|
98
|
+
const reflectionsById = new Set<string>();
|
|
99
|
+
const droppedObservationIds = new Set<string>();
|
|
100
|
+
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
if (isObservationsRecordedEntry(entry) && isCoveredAtOrBefore(entry, indexes, observationsBoundary)) {
|
|
103
|
+
for (const observation of entry.data.observations) {
|
|
104
|
+
if (observationsById.has(observation.id)) continue;
|
|
105
|
+
observationsById.add(observation.id);
|
|
106
|
+
observations.push(observation);
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (isReflectionsRecordedEntry(entry) && isCoveredAtOrBefore(entry, indexes, reflectionsBoundary)) {
|
|
112
|
+
for (const reflection of entry.data.reflections) {
|
|
113
|
+
if (reflectionsById.has(reflection.id)) continue;
|
|
114
|
+
reflectionsById.add(reflection.id);
|
|
115
|
+
reflections.push(reflection);
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (isObservationsDroppedEntry(entry) && isCoveredAtOrBefore(entry, indexes, dropsBoundary)) {
|
|
121
|
+
for (const observationId of entry.data.observationIds) droppedObservationIds.add(observationId);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
observations: observations.filter((observation) => !droppedObservationIds.has(observation.id)),
|
|
127
|
+
reflections,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function projectionFromMemoryDetails(details: MemoryDetails): Projection {
|
|
132
|
+
return {
|
|
133
|
+
observations: [...details.observations],
|
|
134
|
+
reflections: [...details.reflections],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function latestV3CompactionDetails(entries: Entry[]): MemoryDetails | undefined {
|
|
139
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
140
|
+
const entry = entries[i];
|
|
141
|
+
if (entry.type !== "compaction") continue;
|
|
142
|
+
const details = unwrapMemoryDetails(entry);
|
|
143
|
+
if (details) return details;
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function fullProjection(entries: Entry[], upToEntryId?: string): Projection {
|
|
149
|
+
const boundary = upToEntryId ? entryBoundary(upToEntryId) : tipBoundary();
|
|
150
|
+
return foldProjection(entries, {
|
|
151
|
+
observationsBoundary: boundary,
|
|
152
|
+
reflectionsBoundary: boundary,
|
|
153
|
+
dropsBoundary: boundary,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function visibleProjection(entries: Entry[], upToEntryId?: string): Projection {
|
|
158
|
+
if (!upToEntryId) {
|
|
159
|
+
const details = latestV3CompactionDetails(entries);
|
|
160
|
+
return details ? projectionFromMemoryDetails(details) : { observations: [], reflections: [] };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return buildCompactionProjection(entries, upToEntryId, { observationsPoolMaxTokens: Number.POSITIVE_INFINITY });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function unwrapMemoryDetails(entry: Entry): MemoryDetails | undefined {
|
|
167
|
+
if (isMemoryDetails(entry.details)) return entry.details;
|
|
168
|
+
if (entry.details && typeof entry.details === "object" && !Array.isArray(entry.details)) {
|
|
169
|
+
const nested = (entry.details as Record<string, unknown>)["om.folded"];
|
|
170
|
+
if (isMemoryDetails(nested)) return nested;
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function latestFullFoldBoundaryId(entries: Entry[]): string | undefined {
|
|
176
|
+
const indexes = entryIndexById(entries);
|
|
177
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
178
|
+
const entry = entries[i];
|
|
179
|
+
if (entry.type !== "compaction") continue;
|
|
180
|
+
const details = unwrapMemoryDetails(entry);
|
|
181
|
+
if (!details) continue;
|
|
182
|
+
if (!details.fullFold) continue;
|
|
183
|
+
if (!entry.firstKeptEntryId) continue;
|
|
184
|
+
if (!indexes.has(entry.firstKeptEntryId)) continue;
|
|
185
|
+
return entry.firstKeptEntryId;
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function buildCompactionProjection(
|
|
191
|
+
entries: Entry[],
|
|
192
|
+
firstKeptEntryId: string,
|
|
193
|
+
config: CompactionProjectionConfig,
|
|
194
|
+
): CompactionProjection {
|
|
195
|
+
const fullFoldBoundaryId = latestFullFoldBoundaryId(entries);
|
|
196
|
+
const maintenanceBoundary = fullFoldBoundaryId ? entryBoundary(fullFoldBoundaryId) : noneBoundary();
|
|
197
|
+
const normalProjection = foldProjection(entries, {
|
|
198
|
+
observationsBoundary: entryBoundary(firstKeptEntryId),
|
|
199
|
+
reflectionsBoundary: maintenanceBoundary,
|
|
200
|
+
dropsBoundary: maintenanceBoundary,
|
|
201
|
+
});
|
|
202
|
+
const observationTokens = normalProjection.observations.reduce(
|
|
203
|
+
(total, observation) => total + observation.tokenCount,
|
|
204
|
+
0,
|
|
205
|
+
);
|
|
206
|
+
const fullFold = observationTokens >= config.observationsPoolMaxTokens;
|
|
207
|
+
const projection = fullFold
|
|
208
|
+
? fullProjection(entries, firstKeptEntryId)
|
|
209
|
+
: normalProjection;
|
|
210
|
+
|
|
211
|
+
const details: MemoryDetails = {
|
|
212
|
+
type: OM_FOLDED,
|
|
213
|
+
version: 1,
|
|
214
|
+
fullFold,
|
|
215
|
+
observations: projection.observations,
|
|
216
|
+
reflections: projection.reflections,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
fullFold,
|
|
221
|
+
observations: projection.observations,
|
|
222
|
+
reflections: projection.reflections,
|
|
223
|
+
details,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function diffProjection(visible: Projection, full: Projection): ProjectionDiff {
|
|
228
|
+
const visibleObservationIds = new Set(visible.observations.map((observation) => observation.id));
|
|
229
|
+
const fullObservationIds = new Set(full.observations.map((observation) => observation.id));
|
|
230
|
+
const visibleReflectionIds = new Set(visible.reflections.map((reflection) => reflection.id));
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
observationsOnlyInFull: full.observations.filter((observation) => !visibleObservationIds.has(observation.id)),
|
|
234
|
+
reflectionsOnlyInFull: full.reflections.filter((reflection) => !visibleReflectionIds.has(reflection.id)),
|
|
235
|
+
droppedOnlyInFull: visible.observations.filter((observation) => !fullObservationIds.has(observation.id)),
|
|
236
|
+
};
|
|
237
|
+
}
|