context-goblin 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Volodymyr Melnychuk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # Context Goblin
2
+
3
+ An OpenCode plugin that hoards tiny project-context notes so agents read less and waste fewer tokens.
4
+
5
+ > Feed your agent crumbs, not the whole repo.
6
+
7
+ ## What it does
8
+
9
+ Context Goblin creates and maintains a compact project context cache for OpenCode agents.
10
+
11
+ Instead of repeatedly scanning the whole repository, the agent can start from:
12
+
13
+ ```txt
14
+ .opencode/cache/context-goblin/project-context.md
15
+ ```
16
+
17
+ The cache is designed to summarize:
18
+
19
+ * detected stack
20
+ * package manager
21
+ * important commands
22
+ * directory structure
23
+ * entry points
24
+ * relevant config files
25
+ * git state
26
+ * safety exclusions
27
+ * agent instructions
28
+
29
+ ## What it does not do
30
+
31
+ Context Goblin does not directly control model-provider prompt caching.
32
+
33
+ Provider-side prompt caching is handled by the model provider. This plugin focuses on practical repository-context discipline: read the project cache first, then inspect only the files required for the current task.
34
+
35
+ ## Planned OpenCode usage
36
+
37
+ After installation, users will be able to add the plugin to `opencode.json`:
38
+
39
+ ```json
40
+ {
41
+ "$schema": "https://opencode.ai/config.json",
42
+ "plugin": ["context-goblin"]
43
+ }
44
+ ```
45
+
46
+ ## Tools
47
+
48
+ Context Goblin exposes these OpenCode tools:
49
+
50
+ ```txt
51
+ context_goblin_read
52
+ context_goblin_refresh
53
+ context_goblin_status
54
+ ```
55
+
56
+ ## Cache location
57
+
58
+ ```txt
59
+ .opencode/cache/context-goblin/project-context.md
60
+ .opencode/cache/context-goblin/project-context.state.json
61
+ ```
62
+
63
+ ## Does it actually save context?
64
+
65
+ Context Goblin includes a benchmark fixture that compares two OpenCode runs:
66
+
67
+ ```txt
68
+ Baseline:
69
+ OpenCode analyzes the project without Context Goblin.
70
+
71
+ Context Goblin:
72
+ OpenCode starts from `.opencode/cache/context-goblin/project-context.md`.
73
+ ```
74
+
75
+ The benchmark reports:
76
+
77
+ ```txt
78
+ - unique files read
79
+ - tool calls
80
+ - cache size
81
+ - secret leakage check
82
+ - optional token/cost stats when available
83
+ ```
84
+
85
+ The goal is not to fake exact token savings. The goal is to prove that the agent reads fewer files and starts from a compact cache.
86
+
87
+ Run:
88
+
89
+ ```bash
90
+ npm run benchmark
91
+ ```
92
+
93
+ Output:
94
+
95
+ ```txt
96
+ benchmark-results/context-goblin-benchmark.md
97
+ ```
98
+
99
+ ## Safety model
100
+
101
+ Context Goblin should never cache secrets or generated dependency files.
102
+
103
+ Default exclusions include:
104
+
105
+ ```txt
106
+ .env
107
+ .env.*
108
+ *.pem
109
+ *.key
110
+ node_modules/**
111
+ .git/**
112
+ dist/**
113
+ build/**
114
+ coverage/**
115
+ .next/**
116
+ .nuxt/**
117
+ .output/**
118
+ ```
119
+
120
+ ## Development status
121
+
122
+ This project is currently in early MVP development.
123
+
124
+ ## License
125
+
126
+ MIT
@@ -0,0 +1,2 @@
1
+ import type { CacheStatus } from "./types.js";
2
+ export declare function cacheStatus(rootDir: string): Promise<CacheStatus>;
@@ -0,0 +1,45 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { CACHE_MARKDOWN, CACHE_STATE } from "./constants.js";
4
+ import { hashProjectState } from "./hashProjectState.js";
5
+ async function exists(filePath) {
6
+ try {
7
+ await fs.access(filePath);
8
+ return true;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ export async function cacheStatus(rootDir) {
15
+ const cachePath = path.join(rootDir, CACHE_MARKDOWN);
16
+ const statePath = path.join(rootDir, CACHE_STATE);
17
+ const projectHash = await hashProjectState(rootDir);
18
+ const cacheExists = await exists(cachePath);
19
+ const stateExists = await exists(statePath);
20
+ if (!cacheExists && !stateExists) {
21
+ return { exists: false, stale: true, reason: "missing cache", cachePath, statePath, projectHash: projectHash.hash };
22
+ }
23
+ if (!cacheExists) {
24
+ return { exists: false, stale: true, reason: "cache markdown missing", cachePath, statePath, projectHash: projectHash.hash };
25
+ }
26
+ if (!stateExists) {
27
+ return { exists: true, stale: true, reason: "state JSON missing", cachePath, statePath, projectHash: projectHash.hash };
28
+ }
29
+ try {
30
+ const state = JSON.parse(await fs.readFile(statePath, "utf8"));
31
+ const stale = state.projectHash !== projectHash.hash;
32
+ return {
33
+ exists: true,
34
+ stale,
35
+ reason: stale ? "project hash changed" : "fresh",
36
+ cachePath,
37
+ statePath,
38
+ projectHash: projectHash.hash,
39
+ state,
40
+ };
41
+ }
42
+ catch {
43
+ return { exists: true, stale: true, reason: "state JSON invalid", cachePath, statePath, projectHash: projectHash.hash };
44
+ }
45
+ }
@@ -0,0 +1,7 @@
1
+ export declare const CACHE_VERSION = "0.1.0";
2
+ export declare const CACHE_DIR = ".opencode/cache/context-goblin";
3
+ export declare const CACHE_MARKDOWN = ".opencode/cache/context-goblin/project-context.md";
4
+ export declare const CACHE_STATE = ".opencode/cache/context-goblin/project-context.state.json";
5
+ export declare const DEFAULT_MAX_CACHE_KB = 25;
6
+ export declare const HASH_RELEVANT_FILES: string[];
7
+ export declare const SAFETY_EXCLUSIONS: string[];
@@ -0,0 +1,39 @@
1
+ export const CACHE_VERSION = "0.1.0";
2
+ export const CACHE_DIR = ".opencode/cache/context-goblin";
3
+ export const CACHE_MARKDOWN = `${CACHE_DIR}/project-context.md`;
4
+ export const CACHE_STATE = `${CACHE_DIR}/project-context.state.json`;
5
+ export const DEFAULT_MAX_CACHE_KB = 25;
6
+ export const HASH_RELEVANT_FILES = [
7
+ "package.json",
8
+ "pnpm-lock.yaml",
9
+ "package-lock.json",
10
+ "yarn.lock",
11
+ "bun.lockb",
12
+ "tsconfig.json",
13
+ "jsconfig.json",
14
+ "vite.config.ts",
15
+ "vite.config.js",
16
+ "next.config.js",
17
+ "next.config.mjs",
18
+ "opencode.json",
19
+ "opencode.jsonc",
20
+ "AGENTS.md",
21
+ "README.md",
22
+ ];
23
+ export const SAFETY_EXCLUSIONS = [
24
+ ".env",
25
+ ".env.*",
26
+ "*.pem",
27
+ "*.key",
28
+ "secrets.json",
29
+ "credentials.json",
30
+ "node_modules/**",
31
+ ".git/**",
32
+ "dist/**",
33
+ "build/**",
34
+ "coverage/**",
35
+ ".next/**",
36
+ ".nuxt/**",
37
+ ".output/**",
38
+ `${CACHE_DIR}/**`,
39
+ ];
@@ -0,0 +1,2 @@
1
+ import type { DetectedStack } from "./types.js";
2
+ export declare function detectStack(rootDir: string): Promise<DetectedStack>;
@@ -0,0 +1,64 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ async function exists(filePath) {
4
+ try {
5
+ await fs.access(filePath);
6
+ return true;
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
12
+ async function readJson(filePath) {
13
+ try {
14
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
15
+ }
16
+ catch {
17
+ return {};
18
+ }
19
+ }
20
+ export async function detectStack(rootDir) {
21
+ const packageJsonPath = path.join(rootDir, "package.json");
22
+ const packageJson = await readJson(packageJsonPath);
23
+ const dependencies = {
24
+ ...(packageJson.dependencies ?? {}),
25
+ ...(packageJson.devDependencies ?? {}),
26
+ };
27
+ const scripts = packageJson.scripts ?? {};
28
+ const frameworks = [];
29
+ const languages = [];
30
+ const entryPoints = [];
31
+ const notes = [];
32
+ let packageManager = "[NEEDS INPUT]";
33
+ if (await exists(path.join(rootDir, "pnpm-lock.yaml")))
34
+ packageManager = "pnpm";
35
+ else if (await exists(path.join(rootDir, "package-lock.json")))
36
+ packageManager = "npm";
37
+ else if (await exists(path.join(rootDir, "yarn.lock")))
38
+ packageManager = "yarn";
39
+ else if (await exists(path.join(rootDir, "bun.lockb")))
40
+ packageManager = "bun";
41
+ if (await exists(path.join(rootDir, "tsconfig.json")))
42
+ languages.push("TypeScript");
43
+ if (await exists(path.join(rootDir, "jsconfig.json")) || languages.length === 0)
44
+ languages.push("JavaScript");
45
+ if (dependencies.next || (await exists(path.join(rootDir, "next.config.js"))) || (await exists(path.join(rootDir, "next.config.mjs"))))
46
+ frameworks.push("Next.js");
47
+ if (dependencies.react)
48
+ frameworks.push("React");
49
+ if (dependencies.vite || (await exists(path.join(rootDir, "vite.config.ts"))) || (await exists(path.join(rootDir, "vite.config.js"))))
50
+ frameworks.push("Vite");
51
+ if (dependencies.express)
52
+ frameworks.push("Express");
53
+ if (Object.keys(packageJson).length > 0 && !frameworks.includes("Node.js"))
54
+ frameworks.push("Node.js");
55
+ for (const candidate of ["src/index.ts", "src/index.js", "src/main.tsx", "src/main.ts", "app/page.tsx", "pages/index.tsx"]) {
56
+ if (await exists(path.join(rootDir, candidate)))
57
+ entryPoints.push(candidate);
58
+ }
59
+ if (await exists(path.join(rootDir, "app")))
60
+ notes.push("Uses app directory");
61
+ if (Object.keys(scripts).length === 0)
62
+ notes.push("scripts: [NEEDS INPUT]");
63
+ return { packageManager, languages, frameworks, scripts, entryPoints, notes };
64
+ }
@@ -0,0 +1,2 @@
1
+ import type { ContextGoblinOptions, ProjectState } from "./types.js";
2
+ export declare function generateProjectContext(options: ContextGoblinOptions): Promise<ProjectState>;
@@ -0,0 +1,103 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { CACHE_MARKDOWN, CACHE_STATE, CACHE_VERSION, DEFAULT_MAX_CACHE_KB, SAFETY_EXCLUSIONS } from "./constants.js";
4
+ import { detectStack } from "./detectStack.js";
5
+ import { hashProjectState } from "./hashProjectState.js";
6
+ import { isDeniedPath, redactSecrets } from "./security.js";
7
+ import { truncateMarkdown } from "./truncateMarkdown.js";
8
+ async function listDirectoryMap(rootDir, dir = ".", depth = 0) {
9
+ if (depth > 2)
10
+ return [];
11
+ const absoluteDir = path.join(rootDir, dir);
12
+ let entries = [];
13
+ try {
14
+ const dirents = await fs.readdir(absoluteDir, { withFileTypes: true });
15
+ for (const dirent of dirents.sort((a, b) => a.name.localeCompare(b.name))) {
16
+ const relativePath = dir === "." ? dirent.name : `${dir}/${dirent.name}`;
17
+ if (isDeniedPath(relativePath))
18
+ continue;
19
+ entries.push(`${" ".repeat(depth)}- ${dirent.name}${dirent.isDirectory() ? "/" : ""}`);
20
+ if (dirent.isDirectory())
21
+ entries = entries.concat(await listDirectoryMap(rootDir, relativePath, depth + 1));
22
+ if (entries.length >= 120) {
23
+ entries.push(`${" ".repeat(depth)}- ...`);
24
+ return entries;
25
+ }
26
+ }
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ return entries;
32
+ }
33
+ async function readTextIfAllowed(rootDir, relativePath) {
34
+ if (isDeniedPath(relativePath))
35
+ return undefined;
36
+ try {
37
+ return redactSecrets(await fs.readFile(path.join(rootDir, relativePath), "utf8"));
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ function formatScripts(scripts) {
44
+ const entries = Object.entries(scripts);
45
+ if (entries.length === 0)
46
+ return "- [NEEDS INPUT] No package scripts detected.";
47
+ return entries.map(([name, command]) => `- ${name}: \`${command}\``).join("\n");
48
+ }
49
+ export async function generateProjectContext(options) {
50
+ const rootDir = options.rootDir;
51
+ const maxCacheKb = options.maxCacheKb ?? DEFAULT_MAX_CACHE_KB;
52
+ const stack = await detectStack(rootDir);
53
+ const projectHash = await hashProjectState(rootDir);
54
+ const directoryMap = await listDirectoryMap(rootDir);
55
+ const agents = await readTextIfAllowed(rootDir, "AGENTS.md");
56
+ const markdown = truncateMarkdown(redactSecrets(`# Context Goblin Project Cache
57
+
58
+ Generated: ${new Date().toISOString()}
59
+ Version: ${CACHE_VERSION}
60
+
61
+ ## Detected Stack
62
+
63
+ - Package manager: ${stack.packageManager}
64
+ - Languages: ${stack.languages.length ? stack.languages.join(", ") : "[NEEDS INPUT]"}
65
+ - Frameworks: ${stack.frameworks.length ? stack.frameworks.join(", ") : "[NEEDS INPUT]"}
66
+ - Entry points: ${stack.entryPoints.length ? stack.entryPoints.join(", ") : "[NEEDS INPUT]"}
67
+ - Notes: ${stack.notes.length ? stack.notes.join("; ") : "none"}
68
+
69
+ ## Important Commands
70
+
71
+ ${formatScripts(stack.scripts)}
72
+
73
+ ## Directory Map
74
+
75
+ ${directoryMap.length ? directoryMap.join("\n") : "- [NEEDS INPUT] No readable project files detected."}
76
+
77
+ ## Safety Exclusions
78
+
79
+ ${SAFETY_EXCLUSIONS.map((item) => `- ${item}`).join("\n")}
80
+
81
+ Denied paths are summarized only. Their contents are not read into this cache.
82
+
83
+ ## Agent Instructions
84
+
85
+ Before scanning broad repository files:
86
+
87
+ 1. Read this cache.
88
+ 2. Inspect only the smallest file set needed for the task.
89
+ 3. Never read denied paths or secret-looking files.
90
+
91
+ ${agents ? `### Existing AGENTS.md\n\n${agents}` : "No AGENTS.md found."}
92
+ `), maxCacheKb);
93
+ await fs.mkdir(path.join(rootDir, path.dirname(CACHE_MARKDOWN)), { recursive: true });
94
+ await fs.writeFile(path.join(rootDir, CACHE_MARKDOWN), markdown);
95
+ const state = {
96
+ version: CACHE_VERSION,
97
+ generatedAt: new Date().toISOString(),
98
+ projectHash: projectHash.hash,
99
+ trackedFiles: projectHash.trackedFiles,
100
+ };
101
+ await fs.writeFile(path.join(rootDir, CACHE_STATE), `${JSON.stringify(state, null, 2)}\n`);
102
+ return state;
103
+ }
@@ -0,0 +1,2 @@
1
+ import type { HashResult } from "./types.js";
2
+ export declare function hashProjectState(rootDir: string): Promise<HashResult>;
@@ -0,0 +1,42 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import { HASH_RELEVANT_FILES } from "./constants.js";
7
+ const execFileAsync = promisify(execFile);
8
+ async function readIfExists(rootDir, relativePath) {
9
+ try {
10
+ return await fs.readFile(path.join(rootDir, relativePath), "utf8");
11
+ }
12
+ catch {
13
+ return undefined;
14
+ }
15
+ }
16
+ async function gitState(rootDir) {
17
+ try {
18
+ const branch = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: rootDir });
19
+ const head = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: rootDir });
20
+ return `branch:${branch.stdout.trim()}\nhead:${head.stdout.trim()}`;
21
+ }
22
+ catch {
23
+ return "git:[unavailable]";
24
+ }
25
+ }
26
+ export async function hashProjectState(rootDir) {
27
+ const hash = crypto.createHash("sha256");
28
+ const trackedFiles = [];
29
+ for (const relativePath of HASH_RELEVANT_FILES) {
30
+ const content = await readIfExists(rootDir, relativePath);
31
+ if (content === undefined)
32
+ continue;
33
+ trackedFiles.push(relativePath);
34
+ hash.update(`file:${relativePath}\n`);
35
+ hash.update(content);
36
+ hash.update("\n");
37
+ }
38
+ const git = await gitState(rootDir);
39
+ trackedFiles.push("[git-state]");
40
+ hash.update(git);
41
+ return { hash: hash.digest("hex"), trackedFiles };
42
+ }
@@ -0,0 +1,9 @@
1
+ import { type Plugin } from "@opencode-ai/plugin";
2
+ export { cacheStatus } from "./cacheStatus.js";
3
+ export { detectStack } from "./detectStack.js";
4
+ export { generateProjectContext } from "./generateProjectContext.js";
5
+ export { hashProjectState } from "./hashProjectState.js";
6
+ export { isDeniedPath, redactSecrets } from "./security.js";
7
+ export { truncateMarkdown } from "./truncateMarkdown.js";
8
+ export declare const ContextGoblin: Plugin;
9
+ export default ContextGoblin;
@@ -0,0 +1,42 @@
1
+ import fs from "node:fs/promises";
2
+ import { tool } from "@opencode-ai/plugin";
3
+ import { cacheStatus } from "./cacheStatus.js";
4
+ import { CACHE_MARKDOWN } from "./constants.js";
5
+ import { generateProjectContext } from "./generateProjectContext.js";
6
+ export { cacheStatus } from "./cacheStatus.js";
7
+ export { detectStack } from "./detectStack.js";
8
+ export { generateProjectContext } from "./generateProjectContext.js";
9
+ export { hashProjectState } from "./hashProjectState.js";
10
+ export { isDeniedPath, redactSecrets } from "./security.js";
11
+ export { truncateMarkdown } from "./truncateMarkdown.js";
12
+ export const ContextGoblin = async () => {
13
+ return {
14
+ tool: {
15
+ context_goblin_status: tool({
16
+ description: "Check whether the Context Goblin project cache exists and is fresh.",
17
+ args: {},
18
+ async execute(_args, context) {
19
+ return JSON.stringify(await cacheStatus(context.worktree), null, 2);
20
+ },
21
+ }),
22
+ context_goblin_refresh: tool({
23
+ description: "Regenerate the Context Goblin project cache safely.",
24
+ args: {
25
+ maxCacheKb: tool.schema.number().optional(),
26
+ },
27
+ async execute(args, context) {
28
+ const state = await generateProjectContext({ rootDir: context.worktree, maxCacheKb: args.maxCacheKb });
29
+ return JSON.stringify({ ok: true, state }, null, 2);
30
+ },
31
+ }),
32
+ context_goblin_read: tool({
33
+ description: "Read the compact Context Goblin project cache.",
34
+ args: {},
35
+ async execute(_args, context) {
36
+ return await fs.readFile(`${context.worktree}/${CACHE_MARKDOWN}`, "utf8");
37
+ },
38
+ }),
39
+ },
40
+ };
41
+ };
42
+ export default ContextGoblin;
@@ -0,0 +1,3 @@
1
+ export declare function normalizeRelativePath(filePath: string): string;
2
+ export declare function isDeniedPath(filePath: string): boolean;
3
+ export declare function redactSecrets(input: string): string;
@@ -0,0 +1,35 @@
1
+ import path from "node:path";
2
+ import { CACHE_DIR } from "./constants.js";
3
+ const deniedExact = new Set([".env", "private.key", "secrets.json", "credentials.json"]);
4
+ const deniedDirs = new Set([
5
+ "node_modules",
6
+ "dist",
7
+ "build",
8
+ "coverage",
9
+ ".git",
10
+ ".next",
11
+ ".nuxt",
12
+ ".output",
13
+ ]);
14
+ export function normalizeRelativePath(filePath) {
15
+ return filePath.split(path.sep).join("/").replace(/^\.\//, "");
16
+ }
17
+ export function isDeniedPath(filePath) {
18
+ const normalized = normalizeRelativePath(filePath);
19
+ const basename = path.posix.basename(normalized);
20
+ const segments = normalized.split("/");
21
+ if (deniedExact.has(normalized) || deniedExact.has(basename))
22
+ return true;
23
+ if (basename.startsWith(".env"))
24
+ return true;
25
+ if (basename.endsWith(".key") || basename.endsWith(".pem"))
26
+ return true;
27
+ if (segments.some((segment) => deniedDirs.has(segment)))
28
+ return true;
29
+ if (normalized === CACHE_DIR || normalized.startsWith(`${CACHE_DIR}/`))
30
+ return true;
31
+ return false;
32
+ }
33
+ export function redactSecrets(input) {
34
+ return input.replace(/^([\w.-]*(?:API_KEY|TOKEN|SECRET|PASSWORD|PRIVATE_KEY)[\w.-]*\s*=\s*)(.+)$/gim, "$1[REDACTED]");
35
+ }
@@ -0,0 +1,2 @@
1
+ export declare function byteSize(input: string): number;
2
+ export declare function truncateMarkdown(markdown: string, maxCacheKb: number): string;
@@ -0,0 +1,40 @@
1
+ import { Buffer } from "node:buffer";
2
+ export function byteSize(input) {
3
+ return Buffer.byteLength(input, "utf8");
4
+ }
5
+ export function truncateMarkdown(markdown, maxCacheKb) {
6
+ const maxBytes = maxCacheKb * 1024;
7
+ if (byteSize(markdown) <= maxBytes)
8
+ return markdown;
9
+ const lines = markdown.split("\n");
10
+ const criticalHeadings = new Set([
11
+ "# Context Goblin Project Cache",
12
+ "## Detected Stack",
13
+ "## Important Commands",
14
+ "## Directory Map",
15
+ "## Safety Exclusions",
16
+ "## Agent Instructions",
17
+ ]);
18
+ const kept = [];
19
+ let currentHeading = "";
20
+ let sectionLineCount = 0;
21
+ for (const line of lines) {
22
+ if (line.startsWith("#")) {
23
+ currentHeading = line;
24
+ sectionLineCount = 0;
25
+ }
26
+ sectionLineCount += 1;
27
+ if (criticalHeadings.has(currentHeading) || sectionLineCount <= 20)
28
+ kept.push(line);
29
+ }
30
+ let output = `${kept.join("\n")}\n\n[TRUNCATED]\n`;
31
+ while (byteSize(output) > maxBytes && kept.length > 8) {
32
+ kept.splice(Math.max(1, kept.length - 4), 1);
33
+ output = `${kept.join("\n")}\n\n[TRUNCATED]\n`;
34
+ }
35
+ if (byteSize(output) > maxBytes) {
36
+ const marker = "\n\n[TRUNCATED]\n";
37
+ output = output.slice(0, Math.max(0, maxBytes - byteSize(marker) - 4)) + marker;
38
+ }
39
+ return output;
40
+ }
@@ -0,0 +1,31 @@
1
+ export interface ContextGoblinOptions {
2
+ rootDir: string;
3
+ maxCacheKb?: number;
4
+ }
5
+ export interface DetectedStack {
6
+ packageManager: string | "[NEEDS INPUT]";
7
+ languages: string[];
8
+ frameworks: string[];
9
+ scripts: Record<string, string>;
10
+ entryPoints: string[];
11
+ notes: string[];
12
+ }
13
+ export interface ProjectState {
14
+ version: string;
15
+ generatedAt: string;
16
+ projectHash: string;
17
+ trackedFiles: string[];
18
+ }
19
+ export interface CacheStatus {
20
+ exists: boolean;
21
+ stale: boolean;
22
+ reason: string;
23
+ cachePath: string;
24
+ statePath: string;
25
+ projectHash: string;
26
+ state?: ProjectState;
27
+ }
28
+ export interface HashResult {
29
+ hash: string;
30
+ trackedFiles: string[];
31
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "context-goblin",
3
+ "version": "0.0.1",
4
+ "description": "OpenCode plugin that generates a compact, safe project context cache.",
5
+ "type": "module",
6
+ "main": "dist/src/index.js",
7
+ "types": "dist/src/index.d.ts",
8
+ "files": [
9
+ "dist/src",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc -p tsconfig.build.json",
15
+ "typecheck": "tsc --noEmit",
16
+ "test": "vitest run",
17
+ "test:watch": "vitest",
18
+ "smoke:opencode": "bash scripts/smoke-opencode.sh",
19
+ "benchmark": "tsx scripts/benchmark-context-goblin.ts",
20
+ "prepublishOnly": "npm run typecheck && npm run test && npm run build"
21
+ },
22
+ "keywords": [
23
+ "opencode",
24
+ "plugin",
25
+ "context"
26
+ ],
27
+ "author": "Volodymyr Melnychuk",
28
+ "license": "MIT",
29
+ "dependencies": {
30
+ "@opencode-ai/plugin": "latest"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "latest",
34
+ "tsx": "latest",
35
+ "typescript": "latest",
36
+ "vitest": "latest"
37
+ }
38
+ }