notes-watcher 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.
@@ -0,0 +1,3 @@
1
+ """Note Watcher - Detect @ mentions in Obsidian notes and dispatch to AI agents."""
2
+
3
+ __version__ = "0.1.0"
note_watcher/cli.py ADDED
@@ -0,0 +1,116 @@
1
+ """Click CLI for Note Watcher.
2
+
3
+ Provides two commands:
4
+ - `watch`: Starts the file watcher daemon
5
+ - `process`: Batch processes all pending instructions
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ import click
15
+
16
+ from note_watcher.config import load_config
17
+ from note_watcher.dispatcher import AgentDispatcher
18
+ from note_watcher.watcher import process_file_reparse, start_watcher
19
+
20
+
21
+ def setup_logging(verbose: bool = False) -> None:
22
+ """Configure logging to stdout/stderr."""
23
+ level = logging.DEBUG if verbose else logging.INFO
24
+ logging.basicConfig(
25
+ level=level,
26
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
27
+ stream=sys.stderr,
28
+ )
29
+
30
+
31
+ @click.group()
32
+ @click.option("--verbose", "-v", is_flag=True, help="Enable debug logging.")
33
+ def main(verbose: bool) -> None:
34
+ """Note Watcher - Detect @ mentions in Obsidian notes and dispatch to AI agents."""
35
+ setup_logging(verbose)
36
+
37
+
38
+ @main.command()
39
+ @click.option(
40
+ "--vault",
41
+ type=click.Path(exists=True, file_okay=False, resolve_path=True),
42
+ help="Path to the Obsidian vault directory.",
43
+ )
44
+ @click.option(
45
+ "--config",
46
+ "config_path",
47
+ type=click.Path(exists=True, dir_okay=False, resolve_path=True),
48
+ help="Path to the configuration file.",
49
+ )
50
+ def watch(vault: str | None, config_path: str | None) -> None:
51
+ """Start the file watcher daemon.
52
+
53
+ Monitors the vault directory for changes to .md files and processes
54
+ @ mention instructions as they appear.
55
+ """
56
+ config = load_config(config_path)
57
+
58
+ if vault:
59
+ config.vault = Path(vault)
60
+
61
+ if not config.vault.is_dir():
62
+ click.echo(f"Error: Vault directory does not exist: {config.vault}", err=True)
63
+ sys.exit(1)
64
+
65
+ click.echo(f"Watching vault: {config.vault}")
66
+ start_watcher(config)
67
+
68
+
69
+ @main.command()
70
+ @click.option(
71
+ "--all",
72
+ "process_all",
73
+ is_flag=True,
74
+ required=True,
75
+ help="Process all pending instructions.",
76
+ )
77
+ @click.option(
78
+ "--vault",
79
+ type=click.Path(exists=True, file_okay=False, resolve_path=True),
80
+ help="Path to the Obsidian vault directory.",
81
+ )
82
+ @click.option(
83
+ "--config",
84
+ "config_path",
85
+ type=click.Path(exists=True, dir_okay=False, resolve_path=True),
86
+ help="Path to the configuration file.",
87
+ )
88
+ def process(process_all: bool, vault: str | None, config_path: str | None) -> None:
89
+ """Batch process all pending @ mention instructions.
90
+
91
+ Scans all .md files in the vault for unprocessed instructions,
92
+ dispatches them to configured agents, and writes results inline.
93
+ """
94
+ config = load_config(config_path)
95
+
96
+ if vault:
97
+ config.vault = Path(vault)
98
+
99
+ if not config.vault.is_dir():
100
+ click.echo(f"Error: Vault directory does not exist: {config.vault}", err=True)
101
+ sys.exit(1)
102
+
103
+ dispatcher = AgentDispatcher(config)
104
+ vault_path = config.vault
105
+
106
+ # Find all .md files
107
+ md_files = sorted(vault_path.rglob("*.md"))
108
+ total_processed = 0
109
+
110
+ for md_file in md_files:
111
+ count = process_file_reparse(str(md_file), dispatcher)
112
+ if count > 0:
113
+ click.echo(f"Processed {count} instruction(s) in {md_file}")
114
+ total_processed += count
115
+
116
+ click.echo(f"Done. Processed {total_processed} instruction(s) total.")
note_watcher/config.py ADDED
@@ -0,0 +1,110 @@
1
+ """Configuration loading and management for Note Watcher.
2
+
3
+ Loads YAML configuration from a file, applies sensible defaults,
4
+ and validates required fields.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import yaml
15
+
16
+
17
+ DEFAULT_CONFIG_PATH = Path.home() / ".config" / "note-watcher" / "config.yml"
18
+
19
+ DEFAULT_DEBOUNCE_SECONDS = 1.0
20
+
21
+ DEFAULT_IGNORE_PATTERNS: list[str] = [
22
+ "*.excalidraw.md",
23
+ ".trash/**",
24
+ ]
25
+
26
+
27
+ @dataclass
28
+ class AgentConfig:
29
+ """Configuration for a single agent."""
30
+
31
+ name: str
32
+ type: str
33
+ command: str | None = None
34
+ callable: str | None = None
35
+
36
+ @classmethod
37
+ def from_dict(cls, name: str, data: dict[str, Any]) -> AgentConfig:
38
+ return cls(
39
+ name=name,
40
+ type=data.get("type", "echo"),
41
+ command=data.get("command"),
42
+ callable=data.get("callable"),
43
+ )
44
+
45
+
46
+ @dataclass
47
+ class Config:
48
+ """Application configuration."""
49
+
50
+ vault: Path
51
+ debounce_seconds: float = DEFAULT_DEBOUNCE_SECONDS
52
+ ignore_patterns: list[str] = field(default_factory=lambda: list(DEFAULT_IGNORE_PATTERNS))
53
+ agents: dict[str, AgentConfig] = field(default_factory=dict)
54
+
55
+ @classmethod
56
+ def from_dict(cls, data: dict[str, Any]) -> Config:
57
+ """Create a Config from a parsed YAML dictionary."""
58
+ vault_str = data.get("vault", ".")
59
+ # Expand ~ and environment variables in the vault path
60
+ vault = Path(os.path.expanduser(os.path.expandvars(vault_str)))
61
+
62
+ debounce = data.get("debounce_seconds", DEFAULT_DEBOUNCE_SECONDS)
63
+
64
+ ignore = data.get("ignore_patterns", list(DEFAULT_IGNORE_PATTERNS))
65
+
66
+ agents: dict[str, AgentConfig] = {}
67
+ for name, agent_data in data.get("agents", {}).items():
68
+ if isinstance(agent_data, dict):
69
+ agents[name] = AgentConfig.from_dict(name, agent_data)
70
+ else:
71
+ # Simple string value treated as the type
72
+ agents[name] = AgentConfig(name=name, type=str(agent_data))
73
+
74
+ return cls(
75
+ vault=vault,
76
+ debounce_seconds=float(debounce),
77
+ ignore_patterns=ignore,
78
+ agents=agents,
79
+ )
80
+
81
+ @classmethod
82
+ def defaults(cls, vault: str | Path = ".") -> Config:
83
+ """Create a Config with default values."""
84
+ return cls(vault=Path(vault))
85
+
86
+
87
+ def load_config(config_path: str | Path | None = None) -> Config:
88
+ """Load configuration from a YAML file.
89
+
90
+ Args:
91
+ config_path: Path to the config file. If None, uses the default path.
92
+
93
+ Returns:
94
+ A Config instance. If the config file doesn't exist, returns defaults.
95
+ """
96
+ if config_path is None:
97
+ path = DEFAULT_CONFIG_PATH
98
+ else:
99
+ path = Path(config_path)
100
+
101
+ if not path.exists():
102
+ return Config.defaults()
103
+
104
+ with open(path) as f:
105
+ data = yaml.safe_load(f)
106
+
107
+ if data is None:
108
+ return Config.defaults()
109
+
110
+ return Config.from_dict(data)
@@ -0,0 +1,78 @@
1
+ """Debounce logic for rapid file changes.
2
+
3
+ Prevents duplicate processing when a file is modified multiple times
4
+ in quick succession (e.g., editors that write temp files then rename).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import threading
10
+ from typing import Callable
11
+
12
+
13
+ class Debouncer:
14
+ """Debounces file change events by path.
15
+
16
+ When a file change is triggered, the callback is delayed by the configured
17
+ interval. If another trigger arrives before the interval expires, the timer
18
+ resets. This ensures the callback fires only once after the last change
19
+ within a burst of rapid changes.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ interval: float,
25
+ callback: Callable[[str], None],
26
+ ) -> None:
27
+ """Initialize the debouncer.
28
+
29
+ Args:
30
+ interval: Seconds to wait after the last trigger before firing.
31
+ callback: Function to call with the file path when debounce fires.
32
+ """
33
+ self.interval = interval
34
+ self.callback = callback
35
+ self._timers: dict[str, threading.Timer] = {}
36
+ self._lock = threading.Lock()
37
+ self._cancelled = False
38
+
39
+ def trigger(self, file_path: str) -> None:
40
+ """Signal that a file has changed.
41
+
42
+ Resets the debounce timer for this file. The callback will fire
43
+ after ``interval`` seconds of no further triggers for this file.
44
+
45
+ Args:
46
+ file_path: Absolute path to the changed file.
47
+ """
48
+ with self._lock:
49
+ if self._cancelled:
50
+ return
51
+
52
+ # Cancel any existing timer for this file
53
+ existing = self._timers.pop(file_path, None)
54
+ if existing is not None:
55
+ existing.cancel()
56
+
57
+ # Schedule the callback after the full interval
58
+ timer = threading.Timer(self.interval, self._fire, args=(file_path,))
59
+ timer.daemon = True
60
+ self._timers[file_path] = timer
61
+ timer.start()
62
+
63
+ def _fire(self, file_path: str) -> None:
64
+ """Execute the callback if not cancelled."""
65
+ with self._lock:
66
+ if self._cancelled:
67
+ return
68
+ self._timers.pop(file_path, None)
69
+
70
+ self.callback(file_path)
71
+
72
+ def cancel_all(self) -> None:
73
+ """Cancel all pending timers. No further callbacks will fire."""
74
+ with self._lock:
75
+ self._cancelled = True
76
+ for timer in self._timers.values():
77
+ timer.cancel()
78
+ self._timers.clear()
@@ -0,0 +1,92 @@
1
+ """Agent dispatcher that routes instructions to configured agent handlers.
2
+
3
+ Supports built-in agent types (echo, uppercase) and extensible configuration
4
+ for command-based or callable-based agents.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from note_watcher.config import AgentConfig, Config
14
+ from note_watcher.parser import Instruction
15
+
16
+
17
+ class UnknownAgentError(Exception):
18
+ """Raised when an instruction references an agent that isn't configured."""
19
+
20
+ def __init__(self, agent_name: str) -> None:
21
+ self.agent_name = agent_name
22
+ super().__init__(f"Unknown agent: {agent_name!r}")
23
+
24
+
25
+ class AgentDispatcher:
26
+ """Routes instructions to the appropriate agent handler."""
27
+
28
+ def __init__(self, config: Config) -> None:
29
+ self.config = config
30
+
31
+ def dispatch(self, instruction: Instruction) -> str:
32
+ """Dispatch an instruction to the appropriate agent and return the result.
33
+
34
+ Args:
35
+ instruction: The parsed instruction to process.
36
+
37
+ Returns:
38
+ The agent's result as a string.
39
+
40
+ Raises:
41
+ UnknownAgentError: If the agent isn't configured.
42
+ """
43
+ agent_config = self.config.agents.get(instruction.agent_name)
44
+ if agent_config is None:
45
+ raise UnknownAgentError(instruction.agent_name)
46
+
47
+ return self._handle(agent_config, instruction)
48
+
49
+ def _handle(self, agent_config: AgentConfig, instruction: Instruction) -> str:
50
+ """Route to the correct handler based on agent type."""
51
+ handler_type = agent_config.type
52
+
53
+ if handler_type == "echo":
54
+ return self._handle_echo(instruction)
55
+ elif handler_type == "uppercase":
56
+ return self._handle_uppercase(instruction)
57
+ elif handler_type == "command":
58
+ return self._handle_command(agent_config, instruction)
59
+ else:
60
+ raise UnknownAgentError(
61
+ f"{instruction.agent_name} (unsupported type: {handler_type})"
62
+ )
63
+
64
+ def _handle_echo(self, instruction: Instruction) -> str:
65
+ """Echo agent: returns the instruction text unchanged."""
66
+ return instruction.instruction_text
67
+
68
+ def _handle_uppercase(self, instruction: Instruction) -> str:
69
+ """Uppercase agent: returns the instruction text in uppercase."""
70
+ return instruction.instruction_text.upper()
71
+
72
+ def _handle_command(
73
+ self, agent_config: AgentConfig, instruction: Instruction
74
+ ) -> str:
75
+ """Command agent: runs a shell command with the instruction as input.
76
+
77
+ The instruction text is passed via stdin.
78
+ """
79
+ if not agent_config.command:
80
+ raise ValueError(f"Agent {agent_config.name!r} has type 'command' but no command configured")
81
+
82
+ result = subprocess.run(
83
+ agent_config.command,
84
+ input=instruction.instruction_text,
85
+ capture_output=True,
86
+ text=True,
87
+ shell=True,
88
+ timeout=30,
89
+ )
90
+ if result.returncode != 0:
91
+ return f"Error: {result.stderr.strip()}"
92
+ return result.stdout.strip()
note_watcher/parser.py ADDED
@@ -0,0 +1,78 @@
1
+ """Parser for extracting @ mention instructions from markdown content.
2
+
3
+ Extracts instructions like `@agent_name instruction text` from markdown files,
4
+ while skipping content that has already been processed (wrapped in completed markers).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+
12
+
13
+ # Pattern to match @ mention instructions: @agent_name followed by instruction text
14
+ INSTRUCTION_PATTERN = re.compile(r"^@(\w+)\s+(.+)$")
15
+
16
+ # Markers for completed instructions
17
+ # New format: <!-- @done agent_name: instruction text (no closing -->)
18
+ # Old format: <!-- @done agent_name --> (kept for backwards compatibility)
19
+ DONE_START_PATTERN = re.compile(r"^<!--\s*@done\s+(\w+).*$")
20
+ DONE_END_PATTERN = re.compile(r"^.*/@done\s*-->$")
21
+
22
+
23
+ @dataclass
24
+ class Instruction:
25
+ """A parsed @ mention instruction."""
26
+
27
+ agent_name: str
28
+ instruction_text: str
29
+ line_number: int
30
+ original_text: str
31
+
32
+
33
+ def parse_instructions(content: str) -> list[Instruction]:
34
+ """Extract @ mention instructions from markdown content.
35
+
36
+ Skips any content between `<!-- @done ... -->` and `<!-- /@done -->`
37
+ markers, which indicate already-processed instructions.
38
+
39
+ Args:
40
+ content: The full markdown file content.
41
+
42
+ Returns:
43
+ A list of Instruction objects for each unprocessed @ mention found.
44
+ """
45
+ instructions: list[Instruction] = []
46
+ lines = content.split("\n")
47
+ in_done_block = False
48
+
49
+ for i, line in enumerate(lines):
50
+ stripped = line.strip()
51
+
52
+ # Check for start of a completed block
53
+ if DONE_START_PATTERN.match(stripped):
54
+ in_done_block = True
55
+ continue
56
+
57
+ # Check for end of a completed block
58
+ if DONE_END_PATTERN.match(stripped):
59
+ in_done_block = False
60
+ continue
61
+
62
+ # Skip lines inside completed blocks
63
+ if in_done_block:
64
+ continue
65
+
66
+ # Try to match an instruction
67
+ match = INSTRUCTION_PATTERN.match(stripped)
68
+ if match:
69
+ instructions.append(
70
+ Instruction(
71
+ agent_name=match.group(1),
72
+ instruction_text=match.group(2),
73
+ line_number=i + 1, # 1-indexed
74
+ original_text=line,
75
+ )
76
+ )
77
+
78
+ return instructions
@@ -0,0 +1,220 @@
1
+ """File watcher that monitors an Obsidian vault for changes to .md files.
2
+
3
+ Uses watchdog to detect file modifications, applies debouncing, and triggers
4
+ the parse → dispatch → write pipeline for each changed file.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import fnmatch
10
+ import logging
11
+ import signal
12
+ import sys
13
+ import time
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING
16
+
17
+ from watchdog.events import FileSystemEventHandler, FileModifiedEvent
18
+ from watchdog.observers import Observer
19
+
20
+ from note_watcher.debouncer import Debouncer
21
+ from note_watcher.dispatcher import AgentDispatcher, UnknownAgentError
22
+ from note_watcher.parser import parse_instructions
23
+ from note_watcher.writer import write_result
24
+
25
+ if TYPE_CHECKING:
26
+ from note_watcher.config import Config
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class NoteEventHandler(FileSystemEventHandler):
32
+ """Handles file system events for .md files in the vault."""
33
+
34
+ def __init__(self, debouncer: Debouncer, ignore_patterns: list[str]) -> None:
35
+ super().__init__()
36
+ self.debouncer = debouncer
37
+ self.ignore_patterns = ignore_patterns
38
+
39
+ def on_modified(self, event: FileModifiedEvent) -> None: # type: ignore[override]
40
+ if event.is_directory:
41
+ return
42
+
43
+ src_path = str(event.src_path)
44
+
45
+ # Only process .md files
46
+ if not src_path.endswith(".md"):
47
+ return
48
+
49
+ # Check ignore patterns
50
+ if self._should_ignore(src_path):
51
+ logger.debug("Ignoring %s (matches ignore pattern)", src_path)
52
+ return
53
+
54
+ logger.info("Detected change: %s", src_path)
55
+ self.debouncer.trigger(src_path)
56
+
57
+ def _should_ignore(self, file_path: str) -> bool:
58
+ """Check if a file matches any of the ignore patterns."""
59
+ path = Path(file_path)
60
+ for pattern in self.ignore_patterns:
61
+ # Check against the filename and the full path
62
+ if fnmatch.fnmatch(path.name, pattern):
63
+ return True
64
+ if fnmatch.fnmatch(str(path), pattern):
65
+ return True
66
+ return False
67
+
68
+
69
+ def process_file(file_path: str, dispatcher: AgentDispatcher) -> int:
70
+ """Parse a file, dispatch instructions, and write results.
71
+
72
+ Args:
73
+ file_path: Path to the markdown file to process.
74
+ dispatcher: The agent dispatcher to use.
75
+
76
+ Returns:
77
+ Number of instructions processed.
78
+ """
79
+ path = Path(file_path)
80
+ if not path.exists():
81
+ logger.warning("File no longer exists: %s", file_path)
82
+ return 0
83
+
84
+ content = path.read_text()
85
+ instructions = parse_instructions(content)
86
+
87
+ if not instructions:
88
+ logger.debug("No pending instructions in %s", file_path)
89
+ return 0
90
+
91
+ processed = 0
92
+ for instruction in instructions:
93
+ try:
94
+ logger.info(
95
+ "Dispatching @%s: %s",
96
+ instruction.agent_name,
97
+ instruction.instruction_text[:50],
98
+ )
99
+ result = dispatcher.dispatch(instruction)
100
+ write_result(file_path, instruction, result)
101
+ processed += 1
102
+ logger.info("Wrote result for @%s", instruction.agent_name)
103
+
104
+ # Re-read content after each write since line numbers shift
105
+ # For subsequent instructions, we need to re-parse
106
+ if processed < len(instructions):
107
+ content = path.read_text()
108
+ remaining = parse_instructions(content)
109
+ if not remaining:
110
+ break
111
+ # Process just the next instruction from the fresh parse
112
+ # The for loop will naturally move to the next one but we need
113
+ # to handle the shifted line numbers
114
+ except UnknownAgentError as e:
115
+ logger.warning("Skipping unknown agent: %s", e)
116
+ except Exception as e:
117
+ logger.error("Error processing instruction: %s", e)
118
+
119
+ return processed
120
+
121
+
122
+ def process_file_reparse(file_path: str, dispatcher: AgentDispatcher) -> int:
123
+ """Parse a file, dispatch instructions one at a time, re-parsing after each.
124
+
125
+ This handles the line-number shift problem by re-parsing after each write.
126
+
127
+ Args:
128
+ file_path: Path to the markdown file to process.
129
+ dispatcher: The agent dispatcher to use.
130
+
131
+ Returns:
132
+ Number of instructions processed.
133
+ """
134
+ path = Path(file_path)
135
+ processed = 0
136
+
137
+ while True:
138
+ if not path.exists():
139
+ logger.warning("File no longer exists: %s", file_path)
140
+ break
141
+
142
+ content = path.read_text()
143
+ instructions = parse_instructions(content)
144
+
145
+ if not instructions:
146
+ break
147
+
148
+ instruction = instructions[0]
149
+ try:
150
+ logger.info(
151
+ "Dispatching @%s: %s",
152
+ instruction.agent_name,
153
+ instruction.instruction_text[:50],
154
+ )
155
+ result = dispatcher.dispatch(instruction)
156
+ write_result(file_path, instruction, result)
157
+ processed += 1
158
+ logger.info("Wrote result for @%s", instruction.agent_name)
159
+ except UnknownAgentError as e:
160
+ logger.warning("Skipping unknown agent: %s", e)
161
+ break
162
+ except Exception as e:
163
+ logger.error("Error processing instruction: %s", e)
164
+ break
165
+
166
+ return processed
167
+
168
+
169
+ def start_watcher(config: Config) -> None:
170
+ """Start the file watcher daemon.
171
+
172
+ Watches the configured vault directory for .md file changes,
173
+ processes them through the parse → dispatch → write pipeline.
174
+
175
+ Handles SIGTERM and SIGINT for graceful shutdown.
176
+
177
+ Args:
178
+ config: Application configuration.
179
+ """
180
+ dispatcher = AgentDispatcher(config)
181
+
182
+ def on_file_changed(file_path: str) -> None:
183
+ process_file_reparse(file_path, dispatcher)
184
+
185
+ debouncer = Debouncer(
186
+ interval=config.debounce_seconds,
187
+ callback=on_file_changed,
188
+ )
189
+
190
+ handler = NoteEventHandler(
191
+ debouncer=debouncer,
192
+ ignore_patterns=config.ignore_patterns,
193
+ )
194
+
195
+ observer = Observer()
196
+ observer.schedule(handler, str(config.vault), recursive=True)
197
+
198
+ # Signal handling for graceful shutdown
199
+ shutdown_event = False
200
+
201
+ def handle_signal(signum: int, frame: object) -> None:
202
+ nonlocal shutdown_event
203
+ logger.info("Received signal %d, shutting down...", signum)
204
+ shutdown_event = True
205
+
206
+ signal.signal(signal.SIGTERM, handle_signal)
207
+ signal.signal(signal.SIGINT, handle_signal)
208
+
209
+ logger.info("Starting watcher on vault: %s", config.vault)
210
+ observer.start()
211
+
212
+ try:
213
+ while not shutdown_event:
214
+ time.sleep(0.5)
215
+ finally:
216
+ logger.info("Stopping watcher...")
217
+ debouncer.cancel_all()
218
+ observer.stop()
219
+ observer.join(timeout=5)
220
+ logger.info("Watcher stopped.")
note_watcher/writer.py ADDED
@@ -0,0 +1,76 @@
1
+ """Inline writer that replaces instructions with agent results.
2
+
3
+ Wraps results in completed markers to prevent reprocessing.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from note_watcher.parser import Instruction
13
+
14
+
15
+ def format_result(agent_name: str, instruction_text: str, result: str) -> str:
16
+ """Format an agent result with completed markers.
17
+
18
+ The entire result is enclosed in a single HTML comment so it is hidden
19
+ from rendered markdown. The original instruction text is preserved on
20
+ the opening line after the agent name.
21
+
22
+ Args:
23
+ agent_name: Name of the agent that produced the result.
24
+ instruction_text: The original instruction text from the @ mention.
25
+ result: The agent's output text.
26
+
27
+ Returns:
28
+ The result wrapped in a single HTML comment block.
29
+ """
30
+ return f"<!-- @done {agent_name}: {instruction_text}\n{result}\n/@done -->"
31
+
32
+
33
+ def write_result(
34
+ file_path: str | Path,
35
+ instruction: Instruction,
36
+ result: str,
37
+ ) -> None:
38
+ """Write an agent result back into a file, replacing the original instruction.
39
+
40
+ Reads the file, finds the instruction line, replaces it with the formatted
41
+ result block, and writes the file back.
42
+
43
+ Args:
44
+ file_path: Path to the markdown file.
45
+ instruction: The original instruction that was processed.
46
+ result: The agent's output text.
47
+ """
48
+ path = Path(file_path)
49
+ content = path.read_text()
50
+ lines = content.split("\n")
51
+
52
+ # Find the instruction line (0-indexed)
53
+ line_idx = instruction.line_number - 1
54
+
55
+ if line_idx < 0 or line_idx >= len(lines):
56
+ raise IndexError(
57
+ f"Instruction line {instruction.line_number} out of range "
58
+ f"(file has {len(lines)} lines)"
59
+ )
60
+
61
+ # Verify the line still matches the original instruction
62
+ if lines[line_idx].strip() != instruction.original_text.strip():
63
+ raise ValueError(
64
+ f"Line {instruction.line_number} has changed since parsing. "
65
+ f"Expected: {instruction.original_text.strip()!r}, "
66
+ f"Got: {lines[line_idx].strip()!r}"
67
+ )
68
+
69
+ # Replace the instruction line with the formatted result
70
+ formatted = format_result(
71
+ instruction.agent_name, instruction.instruction_text, result
72
+ )
73
+ lines[line_idx] = formatted
74
+
75
+ # Write back
76
+ path.write_text("\n".join(lines))
@@ -0,0 +1,214 @@
1
+ Metadata-Version: 2.4
2
+ Name: notes-watcher
3
+ Version: 0.1.0
4
+ Summary: A daemon that detects @ mentions in Obsidian notes, dispatches instructions to AI agents, and writes results back inline.
5
+ Author: Britt Crawford
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/britt/obsidian-notes-watcher
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: watchdog>=3.0
18
+ Requires-Dist: pyyaml>=6.0
19
+ Requires-Dist: click>=8.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7.0; extra == "dev"
22
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ # Note Watcher
26
+
27
+ A tool that detects `@` mentions in Obsidian markdown notes stored in Git and dispatches instructions to configured agents — like [Claude Code](https://docs.anthropic.com/en/docs/claude-code) — that can read, modify, and reorganize your notes directly.
28
+
29
+ Write `@agent_name do something` in any note, and Note Watcher dispatches the instruction to the named agent. The agent can edit files, create new notes, restructure content, or make any other changes to your vault. The original instruction is then replaced with a completion marker (an HTML comment, invisible in rendered markdown) so it is never reprocessed:
30
+
31
+ ```markdown
32
+ <!-- @done agent_name: do something
33
+ Agent response summary goes here.
34
+ /@done -->
35
+ ```
36
+
37
+ The real work happens in the commit: the agent's changes to your vault are committed back to Git. The completion comment is just a record that the instruction was processed.
38
+
39
+ ## Modes of Operation
40
+
41
+ | Mode | Use case |
42
+ |------|----------|
43
+ | **Daemon** | Real-time file watching on macOS via a LaunchAgent |
44
+ | **GitHub Action** | One-shot batch processing on every push that changes `.md` files |
45
+
46
+ ## Requirements
47
+
48
+ - Python 3.10+
49
+
50
+ ## Installation
51
+
52
+ ```bash
53
+ pip install notes-watcher
54
+ ```
55
+
56
+ For development:
57
+
58
+ ```bash
59
+ pip install -e ".[dev]"
60
+ ```
61
+
62
+ ## Configuration
63
+
64
+ Copy the example config and edit it:
65
+
66
+ ```bash
67
+ mkdir -p ~/.config/note-watcher
68
+ cp config.example.yml ~/.config/note-watcher/config.yml
69
+ ```
70
+
71
+ The default config location is `~/.config/note-watcher/config.yml`. You can override it with `--config`:
72
+
73
+ ```bash
74
+ note-watcher watch --config /path/to/config.yml
75
+ ```
76
+
77
+ ### Config reference
78
+
79
+ ```yaml
80
+ # Path to your Obsidian vault
81
+ vault: ~/Obsidian/MyVault
82
+
83
+ # Seconds to wait before processing after a file change
84
+ debounce_seconds: 1.0
85
+
86
+ # File patterns to ignore (glob syntax)
87
+ ignore_patterns:
88
+ - "*.excalidraw.md"
89
+ - ".trash/**"
90
+
91
+ # Agent definitions
92
+ agents:
93
+ summarizer:
94
+ type: echo # Returns instruction text unchanged
95
+ uppercase:
96
+ type: uppercase # Returns instruction text in uppercase
97
+ word_count:
98
+ type: command
99
+ command: "wc -w" # Runs a shell command, passes instruction via stdin
100
+ ```
101
+
102
+ ### Agent types
103
+
104
+ | Type | Behavior |
105
+ |------|----------|
106
+ | `echo` | Returns the instruction text unchanged |
107
+ | `uppercase` | Returns the instruction text in uppercase |
108
+ | `command` | Runs a shell command with instruction text on stdin, returns stdout |
109
+
110
+ ### Example: Using Claude Code as an agent
111
+
112
+ Configure a `command` agent that dispatches instructions to [Claude Code](https://docs.anthropic.com/en/docs/claude-code):
113
+
114
+ ```yaml
115
+ agents:
116
+ claude:
117
+ type: command
118
+ command: "claude -p" # Dispatches instruction to Claude Code CLI
119
+ ```
120
+
121
+ Claude Code runs with full access to your vault, so it can edit notes, create new files, and reorganize content — not just respond in a comment. Write `@claude` instructions in your notes:
122
+
123
+ ```markdown
124
+ @claude Summarize the key points of this meeting and add action items to my Tasks note
125
+ ```
126
+
127
+ ## Daemon Mode
128
+
129
+ Daemon mode continuously watches your Obsidian vault for changes and processes `@` mentions in real time.
130
+
131
+ ### Running manually
132
+
133
+ ```bash
134
+ # Watch the vault specified in your config
135
+ note-watcher watch
136
+
137
+ # Override the vault path
138
+ note-watcher watch --vault ~/Obsidian/MyVault
139
+
140
+ # Enable verbose logging
141
+ note-watcher -v watch --vault ~/Obsidian/MyVault
142
+ ```
143
+
144
+ Stop the daemon with `Ctrl+C` (`SIGINT`) or `SIGTERM`.
145
+
146
+ ### Installing as a macOS LaunchAgent
147
+
148
+ The included install script sets up Note Watcher as a LaunchAgent that starts on login and restarts on crash:
149
+
150
+ ```bash
151
+ ./scripts/install.sh
152
+ ```
153
+
154
+ The script is idempotent and safe to run multiple times. It will:
155
+
156
+ 1. Detect the `note-watcher` executable on your system
157
+ 2. Generate a LaunchAgent plist from the included template
158
+ 3. Install it to `~/Library/LaunchAgents/`
159
+ 4. Start the daemon
160
+
161
+ Logs are written to `~/Library/Logs/note-watcher/`.
162
+
163
+ ### Uninstalling the LaunchAgent
164
+
165
+ ```bash
166
+ ./scripts/uninstall.sh
167
+ ```
168
+
169
+ To also remove the log directory:
170
+
171
+ ```bash
172
+ ./scripts/uninstall.sh --clean
173
+ ```
174
+
175
+ ## GitHub Action Mode
176
+
177
+ GitHub Action mode processes all pending `@` instructions across the entire vault in a single batch run. This is useful for vaults stored in a Git repository.
178
+
179
+ ### CLI usage
180
+
181
+ ```bash
182
+ note-watcher process --all --vault /path/to/vault
183
+ ```
184
+
185
+ ### Setting up the GitHub Actions workflow
186
+
187
+ See [`examples/github-action/`](examples/github-action/) for a complete, ready-to-copy example that uses [Claude Code](https://docs.anthropic.com/en/docs/claude-code) as the AI agent.
188
+
189
+ To set it up:
190
+
191
+ 1. Copy `examples/github-action/.github/` into your notes repository
192
+ 2. Add a `config.yml` to your notes repo (see `examples/github-action/config.example.yml`)
193
+ 3. Add your `ANTHROPIC_API_KEY` as a repository secret
194
+ 4. Under **Settings > Actions > General**, set "Workflow permissions" to "Read and write permissions"
195
+
196
+ The workflow triggers on any push that modifies `.md` files, processes all unprocessed `@` instructions, and commits the agent's changes back to your repository. It uses `[skip ci]` to prevent infinite loops.
197
+
198
+ See the [Claude Code GitHub Actions documentation](https://docs.anthropic.com/en/docs/claude-code/github-actions) for more on setting up Claude Code in CI.
199
+
200
+ ## Running Tests
201
+
202
+ ```bash
203
+ pytest
204
+ ```
205
+
206
+ With coverage:
207
+
208
+ ```bash
209
+ pytest --cov=note_watcher
210
+ ```
211
+
212
+ ## License
213
+
214
+ [MIT](LICENSE)
@@ -0,0 +1,14 @@
1
+ note_watcher/__init__.py,sha256=TSyqWVGaUFPNonbeSbWcya9PXn4uflBcTFrlKVteiYA,107
2
+ note_watcher/cli.py,sha256=oY3_D5-rRiSw8wQMfHGxZu0wWLdmyz4DAoAwu85pmrI,3286
3
+ note_watcher/config.py,sha256=IkZ8sHHBumrGUPEzdWSRocMc7mQPN8A2bdSqAH7X-xA,3025
4
+ note_watcher/debouncer.py,sha256=66DQgNa-iJhAnh16VvV5LAGz1gTnBvQZtEitimUStl4,2506
5
+ note_watcher/dispatcher.py,sha256=tbiHvpcE9xJDH19eUME-2w9g86N4m7heBJfaOswyimo,3164
6
+ note_watcher/parser.py,sha256=NsQ6xdj0tY6YpN9Y5MO7S7hTkks2S8IA_su9o0uS-Dw,2316
7
+ note_watcher/watcher.py,sha256=9pTicBvlXQBcFG1Nr-JDdoRElPnpr5klXZQtiOU7VT4,6849
8
+ note_watcher/writer.py,sha256=30sVi6npOie3oKrpNhfMDrlakLTPwmzQHlaqqTxftRg,2446
9
+ notes_watcher-0.1.0.dist-info/licenses/LICENSE,sha256=TGsvQjvTOtnI1398o78vrXa7AshhcGTmQ8jwHYQI4Cs,1071
10
+ notes_watcher-0.1.0.dist-info/METADATA,sha256=24d_J9VW4mTtY5QVKC_I-GLMUsfSoqp0ZD-Q4BiHbxI,6259
11
+ notes_watcher-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
12
+ notes_watcher-0.1.0.dist-info/entry_points.txt,sha256=wJKq1pcsnCq6-_KDWU2qI9kv0YU0-wIeawH_tO0Gmg4,55
13
+ notes_watcher-0.1.0.dist-info/top_level.txt,sha256=ZK3wUA1ETERmvHpiKHYg-UMDm-ykKELQ4am5irYhkqU,13
14
+ notes_watcher-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ note-watcher = note_watcher.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Britt Crawford
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ note_watcher