tina4-python 3.13.60__tar.gz → 3.13.62__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 (168) hide show
  1. {tina4_python-3.13.60 → tina4_python-3.13.62}/.gitignore +4 -0
  2. {tina4_python-3.13.60 → tina4_python-3.13.62}/PKG-INFO +1 -1
  3. {tina4_python-3.13.60 → tina4_python-3.13.62}/pyproject.toml +1 -1
  4. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/CLAUDE.md +5 -5
  5. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/__init__.py +1 -1
  6. tina4_python-3.13.62/tina4_python/context/__init__.py +371 -0
  7. tina4_python-3.13.62/tina4_python/context/chunker.py +120 -0
  8. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/dev_admin/__init__.py +13 -0
  9. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/mcp/tools.py +36 -0
  10. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/orm/model.py +29 -1
  11. {tina4_python-3.13.60 → tina4_python-3.13.62}/README.md +0 -0
  12. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/HtmlElement.py +0 -0
  13. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/Testing.py +0 -0
  14. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/ai/__init__.py +0 -0
  15. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/api/__init__.py +0 -0
  16. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/auth/__init__.py +0 -0
  17. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/cache/__init__.py +0 -0
  18. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/cli/__init__.py +0 -0
  19. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/container/__init__.py +0 -0
  20. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/core/__init__.py +0 -0
  21. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/core/cache.py +0 -0
  22. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/core/constants.py +0 -0
  23. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/core/events.py +0 -0
  24. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/core/middleware.py +0 -0
  25. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/core/rate_limiter.py +0 -0
  26. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/core/request.py +0 -0
  27. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/core/response.py +0 -0
  28. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/core/router.py +0 -0
  29. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/core/server.py +0 -0
  30. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/crud/__init__.py +0 -0
  31. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/database/__init__.py +0 -0
  32. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/database/adapter.py +0 -0
  33. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/database/connection.py +0 -0
  34. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/database/firebird.py +0 -0
  35. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/database/mongodb.py +0 -0
  36. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/database/mssql.py +0 -0
  37. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/database/mysql.py +0 -0
  38. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/database/odbc.py +0 -0
  39. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/database/postgres.py +0 -0
  40. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/database/sqlite.py +0 -0
  41. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/debug/__init__.py +0 -0
  42. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/debug/error_overlay.py +0 -0
  43. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/dev_admin/metrics.py +0 -0
  44. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/dev_admin/plan.py +0 -0
  45. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/dev_admin/project_index.py +0 -0
  46. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/docs.py +0 -0
  47. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/docstore/__init__.py +0 -0
  48. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/dotenv/__init__.py +0 -0
  49. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/env.py +0 -0
  50. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/frond/FROND.md +0 -0
  51. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/frond/__init__.py +0 -0
  52. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/frond/engine.py +0 -0
  53. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/gallery/auth/meta.json +0 -0
  54. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  55. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/gallery/database/meta.json +0 -0
  56. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  57. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/gallery/error-overlay/meta.json +0 -0
  58. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  59. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/gallery/orm/meta.json +0 -0
  60. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  61. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  62. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/gallery/queue/meta.json +0 -0
  63. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  64. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/gallery/rest-api/meta.json +0 -0
  65. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  66. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/gallery/templates/meta.json +0 -0
  67. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  68. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  69. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/graphql/__init__.py +0 -0
  70. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/i18n/__init__.py +0 -0
  71. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/mcp/__init__.py +0 -0
  72. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/mcp/protocol.py +0 -0
  73. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/messenger/__init__.py +0 -0
  74. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/migration/__init__.py +0 -0
  75. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/migration/runner.py +0 -0
  76. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/orm/__init__.py +0 -0
  77. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/orm/fields.py +0 -0
  78. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/public/__feedback/widget.js +0 -0
  79. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/public/css/tina4.css +0 -0
  80. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/public/css/tina4.min.css +0 -0
  81. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/public/favicon.ico +0 -0
  82. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/public/images/logo.svg +0 -0
  83. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  84. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/public/js/frond.js +0 -0
  85. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/public/js/frond.min.js +0 -0
  86. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  87. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  88. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/public/js/tina4.min.js +0 -0
  89. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/public/js/tina4js.min.js +0 -0
  90. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/public/swagger/index.html +0 -0
  91. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  92. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/query_builder/__init__.py +0 -0
  93. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/queue/__init__.py +0 -0
  94. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/queue/job.py +0 -0
  95. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/queue/kafka_backend.py +0 -0
  96. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/queue/lite_backend.py +0 -0
  97. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/queue/mongo_backend.py +0 -0
  98. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/queue/rabbitmq_backend.py +0 -0
  99. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/queue_backends/__init__.py +0 -0
  100. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/queue_backends/kafka_backend.py +0 -0
  101. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/queue_backends/mongo_backend.py +0 -0
  102. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  103. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/realtime/__init__.py +0 -0
  104. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/realtime/models/Attachment.py +0 -0
  105. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/realtime/models/Channel.py +0 -0
  106. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/realtime/models/ChannelMember.py +0 -0
  107. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/realtime/models/Message.py +0 -0
  108. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/realtime/models/Workspace.py +0 -0
  109. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/realtime/models/__init__.py +0 -0
  110. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/realtime/storage.py +0 -0
  111. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/scss/__init__.py +0 -0
  112. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  113. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/scss/tina4css/_badges.scss +0 -0
  114. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  115. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/scss/tina4css/_cards.scss +0 -0
  116. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/scss/tina4css/_forms.scss +0 -0
  117. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/scss/tina4css/_grid.scss +0 -0
  118. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/scss/tina4css/_modals.scss +0 -0
  119. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/scss/tina4css/_nav.scss +0 -0
  120. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/scss/tina4css/_reset.scss +0 -0
  121. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/scss/tina4css/_tables.scss +0 -0
  122. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/scss/tina4css/_typography.scss +0 -0
  123. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  124. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/scss/tina4css/_variables.scss +0 -0
  125. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/scss/tina4css/base.scss +0 -0
  126. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/scss/tina4css/colors.scss +0 -0
  127. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/scss/tina4css/tina4.scss +0 -0
  128. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/seeder/__init__.py +0 -0
  129. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/service/__init__.py +0 -0
  130. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/session/__init__.py +0 -0
  131. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/session_handlers/__init__.py +0 -0
  132. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  133. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/session_handlers/redis_handler.py +0 -0
  134. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/session_handlers/valkey_handler.py +0 -0
  135. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/swagger/__init__.py +0 -0
  136. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/templates/components/crud.twig +0 -0
  137. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  138. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  139. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/templates/docker/python/Dockerfile +0 -0
  140. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  141. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/templates/errors/302.twig +0 -0
  142. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/templates/errors/401.twig +0 -0
  143. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/templates/errors/403.twig +0 -0
  144. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/templates/errors/404.twig +0 -0
  145. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/templates/errors/500.twig +0 -0
  146. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/templates/errors/502.twig +0 -0
  147. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/templates/errors/503.twig +0 -0
  148. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/templates/errors/base.twig +0 -0
  149. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/templates/frontend/README.md +0 -0
  150. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/templates/readme.md +0 -0
  151. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/test/__init__.py +0 -0
  152. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/test_client/__init__.py +0 -0
  153. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  154. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  155. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  156. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  157. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  158. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  159. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  160. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  161. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  162. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  163. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  164. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  165. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/validator/__init__.py +0 -0
  166. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/websocket/__init__.py +0 -0
  167. {tina4_python-3.13.60 → tina4_python-3.13.62}/tina4_python/websocket/backplane.py +0 -0
  168. {tina4_python-3.13.60 → tina4_python-3.13.62}/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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.13.60
