@otto-assistant/bridge 0.4.93 → 0.4.96
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.
- package/dist/anthropic-auth-plugin.js +23 -9
- package/dist/context-awareness-plugin.js +1 -1
- package/dist/context-awareness-plugin.test.js +2 -2
- package/dist/discord-utils.js +5 -2
- package/dist/kimaki-opencode-plugin.js +1 -0
- package/dist/logger.js +8 -2
- package/dist/session-handler/thread-session-runtime.js +18 -1
- package/dist/system-prompt-drift-plugin.js +251 -0
- package/dist/utils.js +5 -1
- package/package.json +2 -1
- package/src/anthropic-auth-plugin.ts +22 -9
- package/src/context-awareness-plugin.test.ts +2 -2
- package/src/context-awareness-plugin.ts +1 -1
- package/src/discord-utils.ts +19 -17
- package/src/kimaki-opencode-plugin.ts +1 -0
- package/src/logger.ts +9 -2
- package/src/session-handler/thread-session-runtime.ts +21 -1
- package/src/system-prompt-drift-plugin.ts +379 -0
- package/src/utils.ts +5 -1
|
@@ -430,15 +430,25 @@ function buildAuthorizeHandler(mode) {
|
|
|
430
430
|
function toClaudeCodeToolName(name) {
|
|
431
431
|
return OPENCODE_TO_CLAUDE_CODE_TOOL_NAME[name.toLowerCase()] ?? name;
|
|
432
432
|
}
|
|
433
|
-
function sanitizeSystemText(text) {
|
|
434
|
-
|
|
433
|
+
function sanitizeSystemText(text, onError) {
|
|
434
|
+
const startIdx = text.indexOf(OPENCODE_IDENTITY);
|
|
435
|
+
if (startIdx === -1)
|
|
436
|
+
return text;
|
|
437
|
+
const codeRefsMarker = '# Code References';
|
|
438
|
+
const endIdx = text.indexOf(codeRefsMarker, startIdx);
|
|
439
|
+
if (endIdx === -1) {
|
|
440
|
+
onError?.(`sanitizeSystemText: could not find '# Code References' after OpenCode identity`);
|
|
441
|
+
return text;
|
|
442
|
+
}
|
|
443
|
+
// Remove everything from the OpenCode identity up to (but not including) '# Code References'
|
|
444
|
+
return text.slice(0, startIdx) + text.slice(endIdx);
|
|
435
445
|
}
|
|
436
|
-
function prependClaudeCodeIdentity(system) {
|
|
446
|
+
function prependClaudeCodeIdentity(system, onError) {
|
|
437
447
|
const identityBlock = { type: 'text', text: CLAUDE_CODE_IDENTITY };
|
|
438
448
|
if (typeof system === 'undefined')
|
|
439
449
|
return [identityBlock];
|
|
440
450
|
if (typeof system === 'string') {
|
|
441
|
-
const sanitized = sanitizeSystemText(system);
|
|
451
|
+
const sanitized = sanitizeSystemText(system, onError);
|
|
442
452
|
if (sanitized === CLAUDE_CODE_IDENTITY)
|
|
443
453
|
return [identityBlock];
|
|
444
454
|
return [identityBlock, { type: 'text', text: sanitized }];
|
|
@@ -447,11 +457,11 @@ function prependClaudeCodeIdentity(system) {
|
|
|
447
457
|
return [identityBlock, system];
|
|
448
458
|
const sanitized = system.map((item) => {
|
|
449
459
|
if (typeof item === 'string')
|
|
450
|
-
return { type: 'text', text: sanitizeSystemText(item) };
|
|
460
|
+
return { type: 'text', text: sanitizeSystemText(item, onError) };
|
|
451
461
|
if (item && typeof item === 'object' && item.type === 'text') {
|
|
452
462
|
const text = item.text;
|
|
453
463
|
if (typeof text === 'string') {
|
|
454
|
-
return { ...item, text: sanitizeSystemText(text) };
|
|
464
|
+
return { ...item, text: sanitizeSystemText(text, onError) };
|
|
455
465
|
}
|
|
456
466
|
}
|
|
457
467
|
return item;
|
|
@@ -465,7 +475,7 @@ function prependClaudeCodeIdentity(system) {
|
|
|
465
475
|
}
|
|
466
476
|
return [identityBlock, ...sanitized];
|
|
467
477
|
}
|
|
468
|
-
function rewriteRequestPayload(body) {
|
|
478
|
+
function rewriteRequestPayload(body, onError) {
|
|
469
479
|
if (!body)
|
|
470
480
|
return { body, modelId: undefined, reverseToolNameMap: new Map() };
|
|
471
481
|
try {
|
|
@@ -486,7 +496,7 @@ function rewriteRequestPayload(body) {
|
|
|
486
496
|
});
|
|
487
497
|
}
|
|
488
498
|
// Rename system prompt
|
|
489
|
-
payload.system = prependClaudeCodeIdentity(payload.system);
|
|
499
|
+
payload.system = prependClaudeCodeIdentity(payload.system, onError);
|
|
490
500
|
// Rename tool_choice
|
|
491
501
|
if (payload.tool_choice &&
|
|
492
502
|
typeof payload.tool_choice === 'object' &&
|
|
@@ -658,7 +668,11 @@ const AnthropicAuthPlugin = async ({ client }) => {
|
|
|
658
668
|
.text()
|
|
659
669
|
.catch(() => undefined)
|
|
660
670
|
: undefined;
|
|
661
|
-
const rewritten = rewriteRequestPayload(originalBody)
|
|
671
|
+
const rewritten = rewriteRequestPayload(originalBody, (msg) => {
|
|
672
|
+
client.tui.showToast({
|
|
673
|
+
body: { message: msg, variant: 'error' },
|
|
674
|
+
}).catch(() => { });
|
|
675
|
+
});
|
|
662
676
|
const headers = new Headers(init?.headers);
|
|
663
677
|
if (input instanceof Request) {
|
|
664
678
|
input.headers.forEach((v, k) => {
|
|
@@ -61,7 +61,7 @@ export function shouldInjectPwd({ currentDir, previousDir, announcedDir, }) {
|
|
|
61
61
|
inject: true,
|
|
62
62
|
text: `\n[working directory changed. Previous working directory: ${priorDirectory}. ` +
|
|
63
63
|
`Current working directory: ${currentDir}. ` +
|
|
64
|
-
`You
|
|
64
|
+
`You should read, write, and edit files under ${currentDir}. ` +
|
|
65
65
|
`Do NOT read, write, or edit files under ${priorDirectory}.]`,
|
|
66
66
|
};
|
|
67
67
|
}
|
|
@@ -36,7 +36,7 @@ describe('shouldInjectPwd', () => {
|
|
|
36
36
|
{
|
|
37
37
|
"inject": true,
|
|
38
38
|
"text": "
|
|
39
|
-
[working directory changed. Previous working directory: /repo/main. Current working directory: /repo/worktree. You
|
|
39
|
+
[working directory changed. Previous working directory: /repo/main. Current working directory: /repo/worktree. You should read, write, and edit files under /repo/worktree. Do NOT read, write, or edit files under /repo/main.]",
|
|
40
40
|
}
|
|
41
41
|
`);
|
|
42
42
|
});
|
|
@@ -50,7 +50,7 @@ describe('shouldInjectPwd', () => {
|
|
|
50
50
|
{
|
|
51
51
|
"inject": true,
|
|
52
52
|
"text": "
|
|
53
|
-
[working directory changed. Previous working directory: /repo/worktree-a. Current working directory: /repo/worktree-b. You
|
|
53
|
+
[working directory changed. Previous working directory: /repo/worktree-a. Current working directory: /repo/worktree-b. You should read, write, and edit files under /repo/worktree-b. Do NOT read, write, or edit files under /repo/worktree-a.]",
|
|
54
54
|
}
|
|
55
55
|
`);
|
|
56
56
|
});
|
package/dist/discord-utils.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// Discord-specific utility functions.
|
|
2
2
|
// Handles markdown splitting for Discord's 2000-char limit, code block escaping,
|
|
3
3
|
// thread message sending, and channel metadata extraction from topic tags.
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
// Use namespace import for CJS interop — discord.js is CJS and its named
|
|
5
|
+
// exports aren't detectable by all ESM loaders (e.g. tsx/esbuild) because
|
|
6
|
+
// discord.js uses tslib's __exportStar which is opaque to static analysis.
|
|
7
|
+
import * as discord from 'discord.js';
|
|
8
|
+
const { ChannelType, GuildMember, MessageFlags, PermissionsBitField, REST, Routes } = discord;
|
|
6
9
|
import { discordApiUrl } from './discord-urls.js';
|
|
7
10
|
import { Lexer } from 'marked';
|
|
8
11
|
import { splitTablesFromMarkdown } from './format-tables.js';
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
export { ipcToolsPlugin } from './ipc-tools-plugin.js';
|
|
12
12
|
export { contextAwarenessPlugin } from './context-awareness-plugin.js';
|
|
13
13
|
export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js';
|
|
14
|
+
export { systemPromptDriftPlugin } from './system-prompt-drift-plugin.js';
|
|
14
15
|
export { anthropicAuthPlugin } from './anthropic-auth-plugin.js';
|
|
15
16
|
export { imageOptimizerPlugin } from './image-optimizer-plugin.js';
|
|
16
17
|
export { kittyGraphicsPlugin } from 'kitty-graphics-agent';
|
package/dist/logger.js
CHANGED
|
@@ -80,12 +80,18 @@ export function setLogFilePath(dataDir) {
|
|
|
80
80
|
export function getLogFilePath() {
|
|
81
81
|
return logFilePath;
|
|
82
82
|
}
|
|
83
|
+
const MAX_LOG_ARG_LENGTH = 1000;
|
|
84
|
+
function truncate(str, max) {
|
|
85
|
+
if (str.length <= max)
|
|
86
|
+
return str;
|
|
87
|
+
return str.slice(0, max) + `… [truncated ${str.length - max} chars]`;
|
|
88
|
+
}
|
|
83
89
|
function formatArg(arg) {
|
|
84
90
|
if (typeof arg === 'string') {
|
|
85
|
-
return sanitizeSensitiveText(arg, { redactPaths: false });
|
|
91
|
+
return truncate(sanitizeSensitiveText(arg, { redactPaths: false }), MAX_LOG_ARG_LENGTH);
|
|
86
92
|
}
|
|
87
93
|
const safeArg = sanitizeUnknownValue(arg, { redactPaths: false });
|
|
88
|
-
return util.inspect(safeArg, { colors: true, depth: 4 });
|
|
94
|
+
return truncate(util.inspect(safeArg, { colors: true, depth: 4 }), MAX_LOG_ARG_LENGTH);
|
|
89
95
|
}
|
|
90
96
|
export function formatErrorWithStack(error) {
|
|
91
97
|
if (error instanceof Error) {
|
|
@@ -40,6 +40,14 @@ import { extractLeadingOpencodeCommand } from '../opencode-command-detection.js'
|
|
|
40
40
|
const logger = createLogger(LogPrefix.SESSION);
|
|
41
41
|
const discordLogger = createLogger(LogPrefix.DISCORD);
|
|
42
42
|
const DETERMINISTIC_CONTEXT_LIMIT = 100_000;
|
|
43
|
+
const TOAST_SESSION_ID_REGEX = /\b(ses_[A-Za-z0-9]+)\b\s*$/u;
|
|
44
|
+
function extractToastSessionId({ message }) {
|
|
45
|
+
const match = message.match(TOAST_SESSION_ID_REGEX);
|
|
46
|
+
return match?.[1];
|
|
47
|
+
}
|
|
48
|
+
function stripToastSessionId({ message }) {
|
|
49
|
+
return message.replace(TOAST_SESSION_ID_REGEX, '').trimEnd();
|
|
50
|
+
}
|
|
43
51
|
const shouldLogSessionEvents = process.env['KIMAKI_LOG_SESSION_EVENTS'] === '1' ||
|
|
44
52
|
process.env['KIMAKI_VITEST'] === '1';
|
|
45
53
|
// ── Registry ─────────────────────────────────────────────────────
|
|
@@ -943,6 +951,9 @@ export class ThreadSessionRuntime {
|
|
|
943
951
|
}
|
|
944
952
|
const sessionId = this.state?.sessionId;
|
|
945
953
|
const eventSessionId = getOpencodeEventSessionId(event);
|
|
954
|
+
const toastSessionId = event.type === 'tui.toast.show'
|
|
955
|
+
? extractToastSessionId({ message: event.properties.message })
|
|
956
|
+
: undefined;
|
|
946
957
|
if (shouldLogSessionEvents) {
|
|
947
958
|
const eventDetails = (() => {
|
|
948
959
|
if (event.type === 'session.error') {
|
|
@@ -970,6 +981,7 @@ export class ThreadSessionRuntime {
|
|
|
970
981
|
logger.log(`[EVENT] type=${event.type} eventSessionId=${eventSessionId || 'none'} activeSessionId=${sessionId || 'none'} ${this.formatRunStateForLog()}${eventDetails}`);
|
|
971
982
|
}
|
|
972
983
|
const isGlobalEvent = event.type === 'tui.toast.show';
|
|
984
|
+
const isScopedToastEvent = Boolean(toastSessionId);
|
|
973
985
|
// Drop events that don't match current session (stale events from
|
|
974
986
|
// previous sessions), unless it's a global event or a subtask session.
|
|
975
987
|
if (!isGlobalEvent && eventSessionId && eventSessionId !== sessionId) {
|
|
@@ -977,6 +989,11 @@ export class ThreadSessionRuntime {
|
|
|
977
989
|
return; // stale event from previous session
|
|
978
990
|
}
|
|
979
991
|
}
|
|
992
|
+
if (isScopedToastEvent && toastSessionId !== sessionId) {
|
|
993
|
+
if (!this.getSubtaskInfoForSession(toastSessionId)) {
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
980
997
|
if (isOpencodeSessionEventLogEnabled()) {
|
|
981
998
|
const eventLogResult = await appendOpencodeSessionEventLog({
|
|
982
999
|
threadId: this.threadId,
|
|
@@ -2023,7 +2040,7 @@ export class ThreadSessionRuntime {
|
|
|
2023
2040
|
if (properties.variant === 'warning') {
|
|
2024
2041
|
return;
|
|
2025
2042
|
}
|
|
2026
|
-
const toastMessage = properties.message.trim();
|
|
2043
|
+
const toastMessage = stripToastSessionId({ message: properties.message }).trim();
|
|
2027
2044
|
if (!toastMessage) {
|
|
2028
2045
|
return;
|
|
2029
2046
|
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// OpenCode plugin that detects per-session system prompt drift across turns.
|
|
2
|
+
// When the effective system prompt changes after the first user message, it
|
|
3
|
+
// writes a debug diff file and shows a toast because prompt-cache invalidation
|
|
4
|
+
// increases rate-limit usage and usually means another plugin is mutating the
|
|
5
|
+
// system prompt unexpectedly.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { createPatch, diffLines } from 'diff';
|
|
9
|
+
import * as errore from 'errore';
|
|
10
|
+
import { createPluginLogger, formatPluginErrorWithStack, setPluginLogFilePath } from './plugin-logger.js';
|
|
11
|
+
import { initSentry, notifyError } from './sentry.js';
|
|
12
|
+
import { abbreviatePath } from './utils.js';
|
|
13
|
+
const logger = createPluginLogger('OPENCODE');
|
|
14
|
+
const TOAST_SESSION_MARKER_SEPARATOR = ' ';
|
|
15
|
+
function getSystemPromptDiffDir({ dataDir }) {
|
|
16
|
+
return path.join(dataDir, 'system-prompt-diffs');
|
|
17
|
+
}
|
|
18
|
+
function normalizeSystemPrompt({ system }) {
|
|
19
|
+
return system.join('\n');
|
|
20
|
+
}
|
|
21
|
+
function appendToastSessionMarker({ message, sessionId, }) {
|
|
22
|
+
return `${message}${TOAST_SESSION_MARKER_SEPARATOR}${sessionId}`;
|
|
23
|
+
}
|
|
24
|
+
function buildTurnContext({ input, directory, }) {
|
|
25
|
+
const model = input.model
|
|
26
|
+
? `${input.model.providerID}/${input.model.modelID}${input.variant ? `:${input.variant}` : ''}`
|
|
27
|
+
: undefined;
|
|
28
|
+
return {
|
|
29
|
+
agent: input.agent,
|
|
30
|
+
model,
|
|
31
|
+
directory,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function shouldSuppressDiffNotice({ previousContext, currentContext, }) {
|
|
35
|
+
if (!previousContext || !currentContext) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return (previousContext.agent !== currentContext.agent
|
|
39
|
+
|| previousContext.model !== currentContext.model
|
|
40
|
+
|| previousContext.directory !== currentContext.directory);
|
|
41
|
+
}
|
|
42
|
+
function buildPatch({ beforeText, afterText, beforeLabel, afterLabel, }) {
|
|
43
|
+
const changes = diffLines(beforeText, afterText);
|
|
44
|
+
const additions = changes.reduce((count, change) => {
|
|
45
|
+
if (!change.added) {
|
|
46
|
+
return count;
|
|
47
|
+
}
|
|
48
|
+
return count + change.count;
|
|
49
|
+
}, 0);
|
|
50
|
+
const deletions = changes.reduce((count, change) => {
|
|
51
|
+
if (!change.removed) {
|
|
52
|
+
return count;
|
|
53
|
+
}
|
|
54
|
+
return count + change.count;
|
|
55
|
+
}, 0);
|
|
56
|
+
const patch = createPatch(afterLabel, beforeText, afterText, beforeLabel, afterLabel);
|
|
57
|
+
return {
|
|
58
|
+
additions,
|
|
59
|
+
deletions,
|
|
60
|
+
patch,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function writeSystemPromptDiffFile({ dataDir, sessionId, beforePrompt, afterPrompt, }) {
|
|
64
|
+
const diff = buildPatch({
|
|
65
|
+
beforeText: beforePrompt,
|
|
66
|
+
afterText: afterPrompt,
|
|
67
|
+
beforeLabel: 'system-before.txt',
|
|
68
|
+
afterLabel: 'system-after.txt',
|
|
69
|
+
});
|
|
70
|
+
const timestamp = new Date().toISOString().replaceAll(':', '-');
|
|
71
|
+
const sessionDir = path.join(getSystemPromptDiffDir({ dataDir }), sessionId);
|
|
72
|
+
const filePath = path.join(sessionDir, `${timestamp}.diff`);
|
|
73
|
+
const latestPromptPath = path.join(sessionDir, `${timestamp}.md`);
|
|
74
|
+
const fileContent = [
|
|
75
|
+
`Session: ${sessionId}`,
|
|
76
|
+
`Created: ${new Date().toISOString()}`,
|
|
77
|
+
`Additions: ${diff.additions}`,
|
|
78
|
+
`Deletions: ${diff.deletions}`,
|
|
79
|
+
'',
|
|
80
|
+
diff.patch,
|
|
81
|
+
].join('\n');
|
|
82
|
+
return errore.try({
|
|
83
|
+
try: () => {
|
|
84
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
85
|
+
fs.writeFileSync(filePath, fileContent);
|
|
86
|
+
// fs.writeFileSync(latestPromptPath, afterPrompt)
|
|
87
|
+
return {
|
|
88
|
+
additions: diff.additions,
|
|
89
|
+
deletions: diff.deletions,
|
|
90
|
+
filePath,
|
|
91
|
+
latestPromptPath,
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
catch: (error) => {
|
|
95
|
+
return new Error('Failed to write system prompt diff file', { cause: error });
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function getOrCreateSessionState({ sessions, sessionId, }) {
|
|
100
|
+
const existing = sessions.get(sessionId);
|
|
101
|
+
if (existing) {
|
|
102
|
+
return existing;
|
|
103
|
+
}
|
|
104
|
+
const state = {
|
|
105
|
+
userTurnCount: 0,
|
|
106
|
+
previousTurnPrompt: undefined,
|
|
107
|
+
latestTurnPrompt: undefined,
|
|
108
|
+
latestTurnPromptTurn: 0,
|
|
109
|
+
comparedTurn: 0,
|
|
110
|
+
previousTurnContext: undefined,
|
|
111
|
+
currentTurnContext: undefined,
|
|
112
|
+
};
|
|
113
|
+
sessions.set(sessionId, state);
|
|
114
|
+
return state;
|
|
115
|
+
}
|
|
116
|
+
async function handleSystemTransform({ input, output, sessions, dataDir, client, }) {
|
|
117
|
+
const sessionId = input.sessionID;
|
|
118
|
+
if (!sessionId) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const currentPrompt = normalizeSystemPrompt({ system: output.system });
|
|
122
|
+
const state = getOrCreateSessionState({
|
|
123
|
+
sessions,
|
|
124
|
+
sessionId,
|
|
125
|
+
});
|
|
126
|
+
const currentTurn = state.userTurnCount;
|
|
127
|
+
state.latestTurnPrompt = currentPrompt;
|
|
128
|
+
state.latestTurnPromptTurn = currentTurn;
|
|
129
|
+
if (currentTurn <= 1) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (state.comparedTurn === currentTurn) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const previousPrompt = state.previousTurnPrompt;
|
|
136
|
+
state.comparedTurn = currentTurn;
|
|
137
|
+
if (!previousPrompt || previousPrompt === currentPrompt) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (shouldSuppressDiffNotice({
|
|
141
|
+
previousContext: state.previousTurnContext,
|
|
142
|
+
currentContext: state.currentTurnContext,
|
|
143
|
+
})) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (!dataDir) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const diffFileResult = writeSystemPromptDiffFile({
|
|
150
|
+
dataDir,
|
|
151
|
+
sessionId,
|
|
152
|
+
beforePrompt: previousPrompt,
|
|
153
|
+
afterPrompt: currentPrompt,
|
|
154
|
+
});
|
|
155
|
+
if (diffFileResult instanceof Error) {
|
|
156
|
+
throw diffFileResult;
|
|
157
|
+
}
|
|
158
|
+
await client.tui.showToast({
|
|
159
|
+
body: {
|
|
160
|
+
variant: 'info',
|
|
161
|
+
title: 'Context cache discarded',
|
|
162
|
+
message: appendToastSessionMarker({
|
|
163
|
+
sessionId,
|
|
164
|
+
message: `system prompt changed since the previous message (+${diffFileResult.additions} / -${diffFileResult.deletions}). ` +
|
|
165
|
+
`Diff: \`${abbreviatePath(diffFileResult.filePath)}\`. ` +
|
|
166
|
+
`Latest prompt: \`${abbreviatePath(diffFileResult.latestPromptPath)}\``,
|
|
167
|
+
}),
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const systemPromptDriftPlugin = async ({ client, directory }) => {
|
|
172
|
+
initSentry();
|
|
173
|
+
const dataDir = process.env.KIMAKI_DATA_DIR;
|
|
174
|
+
if (dataDir) {
|
|
175
|
+
setPluginLogFilePath(dataDir);
|
|
176
|
+
}
|
|
177
|
+
const sessions = new Map();
|
|
178
|
+
return {
|
|
179
|
+
'chat.message': async (input) => {
|
|
180
|
+
const sessionId = input.sessionID;
|
|
181
|
+
if (!sessionId) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const state = getOrCreateSessionState({ sessions, sessionId });
|
|
185
|
+
if (state.userTurnCount > 0
|
|
186
|
+
&& state.latestTurnPromptTurn === state.userTurnCount) {
|
|
187
|
+
state.previousTurnPrompt = state.latestTurnPrompt;
|
|
188
|
+
state.previousTurnContext = state.currentTurnContext;
|
|
189
|
+
}
|
|
190
|
+
state.currentTurnContext = buildTurnContext({ input, directory });
|
|
191
|
+
state.userTurnCount += 1;
|
|
192
|
+
},
|
|
193
|
+
'experimental.chat.system.transform': async (input, output) => {
|
|
194
|
+
const result = await errore.tryAsync({
|
|
195
|
+
try: async () => {
|
|
196
|
+
await handleSystemTransform({
|
|
197
|
+
input,
|
|
198
|
+
output,
|
|
199
|
+
sessions,
|
|
200
|
+
dataDir,
|
|
201
|
+
client,
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
catch: (error) => {
|
|
205
|
+
return new Error('system prompt drift transform hook failed', {
|
|
206
|
+
cause: error,
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
if (result instanceof Error) {
|
|
211
|
+
logger.warn(`[system-prompt-drift-plugin] ${formatPluginErrorWithStack(result)}`);
|
|
212
|
+
void notifyError(result, 'system prompt drift plugin transform hook failed');
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
event: async ({ event }) => {
|
|
216
|
+
const result = await errore.tryAsync({
|
|
217
|
+
try: async () => {
|
|
218
|
+
if (event.type !== 'session.deleted') {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const deletedSessionId = getDeletedSessionId({ event });
|
|
222
|
+
if (!deletedSessionId) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
sessions.delete(deletedSessionId);
|
|
226
|
+
},
|
|
227
|
+
catch: (error) => {
|
|
228
|
+
return new Error('system prompt drift event hook failed', {
|
|
229
|
+
cause: error,
|
|
230
|
+
});
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
if (result instanceof Error) {
|
|
234
|
+
logger.warn(`[system-prompt-drift-plugin] ${formatPluginErrorWithStack(result)}`);
|
|
235
|
+
void notifyError(result, 'system prompt drift plugin event hook failed');
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
function getDeletedSessionId({ event }) {
|
|
241
|
+
if (event.type !== 'session.deleted') {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
const sessionInfo = event.properties?.info;
|
|
245
|
+
if (!sessionInfo || typeof sessionInfo !== 'object') {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
const id = 'id' in sessionInfo ? sessionInfo.id : undefined;
|
|
249
|
+
return typeof id === 'string' ? id : undefined;
|
|
250
|
+
}
|
|
251
|
+
export { systemPromptDriftPlugin };
|
package/dist/utils.js
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
// Includes Discord OAuth URL generation, array deduplication,
|
|
3
3
|
// abort error detection, and date/time formatting helpers.
|
|
4
4
|
import os from 'node:os';
|
|
5
|
-
import
|
|
5
|
+
// Use namespace import for CJS interop — discord.js is CJS and its named
|
|
6
|
+
// exports aren't detectable by all ESM loaders (e.g. tsx/esbuild) because
|
|
7
|
+
// discord.js uses tslib's __exportStar which is opaque to static analysis.
|
|
8
|
+
import * as discord from 'discord.js';
|
|
9
|
+
const { PermissionsBitField } = discord;
|
|
6
10
|
import * as errore from 'errore';
|
|
7
11
|
export function generateBotInstallUrl({ clientId, permissions = [
|
|
8
12
|
PermissionsBitField.Flags.ViewChannel,
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@otto-assistant/bridge",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.
|
|
5
|
+
"version": "0.4.96",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "tsx src/bin.ts",
|
|
8
8
|
"prepublishOnly": "pnpm generate && pnpm tsc",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"@prisma/client": "7.4.2",
|
|
67
67
|
"@purinton/resampler": "^1.0.4",
|
|
68
68
|
"cron-parser": "^5.5.0",
|
|
69
|
+
"diff": "^8.0.4",
|
|
69
70
|
"discord.js": "^14.25.1",
|
|
70
71
|
"domhandler": "^6.0.1",
|
|
71
72
|
"errore": "workspace:^",
|
|
@@ -528,17 +528,26 @@ function toClaudeCodeToolName(name: string) {
|
|
|
528
528
|
return OPENCODE_TO_CLAUDE_CODE_TOOL_NAME[name.toLowerCase()] ?? name
|
|
529
529
|
}
|
|
530
530
|
|
|
531
|
-
function sanitizeSystemText(text: string) {
|
|
532
|
-
|
|
531
|
+
function sanitizeSystemText(text: string, onError?: (msg: string) => void) {
|
|
532
|
+
const startIdx = text.indexOf(OPENCODE_IDENTITY)
|
|
533
|
+
if (startIdx === -1) return text
|
|
534
|
+
const codeRefsMarker = '# Code References'
|
|
535
|
+
const endIdx = text.indexOf(codeRefsMarker, startIdx)
|
|
536
|
+
if (endIdx === -1) {
|
|
537
|
+
onError?.(`sanitizeSystemText: could not find '# Code References' after OpenCode identity`)
|
|
538
|
+
return text
|
|
539
|
+
}
|
|
540
|
+
// Remove everything from the OpenCode identity up to (but not including) '# Code References'
|
|
541
|
+
return text.slice(0, startIdx) + text.slice(endIdx)
|
|
533
542
|
}
|
|
534
543
|
|
|
535
|
-
function prependClaudeCodeIdentity(system: unknown) {
|
|
544
|
+
function prependClaudeCodeIdentity(system: unknown, onError?: (msg: string) => void) {
|
|
536
545
|
const identityBlock = { type: 'text', text: CLAUDE_CODE_IDENTITY }
|
|
537
546
|
|
|
538
547
|
if (typeof system === 'undefined') return [identityBlock]
|
|
539
548
|
|
|
540
549
|
if (typeof system === 'string') {
|
|
541
|
-
const sanitized = sanitizeSystemText(system)
|
|
550
|
+
const sanitized = sanitizeSystemText(system, onError)
|
|
542
551
|
if (sanitized === CLAUDE_CODE_IDENTITY) return [identityBlock]
|
|
543
552
|
return [identityBlock, { type: 'text', text: sanitized }]
|
|
544
553
|
}
|
|
@@ -546,11 +555,11 @@ function prependClaudeCodeIdentity(system: unknown) {
|
|
|
546
555
|
if (!Array.isArray(system)) return [identityBlock, system]
|
|
547
556
|
|
|
548
557
|
const sanitized = system.map((item) => {
|
|
549
|
-
if (typeof item === 'string') return { type: 'text', text: sanitizeSystemText(item) }
|
|
558
|
+
if (typeof item === 'string') return { type: 'text', text: sanitizeSystemText(item, onError) }
|
|
550
559
|
if (item && typeof item === 'object' && (item as { type?: unknown }).type === 'text') {
|
|
551
560
|
const text = (item as { text?: unknown }).text
|
|
552
561
|
if (typeof text === 'string') {
|
|
553
|
-
return { ...(item as Record<string, unknown>), text: sanitizeSystemText(text) }
|
|
562
|
+
return { ...(item as Record<string, unknown>), text: sanitizeSystemText(text, onError) }
|
|
554
563
|
}
|
|
555
564
|
}
|
|
556
565
|
return item
|
|
@@ -568,7 +577,7 @@ function prependClaudeCodeIdentity(system: unknown) {
|
|
|
568
577
|
return [identityBlock, ...sanitized]
|
|
569
578
|
}
|
|
570
579
|
|
|
571
|
-
function rewriteRequestPayload(body: string | undefined) {
|
|
580
|
+
function rewriteRequestPayload(body: string | undefined, onError?: (msg: string) => void) {
|
|
572
581
|
if (!body) return { body, modelId: undefined, reverseToolNameMap: new Map<string, string>() }
|
|
573
582
|
|
|
574
583
|
try {
|
|
@@ -589,7 +598,7 @@ function rewriteRequestPayload(body: string | undefined) {
|
|
|
589
598
|
}
|
|
590
599
|
|
|
591
600
|
// Rename system prompt
|
|
592
|
-
payload.system = prependClaudeCodeIdentity(payload.system)
|
|
601
|
+
payload.system = prependClaudeCodeIdentity(payload.system, onError)
|
|
593
602
|
|
|
594
603
|
// Rename tool_choice
|
|
595
604
|
if (
|
|
@@ -779,7 +788,11 @@ const AnthropicAuthPlugin: Plugin = async ({ client }) => {
|
|
|
779
788
|
.catch(() => undefined)
|
|
780
789
|
: undefined
|
|
781
790
|
|
|
782
|
-
const rewritten = rewriteRequestPayload(originalBody)
|
|
791
|
+
const rewritten = rewriteRequestPayload(originalBody, (msg) => {
|
|
792
|
+
client.tui.showToast({
|
|
793
|
+
body: { message: msg, variant: 'error' },
|
|
794
|
+
}).catch(() => {})
|
|
795
|
+
})
|
|
783
796
|
const headers = new Headers(init?.headers)
|
|
784
797
|
if (input instanceof Request) {
|
|
785
798
|
input.headers.forEach((v, k) => {
|
|
@@ -46,7 +46,7 @@ describe('shouldInjectPwd', () => {
|
|
|
46
46
|
{
|
|
47
47
|
"inject": true,
|
|
48
48
|
"text": "
|
|
49
|
-
[working directory changed. Previous working directory: /repo/main. Current working directory: /repo/worktree. You
|
|
49
|
+
[working directory changed. Previous working directory: /repo/main. Current working directory: /repo/worktree. You should read, write, and edit files under /repo/worktree. Do NOT read, write, or edit files under /repo/main.]",
|
|
50
50
|
}
|
|
51
51
|
`)
|
|
52
52
|
})
|
|
@@ -62,7 +62,7 @@ describe('shouldInjectPwd', () => {
|
|
|
62
62
|
{
|
|
63
63
|
"inject": true,
|
|
64
64
|
"text": "
|
|
65
|
-
[working directory changed. Previous working directory: /repo/worktree-a. Current working directory: /repo/worktree-b. You
|
|
65
|
+
[working directory changed. Previous working directory: /repo/worktree-a. Current working directory: /repo/worktree-b. You should read, write, and edit files under /repo/worktree-b. Do NOT read, write, or edit files under /repo/worktree-a.]",
|
|
66
66
|
}
|
|
67
67
|
`)
|
|
68
68
|
})
|
|
@@ -126,7 +126,7 @@ export function shouldInjectPwd({
|
|
|
126
126
|
text:
|
|
127
127
|
`\n[working directory changed. Previous working directory: ${priorDirectory}. ` +
|
|
128
128
|
`Current working directory: ${currentDir}. ` +
|
|
129
|
-
`You
|
|
129
|
+
`You should read, write, and edit files under ${currentDir}. ` +
|
|
130
130
|
`Do NOT read, write, or edit files under ${priorDirectory}.]`,
|
|
131
131
|
}
|
|
132
132
|
}
|
package/src/discord-utils.ts
CHANGED
|
@@ -2,19 +2,21 @@
|
|
|
2
2
|
// Handles markdown splitting for Discord's 2000-char limit, code block escaping,
|
|
3
3
|
// thread message sending, and channel metadata extraction from topic tags.
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
5
|
+
// Use namespace import for CJS interop — discord.js is CJS and its named
|
|
6
|
+
// exports aren't detectable by all ESM loaders (e.g. tsx/esbuild) because
|
|
7
|
+
// discord.js uses tslib's __exportStar which is opaque to static analysis.
|
|
8
|
+
import * as discord from 'discord.js'
|
|
9
|
+
import type {
|
|
10
|
+
APIInteractionGuildMember,
|
|
11
|
+
AutocompleteInteraction,
|
|
12
|
+
GuildMember as GuildMemberType,
|
|
13
|
+
Guild,
|
|
14
|
+
Message,
|
|
15
|
+
REST as RESTType,
|
|
16
|
+
TextChannel,
|
|
17
|
+
ThreadChannel,
|
|
16
18
|
} from 'discord.js'
|
|
17
|
-
|
|
19
|
+
const { ChannelType, GuildMember, MessageFlags, PermissionsBitField, REST, Routes } = discord
|
|
18
20
|
import type { OpencodeClient } from '@opencode-ai/sdk/v2'
|
|
19
21
|
import { discordApiUrl } from './discord-urls.js'
|
|
20
22
|
import { Lexer } from 'marked'
|
|
@@ -37,7 +39,7 @@ const discordLogger = createLogger(LogPrefix.DISCORD)
|
|
|
37
39
|
* Returns false if member is null or has the "no-kimaki" role (overrides all).
|
|
38
40
|
*/
|
|
39
41
|
export function hasKimakiBotPermission(
|
|
40
|
-
member:
|
|
42
|
+
member: GuildMemberType | APIInteractionGuildMember | null,
|
|
41
43
|
guild?: Guild | null,
|
|
42
44
|
): boolean {
|
|
43
45
|
if (!member) {
|
|
@@ -61,7 +63,7 @@ export function hasKimakiBotPermission(
|
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
function hasRoleByName(
|
|
64
|
-
member:
|
|
66
|
+
member: GuildMemberType | APIInteractionGuildMember,
|
|
65
67
|
roleName: string,
|
|
66
68
|
guild?: Guild | null,
|
|
67
69
|
): boolean {
|
|
@@ -89,7 +91,7 @@ function hasRoleByName(
|
|
|
89
91
|
* Check if the member has the "no-kimaki" role that blocks bot access.
|
|
90
92
|
* Separate from hasKimakiBotPermission so callers can show a specific error message.
|
|
91
93
|
*/
|
|
92
|
-
export function hasNoKimakiRole(member:
|
|
94
|
+
export function hasNoKimakiRole(member: GuildMemberType | null): boolean {
|
|
93
95
|
if (!member?.roles?.cache) {
|
|
94
96
|
return false
|
|
95
97
|
}
|
|
@@ -108,7 +110,7 @@ export async function reactToThread({
|
|
|
108
110
|
channelId,
|
|
109
111
|
emoji,
|
|
110
112
|
}: {
|
|
111
|
-
rest:
|
|
113
|
+
rest: RESTType
|
|
112
114
|
threadId: string
|
|
113
115
|
/** Parent channel ID where the thread starter message lives.
|
|
114
116
|
* If not provided, fetches the thread info from Discord API to resolve it. */
|
|
@@ -169,7 +171,7 @@ export async function archiveThread({
|
|
|
169
171
|
client,
|
|
170
172
|
archiveDelay = 0,
|
|
171
173
|
}: {
|
|
172
|
-
rest:
|
|
174
|
+
rest: RESTType
|
|
173
175
|
threadId: string
|
|
174
176
|
parentChannelId?: string
|
|
175
177
|
sessionId?: string
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
export { ipcToolsPlugin } from './ipc-tools-plugin.js'
|
|
13
13
|
export { contextAwarenessPlugin } from './context-awareness-plugin.js'
|
|
14
14
|
export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js'
|
|
15
|
+
export { systemPromptDriftPlugin } from './system-prompt-drift-plugin.js'
|
|
15
16
|
export { anthropicAuthPlugin } from './anthropic-auth-plugin.js'
|
|
16
17
|
export { imageOptimizerPlugin } from './image-optimizer-plugin.js'
|
|
17
18
|
export { kittyGraphicsPlugin } from 'kitty-graphics-agent'
|
package/src/logger.ts
CHANGED
|
@@ -95,12 +95,19 @@ export function getLogFilePath(): string | null {
|
|
|
95
95
|
return logFilePath
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
const MAX_LOG_ARG_LENGTH = 1000
|
|
99
|
+
|
|
100
|
+
function truncate(str: string, max: number): string {
|
|
101
|
+
if (str.length <= max) return str
|
|
102
|
+
return str.slice(0, max) + `… [truncated ${str.length - max} chars]`
|
|
103
|
+
}
|
|
104
|
+
|
|
98
105
|
function formatArg(arg: unknown): string {
|
|
99
106
|
if (typeof arg === 'string') {
|
|
100
|
-
return sanitizeSensitiveText(arg, { redactPaths: false })
|
|
107
|
+
return truncate(sanitizeSensitiveText(arg, { redactPaths: false }), MAX_LOG_ARG_LENGTH)
|
|
101
108
|
}
|
|
102
109
|
const safeArg = sanitizeUnknownValue(arg, { redactPaths: false })
|
|
103
|
-
return util.inspect(safeArg, { colors: true, depth: 4 })
|
|
110
|
+
return truncate(util.inspect(safeArg, { colors: true, depth: 4 }), MAX_LOG_ARG_LENGTH)
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
export function formatErrorWithStack(error: unknown): string {
|
|
@@ -137,6 +137,17 @@ import { extractLeadingOpencodeCommand } from '../opencode-command-detection.js'
|
|
|
137
137
|
const logger = createLogger(LogPrefix.SESSION)
|
|
138
138
|
const discordLogger = createLogger(LogPrefix.DISCORD)
|
|
139
139
|
const DETERMINISTIC_CONTEXT_LIMIT = 100_000
|
|
140
|
+
const TOAST_SESSION_ID_REGEX = /\b(ses_[A-Za-z0-9]+)\b\s*$/u
|
|
141
|
+
|
|
142
|
+
function extractToastSessionId({ message }: { message: string }): string | undefined {
|
|
143
|
+
const match = message.match(TOAST_SESSION_ID_REGEX)
|
|
144
|
+
return match?.[1]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function stripToastSessionId({ message }: { message: string }): string {
|
|
148
|
+
return message.replace(TOAST_SESSION_ID_REGEX, '').trimEnd()
|
|
149
|
+
}
|
|
150
|
+
|
|
140
151
|
const shouldLogSessionEvents =
|
|
141
152
|
process.env['KIMAKI_LOG_SESSION_EVENTS'] === '1' ||
|
|
142
153
|
process.env['KIMAKI_VITEST'] === '1'
|
|
@@ -1381,6 +1392,9 @@ export class ThreadSessionRuntime {
|
|
|
1381
1392
|
const sessionId = this.state?.sessionId
|
|
1382
1393
|
|
|
1383
1394
|
const eventSessionId = getOpencodeEventSessionId(event)
|
|
1395
|
+
const toastSessionId = event.type === 'tui.toast.show'
|
|
1396
|
+
? extractToastSessionId({ message: event.properties.message })
|
|
1397
|
+
: undefined
|
|
1384
1398
|
|
|
1385
1399
|
if (shouldLogSessionEvents) {
|
|
1386
1400
|
const eventDetails = (() => {
|
|
@@ -1412,6 +1426,7 @@ export class ThreadSessionRuntime {
|
|
|
1412
1426
|
}
|
|
1413
1427
|
|
|
1414
1428
|
const isGlobalEvent = event.type === 'tui.toast.show'
|
|
1429
|
+
const isScopedToastEvent = Boolean(toastSessionId)
|
|
1415
1430
|
|
|
1416
1431
|
// Drop events that don't match current session (stale events from
|
|
1417
1432
|
// previous sessions), unless it's a global event or a subtask session.
|
|
@@ -1420,6 +1435,11 @@ export class ThreadSessionRuntime {
|
|
|
1420
1435
|
return // stale event from previous session
|
|
1421
1436
|
}
|
|
1422
1437
|
}
|
|
1438
|
+
if (isScopedToastEvent && toastSessionId !== sessionId) {
|
|
1439
|
+
if (!this.getSubtaskInfoForSession(toastSessionId!)) {
|
|
1440
|
+
return
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1423
1443
|
|
|
1424
1444
|
if (isOpencodeSessionEventLogEnabled()) {
|
|
1425
1445
|
const eventLogResult = await appendOpencodeSessionEventLog({
|
|
@@ -2763,7 +2783,7 @@ export class ThreadSessionRuntime {
|
|
|
2763
2783
|
if (properties.variant === 'warning') {
|
|
2764
2784
|
return
|
|
2765
2785
|
}
|
|
2766
|
-
const toastMessage = properties.message.trim()
|
|
2786
|
+
const toastMessage = stripToastSessionId({ message: properties.message }).trim()
|
|
2767
2787
|
if (!toastMessage) {
|
|
2768
2788
|
return
|
|
2769
2789
|
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// OpenCode plugin that detects per-session system prompt drift across turns.
|
|
2
|
+
// When the effective system prompt changes after the first user message, it
|
|
3
|
+
// writes a debug diff file and shows a toast because prompt-cache invalidation
|
|
4
|
+
// increases rate-limit usage and usually means another plugin is mutating the
|
|
5
|
+
// system prompt unexpectedly.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import type { Plugin } from '@opencode-ai/plugin'
|
|
10
|
+
import { createPatch, diffLines } from 'diff'
|
|
11
|
+
import * as errore from 'errore'
|
|
12
|
+
import { createPluginLogger, formatPluginErrorWithStack, setPluginLogFilePath } from './plugin-logger.js'
|
|
13
|
+
import { initSentry, notifyError } from './sentry.js'
|
|
14
|
+
import { abbreviatePath } from './utils.js'
|
|
15
|
+
|
|
16
|
+
const logger = createPluginLogger('OPENCODE')
|
|
17
|
+
const TOAST_SESSION_MARKER_SEPARATOR = ' '
|
|
18
|
+
|
|
19
|
+
type PluginHooks = Awaited<ReturnType<Plugin>>
|
|
20
|
+
type SystemTransformHook = NonNullable<PluginHooks['experimental.chat.system.transform']>
|
|
21
|
+
type SystemTransformInput = Parameters<SystemTransformHook>[0]
|
|
22
|
+
type SystemTransformOutput = Parameters<SystemTransformHook>[1]
|
|
23
|
+
type PluginEventHook = NonNullable<PluginHooks['event']>
|
|
24
|
+
type PluginEvent = Parameters<PluginEventHook>[0]['event']
|
|
25
|
+
type ChatMessageHook = NonNullable<PluginHooks['chat.message']>
|
|
26
|
+
type ChatMessageInput = Parameters<ChatMessageHook>[0]
|
|
27
|
+
|
|
28
|
+
type SessionState = {
|
|
29
|
+
userTurnCount: number
|
|
30
|
+
previousTurnPrompt: string | undefined
|
|
31
|
+
latestTurnPrompt: string | undefined
|
|
32
|
+
latestTurnPromptTurn: number
|
|
33
|
+
comparedTurn: number
|
|
34
|
+
previousTurnContext: TurnContext | undefined
|
|
35
|
+
currentTurnContext: TurnContext | undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type SystemPromptDiff = {
|
|
39
|
+
additions: number
|
|
40
|
+
deletions: number
|
|
41
|
+
patch: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type TurnContext = {
|
|
45
|
+
agent: string | undefined
|
|
46
|
+
model: string | undefined
|
|
47
|
+
directory: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getSystemPromptDiffDir({ dataDir }: { dataDir: string }): string {
|
|
51
|
+
return path.join(dataDir, 'system-prompt-diffs')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeSystemPrompt({ system }: { system: string[] }): string {
|
|
55
|
+
return system.join('\n')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function appendToastSessionMarker({
|
|
59
|
+
message,
|
|
60
|
+
sessionId,
|
|
61
|
+
}: {
|
|
62
|
+
message: string
|
|
63
|
+
sessionId: string
|
|
64
|
+
}): string {
|
|
65
|
+
return `${message}${TOAST_SESSION_MARKER_SEPARATOR}${sessionId}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildTurnContext({
|
|
69
|
+
input,
|
|
70
|
+
directory,
|
|
71
|
+
}: {
|
|
72
|
+
input: ChatMessageInput
|
|
73
|
+
directory: string
|
|
74
|
+
}): TurnContext {
|
|
75
|
+
const model = input.model
|
|
76
|
+
? `${input.model.providerID}/${input.model.modelID}${input.variant ? `:${input.variant}` : ''}`
|
|
77
|
+
: undefined
|
|
78
|
+
return {
|
|
79
|
+
agent: input.agent,
|
|
80
|
+
model,
|
|
81
|
+
directory,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function shouldSuppressDiffNotice({
|
|
86
|
+
previousContext,
|
|
87
|
+
currentContext,
|
|
88
|
+
}: {
|
|
89
|
+
previousContext: TurnContext | undefined
|
|
90
|
+
currentContext: TurnContext | undefined
|
|
91
|
+
}): boolean {
|
|
92
|
+
if (!previousContext || !currentContext) {
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
return (
|
|
96
|
+
previousContext.agent !== currentContext.agent
|
|
97
|
+
|| previousContext.model !== currentContext.model
|
|
98
|
+
|| previousContext.directory !== currentContext.directory
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildPatch({
|
|
103
|
+
beforeText,
|
|
104
|
+
afterText,
|
|
105
|
+
beforeLabel,
|
|
106
|
+
afterLabel,
|
|
107
|
+
}: {
|
|
108
|
+
beforeText: string
|
|
109
|
+
afterText: string
|
|
110
|
+
beforeLabel: string
|
|
111
|
+
afterLabel: string
|
|
112
|
+
}): SystemPromptDiff {
|
|
113
|
+
const changes = diffLines(beforeText, afterText)
|
|
114
|
+
const additions = changes.reduce((count, change) => {
|
|
115
|
+
if (!change.added) {
|
|
116
|
+
return count
|
|
117
|
+
}
|
|
118
|
+
return count + change.count
|
|
119
|
+
}, 0)
|
|
120
|
+
const deletions = changes.reduce((count, change) => {
|
|
121
|
+
if (!change.removed) {
|
|
122
|
+
return count
|
|
123
|
+
}
|
|
124
|
+
return count + change.count
|
|
125
|
+
}, 0)
|
|
126
|
+
const patch = createPatch(afterLabel, beforeText, afterText, beforeLabel, afterLabel)
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
additions,
|
|
130
|
+
deletions,
|
|
131
|
+
patch,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function writeSystemPromptDiffFile({
|
|
136
|
+
dataDir,
|
|
137
|
+
sessionId,
|
|
138
|
+
beforePrompt,
|
|
139
|
+
afterPrompt,
|
|
140
|
+
}: {
|
|
141
|
+
dataDir: string
|
|
142
|
+
sessionId: string
|
|
143
|
+
beforePrompt: string
|
|
144
|
+
afterPrompt: string
|
|
145
|
+
}): Error | {
|
|
146
|
+
additions: number
|
|
147
|
+
deletions: number
|
|
148
|
+
filePath: string
|
|
149
|
+
latestPromptPath: string
|
|
150
|
+
} {
|
|
151
|
+
const diff = buildPatch({
|
|
152
|
+
beforeText: beforePrompt,
|
|
153
|
+
afterText: afterPrompt,
|
|
154
|
+
beforeLabel: 'system-before.txt',
|
|
155
|
+
afterLabel: 'system-after.txt',
|
|
156
|
+
})
|
|
157
|
+
const timestamp = new Date().toISOString().replaceAll(':', '-')
|
|
158
|
+
const sessionDir = path.join(getSystemPromptDiffDir({ dataDir }), sessionId)
|
|
159
|
+
const filePath = path.join(sessionDir, `${timestamp}.diff`)
|
|
160
|
+
const latestPromptPath = path.join(sessionDir, `${timestamp}.md`)
|
|
161
|
+
const fileContent = [
|
|
162
|
+
`Session: ${sessionId}`,
|
|
163
|
+
`Created: ${new Date().toISOString()}`,
|
|
164
|
+
`Additions: ${diff.additions}`,
|
|
165
|
+
`Deletions: ${diff.deletions}`,
|
|
166
|
+
'',
|
|
167
|
+
diff.patch,
|
|
168
|
+
].join('\n')
|
|
169
|
+
|
|
170
|
+
return errore.try({
|
|
171
|
+
try: () => {
|
|
172
|
+
fs.mkdirSync(sessionDir, { recursive: true })
|
|
173
|
+
fs.writeFileSync(filePath, fileContent)
|
|
174
|
+
// fs.writeFileSync(latestPromptPath, afterPrompt)
|
|
175
|
+
return {
|
|
176
|
+
additions: diff.additions,
|
|
177
|
+
deletions: diff.deletions,
|
|
178
|
+
filePath,
|
|
179
|
+
latestPromptPath,
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
catch: (error) => {
|
|
183
|
+
return new Error('Failed to write system prompt diff file', { cause: error })
|
|
184
|
+
},
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function getOrCreateSessionState({
|
|
189
|
+
sessions,
|
|
190
|
+
sessionId,
|
|
191
|
+
}: {
|
|
192
|
+
sessions: Map<string, SessionState>
|
|
193
|
+
sessionId: string
|
|
194
|
+
}): SessionState {
|
|
195
|
+
const existing = sessions.get(sessionId)
|
|
196
|
+
if (existing) {
|
|
197
|
+
return existing
|
|
198
|
+
}
|
|
199
|
+
const state = {
|
|
200
|
+
userTurnCount: 0,
|
|
201
|
+
previousTurnPrompt: undefined,
|
|
202
|
+
latestTurnPrompt: undefined,
|
|
203
|
+
latestTurnPromptTurn: 0,
|
|
204
|
+
comparedTurn: 0,
|
|
205
|
+
previousTurnContext: undefined,
|
|
206
|
+
currentTurnContext: undefined,
|
|
207
|
+
}
|
|
208
|
+
sessions.set(sessionId, state)
|
|
209
|
+
return state
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function handleSystemTransform({
|
|
213
|
+
input,
|
|
214
|
+
output,
|
|
215
|
+
sessions,
|
|
216
|
+
dataDir,
|
|
217
|
+
client,
|
|
218
|
+
}: {
|
|
219
|
+
input: SystemTransformInput
|
|
220
|
+
output: SystemTransformOutput
|
|
221
|
+
sessions: Map<string, SessionState>
|
|
222
|
+
dataDir: string | undefined
|
|
223
|
+
client: Parameters<Plugin>[0]['client']
|
|
224
|
+
}): Promise<void> {
|
|
225
|
+
const sessionId = input.sessionID
|
|
226
|
+
if (!sessionId) {
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const currentPrompt = normalizeSystemPrompt({ system: output.system })
|
|
231
|
+
const state = getOrCreateSessionState({
|
|
232
|
+
sessions,
|
|
233
|
+
sessionId,
|
|
234
|
+
})
|
|
235
|
+
const currentTurn = state.userTurnCount
|
|
236
|
+
state.latestTurnPrompt = currentPrompt
|
|
237
|
+
state.latestTurnPromptTurn = currentTurn
|
|
238
|
+
|
|
239
|
+
if (currentTurn <= 1) {
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
if (state.comparedTurn === currentTurn) {
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
const previousPrompt = state.previousTurnPrompt
|
|
246
|
+
state.comparedTurn = currentTurn
|
|
247
|
+
if (!previousPrompt || previousPrompt === currentPrompt) {
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
if (
|
|
251
|
+
shouldSuppressDiffNotice({
|
|
252
|
+
previousContext: state.previousTurnContext,
|
|
253
|
+
currentContext: state.currentTurnContext,
|
|
254
|
+
})
|
|
255
|
+
) {
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!dataDir) {
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const diffFileResult = writeSystemPromptDiffFile({
|
|
264
|
+
dataDir,
|
|
265
|
+
sessionId,
|
|
266
|
+
beforePrompt: previousPrompt,
|
|
267
|
+
afterPrompt: currentPrompt,
|
|
268
|
+
})
|
|
269
|
+
if (diffFileResult instanceof Error) {
|
|
270
|
+
throw diffFileResult
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
await client.tui.showToast({
|
|
274
|
+
body: {
|
|
275
|
+
variant: 'info',
|
|
276
|
+
title: 'Context cache discarded',
|
|
277
|
+
message: appendToastSessionMarker({
|
|
278
|
+
sessionId,
|
|
279
|
+
message:
|
|
280
|
+
`system prompt changed since the previous message (+${diffFileResult.additions} / -${diffFileResult.deletions}). ` +
|
|
281
|
+
`Diff: \`${abbreviatePath(diffFileResult.filePath)}\`. ` +
|
|
282
|
+
`Latest prompt: \`${abbreviatePath(diffFileResult.latestPromptPath)}\``,
|
|
283
|
+
}),
|
|
284
|
+
},
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const systemPromptDriftPlugin: Plugin = async ({ client, directory }) => {
|
|
289
|
+
initSentry()
|
|
290
|
+
|
|
291
|
+
const dataDir = process.env.KIMAKI_DATA_DIR
|
|
292
|
+
if (dataDir) {
|
|
293
|
+
setPluginLogFilePath(dataDir)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const sessions = new Map<string, SessionState>()
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
'chat.message': async (input) => {
|
|
300
|
+
const sessionId = input.sessionID
|
|
301
|
+
if (!sessionId) {
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
const state = getOrCreateSessionState({ sessions, sessionId })
|
|
305
|
+
if (
|
|
306
|
+
state.userTurnCount > 0
|
|
307
|
+
&& state.latestTurnPromptTurn === state.userTurnCount
|
|
308
|
+
) {
|
|
309
|
+
state.previousTurnPrompt = state.latestTurnPrompt
|
|
310
|
+
state.previousTurnContext = state.currentTurnContext
|
|
311
|
+
}
|
|
312
|
+
state.currentTurnContext = buildTurnContext({ input, directory })
|
|
313
|
+
state.userTurnCount += 1
|
|
314
|
+
},
|
|
315
|
+
'experimental.chat.system.transform': async (input, output) => {
|
|
316
|
+
const result = await errore.tryAsync({
|
|
317
|
+
try: async () => {
|
|
318
|
+
await handleSystemTransform({
|
|
319
|
+
input,
|
|
320
|
+
output,
|
|
321
|
+
sessions,
|
|
322
|
+
dataDir,
|
|
323
|
+
client,
|
|
324
|
+
})
|
|
325
|
+
},
|
|
326
|
+
catch: (error) => {
|
|
327
|
+
return new Error('system prompt drift transform hook failed', {
|
|
328
|
+
cause: error,
|
|
329
|
+
})
|
|
330
|
+
},
|
|
331
|
+
})
|
|
332
|
+
if (result instanceof Error) {
|
|
333
|
+
logger.warn(
|
|
334
|
+
`[system-prompt-drift-plugin] ${formatPluginErrorWithStack(result)}`,
|
|
335
|
+
)
|
|
336
|
+
void notifyError(result, 'system prompt drift plugin transform hook failed')
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
event: async ({ event }) => {
|
|
340
|
+
const result = await errore.tryAsync({
|
|
341
|
+
try: async () => {
|
|
342
|
+
if (event.type !== 'session.deleted') {
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
const deletedSessionId = getDeletedSessionId({ event })
|
|
346
|
+
if (!deletedSessionId) {
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
sessions.delete(deletedSessionId)
|
|
350
|
+
},
|
|
351
|
+
catch: (error) => {
|
|
352
|
+
return new Error('system prompt drift event hook failed', {
|
|
353
|
+
cause: error,
|
|
354
|
+
})
|
|
355
|
+
},
|
|
356
|
+
})
|
|
357
|
+
if (result instanceof Error) {
|
|
358
|
+
logger.warn(
|
|
359
|
+
`[system-prompt-drift-plugin] ${formatPluginErrorWithStack(result)}`,
|
|
360
|
+
)
|
|
361
|
+
void notifyError(result, 'system prompt drift plugin event hook failed')
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function getDeletedSessionId({ event }: { event: PluginEvent }): string | undefined {
|
|
368
|
+
if (event.type !== 'session.deleted') {
|
|
369
|
+
return undefined
|
|
370
|
+
}
|
|
371
|
+
const sessionInfo = event.properties?.info
|
|
372
|
+
if (!sessionInfo || typeof sessionInfo !== 'object') {
|
|
373
|
+
return undefined
|
|
374
|
+
}
|
|
375
|
+
const id = 'id' in sessionInfo ? sessionInfo.id : undefined
|
|
376
|
+
return typeof id === 'string' ? id : undefined
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export { systemPromptDriftPlugin }
|
package/src/utils.ts
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
// abort error detection, and date/time formatting helpers.
|
|
4
4
|
|
|
5
5
|
import os from 'node:os'
|
|
6
|
-
import
|
|
6
|
+
// Use namespace import for CJS interop — discord.js is CJS and its named
|
|
7
|
+
// exports aren't detectable by all ESM loaders (e.g. tsx/esbuild) because
|
|
8
|
+
// discord.js uses tslib's __exportStar which is opaque to static analysis.
|
|
9
|
+
import * as discord from 'discord.js'
|
|
10
|
+
const { PermissionsBitField } = discord
|
|
7
11
|
import type { BotMode } from './database.js'
|
|
8
12
|
import * as errore from 'errore'
|
|
9
13
|
|