prjct-cli 1.7.3 → 1.7.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.4] - 2026-02-07
4
+
5
+ ### Bug Fixes
6
+
7
+ - add eviction policies to all in-memory caches (PRJ-288) (#143)
8
+
9
+
10
+ ## [1.7.4] - 2026-02-07
11
+
12
+ ### Bug Fixes
13
+ - **Add eviction policies to all in-memory caches (PRJ-288)**: Replaced unbounded Maps with TTLCache in SessionLogManager and ContextBuilder, capped PatternStore decision contexts at 20 with FIFO eviction, and added 90-day archival for stale decisions to prevent unbounded memory growth.
14
+
15
+ ### Implementation Details
16
+ - SessionLogManager: replaced 2 `Map` caches with `TTLCache` (maxSize: 50, TTL: 1hr)
17
+ - ContextBuilder: replaced `Map` + `_mtimes` + manual TTL with single `TTLCache<CachedFile>` (maxSize: 200, TTL: 5s), added project-switch detection
18
+ - PatternStore: added `afterLoad()` hook to truncate oversized contexts arrays, FIFO cap at 20 in `recordDecision()`, new `archiveStaleDecisions()` method for 90-day archival
19
+ - Exposed `archiveStaleDecisions()` via MemorySystem facade
20
+
21
+ ### Test Plan
22
+
23
+ #### For QA
24
+ 1. Run `bun test core/__tests__/agentic/cache-eviction.test.ts` — 12 new tests pass
25
+ 2. Run `bun test` — full suite (538 tests) passes with no regressions
26
+ 3. Run `bun run build` — compiles without errors
27
+ 4. Verify `prjct sync` and `prjct status` work normally
28
+
29
+ #### For Users
30
+ **What changed:** Internal optimization — in-memory caches now bounded to prevent memory growth during long sessions.
31
+ **How to use:** No user-facing changes.
32
+ **Breaking changes:** None
33
+
3
34
  ## [1.7.3] - 2026-02-07
4
35
 
5
36
  ### Bug Fixes
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Cache Eviction Policy Tests (PRJ-288)
3
+ * Verifies TTLCache integration, context caps, and archival.
4
+ */
5
+
6
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test'
7
+ import fs from 'node:fs/promises'
8
+ import path from 'node:path'
9
+ import { ContextBuilder } from '../../agentic/context-builder'
10
+ import { MemorySystem, PatternStore } from '../../agentic/memory-system'
11
+ import pathManager from '../../infrastructure/path-manager'
12
+ import { SessionLogManager } from '../../session/session-log-manager'
13
+ import { TTLCache } from '../../utils/cache'
14
+
15
+ const TEST_GLOBAL_BASE_DIR = path.join(process.cwd(), '.tmp', 'prjct-cli-cache-tests')
16
+ let testCounter = 0
17
+ const getTestProjectId = () => `test-cache-${Date.now()}-${++testCounter}`
18
+
19
+ describe('Cache Eviction Policies (PRJ-288)', () => {
20
+ beforeAll(async () => {
21
+ pathManager.setGlobalBaseDir(TEST_GLOBAL_BASE_DIR)
22
+ await fs.mkdir(TEST_GLOBAL_BASE_DIR, { recursive: true })
23
+ })
24
+
25
+ afterAll(async () => {
26
+ await fs.rm(TEST_GLOBAL_BASE_DIR, { recursive: true, force: true })
27
+ })
28
+
29
+ // ===========================================================================
30
+ // TTLCache unit tests
31
+ // ===========================================================================
32
+
33
+ describe('TTLCache', () => {
34
+ it('should evict oldest entries when maxSize exceeded', () => {
35
+ const cache = new TTLCache<string>({ maxSize: 3, ttl: 60_000 })
36
+
37
+ cache.set('a', 'val-a')
38
+ cache.set('b', 'val-b')
39
+ cache.set('c', 'val-c')
40
+ expect(cache.size).toBe(3)
41
+
42
+ cache.set('d', 'val-d')
43
+ expect(cache.size).toBe(3)
44
+ // 'a' was oldest, should be evicted
45
+ expect(cache.get('a')).toBeNull()
46
+ expect(cache.get('d')).toBe('val-d')
47
+ })
48
+
49
+ it('should expire entries after TTL', async () => {
50
+ const cache = new TTLCache<string>({ maxSize: 10, ttl: 50 })
51
+
52
+ cache.set('key', 'value')
53
+ expect(cache.get('key')).toBe('value')
54
+
55
+ await new Promise((r) => setTimeout(r, 80))
56
+ expect(cache.get('key')).toBeNull()
57
+ })
58
+
59
+ it('should prune expired entries', async () => {
60
+ const cache = new TTLCache<string>({ maxSize: 10, ttl: 50 })
61
+
62
+ cache.set('a', '1')
63
+ cache.set('b', '2')
64
+ await new Promise((r) => setTimeout(r, 80))
65
+
66
+ cache.set('c', '3') // fresh
67
+ const pruned = cache.prune()
68
+ expect(pruned).toBe(2)
69
+ expect(cache.size).toBe(1)
70
+ expect(cache.get('c')).toBe('3')
71
+ })
72
+ })
73
+
74
+ // ===========================================================================
75
+ // SessionLogManager
76
+ // ===========================================================================
77
+
78
+ describe('SessionLogManager', () => {
79
+ it('should use TTLCache with LRU eviction at 50 entries', () => {
80
+ const manager = new SessionLogManager()
81
+ // Verify clearCache works (exercises TTLCache.clear())
82
+ manager.clearCache()
83
+ })
84
+ })
85
+
86
+ // ===========================================================================
87
+ // ContextBuilder
88
+ // ===========================================================================
89
+
90
+ describe('ContextBuilder', () => {
91
+ it('should report stats from TTLCache', () => {
92
+ const builder = new ContextBuilder()
93
+ const stats = builder.getCacheStats()
94
+ expect(stats).toHaveProperty('size')
95
+ expect(stats).toHaveProperty('maxSize')
96
+ expect(stats).toHaveProperty('ttl')
97
+ expect(stats.maxSize).toBe(200)
98
+ expect(stats.ttl).toBe(5000)
99
+ })
100
+
101
+ it('should clear cache and reset projectId', () => {
102
+ const builder = new ContextBuilder()
103
+ builder.clearCache()
104
+ const stats = builder.getCacheStats()
105
+ expect(stats.size).toBe(0)
106
+ })
107
+
108
+ it('should invalidate specific cache entries', async () => {
109
+ const builder = new ContextBuilder()
110
+
111
+ // Write a temp file, batchRead to populate cache, then invalidate
112
+ const tmpDir = path.join(TEST_GLOBAL_BASE_DIR, 'ctx-test')
113
+ await fs.mkdir(tmpDir, { recursive: true })
114
+ const tmpFile = path.join(tmpDir, 'test.txt')
115
+ await fs.writeFile(tmpFile, 'hello')
116
+
117
+ const result = await builder.batchRead([tmpFile])
118
+ expect(result.get(tmpFile)).toBe('hello')
119
+
120
+ // Invalidate and verify
121
+ builder.invalidateCache(tmpFile)
122
+ // After invalidation, a fresh batchRead should re-read from disk
123
+ await fs.writeFile(tmpFile, 'updated')
124
+ const result2 = await builder.batchRead([tmpFile])
125
+ expect(result2.get(tmpFile)).toBe('updated')
126
+ })
127
+ })
128
+
129
+ // ===========================================================================
130
+ // PatternStore - contexts cap
131
+ // ===========================================================================
132
+
133
+ describe('PatternStore contexts cap', () => {
134
+ let TEST_PROJECT_ID: string
135
+
136
+ beforeEach(() => {
137
+ TEST_PROJECT_ID = getTestProjectId()
138
+ })
139
+
140
+ it('should cap decision contexts at 20 (FIFO)', async () => {
141
+ const store = new PatternStore()
142
+
143
+ // Record a decision with many unique contexts
144
+ for (let i = 0; i < 25; i++) {
145
+ await store.recordDecision(TEST_PROJECT_ID, 'test-key', 'test-value', `context-${i}`)
146
+ }
147
+
148
+ const patterns = await store.load(TEST_PROJECT_ID)
149
+ const decision = patterns.decisions['test-key']
150
+ expect(decision.contexts.length).toBeLessThanOrEqual(20)
151
+ // Should keep the latest contexts
152
+ expect(decision.contexts).toContain('context-24')
153
+ expect(decision.contexts).toContain('context-5')
154
+ // Oldest should be evicted
155
+ expect(decision.contexts).not.toContain('context-0')
156
+ })
157
+
158
+ it('should truncate oversized contexts on afterLoad', async () => {
159
+ const store = new PatternStore()
160
+ const projectId = getTestProjectId()
161
+
162
+ // Manually write a patterns file with oversized contexts
163
+ const filePath = path.join(
164
+ pathManager.getGlobalProjectPath(projectId),
165
+ 'memory',
166
+ 'patterns.json'
167
+ )
168
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
169
+
170
+ const oversizedPatterns = {
171
+ version: 1,
172
+ decisions: {
173
+ 'big-key': {
174
+ value: 'v',
175
+ count: 30,
176
+ firstSeen: '2025-01-01T00:00:00.000Z',
177
+ lastSeen: '2025-06-01T00:00:00.000Z',
178
+ confidence: 'high',
179
+ contexts: Array.from({ length: 50 }, (_, i) => `ctx-${i}`),
180
+ userConfirmed: true,
181
+ },
182
+ },
183
+ preferences: {},
184
+ workflows: {},
185
+ counters: {},
186
+ }
187
+ await fs.writeFile(filePath, JSON.stringify(oversizedPatterns, null, 2), 'utf-8')
188
+
189
+ // Load triggers afterLoad which should truncate
190
+ const patterns = await store.load(projectId)
191
+ expect(patterns.decisions['big-key'].contexts.length).toBe(20)
192
+ // Should keep the latest 20 (indices 30-49)
193
+ expect(patterns.decisions['big-key'].contexts[19]).toBe('ctx-49')
194
+ })
195
+ })
196
+
197
+ // ===========================================================================
198
+ // PatternStore - archival
199
+ // ===========================================================================
200
+
201
+ describe('PatternStore archival', () => {
202
+ it('should archive decisions older than 90 days', async () => {
203
+ const store = new PatternStore()
204
+ const projectId = getTestProjectId()
205
+
206
+ // Write patterns with a stale and an active decision
207
+ const filePath = path.join(
208
+ pathManager.getGlobalProjectPath(projectId),
209
+ 'memory',
210
+ 'patterns.json'
211
+ )
212
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
213
+
214
+ const now = new Date()
215
+ const staleDate = new Date(now.getTime() - 100 * 24 * 60 * 60 * 1000) // 100 days ago
216
+ const recentDate = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000) // 5 days ago
217
+
218
+ const patterns = {
219
+ version: 1,
220
+ decisions: {
221
+ 'stale-key': {
222
+ value: 'old',
223
+ count: 3,
224
+ firstSeen: staleDate.toISOString(),
225
+ lastSeen: staleDate.toISOString(),
226
+ confidence: 'medium',
227
+ contexts: ['ctx1'],
228
+ userConfirmed: false,
229
+ },
230
+ 'active-key': {
231
+ value: 'new',
232
+ count: 5,
233
+ firstSeen: recentDate.toISOString(),
234
+ lastSeen: recentDate.toISOString(),
235
+ confidence: 'high',
236
+ contexts: ['ctx2'],
237
+ userConfirmed: true,
238
+ },
239
+ },
240
+ preferences: {},
241
+ workflows: {},
242
+ counters: {},
243
+ }
244
+ await fs.writeFile(filePath, JSON.stringify(patterns, null, 2), 'utf-8')
245
+
246
+ // Reset store cache to force disk read
247
+ store.reset()
248
+ const archived = await store.archiveStaleDecisions(projectId)
249
+ expect(archived).toBe(1)
250
+
251
+ // Verify active decision remains
252
+ const updated = await store.load(projectId)
253
+ expect(updated.decisions['active-key']).toBeDefined()
254
+ expect(updated.decisions['stale-key']).toBeUndefined()
255
+
256
+ // Verify archive file was created
257
+ const archivePath = path.join(
258
+ pathManager.getGlobalProjectPath(projectId),
259
+ 'memory',
260
+ 'patterns-archive.json'
261
+ )
262
+ const archiveContent = JSON.parse(await fs.readFile(archivePath, 'utf-8'))
263
+ expect(archiveContent['stale-key']).toBeDefined()
264
+ expect(archiveContent['stale-key'].value).toBe('old')
265
+ })
266
+
267
+ it('should return 0 when no stale decisions exist', async () => {
268
+ const store = new PatternStore()
269
+ const projectId = getTestProjectId()
270
+
271
+ // Record a fresh decision
272
+ await store.recordDecision(projectId, 'fresh-key', 'fresh-val', 'ctx')
273
+ const archived = await store.archiveStaleDecisions(projectId)
274
+ expect(archived).toBe(0)
275
+ })
276
+ })
277
+
278
+ // ===========================================================================
279
+ // MemorySystem delegation
280
+ // ===========================================================================
281
+
282
+ describe('MemorySystem.archiveStaleDecisions', () => {
283
+ it('should delegate to PatternStore', async () => {
284
+ const ms = new MemorySystem()
285
+ const projectId = getTestProjectId()
286
+
287
+ // Just verify no crash - no stale data to archive
288
+ ms.resetState()
289
+ await ms.recordDecision(projectId, 'key', 'val', 'ctx')
290
+ const count = await ms.archiveStaleDecisions(projectId)
291
+ expect(count).toBe(0)
292
+ })
293
+ })
294
+ })
@@ -11,6 +11,7 @@ import configManager from '../infrastructure/config-manager'
11
11
  import pathManager from '../infrastructure/path-manager'
