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.
- {ai_memory_cli-0.1.1/src/ai_memory_cli.egg-info → ai_memory_cli-0.1.6}/PKG-INFO +45 -2
- {ai_memory_cli-0.1.1 → ai_memory_cli-0.1.6}/README.md +44 -1
- ai_memory_cli-0.1.6/bin/watch.cmd +7 -0
- {ai_memory_cli-0.1.1 → ai_memory_cli-0.1.6}/pyproject.toml +4 -1
- {ai_memory_cli-0.1.1 → ai_memory_cli-0.1.6}/src/ai_memory_cli/__init__.py +1 -1
- {ai_memory_cli-0.1.1 → ai_memory_cli-0.1.6}/src/ai_memory_cli/cli.py +617 -18
- {ai_memory_cli-0.1.1 → ai_memory_cli-0.1.6/src/ai_memory_cli.egg-info}/PKG-INFO +45 -2
- {ai_memory_cli-0.1.1 → ai_memory_cli-0.1.6}/src/ai_memory_cli.egg-info/SOURCES.txt +1 -0
- {ai_memory_cli-0.1.1 → ai_memory_cli-0.1.6}/LICENSE +0 -0
- {ai_memory_cli-0.1.1 → ai_memory_cli-0.1.6}/setup.cfg +0 -0
- {ai_memory_cli-0.1.1 → ai_memory_cli-0.1.6}/src/ai_memory_cli/__main__.py +0 -0
- {ai_memory_cli-0.1.1 → ai_memory_cli-0.1.6}/src/ai_memory_cli.egg-info/dependency_links.txt +0 -0
- {ai_memory_cli-0.1.1 → ai_memory_cli-0.1.6}/src/ai_memory_cli.egg-info/entry_points.txt +0 -0
- {ai_memory_cli-0.1.1 → ai_memory_cli-0.1.6}/src/ai_memory_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ai-memory-cli
|
|
3
|
-
Version: 0.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
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ai-memory-cli"
|
|
7
|
-
version = "0.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,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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|