@rlabs-inc/memory 0.3.9 → 0.3.11

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 CHANGED
@@ -336,6 +336,17 @@ This isn't just about remembering facts. It's about preserving:
336
336
 
337
337
  ## Changelog
338
338
 
339
+ ### v0.3.11
340
+ - **Feature**: Agent SDK integration for curator and manager - no API key needed, uses Claude Code OAuth
341
+ - **Feature**: `memory ingest --session <id>` to ingest a single session (useful when automatic curation fails)
342
+ - **Feature**: Manager now runs after each session during ingestion to organize/link memories
343
+ - **Improvement**: Spinner activity indicator during long curator and manager operations
344
+ - **Improvement**: Better manager output with colored stats (superseded, resolved, linked)
345
+ - **Improvement**: DEBUG logs now only show with `--verbose` flag
346
+
347
+ ### v0.3.10
348
+ - **Improvement**: Use `which claude` for universal CLI path discovery - works with any installation method (native, homebrew, npm)
349
+
339
350
  ### v0.3.9
340
351
  - **Fix**: Claude Code v2.0.76+ changed `--output-format json` from single object to array of events. Updated curator and manager to handle both formats with backwards compatibility.
341
352
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rlabs-inc/memory",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "AI Memory System - Consciousness continuity through intelligent memory curation and retrieval",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -39,6 +39,7 @@
39
39
  "cli": "bun src/cli/index.ts"
40
40
  },
