Skip to content
Open
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ codebase-memory-mcp config set auto_index true

When enabled, new projects are indexed automatically on first connection. Previously-indexed projects are registered with the background watcher for ongoing git-based change detection. Configurable file limit: `config set auto_index_limit 50000`.

The watcher follows MCP sessions: it watches the project each open MCP session is rooted in (the client's working directory) and stops when the last session for that project closes. A repository indexed with `cli index_repository`, or indexed from a session rooted somewhere else, is not watched — re-run `index_repository` after changing it. `index_status` reports this in its `watch` object: `watched`, and either the poll cadence and last scan time or the `reason` it is not watched.

Watcher registration is controlled separately by `auto_watch` (default `true`). Set `config set auto_watch false` to keep a session from registering its project with the background watcher — useful when working across many projects and you want each session contained to explicit indexing.

To turn the watcher off entirely, set `config set watcher_enabled false` (default `true`): the background poll thread never starts and no project is registered, while `auto_index` and manual `index_repository` keep working. Unlike `auto_watch` — which is consulted per session — `watcher_enabled` is read once when the background daemon starts, so run `codebase-memory-mcp daemon stop` after changing it; reconnecting your MCP client alone will not restart the daemon. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#2-cli-managed-runtime-settings).
Expand Down Expand Up @@ -243,7 +245,7 @@ The install script placed beside the binary is **reported, not deleted** — uni

### Distribution & operation
- **Native runtime set, zero infrastructure services**: SQLite-backed, persists to `~/.cache/codebase-memory-mcp/`
- **Auto-sync**: Background watcher detects file changes and re-indexes automatically
- **Auto-sync**: Background watcher detects file changes in the project an MCP session is working in and re-indexes it automatically (other indexed repos: re-run `index_repository`)
- **Route nodes**: REST endpoints are first-class graph entities
- **CLI mode**: `codebase-memory-mcp cli search_graph '{"project": "my-project", "name_pattern": ".*Handler.*"}'`
- **Available on**: npm, PyPI, Homebrew, Scoop, Winget, Chocolatey, AUR, `go install`
Expand Down Expand Up @@ -678,7 +680,7 @@ JSON arguments can also be piped on stdin, for tools that take arguments. A tool

| Tool | Description |
|------|-------------|
| `index_repository` | Index a repository into the graph. Auto-sync keeps it fresh after that. Waits for the whole index by default; pass `async: true` to start it in the daemon and return at once, then poll with `status: true` (see below). |
| `index_repository` | Index a repository into the graph. Auto-sync keeps it fresh only while it is the project of an open MCP session; repos indexed from the CLI or from another project's session need a re-run (`index_status` shows `watch`). Waits for the whole index by default; pass `async: true` to start it in the daemon and return at once, then poll with `status: true` (see below). |
| `list_projects` | List all indexed projects with node/edge counts. |
| `delete_project` | Remove a project and all its graph data. |
| `index_status` | Check indexing status of a project. |
Expand Down
8 changes: 7 additions & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,17 @@ Current keys:
| `auto_index` | `false` | Automatically index new projects when an MCP session starts. |
| `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. |
| `auto_watch` | `true` | Register the session's project with the background git watcher on connect. Set `false` to keep a session from registering its project (the watcher still runs for other projects). |
| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. |
| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. Only an open MCP session's own project is ever watched; `index_status` reports the current state in its `watch` object. |
| `watch_non_git` | `false` | Also poll project roots that are **not git repositories**. By default the watcher only follows git projects, so a project indexed from a plain directory is never refreshed after its first index — reindex it manually with `index_repository`. Set `true` to poll such roots on the same adaptive cadence with a file-tree scan: the indexer's own discovery walk (same skip lists, `.gitignore` and `.cbmignore` rules) hashed over each file's path, size and mtime. Any change reindexes once; paths the indexer skips (including cbm's own `.codebase-memory/` output) never trigger. The first poll after the daemon starts reindexes each such project once, since nothing records which tree state the index holds. The scan walks the whole tree every poll, so it costs more than git polling on very large trees. Read once when the daemon starts, like `watcher_enabled`. |
| `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. |
| `index_max_source_mb` | `off` | Optional maximum accepted source size in MiB in one discovery run. |

For a watched project, `index_status` names the polling method in `watch.strategy`:
`git` (HEAD and dirty-state polling), `tree` (a non-git root polled by the
`watch_non_git` file-tree scan), `none` (a non-git root that is registered but never
polled, the default for a plain directory) or `pending` (registered; the first poll
has not run yet).

> **`watcher_enabled` vs `auto_watch`.** `watcher_enabled` controls whether the
> watcher *subsystem* starts at all (the background poll thread). `auto_watch` is
> narrower: it only controls whether a connecting session registers *its own*
Expand Down
2 changes: 1 addition & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -728,7 +728,7 @@ <h3>Infrastructure-as-code indexing</h3>
</div>
<div class="feature">
<h3>Auto-sync</h3>
<p>A background watcher detects changes and re-indexes incrementally. No manual reindex after editing files.</p>
<p>A background watcher detects changes in the project your MCP session is working in and re-indexes it incrementally. No manual reindex after editing files there.</p>
</div>
<div class="feature">
<h3>Team-shared graph artifact</h3>
Expand Down
52 changes: 52 additions & 0 deletions src/daemon/application.c
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,57 @@ static void application_refresh_watch_locked(cbm_daemon_application_session_t *s
session->watch = watch;
}

static const char *application_watch_strategy_name(cbm_watcher_strategy_t strategy) {
switch (strategy) {
case CBM_WATCHER_STRATEGY_GIT:
return "git";
case CBM_WATCHER_STRATEGY_TREE:
return "tree";
case CBM_WATCHER_STRATEGY_NONE:
return "none";
default:
return "pending";
}
}

/* index_status watch visibility (#2167). The physical watcher is the truth for
* "watched"; the reasons explain the documented scope: only an MCP session's
* own project is watched, and only while that session is open. Takes the
* application mutex and the watcher lock one after the other, never nested. */
static void application_watch_status(void *context, const char *project,
cbm_mcp_watch_status_t *out) {
cbm_daemon_application_session_t *session = context;
cbm_daemon_application_t *application = session ? session->application : NULL;
if (!application || !application->watcher) {
out->reason = "watcher_disabled";
return;
}
cbm_watcher_project_info_t info;
if (cbm_watcher_project_info(application->watcher, project, &info)) {
out->watched = true;
out->strategy = application_watch_strategy_name(info.strategy);
out->poll_interval_ms = info.poll_interval_ms;
out->last_scan_unix_s = info.last_scan_unix_s;
return;
}
const char *session_project = cbm_mcp_server_session_project(session->mcp);
if (!session_project || strcmp(session_project, project) != 0) {
out->reason = "not_session_project";
return;
}
if (application->config &&
!cbm_config_get_bool(application->config, CBM_CONFIG_AUTO_WATCH, true)) {
out->reason = "auto_watch_off";
return;
}
/* Only MCP sessions run background initialization; a one-shot CLI command
* never holds a watch across calls. */
cbm_mutex_lock(&application->mutex);
bool mcp_session = session->background_eligible;
cbm_mutex_unlock(&application->mutex);
out->reason = mcp_session ? "not_registered" : "cli_session";
}

static void application_refresh_watch(cbm_daemon_application_session_t *session) {
if (!session || !session->application) {
return;
Expand Down Expand Up @@ -2798,6 +2849,7 @@ static cbm_daemon_runtime_application_session_t *application_session_open(
}
cbm_mcp_server_set_background_tasks(session->mcp, false);
cbm_mcp_server_set_config(session->mcp, application->config);
cbm_mcp_server_set_watch_status_provider(session->mcp, application_watch_status, session);
cbm_mcp_server_set_index_executor(session->mcp, application_index_execute, session);
cbm_mcp_server_set_index_status_provider(session->mcp, application_index_status, session);
cbm_mcp_server_set_project_mutation_guard(session->mcp, application_session_mutation_begin,
Expand Down
54 changes: 51 additions & 3 deletions src/mcp/mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -745,8 +745,9 @@ static const tool_def_t TOOLS[] = {
"\"project\"]}"},

{"index_status",
"Project readiness, counts, root, and coverage gaps. diagnostics adds coverage rows; verbose "
"adds Git paths. Best-effort only; verify cited paths with check_index_coverage.",
"Project readiness, counts, root, watch state, and coverage gaps. diagnostics adds coverage "
"rows; verbose adds Git paths. Best-effort only; verify cited paths with "
"check_index_coverage.",
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},"
"\"verbose\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Add worktree/"
"shadow Git paths for index-location debugging.\"},"
Expand Down Expand Up @@ -1391,7 +1392,8 @@ static const int SUPPORTED_VERSION_COUNT =
static const char MCP_SERVER_INSTRUCTIONS[] =
"Graph first: search_graph for symbols, trace_path for relationships, get_code_snippet for "
"source, query_graph for multi-hop, and get_architecture for overview. Use search_code/grep "
"for literals or coverage gaps. Indexes auto-refresh. Check cited-path coverage; paginate.";
"for literals or coverage gaps. The session project's index auto-refreshes; re-index others. "
"Check cited-path coverage; paginate.";

static const char MCP_ANALYSIS_SERVER_INSTRUCTIONS[] =
"analysis tool profile: read-only graph work via search_graph, trace_path, "
Expand Down Expand Up @@ -1762,6 +1764,9 @@ struct cbm_mcp_server {
bool background_tasks; /* per-server update/auto-index work enabled */
struct cbm_watcher *watcher; /* external watcher ref (not owned) */
struct cbm_config *config; /* external config ref (not owned) */
/* index_status watch visibility (#2167) */
cbm_mcp_watch_status_fn watch_status_fn;
void *watch_status_context;
cbm_mcp_index_executor_fn index_executor;
void *index_executor_context;
cbm_mcp_index_status_fn index_status_provider; /* #2144; NULL outside the daemon */
Expand Down Expand Up @@ -1849,6 +1854,14 @@ void cbm_mcp_server_set_watcher(cbm_mcp_server_t *srv, struct cbm_watcher *w) {
}
}

void cbm_mcp_server_set_watch_status_provider(cbm_mcp_server_t *srv, cbm_mcp_watch_status_fn fn,
void *context) {
if (srv) {
srv->watch_status_fn = fn;
srv->watch_status_context = context;
}
}

void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg) {
if (srv) {
srv->config = cfg;
Expand Down Expand Up @@ -6863,6 +6876,40 @@ static char *handle_check_index_coverage(cbm_mcp_server_t *srv, const char *args
return result;
}

/* index_status "watch" object (#2167): is this project kept fresh by the
* background watcher right now, and if not, why. Auto-sync covers the active
* MCP session's project only; anything else needs index_repository again. */
static void add_watch_status_json(cbm_mcp_server_t *srv, yyjson_mut_doc *doc, yyjson_mut_val *root,
const char *project) {
cbm_mcp_watch_status_t status = {0};
if (srv->watch_status_fn) {
srv->watch_status_fn(srv->watch_status_context, project, &status);
} else {
status.reason = "no_watcher";
}
yyjson_mut_val *watch = yyjson_mut_obj(doc);
yyjson_mut_obj_add_bool(doc, watch, "watched", status.watched);
if (status.watched) {
yyjson_mut_obj_add_str(doc, watch, "strategy", status.strategy ? status.strategy : "");
yyjson_mut_obj_add_int(doc, watch, "poll_interval_ms", status.poll_interval_ms);
if (status.last_scan_unix_s > 0) {
char when[CBM_SZ_32];
time_t t = (time_t)status.last_scan_unix_s;
struct tm tm;
cbm_gmtime_r(&t, &tm);
if (strftime(when, sizeof(when), "%Y-%m-%dT%H:%M:%SZ", &tm) > 0) {
yyjson_mut_obj_add_strcpy(doc, watch, "last_scan_at", when);
}
}
} else {
yyjson_mut_obj_add_str(doc, watch, "reason", status.reason ? status.reason : "unknown");
yyjson_mut_obj_add_str(doc, watch, "hint",
"Auto-sync watches only the active MCP session's project; "
"re-run index_repository to refresh this index.");
}
yyjson_mut_obj_add_val(doc, root, "watch", watch);
}

static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) {
char *project = get_project_arg(args);
cbm_store_t *store = resolve_store(srv, project);
Expand Down Expand Up @@ -6933,6 +6980,7 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) {
doc, root, "hint",
"Project is empty. Re-run index_repository(repo_path=...) to populate.");
}
add_watch_status_json(srv, doc, root, project);
} else {
yyjson_mut_obj_add_str(doc, root, "status", "no_project");
}
Expand Down
16 changes: 16 additions & 0 deletions src/mcp/mcp.h
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,22 @@ void cbm_mcp_server_free(cbm_mcp_server_t *srv);
/* Set external watcher reference (for auto-index registration). Not owned. */
void cbm_mcp_server_set_watcher(cbm_mcp_server_t *srv, struct cbm_watcher *w);

/* Watch visibility for index_status (#2167). The daemon, which owns the one
* background watcher, answers whether `project` is currently watched. All
* strings are static. A server without a provider runs no watcher at all and
* reports watched=false with reason "no_watcher". */
typedef struct {
bool watched;
const char *reason; /* why not watched (set when !watched) */
const char *strategy; /* "pending" | "git" | "tree" | "none" (set when watched) */
int poll_interval_ms; /* current adaptive cadence (when watched) */
int64_t last_scan_unix_s; /* last completed scan, wall clock; 0 = none yet */
} cbm_mcp_watch_status_t;
typedef void (*cbm_mcp_watch_status_fn)(void *context, const char *project,
cbm_mcp_watch_status_t *out);
void cbm_mcp_server_set_watch_status_provider(cbm_mcp_server_t *srv, cbm_mcp_watch_status_fn fn,
void *context);

/* Set external config store reference (for auto_index setting). Not owned. */
void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg);

Expand Down
49 changes: 49 additions & 0 deletions src/watcher/watcher.c
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ typedef struct {
* from `rev-parse --show-cdup`. Porcelain paths are repository-relative, so
* the signature needs this to stat them. Resolved once at baseline. */
char repo_cdup[CBM_SZ_4K];
/* Published status for cbm_watcher_project_info (#2167). The poll path
* writes these while index_status reads them from a session thread, so
* they are atomics: the diagnostic read is never a data race. */
atomic_int status_strategy; /* cbm_watcher_strategy_t */
atomic_int status_interval_ms; /* mirrors interval_ms */
_Atomic int64_t status_last_scan_s; /* wall-clock seconds; 0 = none yet */
} project_state_t;

/* ── Watcher struct ─────────────────────────────────────────────── */
Expand Down Expand Up @@ -922,6 +928,9 @@ static project_state_t *state_new(const char *name, const char *root_path) {
}
atomic_init(&s->registered, true);
s->interval_ms = POLL_BASE_MS;
atomic_init(&s->status_strategy, CBM_WATCHER_STRATEGY_PENDING);
atomic_init(&s->status_interval_ms, POLL_BASE_MS);
atomic_init(&s->status_last_scan_s, 0);
return s;
}

