@oortmem/core 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/recall.d.ts CHANGED
@@ -1,10 +1,25 @@
1
1
  import type { Embedder, MemoryStore, Reranker, ScopeFilter } from './adapters.js';
2
2
  import type { ResolvedConfig } from './config.js';
3
+ import { type Grant, type Home } from './grant.js';
4
+ import { type IncludeLevel, type MemoryOrigin } from './levels.js';
3
5
  import { type Memory } from './model.js';
4
6
  import { type RecallMode } from './temporal.js';
5
7
  export interface RecallQuery {
6
8
  text: string;
7
- scope: ScopeFilter;
9
+ /**
10
+ * Who recalls, as a pre-level filter: read with `localGrant(scope, { home })`, the person's
11
+ * Planet plus this agent's own Star in these projects. Required unless `grant` is given.
12
+ */
13
+ scope?: ScopeFilter;
14
+ /** The grant to read with, instead of one built from `scope` (multi-node grants, tests). */
15
+ grant?: Grant;
16
+ /** With `scope`: where this agent's memories land (`star` for an agent configured as a Star). */
17
+ home?: Home;
18
+ /**
19
+ * Which levels to read (spec 7.1), intersected with the grant. A level the grant does not cover
20
+ * rejects with `ScopeDeniedError`. Default: every node the grant reads.
21
+ */
22
+ include?: IncludeLevel[];
8
23
  budgetChars?: number;
9
24
  /** ISO timestamp: recall what was true then (valid time). Implies `mode: 'as-of'`. */
10
25
  asOf?: string;
@@ -44,6 +59,8 @@ export interface RecalledMemory extends Memory {
44
59
  /** A volatile fact nobody confirmed within `recall.staleAfterDays`. */
45
60
  stale?: true;
46
61
  };
62
+ /** Where it came from: level, node, author, and whether a narrower memory overrides it. */
63
+ origin?: MemoryOrigin;
47
64
  }
48
65
  export type Degradation = 'no-embedder' | 'no-reranker' | 'embed-failed' | 'rerank-failed'
49
66
  /** The reranker returned no ids: the fused order stands. */
@@ -75,6 +92,8 @@ export interface RecallResult {
75
92
  embedSkipped?: 'lexical-confident';
76
93
  /** The temporal view this recall read. Always set by `recall`. */
77
94
  temporal?: RecallTemporal;
95
+ /** The nodes this recall read, as labels show them (`your Planet`, `Star cursor`). */
96
+ read?: string[];
78
97
  timings: Record<string, number>;
79
98
  }
