cynthia-cli 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.
- cynthia/__init__.py +9 -0
- cynthia/cli.py +442 -0
- cynthia/config.py +75 -0
- cynthia/events.py +91 -0
- cynthia/git_observer.py +198 -0
- cynthia/health.py +429 -0
- cynthia/ml/__init__.py +19 -0
- cynthia/ml/features.py +44 -0
- cynthia/ml/kmeans_focus.py +151 -0
- cynthia/ml/knn_similar.py +125 -0
- cynthia/overview.py +135 -0
- cynthia/scanner.py +196 -0
- cynthia/sessions.py +465 -0
- cynthia/shell.py +397 -0
- cynthia/storage.py +333 -0
- cynthia/timeline.py +52 -0
- cynthia/ui/dashboard.py +198 -0
- cynthia/ui/doctor.py +254 -0
- cynthia/ui/focus.py +135 -0
- cynthia/ui/logo.py +24 -0
- cynthia/ui/overview.py +116 -0
- cynthia/ui/theme.py +46 -0
- cynthia/ui/welcome.py +47 -0
- cynthia/watcher.py +67 -0
- cynthia/workspace.py +198 -0
- cynthia_cli-0.1.0.dist-info/METADATA +1352 -0
- cynthia_cli-0.1.0.dist-info/RECORD +31 -0
- cynthia_cli-0.1.0.dist-info/WHEEL +5 -0
- cynthia_cli-0.1.0.dist-info/entry_points.txt +2 -0
- cynthia_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- cynthia_cli-0.1.0.dist-info/top_level.txt +1 -0
cynthia/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""CYNTHIA — AI-native, local-first developer intelligence CLI.
|
|
2
|
+
|
|
3
|
+
CYNTHIA does not write code. It watches, remembers, and explains what is
|
|
4
|
+
happening across a developer's projects: git activity, file churn, TODOs,
|
|
5
|
+
and overall project health — all from a local SQLite store, no cloud
|
|
6
|
+
upload required.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
cynthia/cli.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""CYNTHIA's command-line entry point.
|
|
2
|
+
|
|
3
|
+
Every subcommand here is a thin wrapper around `workspace` / `ui` —
|
|
4
|
+
the same functions the interactive shell (`shell.py`) calls — so the
|
|
5
|
+
two front ends never drift apart.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import sqlite3
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
from . import config, health, overview, sessions, workspace
|
|
19
|
+
from .storage import Storage
|
|
20
|
+
from .ui import theme
|
|
21
|
+
from .ui.dashboard import render_status
|
|
22
|
+
from .ui.doctor import render_doctor
|
|
23
|
+
from .ui.focus import render_focus_analysis, render_sessions_table, render_similar_table
|
|
24
|
+
from .ui.overview import render_overview
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _configure_stdio() -> None:
|
|
28
|
+
"""Use UTF-8 on Windows so Rich can print symbols and emoji."""
|
|
29
|
+
if sys.platform == "win32":
|
|
30
|
+
for stream in (sys.stdout, sys.stderr):
|
|
31
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
32
|
+
if reconfigure:
|
|
33
|
+
try:
|
|
34
|
+
reconfigure(encoding="utf-8")
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_configure_stdio()
|
|
40
|
+
|
|
41
|
+
app = typer.Typer(
|
|
42
|
+
name="cynthia",
|
|
43
|
+
help="CYNTHIA — a local-first developer intelligence CLI.",
|
|
44
|
+
no_args_is_help=False,
|
|
45
|
+
add_completion=False,
|
|
46
|
+
)
|
|
47
|
+
project_app = typer.Typer(help="Manage the active project for this session.")
|
|
48
|
+
focus_app = typer.Typer(
|
|
49
|
+
help="Focus sessions: start/stop tracking, or show K-Means work-mode breakdown.",
|
|
50
|
+
invoke_without_command=True,
|
|
51
|
+
no_args_is_help=False,
|
|
52
|
+
)
|
|
53
|
+
app.add_typer(project_app, name="project")
|
|
54
|
+
app.add_typer(focus_app, name="focus")
|
|
55
|
+
|
|
56
|
+
console = Console()
|
|
57
|
+
|
|
58
|
+
TAGLINE = "AI-native developer intelligence CLI"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_version() -> str:
|
|
62
|
+
"""Return the package version from `cynthia.__version__`.
|
|
63
|
+
|
|
64
|
+
Falls back to "unknown" rather than raising, so `--version` never
|
|
65
|
+
dies with a traceback on a broken or partial install.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
from . import __version__
|
|
69
|
+
|
|
70
|
+
version = str(__version__).strip()
|
|
71
|
+
except Exception:
|
|
72
|
+
return "unknown"
|
|
73
|
+
return version or "unknown"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _print_version() -> None:
|
|
77
|
+
line = Text()
|
|
78
|
+
line.append("CYNTHIA ", style=f"bold {theme.PRIMARY}")
|
|
79
|
+
line.append(get_version(), style=f"bold {theme.ACCENT}")
|
|
80
|
+
console.print(line)
|
|
81
|
+
console.print(Text(TAGLINE, style=f"italic {theme.SUBTEXT}"))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _version_callback(value: bool) -> None:
|
|
85
|
+
if value:
|
|
86
|
+
_print_version()
|
|
87
|
+
raise typer.Exit()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.callback(invoke_without_command=True)
|
|
91
|
+
def main(
|
|
92
|
+
ctx: typer.Context,
|
|
93
|
+
version: bool = typer.Option(
|
|
94
|
+
False,
|
|
95
|
+
"--version",
|
|
96
|
+
"-v",
|
|
97
|
+
help="Show the CYNTHIA version and exit.",
|
|
98
|
+
callback=_version_callback,
|
|
99
|
+
is_eager=True,
|
|
100
|
+
),
|
|
101
|
+
) -> None:
|
|
102
|
+
if ctx.invoked_subcommand is None:
|
|
103
|
+
from .shell import run_shell
|
|
104
|
+
|
|
105
|
+
run_shell()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command()
|
|
109
|
+
def init(name: str = typer.Argument("default", help="Workspace name")) -> None:
|
|
110
|
+
"""Initialize a new CYNTHIA workspace in ~/.cynthia."""
|
|
111
|
+
workspace.init_workspace(name)
|
|
112
|
+
console.print(f"[green]\u2713[/green] Initialized workspace [bold]{name}[/bold] at [dim]{config.workspace_home()}[/dim]")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.command()
|
|
116
|
+
def add(
|
|
117
|
+
path: str = typer.Argument(..., help="Local path OR a git URL (https://, git@, ssh://) to clone"),
|
|
118
|
+
name: Optional[str] = typer.Option(None, "--name", "-n", help="Override the project name"),
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Register an existing project, or clone-and-register a remote repo URL."""
|
|
121
|
+
try:
|
|
122
|
+
project = workspace.add_project(path, name)
|
|
123
|
+
except (
|
|
124
|
+
config.WorkspaceNotInitialized,
|
|
125
|
+
workspace.ProjectAlreadyExists,
|
|
126
|
+
workspace.CloneFailed,
|
|
127
|
+
FileNotFoundError,
|
|
128
|
+
) as exc:
|
|
129
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
130
|
+
raise typer.Exit(1)
|
|
131
|
+
console.print(f"[green]\u2713[/green] Added project: [bold]{project.name}[/bold] [dim]{project.path}[/dim]")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.command(name="list")
|
|
135
|
+
def list_cmd() -> None:
|
|
136
|
+
"""List every project registered in this workspace."""
|
|
137
|
+
try:
|
|
138
|
+
projects = workspace.list_projects()
|
|
139
|
+
except config.WorkspaceNotInitialized as exc:
|
|
140
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
141
|
+
raise typer.Exit(1)
|
|
142
|
+
if not projects:
|
|
143
|
+
console.print("[dim]No projects registered yet. Try 'cynthia add <path>'.[/dim]")
|
|
144
|
+
return
|
|
145
|
+
with Storage() as db:
|
|
146
|
+
active = db.active_project()
|
|
147
|
+
for p in projects:
|
|
148
|
+
marker = "\u25CF" if p.name == active else " "
|
|
149
|
+
console.print(f" {marker} [bold]{p.name}[/bold] [dim]{p.path}[/dim] [magenta]{p.kind}[/magenta]")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@app.command()
|
|
153
|
+
def remove(name: str = typer.Argument(..., help="Registered project name")) -> None:
|
|
154
|
+
"""Remove a project from the workspace (does not touch files on disk)."""
|
|
155
|
+
try:
|
|
156
|
+
workspace.remove_project(name)
|
|
157
|
+
except (config.WorkspaceNotInitialized, workspace.ProjectNotFound) as exc:
|
|
158
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
159
|
+
raise typer.Exit(1)
|
|
160
|
+
console.print(f"[green]\u2713[/green] Removed project: [bold]{name}[/bold]")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@project_app.command("open")
|
|
164
|
+
def project_open(path_or_name: str = typer.Argument(..., help="Registered name, local path, or git URL")) -> None:
|
|
165
|
+
"""Register (if new) and mark a project as active for this session."""
|
|
166
|
+
try:
|
|
167
|
+
project = workspace.open_project(path_or_name)
|
|
168
|
+
except (config.WorkspaceNotInitialized, workspace.ProjectNotFound, workspace.CloneFailed) as exc:
|
|
169
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
170
|
+
raise typer.Exit(1)
|
|
171
|
+
console.print(f"[green]\u2713[/green] Opened project: [bold]{project.name}[/bold]")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@app.command()
|
|
175
|
+
def status(name: Optional[str] = typer.Argument(None, help="Project name (defaults to the active project)")) -> None:
|
|
176
|
+
"""Show the full health/activity dashboard for a project."""
|
|
177
|
+
try:
|
|
178
|
+
project = workspace.resolve_project(name)
|
|
179
|
+
snap = workspace.snapshot(project)
|
|
180
|
+
except (config.WorkspaceNotInitialized, workspace.ProjectNotFound, workspace.NoActiveProject) as exc:
|
|
181
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
182
|
+
raise typer.Exit(1)
|
|
183
|
+
with Storage() as db:
|
|
184
|
+
db.touch_project(project.name)
|
|
185
|
+
render_status(console, snap, db)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@app.command()
|
|
189
|
+
def doctor(
|
|
190
|
+
name: Optional[str] = typer.Argument(
|
|
191
|
+
None, help="Project name (defaults to the active or most recently opened project)"
|
|
192
|
+
),
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Run a full diagnostic: health, repository, quality, activity, and risk."""
|
|
195
|
+
try:
|
|
196
|
+
project = workspace.resolve_project_or_recent(name)
|
|
197
|
+
report = health.build_health_report(project.id)
|
|
198
|
+
with Storage() as db:
|
|
199
|
+
db.touch_project(project.name)
|
|
200
|
+
except (
|
|
201
|
+
config.WorkspaceNotInitialized,
|
|
202
|
+
workspace.ProjectNotFound,
|
|
203
|
+
workspace.NoActiveProject,
|
|
204
|
+
health.UnknownProject,
|
|
205
|
+
) as exc:
|
|
206
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
207
|
+
raise typer.Exit(1)
|
|
208
|
+
except sqlite3.DatabaseError as exc:
|
|
209
|
+
console.print(
|
|
210
|
+
f"[red]Error:[/red] the workspace database could not be read ({exc}).\n"
|
|
211
|
+
"[dim]If it is corrupted, move ~/.cynthia/workspace.db aside and run 'cynthia init'.[/dim]"
|
|
212
|
+
)
|
|
213
|
+
raise typer.Exit(1)
|
|
214
|
+
render_doctor(console, report)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.command("overview")
|
|
218
|
+
def overview_cmd() -> None:
|
|
219
|
+
"""Show a multi-project workspace overview: health, activity, and branch."""
|
|
220
|
+
try:
|
|
221
|
+
data = overview.build_workspace_overview()
|
|
222
|
+
except config.WorkspaceNotInitialized as exc:
|
|
223
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
224
|
+
raise typer.Exit(1)
|
|
225
|
+
except sqlite3.DatabaseError as exc:
|
|
226
|
+
console.print(
|
|
227
|
+
f"[red]Error:[/red] the workspace database could not be read ({exc}).\n"
|
|
228
|
+
"[dim]If it is corrupted, move ~/.cynthia/workspace.db aside and run 'cynthia init'.[/dim]"
|
|
229
|
+
)
|
|
230
|
+
raise typer.Exit(1)
|
|
231
|
+
render_overview(console, data)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@app.command()
|
|
235
|
+
def log(
|
|
236
|
+
name: Optional[str] = typer.Argument(None, help="Project name (defaults to the active project)"),
|
|
237
|
+
limit: int = typer.Option(20, "--limit", "-l", help="Number of commits to show"),
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Show recent commit history for a project."""
|
|
240
|
+
from .git_observer import read_git_info
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
project = workspace.resolve_project(name)
|
|
244
|
+
except (config.WorkspaceNotInitialized, workspace.ProjectNotFound, workspace.NoActiveProject) as exc:
|
|
245
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
246
|
+
raise typer.Exit(1)
|
|
247
|
+
|
|
248
|
+
info = read_git_info(project.path, recent_limit=limit)
|
|
249
|
+
if not info.git_available:
|
|
250
|
+
console.print(
|
|
251
|
+
"[yellow]Git is not installed on this system — commit history is unavailable.[/yellow]\n"
|
|
252
|
+
"[dim]Local scanning (status, file counts, TODOs) still works without Git.[/dim]"
|
|
253
|
+
)
|
|
254
|
+
return
|
|
255
|
+
if not info.is_repo:
|
|
256
|
+
console.print("[yellow]Not a git repository.[/yellow]")
|
|
257
|
+
return
|
|
258
|
+
console.print(f"[bold]{project.name}[/bold] [dim]— {len(info.recent_commits)} commit(s)[/dim]\n")
|
|
259
|
+
for i, c in enumerate(info.recent_commits):
|
|
260
|
+
dot = theme.COMMIT_DOTS[i % len(theme.COMMIT_DOTS)]
|
|
261
|
+
console.print(
|
|
262
|
+
f"[{dot}]\u25CF[/{dot}] [cyan]{c.short_sha}[/cyan] {c.message} "
|
|
263
|
+
f"[magenta]{c.author}[/magenta] [dim]{c.relative_time}[/dim]"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@app.command()
|
|
268
|
+
def watch(name: Optional[str] = typer.Argument(None, help="Project name (defaults to the active project)")) -> None:
|
|
269
|
+
"""Watch a project in real time, logging file events into the workspace."""
|
|
270
|
+
try:
|
|
271
|
+
project = workspace.resolve_project(name)
|
|
272
|
+
except (config.WorkspaceNotInitialized, workspace.ProjectNotFound, workspace.NoActiveProject) as exc:
|
|
273
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
274
|
+
raise typer.Exit(1)
|
|
275
|
+
|
|
276
|
+
console.print(f"\U0001F441 Watching [bold]{project.name}[/bold] — press Ctrl+C to stop.\n")
|
|
277
|
+
asyncio.run(_watch_loop(project))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@focus_app.callback(invoke_without_command=True)
|
|
281
|
+
def focus_main(
|
|
282
|
+
ctx: typer.Context,
|
|
283
|
+
all_projects: bool = typer.Option(
|
|
284
|
+
False, "--all", help="Analyze sessions across every project"
|
|
285
|
+
),
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Show K-Means focus breakdown (or run a focus subcommand)."""
|
|
288
|
+
if ctx.invoked_subcommand is not None:
|
|
289
|
+
return
|
|
290
|
+
try:
|
|
291
|
+
result = sessions.analyze_sessions(all_projects=all_projects)
|
|
292
|
+
except workspace.NoActiveProject:
|
|
293
|
+
# Bare `cynthia focus` with no active project → analyze everything.
|
|
294
|
+
result = sessions.analyze_sessions(all_projects=True)
|
|
295
|
+
except (
|
|
296
|
+
config.WorkspaceNotInitialized,
|
|
297
|
+
workspace.ProjectNotFound,
|
|
298
|
+
) as exc:
|
|
299
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
300
|
+
raise typer.Exit(1)
|
|
301
|
+
render_focus_analysis(console, result)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@focus_app.command("start")
|
|
305
|
+
def focus_start(
|
|
306
|
+
name: Optional[str] = typer.Argument(None, help="Project name (defaults to the active project)"),
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Begin a focus session by snapshotting the current project state."""
|
|
309
|
+
try:
|
|
310
|
+
project = workspace.resolve_project(name)
|
|
311
|
+
result = sessions.start_focus(project)
|
|
312
|
+
except (
|
|
313
|
+
config.WorkspaceNotInitialized,
|
|
314
|
+
workspace.ProjectNotFound,
|
|
315
|
+
workspace.NoActiveProject,
|
|
316
|
+
sessions.FocusAlreadyActive,
|
|
317
|
+
) as exc:
|
|
318
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
319
|
+
raise typer.Exit(1)
|
|
320
|
+
import datetime as dt
|
|
321
|
+
|
|
322
|
+
started = dt.datetime.fromtimestamp(result.started_at).strftime("%H:%M:%S")
|
|
323
|
+
console.print(
|
|
324
|
+
f"[green]\u2713[/green] Focus started on [bold]{result.project_name}[/bold] "
|
|
325
|
+
f"at [dim]{started}[/dim]"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@focus_app.command("stop")
|
|
330
|
+
def focus_stop() -> None:
|
|
331
|
+
"""End the active focus session and store computed metrics."""
|
|
332
|
+
try:
|
|
333
|
+
session = sessions.stop_focus()
|
|
334
|
+
except (config.WorkspaceNotInitialized, sessions.NoActiveFocus) as exc:
|
|
335
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
336
|
+
raise typer.Exit(1)
|
|
337
|
+
except sessions.SessionTooShort as exc:
|
|
338
|
+
console.print(f"[yellow]{exc}[/yellow]")
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
console.print(
|
|
342
|
+
f"[green]\u2713[/green] Focus session recorded for "
|
|
343
|
+
f"[bold]{session.project_name}[/bold] "
|
|
344
|
+
f"([cyan]{session.duration_minutes:.1f}m[/cyan], "
|
|
345
|
+
f"{session.files_modified} files, "
|
|
346
|
+
f"+{session.lines_added}/-{session.lines_deleted} lines)"
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@app.command("sessions")
|
|
351
|
+
def sessions_cmd(
|
|
352
|
+
args: Optional[list[str]] = typer.Argument(
|
|
353
|
+
None,
|
|
354
|
+
help="Optional project name, or 'analyze' [project]",
|
|
355
|
+
),
|
|
356
|
+
all_projects: bool = typer.Option(
|
|
357
|
+
False, "--all", help="With analyze: use sessions from every project"
|
|
358
|
+
),
|
|
359
|
+
) -> None:
|
|
360
|
+
"""List recorded focus sessions, or run work-mode analysis.
|
|
361
|
+
|
|
362
|
+
Examples:
|
|
363
|
+
cynthia sessions
|
|
364
|
+
cynthia sessions my-app
|
|
365
|
+
cynthia sessions analyze
|
|
366
|
+
cynthia sessions analyze my-app
|
|
367
|
+
cynthia sessions analyze --all
|
|
368
|
+
"""
|
|
369
|
+
parts = list(args or [])
|
|
370
|
+
try:
|
|
371
|
+
if parts and parts[0] == "analyze":
|
|
372
|
+
name = parts[1] if len(parts) > 1 else None
|
|
373
|
+
result = sessions.analyze_sessions(
|
|
374
|
+
project_name=name if not all_projects else None,
|
|
375
|
+
all_projects=all_projects,
|
|
376
|
+
)
|
|
377
|
+
render_focus_analysis(console, result)
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
if all_projects and not parts:
|
|
381
|
+
# `--all` alone implies analyze across projects
|
|
382
|
+
result = sessions.analyze_sessions(all_projects=True)
|
|
383
|
+
render_focus_analysis(console, result)
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
name = parts[0] if parts else None
|
|
387
|
+
rows = sessions.list_focus_sessions(name)
|
|
388
|
+
render_sessions_table(console, rows)
|
|
389
|
+
except (
|
|
390
|
+
config.WorkspaceNotInitialized,
|
|
391
|
+
workspace.ProjectNotFound,
|
|
392
|
+
workspace.NoActiveProject,
|
|
393
|
+
) as exc:
|
|
394
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
395
|
+
raise typer.Exit(1)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@app.command("similar")
|
|
399
|
+
def similar_cmd(
|
|
400
|
+
k: int = typer.Option(3, "--k", "-k", help="Number of similar sessions to return"),
|
|
401
|
+
) -> None:
|
|
402
|
+
"""Find historical sessions most similar to the current focus context (k-NN)."""
|
|
403
|
+
try:
|
|
404
|
+
result = sessions.similar_to_active_session(k=k)
|
|
405
|
+
except (
|
|
406
|
+
config.WorkspaceNotInitialized,
|
|
407
|
+
workspace.ProjectNotFound,
|
|
408
|
+
workspace.NoActiveProject,
|
|
409
|
+
) as exc:
|
|
410
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
411
|
+
raise typer.Exit(1)
|
|
412
|
+
render_similar_table(console, result)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
async def _watch_loop(project) -> None:
|
|
416
|
+
from .events import EventBus
|
|
417
|
+
from .watcher import ProjectWatcher
|
|
418
|
+
|
|
419
|
+
settings = config.Settings.load()
|
|
420
|
+
bus = EventBus()
|
|
421
|
+
bus.bind_loop(asyncio.get_running_loop())
|
|
422
|
+
watcher = ProjectWatcher(project.name, project.path, bus, ignored_dirs=set(settings.ignored_dirs))
|
|
423
|
+
watcher.start()
|
|
424
|
+
|
|
425
|
+
with Storage() as db:
|
|
426
|
+
try:
|
|
427
|
+
async for event in bus.subscribe():
|
|
428
|
+
db.log_event(project.id, event.type, event.payload)
|
|
429
|
+
path = event.payload.get("path", "")
|
|
430
|
+
console.print(f"[dim]{event.type}[/dim] {path}")
|
|
431
|
+
except asyncio.CancelledError:
|
|
432
|
+
pass
|
|
433
|
+
finally:
|
|
434
|
+
watcher.stop()
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def run() -> None:
|
|
438
|
+
app()
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
if __name__ == "__main__":
|
|
442
|
+
run()
|
cynthia/config.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Workspace configuration: where CYNTHIA keeps its local state.
|
|
2
|
+
|
|
3
|
+
Everything lives under ~/.cynthia (overridable via the CYNTHIA_HOME env
|
|
4
|
+
var, mainly so tests don't touch a real home directory). Nothing here
|
|
5
|
+
ever talks to the network — CYNTHIA is local-first by design.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import tomllib
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import tomli_w
|
|
15
|
+
|
|
16
|
+
DEFAULT_HOME = Path.home() / ".cynthia"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def workspace_home() -> Path:
|
|
20
|
+
override = os.environ.get("CYNTHIA_HOME")
|
|
21
|
+
return Path(override).expanduser() if override else DEFAULT_HOME
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def db_path() -> Path:
|
|
25
|
+
return workspace_home() / "workspace.db"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def config_path() -> Path:
|
|
29
|
+
return workspace_home() / "config.toml"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Settings:
|
|
34
|
+
"""User-facing settings, persisted as TOML."""
|
|
35
|
+
|
|
36
|
+
workspace_name: str = "default"
|
|
37
|
+
theme: str = "gemini"
|
|
38
|
+
activity_window_days: int = 7
|
|
39
|
+
ignored_dirs: list[str] = field(
|
|
40
|
+
default_factory=lambda: [
|
|
41
|
+
".git", "node_modules", "__pycache__", ".venv", "venv",
|
|
42
|
+
"dist", "build", ".mypy_cache", ".pytest_cache", ".idea",
|
|
43
|
+
".vscode", "target", ".next", ".turbo",
|
|
44
|
+
]
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def load(cls) -> "Settings":
|
|
49
|
+
path = config_path()
|
|
50
|
+
if not path.exists():
|
|
51
|
+
return cls()
|
|
52
|
+
with open(path, "rb") as f:
|
|
53
|
+
data = tomllib.load(f)
|
|
54
|
+
return cls(**{**cls().__dict__, **data})
|
|
55
|
+
|
|
56
|
+
def save(self) -> None:
|
|
57
|
+
workspace_home().mkdir(parents=True, exist_ok=True)
|
|
58
|
+
with open(config_path(), "wb") as f:
|
|
59
|
+
tomli_w.dump(self.__dict__, f)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_initialized() -> bool:
|
|
63
|
+
return db_path().exists() and config_path().exists()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def ensure_initialized() -> None:
|
|
67
|
+
"""Raise a clear, catchable error if `cynthia init` hasn't run yet."""
|
|
68
|
+
if not is_initialized():
|
|
69
|
+
raise WorkspaceNotInitialized(
|
|
70
|
+
"No CYNTHIA workspace found. Run 'cynthia init' first."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class WorkspaceNotInitialized(RuntimeError):
|
|
75
|
+
pass
|
cynthia/events.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Event Bus — the seam between "things happened" and "things are stored".
|
|
2
|
+
|
|
3
|
+
A tiny asyncio pub/sub. The File Watcher (running on a background thread,
|
|
4
|
+
per watchdog's design) publishes onto it via a thread-safe bridge; the
|
|
5
|
+
`cynthia watch` command's async loop subscribes and persists each event
|
|
6
|
+
through the Storage Layer. Kept separate from Storage on purpose: nothing
|
|
7
|
+
about "how do I react to a change" should need to know "how is a change
|
|
8
|
+
persisted", and vice versa.
|
|
9
|
+
|
|
10
|
+
`SyncEventCollector` is the same publish surface without an asyncio loop —
|
|
11
|
+
used by the interactive shell to count file changes during a focus session.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, AsyncIterator
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Event:
|
|
24
|
+
type: str # "file_created" | "file_modified" | "file_deleted" | "commit_detected" | ...
|
|
25
|
+
project_name: str
|
|
26
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
timestamp: float = field(default_factory=time.time)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EventBus:
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self._queue: asyncio.Queue[Event] = asyncio.Queue()
|
|
33
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
34
|
+
|
|
35
|
+
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
36
|
+
"""Call once from within the running loop so other threads can publish safely."""
|
|
37
|
+
self._loop = loop
|
|
38
|
+
|
|
39
|
+
def publish(self, event: Event) -> None:
|
|
40
|
+
"""Publish from within the event loop's own thread."""
|
|
41
|
+
self._queue.put_nowait(event)
|
|
42
|
+
|
|
43
|
+
def publish_threadsafe(self, event: Event) -> None:
|
|
44
|
+
"""Publish from a *different* thread (e.g. a watchdog observer thread)."""
|
|
45
|
+
if self._loop is None:
|
|
46
|
+
raise RuntimeError("EventBus.bind_loop() must be called before publish_threadsafe()")
|
|
47
|
+
self._loop.call_soon_threadsafe(self._queue.put_nowait, event)
|
|
48
|
+
|
|
49
|
+
async def subscribe(self) -> AsyncIterator[Event]:
|
|
50
|
+
while True:
|
|
51
|
+
yield await self._queue.get()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SyncEventCollector:
|
|
55
|
+
"""Thread-safe sink with the same `publish_threadsafe` surface as EventBus.
|
|
56
|
+
|
|
57
|
+
Used by the interactive shell (no asyncio loop) to collect watcher events
|
|
58
|
+
and count unique file paths touched during a session.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
self._lock = threading.Lock()
|
|
63
|
+
self._events: list[Event] = []
|
|
64
|
+
|
|
65
|
+
def publish_threadsafe(self, event: Event) -> None:
|
|
66
|
+
with self._lock:
|
|
67
|
+
self._events.append(event)
|
|
68
|
+
|
|
69
|
+
def publish(self, event: Event) -> None:
|
|
70
|
+
self.publish_threadsafe(event)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def count(self) -> int:
|
|
74
|
+
with self._lock:
|
|
75
|
+
return len(self._events)
|
|
76
|
+
|
|
77
|
+
def unique_paths(self) -> set[str]:
|
|
78
|
+
with self._lock:
|
|
79
|
+
paths: set[str] = set()
|
|
80
|
+
for event in self._events:
|
|
81
|
+
path = event.payload.get("path")
|
|
82
|
+
if path:
|
|
83
|
+
paths.add(str(path))
|
|
84
|
+
dest = event.payload.get("dest_path")
|
|
85
|
+
if dest:
|
|
86
|
+
paths.add(str(dest))
|
|
87
|
+
return paths
|
|
88
|
+
|
|
89
|
+
def clear(self) -> None:
|
|
90
|
+
with self._lock:
|
|
91
|
+
self._events.clear()
|