3
+ Version: 3.13.62
4
4
  Summary: Tina4 Python v3 — Zero-dependency, lightweight web framework
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.60"
3
+ version = "3.13.62"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -262,16 +262,16 @@ Queue(topic="tasks").push({"action": "send_email"})
262
262
  ### 11. Key tina4_python Gotchas
263
263
 
264
264
  1. **Database import**: Use `from tina4_python.database import Database` (NOT `from tina4_python import Database`)
265
- 2. **noauth/secured import**: Use `from tina4_python.core.router import noauth, secured` (there is NO `tina4_python.Decorators` module). **Never** import `noauth` from `tina4_python.swagger` that version only affects documentation, not actual auth.
265
+ 2. **noauth/secured import**: Use `from tina4_python.core.router import noauth, secured` (there is NO `tina4_python.Decorators` module). `tina4_python.swagger` does **not** export `noauth` (importing it raises `ImportError`). The real docs-only trap is **`@security()`** (from `tina4_python.swagger`): it sets OpenAPI metadata only and does NOT gate a route — use `@secured()`/`@noauth()` for actual auth.
266
266
  2b. **Decorator ordering**: Route decorators (`@get`, `@post`, etc.) must be the **innermost** (closest to the function). Swagger/meta decorators (`@description`, `@tags`, `@noauth`, `@secured`) go above. Correct: `@noauth()` → `@description(...)` → `@post(...)` → `def handler`. Wrong: `@post(...)` → `@description(...)` → `def handler` (will crash).
