@199-bio/engram 0.8.0 → 0.10.0

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.
@@ -16,6 +16,7 @@ import { EngramDatabase, Memory, Digest, Episode } from "../storage/database.js"
16
16
  import { getAnthropicApiKey } from "../settings.js";
17
17
  import { KnowledgeGraph } from "../graph/knowledge-graph.js";
18
18
  import { HybridSearch } from "../retrieval/hybrid.js";
19
+ import { ConsolidationPlan, BacklogAssessment, ConsolidationProgress } from "./plan.js";
19
20
 
20
21
  const CONSOLIDATION_SYSTEM = `You are a high-quality memory consolidation system for a personal AI assistant. Your goal is to create comprehensive, nuanced digests that preserve the richness of human experience and relationships.
21
22
 
@@ -112,6 +113,7 @@ interface ConsolidateOptions {
112
113
 
113
114
  export class Consolidator {
114
115
  private client: Anthropic | null = null;
116
+ private cachedApiKey: string | null = null;
115
117
  private db: EngramDatabase;
116
118
  private graph: KnowledgeGraph | null = null;
117
119
  private search: HybridSearch | null = null;
@@ -125,14 +127,36 @@ export class Consolidator {
125
127
  this.graph = graph || null;
126
128
  this.search = search || null;
127
129
 
130
+ // Initial check
131
+ this.ensureClient();
132
+ }
133
+
134
+ /**
135
+ * Ensure client is configured with latest API key
136
+ * Lazy initialization: checks for new/updated API key each call
137
+ */
138
+ private ensureClient(): Anthropic | null {
128
139
  const apiKey = getAnthropicApiKey();
129
- if (apiKey) {
140
+
141
+ if (!apiKey) {
142
+ this.client = null;
143
+ this.cachedApiKey = null;
144
+ return null;
145
+ }
146
+
147
+ // Only recreate client if API key changed
148
+ if (apiKey !== this.cachedApiKey) {
149
+ console.error(`[Engram] Consolidator: API key ${this.cachedApiKey ? "updated" : "configured"}`);
130
150
  this.client = new Anthropic({ apiKey });
151
+ this.cachedApiKey = apiKey;
131
152
  }
153
+
154
+ return this.client;
132
155
  }
133
156
 
134
157
  isConfigured(): boolean {
135
- return this.client !== null;
158
+ // Re-check in case API key was added after startup
159
+ return this.ensureClient() !== null;
136
160
  }
137
161
 
138
162
  /**
@@ -144,7 +168,8 @@ export class Consolidator {
144
168
  contradictionsFound: number;
145
169
  memoriesProcessed: number;
146
170
  }> {
147
- if (!this.client) {
171
+ const client = this.ensureClient();
172
+ if (!client) {
148
173
  throw new Error("Consolidator not configured - set ANTHROPIC_API_KEY");
149
174
  }
150
175
 
@@ -221,7 +246,8 @@ export class Consolidator {
221
246
  private async consolidateBatch(
222
247
  memories: Memory[]
223
248
  ): Promise<ConsolidationResult | null> {
224
- if (!this.client) return null;
249
+ const client = this.ensureClient();
250
+ if (!client) return null;
225
251
 
226
252
  // Format memories for the prompt
227
253
  const memoriesText = memories
@@ -246,7 +272,7 @@ ${memoriesText}
246
272
  Create a detailed digest that preserves all important information. Respond with JSON only.`;
247
273
 
