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.
Files changed (114) hide show
  1. package/dist/agent-model.e2e.test.js +80 -2
  2. package/dist/anthropic-auth-plugin.js +246 -195
  3. package/dist/anthropic-auth-plugin.test.js +125 -0
  4. package/dist/anthropic-auth-state.js +231 -0
  5. package/dist/bin.js +6 -3
  6. package/dist/cli-parsing.test.js +23 -0
  7. package/dist/cli-send-thread.e2e.test.js +2 -2
  8. package/dist/cli.js +72 -46
  9. package/dist/commands/merge-worktree.js +6 -3
  10. package/dist/commands/new-worktree.js +18 -7
  11. package/dist/commands/worktrees.js +71 -7
  12. package/dist/context-awareness-plugin.js +52 -50
  13. package/dist/context-awareness-plugin.test.js +68 -1
  14. package/dist/discord-bot.js +126 -54
  15. package/dist/discord-utils.test.js +19 -0
  16. package/dist/errors.js +0 -5
  17. package/dist/exec-async.js +26 -0
  18. package/dist/external-opencode-sync.js +33 -72
  19. package/dist/forum-sync/config.js +2 -2
  20. package/dist/forum-sync/markdown.js +4 -8
  21. package/dist/hrana-server.js +11 -3
  22. package/dist/image-optimizer-plugin.js +153 -0
  23. package/dist/ipc-tools-plugin.js +11 -4
  24. package/dist/kimaki-opencode-plugin.js +1 -0
  25. package/dist/logger.js +0 -1
  26. package/dist/markdown.js +2 -2
  27. package/dist/message-preprocessing.js +100 -16
  28. package/dist/onboarding-tutorial.js +1 -1
  29. package/dist/opencode-command-detection.js +70 -0
  30. package/dist/opencode-command-detection.test.js +210 -0
  31. package/dist/opencode-interrupt-plugin.js +64 -8
  32. package/dist/opencode-interrupt-plugin.test.js +23 -39
  33. package/dist/opencode.js +16 -20
  34. package/dist/pkce.js +23 -0
  35. package/dist/plugin-logger.js +59 -0
  36. package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -1
  37. package/dist/queue-advanced-question.e2e.test.js +127 -42
  38. package/dist/sentry.js +7 -114
  39. package/dist/session-handler/event-stream-state.js +1 -1
  40. package/dist/session-handler/thread-runtime-state.js +9 -0
  41. package/dist/session-handler/thread-session-runtime.js +197 -45
  42. package/dist/session-title-rename.test.js +80 -0
  43. package/dist/store.js +1 -2
  44. package/dist/system-message.js +105 -49
  45. package/dist/system-message.test.js +598 -15
  46. package/dist/task-runner.js +7 -4
  47. package/dist/task-schedule.js +2 -0
  48. package/dist/thread-message-queue.e2e.test.js +18 -11
  49. package/dist/unnest-code-blocks.js +11 -1
  50. package/dist/unnest-code-blocks.test.js +32 -0
  51. package/dist/voice-handler.js +15 -5
  52. package/dist/voice.js +53 -23
  53. package/dist/voice.test.js +2 -0
  54. package/dist/worktrees.js +111 -120
  55. package/package.json +15 -19
  56. package/skills/lintcn/SKILL.md +6 -1
  57. package/skills/new-skill/SKILL.md +211 -0
  58. package/skills/npm-package/SKILL.md +3 -2
  59. package/skills/spiceflow/SKILL.md +1 -1
  60. package/skills/usecomputer/SKILL.md +174 -249
  61. package/src/agent-model.e2e.test.ts +95 -2
  62. package/src/anthropic-auth-plugin.test.ts +159 -0
  63. package/src/anthropic-auth-plugin.ts +474 -403
  64. package/src/anthropic-auth-state.ts +282 -0
  65. package/src/bin.ts +6 -3
  66. package/src/cli-parsing.test.ts +32 -0
  67. package/src/cli-send-thread.e2e.test.ts +2 -2
  68. package/src/cli.ts +93 -62
  69. package/src/commands/merge-worktree.ts +8 -3
  70. package/src/commands/new-worktree.ts +22 -10
  71. package/src/commands/worktrees.ts +86 -5
  72. package/src/context-awareness-plugin.test.ts +77 -1
  73. package/src/context-awareness-plugin.ts +85 -64
  74. package/src/discord-bot.ts +135 -56
  75. package/src/discord-utils.test.ts +21 -0
  76. package/src/errors.ts +0 -6
  77. package/src/exec-async.ts +35 -0
  78. package/src/external-opencode-sync.ts +39 -85
  79. package/src/forum-sync/config.ts +2 -2
  80. package/src/forum-sync/markdown.ts +5 -9
  81. package/src/hrana-server.ts +15 -3
  82. package/src/image-optimizer-plugin.ts +194 -0
  83. package/src/ipc-tools-plugin.ts +16 -8
  84. package/src/kimaki-opencode-plugin.ts +1 -0
  85. package/src/logger.ts +0 -1
  86. package/src/markdown.ts +2 -2
  87. package/src/message-preprocessing.ts +117 -16
  88. package/src/onboarding-tutorial.ts +1 -1
  89. package/src/opencode-command-detection.test.ts +268 -0
  90. package/src/opencode-command-detection.ts +79 -0
  91. package/src/opencode-interrupt-plugin.test.ts +93 -50
  92. package/src/opencode-interrupt-plugin.ts +86 -9
  93. package/src/opencode.ts +16 -22
  94. package/src/plugin-logger.ts +68 -0
  95. package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -1
  96. package/src/queue-advanced-question.e2e.test.ts +243 -158
  97. package/src/sentry.ts +7 -120
  98. package/src/session-handler/event-stream-state.ts +1 -1
  99. package/src/session-handler/thread-runtime-state.ts +17 -0
  100. package/src/session-handler/thread-session-runtime.ts +232 -46
  101. package/src/session-title-rename.test.ts +112 -0
  102. package/src/store.ts +3 -8
  103. package/src/system-message.test.ts +612 -0
  104. package/src/system-message.ts +136 -63
  105. package/src/task-runner.ts +7 -4
  106. package/src/task-schedule.ts +3 -0
  107. package/src/thread-message-queue.e2e.test.ts +22 -11
  108. package/src/undici.d.ts +12 -0
  109. package/src/unnest-code-blocks.test.ts +34 -0
  110. package/src/unnest-code-blocks.ts +18 -1
  111. package/src/voice-handler.ts +18 -4
  112. package/src/voice.test.ts +2 -0
  113. package/src/voice.ts +68 -23
  114. package/src/worktrees.ts +152 -156
