Repository navigation
Expand file tree
/
Copy pathprofiles.js
More file actions
286 lines (253 loc) · 11.5 KB
/
Copy pathprofiles.js
File metadata and controls
286 lines (253 loc) · 11.5 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
/**
* 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/>.
*/
/**
* Stateful character and world profiles regenerated from graph state.
*
* Profiles are compact snapshots injected every turn at low token cost as stable
* anchors for the AI. They are regenerated from stored memories on a schedule -
* not from raw chat - so they stay coherent even after compaction removes older
* messages. Profile generation is a single sequential model call that produces
* all three sections at once to minimise round-trips on local hardware.
*
* Stored in chatMetadata.smartMemory.profiles as a per-character map:
* { [characterName]: { character_state, world_state, relationship_matrix, generated_at } }
*
* In group chats each member has their own entry so switching the character
* selector in the settings panel shows the correct character's profile.
*
* loadProfiles - returns stored profiles for a character from chatMetadata (null if none)
* areProfilesStale - true if a character's profiles are older than the configured threshold
* generateProfiles - calls the model and saves the result; returns the profiles
* injectProfiles - pushes the specified character's profiles into the prompt
* clearProfiles - removes stored profiles and clears the injection slot
*/
import {
setExtensionPrompt,
extension_prompt_types,
extension_prompt_roles,
} from '../../../../script.js';
import { getContext, extension_settings } from '../../../extensions.js';
import { generateMemoryExtract } from './generate.js';
import { estimateTokens, MODULE_NAME, META_KEY, PROMPT_KEY_PROFILES } from './constants.js';
import { loadCharacterMemories, formatMemoriesForPrompt } from './longterm.js';
import { loadSessionMemories } from './session.js';
import { loadCharacterEntityRegistry } from './graph-migration.js';
import { buildProfileGenerationPrompt } from './prompts.js';
import { parseProfileOutput } from './parsers.js';
import { smLog } from './logging.js';
import { invalidateUnifiedCache } from './unified-inject.js';
import { MACRO_NAMES, setMacroContent, isMacroActive } from './macros.js';
import { reportTierTrimStats } from './trim-stats.js';
// Default staleness threshold: 30 minutes. Profiles generated within this
// window are considered current and will not be regenerated on chat load.
const DEFAULT_STALE_THRESHOLD_MS = 30 * 60 * 1000;
// ---- Storage ------------------------------------------------------------
/**
* Returns stored profiles for the given character from chatMetadata, or null if none exist yet.
* @param {string} characterName
* @returns {{character_state: string, world_state: string, relationship_matrix: string, generated_at: number}|null}
*/
export function loadProfiles(characterName) {
if (!characterName) return null;
const context = getContext();
return context.chatMetadata?.[META_KEY]?.profiles?.[characterName] ?? null;
}
/**
* Persists profiles for the given character to chatMetadata.
* @param {{character_state: string, world_state: string, relationship_matrix: string, generated_at: number}} profiles
* @param {string} characterName
*/
async function saveProfiles(profiles, characterName) {
if (!characterName) return;
const context = getContext();
if (!context.chatMetadata) context.chatMetadata = {};
if (!context.chatMetadata[META_KEY]) context.chatMetadata[META_KEY] = {};
if (!context.chatMetadata[META_KEY].profiles) context.chatMetadata[META_KEY].profiles = {};
context.chatMetadata[META_KEY].profiles[characterName] = profiles;
await context.saveMetadata();
}
/**
* Returns true if stored profiles for the given character are older than the configured
* threshold or do not exist yet. Used to decide whether to regenerate on chat load.
* @param {number} [thresholdMs] - Staleness threshold in milliseconds.
* @param {string} [characterName]
* @returns {boolean}
*/
export function areProfilesStale(thresholdMs = DEFAULT_STALE_THRESHOLD_MS, characterName) {
const profiles = loadProfiles(characterName);
if (!profiles) return true;
return Date.now() - (profiles.generated_at ?? 0) > thresholdMs;
}
// ---- Generation ---------------------------------------------------------
/**
* Calls the model to regenerate character/world profiles from stored memories
* and saves the result to chatMetadata.
*
* Loads active long-term memories, session memories, and the character entity
* registry. Passes them all to buildProfileGenerationPrompt in one call.
* Returns null and logs a warning if the model produces unparseable output.
*
* @param {string} characterName - Active character name.
* @param {Function|null} [abortCheck] - Optional zero-arg function; if it returns true the write is skipped (chat switched).
* @returns {Promise<{character_state: string, world_state: string, relationship_matrix: string, generated_at: number}|null>}
*/
export async function generateProfiles(characterName, abortCheck = null) {
const settings = extension_settings[MODULE_NAME];
if (!settings.profiles_enabled || !characterName) return null;
// Only pass active (non-retired) memories to the profile prompt.
const longtermMemories = loadCharacterMemories(characterName).filter((m) => !m.superseded_by);
const sessionMemories = loadSessionMemories().filter((m) => !m.superseded_by);
if (longtermMemories.length === 0 && sessionMemories.length === 0) {
// Nothing stored yet - skip generation rather than producing empty profiles.
return null;
}
const ltText = formatMemoriesForPrompt(longtermMemories);
const sessText =
sessionMemories.length > 0
? sessionMemories.map((m) => `[${m.type}] ${m.content}`).join('\n')
: '';
// Pass entity registry names for the relationship matrix. Only character and
// place entities are useful here - concepts and objects clutter the output.
const entityRegistry = loadCharacterEntityRegistry(characterName);
const entities = entityRegistry
.filter((e) => e.type === 'character' || e.type === 'place')
.map((e) => ({ name: e.name, type: e.type }));
const prompt = buildProfileGenerationPrompt(characterName, ltText, sessText, entities);
try {
const response = await generateMemoryExtract(prompt, {
responseLength: settings.profiles_response_length ?? 600,
});
smLog('[SmartMemory] Profile generation response:', response);
if (!response) return null;
const parsed = parseProfileOutput(response);
if (!parsed) {
console.warn(
'[SmartMemory] Profile generation produced unparseable output. Check format above.',
);
return null;
}
const profiles = { ...parsed, generated_at: Date.now() };
if (abortCheck?.()) return null;
await saveProfiles(profiles, characterName);
return profiles;
} catch (err) {
console.error('[SmartMemory] Profile generation failed:', err);
return null;
}
}
// ---- Injection ----------------------------------------------------------
/**
* Formats profiles into a compact text block for prompt injection.
* Sections with empty content are omitted so the block stays short when
* the model only populated some sections.
* @param {{character_state: string, world_state: string, relationship_matrix: string}} profiles
* @param {number} budget - Token budget for the profiles block.
* @returns {string}
*/
function formatProfiles(profiles, budget) {
// Build sections in priority order: character_state is least important
// (drop first to preserve relationship context), relationship_matrix last.
const sections = [
{ key: 'character_state', label: 'Character state:' },
{ key: 'world_state', label: 'World state:' },
{ key: 'relationship_matrix', label: 'Relationships:' },
];
// Start with all non-empty sections as a mutable array of text blocks.
// Trimming rebuilds from the array rather than text-replacing, so repeated
// phrasings across sections cannot cause partial removal.
const activeParts = sections
.filter(({ key }) => profiles[key])
.map(({ key, label }) => `${label}\n${profiles[key]}`);
// Drop sections from the front (least important first) until under budget.
while (estimateTokens(activeParts.join('\n\n')) > budget && activeParts.length > 1) {
activeParts.shift();
}
return activeParts.join('\n\n');
}
/**
* Injects the given character's stored profiles into the prompt via setExtensionPrompt.
* Clears the slot if profiles are disabled, the character is unknown, or nothing is stored.
* @param {string} [characterName]
*/
export function injectProfiles(characterName) {
const settings = extension_settings[MODULE_NAME];
if (!settings.profiles_enabled) {
setMacroContent(MACRO_NAMES.profiles, '');
setExtensionPrompt(PROMPT_KEY_PROFILES, '', extension_prompt_types.NONE, 0);
invalidateUnifiedCache(PROMPT_KEY_PROFILES);
return;
}
const profiles = loadProfiles(characterName);
if (!profiles) {
setMacroContent(MACRO_NAMES.profiles, '');
setExtensionPrompt(PROMPT_KEY_PROFILES, '', extension_prompt_types.NONE, 0);
invalidateUnifiedCache(PROMPT_KEY_PROFILES);
return;
}
const budget = settings.profiles_inject_budget ?? 200;
const sections = [
profiles.character_state,
profiles.world_state,
profiles.relationship_matrix,
].filter(Boolean);
const fullTokens = estimateTokens(sections.join('\n\n'));
const text = formatProfiles(profiles, budget);
if (!text) {
setMacroContent(MACRO_NAMES.profiles, '');
setExtensionPrompt(PROMPT_KEY_PROFILES, '', extension_prompt_types.NONE, 0);
invalidateUnifiedCache(PROMPT_KEY_PROFILES);
return;
}
const template = settings.profiles_template ?? '{{profiles}}';
const content = template.replace('{{profiles}}', text);
reportTierTrimStats(PROMPT_KEY_PROFILES, estimateTokens(content), fullTokens);
setMacroContent(MACRO_NAMES.profiles, content);
if (isMacroActive(MACRO_NAMES.profiles)) {
setExtensionPrompt(PROMPT_KEY_PROFILES, '', extension_prompt_types.NONE, 0);
invalidateUnifiedCache(PROMPT_KEY_PROFILES);
return;
}
setExtensionPrompt(
PROMPT_KEY_PROFILES,
content,
settings.profiles_position ?? extension_prompt_types.IN_PROMPT,
settings.profiles_depth ?? 1,
false,
settings.profiles_role ?? extension_prompt_roles.SYSTEM,
);
}
/**
* Clears stored profiles from chatMetadata and removes the injection slot.
* If characterName is provided, only that character's entry is removed.
* If omitted, all profiles for the chat are removed.
* @param {string} [characterName]
*/
export async function clearProfiles(characterName) {
const context = getContext();
if (context.chatMetadata?.[META_KEY]?.profiles) {
if (characterName) {
delete context.chatMetadata[META_KEY].profiles[characterName];
} else {
delete context.chatMetadata[META_KEY].profiles;
}
await context.saveMetadata();
}
setExtensionPrompt(PROMPT_KEY_PROFILES, '', extension_prompt_types.NONE, 0);
invalidateUnifiedCache(PROMPT_KEY_PROFILES);
}