Skip to content

Commit 33982c4

Browse files
committed
Feat | Context Notes (Author notes)
1 parent 98bde4b commit 33982c4

10 files changed

Lines changed: 440 additions & 9 deletions

File tree

docs/ai/context-assembly.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,35 @@ If the preset is deactivated or deleted, context assembly immediately reverts to
129129

130130
See [ST Preset System — Context Assembly Override](../integrations/sillytavern-preset-system.md#context-assembly-override-phase-3) for the full algorithm with a worked before/after example.
131131

132+
## Author's Note (Context Note) Injection
133+
134+
TomoriBot supports a user-configurable **author's note** injected into the conversation history block at a specified depth. This is the TomoriBot equivalent of the author's note feature in NovelAI/SillyTavern — a short reminder string placed near the model's most recent attention to reduce context drift.
135+
136+
### Storage and Fallback
137+
138+
Two storage scopes exist, set via `/config context-note set`:
139+
140+
| Scope | Table | Column |
141+
|---|---|---|
142+
| Per-persona | `tomoris` | `context_note`, `context_note_depth` |
143+
| Global (server-wide) | `tomori_configs` | `context_note`, `context_note_depth` |
144+
145+
At inference, the **active persona's note takes priority** over the global note. If the active persona has no note (column is `NULL`), the global note is used as a fallback. If neither is set, no injection occurs.
146+
147+
### Depth Semantics
148+
149+
`depth` is an integer from `0` to `100` (Discord's message-fetch max):
150+
151+
- `depth=0` → injected **after** all fetched history messages (the most recent position)
152+
- `depth=N` → injected before the message at position `totalMessages - N` from the start
153+
- `depth >= totalMessages` → clamped to the **top** of the conversation history
154+
155+
### Injection Format
156+
157+
The note is emitted as a `"user"` role context item with `[System: Author's note — <text>]` formatting, matching the existing convention for system annotations in the dialogue stream (short-term memory summaries, users-in-conversation headers, etc.). No new `authorType` was added.
158+
159+
**Implementation location:** `buildContextNative()` in `src/utils/text/contextBuilder.ts` — the pre-loop setup computes `contextNoteTargetIndex`, and the in-loop guard emits the note at that index. A post-loop fallback handles `depth=0`.
160+
132161
## Key Behaviors
133162

134163
### User Impersonation Bypass

docs/systems/command-system.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ Rules:
268268
## Representative Command Groups
269269

270270
- `bot`: respond, generate(image), kill, impersonate
271-
- `config`: setup, model(text/image/embedding), api-key(set/delete/rotation), system-prompt(set/remove/preset), params(*), timezone, message-fetch-limit, bot-permissions
271+
- `config`: setup, model(text/image/embedding), api-key(set/delete/rotation), system-prompt(set/remove/preset), context-note(set), params(*), timezone, message-fetch-limit, bot-permissions
272272
- `nsfw`: jailbreaks
273273
- `optional-key`: google/set/remove, brave/set/remove, elevenlabs/set/remove, novelai/set/remove
274274
- `server`: trigger(add/delete), whitelist(channel/role/remove), stm(manage), cooldown(triggers), auto-trigger(channels/threshold), matrix(link/unlink), quota(image-generation/text-generation/video-generation/reset), rp-channels, crosschannel-blocklist, welcome-channel(set/remove), private-channels, user-blacklist(add/remove), member-permissions, always-reply, thought-logs-channel

docs/systems/database-schema.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ Also requires pgvector (`CREATE EXTENSION IF NOT EXISTS vector`).
105105
- `tomori_configs.llm_logit_biases` stores server-wide logit-bias entries as raw text/token-ID input plus tokenizer-specific cached resolutions. Raw text stays canonical so entries can be refreshed when `llm_id` changes.
106106
- `tomori_configs.videogen_enabled` gates both slash-command and tool-driven video generation exposure.
107107
- `tomori_configs.video_model_id` stores the active server-scoped video generation model selection.
108+
- `tomori_configs.context_note` stores the server-wide author's note injected into conversation history at inference time. Acts as a fallback when the active persona has no persona-specific note.
109+
- `tomori_configs.context_note_depth` stores the injection depth for the global note: `0` = bottom of fetched history (most recent), `N` = N messages from the bottom, clamped to top if it exceeds the actual count.
110+
- `tomoris.context_note` stores a per-persona author's note. Takes priority over `tomori_configs.context_note` at inference when non-null.
111+
- `tomoris.context_note_depth` stores the injection depth for the persona-specific note, using the same semantics as `tomori_configs.context_note_depth`.
108112

109113
### NovelAI profile tags
110114

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
/**
2+
* Command: /config context-note set
3+
* Allows users to set an author's note injected into conversation history
4+
* at a configurable depth to combat context drift.
5+
*
6+
* Scopes:
7+
* - persona: Bound to a specific persona (persona picker shown first)
8+
* - global: Server-wide fallback used when the active persona has no note
9+
*
10+
* At inference, the active persona's note takes priority over the global note.
11+
* Submitting a blank note clears (removes) the stored value.
12+
*/
13+
14+
import type { ButtonInteraction, ChatInputCommandInteraction, Client, ModalSubmitInteraction } from "discord.js";
15+
import { MessageFlags, TextInputStyle } from "discord.js";
16+
import type { TomoriState, UserRow } from "@/types/db/schema";
17+
import { sql } from "@/utils/db/client";
18+
import { getCachedTomoriState, invalidateTomoriStateCache } from "@/utils/cache/tomoriStateCache";
19+
import { loadAllPersonasForServer } from "@/utils/db/dbRead";
20+
import { localizer } from "@/utils/text/localizer";
21+
import { log, ColorCode } from "@/utils/misc/logger";
22+
import { replyInfoEmbed, promptWithRawModal, replyPaginatedPersonaChoicesV2 } from "@/utils/discord/interactionHelper";
23+
24+
const MODAL_CUSTOM_ID = "config_context_note_modal";
25+
const CONTEXT_NOTE_MAX_LENGTH = 2000;
26+
const CONTEXT_NOTE_DEPTH_MAX = 100;
27+
28+
/**
29+
* Configure the /config context-note set subcommand metadata.
30+
* The commandLoader auto-localizes descriptions, option descriptions, and choice labels
31+
* from the keys at commands.config.context-note.set.* in the locale files.
32+
* @param subcommand - Builder provided by commandLoader
33+
* @returns Configured builder
34+
*/
35+
export const configureSubcommand = (subcommand: import("discord.js").SlashCommandSubcommandBuilder) =>
36+
subcommand
37+
.setName("set")
38+
.setDescription(localizer("en-US", "commands.config.context-note.set.description"))
39+
.addStringOption((option) =>
40+
option
41+
.setName("scope")
42+
.setDescription(localizer("en-US", "commands.config.context-note.set.scope_description"))
43+
.setRequired(true)
44+
.addChoices(
45+
{
46+
name: localizer("en-US", "commands.config.context-note.set.persona_option"),
47+
value: "persona",
48+
},
49+
{
50+
name: localizer("en-US", "commands.config.context-note.set.global_option"),
51+
value: "global",
52+
},
53+
),
54+
);
55+
56+
/**
57+
* Execute /config context-note set.
58+
* @param _client - Discord client (unused)
59+
* @param interaction - Chat input command interaction
60+
* @param _userData - User row (unused)
61+
* @param locale - User's locale for localization
62+
*/
63+
export async function execute(
64+
_client: Client,
65+
interaction: ChatInputCommandInteraction,
66+
_userData: UserRow,
67+
locale: string,
68+
): Promise<void> {
69+
// 1. Channel guard
70+
if (!interaction.channel) {
71+
await replyInfoEmbed(interaction, locale, {
72+
titleKey: "general.errors.channel_only_title",
73+
descriptionKey: "general.errors.channel_only_description",
74+
color: ColorCode.ERROR,
75+
flags: MessageFlags.Ephemeral,
76+
});
77+
return;
78+
}
79+
80+
// 2. Resolve server identity and fetch cached state
81+
const serverId = interaction.guildId ?? interaction.user.id;
82+
const tomoriState = await getCachedTomoriState(serverId);
83+
84+
if (!tomoriState) {
85+
await replyInfoEmbed(interaction, locale, {
86+
titleKey: "general.errors.tomori_not_setup_title",
87+
descriptionKey: "general.errors.tomori_not_setup_description",
88+
color: ColorCode.ERROR,
89+
flags: MessageFlags.Ephemeral,
90+
});
91+
return;
92+
}
93+
94+
// 3. Read required scope option
95+
const scope = interaction.options.getString("scope", true) as "persona" | "global";
96+
97+
// 4. Declare interaction handles outside try-catch for fallback error replies
98+
let modalHost: ChatInputCommandInteraction | ButtonInteraction = interaction;
99+
let modalSubmitInteraction: ModalSubmitInteraction | undefined;
100+
let selectedPersona: TomoriState | null = null;
101+
102+
try {
103+
// 5. Persona scope: show paginated persona picker first
104+
if (scope === "persona") {
105+
const allPersonas = await loadAllPersonasForServer(interaction.guild?.id ?? interaction.user.id);
106+
107+
if (allPersonas.length === 0) {
108+
await replyInfoEmbed(interaction, locale, {
109+
titleKey: "commands.config.context-note.set.no_personas_title",
110+
descriptionKey: "commands.config.context-note.set.no_personas_description",
111+
color: ColorCode.ERROR,
112+
flags: MessageFlags.Ephemeral,
113+
});
114+
return;
115+
}
116+
117+
// 5a. Display paginated persona picker; preserveSelectedInteraction=true
118+
// returns the unacknowledged ButtonInteraction so we can show a modal on it.
119+
const personaSelection = await replyPaginatedPersonaChoicesV2(interaction, locale, {
120+
personas: allPersonas,
121+
color: ColorCode.INFO,
122+
preserveSelectedInteraction: true,
123+
onSelect: async () => {},
124+
});
125+
126+
if (!personaSelection.success || !personaSelection.interaction || personaSelection.selectedIndex === undefined) {
127+
return;
128+
}
129+
130+
// 5b. Hand the ButtonInteraction to promptWithRawModal instead of ChatInputCommandInteraction
131+
modalHost = personaSelection.interaction;
132+
selectedPersona = allPersonas[personaSelection.selectedIndex] ?? null;
133+
134+
if (!selectedPersona?.tomori_id) {
135+
return;
136+
}
137+
}
138+
139+
// 6. Load existing values for pre-fill
140+
let existingNote: string | null | undefined;
141+
let existingDepth: number;
142+
143+
if (scope === "persona" && selectedPersona) {
144+
existingNote = selectedPersona.context_note;
145+
existingDepth = selectedPersona.context_note_depth ?? 0;
146+
} else {
147+
existingNote = tomoriState.config.context_note;
148+
existingDepth = tomoriState.config.context_note_depth ?? 0;
149+
}
150+
151+
// 7. Show modal with note text + depth fields, pre-filled with existing values
152+
const modalResult = await promptWithRawModal(
153+
modalHost,
154+
locale,
155+
{
156+
modalCustomId: MODAL_CUSTOM_ID,
157+
modalTitleKey: "commands.config.context-note.set.modal_title",
158+
components: [
159+
{
160+
customId: "context_note_text",
161+
style: TextInputStyle.Paragraph,
162+
labelKey: "commands.config.context-note.set.text_label",
163+
placeholder: "commands.config.context-note.set.text_placeholder",
164+
required: false,
165+
maxLength: CONTEXT_NOTE_MAX_LENGTH,
166+
value: existingNote || undefined,
167+
},
168+
{
169+
customId: "context_note_depth",
170+
style: TextInputStyle.Short,
171+
labelKey: "commands.config.context-note.set.depth_label",
172+
placeholder: "commands.config.context-note.set.depth_placeholder",
173+
required: true,
174+
maxLength: 3,
175+
value: String(existingDepth),
176+
},
177+
],
178+
},
179+
MessageFlags.Ephemeral,
180+
);
181+
182+
if (modalResult.outcome !== "submit") {
183+
log.info(`Context note modal ${modalResult.outcome}`);
184+
return;
185+
}
186+
187+
// 8. Assign (not declare) after successful submit
188+
modalSubmitInteraction = modalResult.interaction;
189+
190+
if (!modalSubmitInteraction) {
191+
log.error("Modal submit interaction is undefined after successful submit");
192+
return;
193+
}
194+
195+
// 9. Parse and validate the submitted values
196+
const rawNote = (modalResult.values?.context_note_text ?? "").trim();
197+
const rawDepth = (modalResult.values?.context_note_depth ?? "0").trim();
198+
const parsedDepth = Number.parseInt(rawDepth, 10);
199+
200+
if (Number.isNaN(parsedDepth) || parsedDepth < 0 || parsedDepth > CONTEXT_NOTE_DEPTH_MAX) {
201+
await replyInfoEmbed(modalSubmitInteraction, locale, {
202+
titleKey: "commands.config.context-note.set.invalid_depth_title",
203+
descriptionKey: "commands.config.context-note.set.invalid_depth_description",
204+
color: ColorCode.ERROR,
205+
flags: MessageFlags.Ephemeral,
206+
});
207+
return;
208+
}
209+
210+
// 10. Blank text = remove the note (NULL + reset depth to 0)
211+
const noteToStore = rawNote || null;
212+
const depthToStore = rawNote ? parsedDepth : 0;
213+
const isRemoving = !rawNote;
214+
215+
// 11. Persist to the appropriate table
216+
if (scope === "persona" && selectedPersona?.tomori_id) {
217+
await sql`
218+
UPDATE tomoris
219+
SET context_note = ${noteToStore},
220+
context_note_depth = ${depthToStore}
221+
WHERE tomori_id = ${selectedPersona.tomori_id}
222+
`;
223+
} else {
224+
await sql`
225+
UPDATE tomori_configs
226+
SET context_note = ${noteToStore},
227+
context_note_depth = ${depthToStore}
228+
WHERE server_id = ${tomoriState.server_id}
229+
`;
230+
}
231+
232+
// 12. Invalidate cache AFTER the successful write
233+
invalidateTomoriStateCache(serverId);
234+
235+
// 13. Reply with scoped success message
236+
const scopeLabel =
237+
scope === "persona" && selectedPersona
238+
? selectedPersona.tomori_nickname
239+
: localizer(locale, "commands.config.context-note.set.global_option");
240+
241+
if (isRemoving) {
242+
await replyInfoEmbed(modalSubmitInteraction, locale, {
243+
titleKey: "commands.config.context-note.set.success_removed_title",
244+
descriptionKey: "commands.config.context-note.set.success_removed_description",
245+
descriptionVars: { scope: scopeLabel },
246+
color: ColorCode.SUCCESS,
247+
flags: MessageFlags.Ephemeral,
248+
});
249+
} else {
250+
const preview = (noteToStore ?? "").substring(0, 200);
251+
await replyInfoEmbed(modalSubmitInteraction, locale, {
252+
titleKey: "commands.config.context-note.set.success_set_title",
253+
descriptionKey: "commands.config.context-note.set.success_set_description",
254+
descriptionVars: { scope: scopeLabel, depth: String(depthToStore), preview },
255+
color: ColorCode.SUCCESS,
256+
flags: MessageFlags.Ephemeral,
257+
});
258+
}
259+
260+
log.info(
261+
`Context note ${isRemoving ? "cleared" : "updated"} for server ${serverId} scope=${scope}${selectedPersona ? ` persona=${selectedPersona.tomori_id}` : ""} depth=${depthToStore}`,
262+
);
263+
} catch (error) {
264+
log.error("Failed to set context note:", error as Error);
265+
266+
// 14. Use the most specific available interaction for the error reply
267+
const replyTarget = modalSubmitInteraction ?? modalHost;
268+
269+
await replyInfoEmbed(replyTarget, locale, {
270+
titleKey: "general.errors.unknown_error_title",
271+
descriptionKey: "general.errors.unknown_error_description",
272+
color: ColorCode.ERROR,
273+
flags: MessageFlags.Ephemeral,
274+
});
275+
}
276+
}

src/db/schema.sql

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2095,3 +2095,18 @@ DROP TRIGGER IF EXISTS update_saved_provider_configs_timestamp ON saved_provider
20952095
CREATE TRIGGER update_saved_provider_configs_timestamp
20962096
BEFORE UPDATE ON saved_provider_configs
20972097
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
2098+
2099+
-- ============================================================
2100+
-- Context Note / Author's Note (April 2026)
2101+
-- Short reminder string injected into conversation history at a
2102+
-- user-specified depth from the bottom to reduce context drift.
2103+
-- Persona value wins at inference; server (global) value is the fallback.
2104+
-- depth=0 means "at the very bottom" (after all fetched messages).
2105+
-- depth=N means N messages above the bottom; clamped to top if N > total.
2106+
-- ============================================================
2107+
-- Per-persona note (on tomoris)
2108+
SELECT add_column_if_not_exists('tomoris', 'context_note', 'TEXT', 'NULL');
2109+
SELECT add_column_if_not_exists('tomoris', 'context_note_depth', 'INTEGER', '0');
2110+
-- Server-wide / global fallback note (on tomori_configs)
2111+
SELECT add_column_if_not_exists('tomori_configs', 'context_note', 'TEXT', 'NULL');
2112+
SELECT add_column_if_not_exists('tomori_configs', 'context_note_depth', 'INTEGER', '0');

0 commit comments

Comments
 (0)