Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claudeignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package-lock.json
node_modules/
dist/
*.png
data/
logs/
*.db
*.db-journal
.env
26 changes: 26 additions & 0 deletions .contextignore
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions .cursorignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package-lock.json
node_modules/
dist/
*.png
data/
logs/
*.db
*.db-journal
.env
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 11 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -26,6 +27,7 @@
"dotenv": "latest",
"express": "latest",
"grammy": "latest",
"ignore": "^7.0.5",
"ws": "^8.19.0"
},
"devDependencies": {
Expand Down
19 changes: 17 additions & 2 deletions src/tools/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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) {
Expand Down Expand Up @@ -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 } };
}
Expand Down
78 changes: 78 additions & 0 deletions src/tools/ignore.ts
Original file line number Diff line number Diff line change
@@ -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<string, CacheEntry>();

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();
}
64 changes: 64 additions & 0 deletions tests/ignore.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
Loading