@agentuity/opencode 1.0.15 → 1.0.17

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 (137) hide show
  1. package/dist/agents/architect.d.ts +1 -1
  2. package/dist/agents/architect.d.ts.map +1 -1
  3. package/dist/agents/architect.js +30 -33
  4. package/dist/agents/architect.js.map +1 -1
  5. package/dist/agents/builder.d.ts +1 -1
  6. package/dist/agents/builder.d.ts.map +1 -1
  7. package/dist/agents/builder.js +53 -60
  8. package/dist/agents/builder.js.map +1 -1
  9. package/dist/agents/expert-backend.d.ts +1 -1
  10. package/dist/agents/expert-backend.d.ts.map +1 -1
  11. package/dist/agents/expert-backend.js +32 -40
  12. package/dist/agents/expert-backend.js.map +1 -1
  13. package/dist/agents/expert-frontend.d.ts +1 -1
  14. package/dist/agents/expert-frontend.d.ts.map +1 -1
  15. package/dist/agents/expert-frontend.js +18 -24
  16. package/dist/agents/expert-frontend.js.map +1 -1
  17. package/dist/agents/expert-ops.d.ts +1 -1
  18. package/dist/agents/expert-ops.d.ts.map +1 -1
  19. package/dist/agents/expert-ops.js +37 -51
  20. package/dist/agents/expert-ops.js.map +1 -1
  21. package/dist/agents/expert.d.ts +1 -1
  22. package/dist/agents/expert.d.ts.map +1 -1
  23. package/dist/agents/expert.js +33 -43
  24. package/dist/agents/expert.js.map +1 -1
  25. package/dist/agents/lead.d.ts +1 -1
  26. package/dist/agents/lead.d.ts.map +1 -1
  27. package/dist/agents/lead.js +179 -222
  28. package/dist/agents/lead.js.map +1 -1
  29. package/dist/agents/memory.d.ts +1 -1
  30. package/dist/agents/memory.d.ts.map +1 -1
  31. package/dist/agents/memory.js +62 -90
  32. package/dist/agents/memory.js.map +1 -1
  33. package/dist/agents/monitor.d.ts +1 -1
  34. package/dist/agents/monitor.d.ts.map +1 -1
  35. package/dist/agents/monitor.js +84 -44
  36. package/dist/agents/monitor.js.map +1 -1
  37. package/dist/agents/product.d.ts +1 -1
  38. package/dist/agents/product.d.ts.map +1 -1
  39. package/dist/agents/product.js +16 -22
  40. package/dist/agents/product.js.map +1 -1
  41. package/dist/agents/reviewer.d.ts +1 -1
  42. package/dist/agents/reviewer.d.ts.map +1 -1
  43. package/dist/agents/reviewer.js +15 -27
  44. package/dist/agents/reviewer.js.map +1 -1
  45. package/dist/agents/runner.d.ts +1 -1
  46. package/dist/agents/runner.d.ts.map +1 -1
  47. package/dist/agents/runner.js +52 -76
  48. package/dist/agents/runner.js.map +1 -1
  49. package/dist/agents/scout.d.ts +1 -1
  50. package/dist/agents/scout.d.ts.map +1 -1
  51. package/dist/agents/scout.js +42 -43
  52. package/dist/agents/scout.js.map +1 -1
  53. package/dist/agents/types.d.ts +8 -0
  54. package/dist/agents/types.d.ts.map +1 -1
  55. package/dist/background/manager.d.ts +18 -0
  56. package/dist/background/manager.d.ts.map +1 -1
  57. package/dist/background/manager.js +201 -33
  58. package/dist/background/manager.js.map +1 -1
  59. package/dist/background/types.d.ts +3 -0
  60. package/dist/background/types.d.ts.map +1 -1
  61. package/dist/config/loader.js +2 -2
  62. package/dist/plugin/hooks/cadence.d.ts +3 -1
  63. package/dist/plugin/hooks/cadence.d.ts.map +1 -1
  64. package/dist/plugin/hooks/cadence.js +167 -70
  65. package/dist/plugin/hooks/cadence.js.map +1 -1
  66. package/dist/plugin/hooks/compaction-utils.d.ts +48 -0
  67. package/dist/plugin/hooks/compaction-utils.d.ts.map +1 -0
  68. package/dist/plugin/hooks/compaction-utils.js +259 -0
  69. package/dist/plugin/hooks/compaction-utils.js.map +1 -0
  70. package/dist/plugin/hooks/completion.d.ts +14 -0
  71. package/dist/plugin/hooks/completion.d.ts.map +1 -0
  72. package/dist/plugin/hooks/completion.js +45 -0
  73. package/dist/plugin/hooks/completion.js.map +1 -0
  74. package/dist/plugin/hooks/params.d.ts +47 -2
  75. package/dist/plugin/hooks/params.d.ts.map +1 -1
  76. package/dist/plugin/hooks/params.js +82 -1
  77. package/dist/plugin/hooks/params.js.map +1 -1
  78. package/dist/plugin/hooks/session-memory.d.ts +2 -1
  79. package/dist/plugin/hooks/session-memory.d.ts.map +1 -1
  80. package/dist/plugin/hooks/session-memory.js +101 -48
  81. package/dist/plugin/hooks/session-memory.js.map +1 -1
  82. package/dist/plugin/hooks/tools.d.ts.map +1 -1
  83. package/dist/plugin/hooks/tools.js +26 -1
  84. package/dist/plugin/hooks/tools.js.map +1 -1
  85. package/dist/plugin/plugin.d.ts.map +1 -1
  86. package/dist/plugin/plugin.js +38 -9
  87. package/dist/plugin/plugin.js.map +1 -1
  88. package/dist/sqlite/index.d.ts +1 -1
  89. package/dist/sqlite/index.d.ts.map +1 -1
  90. package/dist/sqlite/queries.d.ts +1 -0
  91. package/dist/sqlite/queries.d.ts.map +1 -1
  92. package/dist/sqlite/queries.js +4 -0
  93. package/dist/sqlite/queries.js.map +1 -1
  94. package/dist/sqlite/reader.d.ts +11 -1
  95. package/dist/sqlite/reader.d.ts.map +1 -1
  96. package/dist/sqlite/reader.js +62 -0
  97. package/dist/sqlite/reader.js.map +1 -1
  98. package/dist/sqlite/types.d.ts +40 -0
  99. package/dist/sqlite/types.d.ts.map +1 -1
  100. package/dist/tools/background.d.ts.map +1 -1
  101. package/dist/tools/background.js +15 -0
  102. package/dist/tools/background.js.map +1 -1
  103. package/dist/types.d.ts +46 -0
  104. package/dist/types.d.ts.map +1 -1
  105. package/dist/types.js +10 -0
  106. package/dist/types.js.map +1 -1
  107. package/package.json +3 -3
  108. package/src/agents/architect.ts +30 -33
  109. package/src/agents/builder.ts +53 -60
  110. package/src/agents/expert-backend.ts +32 -40
  111. package/src/agents/expert-frontend.ts +18 -24
  112. package/src/agents/expert-ops.ts +37 -51
  113. package/src/agents/expert.ts +33 -43
  114. package/src/agents/lead.ts +179 -222
  115. package/src/agents/memory.ts +62 -90
  116. package/src/agents/monitor.ts +84 -44
  117. package/src/agents/product.ts +16 -22
  118. package/src/agents/reviewer.ts +15 -27
  119. package/src/agents/runner.ts +52 -76
  120. package/src/agents/scout.ts +42 -43
  121. package/src/agents/types.ts +8 -0
  122. package/src/background/manager.ts +227 -38
  123. package/src/background/types.ts +3 -0
  124. package/src/config/loader.ts +2 -2
  125. package/src/plugin/hooks/cadence.ts +188 -74
  126. package/src/plugin/hooks/compaction-utils.ts +291 -0
  127. package/src/plugin/hooks/completion.ts +61 -0
  128. package/src/plugin/hooks/params.ts +107 -2
  129. package/src/plugin/hooks/session-memory.ts +113 -47
  130. package/src/plugin/hooks/tools.ts +32 -1
  131. package/src/plugin/plugin.ts +54 -10
  132. package/src/sqlite/index.ts +4 -0
  133. package/src/sqlite/queries.ts +5 -0
  134. package/src/sqlite/reader.ts +69 -0
  135. package/src/sqlite/types.ts +40 -0
  136. package/src/tools/background.ts +28 -0
  137. package/src/types.ts +40 -0
