ai-memory-cli 0.1.9__tar.gz → 0.1.11__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.9
3
+ Version: 0.1.11
4
4
  Summary: Python CLI for AI Memory terminal capture and offline sync.
5
5
  Author: AI Memory
6
6
  License-Expression: MIT
@@ -70,6 +70,7 @@ On Windows, `python -m ai_memory_cli ...` is the safest form because it avoids P
70
70
  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.
71
71
  Use `cls` on Windows or `clear` on Unix shells to clear the watch screen; those control commands are not stored or synced.
72
72
  On Windows, commands run through PowerShell, so type `ls` directly. Do not type `powershell` or `cmd` inside `watch`; nested shell launchers are ignored and not stored.
73
+ The watch prompt shows the active folder, for example `ai-memory C:\work\repo>`. Use `cd`, `chdir`, `cd..`, `cd /d D:\path`, `cd ~`, or `cd -` normally; successful directory changes are tracked as hashed terminal events and become the working folder for the next command.
73
74
 
74
75
  ## Background agent
75
76
 
@@ -48,6 +48,7 @@ On Windows, `python -m ai_memory_cli ...` is the safest form because it avoids P
48
48
  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.
49
49
  Use `cls` on Windows or `clear` on Unix shells to clear the watch screen; those control commands are not stored or synced.
50
50
  On Windows, commands run through PowerShell, so type `ls` directly. Do not type `powershell` or `cmd` inside `watch`; nested shell launchers are ignored and not stored.
51
+ The watch prompt shows the active folder, for example `ai-memory C:\work\repo>`. Use `cd`, `chdir`, `cd..`, `cd /d D:\path`, `cd ~`, or `cd -` normally; successful directory changes are tracked as hashed terminal events and become the working folder for the next command.
51
52
 
52
53
  ## Background agent
53
54
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ai-memory-cli"
7
- version = "0.1.9"
7
+ version = "0.1.11"
8
8
  description = "Python CLI for AI Memory terminal capture and offline sync."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """AI Memory terminal capture CLI."""
2
2
 
3
- __version__ = "0.1.9"
3
+ __version__ = "0.1.11"
@@ -78,7 +78,7 @@ def read_json(path: Path, default: Any) -> Any:
78
78
  if not path.exists():
79
79
  return default
80
80
  try:
81
- return json.loads(path.read_text(encoding="utf-8"))
81
+ return json.loads(path.read_text(encoding="utf-8-sig"))
82
82
  except (OSError, json.JSONDecodeError):
83
83
  return default
84
84
 
@@ -327,6 +327,60 @@ def is_clear_command(command: str) -> bool:
327
327
  return normalize_command(command).lower() in CLEAR_COMMANDS
328
328
 
329
329
 
330
+ def parse_cd_command(command: str) -> str | None:
331
+ stripped = command.strip()
332
+ lower = stripped.lower()
333
+ if lower in {"cd..", "chdir.."}:
334
+ return ".."
335
+ if os.name == "nt" and lower.startswith("cd\\"):
336
+ return stripped[2:].strip()
337
+
338
+ match = re.match(r"^(cd|chdir)(?:\s+(.*))?$", stripped, flags=re.IGNORECASE)
339
+ if not match:
340
+ return None
341
+
342
+ target = (match.group(2) or "").strip()
343
+ if os.name == "nt" and target.lower().startswith("/d"):
344
+ target = target[2:].strip()
345
+ if len(target) >= 2 and target[0] == target[-1] and target[0] in {"'", '"'}:
346
+ target = target[1:-1]
347
+ return target
348
+
349
+
350
+ def resolve_cd_target(target: str, cwd: Path, previous_cwd: Path | None) -> tuple[Path | None, str, bool]:
351
+ current = cwd.resolve()
352
+ if not target:
353
+ return current, str(current) + os.linesep, False
354
+ if target == "-":
355
+ if previous_cwd:
356
+ return previous_cwd.resolve(), str(previous_cwd.resolve()) + os.linesep, True
357
+ return None, "ai-memory: no previous directory for cd -\n", True
358
+
359
+ expanded = os.path.expandvars(target)
360
+ if os.name == "nt" and re.fullmatch(r"[A-Za-z]:", expanded):
361
+ expanded = f"{expanded}\\"
362
+
363
+ candidate = Path(expanded).expanduser()
364
+ if not candidate.is_absolute():
365
+ candidate = current / candidate
366
+
367
+ try:
368
+ resolved = candidate.resolve(strict=False)
369
+ except OSError:
370
+ resolved = candidate
371
+
372
+ if not resolved.exists() or not resolved.is_dir():
373
+ if os.name == "nt":
374
+ return None, "The system cannot find the path specified.\n", True
375
+ return None, f"cd: no such file or directory: {target}\n", True
376
+
377
+ return resolved, "", True
378
+
379
+
380
+ def watch_prompt(cwd: Path) -> str:
381
+ return f"ai-memory {cwd.resolve()}> "
382
+
383
+
330
384
  def is_interactive_shell_command(command: str) -> bool:
