From 3f62ecf0af77d9708ff8c72b17c43482b9afaac5 Mon Sep 17 00:00:00 2001
From: HeiGeAi <221471965+HeiGeAi@users.noreply.github.com>
Date: Fri, 24 Jul 2026 00:24:26 +0800
Subject: [PATCH 1/2] release: prepare Immortal Memory v1.3.0
---
CHANGELOG.md | 9 +
README.md | 38 +++-
core/VERSION | 2 +-
core/config.py | 7 +
core/control_data.py | 38 ++++
core/export_restore.py | 1 +
core/external_sources.py | 394 +++++++++++++++++++++++++++++++++
core/immortal.py | 75 +++++++
core/orchestrator.py | 45 ++++
docs/ROADMAP.md | 7 +
docs/releases/v1.3.0.md | 32 +++
pyproject.toml | 2 +-
tests/test_control_data.py | 2 +-
tests/test_external_sources.py | 218 ++++++++++++++++++
tests/test_version.py | 4 +-
15 files changed, 865 insertions(+), 9 deletions(-)
create mode 100644 core/external_sources.py
create mode 100644 docs/releases/v1.3.0.md
create mode 100644 tests/test_external_sources.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 16894f6..9299f2f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## 1.3.0
+
+- Add explicit, disabled-by-default imports for Git commit history, GitHub pull requests and issues, Claude Web exports, ChatGPT exports, and Cursor transcripts.
+- Keep external collection local, read-only, incremental, secret-redacted, and limited to user-registered paths.
+- Surface each external source and Feishu Mail separately in the control center without exposing local paths.
+- Include external-source deduplication state in portable recovery exports.
+- Run enabled external sources through the normal orchestrator and report partial or failed collection honestly.
+- Preserve every v1.1 Living Self, transaction, migration, recovery, and dashboard capability by releasing from the current `main` architecture.
+
## 1.1.1
- Mark a loaded daily LaunchAgent as requiring attention when its most recent exit code is nonzero, and expose the code as bounded scheduler evidence instead of showing a false healthy state.
diff --git a/README.md b/README.md
index 845f353..407f306 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
-
+



