tina4-python 3.13.61__tar.gz → 3.13.63__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.
- {tina4_python-3.13.61 → tina4_python-3.13.63}/.gitignore +4 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/PKG-INFO +1 -1
- {tina4_python-3.13.61 → tina4_python-3.13.63}/pyproject.toml +1 -1
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/__init__.py +1 -1
- tina4_python-3.13.63/tina4_python/context/__init__.py +371 -0
- tina4_python-3.13.63/tina4_python/context/chunker.py +120 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/crud/__init__.py +16 -8
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/dev_admin/__init__.py +13 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/mcp/tools.py +36 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/README.md +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/cli/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/core/request.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/core/router.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/core/server.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/database/connection.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/docstore/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/env.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/public/js/tina4-dev-admin.js +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/realtime/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/realtime/models/Attachment.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/realtime/models/Channel.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/realtime/models/ChannelMember.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/realtime/models/Message.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/realtime/models/Workspace.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/realtime/models/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/realtime/storage.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.13.61 → tina4_python-3.13.63}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -46,6 +46,10 @@ test_queue*.db
|
|
|
46
46
|
/publish.sh
|
|
47
47
|
/src/scss/
|
|
48
48
|
/broken
|
|
49
|
+
# Framework runtime artefacts under .tina4/ — the Context FTS5 index
|
|
50
|
+
# (.tina4/context.db), agent.log, and file-write backups. Transient,
|
|
51
|
+
# per-machine, never source-of-truth. Keep the whole dir out of git.
|
|
52
|
+
/.tina4/
|
|
49
53
|
# Framework runtime artefacts — BrokenTracker writes import-time and
|
|
50
54
|
# route-time failure dumps here. They're transient (timestamped per
|
|
51
55
|
# error) and contain stack traces, never source-of-truth content.
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# Tina4Python
|
|
2
|
+
#
|
|
3
|
+
# Context — a native, zero-dependency code/doc grounding index.
|
|
4
|
+
#
|
|
5
|
+
# Lets a Tina4 app ground its own AI assistant on its own source, offline:
|
|
6
|
+
# it walks the project, chunks code on def/class boundaries and docs as prose,
|
|
7
|
+
# and answers keyword/fuzzy queries over a SQLite FTS5 index (Python stdlib
|
|
8
|
+
# ``sqlite3`` — FTS5 + ``bm25()`` are built in, so NO new dependency).
|
|
9
|
+
#
|
|
10
|
+
# The retrieval core is a thin port of the proven slice of neemee
|
|
11
|
+
# (longmem-harness: memory_systems.SqliteFTS + pipeline.retrieve's stable
|
|
12
|
+
# source-over-tests / definition-first reorderings). It COMPLEMENTS the
|
|
13
|
+
# ``api_*`` reflection tools: ``api_*`` is exact structural lookup, Context is
|
|
14
|
+
# fuzzy/semantic FTS over source + docs.
|
|
15
|
+
#
|
|
16
|
+
# from tina4_python.context import Context
|
|
17
|
+
# ctx = Context(".tina4/context.db")
|
|
18
|
+
# ctx.index_root("src")
|
|
19
|
+
# ctx.search("where is the auth token issued?", k=5)
|
|
20
|
+
# # -> [{"path": "src/auth.py", "score": 2.31, "snippet": "..."}, ...]
|
|
21
|
+
#
|
|
22
|
+
# On-disk index defaults to ``.tina4/context.db`` (gitignored). Guards a
|
|
23
|
+
# sqlite build without FTS5: if absent, the Context degrades to safe no-ops
|
|
24
|
+
# rather than crashing the app.
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sqlite3
|
|
29
|
+
import threading
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from .chunker import (
|
|
33
|
+
chunk_code,
|
|
34
|
+
chunk_text,
|
|
35
|
+
fold,
|
|
36
|
+
light_stem,
|
|
37
|
+
terms,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = ["Context", "default_context", "existing_context"]
|
|
41
|
+
|
|
42
|
+
# File classification — mirrors neemee's repo walk.
|
|
43
|
+
CODE_EXTS = {".py", ".php", ".js", ".mjs", ".ts", ".rb",
|
|
44
|
+
".pas", ".dpr", ".dpk", ".inc", ".dfm", ".fmx"}
|
|
45
|
+
DOC_EXTS = {".md", ".txt", ".rst", ".twig", ".html"}
|
|
46
|
+
# deploy/CLI/env answers live in config files, not sources — chunk as code
|
|
47
|
+
# (line windows), since sentence chunking shreds YAML/Dockerfiles.
|
|
48
|
+
CONFIG_EXTS = {".toml", ".yml", ".yaml"}
|
|
49
|
+
SPECIAL_FILES = {"dockerfile", "makefile", "docker-compose.yml",
|
|
50
|
+
"package.json", "composer.json",
|
|
51
|
+
".env.example", ".env.sample"}
|
|
52
|
+
# Same dirs neemee skips, plus Tina4 runtime dirs that hold no source of truth
|
|
53
|
+
# (our own index/backups, session blobs, logs).
|
|
54
|
+
SKIP_DIRS = {".git", "__pycache__", "node_modules", "vendor", "dist",
|
|
55
|
+
"build", "coverage", ".idea", ".venv", "venv", ".pytest_cache",
|
|
56
|
+
".tina4", "sessions", "logs"}
|
|
57
|
+
|
|
58
|
+
# generic question/code vocabulary that never NAMES a symbol — kept small.
|
|
59
|
+
_DEF_STOP = frozenset(
|
|
60
|
+
"the and what how does can which where when who why list all available "
|
|
61
|
+
"module class function functions method methods def get set new return "
|
|
62
|
+
"import from with that this are is was for into use used".split())
|
|
63
|
+
|
|
64
|
+
# a chunk that DEFINES a queried symbol ('def get_token', 'class Widget')
|
|
65
|
+
# should out-rank one that merely USES it. Matched against the folded body.
|
|
66
|
+
_DEF_KW = r"(?:async def|def|class|function|fn|func|interface|trait)"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _fts5_supported() -> bool:
|
|
70
|
+
"""True if this interpreter's sqlite3 was built with FTS5."""
|
|
71
|
+
try:
|
|
72
|
+
conn = sqlite3.connect(":memory:")
|
|
73
|
+
try:
|
|
74
|
+
conn.execute("CREATE VIRTUAL TABLE _probe USING fts5(x)")
|
|
75
|
+
return True
|
|
76
|
+
finally:
|
|
77
|
+
conn.close()
|
|
78
|
+
except sqlite3.OperationalError:
|
|
79
|
+
return False
|
|
80
|
+
except Exception:
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Context:
|
|
85
|
+
"""A SQLite FTS5 index over a project's source + docs.
|
|
86
|
+
|
|
87
|
+
Methods:
|
|
88
|
+
index_path(file, label=None) -> int upsert one file (delete-by-path,
|
|
89
|
+
re-chunk, insert); reindex-safe.
|
|
90
|
+
index_root(root) -> int walk a tree, index every eligible
|
|
91
|
+
file (skips vendor/build dirs).
|
|
92
|
+
search(query, k=5) -> list [{path, score, snippet}] ranked by
|
|
93
|
+
bm25() with source-over-tests +
|
|
94
|
+
definition-first reordering.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, path="./.tina4/context.db", fts5_check=None):
|
|
98
|
+
"""``path`` is the on-disk index file (its parent dir is created).
|
|
99
|
+
``fts5_check`` overrides FTS5 detection (used by tests to exercise the
|
|
100
|
+
graceful-degradation path); defaults to a real probe of this build.
|
|
101
|
+
"""
|
|
102
|
+
self.path = str(path)
|
|
103
|
+
self._lock = threading.Lock()
|
|
104
|
+
self.conn = None
|
|
105
|
+
self.root = None # set by index_root; reindex_file relabels against it
|
|
106
|
+
check = fts5_check or _fts5_supported
|
|
107
|
+
self.available = bool(check())
|
|
108
|
+
if not self.available:
|
|
109
|
+
return
|
|
110
|
+
parent = Path(self.path).parent
|
|
111
|
+
if str(parent) not in ("", "."):
|
|
112
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
# One connection shared across threads (the dev-MCP handler may run on
|
|
114
|
+
# a worker thread) with a lock serializing access — a same-thread-only
|
|
115
|
+
# connection would reject those calls outright.
|
|
116
|
+
self.conn = sqlite3.connect(self.path, check_same_thread=False)
|
|
117
|
+
self._ensure_table()
|
|
118
|
+
|
|
119
|
+
# ── FTS5 availability ───────────────────────────────────────
|
|
120
|
+
@classmethod
|
|
121
|
+
def fts5_available(cls) -> bool:
|
|
122
|
+
"""Whether this Python's sqlite3 supports FTS5 (module-level probe)."""
|
|
123
|
+
return _fts5_supported()
|
|
124
|
+
|
|
125
|
+
# ── schema ──────────────────────────────────────────────────
|
|
126
|
+
def _ensure_table(self):
|
|
127
|
+
# `body` holds fold(text) so query/index tokenization is symmetric;
|
|
128
|
+
# `raw` (UNINDEXED) keeps the original text to return as a snippet;
|
|
129
|
+
# `path`/`cid` (UNINDEXED) are metadata used for upsert + citation.
|
|
130
|
+
self.conn.execute(
|
|
131
|
+
"CREATE VIRTUAL TABLE IF NOT EXISTS chunks "
|
|
132
|
+
"USING fts5(cid UNINDEXED, path UNINDEXED, raw UNINDEXED, body)"
|
|
133
|
+
)
|
|
134
|
+
self.conn.commit()
|
|
135
|
+
|
|
136
|
+
def reset(self):
|
|
137
|
+
"""Drop and recreate the index (full rebuild starting point)."""
|
|
138
|
+
if not self.available:
|
|
139
|
+
return
|
|
140
|
+
with self._lock:
|
|
141
|
+
self.conn.execute("DROP TABLE IF EXISTS chunks")
|
|
142
|
+
self._ensure_table()
|
|
143
|
+
|
|
144
|
+
# ── indexing ────────────────────────────────────────────────
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _chunks_for(label, text):
|
|
147
|
+
"""(index, chunk_text) pairs — code files on def/class boundaries,
|
|
148
|
+
everything else (docs) as prose."""
|
|
149
|
+
ext = os.path.splitext(label)[1].lower()
|
|
150
|
+
special = os.path.basename(label).lower() in SPECIAL_FILES
|
|
151
|
+
if ext in CODE_EXTS or ext in CONFIG_EXTS or special:
|
|
152
|
+
return chunk_code(text, path=label)
|
|
153
|
+
return chunk_text(text)
|
|
154
|
+
|
|
155
|
+
def index_path(self, file, label=None) -> int:
|
|
156
|
+
"""UPSERT one file into the index: delete this path's existing chunks,
|
|
157
|
+
re-chunk the current contents, insert. A single file save re-indexes
|
|
158
|
+
just that file (sub-100ms). ``label`` is the stored/citation path
|
|
159
|
+
(defaults to ``str(file)``) and MUST be stable across calls for the
|
|
160
|
+
same file so the delete targets the right rows. Returns rows inserted.
|
|
161
|
+
"""
|
|
162
|
+
if not self.available:
|
|
163
|
+
return 0
|
|
164
|
+
stored = str(label) if label is not None else str(file)
|
|
165
|
+
try:
|
|
166
|
+
text = Path(file).read_text(encoding="utf-8", errors="ignore")
|
|
167
|
+
except OSError:
|
|
168
|
+
return 0
|
|
169
|
+
rows = [
|
|
170
|
+
(f"{stored}:{i}", stored, chunk, fold(chunk))
|
|
171
|
+
for i, chunk in self._chunks_for(stored, text)
|
|
172
|
+
]
|
|
173
|
+
with self._lock:
|
|
174
|
+
self.conn.execute("DELETE FROM chunks WHERE path = ?", (stored,))
|
|
175
|
+
if rows:
|
|
176
|
+
self.conn.executemany(
|
|
177
|
+
"INSERT INTO chunks(cid, path, raw, body) VALUES (?, ?, ?, ?)",
|
|
178
|
+
rows,
|
|
179
|
+
)
|
|
180
|
+
self.conn.commit()
|
|
181
|
+
return len(rows)
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _eligible(filename) -> bool:
|
|
185
|
+
"""True if a file should be indexed (the per-file filter used by both
|
|
186
|
+
index_root and reindex_file). Directory skipping is handled separately."""
|
|
187
|
+
fn = filename.lower()
|
|
188
|
+
if fn.endswith(".min.js"):
|
|
189
|
+
return False
|
|
190
|
+
ext = os.path.splitext(fn)[1]
|
|
191
|
+
return (ext in CODE_EXTS or ext in DOC_EXTS or ext in CONFIG_EXTS
|
|
192
|
+
or fn in SPECIAL_FILES)
|
|
193
|
+
|
|
194
|
+
def index_root(self, root) -> int:
|
|
195
|
+
"""Walk ``root``, indexing every eligible file (skips vendor/build/
|
|
196
|
+
runtime dirs). Paths are stored RELATIVE to ``root`` for clean
|
|
197
|
+
citations. Records ``root`` so reindex_file can relabel a changed file
|
|
198
|
+
consistently. Returns the total number of chunks inserted."""
|
|
199
|
+
if not self.available:
|
|
200
|
+
return 0
|
|
201
|
+
root = Path(root).resolve()
|
|
202
|
+
self.root = root
|
|
203
|
+
total = 0
|
|
204
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
205
|
+
# prune skip-dirs and dotdirs in place
|
|
206
|
+
dirnames[:] = [d for d in dirnames
|
|
207
|
+
if d not in SKIP_DIRS and not d.startswith(".")]
|
|
208
|
+
for fn in sorted(filenames):
|
|
209
|
+
if not self._eligible(fn):
|
|
210
|
+
continue
|
|
211
|
+
full = Path(dirpath) / fn
|
|
212
|
+
rel = str(full.relative_to(root))
|
|
213
|
+
total += self.index_path(full, label=rel)
|
|
214
|
+
return total
|
|
215
|
+
|
|
216
|
+
def reindex_file(self, changed_path) -> int:
|
|
217
|
+
"""Re-index a single changed file into the LIVE index — the hook the dev
|
|
218
|
+
WebSocket reload trigger (POST /__dev/api/reload) calls so code_search
|
|
219
|
+
tracks edits without a rebuild. Resolves ``changed_path`` against the
|
|
220
|
+
indexed root, then: outside root / under a skip-or-dot dir / ineligible
|
|
221
|
+
→ skip (-1); deleted → drop its chunks (0); otherwise UPSERT (rows).
|
|
222
|
+
No-op (-1) until index_root has run (nothing to keep fresh yet)."""
|
|
223
|
+
if not self.available or self.root is None:
|
|
224
|
+
return -1
|
|
225
|
+
p = Path(changed_path)
|
|
226
|
+
if not p.is_absolute():
|
|
227
|
+
# the reload trigger reports paths relative to the project root (cwd
|
|
228
|
+
# during `tina4 serve`); the index root may be a subdir like src/.
|
|
229
|
+
p = Path.cwd() / changed_path
|
|
230
|
+
try:
|
|
231
|
+
rel = p.resolve().relative_to(self.root)
|
|
232
|
+
except (ValueError, OSError):
|
|
233
|
+
return -1 # outside the indexed root
|
|
234
|
+
if set(rel.parts) & SKIP_DIRS or any(part.startswith(".") for part in rel.parts[:-1]):
|
|
235
|
+
return -1 # under a skipped / dot dir
|
|
236
|
+
if not self._eligible(rel.name):
|
|
237
|
+
return -1
|
|
238
|
+
stored = str(rel)
|
|
239
|
+
if not p.exists(): # deleted → drop its chunks
|
|
240
|
+
with self._lock:
|
|
241
|
+
self.conn.execute("DELETE FROM chunks WHERE path = ?", (stored,))
|
|
242
|
+
self.conn.commit()
|
|
243
|
+
return 0
|
|
244
|
+
return self.index_path(p, label=stored)
|
|
245
|
+
|
|
246
|
+
# ── query ───────────────────────────────────────────────────
|
|
247
|
+
def _match_expr(self, query):
|
|
248
|
+
"""Build the FTS5 MATCH string: each folded query token (plus its
|
|
249
|
+
light stem) as a quoted OR term. Quoting keeps it injection-safe."""
|
|
250
|
+
toks = set()
|
|
251
|
+
for t in terms(query):
|
|
252
|
+
toks.add(t)
|
|
253
|
+
toks.add(light_stem(t))
|
|
254
|
+
toks.discard("")
|
|
255
|
+
if not toks:
|
|
256
|
+
return None, set()
|
|
257
|
+
expr = " OR ".join('"%s"' % t for t in sorted(toks))
|
|
258
|
+
return expr, toks
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
def _is_testlike(path):
|
|
262
|
+
s = (path or "").lower()
|
|
263
|
+
return any(p in s for p in ("/test", "test/", "test_", "_test.",
|
|
264
|
+
".test.", "/example", "example/"))
|
|
265
|
+
|
|
266
|
+
def _defines(self, folded_body, symbols):
|
|
267
|
+
if not symbols:
|
|
268
|
+
return False
|
|
269
|
+
pat = re.compile(
|
|
270
|
+
_DEF_KW + r"\s+(?:" + "|".join(re.escape(s) for s in symbols)
|
|
271
|
+
+ r")(?![a-z0-9])")
|
|
272
|
+
return bool(pat.search(folded_body))
|
|
273
|
+
|
|
274
|
+
def search(self, query, k=5):
|
|
275
|
+
"""Return the top-``k`` chunks as ``[{path, score, snippet}]``, ranked
|
|
276
|
+
by ``bm25()`` then reordered with two stable, proven passes:
|
|
277
|
+
- source-over-tests: a test that merely mentions a symbol sinks below
|
|
278
|
+
the source that defines it (skipped when the query is about tests);
|
|
279
|
+
- definition-first: a chunk that DEFINES a queried symbol rises above
|
|
280
|
+
chunks that only use it.
|
|
281
|
+
Score is a higher-is-better float (sqlite's bm25 sign flipped).
|
|
282
|
+
"""
|
|
283
|
+
if not self.available:
|
|
284
|
+
return []
|
|
285
|
+
expr, _ = self._match_expr(query)
|
|
286
|
+
if expr is None:
|
|
287
|
+
return []
|
|
288
|
+
pool_n = max(k * 3, 15)
|
|
289
|
+
with self._lock:
|
|
290
|
+
rows = self.conn.execute(
|
|
291
|
+
"SELECT path, raw, body, bm25(chunks) AS s FROM chunks "
|
|
292
|
+
"WHERE chunks MATCH ? ORDER BY s LIMIT ?",
|
|
293
|
+
(expr, pool_n),
|
|
294
|
+
).fetchall()
|
|
295
|
+
if not rows:
|
|
296
|
+
return []
|
|
297
|
+
|
|
298
|
+
# candidate symbol names from the query (drop generic vocab).
|
|
299
|
+
symbols = {t for t in terms(query) if len(t) >= 3 and t not in _DEF_STOP}
|
|
300
|
+
about_tests = "test" in query.lower()
|
|
301
|
+
|
|
302
|
+
def sort_key(item):
|
|
303
|
+
idx, (path, raw, body, s) = item
|
|
304
|
+
is_test = 1 if (not about_tests and self._is_testlike(path)) else 0
|
|
305
|
+
is_def = 0 if self._defines(body, symbols) else 1
|
|
306
|
+
return (is_test, is_def, idx) # bm25 order (idx) breaks ties
|
|
307
|
+
|
|
308
|
+
ordered = sorted(enumerate(rows), key=sort_key)
|
|
309
|
+
out = []
|
|
310
|
+
for _, (path, raw, body, s) in ordered[:k]:
|
|
311
|
+
out.append({
|
|
312
|
+
"path": path,
|
|
313
|
+
"score": round(-float(s), 6), # bm25: more negative = better
|
|
314
|
+
"snippet": self._snippet(raw),
|
|
315
|
+
})
|
|
316
|
+
return out
|
|
317
|
+
|
|
318
|
+
@staticmethod
|
|
319
|
+
def _snippet(raw, limit=280):
|
|
320
|
+
text = (raw or "").strip()
|
|
321
|
+
if len(text) <= limit:
|
|
322
|
+
return text
|
|
323
|
+
return text[:limit].rstrip() + " ..."
|
|
324
|
+
|
|
325
|
+
# ── misc ────────────────────────────────────────────────────
|
|
326
|
+
def count(self) -> int:
|
|
327
|
+
if not self.available:
|
|
328
|
+
return 0
|
|
329
|
+
with self._lock:
|
|
330
|
+
return self.conn.execute("SELECT count(*) FROM chunks").fetchone()[0]
|
|
331
|
+
|
|
332
|
+
def is_empty(self) -> bool:
|
|
333
|
+
return self.count() == 0
|
|
334
|
+
|
|
335
|
+
def close(self):
|
|
336
|
+
if self.conn is not None:
|
|
337
|
+
with self._lock:
|
|
338
|
+
self.conn.close()
|
|
339
|
+
self.conn = None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ── process-wide shared index ───────────────────────────────────
|
|
343
|
+
# code_search (dev MCP) and the dev-reload reindex hook must share ONE index so
|
|
344
|
+
# a saved file is immediately searchable. Keyed by resolved db path.
|
|
345
|
+
_shared_contexts: dict = {}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _db_key(db=None) -> str:
|
|
349
|
+
return str((Path(db) if db else Path.cwd() / ".tina4" / "context.db").resolve())
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def default_context(root=None, db=None) -> "Context":
|
|
353
|
+
"""Get (or create) the process-wide Context at ``db`` (default
|
|
354
|
+
``<cwd>/.tina4/context.db``). If ``root`` is given and the index is empty,
|
|
355
|
+
builds it once. This is what code_search uses so the reload hook can keep
|
|
356
|
+
the SAME index fresh."""
|
|
357
|
+
key = _db_key(db)
|
|
358
|
+
ctx = _shared_contexts.get(key)
|
|
359
|
+
if ctx is None:
|
|
360
|
+
ctx = Context(key)
|
|
361
|
+
_shared_contexts[key] = ctx
|
|
362
|
+
if root is not None and ctx.available and ctx.is_empty():
|
|
363
|
+
ctx.index_root(root)
|
|
364
|
+
return ctx
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def existing_context(db=None):
|
|
368
|
+
"""Return the already-created shared Context for ``db`` (or None). Used by
|
|
369
|
+
the reload hook so a file change reindexes an EXISTING index but never
|
|
370
|
+
creates one on its own (nothing to keep fresh until code_search runs)."""
|
|
371
|
+
return _shared_contexts.get(_db_key(db))
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Tina4Python
|
|
2
|
+
#
|
|
3
|
+
# Text folding + chunking for the Context subsystem.
|
|
4
|
+
#
|
|
5
|
+
# A thin, idiomatic port of the proven slice of neemee's tokenizer/chunkers
|
|
6
|
+
# (longmem-harness: pipeline.chunk_code / chunk_text, memory_systems.fold).
|
|
7
|
+
# Pure stdlib — ``re``/``unicodedata`` only. Nothing here touches SQLite; it
|
|
8
|
+
# produces the (folded body, raw text) pairs the FTS5 index stores.
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import unicodedata
|
|
12
|
+
|
|
13
|
+
# join comma-grouped numbers ("24,601" -> "24601") and split camelCase
|
|
14
|
+
# ("ForeignKeyField" -> "foreign key field") so a query token reaches a
|
|
15
|
+
# code identifier. snake_case already splits for free at the tokenizer.
|
|
16
|
+
_NUM_COMMA = re.compile(r"(?<=\d),(?=\d)")
|
|
17
|
+
_CAMEL = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
|
18
|
+
_WORD_RE = re.compile(r"[a-z0-9]+")
|
|
19
|
+
|
|
20
|
+
# Sentence boundary = end punctuation FOLLOWED BY whitespace/EOL, or a newline.
|
|
21
|
+
# NOT a bare '.', which would shred embedded code in prose ("db.fetch()" ->
|
|
22
|
+
# "db. fetch()"). Intra-token dots (method calls, module paths) stay intact.
|
|
23
|
+
_SENT = re.compile(r"(?<=[.!?])\s+|\n+")
|
|
24
|
+
|
|
25
|
+
# Chunk boundaries across languages: python defs/classes/decorators, php/js
|
|
26
|
+
# functions and methods, ts exports/interfaces, php traits, and Object Pascal
|
|
27
|
+
# unit/type/routine headers (Delphi is case-insensitive, accept either case).
|
|
28
|
+
_TOPLEVEL = re.compile(
|
|
29
|
+
r"^(async def |def |class |@\w"
|
|
30
|
+
r"|\s*(?:public |private |protected |static |final |abstract )*function \w"
|
|
31
|
+
r"|(?:export )?(?:default )?(?:abstract )?class "
|
|
32
|
+
r"|interface |trait "
|
|
33
|
+
r"|export (?:const|function|interface|type|async) "
|
|
34
|
+
r"|[Uu]nit |[Pp]rocedure |[Ff]unction |[Cc]onstructor |[Dd]estructor "
|
|
35
|
+
r"|[Tt]ype$)"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def fold(text: str) -> str:
|
|
40
|
+
"""Lowercase, strip diacritics, join comma-grouped numbers, split camelCase.
|
|
41
|
+
|
|
42
|
+
Applied symmetrically to the indexed ``body`` and to query tokens so
|
|
43
|
+
matching is consistent: a query for ``field`` reaches ``IntegerField``.
|
|
44
|
+
"""
|
|
45
|
+
text = _CAMEL.sub(" ", _NUM_COMMA.sub("", text)).lower()
|
|
46
|
+
return unicodedata.normalize("NFKD", text) \
|
|
47
|
+
.encode("ascii", "ignore").decode("ascii")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def light_stem(token: str) -> str:
|
|
51
|
+
"""Fold simple plurals: strip one trailing 's', never from ss/us/is
|
|
52
|
+
endings (class, status, axis). QUERY-side only — so ``fields`` in a
|
|
53
|
+
question also reaches ``Field`` definitions."""
|
|
54
|
+
if len(token) > 3 and token.endswith("s") and not token.endswith(("ss", "us", "is")):
|
|
55
|
+
return token[:-1]
|
|
56
|
+
return token
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def terms(text: str):
|
|
60
|
+
"""Accent-folded lowercase alphanumeric tokens (digits kept)."""
|
|
61
|
+
return _WORD_RE.findall(fold(text))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _pack(segments, max_lines):
|
|
65
|
+
"""Pack line-segments into chunks of at most ``max_lines`` lines,
|
|
66
|
+
hard-splitting any single oversized segment."""
|
|
67
|
+
chunks, cur = [], []
|
|
68
|
+
for seg in segments:
|
|
69
|
+
while len(seg) > max_lines: # oversized segment: hard split
|
|
70
|
+
if cur:
|
|
71
|
+
chunks.append(cur)
|
|
72
|
+
cur = []
|
|
73
|
+
chunks.append(seg[:max_lines])
|
|
74
|
+
seg = seg[max_lines:]
|
|
75
|
+
if cur and len(cur) + len(seg) > max_lines:
|
|
76
|
+
chunks.append(cur)
|
|
77
|
+
cur = []
|
|
78
|
+
cur += seg
|
|
79
|
+
if cur:
|
|
80
|
+
chunks.append(cur)
|
|
81
|
+
return chunks
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def chunk_code(text: str, path: str = "", max_lines: int = 60):
|
|
85
|
+
"""Chunk source on top-level def/class/decorator boundaries (sentence
|
|
86
|
+
chunking shreds code). Segments are packed up to ``max_lines``, and every
|
|
87
|
+
chunk starts with a ``# file: <path>`` line so the path's tokens are
|
|
88
|
+
indexed — 'where is the router?' should match core/router.py by name.
|
|
89
|
+
|
|
90
|
+
Returns a list of ``(index, chunk_text)`` pairs.
|
|
91
|
+
"""
|
|
92
|
+
lines = text.splitlines()
|
|
93
|
+
bounds = [i for i, ln in enumerate(lines) if _TOPLEVEL.match(ln)]
|
|
94
|
+
if not bounds or bounds[0] != 0:
|
|
95
|
+
bounds = [0] + bounds
|
|
96
|
+
segments = [lines[a:b] for a, b in zip(bounds, bounds[1:] + [len(lines)])]
|
|
97
|
+
|
|
98
|
+
header = f"# file: {path}" if path else None
|
|
99
|
+
out = []
|
|
100
|
+
for i, ch in enumerate(_pack(segments, max_lines)):
|
|
101
|
+
body = "\n".join(([header] if header else []) + ch)
|
|
102
|
+
out.append((i, body))
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def chunk_text(text: str, max_words: int = 350):
|
|
107
|
+
"""Chunk prose/docs into sentence-packed windows of at most ``max_words``
|
|
108
|
+
words. Returns a list of ``(index, chunk_text)`` pairs."""
|
|
109
|
+
sents = [s.strip() for s in _SENT.split(text) if s.strip()]
|
|
110
|
+
chunks, cur, cur_words = [], [], 0
|
|
111
|
+
for s in sents:
|
|
112
|
+
w = len(s.split())
|
|
113
|
+
if cur and cur_words + w > max_words:
|
|
114
|
+
chunks.append(" ".join(cur))
|
|
115
|
+
cur, cur_words = [], 0
|
|
116
|
+
cur.append(s)
|
|
117
|
+
cur_words += w
|
|
118
|
+
if cur:
|
|
119
|
+
chunks.append(" ".join(cur))
|
|
120
|
+
return list(enumerate(chunks))
|
|
@@ -21,8 +21,10 @@ Generated endpoints per model:
|
|
|
21
21
|
PUT /api/{table_name}/{id} — update record by primary key
|
|
22
22
|
DELETE /api/{table_name}/{id} — delete record by primary key
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
Write routes (POST/PUT/DELETE) are SECURE BY DEFAULT — they require a valid
|
|
25
|
+
token, matching Tina4's framework-wide default (the router gates writes unless a
|
|
26
|
+
route is @noauth). Pass ``public=True`` to open them explicitly, e.g.
|
|
27
|
+
``AutoCrud.register(Note, public=True)``.
|
|
26
28
|
"""
|
|
27
29
|
import importlib
|
|
28
30
|
import inspect
|
|
@@ -66,12 +68,15 @@ class AutoCrud:
|
|
|
66
68
|
return example
|
|
67
69
|
|
|
68
70
|
@staticmethod
|
|
69
|
-
def register(model_class, prefix: str = "/api"):
|
|
71
|
+
def register(model_class, prefix: str = "/api", public: bool = False):
|
|
70
72
|
"""Register REST endpoints for a single ORM model class.
|
|
71
73
|
|
|
72
74
|
Args:
|
|
73
75
|
model_class: An ORM subclass with table_name and fields defined.
|
|
74
76
|
prefix: URL prefix for the generated routes (default "/api").
|
|
77
|
+
public: If True, the write routes (POST/PUT/DELETE) are OPEN (no auth).
|
|
78
|
+
Default False keeps them **secure-by-default** — a valid token is
|
|
79
|
+
required, matching the framework's write-gating.
|
|
75
80
|
|
|
76
81
|
Returns:
|
|
77
82
|
List of dicts describing the generated routes.
|
|
@@ -168,7 +173,8 @@ class AutoCrud:
|
|
|
168
173
|
|
|
169
174
|
create_handler.__name__ = f"autocrud_create_{table}"
|
|
170
175
|
create_handler.__qualname__ = f"autocrud_create_{table}"
|
|
171
|
-
|
|
176
|
+
if public: # secure-by-default; opt-in to public writes
|
|
177
|
+
create_handler._noauth = True
|
|
172
178
|
create_handler._swagger_summary = f"Create {pretty_name}"
|
|
173
179
|
create_handler._swagger_tags = [table]
|
|
174
180
|
create_handler._swagger_example = example_body
|
|
@@ -201,7 +207,8 @@ class AutoCrud:
|
|
|
201
207
|
|
|
202
208
|
update_handler.__name__ = f"autocrud_update_{table}"
|
|
203
209
|
update_handler.__qualname__ = f"autocrud_update_{table}"
|
|
204
|
-
|
|
210
|
+
if public:
|
|
211
|
+
update_handler._noauth = True
|
|
205
212
|
update_handler._swagger_summary = f"Update {pretty_name}"
|
|
206
213
|
update_handler._swagger_tags = [table]
|
|
207
214
|
update_handler._swagger_example = example_body
|
|
@@ -224,7 +231,8 @@ class AutoCrud:
|
|
|
224
231
|
|
|
225
232
|
delete_handler.__name__ = f"autocrud_delete_{table}"
|
|
226
233
|
delete_handler.__qualname__ = f"autocrud_delete_{table}"
|
|
227
|
-
|
|
234
|
+
if public:
|
|
235
|
+
delete_handler._noauth = True
|
|
228
236
|
delete_handler._swagger_summary = f"Delete {pretty_name}"
|
|
229
237
|
delete_handler._swagger_tags = [table]
|
|
230
238
|
Router.add("DELETE", f"{base_path}/{{id}}", delete_handler)
|
|
@@ -237,7 +245,7 @@ class AutoCrud:
|
|
|
237
245
|
return generated
|
|
238
246
|
|
|
239
247
|
@staticmethod
|
|
240
|
-
def discover(models_dir: str = "src/orm", prefix: str = "/api"):
|
|
248
|
+
def discover(models_dir: str = "src/orm", prefix: str = "/api", public: bool = False):
|
|
241
249
|
"""Auto-discover all ORM models in a directory and register CRUD routes.
|
|
242
250
|
|
|
243
251
|
Scans .py files in the given directory, imports them, and registers
|
|
@@ -308,7 +316,7 @@ class AutoCrud:
|
|
|
308
316
|
and attr._get_table() # Has a table name
|
|
309
317
|
and attr._get_table() not in AutoCrud._registered
|
|
310
318
|
):
|
|
311
|
-
AutoCrud.register(attr, prefix=prefix)
|
|
319
|
+
AutoCrud.register(attr, prefix=prefix, public=public)
|
|
312
320
|
discovered.append(attr.__name__)
|
|
313
321
|
|
|
314
322
|
if discovered:
|
|
@@ -2000,6 +2000,19 @@ async def _api_reload(request, response):
|
|
|
2000
2000
|
except Exception as e:
|
|
2001
2001
|
Log.error(f"Re-discover on reload failed: {e}")
|
|
2002
2002
|
|
|
2003
|
+
# Keep the code Context index LIVE on the same WebSocket-reload trigger:
|
|
2004
|
+
# reindex just the changed file (UPSERT) so the dev-MCP code_search reflects
|
|
2005
|
+
# the edit immediately. Only touches an already-built index (existing_context
|
|
2006
|
+
# never creates one); guarded so a context failure never breaks the reload.
|
|
2007
|
+
try:
|
|
2008
|
+
if _reload_file[0]:
|
|
2009
|
+
from tina4_python.context import existing_context
|
|
2010
|
+
_ctx = existing_context()
|
|
2011
|
+
if _ctx is not None:
|
|
2012
|
+
_ctx.reindex_file(_reload_file[0])
|
|
2013
|
+
except Exception as e:
|
|
2014
|
+
Log.error(f"Context reindex on reload failed: {e}")
|
|
2015
|
+
|
|
2003
2016
|
# WebSocket-primary reload: push an instant message to every browser
|
|
2004
2017
|
# connected on /__dev_reload. The toolbar client (and the dev-admin
|
|
2005
2018
|
# dashboard) act on this immediately — the mtime poll above is only a
|