@lumenflow/memory 2.2.1 → 2.3.1
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/README.md +332 -0
- package/dist/decay/access-tracking.js +171 -0
- package/dist/decay/archival.js +164 -0
- package/dist/decay/scoring.js +143 -0
- package/dist/index.js +10 -0
- package/dist/mem-checkpoint-core.js +3 -3
- package/dist/mem-cleanup-core.js +38 -8
- package/dist/mem-context-core.js +347 -0
- package/dist/mem-create-core.js +4 -4
- package/dist/mem-delete-core.js +277 -0
- package/dist/mem-id.js +4 -4
- package/dist/mem-index-core.js +307 -0
- package/dist/mem-init-core.js +3 -3
- package/dist/mem-profile-core.js +184 -0
- package/dist/mem-promote-core.js +233 -0
- package/dist/mem-ready-core.js +2 -2
- package/dist/mem-signal-core.js +3 -3
- package/dist/mem-start-core.js +3 -3
- package/dist/mem-summarize-core.js +2 -2
- package/dist/mem-triage-core.js +5 -7
- package/dist/memory-schema.js +1 -1
- package/dist/memory-store.js +114 -53
- package/dist/signal-cleanup-core.js +355 -0
- package/package.json +4 -2
package/dist/memory-store.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Memory Store (WU-1463)
|
|
2
|
+
* Memory Store (WU-1463, WU-1238)
|
|
3
3
|
*
|
|
4
4
|
* JSONL-based memory store with load, query, and append operations.
|
|
5
5
|
* Git-friendly format with one node per line for merge-safe diffs.
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
* - Append-only writes (no full file rewrite)
|
|
9
9
|
* - Indexed lookups by ID and WU
|
|
10
10
|
* - Deterministic queryReady() ordering by priority then createdAt
|
|
11
|
+
* - WU-1238: Support for archived node filtering
|
|
12
|
+
* - WU-1238: Decay score-based sorting option
|
|
11
13
|
*
|
|
12
|
-
* @see {@link
|
|
13
|
-
* @see {@link
|
|
14
|
+
* @see {@link packages/@lumenflow/cli/src/lib/__tests__/memory-store.test.ts} - Tests
|
|
15
|
+
* @see {@link packages/@lumenflow/cli/src/lib/memory-schema.ts} - Schema definitions
|
|
14
16
|
*/
|
|
15
17
|
import fs from 'node:fs/promises';
|
|
16
18
|
import path from 'node:path';
|
|
@@ -68,6 +70,15 @@ function compareNodes(a, b) {
|
|
|
68
70
|
// Tertiary: stable sort by ID for identical priority and timestamp
|
|
69
71
|
return a.id.localeCompare(b.id);
|
|
70
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Check if a node is archived (WU-1238).
|
|
75
|
+
*
|
|
76
|
+
* @param node - Memory node to check
|
|
77
|
+
* @returns True if node has metadata.status = 'archived'
|
|
78
|
+
*/
|
|
79
|
+
function isNodeArchived(node) {
|
|
80
|
+
return node.metadata?.status === 'archived';
|
|
81
|
+
}
|
|
71
82
|
/**
|
|
72
83
|
* Loads memory from JSONL file and returns indexed nodes.
|
|
73
84
|
*
|
|
@@ -86,71 +97,111 @@ function compareNodes(a, b) {
|
|
|
86
97
|
* const memory = await loadMemory('/path/to/project');
|
|
87
98
|
* const node = memory.byId.get('mem-abc1');
|
|
88
99
|
* const wuNodes = memory.byWu.get('WU-1463') ?? [];
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* // WU-1238: Include archived nodes
|
|
103
|
+
* const allMemory = await loadMemory('/path/to/project', { includeArchived: true });
|
|
89
104
|
*/
|
|
90
|
-
export async function loadMemory(baseDir) {
|
|
105
|
+
export async function loadMemory(baseDir, options = {}) {
|
|
106
|
+
const { includeArchived = false } = options;
|
|
91
107
|
const filePath = path.join(baseDir, MEMORY_FILE_NAME);
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
108
|
+
const content = await readMemoryFileOrEmpty(filePath);
|
|
109
|
+
if (content === null) {
|
|
110
|
+
return createEmptyIndexedMemory();
|
|
111
|
+
}
|
|
112
|
+
return parseAndIndexMemory(content, includeArchived);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Reads memory file content, returning null if file doesn't exist
|
|
116
|
+
*/
|
|
117
|
+
async function readMemoryFileOrEmpty(filePath) {
|
|
99
118
|
try {
|
|
100
|
-
|
|
119
|
+
return await fs.readFile(filePath, { encoding: 'utf-8' });
|
|
101
120
|
}
|
|
102
121
|
catch (err) {
|
|
103
122
|
const error = err;
|
|
104
123
|
if (error.code === 'ENOENT') {
|
|
105
|
-
|
|
106
|
-
return result;
|
|
124
|
+
return null;
|
|
107
125
|
}
|
|
108
126
|
throw error;
|
|
109
127
|
}
|
|
110
|
-
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Creates an empty indexed memory result
|
|
131
|
+
*/
|
|
132
|
+
function createEmptyIndexedMemory() {
|
|
133
|
+
return {
|
|
134
|
+
nodes: [],
|
|
135
|
+
byId: new Map(),
|
|
136
|
+
byWu: new Map(),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Parses a single JSONL line and validates it
|
|
141
|
+
*/
|
|
142
|
+
function parseAndValidateLine(line, lineNumber) {
|
|
143
|
+
let parsed;
|
|
144
|
+
try {
|
|
145
|
+
parsed = JSON.parse(line);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
149
|
+
throw new Error(`Malformed JSON on line ${lineNumber}: ${errMsg}`);
|
|
150
|
+
}
|
|
151
|
+
const validation = validateMemoryNode(parsed);
|
|
152
|
+
if (!validation.success) {
|
|
153
|
+
const issues = validation.error.issues
|
|
154
|
+
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
|
|
155
|
+
.join(', ');
|
|
156
|
+
throw new Error(`Validation error on line ${lineNumber}: ${issues}`);
|
|
157
|
+
}
|
|
158
|
+
return validation.data;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Adds a node to the WU index
|
|
162
|
+
*/
|
|
163
|
+
function indexNodeByWu(result, node) {
|
|
164
|
+
if (!node.wu_id)
|
|
165
|
+
return;
|
|
166
|
+
if (!result.byWu.has(node.wu_id)) {
|
|
167
|
+
result.byWu.set(node.wu_id, []);
|
|
168
|
+
}
|
|
169
|
+
const wuNodes = result.byWu.get(node.wu_id);
|
|
170
|
+
if (wuNodes) {
|
|
171
|
+
wuNodes.push(node);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Parses JSONL content and builds indexed memory
|
|
176
|
+
*/
|
|
177
|
+
function parseAndIndexMemory(content, includeArchived) {
|
|
178
|
+
const result = createEmptyIndexedMemory();
|
|
111
179
|
const lines = content.split('\n');
|
|
112
180
|
for (let i = 0; i < lines.length; i++) {
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
181
|
+
const line = lines[i]?.trim() ?? '';
|
|
182
|
+
if (!line)
|
|
183
|
+
continue;
|
|
184
|
+
const node = parseAndValidateLine(line, i + 1);
|
|
185
|
+
// WU-1238: Skip archived nodes unless includeArchived is true
|
|
186
|
+
if (!includeArchived && isNodeArchived(node)) {
|
|
117
187
|
continue;
|
|
118
188
|
}
|
|
119
|
-
// Parse JSON line
|
|
120
|
-
let parsed;
|
|
121
|
-
try {
|
|
122
|
-
parsed = JSON.parse(line);
|
|
123
|
-
}
|
|
124
|
-
catch (err) {
|
|
125
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
126
|
-
throw new Error(`Malformed JSON on line ${i + 1}: ${errMsg}`);
|
|
127
|
-
}
|
|
128
|
-
// Validate against schema
|
|
129
|
-
const validation = validateMemoryNode(parsed);
|
|
130
|
-
if (!validation.success) {
|
|
131
|
-
const issues = validation.error.issues
|
|
132
|
-
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
|
|
133
|
-
.join(', ');
|
|
134
|
-
throw new Error(`Validation error on line ${i + 1}: ${issues}`);
|
|
135
|
-
}
|
|
136
|
-
const node = validation.data;
|
|
137
|
-
// Add to nodes array
|
|
138
189
|
result.nodes.push(node);
|
|
139
|
-
// Index by ID
|
|
140
190
|
result.byId.set(node.id, node);
|
|
141
|
-
|
|
142
|
-
if (node.wu_id) {
|
|
143
|
-
if (!result.byWu.has(node.wu_id)) {
|
|
144
|
-
result.byWu.set(node.wu_id, []);
|
|
145
|
-
}
|
|
146
|
-
const wuNodes = result.byWu.get(node.wu_id);
|
|
147
|
-
if (wuNodes) {
|
|
148
|
-
wuNodes.push(node);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
191
|
+
indexNodeByWu(result, node);
|
|
151
192
|
}
|
|
152
193
|
return result;
|
|
153
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* Loads all memory including archived nodes.
|
|
197
|
+
* Convenience function for operations that need to see all nodes.
|
|
198
|
+
*
|
|
199
|
+
* @param baseDir - Directory containing memory.jsonl
|
|
200
|
+
* @returns Indexed memory nodes including archived
|
|
201
|
+
*/
|
|
202
|
+
export async function loadMemoryAll(baseDir) {
|
|
203
|
+
return loadMemory(baseDir, { includeArchived: true });
|
|
204
|
+
}
|
|
154
205
|
/**
|
|
155
206
|
* Appends a single node to the memory file.
|
|
156
207
|
*
|
|
@@ -206,9 +257,14 @@ export async function appendNode(baseDir, node) {
|
|
|
206
257
|
* for (const node of ready) {
|
|
207
258
|
* await processNode(node);
|
|
208
259
|
* }
|
|
260
|
+
*
|
|
261
|
+
* @example
|
|
262
|
+
* // WU-1238: Include archived nodes
|
|
263
|
+
* const all = await queryReady('/path/to/project', 'WU-1463', { includeArchived: true });
|
|
209
264
|
*/
|
|
210
|
-
export async function queryReady(baseDir, wuId) {
|
|
211
|
-
const
|
|
265
|
+
export async function queryReady(baseDir, wuId, options = {}) {
|
|
266
|
+
const { includeArchived = false } = options;
|
|
267
|
+
const memory = await loadMemory(baseDir, { includeArchived });
|
|
212
268
|
// Get nodes for this WU
|
|
213
269
|
const wuNodes = memory.byWu.get(wuId) ?? [];
|
|
214
270
|
// Return sorted copy (don't mutate original)
|
|
@@ -227,8 +283,13 @@ export async function queryReady(baseDir, wuId) {
|
|
|
227
283
|
* @example
|
|
228
284
|
* const nodes = await queryByWu('/path/to/project', 'WU-1463');
|
|
229
285
|
* console.log(`Found ${nodes.length} nodes for WU-1463`);
|
|
286
|
+
*
|
|
287
|
+
* @example
|
|
288
|
+
* // WU-1238: Include archived nodes
|
|
289
|
+
* const all = await queryByWu('/path/to/project', 'WU-1463', { includeArchived: true });
|
|
230
290
|
*/
|
|
231
|
-
export async function queryByWu(baseDir, wuId) {
|
|
232
|
-
const
|
|
291
|
+
export async function queryByWu(baseDir, wuId, options = {}) {
|
|
292
|
+
const { includeArchived = false } = options;
|
|
293
|
+
const memory = await loadMemory(baseDir, { includeArchived });
|
|
233
294
|
return memory.byWu.get(wuId) ?? [];
|
|
234
295
|
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal Cleanup Core (WU-1204)
|
|
3
|
+
*
|
|
4
|
+
* TTL-based cleanup for signals to prevent unbounded growth.
|
|
5
|
+
* Implements configurable retention policies:
|
|
6
|
+
* - Read signals: 7 days default TTL
|
|
7
|
+
* - Unread signals: 30 days default TTL
|
|
8
|
+
* - Max entries: 500 default
|
|
9
|
+
* - Active WU protection: signals linked to in_progress/blocked WUs are never removed
|
|
10
|
+
*
|
|
11
|
+
* Reuses patterns from mem-cleanup-core.ts.
|
|
12
|
+
*
|
|
13
|
+
* @see {@link packages/@lumenflow/cli/src/signal-cleanup.ts} - CLI wrapper
|
|
14
|
+
* @see {@link packages/@lumenflow/memory/src/__tests__/signal-cleanup-core.test.ts} - Tests
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs/promises';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const ms = require('ms');
|
|
21
|
+
import { LUMENFLOW_MEMORY_PATHS } from './paths.js';
|
|
22
|
+
import { SIGNAL_FILE_NAME } from './mem-signal-core.js';
|
|
23
|
+
/**
|
|
24
|
+
* Default TTL values in milliseconds
|
|
25
|
+
*/
|
|
26
|
+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
27
|
+
/**
|
|
28
|
+
* Default signal cleanup configuration
|
|
29
|
+
*/
|
|
30
|
+
export const DEFAULT_SIGNAL_CLEANUP_CONFIG = {
|
|
31
|
+
ttl: 7 * ONE_DAY_MS,
|
|
32
|
+
unreadTtl: 30 * ONE_DAY_MS,
|
|
33
|
+
maxEntries: 500,
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Parse a TTL duration string into milliseconds.
|
|
37
|
+
*
|
|
38
|
+
* Uses the `ms` package to parse human-readable duration strings.
|
|
39
|
+
*
|
|
40
|
+
* @param ttlString - TTL string (e.g., '7d', '30d', '24h', '60m')
|
|
41
|
+
* @returns TTL in milliseconds
|
|
42
|
+
* @throws If TTL format is invalid
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* parseSignalTtl('7d'); // 604800000 (7 days in ms)
|
|
46
|
+
* parseSignalTtl('30d'); // 2592000000 (30 days in ms)
|
|
47
|
+
* parseSignalTtl('24h'); // 86400000 (24 hours in ms)
|
|
48
|
+
*/
|
|
49
|
+
export function parseSignalTtl(ttlString) {
|
|
50
|
+
if (!ttlString || typeof ttlString !== 'string') {
|
|
51
|
+
throw new Error('Invalid TTL format: TTL string is required');
|
|
52
|
+
}
|
|
53
|
+
const trimmed = ttlString.trim();
|
|
54
|
+
if (!trimmed) {
|
|
55
|
+
throw new Error('Invalid TTL format: TTL string is required');
|
|
56
|
+
}
|
|
57
|
+
// Use ms package to parse the duration
|
|
58
|
+
const result = ms(trimmed);
|
|
59
|
+
if (result === undefined || result <= 0) {
|
|
60
|
+
throw new Error(`Invalid TTL format: "${ttlString}" is not a valid duration`);
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Check if a signal has expired based on TTL.
|
|
66
|
+
*
|
|
67
|
+
* @param signal - Signal to check
|
|
68
|
+
* @param ttlMs - TTL in milliseconds
|
|
69
|
+
* @param now - Current timestamp
|
|
70
|
+
* @returns True if signal is older than TTL
|
|
71
|
+
*/
|
|
72
|
+
function isSignalExpired(signal, ttlMs, now) {
|
|
73
|
+
if (!signal.created_at) {
|
|
74
|
+
return false; // No timestamp means we can't determine age - safer to retain
|
|
75
|
+
}
|
|
76
|
+
const createdAt = new Date(signal.created_at).getTime();
|
|
77
|
+
// Invalid date - safer to retain
|
|
78
|
+
if (Number.isNaN(createdAt)) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const age = now - createdAt;
|
|
82
|
+
return age > ttlMs;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Check if a signal should be removed based on TTL and active WU protection.
|
|
86
|
+
*
|
|
87
|
+
* Policy rules (checked in order):
|
|
88
|
+
* 1. Active WU signals are always retained
|
|
89
|
+
* 2. Read signals older than TTL are removed
|
|
90
|
+
* 3. Unread signals older than unreadTtl are removed
|
|
91
|
+
* 4. Otherwise, signal is retained
|
|
92
|
+
*
|
|
93
|
+
* @param signal - Signal to check
|
|
94
|
+
* @param config - Cleanup configuration
|
|
95
|
+
* @param context - Removal context (now timestamp, active WU IDs)
|
|
96
|
+
* @returns Removal decision with reason
|
|
97
|
+
*/
|
|
98
|
+
export function shouldRemoveSignal(signal, config, context) {
|
|
99
|
+
const { now, activeWuIds } = context;
|
|
100
|
+
// Active WU protection: signals linked to in_progress/blocked WUs are always retained
|
|
101
|
+
if (signal.wu_id && activeWuIds.has(signal.wu_id)) {
|
|
102
|
+
return { remove: false, reason: 'active-wu-protected' };
|
|
103
|
+
}
|
|
104
|
+
// Check TTL based on read status
|
|
105
|
+
if (signal.read) {
|
|
106
|
+
// Read signals use the shorter TTL
|
|
107
|
+
if (isSignalExpired(signal, config.ttl, now)) {
|
|
108
|
+
return { remove: true, reason: 'ttl-expired' };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
// Unread signals use the longer unreadTtl
|
|
113
|
+
if (isSignalExpired(signal, config.unreadTtl, now)) {
|
|
114
|
+
return { remove: true, reason: 'unread-ttl-expired' };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Default: retain
|
|
118
|
+
return { remove: false, reason: 'within-ttl' };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Calculate approximate byte size of a signal when serialized.
|
|
122
|
+
*
|
|
123
|
+
* @param signal - Signal to measure
|
|
124
|
+
* @returns Approximate byte size
|
|
125
|
+
*/
|
|
126
|
+
function estimateSignalBytes(signal) {
|
|
127
|
+
// JSON.stringify + newline character
|
|
128
|
+
return JSON.stringify(signal).length + 1;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Calculate compaction ratio (removed / total).
|
|
132
|
+
*
|
|
133
|
+
* @param removedCount - Number of removed signals
|
|
134
|
+
* @param totalCount - Total number of signals
|
|
135
|
+
* @returns Compaction ratio (0 to 1, or 0 if no signals)
|
|
136
|
+
*/
|
|
137
|
+
function getCompactionRatio(removedCount, totalCount) {
|
|
138
|
+
if (totalCount === 0) {
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
return removedCount / totalCount;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Gets the signals file path for a project.
|
|
145
|
+
*
|
|
146
|
+
* @param baseDir - Project base directory
|
|
147
|
+
* @returns Full path to signals.jsonl
|
|
148
|
+
*/
|
|
149
|
+
function getSignalsPath(baseDir) {
|
|
150
|
+
return path.join(baseDir, LUMENFLOW_MEMORY_PATHS.MEMORY_DIR, SIGNAL_FILE_NAME);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Load signals directly from file (for cleanup, to avoid loadSignals filter limitations).
|
|
154
|
+
*
|
|
155
|
+
* @param baseDir - Project base directory
|
|
156
|
+
* @returns Array of all signals
|
|
157
|
+
*/
|
|
158
|
+
async function loadAllSignals(baseDir) {
|
|
159
|
+
const signalsPath = getSignalsPath(baseDir);
|
|
160
|
+
try {
|
|
161
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- CLI tool reads known path
|
|
162
|
+
const content = await fs.readFile(signalsPath, { encoding: 'utf-8' });
|
|
163
|
+
const lines = content.split('\n').filter((line) => line.trim());
|
|
164
|
+
return lines.map((line) => JSON.parse(line));
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
const error = err;
|
|
168
|
+
if (error.code === 'ENOENT') {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Write retained signals back to file.
|
|
176
|
+
*
|
|
177
|
+
* @param baseDir - Project base directory
|
|
178
|
+
* @param signals - Signals to write
|
|
179
|
+
*/
|
|
180
|
+
async function writeSignals(baseDir, signals) {
|
|
181
|
+
const signalsPath = getSignalsPath(baseDir);
|
|
182
|
+
const content = signals.map((s) => JSON.stringify(s)).join('\n') + (signals.length > 0 ? '\n' : '');
|
|
183
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- CLI tool writes known path
|
|
184
|
+
await fs.writeFile(signalsPath, content, { encoding: 'utf-8' });
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Default function to get active WU IDs (returns empty set).
|
|
188
|
+
* Override in CLI to actually query WU status.
|
|
189
|
+
*/
|
|
190
|
+
async function defaultGetActiveWuIds() {
|
|
191
|
+
return new Set();
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Build cleanup configuration from options.
|
|
195
|
+
*
|
|
196
|
+
* @param options - Cleanup options
|
|
197
|
+
* @returns Effective cleanup configuration
|
|
198
|
+
*/
|
|
199
|
+
function buildCleanupConfig(options) {
|
|
200
|
+
const { ttl, ttlMs: providedTtlMs, unreadTtl, unreadTtlMs: providedUnreadTtlMs, maxEntries, } = options;
|
|
201
|
+
let ttlMs = providedTtlMs ?? DEFAULT_SIGNAL_CLEANUP_CONFIG.ttl;
|
|
202
|
+
if (ttl && !providedTtlMs) {
|
|
203
|
+
ttlMs = parseSignalTtl(ttl);
|
|
204
|
+
}
|
|
205
|
+
let unreadTtlMs = providedUnreadTtlMs ?? DEFAULT_SIGNAL_CLEANUP_CONFIG.unreadTtl;
|
|
206
|
+
if (unreadTtl && !providedUnreadTtlMs) {
|
|
207
|
+
unreadTtlMs = parseSignalTtl(unreadTtl);
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
ttl: ttlMs,
|
|
211
|
+
unreadTtl: unreadTtlMs,
|
|
212
|
+
maxEntries: maxEntries ?? DEFAULT_SIGNAL_CLEANUP_CONFIG.maxEntries,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Process signals for TTL-based removal decisions.
|
|
217
|
+
*
|
|
218
|
+
* @param signals - All signals to process
|
|
219
|
+
* @param config - Cleanup configuration
|
|
220
|
+
* @param context - Removal context
|
|
221
|
+
* @returns Cleanup state after TTL processing
|
|
222
|
+
*/
|
|
223
|
+
function processSignalsForTtl(signals, config, context) {
|
|
224
|
+
const state = {
|
|
225
|
+
removedIds: [],
|
|
226
|
+
retainedIds: [],
|
|
227
|
+
retainedSignals: [],
|
|
228
|
+
bytesFreed: 0,
|
|
229
|
+
breakdown: {
|
|
230
|
+
ttlExpired: 0,
|
|
231
|
+
unreadTtlExpired: 0,
|
|
232
|
+
countLimitExceeded: 0,
|
|
233
|
+
activeWuProtected: 0,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
for (const signal of signals) {
|
|
237
|
+
const decision = shouldRemoveSignal(signal, config, context);
|
|
238
|
+
processSignalDecision(signal, decision, state);
|
|
239
|
+
}
|
|
240
|
+
return state;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Process a single signal's removal decision and update state.
|
|
244
|
+
*
|
|
245
|
+
* @param signal - Signal being processed
|
|
246
|
+
* @param decision - Removal decision for this signal
|
|
247
|
+
* @param state - Cleanup state to update
|
|
248
|
+
*/
|
|
249
|
+
function processSignalDecision(signal, decision, state) {
|
|
250
|
+
if (decision.remove) {
|
|
251
|
+
state.removedIds.push(signal.id);
|
|
252
|
+
state.bytesFreed += estimateSignalBytes(signal);
|
|
253
|
+
updateBreakdownForRemoval(decision.reason, state.breakdown);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
state.retainedIds.push(signal.id);
|
|
257
|
+
state.retainedSignals.push(signal);
|
|
258
|
+
if (decision.reason === 'active-wu-protected') {
|
|
259
|
+
state.breakdown.activeWuProtected++;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Update breakdown statistics for a removed signal.
|
|
265
|
+
*
|
|
266
|
+
* @param reason - Removal reason
|
|
267
|
+
* @param breakdown - Breakdown to update
|
|
268
|
+
*/
|
|
269
|
+
function updateBreakdownForRemoval(reason, breakdown) {
|
|
270
|
+
if (reason === 'ttl-expired') {
|
|
271
|
+
breakdown.ttlExpired++;
|
|
272
|
+
}
|
|
273
|
+
else if (reason === 'unread-ttl-expired') {
|
|
274
|
+
breakdown.unreadTtlExpired++;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Apply count-based pruning to keep only maxEntries signals.
|
|
279
|
+
*
|
|
280
|
+
* @param state - Current cleanup state (mutated in place)
|
|
281
|
+
* @param maxEntries - Maximum entries to retain
|
|
282
|
+
*/
|
|
283
|
+
function applyCountPruning(state, maxEntries) {
|
|
284
|
+
if (state.retainedSignals.length <= maxEntries) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
// Sort by created_at (oldest first)
|
|
288
|
+
state.retainedSignals.sort((a, b) => {
|
|
289
|
+
const aTime = new Date(a.created_at).getTime();
|
|
290
|
+
const bTime = new Date(b.created_at).getTime();
|
|
291
|
+
return aTime - bTime;
|
|
292
|
+
});
|
|
293
|
+
const toRemove = state.retainedSignals.length - maxEntries;
|
|
294
|
+
for (let i = 0; i < toRemove; i++) {
|
|
295
|
+
// eslint-disable-next-line security/detect-object-injection -- Safe: i is a controlled loop index
|
|
296
|
+
const signal = state.retainedSignals[i];
|
|
297
|
+
const idIndex = state.retainedIds.indexOf(signal.id);
|
|
298
|
+
if (idIndex !== -1) {
|
|
299
|
+
state.retainedIds.splice(idIndex, 1);
|
|
300
|
+
}
|
|
301
|
+
state.removedIds.push(signal.id);
|
|
302
|
+
state.bytesFreed += estimateSignalBytes(signal);
|
|
303
|
+
state.breakdown.countLimitExceeded++;
|
|
304
|
+
}
|
|
305
|
+
state.retainedSignals.splice(0, toRemove);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Cleanup signals based on TTL and count limits.
|
|
309
|
+
*
|
|
310
|
+
* Removes signals according to policy:
|
|
311
|
+
* 1. Active WU signals (in_progress/blocked) are always retained
|
|
312
|
+
* 2. Read signals older than TTL (default 7d) are removed
|
|
313
|
+
* 3. Unread signals older than unreadTtl (default 30d) are removed
|
|
314
|
+
* 4. If over maxEntries, oldest signals are removed (keeping newest)
|
|
315
|
+
*
|
|
316
|
+
* In dry-run mode, no modifications are made but the result shows
|
|
317
|
+
* what would be removed.
|
|
318
|
+
*
|
|
319
|
+
* @param baseDir - Project base directory
|
|
320
|
+
* @param options - Cleanup options
|
|
321
|
+
* @returns Cleanup result with removed/retained IDs and metrics
|
|
322
|
+
*
|
|
323
|
+
* @example
|
|
324
|
+
* // Cleanup with dry-run to preview
|
|
325
|
+
* const preview = await cleanupSignals(baseDir, { dryRun: true });
|
|
326
|
+
* console.log(`Would remove ${preview.removedIds.length} signals`);
|
|
327
|
+
*
|
|
328
|
+
* @example
|
|
329
|
+
* // Cleanup with custom TTL
|
|
330
|
+
* const result = await cleanupSignals(baseDir, { ttl: '3d' });
|
|
331
|
+
*/
|
|
332
|
+
export async function cleanupSignals(baseDir, options = {}) {
|
|
333
|
+
const { dryRun = false, now = Date.now(), getActiveWuIds = defaultGetActiveWuIds } = options;
|
|
334
|
+
const config = buildCleanupConfig(options);
|
|
335
|
+
const signals = await loadAllSignals(baseDir);
|
|
336
|
+
const activeWuIds = await getActiveWuIds();
|
|
337
|
+
const state = processSignalsForTtl(signals, config, { now, activeWuIds });
|
|
338
|
+
applyCountPruning(state, config.maxEntries);
|
|
339
|
+
const compactionRatio = getCompactionRatio(state.removedIds.length, signals.length);
|
|
340
|
+
const baseResult = {
|
|
341
|
+
success: true,
|
|
342
|
+
removedIds: state.removedIds,
|
|
343
|
+
retainedIds: state.retainedIds,
|
|
344
|
+
bytesFreed: state.bytesFreed,
|
|
345
|
+
compactionRatio,
|
|
346
|
+
breakdown: state.breakdown,
|
|
347
|
+
};
|
|
348
|
+
if (dryRun) {
|
|
349
|
+
return { ...baseResult, dryRun: true };
|
|
350
|
+
}
|
|
351
|
+
if (state.removedIds.length > 0) {
|
|
352
|
+
await writeSignals(baseDir, state.retainedSignals);
|
|
353
|
+
}
|
|
354
|
+
return baseResult;
|
|
355
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumenflow/memory",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"description": "Memory layer for LumenFlow workflow framework - session tracking, context recovery, and agent coordination",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"lumenflow",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"exports": {
|
|
26
26
|
".": "./dist/index.js",
|
|
27
27
|
"./checkpoint": "./dist/mem-checkpoint-core.js",
|
|
28
|
+
"./context": "./dist/mem-context-core.js",
|
|
28
29
|
"./init": "./dist/mem-init-core.js",
|
|
29
30
|
"./start": "./dist/mem-start-core.js",
|
|
30
31
|
"./ready": "./dist/mem-ready-core.js",
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
"./create": "./dist/mem-create-core.js",
|
|
34
35
|
"./summarize": "./dist/mem-summarize-core.js",
|
|
35
36
|
"./triage": "./dist/mem-triage-core.js",
|
|
37
|
+
"./index": "./dist/mem-index-core.js",
|
|
36
38
|
"./schema": "./dist/memory-schema.js",
|
|
37
39
|
"./store": "./dist/memory-store.js",
|
|
38
40
|
"./dist/*": "./dist/*"
|
|
@@ -47,7 +49,7 @@
|
|
|
47
49
|
"ms": "^2.1.3",
|
|
48
50
|
"yaml": "^2.8.2",
|
|
49
51
|
"zod": "^4.3.5",
|
|
50
|
-
"@lumenflow/core": "2.
|
|
52
|
+
"@lumenflow/core": "2.3.1"
|
|
51
53
|
},
|
|
52
54
|
"devDependencies": {
|
|
53
55
|
"@vitest/coverage-v8": "^4.0.17",
|