@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.
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Decay Scoring (WU-1238)
3
+ *
4
+ * Compute decay scores for memory nodes to manage relevance over time.
5
+ * Frequently accessed, recent memories rank higher than stale, rarely-used ones.
6
+ *
7
+ * Decay scoring algorithm:
8
+ * - recencyScore = exp(-age / HALF_LIFE_MS)
9
+ * - accessScore = log1p(access_count) / 10
10
+ * - importanceScore = priority P0=2, P1=1.5, P2=1, P3=0.5
11
+ * - decayScore = recencyScore * (1 + accessScore) * importanceScore
12
+ *
13
+ * @see {@link packages/@lumenflow/memory/__tests__/decay-scoring.test.ts} - Tests
14
+ */
15
+ /**
16
+ * Default half-life for decay scoring: 30 days in milliseconds
17
+ */
18
+ export const DEFAULT_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000;
19
+ /**
20
+ * Importance multipliers by priority level.
21
+ * P0 (critical) gets highest importance, P3 (low) gets lowest.
22
+ */
23
+ export const IMPORTANCE_BY_PRIORITY = {
24
+ P0: 2,
25
+ P1: 1.5,
26
+ P2: 1,
27
+ P3: 0.5,
28
+ };
29
+ /**
30
+ * Default importance for nodes without priority
31
+ */
32
+ const DEFAULT_IMPORTANCE = 1;
33
+ /**
34
+ * Gets the reference timestamp for a node (most recent of created_at/updated_at).
35
+ *
36
+ * @param node - Memory node
37
+ * @returns Timestamp in milliseconds
38
+ */
39
+ function getNodeTimestamp(node) {
40
+ const createdAt = new Date(node.created_at).getTime();
41
+ if (!node.updated_at) {
42
+ return createdAt;
43
+ }
44
+ const updatedAt = new Date(node.updated_at).getTime();
45
+ // Use the more recent timestamp
46
+ return Math.max(createdAt, updatedAt);
47
+ }
48
+ /**
49
+ * Compute recency score based on exponential decay.
50
+ *
51
+ * Formula: exp(-age / halfLife)
52
+ * - Returns 1 for brand new nodes
53
+ * - Returns exp(-1) ~= 0.368 at half-life
54
+ * - Approaches 0 for very old nodes
55
+ *
56
+ * @param node - Memory node to score
57
+ * @param halfLifeMs - Half-life in milliseconds
58
+ * @param now - Current timestamp (default: Date.now())
59
+ * @returns Recency score between 0 and 1
60
+ *
61
+ * @example
62
+ * const score = computeRecencyScore(node, DEFAULT_HALF_LIFE_MS);
63
+ * // Returns ~1 for recent nodes, ~0.368 at half-life, ~0 for old nodes
64
+ */
65
+ export function computeRecencyScore(node, halfLifeMs = DEFAULT_HALF_LIFE_MS, now = Date.now()) {
66
+ const nodeTimestamp = getNodeTimestamp(node);
67
+ const age = now - nodeTimestamp;
68
+ // Exponential decay: exp(-age / halfLife)
69
+ return Math.exp(-age / halfLifeMs);
70
+ }
71
+ /**
72
+ * Compute access score based on access count.
73
+ *
74
+ * Formula: log1p(access_count) / 10
75
+ * - Returns 0 for nodes with no access
76
+ * - Logarithmic scaling prevents runaway scores for frequently accessed nodes
77
+ * - Bounded contribution (log1p(1000)/10 ~= 0.691)
78
+ *
79
+ * @param node - Memory node to score
80
+ * @returns Access score (0 to ~0.7 for typical access counts)
81
+ *
82
+ * @example
83
+ * const score = computeAccessScore(node);
84
+ * // Returns 0 for no access, ~0.07 for 1 access, ~0.24 for 10 accesses
85
+ */
86
+ export function computeAccessScore(node) {
87
+ const accessCount = node.metadata?.access?.count ?? 0;
88
+ // log1p(x) = ln(1 + x), divided by 10 to keep contribution bounded
89
+ return Math.log1p(accessCount) / 10;
90
+ }
91
+ /**
92
+ * Compute importance score based on priority level.
93
+ *
94
+ * Priority multipliers:
95
+ * - P0: 2 (critical, highest importance)
96
+ * - P1: 1.5 (high)
97
+ * - P2: 1 (medium, default)
98
+ * - P3: 0.5 (low)
99
+ *
100
+ * @param node - Memory node to score
101
+ * @returns Importance multiplier (0.5 to 2)
102
+ *
103
+ * @example
104
+ * const score = computeImportanceScore(node);
105
+ * // Returns 2 for P0, 1.5 for P1, 1 for P2/default, 0.5 for P3
106
+ */
107
+ export function computeImportanceScore(node) {
108
+ const priority = node.metadata?.priority;
109
+ if (!priority) {
110
+ return DEFAULT_IMPORTANCE;
111
+ }
112
+ return IMPORTANCE_BY_PRIORITY[priority] ?? DEFAULT_IMPORTANCE;
113
+ }
114
+ /**
115
+ * Compute the overall decay score for a memory node.
116
+ *
117
+ * Formula: recencyScore * (1 + accessScore) * importanceScore
118
+ *
119
+ * The score combines:
120
+ * - Recency: Exponential decay based on age
121
+ * - Access: Logarithmic boost for frequently accessed nodes
122
+ * - Importance: Priority-based multiplier
123
+ *
124
+ * Higher scores indicate more relevant nodes that should be retained.
125
+ * Lower scores indicate stale nodes that may be archived.
126
+ *
127
+ * @param node - Memory node to score
128
+ * @param options - Scoring options (now, halfLifeMs)
129
+ * @returns Decay score (0 to ~2 for typical nodes)
130
+ *
131
+ * @example
132
+ * const score = computeDecayScore(node, { now: Date.now() });
133
+ * // Returns high score for recent/accessed/important nodes
134
+ * // Returns low score for old/unused/low-priority nodes
135
+ */
136
+ export function computeDecayScore(node, options = {}) {
137
+ const { now = Date.now(), halfLifeMs = DEFAULT_HALF_LIFE_MS } = options;
138
+ const recencyScore = computeRecencyScore(node, halfLifeMs, now);
139
+ const accessScore = computeAccessScore(node);
140
+ const importanceScore = computeImportanceScore(node);
141
+ // Combined formula: recency * (1 + access) * importance
142
+ return recencyScore * (1 + accessScore) * importanceScore;
143
+ }
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * @module @lumenflow/memory
4
4
  */
