ReMe/reme4/steps/transfer/ingest.py
jinliyl 83831ec90c
feat(core): enhance reme4 (#281)
### 1. Agent Wrapper(统一 Agent 后端抽象)
- **`base_agent_wrapper.py`**:`reply()` 返回值从 `tuple[str, Any]` 改为 `dict`(含 `session_id` / `last_message` / `result` / 可选 `structured_output`);`reply_stream()` 改为产出统一的 `StreamChunk`。废弃 `add_tools()`,改为 `add_job_tools(names: list[str])`(按名解析 BaseJob)与 `add_skills()`;新增 `_resolve_job_tools()`、`_merged_kwargs()`、`_chunk()` 辅助方法及 `project_path` / `project_skills_root` 属性。
- **`as_agent_wrapper.py`(AgentScope 后端)**:
  - 会话持久化重写:`session_path` 落地到 `<vault>/<session_dir>/agentscope/`,`_load_state` 支持 `resume` / `session_id` / `fork_session`,并做 UUID 校验(`_validate_session_id`);`_cleanup_expired_sessions` 按天数清理过期会话。
  - 新增内置工具集(`BypassAnalysisBash` + Edit/Glob/Grep/Read/Write),`BypassAnalysisBash` 绕过 AgentScope 自带 Bash 静态分析以让 permission_mode 生效;`_resolve_skills()` 把配置的 skill 暴露给后端,`_load_tool_env()` 注入项目 `.env`。
  - `_event_to_chunk()` 把 20+ 种 AgentScope 事件(Reply/Text/Thinking/Data/ToolCall/ToolResult/ModelCall/ExceedMaxIters)归一化为 `StreamChunk`。
- **`cc_agent_wrapper.py`(Claude Code SDK 后端,+551 行)**:
  - 新增 `_CcFileSessionStore`:基于 vault 的文件型会话存储,实现 append(按 uuid 去重)/ load / list / delete / list_subkeys,并对路径做 `_safe_parts` + `resolve()` 防越界校验。
  - `_build_options()`:统一构建 `ClaudeAgentOptions`,处理 skills、disallowed_tools(默认禁 `WebSearch`)、`.env` 注入、Claude Code 的 API 凭据解析(`_claude_code_api_env`,多级 base_url/api_key 回退)、`CLAUDE_CONFIG_DIR` 设置、skill 目录软链接(`_ensure_claude_skill_dir`)。
  - `_raw_event_to_chunk()` / `_message_content_to_chunks()`:把 Anthropic 流式事件(message_start/delta/stop、content_block_*)与 SDK 消息块(AssistantMessage/UserMessage/ResultMessage/RateLimitEvent)转换为统一 `StreamChunk`;跟踪 block_id/block_type/tool_call_name 做关联;处理尾部 `"success"` 误报异常的吞掉逻辑。

### 2. 统一流式协议(StreamChunk / ChunkEnum)
- **`stream_chunk.py`**:`StreamChunk` 扩展为承载 AS + CC 双后端完整信息的统一结构,新增 `session_id` / `block_id` / `tool_call_id` / `tool_call_name` / `media_type` / `input_tokens` / `output_tokens` 等字段,纯文本流仍保持轻量。
- **`chunk_enum.py`**:补全生命周期标记 `REPLY_START` / `REPLY_END`,并文档化两套后端事件 → ChunkEnum 的映射。

### 3. Index 模块重构(变化批次化 + dispatch)
- 新增 `_change_batch.py`:`coalesce_changes()` 把同路径多次事件折叠为最终状态(结合 path 存在性判定),`bucket_changes()` 按 watchfiles.Change 分桶。
- 新增 `init_changes.py`(`InitChangesStep`):一次性扫描,对比 file_store / file_catalog 已索引节点计算 added/modified/deleted,写入 `context["changes"]` 后 dispatch。
- 新增 `update_changes.py`:抽象基类 `ChangeApplyStep` 统一 added/modified/deleted 处理与错误收集;`UpdateCatalogStep`(写 file_catalog)、`UpdateIndexStep`(写 file_store,含按后缀解析 chunker)。
- **`watch_changes.py`**:改用 `dispatch_step_specs`(基类提供的 `dispatch_steps()`),每批先 `coalesce_changes` 再 dispatch;默认参数调整(debounce 5000ms / step 1000ms / poll 5000ms)并暴露常量。
- 删除旧步骤:`clear_and_scan` / `foreach_dispatch` / `scan_changes` / `update_catalog`(旧) / `update_index`(旧);`clear_store.py` 取代 clear_and_scan。

### 4. Evolve / Dream 模块(拆分为多步 pipeline)
- 删除旧的单体 `auto_dream.py` / `dream.py` / `dream.yaml`,新增 `dream/` 子包,按 5 个步骤组织:
  - **`extract.py`**:扫描当日 day-index + daily 笔记,对比 file_catalog 找出 changed/deleted,调用 LLM 全局抽取 `units`(procedure/personal/wiki 三桶)与 `topics`,路径与桶做清洗/路由。
  - **`integrate.py`**:逐个 unit 调用 LLM 写入 digest,结构化输出 `IntegrateOutcome`(CREATE/CORROBORATE/REFINE/CORRECT),失败 unit/路径收集回写。
  - **`topics.py`**:写 `daily/<date>/interests.yaml`,结合当天已有 + 近 N 天做去重(`normalize_topic`),可走 LLM 或纯规则去重两条路径。
  - **`proactive.py`**:读取当日 `interests.yaml`,作为主动推荐话题的入口。
  - **`finish.py`**:把变更路径落盘到 dream file_catalog(checkpoint),渲染最终汇总摘要。
- 新增 `schema.py`(`DreamState` 等跨步骤共享状态与结构化输出模型)与 `utils.py`(状态存取、扫描打包、YAML 读写、结构化回复解析等公共函数)。
- `evolve/__init__.py` 导出全部新 step。

### 5. auto_memory / auto_resource(适配新 Agent API)
- **`auto_memory.py`**:会话路径迁移到 `<session_dir>/dialog/<session_id>.jsonl`;改用 `job_tools`;新增 `source_conversation` frontmatter 反向链接(`_session_link`);执行后刷新 day 索引(`refresh_day_index`),并对 session_id 做合法性校验。
- **`auto_resource.py`**:资源改用「同名 daily note」方案(`_compute_note_stem` 取文件 stem);批量处理 `changes: list[dict]`(`_handle_change` 逐项处理,返回逐项结果摘要);agent 会话 id 用稳定的 `uuid5`;同样刷新 day 索引。

### 6. BaseStep 基类增强
- 新增 `dispatch_steps` / `dispatch_step_specs` 机制:`_resolve_dispatch_step()` 支持字符串或 dict 形式的 step spec,`dispatch_steps()` 复用当前 context 调用下游 step。
- 新增 `config_value()`:按 key 取 app config,缺失时回退 `ApplicationConfig` 默认值。
- 小幅清理:`language` 初始化、`copy()`、`Ref.__init__` 签名精简。

### 7. Components 改动
- **`file_store/local_file_store.py`**:持久化改用 zstd 压缩(`.jsonl.zst`,通过新 `utils/jsonl_zst.py`);upsert 时先删除旧 chunk 的 keyword 文档;embedding 复用改为 `(text, embedding)` 键控,要求文本一致才复用;新增 `_matches_search_filter()` 对 vector/keyword 搜索做 path/path_prefix/metadata 的统一后过滤。
- **`keyword_index/bm25_index.py`**:索引文件名加入组件名 + tokenizer 指纹(sha256 前 12 位),快照/恢复时校验指纹防配置漂移;空索引 dump 时删除文件,加载失败抛错而非静默。
- **`file_chunker/markdown_file_chunker.py`**:弃用 `python-frontmatter`,改用内置 YAML 解析(非法 YAML 不阻断正文索引),并修正因 frontmatter 占用行号导致的 AST 行号偏移(`line_offset`)。
- **`cron_job.py`**:大幅简化(-187 行),由原来「dispatch 外部 job/step + 多种调度模式」改为「在自身 steps 上跑 cron 表达式」;`Application` 启动顺序随之调整为 base > stream > background > cron。
- 其余小调整:service(base/http/mcp)、file_graph、file_catalog、as_llm、as_embedding、tokenizer、prompt_handler、base_component 的签名/接口微调。

### 8. Application 生命周期
- `_start()` 启动顺序明确为 components → base → stream → background → cron,启动失败会触发 `_close()` 回滚并 re-raise(不再吞异常)。
- 启动时创建 `session_dir` 目录;新增 `update_component()`(按类型/名就地更新已存在组件,不存在则报错)。

### 9. File IO / 路径安全
- **`_path.py`**:`resolve_path` 增加 vault 越界防护(`is_relative_to` 校验),禁止 `.` / `..` 路径分量,支持 `allow_empty`。
- **`read.py`**:大文件(超过 `MAX_FILE_READ_BYTES`)走按行读取 `read_file_lines_safe`,避免一次性载入内存。
- **`_file_io.py` / `_daily_index.py` / `_path.py`** 等支持函数补齐(如 `refresh_day_index`、`read_file_lines_safe`)。
- **`env_utils.py`**:新增 `parse_env_file()`,`load_env()` 返回加载到的键值、支持 `override`、对无路径调用做幂等缓存。

### 10. Config
- `ApplicationConfig` 新增 `session_dir`(默认 `reme_session`)。
- `config_parser.py`:环境变量展开后做类型转换(`_convert_value`)、dot-notation 与 key=value 参数校验更严格、配置文件路径支持相对 `_CONFIG_DIR` 查找、根非 dict 报错。
- `default.yaml`:作业编排改用 `init_changes_step` + `dispatch_steps`(index/resource/digest 三个 watch loop 与 reindex);新增 `auto_dream`(4 步)、`proactive` 作业,移除旧 `dream`;file_catalog 增配 `resource` / `digest` / `dream` 实例;LLM 默认值与 Claude Code 凭据配置调整(tool_result_limit 50000、thinking_enable=false 等)。

### 11. 其它
- 新增 `steps/common/add.py`(`AddStep` 算术 demo)、`channel/__init__.py` 与 common `__init__` 导出整理。
- 新增 4 篇文档:`docs4/auto_dream_logic_and_step_refactor.md`、`docs4/watch_loop_step_refactor_plan.md`、`docs4/todo.md`,以及 `reme_design.md` 更新。
**
2026-06-19 01:35:31 +08:00

452 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""``ingest`` — capture an externally-received asset into ``resource/<date>/``.
This step is the dedicated information-capture interface: when an
external channel (wechat group, email, browser save, API push, ...)
hands the agent a file, ``ingest`` lands it in ``resource/<YYYY-MM-DD>/``
keyed by the day it was received (always today, local time),
alongside a ``meta.json`` row recording provenance.
This is the only ingest path through ``resource/``. Materials the
main agent *actively* fetches or generates during a daily task
belong inside the daily folder as sibling materials, not here.
For generic file copy (local fs → arbitrary vault path) use
``upload`` instead.
Bucket layout::
resource/<YYYY-MM-DD>/
meta.json # JSON array of FileNode rows
<date>.md # derived markdown view
<channel>__<HHMMSS>__<source-basename> # the asset itself
...
Naming convention — the file name is **derived**, never caller-supplied::
<channel>__<HHMMSS>__<source-basename>
* ``<channel>`` — top-level identity (anchors provenance), lowercase
letters/digits/dashes only.
* ``<HHMMSS>`` — receive time within the bucket day (the date is
already implicit in the bucket folder).
* ``<source-basename>`` — the basename of the ``path`` argument,
after rejecting path separators, dot segments, and leading dots.
This format is self-describing in directory listings + wikilinks
(``[[resource/<date>/wechat__153022__report.pdf]]``) and makes
cross-channel basename collisions structurally impossible. Two
genuine duplicates (same channel + same second + same basename)
are reported as an error — the step never silently dedupes, so
callers see the conflict and can decide whether to retry, rename
upstream, or skip.
Each ``ingest`` call:
1. Resolves the bucket date as today (local time).
2. Validates the inputs: ``path`` exists, ``channel`` matches the
allowed character class, ``description`` is non-empty, the
source basename has no path separators / dot segments / leading
dot.
3. Builds the final name from the format above.
4. Under a per-day file lock, checks the final name against
``meta.json`` the on-disk listing. Any collision → error
(no silent suffixing).
5. Copies the asset into the bucket.
6. Appends a :class:`FileNode` row to ``meta.json`` (with provenance
on ``front_matter``) and re-renders ``<date>.md``.
Parameters:
* ``path`` (required) — local filesystem path to the asset to ingest.
* ``channel`` (required) — inbound channel identifier (wechat /
email / browser / api / ...). Lowercase letters / digits / dashes
only.
* ``description`` (required) — analysis hint for downstream agents:
where the asset came from, what kind of content it carries, and how
it should be interpreted. The dreamer / auto_memory reads this
verbatim from ``meta.json`` to decide how to read the asset (skim
vs. deep parse, structured extraction vs. summarization, etc.), so
callers should write enough detail to drive that decision — not
just a title. Multi-line is fine; the ``<date>.md`` bullet view
flattens for display while ``meta.json`` preserves the original.
* ``metadata`` (optional dict) — extras persisted on the meta row.
``source`` (free-form origin within the channel) is conventional;
any other keys pass through verbatim. Keys ``name``, ``channel``,
``received_at``, ``description`` are reserved.
Returns ``{date, name, path}`` on success or ``{error}`` on failure.
"""
import datetime
import fcntl
import json
import os
import re
import shutil
import tempfile
from pathlib import Path
from ..base_step import BaseStep
from ...components import R
from ...schema import FileFrontMatter, FileNode
# Channel identifier character class — keeps the derived filename predictable
# and parseable (the `__` separator is also disjoint from this set).
_CHANNEL_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
# Keys the step manages itself — callers cannot smuggle them in via metadata.
_RESERVED_METADATA_KEYS = frozenset({"name", "channel", "received_at", "description"})
@R.register("ingest_step")
class IngestStep(BaseStep):
"""Capture an external asset into ``resource/<date>/`` and update the day's meta + index."""
async def execute(self):
assert self.context is not None
path: str = (self.context.get("path", "") or "").strip()
channel: str = (self.context.get("channel", "") or "").strip()
description: str = (self.context.get("description", "") or "").strip()
metadata_raw = self.context.get("metadata") or {}
prepared, prep_error = _prepare_inputs(path, channel, description, metadata_raw)
if prep_error:
self._fail({"error": prep_error})
self.logger.info(f"[{self.name}] ingest failed channel={channel!r} error={prep_error!r}")
return
try:
outcome = self._land(
src=prepared["src"],
date=prepared["date"],
final_name=prepared["final_name"],
entry_fields={
**prepared["metadata"],
"channel": channel,
"received_at": prepared["received_at"],
"description": description,
},
)
except _DuplicateIngest as e:
self._fail({"error": str(e)})
self.logger.info(f"[{self.name}] ingest duplicate channel={channel!r} error={str(e)!r}")
return
except Exception as e:
self._fail({"error": f"{type(e).__name__}: {e}"})
self.logger.info(f"[{self.name}] ingest crashed channel={channel!r} error={type(e).__name__}: {e}")
return
self.context.response.success = True
self.context.response.answer = f"Ingested {outcome['name']} to {outcome['path']}"
self.context.response.metadata.update(outcome)
self.logger.info(
f"[{self.name}] channel={channel} date={outcome['date']} name={outcome['name']} path={outcome['path']}",
)
# ------------------------------------------------------------------
def _fail(self, payload: dict) -> None:
assert self.context is not None
self.context.response.success = False
self.context.response.answer = f"Error: {payload.get('error', 'ingest failed')}"
self.context.response.metadata.update(payload)
def _resource_dir_name(self) -> str:
"""Configured ``resource_dir`` subdir name."""
return self.config_value("resource_dir")
def _vault_dir(self) -> Path:
vr = getattr(self.file_store, "vault_path", None)
return Path(vr).resolve() if vr else Path.cwd().resolve()
def _land(self, src: Path, date: str, final_name: str, entry_fields: dict) -> dict:
resource_dir = self._resource_dir_name()
bucket = self._vault_dir() / resource_dir / date
bucket.mkdir(parents=True, exist_ok=True)
meta_path = bucket / "meta.json"
day_md = bucket / f"{date}.md"
lock_path = bucket / ".lock"
rel_path = f"{resource_dir}/{date}/{final_name}"
with _bucket_lock(lock_path):
existing_entries = _read_meta(meta_path)
# Collision check spans meta on-disk listing so a stray file
# (from a crashed earlier run) and case-insensitive filesystems
# both surface the conflict rather than getting silently clobbered.
on_disk = {p.name for p in bucket.iterdir() if p.is_file()}
existing_names = {Path(e.path).name for e in existing_entries} | on_disk
existing_names_folded = {name.casefold() for name in existing_names}
if final_name.casefold() in existing_names_folded:
raise _DuplicateIngest(
f"duplicate: {final_name!r} already exists in {resource_dir}/{date}/",
)
dst_path = bucket / final_name
shutil.copyfile(src, dst_path)
# FileFrontMatter has first-class `name` / `description`; everything
# else (channel / source / received_at / passthrough metadata) rides
# the extras bag (model_config extra="allow").
description = entry_fields.pop("description", "")
entry = FileNode(
path=rel_path,
st_mtime=dst_path.stat().st_mtime,
front_matter=FileFrontMatter(description=description, **entry_fields),
)
updated = existing_entries + [entry]
_atomic_write_text(
meta_path,
json.dumps([e.model_dump() for e in updated], ensure_ascii=False, indent=2) + "\n",
)
_atomic_write_text(day_md, _assemble_day_md(updated, date))
return {
"date": date,
"name": final_name,
"path": rel_path,
}
class _DuplicateIngest(Exception):
"""Raised when the derived name already exists in the bucket."""
# ----------------------------------------------------------------------
# Input validation
# ----------------------------------------------------------------------
def _prepare_inputs(
path: str,
channel: str,
description: str,
metadata_raw,
) -> tuple[dict, str]:
"""Validate caller args and derive the bucket date / final name.
Returns ``(prepared, error)``: on success ``prepared`` has
``{src, date, received_at, final_name, metadata}`` and ``error`` is
empty; on failure ``prepared`` is ``{}`` and ``error`` carries the
first violation in user-facing order (path → channel → description
→ metadata-shape → file existence → basename → metadata-keys).
"""
src = Path(path) if path else None
metadata, meta_error = _sanitize_metadata(metadata_raw) if isinstance(metadata_raw, dict) else ({}, "")
error = next(
(
msg
for msg in (
"path is required" if not path else "",
_validate_channel(channel),
"description is required" if not description else "",
"metadata must be a dict" if not isinstance(metadata_raw, dict) else "",
f"path not found: {path}" if src is not None and not src.is_file() else "",
_validate_basename(src.name) if src is not None else "",
meta_error,
)
if msg
),
"",
)
if error:
return {}, error
assert src is not None # narrowed by the "path is required" check
now = datetime.datetime.now()
return (
{
"src": src,
"date": now.strftime("%Y-%m-%d"),
"received_at": now.isoformat(timespec="seconds"),
"final_name": f"{channel}__{now.strftime('%H%M%S')}__{src.name}",
"metadata": metadata,
},
"",
)
def _validate_channel(channel: str) -> str:
"""Return an error string when ``channel`` is unsafe; empty when OK."""
if not channel:
return "channel is required"
if not _CHANNEL_RE.match(channel):
return f"channel {channel!r} must be lowercase letters / digits / dashes " f"and start with a letter or digit"
return ""
def _validate_basename(name: str) -> str:
"""Return an error string when the source basename is unsafe; empty when OK.
The derived filename embeds this string after a ``__`` separator, so we
only need to block characters that would mangle the filesystem path —
path separators, dot segments, leading dots. Bookkeeping-name collisions
(``meta.json`` / ``<date>.md``) are impossible by construction once the
channel + time prefix is prepended.
"""
if not name:
return "source basename is empty"
if "/" in name or "\\" in name:
return f"source basename must not contain path separators: {name!r}"
if name in {".", ".."}:
return f"source basename {name!r} is reserved"
if name.startswith("."):
return f"source basename may not start with '.': {name!r}"
return ""
def _sanitize_metadata(raw: dict) -> tuple[dict, str]:
"""Return ``(cleaned_metadata, error)``.
Rejects reserved keys (those the step manages itself) and coerces
``source`` to a string. All other keys pass through verbatim so
callers can attach arbitrary tags that land on the entry's
``front_matter`` extras.
"""
for key in _RESERVED_METADATA_KEYS:
if key in raw:
return {}, f"metadata key {key!r} is reserved"
cleaned = dict(raw)
if "source" in cleaned:
src = cleaned["source"]
cleaned["source"] = src.strip() if isinstance(src, str) else ""
return cleaned, ""
# ----------------------------------------------------------------------
# Per-day exclusive lock + atomic write helpers
# ----------------------------------------------------------------------
class _bucket_lock:
"""Exclusive ``flock`` on a per-day lock file; serializes meta+md writes."""
def __init__(self, lock_path: Path):
self.lock_path = lock_path
self._fp = None
def __enter__(self):
self._fp = open(self.lock_path, "w", encoding="utf-8")
fcntl.flock(self._fp.fileno(), fcntl.LOCK_EX)
return self
def __exit__(self, *exc):
if self._fp is not None:
try:
fcntl.flock(self._fp.fileno(), fcntl.LOCK_UN)
finally:
self._fp.close()
self._fp = None
def _read_meta(meta_path: Path) -> list[FileNode]:
"""Read ``meta.json`` as a list of :class:`FileNode` rows; missing or malformed → []."""
if not meta_path.is_file():
return []
try:
raw = json.loads(meta_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
if not isinstance(raw, list):
return []
out: list[FileNode] = []
for row in raw:
if not isinstance(row, dict):
continue
try:
out.append(FileNode(**row))
except Exception:
continue
return out
def _atomic_write_text(target: Path, text: str) -> None:
"""Atomic text write via tempfile + os.replace in the same directory."""
target.parent.mkdir(parents=True, exist_ok=True)
tmp_fd, tmp_path = tempfile.mkstemp(prefix=".ingest-", dir=target.parent)
try:
with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
f.write(text)
os.replace(tmp_path, target)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
# ----------------------------------------------------------------------
# Day-view markdown rendering (pure)
# ----------------------------------------------------------------------
def _assemble_day_md(entries: list[FileNode], date: str) -> str:
"""Render the day's bucket as a markdown view.
Layout::
---
name: <date>
assets: [<name1>, <name2>, ...]
---
# <date> resources
- [[<path>]] — <channel> from `<source>` at <hh:mm> — <description>
Provenance lives on each entry's ``front_matter`` (``channel`` /
``source`` / ``received_at`` as extras, ``description`` as the
first-class field). ``received_at`` is rendered as ``HH:MM`` when it
parses as ISO 8601, else dropped silently — the asset list stays
readable even when upstream channels emit malformed timestamps.
``source`` is dropped when empty. ``description`` is flattened
(newlines collapsed to spaces) so the bullet stays one line per
asset; ``meta.json`` preserves the verbatim multi-line text.
"""
names = [Path(e.path).name for e in entries]
lines: list[str] = [
"---",
f"name: {date}",
f"assets: {json.dumps(names, ensure_ascii=False)}",
"---",
"",
f"# {date} resources",
"",
]
for entry in entries:
fm = entry.front_matter
channel = getattr(fm, "channel", "") or ""
source = getattr(fm, "source", "") or ""
received_at = getattr(fm, "received_at", "") or ""
description = fm.description or ""
bits: list[str] = [f"- [[{entry.path}]]"]
provenance = channel
if source:
provenance += f" from `{source}`"
time_part = _hhmm(received_at)
if time_part:
provenance += f" at {time_part}"
bits.append(provenance)
if description:
# Flatten so the bullet stays one line per asset; meta.json keeps
# the verbatim multi-line description for downstream consumers.
bits.append(" ".join(description.split()))
lines.append("".join(bits))
return "\n".join(lines) + "\n"
def _hhmm(received_at: str) -> str:
"""Best-effort HH:MM extraction from an ISO 8601 timestamp."""
if not received_at:
return ""
raw = received_at.replace("Z", "+00:00")
try:
return datetime.datetime.fromisoformat(raw).strftime("%H:%M")
except ValueError:
return ""