12
12
  import type { ContextPaths, ContextState, ProjectContext } from '../types'
13
13
  import { isNotFoundError } from '../types/fs'
14
+ import { TTLCache } from '../utils/cache'
14
15
 
15
16
  // Re-export types for convenience
16
17
  export type { ContextPaths, ContextState, ProjectContext } from '../types'
@@ -20,34 +21,22 @@ export type Paths = ContextPaths
20
21
  export type Context = ProjectContext
21
22
  export type State = ContextState
22
23
 
24
+ interface CachedFile {
25
+ content: string | null
26
+ mtime: number | null
27
+ }
28
+
23
29
  /**
24
30
  * Builds and caches project context for Claude decisions.
25
31
  * Features parallel reads, selective loading, and anti-hallucination mtime checks.
26
32
  */
27
33
  class ContextBuilder {
28
- private _cache: Map<string, string | null>
29
- private _cacheTimeout: number
30
- private _lastCacheTime: number | null
31
- private _mtimes: Map<string, number>
34
+ private _cache: TTLCache<CachedFile>
35
+ private _currentProjectId: string | null
32
36
 
33
37
  constructor() {
34
- // Session cache - cleared between commands or after timeout
35
- this._cache = new Map()
36
- // ANTI-HALLUCINATION: Reduced from 30s to 5s to prevent stale data
37
- this._cacheTimeout = 5000 // 5 seconds (was 30s - caused stale context issues)
38
- this._lastCacheTime = null
39
- // Track file modification times for additional staleness detection
40
- this._mtimes = new Map()
41
- }
42
-
43
- /**
44
- * Clear cache if stale or force clear
45
- */
46
- private _clearCacheIfStale(force: boolean = false): void {
47
- if (force || !this._lastCacheTime || Date.now() - this._lastCacheTime > this._cacheTimeout) {
48
- this._cache.clear()
49
- this._lastCacheTime = Date.now()
50
- }
38
+ this._cache = new TTLCache<CachedFile>({ ttl: 5000, maxSize: 200 })
39
+ this._currentProjectId = null
51
40
  }
52
41
 
53
42
  /**
@@ -57,6 +46,12 @@ class ContextBuilder {
57
46
  const projectId = await configManager.getProjectId(projectPath)
58
47
  const globalPath = pathManager.getGlobalProjectPath(projectId!)
59
48
 
49
+ // Clear cache on project switch
50
+ if (this._currentProjectId !== null && this._currentProjectId !== projectId) {
51
+ this._cache.clear()
52
+ }
53
+ this._currentProjectId = projectId
54
+
60
55
  return {
61
56
  // Project identification
62
57
  projectId,
@@ -94,8 +89,6 @@ class ContextBuilder {
94
89
  * Uses Promise.all() for 40-60% faster file I/O
95
90
  */
96
91
  async loadState(context: Context, onlyKeys: string[] | null = null): Promise<State> {
97
- this._clearCacheIfStale()
98
-
99
92
  const state: State = {}
100
93
  const entries = Object.entries(context.paths)
101
94
 
@@ -105,20 +98,16 @@ class ContextBuilder {
105
98
  // ANTI-HALLUCINATION: Verify mtime before trusting cache
106
99
  // Files can change between commands - stale cache causes hallucinations
107
100
  for (const [, filePath] of filteredEntries) {
108
- if (this._cache.has(filePath)) {
101
+ const cachedEntry = this._cache.get(filePath)
102
+ if (cachedEntry !== null) {
109
103
  try {
110
104
  const stat = await fs.stat(filePath)
111
- const cachedMtime = this._mtimes.get(filePath)
112
- if (!cachedMtime || stat.mtimeMs > cachedMtime) {
113
- // File changed since cached - invalidate
105
+ if (!cachedEntry.mtime || stat.mtimeMs > cachedEntry.mtime) {
114
106
  this._cache.delete(filePath)
115
- this._mtimes.delete(filePath)
116
107
  }
117
108
  } catch (error) {
118
- // File doesn't exist or access error - invalidate cache
119
109
  if (isNotFoundError(error)) {
120
110
  this._cache.delete(filePath)
121
- this._mtimes.delete(filePath)
122
111
  } else {
123
112
  throw error
124
113
  }
@@ -129,8 +118,9 @@ class ContextBuilder {
129
118
  // Separate cached vs uncached files
130
119
  const uncachedEntries: [string, string][] = []
131
120
  for (const [key, filePath] of filteredEntries) {
132
- if (this._cache.has(filePath)) {
133
- state[key] = this._cache.get(filePath)!
121
+ const cachedEntry = this._cache.get(filePath)
122
+ if (cachedEntry !== null) {
123
+ state[key] = cachedEntry.content
134
124
  } else {
135
125
  uncachedEntries.push([key, filePath])
136
126
  }
@@ -155,13 +145,9 @@ class ContextBuilder {
155
145
 
156
146
  const results = await Promise.all(readPromises)
157
147
 
158
- // Populate state and cache (with mtime for anti-hallucination)
159
148
  for (const { key, filePath, content, mtime } of results) {
160
149
  state[key] = content
161
- this._cache.set(filePath, content)
162
- if (mtime) {
163
- this._mtimes.set(filePath, mtime)
164
- }
150
+ this._cache.set(filePath, { content, mtime })
165
151
  }
166
152
  }
167
153
 
@@ -218,15 +204,14 @@ class ContextBuilder {
218
204
  * Utility for custom file sets
219
205
  */
220
206
  async batchRead(filePaths: string[]): Promise<Map<string, string | null>> {
221
- this._clearCacheIfStale()
222
-
223
207
  const results = new Map<string, string | null>()
224
208
  const uncachedPaths: string[] = []
225
209
 
226
210
  // Check cache first
227
211
  for (const filePath of filePaths) {
228
- if (this._cache.has(filePath)) {
229
- results.set(filePath, this._cache.get(filePath)!)
212
+ const cachedEntry = this._cache.get(filePath)
213
+ if (cachedEntry !== null) {
214
+ results.set(filePath, cachedEntry.content)
230
215
  } else {
231
216
  uncachedPaths.push(filePath)
232
217
  }
@@ -250,7 +235,7 @@ class ContextBuilder {
250
235
 
251
236
  for (const { filePath, content } of readResults) {
252
237
  results.set(filePath, content)
253
- this._cache.set(filePath, content)
238
+ this._cache.set(filePath, { content, mtime: null })
254
239
  }
255
240
  }
256
241
 
@@ -268,7 +253,8 @@ class ContextBuilder {
268
253
  * Force clear entire cache
269
254
  */
270
255
  clearCache(): void {
271
- this._clearCacheIfStale(true)
256
+ this._cache.clear()
257
+ this._currentProjectId = null
272
258
  }
273
259
 
274
260
  /**
@@ -289,12 +275,8 @@ class ContextBuilder {
289
275
  /**
290
276
  * Get cache stats (for debugging/metrics)
291
277
  */
292
- getCacheStats(): { size: number; lastRefresh: number | null; timeout: number } {
293
- return {
294
- size: this._cache.size,
295
- lastRefresh: this._lastCacheTime,
296
- timeout: this._cacheTimeout,
297
- }
278
+ getCacheStats(): { size: number; maxSize: number; ttl: number } {
279
+ return this._cache.stats()
298
280
  }
299
281
  }
300
282
 
@@ -351,6 +351,9 @@ export class HistoryStore {
351
351
  * Persistent learned preferences and decisions.
352
352
  */
353
353
  export class PatternStore extends CachedStore<Patterns> {
354
+ private static readonly MAX_CONTEXTS = 20
355
+ private static readonly ARCHIVE_AGE_DAYS = 90
356
+
354
357
  protected getFilename(): string {
355
358
  return 'patterns.json'
356
359
  }
@@ -365,6 +368,14 @@ export class PatternStore extends CachedStore<Patterns> {
365
368
  }
366
369
  }
367
370
 
371
+ protected afterLoad(patterns: Patterns): void {
372
+ for (const decision of Object.values(patterns.decisions)) {
373
+ if (decision.contexts.length > PatternStore.MAX_CONTEXTS) {
374
+ decision.contexts = decision.contexts.slice(-PatternStore.MAX_CONTEXTS)
375
+ }
376
+ }
377
+ }
378
+
368
379
  // Convenience alias for backward compatibility
369
380
  async loadPatterns(projectId: string): Promise<Patterns> {
370
381
  return this.load(projectId)
@@ -404,6 +415,9 @@ export class PatternStore extends CachedStore<Patterns> {
404
415
  decision.lastSeen = now
405
416
  if (context && !decision.contexts.includes(context)) {
406
417
  decision.contexts.push(context)
418
+ if (decision.contexts.length > PatternStore.MAX_CONTEXTS) {
419
+ decision.contexts = decision.contexts.slice(-PatternStore.MAX_CONTEXTS)
420
+ }
407
421
  }
408
422
  if (options.userConfirmed) {
409
423
  decision.userConfirmed = true
@@ -553,6 +567,50 @@ export class PatternStore extends CachedStore<Patterns> {
553
567
  preferences: Object.keys(patterns.preferences).length,
554
568
  }
555
569
  }
570
+
571
+ private _getArchivePath(projectId: string): string {
572
+ const basePath = path.join(pathManager.getGlobalProjectPath(projectId), 'memory')
573
+ return path.join(basePath, 'patterns-archive.json')
574
+ }
575
+
576
+ async archiveStaleDecisions(projectId: string): Promise<number> {
577
+ const patterns = await this.load(projectId)
578
+ const now = Date.now()
579
+ const cutoff = PatternStore.ARCHIVE_AGE_DAYS * 24 * 60 * 60 * 1000
580
+
581
+ const staleKeys: string[] = []
582
+ for (const [key, decision] of Object.entries(patterns.decisions)) {
583
+ const lastSeenMs = new Date(decision.lastSeen).getTime()
584
+ if (now - lastSeenMs > cutoff) {
585
+ staleKeys.push(key)
586
+ }
587
+ }
588
+
589
+ if (staleKeys.length === 0) return 0
590
+
591
+ // Load or create archive
592
+ const archivePath = this._getArchivePath(projectId)
593
+ let archive: Record<string, unknown> = {}
594
+ try {
595
+ const content = await fs.readFile(archivePath, 'utf-8')
596
+ archive = JSON.parse(content)
597
+ } catch (error) {
598
+ if (!isNotFoundError(error)) throw error
599
+ }
600
+
601
+ // Move stale decisions to archive
602
+ for (const key of staleKeys) {
603
+ archive[key] = patterns.decisions[key]
604
+ delete patterns.decisions[key]
605
+ }
606
+
607
+ // Save archive and pruned patterns
608
+ await fs.mkdir(path.dirname(archivePath), { recursive: true })
609
+ await fs.writeFile(archivePath, JSON.stringify(archive, null, 2), 'utf-8')
610
+ await this.save(projectId)
611
+
612
+ return staleKeys.length
613
+ }
556
614
  }
557
615
 
558
616
  // =============================================================================
@@ -1226,6 +1284,10 @@ export class MemorySystem {
1226
1284
  return this._patternStore.getPatternsSummary(projectId)
1227
1285
  }
1228
1286
 
1287
+ archiveStaleDecisions(projectId: string): Promise<number> {
1288
+ return this._patternStore.archiveStaleDecisions(projectId)
1289
+ }
1290
+
1229
1291
  // ===========================================================================
1230
1292
  // TIER 3: History
1231
1293
  // ===========================================================================
@@ -13,6 +13,7 @@ import type {
13
13
  SessionStats,
14
14
  } from '../types'
15
15
  import { getErrorMessage } from '../types/fs'
16
+ import { TTLCache } from '../utils/cache'
16
17
  import * as dateHelper from '../utils/date-helper'
17
18
  import * as fileHelper from '../utils/file-helper'
18
19
  import * as jsonlHelper from '../utils/jsonl-helper'
@@ -20,12 +21,12 @@ import { VERSION } from '../utils/version'
20
21
  import { migrateLegacyJsonl, migrateLegacyMarkdown } from './log-migration'
21
22
 
22
23
  export class SessionLogManager {
23
- private currentSessionCache: Map<string, string>
24
- private sessionMetadataCache: Map<string, SessionLogMetadata>
24
+ private currentSessionCache: TTLCache<string>
25
+ private sessionMetadataCache: TTLCache<SessionLogMetadata>
25
26
 
26
27
  constructor() {
27
- this.currentSessionCache = new Map()
28
- this.sessionMetadataCache = new Map()
28
+ this.currentSessionCache = new TTLCache<string>({ maxSize: 50, ttl: 3_600_000 })
29
+ this.sessionMetadataCache = new TTLCache<SessionLogMetadata>({ maxSize: 50, ttl: 3_600_000 })
29
30
  }
30
31
 
31
32
  /**
@@ -34,8 +35,9 @@ export class SessionLogManager {
34
35
  async getCurrentSession(projectId: string): Promise<string> {
35
36
  const cacheKey = `${projectId}-${this._getTodayKey()}`
36
37
 
37
- if (this.currentSessionCache.has(cacheKey)) {
38
- return this.currentSessionCache.get(cacheKey)!
38
+ const cached = this.currentSessionCache.get(cacheKey)
39
+ if (cached !== null) {
40
+ return cached
39
41
  }
40
42
 
41
43
  const sessionPath = await pathManager.ensureSessionPath(projectId)
@@ -243,8 +245,9 @@ export class SessionLogManager {
243
245
  private async _getSessionLogMetadata(sessionPath: string): Promise<SessionLogMetadata | null> {
244
246
  const metadataPath = path.join(sessionPath, 'session-meta.json')
245
247
 
246
- if (this.sessionMetadataCache.has(sessionPath)) {
247
- return this.sessionMetadataCache.get(sessionPath)!
248
+ const cached = this.sessionMetadataCache.get(sessionPath)
249
+ if (cached !== null) {
250
+ return cached
248
251
  }
249
252
 
250
253
  const metadata = await fileHelper.readJson<SessionLogMetadata>(metadataPath, null)
@@ -7572,28 +7572,16 @@ var init_context_builder = __esm({
7572
7572
  init_config_manager();
7573
7573
  init_path_manager();
7574
7574
  init_fs();
7575
+ init_cache();
7575
7576
  ContextBuilder = class {
7576
7577
  static {
7577
7578
  __name(this, "ContextBuilder");
7578
7579
  }
7579
7580
  _cache;
7580
- _cacheTimeout;
7581
- _lastCacheTime;
7582
- _mtimes;
7581
+ _currentProjectId;
7583
7582
  constructor() {
7584
- this._cache = /* @__PURE__ */ new Map();
7585
- this._cacheTimeout = 5e3;
7586
- this._lastCacheTime = null;
7587
- this._mtimes = /* @__PURE__ */ new Map();
7588
- }
7589
- /**
7590
- * Clear cache if stale or force clear
7591
- */
7592
- _clearCacheIfStale(force = false) {
7593
- if (force || !this._lastCacheTime || Date.now() - this._lastCacheTime > this._cacheTimeout) {
7594
- this._cache.clear();
7595
- this._lastCacheTime = Date.now();
7596
- }
7583
+ this._cache = new TTLCache({ ttl: 5e3, maxSize: 200 });
7584
+ this._currentProjectId = null;
7597
7585
  }
7598
7586
  /**
7599
7587
  * Build full project context for Claude
@@ -7601,6 +7589,10 @@ var init_context_builder = __esm({
7601
7589
  async build(projectPath, commandParams = {}) {
7602
7590
  const projectId = await config_manager_default.getProjectId(projectPath);
7603
7591
  const globalPath = path_manager_default.getGlobalProjectPath(projectId);
7592
+ if (this._currentProjectId !== null && this._currentProjectId !== projectId) {
7593
+ this._cache.clear();
7594
+ }
7595
+ this._currentProjectId = projectId;
7604
7596
  return {
7605
7597
  // Project identification
7606
7598
  projectId,
@@ -7636,23 +7628,20 @@ var init_context_builder = __esm({
7636
7628
  * Uses Promise.all() for 40-60% faster file I/O
7637
7629
  */
7638
7630
  async loadState(context2, onlyKeys = null) {
7639
- this._clearCacheIfStale();
7640
7631
  const state = {};
7641
7632
  const entries = Object.entries(context2.paths);
7642
7633
  const filteredEntries = onlyKeys ? entries.filter(([key]) => onlyKeys.includes(key)) : entries;
7643
7634
  for (const [, filePath] of filteredEntries) {
7644
- if (this._cache.has(filePath)) {
7635
+ const cachedEntry = this._cache.get(filePath);
7636
+ if (cachedEntry !== null) {
7645
7637
  try {
7646
7638
  const stat = await fs21.stat(filePath);
7647
- const cachedMtime = this._mtimes.get(filePath);
7648
- if (!cachedMtime || stat.mtimeMs > cachedMtime) {
7639
+ if (!cachedEntry.mtime || stat.mtimeMs > cachedEntry.mtime) {
7649
7640
  this._cache.delete(filePath);
7650
- this._mtimes.delete(filePath);
7651
7641
  }
7652
7642
  } catch (error) {
7653
7643
  if (isNotFoundError(error)) {
7654
7644
  this._cache.delete(filePath);
7655
- this._mtimes.delete(filePath);
7656
7645
  } else {
7657
7646
  throw error;
7658
7647
  }
@@ -7661,8 +7650,9 @@ var init_context_builder = __esm({
7661
7650
  }
7662
7651
  const uncachedEntries = [];
7663
7652
  for (const [key, filePath] of filteredEntries) {
7664
- if (this._cache.has(filePath)) {
7665
- state[key] = this._cache.get(filePath);
7653
+ const cachedEntry = this._cache.get(filePath);
7654
+ if (cachedEntry !== null) {
7655
+ state[key] = cachedEntry.content;
7666
7656
  } else {
7667
7657
  uncachedEntries.push([key, filePath]);
7668
7658
  }
@@ -7685,10 +7675,7 @@ var init_context_builder = __esm({
7685
7675
  const results = await Promise.all(readPromises);
7686
7676
  for (const { key, filePath, content, mtime } of results) {
7687
7677
  state[key] = content;
7688
- this._cache.set(filePath, content);
7689
- if (mtime) {
7690
- this._mtimes.set(filePath, mtime);
7691
- }
7678
+ this._cache.set(filePath, { content, mtime });
7692
7679
  }
7693
7680
  }
7694
7681
  return state;
@@ -7733,12 +7720,12 @@ var init_context_builder = __esm({
7733
7720
  * Utility for custom file sets
7734
7721
  */
7735
7722
  async batchRead(filePaths) {
7736
- this._clearCacheIfStale();
7737
7723
  const results = /* @__PURE__ */ new Map();
7738
7724
  const uncachedPaths = [];
7739
7725
  for (const filePath of filePaths) {
7740
- if (this._cache.has(filePath)) {
7741
- results.set(filePath, this._cache.get(filePath));
7726
+ const cachedEntry = this._cache.get(filePath);
7727
+ if (cachedEntry !== null) {
7728
+ results.set(filePath, cachedEntry.content);
7742
7729
  } else {
7743
7730
  uncachedPaths.push(filePath);
7744
7731
  }
@@ -7758,7 +7745,7 @@ var init_context_builder = __esm({
7758
7745
  const readResults = await Promise.all(readPromises);
7759
7746
  for (const { filePath, content } of readResults) {
7760
7747
  results.set(filePath, content);
7761
- this._cache.set(filePath, content);
7748
+ this._cache.set(filePath, { content, mtime: null });
7762
7749
  }
7763
7750
  }
7764
7751
  return results;
@@ -7773,7 +7760,8 @@ var init_context_builder = __esm({
7773
7760
  * Force clear entire cache
7774
7761
  */
7775
7762
  clearCache() {
7776
- this._clearCacheIfStale(true);
7763
+ this._cache.clear();
7764
+ this._currentProjectId = null;
7777
7765
  }
7778
7766
  /**
7779
7767
  * Check file existence
@@ -7793,11 +7781,7 @@ var init_context_builder = __esm({
7793
7781
  * Get cache stats (for debugging/metrics)
7794
7782
  */
7795
7783
  getCacheStats() {
7796
- return {
7797
- size: this._cache.size,
7798
- lastRefresh: this._lastCacheTime,
7799
- timeout: this._cacheTimeout
7800
- };
7784
+ return this._cache.stats();
7801
7785
  }
7802
7786
  };
7803
7787
  contextBuilder = new ContextBuilder();
@@ -10094,10 +10078,12 @@ var init_memory_system = __esm({
10094
10078
  return getLastJsonLines(sessionPath, limit);
10095
10079
  }
10096
10080
  };
10097
- PatternStore = class extends CachedStore {
10081
+ PatternStore = class _PatternStore extends CachedStore {
10098
10082
  static {
10099
10083
  __name(this, "PatternStore");
10100
10084
  }
10085
+ static MAX_CONTEXTS = 20;
10086
+ static ARCHIVE_AGE_DAYS = 90;
10101
10087
  getFilename() {
10102
10088
  return "patterns.json";
10103
10089
  }
@@ -10110,6 +10096,13 @@ var init_memory_system = __esm({
10110
10096
  counters: {}
10111
10097
  };
10112
10098
  }
10099
+ afterLoad(patterns) {
10100
+ for (const decision of Object.values(patterns.decisions)) {
10101
+ if (decision.contexts.length > _PatternStore.MAX_CONTEXTS) {
10102
+ decision.contexts = decision.contexts.slice(-_PatternStore.MAX_CONTEXTS);
10103
+ }
10104
+ }
10105
+ }
10113
10106
  // Convenience alias for backward compatibility
10114
10107
  async loadPatterns(projectId) {
10115
10108
  return this.load(projectId);
@@ -10137,6 +10130,9 @@ var init_memory_system = __esm({
10137
10130
  decision.lastSeen = now;
10138
10131
  if (context2 && !decision.contexts.includes(context2)) {
10139
10132
  decision.contexts.push(context2);
10133
+ if (decision.contexts.length > _PatternStore.MAX_CONTEXTS) {
10134
+ decision.contexts = decision.contexts.slice(-_PatternStore.MAX_CONTEXTS);
10135
+ }
10140
10136
  }
10141
10137
  if (options.userConfirmed) {
10142
10138
  decision.userConfirmed = true;
@@ -10246,6 +10242,39 @@ var init_memory_system = __esm({
10246
10242
  preferences: Object.keys(patterns.preferences).length
10247
10243
  };
10248
10244
  }
10245
+ _getArchivePath(projectId) {
10246
+ const basePath = path22.join(path_manager_default.getGlobalProjectPath(projectId), "memory");
10247
+ return path22.join(basePath, "patterns-archive.json");
10248
+ }
10249
+ async archiveStaleDecisions(projectId) {
10250
+ const patterns = await this.load(projectId);
10251
+ const now = Date.now();
10252
+ const cutoff = _PatternStore.ARCHIVE_AGE_DAYS * 24 * 60 * 60 * 1e3;
10253
+ const staleKeys = [];
10254
+ for (const [key, decision] of Object.entries(patterns.decisions)) {
10255
+ const lastSeenMs = new Date(decision.lastSeen).getTime();
10256
+ if (now - lastSeenMs > cutoff) {
10257
+ staleKeys.push(key);
10258
+ }
10259
+ }
10260
+ if (staleKeys.length === 0) return 0;
10261
+ const archivePath = this._getArchivePath(projectId);
10262
+ let archive = {};
10263
+ try {
10264
+ const content = await fs24.readFile(archivePath, "utf-8");
10265
+ archive = JSON.parse(content);
10266
+ } catch (error) {
10267
+ if (!isNotFoundError(error)) throw error;
10268
+ }
10269
+ for (const key of staleKeys) {
10270
+ archive[key] = patterns.decisions[key];
10271
+ delete patterns.decisions[key];
10272
+ }
10273
+ await fs24.mkdir(path22.dirname(archivePath), { recursive: true });
10274
+ await fs24.writeFile(archivePath, JSON.stringify(archive, null, 2), "utf-8");
10275
+ await this.save(projectId);
10276
+ return staleKeys.length;
10277
+ }
10249
10278
  };
10250
10279
  SemanticMemories = class extends CachedStore {
10251
10280
  static {
@@ -10727,6 +10756,9 @@ Context: ${context2}` : ""}`,
10727
10756
  getPatternsSummary(projectId) {
10728
10757
  return this._patternStore.getPatternsSummary(projectId);
10729
10758
  }
10759
+ archiveStaleDecisions(projectId) {
10760
+ return this._patternStore.archiveStaleDecisions(projectId);
10761
+ }
10730
10762
  // ===========================================================================
10731
10763
  // TIER 3: History
10732
10764
  // ===========================================================================
@@ -29110,7 +29142,7 @@ var require_package = __commonJS({
29110
29142
  "package.json"(exports, module) {
29111
29143
  module.exports = {
29112
29144
  name: "prjct-cli",
29113
- version: "1.7.3",
29145
+ version: "1.7.4",
29114
29146
  description: "Context layer for AI agents. Project context for Claude Code, Gemini CLI, and more.",
29115
29147
  main: "core/index.ts",
29116
29148
  bin: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prjct-cli",
3
- "version": "1.7.3",
3
+ "version": "1.7.4",
4
4
  "description": "Context layer for AI agents. Project context for Claude Code, Gemini CLI, and more.",
5
5
  "main": "core/index.ts",
6
6
  "bin": {