minder-cli 0.2.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 (132) hide show
  1. minder/__init__.py +12 -0
  2. minder/api/routers/prompts.py +177 -0
  3. minder/application/__init__.py +1 -0
  4. minder/application/admin/__init__.py +11 -0
  5. minder/application/admin/dto.py +453 -0
  6. minder/application/admin/jobs.py +327 -0
  7. minder/application/admin/use_cases.py +1895 -0
  8. minder/auth/__init__.py +12 -0
  9. minder/auth/context.py +26 -0
  10. minder/auth/middleware.py +70 -0
  11. minder/auth/principal.py +59 -0
  12. minder/auth/rate_limiter.py +89 -0
  13. minder/auth/rbac.py +60 -0
  14. minder/auth/service.py +541 -0
  15. minder/bootstrap/__init__.py +9 -0
  16. minder/bootstrap/providers.py +109 -0
  17. minder/bootstrap/transport.py +807 -0
  18. minder/cache/__init__.py +10 -0
  19. minder/cache/providers.py +140 -0
  20. minder/chunking/__init__.py +4 -0
  21. minder/chunking/code_splitter.py +184 -0
  22. minder/chunking/splitter.py +136 -0
  23. minder/cli.py +1542 -0
  24. minder/config.py +179 -0
  25. minder/continuity.py +363 -0
  26. minder/dev.py +160 -0
  27. minder/embedding/__init__.py +9 -0
  28. minder/embedding/base.py +7 -0
  29. minder/embedding/local.py +65 -0
  30. minder/embedding/openai.py +7 -0
  31. minder/graph/__init__.py +11 -0
  32. minder/graph/edges.py +13 -0
  33. minder/graph/executor.py +127 -0
  34. minder/graph/graph.py +263 -0
  35. minder/graph/nodes/__init__.py +27 -0
  36. minder/graph/nodes/evaluator.py +21 -0
  37. minder/graph/nodes/guard.py +64 -0
  38. minder/graph/nodes/llm.py +59 -0
  39. minder/graph/nodes/planning.py +30 -0
  40. minder/graph/nodes/reasoning.py +87 -0
  41. minder/graph/nodes/reranker.py +141 -0
  42. minder/graph/nodes/retriever.py +86 -0
  43. minder/graph/nodes/verification.py +230 -0
  44. minder/graph/nodes/workflow_planner.py +250 -0
  45. minder/graph/runtime.py +15 -0
  46. minder/graph/state.py +26 -0
  47. minder/llm/__init__.py +5 -0
  48. minder/llm/base.py +14 -0
  49. minder/llm/local.py +381 -0
  50. minder/llm/openai.py +89 -0
  51. minder/models/__init__.py +109 -0
  52. minder/models/base.py +10 -0
  53. minder/models/client.py +137 -0
  54. minder/models/document.py +34 -0
  55. minder/models/error.py +32 -0
  56. minder/models/graph.py +114 -0
  57. minder/models/history.py +32 -0
  58. minder/models/job.py +62 -0
  59. minder/models/prompt.py +41 -0
  60. minder/models/repository.py +62 -0
  61. minder/models/rule.py +68 -0
  62. minder/models/session.py +51 -0
  63. minder/models/skill.py +52 -0
  64. minder/models/user.py +41 -0
  65. minder/models/workflow.py +35 -0
  66. minder/observability/__init__.py +57 -0
  67. minder/observability/audit.py +243 -0
  68. minder/observability/logging.py +253 -0
  69. minder/observability/metrics.py +448 -0
  70. minder/observability/tracing.py +215 -0
  71. minder/presentation/__init__.py +1 -0
  72. minder/presentation/http/__init__.py +1 -0
  73. minder/presentation/http/admin/__init__.py +3 -0
  74. minder/presentation/http/admin/api.py +1309 -0
  75. minder/presentation/http/admin/context.py +94 -0
  76. minder/presentation/http/admin/dashboard.py +111 -0
  77. minder/presentation/http/admin/jobs.py +208 -0
  78. minder/presentation/http/admin/memories.py +185 -0
  79. minder/presentation/http/admin/prompts.py +219 -0
  80. minder/presentation/http/admin/routes.py +127 -0
  81. minder/presentation/http/admin/runtime.py +650 -0
  82. minder/presentation/http/admin/search.py +368 -0
  83. minder/presentation/http/admin/skills.py +230 -0
  84. minder/prompts/__init__.py +646 -0
  85. minder/prompts/formatter.py +142 -0
  86. minder/resources/__init__.py +318 -0
  87. minder/retrieval/__init__.py +5 -0
  88. minder/retrieval/hybrid.py +178 -0
  89. minder/retrieval/mmr.py +116 -0
  90. minder/retrieval/multi_hop.py +115 -0
  91. minder/runtime.py +15 -0
  92. minder/server.py +145 -0
  93. minder/store/__init__.py +64 -0
  94. minder/store/document.py +115 -0
  95. minder/store/error.py +82 -0
  96. minder/store/feedback.py +114 -0
  97. minder/store/graph.py +588 -0
  98. minder/store/history.py +57 -0
  99. minder/store/interfaces.py +512 -0
  100. minder/store/milvus/__init__.py +11 -0
  101. minder/store/milvus/client.py +26 -0
  102. minder/store/milvus/collections.py +15 -0
  103. minder/store/milvus/vector_store.py +232 -0
  104. minder/store/mongodb/__init__.py +11 -0
  105. minder/store/mongodb/client.py +49 -0
  106. minder/store/mongodb/indexes.py +90 -0
  107. minder/store/mongodb/operational_store.py +993 -0
  108. minder/store/relational.py +1087 -0
  109. minder/store/repo_state.py +58 -0
  110. minder/store/rule.py +93 -0
  111. minder/store/vector.py +79 -0
  112. minder/tools/__init__.py +47 -0
  113. minder/tools/auth.py +94 -0
  114. minder/tools/graph.py +839 -0
  115. minder/tools/ingest.py +353 -0
  116. minder/tools/memory.py +381 -0
  117. minder/tools/query.py +307 -0
  118. minder/tools/registry.py +269 -0
  119. minder/tools/repo_scanner.py +1266 -0
  120. minder/tools/search.py +15 -0
  121. minder/tools/session.py +316 -0
  122. minder/tools/skills.py +899 -0
  123. minder/tools/workflow.py +215 -0
  124. minder/transport/__init__.py +4 -0
  125. minder/transport/base.py +286 -0
  126. minder/transport/sse.py +252 -0
  127. minder/transport/stdio.py +29 -0
  128. minder_cli-0.2.0.dist-info/METADATA +318 -0
  129. minder_cli-0.2.0.dist-info/RECORD +132 -0
  130. minder_cli-0.2.0.dist-info/WHEEL +4 -0
  131. minder_cli-0.2.0.dist-info/entry_points.txt +2 -0
  132. minder_cli-0.2.0.dist-info/licenses/LICENSE +201 -0
