ai-memory-cli 0.1.1__tar.gz → 0.1.6__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.6
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,49 @@ 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
+ The `auth` command verifies the website-issued token with FastAPI before it is saved locally. After verification,
57
+ the CLI stores a SHA-512 user hash for this computer, binds the token to that hash on the server, and starts the
58
+ background sync agent once on Windows.
59
+
60
+ `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`.
61
+ On Windows the shortcut is installed as `watch.cmd`; the Python Scripts folder must be on `PATH` for bare `watch` to resolve.
62
+
56
63
  Use `python -m ai_memory_cli run -- COMMAND` when you only want to record one command.
57
64
 
58
65
  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
66
 
60
67
  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
68
 
69
+ ## Background agent
70
+
71
+ After `auth`, the background agent starts once and is installed in the Windows Startup folder so queued terminal
72
+ hashes keep syncing whenever the API is reachable. Use these commands when you need manual control:
73
+
74
+ ```powershell
75
+ python -m ai_memory_cli agent status
76
+ python -m ai_memory_cli agent stop
77
+ python -m ai_memory_cli agent start
78
+ ```
79
+
80
+ The agent does not secretly capture every terminal on the computer. Commands are captured when they run through:
81
+
82
+ ```powershell
83
+ python -m ai_memory_cli watch
84
+ python -m ai_memory_cli run -- python --version
85
+ ```
86
+
87
+ To remove the startup task:
88
+
89
+ ```powershell
90
+ python -m ai_memory_cli agent stop
91
+ python -m ai_memory_cli agent uninstall
92
+ ```
93
+
94
+ Agent logs are written to `%USERPROFILE%\.ai-memory-cli\logs\agent.log`.
95
+
62
96
  ## Storage
63
97
 
64
98
  The CLI stores config and unsynced events in a separate folder:
@@ -68,6 +102,15 @@ The CLI stores config and unsynced events in a separate folder:
68
102
 
69
103
  Set `AI_MEMORY_CLI_HOME` to override this location.
70
104
 
105
+ Accepted command observations are also written as plain daily hash logs:
106
+
107
+ - Windows: `%USERPROFILE%\.ai-memory-cli\history\YYYY-MM-DD.log`
108
+ - macOS/Linux: `~/.ai-memory-cli/history/YYYY-MM-DD.log`
109
+
110
+ 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.
111
+
112
+ 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.
113
+
71
114
  ## Privacy and dedupe
72
115
 
73
116
  The CLI does not send raw commands or raw output to the backend. It sends:
@@ -28,15 +28,49 @@ 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
+ The `auth` command verifies the website-issued token with FastAPI before it is saved locally. After verification,
35
+ the CLI stores a SHA-512 user hash for this computer, binds the token to that hash on the server, and starts the
36
+ background sync agent once on Windows.
37
+
38
+ `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`.
39
+ On Windows the shortcut is installed as `watch.cmd`; the Python Scripts folder must be on `PATH` for bare `watch` to resolve.
40
+
34
41
  Use `python -m ai_memory_cli run -- COMMAND` when you only want to record one command.
35
42
 
36
43
  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
44
 
38
45
  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
46
 
47
+ ## Background agent
48
+
49
+ After `auth`, the background agent starts once and is installed in the Windows Startup folder so queued terminal
50
+ hashes keep syncing whenever the API is reachable. Use these commands when you need manual control:
51
+
52
+ ```powershell
53
+ python -m ai_memory_cli agent status
54
+ python -m ai_memory_cli agent stop
55
+ python -m ai_memory_cli agent start
56
+ ```
57
+
58
+ The agent does not secretly capture every terminal on the computer. Commands are captured when they run through:
59
+
60
+ ```powershell
61
+ python -m ai_memory_cli watch
62
+ python -m ai_memory_cli run -- python --version
63
+ ```
64
+
65
+ To remove the startup task:
66
+
67
+ ```powershell
68
+ python -m ai_memory_cli agent stop
69
+ python -m ai_memory_cli agent uninstall
70
+ ```
71
+
72
+ Agent logs are written to `%USERPROFILE%\.ai-memory-cli\logs\agent.log`.
73
+
40
74
  ## Storage
41
75
 
42
76
  The CLI stores config and unsynced events in a separate folder:
@@ -46,6 +80,15 @@ The CLI stores config and unsynced events in a separate folder:
46
80
 
47
81
  Set `AI_MEMORY_CLI_HOME` to override this location.
48
82
 
83
+ Accepted command observations are also written as plain daily hash logs:
84
+
85
+ - Windows: `%USERPROFILE%\.ai-memory-cli\history\YYYY-MM-DD.log`
86
+ - macOS/Linux: `~/.ai-memory-cli/history/YYYY-MM-DD.log`
87
+
88
+ 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.
89
+
90
+ 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.
91
+
49
92
  ## Privacy and dedupe
50
93
 
51
94
  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.6"
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.6"
@@ -1,10 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import getpass
4
5
  import json
5
6
  import os
6
7
  import platform
7
8
  import re
9
+ import secrets
8
10
  import shutil
9
11
  import subprocess
10
12
  import sys
@@ -18,6 +20,8 @@ from typing import Any
18
20
  from . import __version__
19
21
 
20
22
  DEFAULT_API_URL = "http://127.0.0.1:8000"