5
5
  export * from './mem-checkpoint-core.js';
6
+ export * from './mem-context-core.js';
6
7
  export * from './mem-cleanup-core.js';
7
8
  export * from './mem-create-core.js';
8
9
  export * from './mem-export-core.js';
@@ -10,8 +11,17 @@ export * from './mem-id.js';
10
11
  export * from './mem-init-core.js';
11
12
  export * from './mem-ready-core.js';
12
13
  export * from './mem-signal-core.js';
14
+ export * from './signal-cleanup-core.js';
13
15
  export * from './mem-start-core.js';
14
16
  export { filterSummarizableNodes, summarizeWu, getCompactionRatio as getSummarizeCompactionRatio, } from './mem-summarize-core.js';
15
17
  export * from './mem-triage-core.js';
18
+ export * from './mem-index-core.js';
19
+ export * from './mem-promote-core.js';
20
+ export * from './mem-profile-core.js';
21
+ export * from './mem-delete-core.js';
16
22
  export * from './memory-schema.js';
17
23
  export * from './memory-store.js';
24
+ // WU-1238: Decay scoring and access tracking
25
+ export * from './decay/scoring.js';
26
+ export * from './decay/access-tracking.js';
27
+ export * from './decay/archival.js';
@@ -11,9 +11,9 @@
11
11
  * - Auto-initializes memory layer if not present
