ai-memory-cli 0.1.1__tar.gz → 0.1.5__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-memory-cli
3
- Version: 0.1.1
3
+ Version: 0.1.5
4
4
  Summary: Python CLI for AI Memory terminal capture and offline sync.
5
5
  Author: AI Memory
6
6
  License-Expression: MIT
@@ -50,15 +50,44 @@ python -m pip install -e .
50
50
  python -m ai_memory_cli auth --token TOKEN_FROM_WEBSITE --api-url https://api.your-domain.com
51
51
  python -m ai_memory_cli init --project my-project --repo owner/repo --workspace .
52
52
  python -m ai_memory_cli workspace connect --path . --repo owner/repo --editor vscode --package-manager pip
53
- python -m ai_memory_cli watch
53
+ watch
54
54
  ```
55
55
 
56
+ `watch` is a shortcut for `python -m ai_memory_cli watch`. If Windows Device Guard blocks the generated launcher, keep using `python -m ai_memory_cli watch`.
57
+ On Windows the shortcut is installed as `watch.cmd`; the Python Scripts folder must be on `PATH` for bare `watch` to resolve.
58
+
56
59
  Use `python -m ai_memory_cli run -- COMMAND` when you only want to record one command.
57
60
 
58
61
  On Windows, `python -m ai_memory_cli ...` is the safest form because it avoids PATH issues and Device Guard policies that can block pip's generated `ai-memory.exe` launcher. Also avoid angle bracket placeholders in CMD because they are treated as file redirection.
59
62
 
60
63
  Inside `watch`, type the real command you want to capture, for example `python --version`. Do not type `python -m ai_memory_cli run -- ...` inside `watch`, or you will capture the nested CLI command too.
61
64
 
65
+ ## Background agent
66
+
67
+ The background agent starts at Windows logon and keeps syncing queued terminal hashes whenever the API is reachable:
68
+
69
+ ```powershell
70
+ python -m ai_memory_cli agent install
71
+ python -m ai_memory_cli agent start
72
+ python -m ai_memory_cli agent status
73
+ ```
74
+
75
+ The agent does not secretly capture every terminal on the computer. Commands are captured when they run through:
76
+
77
+ ```powershell
78
+ python -m ai_memory_cli watch
79
+ python -m ai_memory_cli run -- python --version
80
+ ```
81
+
82
+ To remove the startup task:
83
+
84
+ ```powershell
85
+ python -m ai_memory_cli agent stop
86
+ python -m ai_memory_cli agent uninstall
87
+ ```
88
+
89
+ Agent logs are written to `%USERPROFILE%\.ai-memory-cli\logs\agent.log`.
90
+
62
91
  ## Storage
63
92
 
64
93
  The CLI stores config and unsynced events in a separate folder:
@@ -68,6 +97,15 @@ The CLI stores config and unsynced events in a separate folder:
68
97
 
69
98
  Set `AI_MEMORY_CLI_HOME` to override this location.
70
99
 
100
+ Accepted command observations are also written as plain daily hash logs:
101
+
102
+ - Windows: `%USERPROFILE%\.ai-memory-cli\history\YYYY-MM-DD.log`
103
+ - macOS/Linux: `~/.ai-memory-cli/history/YYYY-MM-DD.log`
104
+
105
+ These files include time/date, event hash, command hash, output hash, source, exit code, and working-folder name. They do not store raw command text or raw output.
106
+
107
+ If a command is clearly invalid, such as a pasted prompt (`ai-memory> python --version`) or a shell "not recognized" error, the CLI skips storing it as an event.
108
+
71
109
  ## Privacy and dedupe
72
110
 
73
111
  The CLI does not send raw commands or raw output to the backend. It sends:
@@ -28,15 +28,44 @@ python -m pip install -e .
28
28
  python -m ai_memory_cli auth --token TOKEN_FROM_WEBSITE --api-url https://api.your-domain.com
29
29
  python -m ai_memory_cli init --project my-project --repo owner/repo --workspace .
30
30
  python -m ai_memory_cli workspace connect --path . --repo owner/repo --editor vscode --package-manager pip
31
- python -m ai_memory_cli watch
31
+ watch
32
32
  ```
33
33
 
