kimaki 0.30.0 → 0.30.1

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 (46) hide show
  1. package/dist/cli-commands/send.js +3 -9
  2. package/dist/commands/abort.js +16 -4
  3. package/dist/commands/queue.js +7 -7
  4. package/dist/discord-bot.js +5 -5
  5. package/dist/discord-utils.js +15 -1
  6. package/dist/external-directory-permissions.e2e.test.js +2 -2
  7. package/dist/external-directory-project-deny.e2e.test.js +1 -1
  8. package/dist/message-formatting.js +16 -6
  9. package/dist/message-formatting.test.js +10 -9
  10. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  11. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
  12. package/dist/queue-advanced-abort.e2e.test.js +61 -5
  13. package/dist/queue-advanced-footer.e2e.test.js +1 -1
  14. package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -1
  15. package/dist/session-handler/event-stream-state.js +15 -15
  16. package/dist/session-handler/event-stream-state.test.js +152 -11
  17. package/dist/session-handler/thread-session-runtime.js +41 -28
  18. package/dist/sleep.e2e.test.js +2 -2
  19. package/dist/task-runner.js +3 -3
  20. package/dist/task-schedule.js +2 -2
  21. package/dist/thread-message-queue.e2e.test.js +1 -1
  22. package/dist/undo-redo.e2e.test.js +1 -1
  23. package/package.json +6 -6
  24. package/skills/holocron/SKILL.md +0 -28
  25. package/src/cli-commands/send.ts +3 -9
  26. package/src/commands/abort.ts +20 -4
  27. package/src/commands/queue.ts +7 -7
  28. package/src/discord-bot.ts +5 -5
  29. package/src/discord-utils.ts +17 -1
  30. package/src/external-directory-permissions.e2e.test.ts +2 -2
  31. package/src/external-directory-project-deny.e2e.test.ts +1 -1
  32. package/src/message-formatting.test.ts +10 -9
  33. package/src/message-formatting.ts +15 -6
  34. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  35. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
  36. package/src/queue-advanced-abort.e2e.test.ts +72 -5
  37. package/src/queue-advanced-footer.e2e.test.ts +1 -1
  38. package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -1
  39. package/src/session-handler/event-stream-state.test.ts +155 -11
  40. package/src/session-handler/event-stream-state.ts +17 -20
  41. package/src/session-handler/thread-session-runtime.ts +44 -32
  42. package/src/sleep.e2e.test.ts +2 -2
  43. package/src/task-runner.ts +3 -3
  44. package/src/task-schedule.ts +2 -2
  45. package/src/thread-message-queue.e2e.test.ts +1 -1
  46. package/src/undo-redo.e2e.test.ts +1 -1
@@ -879,7 +879,8 @@ const MIN_PROMPT_CACHE_READ_TO_TRACK = 1024
879
879
  const PROMPT_CACHE_DROP_RATIO = 0.5
880
880
 
