kimaki 0.4.78 → 0.4.80
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/anthropic-auth-plugin.js +628 -0
- package/dist/channel-management.js +2 -2
- package/dist/cli.js +316 -129
- package/dist/commands/action-buttons.js +1 -1
- package/dist/commands/login.js +634 -277
- package/dist/commands/model.js +91 -6
- package/dist/commands/paginated-select.js +57 -0
- package/dist/commands/resume.js +2 -2
- package/dist/commands/tasks.js +205 -0
- package/dist/commands/undo-redo.js +80 -18
- package/dist/context-awareness-plugin.js +347 -0
- package/dist/database.js +103 -7
- package/dist/db.js +39 -1
- package/dist/discord-bot.js +42 -19
- package/dist/discord-urls.js +11 -0
- package/dist/discord-ws-proxy.js +350 -0
- package/dist/discord-ws-proxy.test.js +500 -0
- package/dist/errors.js +1 -1
- package/dist/gateway-session.js +163 -0
- package/dist/hrana-server.js +114 -4
- package/dist/interaction-handler.js +30 -7
- package/dist/ipc-tools-plugin.js +186 -0
- package/dist/message-preprocessing.js +56 -11
- package/dist/onboarding-welcome.js +1 -1
- package/dist/opencode-interrupt-plugin.js +133 -75
- package/dist/opencode-plugin.js +12 -389
- package/dist/opencode.js +59 -5
- package/dist/parse-permission-rules.test.js +117 -0
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
- package/dist/session-handler/thread-session-runtime.js +68 -29
- package/dist/startup-time.e2e.test.js +295 -0
- package/dist/store.js +1 -0
- package/dist/system-message.js +3 -1
- package/dist/task-runner.js +7 -3
- package/dist/task-schedule.js +12 -0
- package/dist/thread-message-queue.e2e.test.js +13 -1
- package/dist/undo-redo.e2e.test.js +166 -0
- package/dist/utils.js +4 -1
- package/dist/voice-attachment.js +34 -0
- package/dist/voice-handler.js +11 -9
- package/dist/voice-message.e2e.test.js +78 -0
- package/dist/voice.test.js +31 -0
- package/package.json +12 -7
- package/skills/egaki/SKILL.md +80 -15
- package/skills/errore/SKILL.md +13 -0
- package/skills/lintcn/SKILL.md +749 -0
- package/skills/npm-package/SKILL.md +17 -3
- package/skills/spiceflow/SKILL.md +14 -0
- package/skills/zele/SKILL.md +9 -0
- package/src/anthropic-auth-plugin.ts +732 -0
- package/src/channel-management.ts +2 -2
- package/src/cli.ts +354 -132
- package/src/commands/action-buttons.ts +1 -0
- package/src/commands/login.ts +836 -337
- package/src/commands/model.ts +102 -7
- package/src/commands/paginated-select.ts +81 -0
- package/src/commands/resume.ts +6 -1
- package/src/commands/tasks.ts +293 -0
- package/src/commands/undo-redo.ts +87 -20
- package/src/context-awareness-plugin.ts +469 -0
- package/src/database.ts +138 -7
- package/src/db.ts +40 -1
- package/src/discord-bot.ts +46 -19
- package/src/discord-urls.ts +12 -0
- package/src/errors.ts +1 -1
- package/src/hrana-server.ts +124 -3
- package/src/interaction-handler.ts +41 -9
- package/src/ipc-tools-plugin.ts +228 -0
- package/src/message-preprocessing.ts +82 -11
- package/src/onboarding-welcome.ts +1 -1
- package/src/opencode-interrupt-plugin.ts +164 -91
- package/src/opencode-plugin.ts +13 -483
- package/src/opencode.ts +60 -5
- package/src/parse-permission-rules.test.ts +127 -0
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
- package/src/session-handler/thread-runtime-state.ts +4 -1
- package/src/session-handler/thread-session-runtime.ts +82 -20
- package/src/startup-time.e2e.test.ts +372 -0
- package/src/store.ts +8 -0
- package/src/system-message.ts +10 -1
- package/src/task-runner.ts +9 -22
- package/src/task-schedule.ts +15 -0
- package/src/thread-message-queue.e2e.test.ts +14 -1
- package/src/undo-redo.e2e.test.ts +207 -0
- package/src/utils.ts +7 -0
- package/src/voice-attachment.ts +51 -0
- package/src/voice-handler.ts +15 -7
- package/src/voice-message.e2e.test.ts +95 -0
- package/src/voice.test.ts +36 -0
- package/src/onboarding-tutorial-plugin.ts +0 -93
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// OpenCode plugin that injects synthetic message parts for context awareness:
|
|
2
|
+
// - Git branch / detached HEAD changes
|
|
3
|
+
// - Working directory (pwd) changes (e.g. after /new-worktree mid-session)
|
|
4
|
+
// - MEMORY.md table of contents on first message
|
|
5
|
+
// - Idle time gap detection with timestamps
|
|
6
|
+
// - Onboarding tutorial instructions (when TUTORIAL_WELCOME_TEXT detected)
|
|
7
|
+
//
|
|
8
|
+
// Synthetic parts are hidden from the TUI but sent to the model, keeping it
|
|
9
|
+
// aware of context changes without cluttering the UI.
|
|
10
|
+
//
|
|
11
|
+
// State design: all per-session mutable state is encapsulated in a single
|
|
12
|
+
// SessionState object per session ID. One Map, one delete() on cleanup.
|
|
13
|
+
// Decision logic is extracted into pure functions that take state + input
|
|
14
|
+
// and return whether to inject — making them testable without mocking.
|
|
15
|
+
//
|
|
16
|
+
// Exported from opencode-plugin.ts — each export is treated as a separate
|
|
17
|
+
// plugin by OpenCode's plugin loader.
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import * as errore from 'errore';
|
|
22
|
+
import { createLogger, formatErrorWithStack, LogPrefix, setLogFilePath, } from './logger.js';
|
|
23
|
+
import { setDataDir } from './config.js';
|
|
24
|
+
import { initSentry, notifyError } from './sentry.js';
|
|
25
|
+
import { execAsync } from './worktrees.js';
|
|
26
|
+
import { condenseMemoryMd } from './condense-memory.js';
|
|
27
|
+
import { ONBOARDING_TUTORIAL_INSTRUCTIONS, TUTORIAL_WELCOME_TEXT, } from './onboarding-tutorial.js';
|
|
28
|
+
const logger = createLogger(LogPrefix.OPENCODE);
|
|
29
|
+
function createSessionState() {
|
|
30
|
+
return {
|
|
31
|
+
gitState: undefined,
|
|
32
|
+
lastMessageTime: undefined,
|
|
33
|
+
memoryInjected: false,
|
|
34
|
+
tutorialInjected: false,
|
|
35
|
+
resolvedDirectory: undefined,
|
|
36
|
+
announcedDirectory: undefined,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// ── Pure derivation functions ────────────────────────────────────
|
|
40
|
+
// These take state + fresh input and return whether to inject.
|
|
41
|
+
// No side effects, no mutations — easy to test with fixtures.
|
|
42
|
+
export function shouldInjectBranch({ previousGitState, currentGitState, }) {
|
|
43
|
+
if (!currentGitState) {
|
|
44
|
+
return { inject: false };
|
|
45
|
+
}
|
|
46
|
+
if (previousGitState && previousGitState.key === currentGitState.key) {
|
|
47
|
+
return { inject: false };
|
|
48
|
+
}
|
|
49
|
+
const text = currentGitState.warning || `\n[current git branch is ${currentGitState.label}]`;
|
|
50
|
+
return { inject: true, text };
|
|
51
|
+
}
|
|
52
|
+
export function shouldInjectPwd({ sessionDir, projectDir, announcedDir, }) {
|
|
53
|
+
if (!sessionDir || sessionDir === projectDir) {
|
|
54
|
+
return { inject: false };
|
|
55
|
+
}
|
|
56
|
+
if (announcedDir === sessionDir) {
|
|
57
|
+
return { inject: false };
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
inject: true,
|
|
61
|
+
text: `\n[working directory is ${sessionDir} (git worktree of ${projectDir}). ` +
|
|
62
|
+
`All file reads, writes, and edits must use paths under ${sessionDir}, ` +
|
|
63
|
+
`not ${projectDir}.]`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const TEN_MINUTES = 10 * 60 * 1000;
|
|
67
|
+
export function shouldInjectTimeGap({ lastMessageTime, now, }) {
|
|
68
|
+
if (!lastMessageTime) {
|
|
69
|
+
return { inject: false };
|
|
70
|
+
}
|
|
71
|
+
const elapsed = now - lastMessageTime;
|
|
72
|
+
if (elapsed < TEN_MINUTES) {
|
|
73
|
+
return { inject: false };
|
|
74
|
+
}
|
|
75
|
+
const totalMinutes = Math.floor(elapsed / 60_000);
|
|
76
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
77
|
+
const minutes = totalMinutes % 60;
|
|
78
|
+
const elapsedStr = hours > 0 ? `${hours}h ${minutes}m` : `${totalMinutes}m`;
|
|
79
|
+
const utcStr = new Date(now)
|
|
80
|
+
.toISOString()
|
|
81
|
+
.replace('T', ' ')
|
|
82
|
+
.replace(/\.\d+Z$/, ' UTC');
|
|
83
|
+
const localTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
84
|
+
const localStr = new Date(now).toLocaleString('en-US', {
|
|
85
|
+
timeZone: localTz,
|
|
86
|
+
year: 'numeric',
|
|
87
|
+
month: '2-digit',
|
|
88
|
+
day: '2-digit',
|
|
89
|
+
hour: '2-digit',
|
|
90
|
+
minute: '2-digit',
|
|
91
|
+
hour12: false,
|
|
92
|
+
});
|
|
93
|
+
return { inject: true, elapsedStr, utcStr, localStr, localTz };
|
|
94
|
+
}
|
|
95
|
+
export function shouldInjectTutorial({ alreadyInjected, parts, }) {
|
|
96
|
+
if (alreadyInjected) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
return parts.some((part) => {
|
|
100
|
+
return part.type === 'text' && part.text?.includes(TUTORIAL_WELCOME_TEXT);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
// ── Impure helpers (I/O) ─────────────────────────────────────────
|
|
104
|
+
async function resolveGitState({ directory, }) {
|
|
105
|
+
const branchResult = await errore.tryAsync(() => {
|
|
106
|
+
return execAsync('git symbolic-ref --short HEAD', { cwd: directory });
|
|
107
|
+
});
|
|
108
|
+
if (!(branchResult instanceof Error)) {
|
|
109
|
+
const branch = branchResult.stdout.trim();
|
|
110
|
+
if (branch) {
|
|
111
|
+
return {
|
|
112
|
+
key: `branch:${branch}`,
|
|
113
|
+
kind: 'branch',
|
|
114
|
+
label: branch,
|
|
115
|
+
warning: null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const shaResult = await errore.tryAsync(() => {
|
|
120
|
+
return execAsync('git rev-parse --short HEAD', { cwd: directory });
|
|
121
|
+
});
|
|
122
|
+
if (shaResult instanceof Error) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const shortSha = shaResult.stdout.trim();
|
|
126
|
+
if (!shortSha) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const superprojectResult = await errore.tryAsync(() => {
|
|
130
|
+
return execAsync('git rev-parse --show-superproject-working-tree', {
|
|
131
|
+
cwd: directory,
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
const superproject = superprojectResult instanceof Error ? '' : superprojectResult.stdout.trim();
|
|
135
|
+
if (superproject) {
|
|
136
|
+
return {
|
|
137
|
+
key: `detached-submodule:${shortSha}`,
|
|
138
|
+
kind: 'detached-submodule',
|
|
139
|
+
label: `detached submodule @ ${shortSha}`,
|
|
140
|
+
warning: `\n[warning: submodule is in detached HEAD at ${shortSha}. ` +
|
|
141
|
+
'create or switch to a branch before committing.]',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
key: `detached-head:${shortSha}`,
|
|
146
|
+
kind: 'detached-head',
|
|
147
|
+
label: `detached HEAD @ ${shortSha}`,
|
|
148
|
+
warning: `\n[warning: repository is in detached HEAD at ${shortSha}. ` +
|
|
149
|
+
'create or switch to a branch before committing.]',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// Resolve the session's actual working directory via the SDK.
|
|
153
|
+
// Cached in SessionState.resolvedDirectory to avoid repeated HTTP calls.
|
|
154
|
+
async function resolveSessionDirectory({ client, sessionID, state, }) {
|
|
155
|
+
if (state.resolvedDirectory) {
|
|
156
|
+
return state.resolvedDirectory;
|
|
157
|
+
}
|
|
158
|
+
const result = await errore.tryAsync(() => {
|
|
159
|
+
return client.session.get({ path: { id: sessionID } });
|
|
160
|
+
});
|
|
161
|
+
if (result instanceof Error || !result.data?.directory) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
state.resolvedDirectory = result.data.directory;
|
|
165
|
+
return result.data.directory;
|
|
166
|
+
}
|
|
167
|
+
// ── Plugin ───────────────────────────────────────────────────────
|
|
168
|
+
const contextAwarenessPlugin = async ({ directory, client }) => {
|
|
169
|
+
initSentry();
|
|
170
|
+
const dataDir = process.env.KIMAKI_DATA_DIR;
|
|
171
|
+
if (dataDir) {
|
|
172
|
+
setDataDir(dataDir);
|
|
173
|
+
setLogFilePath(dataDir);
|
|
174
|
+
}
|
|
175
|
+
// Single Map for all per-session state. One entry per session, one
|
|
176
|
+
// delete on cleanup — no parallel Maps that can drift out of sync.
|
|
177
|
+
const sessions = new Map();
|
|
178
|
+
function getOrCreateSession(sessionID) {
|
|
179
|
+
const existing = sessions.get(sessionID);
|
|
180
|
+
if (existing) {
|
|
181
|
+
return existing;
|
|
182
|
+
}
|
|
183
|
+
const state = createSessionState();
|
|
184
|
+
sessions.set(sessionID, state);
|
|
185
|
+
return state;
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
'chat.message': async (input, output) => {
|
|
189
|
+
const hookResult = await errore.tryAsync({
|
|
190
|
+
try: async () => {
|
|
191
|
+
const { sessionID } = input;
|
|
192
|
+
const state = getOrCreateSession(sessionID);
|
|
193
|
+
// -- Onboarding tutorial injection --
|
|
194
|
+
// Runs before the non-synthetic text guard because the tutorial
|
|
195
|
+
// marker (TUTORIAL_WELCOME_TEXT) can appear in synthetic/system
|
|
196
|
+
// parts prepended by message-preprocessing.ts. The old separate
|
|
197
|
+
// plugin had no such guard, so this preserves that behavior.
|
|
198
|
+
const firstTextPart = output.parts.find((part) => {
|
|
199
|
+
return part.type === 'text';
|
|
200
|
+
});
|
|
201
|
+
if (firstTextPart && shouldInjectTutorial({ alreadyInjected: state.tutorialInjected, parts: output.parts })) {
|
|
202
|
+
state.tutorialInjected = true;
|
|
203
|
+
output.parts.push({
|
|
204
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
205
|
+
sessionID,
|
|
206
|
+
messageID: firstTextPart.messageID,
|
|
207
|
+
type: 'text',
|
|
208
|
+
text: `<system-reminder>\n${ONBOARDING_TUTORIAL_INSTRUCTIONS}\n</system-reminder>`,
|
|
209
|
+
synthetic: true,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
// -- Find first non-synthetic user text part --
|
|
213
|
+
// All remaining injections (branch, pwd, memory, time gap) only
|
|
214
|
+
// apply to real user messages, not empty or synthetic-only messages.
|
|
215
|
+
const now = Date.now();
|
|
216
|
+
const first = output.parts.find((part) => {
|
|
217
|
+
if (part.type !== 'text') {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
return part.synthetic !== true;
|
|
221
|
+
});
|
|
222
|
+
if (!first || first.type !== 'text' || first.text.trim().length === 0) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const messageID = first.messageID;
|
|
226
|
+
// -- Resolve session working directory --
|
|
227
|
+
const sessionDir = await resolveSessionDirectory({
|
|
228
|
+
client,
|
|
229
|
+
sessionID,
|
|
230
|
+
state,
|
|
231
|
+
});
|
|
232
|
+
const effectiveDirectory = sessionDir || directory;
|
|
233
|
+
// -- Branch / detached HEAD detection --
|
|
234
|
+
// Resolved early but injected last so it appears at the end of parts.
|
|
235
|
+
const gitState = await resolveGitState({ directory: effectiveDirectory });
|
|
236
|
+
// -- Working directory change detection --
|
|
237
|
+
const pwdResult = shouldInjectPwd({
|
|
238
|
+
sessionDir,
|
|
239
|
+
projectDir: directory,
|
|
240
|
+
announcedDir: state.announcedDirectory,
|
|
241
|
+
});
|
|
242
|
+
if (pwdResult.inject) {
|
|
243
|
+
state.announcedDirectory = sessionDir;
|
|
244
|
+
output.parts.push({
|
|
245
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
246
|
+
sessionID,
|
|
247
|
+
messageID,
|
|
248
|
+
type: 'text',
|
|
249
|
+
text: pwdResult.text,
|
|
250
|
+
synthetic: true,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
// -- MEMORY.md injection --
|
|
254
|
+
if (!state.memoryInjected) {
|
|
255
|
+
state.memoryInjected = true;
|
|
256
|
+
const memoryPath = path.join(effectiveDirectory, 'MEMORY.md');
|
|
257
|
+
const memoryContent = await fs.promises
|
|
258
|
+
.readFile(memoryPath, 'utf-8')
|
|
259
|
+
.catch(() => null);
|
|
260
|
+
if (memoryContent) {
|
|
261
|
+
const condensed = condenseMemoryMd(memoryContent);
|
|
262
|
+
output.parts.push({
|
|
263
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
264
|
+
sessionID,
|
|
265
|
+
messageID,
|
|
266
|
+
type: 'text',
|
|
267
|
+
text: `<system-reminder>Project memory from MEMORY.md (condensed table of contents, line numbers shown):\n${condensed}\nOnly headings are shown above — section bodies are hidden. Use Grep to search MEMORY.md for specific topics, or Read with offset and limit to read a section's content. When writing to MEMORY.md, make headings detailed and descriptive since they are the only thing visible in this prompt. You can update MEMORY.md to store learnings, tips, insights that will help prevent same mistakes, and context worth preserving across sessions.</system-reminder>`,
|
|
268
|
+
synthetic: true,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// -- Time since last message --
|
|
273
|
+
const timeGapResult = shouldInjectTimeGap({
|
|
274
|
+
lastMessageTime: state.lastMessageTime,
|
|
275
|
+
now,
|
|
276
|
+
});
|
|
277
|
+
state.lastMessageTime = now;
|
|
278
|
+
if (timeGapResult.inject) {
|
|
279
|
+
output.parts.push({
|
|
280
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
281
|
+
sessionID,
|
|
282
|
+
messageID,
|
|
283
|
+
type: 'text',
|
|
284
|
+
text: `[${timeGapResult.elapsedStr} since last message | UTC: ${timeGapResult.utcStr} | Local (${timeGapResult.localTz}): ${timeGapResult.localStr}]`,
|
|
285
|
+
synthetic: true,
|
|
286
|
+
});
|
|
287
|
+
output.parts.push({
|
|
288
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
289
|
+
sessionID,
|
|
290
|
+
messageID,
|
|
291
|
+
type: 'text',
|
|
292
|
+
text: '<system-reminder>Long gap since last message. If the previous conversation had important learnings, tips, insights that will help prevent same mistakes, or context worth preserving, update MEMORY.md before starting the new task.</system-reminder>',
|
|
293
|
+
synthetic: true,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
// -- Branch injection (last synthetic part) --
|
|
297
|
+
const branchResult = shouldInjectBranch({
|
|
298
|
+
previousGitState: state.gitState,
|
|
299
|
+
currentGitState: gitState,
|
|
300
|
+
});
|
|
301
|
+
if (branchResult.inject) {
|
|
302
|
+
state.gitState = gitState;
|
|
303
|
+
output.parts.push({
|
|
304
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
305
|
+
sessionID,
|
|
306
|
+
messageID,
|
|
307
|
+
type: 'text',
|
|
308
|
+
text: branchResult.text,
|
|
309
|
+
synthetic: true,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
catch: (error) => {
|
|
314
|
+
return new Error('context-awareness chat.message hook failed', { cause: error });
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
if (hookResult instanceof Error) {
|
|
318
|
+
logger.warn(`[context-awareness-plugin] ${formatErrorWithStack(hookResult)}`);
|
|
319
|
+
void notifyError(hookResult, 'context-awareness plugin chat.message hook failed');
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
// Clean up per-session state when sessions are deleted.
|
|
323
|
+
// Single delete instead of 5 parallel Map/Set deletes.
|
|
324
|
+
event: async ({ event }) => {
|
|
325
|
+
const cleanupResult = await errore.tryAsync({
|
|
326
|
+
try: async () => {
|
|
327
|
+
if (event.type !== 'session.deleted') {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const id = event.properties?.info?.id;
|
|
331
|
+
if (!id) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
sessions.delete(id);
|
|
335
|
+
},
|
|
336
|
+
catch: (error) => {
|
|
337
|
+
return new Error('context-awareness event hook failed', { cause: error });
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
if (cleanupResult instanceof Error) {
|
|
341
|
+
logger.warn(`[context-awareness-plugin] ${formatErrorWithStack(cleanupResult)}`);
|
|
342
|
+
void notifyError(cleanupResult, 'context-awareness plugin event hook failed');
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
};
|
|
347
|
+
export { contextAwarenessPlugin };
|
package/dist/database.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Stores thread-session mappings, bot tokens, channel directories,
|
|
3
3
|
// API keys, and model preferences in <dataDir>/discord-sessions.db.
|
|
4
4
|
import { getPrisma, closePrisma } from './db.js';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
5
6
|
import { store } from './store.js';
|
|
6
7
|
import { createLogger, LogPrefix } from './logger.js';
|
|
7
8
|
const dbLogger = createLogger(LogPrefix.DB);
|
|
@@ -88,6 +89,43 @@ export async function listScheduledTasks({ statuses, } = {}) {
|
|
|
88
89
|
});
|
|
89
90
|
return rows.map((row) => toScheduledTask(row));
|
|
90
91
|
}
|
|
92
|
+
export async function getScheduledTask(taskId) {
|
|
93
|
+
const prisma = await getPrisma();
|
|
94
|
+
const row = await prisma.scheduled_tasks.findUnique({
|
|
95
|
+
where: { id: taskId },
|
|
96
|
+
});
|
|
97
|
+
return row ? toScheduledTask(row) : null;
|
|
98
|
+
}
|
|
99
|
+
export async function updateScheduledTask({ taskId, payloadJson, promptPreview, scheduleKind, runAt, cronExpr, timezone, nextRunAt, }) {
|
|
100
|
+
const prisma = await getPrisma();
|
|
101
|
+
const data = {
|
|
102
|
+
payload_json: payloadJson,
|
|
103
|
+
prompt_preview: promptPreview,
|
|
104
|
+
};
|
|
105
|
+
if (scheduleKind !== undefined) {
|
|
106
|
+
data.schedule_kind = scheduleKind;
|
|
107
|
+
}
|
|
108
|
+
if (runAt !== undefined) {
|
|
109
|
+
data.run_at = runAt;
|
|
110
|
+
}
|
|
111
|
+
if (cronExpr !== undefined) {
|
|
112
|
+
data.cron_expr = cronExpr;
|
|
113
|
+
}
|
|
114
|
+
if (timezone !== undefined) {
|
|
115
|
+
data.timezone = timezone;
|
|
116
|
+
}
|
|
117
|
+
if (nextRunAt !== undefined) {
|
|
118
|
+
data.next_run_at = nextRunAt;
|
|
119
|
+
}
|
|
120
|
+
const result = await prisma.scheduled_tasks.updateMany({
|
|
121
|
+
where: {
|
|
122
|
+
id: taskId,
|
|
123
|
+
status: 'planned',
|
|
124
|
+
},
|
|
125
|
+
data,
|
|
126
|
+
});
|
|
127
|
+
return result.count > 0;
|
|
128
|
+
}
|
|
91
129
|
export async function cancelScheduledTask(taskId) {
|
|
92
130
|
const prisma = await getPrisma();
|
|
93
131
|
const result = await prisma.scheduled_tasks.updateMany({
|
|
@@ -812,9 +850,11 @@ export async function getBotTokenWithMode() {
|
|
|
812
850
|
if (!row) {
|
|
813
851
|
return undefined;
|
|
814
852
|
}
|
|
853
|
+
const gatewayToken = await ensureServiceAuthToken({ appId: row.app_id });
|
|
854
|
+
const serviceParts = splitServiceAuthToken({ token: gatewayToken });
|
|
815
855
|
const mode = row.bot_mode === 'gateway' ? 'gateway' : 'self_hosted';
|
|
816
|
-
const token = (mode === 'gateway' &&
|
|
817
|
-
?
|
|
856
|
+
const token = (mode === 'gateway' && serviceParts)
|
|
857
|
+
? gatewayToken
|
|
818
858
|
: row.token;
|
|
819
859
|
// Always reset discordBaseUrl on every read so a mode switch within
|
|
820
860
|
// the same process (e.g. DB has gateway row but user proceeds self-hosted)
|
|
@@ -822,26 +862,77 @@ export async function getBotTokenWithMode() {
|
|
|
822
862
|
const discordBaseUrl = (mode === 'gateway' && row.proxy_url)
|
|
823
863
|
? row.proxy_url
|
|
824
864
|
: 'https://discord.com';
|
|
825
|
-
store.setState({ discordBaseUrl });
|
|
865
|
+
store.setState({ discordBaseUrl, gatewayToken });
|
|
826
866
|
return {
|
|
827
867
|
appId: row.app_id,
|
|
828
868
|
token,
|
|
869
|
+
gatewayToken,
|
|
829
870
|
mode,
|
|
830
|
-
clientId: row.client_id,
|
|
831
|
-
clientSecret: row.client_secret,
|
|
871
|
+
clientId: serviceParts?.clientId || row.client_id,
|
|
872
|
+
clientSecret: serviceParts?.clientSecret || row.client_secret,
|
|
832
873
|
proxyUrl: row.proxy_url,
|
|
833
874
|
};
|
|
834
875
|
}
|
|
876
|
+
function splitServiceAuthToken({ token }) {
|
|
877
|
+
const separatorIndex = token.indexOf(':');
|
|
878
|
+
if (separatorIndex <= 0 || separatorIndex >= token.length - 1) {
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
881
|
+
return {
|
|
882
|
+
clientId: token.slice(0, separatorIndex),
|
|
883
|
+
clientSecret: token.slice(separatorIndex + 1),
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
function createServiceCredentials() {
|
|
887
|
+
return {
|
|
888
|
+
clientId: crypto.randomUUID(),
|
|
889
|
+
clientSecret: crypto.randomBytes(32).toString('hex'),
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
export async function ensureServiceAuthToken({ appId, preferredGatewayToken, }) {
|
|
893
|
+
const prisma = await getPrisma();
|
|
894
|
+
const row = await prisma.bot_tokens.findUnique({
|
|
895
|
+
where: { app_id: appId },
|
|
896
|
+
});
|
|
897
|
+
if (!row) {
|
|
898
|
+
throw new Error(`Bot token row not found for app_id ${appId}`);
|
|
899
|
+
}
|
|
900
|
+
const preferred = preferredGatewayToken
|
|
901
|
+
? splitServiceAuthToken({ token: preferredGatewayToken })
|
|
902
|
+
: null;
|
|
903
|
+
const existing = (row.client_id && row.client_secret)
|
|
904
|
+
? { clientId: row.client_id, clientSecret: row.client_secret }
|
|
905
|
+
: null;
|
|
906
|
+
const fromStoredToken = splitServiceAuthToken({ token: row.token });
|
|
907
|
+
const resolved = preferred || existing || fromStoredToken || createServiceCredentials();
|
|
908
|
+
if (row.client_id !== resolved.clientId || row.client_secret !== resolved.clientSecret) {
|
|
909
|
+
await prisma.bot_tokens.update({
|
|
910
|
+
where: { app_id: appId },
|
|
911
|
+
data: {
|
|
912
|
+
client_id: resolved.clientId,
|
|
913
|
+
client_secret: resolved.clientSecret,
|
|
914
|
+
},
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
return `${resolved.clientId}:${resolved.clientSecret}`;
|
|
918
|
+
}
|
|
835
919
|
/**
|
|
836
920
|
* Store a bot token.
|
|
837
921
|
*/
|
|
838
922
|
export async function setBotToken(appId, token) {
|
|
839
923
|
const prisma = await getPrisma();
|
|
924
|
+
const generated = createServiceCredentials();
|
|
840
925
|
await prisma.bot_tokens.upsert({
|
|
841
926
|
where: { app_id: appId },
|
|
842
|
-
create: {
|
|
927
|
+
create: {
|
|
928
|
+
app_id: appId,
|
|
929
|
+
token,
|
|
930
|
+
client_id: generated.clientId,
|
|
931
|
+
client_secret: generated.clientSecret,
|
|
932
|
+
},
|
|
843
933
|
update: { token },
|
|
844
934
|
});
|
|
935
|
+
await ensureServiceAuthToken({ appId });
|
|
845
936
|
}
|
|
846
937
|
/**
|
|
847
938
|
* Persist gateway bot mode credentials.
|
|
@@ -855,11 +946,16 @@ export async function setBotMode({ appId, mode, clientId, clientSecret, proxyUrl
|
|
|
855
946
|
client_secret: clientSecret ?? null,
|
|
856
947
|
proxy_url: proxyUrl ?? null,
|
|
857
948
|
};
|
|
949
|
+
const createToken = (clientId && clientSecret) ? `${clientId}:${clientSecret}` : '';
|
|
858
950
|
await prisma.bot_tokens.upsert({
|
|
859
951
|
where: { app_id: appId },
|
|
860
|
-
create: { app_id: appId, token:
|
|
952
|
+
create: { app_id: appId, token: createToken, ...data },
|
|
861
953
|
update: data,
|
|
862
954
|
});
|
|
955
|
+
await ensureServiceAuthToken({
|
|
956
|
+
appId,
|
|
957
|
+
preferredGatewayToken: (clientId && clientSecret) ? `${clientId}:${clientSecret}` : undefined,
|
|
958
|
+
});
|
|
863
959
|
}
|
|
864
960
|
// ============================================================================
|
|
865
961
|
// Bot API Keys Functions
|
package/dist/db.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// otherwise falls back to direct file: access (bot process, CLI subcommands).
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
6
7
|
import { PrismaLibSql } from '@prisma/adapter-libsql';
|
|
7
8
|
import { PrismaClient, Prisma } from './generated/client.js';
|
|
8
9
|
import { getDataDir } from './config.js';
|
|
@@ -50,6 +51,13 @@ function getDbUrl() {
|
|
|
50
51
|
const dbPath = path.join(dataDir, 'discord-sessions.db');
|
|
51
52
|
return `file:${dbPath}`;
|
|
52
53
|
}
|
|
54
|
+
function getDbAuthToken() {
|
|
55
|
+
const token = process.env.KIMAKI_DB_AUTH_TOKEN;
|
|
56
|
+
if (!token) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
return token;
|
|
60
|
+
}
|
|
53
61
|
async function initializePrisma() {
|
|
54
62
|
const dbUrl = getDbUrl();
|
|
55
63
|
const isFileMode = dbUrl.startsWith('file:');
|
|
@@ -63,7 +71,11 @@ async function initializePrisma() {
|
|
|
63
71
|
}
|
|
64
72
|
}
|
|
65
73
|
dbLogger.log(`Opening database via: ${dbUrl}`);
|
|
66
|
-
const
|
|
74
|
+
const dbAuthToken = getDbAuthToken();
|
|
75
|
+
const adapter = new PrismaLibSql({
|
|
76
|
+
url: dbUrl,
|
|
77
|
+
...(dbAuthToken && { authToken: dbAuthToken }),
|
|
78
|
+
});
|
|
67
79
|
const prisma = new PrismaClient({ adapter });
|
|
68
80
|
try {
|
|
69
81
|
if (isFileMode) {
|
|
@@ -193,6 +205,32 @@ async function migrateSchema(prisma) {
|
|
|
193
205
|
// Table may not exist on first run
|
|
194
206
|
}
|
|
195
207
|
}
|
|
208
|
+
// Migration: ensure every bot row has service auth credentials.
|
|
209
|
+
// These credentials are used for local/internet control-plane auth.
|
|
210
|
+
try {
|
|
211
|
+
const botRows = await prisma.bot_tokens.findMany({
|
|
212
|
+
select: {
|
|
213
|
+
app_id: true,
|
|
214
|
+
client_id: true,
|
|
215
|
+
client_secret: true,
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
for (const botRow of botRows) {
|
|
219
|
+
if (botRow.client_id && botRow.client_secret) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
await prisma.bot_tokens.update({
|
|
223
|
+
where: { app_id: botRow.app_id },
|
|
224
|
+
data: {
|
|
225
|
+
client_id: crypto.randomUUID(),
|
|
226
|
+
client_secret: crypto.randomBytes(32).toString('hex'),
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// Defensive migration only; ignore if table shape is not ready yet.
|
|
233
|
+
}
|
|
196
234
|
}
|
|
197
235
|
/**
|
|
198
236
|
* Close the Prisma connection.
|