kimaki 0.26.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-model.e2e.test.js +25 -122
- package/dist/anthropic-account-identity.js +1 -0
- package/dist/anthropic-auth-plugin.js +1 -0
- package/dist/anthropic-auth-state.js +1 -0
- package/dist/bin.js +73 -19
- package/dist/cli-commands/maintenance.js +46 -7
- package/dist/cli-commands/send.js +24 -0
- package/dist/cli-commands/session.js +183 -8
- package/dist/cli-commands/task.js +13 -1
- package/dist/cli-parsing.test.js +59 -3
- package/dist/cli-runner.js +28 -4
- package/dist/cli-runner.test.js +27 -0
- package/dist/cli-send-thread.e2e.test.js +1 -1
- package/dist/cli.js +32 -0
- package/dist/commands/abort.js +5 -1
- package/dist/commands/agent.js +6 -8
- package/dist/commands/ask-question.js +21 -32
- package/dist/commands/ask-question.test.js +43 -1
- package/dist/commands/login.js +6 -0
- package/dist/commands/merge-worktree.js +36 -10
- package/dist/commands/model-variant.js +7 -1
- package/dist/commands/model.js +14 -8
- package/dist/commands/multioauth.js +1 -0
- package/dist/commands/new-worktree.js +170 -102
- package/dist/commands/user-command.js +6 -8
- package/dist/commands/worktrees.js +5 -13
- package/dist/config.js +16 -1
- package/dist/database.js +124 -3
- package/dist/db.js +22 -0
- package/dist/db.test.js +167 -1
- package/dist/discord-bot.js +34 -32
- package/dist/discord-command-registration.js +9 -1
- package/dist/discord-utils.js +13 -5
- package/dist/discord-utils.test.js +17 -1
- package/dist/errors.js +5 -0
- package/dist/exec-async.js +17 -5
- package/dist/external-directory-permissions.e2e.test.js +2 -2
- package/dist/file-edit-log.js +197 -0
- package/dist/file-edit-log.test.js +258 -0
- package/dist/format-tables.js +52 -0
- package/dist/format-tables.test.js +93 -0
- package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
- package/dist/gateway-proxy.e2e.test.js +5 -5
- package/dist/git-worktree-core.js +182 -17
- package/dist/ipc-tools-plugin.js +63 -3
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
- package/dist/kimaki-opencode-plugin.js +7 -3
- package/dist/kimaki-workspace-adaptor.js +33 -10
- package/dist/logger.js +10 -1
- package/dist/markdown.js +3 -1
- package/dist/markdown.test.js +11 -3
- package/dist/message-formatting.js +18 -7
- package/dist/message-formatting.test.js +56 -9
- package/dist/oauth-rotation-shared.js +10 -0
- package/dist/openai-auth-plugin.js +1 -0
- package/dist/openai-auth-state.js +1 -0
- package/dist/opencode-command.test.js +60 -0
- package/dist/opencode.js +53 -12
- package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
- package/dist/question-text-before-dropdown.e2e.test.js +52 -0
- package/dist/queue-advanced-abort.e2e.test.js +1 -1
- package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
- package/dist/queue-advanced-e2e-setup.js +78 -5
- package/dist/queue-advanced-footer.e2e.test.js +40 -246
- package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
- package/dist/queue-advanced-permissions-typing.e2e.test.js +7 -9
- package/dist/queue-advanced-question.e2e.test.js +31 -26
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +2 -2
- package/dist/queue-delete-message.e2e.test.js +1 -1
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
- package/dist/queue-interrupt-drain.e2e.test.js +112 -2
- package/dist/queue-question-select-drain.e2e.test.js +30 -128
- package/dist/runtime-lifecycle.e2e.test.js +10 -37
- package/dist/schema.js +34 -0
- package/dist/session-handler/event-stream-state.js +267 -50
- package/dist/session-handler/event-stream-state.test.js +415 -8
- package/dist/session-handler/model-utils.js +159 -5
- package/dist/session-handler/model-utils.test.js +345 -0
- package/dist/session-handler/thread-session-runtime.js +252 -182
- package/dist/session-sleep.test.js +224 -0
- package/dist/session-title-rename.test.js +7 -39
- package/dist/sleep.e2e.test.js +491 -0
- package/dist/startup-time.e2e.test.js +2 -1
- package/dist/store.js +3 -0
- package/dist/system-message.js +59 -6
- package/dist/system-message.test.js +76 -6
- package/dist/task-runner.js +94 -4
- package/dist/task-schedule.js +56 -0
- package/dist/task-schedule.test.js +71 -1
- package/dist/thread-message-queue.e2e.test.js +10 -169
- package/dist/undo-redo.e2e.test.js +2 -2
- package/dist/voice-attachment.js +23 -1
- package/dist/voice-message.e2e.test.js +10 -10
- package/dist/voice.test.js +55 -29
- package/dist/worktree-clone-isolation.e2e.test.js +188 -0
- package/dist/worktree-lifecycle.e2e.test.js +29 -10
- package/dist/worktrees.js +226 -443
- package/dist/worktrees.test.js +435 -58
- package/dist/xai-auth-plugin.js +1 -0
- package/dist/xai-auth-state.js +1 -0
- package/package.json +8 -6
- package/skills/errore/SKILL.md +18 -0
- package/skills/goke/SKILL.md +150 -10
- package/skills/holocron/SKILL.md +37 -0
- package/skills/npm-package/SKILL.md +55 -35
- package/skills/profano/SKILL.md +17 -2
- package/skills/spiceflow/SKILL.md +39 -3
- package/skills/strada/SKILL.md +39 -0
- package/skills/zele/SKILL.md +19 -0
- package/src/agent-model.e2e.test.ts +25 -151
- package/src/anthropic-account-identity.ts +1 -0
- package/src/anthropic-auth-plugin.ts +1 -0
- package/src/anthropic-auth-state.ts +1 -0
- package/src/bin.ts +78 -26
- package/src/cli-commands/maintenance.ts +59 -13
- package/src/cli-commands/send.ts +25 -0
- package/src/cli-commands/session.ts +203 -9
- package/src/cli-commands/task.ts +13 -1
- package/src/cli-parsing.test.ts +69 -3
- package/src/cli-runner.test.ts +49 -0
- package/src/cli-runner.ts +28 -4
- package/src/cli-send-thread.e2e.test.ts +1 -1
- package/src/cli.ts +53 -0
- package/src/commands/abort.ts +6 -1
- package/src/commands/agent.ts +6 -6
- package/src/commands/ask-question.test.ts +48 -1
- package/src/commands/ask-question.ts +21 -32
- package/src/commands/login.ts +6 -0
- package/src/commands/merge-worktree.ts +43 -12
- package/src/commands/model-variant.ts +8 -1
- package/src/commands/model.ts +18 -8
- package/src/commands/multioauth.ts +1 -0
- package/src/commands/new-worktree.ts +138 -55
- package/src/commands/user-command.ts +6 -6
- package/src/commands/worktrees.ts +5 -12
- package/src/config.ts +18 -1
- package/src/database.ts +195 -3
- package/src/db.test.ts +188 -0
- package/src/db.ts +26 -0
- package/src/discord-bot.ts +35 -29
- package/src/discord-command-registration.ts +15 -1
- package/src/discord-utils.test.ts +19 -0
- package/src/discord-utils.ts +21 -6
- package/src/errors.ts +5 -0
- package/src/exec-async.ts +20 -11
- package/src/external-directory-permissions.e2e.test.ts +2 -2
- package/src/file-edit-log.test.ts +287 -0
- package/src/file-edit-log.ts +283 -0
- package/src/format-tables.test.ts +101 -0
- package/src/format-tables.ts +75 -0
- package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
- package/src/gateway-proxy.e2e.test.ts +5 -5
- package/src/git-worktree-core.ts +247 -20
- package/src/ipc-tools-plugin.ts +75 -5
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
- package/src/kimaki-opencode-plugin.ts +7 -3
- package/src/kimaki-workspace-adaptor.ts +40 -11
- package/src/logger.ts +13 -4
- package/src/markdown.test.ts +13 -3
- package/src/markdown.ts +4 -1
- package/src/message-formatting.test.ts +86 -12
- package/src/message-formatting.ts +19 -7
- package/src/oauth-rotation-shared.ts +10 -0
- package/src/openai-auth-plugin.ts +1 -0
- package/src/openai-auth-state.ts +1 -0
- package/src/opencode-command.test.ts +81 -0
- package/src/opencode.ts +71 -11
- package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
- package/src/question-text-before-dropdown.e2e.test.ts +67 -0
- package/src/queue-advanced-abort.e2e.test.ts +1 -1
- package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
- package/src/queue-advanced-e2e-setup.ts +83 -4
- package/src/queue-advanced-footer.e2e.test.ts +47 -294
- package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
- package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -9
- package/src/queue-advanced-question.e2e.test.ts +35 -29
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +2 -2
- package/src/queue-delete-message.e2e.test.ts +1 -1
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
- package/src/queue-interrupt-drain.e2e.test.ts +123 -2
- package/src/queue-question-select-drain.e2e.test.ts +32 -157
- package/src/runtime-lifecycle.e2e.test.ts +10 -41
- package/src/schema.sql +14 -1
- package/src/schema.ts +36 -0
- package/src/session-handler/event-stream-state.test.ts +437 -7
- package/src/session-handler/event-stream-state.ts +389 -53
- package/src/session-handler/model-utils.test.ts +415 -0
- package/src/session-handler/model-utils.ts +249 -5
- package/src/session-handler/thread-session-runtime.ts +300 -215
- package/src/session-sleep.test.ts +294 -0
- package/src/session-title-rename.test.ts +9 -48
- package/src/sleep.e2e.test.ts +562 -0
- package/src/startup-time.e2e.test.ts +5 -1
- package/src/store.ts +21 -0
- package/src/system-message.test.ts +86 -6
- package/src/system-message.ts +66 -7
- package/src/task-runner.ts +136 -2
- package/src/task-schedule.test.ts +76 -0
- package/src/task-schedule.ts +95 -0
- package/src/thread-message-queue.e2e.test.ts +10 -202
- package/src/undo-redo.e2e.test.ts +2 -2
- package/src/voice-attachment.ts +29 -1
- package/src/voice-message.e2e.test.ts +10 -10
- package/src/voice.test.ts +63 -29
- package/src/worktree-clone-isolation.e2e.test.ts +213 -0
- package/src/worktree-lifecycle.e2e.test.ts +29 -9
- package/src/worktrees.test.ts +505 -64
- package/src/worktrees.ts +257 -603
- package/src/xai-auth-plugin.ts +1 -0
- package/src/xai-auth-state.ts +1 -0
- package/dist/message-finish-field.e2e.test.js +0 -169
- package/dist/subagent-rate-limit-plugin.js +0 -184
- package/src/message-finish-field.e2e.test.ts +0 -196
- package/src/subagent-rate-limit-plugin.ts +0 -228
package/src/discord-utils.ts
CHANGED
|
@@ -254,12 +254,6 @@ export async function archiveThread({
|
|
|
254
254
|
if (updateResult instanceof Error) {
|
|
255
255
|
discordLogger.warn(`[archive-thread] ${updateResult.message}`)
|
|
256
256
|
}
|
|
257
|
-
|
|
258
|
-
const abortResult = await client.session.abort({ sessionID: sessionId })
|
|
259
|
-
.catch((e) => new Error('Failed to abort session', { cause: e }))
|
|
260
|
-
if (abortResult instanceof Error) {
|
|
261
|
-
discordLogger.warn(`[archive-thread] ${abortResult.message}`)
|
|
262
|
-
}
|
|
263
257
|
}
|
|
264
258
|
|
|
265
259
|
if (archiveDelay > 0) {
|
|
@@ -275,6 +269,27 @@ export async function archiveThread({
|
|
|
275
269
|
})
|
|
276
270
|
}
|
|
277
271
|
|
|
272
|
+
export const DISCORD_THREAD_RENAME_TIMEOUT_MS = 3000
|
|
273
|
+
|
|
274
|
+
// Discord rename can hang on the 3rd call (~2 per 10 min). Race so it never blocks.
|
|
275
|
+
export async function raceDiscordRename<T>({
|
|
276
|
+
rename,
|
|
277
|
+
timeoutMs = DISCORD_THREAD_RENAME_TIMEOUT_MS,
|
|
278
|
+
}: {
|
|
279
|
+
rename: Promise<T>
|
|
280
|
+
timeoutMs?: number
|
|
281
|
+
}): Promise<T | 'timeout'> {
|
|
282
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs)
|
|
283
|
+
return Promise.race([
|
|
284
|
+
rename,
|
|
285
|
+
new Promise<'timeout'>((resolve) => {
|
|
286
|
+
timeoutSignal.addEventListener('abort', () => {
|
|
287
|
+
resolve('timeout')
|
|
288
|
+
})
|
|
289
|
+
}),
|
|
290
|
+
])
|
|
291
|
+
}
|
|
292
|
+
|
|
278
293
|
/**
|
|
279
294
|
* Add a user as a thread member so the thread shows up in their Discord left
|
|
280
295
|
* sidebar. Discord only lists threads you are a member of, so a scheduled
|
package/src/errors.ts
CHANGED
|
@@ -117,6 +117,11 @@ export class OpenCodeSdkError extends errore.createTaggedError({
|
|
|
117
117
|
message: 'OpenCode SDK call failed: $operation',
|
|
118
118
|
}) {}
|
|
119
119
|
|
|
120
|
+
export class InvalidModelError extends errore.createTaggedError({
|
|
121
|
+
name: 'InvalidModelError',
|
|
122
|
+
message: 'Invalid model "$model": $reason',
|
|
123
|
+
}) {}
|
|
124
|
+
|
|
120
125
|
export class FilesystemOperationError extends errore.createTaggedError({
|
|
121
126
|
name: 'FilesystemOperationError',
|
|
122
127
|
message: 'Filesystem operation failed: $operation',
|
package/src/exec-async.ts
CHANGED
|
@@ -1,32 +1,41 @@
|
|
|
1
|
-
import { exec } from 'node:child_process'
|
|
1
|
+
import { exec, execFile } from 'node:child_process'
|
|
2
2
|
import { promisify } from 'node:util'
|
|
3
3
|
|
|
4
4
|
const DEFAULT_EXEC_TIMEOUT_MS = 10_000
|
|
5
5
|
|
|
6
6
|
const _execAsync = promisify(exec)
|
|
7
|
+
const _execFileAsync = promisify(execFile)
|
|
7
8
|
|
|
8
9
|
export function execAsync(
|
|
9
|
-
command: string,
|
|
10
|
+
command: string | { command: string; args: string[] },
|
|
10
11
|
options?: Parameters<typeof _execAsync>[1],
|
|
11
12
|
): Promise<{ stdout: string; stderr: string }> {
|
|
12
|
-
const timeoutMs =
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
const timeoutMs = options?.timeout || DEFAULT_EXEC_TIMEOUT_MS
|
|
14
|
+
const childPromise =
|
|
15
|
+
typeof command === 'string'
|
|
16
|
+
? _execAsync(command, options)
|
|
17
|
+
: _execFileAsync(command.command, command.args, options)
|
|
18
|
+
const execPromise = childPromise.then(({ stdout, stderr }) => {
|
|
19
|
+
return {
|
|
20
|
+
stdout: typeof stdout === 'string' ? stdout : stdout.toString(),
|
|
21
|
+
stderr: typeof stderr === 'string' ? stderr : stderr.toString(),
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
const commandLabel = typeof command === 'string'
|
|
25
|
+
? command
|
|
26
|
+
: [command.command, ...command.args].join(' ')
|
|
18
27
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
19
28
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
20
29
|
timer = setTimeout(() => {
|
|
21
|
-
const pid =
|
|
30
|
+
const pid = childPromise.child?.pid
|
|
22
31
|
if (pid) {
|
|
23
32
|
try {
|
|
24
33
|
process.kill(-pid, 'SIGTERM')
|
|
25
34
|
} catch {
|
|
26
|
-
|
|
35
|
+
childPromise.child?.kill('SIGTERM')
|
|
27
36
|
}
|
|
28
37
|
}
|
|
29
|
-
reject(new Error(`Command timed out after ${timeoutMs}ms: ${
|
|
38
|
+
reject(new Error(`Command timed out after ${timeoutMs}ms: ${commandLabel}`))
|
|
30
39
|
}, timeoutMs)
|
|
31
40
|
})
|
|
32
41
|
return Promise.race([execPromise, timeoutPromise]).finally(() => {
|
|
@@ -85,14 +85,14 @@ describe('external directory permissions', () => {
|
|
|
85
85
|
⬥ reading external directory
|
|
86
86
|
┣ read *probe.txt*
|
|
87
87
|
⬥ external-directory-probe-done
|
|
88
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
88
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
|
|
89
89
|
--- from: user (external-directory-tester)
|
|
90
90
|
EXTERNAL_DIRECTORY_PROBE_MARKER followup
|
|
91
91
|
--- from: assistant (TestBot)
|
|
92
92
|
⬥ reading external directory
|
|
93
93
|
┣ read *probe.txt*
|
|
94
94
|
⬥ external-directory-probe-done
|
|
95
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
95
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
|
|
96
96
|
`)
|
|
97
97
|
expect(text).not.toContain('Permission Required')
|
|
98
98
|
})
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Tests for file-edit event extraction, derivation, and JSONL persistence.
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { afterEach, describe, expect, test } from 'vitest'
|
|
7
|
+
import {
|
|
8
|
+
appendFileEditEvents,
|
|
9
|
+
createFileEditHooks,
|
|
10
|
+
editorsForFile,
|
|
11
|
+
extractEditedFiles,
|
|
12
|
+
loadFileEditEvents,
|
|
13
|
+
type FileEditEvent,
|
|
14
|
+
} from './file-edit-log.js'
|
|
15
|
+
|
|
16
|
+
const tempDirs: string[] = []
|
|
17
|
+
|
|
18
|
+
function makeDataDir() {
|
|
19
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-file-edit-log-'))
|
|
20
|
+
tempDirs.push(dir)
|
|
21
|
+
return dir
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
for (const dir of tempDirs.splice(0)) {
|
|
26
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
function event(partial: Partial<FileEditEvent> & Pick<FileEditEvent, 'sessionId' | 'file'>): FileEditEvent {
|
|
31
|
+
return {
|
|
32
|
+
v: 1,
|
|
33
|
+
at: 1,
|
|
34
|
+
tool: 'edit',
|
|
35
|
+
...partial,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('extractEditedFiles', () => {
|
|
40
|
+
test('extracts edit and write filePath and ignores other tools', () => {
|
|
41
|
+
expect(
|
|
42
|
+
extractEditedFiles({
|
|
43
|
+
tool: 'edit',
|
|
44
|
+
args: { filePath: 'src/foo.ts' },
|
|
45
|
+
directory: '/repo',
|
|
46
|
+
}),
|
|
47
|
+
).toMatchInlineSnapshot(`
|
|
48
|
+
[
|
|
49
|
+
"/repo/src/foo.ts",
|
|
50
|
+
]
|
|
51
|
+
`)
|
|
52
|
+
expect(
|
|
53
|
+
extractEditedFiles({
|
|
54
|
+
tool: 'write',
|
|
55
|
+
args: { filePath: '/abs/bar.ts' },
|
|
56
|
+
directory: '/repo',
|
|
57
|
+
}),
|
|
58
|
+
).toMatchInlineSnapshot(`
|
|
59
|
+
[
|
|
60
|
+
"/abs/bar.ts",
|
|
61
|
+
]
|
|
62
|
+
`)
|
|
63
|
+
expect(
|
|
64
|
+
extractEditedFiles({
|
|
65
|
+
tool: 'read',
|
|
66
|
+
args: { filePath: 'src/foo.ts' },
|
|
67
|
+
directory: '/repo',
|
|
68
|
+
}),
|
|
69
|
+
).toMatchInlineSnapshot(`
|
|
70
|
+
[]
|
|
71
|
+
`)
|
|
72
|
+
expect(
|
|
73
|
+
extractEditedFiles({
|
|
74
|
+
tool: 'bash',
|
|
75
|
+
args: {},
|
|
76
|
+
directory: '/repo',
|
|
77
|
+
}),
|
|
78
|
+
).toMatchInlineSnapshot(`
|
|
79
|
+
[]
|
|
80
|
+
`)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('extracts apply_patch paths and resolves them against directory', () => {
|
|
84
|
+
const patchText = [
|
|
85
|
+
'*** Begin Patch',
|
|
86
|
+
'*** Update File: src/foo.ts',
|
|
87
|
+
'@@',
|
|
88
|
+
'-a',
|
|
89
|
+
'+b',
|
|
90
|
+
'*** Add File: src/bar.ts',
|
|
91
|
+
'+hello',
|
|
92
|
+
'*** Delete File: src/old.ts',
|
|
93
|
+
'*** Move to: src/renamed.ts',
|
|
94
|
+
'*** End Patch',
|
|
95
|
+
].join('\n')
|
|
96
|
+
|
|
97
|
+
expect(
|
|
98
|
+
extractEditedFiles({
|
|
99
|
+
tool: 'apply_patch',
|
|
100
|
+
args: { patchText },
|
|
101
|
+
directory: '/repo',
|
|
102
|
+
}),
|
|
103
|
+
).toMatchInlineSnapshot(`
|
|
104
|
+
[
|
|
105
|
+
"/repo/src/foo.ts",
|
|
106
|
+
"/repo/src/bar.ts",
|
|
107
|
+
"/repo/src/old.ts",
|
|
108
|
+
"/repo/src/renamed.ts",
|
|
109
|
+
]
|
|
110
|
+
`)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('editorsForFile', () => {
|
|
115
|
+
test('orders unique sessions by last edit and matches relative paths', () => {
|
|
116
|
+
const events: FileEditEvent[] = [
|
|
117
|
+
event({ sessionId: 'ses_old', file: '/repo/src/foo.ts', at: 10 }),
|
|
118
|
+
event({ sessionId: 'ses_other', file: '/repo/src/other.ts', at: 50 }),
|
|
119
|
+
event({ sessionId: 'ses_new', file: '/repo/src/foo.ts', at: 20 }),
|
|
120
|
+
event({ sessionId: 'ses_old', file: '/repo/src/foo.ts', at: 40, tool: 'write' }),
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
expect(
|
|
124
|
+
editorsForFile({
|
|
125
|
+
events,
|
|
126
|
+
filePath: 'src/foo.ts',
|
|
127
|
+
cwd: '/repo',
|
|
128
|
+
}),
|
|
129
|
+
).toMatchInlineSnapshot(`
|
|
130
|
+
[
|
|
131
|
+
{
|
|
132
|
+
"at": 40,
|
|
133
|
+
"sessionId": "ses_old",
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"at": 20,
|
|
137
|
+
"sessionId": "ses_new",
|
|
138
|
+
},
|
|
139
|
+
]
|
|
140
|
+
`)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('file edit jsonl log', () => {
|
|
145
|
+
test('appends events and loads them back', () => {
|
|
146
|
+
const dataDir = makeDataDir()
|
|
147
|
+
const first: FileEditEvent[] = [
|
|
148
|
+
event({ sessionId: 'ses_a', file: '/repo/a.ts', at: 1 }),
|
|
149
|
+
]
|
|
150
|
+
const second: FileEditEvent[] = [
|
|
151
|
+
event({ sessionId: 'ses_b', file: '/repo/b.ts', at: 2, tool: 'write' }),
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
const appended = appendFileEditEvents({ dataDir, events: first })
|
|
155
|
+
if (appended instanceof Error) throw appended
|
|
156
|
+
const appendedAgain = appendFileEditEvents({ dataDir, events: second })
|
|
157
|
+
if (appendedAgain instanceof Error) throw appendedAgain
|
|
158
|
+
|
|
159
|
+
const loaded = loadFileEditEvents({ dataDir })
|
|
160
|
+
if (loaded instanceof Error) throw loaded
|
|
161
|
+
expect(loaded).toMatchInlineSnapshot(`
|
|
162
|
+
[
|
|
163
|
+
{
|
|
164
|
+
"at": 1,
|
|
165
|
+
"file": "/repo/a.ts",
|
|
166
|
+
"sessionId": "ses_a",
|
|
167
|
+
"tool": "edit",
|
|
168
|
+
"v": 1,
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
"at": 2,
|
|
172
|
+
"file": "/repo/b.ts",
|
|
173
|
+
"sessionId": "ses_b",
|
|
174
|
+
"tool": "write",
|
|
175
|
+
"v": 1,
|
|
176
|
+
},
|
|
177
|
+
]
|
|
178
|
+
`)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('compacts to the newest events when the log is too large', () => {
|
|
182
|
+
const dataDir = makeDataDir()
|
|
183
|
+
const events: FileEditEvent[] = [
|
|
184
|
+
event({ sessionId: 'ses_1', file: '/repo/a.ts', at: 1 }),
|
|
185
|
+
event({ sessionId: 'ses_2', file: '/repo/b.ts', at: 2 }),
|
|
186
|
+
event({ sessionId: 'ses_3', file: '/repo/c.ts', at: 3 }),
|
|
187
|
+
]
|
|
188
|
+
const appended = appendFileEditEvents({
|
|
189
|
+
dataDir,
|
|
190
|
+
events,
|
|
191
|
+
maxEvents: 2,
|
|
192
|
+
compactAfterBytes: 1,
|
|
193
|
+
})
|
|
194
|
+
if (appended instanceof Error) throw appended
|
|
195
|
+
|
|
196
|
+
const loaded = loadFileEditEvents({ dataDir })
|
|
197
|
+
if (loaded instanceof Error) throw loaded
|
|
198
|
+
expect(loaded).toMatchInlineSnapshot(`
|
|
199
|
+
[
|
|
200
|
+
{
|
|
201
|
+
"at": 2,
|
|
202
|
+
"file": "/repo/b.ts",
|
|
203
|
+
"sessionId": "ses_2",
|
|
204
|
+
"tool": "edit",
|
|
205
|
+
"v": 1,
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
"at": 3,
|
|
209
|
+
"file": "/repo/c.ts",
|
|
210
|
+
"sessionId": "ses_3",
|
|
211
|
+
"tool": "edit",
|
|
212
|
+
"v": 1,
|
|
213
|
+
},
|
|
214
|
+
]
|
|
215
|
+
`)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('compacts repeated edits of the same session and file to one row', () => {
|
|
219
|
+
const dataDir = makeDataDir()
|
|
220
|
+
const events: FileEditEvent[] = [
|
|
221
|
+
event({ sessionId: 'ses_a', file: '/repo/a.ts', at: 1 }),
|
|
222
|
+
event({ sessionId: 'ses_a', file: '/repo/a.ts', at: 2, tool: 'write' }),
|
|
223
|
+
event({ sessionId: 'ses_a', file: '/repo/a.ts', at: 3 }),
|
|
224
|
+
]
|
|
225
|
+
const appended = appendFileEditEvents({
|
|
226
|
+
dataDir,
|
|
227
|
+
events,
|
|
228
|
+
maxEvents: 10,
|
|
229
|
+
compactAfterBytes: 1,
|
|
230
|
+
})
|
|
231
|
+
if (appended instanceof Error) throw appended
|
|
232
|
+
|
|
233
|
+
const loaded = loadFileEditEvents({ dataDir })
|
|
234
|
+
if (loaded instanceof Error) throw loaded
|
|
235
|
+
expect(loaded).toMatchInlineSnapshot(`
|
|
236
|
+
[
|
|
237
|
+
{
|
|
238
|
+
"at": 3,
|
|
239
|
+
"file": "/repo/a.ts",
|
|
240
|
+
"sessionId": "ses_a",
|
|
241
|
+
"tool": "edit",
|
|
242
|
+
"v": 1,
|
|
243
|
+
},
|
|
244
|
+
]
|
|
245
|
+
`)
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
describe('fileEditTrackerPlugin', () => {
|
|
250
|
+
test('records edit and apply_patch and ignores bash', async () => {
|
|
251
|
+
const dataDir = makeDataDir()
|
|
252
|
+
const hooks = createFileEditHooks({ dataDir, directory: '/repo' })
|
|
253
|
+
await hooks['tool.execute.after']({
|
|
254
|
+
tool: 'edit',
|
|
255
|
+
sessionID: 'ses_x',
|
|
256
|
+
args: { filePath: 'src/a.ts' },
|
|
257
|
+
})
|
|
258
|
+
await hooks['tool.execute.after']({
|
|
259
|
+
tool: 'apply_patch',
|
|
260
|
+
sessionID: 'ses_x',
|
|
261
|
+
args: { patchText: '*** Update File: src/b.ts\n' },
|
|
262
|
+
})
|
|
263
|
+
await hooks['tool.execute.after']({
|
|
264
|
+
tool: 'bash',
|
|
265
|
+
sessionID: 'ses_x',
|
|
266
|
+
args: {},
|
|
267
|
+
})
|
|
268
|
+
const loaded = loadFileEditEvents({ dataDir })
|
|
269
|
+
if (loaded instanceof Error) throw loaded
|
|
270
|
+
expect(loaded.map((entry) => {
|
|
271
|
+
return { sessionId: entry.sessionId, file: entry.file, tool: entry.tool }
|
|
272
|
+
})).toMatchInlineSnapshot(`
|
|
273
|
+
[
|
|
274
|
+
{
|
|
275
|
+
"file": "/repo/src/a.ts",
|
|
276
|
+
"sessionId": "ses_x",
|
|
277
|
+
"tool": "edit",
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
"file": "/repo/src/b.ts",
|
|
281
|
+
"sessionId": "ses_x",
|
|
282
|
+
"tool": "apply_patch",
|
|
283
|
+
},
|
|
284
|
+
]
|
|
285
|
+
`)
|
|
286
|
+
})
|
|
287
|
+
})
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Tracks which OpenCode sessions last edited each file.
|
|
2
|
+
// Plugin appends JSONL events; CLI derives the per-file session list on read.
|
|
3
|
+
|
|
4
|
+
import type { Plugin } from '@opencode-ai/plugin'
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import * as errore from 'errore'
|
|
8
|
+
import { FilesystemOperationError } from './errors.js'
|
|
9
|
+
import { extractPatchFilePaths } from './patch-text-parser.js'
|
|
10
|
+
import { createPluginLogger, setPluginLogFilePath } from './plugin-logger.js'
|
|
11
|
+
|
|
12
|
+
const logger = createPluginLogger('FILEEDIT')
|
|
13
|
+
|
|
14
|
+
export const FILE_EDIT_EVENTS_FILENAME = 'file-edit-events.jsonl'
|
|
15
|
+
const DEFAULT_MAX_EVENTS = 10_000
|
|
16
|
+
const DEFAULT_COMPACT_AFTER_BYTES = 5 * 1024 * 1024
|
|
17
|
+
|
|
18
|
+
export type FileEditTool = 'edit' | 'write' | 'apply_patch'
|
|
19
|
+
|
|
20
|
+
export type FileEditEvent = {
|
|
21
|
+
v: 1
|
|
22
|
+
at: number
|
|
23
|
+
sessionId: string
|
|
24
|
+
file: string
|
|
25
|
+
tool: FileEditTool
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isFileEditTool(tool: string): tool is FileEditTool {
|
|
29
|
+
return tool === 'edit' || tool === 'write' || tool === 'apply_patch'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type ToolArgs = {
|
|
33
|
+
filePath?: string
|
|
34
|
+
patchText?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getStringField({ args, key }: { args: ToolArgs; key: keyof ToolArgs }) {
|
|
38
|
+
const value = args[key]
|
|
39
|
+
return typeof value === 'string' ? value : ''
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function extractEditedFiles({
|
|
43
|
+
tool,
|
|
44
|
+
args,
|
|
45
|
+
directory,
|
|
46
|
+
}: {
|
|
47
|
+
tool: string
|
|
48
|
+
args: ToolArgs
|
|
49
|
+
directory: string
|
|
50
|
+
}) {
|
|
51
|
+
const name = tool.toLowerCase()
|
|
52
|
+
if (name === 'edit' || name === 'write') {
|
|
53
|
+
const filePath = getStringField({ args, key: 'filePath' })
|
|
54
|
+
if (!filePath) return []
|
|
55
|
+
return [path.resolve(directory, filePath)]
|
|
56
|
+
}
|
|
57
|
+
if (name !== 'apply_patch') return []
|
|
58
|
+
const patchText = getStringField({ args, key: 'patchText' })
|
|
59
|
+
if (!patchText) return []
|
|
60
|
+
return extractPatchFilePaths(patchText).map((filePath) => {
|
|
61
|
+
return path.resolve(directory, filePath)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function editorsForFile({
|
|
66
|
+
events,
|
|
67
|
+
filePath,
|
|
68
|
+
cwd,
|
|
69
|
+
}: {
|
|
70
|
+
events: FileEditEvent[]
|
|
71
|
+
filePath: string
|
|
72
|
+
cwd: string
|
|
73
|
+
}) {
|
|
74
|
+
const resolved = path.resolve(cwd, filePath)
|
|
75
|
+
const latestBySession = new Map<string, number>()
|
|
76
|
+
for (const event of events) {
|
|
77
|
+
if (path.resolve(event.file) !== resolved) continue
|
|
78
|
+
const previous = latestBySession.get(event.sessionId)
|
|
79
|
+
if (previous === undefined || event.at > previous) {
|
|
80
|
+
latestBySession.set(event.sessionId, event.at)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return [...latestBySession.entries()]
|
|
84
|
+
.map(([sessionId, at]) => {
|
|
85
|
+
return { sessionId, at }
|
|
86
|
+
})
|
|
87
|
+
.sort((left, right) => {
|
|
88
|
+
return right.at - left.at
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseFileEditEvent(line: string) {
|
|
93
|
+
const parsed = errore.try(
|
|
94
|
+
() => JSON.parse(line) as {
|
|
95
|
+
v: number
|
|
96
|
+
at: number
|
|
97
|
+
sessionId: string
|
|
98
|
+
file: string
|
|
99
|
+
tool: string
|
|
100
|
+
},
|
|
101
|
+
(cause) => new FilesystemOperationError({ operation: 'parse file edit event', cause }),
|
|
102
|
+
)
|
|
103
|
+
if (parsed instanceof Error) return null
|
|
104
|
+
if (!parsed || typeof parsed !== 'object') return null
|
|
105
|
+
if (parsed.v !== 1) return null
|
|
106
|
+
if (typeof parsed.at !== 'number' || !Number.isFinite(parsed.at)) return null
|
|
107
|
+
if (typeof parsed.sessionId !== 'string' || parsed.sessionId.length === 0) return null
|
|
108
|
+
if (typeof parsed.file !== 'string' || parsed.file.length === 0) return null
|
|
109
|
+
if (typeof parsed.tool !== 'string' || !isFileEditTool(parsed.tool)) return null
|
|
110
|
+
return {
|
|
111
|
+
v: 1 as const,
|
|
112
|
+
at: parsed.at,
|
|
113
|
+
sessionId: parsed.sessionId,
|
|
114
|
+
file: parsed.file,
|
|
115
|
+
tool: parsed.tool,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function nodeErrorCode(error: Error) {
|
|
120
|
+
const cause = error.cause
|
|
121
|
+
if (!cause || typeof cause !== 'object') return ''
|
|
122
|
+
const code = Reflect.get(cause, 'code')
|
|
123
|
+
return typeof code === 'string' ? code : ''
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function loadFileEditEvents({ dataDir }: { dataDir: string }) {
|
|
127
|
+
const logPath = path.join(dataDir, FILE_EDIT_EVENTS_FILENAME)
|
|
128
|
+
const raw = errore.try(
|
|
129
|
+
() => fs.readFileSync(logPath, 'utf8'),
|
|
130
|
+
(cause) => new FilesystemOperationError({ operation: 'read file edit log', cause }),
|
|
131
|
+
)
|
|
132
|
+
if (raw instanceof Error) {
|
|
133
|
+
if (nodeErrorCode(raw) === 'ENOENT') return []
|
|
134
|
+
return raw
|
|
135
|
+
}
|
|
136
|
+
const events: FileEditEvent[] = []
|
|
137
|
+
for (const line of raw.split('\n')) {
|
|
138
|
+
if (!line.trim()) continue
|
|
139
|
+
const event = parseFileEditEvent(line)
|
|
140
|
+
if (event) events.push(event)
|
|
141
|
+
}
|
|
142
|
+
return events
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function collapseFileEditEvents(events: FileEditEvent[]) {
|
|
146
|
+
const latest = new Map<string, FileEditEvent>()
|
|
147
|
+
for (const event of events) {
|
|
148
|
+
const key = `${event.sessionId}\0${event.file}`
|
|
149
|
+
const previous = latest.get(key)
|
|
150
|
+
if (!previous || event.at >= previous.at) latest.set(key, event)
|
|
151
|
+
}
|
|
152
|
+
return [...latest.values()].sort((left, right) => {
|
|
153
|
+
return left.at - right.at
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function compactFileEditLog({
|
|
158
|
+
dataDir,
|
|
159
|
+
maxEvents,
|
|
160
|
+
}: {
|
|
161
|
+
dataDir: string
|
|
162
|
+
maxEvents: number
|
|
163
|
+
}) {
|
|
164
|
+
const loaded = loadFileEditEvents({ dataDir })
|
|
165
|
+
if (loaded instanceof Error) return loaded
|
|
166
|
+
const collapsed = collapseFileEditEvents(loaded)
|
|
167
|
+
const kept = collapsed.length > maxEvents ? collapsed.slice(-maxEvents) : collapsed
|
|
168
|
+
const logPath = path.join(dataDir, FILE_EDIT_EVENTS_FILENAME)
|
|
169
|
+
const tempPath = `${logPath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
|
|
170
|
+
const body = kept.map((event) => `${JSON.stringify(event)}\n`).join('')
|
|
171
|
+
const written = errore.try(
|
|
172
|
+
() => {
|
|
173
|
+
fs.writeFileSync(tempPath, body)
|
|
174
|
+
fs.renameSync(tempPath, logPath)
|
|
175
|
+
},
|
|
176
|
+
(cause) => new FilesystemOperationError({ operation: 'compact file edit log', cause }),
|
|
177
|
+
)
|
|
178
|
+
if (written instanceof Error) {
|
|
179
|
+
fs.rmSync(tempPath, { force: true })
|
|
180
|
+
return written
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function appendFileEditEvents({
|
|
185
|
+
dataDir,
|
|
186
|
+
events,
|
|
187
|
+
maxEvents = DEFAULT_MAX_EVENTS,
|
|
188
|
+
compactAfterBytes = DEFAULT_COMPACT_AFTER_BYTES,
|
|
189
|
+
}: {
|
|
190
|
+
dataDir: string
|
|
191
|
+
events: FileEditEvent[]
|
|
192
|
+
maxEvents?: number
|
|
193
|
+
compactAfterBytes?: number
|
|
194
|
+
}) {
|
|
195
|
+
if (events.length === 0) return
|
|
196
|
+
const logPath = path.join(dataDir, FILE_EDIT_EVENTS_FILENAME)
|
|
197
|
+
const prepared = errore.try(
|
|
198
|
+
() => {
|
|
199
|
+
fs.mkdirSync(dataDir, { recursive: true })
|
|
200
|
+
fs.appendFileSync(logPath, events.map((event) => `${JSON.stringify(event)}\n`).join(''))
|
|
201
|
+
},
|
|
202
|
+
(cause) => new FilesystemOperationError({ operation: 'append file edit log', cause }),
|
|
203
|
+
)
|
|
204
|
+
if (prepared instanceof Error) return prepared
|
|
205
|
+
const size = errore.try(
|
|
206
|
+
() => fs.statSync(logPath).size,
|
|
207
|
+
(cause) => new FilesystemOperationError({ operation: 'stat file edit log', cause }),
|
|
208
|
+
)
|
|
209
|
+
if (size instanceof Error) return size
|
|
210
|
+
if (size < compactAfterBytes) return
|
|
211
|
+
return compactFileEditLog({ dataDir, maxEvents })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function recordToolEdits({
|
|
215
|
+
dataDir,
|
|
216
|
+
directory,
|
|
217
|
+
sessionId,
|
|
218
|
+
tool,
|
|
219
|
+
args,
|
|
220
|
+
}: {
|
|
221
|
+
dataDir: string
|
|
222
|
+
directory: string
|
|
223
|
+
sessionId: string
|
|
224
|
+
tool: string
|
|
225
|
+
args: ToolArgs
|
|
226
|
+
}) {
|
|
227
|
+
const name = tool.toLowerCase()
|
|
228
|
+
if (!isFileEditTool(name)) return
|
|
229
|
+
const files = extractEditedFiles({ tool: name, args, directory })
|
|
230
|
+
if (files.length === 0) return
|
|
231
|
+
const at = Date.now()
|
|
232
|
+
const events = files.map((file) => {
|
|
233
|
+
return {
|
|
234
|
+
v: 1 as const,
|
|
235
|
+
at,
|
|
236
|
+
sessionId,
|
|
237
|
+
file,
|
|
238
|
+
tool: name,
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
const result = appendFileEditEvents({ dataDir, events })
|
|
242
|
+
if (result instanceof Error) {
|
|
243
|
+
logger.warn('Failed to record file edit', result.message)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function createFileEditHooks({
|
|
248
|
+
dataDir,
|
|
249
|
+
directory,
|
|
250
|
+
}: {
|
|
251
|
+
dataDir: string
|
|
252
|
+
directory: string
|
|
253
|
+
}) {
|
|
254
|
+
return {
|
|
255
|
+
'tool.execute.after': async (input: {
|
|
256
|
+
tool: string
|
|
257
|
+
sessionID: string
|
|
258
|
+
args: ToolArgs
|
|
259
|
+
}) => {
|
|
260
|
+
recordToolEdits({
|
|
261
|
+
dataDir,
|
|
262
|
+
directory,
|
|
263
|
+
sessionId: input.sessionID,
|
|
264
|
+
tool: input.tool,
|
|
265
|
+
args: input.args,
|
|
266
|
+
})
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export const fileEditTrackerPlugin: Plugin = async ({ directory }) => {
|
|
272
|
+
const dataDir = process.env.KIMAKI_DATA_DIR
|
|
273
|
+
if (!dataDir) return {}
|
|
274
|
+
setPluginLogFilePath(dataDir)
|
|
275
|
+
const created = errore.try(
|
|
276
|
+
() => fs.mkdirSync(dataDir, { recursive: true }),
|
|
277
|
+
(cause) => new FilesystemOperationError({ operation: 'create file edit log dir', cause }),
|
|
278
|
+
)
|
|
279
|
+
if (created instanceof Error) {
|
|
280
|
+
logger.warn('Failed to create file edit log dir', created.message)
|
|
281
|
+
}
|
|
282
|
+
return createFileEditHooks({ dataDir, directory })
|
|
283
|
+
}
|