881
881
  export type PromptCacheClear = {
882
- previousCacheRead: number
882
+ // Tokens that should have been read from cache: the smaller of the previous cached prefix and the current prompt.
883
+ expectedCacheRead: number
883
884
  currentCacheRead: number
884
885
  previousMessageId: string
885
886
  currentMessageId: string
@@ -915,13 +916,6 @@ function hasPruneBetween({
915
916
  return false
916
917
  }
917
918
 
918
- function promptInputTokens(tokens: {
919
- input: number
920
- cache: { read: number; write: number }
921
- }): number {
922
- return tokens.input + tokens.cache.read + tokens.cache.write
923
- }
924
-
925
919
  function getCompletedAssistantAt({
926
920
  events,
927
921
  sessionId,
@@ -963,7 +957,8 @@ function isComparableCacheAssistant(message: AssistantMessage): boolean {
963
957
  return true
964
958
  }
965
959
 
966
- // Same-model cache drop vs the previous completed assistant. Errors and compaction summaries block the pair.
960
+ // Same-model cache drop vs the previous completed assistant. Aborted/errored replies are skipped,
961
+ // compaction summaries and pruned tool output block the pair since they rewrite the prompt.
967
962
  export function getPromptCacheClear({
968
963
  events,
969
964
  sessionId,
@@ -1003,13 +998,13 @@ export function getPromptCacheClear({
1003
998
  continue
1004
999
  }
1005
1000
  seen.add(info.id)
1006
- if (!isComparableCacheAssistant(info) || !info.tokens) {
1001
+ if (!isUserFacingAssistantMessage(info)) {
1007
1002
  return undefined
1008
1003
  }
1009
- if (info.modelID !== current.modelID || info.providerID !== current.providerID) {
1010
- return undefined
1004
+ if (!isComparableCacheAssistant(info) || !info.tokens) {
1005
+ continue
1011
1006
  }
1012
- if (info.tokens.cache.read < MIN_PROMPT_CACHE_READ_TO_TRACK) {
1007
+ if (info.modelID !== current.modelID || info.providerID !== current.providerID) {
1013
1008
  return undefined
1014
1009
  }
1015
1010
  if (hasPruneBetween({
@@ -1020,17 +1015,19 @@ export function getPromptCacheClear({
1020
1015
  })) {
1021
1016
  return undefined
1022
1017
  }
1023
- if (current.tokens.cache.read > info.tokens.cache.read * PROMPT_CACHE_DROP_RATIO) {
1018
+ // Anthropic reports a fresh cache as write only, so read alone misses the turn after a miss.
1019
+ const previousCached = info.tokens.cache.read + info.tokens.cache.write
1020
+ const currentPrompt = current.tokens.input + current.tokens.cache.read + current.tokens.cache.write
1021
+ // A reverted (shorter) prompt can only reuse its own length from cache.
1022
+ const expectedCacheRead = Math.min(previousCached, currentPrompt)
1023
+ if (expectedCacheRead < MIN_PROMPT_CACHE_READ_TO_TRACK) {
1024
1024
  return undefined
1025
1025
  }
1026
- const previousPrompt = promptInputTokens(info.tokens)
1027
- const currentPrompt = promptInputTokens(current.tokens)
1028
- // Pruning blanks old tool output and shrinks the next prompt. That is not a cache clear.
1029
- if (currentPrompt < previousPrompt) {
1026
+ if (current.tokens.cache.read > expectedCacheRead * PROMPT_CACHE_DROP_RATIO) {
1030
1027
  return undefined
1031
1028
  }
1032
1029
  return {
1033
- previousCacheRead: info.tokens.cache.read,
1030
+ expectedCacheRead,
1034
1031
  currentCacheRead: current.tokens.cache.read,
1035
1032
  previousMessageId: info.id,
1036
1033
  currentMessageId: current.id,
@@ -1052,7 +1049,7 @@ export function formatPromptCacheClearMessage(
1052
1049
  clear: PromptCacheClear,
1053
1050
  systemDiff?: { additions: number; deletions: number },
1054
1051
  ): string {
1055
- const tokens = `prompt cache missed (${formatCompactTokenCount(clear.previousCacheRead)} → ${formatCompactTokenCount(clear.currentCacheRead)})`
1052
+ const tokens = `prompt cache missed (${formatCompactTokenCount(clear.expectedCacheRead)} → ${formatCompactTokenCount(clear.currentCacheRead)})`
1056
1053
  if (!systemDiff || (systemDiff.additions === 0 && systemDiff.deletions === 0)) {
1057
1054
  return tokens
1058
1055
  }
@@ -50,7 +50,7 @@ import {
50
50
  import type { DiscordFileAttachment, SessionPartKind } from '../message-formatting.js'
51
51
  import {
52
52
  asDiscordQuote,
53
- asSystemLine,
53
+ asSubtext,
54
54
  formatPart,
55
55
  formatTaskToolTitle,
56
56
  planAssistantTurnFlush,
@@ -2349,16 +2349,15 @@ export class ThreadSessionRuntime {
2349
2349
  upToIndex: this.eventBuffer.length - 2,
2350
2350
  })
2351
2351
  const completedAt = msg.time.completed
2352
- if (
2353
- !wasAlreadyCompleted
2354
- && typeof completedAt === 'number'
2355
- && isAssistantMessageNaturalCompletion({ message: msg })
2356
- ) {
2357
- await this.handleNaturalAssistantCompletion({
2358
- completedMessageId: msg.id,
2359
- completedAt,
2360
- })
2361
- return
2352
+ if (!wasAlreadyCompleted && typeof completedAt === 'number') {
2353
+ if (isAssistantMessageNaturalCompletion({ message: msg })) {
2354
+ await this.handleNaturalAssistantCompletion({
2355
+ completedMessageId: msg.id,
2356
+ completedAt,
2357
+ })
2358
+ return
2359
+ }
2360
+ await this.maybeNotifyPromptCacheClear({ sessionId, messageId: msg.id })
2362
2361
  }
2363
2362
 
2364
2363
  // Context usage notice.
@@ -2398,7 +2397,7 @@ export class ThreadSessionRuntime {
2398
2397
  return
2399
2398
  }
2400
2399
  this.lastDisplayedContextPercentage = thresholdCrossed
2401
- const chunk = `${STATUS_PREFIX}context usage ${currentPercentage}%`
2400
+ const chunk = asSubtext(`context usage ${currentPercentage}%`)
2402
2401
  const sendResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
2403
2402
  .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
2404
2403
  if (sendResult instanceof Error) {
@@ -2615,7 +2614,7 @@ export class ThreadSessionRuntime {
2615
2614
  }
2616
2615
  return ` (${pct.toFixed(1)}%)`
2617
2616
  })()
2618
- const chunk = `${STATUS_PREFIX}${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`
2617
+ const chunk = asSubtext(`${STATUS_PREFIX}${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`)
2619
2618
  const largeOutputResult = await this.thread.send({
2620
2619
  content: chunk,
2621
2620
  flags: SILENT_MESSAGE_FLAGS,
@@ -3285,7 +3284,7 @@ export class ThreadSessionRuntime {
3285
3284
  return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`
3286
3285
  })()
3287
3286
 
3288
- const chunk = `${STATUS_PREFIX}${message} - retrying in ${duration} (attempt #${attempt})`
3287
+ const chunk = asSubtext(`${message} - retrying in ${duration} (attempt #${attempt})`)
3289
3288
  const retryResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
3290
3289
  .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
3291
3290
  if (retryResult instanceof Error) {
@@ -3371,7 +3370,7 @@ export class ThreadSessionRuntime {
3371
3370
  const titlePrefix = properties.title
3372
3371
  ? `${properties.title.trim()}: `
3373
3372
  : ''
3374
- const chunk = `${STATUS_PREFIX}${properties.variant}: ${titlePrefix}${toastMessage}`
3373
+ const chunk = asSubtext(`${properties.variant}: ${titlePrefix}${toastMessage}`)
3375
3374
  const toastResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
3376
3375
  .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
3377
3376
  if (toastResult instanceof Error) {
@@ -4051,17 +4050,20 @@ export class ThreadSessionRuntime {
4051
4050
  }
4052
4051
  }
4053
4052
 
4054
- abortActiveRun(reason: string): void {
4053
+ /** Explicit user abort: stops the run and drops all queued messages. Returns the removed items. */
4054
+ async abortActiveRun(reason: string): Promise<threadState.QueuedMessage[]> {
4055
4055
  const outcome = this.abortActiveRunInternal({
4056
4056
  reason,
4057
4057
  })
4058
4058
  if (outcome.apiAbortPromise) {
4059
4059
  void outcome.apiAbortPromise
4060
4060
  }
4061
- // Drain local queued messages after explicit abort.
4062
- void this.dispatchAction(() => {
4063
- return this.tryDrainQueue({ showIndicator: true })
4061
+ // Enqueued synchronously, so it runs before the abort's session.idle can drain the queue.
4062
+ let cleared: threadState.QueuedMessage[] = []
4063
+ await this.dispatchAction(async () => {
4064
+ cleared = await this.clearQueueNow()
4064
4065
  })
4066
+ return cleared
4065
4067
  }
4066
4068
 
4067
4069
  async abortActiveRunAndWait({
@@ -4112,20 +4114,25 @@ export class ThreadSessionRuntime {
4112
4114
  async clearQueue(): Promise<threadState.QueuedMessage[]> {
4113
4115
  let cleared: threadState.QueuedMessage[] = []
4114
4116
  await this.dispatchAction(async () => {
4115
- const persistResult = await deleteThreadQueueItems(this.threadId).catch((error) => {
4116
- return new Error('Failed to clear persisted queue', { cause: error })
4117
- })
4118
- if (persistResult instanceof Error) {
4119
- logger.error(
4120
- `[QUEUE] Failed to clear persisted queue for thread ${this.threadId}: ${persistResult.message}`,
4121
- )
4122
- return
4123
- }
4124
- cleared = threadState.clearQueueItems(this.threadId)
4117
+ cleared = await this.clearQueueNow()
4125
4118
  })
4126
4119
  return cleared
4127
4120
  }
4128
4121
 
4122
+ // Must run inside dispatchAction.
4123
+ private async clearQueueNow(): Promise<threadState.QueuedMessage[]> {
4124
+ const persistResult = await deleteThreadQueueItems(this.threadId).catch((error) => {
4125
+ return new Error('Failed to clear persisted queue', { cause: error })
4126
+ })
4127
+ if (persistResult instanceof Error) {
4128
+ logger.error(
4129
+ `[QUEUE] Failed to clear persisted queue for thread ${this.threadId}: ${persistResult.message}`,
4130
+ )
4131
+ return []
4132
+ }
4133
+ return threadState.clearQueueItems(this.threadId)
4134
+ }
4135
+
4129
4136
  /** Remove a queued message by its 1-based position. */
4130
4137
  async removeQueuePosition(position: number): Promise<threadState.QueuedMessage | undefined> {
4131
4138
  let removed: threadState.QueuedMessage | undefined
@@ -5174,7 +5181,7 @@ export class ThreadSessionRuntime {
5174
5181
  : ''
5175
5182
  const result = await sendThreadMessage(
5176
5183
  this.thread,
5177
- asSystemLine(`*using ${modelLabel}${agentLabel}*`),
5184
+ asSubtext(`*using ${modelLabel}${agentLabel}*`),
5178
5185
  { flags: SILENT_MESSAGE_FLAGS },
5179
5186
  ).catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
5180
5187
  if (result instanceof Error) {
@@ -5327,7 +5334,7 @@ export class ThreadSessionRuntime {
5327
5334
  })
5328
5335
  : undefined
5329
5336
  const mention = mentionUserId ? ` <@${mentionUserId}>` : ''
5330
- const footerText = asSystemLine(
5337
+ const footerText = asSubtext(
5331
5338
  `*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*${mention}`,
5332
5339
  )
5333
5340
  this.stopTyping()
@@ -5356,6 +5363,11 @@ export class ThreadSessionRuntime {
5356
5363
  sessionId: string
5357
5364
  messageId: string
5358
5365
  }): Promise<void> {
5366
+ // Only the first reply after a user prompt can show a cold cache. Later steps re-read the turn's own writes.
5367
+ const [firstAssistantId] = this.getAssistantMessageIdsForCurrentTurn({ sessionId })
5368
+ if (firstAssistantId !== messageId) {
5369
+ return
5370
+ }
5359
5371
  const cacheClear = getPromptCacheClear({
5360
5372
  events: this.eventBuffer,
5361
5373
  sessionId,
@@ -5369,7 +5381,7 @@ export class ThreadSessionRuntime {
5369
5381
  previousMessageId: cacheClear.previousMessageId,
5370
5382
  currentMessageId: cacheClear.currentMessageId,
5371
5383
  })
5372
- const chunk = `${STATUS_PREFIX}${formatPromptCacheClearMessage(cacheClear, systemDiff)}`
5384
+ const chunk = asSubtext(formatPromptCacheClearMessage(cacheClear, systemDiff))
5373
5385
  const sendResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
5374
5386
  .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
5375
5387
  if (sendResult instanceof Error) {
@@ -347,7 +347,7 @@ describe('kimaki_sleep', () => {
347
347
  -# *using deterministic-provider/deterministic-v2*
348
348
  > waiting for the deploy
349
349
 
350
- ┣ kimaki_sleep until 2030-01-01T09:00:00Z _waiting for the deploy_
350
+ -# ┣ kimaki_sleep until 2030-01-01T09:00:00Z _waiting for the deploy_
351
351
 
352
352
  > sleep-started
353
353
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
@@ -466,7 +466,7 @@ describe('kimaki_sleep', () => {
466
466
  SLEEP_CANCEL_MARKER wait for something
467
467
  --- from: assistant (TestBot)
468
468
  -# *using deterministic-provider/deterministic-v2*
469
- ┣ kimaki_sleep for 2h _cancel me_
469
+ -# ┣ kimaki_sleep for 2h _cancel me_
470
470
 
471
471
  > cancel-sleep-started
472
472
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { DiscordAPIError, type REST, Routes } from 'discord.js'
4
4
  import { createDiscordRest } from './discord-urls.js'
5
- import { ensureThreadMember } from './discord-utils.js'
5
+ import { buildThreadStartEmbeds, ensureThreadMember } from './discord-utils.js'
6
6
  import YAML from 'yaml'
7
7
  import {
8
8
  claimScheduledTaskRunning,
@@ -97,7 +97,7 @@ async function executeThreadScheduledTask({
97
97
  : {}),
98
98
  ...(payload.parentSessionId ? { parentSessionId: payload.parentSessionId } : {}),
99
99
  }
100
- const embed = [{ color: 0x2b2d31, footer: { text: YAML.stringify(marker) } }]
100
+ const embed = await buildThreadStartEmbeds(marker)
101
101
  // Newline between prefix and prompt so leading /command detection can
102
102
  // find the command on its own line.
103
103
  const prefixedPrompt = `${QUEUE_PREFIX}**kimaki-cli:**\n${prompt}`
@@ -296,7 +296,7 @@ async function executeChannelScheduledTask({
296
296
  ...(payload.parentSessionId ? { parentSessionId: payload.parentSessionId } : {}),
297
297
  }
298
298
  const embeds = marker
299
- ? [{ color: 0x2b2d31, footer: { text: YAML.stringify(marker) } }]
299
+ ? await buildThreadStartEmbeds(marker)
300
300
  : undefined
301
301
 
302
302
  const starterResult = await rest
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { CronExpressionParser } from 'cron-parser'
4
4
  import * as errore from 'errore'
5
- import { STATUS_PREFIX } from './message-formatting.js'
5
+ import { asSubtext } from './message-formatting.js'
6
6
 
7
7
  export type ScheduledTaskPayload = {
8
8
  preRunCommand: string | null
@@ -194,7 +194,7 @@ export function formatSessionSleepWakePrompt({
194
194
  }): string {
195
195
  const until = formatSessionSleepWakeAt(wakeAt)
196
196
  const reasonLine = reason?.trim() ? `\nReason: ${reason.trim()}` : ''
197
- return `${STATUS_PREFIX}Woke after sleeping until ${until}${reasonLine}\nContinue the work you were waiting for.`
197
+ return `${asSubtext(`Woke after sleeping until ${until}`)}${reasonLine}\nContinue the work you were waiting for.`
198
198
  }
199
199
 
200
200
  // Tool result after persist. Keep "Sleeping until" first so e2e matchers still hit.
@@ -714,7 +714,7 @@ e2eTest('thread message queue ordering', () => {
714
714
  > running create file
715
715
  > creating marker
716
716
 
717
- ┣ bash _Create marker file for e2e test_
717
+ -# ┣ bash _Create marker file for e2e test_
718
718
 
719
719
  > file created
720
720
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
@@ -202,7 +202,7 @@ e2eTest('/undo sets revert state and cleans up on next prompt', () => {
202
202
  -# *using deterministic-provider/deterministic-v2*
203
203
  > creating undo file
204
204
 
205
- ┣ bash _Create undo marker file_
205
+ -# ┣ bash _Create undo marker file_
206
206
 
207
207
  > undo file created
208
208
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*