@@ -0,0 +1,61 @@
1
+ import type { PluginInput } from '@opencode-ai/plugin';
2
+ import type { CoderConfig } from '../../types';
3
+
4
+ export interface CompletionHooks {
5
+ onParams: (input: unknown) => void;
6
+ onMessage: (input: unknown) => void;
7
+ }
8
+
9
+ /**
10
+ * Creates hooks for logging agent completion metrics.
11
+ *
12
+ * Tracks the start of each LLM call (via chat.params) and logs
13
+ * agent name, model, and duration when the response arrives (via chat.message).
14
+ */
15
+ export function createCompletionHooks(ctx: PluginInput, _config: CoderConfig): CompletionHooks {
16
+ const startTimes = new Map<string, { startedAt: number; agent?: string; model?: string }>();
17
+
18
+ return {
19
+ onParams(input: unknown): void {
20
+ const inp = input as {
21
+ sessionID?: string;
22
+ agent?: string;
23
+ model?: string;
24
+ };
25
+ if (!inp.sessionID) return;
26
+ startTimes.set(inp.sessionID, {
27
+ startedAt: Date.now(),
28
+ agent: inp.agent,
29
+ model: inp.model,
30
+ });
31
+ },
32
+
33
+ onMessage(input: unknown): void {
34
+ const inp = input as { sessionID?: string };
35
+ if (!inp.sessionID) return;
36
+
37
+ const start = startTimes.get(inp.sessionID);
38
+ if (!start) return;
39
+
40
+ const durationMs = Date.now() - start.startedAt;
41
+ const durationSec = (durationMs / 1000).toFixed(1);
42
+
43
+ const logLine = `Completion: agent=${start.agent ?? 'unknown'} model=${start.model ?? 'unknown'} duration=${durationSec}s`;
44
+
45
+ // Verbose local logging for immediate visibility
46
+ console.debug(`[agentuity-coder] ${logLine}`);
47
+
48
+ // Also send to the OpenCode log service
49
+ ctx.client.app.log({
50
+ body: {
51
+ service: 'agentuity-coder',
52
+ level: 'debug',
53
+ message: logLine,
54
+ },
55
+ });
56
+
57
+ // Clean up after logging
58
+ startTimes.delete(inp.sessionID);
59
+ },
60
+ };
61
+ }
@@ -1,5 +1,5 @@
1
1
  import type { PluginInput } from '@opencode-ai/plugin';
