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.
@@ -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 next messages (was **test-agent**)
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({ refresh: 'refresh-second' });
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 nextAuth;
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
- const label = `${account.refresh.slice(0, 8)}...${account.refresh.slice(-4)}`;
2238
- console.log(`${active} ${index + 1}. ${label}`);
2237
+ console.log(`${active} ${index + 1}. ${accountLabel(account)}`);
2239
2238
  });
2240
2239
  process.exit(0);
2241
2240
  });
@@ -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 next messages: **${selectedAgent}**`,
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 next messages${previousText}`,
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
- 'A rebase conflict occurred while merging this worktree into the default branch.',
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
- 'Please resolve the rebase conflicts:',
109
- '1. Check `git status` to see which files have conflicts',
110
- '2. Edit the conflicted files to resolve the merge markers',
111
- '3. Stage resolved files with `git add`',
112
- '4. Continue the rebase with `git rebase --continue`',
113
- '5. If git reports more conflicts, repeat steps 1-4 until the rebase finishes (no more MERGE markers, `git status` shows no rebase in progress)',
114
- '6. Once the rebase is fully complete, tell me so I can run `/merge-worktree` again',
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 MUST read, write, and edit files only under ${currentDir}. ` +
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 MUST read, write, and edit files only under /repo/worktree. Do NOT read, write, or edit files under /repo/main.]",
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 MUST read, write, and edit files only under /repo/worktree-b. Do NOT read, write, or edit files under /repo/worktree-a.]",
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, 15);
3136
- const truncatedBranch = truncate(branchName, 15);
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
+ });