A 100% NPM/NodeJS-free, Go implementation of Mert Koseoglu's context-mode.
Local-first Model Context Protocol (MCP) server that virtualizes tool outputs, allowing AI coding agents to execute heavy tasks and save up to 98% in token usage.
Current version: 5.0.0.
Supported platform: Linux. Background process identity verification reads /proc/<pid>/stat; on other platforms ctxmode still runs, but ctx_bg termination is not promised (see ctx_bg).
Six real tools (not skills). Five take action= plus capability-specific fields; ctx_stats takes no action and only accepts json:
| Tool | Actions | Key parameters |
|---|---|---|
| ctx_run | execute, execute_file, batch, run_task |
command/language/timeout_ms/background/intent/cwd/argv/env/stdin; path+code; commands/queries/concurrency/query_scope; kind/target/args/timeout_ms |
| ctx_fs | ls, glob, stat, rg, resolve, related |
path/depth/include_hidden/limit; pattern/path/limit; path; pattern/glob/ignore_case/context/literal |
| ctx_git | status, diff, log |
cwd; path/stat/unified/staged; n/path/oneline |
| ctx_kb | index, search, fetch, stats, purge, doctor |
path; query; url/urls/source/format/force/maxBytes/timeout_ms/ttl_ms; —; confirm/scope/sessionId/dryRun; — |
| ctx_bg | list, kill, log, wait |
—; id/pid; id/pid/tail_lines/tail_bytes; id/pid/timeout_ms |
| ctx_stats | — (no action=; single capability) |
json (machine-readable output) |
Any MCP host (e.g. Grok) uses this surface; the Pi adapter below is the exception. Grok prefixes the server name (e.g. ctxmode__ctx_run).
A Pi-specific TypeScript adapter is maintained in integrations/pi/. It registers six tools (ctx_run, ctx_fs, ctx_git, ctx_kb, ctx_bg, ctx_stats) and bridges stdio MCP to the Go binary.
git clone https://github.com/dorokuma/ctxmode.git
cd ctxmode
bash scripts/install-hooks.sh # ← MANDATORY: blocks bad commits & secret leaks
go build -o ctxmode .scripts/install-hooks.sh installs two hard gates into .git/hooks/:
| Hook | What it blocks |
|---|---|
commit-msg |
Non-English characters, noise words, secret patterns (tokens, keys, passwords) |
pre-push |
Same checks across all outgoing commits |
Run the script once after git clone. Without it, commits may be written in Chinese or leak credentials — both are rejected at the hook level.
Optional YAML (-config / $CTXMODE_CONFIG / ./ctxmode-config.yaml / ~/.config/ctxmode/config.yaml):
workdirs:
- /path/to/your/project
- /path/to/another/projectworkdirs defines the workspace roots; every cwd/path argument is resolved against them (see Tools below). See config.example.yaml.
Environment variables:
CTXMODE_DB— absolute path to the SQLite database file; takes priority over the per-workdir default (see Database).CTXMODE_CONFIG— path to the YAML config file.CTXMODE_ENV_PASSTHROUGH=1— disable the default stripping of sensitive variables from subprocess environments (see Subprocess environment isolation).CTXMODE_AUDIT_LOG—ctx_runaudit target (default/root/.local/state/ctxmode/audit.jsonl, which is root-specific; see the audit bullet). The last path component must be a regular file: a symlink/FIFO/device/directory is refused, so/dev/stdoutand symlinked paths are not usable as targets (point it at a real file instead). A refused or failed write is logged once and the call still runs.CTXMODE_ROLE/CTXMODE_READONLY=1— recorded in the audit line asrole/caller_readonlyand as thereadonly_role_calltag. Record-only: they enforce nothing (CTXMODE_ROLE ∈ scout|planner|researcher|reviewer|oracle,CTXMODE_READONLYaccepts1/true).CTXMODE_RG_BUDGET_MS—ctx_fsrg wall-clock budget in milliseconds (default10000;<=0disables).CTXMODE_RG_MAX_LINE_RUNES—ctx_fsrg match-line truncation in UTF-8 runes (default500;<=0disables). The wildcard-only pattern guard is always-on and has no env switch; passliteral:trueto search metacharacters as a literal string.
Threat model: ctxmode runs as root by design. The operator is the trusted party; the fences below (sensitive-path deny list, secret-value regexes, output gates) exist to keep untrusted content - repository files, fetched web pages - from leaking into model context or the knowledge base. They do not constrain operator privileges and are not a substitute for trusting whoever can call the tools. Auditors: do not report "runs as root", "arbitrary command execution", or "the removed shell-command policy" as findings; they are this documented design.
ctxmode executes arbitrary commands and code with the server process's privileges. There is no sandbox and no security boundary: nothing stops ctx_run from running any shell/argv/code, and interpreters can perform any action the server user can. Run ctxmode only in trusted environments.
The following are defense-in-depth measures, never a security guarantee:
-
The
ctx_runcommand screen is deliberately narrow: it runs shellcommandstrings through a fixed/bin/sh -cand only hard-blocks a NUL byte or a command >64 KiB — measured on the entire code segment as received by the shell runner (post-injection), soexecute_filewith the shell language is rejected once the file content plus yourcodeexceed 64 KiB even though theexecute_filefile-read limit is 10 MB, while non-shell languages run from a temp file and are not subject to this limit. Its heuristic alerts (encoded/downloaded payload piped into a shell, or a redirect into/etc///dev/sd*) are logged only and write to the log — they do not block execution and do not constitute policy. The same screen runs in argv mode with two adjustments: a NUL byte in any argument is a hard block (argv command blocked (nul_byte);execvecannot spawn such a process anyway), while an argv whose joined bytes exceed 64 KiB only warns (oversized) — argv is not a shell string and killing large build/codegen arguments is exactly what the argv path exists to avoid. -
argv mode is not path-fenced.
ctx_run action=executewithargvandrun_taskkindcustomexecute the given file directly; an absolute path is used as-is, a relative path is resolved againstcwd, and a bare name is resolved throughPATH— all three are equivalent in what they may run (anything the server user can execute). Only an emptyargv/argv[0]is rejected. The workspace fences (resolvePath/ensureInsideWorkspaces) still apply toctx_fsandctx_gitpath/pathspecarguments and to everycwd, and are deliberately not applied toargv. -
ctx_runcalls are audited (audit.go): one JSON line per call forexecute,execute_file,batchandrun_task— not per spawned subprocess, and nothing else is covered (ctx_fs,ctx_git,ctx_kb,ctx_bgare not audited). Target:CTXMODE_AUDIT_LOG, default/root/.local/state/ctxmode/audit.jsonl. That default is root-specific: a non-root deployment that does not set the variable gets a failed create, not a trail. The parent directory is created0700and the file opened0600only when they are first created — an existing file keeps the mode it already has, and nothing re-chmods it. "Append-only" describes only how the file is opened (O_APPEND); it is not tamper-proof: any process running as the same user can truncate, rewrite, delete or forge lines. Each line is built in full and appended under a process-wide mutex (batch runs commands concurrently, so lines never interleave). Rotation is best effort: at 32 MiB the file is renamed to<path>.1and a fresh file starts; a failed rename (the two measured causes:<path>.1is a non-empty directory, or the directory is not writable for the process) is logged once per reason and the line is appended to the current file instead — rotation can never drop a record nor stop later appends. (A same-directory rename is always within one filesystem, soEXDEVis not reachable here; a target path that is a mount point fails withEBUSYinstead.) Only the current file and one.1generation are kept (≈2×32 MiB); older generations are silently overwritten, there is no archive and no compression, and there is no cross-process lock — two ctxmode processes sharing oneCTXMODE_AUDIT_LOGcan both rotate, so the retention window then favors whichever writes faster. If you need history, archive externally, e.g.logrotateon that path (daily,rotate 30,copytruncate— do not rely on ctxmode's own rotation for retention) or a periodiccp <path>.1 /archive/audit-$(date +%F).jsonlfrom a timer. The target must be a dedicated regular file: the last path component isLstat-checked and a symlink, FIFO, directory or device there is refused (logged once per distinct path+reason, then silent) instead of being followed or opened. That check runs before the append mutex, and the open additionally usesO_NONBLOCKplus aStatre-check on the descriptor, so a hostile target cannot stall the tool surface (a FIFO would otherwise block the open while the mutex is held); a write failure is logged and ignored rather than failing the call. Scope, stated because it is easy to over-read: a symlinked parent directory still redirects the trail silently, a hardlink to another file is indistinguishable from a regular file here, and a file the filesystem itself stalls on (hung NFS/FUSE/CIFS mount, frozen block device) can still delay a write —O_NONBLOCKprotects the open, not a wedged mount. Refusing symlinks also meansCTXMODE_AUDIT_LOG=/dev/stdout(or a symlink to an external mount) no longer works, which matters in containers: point it at a real file, at a path inside a mounted volume, or at a file the container's log collector tails. Fields:timestamp,pid,session_id,action,role(CTXMODE_ROLE),caller_readonly(CTXMODE_READONLY),command_type(argv/command/file/task),raw_cmd(capped at 64 KiB),resolved_exe(the bare name actually resolved viaexec.LookPath),cwd,background,background_id,duration_ms,exit_code,error,screen_verdict/screen_rule,audit_tags,env_keys,stdin_len,indexed/index_label,commands,first_failed_label,output_len,truncated. Process stdout/stderr are never recorded — they can be huge and are the likeliest place for secrets. The record is not secret-free either:raw_cmdis the caller's command (orexecute_file's path/language/code) and goes through the same sensitive-content patterns used for indexing, replaced by a[redacted: … len=… sha256=…]marker on a hit; a hit keeps only a length and digest, and an unformatted secret (or a secret outside those patterns) can still land in the line.env_keysrecords the caller's env key names andstdin_lenonly a byte count — the values and the stdin payload are never written; a credential-shaped key name (AKIA…,ghp_…, a JWT) is replaced by[redacted], but a name outside those patterns is recorded as sent, so treat key names as potentially sensitive too.screen_verdictisok,warnorblock, or""for a call rejected before it was screened.exit_codeis-1for "no exit status of its own" (rejected call, spawn failure, or a background start — the latter also setsbackground_id, and itsoutput_lenis the length of the start message, not of job output).batchfolds N commands intocommands(per-commandlabel,exit_code,screen_verdict/screen_rule,indexed/index_label) plusfirst_failed_labeland a call-levelexit_codethat is 0 only when every command succeeded, otherwise the first failing code (including-1). Two tags are recorded and enforce nothing:readonly_role_call(CTXMODE_ROLE∈scout/planner/researcher/reviewer/oracle, orCTXMODE_READONLY=1— both are set by the dispatch layer that starts the session, so the tag is absent until that layer sets them) andeval_interpreter, a spelling check over argv, not a detector: it matches explicit inline-code spellings only (-c/-e/-p/-rincl. bundled forms like-lc/-ec/-pe,--eval/--print,Rscript -e,php -r,env/busyboxwrappers), has known false positives (bash -e file.sh,env -i cmd, an interpreter reading stdin) and known misses (any spelling not in the table), and never blocks or delays a call. -
Subprocess environments strip inherited variables whose names look sensitive (
token,key,secret,password,passwd,credential,auth,cookie,session, case-insensitive) by default;CTXMODE_ENV_PASSTHROUGH=1disables this. Caller-providedenvoverrides truly replace same-named inherited variables (deduplicated map, not appended duplicates), and the allowlist still rejectsPATH/HOME/SHELL/LD_*/DYLD_*etc. -
Indexing skips secret-like files by default:
.env/.env.*,.envrc, anything under a.env/directory, private keys (*.pem,*.key,*.p12,*.pfx,id_rsa/id_dsa/id_ecdsa/id_ed25519),credentials.json,.npmrc,.netrc,.docker/config.json(not the rest of.docker/), and anything under.aws/.ssh/.gnupg/.kube. -
ctx_kb action=fetchrefuses SSRF targets: IPv4 and IPv6 loopback, link-local (169.254.0.0/16, fe80::/10), multicast, reserved, private (RFC 1918, fc00::/7), CGNAT and benchmark ranges. Embedded IPv4 in IPv6 (IPv4-mapped, IPv4-compatible, NAT6464:ff9b::/96, RFC 8215 local-use64:ff9b:1::/48, 6to42002::/16, Teredo2001:0::/32, ISATAP) is decoded and checked against the same IPv4 list. The blocklist is a single fixed set: the former strict/non-strict split was removed (CTX_FETCH_STRICTno longer exists) and the intercepted set cannot be changed via the environment.
Every subprocess ctxmode spawns — execute/execute_file, run_task (including its compile step), batch, and the rg and git helpers — starts from a sanitized environment built by childEnv (executor.go). By default, inherited variables whose key name matches (?i)token|key|secret|password|passwd|credential|auth|cookie|session (case-insensitive substring match; values are never inspected) are removed, so API keys, tokens or passwords present in the server's environment never reach a child — whose output is captured and auto-indexed.
Exceptions, in priority order:
- Keys on the
envAllowlist(executor.go) are always kept, even when the name matches the pattern. - Caller-provided
envoverrides (already validated byfilterExecEnv, which still rejectsPATH/HOME/SHELL/LD_*/DYLD_*etc.) are applied last and always win. - Everything else that does not match the pattern —
PATH,HOME,SHELL,LANG,TZ, … — is inherited unchanged.
Side effects to be aware of:
SSH_AUTH_SOCK(containsauth) andXDG_SESSION_*/DESKTOP_SESSION(containsession) are stripped too — git remotes over SSH that authenticate via ssh-agent will fail, and session-aware desktop tooling may misbehave. Thegittool additionally drops all inheritedGIT_*overrides (seesanitizedGitEnvingit_tools.go).- Stripping is name-based only: a variable like
MYVARwhose value contains a secret still passes through.
CTXMODE_ENV_PASSTHROUGH=1 disables stripping:
- It is a global switch: it affects every execution path at once, not a single command.
- It is read from the ctxmode server process's own environment —
childEnvcallsos.Getenv("CTXMODE_ENV_PASSTHROUGH")(executor.go). Passing it throughctx_run'senvparameter has no effect (that env is filtered and applied to the child, not to the server); set it in the parent environment that starts ctxmode, e.g. the shell or MCP host launching the binary. - Risk: with passthrough enabled, every subprocess can read all sensitive variables of the host. On top of that, ctxmode stores captured output into the local knowledge base as plaintext without redaction once it exceeds the indexing threshold (>100KB, or >5KB with
intent) — secrets that reach stdout would be persisted to disk. Prefer enabling it only temporarily, and only in trusted environments.
execute— 12-language subprocess execution (javascript,typescript,python,shell,go,rust,php,perl,ruby,r,elixir,csharp).commandruns via shell (default language);argvexecs directly without a shell (preferred).env(allowlist-validated),stdin(≤1MB),timeout_ms(ms, max 1h),background(supervise via ctx_bg),intent,cwd(workdir-resolved). Output >100KB is auto-indexed (withintent, >5KB too). Auto-indexed replies includeexit_codeand a tail preview (same contract asrun_task). Shell safety boundary: whencommandruns as shell (language shell/unset) it executes via a fixed, controlled/bin/sh -c— theSHELLvariable is ignored and there is no command allowlist;argvskips the shell entirely, so there is no shell string to inject into (preferred). argv is not path-fenced: an absolute path is used as-is, a relative path is resolved againstcwd, and a bare name is resolved viaPATH— all three are equivalent in what they may execute (anything the server user can run). A pre-execution screen hard-blocks a command >64 KiB and a NUL byte in either the command string or any argv argument — counted on the whole post-injection segment (forexecute_filewith the shell language that is the file content plus yourcodemeasured together, which can be rejected below the 10 MB file-read limit; non-shell interpreters are not content-screened). It logs (never blocks) a few hostile-looking shell idioms, and an over-length argv is warned about (oversized) but never blocked, so large build arguments survive. Everyctx_runcall — all four entry points — is appended to the audit log (see the security model below).execute_file—path+code: file content is injected asFILE_CONTENTand the code processes it. Files ≤10MB; binary files refused. Whole-file Go sources usevar FILE_CONTENT(legal at package scope); PHP does not add a second<?phpwhen the source already has one. Auto-indexed replies matchexecute(exit_code+ tail preview).batch—commands(≤50, non-empty unique labels),queries(≤20),concurrency(1-8, default 1; out-of-range is an error),query_scope(batch|global, defaultbatch; invalid is an error),cwd,timeout_ms(default 30s, max 1h; serial: shared budget, concurrent: per-command). Only output >100KB is indexed (same threshold asexecute); small output is not persisted. The index write is a directStore.Indexcall rather thanstoreIndexLocked, but kept-out accounting now lives in theStoreitself, so batch output is counted inctx_stats's kept-out bytes.query_scope=batchsearches only this run's indexed command output. Shell safety boundary: eachcommanduses the same fixed/bin/sh -csemantics asexecute(SHELLignored, no allowlist); a NUL/>64 KiB block on one command fails only that command's result and never cascades to its siblings.run_task— structured test/build with fixed argv (no shell):kind∈go_test|go_build|go_vet|npm_test|npm_run_build|cargo_test|cargo_build|make|custom,target,args,timeout_ms(default 300000, max 3600000),cwd,intent,env. Go kinds accept package targets only (no flag arguments);customrequiresargs[0]as the executable, which (likeexecuteargv) is not path-fenced — it is only normalized (relative paths resolved againstcwd). Its argv goes through the same pre-execution screen asexecuteargv (NUL block,oversizedwarn) and is recorded in the audit log.
ls— list directory:path(omitted,., or./is the primary workdir, even when severalworkdirsare configured),depth(1-5, default 1; >5 is an error),include_hidden,limit(default 200, max 2000; >2000 is an error).glob—pattern(**supported),path(same default asls),limit(default 200, max 2000; >2000 is an error); skips.git/node_modules/vendorand applies basic.gitignorerules.stat—path: size/mode/mtime/symlink/workdir metadata (symlink-aware).rg— content search:pattern(orliteral),path(same default asls),glob,ignore_case,context(0-5),limit(default 20 with summary on, max 500; >500 is an error); systemrgwith--no-config(ignoresRIPGREP_CONFIG_PATH/~/.ripgreprc) and a pure-Go fallback; skips binaries. Wildcard-only patterns (.*,*,.+,.) are rejected (always-on; passliteral:trueto search those characters). Match lines longer than 500 runes are truncated (CTXMODE_RG_MAX_LINE_RUNES;<=0disables). Files over 20KiB (20*1024bytes) get a size tag in the summary.resolve— fuzzy path resolution:path(approximate or@path-style reference),limit(default 10, max 2000). Returns ranked candidates across all workdirs (exact > suffix > substring > subsequence, boundary-aware; ported from pi-fff'sresolve_file, pure Go).related— related-file ranking:path(an existing file),limit. Returns test/impl pairs (same stem across_test.go/.test.ts/test_*.pyconventions), same-stem other-extension files, directory siblings, and stem-prefix matches, each with areason; ported from pi-fff'srelated_files. Both actions walk under the same gitignore/symlink/sensitive-path fences as glob/rg with a 5s wall-clock budget and a 100k-file cap; when that budget or cap stops the walk early the response reportstruncated: true, so a partial scan is never returned as if it were complete.
status—git status --porcelain=v1 -b(cwd).diff—path,stat,staged,unified; output hard-truncated (200KB/2000 lines).log—n(default 20, hard max 100),path,oneline(default). Ignores repositorylog.showSignature/gpg.program.
index—path(file or directory) into SQLite FTS5; skips.git/node_modules, sensitive/secret files, binaries and >1MB files; capped at 5000 files / 100MB total.search—query: BM25 + Porter + Trigram + RRF + proximity rerank; flood-guarded (default 60-second window; 4 successful queries in the same window start throttling with half results; 9 attempts hard-reject). If one FTS index errors and the other returns no hits, the error is returned (not a silent "no matches").ctx_runbatchquery_scope=batchsearches only that batch run's indexed documents and bypasses the guard; it does not searchexecute/run_task/fetch output.fetch—url/urls(≤10) → markdown → index;source,format(markdown/html/json),force,maxBytes(default 50KB),timeout_ms(default 150000),ttl_ms(default 24h, 0 = skip cache); SSRF protection as above. URL fragments (#…) are stripped before indexing so a user fragment cannot collide with internal#chunk-keys. Bodies cut at the 10MB fetch cap are still indexed, and the summary reportsbody truncated at 10MB. Indexed documents are isolated performat: the KB path embeds the format (source:format:url), so the same URL can coexist as markdown/html/json without overwriting, and re-fetching in one format never touches the others. Re-fetching a short URL does not delete a longer sibling URL. The Pi adapter default request timeout forfetchis 150s plus a 30s buffer (and honorstimeout_ms).stats— document/cache/DB statistics,kept_out_bytes(this session's bytes written into the KB store instead of the context window; the same figurectx_statsreports astotal_kept_out), andsession_idfor this server process. Breaking JSON change: the per-tool field is nowkept_out_bytes; the previous namesaved_estimate_bytesis no longer returned, so an external consumer still reading that key must be updated (it wastotal_output_bytes - total_input_bytes, a figure that counted a verbatim-returned output as saved).total_input_bytes/total_output_bytesare kept as raw counters of what the commands this server ran received and produced — they are not a context-saving measure (an output returned in full still counts in full).purge—confirm:trueis mandatory: missing orfalsereturns an error (not a silent no-op).scope=projectwipes the whole KB.scope=sessiondeletes documents tagged withsessionId(the id fromstats/doctor); execute/batch/run_task/fetch writes from this process are tagged automatically.doctor— runtime availability (missing runtimes are listed underwarnings), FTS5 self-test, storage info,session_id.
list— registered jobs (id/pid/age/exit_code/log availability).kill— byidorpid; PID-reuse guarded via the process starttime read from/proc/<pid>/stat.log—id/pid,tail_lines(≤10000),tail_bytes(≤4MB); newest output (ring-capped at 16MB), withlog_truncatedwhen the cap fired.wait—id/pid,timeout_ms(default 60000, max 1h); never kills on timeout; includeslog_truncatedwhen the log cap fired.- Max 16 concurrent background jobs (exceeding is an error); a caller-provided
timeout_mson the launch is honored (default max age 1h); log files capped at 16MB. - Platform contract: the
killidentity check reads/proc/<pid>/stat(Linux). On platforms where that file is unavailable the check fails closed — a job with unknown identity is never signaled — soctx_bgtermination is only guaranteed on Linux;list/log/waitkeep working everywhere.
- Reports per-tool call counts, bytes returned into the context window, estimated tokens (bytes ÷ 4), bytes written into the KB instead of the context window (the kept-out counter, shown under the
storerow), and the overall savings ratio. The kept-out counter is recorded by a sink installed on theStoreitself (Store.onIndexedBytes→statRecordKeptOut), so it covers every KB write path: oversizedctx_runexecute/execute_file/run_taskoutput,ctx_fs rgauto-index,ctx_kb index,ctx_run action=batch's directStore.Indexwrites,ctx_kb action=fetch'sStore.ReplaceExactAndChunkschunked writes, and the legacy JSON migration re-index. Only successful writes are counted. - Second section aggregates the ctx_run audit log for the current session: calls, raw output bytes, auto-indexed and truncated counts.
json:truereturns the same report as JSON. Ported from the original context-modectx_stats(mksglu/context-mode). Counters are recorded after each handler returns; actx_statscall racing a concurrent sibling call may not include it yet (sequential callers always see complete rows).
Each primary workdir gets its own SQLite database at ~/.local/share/ctxmode/<hash>-<basename>/context_mode.db, where <hash> is the first 8 bytes of SHA-256 over the primary workdir's absolute path. The on-disk path is opened as a file: URI with special characters (%, ?, #, spaces) percent-encoded, so a basename or CTXMODE_DB containing those characters does not get parsed as URI syntax. Documents indexed in one project are never searchable from another. CTXMODE_DB overrides the location entirely. The legacy global shared database (~/.local/share/ctxmode/context_mode.db) is no longer used and is not migrated automatically.
The agent runs deploy.sh directly: the script is non-interactive, needs no confirmation and has no manual step. deploy.sh builds the binary, verifies it with an initialize handshake, and atomically replaces the live binary. Right before the atomic swap it backs up the current live binary to <BINARY>.prev (best-effort; a failed backup only disables rollback for that deployment).
The same run also syncs the Pi extension through integrations/pi/install.sh (md5-identical target left untouched; destination PI_CTXMODE_EXT or ~/.pi/agent/extensions/ctxmode.ts); a failed sync aborts the deployment, matching the script's existing hard-failure convention.
./deploy.sh # build + verify + atomic deploy (default)
./deploy.sh rollback # restore the previous binaryrollback runs the same initialize verification against <BINARY>.prev and only then atomically renames it back over <BINARY>, printing the restored version. If <BINARY>.prev is missing (no prior deploy, or already consumed by a previous rollback), or verification fails, it exits 1 without touching the live binary. A successful rollback consumes .prev; deploying again recreates the backup.
A weekly cron job (root, Sunday 06:00 UTC) snapshots every context_mode.db under the data root via sqlite3 .backup (consistent even while the server is writing), runs PRAGMA integrity_check on each snapshot, gzips it, and keeps the last 4 weekly sets in /root/backups/ctxmode/<YYYY-MM-DD>/<hash>-<basename>.db.gz. The script lives at /root/backups/ctxmode/backup-ctxmode.sh and logs to /var/log/ctxmode-backup.log.
To restore one project's KB:
gzip -dc /root/backups/ctxmode/<date>/<hash>-<basename>.db.gz > /tmp/restore.db
sqlite3 ~/.local/share/ctxmode/<hash>-<basename>/context_mode.db ".restore '/tmp/restore.db'"
# or: stop ctxmode, replace context_mode.db with the snapshot, start ctxmode againElastic License 2.0 (ELv2) — see LICENSE. Based on original TypeScript work by Mert Koseoglu.