kimaki 0.4.93 → 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/anthropic-auth-plugin.js +9 -0
- 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/system-prompt-drift-plugin.js +243 -0
- package/dist/system-prompt-drift-plugin.test.js +158 -0
- package/package.json +5 -4
- package/src/anthropic-auth-plugin.ts +8 -0
- 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/system-prompt-drift-plugin.ts +359 -0
|
@@ -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) {
|
|
@@ -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) {
|
|
@@ -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
|
+
});
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.
|
|
5
|
+
"version": "0.4.94",
|
|
6
6
|
"repository": "https://github.com/remorses/kimaki",
|
|
7
7
|
"bin": "bin.js",
|
|
8
8
|
"files": [
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"prisma": "7.4.2",
|
|
26
26
|
"tsx": "^4.20.5",
|
|
27
27
|
"undici": "^8.0.2",
|
|
28
|
+
"opencode-cached-provider": "^0.0.1",
|
|
28
29
|
"db": "^0.0.0",
|
|
29
30
|
"discord-digital-twin": "^0.1.0",
|
|
30
|
-
"opencode-cached-provider": "^0.0.1",
|
|
31
31
|
"opencode-deterministic-provider": "^0.0.1"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"@prisma/client": "7.4.2",
|
|
46
46
|
"@purinton/resampler": "^1.0.4",
|
|
47
47
|
"cron-parser": "^5.5.0",
|
|
48
|
+
"diff": "^8.0.4",
|
|
48
49
|
"discord.js": "^14.25.1",
|
|
49
50
|
"domhandler": "^6.0.1",
|
|
50
51
|
"goke": "^6.3.2",
|
|
@@ -64,9 +65,9 @@
|
|
|
64
65
|
"zod": "^4.3.6",
|
|
65
66
|
"zustand": "^5.0.11",
|
|
66
67
|
"errore": "^0.14.1",
|
|
67
|
-
"
|
|
68
|
+
"traforo": "^0.2.4",
|
|
68
69
|
"opencode-injection-guard": "^0.2.1",
|
|
69
|
-
"
|
|
70
|
+
"libsqlproxy": "^0.1.0"
|
|
70
71
|
},
|
|
71
72
|
"optionalDependencies": {
|
|
72
73
|
"@snazzah/davey": "^0.1.10",
|
|
@@ -743,6 +743,14 @@ async function getFreshOAuth(
|
|
|
743
743
|
|
|
744
744
|
const AnthropicAuthPlugin: Plugin = async ({ client }) => {
|
|
745
745
|
return {
|
|
746
|
+
"experimental.chat.system.transform": async (input, output) => {
|
|
747
|
+
if (input.model.providerID !== ('anthropic')) return
|
|
748
|
+
const opencodePromptPart = output.system.findIndex(x => x?.includes('https://github.com/anomalyco/opencode'))
|
|
749
|
+
// Remove the OpenCode system prompt part if present
|
|
750
|
+
if (opencodePromptPart !== -1) {
|
|
751
|
+
output.system.splice(opencodePromptPart, 1)
|
|
752
|
+
}
|
|
753
|
+
},
|
|
746
754
|
auth: {
|
|
747
755
|
provider: 'anthropic',
|
|
748
756
|
async loader(
|
|
@@ -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
|
}
|
|
@@ -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 {
|
|
@@ -0,0 +1,359 @@
|
|
|
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
|
+
|
|
18
|
+
type PluginHooks = Awaited<ReturnType<Plugin>>
|
|
19
|
+
type SystemTransformHook = NonNullable<PluginHooks['experimental.chat.system.transform']>
|
|
20
|
+
type SystemTransformInput = Parameters<SystemTransformHook>[0]
|
|
21
|
+
type SystemTransformOutput = Parameters<SystemTransformHook>[1]
|
|
22
|
+
type PluginEventHook = NonNullable<PluginHooks['event']>
|
|
23
|
+
type PluginEvent = Parameters<PluginEventHook>[0]['event']
|
|
24
|
+
type ChatMessageHook = NonNullable<PluginHooks['chat.message']>
|
|
25
|
+
type ChatMessageInput = Parameters<ChatMessageHook>[0]
|
|
26
|
+
|
|
27
|
+
type SessionState = {
|
|
28
|
+
userTurnCount: number
|
|
29
|
+
previousTurnPrompt: string | undefined
|
|
30
|
+
latestTurnPrompt: string | undefined
|
|
31
|
+
latestTurnPromptTurn: number
|
|
32
|
+
comparedTurn: number
|
|
33
|
+
previousTurnContext: TurnContext | undefined
|
|
34
|
+
currentTurnContext: TurnContext | undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type SystemPromptDiff = {
|
|
38
|
+
additions: number
|
|
39
|
+
deletions: number
|
|
40
|
+
patch: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type TurnContext = {
|
|
44
|
+
agent: string | undefined
|
|
45
|
+
model: string | undefined
|
|
46
|
+
directory: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getSystemPromptDiffDir({ dataDir }: { dataDir: string }): string {
|
|
50
|
+
return path.join(dataDir, 'system-prompt-diffs')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeSystemPrompt({ system }: { system: string[] }): string {
|
|
54
|
+
return system.join('\n')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildTurnContext({
|
|
58
|
+
input,
|
|
59
|
+
directory,
|
|
60
|
+
}: {
|
|
61
|
+
input: ChatMessageInput
|
|
62
|
+
directory: string
|
|
63
|
+
}): TurnContext {
|
|
64
|
+
const model = input.model
|
|
65
|
+
? `${input.model.providerID}/${input.model.modelID}${input.variant ? `:${input.variant}` : ''}`
|
|
66
|
+
: undefined
|
|
67
|
+
return {
|
|
68
|
+
agent: input.agent,
|
|
69
|
+
model,
|
|
70
|
+
directory,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function shouldSuppressDiffNotice({
|
|
75
|
+
previousContext,
|
|
76
|
+
currentContext,
|
|
77
|
+
}: {
|
|
78
|
+
previousContext: TurnContext | undefined
|
|
79
|
+
currentContext: TurnContext | undefined
|
|
80
|
+
}): boolean {
|
|
81
|
+
if (!previousContext || !currentContext) {
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
return (
|
|
85
|
+
previousContext.agent !== currentContext.agent
|
|
86
|
+
|| previousContext.model !== currentContext.model
|
|
87
|
+
|| previousContext.directory !== currentContext.directory
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildPatch({
|
|
92
|
+
beforeText,
|
|
93
|
+
afterText,
|
|
94
|
+
beforeLabel,
|
|
95
|
+
afterLabel,
|
|
96
|
+
}: {
|
|
97
|
+
beforeText: string
|
|
98
|
+
afterText: string
|
|
99
|
+
beforeLabel: string
|
|
100
|
+
afterLabel: string
|
|
101
|
+
}): SystemPromptDiff {
|
|
102
|
+
const changes = diffLines(beforeText, afterText)
|
|
103
|
+
const additions = changes.reduce((count, change) => {
|
|
104
|
+
if (!change.added) {
|
|
105
|
+
return count
|
|
106
|
+
}
|
|
107
|
+
return count + change.count
|
|
108
|
+
}, 0)
|
|
109
|
+
const deletions = changes.reduce((count, change) => {
|
|
110
|
+
if (!change.removed) {
|
|
111
|
+
return count
|
|
112
|
+
}
|
|
113
|
+
return count + change.count
|
|
114
|
+
}, 0)
|
|
115
|
+
const patch = createPatch(afterLabel, beforeText, afterText, beforeLabel, afterLabel)
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
additions,
|
|
119
|
+
deletions,
|
|
120
|
+
patch,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function writeSystemPromptDiffFile({
|
|
125
|
+
dataDir,
|
|
126
|
+
sessionId,
|
|
127
|
+
beforePrompt,
|
|
128
|
+
afterPrompt,
|
|
129
|
+
}: {
|
|
130
|
+
dataDir: string
|
|
131
|
+
sessionId: string
|
|
132
|
+
beforePrompt: string
|
|
133
|
+
afterPrompt: string
|
|
134
|
+
}): Error | { additions: number; deletions: number; filePath: string } {
|
|
135
|
+
const diff = buildPatch({
|
|
136
|
+
beforeText: beforePrompt,
|
|
137
|
+
afterText: afterPrompt,
|
|
138
|
+
beforeLabel: 'system-before.txt',
|
|
139
|
+
afterLabel: 'system-after.txt',
|
|
140
|
+
})
|
|
141
|
+
const timestamp = new Date().toISOString().replaceAll(':', '-')
|
|
142
|
+
const sessionDir = path.join(getSystemPromptDiffDir({ dataDir }), sessionId)
|
|
143
|
+
const filePath = path.join(sessionDir, `${timestamp}.diff`)
|
|
144
|
+
const latestPromptPath = path.join(sessionDir, `${sessionId}.md`)
|
|
145
|
+
const fileContent = [
|
|
146
|
+
`Session: ${sessionId}`,
|
|
147
|
+
`Created: ${new Date().toISOString()}`,
|
|
148
|
+
`Additions: ${diff.additions}`,
|
|
149
|
+
`Deletions: ${diff.deletions}`,
|
|
150
|
+
'',
|
|
151
|
+
diff.patch,
|
|
152
|
+
].join('\n')
|
|
153
|
+
|
|
154
|
+
return errore.try({
|
|
155
|
+
try: () => {
|
|
156
|
+
fs.mkdirSync(sessionDir, { recursive: true })
|
|
157
|
+
fs.writeFileSync(filePath, fileContent)
|
|
158
|
+
// fs.writeFileSync(latestPromptPath, afterPrompt)
|
|
159
|
+
return {
|
|
160
|
+
additions: diff.additions,
|
|
161
|
+
deletions: diff.deletions,
|
|
162
|
+
filePath,
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
catch: (error) => {
|
|
166
|
+
return new Error('Failed to write system prompt diff file', { cause: error })
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function getOrCreateSessionState({
|
|
172
|
+
sessions,
|
|
173
|
+
sessionId,
|
|
174
|
+
}: {
|
|
175
|
+
sessions: Map<string, SessionState>
|
|
176
|
+
sessionId: string
|
|
177
|
+
}): SessionState {
|
|
178
|
+
const existing = sessions.get(sessionId)
|
|
179
|
+
if (existing) {
|
|
180
|
+
return existing
|
|
181
|
+
}
|
|
182
|
+
const state = {
|
|
183
|
+
userTurnCount: 0,
|
|
184
|
+
previousTurnPrompt: undefined,
|
|
185
|
+
latestTurnPrompt: undefined,
|
|
186
|
+
latestTurnPromptTurn: 0,
|
|
187
|
+
comparedTurn: 0,
|
|
188
|
+
previousTurnContext: undefined,
|
|
189
|
+
currentTurnContext: undefined,
|
|
190
|
+
}
|
|
191
|
+
sessions.set(sessionId, state)
|
|
192
|
+
return state
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function handleSystemTransform({
|
|
196
|
+
input,
|
|
197
|
+
output,
|
|
198
|
+
sessions,
|
|
199
|
+
dataDir,
|
|
200
|
+
client,
|
|
201
|
+
}: {
|
|
202
|
+
input: SystemTransformInput
|
|
203
|
+
output: SystemTransformOutput
|
|
204
|
+
sessions: Map<string, SessionState>
|
|
205
|
+
dataDir: string | undefined
|
|
206
|
+
client: Parameters<Plugin>[0]['client']
|
|
207
|
+
}): Promise<void> {
|
|
208
|
+
const sessionId = input.sessionID
|
|
209
|
+
if (!sessionId) {
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const currentPrompt = normalizeSystemPrompt({ system: output.system })
|
|
214
|
+
const state = getOrCreateSessionState({
|
|
215
|
+
sessions,
|
|
216
|
+
sessionId,
|
|
217
|
+
})
|
|
218
|
+
const currentTurn = state.userTurnCount
|
|
219
|
+
state.latestTurnPrompt = currentPrompt
|
|
220
|
+
state.latestTurnPromptTurn = currentTurn
|
|
221
|
+
|
|
222
|
+
if (currentTurn <= 1) {
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
if (state.comparedTurn === currentTurn) {
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
const previousPrompt = state.previousTurnPrompt
|
|
229
|
+
state.comparedTurn = currentTurn
|
|
230
|
+
if (!previousPrompt || previousPrompt === currentPrompt) {
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
if (
|
|
234
|
+
shouldSuppressDiffNotice({
|
|
235
|
+
previousContext: state.previousTurnContext,
|
|
236
|
+
currentContext: state.currentTurnContext,
|
|
237
|
+
})
|
|
238
|
+
) {
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!dataDir) {
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const diffFileResult = writeSystemPromptDiffFile({
|
|
247
|
+
dataDir,
|
|
248
|
+
sessionId,
|
|
249
|
+
beforePrompt: previousPrompt,
|
|
250
|
+
afterPrompt: currentPrompt,
|
|
251
|
+
})
|
|
252
|
+
if (diffFileResult instanceof Error) {
|
|
253
|
+
throw diffFileResult
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
await client.tui.showToast({
|
|
257
|
+
body: {
|
|
258
|
+
variant: 'info',
|
|
259
|
+
title: 'Context cache discarded',
|
|
260
|
+
message:
|
|
261
|
+
`System prompt changed since the previous message (+${diffFileResult.additions} / -${diffFileResult.deletions}). ` +
|
|
262
|
+
`This usually means a plugin mutated the prompt and increased rate-limit usage. ` +
|
|
263
|
+
`Diff: ${abbreviatePath(diffFileResult.filePath)}`,
|
|
264
|
+
},
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const systemPromptDriftPlugin: Plugin = async ({ client, directory }) => {
|
|
269
|
+
initSentry()
|
|
270
|
+
|
|
271
|
+
const dataDir = process.env.KIMAKI_DATA_DIR
|
|
272
|
+
if (dataDir) {
|
|
273
|
+
setPluginLogFilePath(dataDir)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const sessions = new Map<string, SessionState>()
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
'chat.message': async (input) => {
|
|
280
|
+
const sessionId = input.sessionID
|
|
281
|
+
if (!sessionId) {
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
const state = getOrCreateSessionState({ sessions, sessionId })
|
|
285
|
+
if (
|
|
286
|
+
state.userTurnCount > 0
|
|
287
|
+
&& state.latestTurnPromptTurn === state.userTurnCount
|
|
288
|
+
) {
|
|
289
|
+
state.previousTurnPrompt = state.latestTurnPrompt
|
|
290
|
+
state.previousTurnContext = state.currentTurnContext
|
|
291
|
+
}
|
|
292
|
+
state.currentTurnContext = buildTurnContext({ input, directory })
|
|
293
|
+
state.userTurnCount += 1
|
|
294
|
+
},
|
|
295
|
+
'experimental.chat.system.transform': async (input, output) => {
|
|
296
|
+
const result = await errore.tryAsync({
|
|
297
|
+
try: async () => {
|
|
298
|
+
await handleSystemTransform({
|
|
299
|
+
input,
|
|
300
|
+
output,
|
|
301
|
+
sessions,
|
|
302
|
+
dataDir,
|
|
303
|
+
client,
|
|
304
|
+
})
|
|
305
|
+
},
|
|
306
|
+
catch: (error) => {
|
|
307
|
+
return new Error('system prompt drift transform hook failed', {
|
|
308
|
+
cause: error,
|
|
309
|
+
})
|
|
310
|
+
},
|
|
311
|
+
})
|
|
312
|
+
if (result instanceof Error) {
|
|
313
|
+
logger.warn(
|
|
314
|
+
`[system-prompt-drift-plugin] ${formatPluginErrorWithStack(result)}`,
|
|
315
|
+
)
|
|
316
|
+
void notifyError(result, 'system prompt drift plugin transform hook failed')
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
event: async ({ event }) => {
|
|
320
|
+
const result = await errore.tryAsync({
|
|
321
|
+
try: async () => {
|
|
322
|
+
if (event.type !== 'session.deleted') {
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
const deletedSessionId = getDeletedSessionId({ event })
|
|
326
|
+
if (!deletedSessionId) {
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
sessions.delete(deletedSessionId)
|
|
330
|
+
},
|
|
331
|
+
catch: (error) => {
|
|
332
|
+
return new Error('system prompt drift event hook failed', {
|
|
333
|
+
cause: error,
|
|
334
|
+
})
|
|
335
|
+
},
|
|
336
|
+
})
|
|
337
|
+
if (result instanceof Error) {
|
|
338
|
+
logger.warn(
|
|
339
|
+
`[system-prompt-drift-plugin] ${formatPluginErrorWithStack(result)}`,
|
|
340
|
+
)
|
|
341
|
+
void notifyError(result, 'system prompt drift plugin event hook failed')
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function getDeletedSessionId({ event }: { event: PluginEvent }): string | undefined {
|
|
348
|
+
if (event.type !== 'session.deleted') {
|
|
349
|
+
return undefined
|
|
350
|
+
}
|
|
351
|
+
const sessionInfo = event.properties?.info
|
|
352
|
+
if (!sessionInfo || typeof sessionInfo !== 'object') {
|
|
353
|
+
return undefined
|
|
354
|
+
}
|
|
355
|
+
const id = 'id' in sessionInfo ? sessionInfo.id : undefined
|
|
356
|
+
return typeof id === 'string' ? id : undefined
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export { systemPromptDriftPlugin }
|