micode 0.8.6 → 0.9.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/INSTALL_CLAUDE.md +53 -4
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/index.js +11004 -1830
- package/package.json +3 -2
- package/src/agents/brainstormer.ts +1 -1
- package/src/agents/commander.ts +18 -2
- package/src/agents/implementer.ts +16 -0
- package/src/agents/index.ts +27 -2
- package/src/agents/mindmodel/anti-pattern-detector.ts +95 -0
- package/src/agents/mindmodel/code-clusterer.ts +108 -0
- package/src/agents/mindmodel/constraint-reviewer.ts +84 -0
- package/src/agents/mindmodel/constraint-writer.ts +136 -0
- package/src/agents/mindmodel/convention-extractor.ts +102 -0
- package/src/agents/mindmodel/dependency-mapper.ts +85 -0
- package/src/agents/mindmodel/domain-extractor.ts +77 -0
- package/src/agents/mindmodel/example-extractor.ts +87 -0
- package/src/agents/mindmodel/index.ts +11 -0
- package/src/agents/mindmodel/orchestrator.ts +103 -0
- package/src/agents/mindmodel/pattern-discoverer.ts +77 -0
- package/src/agents/mindmodel/stack-detector.ts +62 -0
- package/src/agents/planner.ts +16 -2
- package/src/agents/reviewer.ts +20 -2
- package/src/config-loader.ts +158 -39
- package/src/hooks/auto-compact.ts +34 -5
- package/src/hooks/constraint-reviewer.ts +177 -0
- package/src/hooks/context-injector.ts +4 -2
- package/src/hooks/context-window-monitor.ts +10 -2
- package/src/hooks/fragment-injector.ts +181 -0
- package/src/hooks/mindmodel-injector.ts +170 -0
- package/src/index.ts +131 -8
- package/src/mindmodel/classifier.ts +36 -0
- package/src/mindmodel/formatter.ts +18 -0
- package/src/mindmodel/index.ts +18 -0
- package/src/mindmodel/loader.ts +66 -0
- package/src/mindmodel/review.ts +68 -0
- package/src/mindmodel/types.ts +87 -0
- package/src/tools/batch-read.ts +75 -0
- package/src/tools/mindmodel-lookup.ts +87 -0
- package/src/tools/spawn-agent.ts +134 -59
- package/src/utils/config.ts +23 -3
- package/src/utils/model-limits.ts +20 -4
package/src/config-loader.ts
CHANGED
|
@@ -13,45 +13,80 @@ export interface ProviderInfo {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
17
|
-
* Returns a Set of "provider/model" strings
|
|
16
|
+
* OpenCode config structure for reading default model and available models
|
|
18
17
|
*/
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
interface OpencodeConfig {
|
|
19
|
+
model?: string;
|
|
20
|
+
provider?: Record<string, { models?: Record<string, unknown> }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Load opencode.json config file (synchronous)
|
|
25
|
+
* Returns the parsed config or null if unavailable
|
|
26
|
+
*/
|
|
27
|
+
function loadOpencodeConfig(configDir?: string): OpencodeConfig | null {
|
|
21
28
|
const baseDir = configDir ?? join(homedir(), ".config", "opencode");
|
|
22
29
|
|
|
23
30
|
try {
|
|
24
31
|
const configPath = join(baseDir, "opencode.json");
|
|
25
32
|
const content = readFileSync(configPath, "utf-8");
|
|
26
|
-
|
|
33
|
+
return JSON.parse(content) as OpencodeConfig;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
27
38
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Load available models from opencode.json config file (synchronous)
|
|
41
|
+
* Returns a Set of "provider/model" strings
|
|
42
|
+
*/
|
|
43
|
+
export function loadAvailableModels(configDir?: string): Set<string> {
|
|
44
|
+
const availableModels = new Set<string>();
|
|
45
|
+
const config = loadOpencodeConfig(configDir);
|
|
46
|
+
|
|
47
|
+
if (config?.provider) {
|
|
48
|
+
for (const [providerId, providerConfig] of Object.entries(config.provider)) {
|
|
49
|
+
if (providerConfig.models) {
|
|
50
|
+
for (const modelId of Object.keys(providerConfig.models)) {
|
|
51
|
+
availableModels.add(`${providerId}/${modelId}`);
|
|
34
52
|
}
|
|
35
53
|
}
|
|
36
54
|
}
|
|
37
|
-
} catch {
|
|
38
|
-
// Config doesn't exist or can't be parsed - return empty set
|
|
39
55
|
}
|
|
40
56
|
|
|
41
57
|
return availableModels;
|
|
42
58
|
}
|
|
43
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Load the default model from opencode.json config file (synchronous)
|
|
62
|
+
* Returns the model string in "provider/model" format or null if not set
|
|
63
|
+
*/
|
|
64
|
+
export function loadDefaultModel(configDir?: string): string | null {
|
|
65
|
+
const config = loadOpencodeConfig(configDir);
|
|
66
|
+
return config?.model ?? null;
|
|
67
|
+
}
|
|
68
|
+
|
|
44
69
|
// Safe properties that users can override
|
|
45
70
|
const SAFE_AGENT_PROPERTIES = ["model", "temperature", "maxTokens"] as const;
|
|
46
71
|
|
|
72
|
+
// Built-in OpenCode models that don't require validation (always available)
|
|
73
|
+
const BUILTIN_MODELS = new Set(["opencode/big-pickle"]);
|
|
74
|
+
|
|
47
75
|
export interface AgentOverride {
|
|
48
76
|
model?: string;
|
|
49
77
|
temperature?: number;
|
|
50
78
|
maxTokens?: number;
|
|
51
79
|
}
|
|
52
80
|
|
|
81
|
+
export interface MicodeFeatures {
|
|
82
|
+
mindmodelInjection?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
53
85
|
export interface MicodeConfig {
|
|
54
86
|
agents?: Record<string, AgentOverride>;
|
|
87
|
+
features?: MicodeFeatures;
|
|
88
|
+
compactionThreshold?: number;
|
|
89
|
+
fragments?: Record<string, string[]>;
|
|
55
90
|
}
|
|
56
91
|
|
|
57
92
|
/**
|
|
@@ -67,7 +102,9 @@ export async function loadMicodeConfig(configDir?: string): Promise<MicodeConfig
|
|
|
67
102
|
const content = await readFile(configPath, "utf-8");
|
|
68
103
|
const parsed = JSON.parse(content) as Record<string, unknown>;
|
|
69
104
|
|
|
70
|
-
|
|
105
|
+
const result: MicodeConfig = {};
|
|
106
|
+
|
|
107
|
+
// Sanitize agents - only allow safe properties
|
|
71
108
|
if (parsed.agents && typeof parsed.agents === "object") {
|
|
72
109
|
const sanitizedAgents: Record<string, AgentOverride> = {};
|
|
73
110
|
|
|
@@ -86,67 +123,143 @@ export async function loadMicodeConfig(configDir?: string): Promise<MicodeConfig
|
|
|
86
123
|
}
|
|
87
124
|
}
|
|
88
125
|
|
|
89
|
-
|
|
126
|
+
result.agents = sanitizedAgents;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Parse features
|
|
130
|
+
if (parsed.features && typeof parsed.features === "object") {
|
|
131
|
+
const features = parsed.features as Record<string, unknown>;
|
|
132
|
+
result.features = {
|
|
133
|
+
mindmodelInjection: features.mindmodelInjection === true,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Parse compactionThreshold (must be number between 0 and 1)
|
|
138
|
+
if (typeof parsed.compactionThreshold === "number") {
|
|
139
|
+
const threshold = parsed.compactionThreshold;
|
|
140
|
+
if (threshold >= 0 && threshold <= 1) {
|
|
141
|
+
result.compactionThreshold = threshold;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Parse fragments
|
|
146
|
+
if (parsed.fragments && typeof parsed.fragments === "object") {
|
|
147
|
+
const fragments = parsed.fragments as Record<string, unknown>;
|
|
148
|
+
const sanitizedFragments: Record<string, string[]> = {};
|
|
149
|
+
|
|
150
|
+
for (const [agentName, fragmentList] of Object.entries(fragments)) {
|
|
151
|
+
if (Array.isArray(fragmentList)) {
|
|
152
|
+
const validFragments = fragmentList.filter((f): f is string => typeof f === "string" && f.trim().length > 0);
|
|
153
|
+
if (validFragments.length > 0) {
|
|
154
|
+
sanitizedFragments[agentName] = validFragments;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
result.fragments = sanitizedFragments;
|
|
90
160
|
}
|
|
91
161
|
|
|
92
|
-
return
|
|
162
|
+
return result;
|
|
93
163
|
} catch {
|
|
94
164
|
return null;
|
|
95
165
|
}
|
|
96
166
|
}
|
|
97
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Load model context limits from opencode.json
|
|
170
|
+
* Returns a Map of "provider/model" -> context limit (tokens)
|
|
171
|
+
*/
|
|
172
|
+
export function loadModelContextLimits(configDir?: string): Map<string, number> {
|
|
173
|
+
const limits = new Map<string, number>();
|
|
174
|
+
const baseDir = configDir ?? join(homedir(), ".config", "opencode");
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const configPath = join(baseDir, "opencode.json");
|
|
178
|
+
const content = readFileSync(configPath, "utf-8");
|
|
179
|
+
const config = JSON.parse(content) as {
|
|
180
|
+
provider?: Record<string, { models?: Record<string, { limit?: { context?: number } }> }>;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
if (config.provider) {
|
|
184
|
+
for (const [providerId, providerConfig] of Object.entries(config.provider)) {
|
|
185
|
+
if (providerConfig.models) {
|
|
186
|
+
for (const [modelId, modelConfig] of Object.entries(providerConfig.models)) {
|
|
187
|
+
const contextLimit = modelConfig?.limit?.context;
|
|
188
|
+
if (typeof contextLimit === "number" && contextLimit > 0) {
|
|
189
|
+
limits.set(`${providerId}/${modelId}`, contextLimit);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
// Config doesn't exist or can't be parsed - return empty map
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return limits;
|
|
200
|
+
}
|
|
201
|
+
|
|
98
202
|
/**
|
|
99
203
|
* Merge user config overrides into plugin agent configs
|
|
100
204
|
* Model overrides are validated against available models from opencode.json
|
|
101
205
|
* Invalid models are logged and skipped (agent uses opencode default)
|
|
206
|
+
*
|
|
207
|
+
* Model resolution priority:
|
|
208
|
+
* 1. Per-agent override in micode.json (highest)
|
|
209
|
+
* 2. Default model from opencode.json "model" field
|
|
210
|
+
* 3. Plugin default (hardcoded in agent definitions)
|
|
102
211
|
*/
|
|
103
212
|
export function mergeAgentConfigs(
|
|
104
213
|
pluginAgents: Record<string, AgentConfig>,
|
|
105
214
|
userConfig: MicodeConfig | null,
|
|
106
215
|
availableModels?: Set<string>,
|
|
216
|
+
defaultModel?: string | null,
|
|
107
217
|
): Record<string, AgentConfig> {
|
|
108
|
-
if (!userConfig?.agents) {
|
|
109
|
-
return pluginAgents;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
218
|
const models = availableModels ?? loadAvailableModels();
|
|
113
219
|
const shouldValidateModels = models.size > 0;
|
|
220
|
+
const opencodeDefaultModel = defaultModel ?? loadDefaultModel();
|
|
221
|
+
|
|
222
|
+
// Helper to validate a model string
|
|
223
|
+
const isValidModel = (model: string): boolean => {
|
|
224
|
+
if (BUILTIN_MODELS.has(model)) return true;
|
|
225
|
+
if (!shouldValidateModels) return true;
|
|
226
|
+
return models.has(model);
|
|
227
|
+
};
|
|
114
228
|
|
|
115
229
|
const merged: Record<string, AgentConfig> = {};
|
|
116
230
|
|
|
117
231
|
for (const [name, agentConfig] of Object.entries(pluginAgents)) {
|
|
118
|
-
const userOverride = userConfig
|
|
232
|
+
const userOverride = userConfig?.agents?.[name];
|
|
119
233
|
|
|
234
|
+
// Start with the base agent config
|
|
235
|
+
let finalConfig: AgentConfig = { ...agentConfig };
|
|
236
|
+
|
|
237
|
+
// Apply opencode default model if available and valid (overrides plugin default)
|
|
238
|
+
if (opencodeDefaultModel && isValidModel(opencodeDefaultModel)) {
|
|
239
|
+
finalConfig = { ...finalConfig, model: opencodeDefaultModel };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Apply user overrides from micode.json (highest priority)
|
|
120
243
|
if (userOverride) {
|
|
121
|
-
// Validate model if specified
|
|
122
244
|
if (userOverride.model) {
|
|
123
|
-
if (
|
|
124
|
-
// Model is valid
|
|
125
|
-
|
|
126
|
-
...agentConfig,
|
|
127
|
-
...userOverride,
|
|
128
|
-
};
|
|
245
|
+
if (isValidModel(userOverride.model)) {
|
|
246
|
+
// Model is valid - apply all overrides including model
|
|
247
|
+
finalConfig = { ...finalConfig, ...userOverride };
|
|
129
248
|
} else {
|
|
130
249
|
// Model is invalid - log warning and apply other overrides only
|
|
131
250
|
console.warn(
|
|
132
251
|
`[micode] Model "${userOverride.model}" for agent "${name}" is not available. Using opencode default.`,
|
|
133
252
|
);
|
|
134
253
|
const { model: _ignored, ...safeOverrides } = userOverride;
|
|
135
|
-
|
|
136
|
-
...agentConfig,
|
|
137
|
-
...safeOverrides,
|
|
138
|
-
};
|
|
254
|
+
finalConfig = { ...finalConfig, ...safeOverrides };
|
|
139
255
|
}
|
|
140
256
|
} else {
|
|
141
|
-
// No model
|
|
142
|
-
|
|
143
|
-
...agentConfig,
|
|
144
|
-
...userOverride,
|
|
145
|
-
};
|
|
257
|
+
// No model in override - apply other overrides (keep resolved model)
|
|
258
|
+
finalConfig = { ...finalConfig, ...userOverride };
|
|
146
259
|
}
|
|
147
|
-
} else {
|
|
148
|
-
merged[name] = agentConfig;
|
|
149
260
|
}
|
|
261
|
+
|
|
262
|
+
merged[name] = finalConfig;
|
|
150
263
|
}
|
|
151
264
|
|
|
152
265
|
return merged;
|
|
@@ -192,6 +305,12 @@ export function validateAgentModels(userConfig: MicodeConfig, providers: Provide
|
|
|
192
305
|
continue;
|
|
193
306
|
}
|
|
194
307
|
|
|
308
|
+
// Skip validation for built-in models
|
|
309
|
+
if (BUILTIN_MODELS.has(trimmedModel)) {
|
|
310
|
+
validatedAgents[agentName] = override;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
195
314
|
// Parse "provider/model" format
|
|
196
315
|
const [providerID, ...rest] = trimmedModel.split("/");
|
|
197
316
|
const modelID = rest.join("/");
|
|
@@ -7,6 +7,13 @@ import { config } from "../utils/config";
|
|
|
7
7
|
import { extractErrorMessage } from "../utils/errors";
|
|
8
8
|
import { getContextLimit } from "../utils/model-limits";
|
|
9
9
|
|
|
10
|
+
export interface AutoCompactConfig {
|
|
11
|
+
/** Compaction threshold (0-1), defaults to config.compaction.threshold */
|
|
12
|
+
compactionThreshold?: number;
|
|
13
|
+
/** Model context limits loaded from opencode.json */
|
|
14
|
+
modelContextLimits?: Map<string, number>;
|
|
15
|
+
}
|
|
16
|
+
|
|
10
17
|
interface PendingCompaction {
|
|
11
18
|
resolve: () => void;
|
|
12
19
|
reject: (error: Error) => void;
|
|
@@ -19,7 +26,10 @@ interface AutoCompactState {
|
|
|
19
26
|
pendingCompactions: Map<string, PendingCompaction>;
|
|
20
27
|
}
|
|
21
28
|
|
|
22
|
-
export function createAutoCompactHook(ctx: PluginInput) {
|
|
29
|
+
export function createAutoCompactHook(ctx: PluginInput, hookConfig?: AutoCompactConfig) {
|
|
30
|
+
const threshold = hookConfig?.compactionThreshold ?? config.compaction.threshold;
|
|
31
|
+
const modelLimits = hookConfig?.modelContextLimits;
|
|
32
|
+
|
|
23
33
|
const state: AutoCompactState = {
|
|
24
34
|
inProgress: new Set(),
|
|
25
35
|
lastCompactTime: new Map(),
|
|
@@ -113,7 +123,7 @@ ${summaryText}
|
|
|
113
123
|
|
|
114
124
|
try {
|
|
115
125
|
const usedPercent = Math.round(usageRatio * 100);
|
|
116
|
-
const thresholdPercent = Math.round(
|
|
126
|
+
const thresholdPercent = Math.round(threshold * 100);
|
|
117
127
|
|
|
118
128
|
await ctx.client.tui
|
|
119
129
|
.showToast({
|
|
@@ -149,12 +159,31 @@ ${summaryText}
|
|
|
149
159
|
.showToast({
|
|
150
160
|
body: {
|
|
151
161
|
title: "Compaction Complete",
|
|
152
|
-
message: "Session summarized
|
|
162
|
+
message: "Session summarized. Continuing...",
|
|
153
163
|
variant: "success",
|
|
154
164
|
duration: config.timeouts.toastSuccessMs,
|
|
155
165
|
},
|
|
156
166
|
})
|
|
157
167
|
.catch(() => {});
|
|
168
|
+
|
|
169
|
+
// Auto-continue after compaction - prompt the agent to resume work
|
|
170
|
+
await ctx.client.session
|
|
171
|
+
.prompt({
|
|
172
|
+
path: { id: sessionID },
|
|
173
|
+
body: {
|
|
174
|
+
parts: [
|
|
175
|
+
{
|
|
176
|
+
type: "text",
|
|
177
|
+
text: "Context was compacted. Continue from where you left off - check the 'In Progress' and 'Next Steps' sections in the summary above.",
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
model: { providerID, modelID },
|
|
181
|
+
},
|
|
182
|
+
query: { directory: ctx.directory },
|
|
183
|
+
})
|
|
184
|
+
.catch(() => {
|
|
185
|
+
// If auto-continue fails, user can manually prompt
|
|
186
|
+
});
|
|
158
187
|
} catch (e) {
|
|
159
188
|
const errorMsg = extractErrorMessage(e);
|
|
160
189
|
await ctx.client.tui
|
|
@@ -222,11 +251,11 @@ ${summaryText}
|
|
|
222
251
|
|
|
223
252
|
const modelID = (info?.modelID as string) || "";
|
|
224
253
|
const providerID = (info?.providerID as string) || "";
|
|
225
|
-
const contextLimit = getContextLimit(modelID);
|
|
254
|
+
const contextLimit = getContextLimit(modelID, providerID, modelLimits);
|
|
226
255
|
const usageRatio = totalUsed / contextLimit;
|
|
227
256
|
|
|
228
257
|
// Trigger compaction if over threshold
|
|
229
|
-
if (usageRatio >=
|
|
258
|
+
if (usageRatio >= threshold) {
|
|
230
259
|
triggerCompaction(sessionID, providerID, modelID, usageRatio);
|
|
231
260
|
}
|
|
232
261
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// src/hooks/constraint-reviewer.ts
|
|
2
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
formatViolationsForRetry,
|
|
6
|
+
formatViolationsForUser,
|
|
7
|
+
type LoadedMindmodel,
|
|
8
|
+
loadMindmodel,
|
|
9
|
+
parseReviewResponse,
|
|
10
|
+
type ReviewResult,
|
|
11
|
+
} from "../mindmodel";
|
|
12
|
+
import { config } from "../utils/config";
|
|
13
|
+
import { log } from "../utils/logger";
|
|
14
|
+
|
|
15
|
+
type ReviewFn = (prompt: string) => Promise<string>;
|
|
16
|
+
|
|
17
|
+
interface ReviewState {
|
|
18
|
+
/** Retry count per file path */
|
|
19
|
+
retryCountByFile: Map<string, number>;
|
|
20
|
+
/** Override active for remainder of turn */
|
|
21
|
+
overrideActive: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createConstraintReviewerHook(ctx: PluginInput, reviewFn: ReviewFn) {
|
|
25
|
+
let cachedMindmodel: LoadedMindmodel | null | undefined;
|
|
26
|
+
const sessionState = new Map<string, ReviewState>();
|
|
27
|
+
|
|
28
|
+
async function getMindmodel(): Promise<LoadedMindmodel | null> {
|
|
29
|
+
if (cachedMindmodel === undefined) {
|
|
30
|
+
cachedMindmodel = await loadMindmodel(ctx.directory);
|
|
31
|
+
}
|
|
32
|
+
return cachedMindmodel;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getSessionState(sessionID: string): ReviewState {
|
|
36
|
+
if (!sessionState.has(sessionID)) {
|
|
37
|
+
sessionState.set(sessionID, {
|
|
38
|
+
retryCountByFile: new Map(),
|
|
39
|
+
overrideActive: false,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return sessionState.get(sessionID)!;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function cleanupSession(sessionID: string): void {
|
|
46
|
+
sessionState.delete(sessionID);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
"tool.execute.after": async (
|
|
51
|
+
input: { tool: string; sessionID: string; args?: Record<string, unknown> },
|
|
52
|
+
output: { output?: string },
|
|
53
|
+
) => {
|
|
54
|
+
// Only review Write and Edit operations
|
|
55
|
+
if (!["Write", "Edit"].includes(input.tool)) return;
|
|
56
|
+
if (!config.mindmodel.reviewEnabled) return;
|
|
57
|
+
|
|
58
|
+
const mindmodel = await getMindmodel();
|
|
59
|
+
if (!mindmodel) return;
|
|
60
|
+
|
|
61
|
+
const state = getSessionState(input.sessionID);
|
|
62
|
+
|
|
63
|
+
// Skip if override is active
|
|
64
|
+
if (state.overrideActive) {
|
|
65
|
+
state.overrideActive = false;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const filePath = input.args?.file_path as string | undefined;
|
|
70
|
+
if (!filePath) return;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// Build review prompt
|
|
74
|
+
const reviewPrompt = buildReviewPrompt(output.output || "", filePath, mindmodel);
|
|
75
|
+
|
|
76
|
+
// Call reviewer
|
|
77
|
+
const reviewResponse = await reviewFn(reviewPrompt);
|
|
78
|
+
const result = parseReviewResponse(reviewResponse);
|
|
79
|
+
|
|
80
|
+
if (result.status === "PASS") {
|
|
81
|
+
// Reset retry count for this file on success
|
|
82
|
+
state.retryCountByFile.delete(filePath);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Handle violations - track retry count per file
|
|
87
|
+
const currentRetryCount = state.retryCountByFile.get(filePath) || 0;
|
|
88
|
+
|
|
89
|
+
if (currentRetryCount < config.mindmodel.reviewMaxRetries) {
|
|
90
|
+
// Trigger retry by modifying output
|
|
91
|
+
state.retryCountByFile.set(filePath, currentRetryCount + 1);
|
|
92
|
+
const violationsText = formatViolationsForRetry(result.violations);
|
|
93
|
+
output.output = `${output.output}\n\n<constraint-violations>\n${violationsText}\n</constraint-violations>`;
|
|
94
|
+
} else {
|
|
95
|
+
// Max retries reached - block
|
|
96
|
+
state.retryCountByFile.delete(filePath);
|
|
97
|
+
const userMessage = formatViolationsForUser(result.violations);
|
|
98
|
+
throw new ConstraintViolationError(userMessage, result);
|
|
99
|
+
}
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof ConstraintViolationError) {
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
// Log but don't block on review failures
|
|
105
|
+
log.warn("mindmodel", `Review failed: ${error instanceof Error ? error.message : "unknown"}`);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
"chat.message": async (input: { sessionID: string }, output: { parts: Array<{ type: string; text?: string }> }) => {
|
|
110
|
+
// Check for override command
|
|
111
|
+
const text = output.parts
|
|
112
|
+
.filter((p) => p.type === "text" && p.text)
|
|
113
|
+
.map((p) => p.text)
|
|
114
|
+
.join(" ");
|
|
115
|
+
|
|
116
|
+
const overrideMatch = text.match(/^override:\s*(.+)$/im);
|
|
117
|
+
if (overrideMatch) {
|
|
118
|
+
const state = getSessionState(input.sessionID);
|
|
119
|
+
state.overrideActive = true;
|
|
120
|
+
|
|
121
|
+
// Log the override
|
|
122
|
+
const reason = overrideMatch[1].trim();
|
|
123
|
+
await logOverride(ctx.directory, reason);
|
|
124
|
+
|
|
125
|
+
log.info("mindmodel", `Override activated: ${reason}`);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
/** Cleanup session state on session deletion to prevent memory leaks */
|
|
130
|
+
cleanupSession,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildReviewPrompt(code: string, filePath: string, mindmodel: LoadedMindmodel): string {
|
|
135
|
+
// For now, include all constraints - selective loading can be added later
|
|
136
|
+
const constraintSummary = mindmodel.manifest.categories.map((c) => `- ${c.path}: ${c.description}`).join("\n");
|
|
137
|
+
|
|
138
|
+
return `Review this generated code against project constraints.
|
|
139
|
+
|
|
140
|
+
File: ${filePath}
|
|
141
|
+
|
|
142
|
+
Code:
|
|
143
|
+
\`\`\`
|
|
144
|
+
${code}
|
|
145
|
+
\`\`\`
|
|
146
|
+
|
|
147
|
+
Available constraints:
|
|
148
|
+
${constraintSummary}
|
|
149
|
+
|
|
150
|
+
Return JSON with status "PASS" or "BLOCKED" and any violations found.`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function logOverride(projectDir: string, reason: string): Promise<void> {
|
|
154
|
+
const { appendFile, mkdir } = await import("node:fs/promises");
|
|
155
|
+
const { join } = await import("node:path");
|
|
156
|
+
|
|
157
|
+
const logPath = join(projectDir, ".mindmodel", config.mindmodel.overrideLogFile);
|
|
158
|
+
const timestamp = new Date().toISOString();
|
|
159
|
+
const entry = `${timestamp} | override | reason: "${reason}"\n`;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
await mkdir(join(projectDir, ".mindmodel"), { recursive: true });
|
|
163
|
+
await appendFile(logPath, entry);
|
|
164
|
+
} catch {
|
|
165
|
+
// Ignore logging failures
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export class ConstraintViolationError extends Error {
|
|
170
|
+
constructor(
|
|
171
|
+
message: string,
|
|
172
|
+
public readonly result: ReviewResult,
|
|
173
|
+
) {
|
|
174
|
+
super(message);
|
|
175
|
+
this.name = "ConstraintViolationError";
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import type { PluginInput } from "@opencode-ai/plugin";
|
|
2
1
|
import { readFile } from "node:fs/promises";
|
|
3
|
-
import {
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
5
|
+
|
|
4
6
|
import { config } from "../utils/config";
|
|
5
7
|
|
|
6
8
|
// Tools that trigger directory-aware context injection
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import type { PluginInput } from "@opencode-ai/plugin";
|
|
2
|
+
|
|
2
3
|
import { config } from "../utils/config";
|
|
3
4
|
import { getContextLimit } from "../utils/model-limits";
|
|
4
5
|
|
|
6
|
+
export interface ContextWindowMonitorConfig {
|
|
7
|
+
/** Model context limits loaded from opencode.json */
|
|
8
|
+
modelContextLimits?: Map<string, number>;
|
|
9
|
+
}
|
|
10
|
+
|
|
5
11
|
interface MonitorState {
|
|
6
12
|
lastWarningTime: Map<string, number>;
|
|
7
13
|
lastUsageRatio: Map<string, number>;
|
|
8
14
|
}
|
|
9
15
|
|
|
10
|
-
export function createContextWindowMonitorHook(ctx: PluginInput) {
|
|
16
|
+
export function createContextWindowMonitorHook(ctx: PluginInput, hookConfig?: ContextWindowMonitorConfig) {
|
|
17
|
+
const modelLimits = hookConfig?.modelContextLimits;
|
|
11
18
|
const state: MonitorState = {
|
|
12
19
|
lastWarningTime: new Map(),
|
|
13
20
|
lastUsageRatio: new Map(),
|
|
@@ -69,7 +76,8 @@ export function createContextWindowMonitorHook(ctx: PluginInput) {
|
|
|
69
76
|
const totalUsed = inputTokens + cacheRead;
|
|
70
77
|
|
|
71
78
|
const modelID = (info.modelID as string) || "";
|
|
72
|
-
const
|
|
79
|
+
const providerID = (info.providerID as string) || "";
|
|
80
|
+
const contextLimit = getContextLimit(modelID, providerID, modelLimits);
|
|
73
81
|
const usageRatio = totalUsed / contextLimit;
|
|
74
82
|
|
|
75
83
|
state.lastUsageRatio.set(sessionID, usageRatio);
|