capsule-narrative 0.1.0__tar.gz

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.
@@ -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,118 @@
1
+ <h1 align="center">timecapsule</h1>
2
+ <p align="center">
3
+ <em>A git-like filesystem that automatically creates narrative commits.</em>
4
+ <br>
5
+ <br>
6
+ <a href="#quick-start">Quick Start</a> ·
7
+ <a href="#how-it-works">How It Works</a> ·
8
+ <a href="#browsing">Browsing</a> ·
9
+ <a href="#ollama">LLM Narrator</a>
10
+ <br>
11
+ <img src="https://img.shields.io/badge/python-≥3.10-00ff88" alt="Python 3.10+">
12
+ <img src="https://img.shields.io/badge/license-MIT-00ff88" alt="MIT License">
13
+ <img src="https://img.shields.io/badge/version-0.1.0-00ff88" alt="Version 0.1.0">
14
+ </p>
15
+
16
+ ---
17
+
18
+ Every file has a story. timecapsule is a background daemon that watches your project folders and creates narrative commit messages from your changes — automatically.
19
+
20
+ 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.
21
+
22
+ > *"Renamed parse() to tokenize() after the linter complained about naming — also dropped the unused xml import."*
23
+
24
+ Over time, you get a searchable, browsable timeline of your work. Each file's history becomes a diary.
25
+
26
+ ## Quick Start
27
+
28
+ ```bash
29
+ pip install capsule-narrative
30
+ timecapsule
31
+ ```
32
+
33
+ This watches the current directory tree. After 2 minutes of inactivity on a file, it snapshots the changes.
34
+
35
+ ```bash
36
+ timecapsule /path/to/project # Watch a specific directory
37
+ timecapsule --idle 60 # Faster snapshots (60s idle)
38
+ timecapsule --once # One-time snapshot of all files
39
+ timecapsule --browse # Open the timeline browser TUI
40
+ timecapsule --status # Show capsule stats
41
+ ```
42
+
43
+ ## How It Works
44
+
45
+ **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.
46
+
47
+ **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.
48
+
49
+ **Narrative commit messages.** The daemon diffs the current file against its last snapshot and generates a commit message that reads like a diary entry.
50
+
51
+ ## Browsing
52
+
53
+ ```bash
54
+ timecapsule --browse
55
+ ```
56
+
57
+ Opens a Textual TUI that shows:
58
+ - **Tracked files** — all files with snapshots, sorted by most recent
59
+ - **Timeline** — commit history for the selected file with narrative messages
60
+ - **Diff preview** — file content at any selected snapshot
61
+
62
+ You can scroll through history, search messages, and see exactly what your file looked like at any point.
63
+
64
+ ## Ollama
65
+
66
+ 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.
67
+
68
+ ```bash
69
+ ollama pull qwen2.5-coder:1.5b
70
+ ```
71
+
72
+ This ~1GB model runs on any modern laptop and generates commit messages in ~2 seconds. Smaller models like `llama3.2:1b` also work.
73
+
74
+ 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.
75
+
76
+ ## Architecture
77
+
78
+ ```
79
+ ~/.capsule/
80
+ ├── timecapsule/ # Bare git repository
81
+ │ └── refs/heads/ # One orphan branch per file
82
+ │ ├── home-user-project-main-py
83
+ │ ├── home-user-project-readme-md
84
+ │ └── ...
85
+ └── timeline.db # SQLite index for fast queries
86
+ ```
87
+
88
+ Each snapshot:
89
+ 1. Hashes the file content into a git blob
90
+ 2. Creates a tree object referencing the blob
91
+ 3. Commits the tree to the file's orphan branch (with parent if one exists)
92
+ 4. Records metadata in SQLite for fast timeline queries
93
+
94
+ The SQLite store makes `timecapsule --browse` instant — no git log parsing needed.
95
+
96
+ ## FAQ
97
+
98
+ **Q: Does it affect my existing Git repos?**
99
+ A: No. timecapsule uses a dedicated bare repository at `~/.capsule/timecapsule`. Your `.git` directories are untouched.
100
+
101
+ **Q: What file types are watched?**
102
+ 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.
103
+
104
+ **Q: Can I delete the capsule database?**
105
+ A: Yes — remove `~/.capsule/` to wipe everything. Snapshots are disposable by design.
106
+
107
+ **Q: Does it work on Windows/Mac/Linux?**
108
+ A: Yes. The watcher uses `watchdog` which works on all three platforms. The git operations are cross-platform.
109
+
110
+ ## License
111
+
112
+ MIT
113
+
114
+ ---
115
+
116
+ *Every file has a story.*
117
+
118
+ <!-- test edit for timecapsule end-to-end -->
@@ -0,0 +1,3 @@
1
+ """timecapsule — A git-like filesystem that creates narrative commits."""
2
+
3
+ __version__ = "0.1.0"
@@ -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()
@@ -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()]