12
12
  * - WU-1748: Persists to wu-events.jsonl for cross-agent visibility
13
13
  *
14
- * @see {@link tools/mem-checkpoint.mjs} - CLI wrapper
15
- * @see {@link tools/__tests__/mem-checkpoint.test.mjs} - Tests
16
- * @see {@link tools/lib/memory-schema.mjs} - Schema definitions
14
+ * @see {@link packages/@lumenflow/cli/src/mem-checkpoint.ts} - CLI wrapper
15
+ * @see {@link packages/@lumenflow/cli/src/__tests__/mem-checkpoint.test.ts} - Tests
16
+ * @see {@link packages/@lumenflow/cli/src/lib/memory-schema.ts} - Schema definitions
17
17
  */
18
18
  import fs from 'node:fs/promises';
19
19
  import path from 'node:path';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Memory Cleanup Core (WU-1472, WU-1554)
2
+ * Memory Cleanup Core (WU-1472, WU-1554, WU-1238)
3
3
  *
4
4
  * Prune closed memory nodes based on lifecycle policy.
5
5
  * Implements compaction to prevent memory bloat.
@@ -13,6 +13,7 @@
13
13
  * - Report compaction metrics (ratio, bytes freed)
14
14
  * - WU-1554: TTL-based expiration for old nodes
15
15
  * - WU-1554: Active session protection regardless of age
16
+ * - WU-1238: Decay-based archival for stale nodes
16
17
  *
17
18
  * Lifecycle Policy:
18
19
  * - ephemeral: Always removed (scratch pad data)
@@ -25,15 +26,23 @@
25
26
  * - Active sessions (status: 'active') are never removed
26
27
  * - Project and sensitive nodes are protected from TTL removal
27
28
  *
28
- * @see {@link tools/mem-cleanup.mjs} - CLI wrapper
29
- * @see {@link tools/__tests__/mem-cleanup.test.mjs} - Tests
29
+ * Decay Policy (WU-1238):
30
+ * - Nodes with decay score below threshold are archived (not deleted)
31
+ * - Project lifecycle nodes are never archived
32
+ * - Already archived nodes are skipped
33
+ *
34
+ * @see {@link packages/@lumenflow/cli/src/mem-cleanup.ts} - CLI wrapper
35
+ * @see {@link packages/@lumenflow/cli/src/__tests__/mem-cleanup.test.ts} - Tests
30
36
  */
31
37
  import fs from 'node:fs/promises';
32
38
  import path from 'node:path';
33
- // eslint-disable-next-line @typescript-eslint/no-require-imports
39
+ import { createRequire } from 'node:module';
40
+ const require = createRequire(import.meta.url);
34
41
  const ms = require('ms');
35
- import { loadMemory, MEMORY_FILE_NAME } from './memory-store.js';
42
+ import { loadMemoryAll, MEMORY_FILE_NAME } from './memory-store.js';
36
43
  import { LUMENFLOW_MEMORY_PATHS } from './paths.js';
44
+ import { archiveByDecay, DEFAULT_DECAY_THRESHOLD, } from './decay/archival.js';
45
+ import { DEFAULT_HALF_LIFE_MS } from './decay/scoring.js';
37
46
  /**
38
47
  * Lifecycle policy definitions
39
48
  *
@@ -299,17 +308,33 @@ async function writeRetainedNodes(memoryDir, retainedNodes) {
299
308
  * // WU-1554: TTL-based cleanup
300
309
  * const result = await cleanupMemory(baseDir, { ttl: '30d' });
301
310
  * console.log(`Removed ${result.breakdown.ttlExpired} expired nodes`);
311
+ *
312
+ * @example
313
+ * // WU-1238: Decay-based archival
314
+ * const result = await cleanupMemory(baseDir, { decay: true, decayThreshold: 0.1 });
315
+ * console.log(`Archived ${result.breakdown.decayArchived} stale nodes`);
302
316
  */
