kimaki 0.4.82 → 0.4.84
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/LICENSE +21 -0
- package/dist/anthropic-auth-plugin.js +7 -0
- package/dist/cli.js +51 -7
- package/dist/commands/abort.js +5 -16
- package/dist/commands/action-buttons.js +3 -3
- package/dist/commands/add-project.js +1 -1
- package/dist/commands/ask-question.js +3 -3
- package/dist/commands/context-usage.js +1 -1
- package/dist/commands/create-new-project.js +1 -1
- package/dist/commands/fork.js +11 -8
- package/dist/commands/merge-worktree.js +1 -1
- package/dist/commands/new-worktree.js +63 -44
- package/dist/commands/remove-project.js +1 -1
- package/dist/commands/resume.js +11 -8
- package/dist/commands/screenshare.js +14 -6
- package/dist/commands/screenshare.test.js +20 -0
- package/dist/commands/session.js +1 -1
- package/dist/commands/undo-redo.js +91 -7
- package/dist/commands/user-command.js +1 -1
- package/dist/config.js +16 -1
- package/dist/database.js +53 -2
- package/dist/db.js +6 -0
- package/dist/discord-bot.js +48 -85
- package/dist/discord-command-registration.js +1 -1
- package/dist/external-opencode-sync.js +515 -0
- package/dist/external-opencode-sync.test.js +151 -0
- package/dist/gateway-proxy.e2e.test.js +8 -5
- package/dist/genai.js +1 -1
- package/dist/generated/enums.js +4 -0
- package/dist/generated/internal/class.js +4 -4
- package/dist/generated/internal/prismaNamespace.js +1 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +1 -0
- package/dist/generated/models/external_session_pending_prompts.js +1 -0
- package/dist/hrana-server.js +14 -285
- package/dist/hrana-server.test.js +4 -2
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +7 -0
- package/dist/kimaki-opencode-plugin.js +2 -0
- package/dist/kitty-graphics-parser.js +3 -0
- package/dist/kitty-graphics-parser.test.js +276 -0
- package/dist/kitty-graphics-plugin.js +3 -0
- package/dist/markdown.js +4 -4
- package/dist/markdown.test.js +1 -1
- package/dist/message-formatting.js +54 -15
- package/dist/onboarding-tutorial.js +1 -1
- package/dist/openai-realtime.js +9 -13
- package/dist/opencode.js +28 -5
- package/dist/queue-advanced-e2e-setup.js +89 -0
- package/dist/queue-advanced-permissions-typing.e2e.test.js +5 -5
- package/dist/queue-advanced-typing.e2e.test.js +9 -22
- package/dist/queue-question-select-drain.e2e.test.js +117 -0
- package/dist/session-handler/event-stream-state.js +101 -7
- package/dist/session-handler/event-stream-state.test.js +7 -3
- package/dist/session-handler/thread-session-runtime.js +120 -9
- package/dist/store.js +1 -0
- package/dist/system-message.js +22 -4
- package/dist/system-message.test.js +19 -0
- package/dist/task-runner.js +1 -1
- package/dist/thread-message-queue.e2e.test.js +8 -14
- package/dist/tools.js +1 -1
- package/dist/undo-redo.e2e.test.js +20 -25
- package/package.json +10 -6
- package/schema.prisma +6 -0
- package/skills/errore/SKILL.md +40 -13
- package/skills/goke/SKILL.md +12 -0
- package/skills/lintcn/SKILL.md +868 -0
- package/skills/npm-package/SKILL.md +1 -0
- package/skills/proxyman/SKILL.md +215 -0
- package/skills/spiceflow/SKILL.md +1 -1
- package/skills/usecomputer/SKILL.md +339 -0
- package/src/ai-tool-to-genai.ts +1 -0
- package/src/anthropic-auth-plugin.ts +7 -0
- package/src/cli.ts +59 -6
- package/src/commands/abort.ts +6 -16
- package/src/commands/action-buttons.ts +5 -1
- package/src/commands/add-project.ts +1 -1
- package/src/commands/ask-question.ts +5 -2
- package/src/commands/context-usage.ts +1 -1
- package/src/commands/create-new-project.ts +1 -1
- package/src/commands/fork.ts +12 -11
- package/src/commands/merge-worktree.ts +1 -1
- package/src/commands/new-worktree.ts +74 -55
- package/src/commands/remove-project.ts +1 -1
- package/src/commands/resume.ts +12 -10
- package/src/commands/screenshare.test.ts +30 -0
- package/src/commands/screenshare.ts +18 -6
- package/src/commands/session.ts +1 -1
- package/src/commands/undo-redo.ts +108 -10
- package/src/commands/user-command.ts +1 -1
- package/src/config.ts +19 -1
- package/src/database.ts +72 -3
- package/src/db.ts +8 -0
- package/src/discord-bot.ts +58 -93
- package/src/discord-command-registration.ts +1 -1
- package/src/external-opencode-sync.ts +729 -0
- package/src/gateway-proxy.e2e.test.ts +9 -5
- package/src/genai.ts +3 -3
- package/src/generated/commonInputTypes.ts +34 -0
- package/src/generated/enums.ts +8 -0
- package/src/generated/internal/class.ts +4 -4
- package/src/generated/internal/prismaNamespace.ts +8 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +1 -0
- package/src/generated/models/thread_sessions.ts +53 -1
- package/src/hrana-server.test.ts +8 -2
- package/src/hrana-server.ts +18 -390
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +7 -0
- package/src/kimaki-opencode-plugin.ts +2 -0
- package/src/markdown.test.ts +1 -1
- package/src/markdown.ts +4 -4
- package/src/message-formatting.ts +66 -17
- package/src/onboarding-tutorial.ts +1 -1
- package/src/openai-realtime.ts +6 -10
- package/src/opencode.ts +31 -7
- package/src/queue-advanced-e2e-setup.ts +92 -0
- package/src/queue-advanced-permissions-typing.e2e.test.ts +5 -5
- package/src/queue-advanced-typing.e2e.test.ts +9 -22
- package/src/queue-question-select-drain.e2e.test.ts +149 -0
- package/src/schema.sql +1 -0
- package/src/session-handler/event-stream-state.test.ts +7 -2
- package/src/session-handler/event-stream-state.ts +128 -7
- package/src/session-handler/thread-runtime-state.ts +5 -0
- package/src/session-handler/thread-session-runtime.ts +153 -11
- package/src/store.ts +8 -0
- package/src/system-message.ts +27 -4
- package/src/task-runner.ts +1 -1
- package/src/thread-message-queue.e2e.test.ts +8 -14
- package/src/tools.ts +1 -1
- package/src/undo-redo.e2e.test.ts +28 -26
- package/skills/jitter/node_modules/.bin/esbuild +0 -21
- package/skills/jitter/node_modules/.bin/tsc +0 -21
- package/skills/jitter/node_modules/.bin/tsserver +0 -21
- package/skills/jitter/node_modules/typescript/LICENSE.txt +0 -55
- package/skills/jitter/node_modules/typescript/README.md +0 -50
- package/skills/jitter/node_modules/typescript/SECURITY.md +0 -41
- package/skills/jitter/node_modules/typescript/ThirdPartyNoticeText.txt +0 -193
- package/skills/jitter/node_modules/typescript/bin/tsc +0 -2
- package/skills/jitter/node_modules/typescript/bin/tsserver +0 -2
- package/skills/jitter/node_modules/typescript/lib/_tsc.js +0 -133792
- package/skills/jitter/node_modules/typescript/lib/_tsserver.js +0 -659
- package/skills/jitter/node_modules/typescript/lib/_typingsInstaller.js +0 -222
- package/skills/jitter/node_modules/typescript/lib/cs/diagnosticMessages.generated.json +0 -2122
- package/skills/jitter/node_modules/typescript/lib/de/diagnosticMessages.generated.json +0 -2122
- package/skills/jitter/node_modules/typescript/lib/es/diagnosticMessages.generated.json +0 -2122
- package/skills/jitter/node_modules/typescript/lib/fr/diagnosticMessages.generated.json +0 -2122
- package/skills/jitter/node_modules/typescript/lib/it/diagnosticMessages.generated.json +0 -2122
- package/skills/jitter/node_modules/typescript/lib/ja/diagnosticMessages.generated.json +0 -2122
- package/skills/jitter/node_modules/typescript/lib/ko/diagnosticMessages.generated.json +0 -2122
- package/skills/jitter/node_modules/typescript/lib/lib.d.ts +0 -22
- package/skills/jitter/node_modules/typescript/lib/lib.decorators.d.ts +0 -384
- package/skills/jitter/node_modules/typescript/lib/lib.decorators.legacy.d.ts +0 -22
- package/skills/jitter/node_modules/typescript/lib/lib.dom.asynciterable.d.ts +0 -41
- package/skills/jitter/node_modules/typescript/lib/lib.dom.d.ts +0 -39429
- package/skills/jitter/node_modules/typescript/lib/lib.dom.iterable.d.ts +0 -571
- package/skills/jitter/node_modules/typescript/lib/lib.es2015.collection.d.ts +0 -147
- package/skills/jitter/node_modules/typescript/lib/lib.es2015.core.d.ts +0 -597
- package/skills/jitter/node_modules/typescript/lib/lib.es2015.d.ts +0 -28
- package/skills/jitter/node_modules/typescript/lib/lib.es2015.generator.d.ts +0 -77
- package/skills/jitter/node_modules/typescript/lib/lib.es2015.iterable.d.ts +0 -605
- package/skills/jitter/node_modules/typescript/lib/lib.es2015.promise.d.ts +0 -81
- package/skills/jitter/node_modules/typescript/lib/lib.es2015.proxy.d.ts +0 -128
- package/skills/jitter/node_modules/typescript/lib/lib.es2015.reflect.d.ts +0 -144
- package/skills/jitter/node_modules/typescript/lib/lib.es2015.symbol.d.ts +0 -46
- package/skills/jitter/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts +0 -326
- package/skills/jitter/node_modules/typescript/lib/lib.es2016.array.include.d.ts +0 -116
- package/skills/jitter/node_modules/typescript/lib/lib.es2016.d.ts +0 -21
- package/skills/jitter/node_modules/typescript/lib/lib.es2016.full.d.ts +0 -23
- package/skills/jitter/node_modules/typescript/lib/lib.es2016.intl.d.ts +0 -31
- package/skills/jitter/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts +0 -21
- package/skills/jitter/node_modules/typescript/lib/lib.es2017.d.ts +0 -26
- package/skills/jitter/node_modules/typescript/lib/lib.es2017.date.d.ts +0 -31
- package/skills/jitter/node_modules/typescript/lib/lib.es2017.full.d.ts +0 -23
- package/skills/jitter/node_modules/typescript/lib/lib.es2017.intl.d.ts +0 -44
- package/skills/jitter/node_modules/typescript/lib/lib.es2017.object.d.ts +0 -49
- package/skills/jitter/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts +0 -135
- package/skills/jitter/node_modules/typescript/lib/lib.es2017.string.d.ts +0 -45
- package/skills/jitter/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts +0 -53
- package/skills/jitter/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts +0 -77
- package/skills/jitter/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts +0 -53
- package/skills/jitter/node_modules/typescript/lib/lib.es2018.d.ts +0 -24
- package/skills/jitter/node_modules/typescript/lib/lib.es2018.full.d.ts +0 -24
- package/skills/jitter/node_modules/typescript/lib/lib.es2018.intl.d.ts +0 -83
- package/skills/jitter/node_modules/typescript/lib/lib.es2018.promise.d.ts +0 -30
- package/skills/jitter/node_modules/typescript/lib/lib.es2018.regexp.d.ts +0 -37
- package/skills/jitter/node_modules/typescript/lib/lib.es2019.array.d.ts +0 -79
- package/skills/jitter/node_modules/typescript/lib/lib.es2019.d.ts +0 -24
- package/skills/jitter/node_modules/typescript/lib/lib.es2019.full.d.ts +0 -24
- package/skills/jitter/node_modules/typescript/lib/lib.es2019.intl.d.ts +0 -23
- package/skills/jitter/node_modules/typescript/lib/lib.es2019.object.d.ts +0 -33
- package/skills/jitter/node_modules/typescript/lib/lib.es2019.string.d.ts +0 -37
- package/skills/jitter/node_modules/typescript/lib/lib.es2019.symbol.d.ts +0 -24
- package/skills/jitter/node_modules/typescript/lib/lib.es2020.bigint.d.ts +0 -765
- package/skills/jitter/node_modules/typescript/lib/lib.es2020.d.ts +0 -27
- package/skills/jitter/node_modules/typescript/lib/lib.es2020.date.d.ts +0 -42
- package/skills/jitter/node_modules/typescript/lib/lib.es2020.full.d.ts +0 -24
- package/skills/jitter/node_modules/typescript/lib/lib.es2020.intl.d.ts +0 -474
- package/skills/jitter/node_modules/typescript/lib/lib.es2020.number.d.ts +0 -28
- package/skills/jitter/node_modules/typescript/lib/lib.es2020.promise.d.ts +0 -47
- package/skills/jitter/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts +0 -99
- package/skills/jitter/node_modules/typescript/lib/lib.es2020.string.d.ts +0 -44
- package/skills/jitter/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts +0 -41
- package/skills/jitter/node_modules/typescript/lib/lib.es2021.d.ts +0 -23
- package/skills/jitter/node_modules/typescript/lib/lib.es2021.full.d.ts +0 -24
- package/skills/jitter/node_modules/typescript/lib/lib.es2021.intl.d.ts +0 -166
- package/skills/jitter/node_modules/typescript/lib/lib.es2021.promise.d.ts +0 -48
- package/skills/jitter/node_modules/typescript/lib/lib.es2021.string.d.ts +0 -33
- package/skills/jitter/node_modules/typescript/lib/lib.es2021.weakref.d.ts +0 -78
- package/skills/jitter/node_modules/typescript/lib/lib.es2022.array.d.ts +0 -121
- package/skills/jitter/node_modules/typescript/lib/lib.es2022.d.ts +0 -25
- package/skills/jitter/node_modules/typescript/lib/lib.es2022.error.d.ts +0 -75
- package/skills/jitter/node_modules/typescript/lib/lib.es2022.full.d.ts +0 -24
- package/skills/jitter/node_modules/typescript/lib/lib.es2022.intl.d.ts +0 -145
- package/skills/jitter/node_modules/typescript/lib/lib.es2022.object.d.ts +0 -26
- package/skills/jitter/node_modules/typescript/lib/lib.es2022.regexp.d.ts +0 -39
- package/skills/jitter/node_modules/typescript/lib/lib.es2022.string.d.ts +0 -25
- package/skills/jitter/node_modules/typescript/lib/lib.es2023.array.d.ts +0 -924
- package/skills/jitter/node_modules/typescript/lib/lib.es2023.collection.d.ts +0 -21
- package/skills/jitter/node_modules/typescript/lib/lib.es2023.d.ts +0 -22
- package/skills/jitter/node_modules/typescript/lib/lib.es2023.full.d.ts +0 -24
- package/skills/jitter/node_modules/typescript/lib/lib.es2023.intl.d.ts +0 -56
- package/skills/jitter/node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts +0 -65
- package/skills/jitter/node_modules/typescript/lib/lib.es2024.collection.d.ts +0 -29
- package/skills/jitter/node_modules/typescript/lib/lib.es2024.d.ts +0 -26
- package/skills/jitter/node_modules/typescript/lib/lib.es2024.full.d.ts +0 -24
- package/skills/jitter/node_modules/typescript/lib/lib.es2024.object.d.ts +0 -29
- package/skills/jitter/node_modules/typescript/lib/lib.es2024.promise.d.ts +0 -35
- package/skills/jitter/node_modules/typescript/lib/lib.es2024.regexp.d.ts +0 -25
- package/skills/jitter/node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts +0 -68
- package/skills/jitter/node_modules/typescript/lib/lib.es2024.string.d.ts +0 -29
- package/skills/jitter/node_modules/typescript/lib/lib.es5.d.ts +0 -4601
- package/skills/jitter/node_modules/typescript/lib/lib.es6.d.ts +0 -23
- package/skills/jitter/node_modules/typescript/lib/lib.esnext.array.d.ts +0 -35
- package/skills/jitter/node_modules/typescript/lib/lib.esnext.collection.d.ts +0 -96
- package/skills/jitter/node_modules/typescript/lib/lib.esnext.d.ts +0 -29
- package/skills/jitter/node_modules/typescript/lib/lib.esnext.decorators.d.ts +0 -28
- package/skills/jitter/node_modules/typescript/lib/lib.esnext.disposable.d.ts +0 -193
- package/skills/jitter/node_modules/typescript/lib/lib.esnext.error.d.ts +0 -24
- package/skills/jitter/node_modules/typescript/lib/lib.esnext.float16.d.ts +0 -443
- package/skills/jitter/node_modules/typescript/lib/lib.esnext.full.d.ts +0 -24
- package/skills/jitter/node_modules/typescript/lib/lib.esnext.intl.d.ts +0 -21
- package/skills/jitter/node_modules/typescript/lib/lib.esnext.iterator.d.ts +0 -148
- package/skills/jitter/node_modules/typescript/lib/lib.esnext.promise.d.ts +0 -34
- package/skills/jitter/node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts +0 -25
- package/skills/jitter/node_modules/typescript/lib/lib.scripthost.d.ts +0 -322
- package/skills/jitter/node_modules/typescript/lib/lib.webworker.asynciterable.d.ts +0 -41
- package/skills/jitter/node_modules/typescript/lib/lib.webworker.d.ts +0 -13150
- package/skills/jitter/node_modules/typescript/lib/lib.webworker.importscripts.d.ts +0 -23
- package/skills/jitter/node_modules/typescript/lib/lib.webworker.iterable.d.ts +0 -340
- package/skills/jitter/node_modules/typescript/lib/pl/diagnosticMessages.generated.json +0 -2122
- package/skills/jitter/node_modules/typescript/lib/pt-br/diagnosticMessages.generated.json +0 -2122
- package/skills/jitter/node_modules/typescript/lib/ru/diagnosticMessages.generated.json +0 -2122
- package/skills/jitter/node_modules/typescript/lib/tr/diagnosticMessages.generated.json +0 -2122
- package/skills/jitter/node_modules/typescript/lib/tsc.js +0 -8
- package/skills/jitter/node_modules/typescript/lib/tsserver.js +0 -8
- package/skills/jitter/node_modules/typescript/lib/tsserverlibrary.d.ts +0 -17
- package/skills/jitter/node_modules/typescript/lib/tsserverlibrary.js +0 -21
- package/skills/jitter/node_modules/typescript/lib/typesMap.json +0 -497
- package/skills/jitter/node_modules/typescript/lib/typescript.d.ts +0 -11438
- package/skills/jitter/node_modules/typescript/lib/typescript.js +0 -200253
- package/skills/jitter/node_modules/typescript/lib/typingsInstaller.js +0 -8
- package/skills/jitter/node_modules/typescript/lib/watchGuard.js +0 -53
- package/skills/jitter/node_modules/typescript/lib/zh-cn/diagnosticMessages.generated.json +0 -2122
- package/skills/jitter/node_modules/typescript/lib/zh-tw/diagnosticMessages.generated.json +0 -2122
- package/skills/jitter/node_modules/typescript/node_modules/.bin/tsc +0 -21
- package/skills/jitter/node_modules/typescript/node_modules/.bin/tsserver +0 -21
- package/skills/jitter/node_modules/typescript/package.json +0 -120
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { ChannelType, ThreadAutoArchiveDuration, } from 'discord.js';
|
|
3
|
+
import { getChannelVerbosity, getPartMessageIds, getThreadIdBySessionId, getThreadSessionSource, listTrackedTextChannels, setPartMessagesBatch, upsertThreadSession, } from './database.js';
|
|
4
|
+
import { sendThreadMessage } from './discord-utils.js';
|
|
5
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
6
|
+
import { formatPart, collectSessionChunks, batchChunksForDiscord, } from './message-formatting.js';
|
|
7
|
+
import { initializeOpencodeForDirectory, } from './opencode.js';
|
|
8
|
+
import { isEssentialToolPart } from './session-handler/thread-session-runtime.js';
|
|
9
|
+
import { notifyError } from './sentry.js';
|
|
10
|
+
import { extractNonXmlContent } from './xml.js';
|
|
11
|
+
const logger = createLogger(LogPrefix.OPENCODE);
|
|
12
|
+
const EXTERNAL_SYNC_INTERVAL_MS = 5_000;
|
|
13
|
+
// Don't sync sessions from before the CLI started. 5 min grace window
|
|
14
|
+
// covers sessions that were just created before the bot connected.
|
|
15
|
+
const CLI_START_MS = Date.now() - 5 * 60 * 1000;
|
|
16
|
+
let externalSyncInterval = null;
|
|
17
|
+
function isSyntheticTextPart(part) {
|
|
18
|
+
const candidate = part;
|
|
19
|
+
return candidate.synthetic === true;
|
|
20
|
+
}
|
|
21
|
+
function parseDiscordOriginMetadata(text) {
|
|
22
|
+
const match = text.match(/^<discord-user\s+([^>]+)\s*\/>$/);
|
|
23
|
+
if (!match?.[1]) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const attrs = [...match[1].matchAll(/([a-z-]+)="([^"]*)"/g)].reduce((acc, current) => {
|
|
27
|
+
const [, key, value] = current;
|
|
28
|
+
if (!key) {
|
|
29
|
+
return acc;
|
|
30
|
+
}
|
|
31
|
+
acc[key] = value || '';
|
|
32
|
+
return acc;
|
|
33
|
+
}, {});
|
|
34
|
+
const username = attrs['name'];
|
|
35
|
+
if (!username) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
messageId: attrs['message-id'] || undefined,
|
|
40
|
+
username,
|
|
41
|
+
threadId: attrs['thread-id'] || undefined,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function getDiscordOriginMetadataFromMessage({ message, }) {
|
|
45
|
+
const syntheticTexts = message.parts.flatMap((part) => {
|
|
46
|
+
if (part.type !== 'text') {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
if (!isSyntheticTextPart(part)) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
return [part.text || ''];
|
|
53
|
+
});
|
|
54
|
+
for (const text of syntheticTexts) {
|
|
55
|
+
const metadata = parseDiscordOriginMetadata(text);
|
|
56
|
+
if (metadata) {
|
|
57
|
+
return metadata;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
function getRenderableUserTextParts({ message, }) {
|
|
63
|
+
if (message.info.role !== 'user') {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
return message.parts.flatMap((part) => {
|
|
67
|
+
if (part.type !== 'text') {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
if (isSyntheticTextPart(part)) {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
const cleanedText = extractNonXmlContent(part.text || '').trim();
|
|
74
|
+
if (!cleanedText) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
return [{ id: part.id, text: cleanedText }];
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function getExternalUserMirrorText({ username, prompt, }) {
|
|
81
|
+
return `» **${username}:** ${prompt.slice(0, 1000)}${prompt.length > 1000 ? '...' : ''}`;
|
|
82
|
+
}
|
|
83
|
+
// Pure derivation: is the latest user turn from Discord?
|
|
84
|
+
// Checks the newest user message with renderable text for a <discord-user />
|
|
85
|
+
// synthetic part. If present, the session is currently driven from Discord
|
|
86
|
+
// (kimaki manages it) and external sync should skip it. If absent (CLI/TUI),
|
|
87
|
+
// external sync should mirror it — this naturally handles the "reclaim" case
|
|
88
|
+
// (external → discord → external) without any DB source toggling.
|
|
89
|
+
function isLatestUserTurnFromDiscord({ messages, }) {
|
|
90
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
91
|
+
const message = messages[i];
|
|
92
|
+
if (message.info.role !== 'user') {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const renderableParts = getRenderableUserTextParts({ message });
|
|
96
|
+
if (renderableParts.length === 0) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// Found the latest user message with actual text content.
|
|
100
|
+
// If it has <discord-user /> origin metadata, it came from Discord.
|
|
101
|
+
return getDiscordOriginMetadataFromMessage({ message }) !== null;
|
|
102
|
+
}
|
|
103
|
+
// No user messages with text — treat as external (allow sync).
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
function shouldMirrorAssistantPart({ part, verbosity, }) {
|
|
107
|
+
if (verbosity === 'text_only') {
|
|
108
|
+
return part.type === 'text';
|
|
109
|
+
}
|
|
110
|
+
if (verbosity === 'text_and_essential_tools') {
|
|
111
|
+
if (part.type === 'text') {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
return isEssentialToolPart(part);
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
function getSessionThreadName({ sessionTitle, messages, }) {
|
|
119
|
+
const normalizedTitle = sessionTitle?.trim();
|
|
120
|
+
if (normalizedTitle) {
|
|
121
|
+
return normalizedTitle.slice(0, 100);
|
|
122
|
+
}
|
|
123
|
+
const firstUserMessage = messages.find((message) => {
|
|
124
|
+
return message.info.role === 'user';
|
|
125
|
+
});
|
|
126
|
+
const firstUserText = firstUserMessage
|
|
127
|
+
? getRenderableUserTextParts({ message: firstUserMessage })
|
|
128
|
+
.map((part) => {
|
|
129
|
+
return part.text;
|
|
130
|
+
})
|
|
131
|
+
.join(' ')
|
|
132
|
+
.trim()
|
|
133
|
+
: '';
|
|
134
|
+
if (firstUserText) {
|
|
135
|
+
return firstUserText.slice(0, 100);
|
|
136
|
+
}
|
|
137
|
+
return 'opencode session';
|
|
138
|
+
}
|
|
139
|
+
function getSessionRecencyTimestamp(session) {
|
|
140
|
+
return session.time.updated || session.time.created || 0;
|
|
141
|
+
}
|
|
142
|
+
function sortSessionsByRecency(sessions) {
|
|
143
|
+
return [...sessions].sort((left, right) => {
|
|
144
|
+
return getSessionRecencyTimestamp(right) - getSessionRecencyTimestamp(left);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
function groupTrackedChannelsByDirectory(trackedChannels) {
|
|
148
|
+
const grouped = trackedChannels.reduce((acc, channel) => {
|
|
149
|
+
const existing = acc.get(channel.directory);
|
|
150
|
+
const createdAtMs = Math.max(channel.created_at?.getTime() || 0, CLI_START_MS);
|
|
151
|
+
if (!existing) {
|
|
152
|
+
acc.set(channel.directory, {
|
|
153
|
+
directory: channel.directory,
|
|
154
|
+
channelId: channel.channel_id,
|
|
155
|
+
startMs: createdAtMs,
|
|
156
|
+
});
|
|
157
|
+
return acc;
|
|
158
|
+
}
|
|
159
|
+
if (createdAtMs < existing.startMs) {
|
|
160
|
+
acc.set(channel.directory, {
|
|
161
|
+
directory: channel.directory,
|
|
162
|
+
channelId: channel.channel_id,
|
|
163
|
+
startMs: createdAtMs,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return acc;
|
|
167
|
+
}, new Map());
|
|
168
|
+
return [...grouped.values()];
|
|
169
|
+
}
|
|
170
|
+
async function ensureExternalSessionThread({ discordClient, channelId, sessionId, sessionTitle, messages, }) {
|
|
171
|
+
const existingThreadId = await getThreadIdBySessionId(sessionId);
|
|
172
|
+
if (existingThreadId) {
|
|
173
|
+
// Caller already verified via isLatestUserTurnFromDiscord that this
|
|
174
|
+
// session should be synced. If the thread was kimaki-owned, flip it
|
|
175
|
+
// to external_poll so typing and future polls work naturally.
|
|
176
|
+
const existingSource = await getThreadSessionSource(existingThreadId);
|
|
177
|
+
if (existingSource === 'kimaki') {
|
|
178
|
+
await upsertThreadSession({
|
|
179
|
+
threadId: existingThreadId,
|
|
180
|
+
sessionId,
|
|
181
|
+
source: 'external_poll',
|
|
182
|
+
});
|
|
183
|
+
logger.log(`[EXTERNAL_SYNC] Reclaimed thread ${existingThreadId} for session ${sessionId} (user resumed from OpenCode)`);
|
|
184
|
+
}
|
|
185
|
+
const existingThread = await discordClient.channels.fetch(existingThreadId).catch((error) => {
|
|
186
|
+
return new Error(`Failed to fetch thread ${existingThreadId}`, {
|
|
187
|
+
cause: error,
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
if (!(existingThread instanceof Error) && existingThread?.isThread()) {
|
|
191
|
+
return existingThread;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const parentChannel = await discordClient.channels.fetch(channelId).catch((error) => {
|
|
195
|
+
return new Error(`Failed to fetch parent channel ${channelId}`, {
|
|
196
|
+
cause: error,
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
if (parentChannel instanceof Error) {
|
|
200
|
+
return parentChannel;
|
|
201
|
+
}
|
|
202
|
+
if (!parentChannel || parentChannel.type !== ChannelType.GuildText) {
|
|
203
|
+
return new Error(`Channel ${channelId} is not a text channel`);
|
|
204
|
+
}
|
|
205
|
+
const threadName = 'Sync: ' + getSessionThreadName({ sessionTitle, messages });
|
|
206
|
+
const thread = await parentChannel.threads.create({
|
|
207
|
+
name: threadName.slice(0, 100),
|
|
208
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
209
|
+
reason: `Sync external OpenCode session ${sessionId}`,
|
|
210
|
+
}).catch((error) => {
|
|
211
|
+
return new Error(`Failed to create thread for session ${sessionId}`, {
|
|
212
|
+
cause: error,
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
if (thread instanceof Error) {
|
|
216
|
+
return thread;
|
|
217
|
+
}
|
|
218
|
+
await upsertThreadSession({
|
|
219
|
+
threadId: thread.id,
|
|
220
|
+
sessionId,
|
|
221
|
+
source: 'external_poll',
|
|
222
|
+
});
|
|
223
|
+
return thread;
|
|
224
|
+
}
|
|
225
|
+
// Collect all unsynced parts from all messages into SessionChunks.
|
|
226
|
+
// User messages that originated from this Discord thread are returned as
|
|
227
|
+
// directMappings (persisted without sending a Discord message). All other
|
|
228
|
+
// user and assistant parts are returned as chunks to send.
|
|
229
|
+
function collectUnsyncedChunks({ messages, syncedPartIds, verbosity, thread, }) {
|
|
230
|
+
const chunks = [];
|
|
231
|
+
const directMappings = [];
|
|
232
|
+
for (const message of messages) {
|
|
233
|
+
if (message.info.role === 'user') {
|
|
234
|
+
const renderableParts = getRenderableUserTextParts({ message });
|
|
235
|
+
const unsyncedParts = renderableParts.filter((p) => {
|
|
236
|
+
return !syncedPartIds.has(p.id);
|
|
237
|
+
});
|
|
238
|
+
if (unsyncedParts.length === 0) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
// If the user message came from this Discord thread, skip mirroring
|
|
242
|
+
// — it's already visible. When message-id is available, record a
|
|
243
|
+
// direct mapping for part dedup. When it's missing (sourceMessageId
|
|
244
|
+
// is optional in IngressInput), just mark parts as synced.
|
|
245
|
+
const discordOrigin = getDiscordOriginMetadataFromMessage({ message });
|
|
246
|
+
if (discordOrigin && (!discordOrigin.threadId || discordOrigin.threadId === thread.id)) {
|
|
247
|
+
unsyncedParts.forEach((part) => {
|
|
248
|
+
directMappings.push({
|
|
249
|
+
partId: part.id,
|
|
250
|
+
messageId: discordOrigin.messageId || '',
|
|
251
|
+
threadId: thread.id,
|
|
252
|
+
});
|
|
253
|
+
syncedPartIds.add(part.id);
|
|
254
|
+
});
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const promptText = unsyncedParts.map((p) => {
|
|
258
|
+
return p.text;
|
|
259
|
+
}).join('\n\n');
|
|
260
|
+
chunks.push({
|
|
261
|
+
partIds: unsyncedParts.map((p) => {
|
|
262
|
+
return p.id;
|
|
263
|
+
}),
|
|
264
|
+
content: getExternalUserMirrorText({ username: 'user', prompt: promptText }),
|
|
265
|
+
});
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (message.info.role !== 'assistant') {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
// Filter assistant parts by verbosity before passing to shared collector
|
|
272
|
+
const filteredParts = message.parts.filter((part) => {
|
|
273
|
+
return shouldMirrorAssistantPart({ part, verbosity });
|
|
274
|
+
});
|
|
275
|
+
const { chunks: assistantChunks } = collectSessionChunks({
|
|
276
|
+
messages: [{ info: message.info, parts: filteredParts }],
|
|
277
|
+
skipPartIds: syncedPartIds,
|
|
278
|
+
});
|
|
279
|
+
// Mark empty-content parts as synced (collectSessionChunks skips them)
|
|
280
|
+
for (const part of filteredParts) {
|
|
281
|
+
if (!syncedPartIds.has(part.id)) {
|
|
282
|
+
const content = formatPart(part);
|
|
283
|
+
if (!content.trim()) {
|
|
284
|
+
syncedPartIds.add(part.id);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
chunks.push(...assistantChunks);
|
|
289
|
+
}
|
|
290
|
+
return { chunks, directMappings };
|
|
291
|
+
}
|
|
292
|
+
async function syncSessionToThread({ client, discordClient, directory, channelId, sessionId, sessionTitle, }) {
|
|
293
|
+
const messagesResponse = await client.session.messages({
|
|
294
|
+
sessionID: sessionId,
|
|
295
|
+
directory,
|
|
296
|
+
}).catch((error) => {
|
|
297
|
+
return new Error(`Failed to fetch messages for session ${sessionId}`, {
|
|
298
|
+
cause: error,
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
if (messagesResponse instanceof Error) {
|
|
302
|
+
throw messagesResponse;
|
|
303
|
+
}
|
|
304
|
+
const messages = messagesResponse.data || [];
|
|
305
|
+
// Pure derivation from opencode events: if the latest user turn has
|
|
306
|
+
// <discord-user /> metadata, kimaki's thread runtime owns this session.
|
|
307
|
+
// Skip external sync entirely. When the user resumes from CLI/TUI the
|
|
308
|
+
// latest user turn will lack the tag, so sync picks it up naturally.
|
|
309
|
+
if (isLatestUserTurnFromDiscord({ messages })) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const thread = await ensureExternalSessionThread({
|
|
313
|
+
discordClient,
|
|
314
|
+
channelId,
|
|
315
|
+
sessionId,
|
|
316
|
+
sessionTitle,
|
|
317
|
+
messages,
|
|
318
|
+
});
|
|
319
|
+
if (thread === null) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (thread instanceof Error) {
|
|
323
|
+
throw thread;
|
|
324
|
+
}
|
|
325
|
+
const [existingPartIds, verbosity] = await Promise.all([
|
|
326
|
+
getPartMessageIds(thread.id),
|
|
327
|
+
getChannelVerbosity(thread.parentId || thread.id),
|
|
328
|
+
]);
|
|
329
|
+
const syncedPartIds = new Set(existingPartIds);
|
|
330
|
+
const { chunks, directMappings } = collectUnsyncedChunks({ messages, syncedPartIds, verbosity, thread });
|
|
331
|
+
// Persist mappings for user parts that originated from this Discord thread
|
|
332
|
+
if (directMappings.length > 0) {
|
|
333
|
+
await setPartMessagesBatch(directMappings);
|
|
334
|
+
}
|
|
335
|
+
const batched = batchChunksForDiscord(chunks);
|
|
336
|
+
for (const batch of batched) {
|
|
337
|
+
const sentMessage = await sendThreadMessage(thread, batch.content);
|
|
338
|
+
await setPartMessagesBatch(batch.partIds.map((partId) => ({
|
|
339
|
+
partId,
|
|
340
|
+
messageId: sentMessage.id,
|
|
341
|
+
threadId: thread.id,
|
|
342
|
+
})));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// Pulse typing indicator for sessions that are currently busy.
|
|
346
|
+
// Takes the global session statuses map (already fetched) and sends
|
|
347
|
+
// typing to threads whose session is busy and still managed by external_poll.
|
|
348
|
+
async function pulseTypingForBusySessions({ discordClient, statuses, }) {
|
|
349
|
+
for (const [sessionId, status] of Object.entries(statuses)) {
|
|
350
|
+
if (status.type !== 'busy') {
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
const threadId = await getThreadIdBySessionId(sessionId);
|
|
354
|
+
if (!threadId) {
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
// Skip sessions already managed by the runtime (source='kimaki')
|
|
358
|
+
const source = await getThreadSessionSource(threadId);
|
|
359
|
+
if (source && source !== 'external_poll') {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const thread = await discordClient.channels.fetch(threadId).catch(() => {
|
|
363
|
+
return null;
|
|
364
|
+
});
|
|
365
|
+
if (thread?.isThread()) {
|
|
366
|
+
await thread.sendTyping().catch(() => { });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Use experimental.session.list (global, all directories) to reduce from
|
|
371
|
+
// N*2 HTTP calls to 1 global list + per-active-directory status calls.
|
|
372
|
+
async function pollExternalSessions({ discordClient, }) {
|
|
373
|
+
const trackedChannels = await listTrackedTextChannels();
|
|
374
|
+
const directoryTargets = groupTrackedChannelsByDirectory(trackedChannels)
|
|
375
|
+
.filter((t) => {
|
|
376
|
+
return fs.existsSync(t.directory);
|
|
377
|
+
});
|
|
378
|
+
if (directoryTargets.length === 0) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
// Build a lookup: directory → { channelId, startMs }
|
|
382
|
+
const directoryMap = new Map();
|
|
383
|
+
for (const target of directoryTargets) {
|
|
384
|
+
directoryMap.set(target.directory, {
|
|
385
|
+
channelId: target.channelId,
|
|
386
|
+
startMs: target.startMs,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
// Use earliest startMs across all directories for the global query
|
|
390
|
+
const globalStartMs = Math.min(...directoryTargets.map((t) => {
|
|
391
|
+
return t.startMs;
|
|
392
|
+
}));
|
|
393
|
+
// Get one opencode client — try each existing directory until one succeeds
|
|
394
|
+
let client;
|
|
395
|
+
for (const target of directoryTargets) {
|
|
396
|
+
const result = await initializeOpencodeForDirectory(target.directory, {
|
|
397
|
+
channelId: target.channelId,
|
|
398
|
+
});
|
|
399
|
+
if (!(result instanceof Error)) {
|
|
400
|
+
client = result();
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (!client) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
// One global API call for all sessions across all directories.
|
|
408
|
+
// Results are sorted by most recently updated, so a fixed limit of 50
|
|
409
|
+
// is enough — we always get the most active sessions first.
|
|
410
|
+
const sessionsResponse = await client.experimental.session.list({
|
|
411
|
+
roots: true,
|
|
412
|
+
start: globalStartMs,
|
|
413
|
+
limit: 50,
|
|
414
|
+
}).catch((error) => {
|
|
415
|
+
return new Error('Failed to list global sessions', { cause: error });
|
|
416
|
+
});
|
|
417
|
+
if (sessionsResponse instanceof Error) {
|
|
418
|
+
logger.warn(`[EXTERNAL_SYNC] ${sessionsResponse.message}`);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const allSessions = sessionsResponse.data || [];
|
|
422
|
+
// Group sessions by directory, filtering to tracked directories only
|
|
423
|
+
const sessionsByDirectory = new Map();
|
|
424
|
+
for (const session of allSessions) {
|
|
425
|
+
const target = directoryMap.get(session.directory);
|
|
426
|
+
if (!target) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
// Filter by per-directory startMs (time.updated or time.created)
|
|
430
|
+
if ((session.time.updated || session.time.created || 0) < target.startMs) {
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
// Skip sessions whose title hasn't been generated yet
|
|
434
|
+
if (/^new session\s*-/i.test(session.title || '')) {
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const existing = sessionsByDirectory.get(session.directory) || [];
|
|
438
|
+
existing.push(session);
|
|
439
|
+
sessionsByDirectory.set(session.directory, existing);
|
|
440
|
+
}
|
|
441
|
+
// Fetch session.status() only for directories that have sessions to sync.
|
|
442
|
+
// session.status() is instance-scoped (uses x-opencode-directory header),
|
|
443
|
+
// so we must call it per directory — but only for active ones, not all 30+.
|
|
444
|
+
const activeDirectories = [...sessionsByDirectory.keys()];
|
|
445
|
+
const statusResults = await Promise.all(activeDirectories.map(async (directory) => {
|
|
446
|
+
const res = await client.session.status({ directory }).catch(() => {
|
|
447
|
+
return null;
|
|
448
|
+
});
|
|
449
|
+
return res?.data ? Object.entries(res.data) : [];
|
|
450
|
+
}));
|
|
451
|
+
const mergedStatuses = Object.fromEntries(statusResults.flat());
|
|
452
|
+
// Pulse typing for busy sessions
|
|
453
|
+
await pulseTypingForBusySessions({ discordClient, statuses: mergedStatuses }).catch(() => { });
|
|
454
|
+
for (const [directory, sessions] of sessionsByDirectory) {
|
|
455
|
+
const target = directoryMap.get(directory);
|
|
456
|
+
const sorted = sortSessionsByRecency(sessions);
|
|
457
|
+
logger.log(`[EXTERNAL_SYNC] ${directory}: ${sorted.length} sessions to sync`);
|
|
458
|
+
for (const session of sorted) {
|
|
459
|
+
await syncSessionToThread({
|
|
460
|
+
client,
|
|
461
|
+
discordClient,
|
|
462
|
+
directory,
|
|
463
|
+
channelId: target.channelId,
|
|
464
|
+
sessionId: session.id,
|
|
465
|
+
sessionTitle: session.title,
|
|
466
|
+
}).catch((error) => {
|
|
467
|
+
logger.warn(`[EXTERNAL_SYNC] Failed syncing session ${session.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
468
|
+
void notifyError(error instanceof Error ? error : new Error(String(error)), `External session sync failed for ${session.id}`);
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
export function startExternalOpencodeSessionSync({ discordClient, }) {
|
|
474
|
+
if (process.env.KIMAKI_VITEST &&
|
|
475
|
+
process.env.KIMAKI_ENABLE_EXTERNAL_OPENCODE_SYNC !== '1') {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (externalSyncInterval) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
logger.log(`[EXTERNAL_SYNC] started, polling every ${EXTERNAL_SYNC_INTERVAL_MS}ms`);
|
|
482
|
+
let polling = false;
|
|
483
|
+
const runPoll = async () => {
|
|
484
|
+
if (polling) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
polling = true;
|
|
488
|
+
const result = await pollExternalSessions({ discordClient }).catch((e) => new Error('External session poll failed', { cause: e }));
|
|
489
|
+
polling = false;
|
|
490
|
+
if (result instanceof Error) {
|
|
491
|
+
logger.warn(`[EXTERNAL_SYNC] ${result.message}`);
|
|
492
|
+
void notifyError(result, 'External session poll top-level failure');
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
void runPoll();
|
|
496
|
+
externalSyncInterval = setInterval(() => {
|
|
497
|
+
void runPoll();
|
|
498
|
+
}, EXTERNAL_SYNC_INTERVAL_MS);
|
|
499
|
+
}
|
|
500
|
+
export function stopExternalOpencodeSessionSync() {
|
|
501
|
+
if (!externalSyncInterval) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
clearInterval(externalSyncInterval);
|
|
505
|
+
externalSyncInterval = null;
|
|
506
|
+
}
|
|
507
|
+
export const externalOpencodeSyncInternals = {
|
|
508
|
+
getRenderableUserTextParts,
|
|
509
|
+
getSessionThreadName,
|
|
510
|
+
groupTrackedChannelsByDirectory,
|
|
511
|
+
sortSessionsByRecency,
|
|
512
|
+
parseDiscordOriginMetadata,
|
|
513
|
+
getDiscordOriginMetadataFromMessage,
|
|
514
|
+
isLatestUserTurnFromDiscord,
|
|
515
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { externalOpencodeSyncInternals } from './external-opencode-sync.js';
|
|
3
|
+
describe('externalOpencodeSyncInternals', () => {
|
|
4
|
+
test('getRenderableUserTextParts strips synthetic and xml-only text parts', () => {
|
|
5
|
+
const message = {
|
|
6
|
+
info: { role: 'user' },
|
|
7
|
+
parts: [
|
|
8
|
+
{
|
|
9
|
+
id: 'synthetic',
|
|
10
|
+
type: 'text',
|
|
11
|
+
sessionID: 'ses_1',
|
|
12
|
+
messageID: 'msg_1',
|
|
13
|
+
time: { start: 1 },
|
|
14
|
+
text: '<discord-user name="Tommy" message-id="discord-1" thread-id="thread-1" />',
|
|
15
|
+
synthetic: true,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'xml-only',
|
|
19
|
+
type: 'text',
|
|
20
|
+
sessionID: 'ses_1',
|
|
21
|
+
messageID: 'msg_1',
|
|
22
|
+
time: { start: 2 },
|
|
23
|
+
text: '<discord-user name="Tommy" />',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'visible',
|
|
27
|
+
type: 'text',
|
|
28
|
+
sessionID: 'ses_1',
|
|
29
|
+
messageID: 'msg_1',
|
|
30
|
+
time: { start: 3 },
|
|
31
|
+
text: 'hello\n<discord-user name="Tommy" />\nworld',
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
expect(externalOpencodeSyncInternals.getRenderableUserTextParts({ message })).toMatchInlineSnapshot(`
|
|
36
|
+
[
|
|
37
|
+
{
|
|
38
|
+
"id": "visible",
|
|
39
|
+
"text": "hello
|
|
40
|
+
world",
|
|
41
|
+
},
|
|
42
|
+
]
|
|
43
|
+
`);
|
|
44
|
+
});
|
|
45
|
+
test('getDiscordOriginMetadataFromMessage returns metadata from synthetic part', () => {
|
|
46
|
+
const message = {
|
|
47
|
+
info: { role: 'user' },
|
|
48
|
+
parts: [
|
|
49
|
+
{
|
|
50
|
+
id: 'visible',
|
|
51
|
+
type: 'text',
|
|
52
|
+
sessionID: 'ses_1',
|
|
53
|
+
messageID: 'msg_1',
|
|
54
|
+
time: { start: 1 },
|
|
55
|
+
text: 'hello from discord',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'synthetic',
|
|
59
|
+
type: 'text',
|
|
60
|
+
sessionID: 'ses_1',
|
|
61
|
+
messageID: 'msg_1',
|
|
62
|
+
time: { start: 2 },
|
|
63
|
+
text: '<discord-user name="Tommy" message-id="discord-1" thread-id="thread-1" />',
|
|
64
|
+
synthetic: true,
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
expect(externalOpencodeSyncInternals.getDiscordOriginMetadataFromMessage({
|
|
69
|
+
message,
|
|
70
|
+
})).toMatchInlineSnapshot(`
|
|
71
|
+
{
|
|
72
|
+
"messageId": "discord-1",
|
|
73
|
+
"threadId": "thread-1",
|
|
74
|
+
"username": "Tommy",
|
|
75
|
+
}
|
|
76
|
+
`);
|
|
77
|
+
});
|
|
78
|
+
test('groupTrackedChannelsByDirectory uses earliest created channel as cutoff', () => {
|
|
79
|
+
const grouped = externalOpencodeSyncInternals.groupTrackedChannelsByDirectory([
|
|
80
|
+
{
|
|
81
|
+
channel_id: 'channel-2',
|
|
82
|
+
directory: '/repo-a',
|
|
83
|
+
created_at: new Date('2026-03-25T12:00:00.000Z'),
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
channel_id: 'channel-1',
|
|
87
|
+
directory: '/repo-a',
|
|
88
|
+
created_at: new Date('2026-03-25T10:00:00.000Z'),
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
channel_id: 'channel-3',
|
|
92
|
+
directory: '/repo-b',
|
|
93
|
+
created_at: null,
|
|
94
|
+
},
|
|
95
|
+
]);
|
|
96
|
+
expect(grouped).toMatchInlineSnapshot(`
|
|
97
|
+
[
|
|
98
|
+
{
|
|
99
|
+
"channelId": "channel-1",
|
|
100
|
+
"directory": "/repo-a",
|
|
101
|
+
"startMs": 1774432800000,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"channelId": "channel-3",
|
|
105
|
+
"directory": "/repo-b",
|
|
106
|
+
"startMs": 0,
|
|
107
|
+
},
|
|
108
|
+
]
|
|
109
|
+
`);
|
|
110
|
+
});
|
|
111
|
+
test('sortSessionsByRecency orders sessions by latest updated time first', () => {
|
|
112
|
+
const sorted = externalOpencodeSyncInternals.sortSessionsByRecency([
|
|
113
|
+
{
|
|
114
|
+
id: 'session-older',
|
|
115
|
+
title: 'older',
|
|
116
|
+
slug: 'session-older',
|
|
117
|
+
projectID: 'project-1',
|
|
118
|
+
directory: '/repo-a',
|
|
119
|
+
version: '1',
|
|
120
|
+
time: { created: 10, updated: 20 },
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: 'session-newest',
|
|
124
|
+
title: 'newest',
|
|
125
|
+
slug: 'session-newest',
|
|
126
|
+
projectID: 'project-1',
|
|
127
|
+
directory: '/repo-a',
|
|
128
|
+
version: '1',
|
|
129
|
+
time: { created: 30, updated: 40 },
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: 'session-no-update',
|
|
133
|
+
title: 'no-update',
|
|
134
|
+
slug: 'session-no-update',
|
|
135
|
+
projectID: 'project-1',
|
|
136
|
+
directory: '/repo-a',
|
|
137
|
+
version: '1',
|
|
138
|
+
time: { created: 35, updated: 0 },
|
|
139
|
+
},
|
|
140
|
+
]);
|
|
141
|
+
expect(sorted.map((session) => {
|
|
142
|
+
return session.id;
|
|
143
|
+
})).toMatchInlineSnapshot(`
|
|
144
|
+
[
|
|
145
|
+
"session-newest",
|
|
146
|
+
"session-no-update",
|
|
147
|
+
"session-older",
|
|
148
|
+
]
|
|
149
|
+
`);
|
|
150
|
+
});
|
|
151
|
+
});
|