kimaki 0.12.0 → 0.13.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 (42) hide show
  1. package/dist/btw-prefix-detection.js +13 -15
  2. package/dist/btw-prefix-detection.test.js +60 -30
  3. package/dist/cli-runner.js +8 -2
  4. package/dist/cli.js +5 -0
  5. package/dist/commands/abort.js +1 -1
  6. package/dist/commands/model-variant.js +1 -1
  7. package/dist/commands/model.js +1 -1
  8. package/dist/commands/restart-opencode-server.js +1 -1
  9. package/dist/commands/undo-redo.js +2 -2
  10. package/dist/commands/upgrade.js +1 -2
  11. package/dist/discord-bot.js +55 -10
  12. package/dist/message-preprocessing.js +1 -1
  13. package/dist/opencode-interrupt-plugin.js +14 -2
  14. package/dist/opencode-interrupt-plugin.test.js +22 -3
  15. package/dist/queue-advanced-model-switch.e2e.test.js +1 -1
  16. package/dist/session-handler/agent-utils.js +9 -9
  17. package/dist/session-handler/thread-runtime-state.js +29 -0
  18. package/dist/session-handler/thread-session-runtime.js +40 -6
  19. package/dist/store.js +1 -0
  20. package/dist/thread-message-queue.e2e.test.js +198 -1
  21. package/package.json +6 -6
  22. package/skills/holocron/SKILL.md +432 -0
  23. package/src/btw-prefix-detection.test.ts +61 -30
  24. package/src/btw-prefix-detection.ts +15 -19
  25. package/src/cli-runner.ts +8 -2
  26. package/src/cli.ts +11 -0
  27. package/src/commands/abort.ts +1 -1
  28. package/src/commands/model-variant.ts +1 -1
  29. package/src/commands/model.ts +1 -1
  30. package/src/commands/restart-opencode-server.ts +1 -1
  31. package/src/commands/undo-redo.ts +2 -2
  32. package/src/commands/upgrade.ts +1 -2
  33. package/src/discord-bot.ts +65 -9
  34. package/src/message-preprocessing.ts +1 -1
  35. package/src/opencode-interrupt-plugin.test.ts +27 -3
  36. package/src/opencode-interrupt-plugin.ts +15 -3
  37. package/src/queue-advanced-model-switch.e2e.test.ts +1 -1
  38. package/src/session-handler/agent-utils.ts +11 -11
  39. package/src/session-handler/thread-runtime-state.ts +35 -0
  40. package/src/session-handler/thread-session-runtime.ts +56 -6
  41. package/src/store.ts +8 -0
  42. package/src/thread-message-queue.e2e.test.ts +227 -1
