@oortmem/core 0.4.0 → 0.5.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.
@@ -1,5 +1,6 @@
1
1
  import type { MemoryStore, ScopeFilter } from './adapters.js';
2
2
  import type { ResolvedConfig } from './config.js';
3
+ import { type Grant } from './grant.js';
3
4
  export interface ConsolidateResult {
4
5
  /** Ids whose tier moved up (warm to hot). */
5
6
  promoted: string[];
@@ -15,6 +16,24 @@ export interface ConsolidateResult {
15
16
  * The record is left as the other writer made it; the next run reconsiders it.
16
17
  */
17
18
  skipped: number;
19
+ /**
20
+ * False when `shouldStop` cut the run short. Every write made so far stands (each is atomic and
21
+ * guarded), and a later run picks up the rest: consolidation recomputes everything from the
22
+ * store, so nothing is lost by stopping between two writes.
23
+ */
24
+ complete: boolean;
25
+ }
26
+ export interface ConsolidateDeps {
27
+ store: MemoryStore;
28
+ config: ResolvedConfig;
29
+ now?: Date;
30
+ /**
31
+ * Checked before each store call that lists or writes: once it returns true, consolidation
32
+ * starts nothing new and resolves with `complete: false`. A time budget (the hook drain's).
33
+ */
34
+ shouldStop?: () => boolean;
35
+ /** Evictions per `coldPutMany` call (default `COLD_EVICT_BATCH`); smaller holds the write lock for less. */
36
+ evictBatch?: number;
18
37
  }
19
38
  /** Evictions per `coldPutMany` call: one store transaction and one directory sync each. */
20
39
  export declare const COLD_EVICT_BATCH = 500;
@@ -24,8 +43,10 @@ export declare const COLD_EVICT_BATCH = 500;
24
43
  * `expiredAt = now`, `endReason = endReason ?? "expired"` (`expireMany`).
25
44
  * 2. Rank current memories by ACT-R activation (ties: newer `updatedAt`
26
45
  * first) and assign tiers via `tierFor`; changes are `setTier`d and new
27
- * cold memories are collected and `coldPutMany`'d in batches of `COLD_EVICT_BATCH`.
28
- * 3. Non-current memories (superseded, expired, corrected) go straight to cold.
46
+ * cold memories are collected and `coldPutMany`'d in batches of `COLD_EVICT_BATCH`
47
+ * (each read whole first, since the listing leaves embeddings out).
48
+ * 3. Non-current memories (superseded, expired, corrected) go straight to cold
49
+ * (evicted ahead of the stale current ones).
29
50
  *
30
51
  * Every write is targeted (expiry writes only the expiry fields, a tier change
31
52
  * only the tier, an eviction only the tier and the cold copy) and guarded by
@@ -40,10 +61,14 @@ export declare const COLD_EVICT_BATCH = 500;
40
61
  *
41
62
  * Each node the scope's grant covers (the person's Planet, this agent's Star)
42
63
  * is consolidated on its own, so the Planet's tiers do not depend on which of
43
- * the person's agents ran consolidation.
64
+ * the person's agents ran consolidation. Inside a node each project, and the
65
+ * global memories (no project), is ranked on its own (`hotTopN` per project),
66
+ * so a global memory's tier does not depend on which project ran it (CON-48).
44
67
  */
45
- export declare function consolidate(scope: ScopeFilter, deps: {
46
- store: MemoryStore;
47
- config: ResolvedConfig;
48
- now?: Date;
49
- }): Promise<ConsolidateResult>;
68
+ export declare function consolidate(scope: ScopeFilter, deps: ConsolidateDeps): Promise<ConsolidateResult>;
69
+ /**
70
+ * `consolidate` over every node `grant` both reads and writes: a Star agent's grant reads the
71
+ * Planet but does not write it, so the Planet is left alone. With `principalGrant` it covers every
72
+ * project of the person, which is what the hook drain runs.
73
+ */
74
+ export declare function consolidateGrant(grant: Grant, deps: ConsolidateDeps): Promise<ConsolidateResult>;
@@ -1,18 +1,21 @@
1
1
  import { activation, tierFor } from './activation.js';
2
- import { nodeKey } from './address.js';
3
- import { localGrant, narrowGrant, readableNodes } from './grant.js';
2
+ import { addressOf, nodeKey } from './address.js';
3
+ import { assertGrant, localGrant, narrowGrant, readableNodes } from './grant.js';
4
4
  import { isCurrent } from './model.js';
5
5
  /** Evictions per `coldPutMany` call: one store transaction and one directory sync each. */
6
6
  export const COLD_EVICT_BATCH = 500;
7
7
  const RANK = { cold: 0, warm: 1, hot: 2 };
8
+ const LISTING = { validOnly: false, embeddings: false };
8
9
  /**
9
10
  * Background consolidation. In order:
10
11
  * 1. Expire: memories with `validTo <= now` and no `expiredAt` get
11
12
  * `expiredAt = now`, `endReason = endReason ?? "expired"` (`expireMany`).
12
13
  * 2. Rank current memories by ACT-R activation (ties: newer `updatedAt`
13
14
  * first) and assign tiers via `tierFor`; changes are `setTier`d and new
14
- * cold memories are collected and `coldPutMany`'d in batches of `COLD_EVICT_BATCH`.
15
- * 3. Non-current memories (superseded, expired, corrected) go straight to cold.
15
+ * cold memories are collected and `coldPutMany`'d in batches of `COLD_EVICT_BATCH`
16
+ * (each read whole first, since the listing leaves embeddings out).
17
+ * 3. Non-current memories (superseded, expired, corrected) go straight to cold
18
+ * (evicted ahead of the stale current ones).
16
19
  *
17
20
  * Every write is targeted (expiry writes only the expiry fields, a tier change
18
21
  * only the tier, an eviction only the tier and the cold copy) and guarded by
@@ -27,73 +30,132 @@ const RANK = { cold: 0, warm: 1, hot: 2 };
27
30
  *
28
31
  * Each node the scope's grant covers (the person's Planet, this agent's Star)
29
32
  * is consolidated on its own, so the Planet's tiers do not depend on which of
30
- * the person's agents ran consolidation.
33
+ * the person's agents ran consolidation. Inside a node each project, and the
34
+ * global memories (no project), is ranked on its own (`hotTopN` per project),
35
+ * so a global memory's tier does not depend on which project ran it (CON-48).
31
36
  */
32
37
  export async function consolidate(scope, deps) {
38
+ return consolidateGrant(localGrant(scope), deps);
39
+ }
40
+ /**
41
+ * `consolidate` over every node `grant` both reads and writes: a Star agent's grant reads the
42
+ * Planet but does not write it, so the Planet is left alone. With `principalGrant` it covers every
43
+ * project of the person, which is what the hook drain runs.
44
+ */
45
+ export async function consolidateGrant(grant, deps) {
46
+ assertGrant(grant);
33
47
  const { store, config } = deps;
34
- const full = localGrant(scope);
35
48
  const now = deps.now ?? new Date();
49
+ const run = {
50
+ store,
51
+ config,
52
+ now,
53
+ stop: deps.shouldStop ?? (() => false),
54
+ evictBatch: Math.max(1, deps.evictBatch ?? COLD_EVICT_BATCH),
55
+ };
36
56
  const result = {
37
57
  promoted: [],
38
58
  demoted: [],
39
59
  evicted: [],
40
60
  expired: [],
41
61
  skipped: 0,
62
+ complete: true,
42
63
  };
43
- // Only nodes this grant may change: a Star agent's grant reads the Planet but does not write it.
44
- const writable = new Set(full.writeNodes.map((p) => nodeKey(p.node)));
45
- for (const node of readableNodes(full).filter((n) => writable.has(nodeKey(n)))) {
46
- await consolidateNode(store, narrowGrant(full, [node]), config, now, result);
64
+ const writable = new Set(grant.writeNodes.map((p) => nodeKey(p.node)));
65
+ for (const node of readableNodes(grant).filter((n) => writable.has(nodeKey(n)))) {
66
+ if (!(await consolidateNode(run, narrowGrant(grant, [node]), result))) {
67
+ result.complete = false;
68
+ break;
69
+ }
47
70
  }
48
71
  return result;
49
72
  }
50
- async function consolidateNode(store, grant, config, now, result) {
73
+ /** A memory's project inside its node (`null`: global). */
74
+ const projectOf = (m) => addressOf(m).projectId ?? null;
75
+ /** One node; false when `run.stop` cut it short. */
76
+ async function consolidateNode(run, grant, result) {
77
+ const { store, config, now } = run;
78
+ if (run.stop())
79
+ return false;
80
+ // One listing serves every step (a second one only after expiry changed records). Embeddings are
81
+ // read only for the memories evicted (a fraction of the cost of listing them all).
82
+ let all = await store.list(grant, LISTING);
51
83
  // 1. Expire.
52
- const due = (await store.list(grant, { validOnly: false })).filter((m) => m.validTo !== null && m.expiredAt === null && Date.parse(m.validTo) <= now.getTime());
84
+ const due = all.filter((m) => m.validTo !== null && m.expiredAt === null && Date.parse(m.validTo) <= now.getTime());
53
85
  if (due.length > 0) {
86
+ if (run.stop())
87
+ return false;
54
88
  const skipped = new Set((await store.expireMany(grant, due, now)).skipped);
55
89
  for (const m of due)
56
90
  if (!skipped.has(m.id))
57
91
  result.expired.push(m.id);
58
92
  result.skipped += skipped.size;
93
+ if (run.stop())
94
+ return false;
95
+ all = await store.list(grant, LISTING);
59
96
  }
60
- // 2. Rank and assign current memories.
61
- const current = (await store.list(grant, { validOnly: true })).filter((m) => isCurrent(m, now));
62
- const scored = current.map((m) => ({
63
- m,
64
- a: activation(m.accessLog, m.salience, now, config.activation),
65
- }));
66
- scored.sort((x, y) => y.a - x.a || (x.m.updatedAt < y.m.updatedAt ? 1 : x.m.updatedAt > y.m.updatedAt ? -1 : 0));
97
+ // 2. Rank and assign current memories, each project (and global) on its own.
98
+ const current = all.filter((m) => isCurrent(m, now));
67
99
  const currentIds = new Set(current.map((m) => m.id));
68
- const evictions = [];
69
- for (const [rank, { m, a }] of scored.entries()) {
70
- const target = tierFor(a, rank, m.pinned, config.activation);
71
- if (target === m.tier)
72
- continue;
73
- if (m.tier === 'cold')
74
- continue; // never promote out of cold here
75
- if (target === 'cold') {
76
- evictions.push(m);
77
- }
78
- else if (await store.setTier(grant, m.id, target, m)) {
79
- (RANK[target] > RANK[m.tier] ? result.promoted : result.demoted).push(m.id);
80
- }
81
- else {
82
- result.skipped++;
83
- }
100
+ const byProject = new Map();
101
+ for (const m of current) {
102
+ const key = projectOf(m);
103
+ const list = byProject.get(key);
104
+ if (list === undefined)
105
+ byProject.set(key, [m]);
106
+ else
107
+ list.push(m);
84
108
  }
85
- // 3. Non-current memories go straight to cold (unless pinned or already cold).
86
- for (const m of await store.list(grant, { validOnly: false })) {
87
- if (currentIds.has(m.id) || m.pinned || m.tier === 'cold')
88
- continue;
89
- evictions.push(m);
109
+ // 3 first. Non-current memories (superseded, expired) go straight to cold, unless pinned or
110
+ // already cold; they lead the evictions, so a run cut short has moved them first.
111
+ const evictions = all.filter((m) => !currentIds.has(m.id) && !m.pinned && m.tier !== 'cold');
112
+ for (const members of byProject.values()) {
113
+ const scored = members.map((m) => ({
114
+ m,
115
+ a: activation(m.accessLog, m.salience, now, config.activation),
116
+ }));
117
+ scored.sort((x, y) => y.a - x.a || (x.m.updatedAt < y.m.updatedAt ? 1 : x.m.updatedAt > y.m.updatedAt ? -1 : 0));
118
+ for (const [rank, { m, a }] of scored.entries()) {
119
+ const target = tierFor(a, rank, m.pinned, config.activation);
120
+ if (target === m.tier)
121
+ continue;
122
+ if (m.tier === 'cold')
123
+ continue; // never promote out of cold here
124
+ if (target === 'cold') {
125
+ evictions.push(m);
126
+ continue;
127
+ }
128
+ if (run.stop())
129
+ return false;
130
+ if (await store.setTier(grant, m.id, target, m)) {
131
+ (RANK[target] > RANK[m.tier] ? result.promoted : result.demoted).push(m.id);
132
+ }
133
+ else {
134
+ result.skipped++;
135
+ }
136
+ }
90
137
  }
91
- await evictAll(store, grant, evictions, result);
138
+ return evictAll(run, grant, evictions, result);
92
139
  }
93
- async function evictAll(store, grant, evictions, result) {
94
- for (let i = 0; i < evictions.length; i += COLD_EVICT_BATCH) {
95
- const batch = evictions.slice(i, i + COLD_EVICT_BATCH);
96
- // Each entry is the snapshot it was chosen from: the store's guard checks it.
140
+ async function evictAll(run, grant, evictions, result) {
141
+ const { store } = run;
142
+ for (let i = 0; i < evictions.length; i += run.evictBatch) {
143
+ if (run.stop())
144
+ return false;
145
+ // The cold copy keeps the embedding the listing left out: read each record whole, and drop
146
+ // any that changed since it was chosen. The store's guard checks the rest at commit.
147
+ const batch = [];
148
+ for (const chosen of evictions.slice(i, i + run.evictBatch)) {
149
+ const whole = await store.get(grant, chosen.id);
150
+ if (whole !== null && whole.updatedAt === chosen.updatedAt && whole.tier === chosen.tier) {
151
+ batch.push(whole);
152
+ }
153
+ else {
154
+ result.skipped++;
155
+ }
156
+ }
157
+ if (batch.length === 0)
158
+ continue;
97
159
  const skipped = new Set((await store.coldPutMany(grant, batch)).skipped);
98
160
  for (const m of batch) {
99
161
  if (skipped.has(m.id))
@@ -103,5 +165,6 @@ async function evictAll(store, grant, evictions, result) {
103
165
  }
104
166
  result.skipped += skipped.size;
105
167
  }
168
+ return true;
106
169
  }
107
170
  //# sourceMappingURL=consolidate.js.map
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.4.0";
1
+ export declare const VERSION = "0.5.0";
2
2
  export * from './model.js';
3
3
  export * from './address.js';
4
4
  export * from './export-format.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export const VERSION = '0.4.0';
1
+ export const VERSION = '0.5.0';
2
2
  export * from './model.js';
3
3
  export * from './address.js';
4
4
  export * from './export-format.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oortmem/core",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",