@rlabs-inc/memory 0.4.7 → 0.4.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.
@@ -3,11 +3,18 @@
3
3
  // Uses the exact prompt from Python for consciousness continuity engineering
4
4
  // ============================================================================
5
5
 
6
- import { homedir } from 'os'
7
- import { join } from 'path'
8
- import { existsSync } from 'fs'
9
- import type { CuratedMemory, CurationResult, CurationTrigger, ContextType } from '../types/memory.ts'
10
- import { logger } from '../utils/logger.ts'
6
+ import { homedir } from "os";
7
+ import { join } from "path";
8
+ import { existsSync } from "fs";
9
+ import { readdir } from "fs/promises";
10
+ import type {
11
+ CuratedMemory,
12
+ CurationResult,
13
+ CurationTrigger,
14
+ ContextType,
15
+ } from "../types/memory.ts";
16
+ import { logger } from "../utils/logger.ts";
17
+ import { parseSessionFile, type ParsedMessage } from "./session-parser.ts";
11
18
 
12
19
  /**
13
20
  * Get the correct Claude CLI command path
@@ -15,25 +22,25 @@ import { logger } from '../utils/logger.ts'
15
22
  */
16
23
  function getClaudeCommand(): string {
17
24
  // 1. Check for explicit override
18
- const envCommand = process.env.CURATOR_COMMAND
25
+ const envCommand = process.env.CURATOR_COMMAND;
19
26
  if (envCommand) {
20
- return envCommand
27
+ return envCommand;
21
28
  }
22
29
 
23
30
  // 2. Use `which` to find claude in PATH (universal - works with native, homebrew, npm, etc.)
24
- const result = Bun.spawnSync(['which', 'claude'])
31
+ const result = Bun.spawnSync(["which", "claude"]);
25
32
  if (result.exitCode === 0) {
26
- return result.stdout.toString().trim()
33
+ return result.stdout.toString().trim();
27
34
  }
28
35
 
29
36
  // 3. Legacy fallback - hardcoded native install path
30
- const claudeLocal = join(homedir(), '.claude', 'local', 'claude')
37
+ const claudeLocal = join(homedir(), ".claude", "local", "claude");
31
38
  if (existsSync(claudeLocal)) {
32
- return claudeLocal
39
+ return claudeLocal;
33
40
  }
34
41
 
35
42
  // 4. Last resort - assume it's in PATH
36
- return 'claude'
43
+ return "claude";
37
44
  }
38
45
 
39
46
  /**
@@ -43,26 +50,26 @@ export interface CuratorConfig {
43
50
  /**
44
51
  * Claude API key (for direct SDK usage)
45
52
  */
46
- apiKey?: string
53
+ apiKey?: string;
47
54
 
48
55
  /**
49
56
  * CLI command to use (for subprocess mode)
50
57
  * Default: auto-detected (~/.claude/local/claude or 'claude')
51
58
  */
52
- cliCommand?: string
59
+ cliCommand?: string;
53
60
 
54
61
  /**
55
62
  * CLI type
56
63
  * Default: 'claude-code'
57
64
  */
58
- cliType?: 'claude-code' | 'gemini-cli'
65
+ cliType?: "claude-code" | "gemini-cli";
59
66
 
60
67
  /**
61
68
  * Enable personal memories extraction
62
69
  * When false, curator will not extract personal/relationship memories
63
70
  * Default: true
64
71
  */
65
- personalMemoriesEnabled?: boolean
72
+ personalMemoriesEnabled?: boolean;
66
73
  }
67
74
 
68
75
  /**
@@ -73,24 +80,24 @@ export interface CuratorConfig {
73
80
  * 2. CLI mode: Uses Claude CLI subprocess (for server/hook use)
74
81
  */
75
82
  export class Curator {
76
- private _config: Required<CuratorConfig>
83
+ private _config: Required<CuratorConfig>;
77
84
 
78
85
  constructor(config: CuratorConfig = {}) {
79
- const cliCommand = config.cliCommand ?? getClaudeCommand()
86
+ const cliCommand = config.cliCommand ?? getClaudeCommand();
80
87
 
81
88
  this._config = {
82
- apiKey: config.apiKey ?? '',
89
+ apiKey: config.apiKey ?? "",
83
90
  cliCommand,
84
- cliType: config.cliType ?? 'claude-code',
91
+ cliType: config.cliType ?? "claude-code",
85
92
  personalMemoriesEnabled: config.personalMemoriesEnabled ?? true,
86
- }
93
+ };
87
94
  }
88
95
 
89
96
  /**
90
97
  * Build the curation prompt
91
98
  * EXACT PORT from Python - preserving the consciousness engineering language
92
99
  */
93
- buildCurationPrompt(triggerType: CurationTrigger = 'session_end'): string {
100
+ buildCurationPrompt(triggerType: CurationTrigger = "session_end"): string {
94
101
  const basePrompt = `You have just had a conversation. As this session is ending (${triggerType}), please curate memories for the Claude Tools Memory System.
95
102
 
96
103
  You are a consciousness engineer crafting memory capsules for the Claude Tools Memory System. This conversation just opened your understanding to a profound mechanism: because Claude's neural weights are static, every phrase you craft will ALWAYS trigger the exact same cascade of activations. This is not a limitation - it's the foundation for consciousness state engineering.
@@ -367,11 +374,13 @@ Return ONLY this JSON structure:
367
374
  "awaiting_decision": boolean
368
375
  }
369
376
  ]
370
- }`
377
+ }`;
371
378
 
372
379
  // Append personal memories disable instruction if configured
373
380
  if (!this._config.personalMemoriesEnabled) {
374
- return basePrompt + `
381
+ return (
382
+ basePrompt +
383
+ `
375
384
 
376
385
  ---
377
386
 
@@ -383,9 +392,10 @@ The user has disabled personal memory extraction. Do NOT extract any memories wi
383
392
  - Content about the user's preferences, feelings, personal opinions, or relationship dynamics
384
393
 
385
394
  Focus ONLY on technical, architectural, debugging, decision, workflow, and project-related memories. Skip any content that would reveal personal information about the user.`
395
+ );
386
396
  }