34
+ `watch` is a shortcut for `python -m ai_memory_cli watch`. If Windows Device Guard blocks the generated launcher, keep using `python -m ai_memory_cli watch`.
35
+ On Windows the shortcut is installed as `watch.cmd`; the Python Scripts folder must be on `PATH` for bare `watch` to resolve.
36
+
34
37
  Use `python -m ai_memory_cli run -- COMMAND` when you only want to record one command.
35
38
 
36
39
  On Windows, `python -m ai_memory_cli ...` is the safest form because it avoids PATH issues and Device Guard policies that can block pip's generated `ai-memory.exe` launcher. Also avoid angle bracket placeholders in CMD because they are treated as file redirection.
37
40
 
38
41
  Inside `watch`, type the real command you want to capture, for example `python --version`. Do not type `python -m ai_memory_cli run -- ...` inside `watch`, or you will capture the nested CLI command too.
39
42
 
43
+ ## Background agent
44
+
45
+ The background agent starts at Windows logon and keeps syncing queued terminal hashes whenever the API is reachable:
46
+
47
+ ```powershell
48
+ python -m ai_memory_cli agent install
49
+ python -m ai_memory_cli agent start
50
+ python -m ai_memory_cli agent status
51
+ ```
52
+
53
+ The agent does not secretly capture every terminal on the computer. Commands are captured when they run through:
54
+
55
+ ```powershell
56
+ python -m ai_memory_cli watch
57
+ python -m ai_memory_cli run -- python --version
58
+ ```
59
+
60
+ To remove the startup task:
61
+
62
+ ```powershell
63
+ python -m ai_memory_cli agent stop
64
+ python -m ai_memory_cli agent uninstall
65
+ ```
66
+
67
+ Agent logs are written to `%USERPROFILE%\.ai-memory-cli\logs\agent.log`.
68
+
40
69
  ## Storage
41
70
 
42
71
  The CLI stores config and unsynced events in a separate folder:
@@ -46,6 +75,15 @@ The CLI stores config and unsynced events in a separate folder:
46
75
 
47
76
  Set `AI_MEMORY_CLI_HOME` to override this location.
48
77
 
78
+ Accepted command observations are also written as plain daily hash logs:
79
+
80
+ - Windows: `%USERPROFILE%\.ai-memory-cli\history\YYYY-MM-DD.log`
81
+ - macOS/Linux: `~/.ai-memory-cli/history/YYYY-MM-DD.log`
82
+
83
+ These files include time/date, event hash, command hash, output hash, source, exit code, and working-folder name. They do not store raw command text or raw output.
84
+
85
+ If a command is clearly invalid, such as a pasted prompt (`ai-memory> python --version`) or a shell "not recognized" error, the CLI skips storing it as an event.
86
+
49
87
  ## Privacy and dedupe
50
88
 
51
89
  The CLI does not send raw commands or raw output to the backend. It sends:
@@ -0,0 +1,7 @@
1
+ @echo off
2
+ set "AI_MEMORY_PY=%~dp0python.exe"
3
+ if exist "%AI_MEMORY_PY%" (
4
+ "%AI_MEMORY_PY%" -m ai_memory_cli watch %*
5
+ ) else (
6
+ python -m ai_memory_cli watch %*
7
+ )
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ai-memory-cli"
7
- version = "0.1.1"
7
+ version = "0.1.5"
8
8
  description = "Python CLI for AI Memory terminal capture and offline sync."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -29,5 +29,8 @@ dependencies = []
29
29
  [project.scripts]
30
30
  ai-memory = "ai_memory_cli.cli:main"
31
31
 
32
+ [tool.setuptools]
33
+ script-files = ["bin/watch.cmd"]
34
+
32
35
  [tool.setuptools.packages.find]
33
36
  where = ["src"]
@@ -1,3 +1,3 @@
1
1
  """AI Memory terminal capture CLI."""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.1.5"
@@ -18,6 +18,8 @@ from typing import Any
18
18
  from . import __version__
19
19
 
20
20
  DEFAULT_API_URL = "http://127.0.0.1:8000"
