probrain 0.1.0__py3-none-any.whl

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 (281) hide show
  1. pb/__init__.py +8 -0
  2. pb/adapters/__init__.py +11 -0
  3. pb/adapters/llm/__init__.py +10 -0
  4. pb/adapters/llm/provider.py +99 -0
  5. pb/agents/__init__.py +48 -0
  6. pb/agents/accountability.py +254 -0
  7. pb/agents/base.py +65 -0
  8. pb/agents/colors.py +70 -0
  9. pb/agents/domain.py +333 -0
  10. pb/agents/review.py +245 -0
  11. pb/agents/spawner.py +326 -0
  12. pb/ai/__init__.py +22 -0
  13. pb/ai/service.py +53 -0
  14. pb/capture/__init__.py +5 -0
  15. pb/capture/server.py +223 -0
  16. pb/cli/__init__.py +24 -0
  17. pb/cli/active_session.py +119 -0
  18. pb/cli/command_runner.py +22 -0
  19. pb/cli/commands/__init__.py +6 -0
  20. pb/cli/commands/anki.py +1023 -0
  21. pb/cli/commands/batch.py +14 -0
  22. pb/cli/commands/brain.py +293 -0
  23. pb/cli/commands/capture.py +68 -0
  24. pb/cli/commands/capture_server.py +53 -0
  25. pb/cli/commands/clarify.py +737 -0
  26. pb/cli/commands/config.py +399 -0
  27. pb/cli/commands/context.py +577 -0
  28. pb/cli/commands/do.py +206 -0
  29. pb/cli/commands/doctor.py +365 -0
  30. pb/cli/commands/execute.py +1001 -0
  31. pb/cli/commands/feedback.py +342 -0
  32. pb/cli/commands/find.py +216 -0
  33. pb/cli/commands/fn.py +9 -0
  34. pb/cli/commands/goals.py +1589 -0
  35. pb/cli/commands/inbox.py +312 -0
  36. pb/cli/commands/ingest.py +86 -0
  37. pb/cli/commands/init.py +443 -0
  38. pb/cli/commands/learn.py +148 -0
  39. pb/cli/commands/mcp.py +172 -0
  40. pb/cli/commands/metric.py +184 -0
  41. pb/cli/commands/model.py +249 -0
  42. pb/cli/commands/next.py +333 -0
  43. pb/cli/commands/note.py +569 -0
  44. pb/cli/commands/notes.py +186 -0
  45. pb/cli/commands/packet.py +13 -0
  46. pb/cli/commands/plan.py +1593 -0
  47. pb/cli/commands/plugin.py +31 -0
  48. pb/cli/commands/practise.py +952 -0
  49. pb/cli/commands/prompts.py +59 -0
  50. pb/cli/commands/review.py +1217 -0
  51. pb/cli/commands/session.py +127 -0
  52. pb/cli/commands/set.py +99 -0
  53. pb/cli/commands/skills.py +120 -0
  54. pb/cli/commands/study.py +1799 -0
  55. pb/cli/commands/suggest.py +54 -0
  56. pb/cli/commands/sync.py +215 -0
  57. pb/cli/commands/system_ops.py +217 -0
  58. pb/cli/commands/tasks.py +289 -0
  59. pb/cli/commands/teach.py +631 -0
  60. pb/cli/commands/vault.py +324 -0
  61. pb/cli/commands/vocab.py +47 -0
  62. pb/cli/console.py +259 -0
  63. pb/cli/context.py +54 -0
  64. pb/cli/context_args.py +65 -0
  65. pb/cli/context_picker.py +250 -0
  66. pb/cli/context_runtime.py +573 -0
  67. pb/cli/display.py +234 -0
  68. pb/cli/glow_style.json +95 -0
  69. pb/cli/helpers.py +1423 -0
  70. pb/cli/input_router.py +384 -0
  71. pb/cli/learning_flow.py +278 -0
  72. pb/cli/learning_transition_flow.py +66 -0
  73. pb/cli/llm_guard.py +75 -0
  74. pb/cli/main.py +1160 -0
  75. pb/cli/markdown.py +202 -0
  76. pb/cli/normalize.py +55 -0
  77. pb/cli/pickers.py +446 -0
  78. pb/cli/preview.py +383 -0
  79. pb/cli/shell.py +1427 -0
  80. pb/cli/task_scoring.py +95 -0
  81. pb/cli/themes/__init__.py +56 -0
  82. pb/cli/themes/catppuccin.py +55 -0
  83. pb/cli/themes/nord.py +53 -0
  84. pb/cli/topic_group.py +26 -0
  85. pb/core/__init__.py +77 -0
  86. pb/core/action_routing.py +1280 -0
  87. pb/core/adjacency.py +218 -0
  88. pb/core/agent_instruction_judge.py +679 -0
  89. pb/core/agent_lifecycle.py +218 -0
  90. pb/core/agent_weights.py +615 -0
  91. pb/core/alignment.py +162 -0
  92. pb/core/anki_bootstrap.py +199 -0
  93. pb/core/anki_mastery_adapter.py +91 -0
  94. pb/core/attenuation.py +50 -0
  95. pb/core/base.py +56 -0
  96. pb/core/brain.py +400 -0
  97. pb/core/chat.py +441 -0
  98. pb/core/clarifier.py +578 -0
  99. pb/core/clock.py +20 -0
  100. pb/core/closeout.py +67 -0
  101. pb/core/concept_navigator.py +227 -0
  102. pb/core/confidence_model.py +59 -0
  103. pb/core/context_classifier.py +85 -0
  104. pb/core/context_file_intake.py +1337 -0
  105. pb/core/context_scope.py +152 -0
  106. pb/core/dedup.py +76 -0
  107. pb/core/dispatch_models.py +94 -0
  108. pb/core/dispatcher.py +852 -0
  109. pb/core/domain_templates.py +180 -0
  110. pb/core/dryrun.py +66 -0
  111. pb/core/durations.py +24 -0
  112. pb/core/encoding_contract.py +216 -0
  113. pb/core/entity_refs.py +120 -0
  114. pb/core/enums.py +221 -0
  115. pb/core/error_logging.py +241 -0
  116. pb/core/evidence_writer.py +365 -0
  117. pb/core/exceptions.py +80 -0
  118. pb/core/feedback_profile.py +244 -0
  119. pb/core/feedback_proposals.py +126 -0
  120. pb/core/finish_assessment.py +470 -0
  121. pb/core/goal_reader.py +114 -0
  122. pb/core/goal_roadmaps.py +629 -0
  123. pb/core/graph_writer.py +359 -0
  124. pb/core/ingestion.py +232 -0
  125. pb/core/insights.py +215 -0
  126. pb/core/intake.py +151 -0
  127. pb/core/interest_hierarchy.py +635 -0
  128. pb/core/learner_memory.py +193 -0
  129. pb/core/learning_block_flow.py +188 -0
  130. pb/core/learning_curriculum.py +390 -0
  131. pb/core/learning_dossier.py +421 -0
  132. pb/core/learning_metadata.py +309 -0
  133. pb/core/learning_partner.py +1554 -0
  134. pb/core/learning_prompting.py +99 -0
  135. pb/core/learning_tasks.py +129 -0
  136. pb/core/learning_transitions.py +305 -0
  137. pb/core/lesson_engine.py +2727 -0
  138. pb/core/matching.py +303 -0
  139. pb/core/math_render.py +273 -0
  140. pb/core/metrics_pedagogy.py +81 -0
  141. pb/core/model_policy.py +61 -0
  142. pb/core/models.py +379 -0
  143. pb/core/naming.py +229 -0
  144. pb/core/packet_engine.py +146 -0
  145. pb/core/planner.py +572 -0
  146. pb/core/prerequisites.py +211 -0
  147. pb/core/priority.py +71 -0
  148. pb/core/probing.py +139 -0
  149. pb/core/product_control.py +594 -0
  150. pb/core/prompts.py +488 -0
  151. pb/core/question_transform.py +93 -0
  152. pb/core/question_tree.py +398 -0
  153. pb/core/refinement_memory.py +297 -0
  154. pb/core/registry.py +204 -0
  155. pb/core/relevance.py +205 -0
  156. pb/core/renderables.py +1234 -0
  157. pb/core/reports.py +451 -0
  158. pb/core/resources.py +66 -0
  159. pb/core/retry_queue.py +197 -0
  160. pb/core/review_engine.py +869 -0
  161. pb/core/review_log_writer.py +131 -0
  162. pb/core/roadmap_dag.py +213 -0
  163. pb/core/rules.py +90 -0
  164. pb/core/schemas.py +297 -0
  165. pb/core/scope_resolution.py +153 -0
  166. pb/core/scorer.py +435 -0
  167. pb/core/screen_time.py +161 -0
  168. pb/core/session_activity.py +217 -0
  169. pb/core/session_blueprints.py +498 -0
  170. pb/core/session_log_writer.py +170 -0
  171. pb/core/sessions.py +338 -0
  172. pb/core/staging.py +228 -0
  173. pb/core/suggestions.py +415 -0
  174. pb/core/timer.py +392 -0
  175. pb/core/verification.py +92 -0
  176. pb/domain/__init__.py +40 -0
  177. pb/domain/enums.py +20 -0
  178. pb/domain/exceptions.py +17 -0
  179. pb/domain/models.py +25 -0
  180. pb/domain/rules.py +12 -0
  181. pb/domain_packs/cardistry.grips.yaml +44 -0
  182. pb/domain_packs/generic.conceptual.yaml +31 -0
  183. pb/domain_packs/generic.creative_artifact.yaml +32 -0
  184. pb/domain_packs/generic.engineering_build_debug.yaml +32 -0
  185. pb/domain_packs/generic.experimental_lab.yaml +33 -0
  186. pb/domain_packs/generic.language.yaml +32 -0
  187. pb/domain_packs/generic.mixed.yaml +30 -0
  188. pb/domain_packs/generic.perceptual.yaml +31 -0
  189. pb/domain_packs/generic.procedural_cognitive.yaml +31 -0
  190. pb/domain_packs/generic.procedural_motor.yaml +33 -0
  191. pb/domain_packs/math.problem_solving.yaml +43 -0
  192. pb/domain_packs/math.proofs.yaml +39 -0
  193. pb/domain_packs/programming.debugging.yaml +43 -0
  194. pb/domain_packs/programming.implementation.yaml +41 -0
  195. pb/domain_packs/universal.fallback.yaml +27 -0
  196. pb/events.py +185 -0
  197. pb/goals/__init__.py +20 -0
  198. pb/goals/repo.py +43 -0
  199. pb/goals/service.py +58 -0
  200. pb/llm/__init__.py +13 -0
  201. pb/llm/drafts.py +483 -0
  202. pb/llm/gemini.py +1080 -0
  203. pb/llm/json_utils.py +31 -0
  204. pb/llm/policy.py +191 -0
  205. pb/llm/runtime.py +1436 -0
  206. pb/llm/structured.py +98 -0
  207. pb/mcp/__init__.py +24 -0
  208. pb/mcp/context.py +52 -0
  209. pb/mcp/encryption.py +153 -0
  210. pb/mcp/pending.py +154 -0
  211. pb/mcp/protocol.py +256 -0
  212. pb/mcp/server.py +70 -0
  213. pb/mcp/tools/__init__.py +15 -0
  214. pb/mcp/tools/pb_tools.py +442 -0
  215. pb/mcp/tools/productivebrain.py +1759 -0
  216. pb/mcp/tools/schema.py +185 -0
  217. pb/mcp/tools/vault.py +424 -0
  218. pb/plan/__init__.py +20 -0
  219. pb/plan/service.py +57 -0
  220. pb/plugins/__init__.py +30 -0
  221. pb/plugins/base.py +33 -0
  222. pb/review/__init__.py +20 -0
  223. pb/review/service.py +50 -0
  224. pb/runtime.py +143 -0
  225. pb/sessions/__init__.py +22 -0
  226. pb/sessions/repo.py +77 -0
  227. pb/sessions/service.py +448 -0
  228. pb/storage/__init__.py +32 -0
  229. pb/storage/config.py +951 -0
  230. pb/storage/database.py +1409 -0
  231. pb/storage/repository.py +3052 -0
  232. pb/storage/yaml_io.py +87 -0
  233. pb/study_service.py +289 -0
  234. pb/tasks/__init__.py +22 -0
  235. pb/tasks/repo.py +38 -0
  236. pb/tasks/service.py +78 -0
  237. pb/templates/communication_session.md +22 -0
  238. pb/templates/event_note.md +36 -0
  239. pb/templates/evidence_generic.md +28 -0
  240. pb/templates/evidence_german.md +19 -0
  241. pb/templates/evidence_math.md +19 -0
  242. pb/templates/evidence_rust.md +19 -0
  243. pb/templates/german_session.md +20 -0
  244. pb/templates/index_md.md +14 -0
  245. pb/templates/math_session.md +16 -0
  246. pb/templates/memory_session.md +19 -0
  247. pb/templates/opportunity_note.md +32 -0
  248. pb/templates/person_note.md +37 -0
  249. pb/templates/piano_session.md +19 -0
  250. pb/templates/project_note.md +16 -0
  251. pb/templates/project_packet.md +36 -0
  252. pb/templates/review_packet.md +19 -0
  253. pb/templates/routine_note.md +29 -0
  254. pb/templates/rust_cpp_session.md +19 -0
  255. pb/templates/session_log.md +29 -0
  256. pb/templates/skill_note.md +23 -0
  257. pb/templates/state_md.md +14 -0
  258. pb/templates/swimming_session.md +19 -0
  259. pb/templates/task_note.md +25 -0
  260. pb/vault/__init__.py +36 -0
  261. pb/vault/anki_client.py +904 -0
  262. pb/vault/anki_service.py +690 -0
  263. pb/vault/batch_client.py +176 -0
  264. pb/vault/concept_note.py +225 -0
  265. pb/vault/config.py +55 -0
  266. pb/vault/embeddings.py +329 -0
  267. pb/vault/graph.py +328 -0
  268. pb/vault/graph_store.py +642 -0
  269. pb/vault/indexer.py +266 -0
  270. pb/vault/lifecycle.py +321 -0
  271. pb/vault/scaffold.py +142 -0
  272. pb/vault/scoring_service.py +216 -0
  273. pb/vault/service.py +55 -0
  274. pb/vault/socratic.py +1036 -0
  275. pb/vault/socratic_service.py +784 -0
  276. probrain-0.1.0.dist-info/METADATA +178 -0
  277. probrain-0.1.0.dist-info/RECORD +281 -0
  278. probrain-0.1.0.dist-info/WHEEL +4 -0
  279. probrain-0.1.0.dist-info/entry_points.txt +5 -0
  280. probrain-0.1.0.dist-info/licenses/LICENSE +661 -0
  281. probrain-0.1.0.dist-info/licenses/NOTICE +8 -0
