capsule-narrative 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.
- capsule/__init__.py +3 -0
- capsule/daemon.py +174 -0
- capsule/git_ops.py +160 -0
- capsule/narrator.py +173 -0
- capsule/store.py +119 -0
- capsule/tui.py +204 -0
- capsule/watcher.py +123 -0
- capsule_narrative-0.1.0.dist-info/METADATA +128 -0
- capsule_narrative-0.1.0.dist-info/RECORD +12 -0
- capsule_narrative-0.1.0.dist-info/WHEEL +5 -0
- capsule_narrative-0.1.0.dist-info/entry_points.txt +2 -0
- capsule_narrative-0.1.0.dist-info/top_level.txt +1 -0
capsule/__init__.py
ADDED
capsule/daemon.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""timecapsule daemon โ entry point that wires watcher, git, store, and narrator.
|
|
2
|
+
|
|
3
|
+
Watches a directory tree for file changes. After 2 minutes of inactivity
|
|
4
|
+
on a file, snapshots it to the timecapsule repo with a narrative commit
|
|
5
|
+
message (generated by Ollama if available, otherwise structural)."""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import git_ops
|
|
15
|
+
from .store import TimelineStore
|
|
16
|
+
from .watcher import FileWatcher, IDLE_SECONDS
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _snapshot_callback(filepath: str):
|
|
20
|
+
"""Called when a file has been idle long enough to snapshot."""
|
|
21
|
+
from .narrator import generate_message as gen_msg
|
|
22
|
+
|
|
23
|
+
store = TimelineStore()
|
|
24
|
+
repo = git_ops.ensure_repo()
|
|
25
|
+
|
|
26
|
+
# Get current content
|
|
27
|
+
if not os.path.isfile(filepath):
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
with open(filepath, "rb") as f:
|
|
31
|
+
current_content = f.read().decode("utf-8", errors="replace")
|
|
32
|
+
|
|
33
|
+
# Get previous snapshot content and message for context
|
|
34
|
+
branch = git_ops._branch_name(filepath)
|
|
35
|
+
previous_content = None
|
|
36
|
+
previous_message = None
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
commits = list(repo.iter_commits(branch, max_count=1))
|
|
40
|
+
if commits:
|
|
41
|
+
previous_message = commits[0].message.strip()
|
|
42
|
+
rel_path = os.path.basename(filepath)
|
|
43
|
+
try:
|
|
44
|
+
previous_content = repo.git.show(f"{commits[0].hexsha}:{rel_path}")
|
|
45
|
+
except Exception:
|
|
46
|
+
previous_content = None
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
# Generate narrative message
|
|
51
|
+
message = gen_msg(
|
|
52
|
+
filepath=filepath,
|
|
53
|
+
current_content=current_content,
|
|
54
|
+
previous_content=previous_content,
|
|
55
|
+
previous_message=previous_message,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Snapshot to git
|
|
59
|
+
hexsha = git_ops.snapshot_file(filepath, message, repo)
|
|
60
|
+
if hexsha:
|
|
61
|
+
store.record_snapshot(filepath, hexsha, message, branch)
|
|
62
|
+
print(f"[snapshot] {os.path.basename(filepath)} โบ {message[:60]}...", flush=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def watch(paths: list[str], idle: int = IDLE_SECONDS, foreground: bool = True, once: bool = False):
|
|
66
|
+
"""Start the file watcher and run until interrupted."""
|
|
67
|
+
print(f"๐ฐ๏ธ timecapsule watching: {', '.join(paths)}")
|
|
68
|
+
print(f" idle threshold: {idle}s | snapshots: ~/.capsule/timecapsule")
|
|
69
|
+
print(f" press Ctrl+C to stop\n")
|
|
70
|
+
|
|
71
|
+
watcher = FileWatcher(paths, _snapshot_callback, idle_seconds=idle)
|
|
72
|
+
|
|
73
|
+
def _handle_signal(sig, frame):
|
|
74
|
+
watcher.stop()
|
|
75
|
+
sys.exit(0)
|
|
76
|
+
|
|
77
|
+
signal.signal(signal.SIGINT, _handle_signal)
|
|
78
|
+
signal.signal(signal.SIGTERM, _handle_signal)
|
|
79
|
+
|
|
80
|
+
watcher.start()
|
|
81
|
+
print(" โ watcher started")
|
|
82
|
+
|
|
83
|
+
# If --once, do one pass of all files and exit
|
|
84
|
+
if once:
|
|
85
|
+
_scan_all_files(paths)
|
|
86
|
+
watcher.stop()
|
|
87
|
+
print("\n โ one-time scan complete")
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
while True:
|
|
92
|
+
time.sleep(1)
|
|
93
|
+
except KeyboardInterrupt:
|
|
94
|
+
pass
|
|
95
|
+
finally:
|
|
96
|
+
watcher.stop()
|
|
97
|
+
print("\n โ watcher stopped")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _scan_all_files(paths: list[str]):
|
|
101
|
+
"""Immediately snapshot all tracked files (one-time mode)."""
|
|
102
|
+
from .narrator import generate_message as gen_msg
|
|
103
|
+
|
|
104
|
+
store = TimelineStore()
|
|
105
|
+
repo = git_ops.ensure_repo()
|
|
106
|
+
|
|
107
|
+
ext_filter = {
|
|
108
|
+
".py", ".js", ".ts", ".rs", ".go", ".md",
|
|
109
|
+
".txt", ".json", ".yaml", ".toml", ".css",
|
|
110
|
+
".html", ".jsx", ".tsx", ".java", ".c", ".cpp",
|
|
111
|
+
".h", ".sh", ".ps1", ".bat", ".sql", ".rb",
|
|
112
|
+
".php", ".swift", ".kt", ".scala", ".zig",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for root_path in paths:
|
|
116
|
+
for dirpath, _, filenames in os.walk(root_path):
|
|
117
|
+
for fn in filenames:
|
|
118
|
+
if fn.startswith(".") or fn.startswith("__"):
|
|
119
|
+
continue
|
|
120
|
+
ext = os.path.splitext(fn)[1].lower()
|
|
121
|
+
if ext in ext_filter:
|
|
122
|
+
fp = os.path.join(dirpath, fn)
|
|
123
|
+
_snapshot_callback(fp)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main():
|
|
127
|
+
parser = argparse.ArgumentParser(
|
|
128
|
+
description="๐ฐ๏ธ timecapsule โ narrative snapshots of your work",
|
|
129
|
+
)
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
"paths", nargs="*", default=["."],
|
|
132
|
+
help="Directories to watch (default: current directory)",
|
|
133
|
+
)
|
|
134
|
+
parser.add_argument(
|
|
135
|
+
"--idle", type=int, default=IDLE_SECONDS,
|
|
136
|
+
help=f"Idle seconds before snapshot (default: {IDLE_SECONDS})",
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"--once", action="store_true",
|
|
140
|
+
help="Snapshot all files once and exit (no daemon)",
|
|
141
|
+
)
|
|
142
|
+
parser.add_argument(
|
|
143
|
+
"--browse", action="store_true",
|
|
144
|
+
help="Open the timeline browser TUI",
|
|
145
|
+
)
|
|
146
|
+
parser.add_argument(
|
|
147
|
+
"--status", action="store_true",
|
|
148
|
+
help="Show capsule stats and exit",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
args = parser.parse_args()
|
|
152
|
+
|
|
153
|
+
if args.browse:
|
|
154
|
+
from .tui import run_browser
|
|
155
|
+
run_browser()
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
if args.status:
|
|
159
|
+
store = TimelineStore()
|
|
160
|
+
repo = git_ops.ensure_repo()
|
|
161
|
+
count = store.total_snapshots()
|
|
162
|
+
files = len(store.get_all_files())
|
|
163
|
+
repo_path = str(git_ops.REPO_PATH)
|
|
164
|
+
print(f"๐ฐ๏ธ timecapsule status")
|
|
165
|
+
print(f" snapshots: {count}")
|
|
166
|
+
print(f" tracked files: {files}")
|
|
167
|
+
print(f" repository: {repo_path}")
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
watch(args.paths, idle=args.idle, once=args.once)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
main()
|
capsule/git_ops.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Git operations for timecapsule.
|
|
2
|
+
|
|
3
|
+
Manages a dedicated hidden repository at ~/.capsule/timecapsule.git.
|
|
4
|
+
Each tracked file gets its own orphan branch named after its canonical path.
|
|
5
|
+
This keeps the user's real .git untouched and makes cleanup trivial.
|
|
6
|
+
|
|
7
|
+
Uses git command-line calls for low-level operations since gitpython's
|
|
8
|
+
LooseObjectDB doesn't handle bare repo blob creation well across versions."""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
import git
|
|
18
|
+
|
|
19
|
+
CAPSULE_DIR = Path.home() / ".capsule"
|
|
20
|
+
REPO_PATH = CAPSULE_DIR / "timecapsule"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _branch_name(filepath: str) -> str:
|
|
24
|
+
"""Derive a branch name from a file path.
|
|
25
|
+
|
|
26
|
+
Converts a path like:
|
|
27
|
+
C:/Users/ngugi/project/main.py
|
|
28
|
+
to:
|
|
29
|
+
home-ngugi-project-main-py (safe for git refs).
|
|
30
|
+
"""
|
|
31
|
+
abs_path = os.path.normpath(os.path.abspath(filepath))
|
|
32
|
+
parts = []
|
|
33
|
+
for p in Path(abs_path).parts:
|
|
34
|
+
if ":" in p:
|
|
35
|
+
continue
|
|
36
|
+
sanitized = re.sub(r"[^\w-]", "-", p.lower().strip("-"))
|
|
37
|
+
if sanitized:
|
|
38
|
+
parts.append(sanitized)
|
|
39
|
+
name = "-".join(parts)
|
|
40
|
+
return name[:250]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _git(*args: str, env: Optional[dict] = None, _input: Optional[str] = None) -> str:
|
|
44
|
+
"""Run a git command in the timecapsule repo."""
|
|
45
|
+
cmd = ["git", "--git-dir", str(REPO_PATH)] + list(args)
|
|
46
|
+
result = subprocess.run(
|
|
47
|
+
cmd,
|
|
48
|
+
capture_output=True, text=True, input=_input,
|
|
49
|
+
env={**os.environ, **(env or {})},
|
|
50
|
+
)
|
|
51
|
+
if result.returncode != 0:
|
|
52
|
+
raise RuntimeError(f"git {' '.join(args)}: {result.stderr.strip()}")
|
|
53
|
+
return result.stdout.strip()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ensure_repo() -> git.Repo:
|
|
57
|
+
"""Create and return the timecapsule bare repository."""
|
|
58
|
+
CAPSULE_DIR.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
if not (REPO_PATH / "HEAD").exists():
|
|
60
|
+
repo = git.Repo.init(str(REPO_PATH), bare=True)
|
|
61
|
+
else:
|
|
62
|
+
repo = git.Repo(str(REPO_PATH))
|
|
63
|
+
return repo
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def snapshot_file(filepath: str, message: str, repo: git.Repo) -> Optional[str]:
|
|
67
|
+
"""Snapshot a file's current state into its orphan branch.
|
|
68
|
+
|
|
69
|
+
Creates a tree object from the file and commits it as a new root
|
|
70
|
+
commit on the file's orphan branch. If the branch already exists,
|
|
71
|
+
the commit becomes a child of the previous tip.
|
|
72
|
+
|
|
73
|
+
Returns the commit hexsha or None on failure.
|
|
74
|
+
"""
|
|
75
|
+
abs_path = os.path.normpath(os.path.abspath(filepath))
|
|
76
|
+
if not os.path.isfile(abs_path):
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
branch = _branch_name(abs_path)
|
|
80
|
+
rel_path = os.path.basename(abs_path)
|
|
81
|
+
|
|
82
|
+
# Create blob via hash-object
|
|
83
|
+
blob_hash = _git("hash-object", "-w", abs_path)
|
|
84
|
+
|
|
85
|
+
# Create tree with the blob
|
|
86
|
+
# git mktree expects: mode SP type SP hash TAB path
|
|
87
|
+
tree_input = f"100644 blob {blob_hash}\t{rel_path}\n"
|
|
88
|
+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
|
|
89
|
+
f.write(tree_input)
|
|
90
|
+
tree_path = f.name
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
tree_hash = _git("mktree", "--missing", _input=open(tree_path, "r").read())
|
|
94
|
+
finally:
|
|
95
|
+
os.unlink(tree_path)
|
|
96
|
+
|
|
97
|
+
# Get parent commit if branch exists
|
|
98
|
+
parent = None
|
|
99
|
+
try:
|
|
100
|
+
parent = _git("rev-parse", f"refs/heads/{branch}")
|
|
101
|
+
except RuntimeError:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
# Create commit
|
|
105
|
+
env = {
|
|
106
|
+
"GIT_AUTHOR_NAME": "timecapsule",
|
|
107
|
+
"GIT_AUTHOR_EMAIL": "capsule@localhost",
|
|
108
|
+
"GIT_COMMITTER_NAME": "timecapsule",
|
|
109
|
+
"GIT_COMMITTER_EMAIL": "capsule@localhost",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
commit_args = ["commit-tree", tree_hash, "-m", message]
|
|
113
|
+
if parent:
|
|
114
|
+
commit_args.extend(["-p", parent])
|
|
115
|
+
|
|
116
|
+
commit_hash = _git(*commit_args, env=env)
|
|
117
|
+
|
|
118
|
+
# Update branch ref
|
|
119
|
+
_git("update-ref", f"refs/heads/{branch}", commit_hash)
|
|
120
|
+
|
|
121
|
+
return commit_hash
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_timeline(filepath: str, repo: git.Repo, max_count: int = 50) -> list[dict]:
|
|
125
|
+
"""Get the commit history for a file's branch.
|
|
126
|
+
|
|
127
|
+
Returns list of {hexsha, message, committed_datetime, author}.
|
|
128
|
+
"""
|
|
129
|
+
branch = _branch_name(filepath)
|
|
130
|
+
try:
|
|
131
|
+
commits = list(repo.iter_commits(branch, max_count=max_count))
|
|
132
|
+
except (ValueError, git.BadName):
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
results = []
|
|
136
|
+
for c in commits:
|
|
137
|
+
results.append({
|
|
138
|
+
"hexsha": c.hexsha,
|
|
139
|
+
"message": c.message.strip(),
|
|
140
|
+
"committed_datetime": c.committed_datetime.isoformat(),
|
|
141
|
+
"committed_timestamp": c.committed_datetime.timestamp(),
|
|
142
|
+
"author": str(c.author),
|
|
143
|
+
})
|
|
144
|
+
return results
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_file_at_commit(filepath: str, commit_hexsha: str, repo: git.Repo) -> Optional[str]:
|
|
148
|
+
"""Retrieve file contents at a given commit."""
|
|
149
|
+
branch = _branch_name(filepath)
|
|
150
|
+
rel_path = os.path.basename(filepath)
|
|
151
|
+
try:
|
|
152
|
+
return repo.git.show(f"{commit_hexsha}:{rel_path}")
|
|
153
|
+
except git.GitCommandError:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def list_tracked_files(repo: git.Repo) -> list[str]:
|
|
158
|
+
"""List all files that have been snapshot (by their branch names)."""
|
|
159
|
+
refs = repo.git.for_each_ref("refs/heads/", format="%(refname:short)").splitlines()
|
|
160
|
+
return [r for r in refs if r.strip()]
|
capsule/narrator.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""LLM-powered narrator that generates narrative commit messages from diffs.
|
|
2
|
+
|
|
3
|
+
Uses Ollama if available (qwen2.5-coder:1.5b or similar small model).
|
|
4
|
+
Gracefully falls back to structural template messages if Ollama is not running."""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _check_ollama() -> bool:
|
|
15
|
+
"""Check if Ollama is available and running."""
|
|
16
|
+
try:
|
|
17
|
+
result = subprocess.run(
|
|
18
|
+
["ollama", "list"],
|
|
19
|
+
capture_output=True, text=True, timeout=5,
|
|
20
|
+
)
|
|
21
|
+
return result.returncode == 0
|
|
22
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, Exception):
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _ollama_generate(model: str, prompt: str, timeout: int = 30) -> Optional[str]:
|
|
27
|
+
"""Generate text using Ollama's API."""
|
|
28
|
+
import urllib.request
|
|
29
|
+
import urllib.error
|
|
30
|
+
|
|
31
|
+
data = json.dumps({
|
|
32
|
+
"model": model,
|
|
33
|
+
"prompt": prompt,
|
|
34
|
+
"stream": False,
|
|
35
|
+
"options": {
|
|
36
|
+
"num_predict": 128,
|
|
37
|
+
"temperature": 0.7,
|
|
38
|
+
}
|
|
39
|
+
}).encode()
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
req = urllib.request.Request(
|
|
43
|
+
"http://localhost:11434/api/generate",
|
|
44
|
+
data=data,
|
|
45
|
+
headers={"Content-Type": "application/json"},
|
|
46
|
+
)
|
|
47
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
48
|
+
result = json.loads(resp.read().decode())
|
|
49
|
+
return result.get("response", "").strip()
|
|
50
|
+
except Exception:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Template message generators (fallback when no LLM available)
|
|
55
|
+
|
|
56
|
+
SNAPSHOT_TEMPLATES = [
|
|
57
|
+
"Modified {filename}",
|
|
58
|
+
"Updated {filename}",
|
|
59
|
+
"Edited {filename}",
|
|
60
|
+
"Changes to {filename}",
|
|
61
|
+
"Work in progress on {filename}",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _template_message(filepath: str, diff: str) -> str:
|
|
66
|
+
"""Generate a structural commit message from the diff."""
|
|
67
|
+
import random
|
|
68
|
+
|
|
69
|
+
filename = os.path.basename(filepath)
|
|
70
|
+
ext = os.path.splitext(filename)[1].lower()
|
|
71
|
+
|
|
72
|
+
# Count changes roughly
|
|
73
|
+
lines_added = len([l for l in diff.splitlines() if l.startswith("+") and not l.startswith("+++")])
|
|
74
|
+
lines_removed = len([l for l in diff.splitlines() if l.startswith("-") and not l.startswith("---")])
|
|
75
|
+
|
|
76
|
+
if lines_added == 0 and lines_removed == 0:
|
|
77
|
+
return f"Saved {filename} (no substantive changes detected)"
|
|
78
|
+
|
|
79
|
+
# File-type specific templates
|
|
80
|
+
lang_map = {
|
|
81
|
+
".py": "Python",
|
|
82
|
+
".js": "JavaScript", ".jsx": "JSX", ".ts": "TypeScript", ".tsx": "TSX",
|
|
83
|
+
".rs": "Rust", ".go": "Go", ".java": "Java",
|
|
84
|
+
".md": "Markdown", ".txt": "text",
|
|
85
|
+
".css": "CSS", ".html": "HTML",
|
|
86
|
+
".json": "JSON", ".yaml": "YAML", ".toml": "TOML",
|
|
87
|
+
}
|
|
88
|
+
lang = lang_map.get(ext, "code")
|
|
89
|
+
|
|
90
|
+
if lines_added > 0 and lines_removed > 0:
|
|
91
|
+
return f"Modified {filename}: +{lines_added}/-{lines_removed} lines in {lang} file"
|
|
92
|
+
elif lines_added > 0:
|
|
93
|
+
return f"Added {lines_added} lines to {filename} ({lang})"
|
|
94
|
+
elif lines_removed > 0:
|
|
95
|
+
return f"Removed {lines_removed} lines from {filename} ({lang})"
|
|
96
|
+
|
|
97
|
+
return f"Updated {filename}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def generate_message(filepath: str, current_content: str,
|
|
101
|
+
previous_content: Optional[str] = None,
|
|
102
|
+
previous_message: Optional[str] = None,
|
|
103
|
+
model: str = "qwen2.5-coder:1.5b") -> str:
|
|
104
|
+
"""Generate a narrative commit message for a file change.
|
|
105
|
+
|
|
106
|
+
Uses Ollama if available; falls back to template messages.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
filepath: Absolute path to the file.
|
|
110
|
+
current_content: The file's current contents.
|
|
111
|
+
previous_content: The file's contents at last snapshot (if any).
|
|
112
|
+
previous_message: The previous commit message (for continuity).
|
|
113
|
+
model: Ollama model to use.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
A commit message string.
|
|
117
|
+
"""
|
|
118
|
+
filename = os.path.basename(filepath)
|
|
119
|
+
|
|
120
|
+
# If no previous version, it's a first snapshot
|
|
121
|
+
if previous_content is None:
|
|
122
|
+
return f"Initial snapshot of {filename}"
|
|
123
|
+
|
|
124
|
+
# Generate a simple diff summary
|
|
125
|
+
diff_lines = []
|
|
126
|
+
current_lines = current_content.splitlines(keepends=True)
|
|
127
|
+
prev_lines = previous_content.splitlines(keepends=True)
|
|
128
|
+
|
|
129
|
+
# Simple line-by-line diff (not as sophisticated as real git diff, but fast)
|
|
130
|
+
import difflib
|
|
131
|
+
diff = list(difflib.unified_diff(prev_lines, current_lines,
|
|
132
|
+
fromfile=f"a/{filename}", tofile=f"b/{filename}",
|
|
133
|
+
n=3))
|
|
134
|
+
|
|
135
|
+
# Build a compact diff string
|
|
136
|
+
diff_text = "".join(diff[:80]) # cap at 80 lines
|
|
137
|
+
|
|
138
|
+
# Template fallback
|
|
139
|
+
template_msg = _template_message(filepath, diff_text)
|
|
140
|
+
|
|
141
|
+
# Try Ollama if available
|
|
142
|
+
if not _check_ollama():
|
|
143
|
+
return template_msg
|
|
144
|
+
|
|
145
|
+
# Build a prompt for the LLM
|
|
146
|
+
context_parts = []
|
|
147
|
+
if previous_message and previous_message != template_msg:
|
|
148
|
+
context_parts.append(f"Previous change: {previous_message}")
|
|
149
|
+
|
|
150
|
+
context = " ".join(context_parts)
|
|
151
|
+
if context:
|
|
152
|
+
context = f"Context: {context}"
|
|
153
|
+
|
|
154
|
+
ext = os.path.splitext(filename)[1].lower()
|
|
155
|
+
|
|
156
|
+
prompt = f"""You are a thoughtful developer's digital diary.
|
|
157
|
+
Given the following code diff and context, write a single sentence that describes WHY the changes were made, in a personal, reflective style. Do NOT list what changed; explain the reason as if you were the developer.
|
|
158
|
+
|
|
159
|
+
File: {filename} ({ext})
|
|
160
|
+
{context}
|
|
161
|
+
|
|
162
|
+
Diff:
|
|
163
|
+
```diff
|
|
164
|
+
{diff_text[:2000]}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Write one sentence explaining why you made these changes:"""
|
|
168
|
+
|
|
169
|
+
llm_message = _ollama_generate(model, prompt)
|
|
170
|
+
if llm_message and len(llm_message) > 10:
|
|
171
|
+
return llm_message[:200] # cap length
|
|
172
|
+
|
|
173
|
+
return template_msg
|
capsule/store.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""SQLite timeline index for fast queries across all snapshots.
|
|
2
|
+
|
|
3
|
+
Stores metadata for every snapshot: filepath, commit hash, message,
|
|
4
|
+
timestamp โ bypasses git log for 100x faster queries."""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
import threading
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
CAPSULE_DIR = Path.home() / ".capsule"
|
|
14
|
+
DB_PATH = CAPSULE_DIR / "timeline.db"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TimelineStore:
|
|
18
|
+
"""Thread-safe SQLite store for snapshot metadata."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, db_path: str | Path = DB_PATH):
|
|
21
|
+
self._db_path = str(db_path)
|
|
22
|
+
self._local = threading.local()
|
|
23
|
+
self._lock = threading.Lock()
|
|
24
|
+
self._init_db()
|
|
25
|
+
|
|
26
|
+
def _get_conn(self) -> sqlite3.Connection:
|
|
27
|
+
if not hasattr(self._local, "conn") or self._local.conn is None:
|
|
28
|
+
self._local.conn = sqlite3.connect(self._db_path)
|
|
29
|
+
self._local.conn.row_factory = sqlite3.Row
|
|
30
|
+
self._local.conn.execute("PRAGMA journal_mode=WAL")
|
|
31
|
+
self._local.conn.execute("PRAGMA synchronous=NORMAL")
|
|
32
|
+
return self._local.conn
|
|
33
|
+
|
|
34
|
+
def _init_db(self):
|
|
35
|
+
conn = sqlite3.connect(self._db_path)
|
|
36
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
37
|
+
conn.execute("""
|
|
38
|
+
CREATE TABLE IF NOT EXISTS snapshots (
|
|
39
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
40
|
+
filepath TEXT NOT NULL,
|
|
41
|
+
hexsha TEXT NOT NULL,
|
|
42
|
+
message TEXT NOT NULL,
|
|
43
|
+
timestamp REAL NOT NULL,
|
|
44
|
+
branch TEXT NOT NULL
|
|
45
|
+
)
|
|
46
|
+
""")
|
|
47
|
+
conn.execute("""
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_snapshots_filepath
|
|
49
|
+
ON snapshots(filepath)
|
|
50
|
+
""")
|
|
51
|
+
conn.execute("""
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_snapshots_timestamp
|
|
53
|
+
ON snapshots(timestamp)
|
|
54
|
+
""")
|
|
55
|
+
conn.commit()
|
|
56
|
+
conn.close()
|
|
57
|
+
|
|
58
|
+
def record_snapshot(self, filepath: str, hexsha: str, message: str,
|
|
59
|
+
branch: str, timestamp: Optional[float] = None):
|
|
60
|
+
ts = timestamp or datetime.now(timezone.utc).timestamp()
|
|
61
|
+
with self._lock:
|
|
62
|
+
conn = sqlite3.connect(self._db_path)
|
|
63
|
+
conn.execute(
|
|
64
|
+
"INSERT INTO snapshots (filepath, hexsha, message, timestamp, branch) "
|
|
65
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
66
|
+
(os.path.normpath(filepath), hexsha, message, ts, branch),
|
|
67
|
+
)
|
|
68
|
+
conn.commit()
|
|
69
|
+
conn.close()
|
|
70
|
+
|
|
71
|
+
def get_timeline(self, filepath: str, limit: int = 50) -> list[dict]:
|
|
72
|
+
"""Get all snapshots for a file, newest first."""
|
|
73
|
+
norm = os.path.normpath(filepath)
|
|
74
|
+
conn = sqlite3.connect(self._db_path)
|
|
75
|
+
conn.row_factory = sqlite3.Row
|
|
76
|
+
rows = conn.execute(
|
|
77
|
+
"SELECT * FROM snapshots WHERE filepath = ? ORDER BY timestamp DESC LIMIT ?",
|
|
78
|
+
(norm, limit),
|
|
79
|
+
).fetchall()
|
|
80
|
+
conn.close()
|
|
81
|
+
return [dict(r) for r in rows]
|
|
82
|
+
|
|
83
|
+
def get_all_files(self) -> list[dict]:
|
|
84
|
+
"""Get the latest snapshot for each tracked file."""
|
|
85
|
+
conn = sqlite3.connect(self._db_path)
|
|
86
|
+
conn.row_factory = sqlite3.Row
|
|
87
|
+
rows = conn.execute("""
|
|
88
|
+
SELECT filepath, MAX(timestamp) as last_seen,
|
|
89
|
+
(SELECT message FROM snapshots s2
|
|
90
|
+
WHERE s2.filepath = snapshots.filepath
|
|
91
|
+
ORDER BY timestamp DESC LIMIT 1) as last_message
|
|
92
|
+
FROM snapshots
|
|
93
|
+
GROUP BY filepath
|
|
94
|
+
ORDER BY last_seen DESC
|
|
95
|
+
""").fetchall()
|
|
96
|
+
conn.close()
|
|
97
|
+
return [dict(r) for r in rows]
|
|
98
|
+
|
|
99
|
+
def search(self, query: str, limit: int = 20) -> list[dict]:
|
|
100
|
+
"""Search snapshot messages."""
|
|
101
|
+
conn = sqlite3.connect(self._db_path)
|
|
102
|
+
conn.row_factory = sqlite3.Row
|
|
103
|
+
rows = conn.execute(
|
|
104
|
+
"SELECT * FROM snapshots WHERE message LIKE ? ORDER BY timestamp DESC LIMIT ?",
|
|
105
|
+
(f"%{query}%", limit),
|
|
106
|
+
).fetchall()
|
|
107
|
+
conn.close()
|
|
108
|
+
return [dict(r) for r in rows]
|
|
109
|
+
|
|
110
|
+
def total_snapshots(self) -> int:
|
|
111
|
+
conn = sqlite3.connect(self._db_path)
|
|
112
|
+
count = conn.execute("SELECT COUNT(*) FROM snapshots").fetchone()[0]
|
|
113
|
+
conn.close()
|
|
114
|
+
return count
|
|
115
|
+
|
|
116
|
+
def close(self):
|
|
117
|
+
if hasattr(self._local, "conn") and self._local.conn:
|
|
118
|
+
self._local.conn.close()
|
|
119
|
+
self._local.conn = None
|
capsule/tui.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Textual TUI for browsing the time capsule timeline.
|
|
2
|
+
|
|
3
|
+
Shows tracked files, commit history for each file, and diff previews."""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from textual import on
|
|
11
|
+
from textual.app import App, ComposeResult
|
|
12
|
+
from textual.containers import Container, Horizontal, Vertical
|
|
13
|
+
from textual.widgets import Header, Footer, ListView, ListItem, Static, RichLog, Label
|
|
14
|
+
|
|
15
|
+
from . import git_ops
|
|
16
|
+
from .store import TimelineStore
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CapsuleBrowser(App):
|
|
20
|
+
"""Textual TUI for browsing timecapsule snapshots."""
|
|
21
|
+
|
|
22
|
+
CSS = """
|
|
23
|
+
Screen {
|
|
24
|
+
background: #0a0a0a;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#layout {
|
|
28
|
+
layout: grid;
|
|
29
|
+
grid-size: 3 2;
|
|
30
|
+
grid-gutter: 1;
|
|
31
|
+
padding: 1;
|
|
32
|
+
height: 100%;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#file-list-panel {
|
|
36
|
+
border: solid #00ff88;
|
|
37
|
+
padding: 0 1;
|
|
38
|
+
background: #0d1a12;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#file-list-panel > Label {
|
|
42
|
+
text-style: bold;
|
|
43
|
+
color: #00ff88;
|
|
44
|
+
height: 1;
|
|
45
|
+
margin-bottom: 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#file-list {
|
|
49
|
+
height: 100%;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#timeline-panel {
|
|
53
|
+
column-span: 2;
|
|
54
|
+
border: solid #ff8800;
|
|
55
|
+
padding: 0 1;
|
|
56
|
+
background: #1a0f05;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#timeline-panel > Label {
|
|
60
|
+
text-style: bold;
|
|
61
|
+
color: #ff8800;
|
|
62
|
+
height: 1;
|
|
63
|
+
margin-bottom: 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#timeline-list {
|
|
67
|
+
height: 100%;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#diff-panel {
|
|
71
|
+
column-span: 3;
|
|
72
|
+
border: solid #444;
|
|
73
|
+
padding: 0 1;
|
|
74
|
+
background: #0a0a0a;
|
|
75
|
+
height: 100%;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#diff-panel > Label {
|
|
79
|
+
color: #888;
|
|
80
|
+
height: 1;
|
|
81
|
+
margin-bottom: 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
RichLog {
|
|
85
|
+
background: #0a0a0a;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
ListView {
|
|
89
|
+
background: #0d1a12;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
ListItem {
|
|
93
|
+
padding: 0 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
ListItem:nth-child(even) {
|
|
97
|
+
background: #0f1f15;
|
|
98
|
+
}
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
BINDINGS = [
|
|
102
|
+
("q", "quit", "Quit"),
|
|
103
|
+
("r", "refresh", "Refresh"),
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
def __init__(self):
|
|
107
|
+
super().__init__()
|
|
108
|
+
self.store = TimelineStore()
|
|
109
|
+
self.repo = git_ops.ensure_repo()
|
|
110
|
+
self._selected_file: Optional[str] = None
|
|
111
|
+
self._selected_commit: Optional[str] = None
|
|
112
|
+
|
|
113
|
+
def compose(self) -> ComposeResult:
|
|
114
|
+
yield Header()
|
|
115
|
+
with Container(id="layout"):
|
|
116
|
+
with Vertical(id="file-list-panel"):
|
|
117
|
+
yield Label("๐ Tracked Files")
|
|
118
|
+
yield ListView(id="file-list")
|
|
119
|
+
with Vertical(id="timeline-panel"):
|
|
120
|
+
yield Label("๐ Timeline")
|
|
121
|
+
yield ListView(id="timeline-list")
|
|
122
|
+
with Vertical(id="diff-panel"):
|
|
123
|
+
yield Label("๐ Diff Preview")
|
|
124
|
+
yield RichLog(id="diff-view", highlight=True, markup=False)
|
|
125
|
+
yield Footer()
|
|
126
|
+
|
|
127
|
+
def on_mount(self):
|
|
128
|
+
self._populate_file_list()
|
|
129
|
+
|
|
130
|
+
def _populate_file_list(self):
|
|
131
|
+
files = self.store.get_all_files()
|
|
132
|
+
lv = self.query_one("#file-list", ListView)
|
|
133
|
+
lv.clear()
|
|
134
|
+
for f in files:
|
|
135
|
+
fp = f["filepath"]
|
|
136
|
+
last_msg = f.get("last_message", "")[:60]
|
|
137
|
+
label = f"{os.path.basename(fp)} โ {last_msg}"
|
|
138
|
+
lv.append(ListItem(Label(label)))
|
|
139
|
+
if files:
|
|
140
|
+
lv.index = 0
|
|
141
|
+
self._on_file_selected(files[0]["filepath"])
|
|
142
|
+
|
|
143
|
+
def _on_file_selected(self, filepath: str):
|
|
144
|
+
self._selected_file = filepath
|
|
145
|
+
self._populate_timeline(filepath)
|
|
146
|
+
|
|
147
|
+
def _populate_timeline(self, filepath: str):
|
|
148
|
+
commits = self.store.get_timeline(filepath)
|
|
149
|
+
lv = self.query_one("#timeline-list", ListView)
|
|
150
|
+
lv.clear()
|
|
151
|
+
for c in commits:
|
|
152
|
+
dt = time.strftime("%b %d %H:%M", time.localtime(c["timestamp"]))
|
|
153
|
+
msg = c["message"][:70]
|
|
154
|
+
label = f"[{dt}] {msg}"
|
|
155
|
+
lv.append(ListItem(Label(f" {label}")))
|
|
156
|
+
if commits:
|
|
157
|
+
lv.index = 0
|
|
158
|
+
self._on_commit_selected(commits[0]["hexsha"])
|
|
159
|
+
else:
|
|
160
|
+
diff = self.query_one("#diff-view", RichLog)
|
|
161
|
+
diff.clear()
|
|
162
|
+
diff.write("(no commits yet for this file)")
|
|
163
|
+
|
|
164
|
+
def _on_commit_selected(self, hexsha: str):
|
|
165
|
+
self._selected_commit = hexsha
|
|
166
|
+
diff = self.query_one("#diff-view", RichLog)
|
|
167
|
+
diff.clear()
|
|
168
|
+
|
|
169
|
+
if not self._selected_file:
|
|
170
|
+
diff.write("(no file selected)")
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
# Show the file content at this commit
|
|
174
|
+
content = git_ops.get_file_at_commit(self._selected_file, hexsha, self.repo)
|
|
175
|
+
if content:
|
|
176
|
+
diff.clear()
|
|
177
|
+
for line in content.splitlines()[:100]:
|
|
178
|
+
diff.write(line)
|
|
179
|
+
if len(content.splitlines()) > 100:
|
|
180
|
+
diff.write("[dim]... (truncated)[/]")
|
|
181
|
+
else:
|
|
182
|
+
diff.write("(file content not available)")
|
|
183
|
+
|
|
184
|
+
@on(ListView.Selected, "#file-list")
|
|
185
|
+
def file_selected(self, event: ListView.Selected):
|
|
186
|
+
files = self.store.get_all_files()
|
|
187
|
+
if event.list_view.index is not None and event.list_view.index < len(files):
|
|
188
|
+
self._on_file_selected(files[event.list_view.index]["filepath"])
|
|
189
|
+
|
|
190
|
+
@on(ListView.Selected, "#timeline-list")
|
|
191
|
+
def commit_selected(self, event: ListView.Selected):
|
|
192
|
+
if not self._selected_file:
|
|
193
|
+
return
|
|
194
|
+
commits = self.store.get_timeline(self._selected_file)
|
|
195
|
+
if event.list_view.index is not None and event.list_view.index < len(commits):
|
|
196
|
+
self._on_commit_selected(commits[event.list_view.index]["hexsha"])
|
|
197
|
+
|
|
198
|
+
def action_refresh(self):
|
|
199
|
+
self._populate_file_list()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def run_browser():
|
|
203
|
+
app = CapsuleBrowser()
|
|
204
|
+
app.run()
|
capsule/watcher.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Watchdog-based file watcher with idle debounce.
|
|
2
|
+
|
|
3
|
+
Tracks per-file modification times. When a file hasn't been touched
|
|
4
|
+
for IDLE_SECONDS, fires a snapshot callback for that file."""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import threading
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Callable, Set
|
|
11
|
+
|
|
12
|
+
from watchdog.events import FileSystemEventHandler, FileModifiedEvent, FileCreatedEvent
|
|
13
|
+
from watchdog.observers import Observer
|
|
14
|
+
|
|
15
|
+
IDLE_SECONDS = 120 # 2 minutes of inactivity triggers a snapshot
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class IdleTracker:
|
|
19
|
+
"""Per-file idle timer."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, idle_seconds: int = IDLE_SECONDS):
|
|
22
|
+
self._idle_seconds = idle_seconds
|
|
23
|
+
self._last_event: dict[str, float] = {}
|
|
24
|
+
self._lock = threading.Lock()
|
|
25
|
+
self._timer: threading.Timer | None = None
|
|
26
|
+
self._pending: Set[str] = set()
|
|
27
|
+
self._callback: Callable[[str], None] | None = None
|
|
28
|
+
|
|
29
|
+
def set_callback(self, cb: Callable[[str], None]):
|
|
30
|
+
self._callback = cb
|
|
31
|
+
|
|
32
|
+
def touch(self, path: str):
|
|
33
|
+
"""Record a file modification event."""
|
|
34
|
+
norm = os.path.normpath(path)
|
|
35
|
+
with self._lock:
|
|
36
|
+
self._last_event[norm] = time.monotonic()
|
|
37
|
+
self._pending.add(norm)
|
|
38
|
+
self._schedule_check()
|
|
39
|
+
|
|
40
|
+
def _schedule_check(self):
|
|
41
|
+
if self._timer and self._timer.is_alive():
|
|
42
|
+
self._timer.cancel()
|
|
43
|
+
self._timer = threading.Timer(self._idle_seconds, self._check_idle)
|
|
44
|
+
self._timer.daemon = True
|
|
45
|
+
self._timer.start()
|
|
46
|
+
|
|
47
|
+
def _check_idle(self):
|
|
48
|
+
now = time.monotonic()
|
|
49
|
+
ready: list[str] = []
|
|
50
|
+
with self._lock:
|
|
51
|
+
still_pending: Set[str] = set()
|
|
52
|
+
for p in list(self._pending):
|
|
53
|
+
last = self._last_event.get(p, 0)
|
|
54
|
+
if now - last >= self._idle_seconds:
|
|
55
|
+
ready.append(p)
|
|
56
|
+
else:
|
|
57
|
+
still_pending.add(p)
|
|
58
|
+
self._pending = still_pending
|
|
59
|
+
if self._callback:
|
|
60
|
+
for p in ready:
|
|
61
|
+
try:
|
|
62
|
+
self._callback(p)
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
# Re-check if anything's still pending
|
|
66
|
+
with self._lock:
|
|
67
|
+
if self._pending:
|
|
68
|
+
self._schedule_check()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CapsuleEventHandler(FileSystemEventHandler):
|
|
72
|
+
"""Dispatches file changes to the idle tracker."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, tracker: IdleTracker, watch_extensions: set[str] | None = None):
|
|
75
|
+
self.tracker = tracker
|
|
76
|
+
self.watch_ext = watch_extensions or {".py", ".js", ".ts", ".rs", ".go", ".md",
|
|
77
|
+
".txt", ".json", ".yaml", ".toml", ".css",
|
|
78
|
+
".html", ".jsx", ".tsx", ".java", ".c", ".cpp",
|
|
79
|
+
".h", ".sh", ".ps1", ".bat", ".sql", ".rb",
|
|
80
|
+
".php", ".swift", ".kt", ".scala", ".zig"}
|
|
81
|
+
|
|
82
|
+
def _should_watch(self, path: str) -> bool:
|
|
83
|
+
ext = os.path.splitext(path)[1].lower()
|
|
84
|
+
return ext in self.watch_ext and not os.path.basename(path).startswith(".")
|
|
85
|
+
|
|
86
|
+
def on_modified(self, event: FileModifiedEvent):
|
|
87
|
+
if not event.is_directory and self._should_watch(event.src_path):
|
|
88
|
+
self.tracker.touch(event.src_path)
|
|
89
|
+
|
|
90
|
+
def on_created(self, event: FileCreatedEvent):
|
|
91
|
+
if not event.is_directory and self._should_watch(event.src_path):
|
|
92
|
+
self.tracker.touch(event.src_path)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class FileWatcher:
|
|
96
|
+
"""High-level watcher that monitors directories and fires snapshot callbacks."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, paths: list[str], snapshot_callback: Callable[[str], None],
|
|
99
|
+
idle_seconds: int = IDLE_SECONDS):
|
|
100
|
+
self.paths = [os.path.abspath(p) for p in paths]
|
|
101
|
+
self.tracker = IdleTracker(idle_seconds=idle_seconds)
|
|
102
|
+
self.tracker.set_callback(snapshot_callback)
|
|
103
|
+
self.handler = CapsuleEventHandler(self.tracker)
|
|
104
|
+
self.observer = Observer()
|
|
105
|
+
|
|
106
|
+
def start(self):
|
|
107
|
+
for p in self.paths:
|
|
108
|
+
if os.path.isdir(p):
|
|
109
|
+
self.observer.schedule(self.handler, p, recursive=True)
|
|
110
|
+
self.observer.start()
|
|
111
|
+
|
|
112
|
+
def stop(self):
|
|
113
|
+
self.observer.stop()
|
|
114
|
+
self.observer.join(timeout=5)
|
|
115
|
+
if self.tracker._timer and self.tracker._timer.is_alive():
|
|
116
|
+
self.tracker._timer.cancel()
|
|
117
|
+
|
|
118
|
+
def __enter__(self):
|
|
119
|
+
self.start()
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def __exit__(self, *args):
|
|
123
|
+
self.stop()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: capsule-narrative
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A git-like filesystem that automatically creates narrative commits.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: watchdog>=4.0.0
|
|
8
|
+
Requires-Dist: gitpython>=3.1.0
|
|
9
|
+
Requires-Dist: textual>=0.41.0
|
|
10
|
+
|
|
11
|
+
<h1 align="center">timecapsule</h1>
|
|
12
|
+
<p align="center">
|
|
13
|
+
<em>A git-like filesystem that automatically creates narrative commits.</em>
|
|
14
|
+
<br>
|
|
15
|
+
<br>
|
|
16
|
+
<a href="#quick-start">Quick Start</a> ยท
|
|
17
|
+
<a href="#how-it-works">How It Works</a> ยท
|
|
18
|
+
<a href="#browsing">Browsing</a> ยท
|
|
19
|
+
<a href="#ollama">LLM Narrator</a>
|
|
20
|
+
<br>
|
|
21
|
+
<img src="https://img.shields.io/badge/python-โฅ3.10-00ff88" alt="Python 3.10+">
|
|
22
|
+
<img src="https://img.shields.io/badge/license-MIT-00ff88" alt="MIT License">
|
|
23
|
+
<img src="https://img.shields.io/badge/version-0.1.0-00ff88" alt="Version 0.1.0">
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
Every file has a story. timecapsule is a background daemon that watches your project folders and creates narrative commit messages from your changes โ automatically.
|
|
29
|
+
|
|
30
|
+
After 2 minutes of inactivity on a file, it snapshots the changes to a local Git repository. The commit message is generated by a small local LLM that summarizes *why* you made the changes, not just what changed.
|
|
31
|
+
|
|
32
|
+
> *"Renamed parse() to tokenize() after the linter complained about naming โ also dropped the unused xml import."*
|
|
33
|
+
|
|
34
|
+
Over time, you get a searchable, browsable timeline of your work. Each file's history becomes a diary.
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install capsule-narrative
|
|
40
|
+
timecapsule
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
This watches the current directory tree. After 2 minutes of inactivity on a file, it snapshots the changes.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
timecapsule /path/to/project # Watch a specific directory
|
|
47
|
+
timecapsule --idle 60 # Faster snapshots (60s idle)
|
|
48
|
+
timecapsule --once # One-time snapshot of all files
|
|
49
|
+
timecapsule --browse # Open the timeline browser TUI
|
|
50
|
+
timecapsule --status # Show capsule stats
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## How It Works
|
|
54
|
+
|
|
55
|
+
**File watcher.** A background daemon monitors file changes using `watchdog`. When a file hasn't been modified for 2 minutes (configurable), it triggers a snapshot โ you've finished a thought.
|
|
56
|
+
|
|
57
|
+
**Git backend.** Each file gets its own orphan branch in a dedicated bare repository at `~/.capsule/timecapsule`. The user's real Git repos are never touched. Branches are named by canonical path, so renaming or moving a file can continue its history.
|
|
58
|
+
|
|
59
|
+
**Narrative commit messages.** The daemon diffs the current file against its last snapshot and generates a commit message that reads like a diary entry.
|
|
60
|
+
|
|
61
|
+
## Browsing
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
timecapsule --browse
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Opens a Textual TUI that shows:
|
|
68
|
+
- **Tracked files** โ all files with snapshots, sorted by most recent
|
|
69
|
+
- **Timeline** โ commit history for the selected file with narrative messages
|
|
70
|
+
- **Diff preview** โ file content at any selected snapshot
|
|
71
|
+
|
|
72
|
+
You can scroll through history, search messages, and see exactly what your file looked like at any point.
|
|
73
|
+
|
|
74
|
+
## Ollama
|
|
75
|
+
|
|
76
|
+
timecapsule works without Ollama โ it generates structural commit messages by default ("Added 3 lines to main.py (Python)"). But with Ollama installed, the messages transform into genuine narrative.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
ollama pull qwen2.5-coder:1.5b
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This ~1GB model runs on any modern laptop and generates commit messages in ~2 seconds. Smaller models like `llama3.2:1b` also work.
|
|
83
|
+
|
|
84
|
+
The prompt context includes the file diff, the file path, and the previous commit message for continuity. The LLM explains *why* the changes were made, not just *what* changed.
|
|
85
|
+
|
|
86
|
+
## Architecture
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
~/.capsule/
|
|
90
|
+
โโโ timecapsule/ # Bare git repository
|
|
91
|
+
โ โโโ refs/heads/ # One orphan branch per file
|
|
92
|
+
โ โโโ home-user-project-main-py
|
|
93
|
+
โ โโโ home-user-project-readme-md
|
|
94
|
+
โ โโโ ...
|
|
95
|
+
โโโ timeline.db # SQLite index for fast queries
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Each snapshot:
|
|
99
|
+
1. Hashes the file content into a git blob
|
|
100
|
+
2. Creates a tree object referencing the blob
|
|
101
|
+
3. Commits the tree to the file's orphan branch (with parent if one exists)
|
|
102
|
+
4. Records metadata in SQLite for fast timeline queries
|
|
103
|
+
|
|
104
|
+
The SQLite store makes `timecapsule --browse` instant โ no git log parsing needed.
|
|
105
|
+
|
|
106
|
+
## FAQ
|
|
107
|
+
|
|
108
|
+
**Q: Does it affect my existing Git repos?**
|
|
109
|
+
A: No. timecapsule uses a dedicated bare repository at `~/.capsule/timecapsule`. Your `.git` directories are untouched.
|
|
110
|
+
|
|
111
|
+
**Q: What file types are watched?**
|
|
112
|
+
A: Common source files: `.py`, `.js`, `.ts`, `.rs`, `.go`, `.java`, `.md`, `.json`, `.yaml`, `.css`, `.html`, `.jsx`, `.tsx`, `.c`, `.cpp`, `.h`, `.sh`, `.rb`, `.sql`, and more.
|
|
113
|
+
|
|
114
|
+
**Q: Can I delete the capsule database?**
|
|
115
|
+
A: Yes โ remove `~/.capsule/` to wipe everything. Snapshots are disposable by design.
|
|
116
|
+
|
|
117
|
+
**Q: Does it work on Windows/Mac/Linux?**
|
|
118
|
+
A: Yes. The watcher uses `watchdog` which works on all three platforms. The git operations are cross-platform.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
*Every file has a story.*
|
|
127
|
+
|
|
128
|
+
<!-- test edit for timecapsule end-to-end -->
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
capsule/__init__.py,sha256=eTEFEXNQKdBnDdKtDdFLnrq1LyT_W7xk5a_DOpg7cK0,102
|
|
2
|
+
capsule/daemon.py,sha256=T8uVxf2_MijOTR4bbCyqoZBCZwImJbrrE386bX8g4ik,5284
|
|
3
|
+
capsule/git_ops.py,sha256=vc2f5xyKL-ZJy4G938j_dgwvvF7VqIhbDMXAKQEmmQc,5042
|
|
4
|
+
capsule/narrator.py,sha256=7BKwqn4ilfDZgtloltStEwIVbNfuK8J3wVSBg9kCfOQ,5617
|
|
5
|
+
capsule/store.py,sha256=x0YZgIk2BNMZ9PYh3FcIXg63DVEzc7eUYWkCKL_W0Fs,4364
|
|
6
|
+
capsule/tui.py,sha256=D_HDRLBFD5IKKxE89XxGIR21e6saBepHIHRICD465BE,5579
|
|
7
|
+
capsule/watcher.py,sha256=-7BypooljzCMYRFGwYdV3Egb9UcQHY311Kz2lzOcXRo,4445
|
|
8
|
+
capsule_narrative-0.1.0.dist-info/METADATA,sha256=vjsbFTpoY5kqPygEP-6uPmleuQLTQeQh8pAHfsi6bNg,5154
|
|
9
|
+
capsule_narrative-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
10
|
+
capsule_narrative-0.1.0.dist-info/entry_points.txt,sha256=xqQmBqrJKAFG3OXSf44RAY2olGHD0Os8gjeRr_xJ_Bs,52
|
|
11
|
+
capsule_narrative-0.1.0.dist-info/top_level.txt,sha256=dDtT_YzY-mbydnnwGHTM4h1e3uvEXDJhkI2OqO3az_M,8
|
|
12
|
+
capsule_narrative-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
capsule
|