kimaki 0.30.1 → 0.31.0

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 (111) hide show
  1. package/dist/agent-model.e2e.test.js +27 -19
  2. package/dist/analytics.js +3 -1
  3. package/dist/btw-prefix-detection.js +9 -0
  4. package/dist/btw-prefix-detection.test.js +38 -1
  5. package/dist/cli-commands/send.js +2 -2
  6. package/dist/cli-runner.js +22 -10
  7. package/dist/cli-runner.test.js +34 -1
  8. package/dist/cli-send-thread.e2e.test.js +1 -1
  9. package/dist/commands/btw.js +147 -39
  10. package/dist/commands/context-usage.js +47 -0
  11. package/dist/commands/context-usage.test.js +49 -0
  12. package/dist/commands/fork.js +32 -1
  13. package/dist/commands/model.js +69 -14
  14. package/dist/commands/new-worktree.js +5 -2
  15. package/dist/commands/queue.js +32 -25
  16. package/dist/commands/session.js +7 -2
  17. package/dist/db.test.js +17 -7
  18. package/dist/discord-bot.js +65 -42
  19. package/dist/errors.js +10 -5
  20. package/dist/external-directory-permissions.e2e.test.js +2 -2
  21. package/dist/external-directory-project-deny.e2e.test.js +1 -1
  22. package/dist/gateway-proxy.e2e.test.js +5 -5
  23. package/dist/message-formatting.js +34 -9
  24. package/dist/message-formatting.test.js +2 -2
  25. package/dist/message-preprocessing.js +53 -75
  26. package/dist/queue-advanced-abort.e2e.test.js +1 -1
  27. package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
  28. package/dist/queue-advanced-footer.e2e.test.js +7 -7
  29. package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
  30. package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -1
  31. package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
  32. package/dist/queue-delete-message.e2e.test.js +146 -5
  33. package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
  34. package/dist/queue-interrupt-drain.e2e.test.js +24 -14
  35. package/dist/queue-question-select-drain.e2e.test.js +27 -27
  36. package/dist/runtime-lifecycle.e2e.test.js +6 -6
  37. package/dist/session-handler/agent-utils.js +16 -17
  38. package/dist/session-handler/event-stream-state.js +10 -2
  39. package/dist/session-handler/event-stream-state.test.js +129 -3
  40. package/dist/session-handler/thread-session-runtime.js +169 -101
  41. package/dist/sleep.e2e.test.js +4 -4
  42. package/dist/system-message.js +214 -392
  43. package/dist/system-message.test.js +204 -380
  44. package/dist/test-utils.js +32 -6
  45. package/dist/thread-message-queue.e2e.test.js +131 -59
  46. package/dist/undo-redo.e2e.test.js +2 -2
  47. package/dist/voice-handler.js +2 -1
  48. package/dist/voice-message.e2e.test.js +22 -31
  49. package/dist/voice-routing-input.e2e.test.js +2 -2
  50. package/dist/voice-routing-question.e2e.test.js +59 -2
  51. package/dist/voice.js +306 -206
  52. package/dist/voice.test.js +161 -13
  53. package/dist/worktree-lifecycle.e2e.test.js +7 -7
  54. package/package.json +8 -11
  55. package/skills/critique/SKILL.md +4 -0
  56. package/skills/opensrc/SKILL.md +20 -13
  57. package/skills/playwriter/SKILL.md +1 -1
  58. package/src/agent-model.e2e.test.ts +28 -19
  59. package/src/analytics.ts +3 -1
  60. package/src/btw-prefix-detection.test.ts +39 -1
  61. package/src/btw-prefix-detection.ts +11 -0
  62. package/src/cli-commands/send.ts +8 -2
  63. package/src/cli-runner.test.ts +37 -0
  64. package/src/cli-runner.ts +26 -10
  65. package/src/cli-send-thread.e2e.test.ts +1 -1
  66. package/src/commands/btw.ts +151 -41
  67. package/src/commands/context-usage.test.ts +57 -0
  68. package/src/commands/context-usage.ts +59 -0
  69. package/src/commands/fork.ts +34 -1
  70. package/src/commands/model.ts +81 -14
  71. package/src/commands/new-worktree.ts +5 -2
  72. package/src/commands/queue.ts +41 -25
  73. package/src/commands/session.ts +10 -2
  74. package/src/db.test.ts +18 -6
  75. package/src/discord-bot.ts +67 -51
  76. package/src/errors.ts +12 -6
  77. package/src/external-directory-permissions.e2e.test.ts +2 -2
  78. package/src/external-directory-project-deny.e2e.test.ts +1 -1
  79. package/src/gateway-proxy.e2e.test.ts +5 -5
  80. package/src/message-formatting.test.ts +2 -2
  81. package/src/message-formatting.ts +37 -10
  82. package/src/message-preprocessing.ts +69 -91
  83. package/src/queue-advanced-abort.e2e.test.ts +1 -1
  84. package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
  85. package/src/queue-advanced-footer.e2e.test.ts +7 -7
  86. package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
  87. package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -1
  88. package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
  89. package/src/queue-delete-message.e2e.test.ts +164 -4
  90. package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
  91. package/src/queue-interrupt-drain.e2e.test.ts +24 -13
  92. package/src/queue-question-select-drain.e2e.test.ts +31 -28
  93. package/src/runtime-lifecycle.e2e.test.ts +6 -6
  94. package/src/session-handler/agent-utils.ts +25 -20
  95. package/src/session-handler/event-stream-state.test.ts +129 -2
  96. package/src/session-handler/event-stream-state.ts +21 -2
  97. package/src/session-handler/thread-runtime-state.ts +7 -0
  98. package/src/session-handler/thread-session-runtime.ts +211 -123
  99. package/src/sleep.e2e.test.ts +4 -4
  100. package/src/system-message.test.ts +216 -379
  101. package/src/system-message.ts +253 -389
  102. package/src/test-utils.ts +55 -12
  103. package/src/thread-message-queue.e2e.test.ts +135 -62
  104. package/src/undo-redo.e2e.test.ts +2 -2
  105. package/src/voice-handler.ts +2 -1
  106. package/src/voice-message.e2e.test.ts +22 -33
  107. package/src/voice-routing-input.e2e.test.ts +2 -2
  108. package/src/voice-routing-question.e2e.test.ts +59 -2
  109. package/src/voice.test.ts +164 -14
  110. package/src/voice.ts +400 -279
  111. package/src/worktree-lifecycle.e2e.test.ts +7 -7