267
- 3. **Jinja2 template syntax** (common mistakes):
267
+ 3. **Frond template syntax** (Frond is Jinja2-*like*, not Jinja2 — note the differences):
268
268
  - **Ternary operator supported**: Both `{{ x ? 'a' : 'b' }}` and `{{ 'a' if x else 'b' }}` work
269
- - **elif not elseif**: Use `{% elif %}` NOT `{% elseif %}`
269
+ - **elif or elseif**: Frond accepts **both** `{% elif %}` and `{% elseif %}`
270
270
  - **Unescaped output**: Both `{{ var | safe }}` and `{{ var | raw }}` work for unescaped output
271
271
  - **format filter**: Use `{{ "%.2f" | format(value) }}` for number formatting
272
272
  - **e() filter has NO arguments**: Use `{{ var|e }}` NOT `{{ var|e('js') }}` — Jinja2's `|e` is HTML-only with no mode parameter (that's PHP Twig syntax)
273
273
  - **JS string escaping**: Use `{{ var|replace("'", "\\'") }}` to escape single quotes for inline JS onclick handlers
274
- - **No `|escape('js')` or `|e('js')`**: These will throw `escape() takes 1 positional argument but 2 were given`
274
+ - **`|e`/`|escape` are HTML-only**: a mode arg like `|e('js')` is **ignored** by Frond (not an error) for inline-JS escaping use `{{ var|replace("'", "\\'") }}`
275
275
  - **Ternary inline**: Use `{{ 's' if count != 1 else '' }}` NOT `{{ count != 1 ? 's' : '' }}`
276
276
  - **Default values**: Use `{{ var|default('fallback') }}` — works on undefined and None
277
277
  - **Chaining filters**: `{{ var|default('')|replace("'", "\\'") }}` — left to right
@@ -289,7 +289,7 @@ Queue(topic="tasks").push({"action": "send_email"})
289
289
  4. **fetch_one()**: Returns a plain dict (or None), NOT a DatabaseResult
290
290
  5. **Dict access**: All query results use dict access `row["column"]` not attribute access `row.column`
291
291
  6. **Connection strings**: v3 uses standard URL format: `driver://host:port/database` with separate `username` and `password` parameters. Example: `Database("firebird://localhost:3050//path/to/db", "SYSDBA", "masterkey")`. Environment variable: `TINA4_DATABASE_URL`.
292
- 7. **Running the app**: `uv run python app.py <port> <name>` port and name are CLI args handled by tina4_python
292
+ 7. **Running the app**: use the `tina4` CLI — `tina4 serve`. The framework **refuses to boot via a plain `python app.py`** (it must be launched by the CLI; `TINA4_OVERRIDE_CLIENT=true` only for embedding). Port/name are CLI args.
293
293
  8. **SCSS**: Files in `src/scss/` are auto-compiled to `src/public/css/` on startup
