kimaki 0.4.78 → 0.4.80
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 +628 -0
- package/dist/channel-management.js +2 -2
- package/dist/cli.js +316 -129
- package/dist/commands/action-buttons.js +1 -1
- package/dist/commands/login.js +634 -277
- package/dist/commands/model.js +91 -6
- package/dist/commands/paginated-select.js +57 -0
- package/dist/commands/resume.js +2 -2
- package/dist/commands/tasks.js +205 -0
- package/dist/commands/undo-redo.js +80 -18
- package/dist/context-awareness-plugin.js +347 -0
- package/dist/database.js +103 -7
- package/dist/db.js +39 -1
- package/dist/discord-bot.js +42 -19
- package/dist/discord-urls.js +11 -0
- package/dist/discord-ws-proxy.js +350 -0
- package/dist/discord-ws-proxy.test.js +500 -0
- package/dist/errors.js +1 -1
- package/dist/gateway-session.js +163 -0
- package/dist/hrana-server.js +114 -4
- package/dist/interaction-handler.js +30 -7
- package/dist/ipc-tools-plugin.js +186 -0
- package/dist/message-preprocessing.js +56 -11
- package/dist/onboarding-welcome.js +1 -1
- package/dist/opencode-interrupt-plugin.js +133 -75
- package/dist/opencode-plugin.js +12 -389
- package/dist/opencode.js +59 -5
- package/dist/parse-permission-rules.test.js +117 -0
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
- package/dist/session-handler/thread-session-runtime.js +68 -29
- package/dist/startup-time.e2e.test.js +295 -0
- package/dist/store.js +1 -0
- package/dist/system-message.js +3 -1
- package/dist/task-runner.js +7 -3
- package/dist/task-schedule.js +12 -0
- package/dist/thread-message-queue.e2e.test.js +13 -1
- package/dist/undo-redo.e2e.test.js +166 -0
- package/dist/utils.js +4 -1
- package/dist/voice-attachment.js +34 -0
- package/dist/voice-handler.js +11 -9
- package/dist/voice-message.e2e.test.js +78 -0
- package/dist/voice.test.js +31 -0
- package/package.json +12 -7
- package/skills/egaki/SKILL.md +80 -15
- package/skills/errore/SKILL.md +13 -0
- package/skills/lintcn/SKILL.md +749 -0
- package/skills/npm-package/SKILL.md +17 -3
- package/skills/spiceflow/SKILL.md +14 -0
- package/skills/zele/SKILL.md +9 -0
- package/src/anthropic-auth-plugin.ts +732 -0
- package/src/channel-management.ts +2 -2
- package/src/cli.ts +354 -132
- package/src/commands/action-buttons.ts +1 -0
- package/src/commands/login.ts +836 -337
- package/src/commands/model.ts +102 -7
- package/src/commands/paginated-select.ts +81 -0
- package/src/commands/resume.ts +6 -1
- package/src/commands/tasks.ts +293 -0
- package/src/commands/undo-redo.ts +87 -20
- package/src/context-awareness-plugin.ts +469 -0
- package/src/database.ts +138 -7
- package/src/db.ts +40 -1
- package/src/discord-bot.ts +46 -19
- package/src/discord-urls.ts +12 -0
- package/src/errors.ts +1 -1
- package/src/hrana-server.ts +124 -3
- package/src/interaction-handler.ts +41 -9
- package/src/ipc-tools-plugin.ts +228 -0
- package/src/message-preprocessing.ts +82 -11
- package/src/onboarding-welcome.ts +1 -1
- package/src/opencode-interrupt-plugin.ts +164 -91
- package/src/opencode-plugin.ts +13 -483
- package/src/opencode.ts +60 -5
- package/src/parse-permission-rules.test.ts +127 -0
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
- package/src/session-handler/thread-runtime-state.ts +4 -1
- package/src/session-handler/thread-session-runtime.ts +82 -20
- package/src/startup-time.e2e.test.ts +372 -0
- package/src/store.ts +8 -0
- package/src/system-message.ts +10 -1
- package/src/task-runner.ts +9 -22
- package/src/task-schedule.ts +15 -0
- package/src/thread-message-queue.e2e.test.ts +14 -1
- package/src/undo-redo.e2e.test.ts +207 -0
- package/src/utils.ts +7 -0
- package/src/voice-attachment.ts +51 -0
- package/src/voice-handler.ts +15 -7
- package/src/voice-message.e2e.test.ts +95 -0
- package/src/voice.test.ts +36 -0
- package/src/onboarding-tutorial-plugin.ts +0 -93
|
@@ -78,29 +78,55 @@ export async function handleUndoCommand({
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
const client = getClient()
|
|
82
|
+
|
|
83
|
+
// Fetch session to check existing revert state
|
|
84
|
+
const sessionResponse = await client.session.get({
|
|
83
85
|
sessionID: sessionId,
|
|
84
86
|
})
|
|
87
|
+
if (sessionResponse.error) {
|
|
88
|
+
await command.editReply(`Failed to undo: ${JSON.stringify(sessionResponse.error)}`)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const messagesResponse = await client.session.messages({
|
|
93
|
+
sessionID: sessionId,
|
|
94
|
+
})
|
|
95
|
+
if (messagesResponse.error) {
|
|
96
|
+
await command.editReply(`Failed to undo: ${JSON.stringify(messagesResponse.error)}`)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
85
99
|
|
|
86
100
|
if (!messagesResponse.data || messagesResponse.data.length === 0) {
|
|
87
101
|
await command.editReply('No messages to undo')
|
|
88
102
|
return
|
|
89
103
|
}
|
|
90
104
|
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
105
|
+
// Follow the same approach as the OpenCode TUI (use-session-commands.tsx):
|
|
106
|
+
// find the last user message that is before the current revert point
|
|
107
|
+
// (or the last user message if no revert is active). This matches the
|
|
108
|
+
// TUI's `findLast(userMessages(), (x) => !revert || x.id < revert)`.
|
|
109
|
+
const currentRevert = sessionResponse.data?.revert?.messageID
|
|
110
|
+
const userMessages = messagesResponse.data.filter((m) => {
|
|
111
|
+
return m.info.role === 'user'
|
|
112
|
+
})
|
|
113
|
+
const targetUserMessage = [...userMessages].reverse().find((m) => {
|
|
114
|
+
return !currentRevert || m.info.id < currentRevert
|
|
115
|
+
})
|
|
95
116
|
|
|
96
|
-
if (!
|
|
97
|
-
await command.editReply('No
|
|
117
|
+
if (!targetUserMessage) {
|
|
118
|
+
await command.editReply('No messages to undo')
|
|
98
119
|
return
|
|
99
120
|
}
|
|
100
121
|
|
|
101
|
-
|
|
122
|
+
// session.revert() reverts filesystem patches (file edits, writes) and
|
|
123
|
+
// marks the session with revert.messageID. Messages are NOT deleted — they
|
|
124
|
+
// get cleaned up automatically on the next promptAsync() call via
|
|
125
|
+
// SessionRevert.cleanup(). The model only sees messages before the revert
|
|
126
|
+
// point when processing the next prompt.
|
|
127
|
+
const response = await client.session.revert({
|
|
102
128
|
sessionID: sessionId,
|
|
103
|
-
messageID:
|
|
129
|
+
messageID: targetUserMessage.info.id,
|
|
104
130
|
})
|
|
105
131
|
|
|
106
132
|
if (response.error) {
|
|
@@ -114,11 +140,9 @@ export async function handleUndoCommand({
|
|
|
114
140
|
? `\n\`\`\`diff\n${response.data.revert.diff.slice(0, 1500)}\n\`\`\``
|
|
115
141
|
: ''
|
|
116
142
|
|
|
117
|
-
await command.editReply(
|
|
118
|
-
`⏪ **Undone** - reverted last assistant message${diffInfo}`,
|
|
119
|
-
)
|
|
143
|
+
await command.editReply(`Undone - reverted last assistant message${diffInfo}`)
|
|
120
144
|
logger.log(
|
|
121
|
-
`Session ${sessionId} reverted message ${
|
|
145
|
+
`Session ${sessionId} reverted to before user message ${targetUserMessage.info.id}`,
|
|
122
146
|
)
|
|
123
147
|
} catch (error) {
|
|
124
148
|
logger.error('[UNDO] Error:', error)
|
|
@@ -189,18 +213,61 @@ export async function handleRedoCommand({
|
|
|
189
213
|
}
|
|
190
214
|
|
|
191
215
|
try {
|
|
192
|
-
|
|
193
|
-
|
|
216
|
+
const client = getClient()
|
|
217
|
+
|
|
218
|
+
// Fetch session to check existing revert state
|
|
219
|
+
const sessionResponse = await client.session.get({
|
|
194
220
|
sessionID: sessionId,
|
|
195
221
|
})
|
|
222
|
+
if (sessionResponse.error) {
|
|
223
|
+
await command.editReply(`Failed to redo: ${JSON.stringify(sessionResponse.error)}`)
|
|
224
|
+
return
|
|
225
|
+
}
|
|
196
226
|
|
|
197
|
-
|
|
227
|
+
const revertMessageID = sessionResponse.data?.revert?.messageID
|
|
228
|
+
if (!revertMessageID) {
|
|
198
229
|
await command.editReply('Nothing to redo - no previous undo found')
|
|
199
230
|
return
|
|
200
231
|
}
|
|
201
232
|
|
|
202
|
-
|
|
233
|
+
// Follow the same approach as the OpenCode TUI (use-session-commands.tsx):
|
|
234
|
+
// find the next user message after the current revert point. If one exists,
|
|
235
|
+
// move the revert cursor forward to it (one step redo). If none exists,
|
|
236
|
+
// fully unrevert — we're at the end of the message history.
|
|
237
|
+
const messagesResponse = await client.session.messages({
|
|
238
|
+
sessionID: sessionId,
|
|
239
|
+
})
|
|
240
|
+
if (messagesResponse.error) {
|
|
241
|
+
await command.editReply(`Failed to redo: ${JSON.stringify(messagesResponse.error)}`)
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
const userMessages = (messagesResponse.data ?? []).filter((m) => {
|
|
245
|
+
return m.info.role === 'user'
|
|
246
|
+
})
|
|
247
|
+
const nextMessage = userMessages.find((m) => {
|
|
248
|
+
return m.info.id > revertMessageID
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
if (!nextMessage) {
|
|
252
|
+
// No more messages after revert point — fully unrevert
|
|
253
|
+
const response = await client.session.unrevert({
|
|
254
|
+
sessionID: sessionId,
|
|
255
|
+
})
|
|
256
|
+
if (response.error) {
|
|
257
|
+
await command.editReply(
|
|
258
|
+
`Failed to redo: ${JSON.stringify(response.error)}`,
|
|
259
|
+
)
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
await command.editReply('Restored - session fully back to previous state')
|
|
263
|
+
logger.log(`Session ${sessionId} unrevert completed`)
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Move revert cursor forward one step to the next user message
|
|
268
|
+
const response = await client.session.revert({
|
|
203
269
|
sessionID: sessionId,
|
|
270
|
+
messageID: nextMessage.info.id,
|
|
204
271
|
})
|
|
205
272
|
|
|
206
273
|
if (response.error) {
|
|
@@ -210,8 +277,8 @@ export async function handleRedoCommand({
|
|
|
210
277
|
return
|
|
211
278
|
}
|
|
212
279
|
|
|
213
|
-
await command.editReply(
|
|
214
|
-
logger.log(`Session ${sessionId}
|
|
280
|
+
await command.editReply('Restored one step forward')
|
|
281
|
+
logger.log(`Session ${sessionId} redo: moved revert to ${nextMessage.info.id}`)
|
|
215
282
|
} catch (error) {
|
|
216
283
|
logger.error('[REDO] Error:', error)
|
|
217
284
|
await command.editReply(
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
// OpenCode plugin that injects synthetic message parts for context awareness:
|
|
2
|
+
// - Git branch / detached HEAD changes
|
|
3
|
+
// - Working directory (pwd) changes (e.g. after /new-worktree mid-session)
|
|
4
|
+
// - MEMORY.md table of contents on first message
|
|
5
|
+
// - Idle time gap detection with timestamps
|
|
6
|
+
// - Onboarding tutorial instructions (when TUTORIAL_WELCOME_TEXT detected)
|
|
7
|
+
//
|
|
8
|
+
// Synthetic parts are hidden from the TUI but sent to the model, keeping it
|
|
9
|
+
// aware of context changes without cluttering the UI.
|
|
10
|
+
//
|
|
11
|
+
// State design: all per-session mutable state is encapsulated in a single
|
|
12
|
+
// SessionState object per session ID. One Map, one delete() on cleanup.
|
|
13
|
+
// Decision logic is extracted into pure functions that take state + input
|
|
14
|
+
// and return whether to inject — making them testable without mocking.
|
|
15
|
+
//
|
|
16
|
+
// Exported from opencode-plugin.ts — each export is treated as a separate
|
|
17
|
+
// plugin by OpenCode's plugin loader.
|
|
18
|
+
|
|
19
|
+
import type { Plugin } from '@opencode-ai/plugin'
|
|
20
|
+
import crypto from 'node:crypto'
|
|
21
|
+
import fs from 'node:fs'
|
|
22
|
+
import path from 'node:path'
|
|
23
|
+
import * as errore from 'errore'
|
|
24
|
+
import {
|
|
25
|
+
createLogger,
|
|
26
|
+
formatErrorWithStack,
|
|
27
|
+
LogPrefix,
|
|
28
|
+
setLogFilePath,
|
|
29
|
+
} from './logger.js'
|
|
30
|
+
import { setDataDir } from './config.js'
|
|
31
|
+
import { initSentry, notifyError } from './sentry.js'
|
|
32
|
+
import { execAsync } from './worktrees.js'
|
|
33
|
+
import { condenseMemoryMd } from './condense-memory.js'
|
|
34
|
+
import {
|
|
35
|
+
ONBOARDING_TUTORIAL_INSTRUCTIONS,
|
|
36
|
+
TUTORIAL_WELCOME_TEXT,
|
|
37
|
+
} from './onboarding-tutorial.js'
|
|
38
|
+
|
|
39
|
+
const logger = createLogger(LogPrefix.OPENCODE)
|
|
40
|
+
|
|
41
|
+
// ── Types ────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
type GitState = {
|
|
44
|
+
key: string
|
|
45
|
+
kind: 'branch' | 'detached-head' | 'detached-submodule'
|
|
46
|
+
label: string
|
|
47
|
+
warning: string | null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// All per-session mutable state in one place. One Map entry, one delete.
|
|
51
|
+
type SessionState = {
|
|
52
|
+
gitState: GitState | undefined
|
|
53
|
+
lastMessageTime: number | undefined
|
|
54
|
+
memoryInjected: boolean
|
|
55
|
+
tutorialInjected: boolean
|
|
56
|
+
// Cached session directory from session.get() (avoids repeated HTTP calls).
|
|
57
|
+
resolvedDirectory: string | undefined
|
|
58
|
+
// Last directory we announced via pwd injection. Separate from
|
|
59
|
+
// resolvedDirectory because the cache is populated before comparison —
|
|
60
|
+
// using the same field for both would skip injection on first message.
|
|
61
|
+
announcedDirectory: string | undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createSessionState(): SessionState {
|
|
65
|
+
return {
|
|
66
|
+
gitState: undefined,
|
|
67
|
+
lastMessageTime: undefined,
|
|
68
|
+
memoryInjected: false,
|
|
69
|
+
tutorialInjected: false,
|
|
70
|
+
resolvedDirectory: undefined,
|
|
71
|
+
announcedDirectory: undefined,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Minimal type for the opencode plugin client (v1 SDK style with path objects).
|
|
76
|
+
type PluginClient = {
|
|
77
|
+
session: {
|
|
78
|
+
get: (params: { path: { id: string } }) => Promise<{ data?: { directory?: string } }>
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Pure derivation functions ────────────────────────────────────
|
|
83
|
+
// These take state + fresh input and return whether to inject.
|
|
84
|
+
// No side effects, no mutations — easy to test with fixtures.
|
|
85
|
+
|
|
86
|
+
export function shouldInjectBranch({
|
|
87
|
+
previousGitState,
|
|
88
|
+
currentGitState,
|
|
89
|
+
}: {
|
|
90
|
+
previousGitState: GitState | undefined
|
|
91
|
+
currentGitState: GitState | null
|
|
92
|
+
}): { inject: false } | { inject: true; text: string } {
|
|
93
|
+
if (!currentGitState) {
|
|
94
|
+
return { inject: false }
|
|
95
|
+
}
|
|
96
|
+
if (previousGitState && previousGitState.key === currentGitState.key) {
|
|
97
|
+
return { inject: false }
|
|
98
|
+
}
|
|
99
|
+
const text = currentGitState.warning || `\n[current git branch is ${currentGitState.label}]`
|
|
100
|
+
return { inject: true, text }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function shouldInjectPwd({
|
|
104
|
+
sessionDir,
|
|
105
|
+
projectDir,
|
|
106
|
+
announcedDir,
|
|
107
|
+
}: {
|
|
108
|
+
sessionDir: string | null
|
|
109
|
+
projectDir: string
|
|
110
|
+
announcedDir: string | undefined
|
|
111
|
+
}): { inject: false } | { inject: true; text: string } {
|
|
112
|
+
if (!sessionDir || sessionDir === projectDir) {
|
|
113
|
+
return { inject: false }
|
|
114
|
+
}
|
|
115
|
+
if (announcedDir === sessionDir) {
|
|
116
|
+
return { inject: false }
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
inject: true,
|
|
120
|
+
text:
|
|
121
|
+
`\n[working directory is ${sessionDir} (git worktree of ${projectDir}). ` +
|
|
122
|
+
`All file reads, writes, and edits must use paths under ${sessionDir}, ` +
|
|
123
|
+
`not ${projectDir}.]`,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const TEN_MINUTES = 10 * 60 * 1000
|
|
128
|
+
|
|
129
|
+
export function shouldInjectTimeGap({
|
|
130
|
+
lastMessageTime,
|
|
131
|
+
now,
|
|
132
|
+
}: {
|
|
133
|
+
lastMessageTime: number | undefined
|
|
134
|
+
now: number
|
|
135
|
+
}): { inject: false } | { inject: true; elapsedStr: string; utcStr: string; localStr: string; localTz: string } {
|
|
136
|
+
if (!lastMessageTime) {
|
|
137
|
+
return { inject: false }
|
|
138
|
+
}
|
|
139
|
+
const elapsed = now - lastMessageTime
|
|
140
|
+
if (elapsed < TEN_MINUTES) {
|
|
141
|
+
return { inject: false }
|
|
142
|
+
}
|
|
143
|
+
const totalMinutes = Math.floor(elapsed / 60_000)
|
|
144
|
+
const hours = Math.floor(totalMinutes / 60)
|
|
145
|
+
const minutes = totalMinutes % 60
|
|
146
|
+
const elapsedStr = hours > 0 ? `${hours}h ${minutes}m` : `${totalMinutes}m`
|
|
147
|
+
|
|
148
|
+
const utcStr = new Date(now)
|
|
149
|
+
.toISOString()
|
|
150
|
+
.replace('T', ' ')
|
|
151
|
+
.replace(/\.\d+Z$/, ' UTC')
|
|
152
|
+
const localTz = Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
153
|
+
const localStr = new Date(now).toLocaleString('en-US', {
|
|
154
|
+
timeZone: localTz,
|
|
155
|
+
year: 'numeric',
|
|
156
|
+
month: '2-digit',
|
|
157
|
+
day: '2-digit',
|
|
158
|
+
hour: '2-digit',
|
|
159
|
+
minute: '2-digit',
|
|
160
|
+
hour12: false,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
return { inject: true, elapsedStr, utcStr, localStr, localTz }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function shouldInjectTutorial({
|
|
167
|
+
alreadyInjected,
|
|
168
|
+
parts,
|
|
169
|
+
}: {
|
|
170
|
+
alreadyInjected: boolean
|
|
171
|
+
parts: Array<{ type: string; text?: string }>
|
|
172
|
+
}): boolean {
|
|
173
|
+
if (alreadyInjected) {
|
|
174
|
+
return false
|
|
175
|
+
}
|
|
176
|
+
return parts.some((part) => {
|
|
177
|
+
return part.type === 'text' && part.text?.includes(TUTORIAL_WELCOME_TEXT)
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Impure helpers (I/O) ─────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
async function resolveGitState({
|
|
184
|
+
directory,
|
|
185
|
+
}: {
|
|
186
|
+
directory: string
|
|
187
|
+
}): Promise<GitState | null> {
|
|
188
|
+
const branchResult = await errore.tryAsync(() => {
|
|
189
|
+
return execAsync('git symbolic-ref --short HEAD', { cwd: directory })
|
|
190
|
+
})
|
|
191
|
+
if (!(branchResult instanceof Error)) {
|
|
192
|
+
const branch = branchResult.stdout.trim()
|
|
193
|
+
if (branch) {
|
|
194
|
+
return {
|
|
195
|
+
key: `branch:${branch}`,
|
|
196
|
+
kind: 'branch',
|
|
197
|
+
label: branch,
|
|
198
|
+
warning: null,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const shaResult = await errore.tryAsync(() => {
|
|
204
|
+
return execAsync('git rev-parse --short HEAD', { cwd: directory })
|
|
205
|
+
})
|
|
206
|
+
if (shaResult instanceof Error) {
|
|
207
|
+
return null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const shortSha = shaResult.stdout.trim()
|
|
211
|
+
if (!shortSha) {
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const superprojectResult = await errore.tryAsync(() => {
|
|
216
|
+
return execAsync('git rev-parse --show-superproject-working-tree', {
|
|
217
|
+
cwd: directory,
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
const superproject =
|
|
221
|
+
superprojectResult instanceof Error ? '' : superprojectResult.stdout.trim()
|
|
222
|
+
if (superproject) {
|
|
223
|
+
return {
|
|
224
|
+
key: `detached-submodule:${shortSha}`,
|
|
225
|
+
kind: 'detached-submodule',
|
|
226
|
+
label: `detached submodule @ ${shortSha}`,
|
|
227
|
+
warning:
|
|
228
|
+
`\n[warning: submodule is in detached HEAD at ${shortSha}. ` +
|
|
229
|
+
'create or switch to a branch before committing.]',
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
key: `detached-head:${shortSha}`,
|
|
235
|
+
kind: 'detached-head',
|
|
236
|
+
label: `detached HEAD @ ${shortSha}`,
|
|
237
|
+
warning:
|
|
238
|
+
`\n[warning: repository is in detached HEAD at ${shortSha}. ` +
|
|
239
|
+
'create or switch to a branch before committing.]',
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Resolve the session's actual working directory via the SDK.
|
|
244
|
+
// Cached in SessionState.resolvedDirectory to avoid repeated HTTP calls.
|
|
245
|
+
async function resolveSessionDirectory({
|
|
246
|
+
client,
|
|
247
|
+
sessionID,
|
|
248
|
+
state,
|
|
249
|
+
}: {
|
|
250
|
+
client: PluginClient
|
|
251
|
+
sessionID: string
|
|
252
|
+
state: SessionState
|
|
253
|
+
}): Promise<string | null> {
|
|
254
|
+
if (state.resolvedDirectory) {
|
|
255
|
+
return state.resolvedDirectory
|
|
256
|
+
}
|
|
257
|
+
const result = await errore.tryAsync(() => {
|
|
258
|
+
return client.session.get({ path: { id: sessionID } })
|
|
259
|
+
})
|
|
260
|
+
if (result instanceof Error || !result.data?.directory) {
|
|
261
|
+
return null
|
|
262
|
+
}
|
|
263
|
+
state.resolvedDirectory = result.data.directory
|
|
264
|
+
return result.data.directory
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Plugin ───────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
const contextAwarenessPlugin: Plugin = async ({ directory, client }) => {
|
|
270
|
+
initSentry()
|
|
271
|
+
|
|
272
|
+
const dataDir = process.env.KIMAKI_DATA_DIR
|
|
273
|
+
if (dataDir) {
|
|
274
|
+
setDataDir(dataDir)
|
|
275
|
+
setLogFilePath(dataDir)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Single Map for all per-session state. One entry per session, one
|
|
279
|
+
// delete on cleanup — no parallel Maps that can drift out of sync.
|
|
280
|
+
const sessions = new Map<string, SessionState>()
|
|
281
|
+
|
|
282
|
+
function getOrCreateSession(sessionID: string): SessionState {
|
|
283
|
+
const existing = sessions.get(sessionID)
|
|
284
|
+
if (existing) {
|
|
285
|
+
return existing
|
|
286
|
+
}
|
|
287
|
+
const state = createSessionState()
|
|
288
|
+
sessions.set(sessionID, state)
|
|
289
|
+
return state
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
'chat.message': async (input, output) => {
|
|
294
|
+
const hookResult = await errore.tryAsync({
|
|
295
|
+
try: async () => {
|
|
296
|
+
const { sessionID } = input
|
|
297
|
+
const state = getOrCreateSession(sessionID)
|
|
298
|
+
|
|
299
|
+
// -- Onboarding tutorial injection --
|
|
300
|
+
// Runs before the non-synthetic text guard because the tutorial
|
|
301
|
+
// marker (TUTORIAL_WELCOME_TEXT) can appear in synthetic/system
|
|
302
|
+
// parts prepended by message-preprocessing.ts. The old separate
|
|
303
|
+
// plugin had no such guard, so this preserves that behavior.
|
|
304
|
+
const firstTextPart = output.parts.find((part) => {
|
|
305
|
+
return part.type === 'text'
|
|
306
|
+
})
|
|
307
|
+
if (firstTextPart && shouldInjectTutorial({ alreadyInjected: state.tutorialInjected, parts: output.parts })) {
|
|
308
|
+
state.tutorialInjected = true
|
|
309
|
+
output.parts.push({
|
|
310
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
311
|
+
sessionID,
|
|
312
|
+
messageID: firstTextPart.messageID,
|
|
313
|
+
type: 'text' as const,
|
|
314
|
+
text: `<system-reminder>\n${ONBOARDING_TUTORIAL_INSTRUCTIONS}\n</system-reminder>`,
|
|
315
|
+
synthetic: true,
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// -- Find first non-synthetic user text part --
|
|
320
|
+
// All remaining injections (branch, pwd, memory, time gap) only
|
|
321
|
+
// apply to real user messages, not empty or synthetic-only messages.
|
|
322
|
+
const now = Date.now()
|
|
323
|
+
const first = output.parts.find((part) => {
|
|
324
|
+
if (part.type !== 'text') {
|
|
325
|
+
return true
|
|
326
|
+
}
|
|
327
|
+
return part.synthetic !== true
|
|
328
|
+
})
|
|
329
|
+
if (!first || first.type !== 'text' || first.text.trim().length === 0) {
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const messageID = first.messageID
|
|
334
|
+
|
|
335
|
+
// -- Resolve session working directory --
|
|
336
|
+
const sessionDir = await resolveSessionDirectory({
|
|
337
|
+
client,
|
|
338
|
+
sessionID,
|
|
339
|
+
state,
|
|
340
|
+
})
|
|
341
|
+
const effectiveDirectory = sessionDir || directory
|
|
342
|
+
|
|
343
|
+
// -- Branch / detached HEAD detection --
|
|
344
|
+
// Resolved early but injected last so it appears at the end of parts.
|
|
345
|
+
const gitState = await resolveGitState({ directory: effectiveDirectory })
|
|
346
|
+
|
|
347
|
+
// -- Working directory change detection --
|
|
348
|
+
const pwdResult = shouldInjectPwd({
|
|
349
|
+
sessionDir,
|
|
350
|
+
projectDir: directory,
|
|
351
|
+
announcedDir: state.announcedDirectory,
|
|
352
|
+
})
|
|
353
|
+
if (pwdResult.inject) {
|
|
354
|
+
state.announcedDirectory = sessionDir!
|
|
355
|
+
output.parts.push({
|
|
356
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
357
|
+
sessionID,
|
|
358
|
+
messageID,
|
|
359
|
+
type: 'text' as const,
|
|
360
|
+
text: pwdResult.text,
|
|
361
|
+
synthetic: true,
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// -- MEMORY.md injection --
|
|
366
|
+
if (!state.memoryInjected) {
|
|
367
|
+
state.memoryInjected = true
|
|
368
|
+
const memoryPath = path.join(effectiveDirectory, 'MEMORY.md')
|
|
369
|
+
const memoryContent = await fs.promises
|
|
370
|
+
.readFile(memoryPath, 'utf-8')
|
|
371
|
+
.catch(() => null)
|
|
372
|
+
if (memoryContent) {
|
|
373
|
+
const condensed = condenseMemoryMd(memoryContent)
|
|
374
|
+
output.parts.push({
|
|
375
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
376
|
+
sessionID,
|
|
377
|
+
messageID,
|
|
378
|
+
type: 'text' as const,
|
|
379
|
+
text: `<system-reminder>Project memory from MEMORY.md (condensed table of contents, line numbers shown):\n${condensed}\nOnly headings are shown above — section bodies are hidden. Use Grep to search MEMORY.md for specific topics, or Read with offset and limit to read a section's content. When writing to MEMORY.md, make headings detailed and descriptive since they are the only thing visible in this prompt. You can update MEMORY.md to store learnings, tips, insights that will help prevent same mistakes, and context worth preserving across sessions.</system-reminder>`,
|
|
380
|
+
synthetic: true,
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// -- Time since last message --
|
|
386
|
+
const timeGapResult = shouldInjectTimeGap({
|
|
387
|
+
lastMessageTime: state.lastMessageTime,
|
|
388
|
+
now,
|
|
389
|
+
})
|
|
390
|
+
state.lastMessageTime = now
|
|
391
|
+
|
|
392
|
+
if (timeGapResult.inject) {
|
|
393
|
+
output.parts.push({
|
|
394
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
395
|
+
sessionID,
|
|
396
|
+
messageID,
|
|
397
|
+
type: 'text' as const,
|
|
398
|
+
text: `[${timeGapResult.elapsedStr} since last message | UTC: ${timeGapResult.utcStr} | Local (${timeGapResult.localTz}): ${timeGapResult.localStr}]`,
|
|
399
|
+
synthetic: true,
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
output.parts.push({
|
|
403
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
404
|
+
sessionID,
|
|
405
|
+
messageID,
|
|
406
|
+
type: 'text' as const,
|
|
407
|
+
text: '<system-reminder>Long gap since last message. If the previous conversation had important learnings, tips, insights that will help prevent same mistakes, or context worth preserving, update MEMORY.md before starting the new task.</system-reminder>',
|
|
408
|
+
synthetic: true,
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// -- Branch injection (last synthetic part) --
|
|
413
|
+
const branchResult = shouldInjectBranch({
|
|
414
|
+
previousGitState: state.gitState,
|
|
415
|
+
currentGitState: gitState,
|
|
416
|
+
})
|
|
417
|
+
if (branchResult.inject) {
|
|
418
|
+
state.gitState = gitState!
|
|
419
|
+
output.parts.push({
|
|
420
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
421
|
+
sessionID,
|
|
422
|
+
messageID,
|
|
423
|
+
type: 'text' as const,
|
|
424
|
+
text: branchResult.text,
|
|
425
|
+
synthetic: true,
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
catch: (error) => {
|
|
430
|
+
return new Error('context-awareness chat.message hook failed', { cause: error })
|
|
431
|
+
},
|
|
432
|
+
})
|
|
433
|
+
if (hookResult instanceof Error) {
|
|
434
|
+
logger.warn(
|
|
435
|
+
`[context-awareness-plugin] ${formatErrorWithStack(hookResult)}`,
|
|
436
|
+
)
|
|
437
|
+
void notifyError(hookResult, 'context-awareness plugin chat.message hook failed')
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
// Clean up per-session state when sessions are deleted.
|
|
442
|
+
// Single delete instead of 5 parallel Map/Set deletes.
|
|
443
|
+
event: async ({ event }) => {
|
|
444
|
+
const cleanupResult = await errore.tryAsync({
|
|
445
|
+
try: async () => {
|
|
446
|
+
if (event.type !== 'session.deleted') {
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
const id = event.properties?.info?.id
|
|
450
|
+
if (!id) {
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
sessions.delete(id)
|
|
454
|
+
},
|
|
455
|
+
catch: (error) => {
|
|
456
|
+
return new Error('context-awareness event hook failed', { cause: error })
|
|
457
|
+
},
|
|
458
|
+
})
|
|
459
|
+
if (cleanupResult instanceof Error) {
|
|
460
|
+
logger.warn(
|
|
461
|
+
`[context-awareness-plugin] ${formatErrorWithStack(cleanupResult)}`,
|
|
462
|
+
)
|
|
463
|
+
void notifyError(cleanupResult, 'context-awareness plugin event hook failed')
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export { contextAwarenessPlugin }
|