devcoach 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 (43) hide show
  1. devcoach/SKILL.md +288 -0
  2. devcoach/__init__.py +3 -0
  3. devcoach/cli/__init__.py +0 -0
  4. devcoach/cli/commands.py +793 -0
  5. devcoach/core/__init__.py +0 -0
  6. devcoach/core/coach.py +141 -0
  7. devcoach/core/db.py +768 -0
  8. devcoach/core/detect.py +132 -0
  9. devcoach/core/git.py +97 -0
  10. devcoach/core/models.py +104 -0
  11. devcoach/core/prompts.py +52 -0
  12. devcoach/mcp/__init__.py +0 -0
  13. devcoach/mcp/server.py +545 -0
  14. devcoach/web/__init__.py +0 -0
  15. devcoach/web/app.py +319 -0
  16. devcoach/web/static/favicon.svg +3 -0
  17. devcoach/web/static/relative-time.js +24 -0
  18. devcoach/web/static/style.css +163 -0
  19. devcoach/web/static/vendor/alpinejs.min.js +5 -0
  20. devcoach/web/static/vendor/flatpickr-dark.min.css +795 -0
  21. devcoach/web/static/vendor/flatpickr.min.css +13 -0
  22. devcoach/web/static/vendor/flatpickr.min.js +2 -0
  23. devcoach/web/static/vendor/highlight.min.js +1232 -0
  24. devcoach/web/static/vendor/hljs-dark.min.css +1 -0
  25. devcoach/web/static/vendor/hljs-light.min.css +1 -0
  26. devcoach/web/static/vendor/htmx.min.js +1 -0
  27. devcoach/web/static/vendor/icons/bitbucket.svg +1 -0
  28. devcoach/web/static/vendor/icons/github.svg +1 -0
  29. devcoach/web/static/vendor/icons/gitlab.svg +1 -0
  30. devcoach/web/static/vendor/icons/vscode.svg +41 -0
  31. devcoach/web/static/vendor/marked.min.js +6 -0
  32. devcoach/web/static/vendor/tailwind.js +83 -0
  33. devcoach/web/templates/base.html +80 -0
  34. devcoach/web/templates/lesson_detail.html +215 -0
  35. devcoach/web/templates/lessons.html +546 -0
  36. devcoach/web/templates/profile.html +240 -0
  37. devcoach/web/templates/settings.html +144 -0
  38. devcoach-0.1.0.dist-info/METADATA +443 -0
  39. devcoach-0.1.0.dist-info/RECORD +43 -0
  40. devcoach-0.1.0.dist-info/WHEEL +4 -0
  41. devcoach-0.1.0.dist-info/entry_points.txt +2 -0
  42. devcoach-0.1.0.dist-info/licenses/LICENSE +201 -0
  43. devcoach-0.1.0.dist-info/licenses/NOTICE +20 -0
