codespy-ai 2.0.1__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/PKG-INFO +2 -2
  2. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/pyproject.toml +2 -2
  3. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/__init__.py +1 -1
  4. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/cerebral/cerebral.py +237 -50
  5. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/cerebral/cost.py +12 -6
  6. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/cerebral/structured_output.py +7 -199
  7. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/prefrontal/format.py +1 -5
  8. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/prefrontal/prefrontal.py +3 -3
  9. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/prefrontal/reach.py +46 -10
  10. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/review/scope/agent.py +15 -29
  11. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/config_memory.py +128 -284
  12. codespy_ai-2.0.1/src/codespy/agents/memory/cerebral/delta_ops_compat.py +0 -197
  13. codespy_ai-2.0.1/src/codespy/agents/memory/cerebral/routines.py +0 -230
  14. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/LICENSE +0 -0
  15. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/README.md +0 -0
  16. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/__init__.py +0 -0
  17. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/context_safe.py +0 -0
  18. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/cost_tracker.py +0 -0
  19. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/diagnostic_adapter.py +0 -0
  20. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/dspy_config.py +0 -0
  21. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/__init__.py +0 -0
  22. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/cerebral/__init__.py +0 -0
  23. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/cerebral/orphans.py +0 -0
  24. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/hippocampus/__init__.py +0 -0
  25. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/hippocampus/budget.py +0 -0
  26. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/hippocampus/cartographer.py +0 -0
  27. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/hippocampus/context_memory.py +0 -0
  28. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/hippocampus/distiller.py +0 -0
  29. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/hippocampus/episode.py +0 -0
  30. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/hippocampus/hippocampus.py +0 -0
  31. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/pg0_manager.py +0 -0
  32. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/postgres.py +0 -0
  33. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/prefrontal/__init__.py +0 -0
  34. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/prefrontal/facets.py +0 -0
  35. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/prefrontal/query.py +0 -0
  36. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/memory/recall.py +0 -0
  37. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/review/__init__.py +0 -0
  38. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/review/audit/__init__.py +0 -0
  39. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/review/audit/agent.py +0 -0
  40. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/review/code_review/__init__.py +0 -0
  41. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/review/code_review/agent.py +0 -0
  42. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/review/doc/__init__.py +0 -0
  43. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/review/doc/agent.py +0 -0
  44. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/review/doc/doc_extractor.py +0 -0
  45. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/review/helpers.py +0 -0
  46. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/review/models.py +0 -0
  47. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/review/scope/__init__.py +0 -0
  48. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/review/scope/manifest_parser.py +0 -0
  49. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/review/scope/models.py +0 -0
  50. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/review/summary/__init__.py +0 -0
  51. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/review/summary/agent.py +0 -0
  52. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/review/supply_chain/__init__.py +0 -0
  53. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/review/supply_chain/agent.py +0 -0
  54. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/agents/rlm_tools.py +0 -0
  55. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/cli.py +0 -0
  56. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/cli_local.py +0 -0
  57. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/cli_mcp_server.py +0 -0
  58. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/cli_remote.py +0 -0
  59. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/config.py +0 -0
  60. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/config_dspy.py +0 -0
  61. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/config_git.py +0 -0
  62. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/config_io.py +0 -0
  63. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/config_llm.py +0 -0
  64. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/config_utils.py +0 -0
  65. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/__init__.py +0 -0
  66. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/cyber/__init__.py +0 -0
  67. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/cyber/osv/__init__.py +0 -0
  68. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/cyber/osv/client.py +0 -0
  69. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/cyber/osv/models.py +0 -0
  70. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/cyber/osv/server.py +0 -0
  71. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/git/__init__.py +0 -0
  72. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/git/base.py +0 -0
  73. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/git/client.py +0 -0
  74. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/git/github_client.py +0 -0
  75. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/git/gitlab_client.py +0 -0
  76. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/git/local_diff.py +0 -0
  77. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/git/models.py +0 -0
  78. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/git/server.py +0 -0
  79. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/mcp_utils.py +0 -0
  80. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/__init__.py +0 -0
  81. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/ripgrep/__init__.py +0 -0
  82. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/ripgrep/client.py +0 -0
  83. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/ripgrep/server.py +0 -0
  84. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/__init__.py +0 -0
  85. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/base_extractor.py +0 -0
  86. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/extractors/__init__.py +0 -0
  87. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/extractors/bash.py +0 -0
  88. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/extractors/cpp.py +0 -0
  89. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/extractors/csharp.py +0 -0
  90. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/extractors/go.py +0 -0
  91. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/extractors/java.py +0 -0
  92. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/extractors/javascript.py +0 -0
  93. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/extractors/kotlin.py +0 -0
  94. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/extractors/objc.py +0 -0
  95. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/extractors/php.py +0 -0
  96. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/extractors/python.py +0 -0
  97. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/extractors/ripgrep_fallback.py +0 -0
  98. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/extractors/ruby.py +0 -0
  99. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/extractors/rust.py +0 -0
  100. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/extractors/swift.py +0 -0
  101. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/extractors/terraform.py +0 -0
  102. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/models.py +0 -0
  103. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/parser.py +0 -0
  104. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/parsers/treesitter/server.py +0 -0
  105. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/storage/__init__.py +0 -0
  106. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/storage/base.py +0 -0
  107. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/storage/filesystem/__init__.py +0 -0
  108. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/storage/filesystem/client.py +0 -0
  109. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/storage/filesystem/server.py +0 -0
  110. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/storage/models.py +0 -0
  111. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/storage/s3/__init__.py +0 -0
  112. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/storage/s3/client.py +0 -0
  113. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/storage/s3/server.py +0 -0
  114. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/web/__init__.py +0 -0
  115. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/web/client.py +0 -0
  116. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/web/models.py +0 -0
  117. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/tools/web/server.py +0 -0
  118. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/workflows/__init__.py +0 -0
  119. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/workflows/review/__init__.py +0 -0
  120. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/workflows/review/models.py +0 -0
  121. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/workflows/review/pipeline.py +0 -0
  122. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/workflows/review/reporters/__init__.py +0 -0
  123. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/workflows/review/reporters/base.py +0 -0
  124. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/workflows/review/reporters/git.py +0 -0
  125. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/workflows/review/reporters/stdout.py +0 -0
  126. {codespy_ai-2.0.1 → codespy_ai-2.1.0}/src/codespy/workflows/review/server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codespy-ai
