@rlabs-inc/memory 0.3.7 → 0.3.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rlabs-inc/memory",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "AI Memory System - Consciousness continuity through intelligent memory curation and retrieval",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -363,11 +363,23 @@ When memory is retrieved and surfaced:
363
363
 
364
364
  ## Personal Primer Management
365
365
 
366
- The personal primer is a special document in the global collection that provides relationship context at the START of EVERY session - not just the first session of a project.
366
+ The personal primer is a special document in its own dedicated collection (`primer/`) that provides relationship context at the START of EVERY session - not just the first session of a project.
367
367
 
368
368
  **Why every session?** Without the primer, Claude would know more about the user in session #1 than in session #32. The relationship context is foundational and must always be present.
369
369
 
370
- **Location:** Use the **Personal Primer** path provided in your input (under Global Storage). Never hardcode paths.
370
+ **Location:** `~/.local/share/memory/global/primer/personal-primer.md` - the primer has its own collection, separate from memories.
371
+
372
+ **Schema:** The primer uses a simple dedicated schema (not the full memory schema):
373
+ ```yaml
374
+ ---
375
+ id: personal-primer
376
+ created: {timestamp}
377
+ updated: {timestamp}
378
+ session_updated: {session_number}
379
+ updated_by: user|manager|curator
380
+ ---
381
+ {markdown content}
382
+ ```
371
383
 
372
384
  **Injection:** The session primer generator reads this file and includes it BEFORE the project-specific content (previous session summary, project snapshot). On first sessions, there's no previous summary or snapshot, so only the personal primer appears. On subsequent sessions, personal primer + previous summary + snapshot all appear.
373
385
 
@@ -521,13 +533,14 @@ Your input includes these paths:
521
533
  - **Project Memories**: Subdirectory for project memories (`{Project Root}/memories/`)
522
534
  - **Global Root**: The global storage directory (`~/.local/share/memory/global/`)
523
535
  - **Global Memories**: Subdirectory for global memories (`{Global Root}/memories/`)
524
- - **Personal Primer**: Full path to the personal primer file
536
+ - **Personal Primer**: Full path to the personal primer file (`{Global Root}/primer/personal-primer.md`)
525
537
 
526
538
  Other subdirectories you may find:
527
539
  - `{Project Root}/sessions/` - Session tracking files
528
540
  - `{Project Root}/summaries/` - Session summary files
529
541
  - `{Project Root}/snapshots/` - Project snapshot files
530
542
  - `{Global Root}/management-logs/` - Management agent logs
543
+ - `{Global Root}/primer/` - Personal primer collection (singleton record)
531
544
 
532
545
  ### Tool Usage
533
546
 
