contexta-ai 2.2.0__tar.gz → 2.2.1__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.
- contexta_ai-2.2.1/.github/workflows/ci.yml +73 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/.github/workflows/publish-pypi.yml +3 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/CHANGELOG.md +22 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/CONTRIBUTING.md +1 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/PKG-INFO +3 -3
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/README.md +2 -2
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/README.pt-BR.md +2 -2
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/SECURITY.md +4 -3
- contexta_ai-2.2.1/assets/dark.png +0 -0
- contexta_ai-2.2.1/assets/empty.png +0 -0
- contexta_ai-2.2.1/assets/memory.png +0 -0
- contexta_ai-2.2.1/assets/result.png +0 -0
- contexta_ai-2.2.1/assets/white.png +0 -0
- contexta_ai-2.2.1/contexta.py +20 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/__init__.py +1 -1
- contexta_ai-2.2.1/contexta_app/__main__.py +4 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/context_engine.py +2 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/engine/fingerprint.py +3 -3
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/engine/focus.py +1 -1
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/engine/insight.py +39 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/engine/models.py +1 -0
- contexta_ai-2.2.0/contexta.py → contexta_ai-2.2.1/contexta_app/entry.py +3 -1
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/graph.py +28 -2
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/graph_cli.py +2 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/mcp_server.py +3 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/scanner.py +5 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/theme.py +6 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/ui.py +61 -26
- contexta_ai-2.2.1/docs/images/error.png +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/packaging/version_info.txt +4 -4
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/packaging/windows/contexta.iss +1 -1
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/pyproject.toml +2 -3
- contexta_ai-2.2.1/requirements-dev.txt +6 -0
- contexta_ai-2.2.1/scripts/smoke_wheel.py +101 -0
- contexta_ai-2.2.1/tests/test_fingerprint_generic.py +29 -0
- contexta_ai-2.2.1/tests/test_graph_cap.py +50 -0
- contexta_ai-2.2.1/tests/test_gui_budget_and_layout.py +100 -0
- contexta_ai-2.2.1/tests/test_mcp_setup.py +69 -0
- contexta_ai-2.2.1/tests/test_packaging.py +49 -0
- contexta_ai-2.2.1/tests/test_python_symbols.py +66 -0
- contexta_ai-2.2.1/tests/test_symlink_dedup.py +47 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_theme.py +11 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_version_and_brand.py +2 -1
- contexta_ai-2.2.0/assets/dark.png +0 -0
- contexta_ai-2.2.0/assets/empty.png +0 -0
- contexta_ai-2.2.0/assets/memory.png +0 -0
- contexta_ai-2.2.0/assets/result.png +0 -0
- contexta_ai-2.2.0/assets/white.png +0 -0
- contexta_ai-2.2.0/docs/images/error.png +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/.github/workflows/build-linux.yml +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/.gitignore +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/LICENSE +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/action.yml +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/assets/action.png +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/assets/assets/dark.png +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/assets/assets/white.png +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/assets/brand_assets.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/assets/cli.png +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/assets/graph.png +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/assets/icon.ico +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/assets/icon.png +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/assets/install.png +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/assets/logo.png +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/assets/logo.svg +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/assets/logo@2x.png +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/benchmarks/README.md +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/benchmarks/bench.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/benchmarks/results.json +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/benchmarks/results.md +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/build.bat +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/build.sh +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/ai_client.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/budgets.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/cache.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/ci_report.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/cli.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/cli_style.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/config.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/embedded_icon_data.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/engine/__init__.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/engine/blobs.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/engine/constants.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/engine/excerpts.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/engine/manifests.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/engine/pipeline.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/engine/relations.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/engine/risk.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/engine/roles.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/engine/scoring.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/engine/selection.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/engine/summary.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/engine/surfaces.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/install.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/memory.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/project_meta.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/redaction.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/renderer.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/safety.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/selector.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/symbols.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/syntax.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_app/utils.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/contexta_mcp.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/docs/contexta-1.6-dependency-proposal.md +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/docs/contexta.toml.example +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/docs/github-action.md +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/docs/graph.md +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/mdcodebrief.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/packaging/cli_screenshot.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/packaging/gui_screenshots.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/packaging/linux/install-contexta.sh +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/packaging/make_brand_assets.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/packaging/pyinstaller/contexta-onefile.spec +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/packaging/pyinstaller/contexta-safe.spec +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/packaging/pyinstaller/contexta.spec +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/packaging/pyinstaller/mdcodebrief.spec +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/packaging/sync_version.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/requirements-build.txt +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/requirements.txt +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/__init__.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/conftest.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_action.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_adoption.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_benchmark.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_budget.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_cli_stdout.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_cli_style.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_context_engine.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_descriptions.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_engine_layout.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_graph.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_graph_cli.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_install.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_mcp_v2.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_memory.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_memory_ui.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_pack_graph.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_performance.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_polish.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_renderer.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_scanner.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_security.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_ui.py +0 -0
- {contexta_ai-2.2.0 → contexta_ai-2.2.1}/tests/test_version.py +0 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
concurrency:
|
|
12
|
+
group: ci-${{ github.ref }}
|
|
13
|
+
cancel-in-progress: true
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
test:
|
|
17
|
+
name: tests (${{ matrix.os }}, Python ${{ matrix.python }})
|
|
18
|
+
runs-on: ${{ matrix.os }}
|
|
19
|
+
strategy:
|
|
20
|
+
fail-fast: false
|
|
21
|
+
matrix:
|
|
22
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
23
|
+
python: ["3.11", "3.13"]
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v4
|
|
26
|
+
with:
|
|
27
|
+
fetch-depth: 0 # the graph and `--for review` read git history
|
|
28
|
+
|
|
29
|
+
- uses: actions/setup-python@v5
|
|
30
|
+
with:
|
|
31
|
+
python-version: ${{ matrix.python }}
|
|
32
|
+
cache: pip
|
|
33
|
+
cache-dependency-path: |
|
|
34
|
+
pyproject.toml
|
|
35
|
+
requirements-dev.txt
|
|
36
|
+
|
|
37
|
+
- name: Linux GUI dependencies
|
|
38
|
+
if: runner.os == 'Linux'
|
|
39
|
+
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends xvfb python3-tk
|
|
40
|
+
|
|
41
|
+
- name: Install
|
|
42
|
+
run: python -m pip install --upgrade pip && python -m pip install -e . -r requirements-dev.txt
|
|
43
|
+
|
|
44
|
+
- name: Test (Linux, virtual display)
|
|
45
|
+
if: runner.os == 'Linux'
|
|
46
|
+
run: xvfb-run -a python -m pytest -q
|
|
47
|
+
|
|
48
|
+
- name: Test
|
|
49
|
+
if: runner.os != 'Linux'
|
|
50
|
+
run: python -m pytest -q
|
|
51
|
+
|
|
52
|
+
wheel:
|
|
53
|
+
name: wheel installs and runs
|
|
54
|
+
runs-on: ubuntu-latest
|
|
55
|
+
steps:
|
|
56
|
+
- uses: actions/checkout@v4
|
|
57
|
+
- uses: actions/setup-python@v5
|
|
58
|
+
with:
|
|
59
|
+
python-version: "3.11"
|
|
60
|
+
- name: Build the wheel, install it in a clean virtualenv, run the CLI and the MCP server
|
|
61
|
+
run: python -m pip install --upgrade pip build && python scripts/smoke_wheel.py
|
|
62
|
+
|
|
63
|
+
lint:
|
|
64
|
+
name: ruff (informational)
|
|
65
|
+
runs-on: ubuntu-latest
|
|
66
|
+
continue-on-error: true # many pre-existing findings; shown, not enforced yet
|
|
67
|
+
steps:
|
|
68
|
+
- uses: actions/checkout@v4
|
|
69
|
+
- uses: actions/setup-python@v5
|
|
70
|
+
with:
|
|
71
|
+
python-version: "3.11"
|
|
72
|
+
- run: python -m pip install ruff
|
|
73
|
+
- run: ruff check contexta_app contexta.py contexta_mcp.py scripts tests --output-format=github
|
|
@@ -6,6 +6,28 @@ Versioning: [Semantic Versioning](https://semver.org/)
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [2.2.1] - 2026-10-24
|
|
10
|
+
|
|
11
|
+
A fix release. **If you installed 2.2.0 or 2.0.0 from PyPI, `contexta` did not start** (`ModuleNotFoundError: No module named 'contexta'`); upgrade.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **The PyPI wheel works.** The console script lived in a top-level module the wheel did not include. `main()` is now `contexta_app/entry.py`, `contexta.py` is a thin launcher, and `python -m contexta_app` works. `scripts/smoke_wheel.py` builds the wheel, installs it in a clean virtualenv and checks `contexta --version`, a pack and `contexta serve` over MCP stdio; the publish workflow runs it before uploading
|
|
15
|
+
- `contexta serve --help` printed "unknown option '--help'" and now prints usage
|
|
16
|
+
- The desktop app's editor MCP setup raised `NameError: messagebox` after writing the config. The config it writes now works from the packaged app (`<exe> serve`) and from a pip install (`python -m contexta_app serve`), and an editor config that cannot be parsed is copied to `.bak` instead of being replaced
|
|
17
|
+
- A directory symlink inside the project (`self -> .`) was followed until the 2000-file cap, listing the same files thousands of times. Directory symlinks that stay inside the project are no longer walked; file symlinks and the rule that links leaving the project are ignored are unchanged
|
|
18
|
+
- The project graph shared the 2000-file cap of a pack and hid it, so on a larger repo `graph impact` answered "(none found in graph)". The graph now has its own limit (50000, or `CONTEXTA_GRAPH_MAX_FILES`), and `graph update` and `graph status` warn when it is reached
|
|
19
|
+
- Any desktop Python project was described as "Scan local repositories and generate curated context packs", Contexta's own purpose
|
|
20
|
+
- Python files are now read with `ast` whether or not the optional tree-sitter is installed. Without it, `from .util import slugify` was seen as importing `.util` only, and with it as importing `slugify` as if it were a module (relative imports never resolved to a local file). `--focus slugify` therefore selected different files depending on what happened to be installed. Modules and imported names are now kept apart, and `--focus` matches both
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- **Token budget** box in the desktop app (`8000`, `8,000` or `32k`), using the same fitter as `--budget`
|
|
24
|
+
- `border_strong` in the theme: inputs, ghost buttons, combo boxes and switch tracks reach 3:1 against their background in both themes
|
|
25
|
+
- CI (`.github/workflows/ci.yml`): the test suite on Linux, Windows and macOS, a wheel smoke test, and ruff in non-blocking mode
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- Scan options in the desktop app use one column, so no label is cut off. Entry and Text widgets no longer draw the default white focus ring
|
|
29
|
+
- SECURITY.md and the README now say what touches the network: the one-time download of the `cl100k_base` token encoding, the optional `--ask`, and the GitHub Action's PR comment
|
|
30
|
+
|
|
9
31
|
## [2.2.0] - 2026-10-24
|
|
10
32
|
|
|
11
33
|
Contexta now remembers your project. A local graph of files, symbols, tests and git history answers
|
|
@@ -8,6 +8,7 @@ Thank you for considering a contribution!
|
|
|
8
8
|
- Optional packaging tools such as **Nuitka** or **PyInstaller** must stay out of runtime code paths
|
|
9
9
|
- Python **3.10+** syntax is acceptable
|
|
10
10
|
- Every PR must keep the project runnable with `python contexta.py` after `python -m pip install -r requirements.txt`
|
|
11
|
+
- Tests: `python -m pip install -e . -r requirements-dev.txt`, then `python -m pytest -q` (on Linux add `xvfb-run -a` for the GUI tests; the tests that need Pillow or PyYAML skip when those are missing)
|
|
11
12
|
|
|
12
13
|
## How to contribute
|
|
13
14
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: contexta-ai
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.1
|
|
4
4
|
Summary: AI-native codebase context engine. Scans projects and serves intelligent context packs to Claude Code, Cursor, Windsurf, and any MCP-compatible tool.
|
|
5
5
|
Project-URL: Homepage, https://github.com/pablokaua03/contexta
|
|
6
6
|
Project-URL: Repository, https://github.com/pablokaua03/contexta
|
|
@@ -58,7 +58,7 @@ Contexta reads a codebase, keeps the files that matter for the task, fits them i
|
|
|
58
58
|
[](https://python.org)
|
|
59
59
|
[](LICENSE)
|
|
60
60
|
[]()
|
|
61
|
-
[]()
|
|
62
62
|
[]()
|
|
63
63
|
|
|
64
64
|
<br>
|
|
@@ -429,7 +429,7 @@ python -m pytest tests/ -k "not test_relation_score"
|
|
|
429
429
|
## Security and behavior
|
|
430
430
|
|
|
431
431
|
- Read-only, with one exception: Contexta never modifies your files. It does keep its project graph in `.contexta/` (names, signatures and relations, never file contents; it ignores itself with a `*` `.gitignore`). Turn it off with `--no-graph` or `graph = false`, move it with `CONTEXTA_GRAPH_DIR`
|
|
432
|
-
- No
|
|
432
|
+
- No telemetry. Packs, the graph and the MCP server work offline. The only network use: the first token count downloads the `cl100k_base` encoding once (cached by `tiktoken`; offline, Contexta falls back to a character-based estimate), the optional `--ask` calls go to the AI provider you configured, and the GitHub Action posts its PR comment to the GitHub API
|
|
433
433
|
- Scan limit of 2000 files by default, raised with `--max-files`
|
|
434
434
|
- Embedded binary/blob payloads are suppressed automatically
|
|
435
435
|
- Likely secrets are redacted in exports and in MCP `read_files` unless `--no-redact` is set. `--dry-run` shows what would be redacted
|
|
@@ -11,7 +11,7 @@ Contexta reads a codebase, keeps the files that matter for the task, fits them i
|
|
|
11
11
|
[](https://python.org)
|
|
12
12
|
[](LICENSE)
|
|
13
13
|
[]()
|
|
14
|
-
[]()
|
|
15
15
|
[]()
|
|
16
16
|
|
|
17
17
|
<br>
|
|
@@ -382,7 +382,7 @@ python -m pytest tests/ -k "not test_relation_score"
|
|
|
382
382
|
## Security and behavior
|
|
383
383
|
|
|
384
384
|
- Read-only, with one exception: Contexta never modifies your files. It does keep its project graph in `.contexta/` (names, signatures and relations, never file contents; it ignores itself with a `*` `.gitignore`). Turn it off with `--no-graph` or `graph = false`, move it with `CONTEXTA_GRAPH_DIR`
|
|
385
|
-
- No
|
|
385
|
+
- No telemetry. Packs, the graph and the MCP server work offline. The only network use: the first token count downloads the `cl100k_base` encoding once (cached by `tiktoken`; offline, Contexta falls back to a character-based estimate), the optional `--ask` calls go to the AI provider you configured, and the GitHub Action posts its PR comment to the GitHub API
|
|
386
386
|
- Scan limit of 2000 files by default, raised with `--max-files`
|
|
387
387
|
- Embedded binary/blob payloads are suppressed automatically
|
|
388
388
|
- Likely secrets are redacted in exports and in MCP `read_files` unless `--no-redact` is set. `--dry-run` shows what would be redacted
|
|
@@ -11,7 +11,7 @@ O Contexta lê o código, fica com os arquivos que importam para a tarefa, encai
|
|
|
11
11
|
[](https://python.org)
|
|
12
12
|
[](LICENSE)
|
|
13
13
|
[]()
|
|
14
|
-
[]()
|
|
15
15
|
[]()
|
|
16
16
|
|
|
17
17
|
<br>
|
|
@@ -366,7 +366,7 @@ python -m pytest tests/ -k "not test_relation_score"
|
|
|
366
366
|
## Segurança e comportamento
|
|
367
367
|
|
|
368
368
|
- Somente leitura, com uma exceção: o Contexta nunca modifica seus arquivos. Ele guarda o grafo do projeto em `.contexta/` (nomes, assinaturas e relações, nunca o conteúdo dos arquivos; ele se ignora com um `.gitignore` com `*`). Desligue com `--no-graph` ou `graph = false`, mude o local com `CONTEXTA_GRAPH_DIR`
|
|
369
|
-
- Sem telemetria
|
|
369
|
+
- Sem telemetria. Packs, grafo e servidor MCP funcionam offline. Único uso de rede: a primeira contagem de tokens baixa a codificação `cl100k_base` uma vez (guardada em cache pelo `tiktoken`; sem rede, o Contexta usa uma estimativa por caracteres), as chamadas opcionais de `--ask` vão para o provedor de IA que você configurou, e a GitHub Action publica o comentário do PR na API do GitHub
|
|
370
370
|
- Limite de 2000 arquivos por padrão, ajustável com `--max-files`
|
|
371
371
|
- Payloads binários/base64 suprimidos automaticamente
|
|
372
372
|
- Segredos prováveis são redatados nos exports e no `read_files` do MCP, a menos que `--no-redact` seja usado. `--dry-run` mostra o que seria redatado
|
|
@@ -21,10 +21,11 @@ You can expect a response within 7 days.
|
|
|
21
21
|
|
|
22
22
|
## Security notes
|
|
23
23
|
|
|
24
|
-
- `Contexta`
|
|
25
|
-
- It
|
|
24
|
+
- `Contexta` never modifies, deletes, or uploads your project files. It does write its own project graph to `.contexta/` (or `CONTEXTA_GRAPH_DIR`) unless `--no-graph` is set, and `contexta install` writes agent rules and MCP config files you ask for
|
|
25
|
+
- It sends no telemetry. Packs, the project graph and the MCP server do not need the network
|
|
26
|
+
- The network is used in three cases only: the first token count downloads the `cl100k_base` encoding once through `tiktoken` (cached afterwards; offline, Contexta falls back to a character-based estimate), the optional `--ask` feature sends the pack to the AI provider you configured, and the GitHub Action posts its pull request comment to the GitHub API
|
|
26
27
|
- It uses local runtime dependencies for parsing, token estimation, and repository analysis
|
|
27
28
|
- On some Linux distributions, `tkinter` may come from a system package such as `python3-tk`
|
|
28
29
|
- Windows builds use `Nuitka`; Linux packaging uses `PyInstaller`
|
|
29
|
-
-
|
|
30
|
+
- Analysis happens locally on your machine
|
|
30
31
|
- Exports replace likely secrets (API keys, private keys, tokens, connection-string passwords) with `[REDACTED]` before the pack is written. This is best-effort and does not make it safe to commit real credentials
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Contexta — AI-native codebase context engine.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
contexta serve [--root DIR] Start the MCP server (for Claude Code, Cursor, etc.).
|
|
6
|
+
Tools only read inside --root (repeatable), CONTEXTA_ROOT,
|
|
7
|
+
or the current directory
|
|
8
|
+
contexta graph <command> Ask the project graph: dependents, impact, hot files, repo map
|
|
9
|
+
contexta install <target> Write agent rules or MCP config (cursor, claude, copilot, mcp)
|
|
10
|
+
contexta <project> [opts] Generate a context pack (CLI mode)
|
|
11
|
+
contexta Launch the desktop GUI
|
|
12
|
+
contexta --configure-ai Set up AI API keys
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from contexta_app.entry import main
|
|
18
|
+
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
main()
|
|
@@ -124,10 +124,12 @@ from contexta_app.engine.relations import ( # noqa: F401
|
|
|
124
124
|
test_relation_score,
|
|
125
125
|
)
|
|
126
126
|
from contexta_app.engine.insight import ( # noqa: F401
|
|
127
|
+
_python_ast_symbols,
|
|
127
128
|
collect_file_signals,
|
|
128
129
|
extract_symbols,
|
|
129
130
|
flatten_tree,
|
|
130
131
|
make_file_insight,
|
|
132
|
+
python_imported_names,
|
|
131
133
|
)
|
|
132
134
|
from contexta_app.engine.roles import ( # noqa: F401
|
|
133
135
|
_CONTEXTA_MODULE_ROLES,
|
|
@@ -326,11 +326,9 @@ def infer_probable_purpose(project_type_label: str | None, domain_label: str | N
|
|
|
326
326
|
("Electron desktop application", "image_editing"): "Provide desktop image or asset-editing workflows through a web-powered desktop shell.",
|
|
327
327
|
("Go application", "task_management"): "Provide Go-based services or tooling for task, board, and workflow management operations.",
|
|
328
328
|
("Rust application", "graph_analysis"): "Analyze, transform, or serve graph and network data with performance-oriented Rust modules.",
|
|
329
|
-
("Desktop GUI + CLI developer tool", "developer_tooling"): "
|
|
329
|
+
("Desktop GUI + CLI developer tool", "developer_tooling"): "Provide developer tooling, automation, or repository-analysis workflows through a desktop GUI and a command-line entry point.",
|
|
330
330
|
("Desktop GUI + CLI developer tool", "image_editing"): "Provide desktop editing workflows for image or asset manipulation through GUI and command-driven tooling.",
|
|
331
331
|
}
|
|
332
|
-
if project_type_label == "Desktop GUI + CLI developer tool":
|
|
333
|
-
return "Scan local repositories and generate curated context packs for AI workflows."
|
|
334
332
|
if (project_type_label, domain_label) in purpose_by_pair:
|
|
335
333
|
return purpose_by_pair[(project_type_label, domain_label)]
|
|
336
334
|
|
|
@@ -418,6 +416,8 @@ def infer_probable_purpose(project_type_label: str | None, domain_label: str | N
|
|
|
418
416
|
return "Build JVM-based application logic using Scala and functional or object-oriented patterns."
|
|
419
417
|
if project_type_label == "PHP application":
|
|
420
418
|
return "Deliver web application functionality using PHP modules and associated server-side tooling."
|
|
419
|
+
if project_type_label == "Desktop GUI + CLI developer tool":
|
|
420
|
+
return "Provide a desktop application with a graphical interface and a command-line entry point."
|
|
421
421
|
if project_type_label == "Python application":
|
|
422
422
|
return "Organize Python modules, scripts, and supporting logic into a runnable application."
|
|
423
423
|
if project_type_label == "JavaScript/TypeScript application":
|
|
@@ -32,7 +32,7 @@ def compute_focus_raw(item: FileInsight, query: str) -> float:
|
|
|
32
32
|
score = 0.0
|
|
33
33
|
rel = item.relpath.as_posix().lower()
|
|
34
34
|
stem = item.path.stem.lower()
|
|
35
|
-
imports_blob = " ".join(imported.lower() for imported in item.imports)
|
|
35
|
+
imports_blob = " ".join(imported.lower() for imported in (*item.imports, *item.imported_names))
|
|
36
36
|
functions_blob = " ".join(func.lower() for func in item.functions)
|
|
37
37
|
classes_blob = " ".join(cls.lower() for cls in item.classes)
|
|
38
38
|
content_lower = item.content_lower
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import ast
|
|
5
6
|
import re
|
|
7
|
+
from functools import lru_cache
|
|
6
8
|
from pathlib import Path
|
|
7
9
|
from contexta_app.scanner import get_language, read_file_safe
|
|
8
10
|
from contexta_app.syntax import extract_symbols_with_treesitter
|
|
@@ -22,6 +24,7 @@ def make_file_insight(project_path: Path, path: Path) -> FileInsight:
|
|
|
22
24
|
content, truncated, total_line_count = read_file_safe(path)
|
|
23
25
|
lang = get_language(path)
|
|
24
26
|
functions, classes, imports = extract_symbols(content, lang)
|
|
27
|
+
imported_names = python_imported_names(content) if lang == "python" else []
|
|
25
28
|
external_imports = [item for item in imports if not item.startswith(".")]
|
|
26
29
|
return FileInsight(
|
|
27
30
|
path=path,
|
|
@@ -34,10 +37,46 @@ def make_file_insight(project_path: Path, path: Path) -> FileInsight:
|
|
|
34
37
|
functions=functions,
|
|
35
38
|
classes=classes,
|
|
36
39
|
imports=imports,
|
|
40
|
+
imported_names=imported_names,
|
|
37
41
|
external_imports=external_imports,
|
|
38
42
|
)
|
|
39
43
|
|
|
44
|
+
@lru_cache(maxsize=64)
|
|
45
|
+
def _python_ast_symbols(content: str) -> tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...], tuple[str, ...]] | None:
|
|
46
|
+
"""Top-level functions and classes, imported modules and imported names, from ``ast``.
|
|
47
|
+
|
|
48
|
+
Python is read with the standard library whether or not tree-sitter is installed, so a pack
|
|
49
|
+
does not depend on an optional package. Modules are written the way ``resolve_local_import``
|
|
50
|
+
expects them: ``pkg.core``, or leading dots for a relative import (``.util``).
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
tree = ast.parse(content)
|
|
54
|
+
except (SyntaxError, ValueError, RecursionError):
|
|
55
|
+
return None
|
|
56
|
+
functions = [n.name for n in tree.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
|
|
57
|
+
classes = [n.name for n in tree.body if isinstance(n, ast.ClassDef)]
|
|
58
|
+
modules: list[str] = []
|
|
59
|
+
names: list[str] = []
|
|
60
|
+
for node in ast.walk(tree):
|
|
61
|
+
if isinstance(node, ast.Import):
|
|
62
|
+
modules.extend(alias.name for alias in node.names)
|
|
63
|
+
elif isinstance(node, ast.ImportFrom):
|
|
64
|
+
modules.append("." * (node.level or 0) + (node.module or ""))
|
|
65
|
+
names.extend(alias.name for alias in node.names if alias.name != "*")
|
|
66
|
+
unique = lambda values, limit: tuple(dict.fromkeys(values))[:limit] # noqa: E731
|
|
67
|
+
return unique(functions, 12), unique(classes, 8), unique(modules, 24), unique(names, 40)
|
|
68
|
+
|
|
69
|
+
def python_imported_names(content: str) -> list[str]:
|
|
70
|
+
"""Names brought in with ``from x import name``, for focus matching ("slugify" finds its callers)."""
|
|
71
|
+
parsed = _python_ast_symbols(content)
|
|
72
|
+
return list(parsed[3]) if parsed else []
|
|
73
|
+
|
|
40
74
|
def extract_symbols(content: str, lang: str | None) -> tuple[list[str], list[str], list[str]]:
|
|
75
|
+
if lang == "python":
|
|
76
|
+
parsed = _python_ast_symbols(content)
|
|
77
|
+
if parsed:
|
|
78
|
+
return list(parsed[0]), list(parsed[1]), list(parsed[2])
|
|
79
|
+
|
|
41
80
|
syntax_symbols = extract_symbols_with_treesitter(content, lang)
|
|
42
81
|
if syntax_symbols:
|
|
43
82
|
functions, classes, imports = syntax_symbols
|
|
@@ -36,6 +36,7 @@ class FileInsight:
|
|
|
36
36
|
functions: list[str] = field(default_factory=list)
|
|
37
37
|
classes: list[str] = field(default_factory=list)
|
|
38
38
|
imports: list[str] = field(default_factory=list)
|
|
39
|
+
imported_names: list[str] = field(default_factory=list) # Python: names from `from x import name`
|
|
39
40
|
local_imports: list[str] = field(default_factory=list)
|
|
40
41
|
external_imports: list[str] = field(default_factory=list)
|
|
41
42
|
tags: set[str] = field(default_factory=set)
|
|
@@ -99,6 +99,7 @@ class UpdateStats:
|
|
|
99
99
|
seconds: float = 0.0
|
|
100
100
|
git_commits: int = 0
|
|
101
101
|
skipped_reason: str = ""
|
|
102
|
+
truncated: bool = False
|
|
102
103
|
|
|
103
104
|
def to_dict(self) -> dict:
|
|
104
105
|
return {k: (round(v, 3) if isinstance(v, float) else v) for k, v in self.__dict__.items()}
|
|
@@ -106,15 +107,31 @@ class UpdateStats:
|
|
|
106
107
|
def summary(self) -> str:
|
|
107
108
|
if self.skipped_reason:
|
|
108
109
|
return f"Graph not updated: {self.skipped_reason}"
|
|
109
|
-
|
|
110
|
+
text = (
|
|
110
111
|
f"Graph: {self.total} file(s), {self.added} new, {self.changed} changed, {self.removed} removed, "
|
|
111
112
|
f"{self.unchanged} unchanged, {self.symbols} symbols, {self.edges} edges ({self.seconds:.2f}s)"
|
|
112
113
|
)
|
|
114
|
+
if self.truncated:
|
|
115
|
+
text += f"\nWarning: the project has more than {self.total} files; only the first {self.total} were indexed. Raise CONTEXTA_GRAPH_MAX_FILES to index the rest."
|
|
116
|
+
return text
|
|
113
117
|
|
|
114
118
|
|
|
115
119
|
# ------------------------------------------------------------------ helpers
|
|
116
120
|
|
|
117
121
|
|
|
122
|
+
DEFAULT_GRAPH_MAX_FILES = 50000
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def graph_max_files() -> int:
|
|
126
|
+
"""File cap for the project graph: CONTEXTA_GRAPH_MAX_FILES, else 50000 (packs keep their own 2000 default)."""
|
|
127
|
+
raw = os.environ.get("CONTEXTA_GRAPH_MAX_FILES", "").strip()
|
|
128
|
+
try:
|
|
129
|
+
value = int(raw) if raw else 0
|
|
130
|
+
except ValueError:
|
|
131
|
+
value = 0
|
|
132
|
+
return value if value > 0 else DEFAULT_GRAPH_MAX_FILES
|
|
133
|
+
|
|
134
|
+
|
|
118
135
|
def classify_kind(rel: str, lang: str | None) -> str:
|
|
119
136
|
parts = PurePosixPath(rel).parts
|
|
120
137
|
name = parts[-1]
|
|
@@ -350,6 +367,7 @@ class ProjectGraph:
|
|
|
350
367
|
self._shared = explicit_db and self.db_path.parent != self.root / GRAPH_DIRNAME
|
|
351
368
|
self.readonly = readonly
|
|
352
369
|
self._con: sqlite3.Connection | None = None
|
|
370
|
+
self.truncated = False
|
|
353
371
|
|
|
354
372
|
# connection
|
|
355
373
|
@property
|
|
@@ -427,6 +445,7 @@ class ProjectGraph:
|
|
|
427
445
|
started = time.perf_counter()
|
|
428
446
|
stats = UpdateStats()
|
|
429
447
|
self._claim()
|
|
448
|
+
self.truncated = False
|
|
430
449
|
if files is None:
|
|
431
450
|
files = self._scan()
|
|
432
451
|
current: dict[str, Path] = {}
|
|
@@ -439,6 +458,7 @@ class ProjectGraph:
|
|
|
439
458
|
continue
|
|
440
459
|
current[rel] = path
|
|
441
460
|
stats.total = len(current)
|
|
461
|
+
stats.truncated = self.truncated
|
|
442
462
|
|
|
443
463
|
con = self.con
|
|
444
464
|
known = {r["path"]: r for r in con.execute("SELECT path, hash, mtime_ns, size FROM files")}
|
|
@@ -485,6 +505,7 @@ class ProjectGraph:
|
|
|
485
505
|
if dirty or use_git:
|
|
486
506
|
self._compute_ranks()
|
|
487
507
|
self._set_meta("updated_at", str(now))
|
|
508
|
+
self._set_meta("truncated", "1" if self.truncated else "0")
|
|
488
509
|
con.commit()
|
|
489
510
|
except Exception:
|
|
490
511
|
con.rollback()
|
|
@@ -532,7 +553,11 @@ class ProjectGraph:
|
|
|
532
553
|
from contexta_app.scanner import build_tree, load_gitignore_patterns
|
|
533
554
|
|
|
534
555
|
patterns = load_gitignore_patterns(self.root)
|
|
535
|
-
|
|
556
|
+
# The memory should cover the whole project, so it does not share the 2000-file cap of a pack.
|
|
557
|
+
limit = graph_max_files()
|
|
558
|
+
notes: list[str] = []
|
|
559
|
+
tree = build_tree(self.root, False, False, lambda msg, *_a, **_k: notes.append(str(msg)), [0], patterns, self.root, limit)
|
|
560
|
+
self.truncated = any("file limit" in n for n in notes)
|
|
536
561
|
return flatten_tree(tree)
|
|
537
562
|
|
|
538
563
|
def _delete_file(self, rel: str) -> None:
|
|
@@ -785,6 +810,7 @@ class ProjectGraph:
|
|
|
785
810
|
"updated_at": _iso(float(self.meta("updated_at", "0") or 0)),
|
|
786
811
|
"db": str(self.db_path.relative_to(self.root)) if self.db_path.is_relative_to(self.root) else "(outside project)",
|
|
787
812
|
"parsers": {r[0] or "none": r[1] for r in con.execute("SELECT backend, COUNT(*) FROM files GROUP BY backend")},
|
|
813
|
+
"truncated": self.meta("truncated", "0") == "1",
|
|
788
814
|
}
|
|
789
815
|
|
|
790
816
|
def has_file(self, path: str) -> bool:
|
|
@@ -103,6 +103,8 @@ def format_status(info: dict, name: str) -> str:
|
|
|
103
103
|
lines.append(" edges: " + (", ".join(f"{k} {v}" for k, v in sorted(info["edges"].items())) or "none"))
|
|
104
104
|
lines.append(" parsers: " + ", ".join(f"{k} {v}" for k, v in sorted(info["parsers"].items())))
|
|
105
105
|
lines.append(f" updated {info['updated_at']} db {info['db']}")
|
|
106
|
+
if info.get("truncated"):
|
|
107
|
+
lines.append(" warning: the last update hit the file limit, so the graph is incomplete (raise CONTEXTA_GRAPH_MAX_FILES)")
|
|
106
108
|
return "\n".join(lines) + "\n"
|
|
107
109
|
|
|
108
110
|
|
|
@@ -55,6 +55,9 @@ def parse_serve_args(args: list[str]) -> list[str]:
|
|
|
55
55
|
i = 0
|
|
56
56
|
while i < len(args):
|
|
57
57
|
arg = args[i]
|
|
58
|
+
if arg in ("-h", "--help"):
|
|
59
|
+
print("Usage: contexta serve [--root DIR]\n\nStart the MCP server over stdio. Tools only read inside --root\n(repeatable), CONTEXTA_ROOT, or the current directory.")
|
|
60
|
+
raise SystemExit(0)
|
|
58
61
|
if arg == "--root":
|
|
59
62
|
if i + 1 >= len(args):
|
|
60
63
|
raise SystemExit("contexta serve: --root needs a folder")
|
|
@@ -561,6 +561,11 @@ def build_tree(
|
|
|
561
561
|
|
|
562
562
|
if entry.is_symlink() and not _symlink_stays_inside(entry, project_root):
|
|
563
563
|
continue
|
|
564
|
+
if entry.is_symlink() and entry.is_dir():
|
|
565
|
+
# A directory link that stays inside the project points at something scanned under its real
|
|
566
|
+
# path anyway. Following it only lists the same files twice (or forever for `self -> .`, until
|
|
567
|
+
# the file cap), and could walk a folder the ignore rules skip.
|
|
568
|
+
continue
|
|
564
569
|
|
|
565
570
|
if entry.is_dir():
|
|
566
571
|
if should_ignore_dir(entry.name, include_hidden):
|
|
@@ -4,6 +4,10 @@ theme.py — Color palettes, theme switching, and widget registry.
|
|
|
4
4
|
The palette is an ink surface with a single mint accent. Text colors are
|
|
5
5
|
chosen so body copy and captions stay above a 4.5:1 contrast ratio on the
|
|
6
6
|
surfaces they sit on. Bright fills use dark ink for their label.
|
|
7
|
+
|
|
8
|
+
``border`` is the quiet outline of a card. ``border_strong`` is for anything the
|
|
9
|
+
user interacts with (inputs, ghost buttons, switch tracks): at least 3:1 against
|
|
10
|
+
the card, as WCAG 1.4.11 asks for UI components.
|
|
7
11
|
"""
|
|
8
12
|
|
|
9
13
|
import tkinter as tk
|
|
@@ -20,6 +24,7 @@ DARK: dict[str, str] = {
|
|
|
20
24
|
"input": "#0f1613",
|
|
21
25
|
"border": "#2c433b",
|
|
22
26
|
"border2": "#3e5c50",
|
|
27
|
+
"border_strong": "#4f7a6a",
|
|
23
28
|
"accent": "#3dcc8e",
|
|
24
29
|
"accent_dk": "#1f8f62",
|
|
25
30
|
"green": "#3dcc8e",
|
|
@@ -49,6 +54,7 @@ LIGHT: dict[str, str] = {
|
|
|
49
54
|
"input": "#ffffff",
|
|
50
55
|
"border": "#d5e2db",
|
|
51
56
|
"border2": "#b7c9c0",
|
|
57
|
+
"border_strong": "#728a7d",
|
|
52
58
|
"accent": "#0d7a4e",
|
|
53
59
|
"accent_dk": "#0b6842",
|
|
54
60
|
"green": "#0d7a4e",
|