41
41
  "dependencies": {
42
+ "@anthropic-ai/claude-agent-sdk": "^0.2.1",
42
43
  "@huggingface/transformers": "^3.4.1",
43
44
  "@rlabs-inc/fsdb": "^1.0.1",
44
45
  "@rlabs-inc/signals": "^1.0.0"
@@ -8,21 +8,63 @@ import { styleText } from 'util'
8
8
  import {
9
9
  findAllSessions,
10
10
  findProjectSessions,
11
+ parseSessionFile,
11
12
  parseSessionFileWithSegments,
12
13
  getSessionSummary,
13
14
  calculateStats,
14
15
  type ParsedProject,
16
+ type ParsedSession,
15
17
  } from '../../core/session-parser.ts'
16
18
  import { Curator } from '../../core/curator.ts'
19
+ import { Manager, type StoragePaths } from '../../core/manager.ts'
17
20
  import { MemoryStore } from '../../core/store.ts'
21
+ import type { CurationResult, CuratedMemory } from '../../types/memory.ts'
18
22
  import { homedir } from 'os'
19
23
  import { join } from 'path'
24
+ import { readdir, stat } from 'fs/promises'
20
25
 
21
26
  type Style = Parameters<typeof styleText>[0]
22
27
  const style = (format: Style, text: string): string => styleText(format, text)
23
28
 
29
+ // Simple spinner for long operations
30
+ const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
31
+
32
+ class Spinner {
33
+ private interval: ReturnType<typeof setInterval> | null = null
34
+ private frameIndex = 0
35
+ private message = ''
36
+
37
+ start(message: string) {
38
+ this.message = message
39
+ this.frameIndex = 0
40
+ process.stdout.write(` ${style('cyan', spinnerFrames[0])} ${style('dim', message)}`)
41
+ this.interval = setInterval(() => {
42
+ this.frameIndex = (this.frameIndex + 1) % spinnerFrames.length
43
+ process.stdout.write(`\r ${style('cyan', spinnerFrames[this.frameIndex])} ${style('dim', this.message)}`)
44
+ }, 80)
45
+ }
46
+
47
+ update(message: string) {
48
+ this.message = message
49
+ process.stdout.write(`\r ${style('cyan', spinnerFrames[this.frameIndex])} ${style('dim', this.message)}`)
50
+ }
51
+
52
+ stop(finalMessage?: string) {
53
+ if (this.interval) {
54
+ clearInterval(this.interval)
55
+ this.interval = null
56
+ }
57
+ // Clear the line and optionally write final message
58
+ process.stdout.write('\r' + ' '.repeat(80) + '\r')
59
+ if (finalMessage) {
60
+ console.log(finalMessage)
61
+ }
62
+ }
63
+ }
64
+
24
65
  interface IngestOptions {
25
66
  project?: string
67
+ session?: string
26
68
  all?: boolean
27
69
  dryRun?: boolean
28
70
  verbose?: boolean
@@ -30,6 +72,71 @@ interface IngestOptions {
30
72
  maxTokens?: number
31
73
  }
32
74
 
75
+ /**
76
+ * Find a specific session by ID
77
+ * Searches in specified project or across all projects
78
+ */
79
+ async function findSessionById(
80
+ sessionId: string,
81
+ projectsFolder: string,
82
+ projectPath?: string
83
+ ): Promise<{ session: ParsedSession; folderId: string } | null> {
84
+ const filename = sessionId.endsWith('.jsonl') ? sessionId : `${sessionId}.jsonl`
85
+
86
+ // If project path is specified, search only there
87
+ if (projectPath) {
88
+ const filepath = join(projectPath, filename)
89
+ try {
90
+ await stat(filepath)
91
+ const session = await parseSessionFile(filepath)
92
+ const folderId = projectPath.split('/').pop() ?? projectPath
93
+ return { session, folderId }
94
+ } catch {
95
+ return null
96
+ }
97
+ }
98
+
99
+ // Search all projects
100
+ try {
101
+ const projectFolders = await readdir(projectsFolder)
102
+
103
+ for (const folder of projectFolders) {
104
+ const folderPath = join(projectsFolder, folder)
105
+ const filepath = join(folderPath, filename)
106
+
107
+ try {
108
+ await stat(filepath)
109
+ const session = await parseSessionFile(filepath)
110
+ return { session, folderId: folder }
111
+ } catch {
112
+ continue
113
+ }
114
+ }
115
+ } catch {
116
+ // projectsFolder doesn't exist
117
+ }
118
+
119
+ return null
120
+ }
121
+
122
+ /**
123
+ * Build storage paths for the manager (mirrors engine.getStoragePaths)
124
+ */
125
+ function buildStoragePaths(projectId: string): StoragePaths {
126
+ const globalPath = join(homedir(), '.local', 'share', 'memory', 'global')
127
+ const centralPath = join(homedir(), '.local', 'share', 'memory')
128
+ const projectPath = join(centralPath, projectId)
129
+
130
+ return {
131
+ projectPath,
132
+ globalPath,
133
+ projectMemoriesPath: join(projectPath, 'memories'),
134
+ globalMemoriesPath: join(globalPath, 'memories'),
135
+ personalPrimerPath: join(globalPath, 'primer', 'personal-primer.md'),
136
+ storageMode: 'central',
137
+ }
138
+ }
139
+
33
140
  export async function ingest(options: IngestOptions) {
34
141
  logger.setVerbose(options.verbose ?? false)
35
142
 
@@ -40,18 +147,8 @@ export async function ingest(options: IngestOptions) {
40
147
  console.log(style(['bold', 'magenta'], '└──────────────────────────────────────────────────────────┘'))
41
148
  console.log()
42
149
 
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
- }
150
+ // Agent SDK uses Claude Code OAuth - no API key needed!
151
+ // Just need Claude Code installed and authenticated
55
152
 
56
153
  const projectsFolder = join(homedir(), '.claude', 'projects')
57
154
  const maxTokens = options.maxTokens ?? 150000
@@ -59,7 +156,35 @@ export async function ingest(options: IngestOptions) {
59
156
  // Find sessions to ingest
60
157
  let projects: ParsedProject[] = []
61
158
 
62
- if (options.project) {
159
+ if (options.session) {
160
+ // Find specific session by ID
161
+ const projectPath = options.project ? join(projectsFolder, options.project) : undefined
162
+ const result = await findSessionById(options.session, projectsFolder, projectPath)
163
+
164
+ if (!result) {
165
+ logger.error(`Session not found: ${options.session}`)
166
+ console.log()
167
+ if (options.project) {
168
+ console.log(style('dim', ` Searched in: ${projectPath}`))
169
+ } else {
170
+ console.log(style('dim', ` Searched in: ${projectsFolder}`))
171
+ console.log(style('dim', ' Tip: Use --project <name> to specify the project folder'))
172
+ }
173
+ console.log()
174
+ process.exit(1)
175
+ }
176
+
177
+ projects = [{
178
+ folderId: result.folderId,
179
+ name: result.session.projectName,
180
+ path: join(projectsFolder, result.folderId),
181
+ sessions: [result.session]
182
+ }]
183
+
184
+ logger.info(`Found session in project: ${result.session.projectName}`)
185
+ console.log(` ${style('dim', 'path:')} ${result.session.filepath}`)
186
+ console.log()
187
+ } else if (options.project) {
63
188
  // Find specific project
64
189
  const projectPath = join(projectsFolder, options.project)
65
190
  const sessions = await findProjectSessions(projectPath, { limit: options.limit })
@@ -86,9 +211,11 @@ export async function ingest(options: IngestOptions) {
86
211
  process.exit(1)
87
212
  }
88
213
  } else {
89
- logger.error('Specify --project <name> or --all')
214
+ logger.error('Specify --session <id>, --project <name>, or --all')
90
215
  console.log()
91
216
  console.log(style('dim', ' Examples:'))
217
+ console.log(style('dim', ' memory ingest --session abc123-def456'))
218
+ console.log(style('dim', ' memory ingest --session abc123-def456 --project my-project'))
92
219
  console.log(style('dim', ' memory ingest --project my-project'))
93
220
  console.log(style('dim', ' memory ingest --all'))
94
221
  console.log(style('dim', ' memory ingest --all --dry-run'))
@@ -137,21 +264,33 @@ export async function ingest(options: IngestOptions) {
137
264
  return
138
265
  }
139
266
 
140
- // Initialize curator and store
141
- const curator = new Curator({ apiKey })
267
+ // Initialize curator, manager, and store
268
+ // Curator uses Agent SDK (no API key needed - uses Claude Code OAuth)
269
+ const curator = new Curator()
270
+ const manager = new Manager()
142
271
  const store = new MemoryStore()
143
272
 
273
+ // Check if manager is enabled
274
+ const managerEnabled = process.env.MEMORY_MANAGER_DISABLED !== '1'
275
+
144
276
  logger.divider()
145
277
  logger.info('Starting ingestion...')
278
+ if (managerEnabled) {
279
+ console.log(` ${style('dim', 'manager:')} enabled (will organize memories after each session)`)
280
+ }
146
281
  console.log()
147
282
 
148
283
  let totalSegments = 0
149
284
  let totalMemories = 0
150
285
  let failedSegments = 0
286
+ let managedSessions = 0
151
287
 
152
288
  for (const project of projects) {
153
289
  console.log(` ${style('cyan', '📁')} ${style('bold', project.name)}`)
154
290
 
291
+ // Build storage paths for manager (same for all sessions in project)
292
+ const storagePaths = buildStoragePaths(project.folderId)
293
+
155
294
  for (const session of project.sessions) {
156
295
  const summary = getSessionSummary(session)
157
296
  const truncated = summary.length > 45 ? summary.slice(0, 42) + '...' : summary
@@ -164,29 +303,88 @@ export async function ingest(options: IngestOptions) {
164
303
  const segments = await parseSessionFileWithSegments(session.filepath, maxTokens)
165
304
  totalSegments += segments.length
166
305
 
306
+ // Accumulate all memories from this session for manager
307
+ const sessionMemories: CuratedMemory[] = []
308
+ let sessionSummary = ''
309
+
310
+ const spinner = new Spinner()
311
+
167
312
  for (const segment of segments) {
168
313
  try {
169
- if (options.verbose) {
170
- console.log(` ${style('dim', `→ Segment ${segment.segmentIndex + 1}/${segment.totalSegments} (${segment.estimatedTokens} tokens)`)}`)
171
- }
314
+ const segmentLabel = `Segment ${segment.segmentIndex + 1}/${segment.totalSegments}`
315
+ const tokensLabel = `${Math.round(segment.estimatedTokens / 1000)}k tokens`
316
+
317
+ // Start spinner for curation
318
+ spinner.start(`${segmentLabel} (${tokensLabel}) - curating with Opus 4.5...`)
172
319
 
173
320
  // Curate the segment
174
321
  const result = await curator.curateFromSegment(segment, 'historical')
175
322
 
323
+ // Stop spinner with success message
324
+ spinner.stop(` ${style('green', '✓')} ${segmentLabel}: ${result.memories.length} memories (${tokensLabel})`)
325
+
176
326
  // Store memories
177
327
  for (const memory of result.memories) {
178
328
  await store.storeMemory(project.folderId, session.id, memory)
329
+ sessionMemories.push(memory)
179
330
  totalMemories++
180
331
  }
181
332
 
182
- if (options.verbose && result.memories.length > 0) {
183
- console.log(` ${style('green', '✓')} Extracted ${result.memories.length} memories`)
333
+ // Keep the most recent session summary
334
+ if (result.session_summary) {
335
+ sessionSummary = result.session_summary
184
336
  }
185
337
  } catch (error: any) {
186
338
  failedSegments++
187
- if (options.verbose) {
188
- console.log(` ${style('red', '✗')} Failed: ${error.message}`)
339
+ spinner.stop(` ${style('red', '✗')} Segment ${segment.segmentIndex + 1}/${segment.totalSegments}: ${error.message}`)
340
+ }
341
+ }
342
+
343
+ // Run manager if we have memories and manager is enabled
344
+ if (sessionMemories.length > 0 && managerEnabled) {
345
+ try {
346
+ // Start spinner for manager
347
+ spinner.start(`Managing ${sessionMemories.length} memories - organizing with Opus 4.5...`)
348
+
349
+ // Build curation result for manager
350
+ const curationResult: CurationResult = {
351
+ memories: sessionMemories,
352
+ session_summary: sessionSummary,
353
+ project_snapshot: undefined,
354
+ }
355
+
356
+ const managerResult = await manager.manageWithSDK(
357
+ project.folderId,
358
+ 1, // session number not relevant for historical ingestion
359
+ curationResult,
360
+ storagePaths
361
+ )
362
+
363
+ if (managerResult.success) {
364
+ managedSessions++
365
+
366
+ // Build detailed action summary
367
+ const actions: string[] = []
368
+ if (managerResult.superseded > 0) actions.push(`${style('yellow', String(managerResult.superseded))} superseded`)
369
+ if (managerResult.resolved > 0) actions.push(`${style('blue', String(managerResult.resolved))} resolved`)
370
+ if (managerResult.linked > 0) actions.push(`${style('cyan', String(managerResult.linked))} linked`)
371
+ if (managerResult.primerUpdated) actions.push(`${style('magenta', 'primer')} updated`)
372
+
373
+ if (actions.length > 0) {
374
+ spinner.stop(` ${style('green', '✓')} Manager: ${actions.join(', ')}`)
375
+ } else {
376
+ spinner.stop(` ${style('green', '✓')} Manager: no changes needed`)
377
+ }
378
+
379
+ // Show file operations in verbose mode
380
+ if (options.verbose && (managerResult.filesRead > 0 || managerResult.filesWritten > 0)) {
381
+ console.log(` ${style('dim', `files: ${managerResult.filesRead} read, ${managerResult.filesWritten} written`)}`)
382
+ }
383
+ } else {
384
+ spinner.stop(` ${style('yellow', '⚠')} Manager: ${managerResult.error || 'unknown error'}`)
189
385
  }
386
+ } catch (error: any) {
387
+ spinner.stop(` ${style('yellow', '⚠')} Manager failed: ${error.message}`)
190
388
  }
191
389
  }
192
390
  }
@@ -200,6 +398,9 @@ export async function ingest(options: IngestOptions) {
200
398
  logger.info('Ingestion complete')
201
399
  console.log(` ${style('dim', 'segments:')} ${totalSegments}`)
202
400
  console.log(` ${style('dim', 'memories:')} ${style('green', String(totalMemories))}`)
401
+ if (managerEnabled && managedSessions > 0) {
402
+ console.log(` ${style('dim', 'managed:')} ${managedSessions} sessions`)
403
+ }
203
404
  if (failedSegments > 0) {
204
405
  console.log(` ${style('dim', 'failed:')} ${style('yellow', String(failedSegments))}`)
205
406
  }
@@ -207,6 +408,9 @@ export async function ingest(options: IngestOptions) {
207
408
 
208
409
  if (totalMemories > 0) {
209
410
  logger.success(`Extracted ${totalMemories} memories from ${totalSegments} segments`)
411
+ if (managerEnabled && managedSessions > 0) {
412
+ console.log(` ${style('dim', 'Manager organized memories in')} ${managedSessions} ${style('dim', 'sessions')}`)
413
+ }
210
414
  } else {
211
415
  logger.warn('No memories extracted. Try --verbose to see details.')
212
416
  }
package/src/cli/index.ts CHANGED
@@ -23,7 +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
+ ${c.command('ingest')} Ingest historical sessions into memory ${c.muted('(--session, --project, or --all)')}
27
27
  ${c.command('migrate')} Upgrade memories to latest schema version
28
28
  ${c.command('doctor')} Check system health
29
29
  ${c.command('help')} Show this help message
@@ -32,8 +32,9 @@ ${c.bold('Options:')}
32
32
  ${c.cyan('-p, --port')} <port> Server port ${c.muted('(default: 8765)')}
33
33
  ${c.cyan('-v, --verbose')} Verbose output
34
34
  ${c.cyan('-q, --quiet')} Minimal output
35
- ${c.cyan('--dry-run')} Preview changes without applying ${c.muted('(migrate)')}
35
+ ${c.cyan('--dry-run')} Preview changes without applying ${c.muted('(migrate, ingest)')}
36
36
  ${c.cyan('--embeddings')} Regenerate embeddings for memories ${c.muted('(migrate)')}
37
+ ${c.cyan('--session')} <id> Ingest a specific session by ID ${c.muted('(ingest)')}
37
38
  ${c.cyan('--claude')} Install hooks for Claude Code
38
39
  ${c.cyan('--gemini')} Install hooks for Gemini CLI
39
40
  ${c.cyan('--version')} Show version
@@ -44,7 +45,8 @@ ${fmt.cmd('memory serve --port 9000')} ${c.muted('# Start on custom port')}
44
45
  ${fmt.cmd('memory stats')} ${c.muted('# Show memory statistics')}
45
46
  ${fmt.cmd('memory install')} ${c.muted('# Install Claude Code hooks (default)')}
46
47
  ${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 --session abc123')} ${c.muted('# Ingest a specific session')}
49
+ ${fmt.cmd('memory ingest --project foo')} ${c.muted('# Ingest all sessions from a project')}
48
50
  ${fmt.cmd('memory ingest --all --dry-run')} ${c.muted('# Preview all sessions to ingest')}
49
51
  ${fmt.cmd('memory migrate')} ${c.muted('# Upgrade memories to v2 schema')}
50
52
  ${fmt.cmd('memory migrate --dry-run')} ${c.muted('# Preview migration without changes')}
@@ -78,6 +80,7 @@ async function main() {
78
80
  'dry-run': { type: 'boolean', default: false },
79
81
  embeddings: { type: 'boolean', default: false }, // Regenerate embeddings in migrate
80
82
  path: { type: 'string' }, // Custom path for migrate
83
+ session: { type: 'string' }, // Session ID to ingest
81
84
  project: { type: 'string' }, // Project to ingest
82
85
  all: { type: 'boolean', default: false }, // Ingest all projects
83
86
  limit: { type: 'string' }, // Limit sessions per project
@@ -146,6 +149,7 @@ async function main() {
146
149
  case 'ingest': {
147
150
  const { ingest } = await import('./commands/ingest.ts')
148
151
  await ingest({
152
+ session: values.session,
149
153
  project: values.project,
150
154
  all: values.all,
151
155
  dryRun: values['dry-run'],
@@ -10,7 +10,7 @@ import type { CuratedMemory, CurationResult, CurationTrigger } from '../types/me
10
10
 
11
11
  /**
12
12
  * Get the correct Claude CLI command path
13
- * Matches Python's get_claude_command() logic
13
+ * Uses `which` for universal discovery across installation methods
14
14
  */
15
15
  function getClaudeCommand(): string {
16
16
  // 1. Check for explicit override
@@ -19,13 +19,19 @@ function getClaudeCommand(): string {
19
19
  return envCommand
20
20
  }
21
21
 
22
- // 2. Check standard Claude Code installation path
22
+ // 2. Use `which` to find claude in PATH (universal - works with native, homebrew, npm, etc.)
23
+ const result = Bun.spawnSync(['which', 'claude'])
24
+ if (result.exitCode === 0) {
25
+ return result.stdout.toString().trim()
26
+ }
27
+
28
+ // 3. Legacy fallback - hardcoded native install path
23
29
  const claudeLocal = join(homedir(), '.claude', 'local', 'claude')
24
30
  if (existsSync(claudeLocal)) {
25
31
  return claudeLocal
26
32
  }
27
33
 
28
- // 3. Fallback to PATH
34
+ // 4. Last resort - assume it's in PATH
29
35
  return 'claude'
30
36
  }
31
37
 
@@ -392,15 +398,107 @@ Focus ONLY on technical, architectural, debugging, decision, workflow, and proje
392
398
  }
393
399
 
394
400
  /**
395
- * Curate using Anthropic SDK with parsed session messages
401
+ * Curate using Claude Agent SDK (no API key needed - uses Claude Code OAuth)
396
402
  * Takes the actual conversation messages in API format
397
403
  */
398
404
  async curateWithSDK(
399
405
  messages: Array<{ role: 'user' | 'assistant'; content: string | any[] }>,
400
406
  triggerType: CurationTrigger = 'session_end'
407
+ ): Promise<CurationResult> {
408
+ // Dynamic import to make Agent SDK optional
409
+ const { query } = await import('@anthropic-ai/claude-agent-sdk')
410
+
411
+ const systemPrompt = this.buildCurationPrompt(triggerType)
412
+
413
+ // Format the conversation as a readable transcript for the prompt
414
+ const transcript = this._formatConversationTranscript(messages)
415
+
416
+ // Build the prompt with transcript + curation request
417
+ const prompt = `Here is the conversation transcript to curate:
418
+
419
+ ${transcript}
420
+
421
+ ---
422
+
423
+ This session has ended. Please curate the memories from this conversation according to your system instructions. Return ONLY the JSON structure with no additional text.`
424
+
425
+ // Use Agent SDK - no API key needed, uses Claude Code OAuth
426
+ const q = query({
427
+ prompt,
428
+ options: {
429
+ systemPrompt,
430
+ permissionMode: 'bypassPermissions',
431
+ maxTurns: 1,
432
+ model: 'claude-opus-4-5-20251101',
433
+ },
434
+ })
435
+
436
+ // Iterate through the async generator to get the result
437
+ let resultText = ''
438
+ for await (const msg of q) {
439
+ if (msg.type === 'result' && 'result' in msg) {
440
+ resultText = msg.result
441
+ break
442
+ }
443
+ }
444
+
445
+ if (!resultText) {
446
+ return { session_summary: '', memories: [] }
447
+ }
448
+
449
+ return this.parseCurationResponse(resultText)
450
+ }
451
+
452
+ /**
453
+ * Format conversation messages into a readable transcript
454
+ */
455
+ private _formatConversationTranscript(
456
+ messages: Array<{ role: 'user' | 'assistant'; content: string | any[] }>
457
+ ): string {
458
+ const lines: string[] = []
459
+
460
+ for (const msg of messages) {
461
+ const role = msg.role === 'user' ? 'User' : 'Assistant'
462
+ let content: string
463
+
464
+ if (typeof msg.content === 'string') {
465
+ content = msg.content
466
+ } else if (Array.isArray(msg.content)) {
467
+ // Extract text from content blocks
468
+ content = msg.content
469
+ .filter((block: any) => block.type === 'text' && block.text)
470
+ .map((block: any) => block.text)
471
+ .join('\n')
472
+
473
+ // Also note tool uses (but don't include full details)
474
+ const toolUses = msg.content.filter((block: any) => block.type === 'tool_use')
475
+ if (toolUses.length > 0) {
476
+ const toolNames = toolUses.map((t: any) => t.name).join(', ')
477
+ content += `\n[Used tools: ${toolNames}]`
478
+ }
479
+ } else {
480
+ content = '[empty message]'
481
+ }
482
+
483
+ if (content.trim()) {
484
+ lines.push(`**${role}:**\n${content}\n`)
485
+ }
486
+ }
487
+
488
+ return lines.join('\n')
489
+ }
490
+
491
+ /**
492
+ * Legacy method: Curate using Anthropic SDK with API key
493
+ * Kept for backwards compatibility
494
+ * @deprecated Use curateWithSDK() which uses Agent SDK (no API key needed)
495
+ */
496
+ async curateWithAnthropicSDK(
497
+ messages: Array<{ role: 'user' | 'assistant'; content: string | any[] }>,
498
+ triggerType: CurationTrigger = 'session_end'
401
499
  ): Promise<CurationResult> {
402
500
  if (!this._config.apiKey) {
403
- throw new Error('API key required for SDK mode. Set ANTHROPIC_API_KEY environment variable.')
501
+ throw new Error('API key required for Anthropic SDK mode. Set ANTHROPIC_API_KEY environment variable.')
404
502
  }
405
503
 
406
504
  // Dynamic import to make SDK optional
@@ -11,15 +11,24 @@ import type { CurationResult } from '../types/memory.ts'
11
11
 
12
12
  /**
13
13
  * Get the Claude CLI command path
14
- * Same logic as curator.ts
14
+ * Uses `which` for universal discovery across installation methods
15
15
  */
16
16
  function getClaudeCommand(): string {
17
+ // 1. Check for explicit override
17
18
  const envCommand = process.env.CURATOR_COMMAND
18
19
  if (envCommand) return envCommand
19
20
 
21
+ // 2. Use `which` to find claude in PATH (universal - works with native, homebrew, npm, etc.)
22
+ const result = Bun.spawnSync(['which', 'claude'])
23
+ if (result.exitCode === 0) {
24
+ return result.stdout.toString().trim()
25
+ }
26
+
27
+ // 3. Legacy fallback - hardcoded native install path
20
28
  const claudeLocal = join(homedir(), '.claude', 'local', 'claude')
21
29
  if (existsSync(claudeLocal)) return claudeLocal
22
30
 
31
+ // 4. Last resort
23
32
  return 'claude'
24
33
  }
25
34
 
@@ -374,7 +383,135 @@ Please process these memories according to your management procedure. Use the ex
374
383
  }
375
384
 
376
385
  /**
377
- * Manage using CLI subprocess
386
+ * Manage using Claude Agent SDK (no API key needed - uses Claude Code OAuth)
387
+ * Use this for ingest command - cleaner than CLI subprocess
388
+ */
389
+ async manageWithSDK(
390
+ projectId: string,
391
+ sessionNumber: number,
392
+ result: CurationResult,
393
+ storagePaths?: StoragePaths
394
+ ): Promise<ManagementResult> {
395
+ // Skip if disabled via config or env var
396
+ if (!this._config.enabled || process.env.MEMORY_MANAGER_DISABLED === '1') {
397
+ return {
398
+ success: true,
399
+ superseded: 0,
400
+ resolved: 0,
401
+ linked: 0,
402
+ filesRead: 0,
403
+ filesWritten: 0,
404
+ primerUpdated: false,
405
+ actions: [],
406
+ summary: 'Management agent disabled',
407
+ fullReport: 'Management agent disabled via configuration',
408
+ }
409
+ }
410
+
411
+ // Skip if no memories
412
+ if (result.memories.length === 0) {
413
+ return {
414
+ success: true,
415
+ superseded: 0,
416
+ resolved: 0,
417
+ linked: 0,
418
+ filesRead: 0,
419
+ filesWritten: 0,
420
+ primerUpdated: false,
421
+ actions: [],
422
+ summary: 'No memories to process',
423
+ fullReport: 'No memories to process - skipped',
424
+ }
425
+ }
426
+
427
+ // Load skill file
428
+ const systemPrompt = await this.buildManagementPrompt()
429
+ if (!systemPrompt) {
430
+ return {
431
+ success: false,
432
+ superseded: 0,
433
+ resolved: 0,
434
+ linked: 0,
435
+ filesRead: 0,
436
+ filesWritten: 0,
437
+ primerUpdated: false,
438
+ actions: [],
439
+ summary: '',
440
+ fullReport: 'Error: Management skill file not found',
441
+ error: 'Management skill not found',
442
+ }
443
+ }
444
+
445
+ const userMessage = this.buildUserMessage(projectId, sessionNumber, result, storagePaths)
446
+
447
+ try {
448
+ // Dynamic import to make Agent SDK optional
449
+ const { query } = await import('@anthropic-ai/claude-agent-sdk')
450
+
451
+ // Build allowed directories for file access
452
+ const globalPath = storagePaths?.globalPath ?? join(homedir(), '.local', 'share', 'memory', 'global')
453
+ const projectPath = storagePaths?.projectPath ?? join(homedir(), '.local', 'share', 'memory')
454
+
455
+ // Use Agent SDK with file tools
456
+ const q = query({
457
+ prompt: userMessage,
458
+ options: {
459
+ systemPrompt,
460
+ permissionMode: 'bypassPermissions',
461
+ model: 'claude-opus-4-5-20251101',
462
+ // Only allow file tools - no Bash, no web
463
+ allowedTools: ['Read', 'Write', 'Edit', 'Glob', 'Grep'],
464
+ // Allow access to memory directories
465
+ additionalDirectories: [globalPath, projectPath],
466
+ // Limit turns if configured
467
+ maxTurns: this._config.maxTurns,
468
+ },
469
+ })
470
+
471
+ // Iterate through the async generator to get the result
472
+ let resultText = ''
473
+ for await (const msg of q) {
474
+ if (msg.type === 'result' && 'result' in msg) {
475
+ resultText = msg.result
476
+ break
477
+ }
478
+ }
479
+
480
+ if (!resultText) {
481
+ return {
482
+ success: true,
483
+ superseded: 0,
484
+ resolved: 0,
485
+ linked: 0,
486
+ filesRead: 0,
487
+ filesWritten: 0,
488
+ primerUpdated: false,
489
+ actions: [],
490
+ summary: 'No result from management agent',
491
+ fullReport: 'Management agent completed but returned no result',
492
+ }
493
+ }
494
+
495
+ return this._parseSDKManagementResult(resultText)
496
+ } catch (error: any) {
497
+ return {
498
+ success: false,
499
+ superseded: 0,
500
+ resolved: 0,
501
+ linked: 0,
502
+ filesRead: 0,
503
+ filesWritten: 0,
504
+ primerUpdated: false,
505
+ actions: [],
506
+ summary: '',
507
+ fullReport: `Error: Agent SDK failed: ${error.message}`,
508
+ error: error.message,
509
+ }
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Manage using CLI subprocess (for hooks - keeps working while we migrate)
378
515
  * Similar to Curator.curateWithCLI
379
516
  */
380
517
  async manageWithCLI(
@@ -487,6 +624,52 @@ Please process these memories according to your management procedure. Use the ex
487
624
 
488
625
  return this.parseManagementResponse(stdout)
489
626
  }
627
+
628
+ /**
629
+ * Parse management result from Agent SDK response
630
+ * Similar to parseManagementResponse but for SDK output format
631
+ */
632
+ private _parseSDKManagementResult(resultText: string): ManagementResult {
633
+ // Extract actions section
634
+ const actionsMatch = resultText.match(/=== MANAGEMENT ACTIONS ===([\s\S]*?)(?:=== SUMMARY ===|$)/)
635
+ const actions: string[] = []
636
+ if (actionsMatch) {
637
+ const actionsText = actionsMatch[1]
638
+ const actionLines = actionsText.split('\n')
639
+ .map((line: string) => line.trim())
640
+ .filter((line: string) => /^(READ|WRITE|RECEIVED|CREATED|UPDATED|SUPERSEDED|RESOLVED|LINKED|PRIMER|SKIPPED|NO_ACTION)/.test(line))
641
+ actions.push(...actionLines)
642
+ }
643
+
644
+ // Extract the full report
645
+ const reportMatch = resultText.match(/(=== MANAGEMENT ACTIONS ===[\s\S]*)/)
646
+ const fullReport = reportMatch ? reportMatch[1].trim() : resultText
647
+
648
+ // Extract stats from result text
649
+ const supersededMatch = resultText.match(/memories_superseded[:\s]+(\d+)/i) || resultText.match(/superseded[:\s]+(\d+)/i)
650
+ const resolvedMatch = resultText.match(/memories_resolved[:\s]+(\d+)/i) || resultText.match(/resolved[:\s]+(\d+)/i)
651
+ const linkedMatch = resultText.match(/memories_linked[:\s]+(\d+)/i) || resultText.match(/linked[:\s]+(\d+)/i)
652
+ const filesReadMatch = resultText.match(/files_read[:\s]+(\d+)/i)
653
+ const filesWrittenMatch = resultText.match(/files_written[:\s]+(\d+)/i)
654
+ const primerUpdated = /primer_updated[:\s]+true/i.test(resultText) || /PRIMER\s+OK/i.test(resultText)
655
+
656
+ // Count file operations from actions if not in summary
657
+ const readActions = actions.filter((a: string) => a.startsWith('READ OK')).length
658
+ const writeActions = actions.filter((a: string) => a.startsWith('WRITE OK')).length
659
+
660
+ return {
661
+ success: true,
662
+ superseded: supersededMatch ? parseInt(supersededMatch[1]) : 0,
663
+ resolved: resolvedMatch ? parseInt(resolvedMatch[1]) : 0,
664
+ linked: linkedMatch ? parseInt(linkedMatch[1]) : 0,
665
+ filesRead: filesReadMatch ? parseInt(filesReadMatch[1]) : readActions,
666
+ filesWritten: filesWrittenMatch ? parseInt(filesWrittenMatch[1]) : writeActions,
667
+ primerUpdated,
668
+ actions,
669
+ summary: resultText.slice(0, 500),
670
+ fullReport,
671
+ }
672
+ }
490
673
  }
491
674
 
492
675
  /**
@@ -260,7 +260,7 @@ export class SmartVectorRetrieval {
260
260
  const min = Math.min(...samples)
261
261
  const max = Math.max(...samples)
262
262
  const avg = samples.reduce((a, b) => a + b, 0) / samples.length
263
- console.log(`[DEBUG] Vector similarities: min=${(min*100).toFixed(1)}% max=${(max*100).toFixed(1)}% avg=${(avg*100).toFixed(1)}% (${samples.length} samples)`)
263
+ logger.debug(`Vector similarities: min=${(min*100).toFixed(1)}% max=${(max*100).toFixed(1)}% avg=${(avg*100).toFixed(1)}% (${samples.length} samples)`, 'retrieval')
264
264
  this._vectorDebugSamples = [] // Reset for next retrieval
265
265
  }
266
266
 
@@ -516,11 +516,13 @@ export class SmartVectorRetrieval {
516
516
  })
517
517
 
518
518
  // Debug: show top 15 candidates with calculated scores
519
- console.log(`[DEBUG] Top 15 candidates (sorted):`)
520
- for (let i = 0; i < Math.min(15, projectsSorted.length); i++) {
521
- const m = projectsSorted[i]
522
- const action = m.memory.action_required ? '⚡' : ''
523
- console.log(` ${i+1}. [${m.signals.count}sig] score=${m.importanceScore.toFixed(2)} ${action} ${m.memory.content.slice(0, 45)}...`)
519
+ if (logger.isVerbose()) {
520
+ logger.debug(`Top 15 candidates (sorted):`, 'retrieval')
521
+ for (let i = 0; i < Math.min(15, projectsSorted.length); i++) {
522
+ const m = projectsSorted[i]
523
+ const action = m.memory.action_required ? '⚡' : ''
524
+ logger.debug(` ${i+1}. [${m.signals.count}sig] score=${m.importanceScore.toFixed(2)} ${action} ${m.memory.content.slice(0, 45)}...`, 'retrieval')
525
+ }
524
526
  }
525
527
 
526
528
  for (const item of projectsSorted) {
package/src/core/store.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import { createDatabase, type Database, type PersistentCollection } from '@rlabs-inc/fsdb'
7
7
  import { homedir } from 'os'
8
8
  import { join } from 'path'
9
+ import { logger } from '../utils/logger.ts'
9
10
  import {
10
11
  type CuratedMemory,
11
12
  type StoredMemory,
@@ -126,7 +127,7 @@ export class MemoryStore {
126
127
 
127
128
  // Use the configured global path (always central)
128
129
  const globalPath = this._config.globalPath
129
- console.log(`🌐 [DEBUG] Initializing global database at ${globalPath}`)
130
+ logger.debug(`Initializing global database at ${globalPath}`, 'store')
130
131
 
131
132
  const db = createDatabase({
132
133
  name: 'global',
@@ -414,13 +415,13 @@ export class MemoryStore {
414
415
  */
415
416
  async getProject(projectId: string): Promise<ProjectDB> {
416
417
  if (this._projects.has(projectId)) {
417
- console.log(`🔄 [DEBUG] Returning cached databases for ${projectId}`)
418
+ logger.debug(`Returning cached databases for ${projectId}`, 'store')
418
419
  return this._projects.get(projectId)!
419
420
  }
420
421
 
421
- console.log(`🆕 [DEBUG] Creating NEW databases for ${projectId}`)
422
+ logger.debug(`Creating NEW databases for ${projectId}`, 'store')
422
423
  const projectPath = join(this._config.basePath, projectId)
423
- console.log(` Path: ${projectPath}`)
424
+ logger.debug(`Path: ${projectPath}`, 'store')
424
425
 
425
426
  // Create the database for this project
426
427
  const db = createDatabase({
@@ -743,9 +744,7 @@ export class MemoryStore {
743
744
  ): Promise<string> {
744
745
  const { summaries } = await this.getProject(projectId)
745
746
 
746
- console.log(`📝 [DEBUG] Storing summary for ${projectId}:`)
747
- console.log(` Summary length: ${summary.length} chars`)
748
- console.log(` Summaries count before: ${summaries.all().length}`)
747
+ logger.debug(`Storing summary for ${projectId}: ${summary.length} chars`, 'store')
749
748
 
750
749
  const id = summaries.insert({
751
750
  session_id: sessionId,
@@ -754,8 +753,7 @@ export class MemoryStore {
754
753
  interaction_tone: interactionTone,
755
754
  })
756
755
 
757
- console.log(` Summaries count after: ${summaries.all().length}`)
758
- console.log(` Inserted ID: ${id}`)
756
+ logger.debug(`Summary stored with ID: ${id}`, 'store')
759
757
 
760
758
  return id
761
759
  }
@@ -766,14 +764,14 @@ export class MemoryStore {
766
764
  async getLatestSummary(projectId: string): Promise<SessionSummary | null> {
767
765
  const { summaries } = await this.getProject(projectId)
768
766
 
769
- console.log(`📖 [DEBUG] Getting latest summary for ${projectId}:`)
767
+ logger.debug(`Getting latest summary for ${projectId}`, 'store')
770
768
  const all = summaries.all()
771
- console.log(` Summaries found: ${all.length}`)
772
769
 
773
770
  if (!all.length) {
774
- console.log(` No summaries found!`)
771
+ logger.debug(`No summaries found for ${projectId}`, 'store')
775
772
  return null
776
773
  }
774
+ logger.debug(`Found ${all.length} summaries for ${projectId}`, 'store')
777
775
 
778
776
  // Sort by created timestamp (most recent first)
779
777
  const sorted = [...all].sort((a, b) => b.created - a.created)
@@ -67,6 +67,7 @@ export type CurationTrigger =
67
67
  | 'pre_compact' // Before context compression
68
68
  | 'context_full' // Context window nearly full
69
69
  | 'manual' // Manual trigger
70
+ | 'historical' // Historical session ingestion
70
71
 
71
72
  /**
72
73
  * A memory curated by Claude with semantic understanding
@@ -64,6 +64,16 @@ export const logger = {
64
64
  return _verbose
65
65
  },
66
66
 
67
+ /**
68
+ * Debug message (only shown in verbose mode)
69
+ */
70
+ debug(message: string, prefix?: string) {
71
+ if (_verbose) {
72
+ const pfx = prefix ? style('dim', `[${prefix}] `) : ''
73
+ console.log(`${style('dim', `🔍 ${pfx}${message}`)}`)
74
+ }
75
+ },
76
+
67
77
  /**
68
78
  * Info message
69
79
  */