minder/config.py ADDED
@@ -0,0 +1,179 @@
1
+ from typing import Optional
2
+
3
+ from pydantic import BaseModel, Field
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict, TomlConfigSettingsSource
5
+
6
+
7
+ class ServerConfig(BaseModel):
8
+ name: str = "minder"
9
+ version: str = "0.1.0"
10
+ transport: str = "sse"
11
+ host: str = "0.0.0.0"
12
+ port: int = 8800
13
+ log_level: str = "info"
14
+
15
+
16
+ class DashboardConfig(BaseModel):
17
+ base_path: str = "/dashboard"
18
+ static_dir: str = "src/dashboard/dist"
19
+ dev_server_url: str | None = None
20
+ api_url: str | None = None
21
+
22
+
23
+ class AuthConfig(BaseModel):
24
+ enabled: bool = True
25
+ jwt_secret: str = "dev-secret-key-change-me-in-prod"
26
+ jwt_expiry_hours: int = 24
27
+ api_key_prefix: str = "mk_"
28
+ client_api_key_prefix: str = "mkc_"
29
+ client_token_expiry_minutes: int = 60
30
+ default_admin_email: str = "admin@example.com"
31
+
32
+
33
+ class EmbeddingConfig(BaseModel):
34
+ provider: str = "llamacpp"
35
+ model_name: str = "ggml-org/embeddinggemma-300M-GGUF"
36
+ model_path: str = "~/.minder/models/embeddinggemma-300M-Q8_0.gguf"
37
+ dimensions: int = 768
38
+ openai_api_key: Optional[str] = None
39
+ openai_model: str = "text-embedding-3-small"
40
+
41
+
42
+ class LLMConfig(BaseModel):
43
+ provider: str = "llamacpp"
44
+ model_name: str = "ggml-org/gemma-4-E2B-it-GGUF"
45
+ model_path: str = "~/.minder/models/gemma-4-e2b-it-Q8_0.gguf"
46
+ context_length: int = 131072
47
+ temperature: float = 0.1
48
+ openai_api_key: Optional[str] = None
49
+ openai_model: str = "gpt-4o-mini"
50
+
51
+
52
+ class VectorStoreConfig(BaseModel):
53
+ provider: str = "milvus_lite" # "milvus" (standalone) | "milvus_lite" | "memory"
54
+ db_path: str = "~/.minder/data/milvus.db" # used by milvus_lite only
55
+ uri: str = "http://localhost:19530" # used by milvus standalone
56
+ collection_prefix: str = "minder_"
57
+
58
+
59
+ class RelationalStoreConfig(BaseModel):
60
+ provider: str = "mongodb" # "mongodb" | "sqlite" | "postgresql"
61
+ db_path: str = "minder.db" # used by sqlite
62
+ uri: str = "postgresql+asyncpg://localhost/minder" # used by postgresql
63
+
64
+
65
+ class GraphStoreConfig(BaseModel):
66
+ enabled: bool = True
67
+ provider: str = "auto" # "auto" | "sqlite" | "postgresql"
68
+ db_path: str = "~/.minder/data/graph.db" # used by sqlite
69
+ uri: str = "postgresql+asyncpg://localhost/minder_graph" # used by postgresql
70
+
71
+
72
+ class MongoDBConfig(BaseModel):
73
+ uri: str = "mongodb://localhost:27017"
74
+ database: str = "minder"
75
+ min_pool_size: int = 5
76
+ max_pool_size: int = 50
77
+
78
+
79
+ class RedisConfig(BaseModel):
80
+ uri: str = "redis://localhost:6379/0"
81
+ prefix: str = "minder:"
82
+ session_ttl: int = 86400
83
+ cache_ttl: int = 3600
84
+
85
+
86
+ class RetrievalConfig(BaseModel):
87
+ top_k: int = 10
88
+ rerank_top_n: int = 5
89
+ similarity_threshold: float = 0.7
90
+ hybrid_alpha: float = 0.7
91
+
92
+
93
+ class CacheConfig(BaseModel):
94
+ enabled: bool = True
95
+ provider: str = "redis" # "redis" is the only supported runtime backend
96
+ max_size: int = (
97
+ 1000 # unused; kept for backwards-compat with any existing .env files
98
+ )
99
+ ttl_seconds: int = 3600
100
+
101
+
102
+ class RateLimitConfig(BaseModel):
103
+ enabled: bool = False
104
+ window_seconds: int = 60
105
+ admin_limit: int = 120
106
+ member_limit: int = 60
107
+ readonly_limit: int = 20
108
+ client_limit: int = 90
109
+
110
+
111
+ class VerificationConfig(BaseModel):
112
+ enabled: bool = True
113
+ sandbox: str = "docker"
114
+ timeout_seconds: int = 30
115
+ docker_image: str = "minder-sandbox:latest"
116
+
117
+
118
+ class WorkflowConfig(BaseModel):
119
+ enforcement: str = "strict"
120
+ default_workflow: str = "tdd"
121
+ repo_state_dir: str = ".minder"
122
+ block_step_skips: bool = True
123
+ orchestration_runtime: str = "internal"
124
+
125
+
126
+ class SeedingConfig(BaseModel):
127
+ skills_repo: str = ""
128
+ skills_branch: str = "main"
129
+ skills_path: str = "skills/"
130
+
131
+
132
+ class Settings(BaseSettings):
133
+ server: ServerConfig = Field(default_factory=ServerConfig)
134
+ dashboard: DashboardConfig = Field(default_factory=DashboardConfig)
135
+ auth: AuthConfig = Field(default_factory=AuthConfig)
136
+ embedding: EmbeddingConfig = Field(default_factory=EmbeddingConfig)
137
+ llm: LLMConfig = Field(default_factory=LLMConfig)
138
+ vector_store: VectorStoreConfig = Field(default_factory=VectorStoreConfig)
139
+ relational_store: RelationalStoreConfig = Field(
140
+ default_factory=RelationalStoreConfig
141
+ )
142
+ graph_store: GraphStoreConfig = Field(default_factory=GraphStoreConfig)
143
+ mongodb: MongoDBConfig = Field(default_factory=MongoDBConfig)
144
+ redis: RedisConfig = Field(default_factory=RedisConfig)
145
+ retrieval: RetrievalConfig = Field(default_factory=RetrievalConfig)
146
+ cache: CacheConfig = Field(default_factory=CacheConfig)
147
+ rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)
148
+ verification: VerificationConfig = Field(default_factory=VerificationConfig)
149
+ workflow: WorkflowConfig = Field(default_factory=WorkflowConfig)
150
+ seeding: SeedingConfig = Field(default_factory=SeedingConfig)
151
+
152
+ model_config = SettingsConfigDict(
153
+ env_prefix="MINDER_",
154
+ env_nested_delimiter="__",
155
+ env_file=".env",
156
+ env_file_encoding="utf-8",
157
+ toml_file="minder.toml",
158
+ )
159
+
160
+ @classmethod
161
+ def settings_customise_sources(
162
+ cls,
163
+ settings_cls,
164
+ init_settings,
165
+ env_settings,
166
+ dotenv_settings,
167
+ file_secret_settings,
168
+ ):
169
+ return (
170
+ init_settings,
171
+ env_settings,
172
+ dotenv_settings,
173
+ TomlConfigSettingsSource(settings_cls),
174
+ file_secret_settings,
175
+ )
176
+
177
+
178
+ MinderConfig = Settings
179
+ settings = Settings()
minder/continuity.py ADDED
@@ -0,0 +1,363 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from typing import Any
6
+
7
+ from minder.config import MinderConfig
8
+
9
+
10
+ def normalize_step_name(value: str | None) -> str:
11
+ return str(value or "").strip().lower()
12
+
13
+
14
+ def step_keywords(step_name: str | None) -> set[str]:
15
+ normalized = normalize_step_name(step_name)
16
+ return {
17
+ token
18
+ for token in normalized.replace("/", " ").replace("_", " ").split()
19
+ if token
20
+ }
21
+
22
+
23
+ def required_artifacts_for_step(step_name: str | None) -> list[str]:
24
+ normalized = normalize_step_name(step_name)
25
+ if "problem" in normalized or "intake" in normalized:
26
+ return ["problem_statement", "acceptance_criteria"]
27
+ if "analysis" in normalized or "use case" in normalized:
28
+ return ["analysis_notes", "use_cases"]
29
+ if "test" in normalized:
30
+ return ["test_plan", "failing_tests"]
31
+ if "implement" in normalized:
32
+ return ["implementation_notes", "changed_files"]
33
+ if "verif" in normalized:
34
+ return ["verification_report", "test_results"]
35
+ if "review" in normalized:
36
+ return ["review_notes", "approval_summary"]
37
+ if "release" in normalized or "deploy" in normalized:
38
+ return ["release_notes", "rollback_plan"]
39
+ return ["step_notes"]
40
+
41
+
42
+ def allowed_tools_for_step(step_name: str | None) -> list[str]:
43
+ normalized = normalize_step_name(step_name)
44
+ base_tools = [
45
+ "minder_session_restore",
46
+ "minder_session_save",
47
+ "minder_session_context",
48
+ "minder_memory_recall",
49
+ "minder_workflow_step",
50
+ "minder_workflow_guard",
51
+ ]
52
+ if "test" in normalized:
53
+ return base_tools + ["minder_search_code", "minder_search_errors"]
54
+ if "implement" in normalized:
55
+ return base_tools + ["minder_search_code", "minder_query"]
56
+ if "review" in normalized:
57
+ return base_tools + ["minder_query", "minder_search_code"]
58
+ return base_tools + ["minder_search", "minder_search_code"]
59
+
60
+
61
+ def forbidden_actions_for_step(
62
+ step_name: str | None,
63
+ *,
64
+ blocked_by: list[str],
65
+ current_step: str | None,
66
+ ) -> list[str]:
67
+ forbidden = ["skip_required_steps", "ignore_workflow_state"]
68
+ if blocked_by:
69
+ forbidden.append("advance_while_blocked")
70
+ if normalize_step_name(step_name) != normalize_step_name(current_step):
71
+ forbidden.append("jump_to_unapproved_step")
72
+ return forbidden
73
+
74
+
75
+ def output_contract_for_step(step_name: str | None) -> dict[str, Any]:
76
+ normalized = normalize_step_name(step_name)
77
+ if "test" in normalized:
78
+ return {
79
+ "type": "test_spec",
80
+ "must_include": ["target_behaviour", "failing_assertions", "scope_limit"],
81
+ }
82
+ if "implement" in normalized:
83
+ return {
84
+ "type": "implementation_update",
85
+ "must_include": ["changed_files", "minimal_fix", "verification_plan"],
86
+ }
87
+ if "review" in normalized:
88
+ return {
89
+ "type": "review_report",
90
+ "must_include": [
91
+ "blocking_issues",
92
+ "recommended_changes",
93
+ "residual_risks",
94
+ ],
95
+ }
96
+ return {
97
+ "type": "step_update",
98
+ "must_include": ["summary", "next_actions"],
99
+ }
100
+
101
+
102
+ def build_instruction_envelope(
103
+ *,
104
+ workflow: Any,
105
+ workflow_state: Any,
106
+ ) -> dict[str, Any]:
107
+ current_step = getattr(workflow_state, "current_step", None)
108
+ blocked_by = list(getattr(workflow_state, "blocked_by", []) or [])
109
+ artifacts = dict(getattr(workflow_state, "artifacts", {}) or {})
110
+ required_artifacts = required_artifacts_for_step(current_step)
111
+ return {
112
+ "workflow_id": str(getattr(workflow, "id", "")),
113
+ "workflow_version": getattr(workflow, "version", 1),
114
+ "workflow_name": getattr(workflow, "name", ""),
115
+ "current_step": current_step,
116
+ "next_step": getattr(workflow_state, "next_step", None),
117
+ "blocked_by": blocked_by,
118
+ "required_artifacts": required_artifacts,
119
+ "required_artifact_status": {
120
+ artifact_name: artifact_name in artifacts and bool(artifacts[artifact_name])
121
+ for artifact_name in required_artifacts
122
+ },
123
+ "forbidden_actions": forbidden_actions_for_step(
124
+ current_step,
125
+ blocked_by=blocked_by,
126
+ current_step=current_step,
127
+ ),
128
+ "allowed_tools": allowed_tools_for_step(current_step),
129
+ "output_contract": output_contract_for_step(current_step),
130
+ "policies": dict(getattr(workflow, "policies", {}) or {}),
131
+ }
132
+
133
+
134
+ def build_continuity_brief(
135
+ *,
136
+ session: Any,
137
+ workflow_state: Any | None = None,
138
+ workflow: Any | None = None,
139
+ recalled_memories: list[dict[str, Any]] | None = None,
140
+ ) -> dict[str, Any]:
141
+ state = dict(getattr(session, "state", {}) or {})
142
+ project_context = dict(getattr(session, "project_context", {}) or {})
143
+ active_skills = dict(getattr(session, "active_skills", {}) or {})
144
+ blocked_by = (
145
+ list(getattr(workflow_state, "blocked_by", []) or []) if workflow_state else []
146
+ )
147
+ completed_steps = (
148
+ list(getattr(workflow_state, "completed_steps", []) or [])
149
+ if workflow_state
150
+ else []
151
+ )
152
+ current_step = (
153
+ getattr(workflow_state, "current_step", None) if workflow_state else None
154
+ )
155
+ next_step = getattr(workflow_state, "next_step", None) if workflow_state else None
156
+
157
+ task = (
158
+ state.get("task")
159
+ or state.get("checkpoint")
160
+ or state.get("phase")
161
+ or "Active engineering task"
162
+ )
163
+ repo_path = project_context.get("repo_path") or project_context.get("repo")
164
+ branch = project_context.get("branch")
165
+ open_files = list(project_context.get("open_files", []) or [])
166
+ state_blockers = state.get("blockers") or state.get("blocked_by") or []
167
+ blockers = [
168
+ str(item) for item in [*blocked_by, *state_blockers] if str(item).strip()
169
+ ]
170
+ next_actions = list(state.get("next_steps", []) or [])
171
+ if not next_actions and next_step:
172
+ next_actions.append(
173
+ f"Advance to {next_step} once current step requirements are satisfied."
174
+ )
175
+ if current_step and not next_actions:
176
+ next_actions.append(f"Complete the remaining artifacts for {current_step}.")
177
+
178
+ confirmed_progress: list[str] = []
179
+ if completed_steps:
180
+ confirmed_progress.append(
181
+ f"Completed workflow steps: {', '.join(completed_steps)}"
182
+ )
183
+ if open_files:
184
+ confirmed_progress.append(f"Open files in focus: {', '.join(open_files)}")
185
+ if active_skills:
186
+ confirmed_progress.append(
187
+ f"Active skills: {', '.join(sorted(str(key) for key in active_skills.keys()))}"
188
+ )
189
+
190
+ risk_signals: list[str] = []
191
+ if blockers:
192
+ risk_signals.append("workflow_blocked")
193
+ if workflow and getattr(workflow, "enforcement", "") == "strict":
194
+ risk_signals.append("strict_workflow_enforcement")
195
+ if not open_files:
196
+ risk_signals.append("low_editor_context")
197
+
198
+ source_refs = [f"session:{getattr(session, 'id', '')}"]
199
+ if workflow_state is not None:
200
+ source_refs.append(f"workflow_state:{getattr(workflow_state, 'id', '')}")
201
+ if recalled_memories:
202
+ source_refs.extend(
203
+ f"memory:{item['id']}" for item in recalled_memories[:3] if item.get("id")
204
+ )
205
+
206
+ return {
207
+ "problem_framing": {
208
+ "task": task,
209
+ "repo_path": repo_path,
210
+ "branch": branch,
211
+ "workflow_step": current_step,
212
+ },
213
+ "confirmed_progress": confirmed_progress,
214
+ "unresolved_blockers": blockers,
215
+ "risk_signals": risk_signals,
216
+ "next_valid_actions": next_actions,
217
+ "source_references": source_refs,
218
+ }
219
+
220
+
221
+ def compatibility_score_for_memory(
222
+ *,
223
+ tags: list[str],
224
+ title: str,
225
+ content: str,
226
+ current_step: str | None,
227
+ artifact_type: str | None,
228
+ ) -> tuple[float, list[str]]:
229
+ reasons: list[str] = []
230
+ score = 0.0
231
+ normalized_tags = {str(tag).strip().lower() for tag in tags if str(tag).strip()}
232
+ text = f"{title} {content}".lower()
233
+
234
+ if current_step:
235
+ step_words = step_keywords(current_step)
236
+ if step_words & normalized_tags:
237
+ score += 1.0
238
+ reasons.append("tag_matches_workflow_step")
239
+ elif any(word in text for word in step_words):
240
+ score += 0.6
241
+ reasons.append("content_mentions_workflow_step")
242
+
243
+ if artifact_type:
244
+ normalized_artifact = artifact_type.strip().lower()
245
+ if normalized_artifact in normalized_tags:
246
+ score += 0.5
247
+ reasons.append("tag_matches_artifact_type")
248
+ elif normalized_artifact in text:
249
+ score += 0.3
250
+ reasons.append("content_mentions_artifact_type")
251
+
252
+ return min(score, 1.5), reasons
253
+
254
+
255
+ def _extract_json_object(raw: str) -> dict[str, Any] | None:
256
+ if not raw.strip():
257
+ return None
258
+ candidates = re.findall(r"\{.*\}", raw, flags=re.DOTALL)
259
+ for candidate in candidates:
260
+ try:
261
+ value = json.loads(candidate)
262
+ except json.JSONDecodeError:
263
+ continue
264
+ if isinstance(value, dict):
265
+ return value
266
+ return None
267
+
268
+
269
+ class ContinuitySynthesizer:
270
+ def __init__(self, config: MinderConfig) -> None:
271
+ from minder.llm.local import LocalModelLLM
272
+
273
+ self._config = config
274
+ self._llm = LocalModelLLM(
275
+ config.llm.model_path,
276
+ runtime="auto",
277
+ context_length=config.llm.context_length,
278
+ )
279
+
280
+ def synthesize_memory_hits(
281
+ self,
282
+ *,
283
+ query: str,
284
+ hits: list[dict[str, Any]],
285
+ current_step: str | None,
286
+ artifact_type: str | None,
287
+ ) -> tuple[dict[str, Any], dict[str, str]]:
288
+ fallback = self._memory_hits_fallback(
289
+ query=query,
290
+ hits=hits,
291
+ current_step=current_step,
292
+ artifact_type=artifact_type,
293
+ )
294
+ prompt = "\n\n".join(
295
+ [
296
+ "You are synthesizing recalled engineering memories for a workflow-aware assistant.",
297
+ "Return only valid JSON with keys: summary, focus, recommended_hit_ids, hit_summaries.",
298
+ "Keep hit_summaries as an object keyed by hit id.",
299
+ f"Current workflow step: {current_step or 'unknown'}",
300
+ f"Artifact type: {artifact_type or 'unknown'}",
301
+ f"User recall query: {query}",
302
+ f"Hits: {json.dumps(hits[:5], ensure_ascii=True, indent=2)}",
303
+ ]
304
+ )
305
+ raw = self._llm.complete_text(
306
+ prompt,
307
+ max_tokens=700,
308
+ temperature=min(max(self._config.llm.temperature, 0.05), 0.3),
309
+ fallback="",
310
+ )
311
+ parsed = _extract_json_object(raw)
312
+ if not parsed:
313
+ return fallback, {
314
+ "provider": "heuristic",
315
+ "model": self._config.llm.model_name,
316
+ "runtime": self._llm.runtime,
317
+ }
318
+ return {
319
+ "summary": str(parsed.get("summary", fallback["summary"]))
320
+ or fallback["summary"],
321
+ "focus": str(parsed.get("focus", fallback["focus"])) or fallback["focus"],
322
+ "recommended_hit_ids": list(
323
+ parsed.get("recommended_hit_ids", fallback["recommended_hit_ids"])
324
+ ),
325
+ "hit_summaries": {
326
+ str(key): str(value)
327
+ for key, value in dict(
328
+ parsed.get("hit_summaries", fallback["hit_summaries"]) or {}
329
+ ).items()
330
+ },
331
+ }, {
332
+ "provider": "local_llm",
333
+ "model": "gemma-4-e2b-it",
334
+ "runtime": self._llm.runtime,
335
+ }
336
+
337
+ def _memory_hits_fallback(
338
+ self,
339
+ *,
340
+ query: str,
341
+ hits: list[dict[str, Any]],
342
+ current_step: str | None,
343
+ artifact_type: str | None,
344
+ ) -> dict[str, Any]:
345
+ focus = current_step or artifact_type or "general retrieval"
346
+ recommended_ids = [str(hit.get("id", "")) for hit in hits[:2] if hit.get("id")]
347
+ hit_summaries = {
348
+ str(hit.get("id", "")): (
349
+ f"Use {hit.get('title', 'this memory')} for {focus}; reasons: {', '.join(hit.get('continuity_reasons', [])) or 'semantic match'}"
350
+ )
351
+ for hit in hits[:5]
352
+ if hit.get("id")
353
+ }
354
+ top_titles = (
355
+ ", ".join(str(hit.get("title", "")) for hit in hits[:2] if hit.get("title"))
356
+ or "no strong hits"
357
+ )
358
+ return {
359
+ "summary": f"Top recalled memories for '{query}' focus on {focus}: {top_titles}.",
360
+ "focus": focus,
361
+ "recommended_hit_ids": recommended_ids,
362
+ "hit_summaries": hit_summaries,
363
+ }
minder/dev.py ADDED
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+
10
+ DEFAULT_WATCH_INTERVAL_SECONDS = 0.75
11
+ WATCHED_CONFIG_FILES = (".env", "minder.toml")
12
+
13
+
14
+ def repo_root() -> Path:
15
+ return Path(__file__).resolve().parents[2]
16
+
17
+
18
+ def build_dev_command() -> list[str]:
19
+ return [sys.executable, "-m", "minder.server"]
20
+
21
+
22
+ def build_dev_env(
23
+ root: Path,
24
+ *,
25
+ transport: str = "sse",
26
+ port: int | None = None,
27
+ ) -> dict[str, str]:
28
+ env = os.environ.copy()
29
+ src_path = str(root / "src")
30
+ existing_pythonpath = env.get("PYTHONPATH", "")
31
+ pythonpath_parts = [part for part in existing_pythonpath.split(os.pathsep) if part]
32
+ if src_path not in pythonpath_parts:
33
+ pythonpath_parts.insert(0, src_path)
34
+ env["PYTHONPATH"] = os.pathsep.join(pythonpath_parts)
35
+ env.setdefault("UV_CACHE_DIR", ".uv-cache")
36
+ env["MINDER_SERVER__TRANSPORT"] = transport
37
+ if port is not None:
38
+ env["MINDER_SERVER__PORT"] = str(port)
39
+ return env
40
+
41
+
42
+ def collect_watch_files(root: Path) -> list[Path]:
43
+ watched_files: list[Path] = []
44
+ src_root = root / "src"
45
+ if src_root.exists():
46
+ watched_files.extend(sorted(path for path in src_root.rglob("*.py") if path.is_file()))
47
+ for config_name in WATCHED_CONFIG_FILES:
48
+ config_path = root / config_name
49
+ if config_path.is_file():
50
+ watched_files.append(config_path)
51
+ return watched_files
52
+
53
+
54
+ def snapshot_mtimes(paths: list[Path]) -> dict[str, float]:
55
+ snapshot: dict[str, float] = {}
56
+ for path in paths:
57
+ if path.exists():
58
+ snapshot[str(path)] = path.stat().st_mtime
59
+ return snapshot
60
+
61
+
62
+ def start_server_process(root: Path, env: dict[str, str]) -> subprocess.Popen[bytes]:
63
+ return subprocess.Popen(build_dev_command(), cwd=root, env=env)
64
+
65
+
66
+ def stop_server_process(process: subprocess.Popen[bytes]) -> None:
67
+ if process.poll() is not None:
68
+ return
69
+ process.terminate()
70
+ try:
71
+ process.wait(timeout=5)
72
+ except subprocess.TimeoutExpired:
73
+ process.kill()
74
+ process.wait(timeout=5)
75
+
76
+
77
+ def run_dev_server(
78
+ *,
79
+ transport: str = "sse",
80
+ port: int | None = None,
81
+ interval_seconds: float = DEFAULT_WATCH_INTERVAL_SECONDS,
82
+ ) -> int:
83
+ root = repo_root()
84
+ env = build_dev_env(root, transport=transport, port=port)
85
+ print(
86
+ "Starting Minder dev server with hot reload "
87
+ f"(transport={transport}, port={env.get('MINDER_SERVER__PORT', 'default')}).",
88
+ flush=True,
89
+ )
90
+ print(f"Watching {root / 'src'} plus {', '.join(WATCHED_CONFIG_FILES)} for changes.", flush=True)
91
+ print("Run with uv run python scripts/dev_server.py", flush=True)
92
+
93
+ process = start_server_process(root, env)
94
+ previous_snapshot = snapshot_mtimes(collect_watch_files(root))
95
+ exit_code: int = 0
96
+ exit_reported = False
97
+
98
+ try:
99
+ while True:
100
+ time.sleep(interval_seconds)
101
+ current_snapshot = snapshot_mtimes(collect_watch_files(root))
102
+ if current_snapshot != previous_snapshot:
103
+ previous_snapshot = current_snapshot
104
+ print("Source change detected. Restarting Minder...", flush=True)
105
+ stop_server_process(process)
106
+ process = start_server_process(root, env)
107
+ exit_reported = False
108
+ continue
109
+
110
+ current_return_code = process.poll()
111
+ if current_return_code is not None:
112
+ exit_code = current_return_code
113
+ if not exit_reported:
114
+ print(
115
+ f"Minder dev server exited with code {current_return_code}. "
116
+ "Waiting for the next file change to restart.",
117
+ flush=True,
118
+ )
119
+ exit_reported = True
120
+ except KeyboardInterrupt:
121
+ print("Stopping Minder dev server...", flush=True)
122
+ finally:
123
+ stop_server_process(process)
124
+ return exit_code
125
+
126
+
127
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
128
+ parser = argparse.ArgumentParser(description="Run Minder in dev mode with hot reload.")
129
+ parser.add_argument(
130
+ "--transport",
131
+ default="sse",
132
+ choices=("sse", "stdio"),
133
+ help="Transport to run during development. Defaults to sse.",
134
+ )
135
+ parser.add_argument(
136
+ "--port",
137
+ type=int,
138
+ default=None,
139
+ help="Override the Minder server port for the dev process.",
140
+ )
141
+ parser.add_argument(
142
+ "--interval",
143
+ type=float,
144
+ default=DEFAULT_WATCH_INTERVAL_SECONDS,
145
+ help="Polling interval in seconds for file watching.",
146
+ )
147
+ return parser.parse_args(argv)
148
+
149
+
150
+ def main(argv: list[str] | None = None) -> int:
151
+ args = parse_args(argv)
152
+ return run_dev_server(
153
+ transport=args.transport,
154
+ port=args.port,
155
+ interval_seconds=args.interval,
156
+ )
157
+
158
+
159
+ if __name__ == "__main__":
160
+ raise SystemExit(main())