kimaki 0.4.90 → 0.4.91
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 +80 -2
- package/dist/anthropic-auth-plugin.js +246 -195
- package/dist/anthropic-auth-plugin.test.js +125 -0
- package/dist/anthropic-auth-state.js +231 -0
- package/dist/bin.js +6 -3
- package/dist/cli-parsing.test.js +23 -0
- package/dist/cli-send-thread.e2e.test.js +2 -2
- package/dist/cli.js +72 -46
- package/dist/commands/merge-worktree.js +6 -3
- package/dist/commands/new-worktree.js +18 -7
- package/dist/commands/worktrees.js +71 -7
- package/dist/context-awareness-plugin.js +52 -50
- package/dist/context-awareness-plugin.test.js +68 -1
- package/dist/discord-bot.js +126 -54
- package/dist/discord-utils.test.js +19 -0
- package/dist/errors.js +0 -5
- package/dist/exec-async.js +26 -0
- package/dist/external-opencode-sync.js +33 -72
- package/dist/forum-sync/config.js +2 -2
- package/dist/forum-sync/markdown.js +4 -8
- package/dist/hrana-server.js +11 -3
- package/dist/image-optimizer-plugin.js +153 -0
- package/dist/ipc-tools-plugin.js +11 -4
- package/dist/kimaki-opencode-plugin.js +1 -0
- package/dist/logger.js +0 -1
- package/dist/markdown.js +2 -2
- package/dist/message-preprocessing.js +100 -16
- package/dist/onboarding-tutorial.js +1 -1
- package/dist/opencode-command-detection.js +70 -0
- package/dist/opencode-command-detection.test.js +210 -0
- package/dist/opencode-interrupt-plugin.js +64 -8
- package/dist/opencode-interrupt-plugin.test.js +23 -39
- package/dist/opencode.js +16 -20
- package/dist/pkce.js +23 -0
- package/dist/plugin-logger.js +59 -0
- package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -1
- package/dist/queue-advanced-question.e2e.test.js +127 -42
- package/dist/sentry.js +7 -114
- package/dist/session-handler/event-stream-state.js +1 -1
- package/dist/session-handler/thread-runtime-state.js +9 -0
- package/dist/session-handler/thread-session-runtime.js +197 -45
- package/dist/session-title-rename.test.js +80 -0
- package/dist/store.js +1 -2
- package/dist/system-message.js +105 -49
- package/dist/system-message.test.js +598 -15
- package/dist/task-runner.js +7 -4
- package/dist/task-schedule.js +2 -0
- package/dist/thread-message-queue.e2e.test.js +18 -11
- package/dist/unnest-code-blocks.js +11 -1
- package/dist/unnest-code-blocks.test.js +32 -0
- package/dist/voice-handler.js +15 -5
- package/dist/voice.js +53 -23
- package/dist/voice.test.js +2 -0
- package/dist/worktrees.js +111 -120
- package/package.json +15 -19
- package/skills/lintcn/SKILL.md +6 -1
- package/skills/new-skill/SKILL.md +211 -0
- package/skills/npm-package/SKILL.md +3 -2
- package/skills/spiceflow/SKILL.md +1 -1
- package/skills/usecomputer/SKILL.md +174 -249
- package/src/agent-model.e2e.test.ts +95 -2
- package/src/anthropic-auth-plugin.test.ts +159 -0
- package/src/anthropic-auth-plugin.ts +474 -403
- package/src/anthropic-auth-state.ts +282 -0
- package/src/bin.ts +6 -3
- package/src/cli-parsing.test.ts +32 -0
- package/src/cli-send-thread.e2e.test.ts +2 -2
- package/src/cli.ts +93 -62
- package/src/commands/merge-worktree.ts +8 -3
- package/src/commands/new-worktree.ts +22 -10
- package/src/commands/worktrees.ts +86 -5
- package/src/context-awareness-plugin.test.ts +77 -1
- package/src/context-awareness-plugin.ts +85 -64
- package/src/discord-bot.ts +135 -56
- package/src/discord-utils.test.ts +21 -0
- package/src/errors.ts +0 -6
- package/src/exec-async.ts +35 -0
- package/src/external-opencode-sync.ts +39 -85
- package/src/forum-sync/config.ts +2 -2
- package/src/forum-sync/markdown.ts +5 -9
- package/src/hrana-server.ts +15 -3
- package/src/image-optimizer-plugin.ts +194 -0
- package/src/ipc-tools-plugin.ts +16 -8
- package/src/kimaki-opencode-plugin.ts +1 -0
- package/src/logger.ts +0 -1
- package/src/markdown.ts +2 -2
- package/src/message-preprocessing.ts +117 -16
- package/src/onboarding-tutorial.ts +1 -1
- package/src/opencode-command-detection.test.ts +268 -0
- package/src/opencode-command-detection.ts +79 -0
- package/src/opencode-interrupt-plugin.test.ts +93 -50
- package/src/opencode-interrupt-plugin.ts +86 -9
- package/src/opencode.ts +16 -22
- package/src/plugin-logger.ts +68 -0
- package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -1
- package/src/queue-advanced-question.e2e.test.ts +243 -158
- package/src/sentry.ts +7 -120
- package/src/session-handler/event-stream-state.ts +1 -1
- package/src/session-handler/thread-runtime-state.ts +17 -0
- package/src/session-handler/thread-session-runtime.ts +232 -46
- package/src/session-title-rename.test.ts +112 -0
- package/src/store.ts +3 -8
- package/src/system-message.test.ts +612 -0
- package/src/system-message.ts +136 -63
- package/src/task-runner.ts +7 -4
- package/src/task-schedule.ts +3 -0
- package/src/thread-message-queue.e2e.test.ts +22 -11
- package/src/undici.d.ts +12 -0
- package/src/unnest-code-blocks.test.ts +34 -0
- package/src/unnest-code-blocks.ts +18 -1
- package/src/voice-handler.ts +18 -4
- package/src/voice.test.ts +2 -0
- package/src/voice.ts +68 -23
- package/src/worktrees.ts +152 -156
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// /merge-worktree command - Merge worktree commits into default branch.
|
|
2
|
-
//
|
|
3
|
-
// On rebase conflicts, asks the AI model
|
|
2
|
+
// Pipeline: rebase worktree commits onto target -> local fast-forward push.
|
|
3
|
+
// Preserves all commits (no squash). On rebase conflicts, asks the AI model
|
|
4
|
+
// in the thread to resolve them.
|
|
4
5
|
import {} from 'discord.js';
|
|
5
6
|
import { getThreadWorktree, getThreadSession, getChannelDirectory, } from '../database.js';
|
|
6
7
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
@@ -103,12 +104,14 @@ export async function handleMergeWorktreeCommand({ command, appId, }) {
|
|
|
103
104
|
await sendPromptToModel({
|
|
104
105
|
prompt: [
|
|
105
106
|
'A rebase conflict occurred while merging this worktree into the default branch.',
|
|
107
|
+
'Rebasing multiple commits can pause on each commit that conflicts, so you may need to repeat the resolve/continue loop several times.',
|
|
106
108
|
'Please resolve the rebase conflicts:',
|
|
107
109
|
'1. Check `git status` to see which files have conflicts',
|
|
108
110
|
'2. Edit the conflicted files to resolve the merge markers',
|
|
109
111
|
'3. Stage resolved files with `git add`',
|
|
110
112
|
'4. Continue the rebase with `git rebase --continue`',
|
|
111
|
-
'5.
|
|
113
|
+
'5. If git reports more conflicts, repeat steps 1-4 until the rebase finishes (no more MERGE markers, `git status` shows no rebase in progress)',
|
|
114
|
+
'6. Once the rebase is fully complete, tell me so I can run `/merge-worktree` again',
|
|
112
115
|
].join('\n'),
|
|
113
116
|
thread,
|
|
114
117
|
projectDirectory: worktreeInfo.project_directory,
|
|
@@ -89,18 +89,30 @@ export async function createWorktreeInBackground({ thread, starterMessage, workt
|
|
|
89
89
|
worktreeName,
|
|
90
90
|
projectDirectory,
|
|
91
91
|
});
|
|
92
|
+
// Serialize status message edits so onProgress can't overwrite the
|
|
93
|
+
// final success/error edit even if Discord's API is slow.
|
|
94
|
+
let editChain = Promise.resolve();
|
|
95
|
+
const editStatus = (content) => {
|
|
96
|
+
editChain = editChain
|
|
97
|
+
.then(async () => {
|
|
98
|
+
await starterMessage?.edit(content);
|
|
99
|
+
})
|
|
100
|
+
.catch(() => { });
|
|
101
|
+
};
|
|
92
102
|
const worktreeResult = await createWorktreeWithSubmodules({
|
|
93
103
|
directory: projectDirectory,
|
|
94
104
|
name: worktreeName,
|
|
95
105
|
baseBranch,
|
|
106
|
+
onProgress: (phase) => {
|
|
107
|
+
editStatus(`🌳 **Worktree: ${worktreeName}**\n${phase}`);
|
|
108
|
+
},
|
|
96
109
|
});
|
|
97
110
|
if (worktreeResult instanceof Error) {
|
|
98
111
|
const errorMsg = worktreeResult.message;
|
|
99
112
|
logger.error('[WORKTREE] Creation failed:', worktreeResult);
|
|
100
113
|
await setWorktreeError({ threadId: thread.id, errorMessage: errorMsg });
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
.catch(() => { });
|
|
114
|
+
editStatus(`🌳 **Worktree: ${worktreeName}**\n❌ ${errorMsg}`);
|
|
115
|
+
await editChain;
|
|
104
116
|
return worktreeResult;
|
|
105
117
|
}
|
|
106
118
|
// Success - update database and edit starter message
|
|
@@ -115,11 +127,10 @@ export async function createWorktreeInBackground({ thread, starterMessage, workt
|
|
|
115
127
|
channelId: thread.parentId || undefined,
|
|
116
128
|
emoji: '🌳',
|
|
117
129
|
});
|
|
118
|
-
|
|
119
|
-
?.edit(`🌳 **Worktree: ${worktreeName}**\n` +
|
|
130
|
+
editStatus(`🌳 **Worktree: ${worktreeName}**\n` +
|
|
120
131
|
`📁 \`${worktreeResult.directory}\`\n` +
|
|
121
|
-
`🌿 Branch: \`${worktreeResult.branch}\``)
|
|
122
|
-
|
|
132
|
+
`🌿 Branch: \`${worktreeResult.branch}\``);
|
|
133
|
+
await editChain;
|
|
123
134
|
return worktreeResult.directory;
|
|
124
135
|
},
|
|
125
136
|
catch: (e) => {
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
// /worktrees command — list
|
|
1
|
+
// /worktrees command — list worktree sessions for the current channel's project.
|
|
2
2
|
// Renders a markdown table that the CV2 pipeline auto-formats for Discord,
|
|
3
3
|
// including HTML-backed action buttons for deletable worktrees.
|
|
4
|
-
import { ButtonInteraction, ChatInputCommandInteraction, ComponentType, MessageFlags, } from 'discord.js';
|
|
4
|
+
import { ButtonInteraction, ChatInputCommandInteraction, ChannelType, ComponentType, MessageFlags, } from 'discord.js';
|
|
5
5
|
import { deleteThreadWorktree, getThreadWorktree, } from '../database.js';
|
|
6
6
|
import { getPrisma } from '../db.js';
|
|
7
7
|
import { splitTablesFromMarkdown } from '../format-tables.js';
|
|
8
8
|
import { buildHtmlActionCustomId, cancelHtmlActionsForOwner, registerHtmlAction, } from '../html-actions.js';
|
|
9
9
|
import * as errore from 'errore';
|
|
10
10
|
import { GitCommandError } from '../errors.js';
|
|
11
|
+
import { resolveWorkingDirectory } from '../discord-utils.js';
|
|
11
12
|
import { deleteWorktree, git, getDefaultBranch } from '../worktrees.js';
|
|
12
13
|
// Extracts the git stderr from a deleteWorktree error via errore.findCause.
|
|
13
14
|
// Chain: Error { cause: GitCommandError { cause: CommandError { stderr } } }.
|
|
@@ -175,9 +176,12 @@ async function resolveGitStatuses({ worktrees, timeout, }) {
|
|
|
175
176
|
clearTimeout(timer);
|
|
176
177
|
}
|
|
177
178
|
}
|
|
178
|
-
async function getRecentWorktrees() {
|
|
179
|
+
async function getRecentWorktrees({ projectDirectory, }) {
|
|
179
180
|
const prisma = await getPrisma();
|
|
180
181
|
return await prisma.thread_worktrees.findMany({
|
|
182
|
+
where: {
|
|
183
|
+
project_directory: projectDirectory,
|
|
184
|
+
},
|
|
181
185
|
orderBy: { created_at: 'desc' },
|
|
182
186
|
take: 10,
|
|
183
187
|
});
|
|
@@ -185,10 +189,21 @@ async function getRecentWorktrees() {
|
|
|
185
189
|
function getWorktreesActionOwnerKey({ userId, channelId, }) {
|
|
186
190
|
return `worktrees:${userId}:${channelId}`;
|
|
187
191
|
}
|
|
188
|
-
|
|
192
|
+
function isProjectChannel(channel) {
|
|
193
|
+
if (!channel) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
return [
|
|
197
|
+
ChannelType.GuildText,
|
|
198
|
+
ChannelType.PublicThread,
|
|
199
|
+
ChannelType.PrivateThread,
|
|
200
|
+
ChannelType.AnnouncementThread,
|
|
201
|
+
].includes(channel.type);
|
|
202
|
+
}
|
|
203
|
+
async function renderWorktreesReply({ guildId, userId, channelId, projectDirectory, notice, editReply, }) {
|
|
189
204
|
const ownerKey = getWorktreesActionOwnerKey({ userId, channelId });
|
|
190
205
|
cancelHtmlActionsForOwner(ownerKey);
|
|
191
|
-
const worktrees = await getRecentWorktrees();
|
|
206
|
+
const worktrees = await getRecentWorktrees({ projectDirectory });
|
|
192
207
|
if (worktrees.length === 0) {
|
|
193
208
|
const message = notice ? `${notice}\n\nNo worktrees found.` : 'No worktrees found.';
|
|
194
209
|
const textDisplay = {
|
|
@@ -269,10 +284,38 @@ async function handleDeleteWorktreeAction({ interaction, threadId, }) {
|
|
|
269
284
|
}
|
|
270
285
|
const worktree = await getThreadWorktree(threadId);
|
|
271
286
|
if (!worktree) {
|
|
287
|
+
if (!isProjectChannel(interaction.channel)) {
|
|
288
|
+
await interaction.editReply({
|
|
289
|
+
components: [
|
|
290
|
+
{
|
|
291
|
+
type: ComponentType.TextDisplay,
|
|
292
|
+
content: 'This action can only be used in a project channel or thread.',
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
flags: MessageFlags.IsComponentsV2,
|
|
296
|
+
});
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const resolved = await resolveWorkingDirectory({
|
|
300
|
+
channel: interaction.channel,
|
|
301
|
+
});
|
|
302
|
+
if (!resolved) {
|
|
303
|
+
await interaction.editReply({
|
|
304
|
+
components: [
|
|
305
|
+
{
|
|
306
|
+
type: ComponentType.TextDisplay,
|
|
307
|
+
content: 'Could not determine the project folder for this channel.',
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
flags: MessageFlags.IsComponentsV2,
|
|
311
|
+
});
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
272
314
|
await renderWorktreesReply({
|
|
273
315
|
guildId,
|
|
274
316
|
userId: interaction.user.id,
|
|
275
317
|
channelId: interaction.channelId,
|
|
318
|
+
projectDirectory: resolved.projectDirectory,
|
|
276
319
|
notice: 'Worktree was already removed.',
|
|
277
320
|
editReply: (options) => {
|
|
278
321
|
return interaction.editReply(options);
|
|
@@ -285,6 +328,7 @@ async function handleDeleteWorktreeAction({ interaction, threadId, }) {
|
|
|
285
328
|
guildId,
|
|
286
329
|
userId: interaction.user.id,
|
|
287
330
|
channelId: interaction.channelId,
|
|
331
|
+
projectDirectory: worktree.project_directory,
|
|
288
332
|
notice: `Cannot delete \`${worktree.worktree_name}\` because it is ${worktree.status}.`,
|
|
289
333
|
editReply: (options) => {
|
|
290
334
|
return interaction.editReply(options);
|
|
@@ -319,6 +363,7 @@ async function handleDeleteWorktreeAction({ interaction, threadId, }) {
|
|
|
319
363
|
guildId,
|
|
320
364
|
userId: interaction.user.id,
|
|
321
365
|
channelId: interaction.channelId,
|
|
366
|
+
projectDirectory: worktree.project_directory,
|
|
322
367
|
notice: `Deleted \`${worktree.worktree_name}\`.`,
|
|
323
368
|
editReply: (options) => {
|
|
324
369
|
return interaction.editReply(options);
|
|
@@ -326,10 +371,28 @@ async function handleDeleteWorktreeAction({ interaction, threadId, }) {
|
|
|
326
371
|
});
|
|
327
372
|
}
|
|
328
373
|
export async function handleWorktreesCommand({ command, }) {
|
|
374
|
+
const channel = command.channel;
|
|
329
375
|
const guildId = command.guildId;
|
|
330
|
-
if (!guildId) {
|
|
376
|
+
if (!guildId || !channel) {
|
|
377
|
+
await command.reply({
|
|
378
|
+
content: 'This command can only be used in a server channel.',
|
|
379
|
+
flags: MessageFlags.Ephemeral,
|
|
380
|
+
});
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (!isProjectChannel(channel)) {
|
|
384
|
+
await command.reply({
|
|
385
|
+
content: 'This command can only be used in a project channel or thread.',
|
|
386
|
+
flags: MessageFlags.Ephemeral,
|
|
387
|
+
});
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const resolved = await resolveWorkingDirectory({
|
|
391
|
+
channel: channel,
|
|
392
|
+
});
|
|
393
|
+
if (!resolved) {
|
|
331
394
|
await command.reply({
|
|
332
|
-
content: '
|
|
395
|
+
content: 'Could not determine the project folder for this channel.',
|
|
333
396
|
flags: MessageFlags.Ephemeral,
|
|
334
397
|
});
|
|
335
398
|
return;
|
|
@@ -339,6 +402,7 @@ export async function handleWorktreesCommand({ command, }) {
|
|
|
339
402
|
guildId,
|
|
340
403
|
userId: command.user.id,
|
|
341
404
|
channelId: command.channelId,
|
|
405
|
+
projectDirectory: resolved.projectDirectory,
|
|
342
406
|
editReply: (options) => {
|
|
343
407
|
return command.editReply(options);
|
|
344
408
|
},
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// - Git branch / detached HEAD changes
|
|
3
3
|
// - Working directory (pwd) changes (e.g. after /new-worktree mid-session)
|
|
4
4
|
// - MEMORY.md table of contents on first message
|
|
5
|
-
// -
|
|
5
|
+
// - MEMORY.md reminder after a large assistant reply
|
|
6
6
|
// - Onboarding tutorial instructions (when TUTORIAL_WELCOME_TEXT detected)
|
|
7
7
|
//
|
|
8
8
|
// Synthetic parts are hidden from the TUI but sent to the model, keeping it
|
|
@@ -19,18 +19,18 @@ import crypto from 'node:crypto';
|
|
|
19
19
|
import fs from 'node:fs';
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import * as errore from 'errore';
|
|
22
|
-
import {
|
|
22
|
+
import { createPluginLogger, formatPluginErrorWithStack, setPluginLogFilePath, } from './plugin-logger.js';
|
|
23
23
|
import { setDataDir } from './config.js';
|
|
24
24
|
import { initSentry, notifyError } from './sentry.js';
|
|
25
|
-
import { execAsync } from './
|
|
25
|
+
import { execAsync } from './exec-async.js';
|
|
26
26
|
import { condenseMemoryMd } from './condense-memory.js';
|
|
27
27
|
import { ONBOARDING_TUTORIAL_INSTRUCTIONS, TUTORIAL_WELCOME_TEXT, } from './onboarding-tutorial.js';
|
|
28
|
-
const logger =
|
|
28
|
+
const logger = createPluginLogger('OPENCODE');
|
|
29
29
|
function createSessionState() {
|
|
30
30
|
return {
|
|
31
31
|
gitState: undefined,
|
|
32
|
-
lastMessageTime: undefined,
|
|
33
32
|
memoryInjected: false,
|
|
33
|
+
lastMemoryReminderAssistantMessageId: undefined,
|
|
34
34
|
tutorialInjected: false,
|
|
35
35
|
resolvedDirectory: undefined,
|
|
36
36
|
announcedDirectory: undefined,
|
|
@@ -65,34 +65,31 @@ export function shouldInjectPwd({ currentDir, previousDir, announcedDir, }) {
|
|
|
65
65
|
`Do NOT read, write, or edit files under ${priorDirectory}.]`,
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
const MEMORY_REMINDER_OUTPUT_TOKENS = 12_000;
|
|
69
|
+
function getOutputTokenTotal(tokens) {
|
|
70
|
+
return Math.max(0, tokens.output + tokens.reasoning);
|
|
71
|
+
}
|
|
72
|
+
export function shouldInjectMemoryReminderFromLatestAssistant({ lastMemoryReminderAssistantMessageId, latestAssistantMessage, threshold = MEMORY_REMINDER_OUTPUT_TOKENS, }) {
|
|
73
|
+
if (!latestAssistantMessage) {
|
|
71
74
|
return { inject: false };
|
|
72
75
|
}
|
|
73
|
-
|
|
74
|
-
if (elapsed < TEN_MINUTES) {
|
|
76
|
+
if (latestAssistantMessage.role !== 'assistant') {
|
|
75
77
|
return { inject: false };
|
|
76
78
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
hour: '2-digit',
|
|
92
|
-
minute: '2-digit',
|
|
93
|
-
hour12: false,
|
|
94
|
-
});
|
|
95
|
-
return { inject: true, elapsedStr, utcStr, localStr, localTz };
|
|
79
|
+
if (typeof latestAssistantMessage.time?.completed !== 'number') {
|
|
80
|
+
return { inject: false };
|
|
81
|
+
}
|
|
82
|
+
if (!latestAssistantMessage.tokens) {
|
|
83
|
+
return { inject: false };
|
|
84
|
+
}
|
|
85
|
+
if (lastMemoryReminderAssistantMessageId === latestAssistantMessage.id) {
|
|
86
|
+
return { inject: false };
|
|
87
|
+
}
|
|
88
|
+
const outputTokens = getOutputTokenTotal(latestAssistantMessage.tokens);
|
|
89
|
+
if (outputTokens < threshold) {
|
|
90
|
+
return { inject: false };
|
|
91
|
+
}
|
|
92
|
+
return { inject: true, assistantMessageId: latestAssistantMessage.id };
|
|
96
93
|
}
|
|
97
94
|
export function shouldInjectTutorial({ alreadyInjected, parts, }) {
|
|
98
95
|
if (alreadyInjected) {
|
|
@@ -177,7 +174,7 @@ const contextAwarenessPlugin = async ({ directory, client }) => {
|
|
|
177
174
|
const dataDir = process.env.KIMAKI_DATA_DIR;
|
|
178
175
|
if (dataDir) {
|
|
179
176
|
setDataDir(dataDir);
|
|
180
|
-
|
|
177
|
+
setPluginLogFilePath(dataDir);
|
|
181
178
|
}
|
|
182
179
|
// Single Map for all per-session state. One entry per session, one
|
|
183
180
|
// delete on cleanup — no parallel Maps that can drift out of sync.
|
|
@@ -219,7 +216,6 @@ const contextAwarenessPlugin = async ({ directory, client }) => {
|
|
|
219
216
|
// -- Find first non-synthetic user text part --
|
|
220
217
|
// All remaining injections (branch, pwd, memory, time gap) only
|
|
221
218
|
// apply to real user messages, not empty or synthetic-only messages.
|
|
222
|
-
const now = Date.now();
|
|
223
219
|
const first = output.parts.find((part) => {
|
|
224
220
|
if (part.type !== 'text') {
|
|
225
221
|
return true;
|
|
@@ -230,6 +226,20 @@ const contextAwarenessPlugin = async ({ directory, client }) => {
|
|
|
230
226
|
return;
|
|
231
227
|
}
|
|
232
228
|
const messageID = first.messageID;
|
|
229
|
+
const latestAssistantMessageResult = await errore.tryAsync(() => {
|
|
230
|
+
return client.session.messages({
|
|
231
|
+
path: { id: sessionID },
|
|
232
|
+
query: { directory, limit: 20 },
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
const latestAssistantMessage = latestAssistantMessageResult instanceof Error
|
|
236
|
+
? undefined
|
|
237
|
+
: [...(latestAssistantMessageResult.data || [])]
|
|
238
|
+
.reverse()
|
|
239
|
+
.find((entry) => {
|
|
240
|
+
return entry.info.role === 'assistant';
|
|
241
|
+
})
|
|
242
|
+
?.info;
|
|
233
243
|
// -- Resolve session working directory --
|
|
234
244
|
const sessionDirectory = await resolveSessionDirectory({
|
|
235
245
|
client,
|
|
@@ -278,34 +288,26 @@ const contextAwarenessPlugin = async ({ directory, client }) => {
|
|
|
278
288
|
sessionID,
|
|
279
289
|
messageID,
|
|
280
290
|
type: 'text',
|
|
281
|
-
text: `<system-reminder>Project memory from MEMORY.md (condensed table of contents, line numbers shown):\n${condensed}\nOnly headings are shown above — section bodies are hidden. Use Grep to search MEMORY.md for specific topics, or Read with offset and limit to read a section's content. When writing to MEMORY.md,
|
|
291
|
+
text: `<system-reminder>Project memory from MEMORY.md (condensed table of contents, line numbers shown):\n${condensed}\nOnly headings are shown above — section bodies are hidden. Use Grep to search MEMORY.md for specific topics, or Read with offset and limit to read a section's content. When writing to MEMORY.md, keep titles concise (under 10 words) and content brief (2-3 sentences max). Only track non-obvious learnings that prevent future mistakes and are not already documented in code comments or AGENTS.md. Do not duplicate information that is self-evident from the code.</system-reminder>`,
|
|
282
292
|
synthetic: true,
|
|
283
293
|
});
|
|
284
294
|
}
|
|
285
295
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
now,
|
|
296
|
+
const memoryReminder = shouldInjectMemoryReminderFromLatestAssistant({
|
|
297
|
+
lastMemoryReminderAssistantMessageId: state.lastMemoryReminderAssistantMessageId,
|
|
298
|
+
latestAssistantMessage,
|
|
290
299
|
});
|
|
291
|
-
|
|
292
|
-
if (timeGapResult.inject) {
|
|
293
|
-
output.parts.push({
|
|
294
|
-
id: `prt_${crypto.randomUUID()}`,
|
|
295
|
-
sessionID,
|
|
296
|
-
messageID,
|
|
297
|
-
type: 'text',
|
|
298
|
-
text: `[${timeGapResult.elapsedStr} since last message | UTC: ${timeGapResult.utcStr} | Local (${timeGapResult.localTz}): ${timeGapResult.localStr}]`,
|
|
299
|
-
synthetic: true,
|
|
300
|
-
});
|
|
300
|
+
if (memoryReminder.inject) {
|
|
301
301
|
output.parts.push({
|
|
302
302
|
id: `prt_${crypto.randomUUID()}`,
|
|
303
303
|
sessionID,
|
|
304
304
|
messageID,
|
|
305
305
|
type: 'text',
|
|
306
|
-
text: '<system-reminder>
|
|
306
|
+
text: '<system-reminder>The previous assistant message was large. If the conversation had non-obvious learnings that prevent future mistakes and are not already in code comments or AGENTS.md, add them to MEMORY.md with concise titles and brief content (2-3 sentences max).</system-reminder>',
|
|
307
307
|
synthetic: true,
|
|
308
308
|
});
|
|
309
|
+
state.lastMemoryReminderAssistantMessageId =
|
|
310
|
+
memoryReminder.assistantMessageId;
|
|
309
311
|
}
|
|
310
312
|
// -- Branch injection (last synthetic part) --
|
|
311
313
|
const branchResult = shouldInjectBranch({
|
|
@@ -329,12 +331,12 @@ const contextAwarenessPlugin = async ({ directory, client }) => {
|
|
|
329
331
|
},
|
|
330
332
|
});
|
|
331
333
|
if (hookResult instanceof Error) {
|
|
332
|
-
logger.warn(`[context-awareness-plugin] ${
|
|
334
|
+
logger.warn(`[context-awareness-plugin] ${formatPluginErrorWithStack(hookResult)}`);
|
|
333
335
|
void notifyError(hookResult, 'context-awareness plugin chat.message hook failed');
|
|
334
336
|
}
|
|
335
337
|
},
|
|
336
338
|
// Clean up per-session state when sessions are deleted.
|
|
337
|
-
// Single delete instead of
|
|
339
|
+
// Single delete instead of parallel Map/Set deletes.
|
|
338
340
|
event: async ({ event }) => {
|
|
339
341
|
const cleanupResult = await errore.tryAsync({
|
|
340
342
|
try: async () => {
|
|
@@ -352,7 +354,7 @@ const contextAwarenessPlugin = async ({ directory, client }) => {
|
|
|
352
354
|
},
|
|
353
355
|
});
|
|
354
356
|
if (cleanupResult instanceof Error) {
|
|
355
|
-
logger.warn(`[context-awareness-plugin] ${
|
|
357
|
+
logger.warn(`[context-awareness-plugin] ${formatPluginErrorWithStack(cleanupResult)}`);
|
|
356
358
|
void notifyError(cleanupResult, 'context-awareness plugin event hook failed');
|
|
357
359
|
}
|
|
358
360
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Tests for context-awareness directory switch reminders.
|
|
2
2
|
import { describe, expect, test } from 'vitest';
|
|
3
|
-
import { shouldInjectPwd } from './context-awareness-plugin.js';
|
|
3
|
+
import { shouldInjectPwd, shouldInjectMemoryReminderFromLatestAssistant, } from './context-awareness-plugin.js';
|
|
4
4
|
describe('shouldInjectPwd', () => {
|
|
5
5
|
test('does not inject when current directory matches announced directory', () => {
|
|
6
6
|
const result = shouldInjectPwd({
|
|
@@ -55,3 +55,70 @@ describe('shouldInjectPwd', () => {
|
|
|
55
55
|
`);
|
|
56
56
|
});
|
|
57
57
|
});
|
|
58
|
+
describe('shouldInjectMemoryReminderFromLatestAssistant', () => {
|
|
59
|
+
test('does not trigger before threshold', () => {
|
|
60
|
+
const result = shouldInjectMemoryReminderFromLatestAssistant({
|
|
61
|
+
latestAssistantMessage: {
|
|
62
|
+
id: 'msg_asst_1',
|
|
63
|
+
role: 'assistant',
|
|
64
|
+
time: { completed: 1 },
|
|
65
|
+
tokens: {
|
|
66
|
+
input: 1_000,
|
|
67
|
+
output: 3_000,
|
|
68
|
+
reasoning: 500,
|
|
69
|
+
cache: { read: 0, write: 0 },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
threshold: 10_000,
|
|
73
|
+
});
|
|
74
|
+
expect(result).toMatchInlineSnapshot(`
|
|
75
|
+
{
|
|
76
|
+
"inject": false,
|
|
77
|
+
}
|
|
78
|
+
`);
|
|
79
|
+
});
|
|
80
|
+
test('triggers when latest assistant message exceeds threshold', () => {
|
|
81
|
+
const result = shouldInjectMemoryReminderFromLatestAssistant({
|
|
82
|
+
latestAssistantMessage: {
|
|
83
|
+
id: 'msg_asst_2',
|
|
84
|
+
role: 'assistant',
|
|
85
|
+
time: { completed: 2 },
|
|
86
|
+
tokens: {
|
|
87
|
+
input: 2_000,
|
|
88
|
+
output: 2_200,
|
|
89
|
+
reasoning: 400,
|
|
90
|
+
cache: { read: 0, write: 0 },
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
threshold: 2_000,
|
|
94
|
+
});
|
|
95
|
+
expect(result).toMatchInlineSnapshot(`
|
|
96
|
+
{
|
|
97
|
+
"assistantMessageId": "msg_asst_2",
|
|
98
|
+
"inject": true,
|
|
99
|
+
}
|
|
100
|
+
`);
|
|
101
|
+
});
|
|
102
|
+
test('does not trigger again for the same reminded assistant message', () => {
|
|
103
|
+
const result = shouldInjectMemoryReminderFromLatestAssistant({
|
|
104
|
+
lastMemoryReminderAssistantMessageId: 'msg_asst_3',
|
|
105
|
+
latestAssistantMessage: {
|
|
106
|
+
id: 'msg_asst_3',
|
|
107
|
+
role: 'assistant',
|
|
108
|
+
time: { completed: 3 },
|
|
109
|
+
tokens: {
|
|
110
|
+
input: 2_000,
|
|
111
|
+
output: 2_200,
|
|
112
|
+
reasoning: 400,
|
|
113
|
+
cache: { read: 0, write: 0 },
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
threshold: 10_000,
|
|
117
|
+
});
|
|
118
|
+
expect(result).toMatchInlineSnapshot(`
|
|
119
|
+
{
|
|
120
|
+
"inject": false,
|
|
121
|
+
}
|
|
122
|
+
`);
|
|
123
|
+
});
|
|
124
|
+
});
|