kimaki 0.4.46 → 0.4.48
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/cli.js +69 -21
- package/dist/commands/abort.js +4 -2
- package/dist/commands/add-project.js +2 -2
- package/dist/commands/agent.js +4 -4
- package/dist/commands/ask-question.js +9 -8
- package/dist/commands/compact.js +126 -0
- package/dist/commands/create-new-project.js +60 -30
- package/dist/commands/fork.js +3 -3
- package/dist/commands/merge-worktree.js +23 -10
- package/dist/commands/model.js +5 -5
- package/dist/commands/permissions.js +5 -3
- package/dist/commands/queue.js +2 -2
- package/dist/commands/remove-project.js +2 -2
- package/dist/commands/resume.js +2 -2
- package/dist/commands/session.js +6 -3
- package/dist/commands/share.js +2 -2
- package/dist/commands/undo-redo.js +2 -2
- package/dist/commands/user-command.js +2 -2
- package/dist/commands/verbosity.js +5 -5
- package/dist/commands/worktree-settings.js +2 -2
- package/dist/commands/worktree.js +18 -8
- package/dist/config.js +7 -0
- package/dist/database.js +10 -7
- package/dist/discord-bot.js +30 -12
- package/dist/discord-utils.js +2 -2
- package/dist/genai-worker-wrapper.js +3 -3
- package/dist/genai-worker.js +2 -2
- package/dist/genai.js +2 -2
- package/dist/interaction-handler.js +6 -2
- package/dist/logger.js +57 -9
- package/dist/markdown.js +2 -2
- package/dist/message-formatting.js +91 -6
- package/dist/openai-realtime.js +2 -2
- package/dist/opencode.js +19 -25
- package/dist/session-handler.js +89 -29
- package/dist/system-message.js +11 -9
- package/dist/tools.js +3 -2
- package/dist/utils.js +1 -0
- package/dist/voice-handler.js +2 -2
- package/dist/voice.js +2 -2
- package/dist/worktree-utils.js +91 -7
- package/dist/xml.js +2 -2
- package/package.json +3 -3
- package/src/cli.ts +108 -21
- package/src/commands/abort.ts +4 -2
- package/src/commands/add-project.ts +2 -2
- package/src/commands/agent.ts +4 -4
- package/src/commands/ask-question.ts +9 -8
- package/src/commands/compact.ts +148 -0
- package/src/commands/create-new-project.ts +87 -36
- package/src/commands/fork.ts +3 -3
- package/src/commands/merge-worktree.ts +47 -10
- package/src/commands/model.ts +5 -5
- package/src/commands/permissions.ts +6 -2
- package/src/commands/queue.ts +2 -2
- package/src/commands/remove-project.ts +2 -2
- package/src/commands/resume.ts +2 -2
- package/src/commands/session.ts +6 -3
- package/src/commands/share.ts +2 -2
- package/src/commands/undo-redo.ts +2 -2
- package/src/commands/user-command.ts +2 -2
- package/src/commands/verbosity.ts +5 -5
- package/src/commands/worktree-settings.ts +2 -2
- package/src/commands/worktree.ts +20 -7
- package/src/config.ts +14 -0
- package/src/database.ts +13 -7
- package/src/discord-bot.ts +45 -12
- package/src/discord-utils.ts +2 -2
- package/src/genai-worker-wrapper.ts +3 -3
- package/src/genai-worker.ts +2 -2
- package/src/genai.ts +2 -2
- package/src/interaction-handler.ts +7 -2
- package/src/logger.ts +64 -10
- package/src/markdown.ts +2 -2
- package/src/message-formatting.ts +100 -6
- package/src/openai-realtime.ts +2 -2
- package/src/opencode.ts +19 -26
- package/src/session-handler.ts +102 -29
- package/src/system-message.ts +11 -9
- package/src/tools.ts +3 -2
- package/src/utils.ts +1 -0
- package/src/voice-handler.ts +2 -2
- package/src/voice.ts +2 -2
- package/src/worktree-utils.ts +111 -7
- package/src/xml.ts +2 -2
package/dist/logger.js
CHANGED
|
@@ -1,11 +1,49 @@
|
|
|
1
|
-
// Prefixed logging utility
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
import { log } from '@clack/prompts';
|
|
1
|
+
// Prefixed logging utility.
|
|
2
|
+
// Uses picocolors for compact frequent logs (log, info, debug).
|
|
3
|
+
// Uses @clack/prompts only for important events (warn, error) with visual distinction.
|
|
4
|
+
import { log as clackLog } from '@clack/prompts';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path, { dirname } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import util from 'node:util';
|
|
9
|
+
import pc from 'picocolors';
|
|
10
|
+
// All known log prefixes - add new ones here to keep alignment consistent
|
|
11
|
+
export const LogPrefix = {
|
|
12
|
+
ABORT: 'ABORT',
|
|
13
|
+
ADD_PROJECT: 'ADD_PROJ',
|
|
14
|
+
AGENT: 'AGENT',
|
|
15
|
+
ASK_QUESTION: 'QUESTION',
|
|
16
|
+
CLI: 'CLI',
|
|
17
|
+
COMPACT: 'COMPACT',
|
|
18
|
+
CREATE_PROJECT: 'NEW_PROJ',
|
|
19
|
+
DB: 'DB',
|
|
20
|
+
DISCORD: 'DISCORD',
|
|
21
|
+
FORK: 'FORK',
|
|
22
|
+
FORMATTING: 'FORMAT',
|
|
23
|
+
GENAI: 'GENAI',
|
|
24
|
+
GENAI_WORKER: 'GENAI_W',
|
|
25
|
+
INTERACTION: 'INTERACT',
|
|
26
|
+
MARKDOWN: 'MARKDOWN',
|
|
27
|
+
MODEL: 'MODEL',
|
|
28
|
+
OPENAI: 'OPENAI',
|
|
29
|
+
OPENCODE: 'OPENCODE',
|
|
30
|
+
PERMISSIONS: 'PERMS',
|
|
31
|
+
QUEUE: 'QUEUE',
|
|
32
|
+
REMOVE_PROJECT: 'RM_PROJ',
|
|
33
|
+
RESUME: 'RESUME',
|
|
34
|
+
SESSION: 'SESSION',
|
|
35
|
+
SHARE: 'SHARE',
|
|
36
|
+
TOOLS: 'TOOLS',
|
|
37
|
+
UNDO_REDO: 'UNDO',
|
|
38
|
+
USER_CMD: 'USER_CMD',
|
|
39
|
+
VERBOSITY: 'VERBOSE',
|
|
40
|
+
VOICE: 'VOICE',
|
|
41
|
+
WORKER: 'WORKER',
|
|
42
|
+
WORKTREE: 'WORKTREE',
|
|
43
|
+
XML: 'XML',
|
|
44
|
+
};
|
|
45
|
+
// compute max length from all known prefixes for alignment
|
|
46
|
+
const MAX_PREFIX_LENGTH = Math.max(...Object.values(LogPrefix).map((p) => p.length));
|
|
9
47
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
48
|
const __dirname = dirname(__filename);
|
|
11
49
|
const isDev = !__dirname.includes('node_modules');
|
|
@@ -32,27 +70,37 @@ function writeToFile(level, prefix, args) {
|
|
|
32
70
|
const message = `[${timestamp}] [${level}] [${prefix}] ${args.map(formatArg).join(' ')}\n`;
|
|
33
71
|
fs.appendFileSync(logFilePath, message);
|
|
34
72
|
}
|
|
73
|
+
function getTimestamp() {
|
|
74
|
+
const now = new Date();
|
|
75
|
+
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
|
|
76
|
+
}
|
|
77
|
+
function padPrefix(prefix) {
|
|
78
|
+
return prefix.padEnd(MAX_PREFIX_LENGTH);
|
|
79
|
+
}
|
|
35
80
|
export function createLogger(prefix) {
|
|
81
|
+
const paddedPrefix = padPrefix(prefix);
|
|
36
82
|
return {
|
|
37
83
|
log: (...args) => {
|
|
38
84
|
writeToFile('INFO', prefix, args);
|
|
39
|
-
log.
|
|
85
|
+
console.log(pc.dim(getTimestamp()), pc.cyan(paddedPrefix), ...args.map(formatArg));
|
|
40
86
|
},
|
|
41
87
|
error: (...args) => {
|
|
42
88
|
writeToFile('ERROR', prefix, args);
|
|
43
|
-
|
|
89
|
+
// use clack for errors - visually distinct
|
|
90
|
+
clackLog.error([paddedPrefix, ...args.map(formatArg)].join(' '));
|
|
44
91
|
},
|
|
45
92
|
warn: (...args) => {
|
|
46
93
|
writeToFile('WARN', prefix, args);
|
|
47
|
-
|
|
94
|
+
// use clack for warnings - visually distinct
|
|
95
|
+
clackLog.warn([paddedPrefix, ...args.map(formatArg)].join(' '));
|
|
48
96
|
},
|
|
49
97
|
info: (...args) => {
|
|
50
98
|
writeToFile('INFO', prefix, args);
|
|
51
|
-
log.
|
|
99
|
+
console.log(pc.dim(getTimestamp()), pc.blue(paddedPrefix), ...args.map(formatArg));
|
|
52
100
|
},
|
|
53
101
|
debug: (...args) => {
|
|
54
102
|
writeToFile('DEBUG', prefix, args);
|
|
55
|
-
log.
|
|
103
|
+
console.log(pc.dim(getTimestamp()), pc.dim(paddedPrefix), ...args.map(formatArg));
|
|
56
104
|
},
|
|
57
105
|
};
|
|
58
106
|
}
|
package/dist/markdown.js
CHANGED
|
@@ -7,7 +7,7 @@ import { createTaggedError } from 'errore';
|
|
|
7
7
|
import * as yaml from 'js-yaml';
|
|
8
8
|
import { formatDateTime } from './utils.js';
|
|
9
9
|
import { extractNonXmlContent } from './xml.js';
|
|
10
|
-
import { createLogger } from './logger.js';
|
|
10
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
11
11
|
import { SessionNotFoundError, MessagesNotFoundError } from './errors.js';
|
|
12
12
|
// Generic error for unexpected exceptions in async operations
|
|
13
13
|
class UnexpectedError extends createTaggedError({
|
|
@@ -15,7 +15,7 @@ class UnexpectedError extends createTaggedError({
|
|
|
15
15
|
message: '$message',
|
|
16
16
|
}) {
|
|
17
17
|
}
|
|
18
|
-
const markdownLogger = createLogger(
|
|
18
|
+
const markdownLogger = createLogger(LogPrefix.MARKDOWN);
|
|
19
19
|
export class ShareMarkdown {
|
|
20
20
|
client;
|
|
21
21
|
constructor(client) {
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import * as errore from 'errore';
|
|
7
|
-
import { createLogger } from './logger.js';
|
|
7
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
8
8
|
import { FetchError } from './errors.js';
|
|
9
9
|
const ATTACHMENTS_DIR = path.join(process.cwd(), 'tmp', 'discord-attachments');
|
|
10
|
-
const logger = createLogger(
|
|
10
|
+
const logger = createLogger(LogPrefix.FORMATTING);
|
|
11
11
|
/**
|
|
12
12
|
* Escapes Discord inline markdown characters so dynamic content
|
|
13
13
|
* doesn't break formatting when wrapped in *, _, **, etc.
|
|
@@ -15,6 +15,73 @@ const logger = createLogger('FORMATTING');
|
|
|
15
15
|
function escapeInlineMarkdown(text) {
|
|
16
16
|
return text.replace(/([*_~|`\\])/g, '\\$1');
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Parses a patchText string (apply_patch format) and counts additions/deletions per file.
|
|
20
|
+
* Patch format uses `*** Add File:`, `*** Update File:`, `*** Delete File:` headers,
|
|
21
|
+
* with diff lines prefixed by `+` (addition) or `-` (deletion) inside `@@` hunks.
|
|
22
|
+
*/
|
|
23
|
+
function parsePatchCounts(patchText) {
|
|
24
|
+
const counts = new Map();
|
|
25
|
+
const lines = patchText.split('\n');
|
|
26
|
+
let currentFile = '';
|
|
27
|
+
let currentType = '';
|
|
28
|
+
let inHunk = false;
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
const addMatch = line.match(/^\*\*\* Add File:\s*(.+)/);
|
|
31
|
+
const updateMatch = line.match(/^\*\*\* Update File:\s*(.+)/);
|
|
32
|
+
const deleteMatch = line.match(/^\*\*\* Delete File:\s*(.+)/);
|
|
33
|
+
if (addMatch || updateMatch || deleteMatch) {
|
|
34
|
+
const match = addMatch || updateMatch || deleteMatch;
|
|
35
|
+
currentFile = (match?.[1] ?? '').trim();
|
|
36
|
+
currentType = addMatch ? 'add' : updateMatch ? 'update' : 'delete';
|
|
37
|
+
counts.set(currentFile, { additions: 0, deletions: 0 });
|
|
38
|
+
inHunk = false;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (line.startsWith('@@')) {
|
|
42
|
+
inHunk = true;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (line.startsWith('*** ')) {
|
|
46
|
+
inHunk = false;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!currentFile) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const entry = counts.get(currentFile);
|
|
53
|
+
if (!entry) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (currentType === 'add') {
|
|
57
|
+
// all content lines in Add File are additions
|
|
58
|
+
if (line.length > 0 && !line.startsWith('*** ')) {
|
|
59
|
+
entry.additions++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else if (currentType === 'delete') {
|
|
63
|
+
// all content lines in Delete File are deletions
|
|
64
|
+
if (line.length > 0 && !line.startsWith('*** ')) {
|
|
65
|
+
entry.deletions++;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else if (inHunk) {
|
|
69
|
+
if (line.startsWith('+')) {
|
|
70
|
+
entry.additions++;
|
|
71
|
+
}
|
|
72
|
+
else if (line.startsWith('-')) {
|
|
73
|
+
entry.deletions++;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return counts;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Normalize whitespace: convert newlines to spaces and collapse consecutive spaces.
|
|
81
|
+
*/
|
|
82
|
+
function normalizeWhitespace(text) {
|
|
83
|
+
return text.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ');
|
|
84
|
+
}
|
|
18
85
|
/**
|
|
19
86
|
* Collects and formats the last N assistant parts from session messages.
|
|
20
87
|
* Used by both /resume and /fork to show recent assistant context.
|
|
@@ -125,6 +192,22 @@ export function getToolSummaryText(part) {
|
|
|
125
192
|
? `*${escapeInlineMarkdown(fileName)}* (+${added}-${removed})`
|
|
126
193
|
: `(+${added}-${removed})`;
|
|
127
194
|
}
|
|
195
|
+
if (part.tool === 'apply_patch') {
|
|
196
|
+
// Only inputs are available when parts are sent during streaming (output/metadata not yet populated)
|
|
197
|
+
const patchText = part.state.input?.patchText || '';
|
|
198
|
+
if (!patchText) {
|
|
199
|
+
return '';
|
|
200
|
+
}
|
|
201
|
+
const patchCounts = parsePatchCounts(patchText);
|
|
202
|
+
return [...patchCounts.entries()]
|
|
203
|
+
.map(([filePath, { additions, deletions }]) => {
|
|
204
|
+
const fileName = filePath.split('/').pop() || '';
|
|
205
|
+
return fileName
|
|
206
|
+
? `*${escapeInlineMarkdown(fileName)}* (+${additions}-${deletions})`
|
|
207
|
+
: `(+${additions}-${deletions})`;
|
|
208
|
+
})
|
|
209
|
+
.join(', ');
|
|
210
|
+
}
|
|
128
211
|
if (part.tool === 'write') {
|
|
129
212
|
const filePath = part.state.input?.filePath || '';
|
|
130
213
|
const content = part.state.input?.content || '';
|
|
@@ -175,7 +258,8 @@ export function getToolSummaryText(part) {
|
|
|
175
258
|
if (value === null || value === undefined)
|
|
176
259
|
return null;
|
|
177
260
|
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
|
178
|
-
const
|
|
261
|
+
const normalized = normalizeWhitespace(stringValue);
|
|
262
|
+
const truncatedValue = normalized.length > 50 ? normalized.slice(0, 50) + '…' : normalized;
|
|
179
263
|
return `${key}: ${truncatedValue}`;
|
|
180
264
|
})
|
|
181
265
|
.filter(Boolean);
|
|
@@ -201,7 +285,7 @@ export function formatTodoList(part) {
|
|
|
201
285
|
return `${num} **${escapeInlineMarkdown(content)}**`;
|
|
202
286
|
}
|
|
203
287
|
export function formatPart(part, prefix) {
|
|
204
|
-
const pfx = prefix ? `${prefix}
|
|
288
|
+
const pfx = prefix ? `${prefix} ⋅ ` : '';
|
|
205
289
|
if (part.type === 'text') {
|
|
206
290
|
if (!part.text?.trim())
|
|
207
291
|
return '';
|
|
@@ -278,12 +362,13 @@ export function formatPart(part, prefix) {
|
|
|
278
362
|
if (part.state.status === 'error') {
|
|
279
363
|
return '⨯';
|
|
280
364
|
}
|
|
281
|
-
if (part.tool === 'edit' || part.tool === 'write') {
|
|
365
|
+
if (part.tool === 'edit' || part.tool === 'write' || part.tool === 'apply_patch') {
|
|
282
366
|
return '◼︎';
|
|
283
367
|
}
|
|
284
368
|
return '┣';
|
|
285
369
|
})();
|
|
286
|
-
|
|
370
|
+
const toolParts = [part.tool, toolTitle, summaryText].filter(Boolean).join(' ');
|
|
371
|
+
return `${icon} ${pfx}${toolParts}`;
|
|
287
372
|
}
|
|
288
373
|
logger.warn('Unknown part type:', part);
|
|
289
374
|
return '';
|
package/dist/openai-realtime.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// @ts-nocheck
|
|
4
4
|
import { RealtimeClient } from '@openai/realtime-api-beta';
|
|
5
5
|
import { writeFile } from 'fs';
|
|
6
|
-
import { createLogger } from './logger.js';
|
|
7
|
-
const openaiLogger = createLogger(
|
|
6
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
7
|
+
const openaiLogger = createLogger(LogPrefix.OPENAI);
|
|
8
8
|
const audioParts = [];
|
|
9
9
|
function saveBinaryFile(fileName, content) {
|
|
10
10
|
writeFile(fileName, content, 'utf8', (err) => {
|
package/dist/opencode.js
CHANGED
|
@@ -8,9 +8,9 @@ import net from 'node:net';
|
|
|
8
8
|
import { createOpencodeClient } from '@opencode-ai/sdk';
|
|
9
9
|
import { createOpencodeClient as createOpencodeClientV2, } from '@opencode-ai/sdk/v2';
|
|
10
10
|
import * as errore from 'errore';
|
|
11
|
-
import { createLogger } from './logger.js';
|
|
11
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
12
12
|
import { DirectoryNotAccessibleError, ServerStartError, ServerNotReadyError, FetchError, } from './errors.js';
|
|
13
|
-
const opencodeLogger = createLogger(
|
|
13
|
+
const opencodeLogger = createLogger(LogPrefix.OPENCODE);
|
|
14
14
|
const opencodeServers = new Map();
|
|
15
15
|
const serverRetryCount = new Map();
|
|
16
16
|
async function getOpenPort() {
|
|
@@ -32,30 +32,24 @@ async function getOpenPort() {
|
|
|
32
32
|
});
|
|
33
33
|
}
|
|
34
34
|
async function waitForServer(port, maxAttempts = 30) {
|
|
35
|
+
const endpoint = `http://127.0.0.1:${port}/api/health`;
|
|
35
36
|
for (let i = 0; i < maxAttempts; i++) {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return true;
|
|
53
|
-
}
|
|
54
|
-
const body = await response.text();
|
|
55
|
-
// Fatal errors that won't resolve with retrying
|
|
56
|
-
if (body.includes('BunInstallFailedError')) {
|
|
57
|
-
return new ServerStartError({ port, reason: body.slice(0, 200) });
|
|
58
|
-
}
|
|
37
|
+
const response = await errore.tryAsync({
|
|
38
|
+
try: () => fetch(endpoint),
|
|
39
|
+
catch: (e) => new FetchError({ url: endpoint, cause: e }),
|
|
40
|
+
});
|
|
41
|
+
if (response instanceof Error) {
|
|
42
|
+
// Connection refused or other transient errors - continue polling
|
|
43
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (response.status < 500) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
const body = await response.text();
|
|
50
|
+
// Fatal errors that won't resolve with retrying
|
|
51
|
+
if (body.includes('BunInstallFailedError')) {
|
|
52
|
+
return new ServerStartError({ port, reason: body.slice(0, 200) });
|
|
59
53
|
}
|
|
60
54
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
61
55
|
}
|
package/dist/session-handler.js
CHANGED
|
@@ -7,14 +7,14 @@ import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2
|
|
|
7
7
|
import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js';
|
|
8
8
|
import { formatPart } from './message-formatting.js';
|
|
9
9
|
import { getOpencodeSystemMessage } from './system-message.js';
|
|
10
|
-
import { createLogger } from './logger.js';
|
|
10
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
11
11
|
import { isAbortError } from './utils.js';
|
|
12
12
|
import { showAskUserQuestionDropdowns, cancelPendingQuestion, pendingQuestionContexts, } from './commands/ask-question.js';
|
|
13
13
|
import { showPermissionDropdown, cleanupPermissionContext, addPermissionRequestToContext, } from './commands/permissions.js';
|
|
14
14
|
import * as errore from 'errore';
|
|
15
|
-
const sessionLogger = createLogger(
|
|
16
|
-
const voiceLogger = createLogger(
|
|
17
|
-
const discordLogger = createLogger(
|
|
15
|
+
const sessionLogger = createLogger(LogPrefix.SESSION);
|
|
16
|
+
const voiceLogger = createLogger(LogPrefix.VOICE);
|
|
17
|
+
const discordLogger = createLogger(LogPrefix.DISCORD);
|
|
18
18
|
export const abortControllers = new Map();
|
|
19
19
|
// Track multiple pending permissions per thread (keyed by permission ID)
|
|
20
20
|
// OpenCode handles blocking/sequencing - we just need to track all pending permissions
|
|
@@ -56,18 +56,20 @@ export async function abortAndRetrySession({ sessionId, thread, projectDirectory
|
|
|
56
56
|
}
|
|
57
57
|
sessionLogger.log(`[ABORT+RETRY] Aborting session ${sessionId} for model change`);
|
|
58
58
|
// Abort with special reason so we don't show "completed" message
|
|
59
|
-
|
|
59
|
+
sessionLogger.log(`[ABORT] reason=model-change sessionId=${sessionId} - user changed model mid-request, will retry with new model`);
|
|
60
|
+
controller.abort(new Error('model-change'));
|
|
60
61
|
// Also call the API abort endpoint
|
|
61
62
|
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
62
63
|
if (getClient instanceof Error) {
|
|
63
64
|
sessionLogger.error(`[ABORT+RETRY] Failed to initialize OpenCode client:`, getClient.message);
|
|
64
65
|
return false;
|
|
65
66
|
}
|
|
67
|
+
sessionLogger.log(`[ABORT-API] reason=model-change sessionId=${sessionId} - sending API abort for model change retry`);
|
|
66
68
|
const abortResult = await errore.tryAsync(() => {
|
|
67
69
|
return getClient().session.abort({ path: { id: sessionId } });
|
|
68
70
|
});
|
|
69
71
|
if (abortResult instanceof Error) {
|
|
70
|
-
sessionLogger.log(`[ABORT
|
|
72
|
+
sessionLogger.log(`[ABORT-API] API abort call failed (may already be done):`, abortResult);
|
|
71
73
|
}
|
|
72
74
|
// Small delay to let the abort propagate
|
|
73
75
|
await new Promise((resolve) => {
|
|
@@ -176,7 +178,9 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
176
178
|
const existingController = abortControllers.get(session.id);
|
|
177
179
|
if (existingController) {
|
|
178
180
|
voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`);
|
|
181
|
+
sessionLogger.log(`[ABORT] reason=new-request sessionId=${session.id} threadId=${thread.id} - new user message arrived while previous request was still running`);
|
|
179
182
|
existingController.abort(new Error('New request started'));
|
|
183
|
+
sessionLogger.log(`[ABORT-API] reason=new-request sessionId=${session.id} - sending API abort because new message arrived`);
|
|
180
184
|
const abortResult = await errore.tryAsync(() => {
|
|
181
185
|
return getClient().session.abort({
|
|
182
186
|
path: { id: session.id },
|
|
@@ -184,7 +188,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
184
188
|
});
|
|
185
189
|
});
|
|
186
190
|
if (abortResult instanceof Error) {
|
|
187
|
-
sessionLogger.log(`[ABORT] Server abort failed (may be already done):`, abortResult);
|
|
191
|
+
sessionLogger.log(`[ABORT-API] Server abort failed (may be already done):`, abortResult);
|
|
188
192
|
}
|
|
189
193
|
}
|
|
190
194
|
// Auto-reject ALL pending permissions for this thread
|
|
@@ -220,10 +224,10 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
220
224
|
await sendThreadMessage(thread, `⚠️ ${rejectedCount} pending permission request${plural} auto-rejected due to new message`);
|
|
221
225
|
}
|
|
222
226
|
}
|
|
223
|
-
//
|
|
224
|
-
const
|
|
225
|
-
if (
|
|
226
|
-
sessionLogger.log(`[QUESTION]
|
|
227
|
+
// Answer any pending question tool with the user's message (silently, no thread message)
|
|
228
|
+
const questionAnswered = await cancelPendingQuestion(thread.id, prompt);
|
|
229
|
+
if (questionAnswered) {
|
|
230
|
+
sessionLogger.log(`[QUESTION] Answered pending question with user message`);
|
|
227
231
|
}
|
|
228
232
|
const abortController = new AbortController();
|
|
229
233
|
abortControllers.set(session.id, abortController);
|
|
@@ -272,10 +276,14 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
272
276
|
let usedAgent;
|
|
273
277
|
let tokensUsedInSession = 0;
|
|
274
278
|
let lastDisplayedContextPercentage = 0;
|
|
279
|
+
let lastRateLimitDisplayTime = 0;
|
|
275
280
|
let modelContextLimit;
|
|
276
281
|
let assistantMessageId;
|
|
277
282
|
let handlerPromise = null;
|
|
278
283
|
let typingInterval = null;
|
|
284
|
+
let hasSentParts = false;
|
|
285
|
+
let promptResolved = false;
|
|
286
|
+
let hasReceivedEvent = false;
|
|
279
287
|
function startTyping() {
|
|
280
288
|
if (abortController.signal.aborted) {
|
|
281
289
|
discordLogger.log(`Not starting typing, already aborted`);
|
|
@@ -312,12 +320,14 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
312
320
|
}
|
|
313
321
|
};
|
|
314
322
|
}
|
|
315
|
-
//
|
|
323
|
+
// Read verbosity dynamically so mid-session /verbosity changes take effect immediately
|
|
316
324
|
const verbosityChannelId = channelId || thread.parentId || thread.id;
|
|
317
|
-
const
|
|
325
|
+
const getVerbosity = () => {
|
|
326
|
+
return getChannelVerbosity(verbosityChannelId);
|
|
327
|
+
};
|
|
318
328
|
const sendPartMessage = async (part) => {
|
|
319
329
|
// In text-only mode, only send text parts (the ⬥ diamond messages)
|
|
320
|
-
if (
|
|
330
|
+
if (getVerbosity() === 'text-only' && part.type !== 'text') {
|
|
321
331
|
return;
|
|
322
332
|
}
|
|
323
333
|
const content = formatPart(part) + '\n\n';
|
|
@@ -335,6 +345,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
335
345
|
discordLogger.error(`ERROR: Failed to send part ${part.id}:`, sendResult);
|
|
336
346
|
return;
|
|
337
347
|
}
|
|
348
|
+
hasSentParts = true;
|
|
338
349
|
sentPartIds.add(part.id);
|
|
339
350
|
getDatabase()
|
|
340
351
|
.prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
|
|
@@ -391,6 +402,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
391
402
|
if (msg.sessionID !== session.id) {
|
|
392
403
|
return;
|
|
393
404
|
}
|
|
405
|
+
hasReceivedEvent = true;
|
|
394
406
|
if (msg.role !== 'assistant') {
|
|
395
407
|
return;
|
|
396
408
|
}
|
|
@@ -476,7 +488,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
476
488
|
const label = `${agent}-${agentSpawnCounts[agent]}`;
|
|
477
489
|
subtaskSessions.set(childSessionId, { label, assistantMessageId: undefined });
|
|
478
490
|
// Skip task messages in text-only mode
|
|
479
|
-
if (
|
|
491
|
+
if (getVerbosity() !== 'text-only') {
|
|
480
492
|
const taskDisplay = `┣ task **${label}** _${description}_`;
|
|
481
493
|
await sendThreadMessage(thread, taskDisplay + '\n\n');
|
|
482
494
|
}
|
|
@@ -485,7 +497,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
485
497
|
}
|
|
486
498
|
return;
|
|
487
499
|
}
|
|
488
|
-
if (part.type === 'tool' && part.state.status === 'completed') {
|
|
500
|
+
if (part.type === 'tool' && part.state.status === 'completed' && getVerbosity() !== 'text-only') {
|
|
489
501
|
const output = part.state.output || '';
|
|
490
502
|
const outputTokens = Math.ceil(output.length / 4);
|
|
491
503
|
const largeOutputThreshold = 3000;
|
|
@@ -531,7 +543,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
531
543
|
};
|
|
532
544
|
const handleSubtaskPart = async (part, subtaskInfo) => {
|
|
533
545
|
// In text-only mode, skip all subtask output (they're tool-related)
|
|
534
|
-
if (
|
|
546
|
+
if (getVerbosity() === 'text-only') {
|
|
535
547
|
return;
|
|
536
548
|
}
|
|
537
549
|
if (part.type === 'step-start' || part.type === 'step-finish') {
|
|
@@ -598,10 +610,15 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
598
610
|
}
|
|
599
611
|
};
|
|
600
612
|
const handlePermissionAsked = async (permission) => {
|
|
601
|
-
|
|
602
|
-
|
|
613
|
+
const isMainSession = permission.sessionID === session.id;
|
|
614
|
+
const isSubtaskSession = subtaskSessions.has(permission.sessionID);
|
|
615
|
+
if (!isMainSession && !isSubtaskSession) {
|
|
616
|
+
voiceLogger.log(`[PERMISSION IGNORED] Permission for unknown session (expected: ${session.id} or subtask, got: ${permission.sessionID})`);
|
|
603
617
|
return;
|
|
604
618
|
}
|
|
619
|
+
const subtaskLabel = isSubtaskSession
|
|
620
|
+
? subtaskSessions.get(permission.sessionID)?.label
|
|
621
|
+
: undefined;
|
|
605
622
|
const dedupeKey = buildPermissionDedupeKey({ permission, directory });
|
|
606
623
|
const threadPermissions = pendingPermissions.get(thread.id);
|
|
607
624
|
const existingPending = threadPermissions
|
|
@@ -634,7 +651,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
634
651
|
}
|
|
635
652
|
return;
|
|
636
653
|
}
|
|
637
|
-
sessionLogger.log(`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`);
|
|
654
|
+
sessionLogger.log(`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}${subtaskLabel ? `, subtask=${subtaskLabel}` : ''}`);
|
|
638
655
|
if (stopTyping) {
|
|
639
656
|
stopTyping();
|
|
640
657
|
stopTyping = null;
|
|
@@ -643,6 +660,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
643
660
|
thread,
|
|
644
661
|
permission,
|
|
645
662
|
directory,
|
|
663
|
+
subtaskLabel,
|
|
646
664
|
});
|
|
647
665
|
if (!pendingPermissions.has(thread.id)) {
|
|
648
666
|
pendingPermissions.set(thread.id, new Map());
|
|
@@ -656,7 +674,9 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
656
674
|
});
|
|
657
675
|
};
|
|
658
676
|
const handlePermissionReplied = ({ requestID, reply, sessionID, }) => {
|
|
659
|
-
|
|
677
|
+
const isMainSession = sessionID === session.id;
|
|
678
|
+
const isSubtaskSession = subtaskSessions.has(sessionID);
|
|
679
|
+
if (!isMainSession && !isSubtaskSession) {
|
|
660
680
|
return;
|
|
661
681
|
}
|
|
662
682
|
sessionLogger.log(`Permission ${requestID} replied with: ${reply}`);
|
|
@@ -706,10 +726,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
706
726
|
sessionLogger.log(`[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`);
|
|
707
727
|
await sendThreadMessage(thread, `» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`);
|
|
708
728
|
setImmediate(() => {
|
|
729
|
+
const prefixedPrompt = `<discord-user name="${nextMessage.username}" />\n${nextMessage.prompt}`;
|
|
709
730
|
void errore
|
|
710
731
|
.tryAsync(async () => {
|
|
711
732
|
return handleOpencodeSession({
|
|
712
|
-
prompt:
|
|
733
|
+
prompt: prefixedPrompt,
|
|
713
734
|
thread,
|
|
714
735
|
projectDirectory: directory,
|
|
715
736
|
images: nextMessage.images,
|
|
@@ -725,10 +746,42 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
725
746
|
});
|
|
726
747
|
});
|
|
727
748
|
};
|
|
749
|
+
const handleSessionStatus = async (properties) => {
|
|
750
|
+
if (properties.sessionID !== session.id) {
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
if (properties.status.type !== 'retry') {
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
// Throttle to once per 10 seconds
|
|
757
|
+
const now = Date.now();
|
|
758
|
+
if (now - lastRateLimitDisplayTime < 10_000) {
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
lastRateLimitDisplayTime = now;
|
|
762
|
+
const { attempt, message, next } = properties.status;
|
|
763
|
+
const remainingMs = Math.max(0, next - now);
|
|
764
|
+
const remainingSec = Math.ceil(remainingMs / 1000);
|
|
765
|
+
const duration = (() => {
|
|
766
|
+
if (remainingSec < 60) {
|
|
767
|
+
return `${remainingSec}s`;
|
|
768
|
+
}
|
|
769
|
+
const mins = Math.floor(remainingSec / 60);
|
|
770
|
+
const secs = remainingSec % 60;
|
|
771
|
+
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
|
772
|
+
})();
|
|
773
|
+
const chunk = `⬦ ${message} - retrying in ${duration} (attempt #${attempt})`;
|
|
774
|
+
await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
|
|
775
|
+
};
|
|
728
776
|
const handleSessionIdle = (idleSessionId) => {
|
|
729
777
|
if (idleSessionId === session.id) {
|
|
730
|
-
|
|
731
|
-
|
|
778
|
+
if (!promptResolved || !hasReceivedEvent) {
|
|
779
|
+
sessionLogger.log(`[SESSION IDLE] Ignoring idle event for ${session.id} (prompt not resolved or no events yet)`);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, ending stream`);
|
|
783
|
+
sessionLogger.log(`[ABORT] reason=finished sessionId=${session.id} threadId=${thread.id} - session completed normally, received idle event after prompt resolved`);
|
|
784
|
+
abortController.abort(new Error('finished'));
|
|
732
785
|
return;
|
|
733
786
|
}
|
|
734
787
|
if (!subtaskSessions.has(idleSessionId)) {
|
|
@@ -763,6 +816,9 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
763
816
|
case 'session.idle':
|
|
764
817
|
handleSessionIdle(event.properties.sessionID);
|
|
765
818
|
break;
|
|
819
|
+
case 'session.status':
|
|
820
|
+
await handleSessionStatus(event.properties);
|
|
821
|
+
break;
|
|
766
822
|
default:
|
|
767
823
|
break;
|
|
768
824
|
}
|
|
@@ -791,7 +847,8 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
791
847
|
stopTyping();
|
|
792
848
|
stopTyping = null;
|
|
793
849
|
}
|
|
794
|
-
|
|
850
|
+
const abortReason = abortController.signal.reason?.message;
|
|
851
|
+
if (!abortController.signal.aborted || abortReason === 'finished') {
|
|
795
852
|
const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime);
|
|
796
853
|
const attachCommand = port ? ` ⋅ ${session.id}` : '';
|
|
797
854
|
const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
|
|
@@ -845,8 +902,9 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
845
902
|
// Send the queued message as a new prompt (recursive call)
|
|
846
903
|
// Use setImmediate to avoid blocking and allow this finally to complete
|
|
847
904
|
setImmediate(() => {
|
|
905
|
+
const prefixedPrompt = `<discord-user name="${nextMessage.username}" />\n${nextMessage.prompt}`;
|
|
848
906
|
handleOpencodeSession({
|
|
849
|
-
prompt:
|
|
907
|
+
prompt: prefixedPrompt,
|
|
850
908
|
thread,
|
|
851
909
|
projectDirectory,
|
|
852
910
|
images: nextMessage.images,
|
|
@@ -860,7 +918,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
860
918
|
}
|
|
861
919
|
}
|
|
862
920
|
else {
|
|
863
|
-
sessionLogger.log(`Session was aborted (reason: ${
|
|
921
|
+
sessionLogger.log(`Session was aborted (reason: ${abortReason}), skipping duration message`);
|
|
864
922
|
}
|
|
865
923
|
}
|
|
866
924
|
};
|
|
@@ -921,6 +979,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
921
979
|
mainRepoDirectory: worktreeInfo.project_directory,
|
|
922
980
|
}
|
|
923
981
|
: undefined;
|
|
982
|
+
hasSentParts = false;
|
|
924
983
|
const response = command
|
|
925
984
|
? await getClient().session.command({
|
|
926
985
|
path: { id: session.id },
|
|
@@ -958,7 +1017,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
958
1017
|
})();
|
|
959
1018
|
throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`);
|
|
960
1019
|
}
|
|
961
|
-
|
|
1020
|
+
promptResolved = true;
|
|
962
1021
|
sessionLogger.log(`Successfully sent prompt, got response`);
|
|
963
1022
|
if (originalMessage) {
|
|
964
1023
|
const reactionResult = await errore.tryAsync(async () => {
|
|
@@ -987,7 +1046,8 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
987
1046
|
return;
|
|
988
1047
|
}
|
|
989
1048
|
sessionLogger.error(`ERROR: Failed to send prompt:`, promptError);
|
|
990
|
-
|
|
1049
|
+
sessionLogger.log(`[ABORT] reason=error sessionId=${session.id} threadId=${thread.id} - prompt failed with error: ${promptError.message}`);
|
|
1050
|
+
abortController.abort(new Error('error'));
|
|
991
1051
|
if (originalMessage) {
|
|
992
1052
|
const reactionResult = await errore.tryAsync(async () => {
|
|
993
1053
|
await originalMessage.reactions.removeAll();
|