303
317
  export async function cleanupMemory(baseDir, options = {}) {
304
- const { dryRun = false, sessionId, ttl, ttlMs: providedTtlMs, now = Date.now() } = options;
318
+ const { dryRun = false, sessionId, ttl, ttlMs: providedTtlMs, now = Date.now(), decay = false, decayThreshold = DEFAULT_DECAY_THRESHOLD, halfLifeMs = DEFAULT_HALF_LIFE_MS, } = options;
305
319
  const memoryDir = path.join(baseDir, LUMENFLOW_MEMORY_PATHS.MEMORY_DIR);
306
320
  // WU-1554: Parse TTL if provided as string
307
321
  let ttlMs = providedTtlMs;
308
322
  if (ttl && !ttlMs) {
309
323
  ttlMs = parseTtl(ttl);
310
324
  }
311
- // Load existing memory
312
- const memory = await loadMemory(memoryDir);
325
+ // WU-1238: Handle decay-based archival if requested
326
+ let decayResult;
327
+ if (decay) {
328
+ decayResult = await archiveByDecay(memoryDir, {
329
+ threshold: decayThreshold,
330
+ now,
331
+ halfLifeMs,
332
+ dryRun,
333
+ });
334
+ }
335
+ // Load existing memory (includes all nodes for policy-based cleanup)
336
+ // Note: We need to include archived nodes to properly track retained vs removed
337
+ const memory = await loadMemoryAll(memoryDir);
313
338
  // Track cleanup decisions
314
339
  const removedIds = [];
315
340
  const retainedIds = [];
@@ -322,6 +347,7 @@ export async function cleanupMemory(baseDir, options = {}) {
322
347
  sensitive: 0,
323
348
  ttlExpired: 0,
324
349
  activeSessionProtected: 0,
350
+ decayArchived: decayResult?.archivedIds.length ?? 0,
325
351
  };
326
352
  // Process each node
327
353
  for (const node of memory.nodes) {
@@ -349,6 +375,10 @@ export async function cleanupMemory(baseDir, options = {}) {
349
375
  if (ttlMs) {
350
376
  baseResult.ttlMs = ttlMs;
351
377
  }
378
+ // WU-1238: Add decay result if present
379
+ if (decayResult) {
380
+ baseResult.decayResult = decayResult;
381
+ }
352
382
  // If dry-run, return preview without modifications
353
383
  if (dryRun) {
354
384
  return { ...baseResult, dryRun: true };
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Memory Context Core (WU-1234, WU-1238, WU-1281)
3
+ *
4
+ * Core logic for generating deterministic, formatted context injection blocks
5
+ * for wu:spawn prompts. Produces structured markdown suitable for embedding
6
+ * into agent spawn prompts.
7
+ *
8
+ * Features:
9
+ * - Deterministic selection: filter by lifecycle, wu_id, recency
10
+ * - Structured output with clear sections (Project Profile, Summaries, WU Context, Discoveries)
11
+ * - Max context size configuration (default 4KB)
12
+ * - Graceful degradation (empty block if no memories match)
13
+ * - No LLM calls (vendor-agnostic)
14
+ * - WU-1238: Optional decay-based ranking (sortByDecay option)
15
+ * - WU-1238: Access tracking for nodes included in context (trackAccess option)
16
+ * - WU-1281: Lane filtering for project memories
17
+ * - WU-1281: Recency limits for summaries (maxRecentSummaries)
18
+ * - WU-1281: Bounded project nodes (maxProjectNodes)
19
+ * - WU-1281: WU-specific context prioritized over project-level
20
+ *
21
+ * @see {@link packages/@lumenflow/cli/src/mem-context.ts} - CLI wrapper
22
+ * @see {@link packages/@lumenflow/memory/__tests__/mem-context-core.test.ts} - Tests
23
+ */
24
+ import path from 'node:path';
25
+ import { loadMemory } from './memory-store.js';
26
+ import { MEMORY_PATTERNS } from './memory-schema.js';
27
+ import { LUMENFLOW_MEMORY_PATHS } from './paths.js';
28
+ import { computeDecayScore, DEFAULT_HALF_LIFE_MS } from './decay/scoring.js';
29
+ import { recordAccessBatch } from './decay/access-tracking.js';
30
+ /**
31
+ * Default maximum context size in bytes (4KB)
32
+ */
33
+ const DEFAULT_MAX_SIZE = 4096;
34
+ /**
35
+ * WU-1281: Default maximum number of project nodes to include
36
+ * Prevents unbounded project memory growth from truncating WU-specific context
37
+ */
38
+ const DEFAULT_MAX_PROJECT_NODES = 10;
39
+ /**
40
+ * WU-1281: Default maximum number of recent summaries to include
41
+ * Ensures only the most relevant recent summaries are included
42
+ */
43
+ const DEFAULT_MAX_RECENT_SUMMARIES = 5;
44
+ /**
45
+ * Error messages for validation
46
+ */
47
+ const ERROR_MESSAGES = {
48
+ WU_ID_REQUIRED: 'wuId is required',
49
+ WU_ID_EMPTY: 'wuId cannot be empty',
50
+ WU_ID_INVALID: 'Invalid WU ID format. Expected pattern: WU-XXX (e.g., WU-1234)',
51
+ };
52
+ /**
53
+ * Section headers for the context block
54
+ */
55
+ const SECTION_HEADERS = {
56
+ PROJECT_PROFILE: '## Project Profile',
57
+ SUMMARIES: '## Summaries',
58
+ WU_CONTEXT: '## WU Context',
59
+ DISCOVERIES: '## Discoveries',
60
+ };
61
+ /**
62
+ * Validates WU ID format
63
+ *
64
+ * @param wuId - WU ID to validate
65
+ * @throws If WU ID is invalid
66
+ */
67
+ function validateWuId(wuId) {
68
+ if (wuId === undefined || wuId === null) {
69
+ throw new Error(ERROR_MESSAGES.WU_ID_REQUIRED);
70
+ }
71
+ if (wuId === '') {
72
+ throw new Error(ERROR_MESSAGES.WU_ID_EMPTY);
73
+ }
74
+ if (!MEMORY_PATTERNS.WU_ID.test(wuId)) {
75
+ throw new Error(ERROR_MESSAGES.WU_ID_INVALID);
76
+ }
77
+ }
78
+ /**
79
+ * Comparator for sorting nodes by recency (most recent first)
80
+ * Uses created_at as primary sort, id as secondary for stability
81
+ */
82
+ function compareByRecency(a, b) {
83
+ const aTime = new Date(a.created_at).getTime();
84
+ const bTime = new Date(b.created_at).getTime();
85
+ // Most recent first
86
+ if (aTime !== bTime) {
87
+ return bTime - aTime;
88
+ }
89
+ // Stable sort by ID for identical timestamps
90
+ return a.id.localeCompare(b.id);
91
+ }
92
+ /**
93
+ * WU-1238: Create comparator for sorting nodes by decay score (highest first)
94
+ * Uses decay score as primary sort, id as secondary for stability
95
+ */
96
+ function createCompareByDecay(now, halfLifeMs) {
97
+ return (a, b) => {
98
+ const aScore = computeDecayScore(a, { now, halfLifeMs });
99
+ const bScore = computeDecayScore(b, { now, halfLifeMs });
100
+ // Highest score first
101
+ if (aScore !== bScore) {
102
+ return bScore - aScore;
103
+ }
104
+ // Stable sort by ID for identical scores
105
+ return a.id.localeCompare(b.id);
106
+ };
107
+ }
108
+ /**
109
+ * Filters nodes by lifecycle
110
+ */
111
+ function filterByLifecycle(nodes, lifecycle) {
112
+ return nodes.filter((node) => node.lifecycle === lifecycle);
113
+ }
114
+ /**
115
+ * Filters nodes by WU ID
116
+ */
117
+ function filterByWuId(nodes, wuId) {
118
+ return nodes.filter((node) => node.wu_id === wuId);
119
+ }
120
+ /**
121
+ * Filters nodes by type
122
+ */
123
+ function filterByType(nodes, type) {
124
+ return nodes.filter((node) => node.type === type);
125
+ }
126
+ /**
127
+ * WU-1281: Filters nodes by lane
128
+ * Includes nodes that either:
129
+ * - Have matching metadata.lane
130
+ * - Have no lane set (general project knowledge)
131
+ */
132
+ function filterByLane(nodes, lane) {
133
+ if (!lane) {
134
+ // No lane filter specified, include all
135
+ return nodes;
136
+ }
137
+ return nodes.filter((node) => {
138
+ const nodeLane = node.metadata?.lane;
139
+ // Include if no lane (general knowledge) or lane matches
140
+ return !nodeLane || nodeLane === lane;
141
+ });
142
+ }
143
+ /**
144
+ * WU-1281: Limits array to first N items
145
+ */
146
+ function limitNodes(nodes, limit) {
147
+ if (limit === undefined || limit <= 0) {
148
+ return nodes;
149
+ }
150
+ return nodes.slice(0, limit);
151
+ }
152
+ /**
153
+ * Formats a single node for display
154
+ */
155
+ function formatNode(node) {
156
+ const timestamp = new Date(node.created_at).toISOString().split('T')[0];
157
+ return `- [${node.id}] (${timestamp}): ${node.content}`;
158
+ }
159
+ /**
160
+ * Formats a section with header and nodes
161
+ */
162
+ function formatSection(header, nodes) {
163
+ if (nodes.length === 0) {
164
+ return '';
165
+ }
166
+ const lines = [header, ''];
167
+ for (const node of nodes) {
168
+ lines.push(formatNode(node));
169
+ }
170
+ lines.push('');
171
+ return lines.join('\n');
172
+ }
173
+ /**
174
+ * Builds the context block header comment
175
+ * Note: Header does not include timestamp to ensure deterministic output
176
+ */
177
+ function buildHeader(wuId) {
178
+ return `<!-- mem:context for ${wuId} -->\n\n`;
179
+ }
180
+ /**
181
+ * Creates an empty context result
182
+ */
183
+ function createEmptyResult() {
184
+ return {
185
+ success: true,
186
+ contextBlock: '',
187
+ stats: {
188
+ totalNodes: 0,
189
+ byType: {},
190
+ truncated: false,
191
+ size: 0,
192
+ },
193
+ };
194
+ }
195
+ /**
196
+ * Adds a section to the sections array if it has content
197
+ */
198
+ function addSectionIfNotEmpty(sections, header, nodes) {
199
+ const section = formatSection(header, nodes);
200
+ if (section) {
201
+ sections.push(section);
202
+ }
203
+ }
204
+ /**
205
+ * Truncates the context block to fit within max size
206
+ * Removes nodes from the end while keeping structure intact
207
+ */
208
+ function truncateToSize(contextBlock, maxSize) {
209
+ if (contextBlock.length <= maxSize) {
210
+ return { content: contextBlock, truncated: false };
211
+ }
212
+ // Find the last complete section that fits
213
+ const lines = contextBlock.split('\n');
214
+ let currentSize = 0;
215
+ const truncatedLines = [];
216
+ let truncated = false;
217
+ for (const line of lines) {
218
+ const lineSize = line.length + 1; // +1 for newline
219
+ if (currentSize + lineSize > maxSize) {
220
+ truncated = true;
221
+ break;
222
+ }
223
+ truncatedLines.push(line);
224
+ currentSize += lineSize;
225
+ }
226
+ // Add truncation marker if truncated
227
+ if (truncated) {
228
+ truncatedLines.push('');
229
+ truncatedLines.push('<!-- truncated - context exceeded max size -->');
230
+ }
231
+ return { content: truncatedLines.join('\n'), truncated };
232
+ }
233
+ /**
234
+ * Generates a deterministic, formatted context injection block.
235
+ *
236
+ * Context block includes:
237
+ * 1. Project profile items (lifecycle=project memories)
238
+ * 2. Recent summaries relevant to the target WU
239
+ * 3. Current WU context (checkpoints, notes with wu_id match)
240
+ * 4. Open discoveries for the WU
241
+ *
242
+ * Selection is deterministic (filter by lifecycle, WU, tags, recency).
243
+ * No LLM calls are made (vendor-agnostic).
244
+ *
245
+ * @param baseDir - Base directory containing .lumenflow/memory
246
+ * @param options - Generation options
247
+ * @returns Result with context block and statistics
248
+ * @throws If WU ID is invalid or memory file is malformed
249
+ *
250
+ * @example
251
+ * const result = await generateContext('/path/to/project', {
252
+ * wuId: 'WU-1234',
253
+ * maxSize: 8192, // 8KB
254
+ * });
255
+ * console.log(result.contextBlock);
256
+ */
257
+ export async function generateContext(baseDir, options) {
258
+ const { wuId, maxSize = DEFAULT_MAX_SIZE, sortByDecay = false, trackAccess = false, halfLifeMs = DEFAULT_HALF_LIFE_MS, now = Date.now(),
259
+ // WU-1281: New options for lane filtering and limits
260
+ lane, maxRecentSummaries = DEFAULT_MAX_RECENT_SUMMARIES, maxProjectNodes = DEFAULT_MAX_PROJECT_NODES, } = options;
261
+ // Validate WU ID
262
+ validateWuId(wuId);
263
+ const memoryDir = path.join(baseDir, LUMENFLOW_MEMORY_PATHS.MEMORY_DIR);
264
+ const allNodes = await loadNodesOrEmpty(memoryDir);
265
+ if (allNodes.length === 0) {
266
+ return createEmptyResult();
267
+ }
268
+ // WU-1238: Choose comparator based on sortByDecay option
269
+ const sortComparator = sortByDecay ? createCompareByDecay(now, halfLifeMs) : compareByRecency;
270
+ // WU-1281: Collect WU-specific nodes FIRST (high priority)
271
+ // These are always included before project-level content
272
+ const summaryNodes = limitNodes([...filterByWuId(filterByType(allNodes, 'summary'), wuId)].sort(sortComparator), maxRecentSummaries);
273
+ const wuContextNodes = [...filterByWuId(allNodes, wuId)]
274
+ .filter((node) => node.type !== 'summary' && node.type !== 'discovery')
275
+ .sort(sortComparator);
276
+ const discoveryNodes = [...filterByWuId(filterByType(allNodes, 'discovery'), wuId)].sort(sortComparator);
277
+ // WU-1281: Collect project nodes with lane filtering and limits (lower priority)
278
+ const projectNodes = limitNodes(filterByLane([...filterByLifecycle(allNodes, 'project')].sort(sortComparator), lane), maxProjectNodes);
279
+ // WU-1281: Build context block with WU-specific content FIRST
280
+ // Order: WU Context -> Summaries -> Discoveries -> Project Profile
281
+ // This ensures WU-specific content is preserved when truncation occurs
282
+ const sections = [buildHeader(wuId)];
283
+ // WU-specific sections first (high priority)
284
+ addSectionIfNotEmpty(sections, SECTION_HEADERS.WU_CONTEXT, wuContextNodes);
285
+ addSectionIfNotEmpty(sections, SECTION_HEADERS.SUMMARIES, summaryNodes);
286
+ addSectionIfNotEmpty(sections, SECTION_HEADERS.DISCOVERIES, discoveryNodes);
287
+ // Project-level section last (lower priority, may be truncated)
288
+ addSectionIfNotEmpty(sections, SECTION_HEADERS.PROJECT_PROFILE, projectNodes);
289
+ // If no sections have content (only header), return empty
290
+ if (sections.length === 1) {
291
+ return createEmptyResult();
292
+ }
293
+ const rawContextBlock = sections.join('');
294
+ const { content: contextBlock, truncated } = truncateToSize(rawContextBlock, maxSize);
295
+ // Calculate statistics based on the limited node sets
296
+ const selectedNodes = [...wuContextNodes, ...summaryNodes, ...discoveryNodes, ...projectNodes];
297
+ const byType = {};
298
+ for (const node of selectedNodes) {
299
+ byType[node.type] = (byType[node.type] || 0) + 1;
300
+ }
301
+ // WU-1238: Track access for selected nodes if requested
302
+ const accessTracked = await trackAccessIfEnabled(trackAccess, selectedNodes, memoryDir, now, halfLifeMs);
303
+ return {
304
+ success: true,
305
+ contextBlock,
306
+ stats: {
307
+ totalNodes: selectedNodes.length,
308
+ byType,
309
+ truncated,
310
+ size: contextBlock.length,
311
+ ...(trackAccess ? { accessTracked } : {}),
312
+ },
313
+ };
314
+ }
315
+ /**
316
+ * Loads memory nodes, returning empty array if directory doesn't exist
317
+ */
318
+ async function loadNodesOrEmpty(memoryDir) {
319
+ try {
320
+ const memory = await loadMemory(memoryDir);
321
+ return memory.nodes;
322
+ }
323
+ catch (error) {
324
+ const err = error;
325
+ if (err.code === 'ENOENT') {
326
+ return [];
327
+ }
328
+ throw error;
329
+ }
330
+ }
331
+ /**
332
+ * Tracks access for nodes if enabled, returns count of tracked nodes
333
+ */
334
+ async function trackAccessIfEnabled(trackAccess, nodes, memoryDir, now, halfLifeMs) {
335
+ if (!trackAccess || nodes.length === 0) {
336
+ return 0;
337
+ }
338
+ try {
339
+ const nodeIds = nodes.map((n) => n.id);
340
+ const tracked = await recordAccessBatch(memoryDir, nodeIds, { now, halfLifeMs });
341
+ return tracked.length;
342
+ }
343
+ catch {
344
+ // Access tracking is best-effort; don't fail context generation
345
+ return 0;
346
+ }
347
+ }
@@ -11,9 +11,9 @@
11
11
  * - Validates node against memory-schema
12
12
  * - Supports discovered-from relationship for provenance tracking
13
13
  *
14
- * @see {@link tools/mem-create.mjs} - CLI wrapper
15
- * @see {@link tools/__tests__/mem-create.test.mjs} - Tests
16
- * @see {@link tools/lib/memory-schema.mjs} - Schema definitions
14
+ * @see {@link packages/@lumenflow/cli/src/mem-create.ts} - CLI wrapper
15
+ * @see {@link packages/@lumenflow/cli/src/__tests__/mem-create.test.ts} - Tests
16
+ * @see {@link packages/@lumenflow/cli/src/lib/memory-schema.ts} - Schema definitions
17
17
  */
18
18
  import fs from 'node:fs/promises';
19
19
  import path from 'node:path';
@@ -244,7 +244,7 @@ function getLifecycleForType(type) {
244
244
  * @example
245
245
  * // Create a simple discovery node
246
246
  * const result = await createMemoryNode(baseDir, {
247
- * title: 'Found relevant file at src/utils.mjs',
247
+ * title: 'Found relevant file at src/utils.ts',
248
248
  * type: 'discovery',
249
249
  * wuId: 'WU-1469',
250
250
  * });