kimaki 0.4.77 → 0.4.78
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 +27 -0
- package/dist/commands/diff.js +20 -85
- package/dist/commands/screenshare.js +295 -0
- package/dist/critique-utils.js +95 -0
- package/dist/diff-patch-plugin.js +314 -0
- package/dist/discord-bot.js +1 -1
- package/dist/interaction-handler.js +10 -0
- package/dist/message-formatting.js +3 -62
- package/dist/onboarding-tutorial-plugin.js +1 -1
- package/dist/opencode-plugin.js +4 -4
- package/dist/patch-text-parser.js +97 -0
- package/dist/session-handler/thread-session-runtime.js +1 -1
- package/dist/websockify.js +69 -0
- package/package.json +7 -5
- package/skills/event-sourcing-state/SKILL.md +188 -34
- package/skills/playwriter/SKILL.md +1 -1
- package/src/cli.ts +35 -0
- package/src/commands/diff.ts +25 -99
- package/src/commands/screenshare.ts +354 -0
- package/src/critique-utils.ts +139 -0
- package/src/discord-bot.ts +1 -1
- package/src/interaction-handler.ts +15 -0
- package/src/message-formatting.ts +3 -68
- package/src/onboarding-tutorial-plugin.ts +1 -1
- package/src/opencode-plugin.ts +5 -4
- package/src/patch-text-parser.ts +107 -0
- package/src/session-handler/thread-session-runtime.ts +2 -1
- package/src/websockify.ts +101 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// Tracks tool-driven file changes and writes per-message/session .patch files
|
|
2
|
+
// to $KIMAKI_DATA_DIR/diffs/ (default ~/.kimaki/diffs/).
|
|
3
|
+
//
|
|
4
|
+
// The bot process reads these patch files in emitFooter() to upload them via
|
|
5
|
+
// critique and append [ses diff] / [msg diff] links to the footer message.
|
|
6
|
+
//
|
|
7
|
+
// Lifecycle:
|
|
8
|
+
// tool.execute.before → snapshot file contents before edit
|
|
9
|
+
// tool.execute.after → snapshot again, record the delta
|
|
10
|
+
// message.updated (completed) → write {sessionId}-{messageId}.patch
|
|
11
|
+
// session.idle → write {sessionId}.patch (cumulative, overwrites)
|
|
12
|
+
// session.deleted → cleanup in-memory state
|
|
13
|
+
//
|
|
14
|
+
// Pruning: caps the diffs folder at MAX_PATCH_FILES, deleting oldest by mtime.
|
|
15
|
+
import { createTwoFilesPatch } from 'diff';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { extractPatchFilePaths } from './patch-text-parser.js';
|
|
19
|
+
const TOOLS = new Set(['edit', 'write', 'apply_patch', 'multiedit']);
|
|
20
|
+
// Skip files larger than 512KB to avoid memory spikes
|
|
21
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
22
|
+
// Max patch files before pruning oldest
|
|
23
|
+
const MAX_PATCH_FILES = 200;
|
|
24
|
+
// Pending tool calls older than 10 min are garbage collected
|
|
25
|
+
const PENDING_TTL_MS = 10 * 60 * 1000;
|
|
26
|
+
function extractPaths(tool, args) {
|
|
27
|
+
if (tool === 'edit' || tool === 'write' || tool === 'multiedit') {
|
|
28
|
+
return typeof args.filePath === 'string' ? [args.filePath] : [];
|
|
29
|
+
}
|
|
30
|
+
if (tool === 'apply_patch') {
|
|
31
|
+
const text = (args.patchText ?? args.patch ?? '');
|
|
32
|
+
return extractPatchFilePaths(text);
|
|
33
|
+
}
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
function buildPatch(files) {
|
|
37
|
+
const parts = [];
|
|
38
|
+
for (const [file, { first, last }] of files) {
|
|
39
|
+
if (first === last) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
parts.push(createTwoFilesPatch(file, file, first, last));
|
|
43
|
+
}
|
|
44
|
+
return parts.join('\n');
|
|
45
|
+
}
|
|
46
|
+
async function readSafe(file) {
|
|
47
|
+
const stat = await fs.promises.stat(file).catch(() => {
|
|
48
|
+
return undefined;
|
|
49
|
+
});
|
|
50
|
+
if (!stat) {
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
return fs.promises.readFile(file, 'utf8').catch(() => {
|
|
57
|
+
return '';
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
async function prunePatchDir(dir) {
|
|
61
|
+
const entries = await fs.promises.readdir(dir).catch(() => {
|
|
62
|
+
return [];
|
|
63
|
+
});
|
|
64
|
+
if (entries.length <= MAX_PATCH_FILES) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const files = (await Promise.all(entries.map(async (name) => {
|
|
68
|
+
const full = path.join(dir, name);
|
|
69
|
+
const stat = await fs.promises.stat(full).catch(() => {
|
|
70
|
+
return undefined;
|
|
71
|
+
});
|
|
72
|
+
return stat?.isFile() ? { full, mtime: stat.mtimeMs } : undefined;
|
|
73
|
+
}))).filter((f) => {
|
|
74
|
+
return f !== undefined;
|
|
75
|
+
});
|
|
76
|
+
files.sort((a, b) => {
|
|
77
|
+
return b.mtime - a.mtime;
|
|
78
|
+
});
|
|
79
|
+
await Promise.all(files.slice(MAX_PATCH_FILES).map((f) => {
|
|
80
|
+
return fs.promises.unlink(f.full).catch(() => { });
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
// Atomic write: write to a temp file then rename to avoid torn reads
|
|
84
|
+
// from the bot process reading a partially-written patch.
|
|
85
|
+
// If content is empty, deletes any existing file so stale patches from
|
|
86
|
+
// previous turns aren't re-uploaded by the bot.
|
|
87
|
+
async function writePatch({ dir, name, content, }) {
|
|
88
|
+
const target = path.join(dir, name);
|
|
89
|
+
if (!content) {
|
|
90
|
+
await fs.promises.unlink(target).catch(() => { });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
94
|
+
const tmp = `${target}.tmp.${process.pid}`;
|
|
95
|
+
await fs.promises.writeFile(tmp, content);
|
|
96
|
+
await fs.promises.rename(tmp, target);
|
|
97
|
+
await prunePatchDir(dir);
|
|
98
|
+
}
|
|
99
|
+
const diffPatchPlugin = async (input) => {
|
|
100
|
+
const dataDir = process.env.KIMAKI_DATA_DIR || path.join(process.env.HOME || '~', '.kimaki');
|
|
101
|
+
const dir = path.join(dataDir, 'diffs');
|
|
102
|
+
// session → file → { first snapshot, last snapshot }
|
|
103
|
+
const sessions = new Map();
|
|
104
|
+
// session → messageId → file → { first, last }
|
|
105
|
+
const messages = new Map();
|
|
106
|
+
// callID → pending tool info (for matching before/after)
|
|
107
|
+
const pending = new Map();
|
|
108
|
+
// session → current active assistant messageId
|
|
109
|
+
const activeMessage = new Map();
|
|
110
|
+
// dedup guard: "sessionID:messageID:completedTimestamp"
|
|
111
|
+
const completed = new Set();
|
|
112
|
+
// child session → root (top-level) parent session.
|
|
113
|
+
// When a task tool spawns a subtask, tools inside that subtask fire with
|
|
114
|
+
// the child sessionID. We resolve up to the root parent so file snapshots
|
|
115
|
+
// always roll up into the top-level session's cumulative patch.
|
|
116
|
+
const childToRoot = new Map();
|
|
117
|
+
/** Resolve up the parent chain to find the root (top-level) session. */
|
|
118
|
+
function resolveRoot(sessionID) {
|
|
119
|
+
return childToRoot.get(sessionID) ?? sessionID;
|
|
120
|
+
}
|
|
121
|
+
function ensureSession(sessionID) {
|
|
122
|
+
if (!sessions.has(sessionID)) {
|
|
123
|
+
sessions.set(sessionID, new Map());
|
|
124
|
+
}
|
|
125
|
+
if (!messages.has(sessionID)) {
|
|
126
|
+
messages.set(sessionID, new Map());
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function ensureMessage(sessionID, messageID) {
|
|
130
|
+
const msgs = messages.get(sessionID);
|
|
131
|
+
if (!msgs.has(messageID)) {
|
|
132
|
+
msgs.set(messageID, new Map());
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function setFirst(map, file, content) {
|
|
136
|
+
if (!map.has(file)) {
|
|
137
|
+
map.set(file, { first: content, last: content });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function prunePending() {
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
for (const [id, item] of pending) {
|
|
143
|
+
if (now - item.createdAt > PENDING_TTL_MS) {
|
|
144
|
+
pending.delete(id);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function cleanupSession(sessionID) {
|
|
149
|
+
sessions.delete(sessionID);
|
|
150
|
+
messages.delete(sessionID);
|
|
151
|
+
activeMessage.delete(sessionID);
|
|
152
|
+
for (const [id, p] of pending) {
|
|
153
|
+
if (p.sessionID === sessionID) {
|
|
154
|
+
pending.delete(id);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const key of completed) {
|
|
158
|
+
if (key.startsWith(sessionID + ':')) {
|
|
159
|
+
completed.delete(key);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Clean up child→root mappings pointing to or from this session
|
|
163
|
+
for (const [child, root] of childToRoot) {
|
|
164
|
+
if (child === sessionID || root === sessionID) {
|
|
165
|
+
childToRoot.delete(child);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
'tool.execute.before': async ({ tool, sessionID, callID }, { args }) => {
|
|
171
|
+
if (!TOOLS.has(tool)) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const raw = extractPaths(tool, args);
|
|
175
|
+
if (!raw.length) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
prunePending();
|
|
179
|
+
// Resolve root session so child edits roll up into the parent's patch.
|
|
180
|
+
const rootID = resolveRoot(sessionID);
|
|
181
|
+
ensureSession(rootID);
|
|
182
|
+
const rootFiles = sessions.get(rootID);
|
|
183
|
+
const msgID = activeMessage.get(rootID);
|
|
184
|
+
const captured = [];
|
|
185
|
+
for (const r of raw) {
|
|
186
|
+
const p = path.isAbsolute(r)
|
|
187
|
+
? r
|
|
188
|
+
: path.resolve(input.directory, r);
|
|
189
|
+
const content = await readSafe(p);
|
|
190
|
+
if (content === undefined) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
setFirst(rootFiles, p, content);
|
|
194
|
+
if (msgID) {
|
|
195
|
+
ensureMessage(rootID, msgID);
|
|
196
|
+
setFirst(messages.get(rootID).get(msgID), p, content);
|
|
197
|
+
}
|
|
198
|
+
captured.push(p);
|
|
199
|
+
}
|
|
200
|
+
if (captured.length) {
|
|
201
|
+
pending.set(callID, {
|
|
202
|
+
// Store rootID so tool.execute.after updates the correct maps
|
|
203
|
+
sessionID: rootID,
|
|
204
|
+
messageID: msgID,
|
|
205
|
+
paths: captured,
|
|
206
|
+
createdAt: Date.now(),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
'tool.execute.after': async ({ callID }) => {
|
|
211
|
+
prunePending();
|
|
212
|
+
const call = pending.get(callID);
|
|
213
|
+
if (!call) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
pending.delete(callID);
|
|
217
|
+
const sessionFiles = sessions.get(call.sessionID);
|
|
218
|
+
// Use messageID captured at before-time, not current activeMessage
|
|
219
|
+
const msgFiles = call.messageID
|
|
220
|
+
? messages.get(call.sessionID)?.get(call.messageID)
|
|
221
|
+
: undefined;
|
|
222
|
+
for (const p of call.paths) {
|
|
223
|
+
const content = await readSafe(p);
|
|
224
|
+
if (content === undefined) {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const s = sessionFiles.get(p);
|
|
228
|
+
if (s) {
|
|
229
|
+
s.last = content;
|
|
230
|
+
}
|
|
231
|
+
if (msgFiles) {
|
|
232
|
+
const m = msgFiles.get(p);
|
|
233
|
+
if (m) {
|
|
234
|
+
m.last = content;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
event: async ({ event }) => {
|
|
240
|
+
// Track child→root session mapping from session.updated events.
|
|
241
|
+
// OpenCode emits session.updated with parentID set when a task tool
|
|
242
|
+
// creates a child session. This fires BEFORE the child starts executing
|
|
243
|
+
// tools, so the mapping is ready when child tool.execute.before fires.
|
|
244
|
+
if (event.type === 'session.updated') {
|
|
245
|
+
const info = event.properties.info;
|
|
246
|
+
if (info.id && info.parentID) {
|
|
247
|
+
const rootID = resolveRoot(info.parentID);
|
|
248
|
+
childToRoot.set(info.id, rootID);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (event.type === 'message.updated' &&
|
|
252
|
+
event.properties.info.role === 'assistant') {
|
|
253
|
+
const msg = event.properties.info;
|
|
254
|
+
activeMessage.set(msg.sessionID, msg.id);
|
|
255
|
+
if (msg.time.completed) {
|
|
256
|
+
const key = `${msg.sessionID}:${msg.id}:${msg.time.completed}`;
|
|
257
|
+
if (completed.has(key)) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
completed.add(key);
|
|
261
|
+
const msgFiles = messages.get(msg.sessionID)?.get(msg.id);
|
|
262
|
+
if (msgFiles?.size) {
|
|
263
|
+
const patch = buildPatch(msgFiles);
|
|
264
|
+
await writePatch({
|
|
265
|
+
dir,
|
|
266
|
+
name: `${msg.sessionID}-${msg.id}.patch`,
|
|
267
|
+
content: patch,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
messages.get(msg.sessionID)?.delete(msg.id);
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (event.type === 'session.idle') {
|
|
275
|
+
const sessionID = event.properties.sessionID;
|
|
276
|
+
// Child sessions: edits already rolled up into root, skip patch write.
|
|
277
|
+
if (childToRoot.has(sessionID)) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const sessionFiles = sessions.get(sessionID);
|
|
281
|
+
if (sessionFiles?.size) {
|
|
282
|
+
const patch = buildPatch(sessionFiles);
|
|
283
|
+
await writePatch({
|
|
284
|
+
dir,
|
|
285
|
+
name: `${sessionID}.patch`,
|
|
286
|
+
content: patch,
|
|
287
|
+
});
|
|
288
|
+
// Reset first snapshots to current state so next turn is incremental
|
|
289
|
+
for (const state of sessionFiles.values()) {
|
|
290
|
+
state.first = state.last;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
messages.get(sessionID)?.clear();
|
|
294
|
+
activeMessage.delete(sessionID);
|
|
295
|
+
for (const [id, p] of pending) {
|
|
296
|
+
if (p.sessionID === sessionID) {
|
|
297
|
+
pending.delete(id);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Clear completed dedup keys for this session to bound memory growth
|
|
301
|
+
for (const key of completed) {
|
|
302
|
+
if (key.startsWith(sessionID + ':')) {
|
|
303
|
+
completed.delete(key);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (event.type === 'session.deleted') {
|
|
309
|
+
cleanupSession(event.properties.info.id);
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
};
|
|
314
|
+
export default diffPatchPlugin;
|
package/dist/discord-bot.js
CHANGED
|
@@ -599,7 +599,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
599
599
|
});
|
|
600
600
|
}
|
|
601
601
|
else {
|
|
602
|
-
discordLogger.log(`Channel type ${channel.type} is not supported`)
|
|
602
|
+
// discordLogger.log(`Channel type ${channel.type} is not supported`)
|
|
603
603
|
}
|
|
604
604
|
}
|
|
605
605
|
catch (error) {
|
|
@@ -37,6 +37,7 @@ import { handleContextUsageCommand } from './commands/context-usage.js';
|
|
|
37
37
|
import { handleSessionIdCommand } from './commands/session-id.js';
|
|
38
38
|
import { handleUpgradeAndRestartCommand } from './commands/upgrade.js';
|
|
39
39
|
import { handleMcpCommand, handleMcpSelectMenu } from './commands/mcp.js';
|
|
40
|
+
import { handleScreenshareCommand, handleScreenshareStopCommand, } from './commands/screenshare.js';
|
|
40
41
|
import { handleModelVariantSelectMenu } from './commands/model.js';
|
|
41
42
|
import { handleModelVariantCommand, handleVariantQuickSelectMenu, handleVariantScopeSelectMenu, } from './commands/model-variant.js';
|
|
42
43
|
import { hasKimakiBotPermission } from './discord-utils.js';
|
|
@@ -211,6 +212,15 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
211
212
|
case 'mcp':
|
|
212
213
|
await handleMcpCommand({ command: interaction, appId });
|
|
213
214
|
return;
|
|
215
|
+
case 'screenshare':
|
|
216
|
+
await handleScreenshareCommand({ command: interaction, appId });
|
|
217
|
+
return;
|
|
218
|
+
case 'screenshare-stop':
|
|
219
|
+
await handleScreenshareStopCommand({
|
|
220
|
+
command: interaction,
|
|
221
|
+
appId,
|
|
222
|
+
});
|
|
223
|
+
return;
|
|
214
224
|
}
|
|
215
225
|
// Handle quick agent commands (ending with -agent suffix, but not the base /agent command)
|
|
216
226
|
if (interaction.commandName.endsWith('-agent') &&
|
|
@@ -5,6 +5,7 @@ import * as errore from 'errore';
|
|
|
5
5
|
import { createLogger, LogPrefix } from './logger.js';
|
|
6
6
|
import { FetchError } from './errors.js';
|
|
7
7
|
import { processImage } from './image-utils.js';
|
|
8
|
+
import { parsePatchFileCounts } from './patch-text-parser.js';
|
|
8
9
|
const logger = createLogger(LogPrefix.FORMATTING);
|
|
9
10
|
/**
|
|
10
11
|
* Resolves Discord mentions in message content to human-readable names.
|
|
@@ -36,67 +37,7 @@ export function resolveMentions(message) {
|
|
|
36
37
|
function escapeInlineMarkdown(text) {
|
|
37
38
|
return text.replace(/([*_~|`\\])/g, '\\$1');
|
|
38
39
|
}
|
|
39
|
-
|
|
40
|
-
* Parses a patchText string (apply_patch format) and counts additions/deletions per file.
|
|
41
|
-
* Patch format uses `*** Add File:`, `*** Update File:`, `*** Delete File:` headers,
|
|
42
|
-
* with diff lines prefixed by `+` (addition) or `-` (deletion) inside `@@` hunks.
|
|
43
|
-
*/
|
|
44
|
-
function parsePatchCounts(patchText) {
|
|
45
|
-
const counts = new Map();
|
|
46
|
-
const lines = patchText.split('\n');
|
|
47
|
-
let currentFile = '';
|
|
48
|
-
let currentType = '';
|
|
49
|
-
let inHunk = false;
|
|
50
|
-
for (const line of lines) {
|
|
51
|
-
const addMatch = line.match(/^\*\*\* Add File:\s*(.+)/);
|
|
52
|
-
const updateMatch = line.match(/^\*\*\* Update File:\s*(.+)/);
|
|
53
|
-
const deleteMatch = line.match(/^\*\*\* Delete File:\s*(.+)/);
|
|
54
|
-
if (addMatch || updateMatch || deleteMatch) {
|
|
55
|
-
const match = addMatch || updateMatch || deleteMatch;
|
|
56
|
-
currentFile = (match?.[1] ?? '').trim();
|
|
57
|
-
currentType = addMatch ? 'add' : updateMatch ? 'update' : 'delete';
|
|
58
|
-
counts.set(currentFile, { additions: 0, deletions: 0 });
|
|
59
|
-
inHunk = false;
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
if (line.startsWith('@@')) {
|
|
63
|
-
inHunk = true;
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
if (line.startsWith('*** ')) {
|
|
67
|
-
inHunk = false;
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
if (!currentFile) {
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
const entry = counts.get(currentFile);
|
|
74
|
-
if (!entry) {
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
if (currentType === 'add') {
|
|
78
|
-
// all content lines in Add File are additions
|
|
79
|
-
if (line.length > 0 && !line.startsWith('*** ')) {
|
|
80
|
-
entry.additions++;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
else if (currentType === 'delete') {
|
|
84
|
-
// all content lines in Delete File are deletions
|
|
85
|
-
if (line.length > 0 && !line.startsWith('*** ')) {
|
|
86
|
-
entry.deletions++;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
else if (inHunk) {
|
|
90
|
-
if (line.startsWith('+')) {
|
|
91
|
-
entry.additions++;
|
|
92
|
-
}
|
|
93
|
-
else if (line.startsWith('-')) {
|
|
94
|
-
entry.deletions++;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return counts;
|
|
99
|
-
}
|
|
40
|
+
// parsePatchCounts → imported from patch-text-parser.ts as parsePatchFileCounts
|
|
100
41
|
/**
|
|
101
42
|
* Normalize whitespace: convert newlines to spaces and collapse consecutive spaces.
|
|
102
43
|
*/
|
|
@@ -220,7 +161,7 @@ export function getToolSummaryText(part) {
|
|
|
220
161
|
if (!patchText) {
|
|
221
162
|
return '';
|
|
222
163
|
}
|
|
223
|
-
const patchCounts =
|
|
164
|
+
const patchCounts = parsePatchFileCounts(patchText);
|
|
224
165
|
return [...patchCounts.entries()]
|
|
225
166
|
.map(([filePath, { additions, deletions }]) => {
|
|
226
167
|
const fileName = filePath.split('/').pop() || '';
|
package/dist/opencode-plugin.js
CHANGED
|
@@ -267,7 +267,7 @@ const kimakiPlugin = async ({ directory }) => {
|
|
|
267
267
|
if (memoryContent) {
|
|
268
268
|
const condensed = condenseMemoryMd(memoryContent);
|
|
269
269
|
output.parts.push({
|
|
270
|
-
id: crypto.randomUUID()
|
|
270
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
271
271
|
sessionID,
|
|
272
272
|
messageID,
|
|
273
273
|
type: 'text',
|
|
@@ -304,7 +304,7 @@ const kimakiPlugin = async ({ directory }) => {
|
|
|
304
304
|
hour12: false,
|
|
305
305
|
});
|
|
306
306
|
output.parts.push({
|
|
307
|
-
id: crypto.randomUUID()
|
|
307
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
308
308
|
sessionID,
|
|
309
309
|
messageID,
|
|
310
310
|
type: 'text',
|
|
@@ -315,7 +315,7 @@ const kimakiPlugin = async ({ directory }) => {
|
|
|
315
315
|
// When the user comes back after a long break, remind the model
|
|
316
316
|
// to save any important context from the previous conversation.
|
|
317
317
|
output.parts.push({
|
|
318
|
-
id: crypto.randomUUID()
|
|
318
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
319
319
|
sessionID,
|
|
320
320
|
messageID,
|
|
321
321
|
type: 'text',
|
|
@@ -340,7 +340,7 @@ const kimakiPlugin = async ({ directory }) => {
|
|
|
340
340
|
})();
|
|
341
341
|
sessionGitStates.set(sessionID, gitState);
|
|
342
342
|
output.parts.push({
|
|
343
|
-
id: crypto.randomUUID()
|
|
343
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
344
344
|
sessionID,
|
|
345
345
|
messageID,
|
|
346
346
|
type: 'text',
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Shared apply_patch text parsing utilities.
|
|
2
|
+
// Used by diff-patch-plugin.ts (file path extraction for snapshots) and
|
|
3
|
+
// message-formatting.ts (per-file addition/deletion counts for Discord display).
|
|
4
|
+
//
|
|
5
|
+
// The apply_patch tool uses three path header formats:
|
|
6
|
+
// *** Add File: path — new file
|
|
7
|
+
// *** Update File: path — existing file edit
|
|
8
|
+
// *** Delete File: path — file removal
|
|
9
|
+
// *** Move to: path — rename destination
|
|
10
|
+
// --- a/path / +++ b/path — unified diff headers (fallback)
|
|
11
|
+
/**
|
|
12
|
+
* Extract all file paths referenced in a patchText string.
|
|
13
|
+
* Handles custom apply_patch headers, move targets, and unified diff headers.
|
|
14
|
+
* Returns deduplicated paths.
|
|
15
|
+
*/
|
|
16
|
+
export function extractPatchFilePaths(patchText) {
|
|
17
|
+
const custom = [
|
|
18
|
+
...patchText.matchAll(/^\*\*\* (?:Add|Update|Delete) File:\s+(.+)$/gm),
|
|
19
|
+
].map((m) => {
|
|
20
|
+
return (m[1] ?? '').trim();
|
|
21
|
+
});
|
|
22
|
+
const moved = [
|
|
23
|
+
...patchText.matchAll(/^\*\*\* Move to:\s+(.+)$/gm),
|
|
24
|
+
].map((m) => {
|
|
25
|
+
return (m[1] ?? '').trim();
|
|
26
|
+
});
|
|
27
|
+
const unified = [
|
|
28
|
+
...patchText.matchAll(/^(?:---|\+\+\+) [ab]\/(.+)$/gm),
|
|
29
|
+
].map((m) => {
|
|
30
|
+
return (m[1] ?? '').trim();
|
|
31
|
+
});
|
|
32
|
+
const all = [...custom, ...moved, ...unified].filter(Boolean);
|
|
33
|
+
return all.filter((v, i, a) => {
|
|
34
|
+
return a.indexOf(v) === i;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Parse a patchText string and count additions/deletions per file.
|
|
39
|
+
* Patch format uses `*** Add File:`, `*** Update File:`, `*** Delete File:` headers,
|
|
40
|
+
* with diff lines prefixed by `+` (addition) or `-` (deletion) inside `@@` hunks.
|
|
41
|
+
*/
|
|
42
|
+
export function parsePatchFileCounts(patchText) {
|
|
43
|
+
const counts = new Map();
|
|
44
|
+
const lines = patchText.split('\n');
|
|
45
|
+
let currentFile = '';
|
|
46
|
+
let currentType = '';
|
|
47
|
+
let inHunk = false;
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
const addMatch = line.match(/^\*\*\* Add File:\s*(.+)/);
|
|
50
|
+
const updateMatch = line.match(/^\*\*\* Update File:\s*(.+)/);
|
|
51
|
+
const deleteMatch = line.match(/^\*\*\* Delete File:\s*(.+)/);
|
|
52
|
+
if (addMatch || updateMatch || deleteMatch) {
|
|
53
|
+
const match = addMatch || updateMatch || deleteMatch;
|
|
54
|
+
currentFile = (match?.[1] ?? '').trim();
|
|
55
|
+
currentType = addMatch ? 'add' : updateMatch ? 'update' : 'delete';
|
|
56
|
+
counts.set(currentFile, { additions: 0, deletions: 0 });
|
|
57
|
+
inHunk = false;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (line.startsWith('@@')) {
|
|
61
|
+
inHunk = true;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (line.startsWith('*** ')) {
|
|
65
|
+
inHunk = false;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!currentFile) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const entry = counts.get(currentFile);
|
|
72
|
+
if (!entry) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (currentType === 'add') {
|
|
76
|
+
// all content lines in Add File are additions
|
|
77
|
+
if (line.length > 0 && !line.startsWith('*** ')) {
|
|
78
|
+
entry.additions++;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else if (currentType === 'delete') {
|
|
82
|
+
// all content lines in Delete File are deletions
|
|
83
|
+
if (line.length > 0 && !line.startsWith('*** ')) {
|
|
84
|
+
entry.deletions++;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else if (inHunk) {
|
|
88
|
+
if (line.startsWith('+')) {
|
|
89
|
+
entry.additions++;
|
|
90
|
+
}
|
|
91
|
+
else if (line.startsWith('-')) {
|
|
92
|
+
entry.deletions++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return counts;
|
|
97
|
+
}
|
|
@@ -2686,7 +2686,7 @@ export class ThreadSessionRuntime {
|
|
|
2686
2686
|
let contextInfo = '';
|
|
2687
2687
|
const folderName = path.basename(this.sdkDirectory);
|
|
2688
2688
|
const client = getOpencodeClient(this.projectDirectory);
|
|
2689
|
-
// Run git branch
|
|
2689
|
+
// Run git branch and token fetch in parallel (fast, no external CLI)
|
|
2690
2690
|
const [branchResult, contextResult] = await Promise.all([
|
|
2691
2691
|
errore.tryAsync(() => {
|
|
2692
2692
|
return execAsync('git symbolic-ref --short HEAD', {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// In-process WebSocket-to-TCP bridge (websockify replacement).
|
|
2
|
+
// Accepts WebSocket connections and pipes raw bytes to/from a TCP target.
|
|
3
|
+
// Used by /screenshare to bridge noVNC (WebSocket) to a VNC server (TCP).
|
|
4
|
+
// Supports the 'binary' subprotocol required by noVNC.
|
|
5
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import { createLogger } from './logger.js';
|
|
8
|
+
const logger = createLogger('SCREEN');
|
|
9
|
+
export function startWebsockify({ wsPort, tcpHost, tcpPort, }) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const wss = new WebSocketServer({
|
|
12
|
+
port: wsPort,
|
|
13
|
+
// noVNC negotiates the 'binary' subprotocol
|
|
14
|
+
handleProtocols: (protocols) => {
|
|
15
|
+
if (protocols.has('binary')) {
|
|
16
|
+
return 'binary';
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
wss.on('listening', () => {
|
|
22
|
+
const addr = wss.address();
|
|
23
|
+
const port = typeof addr === 'object' && addr ? addr.port : wsPort;
|
|
24
|
+
logger.log(`Websockify listening on port ${port} → ${tcpHost}:${tcpPort}`);
|
|
25
|
+
resolve({
|
|
26
|
+
wss,
|
|
27
|
+
port,
|
|
28
|
+
close: () => {
|
|
29
|
+
for (const client of wss.clients) {
|
|
30
|
+
client.close();
|
|
31
|
+
}
|
|
32
|
+
wss.close();
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
wss.on('error', (err) => {
|
|
37
|
+
reject(new Error('Websockify failed to start', { cause: err }));
|
|
38
|
+
});
|
|
39
|
+
wss.on('connection', (ws) => {
|
|
40
|
+
const tcp = net.createConnection(tcpPort, tcpHost, () => {
|
|
41
|
+
logger.log(`TCP connection established to ${tcpHost}:${tcpPort}`);
|
|
42
|
+
});
|
|
43
|
+
tcp.on('data', (data) => {
|
|
44
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
45
|
+
ws.send(data);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
ws.on('message', (data) => {
|
|
49
|
+
if (!tcp.destroyed) {
|
|
50
|
+
tcp.write(data);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
ws.on('close', () => {
|
|
54
|
+
tcp.destroy();
|
|
55
|
+
});
|
|
56
|
+
ws.on('error', (err) => {
|
|
57
|
+
logger.error('WebSocket error:', err);
|
|
58
|
+
tcp.destroy();
|
|
59
|
+
});
|
|
60
|
+
tcp.on('close', () => {
|
|
61
|
+
ws.close();
|
|
62
|
+
});
|
|
63
|
+
tcp.on('error', (err) => {
|
|
64
|
+
logger.error('TCP connection error:', err);
|
|
65
|
+
ws.close();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|