@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/integrity.ts
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GraphState,
|
|
3
|
+
MemoryItem,
|
|
4
|
+
Edge,
|
|
5
|
+
MemoryLifecycleEvent,
|
|
6
|
+
ScoreWeights,
|
|
7
|
+
ScoredItem,
|
|
8
|
+
} from "./types.js";
|
|
9
|
+
import { applyCommand } from "./reducer.js";
|
|
10
|
+
import { getEdges, getChildren, getScoredItems } from "./query.js";
|
|
11
|
+
import { uuidv7 } from "uuidv7";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// 1. Temporal forking — conflict detection & resolution
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export interface Contradiction {
|
|
18
|
+
a: MemoryItem;
|
|
19
|
+
b: MemoryItem;
|
|
20
|
+
edge?: Edge;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Find all active CONTRADICTS edges and return the item pairs.
|
|
25
|
+
*/
|
|
26
|
+
export function getContradictions(state: GraphState): Contradiction[] {
|
|
27
|
+
const contradictEdges = getEdges(state, {
|
|
28
|
+
kind: "CONTRADICTS",
|
|
29
|
+
active_only: true,
|
|
30
|
+
});
|
|
31
|
+
const results: Contradiction[] = [];
|
|
32
|
+
for (const edge of contradictEdges) {
|
|
33
|
+
const a = state.items.get(edge.from);
|
|
34
|
+
const b = state.items.get(edge.to);
|
|
35
|
+
if (a && b) results.push({ a, b, edge });
|
|
36
|
+
}
|
|
37
|
+
return results;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Mark two items as contradicting each other.
|
|
42
|
+
* Creates a CONTRADICTS edge between them.
|
|
43
|
+
*/
|
|
44
|
+
export function markContradiction(
|
|
45
|
+
state: GraphState,
|
|
46
|
+
itemIdA: string,
|
|
47
|
+
itemIdB: string,
|
|
48
|
+
author: string,
|
|
49
|
+
meta?: Record<string, unknown>,
|
|
50
|
+
): { state: GraphState; events: MemoryLifecycleEvent[] } {
|
|
51
|
+
return applyCommand(state, {
|
|
52
|
+
type: "edge.create",
|
|
53
|
+
edge: {
|
|
54
|
+
edge_id: uuidv7(),
|
|
55
|
+
from: itemIdA,
|
|
56
|
+
to: itemIdB,
|
|
57
|
+
kind: "CONTRADICTS",
|
|
58
|
+
author,
|
|
59
|
+
source_kind: "derived_deterministic",
|
|
60
|
+
authority: 1,
|
|
61
|
+
active: true,
|
|
62
|
+
meta,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a contradiction by marking the winner as superseding the loser.
|
|
69
|
+
* - Creates a SUPERSEDES edge (winner -> loser)
|
|
70
|
+
* - Lowers the loser's authority
|
|
71
|
+
* - Retracts the CONTRADICTS edge
|
|
72
|
+
*/
|
|
73
|
+
export function resolveContradiction(
|
|
74
|
+
state: GraphState,
|
|
75
|
+
winnerId: string,
|
|
76
|
+
loserId: string,
|
|
77
|
+
author: string,
|
|
78
|
+
reason?: string,
|
|
79
|
+
): { state: GraphState; events: MemoryLifecycleEvent[] } {
|
|
80
|
+
let current = state;
|
|
81
|
+
const allEvents: MemoryLifecycleEvent[] = [];
|
|
82
|
+
|
|
83
|
+
// find and retract the CONTRADICTS edge(s) between them
|
|
84
|
+
// collect matching edges first to avoid iterating a map while mutating state
|
|
85
|
+
const toRetract: string[] = [];
|
|
86
|
+
for (const edge of current.edges.values()) {
|
|
87
|
+
if (
|
|
88
|
+
edge.kind === "CONTRADICTS" &&
|
|
89
|
+
edge.active &&
|
|
90
|
+
((edge.from === winnerId && edge.to === loserId) ||
|
|
91
|
+
(edge.from === loserId && edge.to === winnerId))
|
|
92
|
+
) {
|
|
93
|
+
toRetract.push(edge.edge_id);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const edgeId of toRetract) {
|
|
98
|
+
const r = applyCommand(current, {
|
|
99
|
+
type: "edge.retract",
|
|
100
|
+
edge_id: edgeId,
|
|
101
|
+
author,
|
|
102
|
+
reason,
|
|
103
|
+
});
|
|
104
|
+
current = r.state;
|
|
105
|
+
allEvents.push(...r.events);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (toRetract.length === 0) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`No active CONTRADICTS edge between ${winnerId} and ${loserId}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// create SUPERSEDES edge
|
|
115
|
+
const r1 = applyCommand(current, {
|
|
116
|
+
type: "edge.create",
|
|
117
|
+
edge: {
|
|
118
|
+
edge_id: uuidv7(),
|
|
119
|
+
from: winnerId,
|
|
120
|
+
to: loserId,
|
|
121
|
+
kind: "SUPERSEDES",
|
|
122
|
+
author,
|
|
123
|
+
source_kind: "derived_deterministic",
|
|
124
|
+
authority: 1,
|
|
125
|
+
active: true,
|
|
126
|
+
meta: reason ? { reason } : undefined,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
current = r1.state;
|
|
130
|
+
allEvents.push(...r1.events);
|
|
131
|
+
|
|
132
|
+
// lower loser's authority
|
|
133
|
+
const loser = current.items.get(loserId);
|
|
134
|
+
if (loser) {
|
|
135
|
+
const r2 = applyCommand(current, {
|
|
136
|
+
type: "memory.update",
|
|
137
|
+
item_id: loserId,
|
|
138
|
+
partial: { authority: loser.authority * 0.1 },
|
|
139
|
+
author,
|
|
140
|
+
reason,
|
|
141
|
+
});
|
|
142
|
+
current = r2.state;
|
|
143
|
+
allEvents.push(...r2.events);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { state: current, events: allEvents };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// 2. Observational continuity — stale detection & cascade
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
export interface StaleItem {
|
|
154
|
+
item: MemoryItem;
|
|
155
|
+
missing_parents: string[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Find items whose parents have been retracted (missing from state).
|
|
160
|
+
*/
|
|
161
|
+
export function getStaleItems(state: GraphState): StaleItem[] {
|
|
162
|
+
const results: StaleItem[] = [];
|
|
163
|
+
for (const item of state.items.values()) {
|
|
164
|
+
if (!item.parents || item.parents.length === 0) continue;
|
|
165
|
+
const missing = item.parents.filter((pid) => !state.items.has(pid));
|
|
166
|
+
if (missing.length > 0) {
|
|
167
|
+
results.push({ item, missing_parents: missing });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return results;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Find items that depend on a specific item (directly or transitively).
|
|
175
|
+
*/
|
|
176
|
+
export function getDependents(
|
|
177
|
+
state: GraphState,
|
|
178
|
+
itemId: string,
|
|
179
|
+
transitive = false,
|
|
180
|
+
): MemoryItem[] {
|
|
181
|
+
const direct = getChildren(state, itemId);
|
|
182
|
+
if (!transitive) return direct;
|
|
183
|
+
|
|
184
|
+
const visited = new Set<string>();
|
|
185
|
+
const result: MemoryItem[] = [];
|
|
186
|
+
const queue = [...direct];
|
|
187
|
+
|
|
188
|
+
while (queue.length > 0) {
|
|
189
|
+
const item = queue.pop()!;
|
|
190
|
+
if (visited.has(item.id)) continue;
|
|
191
|
+
visited.add(item.id);
|
|
192
|
+
result.push(item);
|
|
193
|
+
queue.push(...getChildren(state, item.id));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Cascade retraction: retract an item and all its transitive dependents.
|
|
201
|
+
*/
|
|
202
|
+
export function cascadeRetract(
|
|
203
|
+
state: GraphState,
|
|
204
|
+
itemId: string,
|
|
205
|
+
author: string,
|
|
206
|
+
reason?: string,
|
|
207
|
+
): { state: GraphState; events: MemoryLifecycleEvent[]; retracted: string[] } {
|
|
208
|
+
const dependents = getDependents(state, itemId, true);
|
|
209
|
+
let current = state;
|
|
210
|
+
const allEvents: MemoryLifecycleEvent[] = [];
|
|
211
|
+
const retracted: string[] = [];
|
|
212
|
+
|
|
213
|
+
// retract dependents first (leaves before roots)
|
|
214
|
+
for (const dep of dependents.reverse()) {
|
|
215
|
+
if (!current.items.has(dep.id)) continue;
|
|
216
|
+
const r = applyCommand(current, {
|
|
217
|
+
type: "memory.retract",
|
|
218
|
+
item_id: dep.id,
|
|
219
|
+
author,
|
|
220
|
+
reason: reason ?? `parent ${itemId} retracted`,
|
|
221
|
+
});
|
|
222
|
+
current = r.state;
|
|
223
|
+
allEvents.push(...r.events);
|
|
224
|
+
retracted.push(dep.id);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// retract the item itself
|
|
228
|
+
if (current.items.has(itemId)) {
|
|
229
|
+
const r = applyCommand(current, {
|
|
230
|
+
type: "memory.retract",
|
|
231
|
+
item_id: itemId,
|
|
232
|
+
author,
|
|
233
|
+
reason,
|
|
234
|
+
});
|
|
235
|
+
current = r.state;
|
|
236
|
+
allEvents.push(...r.events);
|
|
237
|
+
retracted.push(itemId);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { state: current, events: allEvents, retracted };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// 3. Recognition vs discovery — identity / aliasing
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Mark two items as referring to the same entity.
|
|
249
|
+
* Creates bidirectional ALIAS edges.
|
|
250
|
+
*/
|
|
251
|
+
export function markAlias(
|
|
252
|
+
state: GraphState,
|
|
253
|
+
itemIdA: string,
|
|
254
|
+
itemIdB: string,
|
|
255
|
+
author: string,
|
|
256
|
+
meta?: Record<string, unknown>,
|
|
257
|
+
): { state: GraphState; events: MemoryLifecycleEvent[] } {
|
|
258
|
+
let current = state;
|
|
259
|
+
const allEvents: MemoryLifecycleEvent[] = [];
|
|
260
|
+
|
|
261
|
+
const r1 = applyCommand(current, {
|
|
262
|
+
type: "edge.create",
|
|
263
|
+
edge: {
|
|
264
|
+
edge_id: uuidv7(),
|
|
265
|
+
from: itemIdA,
|
|
266
|
+
to: itemIdB,
|
|
267
|
+
kind: "ALIAS",
|
|
268
|
+
author,
|
|
269
|
+
source_kind: "derived_deterministic",
|
|
270
|
+
authority: 1,
|
|
271
|
+
active: true,
|
|
272
|
+
meta,
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
current = r1.state;
|
|
276
|
+
allEvents.push(...r1.events);
|
|
277
|
+
|
|
278
|
+
const r2 = applyCommand(current, {
|
|
279
|
+
type: "edge.create",
|
|
280
|
+
edge: {
|
|
281
|
+
edge_id: uuidv7(),
|
|
282
|
+
from: itemIdB,
|
|
283
|
+
to: itemIdA,
|
|
284
|
+
kind: "ALIAS",
|
|
285
|
+
author,
|
|
286
|
+
source_kind: "derived_deterministic",
|
|
287
|
+
authority: 1,
|
|
288
|
+
active: true,
|
|
289
|
+
meta,
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
current = r2.state;
|
|
293
|
+
allEvents.push(...r2.events);
|
|
294
|
+
|
|
295
|
+
return { state: current, events: allEvents };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Get all items that are aliased to a given item (directly).
|
|
300
|
+
*/
|
|
301
|
+
export function getAliases(state: GraphState, itemId: string): MemoryItem[] {
|
|
302
|
+
const aliasEdges = getEdges(state, {
|
|
303
|
+
from: itemId,
|
|
304
|
+
kind: "ALIAS",
|
|
305
|
+
active_only: true,
|
|
306
|
+
});
|
|
307
|
+
const results: MemoryItem[] = [];
|
|
308
|
+
for (const edge of aliasEdges) {
|
|
309
|
+
const item = state.items.get(edge.to);
|
|
310
|
+
if (item) results.push(item);
|
|
311
|
+
}
|
|
312
|
+
return results;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Get the full alias group for an item (transitive closure).
|
|
317
|
+
*/
|
|
318
|
+
export function getAliasGroup(state: GraphState, itemId: string): MemoryItem[] {
|
|
319
|
+
const visited = new Set<string>();
|
|
320
|
+
const result: MemoryItem[] = [];
|
|
321
|
+
const queue = [itemId];
|
|
322
|
+
|
|
323
|
+
while (queue.length > 0) {
|
|
324
|
+
const id = queue.pop()!;
|
|
325
|
+
if (visited.has(id)) continue;
|
|
326
|
+
visited.add(id);
|
|
327
|
+
const item = state.items.get(id);
|
|
328
|
+
if (item) result.push(item);
|
|
329
|
+
const aliases = getEdges(state, {
|
|
330
|
+
from: id,
|
|
331
|
+
kind: "ALIAS",
|
|
332
|
+
active_only: true,
|
|
333
|
+
});
|
|
334
|
+
for (const edge of aliases) {
|
|
335
|
+
queue.push(edge.to);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// 4. Budget-aware probabilistic retrieval
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
export interface BudgetOptions {
|
|
347
|
+
budget: number; // total budget (e.g. token count, cost units)
|
|
348
|
+
costFn: (item: MemoryItem) => number; // cost per item
|
|
349
|
+
weights: ScoreWeights; // scoring weights
|
|
350
|
+
filter?: import("./types.js").MemoryFilter;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Retrieve the highest-scoring items that fit within a budget.
|
|
355
|
+
* Items are ranked by weighted score, then greedily packed.
|
|
356
|
+
*/
|
|
357
|
+
export function getItemsByBudget(
|
|
358
|
+
state: GraphState,
|
|
359
|
+
options: BudgetOptions,
|
|
360
|
+
): ScoredItem[] {
|
|
361
|
+
const scored = getScoredItems(state, options.weights, {
|
|
362
|
+
pre: options.filter,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const results: ScoredItem[] = [];
|
|
366
|
+
let remaining = options.budget;
|
|
367
|
+
|
|
368
|
+
for (const entry of scored) {
|
|
369
|
+
const cost = options.costFn(entry.item);
|
|
370
|
+
if (cost <= remaining) {
|
|
371
|
+
results.push(entry);
|
|
372
|
+
remaining -= cost;
|
|
373
|
+
}
|
|
374
|
+
if (remaining <= 0) break;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return results;
|
|
378
|
+
}
|
package/src/intent.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { uuidv7 } from "uuidv7";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Types
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export type IntentStatus = "active" | "paused" | "completed" | "cancelled";
|
|
8
|
+
|
|
9
|
+
export interface Intent {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
|
|
14
|
+
priority: number; // 0..1
|
|
15
|
+
owner: string; // "user:laz", "agent:reasoner", "system:watchdog"
|
|
16
|
+
status: IntentStatus;
|
|
17
|
+
|
|
18
|
+
context?: Record<string, unknown>;
|
|
19
|
+
root_memory_ids?: string[];
|
|
20
|
+
|
|
21
|
+
meta?: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface IntentState {
|
|
25
|
+
intents: Map<string, Intent>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// State
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
export function createIntentState(): IntentState {
|
|
33
|
+
return { intents: new Map() };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Factory
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
export function createIntent(
|
|
41
|
+
input: Omit<Intent, "id" | "status"> & { id?: string; status?: IntentStatus },
|
|
42
|
+
): Intent {
|
|
43
|
+
return {
|
|
44
|
+
...input,
|
|
45
|
+
id: input.id ?? uuidv7(),
|
|
46
|
+
status: input.status ?? "active",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Commands
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
export type IntentCommand =
|
|
55
|
+
| { type: "intent.create"; intent: Intent }
|
|
56
|
+
| {
|
|
57
|
+
type: "intent.update";
|
|
58
|
+
intent_id: string;
|
|
59
|
+
partial: Partial<Intent>;
|
|
60
|
+
author: string;
|
|
61
|
+
reason?: string;
|
|
62
|
+
}
|
|
63
|
+
| {
|
|
64
|
+
type: "intent.complete";
|
|
65
|
+
intent_id: string;
|
|
66
|
+
author: string;
|
|
67
|
+
reason?: string;
|
|
68
|
+
}
|
|
69
|
+
| {
|
|
70
|
+
type: "intent.cancel";
|
|
71
|
+
intent_id: string;
|
|
72
|
+
author: string;
|
|
73
|
+
reason?: string;
|
|
74
|
+
}
|
|
75
|
+
| { type: "intent.pause"; intent_id: string; author: string; reason?: string }
|
|
76
|
+
| {
|
|
77
|
+
type: "intent.resume";
|
|
78
|
+
intent_id: string;
|
|
79
|
+
author: string;
|
|
80
|
+
reason?: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Lifecycle events
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
export interface IntentLifecycleEvent {
|
|
88
|
+
namespace: "intent";
|
|
89
|
+
type:
|
|
90
|
+
| "intent.created"
|
|
91
|
+
| "intent.updated"
|
|
92
|
+
| "intent.completed"
|
|
93
|
+
| "intent.cancelled"
|
|
94
|
+
| "intent.paused"
|
|
95
|
+
| "intent.resumed";
|
|
96
|
+
intent: Intent;
|
|
97
|
+
cause_type: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Errors
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
export class IntentNotFoundError extends Error {
|
|
105
|
+
constructor(id: string) {
|
|
106
|
+
super(`Intent not found: ${id}`);
|
|
107
|
+
this.name = "IntentNotFoundError";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class DuplicateIntentError extends Error {
|
|
112
|
+
constructor(id: string) {
|
|
113
|
+
super(`Intent already exists: ${id}`);
|
|
114
|
+
this.name = "DuplicateIntentError";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class InvalidIntentTransitionError extends Error {
|
|
119
|
+
constructor(id: string, from: IntentStatus, to: string) {
|
|
120
|
+
super(`Invalid intent transition: ${id} from ${from} to ${to}`);
|
|
121
|
+
this.name = "InvalidIntentTransitionError";
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Helpers
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
function stripUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
|
|
130
|
+
const result: Record<string, unknown> = {};
|
|
131
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
132
|
+
if (value !== undefined) result[key] = value;
|
|
133
|
+
}
|
|
134
|
+
return result as Partial<T>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Reducer
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
function setStatus(
|
|
142
|
+
state: IntentState,
|
|
143
|
+
id: string,
|
|
144
|
+
targetStatus: IntentStatus,
|
|
145
|
+
validFrom: IntentStatus[],
|
|
146
|
+
author: string,
|
|
147
|
+
causeType: string,
|
|
148
|
+
eventType: IntentLifecycleEvent["type"],
|
|
149
|
+
): { state: IntentState; events: IntentLifecycleEvent[] } {
|
|
150
|
+
const existing = state.intents.get(id);
|
|
151
|
+
if (!existing) throw new IntentNotFoundError(id);
|
|
152
|
+
if (!validFrom.includes(existing.status)) {
|
|
153
|
+
throw new InvalidIntentTransitionError(id, existing.status, targetStatus);
|
|
154
|
+
}
|
|
155
|
+
const updated: Intent = { ...existing, status: targetStatus };
|
|
156
|
+
const intents = new Map(state.intents);
|
|
157
|
+
intents.set(id, updated);
|
|
158
|
+
return {
|
|
159
|
+
state: { intents },
|
|
160
|
+
events: [
|
|
161
|
+
{
|
|
162
|
+
namespace: "intent",
|
|
163
|
+
type: eventType,
|
|
164
|
+
intent: updated,
|
|
165
|
+
cause_type: causeType,
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function applyIntentCommand(
|
|
172
|
+
state: IntentState,
|
|
173
|
+
cmd: IntentCommand,
|
|
174
|
+
): { state: IntentState; events: IntentLifecycleEvent[] } {
|
|
175
|
+
switch (cmd.type) {
|
|
176
|
+
case "intent.create": {
|
|
177
|
+
if (state.intents.has(cmd.intent.id)) {
|
|
178
|
+
throw new DuplicateIntentError(cmd.intent.id);
|
|
179
|
+
}
|
|
180
|
+
const intents = new Map(state.intents);
|
|
181
|
+
intents.set(cmd.intent.id, cmd.intent);
|
|
182
|
+
return {
|
|
183
|
+
state: { intents },
|
|
184
|
+
events: [
|
|
185
|
+
{
|
|
186
|
+
namespace: "intent",
|
|
187
|
+
type: "intent.created",
|
|
188
|
+
intent: cmd.intent,
|
|
189
|
+
cause_type: cmd.type,
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
case "intent.update": {
|
|
196
|
+
const existing = state.intents.get(cmd.intent_id);
|
|
197
|
+
if (!existing) throw new IntentNotFoundError(cmd.intent_id);
|
|
198
|
+
const { id: _id, status: _status, ...rest } = cmd.partial;
|
|
199
|
+
const updated: Intent = { ...existing, ...stripUndefined(rest) };
|
|
200
|
+
const intents = new Map(state.intents);
|
|
201
|
+
intents.set(cmd.intent_id, updated);
|
|
202
|
+
return {
|
|
203
|
+
state: { intents },
|
|
204
|
+
events: [
|
|
205
|
+
{
|
|
206
|
+
namespace: "intent",
|
|
207
|
+
type: "intent.updated",
|
|
208
|
+
intent: updated,
|
|
209
|
+
cause_type: cmd.type,
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
case "intent.complete":
|
|
216
|
+
return setStatus(
|
|
217
|
+
state,
|
|
218
|
+
cmd.intent_id,
|
|
219
|
+
"completed",
|
|
220
|
+
["active", "paused"],
|
|
221
|
+
cmd.author,
|
|
222
|
+
cmd.type,
|
|
223
|
+
"intent.completed",
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
case "intent.cancel":
|
|
227
|
+
return setStatus(
|
|
228
|
+
state,
|
|
229
|
+
cmd.intent_id,
|
|
230
|
+
"cancelled",
|
|
231
|
+
["active", "paused"],
|
|
232
|
+
cmd.author,
|
|
233
|
+
cmd.type,
|
|
234
|
+
"intent.cancelled",
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
case "intent.pause":
|
|
238
|
+
return setStatus(
|
|
239
|
+
state,
|
|
240
|
+
cmd.intent_id,
|
|
241
|
+
"paused",
|
|
242
|
+
["active"],
|
|
243
|
+
cmd.author,
|
|
244
|
+
cmd.type,
|
|
245
|
+
"intent.paused",
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
case "intent.resume":
|
|
249
|
+
return setStatus(
|
|
250
|
+
state,
|
|
251
|
+
cmd.intent_id,
|
|
252
|
+
"active",
|
|
253
|
+
["paused"],
|
|
254
|
+
cmd.author,
|
|
255
|
+
cmd.type,
|
|
256
|
+
"intent.resumed",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// Query
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
export interface IntentFilter {
|
|
266
|
+
owner?: string;
|
|
267
|
+
status?: IntentStatus;
|
|
268
|
+
statuses?: IntentStatus[];
|
|
269
|
+
min_priority?: number;
|
|
270
|
+
has_memory_id?: string;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function getIntents(
|
|
274
|
+
state: IntentState,
|
|
275
|
+
filter?: IntentFilter,
|
|
276
|
+
): Intent[] {
|
|
277
|
+
if (!filter) return [...state.intents.values()];
|
|
278
|
+
|
|
279
|
+
const results: Intent[] = [];
|
|
280
|
+
for (const intent of state.intents.values()) {
|
|
281
|
+
if (filter.owner !== undefined && intent.owner !== filter.owner) continue;
|
|
282
|
+
if (filter.status !== undefined && intent.status !== filter.status)
|
|
283
|
+
continue;
|
|
284
|
+
if (
|
|
285
|
+
filter.statuses !== undefined &&
|
|
286
|
+
!filter.statuses.includes(intent.status)
|
|
287
|
+
)
|
|
288
|
+
continue;
|
|
289
|
+
if (
|
|
290
|
+
filter.min_priority !== undefined &&
|
|
291
|
+
intent.priority < filter.min_priority
|
|
292
|
+
)
|
|
293
|
+
continue;
|
|
294
|
+
if (filter.has_memory_id !== undefined) {
|
|
295
|
+
if (
|
|
296
|
+
!intent.root_memory_ids ||
|
|
297
|
+
!intent.root_memory_ids.includes(filter.has_memory_id)
|
|
298
|
+
)
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
results.push(intent);
|
|
302
|
+
}
|
|
303
|
+
return results;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function getIntentById(
|
|
307
|
+
state: IntentState,
|
|
308
|
+
id: string,
|
|
309
|
+
): Intent | undefined {
|
|
310
|
+
return state.intents.get(id);
|
|
311
|
+
}
|