kimaki 0.4.92 → 0.4.94
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/agent-model.e2e.test.js +2 -1
- package/dist/anthropic-auth-plugin.js +16 -0
- package/dist/anthropic-auth-plugin.test.js +7 -1
- package/dist/anthropic-auth-state.js +17 -1
- package/dist/cli.js +2 -3
- package/dist/commands/agent.js +2 -2
- package/dist/commands/merge-worktree.js +11 -8
- package/dist/context-awareness-plugin.js +1 -1
- package/dist/context-awareness-plugin.test.js +2 -2
- package/dist/kimaki-opencode-plugin.js +1 -0
- package/dist/logger.js +8 -2
- package/dist/session-handler/thread-session-runtime.js +2 -2
- package/dist/system-prompt-drift-plugin.js +243 -0
- package/dist/system-prompt-drift-plugin.test.js +158 -0
- package/package.json +7 -6
- package/skills/npm-package/SKILL.md +14 -9
- package/src/agent-model.e2e.test.ts +2 -1
- package/src/anthropic-auth-plugin.test.ts +7 -1
- package/src/anthropic-auth-plugin.ts +15 -0
- package/src/anthropic-auth-state.ts +28 -2
- package/src/cli.ts +2 -2
- package/src/commands/agent.ts +2 -2
- package/src/commands/merge-worktree.ts +11 -8
- package/src/context-awareness-plugin.test.ts +2 -2
- package/src/context-awareness-plugin.ts +1 -1
- package/src/kimaki-opencode-plugin.ts +1 -0
- package/src/logger.ts +9 -2
- package/src/session-handler/thread-session-runtime.ts +2 -2
- package/src/system-prompt-drift-plugin.ts +359 -0
|
@@ -735,7 +735,8 @@ describe('agent model resolution', () => {
|
|
|
735
735
|
--- from: assistant (TestBot)
|
|
736
736
|
⬥ ok
|
|
737
737
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
|
|
738
|
-
Switched to **plan** agent for this session
|
|
738
|
+
Switched to **plan** agent for this session (was **test-agent**)
|
|
739
|
+
The agent will change on the next message.
|
|
739
740
|
--- from: user (agent-model-tester)
|
|
740
741
|
Reply with exactly: after-switch-msg
|
|
741
742
|
--- from: assistant (TestBot)
|
|
@@ -627,6 +627,15 @@ async function getFreshOAuth(getAuth, client) {
|
|
|
627
627
|
// --- Plugin export ---
|
|
628
628
|
const AnthropicAuthPlugin = async ({ client }) => {
|
|
629
629
|
return {
|
|
630
|
+
"experimental.chat.system.transform": async (input, output) => {
|
|
631
|
+
if (input.model.providerID !== ('anthropic'))
|
|
632
|
+
return;
|
|
633
|
+
const opencodePromptPart = output.system.findIndex(x => x?.includes('https://github.com/anomalyco/opencode'));
|
|
634
|
+
// Remove the OpenCode system prompt part if present
|
|
635
|
+
if (opencodePromptPart !== -1) {
|
|
636
|
+
output.system.splice(opencodePromptPart, 1);
|
|
637
|
+
}
|
|
638
|
+
},
|
|
630
639
|
auth: {
|
|
631
640
|
provider: 'anthropic',
|
|
632
641
|
async loader(getAuth, provider) {
|
|
@@ -694,6 +703,13 @@ const AnthropicAuthPlugin = async ({ client }) => {
|
|
|
694
703
|
if (shouldRotateAuth(response.status, bodyText)) {
|
|
695
704
|
const rotated = await rotateAnthropicAccount(freshAuth, client);
|
|
696
705
|
if (rotated) {
|
|
706
|
+
// Show toast notification so Discord thread shows the rotation
|
|
707
|
+
client.tui.showToast({
|
|
708
|
+
body: {
|
|
709
|
+
message: `Switching from account ${rotated.fromLabel} to account ${rotated.toLabel}`,
|
|
710
|
+
variant: 'info',
|
|
711
|
+
},
|
|
712
|
+
}).catch(() => { });
|
|
697
713
|
const retryAuth = await getFreshOAuth(getAuth, client);
|
|
698
714
|
if (retryAuth) {
|
|
699
715
|
response = await runRequest(retryAuth);
|
|
@@ -67,7 +67,13 @@ describe('rotateAnthropicAccount', () => {
|
|
|
67
67
|
const rotated = await rotateAnthropicAccount(firstAccount, client);
|
|
68
68
|
const store = await loadAccountStore();
|
|
69
69
|
const authJson = JSON.parse(await readFile(authFilePath(), 'utf8'));
|
|
70
|
-
expect(rotated).toMatchObject({
|
|
70
|
+
expect(rotated).toMatchObject({
|
|
71
|
+
auth: { refresh: 'refresh-second' },
|
|
72
|
+
fromLabel: '#1 (refresh-...irst)',
|
|
73
|
+
toLabel: '#2 (refresh-...cond)',
|
|
74
|
+
fromIndex: 0,
|
|
75
|
+
toIndex: 1,
|
|
76
|
+
});
|
|
71
77
|
expect(store.activeIndex).toBe(1);
|
|
72
78
|
expect(authJson.anthropic?.refresh).toBe('refresh-second');
|
|
73
79
|
expect(authSetCalls).toEqual([
|
|
@@ -94,6 +94,12 @@ export async function loadAccountStore() {
|
|
|
94
94
|
export async function saveAccountStore(store) {
|
|
95
95
|
await writeJson(accountsFilePath(), normalizeAccountStore(store));
|
|
96
96
|
}
|
|
97
|
+
/** Short label for an account: first 8 + last 4 chars of refresh token. */
|
|
98
|
+
export function accountLabel(account, index) {
|
|
99
|
+
const r = account.refresh;
|
|
100
|
+
const short = r.length > 12 ? `${r.slice(0, 8)}...${r.slice(-4)}` : r;
|
|
101
|
+
return index !== undefined ? `#${index + 1} (${short})` : short;
|
|
102
|
+
}
|
|
97
103
|
function findCurrentAccountIndex(store, auth) {
|
|
98
104
|
if (!store.accounts.length)
|
|
99
105
|
return 0;
|
|
@@ -165,10 +171,14 @@ export async function rotateAnthropicAccount(auth, client) {
|
|
|
165
171
|
if (store.accounts.length < 2)
|
|
166
172
|
return undefined;
|
|
167
173
|
const currentIndex = findCurrentAccountIndex(store, auth);
|
|
174
|
+
const currentAccount = store.accounts[currentIndex];
|
|
168
175
|
const nextIndex = (currentIndex + 1) % store.accounts.length;
|
|
169
176
|
const nextAccount = store.accounts[nextIndex];
|
|
170
177
|
if (!nextAccount)
|
|
171
178
|
return undefined;
|
|
179
|
+
const fromLabel = currentAccount
|
|
180
|
+
? accountLabel(currentAccount, currentIndex)
|
|
181
|
+
: accountLabel(auth, currentIndex);
|
|
172
182
|
nextAccount.lastUsed = Date.now();
|
|
173
183
|
store.activeIndex = nextIndex;
|
|
174
184
|
await saveAccountStore(store);
|
|
@@ -179,7 +189,13 @@ export async function rotateAnthropicAccount(auth, client) {
|
|
|
179
189
|
expires: nextAccount.expires,
|
|
180
190
|
};
|
|
181
191
|
await setAnthropicAuth(nextAuth, client);
|
|
182
|
-
return
|
|
192
|
+
return {
|
|
193
|
+
auth: nextAuth,
|
|
194
|
+
fromLabel,
|
|
195
|
+
toLabel: accountLabel(nextAccount, nextIndex),
|
|
196
|
+
fromIndex: currentIndex,
|
|
197
|
+
toIndex: nextIndex,
|
|
198
|
+
};
|
|
183
199
|
});
|
|
184
200
|
}
|
|
185
201
|
export async function removeAccount(index) {
|
package/dist/cli.js
CHANGED
|
@@ -32,7 +32,7 @@ import { backgroundUpgradeKimaki, upgrade, getCurrentVersion, } from './upgrade.
|
|
|
32
32
|
import { startHranaServer } from './hrana-server.js';
|
|
33
33
|
import { startIpcPolling, stopIpcPolling } from './ipc-polling.js';
|
|
34
34
|
import { getPromptPreview, parseSendAtValue, parseScheduledTaskPayload, serializeScheduledTaskPayload, } from './task-schedule.js';
|
|
35
|
-
import { accountsFilePath, loadAccountStore, removeAccount, } from './anthropic-auth-state.js';
|
|
35
|
+
import { accountLabel, accountsFilePath, loadAccountStore, removeAccount, } from './anthropic-auth-state.js';
|
|
36
36
|
const cliLogger = createLogger(LogPrefix.CLI);
|
|
37
37
|
// Gateway bot mode constants.
|
|
38
38
|
// KIMAKI_GATEWAY_APP_ID is the Discord Application ID of the gateway bot.
|
|
@@ -2234,8 +2234,7 @@ cli
|
|
|
2234
2234
|
}
|
|
2235
2235
|
store.accounts.forEach((account, index) => {
|
|
2236
2236
|
const active = index === store.activeIndex ? '*' : ' ';
|
|
2237
|
-
|
|
2238
|
-
console.log(`${active} ${index + 1}. ${label}`);
|
|
2237
|
+
console.log(`${active} ${index + 1}. ${accountLabel(account)}`);
|
|
2239
2238
|
});
|
|
2240
2239
|
process.exit(0);
|
|
2241
2240
|
});
|
package/dist/commands/agent.js
CHANGED
|
@@ -255,7 +255,7 @@ export async function handleAgentSelectMenu(interaction) {
|
|
|
255
255
|
await setAgentForContext({ context, agentName: selectedAgent });
|
|
256
256
|
if (context.isThread && context.sessionId) {
|
|
257
257
|
await interaction.editReply({
|
|
258
|
-
content: `Agent preference set for this session
|
|
258
|
+
content: `Agent preference set for this session: **${selectedAgent}**\nThe agent will change on the next message.`,
|
|
259
259
|
components: [],
|
|
260
260
|
});
|
|
261
261
|
}
|
|
@@ -317,7 +317,7 @@ export async function handleQuickAgentCommand({ command, appId, }) {
|
|
|
317
317
|
: '';
|
|
318
318
|
if (context.isThread && context.sessionId) {
|
|
319
319
|
await command.editReply({
|
|
320
|
-
content: `Switched to **${resolvedAgentName}** agent for this session
|
|
320
|
+
content: `Switched to **${resolvedAgentName}** agent for this session${previousText}\nThe agent will change on the next message.`,
|
|
321
321
|
});
|
|
322
322
|
}
|
|
323
323
|
else {
|
|
@@ -103,15 +103,18 @@ export async function handleMergeWorktreeCommand({ command, appId, }) {
|
|
|
103
103
|
await command.editReply('Rebase conflict detected. Asking the model to resolve...');
|
|
104
104
|
await sendPromptToModel({
|
|
105
105
|
prompt: [
|
|
106
|
-
|
|
106
|
+
`A rebase conflict occurred while merging this worktree into \`${result.target}\`.`,
|
|
107
107
|
'Rebasing multiple commits can pause on each commit that conflicts, so you may need to repeat the resolve/continue loop several times.',
|
|
108
|
-
'
|
|
109
|
-
'1. Check `git status` to see which files have conflicts',
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
'4.
|
|
113
|
-
'5.
|
|
114
|
-
'6.
|
|
108
|
+
'Before editing anything, first understand both sides so you preserve both intentions and do not drop features or fixes.',
|
|
109
|
+
'1. Check `git status` to see which files have conflicts and confirm the rebase is paused',
|
|
110
|
+
`2. Find the merge base between this worktree and \`${result.target}\`, then read the commit messages from both sides since that merge base so you understand the goal of each change`,
|
|
111
|
+
`3. Read the diffs from that merge base to both sides so you understand exactly what changed on this branch and on \`${result.target}\` before resolving conflicts`,
|
|
112
|
+
'4. Read the commit currently being replayed in the rebase so you know the intent of the specific conflicting patch',
|
|
113
|
+
'5. Edit the conflicted files to preserve both intended changes where possible instead of choosing one side wholesale',
|
|
114
|
+
'6. Stage resolved files with `git add`',
|
|
115
|
+
'7. Continue the rebase with `git rebase --continue`',
|
|
116
|
+
'8. If git reports more conflicts, repeat steps 1-7 until the rebase finishes (no more rebase in progress, `git status` is clean)',
|
|
117
|
+
'9. Once the rebase is fully complete, tell me so I can run `/merge-worktree` again',
|
|
115
118
|
].join('\n'),
|
|
116
119
|
thread,
|
|
117
120
|
projectDirectory: worktreeInfo.project_directory,
|
|
@@ -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
|
});
|
|
@@ -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) {
|
|
@@ -3132,8 +3132,8 @@ export class ThreadSessionRuntime {
|
|
|
3132
3132
|
const truncate = (s, max) => {
|
|
3133
3133
|
return s.length > max ? s.slice(0, max - 1) + '\u2026' : s;
|
|
3134
3134
|
};
|
|
3135
|
-
const truncatedFolder = truncate(folderName,
|
|
3136
|
-
const truncatedBranch = truncate(branchName,
|
|
3135
|
+
const truncatedFolder = truncate(folderName, 30);
|
|
3136
|
+
const truncatedBranch = truncate(branchName, 30);
|
|
3137
3137
|
const projectInfo = truncatedBranch
|
|
3138
3138
|
? `${truncatedFolder} ⋅ ${truncatedBranch} ⋅ `
|
|
3139
3139
|
: `${truncatedFolder} ⋅ `;
|
|
@@ -0,0 +1,243 @@
|
|
|
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
|
+
function getSystemPromptDiffDir({ dataDir }) {
|
|
15
|
+
return path.join(dataDir, 'system-prompt-diffs');
|
|
16
|
+
}
|
|
17
|
+
function normalizeSystemPrompt({ system }) {
|
|
18
|
+
return system.join('\n');
|
|
19
|
+
}
|
|
20
|
+
function buildTurnContext({ input, directory, }) {
|
|
21
|
+
const model = input.model
|
|
22
|
+
? `${input.model.providerID}/${input.model.modelID}${input.variant ? `:${input.variant}` : ''}`
|
|
23
|
+
: undefined;
|
|
24
|
+
return {
|
|
25
|
+
agent: input.agent,
|
|
26
|
+
model,
|
|
27
|
+
directory,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function shouldSuppressDiffNotice({ previousContext, currentContext, }) {
|
|
31
|
+
if (!previousContext || !currentContext) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return (previousContext.agent !== currentContext.agent
|
|
35
|
+
|| previousContext.model !== currentContext.model
|
|
36
|
+
|| previousContext.directory !== currentContext.directory);
|
|
37
|
+
}
|
|
38
|
+
function buildPatch({ beforeText, afterText, beforeLabel, afterLabel, }) {
|
|
39
|
+
const changes = diffLines(beforeText, afterText);
|
|
40
|
+
const additions = changes.reduce((count, change) => {
|
|
41
|
+
if (!change.added) {
|
|
42
|
+
return count;
|
|
43
|
+
}
|
|
44
|
+
return count + change.count;
|
|
45
|
+
}, 0);
|
|
46
|
+
const deletions = changes.reduce((count, change) => {
|
|
47
|
+
if (!change.removed) {
|
|
48
|
+
return count;
|
|
49
|
+
}
|
|
50
|
+
return count + change.count;
|
|
51
|
+
}, 0);
|
|
52
|
+
const patch = createPatch(afterLabel, beforeText, afterText, beforeLabel, afterLabel);
|
|
53
|
+
return {
|
|
54
|
+
additions,
|
|
55
|
+
deletions,
|
|
56
|
+
patch,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function writeSystemPromptDiffFile({ dataDir, sessionId, beforePrompt, afterPrompt, }) {
|
|
60
|
+
const diff = buildPatch({
|
|
61
|
+
beforeText: beforePrompt,
|
|
62
|
+
afterText: afterPrompt,
|
|
63
|
+
beforeLabel: 'system-before.txt',
|
|
64
|
+
afterLabel: 'system-after.txt',
|
|
65
|
+
});
|
|
66
|
+
const timestamp = new Date().toISOString().replaceAll(':', '-');
|
|
67
|
+
const sessionDir = path.join(getSystemPromptDiffDir({ dataDir }), sessionId);
|
|
68
|
+
const filePath = path.join(sessionDir, `${timestamp}.diff`);
|
|
69
|
+
const latestPromptPath = path.join(sessionDir, `${sessionId}.md`);
|
|
70
|
+
const fileContent = [
|
|
71
|
+
`Session: ${sessionId}`,
|
|
72
|
+
`Created: ${new Date().toISOString()}`,
|
|
73
|
+
`Additions: ${diff.additions}`,
|
|
74
|
+
`Deletions: ${diff.deletions}`,
|
|
75
|
+
'',
|
|
76
|
+
diff.patch,
|
|
77
|
+
].join('\n');
|
|
78
|
+
return errore.try({
|
|
79
|
+
try: () => {
|
|
80
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
81
|
+
fs.writeFileSync(filePath, fileContent);
|
|
82
|
+
// fs.writeFileSync(latestPromptPath, afterPrompt)
|
|
83
|
+
return {
|
|
84
|
+
additions: diff.additions,
|
|
85
|
+
deletions: diff.deletions,
|
|
86
|
+
filePath,
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
catch: (error) => {
|
|
90
|
+
return new Error('Failed to write system prompt diff file', { cause: error });
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function getOrCreateSessionState({ sessions, sessionId, }) {
|
|
95
|
+
const existing = sessions.get(sessionId);
|
|
96
|
+
if (existing) {
|
|
97
|
+
return existing;
|
|
98
|
+
}
|
|
99
|
+
const state = {
|
|
100
|
+
userTurnCount: 0,
|
|
101
|
+
previousTurnPrompt: undefined,
|
|
102
|
+
latestTurnPrompt: undefined,
|
|
103
|
+
latestTurnPromptTurn: 0,
|
|
104
|
+
comparedTurn: 0,
|
|
105
|
+
previousTurnContext: undefined,
|
|
106
|
+
currentTurnContext: undefined,
|
|
107
|
+
};
|
|
108
|
+
sessions.set(sessionId, state);
|
|
109
|
+
return state;
|
|
110
|
+
}
|
|
111
|
+
async function handleSystemTransform({ input, output, sessions, dataDir, client, }) {
|
|
112
|
+
const sessionId = input.sessionID;
|
|
113
|
+
if (!sessionId) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const currentPrompt = normalizeSystemPrompt({ system: output.system });
|
|
117
|
+
const state = getOrCreateSessionState({
|
|
118
|
+
sessions,
|
|
119
|
+
sessionId,
|
|
120
|
+
});
|
|
121
|
+
const currentTurn = state.userTurnCount;
|
|
122
|
+
state.latestTurnPrompt = currentPrompt;
|
|
123
|
+
state.latestTurnPromptTurn = currentTurn;
|
|
124
|
+
if (currentTurn <= 1) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (state.comparedTurn === currentTurn) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const previousPrompt = state.previousTurnPrompt;
|
|
131
|
+
state.comparedTurn = currentTurn;
|
|
132
|
+
if (!previousPrompt || previousPrompt === currentPrompt) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (shouldSuppressDiffNotice({
|
|
136
|
+
previousContext: state.previousTurnContext,
|
|
137
|
+
currentContext: state.currentTurnContext,
|
|
138
|
+
})) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!dataDir) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const diffFileResult = writeSystemPromptDiffFile({
|
|
145
|
+
dataDir,
|
|
146
|
+
sessionId,
|
|
147
|
+
beforePrompt: previousPrompt,
|
|
148
|
+
afterPrompt: currentPrompt,
|
|
149
|
+
});
|
|
150
|
+
if (diffFileResult instanceof Error) {
|
|
151
|
+
throw diffFileResult;
|
|
152
|
+
}
|
|
153
|
+
await client.tui.showToast({
|
|
154
|
+
body: {
|
|
155
|
+
variant: 'info',
|
|
156
|
+
title: 'Context cache discarded',
|
|
157
|
+
message: `System prompt changed since the previous message (+${diffFileResult.additions} / -${diffFileResult.deletions}). ` +
|
|
158
|
+
`This usually means a plugin mutated the prompt and increased rate-limit usage. ` +
|
|
159
|
+
`Diff: ${abbreviatePath(diffFileResult.filePath)}`,
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
const systemPromptDriftPlugin = async ({ client, directory }) => {
|
|
164
|
+
initSentry();
|
|
165
|
+
const dataDir = process.env.KIMAKI_DATA_DIR;
|
|
166
|
+
if (dataDir) {
|
|
167
|
+
setPluginLogFilePath(dataDir);
|
|
168
|
+
}
|
|
169
|
+
const sessions = new Map();
|
|
170
|
+
return {
|
|
171
|
+
'chat.message': async (input) => {
|
|
172
|
+
const sessionId = input.sessionID;
|
|
173
|
+
if (!sessionId) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const state = getOrCreateSessionState({ sessions, sessionId });
|
|
177
|
+
if (state.userTurnCount > 0
|
|
178
|
+
&& state.latestTurnPromptTurn === state.userTurnCount) {
|
|
179
|
+
state.previousTurnPrompt = state.latestTurnPrompt;
|
|
180
|
+
state.previousTurnContext = state.currentTurnContext;
|
|
181
|
+
}
|
|
182
|
+
state.currentTurnContext = buildTurnContext({ input, directory });
|
|
183
|
+
state.userTurnCount += 1;
|
|
184
|
+
},
|
|
185
|
+
'experimental.chat.system.transform': async (input, output) => {
|
|
186
|
+
const result = await errore.tryAsync({
|
|
187
|
+
try: async () => {
|
|
188
|
+
await handleSystemTransform({
|
|
189
|
+
input,
|
|
190
|
+
output,
|
|
191
|
+
sessions,
|
|
192
|
+
dataDir,
|
|
193
|
+
client,
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
catch: (error) => {
|
|
197
|
+
return new Error('system prompt drift transform hook failed', {
|
|
198
|
+
cause: error,
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
if (result instanceof Error) {
|
|
203
|
+
logger.warn(`[system-prompt-drift-plugin] ${formatPluginErrorWithStack(result)}`);
|
|
204
|
+
void notifyError(result, 'system prompt drift plugin transform hook failed');
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
event: async ({ event }) => {
|
|
208
|
+
const result = await errore.tryAsync({
|
|
209
|
+
try: async () => {
|
|
210
|
+
if (event.type !== 'session.deleted') {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const deletedSessionId = getDeletedSessionId({ event });
|
|
214
|
+
if (!deletedSessionId) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
sessions.delete(deletedSessionId);
|
|
218
|
+
},
|
|
219
|
+
catch: (error) => {
|
|
220
|
+
return new Error('system prompt drift event hook failed', {
|
|
221
|
+
cause: error,
|
|
222
|
+
});
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
if (result instanceof Error) {
|
|
226
|
+
logger.warn(`[system-prompt-drift-plugin] ${formatPluginErrorWithStack(result)}`);
|
|
227
|
+
void notifyError(result, 'system prompt drift plugin event hook failed');
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
};
|
|
232
|
+
function getDeletedSessionId({ event }) {
|
|
233
|
+
if (event.type !== 'session.deleted') {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
const sessionInfo = event.properties?.info;
|
|
237
|
+
if (!sessionInfo || typeof sessionInfo !== 'object') {
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
const id = 'id' in sessionInfo ? sessionInfo.id : undefined;
|
|
241
|
+
return typeof id === 'string' ? id : undefined;
|
|
242
|
+
}
|
|
243
|
+
export { systemPromptDriftPlugin };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Tests for system prompt drift detection in the OpenCode plugin hook.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, test } from 'vitest';
|
|
5
|
+
import { systemPromptDriftPlugin } from './system-prompt-drift-plugin.js';
|
|
6
|
+
function createContext({ client }) {
|
|
7
|
+
return {
|
|
8
|
+
client: client,
|
|
9
|
+
project: {
|
|
10
|
+
id: 'project-id',
|
|
11
|
+
worktree: '/Users/morse/Documents/GitHub/kimakivoice',
|
|
12
|
+
time: { created: Date.now() },
|
|
13
|
+
},
|
|
14
|
+
directory: '/Users/morse/Documents/GitHub/kimakivoice',
|
|
15
|
+
worktree: '/Users/morse/Documents/GitHub/kimakivoice',
|
|
16
|
+
serverUrl: new URL('http://127.0.0.1:4096'),
|
|
17
|
+
$: {},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function createTransformOutput({ system, }) {
|
|
21
|
+
return { system };
|
|
22
|
+
}
|
|
23
|
+
async function requireHooks({ client, }) {
|
|
24
|
+
const hooks = await systemPromptDriftPlugin(createContext({ client }));
|
|
25
|
+
const transformHook = hooks['experimental.chat.system.transform'];
|
|
26
|
+
if (!transformHook) {
|
|
27
|
+
throw new Error('Expected experimental.chat.system.transform hook');
|
|
28
|
+
}
|
|
29
|
+
const eventHook = hooks.event;
|
|
30
|
+
if (!eventHook) {
|
|
31
|
+
throw new Error('Expected event hook');
|
|
32
|
+
}
|
|
33
|
+
return { transformHook, eventHook };
|
|
34
|
+
}
|
|
35
|
+
function createSessionDeletedEvent({ sessionId }) {
|
|
36
|
+
return {
|
|
37
|
+
type: 'session.deleted',
|
|
38
|
+
properties: {
|
|
39
|
+
info: { id: sessionId },
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const createdPaths = [];
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
delete process.env.KIMAKI_DATA_DIR;
|
|
46
|
+
for (const createdPath of createdPaths.splice(0)) {
|
|
47
|
+
fs.rmSync(createdPath, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
describe('systemPromptDriftPlugin', () => {
|
|
51
|
+
test('first prompt only stores baseline and shows no toast', async () => {
|
|
52
|
+
const toastCalls = [];
|
|
53
|
+
const client = {
|
|
54
|
+
tui: {
|
|
55
|
+
showToast: async (input) => {
|
|
56
|
+
toastCalls.push(input);
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
const tmpRoot = path.join(process.cwd(), 'tmp', 'system-prompt-drift-first');
|
|
61
|
+
createdPaths.push(tmpRoot);
|
|
62
|
+
fs.mkdirSync(tmpRoot, { recursive: true });
|
|
63
|
+
process.env.KIMAKI_DATA_DIR = tmpRoot;
|
|
64
|
+
const { transformHook } = await requireHooks({ client });
|
|
65
|
+
await transformHook({
|
|
66
|
+
sessionID: 'ses-first',
|
|
67
|
+
model: { id: 'model-id', name: 'Model', providerID: 'provider-id' },
|
|
68
|
+
}, createTransformOutput({ system: ['alpha', 'beta'] }));
|
|
69
|
+
expect(toastCalls).toMatchInlineSnapshot('[]');
|
|
70
|
+
expect(fs.existsSync(path.join(tmpRoot, 'system-prompt-diffs', 'ses-first'))).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
test('changed prompt writes diff file and shows toast with counts', async () => {
|
|
73
|
+
const toastCalls = [];
|
|
74
|
+
const client = {
|
|
75
|
+
tui: {
|
|
76
|
+
showToast: async (input) => {
|
|
77
|
+
toastCalls.push(input);
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
const tmpRoot = path.join(process.cwd(), 'tmp', 'system-prompt-drift-changed');
|
|
82
|
+
createdPaths.push(tmpRoot);
|
|
83
|
+
fs.mkdirSync(tmpRoot, { recursive: true });
|
|
84
|
+
process.env.KIMAKI_DATA_DIR = tmpRoot;
|
|
85
|
+
const { transformHook } = await requireHooks({ client });
|
|
86
|
+
const input = {
|
|
87
|
+
sessionID: 'ses-changed',
|
|
88
|
+
model: { id: 'model-id', name: 'Model', providerID: 'provider-id' },
|
|
89
|
+
};
|
|
90
|
+
await transformHook(input, createTransformOutput({ system: ['line-1', 'line-2'] }));
|
|
91
|
+
await transformHook(input, createTransformOutput({ system: ['line-1', 'line-2-updated', 'line-3'] }));
|
|
92
|
+
const diffDir = path.join(tmpRoot, 'system-prompt-diffs', 'ses-changed');
|
|
93
|
+
const diffFiles = fs.readdirSync(diffDir);
|
|
94
|
+
const diffPath = path.join(diffDir, diffFiles[0] || '');
|
|
95
|
+
const diffContent = fs.readFileSync(diffPath, 'utf-8');
|
|
96
|
+
expect(toastCalls).toHaveLength(1);
|
|
97
|
+
const sanitizedToastCall = {
|
|
98
|
+
body: {
|
|
99
|
+
...toastCalls[0].body,
|
|
100
|
+
message: toastCalls[0].body.message.replace(diffPath, '<diff-path>'),
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
expect(sanitizedToastCall).toMatchInlineSnapshot(`
|
|
104
|
+
{
|
|
105
|
+
"body": {
|
|
106
|
+
"message": "The system prompt changed since the previous message (+2 / -1). This discards context cache and increases rate limits. This usually means a plugin is mutating the system prompt. Diff: <diff-path>",
|
|
107
|
+
"title": "System prompt changed",
|
|
108
|
+
"variant": "error",
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
`);
|
|
112
|
+
expect(diffFiles.length).toBe(1);
|
|
113
|
+
expect(diffContent.replace(/Created: .*\n/u, 'Created: <timestamp>\n')).toMatchInlineSnapshot(`
|
|
114
|
+
"Session: ses-changed
|
|
115
|
+
Created: <timestamp>
|
|
116
|
+
Additions: 2
|
|
117
|
+
Deletions: 1
|
|
118
|
+
|
|
119
|
+
Index: system-after.txt
|
|
120
|
+
===================================================================
|
|
121
|
+
--- system-after.txt\tsystem-before.txt
|
|
122
|
+
+++ system-after.txt\tsystem-after.txt
|
|
123
|
+
@@ -1,2 +1,3 @@
|
|
124
|
+
line-1
|
|
125
|
+
-line-2
|
|
126
|
+
\
|
|
127
|
+
+line-2-updated
|
|
128
|
+
+line-3
|
|
129
|
+
\
|
|
130
|
+
"
|
|
131
|
+
`);
|
|
132
|
+
});
|
|
133
|
+
test('session.deleted clears baseline so next turn becomes first turn again', async () => {
|
|
134
|
+
const toastCalls = [];
|
|
135
|
+
const client = {
|
|
136
|
+
tui: {
|
|
137
|
+
showToast: async (input) => {
|
|
138
|
+
toastCalls.push(input);
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
const tmpRoot = path.join(process.cwd(), 'tmp', 'system-prompt-drift-deleted');
|
|
143
|
+
createdPaths.push(tmpRoot);
|
|
144
|
+
fs.mkdirSync(tmpRoot, { recursive: true });
|
|
145
|
+
process.env.KIMAKI_DATA_DIR = tmpRoot;
|
|
146
|
+
const { transformHook, eventHook } = await requireHooks({ client });
|
|
147
|
+
const input = {
|
|
148
|
+
sessionID: 'ses-deleted',
|
|
149
|
+
model: { id: 'model-id', name: 'Model', providerID: 'provider-id' },
|
|
150
|
+
};
|
|
151
|
+
await transformHook(input, createTransformOutput({ system: ['first'] }));
|
|
152
|
+
await eventHook({ event: createSessionDeletedEvent({ sessionId: 'ses-deleted' }) });
|
|
153
|
+
await transformHook(input, createTransformOutput({ system: ['second'] }));
|
|
154
|
+
expect(toastCalls).toMatchInlineSnapshot('[]');
|
|
155
|
+
const diffSessionDir = path.join(tmpRoot, 'system-prompt-diffs', 'ses-deleted');
|
|
156
|
+
expect(fs.existsSync(diffSessionDir)).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
});
|