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