@@ -27,7 +27,7 @@
要让它说出像你会说的话、按你会做的判断去做事,只有一个办法:用**足够高密度的个人上下文**,把它从共识里硬拽出来。
-Immortal Memory 就是干这件事的本地系统。它不是一句 prompt,也不只是一个 Codex 技能。v1.1 用五个可追溯层完成这件事:
+Immortal Memory 就是干这件事的本地系统。它不是一句 prompt,也不只是一个 Codex 技能。v1.3 在 v1.1 Living Self 架构上,用五个可追溯层完成这件事:
1. **Claim**:把原始痕迹变成带出处、作用域、归因和隐私标签的可纠正主张。
2. **Living Self**:只用已确认 Claim 生成有版本的「当前自我」,旧版本永不原地覆盖。
@@ -110,6 +110,36 @@ immortal-memory agent-entry
immortal-memory agent-context "help me review this product idea" --print
```
+### 自动接入你的高质量语料
+
+v1.3 支持五类显式登记的外部来源。全部默认关闭,只有你登记的文件或目录才会被读取:
+
+```bash
+# 本地 Git 提交历史,只读取提交元数据和提交说明,不读取代码正文
+immortal-memory source register git "/absolute/path/to/projects"
+
+# GitHub PR 和 Issue,只读调用本机已登录的 gh CLI
+immortal-memory source register github "/absolute/path/to/projects"
+
+# 官方导出的对话文件或 Cursor transcript 目录
+immortal-memory source register claude-web "/absolute/path/to/conversations.json"
+immortal-memory source register chatgpt "/absolute/path/to/conversations.json"
+immortal-memory source register cursor "/absolute/path/to/cursor/agent-transcripts"
+
+# 检查登记范围并立即执行一次增量采集
+immortal-memory source list --json
+immortal-memory source collect --json
+```
+
+每条记录在写入前都会做凭证形态脱敏,并用本地 SQLite 状态去重。每日编排会自动调用已启用来源。控制台只展示来源健康、更新时间、新增数和错误数,不展示本机私有路径。飞书邮件继续保持显式授权,不会因为安装产品而自动采集。
+
+### 支持环境与 Agent
+
+- macOS:完整支持,包括 LaunchAgent 每日自动化、Codex 和 Claude Code 适配器。
+- Linux:核心 CLI、HTTP Agent Bridge、MCP、采集和测试受支持;需要自行配置 systemd 或 cron 调度。
+- Windows:当前不支持生产运行,因为索引锁依赖 `fcntl.flock`。建议使用 WSL2,并自行配置调度。
+- Agent:Codex、Claude Code、终端 Agent,以及任何能调用 CLI、HTTP 或 MCP 的本地 Agent。适配器只负责发现入口,真实记忆仍由同一套核心权限和上下文合同控制。
+
打开本地控制台:
```bash
@@ -212,7 +242,7 @@ immortal-memory agent-context "release acceptance" --print
```bash
CLEAN_HOME="$(mktemp -d /tmp/immortal-clean-home.XXXXXX)"
python3 -m venv "$CLEAN_HOME/venv"
-WHEEL="$(find "$(pwd)/dist" -maxdepth 1 -name 'immortal_memory-1.1.1-*.whl' | head -n 1)"
+WHEEL="$(find "$(pwd)/dist" -maxdepth 1 -name 'immortal_memory-1.3.0-*.whl' | head -n 1)"
HOME="$CLEAN_HOME" "$CLEAN_HOME/venv/bin/python" -m pip install "$WHEEL"
HOME="$CLEAN_HOME" "$CLEAN_HOME/venv/bin/immortal-memory" init --owner-display-name "Clean Install" --alias "clean"
HOME="$CLEAN_HOME" "$CLEAN_HOME/venv/bin/immortal-memory" train --smoke
@@ -314,7 +344,7 @@ There is a sharper problem underneath. A large model is wired to produce **confi
To make it say what you would say and decide the way you would decide, there is only one move: push **high-density personal context** at it until it gets dragged out of the consensus prior.
-Immortal Memory is the local system that does exactly that. It is not a prompt, and not only a Codex skill. v1.1 implements five traceable layers:
+Immortal Memory is the local system that does exactly that. It is not a prompt, and not only a Codex skill. v1.3 builds on the v1.1 Living Self architecture and implements five traceable layers:
1. **Claim** turns source traces into correctable assertions with evidence, scope, attribution, and privacy labels.
2. **Living Self** builds a versioned current model from confirmed Claims without overwriting history.
diff --git a/core/VERSION b/core/VERSION
index 524cb55..f0bb29e 100644
--- a/core/VERSION
+++ b/core/VERSION
@@ -1 +1 @@
-1.1.1
+1.3.0
diff --git a/core/config.py b/core/config.py
index fb076de..7f4758d 100644
--- a/core/config.py
+++ b/core/config.py
@@ -100,6 +100,13 @@
},
},
"extra_sources": [],
+ "external_sources": {
+ "git": {"enabled": False, "paths": [], "max_commits": 200},
+ "github": {"enabled": False, "paths": [], "max_items": 50},
+ "claude-web": {"enabled": False, "paths": []},
+ "chatgpt": {"enabled": False, "paths": []},
+ "cursor": {"enabled": False, "paths": []},
+ },
}
diff --git a/core/control_data.py b/core/control_data.py
index 48fe5c5..0719f82 100644
--- a/core/control_data.py
+++ b/core/control_data.py
@@ -394,6 +394,13 @@ def _source_status(value: Any, *, has_success: bool = False) -> str:
def sources(self) -> dict[str, Any]:
state = self._read_json(self.immortal_dir / "orchestrator_state.json")
web = self._read_json(self.immortal_dir / "web" / "state.json")
+ config = self._read_json(self.immortal_dir / "config.json")
+ feishu = config.get("feishu") if isinstance(config.get("feishu"), dict) else {}
+ daily_sources = {item.strip() for item in str(feishu.get("daily_sources") or "").split(",") if item.strip()}
+ external_config = config.get("external_sources") if isinstance(config.get("external_sources"), dict) else {}
+ external_state = self._read_json(self.immortal_dir / "external_sources" / "state.json")
+ external_results = external_state.get("sources") if isinstance(external_state.get("sources"), dict) else {}
+ external_last = str(external_state.get("generated_at") or "")
source_specs = [
{
"id": "local",
@@ -416,6 +423,37 @@ def sources(self) -> dict[str, Any]:
"errors": sum("feishu" in str(item) for item in (state.get("errors") or [])),
"evidence": "feishu/state.json",
},
+ {
+ "id": "feishu-mail",
+ "label": "飞书邮件(显式授权)",
+ "last": str(state.get("last_feishu_collect") or "") if "mail" in daily_sources else "",
+ "status": self._source_status(state.get("last_feishu_status"), has_success=bool(state.get("last_feishu_collect")))
+ if "mail" in daily_sources else "skipped",
+ "increment": 0,
+ "errors": 0,
+ "evidence": "config.json + feishu/state.json",
+ },
+ *[
+ {
+ "id": source_id,
+ "label": label,
+ "last": external_last if enabled else "",
+ "status": self._source_status(result.get("status"), has_success=bool(external_last)) if enabled else "skipped",
+ "increment": int(result.get("records_written") or 0),
+ "errors": int(result.get("error_count") or 0),
+ "evidence": "config.json + external_sources/state.json",
+ }
+ for kind, source_id, label in (
+ ("git", "git-history", "Git 本地历史"),
+ ("github", "github-history", "GitHub PR / Issue"),
+ ("claude-web", "claude-web", "Claude Web 导出"),
+ ("chatgpt", "chatgpt", "ChatGPT 导出"),
+ ("cursor", "cursor", "Cursor 导出"),
+ )
+ for configured in [external_config.get(kind) if isinstance(external_config.get(kind), dict) else {}]
+ for enabled in [bool(configured.get("enabled") and configured.get("paths"))]
+ for result in [external_results.get(kind) if isinstance(external_results.get(kind), dict) else {}]
+ ],
{
"id": "web",
"label": "网页访问",
diff --git a/core/export_restore.py b/core/export_restore.py
index 07a9c64..128c092 100644
--- a/core/export_restore.py
+++ b/core/export_restore.py
@@ -54,6 +54,7 @@
"people",
"quality",
"relationships",
+ "external_sources",
# timeline.html / dashboard.html / brief 已于 2026-06-14 停用并删除;
# 留在白名单里会让每个新导出都带 missing 警告,strict 校验对完好数据永久 FAIL
"digests",
diff --git a/core/external_sources.py b/core/external_sources.py
new file mode 100644
index 0000000..49b5d9c
--- /dev/null
+++ b/core/external_sources.py
@@ -0,0 +1,394 @@
+#!/usr/bin/env python3
+"""Read-only imports for external sources registered by an explicit local path."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import re
+import sqlite3
+import subprocess
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Iterable
+
+from index_writer import append_jsonl_records
+from maintenance_gate import writer_access
+from redact_common import redact_tree
+
+
+SUPPORTED_KINDS = ("git", "github", "claude-web", "chatgpt", "cursor")
+
+
+def iso_utc(value: Any = None) -> str:
+ if isinstance(value, (int, float)):
+ return datetime.fromtimestamp(value, tz=timezone.utc).isoformat().replace("+00:00", "Z")
+ text = str(value or "").strip()
+ return text or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+
+
+def register_source(settings: dict[str, Any], kind: str, path: str | Path) -> dict[str, Any]:
+ if kind not in SUPPORTED_KINDS:
+ raise ValueError(f"unsupported source kind: {kind}")
+ resolved = Path(path).expanduser().resolve()
+ if not resolved.exists():
+ raise ValueError(f"source path does not exist: {resolved}")
+ source = settings.setdefault("external_sources", {}).setdefault(kind, {})
+ paths = [str(Path(item).expanduser().resolve()) for item in source.get("paths") or []]
+ if str(resolved) not in paths:
+ paths.append(str(resolved))
+ source.update({"enabled": True, "paths": paths})
+ return settings
+
+
+def unregister_source(settings: dict[str, Any], kind: str, path: str | Path) -> dict[str, Any]:
+ resolved = str(Path(path).expanduser().resolve())
+ external = settings.get("external_sources") if isinstance(settings.get("external_sources"), dict) else {}
+ source = external.get(kind) if isinstance(external.get(kind), dict) else {}
+ source["paths"] = [
+ item for item in source.get("paths") or []
+ if str(Path(item).expanduser().resolve()) != resolved
+ ]
+ source["enabled"] = bool(source["paths"])
+ return settings
+
+
+def _inside(path: Path, root: Path) -> bool:
+ try:
+ path.resolve().relative_to(root.resolve())
+ return True
+ except (OSError, ValueError):
+ return False
+
+
+def discover_git_repositories(root: str | Path, *, max_depth: int = 4) -> list[Path]:
+ resolved = Path(root).expanduser().resolve()
+ if (resolved / ".git").exists():
+ return [resolved]
+ repositories: set[Path] = set()
+ for current, dirs, _files in os.walk(resolved, followlinks=False):
+ current_path = Path(current)
+ try:
+ depth = len(current_path.relative_to(resolved).parts)
+ except ValueError:
+ dirs[:] = []
+ continue
+ if depth >= max_depth:
+ dirs[:] = []
+ dirs[:] = [name for name in dirs if name not in {".git", "node_modules", ".venv", "venv"}]
+ if (current_path / ".git").exists() and _inside(current_path, resolved):
+ repositories.add(current_path.resolve())
+ dirs[:] = []
+ return sorted(repositories)
+
+
+def _connection(vault: Path) -> sqlite3.Connection:
+ state_dir = vault / "external_sources"
+ state_dir.mkdir(parents=True, exist_ok=True)
+ connection = sqlite3.connect(state_dir / "state.sqlite3")
+ connection.execute(
+ "create table if not exists seen (source text not null, item_key text not null, "
+ "first_seen_at text not null, primary key(source,item_key))"
+ )
+ return connection
+
+
+def _record(*, source: str, record_type: str, timestamp: Any, content: str,
+ item_id: str, role: str = "", project: str = "", session_id: str = "",
+ metadata: dict[str, Any] | None = None) -> dict[str, Any]:
+ record_id = hashlib.sha256(f"{source}|{item_id}".encode()).hexdigest()[:24]
+ return {
+ "id": f"{source}-{record_id}", "source": source, "project": project,
+ "session_id": session_id, "timestamp": iso_utc(timestamp), "type": record_type,
+ "role": role, "content": content, "metadata": metadata or {}, "_dedup_key": item_id,
+ }
+
+
+def _append(vault: Path, records: Iterable[dict[str, Any]]) -> int:
+ clean = [redact_tree(item) for item in records]
+ if not clean:
+ return 0
+ by_day: dict[str, list[dict[str, Any]]] = {}
+ for item in clean:
+ try:
+ day = datetime.fromisoformat(str(item["timestamp"]).replace("Z", "+00:00")).astimezone().strftime("%Y-%m-%d")
+ except ValueError:
+ day = datetime.now().astimezone().strftime("%Y-%m-%d")
+ by_day.setdefault(day, []).append(item)
+ with writer_access(vault):
+ append_jsonl_records(vault / "index.jsonl", clean, maintenance_held=True)
+ daily = vault / "daily"
+ daily.mkdir(parents=True, exist_ok=True)
+ for day, items in by_day.items():
+ with (daily / f"{day}.jsonl").open("a", encoding="utf-8") as handle:
+ for item in items:
+ handle.write(json.dumps({k: v for k, v in item.items() if not k.startswith("_")}, ensure_ascii=False) + "\n")
+ return len(clean)
+
+
+def _collect_records(vault: Path, records: Iterable[dict[str, Any]]) -> int:
+ connection = _connection(vault)
+ pending: list[dict[str, Any]] = []
+ try:
+ for item in records:
+ cursor = connection.execute(
+ "insert or ignore into seen(source,item_key,first_seen_at) values(?,?,?)",
+ (item["source"], item["_dedup_key"], iso_utc()),
+ )
+ if cursor.rowcount:
+ pending.append(item)
+ written = _append(vault, pending)
+ connection.commit()
+ return written
+ finally:
+ connection.close()
+
+
+def _git_records(repo: Path, max_commits: int) -> list[dict[str, Any]]:
+ result = subprocess.run(
+ ["git", "log", f"--max-count={max(1, int(max_commits))}", "--date=iso-strict",
+ "--format=%H%x1f%aI%x1f%an%x1f%ae%x1f%B%x1e"],
+ cwd=repo, text=True, capture_output=True, timeout=60,
+ )
+ if result.returncode:
+ return []
+ records = []
+ for block in result.stdout.split("\x1e"):
+ fields = block.strip().split("\x1f", 4)
+ if len(fields) != 5:
+ continue
+ commit, timestamp, author_name, author_email, message = fields
+ records.append(_record(
+ source="git-history", record_type="git-commit", timestamp=timestamp,
+ content=message.strip(), item_id=f"{repo.resolve()}|{commit}", role="author",
+ project=repo.name, session_id=commit,
+ metadata={"repository": repo.name, "commit": commit,
+ "author_name": author_name, "author_email": author_email},
+ ))
+ return records
+
+
+def collect_git_history(vault: str | Path, roots: Iterable[str | Path], *, max_commits: int = 200) -> dict[str, Any]:
+ repositories: set[Path] = set()
+ for root in roots:
+ repositories.update(discover_git_repositories(root))
+ records = [item for repo in sorted(repositories) for item in _git_records(repo, max_commits)]
+ return {"repositories": len(repositories), "records_written": _collect_records(Path(vault).expanduser(), records), "status": "success"}
+
+
+def github_slug_from_remote(remote: str) -> str:
+ match = re.match(r"(?:https://github\.com/|git@github\.com:)([^/\s]+/[^/\s]+?)(?:\.git)?$", str(remote or "").strip())
+ return match.group(1) if match else ""
+
+
+def _github_remote(repo: Path) -> str:
+ result = subprocess.run(["git", "config", "--get", "remote.origin.url"], cwd=repo,
+ text=True, capture_output=True, timeout=15)
+ return github_slug_from_remote(result.stdout) if result.returncode == 0 else ""
+
+
+def _gh_list(repository: str, kind: str, limit: int) -> tuple[list[dict[str, Any]], bool]:
+ command = "pr" if kind == "pull-request" else "issue"
+ fields = "number,title,body,state,createdAt,updatedAt,url"
+ try:
+ result = subprocess.run(
+ ["gh", command, "list", "--repo", repository, "--state", "all", "--limit", str(limit), "--json", fields],
+ text=True, capture_output=True, timeout=90,
+ )
+ except (OSError, subprocess.TimeoutExpired):
+ return [], False
+ if result.returncode:
+ if kind == "issue" and "disabled issues" in result.stderr.lower():
+ return [], True
+ return [], False
+ try:
+ data = json.loads(result.stdout)
+ except json.JSONDecodeError:
+ return [], False
+ return (data if isinstance(data, list) else []), True
+
+
+def github_items_to_records(repository: str, kind: str, items: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
+ records = []
+ for item in items:
+ if not isinstance(item, dict) or not item.get("number"):
+ continue
+ updated = str(item.get("updatedAt") or item.get("createdAt") or "")
+ title, body = str(item.get("title") or "").strip(), str(item.get("body") or "").strip()
+ records.append(_record(
+ source="github-history", record_type=kind, timestamp=updated,
+ content=title + (f"\n\n{body}" if body else ""),
+ item_id=f"{repository}|{kind}|{item['number']}|{updated}", role="author",
+ project=repository, session_id=f"{kind}-{item['number']}",
+ metadata={"repository": repository, "number": int(item["number"]),
+ "state": str(item.get("state") or ""), "url": str(item.get("url") or "")},
+ ))
+ return records
+
+
+def collect_github_history(vault: str | Path, roots: Iterable[str | Path], *, max_items: int = 50) -> dict[str, Any]:
+ repositories: set[Path] = set()
+ for root in roots:
+ repositories.update(discover_git_repositories(root))
+ records, errors, count = [], 0, 0
+ for repo in sorted(repositories):
+ slug = _github_remote(repo)
+ if not slug:
+ continue
+ count += 1
+ for kind in ("pull-request", "issue"):
+ items, ok = _gh_list(slug, kind, max(1, int(max_items)))
+ if ok:
+ records.extend(github_items_to_records(slug, kind, items))
+ else:
+ errors += 1
+ return {"repositories": count, "records_written": _collect_records(Path(vault).expanduser(), records),
+ "status": "partial" if errors else "success", "error_count": errors}
+
+
+def parse_chatgpt_export(path: str | Path) -> list[dict[str, Any]]:
+ data = json.loads(Path(path).expanduser().read_text(encoding="utf-8"))
+ records = []
+ for conversation in data if isinstance(data, list) else []:
+ if not isinstance(conversation, dict):
+ continue
+ cid, title = str(conversation.get("id") or conversation.get("conversation_id") or ""), str(conversation.get("title") or "")
+ mapping = conversation.get("mapping") if isinstance(conversation.get("mapping"), dict) else {}
+ for node in mapping.values():
+ message = node.get("message") if isinstance(node, dict) and isinstance(node.get("message"), dict) else None
+ if not message:
+ continue
+ content = message.get("content") if isinstance(message.get("content"), dict) else {}
+ text = "\n".join(part for part in content.get("parts") or [] if isinstance(part, str)).strip()
+ if not text:
+ continue
+ mid = str(message.get("id") or hashlib.sha256(text.encode()).hexdigest()[:16])
+ author = message.get("author") if isinstance(message.get("author"), dict) else {}
+ records.append(_record(source="chatgpt-conversation", record_type="conversation-message",
+ timestamp=message.get("create_time") or conversation.get("create_time"), content=text,
+ item_id=f"{cid}|{mid}", role=str(author.get("role") or ""), project=title,
+ session_id=cid, metadata={"conversation_title": title}))
+ return sorted(records, key=lambda item: item["timestamp"])
+
+
+def parse_claude_web_export(path: str | Path) -> list[dict[str, Any]]:
+ data = json.loads(Path(path).expanduser().read_text(encoding="utf-8"))
+ records = []
+ for conversation in data if isinstance(data, list) else []:
+ if not isinstance(conversation, dict):
+ continue
+ cid, title = str(conversation.get("uuid") or ""), str(conversation.get("name") or "")
+ for message in conversation.get("chat_messages") or []:
+ if not isinstance(message, dict):
+ continue
+ text = str(message.get("text") or "").strip()
+ if not text:
+ text = "\n".join(str(block.get("text") or "") for block in message.get("content") or []
+ if isinstance(block, dict) and block.get("type") == "text").strip()
+ if not text:
+ continue
+ sender = str(message.get("sender") or "")
+ role = "user" if sender in {"human", "user"} else "assistant" if sender == "assistant" else sender
+ mid = str(message.get("uuid") or hashlib.sha256(text.encode()).hexdigest()[:16])
+ records.append(_record(source="claude-web-conversation", record_type="conversation-message",
+ timestamp=message.get("created_at") or conversation.get("created_at"), content=text,
+ item_id=f"{cid}|{mid}", role=role, project=title, session_id=cid,
+ metadata={"conversation_title": title}))
+ return sorted(records, key=lambda item: item["timestamp"])
+
+
+def parse_cursor_export(path: str | Path) -> list[dict[str, Any]]:
+ export = Path(path).expanduser()
+ records = []
+ with export.open(encoding="utf-8", errors="ignore") as handle:
+ for line_no, line in enumerate(handle, 1):
+ try:
+ item = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if not isinstance(item, dict):
+ continue
+ content = item.get("content")
+ message = item.get("message") if isinstance(item.get("message"), dict) else {}
+ if not content:
+ content = "\n".join(str(block.get("text") or "") for block in message.get("content") or []
+ if isinstance(block, dict) and block.get("type") == "text")
+ text = str(content or "").strip()
+ if not text:
+ continue
+ parts = export.parts
+ project = str(item.get("workspace") or item.get("project") or "")
+ session = str(item.get("session_id") or "")
+ if "agent-transcripts" in parts:
+ marker = parts.index("agent-transcripts")
+ project = project or (parts[marker - 1] if marker else "")
+ session = session or export.parent.name
+ records.append(_record(source="cursor-conversation", record_type="conversation-message",
+ timestamp=item.get("timestamp"), content=text,
+ item_id=str(item.get("id") or f"{export.resolve()}:{line_no}"),
+ role=str(item.get("role") or ""), project=project, session_id=session,
+ metadata={"import_file": export.name}))
+ return records
+
+
+def _registered_files(source: dict[str, Any], kind: str) -> list[Path]:
+ files: set[Path] = set()
+ for configured in source.get("paths") or []:
+ path = Path(configured).expanduser().resolve()
+ if path.is_file():
+ files.add(path)
+ elif path.is_dir():
+ pattern = "conversations.json" if kind in {"chatgpt", "claude-web"} else "*.jsonl"
+ files.update(candidate.resolve() for candidate in path.rglob(pattern)
+ if candidate.is_file() and _inside(candidate, path))
+ return sorted(files)
+
+
+def collect_registered_sources(settings: dict[str, Any], vault: str | Path) -> dict[str, Any]:
+ vault_path = Path(vault).expanduser()
+ external = settings.get("external_sources") if isinstance(settings.get("external_sources"), dict) else {}
+ result: dict[str, Any] = {}
+ for kind, collector, limit_key, default in (
+ ("git", collect_git_history, "max_commits", 200),
+ ("github", collect_github_history, "max_items", 50),
+ ):
+ source = external.get(kind) if isinstance(external.get(kind), dict) else {}
+ result[kind] = collector(vault_path, source.get("paths") or [], **{limit_key: int(source.get(limit_key) or default)}) \
+ if source.get("enabled") and source.get("paths") else {"repositories": 0, "records_written": 0, "status": "disabled"}
+ for kind, parser in (("claude-web", parse_claude_web_export), ("chatgpt", parse_chatgpt_export), ("cursor", parse_cursor_export)):
+ source = external.get(kind) if isinstance(external.get(kind), dict) else {}
+ if not source.get("enabled") or not source.get("paths"):
+ result[kind] = {"files": 0, "records_written": 0, "status": "disabled"}
+ continue
+ files, records, errors = _registered_files(source, kind), [], 0
+ for path in files:
+ try:
+ records.extend(parser(path))
+ except (OSError, ValueError, json.JSONDecodeError):
+ errors += 1
+ result[kind] = {"files": len(files), "records_written": _collect_records(vault_path, records),
+ "status": "partial" if errors else "success", "error_count": errors}
+ state_dir = vault_path / "external_sources"
+ state_dir.mkdir(parents=True, exist_ok=True)
+ temporary = state_dir / "state.json.tmp"
+ temporary.write_text(json.dumps({"generated_at": iso_utc(), "sources": result}, ensure_ascii=False,
+ indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ temporary.replace(state_dir / "state.json")
+ return result
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description="Collect explicitly registered external sources")
+ parser.add_argument("--vault", default=str(Path.home() / ".immortal"))
+ parser.add_argument("--git-root", action="append", default=[])
+ parser.add_argument("--max-commits", type=int, default=200)
+ args = parser.parse_args(argv)
+ print(json.dumps(collect_git_history(args.vault, args.git_root, max_commits=args.max_commits),
+ ensure_ascii=False, sort_keys=True))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/core/immortal.py b/core/immortal.py
index e7cd176..b64ea90 100755
--- a/core/immortal.py
+++ b/core/immortal.py
@@ -45,6 +45,7 @@
migration_backup_gate,
restore_check,
)
+from external_sources import collect_registered_sources, register_source, unregister_source
from index_writer import append_jsonl_records
from judgment_store import InvalidJudgmentOperation, JudgmentStore
from maintenance_gate import writer_access
@@ -224,6 +225,62 @@ def command_run(_args=None) -> int:
return run_script("orchestrator.py")
+def _external_source_payload(config: dict, kind: str) -> dict:
+ external = config.get("external_sources") if isinstance(config.get("external_sources"), dict) else {}
+ source = external.get(kind) if isinstance(external.get(kind), dict) else {}
+ return {
+ "kind": kind,
+ "enabled": bool(source.get("enabled")),
+ "paths": [str(item) for item in source.get("paths") or []],
+ }
+
+
+def command_external_source_register(args: argparse.Namespace) -> int:
+ config = load_config()
+ try:
+ register_source(config, args.kind, args.path)
+ except ValueError as exc:
+ print(f"Source registration failed: {exc}", file=sys.stderr)
+ return 2
+ save_config(config)
+ payload = _external_source_payload(config, args.kind)
+ print(json.dumps(payload, ensure_ascii=False, sort_keys=True) if args.json else f"Registered {args.kind}: {args.path}")
+ return 0
+
+
+def command_external_source_unregister(args: argparse.Namespace) -> int:
+ config = load_config()
+ unregister_source(config, args.kind, args.path)
+ save_config(config)
+ payload = _external_source_payload(config, args.kind)
+ print(json.dumps(payload, ensure_ascii=False, sort_keys=True) if args.json else f"Unregistered {args.kind}: {args.path}")
+ return 0
+
+
+def command_external_source_list(args: argparse.Namespace) -> int:
+ config = load_config()
+ payload = {kind: _external_source_payload(config, kind) for kind in (
+ "git", "github", "claude-web", "chatgpt", "cursor"
+ )}
+ if args.json:
+ print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
+ else:
+ for kind, source in payload.items():
+ print(f"{kind}: {'enabled' if source['enabled'] else 'disabled'} ({len(source['paths'])} paths)")
+ return 0
+
+
+def command_external_source_collect(args: argparse.Namespace) -> int:
+ config = load_config()
+ payload = collect_registered_sources(config, configured_vault_dir(config))
+ if args.json:
+ print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
+ else:
+ for kind, result in payload.items():
+ print(f"{kind}: {int(result.get('records_written') or 0)} new records")
+ return 0
+
+
def command_cards(args) -> int:
try:
store = JudgmentStore(configured_vault_dir())
@@ -1976,6 +2033,24 @@ def build_parser() -> argparse.ArgumentParser:
sub = parser.add_subparsers(dest="command")
sub.add_parser("status", help="Show current memory library state").set_defaults(func=command_status)
+ source = sub.add_parser("source", help="Register and collect explicitly scoped external sources")
+ source_sub = source.add_subparsers(dest="source_command", required=True)
+ source_list = source_sub.add_parser("list", help="List registered source paths without reading them")
+ source_list.add_argument("--json", action="store_true")
+ source_list.set_defaults(func=command_external_source_list)
+ source_register = source_sub.add_parser("register", help="Register one existing path for a source kind")
+ source_register.add_argument("kind", choices=["git", "github", "claude-web", "chatgpt", "cursor"])
+ source_register.add_argument("path")
+ source_register.add_argument("--json", action="store_true")
+ source_register.set_defaults(func=command_external_source_register)
+ source_unregister = source_sub.add_parser("unregister", help="Remove one registered source path")
+ source_unregister.add_argument("kind", choices=["git", "github", "claude-web", "chatgpt", "cursor"])
+ source_unregister.add_argument("path")
+ source_unregister.add_argument("--json", action="store_true")
+ source_unregister.set_defaults(func=command_external_source_unregister)
+ source_collect = source_sub.add_parser("collect", help="Collect all enabled registered sources")
+ source_collect.add_argument("--json", action="store_true")
+ source_collect.set_defaults(func=command_external_source_collect)
init = sub.add_parser("init", help="Initialize this installation with the current user's identity and vault config")
init.add_argument("--owner-name", default=None, help="Legal or internal name for the owner")
init.add_argument("--owner-display-name", default=None, help="Display name used in generated roles")
diff --git a/core/orchestrator.py b/core/orchestrator.py
index 61c7492..8a11d29 100644
--- a/core/orchestrator.py
+++ b/core/orchestrator.py
@@ -41,6 +41,7 @@
REQUIRED_FAILURES = {
"collect failed",
+ "external source collect failed",
"search index sync failed",
"claims migration failed",
"context compile failed",
@@ -326,6 +327,40 @@ def parse_collect_output(output: str) -> dict:
return {"total_new": total_new, "by_source": by_source}
+def external_source_collect():
+ log("=== 阶段 1E: 受控外部来源同步 ===")
+ ok, out = run_script("immortal.py", "source", "collect", "--json", timeout=900)
+ if not ok:
+ log(f"受控外部来源同步失败: {out.strip()[:300]}")
+ return False, {"records_written": 0, "sources": {}}
+ try:
+ sources = json.loads(out)
+ except json.JSONDecodeError:
+ log("受控外部来源同步返回无效 JSON")
+ return False, {"records_written": 0, "sources": {}}
+ if not isinstance(sources, dict):
+ return False, {"records_written": 0, "sources": {}}
+ total = sum(
+ int(value.get("records_written") or 0)
+ for value in sources.values()
+ if isinstance(value, dict)
+ )
+ partial_sources = sorted(
+ name for name, value in sources.items()
+ if isinstance(value, dict)
+ and str(value.get("status") or "").lower() not in {"success", "disabled", "skipped", "not_due"}
+ )
+ if partial_sources:
+ log(f"受控外部来源存在异常: {', '.join(partial_sources)}")
+ return False, {
+ "records_written": total,
+ "sources": sources,
+ "partial_sources": partial_sources,
+ }
+ log(f"受控外部来源同步成功: 新增 {total} 条")
+ return True, {"records_written": total, "sources": sources, "partial_sources": []}
+
+
def days_since(iso_str: str) -> float:
"""距离指定 ISO 时间戳过了多少天。"""
if not iso_str:
@@ -1083,6 +1118,13 @@ def run_main():
errors.append("collect failed")
telemetry_stage("external", "外部来源同步", errors)
+ external_ok, external_info = external_source_collect()
+ external_new = int(external_info.get("records_written") or 0)
+ if external_ok:
+ state["last_external_source_collect"] = now_iso
+ else:
+ errors.append("external source collect failed")
+
web_new = 0
web_due_hours = hours_since(state.get("last_web_collect"))
if web_due_hours >= WEB_CAPTURE_INTERVAL_HOURS:
@@ -1258,6 +1300,7 @@ def run_main():
state["last_run_new_records"] = collect_info.get("total_new", 0)
state["last_run_feishu_new_records"] = feishu_new
state["last_run_web_new_records"] = web_new
+ state["last_run_external_new_records"] = external_new
state["errors"] = errors[-10:] # 保留最近 10 个错误
save_state(state)
@@ -1336,6 +1379,7 @@ def run_main():
log(f"========= 编排器完成 =========")
log(f" 本次新增: {collect_info.get('total_new', 0)} 条")
+ log(f" 受控外部来源新增: {external_new} 条")
log(f" 网页新增: {web_new} 条")
log(f" 飞书新增: {feishu_new} 条")
log(f" 总记录数: {state['total_records']:,}")
@@ -1350,6 +1394,7 @@ def run_main():
"errors": errors,
"results": {
"new_records": collect_info.get("total_new", 0),
+ "external_new_records": external_new,
"web_new_records": web_new,
"feishu_new_records": feishu_new,
"total_records": state["total_records"],
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index e8ee017..9243925 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -24,6 +24,13 @@
- Storage budgeting, retention previews, and safer evidence deletion workflows.
- Stable local API and MCP transport over the same authorization and context contracts.
+## v1.3 Controlled source automation
+
+- Explicit, disabled-by-default Git, GitHub, Claude Web, ChatGPT, and Cursor sources.
+- Incremental local deduplication, secret redaction, bounded collection, and source-level health evidence.
+- Feishu Mail remains a separate opt-in source instead of being folded into a generic success state.
+- External source state is part of portable recovery and the normal orchestration loop.
+
## Later, only after local trust is proven
- Additional connector SDKs and audited adapters.
diff --git a/docs/releases/v1.3.0.md b/docs/releases/v1.3.0.md
new file mode 100644
index 0000000..eee9db6
--- /dev/null
+++ b/docs/releases/v1.3.0.md
@@ -0,0 +1,32 @@
+# Immortal Memory v1.3.0
+
+v1.3.0 turns external knowledge intake into a controlled product capability while preserving the full Living Self architecture already present on `main`.
+
+## What changed
+
+- Git commit history can be collected from explicitly registered roots. It reads commit metadata and messages, not repository file bodies.
+- GitHub pull requests and issues can be collected read-only through an already authenticated `gh` CLI.
+- Claude Web and ChatGPT official exports, plus explicit Cursor JSONL transcripts, can be imported locally.
+- Every connector is disabled by default, limited to registered paths, incrementally deduplicated, and passed through credential-shape redaction before persistence.
+- The control center reports each connector separately without returning local filesystem paths.
+- Feishu Mail is shown as an explicit opt-in source.
+- External-source state is included in portable recovery exports and enabled sources run in the normal orchestrator.
+
+## Compatibility
+
+- Python 3.9 through 3.12 are covered by CI.
+- macOS is the complete production target, including LaunchAgent automation.
+- Linux supports the core CLI, HTTP bridge, MCP, collection, and tests. Scheduling must be configured separately.
+- Native Windows is not a production target because the index lock uses `fcntl.flock`. WSL2 is the practical Windows path.
+- Codex, Claude Code, terminal agents, and other local agents can consume the same bounded context through CLI, HTTP, or MCP.
+
+## Safety boundaries
+
+- Registration and collection are local operations. GitHub collection does not fetch, push, comment, merge, or modify repository state.
+- Chat exports are never discovered globally. A user must register an existing file or directory.
+- A successful local recovery export is not automatically a share-safe artifact. Offsite delivery still requires encryption and a verified restore drill.
+- A missing ChatGPT export is reported as disabled or skipped, not as success.
+
+## Verification contract
+
+The release is accepted only after the private-data scan, Python compilation, smoke test, full regression suite, P0 scenarios, wheel installation in an isolated home, remote CI, and a clean-clone check all succeed.
diff --git a/pyproject.toml b/pyproject.toml
index 6e34fc8..90fb465 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "immortal-memory"
-version = "1.1.1"
+version = "1.3.0"
description = "Local-first personal memory layer for AI agents"
requires-python = ">=3.9"
license = "MIT"
diff --git a/tests/test_control_data.py b/tests/test_control_data.py
index eb58166..2780654 100644
--- a/tests/test_control_data.py
+++ b/tests/test_control_data.py
@@ -331,7 +331,7 @@ def test_memory_and_source_routes_are_live(tmp_path):
server.server_close()
assert source_status == 200
- assert len(sources["items"]) == 5
+ assert len(sources["items"]) == 11
assert list_status == 200
assert len(page["items"]) == 2
assert detail_status == 200
diff --git a/tests/test_external_sources.py b/tests/test_external_sources.py
new file mode 100644
index 0000000..a3023bf
--- /dev/null
+++ b/tests/test_external_sources.py
@@ -0,0 +1,218 @@
+from __future__ import annotations
+
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+import config
+import export_restore
+import external_sources
+import immortal
+import orchestrator
+from control_data import ControlData
+
+
+def _git(repo: Path, *args: str) -> str:
+ result = subprocess.run(
+ ["git", *args], cwd=repo, check=True, text=True, capture_output=True
+ )
+ return result.stdout.strip()
+
+
+def _seed_repo(root: Path) -> Path:
+ repo = root / "project-a"
+ repo.mkdir(parents=True)
+ _git(repo, "init")
+ _git(repo, "config", "user.name", "Tester")
+ _git(repo, "config", "user.email", "tester@example.invalid")
+ (repo / "README.md").write_text("private file body\n", encoding="utf-8")
+ _git(repo, "add", "README.md")
+ _git(repo, "commit", "-m", "record the first decision")
+ return repo
+
+
+def test_external_sources_are_disabled_by_default():
+ assert config.DEFAULT_CONFIG["external_sources"] == {
+ "git": {"enabled": False, "paths": [], "max_commits": 200},
+ "github": {"enabled": False, "paths": [], "max_items": 50},
+ "claude-web": {"enabled": False, "paths": []},
+ "chatgpt": {"enabled": False, "paths": []},
+ "cursor": {"enabled": False, "paths": []},
+ }
+
+
+def test_portable_backup_preserves_external_source_state():
+ assert "external_sources" in export_restore.REQUIRED_PATHS
+
+
+def test_register_source_requires_explicit_existing_path(tmp_path):
+ settings: dict = {}
+ external_sources.register_source(settings, "git", tmp_path)
+ assert settings["external_sources"]["git"]["paths"] == [str(tmp_path.resolve())]
+ with pytest.raises(ValueError, match="does not exist"):
+ external_sources.register_source(settings, "git", tmp_path / "missing")
+
+
+def test_git_collection_is_incremental_and_does_not_read_file_body(tmp_path):
+ repo = _seed_repo(tmp_path / "repos")
+ vault = tmp_path / "vault"
+
+ first = external_sources.collect_git_history(vault, [repo.parent], max_commits=20)
+ second = external_sources.collect_git_history(vault, [repo.parent], max_commits=20)
+
+ assert first["records_written"] == 1
+ assert second["records_written"] == 0
+ row = json.loads((vault / "index.jsonl").read_text(encoding="utf-8").splitlines()[0])
+ assert row["source"] == "git-history"
+ assert row["content"] == "record the first decision"
+ assert "private file body" not in json.dumps(row)
+
+
+@pytest.mark.parametrize(
+ ("remote", "expected"),
+ [
+ ("https://github.com/HeiGeAi/immortal-memory.git", "HeiGeAi/immortal-memory"),
+ ("git@github.com:HeiGeAi/immortal-memory.git", "HeiGeAi/immortal-memory"),
+ ("https://example.com/owner/repo.git", ""),
+ ],
+)
+def test_github_remote_parser_accepts_only_github(remote, expected):
+ assert external_sources.github_slug_from_remote(remote) == expected
+
+
+def test_disabled_github_issues_are_a_supported_empty_state(monkeypatch):
+ monkeypatch.setattr(
+ external_sources.subprocess,
+ "run",
+ lambda *_args, **_kwargs: subprocess.CompletedProcess(
+ args=[], returncode=1, stdout="", stderr="the repository has disabled issues"
+ ),
+ )
+ assert external_sources._gh_list("owner/repo", "issue", 50) == ([], True)
+
+
+def test_missing_github_cli_is_reported_as_connector_failure(monkeypatch):
+ def missing(*_args, **_kwargs):
+ raise FileNotFoundError("gh")
+
+ monkeypatch.setattr(external_sources.subprocess, "run", missing)
+
+ assert external_sources._gh_list("owner/repo", "pull-request", 50) == ([], False)
+
+
+def test_chatgpt_claude_and_cursor_exports_are_parsed(tmp_path):
+ chatgpt = tmp_path / "chatgpt.json"
+ chatgpt.write_text(json.dumps([{
+ "id": "c1", "title": "Decision", "mapping": {"n1": {"message": {
+ "id": "m1", "author": {"role": "user"},
+ "content": {"parts": ["Prefer the reversible option"]},
+ }}}
+ }]), encoding="utf-8")
+ claude = tmp_path / "claude.json"
+ claude.write_text(json.dumps([{
+ "uuid": "c2", "name": "Review", "chat_messages": [{
+ "uuid": "m2", "sender": "human", "text": "Keep the release reversible"
+ }]
+ }]), encoding="utf-8")
+ cursor = tmp_path / "cursor.jsonl"
+ cursor.write_text(json.dumps({
+ "id": "m3", "role": "assistant", "content": "Use the current architecture"
+ }) + "\n", encoding="utf-8")
+
+ assert external_sources.parse_chatgpt_export(chatgpt)[0]["content"] == "Prefer the reversible option"
+ assert external_sources.parse_claude_web_export(claude)[0]["role"] == "user"
+ assert external_sources.parse_cursor_export(cursor)[0]["content"] == "Use the current architecture"
+
+
+def test_registered_collection_redacts_secrets_and_is_incremental(tmp_path):
+ export = tmp_path / "cursor.jsonl"
+ export.write_text(json.dumps({
+ "id": "m1", "role": "user", "content": "token: ghp_" + "a" * 40
+ }) + "\n", encoding="utf-8")
+ settings = {"external_sources": {
+ "cursor": {"enabled": True, "paths": [str(export)]}
+ }}
+ vault = tmp_path / "vault"
+
+ first = external_sources.collect_registered_sources(settings, vault)
+ second = external_sources.collect_registered_sources(settings, vault)
+
+ assert first["cursor"]["records_written"] == 1
+ assert second["cursor"]["records_written"] == 0
+ body = (vault / "index.jsonl").read_text(encoding="utf-8")
+ assert "ghp_" + "a" * 40 not in body
+ assert "[REDACTED]" in body
+
+
+def test_source_cli_lists_disabled_defaults_without_collecting(monkeypatch, capsys):
+ monkeypatch.setattr(immortal, "load_config", lambda: config.DEFAULT_CONFIG)
+
+ assert immortal.main(["source", "list", "--json"]) == 0
+
+ payload = json.loads(capsys.readouterr().out)
+ assert set(payload) == {"git", "github", "claude-web", "chatgpt", "cursor"}
+ assert all(not item["enabled"] for item in payload.values())
+
+
+def test_orchestrator_collects_declared_external_sources(monkeypatch):
+ calls = []
+ payload = {
+ "git": {"records_written": 2, "status": "success"},
+ "cursor": {"records_written": 1, "status": "success"},
+ }
+ monkeypatch.setattr(
+ orchestrator,
+ "run_script",
+ lambda script, *args, **kwargs: calls.append((script, args, kwargs)) or (True, json.dumps(payload)),
+ )
+
+ ok, result = orchestrator.external_source_collect()
+
+ assert ok is True
+ assert result["records_written"] == 3
+ assert calls[0][0:2] == ("immortal.py", ("source", "collect", "--json"))
+
+
+def test_orchestrator_does_not_flatten_partial_external_source_to_success(monkeypatch):
+ payload = {
+ "git": {"records_written": 2, "status": "success"},
+ "github": {"records_written": 1, "status": "partial", "error_count": 1},
+ "chatgpt": {"records_written": 0, "status": "disabled"},
+ }
+ monkeypatch.setattr(
+ orchestrator,
+ "run_script",
+ lambda *_args, **_kwargs: (True, json.dumps(payload)),
+ )
+
+ ok, result = orchestrator.external_source_collect()
+
+ assert ok is False
+ assert result["records_written"] == 3
+ assert result["partial_sources"] == ["github"]
+
+
+def test_control_center_shows_external_sources_and_mail_without_paths(tmp_path):
+ (tmp_path / "config.json").write_text(json.dumps({
+ "feishu": {"daily_sources": "contacts,messages"},
+ "external_sources": {
+ "git": {"enabled": True, "paths": ["/private/projects"]},
+ "chatgpt": {"enabled": False, "paths": []},
+ },
+ }), encoding="utf-8")
+ state_dir = tmp_path / "external_sources"
+ state_dir.mkdir()
+ (state_dir / "state.json").write_text(json.dumps({
+ "generated_at": "2026-07-23T12:00:00Z",
+ "sources": {"git": {"records_written": 3, "status": "success"}},
+ }), encoding="utf-8")
+
+ items = {item["id"]: item for item in ControlData(tmp_path).sources()["items"]}
+
+ assert items["git-history"]["status"] == "success"
+ assert items["git-history"]["increment"] == 3
+ assert items["chatgpt"]["status"] == "skipped"
+ assert items["feishu-mail"]["status"] == "skipped"
+ assert "/private" not in json.dumps(items, ensure_ascii=False)
diff --git a/tests/test_version.py b/tests/test_version.py
index 60025b6..90ea120 100644
--- a/tests/test_version.py
+++ b/tests/test_version.py
@@ -32,11 +32,11 @@ def test_source_versions_and_cli_are_consistent(self):
immortal.main(["--version"])
self.assertEqual(raised.exception.code, 0)
- self.assertEqual(version, "1.1.1")
+ self.assertEqual(version, "1.3.0")
self.assertIsNotNone(project_version)
self.assertEqual(project_version.group(1), version)
self.assertIn(f"version-v{version}-", readme)
- self.assertEqual(output.getvalue().strip(), "immortal 1.1.1")
+ self.assertEqual(output.getvalue().strip(), "immortal 1.3.0")
setup_version = subprocess.run(
[sys.executable, "setup.py", "--version"],
From 896bb21ef2f5e85cd4609cdd3146d9d7416fe16f Mon Sep 17 00:00:00 2001
From: HeiGeAi <221471965+HeiGeAi@users.noreply.github.com>
Date: Fri, 24 Jul 2026 00:27:20 +0800
Subject: [PATCH 2/2] test: isolate external source orchestration logs
---
tests/test_external_sources.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/tests/test_external_sources.py b/tests/test_external_sources.py
index a3023bf..0128b79 100644
--- a/tests/test_external_sources.py
+++ b/tests/test_external_sources.py
@@ -167,6 +167,7 @@ def test_orchestrator_collects_declared_external_sources(monkeypatch):
"run_script",
lambda script, *args, **kwargs: calls.append((script, args, kwargs)) or (True, json.dumps(payload)),
)
+ monkeypatch.setattr(orchestrator, "log", lambda _message: None)
ok, result = orchestrator.external_source_collect()
@@ -186,6 +187,7 @@ def test_orchestrator_does_not_flatten_partial_external_source_to_success(monkey
"run_script",
lambda *_args, **_kwargs: (True, json.dumps(payload)),
)
+ monkeypatch.setattr(orchestrator, "log", lambda _message: None)
ok, result = orchestrator.external_source_collect()