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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "micode",
3
- "version": "0.9.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(
@@ -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: 32000,
254
+ budgetTokens: 64000,
254
255
  },
255
256
  maxTokens: 64000,
256
257
  tools: {
@@ -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: "openai/gpt-5.2-codex" },
34
- brainstormer: { ...brainstormerAgent, model: "openai/gpt-5.2-codex" },
35
- bootstrapper: { ...bootstrapperAgent, model: "openai/gpt-5.2-codex" },
36
- "codebase-locator": { ...codebaseLocatorAgent, model: "openai/gpt-5.2-codex" },
37
- "codebase-analyzer": { ...codebaseAnalyzerAgent, model: "openai/gpt-5.2-codex" },
38
- "pattern-finder": { ...patternFinderAgent, model: "openai/gpt-5.2-codex" },
39
- planner: { ...plannerAgent, model: "openai/gpt-5.2-codex" },
40
- implementer: { ...implementerAgent, model: "openai/gpt-5.2-codex" },
41
- reviewer: { ...reviewerAgent, model: "openai/gpt-5.2-codex" },
42
- executor: { ...executorAgent, model: "openai/gpt-5.2-codex" },
43
- "ledger-creator": { ...ledgerCreatorAgent, model: "openai/gpt-5.2-codex" },
44
- "artifact-searcher": { ...artifactSearcherAgent, model: "openai/gpt-5.2-codex" },
45
- "project-initializer": { ...projectInitializerAgent, model: "openai/gpt-5.2-codex" },
46
- octto: { ...octtoAgent, model: "openai/gpt-5.2-codex" },
47
- probe: { ...probeAgent, model: "openai/gpt-5.2-codex" },
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: "openai/gpt-5.2-codex" },
50
- "mm-pattern-discoverer": { ...mindmodelPatternDiscovererAgent, model: "openai/gpt-5.2-codex" },
51
- "mm-example-extractor": { ...exampleExtractorAgent, model: "openai/gpt-5.2-codex" },
52
- "mm-orchestrator": { ...mindmodelOrchestratorAgent, model: "openai/gpt-5.2-codex" },
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: "openai/gpt-5.2-codex" },
55
- "mm-convention-extractor": { ...conventionExtractorAgent, model: "openai/gpt-5.2-codex" },
56
- "mm-domain-extractor": { ...domainExtractorAgent, model: "openai/gpt-5.2-codex" },
57
- "mm-code-clusterer": { ...codeClustererAgent, model: "openai/gpt-5.2-codex" },
58
- "mm-anti-pattern-detector": { ...antiPatternDetectorAgent, model: "openai/gpt-5.2-codex" },
59
- "mm-constraint-writer": { ...constraintWriterAgent, model: "openai/gpt-5.2-codex" },
60
- "mm-constraint-reviewer": { ...constraintReviewerAgent, model: "openai/gpt-5.2-codex" },
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
+ });
@@ -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
- * Load opencode.json config file (synchronous)
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 = join(baseDir, "opencode.json");
82
+ const configPath = resolveConfigFileSync(baseDir, "opencode");
83
+ if (!configPath) return null;
84
+
32
85
  const content = readFileSync(configPath, "utf-8");
33
- return JSON.parse(content) as OpencodeConfig;
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/micode.json
94
- * Returns null if file doesn't exist or is invalid JSON
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 readFile(configPath, "utf-8");
103
- const parsed = JSON.parse(content) as Record<string, unknown>;
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 = join(baseDir, "opencode.json");
235
+ const configPath = resolveConfigFileSync(baseDir, "opencode");
236
+ if (!configPath) return limits;
237
+
178
238
  const content = readFileSync(configPath, "utf-8");
179
- const config = JSON.parse(content) as {
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. Plugin default (hardcoded in agent definitions)
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 opencode default.`,
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
+ }