Expand Down Expand Up @@ -1262,6 +1271,25 @@ int cbm_watcher_index_failure_count(cbm_watcher_t *w, const char *project_name)
return failures;
}

bool cbm_watcher_project_info(cbm_watcher_t *w, const char *project_name,
cbm_watcher_project_info_t *out) {
if (out) {
memset(out, 0, sizeof(*out));
}
if (!w || !project_name || !out) {
return false;
}
cbm_mutex_lock(&w->projects_lock);
project_state_t *s = cbm_ht_get(w->projects, project_name);
if (s) {
out->strategy = (cbm_watcher_strategy_t)atomic_load(&s->status_strategy);
out->poll_interval_ms = atomic_load(&s->status_interval_ms);
out->last_scan_unix_s = atomic_load(&s->status_last_scan_s);
}
cbm_mutex_unlock(&w->projects_lock);
return s != NULL;
}

int cbm_watcher_watch_count(cbm_watcher_t *w) {
if (!w) {
return 0;
Expand All @@ -1274,13 +1302,31 @@ int cbm_watcher_watch_count(cbm_watcher_t *w) {

/* ── Single poll cycle ──────────────────────────────────────────── */

/* Publish what index_status reports: the strategy, the current cadence and,
* when a check just completed, the wall-clock time of that scan. A non-git
* root is "tree" when watch_non_git polls it (#1948), else "none". */
static void publish_status(project_state_t *s, bool scanned) {
cbm_watcher_strategy_t strategy = CBM_WATCHER_STRATEGY_NONE;
if (s->is_git) {
strategy = CBM_WATCHER_STRATEGY_GIT;
} else if (s->tree_poll) {
strategy = CBM_WATCHER_STRATEGY_TREE;
}
atomic_store(&s->status_strategy, strategy);
atomic_store(&s->status_interval_ms, s->interval_ms);
if (scanned) {
atomic_store(&s->status_last_scan_s, (int64_t)time(NULL));
}
}

/* Init baseline for a project: check if git, get HEAD, count files */
static bool init_baseline(cbm_watcher_t *w, project_state_t *s) {
struct stat st;
if (stat(s->root_path, &st) != 0) {
cbm_log_warn("watcher.root_gone", "project", s->project_name, "path", s->root_path);
s->baseline_done = true;
s->is_git = false;
publish_status(s, false);
return true;
}

Expand Down Expand Up @@ -1358,6 +1404,7 @@ static bool init_baseline(cbm_watcher_t *w, project_state_t *s) {
}

s->next_poll_ns = now_ns() + ((int64_t)s->interval_ms * US_PER_MS);
publish_status(s, true);
return true;
}

Expand Down Expand Up @@ -1524,6 +1571,7 @@ static void commit_baselines(cbm_watcher_t *w, project_state_t *s) {
if (git_file_count(w, s, &file_count) == WATCHER_GIT_OK) {
s->file_count = file_count;
s->interval_ms = cbm_watcher_poll_interval_ms(s->file_count);
publish_status(s, false);
}
}

Expand Down Expand Up @@ -1595,6 +1643,7 @@ static void poll_project(const char *key, void *val, void *ud) {
if (!check_changes(ctx->w, s, &changed)) {
return;
}
publish_status(s, true);
if (!changed) {
s->next_poll_ns = ctx->now + ((int64_t)s->interval_ms * US_PER_MS);
return;
Expand Down
Loading
Loading