Essential commands
make init create the virtualenv and install the pre-commit hooks
make install onboard AGENT_ROOT=<path> through its host agent
make reregister rewrite every onboarded codebase's agent configuration
make lint run every pre-commit hook over every file, cargo only for a changed agent
make test run the test suites the changes reach, the stack only when needed
make test-all run every test suite, the stack ones on a throwaway eval stack
make build build every service image
make up start postgres, mcp-server, the viewer and the dashboard
make down stop the stack, keeping the database volume
make backup write the database, or one project, to a file
make restore put a backup file back
make status show whether the stack runs and whether anything uses it
make psql open a psql session against the context database
make clean remove containers, the database directory and the built images
Run make with no target for the full list, including per-service subdivisions (make db version).
make status prints the running services, the /health payload, and the node count. Its sessions field is the count of connected MCP clients - a healthy stack reporting 0 means the client never attached, a different problem from the stack being down.
MCP tools
| Tool | Arguments | Returns |
|---|---|---|
describe_project | optional project, path | What a project is: type, description, the tree it reads, and for an organization the members it holds |
get_code_graph_neighbors | node_id | Incoming and outgoing edges of a node, with the relation type |
search_code_nodes | query, optional project, project_type, limit | Nodes whose name or id matches, in one project, a whole kind, or every member of an organization |
search_code | query, optional project, project_type, limit, rerank, mode | Files whose text or name answers the question, ranked, with the line range to read |
search_text | pattern, optional loose, regex, path, project, project_type, limit | Every line of the indexed trees containing a string, with file and line, like grep |
get_context | query, optional project, project_type, token_budget, seeds, expand, include_chunks, detail | One context packet: the search hits, the graph around them, the relations between them, within budget |
shortest_path | source_id, target_id, optional max_hops | Shortest chain of relations between two nodes |
find_definition | symbol, optional project, file_path | Where a symbol is defined: file, line, the class holding it, summary |
find_callers | symbol, optional project, file_path, max_hops | Graph calls edges and call sites matched by name, each with its evidence |
find_callees | symbol, optional project, file_path, max_hops | What a symbol calls, from graph edges alone |
find_references | symbol, optional project, file_path, max_hops | Every incoming edge but containment, and every mention of the name |
find_implementations | symbol, optional project, file_path, max_hops | What extends or implements a class or interface |
find_tests | symbol, optional project, file_path, max_hops | Test files that reach a symbol within two hops |
impact_analysis | symbol or a file path, optional project, file_path, depth | What a change could reach: direct, indirect, tests, public API, configuration |
get_project_links | optional project, direction, depth, relation, node_id | Which projects use which and by what, what one provides, and the names left unlinked |
trace | node_id, optional project, max_steps | Follows a node across projects: what provides it, who takes it, what applies them there, what they deploy to |
find_linked_name | name, optional kind, project, project_type | Who defines and who uses a host, image or package, by any spelling of its name |
save_project_link | target_project, relation, optional project, source_id, target_id, note | Declares a relation between two projects that no manifest states |
drop_project_link | target_project, relation, optional project, source_id, target_id | Removes a relation declared with save_project_link |
save_project_export | kind, name, optional project, node_id | Says a project provides a name no file of it states, such as an image CI builds |
drop_project_export | kind, name, optional project | Removes an export declared by hand; one a manifest declares stays |
save_node_summary | node_id, summary | Saves or updates a summary for a specific node |
get_node_summary | node_id | Retrieves summary, file path and type for a node |
get_overview | optional project, path, depth, include_entities, token_budget | The summary tree under a directory or file, ./ being the repository, cut to budget |
save_plan | plan_id, title, content, optional project, status, type, nodes | Creates or updates a persistent plan; project: "*" makes it global |
get_plans | optional project, status, type, node_id, node_project | Plans of one project plus the global ones; project: "*" lists all |
drop_plan | plan_id | Deletes one plan outright, for one written by mistake, and its prompts with it |
save_prompt | prompt_id, plan_id, title, content, optional status | Creates or updates the execution prompt of a plan, under that plan’s scope |
get_prompts | optional project, status, plan_id | Prompts of one project plus the global ones, or of one plan; status: "*" for every status |
drop_prompt | prompt_id | Deletes one prompt outright; its plan stays |
save_roadmap | roadmap_id, title, optional project, content, status | Creates or updates a roadmap; its items are written one by one |
get_roadmaps | optional project, status, roadmap_id | Roadmaps with their items in order, each with its plan’s status and that plan’s prompts |
drop_roadmap | roadmap_id | Deletes a roadmap and its items; the plans they named stay |
save_roadmap_item | roadmap_id, item_id, optional title, content, section, status, plan_id, position | Creates an item, or changes only what is given on an existing one; position moves it |
drop_roadmap_item | roadmap_id, item_id | Deletes one item of a roadmap |
drop_project | name, optional confirm | Reports what dropping a project costs, and drops it on confirm: true |
save_memory | memory_id, title, text, optional about, summary, tags, nodes | Writes a memory into _memory; about: "*" makes it global |
get_memory | optional memory_id, about, tags, query, node_id, node_project, limit | Memories of one scope plus the global ones, in full |
drop_memory | memory_id, optional about | Deletes one memory that turned out to be wrong |
save_suggestion | suggestion_id, title, detail, optional about, summary, kind, lever, status, bump, query, nodes | Records a gap in _suggestions; saving under an existing slug counts a hit rather than duplicating |
get_suggestions | optional suggestion_id, about, status, kind, query, node_id, node_project, group_by, limit | Open gaps of one scope plus the global ones, most often hit first, or rolled up by group_by |
drop_suggestion | suggestion_id, optional about | Deletes one suggestion written by mistake; a closed gap is retired instead |
save_issue | issue_id, optional about, title, detail, kind, summary, status, commit, plan_id, resolution, nodes | Opens an issue in _issues, or changes only what is given; closing names the commit or the plan |
get_issues | optional issue_id, about, status, kind, plan_id, query, node_id, node_project, limit | Open issues of one scope plus the global ones, newest first, each with its plan and prompts |
drop_issue | issue_id, optional about | Deletes one issue written by mistake; one that will not be done is declined instead |
list_indexed_files | optional project | The files tracked in indexed_files, which is the parser half of the tree |
get_file_hash | file_path, optional project | The stored hash of one file, or nothing when it was never indexed |
set_file_hash | file_path, hash, optional project | Writes a file’s hash, marking it indexed |
clear_file_hash | file_path, optional project | Forgets a file’s hash, so the next run re-parses it |
list_skills | optional project | The skills a session should have installed, each with its sha256 version |
get_skill | name, optional project | One skill’s text, version stamped, and the path to write it to |
Example - find how two pieces of code are related:
search_code_nodes(query: "SummaryStore")
shortest_path(source_id: "src/index.ts::handleRequest", target_id: "src/storage.py::SummaryStore")
Errors come back as a tool result with isError set, rather than tearing down the client session.
Searching by meaning
search_code_nodes matches identifiers. search_code answers a question asked in words, by fusing that match with a vector search over the text of the indexed files:
search_code(query: "where does a claimed batch go back to the queue")
Each row names the file and the line range to read, so the answer is a place to open rather than a node id to look up further. The two halves are ranked separately and combined, which is why a file both halves found outranks one that only the vector half did.
The combined list is then reranked: a node whose name is a word of the query, a path that shares words with it, and code rather than a documentation heading move up. rerank: false returns the combined order untouched, which is how a ranking is compared against it.
The vector half needs the files embedded. Nothing is embedded until embedding is switched on for the project in the dashboard settings, and until then search_code answers with its lexical half alone and says so in the reply. See Embedding and the vector half.
When an answer looks wrong, mode says which half produced it:
search_code(query: "where is the retry logic", mode: "lexical")
search_code(query: "where is the retry logic", mode: "vector")
lexical matches words and names only and makes no call to the embedding server. vector matches by meaning only, and is refused with the reason when the query cannot be embedded or nothing in scope has embeddings: it never falls back to the other half. hybrid is the default. An answer to a call that passed mode opens with a line naming it. rerank applies in every mode, so rerank: false beside a mode shows the plain order of that half.
Assembling context in one call
get_context is the call to reach for when the question is broad - how a subsystem works, what a change would touch - rather than where one identifier lives:
get_context(query: "how does a claimed batch get embedded", token_budget: 12000)
It runs the same hybrid search and the same reranker as search_code, takes the top hits as seeds, and then walks the graph around them into tiers: what depends on the seed, the tests near it, what it defines, what it calls, and what it imports. A hit is usually a file - the embedded chunks are keyed to file nodes - so what that file defines is the substance of the match rather than noise. The result is deduplicated, by node and by overlapping line ranges in one file, and cut to token_budget, which is spent on the search hits first and then on the tiers in that order.
Each seed may keep only so much of a tier, and the bulk tiers - what a file defines, what it imports - decay with the rank of the hit that pulled them in. A map of the best hit’s file is context; the same map of the eighth hit is a table of contents nobody asked for.
Every entry carries origin, search for a hit and expansion for a node the graph reached, and why, the relation that brought it in. relationships lists the edges between the entries that survived the budget, so the packet says how the pieces connect rather than only which they are.
expand sets the hops per tier and switches one off with 0:
get_context(query: "authentication", expand: { callers: 2, defines: 1, tests: 1, imports: 0 })
The source text in a packet is read from the tree, for the chunks search_code finds: the database keeps where a chunk is and its words, never its text. A project with embedding switched off still answers, with references, summaries and relations alone, and says so in notes; so does a packet when the worker API that reads the trees does not answer. Pass include_chunks: false to ask for that deliberately, which is far cheaper.
The budget is an estimate, at four characters per token: the server holds no tokenizer, and the callers do not share one.
detail decides what the budget is spent on:
detail | The packet |
|---|---|
source | Code first, as above |
summary | Each hit’s file and directories up to the repository, what the hits hold, and source for the best two hits only |
auto (default) | source when the question names code - an identifier, a path, a call - and summary otherwise, or when token_budget is under 3000 |
The packet says which one ran, in detail and in notes.
Summaries at every level
Every node of the ladder repository, directory, file, symbol carries one sentence. A broad question starts at the top of that ladder and drills down, and reads source only once the summaries have named the place:
get_overview()
get_overview(path: "src/payments/", depth: 1, include_entities: true)
get_node_summary(node_id: "src/payments/service.ts")
A directory id ends in a slash and the repository is ./. Each item carries its summary, summary_source (auto, llm or manual) and how many children it has, so a directory cut by the budget is still one call away.
The summaries need no model. Indexing writes an auto one for each level; the model, where one is set up, replaces it with a better one. See Summarization.
Navigating by symbol
The find_* tools answer one question about one symbol. The symbol is written Class.method, function or Class, and file_path picks one node when several share the name:
find_definition(symbol: "PaymentService.refund")
find_callers(symbol: "PaymentService.refund")
find_tests(symbol: "RetryPolicy")
impact_analysis(symbol: "src/config.ts")
Every result carries evidence. graph is an edge, with its relation and the extractor’s confidence. text is the name matched as a whole word in another indexed file, read from its tree, marked NAME_MATCH, with the lines it was found on.
Two limits shape the answers. Calls across files are graph edges for Python, TypeScript, JavaScript, Go, PHP, Rust, Java, Kotlin, Scala, C#, Ruby, C and C++ only; in other languages a caller in another file is found by text alone. The text half searches the indexed trees through the worker API, embedding or not; when the worker API does not answer, the results are graph edges only and notes says so.
A string the graph does not name - a host, a key, an error message - is found with search_text, the way grep finds it: every line of the indexed trees containing it, with the file and the line, unranked.
search_text(pattern: "web-01.example.com", project: "*")
search_text(pattern: "web_01_example_com", loose: true)
search_text(pattern: "retry_(count|limit)", regex: true, path: "**/*.py")
loose ignores what separates the words of the pattern, regex takes it as a regular expression, and path narrows the files by a glob. The trees are read as the indexer reads them: the ignore lines, the default skip list and the deny list leave the same files out. Projects are read in turn until the limit is met.
impact_analysis walks what depends on the symbol, depth hops out, and adds the files that mention it. It answers with counts and capped lists: direct, indirect, tests, public_api (routes, controllers, handlers, servers), configuration, and files, nearest first. cross_project lists the nodes of other projects linked to the symbol, its file or a directory above it, with evidence link - see Links between projects.
Project types and searching across them
Every project carries a type: codebase (the default), docs and config for indexed trees, and memory for the built-in _memory project. It is set by the type given at onboarding and stored once - a later run without TYPE= keeps it rather than resetting it.
The type earns its place in search_code_nodes, which is the one read that need not stop at a project:
search_code_nodes(query: "retention", project: "*")
search_code_nodes(query: "retention", project_type: "docs")
The first searches every graph in the database, the second every project of one kind. Each row names its project, and the limit is shared out between the projects rather than spent on whichever sorts first, so six indexed codebases answer with six projects’ hits. A named project and a project_type cannot be combined - one narrows what the other spans.
Embedding and the vector half
Vectors are written by a queue rather than by an index run: indexing is fast and a model is not, so a file is queued when its hash moves and embedded in the background afterwards. The queue does nothing until the feature is switched on.
Switch it on in the dashboard, on the settings page for every project at once or on a project’s own settings tab. The global level has two switches: enabled/disabled is the kill switch - disabled is off everywhere, whatever a project says, so the model behind it can be shut down without visiting each project first - and status on/off is only what a project that says nothing about itself does. A project may state the opposite and it wins, which is how one project is embedded and no others.
The model runs as a server. The primary is the URL stored in the settings, or EMBED_SERVER_URL (a llama-server on a machine with a GPU) when none is stored, and the queue only ever uses the primary: with it down, the queue waits. EMBED_LOCAL_URL (the embedder container beside the stack, on CPU) answers search queries when the primary cannot be reached. A GPU is not required: store http://embedder:8080 as the URL and the container embeds the queue too. The bundled one starts with the stack once its weights are in place:
make llm-model-install MODEL=nomic-embed
make up
Each feature is one row on the settings page - the switch, the server URL and a button that reads Test until the address answers, then Save. An empty field inherits from the level above, and the tab shows in grey what it inherits and from which level; emptying a field and saving clears that field alone. A project’s settings tab reports how many of its files have vectors, how many are queued, and which level decided the switch. The three states of that switch, what the first pass costs without a GPU, how the queue handles a server that is not there, and what changing the model would mean are all on the embedding page.
Organizations
An organization is a project that holds other projects rather than a tree of its own. Its own graph is empty by design, so every read naming it covers the projects it holds instead, and each row says which member answered:
describe_project()
search_code_nodes(query: "retention", project: "acme")
get_node_summary(node_id: "README.md", project: "acme")
describe_project is where a session starts. It says whether the project is an organization and, when it is, lists the members with the sentence written about each one - which is what an agent picks by. Given a path it answers for whichever project reads that directory instead, so a session works out which project it is standing in rather than assuming:
describe_project(path: "/home/me/src/monorepo/services/api")
The plans, memories and suggestions of an organization are read as a set the same way: a read naming it returns its own records, every member’s, and the global ones. Writing is the exception. A record saved about an organization belongs to the organization, and a summary or a file hash is refused outright - those belong to a graph, and an organization has none, so the call names the member to write to.
A project is renamed on its own page, under its title, and the rename asks for the current name first. Rows are re-keyed where they stand: the graph, the settings, the memberships and the records written about the old name all follow it, and nothing is indexed again. One thing does not follow, because it is not in the database: point any onboarded codebase’s .mcp.json at the new /mcp/<name>.
The sentence each member is described by is written on that project’s own page in the dashboard, under its name. It is at most 500 characters: what a project is at length is what its README is for.
Links between projects
A link joins a node of one project to a node of another. Most are found by the index run: a project takes a name from outside - an image in a compose file, an Ansible role, a package in a manifest, a host it deploys to, an OS package it installs, a bucket it uses - and another indexed project provides that name. The run reads manifests and the edges it already wrote, so links need no model and no embedding.
| Kind | Taken from | Provided by |
|---|---|---|
image | image: of a compose service or of compose held in data | a compose build: + image:, docker build/tag/push |
role | a role a play or a role applies | a roles/<name>/ directory with tasks/main.yml |
npm | package.json dependencies | package.json name |
composer | composer.json require | composer.json name |
pypi | pyproject.toml, setup.cfg, requirements.txt | pyproject.toml or setup.cfg name |
go | go.mod direct requirements | go.mod module |
cargo | Cargo.toml dependency tables, following package = | Cargo.toml [package].name |
cmake | find_package() | project() in a CMakeLists.txt |
vcpkg | vcpkg.json dependencies | vcpkg.json name |
deploy-role | not taken across projects | a workspace data file a layer key selects |
deploy-module | modules: in workspace data, requires: of a module | a workspace modules/<name>/ directory |
host | a data file named like it, an inventory, a DNS record | instances: beside a .tf file, a machine resource |
tfmodule | a remote source of a Terraform module | declared by hand |
package | a pyinfra module or Puppet class, a .spec Requires: | a .spec Name: and its %package subpackages |
bucket | a workspace bucket:, a Makefile variable named *BUCKET | a key of buckets: in a config.yaml beside a .tf file |
A deployment workspace is read the way hiera is, from its modules. Its layout is what its data declares: a hierarchy: list such as nodes/{node}.yaml names the layers below the directory holding it, and the modules sit beside that directory in modules/<name>/.
A key named like a layer variable selects the file it fills (role: web selects roles/web.yaml), modules: runs the modules it lists, a top-level key naming a module configures it, and a data file named like a host that does any of these deploys to that host. What a module installs or runs is read from its pyinfra code: a packages, files.download or docker operation, its arguments followed through the module’s config to the data, else to the defaults and properties of the config model.
A compose document held as a string in workspace or Puppet data takes the images it names. A shell script or Makefile provides the image it builds, retags or pushes under a name with a registry or namespace, at its build context; variables of the file are expanded and a bare local name is not provided.
An Ansible inventory, INI or YAML, found under its usual names, in an inventory/ directory or where an ansible.cfg points, uses the hosts it lists. A Terraform machine resource provides the host its literal name is, and a DNS record uses the hosts it names; only fully qualified names count.
Terraform, OpenTofu and Terragrunt files get edges too: a local module source to the module’s main file, file() and templatefile() to the file they read, a Terragrunt include and read_terragrunt_config to the file find_in_parent_folders would find, and a dependency to its unit. A path built from a variable is skipped rather than guessed; ${path.module}, ${get_terragrunt_dir()} and ${get_repo_root()} resolve.
An image is matched without its tag and digest, a Python name is normalized as pip does, a host is compared in lower case, and a module source loses its getter, scheme and ref. A name two projects provide is not linked: a guess would be wrong half the time, so it is reported instead. A name the taking project provides itself is its own and links nowhere. Nothing under a test, fixture, example or corpus directory (tests/, fixtures/, examples/, corpus/ and the like) provides a name, so a fixture naming a real package cannot make it ambiguous; what such a tree takes is still taken.
What no file states is declared. An image a CI pipeline builds is an export declared by hand, and every project taking it is linked from then on. A relation such as deploys_to or documents is declared between two projects, or two of their nodes:
get_project_links(direction: "incoming", depth: 2)
save_project_export(kind: "image", name: "example.com/alpha/worker")
save_project_link(target_project: "beta", relation: "documents")
get_project_links answers with the projects reached and their distance, the links between them rolled up by relation with sample node pairs, what the project provides, and the names it takes that nothing or more than one project provides. describe_project carries the same one-step summary, and get_code_graph_neighbors lists the links of a node beside its edges. The dashboard shows all of it on a project’s Links tab, where declared relations and exports are added and removed: a relation picks a node at each end, found by searching that project’s nodes.
A Puppet tree is read from its classes outward. A class takes every artifact it runs or installs - an image, an OS package, a pip package - whether its manifest names it in a package, ensure_resource or create_resources call, an image => attribute or hash key, or an RPM or pip artifact URL. A variable is followed through the manifest, the class parameters with their defaults, and whatever the tree’s data sets under class::param or under a key the class reads with lookup or hiera*, whatever the hiera layout. Every YAML file naming the class under classes: applies it, a key it reads configures it, and a file naming one of those by its stem under the key its directory is named after - role: web beside role/web.yaml - selects it; any of these named like a host deploys to that host. Names are compared by their words, so role: alpha::web finds role/alpha_web.yaml, roles/alpha-web.yaml or roles/alpha/web.yaml, whichever the tree uses; two files in one directory spelling the same words are left unlinked. A role a node is given by code, such as a regexp over its certname, is not followed. A Makefile running docker build -t provides the image at its build context, and a .package.yaml or nfpm.yaml the package it names.
trace follows one node across all of it:
trace(project: "alpha", node_id: "tools/keeper/src/")
From the node it takes what a directory above it provides to the projects taking it, climbs from each taker through whatever applies it - a role including a class, a node selecting the role, a circuit using a module - and records what those use in turn, such as the host a node deploys to and the project creating it. Every step names the edge or the matched name behind it, and the answer lists the chains from the node to where each one ends. The Links tab runs it from a node picked there.
A question that starts from a name rather than from code - where is this host described, what runs this image - goes to find_linked_name:
find_linked_name(name: "web_01_example_com", project: "*")
It answers with every project and node providing the name and every one taking it, whatever the case and separators: web_01_example_com, WEB-01.example.com and the first label web-01 all find the host web-01.example.com, and an image is found by its last path segment too. Both sides empty means no indexed project defines or uses the name in a form the indexer reads; it may still be mentioned in text.
One project, one tree
A project is one host tree, served read-only by its host agent, and every node id is a path relative to it:
list_projects()
search_code_nodes(query: "nginx.conf", project: "configs")
get_node_summary(node_id: "prod/nginx.conf")
list_projects carries a root_path field naming the host tree each project reads, so a lookup knows which project a path belongs to. An organization reads none of its own and carries registered://<name> there instead.
Both halves of the graph are built in one pass over the tree, so a call from an infrastructure file resolves into a definition in a source file rather than becoming an external placeholder.
Memory
What an agent works out about a repository - a convention, a decision, why something is the way it is - has nowhere to live in a tree it may only read. save_memory writes it into _memory, a built-in project of type memory created by the migration, holding records rather than files:
save_memory(memory_id: "commit-style", title: "Commits go through cz",
text: "...", about: "*", tags: ["git"])
get_memory(about: "api")
drop_memory(memory_id: "api/commit-style")
A memory is tagged with what it is about, the way a plan is - a project name, or "*" for one belonging to no repository in particular - and reading one scope always returns the global ones alongside it. Nothing indexes into _memory: indexing refuses a project name starting with _, so a memory is never pruned by a re-index the way a derived node is. It is also not a plan: a memory is what stays true after the task, a plan is what to do next.
Suggestions
The other half of a memory. A memory says what is true about a codebase; a suggestion says what the tools could not tell you about it - a lookup that came back empty, a summary too thin to answer from, a file type no parser reads. save_suggestion writes it into _suggestions, a built-in project of type suggestions, holding records the same way _memory does:
save_suggestion(suggestion_id: "hcl-no-parser",
title: "No parser reads *.hcl",
detail: "...", kind: "no-parser", lever: "coverage")
get_suggestions(about: "zeta")
save_suggestion(suggestion_id: "hcl-no-parser", title: "...", detail: "...",
status: "resolved", bump: false)
The suggestion_id is a stable slug derived from the gap, and that is the whole mechanism: saving under one that exists is how the same gap is reported again. The record keeps its first_seen, moves its last_seen, increments hits, and keeps any kind or lever the call did not name. It also reopens a suggestion that had been resolved, because a gap hit again is not a resolved one. bump: false corrects the wording or the status without claiming a fresh sighting, which is what retiring one looks like.
Three vocabularies, free text in the database and documented rather than CHECK-enforced, like a plan’s status:
kind-empty-lookup,missing-summary,thin-summary,not-indexed,no-parser,stale-index,missing-tool.lever- what closing the gap buys:tokenswhen the answer was re-derived by hand,coveragewhen the graph does not describe it at all,runtimewhen it was answerable but slow.status-open(the default),resolved,wontfix.
Reads default to the open ones of the named scope plus the global ones, most often hit first; status: "*" reads every status. drop_suggestion erases the count a record accumulated, so it is for one written by mistake - a gap that has since been closed is retired with status: "resolved" instead. Because _suggestions is a project like any other, search_code_nodes with project_type: "suggestions" finds them, and the dashboard lists them on its own tab.
A suggestion can carry the question that failed and the code that should have answered it, and then it is a test case as well as a complaint:
save_suggestion(suggestion_id: "host-not-found", title: "...", detail: "...",
query: "where is web-01 described",
nodes: ["infra/hosts/web-01.yaml"])
get_suggestions(group_by: "directory")
query keeps up to twenty distinct questions, a new one added on each save. group_by rolls the gaps up by kind, lever, about or the directory of the nodes they name, each group with its count, its hits summed and the most hit records; the dashboard shows the same table above the list. make replay asks every recorded question of the running stack again and reports how often search_code ranks a named node in its first five and ten results and whether get_context reaches it, so a fix can be checked against what was actually asked. It reads the fifty most hit suggestions and writes its results to eval/results/.
Issues
An issue is a piece of work a project owes: a bug, a security problem, a feature, an improvement or debt to clean up. save_issue writes it into _issues, a built-in project of type issues, holding records the same way _suggestions does, under <about>/<slug>:
save_issue(issue_id: "login-loses-session", about: "alpha", kind: "bug",
title: "Login drops the session after a refresh",
detail: "...", nodes: ["src/auth/session.ts"])
get_issues(about: "alpha")
save_issue(issue_id: "login-loses-session", about: "alpha",
status: "closed", commit: "3f2a9c1", plan_id: "session-refresh")
On an existing issue only the arguments given change, so closing one is a status and what settled it; title, detail and kind are needed only to open one. Both settlements are optional: commit is the git hash of the fix, 7 to 40 hex characters, and plan_id names a plan that must exist, whose prompts get_issues lists beside the issue. resolution says how it was settled or why it was declined. An empty commit, plan_id or resolution clears it.
Two vocabularies, checked by the tool rather than by the database:
kind-bug,security,feature,improvement,debt.status-open(the default),closed,declined. Moving toclosedordeclinedstampsclosed_at; moving back toopenclears it.
Reads default to the open issues of the named scope plus the global ones, newest first; status: "*" reads every status, and kind and plan_id narrow further. A listing leaves the detail out, and issue_id reads one in full. drop_plan unties the issues that named the plan, and save_plan names the open issues it carries. drop_issue is for an issue written by mistake; one that will not be done is kept as declined. Issues follow a project rename and survive its drop, as suggestions do.
On the dashboard the issues tab of a project page lists them with their kind, status and what settled them, and opens new ones. An issue’s page edits it, closes it with a commit, a plan and a resolution, and lists the code it is about.
Plans
A plan is not derived from a tree, so it is not owned by a project: every plan is a node of the built-in _plans project, keyed on a plan_id unique across the database, with project a free-text tag in the node’s metadata rather than a key. Consequences worth knowing:
- A plan survives a project drop and
drop_project, and can name a repository this database has never indexed. get_plansdefaults to the connected project plus the global ones;project: "*"lists every project’s plans at once.save_planwithproject: "*"stores a plan that belongs to no project - the right home for a procedure run on demand rather than finished once.
type is a separate axis from status: status (active, completed, archived) says where a plan stands, type (plan, template, procedure) says what kind of record it is. get_plans defaults to type: "plan", so a reusable procedure never shows up mixed in with pending work; ask for type: "*" to see everything. Re-saving a plan without naming a type resets it to plan, the same way omitting status resets it to active - save_plan writes a whole row, it does not patch one.
A plan says what was decided; its prompt is what a session is handed to carry it out. Prompts are rows of a table of their own, prompts, each naming its plan by id:
save_plan(plan_id: "retry-queue", title: "...", content: "...")
save_prompt(prompt_id: "prompt-retry-queue", plan_id: "retry-queue",
title: "...", content: "...")
get_prompts(plan_id: "retry-queue")
save_promptrefuses aplan_idno plan carries, and takes noproject: a prompt is about whatever its plan is about, and follows the plan when it is moved to another project.get_planslists thepromptsof each plan by id and status. Theenggraphskill does not execute an active plan that has none.drop_plantakes the plan’s prompts with it. Like plans, prompts survive a project drop and follow a project rename.
A roadmap is the ordered list above the plans: what is to be done for a project, each item with a state and the plan that carries it out. Roadmaps and their items are rows of roadmaps and roadmap_items:
save_roadmap(roadmap_id: "queue", title: "Queue reliability")
save_roadmap_item(roadmap_id: "queue", item_id: "retries",
title: "Retry a claimed batch", plan_id: "retry-queue")
save_roadmap_item(roadmap_id: "queue", item_id: "retries", status: "done")
get_roadmaps()
get_roadmapsanswers with every item, the status of its plan and that plan’s prompts, so the walk from an item to its plan and on to its prompt is one call.get_planslists theroadmap_itemseach plan carries.save_roadmap_itemchanges only what is given on an item that exists, and refuses a roadmap or a plan that does not.positionmoves an item; what stood at that place or after it moves down one.- Completing a plan changes no item silently:
save_plannames the items the plan carries, and the item is marked by hand. drop_roadmaptakes its items with it, anddrop_planleaves the items it carried with no plan. Like plans, roadmaps survive a project drop and follow a rename.
On the dashboard all three are drawn inside the project: the roadmaps, plans and prompts tabs of a project page, and of an organization page for what its members hold as well.
Records about code
A memory, a plan, a suggestion or an issue can name the code it is about. nodes on save_memory, save_plan, save_suggestion and save_issue takes node ids in the project the record is about, or { project, node_id } for another one; each must exist, a list given replaces the one stored, and a list left out keeps it:
save_memory(memory_id: "no-orm", title: "The auth service avoids the ORM",
text: "...", about: "alpha", nodes: ["src/auth/"])
get_memory(about: "alpha", node_id: "src/auth/jwt.ts")
A record about a directory is about everything below it, so the read above finds the memory saved against src/auth/. The same node_id filter works on get_plans, get_suggestions and get_issues, and every record they return lists its nodes, a node an index run has since dropped marked missing rather than hidden. The reads that start from code bring the records back on their own: get_context carries them under knowledge for its search hits, impact_analysis for everything a change reaches, and get_code_graph_neighbors lists them beside the edges. describe_project counts them by type.
The links live in record_links. Dropping a memory, plan, suggestion or issue deletes its links in the same statement; an index run that drops a node leaves the links to it, so the record shows the node as missing. Dropping a project removes the links to its code and keeps the records; the drop report counts them. A single-project backup carries the links to its code and restores those whose record still exists. On the dashboard a record’s page lists its nodes and adds or removes them, and a node’s panel lists the records about it under Knowledge.
Queues
Summarizing and embedding both fill a queue and drain it in the background. The Queues page puts the two side by side: a percent for each, and a row per project saying how much is described, how much has vectors, and how much each still owes. Failures are called out because nothing retries them once a file has used up its attempts.
curl -s http://127.0.0.1:3000/api/summaries | python3 -m json.tool
curl -s http://127.0.0.1:3000/api/embeddings | python3 -m json.tool
Web interface
make up serves a dashboard at http://localhost:3000, on the stack’s one entry point. It’s the one service here that writes to the database on a browser’s behalf and it has no authentication of its own, so anything that can reach the entry point can edit what it shows.
- Ask - the opening page. Pick a session project and one of the read tools an agent has (
get_context,search_code, thefind_*family and the rest of the graph, project and record reads), fill in its arguments and run it. The dashboard calls the MCP server as a client of its own, so the tool descriptions, the argument schemas and the answer are the ones an agent gets; the page adds how long the call took and how many tokens the answer costs. Tools that write or drop are not offered. - Projects - at
/projects: what’s indexed, where it came from, node/edge/file/plan counts, and how stale the index is. Three tabs: Indexed for the trees, Organizations for the projects that hold other projects, and System for the built-in ones holding what an agent wrote. Searchable by name or path, sortable by every count and by freshness, with the type staged in place and applied only when the change is confirmed, and aWhencolumn naming the schedule each project resolves to. New project registers one, with a host path or without: a project that reads no tree is an organization, which is what other projects are moved into. - A project - five tabs: overview (node type breakdown, the members it holds when it is an organization - each with its own settings, index and drop buttons, how it is kept indexed and how long ago it last ran - and the organizations holding it), graph (the viewer’s page, proxied so the frame shares this origin), nodes (search and inspect one node’s summary, metadata, neighbours and stored source), files (file nodes with entity counts and hash status), settings (when it is indexed, the formats its runs found, and what it ignores). Under the title: the sentence saying what the project is for, a Rename that asks for the current name first, and Drop project. What an agent wrote about the project has tabs of its own: issues, roadmaps, plans and prompts.
- Plans - every plan in the database, filterable by project, status, type or a text search; opens as rendered markdown and edits in place.
- Suggestions - the recorded gaps, most often hit first, filterable by the project they are about, status or kind. It triages rather than authors: the status, the wording and the vocabularies are editable, the hit count and the first sighting are not, and there is no way to create one here - a suggestion is written by the agent that hit the gap.
- Settings - what every project falls back to when neither it nor an organization holding it has said otherwise: the indexing schedule. The ignore lines here apply to every project, under its own.
Indexing on a schedule
A project is indexed by hand until something says otherwise, and what says otherwise is a mode stored beside the ignore lines, at the same three levels:
| Mode | What it does |
|---|---|
off | nothing runs on its own; the Index button is the only trigger |
periodic | a run every N minutes |
auto | a run when a file changes, throttled, and never otherwise |
auto has the agent watch the tree on its host and starts a run once it has been quiet for 15 seconds, every new change moving the start, and no sooner than the throttle after the last run began, so a checkout or a git rm of many files is indexed once it is over. A change made while a run is going marks the project again and is indexed by the next run, including when that start has to wait for the first one to end. Nothing else starts one: a tree nothing changed is never walked again, and a watch that is blind - a network filesystem, or fs.inotify.max_user_watches exhausted on the host - is logged and retried, not covered by a full run. Such a project is indexed with the Index button.
Every field is resolved on its own, so a project may set periodic while its interval is still the global one. The When column on the projects list shows the mode each project resolves to, and the settings tab states the whole result above the fields.
An organization indexes everything under it. Its Index button starts a run for every project it holds, skipping the members set to off - off is a project saying it is indexed by hand, and the button on its own page still does that. A member already indexing is reported as skipped rather than as a failure, and the page shows the runs folded into one: still going while any of them is, failed with each failure naming its project.
Two runs of one project never overlap: an index run holds a row, and a second start is refused while the first is going, whether it came from the schedule or from the button.
At most INDEX_MAX_RUNNING runs go at once. A press past that limit is queued and the button says Queued...; the queue is started oldest first as runs end, and an organization queues the members past the limit. The queue lives in the worker API’s memory, so a restart drops it and the button is pressed again.
Registering a project writes a row and reads nothing. On the host holding the directory, enggraph-agent project add offers it; bind it on the Agents page, then index it. The dashboard’s form says so.
A drop reports what it will cost before it happens and asks the project name to be typed as confirmation. Dropping a directory is not a drop of what it indexed - those nodes go on the next index run, the same way a deleted file’s do.