From a2469269d275e3c3f249cae707dcfc295b04b2c0 Mon Sep 17 00:00:00 2001 From: yiqi-017 <22300246009@m.fudan.edu.cn> Date: Thu, 30 Jul 2026 21:51:21 +0800 Subject: [PATCH 1/5] feat(paths): externalize user data (temp+memory) to DATA_DIR (P0) Introduce paths.py as the single source of truth splitting APP_DIR (read-only code/bundle) from DATA_DIR (user-writable data). Packaged builds default DATA_DIR to ~/Documents/GenericAgent so reports/outputs are reachable and customizable; source checkouts (.git present) keep data in-place so the dev workflow is unchanged, and GA_DATA_DIR overrides. temp/ and memory/ relocate together because the agent reaches memory via ../memory relative to its temp cwd. memory is seeded from the bundled defaults on first run (never overwriting user edits); the memory Python package still imports from APP_DIR so helper .py keep tracking upgrades. Route ga.py, agentmain.py, desktop_bridge.py, project_mode, workspace_cmd and runtime helpers (reflect/*, ga_ultraplan, compress_session, ui_detect, autonomous helper, cost_tracker, plan_state) through paths. In-process agent core + spawned services are aligned on one root via GA_DATA_DIR. --- agentmain.py | 19 ++--- assets/ga_ultraplan.py | 4 +- frontends/cost_tracker.py | 8 +- frontends/desktop_bridge.py | 29 +++++-- frontends/plan_state.py | 14 +++- frontends/workspace_cmd.py | 5 +- ga.py | 11 +-- memory/L4_raw_sessions/compress_session.py | 6 +- memory/autonomous_operation_sop/helper.py | 5 +- memory/ui_detect.py | 5 +- paths.py | 93 ++++++++++++++++++++++ plugins/project_mode.py | 3 +- reflect/goal_mode.py | 6 +- reflect/scheduler.py | 6 +- 14 files changed, 181 insertions(+), 33 deletions(-) create mode 100644 paths.py diff --git a/agentmain.py b/agentmain.py index 6c5746bd9..31707db83 100644 --- a/agentmain.py +++ b/agentmain.py @@ -12,6 +12,7 @@ from plugins.hooks import discover_and_load; discover_and_load() except Exception: pass from ga import GenericAgentHandler, smart_format, get_global_memory, format_error, consume_file +import paths script_dir = os.path.dirname(os.path.abspath(__file__)) BANNED_TOOLS = (['ask_user', 'start_long_term_update'] if '--no-user-tools' in sys.argv else []) @@ -23,7 +24,7 @@ def load_tool_schema(suffix=''): load_tool_schema() lang_suffix = '_en' if os.environ.get('GA_LANG', '') == 'en' else '' -mem_dir = os.path.join(script_dir, 'memory') +mem_dir = paths.memory_dir() if not os.path.exists(mem_dir): os.makedirs(mem_dir) mem_txt = os.path.join(mem_dir, 'global_mem.txt') if not os.path.exists(mem_txt): open(mem_txt, 'w', encoding='utf-8').write('# [Global Memory - L2]\n') @@ -44,7 +45,7 @@ def get_system_prompt(): # output2_queue = agent.put_task(prompt2) class GenericAgent: def __init__(self): - os.makedirs(os.path.join(script_dir, 'temp'), exist_ok=True) + os.makedirs(paths.temp_dir(), exist_ok=True) self.lock = threading.Lock() self.task_dir = None self.history = []; self.handler = None; @@ -54,7 +55,7 @@ def __init__(self): self.peer_hint = True self.force_non_stream = False logid = f'{(time.time_ns() + random.randrange(1_000_000)) % 1_000_000:06d}' - self.log_path = os.path.join(script_dir, f'temp/model_responses/model_responses_{logid}.txt') + self.log_path = paths.temp_dir('model_responses', f'model_responses_{logid}.txt') self.llmclient = None self.load_llm_sessions() self.extra_sys_prompts = [] @@ -122,7 +123,7 @@ def _handle_slash_cmd(self, raw_query, display_queue): if not raw_query.startswith('/'): return raw_query if _sm := re.match(r'/session\.(\w+)=(.*)', raw_query.strip()): k, v = _sm.group(1), _sm.group(2) - vfile = os.path.join(script_dir, 'temp', v) + vfile = paths.temp_dir(v) if os.path.isfile(vfile): v = open(vfile, encoding='utf-8').read().strip() try: v = json.loads(v) # cover number parsing except (json.JSONDecodeError, ValueError): pass @@ -143,14 +144,14 @@ def run(self): self.task_queue.task_done(); continue self.is_running = True if len(raw_query) > 2000: - task_file = os.path.join(script_dir, 'temp', f'user_prompt_{os.getpid()}_{time.time_ns()}.md') + task_file = paths.temp_dir(f'user_prompt_{os.getpid()}_{time.time_ns()}.md') with open(task_file, 'w', encoding='utf-8') as f: f.write(raw_query) raw_query = f'Long user prompt saved to {task_file}. Read and execute.' rquery = smart_format(raw_query.replace('\n', ' '), max_str_len=200) self.history.append(f"[USER]: {rquery}") sys_prompt = get_system_prompt() + '\n'.join(self.extra_sys_prompts) + getattr(self.llmclient.backend, 'extra_sys_prompt', '') if self.peer_hint: sys_prompt += f"\n[Peer] 用户提及其他会话/后台任务状态时: temp/model_responses/ (只找近期修改的文件尾部)\n" - handler = GenericAgentHandler(self, self.history, os.path.join(script_dir, 'temp')) + handler = GenericAgentHandler(self, self.history, paths.temp_dir()) if getattr(self, 'no_print', False): handler.print = lambda *a, **k: None if self.handler and 'key_info' in self.handler.working: ki = re.sub(r'\n\[SYSTEM\] 此为.*?工作记忆[。\n]*', '', self.handler.working['key_info']) # 去旧 @@ -213,7 +214,7 @@ def run(self): import subprocess, platform cmd = [sys.executable, os.path.abspath(__file__)] + [a for a in sys.argv[1:]] + ['--nobg'] if args.task: - d = os.path.join(script_dir, f'temp/{args.task}'); os.makedirs(d, exist_ok=True) + d = paths.temp_dir(args.task); os.makedirs(d, exist_ok=True) out = open(os.path.join(d, 'stdout.log'), 'w', encoding='utf-8') err = open(os.path.join(d, 'stderr.log'), 'w', encoding='utf-8') else: out, err = subprocess.DEVNULL, subprocess.DEVNULL @@ -230,7 +231,7 @@ def run(self): histfile = args.history if args.task: - agent.task_dir = d = os.path.join(script_dir, f'temp/{args.task}'); nround = '' + agent.task_dir = d = paths.temp_dir(args.task); nround = '' infile = os.path.join(d, 'input.txt'); outfile = f'{d}/output{nround}.txt' if args.input: os.makedirs(d, exist_ok=True) @@ -288,7 +289,7 @@ def run(self): except Exception as e: if getattr(mod, 'ONCE', False): raise print(f'[Reflect] drain error: {e}'); result = f'[ERROR] {e}' - log_dir = os.path.join(script_dir, 'temp/reflect_logs'); os.makedirs(log_dir, exist_ok=True) + log_dir = paths.temp_dir('reflect_logs'); os.makedirs(log_dir, exist_ok=True) script_name = os.path.splitext(os.path.basename(args.reflect))[0] open(os.path.join(log_dir, f'{script_name}_{datetime.now():%Y-%m-%d}.log'), 'a', encoding='utf-8').write(f'[{datetime.now():%m-%d %H:%M}]\n{result}\n\n') if (on_done := getattr(mod, 'on_done', None)): diff --git a/assets/ga_ultraplan.py b/assets/ga_ultraplan.py index 653cfbc7e..9c5b1f76d 100644 --- a/assets/ga_ultraplan.py +++ b/assets/ga_ultraplan.py @@ -7,10 +7,12 @@ __all__ = ["plan", "phase", "parallel", "mapchain"] _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _ROOT not in sys.path: sys.path.insert(0, _ROOT) +import paths _PORT = int(os.environ.get("GA_ULTRAPLAN_PORT", "47831")) _T0 = time(); _phases = []; _phase_stack = []; _tasks = []; _current = "idle"; _events = []; _srv = None; _last = time(); _lock = threading.Lock(); _exec_lock = threading.Lock() _TASK_SLUG = "task"; _FUNC_SEQ = 0; _PLANNED = False; _SESSION = None; _sessions = {} -_RUN_DIR = os.path.abspath(os.environ.get("GA_ULTRAPLAN_RUNDIR", os.path.join(_ROOT, "temp", "ultraplan_default"))) +_RUN_DIR = os.path.abspath(os.environ.get("GA_ULTRAPLAN_RUNDIR", paths.temp_dir("ultraplan_default"))) os.makedirs(_RUN_DIR, exist_ok=True) def _bind(rundir): diff --git a/frontends/cost_tracker.py b/frontends/cost_tracker.py index 744258982..b9e99e92b 100644 --- a/frontends/cost_tracker.py +++ b/frontends/cost_tracker.py @@ -9,9 +9,13 @@ same `[Cache]` / `[Output]` print lines from `temp/*/stdout.log`. """ from __future__ import annotations -import glob, os, re, threading, time +import glob, os, re, sys, threading, time from dataclasses import dataclass, field +_repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _repo not in sys.path: sys.path.insert(0, _repo) +import paths + @dataclass class TokenStats: @@ -76,7 +80,7 @@ def scan_subagent_logs(since: float = 0.0, root: str | None = None) -> TokenStat `since=tui_start_time` to scope to this run. Best-effort: bad logs skipped.""" out = TokenStats() if since > 0: out.started_at = since - pattern = os.path.join(root, _SUBAGENT_GLOB) if root else _SUBAGENT_GLOB + pattern = os.path.join(root, _SUBAGENT_GLOB) if root else os.path.join(paths.temp_dir(), "*", "stdout.log") for p in glob.glob(pattern): try: if since and os.path.getmtime(p) < since: continue diff --git a/frontends/desktop_bridge.py b/frontends/desktop_bridge.py index a42388d8f..1c2d25668 100644 --- a/frontends/desktop_bridge.py +++ b/frontends/desktop_bridge.py @@ -85,6 +85,23 @@ def find_default_ga_root() -> Path: DEFAULT_GA_ROOT = find_default_ga_root() +# Data root split: relocate user-writable data (temp outputs + memory) out of the +# read-only bundle. The agent core runs in-process (see make_agent), so pinning the +# shared `paths` module here aligns bridge, in-process core, and spawned services +# (which inherit GA_DATA_DIR via os.environ) on one writable root. +_REPO_ROOT = str(APP_DIR.parent) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) +import paths # noqa: E402 +_DATA_ROOT = paths.data_dir_for(str(DEFAULT_GA_ROOT)) +os.environ.setdefault("GA_DATA_DIR", _DATA_ROOT) +if os.path.abspath(paths.DATA_DIR) != os.path.abspath(_DATA_ROOT): + paths.DATA_DIR = _DATA_ROOT + paths.TEMP_DIR = os.path.join(_DATA_ROOT, "temp") + paths.MEMORY_DIR = os.path.join(_DATA_ROOT, "memory") + paths._READY = False +paths.ensure_ready() + _FINAL_INFO_RE = re.compile(r'\n*`{5}\n*\[Info\] Final response to user\.\n*`{5}\s*$') @@ -171,9 +188,9 @@ def __init__(self): self.config: Dict[str, Any] = {} self.sessions: Dict[str, Session] = {} self.active_session_id: Optional[str] = None - self._sessions_dir = Path(self.ga_root) / "temp" / "desktop_sessions" + self._sessions_dir = Path(paths.temp_dir()) / "desktop_sessions" # Legacy monolithic store; migrated into _sessions_dir on first load, then retired. - self._sessions_file = Path(self.ga_root) / "temp" / "desktop_sessions.json" + self._sessions_file = Path(paths.temp_dir()) / "desktop_sessions.json" self._load_sessions() @property @@ -1685,7 +1702,7 @@ async def path_open_handler(request): # File attachments live under GA's own temp dir (gitignored), NOT the OS temp # dir, so they survive bridge restarts. Instead of wiping everything on startup, # we keep files for UPLOAD_RETENTION_DAYS and only sweep stale ones. -_WEB_UPLOAD_DIR = Path(DEFAULT_GA_ROOT) / "temp" / "desktop_uploads" +_WEB_UPLOAD_DIR = Path(paths.temp_dir()) / "desktop_uploads" _WEB_UPLOAD_DIR.mkdir(parents=True, exist_ok=True) UPLOAD_RETENTION_DAYS = 30 @@ -1906,7 +1923,9 @@ def _import_memory_from(source_dir: str, ga_root: str) -> dict: temp/model_responses/: 文件名带 pid/logid 天然唯一,只拷目标端不存在的,已存在的跳过。 """ src = Path(source_dir).expanduser().resolve() - dst_root = Path(ga_root).resolve() + # Memory and temp now live under the writable data root, not the (possibly + # read-only) code root passed in as ga_root. + dst_root = Path(paths.DATA_DIR).resolve() if not src.is_dir(): raise ValueError(f"source is not a directory: {src}") if src == dst_root: @@ -2095,7 +2114,7 @@ async def token_stats_handler(request): def _tok_file() -> Path: global _TOKEN_HISTORY_FILE if _TOKEN_HISTORY_FILE is None: - _TOKEN_HISTORY_FILE = Path(manager.ga_root) / "temp" / "desktop_token_history.json" + _TOKEN_HISTORY_FILE = Path(paths.temp_dir()) / "desktop_token_history.json" return _TOKEN_HISTORY_FILE async def get_token_history_handler(request): diff --git a/frontends/plan_state.py b/frontends/plan_state.py index 6b75e8fa6..8bbd6374a 100644 --- a/frontends/plan_state.py +++ b/frontends/plan_state.py @@ -19,9 +19,13 @@ 5. [D] foo ← non-standard marker "D" → open (not done) """ from __future__ import annotations -import os, re +import os, re, sys from typing import Any, Optional +_repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _repo not in sys.path: sys.path.insert(0, _repo) +import paths + _DONE_CHARS = set("xX✓✔√☑") # Newline-insert before a bullet stuck to JSON debris (`{"content": "- [ ] …`). _GLUE_RE = re.compile(r"(? Optional[str]: if not p: return None rel = p.lstrip("./\\") cwd = os.getcwd() - for c in (p, os.path.join(cwd, "temp", rel), os.path.join(cwd, rel)): + for c in (p, os.path.join(cwd, "temp", rel), os.path.join(cwd, rel), + os.path.join(paths.temp_dir(), rel), os.path.join(paths.DATA_DIR, rel)): if os.path.isfile(c) and os.path.getsize(c) > 0: return c return None @@ -221,7 +226,8 @@ def _resolve_stashed_at(p: str, root: str) -> Optional[str]: if not p or not root: return None rel = p.lstrip("./\\") cwd = root.rstrip("/\\") - for c in (p, os.path.join(cwd, "temp", rel), os.path.join(cwd, rel)): + for c in (p, os.path.join(cwd, "temp", rel), os.path.join(cwd, rel), + os.path.join(paths.temp_dir(), rel), os.path.join(paths.DATA_DIR, rel)): if os.path.isfile(c) and os.path.getsize(c) > 0: return c return None @@ -242,7 +248,7 @@ def _store_plan_path(path: str, root: str) -> str: if not p: return "" if os.path.isabs(p) and root: - for base in (os.path.join(root, "temp"), root): + for base in (paths.temp_dir(), os.path.join(root, "temp"), root, paths.DATA_DIR): try: rel = os.path.relpath(p, base) except ValueError: diff --git a/frontends/workspace_cmd.py b/frontends/workspace_cmd.py index c9c4d9846..86c737589 100644 --- a/frontends/workspace_cmd.py +++ b/frontends/workspace_cmd.py @@ -36,10 +36,13 @@ # 路径基准(与 plugins/project_mode.py 的 _TEMP 保持一致) # --------------------------------------------------------------------------- # _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) +import paths def _temp_root() -> str: - return os.path.join(_REPO_ROOT, "temp") + return paths.temp_dir() def _projects_root() -> str: diff --git a/ga.py b/ga.py index 8ba737d19..e88ae2bb1 100644 --- a/ga.py +++ b/ga.py @@ -7,6 +7,7 @@ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) from agent_loop import BaseHandler, StepOutcome, json_default +import paths script_dir = os.path.dirname(os.path.abspath(__file__)) def safe_print(*args, **kwargs): @@ -20,7 +21,7 @@ def code_run(code, code_type="python", timeout=60, cwd=None, code_cwd=None, stop 优先使用python,仅在必要系统操作时使用powershell""" preview = (code[:60].replace('\n', ' ') + '...') if len(code) > 60 else code.strip() yield f"[Action] Running {code_type} in {os.path.basename(cwd)}: {preview}\n" - cwd = cwd or os.path.join(script_dir, 'temp'); tmp_path = None + cwd = cwd or paths.temp_dir(); tmp_path = None if code_type in ["python", "py"]: tmp_file = tempfile.NamedTemporaryFile(suffix=".ai.py", delete=False, mode='w', encoding='utf-8', dir=code_cwd) cr_header = os.path.join(script_dir, 'assets', 'code_run_header.py') @@ -157,7 +158,7 @@ def format_error(e): def log_memory_access(path): if 'memory' not in path: return - stats_file = os.path.join(script_dir, 'memory/file_access_stats.json') + stats_file = paths.memory_dir('file_access_stats.json') try: with open(stats_file, 'r', encoding='utf-8') as f: stats = json.load(f) except: stats = {} @@ -536,7 +537,7 @@ def do_start_long_term_update(self, args, response): **操作**:严格遵循提供的L0的记忆更新SOP。先 `file_read` 看现有 → 判断类型 → 最小化更新 → 无新内容跳过,保证对记忆库最小局部修改。\n ''' + get_global_memory() yield "[Info] Start distilling good memory for long-term storage.\n" - path = './memory/memory_management_sop.md' + path = paths.memory_dir('memory_management_sop.md') if os.path.exists(path): result = 'This is L0:\n' + file_read(path, show_linenos=False) else: result = "Memory Management SOP not found. Do not update memory." if self.current_turn < 10: result, prompt = 'start_long_term_update is only used after completing a long turn task!', '\n' @@ -604,9 +605,9 @@ def get_global_memory(): prompt = "\n" try: suffix = '_en' if os.environ.get('GA_LANG', '') == 'en' else '' - with open(os.path.join(script_dir, 'memory/global_mem_insight.txt'), 'r', encoding='utf-8', errors='replace') as f: insight = f.read() + with open(paths.memory_dir('global_mem_insight.txt'), 'r', encoding='utf-8', errors='replace') as f: insight = f.read() with open(os.path.join(script_dir, f'assets/insight_fixed_structure{suffix}.txt'), 'r', encoding='utf-8') as f: structure = f.read() - prompt += f'cwd = {os.path.join(script_dir, "temp")} (./)\n' + prompt += f'cwd = {paths.temp_dir()} (./)\n' prompt += f"\n[Memory] (../memory)\n" prompt += structure + '\n../memory/global_mem_insight.txt:\n' prompt += insight + "\n" diff --git a/memory/L4_raw_sessions/compress_session.py b/memory/L4_raw_sessions/compress_session.py index a186a3bbe..516b8023a 100644 --- a/memory/L4_raw_sessions/compress_session.py +++ b/memory/L4_raw_sessions/compress_session.py @@ -237,7 +237,11 @@ def batch_process(src, l4_dir=None, dry_run=True): return report # ── CLI ── -RAW_DIR = os.path.join(os.path.dirname(os.path.dirname(L4_DIR)), 'temp', 'model_responses') +import sys as _sys +_repo = os.path.dirname(os.path.dirname(L4_DIR)) +if _repo not in _sys.path: _sys.path.insert(0, _repo) +import paths +RAW_DIR = paths.temp_dir('model_responses') if __name__ == '__main__': import argparse diff --git a/memory/autonomous_operation_sop/helper.py b/memory/autonomous_operation_sop/helper.py index 14a68a655..4b6733635 100644 --- a/memory/autonomous_operation_sop/helper.py +++ b/memory/autonomous_operation_sop/helper.py @@ -20,7 +20,10 @@ _MODULE_DIR = Path(__file__).resolve().parent # memory/autonomous_operation_sop/ _MEMORY_DIR = _MODULE_DIR.parent # memory/ _AGENT_DIR = _MEMORY_DIR.parent # GenericAgent/ -_TEMP_DIR = _AGENT_DIR / "temp" # GenericAgent/temp/ +import sys as _sys +if str(_AGENT_DIR) not in _sys.path: _sys.path.insert(0, str(_AGENT_DIR)) +import paths +_TEMP_DIR = Path(paths.temp_dir()) # writable data root temp/ _REPORTS_DIR = _TEMP_DIR / "autonomous_reports" _HISTORY_FILE = _REPORTS_DIR / "history.txt" _TODO_FILE = _TEMP_DIR / "TODO.txt" diff --git a/memory/ui_detect.py b/memory/ui_detect.py index 0ac5a9341..53fb996d3 100644 --- a/memory/ui_detect.py +++ b/memory/ui_detect.py @@ -21,7 +21,10 @@ #print('[UI DETECT] 截图分析后必须使用物理坐标,ljqCtrl也使用物理坐标!') -DEFAULT_MODEL = str(Path(__file__).resolve().parent.parent / 'temp' / 'weights' / 'icon_detect' / 'model.pt') +_repo = str(Path(__file__).resolve().parent.parent) +if _repo not in sys.path: sys.path.insert(0, _repo) +import paths +DEFAULT_MODEL = paths.temp_dir('weights', 'icon_detect', 'model.pt') try: from rapidocr_onnxruntime import RapidOCR diff --git a/paths.py b/paths.py new file mode 100644 index 000000000..72e929316 --- /dev/null +++ b/paths.py @@ -0,0 +1,93 @@ +"""GenericAgent filesystem roots — single source of truth. + +Two roots, split by responsibility so packaged builds stop "sealing" user data: + +- APP_DIR : read-only code + bundled default resources (this repo / the packaged + bundle). On macOS this lives inside a signed .app and must stay untouched. +- DATA_DIR: user-writable data root (task outputs under temp/, and memory/). Packaged + builds default it to ``~/Documents/GenericAgent`` so users can actually + reach their reports and customize memory; source/dev checkouts keep it + in-place (== APP_DIR) so the existing developer workflow is unchanged. + +Resolution precedence for DATA_DIR (see ``data_dir_for``): + 1. ``GA_DATA_DIR`` environment variable (explicit override, highest priority) + 2. source/dev checkout (the core dir contains ``.git``) -> in-place (== core dir) + 3. packaged run -> ``~/Documents/GenericAgent`` + +temp/ and memory/ are relocated together on purpose: the agent reaches memory via +``../memory`` relative to its temp cwd, so they must share the same data root. +""" + +import os +import shutil + +APP_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def data_dir_for(core_dir): + """Resolve the writable data root for a given code/core directory. + + Kept as a pure function so separate processes (desktop bridge + the spawned + agent core) can compute an identical DATA_DIR from the same core directory. + """ + env = (os.environ.get("GA_DATA_DIR") or "").strip() + if env: + return os.path.abspath(os.path.expanduser(env)) + core_dir = os.path.abspath(core_dir) + # A git checkout means a source/developer run -> keep data in-place (no surprise + # relocation to Documents). Packaged bundles never ship .git. + if os.path.isdir(os.path.join(core_dir, ".git")): + return core_dir + return os.path.join(os.path.expanduser("~"), "Documents", "GenericAgent") + + +DATA_DIR = data_dir_for(APP_DIR) +TEMP_DIR = os.path.join(DATA_DIR, "temp") +MEMORY_DIR = os.path.join(DATA_DIR, "memory") + + +def temp_dir(*parts): + return os.path.join(TEMP_DIR, *parts) if parts else TEMP_DIR + + +def memory_dir(*parts): + return os.path.join(MEMORY_DIR, *parts) if parts else MEMORY_DIR + + +def app_path(*parts): + return os.path.join(APP_DIR, *parts) + + +_READY = False + + +def ensure_ready(): + """Create the data root and seed default memory on first run (idempotent). + + - Always ensures ``DATA_DIR/temp`` exists. + - When data is external (DATA_DIR != APP_DIR) and memory has not been seeded + yet, copies the bundled ``memory/`` defaults over so the agent's ``../memory`` + access and get_global_memory keep working. Existing user edits are never + overwritten (we only seed when the whole memory dir is absent). + Note: the ``memory`` Python package is still imported from APP_DIR, so helper + ``.py`` under memory/ keep tracking app upgrades; only file-based ``.md``/``.txt`` + reads/writes use DATA_DIR. + """ + global _READY + if _READY: + return + _READY = True + try: + os.makedirs(TEMP_DIR, exist_ok=True) + except Exception: + pass + try: + if os.path.abspath(DATA_DIR) != os.path.abspath(APP_DIR): + src = os.path.join(APP_DIR, "memory") + if os.path.isdir(src) and not os.path.isdir(MEMORY_DIR): + shutil.copytree(src, MEMORY_DIR) + except Exception: + pass + + +ensure_ready() diff --git a/plugins/project_mode.py b/plugins/project_mode.py index 3c58592c4..9f3554496 100644 --- a/plugins/project_mode.py +++ b/plugins/project_mode.py @@ -14,9 +14,10 @@ """ import os import plugins.hooks as hooks +import paths _PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -_TEMP = os.path.join(_PROJECT_ROOT, 'temp') +_TEMP = paths.temp_dir() def _active_project(ctx=None): diff --git a/reflect/goal_mode.py b/reflect/goal_mode.py index edec793ae..2836de48f 100644 --- a/reflect/goal_mode.py +++ b/reflect/goal_mode.py @@ -7,10 +7,14 @@ ONCE = False _dir = os.path.dirname(os.path.abspath(__file__)) +import sys as _sys +_repo = os.path.dirname(_dir) +if _repo not in _sys.path: _sys.path.insert(0, _repo) +import paths STATE_FILE = '' def init(a): global STATE_FILE - STATE_FILE = a.get('goal_state') or os.environ.get('GOAL_STATE') or os.path.join(_dir, '../temp/goal_state.json') + STATE_FILE = a.get('goal_state') or os.environ.get('GOAL_STATE') or paths.temp_dir('goal_state.json') if not os.path.isabs(STATE_FILE): STATE_FILE = os.path.join(_dir, '..', STATE_FILE) # --- state 管理 --- def _load(): diff --git a/reflect/scheduler.py b/reflect/scheduler.py index a3ca622d3..de8cdbbe5 100644 --- a/reflect/scheduler.py +++ b/reflect/scheduler.py @@ -12,6 +12,10 @@ ONCE = False _dir = os.path.dirname(os.path.abspath(__file__)) +import sys as _sys +_repo = os.path.dirname(_dir) +if _repo not in _sys.path: _sys.path.insert(0, _repo) +import paths TASKS = os.path.join(_dir, '../sche_tasks') DONE = os.path.join(_dir, '../sche_tasks/done') _LOG = os.path.join(_dir, '../sche_tasks/scheduler.log') @@ -67,7 +71,7 @@ def check(): try: import sys; sys.path.insert(0, os.path.join(_dir, '../memory/L4_raw_sessions')) from compress_session import batch_process - raw_dir = os.path.join(_dir, '../temp/model_responses') + raw_dir = paths.temp_dir('model_responses') r = batch_process(raw_dir, dry_run=False) print(f'[L4 cron] {r}') except Exception as e: From 0afbfabd4b05d45fd500525e3573c933352c26f4 Mon Sep 17 00:00:00 2001 From: yiqi-017 <22300246009@m.fudan.edu.cn> Date: Thu, 30 Jul 2026 23:49:01 +0800 Subject: [PATCH 2/5] fix(paths): default packaged DATA_DIR to ~/GenericAgent_Data ~/Documents is unreliable on Windows: with OneDrive active the literal ~/Documents path is a ghost placeholder while the real folder is redirected under OneDrive, so os.mkdir raises WinError 2/3 (caused the "Setup Required" crash). Hidden AppData dirs are reliable but not discoverable, defeating the goal of letting users copy their reports out. The home root itself is never Known-Folder redirected and is visible, so use ~/GenericAgent_Data: writable on any machine (no username/locale hardcoding) and easy to find. --- paths.py | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/paths.py b/paths.py index 72e929316..77b400c00 100644 --- a/paths.py +++ b/paths.py @@ -5,25 +5,48 @@ - APP_DIR : read-only code + bundled default resources (this repo / the packaged bundle). On macOS this lives inside a signed .app and must stay untouched. - DATA_DIR: user-writable data root (task outputs under temp/, and memory/). Packaged - builds default it to ``~/Documents/GenericAgent`` so users can actually - reach their reports and customize memory; source/dev checkouts keep it - in-place (== APP_DIR) so the existing developer workflow is unchanged. + builds default it to ``~/GenericAgent_Data`` so users can both *reach* their + reports (it sits right at the top of the home folder, unlike hidden AppData) + and rely on it (the home root itself is never OneDrive/Known-Folder + redirected, unlike ~/Documents); source/dev checkouts keep it in-place + (== APP_DIR) so the existing developer workflow is unchanged. Resolution precedence for DATA_DIR (see ``data_dir_for``): 1. ``GA_DATA_DIR`` environment variable (explicit override, highest priority) 2. source/dev checkout (the core dir contains ``.git``) -> in-place (== core dir) - 3. packaged run -> ``~/Documents/GenericAgent`` + 3. packaged run -> ``~/GenericAgent_Data`` (see ``_user_data_root``) + +We deliberately do NOT use ``~/Documents``: it depends on the username, is localized, +and is frequently redirected to OneDrive/Known-Folder placeholders that reject +directory creation (observed WinError 2/3 on real machines). We also avoid hidden +platform app-data dirs (``%LOCALAPPDATA%`` / ``~/Library/Application Support``): they +are reliable but not discoverable, and the whole point here is that users can copy +their reports out. The home root (``~``/``%USERPROFILE%``) is both reliably writable +and visible, so a plain ``GenericAgent_Data`` folder there is the best of both. temp/ and memory/ are relocated together on purpose: the agent reaches memory via ``../memory`` relative to its temp cwd, so they must share the same data root. """ import os +import sys import shutil APP_DIR = os.path.dirname(os.path.abspath(__file__)) +def _user_data_root(): + """Always-writable *and* discoverable data root: ``~/GenericAgent_Data``. + + The home root is resolved from ``%USERPROFILE%`` (win) / ``$HOME`` (unix), so it + never hardcodes a username and is locale-independent. Unlike ~/Documents, the home + root itself is never OneDrive/Known-Folder redirected, so directory creation always + succeeds; unlike hidden AppData dirs, it is right in front of the user.""" + home = os.environ.get("USERPROFILE") if sys.platform == "win32" else None + home = home or os.path.expanduser("~") + return os.path.join(home, "GenericAgent_Data") + + def data_dir_for(core_dir): """Resolve the writable data root for a given code/core directory. @@ -35,10 +58,10 @@ def data_dir_for(core_dir): return os.path.abspath(os.path.expanduser(env)) core_dir = os.path.abspath(core_dir) # A git checkout means a source/developer run -> keep data in-place (no surprise - # relocation to Documents). Packaged bundles never ship .git. + # relocation). Packaged bundles never ship .git. if os.path.isdir(os.path.join(core_dir, ".git")): return core_dir - return os.path.join(os.path.expanduser("~"), "Documents", "GenericAgent") + return _user_data_root() DATA_DIR = data_dir_for(APP_DIR) From 796035c0677f83d25eeede0efd63912a4dbbc3db Mon Sep 17 00:00:00 2001 From: yiqi-017 <22300246009@m.fudan.edu.cn> Date: Fri, 31 Jul 2026 12:30:55 +0800 Subject: [PATCH 3/5] feat(paths): externalize prompt overrides, mykey, and user plugins P1 builds on the writable DATA_DIR split from P0 so packaged users can customize the app without touching the sealed bundle. Layer a user system prompt override from DATA_DIR, move mykey generation/loading/editing to the writable data side, create a data-side plugins drop-in directory, and add a settings entry to open the data folder directly. This makes prompt tuning, credential edits, memory tweaks, and custom plugins survive app upgrades while keeping bundled defaults intact. --- agentmain.py | 10 ++++ assets/configure_mykey.py | 16 ++++++- frontends/desktop/static/app.js | 13 ++++++ frontends/desktop/static/ga-web.js | 3 ++ frontends/desktop/static/i18n.js | 8 +++- frontends/desktop/static/index.html | 4 ++ frontends/desktop_bridge.py | 72 +++++++++++++++++++++++------ llmcore.py | 25 +++++++++- paths.py | 26 +++++++++++ plugins/hooks.py | 55 ++++++++++++++++++---- 10 files changed, 204 insertions(+), 28 deletions(-) diff --git a/agentmain.py b/agentmain.py index 31707db83..6510cae81 100644 --- a/agentmain.py +++ b/agentmain.py @@ -35,6 +35,16 @@ def load_tool_schema(suffix=''): def get_system_prompt(): with open(os.path.join(script_dir, f'assets/sys_prompt{lang_suffix}.txt'), 'r', encoding='utf-8') as f: prompt = f.read() + # P1-A: layer an optional user override from the writable data root on top of the + # bundled baseline. Kept as an append (not a replace) so the agent never loses its + # core operating contract; users add house rules without editing the read-only bundle. + try: + ov = paths.sys_prompt_override(lang_suffix) + if os.path.isfile(ov): + with open(ov, 'r', encoding='utf-8') as f: + extra = f.read().strip() + if extra: prompt += f"\n\n# ── User overrides (sys_prompt_override) ──\n{extra}\n" + except Exception: pass prompt += f"\nToday: {time.strftime('%Y-%m-%d %a')}\n" prompt += get_global_memory() return prompt diff --git a/assets/configure_mykey.py b/assets/configure_mykey.py index 755fae8fd..9aaaf3f69 100644 --- a/assets/configure_mykey.py +++ b/assets/configure_mykey.py @@ -24,7 +24,18 @@ } PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -MYKPY_PATH = os.path.join(PROJECT_ROOT, 'mykey.py') +# P1-D: write credentials to the user-writable data root (packaged bundle is read-only). +# Falls back to the project root for plain source checkouts where paths is unavailable +# or DATA_DIR == PROJECT_ROOT anyway. +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) +try: + import paths as _paths + MYKPY_PATH = _paths.mykey_path() + _BACKUP_DIR = _paths.DATA_DIR +except Exception: + MYKPY_PATH = os.path.join(PROJECT_ROOT, 'mykey.py') + _BACKUP_DIR = PROJECT_ROOT # ── 模型厂商定义 ─────────────────────────────────────────────────────────── @@ -1246,7 +1257,7 @@ def _backup_with_name(model_names, platform_ids): safe_name = 'mykey_backup' # 避免和源文件同名 if len(safe_name) > 100: safe_name = safe_name[:100] - backup_path = os.path.join(PROJECT_ROOT, f'{safe_name}.py') + backup_path = os.path.join(_BACKUP_DIR, f'{safe_name}.py') shutil.copy2(MYKPY_PATH, backup_path) return backup_path @@ -1371,6 +1382,7 @@ def main(): print(f"\n {C['green']}✓ 旧配置已备份至:{C['reset']} {C['dim']}{backup}{C['reset']}") # 写入 + os.makedirs(os.path.dirname(MYKPY_PATH), exist_ok=True) with open(MYKPY_PATH, 'w', encoding='utf-8') as f: f.write(content) print(f"\n {C['green']}✓ mykey.py 已生成!{C['reset']}") diff --git a/frontends/desktop/static/app.js b/frontends/desktop/static/app.js index f3b674e03..6ea417e4d 100644 --- a/frontends/desktop/static/app.js +++ b/frontends/desktop/static/app.js @@ -279,6 +279,9 @@ let bridgeUiOffline = false; selectGaRoot: () => rpc('app/path/selectGaRoot', {}), openMykeyTemplate: () => rpc('app/path/open', { kind: 'mykeyTemplate' }), openMykey: () => rpc('app/path/open', { kind: 'mykey' }), + openDataDir: () => rpc('app/path/open', { kind: 'dataDir' }), + openMemoryDir: () => rpc('app/path/open', { kind: 'memoryDir' }), + openSysPromptOverride: () => rpc('app/path/open', { kind: 'sysPromptOverride' }), startService, stopService, getServiceLogs: (id, tail = 200) => rpc('services/logs', { id, tail }), @@ -716,6 +719,16 @@ bindClick('import-memory-btn', async (e) => { showChanToast(t('err.memoryImport'), err.message || String(err), 'err'); } }); +// 打开可写数据文件夹(报告 temp/ 与记忆 memory/ 所在),方便用户取用/编辑。 +bindClick('open-data-dir-btn', async (e) => { + e.stopPropagation(); + try { + const res = await window.ga.openDataDir(); + if (res && res.ok === false) throw new Error(res.error || 'open failed'); + } catch (err) { + showChanToast(t('err.openDataDir'), err.message || String(err), 'err'); + } +}); const gaSourceCurrentEl = document.getElementById('ga-source-current'); const gaSourceClearBtn = document.getElementById('ga-source-clear-btn'); async function refreshGaSource() { diff --git a/frontends/desktop/static/ga-web.js b/frontends/desktop/static/ga-web.js index 960f0b922..2c8e8edf2 100644 --- a/frontends/desktop/static/ga-web.js +++ b/frontends/desktop/static/ga-web.js @@ -126,6 +126,9 @@ selectGaRoot: () => rpc('app/path/selectGaRoot', {}), openMykeyTemplate: () => rpc('app/path/open', { kind: 'mykeyTemplate' }), openMykey: () => rpc('app/path/open', { kind: 'mykey' }), + openDataDir: () => rpc('app/path/open', { kind: 'dataDir' }), + openMemoryDir: () => rpc('app/path/open', { kind: 'memoryDir' }), + openSysPromptOverride: () => rpc('app/path/open', { kind: 'sysPromptOverride' }), pollSession: (sessionId, afterId = 0) => rpc('session/poll', { sessionId, afterId }), rpc, onBridgeMessage: (cb) => on('bridge-message', cb), diff --git a/frontends/desktop/static/i18n.js b/frontends/desktop/static/i18n.js index 52f6c8a5d..351129a7f 100644 --- a/frontends/desktop/static/i18n.js +++ b/frontends/desktop/static/i18n.js @@ -29,10 +29,11 @@ 'customPreset.removeTitle': '删除', 'customPreset.editTitle': '编辑', 'builtinPreset.restoreBtn': '恢复默认预设', - 'set.appearance': '外观', 'set.plainUi': '素色', 'set.fontSize': '聊天字号', 'set.lang': '语言', 'set.model': '模型', 'set.addModel': '添加模型', 'set.features': '功能', 'set.importMykey': '导入模型配置', 'set.exportMykey': '导出模型配置', 'set.importMemory': '导入记忆与会话记录', 'set.gaSource': '接入外部 GenericAgent 源码', 'set.gaSourceClear': '改用内置版本', 'set.gaSourceCurrent': '当前接入', 'set.serviceManager': '后台服务管理', + 'set.appearance': '外观', 'set.plainUi': '素色', 'set.fontSize': '聊天字号', 'set.lang': '语言', 'set.model': '模型', 'set.addModel': '添加模型', 'set.features': '功能', 'set.importMykey': '导入模型配置', 'set.exportMykey': '导出模型配置', 'set.importMemory': '导入记忆与会话记录', 'set.gaSource': '接入外部 GenericAgent 源码', 'set.gaSourceClear': '改用内置版本', 'set.gaSourceCurrent': '当前接入', 'set.serviceManager': '后台服务管理', 'set.openDataDir': '打开数据文件夹', 'set.importMykeyTip': '从 mykey.py 导入已有的模型与 API 配置', 'set.exportMykeyTip': '将当前模型与 API 配置导出为 mykey.py', 'set.importMemoryTip': '从另一个 GenericAgent 根目录导入记忆、模型响应和会话记录', + 'set.openDataDirTip': '打开可写数据文件夹(报告 temp/ 与记忆 memory/ 所在),可直接取用或编辑', 'set.gaSourceTip': '选择包含 agentmain.py 的 GenericAgent 根目录,桌面端将使用其中的后端源码', 'set.gaSourceClearTip': '取消接入当前外部源码,切换回安装包内置的 GenericAgent 版本', 'set.serviceManagerTip': '查看并管理消息通道与后台进程的运行状态和日志', @@ -161,6 +162,7 @@ 'sys.mykeyExported': '模型配置已导出', 'sys.memoryImported': '记忆已导入', 'err.memoryImport': '导入记忆失败', + 'err.openDataDir': '打开数据文件夹失败', 'sys.memoryImportBackup': '原记忆已备份至', 'sys.memorySessions': '会话', 'sys.gaSourcePickTitle': '选择要接入的 GenericAgent 源码根目录(含 agentmain.py)', @@ -223,10 +225,11 @@ 'customPreset.removeTitle': 'Delete', 'customPreset.editTitle': 'Edit', 'builtinPreset.restoreBtn': 'Restore defaults', - 'set.appearance': 'Appearance', 'set.plainUi': 'Plain', 'set.fontSize': 'Chat font size', 'set.lang': 'Language', 'set.model': 'Model', 'set.addModel': 'Add model', 'set.features': 'Features', 'set.importMykey': 'Import model config', 'set.exportMykey': 'Export model config', 'set.importMemory': 'Import memory and sessions', 'set.gaSource': 'Connect external GenericAgent source', 'set.gaSourceClear': 'Use bundled version', 'set.gaSourceCurrent': 'Connected to', 'set.serviceManager': 'Service manager', + 'set.appearance': 'Appearance', 'set.plainUi': 'Plain', 'set.fontSize': 'Chat font size', 'set.lang': 'Language', 'set.model': 'Model', 'set.addModel': 'Add model', 'set.features': 'Features', 'set.importMykey': 'Import model config', 'set.exportMykey': 'Export model config', 'set.importMemory': 'Import memory and sessions', 'set.gaSource': 'Connect external GenericAgent source', 'set.gaSourceClear': 'Use bundled version', 'set.gaSourceCurrent': 'Connected to', 'set.serviceManager': 'Service manager', 'set.openDataDir': 'Open data folder', 'set.importMykeyTip': 'Import existing model and API settings from mykey.py', 'set.exportMykeyTip': 'Export the current model and API settings as mykey.py', 'set.importMemoryTip': 'Import memory, model responses, and sessions from another GenericAgent root directory', + 'set.openDataDirTip': 'Open the writable data folder (holds reports under temp/ and memory/) to copy or edit files directly', 'set.gaSourceTip': 'Select a GenericAgent root containing agentmain.py and use its backend source code', 'set.gaSourceClearTip': 'Disconnect the current external source and switch back to the bundled GenericAgent version', 'set.serviceManagerTip': 'View and manage message channels, background processes, and their logs', @@ -355,6 +358,7 @@ 'sys.mykeyExported': 'Model config exported', 'sys.memoryImported': 'Memory imported', 'err.memoryImport': 'Failed to import memory', + 'err.openDataDir': 'Failed to open data folder', 'sys.memoryImportBackup': 'Previous memory backed up to', 'sys.memorySessions': 'sessions', 'sys.gaSourcePickTitle': 'Select the GenericAgent source root to connect to (contains agentmain.py)', diff --git a/frontends/desktop/static/index.html b/frontends/desktop/static/index.html index 385a116b9..72998652e 100644 --- a/frontends/desktop/static/index.html +++ b/frontends/desktop/static/index.html @@ -661,6 +661,10 @@ + + + + +