Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<div align="center">

![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)
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion core/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.3.0
1.3.1
4 changes: 4 additions & 0 deletions core/config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
],
"owner_display_name": "Your Name",
"owner_name": "",
"people_index": {
"categories": {},
"identities": []
},
"primary_account": "Main Account",
"role_defaults": {
"goal": "写稿审稿流程",
Expand Down
6 changes: 5 additions & 1 deletion core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@
"people": [], # 关注人物名单,空=不做人物统计
"topic_keywords": [], # 话题词,空=用 distill 内置通用 AI/工作词
},
"people_index": {
"identities": [], # 本机私有的 canonical/aliases/category 规则
"categories": {}, # 无需合并别名的人物分类
},
"automation": {
"daily_launch_agent_label": "",
"daily_schedule": [
Expand Down Expand Up @@ -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": []},
Expand Down
8 changes: 7 additions & 1 deletion core/control_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}]
],
{
Expand Down
128 changes: 94 additions & 34 deletions core/external_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import json
import os
import re
import shutil
import sqlite3
import subprocess
from datetime import datetime, timezone
Expand All @@ -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:
Expand All @@ -44,14 +60,21 @@ 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 {}
source["paths"] = [
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


Expand Down Expand Up @@ -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]]:
Expand All @@ -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]]:
Expand Down Expand Up @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions core/feedback_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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:
Expand Down
Loading
Loading