80
99
  export interface RecallDeps {
@@ -107,3 +126,10 @@ export declare function fitForInjection<M extends RecalledMemory>(memories: M[],
107
126
  kept: M[];
108
127
  lines: string[];
109
128
  };
129
+ /**
130
+ * Memory text with any `<oort-…` or `</oort-…` tag opening written as `&lt;`, so a memory cannot
131
+ * close the `<oort-memory>` block (or open another) and have what follows read as the prompt.
132
+ */
133
+ export declare function escapeBlockTags(text: string): string;
134
+ /** The share of an injection budget hive memories may take (spec 7.2). */
135
+ export declare const HIVE_QUOTA = 0.4;
package/dist/recall.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { activation } from './activation.js';
2
2
  import { fuse, isVisible } from './fusion.js';
3
- import { localGrant } from './grant.js';
3
+ import { addressOf, nodeKey, nodeOf } from './address.js';
4
+ import { assertGrant, grantWrites, localGrant, narrowGrant, } from './grant.js';
5
+ import { contradicted, escapeLabelText, nodeLabel, nodesToRead, originOf, specificity, specificityFactor, } from './levels.js';
4
6
  import { scrub } from './scrub.js';
5
7
  import { isCurrent } from './model.js';
6
8
  import { temporalIntent } from './temporal.js';
@@ -17,8 +19,14 @@ const clock = () => performance.now();
17
19
  */
18
20
  export async function recall(q, deps) {
19
21
  const { store, config } = deps;
20
- // The store enforces scope; recall reads and hydrates only inside q.scope.
21
- const grant = localGrant(q.scope);
22
+ const full = grantOf(q);
23
+ // The nodes to read: the grant intersected with `include` (a level it lacks rejects here,
24
+ // before anything is read). Recall reads and hydrates only inside them; the store enforces it.
25
+ const nodes = nodesToRead(full, q.include);
26
+ const grant = narrowGrant(full, nodes);
27
+ // First stage per node (spec 7.2), then nodes merged by fused score. One node: today's recall.
28
+ const legs = nodes.map((n) => ({ node: n, grant: narrowGrant(full, [n]) }));
29
+ const epsilon = config.recall.specificityPrior;
22
30
  const now = deps.now ?? new Date();
23
31
  // Scrub once: the query text reaches embedders and rerankers, which may be remote. Temporal
24
32
  // words ("on 2 April", "changed over time") pick the view and are not searched for.
@@ -54,14 +62,14 @@ export async function recall(q, deps) {
54
62
  prefetched.set(id, m);
55
63
  return m;
56
64
  };
57
- const lexicalP = timed('lexicalMs', () => store.lexical(grant, text, config.recall.lexicalK));
65
+ const lexicalP = timed('lexicalMs', () => Promise.all(legs.map((l) => store.lexical(l.grant, text, config.recall.lexicalK))));
58
66
  // A failed embed must not leave the lexical promise unobserved.
59
67
  lexicalP.catch(() => { });
60
68
  // Lexical first (CON-57): when the lexical answer is already clear, skip the embedding round
61
69
  // trip. Otherwise the lexical search has run while nothing else waited on it.
62
70
  let embedSkipped;
63
71
  if (deps.embed && config.recall.lexicalFirst) {
64
- const first = await lexicalP;
72
+ const first = byRank(await lexicalP);
65
73
  if (await lexicalIsConfident(first, queryTokens, read, now, view)) {
66
74
  embedSkipped = 'lexical-confident';
67
75
  }
@@ -83,15 +91,24 @@ export async function recall(q, deps) {
83
91
  timings['embedMs'] = clock() - t;
84
92
  }
85
93
  const v = vector;
86
- const [lexical, dense] = await Promise.all([
94
+ let denseFailed = false;
95
+ const [lexicalLegs, denseLegs] = await Promise.all([
87
96
  lexicalP,
88
97
  v === undefined
89
- ? Promise.resolve([])
90
- : timed('denseMs', () => store.dense(grant, v, config.recall.denseK).catch(() => {
91
- degraded.push('dense-failed');
98
+ ? Promise.resolve(legs.map(() => []))
99
+ : timed('denseMs', () => Promise.all(legs.map((l) => store.dense(l.grant, v, config.recall.denseK).catch(() => {
100
+ denseFailed = true;
92
101
  return [];
93
- })),
102
+ })))),
94
103
  ]);
104
+ if (denseFailed)
105
+ degraded.push('dense-failed');
106
+ // Across nodes, by rank (one node: its own list). Used where one list is needed.
107
+ const lexical = byRank(lexicalLegs);
108
+ const dense = byRank(denseLegs);
109
+ // Ranks are per node: each node's first stage ranks its own memories.
110
+ const lexicalRank = rankWithin(lexicalLegs);
111
+ const denseRank = rankWithin(denseLegs);
95
112
  // Live rows only: a cold row keeps its text, stub and entities, which is all fusion, the gate
96
113
  // and the reranker read. Hydration waits until we know what is returned (CON-47).
97
114
  const ids = [...new Set([...lexical, ...dense].map((h) => h.id))];
@@ -101,7 +118,14 @@ export async function recall(q, deps) {
101
118
  if (m !== null)
102
119
  memories.set(m.id, m);
103
120
  const tf = clock();
104
- const fused = fuse([lexical, dense], memories, now, { activation: config.activation, view });
121
+ // Reciprocal rank fusion within each node as before, then the nodes merged by fused score,
122
+ // each scaled by the specificity prior (off at ε = 0). A stable sort: one node keeps its order.
123
+ const fused = legs
124
+ .flatMap((l, i) => fuse([lexicalLegs[i], denseLegs[i]], memories, now, {
125
+ activation: config.activation,
126
+ view,
127
+ }).map((f) => epsilon === 0 ? f : { ...f, score: f.score * specificityFactor(l.node, epsilon) }))
128
+ .sort((x, y) => y.score - x.score);
105
129
  timings['fuseMs'] = clock() - tf;
106
130
  const coverage = new Map();
107
131
  for (const f of fused) {
@@ -168,13 +192,13 @@ export async function recall(q, deps) {
168
192
  };
169
193
  const kept = candidates.filter((f) => relevant(f.id));
170
194
  const dropped = candidates.length - kept.length;
171
- const out = [];
195
+ let out = [];
172
196
  for (const f of kept) {
173
197
  const m = memories.get(f.id);
174
198
  if (m === undefined)
175
199
  continue;
176
- const lr = lexical.findIndex((h) => h.id === f.id);
177
- const dr = dense.findIndex((h) => h.id === f.id);
200
+ const lr = lexicalRank.get(f.id) ?? -1;
201
+ const dr = denseRank.get(f.id) ?? -1;
178
202
  const rs = rerankScores.get(f.id);
179
203
  const ds = denseScore.get(f.id);
180
204
  out.push({
@@ -190,9 +214,13 @@ export async function recall(q, deps) {
190
214
  activation: activation(m.accessLog, m.salience, now, config.activation),
191
215
  hydrated: false,
192
216
  },
217
+ origin: originOf(m, full),
193
218
  });
194
219
  }
195
- let result = view.mode === 'history' ? await withChains(out, read, now, view, queryTokens, config) : out;
220
+ out = withConflicts(out);
221
+ let result = view.mode === 'history'
222
+ ? await withChains(out, full, read, now, view, queryTokens, config)
223
+ : out;
196
224
  for (const m of result) {
197
225
  if (isCurrent(m, now) && isStale(m, now, config.recall.staleAfterDays)) {
198
226
  m.explanation.stale = true;
@@ -211,6 +239,10 @@ export async function recall(q, deps) {
211
239
  result = await Promise.all(result.map(async (m) => {
212
240
  if (!touched.has(m.id) || m.tier !== 'cold' || !isCurrent(m, now))
213
241
  return m;
242
+ // Hydrating writes the memory back; a node this caller only reads (the Planet, for a Star
243
+ // agent) is served from its live row, cold and lexical-only, and left as it is.
244
+ if (!grantWrites(grant, m))
245
+ return m;
214
246
  try {
215
247
  const blob = await store.coldGet(grant, m.id);
216
248
  // A lost or torn blob: the memory stays cold, lexical-only.
@@ -264,9 +296,87 @@ export async function recall(q, deps) {
264
296
  ...(rerankSkipped !== undefined ? { rerankSkipped } : {}),
265
297
  ...(embedSkipped !== undefined ? { embedSkipped } : {}),
266
298
  temporal,
299
+ read: nodes.map((n) => nodeLabel(n, full)),
267
300
  timings,
268
301
  };
269
302
  }
303
+ /** The grant a query reads with: its own, or the local grant for its scope and home. */
304
+ function grantOf(q) {
305
+ if (q.grant !== undefined) {
306
+ assertGrant(q.grant);
307
+ return q.grant;
308
+ }
309
+ if (q.scope === undefined)
310
+ throw new TypeError('recall needs a scope or a grant');
311
+ return localGrant(q.scope, q.home === undefined ? {} : { home: q.home });
312
+ }
313
+ /**
314
+ * Per-node hit lists as one list, by rank: every node's first hit, then every node's second, in
315
+ * node order. With one non-empty node it is exactly that node's list.
316
+ */
317
+ function byRank(lists) {
318
+ if (lists.length === 1)
319
+ return lists[0];
320
+ const out = [];
321
+ const seen = new Set();
322
+ const longest = Math.max(0, ...lists.map((l) => l.length));
323
+ for (let r = 0; r < longest; r++) {
324
+ for (const l of lists) {
325
+ const h = l[r];
326
+ if (h !== undefined && !seen.has(h.id)) {
327
+ seen.add(h.id);
328
+ out.push(h);
329
+ }
330
+ }
331
+ }
332
+ return out;
333
+ }
334
+ /** Each id's 0-based rank within its own node's list. */
335
+ function rankWithin(lists) {
336
+ const out = new Map();
337
+ for (const l of lists) {
338
+ l.forEach((h, i) => {
339
+ if (!out.has(h.id))
340
+ out.set(h.id, i);
341
+ });
342
+ }
343
+ return out;
344
+ }
345
+ const nodeOfMemory = (m) => nodeOf(addressOf(m));
346
+ /**
347
+ * The conflict rule (spec 7.2), always on: when a returned memory contradicts a returned memory in
348
+ * another node (recorded by observe as a `level`/`contradicts` decision), the narrower of the two
349
+ * ranks first and the wider one stays, its origin naming what overrides it. Equally specific: the
350
+ * memory that recorded the contradiction wins.
351
+ */
352
+ function withConflicts(out) {
353
+ const byId = new Map(out.map((m) => [m.id, m]));
354
+ const list = [...out];
355
+ for (const m of out) {
356
+ for (const otherId of contradicted(m)) {
357
+ const other = byId.get(otherId);
358
+ if (other === undefined || other === m)
359
+ continue;
360
+ const [a, b] = [nodeOfMemory(m), nodeOfMemory(other)];
361
+ if (nodeKey(a) === nodeKey(b))
362
+ continue;
363
+ const [winner, loser] = specificity(b) > specificity(a) ? [other, m] : [m, other];
364
+ const iw = list.indexOf(winner);
365
+ const il = list.indexOf(loser);
366
+ if (iw > il) {
367
+ list.splice(iw, 1);
368
+ list.splice(il, 0, winner);
369
+ }
370
+ if (loser.origin !== undefined && winner.origin !== undefined) {
371
+ loser.origin = {
372
+ ...loser.origin,
373
+ overriddenBy: { id: winner.id, node: winner.origin.node },
374
+ };
375
+ }
376
+ }
377
+ }
378
+ return list;
379
+ }
270
380
  /** The view a query asks for: explicit options first, else read from the query in code. */
271
381
  function viewFor(q, text, now) {
272
382
  const at = (iso) => iso ?? now.toISOString();
@@ -305,7 +415,7 @@ const CHAIN_MAX = 8;
305
415
  * first-ranked chain first. Corrected versions are skipped (they were never true) but walked
306
416
  * through. Versions only the walk found are marked `chain`.
307
417
  */
308
- async function withChains(out, read, now, view, queryTokens, config) {
418
+ async function withChains(out, grantFor, read, now, view, queryTokens, config) {
309
419
  const byId = new Map(out.map((m) => [m.id, m]));
310
420
  const seen = new Set();
311
421
  const result = [];
@@ -341,6 +451,7 @@ async function withChains(out, read, now, view, queryTokens, config) {
341
451
  hydrated: false,
342
452
  chain: true,
343
453
  },
454
+ origin: originOf(m, grantFor),
344
455
  });
345
456
  }
346
457
  }
@@ -453,20 +564,55 @@ const INJECT_CLOSE = '\n</oort-memory>';
453
564
  */
454
565
  export function fitForInjection(memories, budgetChars, now = new Date()) {
455
566
  let total = INJECT_OPEN.length + INJECT_CLOSE.length;
567
+ let hive = 0;
456
568
  const lines = [];
457
569
  const kept = [];
570
+ // Levels are named only when something comes from outside the reader's home node: a recall of
571
+ // one node reads exactly as before levels existed.
572
+ const labelled = memories.some((m) => m.origin !== undefined && !m.origin.home);
458
573
  for (const m of memories) {
459
574
  const note = m.explanation.stale === true
460
575
  ? ` (may be outdated; last confirmed ${age(lastConfirmed(m), now)})`
461
576
  : '';
462
- const line = `- [${m.type}, ${when(m, now)}] ${scrub(m.text).text}${note}`;
577
+ const text = scrub(escapeBlockTags(escapeLabelText(m.text))).text;
578
+ const line = `- [${labelFor(m, labelled, now)}] ${text}${note}`;
463
579
  const cost = line.length + (lines.length > 0 ? 1 : 0);
580
+ // Hive memories (Constellation, Galaxy) take at most HIVE_QUOTA of the budget, so a large team
581
+ // cannot crowd out the person's own memory: one past it is skipped, not the end of the block.
582
+ const fromHive = m.origin?.level === 'hive';
583
+ if (fromHive && hive + cost > HIVE_QUOTA * budgetChars)
584
+ continue;
464
585
  if (total + cost > budgetChars)
465
586
  break;
466
587
  lines.push(line);
467
588
  kept.push(m);
468
589
  total += cost;
590
+ if (fromHive)
591
+ hive += cost;
469
592
  }
470
593
  return { kept, lines };
471
594
  }
595
+ /**
596
+ * Memory text with any `<oort-…` or `</oort-…` tag opening written as `&lt;`, so a memory cannot
597
+ * close the `<oort-memory>` block (or open another) and have what follows read as the prompt.
598
+ */
599
+ export function escapeBlockTags(text) {
600
+ return text.replace(/<(?=\s*\/?\s*oort-)/gi, '&lt;');
601
+ }
602
+ /** The share of an injection budget hive memories may take (spec 7.2). */
603
+ export const HIVE_QUOTA = 0.4;
604
+ /**
605
+ * A memory's label, built by code from structured fields only: `semantic, 2d ago`, or with levels
606
+ * named `semantic, Star cursor, 5h ago`, and `, overridden by Star cursor` when a narrower memory
607
+ * in the result contradicts it. Memory text never reaches it (and its brackets are escaped).
608
+ */
609
+ function labelFor(m, labelled, now) {
610
+ const parts = [m.type];
611
+ if (labelled && m.origin !== undefined)
612
+ parts.push(m.origin.node);
613
+ parts.push(when(m, now));
614
+ if (m.origin?.overriddenBy !== undefined)
615
+ parts.push(`overridden by ${m.origin.overriddenBy.node}`);
616
+ return parts.join(', ');
617
+ }
472
618
  //# sourceMappingURL=recall.js.map
@@ -388,12 +388,14 @@ export function storeConformance(name, make) {
388
388
  const others = {
389
389
  'other-org': { ...localScope(), orgId: 'another-org' },
390
390
  'other-customer': { ...localScope(), customerId: 'someone-else' },
391
- 'other-agent': { ...localScope(), agentId: 'another-agent' },
392
391
  };
393
392
  await store.upsert(TG, makeMemory({ id: 'mine', text, embedding }));
394
393
  for (const [id, scope] of Object.entries(others)) {
395
394
  await store.upsert(grantFor(scope), makeMemory({ id, text, embedding, scope }));
396
395
  }
396
+ // Another agent's Star is private to it (CON-62).
397
+ const star = { level: 'agent', orgId: 'local', agentId: 'another-agent' };
398
+ await store.upsert(nodesGrant(star), memoryAt('other-agent', star, { agentId: 'another-agent' }, { text, embedding }));
397
399
  expect(ids(await store.lexical(localGrant(LOCAL), 'auth-service-8080', 10))).toEqual([
398
400
  'mine',
399
401
  ]);
@@ -584,11 +586,31 @@ export function storeConformance(name, make) {
584
586
  expect(await nonInterference(make, seed, 60, { multiNode: true })).toBeNull();
585
587
  }
586
588
  }, 120_000);
589
+ it(`isolation.non-interference-peer-agents-${seeds}-seeds`, async () => {
590
+ // CON-62: another agent of the same person, working in its own Star, never reaches mine.
591
+ for (let seed = 1; seed <= seeds; seed++) {
592
+ expect(await nonInterference(make, seed, 60, { peers: true })).toBeNull();
593
+ }
594
+ }, 120_000);
587
595
  });
588
596
  levelConformance(name, make);
589
597
  }
590
598
  /** The owner every level case belongs to or reads as: the local principal. */
591
599
  const OWNER = { orgId: 'local', customerId: 'self', agentId: 'claude-code' };
600
+ /**
601
+ * The grant every local caller had before CON-62: the owner's Planet, restricted to memories its
602
+ * own agent wrote. Kept to prove data written before levels reads back unchanged.
603
+ */
604
+ const PRE_LEVEL = nodeGrant({
605
+ principal: OWNER,
606
+ read: [
607
+ {
608
+ node: { level: 'user', orgId: 'local', customerId: 'self' },
609
+ projectIds: 'all',
610
+ authorAgentId: 'claude-code',
611
+ },
612
+ ],
613
+ });
592
614
  const PROVENANCE = {
593
615
  derivedFrom: [{ vault: 'acct:1', memoryId: 'src-1' }],
594
616
  contributors: [
@@ -727,14 +749,38 @@ function levelConformance(name, make) {
727
749
  it('levels.pre-level-grants-see-no-other-level', async () => {
728
750
  for (const c of cases())
729
751
  await store.upsert(c.g, c.m);
730
- const legacy = [TG, localGrant({ ...OWNER, projectIds: ['p1', null] })];
731
- for (const g of legacy) {
732
- for (const c of cases())
733
- expect(await store.get(g, c.m.id), c.name).toBeNull();
734
- expect(await store.list(g, { validOnly: false })).toEqual([]);
735
- expect(await store.lexical(g, 'levelprobe', 50)).toEqual([]);
736
- expect(await store.dense(g, vec(1, 1, -1, 2), 50)).toEqual([]);
752
+ for (const c of cases())
753
+ expect(await store.get(PRE_LEVEL, c.m.id), c.name).toBeNull();
754
+ expect(await store.list(PRE_LEVEL, { validOnly: false })).toEqual([]);
755
+ expect(await store.lexical(PRE_LEVEL, 'levelprobe', 50)).toEqual([]);
756
+ expect(await store.dense(PRE_LEVEL, vec(1, 1, -1, 2), 50)).toEqual([]);
757
+ });
758
+ it('levels.local-grants-see-the-planet-and-their-own-star', async () => {
759
+ // CON-62: a person's agents share the Planet; each agent's Star is its own.
760
+ for (const c of cases())
761
+ await store.upsert(c.g, c.m);
762
+ const ccStar = { level: 'agent', orgId: 'local', agentId: 'claude-code' };
763
+ await store.upsert(nodesGrant(ccStar), memoryAt('cc-star', ccStar, { agentId: 'claude-code' }, { text: 'levelprobe cc star' }));
764
+ const asClaude = [TG, localGrant({ ...OWNER, projectIds: ['p1', null] })];
765
+ for (const g of asClaude) {
766
+ expect(ids(await store.list(g, { validOnly: false })).sort()).toEqual([
767
+ 'cc-star',
768
+ 'lv-planet-other-author',
769
+ ]);
770
+ expect(ids(await store.lexical(g, 'levelprobe', 50)).sort()).toEqual([
771
+ 'cc-star',
772
+ 'lv-planet-other-author',
773
+ ]);
774
+ expect(await store.get(g, 'lv-star')).toBeNull();
775
+ expect(await store.setTier(g, 'lv-star', 'hot')).toBe(false);
737
776
  }
777
+ const asCursor = localGrant({ ...OWNER, agentId: 'cursor', projectIds: ['p1', null] });
778
+ expect(ids(await store.list(asCursor, { validOnly: false })).sort()).toEqual([
779
+ 'lv-planet-other-author',
780
+ 'lv-star',
781
+ ]);
782
+ expect(await store.get(asCursor, 'cc-star')).toBeNull();
783
+ expect(ids(await store.dense(asCursor, vec(2, 1, -1, 2), 50))).not.toContain('cc-star');
738
784
  });
739
785
  it('levels.existing-data-unchanged', async () => {
740
786
  const legacy = [
@@ -750,15 +796,16 @@ function levelConformance(name, make) {
750
796
  ];
751
797
  await store.upsertMany(TG, legacy);
752
798
  await seedCold(store, makeMemory({ id: 'old-4', text: 'levelprobe cold', embedding: vec(3, 1, -1, 2) }));
799
+ const g = PRE_LEVEL;
753
800
  const view = async () => {
754
801
  const out = [];
755
802
  for (const id of ['old-1', 'old-2', 'old-3', 'old-4']) {
756
- out.push(render(await store.get(TG, id)), render(await store.coldGet(TG, id)));
803
+ out.push(render(await store.get(g, id)), render(await store.coldGet(g, id)));
757
804
  }
758
- out.push((await store.list(TG, { validOnly: false })).map(render).join('|'));
759
- out.push(JSON.stringify(await store.dense(TG, vec(1, 1, -1, 2), 10)));
805
+ out.push((await store.list(g, { validOnly: false })).map(render).join('|'));
806
+ out.push(JSON.stringify(await store.dense(g, vec(1, 1, -1, 2), 10)));
760
807
  // bm25 scores use table-wide statistics: compare which memories match.
761
- out.push(ids(await store.lexical(TG, 'levelprobe orbit', 50))
808
+ out.push(ids(await store.lexical(g, 'levelprobe orbit', 50))
762
809
  .sort()
763
810
  .join(','));
764
811
  return out;
@@ -846,7 +893,12 @@ function levelConformance(name, make) {
846
893
  'by-cc',
847
894
  'by-cursor',
848
895
  ]);
849
- expect(ids(await store.lexical(TG, 'authorprobe', 10))).toEqual(['by-cc']);
896
+ expect(ids(await store.lexical(PRE_LEVEL, 'authorprobe', 10))).toEqual(['by-cc']);
897
+ // The local grant shares the Planet among the person's agents (CON-62).
898
+ expect(ids(await store.lexical(TG, 'authorprobe', 10)).sort()).toEqual([
899
+ 'by-cc',
900
+ 'by-cursor',
901
+ ]);
850
902
  const cursor = nodeGrant({
851
903
  principal: { ...OWNER, agentId: 'cursor' },
852
904
  read: [{ node: planet, projectIds: 'all', authorAgentId: 'cursor' }],
@@ -869,7 +921,8 @@ function levelConformance(name, make) {
869
921
  read: [...TG.readNodes, { node: nodeOf(hive.address), projectIds: ['p1'] }],
870
922
  write: TG.writeNodes,
871
923
  });
872
- const visible = ['own', hive.m.id].sort();
924
+ const other = all.find((c) => c.name === 'planet-other-author');
925
+ const visible = ['own', other.m.id, hive.m.id].sort();
873
926
  expect(ids(await store.list(g, { validOnly: false })).sort()).toEqual(visible);
874
927
  expect(ids(await store.lexical(g, 'levelprobe', 50)).sort()).toEqual(visible);
875
928
  const dense = await store.dense(g, vec(9, 1, -1, 2), 50);
@@ -885,8 +938,8 @@ function levelConformance(name, make) {
885
938
  expect(await store.get(hive.g, hive.m.id)).not.toBeNull();
886
939
  await expect(store.upsert(g, memoryAt('new-hive', hive.address, hive.author))).rejects.toThrow(GrantError);
887
940
  // A project outside the pattern stays out.
888
- const other = memoryAt('hive-p2', { ...hive.address, projectId: 'p2' }, hive.author);
889
- await store.upsert(hive.g, other);
941
+ const hiveP2 = memoryAt('hive-p2', { ...hive.address, projectId: 'p2' }, hive.author);
942
+ await store.upsert(hive.g, hiveP2);
890
943
  expect(await store.get(g, 'hive-p2')).toBeNull();
891
944
  });
892
945
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oortmem/core",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Oort core: the tiered hot, warm and cold memory engine for AI agents (types, scoring, recall, consolidation).",
5
5
  "keywords": [
6
6
  "oort",