331
385
  tokens = normalize_command(command).lower().split()
332
386
  if not tokens:
@@ -688,7 +742,115 @@ def sync_events(home: Path, config: dict[str, Any], limit: int = 50, quiet: bool
688
742
  return synced
689
743
 
690
744
 
691
- def capture_command(home: Path, config: dict[str, Any], command: str, include_excluded: bool, source: str) -> int:
745
+ def store_captured_event(
746
+ home: Path,
747
+ config: dict[str, Any],
748
+ command: str,
749
+ stdout: str,
750
+ stderr: str,
751
+ exit_code: int | None,
752
+ started_at: str,
753
+ ended_at: str,
754
+ duration_ms: int,
755
+ cwd: Path,
756
+ source: str,
757
+ extra_metadata: dict[str, Any] | None = None,
758
+ ) -> tuple[dict[str, Any], bool, Path]:
759
+ event = make_terminal_event(
760
+ command=command,
761
+ stdout=stdout,
762
+ stderr=stderr,
763
+ exit_code=exit_code,
764
+ started_at=started_at,
765
+ ended_at=ended_at,
766
+ duration_ms=duration_ms,
767
+ cwd=cwd,
768
+ config=config,
769
+ source=source,
770
+ )
771
+ if extra_metadata:
772
+ event["metadata"].update(extra_metadata)
773
+ created, event_path = store_event(home, event)
774
+ state = "stored" if created else "deduped"
775
+ append_history(home, event, state)
776
+ print(f"ai-memory: {state} terminal hash {event['event_hash'][:12]} at {event_path}")
777
+
778
+ try:
779
+ sync_events(home, config, quiet=True)
780
+ except Exception as exc:
781
+ print(f"ai-memory: sync queued until network/API is available ({exc})", file=sys.stderr)
782
+
783
+ return event, created, event_path
784
+
785
+
786
+ def capture_cd_command(
787
+ home: Path,
788
+ config: dict[str, Any],
789
+ command: str,
790
+ cwd: Path,
791
+ previous_cwd: Path | None,
792
+ source: str,
793
+ ) -> tuple[int, Path, Path | None]:
794
+ require_verified_auth(home, config)
795
+ target = parse_cd_command(command)
796
+ if target is None:
797
+ return 1, cwd, previous_cwd
798
+
799
+ old_cwd = cwd.resolve()
800
+ started_at = utc_now()
801
+ started_monotonic = time.monotonic()
802
+ new_cwd, output, should_track = resolve_cd_target(target, old_cwd, previous_cwd)
803
+ ended_at = utc_now()
804
+ duration_ms = int((time.monotonic() - started_monotonic) * 1000)
805
+
806
+ if output:
807
+ if new_cwd is None:
808
+ print(output, end="", file=sys.stderr)
809
+ else:
810
+ print(output, end="")
811
+
812
+ if new_cwd is None:
813
+ print("ai-memory: skipped invalid cd; nothing was stored.")
814
+ return 1, old_cwd, previous_cwd
815
+
816
+ changed = new_cwd.resolve() != old_cwd
817
+ if should_track:
818
+ store_captured_event(
819
+ home=home,
820
+ config=config,
821
+ command=command,
822
+ stdout=output if new_cwd is not None else "",
823
+ stderr="",
824
+ exit_code=0,
825
+ started_at=started_at,
826
+ ended_at=ended_at,
827
+ duration_ms=duration_ms,
828
+ cwd=old_cwd,
829
+ source=source,
830
+ extra_metadata={
831
+ "builtin": "cd",
832
+ "cwd_changed": changed,
833
+ "new_cwd_hash": sha256_text(str(new_cwd.resolve())),
834
+ "new_cwd_tail": new_cwd.resolve().name,
835
+ },
836
+ )
837
+
838
+ return 0, new_cwd.resolve(), old_cwd if changed else previous_cwd
839
+
840
+
841
+ def default_command_cwd(config: dict[str, Any]) -> Path:
842
+ workspace = Path(str(config.get("workspace_path") or ".")).expanduser()
843
+ return workspace.resolve() if workspace.exists() else Path.cwd().resolve()
844
+
845
+
846
+ def capture_command(
847
+ home: Path,
848
+ config: dict[str, Any],
849
+ command: str,
850
+ include_excluded: bool,
851
+ source: str,
852
+ cwd: Path | None = None,
853
+ ) -> int:
692
854
  if is_clear_command(command):
693
855
  clear_console()
694
856
  return 0
@@ -698,16 +860,15 @@ def capture_command(home: Path, config: dict[str, Any], command: str, include_ex
698
860
  return 0
699
861
 
700
862
  require_verified_auth(home, config)
701
- workspace = Path(str(config.get("workspace_path") or ".")).expanduser()
702
- cwd = workspace if workspace.exists() else Path.cwd()
863
+ effective_cwd = cwd.resolve() if cwd else default_command_cwd(config)
703
864
 
704
865
  if is_excluded(command, config) and not include_excluded:
705
866
  print(f"ai-memory: running without capture because this command is excluded: {command}", file=sys.stderr)
706
- return int(run_external_command(command, cwd, capture=False))
867
+ return int(run_external_command(command, effective_cwd, capture=False))
707
868
 
708
869
  started_at = utc_now()
709
870
  started_monotonic = time.monotonic()
710
- completed = run_external_command(command, cwd, capture=True)
871
+ completed = run_external_command(command, effective_cwd, capture=True)
711
872
  if isinstance(completed, int):
712
873
  return completed
713
874
  ended_at = utc_now()
@@ -722,7 +883,9 @@ def capture_command(home: Path, config: dict[str, Any], command: str, include_ex
722
883
  print("ai-memory: skipped invalid command; nothing was stored.")
723
884
  return completed.returncode
724
885
 
725
- event = make_terminal_event(
886
+ store_captured_event(
887
+ home=home,
888
+ config=config,
726
889
  command=command,
727
890
  stdout=completed.stdout or "",
728
891
  stderr=completed.stderr or "",
@@ -730,20 +893,9 @@ def capture_command(home: Path, config: dict[str, Any], command: str, include_ex
730
893
  started_at=started_at,
731
894
  ended_at=ended_at,
732
895
  duration_ms=duration_ms,
733
- cwd=cwd,
734
- config=config,
896
+ cwd=effective_cwd,
735
897
  source=source,
736
898
  )
737
- created, event_path = store_event(home, event)
738
- state = "stored" if created else "deduped"
739
- append_history(home, event, state)
740
- print(f"ai-memory: {state} terminal hash {event['event_hash'][:12]} at {event_path}")
741
-
742
- try:
743
- sync_events(home, config, quiet=True)
744
- except Exception as exc:
745
- print(f"ai-memory: sync queued until network/API is available ({exc})", file=sys.stderr)
746
-
747
899
  return completed.returncode
748
900
 
749
901
 
@@ -909,10 +1061,12 @@ def command_watch(args: argparse.Namespace) -> int:
909
1061
  prompt_for_auth(home, config)
910
1062
  config = load_config(home)
911
1063
 
1064
+ cwd = default_command_cwd(config)
1065
+ previous_cwd: Path | None = None
912
1066
  print("AI Memory watch mode. Type commands to run and capture. Type exit to stop.")
913
1067
  while True:
914
1068
  try:
915
- command = input("ai-memory> ").strip()
1069
+ command = input(watch_prompt(cwd)).strip()
916
1070
  except (EOFError, KeyboardInterrupt):
917
1071
  print()
918
1072
  break
@@ -933,7 +1087,10 @@ def command_watch(args: argparse.Namespace) -> int:
933
1087
  if is_interactive_shell_command(command):
934
1088
  print("ai-memory: do not start a nested shell here. Type commands directly, for example: ls")
935
1089
  continue
936
- capture_command(home, config, command, args.include_excluded, "watch")
1090
+ if parse_cd_command(command) is not None:
1091
+ _, cwd, previous_cwd = capture_cd_command(home, config, command, cwd, previous_cwd, "watch")
1092
+ continue
1093
+ capture_command(home, config, command, args.include_excluded, "watch", cwd=cwd)
937
1094
  return 0
938
1095
 
939
1096
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-memory-cli
3
- Version: 0.1.9
3
+ Version: 0.1.11
4
4
  Summary: Python CLI for AI Memory terminal capture and offline sync.
5
5
  Author: AI Memory
6
6
  License-Expression: MIT
@@ -70,6 +70,7 @@ On Windows, `python -m ai_memory_cli ...` is the safest form because it avoids P
70
70
  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.
71
71
  Use `cls` on Windows or `clear` on Unix shells to clear the watch screen; those control commands are not stored or synced.
72
72
  On Windows, commands run through PowerShell, so type `ls` directly. Do not type `powershell` or `cmd` inside `watch`; nested shell launchers are ignored and not stored.
73
+ The watch prompt shows the active folder, for example `ai-memory C:\work\repo>`. Use `cd`, `chdir`, `cd..`, `cd /d D:\path`, `cd ~`, or `cd -` normally; successful directory changes are tracked as hashed terminal events and become the working folder for the next command.
73
74
 
74
75
  ## Background agent
75
76
 
File without changes
File without changes