@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
|
@@ -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
|
|
15
|
-
* @see {@link
|
|
16
|
-
* @see {@link
|
|
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';
|
package/dist/mem-cleanup-core.js
CHANGED
|
@@ -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
|
-
*
|
|
29
|
-
*
|
|
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
|
-
|
|
39
|
+
import { createRequire } from 'node:module';
|
|
40
|
+
const require = createRequire(import.meta.url);
|
|
34
41
|
const ms = require('ms');
|
|
35
|
-
import {
|
|
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
|
-
//
|
|
312
|
-
|
|
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
|
+
}
|
package/dist/mem-create-core.js
CHANGED
|
@@ -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
|
|
15
|
-
* @see {@link
|
|
16
|
-
* @see {@link
|
|
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.
|
|
247
|
+
* title: 'Found relevant file at src/utils.ts',
|
|
248
248
|
* type: 'discovery',
|
|
249
249
|
* wuId: 'WU-1469',
|
|
250
250
|
* });
|