Skip to content

Commit fcd5874

Browse files
committed
release: publish csep 1.3.1
1 parent 53d9db7 commit fcd5874

8 files changed

Lines changed: 427 additions & 19 deletions

File tree

docs/status-2026-05-16.md

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,68 @@
44

55
本文记录 `csep` 发布后的项目状态、真实本机运行状态,以及三条主线的下一步优化点。
66

7-
最新交接更新:2026-05-17 00:57 CST。下面 `1.2.0` / `1.1.0` 段落保留为历史状态;当前真实状态以本节为准。
7+
最新交接更新:2026-05-17 10:51 CST。下面 `1.3.0` / `1.2.0` / `1.1.0` 段落保留为历史状态;当前真实状态以最新发布节为准。
8+
9+
## 2026-05-17 1.3.1 发布状态
10+
11+
### 发布和远端
12+
13+
- PyPI 已发布:`csep 1.3.1`
14+
- PyPI 页面:https://pypi.org/project/csep/1.3.1/
15+
- `pip --no-cache-dir index versions csep` 已确认可见版本:`1.3.1, 1.3.0, 1.2.0, 1.1.0, 1.0.0`
16+
- `1.3.1` 包含 wheel 和 sdist:
17+
- `csep-1.3.1-py3-none-any.whl`
18+
- `csep-1.3.1.tar.gz`
19+
- 发布前验证:
20+
- `csep config validate` -> ok
21+
- `make test PYTHON=/Users/haha/hermes-agent/venv/bin/python3.11` -> `254 passed`
22+
- `uv run --extra dev python -m build` 成功
23+
- `uv run --extra dev twine check dist/csep-1.3.1*` passed
24+
- `uv run --extra dev twine upload dist/csep-1.3.1*` 成功
25+
- `git diff --check` clean
26+
- 构建仍会出现既有 warning:
27+
- `pyproject.toml` license table / license classifier 的 setuptools deprecation warning
28+
- `MANIFEST.in``tests/fixtures/*.json` 当前无匹配文件
29+
30+
### 1.3.1 主变更
31+
32+
- Stop hook archive 先执行 archive guard,只跳过明确的 reflection child thread。
33+
- `memory_consolidation` payload、child thread registry 命中、或 transcript 中的 `CSEP_REFLECTION_JOB_ID=` / `CSEP_REFLECTION_CHILD=1` 标记都会被判定为 child 并返回 `skipped`
34+
- `csep session-archive``skipped` 返回成功退出码,避免后台 child archive 被当成失败。
35+
- 非 child 且缺 `transcript_path` 的 Stop payload 会做高置信 transcript discovery:
36+
- filename 包含 `session_id`
37+
- 最近 transcript 的 `session_meta.id` 等于 `session_id`
38+
- 最近 transcript 中 `session_meta.cwd` 与 payload `cwd` 唯一匹配
39+
- 多候选会记录 `discovery_ambiguous`,不会乱归档,避免污染 recall DB。
40+
41+
### 下一步建议
42+
43+
- 继续观察真实 Stop hook 后续是否还出现新的 `missing transcript_path`;旧 DB 里的历史错误仍会保留。
44+
- 后续仍建议补:
45+
- 顶层 `csep --version`
46+
- `csep status` 显示 installed/source/PyPI 版本差异
847

948
## 2026-05-17 1.3.0 发布状态
1049

50+
### 发布后本地修复
51+
52+
- Stop hook archive 已补 child guard:明确的 reflection child thread 不再进入 session recall archive,也不再生成 `missing transcript_path` 噪音。
53+
- child 判定信号:
54+
- `thread_source` / `threadSource` / `source``memory_consolidation`
55+
- `session_id` / `thread_id` 命中 `session_reflection/child_threads/` registry
56+
- 已发现 transcript 中包含 `CSEP_REFLECTION_JOB_ID=``CSEP_REFLECTION_CHILD=1`
57+
-`transcript_path` 的非 child Stop payload 会尝试高置信 transcript discovery:
58+
- filename 包含 `session_id`
59+
- 最近 transcript 的 `session_meta.id` 等于 `session_id`
60+
- 最近 transcript 中 `session_meta.cwd` 与 payload `cwd` 唯一匹配
61+
- 多候选时记录 `discovery_ambiguous`,不归档,避免把 child 或错误 session 写进 recall DB。
62+
- 本地安装版已通过 `scripts/install.sh` 刷新,真实 hook 会使用当前修复。
63+
- 验证:
64+
- targeted pytest -> `38 passed`
65+
- `make test PYTHON=/Users/haha/hermes-agent/venv/bin/python3.11` -> `254 passed`
66+
- `git diff --check` clean
67+
- `csep session-archive --from-hook-payload <memory_consolidation payload>` -> `skipped`
68+
1169
### 发布和远端
1270

