vectr 1.0.2__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. {vectr-1.0.2/vectr.egg-info → vectr-1.1.0}/PKG-INFO +57 -14
  2. {vectr-1.0.2 → vectr-1.1.0}/README.md +56 -13
  3. vectr-1.1.0/agent/fs_permissions.py +33 -0
  4. {vectr-1.0.2 → vectr-1.1.0}/agent/instance_registry.py +4 -1
  5. {vectr-1.0.2 → vectr-1.1.0}/agent/working_context_store/__init__.py +4 -0
  6. vectr-1.1.0/agent/working_context_store/_audit.py +102 -0
  7. {vectr-1.0.2 → vectr-1.1.0}/agent/working_context_store/_encryption.py +21 -2
  8. {vectr-1.0.2 → vectr-1.1.0}/agent/working_context_store/_store.py +75 -10
  9. {vectr-1.0.2 → vectr-1.1.0}/api.py +11 -6
  10. {vectr-1.0.2 → vectr-1.1.0}/app/routes.py +8 -2
  11. {vectr-1.0.2 → vectr-1.1.0}/app/service.py +18 -3
  12. {vectr-1.0.2 → vectr-1.1.0}/integrations/mcp_server/_dispatch.py +18 -2
  13. {vectr-1.0.2 → vectr-1.1.0}/main.py +257 -9
  14. {vectr-1.0.2 → vectr-1.1.0}/pyproject.toml +1 -1
  15. {vectr-1.0.2 → vectr-1.1.0}/tests/test_api.py +88 -0
  16. {vectr-1.0.2 → vectr-1.1.0}/tests/test_main.py +203 -2
  17. {vectr-1.0.2 → vectr-1.1.0}/tests/test_memory.py +382 -0
  18. vectr-1.1.0/tests/test_scope_isolation.py +117 -0
  19. {vectr-1.0.2 → vectr-1.1.0}/tests/test_service.py +54 -0
  20. {vectr-1.0.2 → vectr-1.1.0/vectr.egg-info}/PKG-INFO +57 -14
  21. {vectr-1.0.2 → vectr-1.1.0}/vectr.egg-info/SOURCES.txt +2 -0
  22. vectr-1.0.2/agent/working_context_store/_audit.py +0 -46
  23. {vectr-1.0.2 → vectr-1.1.0}/LICENSE +0 -0
  24. {vectr-1.0.2 → vectr-1.1.0}/MANIFEST.in +0 -0
  25. {vectr-1.0.2 → vectr-1.1.0}/agent/__init__.py +0 -0
  26. {vectr-1.0.2 → vectr-1.1.0}/agent/cartographer.py +0 -0
  27. {vectr-1.0.2 → vectr-1.1.0}/agent/chunk_quality.py +0 -0
  28. {vectr-1.0.2 → vectr-1.1.0}/agent/config.py +0 -0
  29. {vectr-1.0.2 → vectr-1.1.0}/agent/config.yaml +0 -0
  30. {vectr-1.0.2 → vectr-1.1.0}/agent/eviction_advisor.py +0 -0
  31. {vectr-1.0.2 → vectr-1.1.0}/agent/identifier_hint.py +0 -0
  32. {vectr-1.0.2 → vectr-1.1.0}/agent/indexer/__init__.py +0 -0
  33. {vectr-1.0.2 → vectr-1.1.0}/agent/indexer/_chunking.py +0 -0
  34. {vectr-1.0.2 → vectr-1.1.0}/agent/indexer/_constants.py +0 -0
  35. {vectr-1.0.2 → vectr-1.1.0}/agent/indexer/_core.py +0 -0
  36. {vectr-1.0.2 → vectr-1.1.0}/agent/indexer/_types.py +0 -0
  37. {vectr-1.0.2 → vectr-1.1.0}/agent/model_cache.py +0 -0
  38. {vectr-1.0.2 → vectr-1.1.0}/agent/prompt_templates.py +0 -0
  39. {vectr-1.0.2 → vectr-1.1.0}/agent/searcher.py +0 -0
  40. {vectr-1.0.2 → vectr-1.1.0}/agent/strategy_selector.py +0 -0
  41. {vectr-1.0.2 → vectr-1.1.0}/agent/symbol_graph/__init__.py +0 -0
  42. {vectr-1.0.2 → vectr-1.1.0}/agent/symbol_graph/_constants.py +0 -0
  43. {vectr-1.0.2 → vectr-1.1.0}/agent/symbol_graph/_extraction.py +0 -0
  44. {vectr-1.0.2 → vectr-1.1.0}/agent/symbol_graph/_graph.py +0 -0
  45. {vectr-1.0.2 → vectr-1.1.0}/agent/symbol_graph/_types.py +0 -0
  46. {vectr-1.0.2 → vectr-1.1.0}/agent/templates/claude_md.md +0 -0
  47. {vectr-1.0.2 → vectr-1.1.0}/agent/templates/claude_md_search_only.md +0 -0
  48. {vectr-1.0.2 → vectr-1.1.0}/agent/templates/cursor_mcp.json.template +0 -0
  49. {vectr-1.0.2 → vectr-1.1.0}/agent/templates/cursor_rules_header.txt +0 -0
  50. {vectr-1.0.2 → vectr-1.1.0}/agent/templates/hook_no_double_recall.txt +0 -0
  51. {vectr-1.0.2 → vectr-1.1.0}/agent/templates/mcp.json.template +0 -0
  52. {vectr-1.0.2 → vectr-1.1.0}/agent/templates/session_start_guidance_default.txt +0 -0
  53. {vectr-1.0.2 → vectr-1.1.0}/agent/templates/session_start_guidance_hooks_aware.txt +0 -0
  54. {vectr-1.0.2 → vectr-1.1.0}/agent/templates/tool_loading_guidance_claude.txt +0 -0
  55. {vectr-1.0.2 → vectr-1.1.0}/agent/templates/tool_loading_guidance_claude_search_only.txt +0 -0
  56. {vectr-1.0.2 → vectr-1.1.0}/agent/templates/vscode_mcp.json.template +0 -0
  57. {vectr-1.0.2 → vectr-1.1.0}/agent/tool_necessity_probe.py +0 -0
  58. {vectr-1.0.2 → vectr-1.1.0}/agent/version_stamp.py +0 -0
  59. {vectr-1.0.2 → vectr-1.1.0}/agent/watcher.py +0 -0
  60. {vectr-1.0.2 → vectr-1.1.0}/agent/working_context_store/_types.py +0 -0
  61. {vectr-1.0.2 → vectr-1.1.0}/app/__init__.py +0 -0
  62. {vectr-1.0.2 → vectr-1.1.0}/app/models.py +0 -0
  63. {vectr-1.0.2 → vectr-1.1.0}/integrations/__init__.py +0 -0
  64. {vectr-1.0.2 → vectr-1.1.0}/integrations/mcp_server/__init__.py +0 -0
  65. {vectr-1.0.2 → vectr-1.1.0}/integrations/mcp_server/_schemas.py +0 -0
  66. {vectr-1.0.2 → vectr-1.1.0}/integrations/mcp_server/_session.py +0 -0
  67. {vectr-1.0.2 → vectr-1.1.0}/integrations/vscode_bridge.py +0 -0
  68. {vectr-1.0.2 → vectr-1.1.0}/integrations/workspace_detect.py +0 -0
  69. {vectr-1.0.2 → vectr-1.1.0}/setup.cfg +0 -0
  70. {vectr-1.0.2 → vectr-1.1.0}/tests/test_agent.py +0 -0
  71. {vectr-1.0.2 → vectr-1.1.0}/tests/test_api_memory.py +0 -0
  72. {vectr-1.0.2 → vectr-1.1.0}/tests/test_cartographer.py +0 -0
  73. {vectr-1.0.2 → vectr-1.1.0}/tests/test_chunk_quality.py +0 -0
  74. {vectr-1.0.2 → vectr-1.1.0}/tests/test_config_loader.py +0 -0
  75. {vectr-1.0.2 → vectr-1.1.0}/tests/test_eviction_advisor.py +0 -0
  76. {vectr-1.0.2 → vectr-1.1.0}/tests/test_eviction_session_scope.py +0 -0
  77. {vectr-1.0.2 → vectr-1.1.0}/tests/test_fetch_route.py +0 -0
  78. {vectr-1.0.2 → vectr-1.1.0}/tests/test_grammar_availability.py +0 -0
  79. {vectr-1.0.2 → vectr-1.1.0}/tests/test_hint_language_live.py +0 -0
  80. {vectr-1.0.2 → vectr-1.1.0}/tests/test_hook_injection_observability.py +0 -0
  81. {vectr-1.0.2 → vectr-1.1.0}/tests/test_identifier_hint.py +0 -0
  82. {vectr-1.0.2 → vectr-1.1.0}/tests/test_indexer_searcher.py +0 -0
  83. {vectr-1.0.2 → vectr-1.1.0}/tests/test_instance_registry.py +0 -0
  84. {vectr-1.0.2 → vectr-1.1.0}/tests/test_integration.py +0 -0
  85. {vectr-1.0.2 → vectr-1.1.0}/tests/test_mcp_jsonrpc.py +0 -0
  86. {vectr-1.0.2 → vectr-1.1.0}/tests/test_mcp_server.py +0 -0
  87. {vectr-1.0.2 → vectr-1.1.0}/tests/test_mcp_session_handshake.py +0 -0
  88. {vectr-1.0.2 → vectr-1.1.0}/tests/test_memory_only_mode.py +0 -0
  89. {vectr-1.0.2 → vectr-1.1.0}/tests/test_model_cache.py +0 -0
  90. {vectr-1.0.2 → vectr-1.1.0}/tests/test_prompt_templates.py +0 -0
  91. {vectr-1.0.2 → vectr-1.1.0}/tests/test_query_prompt.py +0 -0
  92. {vectr-1.0.2 → vectr-1.1.0}/tests/test_ragas_eval.py +0 -0
  93. {vectr-1.0.2 → vectr-1.1.0}/tests/test_recall_hierarchy.py +0 -0
  94. {vectr-1.0.2 → vectr-1.1.0}/tests/test_remember_banner_fatigue.py +0 -0
  95. {vectr-1.0.2 → vectr-1.1.0}/tests/test_search_only_mode.py +0 -0
  96. {vectr-1.0.2 → vectr-1.1.0}/tests/test_strategy.py +0 -0
  97. {vectr-1.0.2 → vectr-1.1.0}/tests/test_subagent_memory.py +0 -0
  98. {vectr-1.0.2 → vectr-1.1.0}/tests/test_symbol_graph.py +0 -0
  99. {vectr-1.0.2 → vectr-1.1.0}/tests/test_task_note_recency.py +0 -0
  100. {vectr-1.0.2 → vectr-1.1.0}/tests/test_tool_necessity_probe.py +0 -0
  101. {vectr-1.0.2 → vectr-1.1.0}/tests/test_version_stamp.py +0 -0
  102. {vectr-1.0.2 → vectr-1.1.0}/tests/test_watcher.py +0 -0
  103. {vectr-1.0.2 → vectr-1.1.0}/tests/test_workspace_detect.py +0 -0
  104. {vectr-1.0.2 → vectr-1.1.0}/vectr.egg-info/dependency_links.txt +0 -0
  105. {vectr-1.0.2 → vectr-1.1.0}/vectr.egg-info/entry_points.txt +0 -0
  106. {vectr-1.0.2 → vectr-1.1.0}/vectr.egg-info/requires.txt +0 -0
  107. {vectr-1.0.2 → vectr-1.1.0}/vectr.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vectr