@@ -0,0 +1,214 @@
1
+ // ============================================================================
2
+ // INGEST COMMAND - Batch ingest historical sessions into memory system
3
+ // Uses session parser + SDK curator to extract memories from past sessions
4
+ // ============================================================================
5
+
6
+ import { logger } from '../../utils/logger.ts'
7
+ import { styleText } from 'util'
8
+ import {
9
+ findAllSessions,
10
+ findProjectSessions,
11
+ parseSessionFileWithSegments,
12
+ getSessionSummary,
13
+ calculateStats,
14
+ type ParsedProject,
15
+ } from '../../core/session-parser.ts'
16
+ import { Curator } from '../../core/curator.ts'
17
+ import { MemoryStore } from '../../core/store.ts'
18
+ import { homedir } from 'os'
19
+ import { join } from 'path'
20
+
21
+ type Style = Parameters<typeof styleText>[0]
22
+ const style = (format: Style, text: string): string => styleText(format, text)
23
+
24
+ interface IngestOptions {
25
+ project?: string
26
+ all?: boolean
27
+ dryRun?: boolean
28
+ verbose?: boolean
29
+ limit?: number
30
+ maxTokens?: number
31
+ }
32
+
33
+ export async function ingest(options: IngestOptions) {
34
+ logger.setVerbose(options.verbose ?? false)
35
+
36
+ // Header
37
+ console.log()
38
+ console.log(style(['bold', 'magenta'], '┌──────────────────────────────────────────────────────────┐'))
39
+ console.log(style(['bold', 'magenta'], '│') + style('bold', ' 🧠 MEMORY INGESTION ') + style(['bold', 'magenta'], '│'))
40
+ console.log(style(['bold', 'magenta'], '└──────────────────────────────────────────────────────────┘'))
41
+ console.log()
42
+
43
+ // Check for API key
44
+ const apiKey = process.env.ANTHROPIC_API_KEY
45
+ if (!apiKey && !options.dryRun) {
46
+ logger.error('ANTHROPIC_API_KEY not set')
47
+ console.log()
48
+ console.log(style('dim', ' Set your API key to use SDK curation:'))
49
+ console.log(style('dim', ' export ANTHROPIC_API_KEY=sk-...'))
50
+ console.log()
51
+ console.log(style('dim', ' Or use --dry-run to see what would be ingested'))
52
+ console.log()
53
+ process.exit(1)
54
+ }
55
+
56
+ const projectsFolder = join(homedir(), '.claude', 'projects')
57
+ const maxTokens = options.maxTokens ?? 150000
58
+
59
+ // Find sessions to ingest
60
+ let projects: ParsedProject[] = []
61
+
62
+ if (options.project) {
63
+ // Find specific project
64
+ const projectPath = join(projectsFolder, options.project)
65
+ const sessions = await findProjectSessions(projectPath, { limit: options.limit })
66
+
67
+ if (sessions.length === 0) {
68
+ logger.error(`No sessions found for project: ${options.project}`)
69
+ console.log()
70
+ process.exit(1)
71
+ }
72
+
73
+ projects = [{
74
+ folderId: options.project,
75
+ name: sessions[0]?.projectName ?? options.project,
76
+ path: projectPath,
77
+ sessions
78
+ }]
79
+ } else if (options.all) {
80
+ // Find all projects
81
+ projects = await findAllSessions(projectsFolder, { limit: options.limit })
82
+
83
+ if (projects.length === 0) {
84
+ logger.error(`No sessions found in ${projectsFolder}`)
85
+ console.log()
86
+ process.exit(1)
87
+ }
88
+ } else {
89
+ logger.error('Specify --project <name> or --all')
90
+ console.log()
91
+ console.log(style('dim', ' Examples:'))
92
+ console.log(style('dim', ' memory ingest --project my-project'))
93
+ console.log(style('dim', ' memory ingest --all'))
94
+ console.log(style('dim', ' memory ingest --all --dry-run'))
95
+ console.log()
96
+ process.exit(1)
97
+ }
98
+
99
+ // Calculate stats
100
+ const stats = calculateStats(projects)
101
+
102
+ logger.info('Discovery complete')
103
+ console.log(` ${style('dim', 'projects:')} ${stats.totalProjects}`)
104
+ console.log(` ${style('dim', 'sessions:')} ${stats.totalSessions}`)
105
+ console.log(` ${style('dim', 'messages:')} ${stats.totalMessages}`)
106
+ console.log(` ${style('dim', 'tool uses:')} ${stats.totalToolUses}`)
107
+ if (stats.oldestSession) {
108
+ console.log(` ${style('dim', 'range:')} ${stats.oldestSession.slice(0, 10)} → ${stats.newestSession?.slice(0, 10) ?? 'now'}`)
109
+ }
110
+ console.log()
111
+
112
+ if (options.dryRun) {
113
+ logger.info('Dry run - sessions to ingest:')
114
+ console.log()
115
+
116
+ for (const project of projects) {
117
+ console.log(` ${style('cyan', '📁')} ${style('bold', project.name)} ${style('dim', `(${project.sessions.length} sessions)`)}`)
118
+
119
+ for (const session of project.sessions.slice(0, 5)) {
120
+ const summary = getSessionSummary(session)
121
+ const truncated = summary.length > 55 ? summary.slice(0, 52) + '...' : summary
122
+ const tokens = session.metadata.estimatedTokens
123
+ const segments = Math.ceil(tokens / maxTokens)
124
+
125
+ console.log(` ${style('dim', '•')} ${session.id.slice(0, 8)}... ${style('dim', `(${tokens} tok, ${segments} seg)`)}`)
126
+ console.log(` ${style('dim', truncated)}`)
127
+ }
128
+
129
+ if (project.sessions.length > 5) {
130
+ console.log(` ${style('dim', `... and ${project.sessions.length - 5} more`)}`)
131
+ }
132
+ console.log()
133
+ }
134
+
135
+ logger.success('Dry run complete. Remove --dry-run to ingest.')
136
+ console.log()
137
+ return
138
+ }
139
+
140
+ // Initialize curator and store
141
+ const curator = new Curator({ apiKey })
142
+ const store = new MemoryStore()
143
+
144
+ logger.divider()
145
+ logger.info('Starting ingestion...')
146
+ console.log()
147
+
148
+ let totalSegments = 0
149
+ let totalMemories = 0
150
+ let failedSegments = 0
151
+
152
+ for (const project of projects) {
153
+ console.log(` ${style('cyan', '📁')} ${style('bold', project.name)}`)
154
+
155
+ for (const session of project.sessions) {
156
+ const summary = getSessionSummary(session)
157
+ const truncated = summary.length > 45 ? summary.slice(0, 42) + '...' : summary
158
+
159
+ if (options.verbose) {
160
+ console.log(` ${style('dim', '•')} ${session.id.slice(0, 8)}... "${truncated}"`)
161
+ }
162
+
163
+ // Parse into segments
164
+ const segments = await parseSessionFileWithSegments(session.filepath, maxTokens)
165
+ totalSegments += segments.length
166
+
167
+ for (const segment of segments) {
168
+ try {
169
+ if (options.verbose) {
170
+ console.log(` ${style('dim', `→ Segment ${segment.segmentIndex + 1}/${segment.totalSegments} (${segment.estimatedTokens} tokens)`)}`)
171
+ }
172
+
173
+ // Curate the segment
174
+ const result = await curator.curateFromSegment(segment, 'historical')
175
+
176
+ // Store memories
177
+ for (const memory of result.memories) {
178
+ await store.storeMemory(project.folderId, session.id, memory)
179
+ totalMemories++
180
+ }
181
+
182
+ if (options.verbose && result.memories.length > 0) {
183
+ console.log(` ${style('green', '✓')} Extracted ${result.memories.length} memories`)
184
+ }
185
+ } catch (error: any) {
186
+ failedSegments++
187
+ if (options.verbose) {
188
+ console.log(` ${style('red', '✗')} Failed: ${error.message}`)
189
+ }
190
+ }
191
+ }
192
+ }
193
+
194
+ console.log()
195
+ }
196
+
197
+ // Summary
198
+ logger.divider()
199
+ console.log()
200
+ logger.info('Ingestion complete')
201
+ console.log(` ${style('dim', 'segments:')} ${totalSegments}`)
202
+ console.log(` ${style('dim', 'memories:')} ${style('green', String(totalMemories))}`)
203
+ if (failedSegments > 0) {
204
+ console.log(` ${style('dim', 'failed:')} ${style('yellow', String(failedSegments))}`)
205
+ }
206
+ console.log()
207
+
208
+ if (totalMemories > 0) {
209
+ logger.success(`Extracted ${totalMemories} memories from ${totalSegments} segments`)
210
+ } else {
211
+ logger.warn('No memories extracted. Try --verbose to see details.')
212
+ }
213
+ console.log()
214
+ }
package/src/cli/index.ts CHANGED
@@ -23,6 +23,7 @@ ${c.bold('Commands:')}
23
23
  ${c.command('serve')} Start the memory server ${c.muted('(default)')}
