@otto-assistant/bridge 0.4.92 → 0.4.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-model.e2e.test.js +2 -1
- package/dist/anthropic-auth-plugin.js +30 -9
- 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/discord-utils.js +5 -2
- package/dist/kimaki-opencode-plugin.js +1 -0
- package/dist/logger.js +8 -2
- package/dist/session-handler/thread-session-runtime.js +20 -3
- package/dist/system-message.js +13 -0
- package/dist/system-message.test.js +13 -0
- package/dist/system-prompt-drift-plugin.js +251 -0
- package/dist/utils.js +5 -1
- package/package.json +2 -1
- 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 +29 -9
- 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/discord-utils.ts +19 -17
- package/src/kimaki-opencode-plugin.ts +1 -0
- package/src/logger.ts +9 -2
- package/src/session-handler/thread-session-runtime.ts +23 -3
- package/src/system-message.test.ts +13 -0
- package/src/system-message.ts +13 -0
- package/src/system-prompt-drift-plugin.ts +379 -0
- package/src/utils.ts +5 -1
package/src/discord-utils.ts
CHANGED
|
@@ -2,19 +2,21 @@
|
|
|
2
2
|
// Handles markdown splitting for Discord's 2000-char limit, code block escaping,
|
|
3
3
|
// thread message sending, and channel metadata extraction from topic tags.
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
5
|
+
// Use namespace import for CJS interop — discord.js is CJS and its named
|
|
6
|
+
// exports aren't detectable by all ESM loaders (e.g. tsx/esbuild) because
|
|
7
|
+
// discord.js uses tslib's __exportStar which is opaque to static analysis.
|
|
8
|
+
import * as discord from 'discord.js'
|
|
9
|
+
import type {
|
|
10
|
+
APIInteractionGuildMember,
|
|
11
|
+
AutocompleteInteraction,
|
|
12
|
+
GuildMember as GuildMemberType,
|
|
13
|
+
Guild,
|
|
14
|
+
Message,
|
|
15
|
+
REST as RESTType,
|
|
16
|
+
TextChannel,
|
|
17
|
+
ThreadChannel,
|
|
16
18
|
} from 'discord.js'
|
|
17
|
-
|
|
19
|
+
const { ChannelType, GuildMember, MessageFlags, PermissionsBitField, REST, Routes } = discord
|
|
18
20
|
import type { OpencodeClient } from '@opencode-ai/sdk/v2'
|
|
19
21
|
import { discordApiUrl } from './discord-urls.js'
|
|
20
22
|
import { Lexer } from 'marked'
|
|
@@ -37,7 +39,7 @@ const discordLogger = createLogger(LogPrefix.DISCORD)
|
|
|
37
39
|
* Returns false if member is null or has the "no-kimaki" role (overrides all).
|
|
38
40
|
*/
|
|
39
41
|
export function hasKimakiBotPermission(
|
|
40
|
-
member:
|
|
42
|
+
member: GuildMemberType | APIInteractionGuildMember | null,
|
|
41
43
|
guild?: Guild | null,
|
|
42
44
|
): boolean {
|
|
43
45
|
if (!member) {
|
|
@@ -61,7 +63,7 @@ export function hasKimakiBotPermission(
|
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
function hasRoleByName(
|
|
64
|
-
member:
|
|
66
|
+
member: GuildMemberType | APIInteractionGuildMember,
|
|
65
67
|
roleName: string,
|
|
66
68
|
guild?: Guild | null,
|
|
67
69
|
): boolean {
|
|
@@ -89,7 +91,7 @@ function hasRoleByName(
|
|
|
89
91
|
* Check if the member has the "no-kimaki" role that blocks bot access.
|
|
90
92
|
* Separate from hasKimakiBotPermission so callers can show a specific error message.
|
|
91
93
|
*/
|
|
92
|
-
export function hasNoKimakiRole(member:
|
|
94
|
+
export function hasNoKimakiRole(member: GuildMemberType | null): boolean {
|
|
93
95
|
if (!member?.roles?.cache) {
|
|
94
96
|
return false
|
|
95
97
|
}
|
|
@@ -108,7 +110,7 @@ export async function reactToThread({
|
|
|
108
110
|
channelId,
|
|
109
111
|
emoji,
|
|
110
112
|
}: {
|
|
111
|
-
rest:
|
|
113
|
+
rest: RESTType
|
|
112
114
|
threadId: string
|
|
113
115
|
/** Parent channel ID where the thread starter message lives.
|
|
114
116
|
* If not provided, fetches the thread info from Discord API to resolve it. */
|
|
@@ -169,7 +171,7 @@ export async function archiveThread({
|
|
|
169
171
|
client,
|
|
170
172
|
archiveDelay = 0,
|
|
171
173
|
}: {
|
|
172
|
-
rest:
|
|
174
|
+
rest: RESTType
|
|
173
175
|
threadId: string
|
|
174
176
|
parentChannelId?: string
|
|
175
177
|
sessionId?: string
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
export { ipcToolsPlugin } from './ipc-tools-plugin.js'
|
|
13
13
|
export { contextAwarenessPlugin } from './context-awareness-plugin.js'
|
|
14
14
|
export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js'
|
|
15
|
+
export { systemPromptDriftPlugin } from './system-prompt-drift-plugin.js'
|
|
15
16
|
export { anthropicAuthPlugin } from './anthropic-auth-plugin.js'
|
|
16
17
|
export { imageOptimizerPlugin } from './image-optimizer-plugin.js'
|
|
17
18
|
export { kittyGraphicsPlugin } from 'kitty-graphics-agent'
|
package/src/logger.ts
CHANGED
|
@@ -95,12 +95,19 @@ export function getLogFilePath(): string | null {
|
|
|
95
95
|
return logFilePath
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
const MAX_LOG_ARG_LENGTH = 1000
|
|
99
|
+
|
|
100
|
+
function truncate(str: string, max: number): string {
|
|
101
|
+
if (str.length <= max) return str
|
|
102
|
+
return str.slice(0, max) + `… [truncated ${str.length - max} chars]`
|
|
103
|
+
}
|
|
104
|
+
|
|
98
105
|
function formatArg(arg: unknown): string {
|
|
99
106
|
if (typeof arg === 'string') {
|
|
100
|
-
return sanitizeSensitiveText(arg, { redactPaths: false })
|
|
107
|
+
return truncate(sanitizeSensitiveText(arg, { redactPaths: false }), MAX_LOG_ARG_LENGTH)
|
|
101
108
|
}
|
|
102
109
|
const safeArg = sanitizeUnknownValue(arg, { redactPaths: false })
|
|
103
|
-
return util.inspect(safeArg, { colors: true, depth: 4 })
|
|
110
|
+
return truncate(util.inspect(safeArg, { colors: true, depth: 4 }), MAX_LOG_ARG_LENGTH)
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
export function formatErrorWithStack(error: unknown): string {
|
|
@@ -137,6 +137,17 @@ import { extractLeadingOpencodeCommand } from '../opencode-command-detection.js'
|
|
|
137
137
|
const logger = createLogger(LogPrefix.SESSION)
|
|
138
138
|
const discordLogger = createLogger(LogPrefix.DISCORD)
|
|
139
139
|
const DETERMINISTIC_CONTEXT_LIMIT = 100_000
|
|
140
|
+
const TOAST_SESSION_ID_REGEX = /\b(ses_[A-Za-z0-9]+)\b\s*$/u
|
|
141
|
+
|
|
142
|
+
function extractToastSessionId({ message }: { message: string }): string | undefined {
|
|
143
|
+
const match = message.match(TOAST_SESSION_ID_REGEX)
|
|
144
|
+
return match?.[1]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function stripToastSessionId({ message }: { message: string }): string {
|
|
148
|
+
return message.replace(TOAST_SESSION_ID_REGEX, '').trimEnd()
|
|
149
|
+
}
|
|
150
|
+
|
|
140
151
|
const shouldLogSessionEvents =
|
|
141
152
|
process.env['KIMAKI_LOG_SESSION_EVENTS'] === '1' ||
|
|
142
153
|
process.env['KIMAKI_VITEST'] === '1'
|
|
@@ -1381,6 +1392,9 @@ export class ThreadSessionRuntime {
|
|
|
1381
1392
|
const sessionId = this.state?.sessionId
|
|
1382
1393
|
|
|
1383
1394
|
const eventSessionId = getOpencodeEventSessionId(event)
|
|
1395
|
+
const toastSessionId = event.type === 'tui.toast.show'
|
|
1396
|
+
? extractToastSessionId({ message: event.properties.message })
|
|
1397
|
+
: undefined
|
|
1384
1398
|
|
|
1385
1399
|
if (shouldLogSessionEvents) {
|
|
1386
1400
|
const eventDetails = (() => {
|
|
@@ -1412,6 +1426,7 @@ export class ThreadSessionRuntime {
|
|
|
1412
1426
|
}
|
|
1413
1427
|
|
|
1414
1428
|
const isGlobalEvent = event.type === 'tui.toast.show'
|
|
1429
|
+
const isScopedToastEvent = Boolean(toastSessionId)
|
|
1415
1430
|
|
|
1416
1431
|
// Drop events that don't match current session (stale events from
|
|
1417
1432
|
// previous sessions), unless it's a global event or a subtask session.
|
|
@@ -1420,6 +1435,11 @@ export class ThreadSessionRuntime {
|
|
|
1420
1435
|
return // stale event from previous session
|
|
1421
1436
|
}
|
|
1422
1437
|
}
|
|
1438
|
+
if (isScopedToastEvent && toastSessionId !== sessionId) {
|
|
1439
|
+
if (!this.getSubtaskInfoForSession(toastSessionId!)) {
|
|
1440
|
+
return
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1423
1443
|
|
|
1424
1444
|
if (isOpencodeSessionEventLogEnabled()) {
|
|
1425
1445
|
const eventLogResult = await appendOpencodeSessionEventLog({
|
|
@@ -2763,7 +2783,7 @@ export class ThreadSessionRuntime {
|
|
|
2763
2783
|
if (properties.variant === 'warning') {
|
|
2764
2784
|
return
|
|
2765
2785
|
}
|
|
2766
|
-
const toastMessage = properties.message.trim()
|
|
2786
|
+
const toastMessage = stripToastSessionId({ message: properties.message }).trim()
|
|
2767
2787
|
if (!toastMessage) {
|
|
2768
2788
|
return
|
|
2769
2789
|
}
|
|
@@ -4111,8 +4131,8 @@ export class ThreadSessionRuntime {
|
|
|
4111
4131
|
const truncate = (s: string, max: number) => {
|
|
4112
4132
|
return s.length > max ? s.slice(0, max - 1) + '\u2026' : s
|
|
4113
4133
|
}
|
|
4114
|
-
const truncatedFolder = truncate(folderName,
|
|
4115
|
-
const truncatedBranch = truncate(branchName,
|
|
4134
|
+
const truncatedFolder = truncate(folderName, 30)
|
|
4135
|
+
const truncatedBranch = truncate(branchName, 30)
|
|
4116
4136
|
const projectInfo = truncatedBranch
|
|
4117
4137
|
? `${truncatedFolder} ⋅ ${truncatedBranch} ⋅ `
|
|
4118
4138
|
: `${truncatedFolder} ⋅ `
|
|
@@ -142,10 +142,23 @@ describe('system-message', () => {
|
|
|
142
142
|
- \`plan\`: planning only
|
|
143
143
|
- \`build\`: edits files
|
|
144
144
|
|
|
145
|
+
## running opencode commands via kimaki send
|
|
146
|
+
|
|
147
|
+
You can trigger registered opencode commands (slash commands, skills, MCP prompts) by starting the \`--prompt\` with \`/commandname\`:
|
|
148
|
+
|
|
149
|
+
kimaki send --thread <thread_id> --prompt "/review fix the auth module"
|
|
150
|
+
kimaki send --channel chan_123 --prompt "/build-cmd update dependencies" --user "Tommy"
|
|
151
|
+
|
|
152
|
+
The command name must match a registered opencode command. If the command is not recognized, the prompt is sent as plain text to the model. This works for both new threads (\`--channel\`) and existing threads (\`--thread\`/\`--session\`).
|
|
153
|
+
|
|
145
154
|
## switching agents in the current session
|
|
146
155
|
|
|
147
156
|
The user can switch the active agent mid-session using the Discord slash command \`/<agentname>-agent\`. For example if you are in plan mode and the user asks you to edit files, tell them to run \`/build-agent\` to switch to the build agent first.
|
|
148
157
|
|
|
158
|
+
You can also switch agents via \`kimaki send\`:
|
|
159
|
+
|
|
160
|
+
kimaki send --thread <thread_id> --prompt "/<agentname>-agent"
|
|
161
|
+
|
|
149
162
|
## scheduled sends and task management
|
|
150
163
|
|
|
151
164
|
Use \`--send-at\` to schedule a one-time or recurring task:
|
package/src/system-message.ts
CHANGED
|
@@ -477,10 +477,23 @@ Use --agent to specify which agent to use for the session:
|
|
|
477
477
|
kimaki send --channel ${channelId} --prompt "Plan the refactor of the auth module" --agent plan${userArg}
|
|
478
478
|
${availableAgentsContext}
|
|
479
479
|
|
|
480
|
+
## running opencode commands via kimaki send
|
|
481
|
+
|
|
482
|
+
You can trigger registered opencode commands (slash commands, skills, MCP prompts) by starting the \`--prompt\` with \`/commandname\`:
|
|
483
|
+
|
|
484
|
+
kimaki send --thread <thread_id> --prompt "/review fix the auth module"
|
|
485
|
+
kimaki send --channel ${channelId} --prompt "/build-cmd update dependencies"${userArg}
|
|
486
|
+
|
|
487
|
+
The command name must match a registered opencode command. If the command is not recognized, the prompt is sent as plain text to the model. This works for both new threads (\`--channel\`) and existing threads (\`--thread\`/\`--session\`).
|
|
488
|
+
|
|
480
489
|
## switching agents in the current session
|
|
481
490
|
|
|
482
491
|
The user can switch the active agent mid-session using the Discord slash command \`/<agentname>-agent\`. For example if you are in plan mode and the user asks you to edit files, tell them to run \`/build-agent\` to switch to the build agent first.
|
|
483
492
|
|
|
493
|
+
You can also switch agents via \`kimaki send\`:
|
|
494
|
+
|
|
495
|
+
kimaki send --thread <thread_id> --prompt "/<agentname>-agent"
|
|
496
|
+
|
|
484
497
|
## scheduled sends and task management
|
|
485
498
|
|
|
486
499
|
Use \`--send-at\` to schedule a one-time or recurring task:
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// OpenCode plugin that detects per-session system prompt drift across turns.
|
|
2
|
+
// When the effective system prompt changes after the first user message, it
|
|
3
|
+
// writes a debug diff file and shows a toast because prompt-cache invalidation
|
|
4
|
+
// increases rate-limit usage and usually means another plugin is mutating the
|
|
5
|
+
// system prompt unexpectedly.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import type { Plugin } from '@opencode-ai/plugin'
|
|
10
|
+
import { createPatch, diffLines } from 'diff'
|
|
11
|
+
import * as errore from 'errore'
|
|
12
|
+
import { createPluginLogger, formatPluginErrorWithStack, setPluginLogFilePath } from './plugin-logger.js'
|
|
13
|
+
import { initSentry, notifyError } from './sentry.js'
|
|
14
|
+
import { abbreviatePath } from './utils.js'
|
|
15
|
+
|
|
16
|
+
const logger = createPluginLogger('OPENCODE')
|
|
17
|
+
const TOAST_SESSION_MARKER_SEPARATOR = ' '
|
|
18
|
+
|
|
19
|
+
type PluginHooks = Awaited<ReturnType<Plugin>>
|
|
20
|
+
type SystemTransformHook = NonNullable<PluginHooks['experimental.chat.system.transform']>
|
|
21
|
+
type SystemTransformInput = Parameters<SystemTransformHook>[0]
|
|
22
|
+
type SystemTransformOutput = Parameters<SystemTransformHook>[1]
|
|
23
|
+
type PluginEventHook = NonNullable<PluginHooks['event']>
|
|
24
|
+
type PluginEvent = Parameters<PluginEventHook>[0]['event']
|
|
25
|
+
type ChatMessageHook = NonNullable<PluginHooks['chat.message']>
|
|
26
|
+
type ChatMessageInput = Parameters<ChatMessageHook>[0]
|
|
27
|
+
|
|
28
|
+
type SessionState = {
|
|
29
|
+
userTurnCount: number
|
|
30
|
+
previousTurnPrompt: string | undefined
|
|
31
|
+
latestTurnPrompt: string | undefined
|
|
32
|
+
latestTurnPromptTurn: number
|
|
33
|
+
comparedTurn: number
|
|
34
|
+
previousTurnContext: TurnContext | undefined
|
|
35
|
+
currentTurnContext: TurnContext | undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type SystemPromptDiff = {
|
|
39
|
+
additions: number
|
|
40
|
+
deletions: number
|
|
41
|
+
patch: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type TurnContext = {
|
|
45
|
+
agent: string | undefined
|
|
46
|
+
model: string | undefined
|
|
47
|
+
directory: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getSystemPromptDiffDir({ dataDir }: { dataDir: string }): string {
|
|
51
|
+
return path.join(dataDir, 'system-prompt-diffs')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeSystemPrompt({ system }: { system: string[] }): string {
|
|
55
|
+
return system.join('\n')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function appendToastSessionMarker({
|
|
59
|
+
message,
|
|
60
|
+
sessionId,
|
|
61
|
+
}: {
|
|
62
|
+
message: string
|
|
63
|
+
sessionId: string
|
|
64
|
+
}): string {
|
|
65
|
+
return `${message}${TOAST_SESSION_MARKER_SEPARATOR}${sessionId}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildTurnContext({
|
|
69
|
+
input,
|
|
70
|
+
directory,
|
|
71
|
+
}: {
|
|
72
|
+
input: ChatMessageInput
|
|
73
|
+
directory: string
|
|
74
|
+
}): TurnContext {
|
|
75
|
+
const model = input.model
|
|
76
|
+
? `${input.model.providerID}/${input.model.modelID}${input.variant ? `:${input.variant}` : ''}`
|
|
77
|
+
: undefined
|
|
78
|
+
return {
|
|
79
|
+
agent: input.agent,
|
|
80
|
+
model,
|
|
81
|
+
directory,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function shouldSuppressDiffNotice({
|
|
86
|
+
previousContext,
|
|
87
|
+
currentContext,
|
|
88
|
+
}: {
|
|
89
|
+
previousContext: TurnContext | undefined
|
|
90
|
+
currentContext: TurnContext | undefined
|
|
91
|
+
}): boolean {
|
|
92
|
+
if (!previousContext || !currentContext) {
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
return (
|
|
96
|
+
previousContext.agent !== currentContext.agent
|
|
97
|
+
|| previousContext.model !== currentContext.model
|
|
98
|
+
|| previousContext.directory !== currentContext.directory
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildPatch({
|
|
103
|
+
beforeText,
|
|
104
|
+
afterText,
|
|
105
|
+
beforeLabel,
|
|
106
|
+
afterLabel,
|
|
107
|
+
}: {
|
|
108
|
+
beforeText: string
|
|
109
|
+
afterText: string
|
|
110
|
+
beforeLabel: string
|
|
111
|
+
afterLabel: string
|
|
112
|
+
}): SystemPromptDiff {
|
|
113
|
+
const changes = diffLines(beforeText, afterText)
|
|
114
|
+
const additions = changes.reduce((count, change) => {
|
|
115
|
+
if (!change.added) {
|
|
116
|
+
return count
|
|
117
|
+
}
|
|
118
|
+
return count + change.count
|
|
119
|
+
}, 0)
|
|
120
|
+
const deletions = changes.reduce((count, change) => {
|
|
121
|
+
if (!change.removed) {
|
|
122
|
+
return count
|
|
123
|
+
}
|
|
124
|
+
return count + change.count
|
|
125
|
+
}, 0)
|
|
126
|
+
const patch = createPatch(afterLabel, beforeText, afterText, beforeLabel, afterLabel)
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
additions,
|
|
130
|
+
deletions,
|
|
131
|
+
patch,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function writeSystemPromptDiffFile({
|
|
136
|
+
dataDir,
|
|
137
|
+
sessionId,
|
|
138
|
+
beforePrompt,
|
|
139
|
+
afterPrompt,
|
|
140
|
+
}: {
|
|
141
|
+
dataDir: string
|
|
142
|
+
sessionId: string
|
|
143
|
+
beforePrompt: string
|
|
144
|
+
afterPrompt: string
|
|
145
|
+
}): Error | {
|
|
146
|
+
additions: number
|
|
147
|
+
deletions: number
|
|
148
|
+
filePath: string
|
|
149
|
+
latestPromptPath: string
|
|
150
|
+
} {
|
|
151
|
+
const diff = buildPatch({
|
|
152
|
+
beforeText: beforePrompt,
|
|
153
|
+
afterText: afterPrompt,
|
|
154
|
+
beforeLabel: 'system-before.txt',
|
|
155
|
+
afterLabel: 'system-after.txt',
|
|
156
|
+
})
|
|
157
|
+
const timestamp = new Date().toISOString().replaceAll(':', '-')
|
|
158
|
+
const sessionDir = path.join(getSystemPromptDiffDir({ dataDir }), sessionId)
|
|
159
|
+
const filePath = path.join(sessionDir, `${timestamp}.diff`)
|
|
160
|
+
const latestPromptPath = path.join(sessionDir, `${timestamp}.md`)
|
|
161
|
+
const fileContent = [
|
|
162
|
+
`Session: ${sessionId}`,
|
|
163
|
+
`Created: ${new Date().toISOString()}`,
|
|
164
|
+
`Additions: ${diff.additions}`,
|
|
165
|
+
`Deletions: ${diff.deletions}`,
|
|
166
|
+
'',
|
|
167
|
+
diff.patch,
|
|
168
|
+
].join('\n')
|
|
169
|
+
|
|
170
|
+
return errore.try({
|
|
171
|
+
try: () => {
|
|
172
|
+
fs.mkdirSync(sessionDir, { recursive: true })
|
|
173
|
+
fs.writeFileSync(filePath, fileContent)
|
|
174
|
+
// fs.writeFileSync(latestPromptPath, afterPrompt)
|
|
175
|
+
return {
|
|
176
|
+
additions: diff.additions,
|
|
177
|
+
deletions: diff.deletions,
|
|
178
|
+
filePath,
|
|
179
|
+
latestPromptPath,
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
catch: (error) => {
|
|
183
|
+
return new Error('Failed to write system prompt diff file', { cause: error })
|
|
184
|
+
},
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function getOrCreateSessionState({
|
|
189
|
+
sessions,
|
|
190
|
+
sessionId,
|
|
191
|
+
}: {
|
|
192
|
+
sessions: Map<string, SessionState>
|
|
193
|
+
sessionId: string
|
|
194
|
+
}): SessionState {
|
|
195
|
+
const existing = sessions.get(sessionId)
|
|
196
|
+
if (existing) {
|
|
197
|
+
return existing
|
|
198
|
+
}
|
|
199
|
+
const state = {
|
|
200
|
+
userTurnCount: 0,
|
|
201
|
+
previousTurnPrompt: undefined,
|
|
202
|
+
latestTurnPrompt: undefined,
|
|
203
|
+
latestTurnPromptTurn: 0,
|
|
204
|
+
comparedTurn: 0,
|
|
205
|
+
previousTurnContext: undefined,
|
|
206
|
+
currentTurnContext: undefined,
|
|
207
|
+
}
|
|
208
|
+
sessions.set(sessionId, state)
|
|
209
|
+
return state
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function handleSystemTransform({
|
|
213
|
+
input,
|
|
214
|
+
output,
|
|
215
|
+
sessions,
|
|
216
|
+
dataDir,
|
|
217
|
+
client,
|
|
218
|
+
}: {
|
|
219
|
+
input: SystemTransformInput
|
|
220
|
+
output: SystemTransformOutput
|
|
221
|
+
sessions: Map<string, SessionState>
|
|
222
|
+
dataDir: string | undefined
|
|
223
|
+
client: Parameters<Plugin>[0]['client']
|
|
224
|
+
}): Promise<void> {
|
|
225
|
+
const sessionId = input.sessionID
|
|
226
|
+
if (!sessionId) {
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const currentPrompt = normalizeSystemPrompt({ system: output.system })
|
|
231
|
+
const state = getOrCreateSessionState({
|
|
232
|
+
sessions,
|
|
233
|
+
sessionId,
|
|
234
|
+
})
|
|
235
|
+
const currentTurn = state.userTurnCount
|
|
236
|
+
state.latestTurnPrompt = currentPrompt
|
|
237
|
+
state.latestTurnPromptTurn = currentTurn
|
|
238
|
+
|
|
239
|
+
if (currentTurn <= 1) {
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
if (state.comparedTurn === currentTurn) {
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
const previousPrompt = state.previousTurnPrompt
|
|
246
|
+
state.comparedTurn = currentTurn
|
|
247
|
+
if (!previousPrompt || previousPrompt === currentPrompt) {
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
if (
|
|
251
|
+
shouldSuppressDiffNotice({
|
|
252
|
+
previousContext: state.previousTurnContext,
|
|
253
|
+
currentContext: state.currentTurnContext,
|
|
254
|
+
})
|
|
255
|
+
) {
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!dataDir) {
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const diffFileResult = writeSystemPromptDiffFile({
|
|
264
|
+
dataDir,
|
|
265
|
+
sessionId,
|
|
266
|
+
beforePrompt: previousPrompt,
|
|
267
|
+
afterPrompt: currentPrompt,
|
|
268
|
+
})
|
|
269
|
+
if (diffFileResult instanceof Error) {
|
|
270
|
+
throw diffFileResult
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
await client.tui.showToast({
|
|
274
|
+
body: {
|
|
275
|
+
variant: 'info',
|
|
276
|
+
title: 'Context cache discarded',
|
|
277
|
+
message: appendToastSessionMarker({
|
|
278
|
+
sessionId,
|
|
279
|
+
message:
|
|
280
|
+
`system prompt changed since the previous message (+${diffFileResult.additions} / -${diffFileResult.deletions}). ` +
|
|
281
|
+
`Diff: \`${abbreviatePath(diffFileResult.filePath)}\`. ` +
|
|
282
|
+
`Latest prompt: \`${abbreviatePath(diffFileResult.latestPromptPath)}\``,
|
|
283
|
+
}),
|
|
284
|
+
},
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const systemPromptDriftPlugin: Plugin = async ({ client, directory }) => {
|
|
289
|
+
initSentry()
|
|
290
|
+
|
|
291
|
+
const dataDir = process.env.KIMAKI_DATA_DIR
|
|
292
|
+
if (dataDir) {
|
|
293
|
+
setPluginLogFilePath(dataDir)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const sessions = new Map<string, SessionState>()
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
'chat.message': async (input) => {
|
|
300
|
+
const sessionId = input.sessionID
|
|
301
|
+
if (!sessionId) {
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
const state = getOrCreateSessionState({ sessions, sessionId })
|
|
305
|
+
if (
|
|
306
|
+
state.userTurnCount > 0
|
|
307
|
+
&& state.latestTurnPromptTurn === state.userTurnCount
|
|
308
|
+
) {
|
|
309
|
+
state.previousTurnPrompt = state.latestTurnPrompt
|
|
310
|
+
state.previousTurnContext = state.currentTurnContext
|
|
311
|
+
}
|
|
312
|
+
state.currentTurnContext = buildTurnContext({ input, directory })
|
|
313
|
+
state.userTurnCount += 1
|
|
314
|
+
},
|
|
315
|
+
'experimental.chat.system.transform': async (input, output) => {
|
|
316
|
+
const result = await errore.tryAsync({
|
|
317
|
+
try: async () => {
|
|
318
|
+
await handleSystemTransform({
|
|
319
|
+
input,
|
|
320
|
+
output,
|
|
321
|
+
sessions,
|
|
322
|
+
dataDir,
|
|
323
|
+
client,
|
|
324
|
+
})
|
|
325
|
+
},
|
|
326
|
+
catch: (error) => {
|
|
327
|
+
return new Error('system prompt drift transform hook failed', {
|
|
328
|
+
cause: error,
|
|
329
|
+
})
|
|
330
|
+
},
|
|
331
|
+
})
|
|
332
|
+
if (result instanceof Error) {
|
|
333
|
+
logger.warn(
|
|
334
|
+
`[system-prompt-drift-plugin] ${formatPluginErrorWithStack(result)}`,
|
|
335
|
+
)
|
|
336
|
+
void notifyError(result, 'system prompt drift plugin transform hook failed')
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
event: async ({ event }) => {
|
|
340
|
+
const result = await errore.tryAsync({
|
|
341
|
+
try: async () => {
|
|
342
|
+
if (event.type !== 'session.deleted') {
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
const deletedSessionId = getDeletedSessionId({ event })
|
|
346
|
+
if (!deletedSessionId) {
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
sessions.delete(deletedSessionId)
|
|
350
|
+
},
|
|
351
|
+
catch: (error) => {
|
|
352
|
+
return new Error('system prompt drift event hook failed', {
|
|
353
|
+
cause: error,
|
|
354
|
+
})
|
|
355
|
+
},
|
|
356
|
+
})
|
|
357
|
+
if (result instanceof Error) {
|
|
358
|
+
logger.warn(
|
|
359
|
+
`[system-prompt-drift-plugin] ${formatPluginErrorWithStack(result)}`,
|
|
360
|
+
)
|
|
361
|
+
void notifyError(result, 'system prompt drift plugin event hook failed')
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function getDeletedSessionId({ event }: { event: PluginEvent }): string | undefined {
|
|
368
|
+
if (event.type !== 'session.deleted') {
|
|
369
|
+
return undefined
|
|
370
|
+
}
|
|
371
|
+
const sessionInfo = event.properties?.info
|
|
372
|
+
if (!sessionInfo || typeof sessionInfo !== 'object') {
|
|
373
|
+
return undefined
|
|
374
|
+
}
|
|
375
|
+
const id = 'id' in sessionInfo ? sessionInfo.id : undefined
|
|
376
|
+
return typeof id === 'string' ? id : undefined
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export { systemPromptDriftPlugin }
|
package/src/utils.ts
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
// abort error detection, and date/time formatting helpers.
|
|
4
4
|
|
|
5
5
|
import os from 'node:os'
|
|
6
|
-
import
|
|
6
|
+
// Use namespace import for CJS interop — discord.js is CJS and its named
|
|
7
|
+
// exports aren't detectable by all ESM loaders (e.g. tsx/esbuild) because
|
|
8
|
+
// discord.js uses tslib's __exportStar which is opaque to static analysis.
|
|
9
|
+
import * as discord from 'discord.js'
|
|
10
|
+
const { PermissionsBitField } = discord
|
|
7
11
|
import type { BotMode } from './database.js'
|
|
8
12
|
import * as errore from 'errore'
|
|
9
13
|
|