248
274
  try {
249
- const response = await this.client.messages.create({
275
+ const response = await client.messages.create({
250
276
  model: "claude-opus-4-5-20251101",
251
277
  max_tokens: 16000,
252
278
  temperature: 1, // Required for extended thinking
@@ -290,7 +316,8 @@ Create a detailed digest that preserves all important information. Respond with
290
316
  * Create an entity profile by consolidating all observations about an entity
291
317
  */
292
318
  async consolidateEntity(entityId: string): Promise<Digest | null> {
293
- if (!this.client) {
319
+ const client = this.ensureClient();
320
+ if (!client) {
294
321
  throw new Error("Consolidator not configured - set ANTHROPIC_API_KEY");
295
322
  }
296
323
 
@@ -335,7 +362,7 @@ ${memoriesText}
335
362
  Create a rich, detailed profile. Do not summarize away important nuances. Respond with JSON only.`;
336
363
 
337
364
  try {
338
- const response = await this.client.messages.create({
365
+ const response = await client.messages.create({
339
366
  model: "claude-opus-4-5-20251101",
340
367
  max_tokens: 16000,
341
368
  temperature: 1, // Required for extended thinking
@@ -444,7 +471,8 @@ Create a rich, detailed profile. Do not summarize away important nuances. Respon
444
471
  memoriesCreated: number;
445
472
  entitiesCreated: number;
446
473
  }> {
447
- if (!this.client) {
474
+ const client = this.ensureClient();
475
+ if (!client) {
448
476
  throw new Error("Consolidator not configured - set ANTHROPIC_API_KEY");
449
477
  }
450
478
 
@@ -537,7 +565,8 @@ Create a rich, detailed profile. Do not summarize away important nuances. Respon
537
565
  private async extractMemoriesFromEpisodes(
538
566
  episodes: Episode[]
539
567
  ): Promise<EpisodeExtractionResult | null> {
540
- if (!this.client) return null;
568
+ const client = this.ensureClient();
569
+ if (!client) return null;
541
570
 
542
571
  // Format conversation
543
572
  const conversationText = episodes
@@ -555,7 +584,7 @@ Respond with JSON only.`;
555
584
 
556
585
  try {
557
586
  // Use Haiku for speed/cost (no extended thinking needed)
558
- const response = await this.client.messages.create({
587
+ const response = await client.messages.create({
559
588
  model: "claude-haiku-4-5-20251201",
560
589
  max_tokens: 4000,
561
590
  messages: [
@@ -588,42 +617,365 @@ Respond with JSON only.`;
588
617
  }
589
618
 
590
619
  /**
591
- * Run full consolidation cycle (episodes memories → digests)
620
+ * Assess current backlog and return a plan
621
+ */
622
+ assessBacklog(): BacklogAssessment {
623
+ const plan = new ConsolidationPlan(this.db);
624
+ return plan.assessBacklog();
625
+ }
626
+
627
+ /**
628
+ * Run full consolidation cycle with safety SOP
592
629
  * This is the "sleep cycle" that should run periodically
630
+ *
631
+ * SOP (Standard Operating Procedure):
632
+ * 1. Assess backlog and check budget
633
+ * 2. Check for incomplete runs to resume
634
+ * 3. Create checkpoint for tracking
635
+ * 4. Process with rate limiting and validation
636
+ * 5. Check rollback triggers after each batch
637
+ * 6. Mark complete or fail with error
593
638
  */
594
- async runSleepCycle(): Promise<{
639
+ async runSleepCycle(options: {
640
+ force?: boolean; // Ignore budget limits
641
+ maxBatches?: number; // Override max batches
642
+ } = {}): Promise<{
595
643
  episodesProcessed: number;
596
644
  memoriesCreated: number;
597
645
  digestsCreated: number;
598
646
  contradictionsFound: number;
599
647
  connectionsDecayed: number;
600
648
  logsCleanedUp: number;
649
+ tokensUsed: number;
650
+ estimatedCost: number;
651
+ aborted: boolean;
652
+ abortReason?: string;
601
653
  }> {
602
- console.error("[Consolidator] Starting sleep cycle...");
654
+ const plan = new ConsolidationPlan(this.db);
655
+
656
+ // Check for incomplete run to resume
657
+ const incomplete = plan.checkForResume();
658
+ if (incomplete) {
659
+ console.error(`[Consolidator] Resuming incomplete run ${incomplete.run_id} from phase ${incomplete.phase}`);
660
+ plan.resumeFrom(incomplete);
661
+ }
662
+
663
+ // Assess backlog
664
+ const assessment = plan.assessBacklog();
665
+ console.error(`[Consolidator] Assessment: ${assessment.unconsolidatedEpisodes} episodes, ${assessment.unconsolidatedMemories} memories`);
666
+ console.error(`[Consolidator] Budget: $${assessment.dailySpent.toFixed(2)} / $${assessment.dailyBudget.toFixed(2)} (remaining: $${assessment.budgetRemaining.toFixed(2)})`);
667
+
668
+ if (assessment.isRecoveryMode) {
669
+ console.error(`[Consolidator] RECOVERY MODE: Large backlog detected, processing conservatively`);
670
+ }
671
+
672
+ // Check if we can proceed
673
+ if (!options.force && !assessment.canProceed) {
674
+ console.error(`[Consolidator] Budget exceeded, skipping consolidation`);
675
+ return {
676
+ episodesProcessed: 0,
677
+ memoriesCreated: 0,
678
+ digestsCreated: 0,
679
+ contradictionsFound: 0,
680
+ connectionsDecayed: 0,
681
+ logsCleanedUp: 0,
682
+ tokensUsed: 0,
683
+ estimatedCost: 0,
684
+ aborted: true,
685
+ abortReason: "Daily budget exceeded",
686
+ };
687
+ }
688
+
689
+ const maxBatches = options.maxBatches ?? assessment.recommendedBatches;
690
+ let totalTokens = 0;
691
+ let totalCost = 0;
692
+ let aborted = false;
693
+ let abortReason: string | undefined;
694
+
695
+ // Create checkpoint
696
+ const totalBatches = assessment.phases
697
+ .filter(p => p.phase === "episodes" || p.phase === "memories")
698
+ .reduce((sum, p) => sum + Math.min(p.batchCount, maxBatches), 0);
699
+
700
+ if (!incomplete) {
701
+ plan.createCheckpoint("episodes", totalBatches);
702
+ }
703
+
704
+ console.error(`[Consolidator] Starting sleep cycle (max ${maxBatches} batches per phase)...`);
705
+
706
+ // ============ Phase 1: Episodes → Memories ============
707
+ let episodesProcessed = incomplete?.episodes_processed || 0;
708
+ let memoriesCreated = 0;
709
+ let entitiesCreated = 0;
710
+
711
+ if (assessment.unconsolidatedEpisodes >= 4 && !aborted) {
712
+ plan.updateProgress({ phase: "episodes" });
713
+ console.error(`[Consolidator] Phase 1: Processing episodes...`);
714
+
715
+ const episodeBatchSize = 20;
716
+ const episodes = plan.getPrioritizedEpisodes(maxBatches * episodeBatchSize);
717
+
718
+ // Group by session
719
+ const sessionGroups = new Map<string, Episode[]>();
720
+ for (const ep of episodes) {
721
+ const existing = sessionGroups.get(ep.session_id) || [];
722
+ existing.push(ep);
723
+ sessionGroups.set(ep.session_id, existing);
724
+ }
725
+
726
+ let batchIndex = 0;
727
+ for (const [sessionId, sessionEpisodes] of sessionGroups) {
728
+ if (batchIndex >= maxBatches) break;
729
+ if (sessionEpisodes.length < 2) continue;
730
+
731
+ try {
732
+ // Rate limiting delay
733
+ if (batchIndex > 0) {
734
+ await plan.delay();
735
+ }
736
+
737
+ const result = await this.extractMemoriesFromEpisodes(sessionEpisodes);
738
+ plan.recordApiCall(result !== null);
739
+
740
+ if (result && result.memories.length > 0) {
741
+ for (const mem of result.memories) {
742
+ const memory = this.db.createMemory(
743
+ mem.content,
744
+ "episode_consolidation",
745
+ mem.importance,
746
+ {
747
+ eventTime: mem.event_time ? new Date(mem.event_time) : undefined,
748
+ emotionalWeight: mem.emotional_weight,
749
+ }
750
+ );
751
+ memoriesCreated++;
752
+
753
+ if (this.search) {
754
+ await this.search.indexMemory(memory);
755
+ }
756
+
757
+ if (this.graph) {
758
+ for (const ent of mem.entities || []) {
759
+ const entity = this.graph.getOrCreateEntity(
760
+ ent.name,
761
+ ent.type as "person" | "place" | "concept" | "event" | "organization"
762
+ );
763
+ this.db.addObservation(entity.id, mem.content, memory.id, 1.0);
764
+ entitiesCreated++;
765
+ }
766
+
767
+ for (const rel of mem.relationships || []) {
768
+ try {
769
+ const fromEntity = this.graph.getOrCreateEntity(rel.from, "person");
770
+ const toEntity = this.graph.getOrCreateEntity(rel.to, "person");
771
+ this.graph.relate(fromEntity.name, toEntity.name, rel.type);
772
+ } catch {
773
+ // Skip invalid relationships
774
+ }
775
+ }
776
+ }
777
+ }
778
+ }
779
+
780
+ this.db.markEpisodesConsolidated(sessionEpisodes.map(e => e.id));
781
+ episodesProcessed += sessionEpisodes.length;
782
+ batchIndex++;
783
+
784
+ // Estimate tokens (Haiku)
785
+ const batchTokens = 3000; // Conservative estimate
786
+ totalTokens += batchTokens;
787
+ totalCost += plan.calculateCost("haiku", 2000, 1000);
788
+
789
+ plan.updateProgress({
790
+ batchesCompleted: batchIndex,
791
+ episodesProcessed,
792
+ tokensUsed: totalTokens,
793
+ estimatedCost: totalCost,
794
+ });
795
+
796
+ // Check rollback triggers
797
+ const triggers = plan.checkRollbackTriggers();
798
+ const fired = triggers.filter(t => t.triggered);
799
+ if (fired.length > 0) {
800
+ aborted = true;
801
+ abortReason = fired.map(t => t.message).join("; ");
802
+ console.error(`[Consolidator] ROLLBACK TRIGGERED: ${abortReason}`);
803
+ break;
804
+ }
805
+
806
+ } catch (error) {
807
+ const errMsg = error instanceof Error ? error.message : String(error);
808
+ plan.recordError(errMsg);
809
+ plan.recordApiCall(false);
810
+ console.error(`[Consolidator] Episode batch failed: ${errMsg}`);
811
+ }
812
+ }
813
+
814
+ console.error(`[Consolidator] Episodes: ${episodesProcessed} → ${memoriesCreated} memories`);
815
+ }
816
+
817
+ // ============ Phase 2: Memories → Digests ============
818
+ let digestsCreated = incomplete?.digests_created || 0;
819
+ let contradictionsFound = incomplete?.contradictions_found || 0;
820
+ let memoriesConsolidated = incomplete?.memories_processed || 0;
821
+
822
+ if (assessment.unconsolidatedMemories >= 5 && !aborted) {
823
+ plan.updateProgress({ phase: "memories" });
824
+ console.error(`[Consolidator] Phase 2: Consolidating memories...`);
825
+
826
+ const batchSize = 15;
827
+ const memories = plan.getPrioritizedMemories(maxBatches * batchSize);
828
+
829
+ for (let i = 0; i < memories.length && !aborted; i += batchSize) {
830
+ if (i / batchSize >= maxBatches) break;
831
+
832
+ const batch = memories.slice(i, i + batchSize);
833
+ if (batch.length < 3) break;
834
+
835
+ try {
836
+ // Rate limiting delay
837
+ if (i > 0) {
838
+ await plan.delay();
839
+ }
840
+
841
+ const result = await this.consolidateBatch(batch);
842
+ plan.recordApiCall(result !== null);
843
+ plan.recordDigest(result === null || !result.digest);
844
+
845
+ if (result) {
846
+ const memoryIds = batch.map(m => m.id);
847
+ const periodStart = new Date(Math.min(...batch.map(m => m.timestamp.getTime())));
848
+ const periodEnd = new Date(Math.max(...batch.map(m => m.timestamp.getTime())));
849
+
850
+ this.db.createDigest(result.digest, 1, memoryIds, {
851
+ topic: result.topic,
852
+ periodStart,
853
+ periodEnd,
854
+ });
855
+ digestsCreated++;
856
+ memoriesConsolidated += batch.length;
857
+
858
+ for (const c of result.contradictions) {
859
+ if (c.memory_ids.length >= 2) {
860
+ const [idA, idB] = c.memory_ids.slice(0, 2);
861
+ const memA = batch.find(m => m.id === idA);
862
+ const memB = batch.find(m => m.id === idB);
863
+
864
+ if (memA && memB) {
865
+ this.db.createContradiction(memA.id, memB.id, c.description);
866
+ contradictionsFound++;
867
+ }
868
+ }
869
+ }
870
+ }
871
+
872
+ // Estimate tokens (Opus with thinking)
873
+ const batchTokens = 15000; // Conservative estimate
874
+ totalTokens += batchTokens;
875
+ totalCost += plan.calculateCost("opus", 3000, 2000, 10000);
876
+
877
+ plan.updateProgress({
878
+ batchesCompleted: (i / batchSize) + 1,
879
+ memoriesProcessed: memoriesConsolidated,
880
+ digestsCreated,
881
+ contradictionsFound,
882
+ tokensUsed: totalTokens,
883
+ estimatedCost: totalCost,
884
+ });
885
+
886
+ // Check rollback triggers
887
+ const triggers = plan.checkRollbackTriggers();
888
+ const fired = triggers.filter(t => t.triggered);
889
+ if (fired.length > 0) {
890
+ aborted = true;
891
+ abortReason = fired.map(t => t.message).join("; ");
892
+ console.error(`[Consolidator] ROLLBACK TRIGGERED: ${abortReason}`);
893
+ break;
894
+ }
603
895
 
604
- // Step 1: Process episodes into memories
605
- const episodeResult = await this.consolidateEpisodes();
606
- console.error(`[Consolidator] Episodes: ${episodeResult.episodesProcessed} → ${episodeResult.memoriesCreated} memories`);
896
+ } catch (error) {
897
+ const errMsg = error instanceof Error ? error.message : String(error);
898
+ plan.recordError(errMsg);
899
+ plan.recordApiCall(false);
900
+ console.error(`[Consolidator] Memory batch failed: ${errMsg}`);
901
+ }
902
+ }
903
+
904
+ console.error(`[Consolidator] Memories: ${memoriesConsolidated} → ${digestsCreated} digests`);
905
+ }
906
+
907
+ // ============ Phase 3: Decay connections ============
908
+ let connectionsDecayed = 0;
909
+ if (!aborted) {
910
+ plan.updateProgress({ phase: "decay" });
911
+ connectionsDecayed = this.db.decayConnections(30, 0.9);
912
+ console.error(`[Consolidator] Connections decayed: ${connectionsDecayed}`);
913
+ }
607
914
 
608
- // Step 2: Consolidate memories into digests
609
- const memoryResult = await this.consolidate();
610
- console.error(`[Consolidator] Memories: ${memoryResult.memoriesProcessed} → ${memoryResult.digestsCreated} digests`);
915
+ // ============ Phase 4: Cleanup ============
916
+ let logsCleanedUp = 0;
917
+ if (!aborted) {
918
+ plan.updateProgress({ phase: "cleanup" });
919
+ logsCleanedUp = this.db.cleanupRetrievalLogs(7);
920
+ console.error(`[Consolidator] Retrieval logs cleaned: ${logsCleanedUp}`);
921
+ }
611
922
 
612
- // Step 3: Decay unused Hebbian connections (memories that haven't fired together recently)
613
- const connectionsDecayed = this.db.decayConnections(30, 0.9);
614
- console.error(`[Consolidator] Connections decayed: ${connectionsDecayed}`);
923
+ // Mark complete or failed
924
+ if (aborted) {
925
+ plan.fail(abortReason || "Unknown error");
926
+ } else {
927
+ plan.complete();
928
+ }
615
929
 
616
- // Step 4: Clean up old retrieval logs
617
- const logsCleanedUp = this.db.cleanupRetrievalLogs(7);
618
- console.error(`[Consolidator] Retrieval logs cleaned: ${logsCleanedUp}`);
930
+ console.error(`[Consolidator] Sleep cycle complete. Tokens: ${totalTokens}, Cost: $${totalCost.toFixed(4)}`);
619
931
 
620
932
  return {
621
- episodesProcessed: episodeResult.episodesProcessed,
622
- memoriesCreated: episodeResult.memoriesCreated,
623
- digestsCreated: memoryResult.digestsCreated,
624
- contradictionsFound: memoryResult.contradictionsFound,
933
+ episodesProcessed,
934
+ memoriesCreated,
935
+ digestsCreated,
936
+ contradictionsFound,
625
937
  connectionsDecayed,
626
938
  logsCleanedUp,
939
+ tokensUsed: totalTokens,
940
+ estimatedCost: totalCost,
941
+ aborted,
942
+ abortReason,
627
943
  };
628
944
  }
945
+
946
+ /**
947
+ * Get consolidation progress for the current/latest run
948
+ */
949
+ getConsolidationProgress(): ConsolidationProgress | null {
950
+ const plan = new ConsolidationPlan(this.db);
951
+ const checkpoint = plan.checkForResume();
952
+ if (checkpoint) {
953
+ plan.resumeFrom(checkpoint);
954
+ return plan.getProgress();
955
+ }
956
+
957
+ // Get most recent completed run
958
+ const recent = this.db.getRecentCheckpoints(1);
959
+ if (recent.length > 0) {
960
+ return {
961
+ runId: recent[0].run_id,
962
+ phase: recent[0].phase,
963
+ batchesCompleted: recent[0].batches_completed,
964
+ batchesTotal: recent[0].batches_total,
965
+ memoriesProcessed: recent[0].memories_processed,
966
+ episodesProcessed: recent[0].episodes_processed,
967
+ digestsCreated: recent[0].digests_created,
968
+ contradictionsFound: recent[0].contradictions_found,
969
+ tokensUsed: recent[0].tokens_used,
970
+ estimatedCost: recent[0].estimated_cost_usd,
971
+ errors: recent[0].error ? [recent[0].error] : [],
972
+ startedAt: recent[0].started_at,
973
+ elapsedMs: recent[0].completed_at
974
+ ? recent[0].completed_at.getTime() - recent[0].started_at.getTime()
975
+ : Date.now() - recent[0].started_at.getTime(),
976
+ };
977
+ }
978
+
979
+ return null;
980
+ }
629
981
  }