@@ -1,6 +1,7 @@
1
1
  // /merge-worktree command - Merge worktree commits into default branch.
2
- // Uses worktrunk-style pipeline: squash -> rebase -> local push.
3
- // On rebase conflicts, asks the AI model in the thread to resolve them.
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. After the rebase completes successfully, tell me so I can run `/merge-worktree` again',
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
- await starterMessage
102
- ?.edit(`🌳 **Worktree: ${worktreeName}**\n❌ ${errorMsg}`)
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
- await starterMessage
119
- ?.edit(`🌳 **Worktree: ${worktreeName}**\n` +
130
+ editStatus(`🌳 **Worktree: ${worktreeName}**\n` +
120
131
  `📁 \`${worktreeResult.directory}\`\n` +
121
- `🌿 Branch: \`${worktreeResult.branch}\``)
122
- .catch(() => { });
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 all worktree sessions sorted by creation date.
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
- async function renderWorktreesReply({ guildId, userId, channelId, notice, editReply, }) {
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: 'This command can only be used in a server.',
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
- // - Idle time gap detection with timestamps
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 { createLogger, formatErrorWithStack, LogPrefix, setLogFilePath, } from './logger.js';
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 './worktrees.js';
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 = createLogger(LogPrefix.OPENCODE);
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 TEN_MINUTES = 10 * 60 * 1000;
69
- export function shouldInjectTimeGap({ lastMessageTime, now, }) {
70
- if (!lastMessageTime) {
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
- const elapsed = now - lastMessageTime;
74
- if (elapsed < TEN_MINUTES) {
76
+ if (latestAssistantMessage.role !== 'assistant') {
75
77
  return { inject: false };
76
78
  }
77
- const totalMinutes = Math.floor(elapsed / 60_000);
78
- const hours = Math.floor(totalMinutes / 60);
79
- const minutes = totalMinutes % 60;
80
- const elapsedStr = hours > 0 ? `${hours}h ${minutes}m` : `${totalMinutes}m`;
81
- const utcStr = new Date(now)
82
- .toISOString()
83
- .replace('T', ' ')
84
- .replace(/\.\d+Z$/, ' UTC');
85
- const localTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
86
- const localStr = new Date(now).toLocaleString('en-US', {
87
- timeZone: localTz,
88
- year: 'numeric',
89
- month: '2-digit',
90
- day: '2-digit',
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
- setLogFilePath(dataDir);
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, make headings detailed and descriptive since they are the only thing visible in this prompt. You can update MEMORY.md to store learnings, tips, insights that will help prevent same mistakes, and context worth preserving across sessions.</system-reminder>`,
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
- // -- Time since last message --
287
- const timeGapResult = shouldInjectTimeGap({
288
- lastMessageTime: state.lastMessageTime,
289
- now,
296
+ const memoryReminder = shouldInjectMemoryReminderFromLatestAssistant({
297
+ lastMemoryReminderAssistantMessageId: state.lastMemoryReminderAssistantMessageId,
298
+ latestAssistantMessage,
290
299
  });
291
- state.lastMessageTime = now;
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>Long gap since last message. If the previous conversation had important learnings, tips, insights that will help prevent same mistakes, or context worth preserving, update MEMORY.md before starting the new task.</system-reminder>',
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] ${formatErrorWithStack(hookResult)}`);
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 5 parallel Map/Set deletes.
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] ${formatErrorWithStack(cleanupResult)}`);
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
+ });