devcoach/mcp/server.py ADDED
@@ -0,0 +1,545 @@
1
+ """FastMCP server for devcoach — tools, resources, prompt, and entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.resources
6
+ import json
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ from fastmcp import Context, FastMCP
14
+
15
+ from devcoach.core import coach, db
16
+ from devcoach.core.detect import detect_stack
17
+ from devcoach.core.git import detect_git_context
18
+ from devcoach.core.models import Lesson, Level, Profile, RepositoryPlatform
19
+
20
+ # ── FastMCP app ────────────────────────────────────────────────────────────
21
+
22
+ mcp = FastMCP(
23
+ name="devcoach",
24
+ instructions=(
25
+ "Progressive technical coaching server. "
26
+ "Use the devcoach_instructions prompt for full coaching behaviour guidelines."
27
+ ),
28
+ )
29
+
30
+
31
+ # ── MCP Tools ─────────────────────────────────────────────────────────────
32
+
33
+
34
+ @mcp.tool
35
+ async def log_lesson(
36
+ ctx: Context,
37
+ id: str,
38
+ timestamp: str,
39
+ topic_id: str,
40
+ categories: list[str],
41
+ title: str,
42
+ level: Level,
43
+ summary: str,
44
+ task_context: str | None = None,
45
+ project: str | None = None,
46
+ repository: str | None = None,
47
+ branch: str | None = None,
48
+ commit_hash: str | None = None,
49
+ folder: str | None = None,
50
+ repository_platform: RepositoryPlatform | None = None,
51
+ ) -> str:
52
+ """Save a delivered lesson to the coaching log.
53
+
54
+ Git metadata fields (project, repository, branch, commit_hash, folder,
55
+ repository_platform) are auto-detected from the current workspace when
56
+ not provided. Detection order: caller value → git auto-detect → usage
57
+ default from past lessons → None.
58
+
59
+ Returns 'ok' on success.
60
+ """
61
+ git_ctx = detect_git_context()
62
+ try:
63
+ with db.connection() as conn:
64
+ usage = db.get_usage_defaults(conn)
65
+ except Exception:
66
+ usage = {}
67
+
68
+ resolved_project = project or git_ctx["project"] or usage.get("project")
69
+ resolved_repository = repository or git_ctx["repository"] or usage.get("repository")
70
+ resolved_branch = branch or git_ctx["branch"] or usage.get("branch")
71
+ resolved_commit = commit_hash or git_ctx["commit_hash"]
72
+ resolved_folder = folder or git_ctx["folder"]
73
+ resolved_platform = (
74
+ repository_platform or git_ctx["repository_platform"] or usage.get("repository_platform")
75
+ )
76
+
77
+ auto_filled = {
78
+ k: v
79
+ for k, v in {
80
+ "project": resolved_project if not project else None,
81
+ "branch": resolved_branch if not branch else None,
82
+ "commit_hash": resolved_commit if not commit_hash else None,
83
+ }.items()
84
+ if v is not None
85
+ }
86
+ if auto_filled:
87
+ await ctx.info(f"log_lesson: auto-filled git context {auto_filled}")
88
+
89
+ lesson = Lesson(
90
+ id=id,
91
+ timestamp=timestamp,
92
+ topic_id=topic_id,
93
+ categories=categories,
94
+ title=title,
95
+ level=level,
96
+ summary=summary,
97
+ task_context=task_context,
98
+ project=resolved_project,
99
+ repository=resolved_repository,
100
+ branch=resolved_branch,
101
+ commit_hash=resolved_commit,
102
+ folder=resolved_folder,
103
+ repository_platform=resolved_platform,
104
+ )
105
+ try:
106
+ with db.connection() as conn:
107
+ db.insert_lesson(conn, lesson)
108
+ return "ok"
109
+ except Exception as exc:
110
+ return f"error: {exc}"
111
+
112
+
113
+ @mcp.tool
114
+ def update_knowledge(topic: str, delta: int) -> Profile:
115
+ """Adjust the confidence score for a topic by delta (e.g. +1 or -1).
116
+
117
+ Returns the updated Profile. Confidence is clamped to 0-10.
118
+ If the topic does not exist it is created with a base confidence of 5.
119
+ """
120
+ try:
121
+ with db.connection() as conn:
122
+ return coach.apply_knowledge_delta(conn, topic, delta)
123
+ except Exception:
124
+ return Profile(knowledge=[], groups=[])
125
+
126
+
127
+ @mcp.tool
128
+ def get_lessons(
129
+ period: Literal["today", "week", "month", "year", "all"] | None = None,
130
+ category: str | None = None,
131
+ level: Literal["junior", "mid", "senior"] | None = None,
132
+ project: str | None = None,
133
+ repository: str | None = None,
134
+ branch: str | None = None,
135
+ commit: str | None = None,
136
+ starred: bool | None = None,
137
+ feedback: Literal["know", "dont_know", "none"] | None = None,
138
+ search: str | None = None,
139
+ date_from: str | None = None,
140
+ date_to: str | None = None,
141
+ ) -> list[Lesson]:
142
+ """Query the coaching lesson history.
143
+
144
+ period: today | week | month | year | all (default: all)
145
+ category: filter by a specific category tag (e.g. "python", "docker")
146
+ level: filter by difficulty level — junior | mid | senior
147
+ project / repository / branch: fuzzy match on git metadata
148
+ commit: fuzzy match on commit hash
149
+ starred: True to return only starred (favourite) lessons
150
+ feedback: "know" | "dont_know" | "none" (no response given)
151
+ search: full-text search across title, topic_id, and summary
152
+ date_from / date_to: ISO date/datetime strings; override period when set.
153
+ Date-only (YYYY-MM-DD) or with time (YYYY-MM-DDTHH:MM or YYYY-MM-DDTHH:MM:SS).
154
+ date_to with date-only is treated as end-of-day (23:59:59).
155
+ All filters can be combined.
156
+ """
157
+ try:
158
+ with db.connection() as conn:
159
+ return db.get_lessons(
160
+ conn,
161
+ period=period,
162
+ category=category,
163
+ level=level,
164
+ project=project,
165
+ repository=repository,
166
+ branch=branch,
167
+ commit=commit,
168
+ starred=starred,
169
+ feedback=feedback,
170
+ search=search,
171
+ date_from=date_from,
172
+ date_to=date_to,
173
+ )
174
+ except Exception:
175
+ return []
176
+
177
+
178
+ @mcp.tool
179
+ def star_lesson(lesson_id: str) -> str:
180
+ """Toggle the starred (favourite) flag on a lesson.
181
+
182
+ Returns 'starred' or 'unstarred' to indicate the new state.
183
+ """
184
+ try:
185
+ with db.connection() as conn:
186
+ new_state = db.toggle_star(conn, lesson_id)
187
+ return "starred" if new_state else "unstarred"
188
+ except Exception as exc:
189
+ return f"error: {exc}"
190
+
191
+
192
+ @mcp.tool
193
+ def submit_feedback(lesson_id: str, feedback: str) -> Profile:
194
+ """Record user comprehension feedback for a lesson and update knowledge confidence.
195
+
196
+ feedback: "know" (understood, confidence +1) | "dont_know" (needs practice, confidence -1)
197
+ | "clear" (remove feedback only — confidence is NOT adjusted)
198
+ Returns the updated Profile.
199
+ """
200
+ try:
201
+ feedback_value = None if feedback == "clear" else feedback
202
+ with db.connection() as conn:
203
+ coach.record_feedback(conn, lesson_id, feedback_value)
204
+ return coach.get_profile(conn)
205
+ except Exception:
206
+ return Profile(knowledge=[], groups=[])
207
+
208
+
209
+ @mcp.tool
210
+ def add_topic(topic: str, confidence: int = 5, group: str | None = None) -> Profile:
211
+ """Add a new topic to the knowledge map, or update confidence if it already exists.
212
+
213
+ topic: topic identifier, e.g. 'rust_lifetimes'
214
+ confidence: initial confidence score 0-10 (default 5)
215
+ group: optional group name; topic appears under 'Other' if omitted
216
+ Returns the updated Profile.
217
+ """
218
+ try:
219
+ with db.connection() as conn:
220
+ db.upsert_knowledge(conn, topic, confidence)
221
+ if group and group != "Other":
222
+ db.assign_topic_to_group(conn, topic, group)
223
+ return coach.get_profile(conn)
224
+ except Exception:
225
+ return Profile(knowledge=[], groups=[])
226
+
227
+
228
+ @mcp.tool
229
+ def remove_topic(topic: str) -> Profile:
230
+ """Remove a topic from the knowledge map entirely.
231
+
232
+ Returns the updated Profile.
233
+ """
234
+ try:
235
+ with db.connection() as conn:
236
+ db.delete_knowledge(conn, topic)
237
+ return coach.get_profile(conn)
238
+ except Exception:
239
+ return Profile(knowledge=[], groups=[])
240
+
241
+
242
+ @mcp.tool
243
+ def add_group(name: str) -> Profile:
244
+ """Create a new (initially empty) knowledge group.
245
+
246
+ name: group name, e.g. 'Machine Learning'
247
+ Note: add_topic(group=name) also auto-creates the group when assigning a topic.
248
+ Returns the updated Profile.
249
+ """
250
+ try:
251
+ name = name.strip()
252
+ with db.connection() as conn:
253
+ db.add_group(conn, name)
254
+ return coach.get_profile(conn)
255
+ except Exception:
256
+ return Profile(knowledge=[], groups=[])
257
+
258
+
259
+ @mcp.tool
260
+ def remove_group(name: str) -> Profile:
261
+ """Delete a knowledge group. Topics in the group move to Other.
262
+
263
+ Returns the updated Profile.
264
+ """
265
+ try:
266
+ with db.connection() as conn:
267
+ db.delete_group(conn, name)
268
+ return coach.get_profile(conn)
269
+ except Exception:
270
+ return Profile(knowledge=[], groups=[])
271
+
272
+
273
+ @mcp.tool
274
+ def update_settings(key: str, value: str) -> dict:
275
+ """Update a coaching setting.
276
+
277
+ key: 'max_per_day' or 'min_gap_minutes'
278
+ value: new value as a string
279
+ - max_per_day: integer 1-20 (max lessons delivered in a 24h window)
280
+ - min_gap_minutes: integer 0-1440 (minimum minutes between lessons; 0 = no cooldown)
281
+ Returns the updated settings dict, or {"error": "..."} if validation fails.
282
+ """
283
+ try:
284
+ valid_keys = {"max_per_day", "min_gap_minutes"}
285
+ if key not in valid_keys:
286
+ return {"error": f"Unknown key '{key}'. Valid keys: {', '.join(sorted(valid_keys))}"}
287
+ try:
288
+ int_val = int(value)
289
+ except ValueError:
290
+ return {"error": f"Value must be an integer, got '{value}'"}
291
+ if key == "max_per_day" and not (1 <= int_val <= 20):
292
+ return {"error": "max_per_day must be between 1 and 20"}
293
+ if key == "min_gap_minutes" and not (0 <= int_val <= 1440):
294
+ return {"error": "min_gap_minutes must be between 0 and 1440"}
295
+ with db.connection() as conn:
296
+ db.set_setting(conn, key, str(int_val))
297
+ s = db.get_settings(conn)
298
+ return {"max_per_day": s.max_per_day, "min_gap_minutes": s.min_gap_minutes}
299
+ except Exception as exc:
300
+ return {"error": str(exc)}
301
+
302
+
303
+ @mcp.tool
304
+ def open_ui(port: int = 7860) -> str:
305
+ """Launch the devcoach web dashboard in the background.
306
+
307
+ Opens http://localhost:<port> — defaults to 7860.
308
+ port must be in the range 1024-65535.
309
+ """
310
+ if not (1024 <= port <= 65535):
311
+ return f"error: port {port} is out of valid range (1024-65535)"
312
+ cmd = (
313
+ ["uvx", "devcoach", "ui", "--port", str(port)]
314
+ if shutil.which("uvx")
315
+ else ["devcoach", "ui", "--port", str(port)]
316
+ )
317
+ subprocess.Popen(cmd)
318
+ return f"devcoach UI starting at http://localhost:{port}"
319
+
320
+
321
+ @mcp.tool
322
+ async def complete_onboarding(
323
+ ctx: Context,
324
+ topics: dict[str, int],
325
+ groups: dict[str, list[str]] | None = None,
326
+ ) -> Profile:
327
+ """Save the user's initial knowledge profile and mark onboarding complete.
328
+
329
+ topics: {topic_id: confidence_0_to_10} — all topics the user confirmed.
330
+ groups: {group_name: [topic_id, ...]} — optional grouping structure.
331
+ Topics not listed in any group are placed in 'Other'.
332
+ Groups are dynamically defined by the onboarding conversation — there
333
+ is no predefined catalogue. Suggest logical groupings based on what
334
+ the user selected (e.g. Languages, Backend, DevOps, Version Control)
335
+ and confirm with the user before calling this tool.
336
+
337
+ Wipes any default-seeded profile, saves selections, marks onboarding done.
338
+ Returns the updated Profile.
339
+ """
340
+ try:
341
+ with db.connection() as conn:
342
+ conn.execute("DELETE FROM knowledge")
343
+ conn.execute("DELETE FROM knowledge_groups")
344
+ conn.execute("DELETE FROM knowledge_group_names")
345
+ conn.commit()
346
+ for topic, confidence in topics.items():
347
+ db.upsert_knowledge(conn, topic, max(0, min(10, confidence)))
348
+ if groups:
349
+ for group_name, group_topics in groups.items():
350
+ for t in group_topics:
351
+ if t in topics:
352
+ db.assign_topic_to_group(conn, t, group_name)
353
+ db.set_setting(conn, "onboarding_completed", "1")
354
+ profile = coach.get_profile(conn)
355
+ await ctx.info(f"Onboarding complete — {len(topics)} topics, {len(groups or {})} groups")
356
+ return profile
357
+ except Exception:
358
+ return Profile(knowledge=[], groups=[])
359
+
360
+
361
+ # ── MCP Resources ──────────────────────────────────────────────────────────
362
+
363
+
364
+ @mcp.resource("devcoach://profile")
365
+ def profile_resource() -> str:
366
+ """Current knowledge map — topics, confidence scores, and groups."""
367
+ try:
368
+ with db.connection() as conn:
369
+ return coach.get_profile(conn).model_dump_json(indent=2)
370
+ except Exception as exc:
371
+ return json.dumps({"error": str(exc)})
372
+
373
+
374
+ @mcp.resource("devcoach://settings")
375
+ def settings_resource() -> str:
376
+ """Current coaching settings (rate limits)."""
377
+ try:
378
+ with db.connection() as conn:
379
+ settings = db.get_settings(conn)
380
+ return json.dumps(settings.model_dump(), indent=2)
381
+ except Exception as exc:
382
+ return json.dumps({"error": str(exc)})
383
+
384
+
385
+ @mcp.resource("devcoach://lessons/recent")
386
+ def recent_lessons_resource() -> str:
387
+ """Last 10 lessons from the current week."""
388
+ try:
389
+ with db.connection() as conn:
390
+ lessons = db.get_lessons(conn, period="week")
391
+ return json.dumps(
392
+ [lesson.model_dump() for lesson in lessons[:10]],
393
+ indent=2,
394
+ ensure_ascii=False,
395
+ )
396
+ except Exception as exc:
397
+ return json.dumps({"error": str(exc)})
398
+
399
+
400
+ @mcp.resource("devcoach://stats")
401
+ def stats_resource() -> str:
402
+ """Aggregate coaching statistics: lesson counts, rate-limit state, weakest/strongest topics."""
403
+ try:
404
+ with db.connection() as conn:
405
+ return json.dumps(coach.get_stats(conn), indent=2)
406
+ except Exception as exc:
407
+ return json.dumps({"error": str(exc)})
408
+
409
+
410
+ @mcp.resource("devcoach://taught-topics")
411
+ def taught_topics_resource() -> str:
412
+ """All topic_ids that have already been taught.
413
+
414
+ Read this before selecting a new lesson topic to avoid repetition.
415
+ """
416
+ try:
417
+ with db.connection() as conn:
418
+ return json.dumps(coach.list_taught_topics(conn))
419
+ except Exception as exc:
420
+ return json.dumps({"error": str(exc)})
421
+
422
+
423
+ @mcp.resource("devcoach://rate-limit")
424
+ def rate_limit_resource() -> str:
425
+ """Current rate-limit status.
426
+
427
+ Returns {allowed, reason} — check this before delivering a lesson.
428
+ """
429
+ try:
430
+ with db.connection() as conn:
431
+ result = coach.check_rate_limit(conn)
432
+ return result.model_dump_json(indent=2)
433
+ except Exception as exc:
434
+ return json.dumps({"allowed": False, "reason": f"Rate limit check unavailable: {exc}"})
435
+
436
+
437
+ @mcp.resource("devcoach://context")
438
+ def context_resource() -> str:
439
+ """Current workspace git context and most-used lesson metadata defaults.
440
+
441
+ git: auto-detected from cwd (branch, commit, repository, platform, folder).
442
+ usage_defaults: most-frequently used values from past lessons — used as
443
+ fallback when git detection finds nothing.
444
+ """
445
+ try:
446
+ git = detect_git_context()
447
+ with db.connection() as conn:
448
+ usage = db.get_usage_defaults(conn)
449
+ return json.dumps({"git": git, "usage_defaults": usage}, indent=2)
450
+ except Exception as exc:
451
+ return json.dumps({"error": str(exc)})
452
+
453
+
454
+ @mcp.resource("devcoach://onboarding")
455
+ def onboarding_resource() -> str:
456
+ """Onboarding status and auto-detected stack for first-run setup.
457
+
458
+ needs_onboarding: true if the user has not yet completed the setup flow.
459
+ detected_stack: {topic_id: confidence} inferred from project files in cwd.
460
+ These are suggestions only — the user confirms or adjusts them during
461
+ the onboarding conversation before complete_onboarding is called.
462
+ context_ready: true if a git branch was successfully detected in cwd.
463
+ """
464
+ try:
465
+ with db.connection() as conn:
466
+ done = db.is_onboarding_complete(conn)
467
+ git = detect_git_context()
468
+ detected = detect_stack(git["folder"] or str(Path.cwd()))
469
+ return json.dumps(
470
+ {
471
+ "needs_onboarding": not done,
472
+ "detected_stack": detected,
473
+ "context_ready": git["branch"] is not None,
474
+ },
475
+ indent=2,
476
+ )
477
+ except Exception as exc:
478
+ return json.dumps({"error": str(exc)})
479
+
480
+
481
+ @mcp.resource("devcoach://lessons/{lesson_id}")
482
+ def lesson_resource(lesson_id: str) -> str:
483
+ """A single lesson by ID.
484
+
485
+ Returns the full lesson JSON, or {"error": "..."} if not found.
486
+ """
487
+ try:
488
+ with db.connection() as conn:
489
+ lesson = db.get_lesson_by_id(conn, lesson_id)
490
+ if lesson is None:
491
+ return json.dumps({"error": f"Lesson '{lesson_id}' not found"})
492
+ return lesson.model_dump_json(indent=2)
493
+ except Exception as exc:
494
+ return json.dumps({"error": str(exc)})
495
+
496
+
497
+ # ── MCP Prompt ────────────────────────────────────────────────────────────
498
+
499
+
500
+ @mcp.prompt
501
+ def devcoach_instructions() -> str:
502
+ """Full coaching instructions for the devcoach skill (content of SKILL.md)."""
503
+ try:
504
+ return (
505
+ importlib.resources.files("devcoach").joinpath("SKILL.md").read_text(encoding="utf-8")
506
+ )
507
+ except Exception:
508
+ return "devcoach: coaching instructions unavailable (SKILL.md not found in package)."
509
+
510
+
511
+ # ── Entry point ───────────────────────────────────────────────────────────
512
+
513
+
514
+ def main() -> None:
515
+ """Start devcoach: CLI subcommand if given, else stdio MCP server."""
516
+ cli_commands = {
517
+ "profile",
518
+ "lessons",
519
+ "lesson",
520
+ "star",
521
+ "feedback",
522
+ "settings",
523
+ "set",
524
+ "stats",
525
+ "backup",
526
+ "restore",
527
+ "ui",
528
+ "knowledge-add",
529
+ "knowledge-remove",
530
+ "group-add",
531
+ "group-remove",
532
+ "group-assign",
533
+ "install",
534
+ "setup",
535
+ }
536
+ if len(sys.argv) > 1 and sys.argv[1] in cli_commands:
537
+ from devcoach.cli.commands import run_cli
538
+
539
+ run_cli()
540
+ else:
541
+ mcp.run(transport="stdio")
542
+
543
+
544
+ if __name__ == "__main__":
545
+ main()
File without changes