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 ADDED
@@ -0,0 +1,3 @@
1
+ """timecapsule โ€” A git-like filesystem that creates narrative commits."""
2
+
3
+ __version__ = "0.1.0"
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ timecapsule = capsule.daemon:main
@@ -0,0 +1 @@
1
+ capsule