project_map
Generate a compact indented outline of folders, file titles, tags, and IDs to quickly orient yourself in a vault or project.
Instructions
Return a compact indented outline of folders, file titles, tags, and IDs in a single dense block — substantially fewer tokens than the equivalent list_files JSON for the same scope. Read-only; no side effects, auth, or rate limits. Capped at max_lines (default 5000); the response reports est_tokens and emits a warning field if it exceeds CTXNEST_PROJECT_TOKEN_WARN. project_id: null = KB only; omit = everything. Defaults: include_tags=true, show_titles=true. Use to orient yourself in an unfamiliar vault or project; for keyword lookup use search.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| project_id | No | Restrict to a single project. Pass null for knowledge-base-only files. Omit for everything. | |
| include_tags | No | Append #tags inline. Default true. Set false to shrink the outline. | |
| show_titles | No | Show file titles instead of filenames. Default true. | |
| max_lines | No | Hard cap on output lines (each line ≈ one folder or file). Default 5000. |
Implementation Reference
- The core handler function `projectMap()` that builds the folder/file outline from the SQLite database. It queries files (optionally scoped by project_id), locates each file under its root (knowledge or project), builds an in-memory tree, and renders a compact indented outline with file IDs, titles, tags, and folder hierarchy.
export function projectMap(opts: ProjectMapOptions): ProjectMapResult { const includeTags = opts.include_tags !== false; const showTitles = opts.show_titles !== false; const maxLines = opts.max_lines ?? 5000; const db = getDatabase(); let sql = "SELECT * FROM files WHERE 1=1"; const params: unknown[] = []; if (opts.project_id !== undefined) { if (opts.project_id === null) { sql += " AND project_id IS NULL"; } else { sql += " AND project_id = ?"; params.push(opts.project_id); } } sql += " ORDER BY path ASC"; const files = db.prepare(sql).all(...params) as FileRecord[]; const projects = db.prepare("SELECT * FROM projects").all() as ProjectRecord[]; const projectsById = new Map(projects.map((p) => [p.id, p])); const tagsByFileId = includeTags ? getTagsForFiles(files.map((f) => f.id)) : new Map(); const tree: TreeNode = emptyNode(); for (const file of files) { const location = locateFile(file, projectsById, opts.dataDir); const leaf: FileLeaf = { id: file.id, title: file.title, filename: basename(file.path), tags: tagsByFileId.get(file.id) ?? [], }; insert(tree, location, leaf); } const out: string[] = []; const budget = { maxLines }; let totalFolders = 0; let totalFiles = 0; let roots = 0; // Render each root as a top-level group. const rootNames = [...tree.dirs.keys()].sort(); for (const rootName of rootNames) { if (out.length >= budget.maxLines) break; out.push(`${rootName}/`); roots++; totalFolders++; const sub = renderNode(tree.dirs.get(rootName)!, " ", showTitles, out, budget); totalFolders += sub.folders; totalFiles += sub.files; } const truncated = out.length >= maxLines && (totalFiles < files.length); if (truncated) { out.push(`... (truncated at ${maxLines} lines; use list_files for full enumeration)`); } const outline = out.join("\n"); return { outline, stats: { files: totalFiles, folders: Math.max(0, totalFolders - roots), // don't count the root labels themselves roots, truncated, }, est_tokens: Math.ceil(outline.length / 4), }; } - Type definitions: `ProjectMapOptions` (input params including project_id filter, include_tags, show_titles, max_lines), `ProjectMapStats` (counts of files/folders/roots and truncation flag), and `ProjectMapResult` (outline string, stats, estimated token count).
export interface ProjectMapOptions { dataDir: string; /** * Filter scope: * - undefined / "all": every file in every root * - "knowledge": only files with project_id IS NULL * - { project_id: N }: only that project (use null for knowledge-only) */ project_id?: number | null; /** Attach `tags` to each file leaf. Default true. */ include_tags?: boolean; /** Render the title before the filename. Default true. */ show_titles?: boolean; /** Cap output size. Default 5000 lines (one line per file/folder). */ max_lines?: number; } export interface ProjectMapStats { files: number; folders: number; roots: number; truncated: boolean; } export interface ProjectMapResult { /** Indented text outline — the primary payload for agent consumption. */ outline: string; stats: ProjectMapStats; /** * Token estimate of `outline` (chars/4 heuristic). Lets the caller decide * whether the map fits its context budget before passing it along. */ est_tokens: number; - apps/mcp/src/index.ts:2036-2071 (registration)MCP tool registration for 'project_map' using server.tool(). Defines the Zod schema for parameters (project_id, include_tags, show_titles, max_lines) and the async handler that calls the core `projectMap()` function, wrapping the result with token warnings.
server.tool( "project_map", "Return a compact indented outline of folders, file titles, tags, and IDs in a single dense block — substantially fewer tokens than the equivalent `list_files` JSON for the same scope. Read-only; no side effects, auth, or rate limits. Capped at `max_lines` (default 5000); the response reports `est_tokens` and emits a `warning` field if it exceeds `CTXNEST_PROJECT_TOKEN_WARN`. `project_id: null` = KB only; omit = everything. Defaults: include_tags=true, show_titles=true. Use to orient yourself in an unfamiliar vault or project; for keyword lookup use `search`.", { project_id: z.number().nullable().optional().describe("Restrict to a single project. Pass null for knowledge-base-only files. Omit for everything."), include_tags: z.boolean().optional().describe("Append #tags inline. Default true. Set false to shrink the outline."), show_titles: z.boolean().optional().describe("Show file titles instead of filenames. Default true."), max_lines: z.number().optional().describe("Hard cap on output lines (each line ≈ one folder or file). Default 5000."), }, async ({ project_id, include_tags, show_titles, max_lines }) => { const opts: any = { dataDir }; if (project_id !== undefined) opts.project_id = project_id; if (include_tags !== undefined) opts.include_tags = include_tags; if (show_titles !== undefined) opts.show_titles = show_titles; if (max_lines !== undefined) opts.max_lines = max_lines; const result = projectMap(opts); const warning = tokenWarning(result.est_tokens); return { content: [ { type: "text", text: JSON.stringify( { stats: result.stats, est_tokens: result.est_tokens, outline: result.outline, ...(warning ? { warning } : {}), }, null, 2 ), }, ], }; } ); - Helper functions: `emptyNode()` creates a tree node, `stripRoot()` strips a known root prefix from a file path returning relative segments, `locateFile()` resolves a file's location under knowledge or project roots, `insert()` places a file leaf into the tree, and `renderNode()` recursively renders the tree as indented text lines.
function emptyNode(): TreeNode { return { dirs: new Map(), files: [] }; } /** * Strip a known root prefix from an absolute file path. Returns the segments * relative to that root, or null if the path doesn't sit under the root. */ function stripRoot(filePath: string, root: string): string[] | null { const rootWithSep = root.endsWith(sep) ? root : root + sep; if (filePath === root) return []; if (!filePath.startsWith(rootWithSep)) return null; return filePath.slice(rootWithSep.length).split(sep).filter(Boolean); - The `getTagsForFiles()` helper is used by projectMap to bulk-fetch tags for all files in a single query, avoiding N+1 round-trips.
export function getTagsForFiles(fileIds: number[]): Map<number, string[]> { const out = new Map<number, string[]>(); if (fileIds.length === 0) return out; const db = getDatabase(); const placeholders = fileIds.map(() => "?").join(","); const rows = db .prepare( `SELECT ft.file_id AS file_id, t.name AS name FROM file_tags ft JOIN tags t ON t.id = ft.tag_id WHERE ft.file_id IN (${placeholders}) ORDER BY t.name ASC` ) .all(...fileIds) as { file_id: number; name: string }[]; for (const r of rows) { const arr = out.get(r.file_id); if (arr) arr.push(r.name); else out.set(r.file_id, [r.name]); } return out; }