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.
- package/dist/btw-prefix-detection.js +13 -15
- package/dist/btw-prefix-detection.test.js +60 -30
- package/dist/cli-runner.js +8 -2
- package/dist/cli.js +5 -0
- package/dist/commands/abort.js +1 -1
- package/dist/commands/model-variant.js +1 -1
- package/dist/commands/model.js +1 -1
- package/dist/commands/restart-opencode-server.js +1 -1
- package/dist/commands/undo-redo.js +2 -2
- package/dist/commands/upgrade.js +1 -2
- package/dist/discord-bot.js +55 -10
- package/dist/message-preprocessing.js +1 -1
- package/dist/opencode-interrupt-plugin.js +14 -2
- package/dist/opencode-interrupt-plugin.test.js +22 -3
- package/dist/queue-advanced-model-switch.e2e.test.js +1 -1
- package/dist/session-handler/agent-utils.js +9 -9
- package/dist/session-handler/thread-runtime-state.js +29 -0
- package/dist/session-handler/thread-session-runtime.js +40 -6
- package/dist/store.js +1 -0
- package/dist/thread-message-queue.e2e.test.js +198 -1
- package/package.json +6 -6
- package/skills/holocron/SKILL.md +432 -0
- package/src/btw-prefix-detection.test.ts +61 -30
- package/src/btw-prefix-detection.ts +15 -19
- package/src/cli-runner.ts +8 -2
- package/src/cli.ts +11 -0
- package/src/commands/abort.ts +1 -1
- package/src/commands/model-variant.ts +1 -1
- package/src/commands/model.ts +1 -1
- package/src/commands/restart-opencode-server.ts +1 -1
- package/src/commands/undo-redo.ts +2 -2
- package/src/commands/upgrade.ts +1 -2
- package/src/discord-bot.ts +65 -9
- package/src/message-preprocessing.ts +1 -1
- package/src/opencode-interrupt-plugin.test.ts +27 -3
- package/src/opencode-interrupt-plugin.ts +15 -3
- package/src/queue-advanced-model-switch.e2e.test.ts +1 -1
- package/src/session-handler/agent-utils.ts +11 -11
- package/src/session-handler/thread-runtime-state.ts +35 -0
- package/src/session-handler/thread-session-runtime.ts +56 -6
- package/src/store.ts +8 -0
- 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 (
|
|
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
|
|
3263
|
-
|
|
3264
|
-
|
|
3287
|
+
const createResult = await errore.tryAsync(() => {
|
|
3288
|
+
return getClient().session.create({
|
|
3289
|
+
directory: this.sdkDirectory,
|
|
3290
|
+
permission: sessionPermissions,
|
|
3291
|
+
});
|
|
3265
3292
|
});
|
|
3266
|
-
|
|
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(
|
|
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
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
"
|
|
27
|
+
"discord-digital-twin": "^0.1.0",
|
|
28
28
|
"db": "^0.0.0",
|
|
29
29
|
"opencode-cached-provider": "^0.0.1",
|
|
30
|
-
"
|
|
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
|
-
"
|
|
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",
|