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 @@
-
+



@@ -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"],