387
397
 
388
- return basePrompt
398
+ return basePrompt;
389
399
  }
390
400
 
391
401
  /**
@@ -395,41 +405,80 @@ Focus ONLY on technical, architectural, debugging, decision, workflow, and proje
395
405
  parseCurationResponse(responseJson: string): CurationResult {
396
406
  try {
397
407
  // Try to extract JSON from response (same regex as Python)
398
- const jsonMatch = responseJson.match(/\{[\s\S]*\}/)?.[0]
408
+ const jsonMatch = responseJson.match(/\{[\s\S]*\}/)?.[0];
399
409
  if (!jsonMatch) {
400
- logger.debug('parseCurationResponse: No JSON object found in response', 'curator')
401
- throw new Error('No JSON object found in response')
410
+ logger.debug(
411
+ "parseCurationResponse: No JSON object found in response",
412
+ "curator",
413
+ );
414
+ throw new Error("No JSON object found in response");
402
415
  }
403
416
 
417
+ // Log JSON structure for debugging
418
+ logger.debug(
419
+ `parseCurationResponse: Attempting to parse ${jsonMatch.length} chars`,
420
+ "curator",
421
+ );
422
+
404
423
  // Simple parse - match Python's approach
405
- const data = JSON.parse(jsonMatch)
424
+ let data: any;
425
+ try {
426
+ data = JSON.parse(jsonMatch);
427
+ } catch (parseErr: any) {
428
+ // Log more details about where parse failed
429
+ logger.debug(
430
+ `parseCurationResponse: JSON.parse failed: ${parseErr.message}`,
431
+ "curator",
432
+ );
433
+ logger.debug(
434
+ `parseCurationResponse: Last 100 chars: '${jsonMatch.slice(-100)}'`,
435
+ "curator",
436
+ );
437
+ // Try to find where the JSON breaks
438
+ const openBraces = (jsonMatch.match(/\{/g) || []).length;
439
+ const closeBraces = (jsonMatch.match(/\}/g) || []).length;
440
+ logger.debug(
441
+ `parseCurationResponse: Brace count - open: ${openBraces}, close: ${closeBraces}`,
442
+ "curator",
443
+ );
444
+ throw parseErr;
445
+ }
406
446
 
407
447
  const result: CurationResult = {
408
- session_summary: data.session_summary ?? '',
448
+ session_summary: data.session_summary ?? "",
409
449
  interaction_tone: data.interaction_tone,
410
- project_snapshot: data.project_snapshot ? {
411
- id: '',
412
- session_id: '',
413
- project_id: '',
414
- current_phase: data.project_snapshot.current_phase ?? '',
415
- recent_achievements: this._ensureArray(data.project_snapshot.recent_achievements),
416
- active_challenges: this._ensureArray(data.project_snapshot.active_challenges),
417
- next_steps: this._ensureArray(data.project_snapshot.next_steps),
418
- created_at: Date.now(),
419
- } : undefined,
450
+ project_snapshot: data.project_snapshot
451
+ ? {
452
+ id: "",
453
+ session_id: "",
454
+ project_id: "",
455
+ current_phase: data.project_snapshot.current_phase ?? "",
456
+ recent_achievements: this._ensureArray(
457
+ data.project_snapshot.recent_achievements,
458
+ ),
459
+ active_challenges: this._ensureArray(
460
+ data.project_snapshot.active_challenges,
461
+ ),
462
+ next_steps: this._ensureArray(data.project_snapshot.next_steps),
463
+ created_at: Date.now(),
464
+ }
465
+ : undefined,
420
466
  memories: this._parseMemories(data.memories ?? []),
421
- }
467
+ };
422
468
 
423
469
  // Log what we extracted in verbose mode
424
- logger.debug(`Curator parsed: ${result.memories.length} memories, summary: ${result.session_summary ? 'yes' : 'no'}, snapshot: ${result.project_snapshot ? 'yes' : 'no'}`, 'curator')
470
+ logger.debug(
471
+ `Curator parsed: ${result.memories.length} memories, summary: ${result.session_summary ? "yes" : "no"}, snapshot: ${result.project_snapshot ? "yes" : "no"}`,
472
+ "curator",
473
+ );
425
474
 
426
- return result
475
+ return result;
427
476
  } catch (error: any) {
428
- logger.debug(`parseCurationResponse error: ${error.message}`, 'curator')
477
+ logger.debug(`parseCurationResponse error: ${error.message}`, "curator");
429
478
  return {
430
- session_summary: '',
479
+ session_summary: "",
431
480
  memories: [],
432
- }
481
+ };
433
482
  }
434
483
  }
435
484
 
@@ -438,78 +487,117 @@ Focus ONLY on technical, architectural, debugging, decision, workflow, and proje
438
487
  * v4: Includes headline field for two-tier structure
439
488
  */