294
294
  12. **Background tasks**: Use `background(fn, interval)` from `tina4_python.core.server` — never use `threading.Thread` for periodic work. The `background()` function runs tasks cooperatively in the asyncio event loop with proper shutdown handling.
295
295
 
@@ -8,7 +8,7 @@ Tina4 Python v3.0 — Zero-dependency, lightweight web framework.
8
8
 
9
9
  One import, everything works.
10
10
  """
11
- __version__ = "3.13.60"
11
+ __version__ = "3.13.62"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -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))
@@ -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
@@ -971,6 +971,36 @@ def register_dev_tools(server):
971
971
  spec = Docs(project_root=str(project_root)).method_spec(class_, name)
972
972
  return spec if spec is not None else {"error": f"method not found: {class_}.{name}"}
973
973
 
974
+ # ── Code/doc grounding (tina4_python.context) ───────────────
975
+ # Sibling of api_* but the DUAL of it: api_* is exact structural
976
+ # reflection (class/method signatures); code_search is fuzzy/semantic
977
+ # FTS over the project's own SOURCE + docs. Use code_search for
978
+ # "where/how is X done in THIS codebase?" and api_* for "what's the
979
+ # signature of X?". Backed by a zero-dependency SQLite FTS5 index at
980
+ # .tina4/context.db, kept in a closure so repeat calls reuse it.
981
+ # Held as a PROCESS-WIDE shared Context (context.default_context) so the dev
982
+ # WebSocket reload hook can keep the SAME index fresh on every file save.
983
+ def _code_root():
984
+ return project_root / "src" if (project_root / "src").is_dir() else project_root
985
+
986
+ def code_search(query: str, k: int = 5, rebuild: bool = False) -> list:
987
+ """Fuzzy/semantic search over THIS project's own source + docs
988
+ (SQLite FTS5, zero-dep). Ranks definitions above tests that merely
989
+ mention a symbol. Use for 'where is X done here?'; use api_* for exact
990
+ signatures. First call (or rebuild=True) indexes src/ (falls back to
991
+ the project root); thereafter the dev-reload hook keeps it live on save.
992
+ Returns [{path, score, snippet}]."""
993
+ from tina4_python.context import default_context
994
+ ctx = default_context(root=_code_root(),
995
+ db=str(project_root / ".tina4" / "context.db"))
996
+ if not ctx.available:
997
+ return {"error": "SQLite FTS5 is not available in this Python build; "
998
+ "code_search is disabled."}
999
+ if rebuild:
1000
+ ctx.reset()
1001
+ ctx.index_root(_code_root())
1002
+ return ctx.search(query, k=k)
1003
+
974
1004
  # ── Register all tools ──────────────────────────────────────
975
1005
 
976
1006
  tools = [
@@ -1029,6 +1059,12 @@ def register_dev_tools(server):
1029
1059
  ("api_search", api_search, "Live API search — finds framework + user classes/methods by name/summary. Use BEFORE guessing a method exists."),
1030
1060
  ("api_class", api_class, "Live class reflection — returns full method list + signatures for an FQN."),
1031
1061
  ("api_method", api_method, "Live method reflection — returns signature, params, return type, file, line."),
1062
+
1063
+ # ── Code/doc grounding (semantic FTS over this repo's source) ──
1064
+ # code_search is the fuzzy DUAL of the structural api_* tools:
1065
+ # api_* = exact signature lookup; code_search = "where/how is X
1066
+ # done in THIS codebase?" over source + docs (zero-dep FTS5).
1067
+ ("code_search", code_search, "Fuzzy/semantic search over THIS project's source + docs (FTS5). Use for 'where/how is X done here?'; api_* for exact signatures."),
1032
1068
  ]
1033
1069
 
1034
1070
  for name, handler, description in tools: