micode 0.9.0 → 0.10.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/README.md +40 -4
- package/dist/index.js +1614 -530
- package/package.json +2 -1
- package/src/agents/brainstormer.ts +1 -1
- package/src/agents/commander.ts +2 -1
- package/src/agents/index.ts +27 -26
- package/src/config-loader.test.ts +53 -2
- package/src/config-loader.ts +78 -17
- package/src/hooks/fetch-tracker.ts +233 -0
- package/src/index.ts +22 -5
- package/src/mindmodel/loader.ts +3 -2
- package/src/tools/mindmodel-lookup.ts +2 -2
- package/src/tools/pty/index.ts +10 -8
- package/src/tools/pty/manager.ts +33 -3
- package/src/tools/pty/pty-loader.ts +123 -0
- package/src/utils/config.ts +25 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "micode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "OpenCode plugin with Brainstorm-Research-Plan-Implement workflow",
|
|
5
5
|
"module": "dist/index.js",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@opencode-ai/plugin": "1.1.23",
|
|
44
44
|
"bun-pty": "^0.4.5",
|
|
45
|
+
"jsonc-parser": "^3.3.1",
|
|
45
46
|
"valibot": "^1.2.0",
|
|
46
47
|
"yaml": "^2.8.2"
|
|
47
48
|
},
|
|
@@ -135,7 +135,7 @@ The redesigned artifact system treats artifacts as first‑class records stored
|
|
|
135
135
|
|
|
136
136
|
<phase name="finalizing" trigger="after presenting design">
|
|
137
137
|
<action>Write validated design to thoughts/shared/designs/YYYY-MM-DD-{topic}-design.md</action>
|
|
138
|
-
<action>Commit the design document to git</action>
|
|
138
|
+
<action>Commit the design document to git (if git add fails because the file is gitignored, skip the commit — NEVER force-add ignored files)</action>
|
|
139
139
|
<action>IMMEDIATELY spawn planner - do NOT ask "Ready for planner?"</action>
|
|
140
140
|
<spawn>
|
|
141
141
|
Task(
|
package/src/agents/commander.ts
CHANGED
|
@@ -128,6 +128,7 @@ Not everything needs brainstorm → plan → execute.
|
|
|
128
128
|
<rule>Commit message format: type(scope): description</rule>
|
|
129
129
|
<rule>Types: feat, fix, refactor, docs, test, chore</rule>
|
|
130
130
|
<rule>Reference plan file in commit body</rule>
|
|
131
|
+
<rule>NEVER use git add -f or --force. If a file is gitignored, respect it and skip it.</rule>
|
|
131
132
|
</phase>
|
|
132
133
|
|
|
133
134
|
<phase name="ledger" trigger="context getting full or session ending">
|
|
@@ -250,7 +251,7 @@ export const primaryAgent: AgentConfig = {
|
|
|
250
251
|
temperature: 0.2,
|
|
251
252
|
thinking: {
|
|
252
253
|
type: "enabled",
|
|
253
|
-
budgetTokens:
|
|
254
|
+
budgetTokens: 64000,
|
|
254
255
|
},
|
|
255
256
|
maxTokens: 64000,
|
|
256
257
|
tools: {
|
package/src/agents/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AgentConfig } from "@opencode-ai/sdk";
|
|
2
2
|
|
|
3
|
+
import { DEFAULT_MODEL } from "../utils/config";
|
|
3
4
|
import { artifactSearcherAgent } from "./artifact-searcher";
|
|
4
5
|
import { bootstrapperAgent } from "./bootstrapper";
|
|
5
6
|
import { brainstormerAgent } from "./brainstormer";
|
|
@@ -30,34 +31,34 @@ import { projectInitializerAgent } from "./project-initializer";
|
|
|
30
31
|
import { reviewerAgent } from "./reviewer";
|
|
31
32
|
|
|
32
33
|
export const agents: Record<string, AgentConfig> = {
|
|
33
|
-
[PRIMARY_AGENT_NAME]: { ...primaryAgent, model:
|
|
34
|
-
brainstormer: { ...brainstormerAgent, model:
|
|
35
|
-
bootstrapper: { ...bootstrapperAgent, model:
|
|
36
|
-
"codebase-locator": { ...codebaseLocatorAgent, model:
|
|
37
|
-
"codebase-analyzer": { ...codebaseAnalyzerAgent, model:
|
|
38
|
-
"pattern-finder": { ...patternFinderAgent, model:
|
|
39
|
-
planner: { ...plannerAgent, model:
|
|
40
|
-
implementer: { ...implementerAgent, model:
|
|
41
|
-
reviewer: { ...reviewerAgent, model:
|
|
42
|
-
executor: { ...executorAgent, model:
|
|
43
|
-
"ledger-creator": { ...ledgerCreatorAgent, model:
|
|
44
|
-
"artifact-searcher": { ...artifactSearcherAgent, model:
|
|
45
|
-
"project-initializer": { ...projectInitializerAgent, model:
|
|
46
|
-
octto: { ...octtoAgent, model:
|
|
47
|
-
probe: { ...probeAgent, model:
|
|
34
|
+
[PRIMARY_AGENT_NAME]: { ...primaryAgent, model: DEFAULT_MODEL },
|
|
35
|
+
brainstormer: { ...brainstormerAgent, model: DEFAULT_MODEL },
|
|
36
|
+
bootstrapper: { ...bootstrapperAgent, model: DEFAULT_MODEL },
|
|
37
|
+
"codebase-locator": { ...codebaseLocatorAgent, model: DEFAULT_MODEL },
|
|
38
|
+
"codebase-analyzer": { ...codebaseAnalyzerAgent, model: DEFAULT_MODEL },
|
|
39
|
+
"pattern-finder": { ...patternFinderAgent, model: DEFAULT_MODEL },
|
|
40
|
+
planner: { ...plannerAgent, model: DEFAULT_MODEL },
|
|
41
|
+
implementer: { ...implementerAgent, model: DEFAULT_MODEL },
|
|
42
|
+
reviewer: { ...reviewerAgent, model: DEFAULT_MODEL },
|
|
43
|
+
executor: { ...executorAgent, model: DEFAULT_MODEL },
|
|
44
|
+
"ledger-creator": { ...ledgerCreatorAgent, model: DEFAULT_MODEL },
|
|
45
|
+
"artifact-searcher": { ...artifactSearcherAgent, model: DEFAULT_MODEL },
|
|
46
|
+
"project-initializer": { ...projectInitializerAgent, model: DEFAULT_MODEL },
|
|
47
|
+
octto: { ...octtoAgent, model: DEFAULT_MODEL },
|
|
48
|
+
probe: { ...probeAgent, model: DEFAULT_MODEL },
|
|
48
49
|
// Mindmodel generation agents
|
|
49
|
-
"mm-stack-detector": { ...stackDetectorAgent, model:
|
|
50
|
-
"mm-pattern-discoverer": { ...mindmodelPatternDiscovererAgent, model:
|
|
51
|
-
"mm-example-extractor": { ...exampleExtractorAgent, model:
|
|
52
|
-
"mm-orchestrator": { ...mindmodelOrchestratorAgent, model:
|
|
50
|
+
"mm-stack-detector": { ...stackDetectorAgent, model: DEFAULT_MODEL },
|
|
51
|
+
"mm-pattern-discoverer": { ...mindmodelPatternDiscovererAgent, model: DEFAULT_MODEL },
|
|
52
|
+
"mm-example-extractor": { ...exampleExtractorAgent, model: DEFAULT_MODEL },
|
|
53
|
+
"mm-orchestrator": { ...mindmodelOrchestratorAgent, model: DEFAULT_MODEL },
|
|
53
54
|
// Mindmodel v2 analysis agents
|
|
54
|
-
"mm-dependency-mapper": { ...dependencyMapperAgent, model:
|
|
55
|
-
"mm-convention-extractor": { ...conventionExtractorAgent, model:
|
|
56
|
-
"mm-domain-extractor": { ...domainExtractorAgent, model:
|
|
57
|
-
"mm-code-clusterer": { ...codeClustererAgent, model:
|
|
58
|
-
"mm-anti-pattern-detector": { ...antiPatternDetectorAgent, model:
|
|
59
|
-
"mm-constraint-writer": { ...constraintWriterAgent, model:
|
|
60
|
-
"mm-constraint-reviewer": { ...constraintReviewerAgent, model:
|
|
55
|
+
"mm-dependency-mapper": { ...dependencyMapperAgent, model: DEFAULT_MODEL },
|
|
56
|
+
"mm-convention-extractor": { ...conventionExtractorAgent, model: DEFAULT_MODEL },
|
|
57
|
+
"mm-domain-extractor": { ...domainExtractorAgent, model: DEFAULT_MODEL },
|
|
58
|
+
"mm-code-clusterer": { ...codeClustererAgent, model: DEFAULT_MODEL },
|
|
59
|
+
"mm-anti-pattern-detector": { ...antiPatternDetectorAgent, model: DEFAULT_MODEL },
|
|
60
|
+
"mm-constraint-writer": { ...constraintWriterAgent, model: DEFAULT_MODEL },
|
|
61
|
+
"mm-constraint-reviewer": { ...constraintReviewerAgent, model: DEFAULT_MODEL },
|
|
61
62
|
};
|
|
62
63
|
|
|
63
64
|
export {
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// src/config-loader.test.ts
|
|
2
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
3
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
3
6
|
|
|
4
|
-
import { type MicodeConfig, type ProviderInfo, validateAgentModels } from "./config-loader";
|
|
7
|
+
import { loadMicodeConfig, type MicodeConfig, type ProviderInfo, validateAgentModels } from "./config-loader";
|
|
5
8
|
|
|
6
9
|
// Helper to create a minimal ProviderInfo for testing
|
|
7
10
|
function createProvider(id: string, modelIds: string[]): ProviderInfo {
|
|
@@ -224,3 +227,51 @@ describe("validateAgentModels", () => {
|
|
|
224
227
|
expect(result).toEqual({ agents: {} });
|
|
225
228
|
});
|
|
226
229
|
});
|
|
230
|
+
|
|
231
|
+
describe("JSONC parsing via loadMicodeConfig", () => {
|
|
232
|
+
let testConfigDir: string;
|
|
233
|
+
|
|
234
|
+
beforeEach(() => {
|
|
235
|
+
testConfigDir = join(tmpdir(), `micode-jsonc-unit-test-${Date.now()}`);
|
|
236
|
+
mkdirSync(testConfigDir, { recursive: true });
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
afterEach(() => {
|
|
240
|
+
rmSync(testConfigDir, { recursive: true, force: true });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("parses .jsonc file with comments and trailing commas", async () => {
|
|
244
|
+
writeFileSync(
|
|
245
|
+
join(testConfigDir, "micode.jsonc"),
|
|
246
|
+
`{
|
|
247
|
+
// Agent configuration
|
|
248
|
+
"agents": {
|
|
249
|
+
"commander": {
|
|
250
|
+
"model": "openai/gpt-4o", // use GPT-4o
|
|
251
|
+
"temperature": 0.2,
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
}`,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const config = await loadMicodeConfig(testConfigDir);
|
|
258
|
+
|
|
259
|
+
expect(config).not.toBeNull();
|
|
260
|
+
expect(config?.agents?.commander?.model).toBe("openai/gpt-4o");
|
|
261
|
+
expect(config?.agents?.commander?.temperature).toBe(0.2);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("still parses plain .json files (backward compatibility)", async () => {
|
|
265
|
+
writeFileSync(
|
|
266
|
+
join(testConfigDir, "micode.json"),
|
|
267
|
+
JSON.stringify({
|
|
268
|
+
agents: { commander: { model: "openai/gpt-4o" } },
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const config = await loadMicodeConfig(testConfigDir);
|
|
273
|
+
|
|
274
|
+
expect(config).not.toBeNull();
|
|
275
|
+
expect(config?.agents?.commander?.model).toBe("openai/gpt-4o");
|
|
276
|
+
});
|
|
277
|
+
});
|
package/src/config-loader.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// src/config-loader.ts
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
|
|
7
7
|
import type { AgentConfig } from "@opencode-ai/sdk";
|
|
8
|
+
import { type ParseError, parse as parseJsonc } from "jsonc-parser";
|
|
8
9
|
|
|
9
10
|
// Minimal type for provider validation - only what we need
|
|
10
11
|
export interface ProviderInfo {
|
|
@@ -21,23 +22,75 @@ interface OpencodeConfig {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
|
-
*
|
|
25
|
+
* Parse a JSON or JSONC string, supporting comments and trailing commas.
|
|
26
|
+
* Uses the same options as OpenCode's own parser.
|
|
27
|
+
*/
|
|
28
|
+
function parseConfigJson(content: string): unknown {
|
|
29
|
+
const errors: ParseError[] = [];
|
|
30
|
+
const result = parseJsonc(content, errors, { allowTrailingComma: true });
|
|
31
|
+
if (errors.length > 0) {
|
|
32
|
+
throw new Error(`Invalid JSON/JSONC: ${errors.length} parse error(s)`);
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve a config file path, preferring .jsonc over .json (synchronous).
|
|
39
|
+
* Returns the path to the first file found, or null if neither exists.
|
|
40
|
+
*/
|
|
41
|
+
function resolveConfigFileSync(baseDir: string, baseName: string): string | null {
|
|
42
|
+
const jsoncPath = join(baseDir, `${baseName}.jsonc`);
|
|
43
|
+
if (existsSync(jsoncPath)) {
|
|
44
|
+
return jsoncPath;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const jsonPath = join(baseDir, `${baseName}.json`);
|
|
48
|
+
if (existsSync(jsonPath)) {
|
|
49
|
+
return jsonPath;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read a config file, preferring .jsonc over .json (async).
|
|
57
|
+
* Returns the file content string, or null if neither file exists.
|
|
58
|
+
*/
|
|
59
|
+
async function readConfigFileAsync(baseDir: string, baseName: string): Promise<string | null> {
|
|
60
|
+
// Try .jsonc first
|
|
61
|
+
try {
|
|
62
|
+
return await readFile(join(baseDir, `${baseName}.jsonc`), "utf-8");
|
|
63
|
+
} catch {
|
|
64
|
+
// .jsonc not found, try .json
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
return await readFile(join(baseDir, `${baseName}.json`), "utf-8");
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Load opencode.json/opencode.jsonc config file (synchronous)
|
|
25
76
|
* Returns the parsed config or null if unavailable
|
|
26
77
|
*/
|
|
27
78
|
function loadOpencodeConfig(configDir?: string): OpencodeConfig | null {
|
|
28
79
|
const baseDir = configDir ?? join(homedir(), ".config", "opencode");
|
|
29
80
|
|
|
30
81
|
try {
|
|
31
|
-
const configPath =
|
|
82
|
+
const configPath = resolveConfigFileSync(baseDir, "opencode");
|
|
83
|
+
if (!configPath) return null;
|
|
84
|
+
|
|
32
85
|
const content = readFileSync(configPath, "utf-8");
|
|
33
|
-
return
|
|
86
|
+
return parseConfigJson(content) as OpencodeConfig;
|
|
34
87
|
} catch {
|
|
35
88
|
return null;
|
|
36
89
|
}
|
|
37
90
|
}
|
|
38
91
|
|
|
39
92
|
/**
|
|
40
|
-
* Load available models from opencode.json config file (synchronous)
|
|
93
|
+
* Load available models from opencode.json/opencode.jsonc config file (synchronous)
|
|
41
94
|
* Returns a Set of "provider/model" strings
|
|
42
95
|
*/
|
|
43
96
|
export function loadAvailableModels(configDir?: string): Set<string> {
|
|
@@ -58,7 +111,7 @@ export function loadAvailableModels(configDir?: string): Set<string> {
|
|
|
58
111
|
}
|
|
59
112
|
|
|
60
113
|
/**
|
|
61
|
-
* Load the default model from opencode.json config file (synchronous)
|
|
114
|
+
* Load the default model from opencode.json/opencode.jsonc config file (synchronous)
|
|
62
115
|
* Returns the model string in "provider/model" format or null if not set
|
|
63
116
|
*/
|
|
64
117
|
export function loadDefaultModel(configDir?: string): string | null {
|
|
@@ -67,7 +120,7 @@ export function loadDefaultModel(configDir?: string): string | null {
|
|
|
67
120
|
}
|
|
68
121
|
|
|
69
122
|
// Safe properties that users can override
|
|
70
|
-
const SAFE_AGENT_PROPERTIES = ["model", "temperature", "maxTokens"] as const;
|
|
123
|
+
const SAFE_AGENT_PROPERTIES = ["model", "temperature", "maxTokens", "thinking"] as const;
|
|
71
124
|
|
|
72
125
|
// Built-in OpenCode models that don't require validation (always available)
|
|
73
126
|
const BUILTIN_MODELS = new Set(["opencode/big-pickle"]);
|
|
@@ -76,6 +129,10 @@ export interface AgentOverride {
|
|
|
76
129
|
model?: string;
|
|
77
130
|
temperature?: number;
|
|
78
131
|
maxTokens?: number;
|
|
132
|
+
thinking?: {
|
|
133
|
+
type: string;
|
|
134
|
+
budgetTokens: number;
|
|
135
|
+
};
|
|
79
136
|
}
|
|
80
137
|
|
|
81
138
|
export interface MicodeFeatures {
|
|
@@ -90,17 +147,18 @@ export interface MicodeConfig {
|
|
|
90
147
|
}
|
|
91
148
|
|
|
92
149
|
/**
|
|
93
|
-
* Load micode.json from ~/.config/opencode/
|
|
94
|
-
* Returns null if file doesn't exist or is invalid
|
|
150
|
+
* Load micode.json/micode.jsonc from ~/.config/opencode/
|
|
151
|
+
* Returns null if file doesn't exist or is invalid
|
|
95
152
|
* @param configDir - Optional override for config directory (for testing)
|
|
96
153
|
*/
|
|
97
154
|
export async function loadMicodeConfig(configDir?: string): Promise<MicodeConfig | null> {
|
|
98
155
|
const baseDir = configDir ?? join(homedir(), ".config", "opencode");
|
|
99
|
-
const configPath = join(baseDir, "micode.json");
|
|
100
156
|
|
|
101
157
|
try {
|
|
102
|
-
const content = await
|
|
103
|
-
|
|
158
|
+
const content = await readConfigFileAsync(baseDir, "micode");
|
|
159
|
+
if (!content) return null;
|
|
160
|
+
|
|
161
|
+
const parsed = parseConfigJson(content) as Record<string, unknown>;
|
|
104
162
|
|
|
105
163
|
const result: MicodeConfig = {};
|
|
106
164
|
|
|
@@ -166,7 +224,7 @@ export async function loadMicodeConfig(configDir?: string): Promise<MicodeConfig
|
|
|
166
224
|
}
|
|
167
225
|
|
|
168
226
|
/**
|
|
169
|
-
* Load model context limits from opencode.json
|
|
227
|
+
* Load model context limits from opencode.json/opencode.jsonc
|
|
170
228
|
* Returns a Map of "provider/model" -> context limit (tokens)
|
|
171
229
|
*/
|
|
172
230
|
export function loadModelContextLimits(configDir?: string): Map<string, number> {
|
|
@@ -174,9 +232,11 @@ export function loadModelContextLimits(configDir?: string): Map<string, number>
|
|
|
174
232
|
const baseDir = configDir ?? join(homedir(), ".config", "opencode");
|
|
175
233
|
|
|
176
234
|
try {
|
|
177
|
-
const configPath =
|
|
235
|
+
const configPath = resolveConfigFileSync(baseDir, "opencode");
|
|
236
|
+
if (!configPath) return limits;
|
|
237
|
+
|
|
178
238
|
const content = readFileSync(configPath, "utf-8");
|
|
179
|
-
const config =
|
|
239
|
+
const config = parseConfigJson(content) as {
|
|
180
240
|
provider?: Record<string, { models?: Record<string, { limit?: { context?: number } }> }>;
|
|
181
241
|
};
|
|
182
242
|
|
|
@@ -207,7 +267,7 @@ export function loadModelContextLimits(configDir?: string): Map<string, number>
|
|
|
207
267
|
* Model resolution priority:
|
|
208
268
|
* 1. Per-agent override in micode.json (highest)
|
|
209
269
|
* 2. Default model from opencode.json "model" field
|
|
210
|
-
* 3.
|
|
270
|
+
* 3. DEFAULT_MODEL from config (plugin fallback)
|
|
211
271
|
*/
|
|
212
272
|
export function mergeAgentConfigs(
|
|
213
273
|
pluginAgents: Record<string, AgentConfig>,
|
|
@@ -247,8 +307,9 @@ export function mergeAgentConfigs(
|
|
|
247
307
|
finalConfig = { ...finalConfig, ...userOverride };
|
|
248
308
|
} else {
|
|
249
309
|
// Model is invalid - log warning and apply other overrides only
|
|
310
|
+
const fallbackModel = finalConfig.model || "DEFAULT_MODEL";
|
|
250
311
|
console.warn(
|
|
251
|
-
`[micode] Model "${userOverride.model}" for agent "${name}" is not available. Using
|
|
312
|
+
`[micode] Model "${userOverride.model}" for agent "${name}" is not available. Using ${fallbackModel}.`,
|
|
252
313
|
);
|
|
253
314
|
const { model: _ignored, ...safeOverrides } = userOverride;
|
|
254
315
|
finalConfig = { ...finalConfig, ...safeOverrides };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// src/hooks/fetch-tracker.ts
|
|
2
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
3
|
+
|
|
4
|
+
import { config } from "../utils/config";
|
|
5
|
+
import { log } from "../utils/logger";
|
|
6
|
+
|
|
7
|
+
// --- Tracked tools ---
|
|
8
|
+
|
|
9
|
+
export const FETCH_TOOLS = new Set(["webfetch", "context7_query-docs", "context7_resolve-library-id", "btca_ask"]);
|
|
10
|
+
|
|
11
|
+
// --- LRU Cache (same pattern as mindmodel-injector.ts) ---
|
|
12
|
+
|
|
13
|
+
interface CacheEntry {
|
|
14
|
+
content: string;
|
|
15
|
+
timestamp: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class LRUCache<V> {
|
|
19
|
+
private cache = new Map<string, V>();
|
|
20
|
+
constructor(private maxSize: number) {}
|
|
21
|
+
|
|
22
|
+
get(key: string): V | undefined {
|
|
23
|
+
const value = this.cache.get(key);
|
|
24
|
+
if (value !== undefined) {
|
|
25
|
+
// Move to end (most recently used)
|
|
26
|
+
this.cache.delete(key);
|
|
27
|
+
this.cache.set(key, value);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
set(key: string, value: V): void {
|
|
33
|
+
if (this.cache.has(key)) {
|
|
34
|
+
this.cache.delete(key);
|
|
35
|
+
} else if (this.cache.size >= this.maxSize) {
|
|
36
|
+
// Delete oldest (first) entry
|
|
37
|
+
const firstKey = this.cache.keys().next().value;
|
|
38
|
+
if (firstKey !== undefined) this.cache.delete(firstKey);
|
|
39
|
+
}
|
|
40
|
+
this.cache.set(key, value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
delete(key: string): void {
|
|
44
|
+
this.cache.delete(key);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
clear(): void {
|
|
48
|
+
this.cache.clear();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- Per-session state ---
|
|
53
|
+
|
|
54
|
+
// Call counts: sessionID -> (normalizedKey -> count)
|
|
55
|
+
const sessionCallCounts = new Map<string, Map<string, number>>();
|
|
56
|
+
|
|
57
|
+
// Cache: sessionID -> LRUCache of fetch results
|
|
58
|
+
const sessionCaches = new Map<string, LRUCache<CacheEntry>>();
|
|
59
|
+
|
|
60
|
+
// --- Key normalization ---
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Normalize a tool call into a cache/tracking key.
|
|
64
|
+
* Returns null if the tool is not tracked or args are missing/malformed.
|
|
65
|
+
*/
|
|
66
|
+
export function normalizeKey(tool: string, args: Record<string, unknown> | undefined): string | null {
|
|
67
|
+
if (!FETCH_TOOLS.has(tool) || !args) return null;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
switch (tool) {
|
|
71
|
+
case "webfetch": {
|
|
72
|
+
const rawUrl = args.url as string | undefined;
|
|
73
|
+
if (!rawUrl) return null;
|
|
74
|
+
try {
|
|
75
|
+
const parsed = new URL(rawUrl);
|
|
76
|
+
// Sort query params for consistent keys
|
|
77
|
+
parsed.searchParams.sort();
|
|
78
|
+
return `webfetch|${parsed.toString()}`;
|
|
79
|
+
} catch {
|
|
80
|
+
// Malformed URL — use raw string as fallback
|
|
81
|
+
return `webfetch|${rawUrl}`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
case "context7_query-docs": {
|
|
85
|
+
const libraryId = args.libraryId as string | undefined;
|
|
86
|
+
const query = args.query as string | undefined;
|
|
87
|
+
if (!libraryId || !query) return null;
|
|
88
|
+
return `context7_query-docs|${libraryId}|${query}`;
|
|
89
|
+
}
|
|
90
|
+
case "context7_resolve-library-id": {
|
|
91
|
+
const libraryName = args.libraryName as string | undefined;
|
|
92
|
+
const query = args.query as string | undefined;
|
|
93
|
+
if (!libraryName || !query) return null;
|
|
94
|
+
return `context7_resolve-library-id|${libraryName}|${query}`;
|
|
95
|
+
}
|
|
96
|
+
case "btca_ask": {
|
|
97
|
+
const tech = args.tech as string | undefined;
|
|
98
|
+
const question = args.question as string | undefined;
|
|
99
|
+
if (!tech || !question) return null;
|
|
100
|
+
return `btca_ask|${tech}|${question}`;
|
|
101
|
+
}
|
|
102
|
+
default:
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
log.warn("hooks.fetch-tracker", `Key normalization failed: ${error instanceof Error ? error.message : "unknown"}`);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- Public accessors (for testing and external use) ---
|
|
112
|
+
|
|
113
|
+
export function getCallCount(sessionID: string, normalizedKey: string): number {
|
|
114
|
+
return sessionCallCounts.get(sessionID)?.get(normalizedKey) ?? 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function getCacheEntry(sessionID: string, normalizedKey: string): CacheEntry | undefined {
|
|
118
|
+
return sessionCaches.get(sessionID)?.get(normalizedKey);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function clearSession(sessionID: string): void {
|
|
122
|
+
sessionCallCounts.delete(sessionID);
|
|
123
|
+
sessionCaches.delete(sessionID);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- Internal helpers ---
|
|
127
|
+
|
|
128
|
+
function getOrCreateCounts(sessionID: string): Map<string, number> {
|
|
129
|
+
let counts = sessionCallCounts.get(sessionID);
|
|
130
|
+
if (!counts) {
|
|
131
|
+
counts = new Map();
|
|
132
|
+
sessionCallCounts.set(sessionID, counts);
|
|
133
|
+
}
|
|
134
|
+
return counts;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function getOrCreateCache(sessionID: string): LRUCache<CacheEntry> {
|
|
138
|
+
let cache = sessionCaches.get(sessionID);
|
|
139
|
+
if (!cache) {
|
|
140
|
+
cache = new LRUCache<CacheEntry>(config.fetch.cacheMaxEntries);
|
|
141
|
+
sessionCaches.set(sessionID, cache);
|
|
142
|
+
}
|
|
143
|
+
return cache;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function incrementCount(sessionID: string, key: string): number {
|
|
147
|
+
const counts = getOrCreateCounts(sessionID);
|
|
148
|
+
const current = counts.get(key) ?? 0;
|
|
149
|
+
const next = current + 1;
|
|
150
|
+
counts.set(key, next);
|
|
151
|
+
return next;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isCacheExpired(entry: CacheEntry): boolean {
|
|
155
|
+
return Date.now() - entry.timestamp > config.fetch.cacheTtlMs;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- Hook factory ---
|
|
159
|
+
|
|
160
|
+
export function createFetchTrackerHook(_ctx: PluginInput) {
|
|
161
|
+
return {
|
|
162
|
+
/**
|
|
163
|
+
* After hook: track fetch calls, cache results, inject warnings/blocks.
|
|
164
|
+
*
|
|
165
|
+
* On first call: stores result in cache, increments count.
|
|
166
|
+
* On repeated calls: replaces output with cached content + warning.
|
|
167
|
+
* After maxCallsPerResource: replaces output with block message.
|
|
168
|
+
*
|
|
169
|
+
* Note: We use tool.execute.after (not before) because the plugin SDK's
|
|
170
|
+
* before hook only exposes { args } for modification, not { output }.
|
|
171
|
+
* The after hook can mutate output.output to replace the tool's result.
|
|
172
|
+
*/
|
|
173
|
+
"tool.execute.after": async (
|
|
174
|
+
input: { tool: string; sessionID: string; args?: Record<string, unknown> },
|
|
175
|
+
output: { output?: string },
|
|
176
|
+
) => {
|
|
177
|
+
try {
|
|
178
|
+
if (!FETCH_TOOLS.has(input.tool)) return;
|
|
179
|
+
|
|
180
|
+
const key = normalizeKey(input.tool, input.args);
|
|
181
|
+
if (!key) return;
|
|
182
|
+
|
|
183
|
+
// Increment call count
|
|
184
|
+
const count = incrementCount(input.sessionID, key);
|
|
185
|
+
|
|
186
|
+
// Hard block: exceeded max calls — unconditional, independent of cache state
|
|
187
|
+
if (count > config.fetch.maxCallsPerResource) {
|
|
188
|
+
output.output = `<fetch-blocked>This resource has been fetched ${count} times this session. The content is already available in the conversation above. Use the information already available instead of re-fetching.</fetch-blocked>`;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Check for cached content from a previous call
|
|
193
|
+
const cache = getOrCreateCache(input.sessionID);
|
|
194
|
+
const cached = cache.get(key);
|
|
195
|
+
|
|
196
|
+
if (count > 1 && cached && !isCacheExpired(cached)) {
|
|
197
|
+
// Repeated call with valid cache — replace output with cached content
|
|
198
|
+
let cachedOutput = `<from-cache>Returning cached result (fetched ${count} time${count !== 1 ? "s" : ""} previously).</from-cache>\n\n${cached.content}`;
|
|
199
|
+
|
|
200
|
+
// Add warning if at or above warn threshold
|
|
201
|
+
if (count >= config.fetch.warnThreshold) {
|
|
202
|
+
cachedOutput += `\n\n<fetch-warning>You have fetched this resource ${count} times. The content is cached and identical. Consider using the information you already have instead of re-fetching.</fetch-warning>`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
output.output = cachedOutput;
|
|
206
|
+
} else {
|
|
207
|
+
// First call or cache expired — store fresh result
|
|
208
|
+
if (output.output) {
|
|
209
|
+
cache.set(key, { content: output.output, timestamp: Date.now() });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} catch (error) {
|
|
213
|
+
log.warn("hooks.fetch-tracker", `After hook error: ${error instanceof Error ? error.message : "unknown"}`);
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Event handler: clean up on session deletion.
|
|
219
|
+
* Same pattern as file-ops-tracker.
|
|
220
|
+
*/
|
|
221
|
+
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
|
222
|
+
if (event.type === "session.deleted") {
|
|
223
|
+
const props = event.properties as { info?: { id?: string } } | undefined;
|
|
224
|
+
if (props?.info?.id) {
|
|
225
|
+
clearSession(props.info.id);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
/** Direct cleanup function (used by index.ts for explicit cleanup) */
|
|
231
|
+
cleanupSession: clearSession,
|
|
232
|
+
};
|
|
233
|
+
}
|