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.
- note_watcher/__init__.py +3 -0
- note_watcher/cli.py +116 -0
- note_watcher/config.py +110 -0
- note_watcher/debouncer.py +78 -0
- note_watcher/dispatcher.py +92 -0
- note_watcher/parser.py +78 -0
- note_watcher/watcher.py +220 -0
- note_watcher/writer.py +76 -0
- notes_watcher-0.1.0.dist-info/METADATA +214 -0
- notes_watcher-0.1.0.dist-info/RECORD +14 -0
- notes_watcher-0.1.0.dist-info/WHEEL +5 -0
- notes_watcher-0.1.0.dist-info/entry_points.txt +2 -0
- notes_watcher-0.1.0.dist-info/licenses/LICENSE +21 -0
- notes_watcher-0.1.0.dist-info/top_level.txt +1 -0
note_watcher/__init__.py
ADDED
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
|
note_watcher/watcher.py
ADDED
|
@@ -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,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
|