From 890f28655373e5bbd758cd4e5cf631895e7a7fa6 Mon Sep 17 00:00:00 2001 From: Arnab Date: Wed, 22 Apr 2026 08:43:39 -0700 Subject: [PATCH] feat: ctx-ignore setup + enforce in fs tools - Generated .contextignore / .claudeignore / .cursorignore via `ctx-ignore scan` (github.com/arniesaha/ctx-ignore) - Added `npm run ctx-ignore` script for regeneration - New src/tools/ignore.ts: walk-up loader + isIgnored/filterIgnored helpers, cached per root, with MAX_IGNORE_CONTEXT=false escape hatch - read_file returns an "(ignored)" stub instead of dumping huge files (node_modules, build artifacts, binaries) - list_files filters ignored entries and reports hidden count - Kept test files and package-lock.json OUT of the patched .gitignore (ctx-ignore's default patches those, but they belong in git) Part of the context-optimization series addressing yesterday's Claude subscription quota exhaustion. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claudeignore | 9 +++++ .contextignore | 26 +++++++++++++++ .cursorignore | 9 +++++ .gitignore | 4 +++ package-lock.json | 12 ++++++- package.json | 4 ++- src/tools/fs.ts | 19 +++++++++-- src/tools/ignore.ts | 78 ++++++++++++++++++++++++++++++++++++++++++++ tests/ignore.test.ts | 64 ++++++++++++++++++++++++++++++++++++ 9 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 .claudeignore create mode 100644 .contextignore create mode 100644 .cursorignore create mode 100644 src/tools/ignore.ts create mode 100644 tests/ignore.test.ts diff --git a/.claudeignore b/.claudeignore new file mode 100644 index 0000000..dafd794 --- /dev/null +++ b/.claudeignore @@ -0,0 +1,9 @@ +package-lock.json +node_modules/ +dist/ +*.png +data/ +logs/ +*.db +*.db-journal +.env diff --git a/.contextignore b/.contextignore new file mode 100644 index 0000000..57015d4 --- /dev/null +++ b/.contextignore @@ -0,0 +1,26 @@ +# ctx-ignore: generated by ctx-ignore scan +# Generated: 2026-04-22 +# Edit this file to customize what AI tools see in your repo +# .claudeignore and .cursorignore are derived from this file + +# Lockfiles — auto-generated, never edit manually +package-lock.json + +# Dependencies — never edit, always restorable +node_modules/ + +# Build artifacts — generated output +dist/ + +# Binary files — not readable by AI tools +*.png + +# Tests are intentionally NOT ignored — they document expected behavior +# and are often the fastest way for an AI to understand a module. + +# Agent-max specifics +data/ +logs/ +*.db +*.db-journal +.env diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..dafd794 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,9 @@ +package-lock.json +node_modules/ +dist/ +*.png +data/ +logs/ +*.db +*.db-journal +.env diff --git a/.gitignore b/.gitignore index 1164be1..6bd78c2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ logs/ .claude/ REQUIREMENTS.md TODO.md + +# ctx-ignore: added by ctx-ignore scan +# (lockfiles and tests intentionally excluded — they belong in git, ignored only for AI context) +*.png diff --git a/package-lock.json b/package-lock.json index 581c637..1c26122 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "dotenv": "latest", "express": "latest", "grammy": "latest", + "ignore": "^7.0.5", "ws": "^8.19.0" }, "bin": { @@ -56,7 +57,7 @@ }, "../agentweave/sdk/js": { "name": "agentweave-sdk", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { "@opentelemetry/exporter-trace-otlp-http": "^0.33.0", "@opentelemetry/sdk-node": "^0.41.0" @@ -5962,6 +5963,15 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", diff --git a/package.json b/package.json index a94a522..2138da5 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "build": "tsc", "start": "node dist/index.js", "dev": "nodemon --exec tsx src/index.ts", - "tui": "tsx src/tui.ts" + "tui": "tsx src/tui.ts", + "ctx-ignore": "ctx-ignore scan ." }, "bin": { "max-tui": "dist/tui.js" @@ -26,6 +27,7 @@ "dotenv": "latest", "express": "latest", "grammy": "latest", + "ignore": "^7.0.5", "ws": "^8.19.0" }, "devDependencies": { diff --git a/src/tools/fs.ts b/src/tools/fs.ts index a214f3a..a9aa4eb 100644 --- a/src/tools/fs.ts +++ b/src/tools/fs.ts @@ -2,6 +2,7 @@ import { Type } from "@mariozechner/pi-ai"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import { readFile, writeFile, readdir, stat, mkdir } from "fs/promises"; import path from "path"; +import { isIgnored, filterIgnored } from "./ignore.js"; const MAX_HOME = path.join(process.env.HOME!, "max"); @@ -21,6 +22,12 @@ export const readFileTool: AgentTool = { execute: async (_id, params: any) => { try { const resolved = resolvePath(params.path); + if (isIgnored(resolved)) { + return { + content: [{ type: "text", text: `(ignored by .contextignore — set MAX_IGNORE_CONTEXT=false to override)` }], + details: { path: resolved, ignored: true }, + }; + } const content = await readFile(resolved, "utf-8"); return { content: [{ type: "text", text: content }], details: { path: resolved, size: content.length } }; } catch (e: any) { @@ -60,8 +67,16 @@ export const listFilesTool: AgentTool = { try { const resolved = resolvePath(params.path || "."); const entries = await readdir(resolved, { withFileTypes: true }); - const lines = entries.map((e) => `${e.isDirectory() ? "d" : "f"} ${e.name}`).join("\n"); - return { content: [{ type: "text", text: lines || "(empty directory)" }], details: { path: resolved, count: entries.length } }; + const allNames = entries.map((e) => e.name); + const visibleNames = new Set(filterIgnored(resolved, allNames)); + const visible = entries.filter((e) => visibleNames.has(e.name)); + const hiddenCount = entries.length - visible.length; + const lines = visible.map((e) => `${e.isDirectory() ? "d" : "f"} ${e.name}`).join("\n"); + const suffix = hiddenCount > 0 ? `\n(${hiddenCount} entries hidden by .contextignore)` : ""; + return { + content: [{ type: "text", text: (lines || "(empty directory)") + suffix }], + details: { path: resolved, count: visible.length, hidden: hiddenCount }, + }; } catch (e: any) { return { content: [{ type: "text", text: `Error listing directory: ${e.message}` }], details: { error: e.message } }; } diff --git a/src/tools/ignore.ts b/src/tools/ignore.ts new file mode 100644 index 0000000..25b5f59 --- /dev/null +++ b/src/tools/ignore.ts @@ -0,0 +1,78 @@ +import { readFileSync, existsSync, statSync } from "fs"; +import path from "path"; +import ignore, { Ignore } from "ignore"; +import { log } from "../logger.js"; + +/** + * Loads .contextignore patterns and returns a predicate for whether a given + * absolute path should be hidden from the agent's own file tools. + * + * Lookup: walks up from the requested path looking for the nearest + * .contextignore, up to the repo root (a directory containing .git or + * package.json). Results are cached per-root. + * + * Disable globally with MAX_IGNORE_CONTEXT=false. + */ + +type CacheEntry = { root: string; ig: Ignore }; +const cache = new Map(); + +function findRoot(start: string): string | null { + let dir = start; + while (true) { + if (existsSync(path.join(dir, ".contextignore"))) return dir; + if (existsSync(path.join(dir, ".git")) || existsSync(path.join(dir, "package.json"))) return dir; + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +function loadFor(root: string): Ignore { + const cached = cache.get(root); + if (cached) return cached.ig; + const ig = ignore(); + const ciPath = path.join(root, ".contextignore"); + if (existsSync(ciPath)) { + try { + ig.add(readFileSync(ciPath, "utf-8")); + } catch (e: any) { + log("warn", `Failed to read ${ciPath}: ${e.message}`); + } + } + cache.set(root, { root, ig }); + return ig; +} + +export function isIgnored(absPath: string): boolean { + if (process.env.MAX_IGNORE_CONTEXT === "false") return false; + const startDir = path.dirname(path.resolve(absPath)); + const root = findRoot(startDir); + if (!root) return false; + const rel = path.relative(root, path.resolve(absPath)); + if (!rel || rel.startsWith("..")) return false; + const ig = loadFor(root); + return ig.ignores(rel); +} + +export function filterIgnored(baseDir: string, names: string[]): string[] { + if (process.env.MAX_IGNORE_CONTEXT === "false") return names; + const root = findRoot(path.resolve(baseDir)); + if (!root) return names; + const ig = loadFor(root); + return names.filter((n) => { + const abs = path.resolve(baseDir, n); + const rel = path.relative(root, abs); + if (!rel || rel.startsWith("..")) return true; + // Directory-ending patterns (e.g. `node_modules/`) only match paths with a + // trailing slash, so probe both forms when the entry is a directory. + let isDir = false; + try { isDir = statSync(abs).isDirectory(); } catch {} + if (isDir && ig.ignores(rel + "/")) return false; + return !ig.ignores(rel); + }); +} + +export function clearIgnoreCache(): void { + cache.clear(); +} diff --git a/tests/ignore.test.ts b/tests/ignore.test.ts new file mode 100644 index 0000000..4213004 --- /dev/null +++ b/tests/ignore.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach, afterEach } from "@jest/globals"; +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { isIgnored, filterIgnored, clearIgnoreCache } from "../src/tools/ignore.js"; + +describe("contextignore enforcement", () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(path.join(tmpdir(), "ctxignore-")); + // Stand up a fake repo root with a package.json anchor. + writeFileSync(path.join(root, "package.json"), "{}"); + writeFileSync( + path.join(root, ".contextignore"), + "node_modules/\n*.png\ndist/\n" + ); + mkdirSync(path.join(root, "node_modules", "foo"), { recursive: true }); + writeFileSync(path.join(root, "node_modules", "foo", "index.js"), "x"); + mkdirSync(path.join(root, "dist"), { recursive: true }); + writeFileSync(path.join(root, "src.ts"), "ok"); + writeFileSync(path.join(root, "image.png"), "binary"); + clearIgnoreCache(); + delete process.env.MAX_IGNORE_CONTEXT; + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + clearIgnoreCache(); + }); + + it("matches ignored files", () => { + expect(isIgnored(path.join(root, "node_modules", "foo", "index.js"))).toBe(true); + expect(isIgnored(path.join(root, "image.png"))).toBe(true); + expect(isIgnored(path.join(root, "src.ts"))).toBe(false); + }); + + it("filters directory entries", () => { + const entries = ["src.ts", "image.png", "node_modules", "dist"]; + expect(filterIgnored(root, entries)).toEqual(["src.ts"]); + }); + + it("honors MAX_IGNORE_CONTEXT=false", () => { + process.env.MAX_IGNORE_CONTEXT = "false"; + try { + expect(isIgnored(path.join(root, "image.png"))).toBe(false); + expect(filterIgnored(root, ["image.png", "src.ts"])).toEqual(["image.png", "src.ts"]); + } finally { + delete process.env.MAX_IGNORE_CONTEXT; + } + }); + + it("returns false when no .contextignore is found", () => { + const orphanRoot = mkdtempSync(path.join(tmpdir(), "ctxignore-orphan-")); + try { + writeFileSync(path.join(orphanRoot, "package.json"), "{}"); + writeFileSync(path.join(orphanRoot, "whatever.png"), "x"); + clearIgnoreCache(); + expect(isIgnored(path.join(orphanRoot, "whatever.png"))).toBe(false); + } finally { + rmSync(orphanRoot, { recursive: true, force: true }); + } + }); +});