2
- import type { CoderConfig } from '../../types';
2
+ import type { AgentConfig, CoderConfig } from '../../types';
3
3
 
4
4
  export interface ParamsHooks {
5
5
  onParams: (input: unknown, output: unknown) => Promise<void>;
@@ -95,7 +95,11 @@ function detectMode(
95
95
  return null;
96
96
  }
97
97
 
98
- export function createParamsHooks(ctx: PluginInput, _config: CoderConfig): ParamsHooks {
98
+ export function createParamsHooks(
99
+ ctx: PluginInput,
100
+ _config: CoderConfig,
101
+ lastUserMessages?: Map<string, string>
102
+ ): ParamsHooks {
99
103
  return {
100
104
  async onParams(input: unknown, output: unknown): Promise<void> {
101
105
  // Input contains: sessionID, agent, model, provider, message
@@ -117,6 +121,11 @@ export function createParamsHooks(ctx: PluginInput, _config: CoderConfig): Param
117
121
  const messageContent = inputObj.message?.content || '';
118
122
  if (!messageContent) return;
119
123
 
124
+ // Store user message text for downstream hooks (e.g. cadence trigger detection)
125
+ if (lastUserMessages && inputObj.sessionID) {
126
+ lastUserMessages.set(inputObj.sessionID, messageContent);
127
+ }
128
+
120
129
  // Check for dynamic mode triggers
121
130
  const detected = detectMode(messageContent);
122
131
  if (!detected) return;
@@ -190,3 +199,99 @@ export function createParamsHooks(ctx: PluginInput, _config: CoderConfig): Param
190
199
  *
191
200
  * Note: Triggers use multi-word phrases to avoid false positives from common words.
192
201
  */
202
+
203
+ // ─────────────────────────────────────────────────────────────────────────────
204
+ // Model Fallback Chain
205
+ // ─────────────────────────────────────────────────────────────────────────────
206
+
207
+ /** Retryable HTTP status codes that should trigger model fallback */
208
+ export const RETRYABLE_STATUS_CODES = [429, 500, 502, 503] as const;
209
+
210
+ /**
211
+ * Tracks API errors per agent to enable model fallback on subsequent calls.
212
+ *
213
+ * When an agent's primary model fails with a retryable error (429, 500, 502, 503),
214
+ * the next `chat.params` call can select a fallback model from the agent's
215
+ * `fallbackModels` list.
216
+ *
217
+ * Current limitation: The `chat.params` hook can modify temperature/topP/topK/options
218
+ * but CANNOT change the model itself (model is in the input, not output). Full model
219
+ * fallback requires one of:
220
+ * 1. A `chat.error` hook that allows retrying with a different model
221
+ * 2. A `chat.model` hook that allows overriding the model selection
222
+ * 3. Adding `model` to the `chat.params` output type
223
+ *
224
+ * TODO: When OpenCode adds a suitable hook, implement the retry logic here:
225
+ * - On API error (429/5xx), record the failure in `agentErrorState`
226
+ * - On next `chat.params` call for the same agent, select next fallback model
227
+ * - Log: `[ModelFallback] Switching from ${currentModel} to ${fallbackModel} due to ${error}`
228
+ * - Reset fallback state after successful completion or after TTL expires
229
+ */
230
+ export class ModelFallbackTracker {
231
+ /**
232
+ * Map of agent name → { failedModel, failedAt, errorCode, fallbackIndex }
233
+ * Used to track which agents have experienced API errors.
234
+ */
235
+ private agentErrorState = new Map<
236
+ string,
237
+ {
238
+ failedModel: string;
239
+ failedAt: number;
240
+ errorCode: number;
241
+ fallbackIndex: number;
242
+ }
243
+ >();
244
+
245
+ /** TTL for error state — reset after 5 minutes */
246
+ private readonly ERROR_STATE_TTL_MS = 5 * 60 * 1000;
247
+
248
+ /**
249
+ * Record an API error for an agent. Call this from an event handler
250
+ * when a retryable API error is detected.
251
+ */
252
+ recordError(agentName: string, model: string, errorCode: number): void {
253
+ const existing = this.agentErrorState.get(agentName);
254
+ const fallbackIndex = existing ? existing.fallbackIndex + 1 : 0;
255
+ this.agentErrorState.set(agentName, {
256
+ failedModel: model,
257
+ failedAt: Date.now(),
258
+ errorCode,
259
+ fallbackIndex,
260
+ });
261
+ console.debug(
262
+ `[ModelFallback] Recorded error for ${agentName}: model=${model} code=${errorCode} fallbackIndex=${fallbackIndex}`
263
+ );
264
+ }
265
+
266
+ /**
267
+ * Get the next fallback model for an agent, if one is available.
268
+ * Returns undefined if no fallback is needed or available.
269
+ */
270
+ getNextFallback(agentName: string, agentConfig: AgentConfig): string | undefined {
271
+ const state = this.agentErrorState.get(agentName);
272
+ if (!state) return undefined;
273
+
274
+ // Check TTL
275
+ if (Date.now() - state.failedAt > this.ERROR_STATE_TTL_MS) {
276
+ this.agentErrorState.delete(agentName);
277
+ return undefined;
278
+ }
279
+
280
+ const fallbacks = agentConfig.fallbackModels;
281
+ if (!fallbacks?.length) return undefined;
282
+
283
+ if (state.fallbackIndex >= fallbacks.length) {
284
+ // Exhausted all fallbacks
285
+ return undefined;
286
+ }
287
+
288
+ return fallbacks[state.fallbackIndex];
289
+ }
290
+
291
+ /**
292
+ * Clear error state for an agent (e.g., after successful completion).
293
+ */
294
+ clearError(agentName: string): void {
295
+ this.agentErrorState.delete(agentName);
296
+ }
297
+ }
@@ -1,23 +1,18 @@
1
1
  import type { PluginInput } from '@opencode-ai/plugin';
2
2
  import type { CoderConfig } from '../../types';
3
3
  import type { BackgroundManager } from '../../background';
4
-
5
- /**
6
- * Get the current git branch name.
7
- */
8
- async function getCurrentBranch(): Promise<string> {
9
- try {
10
- const proc = Bun.spawn(['git', 'branch', '--show-current'], {
11
- stdout: 'pipe',
12
- stderr: 'pipe',
13
- });
14
- const stdout = await new Response(proc.stdout).text();
15
- await proc.exited;
16
- return stdout.trim() || 'unknown';
17
- } catch {
18
- return 'unknown';
19
- }
20
- }
4
+ import type { OpenCodeDBReader } from '../../sqlite';
5
+ import type { CompactionStats } from '../../sqlite/types';
6
+ import {
7
+ getCurrentBranch,
8
+ buildCustomCompactionPrompt,
9
+ fetchAndFormatPlanningState,
10
+ getImageDescriptions,
11
+ getRecentToolCallSummaries,
12
+ storePreCompactionSnapshot,
13
+ formatCompactionDiagnostics,
14
+ countListItems,
15
+ } from './compaction-utils';
21
16
 
22
17
  export interface SessionMemoryHooks {
23
18
  onEvent: (input: {
@@ -38,8 +33,9 @@ export interface SessionMemoryHooks {
38
33
  */
39
34
  export function createSessionMemoryHooks(
40
35
  ctx: PluginInput,
41
- _config: CoderConfig,
42
- backgroundManager?: BackgroundManager
36
+ config: CoderConfig,
37
+ backgroundManager?: BackgroundManager,
38
+ dbReader?: OpenCodeDBReader
43
39
  ): SessionMemoryHooks {
44
40
  const log = (msg: string) => {
45
41
  ctx.client.app.log({
@@ -55,6 +51,10 @@ export function createSessionMemoryHooks(
55
51
  /**
56
52
  * Listen for session.compacted event.
57
53
  * The compaction summary is already in context - just tell Lead to save it.
54
+ *
55
+ * Note: Compaction continues in the SAME session (via session.prompt with
56
+ * the existing sessionId), so permissions configured in the config hook
57
+ * (plugin.ts) are automatically inherited — no re-application needed.
58
58
  */
59
59
  async onEvent(input: {
60
60
  event: { type: string; properties?: Record<string, unknown> };
@@ -144,7 +144,8 @@ Then continue with the current task if there is one.`,
144
144
 
145
145
  /**
146
146
  * Inject Memory system info during compaction.
147
- * This gets included in OpenCode's generated summary.
147
+ * Uses output.prompt to REPLACE the default compaction prompt with
148
+ * enriched context (planning state, images, tool calls, diagnostics).
148
149
  */
149
150
  async onCompacting(
150
151
  input: { sessionID: string },
@@ -153,12 +154,43 @@ Then continue with the current task if there is one.`,
153
154
  const sessionId = input.sessionID;
154
155
  log(`Compacting session ${sessionId}`);
155
156
 
156
- // Get current git branch
157
- const branch = await getCurrentBranch();
157
+ // Config flags for compaction behavior
158
+ const compactionCfg = config?.compaction ?? {};
159
+ const useCustomPrompt = compactionCfg.customPrompt !== false;
160
+ const useInlinePlanning = compactionCfg.inlinePlanning !== false;
161
+ const useImageAwareness = compactionCfg.imageAwareness !== false;
162
+ const useSnapshotToKV = compactionCfg.snapshotToKV !== false;
163
+ const maxTokens = compactionCfg.maxContextTokens ?? 4000;
164
+
165
+ // 1. Build custom compaction instructions
166
+ const instructions = useCustomPrompt ? buildCustomCompactionPrompt('regular') : null;
167
+
168
+ // 2. Gather enrichment data in parallel
169
+ const toolCallLimit = config?.compaction?.toolCallSummaryLimit ?? 5;
170
+ const [branch, planningState, imageDescs, toolSummaries] = await Promise.all([
171
+ getCurrentBranch(),
172
+ useInlinePlanning ? fetchAndFormatPlanningState(sessionId) : Promise.resolve(null),
173
+ useImageAwareness
174
+ ? Promise.resolve(getImageDescriptions(dbReader ?? null, sessionId))
175
+ : Promise.resolve(null),
176
+ Promise.resolve(getRecentToolCallSummaries(dbReader ?? null, sessionId, toolCallLimit)),
177
+ ]);
178
+
179
+ // 3. Build session state section
180
+ const sessionStateSection = `## Session Memory
181
+
182
+ This session's context is being saved to persistent memory.
183
+ Session record location: \`session:${sessionId}\` in agentuity-opencode-memory
184
+ Current branch: ${branch}
158
185
 
159
- // Get active background tasks for this session
186
+ After compaction:
187
+ 1. Memory will save this summary to the session record
188
+ 2. If planning is active, Memory should update planning.progress with this compaction
189
+ 3. Memory will apply inline reasoning if significant patterns/corrections emerged`;
190
+
191
+ // 4. Build background tasks section
160
192
  const tasks = backgroundManager?.getTasksByParent(sessionId) ?? [];
161
- let backgroundTaskContext = '';
193
+ let backgroundSection: string | null = null;
162
194
 
163
195
  if (tasks.length > 0) {
164
196
  const taskList = tasks
@@ -168,38 +200,72 @@ Then continue with the current task if there is one.`,
168
200
  )
169
201
  .join('\n');
170
202
 
171
- backgroundTaskContext = `
172
-
173
- ## Active Background Tasks
203
+ backgroundSection = `## Active Background Tasks
174
204
 
175
205
  This session has ${tasks.length} background task(s) running in separate sessions:
176
206
  ${taskList}
177
207
 
178
208
  **CRITICAL:** Task IDs and session IDs persist across compaction - these tasks are still running.
179
- Use \`agentuity_background_output({ task_id: "..." })\` to check their status.
180
- `;
209
+ Use \`agentuity_background_output({ task_id: "..." })\` to check their status.`;
181
210
  }
182
211
 
183
- output.context.push(`
184
- ## Session Memory
212
+ // 5. Combine everything into the full prompt
213
+ const sections: string[] = [];
214
+ if (instructions) sections.push(instructions);
215
+ sections.push(sessionStateSection);
216
+ if (backgroundSection) sections.push(backgroundSection);
217
+ if (planningState) sections.push(planningState);
218
+ if (imageDescs) sections.push(imageDescs);
219
+ if (toolSummaries) sections.push(toolSummaries);
220
+
221
+ // 6. Add diagnostics
222
+ const stats: CompactionStats = {
223
+ planningPhasesCount: countListItems(planningState),
224
+ backgroundTasksCount: tasks.length,
225
+ imageDescriptionsCount: countListItems(imageDescs),
226
+ toolCallSummariesCount: countListItems(toolSummaries),
227
+ estimatedTokens: Math.ceil(sections.join('\n\n').length / 4),
228
+ };
229
+ const diagnostics = formatCompactionDiagnostics(stats);
230
+ if (diagnostics) sections.push(diagnostics);
231
+
232
+ // 7. Enforce token budget
233
+ let fullPrompt = sections.join('\n\n');
234
+ const estimatedTokens = Math.ceil(fullPrompt.length / 4);
235
+ if (maxTokens > 0 && estimatedTokens > maxTokens) {
236
+ // Trim least-critical sections first
237
+ const trimOrder = [diagnostics, toolSummaries, imageDescs, planningState].filter(
238
+ Boolean
239
+ );
240
+ let trimmed = [...sections];
241
+ for (const candidate of trimOrder) {
242
+ if (Math.ceil(trimmed.join('\n\n').length / 4) <= maxTokens) break;
243
+ trimmed = trimmed.filter((s) => s !== candidate);
244
+ }
245
+ fullPrompt = trimmed.join('\n\n');
246
+ }
185
247
 
186
- This session's context is being saved to persistent memory.
187
- Session record location: \`session:${sessionId}\` in agentuity-opencode-memory
188
- Current branch: ${branch}
248
+ // 8. Set the full prompt or push to context
249
+ if (useCustomPrompt) {
250
+ output.prompt = fullPrompt;
251
+ } else {
252
+ output.context.push(fullPrompt);
253
+ }
189
254
 
190
- **Planning State (if active):**
191
- If this session has planning active (user requested "track progress" or similar), the session record contains:
192
- - \`planning.prdKey\` - Link to PRD if one exists
193
- - \`planning.objective\` - What we're trying to accomplish
194
- - \`planning.phases\` - Current phases with status and notes
195
- - \`planning.findings\` - Discoveries made during work
196
- - \`planning.errors\` - Failures to avoid repeating
197
- ${backgroundTaskContext}
198
- After compaction:
199
- 1. Memory will save this summary to the session record
200
- 2. If planning is active, Memory should update planning.progress with this compaction
201
- 3. Memory will apply inline reasoning if significant patterns/corrections emerged
202
- `);
255
+ // 9. Store pre-compaction snapshot to KV (fire-and-forget)
256
+ if (useSnapshotToKV) {
257
+ storePreCompactionSnapshot(sessionId, {
258
+ timestamp: new Date().toISOString(),
259
+ sessionId,
260
+ planningState: planningState ? { raw: planningState } : undefined,
261
+ backgroundTasks: tasks.map((t) => ({
262
+ id: t.id,
263
+ description: t.description || 'No description',
264
+ status: t.status,
265
+ })),
266
+ branch,
267
+ }).catch(() => {}); // Fire and forget
268
+ }
203
269
  },
204
270
  };
205
271
  }
@@ -165,7 +165,38 @@ export function createToolHooks(ctx: PluginInput, config: CoderConfig): ToolHook
165
165
  }
166
166
  },
167
167
 
168
- async after(_input: unknown, _output: unknown): Promise<void> {},
168
+ async after(input: unknown, output: unknown): Promise<void> {
169
+ // Graceful handling for unavailable tools: if a tool execution produced an
170
+ // error indicating the tool doesn't exist or is unavailable, normalize the
171
+ // output to a helpful message so the session continues instead of crashing.
172
+ const toolName = extractToolName(input);
173
+ if (!toolName) return;
174
+
175
+ const out = output as {
176
+ output?: string;
177
+ title?: string;
178
+ metadata?: Record<string, unknown>;
179
+ };
180
+ if (typeof out.output !== 'string') return;
181
+
182
+ const lower = out.output.toLowerCase();
183
+ const isToolMissing =
184
+ (lower.includes('not found') ||
185
+ lower.includes('not available') ||
186
+ lower.includes('does not exist') ||
187
+ lower.includes('unknown tool') ||
188
+ lower.includes('no such tool')) &&
189
+ (lower.includes('tool') || lower.includes(toolName.toLowerCase()));
190
+
191
+ if (isToolMissing) {
192
+ out.output = JSON.stringify({
193
+ error: `Tool '${toolName}' is not available in this session. It may have been removed or is not installed. Please use an alternative approach or ask the user for guidance.`,
194
+ tool: toolName,
195
+ recoverable: true,
196
+ });
197
+ out.title = `Tool unavailable: ${toolName}`;
198
+ }
199
+ },
169
200
  };
170
201
  }
171
202
 
@@ -15,6 +15,7 @@ import { createKeywordHooks } from './hooks/keyword';
15
15
  import { createParamsHooks } from './hooks/params';
16
16
  import { createCadenceHooks } from './hooks/cadence';
17
17
  import { createSessionMemoryHooks } from './hooks/session-memory';
18
+ import { createCompletionHooks } from './hooks/completion';
18
19
  import type { AgentRole } from '../types';
19
20
  import { BackgroundManager } from '../background';
20
21
  import type { SessionTreeNode } from '../sqlite';
@@ -92,10 +93,15 @@ export async function createCoderPlugin(ctx: PluginInput): Promise<Hooks> {
92
93
  const resolvedDbPath = resolveOpenCodeDBPath();
93
94
  const dbReader = new OpenCodeDBReader(resolvedDbPath ? { dbPath: resolvedDbPath } : undefined);
94
95
 
96
+ // Shared Map: chat.params stores the user's message text per session,
97
+ // chat.message reads it for trigger detection (avoids scanning model output).
98
+ const lastUserMessages = new Map<string, string>();
99
+
95
100
  const sessionHooks = createSessionHooks(ctx, coderConfig);
96
101
  const toolHooks = createToolHooks(ctx, coderConfig);
97
102
  const keywordHooks = createKeywordHooks(ctx, coderConfig);
98
103
  const paramsHooks = createParamsHooks(ctx, coderConfig);
104
+ const completionHooks = createCompletionHooks(ctx, coderConfig);
99
105
  const tmuxManager = coderConfig.tmux?.enabled
100
106
  ? new TmuxSessionManager(ctx, coderConfig.tmux, {
101
107
  onLog: (message) =>
@@ -157,11 +163,22 @@ export async function createCoderPlugin(ctx: PluginInput): Promise<Hooks> {
157
163
  });
158
164
 
159
165
  // Create hooks that need backgroundManager for task reference injection during compaction
160
- const cadenceHooks = createCadenceHooks(ctx, coderConfig, backgroundManager, dbReader);
166
+ const cadenceHooks = createCadenceHooks(
167
+ ctx,
168
+ coderConfig,
169
+ backgroundManager,
170
+ dbReader,
171
+ lastUserMessages
172
+ );
161
173
 
162
174
  // Session memory hooks handle checkpointing and compaction for non-Cadence sessions
163
175
  // Orchestration (deciding which module handles which session) happens below in the hooks
164
- const sessionMemoryHooks = createSessionMemoryHooks(ctx, coderConfig, backgroundManager);
176
+ const sessionMemoryHooks = createSessionMemoryHooks(
177
+ ctx,
178
+ coderConfig,
179
+ backgroundManager,
180
+ dbReader
181
+ );
165
182
 
166
183
  const configHandler = createConfigHandler(coderConfig);
167
184
 
@@ -193,11 +210,15 @@ export async function createCoderPlugin(ctx: PluginInput): Promise<Hooks> {
193
210
  ...(tools ? { tool: tools } : {}),
194
211
  config: configHandler,
195
212
  'chat.message': async (input: unknown, output: unknown) => {
213
+ completionHooks.onMessage(input);
196
214
  await keywordHooks.onMessage(input, output);
197
215
  await sessionHooks.onMessage(input, output);
198
216
  await cadenceHooks.onMessage(input, output);
199
217
  },
200
- 'chat.params': paramsHooks.onParams,
218
+ 'chat.params': async (input: unknown, output: unknown) => {
219
+ completionHooks.onParams(input);
220
+ await paramsHooks.onParams(input, output);
221
+ },
201
222
  'tool.execute.before': toolHooks.before,
202
223
  'tool.execute.after': toolHooks.after,
203
224
  'shell.env': async (_input: unknown, output: unknown) => {
@@ -230,16 +251,28 @@ export async function createCoderPlugin(ctx: PluginInput): Promise<Hooks> {
230
251
  }
231
252
  // Orchestrate: route to appropriate module based on session type
232
253
  const sessionId = extractSessionIdFromEvent(input);
233
- if (sessionId && cadenceHooks.isActiveCadenceSession(sessionId)) {
234
- await cadenceHooks.onEvent(input);
235
- } else if (sessionId) {
236
- // Non-Cadence sessions - handle session.compacted for checkpointing
237
- await sessionMemoryHooks.onEvent(
238
- input as { event: { type: string; properties?: Record<string, unknown> } }
239
- );
254
+ if (sessionId) {
255
+ // Try lazy restore from KV if not in memory (survives plugin restarts)
256
+ if (!cadenceHooks.isActiveCadenceSession(sessionId)) {
257
+ await cadenceHooks.tryRestoreFromKV(sessionId);
258
+ }
259
+
260
+ if (cadenceHooks.isActiveCadenceSession(sessionId)) {
261
+ await cadenceHooks.onEvent(input);
262
+ } else {
263
+ // Non-Cadence sessions - handle session.compacted for checkpointing
264
+ await sessionMemoryHooks.onEvent(
265
+ input as { event: { type: string; properties?: Record<string, unknown> } }
266
+ );
267
+ }
240
268
  }
241
269
  },
242
270
  'experimental.session.compacting': async (input, output) => {
271
+ // Try lazy restore from KV if not in memory (survives plugin restarts)
272
+ if (!cadenceHooks.isActiveCadenceSession(input.sessionID)) {
273
+ await cadenceHooks.tryRestoreFromKV(input.sessionID);
274
+ }
275
+
243
276
  // Orchestrate: route to appropriate module based on session type
244
277
  if (cadenceHooks.isActiveCadenceSession(input.sessionID)) {
245
278
  await cadenceHooks.onCompacting(input, output);
@@ -318,6 +351,16 @@ function createConfigHandler(
318
351
  };
319
352
  }
320
353
 
354
+ // Compaction config: increase reserved token buffer to accommodate our enriched
355
+ // compaction prompts (planning state, image descriptions, tool summaries, diagnostics).
356
+ // Default OpenCode reserved buffer is too small for the context we inject.
357
+ const existingCompaction = (config.compaction ?? {}) as Record<string, unknown>;
358
+ const existingReserved = existingCompaction.reserved;
359
+ config.compaction = {
360
+ ...existingCompaction,
361
+ reserved: typeof existingReserved === 'number' ? existingReserved : 40_000,
362
+ };
363
+
321
364
  config.command = {
322
365
  ...(config.command as Record<string, CommandDefinition> | undefined),
323
366
  ...commands,
@@ -360,6 +403,7 @@ function createAgentConfigs(
360
403
  ...(agent.reasoningEffort ? { reasoningEffort: agent.reasoningEffort } : {}),
361
404
  ...(agent.thinking ? { thinking: agent.thinking } : {}),
362
405
  ...(agent.hidden ? { hidden: agent.hidden } : {}),
406
+ ...(agent.fallbackModels?.length ? { fallbackModels: agent.fallbackModels } : {}),
363
407
  };
364
408
  }
365
409
 
@@ -1,13 +1,17 @@
1
1
  export { OpenCodeDBReader } from './reader';
2
2
  export type {
3
+ CompactionStats,
3
4
  DBMessage,
5
+ DBNonTextPart,
4
6
  DBPart,
5
7
  DBSession,
6
8
  DBTextPart,
7
9
  DBTodo,
8
10
  DBToolCall,
11
+ DBToolCallSummary,
9
12
  MessageTokens,
10
13
  OpenCodeDBConfig,
14
+ PreCompactionSnapshot,
11
15
  SessionCostSummary,
12
16
  SessionStatus,
13
17
  SessionSummary,
@@ -47,4 +47,9 @@ export const QUERIES = {
47
47
  SEARCH_SESSIONS: `SELECT id, project_id, parent_id, slug, directory, title, version, share_url, summary_additions, summary_deletions, summary_files, summary_diffs, time_created, time_updated, time_compacting, time_archived FROM session WHERE title LIKE ? COLLATE NOCASE ORDER BY time_updated DESC`,
48
48
 
49
49
  SEARCH_SESSIONS_LIMITED: `SELECT id, project_id, parent_id, slug, directory, title, version, share_url, summary_additions, summary_deletions, summary_files, summary_diffs, time_created, time_updated, time_compacting, time_archived FROM session WHERE title LIKE ? COLLATE NOCASE ORDER BY time_updated DESC LIMIT ?`,
50
+
51
+ GET_NON_TEXT_PARTS: `SELECT * FROM part WHERE session_id = ?
52
+ AND json_valid(data)
53
+ AND json_extract(data, '$.type') != 'text'
54
+ ORDER BY time_created DESC LIMIT ?`,
50
55
  } as const;