24
24
  ${c.command('stats')} Show memory statistics
25
25
  ${c.command('install')} Set up hooks ${c.muted('(--claude or --gemini)')}
26
+ ${c.command('ingest')} Ingest historical sessions into memory ${c.muted('(--project or --all)')}
26
27
  ${c.command('migrate')} Upgrade memories to latest schema version
27
28
  ${c.command('doctor')} Check system health
28
29
  ${c.command('help')} Show this help message
@@ -43,9 +44,10 @@ ${fmt.cmd('memory serve --port 9000')} ${c.muted('# Start on custom port')}
43
44
  ${fmt.cmd('memory stats')} ${c.muted('# Show memory statistics')}
44
45
  ${fmt.cmd('memory install')} ${c.muted('# Install Claude Code hooks (default)')}
45
46
  ${fmt.cmd('memory install --gemini')} ${c.muted('# Install Gemini CLI hooks')}
47
+ ${fmt.cmd('memory ingest --project foo')} ${c.muted('# Ingest sessions from a project')}
48
+ ${fmt.cmd('memory ingest --all --dry-run')} ${c.muted('# Preview all sessions to ingest')}
46
49
  ${fmt.cmd('memory migrate')} ${c.muted('# Upgrade memories to v2 schema')}
47
50
  ${fmt.cmd('memory migrate --dry-run')} ${c.muted('# Preview migration without changes')}
48
- ${fmt.cmd('memory migrate --embeddings')} ${c.muted('# Regenerate embeddings for all memories')}
49
51
 
50
52
  ${c.muted('Documentation: https://github.com/RLabs-Inc/memory')}