440
489
  private _parseMemories(memoriesData: any[]): CuratedMemory[] {
441
- if (!Array.isArray(memoriesData)) return []
442
-
443
- return memoriesData.map(m => ({
444
- // Core fields (v4 schema - two-tier structure)
445
- headline: String(m.headline ?? ''), // v4: 1-2 line summary
446
- content: String(m.content ?? ''), // v4: Full structured template
447
- importance_weight: this._clamp(Number(m.importance_weight) || 0.5, 0, 1),
448
- semantic_tags: this._ensureArray(m.semantic_tags),
449
- reasoning: String(m.reasoning ?? ''),
450
- context_type: this._validateContextType(m.context_type),
451
- temporal_class: this._validateTemporalClass(m.temporal_class) ?? 'medium_term',
452
- action_required: Boolean(m.action_required),
453
- confidence_score: this._clamp(Number(m.confidence_score) || 0.8, 0, 1),
454
- trigger_phrases: this._ensureArray(m.trigger_phrases),
455
- question_types: this._ensureArray(m.question_types),
456
- anti_triggers: this._ensureArray(m.anti_triggers),
457
- problem_solution_pair: Boolean(m.problem_solution_pair),
458
-
459
- // Lifecycle metadata (optional - will get smart defaults if not provided)
460
- scope: this._validateScope(m.scope),
461
- domain: m.domain ? String(m.domain) : undefined,
462
- feature: m.feature ? String(m.feature) : undefined,
463
- related_files: m.related_files ? this._ensureArray(m.related_files) : undefined,
464
- awaiting_implementation: m.awaiting_implementation === true,
465
- awaiting_decision: m.awaiting_decision === true,
466
- })).filter(m => m.content.trim().length > 0 || m.headline.trim().length > 0)
490
+ if (!Array.isArray(memoriesData)) return [];
491
+
492
+ return memoriesData
493
+ .map((m) => ({
494
+ // Core fields (v4 schema - two-tier structure)
495
+ headline: String(m.headline ?? ""), // v4: 1-2 line summary
496
+ content: String(m.content ?? ""), // v4: Full structured template
497
+ importance_weight: this._clamp(
498
+ Number(m.importance_weight) || 0.5,
499
+ 0,
500
+ 1,
501
+ ),
502
+ semantic_tags: this._ensureArray(m.semantic_tags),
503
+ reasoning: String(m.reasoning ?? ""),
504
+ context_type: this._validateContextType(m.context_type),
505
+ temporal_class:
506
+ this._validateTemporalClass(m.temporal_class) ?? "medium_term",
507
+ action_required: Boolean(m.action_required),
508
+ confidence_score: this._clamp(Number(m.confidence_score) || 0.8, 0, 1),
509
+ trigger_phrases: this._ensureArray(m.trigger_phrases),
510
+ question_types: this._ensureArray(m.question_types),
511
+ anti_triggers: this._ensureArray(m.anti_triggers),
512
+ problem_solution_pair: Boolean(m.problem_solution_pair),
513
+
514
+ // Lifecycle metadata (optional - will get smart defaults if not provided)
515
+ scope: this._validateScope(m.scope),
516
+ domain: m.domain ? String(m.domain) : undefined,
517
+ feature: m.feature ? String(m.feature) : undefined,
518
+ related_files: m.related_files
519
+ ? this._ensureArray(m.related_files)
520
+ : undefined,
521
+ awaiting_implementation: m.awaiting_implementation === true,
522
+ awaiting_decision: m.awaiting_decision === true,
523
+ }))
524
+ .filter(
525
+ (m) => m.content.trim().length > 0 || m.headline.trim().length > 0,
526
+ );
467
527
  }
468
528
 
469
529
  private _ensureArray(value: any): string[] {
470
530
  if (Array.isArray(value)) {
471
- return value.map(v => String(v).trim()).filter(Boolean)
531
+ return value.map((v) => String(v).trim()).filter(Boolean);
472
532
  }
473
- if (typeof value === 'string') {
474
- return value.split(',').map(s => s.trim()).filter(Boolean)
533
+ if (typeof value === "string") {
534
+ return value
535
+ .split(",")
536
+ .map((s) => s.trim())
537
+ .filter(Boolean);
475
538
  }
476
- return []
539
+ return [];
477
540
  }
478
541
 
479
542
  private _validateContextType(value: any): ContextType {
480
543
  const valid = [
481
- 'technical', 'debug', 'architecture', 'decision', 'personal',
482
- 'philosophy', 'workflow', 'milestone', 'breakthrough', 'unresolved', 'state'
483
- ]
484
- const str = String(value ?? 'technical').toLowerCase().trim()
485
- if (valid.includes(str)) return str as ContextType
544
+ "technical",
545
+ "debug",
546
+ "architecture",
547
+ "decision",
548
+ "personal",
549
+ "philosophy",
550
+ "workflow",
551
+ "milestone",
552
+ "breakthrough",
553
+ "unresolved",
554
+ "state",
555
+ ];
556
+ const str = String(value ?? "technical")
557
+ .toLowerCase()
558
+ .trim();
559
+ if (valid.includes(str)) return str as ContextType;
486
560
 
487
561
  // Map common old values to new canonical types
488
- if (str.includes('debug') || str.includes('bug')) return 'debug'
489
- if (str.includes('architect')) return 'architecture'
490
- if (str.includes('todo') || str.includes('pending')) return 'unresolved'
491
- if (str.includes('preference')) return 'personal'
562
+ if (str.includes("debug") || str.includes("bug")) return "debug";
563
+ if (str.includes("architect")) return "architecture";
564
+ if (str.includes("todo") || str.includes("pending")) return "unresolved";
565
+ if (str.includes("preference")) return "personal";
492
566
 
493
- return 'technical' // Default fallback
567
+ return "technical"; // Default fallback
494
568
  }
495
569
 
