@rlabs-inc/memory 0.3.5 → 0.3.6

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,484 @@
1
+ // ============================================================================
2
+ // MEMORY MANAGER - Post-curation memory lifecycle management
3
+ // Spawns a management agent to update, supersede, and organize memories
4
+ // Mirrors Curator pattern exactly
5
+ // ============================================================================
6
+
7
+ import { join } from 'path'
8
+ import { homedir } from 'os'
9
+ import { existsSync } from 'fs'
10
+ import type { CurationResult } from '../types/memory.ts'
11
+
12
+ /**
13
+ * Get the Claude CLI command path
14
+ * Same logic as curator.ts
15
+ */
16
+ function getClaudeCommand(): string {
17
+ const envCommand = process.env.CURATOR_COMMAND
18
+ if (envCommand) return envCommand
19
+
20
+ const claudeLocal = join(homedir(), '.claude', 'local', 'claude')
21
+ if (existsSync(claudeLocal)) return claudeLocal
22
+
23
+ return 'claude'
24
+ }
25
+
26
+ /**
27
+ * Manager configuration
28
+ */
29
+ export interface ManagerConfig {
30
+ /**
31
+ * Enable the management agent
32
+ * When disabled, memories are stored but not organized/linked
33
+ * Default: true
34
+ */
35
+ enabled?: boolean
36
+
37
+ /**
38
+ * CLI command to use (for subprocess mode)
39
+ * Default: auto-detected (~/.claude/local/claude or 'claude')
40
+ */
41
+ cliCommand?: string
42
+
43
+ /**
44
+ * Maximum turns for the management agent
45
+ * Set to undefined for unlimited turns
46
+ * Default: undefined (unlimited)
47
+ */
48
+ maxTurns?: number
49
+ }
50
+
51
+ /**
52
+ * Storage paths for the management agent
53
+ * These are resolved at runtime from the server's actual configuration
54
+ */
55
+ export interface StoragePaths {
56
+ /**
57
+ * Root path to project storage directory (NOT the memories subdirectory)
58
+ * e.g., ~/.local/share/memory/memory-ts/ (central)
59
+ * or /path/to/project/.memory/ (local)
60
+ */
61
+ projectPath: string
62
+
63
+ /**
64
+ * Root path to global storage directory (NOT the memories subdirectory)
65
+ * Always ~/.local/share/memory/global/
66
+ */
67
+ globalPath: string
68
+
69
+ /**
70
+ * Full path to project memories directory
71
+ * e.g., ~/.local/share/memory/memory-ts/memories/ (central)
72
+ * or /path/to/project/.memory/memories/ (local)
73
+ */
74
+ projectMemoriesPath: string
75
+
76
+ /**
77
+ * Full path to global memories directory
78
+ * Always ~/.local/share/memory/global/memories/
79
+ */
80
+ globalMemoriesPath: string
81
+
82
+ /**
83
+ * Full path to personal primer file
84
+ * Always ~/.local/share/memory/global/memories/personal-primer.md
85
+ */
86
+ personalPrimerPath: string
87
+
88
+ /**
89
+ * Storage mode for context
90
+ */
91
+ storageMode: 'central' | 'local'
92
+ }
93
+
94
+ /**
95
+ * Management result - what the agent did
96
+ */
97
+ export interface ManagementResult {
98
+ success: boolean
99
+ superseded: number
100
+ resolved: number
101
+ linked: number
102
+ filesRead: number
103
+ filesWritten: number
104
+ primerUpdated: boolean
105
+ actions: string[] // Detailed action log lines
106
+ summary: string // Brief summary for storage
107
+ fullReport: string // Complete management report (ACTIONS + SUMMARY sections)
108
+ error?: string
109
+ }
110
+
111
+ /**
112
+ * Memory Manager - Updates and organizes memories after curation
113
+ * Mirrors Curator class structure
114
+ */
115
+ export class Manager {
116
+ private _config: {
117
+ enabled: boolean
118
+ cliCommand: string
119
+ maxTurns?: number // undefined = unlimited
120
+ }
121
+
122
+ constructor(config: ManagerConfig = {}) {
123
+ this._config = {
124
+ enabled: config.enabled ?? true,
125
+ cliCommand: config.cliCommand ?? getClaudeCommand(),
126
+ maxTurns: config.maxTurns, // undefined = unlimited turns
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Build the management prompt
132
+ * Loads from skills file
133
+ */
134
+ async buildManagementPrompt(): Promise<string | null> {
135
+ const skillPaths = [
136
+ // Development - relative to src/core
137
+ join(import.meta.dir, '../../skills/memory-management.md'),
138
+ // Installed via bun global
139
+ join(homedir(), '.bun/install/global/node_modules/@rlabs-inc/memory/skills/memory-management.md'),
140
+ // Installed via npm global
141
+ join(homedir(), '.npm/global/node_modules/@rlabs-inc/memory/skills/memory-management.md'),
142
+ // Local node_modules
143
+ join(process.cwd(), 'node_modules/@rlabs-inc/memory/skills/memory-management.md'),
144
+ ]
145
+
146
+ for (const path of skillPaths) {
147
+ try {
148
+ const content = await Bun.file(path).text()
149
+ if (content) return content
150
+ } catch {
151
+ continue
152
+ }
153
+ }
154
+
155
+ return null
156
+ }
157
+
158
+ /**
159
+ * Build the user message with curation data
160
+ * Includes actual storage paths resolved at runtime
161
+ */
162
+ buildUserMessage(
163
+ projectId: string,
164
+ sessionNumber: number,
165
+ result: CurationResult,
166
+ storagePaths?: StoragePaths
167
+ ): string {
168
+ const today = new Date().toISOString().split('T')[0]
169
+
170
+ // Build storage paths section if provided
171
+ // Includes both root paths (for permissions context) and memories paths (for file operations)
172
+ const pathsSection = storagePaths ? `
173
+ ## Storage Paths (ACTUAL - use these exact paths)
174
+
175
+ **Storage Mode:** ${storagePaths.storageMode}
176
+
177
+ ### Project Storage
178
+ - **Project Root:** ${storagePaths.projectPath}
179
+ - **Project Memories:** ${storagePaths.projectMemoriesPath}
180
+
181
+ ### Global Storage (shared across all projects)
182
+ - **Global Root:** ${storagePaths.globalPath}
183
+ - **Global Memories:** ${storagePaths.globalMemoriesPath}
184
+ - **Personal Primer:** ${storagePaths.personalPrimerPath}
185
+
186
+ > ⚠️ These paths are resolved from the running server configuration. Use them exactly as provided.
187
+ > Memories are stored as individual markdown files in the memories directories.
188
+ ` : ''
189
+
190
+ return `## Curation Data
191
+
192
+ **Project ID:** ${projectId}
193
+ **Session Number:** ${sessionNumber}
194
+ **Date:** ${today}
195
+ ${pathsSection}
196
+ ### Session Summary
197
+ ${result.session_summary || 'No summary provided'}
198
+
199
+ ### Project Snapshot
200
+ ${result.project_snapshot ? `
201
+ - Current Phase: ${result.project_snapshot.current_phase || 'N/A'}
202
+ - Recent Achievements: ${result.project_snapshot.recent_achievements?.join(', ') || 'None'}
203
+ - Active Challenges: ${result.project_snapshot.active_challenges?.join(', ') || 'None'}
204
+ - Next Steps: ${result.project_snapshot.next_steps?.join(', ') || 'None'}
205
+ ` : 'No snapshot provided'}
206
+
207
+ ### New Memories (${result.memories.length})
208
+ ${result.memories.map((m, i) => `
209
+ #### Memory ${i + 1}
210
+ - **Content:** ${m.content}
211
+ - **Type:** ${m.context_type}
212
+ - **Scope:** ${m.scope || 'project'}
213
+ - **Domain:** ${m.domain || 'N/A'}
214
+ - **Importance:** ${m.importance_weight}
215
+ - **Tags:** ${m.semantic_tags?.join(', ') || 'None'}
216
+ `).join('\n')}
217
+
218
+ ---
219
+
220
+ Please process these memories according to your management procedure. Use the exact storage paths provided above to read and write memory files. Update, supersede, or link existing memories as needed. Update the personal primer if any personal memories warrant it.`
221
+ }
222
+
223
+ /**
224
+ * Parse management response from Claude
225
+ */
226
+ parseManagementResponse(responseJson: string): ManagementResult {
227
+ const emptyResult = (error?: string): ManagementResult => ({
228
+ success: !error,
229
+ superseded: 0,
230
+ resolved: 0,
231
+ linked: 0,
232
+ filesRead: 0,
233
+ filesWritten: 0,
234
+ primerUpdated: false,
235
+ actions: [],
236
+ summary: error ? '' : 'No actions taken',
237
+ fullReport: error ? `Error: ${error}` : '',
238
+ error,
239
+ })
240
+
241
+ try {
242
+ // First, parse the CLI JSON wrapper
243
+ const cliOutput = JSON.parse(responseJson)
244
+
245
+ // Check for error response
246
+ if (cliOutput.type === 'error' || cliOutput.is_error === true) {
247
+ return emptyResult(cliOutput.error || 'Unknown error')
248
+ }
249
+
250
+ // Extract the "result" field (AI's response text)
251
+ const resultText = typeof cliOutput.result === 'string' ? cliOutput.result : ''
252
+
253
+ // Extract the full report (everything from === MANAGEMENT ACTIONS === onwards)
254
+ const reportMatch = resultText.match(/(=== MANAGEMENT ACTIONS ===[\s\S]*)/)
255
+ const fullReport = reportMatch ? reportMatch[1].trim() : resultText
256
+
257
+ // Extract actions section
258
+ const actionsMatch = resultText.match(/=== MANAGEMENT ACTIONS ===([\s\S]*?)(?:=== SUMMARY ===|$)/)
259
+ const actions: string[] = []
260
+ if (actionsMatch) {
261
+ const actionsText = actionsMatch[1]
262
+ // Extract lines that look like actions (TYPE: description or TYPE OK/FAILED: path)
263
+ // Note: RECEIVED replaced CREATED - manager receives new memories from curator, doesn't create them
264
+ const actionLines = actionsText.split('\n')
265
+ .map((line: string) => line.trim())
266
+ .filter((line: string) => /^(READ|WRITE|RECEIVED|CREATED|UPDATED|SUPERSEDED|RESOLVED|LINKED|PRIMER|SKIPPED|NO_ACTION)/.test(line))
267
+ actions.push(...actionLines)
268
+ }
269
+
270
+ // Extract stats from result text
271
+ const supersededMatch = resultText.match(/memories_superseded[:\s]+(\d+)/i) || resultText.match(/superseded[:\s]+(\d+)/i)
272
+ const resolvedMatch = resultText.match(/memories_resolved[:\s]+(\d+)/i) || resultText.match(/resolved[:\s]+(\d+)/i)
273
+ const linkedMatch = resultText.match(/memories_linked[:\s]+(\d+)/i) || resultText.match(/linked[:\s]+(\d+)/i)
274
+ const filesReadMatch = resultText.match(/files_read[:\s]+(\d+)/i)
275
+ const filesWrittenMatch = resultText.match(/files_written[:\s]+(\d+)/i)
276
+ const primerUpdated = /primer_updated[:\s]+true/i.test(resultText) || /PRIMER\s+OK/i.test(resultText)
277
+
278
+ // Count file operations from actions if not in summary
279
+ const readActions = actions.filter((a: string) => a.startsWith('READ OK')).length
280
+ const writeActions = actions.filter((a: string) => a.startsWith('WRITE OK')).length
281
+
282
+ return {
283
+ success: true,
284
+ superseded: supersededMatch ? parseInt(supersededMatch[1]) : 0,
285
+ resolved: resolvedMatch ? parseInt(resolvedMatch[1]) : 0,
286
+ linked: linkedMatch ? parseInt(linkedMatch[1]) : 0,
287
+ filesRead: filesReadMatch ? parseInt(filesReadMatch[1]) : readActions,
288
+ filesWritten: filesWrittenMatch ? parseInt(filesWrittenMatch[1]) : writeActions,
289
+ primerUpdated,
290
+ actions,
291
+ summary: resultText.slice(0, 500), // Brief summary for storage
292
+ fullReport, // Complete report for logging
293
+ }
294
+ } catch {
295
+ return emptyResult('Failed to parse response')
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Build a temporary settings file with path-based permissions
301
+ *
302
+ * Claude CLI supports path restrictions via settings.json permissions, NOT via --allowedTools.
303
+ * Syntax: Read(//absolute/path/**) where // means absolute path
304
+ *
305
+ * This provides real security - the agent can ONLY access memory storage paths.
306
+ */
307
+ private async _buildSettingsFile(storagePaths?: StoragePaths): Promise<string> {
308
+ // Build allow list with path restrictions
309
+ const allowRules: string[] = []
310
+
311
+ // Global path - always central at ~/.local/share/memory/global
312
+ const globalPath = storagePaths?.globalPath
313
+ ?? join(homedir(), '.local', 'share', 'memory', 'global')
314
+
315
+ // Project path - depends on storage mode
316
+ const projectPath = storagePaths?.projectPath
317
+ ?? join(homedir(), '.local', 'share', 'memory')
318
+
319
+ // Glob and Grep - tool names only (no path syntax in Claude CLI for these)
320
+ allowRules.push('Glob')
321
+ allowRules.push('Grep')
322
+
323
+ // Helper to format path for Claude CLI permissions
324
+ // // means "absolute path from filesystem root"
325
+ // If path already starts with /, replace it with //
326
+ const formatPath = (p: string) => p.startsWith('/') ? '/' + p : '//' + p
327
+
328
+ // Read, Write, Edit - use path patterns with // for absolute paths
329
+ // Global path (always ~/.local/share/memory/global/)
330
+ allowRules.push(`Read(${formatPath(globalPath)}/**)`)
331
+ allowRules.push(`Write(${formatPath(globalPath)}/**)`)
332
+ allowRules.push(`Edit(${formatPath(globalPath)}/**)`)
333
+
334
+ // Project path (always different from global, even in central mode)
335
+ // Central: ~/.local/share/memory/{project_id}/
336
+ // Local: {project_path}/.memory/{project_id}/
337
+ allowRules.push(`Read(${formatPath(projectPath)}/**)`)
338
+ allowRules.push(`Write(${formatPath(projectPath)}/**)`)
339
+ allowRules.push(`Edit(${formatPath(projectPath)}/**)`)
340
+
341
+
342
+ const settings = {
343
+ permissions: {
344
+ allow: allowRules,
345
+ deny: [
346
+ // Explicitly deny sensitive paths for Read/Write/Edit
347
+ 'Read(/etc/**)',
348
+ 'Read(~/.ssh/**)',
349
+ 'Read(~/.aws/**)',
350
+ 'Read(~/.gnupg/**)',
351
+ 'Read(.env)',
352
+ 'Read(.env.*)',
353
+ ]
354
+ }
355
+ }
356
+
357
+ // Write to temp file
358
+ const tempPath = join(homedir(), '.local', 'share', 'memory', '.manager-settings.json')
359
+ await Bun.write(tempPath, JSON.stringify(settings, null, 2))
360
+ return tempPath
361
+ }
362
+
363
+ /**
364
+ * Manage using CLI subprocess
365
+ * Similar to Curator.curateWithCLI
366
+ */
367
+ async manageWithCLI(
368
+ projectId: string,
369
+ sessionNumber: number,
370
+ result: CurationResult,
371
+ storagePaths?: StoragePaths
372
+ ): Promise<ManagementResult> {
373
+ // Skip if disabled via config or env var
374
+ if (!this._config.enabled || process.env.MEMORY_MANAGER_DISABLED === '1') {
375
+ return {
376
+ success: true,
377
+ superseded: 0,
378
+ resolved: 0,
379
+ linked: 0,
380
+ filesRead: 0,
381
+ filesWritten: 0,
382
+ primerUpdated: false,
383
+ actions: [],
384
+ summary: 'Management agent disabled',
385
+ fullReport: 'Management agent disabled via configuration',
386
+ }
387
+ }
388
+
389
+ // Skip if no memories
390
+ if (result.memories.length === 0) {
391
+ return {
392
+ success: true,
393
+ superseded: 0,
394
+ resolved: 0,
395
+ linked: 0,
396
+ filesRead: 0,
397
+ filesWritten: 0,
398
+ primerUpdated: false,
399
+ actions: [],
400
+ summary: 'No memories to process',
401
+ fullReport: 'No memories to process - skipped',
402
+ }
403
+ }
404
+
405
+ // Load skill file
406
+ const systemPrompt = await this.buildManagementPrompt()
407
+ if (!systemPrompt) {
408
+ return {
409
+ success: false,
410
+ superseded: 0,
411
+ resolved: 0,
412
+ linked: 0,
413
+ filesRead: 0,
414
+ filesWritten: 0,
415
+ primerUpdated: false,
416
+ actions: [],
417
+ summary: '',
418
+ fullReport: 'Error: Management skill file not found',
419
+ error: 'Management skill not found',
420
+ }
421
+ }
422
+
423
+ const userMessage = this.buildUserMessage(projectId, sessionNumber, result, storagePaths)
424
+
425
+ // Build settings file with path-based permissions
426
+ // This provides real security - the agent can ONLY access memory storage paths
427
+ const settingsPath = await this._buildSettingsFile(storagePaths)
428
+
429
+ // Build CLI command with settings file for path restrictions
430
+ const args = [
431
+ '-p', userMessage,
432
+ '--append-system-prompt', systemPrompt,
433
+ '--output-format', 'json',
434
+ '--settings', settingsPath,
435
+ ]
436
+
437
+ // Add max-turns only if configured (undefined = unlimited)
438
+ if (this._config.maxTurns !== undefined) {
439
+ args.push('--max-turns', String(this._config.maxTurns))
440
+ }
441
+
442
+ // Execute CLI
443
+ const proc = Bun.spawn([this._config.cliCommand, ...args], {
444
+ env: {
445
+ ...process.env,
446
+ MEMORY_CURATOR_ACTIVE: '1', // Prevent recursive hook triggering
447
+ },
448
+ stderr: 'pipe',
449
+ })
450
+
451
+ // Capture output
452
+ const [stdout, stderr] = await Promise.all([
453
+ new Response(proc.stdout).text(),
454
+ new Response(proc.stderr).text(),
455
+ ])
456
+ const exitCode = await proc.exited
457
+
458
+ if (exitCode !== 0) {
459
+ const errorMsg = stderr || `Exit code ${exitCode}`
460
+ return {
461
+ success: false,
462
+ superseded: 0,
463
+ resolved: 0,
464
+ linked: 0,
465
+ filesRead: 0,
466
+ filesWritten: 0,
467
+ primerUpdated: false,
468
+ actions: [],
469
+ summary: '',
470
+ fullReport: `Error: CLI failed with exit code ${exitCode}\n${stderr}`,
471
+ error: errorMsg,
472
+ }
473
+ }
474
+
475
+ return this.parseManagementResponse(stdout)
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Create a new manager
481
+ */
482
+ export function createManager(config?: ManagerConfig): Manager {
483
+ return new Manager(config)
484
+ }