terminalpulse 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,4 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ *.egg-info/
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: terminalpulse
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.10
5
+ Requires-Dist: networkx>=3.2
6
+ Requires-Dist: pydantic>=2.6
7
+ Requires-Dist: rich>=13.7
8
+ Requires-Dist: typer>=0.12
9
+ Requires-Dist: watchdog>=4.0
@@ -0,0 +1,55 @@
1
+ # TerminalPulse ⚡
2
+
3
+ Short-term memory for AI agents. Captures your coding context in real time — silently.
4
+
5
+ ## The Problem
6
+ Every AI tool today requires you to manually copy-paste your error. That takes 30 seconds and breaks your flow.
7
+
8
+ ## The Solution
9
+ TerminalPulse watches your terminal, editor, and filesystem in the background. When something goes wrong, just type `pulse fix` — no copy-pasting, no explaining.
10
+
11
+ ## Install
12
+ pip install terminalpulse
13
+
14
+ ## Setup
15
+ pulse init
16
+ source ~/.bashrc
17
+ pulse start --watch .
18
+
19
+ ## Commands
20
+ | Command | What it does |
21
+ |---|---|
22
+ | `pulse init` | Auto-injects shell hook into .bashrc |
23
+ | `pulse start` | Starts the background daemon |
24
+ | `pulse state` | Shows current coding context as JSON |
25
+ | `pulse fix` | Sends hottest error to TerminalMind for auto-fix |
26
+ | `pulse init-windows` | Setup instructions for Windows focus tracking |
27
+
28
+ ## How it works
29
+ Three streams feed a time-decaying knowledge graph:
30
+ - **Focus** — which file is open in VS Code
31
+ - **Activity** — which files were just saved
32
+ - **Distress** — which terminal commands just failed
33
+
34
+ Older events decay automatically so the AI always gets current context.
35
+
36
+ ## Test Results
37
+ | Test | Error Type | Result |
38
+ |---|---|---|
39
+ | Test 1 | ZeroDivisionError | Fixed and verified |
40
+ | Test 2 | ModuleNotFoundError | Captured (fix depends on TerminalMind version) |
41
+ | Test 3 | SyntaxError | Fixed and verified |
42
+
43
+ ## Requirements
44
+ - Python 3.10+
45
+ - WSL Ubuntu (for Linux daemon)
46
+ - terminalmind (for pulse fix command)
47
+
48
+ ## Architecture
49
+ ```
50
+ VS Code (Windows) → HTTP:7077 → pulsed daemon → ~/.devpulse_state.json
51
+ Terminal errors → Unix socket → pulsed daemon → ~/.devpulse_state.json
52
+ File saves → watchdog → pulsed daemon → ~/.devpulse_state.json
53
+
54
+ pulse fix → TerminalMind → Auto-fix
55
+ ```
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "terminalpulse"
3
+ version = "0.1.0"
4
+ requires-python = ">=3.10"
5
+ dependencies = [
6
+ "typer>=0.12",
7
+ "rich>=13.7",
8
+ "watchdog>=4.0",
9
+ "networkx>=3.2",
10
+ "pydantic>=2.6",
11
+ ]
12
+
13
+ [project.scripts]
14
+ pulse = "terminalpulse.cli:app"
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
File without changes
@@ -0,0 +1,108 @@
1
+ import os, sys, json, subprocess
2
+ from pathlib import Path
3
+ import typer, asyncio
4
+ from rich import print
5
+ from . import daemon as _daemon
6
+
7
+ app = typer.Typer(help="TerminalPulse — short-term memory for AI agents")
8
+ STATE_FILE = Path.home() / ".devpulse_state.json"
9
+
10
+
11
+ @app.command()
12
+ def start(watch: str = typer.Option(".", help="Directory to watch")):
13
+ """Run the pulse daemon in the foreground."""
14
+ asyncio.run(_daemon.main(os.path.abspath(watch)))
15
+
16
+
17
+ @app.command()
18
+ def state():
19
+ """Print the current hottest context."""
20
+ if not STATE_FILE.exists():
21
+ print("[yellow]No state yet. Is the daemon running?[/]")
22
+ raise typer.Exit(1)
23
+ print(json.loads(STATE_FILE.read_text()))
24
+
25
+
26
+ @app.command()
27
+ def fix():
28
+ """Read hottest error and active file, send to TerminalMind to fix."""
29
+ if not STATE_FILE.exists():
30
+ print("[yellow]No state yet. Is the daemon running?[/]")
31
+ raise typer.Exit(1)
32
+
33
+ state_data = json.loads(STATE_FILE.read_text())
34
+ hottest = state_data.get("hottest", [])
35
+
36
+ error = next((e for e in hottest if e["type"] == "command_failed"), None)
37
+ focus = next((e for e in hottest if e["type"] == "focus_changed"), None)
38
+
39
+ if not error:
40
+ print("[yellow]No recent errors found in pulse state.[/]")
41
+ raise typer.Exit(0)
42
+
43
+ cmd = error.get("cmd", "unknown command")
44
+ exit_code = error.get("exit_code", 1)
45
+ active_file = focus.get("path", "unknown file") if focus else "unknown file"
46
+ stderr = error.get("stderr_tail", None)
47
+
48
+ if stderr:
49
+ context = f"Command: `{cmd}` failed with exit code {exit_code}.\nActive file: {active_file}.\nError output:\n{stderr.replace('|', chr(10))}"
50
+ print(f"[cyan]Sending to TerminalMind with full error context[/]")
51
+ subprocess.run(["tmind", "ask", context])
52
+ else:
53
+ context = f"Command failed: `{cmd}` (exit code {exit_code}). Active file: {active_file}."
54
+ print(f"[cyan]Sending to TerminalMind:[/] {context}")
55
+ subprocess.run(["tmind", "heal", cmd], cwd=error.get("cwd", "."))
56
+
57
+ @app.command()
58
+ def init():
59
+ """Auto-setup: injects shell hook into .bashrc so pulse works automatically."""
60
+ bashrc = Path.home() / ".bashrc"
61
+
62
+ hook = '''
63
+ # TerminalPulse hook
64
+ _pulse_sock="$HOME/.terminalpulse.sock"
65
+ _pulse_last_cmd=""
66
+ _pulse_preexec() {
67
+ [[ "$BASH_COMMAND" == _pulse_* ]] && return
68
+ _pulse_last_cmd="$BASH_COMMAND"
69
+ }
70
+ trap '_pulse_preexec' DEBUG
71
+ _pulse_precmd() {
72
+ local code=$?
73
+ if [ $code -ne 0 ] && [ -n "$_pulse_last_cmd" ] && [ -S "$_pulse_sock" ]; then
74
+ printf \'{"type":"command_failed","cmd":"%s","exit_code":%d,"cwd":"%s"}\\n\' \\
75
+ "$_pulse_last_cmd" "$code" "$PWD" | nc -U "$_pulse_sock" 2>/dev/null
76
+ fi
77
+ _pulse_last_cmd=""
78
+ }
79
+ PROMPT_COMMAND="_pulse_precmd${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
80
+ '''
81
+
82
+ content = bashrc.read_text() if bashrc.exists() else ""
83
+ if "TerminalPulse hook" in content:
84
+ print("[yellow]TerminalPulse hook already exists in .bashrc — skipping.[/]")
85
+ return
86
+
87
+ with open(bashrc, "a") as f:
88
+ f.write(hook)
89
+
90
+ print("[green]TerminalPulse hook added to ~/.bashrc[/]")
91
+ print("[cyan]Run: source ~/.bashrc to activate[/]")
92
+
93
+
94
+ @app.command()
95
+ def init_windows():
96
+ """Print instructions to set up the Windows focus tracker."""
97
+ print("[cyan]Windows Setup Instructions:[/]")
98
+ print("")
99
+ print("1. Open Windows PowerShell")
100
+ print("2. Run this command:")
101
+ print(f"[green] python {Path.home() / 'terminalpulse/terminalpulse/windows_tracker.py'}[/]")
102
+ print("")
103
+ print("3. Keep PowerShell open while coding.")
104
+ print("[yellow]Tip: Add it to Windows Task Scheduler to auto-start on login.[/]")
105
+
106
+
107
+ if __name__ == "__main__":
108
+ app()
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+ import asyncio, json, os, signal
3
+ from pathlib import Path
4
+ from watchdog.observers import Observer
5
+ from watchdog.events import FileSystemEventHandler
6
+ from .events import Event, EventType
7
+ from .graph import PulseGraph
8
+
9
+ SOCK_PATH = Path.home() / ".terminalpulse.sock"
10
+ WATCH_EXTS = {".py", ".js", ".ts", ".tsx", ".rs", ".go", ".md", ".toml", ".json"}
11
+
12
+
13
+ class FsHandler(FileSystemEventHandler):
14
+ def __init__(self, queue: asyncio.Queue, loop: asyncio.AbstractEventLoop):
15
+ self.queue = queue
16
+ self.loop = loop
17
+
18
+ def on_modified(self, event):
19
+ if event.is_directory: return
20
+ p = Path(event.src_path)
21
+ if p.suffix not in WATCH_EXTS: return
22
+ ev = Event(type=EventType.FILE_SAVED, path=str(p), cwd=os.getcwd())
23
+ self.loop.call_soon_threadsafe(self.queue.put_nowait, ev)
24
+
25
+
26
+ async def handle_shell_client(reader, writer, queue: asyncio.Queue):
27
+ data = await reader.readline()
28
+ try:
29
+ payload = json.loads(data)
30
+ ev = Event(**payload)
31
+ await queue.put(ev)
32
+ except Exception as e:
33
+ print(f"bad payload: {e}")
34
+ writer.close()
35
+
36
+
37
+ async def handle_http_client(reader, writer, queue: asyncio.Queue):
38
+ data = b""
39
+ while True:
40
+ chunk = await reader.read(1024)
41
+ if not chunk:
42
+ break
43
+ data += chunk
44
+ if b"\r\n\r\n" in data:
45
+ break
46
+ try:
47
+ body = data.split(b"\r\n\r\n", 1)[1]
48
+ payload = json.loads(body)
49
+ ev = Event(**payload)
50
+ await queue.put(ev)
51
+ writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK")
52
+ except Exception as e:
53
+ print(f"bad http payload: {e}")
54
+ writer.write(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 2\r\n\r\nER")
55
+ await writer.drain()
56
+ writer.close()
57
+
58
+
59
+ async def consumer(queue: asyncio.Queue, graph: PulseGraph):
60
+ while True:
61
+ ev = await queue.get()
62
+ graph.ingest(ev)
63
+ print(f"⚡ {ev.type.value} {ev.path or ev.cmd or ''}")
64
+
65
+
66
+ async def main(watch_dir: str):
67
+ queue: asyncio.Queue = asyncio.Queue()
68
+ graph = PulseGraph()
69
+ loop = asyncio.get_running_loop()
70
+
71
+ obs = Observer()
72
+ obs.schedule(FsHandler(queue, loop), watch_dir, recursive=True)
73
+ obs.start()
74
+
75
+ if SOCK_PATH.exists(): SOCK_PATH.unlink()
76
+ server = await asyncio.start_unix_server(
77
+ lambda r, w: handle_shell_client(r, w, queue), path=str(SOCK_PATH)
78
+ )
79
+ os.chmod(SOCK_PATH, 0o600)
80
+
81
+ http_server = await asyncio.start_server(
82
+ lambda r, w: handle_http_client(r, w, queue),
83
+ "0.0.0.0", 7077
84
+ )
85
+
86
+ print(f"🧠 pulsed listening on {SOCK_PATH}, watching {watch_dir}")
87
+ print(f"🌐 HTTP endpoint on port 7077 for Windows bridge")
88
+ try:
89
+ await asyncio.gather(
90
+ server.serve_forever(),
91
+ http_server.serve_forever(),
92
+ consumer(queue, graph)
93
+ )
94
+ finally:
95
+ obs.stop(); obs.join()
96
+ if SOCK_PATH.exists(): SOCK_PATH.unlink()
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+ import time
3
+ from enum import Enum
4
+ from typing import Optional
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class EventType(str, Enum):
9
+ FILE_SAVED = "file_saved"
10
+ COMMAND_FAILED = "command_failed"
11
+ COMMAND_OK = "command_ok"
12
+ FOCUS_CHANGED = "focus_changed"
13
+
14
+
15
+ class Event(BaseModel):
16
+ type: EventType
17
+ ts: float = Field(default_factory=time.time)
18
+ cwd: Optional[str] = None
19
+ # type-specific:
20
+ path: Optional[str] = None # FILE_SAVED, FOCUS_CHANGED
21
+ cmd: Optional[str] = None # COMMAND_*
22
+ exit_code: Optional[int] = None # COMMAND_*
23
+ stderr_tail: Optional[str] = None # COMMAND_FAILED
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+ import json, math, time
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ import networkx as nx
6
+ from .events import Event, EventType
7
+
8
+ STATE_FILE = Path.home() / ".devpulse_state.json"
9
+ LAMBDA = 0.01 # decay rate (per second). ~0.5 weight after ~70s
10
+ PRUNE_BELOW = 0.01 # drop nodes whose weight falls below this
11
+ SEVERITY = {
12
+ EventType.COMMAND_FAILED: 1.0,
13
+ EventType.FILE_SAVED: 0.3,
14
+ EventType.FOCUS_CHANGED: 0.1,
15
+ EventType.COMMAND_OK: 0.05,
16
+ }
17
+
18
+
19
+ class PulseGraph:
20
+ def __init__(self) -> None:
21
+ self.g = nx.DiGraph()
22
+ self._counter = 0
23
+ self._focus_node: Optional[str] = None
24
+
25
+ def _weight(self, ts: float) -> float:
26
+ return math.exp(-LAMBDA * (time.time() - ts))
27
+
28
+ def ingest(self, ev: Event) -> None:
29
+ nid = f"{ev.type.value}:{self._counter}"
30
+ self._counter += 1
31
+ self.g.add_node(nid, event=ev.model_dump(), severity=SEVERITY[ev.type])
32
+
33
+ # link errors to current focus
34
+ if ev.type == EventType.COMMAND_FAILED and self._focus_node:
35
+ self.g.add_edge(nid, self._focus_node, kind="occurred_while_editing")
36
+
37
+ if ev.type == EventType.FOCUS_CHANGED:
38
+ self._focus_node = nid
39
+
40
+ self._prune()
41
+ self.export()
42
+
43
+ def _prune(self) -> None:
44
+ dead = [n for n, d in self.g.nodes(data=True)
45
+ if self._weight(d["event"]["ts"]) < PRUNE_BELOW]
46
+ self.g.remove_nodes_from(dead)
47
+ if self._focus_node not in self.g:
48
+ self._focus_node = None
49
+
50
+ def hottest(self, k: int = 5) -> list[dict]:
51
+ scored = []
52
+ for n, d in self.g.nodes(data=True):
53
+ heat = self._weight(d["event"]["ts"]) * d["severity"]
54
+ scored.append((heat, d["event"]))
55
+ scored.sort(key=lambda x: -x[0])
56
+ return [{"heat": round(h, 4), **e} for h, e in scored[:k]]
57
+
58
+ def export(self) -> None:
59
+ state = {
60
+ "generated_at": time.time(),
61
+ "hottest": self.hottest(5),
62
+ "node_count": self.g.number_of_nodes(),
63
+ }
64
+ tmp = STATE_FILE.with_suffix(".tmp")
65
+ tmp.write_text(json.dumps(state, indent=2))
66
+ tmp.replace(STATE_FILE) # atomic write
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+ import time
3
+ import json
4
+ import urllib.request
5
+ import ctypes
6
+ import ctypes.wintypes
7
+
8
+ PULSE_PORT = 7077
9
+ POLL_INTERVAL = 3
10
+ EDITORS = {"Visual Studio Code", "Cursor", "VSCodium"}
11
+
12
+ def get_active_window_title() -> str:
13
+ hwnd = ctypes.windll.user32.GetForegroundWindow()
14
+ length = ctypes.windll.user32.GetWindowTextLengthW(hwnd)
15
+ buf = ctypes.create_unicode_buffer(length + 1)
16
+ ctypes.windll.user32.GetWindowTextW(hwnd, buf, length + 1)
17
+ return buf.value
18
+
19
+ IGNORE = {"Visual Studio Code", "Cursor", "Welcome", "New Tab", "Settings", ""}
20
+
21
+ def extract_file(title: str) -> str | None:
22
+ for editor in EDITORS:
23
+ if editor in title:
24
+ parts = title.split(" - ")
25
+ if parts:
26
+ name = parts[0].strip().lstrip("●").strip()
27
+ if name and name not in IGNORE:
28
+ return name
29
+ return None
30
+ def send_focus(filename: str, port: int = PULSE_PORT):
31
+ payload = json.dumps({
32
+ "type": "focus_changed",
33
+ "path": filename,
34
+ "cwd": ""
35
+ }).encode()
36
+ try:
37
+ req = urllib.request.Request(
38
+ f"http://localhost:{port}",
39
+ data=payload,
40
+ headers={"Content-Type": "application/json"}
41
+ )
42
+ urllib.request.urlopen(req, timeout=2)
43
+ except Exception:
44
+ pass
45
+
46
+ def run():
47
+ print(f"TerminalPulse Windows tracker running (port {PULSE_PORT})")
48
+ last = None
49
+ while True:
50
+ title = get_active_window_title()
51
+ f = extract_file(title)
52
+ if f and f != last:
53
+ print(f"Focus: {f}")
54
+ send_focus(f)
55
+ last = f
56
+ time.sleep(POLL_INTERVAL)
57
+
58
+ if __name__ == "__main__":
59
+ run()
File without changes
File without changes