3
- Version: 2.0.1
3
+ Version: 2.1.0
4
4
  Summary: Code review agent powered by DSPy
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -23,7 +23,7 @@ Requires-Dist: cloudpickle (>=3.1.2,<4.0.0)
23
23
  Requires-Dist: ddgs (>=8.0.0)
24
24
  Requires-Dist: dspy[mcp] (>=3.3.0,<4.0.0)
25
25
  Requires-Dist: gitpython (>=3.1.42)
26
- Requires-Dist: hindsight-api-slim (>=0.10.1)
26
+ Requires-Dist: hindsight-api-slim (>=1.0.0,<2)
27
27
  Requires-Dist: httpx (>=0.28.0)
28
28
  Requires-Dist: json-repair (>=0.60.1)
29
29
  Requires-Dist: litellm (>=1.84.0,<2.0.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "codespy-ai"
3
- version = "2.0.1"
3
+ version = "2.1.0"
4
4
  description = "Code review agent powered by DSPy"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -53,7 +53,7 @@ beautifulsoup4 = ">=4.12.0"
53
53
  markdownify = ">=0.15.0"
54
54
  ddgs = ">=8.0.0"
55
55
  pg0-embedded = ">=0.15"
56
- hindsight-api-slim = ">=0.10.1"
56
+ hindsight-api-slim = ">=1.0.0,<2"
57
57
 
58
58
  [tool.poetry.group.dev.dependencies]
59
59
  pytest = ">=8.0.0"
@@ -1,3 +1,3 @@
1
1
  """codespy - Code review agent powered by DSPy."""
2
2
 
3
- __version__ = "2.0.1"
3
+ __version__ = "2.1.0"
@@ -33,7 +33,7 @@ HINDSIGHT_SCHEMA_DEFAULT = "semantic"
33
33
  # needs sentence-transformers and emits a misleading startup warning
34
34
  # (hindsight_api/config.py _validate). That config is built and cached at IMPORT
35
35
  # time — the `from hindsight_api import MemoryEngine` below pulls in
36
- # engine.llm_wrapper, whose module-level _get_raw_config() calls
36
+ # engine.llm_wrapper, which builds Hindsight's cached config via
37
37
  # HindsightConfig.from_env(). So these env vars MUST be set before that import
38
38
  # line, not in Cerebral.__init__ (too late). We inject our own LiteLLM-SDK
39
39
  # embeddings and an RRF-passthrough cross-encoder in Cerebral.__init__, so the
@@ -61,20 +61,12 @@ from hindsight_api.extensions.builtin.tenant import DefaultTenantExtension
61
61
  from hindsight_api.models import RequestContext
62
62
  from hindsight_api.worker.exceptions import DeferOperation
63
63
 
64
- # Install delta operations coercion shim for string-encoded operations
65
- # (see delta_ops_compat.py for details)
66
- from codespy.agents.memory.cerebral.delta_ops_compat import install_delta_ops_coercion
67
-
68
- install_delta_ops_coercion()
69
-
70
- # Install native structured output support and Bedrock schema sanitizer
64
+ # Register native structured output for Bedrock models litellm's map lacks the flag for
71
65
  from codespy.agents.memory.cerebral.structured_output import (
72
- install_bedrock_schema_sanitizer,
73
66
  register_native_structured_output_overrides,
74
67
  )
75
68
 
76
69
  register_native_structured_output_overrides()
77
- install_bedrock_schema_sanitizer()
78
70
 
79
71
 
80
72
  class _SchemaSyncTaskBackend(SyncTaskBackend):
@@ -233,13 +225,14 @@ from codespy.agents.memory.cerebral.cost import (
233
225
  MeteredLiteLLMSDKEmbeddings,
234
226
  register_cerebral_cost_recorder,
235
227
  )
236
- from codespy.agents.memory.cerebral.routines import ensure_maintenance_routines
237
228
  from codespy.agents.memory.prefrontal.reach import (
238
229
  TAG_ORG,
239
230
  TAG_PROJECT_SCOPE,
240
231
  TAG_REPO,
232
+ is_root_scope,
241
233
  mental_model_id,
242
234
  repo_tags,
235
+ root_scope_id,
243
236
  scope_tags,
244
237
  )
245
238
 
@@ -281,17 +274,23 @@ HINDSIGHT_SCHEMA = HINDSIGHT_SCHEMA_DEFAULT
281
274
  # - include_chunks=False: Observation text only (no file chunks)
282
275
  # - reflect_search_observations_max_tokens=3000: Down from 5000 default
283
276
  # - reflect_search_observations_include_entities=False: Exclude entity metadata
277
+ # - budget=low: a refresh gets reflect_max_iterations // 2 iterations, like Prefrontal
278
+ # reflect (Hindsight treats a null budget as MID for refreshes)
279
+ # - keep_trace=True: store the refresh trace with the briefing
280
+ # Key names must be exact: create_mental_model stores the dict without validating it.
284
281
  _BRIEFING_TRIGGER_STATIC = {
285
282
  "mode": "delta",
286
283
  "exclude_mental_models": True,
287
284
  "include_chunks": False,
288
285
  "reflect_search_observations_max_tokens": 3000,
289
286
  "reflect_search_observations_include_entities": False,
287
+ "budget": "low",
288
+ "keep_trace": True,
290
289
  }
291
290
 
292
- # Content Hindsight's HTTP API gives a mental model before its first refresh.
293
- # Prefrontal skips briefings that still hold it.
294
- MENTAL_MODEL_PLACEHOLDER = "Generating content..."
291
+ # Content pre-1.0 Hindsight gave a mental model before its first refresh.
292
+ # Only used to clear it from upgraded rows (see Cerebral._clear_legacy_placeholders).
293
+ _LEGACY_PLACEHOLDER = "Generating content..."
295
294
 
296
295
  # Generic (not code-review specific) question each briefing answers.
297
296
  MENTAL_MODEL_SOURCE_QUERY = (
@@ -300,6 +299,26 @@ MENTAL_MODEL_SOURCE_QUERY = (
300
299
  "dependencies and integrations, pitfalls and recurring problems, facts shown to be wrong."
301
300
  )
302
301
 
302
+ # Question the repo-root briefing answers. The root scope of a monorepo sees
303
+ # facts from many scopes; this briefing must keep only what holds for all of them.
304
+ ROOT_MENTAL_MODEL_SOURCE_QUERY = (
305
+ "What should an agent starting work anywhere in this repository know, that holds for "
306
+ "every scope of it: repository layout and naming conventions, how scopes and manifests "
307
+ "are organized, shared tooling, build, CI and deployment, cross-scope integrations and "
308
+ "contracts, and repository-wide pitfalls or facts shown to be wrong. "
309
+ "Do not describe individual services, packages or libraries, their files, pull requests "
310
+ "or review findings; omit anything that is true for only one scope."
311
+ )
312
+
313
+ # A delta refresh only adds content, so briefings grow past max_tokens. Past this
314
+ # factor of max_tokens, consolidate_run forces one full refresh (rewrite from scratch).
315
+ BRIEFING_FULL_REFRESH_FACTOR = 1.5
316
+
317
+
318
+ def _estimate_tokens(text: str) -> int:
319
+ """Cheap token estimate (~4 chars per token), same heuristic as prefrontal.format."""
320
+ return (len(text or "") + 3) // 4
321
+
303
322
  OBSERVATIONS_MISSION = (
304
323
  "Merge facts about the same subject into one observation. Keep details that matter only "
305
324
  "to one kind of work (e.g. documentation, security, dependencies) instead of generalizing "
@@ -443,19 +462,19 @@ class Cerebral:
443
462
  # Install two-step structured output fallback when extraction model is configured
444
463
  if extraction_llm_model:
445
464
  try:
446
- from hindsight_api.engine.llm_wrapper import LLMConfig
465
+ from hindsight_api.engine.llm_wrapper import LLMProvider
447
466
  from codespy.agents.memory.cerebral.structured_output import (
448
467
  install_two_step_structured_output,
449
468
  )
450
469
 
451
- extraction_cfg = LLMConfig(
470
+ extraction_llm = LLMProvider(
452
471
  provider="litellm",
453
472
  model=extraction_llm_model,
454
473
  api_key=extraction_llm_api_key,
455
474
  base_url=extraction_llm_base_url or "",
456
475
  reasoning_effort=None,
457
476
  )
458
- extraction_impl = extraction_cfg._provider_impl
477
+ extraction_impl = extraction_llm._provider_impl
459
478
  install_two_step_structured_output(self._engine, extraction_impl)
460
479
  except Exception:
461
480
  logger.warning(
@@ -466,11 +485,6 @@ class Cerebral:
466
485
 
467
486
  self._run_async(self._engine.initialize())
468
487
 
469
- # One-time repair: install maintenance routines if missing.
470
- # This is idempotent and only needed for databases that were migrated
471
- # before HINDSIGHT_API_DATABASE_SCHEMA was set to "semantic" (upstream #2638).
472
- self._run_async(ensure_maintenance_routines(self._engine, HINDSIGHT_SCHEMA))
473
-
474
488
  # Repair orphaned pending operations left by inline SyncTaskBackend
475
489
  from codespy.agents.memory.cerebral.orphans import repair_orphaned_operations
476
490
 
@@ -574,14 +588,11 @@ class Cerebral:
574
588
  # pending rows from blocking scoped submissions).
575
589
  "enable_auto_consolidation": False,
576
590
  }
577
- # Per-scope observation cap (not bank-wide; -1 means unlimited)
578
- if self._max_observations_per_scope >= 0:
579
- updates["observation_scope_limits"] = [
580
- {
581
- "scope": [f"{TAG_ORG}*", f"{TAG_REPO}*", f"{TAG_PROJECT_SCOPE}*"],
582
- "limit": self._max_observations_per_scope,
583
- }
584
- ]
591
+ # Per-scope observation cap (never the bank-wide max_observations_per_scope).
592
+ # Sent unconditionally so -1 (unlimited) lifts a previously stored cap.
593
+ updates["consolidation_strategies"] = self._consolidation_strategies()
594
+ # Tombstone the deprecated key (bank config merges with `config || $1`).
595
+ updates["observation_scope_limits"] = None
585
596
  self._run_async(
586
597
  self._engine.update_bank_config(
587
598
  self._bank_id,
@@ -589,10 +600,58 @@ class Cerebral:
589
600
  request_context=ctx,
590
601
  )
591
602
  )
603
+ self._clear_legacy_placeholders(ctx)
592
604
  except Exception:
593
605
  logger.warning("cerebral: failed to configure bank %s", self._bank_id, exc_info=True)
594
606
  self._bank_ensured = True
595
607
 
608
+ def _consolidation_strategies(self) -> list[dict[str, Any]]:
609
+ """``consolidation_strategies`` payload carrying the per-scope observation cap."""
610
+ return [
611
+ {
612
+ "scopes": [
613
+ {
614
+ "tags": [f"{TAG_ORG}*", f"{TAG_REPO}*", f"{TAG_PROJECT_SCOPE}*"],
615
+ "tags_match": "exact",
616
+ }
617
+ ],
618
+ "max_observations_per_scope": self._max_observations_per_scope,
619
+ }
620
+ ]
621
+
622
+ def _clear_legacy_placeholders(self, ctx: RequestContext) -> int:
623
+ """Empty briefings that still hold the pre-1.0 placeholder. Returns the count.
624
+
625
+ Pages through every mental model of the bank and rewrites legacy rows
626
+ with ``content=""`` (what Hindsight 1.0 creates pages with).
627
+ """
628
+ cleared = 0
629
+ offset = 0
630
+ while True:
631
+ page = self._run_async(
632
+ self._engine.list_mental_models(
633
+ self._bank_id,
634
+ detail="full",
635
+ limit=100,
636
+ offset=offset,
637
+ request_context=ctx,
638
+ )
639
+ )
640
+ for item in page.items:
641
+ if (item.get("content") or "").strip().startswith(_LEGACY_PLACEHOLDER):
642
+ self._run_async(
643
+ self._engine.update_mental_model(
644
+ self._bank_id, item["id"], content="", request_context=ctx
645
+ )
646
+ )
647
+ cleared += 1
648
+ offset += len(page.items)
649
+ if not page.items or offset >= page.total:
650
+ break
651
+ if cleared:
652
+ logger.info("cerebral: cleared legacy placeholder from %d briefing(s)", cleared)
653
+ return cleared
654
+
596
655
  @staticmethod
597
656
  def _mutation_lines(episode: Episode) -> list[str]:
598
657
  """Build observation lines from episode mutations.
@@ -823,16 +882,21 @@ class Cerebral:
823
882
  if topic.type == "project_scope"
824
883
  ]
825
884
  # Fallback to repo-root project scope instead of bare [org:, repo:]
826
- return scopes or [scope_tags(repo_full_name, repo_full_name)]
885
+ return scopes or [scope_tags(repo_full_name, root_scope_id(repo_full_name))]
827
886
 
828
887
  @staticmethod
829
888
  def _mental_model_scopes(
830
889
  scopes: Sequence[Sequence[str]], repo_full_name: str
831
890
  ) -> list[list[str]]:
832
- """Observation scopes plus the repo scope ``[org:, repo:]`` (always added, deduped)."""
891
+ """Observation scopes whose briefings are kept (deduped).
892
+
893
+ No bare ``[org:, repo:]`` scope: its briefing would read every fact of
894
+ the repo (all scopes of a monorepo). Repo-wide knowledge lives in the
895
+ repo-root scope ``[org:, repo:, project_scope:<repo>]`` instead.
896
+ """
833
897
  out: list[list[str]] = []
834
898
  seen: set[tuple[str, ...]] = set()
835
- for sc in [*scopes, repo_tags(repo_full_name)]:
899
+ for sc in scopes:
836
900
  key = tuple(sorted(sc))
837
901
  if key not in seen:
838
902
  seen.add(key)
@@ -840,15 +904,43 @@ class Cerebral:
840
904
  return out
841
905
 
842
906
  @staticmethod
843
- def _mental_model_name(tags: Sequence[str]) -> str:
907
+ def _scope_id_of(tags: Sequence[str]) -> str | None:
844
908
  for tag in tags:
845
909
  if tag.startswith(TAG_PROJECT_SCOPE):
846
- return f"Briefing: {tag[len(TAG_PROJECT_SCOPE):]}"
910
+ return tag[len(TAG_PROJECT_SCOPE):]
911
+ return None
912
+
913
+ @staticmethod
914
+ def _repo_of(tags: Sequence[str]) -> str | None:
847
915
  for tag in tags:
848
- if tag.startswith("repo:"):
849
- return f"Briefing: {tag[len('repo:'):]}"
916
+ if tag.startswith(TAG_REPO):
917
+ return tag[len(TAG_REPO):]
918
+ return None
919
+
920
+ @classmethod
921
+ def _is_root_briefing(cls, tags: Sequence[str]) -> bool:
922
+ scope_id = cls._scope_id_of(tags)
923
+ repo = cls._repo_of(tags)
924
+ return bool(scope_id and repo and is_root_scope(repo, scope_id))
925
+
926
+ @classmethod
927
+ def _mental_model_name(cls, tags: Sequence[str]) -> str:
928
+ scope_id = cls._scope_id_of(tags)
929
+ if scope_id:
930
+ if cls._is_root_briefing(tags):
931
+ return f"Briefing: {scope_id} (repo-wide)"
932
+ return f"Briefing: {scope_id}"
933
+ repo = cls._repo_of(tags)
934
+ if repo:
935
+ return f"Briefing: {repo}"
850
936
  return "Briefing"
851
937
 
938
+ @classmethod
939
+ def _mental_model_source_query(cls, tags: Sequence[str]) -> str:
940
+ if cls._is_root_briefing(tags):
941
+ return ROOT_MENTAL_MODEL_SOURCE_QUERY
942
+ return MENTAL_MODEL_SOURCE_QUERY
943
+
852
944
  def _sync_briefing_triggers(self, scopes: list[list[str]]) -> None:
853
945
  """Sync briefing triggers to match current settings before consolidation.
854
946
 
@@ -863,11 +955,7 @@ class Cerebral:
863
955
  if not scopes:
864
956
  return
865
957
 
866
- desired_trigger = {
867
- **_BRIEFING_TRIGGER_STATIC,
868
- "refresh_after_consolidation": self._mental_models,
869
- "min_refresh_interval_seconds": self._min_mental_model_refresh_seconds,
870
- }
958
+ desired_trigger = self._desired_trigger()
871
959
 
872
960
  for tags in scopes:
873
961
  mm_id = mental_model_id(tags)
@@ -891,8 +979,8 @@ class Cerebral:
891
979
  self._engine.create_mental_model(
892
980
  self._bank_id,
893
981
  self._mental_model_name(tags),
894
- MENTAL_MODEL_SOURCE_QUERY,
895
- MENTAL_MODEL_PLACEHOLDER,
982
+ self._mental_model_source_query(tags),
983
+ "",
896
984
  mental_model_id=mm_id,
897
985
  tags=sorted(tags),
898
986
  max_tokens=self._max_mental_model_tokens,
@@ -914,11 +1002,12 @@ class Cerebral:
914
1002
  self._engine.get_mental_model(self._bank_id, mm_id, request_context=ctx)
915
1003
  )
916
1004
  else:
917
- # Existing briefing: update trigger if it differs
1005
+ # Existing briefing: update trigger if it differs. Stored triggers
1006
+ # are merged supersets (update_mental_model patches), so compare
1007
+ # only the keys codespy sends.
918
1008
  current_trigger = existing.get("trigger") or {}
919
- needs_update = (
920
- current_trigger.get("refresh_after_consolidation") != self._mental_models
921
- or current_trigger.get("exclude_mental_models") is not True
1009
+ needs_update = any(
1010
+ current_trigger.get(k) != v for k, v in desired_trigger.items()
922
1011
  )
923
1012
  if needs_update:
924
1013
  self._run_async(
@@ -935,11 +1024,103 @@ class Cerebral:
935
1024
  "on" if current_trigger.get("refresh_after_consolidation") else "off",
936
1025
  "on" if self._mental_models else "off",
937
1026
  )
1027
+ # Name / source query drift (e.g. the repo-root briefing moved to
1028
+ # its repo-wide question). A changed source query makes Hindsight's
1029
+ # next delta refresh fall back to a full rewrite.
1030
+ desired_name = self._mental_model_name(tags)
1031
+ desired_query = self._mental_model_source_query(tags)
1032
+ if (
1033
+ existing.get("name") != desired_name
1034
+ or existing.get("source_query") != desired_query
1035
+ ):
1036
+ self._run_async(
1037
+ self._engine.update_mental_model(
1038
+ self._bank_id,
1039
+ mm_id,
1040
+ name=desired_name,
1041
+ source_query=desired_query,
1042
+ request_context=ctx,
1043
+ )
1044
+ )
1045
+ logger.info(
1046
+ "cerebral: briefing %s renamed/re-queried as %r (next refresh is full)",
1047
+ mm_id,
1048
+ desired_name,
1049
+ )
938
1050
 
939
1051
  self._ensured_mental_models.add(mm_id)
940
1052
  except Exception:
941
1053
  logger.warning("cerebral: failed to sync briefing trigger for %s", mm_id, exc_info=True)
942
1054
 
1055
+ def _desired_trigger(self) -> dict[str, Any]:
1056
+ """Briefing trigger for the current settings (delta mode)."""
1057
+ return {
1058
+ **_BRIEFING_TRIGGER_STATIC,
1059
+ "refresh_after_consolidation": self._mental_models,
1060
+ "min_refresh_interval_seconds": self._min_mental_model_refresh_seconds,
1061
+ }
1062
+
1063
+ def _enforce_briefing_size(self, scopes: Sequence[Sequence[str]]) -> None:
1064
+ """Force a full refresh of briefings grown past the size limit. Never raises.
1065
+
1066
+ Delta refreshes only add content, so a briefing drifts past
1067
+ ``max_mental_model_tokens``. Above ``BRIEFING_FULL_REFRESH_FACTOR`` times
1068
+ that budget, the briefing is rewritten from scratch once: the trigger is
1069
+ switched to ``mode=full``, one explicit refresh runs inline (sync task
1070
+ backend), then the delta trigger is restored. Never deletes anything.
1071
+ """
1072
+ if not self._mental_models or not scopes:
1073
+ return
1074
+ limit = int(self._max_mental_model_tokens * BRIEFING_FULL_REFRESH_FACTOR)
1075
+ for tags in scopes:
1076
+ mm_id = mental_model_id(tags)
1077
+ try:
1078
+ ctx = RequestContext()
1079
+ mm = self._run_async(
1080
+ self._engine.get_mental_model(self._bank_id, mm_id, request_context=ctx)
1081
+ )
1082
+ if not mm:
1083
+ continue
1084
+ before = _estimate_tokens((mm.get("content") or "").strip())
1085
+ if before <= limit:
1086
+ continue
1087
+ desired = self._desired_trigger()
1088
+ self._run_async(
1089
+ self._engine.update_mental_model(
1090
+ self._bank_id,
1091
+ mm_id,
1092
+ trigger={**desired, "mode": "full"},
1093
+ request_context=ctx,
1094
+ )
1095
+ )
1096
+ try:
1097
+ self._run_async(
1098
+ self._engine.submit_async_refresh_mental_model(
1099
+ self._bank_id, mm_id, request_context=ctx
1100
+ )
1101
+ )
1102
+ finally:
1103
+ self._run_async(
1104
+ self._engine.update_mental_model(
1105
+ self._bank_id, mm_id, trigger=desired, request_context=ctx
1106
+ )
1107
+ )
1108
+ after_mm = self._run_async(
1109
+ self._engine.get_mental_model(self._bank_id, mm_id, request_context=ctx)
1110
+ )
1111
+ after = _estimate_tokens(((after_mm or {}).get("content") or "").strip())
1112
+ logger.info(
1113
+ "cerebral: briefing %s (%s) over size (%d > %d tokens); "
1114
+ "full refresh -> %d tokens",
1115
+ mm_id,
1116
+ self._mental_model_name(tags),
1117
+ before,
1118
+ limit,
1119
+ after,
1120
+ )
1121
+ except Exception:
1122
+ logger.warning("cerebral: briefing size guard failed for %s", mm_id, exc_info=True)
1123
+
943
1124
  def consolidate_run(self, run_id: str) -> None:
944
1125
  """Trigger one consolidation for all scopes touched during a run.
945
1126
 
@@ -986,6 +1167,8 @@ class Cerebral:
986
1167
  observation_scopes=scopes,
987
1168
  )
988
1169
  )
1170
+ # Rewrite briefings that delta refreshes grew past their budget
1171
+ self._enforce_briefing_size(scopes)
989
1172
 
990
1173
  delta = {b: _calls(b) - baseline[b] for b in buckets}
991
1174
  logger.info(
@@ -1134,10 +1317,10 @@ class Cerebral:
1134
1317
  summary.rewrite = True
1135
1318
 
1136
1319
  # Count iterations from agent_<n> scopes (excluding _err variants)
1137
- # +1 if there's any final scope (map, reduce, or rewrite)
1320
+ # +1 if there's any final scope (map, reduce, rewrite, or Hindsight 1.0's closing_done)
1138
1321
  agent_scopes = {s for s in scope_names if s.startswith("agent_") and not s.endswith("_err")}
1139
1322
  summary.iterations = len(agent_scopes)
1140
- if any(s.startswith("final") for s in scope_names):
1323
+ if any(s.startswith("final") or s == "closing_done" for s in scope_names):
1141
1324
  summary.iterations += 1
1142
1325
 
1143
1326
  # Tools from tool_trace (list of ToolCallTrace with output dict)
@@ -1218,6 +1401,10 @@ class Cerebral:
1218
1401
  if topic.type == "project_scope":
1219
1402
  tags.append(f"{topic.type}:{topic.id}")
1220
1403
  if repo_full_name:
1404
+ # No scope topic: tag with the repo-root scope, matching the
1405
+ # _observation_scopes fallback (recall reads project_scope:, not repo:).
1406
+ if not tags:
1407
+ tags.append(f"{TAG_PROJECT_SCOPE}{root_scope_id(repo_full_name)}")
1221
1408
  tags.extend(repo_tags(repo_full_name))
1222
1409
  tags.append(f"task:{episode.task}")
1223
1410
  return tags
@@ -117,6 +117,7 @@ class CerebralCostRecorder:
117
117
  scope: str = "",
118
118
  input_tokens: int = 0,
119
119
  output_tokens: int = 0,
120
+ thoughts_tokens: int | None = 0,
120
121
  error: Exception | None = None,
121
122
  duration: float = 0.0,
122
123
  **_: Any,
@@ -127,7 +128,10 @@ class CerebralCostRecorder:
127
128
  model: Full litellm model string (e.g., "bedrock/converse/moonshotai.kimi-k2.5")
128
129
  scope: Hindsight scope (e.g., "retain_extract_facts", "consolidation")
129
130
  input_tokens: Input/prompt token count
130
- output_tokens: Output/completion token count
131
+ output_tokens: Visible output/completion token count
132
+ thoughts_tokens: Reasoning tokens. Hindsight >=0.10.2 reports them apart
133
+ from ``output_tokens``; they are billed as output, so they are
134
+ folded into the output count used for pricing and tracking.
131
135
  error: Exception if the call failed
132
136
  duration: Duration of the call in seconds
133
137
  **_: Ignored extra kwargs for forward compatibility
@@ -137,21 +141,23 @@ class CerebralCostRecorder:
137
141
  and errored calls that reached the provider (with billed usage).
138
142
  """
139
143
  try:
144
+ billed_output = output_tokens + (thoughts_tokens or 0)
145
+
140
146
  # Skip calls with no tokens (includes failed calls that never reached provider)
141
- if input_tokens == 0 and output_tokens == 0:
147
+ if input_tokens == 0 and billed_output == 0:
142
148
  return
143
149
 
144
150
  # Price the call using litellm
145
- prompt_cost, completion_cost = self._price_call_split(model, input_tokens, output_tokens)
151
+ prompt_cost, completion_cost = self._price_call_split(model, input_tokens, billed_output)
146
152
  cost = prompt_cost + completion_cost
147
- tokens = input_tokens + output_tokens
153
+ tokens = input_tokens + billed_output
148
154
 
149
155
  # Determine bucket: an active recall wins over the scope/task tag,
150
156
  # because Hindsight scopes cannot tell a reflect apart
151
157
  # from a mental-model refresh.
152
158
  usage = current_recall_usage.get()
153
159
  if usage is not None:
154
- usage.add_llm(model, input_tokens, output_tokens, prompt_cost, completion_cost)
160
+ usage.add_llm(model, input_tokens, billed_output, prompt_cost, completion_cost)
155
161
  bucket = BUCKET_MEMORY_RECALL
156
162
  else:
157
163
  bucket = _llm_bucket(scope)
@@ -166,7 +172,7 @@ class CerebralCostRecorder:
166
172
  tokens=tokens,
167
173
  calls=1,
168
174
  input_tokens=input_tokens,
169
- output_tokens=output_tokens,
175
+ output_tokens=billed_output,
170
176
  input_cost=prompt_cost,
171
177
  output_cost=completion_cost,
172
178
  duration=duration,