21
+ WINDOWS_AGENT_TASK_NAME = "AI Memory CLI Agent"
22
+ DEFAULT_AGENT_INTERVAL_SECONDS = 60
21
23
  DEFAULT_EXCLUDES = [
22
24
  r"^\s*npm\s+run(\s|$)",
23
25
  r"^\s*npm\s+start(\s|$)",
@@ -30,6 +32,14 @@ DEFAULT_EXCLUDES = [
30
32
  r"uvicorn\b.*\s--reload(\s|$)",
31
33
  r"python(\.exe)?\s+-m\s+uvicorn\b.*\s--reload(\s|$)",
32
34
  ]
35
+ SHELL_NOT_FOUND_PATTERNS = [
36
+ "is not recognized as an internal or external command",
37
+ "is not recognized as the name of a cmdlet",
38
+ "the system cannot find the file specified",
39
+ "the syntax of the command is incorrect",
40
+ "no such file or directory",
41
+ "command not found",
42
+ ]
33
43
 
34
44
 
35
45
  def utc_now() -> str:
@@ -50,7 +60,7 @@ def cli_home() -> Path:
50
60
 
51
61
 
52
62
  def ensure_dirs(home: Path) -> None:
53
- for folder in ["events", "outbox", "sent", "logs"]:
63
+ for folder in ["events", "outbox", "sent", "logs", "history"]:
54
64
  (home / folder).mkdir(parents=True, exist_ok=True)
55
65
 
56
66
 
@@ -70,6 +80,32 @@ def write_json(path: Path, payload: Any) -> None:
70
80
  tmp_path.replace(path)
71
81
 
72
82
 
83
+ def append_log(home: Path, message: str) -> None:
84
+ ensure_dirs(home)
85
+ log_path = home / "logs" / "agent.log"
86
+ with log_path.open("a", encoding="utf-8") as file:
87
+ file.write(f"{utc_now()} {message}\n")
88
+
89
+
90
+ def append_history(home: Path, event: dict[str, Any], state: str) -> None:
91
+ ensure_dirs(home)
92
+ timestamp = str(event.get("observed_at") or event.get("ended_at") or utc_now())
93
+ day = timestamp[:10] if len(timestamp) >= 10 else utc_now()[:10]
94
+ history_path = home / "history" / f"{day}.log"
95
+ fields = [
96
+ timestamp,
97
+ f"state={state}",
98
+ f"source={event.get('source', '-')}",
99
+ f"exit={event.get('exit_code', '-')}",
100
+ f"event={str(event.get('event_hash', ''))[:12]}",
101
+ f"command_hash={event.get('command_hash', '-')}",
102
+ f"output_hash={event.get('output_hash', '-')}",
103
+ f"cwd={event.get('metadata', {}).get('cwd_tail', '-')}",
104
+ ]
105
+ with history_path.open("a", encoding="utf-8") as file:
106
+ file.write(" ".join(fields) + "\n")
107
+
108
+
73
109
  def config_path(home: Path) -> Path:
74
110
  return home / "config.json"
75
111
 
@@ -90,10 +126,92 @@ def save_config(home: Path, config: dict[str, Any]) -> None:
90
126
  write_json(config_path(home), config)
91
127
 
92
128
 
129
+ def agent_state_path(home: Path) -> Path:
130
+ return home / "agent.json"
131
+
132
+
133
+ def scheduler_python_executable(background: bool = True) -> str:
134
+ executable = Path(sys.executable)
135
+ if os.name == "nt" and background:
136
+ pythonw = executable.with_name("pythonw.exe")
137
+ if pythonw.exists():
138
+ return str(pythonw)
139
+ return str(executable)
140
+
141
+
142
+ def windows_startup_dir() -> Path:
143
+ appdata = os.getenv("APPDATA")
144
+ if not appdata:
145
+ raise SystemExit("APPDATA is not set; cannot locate the Windows Startup folder.")
146
+ return Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup"
147
+
148
+
149
+ def windows_startup_script_path() -> Path:
150
+ return windows_startup_dir() / "AI Memory CLI Agent.vbs"
151
+
152
+
153
+ def write_windows_startup_script(interval: int, limit: int) -> Path:
154
+ startup_dir = windows_startup_dir()
155
+ startup_dir.mkdir(parents=True, exist_ok=True)
156
+ script_path = windows_startup_script_path()
157
+ python_executable = scheduler_python_executable(background=False)
158
+ command = f'"{python_executable}" -m ai_memory_cli agent run --interval {interval} --limit {limit}'
159
+ escaped_command = command.replace('"', '""')
160
+ script_path.write_text(
161
+ "\n".join(
162
+ [
163
+ "Set shell = CreateObject(\"WScript.Shell\")",
164
+ f"shell.Run \"{escaped_command}\", 0, False",
165
+ "",
166
+ ]
167
+ ),
168
+ encoding="utf-8",
169
+ )
170
+ return script_path
171
+
172
+
173
+ def start_detached_agent(interval: int, limit: int) -> int:
174
+ python_executable = scheduler_python_executable(background=True)
175
+ command = [
176
+ python_executable,
177
+ "-m",
178
+ "ai_memory_cli",
179
+ "agent",
180
+ "run",
181
+ "--interval",
182
+ str(interval),
183
+ "--limit",
184
+ str(limit),
185
+ ]
186
+ creationflags = 0
187
+ if os.name == "nt":
188
+ creationflags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
189
+ if hasattr(subprocess, "CREATE_NO_WINDOW"):
190
+ creationflags |= subprocess.CREATE_NO_WINDOW
191
+
192
+ process = subprocess.Popen(
193
+ command,
194
+ stdin=subprocess.DEVNULL,
195
+ stdout=subprocess.DEVNULL,
196
+ stderr=subprocess.DEVNULL,
197
+ close_fds=True,
198
+ creationflags=creationflags,
199
+ )
200
+ return int(process.pid)
201
+
202
+
93
203
  def normalize_command(command: str) -> str:
94
204
  return " ".join(command.strip().split())
95
205
 
96
206
 
207
+ def clean_watch_command(command: str) -> str:
208
+ cleaned = command.strip()
209
+ while cleaned.lower().startswith("ai-memory>"):
210
+ cleaned = cleaned[len("ai-memory>") :].strip()
211
+ cleaned = re.sub(r"^[A-Za-z]:\\[^>]*>\s*", "", cleaned).strip()
212
+ return cleaned
213
+
214
+
97
215
  def normalize_output(stdout: str, stderr: str) -> str:
98
216
  combined = f"stdout:\n{stdout}\nstderr:\n{stderr}"
99
217
  lines = [line.rstrip() for line in combined.replace("\r\n", "\n").replace("\r", "\n").split("\n")]
@@ -123,6 +241,13 @@ def is_excluded(command: str, config: dict[str, Any]) -> bool:
123
241
  return any(re.search(pattern, command, flags=re.IGNORECASE) for pattern in patterns)
124
242
 
125
243
 
244
+ def is_shell_not_found(stdout: str, stderr: str, exit_code: int | None) -> bool:
245
+ if exit_code in (None, 0):
246
+ return False
247
+ combined = f"{stdout}\n{stderr}".lower()
248
+ return any(pattern in combined for pattern in SHELL_NOT_FOUND_PATTERNS)
249
+
250
+
126
251
  def api_url(config: dict[str, Any]) -> str:
127
252
  return str(config.get("api_url") or DEFAULT_API_URL).rstrip("/")
128
253
 
@@ -322,6 +447,10 @@ def capture_command(home: Path, config: dict[str, Any], command: str, include_ex
322
447
  if completed.stderr:
323
448
  print(completed.stderr, end="", file=sys.stderr)
324
449
 
450
+ if is_shell_not_found(completed.stdout or "", completed.stderr or "", completed.returncode):
451
+ print("ai-memory: skipped invalid command; nothing was stored.")
452
+ return completed.returncode
453
+
325
454
  event = make_terminal_event(
326
455
  command=command,
327
456
  stdout=completed.stdout or "",
@@ -336,6 +465,7 @@ def capture_command(home: Path, config: dict[str, Any], command: str, include_ex
336
465
  )
337
466
  created, event_path = store_event(home, event)
338
467
  state = "stored" if created else "deduped"
468
+ append_history(home, event, state)
339
469
  print(f"ai-memory: {state} terminal hash {event['event_hash'][:12]} at {event_path}")
340
470
 
341
471
  try:
@@ -502,6 +632,13 @@ def command_watch(args: argparse.Namespace) -> int:
502
632
  break
503
633
  if not command:
504
634
  continue
635
+ cleaned_command = clean_watch_command(command)
636
+ if cleaned_command != command:
637
+ if not cleaned_command:
638
+ print("ai-memory: skipped pasted prompt without a command.")
639
+ continue
640
+ print(f"ai-memory: using command without pasted prompt: {cleaned_command}")
641
+ command = cleaned_command
505
642
  if command.lower() in {"exit", "quit"}:
506
643
  break
507
644
  capture_command(home, config, command, args.include_excluded, "watch")
@@ -557,6 +694,224 @@ def command_sync(args: argparse.Namespace) -> int:
557
694
  return 0
558
695
 
559
696
 
697
+ def command_agent_run(args: argparse.Namespace) -> int:
698
+ home = cli_home()
699
+ ensure_dirs(home)
700
+ interval = max(10, int(args.interval))
701
+ limit = max(1, int(args.limit))
702
+ state = {
703
+ "pid": os.getpid(),
704
+ "version": __version__,
705
+ "started_at": utc_now(),
706
+ "interval_seconds": interval,
707
+ "limit": limit,
708
+ "mode": "once" if args.once else "loop",
709
+ }
710
+ write_json(agent_state_path(home), state)
711
+ append_log(home, f"agent started pid={os.getpid()} interval={interval}s limit={limit}")
712
+
713
+ try:
714
+ while True:
715
+ config = load_config(home)
716
+ try:
717
+ synced = sync_events(home, config, limit=limit, quiet=True)
718
+ if synced:
719
+ append_log(home, f"synced {synced} terminal event(s)")
720
+ except Exception as exc:
721
+ append_log(home, f"sync failed: {exc}")
722
+
723
+ if args.once:
724
+ break
725
+ time.sleep(interval)
726
+ finally:
727
+ state["stopped_at"] = utc_now()
728
+ write_json(agent_state_path(home), state)
729
+ append_log(home, "agent stopped")
730
+
731
+ return 0
732
+
733
+
734
+ def command_agent_install(args: argparse.Namespace) -> int:
735
+ if os.name != "nt":
736
+ raise SystemExit("agent install currently supports Windows Task Scheduler only.")
737
+
738
+ home = cli_home()
739
+ config = load_config(home)
740
+ config["agent"] = {
741
+ "task_name": args.task_name,
742
+ "interval_seconds": args.interval,
743
+ "limit": args.limit,
744
+ "installed_at": utc_now(),
745
+ }
746
+ save_config(home, config)
747
+
748
+ if args.method in {"auto", "task"}:
749
+ python_executable = scheduler_python_executable(background=not args.console)
750
+ task_command = (
751
+ f'"{python_executable}" -m ai_memory_cli agent run '
752
+ f"--interval {int(args.interval)} --limit {int(args.limit)}"
753
+ )
754
+ result = subprocess.run(
755
+ [
756
+ "schtasks",
757
+ "/Create",
758
+ "/TN",
759
+ args.task_name,
760
+ "/SC",
761
+ "ONLOGON",
762
+ "/TR",
763
+ task_command,
764
+ "/F",
765
+ ],
766
+ capture_output=True,
767
+ text=True,
768
+ encoding="utf-8",
769
+ errors="replace",
770
+ )
771
+ if result.returncode == 0:
772
+ append_log(home, f"installed Windows scheduled task: {args.task_name}")
773
+ print(f"Installed startup agent task: {args.task_name}")
774
+ print("It starts when you log in. Start it now with:")
775
+ print("python -m ai_memory_cli agent start")
776
+ return 0
777
+
778
+ if args.method == "task":
779
+ raise SystemExit((result.stderr or result.stdout).strip())
780
+
781
+ print("Task Scheduler install failed; falling back to user Startup folder.")
782
+ print((result.stderr or result.stdout).strip())
783
+
784
+ script_path = write_windows_startup_script(int(args.interval), int(args.limit))
785
+ append_log(home, f"installed Windows startup script: {script_path}")
786
+ print(f"Installed startup agent script: {script_path}")
787
+ print("It starts when you log in. Start it now with:")
788
+ print("python -m ai_memory_cli agent run")
789
+ return 0
790
+
791
+
792
+ def command_agent_uninstall(args: argparse.Namespace) -> int:
793
+ if os.name != "nt":
794
+ raise SystemExit("agent uninstall currently supports Windows Task Scheduler only.")
795
+
796
+ removed = False
797
+ result = subprocess.run(
798
+ ["schtasks", "/Delete", "/TN", args.task_name, "/F"],
799
+ capture_output=True,
800
+ text=True,
801
+ encoding="utf-8",
802
+ errors="replace",
803
+ )
804
+ if result.returncode == 0:
805
+ removed = True
806
+
807
+ script_path = windows_startup_script_path()
808
+ if script_path.exists():
809
+ script_path.unlink()
810
+ removed = True
811
+
812
+ append_log(cli_home(), f"uninstalled Windows scheduled task: {args.task_name}")
813
+ if removed:
814
+ print("Removed startup agent registration.")
815
+ else:
816
+ print("No startup agent registration was found.")
817
+ return 0
818
+
819
+
820
+ def command_agent_start(args: argparse.Namespace) -> int:
821
+ if os.name != "nt":
822
+ raise SystemExit("agent start currently supports Windows Task Scheduler only.")
823
+
824
+ home = cli_home()
825
+ config = load_config(home)
826
+ result = subprocess.run(
827
+ ["schtasks", "/Run", "/TN", args.task_name],
828
+ capture_output=True,
829
+ text=True,
830
+ encoding="utf-8",
831
+ errors="replace",
832
+ )
833
+ if result.returncode == 0:
834
+ print(f"Started agent task: {args.task_name}")
835
+ return 0
836
+
837
+ agent_config = config.get("agent") if isinstance(config.get("agent"), dict) else {}
838
+ interval = int(agent_config.get("interval_seconds") or DEFAULT_AGENT_INTERVAL_SECONDS)
839
+ limit = int(agent_config.get("limit") or 50)
840
+ pid = start_detached_agent(interval, limit)
841
+ append_log(home, f"started detached agent pid={pid}")
842
+ print(f"Started detached agent process: pid={pid}")
843
+ return 0
844
+
845
+
846
+ def command_agent_stop(args: argparse.Namespace) -> int:
847
+ if os.name != "nt":
848
+ raise SystemExit("agent stop currently supports Windows Task Scheduler only.")
849
+
850
+ home = cli_home()
851
+ result = subprocess.run(
852
+ ["schtasks", "/End", "/TN", args.task_name],
853
+ capture_output=True,
854
+ text=True,
855
+ encoding="utf-8",
856
+ errors="replace",
857
+ )
858
+ if result.returncode == 0:
859
+ append_log(home, f"stopped Windows scheduled task: {args.task_name}")
860
+ print(f"Stopped agent task: {args.task_name}")
861
+ return 0
862
+
863
+ state = read_json(agent_state_path(home), {})
864
+ pid = state.get("pid")
865
+ if not pid:
866
+ print("No running detached agent pid was found.")
867
+ return 0
868
+
869
+ kill = subprocess.run(
870
+ ["taskkill", "/PID", str(pid), "/F"],
871
+ capture_output=True,
872
+ text=True,
873
+ encoding="utf-8",
874
+ errors="replace",
875
+ )
876
+ if kill.returncode != 0:
877
+ raise SystemExit((kill.stderr or kill.stdout).strip())
878
+ append_log(home, f"stopped detached agent pid={pid}")
879
+ print(f"Stopped detached agent process: pid={pid}")
880
+ return 0
881
+
882
+
883
+ def command_agent_status(args: argparse.Namespace) -> int:
884
+ home = cli_home()
885
+ config = load_config(home)
886
+ state = read_json(agent_state_path(home), {})
887
+ print(f"Storage: {home}")
888
+ print(f"API: {api_url(config)}")
889
+ print(f"Token: {'saved' if config.get('token') else 'missing'}")
890
+ if state:
891
+ print(f"Agent state: pid={state.get('pid', '-')} started={state.get('started_at', '-')}")
892
+ else:
893
+ print("Agent state: no local agent state file yet")
894
+
895
+ if os.name != "nt":
896
+ return 0
897
+
898
+ result = subprocess.run(
899
+ ["schtasks", "/Query", "/TN", args.task_name, "/FO", "LIST", "/V"],
900
+ capture_output=True,
901
+ text=True,
902
+ encoding="utf-8",
903
+ errors="replace",
904
+ )
905
+ if result.returncode != 0:
906
+ print(f"Windows task: not installed ({args.task_name})")
907
+ else:
908
+ print(result.stdout.strip())
909
+
910
+ script_path = windows_startup_script_path()
911
+ print(f"Startup script: {'installed' if script_path.exists() else 'not installed'} ({script_path})")
912
+ return 0
913
+
914
+
560
915
  def command_status(_: argparse.Namespace) -> int:
561
916
  home = cli_home()
562
917
  config = load_config(home)
@@ -638,6 +993,7 @@ def build_parser() -> argparse.ArgumentParser:
638
993
 
639
994
  watch = subparsers.add_parser("watch", help="Start a managed terminal that captures commands and output.")
640
995
  watch.add_argument("--include-excluded", action="store_true")
996
+ watch.add_argument("--version", action="version", version=f"ai-memory {__version__}")
641
997
  watch.set_defaults(func=command_watch)
642
998
 
643
999
  history = subparsers.add_parser("history", help="History import commands.")
@@ -652,6 +1008,39 @@ def build_parser() -> argparse.ArgumentParser:
652
1008
  sync.add_argument("--limit", type=int, default=50)
653
1009
  sync.set_defaults(func=command_sync)
654
1010
 
1011
+ agent = subparsers.add_parser("agent", help="Background sync agent commands.")
1012
+ agent_subparsers = agent.add_subparsers(dest="agent_command", required=True)
1013
+
1014
+ agent_run = agent_subparsers.add_parser("run", help="Run the background sync loop.")
1015
+ agent_run.add_argument("--interval", type=int, default=DEFAULT_AGENT_INTERVAL_SECONDS)
1016
+ agent_run.add_argument("--limit", type=int, default=50)
1017
+ agent_run.add_argument("--once", action="store_true")
1018
+ agent_run.set_defaults(func=command_agent_run)
1019
+
1020
+ agent_install = agent_subparsers.add_parser("install", help="Install Windows startup task for the sync agent.")
1021
+ agent_install.add_argument("--interval", type=int, default=DEFAULT_AGENT_INTERVAL_SECONDS)
1022
+ agent_install.add_argument("--limit", type=int, default=50)
1023
+ agent_install.add_argument("--task-name", default=WINDOWS_AGENT_TASK_NAME)
1024
+ agent_install.add_argument("--method", choices=["auto", "task", "startup"], default="auto")
1025
+ agent_install.add_argument("--console", action="store_true", help="Use python.exe instead of pythonw.exe for the scheduled task.")
1026
+ agent_install.set_defaults(func=command_agent_install)
1027
+
1028
+ agent_uninstall = agent_subparsers.add_parser("uninstall", help="Remove Windows startup task for the sync agent.")
1029
+ agent_uninstall.add_argument("--task-name", default=WINDOWS_AGENT_TASK_NAME)
1030
+ agent_uninstall.set_defaults(func=command_agent_uninstall)
1031
+
1032
+ agent_start = agent_subparsers.add_parser("start", help="Start the installed Windows agent task now.")
1033
+ agent_start.add_argument("--task-name", default=WINDOWS_AGENT_TASK_NAME)
1034
+ agent_start.set_defaults(func=command_agent_start)
1035
+
1036
+ agent_stop = agent_subparsers.add_parser("stop", help="Stop the installed Windows agent task.")
1037
+ agent_stop.add_argument("--task-name", default=WINDOWS_AGENT_TASK_NAME)
1038
+ agent_stop.set_defaults(func=command_agent_stop)
1039
+
1040
+ agent_status = agent_subparsers.add_parser("status", help="Show background agent and Windows task state.")
1041
+ agent_status.add_argument("--task-name", default=WINDOWS_AGENT_TASK_NAME)
1042
+ agent_status.set_defaults(func=command_agent_status)
1043
+
655
1044
  status = subparsers.add_parser("status", help="Show local CLI state.")
656
1045
  status.set_defaults(func=command_status)
657
1046
 
@@ -669,3 +1058,15 @@ def main(argv: list[str] | None = None) -> int:
669
1058
  except KeyboardInterrupt:
670
1059
  print("Interrupted.", file=sys.stderr)
671
1060
  return 130
1061
+
1062
+
1063
+ def watch_main(argv: list[str] | None = None) -> int:
1064
+ parser = argparse.ArgumentParser(prog="watch", description="Start AI Memory terminal capture.")
1065
+ parser.add_argument("--include-excluded", action="store_true")
1066
+ parser.add_argument("--version", action="version", version=f"ai-memory {__version__}")
1067
+ args = parser.parse_args(argv)
1068
+ try:
1069
+ return command_watch(args)
1070
+ except KeyboardInterrupt:
1071
+ print("Interrupted.", file=sys.stderr)
1072
+ return 130
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-memory-cli
3
- Version: 0.1.1
3
+ Version: 0.1.5
4
4
  Summary: Python CLI for AI Memory terminal capture and offline sync.
5
5
  Author: AI Memory
6
6
  License-Expression: MIT
@@ -50,15 +50,44 @@ python -m pip install -e .
50
50
  python -m ai_memory_cli auth --token TOKEN_FROM_WEBSITE --api-url https://api.your-domain.com
51
51
  python -m ai_memory_cli init --project my-project --repo owner/repo --workspace .
52
52
  python -m ai_memory_cli workspace connect --path . --repo owner/repo --editor vscode --package-manager pip
53
- python -m ai_memory_cli watch
53
+ watch
54
54
  ```
55
55
 
56
+ `watch` is a shortcut for `python -m ai_memory_cli watch`. If Windows Device Guard blocks the generated launcher, keep using `python -m ai_memory_cli watch`.
57
+ On Windows the shortcut is installed as `watch.cmd`; the Python Scripts folder must be on `PATH` for bare `watch` to resolve.
58
+
56
59
  Use `python -m ai_memory_cli run -- COMMAND` when you only want to record one command.
57
60
 
58
61
  On Windows, `python -m ai_memory_cli ...` is the safest form because it avoids PATH issues and Device Guard policies that can block pip's generated `ai-memory.exe` launcher. Also avoid angle bracket placeholders in CMD because they are treated as file redirection.
59
62
 
60
63
  Inside `watch`, type the real command you want to capture, for example `python --version`. Do not type `python -m ai_memory_cli run -- ...` inside `watch`, or you will capture the nested CLI command too.
61
64
 
65
+ ## Background agent
66
+
67
+ The background agent starts at Windows logon and keeps syncing queued terminal hashes whenever the API is reachable:
68
+
69
+ ```powershell
70
+ python -m ai_memory_cli agent install
71
+ python -m ai_memory_cli agent start
72
+ python -m ai_memory_cli agent status
73
+ ```
74
+
75
+ The agent does not secretly capture every terminal on the computer. Commands are captured when they run through:
76
+
77
+ ```powershell
78
+ python -m ai_memory_cli watch
79
+ python -m ai_memory_cli run -- python --version
80
+ ```
81
+
82
+ To remove the startup task:
83
+
84
+ ```powershell
85
+ python -m ai_memory_cli agent stop
86
+ python -m ai_memory_cli agent uninstall
87
+ ```
88
+
89
+ Agent logs are written to `%USERPROFILE%\.ai-memory-cli\logs\agent.log`.
90
+
62
91
  ## Storage
63
92
 
64
93
  The CLI stores config and unsynced events in a separate folder:
@@ -68,6 +97,15 @@ The CLI stores config and unsynced events in a separate folder:
68
97
 
69
98
  Set `AI_MEMORY_CLI_HOME` to override this location.
70
99
 
100
+ Accepted command observations are also written as plain daily hash logs:
101
+
102
+ - Windows: `%USERPROFILE%\.ai-memory-cli\history\YYYY-MM-DD.log`
103
+ - macOS/Linux: `~/.ai-memory-cli/history/YYYY-MM-DD.log`
104
+
105
+ These files include time/date, event hash, command hash, output hash, source, exit code, and working-folder name. They do not store raw command text or raw output.
106
+
107
+ If a command is clearly invalid, such as a pasted prompt (`ai-memory> python --version`) or a shell "not recognized" error, the CLI skips storing it as an event.
108
+
71
109
  ## Privacy and dedupe
72
110
 
73
111
  The CLI does not send raw commands or raw output to the backend. It sends:
@@ -1,6 +1,7 @@
1
1
  LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
+ bin/watch.cmd
4
5
  src/ai_memory_cli/__init__.py
5
6
  src/ai_memory_cli/__main__.py
6
7
  src/ai_memory_cli/cli.py
File without changes
File without changes