diff --git a/CHANGELOG.md b/CHANGELOG.md index 9299f2f..e455c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 1.3.1 + +- Move private identity aliases and categories out of public source code into local configuration, and make owner-only defaults work for every installation. +- Restore correct person extraction, relationship indexing, and quality scoring after sanitized public-code installation. +- Snapshot registered Claude Web and ChatGPT exports into the private vault so macOS background jobs can read them reliably. +- Support explicit GitHub `owner/repository` registration without scanning protected local directories. +- Show explicitly registered GitHub repositories as enabled and healthy in the control center even when no local checkout path is configured. +- Retry transient GitHub and Feishu network failures and persist bounded, secret-redacted error details. +- Treat restricted chats, missing meeting notes, deleted minute resources, and unavailable recordings as visible access-boundary skips instead of system failures. +- Prefer fresh Feishu collector state over stale orchestrator status in automatic feedback. + ## 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. diff --git a/README.md b/README.md index 407f306..6c92574 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@
-![Version](https://img.shields.io/badge/version-v1.3.0-111827.svg) +![Version](https://img.shields.io/badge/version-v1.3.1-111827.svg) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB.svg?logo=python&logoColor=white) ![Platform](https://img.shields.io/badge/platform-Codex%20%7C%20Claude%20Code%20%7C%20Local%20Agent-0F766E.svg) ![License](https://img.shields.io/badge/license-MIT-059669.svg) @@ -118,8 +118,8 @@ v1.3 支持五类显式登记的外部来源。全部默认关闭,只有你登 # 本地 Git 提交历史,只读取提交元数据和提交说明,不读取代码正文 immortal-memory source register git "/absolute/path/to/projects" -# GitHub PR 和 Issue,只读调用本机已登录的 gh CLI -immortal-memory source register github "/absolute/path/to/projects" +# GitHub PR 和 Issue,直接登记 owner/repository,不依赖后台扫描 Documents +immortal-memory source register github "owner/repository" # 官方导出的对话文件或 Cursor transcript 目录 immortal-memory source register claude-web "/absolute/path/to/conversations.json" @@ -131,6 +131,8 @@ immortal-memory source list --json immortal-memory source collect --json ``` +单文件的 Claude Web 和 ChatGPT 导出在登记时会复制到 `~/.immortal/imports/`,避免 macOS LaunchAgent 无法读取 Downloads 的权限差异。GitHub 建议直接登记仓库名;只有确实需要本地未推送 commit 时,才登记本地 Git 目录。 + 每条记录在写入前都会做凭证形态脱敏,并用本地 SQLite 状态去重。每日编排会自动调用已启用来源。控制台只展示来源健康、更新时间、新增数和错误数,不展示本机私有路径。飞书邮件继续保持显式授权,不会因为安装产品而自动采集。 ### 支持环境与 Agent @@ -242,7 +244,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.3.0-*.whl' | head -n 1)" +WHEEL="$(find "$(pwd)/dist" -maxdepth 1 -name 'immortal_memory-1.3.1-*.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 diff --git a/core/VERSION b/core/VERSION index f0bb29e..3a3cd8c 100644 --- a/core/VERSION +++ b/core/VERSION @@ -1 +1 @@ -1.3.0 +1.3.1 diff --git a/core/config.example.json b/core/config.example.json index 55d9e9e..2ac16f3 100644 --- a/core/config.example.json +++ b/core/config.example.json @@ -36,6 +36,10 @@ ], "owner_display_name": "Your Name", "owner_name": "", + "people_index": { + "categories": {}, + "identities": [] + }, "primary_account": "Main Account", "role_defaults": { "goal": "写稿审稿流程", diff --git a/core/config.py b/core/config.py index 7f4758d..e77e557 100644 --- a/core/config.py +++ b/core/config.py @@ -44,6 +44,10 @@ "people": [], # 关注人物名单,空=不做人物统计 "topic_keywords": [], # 话题词,空=用 distill 内置通用 AI/工作词 }, + "people_index": { + "identities": [], # 本机私有的 canonical/aliases/category 规则 + "categories": {}, # 无需合并别名的人物分类 + }, "automation": { "daily_launch_agent_label": "", "daily_schedule": [ @@ -102,7 +106,7 @@ "extra_sources": [], "external_sources": { "git": {"enabled": False, "paths": [], "max_commits": 200}, - "github": {"enabled": False, "paths": [], "max_items": 50}, + "github": {"enabled": False, "paths": [], "repositories": [], "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 0719f82..d16d78d 100644 --- a/core/control_data.py +++ b/core/control_data.py @@ -451,7 +451,13 @@ def sources(self) -> dict[str, Any]: ("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 enabled in [bool( + configured.get("enabled") + and ( + configured.get("paths") + or (kind == "github" and configured.get("repositories")) + ) + )] for result in [external_results.get(kind) if isinstance(external_results.get(kind), dict) else {}] ], { diff --git a/core/external_sources.py b/core/external_sources.py index 49b5d9c..435634f 100644 --- a/core/external_sources.py +++ b/core/external_sources.py @@ -8,6 +8,7 @@ import json import os import re +import shutil import sqlite3 import subprocess from datetime import datetime, timezone @@ -32,9 +33,24 @@ def iso_utc(value: Any = None) -> str: 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}") + raw = str(path).strip() + if kind == "github" and re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", raw): + source = settings.setdefault("external_sources", {}).setdefault(kind, {}) + repositories = [str(item).strip() for item in source.get("repositories") or [] if str(item).strip()] + if raw not in repositories: + repositories.append(raw) + source.update({"enabled": True, "repositories": repositories}) + return settings resolved = Path(path).expanduser().resolve() if not resolved.exists(): raise ValueError(f"source path does not exist: {resolved}") + if kind in {"claude-web", "chatgpt"} and resolved.is_file(): + vault = Path(str(settings.get("vault_dir") or Path.home() / ".immortal")).expanduser() + import_dir = vault / "imports" / kind + import_dir.mkdir(parents=True, exist_ok=True) + imported = import_dir / resolved.name + shutil.copy2(resolved, imported) + resolved = imported.resolve() 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: @@ -44,6 +60,13 @@ def register_source(settings: dict[str, Any], kind: str, path: str | Path) -> di def unregister_source(settings: dict[str, Any], kind: str, path: str | Path) -> dict[str, Any]: + raw = str(path).strip() + if kind == "github" and re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", raw): + 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["repositories"] = [item for item in source.get("repositories") or [] if str(item) != raw] + source["enabled"] = bool(source.get("paths") or source.get("repositories")) + return settings 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 {} @@ -51,7 +74,7 @@ def unregister_source(settings: dict[str, Any], kind: str, path: str | Path) -> item for item in source.get("paths") or [] if str(Path(item).expanduser().resolve()) != resolved ] - source["enabled"] = bool(source["paths"]) + source["enabled"] = bool(source["paths"] or source.get("repositories")) return settings @@ -189,25 +212,39 @@ def _github_remote(repo: Path) -> str: 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]: +def _gh_list_with_error(repository: str, kind: str, limit: int) -> tuple[list[dict[str, Any]], bool, dict[str, str] | None]: 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 + last_error: dict[str, str] | None = None + for _attempt in range(2): + 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) as exc: + last_error = {"error_type": type(exc).__name__, "message": str(redact_tree(str(exc)))[:240]} + continue + if result.returncode: + if kind == "issue" and "disabled issues" in result.stderr.lower(): + return [], True, None + last_error = { + "error_type": "gh_command_failed", + "message": str(redact_tree(result.stderr.strip() or f"exit {result.returncode}"))[:240], + } + continue + try: + data = json.loads(result.stdout) + except json.JSONDecodeError as exc: + last_error = {"error_type": "JSONDecodeError", "message": str(exc)[:240]} + continue + return (data if isinstance(data, list) else []), True, None + return [], False, last_error + + +def _gh_list(repository: str, kind: str, limit: int) -> tuple[list[dict[str, Any]], bool]: + items, ok, _error = _gh_list_with_error(repository, kind, limit) + return items, ok def github_items_to_records(repository: str, kind: str, items: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: @@ -228,24 +265,33 @@ def github_items_to_records(repository: str, kind: str, items: Iterable[dict[str return records -def collect_github_history(vault: str | Path, roots: Iterable[str | Path], *, max_items: int = 50) -> dict[str, Any]: - repositories: set[Path] = set() +def collect_github_history( + vault: str | Path, + roots: Iterable[str | Path], + *, + repositories: Iterable[str] = (), + max_items: int = 50, +) -> dict[str, Any]: + local_repositories: set[Path] = set() for root in roots: - repositories.update(discover_git_repositories(root)) - records, errors, count = [], 0, 0 - for repo in sorted(repositories): + local_repositories.update(discover_git_repositories(root)) + slugs = {str(value).strip() for value in repositories if str(value).strip()} + for repo in sorted(local_repositories): slug = _github_remote(repo) - if not slug: - continue - count += 1 + if slug: + slugs.add(slug) + records, errors, error_details = [], 0, [] + for slug in sorted(slugs): for kind in ("pull-request", "issue"): - items, ok = _gh_list(slug, kind, max(1, int(max_items))) + items, ok, error = _gh_list_with_error(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} + error_details.append({"repository": slug, "kind": kind, **(error or {})}) + return {"repositories": len(slugs), "records_written": _collect_records(Path(vault).expanduser(), records), + "status": "partial" if errors else "success", "error_count": errors, + "errors": error_details} def parse_chatgpt_export(path: str | Path) -> list[dict[str, Any]]: @@ -355,21 +401,35 @@ def collect_registered_sources(settings: dict[str, Any], vault: str | Path) -> d ("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"} + enabled = bool(source.get("enabled")) + paths = source.get("paths") or [] + registered_repositories = (source.get("repositories") or []) if kind == "github" else [] + if not enabled or (not paths and not registered_repositories): + result[kind] = {"repositories": 0, "records_written": 0, "status": "disabled"} + continue + kwargs = {limit_key: int(source.get(limit_key) or default)} + if kind == "github": + kwargs["repositories"] = registered_repositories + result[kind] = collector(vault_path, paths, **kwargs) 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 + files, records, errors, error_details = _registered_files(source, kind), [], 0, [] for path in files: try: records.extend(parser(path)) - except (OSError, ValueError, json.JSONDecodeError): + except (OSError, ValueError, json.JSONDecodeError) as exc: errors += 1 + error_details.append({ + "file": path.name, + "error_type": type(exc).__name__, + "message": str(redact_tree(str(exc)))[:240], + }) result[kind] = {"files": len(files), "records_written": _collect_records(vault_path, records), - "status": "partial" if errors else "success", "error_count": errors} + "status": "partial" if errors else "success", "error_count": errors, + "errors": error_details} state_dir = vault_path / "external_sources" state_dir.mkdir(parents=True, exist_ok=True) temporary = state_dir / "state.json.tmp" diff --git a/core/feedback_report.py b/core/feedback_report.py index fdde3dc..7a401f1 100644 --- a/core/feedback_report.py +++ b/core/feedback_report.py @@ -109,8 +109,8 @@ def build_report(vault: Path, run_status: int | None = None) -> dict[str, Any]: "collect_count": state.get("collect_count"), } feishu = { - "last_collect": state.get("last_feishu_collect"), - "last_status": state.get("last_feishu_status"), + "last_collect": feishu_state.get("last_run_at") or state.get("last_feishu_collect"), + "last_status": ("partial" if feishu_errors else "ok") if feishu_fresh else state.get("last_feishu_status"), "last_clean": state.get("last_feishu_clean"), "last_distill": state.get("last_feishu_distill"), "source_errors": len(feishu_errors), @@ -130,7 +130,7 @@ def build_report(vault: Path, run_status: int | None = None) -> dict[str, Any]: elif current_errors: status = "failed" status_label = "存在错误" - elif run_status == 2 or feishu_errors or str(state.get("last_feishu_status") or "") == "partial": + elif run_status == 2 or feishu_errors or str(feishu.get("last_status") or "") == "partial": status = "partial" status_label = "部分成功" elif quality_status == "attention" or issue_count > 0 or stale_inputs: diff --git a/core/feishu_collect.py b/core/feishu_collect.py index 53c58c6..1c55a0d 100644 --- a/core/feishu_collect.py +++ b/core/feishu_collect.py @@ -311,42 +311,53 @@ def update_sources_backup(stats: dict[str, Any], failed_sources: set[str] | None write_json(SOURCES_FILE, config) +def _retryable_lark_failure(body: dict[str, Any], error: str) -> bool: + text = (str(error or "") + "\n" + compact_json(body, 2000)).lower() + return any(marker in text for marker in ("network", "timeout", "timed out", "connection reset")) + + def run_lark(args: list[str], *, timeout: int = 60, cwd: Path | None = None) -> tuple[bool, dict[str, Any], str]: executable = next((str(path) for path in LARK_CLI_CANDIDATES if path.exists()), "lark-cli") cmd = [executable, *args] env_path = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" - try: - proc = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=timeout, - env={**os.environ, "NO_COLOR": "1", "PATH": f"{env_path}:{os.environ.get('PATH', '')}"}, - cwd=str(cwd) if cwd else None, - ) - except subprocess.TimeoutExpired as exc: - return False, {}, f"timeout after {timeout}s: {' '.join(cmd)}" - except Exception as exc: - return False, {}, f"{type(exc).__name__}: {exc}" - - stdout = proc.stdout.strip() - stderr = proc.stderr.strip() - body: dict[str, Any] = {} - if stdout: + last: tuple[bool, dict[str, Any], str] = (False, {}, "lark-cli did not run") + for attempt in range(2): try: - body = json.loads(stdout) - except json.JSONDecodeError: - body = {"raw_stdout": stdout} + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + env={**os.environ, "NO_COLOR": "1", "PATH": f"{env_path}:{os.environ.get('PATH', '')}"}, + cwd=str(cwd) if cwd else None, + ) + except subprocess.TimeoutExpired: + last = (False, {}, f"timeout after {timeout}s: {' '.join(cmd)}") + if attempt == 0: + continue + return last + except Exception as exc: + return False, {}, f"{type(exc).__name__}: {exc}" - ok = proc.returncode == 0 - if body.get("ok") is False: - ok = False - if body.get("code") not in (None, 0): - ok = False - err = stderr - if not ok and body: - err = compact_json(body, 2000) - return ok, body, err + stdout = proc.stdout.strip() + stderr = proc.stderr.strip() + body: dict[str, Any] = {} + if stdout: + try: + body = json.loads(stdout) + except json.JSONDecodeError: + body = {"raw_stdout": stdout} + + ok = proc.returncode == 0 + if body.get("ok") is False or body.get("code") not in (None, 0): + ok = False + err = stderr + if not ok and body: + err = compact_json(body, 2000) + last = (ok, body, err) + if ok or attempt == 1 or not _retryable_lark_failure(body, err): + return last + return last def current_auth_status() -> tuple[bool, dict[str, Any], str]: @@ -480,6 +491,23 @@ def write_records(records: list[dict[str, Any]]) -> None: _write_records_locked(records) +EXPECTED_ACCESS_BOUNDARY_MARKERS = ( + "chat open restricted mode", + "don't allow copying or forwarding messages", + "no notes available for this meeting", + "no permission to access this meeting's minute", + "user lacks permission for the requested resource", + "resource deleted", +) + + +def is_expected_access_boundary(source: str, message: str) -> bool: + if source not in {"feishu-im", "feishu-vc-note", "feishu-vc-recording", "feishu-minutes-note"}: + return False + lowered = str(message or "").lower() + return any(marker in lowered for marker in EXPECTED_ACCESS_BOUNDARY_MARKERS) + + class Collector: def __init__(self, args: argparse.Namespace): self.args = args @@ -487,12 +515,18 @@ def __init__(self, args: argparse.Namespace): self.run_id = str(uuid.uuid4()) self.stats: dict[str, int] = defaultdict(int) self.errors: list[dict[str, str]] = [] + self.skips: list[dict[str, str]] = [] prev_state = read_json(STATE_FILE, {}) self.start, self.end = window_from_args(args, prev_state.get("last_window_end")) self.chats: list[dict[str, Any]] = [] self.self_user: dict[str, Any] | None = None def error(self, source: str, message: str) -> None: + if is_expected_access_boundary(source, message): + item = {"source": source, "reason": "access_boundary", "message": message[:1000]} + self.skips.append(item) + log_event("info", "collector_skipped", **item) + return self.errors.append({"source": source, "message": message[:1000]}) log_event("error", "collector_error", source=source, message=message[:1000]) @@ -565,11 +599,12 @@ def finish_run(self) -> None: "last_window_end": iso_local(self.end), "last_stats": stats, "last_errors": self.errors[-20:], + "last_skips": self.skips[-50:], } ) write_json(STATE_FILE, state) update_sources_backup(stats, failed_sources={e.get("source") for e in self.errors}) - log_event("info", "run_finished", run_id=self.run_id, stats=stats, errors=len(self.errors)) + log_event("info", "run_finished", run_id=self.run_id, stats=stats, errors=len(self.errors), skips=len(self.skips)) def collect_contact_self(self) -> None: ok, body, err = run_lark(["contact", "+get-user", "--as", "user", "--format", "json"]) diff --git a/core/feishu_distill.py b/core/feishu_distill.py index 09afa10..a26c8d8 100644 --- a/core/feishu_distill.py +++ b/core/feishu_distill.py @@ -158,6 +158,38 @@ def load_owner_identity() -> tuple[list[str], str]: ] MAMA_ROLE_RE = re.compile(r"@?协作者庚\s*协作者庚|@?协作者庚") + +def load_configured_people_terms() -> tuple[list[str], dict[str, dict[str, Any]]]: + if load_config is None: + return PEOPLE_TERMS, {} + try: + config = load_config() + except Exception: + return PEOPLE_TERMS, {} + section = config.get("people_index") if isinstance(config.get("people_index"), dict) else {} + identity_rows = section.get("identities") if isinstance(section.get("identities"), list) else [] + identities = { + str(row.get("id") or ""): row + for row in identity_rows + if isinstance(row, dict) and row.get("canonical") + } + configured: list[str] = [] + for row in identity_rows: + if not isinstance(row, dict): + continue + configured.extend(str(value).strip() for value in row.get("aliases") or [] if str(value).strip()) + configured.extend(str(value).strip() for value in (section.get("categories") or {}) if str(value).strip()) + distill = config.get("distill") if isinstance(config.get("distill"), dict) else {} + configured.extend(str(value).strip() for value in distill.get("people") or [] if str(value).strip()) + return list(dict.fromkeys([*configured, *PEOPLE_TERMS])), identities + + +PEOPLE_TERMS, CONFIGURED_IDENTITIES = load_configured_people_terms() +_configured_mama = CONFIGURED_IDENTITIES.get("mama") or {} +_configured_mama_aliases = _configured_mama.get("role_aliases") or _configured_mama.get("aliases") or [] +if _configured_mama_aliases: + MAMA_ROLE_RE = re.compile("|".join(re.escape(str(value)) for value in _configured_mama_aliases if str(value))) + DECISION_TERMS = ["决定", "明确", "拍板", "采用", "选用", "不再", "转向", "统一", "收口", "替代"] PREFERENCE_TERMS = ["原则", "最高原则", "要求", "必须", "不要", "不能", "优先", "规范", "标准", "偏好"] COMMITMENT_TERMS = ["待办", "跟进", "完成", "推进", "下周", "本周", "明天", "后续", "尽快", "负责", "配合"] @@ -165,7 +197,7 @@ def load_owner_identity() -> tuple[list[str], str]: RELATION_TERMS = ["客户", "对接", "主导", "承接", "配合", "负责人", "服务群", "售前", "交付方"] JUNK_TASK_RE = re.compile(r"^(123123|测试|写明具体任务,,|填写下一步任务,,|填写具体的执行计划,,|剪辑|来自会话:)") -USER_ALIAS_RE = re.compile(r"(用户本人|用户本人|Owner|用户本人)") +USER_ALIAS_RE = re.compile("|".join(re.escape(value) for value in OWNER_ALIASES if value)) BIBI_ACCOUNT_TITLE_RE = re.compile(r"(内容创作者A的正确使用方式|内容账号A ·|三篇Claude Code对标文章审稿报告|内容账号A.*审稿|内容账号A.*文风)") BIBI_ACCOUNT_STATEMENT_RE = re.compile( r"(内容账号A.*(内容特色|文风|选题|标题技巧|粉丝IP)|" diff --git a/core/people_index.py b/core/people_index.py index 4dce9c1..e0169e9 100644 --- a/core/people_index.py +++ b/core/people_index.py @@ -17,6 +17,8 @@ from typing import Any from zoneinfo import ZoneInfo +from config import load_config, owner_aliases, owner_display_name + HOME = Path.home() IMMORTAL_DIR = HOME / ".immortal" @@ -182,6 +184,73 @@ "协作者寅": "other", } + +def _configured_identity_rules() -> list[dict[str, Any]]: + """Load private identity bindings without placing them in public code.""" + config = load_config() + section = config.get("people_index") if isinstance(config.get("people_index"), dict) else {} + rows = section.get("identities") if isinstance(section.get("identities"), list) else [] + result: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + canonical = str(row.get("canonical") or "").strip() + aliases = [str(value).strip() for value in row.get("aliases") or [] if str(value).strip()] + if canonical: + result.append({**row, "canonical": canonical, "aliases": list(dict.fromkeys(aliases))}) + return result + + +IDENTITY_RULES = _configured_identity_rules() +_IDENTITY_BY_ID = {str(row.get("id") or ""): row for row in IDENTITY_RULES} +_RUNTIME_CONFIG = load_config() +_OWNER_RULE = _IDENTITY_BY_ID.get("user") or {} +_configured_owner = str(_OWNER_RULE.get("canonical") or owner_display_name(_RUNTIME_CONFIG)).strip() +if _configured_owner and _configured_owner != "the configured user": + USER_CANONICAL = _configured_owner + USER_ALIASES = set(_OWNER_RULE.get("aliases") or owner_aliases(_RUNTIME_CONFIG)) + CANONICAL_ALIASES[USER_CANONICAL] = list(dict.fromkeys([USER_CANONICAL, *sorted(USER_ALIASES)])) + for _owner_alias in CANONICAL_ALIASES[USER_CANONICAL]: + ALIASES[_owner_alias] = USER_CANONICAL + CATEGORY_BY_NAME[USER_CANONICAL] = "self" + +for _identity_id, _constant_name in (("bibi", "BIBI_CANONICAL"), ("taozi", "TAOZI_CANONICAL"), ("mama", "MAMA_CANONICAL")): + _rule = _IDENTITY_BY_ID.get(_identity_id) or {} + if _rule.get("canonical"): + globals()[_constant_name] = str(_rule["canonical"]) + +for _rule in IDENTITY_RULES: + _canonical = str(_rule["canonical"]) + _aliases = list(dict.fromkeys([_canonical, *(_rule.get("aliases") or [])])) + CANONICAL_ALIASES[_canonical] = _aliases + for _alias in _aliases: + ALIASES[_alias] = _canonical + if _rule.get("category"): + CATEGORY_BY_NAME[_canonical] = str(_rule["category"]) + +_people_section = _RUNTIME_CONFIG.get("people_index") if isinstance(_RUNTIME_CONFIG.get("people_index"), dict) else {} +for _name, _category in (_people_section.get("categories") or {}).items(): + if str(_category) in {"self", "team", "business", "customer", "other"}: + CATEGORY_BY_NAME[canonical_name(str(_name)) if "canonical_name" in globals() else ALIASES.get(str(_name), str(_name))] = str(_category) + +_mama_rule = _IDENTITY_BY_ID.get("mama") or {} +_mama_text_aliases = _mama_rule.get("role_aliases") or _mama_rule.get("aliases") or [] +if _mama_text_aliases: + MAMA_ROLE_RE = re.compile("|".join(re.escape(str(value)) for value in _mama_text_aliases if str(value))) + +_bibi_rule = _IDENTITY_BY_ID.get("bibi") or {} +_brand_aliases = [str(value) for value in _bibi_rule.get("brand_aliases") or [] if str(value)] +_person_aliases = [str(value) for value in _bibi_rule.get("person_aliases") or [] if str(value)] +if _brand_aliases: + _brand = "(?:" + "|".join(re.escape(value) for value in _brand_aliases) + ")" + BIBI_BRAND_CONTEXT_RE = re.compile( + rf"({_brand}[-—_ ]?(账号|品牌|公众号|栏目|文章|视频|内容|矩阵|社群|方案|案例|业务|客户|平台|IP)|" + rf"(账号|品牌|公众号|栏目|文章|视频|内容|矩阵|社群|方案|案例|业务|客户|平台|IP).{{0,8}}{_brand})" + ) +if _person_aliases: + _person = "(?:" + "|".join(re.escape(value) for value in _person_aliases) + ")" + BIBI_PERSON_CONTEXT_RE = re.compile(rf"({_person}(说|认为|负责|提醒|组织|审稿|反馈|要求|提到|提出|明确|提供|赋能|协助|主导|@)|@{_person})") + CATEGORY_LABELS = { "self": "用户本人", "team": "团队同事", @@ -368,6 +437,11 @@ def row_people(row: dict[str, Any]) -> list[str]: output.append(MAMA_CANONICAL) if "用户本人" in row_text and USER_CANONICAL not in output: output.append(USER_CANONICAL) + for identity in IDENTITY_RULES: + canonical = str(identity.get("canonical") or "") + text_aliases = [str(value) for value in identity.get("text_aliases") or [] if str(value)] + if canonical and text_aliases and any(alias in row_text for alias in text_aliases) and canonical not in output: + output.append(canonical) return output diff --git a/core/quality_report.py b/core/quality_report.py index 8843ff9..f49335e 100644 --- a/core/quality_report.py +++ b/core/quality_report.py @@ -80,6 +80,34 @@ }, ] +if people_layer.IDENTITY_RULES: + CONFIRMED_IDENTITY_RULES = [] + for configured_rule in people_layer.IDENTITY_RULES: + rule = { + "id": str(configured_rule.get("id") or configured_rule["canonical"]), + "canonical": str(configured_rule["canonical"]), + "aliases": list(configured_rule.get("aliases") or []), + "must_exist": bool(configured_rule.get("must_exist", True)), + } + category = str(configured_rule.get("category") or "") + if category == "self": + rule["expected_category"] = "self" + elif category: + rule["expected_not_category"] = "self" + CONFIRMED_IDENTITY_RULES.append(rule) + SHUSHU_CANONICAL = next( + (str(rule["canonical"]) for rule in people_layer.IDENTITY_RULES if rule.get("id") == "shushu"), + SHUSHU_CANONICAL, + ) +else: + CONFIRMED_IDENTITY_RULES = [{ + "id": "user", + "canonical": USER_CANONICAL, + "aliases": sorted(people_layer.USER_ALIASES), + "expected_category": "self", + "must_exist": True, + }] + SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2} STATUS_LABELS = { "ok": "可用", @@ -398,11 +426,13 @@ def check_identity_rules(people: list[dict[str, Any]], rows: list[dict[str, Any] mama_misses = [] bibi_brand_hits = [] contamination_hits = [] + owner_rule = next((rule for rule in people_layer.IDENTITY_RULES if rule.get("id") == "user"), {}) + owner_text_aliases = owner_rule.get("quality_text_aliases") or people_layer.USER_ALIASES for row in rows: text = row_text(row) canonical_people = people_layer.row_people(row) raw_people = [str(person).strip().lstrip("@") for person in row.get("people") or []] - if "用户本人" in text and USER_CANONICAL not in canonical_people: + if any(str(alias) in text for alias in owner_text_aliases) and USER_CANONICAL not in canonical_people: user_alias_misses.append(row_sample(row)) if people_layer.MAMA_ROLE_RE.search(text) and MAMA_CANONICAL not in canonical_people: mama_misses.append(row_sample(row)) @@ -427,11 +457,11 @@ def check_identity_rules(people: list[dict[str, Any]], rows: list[dict[str, Any] if user_alias_misses: make_issue( issues, - issue_id="user_alias_extraction_miss:xujiang", + issue_id="user_alias_extraction_miss:owner", area="identity", severity="medium", title="用户本人别名可能漏抽取", - detail=f"发现 {len(user_alias_misses)} 条文本包含“用户本人”,但人物字段没有归到用户本人。", + detail=f"发现 {len(user_alias_misses)} 条文本包含用户别名,但人物字段没有归到用户本人。", suggested_action="补强 Feishu 蒸馏层人物抽取词表,或在 people_index row_people 中兜底识别。", evidence=user_alias_misses[:6], ) diff --git a/docs/releases/v1.3.1.md b/docs/releases/v1.3.1.md new file mode 100644 index 0000000..34c0c66 --- /dev/null +++ b/docs/releases/v1.3.1.md @@ -0,0 +1,17 @@ +# Immortal Memory v1.3.1 + +v1.3.1 is a production reliability patch for background automation and private identity configuration. + +## Fixed + +- Public, sanitized code no longer overwrites the installed user's real identity aliases or person categories. +- Claude Web and ChatGPT file registrations are snapshotted into the private vault, avoiding macOS LaunchAgent access failures for Downloads. +- GitHub repositories can be registered explicitly as `owner/repository`, so background collection does not depend on protected Documents folders. +- The control center now recognizes explicit GitHub registrations and reports their real collector state instead of a false skipped state. +- GitHub and Feishu transient network failures are retried and reported with bounded, secret-redacted diagnostics. +- Expected Feishu access boundaries remain visible as skips but no longer turn a healthy run into a false partial failure. +- Automatic feedback uses the freshest successful Feishu collector state. + +## Privacy + +Identity rules remain in `~/.immortal/config.json`. The public repository, wheel, and release notes contain no private names, local vault content, tokens, or generated personal models. diff --git a/examples/config.example.json b/examples/config.example.json index 55d9e9e..2ac16f3 100644 --- a/examples/config.example.json +++ b/examples/config.example.json @@ -36,6 +36,10 @@ ], "owner_display_name": "Your Name", "owner_name": "", + "people_index": { + "categories": {}, + "identities": [] + }, "primary_account": "Main Account", "role_defaults": { "goal": "写稿审稿流程", diff --git a/pyproject.toml b/pyproject.toml index 90fb465..d87bd1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "immortal-memory" -version = "1.3.0" +version = "1.3.1" description = "Local-first personal memory layer for AI agents" requires-python = ">=3.9" license = "MIT" diff --git a/tests/test_external_sources.py b/tests/test_external_sources.py index 0128b79..65711a4 100644 --- a/tests/test_external_sources.py +++ b/tests/test_external_sources.py @@ -36,7 +36,7 @@ def _seed_repo(root: Path) -> Path: 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}, + "github": {"enabled": False, "paths": [], "repositories": [], "max_items": 50}, "claude-web": {"enabled": False, "paths": []}, "chatgpt": {"enabled": False, "paths": []}, "cursor": {"enabled": False, "paths": []}, @@ -55,6 +55,28 @@ def test_register_source_requires_explicit_existing_path(tmp_path): external_sources.register_source(settings, "git", tmp_path / "missing") +def test_register_github_repository_without_local_checkout(): + settings: dict = {} + external_sources.register_source(settings, "github", "owner/project") + assert settings["external_sources"]["github"] == { + "enabled": True, + "repositories": ["owner/project"], + } + + +def test_registered_conversation_export_is_snapshotted_inside_vault(tmp_path): + source = tmp_path / "downloads" / "conversations.json" + source.parent.mkdir() + source.write_text("[]", encoding="utf-8") + settings = {"vault_dir": str(tmp_path / "vault")} + + external_sources.register_source(settings, "claude-web", source) + + imported = tmp_path / "vault" / "imports" / "claude-web" / "conversations.json" + assert imported.read_text(encoding="utf-8") == "[]" + assert settings["external_sources"]["claude-web"]["paths"] == [str(imported.resolve())] + + def test_git_collection_is_incremental_and_does_not_read_file_body(tmp_path): repo = _seed_repo(tmp_path / "repos") vault = tmp_path / "vault" @@ -102,6 +124,52 @@ def missing(*_args, **_kwargs): assert external_sources._gh_list("owner/repo", "pull-request", 50) == ([], False) +def test_github_collection_retries_and_reports_bounded_error(monkeypatch, tmp_path): + repo = _seed_repo(tmp_path / "repos") + monkeypatch.setattr(external_sources, "_github_remote", lambda _repo: "owner/project") + calls = [] + + def failed(*_args, **_kwargs): + calls.append(1) + return subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="network unavailable") + + monkeypatch.setattr(external_sources.subprocess, "run", failed) + + result = external_sources.collect_github_history(tmp_path / "vault", [repo.parent]) + + assert result["status"] == "partial" + assert result["error_count"] == 2 + assert len(calls) == 4 + assert result["errors"][0] == { + "repository": "owner/project", + "kind": "pull-request", + "error_type": "gh_command_failed", + "message": "network unavailable", + } + + +def test_github_collection_accepts_explicit_repositories_without_local_paths(monkeypatch, tmp_path): + monkeypatch.setattr( + external_sources, + "_gh_list_with_error", + lambda repository, kind, limit: ([], True, None), + ) + + result = external_sources.collect_registered_sources({ + "external_sources": { + "github": { + "enabled": True, + "paths": [], + "repositories": ["owner/project"], + "max_items": 10, + } + } + }, tmp_path / "vault") + + assert result["github"]["status"] == "success" + assert result["github"]["repositories"] == 1 + + def test_chatgpt_claude_and_cursor_exports_are_parsed(tmp_path): chatgpt = tmp_path / "chatgpt.json" chatgpt.write_text(json.dumps([{ @@ -146,6 +214,24 @@ def test_registered_collection_redacts_secrets_and_is_incremental(tmp_path): assert "[REDACTED]" in body +def test_registered_collection_reports_sanitized_parser_errors(tmp_path): + export = tmp_path / "conversations.json" + secret = "ghp_" + "a" * 40 + export.write_text('{"broken":"' + secret, encoding="utf-8") + settings = {"external_sources": { + "claude-web": {"enabled": True, "paths": [str(export)]} + }} + + result = external_sources.collect_registered_sources(settings, tmp_path / "vault") + + source = result["claude-web"] + assert source["status"] == "partial" + assert source["error_count"] == 1 + assert source["errors"][0]["file"] == "conversations.json" + assert source["errors"][0]["error_type"] == "JSONDecodeError" + assert secret not in json.dumps(source) + + def test_source_cli_lists_disabled_defaults_without_collecting(monkeypatch, capsys): monkeypatch.setattr(immortal, "load_config", lambda: config.DEFAULT_CONFIG) @@ -201,6 +287,11 @@ def test_control_center_shows_external_sources_and_mail_without_paths(tmp_path): "feishu": {"daily_sources": "contacts,messages"}, "external_sources": { "git": {"enabled": True, "paths": ["/private/projects"]}, + "github": { + "enabled": True, + "paths": [], + "repositories": ["owner/project"], + }, "chatgpt": {"enabled": False, "paths": []}, }, }), encoding="utf-8") @@ -208,13 +299,22 @@ def test_control_center_shows_external_sources_and_mail_without_paths(tmp_path): 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"}}, + "sources": { + "git": {"records_written": 3, "status": "success"}, + "github": { + "records_written": 5, + "repositories": 1, + "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["github-history"]["status"] == "success" + assert items["github-history"]["increment"] == 5 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_feedback_report.py b/tests/test_feedback_report.py index fb4c1a0..321ee5c 100644 --- a/tests/test_feedback_report.py +++ b/tests/test_feedback_report.py @@ -82,6 +82,19 @@ def test_feishu_source_errors_yield_partial(self): report = feedback_report.build_report(vault, run_status=0) self.assertEqual(report["status"], "partial") + def test_fresh_feishu_success_overrides_stale_orchestrator_partial(self): + with tempfile.TemporaryDirectory() as tmp: + vault = make_vault(tmp) + state_path = vault / "orchestrator_state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["last_feishu_status"] = "partial" + state_path.write_text(json.dumps(state), encoding="utf-8") + + report = feedback_report.build_report(vault, run_status=0) + + self.assertEqual(report["status"], "ok") + self.assertEqual(report["feishu"]["last_status"], "ok") + class MainExitCodeTest(unittest.TestCase): def _main(self, vault: Path, argv_extra: list[str]) -> int: diff --git a/tests/test_feishu_distill_people_config.py b/tests/test_feishu_distill_people_config.py new file mode 100644 index 0000000..f6d4c7c --- /dev/null +++ b/tests/test_feishu_distill_people_config.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import feishu_distill + + +def test_configured_identity_aliases_are_added_to_people_terms(monkeypatch): + monkeypatch.setattr(feishu_distill, "load_config", lambda: { + "people_index": { + "identities": [{ + "id": "user", + "canonical": "Owner Card", + "aliases": ["Owner Alias"], + }], + "categories": {"Teammate": "team"}, + }, + "distill": {"people": ["Expert"]}, + }) + + terms, identities = feishu_distill.load_configured_people_terms() + + assert terms[:3] == ["Owner Alias", "Teammate", "Expert"] + assert identities["user"]["canonical"] == "Owner Card" diff --git a/tests/test_feishu_partial_status.py b/tests/test_feishu_partial_status.py index 7d36b87..d3f6c6d 100644 --- a/tests/test_feishu_partial_status.py +++ b/tests/test_feishu_partial_status.py @@ -21,6 +21,20 @@ def test_feishu_returns_partial_exit_code_when_any_requested_source_errors(self) self.assertEqual(feishu_collect.run_exit_code([{"source": "feishu-im", "message": "denied"}]), 2) self.assertEqual(feishu_collect.run_exit_code([]), 0) + def test_expected_access_boundaries_are_skipped_not_failed(self): + self.assertTrue(feishu_collect.is_expected_access_boundary( + "feishu-vc-note", "no notes available for this meeting" + )) + self.assertTrue(feishu_collect.is_expected_access_boundary( + "feishu-im", "Chat open Restricted Mode, don't allow copying or forwarding messages" + )) + self.assertTrue(feishu_collect.is_expected_access_boundary( + "feishu-minutes-note", "resource deleted" + )) + self.assertFalse(feishu_collect.is_expected_access_boundary( + "feishu-contact", "user lacks permission for the requested resource" + )) + class UpdateSourcesBackupTest(unittest.TestCase): def test_feishu_updates_last_backup_only_for_successful_requested_sources(self): diff --git a/tests/test_feishu_retry.py b/tests/test_feishu_retry.py new file mode 100644 index 0000000..1646aa3 --- /dev/null +++ b/tests/test_feishu_retry.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import json +import subprocess + +import feishu_collect + + +def test_run_lark_retries_transient_network_failure(monkeypatch): + calls = [] + + def run(*_args, **_kwargs): + calls.append(1) + if len(calls) == 1: + return subprocess.CompletedProcess( + args=[], returncode=1, + stdout=json.dumps({"ok": False, "error": {"type": "network", "subtype": "timeout"}}), + stderr="", + ) + return subprocess.CompletedProcess(args=[], returncode=0, stdout=json.dumps({"ok": True}), stderr="") + + monkeypatch.setattr(feishu_collect.subprocess, "run", run) + + ok, body, error = feishu_collect.run_lark(["auth", "status"]) + + assert ok is True + assert body == {"ok": True} + assert error == "" + assert len(calls) == 2 diff --git a/tests/test_people_identity_config.py b/tests/test_people_identity_config.py new file mode 100644 index 0000000..d985e0d --- /dev/null +++ b/tests/test_people_identity_config.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + + +def test_private_identity_config_drives_canonical_names(tmp_path): + vault = tmp_path / ".immortal" + vault.mkdir() + (vault / "config.json").write_text(json.dumps({ + "owner_display_name": "Owner Card", + "owner_aliases": ["Owner Alias"], + "people_index": { + "identities": [ + { + "id": "user", + "canonical": "Owner Card", + "aliases": ["Owner Alias"], + "category": "self", + }, + { + "id": "bibi", + "canonical": "Partner Card", + "aliases": ["Partner Alias"], + "category": "business", + "text_aliases": ["Partner Mention"], + }, + ], + "categories": {"Teammate": "team"}, + }, + }), encoding="utf-8") + script = """ +import json +import people_index +print(json.dumps({ + 'owner': people_index.canonical_name('Owner Alias'), + 'partner': people_index.canonical_name('Partner Alias'), + 'owner_category': people_index.CATEGORY_BY_NAME['Owner Card'], + 'partner_category': people_index.CATEGORY_BY_NAME['Partner Card'], + 'teammate_category': people_index.CATEGORY_BY_NAME['Teammate'], + 'text_people': people_index.row_people({'statement': 'Partner Mention joined', 'people': []}), +})) +""" + env = dict(os.environ) + env["HOME"] = str(tmp_path) + env["PYTHONPATH"] = str(Path(__file__).parents[1] / "core") + result = subprocess.run( + [sys.executable, "-c", script], + check=True, + capture_output=True, + text=True, + env=env, + ) + + payload = json.loads(result.stdout) + assert payload == { + "owner": "Owner Card", + "partner": "Partner Card", + "owner_category": "self", + "partner_category": "business", + "teammate_category": "team", + "text_people": ["Partner Card"], + } + + +def test_owner_config_works_without_private_identity_rules(tmp_path): + vault = tmp_path / ".immortal" + vault.mkdir() + (vault / "config.json").write_text(json.dumps({ + "owner_display_name": "Owner Card", + "owner_aliases": ["Owner Alias"], + }), encoding="utf-8") + script = """ +import json +import people_index +import quality_report +print(json.dumps({ + 'canonical': people_index.canonical_name('Owner Alias'), + 'category': people_index.CATEGORY_BY_NAME['Owner Card'], + 'quality_rule_ids': [rule['id'] for rule in quality_report.CONFIRMED_IDENTITY_RULES], +})) +""" + env = dict(os.environ) + env["HOME"] = str(tmp_path) + env["PYTHONPATH"] = str(Path(__file__).parents[1] / "core") + result = subprocess.run([sys.executable, "-c", script], check=True, capture_output=True, text=True, env=env) + + assert json.loads(result.stdout) == { + "canonical": "Owner Card", + "category": "self", + "quality_rule_ids": ["user"], + } diff --git a/tests/test_runtime_resilience.py b/tests/test_runtime_resilience.py index 19bf2bf..9e48e12 100644 --- a/tests/test_runtime_resilience.py +++ b/tests/test_runtime_resilience.py @@ -133,7 +133,9 @@ def test_context_timeout_writes_bounded_failure_artifact(self): ), ) latest_json = Path(tmp) / "latest-context.json" - with mock.patch.object(agent_bridge.subprocess, "run", side_effect=timeout), \ + with mock.patch.object( + agent_bridge, "_authoritative_runtime_available", return_value=False + ), mock.patch.object(agent_bridge.subprocess, "run", side_effect=timeout), \ mock.patch.object(agent_bridge, "LATEST_CONTEXT_JSON", latest_json): code = agent_bridge.command_context(args) diff --git a/tests/test_version.py b/tests/test_version.py index 90ea120..6290f75 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.3.0") + self.assertEqual(version, "1.3.1") 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.3.0") + self.assertEqual(output.getvalue().strip(), "immortal 1.3.1") setup_version = subprocess.run( [sys.executable, "setup.py", "--version"],