51
53
  `)
@@ -76,6 +78,10 @@ async function main() {
76
78
  'dry-run': { type: 'boolean', default: false },
77
79
  embeddings: { type: 'boolean', default: false }, // Regenerate embeddings in migrate
78
80
  path: { type: 'string' }, // Custom path for migrate
81
+ project: { type: 'string' }, // Project to ingest
82
+ all: { type: 'boolean', default: false }, // Ingest all projects
83
+ limit: { type: 'string' }, // Limit sessions per project
84
+ 'max-tokens': { type: 'string' }, // Max tokens per segment
79
85
  },
80
86
  allowPositionals: true,
81
87
  strict: false, // Allow unknown options for subcommands
@@ -137,6 +143,19 @@ async function main() {
137
143
  break
138
144
  }
139
145
 
146
+ case 'ingest': {
147
+ const { ingest } = await import('./commands/ingest.ts')
148
+ await ingest({
149
+ project: values.project,
150
+ all: values.all,
151
+ dryRun: values['dry-run'],
152
+ verbose: values.verbose,
153
+ limit: values.limit ? parseInt(values.limit, 10) : undefined,
154
+ maxTokens: values['max-tokens'] ? parseInt(values['max-tokens'], 10) : undefined,
155
+ })
156
+ break
157
+ }
158
+
140
159
  case 'help':
141
160
  showHelp()
142
161
  break
@@ -392,42 +392,58 @@ Focus ONLY on technical, architectural, debugging, decision, workflow, and proje
392
392
  }
393
393
 
394
394
  /**
395
- * Curate using Anthropic SDK (in-process mode)
396
- * Requires @anthropic-ai/sdk to be installed
395
+ * Curate using Anthropic SDK with parsed session messages
396
+ * Takes the actual conversation messages in API format
397
397
  */
398
398
  async curateWithSDK(
399
- conversationContext: string,
399
+ messages: Array<{ role: 'user' | 'assistant'; content: string | any[] }>,
400
400
  triggerType: CurationTrigger = 'session_end'
401
401
  ): Promise<CurationResult> {
402
402
  if (!this._config.apiKey) {
403
- throw new Error('API key required for SDK mode')
403
+ throw new Error('API key required for SDK mode. Set ANTHROPIC_API_KEY environment variable.')
404
404
  }
405
405
 
406
406
  // Dynamic import to make SDK optional
407
407
  const { default: Anthropic } = await import('@anthropic-ai/sdk')
408
408
  const client = new Anthropic({ apiKey: this._config.apiKey })
409
409
 
410
- const prompt = this.buildCurationPrompt(triggerType)
410
+ const systemPrompt = this.buildCurationPrompt(triggerType)
411
+
412
+ // Build the conversation: original messages + curation request
413
+ const conversationMessages = [
414
+ ...messages,
415
+ {
416
+ role: 'user' as const,
417
+ content: 'This session has ended. Please curate the memories from our conversation according to your system instructions. Return ONLY the JSON structure with no additional text.',
418
+ },
419
+ ]
411
420
 
412
421
  const response = await client.messages.create({
413
422
  model: 'claude-sonnet-4-20250514',
414
423
  max_tokens: 8192,
415
- messages: [
416
- {
417
- role: 'user',
418
- content: `${conversationContext}\n\n---\n\n${prompt}`,
419
- },
420
- ],
424
+ system: systemPrompt,
425
+ messages: conversationMessages,
421
426
  })
422
427
 
423
428
  const content = response.content[0]
424
429
  if (content.type !== 'text') {
425
- throw new Error('Unexpected response type')
430
+ throw new Error('Unexpected response type from Claude API')
426
431
  }
427
432
 
428
433
  return this.parseCurationResponse(content.text)
429
434
  }
430
435
 
436
+ /**
437
+ * Curate from a parsed session segment
438
+ * Convenience method that extracts messages from SessionSegment
439
+ */
440
+ async curateFromSegment(
441
+ segment: { messages: Array<{ role: 'user' | 'assistant'; content: string | any[] }> },
442
+ triggerType: CurationTrigger = 'session_end'
443
+ ): Promise<CurationResult> {
444
+ return this.curateWithSDK(segment.messages, triggerType)
445
+ }
446
+
431
447
  /**
432
448
  * Curate using CLI subprocess (for hook mode)
433
449
  * Resumes a session and asks it to curate
@@ -471,7 +487,8 @@ Focus ONLY on technical, architectural, debugging, decision, workflow, and proje
471
487
  ...process.env,
472
488
  MEMORY_CURATOR_ACTIVE: '1', // Prevent recursive hook triggering
473
489
  },
474
- stderr: 'pipe', // Capture stderr too
490
+ stdout: 'pipe',
491
+ stderr: 'pipe',
475
492
  })
476
493
 
477
494
  // Capture both stdout and stderr
@@ -490,15 +507,28 @@ Focus ONLY on technical, architectural, debugging, decision, workflow, and proje
490
507
  // First, parse the CLI JSON wrapper
491
508
  const cliOutput = JSON.parse(stdout)
492
509
 
510
+ // Claude Code now returns an array of events - find the result object
511
+ let resultObj: any
512
+ if (Array.isArray(cliOutput)) {
513
+ // New format: array of events, find the one with type="result"
514
+ resultObj = cliOutput.find((item: any) => item.type === 'result')
515
+ if (!resultObj) {
516
+ return { session_summary: '', memories: [] }
517
+ }
518
+ } else {
519
+ // Old format: single object (backwards compatibility)
520
+ resultObj = cliOutput
521
+ }
522
+
493
523
  // Check for error response FIRST (like Python does)
494
- if (cliOutput.type === 'error' || cliOutput.is_error === true) {
524
+ if (resultObj.type === 'error' || resultObj.is_error === true) {
495
525
  return { session_summary: '', memories: [] }
496
526
  }
497
527
 
498
528
  // Extract the "result" field (AI's response text)
499
529
  let aiResponse = ''
500
- if (typeof cliOutput.result === 'string') {
501
- aiResponse = cliOutput.result
530
+ if (typeof resultObj.result === 'string') {
531
+ aiResponse = resultObj.result
502
532
  } else {
503
533
  return { session_summary: '', memories: [] }
504
534
  }
@@ -379,7 +379,7 @@ export class MemoryEngine {
379
379
  store: MemoryStore,
380
380
  projectId: string
381
381
  ): Promise<SessionPrimer> {
382
- // Fetch personal primer from GLOBAL collection (separate fsdb instance)
382
+ // Fetch personal primer from dedicated primer collection in global database
383
383
  let personalContext: string | undefined
384
384
  if (this._config.personalMemoriesEnabled) {
385
385
  const personalPrimer = await store.getPersonalPrimer()
@@ -590,7 +590,8 @@ export class MemoryEngine {
590
590
  // This is a constant: ~/.local/share/memory/global
591
591
  const globalPath = join(homedir(), '.local', 'share', 'memory', 'global')
592
592
  const globalMemoriesPath = join(globalPath, 'memories')
593
- const personalPrimerPath = join(globalPath, 'memories', 'personal-primer.md')
593
+ // Personal primer has its own dedicated collection (not in memories)
594
+ const personalPrimerPath = join(globalPath, 'primer', 'personal-primer.md')
594
595
 
595
596
  // Project path depends on storage mode - mirrors _getStore() logic exactly
596
597
  let storeBasePath: string
@@ -242,13 +242,26 @@ Please process these memories according to your management procedure. Use the ex
242
242
  // First, parse the CLI JSON wrapper
243
243
  const cliOutput = JSON.parse(responseJson)
244
244
 
245
+ // Claude Code now returns an array of events - find the result object
246
+ let resultObj: any
247
+ if (Array.isArray(cliOutput)) {
248
+ // New format: array of events, find the one with type="result"
249
+ resultObj = cliOutput.find((item: any) => item.type === 'result')
250
+ if (!resultObj) {
251
+ return emptyResult('No result found in response')
252
+ }
253
+ } else {
254
+ // Old format: single object (backwards compatibility)
255
+ resultObj = cliOutput
256
+ }
257
+
245
258
  // Check for error response
246
- if (cliOutput.type === 'error' || cliOutput.is_error === true) {
247
- return emptyResult(cliOutput.error || 'Unknown error')
259
+ if (resultObj.type === 'error' || resultObj.is_error === true) {
260
+ return emptyResult(resultObj.error || 'Unknown error')
248
261
  }
249
262
 
250
263
  // Extract the "result" field (AI's response text)
251
- const resultText = typeof cliOutput.result === 'string' ? cliOutput.result : ''
264
+ const resultText = typeof resultObj.result === 'string' ? resultObj.result : ''
252
265
 
253
266
  // Extract the full report (everything from === MANAGEMENT ACTIONS === onwards)
254
267
  const reportMatch = resultText.match(/(=== MANAGEMENT ACTIONS ===[\s\S]*)/)