1371
- PyPI 已发布:`csep 1.3.0`
@@ -60,7 +118,6 @@
60118
- 后续仍建议补:
61119
- 顶层 `csep --version`
62120
- `csep status` 显示 installed/source/PyPI 版本差异
63-
- Stop hook `transcript_path` discovery fallback
64121

65122
## 2026-05-17 1.2.0 历史交接状态
66123

@@ -234,7 +291,7 @@ Treat `csep recall` like `rg` over past sessions: search short concrete needles,
234291

235292
- 本机已安装 tool 仍是 `1.0.0`,真实 hook 暂时还跑旧代码
236293
- `csep recall` 查不到时,还不会基于 DB / `~/.codex/sessions` 状态给自救提示
237-
- Stop hook 缺 `transcript_path` 时只记录错误,没有尝试按 session id / mtime 兜底定位 transcript
294+
- Stop hook 缺 `transcript_path` 的本地修复已完成:先跳过 reflection child,再对真实缺路径 Stop payload 做高置信 discovery
238295
- 搜索排序仍主要依赖 FTS5 + 简单 role 权重
239296

240297
本轮讨论后的方向:
@@ -255,7 +312,7 @@ Treat `csep recall` like `rg` over past sessions: search short concrete needles,
255312
建议:
256313

257314
- 增加 `csep --version`,并让 `csep status` 显示 installed/source/PyPI 版本差异
258-
- Stop hook 增加 transcript discovery fallback
315+
- 继续观察 Stop hook discovery 的真实命中率和 ambiguous 记录
259316
- 新增插件 skill:`skills/csep-session-recall/SKILL.md`
260317
- 插件 manifest 增加 `"skills": "./skills/"`,并保持 source bundle / marketplace bundle 一致
261318
- `csep recall` 支持 `|` OR alias、strict -> OR fallback、`--all-terms`
@@ -305,7 +362,6 @@ P0:
305362
- CSEP plugin 内置 `csep-session-recall` skill
306363
- `csep recall` grep-like query:`|` OR alias、strict -> OR fallback、`--all-terms`
307364
- recall evidence window 降噪:优先 user/assistant/tool,recent preview 优先第一条 user message
308-
- Stop hook `transcript_path` 兜底定位
309365
- reflection scope-aware trigger
310366

311367
P1:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "csep"
7-
version = "1.3.0"
7+
version = "1.3.1"
88
description = "Self-evolution loop for OpenAI Codex: session reflection, session recall, and reflection-generated skills with zero runtime dependencies."
99
readme = "README.md"
1010
requires-python = ">=3.11"
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__all__ = ["__version__"]
22

3-
__version__ = "1.3.0"
3+
__version__ = "1.3.1"

src/codex_self_evolution/csep.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ def _handle_session_archive(args: argparse.Namespace) -> int:
317317
db_path=db_path,
318318
)
319319
print(json.dumps(result, indent=2, sort_keys=True))
320-
return 0 if result.get("status") == "archived" else 1
320+
return 0 if result.get("status") in {"archived", "skipped"} else 1
321321

322322

323323
def _handle_session_ingest(args: argparse.Namespace) -> int:

src/codex_self_evolution/session_recall/archive.py

Lines changed: 170 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
from __future__ import annotations
22

33
import json
4+
import time
45
from datetime import UTC, datetime, timedelta
56
from pathlib import Path
6-
from typing import Any
7+
from typing import Any, Iterable
78

89
from ..config import get_home_dir
10+
from ..session_reflection.guard import evaluate_archive_guard
911
from .parser import parse_codex_jsonl
1012
from .store import SessionRecallStore
1113

14+
DISCOVERY_MTIME_WINDOW_SECONDS = 15 * 60
15+
DISCOVERY_MAX_RECENT_FILES = 80
16+
1217