23
+ WINDOWS_AGENT_TASK_NAME = "AI Memory CLI Agent"
24
+ DEFAULT_AGENT_INTERVAL_SECONDS = 60
21
25
  DEFAULT_EXCLUDES = [
22
26
  r"^\s*npm\s+run(\s|$)",
23
27
  r"^\s*npm\s+start(\s|$)",
@@ -30,6 +34,14 @@ DEFAULT_EXCLUDES = [
30
34
  r"uvicorn\b.*\s--reload(\s|$)",
31
35
  r"python(\.exe)?\s+-m\s+uvicorn\b.*\s--reload(\s|$)",
32
36
  ]
37
+ SHELL_NOT_FOUND_PATTERNS = [
38
+ "is not recognized as an internal or external command",
39
+ "is not recognized as the name of a cmdlet",
40
+ "the system cannot find the file specified",
41
+ "the syntax of the command is incorrect",
42
+ "no such file or directory",
43
+ "command not found",
44
+ ]
33
45
 
34
46
 
35
47
  def utc_now() -> str:
@@ -42,6 +54,12 @@ def sha256_text(value: str) -> str:
42
54
  return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()
43
55
 
44
56
 
57
+ def sha512_text(value: str) -> str:
58
+ import hashlib
59
+
60
+ return hashlib.sha512(value.encode("utf-8", errors="replace")).hexdigest()
61
+
62
+
45
63
  def cli_home() -> Path:
46
64
  configured = os.getenv("AI_MEMORY_CLI_HOME")
47
65
  if configured:
@@ -50,7 +68,7 @@ def cli_home() -> Path:
50
68
 
51
69
 
52
70
  def ensure_dirs(home: Path) -> None:
53
- for folder in ["events", "outbox", "sent", "logs"]:
71
+ for folder in ["events", "outbox", "sent", "logs", "history"]:
54
72
  (home / folder).mkdir(parents=True, exist_ok=True)
55
73
 
56
74
 
@@ -70,6 +88,32 @@ def write_json(path: Path, payload: Any) -> None:
70
88
  tmp_path.replace(path)
71
89
 
72
90
 
91
+ def append_log(home: Path, message: str) -> None:
92
+ ensure_dirs(home)
93
+ log_path = home / "logs" / "agent.log"
94
+ with log_path.open("a", encoding="utf-8") as file:
95
+ file.write(f"{utc_now()} {message}\n")
96
+
97
+
98
+ def append_history(home: Path, event: dict[str, Any], state: str) -> None:
99
+ ensure_dirs(home)
100
+ timestamp = str(event.get("observed_at") or event.get("ended_at") or utc_now())
101
+ day = timestamp[:10] if len(timestamp) >= 10 else utc_now()[:10]
102
+ history_path = home / "history" / f"{day}.log"
103
+ fields = [
104
+ timestamp,
105
+ f"state={state}",
106
+ f"source={event.get('source', '-')}",
107
+ f"exit={event.get('exit_code', '-')}",
108
+ f"event={str(event.get('event_hash', ''))[:12]}",
109
+ f"command_hash={event.get('command_hash', '-')}",
110
+ f"output_hash={event.get('output_hash', '-')}",
111
+ f"cwd={event.get('metadata', {}).get('cwd_tail', '-')}",
112
+ ]
113
+ with history_path.open("a", encoding="utf-8") as file:
114
+ file.write(" ".join(fields) + "\n")
115
+
116
+
73
117
  def config_path(home: Path) -> Path:
74
118
  return home / "config.json"
75
119
 
@@ -90,10 +134,189 @@ def save_config(home: Path, config: dict[str, Any]) -> None:
90
134
  write_json(config_path(home), config)
91
135
 
92
136
 
137
+ def ensure_local_identity(home: Path, config: dict[str, Any]) -> str:
138
+ secret = str(config.get("local_identity_secret") or "").strip()
139
+ if not secret:
140
+ secret = secrets.token_urlsafe(96)
141
+ config["local_identity_secret"] = secret
142
+ config["local_identity_created_at"] = utc_now()
143
+
144
+ local_user_hash = sha512_text(
145
+ "\0".join(
146
+ [
147
+ secret,
148
+ str(home),
149
+ platform.node() or "unknown-host",
150
+ getpass.getuser() or "unknown-user",
151
+ platform.system(),
152
+ ]
153
+ )
154
+ )
155
+ config["local_user_hash"] = local_user_hash
156
+ return local_user_hash
157
+
158
+
159
+ def client_identity(home: Path, config: dict[str, Any]) -> dict[str, Any]:
160
+ local_user_hash = ensure_local_identity(home, config)
161
+ return {
162
+ "name": "ai-memory-cli",
163
+ "version": __version__,
164
+ "local_user_hash": local_user_hash,
165
+ "user_hash": config.get("user_hash") or "",
166
+ "github_user": config.get("github_user") or "",
167
+ "session_id": config.get("session_id") or "",
168
+ "storage_home_hash": sha256_text(str(home)),
169
+ "hostname_hash": sha256_text(platform.node() or "unknown"),
170
+ "username_hash": sha256_text(getpass.getuser() or "unknown"),
171
+ "platform": platform.system(),
172
+ "python": platform.python_version(),
173
+ "auth_verified_at": config.get("auth_verified_at") or "",
174
+ }
175
+
176
+
177
+ def agent_state_path(home: Path) -> Path:
178
+ return home / "agent.json"
179
+
180
+
181
+ def scheduler_python_executable(background: bool = True) -> str:
182
+ executable = Path(sys.executable)
183
+ if os.name == "nt" and background:
184
+ pythonw = executable.with_name("pythonw.exe")
185
+ if pythonw.exists():
186
+ return str(pythonw)
187
+ return str(executable)
188
+
189
+
190
+ def windows_startup_dir() -> Path:
191
+ appdata = os.getenv("APPDATA")
192
+ if not appdata:
193
+ raise SystemExit("APPDATA is not set; cannot locate the Windows Startup folder.")
194
+ return Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup"
195
+
196
+
197
+ def windows_startup_script_path() -> Path:
198
+ return windows_startup_dir() / "AI Memory CLI Agent.vbs"
199
+
200
+
201
+ def write_windows_startup_script(interval: int, limit: int) -> Path:
202
+ startup_dir = windows_startup_dir()
203
+ startup_dir.mkdir(parents=True, exist_ok=True)
204
+ script_path = windows_startup_script_path()
205
+ python_executable = scheduler_python_executable(background=False)
206
+ command = f'"{python_executable}" -m ai_memory_cli agent run --interval {interval} --limit {limit}'
207
+ escaped_command = command.replace('"', '""')
208
+ script_path.write_text(
209
+ "\n".join(
210
+ [
211
+ "Set shell = CreateObject(\"WScript.Shell\")",
212
+ f"shell.Run \"{escaped_command}\", 0, False",
213
+ "",
214
+ ]
215
+ ),
216
+ encoding="utf-8",
217
+ )
218
+ return script_path
219
+
220
+
221
+ def start_detached_agent(interval: int, limit: int) -> int:
222
+ python_executable = scheduler_python_executable(background=True)
223
+ command = [
224
+ python_executable,
225
+ "-m",
226
+ "ai_memory_cli",
227
+ "agent",
228
+ "run",
229
+ "--interval",
230
+ str(interval),
231
+ "--limit",
232
+ str(limit),
233
+ ]
234
+ creationflags = 0
235
+ if os.name == "nt":
236
+ creationflags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
237
+ if hasattr(subprocess, "CREATE_NO_WINDOW"):
238
+ creationflags |= subprocess.CREATE_NO_WINDOW
239
+
240
+ process = subprocess.Popen(
241
+ command,
242
+ stdin=subprocess.DEVNULL,
243
+ stdout=subprocess.DEVNULL,
244
+ stderr=subprocess.DEVNULL,
245
+ close_fds=True,
246
+ creationflags=creationflags,
247
+ )
248
+ return int(process.pid)
249
+
250
+
251
+ def is_process_running(pid: Any) -> bool:
252
+ try:
253
+ pid_int = int(pid)
254
+ except (TypeError, ValueError):
255
+ return False
256
+ if pid_int <= 0:
257
+ return False
258
+
259
+ if os.name == "nt":
260
+ result = subprocess.run(
261
+ ["tasklist", "/FI", f"PID eq {pid_int}", "/FO", "CSV", "/NH"],
262
+ capture_output=True,
263
+ text=True,
264
+ encoding="utf-8",
265
+ errors="replace",
266
+ )
267
+ return result.returncode == 0 and str(pid_int) in result.stdout
268
+
269
+ try:
270
+ os.kill(pid_int, 0)
271
+ return True
272
+ except OSError:
273
+ return False
274
+
275
+
276
+ def ensure_agent_started_once(home: Path, config: dict[str, Any]) -> None:
277
+ agent_config = config.get("agent") if isinstance(config.get("agent"), dict) else {}
278
+ interval = int(agent_config.get("interval_seconds") or DEFAULT_AGENT_INTERVAL_SECONDS)
279
+ limit = int(agent_config.get("limit") or 50)
280
+ state = read_json(agent_state_path(home), {})
281
+
282
+ if is_process_running(state.get("pid")):
283
+ print(f"AI Memory sync agent already running: pid={state.get('pid')}")
284
+ return
285
+
286
+ if os.name == "nt":
287
+ script_path = write_windows_startup_script(interval, limit)
288
+ pid = start_detached_agent(interval, limit)
289
+ config["agent"] = {
290
+ **agent_config,
291
+ "method": "startup",
292
+ "interval_seconds": interval,
293
+ "limit": limit,
294
+ "startup_script": str(script_path),
295
+ "last_started_pid": pid,
296
+ "last_started_at": utc_now(),
297
+ }
298
+ save_config(home, config)
299
+ append_log(home, f"auth started detached agent pid={pid}")
300
+ print(f"AI Memory sync agent started once: pid={pid}")
301
+ print(f"Startup sync installed at: {script_path}")
302
+ return
303
+
304
+ print("Automatic startup agent install is only implemented for Windows.")
305
+ print("Start sync manually with: python -m ai_memory_cli agent run")
306
+
307
+
93
308
  def normalize_command(command: str) -> str:
94
309
  return " ".join(command.strip().split())
95
310
 
96
311
 
312
+ def clean_watch_command(command: str) -> str:
313
+ cleaned = command.strip()
314
+ while cleaned.lower().startswith("ai-memory>"):
315
+ cleaned = cleaned[len("ai-memory>") :].strip()
316
+ cleaned = re.sub(r"^[A-Za-z]:\\[^>]*>\s*", "", cleaned).strip()
317
+ return cleaned
318
+
319
+
97
320
  def normalize_output(stdout: str, stderr: str) -> str:
98
321
  combined = f"stdout:\n{stdout}\nstderr:\n{stderr}"
99
322
  lines = [line.rstrip() for line in combined.replace("\r\n", "\n").replace("\r", "\n").split("\n")]
@@ -123,6 +346,13 @@ def is_excluded(command: str, config: dict[str, Any]) -> bool:
123
346
  return any(re.search(pattern, command, flags=re.IGNORECASE) for pattern in patterns)
124
347
 
125
348
 
349
+ def is_shell_not_found(stdout: str, stderr: str, exit_code: int | None) -> bool:
350
+ if exit_code in (None, 0):
351
+ return False
352
+ combined = f"{stdout}\n{stderr}".lower()
353
+ return any(pattern in combined for pattern in SHELL_NOT_FOUND_PATTERNS)
354
+
355
+
126
356
  def api_url(config: dict[str, Any]) -> str:
127
357
  return str(config.get("api_url") or DEFAULT_API_URL).rstrip("/")
128
358
 
@@ -158,6 +388,58 @@ def http_json(
158
388
  return json.loads(response_body)
159
389
 
160
390
 
391
+ def describe_http_error(exc: urllib.error.HTTPError) -> str:
392
+ try:
393
+ body = exc.read().decode("utf-8", errors="replace")
394
+ payload = json.loads(body) if body else {}
395
+ detail = payload.get("detail") if isinstance(payload, dict) else None
396
+ if detail:
397
+ return str(detail)
398
+ if body:
399
+ return body[:500]
400
+ except Exception:
401
+ pass
402
+ return f"HTTP {exc.code} {exc.reason}"
403
+
404
+
405
+ def verify_cli_auth(home: Path, config: dict[str, Any], token: str) -> dict[str, Any]:
406
+ identity = client_identity(home, config)
407
+ response = http_json(
408
+ "POST",
409
+ f"{api_url(config)}/cli/auth/verify",
410
+ {"client": identity},
411
+ token,
412
+ )
413
+ if not response.get("verified"):
414
+ raise RuntimeError("CLI token was not verified by the backend.")
415
+
416
+ config["token"] = token
417
+ config["token_hash"] = sha256_text(token)
418
+ config["token_tail"] = response.get("token_tail") or ""
419
+ config["session_id"] = response.get("session_id") or ""
420
+ config["github_user"] = response.get("github_user") or response.get("github_account_name") or ""
421
+ config["user_hash"] = response.get("user_hash") or ""
422
+ config["bound_local_user_hash"] = response.get("bound_local_user_hash") or identity["local_user_hash"]
423
+ config["auth_verified_at"] = response.get("verified_at") or utc_now()
424
+ config["server_account_storage_dir"] = response.get("account_storage_dir") or ""
425
+ return response
426
+
427
+
428
+ def require_verified_auth(home: Path, config: dict[str, Any]) -> str:
429
+ token = require_token(config)
430
+ if config.get("auth_verified_at") and config.get("user_hash") and config.get("local_user_hash"):
431
+ return token
432
+
433
+ try:
434
+ verify_cli_auth(home, config, token)
435
+ save_config(home, config)
436
+ return token
437
+ except urllib.error.HTTPError as exc:
438
+ raise SystemExit(f"CLI auth is not verified: {describe_http_error(exc)}") from exc
439
+ except Exception as exc:
440
+ raise SystemExit(f"CLI auth is not verified. Run website auth again when the local server is available: {exc}") from exc
441
+
442
+
161
443
  def make_terminal_event(
162
444
  command: str,
163
445
  stdout: str,
@@ -263,6 +545,14 @@ def sync_events(home: Path, config: dict[str, Any], limit: int = 50, quiet: bool
263
545
  if not quiet:
264
546
  print("No CLI token saved. Events remain queued until python -m ai_memory_cli auth is configured.")
265
547
  return 0
548
+ if not (config.get("auth_verified_at") and config.get("user_hash")):
549
+ try:
550
+ verify_cli_auth(home, config, token)
551
+ save_config(home, config)
552
+ except Exception as exc:
553
+ if not quiet:
554
+ print(f"CLI auth is not verified yet. Events remain queued: {exc}")
555
+ return 0
266
556
  paths = sorted((home / "outbox").glob("*.json"))[:limit]
267
557
  if not paths:
268
558
  if not quiet:
@@ -276,12 +566,7 @@ def sync_events(home: Path, config: dict[str, Any], limit: int = 50, quiet: bool
276
566
 
277
567
  payload = {
278
568
  "events": events,
279
- "client": {
280
- "name": "ai-memory-cli",
281
- "version": __version__,
282
- "storage_home_hash": sha256_text(str(home)),
283
- "hostname_hash": sha256_text(platform.node() or "unknown"),
284
- },
569
+ "client": client_identity(home, config),
285
570
  }
286
571
  response = http_json("POST", f"{api_url(config)}/cli/events/terminal", payload, token)
287
572
  accepted = {item.get("event_hash") for item in response.get("events", []) if item.get("event_hash")}
@@ -296,6 +581,7 @@ def sync_events(home: Path, config: dict[str, Any], limit: int = 50, quiet: bool
296
581
 
297
582
 
298
583
  def capture_command(home: Path, config: dict[str, Any], command: str, include_excluded: bool, source: str) -> int:
584
+ require_verified_auth(home, config)
299
585
  workspace = Path(str(config.get("workspace_path") or ".")).expanduser()
300
586
  cwd = workspace if workspace.exists() else Path.cwd()
301
587
 
@@ -322,6 +608,10 @@ def capture_command(home: Path, config: dict[str, Any], command: str, include_ex
322
608
  if completed.stderr:
323
609
  print(completed.stderr, end="", file=sys.stderr)
324
610
 
611
+ if is_shell_not_found(completed.stdout or "", completed.stderr or "", completed.returncode):
612
+ print("ai-memory: skipped invalid command; nothing was stored.")
613
+ return completed.returncode
614
+
325
615
  event = make_terminal_event(
326
616
  command=command,
327
617
  stdout=completed.stdout or "",
@@ -336,6 +626,7 @@ def capture_command(home: Path, config: dict[str, Any], command: str, include_ex
336
626
  )
337
627
  created, event_path = store_event(home, event)
338
628
  state = "stored" if created else "deduped"
629
+ append_history(home, event, state)
339
630
  print(f"ai-memory: {state} terminal hash {event['event_hash'][:12]} at {event_path}")
340
631
 
341
632
  try:
@@ -368,17 +659,33 @@ def command_auth(args: argparse.Namespace) -> int:
368
659
  config = load_config(home)
369
660
  if args.api_url:
370
661
  config["api_url"] = args.api_url.rstrip("/")
371
- config["token"] = args.token.strip()
372
- config["token_hash"] = sha256_text(args.token.strip())
662
+
663
+ token = args.token.strip()
664
+ config["pending_token_hash"] = sha256_text(token)
665
+ try:
666
+ response = verify_cli_auth(home, config, token)
667
+ except urllib.error.HTTPError as exc:
668
+ save_config(home, config)
669
+ raise SystemExit(f"CLI auth failed: {describe_http_error(exc)}") from exc
670
+ except Exception as exc:
671
+ save_config(home, config)
672
+ raise SystemExit(f"CLI auth failed. Keep the local FastAPI server running and generate a fresh website token: {exc}") from exc
673
+
373
674
  config["authed_at"] = utc_now()
374
675
  save_config(home, config)
375
676
  print(f"Saved CLI auth in {config_path(home)}")
677
+ print(f"GitHub account: {response.get('github_user') or config.get('github_user') or '-'}")
678
+ print(f"Local user hash: {str(config.get('user_hash') or '')[:24]}...")
679
+ if response.get("account_storage_dir"):
680
+ print(f"Server account storage: {response['account_storage_dir']}")
681
+
682
+ if not args.no_agent:
683
+ ensure_agent_started_once(home, config)
376
684
 
377
685
  try:
378
- health = http_json("GET", f"{api_url(config)}/health", None, None)
379
- print(f"Connected to API: {health.get('service', api_url(config))}")
686
+ sync_events(home, config, quiet=True)
380
687
  except Exception as exc:
381
- print(f"Auth saved. API check failed, sync will retry later: {exc}", file=sys.stderr)
688
+ print(f"Auth saved. Sync will retry later: {exc}", file=sys.stderr)
382
689
  return 0
383
690
 
384
691
 
@@ -395,7 +702,7 @@ def command_init(args: argparse.Namespace) -> int:
395
702
  config["workspace_path"] = args.workspace
396
703
  save_config(home, config)
397
704
 
398
- token = require_token(config)
705
+ token = require_verified_auth(home, config)
399
706
  payload = {
400
707
  "project": config.get("project") or "memory-project",
401
708
  "repository": config.get("repository") or "",
@@ -408,7 +715,7 @@ def command_init(args: argparse.Namespace) -> int:
408
715
  print(f"Initialized project: {project.get('id', payload['project'])}")
409
716
  except Exception as exc:
410
717
  print(f"Project config saved locally. Server init will need retry: {exc}", file=sys.stderr)
411
- print("Start terminal capture with: python -m ai_memory_cli watch")
718
+ print("Start terminal capture with: watch")
412
719
  return 0
413
720
 
414
721
 
@@ -421,7 +728,8 @@ def command_workspace_connect(args: argparse.Namespace) -> int:
421
728
  config["repository"] = args.repo
422
729
  save_config(home, config)
423
730
 
424
- token = require_token(config)
731
+ token = require_verified_auth(home, config)
732
+ identity = client_identity(home, config)
425
733
  payload = {
426
734
  "payload": {
427
735
  "source": "ai-memory-cli",
@@ -431,6 +739,9 @@ def command_workspace_connect(args: argparse.Namespace) -> int:
431
739
  "editor": args.editor,
432
740
  "package_manager": args.package_manager,
433
741
  "cli_storage_home_hash": sha256_text(str(home)),
742
+ "local_user_hash": identity["local_user_hash"],
743
+ "user_hash": identity["user_hash"],
744
+ "github_user": identity["github_user"],
434
745
  }
435
746
  }
436
747
  response = http_json("POST", f"{api_url(config)}/workspace/connect", payload, token)
@@ -442,7 +753,8 @@ def command_workspace_connect(args: argparse.Namespace) -> int:
442
753
  def command_mcp_connect(args: argparse.Namespace) -> int:
443
754
  home = cli_home()
444
755
  config = load_config(home)
445
- token = require_token(config)
756
+ token = require_verified_auth(home, config)
757
+ identity = client_identity(home, config)
446
758
  config["mcp_server"] = args.server
447
759
  save_config(home, config)
448
760
  payload = {
@@ -452,6 +764,9 @@ def command_mcp_connect(args: argparse.Namespace) -> int:
452
764
  "project": config.get("project") or "",
453
765
  "repository": config.get("repository") or "",
454
766
  "cli_storage_home_hash": sha256_text(str(home)),
767
+ "local_user_hash": identity["local_user_hash"],
768
+ "user_hash": identity["user_hash"],
769
+ "github_user": identity["github_user"],
455
770
  }
456
771
  }
457
772
  response = http_json("POST", f"{api_url(config)}/mcp/connect", payload, token)
@@ -463,7 +778,8 @@ def command_mcp_connect(args: argparse.Namespace) -> int:
463
778
  def command_chat_connect(args: argparse.Namespace) -> int:
464
779
  home = cli_home()
465
780
  config = load_config(home)
466
- token = require_token(config)
781
+ token = require_verified_auth(home, config)
782
+ identity = client_identity(home, config)
467
783
  config["chat_provider"] = args.provider
468
784
  save_config(home, config)
469
785
  payload = {
@@ -473,6 +789,9 @@ def command_chat_connect(args: argparse.Namespace) -> int:
473
789
  "project": config.get("project") or "",
474
790
  "repository": config.get("repository") or "",
475
791
  "cli_storage_home_hash": sha256_text(str(home)),
792
+ "local_user_hash": identity["local_user_hash"],
793
+ "user_hash": identity["user_hash"],
794
+ "github_user": identity["github_user"],
476
795
  }
477
796
  }
478
797
  response = http_json("POST", f"{api_url(config)}/chat/connect", payload, token)
@@ -502,6 +821,13 @@ def command_watch(args: argparse.Namespace) -> int:
502
821
  break
503
822
  if not command:
504
823
  continue
824
+ cleaned_command = clean_watch_command(command)
825
+ if cleaned_command != command:
826
+ if not cleaned_command:
827
+ print("ai-memory: skipped pasted prompt without a command.")
828
+ continue
829
+ print(f"ai-memory: using command without pasted prompt: {cleaned_command}")
830
+ command = cleaned_command
505
831
  if command.lower() in {"exit", "quit"}:
506
832
  break
507
833
  capture_command(home, config, command, args.include_excluded, "watch")
@@ -511,6 +837,7 @@ def command_watch(args: argparse.Namespace) -> int:
511
837
  def command_history_import(args: argparse.Namespace) -> int:
512
838
  home = cli_home()
513
839
  config = load_config(home)
840
+ require_verified_auth(home, config)
514
841
  history_path = Path(args.path).expanduser() if args.path else detect_history_file()
515
842
  if not history_path or not history_path.exists():
516
843
  raise SystemExit("No shell history file found. Pass --path <history-file>.")
@@ -557,6 +884,229 @@ def command_sync(args: argparse.Namespace) -> int:
557
884
  return 0
558
885
 
559
886
 
887
+ def command_agent_run(args: argparse.Namespace) -> int:
888
+ home = cli_home()
889
+ ensure_dirs(home)
890
+ interval = max(10, int(args.interval))
891
+ limit = max(1, int(args.limit))
892
+ state = {
893
+ "pid": os.getpid(),
894
+ "version": __version__,
895
+ "started_at": utc_now(),
896
+ "interval_seconds": interval,
897
+ "limit": limit,
898
+ "mode": "once" if args.once else "loop",
899
+ }
900
+ write_json(agent_state_path(home), state)
901
+ append_log(home, f"agent started pid={os.getpid()} interval={interval}s limit={limit}")
902
+
903
+ try:
904
+ while True:
905
+ config = load_config(home)
906
+ try:
907
+ synced = sync_events(home, config, limit=limit, quiet=True)
908
+ if synced:
909
+ append_log(home, f"synced {synced} terminal event(s)")
910
+ except Exception as exc:
911
+ append_log(home, f"sync failed: {exc}")
912
+
913
+ if args.once:
914
+ break
915
+ time.sleep(interval)
916
+ finally:
917
+ state["stopped_at"] = utc_now()
918
+ write_json(agent_state_path(home), state)
919
+ append_log(home, "agent stopped")
920
+
921
+ return 0
922
+
923
+
924
+ def command_agent_install(args: argparse.Namespace) -> int:
925
+ if os.name != "nt":
926
+ raise SystemExit("agent install currently supports Windows Task Scheduler only.")
927
+
928
+ home = cli_home()
929
+ config = load_config(home)
930
+ config["agent"] = {
931
+ "task_name": args.task_name,
932
+ "interval_seconds": args.interval,
933
+ "limit": args.limit,
934
+ "installed_at": utc_now(),
935
+ }
936
+ save_config(home, config)
937
+
938
+ if args.method in {"auto", "task"}:
939
+ python_executable = scheduler_python_executable(background=not args.console)
940
+ task_command = (
941
+ f'"{python_executable}" -m ai_memory_cli agent run '
942
+ f"--interval {int(args.interval)} --limit {int(args.limit)}"
943
+ )
944
+ result = subprocess.run(
945
+ [
946
+ "schtasks",
947
+ "/Create",
948
+ "/TN",
949
+ args.task_name,
950
+ "/SC",
951
+ "ONLOGON",
952
+ "/TR",
953
+ task_command,
954
+ "/F",
955
+ ],
956
+ capture_output=True,
957
+ text=True,
958
+ encoding="utf-8",
959
+ errors="replace",
960
+ )
961
+ if result.returncode == 0:
962
+ append_log(home, f"installed Windows scheduled task: {args.task_name}")
963
+ print(f"Installed startup agent task: {args.task_name}")
964
+ print("It starts when you log in. Start it now with:")
965
+ print("python -m ai_memory_cli agent start")
966
+ return 0
967
+
968
+ if args.method == "task":
969
+ raise SystemExit((result.stderr or result.stdout).strip())
970
+
971
+ print("Task Scheduler install failed; falling back to user Startup folder.")
972
+ print((result.stderr or result.stdout).strip())
973
+
974
+ script_path = write_windows_startup_script(int(args.interval), int(args.limit))
975
+ append_log(home, f"installed Windows startup script: {script_path}")
976
+ print(f"Installed startup agent script: {script_path}")
977
+ print("It starts when you log in. Start it now with:")
978
+ print("python -m ai_memory_cli agent run")
979
+ return 0
980
+
981
+
982
+ def command_agent_uninstall(args: argparse.Namespace) -> int:
983
+ if os.name != "nt":
984
+ raise SystemExit("agent uninstall currently supports Windows Task Scheduler only.")
985
+
986
+ removed = False
987
+ result = subprocess.run(
988
+ ["schtasks", "/Delete", "/TN", args.task_name, "/F"],
989
+ capture_output=True,
990
+ text=True,
991
+ encoding="utf-8",
992
+ errors="replace",
993
+ )
994
+ if result.returncode == 0:
995
+ removed = True
996
+
997
+ script_path = windows_startup_script_path()
998
+ if script_path.exists():
999
+ script_path.unlink()
1000
+ removed = True
1001
+
1002
+ append_log(cli_home(), f"uninstalled Windows scheduled task: {args.task_name}")
1003
+ if removed:
1004
+ print("Removed startup agent registration.")
1005
+ else:
1006
+ print("No startup agent registration was found.")
1007
+ return 0
1008
+
1009
+
1010
+ def command_agent_start(args: argparse.Namespace) -> int:
1011
+ if os.name != "nt":
1012
+ raise SystemExit("agent start currently supports Windows Task Scheduler only.")
1013
+
1014
+ home = cli_home()
1015
+ config = load_config(home)
1016
+ state = read_json(agent_state_path(home), {})
1017
+ if is_process_running(state.get("pid")):
1018
+ print(f"Agent already running: pid={state.get('pid')}")
1019
+ return 0
1020
+
1021
+ result = subprocess.run(
1022
+ ["schtasks", "/Run", "/TN", args.task_name],
1023
+ capture_output=True,
1024
+ text=True,
1025
+ encoding="utf-8",
1026
+ errors="replace",
1027
+ )
1028
+ if result.returncode == 0:
1029
+ print(f"Started agent task: {args.task_name}")
1030
+ return 0
1031
+
1032
+ agent_config = config.get("agent") if isinstance(config.get("agent"), dict) else {}
1033
+ interval = int(agent_config.get("interval_seconds") or DEFAULT_AGENT_INTERVAL_SECONDS)
1034
+ limit = int(agent_config.get("limit") or 50)
1035
+ pid = start_detached_agent(interval, limit)
1036
+ append_log(home, f"started detached agent pid={pid}")
1037
+ print(f"Started detached agent process: pid={pid}")
1038
+ return 0
1039
+
1040
+
1041
+ def command_agent_stop(args: argparse.Namespace) -> int:
1042
+ if os.name != "nt":
1043
+ raise SystemExit("agent stop currently supports Windows Task Scheduler only.")
1044
+
1045
+ home = cli_home()
1046
+ result = subprocess.run(
1047
+ ["schtasks", "/End", "/TN", args.task_name],
1048
+ capture_output=True,
1049
+ text=True,
1050
+ encoding="utf-8",
1051
+ errors="replace",
1052
+ )
1053
+ if result.returncode == 0:
1054
+ append_log(home, f"stopped Windows scheduled task: {args.task_name}")
1055
+ print(f"Stopped agent task: {args.task_name}")
1056
+ return 0
1057
+
1058
+ state = read_json(agent_state_path(home), {})
1059
+ pid = state.get("pid")
1060
+ if not pid:
1061
+ print("No running detached agent pid was found.")
1062
+ return 0
1063
+
1064
+ kill = subprocess.run(
1065
+ ["taskkill", "/PID", str(pid), "/F"],
1066
+ capture_output=True,
1067
+ text=True,
1068
+ encoding="utf-8",
1069
+ errors="replace",
1070
+ )
1071
+ if kill.returncode != 0:
1072
+ raise SystemExit((kill.stderr or kill.stdout).strip())
1073
+ append_log(home, f"stopped detached agent pid={pid}")
1074
+ print(f"Stopped detached agent process: pid={pid}")
1075
+ return 0
1076
+
1077
+
1078
+ def command_agent_status(args: argparse.Namespace) -> int:
1079
+ home = cli_home()
1080
+ config = load_config(home)
1081
+ state = read_json(agent_state_path(home), {})
1082
+ print(f"Storage: {home}")
1083
+ print(f"API: {api_url(config)}")
1084
+ print(f"Token: {'saved' if config.get('token') else 'missing'}")
1085
+ if state:
1086
+ print(f"Agent state: pid={state.get('pid', '-')} started={state.get('started_at', '-')}")
1087
+ else:
1088
+ print("Agent state: no local agent state file yet")
1089
+
1090
+ if os.name != "nt":
1091
+ return 0
1092
+
1093
+ result = subprocess.run(
1094
+ ["schtasks", "/Query", "/TN", args.task_name, "/FO", "LIST", "/V"],
1095
+ capture_output=True,
1096
+ text=True,
1097
+ encoding="utf-8",
1098
+ errors="replace",
1099
+ )
1100
+ if result.returncode != 0:
1101
+ print(f"Windows task: not installed ({args.task_name})")
1102
+ else:
1103
+ print(result.stdout.strip())
1104
+
1105
+ script_path = windows_startup_script_path()
1106
+ print(f"Startup script: {'installed' if script_path.exists() else 'not installed'} ({script_path})")
1107
+ return 0
1108
+
1109
+
560
1110
  def command_status(_: argparse.Namespace) -> int:
561
1111
  home = cli_home()
562
1112
  config = load_config(home)
@@ -570,7 +1120,9 @@ def command_status(_: argparse.Namespace) -> int:
570
1120
  print(f"Project: {config.get('project') or '-'}")
571
1121
  print(f"Repository: {config.get('repository') or '-'}")
572
1122
  print(f"Workspace: {config.get('workspace_path') or '.'}")
573
- print(f"Token: {'saved' if config.get('token') else 'missing'}")
1123
+ print(f"Token: {'verified' if config.get('auth_verified_at') and config.get('user_hash') else 'saved' if config.get('token') else 'missing'}")
1124
+ print(f"GitHub account: {config.get('github_user') or '-'}")
1125
+ print(f"User hash: {str(config.get('user_hash') or '-')[:24]}{'...' if config.get('user_hash') else ''}")
574
1126
  print(f"Events: {event_count} total, {outbox_count} queued, {sent_count} synced receipts")
575
1127
  return 0
576
1128
 
@@ -600,6 +1152,7 @@ def build_parser() -> argparse.ArgumentParser:
600
1152
  auth = subparsers.add_parser("auth", help="Save the app-issued CLI token.")
601
1153
  auth.add_argument("--token", required=True, help="Token generated by the website.")
602
1154
  auth.add_argument("--api-url", default=DEFAULT_API_URL, help="FastAPI base URL.")
1155
+ auth.add_argument("--no-agent", action="store_true", help="Do not auto-start the background sync agent after auth.")
603
1156
  auth.set_defaults(func=command_auth)
604
1157
 
605
1158
  init = subparsers.add_parser("init", help="Save project config and call /projects/init.")
@@ -638,6 +1191,7 @@ def build_parser() -> argparse.ArgumentParser:
638
1191
 
639
1192
  watch = subparsers.add_parser("watch", help="Start a managed terminal that captures commands and output.")
640
1193
  watch.add_argument("--include-excluded", action="store_true")
1194
+ watch.add_argument("--version", action="version", version=f"ai-memory {__version__}")
641
1195
  watch.set_defaults(func=command_watch)
642
1196
 
643
1197
  history = subparsers.add_parser("history", help="History import commands.")
@@ -652,6 +1206,39 @@ def build_parser() -> argparse.ArgumentParser:
652
1206
  sync.add_argument("--limit", type=int, default=50)
653
1207
  sync.set_defaults(func=command_sync)
654
1208
 
1209
+ agent = subparsers.add_parser("agent", help="Background sync agent commands.")
1210
+ agent_subparsers = agent.add_subparsers(dest="agent_command", required=True)
1211
+
1212
+ agent_run = agent_subparsers.add_parser("run", help="Run the background sync loop.")
1213
+ agent_run.add_argument("--interval", type=int, default=DEFAULT_AGENT_INTERVAL_SECONDS)
1214
+ agent_run.add_argument("--limit", type=int, default=50)
1215
+ agent_run.add_argument("--once", action="store_true")
1216
+ agent_run.set_defaults(func=command_agent_run)
1217
+
1218
+ agent_install = agent_subparsers.add_parser("install", help="Install Windows startup task for the sync agent.")
1219
+ agent_install.add_argument("--interval", type=int, default=DEFAULT_AGENT_INTERVAL_SECONDS)
1220
+ agent_install.add_argument("--limit", type=int, default=50)
1221
+ agent_install.add_argument("--task-name", default=WINDOWS_AGENT_TASK_NAME)
1222
+ agent_install.add_argument("--method", choices=["auto", "task", "startup"], default="auto")
1223
+ agent_install.add_argument("--console", action="store_true", help="Use python.exe instead of pythonw.exe for the scheduled task.")
1224
+ agent_install.set_defaults(func=command_agent_install)
1225
+
1226
+ agent_uninstall = agent_subparsers.add_parser("uninstall", help="Remove Windows startup task for the sync agent.")
1227
+ agent_uninstall.add_argument("--task-name", default=WINDOWS_AGENT_TASK_NAME)
1228
+ agent_uninstall.set_defaults(func=command_agent_uninstall)
1229
+
1230
+ agent_start = agent_subparsers.add_parser("start", help="Start the installed Windows agent task now.")
1231
+ agent_start.add_argument("--task-name", default=WINDOWS_AGENT_TASK_NAME)
1232
+ agent_start.set_defaults(func=command_agent_start)
1233
+
1234
+ agent_stop = agent_subparsers.add_parser("stop", help="Stop the installed Windows agent task.")
1235
+ agent_stop.add_argument("--task-name", default=WINDOWS_AGENT_TASK_NAME)
1236
+ agent_stop.set_defaults(func=command_agent_stop)
1237
+
1238
+ agent_status = agent_subparsers.add_parser("status", help="Show background agent and Windows task state.")
1239
+ agent_status.add_argument("--task-name", default=WINDOWS_AGENT_TASK_NAME)
1240
+ agent_status.set_defaults(func=command_agent_status)
1241
+
655
1242
  status = subparsers.add_parser("status", help="Show local CLI state.")
656
1243
  status.set_defaults(func=command_status)
657
1244
 
@@ -669,3 +1256,15 @@ def main(argv: list[str] | None = None) -> int:
669
1256
  except KeyboardInterrupt:
670
1257
  print("Interrupted.", file=sys.stderr)
671
1258
  return 130
1259
+
1260
+
1261
+ def watch_main(argv: list[str] | None = None) -> int:
1262
+ parser = argparse.ArgumentParser(prog="watch", description="Start AI Memory terminal capture.")
1263
+ parser.add_argument("--include-excluded", action="store_true")
1264
+ parser.add_argument("--version", action="version", version=f"ai-memory {__version__}")
1265
+ args = parser.parse_args(argv)
1266
+ try:
1267
+ return command_watch(args)
1268
+ except KeyboardInterrupt:
1269
+ print("Interrupted.", file=sys.stderr)
1270
+ 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.6
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,49 @@ 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
+ The `auth` command verifies the website-issued token with FastAPI before it is saved locally. After verification,
57
+ the CLI stores a SHA-512 user hash for this computer, binds the token to that hash on the server, and starts the
58
+ background sync agent once on Windows.
59
+
60
+ `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`.
61
+ On Windows the shortcut is installed as `watch.cmd`; the Python Scripts folder must be on `PATH` for bare `watch` to resolve.
62
+
56
63
  Use `python -m ai_memory_cli run -- COMMAND` when you only want to record one command.
57
64
 
58
65
  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
66
 
60
67
  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
68
 
69
+ ## Background agent
70
+
71
+ After `auth`, the background agent starts once and is installed in the Windows Startup folder so queued terminal
72
+ hashes keep syncing whenever the API is reachable. Use these commands when you need manual control:
73
+
74
+ ```powershell
75
+ python -m ai_memory_cli agent status
76
+ python -m ai_memory_cli agent stop
77
+ python -m ai_memory_cli agent start
78
+ ```
79
+
80
+ The agent does not secretly capture every terminal on the computer. Commands are captured when they run through:
81
+
82
+ ```powershell
83
+ python -m ai_memory_cli watch
84
+ python -m ai_memory_cli run -- python --version
85
+ ```
86
+
87
+ To remove the startup task:
88
+
89
+ ```powershell
90
+ python -m ai_memory_cli agent stop
91
+ python -m ai_memory_cli agent uninstall
92
+ ```
93
+
94
+ Agent logs are written to `%USERPROFILE%\.ai-memory-cli\logs\agent.log`.
95
+
62
96
  ## Storage
63
97
 
64
98
  The CLI stores config and unsynced events in a separate folder:
@@ -68,6 +102,15 @@ The CLI stores config and unsynced events in a separate folder:
68
102
 
69
103
  Set `AI_MEMORY_CLI_HOME` to override this location.
70
104
 
105
+ Accepted command observations are also written as plain daily hash logs:
106
+
107
+ - Windows: `%USERPROFILE%\.ai-memory-cli\history\YYYY-MM-DD.log`
108
+ - macOS/Linux: `~/.ai-memory-cli/history/YYYY-MM-DD.log`
109
+
110
+ 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.
111
+
112
+ 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.
113
+
71
114
  ## Privacy and dedupe
72
115
 
73
116
  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