Repository navigation
Expand file tree
/
Copy pathlongterm.js
More file actions
1346 lines (1223 loc) · 55.8 KB
/
Copy pathlongterm.js
File metadata and controls
1346 lines (1223 loc) · 55.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* Smart Memory - SillyTavern Extension
* Copyright (C) 2026 Senjin the Dragon
* https://github.com/senjinthedragon/Smart-Memory
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Long-term memory: per-character persistent facts stored in extension_settings.
*
* Memories survive across all sessions and are injected at the start of every
* new chat with the same character. A fresh-start flag in chatMetadata suppresses
* extraction for a specific chat while keeping injection active.
*
* loadCharacterMemories - returns the stored memory array for a character
* saveCharacterMemories - persists the memory array for a character
* clearCharacterMemories - deletes all memories for a character
* formatMemoriesForPrompt - formats the memory array as [type] content lines
* extractAndStoreMemories - runs extraction against recent messages and merges results;
* also populates activation triggers and relationship deltas
* consolidateMemories - evaluates unprocessed entries against the stable consolidated base per type
* injectMemories - pushes memories into the prompt via setExtensionPrompt;
* memories whose triggers match the current turn are injected a second
* time into PROMPT_KEY_TRIGGERED closer to the prompt
* loadRelationshipHistory - returns the relationship history map for a character
* saveRelationshipHistory - persists the relationship history map for a character
* clearRelationshipHistory - deletes the relationship history for a character
* injectRelationshipHistory - pushes relevant relationship state into the prompt via PROMPT_KEY_RELATIONSHIPS
* isFreshStart - returns whether the current chat has fresh-start enabled
* setFreshStart - toggles the fresh-start flag and saves chatMetadata
* getReadOnlyStartIndex - returns the chat index at which read-only mode was last enabled
* setReadOnlyStartIndex - stores or clears the read-only window start index (also stores/clears readOnlyStartTime)
* getReadOnlyStartTime - returns the Unix ms timestamp at which read-only mode was last enabled
*
* Internal helpers (not exported):
* shouldInjectMemory - returns 'full' or 'secondhand' based on witnessed_by vs responding character
*/
import {
setExtensionPrompt,
extension_prompt_types,
extension_prompt_roles,
saveSettingsDebounced,
getCurrentChatId,
} from '../../../../script.js';
import { generateMemoryExtract } from './generate.js';
import { getContext, extension_settings } from '../../../extensions.js';
import {
estimateTokens,
MODULE_NAME,
PROMPT_KEY_LONG,
PROMPT_KEY_TRIGGERED,
PROMPT_KEY_RELATIONSHIPS,
MEMORY_TYPES,
META_KEY,
MAX_RETIRED_POOL,
} from './constants.js';
import {
applyGraphDefaults,
loadCharacterEntityRegistry,
saveCharacterEntityRegistry,
resolveEntityNames,
reconcileEntityRegistry,
} from './graph-migration.js';
import {
buildExtractionPrompt,
buildLongtermConsolidationPrompt,
buildSupersessionConfirmPrompt,
buildTriggerGenerationPrompt,
buildRelationshipDeltaPrompt,
} from './prompts.js';
import {
parseExtractionOutput,
parseTriggerResponse,
parseRelationshipDeltaResponse,
} from './parsers.js';
import {
prioritizeMemories,
hybridPrioritize,
extractTurnEntityMentions,
reconcileTypeEntries,
selectProtectedMemories,
sortByTimeline,
trimByPriority,
keywordSet,
filterTriggersByFrequency,
} from './memory-utils.js';
import { batchVerify, getEmbeddingBatch, getHardwareProfile } from './embeddings.js';
import { smLog } from './logging.js';
import { invalidateUnifiedCache } from './unified-inject.js';
import { MACRO_NAMES, setMacroContent, isMacroActive } from './macros.js';
import { getSceneParticipants } from './scenes.js';
import { reportTierTrimStats } from './trim-stats.js';
// Maximum new entries accepted per type per extraction pass.
// Profile B (hosted) uses a higher cap because hosted models extract more
// reliably and rarely over-fire on a single type the way small local models can.
function maxNewPerType() {
return getHardwareProfile() === 'b' ? 4 : 2;
}
function incomingPriorityScore(mem) {
const typeBonus =
mem.type === 'relationship'
? 30
: mem.type === 'fact'
? 20
: mem.type === 'preference'
? 10
: 0;
return (mem.importance ?? 2) * 100 + typeBonus + (mem.ts ?? 0) / 1e13;
}
/**
* Filters a list of candidate memories against existing ones, removing
* near-duplicates and entries that fail basic quality checks. Identifies
* supersessions (state-change updates that should retire an existing memory).
*
* All texts are embedded in a single batch API call so nomic-embed-text only
* needs to load once per verification pass rather than once per candidate.
* Falls back to Jaccard word-overlap when embeddings are unavailable.
*
* @param {Array} candidates - Newly extracted memory objects to evaluate.
* @param {Array} existing - Active (non-retired) memories to compare against.
* @returns {Promise<{verified: Array, superseded: Map<string, string>, confirmed: Set<string>}>}
* verified - Candidates that passed dedup and should be added.
* superseded - Map from candidate content (lowercase) to the id of the
* existing memory it replaces.
* confirmed - Set of existing memory ids re-extracted this pass (still true).
*/
async function verifyLongtermCandidates(candidates, existing) {
if (!Array.isArray(candidates) || candidates.length === 0) {
return { verified: [], superseded: new Map(), confirmed: new Set() };
}
const bannedPhrases = ['maybe', 'might be', 'possibly', 'i think', 'perhaps', 'seems like'];
const seen = new Set();
// Apply quality filters before embedding - no point embedding entries we'll discard.
const filtered = candidates.filter((mem) => {
const text = String(mem.content || '').trim();
if (text.length < 8 || text.length > 280) return false;
const lower = text.toLowerCase();
if (bannedPhrases.some((p) => lower.includes(p))) return false;
const key = `${mem.type}|${lower}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
if (filtered.length === 0) return { verified: [], superseded: new Map(), confirmed: new Set() };
// Batch-embed all candidates and existing memories in one API call.
const { passed, superseded, confirmed, uncertain } = await batchVerify(filtered, existing);
// Method B: for pairs that scored above the same-topic threshold but had no
// state-change pattern, ask the model directly. Runs sequentially - Ollama
// serializes requests anyway and parallel calls risk OOM on 8GB VRAM.
for (const pair of uncertain) {
// Skip if a pattern already resolved this candidate as a supersession.
if (superseded.has(pair.candText)) continue;
try {
const prompt = buildSupersessionConfirmPrompt(pair.candObj.content, pair.existingContent);
const raw = await generateMemoryExtract(prompt, { responseLength: 20 });
const answer = raw
.trim()
.toUpperCase()
.split(/\s/)[0]
.replace(/[^A-Z]/g, '');
if (answer === 'UPDATE') {
superseded.set(pair.candText, pair.existingId);
smLog(
`[SmartMemory] Method B supersession: "${pair.candObj.content.slice(0, 60)}" replaces id ${pair.existingId}`,
);
}
} catch (err) {
// Non-fatal: if B fails, the candidate is treated as a new independent memory.
smLog(
`[SmartMemory] Method B confirmation failed for "${pair.candText.slice(0, 60)}": ${err.message}`,
);
}
}
const verified = filtered.filter((m) =>
passed.has(
String(m.content || '')
.toLowerCase()
.trim(),
),
);
return { verified, superseded, confirmed };
}
// ---- Storage helpers ----------------------------------------------------
/**
* Returns the memory array for a character, or an empty array if none exist.
* Migrates legacy entries (no consolidated flag) to consolidated: true on load
* so existing memories are treated as the stable base.
* @param {string} characterName
* @returns {Array<{type: string, content: string, ts: number, consolidated: boolean}>}
*/
export function loadCharacterMemories(characterName) {
if (!characterName) return [];
const chars = extension_settings[MODULE_NAME].characters;
const memories = chars?.[characterName]?.memories ?? [];
// Migrate: entries without the consolidated flag are pre-existing stable memories.
// Entries without an importance score default to 2 (medium).
// applyGraphDefaults is a safety net for entries that predate the one-shot
// migration pass. It is non-destructive and only generates a new id when one
// is truly absent (e.g. a rollback/downgrade scenario).
return memories.map((m) =>
applyGraphDefaults({
...m,
consolidated: m.consolidated ?? true,
importance: m.importance ?? 2,
expiration: m.expiration ?? 'permanent',
confidence: m.confidence ?? 0.7,
persona_relevance: m.persona_relevance ?? (m.type === 'relationship' ? 3 : 1),
intimacy_relevance: m.intimacy_relevance ?? (m.type === 'preference' ? 3 : 1),
retrieval_count: m.retrieval_count ?? 0,
// Fall back to 0 (not Date.now()) when both fields are absent so legacy
// entries don't receive an artificial recency boost in memoryUtilityScore.
last_confirmed_ts: m.last_confirmed_ts ?? m.ts ?? 0,
}),
);
}
/**
* Persists the memory array for a character into extension_settings.
* Caller must call saveSettingsDebounced() afterwards.
* @param {string} characterName
* @param {Array<{type: string, content: string, ts: number}>} memories
*/
export function saveCharacterMemories(characterName, memories) {
if (!characterName || !Array.isArray(memories)) return;
if (!extension_settings[MODULE_NAME].characters) {
extension_settings[MODULE_NAME].characters = {};
}
// Spread the existing character object so the entity registry and any other
// fields stored alongside memories (e.g. entities, canon) are preserved.
const existing = extension_settings[MODULE_NAME].characters[characterName] ?? {};
extension_settings[MODULE_NAME].characters[characterName] = {
...existing,
memories,
lastUpdated: Date.now(),
};
}
/**
* Removes all stored memories for a character.
* Caller must call saveSettingsDebounced() afterwards.
* @param {string} characterName
*/
export function clearCharacterMemories(characterName) {
if (!characterName) return;
if (extension_settings[MODULE_NAME].characters?.[characterName]) {
delete extension_settings[MODULE_NAME].characters[characterName];
}
}
// ---- Relationship history storage ---------------------------------------
/**
* Returns the relationship history map for a character, or an empty object
* if none exists yet. Keys are "subject→target" strings; values are
* { descriptors: string[], magnitude: string, updatedAt: number }.
* @param {string} characterName
* @returns {Object}
*/
export function loadRelationshipHistory(characterName) {
if (!characterName) return {};
const raw =
extension_settings[MODULE_NAME].characters?.[characterName]?.relationship_history ?? {};
// Normalize entries still in the old flat format { descriptors: string[], magnitude: string }
// to the current per-descriptor format { descriptors: Array<{word, magnitude}> }.
// This is a read-time safety net in case the schema migration did not run yet.
const normalized = {};
for (const [key, state] of Object.entries(raw)) {
const descs = state.descriptors ?? [];
if (descs.length > 0 && typeof descs[0] === 'string') {
const fallbackMag = state.magnitude ?? 'medium';
normalized[key] = {
descriptors: descs.map((w) => ({ word: w, magnitude: fallbackMag })),
updatedAt: state.updatedAt ?? Date.now(),
};
} else {
normalized[key] = state;
}
}
return normalized;
}
/**
* Persists a relationship history map for a character into extension_settings.
* Caller must call saveSettingsDebounced() afterwards.
* @param {string} characterName
* @param {Object} history - Map of "subject→target" keys to state objects.
*/
export function saveRelationshipHistory(characterName, history) {
if (!characterName || typeof history !== 'object') return;
if (!extension_settings[MODULE_NAME].characters) {
extension_settings[MODULE_NAME].characters = {};
}
const existing = extension_settings[MODULE_NAME].characters[characterName] ?? {};
extension_settings[MODULE_NAME].characters[characterName] = {
...existing,
relationship_history: history,
};
}
/**
* Removes the relationship history for a character from extension_settings.
* Caller must call saveSettingsDebounced() afterwards.
* @param {string} characterName
*/
export function clearRelationshipHistory(characterName) {
if (!characterName) return;
const char = extension_settings[MODULE_NAME].characters?.[characterName];
if (char) delete char.relationship_history;
}
// ---- Formatting ---------------------------------------------------------
/**
* Formats the memory array into [type] content lines for prompt injection
* or for passing to the extraction prompt as existing context.
* @param {Array<{type: string, content: string}>} memories
* @returns {string}
*/
export function formatMemoriesForPrompt(memories) {
if (!memories || memories.length === 0) return '';
return sortByTimeline(memories)
.map((m) => `[${m.type}] ${m.content}`)
.join('\n');
}
// ---- Extraction ---------------------------------------------------------
/**
* Merges new memories into the existing set, skipping near-duplicates and
* trimming to the configured maximum.
*
* Duplicate detection uses word-overlap (Jaccard-like): if more than 70% of
* the words in the new memory also appear in an existing memory, it is
* considered a duplicate and dropped. This is intentionally conservative -
* false negatives (keeping a near-duplicate) are less harmful than false
* positives (discarding genuinely new information).
*
* When the merged total exceeds maxTotal, the oldest entries are dropped
* (splice from the front) to keep the most recent memories.
*
* @param {Array} existing - Currently stored memories.
* @param {Array} incoming - Newly extracted memories to merge in.
* @param {number} maxTotal - Hard cap on the total number of memories to keep.
* @returns {Array} The merged memory array.
*/
/**
* Merges new memories into the existing set with two layers of churn control:
*
* 1. Per-type extraction cap: at most maxNewPerType() entries per type are
* accepted per pass (2 on Profile A, 4 on Profile B). Prevents a burst of
* similar events from flooding one type while the rest accumulate normally.
*
* 2. Per-type storage cap: derived from maxTotal / number of types (rounded up).
* When a new entry would push a type over its cap, the lowest-priority entry
* of that type is evicted first so the total stays balanced. Cloud users who
* raise maxTotal get proportionally larger per-type budgets automatically.
*
* @param {Array} existing - Currently stored memories.
* @param {Array} incoming - Newly extracted memories (already deduped by verifyLongtermCandidates).
* @param {number} maxTotal - Hard cap on total stored memories (from settings).
* @returns {Array} The merged memory array.
*/
function mergeMemories(existing, incoming, maxTotal) {
const merged = [...existing];
// Per-type cap derived from the overall max - equal split across all types.
// At 25 total -> 7 per type (ceil(25/4)), at 50 -> 13, at 100 -> 25.
const perTypeCap = Math.ceil(maxTotal / MEMORY_TYPES.length);
// Track how many new entries we've accepted per type this pass.
const addedPerType = new Map();
// Sort incoming by priority so when we hit the per-type cap we keep the best.
const sorted = [...incoming].sort((a, b) => incomingPriorityScore(b) - incomingPriorityScore(a));
for (const mem of sorted) {
const typeAdded = addedPerType.get(mem.type) ?? 0;
if (typeAdded >= maxNewPerType()) continue;
// If this type is already at the per-type storage cap, evict the
// lowest-priority existing entry of this type before adding the new one -
// but only if the new entry actually outscores the one we'd displace.
// Without this guard a burst of low-priority new entries could displace
// high-priority existing ones that are far more valuable to keep.
const typeEntries = merged.filter((m) => m.type === mem.type);
if (typeEntries.length >= perTypeCap) {
const prioritized = prioritizeMemories(typeEntries);
// Last entry in prioritized is lowest priority.
const toEvict = prioritized[prioritized.length - 1];
if (incomingPriorityScore(mem) <= incomingPriorityScore(toEvict)) continue;
const evictIdx = merged.findIndex(
(m) => m.type === toEvict.type && m.content === toEvict.content,
);
if (evictIdx >= 0) merged.splice(evictIdx, 1);
}
merged.push(mem);
addedPerType.set(mem.type, typeAdded + 1);
}
// Final safety trim to maxTotal in case types were already over cap before
// this pass (e.g. migrating from an older version without the cap).
if (merged.length > maxTotal) {
const prioritized = prioritizeMemories(merged);
merged.splice(0, merged.length, ...prioritized);
merged.splice(maxTotal);
}
return merged;
}
/**
* Extracts memorable facts from recent chat messages via the model and merges
* them into the character's stored memories. Safe to fire-and-forget.
* @param {string} characterName
* @param {Array} recentMessages - Last N message objects from context.chat.
* @returns {Promise<number>} Count of new memories added (0 on failure or nothing found).
*/
export async function extractAndStoreMemories(characterName, recentMessages, statusFn = null) {
const settings = extension_settings[MODULE_NAME];
if (!settings.longterm_enabled || !characterName) return 0;
try {
const chatHistory = recentMessages
.filter((m) => m.mes && !m.is_system)
.map((m) => `${m.name}: ${m.mes}`)
.join('\n\n');
if (!chatHistory.trim()) return 0;
const existingMemories = loadCharacterMemories(characterName);
// Separate active from retired memories. Verification and merge operate only
// on active entries; retired ones are preserved in storage for history but
// should not be compared against (or count toward type caps during merge).
const activeMemories = existingMemories.filter((m) => !m.superseded_by);
const retiredMemories = existingMemories.filter((m) => m.superseded_by);
// Only show active memories as context in the extraction prompt.
const existingText = formatMemoriesForPrompt(activeMemories);
const response = await generateMemoryExtract(
buildExtractionPrompt(chatHistory, existingText, characterName),
{
responseLength: settings.longterm_response_length || 600,
},
);
smLog(`[SmartMemory] Raw extraction response for "${characterName}":`, response);
if (!response || response.trim().toUpperCase() === 'NONE') return 0;
const parsed = parseExtractionOutput(response);
if (parsed.length === 0) {
smLog('[SmartMemory] Extraction response produced no parseable lines. Check format above.');
return 0;
}
const {
verified: newMemories,
superseded: supersessionMap,
confirmed: confirmedIds,
} = await verifyLongtermCandidates(parsed, activeMemories);
if (newMemories.length === 0) {
smLog(
`[SmartMemory] All ${parsed.length} extracted candidates were duplicates of existing memories.`,
);
return 0;
}
// Tag each new memory with the source message range and chat ID so users
// can jump back to the passage that prompted the extraction.
const context = getContext();
const chatLen = context.chat?.length ?? 1;
// Window ends one before the last message (last AI turn is excluded from extraction).
const windowEnd = Math.max(0, chatLen - 2);
const windowStart = Math.max(0, windowEnd - recentMessages.length + 1);
const sourceChatId = getCurrentChatId() ?? null;
// Derive the characters present in this extraction window so the injection
// path can downgrade memories the responding character did not witness.
const witnessedBy = getSceneParticipants(recentMessages);
for (const mem of newMemories) {
mem.source_messages = [[windowStart, windowEnd]];
mem.source_chat_id = sourceChatId;
mem.witnessed_by = witnessedBy;
}
const maxMemories = settings.longterm_max_memories || 25;
// Merge new memories into the active set. Result includes both existing
// active entries and the newly accepted candidates.
const merged = mergeMemories(activeMemories, newMemories, maxMemories);
// Apply supersession links. For each candidate that supersedes an existing
// memory: mark the old memory as retired (superseded_by + valid_to) and
// link the new memory back to it (supersedes + valid_from).
const messageIndex = Math.max(0, chatLen - 1);
const newlyRetiredIds = new Set();
for (const [candText, oldId] of supersessionMap) {
// Find the new memory in the merged active set.
const newMem = merged.find(
(m) =>
String(m.content || '')
.toLowerCase()
.trim() === candText,
);
// Find the old memory in the active set (it may not be in merged if evicted).
const oldMem = activeMemories.find((m) => m.id === oldId);
// Guard: newMem must be a different object with different content.
// merged includes both active existing memories and new candidates, so
// find() could return the existing memory when content strings match -
// which would create a self-supersession chain of identical nodes.
const newText = String(newMem?.content || '')
.toLowerCase()
.trim();
const oldText = String(oldMem?.content || '')
.toLowerCase()
.trim();
if (
newMem &&
oldMem &&
!oldMem.superseded_by &&
newMem.id !== oldMem.id &&
newText !== oldText
) {
// Link new -> old.
if (!newMem.supersedes) newMem.supersedes = [];
if (!newMem.supersedes.includes(oldId)) newMem.supersedes.push(oldId);
newMem.valid_from = newMem.valid_from ?? messageIndex;
// Retire old memory.
oldMem.superseded_by = newMem.id;
oldMem.valid_to = messageIndex;
newlyRetiredIds.add(oldId);
smLog(
`[SmartMemory] Supersession: "${oldMem.content.slice(0, 60)}" retired by "${newMem.content.slice(0, 60)}"`,
);
}
}
// Remove newly retired entries from the active merged set - they move to
// the retired pool so they stay in storage but are excluded from injection.
const finalActive = merged.filter((m) => !newlyRetiredIds.has(m.id));
// Confidence decay pass.
// Confirmed memories (re-extracted this pass) get a small confidence boost
// and reset their unconfirmed counter. All other active memories increment
// their unconfirmed counter; once it reaches the threshold, confidence
// decays slightly. Importance does not decay - only confidence (recall
// freshness) does, so impactful memories remain prioritised even as they fade.
const DECAY_THRESHOLD = 10;
const now = Date.now();
for (const mem of finalActive) {
if (confirmedIds.has(mem.id)) {
mem.last_confirmed_ts = now;
mem.confidence = Math.min(1.0, (mem.confidence ?? 1.0) + 0.05);
mem.unconfirmed_since = 0;
} else {
mem.unconfirmed_since = (mem.unconfirmed_since ?? 0) + 1;
if (mem.unconfirmed_since >= DECAY_THRESHOLD) {
mem.confidence = Math.max(0.3, (mem.confidence ?? 1.0) - 0.02);
}
}
}
// Profile B only: populate LLM-suggested triggers for newly added memories.
// Noun-derived triggers (Profile A) are redundant with direct content matching
// and add no signal - the scoring path handles Profile A via content overlap.
// LLM-suggested triggers add genuine value by capturing synonyms and contextual
// cues not present in the memory text itself, scored at a higher bonus per hit.
// Runs sequentially to avoid OOM on limited VRAM (Ollama serialises anyway).
const existingKeys = new Set(activeMemories.map((m) => `${m.type}|${m.content}`));
if (getHardwareProfile() === 'b' || settings.longterm_triggers_enabled) {
for (const mem of finalActive) {
// Skip if triggers are already present - covers both new memories with
// triggers derived this pass and existing memories that survived consolidation
// with their triggers intact. Existing memories that lost triggers through
// consolidation (no triggers field) are NOT skipped, allowing recovery.
if (Array.isArray(mem.triggers) && mem.triggers.length > 0) continue;
try {
const triggerPrompt = buildTriggerGenerationPrompt(mem.content);
const triggerResponse = await generateMemoryExtract(triggerPrompt, {
responseLength: 60,
});
const raw = parseTriggerResponse(triggerResponse, mem.content);
mem.triggers = filterTriggersByFrequency(raw, finalActive);
smLog(
`[SmartMemory] Triggers for "${mem.content.slice(0, 50)}": ${mem.triggers.join(', ')}`,
);
} catch (err) {
smLog(`[SmartMemory] Trigger generation failed: ${err.message}`);
mem.triggers = [];
}
}
}
// Relationship delta extraction: runs after memory extraction so newly
// added memories are already in finalActive and entity names are known.
// Sequential like trigger generation to avoid OOM on limited VRAM.
{
const relHistory = loadRelationshipHistory(characterName);
// Build the current-state string from stored history for the prompt baseline.
// Format: "pair: word(magnitude), word(magnitude)" so the model sees existing magnitudes.
const stateLines = Object.entries(relHistory)
.map(([pair, state]) => {
const descStr = (state.descriptors ?? [])
.map((d) => `${d.word}(${d.magnitude})`)
.join(', ');
return `${pair}: ${descStr}`;
})
.join('\n');
// Extract the character card description for seeding new pairs.
const relContext = getContext();
const cardChar = relContext.characters?.find((c) => c.name === characterName);
const cardExcerpt = cardChar?.description ?? '';
try {
if (statusFn) statusFn(`Extracting relationship history for ${characterName}...`);
const relPrompt = buildRelationshipDeltaPrompt(chatHistory, stateLines, cardExcerpt);
const relResponse = await generateMemoryExtract(relPrompt, { responseLength: 300 });
const deltas = parseRelationshipDeltaResponse(relResponse);
// Only store pairs where the character is one of the parties.
if (deltas.length > 0) {
for (const { subject, target, updates, removals } of deltas) {
const key = `${subject}→${target}`;
const existing = relHistory[key] ?? { descriptors: [], updatedAt: Date.now() };
// Build a word->magnitude map from the current stored state.
const descMap = new Map((existing.descriptors ?? []).map((d) => [d.word, d.magnitude]));
// Apply removals first, then add/update.
for (const word of removals) descMap.delete(word);
for (const { word, magnitude } of updates) descMap.set(word, magnitude);
// Enforce a hard cap of 6 descriptors per pair. When over the cap,
// drop the lowest-magnitude entries first to preserve the most significant ones.
const MAG_RANK = { high: 2, medium: 1, low: 0 };
const REL_DESCRIPTOR_CAP = 6;
if (descMap.size > REL_DESCRIPTOR_CAP) {
const sorted = Array.from(descMap.entries()).sort(
(a, b) => (MAG_RANK[b[1]] ?? 0) - (MAG_RANK[a[1]] ?? 0),
);
descMap.clear();
for (const [w, m] of sorted.slice(0, REL_DESCRIPTOR_CAP)) descMap.set(w, m);
}
relHistory[key] = {
descriptors: Array.from(descMap.entries()).map(([word, magnitude]) => ({
word,
magnitude,
})),
updatedAt: Date.now(),
};
}
saveRelationshipHistory(characterName, relHistory);
smLog(`[SmartMemory] Relationship deltas applied: ${deltas.length} pair(s)`);
}
} catch (err) {
smLog(`[SmartMemory] Relationship extraction failed: ${err.message}`);
}
}
// Resolve entity names to ids for any new memories that carried
// _raw_entity_names through the pipeline. The entity registry is loaded,
// updated in place, then persisted alongside the memories.
const entityRegistry = loadCharacterEntityRegistry(characterName);
for (const mem of finalActive) {
if (Array.isArray(mem._raw_entity_names)) {
resolveEntityNames(mem, mem._raw_entity_names, messageIndex, entityRegistry);
}
}
// Reconcile after every extraction pass, not just after consolidation.
// Local models often omit the :entity= tag even when the entity is clearly
// named in the memory content. The substring pass here catches those misses
// immediately so memories don't float as isolated nodes until the next
// consolidation cycle (which may never come for small memory sets).
if (entityRegistry.length > 0) {
reconcileEntityRegistry(entityRegistry, finalActive);
saveCharacterEntityRegistry(characterName, entityRegistry);
}
// Newly retired active memories are moved to the retired pool.
let updatedRetired = [
...retiredMemories,
...activeMemories.filter((m) => newlyRetiredIds.has(m.id)),
];
// Cap the retired pool so it does not grow unbounded when consolidation is
// disabled. Drop the oldest entries (from the front) to stay within the limit.
if (updatedRetired.length > MAX_RETIRED_POOL) {
updatedRetired = updatedRetired.slice(updatedRetired.length - MAX_RETIRED_POOL);
}
// Count new entries that made it into the final active set.
const added = finalActive.filter((m) => !existingKeys.has(`${m.type}|${m.content}`)).length;
// Save: final active set + all retired memories (history is preserved).
saveCharacterMemories(characterName, [...finalActive, ...updatedRetired]);
smLog(
`[SmartMemory] Saved ${added} new memories for "${characterName}". Active: ${finalActive.length}, Retired: ${updatedRetired.length}`,
);
return added;
} catch (err) {
console.error('[SmartMemory] Memory extraction failed:', err);
throw err;
}
}
// How many unprocessed entries of a single type must accumulate before
// consolidation fires for that type.
//
// User-configurable via settings panel; defaults preserve earlier tuned values.
const DEFAULT_CONSOLIDATION_THRESHOLDS = {
fact: 4,
relationship: 3,
preference: 3,
event: 4,
};
function getConsolidationThresholds(settings) {
return {
fact: Math.max(
2,
settings.longterm_consolidation_threshold_fact ?? DEFAULT_CONSOLIDATION_THRESHOLDS.fact,
),
relationship: Math.max(
2,
settings.longterm_consolidation_threshold_relationship ??
DEFAULT_CONSOLIDATION_THRESHOLDS.relationship,
),
preference: Math.max(
2,
settings.longterm_consolidation_threshold_preference ??
DEFAULT_CONSOLIDATION_THRESHOLDS.preference,
),
event: Math.max(
2,
settings.longterm_consolidation_threshold_event ?? DEFAULT_CONSOLIDATION_THRESHOLDS.event,
),
};
}
/**
* Runs a consolidation pass on the stored memories for a character.
*
* New approach: maintains a stable consolidated base per memory type. When
* enough unprocessed entries accumulate for a given type, the model evaluates
* only that batch against the base for that type - it may drop duplicates, fold
* new details into existing base entries, or add genuinely new entries. The
* base itself is never rewritten, only extended.
*
* Consolidation fires per-type independently - a burst of new [fact] entries
* does not trigger [relationship] consolidation.
*
* @param {string} characterName
* @param {boolean} [force=false] - If true, consolidate all types regardless of threshold.
* Used by the catch-up final pass to flush any entries that never accumulated enough
* to hit the threshold during per-chunk consolidation.
* @returns {Promise<number>} Number of memories removed by consolidation (0 on no change or failure).
*/
export async function consolidateMemories(characterName, force = false) {
const settings = extension_settings[MODULE_NAME];
if (!settings.consolidation_enabled || !characterName) return 0;
const thresholds = getConsolidationThresholds(settings);
const memories = loadCharacterMemories(characterName);
let totalRemoved = 0;
let dirty = false;
for (const type of MEMORY_TYPES) {
// Exclude retired memories from consolidation - they've already been
// replaced and should not be re-evaluated or re-injected.
const base = memories.filter((m) => m.type === type && m.consolidated && !m.superseded_by);
const unprocessed = memories.filter(
(m) => m.type === type && !m.consolidated && !m.superseded_by,
);
const threshold = thresholds[type] ?? DEFAULT_CONSOLIDATION_THRESHOLDS.fact;
if (!force && unprocessed.length < threshold) continue;
if (unprocessed.length === 0) continue;
try {
const baseText = formatMemoriesForPrompt(base);
const batchText = formatMemoriesForPrompt(unprocessed);
const response = await generateMemoryExtract(
buildLongtermConsolidationPrompt(type, baseText, batchText),
{ responseLength: Math.max(400, (base.length + unprocessed.length) * 60) },
);
smLog(`[SmartMemory] Consolidation response for [${type}]:`, response);
if (!response || response.trim().toUpperCase() === 'NONE') {
// Model found nothing to add - mark unprocessed as consolidated as-is.
unprocessed.forEach((m) => (m.consolidated = true));
dirty = true;
continue;
}
// Parse the model's output - these are the entries to add/update in the base.
const incoming = parseExtractionOutput(response);
// Mark all incoming as consolidated since they've been through the process.
const promoted = incoming.map((m) => ({ ...m, consolidated: true }));
// Reconcile promoted entries with the base so "updated" base entries
// replace older variants instead of being appended as duplicates.
const reconciledType = await reconcileTypeEntries(
base,
promoted,
0.7,
[...base, ...unprocessed],
getEmbeddingBatch,
);
// Carry source provenance forward: for each reconciled entry that matches
// a pre-consolidation memory by ID, inherit its source_messages and
// source_chat_id. Synthesized composite entries get the most recent range
// from the unprocessed batch they were derived from.
const allInputs = [...base, ...unprocessed];
const mostRecentSource = unprocessed.reduce((best, m) => {
const ranges = m.source_messages;
if (!Array.isArray(ranges) || ranges.length === 0) return best;
const last = ranges[ranges.length - 1];
return !best || last[1] > best[1] ? last : best;
}, null);
const mostRecentChatId =
unprocessed.find((m) => Array.isArray(m.source_messages) && m.source_messages.length > 0)
?.source_chat_id ?? null;
for (const entry of reconciledType) {
const match = allInputs.find((m) => m.id === entry.id);
if (match && Array.isArray(match.source_messages) && match.source_messages.length > 0) {
entry.source_messages = match.source_messages;
entry.source_chat_id = match.source_chat_id ?? null;
} else if (!entry.source_messages?.length && mostRecentSource) {
entry.source_messages = [mostRecentSource];
entry.source_chat_id = mostRecentChatId;
}
}
// Replace this type's entries. Other types are untouched.
const otherTypes = memories.filter((m) => m.type !== type);
memories.splice(0, memories.length, ...otherTypes, ...reconciledType);
const before = base.length + unprocessed.length;
const after = reconciledType.length;
const removed = before - after;
totalRemoved += Math.max(0, removed);
dirty = true;
smLog(
`[SmartMemory] [${type}] consolidation: ${unprocessed.length} unprocessed -> ${promoted.length} promoted. Base: ${base.length}. Removed: ${Math.max(0, removed)}.`,
);
} catch (err) {
console.error(`[SmartMemory] Consolidation failed for type [${type}]:`, err);
// On failure, mark unprocessed as consolidated so they don't block future passes.
// Set dirty before the forEach so a mid-loop error still triggers the save.
dirty = true;
unprocessed.forEach((m) => (m.consolidated = true));
}
}
const maxMemories = settings.longterm_max_memories || 25;
const finalMemories = sortByTimeline(trimByPriority(memories, maxMemories));
if (dirty || finalMemories.length !== memories.length) {
// Repair entity registry links after consolidation - consolidation replaces
// memories with new IDs, leaving the registry with stale memory_id refs.
// reconcileEntityRegistry prunes those stale IDs and re-links by name match.
const entityRegistry = loadCharacterEntityRegistry(characterName);
if (entityRegistry.length > 0) {
reconcileEntityRegistry(entityRegistry, finalMemories);
saveCharacterEntityRegistry(characterName, entityRegistry);
}
saveCharacterMemories(characterName, finalMemories);
}
return totalRemoved;
}
// ---- Injection ----------------------------------------------------------
/**
* Injects the character's stored memories into the prompt.
* Clears the injection slot if fresh-start is active, no character is set,
* or the character has no memories yet.
* @param {string} characterName
* @param {boolean} [updateTelemetry=false] - If true, increment retrieval_count for injected memories.
* Only pass true from the post-extraction path (one real AI response turn). All other callers
* (chat load, settings change, etc.) leave telemetry unchanged to avoid inflating the signal.
*/
/**
* Determines how a memory should be handled during injection based on its
* witnessed_by field relative to the responding character.
*
* - 'full' : inject normally (character was present, or legacy entry with no witness data)
* - 'secondhand': character was not present; caller applies secondhand framing or omits
*
* Legacy entries (witnessed_by empty or absent) are always treated as 'full' so
* memories written before this system existed are unaffected.
*
* @param {Object} memory - Long-term memory entry.
* @param {string} respondingChar - Name of the character being injected for.
* @returns {'full'|'secondhand'}
*/
function shouldInjectMemory(memory, respondingChar) {
if (!memory.witnessed_by || memory.witnessed_by.length === 0) return 'full';
if (memory.witnessed_by.includes(respondingChar)) return 'full';
return 'secondhand';
}
export async function injectMemories(characterName, updateTelemetry = false) {
const settings = extension_settings[MODULE_NAME];
if (!settings.longterm_enabled || !characterName) {
setMacroContent(MACRO_NAMES.longterm, '');
setMacroContent(MACRO_NAMES.triggered, '');
setExtensionPrompt(PROMPT_KEY_LONG, '', extension_prompt_types.NONE, 0);
setExtensionPrompt(PROMPT_KEY_TRIGGERED, '', extension_prompt_types.NONE, 0);
invalidateUnifiedCache(PROMPT_KEY_LONG);
return;
}
// Only inject active memories - retired ones (superseded_by set) are kept in
// storage for history but must not appear in the prompt.
const memories = loadCharacterMemories(characterName).filter((m) => !m.superseded_by);
if (memories.length === 0) {
setMacroContent(MACRO_NAMES.longterm, '');
setMacroContent(MACRO_NAMES.triggered, '');
setExtensionPrompt(PROMPT_KEY_LONG, '', extension_prompt_types.NONE, 0);
setExtensionPrompt(PROMPT_KEY_TRIGGERED, '', extension_prompt_types.NONE, 0);
invalidateUnifiedCache(PROMPT_KEY_LONG);
return;
}
// Trim to token budget using hybrid scoring when current-turn context is
// available. On chat load (updateTelemetry=false) there is no "current turn"
// to read entity mentions from, so plain utility scoring is used instead.
const budget = settings.longterm_inject_budget ?? 500;
const fullTokens = estimateTokens(memories.map((m) => `- ${m.content}`).join('\n'));
const protectedSet = new Set(
selectProtectedMemories(memories, ['relationship', 'preference', 'fact']),
);
let trimmed;
if (updateTelemetry) {
// Post-extraction path: we have a fresh AI response to extract entity
// mentions from. Build turn context for the hybrid scorer.
const context = getContext();
const lastMessages = (context.chat ?? []).slice(-2);
const turnMentions = extractTurnEntityMentions(lastMessages);
const entityRegistry = loadCharacterEntityRegistry(characterName);
const recentTextForScorer = lastMessages
.map((m) => m.mes || '')
.join(' ')
.toLowerCase();
trimmed = await hybridPrioritize(memories, {
turnMentions,
entityRegistry,
floorTypes: ['relationship', 'fact'],
embedFn: getEmbeddingBatch,
lastTurnText: lastMessages[lastMessages.length - 1]?.mes ?? '',
w5: getHardwareProfile() === 'b' ? 0.6 : 0.2,
recentText: recentTextForScorer,
});
} else {
trimmed = prioritizeMemories(memories);
}
// Use the injection format for budget estimation so the check matches what is actually injected.
while (