1318
def default_db_path(home: str | Path | None = None, state_dir: str | Path | None = None) -> Path:
1419
if state_dir:
@@ -47,12 +52,15 @@ def archive_from_hook_payload(
4752
*,
4853
db_path: str | Path | None = None,
4954
cleanup_payload: bool = False,
55+
sessions_root: str | Path | None = None,
5056
) -> dict[str, Any]:
5157
path = Path(payload_path).expanduser().resolve()
58+
resolved_db_path = Path(db_path).expanduser().resolve() if db_path else default_db_path()
59+
home = _home_from_db_path(resolved_db_path)
5260
try:
5361
payload = json.loads(path.read_text(encoding="utf-8"))
5462
except Exception as exc: # noqa: BLE001
55-
store = SessionRecallStore(db_path or default_db_path())
63+
store = SessionRecallStore(resolved_db_path)
5664
try:
5765
store.record_error(source_path=str(path), error=f"{type(exc).__name__}: {exc}")
5866
finally:
@@ -65,7 +73,7 @@ def archive_from_hook_payload(
6573
except OSError:
6674
pass
6775
if not isinstance(payload, dict):
68-
store = SessionRecallStore(db_path or default_db_path())
76+
store = SessionRecallStore(resolved_db_path)
6977
try:
7078
store.record_error(source_path=str(path), error="payload is not an object")
7179
finally:
@@ -74,14 +82,116 @@ def archive_from_hook_payload(
7482
session_id = str(payload.get("session_id") or payload.get("thread_id") or "unknown-session")
7583
transcript_path = str(payload.get("transcript_path") or payload.get("codex_transcript_path") or "")
7684
cwd = str(payload.get("cwd") or ".")
85+
86+
guard = evaluate_archive_guard(payload, home=home)
87+
if guard.skip:
88+
return {
89+
"status": "skipped",
90+
"reason": guard.reason,
91+
"detail": guard.detail,
92+
"session_id": session_id,
93+
"message_count": 0,
94+
"db_path": str(resolved_db_path),
95+
}
96+
7797
if not transcript_path:
78-
store = SessionRecallStore(db_path or default_db_path())
98+
discovery = discover_transcript_for_hook_payload(payload, sessions_root=sessions_root)
99+
if discovery["status"] == "found":
100+
discovered_guard = evaluate_archive_guard({**payload, "transcript_path": discovery["path"]}, home=home)
101+
if discovered_guard.skip:
102+
return {
103+
"status": "skipped",
104+
"reason": discovered_guard.reason,
105+
"detail": discovered_guard.detail,
106+
"session_id": session_id,
107+
"message_count": 0,
108+
"db_path": str(resolved_db_path),
109+
"discovery_status": discovery["status"],
110+
"discovery_reason": discovery["reason"],
111+
"discovered_transcript_path": discovery["path"],
112+
}
113+
result = archive_transcript(
114+
discovery["path"],
115+
session_id=session_id,
116+
cwd=cwd,
117+
db_path=resolved_db_path,
118+
)
119+
result["discovery_status"] = discovery["status"]
120+
result["discovery_reason"] = discovery["reason"]
121+
result["discovered_transcript_path"] = discovery["path"]
122+
return result
123+
124+
store = SessionRecallStore(resolved_db_path)
125+
error = f"missing transcript_path; {discovery['status']}"
126+
if discovery.get("candidate_count"):
127+
error += f"; candidates={discovery['candidate_count']}"
128+
keys = ",".join(sorted(str(key) for key in payload.keys()))
129+
if keys:
130+
error += f"; payload_keys={keys}"
79131
try:
80-
store.record_error(session_id=session_id, cwd=cwd, error="missing transcript_path")
132+
store.record_error(session_id=session_id, cwd=cwd, error=error)
81133
finally:
82134
store.close()
83-
return {"status": "error", "session_id": session_id, "message_count": 0, "error": "missing transcript_path"}
84-
return archive_transcript(transcript_path, session_id=session_id, cwd=cwd, db_path=db_path)
135+
return {"status": "error", "session_id": session_id, "message_count": 0, "error": error}
136+
return archive_transcript(transcript_path, session_id=session_id, cwd=cwd, db_path=resolved_db_path)
137+
138+
139+
def discover_transcript_for_hook_payload(
140+
payload: dict[str, Any],
141+
*,
142+
sessions_root: str | Path | None = None,
143+
) -> dict[str, Any]:
144+
"""Find a high-confidence transcript for a Stop payload missing transcript_path."""
145+
root = Path(sessions_root).expanduser().resolve() if sessions_root else Path.home() / ".codex" / "sessions"
146+
if not root.exists():
147+
return {"status": "discovery_not_found", "reason": "sessions_root_missing", "path": "", "candidate_count": 0}
148+
149+
session_id = str(payload.get("session_id") or payload.get("thread_id") or "")
150+
if session_id:
151+
matches = _unique_existing_files(path for path in root.rglob("*.jsonl") if session_id in path.name)
152+
if len(matches) == 1:
153+
return _discovered(matches[0], "filename_session_id")
154+
if len(matches) > 1:
155+
return {
156+
"status": "discovery_ambiguous",
157+
"reason": "filename_session_id",
158+
"path": "",
159+
"candidate_count": len(matches),
160+
}
161+
162+
recent_files = _recent_transcripts(root)
163+
if session_id:
164+
meta_matches = [
165+
path for path in recent_files
166+
if _session_meta(path).get("id") == session_id
167+
]
168+
if len(meta_matches) == 1:
169+
return _discovered(meta_matches[0], "meta_session_id")
170+
if len(meta_matches) > 1:
171+
return {
172+
"status": "discovery_ambiguous",
173+
"reason": "meta_session_id",
174+
"path": "",
175+
"candidate_count": len(meta_matches),
176+
}
177+
178+
cwd = str(payload.get("cwd") or "")
179+
if cwd:
180+
cwd_matches = [
181+
path for path in recent_files
182+
if _same_path(str(_session_meta(path).get("cwd") or ""), cwd)
183+
]
184+
if len(cwd_matches) == 1:
185+
return _discovered(cwd_matches[0], "recent_cwd_unique")
186+
if len(cwd_matches) > 1:
187+
return {
188+
"status": "discovery_ambiguous",
189+
"reason": "recent_cwd_unique",
190+
"path": "",
191+
"candidate_count": len(cwd_matches),
192+
}
193+
194+
return {"status": "discovery_not_found", "reason": "no_high_confidence_match", "path": "", "candidate_count": 0}
85195

86196

87197
def backfill_sessions(
@@ -173,6 +283,59 @@ def _session_id_from_filename(path: Path) -> str:
173283
return stem
174284

175285

286+
def _home_from_db_path(db_path: Path) -> Path:
287+
"""Infer CSEP home from the standard session_recall/state.db path."""
288+
if db_path.name == "state.db" and db_path.parent.name == "session_recall":
289+
return db_path.parent.parent
290+
return get_home_dir()
291+
292+
293+
def _unique_existing_files(paths: Iterable[Path]) -> list[Path]:
294+
return sorted({path.expanduser().resolve() for path in paths if path.is_file()})
295+
296+
297+
def _recent_transcripts(root: Path) -> list[Path]:
298+
now = time.time()
299+
files = [
300+
path for path in root.rglob("*.jsonl")
301+
if path.is_file() and now - _file_mtime(path) <= DISCOVERY_MTIME_WINDOW_SECONDS
302+
]
303+
return sorted(files, key=lambda path: (_file_mtime(path), str(path)), reverse=True)[:DISCOVERY_MAX_RECENT_FILES]
304+
305+
306+
def _session_meta(path: Path) -> dict[str, Any]:
307+
try:
308+
with path.open("r", encoding="utf-8", errors="replace") as handle:
309+
for idx, raw in enumerate(handle):
310+
if idx >= 32:
311+
break
312+
try:
313+
entry = json.loads(raw)
314+
except ValueError:
315+
continue
316+
if isinstance(entry, dict) and entry.get("type") == "session_meta":
317+
payload = entry.get("payload")
318+
return payload if isinstance(payload, dict) else {}
319+
except OSError:
320+
return {}
321+
return {}
322+
323+
324+
def _same_path(left: str, right: str) -> bool:
325+
if not left or not right:
326+
return False
327+
return Path(left).expanduser().resolve(strict=False) == Path(right).expanduser().resolve(strict=False)
328+
329+
330+
def _discovered(path: Path, reason: str) -> dict[str, Any]:
331+
return {
332+
"status": "found",
333+
"reason": reason,
334+
"path": str(path),
335+
"candidate_count": 1,
336+
}
337+
338+
176339
def _file_mtime(path: Path) -> float:
177340
try:
178341
return path.stat().st_mtime

src/codex_self_evolution/session_reflection/guard.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,19 @@
1212

1313
def evaluate_recursion_guard(payload: dict[str, Any], *, home: str | Path | None = None) -> GuardDecision:
1414
"""Evaluate whether a Stop payload should start a reflection job."""
15+
archive_decision = evaluate_archive_guard(payload, home=home)
16+
if archive_decision.skip:
17+
return archive_decision
18+
19+
lock = global_lock_status(home=home)
20+
if lock["locked"] and not lock["stale"]:
21+
return GuardDecision(True, "global_lock", str(lock["path"]))
22+
23+
return GuardDecision(False)
24+
25+
26+
def evaluate_archive_guard(payload: dict[str, Any], *, home: str | Path | None = None) -> GuardDecision:
27+
"""Return whether a Stop payload is a reflection child that should not archive."""
1528
thread_source = _payload_text(payload, "threadSource", "thread_source", "source")
1629
if thread_source == "memory_consolidation":
1730
return GuardDecision(True, "thread_source_memory_consolidation", thread_source)
@@ -24,10 +37,6 @@ def evaluate_recursion_guard(payload: dict[str, Any], *, home: str | Path | None
2437
if transcript_path and _transcript_has_marker(Path(transcript_path)):
2538
return GuardDecision(True, "reflection_marker", transcript_path)
2639

27-
lock = global_lock_status(home=home)
28-
if lock["locked"] and not lock["stale"]:
29-
return GuardDecision(True, "global_lock", str(lock["path"]))
30-
3140
return GuardDecision(False)
3241

3342

0 commit comments

Comments
 (0)