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.
Files changed (90) hide show
  1. package/dist/anthropic-auth-plugin.js +628 -0
  2. package/dist/channel-management.js +2 -2
  3. package/dist/cli.js +316 -129
  4. package/dist/commands/action-buttons.js +1 -1
  5. package/dist/commands/login.js +634 -277
  6. package/dist/commands/model.js +91 -6
  7. package/dist/commands/paginated-select.js +57 -0
  8. package/dist/commands/resume.js +2 -2
  9. package/dist/commands/tasks.js +205 -0
  10. package/dist/commands/undo-redo.js +80 -18
  11. package/dist/context-awareness-plugin.js +347 -0
  12. package/dist/database.js +103 -7
  13. package/dist/db.js +39 -1
  14. package/dist/discord-bot.js +42 -19
  15. package/dist/discord-urls.js +11 -0
  16. package/dist/discord-ws-proxy.js +350 -0
  17. package/dist/discord-ws-proxy.test.js +500 -0
  18. package/dist/errors.js +1 -1
  19. package/dist/gateway-session.js +163 -0
  20. package/dist/hrana-server.js +114 -4
  21. package/dist/interaction-handler.js +30 -7
  22. package/dist/ipc-tools-plugin.js +186 -0
  23. package/dist/message-preprocessing.js +56 -11
  24. package/dist/onboarding-welcome.js +1 -1
  25. package/dist/opencode-interrupt-plugin.js +133 -75
  26. package/dist/opencode-plugin.js +12 -389
  27. package/dist/opencode.js +59 -5
  28. package/dist/parse-permission-rules.test.js +117 -0
  29. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  30. package/dist/session-handler/thread-session-runtime.js +68 -29
  31. package/dist/startup-time.e2e.test.js +295 -0
  32. package/dist/store.js +1 -0
  33. package/dist/system-message.js +3 -1
  34. package/dist/task-runner.js +7 -3
  35. package/dist/task-schedule.js +12 -0
  36. package/dist/thread-message-queue.e2e.test.js +13 -1
  37. package/dist/undo-redo.e2e.test.js +166 -0
  38. package/dist/utils.js +4 -1
  39. package/dist/voice-attachment.js +34 -0
  40. package/dist/voice-handler.js +11 -9
  41. package/dist/voice-message.e2e.test.js +78 -0
  42. package/dist/voice.test.js +31 -0
  43. package/package.json +12 -7
  44. package/skills/egaki/SKILL.md +80 -15
  45. package/skills/errore/SKILL.md +13 -0
  46. package/skills/lintcn/SKILL.md +749 -0
  47. package/skills/npm-package/SKILL.md +17 -3
  48. package/skills/spiceflow/SKILL.md +14 -0
  49. package/skills/zele/SKILL.md +9 -0
  50. package/src/anthropic-auth-plugin.ts +732 -0
  51. package/src/channel-management.ts +2 -2
  52. package/src/cli.ts +354 -132
  53. package/src/commands/action-buttons.ts +1 -0
  54. package/src/commands/login.ts +836 -337
  55. package/src/commands/model.ts +102 -7
  56. package/src/commands/paginated-select.ts +81 -0
  57. package/src/commands/resume.ts +6 -1
  58. package/src/commands/tasks.ts +293 -0
  59. package/src/commands/undo-redo.ts +87 -20
  60. package/src/context-awareness-plugin.ts +469 -0
  61. package/src/database.ts +138 -7
  62. package/src/db.ts +40 -1
  63. package/src/discord-bot.ts +46 -19
  64. package/src/discord-urls.ts +12 -0
  65. package/src/errors.ts +1 -1
  66. package/src/hrana-server.ts +124 -3
  67. package/src/interaction-handler.ts +41 -9
  68. package/src/ipc-tools-plugin.ts +228 -0
  69. package/src/message-preprocessing.ts +82 -11
  70. package/src/onboarding-welcome.ts +1 -1
  71. package/src/opencode-interrupt-plugin.ts +164 -91
  72. package/src/opencode-plugin.ts +13 -483
  73. package/src/opencode.ts +60 -5
  74. package/src/parse-permission-rules.test.ts +127 -0
  75. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  76. package/src/session-handler/thread-runtime-state.ts +4 -1
  77. package/src/session-handler/thread-session-runtime.ts +82 -20
  78. package/src/startup-time.e2e.test.ts +372 -0
  79. package/src/store.ts +8 -0
  80. package/src/system-message.ts +10 -1
  81. package/src/task-runner.ts +9 -22
  82. package/src/task-schedule.ts +15 -0
  83. package/src/thread-message-queue.e2e.test.ts +14 -1
  84. package/src/undo-redo.e2e.test.ts +207 -0
  85. package/src/utils.ts +7 -0
  86. package/src/voice-attachment.ts +51 -0
  87. package/src/voice-handler.ts +15 -7
  88. package/src/voice-message.e2e.test.ts +95 -0
  89. package/src/voice.test.ts +36 -0
  90. package/src/onboarding-tutorial-plugin.ts +0 -93
@@ -78,29 +78,55 @@ export async function handleUndoCommand({
78
78
  }
79
79
 
80
80
  try {
81
- // Fetch messages to find the last assistant message
82
- const messagesResponse = await getClient().session.messages({
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
- // Find the last assistant message
92
- const lastAssistantMessage = [...messagesResponse.data]
93
- .reverse()
94
- .find((m) => m.info.role === 'assistant')
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 (!lastAssistantMessage) {
97
- await command.editReply('No assistant message to undo')
117
+ if (!targetUserMessage) {
118
+ await command.editReply('No messages to undo')
98
119
  return
99
120
  }
100
121
 
101
- const response = await getClient().session.revert({
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: lastAssistantMessage.info.id,
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 ${lastAssistantMessage.info.id}`,
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
- // Check if session has reverted state
193
- const sessionResponse = await getClient().session.get({
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
- if (!sessionResponse.data?.revert) {
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
- const response = await getClient().session.unrevert({
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(`⏩ **Restored** - session back to previous state`)
214
- logger.log(`Session ${sessionId} unrevert completed`)
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 }