@@ -400,7 +400,7 @@ describe('agent model resolution', () => {
400
400
  Reply with exactly: agent-model-check
401
401
  --- from: assistant (TestBot)
402
402
  -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
403
- > ok
403
+ ok
404
404
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
405
405
  `);
406
406
  expect(footerMessage).toBeDefined();
@@ -444,7 +444,7 @@ describe('agent model resolution', () => {
444
444
  Reply with exactly: system-context-check
445
445
  --- from: assistant (TestBot)
446
446
  -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
447
- > system-context-ok
447
+ system-context-ok
448
448
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
449
449
  `);
450
450
  }, 15_000);
@@ -550,7 +550,7 @@ describe('agent model resolution', () => {
550
550
  Reply with exactly: channel-model-check
551
551
  --- from: assistant (TestBot)
552
552
  -# *using deterministic-provider/channel-model-v2*
553
- > ok
553
+ ok
554
554
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
555
555
  `);
556
556
  expect(footerMessage).toBeDefined();
@@ -594,7 +594,7 @@ describe('agent model resolution', () => {
594
594
  Reply with exactly: variant-check
595
595
  --- from: assistant (TestBot)
596
596
  -# *using deterministic-provider/channel-model-v2*
597
- > ok
597
+ ok
598
598
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
599
599
  `);
600
600
  expect(footer?.content).toContain(CHANNEL_MODEL);
@@ -655,13 +655,21 @@ describe('agent model resolution', () => {
655
655
  const forkedSessionModel = forkedSessionId
656
656
  ? await getSessionModel(forkedSessionId)
657
657
  : undefined;
658
+ // The immediate btw ack is edited in place with the fork link.
659
+ const sourceThreadText = (await discord.thread(sourceThread.id).text())
660
+ .replace(`<#${forkedThread.id}>`, '<#FORK_THREAD>');
661
+ expect(sourceThreadText.split('\n').slice(-3).join('\n')).toMatchInlineSnapshot(`
662
+ "Reply with exactly: btw-model-check. btw
663
+ --- from: assistant (TestBot)
664
+ Session forked! Continue in <#FORK_THREAD>"
665
+ `);
658
666
  const forkedThreadText = (await discord.thread(forkedThread.id).text())
659
667
  .replace(`<#${sourceThread.id}>`, '<#SOURCE_THREAD>');
660
668
  expect(forkedThreadText).toMatchInlineSnapshot(`
661
669
  "--- from: assistant (TestBot)
662
670
  Reusing context from <#SOURCE_THREAD> to answer prompt...
663
671
  Reply with exactly: btw-model-check
664
- > ok
672
+ ok
665
673
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2*"
666
674
  `);
667
675
  expect(forkedSessionModel).toMatchInlineSnapshot(`
@@ -726,12 +734,12 @@ describe('agent model resolution', () => {
726
734
  Reply with exactly: first-thread-msg
727
735
  --- from: assistant (TestBot)
728
736
  -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
729
- > ok
737
+ ok
730
738
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
731
739
  --- from: user (agent-model-tester)
732
740
  Reply with exactly: second-thread-msg
733
741
  --- from: assistant (TestBot)
734
- > ok
742
+ ok
735
743
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
736
744
  `);
737
745
  const secondMessages = await discord.thread(thread.id).getMessages();
@@ -780,7 +788,7 @@ describe('agent model resolution', () => {
780
788
  "--- from: assistant (TestBot)
781
789
  » **agent-model-tester** (plan): Reply with exactly: inline-plan-agent-msg
782
790
  -# *using deterministic-provider/plan-model-v2 ⋅ plan*
783
- > ok
791
+ ok
784
792
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
785
793
  `);
786
794
  }, 20_000);
@@ -816,7 +824,7 @@ describe('agent model resolution', () => {
816
824
  "--- from: assistant (TestBot)
817
825
  » **agent-model-tester** (plan): Reply with exactly: inline-plan-agent-variant-msg
818
826
  -# *using deterministic-provider/plan-model-v2 ⋅ plan*
819
- > ok
827
+ ok
820
828
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
821
829
  `);
822
830
  expect(sessionId ? await getSessionModel(sessionId) : undefined).toMatchInlineSnapshot(`
@@ -909,10 +917,10 @@ describe('agent model resolution', () => {
909
917
  Reply with exactly: inline-existing-first-msg
910
918
  --- from: assistant (TestBot)
911
919
  -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
912
- > ok
920
+ ok
913
921
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
914
922
  » **agent-model-tester** (plan): Reply with exactly: inline-existing-plan-msg
915
- > ok
923
+ ok
916
924
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
917
925
  `);
918
926
  }, 20_000);
@@ -963,7 +971,7 @@ describe('agent model resolution', () => {
963
971
  Reply with exactly: switch-in-thread-msg
964
972
  --- from: assistant (TestBot)
965
973
  -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
966
- > ok
974
+ ok
967
975
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
968
976
  Switched to **plan** agent for this session (was **test-agent**)
969
977
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
@@ -971,7 +979,7 @@ describe('agent model resolution', () => {
971
979
  --- from: user (agent-model-tester)
972
980
  Reply with exactly: after-switch-msg
973
981
  --- from: assistant (TestBot)
974
- > ok
982
+ ok
975
983
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
976
984
  `);
977
985
  const secondFooter = [...(await discord.thread(thread.id).getMessages())]
@@ -1022,7 +1030,7 @@ describe('agent model resolution', () => {
1022
1030
  Reply with exactly: race-switch-first-msg
1023
1031
  --- from: assistant (TestBot)
1024
1032
  -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
1025
- > ok
1033
+ ok
1026
1034
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
1027
1035
  --- from: user (agent-model-tester)
1028
1036
  Reply with exactly: race-switch-follow-up
@@ -1030,7 +1038,7 @@ describe('agent model resolution', () => {
1030
1038
  Switched to **plan** agent for this session (was **test-agent**)
1031
1039
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
1032
1040
  The agent will change on the next message.
1033
- > ok
1041
+ ok
1034
1042
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
1035
1043
  `);
1036
1044
  const secondFooter = [...(await discord.thread(thread.id).getMessages())]
@@ -1072,7 +1080,7 @@ describe('agent model resolution', () => {
1072
1080
  Reply with exactly: race-channel-follow-up
1073
1081
  --- from: assistant (TestBot)
1074
1082
  -# *using deterministic-provider/plan-model-v2 ⋅ plan*
1075
- > ok
1083
+ ok
1076
1084
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
1077
1085
  `);
1078
1086
  const footer = [...(await discord.thread(thread.id).getMessages())]
@@ -1120,7 +1128,7 @@ describe('agent model resolution', () => {
1120
1128
  Reply with exactly: refresh-agent-model-msg
1121
1129
  --- from: assistant (TestBot)
1122
1130
  -# *using deterministic-provider/plan-model-v2 ⋅ plan*
1123
- > ok
1131
+ ok
1124
1132
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***
1125
1133
  Using **plan** agent for this session
1126
1134
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
@@ -1162,7 +1170,7 @@ describe('agent model resolution', () => {
1162
1170
  Reply with exactly: channel-vs-agent-msg
1163
1171
  --- from: assistant (TestBot)
1164
1172
  -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
1165
- > ok
1173
+ ok
1166
1174
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
1167
1175
  Switched to **plan** agent for this session (was **test-agent**)
1168
1176
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
@@ -1204,7 +1212,7 @@ describe('agent model resolution', () => {
1204
1212
  Reply with exactly: plain-agent-override-msg
1205
1213
  --- from: assistant (TestBot)
1206
1214
  -# *using deterministic-provider/channel-model-v2*
1207
- > ok
1215
+ ok
1208
1216
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*
1209
1217
  Switched to **plain** agent for this session
1210
1218
  Model: *deterministic-provider/channel-model-v2* (channel override)
package/dist/analytics.js CHANGED
@@ -1,4 +1,4 @@
1
- // Anonymous product analytics via Strada (OpenTelemetry).
1
+ // Anonymous product analytics via Strada (@strada.sh/sdk, no dependencies).
2
2
  // Tracks install-level usage only: no Discord IDs, paths, prompts, or secrets.
3
3
  // A random install id is stored in {dataDir}/install-id for DAU-style queries.
4
4
  //
@@ -158,6 +158,8 @@ export function initAnalytics() {
158
158
  environment,
159
159
  version: getKimakiVersion(),
160
160
  userId: installId,
161
+ // Kimaki owns crash handling in discord-bot.ts; the SDK must not exit the process.
162
+ captureUncaughtErrors: false,
161
163
  });
162
164
  initialized = true;
163
165
  }
@@ -6,10 +6,19 @@
6
6
  // - punctuation + btw: ". btw", "! btw", ". btw.", "!btw."
7
7
  // - btw as its own final line: "text\nbtw"
8
8
  // Non-matches: "btw fix this" (start only), "hello btw" (no punctuation)
9
+ import { extractQueueSuffix } from './message-formatting.js';
9
10
  const BTW_SUFFIX_RE = /(?:[.!?,;:])\s*btw\.?\s*$|\n\s*btw\.?\s*$/i;
11
+ const BTW_QUEUE_SUFFIX_RE = /(?:[.!?,;:])\s*btw\s+queue\.?\s*$|\n\s*btw\s+queue\.?\s*$/i;
10
12
  export function extractBtwSuffix(content) {
11
13
  if (!BTW_SUFFIX_RE.test(content)) {
12
14
  return { prompt: content, forceBtw: false };
13
15
  }
14
16
  return { prompt: content.replace(BTW_SUFFIX_RE, '').trimEnd(), forceBtw: true };
15
17
  }
18
+ export function extractBtwQueueSuffix(content) {
19
+ const queued = BTW_QUEUE_SUFFIX_RE.test(content)
20
+ ? { prompt: content.replace(/\s+queue\.?\s*$/i, ''), forceQueue: true }
21
+ : extractQueueSuffix(content);
22
+ const btw = extractBtwSuffix(queued.prompt);
23
+ return { prompt: btw.prompt, forceQueue: queued.forceQueue, forceBtw: btw.forceBtw };
24
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'vitest';
2
- import { extractBtwSuffix } from './btw-prefix-detection.js';
2
+ import { extractBtwSuffix, extractBtwQueueSuffix } from './btw-prefix-detection.js';
3
3
  describe('extractBtwSuffix', () => {
4
4
  test('matches after period', () => {
5
5
  expect(extractBtwSuffix('fix the bug. btw')).toMatchInlineSnapshot(`
@@ -91,3 +91,40 @@ describe('extractBtwSuffix', () => {
91
91
  `);
92
92
  });
93
93
  });
94
+ test('btw queue suffix keeps both actions in order', () => {
95
+ expect([
96
+ 'Explain this. btw queue',
97
+ 'Explain this. btw. queue',
98
+ 'Explain this\nbtw queue',
99
+ 'Explain this. btw',
100
+ 'Explain this. queue',
101
+ ].map(extractBtwQueueSuffix)).toMatchInlineSnapshot(`
102
+ [
103
+ {
104
+ "forceBtw": true,
105
+ "forceQueue": true,
106
+ "prompt": "Explain this",
107
+ },
108
+ {
109
+ "forceBtw": true,
110
+ "forceQueue": true,
111
+ "prompt": "Explain this",
112
+ },
113
+ {
114
+ "forceBtw": true,
115
+ "forceQueue": true,
116
+ "prompt": "Explain this",
117
+ },
118
+ {
119
+ "forceBtw": true,
120
+ "forceQueue": false,
121
+ "prompt": "Explain this",
122
+ },
123
+ {
124
+ "forceBtw": false,
125
+ "forceQueue": true,
126
+ "prompt": "Explain this",
127
+ },
128
+ ]
129
+ `);
130
+ });
@@ -37,7 +37,7 @@ cli
37
37
  .alias('start-session') // backwards compatibility
38
38
  .option('-c, --channel <channelId>', 'Discord channel ID')
39
39
  .option('-d, --project <path>', 'Project directory (alternative to --channel)')
40
- .option('-p, --prompt <prompt>', 'Message content')
40
+ .option('-p, --prompt <prompt>', 'Message content. With --thread/--session, end with ". queue" to wait for the current run instead of interrupting it, ". btw" to fork a side session now, or ". btw queue" to fork after the current run')
41
41
  .option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
42
42
  .option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
43
43
  .option('--notify-only', 'Create notification thread without starting AI session')
@@ -55,7 +55,7 @@ cli
55
55
  .option('--send-at <schedule>', 'Schedule send for future (UTC ISO date/time ending in Z, or cron expression)')
56
56
  .option('--pre-run <command>', 'Run a shell command in the project before starting a scheduled task')
57
57
  .option('--allow-concurrency', 'Allow concurrent sessions from the same scheduled task')
58
- .option('--thread <threadId>', 'Post prompt to an existing thread')
58
+ .option('--thread <threadId>', 'Post prompt to an existing thread. Interrupts a busy session unless the prompt ends with ". queue"')
59
59
  .option('--session <sessionId>', 'Post prompt to thread mapped to an existing session')
60
60
  .option('--parent-session <sessionId>', 'Parent OpenCode session ID for newly created child sessions')
61
61
  .option('--wait', 'Wait for session to complete, then print session text to stdout')
@@ -9,6 +9,7 @@ import * as dbSchema from './schema.js';
9
9
  import { selectResolvedCommand } from './opencode-command.js';
10
10
  import { Events, ChannelType, Routes, AttachmentBuilder, } from 'discord.js';
11
11
  import { discordApiUrl, getDiscordRestApiUrl, getGatewayProxyRestBaseUrl, getInternetReachableBaseUrl } from './discord-urls.js';
12
+ import { extractBtwQueueSuffix } from './btw-prefix-detection.js';
12
13
  import crypto from 'node:crypto';
13
14
  import path from 'node:path';
14
15
  import fs from 'node:fs';
@@ -160,6 +161,18 @@ function wrapPromptAttachmentText(prompt) {
160
161
  })
161
162
  .join('\n');
162
163
  }
164
+ /**
165
+ * Visible message content + prompt.md text for prompts over Discord's limit.
166
+ * The bot reads queue/btw suffixes only from message content, so the suffix
167
+ * moves from the file to the visible message and the send still queues or forks.
168
+ */
169
+ export function buildLongPromptMessage(prompt) {
170
+ const { prompt: fileText, forceQueue, forceBtw } = extractBtwQueueSuffix(prompt);
171
+ const preview = fileText.slice(0, 100).replace(/\n/g, ' ');
172
+ const summary = `Prompt attached as file (${fileText.length} chars)\n\n> ${preview}…`;
173
+ const suffix = [forceBtw && 'btw', forceQueue && 'queue'].filter(Boolean).join(' ');
174
+ return { content: suffix ? `${summary}\n\n${suffix}` : summary, fileText };
175
+ }
163
176
  function promptAttachmentBlob(text) {
164
177
  return new Blob([new Uint8Array(Buffer.from(text, 'utf8'))], {
165
178
  type: 'text/markdown',
@@ -182,18 +195,18 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
182
195
  // When prompt exceeds Discord's limit, attach it as prompt.md alongside
183
196
  // user files so nothing is silently lost. Build prompt.md from memory so
184
197
  // parallel kimaki send processes never share or unlink a temp path.
185
- const isLongPrompt = prompt.length > discordMaxLength;
186
- const content = isLongPrompt
187
- ? `Prompt attached as file (${prompt.length} chars)\n\n> ${prompt.slice(0, 100).replace(/\n/g, ' ')}...`
188
- : prompt;
198
+ const longPrompt = prompt.length > discordMaxLength
199
+ ? buildLongPromptMessage(prompt)
200
+ : undefined;
201
+ const content = longPrompt ? longPrompt.content : prompt;
189
202
  const allFiles = files.map((file) => ({
190
203
  data: new Uint8Array(fs.readFileSync(file)),
191
204
  filename: path.basename(file),
192
205
  mimeType: mime.getType(file) || 'application/octet-stream',
193
206
  }));
194
- if (isLongPrompt) {
207
+ if (longPrompt) {
195
208
  allFiles.push({
196
- data: new Uint8Array(Buffer.from(prompt, 'utf8')),
209
+ data: new Uint8Array(Buffer.from(longPrompt.fileText, 'utf8')),
197
210
  filename: 'prompt.md',
198
211
  mimeType: 'text/markdown',
199
212
  });
@@ -259,17 +272,16 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
259
272
  }
260
273
  return firstMessage;
261
274
  }
262
- const preview = prompt.slice(0, 100).replace(/\n/g, ' ');
263
- const summaryContent = `Prompt attached as file (${prompt.length} chars)\n\n> ${preview}...`;
275
+ const longPrompt = buildLongPromptMessage(prompt);
264
276
  // In-memory Blob only — no temp file. Parallel send must never share a path.
265
277
  const formData = new FormData();
266
278
  formData.append('payload_json', JSON.stringify({
267
- content: summaryContent,
279
+ content: longPrompt.content,
268
280
  attachments: [{ id: 0, filename: 'prompt.md' }],
269
281
  embeds,
270
282
  allowed_mentions: { parse: store.getState().allowedMentions },
271
283
  }));
272
- formData.append('files[0]', promptAttachmentBlob(wrapPromptAttachmentText(prompt)), 'prompt.md');
284
+ formData.append('files[0]', promptAttachmentBlob(wrapPromptAttachmentText(longPrompt.fileText)), 'prompt.md');
273
285
  const starterMessageResponse = await fetch(discordApiUrl(`/channels/${channelId}/messages`), {
274
286
  method: 'POST',
275
287
  headers: {
@@ -1,6 +1,39 @@
1
1
  import { describe, expect, test } from 'vitest';
2
2
  import { REST } from 'discord.js';
3
- import { getOpenUrlCommand, isTransientNetworkError, resolveDiscordUserOption, } from './cli-runner.js';
3
+ import { buildLongPromptMessage, getOpenUrlCommand, isTransientNetworkError, resolveDiscordUserOption, } from './cli-runner.js';
4
+ import { extractBtwQueueSuffix } from './btw-prefix-detection.js';
5
+ // Regression: a long `. btw` prompt lost its suffix and interrupted the source session.
6
+ test('long prompts keep queue and btw suffixes in the visible message', () => {
7
+ const body = 'x'.repeat(2100);
8
+ expect(['queue', 'btw', 'btw queue', ''].map((suffix) => {
9
+ const { content, fileText } = buildLongPromptMessage(suffix ? `${body}. ${suffix}` : body);
10
+ const parsed = extractBtwQueueSuffix(content);
11
+ return { fileText: fileText === body, forceQueue: parsed.forceQueue, forceBtw: parsed.forceBtw };
12
+ })).toMatchInlineSnapshot(`
13
+ [
14
+ {
15
+ "fileText": true,
16
+ "forceBtw": false,
17
+ "forceQueue": true,
18
+ },
19
+ {
20
+ "fileText": true,
21
+ "forceBtw": true,
22
+ "forceQueue": false,
23
+ },
24
+ {
25
+ "fileText": true,
26
+ "forceBtw": true,
27
+ "forceQueue": true,
28
+ },
29
+ {
30
+ "fileText": true,
31
+ "forceBtw": false,
32
+ "forceQueue": false,
33
+ },
34
+ ]
35
+ `);
36
+ });
4
37
  test('raw Discord user ID does not invent a username', async () => {
5
38
  const user = await resolveDiscordUserOption({
6
39
  user: '535922349652836367',
@@ -320,7 +320,7 @@ describe('kimaki send --channel thread creation', () => {
320
320
  thread empty content seed
321
321
  --- from: assistant (TestBot)
322
322
  -# *using deterministic-provider/deterministic-v2*
323
- > caught-by-model
323
+ caught-by-model
324
324
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
325
325
  --- from: user (cli-send-tester)
326
326
  --- from: assistant (TestBot)
@@ -8,9 +8,19 @@ import { resolveWorkingDirectory, resolveTextChannel, sendThreadMessage, } from
8
8
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
9
9
  import { createLogger, LogPrefix } from '../logger.js';
10
10
  import { initializeOpencodeForDirectory } from '../opencode.js';
11
- import { copyCurrentSessionModel } from './model.js';
11
+ import { copySessionPreferences } from './model.js';
12
+ import { copySessionSystemPrompt } from '../system-message.js';
13
+ import { OpenCodeSdkError } from '../errors.js';
14
+ import { extractQueueSuffix } from '../message-formatting.js';
12
15
  const logger = createLogger(LogPrefix.FORK);
16
+ // Resolves to [value, elapsed ms] so each btw setup step can be logged.
17
+ async function timed(promise) {
18
+ const start = Date.now();
19
+ const value = await promise;
20
+ return [value, Date.now() - start];
21
+ }
13
22
  export async function forkSessionToBtwThread({ sourceThread, projectDirectory, sdkDirectory, prompt, modelPrompt = prompt, userId, username, appId, agent, images, }) {
23
+ const startedAt = Date.now();
14
24
  // Parallelize: session lookup + opencode init + parent channel resolve are independent
15
25
  const [sessionId, getClientResult, textChannel] = await Promise.all([
16
26
  getThreadSession(sourceThread.id),
@@ -28,51 +38,106 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, s
28
38
  if (!textChannel) {
29
39
  return new Error('Could not resolve parent text channel');
30
40
  }
31
- // Fork must succeed before creating the Discord thread to avoid orphan threads
32
- const forkResponse = await getClientResult().session.fork({ sessionID: sessionId, directory: sdkDirectory });
33
- if (!forkResponse.data) {
34
- return new Error('Failed to fork session');
41
+ // Fork and thread creation are independent round trips, so run them together.
42
+ // If either side fails, remove whichever side succeeded.
43
+ const initMs = Date.now() - startedAt;
44
+ const [forkSettled, threadSettled] = await Promise.allSettled([
45
+ timed(getClientResult().session.fork({ sessionID: sessionId, directory: sdkDirectory })),
46
+ timed(textChannel.threads.create({
47
+ name: `btw: ${prompt}`.slice(0, 100),
48
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
49
+ reason: `btw fork from session ${sessionId}`,
50
+ })),
51
+ ]);
52
+ const forkedSession = forkSettled.status === 'fulfilled' ? forkSettled.value[0].data : undefined;
53
+ const createdThread = threadSettled.status === 'fulfilled' ? threadSettled.value[0] : undefined;
54
+ const cleanup = async () => {
55
+ await Promise.all([
56
+ createdThread?.delete('btw fork setup failed').catch((error) => {
57
+ logger.warn(`Could not delete orphan btw thread ${createdThread.id}:`, error);
58
+ }),
59
+ forkedSession && getClientResult()
60
+ .session.delete({ sessionID: forkedSession.id, directory: sdkDirectory })
61
+ .catch((error) => {
62
+ logger.warn(`Could not delete orphan btw session ${forkedSession.id}:`, error);
63
+ }),
64
+ ]);
65
+ };
66
+ if (!forkedSession) {
67
+ await cleanup();
68
+ const cause = forkSettled.status === 'rejected' ? forkSettled.reason : forkSettled.value[0].error;
69
+ return new OpenCodeSdkError({ operation: 'session.fork', cause });
35
70
  }
36
- const forkedSession = forkResponse.data;
37
- const channelId = sourceThread.parentId || sourceThread.id;
38
- await copyCurrentSessionModel({
39
- sourceSessionId: sessionId,
40
- targetSessionId: forkedSession.id,
41
- channelId,
42
- appId,
43
- getClient: getClientResult,
44
- directory: sdkDirectory,
45
- });
46
- const thread = await textChannel.threads.create({
47
- name: `btw: ${prompt}`.slice(0, 100),
48
- autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
49
- reason: `btw fork from session ${sessionId}`,
50
- });
51
- // DB mapping must complete before user-visible actions so the thread is routable
52
- await setThreadSession(thread.id, forkedSession.id);
53
- const sourceWorkspace = await getThreadWorktreeOrWorkspace(sourceThread.id);
54
- if (sourceWorkspace?.status === 'ready' && sourceWorkspace.workspace_directory) {
55
- await createPendingWorkspace({
56
- threadId: thread.id,
57
- workspaceType: sourceWorkspace.workspace_type,
58
- workspaceName: sourceWorkspace.workspace_name ?? '',
59
- projectDirectory,
60
- });
61
- await setWorkspaceReady({
62
- threadId: thread.id,
63
- workspaceId: sourceWorkspace.workspace_id ?? undefined,
64
- workspaceDirectory: sourceWorkspace.workspace_directory,
71
+ if (!createdThread) {
72
+ await cleanup();
73
+ return new Error('Failed to create the btw thread', {
74
+ cause: threadSettled.status === 'rejected' ? threadSettled.reason : undefined,
65
75
  });
66
76
  }
67
- // Parallelize: member add and status message are independent best-effort actions
77
+ const thread = createdThread;
78
+ const forkMs = forkSettled.status === 'fulfilled' ? forkSettled.value[1] : -1;
79
+ const threadMs = threadSettled.status === 'fulfilled' ? threadSettled.value[1] : -1;
80
+ const channelId = sourceThread.parentId || sourceThread.id;
68
81
  const sourceThreadLink = `<#${sourceThread.id}>`;
82
+ // The fork must run with the source agent, model and pinned system prompt,
83
+ // so its request prefix is byte-identical and hits the source prompt cache.
84
+ // `false` (source not pinned yet) is the only fallback; I/O errors fail setup.
85
+ const copyStartedAt = Date.now();
86
+ const [, copiedSystem, history] = await Promise.all([
87
+ copySessionPreferences({
88
+ sourceSessionId: sessionId,
89
+ targetSessionId: forkedSession.id,
90
+ channelId,
91
+ appId,
92
+ getClient: getClientResult,
93
+ directory: sdkDirectory,
94
+ }),
95
+ copySessionSystemPrompt({
96
+ sourceSessionId: sessionId,
97
+ targetSessionId: forkedSession.id,
98
+ }),
99
+ getClientResult().session.messages({
100
+ sessionID: forkedSession.id,
101
+ directory: sdkDirectory,
102
+ limit: 10,
103
+ }).catch((cause) => new OpenCodeSdkError({ operation: 'session.messages', cause })),
104
+ ]);
105
+ if (copiedSystem instanceof Error) {
106
+ await cleanup();
107
+ return new Error(`Could not copy the source system prompt to the fork: ${copiedSystem.message}`, {
108
+ cause: copiedSystem,
109
+ });
110
+ }
111
+ const copyMs = Date.now() - copyStartedAt;
112
+ const routeStartedAt = Date.now();
69
113
  await Promise.all([
114
+ // DB mapping must complete before dispatch so the thread is routable
115
+ (async () => {
116
+ await setThreadSession(thread.id, forkedSession.id);
117
+ const sourceWorkspace = await getThreadWorktreeOrWorkspace(sourceThread.id);
118
+ if (sourceWorkspace?.status !== 'ready' || !sourceWorkspace.workspace_directory) {
119
+ return;
120
+ }
121
+ await createPendingWorkspace({
122
+ threadId: thread.id,
123
+ workspaceType: sourceWorkspace.workspace_type,
124
+ workspaceName: sourceWorkspace.workspace_name ?? '',
125
+ projectDirectory,
126
+ });
127
+ await setWorkspaceReady({
128
+ threadId: thread.id,
129
+ workspaceId: sourceWorkspace.workspace_id ?? undefined,
130
+ workspaceDirectory: sourceWorkspace.workspace_directory,
131
+ });
132
+ })(),
70
133
  thread.members.add(userId).catch((error) => {
71
134
  logger.warn('Could not add fork member:', error);
72
135
  }),
73
136
  sendThreadMessage(thread, `Reusing context from ${sourceThreadLink} to answer prompt...\n${prompt}`),
74
137
  ]);
75
- logger.log(`Created btw fork session ${forkedSession.id} in thread ${thread.id} from source thread ${sourceThread.id} (session ${sessionId})`);
138
+ const routeMs = Date.now() - routeStartedAt;
139
+ logger.log(`Created btw fork session ${forkedSession.id} in thread ${thread.id} from source thread ${sourceThread.id} (session ${sessionId}), system prompt ${copiedSystem ? 'reused' : 'regenerated'}`);
140
+ logger.log(`[BTW TIMING] ${forkedSession.id} init=${initMs}ms fork=${forkMs}ms threadCreate=${threadMs}ms copyPrefs=${copyMs}ms route=${routeMs}ms total=${Date.now() - startedAt}ms`);
76
141
  // Parent context stays in the user prompt only. Do NOT pass parentSessionId
77
142
  // into enqueueIncoming: that would inject a parent block into the system
78
143
  // message and bust prompt cache shared with the parent session.
@@ -95,7 +160,18 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, s
95
160
  appId,
96
161
  sessionId: forkedSession.id,
97
162
  });
98
- await runtime.enqueueIncoming({
163
+ if (history instanceof Error || history.error) {
164
+ logger.warn('Could not load copied messages for btw cache diagnostics:', history);
165
+ }
166
+ else {
167
+ const last = history.data?.findLast(({ info }) => info.role === 'assistant' && typeof info.time.completed === 'number')?.info;
168
+ if (last?.role === 'assistant') {
169
+ runtime.seedForkPromptCacheBaseline(last);
170
+ }
171
+ }
172
+ // Not awaited: the caller confirms in the source thread right away while the
173
+ // runtime resolves preferences and dispatches. Failures are reported in the fork.
174
+ void runtime.enqueueIncoming({
99
175
  prompt: wrappedPrompt,
100
176
  agent,
101
177
  images,
@@ -103,7 +179,9 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, s
103
179
  username,
104
180
  appId,
105
181
  mode: 'opencode',
106
- }).catch(async (error) => {
182
+ }).then(() => {
183
+ logger.log(`[BTW TIMING] ${forkedSession.id} promptAccepted=${Date.now() - startedAt}ms`);
184
+ }, async (error) => {
107
185
  logger.error('Fork dispatch failed:', error);
108
186
  await sendThreadMessage(thread, 'Could not send the request to OpenCode. Send your request again in this thread.');
109
187
  });
@@ -131,7 +209,11 @@ export async function handleBtwCommand({ command, appId, }) {
131
209
  return;
132
210
  }
133
211
  const threadChannel = channel;
134
- const prompt = command.options.getString('prompt', true);
212
+ const { prompt, forceQueue } = extractQueueSuffix(command.options.getString('prompt', true));
213
+ if (!prompt.trim()) {
214
+ await command.reply({ content: 'Write a question to fork', flags: MessageFlags.Ephemeral });
215
+ return;
216
+ }
135
217
  const resolved = await resolveWorkingDirectory({
136
218
  channel: threadChannel,
137
219
  });
@@ -145,6 +227,32 @@ export async function handleBtwCommand({ command, appId, }) {
145
227
  const { projectDirectory, workingDirectory } = resolved;
146
228
  await command.deferReply({ flags: MessageFlags.Ephemeral });
147
229
  try {
230
+ if (forceQueue) {
231
+ if (!await getThreadSession(threadChannel.id)) {
232
+ await command.editReply('No active session in this thread. Start a session, then retry /btw.');
233
+ return;
234
+ }
235
+ const runtime = getOrCreateRuntime({
236
+ threadId: threadChannel.id,
237
+ thread: threadChannel,
238
+ projectDirectory,
239
+ sdkDirectory: workingDirectory,
240
+ channelId: threadChannel.parentId || threadChannel.id,
241
+ appId,
242
+ });
243
+ const queued = await runtime.enqueueIncoming({
244
+ prompt,
245
+ queuedAction: 'btw',
246
+ userId: command.user.id,
247
+ username: command.user.displayName,
248
+ appId,
249
+ mode: 'local-queue',
250
+ });
251
+ await command.editReply(queued.queued
252
+ ? `Btw fork queued at position ${queued.position}. It will start after the earlier prompts finish.`
253
+ : 'Btw fork started. The new thread will appear shortly.');
254
+ return;
255
+ }
148
256
  const result = await forkSessionToBtwThread({
149
257
  sourceThread: threadChannel,
150
258
  projectDirectory,