@ai2070/memex 0.9.0 → 0.10.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/API.md CHANGED
@@ -21,6 +21,9 @@ interface MemoryItem {
21
21
  conviction?: number; // 0..1 -- how sure was the author?
22
22
  importance?: number; // 0..1 -- how much attention does this need right now? (salience)
23
23
 
24
+ intent_id?: string; // intent that produced this item
25
+ task_id?: string; // task that produced this item
26
+
24
27
  meta?: {
25
28
  agent_id?: string;
26
29
  session_id?: string;
@@ -221,6 +224,11 @@ interface MemoryFilter {
221
224
  kind?: MemoryKind;
222
225
  source_kind?: SourceKind;
223
226
 
227
+ intent_id?: string; // exact match on intent_id
228
+ intent_ids?: string[]; // match any of these intent_ids
229
+ task_id?: string; // exact match on task_id
230
+ task_ids?: string[]; // match any of these task_ids
231
+
224
232
  range?: {
225
233
  authority?: { min?: number; max?: number };
226
234
  conviction?: { min?: number; max?: number };
@@ -757,6 +765,7 @@ type IntentStatus = "active" | "paused" | "completed" | "cancelled";
757
765
 
758
766
  interface Intent {
759
767
  id: string;
768
+ parent_id?: string; // parent intent for sub-intent hierarchies
760
769
  label: string;
761
770
  description?: string;
762
771
  priority: number; // 0..1
@@ -808,10 +817,13 @@ interface IntentFilter {
808
817
  statuses?: IntentStatus[];
809
818
  min_priority?: number;
810
819
  has_memory_id?: string; // intent references this memory item
820
+ parent_id?: string; // filter by parent intent
821
+ is_root?: boolean; // true = no parent, false = has parent
811
822
  }
812
823
 
813
824
  getIntents(state, { owner: "user:laz", statuses: ["active", "paused"] });
814
825
  getIntentById(state, "i1");
826
+ getChildIntents(state, "i1"); // all intents with parent_id = "i1"
815
827
  ```
816
828
 
817
829
  ---
@@ -828,6 +840,7 @@ type TaskStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
828
840
  interface Task {
829
841
  id: string;
830
842
  intent_id: string; // parent intent
843
+ parent_id?: string; // parent task for subtask hierarchies
831
844
  action: string; // "search_linkedin", "summarize_case"
832
845
  label?: string;
833
846
  status: TaskStatus;
@@ -886,10 +899,13 @@ interface TaskFilter {
886
899
  min_priority?: number;
887
900
  has_input_memory_id?: string;
888
901
  has_output_memory_id?: string;
902
+ parent_id?: string; // filter by parent task
903
+ is_root?: boolean; // true = no parent, false = has parent
889
904
  }
890
905
 
891
906
  getTasks(state, { intent_id: "i1", statuses: ["pending", "running"] });
892
907
  getTaskById(state, "t1");
908
+ getChildTasks(state, "t1"); // all tasks with parent_id = "t1"
893
909
  getTasksByIntent(state, "i1");
894
910
  ```
895
911
 
@@ -902,11 +918,13 @@ The three graphs (memory, intent, task) reference each other by ID:
902
918
  | From | To | Field |
903
919
  |------|----|-------|
904
920
  | Intent | Memory | `Intent.root_memory_ids` |
921
+ | Intent | Intent (parent) | `Intent.parent_id` |
905
922
  | Task | Intent | `Task.intent_id` |
923
+ | Task | Task (parent) | `Task.parent_id` |
906
924
  | Task | Memory (input) | `Task.input_memory_ids` |
907
925
  | Task | Memory (output) | `Task.output_memory_ids` |
908
- | Memory | Intent | `MemoryItem.meta.creation_intent_id` |
909
- | Memory | Task | `MemoryItem.meta.creation_task_id` |
926
+ | Memory | Intent | `MemoryItem.intent_id` |
927
+ | Memory | Task | `MemoryItem.task_id` |
910
928
 
911
929
  No unified query across graphs — each graph has its own getters. The app layer composes them.
912
930
 
package/README.md CHANGED
@@ -14,7 +14,7 @@ Every chat session starts from scratch. Memory systems try to fix this by append
14
14
  - **Whether** it's still relevant (decay)
15
15
  - **Where** it came from (source attribution)
16
16
 
17
- The result: agents that can retrieve old text but can't reason about what they know.
17
+ Most systems conflate "I can retrieve it" with "I know it." Retrieval is not memory. MemEX separates recall (a tool problem) from belief state (a knowledge problem).
18
18
 
19
19
  ## What MemEX Does
20
20
 
@@ -77,7 +77,7 @@ MemEX is the structured memory layer in a larger stack. It doesn't replace your
77
77
 
78
78
  **Event store** -- the append-only command log. MemEX emits lifecycle events that get persisted. On restart, `replayFromEnvelopes` rebuilds the graph from the log.
79
79
 
80
- MemEX is the system of record. The library itself is pure TypeScript with a single runtime dependency (`uuidv7`). Storage, search, and bus integration belong in the service layer above.
80
+ MemEX is the system of record. It does not replace retrieval systems -- it governs them. Vector search and keyword search are recall tools; MemEX is the epistemic coordination layer that decides what matters, what conflicts, and what to include in context. The library itself is pure TypeScript with a single runtime dependency (`uuidv7`). Storage, search, and bus integration belong in the service layer above.
81
81
 
82
82
  ### What changes in agent behavior
83
83
 
@@ -257,6 +257,24 @@ Commands go in, lifecycle events come out of the reducer, state events are full
257
257
 
258
258
  `applyCommand` never mutates input state. It returns a new `GraphState` and an array of lifecycle events. History is in the append-only event log; `GraphState` is always the latest snapshot.
259
259
 
260
+ ## Design Philosophy
261
+
262
+ Every system encodes assumptions about truth, knowledge, and time -- whether it acknowledges them or not. MemEX makes those assumptions explicit.
263
+
264
+ | Question | Typical system | MemEX |
265
+ |----------|---------------|-------|
266
+ | What is knowledge? | Similar text (vectors) or structured facts (SQL) | Beliefs with provenance, confidence, and conflict |
267
+ | What exists? | Documents, rows | Observations, hypotheses, derivations, policies, traits |
268
+ | Is truth binary? | Yes (stored or not) | No -- graded by authority, conviction, and importance |
269
+ | Does knowledge decay? | No (or manually pruned) | Yes -- query-time decay, configurable per retrieval |
270
+ | What about contradictions? | Overwrite or ignore | Represent, carry, and optionally resolve |
271
+
272
+ Most memory systems compress and resolve -- they produce a single clean narrative. MemEX preserves and represents -- it maintains a field of competing claims that a reasoning layer can interpret. The graph is the pre-answer belief state, not the final answer.
273
+
274
+ This is a deliberate architectural choice. MemEX is not a thinking system. It is a substrate that makes thinking systems possible. Storage, search, and cognition belong above the library. MemEX provides the structured epistemic state they operate on.
275
+
276
+ Vector search tells you what is similar. MemEX tells you what you believe.
277
+
260
278
  ## Three Graphs
261
279
 
262
280
  MemEX contains three logical graphs in one package. Use what you need:
@@ -273,11 +291,17 @@ All three follow the same pattern: commands → reducer → lifecycle events. Th
273
291
  // intent links to memory items that motivated it
274
292
  const intent = createIntent({ label: "find_kati", root_memory_ids: [obs.id], ... });
275
293
 
294
+ // sub-intent decomposes a parent goal
295
+ const sub = createIntent({ label: "check_financials", parent_id: intent.id, ... });
296
+
276
297
  // task links to its parent intent and memory items it consumes/produces
277
298
  const task = createTask({ intent_id: intent.id, input_memory_ids: [obs.id], ... });
278
299
 
300
+ // subtask breaks a task into steps
301
+ const step = createTask({ intent_id: intent.id, parent_id: task.id, action: "parse_profile", ... });
302
+
279
303
  // after task completes, memory items link back
280
- createMemoryItem({ ..., meta: { creation_intent_id: intent.id, creation_task_id: task.id } });
304
+ createMemoryItem({ ..., intent_id: intent.id, task_id: task.id });
281
305
  ```
282
306
 
283
307
  ## The Loop
@@ -352,10 +376,21 @@ Transferring cognition between agents is transferring this vector. The receiving
352
376
 
353
377
  This is what `exportSlice` / `importSlice` enables at the library level. The transport layer (network, bus, file) is outside the library; MemEX provides the serializable structure.
354
378
 
379
+ ### What transplant enables
380
+
381
+ | Pattern | How it works |
382
+ |---------|-------------|
383
+ | **Safe delegation** | Export a slice to a sub-agent. It operates on its own copy. Merge results back append-only -- no risk of corrupting the main graph. |
384
+ | **Parallel reasoning** | Fork belief state into multiple slices. Run different reasoning paths independently. Compare outcomes before merging. |
385
+ | **Reproducibility** | Event logs + deterministic slices mean any state can be replayed, audited, or debugged after the fact. |
386
+ | **State mobility** | Memory is not tied to one runtime. Export, serialize, move between agents or machines, rehydrate anywhere. |
387
+
388
+ Memory is no longer a local resource. It is portable belief.
389
+
355
390
  ## Features
356
391
 
357
392
  **Memory graph:**
358
- - Full query algebra: `and`, `or`, `not`, `range`, `ids`, `scope_prefix`, `parents` (includes/count), `meta` (dot-path), `meta_has`, `created` (time range), `decay` (freshness filter)
393
+ - Full query algebra: `and`, `or`, `not`, `range`, `ids`, `scope_prefix`, `parents` (includes/count), `intent_id`, `task_id`, `meta` (dot-path), `meta_has`, `created` (time range), `decay` (freshness filter)
359
394
  - Multi-sort with tiebreakers (authority, conviction, importance, recency)
360
395
  - Configurable time decay: exponential, linear, or step -- applied at query time, not stored
361
396
  - Scored retrieval with pre/post filters, min_score threshold, and decay
@@ -374,13 +409,15 @@ This is what `exportSlice` / `importSlice` enables at the library level. The tra
374
409
 
375
410
  **Intent graph:**
376
411
  - Status machine: active ↔ paused → completed / cancelled
377
- - Query by owner, status, priority, linked memory items
412
+ - Sub-intent hierarchies via `parent_id`
413
+ - Query by owner, status, priority, parent, linked memory items
378
414
  - Invalid transitions throw typed errors
379
415
 
380
416
  **Task graph:**
381
417
  - Status machine: pending → running → completed / failed, with retry support (failed → running)
418
+ - Subtask hierarchies via `parent_id`
382
419
  - Links to parent intent, input/output memory items, agent assignment
383
- - Query by intent, action, status, agent, linked memory items
420
+ - Query by intent, action, status, agent, parent, linked memory items
384
421
 
385
422
  **Transplant (export / import):**
386
423
  - Export a self-contained slice by walking provenance chains, aliases, related intents/tasks
@@ -464,7 +501,10 @@ item.meta.session_id // "session-abc"
464
501
  item.meta.crew_id // "crew:investigation-42"
465
502
 
466
503
  // which intent spawned this?
467
- item.meta.creation_intent_id // "i1"
504
+ item.intent_id // "i1"
505
+
506
+ // which task produced this?
507
+ item.task_id // "t1"
468
508
  ```
469
509
 
470
510
  All of these are queryable via `meta` and `meta_has` filters. The graph is one shared structure; segmentation is just queries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai2070/memex",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "MemEX memory layer — graph of memories and edges over an append-only event log",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -98,6 +98,7 @@ export {
98
98
  applyIntentCommand,
99
99
  getIntents,
100
100
  getIntentById,
101
+ getChildIntents,
101
102
  IntentNotFoundError,
102
103
  DuplicateIntentError,
103
104
  InvalidIntentTransitionError,
@@ -119,6 +120,7 @@ export {
119
120
  getTasks,
120
121
  getTaskById,
121
122
  getTasksByIntent,
123
+ getChildTasks,
122
124
  TaskNotFoundError,
123
125
  DuplicateTaskError,
124
126
  InvalidTaskTransitionError,
package/src/intent.ts CHANGED
@@ -8,6 +8,7 @@ export type IntentStatus = "active" | "paused" | "completed" | "cancelled";
8
8
 
9
9
  export interface Intent {
10
10
  id: string;
11
+ parent_id?: string; // parent intent for sub-intent hierarchies
11
12
  label: string;
12
13
  description?: string;
13
14
 
@@ -268,6 +269,8 @@ export interface IntentFilter {
268
269
  statuses?: IntentStatus[];
269
270
  min_priority?: number;
270
271
  has_memory_id?: string;
272
+ parent_id?: string;
273
+ is_root?: boolean; // true = no parent, false = has parent
271
274
  }
272
275
 
273
276
  export function getIntents(
@@ -298,6 +301,13 @@ export function getIntents(
298
301
  )
299
302
  continue;
300
303
  }
304
+ if (filter.parent_id !== undefined && intent.parent_id !== filter.parent_id)
305
+ continue;
306
+ if (filter.is_root !== undefined) {
307
+ const hasParent = intent.parent_id !== undefined;
308
+ if (filter.is_root && hasParent) continue;
309
+ if (!filter.is_root && !hasParent) continue;
310
+ }
301
311
  results.push(intent);
302
312
  }
303
313
  return results;
@@ -309,3 +319,10 @@ export function getIntentById(
309
319
  ): Intent | undefined {
310
320
  return state.intents.get(id);
311
321
  }
322
+
323
+ export function getChildIntents(
324
+ state: IntentState,
325
+ parentId: string,
326
+ ): Intent[] {
327
+ return getIntents(state, { parent_id: parentId });
328
+ }
package/src/query.ts CHANGED
@@ -60,6 +60,16 @@ function matchesFilter(item: MemoryItem, filter: MemoryFilter): boolean {
60
60
  )
61
61
  return false;
62
62
 
63
+ // cross-graph links
64
+ if (filter.intent_id !== undefined && item.intent_id !== filter.intent_id)
65
+ return false;
66
+ if (filter.intent_ids !== undefined && (!item.intent_id || !filter.intent_ids.includes(item.intent_id)))
67
+ return false;
68
+ if (filter.task_id !== undefined && item.task_id !== filter.task_id)
69
+ return false;
70
+ if (filter.task_ids !== undefined && (!item.task_id || !filter.task_ids.includes(item.task_id)))
71
+ return false;
72
+
63
73
  // score ranges
64
74
  if (filter.range) {
65
75
  if (!matchesRange(item.authority, filter.range.authority)) return false;
package/src/task.ts CHANGED
@@ -14,6 +14,7 @@ export type TaskStatus =
14
14
  export interface Task {
15
15
  id: string;
16
16
  intent_id: string;
17
+ parent_id?: string; // parent task for subtask hierarchies
17
18
 
18
19
  action: string; // "search_linkedin", "summarize_case"
19
20
  label?: string;
@@ -325,6 +326,8 @@ export interface TaskFilter {
325
326
  min_priority?: number;
326
327
  has_input_memory_id?: string;
327
328
  has_output_memory_id?: string;
329
+ parent_id?: string;
330
+ is_root?: boolean; // true = no parent, false = has parent
328
331
  }
329
332
 
330
333
  export function getTasks(state: TaskState, filter?: TaskFilter): Task[] {
@@ -359,6 +362,13 @@ export function getTasks(state: TaskState, filter?: TaskFilter): Task[] {
359
362
  )
360
363
  continue;
361
364
  }
365
+ if (filter.parent_id !== undefined && task.parent_id !== filter.parent_id)
366
+ continue;
367
+ if (filter.is_root !== undefined) {
368
+ const hasParent = task.parent_id !== undefined;
369
+ if (filter.is_root && hasParent) continue;
370
+ if (!filter.is_root && !hasParent) continue;
371
+ }
362
372
  results.push(task);
363
373
  }
364
374
  return results;
@@ -371,3 +381,10 @@ export function getTaskById(state: TaskState, id: string): Task | undefined {
371
381
  export function getTasksByIntent(state: TaskState, intentId: string): Task[] {
372
382
  return getTasks(state, { intent_id: intentId });
373
383
  }
384
+
385
+ export function getChildTasks(
386
+ state: TaskState,
387
+ parentId: string,
388
+ ): Task[] {
389
+ return getTasks(state, { parent_id: parentId });
390
+ }
package/src/transplant.ts CHANGED
@@ -148,12 +148,10 @@ export function exportSlice(
148
148
  }
149
149
  }
150
150
  }
151
- // also check memory meta for creation_intent_id
151
+ // also check memory items for linked intent
152
152
  for (const mid of memoryIds) {
153
153
  const item = memState.items.get(mid);
154
- if (item?.meta?.creation_intent_id) {
155
- intentIds.add(item.meta.creation_intent_id as string);
156
- }
154
+ if (item?.intent_id) intentIds.add(item.intent_id);
157
155
  }
158
156
  }
159
157
 
@@ -172,12 +170,10 @@ export function exportSlice(
172
170
  taskIds.add(task.id);
173
171
  }
174
172
  }
175
- // also check memory meta for creation_task_id
173
+ // also check memory items for linked task
176
174
  for (const mid of memoryIds) {
177
175
  const item = memState.items.get(mid);
178
- if (item?.meta?.creation_task_id) {
179
- taskIds.add(item.meta.creation_task_id as string);
180
- }
176
+ if (item?.task_id) taskIds.add(item.task_id);
181
177
  }
182
178
  }
183
179
 
@@ -404,6 +400,9 @@ export function importSlice(
404
400
  const remapped: Intent = {
405
401
  ...intent,
406
402
  id: newId,
403
+ parent_id: intent.parent_id
404
+ ? rewriteId(intent.parent_id, intentIdMap)
405
+ : undefined,
407
406
  root_memory_ids: rewriteIds(intent.root_memory_ids, memIdMap),
408
407
  };
409
408
  const result = applyIntentCommand(currentIntent, {
@@ -423,6 +422,9 @@ export function importSlice(
423
422
  }
424
423
  const remapped: Intent = {
425
424
  ...intent,
425
+ parent_id: intent.parent_id
426
+ ? rewriteId(intent.parent_id, intentIdMap)
427
+ : undefined,
426
428
  root_memory_ids: rewriteIds(intent.root_memory_ids, memIdMap),
427
429
  };
428
430
  const result = applyIntentCommand(currentIntent, {
@@ -447,6 +449,9 @@ export function importSlice(
447
449
  ...task,
448
450
  id: newId,
449
451
  intent_id: rewriteId(task.intent_id, intentIdMap),
452
+ parent_id: task.parent_id
453
+ ? rewriteId(task.parent_id, taskIdMap)
454
+ : undefined,
450
455
  input_memory_ids: rewriteIds(task.input_memory_ids, memIdMap),
451
456
  output_memory_ids: rewriteIds(task.output_memory_ids, memIdMap),
452
457
  };
@@ -468,6 +473,9 @@ export function importSlice(
468
473
  const remapped: Task = {
469
474
  ...task,
470
475
  intent_id: rewriteId(task.intent_id, intentIdMap),
476
+ parent_id: task.parent_id
477
+ ? rewriteId(task.parent_id, taskIdMap)
478
+ : undefined,
471
479
  input_memory_ids: rewriteIds(task.input_memory_ids, memIdMap),
472
480
  output_memory_ids: rewriteIds(task.output_memory_ids, memIdMap),
473
481
  };
package/src/types.ts CHANGED
@@ -46,6 +46,9 @@ export interface MemoryItem {
46
46
  conviction?: number; // 0..1 -- how sure was the author?
47
47
  importance?: number; // 0..1 -- how much attention does this need right now? (salience)
48
48
 
49
+ intent_id?: string; // intent that produced this item
50
+ task_id?: string; // task that produced this item
51
+
49
52
  meta?: {
50
53
  agent_id?: string;
51
54
  session_id?: string;
@@ -181,6 +184,11 @@ export interface MemoryFilter {
181
184
  importance?: { min?: number; max?: number };
182
185
  };
183
186
 
187
+ intent_id?: string; // exact match on intent_id
188
+ intent_ids?: string[]; // match any of these intent_ids
189
+ task_id?: string; // exact match on task_id
190
+ task_ids?: string[]; // match any of these task_ids
191
+
184
192
  has_parent?: string; // sugar for parents.includes
185
193
  is_root?: boolean; // sugar for parents.count.max = 0
186
194
  parents?: {
@@ -621,13 +621,13 @@ describe("resolveContradiction with multiple CONTRADICTS edges", () => {
621
621
  });
622
622
 
623
623
  // =========================================================================
624
- // 10. exportSlice with include_related_intents via meta.creation_intent_id
624
+ // 10. exportSlice with include_related_intents via intent_id
625
625
  // =========================================================================
626
626
 
627
- describe("exportSlice walks meta.creation_intent_id", () => {
628
- it("includes intents referenced by memory meta", () => {
627
+ describe("exportSlice walks intent_id", () => {
628
+ it("includes intents referenced by memory intent_id", () => {
629
629
  const memState = stateWith([
630
- makeItem("m1", { meta: { creation_intent_id: "i1" } }),
630
+ makeItem("m1", { intent_id: "i1" }),
631
631
  ]);
632
632
  let intentState = createIntentState();
633
633
  const intent = createIntent({
@@ -653,13 +653,13 @@ describe("exportSlice walks meta.creation_intent_id", () => {
653
653
  });
654
654
 
655
655
  // =========================================================================
656
- // 11. exportSlice with include_related_tasks via meta.creation_task_id
656
+ // 11. exportSlice with include_related_tasks via task_id
657
657
  // =========================================================================
658
658
 
659
- describe("exportSlice walks meta.creation_task_id", () => {
660
- it("includes tasks referenced by memory meta", () => {
659
+ describe("exportSlice walks task_id", () => {
660
+ it("includes tasks referenced by memory task_id", () => {
661
661
  const memState = stateWith([
662
- makeItem("m1", { meta: { creation_task_id: "t1" } }),
662
+ makeItem("m1", { task_id: "t1" }),
663
663
  ]);
664
664
  const intentState = createIntentState();
665
665
  let taskState = createTaskState();
@@ -0,0 +1,378 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createGraphState } from "../src/graph.js";
3
+ import { applyCommand } from "../src/reducer.js";
4
+ import { getItems } from "../src/query.js";
5
+ import {
6
+ createIntentState,
7
+ createIntent,
8
+ applyIntentCommand,
9
+ getIntents,
10
+ getIntentById,
11
+ getChildIntents,
12
+ } from "../src/intent.js";
13
+ import {
14
+ createTaskState,
15
+ createTask,
16
+ applyTaskCommand,
17
+ getTasks,
18
+ getTaskById,
19
+ getChildTasks,
20
+ } from "../src/task.js";
21
+ import { exportSlice, importSlice } from "../src/transplant.js";
22
+ import type { MemoryItem, GraphState } from "../src/types.js";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function makeItem(
29
+ id: string,
30
+ overrides: Partial<MemoryItem> = {},
31
+ ): MemoryItem {
32
+ return {
33
+ id,
34
+ scope: "test",
35
+ kind: "observation",
36
+ content: { text: `item ${id}` },
37
+ author: "agent:test",
38
+ source_kind: "observed",
39
+ authority: 0.8,
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ function stateWith(items: MemoryItem[]): GraphState {
45
+ let state = createGraphState();
46
+ for (const item of items) {
47
+ state = applyCommand(state, { type: "memory.create", item }).state;
48
+ }
49
+ return state;
50
+ }
51
+
52
+ // =========================================================================
53
+ // MemoryFilter: intent_id / task_id exact match
54
+ // =========================================================================
55
+
56
+ describe("MemoryFilter intent_id and task_id", () => {
57
+ it("filters by exact intent_id", () => {
58
+ const state = stateWith([
59
+ makeItem("m1", { intent_id: "i1" }),
60
+ makeItem("m2", { intent_id: "i2" }),
61
+ makeItem("m3"),
62
+ ]);
63
+
64
+ const results = getItems(state, { intent_id: "i1" });
65
+ expect(results.map((i) => i.id)).toEqual(["m1"]);
66
+ });
67
+
68
+ it("filters by exact task_id", () => {
69
+ const state = stateWith([
70
+ makeItem("m1", { task_id: "t1" }),
71
+ makeItem("m2", { task_id: "t2" }),
72
+ makeItem("m3"),
73
+ ]);
74
+
75
+ const results = getItems(state, { task_id: "t1" });
76
+ expect(results.map((i) => i.id)).toEqual(["m1"]);
77
+ });
78
+
79
+ it("excludes items without intent_id when filtering", () => {
80
+ const state = stateWith([
81
+ makeItem("m1"),
82
+ makeItem("m2", { intent_id: "i1" }),
83
+ ]);
84
+
85
+ const results = getItems(state, { intent_id: "i1" });
86
+ expect(results.length).toBe(1);
87
+ expect(results[0].id).toBe("m2");
88
+ });
89
+ });
90
+
91
+ // =========================================================================
92
+ // MemoryFilter: intent_ids / task_ids (any-of)
93
+ // =========================================================================
94
+
95
+ describe("MemoryFilter intent_ids and task_ids", () => {
96
+ it("filters by any of intent_ids", () => {
97
+ const state = stateWith([
98
+ makeItem("m1", { intent_id: "i1" }),
99
+ makeItem("m2", { intent_id: "i2" }),
100
+ makeItem("m3", { intent_id: "i3" }),
101
+ makeItem("m4"),
102
+ ]);
103
+
104
+ const results = getItems(state, { intent_ids: ["i1", "i3"] });
105
+ expect(results.map((i) => i.id).sort()).toEqual(["m1", "m3"]);
106
+ });
107
+
108
+ it("filters by any of task_ids", () => {
109
+ const state = stateWith([
110
+ makeItem("m1", { task_id: "t1" }),
111
+ makeItem("m2", { task_id: "t2" }),
112
+ makeItem("m3", { task_id: "t3" }),
113
+ ]);
114
+
115
+ const results = getItems(state, { task_ids: ["t2", "t3"] });
116
+ expect(results.map((i) => i.id).sort()).toEqual(["m2", "m3"]);
117
+ });
118
+
119
+ it("excludes items without task_id when using task_ids filter", () => {
120
+ const state = stateWith([
121
+ makeItem("m1"),
122
+ makeItem("m2", { task_id: "t1" }),
123
+ ]);
124
+
125
+ const results = getItems(state, { task_ids: ["t1"] });
126
+ expect(results.length).toBe(1);
127
+ expect(results[0].id).toBe("m2");
128
+ });
129
+
130
+ it("combines intent_id and task_id filters (AND)", () => {
131
+ const state = stateWith([
132
+ makeItem("m1", { intent_id: "i1", task_id: "t1" }),
133
+ makeItem("m2", { intent_id: "i1", task_id: "t2" }),
134
+ makeItem("m3", { intent_id: "i2", task_id: "t1" }),
135
+ ]);
136
+
137
+ const results = getItems(state, { intent_id: "i1", task_id: "t1" });
138
+ expect(results.map((i) => i.id)).toEqual(["m1"]);
139
+ });
140
+ });
141
+
142
+ // =========================================================================
143
+ // IntentFilter: parent_id / is_root
144
+ // =========================================================================
145
+
146
+ describe("IntentFilter parent_id and is_root", () => {
147
+ function setupIntents() {
148
+ let state = createIntentState();
149
+ const root = createIntent({
150
+ id: "i1",
151
+ label: "investigate",
152
+ priority: 0.9,
153
+ owner: "user:laz",
154
+ });
155
+ const child1 = createIntent({
156
+ id: "i2",
157
+ parent_id: "i1",
158
+ label: "find associates",
159
+ priority: 0.7,
160
+ owner: "user:laz",
161
+ });
162
+ const child2 = createIntent({
163
+ id: "i3",
164
+ parent_id: "i1",
165
+ label: "map finances",
166
+ priority: 0.8,
167
+ owner: "user:laz",
168
+ });
169
+ state = applyIntentCommand(state, { type: "intent.create", intent: root }).state;
170
+ state = applyIntentCommand(state, { type: "intent.create", intent: child1 }).state;
171
+ state = applyIntentCommand(state, { type: "intent.create", intent: child2 }).state;
172
+ return state;
173
+ }
174
+
175
+ it("filters by parent_id", () => {
176
+ const state = setupIntents();
177
+ const children = getIntents(state, { parent_id: "i1" });
178
+ expect(children.map((i) => i.id).sort()).toEqual(["i2", "i3"]);
179
+ });
180
+
181
+ it("is_root: true returns only root intents", () => {
182
+ const state = setupIntents();
183
+ const roots = getIntents(state, { is_root: true });
184
+ expect(roots.map((i) => i.id)).toEqual(["i1"]);
185
+ });
186
+
187
+ it("is_root: false returns only child intents", () => {
188
+ const state = setupIntents();
189
+ const children = getIntents(state, { is_root: false });
190
+ expect(children.map((i) => i.id).sort()).toEqual(["i2", "i3"]);
191
+ });
192
+
193
+ it("getChildIntents returns children of a parent", () => {
194
+ const state = setupIntents();
195
+ const children = getChildIntents(state, "i1");
196
+ expect(children.map((i) => i.id).sort()).toEqual(["i2", "i3"]);
197
+ });
198
+
199
+ it("getChildIntents returns empty for leaf intents", () => {
200
+ const state = setupIntents();
201
+ const children = getChildIntents(state, "i2");
202
+ expect(children).toEqual([]);
203
+ });
204
+ });
205
+
206
+ // =========================================================================
207
+ // TaskFilter: parent_id / is_root
208
+ // =========================================================================
209
+
210
+ describe("TaskFilter parent_id and is_root", () => {
211
+ function setupTasks() {
212
+ let state = createTaskState();
213
+ const root = createTask({
214
+ id: "t1",
215
+ intent_id: "i1",
216
+ action: "search",
217
+ priority: 0.9,
218
+ });
219
+ const sub1 = createTask({
220
+ id: "t2",
221
+ intent_id: "i1",
222
+ parent_id: "t1",
223
+ action: "parse_profile",
224
+ priority: 0.7,
225
+ });
226
+ const sub2 = createTask({
227
+ id: "t3",
228
+ intent_id: "i1",
229
+ parent_id: "t1",
230
+ action: "extract_contacts",
231
+ priority: 0.6,
232
+ });
233
+ state = applyTaskCommand(state, { type: "task.create", task: root }).state;
234
+ state = applyTaskCommand(state, { type: "task.create", task: sub1 }).state;
235
+ state = applyTaskCommand(state, { type: "task.create", task: sub2 }).state;
236
+ return state;
237
+ }
238
+
239
+ it("filters by parent_id", () => {
240
+ const state = setupTasks();
241
+ const subs = getTasks(state, { parent_id: "t1" });
242
+ expect(subs.map((t) => t.id).sort()).toEqual(["t2", "t3"]);
243
+ });
244
+
245
+ it("is_root: true returns only root tasks", () => {
246
+ const state = setupTasks();
247
+ const roots = getTasks(state, { is_root: true });
248
+ expect(roots.map((t) => t.id)).toEqual(["t1"]);
249
+ });
250
+
251
+ it("is_root: false returns only subtasks", () => {
252
+ const state = setupTasks();
253
+ const subs = getTasks(state, { is_root: false });
254
+ expect(subs.map((t) => t.id).sort()).toEqual(["t2", "t3"]);
255
+ });
256
+
257
+ it("getChildTasks returns children of a parent", () => {
258
+ const state = setupTasks();
259
+ const subs = getChildTasks(state, "t1");
260
+ expect(subs.map((t) => t.id).sort()).toEqual(["t2", "t3"]);
261
+ });
262
+
263
+ it("getChildTasks returns empty for leaf tasks", () => {
264
+ const state = setupTasks();
265
+ const subs = getChildTasks(state, "t3");
266
+ expect(subs).toEqual([]);
267
+ });
268
+ });
269
+
270
+ // =========================================================================
271
+ // Transplant: parent_id rewriting
272
+ // =========================================================================
273
+
274
+ describe("transplant rewrites parent_id", () => {
275
+ it("rewrites intent parent_id on re-id", () => {
276
+ const memState = createGraphState();
277
+ let intentState = createIntentState();
278
+ const taskState = createTaskState();
279
+
280
+ // create parent intent in destination
281
+ const existing = createIntent({
282
+ id: "i1",
283
+ label: "existing",
284
+ priority: 0.5,
285
+ owner: "user:laz",
286
+ });
287
+ intentState = applyIntentCommand(intentState, {
288
+ type: "intent.create",
289
+ intent: existing,
290
+ }).state;
291
+
292
+ // slice has same id but different data, plus a child
293
+ const sliceParent = createIntent({
294
+ id: "i1",
295
+ label: "different",
296
+ priority: 0.9,
297
+ owner: "user:laz",
298
+ });
299
+ const sliceChild = createIntent({
300
+ id: "i2",
301
+ parent_id: "i1",
302
+ label: "child",
303
+ priority: 0.7,
304
+ owner: "user:laz",
305
+ });
306
+
307
+ const result = importSlice(memState, intentState, taskState, {
308
+ memories: [],
309
+ edges: [],
310
+ intents: [sliceParent, sliceChild],
311
+ tasks: [],
312
+ }, {
313
+ skipExistingIds: true,
314
+ shallowCompareExisting: true,
315
+ reIdOnDifference: true,
316
+ });
317
+
318
+ // parent got re-id'd
319
+ const newParentId = result.report.created.intents[0];
320
+ expect(newParentId).not.toBe("i1");
321
+
322
+ // child should reference the new parent id
323
+ const child = result.intentState.intents.get("i2")!;
324
+ expect(child.parent_id).toBe(newParentId);
325
+ });
326
+
327
+ it("rewrites task parent_id on re-id", () => {
328
+ const memState = createGraphState();
329
+ const intentState = createIntentState();
330
+ let taskState = createTaskState();
331
+
332
+ // create parent task in destination
333
+ const existing = createTask({
334
+ id: "t1",
335
+ intent_id: "i1",
336
+ action: "search",
337
+ priority: 0.5,
338
+ });
339
+ taskState = applyTaskCommand(taskState, {
340
+ type: "task.create",
341
+ task: existing,
342
+ }).state;
343
+
344
+ // slice has same id but different data, plus a child
345
+ const sliceParent = createTask({
346
+ id: "t1",
347
+ intent_id: "i1",
348
+ action: "different_search",
349
+ priority: 0.9,
350
+ });
351
+ const sliceChild = createTask({
352
+ id: "t2",
353
+ intent_id: "i1",
354
+ parent_id: "t1",
355
+ action: "parse",
356
+ priority: 0.7,
357
+ });
358
+
359
+ const result = importSlice(memState, intentState, taskState, {
360
+ memories: [],
361
+ edges: [],
362
+ intents: [],
363
+ tasks: [sliceParent, sliceChild],
364
+ }, {
365
+ skipExistingIds: true,
366
+ shallowCompareExisting: true,
367
+ reIdOnDifference: true,
368
+ });
369
+
370
+ // parent got re-id'd
371
+ const newParentId = result.report.created.tasks[0];
372
+ expect(newParentId).not.toBe("t1");
373
+
374
+ // child should reference the new parent id
375
+ const child = result.taskState.tasks.get("t2")!;
376
+ expect(child.parent_id).toBe(newParentId);
377
+ });
378
+ });