3
- Version: 1.0.2
3
+ Version: 1.1.0
4
4
  Summary: Semantic codebase search + persistent working memory for AI code editors. Local, zero-config, MCP. No API key.
5
5
  Author-email: Swapnanil Saha <swapnanilsaha26@gmail.com>
6
6
  License: MIT
@@ -43,6 +43,12 @@ Requires-Dist: pytest-asyncio==1.3.0; extra == "dev"
43
43
  Requires-Dist: anyio==4.13.0; extra == "dev"
44
44
  Dynamic: license-file
45
45
 
46
+ <!-- mcp-name: io.github.swapnanil/vectr -->
47
+
48
+ <p align="center">
49
+ <img src="https://raw.githubusercontent.com/swapnanil/vectr/main/assets/banner.svg" alt="vectr — semantic codebase search + persistent working memory for AI code editors" width="560">
50
+ </p>
51
+
46
52
  # Vectr
47
53
 
48
54
  > **Semantic search and persistent memory for AI code editors.**
@@ -50,10 +56,10 @@ Dynamic: license-file
50
56
  [![CI](https://github.com/swapnanil/vectr/actions/workflows/ci.yml/badge.svg)](https://github.com/swapnanil/vectr/actions/workflows/ci.yml)
51
57
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
52
58
  [![Python 3.14+](https://img.shields.io/badge/python-3.14%2B-blue.svg)](https://www.python.org/downloads/)
53
- [![Version 1.0.0](https://img.shields.io/badge/version-1.0.0-blue.svg)](CHANGELOG.md)
59
+ [![Version 1.1.0](https://img.shields.io/badge/version-1.1.0-blue.svg)](CHANGELOG.md)
54
60
  [![MCP: 14 tools](https://img.shields.io/badge/MCP-14%20tools-blue.svg)](#14-mcp-tools)
55
61
 
56
- Version 1.0.0 · Last updated 2026-07-08 · [CHANGELOG](CHANGELOG.md)
62
+ Version 1.1.0 · Last updated 2026-07-11 · [CHANGELOG](CHANGELOG.md)
57
63
 
58
64
  Vectr gives AI code editors two things they lack: **semantic codebase search** and **persistent working memory** — both served over MCP with zero configuration.
59
65
 
@@ -155,7 +161,7 @@ vectr start
155
161
  - Ubuntu/Debian: `sudo add-apt-repository ppa:deadsnakes/ppa && sudo apt install python3.14 python3.14-venv`
156
162
  - Windows: [python.org/downloads](https://www.python.org/downloads/)
157
163
 
158
- `vectr start` returns immediately. Indexing runs in the background — run `vectr status` to check progress. On first run the embedding model downloads once (~440 MB). Restart your AI code editor once to pick up the new MCP config.
164
+ `vectr start` returns immediately. Indexing runs in the background — run `vectr status` to check progress. On first run the embedding model downloads once (~290 MB). Restart your AI code editor once to pick up the new MCP config.
159
165
 
160
166
  **Docker (CI/servers)**
161
167
 
@@ -335,7 +341,7 @@ HTTP routes (Flask/FastAPI decorators, Express `app.get()`, Spring `@GetMapping`
335
341
 
336
342
  | | Cost |
337
343
  |---|---|
338
- | Embedding model | $0.00 — one-time ~440 MB download, cached at `~/.cache/vectr/` |
344
+ | Embedding model | $0.00 — one-time ~290 MB download, cached at `~/.cache/vectr/` |
339
345
  | Re-index (10k files, first run) | ~10 min on CPU; <5 sec on subsequent runs (mtime cache) |
340
346
  | Incremental re-index per changed file | ~0.5 sec |
341
347
  | vectr_search / vectr_recall | $0.00 — local inference only |
@@ -344,15 +350,52 @@ HTTP routes (Flask/FastAPI decorators, Express `app.get()`, Spring `@GetMapping`
344
350
 
345
351
  ## Security
346
352
 
347
- Vectr v1 is designed for a **solo developer on a personal machine**.
348
-
349
- - MCP server binds to `127.0.0.1` only not reachable from other hosts
350
- - CORS restricted to localhost origins
351
- - Each workspace gets its own isolated DB directory, port, and process
352
- - No API key authentication in v1 — any local process can query
353
- - Index and notes persist locally in `~/.cache/vectr/`
354
-
355
- Multi-user, authentication, and encryption at rest are out of scope for v1.
353
+ The default is unchanged and stays the headline: **local, no API key, zero
354
+ config** — a solo developer on a personal machine. Out of the box, the daemon
355
+ binds to `127.0.0.1` only, CORS is restricted to localhost origins, each
356
+ workspace gets its own isolated DB directory, port, and process (owner-only
357
+ `0700` on POSIX), and the index and notes persist locally in `~/.cache/vectr/`.
358
+
359
+ Everything below is **opt-in**; enabling nothing changes nothing.
360
+
361
+ **Authentication** set `VECTR_API_KEY` and every request to `/v1/*` and
362
+ `/mcp` must carry it (`X-Api-Key: <key>` or `Authorization: Bearer <key>`;
363
+ constant-time comparison; `/v1/health` stays open for liveness probes).
364
+ Generate a key with `vectr key`. When the key is set at start time, the editor
365
+ MCP configs vectr writes include the header automatically. Those files
366
+ (`.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`) then hold the key in
367
+ plaintext — treat them as secrets and keep them out of shared or public
368
+ version control.
369
+
370
+ **Encryption at rest** — set `VECTR_ENCRYPT_KEY` (or store a passphrase in the
371
+ OS keychain: service `vectr`, username `encrypt-key`; requires
372
+ `pip install vectr[encryption]`) and note content, note titles, and snapshot
373
+ payloads are encrypted (Fernet, PBKDF2-derived key). Honest boundary: the
374
+ **code index is not encrypted** — the search engine needs readable chunk text
375
+ and vectors; protect it with OS full-disk encryption. Note tags/metadata stay
376
+ plaintext, and note embedding vectors (a lossy projection of note text) are
377
+ kept for semantic recall unless you set
378
+ `VECTR_ENCRYPT_DISABLE_NOTE_VECTORS=1`.
379
+
380
+ **Retention and audit** — notes are kept until you delete them; set
381
+ `VECTR_NOTES_TTL_DAYS` to auto-purge older notes at startup.
382
+ `vectr_forget(all=true)` / `vectr forget --all` delete notes, snapshots, and
383
+ note vectors — everything means everything. Set `VECTR_AUDIT_LOG=<path>` for a
384
+ rotating local log of index/search/remember/recall events (off by default; it
385
+ records query text — that is its purpose — and is never transmitted). Full
386
+ policy: [docs/data-handling.md](docs/data-handling.md).
387
+
388
+ **Team mode (shared instance)** — one central daemon can serve a team on one
389
+ repo: `VECTR_API_KEY=<key> vectr start --host 0.0.0.0` on the server (a
390
+ non-loopback bind **refuses to start without a key**), then
391
+ `vectr connect --url http://<host>:<port> --api-key <key> --label <you>` on
392
+ each client to point the editor at it. Working memory is shared: a note one
393
+ agent stores, every connected agent can recall; `--label` attributes notes and
394
+ audit lines. Plain limits: one shared key means every holder is an equal,
395
+ trusted peer (no roles, no per-user permissions); the server operator can read
396
+ everything; search results reference the **server's** checkout, which may
397
+ differ from your local tree; vectr speaks plain HTTP — put TLS at a reverse
398
+ proxy or tunnel if the network isn't trusted.
356
399
 
357
400
  ---
358
401
 
@@ -1,3 +1,9 @@
1
+ <!-- mcp-name: io.github.swapnanil/vectr -->
2
+
3
+ <p align="center">
4
+ <img src="https://raw.githubusercontent.com/swapnanil/vectr/main/assets/banner.svg" alt="vectr — semantic codebase search + persistent working memory for AI code editors" width="560">
5
+ </p>
6
+
1
7
  # Vectr
2
8
 
3
9
  > **Semantic search and persistent memory for AI code editors.**
@@ -5,10 +11,10 @@
5
11
  [![CI](https://github.com/swapnanil/vectr/actions/workflows/ci.yml/badge.svg)](https://github.com/swapnanil/vectr/actions/workflows/ci.yml)
6
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7
13
  [![Python 3.14+](https://img.shields.io/badge/python-3.14%2B-blue.svg)](https://www.python.org/downloads/)
8
- [![Version 1.0.0](https://img.shields.io/badge/version-1.0.0-blue.svg)](CHANGELOG.md)
14
+ [![Version 1.1.0](https://img.shields.io/badge/version-1.1.0-blue.svg)](CHANGELOG.md)
9
15
  [![MCP: 14 tools](https://img.shields.io/badge/MCP-14%20tools-blue.svg)](#14-mcp-tools)
10
16
 
11
- Version 1.0.0 · Last updated 2026-07-08 · [CHANGELOG](CHANGELOG.md)
17
+ Version 1.1.0 · Last updated 2026-07-11 · [CHANGELOG](CHANGELOG.md)
12
18
 
13
19
  Vectr gives AI code editors two things they lack: **semantic codebase search** and **persistent working memory** — both served over MCP with zero configuration.
14
20
 
@@ -110,7 +116,7 @@ vectr start
110
116
  - Ubuntu/Debian: `sudo add-apt-repository ppa:deadsnakes/ppa && sudo apt install python3.14 python3.14-venv`
111
117
  - Windows: [python.org/downloads](https://www.python.org/downloads/)
112
118
 
113
- `vectr start` returns immediately. Indexing runs in the background — run `vectr status` to check progress. On first run the embedding model downloads once (~440 MB). Restart your AI code editor once to pick up the new MCP config.
119
+ `vectr start` returns immediately. Indexing runs in the background — run `vectr status` to check progress. On first run the embedding model downloads once (~290 MB). Restart your AI code editor once to pick up the new MCP config.
114
120
 
115
121
  **Docker (CI/servers)**
116
122
 
@@ -290,7 +296,7 @@ HTTP routes (Flask/FastAPI decorators, Express `app.get()`, Spring `@GetMapping`
290
296
 
291
297
  | | Cost |
292
298
  |---|---|
293
- | Embedding model | $0.00 — one-time ~440 MB download, cached at `~/.cache/vectr/` |
299
+ | Embedding model | $0.00 — one-time ~290 MB download, cached at `~/.cache/vectr/` |
294
300
  | Re-index (10k files, first run) | ~10 min on CPU; <5 sec on subsequent runs (mtime cache) |
295
301
  | Incremental re-index per changed file | ~0.5 sec |
296
302
  | vectr_search / vectr_recall | $0.00 — local inference only |
@@ -299,15 +305,52 @@ HTTP routes (Flask/FastAPI decorators, Express `app.get()`, Spring `@GetMapping`
299
305
 
300
306
  ## Security
301
307
 
302
- Vectr v1 is designed for a **solo developer on a personal machine**.
303
-
304
- - MCP server binds to `127.0.0.1` only not reachable from other hosts
305
- - CORS restricted to localhost origins
306
- - Each workspace gets its own isolated DB directory, port, and process
307
- - No API key authentication in v1 — any local process can query
308
- - Index and notes persist locally in `~/.cache/vectr/`
309
-
310
- Multi-user, authentication, and encryption at rest are out of scope for v1.
308
+ The default is unchanged and stays the headline: **local, no API key, zero
309
+ config** — a solo developer on a personal machine. Out of the box, the daemon
310
+ binds to `127.0.0.1` only, CORS is restricted to localhost origins, each
311
+ workspace gets its own isolated DB directory, port, and process (owner-only
312
+ `0700` on POSIX), and the index and notes persist locally in `~/.cache/vectr/`.
313
+
314
+ Everything below is **opt-in**; enabling nothing changes nothing.
315
+
316
+ **Authentication** set `VECTR_API_KEY` and every request to `/v1/*` and
317
+ `/mcp` must carry it (`X-Api-Key: <key>` or `Authorization: Bearer <key>`;
318
+ constant-time comparison; `/v1/health` stays open for liveness probes).
319
+ Generate a key with `vectr key`. When the key is set at start time, the editor
320
+ MCP configs vectr writes include the header automatically. Those files
321
+ (`.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`) then hold the key in
322
+ plaintext — treat them as secrets and keep them out of shared or public
323
+ version control.
324
+
325
+ **Encryption at rest** — set `VECTR_ENCRYPT_KEY` (or store a passphrase in the
326
+ OS keychain: service `vectr`, username `encrypt-key`; requires
327
+ `pip install vectr[encryption]`) and note content, note titles, and snapshot
328
+ payloads are encrypted (Fernet, PBKDF2-derived key). Honest boundary: the
329
+ **code index is not encrypted** — the search engine needs readable chunk text
330
+ and vectors; protect it with OS full-disk encryption. Note tags/metadata stay
331
+ plaintext, and note embedding vectors (a lossy projection of note text) are
332
+ kept for semantic recall unless you set
333
+ `VECTR_ENCRYPT_DISABLE_NOTE_VECTORS=1`.
334
+
335
+ **Retention and audit** — notes are kept until you delete them; set
336
+ `VECTR_NOTES_TTL_DAYS` to auto-purge older notes at startup.
337
+ `vectr_forget(all=true)` / `vectr forget --all` delete notes, snapshots, and
338
+ note vectors — everything means everything. Set `VECTR_AUDIT_LOG=<path>` for a
339
+ rotating local log of index/search/remember/recall events (off by default; it
340
+ records query text — that is its purpose — and is never transmitted). Full
341
+ policy: [docs/data-handling.md](docs/data-handling.md).
342
+
343
+ **Team mode (shared instance)** — one central daemon can serve a team on one
344
+ repo: `VECTR_API_KEY=<key> vectr start --host 0.0.0.0` on the server (a
345
+ non-loopback bind **refuses to start without a key**), then
346
+ `vectr connect --url http://<host>:<port> --api-key <key> --label <you>` on
347
+ each client to point the editor at it. Working memory is shared: a note one
348
+ agent stores, every connected agent can recall; `--label` attributes notes and
349
+ audit lines. Plain limits: one shared key means every holder is an equal,
350
+ trusted peer (no roles, no per-user permissions); the server operator can read
351
+ everything; search results reference the **server's** checkout, which may
352
+ differ from your local tree; vectr speaks plain HTTP — put TLS at a reverse
353
+ proxy or tunnel if the network isn't trusted.
311
354
 
312
355
  ---
313
356
 
@@ -0,0 +1,33 @@
1
+ """Filesystem permission helpers for vectr's local state directories.
2
+
3
+ On a shared host, vectr's cache and state directories hold a plaintext code
4
+ index and working-memory notes. Restricting them to owner-only (0700) stops
5
+ other OS users on the same machine from reading them at the filesystem level —
6
+ the concrete defense for the scope-isolation model on multi-user hosts.
7
+
8
+ Best-effort and POSIX-scoped: on Windows, os.chmod cannot express a POSIX 0700
9
+ mode, so the chmod is effectively a near-no-op there and full-disk encryption /
10
+ NTFS ACLs are the right tool instead. A permission-tightening failure must never
11
+ stop the daemon, so every call here is guarded.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+
18
+ def secure_dir(path: str | Path, mode: int = 0o700) -> Path:
19
+ """Create `path` (and parents) if needed, then restrict it to `mode`.
20
+
21
+ Idempotent and safe to call on every startup; never raises. Returns the
22
+ Path so callers can chain. Tightens pre-existing directories too.
23
+ """
24
+ p = Path(path)
25
+ try:
26
+ p.mkdir(parents=True, exist_ok=True)
27
+ except OSError:
28
+ return p
29
+ try:
30
+ p.chmod(mode)
31
+ except OSError:
32
+ pass # e.g. Windows, or a dir we do not own — best effort only
33
+ return p
@@ -53,7 +53,10 @@ class InstanceRegistry:
53
53
  return {}
54
54
 
55
55
  def _write(self, data: dict[str, Any]) -> None:
56
- self._path.parent.mkdir(parents=True, exist_ok=True)
56
+ # ~/.vectr holds the instance registry, logs, and (opt-in) audit log —
57
+ # owner-only on POSIX hosts (see agent/fs_permissions.py).
58
+ from agent.fs_permissions import secure_dir
59
+ secure_dir(self._path.parent)
57
60
  tmp = self._path.with_suffix(".tmp")
58
61
  tmp.write_text(json.dumps(data, indent=2))
59
62
  os.rename(tmp, self._path)
@@ -36,6 +36,8 @@ from __future__ import annotations
36
36
  from agent.working_context_store._audit import (
37
37
  _get_audit_logger,
38
38
  audit,
39
+ reset_audit_client,
40
+ set_audit_client,
39
41
  )
40
42
 
41
43
  # Types and constants
@@ -61,6 +63,8 @@ __all__ = [
61
63
  # Audit
62
64
  "_get_audit_logger",
63
65
  "audit",
66
+ "set_audit_client",
67
+ "reset_audit_client",
64
68
  # Types and constants
65
69
  "DEFAULT_KIND",
66
70
  "VALID_KINDS",
@@ -0,0 +1,102 @@
1
+ """Opt-in rotating audit logger for vectr working-context and index events.
2
+
3
+ OFF BY DEFAULT. Enable by setting VECTR_AUDIT_LOG to a file path; when unset,
4
+ nothing is recorded (a NullHandler). When enabled, logs remember/recall/forget/
5
+ purge/index/search events to that local file — never transmitted anywhere.
6
+ Rotates at 10 MB; keeps 3 backups.
7
+
8
+ In team mode, each line optionally carries a `client=<label>` attribution taken
9
+ from the connecting client's X-Vectr-Client header (see set_audit_client), so
10
+ the operator can see what each client indexed/recalled — still a local file on
11
+ the server host only.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import logging.handlers
17
+ import os
18
+ import threading
19
+ from contextvars import ContextVar
20
+ from pathlib import Path
21
+
22
+ # Guards lazy handler initialization: without it, two threads auditing
23
+ # concurrently can both see an empty handler list and both attach a file
24
+ # handler, duplicating every subsequent line.
25
+ _init_lock = threading.Lock()
26
+
27
+
28
+ # Per-request client attribution label (team mode). Set at the MCP dispatch
29
+ # boundary from the X-Vectr-Client header; read by audit() so every event a
30
+ # request triggers is attributed. A ContextVar (not a global) so concurrent
31
+ # requests never bleed each other's label.
32
+ _audit_client: ContextVar[str] = ContextVar("vectr_audit_client", default="")
33
+
34
+
35
+ def set_audit_client(label: str):
36
+ """Set the client-attribution label for audit lines emitted on this task.
37
+ Returns the ContextVar token so the caller can reset() it afterwards."""
38
+ return _audit_client.set(label or "")
39
+
40
+
41
+ def reset_audit_client(token) -> None:
42
+ """Restore the previous client-attribution label."""
43
+ try:
44
+ _audit_client.reset(token)
45
+ except Exception:
46
+ pass
47
+
48
+
49
+ def _get_audit_logger() -> logging.Logger:
50
+ """Return the vectr audit logger (lazy-initialised, singleton per process).
51
+
52
+ Disabled unless VECTR_AUDIT_LOG names a file path (opt-in)."""
53
+ name = "vectr.audit"
54
+ log = logging.getLogger(name)
55
+ if log.handlers:
56
+ return log
57
+
58
+ with _init_lock:
59
+ if log.handlers: # another thread initialized while we waited
60
+ return log
61
+ return _init_audit_logger(log)
62
+
63
+
64
+ def _init_audit_logger(log: logging.Logger) -> logging.Logger:
65
+ """Attach the configured handler to `log` (caller holds _init_lock)."""
66
+ log_path_str = os.getenv("VECTR_AUDIT_LOG", "") # opt-in: unset ⇒ disabled
67
+ if not log_path_str:
68
+ log.addHandler(logging.NullHandler())
69
+ return log
70
+
71
+ log_path = Path(log_path_str)
72
+ # The audit file records query text — keep its directory owner-only (0700)
73
+ # on POSIX hosts, same as the rest of vectr's state.
74
+ try:
75
+ from agent.fs_permissions import secure_dir
76
+ secure_dir(log_path.parent)
77
+ except Exception:
78
+ log_path.parent.mkdir(parents=True, exist_ok=True)
79
+
80
+ handler = logging.handlers.RotatingFileHandler(
81
+ str(log_path), maxBytes=10 * 1024 * 1024, backupCount=3, encoding="utf-8",
82
+ )
83
+ handler.setFormatter(logging.Formatter("%(asctime)s %(message)s", datefmt="%Y-%m-%dT%H:%M:%SZ"))
84
+ log.addHandler(handler)
85
+ log.setLevel(logging.INFO)
86
+ log.propagate = False
87
+ return log
88
+
89
+
90
+ def audit(event: str, **kwargs) -> None:
91
+ """Write one audit log entry: `event key=val key=val …` (no-op when disabled).
92
+
93
+ A non-empty client-attribution label (set_audit_client) is appended as
94
+ `client=<label>`."""
95
+ try:
96
+ parts = [event] + [f"{k}={v}" for k, v in kwargs.items()]
97
+ client = _audit_client.get()
98
+ if client:
99
+ parts.append(f"client={client}")
100
+ _get_audit_logger().info(" ".join(parts))
101
+ except Exception:
102
+ pass # audit failures must never crash the main path
@@ -48,9 +48,28 @@ class _NoteEncryptor:
48
48
  return stored
49
49
 
50
50
 
51
+ def _key_from_keyring() -> str:
52
+ """Read the encryption passphrase from the OS keychain, if the optional
53
+ `keyring` dependency is installed and a value is stored under
54
+ service "vectr", username "encrypt-key". Returns "" on any failure —
55
+ keychain sourcing is a best-effort convenience, never a hard requirement.
56
+ """
57
+ try:
58
+ import keyring # optional dependency (pip install vectr[encryption])
59
+ except Exception:
60
+ return ""
61
+ try:
62
+ return keyring.get_password("vectr", "encrypt-key") or ""
63
+ except Exception:
64
+ return ""
65
+
66
+
51
67
  def _build_encryptor() -> _NoteEncryptor | None:
52
- """Return a _NoteEncryptor if VECTR_ENCRYPT_KEY is set, else None."""
53
- key = os.getenv("VECTR_ENCRYPT_KEY", "")
68
+ """Return a _NoteEncryptor when an encryption passphrase is available, else
69
+ None (encryption off — the default). Sourcing precedence: the
70
+ VECTR_ENCRYPT_KEY environment variable wins; if it is unset, the OS keychain
71
+ is consulted (env or OS keychain, per the security design)."""
72
+ key = os.getenv("VECTR_ENCRYPT_KEY", "") or _key_from_keyring()
54
73
  return _NoteEncryptor(key) if key else None
55
74
 
56
75
 
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import json
7
7
  import logging
8
+ import os
8
9
  import sqlite3
9
10
  import time
10
11
  from pathlib import Path
@@ -30,6 +31,11 @@ _NOTES_EMBED_MODEL_KEY = "embed_model"
30
31
  # embed_fn per call during a one-time startup migration.
31
32
  _NOTES_REEMBED_BATCH_SIZE = 256
32
33
 
34
+ # SQLite busy-wait for a contended write lock (team mode: concurrent clients +
35
+ # the CLI can share one workspace's notes DB). Intentionally NOT in config.yaml
36
+ # — a robustness/timeout knob, same category as the throughput constants above.
37
+ _SQLITE_BUSY_TIMEOUT_S = 5.0
38
+
33
39
 
34
40
  class WorkingContextStore:
35
41
  """
@@ -80,7 +86,16 @@ class WorkingContextStore:
80
86
  self._embed_query_fn = embed_query_fn or embed_fn # query-mode embed, defaults to embed_fn
81
87
  self._embed_model = embed_model # current model name, for the embed-model stamp/migration
82
88
  self._notes_col = None
83
- if embed_fn is not None and notes_chroma_client is not None:
89
+ # Strict encryption posture (VECTR_ENCRYPT_DISABLE_NOTE_VECTORS): when
90
+ # encryption is on, the note embedding vectors are a lossy plaintext
91
+ # projection of note content living in the Chroma store. Setting this
92
+ # omits them entirely — recall falls back to lexical SQL LIKE — so no
93
+ # representation of note content leaves the encrypted SQLite column.
94
+ strict_no_vectors = (
95
+ self._encryptor is not None
96
+ and os.getenv("VECTR_ENCRYPT_DISABLE_NOTE_VECTORS", "") == "1"
97
+ )
98
+ if embed_fn is not None and notes_chroma_client is not None and not strict_no_vectors:
84
99
  try:
85
100
  self._notes_col = notes_chroma_client.get_or_create_collection(
86
101
  name="working_memory",
@@ -101,9 +116,15 @@ class WorkingContextStore:
101
116
  )
102
117
 
103
118
  def _conn(self) -> sqlite3.Connection:
104
- conn = sqlite3.connect(str(self._db_path))
119
+ conn = sqlite3.connect(str(self._db_path), timeout=_SQLITE_BUSY_TIMEOUT_S)
105
120
  conn.row_factory = sqlite3.Row
106
121
  conn.execute("PRAGMA journal_mode=WAL")
122
+ # Team mode: several clients (and the CLI) can hit one workspace's notes
123
+ # DB concurrently. WAL allows concurrent readers + one writer; busy_timeout
124
+ # makes a would-be second writer wait for the lock instead of immediately
125
+ # raising "database is locked". note_id is AUTOINCREMENT, so IDs stay
126
+ # unique under concurrent inserts once writes are serialized by the lock.
127
+ conn.execute(f"PRAGMA busy_timeout={int(_SQLITE_BUSY_TIMEOUT_S * 1000)}")
107
128
  return conn
108
129
 
109
130
  def _init_db(self) -> None:
@@ -214,7 +235,15 @@ class WorkingContextStore:
214
235
  if stripped:
215
236
  title = stripped[:80]
216
237
  break
217
- stored_content = self._encryptor.encrypt(content) if self._encryptor else content
238
+ # Encrypt BOTH content and the (possibly content-derived) title: the
239
+ # default title is the first content line, so a plaintext title column
240
+ # would leak the very text encryption is meant to protect.
241
+ if self._encryptor:
242
+ stored_content = self._encryptor.encrypt(content)
243
+ stored_title = self._encryptor.encrypt(title)
244
+ else:
245
+ stored_content = content
246
+ stored_title = title
218
247
 
219
248
  with self._conn() as conn:
220
249
  # conflict resolution: if another note anchors the same code block, supersede it
@@ -241,7 +270,7 @@ class WorkingContextStore:
241
270
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1.0, ?, 1.0, ?, NULL, ?, ?)
242
271
  """,
243
272
  (workspace, stored_content, tags_json, priority, kind, now, now, session_id,
244
- author_id, now, code_hash, title),
273
+ author_id, now, code_hash, stored_title),
245
274
  )
246
275
  note_id = cur.lastrowid
247
276
 
@@ -751,11 +780,17 @@ class WorkingContextStore:
751
780
  return row[0] if row else 0
752
781
 
753
782
  def forget_all(self, workspace: str) -> int:
754
- """Clear all notes for a workspace."""
783
+ """Clear all notes AND snapshots for a workspace.
784
+
785
+ Snapshots embed full note contents in their payload, so a purge that
786
+ deleted only the notes table would silently keep every note's text
787
+ alive in `snapshots` — "delete everything" must mean everything,
788
+ including the note embedding vectors in the Chroma collection."""
755
789
  with self._conn() as conn:
756
790
  deleted = conn.execute(
757
791
  "DELETE FROM notes WHERE workspace = ?", (workspace,)
758
792
  ).rowcount
793
+ conn.execute("DELETE FROM snapshots WHERE workspace = ?", (workspace,))
759
794
  if deleted > 0 and self._notes_col is not None:
760
795
  try:
761
796
  existing_ids = self._notes_col.get(include=[])["ids"]
@@ -767,13 +802,23 @@ class WorkingContextStore:
767
802
  return deleted
768
803
 
769
804
  def forget_all_workspaces(self) -> int:
770
- """Delete ALL notes across ALL workspaces in this SQLite file.
805
+ """Delete ALL notes, snapshots, and note vectors across ALL workspaces
806
+ in this SQLite file.
771
807
 
772
- Used by `vectr forget --all` to give a global clean slate.
808
+ Used by `vectr forget --all` to give a global clean slate — the same
809
+ "everything means everything" contract as forget_all above.
773
810
  Audit entry logged per deletion.
774
811
  """
775
812
  with self._conn() as conn:
776
813
  deleted = conn.execute("DELETE FROM notes").rowcount
814
+ conn.execute("DELETE FROM snapshots")
815
+ if self._notes_col is not None:
816
+ try:
817
+ existing_ids = self._notes_col.get(include=[])["ids"]
818
+ if existing_ids:
819
+ self._notes_col.delete(ids=existing_ids)
820
+ except Exception:
821
+ pass
777
822
  audit("FORGET_ALL_WORKSPACES", deleted=deleted)
778
823
  return deleted
779
824
 
@@ -846,6 +891,11 @@ class WorkingContextStore:
846
891
  "retrieved_chunks": retrieved_chunks or [],
847
892
  "session_id": session_id,
848
893
  })
894
+ # The payload embeds decrypted note contents (recall() decrypts), so a
895
+ # plaintext snapshots table would bypass note encryption entirely.
896
+ # Encrypt the whole payload under the same key as note content.
897
+ if self._encryptor:
898
+ payload = self._encryptor.encrypt(payload)
849
899
  with self._conn() as conn:
850
900
  conn.execute(
851
901
  "INSERT OR REPLACE INTO snapshots (snapshot_id, workspace, label, payload, created_at) VALUES (?, ?, ?, ?, ?)",
@@ -868,7 +918,18 @@ class WorkingContextStore:
868
918
  ).fetchone()
869
919
  if row is None:
870
920
  return None
871
- return json.loads(row["payload"])
921
+ payload = row["payload"]
922
+ if self._encryptor:
923
+ # Tolerant decrypt: snapshots written before payload encryption (or
924
+ # before a key was configured) pass through unchanged.
925
+ payload = self._encryptor.decrypt(payload)
926
+ try:
927
+ return json.loads(payload)
928
+ except (json.JSONDecodeError, TypeError):
929
+ # Ciphertext without the (correct) key configured — unreadable by
930
+ # design; treat as not restorable rather than crashing the caller.
931
+ logger.warning("snapshot %s payload is not readable (encrypted with a different key?)", snapshot_id)
932
+ return None
872
933
 
873
934
  # ------------------------------------------------------------------
874
935
  # Eviction hints — which chunks can vectr re-retrieve in <50ms?
@@ -995,9 +1056,13 @@ class WorkingContextStore:
995
1056
 
996
1057
  def _row_to_note(self, row: sqlite3.Row) -> WorkingNote:
997
1058
  content = row["content"]
1059
+ keys = row.keys()
1060
+ title = row["title"] if "title" in keys else ""
998
1061
  if self._encryptor:
999
1062
  content = self._encryptor.decrypt(content)
1000
- keys = row.keys()
1063
+ # Tolerant decrypt: titles written before title-encryption (or before
1064
+ # encryption was enabled at all) are returned unchanged.
1065
+ title = self._encryptor.decrypt(title)
1001
1066
  return WorkingNote(
1002
1067
  note_id=row["note_id"],
1003
1068
  workspace=row["workspace"],
@@ -1017,7 +1082,7 @@ class WorkingContextStore:
1017
1082
  code_hash=row["code_hash"] if "code_hash" in keys else "",
1018
1083
  superseded_by=row["superseded_by"] if "superseded_by" in keys else None,
1019
1084
  superseded_at=row["superseded_at"] if "superseded_at" in keys else None,
1020
- title=row["title"] if "title" in keys else "",
1085
+ title=title,
1021
1086
  )
1022
1087
 
1023
1088
  def format_notes_for_llm(
@@ -1,6 +1,7 @@
1
1
  """FastAPI application entry point. Port: 8765."""
2
2
  from __future__ import annotations
3
3
 
4
+ import hmac
4
5
  import logging
5
6
  import os
6
7
 
@@ -56,7 +57,7 @@ app = FastAPI(
56
57
  "(search/locate/trace/map) plus a working-memory store (remember/recall/"
57
58
  "snapshot) that survives context compaction and session boundaries."
58
59
  ),
59
- version="1.0.2",
60
+ version="1.0.3",
60
61
  lifespan=lifespan,
61
62
  )
62
63
 
@@ -65,15 +66,16 @@ app.add_middleware(
65
66
  allow_origins=["http://localhost", "http://127.0.0.1"],
66
67
  allow_origin_regex=r"http://(localhost|127\.0\.0\.1)(:\d+)?",
67
68
  allow_methods=["GET", "POST"],
68
- allow_headers=["Content-Type", "Authorization", "X-Api-Key"],
69
+ allow_headers=["Content-Type", "Authorization", "X-Api-Key", "X-Vectr-Client", "X-Session-ID"],
69
70
  )
70
71
 
71
72
 
72
73
  # Optional API key enforcement — only active when VECTR_API_KEY is set.
73
74
  # Solo dev / personal machine: no key required by default.
74
- # For shared/enterprise use: set VECTR_API_KEY and communicate it to IDE users.
75
- # Middleware reads the env var at request time (not import time) so the key can
76
- # be changed without restarting and tests can patch os.environ cleanly.
75
+ # For shared-host or team (central instance) use: set VECTR_API_KEY and share it
76
+ # with connecting clients. Middleware reads the env var at request time (not
77
+ # import time) so the key can be changed without restarting and tests can patch
78
+ # os.environ cleanly.
77
79
  @app.middleware("http")
78
80
  async def require_api_key(request: Request, call_next) -> Response:
79
81
  api_key = os.getenv("VECTR_API_KEY", "")
@@ -89,7 +91,10 @@ async def require_api_key(request: Request, call_next) -> Response:
89
91
  request.headers.get("X-Api-Key", "")
90
92
  or request.headers.get("Authorization", "").removeprefix("Bearer ").strip()
91
93
  )
92
- if provided != api_key:
94
+ # Constant-time comparison — never a plain `==`/`!=`, which leaks the key
95
+ # length and prefix via response timing. compare_digest requires both
96
+ # operands be the same type; encode to bytes so a unicode key is handled.
97
+ if not hmac.compare_digest(provided.encode("utf-8"), api_key.encode("utf-8")):
93
98
  return Response(
94
99
  content='{"error":"unauthorized","detail":"Set X-Api-Key or Authorization: Bearer <VECTR_API_KEY>"}',
95
100
  status_code=401,