@@ -2759,6 +2759,25 @@ export class ThreadSessionRuntime {
2759
2759
  removeQueuePosition(position) {
2760
2760
  return threadState.removeQueueItemAtPosition(this.threadId, position);
2761
2761
  }
2762
+ /**
2763
+ * Update a queued message identified by its Discord source message ID.
2764
+ * If newPrompt is empty, the item is removed from the queue.
2765
+ * Returns { found: true, removed } if the item was in the queue,
2766
+ * or { found: false } if it was already dispatched or never queued.
2767
+ */
2768
+ updateQueuedMessage(sourceMessageId, newPrompt) {
2769
+ const trimmed = newPrompt.trim();
2770
+ const original = threadState.updateQueueItemBySourceMessageId(this.threadId, sourceMessageId, (item) => {
2771
+ if (!trimmed)
2772
+ return null;
2773
+ return { ...item, prompt: trimmed };
2774
+ });
2775
+ if (!original)
2776
+ return { found: false, removed: false };
2777
+ if (!trimmed)
2778
+ return { found: true, removed: true };
2779
+ return { found: true, removed: false };
2780
+ }
2762
2781
  // ── Queue Drain ─────────────────────────────────────────────
2763
2782
  /**
2764
2783
  * Check if we can dispatch the next queued message. If so, dequeue and
@@ -3239,9 +3258,15 @@ export class ThreadSessionRuntime {
3239
3258
  directory: this.sdkDirectory,
3240
3259
  });
3241
3260
  });
3242
- if (!(sessionResponse instanceof Error) && sessionResponse.data) {
3261
+ if (sessionResponse instanceof Error) {
3262
+ logger.warn(`[ENSURE SESSION] Failed to get existing session ${sessionId}: ${sessionResponse.message}`);
3263
+ }
3264
+ else if (sessionResponse.data) {
3243
3265
  session = sessionResponse.data;
3244
3266
  }
3267
+ else {
3268
+ logger.warn(`[ENSURE SESSION] session.get returned no data for ${sessionId}`);
3269
+ }
3245
3270
  }
3246
3271
  if (!session) {
3247
3272
  // Pass per-session external_directory permissions so this session can
@@ -3259,11 +3284,20 @@ export class ThreadSessionRuntime {
3259
3284
  ...parsePermissionRules(permissions ?? []),
3260
3285
  ];
3261
3286
  // Omit title so OpenCode auto-generates a summary from the conversation
3262
- const sessionResponse = await getClient().session.create({
3263
- directory: this.sdkDirectory,
3264
- permission: sessionPermissions,
3287
+ const createResult = await errore.tryAsync(() => {
3288
+ return getClient().session.create({
3289
+ directory: this.sdkDirectory,
3290
+ permission: sessionPermissions,
3291
+ });
3265
3292
  });
3266
- session = sessionResponse.data;
3293
+ if (createResult instanceof Error) {
3294
+ logger.error(`[ENSURE SESSION] session.create failed: ${createResult.message}`);
3295
+ return new Error(`Failed to create session: ${createResult.message}, threadId=${this.thread.id}, directory=${this.sdkDirectory}`, { cause: createResult });
3296
+ }
3297
+ session = createResult.data;
3298
+ if (!session) {
3299
+ logger.warn(`[ENSURE SESSION] session.create returned no data, threadId=${this.thread.id}, directory=${this.sdkDirectory}`);
3300
+ }
3267
3301
  // Insert DB row immediately so the external-sync poller sees
3268
3302
  // source='kimaki' before the next poll tick and skips this session.
3269
3303
  // The upsert at the end of ensureSession is kept for the reuse path.
@@ -3279,7 +3313,7 @@ export class ThreadSessionRuntime {
3279
3313
  createdNewSession = true;
3280
3314
  }
3281
3315
  if (!session) {
3282
- return new Error('Failed to create or get session');
3316
+ return new Error(`Failed to create or get session: threadId=${this.thread.id}, channelId=${this.channelId}, directory=${directory}, sdkDirectory=${this.sdkDirectory}, existingSessionId=${sessionId ?? 'none'}, createdNewSession=${createdNewSession}`);
3283
3317
  }
3284
3318
  // Store session in DB and thread state
3285
3319
  await setThreadSession(this.thread.id, session.id);
package/dist/store.js CHANGED
@@ -11,6 +11,7 @@ export const store = createStore(() => ({
11
11
  critiqueEnabled: true,
12
12
  enabledSkills: [],
13
13
  disabledSkills: [],
14
+ allowedMentions: ['users'],
14
15
  allowAllUsers: false,
15
16
  syncEnabled: true,
16
17
  discordBaseUrl: 'https://discord.com',
@@ -197,10 +197,35 @@ function createDeterministicMatchers() {
197
197
  partDelaysMs: [0, 100, 0, 0, 0],
198
198
  },
199
199
  };
200
+ // Matcher that keeps the session busy for 2s after the first text part.
201
+ // Used by queue-edit tests to ensure the follow-up message lands in the
202
+ // local queue before the session finishes.
203
+ const slowBusyMatcher = {
204
+ id: 'slow-busy-reply',
205
+ priority: 115,
206
+ when: {
207
+ latestUserTextIncludes: 'SLOW_BUSY_MARKER',
208
+ },
209
+ then: {
210
+ parts: [
211
+ { type: 'stream-start', warnings: [] },
212
+ { type: 'text-start', id: 'slow-busy' },
213
+ { type: 'text-delta', id: 'slow-busy', delta: 'slow-busy-reply' },
214
+ { type: 'text-end', id: 'slow-busy' },
215
+ {
216
+ type: 'finish',
217
+ finishReason: 'stop',
218
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
219
+ },
220
+ ],
221
+ partDelaysMs: [0, 100, 0, 0, 2_000],
222
+ },
223
+ };
200
224
  return [
201
225
  bashCreateFileMatcher,
202
226
  bashCreateFileFollowupMatcher,
203
227
  raceFinalReplyMatcher,
228
+ slowBusyMatcher,
204
229
  hotelSlowMatcher,
205
230
  userReplyMatcher,
206
231
  ];
@@ -640,7 +665,8 @@ e2eTest('thread message queue ordering', () => {
640
665
  --- from: assistant (TestBot)
641
666
  *using deterministic-provider/deterministic-v2*
642
667
  ⬥ running create file
643
- ok
668
+ bash _mkdir -p tmp && printf "created" > tmp/bash-tool-executed.txt_
669
+ ⬥ file created
644
670
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
645
671
  `);
646
672
  expect(fs.existsSync(markerPath)).toBe(true);
@@ -1115,4 +1141,175 @@ e2eTest('thread message queue ordering', () => {
1115
1141
  });
1116
1142
  expect(userNovemberIndex).toBeLessThan(lastBotIndex);
1117
1143
  }, 8_000);
1144
+ test('editing a queued message updates its prompt before dispatch', async () => {
1145
+ // 1. Start a session with a slow matcher (2s busy) to keep it busy.
1146
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
1147
+ content: 'SLOW_BUSY_MARKER Reply with exactly: edit-queue-setup',
1148
+ });
1149
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
1150
+ timeout: 4_000,
1151
+ predicate: (t) => {
1152
+ return t.name === 'SLOW_BUSY_MARKER Reply with exactly: edit-queue-setup';
1153
+ },
1154
+ });
1155
+ const th = discord.thread(thread.id);
1156
+ // 2. While session is busy (race-final has 500ms delay), queue a message
1157
+ // with queue suffix. The queue suffix forces local-queue mode.
1158
+ const queuedMsg = await th.user(TEST_USER_ID).sendMessage({
1159
+ content: 'Reply with exactly: original-queued. queue',
1160
+ });
1161
+ // 3. Verify the message landed in the local queue.
1162
+ const queuedState = await waitForThreadState({
1163
+ threadId: thread.id,
1164
+ predicate: (state) => {
1165
+ return state.queueItems.length > 0;
1166
+ },
1167
+ timeout: 4_000,
1168
+ description: 'queue has item from suffix message',
1169
+ });
1170
+ expect(queuedState.queueItems.length).toBeGreaterThanOrEqual(1);
1171
+ const queueItem = queuedState.queueItems.find((item) => {
1172
+ return item.sourceMessageId === queuedMsg.id;
1173
+ });
1174
+ expect(queueItem).toBeTruthy();
1175
+ expect(queueItem.prompt).toContain('original-queued');
1176
+ // 4. Edit the message while it's still in the queue.
1177
+ await th.user(TEST_USER_ID).editMessage({
1178
+ messageId: queuedMsg.id,
1179
+ content: 'Reply with exactly: edited-queued. queue',
1180
+ });
1181
+ // 5. Verify the queue item was updated.
1182
+ const updatedState = await waitForThreadState({
1183
+ threadId: thread.id,
1184
+ predicate: (state) => {
1185
+ return state.queueItems.some((item) => {
1186
+ return item.prompt.includes('edited-queued');
1187
+ });
1188
+ },
1189
+ timeout: 4_000,
1190
+ description: 'queue item updated after edit',
1191
+ });
1192
+ const updatedItem = updatedState.queueItems.find((item) => {
1193
+ return item.sourceMessageId === queuedMsg.id;
1194
+ });
1195
+ expect(updatedItem).toBeTruthy();
1196
+ expect(updatedItem.prompt).toContain('edited-queued');
1197
+ expect(updatedItem.prompt).not.toContain('original-queued');
1198
+ // 6. Wait for the queue to drain and verify the edited prompt was dispatched.
1199
+ await waitForBotMessageContaining({
1200
+ discord,
1201
+ threadId: thread.id,
1202
+ userId: TEST_USER_ID,
1203
+ text: '» **queue-tester:** Reply with exactly: edited-queued',
1204
+ timeout: 8_000,
1205
+ });
1206
+ await waitForFooterMessage({
1207
+ discord,
1208
+ threadId: thread.id,
1209
+ timeout: 8_000,
1210
+ afterMessageIncludes: 'edited-queued',
1211
+ afterAuthorId: discord.botUserId,
1212
+ });
1213
+ expect(await th.text()).toMatchInlineSnapshot(`
1214
+ "--- from: user (queue-tester)
1215
+ SLOW_BUSY_MARKER Reply with exactly: edit-queue-setup
1216
+ --- from: assistant (TestBot)
1217
+ *using deterministic-provider/deterministic-v2*
1218
+ --- from: user (queue-tester)
1219
+ Reply with exactly: edited-queued. queue
1220
+ --- from: assistant (TestBot)
1221
+ Queued at position 1
1222
+ ⬥ slow-busy-reply
1223
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
1224
+ » **queue-tester:** Reply with exactly: edited-queued
1225
+ ⬥ ok
1226
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
1227
+ `);
1228
+ const finalText = await th.text();
1229
+ expect(finalText).toContain('edited-queued');
1230
+ expect(finalText).not.toContain('original-queued');
1231
+ }, 12_000);
1232
+ test('editing a queued message to remove queue suffix removes it from queue', async () => {
1233
+ // 1. Start a session with a slow matcher (2s busy) to keep it busy.
1234
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
1235
+ content: 'SLOW_BUSY_MARKER Reply with exactly: remove-queue-setup',
1236
+ });
1237
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
1238
+ timeout: 4_000,
1239
+ predicate: (t) => {
1240
+ return t.name === 'SLOW_BUSY_MARKER Reply with exactly: remove-queue-setup';
1241
+ },
1242
+ });
1243
+ const th = discord.thread(thread.id);
1244
+ // 2. Queue a message with queue suffix while session is busy.
1245
+ const queuedMsg = await th.user(TEST_USER_ID).sendMessage({
1246
+ content: 'Reply with exactly: will-be-removed. queue',
1247
+ });
1248
+ // 3. Verify the message is in the queue.
1249
+ await waitForThreadState({
1250
+ threadId: thread.id,
1251
+ predicate: (state) => {
1252
+ return state.queueItems.some((item) => {
1253
+ return item.sourceMessageId === queuedMsg.id;
1254
+ });
1255
+ },
1256
+ timeout: 4_000,
1257
+ description: 'queue has item to be removed',
1258
+ });
1259
+ // 4. Edit the message to remove the queue suffix.
1260
+ await th.user(TEST_USER_ID).editMessage({
1261
+ messageId: queuedMsg.id,
1262
+ content: 'Reply with exactly: will-be-removed',
1263
+ });
1264
+ // 5. Verify the item was removed from the queue.
1265
+ // Poll briefly since the MessageUpdate event is async.
1266
+ for (let i = 0; i < 20; i++) {
1267
+ const state = await waitForThreadState({
1268
+ threadId: thread.id,
1269
+ predicate: () => true,
1270
+ timeout: 100,
1271
+ description: 'check queue after edit',
1272
+ });
1273
+ const stillQueued = state.queueItems.some((item) => {
1274
+ return item.sourceMessageId === queuedMsg.id;
1275
+ });
1276
+ if (!stillQueued)
1277
+ break;
1278
+ await new Promise((r) => setTimeout(r, 50));
1279
+ }
1280
+ const finalState = await waitForThreadState({
1281
+ threadId: thread.id,
1282
+ predicate: () => true,
1283
+ timeout: 100,
1284
+ description: 'final queue check',
1285
+ });
1286
+ const removedItem = finalState.queueItems.find((item) => {
1287
+ return item.sourceMessageId === queuedMsg.id;
1288
+ });
1289
+ expect(removedItem).toBeUndefined();
1290
+ // 6. Wait for the slow session to finish and verify the removed
1291
+ // message was never dispatched as a queue drain (no » indicator).
1292
+ await waitForFooterMessage({
1293
+ discord,
1294
+ threadId: thread.id,
1295
+ timeout: 8_000,
1296
+ });
1297
+ const finalText = await th.text();
1298
+ // The user message text appears in the thread, but the queue dispatch
1299
+ // indicator (» **username:** ...) should NOT appear because the item
1300
+ // was removed from the queue before drain.
1301
+ expect(finalText).not.toContain('» **queue-tester:** Reply with exactly: will-be-removed');
1302
+ expect(finalText).toMatchInlineSnapshot(`
1303
+ "--- from: user (queue-tester)
1304
+ SLOW_BUSY_MARKER Reply with exactly: remove-queue-setup
1305
+ --- from: assistant (TestBot)
1306
+ *using deterministic-provider/deterministic-v2*
1307
+ --- from: user (queue-tester)
1308
+ Reply with exactly: will-be-removed
1309
+ --- from: assistant (TestBot)
1310
+ Queued at position 1
1311
+ ⬥ slow-busy-reply
1312
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
1313
+ `);
1314
+ }, 12_000);
1118
1315
  });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.12.0",
5
+ "version": "0.13.0",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [
@@ -24,10 +24,10 @@
24
24
  "lintcn": "^0.7.1",
25
25
  "tsx": "^4.20.5",
26
26
  "undici": "^8.0.2",
27
- "opencode-deterministic-provider": "^0.0.1",
27
+ "discord-digital-twin": "^0.1.0",
28
28
  "db": "^0.0.0",
29
29
  "opencode-cached-provider": "^0.0.1",
30
- "discord-digital-twin": "^0.1.0"
30
+ "opencode-deterministic-provider": "^0.0.1"
31
31
  },
32
32
  "dependencies": {
33
33
  "@ai-sdk/google": "^3.0.53",
@@ -62,10 +62,10 @@
62
62
  "yaml": "^2.8.3",
63
63
  "zod": "^4.3.6",
64
64
  "zustand": "^5.0.11",
65
- "errore": "^0.14.1",
66
- "opencode-injection-guard": "^0.2.1",
67
65
  "libsqlproxy": "^0.1.0",
68
- "traforo": "^0.6.0"
66
+ "opencode-injection-guard": "^0.2.1",
67
+ "errore": "^0.14.1",
68
+ "traforo": "^0.7.0"
69
69
  },
70
70
  "optionalDependencies": {
71
71
  "@snazzah/davey": "^0.1.10",