micode 0.8.3 → 0.8.5
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/README.md +2 -2
- package/dist/index.js +21020 -0
- package/package.json +10 -6
- package/src/agents/artifact-searcher.ts +0 -1
- package/src/agents/bootstrapper.ts +164 -0
- package/src/agents/brainstormer.ts +140 -33
- package/src/agents/codebase-analyzer.ts +0 -1
- package/src/agents/codebase-locator.ts +0 -1
- package/src/agents/commander.ts +99 -10
- package/src/agents/executor.ts +18 -1
- package/src/agents/implementer.ts +83 -6
- package/src/agents/index.ts +29 -19
- package/src/agents/ledger-creator.ts +0 -1
- package/src/agents/octto.ts +132 -0
- package/src/agents/pattern-finder.ts +0 -1
- package/src/agents/planner.ts +139 -49
- package/src/agents/probe.ts +152 -0
- package/src/agents/project-initializer.ts +0 -1
- package/src/agents/reviewer.ts +75 -5
- package/src/config-loader.test.ts +226 -0
- package/src/config-loader.ts +132 -6
- package/src/hooks/artifact-auto-index.ts +2 -1
- package/src/hooks/auto-compact.ts +14 -21
- package/src/hooks/context-injector.ts +6 -13
- package/src/hooks/context-window-monitor.ts +8 -13
- package/src/hooks/ledger-loader.ts +4 -6
- package/src/hooks/token-aware-truncation.ts +11 -17
- package/src/index.ts +54 -22
- package/src/indexing/milestone-artifact-classifier.ts +26 -0
- package/src/indexing/milestone-artifact-ingest.ts +42 -0
- package/src/octto/constants.ts +20 -0
- package/src/octto/session/browser.ts +32 -0
- package/src/octto/session/index.ts +25 -0
- package/src/octto/session/server.ts +89 -0
- package/src/octto/session/sessions.ts +383 -0
- package/src/octto/session/types.ts +305 -0
- package/src/octto/session/utils.ts +25 -0
- package/src/octto/session/waiter.ts +139 -0
- package/src/octto/state/index.ts +5 -0
- package/src/octto/state/persistence.ts +65 -0
- package/src/octto/state/store.ts +161 -0
- package/src/octto/state/types.ts +51 -0
- package/src/octto/types.ts +376 -0
- package/src/octto/ui/bundle.ts +1650 -0
- package/src/octto/ui/index.ts +2 -0
- package/src/tools/artifact-index/index.ts +152 -3
- package/src/tools/artifact-index/schema.sql +21 -0
- package/src/tools/milestone-artifact-search.ts +48 -0
- package/src/tools/octto/brainstorm.ts +332 -0
- package/src/tools/octto/extractor.ts +95 -0
- package/src/tools/octto/factory.ts +89 -0
- package/src/tools/octto/formatters.ts +63 -0
- package/src/tools/octto/index.ts +27 -0
- package/src/tools/octto/processor.ts +165 -0
- package/src/tools/octto/questions.ts +508 -0
- package/src/tools/octto/responses.ts +135 -0
- package/src/tools/octto/session.ts +114 -0
- package/src/tools/octto/types.ts +21 -0
- package/src/tools/octto/utils.ts +4 -0
- package/src/tools/pty/manager.ts +13 -7
- package/src/tools/spawn-agent.ts +1 -3
- package/src/utils/config.ts +123 -0
- package/src/utils/errors.ts +57 -0
- package/src/utils/logger.ts +50 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// src/config-loader.test.ts
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
|
|
4
|
+
import { type MicodeConfig, type ProviderInfo, validateAgentModels } from "./config-loader";
|
|
5
|
+
|
|
6
|
+
// Helper to create a minimal ProviderInfo for testing
|
|
7
|
+
function createProvider(id: string, modelIds: string[]): ProviderInfo {
|
|
8
|
+
const models: Record<string, unknown> = {};
|
|
9
|
+
for (const modelId of modelIds) {
|
|
10
|
+
models[modelId] = { id: modelId };
|
|
11
|
+
}
|
|
12
|
+
return { id, models };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("validateAgentModels", () => {
|
|
16
|
+
test("returns config unchanged when all models are valid", () => {
|
|
17
|
+
const userConfig: MicodeConfig = {
|
|
18
|
+
agents: {
|
|
19
|
+
commander: { model: "openai/gpt-4" },
|
|
20
|
+
brainstormer: { model: "anthropic/claude-3" },
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const providers: ProviderInfo[] = [
|
|
25
|
+
createProvider("openai", ["gpt-4", "gpt-3.5"]),
|
|
26
|
+
createProvider("anthropic", ["claude-3", "claude-2"]),
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const result = validateAgentModels(userConfig, providers);
|
|
30
|
+
|
|
31
|
+
expect(result.agents?.commander?.model).toBe("openai/gpt-4");
|
|
32
|
+
expect(result.agents?.brainstormer?.model).toBe("anthropic/claude-3");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("removes model override when provider does not exist", () => {
|
|
36
|
+
const userConfig: MicodeConfig = {
|
|
37
|
+
agents: {
|
|
38
|
+
commander: { model: "nonexistent/gpt-4" },
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const providers: ProviderInfo[] = [createProvider("openai", ["gpt-4"])];
|
|
43
|
+
|
|
44
|
+
const result = validateAgentModels(userConfig, providers);
|
|
45
|
+
|
|
46
|
+
// Model should be removed, falling back to default
|
|
47
|
+
expect(result.agents?.commander?.model).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("removes model override when model does not exist in provider", () => {
|
|
51
|
+
const userConfig: MicodeConfig = {
|
|
52
|
+
agents: {
|
|
53
|
+
commander: { model: "openai/nonexistent-model" },
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const providers: ProviderInfo[] = [createProvider("openai", ["gpt-4", "gpt-3.5"])];
|
|
58
|
+
|
|
59
|
+
const result = validateAgentModels(userConfig, providers);
|
|
60
|
+
|
|
61
|
+
// Model should be removed, falling back to default
|
|
62
|
+
expect(result.agents?.commander?.model).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("preserves other properties when model is invalid", () => {
|
|
66
|
+
const userConfig: MicodeConfig = {
|
|
67
|
+
agents: {
|
|
68
|
+
commander: {
|
|
69
|
+
model: "nonexistent/model",
|
|
70
|
+
temperature: 0.7,
|
|
71
|
+
maxTokens: 4000,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const providers: ProviderInfo[] = [createProvider("openai", ["gpt-4"])];
|
|
77
|
+
|
|
78
|
+
const result = validateAgentModels(userConfig, providers);
|
|
79
|
+
|
|
80
|
+
// Model removed but other properties preserved
|
|
81
|
+
expect(result.agents?.commander?.model).toBeUndefined();
|
|
82
|
+
expect(result.agents?.commander?.temperature).toBe(0.7);
|
|
83
|
+
expect(result.agents?.commander?.maxTokens).toBe(4000);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("handles config with no agents", () => {
|
|
87
|
+
const userConfig: MicodeConfig = {};
|
|
88
|
+
|
|
89
|
+
const providers: ProviderInfo[] = [createProvider("openai", ["gpt-4"])];
|
|
90
|
+
|
|
91
|
+
const result = validateAgentModels(userConfig, providers);
|
|
92
|
+
|
|
93
|
+
expect(result).toEqual({});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("handles agent override with no model specified", () => {
|
|
97
|
+
const userConfig: MicodeConfig = {
|
|
98
|
+
agents: {
|
|
99
|
+
commander: { temperature: 0.5 },
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const providers: ProviderInfo[] = [createProvider("openai", ["gpt-4"])];
|
|
104
|
+
|
|
105
|
+
const result = validateAgentModels(userConfig, providers);
|
|
106
|
+
|
|
107
|
+
// No model to validate, config unchanged
|
|
108
|
+
expect(result.agents?.commander?.temperature).toBe(0.5);
|
|
109
|
+
expect(result.agents?.commander?.model).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("handles empty providers list", () => {
|
|
113
|
+
const userConfig: MicodeConfig = {
|
|
114
|
+
agents: {
|
|
115
|
+
commander: { model: "openai/gpt-4" },
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const providers: ProviderInfo[] = [];
|
|
120
|
+
|
|
121
|
+
const result = validateAgentModels(userConfig, providers);
|
|
122
|
+
|
|
123
|
+
// No providers available, config should remain unchanged
|
|
124
|
+
expect(result).toEqual(userConfig);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("handles providers with no models", () => {
|
|
128
|
+
const userConfig: MicodeConfig = {
|
|
129
|
+
agents: {
|
|
130
|
+
commander: { model: "openai/gpt-4" },
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const providers: ProviderInfo[] = [{ id: "openai", models: {} }];
|
|
135
|
+
|
|
136
|
+
const result = validateAgentModels(userConfig, providers);
|
|
137
|
+
|
|
138
|
+
// No provider models available, config should remain unchanged
|
|
139
|
+
expect(result).toEqual(userConfig);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("validates multiple agents with mixed valid/invalid models", () => {
|
|
143
|
+
const userConfig: MicodeConfig = {
|
|
144
|
+
agents: {
|
|
145
|
+
commander: { model: "openai/gpt-4" }, // valid
|
|
146
|
+
brainstormer: { model: "fake/model" }, // invalid provider
|
|
147
|
+
planner: { model: "openai/fake-model" }, // invalid model
|
|
148
|
+
reviewer: { model: "anthropic/claude-3" }, // valid
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const providers: ProviderInfo[] = [
|
|
153
|
+
createProvider("openai", ["gpt-4", "gpt-3.5"]),
|
|
154
|
+
createProvider("anthropic", ["claude-3"]),
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
const result = validateAgentModels(userConfig, providers);
|
|
158
|
+
|
|
159
|
+
expect(result.agents?.commander?.model).toBe("openai/gpt-4");
|
|
160
|
+
expect(result.agents?.brainstormer?.model).toBeUndefined();
|
|
161
|
+
expect(result.agents?.planner?.model).toBeUndefined();
|
|
162
|
+
expect(result.agents?.reviewer?.model).toBe("anthropic/claude-3");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("removes empty string model", () => {
|
|
166
|
+
const userConfig: MicodeConfig = {
|
|
167
|
+
agents: {
|
|
168
|
+
commander: { model: "", temperature: 0.5 },
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const providers: ProviderInfo[] = [createProvider("openai", ["gpt-4"])];
|
|
173
|
+
|
|
174
|
+
const result = validateAgentModels(userConfig, providers);
|
|
175
|
+
|
|
176
|
+
// Empty string model should be removed as invalid
|
|
177
|
+
expect(result.agents?.commander?.model).toBeUndefined();
|
|
178
|
+
expect(result.agents?.commander?.temperature).toBe(0.5);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("removes model string without slash (malformed)", () => {
|
|
182
|
+
const userConfig: MicodeConfig = {
|
|
183
|
+
agents: {
|
|
184
|
+
commander: { model: "gpt-4-no-provider" },
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const providers: ProviderInfo[] = [createProvider("openai", ["gpt-4"])];
|
|
189
|
+
|
|
190
|
+
const result = validateAgentModels(userConfig, providers);
|
|
191
|
+
|
|
192
|
+
// Malformed model (no slash) should be removed
|
|
193
|
+
expect(result.agents?.commander?.model).toBeUndefined();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("handles model with multiple slashes in model ID", () => {
|
|
197
|
+
const userConfig: MicodeConfig = {
|
|
198
|
+
agents: {
|
|
199
|
+
commander: { model: "openai/gpt-4/turbo" },
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Model ID is "gpt-4/turbo" (contains slash)
|
|
204
|
+
const providers: ProviderInfo[] = [createProvider("openai", ["gpt-4/turbo"])];
|
|
205
|
+
|
|
206
|
+
const result = validateAgentModels(userConfig, providers);
|
|
207
|
+
|
|
208
|
+
// Should be valid - "gpt-4/turbo" is the full model ID
|
|
209
|
+
expect(result.agents?.commander?.model).toBe("openai/gpt-4/turbo");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("returns consistent shape when all agents have invalid models", () => {
|
|
213
|
+
const userConfig: MicodeConfig = {
|
|
214
|
+
agents: {
|
|
215
|
+
commander: { model: "invalid/model" },
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const providers: ProviderInfo[] = [createProvider("openai", ["gpt-4"])];
|
|
220
|
+
|
|
221
|
+
const result = validateAgentModels(userConfig, providers);
|
|
222
|
+
|
|
223
|
+
// Should return { agents: {} } for consistency, not {}
|
|
224
|
+
expect(result).toEqual({ agents: {} });
|
|
225
|
+
});
|
|
226
|
+
});
|
package/src/config-loader.ts
CHANGED
|
@@ -1,9 +1,46 @@
|
|
|
1
1
|
// src/config-loader.ts
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { readFile } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
5
7
|
import type { AgentConfig } from "@opencode-ai/sdk";
|
|
6
8
|
|
|
9
|
+
// Minimal type for provider validation - only what we need
|
|
10
|
+
export interface ProviderInfo {
|
|
11
|
+
id: string;
|
|
12
|
+
models: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Load available models from opencode.json config file (synchronous)
|
|
17
|
+
* Returns a Set of "provider/model" strings
|
|
18
|
+
*/
|
|
19
|
+
export function loadAvailableModels(configDir?: string): Set<string> {
|
|
20
|
+
const availableModels = new Set<string>();
|
|
21
|
+
const baseDir = configDir ?? join(homedir(), ".config", "opencode");
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const configPath = join(baseDir, "opencode.json");
|
|
25
|
+
const content = readFileSync(configPath, "utf-8");
|
|
26
|
+
const config = JSON.parse(content) as { provider?: Record<string, { models?: Record<string, unknown> }> };
|
|
27
|
+
|
|
28
|
+
if (config.provider) {
|
|
29
|
+
for (const [providerId, providerConfig] of Object.entries(config.provider)) {
|
|
30
|
+
if (providerConfig.models) {
|
|
31
|
+
for (const modelId of Object.keys(providerConfig.models)) {
|
|
32
|
+
availableModels.add(`${providerId}/${modelId}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// Config doesn't exist or can't be parsed - return empty set
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return availableModels;
|
|
42
|
+
}
|
|
43
|
+
|
|
7
44
|
// Safe properties that users can override
|
|
8
45
|
const SAFE_AGENT_PROPERTIES = ["model", "temperature", "maxTokens"] as const;
|
|
9
46
|
|
|
@@ -60,26 +97,53 @@ export async function loadMicodeConfig(configDir?: string): Promise<MicodeConfig
|
|
|
60
97
|
|
|
61
98
|
/**
|
|
62
99
|
* Merge user config overrides into plugin agent configs
|
|
63
|
-
*
|
|
100
|
+
* Model overrides are validated against available models from opencode.json
|
|
101
|
+
* Invalid models are logged and skipped (agent uses opencode default)
|
|
64
102
|
*/
|
|
65
103
|
export function mergeAgentConfigs(
|
|
66
104
|
pluginAgents: Record<string, AgentConfig>,
|
|
67
105
|
userConfig: MicodeConfig | null,
|
|
106
|
+
availableModels?: Set<string>,
|
|
68
107
|
): Record<string, AgentConfig> {
|
|
69
108
|
if (!userConfig?.agents) {
|
|
70
109
|
return pluginAgents;
|
|
71
110
|
}
|
|
72
111
|
|
|
112
|
+
const models = availableModels ?? loadAvailableModels();
|
|
113
|
+
const shouldValidateModels = models.size > 0;
|
|
114
|
+
|
|
73
115
|
const merged: Record<string, AgentConfig> = {};
|
|
74
116
|
|
|
75
117
|
for (const [name, agentConfig] of Object.entries(pluginAgents)) {
|
|
76
118
|
const userOverride = userConfig.agents[name];
|
|
77
119
|
|
|
78
120
|
if (userOverride) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
121
|
+
// Validate model if specified
|
|
122
|
+
if (userOverride.model) {
|
|
123
|
+
if (!shouldValidateModels || models.has(userOverride.model)) {
|
|
124
|
+
// Model is valid (or validation unavailable) - apply all overrides
|
|
125
|
+
merged[name] = {
|
|
126
|
+
...agentConfig,
|
|
127
|
+
...userOverride,
|
|
128
|
+
};
|
|
129
|
+
} else {
|
|
130
|
+
// Model is invalid - log warning and apply other overrides only
|
|
131
|
+
console.warn(
|
|
132
|
+
`[micode] Model "${userOverride.model}" for agent "${name}" is not available. Using opencode default.`,
|
|
133
|
+
);
|
|
134
|
+
const { model: _ignored, ...safeOverrides } = userOverride;
|
|
135
|
+
merged[name] = {
|
|
136
|
+
...agentConfig,
|
|
137
|
+
...safeOverrides,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
// No model specified - apply all overrides
|
|
142
|
+
merged[name] = {
|
|
143
|
+
...agentConfig,
|
|
144
|
+
...userOverride,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
83
147
|
} else {
|
|
84
148
|
merged[name] = agentConfig;
|
|
85
149
|
}
|
|
@@ -87,3 +151,65 @@ export function mergeAgentConfigs(
|
|
|
87
151
|
|
|
88
152
|
return merged;
|
|
89
153
|
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Validate that configured models exist in available providers
|
|
157
|
+
* Removes invalid model overrides and logs warnings
|
|
158
|
+
*/
|
|
159
|
+
export function validateAgentModels(userConfig: MicodeConfig, providers: ProviderInfo[]): MicodeConfig {
|
|
160
|
+
if (!userConfig.agents) {
|
|
161
|
+
return userConfig;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const hasAnyModels = providers.some((provider) => Object.keys(provider.models).length > 0);
|
|
165
|
+
if (!hasAnyModels) {
|
|
166
|
+
return userConfig;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Build lookup map for providers and their models
|
|
170
|
+
const providerMap = new Map<string, Set<string>>();
|
|
171
|
+
for (const provider of providers) {
|
|
172
|
+
providerMap.set(provider.id, new Set(Object.keys(provider.models)));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const validatedAgents: Record<string, AgentOverride> = {};
|
|
176
|
+
|
|
177
|
+
for (const [agentName, override] of Object.entries(userConfig.agents)) {
|
|
178
|
+
// No model specified - keep other properties as-is
|
|
179
|
+
if (override.model === undefined) {
|
|
180
|
+
validatedAgents[agentName] = override;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Empty or whitespace-only model - treat as invalid
|
|
185
|
+
const trimmedModel = override.model.trim();
|
|
186
|
+
if (!trimmedModel) {
|
|
187
|
+
const { model: _removed, ...otherProps } = override;
|
|
188
|
+
console.warn(`[micode] Empty model for agent "${agentName}". Using default model.`);
|
|
189
|
+
if (Object.keys(otherProps).length > 0) {
|
|
190
|
+
validatedAgents[agentName] = otherProps;
|
|
191
|
+
}
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Parse "provider/model" format
|
|
196
|
+
const [providerID, ...rest] = trimmedModel.split("/");
|
|
197
|
+
const modelID = rest.join("/");
|
|
198
|
+
|
|
199
|
+
const providerModels = providerMap.get(providerID);
|
|
200
|
+
const isValid = providerModels?.has(modelID) ?? false;
|
|
201
|
+
|
|
202
|
+
if (isValid) {
|
|
203
|
+
validatedAgents[agentName] = override;
|
|
204
|
+
} else {
|
|
205
|
+
// Remove invalid model but keep other properties
|
|
206
|
+
const { model: _removed, ...otherProps } = override;
|
|
207
|
+
console.warn(`[micode] Model "${override.model}" not found for agent "${agentName}". Using default model.`);
|
|
208
|
+
if (Object.keys(otherProps).length > 0) {
|
|
209
|
+
validatedAgents[agentName] = otherProps;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { agents: validatedAgents };
|
|
215
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { PluginInput } from "@opencode-ai/plugin";
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
6
|
import { getArtifactIndex } from "../tools/artifact-index";
|
|
7
|
+
import { log } from "../utils/logger";
|
|
7
8
|
|
|
8
9
|
const LEDGER_PATH_PATTERN = /thoughts\/ledgers\/CONTINUITY_(.+)\.md$/;
|
|
9
10
|
const PLAN_PATH_PATTERN = /thoughts\/shared\/plans\/(.+)\.md$/;
|
|
@@ -102,7 +103,7 @@ export function createArtifactAutoIndexHook(_ctx: PluginInput) {
|
|
|
102
103
|
}
|
|
103
104
|
} catch (e) {
|
|
104
105
|
// Silent failure - don't interrupt user flow
|
|
105
|
-
|
|
106
|
+
log.error("artifact-auto-index", `Error indexing ${filePath}`, e);
|
|
106
107
|
}
|
|
107
108
|
},
|
|
108
109
|
};
|
|
@@ -1,15 +1,11 @@
|
|
|
1
|
-
import type { PluginInput } from "@opencode-ai/plugin";
|
|
2
|
-
import { getContextLimit } from "../utils/model-limits";
|
|
3
1
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
2
|
import { join } from "node:path";
|
|
5
3
|
|
|
6
|
-
|
|
7
|
-
const COMPACT_THRESHOLD = 0.5;
|
|
8
|
-
|
|
9
|
-
const LEDGER_DIR = "thoughts/ledgers";
|
|
4
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
10
5
|
|
|
11
|
-
|
|
12
|
-
|
|
6
|
+
import { config } from "../utils/config";
|
|
7
|
+
import { extractErrorMessage } from "../utils/errors";
|
|
8
|
+
import { getContextLimit } from "../utils/model-limits";
|
|
13
9
|
|
|
14
10
|
interface PendingCompaction {
|
|
15
11
|
resolve: () => void;
|
|
@@ -23,9 +19,6 @@ interface AutoCompactState {
|
|
|
23
19
|
pendingCompactions: Map<string, PendingCompaction>;
|
|
24
20
|
}
|
|
25
21
|
|
|
26
|
-
// Cooldown between compaction attempts (prevent rapid re-triggering)
|
|
27
|
-
const COMPACT_COOLDOWN_MS = 30_000; // 30 seconds
|
|
28
|
-
|
|
29
22
|
export function createAutoCompactHook(ctx: PluginInput) {
|
|
30
23
|
const state: AutoCompactState = {
|
|
31
24
|
inProgress: new Set(),
|
|
@@ -65,13 +58,13 @@ export function createAutoCompactHook(ctx: PluginInput) {
|
|
|
65
58
|
if (!summaryText.trim()) return;
|
|
66
59
|
|
|
67
60
|
// Create ledger directory if needed
|
|
68
|
-
const ledgerDir = join(ctx.directory,
|
|
61
|
+
const ledgerDir = join(ctx.directory, config.paths.ledgerDir);
|
|
69
62
|
await mkdir(ledgerDir, { recursive: true });
|
|
70
63
|
|
|
71
64
|
// Write ledger file - summary is already structured (Factory.ai/pi-mono format)
|
|
72
65
|
const timestamp = new Date().toISOString();
|
|
73
66
|
const sessionName = sessionID.slice(0, 8); // Use first 8 chars of session ID
|
|
74
|
-
const ledgerPath = join(ledgerDir,
|
|
67
|
+
const ledgerPath = join(ledgerDir, `${config.paths.ledgerPrefix}${sessionName}.md`);
|
|
75
68
|
|
|
76
69
|
// Add metadata header, then the structured summary as-is
|
|
77
70
|
const ledgerContent = `---
|
|
@@ -94,7 +87,7 @@ ${summaryText}
|
|
|
94
87
|
const timeoutId = setTimeout(() => {
|
|
95
88
|
state.pendingCompactions.delete(sessionID);
|
|
96
89
|
reject(new Error("Compaction timed out"));
|
|
97
|
-
},
|
|
90
|
+
}, config.compaction.timeoutMs);
|
|
98
91
|
|
|
99
92
|
state.pendingCompactions.set(sessionID, { resolve, reject, timeoutId });
|
|
100
93
|
});
|
|
@@ -112,7 +105,7 @@ ${summaryText}
|
|
|
112
105
|
|
|
113
106
|
// Check cooldown
|
|
114
107
|
const lastCompact = state.lastCompactTime.get(sessionID) || 0;
|
|
115
|
-
if (Date.now() - lastCompact <
|
|
108
|
+
if (Date.now() - lastCompact < config.compaction.cooldownMs) {
|
|
116
109
|
return;
|
|
117
110
|
}
|
|
118
111
|
|
|
@@ -120,7 +113,7 @@ ${summaryText}
|
|
|
120
113
|
|
|
121
114
|
try {
|
|
122
115
|
const usedPercent = Math.round(usageRatio * 100);
|
|
123
|
-
const thresholdPercent = Math.round(
|
|
116
|
+
const thresholdPercent = Math.round(config.compaction.threshold * 100);
|
|
124
117
|
|
|
125
118
|
await ctx.client.tui
|
|
126
119
|
.showToast({
|
|
@@ -128,7 +121,7 @@ ${summaryText}
|
|
|
128
121
|
title: "Auto Compacting",
|
|
129
122
|
message: `Context at ${usedPercent}% (threshold: ${thresholdPercent}%). Summarizing...`,
|
|
130
123
|
variant: "warning",
|
|
131
|
-
duration:
|
|
124
|
+
duration: config.timeouts.toastWarningMs,
|
|
132
125
|
},
|
|
133
126
|
})
|
|
134
127
|
.catch(() => {});
|
|
@@ -158,19 +151,19 @@ ${summaryText}
|
|
|
158
151
|
title: "Compaction Complete",
|
|
159
152
|
message: "Session summarized and ledger updated.",
|
|
160
153
|
variant: "success",
|
|
161
|
-
duration:
|
|
154
|
+
duration: config.timeouts.toastSuccessMs,
|
|
162
155
|
},
|
|
163
156
|
})
|
|
164
157
|
.catch(() => {});
|
|
165
158
|
} catch (e) {
|
|
166
|
-
const errorMsg =
|
|
159
|
+
const errorMsg = extractErrorMessage(e);
|
|
167
160
|
await ctx.client.tui
|
|
168
161
|
.showToast({
|
|
169
162
|
body: {
|
|
170
163
|
title: "Compaction Failed",
|
|
171
164
|
message: errorMsg.slice(0, 100),
|
|
172
165
|
variant: "error",
|
|
173
|
-
duration:
|
|
166
|
+
duration: config.timeouts.toastErrorMs,
|
|
174
167
|
},
|
|
175
168
|
})
|
|
176
169
|
.catch(() => {});
|
|
@@ -233,7 +226,7 @@ ${summaryText}
|
|
|
233
226
|
const usageRatio = totalUsed / contextLimit;
|
|
234
227
|
|
|
235
228
|
// Trigger compaction if over threshold
|
|
236
|
-
if (usageRatio >=
|
|
229
|
+
if (usageRatio >= config.compaction.threshold) {
|
|
237
230
|
triggerCompaction(sessionID, providerID, modelID, usageRatio);
|
|
238
231
|
}
|
|
239
232
|
}
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import type { PluginInput } from "@opencode-ai/plugin";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { join, dirname, resolve } from "node:path";
|
|
4
|
-
|
|
5
|
-
// Files to inject at project root level (AGENTS.md and CLAUDE.md handled by OpenCode natively)
|
|
6
|
-
const ROOT_CONTEXT_FILES = ["ARCHITECTURE.md", "CODE_STYLE.md", "README.md"] as const;
|
|
7
|
-
|
|
8
|
-
// Files to collect when walking up directories (AGENTS.md handled by OpenCode natively)
|
|
9
|
-
const DIRECTORY_CONTEXT_FILES = ["README.md"] as const;
|
|
4
|
+
import { config } from "../utils/config";
|
|
10
5
|
|
|
11
6
|
// Tools that trigger directory-aware context injection
|
|
12
7
|
const FILE_ACCESS_TOOLS = ["Read", "read", "Edit", "edit"];
|
|
@@ -18,8 +13,6 @@ interface ContextCache {
|
|
|
18
13
|
lastRootCheck: number;
|
|
19
14
|
}
|
|
20
15
|
|
|
21
|
-
const CACHE_TTL = 30_000; // 30 seconds
|
|
22
|
-
|
|
23
16
|
export function createContextInjectorHook(ctx: PluginInput) {
|
|
24
17
|
const cache: ContextCache = {
|
|
25
18
|
rootContent: new Map(),
|
|
@@ -30,14 +23,14 @@ export function createContextInjectorHook(ctx: PluginInput) {
|
|
|
30
23
|
async function loadRootContextFiles(): Promise<Map<string, string>> {
|
|
31
24
|
const now = Date.now();
|
|
32
25
|
|
|
33
|
-
if (now - cache.lastRootCheck <
|
|
26
|
+
if (now - cache.lastRootCheck < config.limits.contextCacheTtlMs && cache.rootContent.size > 0) {
|
|
34
27
|
return cache.rootContent;
|
|
35
28
|
}
|
|
36
29
|
|
|
37
30
|
cache.rootContent.clear();
|
|
38
31
|
cache.lastRootCheck = now;
|
|
39
32
|
|
|
40
|
-
for (const filename of
|
|
33
|
+
for (const filename of config.paths.rootContextFiles) {
|
|
41
34
|
try {
|
|
42
35
|
const filepath = join(ctx.directory, filename);
|
|
43
36
|
const content = await readFile(filepath, "utf-8");
|
|
@@ -67,7 +60,7 @@ export function createContextInjectorHook(ctx: PluginInput) {
|
|
|
67
60
|
|
|
68
61
|
// Walk up from file directory to project root
|
|
69
62
|
while (currentDir.startsWith(projectRoot) || currentDir === projectRoot) {
|
|
70
|
-
for (const filename of
|
|
63
|
+
for (const filename of config.paths.dirContextFiles) {
|
|
71
64
|
const contextPath = join(currentDir, filename);
|
|
72
65
|
const relPath = currentDir.replace(projectRoot, "").replace(/^\//, "") || ".";
|
|
73
66
|
const key = `${relPath}/${filename}`;
|
|
@@ -98,7 +91,7 @@ export function createContextInjectorHook(ctx: PluginInput) {
|
|
|
98
91
|
cache.directoryContent.set(cacheKey, collected);
|
|
99
92
|
|
|
100
93
|
// Limit cache size
|
|
101
|
-
if (cache.directoryContent.size >
|
|
94
|
+
if (cache.directoryContent.size > config.limits.contextCacheMaxSize) {
|
|
102
95
|
const firstKey = cache.directoryContent.keys().next().value;
|
|
103
96
|
if (firstKey) cache.directoryContent.delete(firstKey);
|
|
104
97
|
}
|
|
@@ -143,7 +136,7 @@ export function createContextInjectorHook(ctx: PluginInput) {
|
|
|
143
136
|
) => {
|
|
144
137
|
if (!FILE_ACCESS_TOOLS.includes(input.tool)) return;
|
|
145
138
|
|
|
146
|
-
const filePath = input.args?.
|
|
139
|
+
const filePath = input.args?.filePath as string | undefined;
|
|
147
140
|
if (!filePath) return;
|
|
148
141
|
|
|
149
142
|
try {
|
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
import type { PluginInput } from "@opencode-ai/plugin";
|
|
2
|
+
import { config } from "../utils/config";
|
|
2
3
|
import { getContextLimit } from "../utils/model-limits";
|
|
3
4
|
|
|
4
|
-
// Thresholds for context window warnings
|
|
5
|
-
const WARNING_THRESHOLD = 0.7; // 70% - remind there's still room
|
|
6
|
-
const CRITICAL_THRESHOLD = 0.85; // 85% - getting tight
|
|
7
|
-
|
|
8
5
|
interface MonitorState {
|
|
9
6
|
lastWarningTime: Map<string, number>;
|
|
10
7
|
lastUsageRatio: Map<string, number>;
|
|
11
8
|
}
|
|
12
9
|
|
|
13
|
-
const WARNING_COOLDOWN_MS = 120_000; // 2 minutes between warnings
|
|
14
|
-
|
|
15
10
|
export function createContextWindowMonitorHook(ctx: PluginInput) {
|
|
16
11
|
const state: MonitorState = {
|
|
17
12
|
lastWarningTime: new Map(),
|
|
@@ -21,11 +16,11 @@ export function createContextWindowMonitorHook(ctx: PluginInput) {
|
|
|
21
16
|
function getEncouragementMessage(usageRatio: number): string {
|
|
22
17
|
const remaining = Math.round((1 - usageRatio) * 100);
|
|
23
18
|
|
|
24
|
-
if (usageRatio <
|
|
19
|
+
if (usageRatio < config.contextWindow.warningThreshold) {
|
|
25
20
|
return ""; // No message needed
|
|
26
21
|
}
|
|
27
22
|
|
|
28
|
-
if (usageRatio <
|
|
23
|
+
if (usageRatio < config.contextWindow.criticalThreshold) {
|
|
29
24
|
return `Context: ${remaining}% remaining. Plenty of room - don't rush.`;
|
|
30
25
|
}
|
|
31
26
|
|
|
@@ -40,7 +35,7 @@ export function createContextWindowMonitorHook(ctx: PluginInput) {
|
|
|
40
35
|
) => {
|
|
41
36
|
const usageRatio = state.lastUsageRatio.get(input.sessionID);
|
|
42
37
|
|
|
43
|
-
if (usageRatio && usageRatio >=
|
|
38
|
+
if (usageRatio && usageRatio >= config.contextWindow.warningThreshold) {
|
|
44
39
|
const message = getEncouragementMessage(usageRatio);
|
|
45
40
|
if (message && output.system) {
|
|
46
41
|
output.system = `${output.system}\n\n<context-status>${message}</context-status>`;
|
|
@@ -80,13 +75,13 @@ export function createContextWindowMonitorHook(ctx: PluginInput) {
|
|
|
80
75
|
state.lastUsageRatio.set(sessionID, usageRatio);
|
|
81
76
|
|
|
82
77
|
// Show toast warning if threshold crossed
|
|
83
|
-
if (usageRatio >=
|
|
78
|
+
if (usageRatio >= config.contextWindow.warningThreshold) {
|
|
84
79
|
const lastWarning = state.lastWarningTime.get(sessionID) || 0;
|
|
85
|
-
if (Date.now() - lastWarning >
|
|
80
|
+
if (Date.now() - lastWarning > config.contextWindow.warningCooldownMs) {
|
|
86
81
|
state.lastWarningTime.set(sessionID, Date.now());
|
|
87
82
|
|
|
88
83
|
const remaining = Math.round((1 - usageRatio) * 100);
|
|
89
|
-
const variant = usageRatio >=
|
|
84
|
+
const variant = usageRatio >= config.contextWindow.criticalThreshold ? "warning" : "info";
|
|
90
85
|
|
|
91
86
|
await ctx.client.tui
|
|
92
87
|
.showToast({
|
|
@@ -94,7 +89,7 @@ export function createContextWindowMonitorHook(ctx: PluginInput) {
|
|
|
94
89
|
title: "Context Window",
|
|
95
90
|
message: `${remaining}% remaining (${Math.round(totalUsed / 1000)}K / ${Math.round(contextLimit / 1000)}K tokens)`,
|
|
96
91
|
variant,
|
|
97
|
-
duration:
|
|
92
|
+
duration: config.timeouts.toastWarningMs,
|
|
98
93
|
},
|
|
99
94
|
})
|
|
100
95
|
.catch(() => {});
|