@ai2070/memex 0.9.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/.github/workflows/ci.yml +31 -0
- package/.github/workflows/release.yml +35 -0
- package/API.md +1078 -0
- package/LICENSE +190 -0
- package/README.md +574 -0
- package/package.json +30 -0
- package/src/bulk.ts +128 -0
- package/src/envelope.ts +52 -0
- package/src/errors.ts +27 -0
- package/src/graph.ts +15 -0
- package/src/helpers.ts +51 -0
- package/src/index.ts +142 -0
- package/src/integrity.ts +378 -0
- package/src/intent.ts +311 -0
- package/src/query.ts +357 -0
- package/src/reducer.ts +177 -0
- package/src/replay.ts +32 -0
- package/src/retrieval.ts +306 -0
- package/src/serialization.ts +34 -0
- package/src/stats.ts +62 -0
- package/src/task.ts +373 -0
- package/src/transplant.ts +488 -0
- package/src/types.ts +248 -0
- package/tests/bugfix-and-coverage.test.ts +958 -0
- package/tests/bugfix-holes.test.ts +856 -0
- package/tests/bulk.test.ts +256 -0
- package/tests/edge-cases-v2.test.ts +355 -0
- package/tests/edge-cases.test.ts +661 -0
- package/tests/envelope.test.ts +92 -0
- package/tests/graph.test.ts +41 -0
- package/tests/helpers.test.ts +120 -0
- package/tests/integrity.test.ts +371 -0
- package/tests/intent.test.ts +276 -0
- package/tests/query-advanced.test.ts +252 -0
- package/tests/query.test.ts +623 -0
- package/tests/reducer.test.ts +342 -0
- package/tests/replay.test.ts +145 -0
- package/tests/retrieval.test.ts +691 -0
- package/tests/serialization.test.ts +118 -0
- package/tests/setup.test.ts +7 -0
- package/tests/stats.test.ts +163 -0
- package/tests/task.test.ts +322 -0
- package/tests/transplant.test.ts +385 -0
- package/tests/types.test.ts +231 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +7 -0
package/src/query.ts
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GraphState,
|
|
3
|
+
MemoryItem,
|
|
4
|
+
Edge,
|
|
5
|
+
MemoryFilter,
|
|
6
|
+
EdgeFilter,
|
|
7
|
+
QueryOptions,
|
|
8
|
+
SortField,
|
|
9
|
+
SortOption,
|
|
10
|
+
ScoreWeights,
|
|
11
|
+
ScoredItem,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
|
|
14
|
+
function resolvePath(obj: unknown, path: string): unknown {
|
|
15
|
+
let current = obj;
|
|
16
|
+
for (const segment of path.split(".")) {
|
|
17
|
+
if (
|
|
18
|
+
current === null ||
|
|
19
|
+
current === undefined ||
|
|
20
|
+
typeof current !== "object"
|
|
21
|
+
) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
current = (current as Record<string, unknown>)[segment];
|
|
25
|
+
}
|
|
26
|
+
return current;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function matchesRange(
|
|
30
|
+
value: number | undefined,
|
|
31
|
+
range: { min?: number; max?: number } | undefined,
|
|
32
|
+
): boolean {
|
|
33
|
+
if (!range) return true;
|
|
34
|
+
if (range.min !== undefined && (value === undefined || value < range.min))
|
|
35
|
+
return false;
|
|
36
|
+
if (range.max !== undefined && (value === undefined || value > range.max))
|
|
37
|
+
return false;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function matchesFilter(item: MemoryItem, filter: MemoryFilter): boolean {
|
|
42
|
+
// ids
|
|
43
|
+
if (filter.ids !== undefined && !filter.ids.includes(item.id)) return false;
|
|
44
|
+
|
|
45
|
+
// scope
|
|
46
|
+
if (filter.scope !== undefined && item.scope !== filter.scope) return false;
|
|
47
|
+
if (
|
|
48
|
+
filter.scope_prefix !== undefined &&
|
|
49
|
+
!item.scope.startsWith(filter.scope_prefix)
|
|
50
|
+
)
|
|
51
|
+
return false;
|
|
52
|
+
|
|
53
|
+
// kind / source
|
|
54
|
+
if (filter.author !== undefined && item.author !== filter.author)
|
|
55
|
+
return false;
|
|
56
|
+
if (filter.kind !== undefined && item.kind !== filter.kind) return false;
|
|
57
|
+
if (
|
|
58
|
+
filter.source_kind !== undefined &&
|
|
59
|
+
item.source_kind !== filter.source_kind
|
|
60
|
+
)
|
|
61
|
+
return false;
|
|
62
|
+
|
|
63
|
+
// score ranges
|
|
64
|
+
if (filter.range) {
|
|
65
|
+
if (!matchesRange(item.authority, filter.range.authority)) return false;
|
|
66
|
+
if (!matchesRange(item.conviction, filter.range.conviction)) return false;
|
|
67
|
+
if (!matchesRange(item.importance, filter.range.importance)) return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// parent sugar
|
|
71
|
+
if (filter.has_parent !== undefined) {
|
|
72
|
+
if (!item.parents || !item.parents.includes(filter.has_parent))
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
if (filter.is_root !== undefined) {
|
|
76
|
+
const hasParents = item.parents !== undefined && item.parents.length > 0;
|
|
77
|
+
if (filter.is_root && hasParents) return false;
|
|
78
|
+
if (!filter.is_root && !hasParents) return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// parents (advanced)
|
|
82
|
+
if (filter.parents) {
|
|
83
|
+
const p = item.parents ?? [];
|
|
84
|
+
if (filter.parents.includes !== undefined) {
|
|
85
|
+
if (!p.includes(filter.parents.includes)) return false;
|
|
86
|
+
}
|
|
87
|
+
if (filter.parents.includes_any !== undefined) {
|
|
88
|
+
if (!filter.parents.includes_any.some((id) => p.includes(id)))
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
if (filter.parents.includes_all !== undefined) {
|
|
92
|
+
if (!filter.parents.includes_all.every((id) => p.includes(id)))
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if (filter.parents.count !== undefined) {
|
|
96
|
+
if (!matchesRange(p.length, filter.parents.count)) return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// decay
|
|
101
|
+
if (filter.decay) {
|
|
102
|
+
const multiplier = computeDecayMultiplier(item.id, filter.decay.config);
|
|
103
|
+
if (multiplier < filter.decay.min) return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// created
|
|
107
|
+
if (filter.created) {
|
|
108
|
+
const ts = extractTimestamp(item.id);
|
|
109
|
+
if (filter.created.before !== undefined && ts >= filter.created.before)
|
|
110
|
+
return false;
|
|
111
|
+
if (filter.created.after !== undefined && ts < filter.created.after)
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// combinators
|
|
116
|
+
if (filter.not && matchesFilter(item, filter.not)) return false;
|
|
117
|
+
if (filter.meta !== undefined) {
|
|
118
|
+
for (const [path, value] of Object.entries(filter.meta)) {
|
|
119
|
+
if (resolvePath(item.meta, path) !== value) return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (filter.meta_has !== undefined) {
|
|
123
|
+
for (const path of filter.meta_has) {
|
|
124
|
+
if (resolvePath(item.meta, path) === undefined) return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (filter.or !== undefined && filter.or.length > 0) {
|
|
128
|
+
if (!filter.or.some((sub) => matchesFilter(item, sub))) return false;
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Extract millisecond timestamp from a uuidv7 id.
|
|
135
|
+
* uuidv7 encodes unix ms in the first 48 bits.
|
|
136
|
+
*/
|
|
137
|
+
export function extractTimestamp(uuidv7Id: string): number {
|
|
138
|
+
const hex = uuidv7Id.replace(/-/g, "").slice(0, 12);
|
|
139
|
+
return parseInt(hex, 16);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getSortValue(item: MemoryItem, field: SortField): number {
|
|
143
|
+
switch (field) {
|
|
144
|
+
case "authority":
|
|
145
|
+
return item.authority;
|
|
146
|
+
case "conviction":
|
|
147
|
+
return item.conviction ?? 0;
|
|
148
|
+
case "importance":
|
|
149
|
+
return item.importance ?? 0;
|
|
150
|
+
case "recency":
|
|
151
|
+
return extractTimestamp(item.id);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function getItems(
|
|
156
|
+
state: GraphState,
|
|
157
|
+
filter?: MemoryFilter,
|
|
158
|
+
options?: QueryOptions,
|
|
159
|
+
): MemoryItem[] {
|
|
160
|
+
let results: MemoryItem[];
|
|
161
|
+
|
|
162
|
+
if (!filter) {
|
|
163
|
+
results = [...state.items.values()];
|
|
164
|
+
} else {
|
|
165
|
+
results = [];
|
|
166
|
+
for (const item of state.items.values()) {
|
|
167
|
+
if (matchesFilter(item, filter)) results.push(item);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (options?.sort) {
|
|
172
|
+
const sorts: SortOption[] = Array.isArray(options.sort)
|
|
173
|
+
? options.sort
|
|
174
|
+
: [options.sort];
|
|
175
|
+
results.sort((a, b) => {
|
|
176
|
+
for (const { field, order } of sorts) {
|
|
177
|
+
const va = getSortValue(a, field);
|
|
178
|
+
const vb = getSortValue(b, field);
|
|
179
|
+
if (va < vb) return order === "asc" ? -1 : 1;
|
|
180
|
+
if (va > vb) return order === "asc" ? 1 : -1;
|
|
181
|
+
}
|
|
182
|
+
return 0;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (options?.offset !== undefined || options?.limit !== undefined) {
|
|
187
|
+
const start = options.offset ?? 0;
|
|
188
|
+
const end = options.limit !== undefined ? start + options.limit : undefined;
|
|
189
|
+
results = results.slice(start, end);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return results;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const INTERVAL_MS: Record<string, number> = {
|
|
196
|
+
hour: 3_600_000,
|
|
197
|
+
day: 86_400_000,
|
|
198
|
+
week: 604_800_000,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
function computeDecayMultiplier(
|
|
202
|
+
itemId: string,
|
|
203
|
+
decay: import("./types.js").DecayConfig,
|
|
204
|
+
): number {
|
|
205
|
+
const ageMs = Date.now() - extractTimestamp(itemId);
|
|
206
|
+
if (ageMs <= 0) return 1; // future item (clock skew) — no decay
|
|
207
|
+
const intervalMs = INTERVAL_MS[decay.interval];
|
|
208
|
+
if (intervalMs === undefined) {
|
|
209
|
+
throw new RangeError(
|
|
210
|
+
`Unknown decay interval: "${decay.interval}". Expected "hour", "day", or "week".`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
const intervals = ageMs / intervalMs;
|
|
214
|
+
|
|
215
|
+
switch (decay.type) {
|
|
216
|
+
case "exponential":
|
|
217
|
+
return Math.pow(1 - decay.rate, intervals);
|
|
218
|
+
case "linear":
|
|
219
|
+
return Math.max(0, 1 - decay.rate * intervals);
|
|
220
|
+
case "step":
|
|
221
|
+
return Math.pow(1 - decay.rate, Math.floor(intervals));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function computeScore(item: MemoryItem, weights: ScoreWeights): number {
|
|
226
|
+
const base =
|
|
227
|
+
(weights.authority ?? 0) * item.authority +
|
|
228
|
+
(weights.conviction ?? 0) * (item.conviction ?? 0) +
|
|
229
|
+
(weights.importance ?? 0) * (item.importance ?? 0);
|
|
230
|
+
|
|
231
|
+
if (!weights.decay) return base;
|
|
232
|
+
|
|
233
|
+
return base * computeDecayMultiplier(item.id, weights.decay);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface ScoredQueryOptions {
|
|
237
|
+
pre?: MemoryFilter; // filter before scoring
|
|
238
|
+
post?: MemoryFilter; // filter after scoring (applied to scored items)
|
|
239
|
+
min_score?: number; // drop items below this score
|
|
240
|
+
limit?: number;
|
|
241
|
+
offset?: number;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function getScoredItems(
|
|
245
|
+
state: GraphState,
|
|
246
|
+
weights: ScoreWeights,
|
|
247
|
+
options?: ScoredQueryOptions,
|
|
248
|
+
): ScoredItem[] {
|
|
249
|
+
const items = getItems(state, options?.pre);
|
|
250
|
+
|
|
251
|
+
let scored = items.map((item) => ({
|
|
252
|
+
item,
|
|
253
|
+
score: computeScore(item, weights),
|
|
254
|
+
}));
|
|
255
|
+
|
|
256
|
+
scored.sort((a, b) => b.score - a.score);
|
|
257
|
+
|
|
258
|
+
if (options?.min_score !== undefined) {
|
|
259
|
+
scored = scored.filter((s) => s.score >= options.min_score!);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (options?.post) {
|
|
263
|
+
const postFilter = options.post;
|
|
264
|
+
scored = scored.filter((s) => matchesFilter(s.item, postFilter));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (options?.offset !== undefined || options?.limit !== undefined) {
|
|
268
|
+
const start = options.offset ?? 0;
|
|
269
|
+
const end = options.limit !== undefined ? start + options.limit : undefined;
|
|
270
|
+
scored = scored.slice(start, end);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return scored;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function getEdges(state: GraphState, filter?: EdgeFilter): Edge[] {
|
|
277
|
+
const activeOnly = filter?.active_only ?? true;
|
|
278
|
+
const results: Edge[] = [];
|
|
279
|
+
for (const edge of state.edges.values()) {
|
|
280
|
+
if (activeOnly && !edge.active) continue;
|
|
281
|
+
if (filter?.from !== undefined && edge.from !== filter.from) continue;
|
|
282
|
+
if (filter?.to !== undefined && edge.to !== filter.to) continue;
|
|
283
|
+
if (filter?.kind !== undefined && edge.kind !== filter.kind) continue;
|
|
284
|
+
if (
|
|
285
|
+
filter?.min_weight !== undefined &&
|
|
286
|
+
(edge.weight === undefined || edge.weight < filter.min_weight)
|
|
287
|
+
)
|
|
288
|
+
continue;
|
|
289
|
+
results.push(edge);
|
|
290
|
+
}
|
|
291
|
+
return results;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function getItemById(
|
|
295
|
+
state: GraphState,
|
|
296
|
+
id: string,
|
|
297
|
+
): MemoryItem | undefined {
|
|
298
|
+
return state.items.get(id);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function getEdgeById(
|
|
302
|
+
state: GraphState,
|
|
303
|
+
edgeId: string,
|
|
304
|
+
): Edge | undefined {
|
|
305
|
+
return state.edges.get(edgeId);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function getRelatedItems(
|
|
309
|
+
state: GraphState,
|
|
310
|
+
itemId: string,
|
|
311
|
+
direction: "from" | "to" | "both" = "both",
|
|
312
|
+
): MemoryItem[] {
|
|
313
|
+
const relatedIds = new Set<string>();
|
|
314
|
+
|
|
315
|
+
for (const edge of state.edges.values()) {
|
|
316
|
+
if (!edge.active) continue;
|
|
317
|
+
if (
|
|
318
|
+
(direction === "from" || direction === "both") &&
|
|
319
|
+
edge.from === itemId
|
|
320
|
+
) {
|
|
321
|
+
relatedIds.add(edge.to);
|
|
322
|
+
}
|
|
323
|
+
if ((direction === "to" || direction === "both") && edge.to === itemId) {
|
|
324
|
+
relatedIds.add(edge.from);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
relatedIds.delete(itemId);
|
|
329
|
+
|
|
330
|
+
const results: MemoryItem[] = [];
|
|
331
|
+
for (const id of relatedIds) {
|
|
332
|
+
const item = state.items.get(id);
|
|
333
|
+
if (item) results.push(item);
|
|
334
|
+
}
|
|
335
|
+
return results;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function getParents(state: GraphState, itemId: string): MemoryItem[] {
|
|
339
|
+
const item = state.items.get(itemId);
|
|
340
|
+
if (!item?.parents) return [];
|
|
341
|
+
const results: MemoryItem[] = [];
|
|
342
|
+
for (const pid of item.parents) {
|
|
343
|
+
const parent = state.items.get(pid);
|
|
344
|
+
if (parent) results.push(parent);
|
|
345
|
+
}
|
|
346
|
+
return results;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function getChildren(state: GraphState, itemId: string): MemoryItem[] {
|
|
350
|
+
const results: MemoryItem[] = [];
|
|
351
|
+
for (const item of state.items.values()) {
|
|
352
|
+
if (item.parents && item.parents.includes(itemId)) {
|
|
353
|
+
results.push(item);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return results;
|
|
357
|
+
}
|
package/src/reducer.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GraphState,
|
|
3
|
+
MemoryCommand,
|
|
4
|
+
MemoryLifecycleEvent,
|
|
5
|
+
MemoryItem,
|
|
6
|
+
Edge,
|
|
7
|
+
} from "./types.js";
|
|
8
|
+
import {
|
|
9
|
+
MemoryNotFoundError,
|
|
10
|
+
EdgeNotFoundError,
|
|
11
|
+
DuplicateMemoryError,
|
|
12
|
+
DuplicateEdgeError,
|
|
13
|
+
} from "./errors.js";
|
|
14
|
+
|
|
15
|
+
function stripUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
|
|
16
|
+
const result: Record<string, unknown> = {};
|
|
17
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
18
|
+
if (value !== undefined) result[key] = value;
|
|
19
|
+
}
|
|
20
|
+
return result as Partial<T>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function mergeItem(
|
|
24
|
+
existing: MemoryItem,
|
|
25
|
+
partial: Partial<MemoryItem>,
|
|
26
|
+
): MemoryItem {
|
|
27
|
+
const {
|
|
28
|
+
content: partialContent,
|
|
29
|
+
meta: partialMeta,
|
|
30
|
+
id: _id,
|
|
31
|
+
...rest
|
|
32
|
+
} = partial;
|
|
33
|
+
return {
|
|
34
|
+
...existing,
|
|
35
|
+
...stripUndefined(rest),
|
|
36
|
+
content:
|
|
37
|
+
partialContent !== undefined
|
|
38
|
+
? { ...existing.content, ...stripUndefined(partialContent) }
|
|
39
|
+
: existing.content,
|
|
40
|
+
meta:
|
|
41
|
+
partialMeta !== undefined
|
|
42
|
+
? { ...existing.meta, ...stripUndefined(partialMeta) }
|
|
43
|
+
: existing.meta,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function mergeEdge(existing: Edge, partial: Partial<Edge>): Edge {
|
|
48
|
+
const { edge_id: _eid, from: _from, to: _to, ...rest } = partial;
|
|
49
|
+
return { ...existing, ...stripUndefined(rest) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function applyCommand(
|
|
53
|
+
state: GraphState,
|
|
54
|
+
cmd: MemoryCommand,
|
|
55
|
+
): { state: GraphState; events: MemoryLifecycleEvent[] } {
|
|
56
|
+
switch (cmd.type) {
|
|
57
|
+
case "memory.create": {
|
|
58
|
+
if (state.items.has(cmd.item.id)) {
|
|
59
|
+
throw new DuplicateMemoryError(cmd.item.id);
|
|
60
|
+
}
|
|
61
|
+
const items = new Map(state.items);
|
|
62
|
+
items.set(cmd.item.id, cmd.item);
|
|
63
|
+
return {
|
|
64
|
+
state: { items, edges: state.edges },
|
|
65
|
+
events: [
|
|
66
|
+
{
|
|
67
|
+
namespace: "memory",
|
|
68
|
+
type: "memory.created",
|
|
69
|
+
item: cmd.item,
|
|
70
|
+
cause_type: cmd.type,
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
case "memory.update": {
|
|
77
|
+
const existing = state.items.get(cmd.item_id);
|
|
78
|
+
if (!existing) {
|
|
79
|
+
throw new MemoryNotFoundError(cmd.item_id);
|
|
80
|
+
}
|
|
81
|
+
const merged = mergeItem(existing, cmd.partial);
|
|
82
|
+
const items = new Map(state.items);
|
|
83
|
+
items.set(cmd.item_id, merged);
|
|
84
|
+
return {
|
|
85
|
+
state: { items, edges: state.edges },
|
|
86
|
+
events: [
|
|
87
|
+
{
|
|
88
|
+
namespace: "memory",
|
|
89
|
+
type: "memory.updated",
|
|
90
|
+
item: merged,
|
|
91
|
+
cause_type: cmd.type,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
case "memory.retract": {
|
|
98
|
+
const existing = state.items.get(cmd.item_id);
|
|
99
|
+
if (!existing) {
|
|
100
|
+
throw new MemoryNotFoundError(cmd.item_id);
|
|
101
|
+
}
|
|
102
|
+
const items = new Map(state.items);
|
|
103
|
+
items.delete(cmd.item_id);
|
|
104
|
+
return {
|
|
105
|
+
state: { items, edges: state.edges },
|
|
106
|
+
events: [
|
|
107
|
+
{
|
|
108
|
+
namespace: "memory",
|
|
109
|
+
type: "memory.retracted",
|
|
110
|
+
item: existing,
|
|
111
|
+
cause_type: cmd.type,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
case "edge.create": {
|
|
118
|
+
if (state.edges.has(cmd.edge.edge_id)) {
|
|
119
|
+
throw new DuplicateEdgeError(cmd.edge.edge_id);
|
|
120
|
+
}
|
|
121
|
+
const edges = new Map(state.edges);
|
|
122
|
+
edges.set(cmd.edge.edge_id, cmd.edge);
|
|
123
|
+
return {
|
|
124
|
+
state: { items: state.items, edges },
|
|
125
|
+
events: [
|
|
126
|
+
{
|
|
127
|
+
namespace: "memory",
|
|
128
|
+
type: "edge.created",
|
|
129
|
+
edge: cmd.edge,
|
|
130
|
+
cause_type: cmd.type,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
case "edge.update": {
|
|
137
|
+
const existing = state.edges.get(cmd.edge_id);
|
|
138
|
+
if (!existing) {
|
|
139
|
+
throw new EdgeNotFoundError(cmd.edge_id);
|
|
140
|
+
}
|
|
141
|
+
const merged = mergeEdge(existing, cmd.partial);
|
|
142
|
+
const edges = new Map(state.edges);
|
|
143
|
+
edges.set(cmd.edge_id, merged);
|
|
144
|
+
return {
|
|
145
|
+
state: { items: state.items, edges },
|
|
146
|
+
events: [
|
|
147
|
+
{
|
|
148
|
+
namespace: "memory",
|
|
149
|
+
type: "edge.updated",
|
|
150
|
+
edge: merged,
|
|
151
|
+
cause_type: cmd.type,
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
case "edge.retract": {
|
|
158
|
+
const existing = state.edges.get(cmd.edge_id);
|
|
159
|
+
if (!existing) {
|
|
160
|
+
throw new EdgeNotFoundError(cmd.edge_id);
|
|
161
|
+
}
|
|
162
|
+
const edges = new Map(state.edges);
|
|
163
|
+
edges.delete(cmd.edge_id);
|
|
164
|
+
return {
|
|
165
|
+
state: { items: state.items, edges },
|
|
166
|
+
events: [
|
|
167
|
+
{
|
|
168
|
+
namespace: "memory",
|
|
169
|
+
type: "edge.retracted",
|
|
170
|
+
edge: existing,
|
|
171
|
+
cause_type: cmd.type,
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
package/src/replay.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GraphState,
|
|
3
|
+
MemoryCommand,
|
|
4
|
+
MemoryLifecycleEvent,
|
|
5
|
+
EventEnvelope,
|
|
6
|
+
} from "./types.js";
|
|
7
|
+
import { createGraphState } from "./graph.js";
|
|
8
|
+
import { applyCommand } from "./reducer.js";
|
|
9
|
+
|
|
10
|
+
export function replayCommands(commands: MemoryCommand[]): {
|
|
11
|
+
state: GraphState;
|
|
12
|
+
events: MemoryLifecycleEvent[];
|
|
13
|
+
} {
|
|
14
|
+
let state = createGraphState();
|
|
15
|
+
const allEvents: MemoryLifecycleEvent[] = [];
|
|
16
|
+
|
|
17
|
+
for (const cmd of commands) {
|
|
18
|
+
const result = applyCommand(state, cmd);
|
|
19
|
+
state = result.state;
|
|
20
|
+
allEvents.push(...result.events);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return { state, events: allEvents };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function replayFromEnvelopes(
|
|
27
|
+
envelopes: EventEnvelope<MemoryCommand>[],
|
|
28
|
+
): { state: GraphState; events: MemoryLifecycleEvent[] } {
|
|
29
|
+
const sorted = [...envelopes].sort((a, b) => a.ts.localeCompare(b.ts));
|
|
30
|
+
const commands = sorted.map((env) => env.payload);
|
|
31
|
+
return replayCommands(commands);
|
|
32
|
+
}
|