pb/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # This file is part of ProductiveBrain.
3
+ # Canonical source: https://github.com/qwerty-sapien/pbrain
4
+ # Compliance fingerprint: PB-2026-A17F
5
+
6
+ """ProductiveBrain - CLI-first learning system exposed as the `pb` command."""
7
+
8
+ __version__ = "0.1.0"
@@ -0,0 +1,11 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # This file is part of ProductiveBrain.
3
+ # Canonical source: https://github.com/qwerty-sapien/pbrain
4
+ # Compliance fingerprint: PB-2026-A17F
5
+
6
+ """External adapters for LLM providers.
7
+
8
+ Taskwarrior and Timewarrior adapters moved to memo/.
9
+ """
10
+
11
+ __all__: list[str] = []
@@ -0,0 +1,10 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # This file is part of ProductiveBrain.
3
+ # Canonical source: https://github.com/qwerty-sapien/pbrain
4
+ # Compliance fingerprint: PB-2026-A17F
5
+
6
+ """LLM provider adapters for the learning system runtime."""
7
+
8
+ from pb.adapters.llm.provider import GeminiProvider, LLMProvider, get_provider
9
+
10
+ __all__ = ["LLMProvider", "GeminiProvider", "get_provider"]
@@ -0,0 +1,99 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # This file is part of ProductiveBrain.
3
+ # Canonical source: https://github.com/qwerty-sapien/pbrain
4
+ # Compliance fingerprint: PB-2026-A17F
5
+
6
+ """LLM provider interface backed by the configured Gemini runtime."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+ from typing import Optional
12
+
13
+ import structlog
14
+
15
+ from pb.llm.gemini import FLASH_MODEL, get_client
16
+
17
+
18
+ logger = structlog.get_logger()
19
+
20
+
21
+ class LLMProvider(ABC):
22
+ """Small adapter interface for legacy call sites."""
23
+
24
+ @abstractmethod
25
+ def summarize_session(self, session_notes: str) -> str:
26
+ """Summarize session notes into a concise recap."""
27
+
28
+ @abstractmethod
29
+ def compress_clip(self, raw_text: str) -> str:
30
+ """Compress captured text into a concise packet."""
31
+
32
+ @abstractmethod
33
+ def extract_actions(self, notes: str) -> list[str]:
34
+ """Extract explicit next actions from notes."""
35
+
36
+ @abstractmethod
37
+ def draft_session_summary(self, context: str) -> str:
38
+ """Draft a concise session summary from context."""
39
+
40
+
41
+ class GeminiProvider(LLMProvider):
42
+ """Gemini-backed provider. Raises when credentials are unavailable."""
43
+
44
+ def __init__(self, model: str = FLASH_MODEL):
45
+ self.model = model
46
+ self._client = get_client()
47
+ if not self._client.is_available():
48
+ raise RuntimeError(
49
+ "pb requires an LLM for this workflow.\n\n"
50
+ "Run:\n"
51
+ " pb init llm\n\n"
52
+ "Then configure Gemini credentials via GEMINI_API_KEY or GOOGLE_CLOUD_PROJECT."
53
+ )
54
+
55
+ def _generate(self, prompt: str, *, max_output_tokens: int = 800) -> str:
56
+ result = self._client.generate_with_model(
57
+ prompt,
58
+ self.model,
59
+ timeout=30,
60
+ max_output_tokens=max_output_tokens,
61
+ )
62
+ if not result or not result.strip():
63
+ raise RuntimeError("Gemini returned an empty response.")
64
+ return result.strip()
65
+
66
+ def summarize_session(self, session_notes: str) -> str:
67
+ return self._generate(
68
+ "Summarize this learning session in 4-6 tight sentences. "
69
+ "Focus on what was learned, what degraded, and the next adjustment.\n\n"
70
+ f"{session_notes}",
71
+ )
72
+
73
+ def compress_clip(self, raw_text: str) -> str:
74
+ return self._generate(
75
+ "Compress this learning artifact into a concise study note. Preserve specific facts and action items.\n\n"
76
+ f"{raw_text}",
77
+ )
78
+
79
+ def extract_actions(self, notes: str) -> list[str]:
80
+ raw = self._generate(
81
+ "Extract up to five explicit next actions from these learning notes. "
82
+ "Return one action per line with no bullets or numbering.\n\n"
83
+ f"{notes}",
84
+ max_output_tokens=4000,
85
+ )
86
+ return [line.strip() for line in raw.splitlines() if line.strip()]
87
+
88
+ def draft_session_summary(self, context: str) -> str:
89
+ return self._generate(
90
+ "Draft a short markdown summary for this learning session. "
91
+ "Include summary, evidence, friction, and next action headings.\n\n"
92
+ f"{context}",
93
+ )
94
+
95
+
96
+ def get_provider(provider_name: Optional[str] = None) -> LLMProvider:
97
+ """Return the configured Gemini-backed provider."""
98
+ logger.debug("llm.get_provider", requested=provider_name or "gemini", actual="gemini")
99
+ return GeminiProvider()
pb/agents/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # This file is part of ProductiveBrain.
3
+ # Canonical source: https://github.com/qwerty-sapien/pbrain
4
+ # Compliance fingerprint: PB-2026-A17F
5
+
6
+ """Agent registry for the dispatch subsystem (Phase 10).
7
+
8
+ Provides register/resolve/list helpers that mirror the pb/mcp/pending.py
9
+ pattern but for AgentHandler instances rather than MCP tool implementations.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING, Optional
15
+
16
+ if TYPE_CHECKING:
17
+ from pb.agents.base import AgentHandler
18
+
19
+ _AGENT_REGISTRY: dict[str, "AgentHandler"] = {}
20
+ _AGENTS_LOADED = False
21
+
22
+
23
+ def register_agent(agent_id: str, handler: "AgentHandler") -> None:
24
+ """Register a specialised agent handler under its agent_id."""
25
+ _AGENT_REGISTRY[agent_id] = handler
26
+
27
+
28
+ def _ensure_agents_loaded() -> None:
29
+ """Import all built-in agent modules so they self-register."""
30
+ global _AGENTS_LOADED
31
+ if _AGENTS_LOADED:
32
+ return
33
+ _AGENTS_LOADED = True
34
+ import pb.agents.accountability # noqa: F401
35
+ import pb.agents.spawner # noqa: F401
36
+ import pb.agents.review # noqa: F401
37
+ import pb.agents.domain # noqa: F401
38
+
39
+
40
+ def resolve_agent(agent_id: str) -> Optional["AgentHandler"]:
41
+ """Look up a registered agent by ID. Returns None if not found."""
42
+ _ensure_agents_loaded()
43
+ return _AGENT_REGISTRY.get(agent_id)
44
+
45
+
46
+ def list_agents() -> list[str]:
47
+ """Return sorted list of registered agent IDs."""
48
+ return sorted(_AGENT_REGISTRY.keys())
@@ -0,0 +1,254 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # This file is part of ProductiveBrain.
3
+ # Canonical source: https://github.com/qwerty-sapien/pbrain
4
+ # Compliance fingerprint: PB-2026-A17F
5
+
6
+ """Accountability agent — creates durable commitment records (Phase 10).
7
+
8
+ Persists commitments to SQLite, enforces spam guard (>5/session),
9
+ optionally surfaces avoidance when goals/commitments context is provided.
10
+
11
+ Follows D-03: commitments are passive + contextual; no unsolicited nudging.
12
+ Follows D-04: avoidance detection only surfaces in pb next / pb review context.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Optional
18
+
19
+ import structlog
20
+ from pydantic import BaseModel, Field
21
+
22
+ from pb.agents import register_agent
23
+ from pb.agents.base import AgentHandler
24
+ from pb.core.agent_instruction_judge import agent_instruction_suffix
25
+ from pb.core.dispatch_models import AgentOutput, DispatchSession
26
+ from pb.core.models import generate_internal_id, utc_now
27
+ from pb.llm.structured import structured_output_call
28
+ from pb.storage.database import get_connection
29
+
30
+ logger = structlog.get_logger()
31
+
32
+ _COMMITMENT_SPAM_THRESHOLD = 5
33
+
34
+
35
+ class CommitmentExtraction(BaseModel):
36
+ """LLM extraction of commitment details from user intent."""
37
+
38
+ description: str = Field(description="What the user is committing to")
39
+ due_date: Optional[str] = Field(
40
+ default=None,
41
+ description="ISO date YYYY-MM-DD if mentioned, otherwise null",
42
+ )
43
+ is_commitment: bool = Field(
44
+ description="True if intent is genuinely an accountability request"
45
+ )
46
+ avoidance_note: str = Field(
47
+ default="",
48
+ description=(
49
+ "If context shows the user is avoiding an existing commitment, "
50
+ "describe which one and how many days since last touched. Empty string if none."
51
+ ),
52
+ )
53
+
54
+
55
+ _EXTRACTION_SYSTEM_PROMPT = """\
56
+ You are a commitment extraction assistant. Analyse the user's intent and determine:
57
+ 1. Whether it is a genuine accountability/commitment request (not just a todo or general chat).
58
+ 2. What specific commitment they are making, cleaned of filler words.
59
+ 3. If they mention a deadline, extract it as YYYY-MM-DD.
60
+ 4. If the provided context contains existing commitments or goals that the user appears to be
61
+ avoiding or neglecting (e.g. not mentioned, repeatedly deferred), note the most relevant one.
62
+ Pay special attention to items with approaching deadlines — a commitment due within 3 days
63
+ that the user is ignoring is more urgent than one due in a month.
64
+ Only flag avoidance when context data is provided — do NOT fabricate neglect signals.
65
+
66
+ Accountability requests include: "keep me accountable for X", "I commit to Y", "hold me to Z",
67
+ "remind me to do A by date", "make sure I finish B". Pure todos ("buy milk") are NOT commitments.
68
+ """
69
+
70
+
71
+ class AccountabilityAgent(AgentHandler):
72
+ """Accountability specialised agent.
73
+
74
+ Creates durable commitment records in SQLite (ACCT-01).
75
+ Detects and notes avoidance patterns when context is available (ACCT-02).
76
+ Kicks back non-accountability requests to the dispatcher (in_scope=False).
77
+ """
78
+
79
+ agent_id: str = "accountability"
80
+ display_name: str = "Accountability"
81
+ model_tier: str = "mid"
82
+
83
+ async def handle(
84
+ self,
85
+ intent: str,
86
+ session: DispatchSession,
87
+ *,
88
+ context: Optional[dict] = None,
89
+ ) -> AgentOutput:
90
+ """Extract commitment from intent, persist to SQLite, surface avoidance if relevant.
91
+
92
+ Args:
93
+ intent: Raw user intent string.
94
+ session: Current dispatch session record.
95
+ context: Optional dict. May include "goals" and "commitments" for
96
+ avoidance detection; keys match ReviewAgent convention.
97
+
98
+ Returns:
99
+ AgentOutput. in_scope=False if not an accountability request;
100
+ in_scope=True with persisted commitment and optional avoidance note otherwise.
101
+ """
102
+ ctx = context or {}
103
+ extraction = await self._extract_commitment(intent, ctx)
104
+
105
+ if extraction is None:
106
+ # LLM failed — try to proceed with a minimal commitment
107
+ logger.warning("accountability.extraction_failed", session_id=session.id)
108
+ return await self._persist_and_respond(
109
+ session_id=session.id,
110
+ description=intent.strip(),
111
+ due_date=None,
112
+ avoidance_note="",
113
+ )
114
+
115
+ if not extraction.is_commitment:
116
+ logger.info(
117
+ "accountability.not_a_commitment",
118
+ session_id=session.id,
119
+ intent=intent[:80],
120
+ )
121
+ return AgentOutput(
122
+ in_scope=False,
123
+ scope_reason="Not an accountability request",
124
+ response="",
125
+ )
126
+
127
+ return await self._persist_and_respond(
128
+ session_id=session.id,
129
+ description=extraction.description,
130
+ due_date=extraction.due_date,
131
+ avoidance_note=extraction.avoidance_note,
132
+ )
133
+
134
+ async def _extract_commitment(
135
+ self, intent: str, ctx: dict
136
+ ) -> Optional[CommitmentExtraction]:
137
+ """Call mid-tier LLM to extract structured commitment data."""
138
+ context_block = ""
139
+ goals = ctx.get("goals", [])
140
+ commitments = ctx.get("commitments", [])
141
+ deadline_todos = ctx.get("deadline_todos", [])
142
+ if goals or commitments or deadline_todos:
143
+ goals_summary = ", ".join(
144
+ getattr(g, "title", str(g)) for g in goals[:5]
145
+ )
146
+ commitments_summary = "; ".join(
147
+ (c.get("description", "") if isinstance(c, dict) else str(c))
148
+ for c in commitments[:5]
149
+ )
150
+ deadline_lines = []
151
+ for t in deadline_todos[:5]:
152
+ due = getattr(t, "due_date", None)
153
+ due_str = due.strftime("%Y-%m-%d") if due else "no deadline"
154
+ deadline_lines.append(f"{getattr(t, 'title', '?')} (due {due_str})")
155
+ deadline_summary = "; ".join(deadline_lines) if deadline_lines else "none"
156
+
157
+ context_block = (
158
+ f"\n\nContext (use only for avoidance detection):\n"
159
+ f"Active goals: {goals_summary or 'none'}\n"
160
+ f"Existing commitments: {commitments_summary or 'none'}\n"
161
+ f"Deadline-approaching tasks: {deadline_summary}"
162
+ )
163
+
164
+ prompt = (
165
+ f"User intent: {intent!r}{context_block}\n\n"
166
+ f"Extract commitment details."
167
+ )
168
+ try:
169
+ return await structured_output_call(
170
+ prompt,
171
+ CommitmentExtraction,
172
+ system_prompt=_EXTRACTION_SYSTEM_PROMPT
173
+ + agent_instruction_suffix(self.agent_id),
174
+ tier="mid",
175
+ )
176
+ except Exception as exc:
177
+ logger.warning("accountability.llm_error", error=str(exc))
178
+ return None
179
+
180
+ async def _persist_and_respond(
181
+ self,
182
+ *,
183
+ session_id: str,
184
+ description: str,
185
+ due_date: Optional[str],
186
+ avoidance_note: str,
187
+ ) -> AgentOutput:
188
+ """Insert commitment into SQLite and build response."""
189
+ commitment_id = generate_internal_id()
190
+ created_at = utc_now().isoformat()
191
+
192
+ try:
193
+ with get_connection() as conn:
194
+ # T-10-09: fully parameterised INSERT, no string interpolation
195
+ conn.execute(
196
+ "INSERT INTO commitments "
197
+ "(id, description, created_at, due_date, status, session_id) "
198
+ "VALUES (?, ?, ?, ?, ?, ?)",
199
+ (commitment_id, description, created_at, due_date, "active", session_id),
200
+ )
201
+ conn.commit()
202
+
203
+ # Commitment spam guard (T-10-09)
204
+ row = conn.execute(
205
+ "SELECT COUNT(*) FROM commitments WHERE session_id = ?",
206
+ (session_id,),
207
+ ).fetchone()
208
+ session_count: int = row[0] if row else 0
209
+
210
+ except Exception as exc:
211
+ logger.warning(
212
+ "accountability.persist_error",
213
+ error=str(exc),
214
+ session_id=session_id,
215
+ )
216
+ return AgentOutput(
217
+ in_scope=True,
218
+ response=f"I tried to record your commitment ({description}) but hit a storage error. Please try again.",
219
+ status="complete",
220
+ )
221
+
222
+ logger.info(
223
+ "accountability.commitment_created",
224
+ commitment_id=commitment_id,
225
+ session_id=session_id,
226
+ due_date=due_date,
227
+ )
228
+
229
+ due_suffix = f" by {due_date}" if due_date else ""
230
+ response = (
231
+ f"Committed: {description}{due_suffix}. "
232
+ f"I'll surface this in your next review and recommendations."
233
+ )
234
+
235
+ if session_count > _COMMITMENT_SPAM_THRESHOLD:
236
+ response += (
237
+ f"\n\nYou've created {_COMMITMENT_SPAM_THRESHOLD}+ commitments this session. "
238
+ f"Are these all real commitments?"
239
+ )
240
+
241
+ # D-04: avoidance note only surfaces when caller explicitly passes context
242
+ # (i.e. called from pb next / pb review context, not from a cold do "...")
243
+ if avoidance_note:
244
+ response += f"\n\nNote: {avoidance_note}"
245
+
246
+ return AgentOutput(
247
+ in_scope=True,
248
+ response=response,
249
+ status="complete",
250
+ )
251
+
252
+
253
+ # Register at module level so importing this module side-effects the registry
254
+ register_agent("accountability", AccountabilityAgent())
pb/agents/base.py ADDED
@@ -0,0 +1,65 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # This file is part of ProductiveBrain.
3
+ # Canonical source: https://github.com/qwerty-sapien/pbrain
4
+ # Compliance fingerprint: PB-2026-A17F
5
+
6
+ """AgentHandler ABC — contract all specialised agents implement (Phase 10)."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import abc
11
+ from typing import Optional
12
+
13
+ import structlog
14
+
15
+ from pb.core.dispatch_models import AgentOutput, DispatchSession, InteractionEnvelope
16
+
17
+ logger = structlog.get_logger()
18
+
19
+
20
+ class AgentHandler(abc.ABC):
21
+ """Abstract base class for all specialised dispatch agents.
22
+
23
+ Every agent must declare its ``agent_id``, ``display_name``, and implement
24
+ ``handle()`` to process a user intent within a session context.
25
+ """
26
+
27
+ agent_id: str
28
+ display_name: str
29
+ model_tier: str = "mid" # lite | mid | heavy
30
+
31
+ @abc.abstractmethod
32
+ async def handle(
33
+ self,
34
+ intent: str,
35
+ session: DispatchSession,
36
+ *,
37
+ context: Optional[dict] = None,
38
+ ) -> AgentOutput:
39
+ """Process user intent and return structured output.
40
+
41
+ Args:
42
+ intent: The raw user intent string.
43
+ session: The current dispatch session record.
44
+ context: Optional extra context dict (e.g. goal data, history).
45
+
46
+ Returns:
47
+ AgentOutput with in_scope flag, response, options, and status.
48
+ """
49
+ ...
50
+
51
+ def to_envelope(
52
+ self, session: DispatchSession, output: AgentOutput
53
+ ) -> InteractionEnvelope:
54
+ """Convert AgentOutput to the uniform InteractionEnvelope wire format.
55
+
56
+ scope_reason stays in AgentOutput (internal). It is intentionally NOT
57
+ propagated to the envelope (T-10-03 mitigation).
58
+ """
59
+ return InteractionEnvelope(
60
+ session_id=session.id,
61
+ status=output.status,
62
+ prompt=output.response,
63
+ options=output.options,
64
+ fields=output.fields,
65
+ )
pb/agents/colors.py ADDED
@@ -0,0 +1,70 @@
1
+ """Unique quaternary colors for agent labels in terminal output.
2
+
3
+ Each registered agent gets a distinct Rich color name from a 24-color
4
+ quaternary palette. When more than 24 agents exist, the color of the
5
+ agent inactive for the longest period is recycled.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import time
10
+
11
+ _QUATERNARY_PALETTE: list[str] = [
12
+ "bright_cyan",
13
+ "bright_magenta",
14
+ "bright_yellow",
15
+ "bright_green",
16
+ "dodger_blue2",
17
+ "dark_orange",
18
+ "medium_purple1",
19
+ "spring_green2",
20
+ "deep_pink2",
21
+ "chartreuse2",
22
+ "cornflower_blue",
23
+ "indian_red1",
24
+ "turquoise2",
25
+ "orchid1",
26
+ "gold1",
27
+ "pale_green1",
28
+ "hot_pink",
29
+ "sky_blue1",
30
+ "salmon1",
31
+ "medium_spring_green",
32
+ "plum2",
33
+ "light_goldenrod2",
34
+ "aquamarine1",
35
+ "light_coral",
36
+ ]
37
+
38
+ _PALETTE_SIZE = len(_QUATERNARY_PALETTE)
39
+
40
+ _agent_color_map: dict[str, str] = {}
41
+ _agent_last_seen: dict[str, float] = {}
42
+
43
+
44
+ def color_for_agent(agent_id: str) -> str:
45
+ """Return a unique Rich color name for the given agent ID.
46
+
47
+ Deterministic for known agents. If the palette is full, evicts the
48
+ agent with the oldest last-seen timestamp and reassigns its color.
49
+ """
50
+ now = time.monotonic()
51
+ _agent_last_seen[agent_id] = now
52
+
53
+ if agent_id in _agent_color_map:
54
+ return _agent_color_map[agent_id]
55
+
56
+ used_colors = set(_agent_color_map.values())
57
+ for color in _QUATERNARY_PALETTE:
58
+ if color not in used_colors:
59
+ _agent_color_map[agent_id] = color
60
+ return color
61
+
62
+ # Palette exhausted — evict the longest-inactive agent
63
+ oldest_agent = min(
64
+ (aid for aid in _agent_last_seen if aid != agent_id and aid in _agent_color_map),
65
+ key=lambda aid: _agent_last_seen[aid],
66
+ )
67
+ recycled_color = _agent_color_map.pop(oldest_agent)
68
+ _agent_last_seen.pop(oldest_agent, None)
69
+ _agent_color_map[agent_id] = recycled_color
70
+ return recycled_color