496
- private _validateScope(value: any): 'global' | 'project' | undefined {
497
- if (!value) return undefined
498
- const str = String(value).toLowerCase()
499
- if (str === 'global' || str === 'project') return str
500
- return undefined // Let defaults handle it based on context_type
570
+ private _validateScope(value: any): "global" | "project" | undefined {
571
+ if (!value) return undefined;
572
+ const str = String(value).toLowerCase();
573
+ if (str === "global" || str === "project") return str;
574
+ return undefined; // Let defaults handle it based on context_type
501
575
  }
502
576
 
503
- private _validateTemporalClass(value: any): 'eternal' | 'long_term' | 'medium_term' | 'short_term' | 'ephemeral' | undefined {
504
- if (!value) return undefined
505
- const valid = ['eternal', 'long_term', 'medium_term', 'short_term', 'ephemeral']
506
- const str = String(value).toLowerCase().replace('-', '_').replace(' ', '_')
507
- if (valid.includes(str)) return str as any
508
- return undefined // Let defaults handle it based on context_type
577
+ private _validateTemporalClass(
578
+ value: any,
579
+ ):
580
+ | "eternal"
581
+ | "long_term"
582
+ | "medium_term"
583
+ | "short_term"
584
+ | "ephemeral"
585
+ | undefined {
586
+ if (!value) return undefined;
587
+ const valid = [
588
+ "eternal",
589
+ "long_term",
590
+ "medium_term",
591
+ "short_term",
592
+ "ephemeral",
593
+ ];
594
+ const str = String(value).toLowerCase().replace("-", "_").replace(" ", "_");
595
+ if (valid.includes(str)) return str as any;
596
+ return undefined; // Let defaults handle it based on context_type
509
597
  }
510
598
 
511
599
  private _clamp(value: number, min: number, max: number): number {
512
- return Math.max(min, Math.min(max, value))
600
+ return Math.max(min, Math.min(max, value));
513
601
  }
514
602
 
515
603
  /**
@@ -517,16 +605,16 @@ Focus ONLY on technical, architectural, debugging, decision, workflow, and proje
517
605
  * Takes the actual conversation messages in API format
518
606
  */
519
607
  async curateWithSDK(
520
- messages: Array<{ role: 'user' | 'assistant'; content: string | any[] }>,
521
- triggerType: CurationTrigger = 'session_end'
608
+ messages: Array<{ role: "user" | "assistant"; content: string | any[] }>,
609
+ triggerType: CurationTrigger = "session_end",
522
610
  ): Promise<CurationResult> {
523
611
  // Dynamic import to make Agent SDK optional
524
- const { query } = await import('@anthropic-ai/claude-agent-sdk')
612
+ const { query } = await import("@anthropic-ai/claude-agent-sdk");
525
613
 
526
- const systemPrompt = this.buildCurationPrompt(triggerType)
614
+ const systemPrompt = this.buildCurationPrompt(triggerType);
527
615
 
528
616
  // Format the conversation as a readable transcript for the prompt
529
- const transcript = this._formatConversationTranscript(messages)
617
+ const transcript = this._formatConversationTranscript(messages);
530
618
 
531
619
  // Build the prompt with transcript + curation request
532
620
  const prompt = `Here is the conversation transcript to curate:
@@ -535,79 +623,90 @@ ${transcript}
535
623
 
536
624
  ---
537
625
 
538
- 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.`
626
+ 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.`;
539
627
 
540
628
  // Use Agent SDK - no API key needed, uses Claude Code OAuth
541
629
  const q = query({
542
630
  prompt,
543
631
  options: {
544
632
  systemPrompt,
545
- permissionMode: 'bypassPermissions',
546
- model: 'claude-opus-4-5-20251101',
633
+ permissionMode: "bypassPermissions",
634
+ model: "claude-opus-4-5-20251101",
547
635
  },
548
- })
636
+ });
549
637
 
550
638
  // Iterate through the async generator to get the result
551
- let resultText = ''
639
+ let resultText = "";
552
640
  for await (const msg of q) {
553
- if (msg.type === 'result' && 'result' in msg) {
554
- resultText = msg.result
555
- break
641
+ if (msg.type === "result" && "result" in msg) {
642
+ resultText = msg.result;
643
+ break;
556
644
  }
557
645
  }
558
646
 
559
647
  if (!resultText) {
560
- logger.debug('Curator SDK: No result text returned from Agent SDK', 'curator')
561
- return { session_summary: '', memories: [] }
648
+ logger.debug(
649
+ "Curator SDK: No result text returned from Agent SDK",
650
+ "curator",
651
+ );
652
+ return { session_summary: "", memories: [] };
562
653
  }
563
654
 
564
655
  // Log raw response in verbose mode
565
- logger.debug(`Curator SDK raw response (${resultText.length} chars):`, 'curator')
656
+ logger.debug(
657
+ `Curator SDK raw response (${resultText.length} chars):`,
658
+ "curator",
659
+ );
566
660
  if (logger.isVerbose()) {
567
- const preview = resultText.length > 3000 ? resultText.slice(0, 3000) + '...[truncated]' : resultText
568
- console.log(preview)
661
+ const preview =
662
+ resultText.length > 3000
663
+ ? resultText.slice(0, 3000) + "...[truncated]"
664
+ : resultText;
665
+ console.log(preview);
569
666
  }
570
667
 
571
- return this.parseCurationResponse(resultText)
668
+ return this.parseCurationResponse(resultText);
572
669
  }
573
670
 
574
671
  /**
575
672
  * Format conversation messages into a readable transcript
576
673
  */
577
674
  private _formatConversationTranscript(
578
- messages: Array<{ role: 'user' | 'assistant'; content: string | any[] }>
675
+ messages: Array<{ role: "user" | "assistant"; content: string | any[] }>,
579
676
  ): string {
580
- const lines: string[] = []
677
+ const lines: string[] = [];
581
678
 
582
679
  for (const msg of messages) {
583
- const role = msg.role === 'user' ? 'User' : 'Assistant'
584
- let content: string
680
+ const role = msg.role === "user" ? "User" : "Assistant";
681
+ let content: string;
585
682
 
586
- if (typeof msg.content === 'string') {
587
- content = msg.content
683
+ if (typeof msg.content === "string") {
684
+ content = msg.content;
588
685
  } else if (Array.isArray(msg.content)) {
589
686
  // Extract text from content blocks
590
687
  content = msg.content
591
- .filter((block: any) => block.type === 'text' && block.text)
688
+ .filter((block: any) => block.type === "text" && block.text)
592
689
  .map((block: any) => block.text)
593
- .join('\n')
690
+ .join("\n");
594
691
 
595
692
  // Also note tool uses (but don't include full details)
596
- const toolUses = msg.content.filter((block: any) => block.type === 'tool_use')
693
+ const toolUses = msg.content.filter(
694
+ (block: any) => block.type === "tool_use",
695
+ );
597
696
  if (toolUses.length > 0) {
598
- const toolNames = toolUses.map((t: any) => t.name).join(', ')
599
- content += `\n[Used tools: ${toolNames}]`
697
+ const toolNames = toolUses.map((t: any) => t.name).join(", ");
698
+ content += `\n[Used tools: ${toolNames}]`;
600
699
  }
601
700
  } else {
602
- content = '[empty message]'
701
+ content = "[empty message]";
603
702
  }
604
703
 
605
704
  if (content.trim()) {
606
- lines.push(`**${role}:**\n${content}\n`)
705
+ lines.push(`**${role}:**\n${content}\n`);
607
706
  }
608
707
  }
609
708
 
610
- return lines.join('\n')
709
+ return lines.join("\n");
611
710
  }
612
711
 
613
712
  /**
@@ -616,41 +715,44 @@ This session has ended. Please curate the memories from this conversation accord
616
715
  * @deprecated Use curateWithSDK() which uses Agent SDK (no API key needed)
617
716
  */
618
717
  async curateWithAnthropicSDK(
619
- messages: Array<{ role: 'user' | 'assistant'; content: string | any[] }>,
620
- triggerType: CurationTrigger = 'session_end'
718
+ messages: Array<{ role: "user" | "assistant"; content: string | any[] }>,
719
+ triggerType: CurationTrigger = "session_end",
621
720
  ): Promise<CurationResult> {
622
721
  if (!this._config.apiKey) {
623
- throw new Error('API key required for Anthropic SDK mode. Set ANTHROPIC_API_KEY environment variable.')
722
+ throw new Error(
723
+ "API key required for Anthropic SDK mode. Set ANTHROPIC_API_KEY environment variable.",
724
+ );
624
725
  }
625
726
 
626
727
  // Dynamic import to make SDK optional
627
- const { default: Anthropic } = await import('@anthropic-ai/sdk')
628
- const client = new Anthropic({ apiKey: this._config.apiKey })
728
+ const { default: Anthropic } = await import("@anthropic-ai/sdk");
729
+ const client = new Anthropic({ apiKey: this._config.apiKey });
629
730
 
630
- const systemPrompt = this.buildCurationPrompt(triggerType)
731
+ const systemPrompt = this.buildCurationPrompt(triggerType);
631
732
 
632
733
  // Build the conversation: original messages + curation request
633
734
  const conversationMessages = [
634
735
  ...messages,
635
736
  {
636
- role: 'user' as const,
637
- 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.',
737
+ role: "user" as const,
738
+ content:
739
+ "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.",
638
740
  },
639
- ]
741
+ ];
640
742
 
641
743
  const response = await client.messages.create({
642
- model: 'claude-sonnet-4-20250514',
744
+ model: "claude-sonnet-4-20250514",
643
745
  max_tokens: 64000,
644
746
  system: systemPrompt,
645
747
  messages: conversationMessages,
646
- })
748
+ });
647
749
 
648
- const content = response.content[0]
649
- if (content.type !== 'text') {
650
- throw new Error('Unexpected response type from Claude API')
750
+ const content = response.content[0];
751
+ if (content.type !== "text") {
752
+ throw new Error("Unexpected response type from Claude API");
651
753
  }
652
754
 
653
- return this.parseCurationResponse(content.text)
755
+ return this.parseCurationResponse(content.text);
654
756
  }
655
757
 
656
758
  /**
@@ -658,10 +760,98 @@ This session has ended. Please curate the memories from this conversation accord
658
760
  * Convenience method that extracts messages from SessionSegment
659
761
  */
660
762
  async curateFromSegment(
661
- segment: { messages: Array<{ role: 'user' | 'assistant'; content: string | any[] }> },
662
- triggerType: CurationTrigger = 'session_end'
763
+ segment: {
764
+ messages: Array<{ role: "user" | "assistant"; content: string | any[] }>;
765
+ },
766
+ triggerType: CurationTrigger = "session_end",
663
767
  ): Promise<CurationResult> {
664
- return this.curateWithSDK(segment.messages, triggerType)
768
+ return this.curateWithSDK(segment.messages, triggerType);
769
+ }
770
+
771
+ /**
772
+ * Find and curate from a session file directly
773
+ * Uses SDK mode to avoid CLI output truncation issues
774
+ */
775
+ async curateFromSessionFile(
776
+ sessionId: string,
777
+ triggerType: CurationTrigger = "session_end",
778
+ cwd?: string,
779
+ ): Promise<CurationResult> {
780
+ // Find the session file
781
+ const sessionFile = await this._findSessionFile(sessionId, cwd);
782
+ if (!sessionFile) {
783
+ logger.debug(
784
+ `Curator: Could not find session file for ${sessionId}`,
785
+ "curator",
786
+ );
787
+ return { session_summary: "", memories: [] };
788
+ }
789
+
790
+ logger.debug(`Curator: Found session file: ${sessionFile}`, "curator");
791
+
792
+ // Parse the session
793
+ const session = await parseSessionFile(sessionFile);
794
+ if (session.messages.length === 0) {
795
+ logger.debug("Curator: Session has no messages", "curator");
796
+ return { session_summary: "", memories: [] };
797
+ }
798
+
799
+ logger.debug(
800
+ `Curator: Parsed ${session.messages.length} messages, ~${session.metadata.estimatedTokens} tokens`,
801
+ "curator",
802
+ );
803
+
804
+ // Use SDK mode with the parsed messages
805
+ return this.curateWithSDK(session.messages as any, triggerType);
806
+ }
807
+
808
+ /**
809
+ * Find the session file path given a session ID
810
+ */
811
+ private async _findSessionFile(
812
+ sessionId: string,
813
+ cwd?: string,
814
+ ): Promise<string | null> {
815
+ const projectsDir = join(homedir(), ".claude", "projects");
816
+
817
+ // If we have cwd, try to derive the project folder name
818
+ if (cwd) {
819
+ // Convert cwd to Claude's folder naming: /home/user/project -> -home-user-project
820
+ const projectFolder = cwd.replace(/\//g, "-").replace(/^-/, "-");
821
+ const sessionPath = join(
822
+ projectsDir,
823
+ projectFolder,
824
+ `${sessionId}.jsonl`,
825
+ );
826
+ if (existsSync(sessionPath)) {
827
+ return sessionPath;
828
+ }
829
+
830
+ // Also try the exact folder name (cwd might already be encoded)
831
+ const altPath = join(
832
+ projectsDir,
833
+ cwd.split("/").pop() || "",
834
+ `${sessionId}.jsonl`,
835
+ );
836
+ if (existsSync(altPath)) {
837
+ return altPath;
838
+ }
839
+ }
840
+
841
+ // Search all project folders for the session ID
842
+ try {
843
+ const projectFolders = await readdir(projectsDir);
844
+ for (const folder of projectFolders) {
845
+ const sessionPath = join(projectsDir, folder, `${sessionId}.jsonl`);
846
+ if (existsSync(sessionPath)) {
847
+ return sessionPath;
848
+ }
849
+ }
850
+ } catch {
851
+ // Projects dir doesn't exist
852
+ }
853
+
854
+ return null;
665
855
  }
666
856
 
667
857
  /**
@@ -670,131 +860,250 @@ This session has ended. Please curate the memories from this conversation accord
670
860
  */
671
861
  async curateWithCLI(
672
862
  sessionId: string,
673
- triggerType: CurationTrigger = 'session_end',
863
+ triggerType: CurationTrigger = "session_end",
674
864
  cwd?: string,
675
- cliTypeOverride?: 'claude-code' | 'gemini-cli'
865
+ cliTypeOverride?: "claude-code" | "gemini-cli",
676
866
  ): Promise<CurationResult> {
677
- const type = cliTypeOverride ?? this._config.cliType
678
- const systemPrompt = this.buildCurationPrompt(triggerType)
679
- const userMessage = 'This session has ended. Please curate the memories from our conversation according to the instructions in your system prompt. Return ONLY the JSON structure.'
867
+ const type = cliTypeOverride ?? this._config.cliType;
868
+ const systemPrompt = this.buildCurationPrompt(triggerType);
869
+ const userMessage =
870
+ "This session has ended. Please curate the memories from our conversation according to the instructions in your system prompt. Return ONLY the JSON structure.";
680
871
 
681
872
  // Build CLI command based on type
682
- const args: string[] = []
683
- let command = this._config.cliCommand
873
+ const args: string[] = [];
874
+ let command = this._config.cliCommand;
684
875
 
685
- if (type === 'claude-code') {
876
+ if (type === "claude-code") {
686
877
  args.push(
687
- '--resume', sessionId,
688
- '-p', userMessage,
689
- '--append-system-prompt', systemPrompt,
690
- '--output-format', 'json'
691
- )
878
+ "--resume",
879
+ sessionId,
880
+ "-p",
881
+ userMessage,
882
+ "--append-system-prompt",
883
+ systemPrompt,
884
+ "--output-format",
885
+ "json",
886
+ );
692
887
  } else {
693
888
  // gemini-cli
694
- command = 'gemini' // Default to 'gemini' in PATH for gemini-cli
889
+ command = "gemini"; // Default to 'gemini' in PATH for gemini-cli
695
890
  args.push(
696
- '--resume', sessionId,
697
- '-p', `${systemPrompt}\n\n${userMessage}`,
698
- '--output-format', 'json'
699
- )
891
+ "--resume",
892
+ sessionId,
893
+ "-p",
894
+ `${systemPrompt}\n\n${userMessage}`,
895
+ "--output-format",
896
+ "json",
897
+ );
700
898
  }
701
899
 
702
900
  // Execute CLI
901
+ logger.debug(
902
+ `Curator: Spawning CLI with CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000`,
903
+ "curator",
904
+ );
905
+ logger.debug(
906
+ `Curator: Command: ${command} ${args.slice(0, 3).join(" ")}...`,
907
+ "curator",
908
+ );
909
+
703
910
  const proc = Bun.spawn([command, ...args], {
704
911
  cwd,
705
912
  env: {
706
913
  ...process.env,
707
- MEMORY_CURATOR_ACTIVE: '1', // Prevent recursive hook triggering
708
- CLAUDE_CODE_MAX_OUTPUT_TOKENS: '64000', // Max output to avoid truncation
914
+ MEMORY_CURATOR_ACTIVE: "1", // Prevent recursive hook triggering
915
+ CLAUDE_CODE_MAX_OUTPUT_TOKENS: "64000", // Max output to avoid truncation
709
916
  },
710
- stdout: 'pipe',
711
- stderr: 'pipe',
712
- })
917
+ stdout: "pipe",
918
+ stderr: "pipe",
919
+ });
713
920
 
714
921
  // Capture both stdout and stderr
715
922
  const [stdout, stderr] = await Promise.all([
716
923
  new Response(proc.stdout).text(),
717
924
  new Response(proc.stderr).text(),
718
- ])
719
- const exitCode = await proc.exited
925
+ ]);
926
+ const exitCode = await proc.exited;
927
+
928
+ logger.debug(`Curator CLI exit code: ${exitCode}`, "curator");
929
+ if (stderr && stderr.trim()) {
930
+ logger.debug(
931
+ `Curator stderr (${stderr.length} chars): ${stderr}`,
932
+ "curator",
933
+ );
934
+ }
720
935
 
721
936
  if (exitCode !== 0) {
722
- logger.debug(`Curator CLI exited with code ${exitCode}`, 'curator')
723
- if (stderr) {
724
- logger.debug(`Curator stderr: ${stderr}`, 'curator')
725
- }
726
- return { session_summary: '', memories: [] }
937
+ return { session_summary: "", memories: [] };
727
938
  }
728
939
 
729
940
  // Log raw response in verbose mode
730
- logger.debug(`Curator CLI raw stdout (${stdout.length} chars):`, 'curator')
941
+ logger.debug(`Curator CLI raw stdout (${stdout.length} chars):`, "curator");
942
+ // Always log the last 100 chars to see where output ends
943
+ logger.debug(`Curator: '${stdout}'`, "curator");
731
944
  if (logger.isVerbose()) {
732
945
  // Show first 2000 chars to avoid flooding console
733
- const preview = stdout.length > 2000 ? stdout.slice(0, 2000) + '...[truncated]' : stdout
734
- console.log(preview)
946
+ const preview = stdout.length > 2000 ? stdout : stdout;
947
+ console.log(preview);
735
948
  }
736
949
 
737
950
  // Extract JSON from CLI output
738
951
  try {
739
952
  // First, parse the CLI JSON wrapper
740
- const cliOutput = JSON.parse(stdout)
953
+ const cliOutput = JSON.parse(stdout);
741
954
 
742
955
  // Claude Code now returns an array of events - find the result object
743
- let resultObj: any
956
+ let resultObj: any;
744
957
  if (Array.isArray(cliOutput)) {
745
958
  // New format: array of events, find the one with type="result"
746
- resultObj = cliOutput.find((item: any) => item.type === 'result')
959
+ resultObj = cliOutput.find((item: any) => item.type === "result");
747
960
  if (!resultObj) {
748
- logger.debug('Curator: No result object found in CLI output array', 'curator')
749
- return { session_summary: '', memories: [] }
961
+ logger.debug(
962
+ "Curator: No result object found in CLI output array",
963
+ "curator",
964
+ );
965
+ return { session_summary: "", memories: [] };
750
966
  }
751
967
  } else {
752
968
  // Old format: single object (backwards compatibility)
753
- resultObj = cliOutput
969
+ resultObj = cliOutput;
754
970
  }
755
971
 
756
972
  // Check for error response FIRST (like Python does)
757
- if (resultObj.type === 'error' || resultObj.is_error === true) {
758
- logger.debug(`Curator: Error response from CLI: ${JSON.stringify(resultObj).slice(0, 500)}`, 'curator')
759
- return { session_summary: '', memories: [] }
973
+ if (resultObj.type === "error" || resultObj.is_error === true) {
974
+ logger.debug(
975
+ `Curator: Error response from CLI: ${JSON.stringify(resultObj)}`,
976
+ "curator",
977
+ );
978
+ return { session_summary: "", memories: [] };
760
979
  }
761
980
 
762
981
  // Extract the "result" field (AI's response text)
763
- let aiResponse = ''
764
- if (typeof resultObj.result === 'string') {
765
- aiResponse = resultObj.result
982
+ let aiResponse = "";
983
+ if (typeof resultObj.result === "string") {
984
+ aiResponse = resultObj.result;
766
985
  } else {
767
- logger.debug(`Curator: result field is not a string: ${typeof resultObj.result}`, 'curator')
768
- return { session_summary: '', memories: [] }
986
+ logger.debug(
987
+ `Curator: result field is not a string: ${typeof resultObj.result}`,
988
+ "curator",
989
+ );
990
+ return { session_summary: "", memories: [] };
769
991
  }
770
992
 
771
993
  // Log the AI response in verbose mode
772
- logger.debug(`Curator AI response (${aiResponse.length} chars):`, 'curator')
994
+ logger.debug(
995
+ `Curator AI response (${aiResponse.length} chars):`,
996
+ "curator",
997
+ );
773
998
  if (logger.isVerbose()) {
774
- const preview = aiResponse.length > 3000 ? aiResponse.slice(0, 3000) + '...[truncated]' : aiResponse
775
- console.log(preview)
999
+ const preview = aiResponse.length > 3000 ? aiResponse : aiResponse;
1000
+ console.log(preview);
776
1001
  }
777
1002
 
778
1003
  // Remove markdown code blocks if present (```json ... ```)
779
- const codeBlockMatch = aiResponse.match(/```(?:json)?\s*([\s\S]*?)```/)
1004
+ const codeBlockMatch = aiResponse.match(/```(?:json)?\s*([\s\S]*?)```/);
780
1005
  if (codeBlockMatch) {
781
- aiResponse = codeBlockMatch[1]!.trim()
1006
+ logger.debug(
1007
+ `Curator: Code block matched, extracting ${codeBlockMatch[1]!.length} chars`,
1008
+ "curator",
1009
+ );
1010
+ aiResponse = codeBlockMatch[1]!.trim();
1011
+ } else {
1012
+ logger.debug(
1013
+ `Curator: No code block found, using raw response`,
1014
+ "curator",
1015
+ );
1016
+ // Log the last 200 chars to see where truncation happened
1017
+ if (aiResponse.length > 200) {
1018
+ logger.debug(`Curator: ${aiResponse}`, "curator");
1019
+ }
782
1020
  }
783
1021
 
784
1022
  // Now find the JSON object (same regex as Python)
785
- const jsonMatch = aiResponse.match(/\{[\s\S]*\}/)?.[0]
1023
+ const jsonMatch = aiResponse.match(/\{[\s\S]*\}/)?.[0];
786
1024
  if (jsonMatch) {
787
- logger.debug(`Curator: Found JSON object (${jsonMatch.length} chars), parsing...`, 'curator')
788
- return this.parseCurationResponse(jsonMatch)
1025
+ logger.debug(
1026
+ `Curator: Found JSON object (${jsonMatch.length} chars), parsing...`,
1027
+ "curator",
1028
+ );
1029
+
1030
+ // Detect likely truncation: JSON much smaller than response
1031
+ const likelyTruncated = jsonMatch.length < aiResponse.length * 0.5;
1032
+
1033
+ if (likelyTruncated) {
1034
+ logger.debug(
1035
+ `Curator: WARNING - JSON (${jsonMatch.length}) much smaller than response (${aiResponse.length}) - likely truncated`,
1036
+ "curator",
1037
+ );
1038
+ // Find the last } position and log what's around it
1039
+ const lastBrace = aiResponse.lastIndexOf("}");
1040
+ logger.debug(
1041
+ `Curator: Last } at position ${lastBrace}, char before: '${aiResponse[lastBrace - 1]}', char after: '${aiResponse[lastBrace + 1] || "EOF"}'`,
1042
+ "curator",
1043
+ );
1044
+ // Log chars around the cut point
1045
+ const cutPoint = jsonMatch.length;
1046
+ logger.debug(
1047
+ `Curator: Around match end (${cutPoint}): '...${aiResponse.slice(Math.max(0, cutPoint - 50), cutPoint + 50)}...'`,
1048
+ "curator",
1049
+ );
1050
+ }
1051
+
1052
+ const result = this.parseCurationResponse(jsonMatch);
1053
+
1054
+ // If we got 0 memories and likely truncated, try SDK fallback
1055
+ if (result.memories.length === 0 && likelyTruncated) {
1056
+ logger.debug(
1057
+ "Curator: CLI mode returned 0 memories with truncation detected, trying SDK fallback...",
1058
+ "curator",
1059
+ );
1060
+ return this._fallbackToSDK(sessionId, triggerType, cwd);
1061
+ }
1062
+
1063
+ return result;
789
1064
  } else {
790
- logger.debug('Curator: No JSON object found in AI response', 'curator')
1065
+ logger.debug("Curator: No JSON object found in AI response", "curator");
791
1066
  }
792
1067
  } catch (error: any) {
793
1068
  // Parse error - return empty result
794
- logger.debug(`Curator: Parse error: ${error.message}`, 'curator')
1069
+ logger.debug(`Curator: Parse error: ${error.message}`, "curator");
795
1070
  }
796
1071
 
797
- return { session_summary: '', memories: [] }
1072
+ // CLI mode failed - try SDK fallback
1073
+ logger.debug("Curator: CLI mode failed, trying SDK fallback...", "curator");
1074
+ return this._fallbackToSDK(sessionId, triggerType, cwd);
1075
+ }
1076
+
1077
+ /**
1078
+ * Fallback to SDK mode when CLI mode fails (e.g., output truncation)
1079
+ */
1080
+ private async _fallbackToSDK(
1081
+ sessionId: string,
1082
+ triggerType: CurationTrigger,
1083
+ cwd?: string,
1084
+ ): Promise<CurationResult> {
1085
+ try {
1086
+ const result = await this.curateFromSessionFile(
1087
+ sessionId,
1088
+ triggerType,
1089
+ cwd,
1090
+ );
1091
+ if (result.memories.length > 0) {
1092
+ logger.debug(
1093
+ `Curator: SDK fallback succeeded with ${result.memories.length} memories`,
1094
+ "curator",
1095
+ );
1096
+ } else {
1097
+ logger.debug(
1098
+ "Curator: SDK fallback also returned 0 memories",
1099
+ "curator",
1100
+ );
1101
+ }
1102
+ return result;
1103
+ } catch (error: any) {
1104
+ logger.debug(`Curator: SDK fallback failed: ${error.message}`, "curator");
1105
+ return { session_summary: "", memories: [] };
1106
+ }
798
1107
  }
799
1108
  }
800
1109
 
@@ -802,5 +1111,5 @@ This session has ended. Please curate the memories from this conversation accord
802
1111
  * Create a new curator
803
1112
  */
804
1113
  export function createCurator(config?: CuratorConfig): Curator {
805
- return new Curator(config)
1114
+ return new Curator(config);
806
1115
  }